☰
LeetCode 165 比较版本号:从split陷阱到双指针优化,边界条件全解析
2026/10/13 22:32:56 网站建设 项目流程

刷题刷到第 165 题的时候,说实话我第一眼是有点不在意的:比较版本号?不就是拿点号切一切、比一比整数大小吗,能难到哪去。结果自己动手一写,连续两次提交都没过,不是漏了前导零的场景,就是忽略了版本号长度不一样的情况。这道题表面上是字符串处理,本质上考的却是你对边界条件的敏感程度,以及能不能想清楚“切分”和“扫描”两种思路的取舍。这篇文章不打算只贴一份 AC 代码,而是想把整道题从题目拆解、知识点归纳、三种解法的完整实现,到我自己踩过的坑,一次性讲透。无论你是刚开始刷题的初学者,还是准备面试想查漏补缺,这篇总结应该都能让你少走几步弯路。

1. 第一眼看到的是什么:题目拆解与分析

1.1 版本号的构成规则

题目给你两个字符串形式的版本号,例如"1.10.2"和"1.2.0",要求你判断哪个版本号更大。版本号由若干个修订号组成,修订号之间用.分隔,比如"1.2.3"就表示主版本号 1、次版本号 2、补丁版本号 3。

比较规则有三条,每一条都很简短,但合在一起就隐藏了不少边界情况。第一条,从左到右逐段比较修订号,一旦遇到某一段不相等,直接根据这一段的数值大小返回结果。第二条,每一段可以包含前导零,比如"1.01"和"1.001"要被视作相等的版本号,因为01和001解析成整数都是 1。第三条,如果两个版本号长度不一样,缺失的修订号按 0 处理,所以"1.0"和"1.0.0"相等,而"1.0.1"和"1"比较时,后者缺失的第二段、第三段都按 0 计,结果是前者更大。

这三条规则本身都不难理解,但放到具体实现里就会出现各种意想不到的偏差。比如我用第一版代码直接调split("."),结果一个用例都过不了,因为.在正则表达式里代表“匹配任意字符”,把整个字符串切得七零八落。这类细节,就是整道题真正的考点所在。

1.2 为什么说这道题比看起来难

LeetCode 给这道题的难度定级是中等,但我个人觉得,它的算法思维难度其实只有简单水平,真正的难度在“代码写得对不对”这个层面。你想想,版本号比较的最终逻辑就是循环比整数大小,任何一个学过基础编程的人都能写出来。但要用代码严谨地处理完所有边界场景,不踩split的坑、不考虑溢出、不把循环的指针搞乱,就立刻淘汰掉一批人。

这也是面试官喜欢出这道题的原因。它不像动态规划那样需要很强的思维建模能力,反而更容易考查一个工程师写代码的基本功:能不能想到前导零、长度不一致、空串这类输入,能不能在压力下写出不越界、不遗漏的代码。老实说,我见过不少准备了大量难题的人,在这道题上反而翻了车。所以,把它当作边界条件处理能力的一个训练样本,价值非常高。

2. 核心知识点拆解:字符串处理与边界敏感度

2.1 字符串分割:正则、尾空与性能

版本号比较最直观的思路就是把字符串按照.切分成数组,然后逐段比较。这个思路听着简单,但不同语言里的split行为差异很大。

先看 Java,String.split()接收的是一个正则表达式。如果你写version1.split("."),那不是按点号切,而是按任意字符切,结果是空数组。必须转义成split("\\.")才算正确。这个问题很多人刷题时遇到过,但实际工作里如果直接在代码里切版本号,同样会踩中,因为生产环境里可没有测试用例帮你兜底。

Java 的split还有一个隐藏行为:它会丢弃末尾的空字符串。比如"1.0.0.".split("\\.")返回的是["1", "0", "0"],而不是["1", "0", "0", ""]。好在版本号比较时,末尾缺失的段本来就按 0 处理,所以大多数情况下这个行为不影响结果。但如果你在真实项目里用同样的方法解析一个"2023.10.1."这样的脏数据,可能就会漏掉一个空版本段。Python 的split(".")行为又不一样,它会保留末尾空串。这些语言层面的细节,不在题目范围内,却往往是实际编码中最容易埋雷的地方。

另外,每次调用正则split都要编译一次正则表达式,如果在一个高频接口里拿它去切版本号,性能算不上好。这让我养成了一个习惯:字符串分割能用索引扫描就用索引扫描,不要动不动就split,尤其不能在高频路径里这么做。

