leetcode第28題實現strStr()
概述
暑假期間,腆著臉皮面了位元組跳動的暑期夏令營活動,被虐很慘,發現好多基礎的演算法題目都已經忘記了,而基本上筆試和麵試考察的都是演算法、作業系統等計算機基礎知識。因此在後續的學習過程中,需要調整重點,將自己的精力放到刷題上面來,畢竟下半學期如果要出去實習演算法和作業系統等基礎知識是不可或缺的。
閒話少敘,下邊來看下leetcode的第28題,具體題目如下:
實現 strStr() 函式。 給定一個 haystack 字串和一個 needle 字串,在 haystack 字串中找出 needle 字串出現的第一個位置 (從0開始)。如果不存在,則返回 -1。 示例 1: 輸入: haystack = "hello", needle = "ll" 輸出: 2 示例 2: 輸入: haystack = "aaaaa", needle = "bba" 輸出: -1 說明: 當 needle 是空字串時,我們應當返回什麼值呢?這是一個在面試中很好的問題。 對於本題而言,當 needle 是空字串時我們應當返回 0 。這與C語言的 strstr() 以及 Java的 indexOf() 定義相符。
分析
該問題一看就是一個傳統字串匹配問題,最容易想到的方法,可能就是一個雙層的for迴圈。讓目標字串不斷和源字串進行比較,尋找到能夠匹配目標字串的位置。
雙層for迴圈
因此我們可以實現出下邊的程式碼:
public static int subStr(String haystack, String needle) { if (haystack == "" || haystack == null) { return -1; } int i = 0, j = 0; // 轉化成陣列進行處理 char hayStringArry[] = haystack.toCharArray(); char needleArry[] = needle.toCharArray(); for (i = 0; i <= hayStringArry.length - needleArry.length; i++) { for (j = 0; j < needleArry.length; j++) { if (hayStringArry[i + j] != needleArry[j]) { break; } } if (j == needle.length()) { return i; } } return -1; }
上邊的程式碼很明顯時間複雜度是O(m*n)
,其中m指的是源字串haystack的長度,n表示的是目標字串needle的長度。
很明顯這個演算法不是最優解,我們考慮如何對其進行優化。首先我們思考,該演算法時間複雜度比較高的原因主要是這兩層for迴圈
,因此我們考慮能否通過一次for迴圈就得出結果那?
此時我們想到了《資料結構》這門課中講的一個演算法--簡單的模式匹配演算法。
簡單的模式匹配演算法
該演算法主要思想如下:
從源字串的第一個字串開始逐個與目標字串(待匹配的字串)進行比較,如果相等,則繼續逐個向後比較字元,直到目標字串依次和源字串比較完成,則稱為匹配成功;如果比較過程中有某對字元不相等,則從源字串的下一個字元起重新和目標字串的第一個字元進行比較
進而我們可以設計出字元的匹配演算法如下:
public int strStr(String haystack, String needle) {
if (haystack == null) {
return -1;
}
char hayStackArry[] = haystack.toCharArray();
char needleArry[] = needle.toCharArray();
// 記錄長度,減少length()函式的呼叫次數
int hayStackLength = haystack.length();
int needleLength = needle.length();
int i = 0, j = 0;
// i,j分別指向源字串和目標字元
while (i < hayStackLength && j < needleLength) {
if (hayStackArry[i] == needleArry[j]) {
i++;
j++;
} else {
// 回退到下次進行匹配的字串位置
i = i - j + 1;
// 從目標字串的第一個位置開始進行匹配
j = 0;
}
}
if (j > needleLength - 1) {
return i - needleLength;
} else {
return -1;
}
}
我們分析該演算法的時間複雜度,我們發現,雖然我們使用了單層迴圈,但我們發現由於j不斷的回退,從而在壞情況下該演算法的時間複雜度也可能達到O(m*n)因此,我們考慮能否減少j的回退次數。此時我們考慮引入KMP演算法。
KMP演算法
KMP演算法基本思想是這樣的:較之於簡單模式匹配演算法其改進在於,每當一次匹配過程中出現的字元不相等的時候,不需要回溯j指標,而是通過已經得到的“部分匹配”的結果將模式向右“滑動”儘可能遠的距離,繼續進行比較。
因此,KMP演算法分成了兩部分,第一部分是next陣列的求解,第二部分是字串匹配。
字串匹配
KMP演算法的核心之一在於next陣列,但在瞭解next陣列的意義之前,我們首先要了解一個叫做部分匹配表(Partial Match Table)表的東西。
對於字串"abababca",其PMT如下表所示
index | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
---|---|---|---|---|---|---|---|---|
char | a | b | a | b | a | b | c | a |
value | 0 | 0 | 1 | 2 | 3 | 4 | 0 | 1 |
我先解釋一下字串的字首和字尾。如果字串A和B,存在A=BS,其中S是任意的非空字串,那就稱B為A的字首。例如,”Harry”的字首包括{”H”, ”Ha”, ”Har”, ”Harr”},我們把所有字首組成的集合,稱為字串的字首集合。同樣可以定義字尾A=SB, 其中S是任意的非空字串,那就稱B為A的字尾,例如,”Potter”的字尾包括{”otter”, ”tter”, ”ter”, ”er”, ”r”},然後把所有後綴組成的集合,稱為字串的字尾集合。要注意的是,字串本身並不是自己的字尾。
有了這個定義,就可以說明PMT中的值的意義了。PMT中的值是字串的字首集合與字尾集合的交集中最長元素的長度。例如,對於”aba”,它的字首集合為{”a”, ”ab”},字尾 集合為{”ba”, ”a”}。兩個集合的交集為{”a”},那麼長度最長的元素就是字串”a”了,長 度為1,所以對於”aba”而言,它在PMT表中對應的值就是1。再比如,對於字串”ababa”,它的字首集合為{”a”, ”ab”, ”aba”, ”abab”},它的字尾集合為{”baba”, ”aba”, ”ba”, ”a”}, 兩個集合的交集為{”a”, ”aba”},其中最長的元素為”aba”,長度為3。 好了,解釋清楚這個表是什麼之後,我們再來看如何使用這個表來加速字串的查詢,以及這樣用的道理是什麼。如圖 1.12 所示,要在主字串"ababababca"中查詢模式字串"abababca"。如果在 j 處字元不匹配,那麼由於前邊所說的模式字串 PMT 的性質,主字串中 i 指標之前的 PMT[j −1] 位就一定與模式字串的第 0 位至第 PMT[j−1] 位是相同的。這是因為主字串在 i 位失配,也就意味著主字串從 i−j 到 i 這一段是與模式字串的 0 到 j 這一段是完全相同的。而我們上面也解釋了,模式字串從 0 到 j−1 ,在這個例子中就是”ababab”,其字首集合與字尾集合的交集的最長元素為”abab”, 長度為4。所以就可以斷言,主字串中i指標之前的 4 位一定與模式字串的第0位至第 4 位是相同的,即長度為 4 的字尾與字首相同。這樣一來,我們就可以將這些字元段的比較省略掉。具體的做法是,保持i指標不動,然後將j指標指向模式字串的PMT[j −1]位即可。
簡言之,以圖中的例子來說,在 i 處失配,那麼主字串和模式字串的前邊6位就是相同的。又因為模式字串的前6位,它的前4位字首和後4位字尾是相同的,所以我們推知主字串i之前的4位和模式字串開頭的4位是相同的。就是圖中的灰色部分。那這部分就不用再比較了。
有了上面的思路,我們就可以使用PMT加速字串的查找了。我們看到如果是在 j 位 失配,那麼影響 j 指標回溯的位置的其實是第 j −1 位的 PMT 值,所以為了程式設計的方便, 我們不直接使用PMT陣列,而是將PMT陣列向後偏移一位。我們把新得到的這個陣列稱為next陣列。下面給出根據next陣列進行字串匹配加速的字串匹配程式。其中要注意的一個技巧是,在把PMT進行向右偏移時,第0位的值,我們將其設成了-1,這只是為了程式設計的方便,並沒有其他的意義。在本節的例子中,next陣列如下表所示。
因此KMP演算法的匹配程式碼如下:
public static int kmp(String haystack, String needle) {
char[] needleArry = needle.toCharArray();
char[] haystackArry = haystack.toCharArray();
int needleLength = needle.length();
int haystackLength = haystack.length();
int[] next = getNext(needleArry);
int i = 0, j = 0;
while (i < haystackLength && j < needleLength) {
if (j == -1 || haystackArry[i] == needleArry[j]) {
i++;
j++;
} else {
j = next[j];
}
}
if (j > needleLength - 1) {
return i - needleLength;
} else {
return -1;
}
}
next陣列求解
前邊我們講了kmp演算法的匹配過程,下邊我們主要講一下next陣列的求解過程。其實簡單來說next陣列的求解過程完全可能一個字串的匹配過程,即以模式字串為主字串,以模式字串的字首為目標字串,一旦字串匹配成功,那麼當前的next值就是匹配 成功的字串的長度。
其匹配過程的具體程式碼如下:
// 生成next陣列
private static int[] getNext(char[] needleArry) {
int next[] = new int[needleArry.length + 1];
next[0] = -1;
int i = 0, j = -1;
while (i < needleArry.length) {
if (j == -1 || needleArry[i] == needleArry[j]) {
i++;
j++;
next[i] = j;
} else {
j = next[j];
}
}
return next;
}
雖然說KMP演算法已經十分好了,但實際在程式設計中很少直接kmp演算法來直接求解,更多的會使用BM演算法以及Sunday演算法。因為這兩個演算法較之於KMP演算法,它會更快。
Sunday演算法
Sunday演算法某種程度演算法BM演算法的改良,而且效果更好,因此主要考慮使用Sunday演算法解決一下該問題。由於篇幅原因本片文章就不再詳解,感興趣的話可以看筆者的另一篇文章--《使用sunday演算法解決字串匹配問題》