ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

LeetCode 165 比较版本号:字符串处理与边界条件的经典面试题

LeetCode 165 比较版本号:字符串处理与边界条件的经典面试题

这道题在不少人的刷题名单里排得很靠前,属于那种“看起来5分钟能AC,结果修边界条件修了半小时”的类型。LeetCode第165题,比较版本号,字符串题里的经典常青树。我拿它做知识点总结再合适不过,因为它的考察点非常集中,却又能把字符串处理、边界思维、防御式编程全部串起来,面试里出现的频率比很多人想象中高得多。

先说清楚这道题解决什么问题:给你两个版本号字符串,比如"1.2"和"1.10",要比较它们的大小。版本号里用点号分隔每一段修订号,修订号可能有前导零,甚至可能出现"1.0.0"和"1"这种长度不一致的情况。你需要返回1、-1或0,表示前者大、后者大、还是相等。适合谁看?准备技术面试的、想巩固字符串处理功底的、还有那些“明明写出来了却总是TLE或越界”的刷题人。这篇总结不会只贴代码,我会把这道题背后的考察逻辑、边界坑、语言差异和业务延展一起聊透。

1. 题目到底在考什么:拆解核心知识图谱

很多人在拿到这道题的第一反应是“这不就split然后比较呗”,这恰恰是这道题设置的最大陷阱。如果面试官真想考split,没必要把版本号规则设计得这么啰嗦。我们得从题目本身逆向推导出它真正想考察的能力。

1.1 抛开版本号外壳,本质是一场字符串解析

版本号只是壳,本质是一串用固定分隔符隔开的数字块。你需要做的是:按分隔符切分、提取每一块的有效值、处理缺失块、然后逐位比较。这个过程涉及的知识点其实很有限但很核心。

第一是字符串分割。不同语言的分割API差异极大,比如Java的split("\\.")需要转义,Python的split(".")直接传字符串,C++甚至没有现成的split函数,得自己手写。这个细节本身就够面试官展开聊好几层。

第二是字符串到整数的转换。这里不只是Integer.parseInt或int()那么简单,你得意识到"01"和"1"是同一个数字。用语言内置的转换函数可以自动消解前导零,但如果你选择手写逐字符累加,就得明白从高位到低位乘10累加的原理。

第三是长度不对齐的处理。"1.0"和"1.0.0"怎么比?长度不同意味着后面缺失的修订号应该被视为0。这个逻辑不复杂,但必须在循环条件里处理好,否则数组越界或者漏比。

第四是逐段比较而非整体比较。有人会试图把版本号转成浮点数比大小,比如parseFloat("1.10")得到1.1。这是经典错误,因为版本号不是小数,"1.2"大于"1.10"还是小于,取决于规则而不是浮点数值。逐段比较才是唯一正确的思路。

1.2 这类题目在面试里的真实定位

我帮别人模拟面试时经常发现一个现象:难度低的题反而更能看出候选人代码习惯的好坏。第165题就是这样一面照妖镜。

数据结构层面它几乎什么都不考,没有链表、没有树、没有动态规划。但它在面试流程里的定位非常明确:考察代码风格、边界敏感度和沟通能力。候选人能不能在动笔前把"前导零、空段、长度不一致"这几个corner case主动说出来,直接反映平时写代码的积累。

打个比方,这题就像驾照考试里的侧方停车。科目二不考你跑多快,就考你能不能在不压线的情况下把车停进去。第165题对算法能力的要求也很低,但它要求你在毫秒级思考时间内识别出所有边界条件,并且用最稳妥的方式实现。面试官看的是你处理无聊但必要的细节时有没有耐心。

另外一个容易被忽略的点是:这道题在不少真实业务场景中都有高度类似的代码需求,例如App的版本强更判断、后端API的版本兼容策略等,所以它属于"面试题与工作内容挂钩"的典型代表,比那些纯粹烧脑的算法题更贴近生产实际。

2. 从零到AC:完整解题思路与代码实现

下面给出我实际在编辑器里敲过、跑通过的两种解法。第一种适合快速解题,代码最短;第二种偏向生产级健壮性,能体现对底层逻辑的理解。两种都值得收藏。

2.1 思路一:语言内置split函数的快速打法

这是最直观的方案:按点号切分成字符串数组,再逐段转成整数比较。因为越界的段统一补0,所以先求出两个数组的最大长度,循环里缺的段按0处理。

Python实现如下:

def compareVersion(version1: str, version2: str) -> int: v1 = [int(x) for x in version1.split(".")] v2 = [int(x) for x in version2.split(".")] max_len = max(len(v1), len(v2)) for i in range(max_len): num1 = v1[i] if i < len(v1) else 0 num2 = v2[i] if i < len(v2) else 0 if num1 > num2: return 1 elif num1 < num2: return -1 return 0

