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() 定义相符。
字符串匹配是计算机的基本任务之一,有许多种算法,其中Knuth-Morris-Pratt算法(简称KMP)是常用的之一,思路比较复杂,以其三个发明者命名
算法思路
以文本串"BBC ABCDAB ABCDABCDABDE"和模式串"ABCDABD" 为例
首先,文本串与模式串的第一个字符比较
![2ef2434d42ee1675ab593ac6bd03d00d.png](https://i-blog.csdnimg.cn/blog_migrate/de6e093414e4cbd009b7fc745f32c109.jpeg)
不匹配,所以文本串指针后移一位
![2cf7f75ab84055a4807de7d395f77a41.png](https://i-blog.csdnimg.cn/blog_migrate/a71796434ce09ea0fc67f8614f1909a1.jpeg)
不匹配,文本串指针继续后移
……
![f72744124228d3636c740002d4d90afc.png](https://i-blog.csdnimg.cn/blog_migrate/27743dfa2f4dffb59bd866d6aa818420.jpeg)
直到模式串的第一个字符匹配成功,双指针同时后移一位
![0228f1803e01cb7da206227ba8177a85.png](https://i-blog.csdnimg.cn/blog_migrate/0294234c65210f4c1c42966acb249126.jpeg)
继续匹配成功,双指针继续后移
……
![2b1a0d2f77d772b53e7b710ef01a2a74.png](https://i-blog.csdnimg.cn/blog_migrate/e9b97cf03f539793d9633e7000dcf735.jpeg)
至此匹配失败
此时如果将文本串指针回退到下一个起始位置,并将模式串指针归零,那么就是暴力搜索算法,时间复杂度为O(n * m)(设文本串长度为n,模式串长度为m),较高
![2240c0af9dc526d13e78750ba6e62f8a.png](https://i-blog.csdnimg.cn/blog_migrate/ec74f1dcdc09b5cf088b381de1630100.jpeg)
KMP算法的思路是
![2b1a0d2f77d772b53e7b710ef01a2a74.png](https://i-blog.csdnimg.cn/blog_migrate/e9b97cf03f539793d9633e7000dcf735.jpeg)
当匹配失败时,模式串指针前面的"AB"与模式串开头的"AB"是匹配的,利用这个信息,可以将模式串的指针从6回退到2,这里的从6到2的映射,需要事先计算出来,也就是“模式串的部分匹配值”,这个我们后面再讲
![6d05c441a2726088c6c17c48046dbddb.png](https://i-blog.csdnimg.cn/blog_migrate/586e247dad56d391fa074ae8ace25ece.jpeg)
不匹配,根据“模式串的部分匹配值”,模式串指针从2回退到0
![13550f4d4159fd07cebaee28592f03ff.png](https://i-blog.csdnimg.cn/blog_migrate/fa6a0bda7a85003172660586a1b8d723.jpeg)
不匹配,由于模式串的指针在开头,因此文本串指针右移一位
……
![bd19cdec4df113cfad35595ccdbb213d.png](https://i-blog.csdnimg.cn/blog_migrate/591ee55a5061a8b998bcbc597433e112.jpeg)
不匹配,根据“模式串的部分匹配值”,模式串指针从6回退到2
……
![cf659a89bf06f0b288a58da7e2eeae37.png](https://i-blog.csdnimg.cn/blog_migrate/d700e2010c3be803664beafeed53d62b.jpeg)
直至匹配成功,算法结束
以上KMP算法的总体思路是:
当匹配成功时,双指针同时后移一位
当匹配失败时,如果模式串指针为0,则文本串指针后移一位
否则模式串指针根据“模式串的部分匹配值”回退
下面讲一下“模式串的部分匹配值”的计算,这里我们定义字符串的:
前缀:字符串的全部头部子串(不包含自身)
后缀:字符串的全部尾部子串(不包含自身)
![abaac67570cd2c9263aa767f78df41ab.png](https://i-blog.csdnimg.cn/blog_migrate/d7d322c7be04c8dcdb6c4f7275f728b1.jpeg)
模式串的部分匹配值,定义为一个i->j的映射,j为模式串前i位子串的前缀和后缀的最大相同长度
以"ABCDABD"为例
"A"的前缀和后缀都为空集,共有元素的长度为0;
"AB"的前缀为["A"],后缀为["B"],共有元素的长度为0;
"ABC"的前缀为["A", "AB"],后缀为["BC", "C"],共有元素的长度0;
"ABCD"的前缀为["A", "AB", "ABC"],后缀为["BCD", "CD", "D"],共有元素的长度为0;
"ABCDA"的前缀为["A", "AB", "ABC", "ABCD"],后缀为["BCDA", "CDA", "DA", "A"],共有元素为"A",长度为1;
"ABCDAB"的前缀为["A", "AB", "ABC", "ABCD", "ABCDA"],后缀为["BCDAB", "CDAB", "DAB", "AB", "B"],共有元素为"AB",长度为2;
"ABCDABD"的前缀为["A", "AB", "ABC", "ABCD", "ABCDA", "ABCDAB"],后缀为["BCDABD", "CDABD", "DABD", "ABD", "BD", "D"],共有元素的长度为0。
![581dee1122c8bf11b1a8c2bcea00b1d1.png](https://i-blog.csdnimg.cn/blog_migrate/26208dbc285e8c9f98966c7c5e674152.jpeg)
“部分匹配”的实质(其实也就是KMP算法的本质)是,部分模式串的头部和尾部有时会重复,当模式串与文本串匹配失败时,模式串当前指针(例子中的"ABCDABD")的前面一部分("ABCDABD")与模式串的头部("ABCDABD")相同,利用这个信息,可以跳过一些文本串和模式串的指针,从而降低复杂度
但是,按照上文描述的计算逻辑,计算“模式串的部分匹配值”这个过程,时间复杂度是很高的O(m ^ 3),所以需要通过一些算法来降低复杂度,比如使用动态规划
设模式串为s,定义“模式串的部分匹配值”为a[],给定初始值a[0] = 0
在计算a[i]时,令j = a[i - 1]
根据a[i - 1]的含义,我们知道s[: i]中最长的相同前缀和后缀是
s[: j]与s[i - j: i]
比较s[i]和s[j],如果相同,那么这两个字符就可以分别加到上面的后缀和前缀中,成为s[: i + 1]中最长的相同前缀和后缀,长度为j + 1,即
j = a[i - 1]if s[i] == s[j]: a[i] = j + 1
如果s[i] != s[j],那么就需要寻找s[: i + 1]中短一点的相同前缀和后缀
就是要寻找s[i - j: i]去掉头部一些元素,再加上s[i],能否与s的头部对上,这里需要观察到,由于s[: j] = s[i - j: i],所以上述问题等价于,s[: j]去掉头部一些元素,再加上s[i],能否与s的头部对上,s[: j]中最长的相同前缀和后缀,是
s[: a[j - 1]]和s[j - a[j - 1]: j]
因此只需要对比s[i]和s[a[j - 1]],这里就是动态规划递归产生的地方
if s[i] != s[j]: j = a[j - 1]
对j循环迭代,直至s[i]与s[j]匹配成功或者j回退到0退出
复杂度分析
设文本串长度n,模式串长度m
时间复杂度
- 计算“模式串部分匹配值”的过程
计算第i位需要O(m),即指针回退的最大步数,总共计算m位
时间复杂度为O(m ^ 2)
- 匹配过程
设文本串指针为i,模式串指针为j
初始值i = 0,j = 0
算法结束的最差条件为i > n
双指针共有三种移动类型:
a:匹配成功,i ++,j ++
b:匹配失败且j = 0,i ++
c:匹配失败且j != 0,j回退
设每种移动类型的次数为f()
a和b中i都是单向移动,所以
f(a) + f(b) <= n
这里我们构建一个虚拟的移动类型:
d:匹配失败且j != 0,j --
c中j回退到0的速度是大于等于d中j --到0的速度的,所以
f(c) <= f(d)
这里我们可以注意到,由于d的前置条件是j != 0,所以
f(d) <= f(a)
通俗理解就是,初始位置在0的j,右移的次数必然大于等于左移的次数
综上
f(a) + f(b) + f(c) <= f(a) + f(b) + f(d) <= f(a) + f(b) + f(a) <= 2(f(a) + f(b)) <= 2n
时间复杂度为O(n)
- KMP算法总的时间复杂度为O(n + m ^ 2)
空间复杂度
“模式串部分匹配值”的结果需要维护一个长度为m的数组
匹配过程需维护双指针+已匹配的字符数,共3个变量
总的空间复杂度为O(m)
代码(python3)
class Solution: def strStr(self, haystack: str, needle: str) -> int: # 特殊情况 if len(needle) == 0: return 0 if len(haystack) == 0: return -1 return self.kmpMatch(haystack, needle) def kmpMatch(self, haystack, needle): ''' KMP算法 ''' result = -1 needle_partial_match = self.partialMatch(needle) # 双指针 i = 0 j = 0 # 已匹配的字符数 matched_count = 0 while i < len(haystack) and j < len(needle): # 双指针匹配,双指针同时右移一位 if haystack[i] == needle[j]: if j == len(needle) - 1: result = i - j return result i += 1 j += 1 matched_count += 1 continue # 双指针不匹配,且needle的指针是第0位,则haystask指针右移一位,已匹配的字符数更新为0 if j == 0: i += 1 matched_count = 0 continue # 双指针不匹配,且needle的指针不是第0位,则needle指针左移至(已匹配的字符数对应的部分匹配值)位置,已匹配的字符数更新为needle指针前面的位数 j = needle_partial_match[j - 1] matched_count = j return result def partialMatch(self, s): ''' 对于模式串,求出每一位的部分匹配值,即当匹配失败时,needle需要移动的位数 部分匹配值定义为:部分字符串的前缀和后缀相同的最大长度 使用动态规划降低复杂度 ''' result = [None] * len(s) # 边界条件 result[0] = 0 if len(s) == 1: return result for i in range(1, len(s)): result[i] = 0 j = result[i - 1] while j >= 0: if s[i] == s[j]: result[i] = j + 1 break if j == 0: break j = result[j - 1] return result
参考文献
阮一峰的网络日志:字符串匹配的KMP算法