最长公共前缀算法精解:横向与纵向扫描的实战剖析

发布时间:2026/8/15 22:02:00
最长公共前缀算法精解:横向与纵向扫描的实战剖析 1. 项目概述从“最长公共前缀”看字符串处理的基石在算法面试和日常编程中字符串处理是绕不开的经典话题。今天要聊的这道题——LeetCode第14题“最长公共前缀”可以说是字符串处理领域的“敲门砖”。乍一看题目很简单给你一个字符串数组找出所有字符串共有的、最长的前缀。比如[flower,flow,flight]的公共前缀就是fl。但正是这种看似简单的题目最能考验一个程序员对基础数据结构的理解、对边界条件的把控以及优化算法的思维。我见过不少朋友在面试时栽在这道“简单题”上不是思路不对而是细节没处理好导致代码冗长或者效率不佳。这道题的价值在于它完美串联了数组遍历、字符串比较、循环控制以及时间复杂度分析等多个基础知识点。无论是刚接触LeetCode的新手还是想巩固基础的老手深入剖析这道题都能带来新的收获。网络上常见的解法往往只给代码缺少对“为什么这么写”以及“还能怎么写”的深度拆解。接下来我会结合自己刷题和面试官的经验用两种最核心的解法配上详细的图解和步骤拆解带你彻底吃透这个问题。我们不仅追求AC通过更追求写出清晰、高效、健壮的代码。2. 核心思路拆解横向扫描与纵向扫描的哲学面对“最长公共前缀”问题我们的目标是在字符串集合中寻找一个最大的交集。这个交集必须从每个字符串的头部开始并且连续。理解这一点后最直接的思路通常有两种要么一个个字符串比过去像“接力赛”一样不断缩小公共前缀横向扫描要么像“同时检阅多列队伍”一样从第一个字符开始一列一列地比较所有字符串纵向扫描。这两种思路构成了本题最经典、最高效的解法。2.1 思路一横向扫描法——迭代缩小公共范围横向扫描的思路非常符合人类的直觉先假设第一个字符串就是最终的公共前缀然后让它去和第二个字符串比较找出它们俩的公共前缀用这个结果再去和第三个字符串比较如此迭代直到遍历完所有字符串或公共前缀被缩减为空。为什么选择这种思路它的优势在于逻辑清晰易于理解和实现。我们不需要同时处理所有字符串的同一位置只需要关注当前累积的“公共前缀候选”和下一个字符串的关系。这个过程就像一个过滤器不断筛掉不匹配的部分。核心步骤与状态变化假设数组为strs [flower,flow,flight]。初始化前缀prefix strs[0]即flower。将prefix与strs[1](flow) 比较找出两者共同的前缀。比较发现“flower”和“flow”的共同前缀是“flow”。更新prefix flow。将新的prefix(flow) 与strs[2](flight) 比较找出“flow”和“flight”的共同前缀。比较发现是“fl”。更新prefix fl。遍历结束最终prefix fl即为答案。这个过程中公共前缀的范围从flower-flow-fl逐步缩小。如果中途prefix被缩减为空字符串就可以立即返回因为不可能再有更长的公共前缀了。2.2 思路二纵向扫描法——按列同步比较纵向扫描采取了另一种视角既然公共前缀要从头开始连续那么我们可以从第一个字符开始依次检查所有字符串的第一位、第二位、第三位……是否相同。为什么选择这种思路这种方法的空间复杂度直觉上可能更低在某些实现中并且当字符串很长但公共前缀很短时可能提前结束比较避免不必要的遍历。它更贴近“同时比较”的原始问题定义。核心步骤与状态变化同样以strs [flower,flow,flight]为例。以第一个字符串flower为基准取其长度6作为最大可能列数。比较第0列第一个字符检查strs[0][0](‘f’)strs[1][0](‘f’)strs[2][0](‘f’)。全部相同继续。比较第1列第二个字符检查strs[0][1](‘l’)strs[1][1](‘l’)strs[2][1](‘l’)。全部相同继续。比较第2列第三个字符检查strs[0][2](‘o’)strs[1][2](‘o’)strs[2][2](‘i’)。发现strs[2][2]是 ‘i’ 与 ‘o’ 不同。比较在此中断。公共前缀即为前2列字符组成的子串fl。纵向扫描像是一把垂直的尺子从左向右移动一旦在某一个刻度上发现高度字符不一致就停止测量。注意两种方法在最坏情况下的时间复杂度都是 O(S)其中 S 是所有字符串中字符的总数。但在实际表现上如果公共前缀非常短纵向扫描可能更快退出如果数组第一个字符串非常短横向扫描可能更快。通常两者差异不大选择自己更容易理解和编码清晰的即可。3. 解法一详解横向扫描编码实现与图解理解了横向扫描的思想后我们来看如何将它转化为健壮的代码。这里的关键在于实现一个辅助函数用于比较两个字符串并返回它们的公共前缀。3.1 代码实现与逐行解析我们先给出Python版本的完整代码然后逐一拆解。class Solution: def longestCommonPrefix(self, strs: List[str]) - str: # 边界情况处理如果数组为空则没有公共前缀 if not strs: return # 初始化前缀为第一个字符串 prefix strs[0] # 遍历数组中的其他字符串 for i in range(1, len(strs)): # 关键调用辅助函数获取当前prefix与当前字符串的公共前缀 prefix self._get_common_prefix(prefix, strs[i]) # 如果公共前缀在比较中变为空串可以立即返回无需继续比较 if not prefix: break return prefix def _get_common_prefix(self, str1: str, str2: str) - str: 辅助函数返回两个字符串的公共前缀。 思路同时遍历两个字符串直到遇到第一个不同的字符或任一字符串结束。 # 计算最小长度避免索引越界 min_length min(len(str1), len(str2)) index 0 # 逐个字符比较 while index min_length and str1[index] str2[index]: index 1 # 返回从0到index-1的子串即相同的部分 return str1[:index]逐行解析与设计理由边界检查 (if not strs:): 这是编写健壮代码的第一步。如果输入是空数组[]后续操作会出错。直接返回空字符串符合逻辑定义。初始化前缀 (prefix strs[0]): 以第一个字符串作为比较的起点。这里隐含了一个假设公共前缀不可能比第一个字符串更长。如果数组只有一个字符串那么它本身就是自己的公共前缀循环不会执行直接返回。主循环 (for i in range(1, len(strs)):): 从第二个字符串开始遍历。每次循环的目标是用当前prefix和strs[i]碰撞产生一个更短或不变的新prefix。调用辅助函数 (self._get_common_prefix): 这是核心逻辑的封装。将两个字符串的比较独立出来使主函数逻辑更清晰。提前终止 (if not prefix: break): 这是一个重要的优化。一旦公共前缀变为空字符串说明已经不可能有公共前缀了继续遍历后面的字符串没有意义直接跳出循环。辅助函数中的min_length: 比较时公共前缀的长度不可能超过两个字符串中较短的那个。先计算出这个长度作为循环的上限既安全又高效。辅助函数中的while循环: 条件index min_length and str1[index] str2[index]确保了在安全范围内不越界且字符相等时才增加index。一旦条件不满足循环停止。切片返回 (return str1[:index]):index停在了第一个不相等字符的位置或较短字符串的末尾。因此从开头到这个位置之前的子串就是公共前缀。Python的切片操作非常高效。3.2 图解算法执行过程让我们用strs [interspecies, interstellar, interstate]这个例子来可视化横向扫描的过程。初始状态prefix interspecies 待比较列表: [interstellar, interstate]第一轮比较prefixvsinterstellar调用_get_common_prefix(interspecies, interstellar)。两字符串逐字符比较ii- 继续nn- 继续tt- 继续ee- 继续rr- 继续ss- 继续p!t- 停止此时index 6返回interspecies[:6]即inters。更新prefix inters。第二轮比较prefixvsinterstate调用_get_common_prefix(inters, interstate)。两字符串逐字符比较ii- 继续nn- 继续tt- 继续ee- 继续rr- 继续ss- 继续inters已到末尾循环停止。此时index 6返回inters[:6]即inters。更新prefix inters。最终结果inters。通过图解可以清晰看到prefix像雪球一样滚动每经过一个字符串就被“削去”不匹配的部分变得越来越精炼。3.3 横向扫描的变体与优化除了上述标准实现横向扫描还有一些常见的变体写法理解它们有助于加深对问题的认识。变体1使用find方法Python的字符串find方法可以查找子串如果找不到则返回-1。我们可以利用它来不断调整前缀。def longestCommonPrefix(self, strs): if not strs: return prefix strs[0] for s in strs[1:]: # 当s不以prefix开头时循环缩短prefix while s.find(prefix) ! 0: # find返回0表示prefix在s的起始位置 prefix prefix[:-1] # 去掉最后一个字符 if not prefix: # 如果prefix被删空了 return return prefix优劣分析代码非常简洁。但find方法内部也是进行字符串比较且每次while循环都可能调用一次find在极端情况下如第一个字符串很长且与后续字符串毫无共同前缀时间复杂度可能退化到 O(n*m)其中n是字符串个数m是第一个字符串长度。不如双指针逐字符比较稳定。变体2递归分治法将问题分解数组的公共前缀 左半部分数组的公共前缀 与 右半部分数组的公共前缀 的公共前缀。def longestCommonPrefix(self, strs): def common_prefix(left, right): # 类似_get_common_prefix函数 min_len min(len(left), len(right)) for i in range(min_len): if left[i] ! right[i]: return left[:i] return left[:min_len] def divide_and_conquer(strs, l, r): if l r: # 只有一个字符串 return strs[l] else: mid (l r) // 2 lcp_left divide_and_conquer(strs, l, mid) lcp_right divide_and_conquer(strs, mid1, r) return common_prefix(lcp_left, lcp_right) if not strs: return return divide_and_conquer(strs, 0, len(strs)-1)优劣分析这是一个非常漂亮的递归解法体现了分治思想。时间复杂度也是 O(S)。但在实际运行中由于递归调用栈的开销对于本题通常不如迭代法高效。不过这是一种重要的思维训练在解决更复杂的问题时很有用。实操心得在面试或竞赛中我推荐使用标准的双指针横向扫描法。它效率稳定代码清晰几乎不会出错。find变体虽然简洁但可能引发关于时间复杂度的追问。分治法可以作为展示你算法深度的加分项但不要作为首选。4. 解法二详解纵向扫描编码实现与图解纵向扫描从另一个维度切入问题代码结构同样清晰。它的核心是同时遍历所有字符串的同一列索引位置。4.1 代码实现与逐行解析class Solution: def longestCommonPrefix(self, strs: List[str]) - str: # 边界情况处理 if not strs: return # 以第一个字符串为基准遍历它的每一个字符列 for i in range(len(strs[0])): # 获取当前要比较的基准字符 char_to_compare strs[0][i] # 遍历数组中其他所有字符串 for j in range(1, len(strs)): # 关键条件判断 # 1. 当前字符串strs[j]的长度是否已经 i如果是说明这个字符串比基准字符串短已经到头了。 # 2. 当前字符串strs[j]在第i位的字符是否不等于基准字符 # 只要满足以上任一条件说明公共前缀到此为止。 if i len(strs[j]) or strs[j][i] ! char_to_compare: return strs[0][:i] # 返回从0到i-1的子串 # 如果循环完整执行完毕说明第一个字符串本身就是整个数组的公共前缀 return strs[0]逐行解析与设计理由外层循环 (for i in range(len(strs[0])):): 这个循环控制着我们比较的“列数”。我们以第一个字符串strs[0]的长度为最大可能列数进行遍历。i代表当前正在比较的字符索引。获取基准字符 (char_to_compare strs[0][i]): 在每一列我们都以第一个字符串在该位置的字符作为比较的基准。内层循环 (for j in range(1, len(strs)):): 对于每一列我们需要检查数组中其他所有字符串从strs[1]开始在同一位置i的字符是否与基准字符一致。核心条件判断 (if i len(strs[j]) or strs[j][i] ! char_to_compare:):i len(strs[j]): 这是长度边界检查至关重要如果当前遍历的字符串strs[j]的长度小于等于i意味着这个字符串已经结束了没有第i个字符。既然公共前缀要求所有字符串都有的连续字符那么有一个字符串已经到头了公共前缀自然也就到此为止。如果不做这个检查尝试访问strs[j][i]会导致索引越界错误。strs[j][i] ! char_to_compare: 这是字符相等性检查。如果当前字符串在第i位的字符与基准字符不同公共前缀也在i处终止。这两个条件用or连接只要有一个为真就立即返回结果。返回结果 (return strs[0][:i]): 当发现不匹配时i指向了第一个不匹配的列或某个字符串的末尾。因此公共前缀是strs[0]从开头到i-1的子串。注意切片[:i]是取前i个字符索引0到i-1。循环完整结束后的返回 (return strs[0]): 如果外层for循环顺利执行完毕没有在中间return说明第一个字符串的每一个字符都成功通过了所有其他字符串的检验。这意味着第一个字符串本身就是整个数组的公共前缀。4.2 图解算法执行过程我们使用一个包含空字符串的案例来演示这能更好地展示边界条件处理strs [ab, a, ]。注意第三个字符串是空串。初始状态基准字符串: ab (长度2) 比较列索引 i 0 开始第一轮比较 (i0):基准字符char_to_compare strs[0][0] a。内层循环j1比较strs[1][0]:strs[1] a,len(strs[1])1。i0小于长度1继续。strs[1][0] a等于a通过。内层循环j2比较strs[2][0]:strs[2] ,len(strs[2])0。判断条件i len(strs[2])即0 0为True。条件触发立即执行return strs[0][:0]即返回空字符串。最终结果。这个例子清晰地展示了为什么必须要有i len(strs[j])这个判断。如果没有它代码在尝试执行strs[2][0]时会直接崩溃索引越界。有了这个判断我们就能安全、正确地处理字符串长度不一甚至存在空串的情况。4.3 纵向扫描的边界与细节处理纵向扫描法有几个细节需要特别注意这些地方是代码正确性的保障也是面试官喜欢考察的点。1. 空输入数组和单元素数组if not strs: return 处理了输入为[]的情况。如果输入是[abc]外层循环会遍历abc的每个字符。内层循环for j in range(1, 1)不会执行因为range(1,1)是空的。因此循环直接结束执行最后的return strs[0]正确返回abc。2. 第一个字符串是空串如果strs [, abc, ab]那么len(strs[0]) 0。外层循环for i in range(0)根本不会进入直接跳过执行最后的return strs[0]即返回空串。这是正确的因为空串与其他任何字符串的公共前缀只能是空串。3. 使用zip和set的优雅写法Python特有Python的zip(*strs)函数可以将多个列表或字符串的对应元素打包成元组。利用这个特性可以写出非常简洁的纵向扫描代码。def longestCommonPrefix(self, strs): if not strs: return # zip(*strs) 会产生类似 [(f,f,f), (l,l,l), (o,o,i), ...] 的迭代器 for i, column in enumerate(zip(*strs)): # 使用set去重如果set的长度大于1说明这一列字符不完全相同 if len(set(column)) 1: return strs[0][:i] # 如果所有列都相同则最短的字符串就是公共前缀 return min(strs, keylen)优劣分析这段代码极其简洁利用了Python的高级特性。zip(*strs)会自动以最短的字符串为准进行打包完美处理了长度不一致的问题。set(column)用来快速判断一列字符是否全部相同。最后返回最短的字符串。这种写法的可读性对于熟悉Python的人来说很高但可能掩盖了算法的一些底层细节如边界处理在向不熟悉Python的面试官解释时需要多费口舌。注意事项在手动实现纵向扫描时务必把i len(strs[j])的判断放在strs[j][i] ! char之前。因为如果i已经等于字符串长度意味着索引i是无效的再尝试访问strs[j][i]就会出错。利用逻辑运算符or的短路特性如果第一个条件为真就不再判断第二个条件我们可以安全地写出这个判断。5. 复杂度分析与方法对比在掌握了两种解法的实现后我们需要从理论层面分析它们的效率并指导在何种场景下如何选择。5.1 时间复杂度分析两种方法的时间复杂度在最坏情况下都是O(S)其中S是输入数组中所有字符串的字符总数。横向扫描主循环遍历 n-1 个字符串。每次比较两个字符串最坏情况下需要比较min(len(prefix), len(current_string))个字符。在最坏情况下所有字符串都相同且很长第一次比较 m 个字符第二次比较 m 个字符……第 n-1 次比较 m 个字符总比较次数约为m m ... m (n-1)*m。由于S n * m所以时间复杂度为 O(S)。纵向扫描外层循环最多执行 m 次第一个字符串的长度。内层循环每次执行 n-1 次。在最坏情况下所有字符串都相同且很长总操作次数为m * (n-1)同样也是 O(S)。结论从渐进时间复杂度大O表示法上看两种方法没有区别。5.2 空间复杂度分析两种方法的额外空间复杂度都是O(1)如果不考虑存储答案所需的空间因为答案字符串是必须返回的通常不计入额外空间复杂度。横向扫描只使用了常数个变量prefix,i, 辅助函数中的index,min_length。纵向扫描只使用了常数个变量i,j,char_to_compare。结论空间效率上两者打平。5.3 实际性能与场景考量虽然理论复杂度相同但在实际运行中性能会受到数据特点的微妙影响。特性横向扫描法纵向扫描法代码逻辑直观像“接力赛”直观像“检阅列队”提前终止当某次比较后prefix为空时可立即终止。当在某一列发现不匹配或遇到最短字符串结尾时可立即终止。最坏情况所有字符串完全相同且很长。需要完整进行n-1次两两比较。所有字符串完全相同且很长。需要完整比较所有字符。最佳情况第一个字符串与第二个字符串就完全不同。只需一次比较。所有字符串的第一个字符就不同。只需比较第一列。对空串/短串敏感度不敏感。以第一个字符串为起点后续比较会自动处理长度。敏感。需要显式检查i len(strs[j])来处理短字符串。适用场景当预计公共前缀较长或者字符串长度差异较大时表现稳定。当预计公共前缀很短或者字符串数量很多但长度相近时可能提前结束。选择建议追求代码稳定与清晰选择横向扫描。它的逻辑流非常直接边界情况处理简单主要依赖辅助函数不容易出错。这是我个人在面试和实际编码中最常使用的方法。处理超大规模数据且预计前缀极短可以考虑纵向扫描。如果你知道数据中字符串几乎不可能有共同前缀纵向扫描可能在第一列就返回而横向扫描至少需要完成一次完整的字符串比较prefixvsstrs[1]。展示语言特性在Python中使用zip和set的纵向扫描写法非常优雅可以展示你对语言特性的掌握但务必能解释清楚其原理和边界处理逻辑。实操心得在绝大多数LeetCode场景和面试中两种方法都是完全可以接受的。面试官更关注的是你对算法的理解、代码的健壮性边界处理和清晰的沟通。我通常会先说出两种思路然后选择一种进行实现并主动分析其时间/空间复杂度。如果时间允许可以再提一下另一种思路作为对比这能展现你思维的全面性。6. 常见错误与排查技巧实录即便思路清晰在实现“最长公共前缀”时依然有几个高频“坑点”。下面是我在自己刷题和看别人代码时总结的常见错误及解决方法。6.1 错误一索引越界IndexError这是纵向扫描法中最容易犯的错误尤其是在手动比较字符时忘记检查字符串长度。错误代码示例# 错误的纵向扫描 def longestCommonPrefix(strs): if not strs: return for i in range(len(strs[0])): c strs[0][i] for s in strs[1:]: # 如果s比strs[0]短s[i]就会导致IndexError! if s[i] ! c: return strs[0][:i] return strs[0]问题当s的长度小于等于i时s[i]是无效访问。修正必须在比较字符之前先判断索引i是否已经超出了当前字符串s的范围。即使用if i len(s) or s[i] ! c:。6.2 错误二错误理解公共前缀的定义公共前缀必须是所有字符串都有的、从开头连续的字符子串。错误案例输入[car, race, arc]错误理解认为公共前缀是r或c因为它们都出现了。正确答案。因为第一个字符串以c开头第二个以r开头第三个以a开头开头字符都不同所以没有公共前缀。排查技巧牢牢记住“从开头连续”这个条件。你的算法必须从索引0开始比较一旦中断后面的部分即使相同也不再考虑。6.3 错误三对输入的特殊情况处理不足只考虑了“正常”输入没有考虑边界情况导致程序崩溃或返回错误结果。需要处理的特殊情况输入预期输出常见错误处理[](空数组)未做判断访问strs[0]导致崩溃。[](仅一个空串)可能进入循环导致错误或正确处理。[, abc]在纵向扫描中如果以第一个字符串为基准循环不会进入应返回。[a](仅一个字符串)a应直接返回该字符串本身。[abc, ab, a](长度递减)a算法需要能正确处理长度不同的字符串。健壮性检查清单函数开头检查if not strs:返回。在横向扫描中如果使用find变体注意while循环的终止条件防止死循环。在纵向扫描中内层循环务必先判断索引是否有效 (i len(s))再访问字符。思考如果输入数组非常大例如10^4个字符串你的算法是否会超时或超内存O(S)的复杂度通常是可接受的。6.4 错误四使用内置函数的陷阱以Python的os.path.commonprefix函数为例。这个函数确实是用来找公共前缀的但它不是为这个问题设计的import os strs [flower, flow, flight] print(os.path.commonprefix(strs)) # 输出fl (这次对了) strs2 [interspecies, interstellar, interstate] print(os.path.commonprefix(strs2)) # 输出inters (这次也对了) strs3 [dog, racecar, car] print(os.path.commonprefix(strs3)) # 输出 (这次也对了)虽然在一些情况下它能得到正确结果但强烈不建议在面试或解题中使用。原因有二1. 这显得你只是在调用API没有展示算法能力2. 这个函数是用于文件路径前缀的其行为在极端情况下可能不符合本题定义尽管本题的测试用例可能碰巧通过。面试官想考察的是你自己的实现逻辑。6.5 调试与测试技巧自己编写测试用例是验证代码正确性的最好方法。一个简单的测试框架思路def test(): solution Solution() test_cases [ ([flower,flow,flight], fl), ([dog,racecar,car], ), ([], ), ([], ), ([a], a), ([ab, a], a), ([abc, abcde, abcdef], abc), ([, abc, ab], ), ([same, same, same], same), ] for i, (input_strs, expected) in enumerate(test_cases): result solution.longestCommonPrefix(input_strs) if result expected: print(fTest case {i1} PASSED: {input_strs} - {result}) else: print(fTest case {i1} FAILED: {input_strs} - expected {expected}, got {result}) if __name__ __main__: test()运行这个测试可以快速验证你的代码是否覆盖了各种边界情况。养成自己写测试的习惯能极大提高一次写出正确代码的概率。最后关于这道题我个人最深的体会是“简单题”不简单。它像一面镜子能照出一个程序员对细节的掌控力。无论是横向扫描中辅助函数的设计还是纵向扫描中那个先判断长度的or条件都体现了严谨的思维。在平时练习时不要满足于通过Accept要多问自己几个“为什么”为什么这个循环要这么写为什么这个判断要放在前面还有没有更优或更清晰的写法把这些想明白了你的基础才算真正扎实。这道题掌握好了再面对更复杂的字符串问题比如KMP、字典树Trie等你也会更有底气。