news 2026/10/24 16:33:52

LeetCode 14最长公共前缀:纵向扫描与边界条件实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
LeetCode 14最长公共前缀:纵向扫描与边界条件实战

1. 从一道"简单题"说起:最长公共前缀到底在考什么

LeetCode 第 14 题"最长公共前缀",在题库里被标记为简单难度,很多人扫一眼题目描述——给一个字符串数组,找出所有字符串共有的最长前缀——脑子里立刻蹦出"两两比较"或者"拿第一个当模板逐个比对"的思路,然后几分钟就提交通过了。但我在带新人刷题的过程中发现,这道题真正值得聊的地方,恰恰藏在"简单"这两个字背后:它是一道典型的字符串边界处理题,也是一道能暴露你代码习惯好不好的"照妖镜"。

为什么这么说?因为这道题的输入有太多坑:数组可能为空、数组里可能只有一个字符串、字符串可能是空串、所有字符串可能完全没有公共前缀、公共前缀可能恰好等于最短的那个字符串本身。这些边界情况,面试官不会在题目描述里逐条列给你,但测试用例一个都不会少。我见过太多人本地跑样例通过,一提交就挂在[]、[""]、["a"]这种输入上。

这篇文章我会把这道题从思路选型、代码实现、复杂度分析到边界处理、常见翻车点,完整地拆一遍。适合刚接触 LeetCode 的初学者建立字符串题的处理直觉,也适合已经刷了几十道题、想把代码写得更"工程化"的中级选手对照检查自己的习惯。核心关键词就三个:最长公共前缀、字符串数组、边界条件。我会用 C++ 来写,但思路对任何语言都通用。

先说结论:这道题的最优解不是"两两比较",而是纵向扫描或者分治,前者代码最短、最容易写对,后者在特定场景下更优雅。下面我一步步展开为什么。

2. 题目拆解与核心思路选型

2.1 题目到底在问什么

先把题目用大白话翻译一遍。给你一个字符串数组strs,比如["flower", "flow", "flight"],你要找出一个字符串,它是数组里每一个字符串的前缀,并且是所有满足条件的前缀里最长的那个。上面这个例子的答案就是"fl"。

注意两个关键词:每一个和最长。"每一个"意味着只要有一个字符串不满足,这个前缀就不合法;"最长"意味着我们要在所有合法前缀里取长度最大的。这两个约束一叠加,其实就限定了答案的范围:答案的长度不可能超过数组里最短字符串的长度,因为前缀必须对最短的那个字符串也成立。

这个观察很关键,它直接给出了一个上界:答案长度 ≤ min(所有字符串长度)。很多优化思路都是从这个上界出发的。

2.2 四种主流解法对比

这道题至少有四种能拿得出手的解法,我把它们的核心逻辑、代码量和适用场景整理成一张表,方便你一眼看清差异:

解法核心逻辑时间复杂度代码量推荐度
横向扫描(两两比较)拿前两个求公共前缀,结果再和第三个比,依次类推O(S),S 为所有字符总数中等一般
纵向扫描按列比较,第 i 列所有字符串都相同才继续O(S)最短强烈推荐
分治数组一分为二,分别求前缀再合并O(S)较长面试加分
二分查找对前缀长度二分,每次验证O(S·log m)较长了解即可

这里的 S 指的是所有字符串的字符总数,m 是最短字符串的长度。可以看到,横向扫描、纵向扫描、分治的复杂度都是 O(S),差别主要在常数和代码可读性上。

2.3 为什么我首推纵向扫描

纵向扫描的思路特别符合人的直觉:把所有字符串想象成排成一列一列的字符矩阵,我们从第 0 列开始,一列一列往右看。如果某一列上所有字符串的字符都相同,说明这一列可以纳入公共前缀;一旦发现某个字符串在这一列没有字符(越界了)或者字符和别人不一样,立刻停止,前面所有列拼起来就是答案。

它的优势有三个。第一,代码极短,一个双重循环就能搞定,不容易写错。第二,天然处理边界,因为循环条件里就包含了"越界检查",空数组、空串这些情况顺手就处理了。第三,提前终止,一旦发现不匹配马上返回,不需要把所有字符串都扫完,实际运行往往比理论复杂度更快。

