計算機上的非數值處理的對象大部分是字符串數據, 字符串一般簡稱為串。串是一種特殊的
線性表, 其特殊性體現在數據元素是一個字符, 也就是說, 串是一種內容受限的線性表。
1、串的定義
串(string)(或字符串)是由零個或多個字符組成的有限序列,其中每個字符都來自某個字符表( Alphabet) Σ,比如 ASCII 字符集或 Unicode 字符集。 一般記為:
其中,str是串的名, 用雙引號括起來的字符序列是串的值;ai(1<=n<=n)可以是字母、 數字或其他字符;串中字符的數目n稱為串的長度。零個字符的串稱為空串(null string), 其長度為零。
串中任意個連續的字符組成的子序列稱為該電的子串。包含子串的串相應地稱為主串。 通常
稱字符在序列中的序號為該字符在串中的位置。
兩個串相等,只有當兩個串的長度相等,並且各個對應位置的字符都相等時才相等。
一個或多個空格組成的串" "稱為空格串 (blank string), 請注意:此處不是空串), 其長度為串
中空格字符的個數。
2、串的基本操作
串的邏輯結構和線性表極為相似,區別僅在於串的數據對象約束為字符集。
然而,串的基本操作和線性表有很大差別。在線性表的基本操作中,大多以 “單個元素” 作為操作對象。
例如,在線性表中查找某個元素,求取某個元素,在某個位置上插入一個元素或刪除一個元素等;而在串的基本操作中,通常以 “ 串的整體 ” 作為操作對象,例如,在串中查找某個子串,求取一個子串,在串的某個位置上插入一個子串,以及刪除一個子串等。
串的抽象數據類型定義:
/**
* @Author 三分惡
* @Date 2020/9/8
* @Description 串的抽象定義
*/
public interface IString {
int length(); //查詢串的長度
char charAt(int i); //返回第i個字符
String subStr(int i,int k); //返回從第 i 個字符起、長度為 k 的子串
String prefix(int k); //返回長度為k的前綴
String suffix(int k); //返回長度為k的后綴
boolean equals(String t); //判斷t是否與當前字符串相等
void concat(String t); //將t拼接在當前字符串之后
int indexOf(String p); //若 p是當前字符串的一個子串,則返回該子串的起始位置;否則返回-1
}
3、串的存儲結構
與線性表類似, 串也有兩種基本存儲結構:順序存儲和鏈式存儲。但考慮到存儲效率和算法的方便性, 串多采用順序存儲結構。
3.1、串的順序存儲
類似於線性表的順序存儲結構, 用一組地址連續的存儲單元存儲串值的字符序列。 按照預定義的大小, 為每個定義的串變量分配一個固定長度的存儲區。
可以用一個定長的char數組來表示:
private int defaultSize=100; //字符數組默認容量
private int length; // 字符串長度
private char[] items; //字符數組
//構造方法,初始化字符數組
public SequeueString() {
items=new char[defaultSize];
}
當然,實際應用中字符串實際需要的空間差別不定,所以可以參照前面的線性表,進行動態擴容。
3.1、串的鏈式存儲
對於串的鏈式存儲結構, 與線性表是相似的, 但由於串結構的特殊性, 結構中的每個元素數據是一個字符, 如果也簡單的應用鏈表存儲串值, 一個結點對應一個字符, 就會存在很大的空間浪費。 因此, 一個結點可以存放一個字符, 也可以考慮存放多個字符, 最后一個結點若是未被占滿時, 可以用 “#” 或其他非串值字符補全。
但串的鏈式存儲結構除了在連接串與串操作時有一定方便之外, 總的來說不如順序存儲靈活, 性能也不如順序存儲結構好。
4、串的模式匹配算法
子串的定位運算通常稱為串的模式匹配或串匹配。此運算的應用非常廣泛,比如在搜索引擎、拼寫檢查、 語言翻譯、數據壓縮等應用中, 都需要進行串匹配。
著名的模式匹配算法有BF (蠻力)算法和、KMP 算法和BM算法, 下面詳細介紹這些算法。
4.1、BF算法
4.1.1、算法描述
Brute-Force算法,簡稱BF算法,中文可以譯為蠻力算法。
BF算法是最直接、直觀的方法。想象:
- 將主串和模式串分別寫在兩條印有等間距方格的紙帶上,主串對應的紙帶固定,模式串的首字符與主串的首字符對齊,沿水平方向放好。主串的前m個字符將與模式串的m個字符兩兩對齊。
- 接下來,自左向右檢查對齊的每一對字符:如果匹配,則轉向下一對字符;
- 如果失配,則說明在這個位置主串與模式串無法匹配,是將模式串對應的紙帶右移一個字符,然后從首字符開始重新對比。
- 若經過檢查,當前的m個字符對都是匹配的,則匹配成功,並返回匹配子串的位置。
模式串中,黑色方格為經檢查與主串匹配的字符,灰色方格為失配的字符,白色方格為無需檢查的字符。
4.1.2、算法實現
/**
*
* @param s 目標串
* @param t 模式串
* @return 返回匹配子串的起始位置
*/
public static int bruceForce(String s,String t){
int i=0,j=0;
while (i<s.length()&&j<t.length()){ //遍歷兩個字符串
if (s.charAt(i)==t.charAt(j)){ //繼續往后比較字符
i++;
j++;
}else{ // 字符不相等
i=i-j+1; //位置回退,重新比較
j=0;
}
}
if (j>=t.length()){ //匹配成功
return i-t.length();
}
//匹配失敗
return -1;
}
4.1.3、算法分析
分析BF算法,必須考慮BF算法的最好情況和最壞情況。
- 最好情況
最好情況下,每趟不成功的匹配都發生在模式串的第一個字符與主串中相應字符的比較。
例如:
s="aaaaaba";
t="ba";
主串的長度為n, 子串的長度為m, 假設從主串的第i個位置開始與模式串匹配成功,則在前 i-1 趟匹配中字符總共比較了 i-I 次;若第 i 趟成功的字符比較次數為 m, 則總比較次數為i- 1+m。 對於成功匹配的主串, 其起始位置由 1 到 n-m+I, 假定這 n-m+I 個起始位置上的匹配成功概率相等, 則最好的情況下匹配成功的平均比較次數為:
即最好情況下的平均時間復雜度是 O(n + m)。
- 最壞情況
每趟不成功的匹配都發生在模式串的最后一個字符與主串中相應字符的比較。
例如:
s= "aaaaaab"
t= "aab"
假設從主串的第 l 個位置開始與模式串匹配成功, 則在前 i- 1 趟匹配中字符總共比較了
(i-1) ×m 次;若第 l 趟成功的字符比較次數為 m,_則總比較次數 i X m。 因此最壞情況下匹配成功
的平均比較次數為:
即最壞情況下的平均時間復雜度是 O(n×m)。
4.2、KMP算法
4.2.1、算法原理
針對BF算法,有一種改進的算法,算法是由 Knuth 、 Morris 和 Pratt 同時設計實現的, 因此簡稱 KMP 算法。
學習一下KMP算法的整體思路:
KMP算法和BF算法的“開局”是一樣的,同樣是把主串和模式串的首位對齊,從左到右對逐個字符進行比較。
第一輪:模式串和主串的第一個等長子串比較,發現前5個字符都是匹配的,第6個字符不匹配,是一個“壞字符”:
和BF算法不同的是,KMP算法利用到了我們已經匹配的字符,那么究竟是如何利用已匹配的前綴 “GTGTG” 呢?
在前綴“GTGTG”當中,后三個字符“GTG”和前三位字符“GTG”是相同的:
在下一輪的比較時,只有把這兩個相同的片段對齊,才有可能出現匹配。這兩個字符串片段,分別叫做最長可匹配后綴子串和最長可匹配前綴子串。
第二輪:所以在這一輪里,我們是把匹配串后移一位,而是移兩位,這樣就剛好讓兩個“GTG”對齊了。接着從上面的壞字符“A”開始比較。
“A” 仍然是個壞字符,這時候,匹配前綴縮短了,變成了“GTC”。
按照第一輪的思路,重新確定最長可匹配前綴子串和最長可匹配后綴子串。
第三輪:再次讓讓兩個“G”對齊,把模式串后移兩位,繼續從剛才主串的壞字符A開始進行比較:
KMP算法的整體思路:在已匹配的前綴當中尋找到最長可匹配后綴子串和最長可匹配前綴子串,在下一輪直接把兩者對齊,從而實現模式串的快速移動。
現在新的問題又來了,怎么找到最長可匹配后綴子串和最長可匹配前綴子串呢?
答案是可以事先把兩個子串緩存到一個數組里,這個數組稱為next數組,接下來看看next數組的生成:
next數組
next數組是一個一維數組,數組的下標代表了“已匹配前綴的下一個位置”,元素的值則是“最長可匹配前綴子串的下一個位置”。
- 當模式串的第一個字符就和主串不匹配時,並不存在已匹配前綴子串,更不存在最長可匹配前綴子串。這種情況對應的next數組下標是0,next[0]的元素值也是0。
- 如果已匹配前綴是G、GT、GTGTGC,並不存在最長可匹配前綴子串,所以對應的next數組元素值(next[1],next[2],next[6])同樣是0。
- GTG的最長可匹配前綴是G,對應數組中的next[3],元素值是1。
以此類推,
- GTGT 對應 next[4],元素值是2。
- GTGTG 對應 next[5],元素值是3。
可以通過next數組,快速尋找到最長可匹配前綴的下一個位置,然后把這兩個位置對齊。
比如下面的場景,我們通過壞字符下標5,可以找到next[5]=3,即最長可匹配前綴的下一個位置:
那么,next數組如何事先生成呢?
最簡單的方法是從最長的前綴子串開始,把每一種可能情況都做一次比較。假設模式串的長度是m,生成next數組所需的最大總比較次數是1+2+3+4+......+m-2 次。
這種方法效率太低,如何進行優化呢?
我們可以采用類似“動態規划”的方法。首先next[0]和next[1]的值肯定是0,因為這時候不存在前綴子串;從next[2]開始,next數組的每一個元素都可以由上一個元素推導而來。
已知next[i]的值,如何推導出next[i+1]呢?看一下上述next數組的填充過程:
- 們設置兩個變量i和j,其中i表示“已匹配前綴的下一個位置”,也就是待填充的數組下標,j表示“最長可匹配前綴子串的下一個位置”,也就是待填充的數組元素值。
當已匹配前綴不存在的時候,最長可匹配前綴子串當然也不存在,所以i=0,j=0,此時next[0] = 0。
- 接下來,我們讓已匹配前綴子串的長度加1:此時的已匹配前綴是G,由於只有一個字符,同樣不存在最長可匹配前綴子串,所以i=1,j=0,next[1] = 0
- 接下來,我們讓已匹配前綴子串的長度繼續加1:此時的已匹配前綴是GT,我們需要開始做判斷了:由於模式串當中 pattern[j] != pattern[i-1],即G!=T,最長可匹配前綴子串仍然不存在。
所以當i=2時,j仍然是0,next[2] = 0。
- 接下來,我們讓已匹配前綴子串的長度繼續加1:此時的已匹配前綴是GTG,由於模式串當中 pattern[j] = pattern[i-1],即G=G,最長可匹配前綴子串出現了,是G。
所以當i=3時,j=1,next[3] = next[2]+1 = 1。
- 接下來,我們讓已匹配前綴子串的長度繼續加1:此時的已匹配前綴是GTGT,由於模式串當中 pattern[j] = pattern[i-1],即T=T,最長可匹配前綴子串又增加了一位,是GT。
所以當i=4時,j=2,next[4] = next[3]+1 = 2。
- 接下來,我們讓已匹配前綴子串的長度繼續加1:
此時的已匹配前綴是GTGTG,由於模式串當中 pattern[j] = pattern[i-1],即G=G,最長可匹配前綴子串又增加了一位,是GTG。
所以當i=5時,j=3,next[5] = next[4]+1 = 3。
- 接下來,我們讓已匹配前綴子串的長度繼續加1:
此時的已匹配前綴是GTGTGC,這時候需要注意了,模式串當中 pattern[j] != pattern[i-1],即T != C,這時候該怎么辦呢?
這時候,我們已經無法從next[5]的值來推導出next[6],而字符C的前面又有兩段重復的子串“GTG”。那么,我們能不能把問題轉化一下?
或許聽起來有些繞:我們可以把計算“GTGTGC”最長可匹配前綴子串的問題,轉化成計算“GTGC”最長可匹配前綴子串的問題。
這樣的問題轉化,也就相當於把變量j回溯到了next[j],也就是j=1的局面(i值不變):
回溯后,情況仍然是 pattern[j] != pattern[i-1],即T!=C。那么我們可以把問題繼續進行轉化:
問題再次的轉化,相當於再一次把變量j回溯到了next[j],也就是j=0的局面:
回溯后,情況仍然是 pattern[j] != pattern[i-1],即G!=C。j已經不能再次回溯了,所以我們得出結論:i=6時,j=0,next[6] = 0。
以上就是next數組元素的推導過程。
4.2.2、算法實現
最后來整理一下KMP算法的步驟:
-
對模式串預處理,生成next數組
-
進入主循環,遍歷主串
2.1. 比較主串和模式串的字符2.2. 如果發現壞字符,查詢next數組,得到匹配前綴所對應的最長可匹配前綴子串,移動模式串到對應位置
2.3.如果當前字符匹配,繼續循環
/**
* @Author 三分惡
* @Date 2020/9/10
* @Description KMP字符串匹配算法
*/
public class KMP {
public static int kmp(String str,String pattern){
//預處理
int [] next=getNext(pattern);
int j=0;
for (int i=0;i<str.length();i++){
while (j>0&&str.charAt(i)!=pattern.charAt(j)){
//遇到壞字符時,查詢next數組並改變模式串的起點
j=next[j];
}
if (str.charAt(i)==pattern.charAt(j)){
j++;
}
//找到匹配串
if (j==pattern.length()){
return i-1;
}
}
return -1;
}
/**
* 獲取next數組
* @param pattern
* @return
*/
private static int [] getNext(String pattern){
int next[]=new int[pattern.length()];
int j=0;
for (int i=2;i<pattern.length();i++){
while (j!=0&&pattern.charAt(j)!=pattern.charAt(i-1)){
//從next[i+1]的求解回溯到 next[j]
j=next[j];
}
if (pattern.charAt(j)==pattern.charAt(i-1)){
j++;
}
next[i] = j;
}
return next;
}
public static void main(String[] args) {
/* String str = "ATGTGAGCTGGTGTGTGCFAA";
String pattern = "GTGTGCF";*/
String str = "ABABBABBAAB";
String pattern = "ABB";
int index = kmp(str, pattern);
System.out.println("首次出現位置:" + index);
}
}
4.2.2、算法時間復雜度
KMP算法包含兩步,第一步生成next數組,時間復雜度估算為O(m);第二步是遍歷主串,時間復雜度為O(n)。
因此,KMP算法的時間復雜度是O(m+n),其中m是模式串的長度,n是主串的長度。
4.3、BM算法
KMP已經很巧妙了,但是還有更巧妙的BM算法。
Boyer-Moore算法不僅效率高,而且構思巧妙,容易理解。1977年,德克薩斯大學的Robert S. Boyer教授和J Strother Moore教授發明了這種算法。
BM算法算法的構思是:不斷自右向左地比較模式串P與主串T,一旦發現失配,則利用此前的掃描所提供的信息,將P右移一定距離,然后重新自右向左掃描比較。該算法有兩種啟發式策略⎯⎯借助壞字符( Bad Character)和好后綴( Good Suffix)確定移動的距離⎯⎯也可將二者結合起來,同時采用。
4.3.1、壞字符規則
從上面的KMP中我們也知道了,“壞字符”就是失配的字符。
以下面的字符串為例:
- 當模式串和主串的第一個等長子串比較時,子串的最后一個字符T就是壞字符:
當檢測到第一個壞字符之后,我們並不需要讓模式串一位一位向后挪動和比較。因為只有模式串與壞字符T對齊的位置也是字符T的情況下,兩者才有匹配的可能。
- 可以發現,模式串的第1位字符也是T,這樣一來我們就可以對模式串做一次“乾坤大挪移”,直接把模式串當中的字符T和主串的壞字符對齊,進行下一輪的比較:
壞字符的位置越靠右,下一輪模式串的挪動跨度就可能越長,節省的比較次數也就越多。這就是BM算法從右向左檢測的好處。
后移位數 = 壞字符的位置 - 模式串中的上一次出現位置
- 接下來,我們繼續逐個字符比較,發現右側的G、C、G都是一致的,但主串當中的字符A,又是一個壞字符:
- 按照剛才的方式,找到模式串的第2位字符也是A,於是我們把模式串的字符A和主串中的壞字符對齊,進行下一輪比較:
- 接下來,我們繼續逐個字符比較,這次發現全部字符都是匹配的,比較完成:
如果模式串中不存在和壞字符相同的字符怎么辦?直接將模式串移動到壞字符的下一位即可:
代碼實現:
public class BM {
/**
* 基於壞字符規則的BM算法
* @param str
* @param pattern
* @return
*/
public static int boyerMooreBadChar(String str,String pattern){
int strLength = str.length();
int patternLength = pattern.length();
//模式串的起始位置
int start = 0;
//遍歷主串
while (start <= strLength - patternLength) {
int i;
//模式串從右往左比較
for(i=patternLength-1;i>=0;i--){
if (str.charAt(start+i) != pattern.charAt(i))
//發現壞字符,跳出比較,i記錄了壞字符的位置
break;
}
//匹配成功
if (i<0){
return start;
}
//獲取壞字符在模式串中對應的字符
int charIndex=findCharacter(pattern,str.charAt(start+i),i);
//計算移動位數
int bcOffset = charIndex>=0 ? i-charIndex : i+1;
//移動
start+=bcOffset;
}
return -1;
}
/**
* 在模式串中查找和壞字符相同的字符的位置
* @param pattern
* @param badCharacter
* @param index
*/
private static int findCharacter(String pattern, char badCharacter, int index){
//從右往左查找
for(int i= index-1; i>=0; i--){
if(pattern.charAt(i) == badCharacter){
return i;
}
}
//不存在返回-1
return -1;
}
public static void main(String[] args) {
String str = "GTTATAGCTGGTAGCGGCGAA";
String pattern = "GCGAA";
int index = boyerMooreBadChar(str, pattern);
System.out.println("首次出現位置:" + index);
}
}
4.3.2、好后綴規則
BM 算法的思想,是盡可能地利用此前已進行過的比較所提供的信息,以加速模式串的移動。
上述壞字符策略,就很好地體現了這一構思:既然已經發現 P[j]與 T[i+j]不匹配,就應該從 P 中找出一個與 T[i+j]匹配的字符,將二者對齊之后,重新自右向左開始比較。
然而,仔細分析后我們可以發現,壞字符規則只利用了此前(最后一次)失敗的比較所提供的信息。實際上,在失敗之前往往還會有一系列成功的比較,它們也能提供大量的信息,對此我們能否加以利用呢?
來看一組例子。
我們繼續使用“壞字符規則”。
-
從后向前比對字符,我們發現后面三個字符都是匹配的,到了第四個字符的時候,發現壞字符G:
-
接下來我們在模式串找到了對應的字符G,但是按照壞字符規則,模式串僅僅能夠向后挪動一位:
這時候壞字符規則顯然並沒有起到作用,為了能真正減少比較次數,輪到我們的好后綴規則出場了。 -
我們回到第一輪的比較過程,發現主串和模式串都有共同的后綴“GCG”,這就是所謂的“好后綴”。
如果模式串其他位置也包含與“GCG”相同的片段,那么我們就可以挪動模式串,讓這個片段和好后綴對齊,進行下一輪的比較:
后移位數 = 好后綴的位置 - 搜索詞中的上一次出現位置
再舉一個例子,字符串"ABCDAB"的后一個"AB"是"好后綴"。那么它的位置是5(從0開始計算,取最后的"B"的值),在"搜索詞中的上一次出現位置"是1(第一個"B"的位置),所以后移 5 - 1 = 4位,前一個"AB"移到后一個"AB"的位置。
如果沒有“好后綴”呢?字符串"ABCDEF"的"EF"是好后綴,則"EF"的位置是5 ,上一次出現的位置是 -1(即未出現),所以后移 5 - (-1) = 6位,即整個字符串移到"F"的后一位。
好后綴有3個需要注意的點:
-
(1)"好后綴"的位置以最后一個字符為准。假定"ABCDEF"的"EF"是好后綴,則它的位置以"F"為准,即5(從0開始計算)。
-
(2)如果"好后綴"在搜索詞中只出現一次,則它的上一次出現位置為 -1。比如,"EF"在"ABCDEF"之中只出現一次,則它的上一次出現位置為-1(即未出現)。
-
(3)如果"好后綴"有多個,則除了最長的那個"好后綴",其他"好后綴"的上一次出現位置必須在頭部。比如,假定"BABCDAB"的"好后綴"是"DAB"、"AB"、"B",請問這時"好后綴"的上一次出現位置是什么?回答是,此時采用的好后綴是"B",它的上一次出現位置是頭部,即第0位。這個規則也可以這樣表達:如果最長的那個"好后綴"只出現一次,則可以把搜索詞改寫成如下形式進行位置計算"(DA)BABCDAB",即虛擬加入最前面的"DA"。
那么在什么時候用“壞字符”,什么時候用“好后綴”呢?
Boyer-Moore算法的基本思想是,每次后移這兩個規則之中的較大值。
算法實現:
/**
* @Author 三分惡
* @Date 2020/9/15
* @Description BM算法
*/
public class BM {
/**
* 基於壞字符規則的BM算法
*
* @param str
* @param pattern
* @return
*/
public static int boyerMoore(String str, String pattern) {
int strLength = str.length();
int patternLength = pattern.length();
//模式串的起始位置
int start = 0;
//遍歷主串
while (start <= strLength - patternLength) {
int i;
//模式串從右往左比較
for (i = patternLength - 1; i >= 0; i--) {
if (str.charAt(start + i) != pattern.charAt(i))
//發現壞字符,跳出比較,i記錄了壞字符的位置,壞字符后都是好后綴
break;
}
//匹配成功
if (i < 0) {
return start;
}
//獲取壞字符在模式串中對應的字符
int charIndex = findBadChar(pattern, str.charAt(start + i), i);
//計算壞字符規則移動位數
int badOffset = charIndex >= 0 ? i - charIndex : i + 1;
//計算好后綴移動規則
int goodOffset=findGoodSuffix(pattern,i);
int offset=badOffset>=goodOffset?badOffset:goodOffset;
//移動
start += offset;
}
return -1;
}
/**
* 在模式串中查找和壞字符相同的字符的位置
* @param pattern
* @param badCharacter
* @param index
*/
private static int findBadChar(String pattern, char badCharacter, int index) {
//從右往左查找
for (int i = index - 1; i >= 0; i--) {
if (pattern.charAt(i) == badCharacter) {
return i;
}
}
//不存在返回-1
return -1;
}
/**
* 應用好后綴規則計算移動位數
* @param pattern
* @param goodCharSuffix
* @return
*/
private static int findGoodSuffix(String pattern,int goodCharSuffix) {
int result = -1;
// 模式串長度
int moduleLength = pattern.length();
// 好字符數
int goodCharNum = moduleLength -1 - goodCharSuffix;
for(;goodCharNum > 0; goodCharNum--){
String endSection = pattern.substring(moduleLength - goodCharNum, moduleLength);
String startSection = pattern.substring(0, goodCharNum);
if(startSection.equals(endSection)){
result = moduleLength - goodCharNum;
}
}
return result;
}
public static void main(String[] args) {
String str = "GTTATAGCTGGTAGCGGCGAA";
String pattern = "GCGAA";
int index = boyerMoore(str, pattern);
System.out.println("首次出現位置:" + index);
}
}
本文為學習筆記類博客,主要資料來源如下!
參考:
【1】:鄧俊輝 編著. 《數據結構與算法》
【2】:王世民 等編著 . 《數據結構與算法分析》
【3】: Michael T. Goodrich 等編著.《Data-Structures-and-Algorithms-in-Java-6th-Edition》
【4】:嚴蔚敏、吳偉民 編著 . 《數據結構》
【5】:程傑 編著 . 《大話數據結構》
【6】:阮一峰:字符串匹配的KMP算法
【7】:阮一峰:字符串匹配的Boyer-Moore算法
【8】:程序員小灰 :漫畫:什么是KMP算法?
【9】:從頭到尾徹底理解KMP(2014年8月22日版)
【10】:如何更好地理解和掌握 KMP 算法?
【11】:圖解 KMP 算法
【12】:程序員小灰:漫畫:如何優化 “字符串匹配算法”?
【13】:字符串匹配的Boyer-Moore算法
【14】:字符串匹配算法-BM