算法随笔
首发于算法随笔

hashing的一些正确姿势

虽然在实践中很多“随便写的”玄学hash函数常常有着不错的效果,但缺乏理论证明的hash函数终究存在缺憾,使用起来也让人提心吊胆。在这篇文章里,我们会从一些常见对象的hash函数出发,介绍hashing的几个正确姿势,最后证明一种对于树的hash函数的正确性。本文是对这个回答的一点扩展:

二叉树怎么判断同构?www.zhihu.com图标

整数的hashing

整数可以说是最简单的对象了,研究整数的hash函数可以为之后的应用打下基础。先介绍hash函数的一些定义:

假设我们希望把 [U]\triangleq\{0,\dots,U-1\} 范围内的整数通过一个hash函数 h 映射到 [m] 范围内的数。令 \mathcal{H} 表示一个hash family,我们从 \mathcal{H} 中随机选择 h

然后定义一些hash函数的性质:

Universal: \forall x,y\in [U] where x\neq y , \mathrm{Pr}_{h\in \mathcal{H}}[h(x)=h(y)]\leq \frac{1}{m}

Strongly Universal: \forall x,y\in [U] where x\neq y , \alpha,\beta\in [m] ,\mathrm{Pr}_{h\in \mathcal{H}}[h(x)=\alpha\wedge h(y)=\beta]= \frac{1}{m^2}

注:这里可将要求放宽为 \mathrm{Pr}_{h\in \mathcal{H}}[h(x)=\alpha\wedge h(y)=\beta]\leq O(\frac{1}{m^2})

直观上来说,universal的性质保证了对于任意两个不同的元素 xyh(x)h(y) 发生碰撞的概率都很小。可以将这个定义稍稍推广一下:

d-universal: \forall x,y\in [U] where x\neq y , \mathrm{Pr}_{h\in \mathcal{H}}[h(x)=h(y)]\leq \frac{d}{m}

我很喜爱的一个hash函数是 h(x)=x\bmod p ,其中模数 p[\frac{m}{2},m) 内随机选择的质数。虽然它在实践中并不那么常用,但它形式简单,也比较容易证明一些不错的性质。可以证明它是 O(\log U) -universal的,参见我的这个回答:

Hash时取模一定要模质数吗?www.zhihu.com图标

注意在理论上来说hash函数 h 是随机选的,在 h(x)=x\bmod p 这个例子中体现为随机选择一个模数 p 。分析随机算法的时候一个常见设定是输入是adversary选择的最坏情况,然后你自己产生一些随机数来跑算法。随机选择 h 的目的就是让任何adversary都无法针对你,你只能受到随机数的制裁(对于任何输入数据,出错概率都非常小)。

在实践中大家可能会将模数固定为1000000007或者998244353,这实际上是违反上述这点的,如果出题人猜到了你会使用的模数,就可以让你出错了。考虑到随机生成一个素数需要一点编码时间(科学的 O(\mathrm{poly}\log m) 的做法是重复随机生成一个整数再用素性判定,直到找到素数),我的一个小建议是大家可以随便在某个范围内选择一个“本命素数”并预先背下来,以达到随机的效果。当然对于可以先看见你的代码再hack的比赛这就不适用了。

一个strongly universal的hash函数的例子为 h_{a,b}=((ax+b)\bmod p)\bmod m,其中 p\geq U 的素数,hash family \mathcal{H}=\{h_{a,b}|a,b\in [p],a\neq 0\} 。它会在之后的部分被用到。

字符串的hashing

首先字符串和整数序列(数组)是类似的,所以不妨讨论字符串的情况。对于一个长度为 \ell 的字符串 (a_1,\dots,a_\ell) ,其中 \forall i,a_i\in [D] ,可以将它映射到整数: (a_1,\dots,a_\ell)\mapsto \sum_{i=1}^\ell (a_i+1)\cdot (D+1)^{i-1} ,注意这是一个单射。采用 D+1 进制而不是 D 进制的目的是区分不同长度的字符串(第 i 位不存在则视为 0 )。

接下来就可以直接使用整数的hash函数了,一个办法是用 h((a_1,\dots,a_\ell)) =(\sum_{i=1}^\ell (a_i+1)\cdot (D+1)^{i-1})\bmod p ,其中模数 p[\frac{m}{2},m) 内随机选择的质数。虽然看起来映射到的整数非常大,但沿用前述证明可知取 m=\mathrm{poly}(\log D,\ell) 即可。当然因为映射到的整数很大,其他一些对于整数的hash函数可能因为计算复杂度的关系就不适用了。

