为什么我们有双重哈希函数 [(hash1(key) + i * hash2(key)) % TABLE_SIZE] 而不是简单的 [(i * hash2(key)) % TABLE_SIZE]?
Why do we have double hashing function as [(hash1(key) + i * hash2(key)) % TABLE_SIZE] but not simply as [(i * hash2(key)) % TABLE_SIZE]?
我几天前学习了双重哈希的符号 [(hash1(key) + i * hash2(key)) % TABLE_SIZE] 。有一个地方想了好几天没明白
Why don't we discard the [hash1(key)] part from the double hashing function and simply make it as [(i * hash2(key)) % TABLE_SIZE]?
我找不到这样做的任何缺点,除了所有哈希码将从 0 开始(当 i = 0 时)。
使用双重散列的主要目的,避免集群,还是可以实现的。
如果有人可以的话,将不胜感激help:D
双重哈希的证明是在关于 h1 和 h2 的一些弱假设下进行的,即它们来自通用家族。您得到的结果是每个操作都需要恒定时间(对于不依赖于 h1 和 h2 选择的每个访问)。
对于 h2 only,您需要加强 h2 的条件或按照规定给出时间限制。选择一个与 1 mod 4 一致的素数 p,设 P* = {1, …, p−1} 为单位集 mod p,并考虑以下平庸但通用的哈希函数族来自 {L, R} × P*。绘制一个随机标量 c ← P* 和一个随机函数 f ← (P* → P*) 并定义
h2((L, x)) = cx mod p
h2((R, x)) = f(x).
这个系列是通用的,因为 (L, x) 键永远不会相互碰撞,并且每隔一对键碰撞的概率恰好为 1/|P*|。对于仅使用 h2 的双重哈希算法来说,这是一个糟糕的选择,因为它在其范围的一半上是线性的,并且线性保留了算术序列。
考虑以下操作顺序。通过插入 (R, 1), …, (R, (p−1)/2) 随机填充散列 table 的一半,然后再插入一半的元素 (L, (p−1)/ 4), …, (L, 1)。 table 负载最多为 3/4,因此一切都应在预期的恒定时间内 运行。然而,考虑一下当我们插入 (L, 1) 时会发生什么。以 1/2 的概率,位置 h2((L, 1)) 被 R 键之一占据。第 ith 个探针 i h2((L, 1)) 命中与 h2((L, i)) 相同的位置,对于 i ≤ (p−1)/4 是有保证的由较早的操作填满。因此,此操作的预期成本在 p 中是线性的,即使密钥序列不依赖于哈希函数,即 unacceptable.
将 h1 放回组合中会破坏此结构。
(呃,这不太行得通,因为预期恒定时间的证明假定了强普遍性,而不是摘要中所述的普遍性。)
这个答案的快速总结:
- 您修改后的版本有实际的性能影响,虽然不是很大。
- 我认为这是因为没有像常规双重散列那样多的不同探测序列,由于 birthday paradox.
而导致一些额外的冲突
现在,真正的答案。 :-)
让我们从一些实证分析开始。如果您从双重散列的“标准”版本切换到您提出的双重散列的变体,会发生什么情况?
我编写了一个 C++ 程序,它为每个元素生成 h1 和 h2 值的统一随机选择。然后它将它们插入到两个不同的双重散列 table 中,一个使用正常方法,一个使用变体。它多次重复此过程并报告每次插入所需的平均探针数。这是我的发现:
#include <iostream>
#include <vector>
#include <random>
#include <utility>
using namespace std;
/* Table size is picked to be a prime number. */
const size_t kTableSize = 5003;
/* Load factor for the hash table. */
const double kLoadFactor = 0.9;
/* Number of rounds to use. */
const size_t kNumRounds = 100000;
/* Random generator. */
static mt19937 generator;
/* Creates and returns an empty double hashing table. */
auto emptyTable(const size_t numSlots) {
return vector<bool>(numSlots, false);
}
/* Simulation of double hashing. Each vector entry represents an item to store.
* The first element of the pair is the value of h1(x), and the second element
* of the pair is the value of h2(x).
*/
auto hashCodes(const size_t numItems) {
vector<pair<size_t, size_t>> result;
uniform_int_distribution<size_t> first(0, kTableSize - 1), second(1, kTableSize - 1);
for (size_t i = 0; i < numItems; i++) {
result.push_back({ first(generator), second(generator) });
}
return result;
}
/* Returns the probe location to use given a number of steps taken so far.
* If modified is true, we ignore h1.
*/
size_t locationOf(size_t tableSize, size_t numProbes, size_t h1, size_t h2, bool modified) {
size_t result = (numProbes == 0 || !modified)? h1 : 0;
result += h2 * numProbes;
return result % tableSize;
}
/* Performs a double-hashing insert, returning the number of probes required to
* settle the element into its place.
*/
size_t insert(vector<bool>& table, size_t h1, size_t h2, bool modified) {
size_t numProbes = 0;
while (table[locationOf(table.size(), numProbes, h1, h2, modified)]) {
numProbes++;
}
table[locationOf(table.size(), numProbes, h1, h2, modified)] = true;
return numProbes + 1; // Count the original location as one probe
}
int main() {
size_t normalProbes = 0, variantProbes = 0;
for (size_t round = 0; round < kNumRounds; round++) {
auto normalTable = emptyTable(kTableSize);
auto variantTable = emptyTable(kTableSize);
/* Insert a collection of items into the table. */
for (auto [h1, h2]: hashCodes(kTableSize * kLoadFactor)) {
normalProbes += insert(normalTable, h1, h2, false);
variantProbes += insert(variantTable, h1, h2, true);
}
}
cout << "Normal probes: " << normalProbes << endl;
cout << "Variant probes: " << variantProbes << endl;
}
输出:
Normal probes: 1150241942
Variant probes: 1214644088
因此,根据经验,修改后的方法看起来会导致放置所有元素所需的探针增加大约 5%。那么,问题是为什么会这样。
对于为什么修改后的版本变慢,我没有一个完整的理论解释,但我对发生了什么有一个合理的猜测。直观上,双重哈希通过为插入的每个元素分配一个随机探测序列来工作,这是 table 槽的某种排列。它不是真正随机的排列,因为并非所有排列都可以实现,但对于“足够随机”的某些定义来说它足够随机(例如,参见 Guibas and Szemendi's "The Analysis of Double Hashing)。
让我们考虑一下插入时会发生什么。按照预期,我们需要查看 h1 之外的探针序列多少次?第一项需要看h2的概率为0。第二项有 1/T 的概率,因为它以 1/T 的概率命中第一个元素。第二个项目有 2/T 的概率,因为它有 2/T 的机会击中前两个项目。更一般地说,使用线性期望,我们可以证明一个项目在一个已经被占用的地方的预期次数由
给出
1/T + 2/T + 3/T + 4/T + ... + (n-1)/T
=(1+2+3+...+(n-1))/T
= n(n-1)/2T
现在,假设我们的散列 table 上的负载因子为 α,即 αT = n。那么预期的碰撞次数为
αT(αT - 1) / 2T
≈ α2T / 2.
换句话说,我们应该期望在使用双哈希时看到相当多的次数需要检查 h2。
现在,当我们查看探针序列时会发生什么?使用传统的双重哈希的不同探测序列的数量是T(T-1),其中T是table中的槽数。这是因为 h1(x) 有 T 种可能的选择,h2(x) 有 T-1 种选择。
birthday paradox 背后的数学表明一旦大约 √(2T(T-1)) ≈ T√2 项被插入到 table,我们有 50% 的机会其中两个最终将分配有相同的探针序列。这里的好消息是不可能将 T√2 项插入 T 槽哈希 table - 元素比槽多! - 因此我们看到被分配相同探测序列的元素的可能性相当低。这意味着我们在 table 中遇到的冲突主要是由于具有不同探测序列的元素之间的碰撞,但巧合的是最终落在彼此之上。
另一方面,让我们考虑一下您的变化。从技术上讲,仍然有 T(T-1) 个不同的探针序列。然而,我要争辩说,“有效地”更像是只有 T-1 不同的探针序列。这样做的原因是
- 探针序列并不重要,除非在插入时发生碰撞,并且
- 一旦插入后发生碰撞,给定元素的探测序列完全由其 h2 值决定。
这不是一个严格的论点 - 它更像是一种直觉 - 但它表明我们在选择排列的方式上变化较小。
因为一旦发生碰撞,只有 T-1 个不同的探测序列可供选择,生日悖论表明我们需要看到大约 √(2T) 次碰撞才能找到具有相同值的两个项目h2。事实上,鉴于我们预期,α2T/2 项需要 h2 检查,这意味着我们很有可能找到其位置将由完全相同的 h2 值序列确定的项目。这意味着与“经典”双重哈希相比,我们有了一种新的冲突源:来自 h2 值的冲突相互重叠。
现在,即使我们确实与 h2 值发生冲突,也没什么大不了的。毕竟,在我们到达新插槽之前,只需要一些额外的探测就可以跳过过去放置有相同 h2 值的项目。但我认为这可能是在修改版本中看到的额外探针的来源。
希望对您有所帮助!
再咬一口苹果,这次的普适性很强。留下我的另一个答案,因为这个答案使用 Patrascu 和 Thorup 的结果作为黑匣子(以及您选择的一些深度数论或一些 handwaving),这可能不太令人满意。结果是关于线性探测的,即对于每个 table 大小为 4 的幂的 m,存在一个 2-通用(即强通用)哈希族和一系列操作,使得期望超过随机散列函数,一次操作(简称The Query)探测Theta(√m)个单元格。
为了使用这个结果,我们真的想要一个大小为 p−1 的 table,其中 p 是一个素数,所以固定 m 和不利于线性探测的哈希族 Hm(其函数的辅域为{0, …, m-1}),选择p为大于m的最小素数。 (或者,m 是 4 的幂的要求基本上是为了方便编写证明;这似乎很乏味,但可以将 Patrascu 和 Thorup 的结果推广到其他 table 大小。)定义分布 Hp通过画一个函数h''←Hm然后根据分布独立定义h'的每个值
h'(x) = | h''(x) + 1 with probability m/(p-1)
| m with probability 1/m
...
| p-1 with probability 1/m.
设 K 为字段 mod p,函数 h 的陪域 K* = {1, …, p-1},K 的单位。除非我搞砸了定义,否则很容易验证Hp 是强普遍的。我们需要引入一些 heavy-duty number theory 来证明 p - m 是 O(m2/3)(这是因为足够大的连续立方体之间存在素数),这意味着对于 Omega(m1/3) 步,我们的长度为 O(√m) 的线性探测序列保持不变,概率恒定,远远超过常数。
现在,为了将这个系列从线性探测破坏器更改为“双”哈希破坏器,我们需要为查询中涉及的密钥命名,假设为 q。 (我们肯定知道它是哪一个,因为操作顺序不依赖于散列函数。)我们定义一个新的散列函数分布 h 通过像以前一样绘制 h',绘制 c ← K*,然后定义
h(x) = | c h'(x) if x ≠ q
| c if x = q.
让我们验证一下这仍然是2-universal。给定键 x 和 y 都不等于 q,很明显 (h(x), h(y)) = (c h'(x), c h'(y)) 在 K* × K* 上均匀分布.给定 q 和一些其他键 x,我们检查 (h(q), h(x)) = (c, c h'(x)) 的分布,这是需要的,因为 c 是均匀的,而 h'(x ) 是统一的并且独立于 c,因此 c h'(x) 也是如此。
OK,本次练习的重点终于来了。 The Query 的探测序列将是 c、2c、3c 等。哪些键散列到(例如)2c?它们是满足方程
的x
h(x) = c h'(x) = 2c
我们从中得出
h'(x) = 2,
即,其首选插槽在线性探测顺序中紧随查询之后的键。从 2 推广到 i,我们得出结论,对于 h' 的查询,错误的线性探测序列变成了对于 h 的查询,QED 的错误“双重”散列探测序列。
我几天前学习了双重哈希的符号 [(hash1(key) + i * hash2(key)) % TABLE_SIZE] 。有一个地方想了好几天没明白
Why don't we discard the [hash1(key)] part from the double hashing function and simply make it as [(i * hash2(key)) % TABLE_SIZE]?
我找不到这样做的任何缺点,除了所有哈希码将从 0 开始(当 i = 0 时)。 使用双重散列的主要目的,避免集群,还是可以实现的。
如果有人可以的话,将不胜感激help:D
双重哈希的证明是在关于 h1 和 h2 的一些弱假设下进行的,即它们来自通用家族。您得到的结果是每个操作都需要恒定时间(对于不依赖于 h1 和 h2 选择的每个访问)。
对于 h2 only,您需要加强 h2 的条件或按照规定给出时间限制。选择一个与 1 mod 4 一致的素数 p,设 P* = {1, …, p−1} 为单位集 mod p,并考虑以下平庸但通用的哈希函数族来自 {L, R} × P*。绘制一个随机标量 c ← P* 和一个随机函数 f ← (P* → P*) 并定义
h2((L, x)) = cx mod p
h2((R, x)) = f(x).
这个系列是通用的,因为 (L, x) 键永远不会相互碰撞,并且每隔一对键碰撞的概率恰好为 1/|P*|。对于仅使用 h2 的双重哈希算法来说,这是一个糟糕的选择,因为它在其范围的一半上是线性的,并且线性保留了算术序列。
考虑以下操作顺序。通过插入 (R, 1), …, (R, (p−1)/2) 随机填充散列 table 的一半,然后再插入一半的元素 (L, (p−1)/ 4), …, (L, 1)。 table 负载最多为 3/4,因此一切都应在预期的恒定时间内 运行。然而,考虑一下当我们插入 (L, 1) 时会发生什么。以 1/2 的概率,位置 h2((L, 1)) 被 R 键之一占据。第 ith 个探针 i h2((L, 1)) 命中与 h2((L, i)) 相同的位置,对于 i ≤ (p−1)/4 是有保证的由较早的操作填满。因此,此操作的预期成本在 p 中是线性的,即使密钥序列不依赖于哈希函数,即 unacceptable.
将 h1 放回组合中会破坏此结构。
(呃,这不太行得通,因为预期恒定时间的证明假定了强普遍性,而不是摘要中所述的普遍性。)
这个答案的快速总结:
- 您修改后的版本有实际的性能影响,虽然不是很大。
- 我认为这是因为没有像常规双重散列那样多的不同探测序列,由于 birthday paradox. 而导致一些额外的冲突
现在,真正的答案。 :-)
让我们从一些实证分析开始。如果您从双重散列的“标准”版本切换到您提出的双重散列的变体,会发生什么情况?
我编写了一个 C++ 程序,它为每个元素生成 h1 和 h2 值的统一随机选择。然后它将它们插入到两个不同的双重散列 table 中,一个使用正常方法,一个使用变体。它多次重复此过程并报告每次插入所需的平均探针数。这是我的发现:
#include <iostream>
#include <vector>
#include <random>
#include <utility>
using namespace std;
/* Table size is picked to be a prime number. */
const size_t kTableSize = 5003;
/* Load factor for the hash table. */
const double kLoadFactor = 0.9;
/* Number of rounds to use. */
const size_t kNumRounds = 100000;
/* Random generator. */
static mt19937 generator;
/* Creates and returns an empty double hashing table. */
auto emptyTable(const size_t numSlots) {
return vector<bool>(numSlots, false);
}
/* Simulation of double hashing. Each vector entry represents an item to store.
* The first element of the pair is the value of h1(x), and the second element
* of the pair is the value of h2(x).
*/
auto hashCodes(const size_t numItems) {
vector<pair<size_t, size_t>> result;
uniform_int_distribution<size_t> first(0, kTableSize - 1), second(1, kTableSize - 1);
for (size_t i = 0; i < numItems; i++) {
result.push_back({ first(generator), second(generator) });
}
return result;
}
/* Returns the probe location to use given a number of steps taken so far.
* If modified is true, we ignore h1.
*/
size_t locationOf(size_t tableSize, size_t numProbes, size_t h1, size_t h2, bool modified) {
size_t result = (numProbes == 0 || !modified)? h1 : 0;
result += h2 * numProbes;
return result % tableSize;
}
/* Performs a double-hashing insert, returning the number of probes required to
* settle the element into its place.
*/
size_t insert(vector<bool>& table, size_t h1, size_t h2, bool modified) {
size_t numProbes = 0;
while (table[locationOf(table.size(), numProbes, h1, h2, modified)]) {
numProbes++;
}
table[locationOf(table.size(), numProbes, h1, h2, modified)] = true;
return numProbes + 1; // Count the original location as one probe
}
int main() {
size_t normalProbes = 0, variantProbes = 0;
for (size_t round = 0; round < kNumRounds; round++) {
auto normalTable = emptyTable(kTableSize);
auto variantTable = emptyTable(kTableSize);
/* Insert a collection of items into the table. */
for (auto [h1, h2]: hashCodes(kTableSize * kLoadFactor)) {
normalProbes += insert(normalTable, h1, h2, false);
variantProbes += insert(variantTable, h1, h2, true);
}
}
cout << "Normal probes: " << normalProbes << endl;
cout << "Variant probes: " << variantProbes << endl;
}
输出:
Normal probes: 1150241942
Variant probes: 1214644088
因此,根据经验,修改后的方法看起来会导致放置所有元素所需的探针增加大约 5%。那么,问题是为什么会这样。
对于为什么修改后的版本变慢,我没有一个完整的理论解释,但我对发生了什么有一个合理的猜测。直观上,双重哈希通过为插入的每个元素分配一个随机探测序列来工作,这是 table 槽的某种排列。它不是真正随机的排列,因为并非所有排列都可以实现,但对于“足够随机”的某些定义来说它足够随机(例如,参见 Guibas and Szemendi's "The Analysis of Double Hashing)。
让我们考虑一下插入时会发生什么。按照预期,我们需要查看 h1 之外的探针序列多少次?第一项需要看h2的概率为0。第二项有 1/T 的概率,因为它以 1/T 的概率命中第一个元素。第二个项目有 2/T 的概率,因为它有 2/T 的机会击中前两个项目。更一般地说,使用线性期望,我们可以证明一个项目在一个已经被占用的地方的预期次数由
给出1/T + 2/T + 3/T + 4/T + ... + (n-1)/T
=(1+2+3+...+(n-1))/T
= n(n-1)/2T
现在,假设我们的散列 table 上的负载因子为 α,即 αT = n。那么预期的碰撞次数为
αT(αT - 1) / 2T
≈ α2T / 2.
换句话说,我们应该期望在使用双哈希时看到相当多的次数需要检查 h2。
现在,当我们查看探针序列时会发生什么?使用传统的双重哈希的不同探测序列的数量是T(T-1),其中T是table中的槽数。这是因为 h1(x) 有 T 种可能的选择,h2(x) 有 T-1 种选择。
birthday paradox 背后的数学表明一旦大约 √(2T(T-1)) ≈ T√2 项被插入到 table,我们有 50% 的机会其中两个最终将分配有相同的探针序列。这里的好消息是不可能将 T√2 项插入 T 槽哈希 table - 元素比槽多! - 因此我们看到被分配相同探测序列的元素的可能性相当低。这意味着我们在 table 中遇到的冲突主要是由于具有不同探测序列的元素之间的碰撞,但巧合的是最终落在彼此之上。
另一方面,让我们考虑一下您的变化。从技术上讲,仍然有 T(T-1) 个不同的探针序列。然而,我要争辩说,“有效地”更像是只有 T-1 不同的探针序列。这样做的原因是
- 探针序列并不重要,除非在插入时发生碰撞,并且
- 一旦插入后发生碰撞,给定元素的探测序列完全由其 h2 值决定。
这不是一个严格的论点 - 它更像是一种直觉 - 但它表明我们在选择排列的方式上变化较小。
因为一旦发生碰撞,只有 T-1 个不同的探测序列可供选择,生日悖论表明我们需要看到大约 √(2T) 次碰撞才能找到具有相同值的两个项目h2。事实上,鉴于我们预期,α2T/2 项需要 h2 检查,这意味着我们很有可能找到其位置将由完全相同的 h2 值序列确定的项目。这意味着与“经典”双重哈希相比,我们有了一种新的冲突源:来自 h2 值的冲突相互重叠。
现在,即使我们确实与 h2 值发生冲突,也没什么大不了的。毕竟,在我们到达新插槽之前,只需要一些额外的探测就可以跳过过去放置有相同 h2 值的项目。但我认为这可能是在修改版本中看到的额外探针的来源。
希望对您有所帮助!
再咬一口苹果,这次的普适性很强。留下我的另一个答案,因为这个答案使用 Patrascu 和 Thorup 的结果作为黑匣子(以及您选择的一些深度数论或一些 handwaving),这可能不太令人满意。结果是关于线性探测的,即对于每个 table 大小为 4 的幂的 m,存在一个 2-通用(即强通用)哈希族和一系列操作,使得期望超过随机散列函数,一次操作(简称The Query)探测Theta(√m)个单元格。
为了使用这个结果,我们真的想要一个大小为 p−1 的 table,其中 p 是一个素数,所以固定 m 和不利于线性探测的哈希族 Hm(其函数的辅域为{0, …, m-1}),选择p为大于m的最小素数。 (或者,m 是 4 的幂的要求基本上是为了方便编写证明;这似乎很乏味,但可以将 Patrascu 和 Thorup 的结果推广到其他 table 大小。)定义分布 Hp通过画一个函数h''←Hm然后根据分布独立定义h'的每个值
h'(x) = | h''(x) + 1 with probability m/(p-1)
| m with probability 1/m
...
| p-1 with probability 1/m.
设 K 为字段 mod p,函数 h 的陪域 K* = {1, …, p-1},K 的单位。除非我搞砸了定义,否则很容易验证Hp 是强普遍的。我们需要引入一些 heavy-duty number theory 来证明 p - m 是 O(m2/3)(这是因为足够大的连续立方体之间存在素数),这意味着对于 Omega(m1/3) 步,我们的长度为 O(√m) 的线性探测序列保持不变,概率恒定,远远超过常数。
现在,为了将这个系列从线性探测破坏器更改为“双”哈希破坏器,我们需要为查询中涉及的密钥命名,假设为 q。 (我们肯定知道它是哪一个,因为操作顺序不依赖于散列函数。)我们定义一个新的散列函数分布 h 通过像以前一样绘制 h',绘制 c ← K*,然后定义
h(x) = | c h'(x) if x ≠ q
| c if x = q.
让我们验证一下这仍然是2-universal。给定键 x 和 y 都不等于 q,很明显 (h(x), h(y)) = (c h'(x), c h'(y)) 在 K* × K* 上均匀分布.给定 q 和一些其他键 x,我们检查 (h(q), h(x)) = (c, c h'(x)) 的分布,这是需要的,因为 c 是均匀的,而 h'(x ) 是统一的并且独立于 c,因此 c h'(x) 也是如此。
OK,本次练习的重点终于来了。 The Query 的探测序列将是 c、2c、3c 等。哪些键散列到(例如)2c?它们是满足方程
的xh(x) = c h'(x) = 2c
我们从中得出
h'(x) = 2,
即,其首选插槽在线性探测顺序中紧随查询之后的键。从 2 推广到 i,我们得出结论,对于 h' 的查询,错误的线性探测序列变成了对于 h 的查询,QED 的错误“双重”散列探测序列。