这个写法有几个细节值得讲。int(x)这一步把"01"、"000"这样的字符串自动转成了1和0,前导零问题直接消失。你不需要自己写去零逻辑,语言帮你搞定了。if i < len(v1) else 0处理的是"1.0"和"1.0.0"这种长度不齐的情况,缺失段默认为0,逻辑非常干净。

如果把同样的思路用Java写,唯一的注意点是split方法的正则陷阱。Java里"1.2".split("\\.")必须写双反斜杠,因为split接收的是正则表达式,裸的"."会匹配任意字符,导致切分结果完全错误。这是Java选手最容易当场翻车的地方。C++选手则没有现成的split函数,需要自己用std::stringstream或手写循环按点切分,代码会变长,但思路完全一致。

2.2 思路二:低内存开销的双指针扫描法

如果用不了split,或者面试官故意要求你别用现成API,双指针法是更底层的答案。它不需要额外数组,直接在原字符串上边扫描边比较,空间复杂度从O(n)降到O(1)。

def compareVersion(version1: str, version2: str) -> int: i, j = 0, 0 n1, n2 = len(version1), len(version2) while i < n1 or j < n2: num1 = 0 while i < n1 and version1[i] != '.': num1 = num1 * 10 + int(version1[i]) i += 1 num2 = 0 while j < n2 and version2[j] != '.': num2 = num2 * 10 + int(version2[j]) j += 1 if num1 > num2: return 1 if num1 < num2: return -1 i += 1 j += 1 return 0

核心机制是两层循环:外层循环控制每次比较一个完整的修订号块,内层循环负责把连续的数字字符累加成真正的数值。num1 = num1 * 10 + int(version1[i])这行是经典的逐位转数字手法,相当于手写了一个atoi。遇到点号就停,比较完当前块后把指针跳过点号继续下一轮。

这版代码的高明之处在于,它不关心两个版本号是否等长。如果某个版本号先扫描完,对应的num就一直是0,自然就能和另一个版本号剩余的块比较。这种“缺省即0”的处理方式,和第一种思路殊途同归,但思路更底层,也更体现对字符串处理本质的理解。

2.3 两种思路的取舍与复杂度对比

先用表格把两种方法的复杂度理清楚:

方案时间复杂度空间复杂度代码量适用场景
split法O(N+M)O(N+M)少快速解题、笔试时间紧
双指针法O(N+M)O(1)中面试展示功底、内存敏感场景

两个方案时间复杂度相同,因为无论如何都要把两个字符串完整遍历一遍。差别主要在空间:split法要额外存储切分后的数组,而双指针法只用了两个索引变量。在LeetCode这种不太纠结空间的平台上,两种都能过,但面试场景我更推荐先讲双指针法,再补一句“如果语言支持且不限制空间,split法也能达到同样效果”,这样既展示了底层理解,又体现了对多种工具的掌握。

选型逻辑也很简单:如果这道题出现在笔试前几题,时间紧张,用split法快速拿下,把时间留给后面的硬骨头;如果出现在现场面试,优先写双指针法,因为它能顺带引出“你自己实现一个split会怎么做”的追问,反而给了你展示的舞台。

3. 边界条件与经典陷阱:那些年我们因为0踩的坑

这题的AC率不高的原因只有一个:边界条件远比看起来多。我自己第一次提交就挂在“1.0”和“1.0.0”的等值判断上。下面这些坑是历年刷题人用提交记录堆出来的,每个都值得记在小本本上。

3.1 前导零真的“不存在”吗

版本号里的"01"和"1"必须视为同一个数字。很多人知道这个规则,但实现时容易想当然。如果你用int()转换,没问题;但如果你比较的是字符串本身,"01"和"1"会被判定为不相等,直接返回错误结果。

还有一种隐蔽的前导零坑出现在双指针写法里。假设版本号是"0.001",逐字符累加时第一个字符是'0',num = 0 * 10 + 0得到0;第二个字符还是'0',还是0;直到第三个字符'1',num才变成1。这个过程自然消解了前导零,不需要你额外判断。但如果你在提取块之前先写一个“跳过所有前导零”的优化,就必须额外小心,因为跳过逻辑写错反而漏掉有效数字。

这类问题可以用一个生活类比帮助理解:版本号里的前导零,就像银行卡号前面的0。007和7是同一个号码吗?对银行系统来说不是,因为卡号是定长且有校验位的;但版本号的规则明确说了,前导零不改变修订号的值。规则不同,处理方式自然不同,刷题时一定要以题目描述为准,不能用日常直觉代替代码逻辑。

