qinghuan
← 回到知识探索

计算机趣闻 / 动手实验

KMP匹配失败时,为什么不是把已匹配长度直接清零?

从同一个文本字符继续比较,再用重叠匹配看清:前缀表保存的是还能沿用的后缀,不是一张跳过字符的许可表。

展开本文目录
  1. 下载完整练习包
  2. 先动手看看
  3. 先只看模式:哪些结尾同时也是开头?
  4. 失败后保留aba,再比较刚才同一个b
  5. 完整匹配以后,也可能保留一段后缀
  6. 文本指针只前进,不等于每个字符只比较一次
  7. 先固定字母和空模式规则,避免把文本语义混进匹配机制
  8. 完整程序:用独立方法核对结果
  9. 换一组有重复前缀的字符串,自己画回退
  10. 记住这些要点
  11. 别把例子用过头
  12. 自己试一小步
  13. 查原始资料
开始前,会这些就够了
遇到不熟悉的词,先看这 2 个解释
真前后缀
同一段字符串中既出现在开头又出现在结尾、但不等于整段自身的部分。
失配回退
当前字符对不上时,把已匹配长度缩到仍可沿用的后缀长度。
打开完整术语小抄 ↗

在文本abababaca里找模式ababaca。已经对上ababa以后,接下来的文本字符是b,模式却想要c。这一刻如果把已经对上的长度清零,就把之前观察到的字符关系一起扔掉了。

匹配失败不表示所有已读字符都没有用。ababa的结尾aba恰好也是模式的开头;保留这个关系,才可能从文本下标2开始找到完整模式。下面逐步看保留了什么,以及当前这个b为什么还要再比较。

把例子带到自己的电脑上

下载完整练习包

需要 Python 3.10 或更新版本,无第三方依赖;实测 Windows / Python 3.14.0,Python 3.10 仅做语法兼容检查。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。

下载 ZIP · 5 个文件
包里有什么
  • demo.py:完整可运行源码
  • README.md:运行步骤与练习
  • observed.stdout.txt:本次实际输出
  • verification.json:源码和运行验证记录
  • SHA256SUMS.txt:文件摘要

在这个文件夹打开 PowerShell,逐行运行

python --version
python -B demo.py

解压后,在含 demo.py 的目录打开终端。命令分别回车执行。Python 标准库的独立 KMP 实验;固定示例加 7650 组匹配与 126 个前缀表对照。仅接受短小写英文字母,不验证完整 Unicode 文本搜索。

找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。

字符失配时,已经匹配的部分要全部扔掉吗?

正在准备实验控件…

文本 abababaca,模式 ababaca。先预测会出现几次,再逐字读取。

模式每个前缀的最长真前后缀长度:0 | 0 | 1 | 2 | 3 | 0 | 1。

已读字符数
0
留给下一步的匹配长度
0
已发现起点
尚未读取

还没有读取文本;前缀表只根据模式本身计算。

固定小写英文字母,找出所有从 0 编号的匹配起点。错误模式在失配和完整匹配后都把长度清零;正确模式保留仍可作为新开头的后缀。这里不处理正则表达式、Unicode 字素或忽略大小写。

先只看模式:哪些结尾同时也是开头?

模式ababaca的前缀表是0、0、1、2、3、0、1。pi[i]表示:模式从开头到下标i这一段中,同时作为开头和结尾的最长真前后缀长度。真表示不能把整段自己也算进去。

例如ababa的开头aba和结尾aba相同,长度3;整段ababa长度5虽然也相同,却必须排除。因此对应的表项是3。再看ababac,结尾c连模式的第一个a都对不上,表项是0。

表项保存长度,不是文本下标。本文统一从0编号:已经匹配j个字符时,下一次想比较的是pattern[j];需要回退时读取pi[j−1]。混用“第几个字符”和“下标几”,很容易多减一次。

失败后保留aba,再比较刚才同一个b

文本abababaca的下标0到4先匹配了ababa,此时j=5。下标5的b与模式下标5的c不相等。查pi[4]=3,把j改为3,就表示前面读过的字符末尾还保留aba。

文本指针此刻没有前进,仍是下标5的b。现在拿它与模式下标3的b比较,就能把j推进到4。继续读取后面的aca,最终找到从文本下标2开始的完整ababaca。

演示里的错误模式在失败时直接把j置0,再让同一个文本字符与模式开头比较。它会丢掉这里的候选起点,输出空列表。并不是说所有从头比较的方法都错;朴素逐起点检查可以正确,只是不能一边丢掉部分状态、一边仍只让文本指针往前走。

