hash進階:使用字串hash亂搞的姿勢
前言
此文主要介紹hash的各種亂搞方法,hash入門請參照我之前這篇文章
在開頭先放一下題表
查詢子串hash值
必備的入門操作,因為OI中用到的hash一般都是進位制雜湊 ,因為它有一些極其方便的性質,比如說,是具有和字首和差不多的性質的。
假設一個字串的字首hash值記為\(h[i]\) ,我們hash時使用的進位制數為\(base\) ,那麼顯然\(h[i]=h[i-1]*base+s[i]\)
記\(p[i]\) 表示\(base\) 的\(i\) 次方,那麼我們可以通過這種方式\(O(1)\) 得到一個子串的hash值(設這個子串為s[l]...s[r])
typedef unsigned long long ull; ull get_hash(int l, int r) { return h[r] - h[l - 1] * p[r - l + 1]; }
可是為什麼呢?
我們知道,進行進位制雜湊的過程本質上就是把原先得到的雜湊值在\(base\) 進位制上強行左移一位,然後放進去當前的這個字元。
現在的目的是,取出\(l\) 到\(r\) 這段子串的hash值,也就是說,\(h[l-1]\) 這一段是沒有用的,我們把在\(h[r]\) 這一位上,\(h[l-1]\) 這堆字串的hash值做的左移運算全部還原給\(h[l-1]\) ,就可以知道\(h[l-1]\) 在\(h[r]\) 中的hash值,那麼減去即可。(簡單的容斥思想)
這是基本操作,現在來看一個這個的拓展問題。
題意
現在有一個字串\(s\) ,每次詢問它的一個子串刪除其中一個字元後的hash值(刪除的字元時給定的)
要求必須\(O(1)\) 回答詢問
Sol
刪除操作?那不能像上面那樣子簡單粗暴的來搞了,但是其實本質上是一樣的。
假設我們現在詢問的區間為\([l,r]\) ,刪除的字元為\(x\) (指位置,不是字元)
類比上面的做法,我們可以先\(O(1)\) 得到區間\([l,x-1]\) 和區間\([x+1,r]\) 的hash值,那麼現在要做的事情就是把這兩段拼起來了,由於我們使用的是進位制hash,所以其實很簡單,強行將前面的區間強行左移\(r-x\) 位(這麼看可能會好理解一點:\(r-(x+1)+1\) )就好。
程式碼實現也很簡單
typedef unsigned long long ull; ull get_hash(int l, int r) { return h[r] - h[l - 1] * p[r - l + 1]; } ull get_s(int l, int r, int x) { return get_hash(l, x - 1) * p[r - x] + get_hash(x + 1, r); }
這題的原題是LOJ#2823. 「BalticOI 2014 Day 1」三個朋友 ,需要分類討論一下,不過知道上面這個也就不難了
用hash求最長迴文子串/迴文子串數
最長迴文子串!我知道!馬拉車!可以\(O(n)\) !
可是如果你馬拉車寫掛了呢?
這時候就得靠hash來水分了
我們知道,迴文子串是具有單調性的
如果字串s[l...r]為迴文子串,那麼s[x...y](l<x,y<r)也一定是迴文子串
單調性!我們是不是可以二分?
我們暫時只討論長度為奇數的迴文子串。(事實上,長度為偶數的迴文子串與奇數的只是處理上的一些細節不同,僅此而已)
考慮列舉迴文子串的中點,並二分迴文子串的長度(不過一般來說,二分迴文子串的長度的1/2可能會更好寫一點),那麼我們使用上文提到的\(O(1)\) 查詢子串hash值的方法,就可以\(O(1)\) 判斷二分得到的這個子串是不是迴文子串了。
對於長度為偶數的迴文子串,列舉中點左邊/右邊的字元即可
效率是\(O(nlogn)\) 的,複雜度較馬拉車演算法比較遜色,不過如果馬拉車演算法打掛或者是時間複雜度允許的情況下,hash也是一個不錯的選擇。
然後還有一種方法,適合像我這種下標總是搞錯的,可以直接處理出正串和反串的hash值,然後每次根據二分出來的長度計算整個字串的起止,判斷正串和反串的hash值是否相等即可。(這樣就不用研究噁心的下標了...研究下標還得分奇偶討論...)
字串的很多特性是具有單調性的,二分求解是一個常見的思路,配合雜湊進行判斷操作一般可以做到在\(O(nlogn)\) 效率內完成問題
例題:SP7586 NUMOFPAL - Number of Palindromes
練習:LOJ#2452. 「POI2010」反對稱 Antisymmetry
例題程式碼
#include<bits/stdc++.h> using namespace std; typedef unsigned long long ull; #define N 10100 #define base 13131 char s[N]; ull h1[N], p[N], h2[N], ans = 0; int n; ull gh1(int l, int r) { return h1[r] - h1[l - 1] * p[r - l + 1]; } ull gh2(int l, int r) { return h2[l] - h2[r + 1] * p[r - l + 1]; } ull query1(int x) { //奇 int l = 1, r = min(x, n - x); while(l <= r) { int mid = (l + r) >> 1; if(gh1(x - mid, x + mid) == gh2(x - mid, x + mid)) l = mid + 1; else r = mid - 1; } return r; } ull query2(int x) { //偶 int l = 1, r = min(x, n - x); while(l <= r) { int mid = (l + r) >> 1; if(gh1(x - mid + 1, x + mid) == gh2(x - mid + 1, x + mid)) l = mid + 1; else r = mid - 1; } return r; } int main() { scanf("%s", s + 1); p[0] = 1; n = strlen(s + 1); for(int i = 1; i <= n; ++i) { h1[i] = h1[i - 1] * base + s[i]; p[i] = p[i - 1] * base; } for(int i = n; i; i--) h2[i] = h2[i + 1] * base + s[i]; for(int i = 1; i < n; ++i) { ans += query1(i) + query2(i); } printf("%llu\n", ans + n); }
用hash代替kmp演算法
關於kmp演算法,可以看pks大佬的blog ,講的真的很好!
但是我們這裡不講kmp演算法,我們利用hash來代替kmp演算法求解單模式串匹配問題。
但是kmp演算法的next陣列真的很妙!可以解決很多神奇的東西,強烈推薦去學學!
好了,步入正題。
單模式串匹配問題是什麼?
給出兩個字串\(s1\) 和\(s2\) ,其中\(s2\) 為\(s1\) 的子串,求\(s2\) 在\(s1\) 中出現多少次/出現的位置。
如果有認真看過該篇文章的第一子目的話,應該不難想到這題的hash做法。
具體做法是預處理出來兩個串的hash值,因為求的是\(s2\) 在\(s1\) 中出現的次數,所以我們要匹配的長度被壓縮到了\(s2\) 的長度,所以我們只需要列舉\(s2\) 在\(s1\) 中的起點,看看後面一段長度為\(len\) 的區間的hash值和\(s2\) 的hash值一不一樣就好。
時間複雜度是\(O(n+m)\) 的!和kmp演算法一樣!
例題:LOJ #103. 子串查詢 (本來想放洛谷的結果要輸出next陣列就沒辦法了23333)
例題程式碼
#include <bits/stdc++.h> using namespace std; #define N 1000010 #define ull unsigned long long #define base 233 ull h[N], p[N], ha; char s1[N], s2[N]; int main() { scanf("%s%s", s1 + 1, s2 + 1); int n = strlen(s1 + 1), m = strlen(s2 + 1); for(int i = 1; i <= m; ++i) ha = ha * base + (ull)s2[i]; p[0] = 1; for(int i = 1; i <= n; ++i) { h[i] = h[i - 1] * base + (ull)s1[i]; p[i] = p[i - 1] * base; } int l = 1, r = m, ans = 0; while(r <= n) { if(h[r] - h[l - 1] * p[m] == ha) ++ans; ++l, ++r; } printf("%d\n", ans); }
用hash代替其他一些字串演算法
因為博主並沒有寫過,所以並不打算深入講(沒寫過不熟悉啊...)
這一子目會分析一下hash還能代替哪些演算法以及使用hash演算法代替的複雜度是多少
manacher演算法
求最長迴文串/迴文串個數manacher演算法是可以做到\(O(n)\) 的
使用hash+二分可以做到\(O(nlogn)\) ,並且實現簡單
kmp演算法
進行單模式串匹配可以使用hash進行
複雜度\(O(n+m)\) ,kmp演算法複雜度也是\(O(n+m)\) 。但是kmp的next陣列可以做到一些hash做不到的事情。
上面兩個是前面兩子目分析過的。
AC自動機
多模式串匹配:求文字串中各個模式串出現了多少次。
設文字串的長度為\(n\) ,模式串的總長度為\(len\) ,模式串的個數為\(m\)
hash出文本串中每個子串,並存入一個map中,複雜度是\(O(n^2logn)\) 的(用map主要是便於查詢)。然後hash出每個模式串,複雜度是\(O(len)\) 的。
對每個模式串,查詢對應的map中文字串的子串的個數即可。複雜度\(O(mlogn)\)
總複雜度是\(O(n^2logn+len+mlogn)\)
這個\(log\) 可以去掉的(自行寫個雜湊表)。
所以並沒有什麼用...還是用AC自動機實在。
用AC自動機可以做到\(O(n+len)\)
字尾陣列
求字尾陣列中的SA陣列。(如果不知道請自行百度)(給定的串為S)
最暴力的做法是直接對每個字尾進行排序,並逐字元匹配,這樣會達到\(O(n^2logn)\)
那麼有沒有不這麼無腦的做法?
有!有個hash+二分的神仙做法可以做到\(O(nlognlogn)\)
我們處理出整個串S的hash值。
在排序中對兩個子串進行排序的過程中,採用二分找相同的字首(比較用hash,可以\(O(1)\) ),那麼設我們最後二分到的值為r,則直接比較\(s[x+r+1]\) 和\(s[y+r+1]\) 的大小即可(設子串1的起點為\(x\) ,子串2的起點為\(y\) )。這樣每次比較的複雜度就是\(O(logn)\) 了。
加上排序,總的複雜度為\(O(nlognlogn)\)
並且其實還能求出height陣列的,但是我自己對height陣列的理解也不大行,所以這裡就不討論這個。
而後綴陣列的複雜度是\(O(nlogn)\) (使用倍增法)
字尾陣列這部分主要參考自李煜東的《演算法競賽進階指南》。
使用hash的幾個要注意的地方
在複雜度允許的情況下,儘量採用多hash(不過一般雙hash就夠)
比賽時能不用自然溢位就不要(平時刷題如果用自然溢位被卡可以及時換掉,但是比賽時如果用自然溢位,OI賽制就GG了)
模數用大質數這個不用說了
並且進位制數不要選太簡單的,比如\(233\) 和\(13131\) 這樣的,儘量大一點,比如\(13131\) 和\(233333\) 。太小容易被卡。
以及要合理應對各種卡hash方法的最好方法就是自己去卡一遍hash,詳情請參考BZOJ hash killer系列。