【算法讲解】杂项算法——异或哈希(xor-hashing)

前言

异或哈希是个很神奇的算法,利用了异或操作的特殊性和哈希降低冲突的原理,可以用于快速找到一个组合是否出现、序列中的数是否出现了k次

异或(xor)

异或是计算机语言中的一个运算符,代码中用^表示,数学符号用 ⊕ \oplus 表示,含义是对数字的二进制表示按位相加并对2取余,举个例子 3 ⊕ 5 = ( 011 ) 2 ⊕ ( 101 ) 2 = ( 110 ) 2 = 7 3\oplus5=(011)_2\oplus (101)_2=(110)_2=7 35=(011)2(101)2=(110)2=7
异或运算符合交换律(类似加法交换律、乘法交换律),既 A ⊕ B = B ⊕ A A\oplus B = B \oplus A AB=BA
异或运算相比其他运算,有一个很重要的独有特性:
A ⊕ 0 = A A ⊕ A = 0 A\oplus 0=A\\ A\oplus A=0 A0=AAA=0
根据上述这个特性,我们可以推演得到重要特性:当一个数进行偶数次异或运算后,其值为0,当一个数进行奇数次操作后,其结果为自身

我们可以根据这个特性快速解决这个题目 leetcode: 一个数组中除了一个元素外其他元素都出现了两次(偶数次),找出这个元素

int xor = 0;
for (auto item: a) {
  xor ^= item;
}
cout << xor << endl;

哈希(hashing)

哈希(中文称之为散列)算法是一种用于快速定位资源的算法。
对于键值对(key,value),如果key是数字,我们可以直接通过数组的下标做key来定位数字所对应的value
但如果value是很大的数字(没办法开下那么大的数组),或者是string类型,亦或是其他乱七八糟的类型的组合,我们就没办法用下标来定位了
这时候就需要用将key哈希成一个数字

在c++中,可以直接用 unordered_map 作为 hash 表,但是对于复杂的元素,需要重载 == 运算符以及 hash 函数才行。对于更复杂的数组,就不太适用了

应用

分别介绍完异或和哈希的背景后,我们来看看两者结合有哪些应用

组合问题

判断一个序列是否是另一个序列的排列组合

先来看一道题:

给一个长度为 n n n 的数组 a a a,找到所有的连续子序列 [ l , r ] [l,r] [l,r] 满足:正好包含 1 1 1 r − l + 1 r-l+1 rl+1(既 1 1 1 r − l + 1 r-l+1 rl+1 的排列组合)

最朴素的做法:

1 1 1 n n n 遍历 l l l,从 i i i n n n 遍历 r r r,判断 [ l , r ] [l,r] [l,r] 是否包含 1 1 1 r − l + 1 r-l+1 rl+1

这种做法的复杂度至少是 O ( n 3 ) O(n^3) O(n3)

问题抽象

上述算法中相对复杂的步骤为:判断 [ l , r ] [l,r] [l,r] 是否包含 1 1 1 r − l + 1 r-l+1 rl+1
既判断 [ a l , a l + 1 , ⋯   , a r ] [a_l, a_l+1,\cdots,a_r] [al,al+1,,ar] 是否是 [ 1 , 2 , ⋯   , r − l + 1 ] [1,2,\cdots, r-l+1] [1,2,,rl+1] 的排列组合
既问题转化成:判断两个数组的排列组合是否相同。

用异或求解