横向扫描虽然也对,但它有个隐患:每次两两比较都会产生一个中间结果字符串,如果数组很长,这些中间字符串的创建和销毁是有开销的。而且横向扫描在遇到"第一个字符串特别长、后面都很短"的情况时,会做很多无用功。纵向扫描没有这个问题。

3. 纵向扫描的完整实现与逐行剖析

3.1 核心代码

先上代码,然后我逐行拆:

class Solution { public: string longestCommonPrefix(vector<string>& strs) { if (strs.empty()) return ""; int n = strs.size(); int firstLen = strs[0].size(); for (int col = 0; col < firstLen; ++col) { char c = strs[0][col]; for (int i = 1; i < n; ++i) { // 两种情况需要停止:strs[i] 越界,或字符不匹配 if (col >= strs[i].size() || strs[i][col] != c) { return strs[0].substr(0, col); } } } return strs[0]; } };

3.2 逐行拆解每个决策

第一行if (strs.empty()) return "";这是必须的防御性检查。题目约束里虽然说了数组长度至少为 1,但实际面试中面试官很可能故意给你一个空数组看你怎么反应。更重要的是,如果不检查,下面strs[0]就会越界访问,直接崩溃。养成"访问容器第一个元素前先判空"的习惯,能帮你躲掉一大类 bug。

int firstLen = strs[0].size();这里我选择以第一个字符串的长度作为外层循环的上界。为什么不用最短字符串的长度?因为找最短本身要遍历一遍数组,多一次 O(n) 的开销。而用第一个字符串的长度做上界是安全的:如果第一个字符串比答案长,内层循环会在越界检查时提前返回;如果它比答案短,那答案最多也就是它本身,循环自然结束。所以用第一个的长度既省事又正确。

外层循环for (int col = 0; col < firstLen; ++col)这里的col代表"列号",也就是当前正在比较的字符位置。注意我用的是col而不是i,这是有意的命名习惯——在二维逻辑里,用row/col比用i/j可读性高得多,尤其是当内外层循环都叫i、j的时候,很容易搞混谁是谁。

char c = strs[0][col];把第一个字符串在当前列的字符取出来当"基准"。所有其他字符串都要和这个基准比。这里有个小优化点:把strs[0][col]存到局部变量c,避免在内层循环里反复访问strs[0],虽然编译器大概率会帮你优化,但显式写出来更清晰。

内层循环for (int i = 1; i < n; ++i)从下标 1 开始,因为下标 0 是基准本身,不用和自己比。

关键判断if (col >= strs[i].size() || strs[i][col] != c)这是整段代码的灵魂。两个条件用||连接,顺序不能反。必须先判断越界,再判断字符是否相等。为什么?因为如果col >= strs[i].size()成立,说明第 i 个字符串已经比当前列短了,此时访问strs[i][col]就是越界访问,是未定义行为。C++ 的||有短路特性,左边为真时右边不会执行,所以把越界检查放左边是安全的。如果你手滑写反了,在某些编译器上可能侥幸不崩,但这是定时炸弹。

return strs[0].substr(0, col);一旦发现不匹配,立刻返回从 0 开始、长度为col的子串。注意substr(0, col)的第二个参数是长度不是结束下标,返回的是[0, col)这个区间,正好是前col个字符,也就是已经确认的公共前缀。

循环外的return strs[0];如果外层循环完整跑完都没触发返回,说明第一个字符串的每一个字符都是公共前缀的一部分,那答案就是第一个字符串本身。这个收尾很容易被漏掉,漏了就会编译报错"函数没有返回值"。

3.3 手动跑一遍样例

拿["flower", "flow", "flight"]走一遍:

  • col=0:基准f,flow[0]=f匹配,flight[0]=f匹配,继续。
  • col=1:基准l,flow[1]=l匹配,flight[1]=l匹配,继续。
  • col=2:基准o,flow[2]=o匹配,flight[2]=i不匹配,触发返回strs[0].substr(0, 2)="fl"。

结果正确。再拿["dog", "racecar", "car"]走一遍:col=0 时基准d,racecar[0]=r不匹配,直接返回substr(0,0)=""。也对。

4. 边界条件与常见翻车点实录

4.1 必须覆盖的边界用例

我把这道题容易翻车的输入整理成一张速查表,建议你写完代码后逐条自测:

输入期望输出考察点
[]""空数组防御
[""]""单元素空串
["a"]"a"单元素非空
["", "b"]""含空串的数组
["abc", "abc", "abc"]"abc"完全相同
["ab", "a"]"a"答案等于最短串
["a", "ab"]"a"第一个是最短的
["abc", "def"]""完全无公共前缀

其中["", "b"]和["a", "ab"]是最容易挂的两个。前者考察你对空串的处理,后者考察"第一个字符串最短"时循环能否正确结束。

4.2 三个高频翻车点

翻车点一:越界检查顺序写反。前面强调过,col >= strs[i].size()必须放在||左边。我见过有人写成strs[i][col] != c || col >= strs[i].size(),逻辑上等价但执行顺序错了,一旦越界就是未定义行为。这种 bug 在本地可能不崩,提交到线上环境换了编译器就炸,非常难查。

翻车点二:忘记处理空数组。直接strs[0]访问空 vector 是经典的段错误来源。哪怕题目保证非空,加一行判空也就一秒钟的事,收益远大于成本。

翻车点三:substr参数理解错。substr(pos, len)的第二个参数是长度,不是结束位置。有人想返回前 col 个字符,写成substr(0, col - 1)或者substr(0, col + 1),结果差一位。记住:要前 col 个字符,就是substr(0, col)。

提示:写完代码后,别急着提交,先把上面表格里的用例在脑子里或者纸上跑一遍。这道题总共就几行代码,手动验证的成本极低,但能帮你挡掉 90% 的低级错误。

4.3 一个容易被忽略的性能细节

纵向扫描在"提前终止"这件事上做得很好,但有一个场景它反而不如横向扫描:当数组里第一个字符串特别长,而公共前缀又特别短的时候。比如["a" * 10000, "b"],纵向扫描会在 col=0 就发现不匹配并返回,其实很快。真正慢的场景是公共前缀很长、但最后一个字符串在很靠后的位置才不匹配,这时候纵向扫描要扫很多列。不过这种情况横向扫描也一样慢,没有本质区别。

真正值得注意的性能点是:不要在循环里反复调用strs[i].size()。虽然std::string::size()是 O(1) 的,但把它提到循环外或者用引用缓存,在极端数据量下还是有意义的。我在上面的代码里没做这个优化,因为可读性优先,但如果你在打比赛追求极致,可以改成先算好每个字符串的长度存数组。

5. 进阶:分治解法与面试加分技巧

5.1 分治解法的完整实现

分治的思路是:把数组从中间劈成两半,分别求左半部分的最长公共前缀和右半部分的最长公共前缀,然后再求这两个前缀的公共前缀。递归下去,直到区间里只剩一个字符串,它的公共前缀就是它自己。

class Solution { public: string longestCommonPrefix(vector<string>& strs) { if (strs.empty()) return ""; return divide(strs, 0, strs.size() - 1); } private: string divide(vector<string>& strs, int left, int right) { if (left == right) return strs[left]; int mid = left + (right - left) / 2; string lcpLeft = divide(strs, left, mid); string lcpRight = divide(strs, mid + 1, right); return commonPrefix(lcpLeft, lcpRight); } string commonPrefix(const string& a, const string& b) { int len = min(a.size(), b.size()); int i = 0; while (i < len && a[i] == b[i]) ++i; return a.substr(0, i); } };

分治的复杂度同样是 O(S),但它的常数比纵向扫描大,因为多了递归调用和字符串拷贝的开销。那为什么还要学它?因为面试官喜欢看你会不会分治。分治是一种通用的算法思想,能体现你对递归、区间划分、结果合并的理解。如果面试时你先写出纵向扫描,然后主动说"这题也可以用分治做,思路是……",加分效果立竿见影。

5.2 二分查找解法的适用场景

二分查找的思路稍微绕一点:我们不逐个字符比较,而是二分"前缀的长度"。设最短字符串长度为 m,我们在[0, m]范围内二分长度 mid,然后验证所有字符串的前 mid 个字符是否都相同。如果相同,说明答案至少是 mid,往右半区继续找;如果不同,往左半区找。

class Solution { public: string longestCommonPrefix(vector<string>& strs) { if (strs.empty()) return ""; int minLen = INT_MAX; for (const auto& s : strs) minLen = min(minLen, (int)s.size()); int lo = 0, hi = minLen; while (lo < hi) { int mid = lo + (hi - lo + 1) / 2; if (isCommonPrefix(strs, mid)) lo = mid; else hi = mid - 1; } return strs[0].substr(0, lo); } private: bool isCommonPrefix(vector<string>& strs, int len) { string prefix = strs[0].substr(0, len); for (int i = 1; i < strs.size(); ++i) { if (strs[i].substr(0, len) != prefix) return false; } return true; } };

注意二分里的mid = lo + (hi - lo + 1) / 2,这个+1是为了避免死循环。当lo和hi相邻时,如果不加 1,mid会等于lo,而如果此时isCommonPrefix返回 true,lo = mid就原地踏步了。这是二分查找的经典陷阱,写的时候一定要想清楚"我要找的是最后一个满足条件的值"还是"第一个满足条件的值",然后决定 mid 的取整方向。

二分法的时间复杂度是 O(S·log m),比纵向扫描的 O(S) 差,所以实际并不推荐。但它的价值在于:当验证操作很昂贵时,二分能把线性验证变成对数次验证。这道题里验证很便宜,所以二分不划算;但如果换成"验证一个前缀是否是所有字符串的前缀"需要读磁盘或者走网络,二分就香了。理解这个 trade-off,比记住代码本身更重要。

5.3 面试时怎么讲这道题

如果面试官让你讲思路,我建议按这个顺序说:

