• 在影子里验证比较对象:随机指纹和哈希的数学原理


    程序里经常会遇到一种看似朴素、实际很贵的问题:两个东西是不是一样?

    它可能是两个字符串、两个文件、两个集合、一段文本里的子串,或者三个矩阵是否满足 AB=CA \cdot B = C。如果对象本身很大,尤其是在对象需要跨机器通信、被反复比较、以流式方式到达,或者比较结果只需要高概率可靠时,直接逐个字节比较往往不是最想要的方案。

    随机指纹法处理的正是这类问题。它不试图完整保留对象,而是把对象映射成一个短得多的值,然后比较这些短值。这个短值可能丢失信息,所以它不是压缩意义上的可逆编码,而更像一个可快速比较的影子。

    影子不能替代实体,但如果影子不同,实体一定不同;如果影子相同,实体也许相同,也许只是碰巧投在了同一个地方。随机性的作用,是让这种“碰巧”变得可控。

    指纹与哈希

    指纹和哈希函数有相似之处。设对象集合为 O\mathcal{O},我们从一组映射 H\mathcal{H} 中随机选择一个函数 ff,然后用 f(O)f(O) 作为对象 OO 的指纹。对于两个对象 O1O_1O2O_2

    • 如果 O1=O2O_1 = O_2,那么必然有 f(O1)=f(O2)f(O_1) = f(O_2)
    • 如果 O1O2O_1 \ne O_2,那么大多数 fHf \in \mathcal{H} 都能让 f(O1)f(O2)f(O_1) \ne f(O_2)

    第二点是关键。对于输出空间远小于输入空间的固定哈希函数,理论上总会存在碰撞;在非密码学场景下,如果对手知道函数,也可能针对性地寻找碰撞。随机选择函数后,对手很难提前知道我们会拿哪一把尺子去量对象。随机化并不是为了神秘,而是为了把最坏情况变成概率问题。

    在算法分析里,能区分 O1O_1O2O_2 的函数常被称为 witness,也就是“见证者”。如果某个 hHh \in \mathcal{H} 满足 h(O1)h(O2)h(O_1) \ne h(O_2),那么 hh 就见证了 O1O2O_1 \ne O_2。指纹法的设计目标,就是让见证者足够多,同时让每个指纹足够短。

    这两个目标天然冲突。指纹越短,比较越快,通信越省,但碰撞空间也更大;指纹越长,错误概率更低,却离“直接发送原对象”越来越近。许多随机化算法的味道,就藏在这个 trade-off 里。

    用随机素数给二进制串取指纹

    一切复杂对象都可以映射成二进制数,而一切二进制串又可以看成整数。给定 x{0,1}nx \in \{0,1\}^n,令 Number(x)\mathrm{Number}(x) 表示它对应的二进制整数。例如 101121011_2 对应十进制的 1111

    随机选择一个素数 pp,定义:

    Fingerp(x)=Number(x)modp\mathrm{Finger}_p(x) = \mathrm{Number}(x) \bmod p

    这个定义非常朴素,但足够有力。如果有 xxyy 不同,那么 Number(x)Number(y)\mathrm{Number}(x)-\mathrm{Number}(y) 是一个非零整数。若它们在模 pp 下指纹相等,则有:

    Number(x)Number(y)(modp)\mathrm{Number}(x) \equiv \mathrm{Number}(y) \pmod p

    也就是:

    p(Number(x)Number(y))p \mid \left(\mathrm{Number}(x)-\mathrm{Number}(y)\right)

    所以,错误只会发生在随机选中的 pp 刚好是这个差值的某个素因子时。一个非零整数的素因子数量有限,而可选素数集合可以通过扩大范围来增加。

    于是,只要 pp 的可选范围越大,坏素数所占比例就会越低。这就是模素数指纹法的基本逻辑:不是保证没有碰撞,而是让碰撞需要满足一个很具体、很稀疏的条件。

    相等判断

    考虑一个通信问题。机器 RI 有一个字符串 x{0,1}nx \in \{0,1\}^n,机器 RII 有一个集合 U={u1,u2,,uk}U=\{u_1,u_2,\ldots,u_k\},其中每个 uiu_i 也是长度为 nn 的二进制串。RI 不知道 UU,RII 不知道 xx,它们要判断 xUx \in U

    最直接的方法是 RI 把整个 xx 发过去,通信量是 nn bit。随机指纹法可以把通信量降到 O(logn)O(\log n),代价是引入单边错误。

    RI:
    choose random prime p from PRIME(n^2)
    s = Number(x) mod p
    send (p, s) to RII
    RII:
    for each u_i in U:
    q_i = Number(u_i) mod p
    if s is equal to some q_i:
    output "x may be in U"
    else:
    output "x is not in U"

    这里 PRIME(n2)\mathrm{PRIME}(n^2) 表示不超过 n2n^2 的素数集合。因为 pn2p \le n^2,发送 pp 需要约 2logn2\log n bit;又因为 s<ps < p,发送 ss 也需要约 2logn2\log n bit。所以总通信复杂度大约是 4logn4\log n,也就是 O(logn)O(\log n)

    这个协议的正确性方向很清楚。如果 xUx \in U,设 x=ujx=u_j,那么对任何 pp 都有 Fingerp(x)=Fingerp(uj)\mathrm{Finger}_p(x)=\mathrm{Finger}_p(u_j),协议一定会输出“在集合中”。

    错误只可能发生在 xUx \notin U 时。此时某个 uiu_i 可能满足:

    Number(x)Number(ui)(modp)\mathrm{Number}(x) \equiv \mathrm{Number}(u_i) \pmod p

    也就是一次碰撞。

    PRIME(n2)\mathrm{PRIME}(n^2) 的质数选取范围下,错误的概率具体有多大呢?设 xUx\notin U。对每个 uiu_i,令

    Δi=Number(x)Number(ui)\Delta_i = \left|\mathrm{Number}(x)-\mathrm{Number}(u_i)\right|

    因为 xuix\ne u_i,所以 Δi0\Delta_i\ne 0;又因为二者都是 nn 位二进制串,所以 Δi<2n\Delta_i < 2^n。如果 xxuiu_i 在模 pp 下发生碰撞,那么 pΔi p\mid \Delta_i

    而数论知识还告诉我们,一个小于 2n2^n 的非零整数最多有 nn 个不同的素因子。所以,对一个固定的 uiu_i,能造成碰撞的坏素数最多有 nn 个。对所有 kk 个元素,坏素数总数最多有 knkn 个。

    另一方面,不超过 n2n^2 的素数个数约为

    π(n2)n2ln(n2)=n22lnn\pi(n^2) \sim \frac{n^2}{\ln(n^2)} = \frac{n^2}{2\ln n}

    所以随机选中坏素数的概率最多约为

    knπ(n2)knn2/(2lnn)=2klnnn\frac{kn}{\pi(n^2)} \approx \frac{kn}{n^2/(2\ln n)} = \frac{2k\ln n}{n}

    这符合直觉:元素数量 kk 越多,越容易冲突;而当质数范围取 n2n^2 时,规模越大越不容易冲突。当

    kn4lnnk \le \frac{n}{4\ln n}

    时,错误概率就至多约为 12\frac 12

    为什么 12\frac 12 这个节点这么重要呢?它重要是因为,一旦单次错误概率被压到某个小于 11 的常数,就可以通过独立重复把错误概率指数级压低。重复 tt 轮,每一轮都重新选择独立的随机素数,并且只有每一轮都发生碰撞才会误判,那么错误概率最多变成:

    2t2^{-t}

    随着次数增加,错误概率会迅速降低;而只要问题规模较大,kk 次实验发送的 4klogn4k\log n bit 仍然远小于 nn 个全部比特。

    这种协议叫 one-sided-error Monte Carlo protocol。它会很快给出答案,但答案只有一个方向是绝对可靠的:如果它说 xUx \notin U,那一定是真的;如果它说 xUx \in U,则可能是碰撞造成的假阳性。

    集合不交:碰撞机会按成对数量累积

    上面可以问题可以推广。RI 有 V={v1,v2,,vl}V=\{v_1,v_2,\ldots,v_l\},RII 有 U={u1,u2,,uk}U=\{u_1,u_2,\ldots,u_k\},目标是判断 UVU \cap V 是否为空。

    那么思路仍然没有变化,RI 选择一个随机素数 pp,计算自己每个元素的指纹并发送,RII 也计算自己每个元素的指纹,并和 RI 发送的指纹比较。直接发送整个集合需要 O(ln)O(ln) bit。使用随机指纹时,RI 只需要发送一次 pp,再发送 ll 个元素的模 pp 指纹,通信量变为 O(llogn)O(l\log n) bit。

    如果 UVU \cap V \ne \emptyset,真实公共元素一定会产生相同指纹,所以不会漏报。若 UV=U \cap V = \emptyset,协议可能因为某一对 (ui,vj)(u_i,v_j) 的碰撞而误报“有交集”。

    这里的错误风险不再只和 kk 有关,而是和 klk \cdot l 有关,因为每一对元素都可能成为碰撞源。若 UV=U\cap V=\emptyset,那么每一对 (ui,vj)(u_i,v_j) 都是不相等的。对固定一对元素,碰撞要求:

    p(Number(ui)Number(vj))p\mid \left(\mathrm{Number}(u_i)-\mathrm{Number}(v_j)\right)

    这个非零差值仍然小于 2n2^n,所以最多有 nn 个不同素因子可能造成碰撞。

    现在这样的元素对一共有 klkl 个,因此所有可能导致误报的坏素数总数最多是 klnkln。随机从 PRIME(n2)\mathrm{PRIME}(n^2) 中选 pp,错误概率最多约为:

    klnπ(n2)2kllnnn\frac{kln}{\pi(n^2)} \approx \frac{2kl\ln n}{n}

    因此,只要

    UV=o(nlnn)|U| \cdot |V| = o\left(\frac{n}{\ln n}\right)

    错误概率就会随着 nn 增大而趋近于 00。如果希望把单次错误概率压到大约不超过 1/21/2,可以要求:

    UVn4lnn|U| \cdot |V| \le \frac{n}{4\ln n}

    在对象对数太多时,碰撞机会会累积。想继续压低错误概率,要么增加指纹长度,要么重复多轮独立随机试验。这里体现的是一个非常常见的原则:单个比较的碰撞概率再低,也会被大量比较通过 union bound 放大。

    从 Monte Carlo 到 Las Vegas

    上面的协议是 Monte Carlo 型的:它运行很快,但在某个方向上允许小概率错误。另一种常见改造是 Las Vegas 型:先用随机指纹做便宜的初筛,如果指纹已经不同,就直接给出确定的否定答案;如果指纹相同,则回到原对象上做一次完整验证。

    这样一来,算法不再出错。因为所有可能出错的“指纹相同”情形,都会被后续的完整验证拦住。

    可以把期望成本写成一个简单公式。设随机指纹阶段的成本为 TfT_f,完整验证的成本为 TvT_v,假阳性概率为 ε\varepsilon。在真实答案是否定的情况下,只有发生碰撞时才会进入完整验证,所以期望成本为:

    Tf+εTvT_f+\varepsilon T_v

    例如成员判断中,如果 xUx\notin U,初筛大多数时候会直接发现“不在集合中”。只有当 xx 和某个 uiu_i 的指纹碰撞时,才需要让 RI 发送完整的 xx,或者用其他确定性方式检查。因此期望通信量可以写成:

    O(logn)+εO(n)O(\log n)+\varepsilon O(n)

    如果通过扩大素数范围或重复独立试验把 ε\varepsilon 压得足够低,那么第二项就会变小。

    但 Las Vegas 改造并不是免费午餐。在真实答案是肯定的情况占多数下,例如 xUx\in U,真实相等的元素必然产生相同指纹,于是不管如果选取质数,完整验证通常一定会发生。这样也就退化成了全部比较。

    Monte Carlo 用小概率错误换取始终很快;Las Vegas 消除了错误,但把部分情形的完整成本放进了期望复杂度里。适合哪一种,取决于应用更不能接受错误,还是更不能接受偶尔变慢。

    Freivalds 算法:不乘矩阵也能验证矩阵乘法

    再看一个例子。给定三个 n×nn\times n 矩阵 A,B,CA,B,C,要判断 AB=CA\cdot B=C。如果直接计算 ABA\cdot B 需要 O(n3)O(n^3) 时间。而 Freivalds 算法把验证降到了 O(n2)O(n^2)

    随机选择一个向量 α{0,1}n\alpha \in \{0,1\}^n,然后计算:

    β=A(Bα),γ=Cα\beta = A\cdot(B\cdot \alpha), \qquad \gamma = C\cdot \alpha

    如果 βγ\beta \ne \gamma,输出 ABCA\cdot B \ne C;如果 β=γ\beta=\gamma,接受 AB=CA\cdot B=C,或者说认为它们“可能相等”。

    这里就利用了“不计算,只验证”的思想。直接计算 ABA\cdot B 是复杂的。但是,矩阵乘向量只需要 O(n2)O(n^2),所以 BαB\cdot \alphaA(Bα)A\cdot(B\cdot\alpha)CαC\cdot\alpha 都只需要 O(n2)O(n^2)。整个过程不需要显式算出完整的 ABA\cdot B

    • 如果 AB=CA\cdot B=C。那么对任何 α\alpha 都有 ABα=CαA\cdot B\cdot\alpha=C\cdot\alpha,算法不会误判为不相等。

    • ABCA\cdot B \ne C。令 D=ABCD=A\cdot B-C,则 D0D\ne 0。我们希望随机选出的 α\alphaDα0D\alpha \ne 0

    为什么至少一半的 α\alpha 能发现错误?因为 DD 至少有一行非零,记为 d=(d1,,dn)d=(d_1,\ldots,d_n)。存在某个 dj0d_j\ne 0。固定 α\alpha 中除 αj\alpha_j 外的其他坐标后,表达式 dαd\cdot\alpha 可以看作关于 αj\alpha_j 的一次式:

    djαj+cd_j\alpha_j + c

    其中 cc 由其他坐标决定。由于 dj0d_j \ne 0,在 αj0,1\alpha_j \in {0,1} 的两个选择中,不可能两个都让它等于 00。如果 c=0c=0,那么 αj=1\alpha_j=1 时值为 dj0d_j\ne 0;如果 c0c\ne 0,也至多只有一个选择会把它抵消为 00

    于是,在所有 2n2^n 个 0-1 向量中,至少 2n12^{n-1} 个能让这一行的内积非零,从而让 Dα0D\alpha\ne 0

    所以,一次运行的错误概率最多是 1/21/2。独立重复 tt 次,并且只有每次都通过才接受 AB=CA\cdot B=C,错误概率最多降为 2t2^{-t},复杂度变为 O(tn2)O(tn^2)。当 tt 是几十这样的常数时,这在工程上已经非常强。

    这个算法的美感在于,它并没有计算要验证的对象本身,而是随机观察它在某个方向上的投影。若两个矩阵真的不同,它们在至少一半的 0-1 方向上会表现出差异。

    更多例子

    很多看似不同的算法,都在使用同一个原则:不直接比较对象本身,而是比较它们在某个随机选择下的影子。

    Rabin-Karp 字符串匹配

    要在长文本 TT 中寻找模式串 PP,朴素方法会把 PPTT 的每个等长窗口逐字符比较。Rabin-Karp 则先比较窗口的指纹。

    如果指纹不同,窗口一定不匹配;如果指纹相同,再逐字符确认。由于相邻窗口只差一个字符,指纹还可以滚动更新,不必每次从头计算。这就是随机指纹在字符串算法中的典型用法:它不直接证明两个字符串相等,而是快速排除大量“不可能相等”的候选。

    参考:字符串问题的歪门奇宝:进制哈希

    多项式恒等测试

    给定两个多项式 P(x)P(x)Q(x)Q(x),要判断它们是否完全相同。直接展开可能很贵,但可以随机选一个点 rr,比较:

    P(r)Q(r)P(r) \quad \text{和} \quad Q(r)

    如果 P=QP=Q,那么对所有 rr 都相等。若 PQP\ne Q,则 PQP-Q 是一个非零多项式。一个次数为 dd 的非零多项式最多只有 dd 个根。因此,只要随机点来自足够大的集合,误判概率就不高。

    集合测试

    集合也可以被编码成多项式。给定集合 S=s1,,smS={s_1,\ldots,s_m},定义:

    PS(z)=i=1m(zsi)P_S(z)=\prod_{i=1}^m(z-s_i)

    如果两个集合 SSTT 相等,那么 PS=PTP_S=P_T。如果集合不同,则两个多项式不同。随机选择一个点 rr,比较 PS(r)P_S(r)PT(r)P_T(r),就能用一个短值随机验证两个集合是否相等。

    这些例子的共同点是:对象不同的时候,能让它们“看起来相同”的随机选择只占少数。随机性不是为了制造不确定,而是为了把潜在碰撞压缩到一个可计算、可放大的概率界里。

    总结

    随机指纹适合“否定容易、肯定昂贵”的场景。两个指纹不同,通常可以立刻否定;两个指纹相同,则要看算法类型决定是否接受。成员判断和矩阵验证中的协议选择接受小概率错误,因此是 Monte Carlo;如果指纹相同后回查原文,就变成 Las Vegas。

    它不适合无条件要求零错误、且又不能回查原对象的场景。比如安全认证里不能把普通随机哈希当作密码学承诺;面对主动攻击者时,也不能只靠短指纹断言两个对象完全相同。这里讨论的是算法意义上的随机化验证,不是密码学安全。

    实现层面还有几个现实细节。随机源要足够独立,否则重复试验未必能按 2t2^{-t} 衰减;模运算要注意溢出和负数;当对象数量很大时,单个碰撞概率再小也会被 union bound 放大。很多“理论上概率很低”的事件,在海量系统里会变成迟早发生的事件。

  • 相关阅读:
    Spring Cloud Consul
    ClickHouse监控及备份
    Vscode GDB 查看内存的值
    [附源码]Java计算机毕业设计SSM东北鹿产品售卖网站
    .net-----集合和数据结构
    JVM总结全
    【C ++基础】迭代器(iterator)在string里面的简单使用
    HK32F030MF4P6 EXTI外部中断例程
    【PAT(甲级)】1067 Sort with Swap(0, i)(附解题思路)
    并发编程之定时任务&定时线程池
  • 原文地址:https://www.cnblogs.com/ofnoname/p/20174144