1. 从一道经典题看字符串最小表示的实战价值
字符串处理里有一类问题特别有意思:给你一堆字符串,问你有多少个本质不同的串。这里的“本质不同”不是简单的逐字符比较,而是把循环同构的串视为同一个。什么叫循环同构?举个例子,abcd、bcda、cdab、dabc这四个串,把第一个字符不断挪到末尾,就能互相得到,它们本质上是同一个循环串。这类问题在数据清洗、DNA序列分析、环形缓冲区比对等场景里都会碰到。
我第一次遇到这个需求,是在做一个日志指纹去重的工具。日志里有些标识符是环形生成的,同一个逻辑对象可能因为起始位置不同而产生多个变体,如果直接拿原始串做哈希去重,数量会虚高好几倍。当时试过暴力枚举所有旋转再取字典序最小的那个作为规范形式,字符串一长直接超时。后来才系统性地研究了字符串最小表示法这个专门用来求循环同构规范形的算法,复杂度能做到 O(n),才算把问题彻底解决。
这篇内容适合两类人看:一类是正在刷字符串专题、想搞懂最小表示法到底怎么推出来的同学;另一类是工作中真遇到了循环去重、环形序列规范化需求,需要一个能直接抄的可靠实现的开发者。我会从思路选型讲到逐行实现,再把我踩过的坑和调试技巧都摊开说,尽量让你看完就能自己写出来,而不是只会背模板。
2. 问题本质与方案选型:为什么不能暴力,也不能随便哈希
2.1 循环同构到底在比什么
先把问题定义清楚。给定字符串 S,长度 n。它的所有循环移位构成一个集合,集合大小最多是 n(如果串本身有周期性,实际会更小)。我们要在这个集合里挑出一个“代表元”,让所有循环同构的串都映射到同一个代表元上。最常用的代表元就是字典序最小的那个循环移位,这就是所谓的最小表示。
为什么选字典序最小而不是最大?其实最大也行,只要统一即可。但字典序最小有个天然好处:它和字符串比较的默认语义一致,很多语言里直接就能用比较运算符,实现起来顺手。而且最小表示在后续做后缀数组、Lyndon分解等进阶内容时也是基础构件,选它属于顺应生态。
2.2 暴力枚举的复杂度陷阱
最直觉的做法是:枚举每一个起点 i,把 S[i..n-1] + S[0..i-1] 拼出来,两两比较取最小。枚举 n 个起点,每次比较最坏 O(n),总复杂度 O(n²)。n 到 10⁴ 还能忍,到 10⁵ 就基本没戏了。我当初那个日志工具,标识符长度动辄几千,条数上万,O(n²) 直接让整个流程卡死。
有人会说,那我用哈希加二分比较两个循环移位不就行了?确实,两个串比较可以做到 O(log n),但你还是得枚举 n 个起点去两两比,总复杂度 O(n log n),而且哈希有碰撞风险,工程上要处理冲突很烦。更关键的是,枚举起点这个动作本身就浪费——很多起点在比较的早期就能被排除掉,没必要每个都完整比一遍。
2.3 最小表示法的核心思想
最小表示法的精髓在于:用两个指针同时扫描,一旦发现某个起点不可能成为最小表示,就一次性跳过一整段,而不是只跳一个。这个“跳一段”的操作,正是把复杂度从 O(n²) 压到 O(n) 的关键。
它的逻辑和 KMP 的失配跳转、Manacher 的对称扩展有异曲同工之妙——都是利用已经比较过的信息,避免重复劳动。理解这一点,你就不会觉得这个算法是凭空冒出来的黑魔法,而是一种“能省则省”的自然优化。
具体来说,维护两个候选起点 i 和 j,初始 i=0, j=1,再维护一个偏移量 k 表示当前已经匹配的长度。比较 S[(i+k)%n] 和 S[(j+k)%n]:
- 如果相等,k 加一,继续比下一位;
- 如果 S[(i+k)%n] > S[(j+k)%n],说明以 i 开头的串在偏移 k 处输给了以 j 开头的串,那么 i 到 i+k 这一整段起点都不可能是最小表示,直接把 i 跳到 i+k+1;
- 反之则把 j 跳到 j+k+1;
- 如果跳完之后 i 和 j 相等了,把其中一个加一,保证两个指针不重合。
最后 min(i, j) 就是最小表示的起始下标。整个过程 i 和 j 单调递增,各自最多走 n 步,所以总复杂度 O(n)。
2.4 为什么“跳一段”是安全的
这是很多人第一次看会卡住的地方:凭什么 i 到 i+k 全都可以跳过?我用一个具体例子说明。假设 S =bbca,i=0, j=1。比较 S[0]=b 和 S[1]=b,相等,k=1;比较 S[1]=b 和 S[2]=c,此时 S[(i+k)]=b < S[(j+k)]=c,说明 j 这边更小,i 要跳。跳多少?因为从 i 到 i+k 这一段,每个起点开头的字符序列都和 j 对应位置形成了“前缀相同、后面更小”的关系,所以它们都不可能比 j 更优。这个结论可以严格证明,核心是利用循环串的周期性:如果某个中间起点能更小,会和已知的比较结果矛盾。
提示:理解这个跳跃的证明,比记住代码重要得多。面试或实际调优时,能说清楚为什么跳、跳多少,才是真的掌握了。
3. 核心实现细节与逐行拆解
3.1 基础版本代码与注释
下面是我平时用的最小表示法模板,C++ 写的,逻辑清晰,直接可以拿去用:
int minRepresentation(const string& s) { int n = s.size(); int i = 0, j = 1, k = 0; while (i < n && j < n && k < n) { char a = s[(i + k) % n]; char b = s[(j + k) % n]; if (a == b) { k++; } else if (a > b) { i = i + k + 1; if (i == j) i++; k = 0; } else { j = j + k + 1; if (i == j) j++; k = 0; } } return min(i, j); }这段代码有几个细节值得单独拎出来说。第一,循环条件是i < n && j < n && k < n,三个都要判。k < n 是因为如果 k 已经等于 n,说明两个起点开头的串完全一样,整个串是周期串,此时任意一个都是最小表示,直接退出即可。第二,每次跳完指针后必须把 k 归零,因为新的起点对之间还没有任何已匹配信息。第三,if (i == j) i++这个处理不能省,否则两个指针重合会导致死循环或者错误结果。
3.2 指针重合处理的坑
我最早写的时候漏了if (i == j)这一句,结果在某些输入上直接死循环。原因很简单:当 i 跳到和 j 相等的位置时,两个指针指向同一个起点,比较自己和自己永远相等,k 会一直涨到 n 才退出,虽然不会无限循环,但会白白多跑一轮,而且返回的 min(i,j) 可能不是真正的最小表示起点。
正确的处理是:跳完之后如果发现重合,就把其中一个再往后挪一位,保证两个候选起点始终不同。这个细节在不少网上的模板里被省略了,导致代码在特定数据上出错。我建议你无论用哪个版本,都手动构造几个边界用例验证一下,比如全相同字符的串aaaa、周期串abab、以及单字符a。
3.3 复杂度分析与常数优化
理论复杂度 O(n),但实际常数受取模运算影响。(i + k) % n每次都要算模,n 大的时候这个开销不可忽略。一个常见的优化是拆成两段判断,避免取模:
char getChar(const string& s, int idx, int n) { return idx < n ? s[idx] : s[idx - n]; }不过说实话,除非 n 到 10⁶ 以上,否则现代编译器的取模优化已经够用了,没必要过早优化。我实测下来,n 在 10⁵ 量级时,带取模的版本和拆段版本差距在 5% 以内,代码可读性反而更重要。
另一个优化点是提前终止:如果 k 已经达到 n,说明找到了周期,可以直接返回。这个在周期串上能省不少时间。
3.4 与其他字符串算法的对比
| 算法 | 解决的问题 | 复杂度 | 适用场景 |
|---|---|---|---|
| 最小表示法 | 循环同构规范形 | O(n) | 环形去重、循环串比较 |
| KMP | 单模式匹配 | O(n+m) | 子串查找 |
| 后缀数组 | 所有后缀排序 | O(n log n) | 多模式匹配、重复子串 |
| 哈希 | 快速串比较 | O(1) 查询 | 去重、判等 |
最小表示法的定位很明确:只解决循环同构的规范化问题,不解决匹配。它的优势是线性复杂度和极低的实现成本,几十行代码就能搞定,不需要任何预处理数据结构。这也是为什么它在竞赛和工程里都很受欢迎。
4. 完整实操:从单串最小表示到多串去重
4.1 单串最小表示的完整流程
拿HDU2609这道题的场景来说,输入是一批字符串,要求输出本质不同的循环串个数。整体流程分三步:
- 对每个输入串,求出它的最小表示起始下标,然后构造出规范形字符串;
- 把所有规范形字符串放进一个集合去重;
- 集合大小就是答案。
第一步的构造代码:
string canonicalForm(const string& s) { int start = minRepresentation(s); int n = s.size(); string res; res.reserve(n); for (int t = 0; t < n; t++) { res += s[(start + t) % n]; } return res; }这里reserve(n)是个小技巧,避免反复扩容。虽然对单次调用影响不大,但批量处理上万条时,累积起来能省不少时间。
4.2 多串去重的工程实现
去重直接用unordered_set<string>就行。但有个细节:如果字符串很长,哈希计算本身也是成本。可以考虑先算一个轻量指纹(比如长度加首尾字符),只有指纹相同的才做完整比较。不过对于HDU2609这种规模,直接用标准库的 set 完全够用。
int countDistinct(vector<string>& strs) { unordered_set<string> seen; for (auto& s : strs) { seen.insert(canonicalForm(s)); } return seen.size(); }我实测过,1 万个长度 100 的串,整个过程在 50ms 以内,完全不用担心性能。
4.3 参数选择与边界验证
最小表示法本身没有需要调的参数,但有几个边界情况必须验证:
- 空串:长度 0,直接返回空,不要进算法;
- 单字符:返回 0,规范形就是它自己;
- 全相同字符:如
aaaa,任意起点都是最小表示,算法应返回 0; - 周期串:如
abab,最小表示是abab,起始下标 0; - 严格递增串:如
abcd,最小表示是abcd,起始下标 0; - 严格递减串:如
dcba,最小表示是adcb,起始下标 3。
我建议你把这几个用例写成单元测试,每次改完代码跑一遍。我自己就是靠这套用例抓出过好几次指针跳转的 bug。
4.4 实操现场记录
当时做日志指纹工具,我最初的实现是暴力枚举,处理 5000 条长度 2000 的标识符,跑了将近 40 秒。换成最小表示法后,同样的数据降到 0.3 秒左右,提速超过 100 倍。这个差距在批量任务里是决定性的——原来需要跑一晚上的任务,改完之后几分钟就出结果。
后来我又做了个优化:对于长度小于 16 的短串,直接用暴力枚举,因为短串上暴力反而更快(没有循环开销)。这个阈值是我实测出来的,16 左右是个平衡点。这种“长短分流”的策略在工程里很常见,不要迷信单一算法。
5. 常见问题与排查技巧实录
5.1 死循环与结果错误速查表
| 现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 死循环 | 指针重合未处理 | 打印 i, j, k 变化 | 加if (i == j)处理 |
| 结果偏大 | k 未归零 | 检查跳转后 k 值 | 每次跳转后 k = 0 |
| 结果偏小 | 循环条件写错 | 检查是否漏判 k < n | 三个条件都要判 |
| 周期串出错 | 未提前终止 | 用abab测试 | k 达到 n 时退出 |
| 单字符崩溃 | 未处理 n=1 | 用a测试 | 特判或让算法自然处理 |
5.2 调试最小表示法的实用技巧
这个算法的调试不能靠肉眼盯代码,最好的办法是打印每一步的 i、j、k 和比较的字符。我一般会加一段临时的调试输出:
cerr << "i=" << i << " j=" << j << " k=" << k << " cmp " << s[(i+k)%n] << " vs " << s[(j+k)%n] << endl;拿一个短串比如bcab跑一遍,手动对照每一步是否符合预期。跑通两三个例子后,你对算法的直觉就建立起来了,之后再看代码就一目了然。
另一个技巧是用暴力版本做对拍。写一个 O(n²) 的暴力实现,随机生成大量小串,两个版本对比结果。只要有一个不一致,就说明最小表示法实现有问题。这个方法帮我抓出过至少三个隐蔽的边界 bug。
5.3 我踩过的三个坑
第一个坑:以为 k 可以不清零。我一开始想,跳转之后前面匹配的信息是不是还能复用?答案是不能。因为跳转意味着换了一个全新的候选起点,之前匹配的信息只对旧起点对有效,对新起点对完全没用。强行复用会导致结果错误。
第二个坑:把i = i + k + 1写成i = i + 1。这样虽然不会错,但复杂度退化成 O(n²),失去了算法的意义。这个错误很隐蔽,因为小数据上结果是对的,只有大数据才会暴露性能问题。
第三个坑:忽略周期串的提前终止。对于aaaa...这种串,如果不判 k < n,k 会一直涨,虽然最终也能退出(因为 i 或 j 会越界),但会多跑很多无用循环。加上 k < n 的判断后,周期串能立刻返回。
注意:这三个坑我都实际踩过,而且都是在自以为写对了的情况下。建议你写完代码后,专门用周期串和全相同串测一遍,这两个是最容易暴露问题的输入。
5.4 性能调优的实战经验
如果你的场景里字符串特别长(比如 10⁶ 以上),可以考虑几个优化方向。一是把string换成char*加长度,避免边界检查开销;二是把取模运算换成条件减法;三是如果只需要比较两个串是否循环同构,不需要完整规范形,可以在算法中途提前返回。
但我要提醒一句:不要为了性能牺牲正确性。我见过有人为了省一次取模,把边界条件写错,结果在特定长度上出错。这种 bug 极难排查,因为只在少数输入上触发。宁可慢一点,也要保证逻辑清晰。
6. 从最小表示延伸出去的几个实用方向
最小表示法本身不复杂,但它背后的“利用已比较信息跳过无效候选”的思想,可以迁移到很多地方。比如判断两个串是否循环同构,只需要分别求最小表示再比较即可,比暴力枚举所有旋转高效得多。再比如处理环形缓冲区里的数据比对,可以先把两段数据都规范化成最小表示,再逐字节比较。
还有一个进阶方向是Lyndon 分解,它把字符串拆成一系列 Lyndon 串,和最小表示法共享很多思想。如果你对字符串算法感兴趣,学完最小表示法后接着看 Lyndon 分解,会有种豁然开朗的感觉。
我在实际项目里还用过它做环形日志的归一化:同一条逻辑日志可能因为写入位置不同而有不同的起始点,用最小表示法归一后,去重准确率从 70% 提升到接近 100%。这个收益是实打实的,也是我愿意花时间把这套东西整理出来的原因。
最后分享一个小技巧:如果你只是要判断两个串是否循环同构,有个更简单的办法——判断其中一个串是否是另一个串的两倍串的子串。用 KMP 做子串匹配,复杂度也是 O(n),代码比最小表示法还短。但如果要处理一批串的去重,还是最小表示法更合适,因为它能给出统一的规范形,方便放进集合里比较。工具选哪个,取决于你的具体需求,没有绝对的好坏。