完整匹配以后,也可能保留一段后缀

在abababa里找aba,正确起点是0、2、4。找到下标0开始的aba之后,末尾那个a仍然可以作为下一次匹配的开头,所以j应回到pi[2]=1,而不是0。

若找到完整模式后直接清零,接下来从后续字符重新积累,就会漏掉下标2的重叠匹配,只找到0和4。模式aaaa与一串a也会产生大量重叠,找到一次并不等于可以把所有已读信息丢掉。

这种行为取决于任务要不要返回重叠匹配。本文要求返回每一个匹配起点;如果产品只允许不重叠命中,需要明确改变输出规则,而不能把漏掉的重叠结果当作同一任务的正确答案。

文本指针只前进,不等于每个字符只比较一次

前缀表的构建也用同样的回退:新字符与当前候选后缀接不上,就尝试这个候选内部更短的真前后缀。它按长度逐渐缩短,不必把所有长度从头逐一验证。

搜索时,一个文本字符可能与多个模式位置比较。线性时间的理由是j只会因成功比较增加,且每读一个文本字符最多增加1;每次回退又让j严格减小。总下降次数受此前总增加次数限制,所以累计比较次数是O(n+m),n为文本长度,m为模式长度。

本文Python记录每次回退和命中,全部回退数量仍为线性,含轨迹和输出的空间是O(n+m)。浏览器为了来回翻步骤,会在每个字符后复制当前命中列表;重叠很多时这些快照可能占O(n²)空间和复制时间。交互演示不是性能基准。

先固定字母和空模式规则,避免把文本语义混进匹配机制

示例只接受最多200个小写英文字母;文本可以为空,模式必须非空。这是为了让一个字符的位置直观明确。它不负责大小写折叠、Unicode规范化、中文分词或正则表达式。

空模式在不同API中可能另有定义,本文选择拒绝,而不是暗中返回全部位置。正文中的相等就是字符相等;外观相同的复杂Unicode文本是否相等,需要另一个处理层决定。

完整程序:用独立方法核对结果

保存下方全部代码为 demo.py,执行 python demo.py。程序只使用 Python 标准库,本次实际运行环境是 Windows / CPython 3.14.0;另通过 Python 3.10 语法解析,未在 3.10 解释器中执行。

枚举a、b组成的长度0到7文本和长度1到4模式,共7650组,用逐个起点切片比较作独立参考;另枚举126个模式,用直接检查全部真前后缀核对前缀表,并拒绝5类无效输入。

检查使用显式 AssertionError,正常执行和 python -O 执行都通过。输出直接取自本次运行;小范围穷举核对实现,不能代替前文对一般情况的解释。

展开收起代码 · 小实验python · 97 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

小实验 · python
"""KMP retains a matched suffix; resetting it can lose overlapping matches."""
from itertools import product


def require(ok, message):
    if not ok:
        raise AssertionError(message)


def valid_string(value, empty=True):
    if type(value) is not str or len(value) > 200 or (not empty and not value):
        raise ValueError("a string of at most 200 lowercase ASCII letters required")
    if any(not "a" <= char <= "z" for char in value):
        raise ValueError("lowercase ASCII only")


def prefix(pattern):
    valid_string(pattern, False)
    pi = [0] * len(pattern)
    for i in range(1, len(pattern)):
        j = pi[i - 1]
        while j and pattern[i] != pattern[j]:
            j = pi[j - 1]
        if pattern[i] == pattern[j]:
            j += 1
        pi[i] = j
    return pi


def search(text, pattern, reset=False):
    valid_string(text)
    pi = prefix(pattern)
    if type(reset) is not bool:
        raise ValueError("reset must be bool")
    j, matches, trace = 0, [], []
    for i, char in enumerate(text):
        before, falls = j, []
        while j and char != pattern[j]:
            target = 0 if reset else pi[j - 1]
            falls.append((j, target))
            j = target
        if char == pattern[j]:
            j += 1
        matched = None
        if j == len(pattern):
            matched = i - len(pattern) + 1
            matches.append(matched)
            j = 0 if reset else pi[-1]
        trace.append((i, char, before, falls, j, matched))
    return matches, trace


def naive(text, pattern):
    return [i for i in range(len(text) - len(pattern) + 1)
            if text[i:i + len(pattern)] == pattern]


