高效去除字符串末尾重复单元的 KMP 前缀函数优化算法实现
一、背景与问题描述
-
需求:给定字符串
s
,若其末尾存在某段最小单元重复多次,则仅保留一个单元,其它折叠删除;否则保持原样。 -
示例:
"abcabcabc"
→"abc"
"hellohello"
→"hello"
"谢谢您谢谢您谢谢您"
→"谢谢您"
"no repeat here"
→"no repeat here"
要求支持 Unicode(按 rune 单位处理),并且只有当末尾某模式重复次数 ≥2 时才进行去重。
二、朴素枚举算法
1. 算法思路
-
将字符串转为
[]rune
,长度记为n
。 -
枚举可能的最小单元长度
l
从1
到n/2
; -
将末尾
l
个 rune 取为“模式”(pattern); -
从末尾开始,每次向前跳
l
,比较长度为l
的子串是否与模式相同,统计连续匹配次数count
; -
若
count ≥ 2
,说明末尾有 ≥2 次重复,将多余部分折叠,只保留一个模式:prefix := string(runes[:n-count*l]) return prefix + string(pattern)
-
若所有
l
都无法满足,返回原串。
2. 代码实现
// removeSuffixRepeats 去掉 s 中末尾连续重复的最小单元,只保留一个
func removeSuffixRepeats(s string) string {runes := []rune(s)n := len(runes)for l := 1; l <= n/2; l++ {pattern := runes[n-l:]count := 0for pos := n; pos >= l; pos -= l {match := truefor i := 0; i < l; i++ {if runes[pos-l+i] != pattern[i] {match = falsebreak}}if match {count++} else {break}}if count >= 2 {prefix := string(runes[:n-count*l])return prefix + string(pattern)}}return s
}
3. 复杂度分析
-
时间复杂度:外层
l
枚举约n/2
次,内层每次最坏比较O(l)
,并做n/l
次匹配,整体∑l=1n/2O((n/l)×l)=O(∑l=1n/2n)=O(n2).\sum_{l=1}^{n/2} O\bigl((n/l)\times l\bigr)= O\bigl(\sum_{l=1}^{n/2} n\bigr)= O(n^2). l=1∑n/2O((n/l)×l)=O(l=1∑n/2n)=O(n2).
-
空间复杂度:
O(n)
,用于存储[]rune
。
适用场景:短字符串或对性能要求不高时实现简单直观。
三、KMP 前缀函数优化
1. 核心原理
- 目标:在 O(n) 时间内找出末尾可折叠的最小单元并去重。
- 思路:将字符串反转,末尾重复后缀转换为开头重复前缀;对反转串计算 KMP 前缀函数(π 函数),快速得到每个前缀最长真前缀-后缀长度;据此判断最小周期并验证是否至少重复两次。
2. 算法步骤
-
反转:令
rev
为runes
的倒序。 -
计算前缀函数:
对rev
构造长度n
的数组pi
,其中π[i]=max{ k<i+1∣rev[0:k]=rev[i−k+1:i+1]}.\pi[i] = \max\{\,k < i+1 \mid rev[0:k] = rev[i-k+1:i+1]\}. π[i]=max{k<i+1∣rev[0:k]=rev[i−k+1:i+1]}.
时间 O(n)。
-
扫描周期:对于每个前缀长度
L = i+1
,最小周期p = L - π[i]
。若L % p == 0 && L/p ≥ 2
,说明该前缀由长度 p 的模式重复 ≥2 次。此模式即反转后缀,反转回去即可得到原串末尾的单元。 -
拼接结果:找到第一个(最长)可折叠后缀,保留原串前缀
runes[:n-L]
,再加一个模式。
3. 优化代码
package mainimport "fmt"// removeSuffixRepeatsOptimized O(n) 实现
func removeSuffixRepeatsOptimized(s string) string {runes := []rune(s)n := len(runes)if n < 2 {return s}// 1. 反转rev := make([]rune, n)for i, r := range runes {rev[n-1-i] = r}// 2. 计算前缀函数 pipi := make([]int, n)for i := 1; i < n; i++ {j := pi[i-1]for j > 0 && rev[i] != rev[j] {j = pi[j-1]}if rev[i] == rev[j] {j++}pi[i] = j}// 3. 扫描前缀找最小周期for i := n - 1; i > 0; i-- {L := i + 1p := L - pi[i]if pi[i] > 0 && L%p == 0 && L/p >= 2 {// 反转回最小单元pat := make([]rune, p)for k := 0; k < p; k++ {pat[p-1-k] = rev[k]}// 拼接结果return string(runes[:n-L]) + string(pat)}}return s
}func main() {examples := []string{"谢谢您,谢谢您,谢谢您","谢谢您谢谢您谢谢您","abcabcabc","hellohello","no repeat here",}for _, ex := range examples {fmt.Printf("%q -> %q\n", ex, removeSuffixRepeatsOptimized(ex))}
}
4. 复杂度分析
- 时间复杂度:反转 O(n) + 前缀函数 O(n) + 扫描 O(n) = O(n)。
- 空间复杂度:O(n),多了一个反转切片
rev
和pi
数组。
适用场景:长字符串、大批量数据或对性能敏感时推荐使用。
四、对比与实践建议
特性 | 朴素枚举算法 | KMP 优化算法 |
---|---|---|
时间复杂度 | O(n²) | O(n) |
代码复杂度 | 简单易懂 | 稍复杂 |
适用场景 | 短字符串或偶发调用 | 大规模文本处理 |
实现难度 | 低 | 中 |
- 短小字符串:可直接使用枚举版本,代码直观,维护成本低。
- 性能敏感:强烈推荐 KMP 版本,能够在线性时间内完成重复检测与去重。
五、总结与扩展
本文从最直观的枚举匹配算法切入,详细剖析了字符串末尾重复折叠的逻辑,并在此基础上借助 KMP 前缀函数,将时间复杂度从 O(n²) 优化到 O(n)。掌握这一思路后,还可进一步:
- 哈希滚动:结合双向哈希快速比较子串,减少常数;
- 后缀自动机:用于更通用的后缀匹配和重复检测;
- 并行化:针对超大文本,可将检测过程并行分片处理。
希望本文能帮助你在文本预处理、日志清洗、聊天记录去重等场景中,高效地解决末尾重复折叠问题。欢迎在评论中交流优化思路!