跳到内容

第 2 卷 / 第 11 章 / 第 2 课

11.2 字符串匹配 ​

前置:二分搜索与哈希表。本页统一约定:空模式在任意文本中的首次位置为 0,未找到返回 -1;Java 下标按 UTF-16 代码单元计算。

日志不是有序数组 ​

本页沿三条路线推进:

  • 朴素匹配:保留最简单、最容易验证的基线;
  • Rabin–Karp:用滚动哈希筛出候选窗口,再逐字确认;
  • KMP:从模式自身计算失配后的跳转位置,让文本指针不回退。

最后的 Trie 解决的是另一类问题:不是在一段文本中寻找模式,而是在一组词中查询完整词或公共前缀。

一份可编译的实现 ​

字符串参数必须非 null,否则抛出 NullPointerException。窗口循环用 start <= n-m,避免超长文本末端的 start+m 溢出。Trie 先检查完整输入,再访问或创建节点,非法后缀不会留下半条路径。

java
import java.util.Objects;

public final class StringMatchingDemo {
    private static final long BASE = 65_536;
    private static final long MOD = 1_000_000_007L;

    private StringMatchingDemo() {}

    static int naiveSearch(String text, String pattern) {
        Objects.requireNonNull(text, "text");
        Objects.requireNonNull(pattern, "pattern");
        int n = text.length();
        int m = pattern.length();
        if (m == 0) {
            return 0;
        }
        for (int start = 0; start <= n - m; start++) {
            int matched = 0;
            while (matched < m
                    && text.charAt(start + matched) == pattern.charAt(matched)) {
                matched++;
            }
            if (matched == m) {
                return start;
            }
        }
        return -1;
    }

    static int rabinKarp(String text, String pattern) {
        Objects.requireNonNull(text, "text");
        Objects.requireNonNull(pattern, "pattern");
        int n = text.length();
        int m = pattern.length();
        if (m == 0) {
            return 0;
        }
        if (m > n) {
            return -1;
        }

        long highestPlace = 1;
        long patternHash = 0;
        long windowHash = 0;
        for (int index = 0; index < m - 1; index++) {
            highestPlace = highestPlace * BASE % MOD;
        }
        for (int index = 0; index < m; index++) {
            patternHash = (patternHash * BASE + pattern.charAt(index)) % MOD;
            windowHash = (windowHash * BASE + text.charAt(index)) % MOD;
        }

        for (int start = 0; start <= n - m; start++) {
            if (windowHash == patternHash
                    && text.regionMatches(start, pattern, 0, m)) {
                return start;
            }
            if (start < n - m) {
                long outgoing = text.charAt(start) * highestPlace % MOD;
                windowHash = (windowHash - outgoing + MOD) % MOD;
                windowHash = (windowHash * BASE + text.charAt(start + m)) % MOD;
            }
        }
        return -1;
    }

    static int[] prefixFunction(String pattern) {
        int[] prefix = new int[pattern.length()];
        for (int index = 1, length = 0; index < pattern.length(); index++) {
            while (length > 0
                    && pattern.charAt(index) != pattern.charAt(length)) {
                length = prefix[length - 1];
            }
            if (pattern.charAt(index) == pattern.charAt(length)) {
                length++;
            }
            prefix[index] = length;
        }
        return prefix;
    }

    static int kmpSearch(String text, String pattern) {
        Objects.requireNonNull(text, "text");
        Objects.requireNonNull(pattern, "pattern");
        if (pattern.isEmpty()) {
            return 0;
        }

        int[] prefix = prefixFunction(pattern);
        for (int textIndex = 0, matched = 0;
                textIndex < text.length();
                textIndex++) {
            while (matched > 0
                    && text.charAt(textIndex) != pattern.charAt(matched)) {
                matched = prefix[matched - 1];
            }
            if (text.charAt(textIndex) == pattern.charAt(matched)) {
                matched++;
                if (matched == pattern.length()) {
                    return textIndex - matched + 1;
                }
            }
        }
        return -1;
    }

