字符串
串( string)是由零个或多个字符组成的有限序列,又名叫字符串。
一般记为:
S
=
′
a
1
a
2
.
.
.
a
n
′
(
n
>
=
0
)
S = ′ a_1a_2 . . . a_n ′ ( n > = 0 )
S=′a1a2...an′(n>=0)
其中,
S
S
S 是串名,单引号括起来的字符序列是串的值;
a
n
a_n
an 可以是字母、数字或其他字符; 串中字符的个数
n
n
n 称为串的长度。
另外还有一些其它概念:
- 空串:n = 0 n=0n=0时的串称为空串。
- 空格串:是只包含空格的串。注意它与空串的区别,空格串是有内容有长度的,而且可以不止一个空格。
- 子串与主串:串中任意个数的连续字符组成的子序列称为该串的子串,相应地,包含子串的串称为主串。
- 子串在主串中的位置就是子串的第一个字符在主串中的序号。
串的逻辑结构和线性表极为相似,区别仅在于串的数据对象限定为字符集。在基本操作上,串和线性表有很大差别。线性表的基本操作主要以单个元素作为操作对象,如查找、插入或删除某个元素等;而串的基本操作通常以子串作为操作对象,如查找、插入或删除一个子串等。
1. 串的模式匹配(重点)
1.1 简单的模式匹配算法
子串的定位操作通常称为串的模式匹配,它求的是子串(常称模式串 p p p )在主串 s s s 中的位置。这里采用定长顺序存储结构,给出一种不依赖于其他串操作的暴力匹配算法。
def naive_match(s,p):
n,m = len(s),len(p)
i,j = 0,0
while i<n and j<m:
if s[i]==p[j]:
i+=1
j+=1
else:
i = i-j+1 # 不匹配回退至上一次匹配的下一个位置
j = 0
if j==m:
return i-j
return -1
下图展示了模式串
P
=
′
a
b
c
a
c
′
P = 'abcac'
P=′abcac′ 和主串
S
=
′
a
b
a
b
c
a
b
c
a
c
b
a
b
′
S='ababcabcacbab'
S=′ababcabcacbab′ 的匹配过程
简单的模式匹配算法的最坏时间复杂度为
O
(
n
m
)
O(nm)
O(nm),其中
n
n
n 和
m
m
m 分别为主串和模式串的长度。
1.2 KMP算法
在上面的简单匹配中,每趟匹配失败都是模式后移一位再从头开始比较。而某趟已匹配相等的字符序列是模式的某个前缀,这种频繁的重复比较相当于模式串在不断地进行自我比较,这就是其低效率的根源。
因此,可以从分析模式本身的结构着手,如果已匹配相等的前缀序列中有某个后缀正好是模式的前缀,那么就可以将模式向后滑动到与这些相等字符对齐的位置,主串
i
i
i 指针无须回溯,并继续从该位置开始进行比较。而模式向后滑动位数的计算仅与模式本身的结构有关,与主串无关。
KMP算法的特点就是:仅仅后移模式串,比较指针不回溯。
(1)字符串的前缀、后缀和最大公共前后缀长度
要了解子串的结构,首先要弄清楚几个概念:前缀、后缀和部分匹配值。前缀指除最后一个字符以外,字符串的所有头部子串; 后缀指除第一个字符外,字符串的所有尾部子串; 部分匹配值则为字符串的前缀和后缀的最大公共前后缀长度。下面以 ′ a b a b a ′ ' ababa' ′ababa′ 为例进行说明
- ′ a ′ 'a' ′a′ 的前缀和后缀都为空集最大公共前后缀长度长度为 0。
- ′ a b ′ 'ab' ′ab′ 的前缀为 { a } \{a\} {a} ,后缀为 { b } \{b\} {b} , { a } ∩ { b } = N U L L \{a\} ∩ \{b\} = NULL {a}∩{b}=NULL ,最大公共前后缀长度长度为 0。
- ′ a b a ′ ′aba′ ′aba′ 的前缀为 { a , a b } \{a, ab\} {a,ab}, 后缀为 { a , b a } \{a, ba\} {a,ba} , { a , a b } ∩ { a , b a } = { a } \{a,ab\}∩\{a,ba\} =\{a\} {a,ab}∩{a,ba}={a} , 最大公共前后缀长度为 1
- ′ a b a b ′ ′abab′ ′abab′ 的前缀为 { a , a b , a b a } \{a, ab,aba\} {a,ab,aba},后缀为 { b , a b , b a b } \{b, ab,bab\} {b,ab,bab}, { a , a b , a b a } ∩ { b , a b , b a b } = { a b } \{a, ab,aba\}∩\{b, ab,bab\} = \{ab\} {a,ab,aba}∩{b,ab,bab}={ab}, 最大公共前后缀长度为 2
- ′ a b a b a ′ ′ababa′ ′ababa′ 的前缀为 { a , a b , a b a , a b a b } \{a, ab,aba,abab\} {a,ab,aba,abab},后缀为 { a , b a , a b a , b a b a } \{a,ba,aba,baba\} {a,ba,aba,baba}, { a , a b , a b a , a b a b } ∩ { a , b a , a b a , b a b a } = { a , a b a } \{a, ab,aba,abab\}∩\{a,ba,aba,baba\} = \{a,aba\} {a,ab,aba,abab}∩{a,ba,aba,baba}={a,aba}, 公共元素有两个,最大公共前后缀长度为 3
故字符串
′
a
b
a
b
a
′
'ababa'
′ababa′ 的最大公共前后缀长度为00123。
这个值有什么作用呢?
回到最初的问题,主串为
′
a
b
a
c
a
b
c
a
c
b
a
b
′
'abacabcacbab'
′abacabcacbab′,模式串为
′
a
b
c
a
c
′
'abcac'
′abcac′。
利用上述方法容易写出模式串
′
a
b
c
a
c
′
'abcac'
′abcac′ 的最大公共前后缀长度为00010,将最大公共前后缀长度值写成数组形式,就得到了最大公共前后缀长度(Partial match,PM)的表
下面用PM表来进行字符串匹配:
第一趟匹配过程:
发现
c
c
c 与
a
a
a 不匹配,前面的 2 个字符
′
a
b
′
'ab'
′ab′ 是匹配的,查表可知,最后一个匹配字符
b
b
b 对应的部分匹配值为0,因此按照下面的公式算出子串需要向后移动的位数:
因为 2 − 0 = 2 2-0=2 2−0=2,所以将子串向后移动 2 位,如下进行第二趟匹配:
第二趟匹配过程:
发现
c
c
c 与
b
b
b 不匹配,前面 4 个字符
′
a
b
c
a
′
'abca'
′abca′ 是匹配的,最后一个匹配字符
a
a
a 对应的部分匹配值为 1,
4
−
1
=
3
4 − 1 = 3
4−1=3,将子串向后移动 3 位.
第三趟匹配过程:
子串全部比较完成,匹配成功。整个匹配过程中,主串始终没有回退,故 KMP 算法可以在
O
(
n
+
m
)
O(n + m)
O(n+m) 的时间数量级上完成串的模式匹配操作,大大提高了匹配效率。
(2)对算法的改进方法
使用部分匹配值时,每当匹配失败,就去找它前一个元素的部分匹配值,这样使用起来有些不方便,所以将 PM 表右移一位,这样哪个元素匹配失败,直接看它自己的部分匹配值即可。将上例中字符串
′
a
b
a
c
′
'abac'
′abac′
的 PM 表右移一位,就得到了 next 数组:
有时为了使公式更加简洁、计算简单,将 next 数组整体+1。因此,next 数组就变成:
最终得到子串指针变化公式
j
=
n
e
x
t
[
j
]
j = next[ j ]
j=next[j]。
n
e
x
t
[
j
]
next[j]
next[j] 的含义是:在子串的第
j
j
j 个字符与主串发生失配时,则跳到子串的
n
e
x
t
[
j
]
next[ j ]
next[j] 位置重新与主串当前位置进行比较。
通过分析,可以知道,除第一个字符外,模式串中其余的字符对应的 next 数组的值等于其最大公共前后缀长度加上1
科学的推导得出以下公式:
通过科学推论,我们可以写出求 next 数组的程序如下:
def get_next(p):
i,j,m = 0,-1,len(p)
next = [-1]*(m)
while i<m-1:
if j==-1 or p[i]==p[j]: # p[i]表示后缀的单个字符,p[j]表示前缀的单个字符
i += 1
j += 1
next[i] = j # 若 p[i] = p[j],则 next[i+1] = j + 1
else:
j = next[j] # 否则令j = next[j],j值回溯,循环继续
return next
与next数组的求解相比,KMP算法就简单许多,和简单模式匹配算法很相似:
def KMP(s,p):
s,p = s,p
i,j = 0,0
n,m = len(s),len(p)
next_ = get_next(p)
while i<n and j<m:
if s[i]==p[j] or j==-1:
i += 1
j += 1
else:
j = next_[j]
if j==m:
return i-j
return -1
(3)KMP算法的进一步优化
前面定义的next数组在某些情况下尚有缺陷,还可以进一步优化。如图所示,模式
′
a
a
a
a
b
′
' aaaab '
′aaaab′在和主串
′
a
a
a
b
a
a
a
a
a
b
′
' aaabaaaaab '
′aaabaaaaab′进行匹配时:
显然后面 3 次用一个和
p
4
(
a
)
p_4(a)
p4(a) 相同的字符跟
S
4
(
b
)
S_4(b)
S4(b) 比较毫无意义,必然失配。
比较毫无意义。那么如果出现了这种类型的应该如何处理呢?
如果出现了,则需要再次递归,将
n
e
x
t
[
j
]
next[j]
next[j] 修正为
n
e
x
t
[
n
e
x
t
[
j
]
]
next[next[j]]
next[next[j]],直至两者不相等为止,更新后的数组命名为 nextval。计算 next 数组修正值的算法如下,此时匹配算法不变。
def get_nextval(p):
i,j,m = 0,-1,len(p)
nextval = [-1]*m
while i<m-1:
if j==-1 or p[i]==p[j]:
i+=1
j+=1
if p[i]==p[j]: # 若当前字符与前缀字符相同
nextval[i] = nextval[j] # 则将前缀字符的nextval值给nextval在i位置上的值
else: # 不同
nextval[i] = j # 则当前的j为nextval在i位置的值
else: # 回溯
j = nextval[j]
return nextval
习题
1. 647. 回文子串
解题思路:
我们可以从字符串的每个位置开始,向左向右延长,判断存在多少以 当前位置为中轴 的回文子字符串。(其中为轴的元素可能为一个字符也可能为两个子符)
代码实现:
def countSubstrings(self, s: str) -> int:
def extendSubString(l,r):
cnt = 0
while l>=0 and r<n and s[l]==s[r]:
cnt+=1
l-=1
r+=1
return cnt
ans = 0
n = len(s)
for i in range(n):
ans += extendSubString(i,i) # 奇数长度子串 (轴字符为一个字符)
ans += extendSubString(i,i+1) # 偶数长度子串 (轴字符为两个字符)
return ans
2. 696. 计数二进制子串
解题思路:
从左往右遍历数组,记录和当前位置数字相同且连续的长度,以及其之前连续的不同数字的
长度。
举例来说,对于 00110 的最后一位,我们记录的相同数字长度是 1,因为只有一个连续 0;
我们记录的不同数字长度是 2,因为在 0 之前有两个连续的 1。
若不同数字的连续长度大于等于当前数字的连续长度,则说明存在一个且只存在一个以当前数字结尾的满足条件的子字符串
def countBinarySubstrings(self, s: str) -> int:
pre,cur = 0,1
ans = 0
for i in range(1,len(s)):
if s[i]==s[i-1]:
cur+=1
else:
pre = cur
cur = 1
if pre>=cur: # 不同数字的连续长度 >= 当前数字的连续长度
ans+=1
return ans
3. 28. 找出字符串第一个匹配
class Solution:
def strStr(self, haystack: str, needle: str) -> int:
s = haystack
p = needle
def get_nextval(p):
i,j,m = 0,-1,len(p)
nextval = [-1]*m
while i<m-1:
if j==-1 or p[i]==p[j]:
i+=1
j+=1
if p[i]==p[j]:
nextval[i] = nextval[j]
else:
nextval[i] = j
else:
j = nextval[j]
return nextval
nextval = get_nextval(p)
i,j,n,m = 0,0,len(s),len(p)
while i<n and j<m:
if s[i]==p[j] or j==-1:
i+=1
j+=1
else:
j = nextval[j]
if j==m:
return i-j
return -1