串(Sequence)
數據結構與算法筆記:戀上數據結構筆記目錄
串(前綴、後綴)
本課程研究的串是開發中非常熟悉的字符串,是由若干個字符組成的有限序列
字符串 thank
的前綴(prefix)、真前綴(proper prefix)、後綴(suffix)、真後綴(proper suffix)
串匹配算法
- 查找一個模式串(pattern)在文本串(text)中的位置:
String text = "Hello World";
String pattern = "or";
text.indexOf(pattern); // 7
text.indexOf(pattern); // -1
幾個經典的串匹配算法:
- 蠻力(Brute Force)
- KMP
- Boyer-Moore
- Karp-Rabin / Rabin-Karp
- Sunday
下面用 tlen
代表文本串 text 的長度,plen
代表模式串 pattern 的長度;
蠻力(Brute Force)
- 以字符爲單位,從左到右移動模式串,直到匹配成功 ;
蠻力算法有 2 種常見實現思路:
蠻力1 – 執行過程 + 實現
/**
* 蠻力匹配
*/
public static int indexOf(String text, String pattern) {
if (text == null || pattern == null) return -1;
char[] textChars = text.toCharArray();
int tlen = textChars.length;
char[] patternChars = pattern.toCharArray();
int plen = patternChars.length;
if (tlen == 0 || plen == 0 || tlen < plen) return -1;
int pi = 0, ti = 0;
while (pi < plen && ti < tlen) {
if (textChars[ti] == patternChars[pi]) {
ti++;
pi++;
} else {
ti = ti - pi + 1;
// ti -= pi - 1;
pi = 0;
}
}
return pi == plen ? ti - pi : -1;
}
蠻力1 – 優化
/**
* 蠻力匹配 - 改進
*/
public static int indexOf(String text, String pattern) {
if (text == null || pattern == null) return -1;
char[] textChars = text.toCharArray();
int tlen = textChars.length;
char[] patternChars = pattern.toCharArray();
int plen = patternChars.length;
if (tlen == 0 || plen == 0 || tlen < plen) return -1;
int pi = 0, ti = 0;
while (pi < plen && ti - pi <= tlen - plen) { // ti - pi <= tlen - plen 是關鍵
if (textChars[ti] == patternChars[pi]) {
ti++;
pi++;
} else {
ti = ti - pi + 1;
// ti -= pi - 1;
pi = 0;
}
}
return pi == plen ? ti - pi : -1;
}
蠻力2 – 執行過程 + 實現
/**
* 蠻力匹配2
*/
public static int indexOf(String text, String pattern) {
if (text == null || pattern == null) return -1;
char[] textChars = text.toCharArray();
int tlen = textChars.length;
char[] patternChars = pattern.toCharArray();
int plen = patternChars.length;
if (tlen == 0 || plen == 0 || tlen < plen) return -1;
// 如果模式串的頭在 tlen - plen 後面, 必然會匹配失敗
int tiMax = tlen - plen;
for (int ti = 0; ti <= tiMax; ti++) {
int pi = 0;
for (; pi < plen; pi++) {
if (textChars[ti + pi] != patternChars[pi]) break;
}
if (pi == plen) return ti;
}
return -1;
}
蠻力 – 性能分析
最好情況:
- 只需一輪比較就完全匹配成功,比較 m 次( m 是模式串的長度)
- 時間複雜度爲 O(m)
最壞情況(字符集越大,出現概率越低):
- 執行了 n – m + 1 輪比較( n 是文本串的長度)
- 每輪都比較至模式串的末字符後失敗( m – 1 次成功,1 次失敗)
- 時間複雜度爲 O(m ∗ (n − m + 1)),由於一般 m 遠小於 n,所以爲 O(mn)
KMP
KMP 是 Knuth–Morris–Pratt 的簡稱(取名自3位發明人的名字),於1977年發佈
蠻力 vs KMP
首先大概瞭解一下兩者的差距:
蠻力算法:會經歷很多次沒有必要的比較。
KMP算法:充分利用了此前比較過的內容,可以很聰明地跳過一些不必要的比較位置。
KMP – next表的使用
KMP 會預先根據模式串的內容生成一張 next 表(一般是個數組):
例如,下圖串匹配時, pi = 7 時 失配
- 根據失配的索引 7 查表,查到的元素索引爲 3
next[pi] == 3
- 將模式串索引爲 3 的元素移動到失配的位置
pi = next[pi]; // pi = 3
- 向右移動的距離爲
p - next[pi]
再比如:pi = 3 時失配, next[3] = 0
,將 0 位置的元素移到失配處:pi = next[pi]
KMP – 核心原理(構造next表)
d、e 失配時,如果希望 pattern 能夠一次性向右移動一大段距離,然後直接比較 d、c 字符
- 前提條件是 A 必須等於 B
所以 KMP 必須在失配字符 e 左邊的子串中找出符合條件的 A、B,從而得知向右移動的距離
向右移動的距離:e左邊子串的長度 – A的長度,等價於:e的索引 – c的索引,
且 c的索引 == next[e的索引],所以向右移動的距離:e的索引 – next[e的索引]
總結:
- 如果在 pi 位置失配,向右移動的距離是
pi – next[pi]
,所以next[pi]
越小,移動距離越大 next[pi]
是 pi 左邊子串的真前綴後綴的最大公共子串長度
真前綴後綴的最大公共子串長度
如何求 真前綴後綴的最大公共子串長度:
構造 next 表
根據最大公共子串長度得到 next 表:
-1的精妙之處
爲什麼要將首字符設置爲 - 1?
KMP – 主算法代碼實現
KMP – 爲什麼是“最大“公共子串長度?
假設文本串是 AAAAABCDEF
,模式串是 AAAAB
:
KMP – next表的構造思路及實現
已知 next[i] == n
;
① 如果 pattern.charAt(i)
== pattern.charAt(n)
- 那麼
next[i + 1]
==n + 1
② 如果 pattern.charAt(i)
!= pattern.charAt(n)
- 已知
next[n]
==k
- 如果
pattern.charAt(i)
==pattern.charAt(k)
那麼next[i + 1]
==k + 1
- 如果
pattern.charAt(i)
!=pattern.charAt(k)
將 k 代入 n ,重複執行 ②
構造 next 表 代碼實現:
private static int[] next(String pattern) {
char[] chars = pattern.toCharArray();
int[] next = new int [chars.length];
next[0] = -1;
int i = 0;
int n = -1;
int iMax = chars.length - 1;
while (i < iMax) {
if (n < 0 || chars[i] == chars[n]) {
next[++i] = ++n;
} else {
n = next[n];
}
}
return next;
}
KMP – next表的不足之處
假設文本串是 AAABAAAAB
,模式串是 AAAAB
KMP – next表的優化思路及實現
- 如果
pattern[i]
!=d
,就讓模式串滑動到next[i]
(也就是n)位置跟d
進行比較; - 如果
pattern[n]
!=d
,就讓模式串滑動到next[n]
(也就是k)位置跟d
進行比較; - 如果
pattern[i]
==pattern[n]
,那麼當i
位置失配時,
模式串最終必然會滑到k
位置跟d
進行比較,
所以next[i]
直接存儲next[n]
(也就是k)即可;
next 表 的優化代碼實現:
private static int[] next(String pattern) {
char[] chars = pattern.toCharArray();
int[] next = new int [chars.length];
next[0] = -1;
int i = 0;
int n = -1;
int iMax = chars.length - 1;
while (i < iMax) {
if (n < 0 || chars[i] == chars[n]) {
// 優化
++i;
++n;
if (chars[i] == chars[n]) {
next[i] = next[n];
} else {
next[i] = n;
}
} else {
n = next[n];
}
}
return next;
}
KMP – next表的優化效果
KMP – 性能分析
KMP 主邏輯:
- 最好時間複雜度:O(m)
- 最壞時間複雜度:O(n),不超過O(2n)
next 表的構造過程跟 KMP 主體邏輯類似:
- 時間複雜度:O(m)
KMP 整體:
- 最好時間複雜度:O(m)
- 最壞時間複雜度:O(n + m)
- 空間複雜度: O(m)
KMP完整源碼
public class KMP {
public static int indexOf(String text, String pattern) {
// 檢測數據合法性
if (text == null || pattern == null) return -1;
char[] textChars = text.toCharArray();
int tlen = textChars.length;
char[] patternChars = pattern.toCharArray();
int plen = patternChars.length;
if (tlen == 0 || plen == 0 || tlen < plen) return -1;
// next表
int[] next = next(pattern);
int pi = 0, ti = 0;
while (pi < plen && ti < tlen) {
// next表置-1的精妙之處, pi = -1 則 pi = 0, ti++ 相當於模式串後一一位
if (pi < 0 || textChars[ti] == patternChars[pi]) {
ti++;
pi++;
} else {
pi = next[pi];
}
}
return pi == plen ? ti - pi : -1;
}
/**
* next 表構造 - 優化
*/
private static int[] next(String pattern) {
char[] chars = pattern.toCharArray();
int[] next = new int [chars.length];
next[0] = -1;
int i = 0;
int n = -1;
int iMax = chars.length - 1;
while (i < iMax) {
if (n < 0 || chars[i] == chars[n]) {
++i;
++n;
if (chars[i] == chars[n]) {
next[i] = next[n];
} else {
next[i] = n;
}
} else {
n = next[n];
}
}
return next;
}
/**
* next表構造
*/
private static int[] next2(String pattern) {
char[] chars = pattern.toCharArray();
int[] next = new int [chars.length];
next[0] = -1;
int i = 0;
int n = -1;
int iMax = chars.length - 1;
while (i < iMax) {
if (n < 0 || chars[i] == chars[n]) {
next[++i] = ++n;
} else {
n = next[n];
}
}
return next;
}
}
蠻力 vs KMP
蠻力算法爲何低效?
當字符失配時:
- 蠻力算法
ti
回溯到左邊位置
pi
回溯到 0 - KMP 算法
ti
不必回溯
pi
不一定要回溯到0
Boyer-Moore
Boyer-Moore 算法,簡稱 BM 算法,由 Robert S.Boyer 和 J Strother Moore 於 1977 年發明;
- 最好時間複雜度:O(n / m),最壞時間複雜度:O(n + m)
- 該算法從模式串的尾部開始匹配(自後向前)
BM 算法的移動字符數是通過 2 條規則計算出的最大值:
- 壞字符規則(Bad Character,簡稱 BC)
- 好後綴規則(Good Suffix,簡稱 GS)
壞字符規則(Bad Character)
當 Pattern 中的字符 E
和 Text 中的 S
失配時,稱 S
爲 壞字符;
- 如果 Pattern 的未匹配子串中不存在壞字符,直接將 Pattern 移動到壞字符的下一位
- 否則,讓 Pattern 的未匹配子串中最靠右的壞字符與 Text 中的壞字符對齊
好後綴規則(Good Suffix)
MPLE
是一個成功匹配的後綴,E
、LE
、PLE
、MPLE
都是 好後綴;
- 如果 Pattern 中找不到與好後綴對齊的子串,直接將 Pattern 移動到好後綴的下一位
- 否則,從 Pattern 中找出子串與 Text 中的好後綴對齊
BM算法最好情況與最壞情況
最好情況,時間複雜度:O(n / m);
最壞情況,時間複雜度:O(n + m) ,其中 O(m) 爲構造表的時間;
Karp-Rabin / Rabin-Kary
Rabin-Karp 算法(或 Karp-Rabin 算法),簡稱 RK 算法,是一種 基於hash 的字符串匹配算法
- 由 Richard M.Karp 和 Michael O.Rabin 於 1987 年發明
大致原理:
- 將 Pattern 的 hash 值與 Text 中每個子串的 hash 值進行比較
- 某一子串的 hash 值可以根據上一子串的 hash 值在 O(1) 時間內計算出來
Sunday
Sunday 算法由 Daniel M.Sunday 在 1990 年提出,它的思想跟 BM算法 很相似
- 從前向後匹配(BM算法是從後往前)
- 當匹配失敗時,關注的是 Text 中參與匹配的子串的下一位字符 A
如果 A 沒有在 Pattern 中出現,則直接跳過,即 移動位數 = Pattern長度 + 1
否則,讓 Pattern 中最靠右的 A 與 Text 中的 A 對齊