文章目录
- 一、暴力穷解法
- 二、KMP算法
- 二、BM算法
- 三、Sunday算法
- 四、完整代码
所有的LeetCode题解索引,可以看这篇文章——【算法和数据结构】LeetCode题解。
一、暴力穷解法
思路分析:首先判断字符串是否合法,然后利用for循环,取出子字符串利用compare函数进行比较。
程序如下:
class Solution {
public:
// 复杂度n * m
int strStr(string haystack, string needle) {
if (haystack.size() < needle.size()) return -1;
if (!needle.size()) return 0; // needle为空返回0
for (int i = 0; i < haystack.size(); ++i) {
string substr = haystack.substr(i, needle.size());
if (!needle.compare(substr)) return i;
}
return -1;
}
};
复杂度分析:
- 时间复杂度: O ( n ∗ m ) O(n * m) O(n∗m),假设haystack的长度为n,needle的长度为m,for循环的复杂度为n,当中调用了compare函数,它是逐字符比较的,复杂度为m,因此总复杂度为 O ( n ∗ m ) O(n * m) O(n∗m)。
- 空间复杂度: O ( 1 ) O(1) O(1)。
二、KMP算法
KMP成功实现了字符串匹配算法从乘法复杂度到线性复杂度的跨越。KMP算法是由这三位学者发明的:Knuth,Morris和Pratt,所以得名KMP算法。KMP算法的主要思想是:当出现字符串不匹配时,可以知道一部分之前已经匹配的文本内容,可以利用这些信息避免从头再去做匹配。在KMP算法当中,利用next数组记录前缀表(prefix table)。前缀表用来回退,它记录了模式串与文本串不匹配的时候,模式串应该从哪里开始重新匹配。文本串是指要搜寻的字符串,模式串是目标字符串。例如,要在aabaaf当中找aaf,那么aaf就是模式串,aabaaf就是文本串。
在next数组当中我们保存了模式串前缀的最长公共前后缀长度,以下简称为前缀表。字符串的前缀是指不包含最后一个字符的所有以第一个字符开头的连续子串;后缀是指不包含第一个字符的所有以最后一个字符结尾的连续子串,所谓最长公共前后缀,笔者理解为既在前缀中,又在后缀中,且长度最大的字符串。例如对于字符串 aabaa,其前缀有 a, aa, aab, aaba,后缀有a, aa, baa, abaa。最长公共前后缀就是 aa。
那么为什么前缀表能告诉我们上次匹配的位置呢?考虑一个文本串aabaabaafa和模式串aabaaf。求出前缀表如下:
假设我们在f位置,文本字符和模式字符不匹配(aabaa与模式串全部匹配),那么我们就得找f字符的前一个前缀字符串,也就是aabaa,前缀表next当中保存着所有前缀最长公共前后缀长度,aabaa的最长公共前后缀长度为2。也就是说,后缀aa匹配,那么前缀aa也必然和模式串匹配,因此前缀aa不需要再进行对比,直接让模式串的指针按next表跳转到下一个位置,因为next数组当中存储的数值为2,所以模式串跳转到下标为2的地方进行对比。(图片引用自代码随想录28.strStr)。
其中n为文本串长度,m为模式串长度,因为在匹配的过程中,根据前缀表不断调整匹配的位置,可以看出匹配的过程是O(n),之前还要单独生成next数组,时间复杂度是O(m)。所以整个KMP算法的时间复杂度是O(n+m)的。
复杂度分析:
- 时间复杂度: O ( n + m ) O(n + m) O(n+m),其中n为文本串长度,m为模式串长度,因为在匹配的过程中,根据前缀表不断调整匹配的位置,可以看出匹配的过程是O(n),之前还要单独生成next数组,时间复杂度是O(m)。所以整个KMP算法的时间复杂度是O(n+m)的。
- 空间复杂度: O ( m ) O(m) O(m),需要额外的空间存放大小为m的数组。
KMP算法程序:
// KMP算法
void getNext(int* next, const string& s) {
int j = -1;
next[0] = j;
for (int i = 1; i < s.size(); i++) { // 注意i从1开始
while (j >= 0 && s[i] != s[j + 1]) { // 前后缀不相同了
j = next[j]; // 向前回退
}
if (s[i] == s[j + 1]) { // 找到相同的前后缀
j++;
}
next[i] = j; // 将j(前缀的长度)赋给next[i]
}
}
int strStr2(string haystack, string needle) {
if (needle.size() == 0) {
return 0;
}
int* next = new int[needle.size()];
//int next[needle.size()];
getNext(next, needle);
//my_print(next, needle.size(), "前缀表:");
int j = -1; // // 因为next数组里记录的起始位置为-1
for (int i = 0; i < haystack.size(); i++) { // 注意i就从0开始
while (j >= 0 && haystack[i] != needle[j + 1]) { // 不匹配
j = next[j]; // j 寻找之前匹配的位置
}
if (haystack[i] == needle[j + 1]) { // 匹配,j和i同时向后移动
j++; // i的增加在for循环里
}
if (j == (needle.size() - 1)) { // 文本串s里出现了模式串t
return (i - needle.size() + 1);
}
}
return -1;
}
二、BM算法
本节转载自从头到尾彻底理解KMP。
1977年,德克萨斯大学的Robert S. Boyer教授和J Strother Moore教授发明了一种新的字符串匹配算法:Boyer-Moore算法,简称BM算法。该算法从模式串的尾部开始匹配,且拥有在最坏情况下O(N)的时间复杂度。在实践中,比KMP算法的实际效能高。BM算法定义了两个规则:
- 坏字符规则:当文本串中的某个字符跟模式串的某个字符不匹配时,我们称文本串中的这个失配字符为坏字符,此时模式串需要向右移动,移动的位数 = 坏字符在模式串中的位置 - 坏字符在模式串中最右出现的位置。此外,如果"坏字符"不包含在模式串之中,则最右出现位置为-1。
- 好后缀规则:当字符失配时,后移位数 = 好后缀在模式串中的位置 - 好后缀在模式串上一次出现的位置,且如果好后缀在模式串中没有再次出现,则为-1。
下面举例说明BM算法。例如,给定文本串“HERE IS A SIMPLE EXAMPLE”,和模式串“EXAMPLE”,现要查找模式串是否在文本串中,如果存在,返回模式串在文本串中的位置。- 首先,"文本串"与"模式串"头部对齐,从尾部开始比较。"S"与"E"不匹配。这时,“S"就被称为"坏字符”(bad character),即不匹配的字符,它对应着模式串的第6位。且"S"不包含在模式串"EXAMPLE"之中(相当于最右出现位置是-1),这意味着可以把模式串后移6-(-1)=7位,从而直接移到"S"的后一位。
- 依然从尾部开始比较,发现"P"与"E"不匹配,所以"P"是"坏字符"。但是,"P"包含在模式串"EXAMPLE"之中。因为“P”这个“坏字符”对应着模式串的第6位(从0开始编号),且在模式串中的最右出现位置为4,所以,将模式串后移6-4=2位,两个"P"对齐。
- 依次比较,得到 “MPLE”匹配,称为"好后缀"(good suffix),即所有尾部匹配的字符串。注意,“MPLE”、“PLE”、“LE”、"E"都是好后缀。
- 发现“I”与“A”不匹配:“I”是坏字符。如果是根据坏字符规则,此时模式串应该后移2-(-1)=3位。问题是,有没有更优的移法?
- 更优的移法是利用好后缀规则:当字符失配时,后移位数 = 好后缀在模式串中的位置 - 好后缀在模式串中上一次出现的位置,且如果好后缀在模式串中没有再次出现,则为-1。所有的“好后缀”(MPLE、PLE、LE、E)之中,只有“E”在“EXAMPLE”的头部出现,所以后移6-0=6位。可以看出,“坏字符规则”只能移3位,“好后缀规则”可以移6位。每次后移这两个规则之中的较大值。这两个规则的移动位数,只与模式串有关,与原文本串无关。
- 继续从尾部开始比较,“P”与“E”不匹配,因此“P”是“坏字符”,根据“坏字符规则”,后移 6 - 4 = 2位。因为是最后一位就失配,尚未获得好后缀。
由上可知,BM算法不仅效率高,而且构思巧妙,容易理解。
三、Sunday算法
KMP算法并不比最简单的c库函数strstr()快多少,而BM算法虽然通常比KMP算法快,但BM算法也还不是现有字符串查找算法中最快的算法,本文最后再介绍一种比BM算法更快的查找算法即Sunday算法。Sunday算法由Daniel M.Sunday在1990年提出,它的思想跟BM算法很相似:只不过Sunday算法是从前往后匹配,在匹配失败时关注的是文本串中参加匹配的最末位字符的下一位字符。
- 如果该字符没有在模式串中出现则直接跳过,即移动位数 = 匹配串长度 + 1;
- 否则,其移动位数 = 模式串中最右端的该字符到末尾的距离+1。
// Sunday算法
int find_single_char(char c, const string& needle) {
for (int i = needle.size() - 1; i >= 0; --i) { // 找最右端的字符,因此从后往前循环
if (c == needle[i]) return i;
}
return -1;
}
int strStr3(string haystack, string needle) {
if (haystack.size() < needle.size()) return -1; // 检查合法性
if (!needle.size()) return 0; // needle为空返回0
for (int i = 0; i <= haystack.size() - needle.size(); ) {
for (int j = 0; j < needle.size(); ++j) {
if (needle[j] != haystack[i + j]) { // 匹配失败
int k = find_single_char(haystack[i + needle.size()], needle); // 文本字符串末尾的下一位字符串
if (k == -1) i += needle.size() + 1; // 模式串向右移动 模式串长度 + 1
else i += needle.size() - k; // 向右移动 模式串最右端的该字符到末尾的距离+1
break;
}
if (j == needle.size() - 1) return i; // 匹配成功
}
}
return -1;
}
除了上面一个版本的代码之外,笔者还写了另外一个版本的代码。可以观察到,我们通过find_single_char这个函数去查找字符串是否存在这个字符的效率是比较低的。想要快速的查找一个元素是否在一个字符串当中,不得不考虑用哈希表。字符串在编译器内存是使用ASCII码,因此我们建立一个ASCII码表数组,提前计算出模式串的偏移量,代码如下:
// 查找算法用哈希表代替的Sunday算法
int strStr4(string haystack, string needle) {
if (haystack.size() < needle.size()) return -1; // 检查合法性
if (!needle.size()) return 0; // needle为空返回0
int shift_table[128] = { 0 }; // 128为ASCII码表长度
for (int i = 0; i < 128; i++) { // 偏移表默认值设置为 模式串长度 + 1
shift_table[i] = needle.size() + 1;
}
for (int i = 0; i < needle.size(); i++) {
shift_table[needle[i]] = needle.size() - i;
}
int s = 0, j; // 文本串初始位置
while (s <= haystack.size() - needle.size()) {
j = 0;
while (haystack[s + j] == needle[j]) {
++j;
if (j >= needle.size()) return s; // 匹配成功
}
// 找到主串中当前跟模式串匹配的最末字符的下一个字符
// 在模式串中出现最后的位置
// 所需要从(模式串末尾+1)移动到该位置的步数
s += shift_table[haystack[s + needle.size()]];
}
return -1;
}
复杂度分析:
- 时间复杂度: 平均时间复杂度为 O ( n ) O(n) O(n),最坏情况时间复杂度为 O ( n ∗ m ) O(n*m) O(n∗m)。
- 空间复杂度: O ( 1 ) O(1) O(1),常量存储空间。
四、完整代码
代码当中包含了暴力穷解法、KMP算法、Sunday算法。
# include <iostream>
# include <string>
using namespace std;
void my_print(int* arr, int arr_len, string str) {
cout << str << endl;
for (int i = 0; i < arr_len; ++i) {
cout << arr[i] << ' ';
}
cout << endl;
}
class Solution {
public:
// 暴力穷解
// 复杂度n * m
int strStr(string haystack, string needle) {
if (haystack.size() < needle.size()) return -1;
if (!needle.size()) return 0; // needle为空返回0
for (int i = 0; i < haystack.size(); ++i) {
string substr = haystack.substr(i, needle.size());
if (!needle.compare(substr)) return i;
}
return -1;
}
// KMP算法
void getNext(int* next, const string& s) {
int j = -1;
next[0] = j;
for (int i = 1; i < s.size(); i++) { // 注意i从1开始
while (j >= 0 && s[i] != s[j + 1]) { // 前后缀不相同了
j = next[j]; // 向前回退
}
if (s[i] == s[j + 1]) { // 找到相同的前后缀
j++;
}
next[i] = j; // 将j(前缀的长度)赋给next[i]
}
}
int strStr2(string haystack, string needle) {
if (needle.size() == 0) {
return 0;
}
int* next = new int[needle.size()];
//int next[needle.size()];
getNext(next, needle);
//my_print(next, needle.size(), "前缀表:");
int j = -1; // // 因为next数组里记录的起始位置为-1
for (int i = 0; i < haystack.size(); i++) { // 注意i就从0开始
while (j >= 0 && haystack[i] != needle[j + 1]) { // 不匹配
j = next[j]; // j 寻找之前匹配的位置
}
if (haystack[i] == needle[j + 1]) { // 匹配,j和i同时向后移动
j++; // i的增加在for循环里
}
if (j == (needle.size() - 1)) { // 文本串s里出现了模式串t
return (i - needle.size() + 1);
}
}
return -1;
}
// Sunday算法
int find_single_char(char c, const string& needle) {
for (int i = needle.size() - 1; i >= 0; --i) { // 找最右端的字符,因此从后往前循环
if (c == needle[i]) return i;
}
return -1;
}
int strStr3(string haystack, string needle) {
if (haystack.size() < needle.size()) return -1; // 检查合法性
if (!needle.size()) return 0; // needle为空返回0
for (int i = 0; i <= haystack.size() - needle.size(); ) {
for (int j = 0; j < needle.size(); ++j) {
if (needle[j] != haystack[i + j]) { // 匹配失败
int k = find_single_char(haystack[i + needle.size()], needle); // 文本字符串末尾的下一位字符串
if (k == -1) i += needle.size() + 1; // 模式串向右移动 模式串长度 + 1
else i += needle.size() - k; // 向右移动 模式串最右端的该字符到末尾的距离+1
break;
}
if (j == needle.size() - 1) return i; // 匹配成功
}
}
return -1;
}
// 查找算法用哈希表代替的Sunday算法
int strStr4(string haystack, string needle) {
if (haystack.size() < needle.size()) return -1; // 检查合法性
if (!needle.size()) return 0; // needle为空返回0
int shift_table[128] = { 0 }; // 128为ASCII码表长度
for (int i = 0; i < 128; i++) { // 偏移表默认值设置为 模式串长度 + 1
shift_table[i] = needle.size() + 1;
}
for (int i = 0; i < needle.size(); i++) {
shift_table[needle[i]] = needle.size() - i;
}
int s = 0, j; // 文本串初始位置
while (s <= haystack.size() - needle.size()) {
j = 0;
while (haystack[s + j] == needle[j]) {
++j;
if (j >= needle.size()) return s; // 匹配成功
}
// 找到主串中当前跟模式串匹配的最末字符的下一个字符
// 在模式串中出现最后的位置
// 所需要从(模式串末尾+1)移动到该位置的步数
s += shift_table[haystack[s + needle.size()]];
}
return -1;
}
};
int main()
{
//string haystack = "sadbutsad";
//string needle = "sad";
//string haystack = "abc";
//string needle = "c";
//string haystack = "substring searching algorithm";
//string needle = "search";
//string haystack = "hello";
//string needle = "ll";
//string haystack = "mississippi";
//string needle = "issi";
string haystack = "aabaaaababaababaa";
string needle = "bbbb";
int k = 2;
Solution s1;
cout << "目标字符串:\n" << "“" << haystack << "”" << endl;
int result = s1.strStr4(haystack, needle);
cout << "查找子串结果:\n" << result << endl;
system("pause");
return 0;
}
参考博文:
- 最长公共前后缀
- 字符串最长公共前缀后缀长度
- 从头到尾彻底理解KMP
- 字符串匹配——Sunday算法