qinghuan
← 回到知识探索

计算机趣闻 / 可下载小项目

一串字绕成圆,怎样选同一个开头?提前停下为什么会选错

aaba 从不同地方读,可以得到 aaba、abaa、baaa、aaab。用 Booth 算法找统一写法,再亲手重现作者勘误中的提前停止反例。

展开本文目录
  1. 下载完整练习包
  2. 先动手看看
  3. 先说清楚:只转起点,不改变圆上的顺序
  4. 先写慢而清楚的方法,作为独立裁判
  5. 用 k 记候选开头,用 failure 记还能复用的前后缀
  6. aaba 在第 5 步才纠正候选,捷径刚好抢先返回
  7. 得到的是字符顺序标签,不是人类词典或图形旋转
  8. 先看真实反例,再检查 7375 个短串
  9. 自己改变等价规则,先写检查再改代码
  10. 记住这些要点
  11. 别把例子用过头
  12. 自己试一小步
  13. 查原始资料
开始前,会这些就够了
  • 会用字符串下标、切片和 while 循环;能按第一个不同字符比较两个字符串。
  • 可先读 Unicode 规范化与相等,区分字面比较和显示相似。
遇到不熟悉的词,先看这 3 个解释
最小循环表示
从圆的不同起点读完一圈,选择字符顺序最小的写法。
失败链接
比较失败时,指出已经匹配内容中还能接着使用的较短前缀。
字典序
从左往右,按第一个不同元素决定先后。
打开完整术语小抄 ↗

把 aaba 的四个字符排成一个圆,圆上没有天然的第一个字符。两个人从不同位置记下同一个圆,可能分别写成 aaba 和 abaa。需要查重时,可以约定:把所有起点中按字符顺序最小的写法作为共同标签。

本篇的惊讶点是一个曾出现在已发表算法中的捷径:看起来已经走够一圈,就提前返回当前起点。Booth 本人的勘误页记录了反例;我们保留可切换的错误模式,并用枚举全部旋转逐项核对。

把例子带到自己的电脑上

下载完整练习包

需要 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 的目录打开终端。命令分别回车执行。穷举 a/b 字母表长度 0 到 11,以及 a/b/c 长度 0 到 7,共 7375 个输入;与全部旋转枚举核对。

另有 22 次循环不变性检查、99 个 a…aba 回归用例、中文及 emoji 的边界样例和 4 类非法输入。显式 require 会抛 AssertionError,不受 python -O 关闭 assert 的影响。

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

走够一圈就停,会错过更小的开头吗?

正在准备实验控件…

扫描位置 j
0
候选开头 k
0
当前候选写法
aaba
本步是否提前返回
否

初始候选开头为 0,尚未比较。

  1. 起点 0aaba当前候选
  2. 起点 1abaa
  3. 起点 2baaa
  4. 起点 3aaab

按 Unicode 码点旋转和比较;不做语言排序、字形簇分割或反向旋转。错误模式复现 Booth 作者勘误中 j−k>n 的提前退出。这里只展示小输入轨迹;保存全部旋转和状态的展示成本不同于正文算法。

先说清楚:只转起点,不改变圆上的顺序

原串 aaba 的起点 0、1、2、3 分别产生 aaba、abaa、baaa、aaab。比较字符串时从左往右看第一个不同字符,a 在 b 之前,因此最小结果是 aaab,起点为 3。这里下标从 0 开始。

旋转不是排序。把 caba 排序会得到 aabc,但这不是原圆任意起点的顺序;旋转后的最小结果是 abac。旋转也不允许反向读,若业务把顺时针和逆时针当作相同,需要另外比较反转后的圆。

对于 baba,起点 1 和 3 都产生 abab。统一写法是唯一的字符串,产生它的起点未必唯一。本程序交付一个有效起点,测试主要核对结果字符串,不把“唯一最优编号”当成额外保证。空串按接口约定返回起点 0 与空串。

先写慢而清楚的方法,作为独立裁判

brute 将每个起点的 text[k:]+text[:k] 实际构造出来,取最小字符串。它直接回答定义,不需要理解快速算法;这也让它适合作为小输入测试的裁判。

长度 n 有 n 个起点,每次切片拼接和最坏比较都可能涉及 n 个字符,因此枚举方法的最坏时间为 O(n²)。代码采用生成器,不同时保存所有旋转,额外峰值空间是 O(n)。

快速算法的目标是复用已经比较出的前缀关系,避免每换一个候选就从头重新读完。我们仍保留慢方法:检查结果时不复用快速实现的 failure 表,避免两份代码共享同一个下标错误。

用 k 记候选开头,用 failure 记还能复用的前后缀

把原串当成可以循环访问的序列:第 j 个字符用 text[j % n] 读取。k 是当前候选起点,j 从 1 走到 2n−1;这样能够看到跨过原串末尾的候选。没有实际复制两遍字符串。

failure 按当前候选之后的相对位置记信息。值 i 表示已匹配的较短前缀最后下标,−1 表示空前缀。发生不同字符时,沿 failure[i] 尝试更短的可复用部分;若新位置的字符更小,就把候选起点移到 j−i−1。