  1. 先点明答案的上界是"最短字符串长度",这是所有解法的共同出发点。
  2. 给出纵向扫描的思路,强调它代码短、天然处理边界、能提前终止。
  3. 主动提一句分治,说明它复杂度相同但能体现分治思想。
  4. 如果面试官追问优化,再聊二分,并解释为什么这题里二分不划算。

这个顺序的好处是:先给最实用的方案,再展示知识广度,最后展示你对复杂度 trade-off 的理解。比一上来就炫技写分治要稳妥得多。

6. 从这道题延伸出的字符串处理习惯

6.1 三个可以带走的通用习惯

刷这道题最大的收获,其实不是记住某段代码,而是养成几个处理字符串数组的通用习惯。

习惯一:先想边界,再写逻辑。拿到任何字符串题,先在纸上列出可能的边界输入:空数组、空串、单元素、全相同、全不同、答案等于最短串。列完再动手,能省下大量调试时间。

习惯二:访问二维逻辑时用 row/col 命名。字符串数组本质是字符矩阵,用row/col比i/j可读性高一个档次。这个习惯在写矩阵题、动态规划题时同样适用。

习惯三:越界检查永远放前面。只要涉及下标访问,先问自己"这个下标会不会越界",把检查写在访问之前。C++ 的||短路特性可以帮你,但前提是你把检查放对了位置。

6.2 这道题还能怎么变

最长公共前缀这个模型,在实际工程里有很多变体。比如"找一组文件路径的公共目录前缀",本质就是这道题,只不过分隔符从字符变成了/。再比如"找一组 DNA 序列的公共片段",思路一样,只是匹配规则可能更复杂。还有"版本号比较"里的公共前缀提取,也是同一个套路。

我在实际项目里遇到过一次类似需求:要给一批日志文件按公共前缀分组,方便批量处理。当时用的就是纵向扫描的变体,先按最短文件名长度定上界,再逐列比较。代码不到二十行,但比正则表达式方案快了一个数量级。所以别小看这道简单题,它的模型迁移能力很强。

注意:迁移到实际场景时,要特别注意"前缀"的定义。文件路径的前缀通常以分隔符为界,不能简单按字符比较,否则/abc和/abcd会被误判为有公共前缀/abc,但实际上它们是两个不同的目录。这种语义层面的坑,比代码层面的坑更隐蔽。

6.3 关于刷题节奏的一点个人体会

最后聊点题外话。像第 14 题这种"简单题",很多人刷完就过,觉得没什么可总结的。但我的经验是,简单题才是打磨代码习惯的最佳素材。难题你光是想出思路就用光了精力,没空去抠命名、边界、可读性;简单题思路秒出,正好可以把注意力放在"怎么写得漂亮"上。

我现在回头看自己早期刷的题,那些当时觉得"太简单不值得记"的题,恰恰是后来面试里被追问细节最多的。面试官不会问你红黑树怎么旋转,但很可能会盯着你写的substr(0, col)问一句"这里为什么是 col 不是 col+1"。所以,把每一道简单题都当成一次代码审阅的机会,长期下来收益远超你的想象。

这道题我前后写过不下十遍,每次重写都会微调一点:有时候把越界检查抽成单独的函数,有时候试试用迭代器代替下标。不是为了炫技,而是为了保持手感。字符串处理是工程里最高频的操作之一,把这些基本功练到肌肉记忆,比多刷十道难题实在得多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/22 8:35:33

jeecg-boot前端构建踩坑指南:npm run build常见问题与部署方案

用 jeecg-boot 做交付开发的朋友应该都有同感&#xff1a;后端启动很简单&#xff0c;真正让人血压上升的往往是前端npm run build。我前后用 jeecg 做过好几个管理系统&#xff0c;Windows 上构建、Linux 服务器上构建、CI 里构建&#xff0c;环境换一次&#xff0c;坑就换一批…

作者头像 李华
网站建设 2026/10/20 19:34:58

WebGL着色器编程:从基础到高级实战

1. WebGL着色器基础概念解析 WebGL作为现代网页3D图形的核心技术&#xff0c;其核心工作原理与传统Canvas 2D绘图有着本质区别。理解着色器&#xff08;Shader&#xff09;是掌握WebGL编程的关键突破口。与固定渲染管线不同&#xff0c;WebGL采用可编程渲染管线&#xff0c;这意…

作者头像 李华
网站建设 2026/10/22 10:32:07

私有文档检索增强方案:向量数据库选型与检索调优实战

1. 从"文档堆成山"到"问答秒回"&#xff1a;这套私有检索增强方案到底在解决什么手里攒了几百上千份内部文档&#xff0c;PDF、Word、Markdown、会议纪要混在一起&#xff0c;想找一句话得靠系统自带的搜索框一个个翻——这个场景我相信很多人都经历过。更…

作者头像 李华
网站建设 2026/10/22 12:41:06

SpringBoot民族乐器电商平台架构设计与实践

1. 项目背景与核心价值民族乐器交易平台这个细分领域在近几年呈现出明显的增长趋势。根据中国乐器协会发布的数据&#xff0c;传统民族乐器线上交易额年均增长率达到23%&#xff0c;远超乐器行业整体水平。这个Java EE项目正是瞄准了这个垂直市场的技术需求痛点。我去年参与过一…

作者头像 李华
网站建设 2026/10/21 8:42:00

经纬度与大地坐标互转:高斯投影正反算、基准转换与避坑指南

简介&#xff1a;针对经纬度与大地坐标互相转换的实际需求&#xff0c;这份VC工程源码为GIS、测绘及导航方向开发者提供了可直接运行的示例程序。资源内含完整对话框应用&#xff0c;涵盖经纬度坐标系与大地投影坐标系的转换算法实现&#xff0c;包括椭球参数设置、投影正反算等…

作者头像 李华
网站建设 2026/10/20 6:50:31

语音AI第一公里:语音预处理与特征提取实战指南

做语音相关的项目&#xff0c;不管是语音识别、语音合成还是声纹识别&#xff0c;圈子里一直流传一句话叫“Garbage in, garbage out”。模型结构再先进&#xff0c;训练技巧再花哨&#xff0c;如果喂进去的语音预处理特征本身有问题&#xff0c;那后面的所有工作都是在垃圾堆上…

作者头像 李华