最长公共前缀怎么求?四种解法一次讲清
求一组字符串的最长公共前缀,是 LeetCode 第 14 题。
题目很短:给你一个字符串数组,找出它们共有的最长前缀;没有就返回空字符串。
我第一次做的时候随手写了个横向扫描,过了,但总觉得不踏实。
后来翻官方题解才发现,这道题至少有四种思路,复杂度还各有讲究。
下面把它们一次讲清楚,代码用 Go,都能直接跑。
最长公共前缀是什么?
最长公共前缀指一组字符串从第一个字符开始,连续相同的最长那一段。
举个例子。
数组 ["flower", "flow", "flight"],公共前缀是 fl。
数组 ["dog", "cat", "fish"],第一个字符就对不上,结果是空串。
只要有任意一个字符串提前结束,或者某一位字符不一致,前缀就到此为止。
横向扫描:最直觉的写法
横向扫描的思路是:先拿第一个字符串当候选前缀,再依次和后面每个字符串求公共前缀。
公式上就是 LCP(S1…Sn) = LCP(LCP(LCP(S1, S2), S3), …Sn)。
每比较一次,候选前缀只会变短或不变。
func longestCommonPrefix(strs []string) string {
if len(strs) == 0 {
return ""
}
prefix := strs[0]
for i := 1; i < len(strs); i++ {
prefix = commonPrefix(prefix, strs[i])
if prefix == "" {
return "" // 提前剪枝,没必要再比了
}
}
return prefix
}
// 求两个字符串的公共前缀
func commonPrefix(s1, s2 string) string {
n := min(len(s1), len(s2))
i := 0
for i < n && s1[i] == s2[i] {
i++
}
return s1[:i]
}时间复杂度 O(mn),m 是字符串平均长度,n 是字符串个数。
空间复杂度 O(1)。
注意那个 prefix == "" 的提前返回,我一开始漏写,遇到第一位就不匹配的数据还在傻傻往后跑。
纵向扫描:按列比对
纵向扫描换了个角度:不再两两比,而是按字符位置一列一列地比。
先看所有字符串的第 0 位是否相同,再看第 1 位,直到某一位出现分歧或某个串走到头。
func longestCommonPrefix(strs []string) string {
if len(strs) == 0 {
return ""
}
for i := 0; i < len(strs[0]); i++ {
c := strs[0][i]
for j := 1; j < len(strs); j++ {
// 走到某个串末尾,或字符不一致
if i == len(strs[j]) || strs[j][i] != c {
return strs[0][:i]
}
}
}
return strs[0]
}复杂度和横向扫描一样,O(mn) 时间,O(1) 空间。
区别在于纵向扫描遇到分歧能立刻停,最坏情况下比较次数往往更少。
分治:把问题切两半
分治的核心想法是把数组从中间切开,分别求左右两半的公共前缀,再合并。
LCP(S1…Sn) = LCP(LCP(S1…Sk), LCP(Sk+1…Sn))。
func longestCommonPrefix(strs []string) string {
if len(strs) == 0 {
return ""
}
return lcp(strs, 0, len(strs)-1)
}
func lcp(strs []string, lo, hi int) string {
if lo == hi {
return strs[lo]
}
mid := (lo + hi) / 2
left := lcp(strs, lo, mid)
right := lcp(strs, mid+1, hi)
return commonPrefix(left, right)
}时间复杂度还是 O(mn),但因为递归用了栈,空间是 O(m log n)。
说白了,分治在这道题上并不省时间,更多是练手思路。
二分查找:在前缀长度上二分
这个解法稍微绕一点。
公共前缀的长度,一定不超过最短字符串的长度。
那就在 [0, 最短长度] 这个区间里二分:如果长度 mid 的前缀是公共的,就往更长找;否则往更短找。
func longestCommonPrefix(strs []string) string {
if len(strs) == 0 {
return ""
}
minLen := len(strs[0])
for _, s := range strs {
if len(s) < minLen {
minLen = len(s)
}
}
low, high := 0, minLen
for low < high {
mid := (low + high + 1) / 2
if isCommonPrefix(strs, mid) {
low = mid
} else {
high = mid - 1
}
}
return strs[0][:low]
}
func isCommonPrefix(strs []string, length int) bool {
prefix := strs[0][:length]
for i := 1; i < len(strs); i++ {
if !strings.HasPrefix(strs[i], prefix) {
return false
}
}
return true
}时间复杂度 O(mn log m),空间 O(1)。
理论上它比前面几种多了个 log m,实战里反而不一定快。
四种解法复杂度对比
| 解法 | 时间复杂度 | 空间复杂度 | 特点 |
|---|---|---|---|
| 横向扫描 | O(mn) | O(1) | 最直觉,可提前剪枝 |
| 纵向扫描 | O(mn) | O(1) | 遇分歧即停,常更快 |
| 分治 | O(mn) | O(m log n) | 练递归思路 |
| 二分查找 | O(mn log m) | O(1) | 思路新颖,实战未必快 |
n 是字符串个数,m 是字符串平均长度。
面试里写横向或纵向扫描就够了,干净利落。
分治和二分更多是展示你知道这些套路。
一个容易忽略的坑
上面所有代码都是按字节(byte)比较的。
LeetCode 第 14 题限定输入是小写英文字母,所以没问题。
但如果输入含中文或 emoji,按字节切 s[:i] 可能切碎一个多字节字符,得到乱码。
真要处理 Unicode,得先 []rune(s) 转成 rune 切片再比。
这点我也是被一道变体题坑过才记住的。
常见问题
数组里有空字符串会怎样?
只要任意一个字符串是空串,最长公共前缀必然是空串。
横向和纵向扫描天然能处理:第一轮比较就会返回空。
横向扫描和纵向扫描哪个更好?
两者复杂度相同,都是 O(mn)。
纵向扫描遇到不匹配能立刻停在当前列,平均比较次数通常更少,略占优。
二分查找复杂度更高,为什么还要学?
它提供了一种「在答案空间上二分」的思路。
这类思想在很多查找最优值的题里都用得上,本题只是个入门练习。
这道题需要排序吗?
不需要。
公共前缀和顺序无关,排序只会徒增 O(n log n) 的开销。
如果大家对某种解法还有疑问,或者踩过别的坑,欢迎在评论区交流~~~
参考:LeetCode 官方题解 最长公共前缀
版权声明
未经授权,禁止转载本文章。
如需转载请保留原文链接并注明出处。即视为默认获得授权。
未保留原文链接未注明出处或删除链接将视为侵权,必追究法律责任!
本文原文链接: https://fiveyoboy.com/articles/longest-common-prefix/
备用原文链接: https://blog.fiveyoboy.com/articles/longest-common-prefix/