    static final class Trie {
        private final Trie[] children = new Trie[26];
        private boolean terminal;

        void insert(String word) {
            validate(word);
            Trie node = this;
            for (int index = 0; index < word.length(); index++) {
                int slot = slot(word.charAt(index));
                if (node.children[slot] == null) {
                    node.children[slot] = new Trie();
                }
                node = node.children[slot];
            }
            node.terminal = true;
        }

        boolean contains(String word) {
            Trie node = findPrefix(word);
            return node != null && node.terminal;
        }

        boolean startsWith(String prefix) {
            return findPrefix(prefix) != null;
        }

        private Trie findPrefix(String value) {
            validate(value);
            Trie node = this;
            for (int index = 0; index < value.length(); index++) {
                node = node.children[slot(value.charAt(index))];
                if (node == null) {
                    return null;
                }
            }
            return node;
        }

        private static void validate(String value) {
            for (int index = 0; index < value.length(); index++) {
                slot(value.charAt(index));
            }
        }

        private static int slot(char value) {
            if (value < 'a' || value > 'z') {
                throw new IllegalArgumentException(
                        "this trie accepts lowercase ASCII letters only");
            }
            return value - 'a';
        }
    }

    public static void main(String[] args) {
        String text = "AABAACAADAABAAABAA";
        String pattern = "AABA";

        assert naiveSearch(text, pattern) == 0;
        assert rabinKarp(text, pattern) == 0;
        assert kmpSearch(text, pattern) == 0;
        assert naiveSearch("ABABABACABA", "ABABAC") == 2;
        assert rabinKarp("ABABABACABA", "ABABAC") == 2;
        assert kmpSearch("ABABABACABA", "ABABAC") == 2;
        assert kmpSearch("abc", "") == 0;
        assert rabinKarp("short", "longer") == -1;

        Trie trie = new Trie();
        trie.insert("app");
        trie.insert("apple");
        trie.insert("application");
        assert trie.contains("app");
        assert !trie.contains("appl");
        assert trie.startsWith("appl");
        assert trie.startsWith("");

        System.out.println("三种匹配结果一致");
    }
}

运行:

bash
javac StringMatchingDemo.java
java -ea StringMatchingDemo

断言通过后输出 三种匹配结果一致。

Rabin–Karp:哈希命中仍要确认 ​

长度为 m 的窗口哈希可写成:

text
c0 * BASE^(m-1) + c1 * BASE^(m-2) + ... + c(m-1)

实际每一步都对 MOD 取模。窗口右移时,先减去最左代码单元的高位贡献,再乘 BASE,最后加入新代码单元。BASE=65536 对应整个 char 值域;本组常量的乘积在 long 范围内。对 0<m<=n,预处理模式与首个窗口为 O(m),之后每次更新哈希为 O(1)。

不同字符串可能产生同一模哈希,所以哈希相等只能说明“值得核对”。代码用 regionMatches 逐字符确认,不会因碰撞返回错误答案。设遇到 c 个哈希相等的候选窗口,总上界为 O(n+m+c*m);实际核对也可能提前遇到不同字符。只有进一步约定输入或哈希随机模型,并控制核对总成本,才能谈期望 O(n+m)。本例固定 BASE 与 MOD,没有随机化保证;最坏仍可到 O(nm)。对抗输入、密码或防篡改场景不能把这份教学滚动哈希当作安全摘要。

KMP:失配后保留多长前缀 ​

对模式 ABABAC,前缀函数是:

text
pattern: A B A B A C
prefix:  0 0 1 2 3 0

prefix[i] 表示 pattern[0..i] 的最长真前缀长度,同时这个前缀也是后缀。若已经匹配 matched 个字符却在下一个位置失配,不必让文本指针回退;把 matched 改成 prefix[matched-1],继续检查仍可能成立的较短边界。

