题目
给你两个版本号字符串
version1 和
version2,比较它们。
版本号由以点(
.)分隔的修订号组成,修订号从左到右依次比较:
- 忽略前导零:每个修订号的数字比较时忽略前导零(
"01" 等于 "1");
- 缺失视为 0:如果版本号没有某处的修订号,视为
0("1.0" 与 "1.0.0" 相等)。
返回规则:
version1 > version2 返回 1,
version1 < version2 返回 -1,相等返回 0。
输入: version1 = "1.01", version2 = "1.001"
输出: 0 (忽略前导零后都是 1.1)
输入: version1 = "1.0", version2 = "1.0.0"
输出: 0 (缺失的修订号视为 0)
输入: version1 = "1.2", version2 = "1.10"
输出: -1 (2 < 10 —— 字符比较 '2' > '1' 会错成 1)
暑期面经高频题,考的就是"规则翻译得全不全"。
一、核心洞察:先归一化,再逐列比
这题(LC 165)看起来规则一堆(前导零、缺失补零、逐列、返回三种值),但两条规则其实指向同一个动作:
把每个修订号先"归一化",再逐列比较 —— 归一化 = 剥前导零(缺失修订号视为 "0")。
剥完之后,剩下的就是最朴素的逐列比对:相等进下一列,不等就短路返回。
上一题 LC 415(字符串相加)讲过:竖式的灵魂是"排除法" —— 排除捷径后剩下的唯一做法。这题一模一样:
不能转数字比大小吗?能,但有陷阱(见第五节);不转数字怎么比?归一化之后有个漂亮的事实(下一节)。
另一个洞察:这题和 LC 415 的负数变体共享同一个技巧 ——
比较两个字符串形式的数字(先剥前导零 / 先取绝对值),第一步永远是"先比长度,等长再比字典序"。系列内部的知识迁移,面试里主动点出来是加分项。
二、拆解:归一化之后的漂亮事实
2.1 剥前导零
def strip_zeros(s: str) -> str:
i = 0
while i < len(s) - 1 and s[i] == '0': # len(s) - 1 保证至少留一位
i += 1
return s[i:]
两个边界:全零
"000" 剥成
"0"(不是空串);
i < len(s) - 1 的守卫就是为它服务的 ——
永远给结果留一位。缺失的修订号直接当
"0" 用。
2.2 漂亮的事实:比数值 = 先比长度,等长再比字典序
剥完前导零的数字串
没有前导零,于是:
- 长度不等:长的那个数一定更大(没有前导零的数,位数多就是大);
- 长度相等:字典序(逐字符比较)与数值序完全一致。
所以比较两个归一化后的修订号,全程
不需要任何数字转换:
def cmp_norm(a: str, b: str) -> int:
if len(a) != len(b):
return -1 if len(a) < len(b) else 1
if a == b:
return 0
return -1 if a < b else 1
这个"先长度后字典序"正是 LC 415 含负数变体里比绝对值用的同一招。它的附加价值在第五节:
免疫超长修订号的精度陷阱。
2.3 主流程:逐列比较 + 短路
def compareVersion(version1: str, version2: str) -> int:
rev1 = version1.split('.')
rev2 = version2.split('.')
cols = max(len(rev1), len(rev2))
for i in range(cols):
v1 = strip_zeros(rev1[i]) if i < len(rev1) else '0'
v2 = strip_zeros(rev2[i]) if i < len(rev2) else '0'
c = cmp_norm(v1, v2)
if c != 0:
return c # 第一列分出胜负,短路返回
return 0
三、动画怎么看
动画就是两张修订号序列(原始串,
"02" 原样显示):上排 v1、下排 v2,从左到右逐列比较。
- compare 帧:当前列淡金底;相等列进下一列;分出胜负的列变橙边,横幅给出归一化后的比较(
"02" → 2 = "2"、"3" → 3 < "10")—— 注意前导零是在这一帧当场剥掉的;
- 补零侧:短版本用完的列画成灰虚格,显示灰色的 0;
- done 帧:裁决列变绿,横幅给出
return 1 / -1 / 0。
主例
"1.02.3" 对
"1.2.10" 三列讲完全部规则:第 1 列相等、第 2 列当场剥零、第 3 列
"3" < "10"(数值序)分出胜负短路 —— 字符序里
'3' > '1',这就是为什么不能直接比字符串。
四、完整推演表
以
"1.02.3" vs
"1.2.10" 为主例:
对照检查:第 2 列展示剥前导零(不剥的话
"02" == "2" 逐字符就不等了);第 3 列展示"先比长度" ——
"3" 与
"10" 等长才能逐字符比,不等长直接位数定大小。全等路径的例子
"1.01" vs
"1.001":三列
1=1、1=1、0=0(缺失侧补零)→ 返回 0。
五、变体:parseInt 精度陷阱与原地双指针
5.1 最短写法(parseInt 版)与它的陷阱
def compareVersion(version1: str, version2: str) -> int:
rev1 = version1.split('.')
rev2 = version2.split('.')
n = max(len(rev1), len(rev2))
for i in range(n):
a = int(rev1[i]) if i < len(rev1) else 0
b = int(rev2[i]) if i < len(rev2) else 0
if a != b:
return -1 if a < b else 1
return 0
Python 的
int() 是任意精度,直接写没问题。
JS 的 parseInt / Java 的 Long.parseLong 就有陷阱:修订号可以任意长,超过安全整数(JS 的 2^53)或 Long 范围就失真。实测一组反例(只差
末位的 21 位数):
version1 = "1.100000000000000000001"
version2 = "1.100000000000000000002"
JS parseInt:两个修订号都变成 1.0000000000000000e+21 → 判相等,返回 0 ❌
正确答案:-1(末位 1 < 2)
面试里把这组反例主动说出来("我会用字符串法避免 parseInt 的精度问题"),比写出最短版更值钱 ——
这就是第二节"先长度后字典序"的用武之地:纯字符串操作,任意长度都对。
5.2 原地双指针版(不 split)
不切分数组、两个指针直接在原串上扫:每列先把
p1 停在下一个
. 前,边扫边算
val = val * 10 + digit(或边扫边剥零),到
. 一列结束。省掉 split 的 O(m + n) 额外数组,但
必须小心"一侧用完"的判定(
p1 >= len 时视为 0)。写法长、易错点多,面试口头提一句"可以原地扫"即可,
动手时写 split 版 —— 清晰度值这个空间。
5.3 同族题
家族口诀:
"字符串形式的数字比大小,第一步先归一化(剥零/取绝对值),第二步先比长度再比字典序" —— 版本号、大数减法选操作数,都是这一招。
六、Java 版
public int compareVersion(String version1, String version2) {
String[] rev1 = version1.split("\\.");
String[] rev2 = version2.split("\\.");
int cols = Math.max(rev1.length, rev2.length);
for (int i = 0; i < cols; i++) {
String v1 = i < rev1.length ? stripZeros(rev1[i]) : "0";
String v2 = i < rev2.length ? stripZeros(rev2[i]) : "0";
int c = cmpNorm(v1, v2);
if (c != 0) return c;
}
return 0;
}
private String stripZeros(String s) {
int i = 0;
while (i < s.length() - 1 && s.charAt(i) == '0') i++;
return s.substring(i);
}
private int cmpNorm(String a, String b) {
if (a.length() != b.length()) return a.length() < b.length() ? -1 : 1;
int c = a.compareTo(b);
return c < 0 ? -1 : c > 0 ? 1 : 0;
}
三个 Java 细节:
split 的参数是
正则,
. 要写成
"\\.";等长时
compareTo 就是字典序;Java 8+ 也可以用
new BigDecimal(rev).compareTo(...) 但那是杀鸡用牛刀,且面试官想看的是字符串功夫。
七、边界情况
八、复杂度
- 时间 O(m + n + C·L):m、n 是两串长度(split 扫一遍),C 是列数、L 是修订号平均长度(逐列比较扫一遍)。总量级就是 O(m + n)。
- 空间 O(m + n):split 出的数组。原地双指针版可以做到 O(1)(不计输出),见 5.2。
九、常见坑
9.1 直接比字符串
"1.2" vs "1.10":逐字符
'2' > '1' 判 version1 大,
数值上 2 < 10。
"3" vs
"10" 同理。只要修订号能超过一位,字符比较就是错的 —— 必须先归一化再按"长度 + 字典序"比。
9.2 parseInt / Long 的精度陷阱
超长修订号(题面没限制长度)转数值会失真,两个不同的修订号可能被转成同一个数(见 5.1 的 21 位反例)。
字符串法零转换,天然免疫 —— 这也是本文第二节那个"漂亮事实"的实际价值。
9.3 剥前导零剥成空串
while (s[i] == '0') i++ 不带边界,
"000" 会剥成
"",之后
"" 和
"0" 比较长度出错。守卫是
i < len(s) - 1 ——
永远给结果留一位。
9.4 缺失修订号不补零
短版本用完就
return len1 - len2(拿列数当大小)是错的:
"1.0.1" vs
"1" 差的不是列数,是
那一列的值。缺失侧必须按 0 参与:
"1.0" vs
"1.0.0" 相等,
"1.0.1" vs
"1" 返回 -1。
9.5 Java 的 split 正则
version.split(".") 返回
空数组(
. 是正则的"任意字符",按分隔符切掉了所有内容)。必须
"\\."。Python 的
split('.') 是字面量分割,没这个问题。
9.6 忘了短路或短路错方向
逐列比较一旦分出胜负立即 return,不要比完整表再汇总(虽然结果通常一样,但
return c != 0 ? c : 0 的写法容易把 c 的正负号弄反)。每列的裁决就是最终裁决。
十、面试怎么答
- 一句话定性:"两条规则(前导零、缺失补零)都指向同一个动作 —— 先归一化,再逐列比较。"
- 说出那个漂亮事实:"剥完前导零后,比数值 = 先比长度、等长比字典序 —— 全程不用转数字。"
- 主动提精度陷阱:"JS/Java 里 parseInt/Long 会失真,字符串法免疫;Python 的 int 任意精度,直接转也行。"
- 边界报菜名:
"1.2" vs "1.10"(字符序反例)、"1.0" vs "1.0.0"(补零)、"000" 剥零留一位。
- 复杂度:O(m + n) 时间;split 版 O(m + n) 空间,原地双指针 O(1)。
- 如果面试官追问"那版本号带字母后缀呢"(如
1.0.0-beta)—— 先按 - 切开比数字部分,相同再比后缀(alpha < beta),规则照旧逐层归一化。
结语
比较版本号表面是规则模拟,内核还是系列反复出现的两条线:
归一化让比较变简单(剥前导零后"长度即大小"),
字符串功夫兜住数值做不到的事(任意长度不失真)。上一题 LC 415 的"先比长度再比字典序"在这里原样复用 —— 系列里同一招反复出现,才说明它真的成了你的工具。