根据异或的交换律可知:一个数组的任何排列组合,它的异或结果都相同
既:
1 ⊕ 2 = 2 ⊕ 1 1 ⊕ 2 ⊕ 3 = 1 ⊕ 3 ⊕ 2 = 2 ⊕ 1 ⊕ 3 = 2 ⊕ 3 ⊕ 1 = 3 ⊕ 1 ⊕ 2 = 3 ⊕ 2 ⊕ 1 \begin{aligned} 1\oplus 2&=2\oplus 1\\ 1\oplus 2\oplus 3&=1\oplus3\oplus2\\ &=2\oplus1\oplus3\\ &=2\oplus3\oplus1\\ &=3\oplus1\oplus2\\ &=3\oplus2\oplus1 \end{aligned} 12123=21=132=213=231=312=321
看起来好像可以根据这个来判断两个数组是否是同一个排列组合
其实有很多其他的组合异或后也是相同的结果,比如 1 ⊕ 2 = 5 ⊕ 6 1\oplus2=5\oplus6 12=56 1 ⊕ 2 ⊕ 3 = 4 ⊕ 8 ⊕ 12 1\oplus2\oplus3=4\oplus8\oplus12 123=4812
本质原因是组合数一共有 n ⋅ ( n − 1 ) 2 \frac{n\cdot(n-1)}{2} 2n(n1),而异或的结果集 ≈ n \approx n n(实际上是比 n n n大的最小 2 k 2^k 2k),所以一定会发生冲突
有什么办法可以解决呢?

用哈希降低冲突

为了解决冲突我们可以利用哈希将异或的结果集扩大:将数字 i i i 哈希成 64 位无符号整数
那么结果集就扩大为了 2 64 2^{64} 264,和 n ⋅ ( n − 1 ) 2 \frac{n\cdot(n-1)}{2} 2n(n1)相比,冲突的概率就是 n ⋅ ( n − 1 ) 2 65 \frac{n\cdot(n-1)}{2^{65}} 265n(n1),微乎其微

代码

我们将 i i i 哈希成 64 位无符号整数,记为 c o d e i code_i codei,后续的异或操作就不是直接用 i i i 来运算了,而是用 c o d e i code_i codei 代替
c o d e 1 code_1 code1 c o d e n code_n coden 的异或结果记为 X O R _ N n XOR\_N_n XOR_Nn,可推得 X O R _ N n = X O R _ N n − 1 ⊕ c o d e n XOR\_N_n=XOR\_N_{n-1}\oplus code_n XOR_Nn=XOR_Nn1coden
c o d e a 1 code_{a_1} codea1 c o d e a n code_{a_n} codean 的异或结果记为 X O R n XOR_n XORn,可推得 X O R n = X O R n − 1 ⊕ c o d e a n XOR_n=XOR_{n-1}\oplus code_{a_n} XORn=XORn1codean
连续子序列 [ l , r ] [l,r] [l,r]的结果就是 X O R r ⊕ X O R l − 1 XOR_r\oplus XOR_{l-1} XORrXORl1,如果他的结果等于 X O R _ N r − l + 1 XOR\_N_{r-l+1} XOR_Nrl+1,那么就是符合条件的序列

mt19937_64 rnd(time(0));
typedef uint64_t hash_t;

const int MAXN = 3e5+1;
hash_t code[MAXN];
hash_t xorn[MAXN];
hash_t Xor[MAXN];
int a[MAXN];
int n;
void init() {
  for (int i = 1; i <= n; i ++) {
    code[i] = rnd();
    xorn[i] = xorn[i-1] ^ code[i];
  }
}
int solve() {
  init();
  for (int i = 1; i <= n; i ++) {
    xor[i] = xor[i - 1] ^ code[ a[i] ];
  }
  int res = 0;
  for (int l = 1; l <= n; l ++) {
    for (int r = l; r <= n; r ++) {
      if (xor[r] ^ xor[l - 1] = xorn[r - l + 1]) {
        res++;
      }
    }
  }
  return res;
}

自此,将算法的复杂度从 O ( n 3 ) O(n^3) O(n3) 优化成了 O ( n 2 ) O(n^2) O(n2),至于如何优化到 O ( n ) O(n) O(n),不是本篇介绍的主要内容,大家可以自行思考

上面代码大家会看到一个很神奇的内容 mt19937_64 rnd(time(0)),这是 c++ 自带的梅森旋转(Mersenne Twister)伪随机数,可以随机生成 64 位整数,随机的内容直到 2 19937 2^{19937} 219937 次调用后才会出现重复,具体的原理这里不过多介绍。把他当成一个工具使用就行。

思考

用异或求解章节我们利用了异或的交换律特性,那是否用也符合交换律的加法运算也能达到相同的效果呢?
答案是:yes

