OpenDSA 完整目录

Chapter 14 Hashing

| 关于   «  6. 冲突解决   ::   目录   ::   8. 闭散列分析  »

7. 改进的冲突解决

7.1. 按步长的线性探测

我们怎样才能避免一次聚集? 一种可能的改进是使用线性探测, 但以某个不为 1 的常数 \(c\) 跳过槽位。 这样,探测函数就是 \(\textbf{p}(K, i) = ci\) , 于是探测序列中的第 \(i\) 个槽位将是 \((\textbf{h}(K) + ic) \mod M\) 。 这样一来,起始位置相邻的记录就不会遵循 相同的探测序列。

Settings

Proficient Saving... Error Saving
Server Error
Resubmit

好的探测序列的一个性质是,它会在回到起始位置之前 遍历散列表中的所有槽位。 显然,线性探测(每次都把槽位"跳过"一个)能够做到这一点。 遗憾的是,并非所有 \(c\) 的取值都能实现这一点。 例如,如果 \(c = 2\) 且表中包含偶数个 槽位,那么任何起始位置在偶数槽位中的键, 其探测序列都只会遍历偶数槽位。 同样,起始位置在奇数槽位中的键, 其探测序列也只会遍历奇数槽位。 因此,表长与线性探测常数的这种组合 实际上把记录分成两个集合,分别存储在 散列表中两个不相交的区域里。 只要表中这两个区域包含的记录数相同, 这就并不太重要。 然而,仅凭偶然,很可能有一个区域会变得 比另一个更满,导致那些记录出现更多冲突、性能更差。 另一个区域则记录更少,因而性能更好。 但系统的整体性能会下降, 因为较满一侧增加的成本超过了 较空一侧性能的提升。

常数 \(c\) 必须与 \(M\) 互素,才能生成 访问表中所有槽位的线性探测序列 (也就是说, \(c\) 与 \(M\) 不能有公因子)。 对于大小为 \(M = 10\) 的散列表,如果 \(c\) 取 1、3、7 或 9 中的任意一个, 那么对任意键,探测序列都会访问所有槽位。 当 \(M = 11\) 时, \(c\) 取 1 到 10 之间的任意值, 都会为每个键生成访问所有槽位的探测序列。

Settings

Proficient Saving... Error Saving
Server Error
Resubmit

现在你可以练习使用不同步长的线性探测。

7.2. 伪随机探测

考虑 \(c = 2\) 且我们希望插入一条 键为 \(k_1\) 的记录、使得 \(\textbf{h}(k_1) = 3\) 的情形。 \(k_1\) 的探测序列是 3、5、7、9,依此类推。 如果另一个键 \(k_2\) 的起始位置在槽位 5, 那么它的探测序列将是 5、7、9,依此类推。 \(k_1\) 与 \(k_2\) 的探测序列 以某种方式联系在一起,从而助长了聚集。 换句话说,取值 \(c > 1\) 的线性探测并不能 解决一次聚集问题。 我们希望能找到一种探测函数,它不会以这种方式 把键联系在一起。 我们更希望 \(k_1\) 的探测序列 在序列的第一步之后,不应与 \(k_2\) 的探测序列相同。 相反,它们的探测序列应当分道扬镳。

理想的探测函数会从未访问过的槽位中 随机选择探测序列上的下一个位置;也就是说,探测 序列应当是散列表位置的一个随机排列。 遗憾的是,我们实际上无法随机选择 探测序列中的下一个位置,因为这样在查找键时 就无法复现同一个探测序列。 不过,我们可以采用一种类似的做法,称为 伪随机探测 。 在伪随机探测中,探测序列中的第 \(i\) 个槽位是 \((\textbf{h}(K) + r_i) \mod M\) , 其中 \(r_i\) 是 1 到 \(M-1\) 这些数的 随机排列中的第 \(i\) 个值。 所有插入和查找都必须使用同一组随机数序列。 探测函数将是 \(\textbf{p}(K, i) = \textbf{Permutation}[i]\) , 其中 Permutation 是一个长度为 \(M\) 的数组,在位置 Permutation[0] 中存储 值 0,并在槽位 1 到 \(M - 1\) 中存储 1 到 \(M - 1\) 这些值的一个随机排列。

Settings

Proficient Saving... Error Saving
Server Error
Resubmit

下面是伪随机探测的练习。

伪随机探测在散列函数中展现出另一个理想特性。

Settings

Proficient Saving... Error Saving
Server Error
Resubmit

7.3. 二次探测

