黑马Java基础笔记-13常用查找算法
查找算法
基本查找(也叫顺序查找,线性查找)
二分查找(需要有序数据)
public static int binarySearch(int[] arr, int number){//1.定义两个变量记录要查找的范围int min = 0;int max = arr.length - 1;//2.利用循环不断的去找要查找的数据while(true){if(min > max){return -1;}//3.找到min和max的中间位置int mid = (min + max) / 2;//4.拿着mid指向的元素跟要查找的元素进行比较if(arr[mid] > number){//4.1 number在mid的左边//min不变,max = mid - 1;max = mid - 1;}else if(arr[mid] < number){//4.2 number在mid的右边//max不变,min = mid + 1;min = mid + 1;}else{//4.3 number跟mid指向的元素一样//找到了return mid;}}}
插值查找(二分查找改进1)
为什么二分查找算法一定要是折半,而不是折四分之一或者折更多呢?
其实就是因为方便,简单,但是如果我能在二分查找的基础上,让中间的mid点,尽可能靠近想要查找的元素,那不就能提高查找的效率了吗?
mid = min + key − arr [ min ] ‾ arr [ max ] − arr [ min ] ⋅ ( max − min ) \text{mid} = \min + \frac{\underline{\text{key} - \text{arr}[\min]}}{\text{arr}[\max] - \text{arr}[\min]} \cdot (\max - \min) mid=min+arr[max]−arr[min]key−arr[min]⋅(max−min)
中间这个为key的大小在查找范围中的比例(因为是有序的列表)
max-min
是获取比例对应的索引
min+
为设置基数注意:需要有序数据分布较为均匀
斐波那契查找(二分查找改进2)
和二分查找类似,只是使用的不是二分,而是黄金分割比
步骤:
1.先生成一个斐波那契数列 f[]
2.找到查找数组长度,找到最小的f[n]
使得查找数组的长度 <= f[n]-1
(f[n-1]-1
+ f[n-2]-1
+ 1
= f[n]-1
) ,长度没到需要将最大值补齐
3.经典初始化三件套int low = 0;
int high = arr.length - 1;
int mid = 0;
还要加上 int index = n;
用来表示这个兔子数列的下标
4.如果比mid小就将index-=1,如果比mid大就-=2,同时调整上下限
5.最后找到输出,如果是补齐的元素输出最大元素的原下标
困惑点:
为什么采用f[]-1的形式呢?
**我的理解:**是为了将mid提取为一个部分,刚好可以将原数组分割成f[n-1]-1
、mid
、f[n-2]-1
,三部分,且前后两个部分又是 f[]-1
的格式,便于代码的书写,也方便实现递归与循环。
参考代码:
public class FeiBoSearchDemo {public static int maxSize = 20;public static void main(String[] args) {int[] arr = {1, 8, 10, 89, 1000, 1234};System.out.println(search(arr, 1234));}public static int[] getFeiBo() {int[] arr = new int[maxSize];arr[0] = 1;arr[1] = 1;for (int i = 2; i < maxSize; i++) {arr[i] = arr[i - 1] + arr[i - 2];}return arr;}public static int search(int[] arr, int key) {int low = 0;int high = arr.length - 1;//表示斐波那契数分割数的下标值int index = 0;int mid = 0;//调用斐波那契数列int[] f = getFeiBo();//获取斐波那契分割数值的下标while (high > (f[index] - 1)) {index++;}//因为f[k]值可能大于a的长度,因此需要使用Arrays工具类,构造一个新法数组,并指向temp[],不足的部分会使用0补齐int[] temp = Arrays.copyOf(arr, f[index]);//实际需要使用arr数组的最后一个数来填充不足的部分for (int i = high + 1; i < temp.length; i++) {temp[i] = arr[high];}//使用while循环处理,找到key值while (low <= high) {mid = low + f[index - 1] - 1;if (key < temp[mid]) {//向数组的前面部分进行查找high = mid - 1;/*对k--进行理解1.全部元素=前面的元素+后面的元素2.f[k]=k[k-1]+f[k-2]因为前面有k-1个元素没所以可以继续分为f[k-1]=f[k-2]+f[k-3]即在f[k-1]的前面继续查找k--即下次循环,mid=f[k-1-1]-1*/index--;} else if (key > temp[mid]) {//向数组的后面的部分进行查找low = mid + 1;index -= 2;} else {//找到了//需要确定返回的是哪个下标if (mid <= high) {return mid;} else {return high;}}}return -1;}
}
查找算法对比分析:斐波那契查找、二分查找与插值查找
在有序数组中,常见的查找算法包括二分查找、斐波那契查找和插值查找。它们都属于分治类算法,但在分割策略、依赖条件和适用场景上存在差异。以下通过算法原理、时间复杂度、数据分布依赖性及优缺点等方面进行详细对比,并重点说明斐波那契查找优于二分查找的场景。
算法原理
- 二分查找:在已排序的数组中,每次将待查范围对半分,检查中间位置的元素与目标比较,根据大小关系决定向左或向右继续查找。该算法实现简单,每次都能排除一半区间,时间复杂度为 O ( log n ) O(\log n) O(logn)。
- 斐波那契查找:利用斐波那契数列来分割搜索区间,首先找到不小于数组长度的最小斐波那契数 F m F_m Fm,然后使用 F m − 2 F_{m-2} Fm−2 作为索引偏移量进行比较。相比二分查找固定的“平分”策略,斐波那契查找将区间划分为相邻两个斐波那契数之和的比例(约为黄金比例1:1.618)。查找时根据比较结果调整索引和剩余区间长度,通过加法、减法更新斐波那契数并继续搜索。斐波那契查找的平均和最坏时间复杂度均为 O ( log n ) O(\log n) O(logn),但平均需要执行约4%的额外比较(即比较次数略多)。
- 插值查找:适用于已排序且键值大致均匀分布的数值数组。它根据待查值相对于当前区间端点的比例,用线性插值公式估算目标可能的位置 p o s = l o + ( k e y − a r r [ l o ] ) × ( h i g h − l o ) a r r [ h i ] − a r r [ l o ] pos=lo+\frac{(key-arr[lo])\times(high-lo)}{arr[hi]-arr[lo]} pos=lo+arr[hi]−arr[lo](key−arr[lo])×(high−lo)。不同于二分查找总是检查中点,插值查找会根据关键字值直接跳向估计位置。如果分布均匀,插值查找平均定位更接近目标,从而减少比较次数;若估算位置不准确,则退化为缩小区间的二分或线性查找方式。插值查找的平均时间复杂度可达到 O ( log log n ) O(\log\log n) O(loglogn)(对数的对数),而最坏情况下可退化至 O ( n ) O(n) O(n)。
时间复杂度与效率比较
三种算法的理论时间复杂度如下表所示:二分查找和斐波那契查找在平均及最坏情况下均为 O ( log n ) O(\log n) O(logn),而插值查找在理想(均匀分布)情况平均为 O ( log log n ) O(\log\log n) O(loglogn),但最坏可达 O ( n ) O(n) O(n)。
算法 | 平均时间复杂度 | 最坏时间复杂度 |
---|---|---|
二分查找 | O ( log n ) O(\log n) O(logn) | O ( log n ) O(\log n) O(logn) |
斐波那契查找 | O ( log n ) O(\log n) O(logn) | O ( log n ) O(\log n) O(logn) |
插值查找 | O ( log log n ) O(\log\log n) O(loglogn) | O ( n ) O(n) O(n) |
从比较次数上看,二分查找每步半分区间,斐波那契查找划分比例略偏向一端,导致斐波那契查找平均比较次数略多。插值查找在数据非常均匀时平均比较次数最少,但需要做乘除运算来估算位置,实际效率取决于计算开销与数据分布。在现代硬件上,二分查找计算中间下标可用位运算完成,与斐波那契查找使用加减法的硬件成本差距很小。
数据分布依赖性
- 二分查找:只要求数据有序,不依赖数值分布。无论数据是均匀还是极端分布,二分查找的行为都是固定的,每次访问中点。
- 斐波那契查找:同样只要求有序,与数据分布无关。其查找过程由斐波那契数列决定,只关注索引位置,不考虑数值大小分布。
- 插值查找:高度依赖数据分布。插值查找假设关键字按数值线性分布,才能准确估算目标位置。当数据均匀时插值查找效率远优于二分;若分布不均匀(如指数增长或大量重复键),插值估算可能误差大,导致多次不准确探测,从而退化为 O ( n ) O(n) O(n)。如表所示,插值查找对“均匀分布”这一前提要求很高,否则不推荐使用。
优缺点对比
下表总结了三种算法的主要优缺点对比:
算法 | 主要优点 | 主要缺点 |
---|---|---|
二分查找 | 简单易实现;查找过程稳定,时间复杂度始终为 O ( log n ) O(\log n) O(logn) | 只适用于有序数据;每次固定平分,未利用数据分布信息;对顺序存取介质(如磁带)不友好 |
斐波那契查找 | 时间复杂度也是 O ( log n ) O(\log n) O(logn);无需除法,仅用加减运算,可降低硬件成本;访问位置更灵活(近似斐波比分割),对大数据集的内存局部性较好 | 实现较复杂,需要生成斐波那契数列;平均比较次数略多于二分(约多4%);对数据分布同样无依赖,无法利用均匀分布优势 |
插值查找 | 均匀分布时效率极高,平均比较次数仅为 O ( log log n ) O(\log\log n) O(loglogn);可快速接近目标位置 | 仅适用于数值型且分布均匀的有序数据;数据分布偏差大时退化为 O ( n ) O(n) O(n);实现需进行乘除法计算 |
除上述比较,三者在实现上额外空间均为常数级,查找过程中仅需几个索引变量。
斐波那契查找优势场景
虽然斐波那契查找在比较次数上不及二分查找,它在一些特定条件下具有优势:
- 非均匀访问存储:当数据存储在磁盘或磁带等介质上时,访问不同位置的开销不一样。斐波那契查找倾向于先访问与上次访问位置接近的元素,因此可以减少寻道(磁头移动)距离。例如,在磁盘上从位置1到位置2比从位置1到位置3更快;二分查找若每次跳跃范围较大,可能导致磁头频繁大范围移动,而斐波那契查找更小的跳跃平均可减小寻道时间。
- 缓存友好性:如果处理器使用直接映射缓存,二分查找由于频繁访问固定模式的数组索引,容易集中落在少数缓存行,增加未命中概率。斐波那契查找的分割点不是2的幂次,所以访问位置更为分散,可降低缓存冲突。此外,当数组特别大无法全部放入高速缓存时,斐波那契查找访问的元素相对更靠近(具有更好的局部性),对缓存性能更友好。
- 算术运算成本:历史上,计算机硬件中除法运算比加法和位移运算要慢。斐波那契查找只用加减法计算索引,而二分查找需要做除法或右移来取中点,因此在老旧硬件上可能略有性能优势。但在现代处理器上,位移运算和加法的成本相当,这一点优势已不显著。
综上所述,当查找操作发生在对寻址开销敏感的存储设备上(如旋转磁盘或磁带)时,或在对缓存局部性要求较高的大规模数据集上,斐波那契查找可能优于二分查找。它在这些场景下通过减少远距离内存访问,实现了平均寻址时间的微小改善。然而,对于一般内存中的有序数组,现代系统上二分查找因实现简单且效率高,仍然是首选方法。
分块查找
核心思想
- 块内无序:每个子块内部无需排序。
- 块间有序:所有属于第 i i i 块的元素均小于所有属于第 i + 1 i+1 i+1 块的元素。(我很好奇,真有这样的数据吗?)
- 索引表:维护一个长度约为 n \sqrt{n} n 的索引数组,每项记录对应块的最大关键字(或最小关键字)及该块的起始下标,用于快速定位目标块。
实现步骤
1. 分块预处理
-
确定块大小 s s s
s = ⌈ n ⌉ s \;=\;\lceil\sqrt{n}\rceil s=⌈n⌉
-
划分块数
块数 = ⌈ n / s ⌉ ≈ n \text{块数} \;=\;\bigl\lceil n/s\bigr\rceil \;\approx\;\sqrt{n} 块数=⌈n/s⌉≈n
-
构建索引表
我也想知道,黑马教程直接就手动构建了
2. 查找流程
-
定位块
- 在索引表中顺序或二分查找,找到第一个“块最大关键字 ≥ 目标值” 的块编号 b b b。
-
块内顺序扫描
- 在原表下标区间 [ L b , R b ] [L_b,R_b] [Lb,Rb] 执行简单线性查找,若找到则返回下标,否则返回 − 1 -1 −1。
复杂度分析
-
时间复杂度
- 索引查找:顺序 O ( n ) O(\sqrt{n}) O(n),二分 O ( log n ) O(\log\sqrt{n}) O(logn)。
- 块内扫描:最坏 O ( n ) O(\sqrt{n}) O(n)。
- 总计: O ( n ) O(\sqrt{n}) O(n)。
-
空间复杂度
- 原表: O ( n ) O(n) O(n)。
- 索引表: O ( n ) O(\sqrt{n}) O(n)。
适用场景
- 静态查找:数据不发生插入/删除,或仅偶尔重建索引。
- 数据量中等( 10 4 ∼ 10 7 10^4\sim10^7 104∼107 级别)时,比线性查找更快,比完全排序的二分查找实现更简单。
- 对存储局部有序但全局大规模有序的场景(如日志分段、分批归档)非常合适。
困惑(待解决)
我看了黑马的教程还是不知道这个分块要如何进行,他似乎是直接调整原索引(在优化块时)而且数据也非常切合?
上网搜索时也发现讲解的人举得例子似乎全是为了贴合这个分块思想设计的(如下图):似乎这个查找只是想告诉我们这个分治的思想
哈希查找
哈希查找利用哈希函数将关键字(Key)映射到表(数组)中的下标,从而实现平均情况下 O(1) 的查找、插入和删除效率。核心难点在于设计良好的哈希函数和高效的冲突处理策略。
1. 原理与结构
1.1 哈希表(Hash Table)
- 表结构:通常用一个定长数组
table[]
存储数据槽(bucket)。 - 映射关系:每个元素的关键字
key
经哈希函数h(key)
计算后,得到一个索引idx = h(key) % M
,M 为表长。
1.2 哈希函数(Hash Function)
-
目标:将任意长度输入均匀分布到
[0, M)
的整数区间。 -
性质:
- 确定性:相同输入总映射到相同输出。
- 均匀性:输入在哈希域上尽可能均匀分布,减少冲突概率。
-
常见实现:
-
对字符串:
hash = ∑ (s[i] * p^i) mod P
,再对表长取模。 -
对整数:
h(x) = ((x * A) mod 2^w) >> (w − p)
(乘法哈希)。- 字符串的多项式滚动哈希
- 整数的乘法哈希(Multiply-Shift)
在深入细节前,先简要概述:
- 多项式滚动哈希 通过将字符串视为多项式的系数,将字符值乘以底数的不同幂次后累加,再对大素数取模,以获得分布均匀且方便滚动更新的哈希值。
- 乘法哈希 则利用键与一个随机奇数常数相乘,并在机器字长范围内取模,再右移若干位,直接提取高位以获得哈希索引。
多项式滚动哈希(Polynomial Rolling Hash)
哈希公式
hash = ( ∑ i = 0 n − 1 ( val ( s [ i ] ) × p i ) ) m o d P , index = hash m o d M \text{hash} = \biggl(\sum_{i=0}^{n-1} \bigl(\text{val}(s[i]) \times p^i\bigr)\bigr)\bmod P \quad,\quad \text{index} = \text{hash} \bmod M hash=(i=0∑n−1(val(s[i])×pi))modP,index=hashmodM
各部分含义
- s [ i ] s[i] s[i]:字符串中第 i i i 个字符,通常映射为一个整数值(例如 ASCII 或 Unicode 码点)。这一映射保证不同字符产生不同的数值输入。
- val ( s [ i ] ) \text{val}(s[i]) val(s[i]):将字符 s [ i ] s[i] s[i] 转为数值后的结果,用作多项式的系数。
- p i p^i pi:底数 p p p 的第 i i i 次幂,用于赋予字符串中不同位置不同的权重。选择合适的 p p p(通常是一个质数或 31, 131 等)可使散列值分布更均匀。
- 求和 ∑ \sum ∑:累加所有字符对应的加权值,等价于将字符串视为系数构成的多项式在点 p p p 处的取值。
- 取模 P P P:对一个较大的素数 P P P 取模,既可防止累加结果溢出,又能利用模运算的良好随机性减少碰撞。常选 Mersenne 素数或接近机器字长的素数,如 2 61 − 1 2^{61}-1 261−1。
- 最终索引 m o d M \bmod\,M modM:将模 P P P 后的哈希值再次对表长 M M M 取模,以映射到哈希表的实际槽位范围 [ 0 , M ) [0, M) [0,M)。
示例 1:多项式滚动哈希(字符串)
目标:计算字符串
"abc"
的哈希值。设定参数:
- 字符串:
"abc"
- 字符编码:使用 ASCII 值
- 基数 p = 31 p = 31 p=31
- 模数 P = 10 9 + 7 P = 10^9 + 7 P=109+7
计算过程:
- 将每个字符转换为对应的 ASCII 值:
'a'
→ 97'b'
→ 98'c'
→ 99
- 应用多项式哈希公式:
hash = ( 97 × 31 0 + 98 × 31 1 + 99 × 31 2 ) m o d 10 9 + 7 \text{hash} = (97 \times 31^0 + 98 \times 31^1 + 99 \times 31^2) \mod 10^9 + 7 hash=(97×310+98×311+99×312)mod109+7
- 逐步计算:
- 97 × 1 = 97 97 \times 1 = 97 97×1=97
- 98 × 31 = 3038 98 \times 31 = 3038 98×31=3038
- 99 × 961 = 95139 99 \times 961 = 95139 99×961=95139 (因为 31 2 = 961 31^2 = 961 312=961)
- 累加并取模:
hash = ( 97 + 3038 + 95139 ) m o d 10 9 + 7 = 98274 \text{hash} = (97 + 3038 + 95139) \mod 10^9 + 7 = 98274 hash=(97+3038+95139)mod109+7=98274
结果:字符串
"abc"
的哈希值为 98274。乘法哈希(Multiply-Shift Method)
哈希公式
h ( x ) = ( ( A × x ) m o d 2 w ) ≫ ( w − p ) h(x) = \bigl((A \times x)\bmod 2^w \bigr)\; \gg\;(w - p) h(x)=((A×x)mod2w)≫(w−p)
其中表长 m = 2 p m = 2^p m=2p,机器字长为 w w w。(32位,64位)
各部分含义
- x x x:待哈希的整数键,假设能用一个 w w w-位字存储。
- 常数 A A A:选取一个奇整数,满足 2 w − 1 < A < 2 w 2^{w-1} < A < 2^w 2w−1<A<2w。奇数确保与 2 w 2^w 2w 的可逆性,进而使高位更“随机”
- 乘法 ( A × x ) (A \times x) (A×x):将键与常数相乘,相当于线性变换,能很好地打散输入数据。
- 模 2 w 2^w 2w:在机器字长范围内自动截断(或通过按位与 ( 2 w − 1 ) (2^w - 1) (2w−1) 实现),等价于仅保留乘积的低 w w w 位。此步骤非常高效。
不需显式写
mod 2^32
,而是溢出扩展:
当编译器遇到源代码中显式对非硬件自然宽度的“ m o d 2 k \bmod 2^k mod2k”操作时,若 kkk 与表长或掩码相关,它通常识别出幂次性质,并用一次按位与(AND)或右移(>>)来替代除法/取模
例如:// 原始 r = x % 256; // 优化后 r = x & 0xFF;
这一步骤是在编译时完成的
- 右移 ≫ ( w − p ) \gg (w - p) ≫(w−p):将得到的 w w w 位值向右移动 w − p w - p w−p 位,相当于取乘积的高 p p p 位,作为哈希表的索引。取高位而非低位能更均匀地利用乘法产生的位混合效果。
- 参数 p p p:满足表长 m = 2 p m = 2^p m=2p;因而最终索引范围为 [ 0 , 2 p ) [0, 2^p) [0,2p)。
表长为何常取 m = 2 p m = 2^p m=2p?
- 简化索引提取:若哈希表长度 m = 2 p m=2^p m=2p,则将 ( A × x ) m o d 2 w (A \times x)\bmod 2^w (A×x)mod2w 的高 p p p 位右移 ( w − p ) (w-p) (w−p) 位,即可直接得到范围 [ 0 , 2 p ) [0,2^p) [0,2p) 之间的索引:
h ( x ) = ( ( A ⋅ x ) m o d 2 w ) ≫ ( w − p ) . h(x) \;=\; \bigl((A\cdot x)\bmod 2^w\bigr)\;\gg\;(w-p). h(x)=((A⋅x)mod2w)≫(w−p).
位移操作比任意模除更快,也易于硬件优化
-
位掩码替代:如果想使用低 p p p 位,也可以对 2 p − 1 2^p-1 2p−1 做位与运算,效果等同于对 2 p 2^p 2p 取模,同样高效
-
非幂次情况:理论上的乘法哈希(如 CLRS 中描述的多项式乘法法)并不强制要求表长是 2 p 2^p 2p,而是可先算出一个实数乘积的分数部分再乘以任意 m m m 并取底。但若要用纯整数、位运算高效实现,就几乎都选 m = 2 p m=2^p m=2p 来避免除法
示例 2:乘法哈希(整数)
目标:计算整数键
123456
的哈希索引。设定参数:
- 键值:
123456
- 常数 A = 2654435769 A = 2654435769 A=2654435769(Knuth 推荐的乘数)
- 机器字长 w = 32 w = 32 w=32
- 哈希表大小 m = 2 8 = 256 m = 2^8 = 256 m=28=256
计算过程:
- 计算乘积并取模 2 32 2^{32} 232:
( 123456 × 2654435769 ) m o d 2 32 (123456 \times 2654435769) \mod 2^{32} (123456×2654435769)mod232
- 将结果右移 32 − 8 = 24 32 - 8 = 24 32−8=24 位,提取高位作为哈希索引:
hash = ( ( 123456 × 2654435769 ) m o d 2 32 ) ≫ 24 \text{hash} = \left( (123456 \times 2654435769) \mod 2^{32} \right) \gg 24 hash=((123456×2654435769)mod232)≫24
- 假设计算结果为
0x12345678
,则:
hash = 0 x 12345678 ≫ 24 = 0 x 12 = 18 \text{hash} = 0x12345678 \gg 24 = 0x12 = 18 hash=0x12345678≫24=0x12=18
结果:整数键
123456
的哈希索引为 18。
-
2. 冲突处理(Collision Resolution)
2.1 开放寻址(Open Addressing)
- 线性探测(Linear Probing):
idx = (h(key) + i) % M
,i 从 0 递增 - 二次探测(Quadratic Probing):
idx = (h(key) + c1·i + c2·i^2) % M
- 双重哈希(Double Hashing):
idx = (h1(key) + i·h2(key)) % M
优点:内存集中,缓存友好。
缺点:高负载下“聚集”现象严重,探测长度增加。
2.2 链地址法(Separate Chaining)
- 每个槽存储一个链表(或其他动态结构,如红黑树、平衡树)。
- 插入:直接插入到对应链表头部或尾部。
- 查找:在链表中线性扫描或在树中对数搜索。
优点:负载因子超过 1 时仍能正常工作;删除操作简单。
缺点:额外的指针开销;链表长时查找较慢。
概要:
- 负载因子(Load Factor)是哈希表中实际存储元素数 n n n 与桶(或槽)总数 m m m 之比。 α = n / m \alpha = n/m α=n/m;它反映了哈希表的“拥挤”程度,直接影响查找、插入和删除操作的平均性能,值越高意味着冲突越多、性能越差。
- 负载因子不仅限于链地址法,在开放寻址(如线性探测、二次探测、双重哈希)中同样适用,但最大值受限(开放寻址时 α < 1 \alpha<1 α<1)。
- 再哈希(Rehashing)是当负载因子超过预设阈值后,为恢复高效性能而动态扩大哈希表并重新计算所有现有键的哈希索引的过程。
负载因子(Load Factor)
定义
- 数学表达: α = n m \displaystyle \alpha = \frac{n}{m} α=mn,其中 n n n 为哈希表中当前元素数, m m m 为桶的总数或槽位总数。
- 意义:表示哈希表的“填充率”,负载因子高意味着更多的键映射到相同或相邻的位置,冲突概率也随之升高,从而增大查找或插入时的探测或链表长度。
在链地址法中的表现
- 链地址法(Separate Chaining):每个桶对应一个链表(或其他结构),冲突时元素附加到对应链表中。
- 性能影响:平均情况下,链长约为 α \alpha α,因此插入与查找的平均时间复杂度为 O ( 1 + α ) O(1 + \alpha) O(1+α);当 α \alpha α 增大时,链表变长,操作成本也线性增加。
- 阈值选择:通常将 α max \alpha_{\max} αmax 设在 1–3 之间,以平衡内存利用与性能,多数实现会在 α \alpha α 超过设定阈值时触发再哈希。
在开放寻址中的表现
- 开放寻址(Open Addressing):冲突时在表内探测其他空槽,直至找到空位。
- 性能影响:当 α \alpha α 接近 1 时,将出现严重的“聚集”现象,探测长度急剧上升,查找与插入效率急剧下降。
- 最大限制:开放寻址表中 m m m 是槽位数,必须保证 n < m n<m n<m,即 α < 1 \alpha<1 α<1;实际应用中,多数实现将 α max \alpha_{\max} αmax 取在 0.6–0.75 之间,以保证高效。
3. 基本操作
操作 | 开放寻址复杂度 | 链地址法复杂度 |
---|---|---|
插入 | 平均 O(1),最坏 O(M) | 平均 O(1 + α),最坏 O(N) |
查找 | 平均 O(1),最坏 O(M) | 平均 O(1 + α),最坏 O(N) |
删除 | 平均 O(1),最坏 O(M) | 平均 O(1 + α),最坏 O(N) |
其中 α = N/M 为负载因子,N 为表中实际元素数。
4. 扩容与再哈希
- 扩容阈值:当负载因子 α 超过设定(如 0.75)时,表长扩倍(通常×2),并对所有元素重新 hash 到新表。
- 再哈希开销:O(N),但由于扩容次数为 O(log N),均摊插入仍是 O(1)。
再哈希(Rehashing)
概念与定义
- 再哈希是指在哈希表运行过程中,检测到负载因子 α \alpha α 达到或超过预设阈值后,动态地创建一个更大的新表(通常容量翻倍),并将旧表中所有元素按照新表大小重新计算哈希并插入新表的过程。
- 这一过程又称“扩容”(resize)或“重散列”,旨在降低 α \alpha α 并显著减少后续的冲突开销。
执行流程
- 触发条件:当插入操作导致 α ≥ α max \alpha \ge \alpha_{\max} α≥αmax 时触发,再哈希通常放在插入后判断并执行。
- 分配新表:依据策略(多数为翻倍或乘以固定系数),分配一个新的、更大的桶数组或槽位数组。
- 重计算哈希:遍历旧表中每个元素,调用哈希函数重新计算索引(因表长已变,索引需更新),并插入到新表中。
- 替换旧表:完成所有元素迁移后,用新表替换旧表,继续提供服务。
性能与成本
- 单次开销:再哈希过程本身是 O ( n ) O(n) O(n) 的昂贵操作,因为需要对所有元素重新散列并插入。
- 均摊分析:由于容量每次通常翻倍(或按几何级数增长),再哈希在 n n n 次插入中的总成本为 O ( n ) O(n) O(n) 级别,故均摊到每次插入仍为 O ( 1 ) O(1) O(1) 平均时间。
- 内存权衡:再哈希会暂时占用两倍内存,且旧表在迁移完毕前无法立即释放,需在设计时考虑内存压力。
结论:
- 负载因子不仅是衡量哈希表装填度的重要指标,也是触发再哈希动作的关键条件,既适用于链地址法,也适用于开放寻址等所有哈希表实现。
- 再哈希通过动态扩容并重散列来降低负载因子,从而保持哈希表在平均 O ( 1 ) O(1) O(1) 性能上的稳定性,但需要留意其偶发的 O ( n ) O(n) O(n) 开销和内存开销。
5. 注意事项
- 哈希函数安全:对抗攻击时需防止恶意构造大量冲突,可以使用随机化哈希或更复杂的哈希算法。
- 负载因子控制:负载因子过低浪费内存,过高导致冲突增多,应根据业务特点调整。
- 并发环境:使用分段锁、CAS 与无锁结构(如 ConcurrentHashMap)来保证线程安全。