2.2 数字字符串解析:手写循环的必要性

把一段像"0123"这样的字符串解析成整数,最简单的方式当然是用Integer.parseInt。在 LeetCode 165 这道题里,直接用parseInt完全没问题,因为题目保证了每一小段都是合法的数字字符串,而且长度不会超过整型范围。但在工程实践里,版本段未必这么规整,太长、带空格甚至带异常字符的情况都有可能出现。

手写解析的思路其实就一行核心公式:num = num * 10 + (c - '0')。遍历当前段的每一个字符,把字符转成对应的数字值,再累加进结果。这样做的好处有几个:第一,不受限于int范围,你可以用long甚至更大的存储结构;第二,可以自己控制遇到非法字符时的策略;第三,不依赖语言自带的转换库,在面试中能够展示你确实理解字符串转数字的本质。

比如版本号"1.10"里有一段的原文是"10",手写解析过程就是:先读'1',num = 0 * 10 + 1 = 1;再读'0',num = 1 * 10 + 0 = 10。这个数字值可以直接用于比较,完全不用额外处理前导零,因为01按这个流程解析出来也是1。这就是为什么很多解法不需要先去掉前导零,解析过程天然帮我们处理掉了。

2.3 双指针思路:空间换不换时间

除了先分割再比较,另一种主流思路是用双指针扫描两个字符串,每找到一个点号就停下来比较当前段的数值。这种方案的优点在于不需要额外开辟数组存放每一段,空间复杂度从 O(n + m) 降到了 O(1),而且不会踩到split在尾空串上的行为差异。

代价是代码写起来要更小心。你需要自己维护两个游标i和j,各自负责扫描一个版本号;遇到点号时,当前段已经累计完成,可以比较;比较完以后,两个游标都要跳过点号继续下一轮。如果其中一个字符串已经扫描完了,它的当前段数值就按 0 处理,直到另一个字符串也扫描完。

我自己的体会是,双指针方案其实更适合作为面试时的最终解。它代码量不大,思路清楚,还展示了你对空间复杂度的敏感度。相比一上来就split的做法,面试官更愿意看到你有意识地避免不必要的数组分配。下面我会在实操部分同时给出这两种方案,方便你对比它们的代码差异。

3. 三种解法实操:从最直觉到最优

3.1 解法一:split 分割 + 补零循环

这是最符合直觉的写法。先用正则把两个版本号切成数组,然后取两个数组长度的最大值作为循环次数,长度不够的一方在比较时补 0。

完整代码长这样:

public int compareVersion(String version1, String version2) { String[] parts1 = version1.split("\\."); String[] parts2 = version2.split("\\."); int length = Math.max(parts1.length, parts2.length); for (int i = 0; i < length; i++) { int num1 = i < parts1.length ? Integer.parseInt(parts1[i]) : 0; int num2 = i < parts2.length ? Integer.parseInt(parts2[i]) : 0; if (num1 != num2) { return num1 > num2 ? 1 : -1; } } return 0; }

这段代码的重点是补零逻辑。比如"1.0.1"和"1"比较时,循环只跑两轮:第一轮两个数都是 1,相等;第二轮parts1取到 0,parts2因为已经越界取 0,相等;第三轮parts1取到 1,parts2取 0,这时才返回 1。整个流程非常清晰。

但这不是我最推荐的生产级写法,原因前面提到了:正则split有额外的性能开销,Java 的尾空串行为也未必符合预期。刷题阶段拿它练手没问题,但在后面的双指针解法里,你能看到同样清晰的逻辑,还能省掉数组分配,代码的健壮性也更好。

3.2 解法二:双指针逐段解析

既然题目涉及的是字符串逐段比较,那么最符合字符串本质的做法,就是用两个指针直接在原串上走,不额外生成数组。

实现代码如下:

public int compareVersion(String version1, String version2) { int i = 0, j = 0; int len1 = version1.length(), len2 = version2.length(); while (i < len1 || j < len2) { long num1 = 0; while (i < len1 && version1.charAt(i) != '.') { num1 = num1 * 10 + (version1.charAt(i) - '0'); i++; } long num2 = 0; while (j < len2 && version2.charAt(j) != '.') { num2 = num2 * 10 + (version2.charAt(j) - '0'); j++; } if (i < len1) i++; if (j < len2) j++; if (num1 != num2) { return num1 > num2 ? 1 : -1; } } return 0; }