异或的本质是对 2 进制每位进行不进位的加法
加法的本质是对 2 进制每位进行进位的加法

而c++的语言特性中,无符号整数对于溢出的部分会自动截断,所以效果是相同的
只是在求区间结果的时候需要将xor[r] ^ xor[l - 1]换成xor[r] - xor[l - 1]

那么乘法呢?
乘法其实也是类似的道理,但是转化为对 2 64 2^{64} 264去模除法,这个很麻烦,不利于模拟。而且虽然没有证明,但我觉得乘法的冲突概率要比异或高。所以不会建议使用乘法

而计算机对位运算的处理效率要比其他运算高很多,并且不用像加法一样额外考虑减法,所以这类题目我们都用异或来进行计算。

出现次数问题

判断一个序列内的元素是否出现偶数( k k k的倍数)次

在开篇我们提到一个用异或来解决的经典题目:快速找到一个数组中只出现1次(其他数都出现偶数次)的数
利用的就是 A ⊕ A = 0 A\oplus A=0 AA=0的特性,我们可以快速判断一个数组(区间)的元素是否都出现了偶数次
直接异或结果为0就行了
不对!!
因为 4 ⊕ 8 ⊕ 12 4\oplus 8 \oplus 12 4812 也等于0,所以我们需要将数字进行hash处理,降低冲突概率,再用异或操作

现在我们将问题升级一下:

给一个长度为 n n n 的数组 a a a,找到所有的连续子序列 [ l , r ] [l,r] [l,r] 满足:所有 a i , i ∈ [ l , r ] a_i, i\in[l,r] ai,i[l,r] 出现的次数都是 3 次倍数

k 进制

之前提到,二进制的异或的本质是对每一位进行不进位的加法,也就是每一位相加对2取模,既:
0 ⊕ 0 = ( 0 + 0 ) % 2 = 0 1 ⊕ 0 = ( 1 + 0 ) % 2 = 1 0 ⊕ 1 = ( 0 + 1 ) % 2 = 1 1 ⊕ 1 = ( 1 + 1 ) % 2 = 0 \begin{aligned} 0\oplus0&=(0+0)\%2&=0\\ 1\oplus0&=(1+0)\%2&=1\\ 0\oplus1&=(0+1)\%2&=1\\ 1\oplus1&=(1+1)\%2&=0\\ \end{aligned} 00100111=(0+0)%2=(1+0)%2=(0+1)%2=(1+1)%2=0=1=1=0
假设有这么一个运算符 3 ◯ \textcircled{3} 3,可以让 A 3 ◯ A 3 ◯ A = 0 A\textcircled{3}A\textcircled{3}A=0 A3A3A=0,那么问题就解决了,既:
0 3 ◯ 0 = ( 0 + 0 ) % 3 = 0 0 3 ◯ 1 = ( 0 + 1 ) % 3 = 1 0 3 ◯ 2 = ( 0 + 2 ) % 3 = 2 1 3 ◯ 2 = ( 1 + 2 ) % 3 = 0 \begin{aligned} 0\textcircled{3}0&=(0+0)\%3&=0\\ 0\textcircled{3}1&=(0+1)\%3&=1\\ 0\textcircled{3}2&=(0+2)\%3&=2\\ 1\textcircled{3}2&=(1+2)\%3&=0\\ \end{aligned} 030031032132=(0+0)%3=(0+1)%3=(0+2)%3=(1+2)%3=0=1=2=0
这就是 3 3 3 进制,也可以推演至 k k k 进制。

// 特别注意,随机数 a,b 上限要取 uint64/k,避免溢出
// 只有2进制的溢出会自动处理,因为就少了一位,而 k 进制溢出就会出错
uint64_t xork(uint64_t a, uint64_t b, int k) {
  vector<int> vec;
  while (a || b) {
    vec.push_back((a + b) % k);
    a /= k;
    b /= k;
  }
  uint64_t res = 0;
  uint64_t p = 1;
  for (auto x: vec) {
    res += p * x;
    p *= k;
  }
  return res;
}

