title: 前綴函數與KMP算法
date: 2020-08-05
tags:
- 算法
- 字符串
- OI
categories:
- 技術
因為大二的時候全程划水,導致我對KMP只聽說過名字。老師似乎都沒展開講,我記得是有一節下課時說這個算拓展內容,可以自己回去研究,所以我印象中還蠻難的。
前段時間在廖雪峰的網站重新學了一遍,自己代碼實現了一下,感覺還蠻簡單的,與印象中不符,有點奇怪。
直到今天在Leetcode上用KMP解一道字符串匹配居然超時了,才發現不對勁。
仔細查閱后才意識到,KMP的難點根本不在於算法本身,而在於構建前綴函數的過程。廖雪峰的網站把這個前綴函數是什么講得很清晰了(也就是PMT,部分匹配表),但是如果按照朴素的想法來構建這個東西,會發現時間復雜度特別高:
//朴素方法,j即最長公共前后綴的長度
vector<int> pi(s.size());
for (int i = 1; i < s.size(); ++i) {
for (int j = i; j >= 0; --j) {
if (s.substr(0, j) == s.substr(i - j + 1, j))
pi[i] = j;
break;
}
}
雙重循環內再加子串的比較,時間復雜度直接來到O(N^3)
。盡管構建后的表格能加速后面的比較,但是構建表格本身消耗立方時間的話會得不償失,一定要優化這個方法。
看了半天,還是OI Wiki講得靠譜。
優化
我們首先可以觀察到一點就是,i + 1
位置的前綴函數pi[i + 1]
,最大也只能比pi[i]
大1。
這個應該不難理解。因為相對於上一個子串,長度只增加了一,就算是最完美的情況下,前綴函數(PMT)的值也只能加1。例如字符串abab
,前三個前綴函數是[0, 0, 1]
,現在看pi[3]
也就是第四位,前綴只能增加為2。
但是這里難點就在於,我們要思考是什么情況可以使得p[i + 1] == p[i] + 1
。好吧,我覺得一般人也思考不出來這個東西,答案就是s[i + 1] == s[pi[i]]
的時候。
這個式子初看直接懵逼,卡了我一小時,突然想到舉個例子不就完了,於是舉個例子,確實很簡單。。。可惜我一個人自學,就是容易走進死胡同。
看例子saba
,其pi
為[0,0,1]
我們現在來填第四個字符,使得pi
變為[0,0,1,2]
。根據公式知道s[pi[2]] == 'b'
,變為abab
。
倒推下原因,因為pi[2] == 1
其實就是說ab(a)
,右邊這個a
處的最長公共前后綴長度為1,其實就是前后的兩個a
。現在想讓pi[3] == p[2] + 1
,就是使得新的最長前綴變為ab
,那么其相應的后綴ax
,就只能是ab
,所以合並abax == abab
。
推廣
上面的優化方式其實不僅能用邊界情況的一次,其實可以一直往前推廣。我們第一次是看abax
中的s[3]
和s[1]
的對比(也就是s[3]
和s[pi[2]]
),要是這個對比不相等,我們就繼續比較s[3]
和s[pi[pi[2]] - 1]
。這么說好像更復雜了。。。直接看代碼吧:
//j是當前最大前綴長度
vector<int> pi(s.size());
for (int i = 1; i < s. size(); ++i) {
int j = pi[i - 1];
while (j > 0 && s[i] != s[j]) j = pi[j - 1];//一次不成,就把結論往前推廣
if (s[i] == s[j]) ++j;//成了,依然是+1
pi[i] = j;
}
循環隱藏
上面j
每次都是從最邊界的情況開始往前迭代,其實還能偷懶,寫成這樣:
vector<int> pi(s.size());
for (int i = 1, j = 0; i < s.size() - 1;) //最后一位其實可以不算,因為沒用
{
if (s[i] == s[j]) {
pi[i] = ++j;
++i;
}
else {
//把內層循環合並了
if (j == 0) {
++i;
}
else {
j = pi[j - 1];
}
}
}
復雜度分析
眾所周知KMP時間復雜度是O(N+M)。查找的過程O(N)先不說,我現在還比較困惑為什么構建前綴函數的過程只有O(M)次操作。j不是在回滾嗎?網上說用均攤分析,但是似乎都沒說清楚,有知道的人能說一下嗎?