【令人心动的算法】进制哈希
本文最后更新于99 天前,其中的信息可能已经过时,如有错误请发送邮件到727189340@qq.com

BKDRHash 哈希函数

字符串哈希函数

哈希函数擅长于解决这一类问题:在很多字符串中,尽快操作某个字符串,如查询、修改等,它能以最快的速率完成对字符串的访问。

用哈希函数对每个字串进行哈希,分别映射到不同的数字,即一个整数哈希值,然后根据哈希值找到字串。

该算法的核心是找到哈希函数。理论上来说任何函数 $h(x)$ 都可以是哈希函数,不过一个好的哈希函数应该避免冲突——被称为完美哈希函数,指的是没有冲突的哈希函数:把 $n$ 个字串的 $key$ 值映射到 $m$ 个整数上,如果对任意的 $key1$ $\neq$ $key2$,都有 $h(key1) \neq h(key2)$,那么这就是一个完美哈希函数。

值得注意的是,这里的 $m$ 并不指代“这 $n$ 个字串映射出来的结果”,应该理解为“准备了 $m$ 个整数用以映射,防止出现数据丢失”。

此时,必然有 $n \leq m$,特别地,如果 $n = m$,称为最小完美哈希函数。

通俗地说,就是要找到一种函数关系,使得每一个字串的 $key$ 值都能唯一确定地映射到某个整数上,且这个整数的范围越小越好。否则就会占用很大一部分空间。

哈希函数有很多种,其中最好的是 BKDRHash 函数,其好处是几乎不会发生冲突。

然而,通常我们得到的映射值都会很大,为了保证空间利用率,我们可以通过取模的方式进行。也就是“溢出空间”的部分再从头开始索引,但这样有可能出现冲突,需要小心地设计。

一个别出心裁的优化是:我们无需使用低效的取模运算,我们可以取空间大小为 $M = 2^{64}$,其中 $64$ 是 unsigned long long 型的长度,那么对于一个 unsigned long long 型的哈希值 $H$ 来说,当 $H > M$ 时会自动溢出,等价于自动对 $M$ 取余。

BKDRHash

介绍完哈希函数的原理之后,接下来介绍 BKDRHash,它是一种进制哈希。

设定一个进制 $P$,需要计算一个字符串的哈希值时,把每个字符看作每个进制位上的一个数字,这个串转化成一个基于进制 $P$ 的数,最后对 $M$ 取余数,就得到了这个字符串的哈希值。

例如,计算一个只有小写字母的字符串的哈希值,以”abc”为例,令进制 $P=131$,有以下两种方法。

  • 把每个字符看作一个数字,即 $a=1,b=2,…,z=26$,然后把字符串的每位按进制 $P$ 的权值相加,即 $1 \times 131^2 + 2 \times 131^1 + 3 \times 131^0 = 17426$。
  • 也可以直接把每个字符对应的 ASCII 码作为计算时使用的数字,计算方法与上面一致,即 $a=97,b=98,……$,求解得到 $1677554$

进制 $P$ 常用的值有 $31,131,1313,13131,131313$ 等,用这些值可以有效防止碰撞。

模板代码

下面将用P3370 【模板】字符串哈希演示进制哈希的模板书写流程。

#include <iostream>
#include <algorithm>
#define ull unsigned long long

using namespace std;

const int maxN = 10010;

int read()
{
    int x = 0, f = 1;
    char ch = 0;
    while (!isdigit(ch))
    {
        if (ch == '-')
            f = -1;
        ch = getchar();
    }
    while (isdigit(ch))
    {
        x = x * 10 + ch - '0';
        ch = getchar();
    }
    return x * f;
}

int n;

string s;

ull a[maxN];

// 哈希函数
ull BKDRHash(string s)
{
    ull P = 131, H = 0; // P 表示进制,H 表示哈希值
    int n = s.size();
    for (int i = 0; i < n; i++)
        H = H * P + s[i] - 'a' + 1;
    return H;
}

int main()
{
    n = read();
    for (int i = 1; i <= n; i++)
    {
        cin >> s;
        a[i] = BKDRHash(s);
    }
    int ans = 0;
    sort(a + 1, a + n + 1);
    for (int i = 1; i <= n; i++)
        if (a[i] != a[i + 1])
            ans++;
    printf("%d\n", ans);
}

进制哈希的应用

由于进制哈希把字符串的每位看作一个数字,所以字符串的各种操作对应的哈希计算都能按 $P$ 进制数的计算规则进行。

设字符串 $S$ 的哈希值为 $H(S)$,长度为 $len(s)$。对于字符串 $S_1$ 和 $S_2$ 的组合,即 $S_1 + S_2$,可以认为是字符串 $S_1$ 向左移动了 $len(S_2)$ 位,其哈希值为 $H(S_1) \times P^{len(S_2)} + H(S_2)$。

利用这个性质,我们可以快速计算字符串 $S$ 的所有前缀。例如字符串”abcdefg”,其前缀就有”a”,”ab”,”abc”……

显然地,计算出 $H(a)$ 后,$H(ab) = H(a) \times P + H(b)$,$H(abc) = H(ab) \times P + H(c)$,等等。只要提前计算出每一位的哈希值,那么所有前缀的哈希值都只要在一次循环中得到。而这个计算是 $O(1)$ 的。

计算得到前缀之后,我们就可以利用前缀和的思想,迅速求出每一个子串的哈希值。例如,$H(de) = H(abcde) – H(abc) \times P^2$,只需要预先处理好 $P$ 的幂值。同样,仿照前缀和的代码,我们可以写出求解区间 $[L,R]$ 的哈希值的代码(其中 P[i] 代表 $P$ 的 $i$ 次方)