这段代码有几处细节值得展开讲。首先是内层循环的终止条件,i < len1 && version1.charAt(i) != '.',保证游标不会越界,同时只要没遇到点号就继续累积数字。然后是跳过点号的处理,我用了两个单独的if判断,而不是直接i++,避免在字符串已经扫描完时把游标推到一个负数或越界的位置。最后是num变量用了long而不是int,虽然题目里版本段的数值不会超过int范围,但工程中完全可能出现超长数字段,用long可以扩大安全区间。

用双指针实现的逻辑有一个明显的好处:两个字符串同时只保留一个窗口,不需要预先知道切分后的数组长度。无论输入是"1.0"还是"1.0.0.0.0",循环都能自然结束。我在多次面试模拟里都用了这个版本,反馈普遍比split版本好。

3.3 解法三:递归分治的思路变体

除了上面两种主流解法,还有一种可以拓展思维的递归写法。思路是每次都只比较第一个.之前的数字,如果相等就截掉已经比较过的部分,递归比较剩下的字符串。

Python 版本可以这样写:

def compareVersion(self, version1: str, version2: str) -> int: parts1 = version1.split('.', 1) parts2 = version2.split('.', 1) v1 = parts1[0] if parts1 else "0" v2 = parts2[0] if parts2 else "0" if int(v1) != int(v2): return 1 if int(v1) > int(v2) else -1 rest1 = parts1[1] if len(parts1) > 1 else "" rest2 = parts2[1] if len(parts2) > 1 else "" if rest1 == "" and rest2 == "": return 0 return self.compareVersion(rest1, rest2)

递归解法在思路上很优雅,每一层只处理一件事:当前段的大小比较。但它有显而易见的缺点,比如递归深度会随着版本段数量增加而增加,极端情况下可能栈溢出;另外每一次递归都涉及字符串切片,性能和空间都不算好。因此我不建议用它作为面试的标准答案,但作为训练递归思维的小练习倒是不错。

3.4 三个方案的复杂度对比

我把三种方案的核心差异整理成一张表:

方案时间复杂度空间复杂度代码可读性风险点
split 分割O(n + m)O(n + m)高正则转义、尾空串
双指针O(n + m)O(1)中游标越界、点号跳过
递归分治O(n + m)O(段数)中栈溢出、切片开销

时间上三者都是线性级别,毕竟每个字符最多被扫描一次。空间上双指针方案明显更优,这也是我推荐它的核心理由。如果你在面试中写的是split版本,其实也不算错,但主动提一句“这里如果用双指针可以把空间复杂度降到 O(1)”,会是很好的加分项。

4. 踩坑实录:版本号比较的经典错误

4.1 忘了转义.,直接写split(".")

这是我最初犯的错误,也是每个刷这道题的人几乎都会遇到的一次。在 Java 或者 JavaScript 这类支持正则的分割方法里,"."不是字面意义上的点号,而是匹配任意字符的正则通配符。如果你写"1.2.3".split("."),得到的结果往往是一个空数组,因为任意字符都被当作分隔符,整个字符串被切得干干净净。

正确写法是split("\\.")或者用字符匹配的方式。这个坑在真实的工程代码里也很常见,所以我后来统一养成一个习惯:凡是按固定字符分割字符串,尽量避免依赖正则语义,要么转义,要么直接手写索引扫描。

4.2 试图用 double 或浮点数比较版本号

另一种我见过不少人踩的坑,是把版本号直接转成浮点数来比较。比如把"1.10"转成1.1,把"1.2"转成1.2,然后比较大小。听起来有点道理,但仔细想想就知道问题在哪:第一,"1.10"按照数字解析是1.10,但版本号语义里它应该和"1.1"不同,因为10和1是两个不同的修订号;第二,浮点数存在精度问题,拿它做版本比较本身就是不严谨的;第三,遇到"1.0.0"这种串,你根本无法用一个浮点数准确表达它的语义。

正确的做法永远是逐段比较整数,而不是把整串转成单一数值。这道题的核心规则已经决定了它不能被压缩成一个数字来比。

4.3 int 溢出与超长版本段

题目里的版本段长度限制在int范围内,所以很多题解直接写Integer.parseInt。但工程里如果遇到一个非常长的数字段,比如某个内部构建号长达 15 位,int就撑不住了。即使你换成long,仍然存在理论上的上限。

