大話資料結構學習筆記(五)——串
串(string)是由零個或多個字元組成的有限序列,又名叫字串。
1 串的定義
一般記為s="a[1]a[2]......a[n]"(n≥0)
,其中,s
是串的名稱,用雙引號(有些書中也用單引號)括起來的字元序列是串的值,注意引號不屬於串的內容。a[i](1≤i≤n)
可以是字母、數字或其他字元,i
就是該字元在串中的位置。串中的字元數目n
稱為串的長度,定義中談到“有限”是指長度n
是一個有限的數值。零個字元的串稱為空串(nullstring),它的長度為零, 可以直接用兩雙引號“""
”表示,也可以用希臘字母“Φ
”來表示。所謂的序列,說明串的相鄰字元之間具有前驅和後繼的關係。
空格串,是隻包含空格的串。注意它與空串的區別,空格串是有內容有長度的,而且可以不止一個空格。
子串
子串在主串中的位置就是子串的第一個字元在主串中的序號。
2 串的比較
串的比較是通過組成串的字元之間的編碼來進行的,而字元的編碼指的是字元在對應字符集中的序號。
計算機中的常用字元是使用標準的ASCII
編碼,更準確一點,由7位二進位制數表示一個字元,總共可以表示128個字元。後來發現一些特殊符號的出現,128個不夠用,於是擴充套件ASCII
碼由8位二進位制數表示一個字元,總共可以表示256個字元,這已經足夠滿足以英語為主的語言和特殊符號進行輸入、儲存、輸出等操作的字元需要了。可是,單我們國家就有除漢族外的滿、回、藏、蒙古、維吾爾等多個少數民族文字,換作全世界估計要有成百上千種語言與文字,顯然這256個字元是不夠的,因此後來就有了Unicode
ASCII
碼相容,Unicode
的前256個字元與ASCII
碼完全相同。
那麼對於兩個串不相等時,如何判定它們的大小呢。我們這樣定義:
給定兩個串:s="a[1]a[2]......a[n]"
,t="b[1]b[2]......b[n]"
,當滿足以下條件之一
時,s<t
。
-
n<m
,且a[i]=b[i](i=1,2,...,n)
。例如當s="hap"
,t="happy"
, 就有s<t
。 因為t
比s
多出了兩個字母。 - 存在某個
k≤min(m,n)
a[i]=b[i](i=1,2,...,k-1)
,a[k]<b[k]
。
3 串的抽象資料型別
串的基本操作與線性表是有很大差別的。線性表更關注的是單個元素的操作, 比如查詢一個元素, 插入或刪除一個元素,但串中更多的是查詢子串位置、得到指定位置子串、替換子串等操作。
ADT 串(string)
Data
串中元素僅由一個字元組成, 相鄰元素具有前驅和後繼關係。
Operation
StrAssign(T, *chars): 生成一個其值等於字串常量chars的串
StrCopy(T, S): 串S存在,由串S複製得串T。
ClearString(S): 串S存在,將串清空。
StringEmpty(S): 若串S為空,返回true,否則返回false。
StrLength(S): 返回串S的元素個數,即串的長度。
StrCompare(S, T): 若S>T,返回值>0,若S=T,返回0,若S<T,返回值<0
Concat(T, S1, S2): 用T返回由S1和S2聯接而成的新串。
SubString(Sub, S, pos, len): 串S存在,1≤pos≤StrLength(S),且0≤len≤StrLength(S)-pos+1,用Sub表示回串S的第pos個字元起長度為len的子串
Index(S, T, pos): 串S和T存在,T是非空串,1≤pos≤StrLength(T)。若主串S中存在和串T值相同的子串,則返第pos個字元之後第一次出現的位置,否則返回0
Replace(S, T, V): 串S、T和V存在,T是非空串。用V替換主與T相等的不重疊的子串。
StrInsert(S, pos, T): 串S和T存在,1≤pos≤StrLength(S)+1。在串S的第pos個字元之前插入串T。
StrDelete(S, pos, len): 串S存在,1≤pos≤StrLength(S)-len+1從串S中刪除第pos個字元起長度為len的子串
endADT
一個操作Index
的實現演算法:
// T為非空串。若主串S中第pos個字元之後存在於T相等的子串,則返回第一個這樣的子串在S中的位置,否則返回0
int Index(String s, String T, int pos)
{
int n, m, i;
String sub;
if (pos > 0)
{
// 得到主串S的長度
n = StrLength(S);
// 得到子串T的長度
m = StrLength(T);
i = pos;
while (i <= n - m + 1)
{
// 取主串第i個位置長度與T相等的子串給sub
SubString(sub, S, i, m);
// 如果兩串不相等
if (StrCompare(sub, T) != 0)
++ i;
// 如果兩串相等
else
// 則返回i值
return i;
}
}
// 若無子串與T相等,返回0
return 0;
}
4 串的儲存結構
4.1 串的順序儲存結構
串的順序儲存結構是用一組地址連續的儲存單元來儲存串中的字元序列的。按照預定義的大小,為每個定義的串變數分配一個固定長度的儲存區。一般是用定長陣列來定義。
既然是定長陣列,就存在一個預定義的最大串長度,一般可以將實際的串長度值儲存在陣列的0下標位置,有的書中也會定義儲存在陣列的最後一個下標位置。但也有些程式語言不想這麼幹,覺得存個數字佔個空間麻煩。它規定在串值後面加一個不計入串長度的結束標記字元,比如"\0"
來表示串值的終結,這個時候,你要想知道此時的串長度,就需要遍歷計算一下才知道了,其實這還是需要佔用一個空間,何必呢。
剛才講的串的順序儲存方式其實是有問題的,因為字串的操作,比如兩串的連線Concat
、新串的插入StrInsert
,以及字串的替換Replace,都有可能使得串序列的長度超過了陣列的長度Max-Size
。
於是對於串的順序儲存,有一些變化,串值的儲存空間可在程式執行過程中動態分配而得。比如在計算機中存在一個自由儲存區,叫做“堆”。這個堆可由C語言的動態分配函式malloc()
和free()
來管理。
4.2 串的鏈式儲存結構
對於串的鏈式儲存結構,與線性表是相似的,但由於串結構的特殊性,結構中的每個元素資料是一個字元,如果也簡單的應用連結串列儲存串值,一個結點對應一個字元,就會存在很大的空間浪費。因此,一個結點可以存放一個字元,也可以考慮存放多個字元,最後一個結點若是未被佔
滿時,可以用"#"
或其他非串值字元補全,如下圖所示。
當然,這裡一個結點存多少個字元才合適就變得很重要,這會直接影響著串處理的效率,需要根據實際情況做出選擇。
但串的鏈式儲存結構除了在連線串與串操作時有一定方便之外,總的來說不如順序儲存靈活,效能也不如順序儲存結構好。
5 樸素的模式匹配演算法
假設我們要從下面的主串S="goodgoogle"
中,找到T="google"
這個子串的位置。我們通常需要下面的步驟。
-
主串
S
第一位開始,S
與T
前三個字母都匹配成功,但S
第四個字母是d
而T
的是g
。第一位匹配失敗。如下圖所示,其中豎直連線表示相等,閃電狀彎折連線表示不等。 -
主串
S
第二位開始,主串S
首字母是o
,要匹配的T首字母是g
,匹配失敗,如下圖所示。 -
主串
S
第三位開始,主串S
首字母是o
,要匹配的T
首字母是g
,匹配失敗,如下圖所示。 -
主串
S
第四位開始,主串S
首字母是d
,要匹配的T
首字母是g
,匹配失敗,如下圖所示。 -
主串
S
第五位開始,S
與T
,6個字母全匹配,匹配成功,如下圖所示。
簡單的說,就是對主串的每一個字元作為子串開頭,與要匹配的字串進行匹配。對主串做大迴圈,每個字元開頭做T
的長度的小迴圈,直到匹配成功或全部遍歷完成為止。
前面我們已經用串的其他操作實現了模式匹配的演算法Index
。現在考慮不用串的其他操作,而是隻用基本的陣列來實現同樣的演算法。注意我們假設主串S
和要匹配的子串T的長度存在S[0]
與T[0]
中。實現程式碼如下:
// 返回子串T在主串S中第pos個字元之後的位置。若不存在,則函式返回為0。
// T非空,1≤pos≤StrLength(S)。
int Index(String S, String T, int pos)
{
// i用於主串S中當前位置下標,若pos不為1,則從pos位置開始匹配
int i = pos;
// j用於子串T中當前位置下標值
int j = 1;
// 若i小於S長度且j小於T的長度時迴圈
while (i <= S[0] && j <=T[0])
{
// 兩字母相等則繼續
if (S[i] == T[j])
{
++ i;
++ j;
}
// 指標後退重新開始匹配
else
{
// i退回到上次匹配首位的下一位
i = i - j + 2;
// j退回到子串T的首位
j = 1;
}
}
if (j = T[0])
return i - T[0];
else
return 0;
}
分析一下,最好的情況是什麼?那就是一開始就區配成功,比如"googlegood"
中去找"google"
,時間複雜度為O(1)
。稍差一些,如果像剛才例子中第二、三、四位一樣,每次都是首字母就不匹配,那麼對T
串的迴圈就不必進行了,比如"abcdef-google"
中去找"google"
。 那麼時間複雜度為O(n+m)
,其中n
為主串長度,m
為要匹配的子串長度。根據等概率原則, 平均是(n+m)/2
次查詢,時間複雜度為O(n+m)
。
那麼最壞的情況又是什麼?就是每次不成功的匹配都發生在串T的最後一個字元。舉一個很極端的例子。 主串為S="00000000000000000000000000000000000000000000000001"
,而要匹配的子串為T="0000000001"
,前者是有49個"0"
和1個"1"
的主串,後者是9個"0"和1個"1"的子串。在匹配時,每次都得將T中字元迴圈到最後一位才發現:哦,原來它們是不匹配的。這樣等於T
串需要在S
串的前40個位置都需要判斷10次,並得出不匹配的結論, 如下圖所示:
直到最後第41個位置,因為全部匹配相等,所以不需要再繼續進行下去。如果最終沒有可匹配的子串,比如是T="0000000002"
,到了第41位置判斷不匹配後同樣不需要繼續比對下去。因此最壞情況的時間複雜度為O((n-m+1)*m)
。
6 KMP模式匹配演算法
很多年前我們的科學家們,覺得像這種有多個0和1重複字元的字串,模式匹配需要挨個遍歷的演算法是非常糟糕的。於是有三位前輩,D.E. Knuth、J.H. Morris和V.R. Pratt(其中Knuth和Pratt共同研究,Mor-ris獨立研究)發表一個模式匹配演算法,可以大大避免重複遍歷的情況,我們把它稱之為克努特—莫里斯—普拉特演算法, 簡稱KMP演算法。
6.1 KMP模式匹配演算法原理
如果主串S="abcdefgab"
,其實還可以更長一些,我們就省略掉只保留前9位,我們要匹配的T="abcdex"
,那麼如果用前面的樸素演算法的話,前5個字母,兩個串完全相等,直到第6個字母, "f"
與"x"
不等, 如下圖中的①所示。
接下來,按照樸素模式匹配演算法,應該是如上圖中的流程②③④⑤⑥。即主串S
中當i=2、3、4、5、6
時,首字元與子串T
的首字元均不等。
似乎這也是理所當然,原來的演算法就是這樣設計的。可仔細觀察發現。對於要匹配的子串T
來說,"abcdex"
首字母"a"
與後面的串"bcdex"
中任意一個字元都不相等。也就是說,既然"a"
不與自己後面的子串中任何一字元相等,那麼對於上圖中的①來說,前五位字元分別相等,意味著
子串T
的首字元"a"
不可能與S
串的第2位到第5位的字元相等。在上圖中,②③④⑤的判斷都是多餘。
注意這裡是理解KMP
演算法的關鍵。如果我們知道T
串中首字元"a"
與T
中後面的字元均不相等(注意這是前提,如何判斷後面再講)。而T
串的第二位的"b"
與S
串中第二位的"b"
在上圖的①中已經判斷是相等的,那麼也就意味著,T
串中首字元"a"
與S
串中的第二位"b"
是不需要判斷也知道它們是不可能相等了,這樣上圖的②這一步判斷是可以省略的,如下圖所示。
同樣道理,在我們知道T
串中首字元"a"
與T
中後面的字元均不相等的前提下,T
串的"a"
與S
串後面的"c"
、"d"
、"e"
也都可以在①之後就可以確定是不相等的,所以這個演算法當中②③④⑤沒有必要,只保留①⑥即可,如下圖所示。
之所以保留⑥中的判斷是因為在①中T[6]≠S[6]
,儘管我們已經知道T[1]≠T[6]
,但也不能斷定T[1]
一定不等於S[6]
,因此需要保留⑥這一步。
有人就會問,如果T
串後面也含有首字元"a"
的字元怎麼辦呢?
我們來看下面一個例子,假設S="abcababca"
,T="abcabx"
。對於開始的判斷, 前5個字元完全相等,第6個字元不等,如下圖中的①。此時,根據剛才的經驗,T
的首字元"a"
與T
的第二位字元"b"
、第三位字元"c"
均不等,所以不需要做判斷,下圖中的樸素演算法步驟②③都是多餘。
因為T
的首位"a"
與T第四位的"a"
相等,第二位的"b"
與第五位的"b"
相等。而在①時,第四位的"a"
與第五位的"b"
已經與主串S
中的相應位置比較過了,是相等的,因此可以斷定,T
的首字元"a"
、第二位的字元"b"
與S
的第四位字元和第五位字元也不需要比較了, 肯定也是相等的——之前比較過了,還判斷什麼,所以④⑤這兩個比較得出字元相等的步驟也可以省略。
也就是說,對於在子串中有與首字元相等的字元,也是可以省略一部分不必要的判斷步驟。如下圖所示,省略掉右圖的T
串前兩位"a"
與"b"
同S
串中的4、 5位置字元匹配操作。
對比這兩個例子,我們會發現在①時,我們的i值,也就是主串當前位置的下標是6,②③④⑤,i
值是2、3、4、5,到了⑥,i
值才又回到了6。即我們在樸素的模式匹配演算法中,主串的i
值是不斷地回溯來完成的。而我們的分析發現,這種回溯其實是可以不需要的——正所謂好馬不吃回頭草,我們的KMP
模式匹配演算法就是為了讓這沒必要的回溯不發生。
既然i
值不回溯,也就是不可以變小,那麼要考慮的變化就是j
值了。通過觀察也可發現,我們屢屢提到了T
串的首字元與自身後面字元的比較,發現如果有相等字元,j
值的變化就會不相同。也就是說,這個j
值的變化與主串其實沒什麼關係,關鍵就取決於T
串的結構中是否有重複的問題。
比如上上上圖中,由於T="abcdex"
,當中沒有任何重複的字元,所以j
就由6變成了1。而上圖中,由於T="abcabx"
,字首的"ab"
與最後"x"
之前串的字尾"ab"
是相等的。因此j
就由6變成了3。因此,我們可以得出規律,j
值的多少取決於當前字元之前的串的前後綴的相似度。
我們把T
串各個位置的j
值的變化定義為一個數組next
,那麼next
的長度就是T
串的長度。於是我們可以得到下面的函式定義:
6.2 next陣列值推導
6.2.1 T="abcdex"
j | 模式串T | next[j] |
---|---|---|
123456 | abcdex | 011111 |
- 當j=1時,next[1]=0;
- 當j=2時,j由1到j-1就只有字元
"a"
,屬於其他情況,next[2]=1; - 當j=3時,j由1到j-1串是
"ab"
,顯然"a"
與"b"
不相等,屬於其他情況,next[3]=1; - 以後同理,所以最終此
T
串的next[j]為011111。
6.2.2 T="abcabx"
j | 模式串T | next[j] |
---|---|---|
123456 | abcabx | 011123 |
- 當
j=1
時,next[1]=0
; - 當
j=2
時,同上例說明,next[2]=1
; - 當
j=3
時,同上,next[3]=1
; - 當
j=4
時,同上,next[4]=1
; - 當
j=5
時,此時j
由1到j-1
的串是"abca"
,字首字元"a"
與字尾字元"a"
相等(字首用下劃線表示,字尾用斜體表示),因此可推算出k
值為2(由"p[1]...p[k-1]"="p[j-k+1]...p[j-1]"
,得到p[1]=p[4]
),因此next[5]=2
; - 當
j=6
時,j
由1到j-1
的串是"abcab"
,由於字首字元"ab"
與字尾字元"ab"
相等,所以next[6]=3
。
我們可以根據經驗得到:如果前後綴一個字元相等,k
值是2,兩個字元相等k
值是3,n
個相等k
值就是n-1
。
6.2.3 T="ababaaaba"
j | 模式串T | next[j] |
---|---|---|
123456789 | ababaaaba | 011234223 |
- 當
j=1
時,next[1]=0
; - 當
j=2
時,同上例說明,next[2]=1
; - 當
j=3
時,同上,next[3]=1
; - 當
j=4
時,j
由1到j-1
的串是"aba"
,字首字元"a"
與字尾字元"a"
相等,next[4]=2
; - 當
j=5
時,j
由1到j-1
的串是"abab"
,由於字首字元"ab"
與字尾"ab"
相等,所以next[5]=3
; - 當
j=6
時,j
由1到j-1
的串是"ababa"
,由於字首字元"aba"
與字尾"aba"
相等,所以next[6]=4
; - 當
j=7
時,j
由1到j-1
的串是"ababaa"
,由於字首字元"ab"
與字尾"aa"
並不相等,只有"a"
,相等,所以next[7]=2
; - 當
j=8
時,j
由1到j-1
的串是"ababaaa"
,只有"a"
相等,所以next[8]=2
; - 當
j=9
時,j
由1到j-1
的串是"ababaaab"
,由於字首字元"ab"
與字尾"ab"
相等,所以next[9]=3
。
6.2.4 T="aaaaaaaab"
j | 模式串T | next[j] |
---|---|---|
123456789 | aaaaaaaab | 012345678 |
- 當
j=1
時,next[1]=0
; - 當
j=2
時,同上例說明,next[2]=1
; - 當
j=3
時,j
由1到j-1
的串是"aa"
,字首字元"a"
與字尾字元"a"
相等,next[3]=2
; - 當
j=4
時,j
由1到j-1
的串是"aaa"
,字首字元"aa"
與字尾字元"aa"
相等,next[4]=3
; - ......
- 當
j=9
時,j
由1到j-1
的串是"aaaaaaaa"
,字首字元"aaaaaaa"
與字尾字元"aaaaaaa"
相等,next[9]=8
。
6.3 KMP模式匹配演算法實現
程式碼如下:
// 通過計算返回子串T的next陣列
void get_next(String T, int *next)
{
int i, j;
i = 1;
j = 0;
next[1] = 0;
// 此處T[0]表示串T的長度
while (i < T[0])
{
// T[i]表示字尾的單個字元
// T[j]表示字首的單個字元
if (j == 0 || T[i] == T[j])
{
++ i;
++ j;
next[i] = j;
}
else
// 若字元不相同,則j值回溯
j = next[j];
}
}
這段程式碼的目的就是為了計算出當前要匹配的串T
的next
陣列。
// 返回子串T在主串S中第pos個字元後的位置。若不存在,則函式返回值為0。
// T非空,1≤pos≤StrLength(S)。
int Index_KMP(String S, String T, int pos)
{
// i用於主串S當前位置下標值,若pos不為1,則從pos位置開始匹配
int i = pos;
// j用於子串T中當前位置下標值
int j = 1;
// 定義一next陣列
int next[255];
// 對串T作分析,得到next陣列
get_next(T, next);
// 若i小於S的長度且j小於T的長度時,迴圈繼續
while (i <= S[0] && j <= T[0])
{
// 兩字母相等則繼續,相對於樸素演算法增加了j=0判斷
if (j = 0 || S[i] == T[j])
{
++ i;
++ j;
}
// 指標後退重新開始匹配
else
{
// j退回合適的位置,i值不變
j = next[j];
}
}
if (j > T[0])
return i - T[0];
else
return 0;
}
相對於樸素匹配演算法增加的程式碼,改動不算大,關鍵就是去掉了i
值回溯的部分。對於get_next
函式來說,若T
的長度為m
,因只涉及到簡單的單迴圈,其時間複雜度為O(m)
,而由於i
值的不回溯,使得index_KMP
演算法效率得到了提高,while
迴圈的時間複雜度為O(n)
。因此,整個演算法的時間複雜度為O(n+m)
。相較於樸素模式匹配演算法的O((n-m+1)*m)
來說,是要好一些。
這裡也需要強調,KMP
演算法僅當模式與主串之間存在許多“部分匹配”的情況下才體現出它的優勢,否則兩者差異並不明顯。
6.4 KMP模式匹配演算法改進
假設T
串的第二、三、四、五位置的字元都與首位的"a"相等,那麼可以用首位next[1]
的值去取代與它相等的字元後續next[j]
的值,這是個很好的辦法。因此我們對求next
函式進行了改良。
假設取代的陣列為nextval
,增加了加粗部分,程式碼如下:
// 求模式串T的next函式修正值並存入陣列nextval
void get_nextval(String T, int *nextVal)
{
int i, j;
i = 1;
j = 0;
nextval[1] = 0;
// 此處T[0]表示串T的長度
while (i < T[0])
{
// T[i]表示字尾的單個字元
// T[j]表示字首的單個字元
if (j == 0 || T[i] == T[j])
{
++ i;
++ j;
// 若當前字元與字首字元不同
if (T[i] != T[j])
// 則當前的j為nextval在i位置的值
nextval[i] = j;
else
// 如果與字首字元相同,則將字首欄位的nextval值賦值給nextval在i位置的值
nextval[i] = nextval[j];
}
else
// 若字元不相同,則j值回溯
j = nextval[j];
}
}
6.5 nextval陣列值推導
6.5.1 T="ababaaaba"
j | 模式串T | next[j] | nextval[j] |
---|---|---|---|
123456789 | ababaaaba | 011234223 | 010104210 |
6.5.2 T="aaaaaaaab"
j | 模式串T | next[j] | nextval[j] |
---|---|---|---|
123456789 | aaaaaaaab | 012345678 | 000000009 |
總結改進過的KMP
演算法,它是在計算出next
值的同時,如果a位字元與它next
值指向的b位字元相等,則該a位的nextval
就指向b位的nextval
值,如果不等,則該a位的nextval
值就是它自己a位的next
的值。