这里的表采用“最后下标、−1 为空”的约定,不能直接与“长度、0 为空”的前缀数组混用。学习时先追踪 k 的变化,再观察 failure 如何跳转;完整代码是对作者无提前停止分支的独立 Python 教学改写。

aaba 在第 5 步才纠正候选,捷径刚好抢先返回

默认 n=4。无捷径版本在 j=1、2、3、4 时仍保留 k=0;到 j=5,读取循环序列中的 a,才把候选改成 k=3。继续完成扫描,最终得到 aaab。

错误模式在每轮比较前加入 if j-k > n: return k。到 j=5、k=0 时,5>4 成立,于是直接返回 aaba,错过本轮应发生的候选更新。0010 同样被错误模式留在起点 0,而正确结果是起点 3 的 0001。

作者页面还记录了更早的 >= 写法在 alfa 上出错,以及改成 > 后仍有 aaba 一族反例。本实验的错误开关重现后者;没有把 > 称为已经修好的通用条件。删掉这个不必要的提前退出后,按原本完整扫描执行。

得到的是字符顺序标签,不是人类词典或图形旋转

Python 字符串按 Unicode 码点比较,索引也按码点计数;汉字和 emoji 可以参与,但组合重音和多个码点组成的 emoji 仍可能在码点中间被切开。本程序不做字形簇分割、不做规范化、不忽略大小写。

用于圆形序列查重时,比较的是完整相同标签,不是哈希碰巧相同。两个字符串有相同的最小旋转,当且仅当它们在这种字符序列规则下互为旋转;要把反射、字符替换或忽略空白也视为相同,必须另写等价规则。

Booth 扫描的核心最坏时间 O(n)、额外 failure 空间 O(n)。演示回调若保存全部状态,会增加展示存储;本文件默认只保存必要表,测试中的慢枚举和长输出不属于快速核心的成本。这里没有给出实际吞吐性能承诺。

先看真实反例,再检查 7375 个短串

保存下面完整代码为 demo.py,运行 python demo.py。本次在 Windows 的 CPython 3.14.0 实跑,标准库即可;另用 AST 的 Python 3.10 语法规则解析通过,未声称已在 3.10 解释器实跑。

穷举 a/b 字母表长度 0 到 11,以及 a/b/c 长度 0 到 7,共 7375 个输入;与全部旋转枚举核对。另有 22 次循环不变性检查、99 个 a…aba 回归用例、中文及 emoji 的边界样例和 4 类非法输入。显式 require 会抛 AssertionError,不受 python -O 关闭 assert 的影响。

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

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

小实验 · python
"""Booth's least circular shift, WITHOUT the invalid early-return shortcut.

Algorithm reference: https://www.cs.ubc.ca/~ksbooth/PUB/LCS.shtml
Offsets and comparisons use Python Unicode code points, without normalization.
"""
from itertools import product


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


def rotate(text, start):
    return text[start:] + text[:start]


def least_rotation(text, shortcut=False, emit=None):
    if type(text) is not str:
        raise TypeError("text must be str")
    if type(shortcut) is not bool:
        raise TypeError("shortcut must be bool")
    n = len(text)
    if not n:
        return 0
    failure = [-1] * (2*n)
    k = 0
    for j in range(1, 2*n):
        # Intentionally faulty mode reproduces the author's documented shortcut.
        if shortcut and j-k > n:
            if emit:
                emit(j, k, [], True)
            return k
        i = failure[j-k-1]
        updates = []
        while i != -1 and text[j % n] != text[(k+i+1) % n]:
            if text[j % n] < text[(k+i+1) % n]:
                previous = k
                k = j-i-1
                updates.append((previous, k))
            i = failure[i]
        if i == -1 and text[j % n] != text[(k+i+1) % n]:
            if text[j % n] < text[(k+i+1) % n]:
                previous = k
                k = j
                updates.append((previous, k))
            failure[j-k] = -1
        else:
            failure[j-k] = i+1
        if emit:
            emit(j, k, updates, False)
    return k


def brute(text):
    # Full enumeration shares neither failure links nor candidate updates.
    return min((rotate(text, k), k) for k in range(len(text))) if text else ("", 0)