ull get_hash(ull L, ull R)
{
    return H[R] - H[L-1] * P[R-L+1];
}

尽管进制哈希在求解部分问题时并不是最优解,但作为一个“通用解”和“基础解”仍然具有很大的意义。下面给出两个类型题。

进制哈希与最长回文集

查找一个长度为 $n$ 的字符串 $S$ 中的最长回文串

这种题目的标准做法是 Manacher 算法,复杂度为 $O(n)$。

而如果使用进制哈希,其复杂度为 $O(n \log_2 n)$,尽管效率上不如 Manacher,但编码较为简单。

如何利用前缀找到回文串?显然,回文串是正着读、反着读都相等的字符串,也就是字符对称,也就是说它们的哈希值应该也是相等的。

不过,如果从头到尾遍历 $S$ 的所有子区间,需 $O(n^2)$ 次。我们可以使用“中心扩展法”进行优化:以 $S$ 的每个字符为中心,左右扩展,如果左右对应的字符是相同的,就是一个回文串。不过,这样的做法仍然需要 $O(n)$ 的时间。

考虑二分加速:扩展中心字符的左右求回文串的长度具有单调性,可以用二分法加速。优化后的时间复杂度为 $O(\log_2 n)$,再加上计算哈希值的部分,可以认为最终的时间复杂度为 $O(n \log_2 n)$。

给出例题:P3501 [POI2010] ANT-Antisymmetry

#include <iostream>
#include <algorithm>
#define ll long long
#define ull unsigned long long

using namespace std;

int read()
{
    int x = 0, f = 1;
    char ch = 0;
    while (!isdigit(ch))
    {
        if (ch == '-')
            f = -1;
        ch = getchar();
    }
    while (isdigit(ch))
    {
        x = x * 10 + ch - '0';
        ch = getchar();
    }
    return x * f;
}

const int maxN = 5e5 + 5;
const int pp = 131;

int n;
ll ans;

ull p[maxN], f[maxN], g[maxN];

char s[maxN], t[maxN];

// 二分,mid 是半径
void bin_search(int x)
{
    int L = 0, R = min(x, n - x);
    while (L < R)
    {
        int mid = (L + R + 1) >> 1;
        if ((ull)(f[x] - f[x - mid] * p[mid]) == (ull)(g[x + 1] - g[x + 1 + mid] * p[mid]))
            L = mid;
        else
            R = mid - 1;
    }
    ans += L;
}

int main()
{
    n = read();
    scanf("%s", s + 1);
    p[0] = 1;
    for (int i = 1; i <= n; i++)
        s[i] == '1' ? t[i] = '0' : t[i] = '1';
    for (int i = 1; i <= n; i++)
        p[i] = p[i - 1] * pp;
    for (int i = 1; i <= n; i++)
        f[i] = f[i - 1] * pp + s[i];
    for (int i = n; i >= 1; i--)
        g[i] = g[i + 1] * pp + t[i];
    for (int i = 1; i < n; i++)
        bin_search(i);
    printf("%lld\n", ans);
    return 0;
}

进制哈希与循环节

下面例题的标准解法是 KMP 算法,复杂度为 $O(n)$。如果用哈希进制求解,略差一点,但也非常接近 $O(n)$。

P4391 [BOI2009] Radio Transmission 无线传输

思路非常暴力:枚举这个循环节的长度,然后判断之后的每一个长度相同的区间是否和循环节相同,如果有多出来的段落,就单独计算。

#include <iostream>
#define ull unsigned long long

using namespace std;

int read()
{
    int x = 0, f = 1;
    char ch = 0;
    while (!isdigit(ch))
    {
        if (ch == '-')
            f = -1;
        ch = getchar();
    }
    while (isdigit(ch))
    {
        x = x * 10 + ch - '0';
        ch = getchar();
    }
    return x * f;
}

const int maxN = 1e6 + 5;
const int PP = 131;

char s[maxN];
ull P[maxN], a[maxN], N;

ull get_hash(int L, int R)
{
    return a[R] - a[L - 1] * P[R - L + 1];
}

int main()
{
    N = read();
    scanf("%s", s + 1);
    P[0] = 1;
    for (int i = 1; i <= N; i++)
        P[i] = P[i - 1] * PP;
    for (int i = 1; i <= N; i++)
        a[i] = a[i - 1] * PP + s[i];
    for (int i = 1; i <= N; i++)
    {
        bool flag = true;
        ull last = get_hash(1, i);
        for (int j = 1; j * i + i <= N; j++)
            if (get_hash(j * i + 1, j * i + i) != last)
            {
                flag = false;
                break;
            }
        if (N % i != 0)
        {
            int len = N % i;
            if (get_hash(1, len) != get_hash(N - len + 1, N))
                flag = false;
        }
        if (flag)
        {
            printf("%d\n", i);
            break;
        }
    }
    return 0;
}

作者

觉得有帮助的话可以投喂煮啵,助力煮啵过上点外卖不用神卷的生活

本文作者:Flaw_Owl
看到这里的朋友们,作者十分感谢!如果你看到任何写得不清楚或是错误之处,或者是有什么希望看到的课程和内容笔记,欢迎在评论区留言!评论区可以单纯的发表文字,也可以使用 Markdown 语法,你可以参照 Markdown 官方教程发出带有格式的评论。

版权说明:本博客所有文章除特别声明外,均采用CC BY-NC-SA 4.0协议,转载请注明文章地址及作者哦
暂无评论

发送评论 编辑评论

|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