3.2 长度不同的版本号:1.0和1.0.0怎么比

这是最容易导致WA(Wrong Answer)的场景。"1.0"和"1.0.0"指向的是同一个版本吗?题目规则明确说:修订号从左到右比较,缺失的修订号视为0。所以答案是相等,返回0。

比较大小为0的段落时,还有一个让人意外的点:"1.0"和"1"也相等。因为第二段缺失按0处理,而另一方的0也确实为0。也就是说,版本号里靠后的0段全部没有意义。理解这一点就能理解为什么"0.0.0"和"0"相等,为什么"1.1"大于"1.0.1",因为第一段相等的情况下,第二段1大于0,后面的1根本轮不到比较。

这个规则和数学里“小数点后多余的0不影响小数大小”很像,但又不完全一样。版本号不是小数,不能直接按数值比,但“缺少的部分视为0”这个处理方式和小数补位倒是异曲同工。写代码时只需要在循环时用最大长度兜底,短的缺段补0即可。

3.3 空字符串和非数字字符的防御

题目约束了版本号只由数字和点号组成,所以空块是可能出现的。比如"1..2"这种输入,按split切分会出现空字符串。Python里int("")会直接抛异常,Java里Integer.parseInt("")也会抛异常。虽然LeetCode官方给的约束条件里不会出现这种输入,但生产环境可不一定。

如果你在面试手撕代码时主动讨论这个场景,会非常加分。一个稳妥的做法是在提取修订号字符时跳过空段,或者在累加数字前先判断字符是否为数字。用字符判断时,int(version1[i])这种写法在遇到非数字字符时会崩溃,你需要用条件判断或者ASCII码范围来过滤。另一个常见做法是使用isdigit()方法,但要注意它只能识别Unicode数字,在某些语言里可能有意外行为,最好明确限定在0到9之间。

我的个人建议是:把输入守卫放到最前面,在正式比较之前先校验字符串格式。虽然刷题这么做有点过度设计,但当你把这段代码搬到真实项目里时,它就是一道免死金牌。

3.4 整数溢出的隐患

版本号的每一段修订号,题目通常只说是数字,没说大小限制。如果某一段是"99999999999999999999",在C++或Java里直接用int存储就会溢出。C++的int是32位,最大值约21亿,一个超长修订号直接变成未定义行为。Java的int溢出会发生回绕,变成负数,比较结果全错。

解决方案有三个方向:一是使用long long或long,能覆盖大多数场景;二是Python不需要考虑这个问题,因为整数是无限精度的;三是既然版本号是数字字符串,可以直接按“去除前导零后的字符串长度和字典序”来比较,完全避开数值转换,这是理论上最健壮的方案。

面试时如果你能主动指出溢出风险,并给出用long或字符串比较的备选方案,面试官基本都会在评分表上给你打个不错的勾。刷题不只是为了AC,也是在模拟真实工程里对数据范围敏感的职业习惯。

4. 知识点延伸:从比较版本号到真实业务场景

很多人把第165题当成纯粹的脑筋急转弯,AC完就扔了。实际上,这道题背后的版本比较逻辑在软件开发里几乎无处不在,了解它的业务背景,对你理解题目价值、面试时扩展话题都有很大帮助。

4.1 语义化版本规范(SemVer)基础

版本号系统里有个流传最广的规范叫SemVer(语义化版本),它的核心结构是主版本号.次版本号.修订号三段式,比如2.4.1。主版本号变化意味着不兼容的API改动,次版本号变化代表向后兼容的功能新增,修订号变化则指向后兼容的问题修复。有些系统还会在后面追加预发布标识,比如2.4.1-beta.1,这就超出了纯数字版本号的范围,比较规则会更复杂。

回到第165题,题目里的版本号只包含数字和点号,没有预发布后缀,这其实是SemVer的一个简化子集。刷完这道题后,你可以试着扩展思考:如果版本号里出现"1.0.0-alpha"怎么办?字母段和数字段如何排序?"1.0.0-alpha"和"1.0.0-beta"谁大?这一连串问题一旦想清楚,你对版本号的理解就从“刷题模板”升级到了“工程规范”的层面。

4.2 版本比较在移动端强更新逻辑中的应用

我是从一次真实的移动端项目需求里深刻认识到这道题价值的。当时需要做一个“如果当前App版本低于后台配置的最低版本,则弹窗强制更新”的功能。后台下发的最低版本号是字符串,客户端本地也知道当前版本号,两者比较的代码,本质上就是第165题。

这个场景里有两个额外的复杂性:一是iOS和Android的版本号风格不同,iOS通常是1.2.3三段,Android可能是1.2.3.4四段,甚至带alpha后缀;二是线上版本可能存在脏数据,比如错误提交了"abc"或者空字符串。谁能用最少的代码同时处理格式差异和数据脏乱?答案就是基于字符串比较的版本号通用比较函数。

