cswork / 模板打开 500 路题单

第 03 站 · 线性结构

字符串解析与匹配

KMP 前缀函数匹配 时间 O(n + m) · 空间 O(m)

周赛难度分 难度分 1167–难度分 2429· 中位 难度分 1512(8/20 道题有 zerotrac 周赛分)

打开 500 路题单 · 第 03 站 · 20 题
先学
学完去
面试频次
6 家公司考过 · 面经出现 7 次DoorDash 2、Airbnb 1、Akuna Capital 1、Carvana 1、Meta 1

先做这道

LC 344反转字符串简单
卡住再看

用双指针原地交换字符,明确半开区间与停止条件。

要点

  1. pi[i] 是 pat[0..i] 中「既是真前缀又是真后缀」的最长长度;失配时 j 退回 pi[j-1],文本指针从不回头。
  2. 建 pi 表和在文本里匹配是同一段循环:建表就是模式串和自己匹配。
  3. 解析类题目用一个下标手动往前读数字、符号和括号,边界比 split 好处理。

易错点

  • 匹配成功后要令 j = pi[j-1] 继续,否则会漏掉重叠出现("aaaa" 里的 "aa" 出现 3 次)。
  • 在循环里用 + 拼字符串是 O(n²);Python 先收集到列表再 join,Java 用 StringBuilder。
  • Go 里 s[i] 是字节、len(s) 是字节数;处理中文等多字节字符要先转成 []rune。

看动画

一步一步播放这个模式在小例子上的执行过程。可以暂停、单步前进或后退,拖动进度条跳到任意一步。

交互演示 · 逐步观察

KMP · 前缀函数与线性匹配

在「abxabcabcaby」中找「abcaby」的所有出现位置

换个输入
a0
b1
x2
a3
b4
c5
a6
b7
c8
a9
b10
y11
ap0
bp1
cp2
ap3
bp4
yp5
·pi0
·pi1
·pi2
·pi3
·pi4
·pi5
当前处理已发现 / 已计算答案
阶段
求前缀函数
匹配起点
—

先只看模式串「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 res
C++
// 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 res
C++
// 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])
}

为什么对

  1. 失配时只回退 j、不回退文本指针是安全的:还可能匹配成功的更早起点,对应的已匹配部分一定是当前已匹配串的一个「真前缀 = 后缀」,而 pi 给的正是最长的那个,按 pi 回退不会跳过任何可能的起点。
  2. 线性复杂度来自势能论证:j 每步最多 +1,每次回退至少 −1,而且 j ≥ 0,所以回退总次数不超过前进总次数,整体 O(n + m)。

30 秒口述稿

面试官让你讲思路时,按做法、为什么对、复杂度的顺序说。中英文各一版,可以对着读几遍。

中文
KMP 的核心是前缀函数 pi,pi[i] 表示模式串前 i+1 个字符里,既是真前缀又是后缀的最长长度。匹配时文本指针从不后退,失配就把模式指针跳到 pi[j−1],因为能继续匹配的起点只可能对应这些前后缀相等的长度。模式指针每步最多加一、回退总量不超过前进总量,所以整体是 O(n + m)。建表本身就是模式串和自己匹配。
English
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 天的间隔回来,到期的题在「我的进度 · 到期复习」里。 只存在这台设备上;登录后会合并到你的账号。

对照要点(0/2)
这道题你会了吗?

小测

4 道概念题和复杂度题。答过的题会和 OA 选择题练习一起按间隔回来复习。 去 OA 选择题练习

4 道题
  1. 在 Python 里拼一个很长的字符串,为什么推荐先把片段放进列表再 "".join(parts),而不是循环里 s += part?
  2. 只含小写字母的两个长度为 n 的字符串,用一个大小为 26 的计数数组判断它们是否互为字母异位词。时间和额外空间分别是?
  3. 下面代码在 s 是回文时的时间复杂度是?(n = len(s))
    def is_pal(s):
        if len(s) <= 1:
            return True
        return s[0] == s[-1] and is_pal(s[1:-1])
  4. 下面 Java 方法的时间复杂度是?
    static String repeatX(int n) {
        String s = "";
        for (int i = 0; i < n; i++) {
            s += "x";
        }
        return s;
    }

作答记录只保存在这台设备的浏览器里;登录后打开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仅仅反转字母在保留非字母位置的条件下反转字母,处理字符分类边界。难度分 1229
796旋转字符串把旋转关系化为拼接字符串中的匹配,同时检查长度。难度分 1167

核心 11

#题目一句话思路难度分
151反转字符串中的单词分离单词分词、空白归一化与顺序反转三个操作。
8字符串转换整数 (atoi)把空白、符号、连续数字与溢出处理写成清晰的解析流程。
273整数转换英文表示按千位分组组织数值转英文,处理零与分组间空格。
28找出字符串中第一个匹配项的下标从朴素匹配推导前缀函数,理解失配时如何复用已匹配信息。
459重复的子字符串利用周期结构或前缀函数判断重复子串,避免仅检查首尾字符。
686重复叠加字符串匹配限定重复串需要扩展的次数,再完成跨边界模式匹配。
1023驼峰式匹配让模式指针只在匹配时前进,禁止跳过多余大写字母。难度分 1537
809情感丰富的文字按字符游程对齐,判断每段长度是否满足可伸缩规则。难度分 1605
1750删除字符串两端相同字符后的最短长度按首尾相同字符整段缩减,处理全部字符相同的退出条件。难度分 1502
1328破坏回文串分析回文串首次可变小的位置与兜底变大位置,维护字典序最优。难度分 1474
1763最长的美好子字符串枚举并验证大小写成对条件,讨论如何用分治进一步缩小搜索范围。难度分 1522

进阶 5

#题目一句话思路难度分
65有效数字用有限状态或语法分支识别带小数点和指数的合法数字。
68文本左右对齐逐行贪心装词并分配空格,区分末行与单词数量为一的情况。
214最短回文串把最短补前缀回文转化为最长回文前缀的字符串匹配问题。
1044最长重复子串对重复长度二分并配合子串哈希,讨论哈希碰撞与验证策略。难度分 2429
336回文对枚举单词切分点并查询反向补串,处理空串和回文后缀。

Core Skills

不调库,亲手实现这一站用到的数据结构或算法,四种语言都能判题。