def main():
    for text in ("aaba", "0010", "alfa", "baba", "aaaa", ""):
        good = least_rotation(text)
        bad = least_rotation(text, True)
        require(rotate(text, good) == brute(text)[0], "fixed example")
        print(f"text={text!r}; start={good}; minimum={rotate(text, good)!r}; shortcut_start={bad}; shortcut_result={rotate(text,bad)!r}")
    require(least_rotation("aaba", True) == 0, "reproduce faulty shortcut")
    print("trace aaba without shortcut:")
    least_rotation("aaba", emit=lambda j,k,changes,early: print(f"j={j}; candidate={k}; updates={changes}; early={early}"))
    counts = 0
    for alphabet, maximum in (("ab", 11), ("abc", 7)):
        for n in range(maximum + 1):
            for letters in product(alphabet, repeat=n):
                text = "".join(letters)
                index = least_rotation(text)
                minimum, _ = brute(text)
                require(rotate(text,index) == minimum, "rotation oracle")
                require(index == 0 if n == 0 else 0 <= index < n, "index range")
                counts += 1
    cyclic_checks = 0
    for text in ("caba", "aaba", "baba", "aaaa", "你呀你", "😀a😀"):
        for k in range(len(text)):
            shifted = rotate(text,k)
            require(rotate(shifted, least_rotation(shifted)) == brute(text)[0], "rotation invariant")
            cyclic_checks += 1
    for n in range(2, 101):
        text = "a" * n + "ba"
        require(rotate(text,least_rotation(text)) == brute(text)[0], "published regression family")
    for invalid in (None, 123, b"ab", ["a"]):
        try:
            least_rotation(invalid)
        except TypeError:
            pass
        else:
            raise AssertionError("invalid input accepted")
    print(f"exhaustive_strings={counts}; cyclic_invariance={cyclic_checks}; regression_family=99; invalid_inputs=4")
    print("PASS: minimum rotation string agrees with enumeration; tied positions may coexist")


if __name__ == "__main__":
    main()

怎样运行:在独立练习文件夹中,依次执行下载区的命令。2026-10-24 使用 Python 3.10 或更新版本,无第三方依赖;实测 Windows / Python 3.14.0,Python 3.10 仅做语法兼容检查 复跑这份源码并核对完整输出。穷举 a/b 字母表长度 0 到 11,以及 a/b/c 长度 0 到 7,共 7375 个输入;与全部旋转枚举核对。另有 22 次循环不变性检查、99 个 a…aba 回归用例、中文及 emoji 的边界样例和 4 类非法输入。显式 require 会抛 AssertionError,不受 python -O 关闭 assert 的影响。

运行后对照结果
text='aaba'; start=3; minimum='aaab'; shortcut_start=0; shortcut_result='aaba'
text='0010'; start=3; minimum='0001'; shortcut_start=0; shortcut_result='0010'
text='alfa'; start=3; minimum='aalf'; shortcut_start=3; shortcut_result='aalf'
text='baba'; start=1; minimum='abab'; shortcut_start=1; shortcut_result='abab'
text='aaaa'; start=0; minimum='aaaa'; shortcut_start=0; shortcut_result='aaaa'
text=''; start=0; minimum=''; shortcut_start=0; shortcut_result=''
trace aaba without shortcut:
j=1; candidate=0; updates=[]; early=False
j=2; candidate=0; updates=[]; early=False
j=3; candidate=0; updates=[]; early=False
j=4; candidate=0; updates=[]; early=False
j=5; candidate=3; updates=[(0, 3)]; early=False
j=6; candidate=3; updates=[]; early=False
j=7; candidate=3; updates=[]; early=False
exhaustive_strings=7375; cyclic_invariance=22; regression_family=99; invalid_inputs=4
PASS: minimum rotation string agrees with enumeration; tied positions may coexist

自己改变等价规则,先写检查再改代码

练习不预先给结果:选一个同时包含重复字符和大小写的圆,先手写所有起点,再比较当前程序结果。接着尝试把反向读取也视为相同,明确新标签如何计算。

记录原规则和新规则各自允许哪些变换;用两个互为反转、却不是旋转的序列测试。若展示起点,说明它属于原序列还是反转后的序列,不要只返回一个含义模糊的编号。

记住这些要点

  • 循环表示要保持原有相邻顺序,不能用字符排序代替。
  • 最小字符串唯一,不代表最优起点唯一。
  • 扫描跨度超过一圈不自动证明可以提前返回;aaba 是可复现反例。
  • 独立枚举验证结果,码点与人类字符边界要单独约定。

别把例子用过头

  • 把 faulty shortcut 开关用于真正查重。
  • 将 failure 的下标约定和普通前缀长度数组混用。
  • 按码点旋转后,却把结果声称为完整视觉字符的旋转。

自己试一小步

怎样把反向读取也纳入圆的统一标签,并保持起点解释清楚?

  1. 先定义允许旋转还是同时允许反射。
  2. 为不同长度、重复周期、空串分别写输入。
  3. 分别枚举两种方向的全部旋转作为独立参考。

做到这些,就算完成

  • 参考方法不调用快速核心。
  • 相同标签的语义说明完整。
  • 练习报告同时记录输入、方向和起点,不预填答案。

查原始资料

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

  • Booth 本人的 LCS 算法与勘误 (新标签页)↗Kellogg S. Booth / UBC · 英文

    原算法的 failure 状态、无 shortcut 分支,以及 alfa、aaba、0010 的提前返回反例。

    已阅读整页算法和勘误;页面默认宏值仍展示有问题的 shortcut,本篇明确使用禁用分支。

  • Python 字符串比较规则 (新标签页)↗Python Software Foundation · 英文

    字符串按 Unicode 码点逐项比较,显示相同不保证码点相等。

    已阅读 Value comparisons 及 Unicode 码点脚注;不作为 Booth 正确性的来源。