另一种能消除 一次聚集的探测函数称为 二次探测 。 这里探测函数是某个二次函数 \(\textbf{p}(K, i) = c_1 i^2 + c_{2}i + c_3\) , 其中 \(c_1\) 、 \(c_2\) 和 \(c_3\) 是某些选定的常数。

最简单的变体是 \(\textbf{p}(K, i) = i^2\) (即 \(c_1 = 1\) 、 \(c_2 = 0\) ,以及 \(c_3 = 0\))。 于是探测序列中的第 \(i\) 个值将是 \((\textbf{h}(K) + i^2) \mod M\) 。

Settings

Proficient Saving... Error Saving
Server Error
Resubmit

现在你可以练习二次探测。

二次探测有一个问题:它的探测序列 通常不会访问散列表中的所有槽位。

Settings

Proficient Saving... Error Saving
Server Error
Resubmit

对于许多散列表大小,这个探测函数只会遍历 相对较少数量的槽位。 如果该循环上的所有槽位恰好都已满,这就意味着 该记录根本无法插入! 一个更现实的例子是含 105 个槽位的表。 从任意给定槽位开始的探测序列只会访问表中 另外 23 个槽位。 如果这 24 个槽位恰好都已满,即使表中其他槽位 为空,该记录也无法插入,因为 探测序列会不断命中同样的这 24 个槽位。

幸运的是,二次探测有可能以较低的代价 取得良好结果。 探测函数与表长的正确组合会访问表中 许多槽位。 特别地,如果散列表大小是素数,且探测 函数为 \(\textbf{p}(K, i) = i^2\) , 那么表中至少一半的槽位会被访问。 因此,如果表的装填程度不到一半,我们就能确定 会找到一个空闲槽位。 另一种做法是,如果散列表大小是 2 的幂,且探测 函数为 \(\textbf{p}(K, i) = (i^2 + i)/2\) , 那么探测函数会访问表中的每一个槽位。

7.4. 双重散列

伪随机探测和二次探测都能消除 一次聚集,一次聚集是指 键共享探测序列中相当长一段的情形。 然而,如果两个键散列到同一个起始位置,那么对于 我们目前见过的每一种冲突解决方法,它们都会 始终遵循相同的探测序列。 (例如)伪随机探测和 二次探测生成的探测序列完全由起始 位置决定,而与原始键值无关。 这是因为对于这些冲突解决方法,函数 p 忽略了它的输入参数 \(K\) 。 如果散列函数在某个特定起始 位置产生了一个聚集,那么在伪随机探测和二次探测下, 这个聚集依然存在。 这个问题称为 二次聚集 。

为了避免二次聚集,我们需要让探测序列在 决策过程中利用原始键值。 一个简单的做法是回到 使用固定步长的线性探测 作为探测函数,但让 这个步长由第二个散列函数 \(\textbf{h}_2\) 决定。 于是,探测序列将具有 \(\textbf{p}(K, i) = i * \textbf{h}_2(K)\) 的形式。 这种方法称为 双重散列 。

\(h_2\) 有一些重要的限制。 最重要的是, \(h_2\) 返回的值绝不能为零 (或 \(M\)),因为那样会立即导致无限循环, 使探测序列毫无进展。 然而,一个好的双重散列实现还应确保 所有探测序列常数都与表长 \(M\) 互素。 例如,如果散列表大小是 100,而线性探测的步长 (由函数 \(h_2\) 生成)是 50,那么 探测序列上就只会有一个槽位。 反之,如果散列表大小是 101(一个素数),那么任何小于 101 的 步长都会访问表中的每一个槽位。

这一点很容易做到。 一种方法是选择 \(M\) 为素数,并让 \(\textbf{h}_2\) 返回一个落在范围 \(1 <= \textbf{h}_2(k) <= M - 1\) 内的值。 我们可以通过使用这个第二散列函数来做到这一点: \(\textbf{h}_2(k) = 1 + (k \mod (M-1))\) 。 另一种方法是令 \(M = 2^m\) , 其中 \(m\) 为某个值,并让 \(\textbf{h}_2\) 返回一个介于 1 和 \(2^m\) 之间的 奇数值。 我们可以用这个第二散列函数得到该结果: \(\textbf{h}_2(k) = (((k/M) \mod (M/2)) * 2) + 1\) 。 [1]

Settings

Proficient Saving... Error Saving
Server Error
Resubmit


Settings

Proficient Saving... Error Saving
Server Error
Resubmit

现在你可以试一试。

   «  6. 冲突解决   ::   目录   ::   8. 闭散列分析  »

关闭窗口