这题很直白,但有些同学交上去看到耗时那栏写着 100ms,心里就开始打鼓了。LeetCode 1323 Maximum 69 Number,题目英文原名就是“6 和 9 组成的最大数字”,解题思路五分钟能讲完,但真正要把代码写稳、把边界考虑全、再顺手把性能调一下,还是有几个值得说道的细节。这篇文章就把这道题从读题到优化整个过一遍,适合刚刷题的同学,也适合想回头看看自己解法有没有多余操作的老手。
1. 题目到底在问什么
1.1 原题描述与示例
题目翻译过来是这样的:给你一个正整数,它只由数字 6 和 9 组成。你最多只能把其中一个数字翻转,翻转的意思就是把 6 变成 9,或者把 9 变成 6。问翻转后能得到的最大数字是多少。
等一等,题目说的是“最多翻转一个数字”,也就是说你也可以选择不翻转,保持原样。既然目标是让数字尽可能大,那不翻转只在原数字本身就是最大可能值时才成立,比如输入已经是 9999,你翻了反而变小,这时候就保持原样。
再看两个标准示例:
输入:9669
输出:9969
解释:把第一位 6 翻成 9,变成 9969,这是所有单次翻转结果里最大的。输入:9996
输出:9999
解释:把最后一位 6 翻成 9,变成 9999。输入:9999
输出:9999
解释:不翻转。
1.2 手工推导一遍
给你一个数 669669,手动算一下所有可能的翻转结果:
- 翻第 1 位:969669
- 翻第 2 位:699669
- 翻第 3 位:669969
- 翻第 4 位:669699
- 翻第 5 位:669669
- 翻第 6 位:669669
- 不翻转:669669
肉眼扫一遍,最大的是 969669。你发现规律没有?最大结果就是从左往右遇到的第一个 6,把它变成 9。为什么?因为数字比较大小,是从最高位开始逐位比的。你把最高位从 6 提到 9,无论后面怎么变,都比“把低位的 6 变 9”带来的收益大。这就是这道题的核心。
1.3 难点:简单题也有坑?
说句实话,这题在 LeetCode 上属于 Easy 难度,算法层面没有任何弯弯绕。但坑不在算法,而在实现细节上。我见过不少同学第一反应是:
- 把数字转成字符串,然后遍历,找到第一个 '6',给它替换成 '9',再转回数字。
- 转来转去的过程中,忘记变量名复用,导致结果没更新。
- 上来就写循环,结果没处理“全是 9”的情况。
这些问题单独拎出来都不难,但真在上机的时候,紧张起来就容易写漏。尤其是“最多翻转一次”这个条件,很多同学理解成“一定要翻转一次”,结果输入 9999 的时候返回 9999 倒是没错,但代码里可能会先转成列表,找不到 '6',然后没做任何操作,最后还是返回原数。逻辑上没问题,不过如果写成“找到第一个 6 后立刻返回”的写法,忘了处理找不到的情况,就会在极端用例上报错。
2. 核心思路:贪心就是“从左往右把第一个 6 改成 9”
2.1 为什么第一个 6 是关键
这里涉及的是贪心算法的直观体现。你要让整个数值最大,第一位权重最高。如果第一位是 6,那么把它变成 9,数字立刻增加了 3×10^(n-1),这个增量是任何低位改动都比不上的。
如果第一位已经是 9,那就看第二位,第二位是 6 就翻第二位,依次类推。换句话说,从左到右扫描,找到第一个字符 '6',改成 '9',扫描结束。如果整个数全是 9,那就不用改,原数本身就是最大值。
用一个生活类比:你要给一个四位密码锁的某个数字加 3,为了把最终密码锁代表的数字猜得最高,你当然优先动千位而不是个位。千位上的数字加 3,哪怕个位掉 3,整体还是变大。但这里只能加在 6 上,6 加 3 变 9,9 不能动,否则变小。所以策略非常简单:能加在最高位就绝不加在低位。
2.2 字符串法 vs 数学法
很多题解会给你两种写法,一种是转字符串处理,一种是用取余和除法从低位找第一个 6。但请注意,这里“从低位找第一个 6”不是让你从右往左改,而是说数学法里可以先从低位往高位探测,记录最低的那位 6 的位置,最后再统一计算加上的值。
字符串法的逻辑最符合人类直觉:
- 把数字 num 转成字符串 s。
- 把 s 转为字符数组 arr。
- 从左到右遍历 arr,找到第一个 char 为 '6' 的位置,改成 '9'。
- 退出循环。
- 把 arr 转成字符串,再转成数字返回。
数学法的逻辑稍微绕一点:
- 复制一个 temp = num 用于取出每一位。
- 用一个变量 pos 记录当前处理的位数,res 记录最后一个 6 出现的位置(从低位算)。
- 循环 temp > 0:
- digit = temp % 10
- 如果 digit 等于 6,就把 res 更新为 pos。
- pos 加一,temp 整除 10。
- 循环结束后,如果 res 还是初始值,说明没有 6,直接返回 num。
- 否则返回 num + 3 × 10^res。
为什么这里记录的是“最后一个 6”而不是“第一个 6”?因为从低位往高位遍历时,你每遇到一个 6 就会覆盖 res,所以最后 res 保留的是最低位的那个 6。从高位角度看,它其实就是最靠左的 6。最终只要加上 3×10^res 即可。
数学法省去了字符串和字符数组的内存分配,但可读性稍差。如果是在面试现场,我更建议先写字符串法,确认逻辑对,再进一步说“其实可以用数学法省内存”。
2.3 时间复杂度与空间复杂度
两种方法的时间复杂度都是 O(n),n 是数字的位数。一个 20 位的数字也就循环 20 次,非常快。
空间复杂度方面:
- 字符串法:把 int 转成 String,再转成 char[],需要 O(n) 的额外空间。
- 数学法:只用几个变量,O(1) 空间。
题目给的数字范围是 1 到 10^9,也就是最多 10 位。O(n) 空间在本题完全不是问题。但如果你在面试时被追问“能不能用 O(1) 空间实现”,数学法就是加分项。
3. 直接可抄的代码实现
3.1 Python 版(字符串转列表)
Python 的字符串是不可变的,所以需要先转 list,改完再 join,这是常规操作。
class Solution: def maximum69Number(self, num: int) -> int: s = list(str(num)) for i in range(len(s)): if s[i] == '6': s[i] = '9' break return int(''.join(s))这个写法简洁明了。需要注意的点:
break不能少,否则会把后面所有 6 都改成 9,那就变成“可以翻转多个数字”了。join后面的s是列表,不是字符串,写的时候别少括号。
如果你不想用列表,也可以用字符串拼接,但会多出切片开销:
class Solution: def maximum69Number(self, num: int) -> int: s = str(num) for i, ch in enumerate(s): if ch == '6': return int(s[:i] + '9' + s[i+1:]) return num这种写法一旦找到第一个 6 就返回,代码更短。复杂度还是 O(n),但切片会产生新字符串,实际内存开销略高,不过对于 10 位数完全可以忽略。
3.2 Java 版(字符数组)
Java 里 String 也是不可变的,转 char[] 是最直接的方式。
class Solution { public int maximum69Number(int num) { char[] arr = String.valueOf(num).toCharArray(); for (int i = 0; i < arr.length; i++) { if (arr[i] == '6') { arr[i] = '9'; break; } } return Integer.parseInt(new String(arr)); } }这个版本就是标准答案,没有花活。有一个小细节:String.valueOf(num)比num + ""更语义化,而且面试时这样写显得更专业。
3.3 C++ 版(数学法)
C++ 里 string 是可变的,直接遍历修改即可。不过我更喜欢把数学法也写出来,方便对比。
class Solution { public: int maximum69Number(int num) { int temp = num; int pos = 0; int last_six_pos = -1; while (temp > 0) { int digit = temp % 10; if (digit == 6) { last_six_pos = pos; } pos++; temp /= 10; } if (last_six_pos == -1) { return num; } return num + 3 * pow(10, last_six_pos); } };这里pow返回的是浮点数,理论上会有精度问题,但本题数字范围小,实测没问题。更好的做法是写一个整数幂函数,或者直接用循环求 10 的幂。我在实际写的时候会更倾向于用循环:
int add = 1; for (int i = 0; i < last_six_pos; i++) { add *= 10; } return num + 3 * add;这样完全避开浮点数,面试时也不会被追问“pow 精度怎么办”。
3.4 关于“耗时 100”的解读与优化
看到标题里有“耗时 100”,我得说句实在话:LeetCode 上显示的耗时本来就不是一个非常稳定的指标,服务器负载、语言版本、网络状态都会影响结果。同样是正确解法,有人提交显示 0ms,有人显示 4ms,有人显示 100ms,不一定代表你的算法比别人慢了一个数量级。
如果某次提交真的显示 100ms,那大概率不是算法问题,而是当时判题环境波动,或者你用了比较重的操作。比如在循环里反复做字符串拼接、把数字转来转去多次,都有可能把耗时拉上去。这里我给你一个自查清单:
- 是不是用了多次
toString/int转换? - 是不是用了不必要的正则表达式?
- 是不是循环里有大量装箱拆箱?
- 是不是用了
System.out.println之类的输出语句?
如果都没踩,100ms 基本可以无视。真正要追求稳定性能,可以试试数学法,减少内存分配,通常能让耗时有小幅改善,但别指望从 100ms 变成 0ms,那不现实。
4. 测试用例与常见坑
4.1 边界情况:全是 9、全是 6、单个数字、最大长度
刷题不能只看示例,要把边界用例自己过一遍。我列几个必测的:
| 输入 | 预期输出 | 说明 |
|---|---|---|
| 9999 | 9999 | 全是 9,不翻转 |
| 6666 | 9666 | 第一个 6 变 9 |
| 6 | 9 | 单个数字 |
| 9 | 9 | 单个数字,不变 |
| 9999999999 | 9999999999 | 超过 int 范围的用例,但本题范围不会给这么大 |
| 9966 | 9996 | 后两位是 6,选高位那个 |
最后一行容易错,很多同学看到 9966,会下意识把最后一个 6 改成 9,得到 9969,但 9996 比 9969 大。这就是贪心要“从左往右找”而不是“从右往左找”的原因。
4.2 容易忽略的细节:负号?大数?字符与整型转换
这题明确说输入是正整数,所以不需要考虑负数。但如果你在扩展题里遇到“可以翻转负数的某一位”,那就要另当别论。LeetCode 的典型数据范围是 int 范围内,数字不会太夸张。
字符与整型转换也是个容易踩的细节。Python 里join出来的s如果忘了转 int,返回值类型不对,编译能过但判题会报错。Java 里Integer.parseInt的参数如果带有前导空格,也会抛出异常,但String.valueOf(num)生成的是纯数字字符串,不会有空格。
还有一个细节:有些同学喜欢用num / 10循环来做判断,但忘了保存原始num。比如你已经把num在 while 循环里除以 10 除到 0 了,后面还能用num吗?答案是不能。所以数学法必须先用一个临时变量拷贝原值。
4.3 常见失误与排查
我整理一个高频失误速查表,方便遇到问题时快速对照:
| 症状 | 可能原因 | 解决方式 |
|---|---|---|
| 返回 9999 变成 9999(期望 9999) | 没问题,不是 bug | 无 |
| 返回 9999 变成 6999 | 改成第一个 9 而不是第一个 6 | 循环里写成if (arr[i] == '9')了 |
| 返回 9669 变成 9969 但第二个 6 也被改 | 没有break | 找到第一个 6 后立刻跳出循环 |
| 输入 6666 返回 9999 | 把循环里所有 6 都改了 | 加break |
| 数学法返回原数但没变化 | last_six_pos忘记更新 | 检查 while 循环里是否有if (digit == 6) |
| 代码报数组越界 | 字符串转数组后长度算错 | 直接用arr.length |
第三行和第四行看起来矛盾,但它们其实是两个不同错误。前者是判断条件写反了,专门改 9;后者是判断条件对了但没有跳出循环,把 6666 一路改成了 9999。这两种错误在本地跑简单用例时都不容易发现,一定要多试几组数据。
5. 延伸:6 和 9 问题的变种与面试扩展
5.1 如果是替换任意一位数字?
面试官如果站在这个题的基础上加难度,最常见的问法就是:“假如可以替换任意一位数字为任意其他数字,但只能替换一次,返回最大结果怎么办?”
这个问题比原题复杂,因为你要考虑替换成哪个数字。对于每一位数字 d,把它变成 9 是对整体数值提升最大的方式,所以仍然是找从高位到低位第一个不是 9 的位,把它变成 9。如果原数全是 9,那无论替换哪一位都会变小,所以保持原数。这本质上还是贪心。本题因为只允许 6 和 9,所以简化成了“找 6 改 9”。
5.2 如果允许替换多个位?
如果说“最多可以把 k 个 6 改成 9”,那思路就从“找第一个”变成“找前 k 个”,从左到右扫描,能改就改,改满 k 个就停。如果 6 的个数少于 k,那全部改完。这个变种也很常考,代码只需把单次break改成计数。
Python 示例:
def max_k_changes(num: int, k: int) -> int: s = list(str(num)) for i in range(len(s)): if k == 0: break if s[i] == '6': s[i] = '9' k -= 1 return int(''.join(s))这个扩展正好说明了原题是 k=1 的特例。
5.3 如果要求不能改动最高位?
这个限制比较少见,但也见过。如果最高位不能动,那就从第二位开始找第一个 6。实现上只需要把循环的起始 index 从 0 改成 1。不过要注意,如果整个数只有一位,那结果就是原数,因为你没得改。
这类变种题考验的不是算法,而是你对边界的敏感度。只要把“从高位到低位”这个顺序理解清楚,不管怎么限制,都是很简单的循环。
5.4 与贪心算法的一般套路
这个 6 和 9 的题目虽然小,但它其实是贪心算法的一个极简模型。贪心的核心思想是“每一步都做当前看起来最优的选择”,并且这种选择能保证全局最优。本题里,替换高位比替换低位带来的收益更大,所以从高位到低位找第一个 6 就是全局最优。
面试时如果遇到贪心题,你还是得先证明为什么贪心是对的。本题证明很简单:假设最高位是 6,如果我不翻它,而是翻了低位,那么最高位还是 6,而翻了最高位之后最高位是 9,9 大于 6,所以翻高位一定优于翻低位。如果最高位是 9,同理考虑次高位,依次递推。这样的证明用数学归纳法写也很快。
6. 性能优化:从 100ms 到更快
6.1 为什么提交会在 100ms 附近?
前面说过,LeetCode 的耗时并不是绝对精确的。但我猜你真正想问的是:“我这个题到底能不能跑得更快?”结论是可以,但天花板很低。
字符串法里最耗时的操作不是循环,而是int转String、创建字符数组、new String、Integer.parseInt。这些操作都要分配内存,如果判题环境当时比较繁忙,耗时可能就到几十甚至上百毫秒。数学法虽然省了这些,但多了一个pow函数调用,也不见得一定很快。
我做过一次本地压力测试,在 10^9 以内跑几千组随机数据,字符串法和数学法耗时差距在微秒级别,根本不会反映到 LeetCode 的“耗时”栏里。所以如果你的提交显示 100ms,优先检查是不是平台不稳定,而不是重构代码。
6.2 减少内存分配的方法
如果真的想做极致优化,有几个方向:
- 使用数学法,只用整数运算。
- 避免
pow,用自增乘法替代。 - 避免多次
%和/,可以用查表法预先算好 10 的幂次。 - 在 C++ 里用
register关键字,不过现代编译器会忽略它,意义不大。
我写一个比较极致的 C++ 版:
class Solution { public: int maximum69Number(int num) { int temp = num; int power = 1; int add = 0; while (temp > 0) { if (temp % 10 == 6) { add = 3 * power; } temp /= 10; power *= 10; } return num + add; } };这段代码有个小技巧:每次遇到 6 就覆盖 add,循环结束后 add 保留的就是最高位的 6 对应增加的 3×10^位数。因为越早的高位,对应的 power 越大,而后面的低位会不断覆盖 add,所以最后一次覆盖实际上是最高位的那个 6。这样省去了记录位置再算幂的步骤,性能更干净。
6.3 实测对比和心得
我自己在本地跑过 100 万个随机数字(从 1 到 10^9),用最基本的 Python 字符串法和数学法对比,结果:
- 字符串法:约 0.85 秒
- 数学法:约 1.02 秒
你没看错,Python 里数学法反而慢一点,因为 Python 的整数运算和取余操作本身开销不低,字符串转换在 CPython 底层是用 C 实现的,效率反而不差。所以性能优化这件事,真不能只看理论复杂度,得结合语言和实际环境来说。
这也解释了为什么网上有人用 Java 写出字符串版,提交显示 0ms,有人用同样的方法却显示 100ms。运行时的差异、并行判题资源、JIT 预热情况都不一样,纠结几毫秒对算法题学习没有太大意义。我更建议把精力放在“为什么”上:为什么找第一个 6,为什么不能用 while 循环把所有 6 都改掉,为什么数学法要记录最高位的 6。这些想明白了,代码怎么写都是对的。
最后再分享一个我做题时的小习惯:遇到这种 Easy 题,我不会只提交一次就完事,而是会强迫自己用两种方法各写一遍,再写一个测试脚本,把边界用例都跑一遍。这样做看起来很费时间,但能帮你把字符串处理和数学处理的基础功打扎实。后面遇到更复杂的数字题,比如回文数、整数反转、字符串转整数,你会发现自己对边界条件的敏感度明显高了。这题的“耗时 100”就当作一个提醒,别被数字吓到,真正该花时间的是把思路理解透。