刷题阶段可以放宽心,因为测试用例不会故意刁难到 20 位数字。但我在写第二版双指针代码时还是直接把累加变量设成了long,这是一个好的习惯:不假设输入一定符合你自己心里的预期边界。真遇到超过long的场景,可以一边扫描一边比较,先比位数,再逐位比较字典序,不一定要把整段完整解析成数字。

4.4 循环结束后的指针自增

双指针解法里最隐蔽的坑,是在一个字符串已经扫描完后,依然对指针做自增操作。比如version1已经走到len1,此时再执行i++会让i变成len1 + 1,虽然内层循环不会用这个值去访问数组,但逻辑上不再严谨。

我在实现里特意用两个if判断来保护这个行为:只有游标还在字符串范围内时才跳过点号。这种细节特别能体现编码习惯,面试官如果逐行读你的代码,往往会注意到这些地方。

4.5 常见问题速查表

这里把这类题容易踩的问题整理成一个速查表,方便你复习时一眼定位。

问题现象根因解决思路
split 后数组为空正则中.匹配任意字符使用split("\\.")
"1.01"被判定大于"1.001"直接按字符串字典序比较先把每段解析成整数再比较
"1.0"与"1"结果不相等没有对缺失段补零循环长度取两者最大值,缺失按 0
超长数字段导致数值溢出使用int累加用long或逐位比较
指针越界或结果错误内层循环未检查边界循环条件同时判断索引不越界

5. 从刷题到工程:版本号比较的真实战场

5.1 客户端版本更新与阈值判断

版本号比较最典型的工程场景,是客户端强制更新和灰度发布。后端返回一个最低支持版本号,客户端拿到本机版本号,如果本机版本低于最低版本,就弹窗要求用户升级。这里的比较逻辑就跟 LeetCode 165 一模一样,主版本号、次版本号、补丁号逐段比。

但工程版本号往往比题目复杂,比如可能出现"2.1.0-beta"这种带预发布标识的串。处理方式一般是先切出数字部分和预发布后缀,数字部分按题目思路比较,后缀再单独定义优先级规则。这也说明了刷题和工程的区别:题目给你干净输入,工程里你永远不知道版本号会脏成什么样。

5.2 依赖管理和接口协商中的版本比较

另一个高频使用场景是依赖管理。包管理器在解析依赖时,经常需要判断当前安装的版本是否满足某个区间要求。很多包管理器不直接用原生的字符串比较,而是会用现成的语义化版本库,但在底层,它们的核心算法依然离不开逐段比较整数这个逻辑。

接口协商场景也类似,比如服务端与客户端约定"v1.2"以上的版本才支持新字段,服务端解析到请求头里的版本号后做比较判断。这类代码往往运行在高频请求链路上,所以双指针的 O(1) 空间方案更适合落地,而不是每次请求都split出一堆字符串数组。

5.3 如果面试官追问语义化版本怎么处理

很多面试官在刷题阶段问完这道题,还会加一个追问:如果版本号是语义化版本SemVer,比如"1.2.3-alpha.4+build.567",你怎么比较?这里新增了三个部分,主版本、次版本、补丁版本,以及预发布标识和构建元数据。

处理的核心思路还是 LeetCode 165 那套:先按点号切分,逐一比较数字部分;如果数字部分相同,再比较预发布标识。构建元数据通常不参与优先级比较。你可以顺着题目思路去答,强调需要把字符拆解出数字段和非数字段,再定义额外的排序规则。这个追问本身考察的,就是你能否把一道简单题迁移到更复杂的领域模型里,我觉得后面的扩展思路对面试表达会很有帮助。


做完这道题以后,我有一个很深的体会:很多看似简单的题目,真正考查的不是你会不会某个花哨的算法,而是你写代码时有没有把边界条件想完整。版本号比较就是一个很好的例子,前导零、缺失段、溢出、指针越界,每一个都足够让一段看起来正确的代码轰然倒塌。如果让我给自己的刷题之路提一个小建议,那就是遇到字符串处理题,先用两分钟想一遍空串、特殊分隔符、长度不相等、超大数字这四类边界,再动手写代码,你会发现自己提交的通过率明显提升。后面如果再遇到类似题,比如 IP 地址解析、路径简化、字符串中提取数字等,都可以复用这套字符串扫描和解析的思路,性价比很高。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询