注意这里使用任意大于 D 的进制都可以,关键是模数 p 需要选择一个随机的质数。一个实践中常用的hash函数BKDRHash可以说同时与这两点背道而驰,它的定义为:

h((a_1,\dots,a_\ell))=(\sum_{i=1}^\ell a_i\cdot 31^{i-1})\bmod 2^{32} ,where D\leq 26

注:这里为了和之前的形式统一,我把字符串倒过来了,不影响分析。

我看到的很多解释是说选择进制31为一个素数,而模数选择 2^{32} 从而利用自然溢出加快计算速度。事实上在我们的理论框架里进制是否为素数并不重要,而固定的非质数的模数很容易被adversary针对,特别是卡自然溢出hash的办法已经众所周知了(无论选择什么进制)。当然这并不是说BKDRHash在实践中就是不好的,它仍然受人喜爱。

还有一个类似的hash函数也是对的:h((a_1,\dots,a_\ell))=(\sum_{i=1}^\ell (a_i+1)\cdot X^{i-1})\bmod p,其中 X[p] 中随机选择的整数, p 为任意大小为 \mathrm{poly}(\ell) 的素数。证明利用了有限域 \mathbb{Z}_p 上的度数为 \ell 的多项式最多只有 \ell 个根,所以碰撞概率不超过 \frac{\ell}{p} 。但是BKDRHash使用的 \mathbb{Z}_{2^{32}} 并不满足这一条件。

此外,还可以用以下定理[1]将对于整数的strongly universal hash函数转化为对于字符串的hash函数:

Lemma. 对于一个strongly universal hash family \mathcal{H}:[D]\rightarrow[m] ,若 m 为2的幂,令 (h_1,\dots,h_\ell)((a_1,\dots,a_\ell)) =h_1(a_1)\oplus\dots\oplus h_\ell(a_\ell) ,则 \mathcal{H}^{\ell}=\{(h_1,\dots,h_\ell)|h_i\in \mathcal{H}\}: [D]\rightarrow[m] 也是strongly universal的。

另外在wiki上可以找到更多避免取模的hash函数。

集合的hashing