写这类业务代码时,我通常会把它封装成一个独立工具函数,输入输出和LeetCode版本保持一致,但内部会多做一层格式校验和异常兜底。因为业务代码的目标不是比赛AC,而是线上零事故,多一行防御就少一个半夜call。

4.3 这道题的姊妹题与拓展变体

LeetCode里和版本比较相关的题不多,但第165题的变体在各大公司的题库里非常常见。常见变体有以下几种。

第一种是把分隔符从点号换成其他字符,比如下划线或中划线,题目外壳换一下,核心解法不变。第二种是加入预发布标签,比如要求比较"1.0.0-rc.1"和"1.0.0",这需要额外定义预发布版本比正式版小。第三种是严格执行SemVer的段数,要求版本号必须凑齐三段,缺少的段补0,本质和我们这题的处理方式一样。

如果把这些变体都练一遍,你的套路就会非常清晰:切分、去前导零、补位、逐段比较。这四个步骤几乎可以通吃所有版本比较题,差别只在切分规则和补位策略。这也是我为什么总跟人说,与其刷100道不同风格的字符串题,不如吃透一道有代表性的,然后把变体全推给同一套解法。

5. 常见问题与调试实录:实测中踩过的坑

题目刷多了,你会发现AC只是起点,真正有价值的是那些让你卡住的报错用例和思维误区。这一节整理我发现频率最高的问题,配上调试思路,希望能帮你在下一次提交前就把bug消灭掉。

5.1 高频报错用例速查

我把这题最容易导致错误的输入总结成一张表,每一条都是我或身边同学真实踩过的:

输入用例期望结果容易错的点
version1 = "1.0", version2 = "1.0.0"0长度不等,缺失段未补0
version1 = "1.01", version2 = "1.1"0前导零未消除
version1 = "1.2", version2 = "1.10"-1误用浮点比较,或切分后未逐段比
version1 = "1", version2 = "0"1最基本的正负判断
version1 = "0.1", version2 = "1.0"-1第一段0 vs 1就应出结果
version1 = "19.2.3", version2 = "19.2.3.0.0"0末尾多串0段

这些用例如果能在写代码时先在脑子里过一遍,基本就能保证提交一次AC。我刷题有个习惯,先写3个正向用例、3个边界用例,再动笔写代码。步骤虽小,但能省下无数次提交失败的挫败感。

5.2 换语言实现时的细节差异

这题在不同语言里踩坑的点还不一样。Python选手最容易踩的是split参数传错,注意Python的split(".")是按字符串分割,不是正则,所以不用转义。Java选手最容易踩的是split("\\.")少写一层反斜杠,导致切分结果为空数组。C++选手最烦的是没有现成的split,得自己遍历。

JavaScript选手有个隐蔽的坑:parseInt("01")会得到1,但Number("01")也会得到1,看起来都行,但如果版本号段特别长,parseInt会丢失精度,因为它本质上是双精度浮点数。这种情况用BigInt或者字符串比较会更安全。写JS版本时,我建议直接走双指针手写累加,避开一切数字精度陷阱。

无论用哪种语言,核心逻辑都围绕“切分、消零、补位、逐段比”这四个动作展开。语言只是工具,思路一旦统一,换个语法五分钟就能写完,这也是刷题训练的最大价值。

5.3 面试官追问环节:如何答出区分度

这题的面试追问基本围绕四个方向:你能不能用双指针重写?输入里有非法字符怎么办?版本号段超过整数范围怎么办?如果版本号按SemVer标准多出预发布标签,你的比较函数要怎么改?

我建议的回答策略是:先指出使用内置函数的时间成本优势,再补充双指针版本体现空间敏感度,然后主动引出非法字符和溢出问题,最后把话题延展到SemVer规范和真实业务中的版本管理。这套组合拳打下来,面试官大概率会觉得你不只是在刷题,而是真思考过生产环境里的边界问题。

亲手写这道题时我还发现一个有意思的小技巧:把两个版本号按点号切分后,可以先把短的数组用0补齐到等长,再统一比较。逻辑更直接,调试时也少一层if判断。虽然多生成一个数组,但对理解没有负担,适合笔试时间紧的时候使用。

这题后续值得扩展的方向也很明确:把比较函数抽成独立模块,适配不同分割符和预发布标签规则;或者进一步读一读SemVer规范原文,统计包里各类操作符的匹配规则。针对前端方向的读者,我还建议试试用这个思路实现一个简单的npm包版本选择器,会比单纯刷题有意思得多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进