哈希简单解说

这个哈希确实是啊。

这里不说各种冲突什么东西的证明,因为作者不会,看不懂。

你说说是谁把钢琴和弦乐放一块去的,我怎么飞起来了。

哈希

下面的定义都是不严谨的,这里仅是我的通俗解释。

哈希的基本原理是:把一个判断需要很多时间或空间资源的元素或者状态,通过某种函数变成一个特殊的值或者是什么易于比较的东西,来大大节约判断花销。

比较重要的一点是,我们可以比较快速的维护出各个状态的哈希值。这通常需要一些数据结构辅助或者是利用一些运算符的运算性质。

下文解说的都是把什么东西变成值的哈希。

这里把这个东西 \(S\) 变成值的函数称为哈希函数 \(H(S)\)

当然,我们不能保证 \(H(S)\) 一定会对两个不同的 \(S\) 输出不同的结果。我们称这种情况为“碰撞”。稍微想一下就可以发现,既然输入数据长度不固定,而输出的哈希值是一个用 unsigned intunsigned long long 存的数,这意味着哈希值是一个有限集合,而输入数据则可以是无穷多个,那么建立一对一关系明显是不现实的。所以“碰撞”是必然会发生的。

我们的工作就是去设计一个有着尽量小可能发生碰撞的哈希函数来做题。

序列哈希

最开始应该是从字符串哈希开始学的。

序列哈希的重要性质与单模哈希

我们定义一有序序列 \(a_1,a_2,\cdots,a_n\) 的哈希函数为

\[H(n)=\left ( \sum_{i=1}^n a_i \cdot base^{n-1} \right ) \bmod M \]

其中 \(base\) 是一个特殊选择的质数,\(M\) 是一个很大的质数模数。常取 \(base=31,M=10^9+7\)

你问为什么要这样取?因为大家发现这样产生碰撞的可能性很小。也就是经验之谈。

\(H\) 显然可以递推,也就是:

\[H(i)=\left ( H(i-1)\times base +s_i \right ) \bmod M \]

这个哈希函数有不少优点:

可拆性

我们可以通过进行前缀哈希在 \(O(n)\) 的时间内对 \(a\) 预处理,并在 \(O(1)\) 时间内得到 \(a\) 任何一个子串的哈希值。

考虑一个新序列 \(s\),我们从 \(1\)\(n\) 处理前缀哈希,计入数组 \(H(n)\) 中,也即是:

\[H(i) = (s_1 \cdot base^{n-1} + s_2 \cdot base^{n-2} + \cdots + s_i \cdot base^{0}) \bmod M \]

对于一个子串 \(s_{l,r}\),其哈希值为:

\[H(s_{l,r}) = (H(r) - H(l-1) \cdot base^{r-l+1}) \bmod M \]

怎么推的

假设我们要求解区间 \([l,r]\) 的哈希值,有下面两个式子,下面省略一下 \(\bmod \ M\)

\[H(r) = s_1 \cdot base^{0} + s_2 \cdot base^{1} + \cdots + s_r \cdot base^{r-1} \]

\[H(l-1) = s_1 \cdot base^{0} + s_2 \cdot base^{1} + \cdots + s_{l-1} \cdot base^{l-2} \]

如果把 \(H(l-1)\) 乘上 \(base^{\,r-l+1}\),它就会对齐到 \(H(r)\) 的高位:

\[H(l-1) \cdot base^{\,r-l+1} = s_1 \cdot base^{r-l+1-1} + \cdots + s_{l-1} \cdot base^{r-1} \]

此时:

\[H(r) - H(l-1)\cdot base^{\,r-l+1} \]

就只剩下:

\[s_l \cdot base^{0} + s_{l+1} \cdot base^{1} + \cdots + s_r \cdot base^{r-l} \]

于是:

\[H(s_{l,r}) = (H(r) - H(l-1) \cdot base^{r-l+1}) \bmod M \]

可并性

我们可以在知道序列 \(a,b\) 的长度及其哈希值时,在两者生成哈希值的 \(base,M\) 均相同的前提下,得到拼接后的序列 \(a+b\)\(b+a\) 的哈希值。

拼接串 \(a+b\) 的哈希值为:

\[H(a+b) = H(a) + H(b) \cdot base^{|A|} \bmod M \]

其中 \(|a|\) 为序列 \(a\) 的长度。

怎么推的

设有两个子串:

  • \(A = s[l_1 \dots r_1]\),长度 \(|A| = r_1-l_1+1\),哈希为 \(Hash(A)\)
  • \(B = s[l_2 \dots r_2]\),长度 \(|B| = r_2-l_2+1\),哈希为 \(Hash(B)\)

目标:计算拼接串 \(C = A+B\) 的哈希。


\[H(A) = s_{l_1} \cdot base^0 + s_{l_1+1}\cdot base^1 + \cdots + s_{r_1}\cdot base^{|A|-1} \]

\[H(B) = s_{l_2} \cdot base^0 + s_{l_2+1}\cdot base^1 + \cdots + s_{r_2}\cdot base^{|B|-1} \]

拼接串 \(C = A+B\) 的哈希为:

\[H(C) = H(A) + H(B) \cdot base^{|A|} \bmod M \]

/n

由上面的合并性质,我们注意到一个序列的哈希值是可以动态维护的,使用树状数组或线段树均可。

性质说完了,你可能注意到我们上面的哈希值都对一个固定的 \(M\) 取模,我们称这样的哈希为单模哈希,这种哈希比较容易被 hack,下面说点更牛的。但好像赛场上写这种也就够了?

双模哈希

如名字,我们选择两个不同的大模数 \(M_1, M_2\),分别计算哈希:

\[H_1(s) = \left(\sum_{i=1}^n s_i \cdot base^{\,n-i}\right) \bmod M_1 \]

\[H_2(s) = \left(\sum_{i=1}^n s_i \cdot base^{\,n-i}\right) \bmod M_2 \]

最终哈希值为一个二元组:

\[\left(H_1(s), H_2(s)\right) \]

只有当两个模数下都发生碰撞时才会误判,冲突概率大约是原来的平方级下降,总而言之就是更不容易被卡了!

当然你也可以用两个不同的 \(base\) 对一个相同的 \(M\) 取模算出来两个哈希值,也有相同的效果。

如果你不嫌麻烦,三模及以上的哈希也是可以的。

自然溢出哈希

我们直接让所有存储哈希值的变量为 unsigned intunsigned long long 类型,这样在计算时相当于随时都对着 \(2^{32}\)\(2^{64}\) 取模。

可以说比较好写一些?但由于取模的是个偶数,被卡的可能性也大一些(相对来说)。

字符串哈希

就是把上述理论中的序列换成字符串而已啦。

因为每个字符都有一个自己的 ASCII 码,于是和序列哈希是一模一样的。

例题

ABC331F

是线段树维护区间哈希值的例题。

还没有代码。

[CF???]

观察下什么样的排列和一个确定的排列 \(p\) 进行点乘之后能出来 \(1,2,3,4,\cdots\)。你会发现就是这个 \(p\)\(1,2,3,4,\cdots\) 点乘之后的结果。转置了一下原排列。记作 \(p'\)

而且有个很好的的性质。一个排列 \(q\)\(p'\) 前缀最长有多少位相等,乘出来的排列就有多少美丽度。

于是首先把所有排列的前缀全部哈希然后存起来,之后对每一个排列,转置之后也哈希出来每一个前缀,在总的里面找,最多能找到那一位就是美丽度。

code

Show me the code
#define psb push_back
#define mkp make_pair
#define ls p<<1
#define rs (p<<1)+1
#define rep(i,a,b) for( int i=(a); i<=(b); ++i)
#define per(i,a,b) for( int i=(a); i>=(b); --i)
#define rd read()
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
ll read(){
  ll x=0,f=1;
  char c=getchar();
  while(c>'9'||c<'0'){if(c=='-') f=-1;c=getchar();}
  while(c>='0'&&c<='9'){x=(x<<3)+(x<<1)+(c^48);c=getchar();}
  return x*f;
}
const int mod=998244353;
const int b=127;
const int N=5e4+5;
int arr[N][20];
int inv[N][20];
map<ll,int> mp[20];
ll pre[N][20];
int n,m;
void init(){
  for(int i=1;i<=15;i++)mp[i].clear();
  for(int i=1;i<=n;i++){
    for(int j=1;j<=16;j++){
      arr[i][j]=0;
      inv[i][j]=0;
      pre[i][j]=0;
    }
  }
  return ;
}
void solve(){
  cin>>n>>m;
  for(int i=1;i<=n;i++){
    for(int j=1;j<=m;j++){
      cin>>arr[i][j];
      inv[i][arr[i][j]]=j;
    }
    for(int j=1;j<=m;j++){
      pre[i][j]=(pre[i][j-1]*b%mod+inv[i][j])%mod;
      mp[j][pre[i][j]]++;
    }
  }
  for(int i=1;i<=n;i++){
    ll hs=0,k=0;
    for(int j=1;j<=m;j++){
      hs=(hs*b%mod+arr[i][j])%mod;
      if(mp[j].find(hs)!=mp[j].end())k=j;
      else break;
    }
    cout<<k<<' ';
  }
  cout<<'\n';
  return ;
}
int main(){
  
  int T;cin>>T;
  while(T--){
    init();
    solve();
  }

  return 0;
}

我咋就记住这俩题了。

集合哈希与多重集合哈希

普通的集合哈希就是给你两个集合 \(S,T\),判断两个集合是否相同。两个集合相同当仅当两集合内出现的元素相同。

由于集合的无序性,进行集合哈希时用的运算也是满足交换律和结合律的,最常用的就是 和哈希 和 异或哈希。这两个东西我们下面一块说。

如果要求的是对几个可重集做哈希,一般不使用异或哈希而采用和哈希,因为异或的偶数消去性质导致其不能很好的记录出现次数。

多重集合哈希,就是给你几个元素是集合的集合,这种也是无脑上和哈希就差不多。

千万不要在写多重集合哈希的时候里外都用异或哈希啊,不然似的可惨了。

实现

无论是进行异或还是和哈希,第一步都是把每一种元素映射到一个巨大无比元素上,一般是一个 unsigned long long,这也是为了减少碰撞。

一般我们使用 mt19937_64 来高效的生成这些巨大无比的随机元素,代码是这样的:

mt19937_64 rnd(std::chrono::duration_cast<std::chrono::nanoseconds>(std::chrono::system_clock::now().time_since_epoch()).count());

把这个东西放在全局里,之后调用 rnd() 就可以得到一个很大的正随机数。

你可能想问 rnd() 括号里面那些是什么,这是随机数的种子,只不过里面用 std::chrono 的纳秒计数器来保证每次程序运行是都可以生成不同的随机数序列。

这东西应该不难背吧,多写几遍就记住了。对拍时候也能用。

做完这步操作之后,把这些元素加起来或者异或起来就行了。

例题

CF1175F

ABC331F

NOI 2024 集合

CSPS 2022 星战

简单的树哈希

今晚上写不完了(恼

posted @ 2025-10-04 23:34  hm2ns  阅读(31)  评论(0)    收藏  举报