开始前,会这些就够了
- 会用字符串下标、切片和 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 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。
走够一圈就停,会错过更小的开头吗?
正在准备实验控件…
按 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 的影响。
展开收起代码 · 小实验
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
"""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 的下标约定和普通前缀长度数组混用。
- 按码点旋转后,却把结果声称为完整视觉字符的旋转。
自己试一小步
怎样把反向读取也纳入圆的统一标签,并保持起点解释清楚?
- 先定义允许旋转还是同时允许反射。
- 为不同长度、重复周期、空串分别写输入。
- 分别枚举两种方向的全部旋转作为独立参考。
做到这些,就算完成
- 参考方法不调用快速核心。
- 相同标签的语义说明完整。
- 练习报告同时记录输入、方向和起点,不预填答案。
查原始资料
想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。
- Booth 本人的 LCS 算法与勘误 (新标签页)↗
原算法的 failure 状态、无 shortcut 分支,以及 alfa、aaba、0010 的提前返回反例。
- Python 字符串比较规则 (新标签页)↗
字符串按 Unicode 码点逐项比较,显示相同不保证码点相等。