考虑计算一个multiset A=\{a_1,\dots,a_d\} 的hash值,其中 \forall i,a_i\in [D] 。一个办法是将这个multiset存储到一个包含 \Theta(d) 个桶的哈希表里(注意这个哈希表会使用自己的hash函数,我们不妨对于所有大小在 [2^i,2^{i+1}) 范围内的multiset使用同一个hash函数 h_i' ),对每个桶将里面的 a_i 排序(有hash函数可以保证桶大小的平方的期望为 O(1) ,参见perfect hashing的证明,所以时间复杂度没问题),然后按某个固定顺序遍历哈希表,得到一个序列 A'=(a_{\pi(1)},\dots,a_{\pi(d)}) ,其中在固定哈希表的hash函数的情况下, \pi=\pi(A) 为一个由 A 决定的排列。可以看出这是一个multiset到序列的单射,所以可以代替原先的排序步骤。接下来只需对这个序列使用字符串的hash函数就行了。

注:我还尝试过映射 A\mapsto\prod_{i=1}^\ell\mathrm{prime}[a_i] ,其中 \mathrm{prime}[i] 为第 i 个素数,这也是一个单射,但是看起来预处理很慢的样子。

我们可以用unordered_set比较方便地实现这个hash函数,代码如下:

(算了,可能手写哈希表更简单,就不写了)

树的hashing

之前我在文章开头提到的判断树同构的回答中已经给出了两个将树序列化的确定性的 O(n) 算法,接下来只需在序列上做字符串hashing就行,但是它们看起来实现都比较复杂。这里我试着修改一下常见的那种 O(n\log n) 的基于排序求最小表示的树hashing算法,实现较为简单,并且同样可以做到 O(n) 的复杂度。

假设一个点的度数为 d ,令 a_i 表示它的第 i 个子树的hash值。我们想在 O(d) 的时间内求出以这个点为根的子树的hash值,即计算一个multiset A=\{a_1,\dots,a_d\} 的hash值,其中 \forall i,a_i\in \{0,\dots,D-1\}D 为hash函数的值域大小。容易发现可以使用对于集合的hashing来实现这一步。

这里只需注意一个细节:所有树高为 i 的点都需要使用同一个hash函数 h_i\in \mathcal{H} ,以保证相同的高度为 i 的子树被映射到相同的hash值。但是我们的证明需要对于每一层使用不同的hash函数 h_i :这是为了保证第 i 层的hash函数计算与前 i-1 层的计算结果独立。所以我们一共需要 O(n) 个hash函数。所幸前文介绍的后两种字符串的hash函数可以在 O(1) 的时间内随机产生,所以总复杂度还是 O(n) 的。

最后分析整个算法的错误率。首先同构的树的hash值必然相同,而对于任意两棵不同构的树,若它们高度不同则直接可以判断,否则取hash函数的值域 m=O(n^3),并对 O(n^2) 对高度相同但不同构的子树用union bound可知,存在两棵不同构的树但hash值相同的概率为 O(n^2)\cdot O(\frac{1}{m})=O(\frac{1}{n}) (取更大的 m=\mathrm{poly(n)} ,或者把整个算法重复常数次就可以得到任意 \frac{1}{\mathrm{poly}(n)} 的错误率),所以算法w.h.p.正确。

注:即使对于那个O(n\log n) 的基于排序的树hashing算法,如果全局只使用同一个hash函数的话(实践中大家都这样做),因为存在一些非常微妙的上文提到的独立性的问题,正确性我还暂时没有证明。直觉上会有一些证明技巧处理这种情况,但一下子还没有想到。

一些例题:

[BJOI2015]树的同构

TREEISO - Tree Isomorphism

图的hashing

目前图同构(graph isomorphism)是否在P里还是open的,已知最好的算法有着quasi-polynomial的复杂度[2,3]。所以试图对图hash是不太行的。(更新:或者说,大部分人不相信现有的那些hash函数或标号算法能解决图同构问题,但也不是毫无希望。具体见下文。)另外可以把一般图的同构问题归约到对于有向无环图的同构判断,所以对有向无环图hash也是不太行的。不过我以前居然还做过图同构的题,例如:

51nod 1676 无向图同构

关于图的hash函数,先来看[4]中提到的办法:

(这篇集训队论文还是我以前学习树hashing的入门资料... 现在看起来有一点偏差。)

如果没有 g(v) 那一项的话,这个hash函数是无法区分三元环和四元环的情况的,或者说更一般的反例是k-regular graph。据我所知,很多其他的hash函数也会有这个问题。不少类似的hash函数可以被归类到称为k-dimensional Weisfeiler-Lehman method的一类算法(前面的例子相当于k=1),而文章[5]指出了让这整类算法失效的一些图。(之前我这一段的描述有点偏差,修改了一下。)

g(v) 的设计还是比较有意思的,反例就没那么简单了,不过仍然是对Weisfeiler-Lehman method的一点微调。文章[6]的一段描述摘录如下:

Indeed, following the work of [5], the question of whether the WL method or some minor variation might solve GI has (to the knowledge of the author) been considered closed.

事实上[4]中的这个hash函数看起来是在“deep stabilization”[7]的框架下的,原文不太好读,但目测符合[5]和[6]在previous works里的描述。这个框架和Weisfeiler-Lehman method的能力很相似,所以之前的结果也说明了它不太行。

最后说句题外话,我真诚地建议民科们去做图同构问题instead of黎曼假设/哥德巴赫猜想/P=?NP。首先图同构这个问题是足够有影响力的,只是对大众的宣传工作做得略逊一筹。另外有一个好处是算法相对来说容易设计,更大的难点在于证明/证伪算法的正确性,像Weisfeiler-Lehman method是不是work的问题可是花了几十年才把坑填上的。所以可能你花十分钟瞎写一个算法,理论计算机科学家们花一周都找不到反例,实践中还特别work(average linear time的算法不要太多,难点在于少数worst-case的graph,它们长什么样还不好构造)。所以特别适合糊弄general public,不容易被打脸。

References

[1] Carter J L, Wegman M N. Universal classes of hash functions[J]. Journal of computer and system sciences, 1979, 18(2): 143-154.

[2] Babai L. Graph isomorphism in quasipolynomial time[C]//Proceedings of the forty-eighth annual ACM symposium on Theory of Computing. 2016: 684-697.

[3] Babai, László, Graph isomorphism update.

[4] 杨弋:《Hash在信息学竞赛中的一类应用》,OI国家集训队2007论文集。

[5] Cai J Y, Fürer M, Immerman N. An optimal lower bound on the number of variables for graph identification[J]. Combinatorica, 1992, 12(4): 389-410.

[6] Douglas B L. The weisfeiler-lehman method and graph isomorphism testing[J]. arXiv preprint arXiv:1101.5211, 2011.

[7] B. Weisfeiler, editor. On construction and identification of graphs. Lecture Notes in Mathematics, Vol. 558. Springer-Verlag, Berlin, 1976. With contributions by A. Lehman, G. M. Adelson-Velsky, V. Arlazarov, I. Faragev, A. Uskov, I. Zuev, M. Rosenfeld and B. Weisfeiler.

编辑于 2020-03-24

文章被以下专栏收录