构造前缀函数和扫描文本都具有同样的“指针只前进、失败长度逐步缩短”结构,总时间是 O(n+m),额外空间 O(m)。每次成功比较最多把已匹配长度增加 1,每次回退又严格减小它,回退总量受此前增加量限制。这是线性分析的依据;内层 while 并不自动意味着二次时间。KMP 的优势是确定性线性界,不是保证在每个短字符串上都比标准库快。

Trie:前缀查询是另一份合同 ​

Trie 把词拆成边,共享公共前缀。contains("app") 要求路径存在且终点标记为完整词;startsWith("app") 只要求路径存在。这两个判断不能混用。

本页为了把索引关系写清,只接受 a..z,每个节点固定保存 26 个孩子引用。它先验证整串,所以即使开头就无匹配,查询也要 O(m+1) 时间;m 是输入词长度。若所有插入词合计 L 个字符,节点数至多 L+1,包括始终存在的根。每个节点 26 个槽,存储为 Θ(26P),P 为实际前缀节点数;26 固定时也写作 Θ(P)。实际字节数受 JVM、对象头、引用宽度与对齐影响,不能从“26 个槽”推导出一个跨环境固定数字。字符集很大或分支稀疏时,映射可避免为不存在的分支预留整排槽位,但会引入哈希、容器对象和不同的访问常数。不能不看分支密度就断言一定更省。

空前缀始终存在,即使还没插入任何词,startsWith("") 也返回 true;contains("") 则只有在插入空词后才为 true。重复插入不会产生额外词记录。非法字符会统一抛出 IllegalArgumentException,不因前缀碰巧不存在就提前返回 false。

Java char 是 UTF-16 代码单元。例如文本 "😀x" 中 x 的 Java 下标是 2,不是码点下标 1。若产品合同按 Unicode 码点、用户感知字符或规范化文本匹配,需要先统一编码与规范化策略,不能把 charAt 下标直接当作所有语言中的“字符位置”。

复杂度与选择 ​

方法时间额外空间适合的问题
朴素匹配最坏 O(nm)O(1)短模式、基线验证
Rabin–KarpO(n+m+c*m),c 为哈希候选数;最坏 O(nm)O(1)滚动窗口、多模式哈希扩展
KMPO(n+m)O(m)单模式、流式或确定性线性要求
Trie每次 O(m+1)存储 Θ(P);查询辅助 O(1)词典、前缀查询

空模式在三个搜索中均为常数时间;模式长于文本时朴素法与 Rabin–Karp 直接失败,KMP 仍会先建模式前缀表。本页 KMP 接收完整 String;流式版本还需跨输入块保存 matched 和累计位置。可参照 Princeton 子串查找与 Trie资料。

工程代码应优先明确标准库的语义和性能边界。Java String.indexOf 已提供子串查找;算法页面的价值在于理解何时能复用工作,而不是要求业务代码重复实现。

动手验证失配与前缀 ​

  1. 手算 AAAA、ABABAC 与 aabaaab 的前缀函数。
  2. 为三种搜索生成随机短字符串,与 String.indexOf 对照。
  3. 构造一个很小模数的 Rabin–Karp,主动制造哈希碰撞,确认逐字核对仍能拒绝假命中。
  4. 给 Trie 增加删除。共享前缀节点在什么条件下才能回收?
  5. 若要支持大小写不敏感的 Unicode 文本,输入合同还缺哪些规范化步骤?

朴素匹配提供最容易信任的基线;Rabin–Karp 以哈希筛选候选,但必须核对碰撞;KMP 把模式中已经知道的前后缀关系写进跳转表;Trie 则把问题从“文本中找模式”换成“词典中查前缀”。

下一章继续练习缩小问题:先看 分治如何拆开独立子问题,再看 回溯如何系统枚举选择。

Built with VitePress | Software Systems Atlas