def main():
    for text, pattern in [("abababaca", "ababaca"), ("abababa", "aba")]:
        good, _ = search(text, pattern)
        bad, _ = search(text, pattern, True)
        print(f"text={text}; pattern={pattern}; prefix={prefix(pattern)}")
        print(f"keep_suffix={good}; reset_to_zero={bad}")
        require(good == naive(text, pattern) and good != bad, "visible counterexample")
    cases = 0
    for n in range(8):
        for letters in product("ab", repeat=n):
            text = "".join(letters)
            for m in range(1, 5):
                for letters in product("ab", repeat=m):
                    pattern = "".join(letters)
                    actual, _ = search(text, pattern)
                    require(actual == naive(text, pattern), "all starting positions")
                    cases += 1
    prefix_cases = 0
    for m in range(1, 7):
        for letters in product("ab", repeat=m):
            pattern = "".join(letters)
            expected = []
            for end in range(1, m + 1):
                word = pattern[:end]
                expected.append(max(k for k in range(end) if word[:k] == word[end-k:]))
            require(prefix(pattern) == expected, "proper prefix/suffix enumeration")
            prefix_cases += 1
    for args in [("abc", ""), ("A", "a"), ("a", "é"), (True, "a"), ("a" * 201, "a")]:
        try:
            search(*args)
        except ValueError:
            pass
        else:
            raise AssertionError("invalid input accepted")
    print(f"search_cases={cases}; prefix_cases={prefix_cases}; invalid_inputs=5")
    print("PASS: all-start-position search and independent proper-border enumeration")


if __name__ == "__main__":
    main()

怎样运行:在独立练习文件夹中,依次执行下载区的命令。2026-10-26 使用 Python 3.10 或更新版本,无第三方依赖;实测 Windows / Python 3.14.0,Python 3.10 仅做语法兼容检查 复跑这份源码并核对完整输出。Python 标准库的独立 KMP 实验;固定示例加 7650 组匹配与 126 个前缀表对照。仅接受短小写英文字母,不验证完整 Unicode 文本搜索。

运行后对照结果
text=abababaca; pattern=ababaca; prefix=[0, 0, 1, 2, 3, 0, 1]
keep_suffix=[2]; reset_to_zero=[]
text=abababa; pattern=aba; prefix=[0, 0, 1]
keep_suffix=[0, 2, 4]; reset_to_zero=[0, 4]
search_cases=7650; prefix_cases=126; invalid_inputs=5
PASS: all-start-position search and independent proper-border enumeration

换一组有重复前缀的字符串,自己画回退

练习不预填答案:在文本aaaaaa中找模式aaaa。先预测全部匹配起点,再写出每次完整命中后j应保留多少。比较直接清零会漏掉哪些位置。

再把文本改成ababababca、模式改成ababca,逐字符记录失败前的j、回退后的j,以及当前文本字符有没有前进。解释一次回退为什么仍可能失败,并继续回退。

记住这些要点

  • 前缀表记录最长真前后缀的长度,不是文本中的跳转下标。
  • 失配时先保留可复用的后缀,并重新比较同一个文本字符。
  • 要求所有命中位置时,完整匹配后也要保留重叠候选。
  • 线性时间依靠累计回退次数受限,不是每个字符只比较一次。

别把例子用过头

  • 失配就清零,却不回头检查被丢弃的候选起点。
  • 完整匹配后清零,漏掉重叠命中。
  • 把pi[i]当作下标,或把整段自身算作真前后缀。
  • 把ASCII小示例直接当作完整的Unicode搜索规则。

自己试一小步

文本ababababca、模式ababca发生失配时,如何保留候选并证明没有跳过当前字符?

  1. 独立列出模式每段的最长真前后缀。
  2. 记录每个文本字符对应的j与回退链。
  3. 用逐起点切片的参考方法核对全部命中。

做到这些,就算完成

  • 回退不会提前推进文本指针。
  • 完整匹配后保留所需后缀。
  • 先预测再运行,练习不预填命中列表。

查原始资料

想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。

  • Cornell CS312:KMP字符串匹配与前缀回退 (新标签页)↗Cornell University / CS312 · 英文

    失配后利用已匹配段的前后缀继续搜索,以及累计回退次数的线性界。

    已阅读KMP与prefix函数相关正文和伪代码;本文pi数组采用从0开始的下标,代码独立编写,未使用该页其他算法的结论。

  • Hawaii ICS 311 Topic 23:String Matching (新标签页)↗University of Hawaiʻi / ICS 311 · 英文

    前缀函数含义,以及完整匹配之后继续利用前缀关系寻找后续匹配。

    已阅读KMP段落及完整命中后处理的文字说明;网页部分伪代码以图片呈现,本次没有读取那些图片,也没有声称读完整教材。