
定义 的真前缀为不等于 的前缀, 的真后缀为不等于 的后缀。
定义 的 为既是 的真前缀又是 的真后缀的字符串。例如在 中, 和 都是 的 。
对于模式串 的每个前缀 ,计算这个前缀的最长 长度,记在 数组中。
利用 数组,可以快速计算模式串 出现在文本串 的哪些位置上。
注: 数组的定义来自《算法导论》。国内数据结构教材通常定义为 数组,有的教材下标是从 开始的,也有从 开始的(但在数组最前面插入了一个 ),请读者注意甄别。总的来说,思路是共通的,只是实现上略有区别。
模板:
# 在文本串 text 中查找模式串 pattern,返回所有成功匹配的位置(pattern[0] 在 text 中的下标)
def kmp_search(text: str, pattern: str) -> List[int]:
m = len(pattern)
pi = [0] * m
cnt = 0
for i in range(1, m):
b = pattern[i]
while cnt and pattern[cnt] != b:
cnt = pi[cnt - 1]
if pattern[cnt] == b:
cnt += 1
pi[i] = cnt
pos = []
cnt = 0
for i, b in enumerate(text):
while cnt and pattern[cnt] != b:
cnt = pi[cnt - 1]
if pattern[cnt] == b:
cnt += 1
if cnt == len(pattern):
pos.append(i - m + 1)
cnt = pi[cnt - 1]
return pos注:在国内算法竞赛圈,这个算法也叫扩展 KMP。
对于字符串 ,定义 表示后缀 与 的 LCP(最长公共前缀)的长度,其中 表示从 到 的子串。
常用技巧是构造字符串 或者 ,如果发现 ( 是 的长度),则说明从 开始的子串与 匹配。
所以上面的一些 KMP 题目(子串匹配相关的),也可以用 Z 函数解决。读者可以尝试用 Z 函数解决 28. 找出字符串中第一个匹配项的下标。
模板:
# 计算并返回 z 数组,其中 z[i] = |LCP(s[i:], s)|
def calc_z(s: str) -> List[int]:
n = len(s)
z = [0] * n
box_l = box_r = 0
for i in range(1, n):
if i <= box_r:
z[i] = min(z[i - box_l], box_r - i + 1)
while i + z[i] < n and s[z[i]] == s[i + z[i]]:
box_l, box_r = i, i + z[i]
z[i] += 1
z[0] = n
return zLCP 数组
Manacher 算法可以计算以 (或者 和 )为回文中心的最长回文子串的长度。
此外,还可以:
注:Z 函数和 Manacher 算法都会用到类似 Z-box 的概念,在学习时,可以对比体会。
Manacher 算法模板:
class Manacher:
def __init__(self, s: str):
# 将 s 改造为 t,这样就不需要讨论 len(s) 的奇偶性,因为新串 t 的每个回文子串都是奇回文串(都有回文中心)
# s 和 t 的下标转换关系:
# (si+1)*2 = ti
# ti/2-1 = si
# ti 为偶数,对应奇回文串(从 2 开始)
# ti 为奇数,对应偶回文串(从 3 开始)
t = "#".join("^" + s + "$")
# 定义一个奇回文串的回文半径=(长度+1)/2,即保留回文中心,去掉一侧后的剩余字符串的长度
# half_len[i] 表示在 t 上的以 t[i] 为回文中心的最长回文子串的回文半径
# 即 [i-half_len[i]+1, i+half_len[i]-1] 是 t 上的一个回文子串
half_len = [0] * (len(t) - 2)
half_len[1] = 1
# box_r 表示当前右边界下标最大的回文子串的右边界下标+1
# box_m 为该回文子串的中心位置
# 二者的关系为 box_r = box_m + half_len[box_m]
box_m = box_r = 0
for i in range(2, len(half_len)):
hl = 1
if i < box_r:
# 记 i 关于 box_m 的对称位置 i'=box_m*2-i
# 若以 i' 为中心的最长回文子串范围超出了以 box_m 为中心的回文串的范围
# 则 half_len[i] 应先初始化为已知的回文半径 box_r-i,然后再继续暴力匹配
# 否则 half_len[i] 与 half_len[i'] 相等
hl = min(box_r - i, half_len[box_m * 2 - i])
# 暴力扩展
# 算法的复杂度取决于这部分执行的次数
# 由于扩展之后 box_r 必然会更新(右移),且扩展的的次数就是 box_r 右移的次数
# 因此算法的复杂度 = O(len(t)) = O(n)
while t[i - hl] == t[i + hl]:
hl += 1
box_m, box_r = i, i + hl
half_len[i] = hl
self.half_len = half_len
# 判断子串 s[l:r](左闭右开)是否为回文串
def is_palindrome(self, l: int, r: int) -> bool:
# 根据下标转换关系得到子串 s[l:r] 在 t 中对应的回文中心下标为 l+r+1
# t 中回文子串的长度为 hl*2-1
# 由于其中 '#' 的数量总是比字母的数量多 1
# 因此其在 s 中对应的回文子串的长度为 hl-1
return self.half_len[l + r + 1] > r - l # half_len[l+r+1]-1 >= r-l
# is_odd=True: 返回以 s[i] 为回文中心的最长奇回文子串长度
# is_odd=False: 返回以 s[i] 和 s[i+1] 为回文中心的最长偶回文子串长度(若不存在,则为 0)
def longest_palindrome_at(self, i: int, is_odd: bool) -> int:
# 根据下标转换关系得到在 t 中对应的回文中心下标
if is_odd:
return self.half_len[i * 2 + 2] - 1
return self.half_len[i * 2 + 3] - 1附:中心扩展法模板
# 最长回文子串
def longestPalindrome(s: str) -> str:
n = len(s)
ans_left = ans_right = 0
for i in range(2 * n - 1):
l, r = i // 2, (i + 1) // 2
while l >= 0 and r < n and s[l] == s[r]:
l -= 1
r += 1
# 循环结束后,s[l+1] 到 s[r-1] 是回文串
if r - l - 1 > ans_right - ans_left:
ans_left, ans_right = l + 1, r # 左闭右开区间
return s[ans_left: ans_right]用到中心扩展法(及其思想)的算法题:
本题单的大多数题目都可以用字符串哈希解决。
推荐先把 2156. 查找给定哈希值的子串 和 3756. 连接非零数字并乘以其数字和 II 做了,对理解多项式哈希的计算方法有帮助。
模板代码见 我的题解,包含单模哈希和双模哈希。
小技巧:我们可以用字符串哈希比较两个子串的字典序大小。做法是二分长度,计算最长公共前缀(LCP),然后比较 LCP 的下一个字母(一定不同,或者不存在),即可判断两个子串谁大谁小。时间复杂度:。见 3722 题。
定义循环左移操作:把字符串 的第一个字符 移除,添加到 的末尾。例如 操作一次后得到 。
问题:你可以执行任意次循环左移操作,计算你能得到的字典序最小的字符串。
注:任意次循环左移操作后,得到的字符串叫做 的循环同构串。
# 返回 s 的字典序最小的循环同构串
# 时间复杂度 O(|s|),证明见代码末尾的注释
def smallestRepresentation(s: str) -> str:
n = len(s)
s += s
i = 0 # 始终指向当前最小子串的首字母下标
j = 1 # 指向需要和 i 比较的子串的首字母下标
while j < n:
# 暴力比较:是 i 开头的字典序小,还是 j 开头的字典序小?
k = 0
while k < n and s[i + k] == s[j + k]:
k += 1
if k >= n:
# s 是个周期字符串,周期为 j-i
# j+d 开头的子串等于 i+d 开头的子串,而这些子串我们之前已经排除了,继续遍历不会找到更小的
break
if s[i + k] < s[j + k]: # 注:如果求字典序最大,改成 >
# 比如从 i 开始是 "aaab",从 j 开始是 "aaac"
# 从 i 开始比从 j 开始更小(排除 j)
# 此外:
# 从 i+1 开始比从 j+1 开始更小,所以从 j+1 开始不可能是答案,排除
# 从 i+2 开始比从 j+2 开始更小,所以从 j+2 开始不可能是答案,排除
# ……
# 从 i+k 开始比从 j+k 开始更小,所以从 j+k 开始不可能是答案,排除
# 所以下一个「可能是答案」的开始位置是 j+k+1
j += k + 1
else:
# 从 j 开始比从 i 开始更小,更新 i=j(也意味着我们排除了 i)
# 此外:
# 从 j+1 开始比从 i+1 开始更小,所以从 i+1 开始不可能是答案,排除
# 从 j+2 开始比从 i+2 开始更小,所以从 i+2 开始不可能是答案,排除
# ……
# 从 j+k 开始比从 i+k 开始更小,所以从 i+k 开始不可能是答案,排除
# 所以把 j 跳到 i+k+1,不过这可能比 j+1 小,所以与 j+1 取 max
# 综上所述,下一个「可能是答案」的开始位置是 max(j+1, i+k+1)
i, j = j, max(j, i + k) + 1
# 每次要么排除 k+1 个与 i 相关的位置(这样的位置至多 n 个),要么排除 k+1 个与 j 相关的位置(这样的位置至多 n 个)
# 所以上面关于 k 的循环,∑k <= 2n,所以二重循环的总循环次数是 O(n) 的
return s[i: i + n]推荐先完成 1163. 按字典序排在最后的子串,最小表示法是这题的环形版本。
AC 自动机 = 字典树 + KMP。
由于这些题目也可以用其他算法(字符串哈希等)解决,难度分仅供参考。
由于这些题目也可以用其他算法(字符串哈希等)解决,难度分仅供参考。
上面都是和子串相关的算法,本节是和子序列相关的算法:子序列自动机。
虽然名字有些高大上,但实际上只是预处理 的最近字母 的下标而已。
见 讲解 中的「进阶问题」。
欢迎关注 B站@灵茶山艾府
如果你发现有题目可以补充进来,欢迎评论反馈。