LeetCode 165 比较版本号:从split陷阱到双指针优化,边界条件全解析 刷题刷到第 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行为差异很大。先看 JavaString.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手写解析过程就是先读1num 0 * 10 1 1再读0num 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取到 0parts2因为已经越界取 0相等第三轮parts1取到 1parts2取 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.4build.567你怎么比较这里新增了三个部分主版本、次版本、补丁版本以及预发布标识和构建元数据。处理的核心思路还是 LeetCode 165 那套先按点号切分逐一比较数字部分如果数字部分相同再比较预发布标识。构建元数据通常不参与优先级比较。你可以顺着题目思路去答强调需要把字符拆解出数字段和非数字段再定义额外的排序规则。这个追问本身考察的就是你能否把一道简单题迁移到更复杂的领域模型里我觉得后面的扩展思路对面试表达会很有帮助。做完这道题以后我有一个很深的体会很多看似简单的题目真正考查的不是你会不会某个花哨的算法而是你写代码时有没有把边界条件想完整。版本号比较就是一个很好的例子前导零、缺失段、溢出、指针越界每一个都足够让一段看起来正确的代码轰然倒塌。如果让我给自己的刷题之路提一个小建议那就是遇到字符串处理题先用两分钟想一遍空串、特殊分隔符、长度不相等、超大数字这四类边界再动手写代码你会发现自己提交的通过率明显提升。后面如果再遇到类似题比如 IP 地址解析、路径简化、字符串中提取数字等都可以复用这套字符串扫描和解析的思路性价比很高。