第 03 站 · 线性结构
字符串解析与匹配
KMP 前缀函数匹配 时间 O(n + m) · 空间 O(m)
- 先学
- 面试频次
- 6 家公司考过 · 面经出现 7 次DoorDash 2、Airbnb 1、Akuna Capital 1、Carvana 1、Meta 1
先做这道
LC 344反转字符串卡住再看
用双指针原地交换字符,明确半开区间与停止条件。
要点
- pi[i] 是 pat[0..i] 中「既是真前缀又是真后缀」的最长长度;失配时 j 退回 pi[j-1],文本指针从不回头。
- 建 pi 表和在文本里匹配是同一段循环:建表就是模式串和自己匹配。
- 解析类题目用一个下标手动往前读数字、符号和括号,边界比 split 好处理。
易错点
- 匹配成功后要令 j = pi[j-1] 继续,否则会漏掉重叠出现("aaaa" 里的 "aa" 出现 3 次)。
- 在循环里用 + 拼字符串是 O(n²);Python 先收集到列表再 join,Java 用 StringBuilder。
- Go 里 s[i] 是字节、len(s) 是字节数;处理中文等多字节字符要先转成 []rune。
看动画
一步一步播放这个模式在小例子上的执行过程。可以暂停、单步前进或后退,拖动进度条跳到任意一步。
KMP · 前缀函数与线性匹配
在「abxabcabcaby」中找「abcaby」的所有出现位置
换个输入
- 阶段
- 求前缀函数
- 匹配起点
- —
先只看模式串「abcaby」:pi[i] 是它前 i+1 个字符里,既是前缀又是后缀的最长长度(不算整个串)。
始终成立j 是当前已匹配的长度;失配时退到 pi[j−1],它是已匹配部分最长的相同前后缀,不会漏掉任何起点。
模板
选一种语言展开。四种写法实现的是同一个模板,每一份都编译运行过,并用测试数据核对了输出。看完用它重写热身题 LC 344。
Python
def find_all(text: str, pat: str) -> list[int]:
"""Start index of every occurrence of a non-empty pat in text."""
m = len(pat)
pi = [0] * m # pi[i]: longest proper prefix of pat[:i+1] that is also its suffix
j = 0
for i in range(1, m):
while j and pat[i] != pat[j]:
j = pi[j - 1]
if pat[i] == pat[j]:
j += 1
pi[i] = j
res, j = [], 0
for i, c in enumerate(text):
while j and c != pat[j]:
j = pi[j - 1]
if c == pat[j]:
j += 1
if j == m:
res.append(i - m + 1)
j = pi[j - 1] # keep going: matches may overlap
return resC++
// Start index of every occurrence of a non-empty pat in text.
vector<int> findAll(const string& text, const string& pat) {
int m = pat.size();
vector<int> pi(m, 0); // pi[i]: longest proper border of pat[0..i]
for (int i = 1, j = 0; i < m; i++) {
while (j && pat[i] != pat[j]) j = pi[j - 1];
if (pat[i] == pat[j]) j++;
pi[i] = j;
}
vector<int> res;
for (int i = 0, j = 0; i < (int)text.size(); i++) {
while (j && text[i] != pat[j]) j = pi[j - 1];
if (text[i] == pat[j]) j++;
if (j == m) {
res.push_back(i - m + 1);
j = pi[j - 1]; // keep going: matches may overlap
}
}
return res;
}Java
class Solution {
// Start index of every occurrence of a non-empty pat in text.
List<Integer> findAll(String text, String pat) {
int m = pat.length();
int[] pi = new int[m]; // pi[i]: longest proper border of pat[0..i]
for (int i = 1, j = 0; i < m; i++) {
while (j > 0 && pat.charAt(i) != pat.charAt(j)) j = pi[j - 1];
if (pat.charAt(i) == pat.charAt(j)) j++;
pi[i] = j;
}
List<Integer> res = new ArrayList<>();
for (int i = 0, j = 0; i < text.length(); i++) {
while (j > 0 && text.charAt(i) != pat.charAt(j)) j = pi[j - 1];
if (text.charAt(i) == pat.charAt(j)) j++;
if (j == m) {
res.add(i - m + 1);
j = pi[j - 1]; // keep going: matches may overlap
}
}
return res;
}
}Go
// findAll returns the start index of every occurrence of a non-empty pat in text.
func findAll(text, pat string) []int {
m := len(pat)
pi := make([]int, m) // pi[i]: longest proper border of pat[:i+1]
for i, j := 1, 0; i < m; i++ {
for j > 0 && pat[i] != pat[j] {
j = pi[j-1]
}
if pat[i] == pat[j] {
j++
}
pi[i] = j
}
var res []int
for i, j := 0, 0; i < len(text); i++ {
for j > 0 && text[i] != pat[j] {
j = pi[j-1]
}
if text[i] == pat[j] {
j++
}
if j == m {
res = append(res, i-m+1)
j = pi[j-1] // keep going: matches may overlap
}
}
return res
}常见变体
这一站的题还会用到下面 2 种写法。展开看什么时候换用它,代码同样四种语言都跑过测试。
Rabin-Karp 滚动哈希期望时间 O(n + m) · 空间 O(1)
要快速比较很多个等长子串(找重复子串、最长重复子串二分长度、回文前缀)时,用多项式哈希:窗口右移一位,减掉离开字符的贡献、乘基数、加上新字符,O(1) 更新。哈希相等还要逐字确认,防碰撞。LC 28、LC 1044、LC 214 都能用它。
Python
def rabin_karp(text: str, pat: str) -> list[int]:
"""Start of every occurrence of a non-empty pat, found with rolling hashes."""
MOD, BASE = 1_000_000_007, 131
n, m = len(text), len(pat)
if m > n:
return []
top = pow(BASE, m - 1, MOD) # weight of the character leaving the window
hp = hw = 0
for i in range(m):
hp = (hp * BASE + ord(pat[i])) % MOD
hw = (hw * BASE + ord(text[i])) % MOD
res = []
for i in range(n - m + 1):
if hw == hp and text[i : i + m] == pat: # confirm a hit: hashes can collide
res.append(i)
if i + m < n: # roll: drop text[i], add text[i + m]
hw = ((hw - ord(text[i]) * top) * BASE + ord(text[i + m])) % MOD
return resC++
// Start of every occurrence of a non-empty pat, found with rolling hashes.
vector<int> rabinKarp(const string& text, const string& pat) {
const long long MOD = 1000000007, BASE = 131;
int n = text.size(), m = pat.size();
if (m > n) return {};
long long top = 1, hp = 0, hw = 0; // top: weight of the character leaving the window
for (int i = 1; i < m; i++) top = top * BASE % MOD;
for (int i = 0; i < m; i++) {
hp = (hp * BASE + pat[i]) % MOD;
hw = (hw * BASE + text[i]) % MOD;
}
vector<int> res;
for (int i = 0; i + m <= n; i++) {
if (hw == hp && text.compare(i, m, pat) == 0) res.push_back(i); // confirm a hit: hashes can collide
if (i + m < n) // roll: drop text[i], add text[i + m]
hw = ((hw - text[i] * top % MOD + MOD) % MOD * BASE + text[i + m]) % MOD;
}
return res;
}Java
class Solution {
// Start of every occurrence of a non-empty pat, found with rolling hashes.
List<Integer> rabinKarp(String text, String pat) {
final long MOD = 1_000_000_007L, BASE = 131;
int n = text.length(), m = pat.length();
List<Integer> res = new ArrayList<>();
if (m > n) return res;
long top = 1, hp = 0, hw = 0; // top: weight of the character leaving the window
for (int i = 1; i < m; i++) top = top * BASE % MOD;
for (int i = 0; i < m; i++) {
hp = (hp * BASE + pat.charAt(i)) % MOD;
hw = (hw * BASE + text.charAt(i)) % MOD;
}
for (int i = 0; i + m <= n; i++) {
if (hw == hp && text.startsWith(pat, i)) res.add(i); // confirm a hit: hashes can collide
if (i + m < n) // roll: drop text[i], add text[i + m]
hw = ((hw - text.charAt(i) * top % MOD + MOD) % MOD * BASE + text.charAt(i + m)) % MOD;
}
return res;
}
}Go
// rabinKarp returns the start of every occurrence of a non-empty pat, found with rolling hashes.
func rabinKarp(text, pat string) []int {
const mod, base = 1_000_000_007, 131
n, m := len(text), len(pat)
res := []int{}
if m > n {
return res
}
top, hp, hw := 1, 0, 0 // top: weight of the character leaving the window
for i := 1; i < m; i++ {
top = top * base % mod
}
for i := 0; i < m; i++ {
hp = (hp*base + int(pat[i])) % mod
hw = (hw*base + int(text[i])) % mod
}
for i := 0; i+m <= n; i++ {
if hw == hp && text[i:i+m] == pat { // confirm a hit: hashes can collide
res = append(res, i)
}
if i+m < n { // roll: drop text[i], add text[i + m]
hw = ((hw-int(text[i])*top%mod+mod)%mod*base + int(text[i+m])) % mod
}
}
return res
}先整体反转,再逐词反转时间 O(n) · 空间 O(1)(Python 字符串不可变,借一个字符列表)
要求原地(O(1) 额外空间)调整单词顺序:先用读写指针压掉多余空格,把每个词反转,最后整体反转一次,词序就倒过来、词内顺序又正回来。LC 151 反转字符串中的单词;LC 344、LC 345、LC 917 是其中的双指针反转。
Python
def reverse_words(s: str) -> str:
"""Words in reverse order, single spaces between them, none at the ends."""
chars = list(s)
def reverse(i: int, j: int) -> None: # chars[i..j] in place
while i < j:
chars[i], chars[j] = chars[j], chars[i]
i, j = i + 1, j - 1
w = i = 0 # w writes, i reads; chars[:w] is the squeezed result so far
while i < len(chars):
if chars[i] == " ":
i += 1
continue
if w:
chars[w] = " " # one space before every word but the first
w += 1
start = w
while i < len(chars) and chars[i] != " ":
chars[w] = chars[i]
w, i = w + 1, i + 1
reverse(start, w - 1) # each word backwards now...
reverse(0, w - 1) # ...so reversing it all leaves the words in reverse order, each readable
return "".join(chars[:w])C++
// Words in reverse order, single spaces between them, none at the ends.
string reverseWords(string s) {
int w = 0; // s[0, w) is the squeezed result so far
for (int i = 0; i < (int)s.size();) {
if (s[i] == ' ') {
i++;
continue;
}
if (w) s[w++] = ' '; // one space before every word but the first
int start = w;
while (i < (int)s.size() && s[i] != ' ') s[w++] = s[i++];
reverse(s.begin() + start, s.begin() + w); // each word backwards now...
}
s.resize(w);
reverse(s.begin(), s.end()); // ...so reversing it all leaves the words in reverse order, each readable
return s;
}Java
class Solution {
// Words in reverse order, single spaces between them, none at the ends.
String reverseWords(String text) {
char[] s = text.toCharArray();
int w = 0; // s[0, w) is the squeezed result so far
for (int i = 0; i < s.length;) {
if (s[i] == ' ') {
i++;
continue;
}
if (w > 0) s[w++] = ' '; // one space before every word but the first
int start = w;
while (i < s.length && s[i] != ' ') s[w++] = s[i++];
reverse(s, start, w - 1); // each word backwards now...
}
reverse(s, 0, w - 1); // ...so reversing it all leaves the words in reverse order, each readable
return new String(s, 0, w);
}
private void reverse(char[] s, int i, int j) {
for (; i < j; i++, j--) {
char t = s[i];
s[i] = s[j];
s[j] = t;
}
}
}Go
// reverseWords returns the words in reverse order, single spaces between them, none at the ends.
func reverseWords(text string) string {
s := []byte(text)
reverse := func(i, j int) { // s[i..j] in place
for ; i < j; i, j = i+1, j-1 {
s[i], s[j] = s[j], s[i]
}
}
w := 0 // s[:w] is the squeezed result so far
for i := 0; i < len(s); {
if s[i] == ' ' {
i++
continue
}
if w > 0 {
s[w] = ' ' // one space before every word but the first
w++
}
start := w
for i < len(s) && s[i] != ' ' {
s[w] = s[i]
w, i = w+1, i+1
}
reverse(start, w-1) // each word backwards now...
}
reverse(0, w-1) // ...so reversing it all leaves the words in reverse order, each readable
return string(s[:w])
}为什么对
- 失配时只回退 j、不回退文本指针是安全的:还可能匹配成功的更早起点,对应的已匹配部分一定是当前已匹配串的一个「真前缀 = 后缀」,而 pi 给的正是最长的那个,按 pi 回退不会跳过任何可能的起点。
- 线性复杂度来自势能论证:j 每步最多 +1,每次回退至少 −1,而且 j ≥ 0,所以回退总次数不超过前进总次数,整体 O(n + m)。
30 秒口述稿
面试官让你讲思路时,按做法、为什么对、复杂度的顺序说。中英文各一版,可以对着读几遍。
KMP 的核心是前缀函数 pi,pi[i] 表示模式串前 i+1 个字符里,既是真前缀又是后缀的最长长度。匹配时文本指针从不后退,失配就把模式指针跳到 pi[j−1],因为能继续匹配的起点只可能对应这些前后缀相等的长度。模式指针每步最多加一、回退总量不超过前进总量,所以整体是 O(n + m)。建表本身就是模式串和自己匹配。KMP rests on the prefix function: pi[i] is the length of the longest proper prefix of the first i + 1 pattern characters that is also a suffix. During matching the text pointer never moves back; on a mismatch the pattern pointer jumps to pi[j − 1], because any start that can still match corresponds to one of those borders. The pattern pointer rises by at most one per step and can only fall as much as it rose, so the whole thing is O(n + m). Building the table is the pattern matching against itself.自评一下
对照上面的答案给自己打个分。没掌握的题会按 1、3、7、21 天的间隔回来,到期的题在「我的进度 · 到期复习」里。 只存在这台设备上;登录后会合并到你的账号。
小测
4 道概念题和复杂度题。答过的题会和 OA 选择题练习一起按间隔回来复习。 去 OA 选择题练习
作答记录只保存在这台设备的浏览器里;登录后打开OA 选择题练习页,会存进账号。
模板练习
用上面的模板和变体出题:把打乱的行排回原序,把挖掉的边界条件填回去,或者在空白编辑器里整段默写,跑本站的测试并逐行对比。每次「换一题」都会重新出题。
下面几行被打乱了。点一行选中,再点另一行和它交换,排回模板原来的顺序;缩进是提示。
def find_all(text: str, pat: str) -> list[int]:
"""Start index of every occurrence of a non-empty pat in text."""
m = len(pat)
pi = [0] * m
j = 0
for i in range(1, m): res.append(i - m + 1)
j = pi[j - 1]
return res分档练习
本站在 500 路里的 20 道题,按基础、核心、进阶分三档。每题附一句话思路,先自己想,想不出再看。
基础 4
| # | 题目 | 一句话思路 | 难度分 |
|---|---|---|---|
| 344 | 反转字符串 | 用双指针原地交换字符,明确半开区间与停止条件。 | |
| 345 | 反转字符串中的元音字母 | 双向跳过非目标字符,只对元音子序列执行反转。 | |
| 917 | 仅仅反转字母 | 在保留非字母位置的条件下反转字母,处理字符分类边界。 | |
| 796 | 旋转字符串 | 把旋转关系化为拼接字符串中的匹配,同时检查长度。 |
核心 11
| # | 题目 | 一句话思路 | 难度分 |
|---|---|---|---|
| 151 | 反转字符串中的单词 | 分离单词分词、空白归一化与顺序反转三个操作。 | |
| 8 | 字符串转换整数 (atoi) | 把空白、符号、连续数字与溢出处理写成清晰的解析流程。 | |
| 273 | 整数转换英文表示 | 按千位分组组织数值转英文,处理零与分组间空格。 | |
| 28 | 找出字符串中第一个匹配项的下标 | 从朴素匹配推导前缀函数,理解失配时如何复用已匹配信息。 | |
| 459 | 重复的子字符串 | 利用周期结构或前缀函数判断重复子串,避免仅检查首尾字符。 | |
| 686 | 重复叠加字符串匹配 | 限定重复串需要扩展的次数,再完成跨边界模式匹配。 | |
| 1023 | 驼峰式匹配 | 让模式指针只在匹配时前进,禁止跳过多余大写字母。 | |
| 809 | 情感丰富的文字 | 按字符游程对齐,判断每段长度是否满足可伸缩规则。 | |
| 1750 | 删除字符串两端相同字符后的最短长度 | 按首尾相同字符整段缩减,处理全部字符相同的退出条件。 | |
| 1328 | 破坏回文串 | 分析回文串首次可变小的位置与兜底变大位置,维护字典序最优。 | |
| 1763 | 最长的美好子字符串 | 枚举并验证大小写成对条件,讨论如何用分治进一步缩小搜索范围。 |
进阶 5
本站 OA 真题 213
- Subarray Matches a Comparison PatternMeta
- Cyclic Vowel ShiftMeta
- Compute Difference Between Uppercase and Lowercase LettersMeta
- Replace Every Nth ConsonantMeta
- Buses in a Bustling TownMeta
- Find How Many Subarrays Match The PatternMeta
- Find Longest RegexAmazon
- Find Similarities (Password Subsequence)Amazon
- Count Valid SplitsAmazon
- Get Minimum String (Box Lex-Smallest)Amazon