但很不幸, k k k 进制虽然能解决问题,但是会额外增加 log ⁡ 3 C \log_3C log3C的常数 ≈ 40 \approx 40 40
上文有提到,加法配合减法可以达到异或类似的效果,所以我们对于这类问题可以取个巧,将第 k k k 的倍数次出现的数字设置为 k − 1 k-1 k1倍的负数 − ( k − 1 ) ⋅ a i -(k-1)\cdot a_i (k1)ai,那么只要一个区间内出现的次数是 k k k 的倍数,那么他们的和一定是0
对于 k = 2 k=2 k=2,设置为 − a i -a_i ai,对于 k = 3 k=3 k=3,设置为 − 2 ⋅ a i -2\cdot a_i 2ai
所以我们只需要判断区间和为0,那么这个区间的数字出现的次数一定是 k k k的倍数
注意:为了降低冲突的概率,我们需要将元素映射为64位无符号整数

思考
这个做法我们只将复杂度从 O ( n 3 ) O(n^3) O(n3)降低为 O ( n 2 ) O(n^2) O(n2),如何降低为 O ( n ) O(n) O(n)大家可以自行思考
如果要求区间出现的次数一定是 k k k次,而不是 k k k的倍数,又应该如何解呢?
对于这两个思考,可以在 CF 1418 G Three Occurrences 这道题中进行验证

x 2 x^2 x2 问题

判断一个序列的乘积是否是 x 2 x^2 x2( x x x为某个整数)

通常题目不会直观让你判断次数是否偶数次,会进行一些伪装,而 x 2 x^2 x2 就是一个非常常见的伪装
我们将一个 a i a_i ai进行质因数分解可得 p 1 k i 1 p 2 k i 2 ⋯ p m k i m p_1^{k_{i_1}}p_2^{k_{i_2}}\cdots p_m^{k_{i_m}} p1ki1p2ki2pmkim,其中 p i p_i pi都是质数, k i k_i ki是对应的指数。
那么所有 a i a_i ai的乘积就为 ∏ i = 1 n a i = p 1 ∑ i = 1 n k i 1 p 2 ∑ i = 1 n k i 2 ⋯ p m ∑ i = 1 n k i m \prod_{i=1}^{n} a_i=p_1^{\sum_{i=1}^n k_{i_1}}p_2^{\sum_{i=1}^n k_{i_2}}\cdots p_m^{\sum_{i=1}^n k_{i_m}} i=1nai=p1i=1nki1p2i=1nki2pmi=1nkim
(为了简化表达,我们将 ∑ i = 1 n k i m \sum_{i=1}^n k_{i_m} i=1nkim 记为 K m K_m Km)
如果: p 1 K 1 p 2 K 2 ⋯ p n K n = x 2 p_1^{K_1}p_2^{K_2}\cdots p_n^{K_n}=x^2 p1K1p2K2pnKn=x2
那么: ( p 1 K 1 2 p 2 K 2 2 ⋯ p n K n 2 ) 2 = x 2 (p_1^{\frac{K_1}{2}}p_2^{\frac{K_2}{2}}\cdots p_n^{\frac{K_n}{2}})^2=x^2 (p12K1p22K2pn2Kn)2=x2
可以推得:所有的 K i K_i Ki都是偶数, ∏ i = 1 n a i \prod_{i=1}^{n} a_i i=1nai才可能是某个整数的平方 x 2 x^2 x2
所以问题就转化成了质因数的个数是否是偶数次
这就是上述已经解决的问题

练习

题目说明
AtCoder: 250 E Prefix Equality组合问题
CF: 1175 F The Number of Subpermutations组合问题
CF: 869 E The Untended Antiquity组合问题
洛谷: P3792 由乃与大母神原型和偶像崇拜组合问题
CF: 1418 G Three Occurrences出现次数问题
HackerRank: Number Game On A Tree出现次数问题
CF: 1622 F Quadratic Set x 2 x^2 x2问题
CF: 895 C Square Subsets x 2 x^2 x2问题,不过不用 hash,因为组合数有限,不会冲突

参考

1. XOR Hashing [TUTORIAL]
2. 哈希算法学习笔记

  • 5
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值