☰
LeetCode 66 加一:从数组进位到工程思维的完整拆解
2026/10/17 3:30:34 网站建设 项目流程

第一次刷这道题的时候,我的内心独白是:加一?这不是用小学生都会的加法就能解决的问题吗?直到有一次模拟面试里,对方追问了一连串问题——数组全是9怎么办?为什么不能转成整数再算?空间复杂度能不能做到O(1)?——我才意识到,这道“简单题”真正考验的不是你会不会加法,而是你有没有把一个真实工程问题拆干净的意识。

LeetCode 66题“加一”的题干很短:给定一个由整数组成的非空数组,它表示一个非负整数,在这个数的基础上加一。数组的每个元素只存储单个数字,最高位存放在数组的首位。看起来平平无奇,但它在面试里的出现频率非常高,因为它能一次性考察数组边界处理、进位传播思想、原地修改意识,以及最基础的复杂度分析。这篇文章我想从读题开始,一路讲到多语言实现、测试用例设计,再到几个变体题目,希望能帮你把这道题吃透,而不是背一个答案就完事。

1. 先别急着写循环:把题干里的“坑”全找出来

1.1 数组不是“数字”,而是“十进制数的每一位”

很多人看到这个题目,第一反应是把数组拼成一个整数,加一,再拆回数组。这个思路在大数面前会立刻失效——比如数组长度是100,那这个整数早就超出了常规编程语言能表示的范围,结果是溢出后得到一堆谁都看不懂的数。

所以这道题的第一个隐含考点就是:你必须要用“数字字符串逐位处理”的思维,而不是“数值计算”的思维。数组中每个位置存储的是十进制数的一位,从下标0开始依次对应最高位到最低位。比如[1, 2, 3]表示数字123,加一之后的结果是124,对应数组[1, 2, 4]。

搞清楚这个映射关系后,很多后续的困惑都会迎刃而解:如果数组末尾是4,那我们只要把最后一位改掉就行数组本身没有“长度变化”的压力;如果末尾是9,则要考虑“9加1变成10,得进位,当前位置写0”。

提示:把数组理解为“按位存储的十进制数”,而不是“一个数字的容器”,是解这道题的认知起点。这个认知在后续处理大数加法、二进制加法等变体中同样适用。

1.2 两个容易忽略的题干细节:非空数组与最高位不为0

题干里有几个不起眼的限定条件,往往也是很多人写出错误代码的原因。

  • 非空数组:意味着不需要处理输入为空的边界,但并不意味着你可以不写防御性判断。实际工程中,如果这个函数被别的模块调用,调用方可能因为数据源异常而传入空数组,导致运行时崩溃。所以哪怕是刷题,也建议保留一个空数组的保护分支。
  • 最高位不为0:这个条件保证数组不会以[0, 1, 2]这样的形式出现(它应该写作[1, 2])。这个约束简化了很多分支,但它的真正价值在于提醒我们:进位可能让数组长度增加,比如[9]加一变成[1, 0],此时最高位不再是9,而是新插入的1。这一点恰恰是最多人漏掉的边界。

很多解法看起来很好看,其实暗中依赖了“数组非空”和“最高位非0”这两个假设。你在面试时如果能主动说出这两个前提,并且在代码里对它们做显式处理,会给面试官留下“这个人读题很细”的印象。

1.3 进位传播:从右往左“冒泡”的1

“进位传播”是这道题的核心机制。想象你在用竖式做加法:个位相加,满十进一,这个“1”会向左移动到十位;如果十位也满十,它继续向左移动。数组版本里,“向左”对应的是下标递减的方向,也就是从数组末尾往数组开头遍历。

这里有一个必须养成的习惯:进位的方向与数组下标方向相反。数组[9, 9]加一,末尾的9产生进位,前一位9收到进位后也产生进位,最后结果是[1, 0, 0]。这个过程像水中的涟漪,从右往左传播。

还有一个细节值得注意:进位传播的最远距离是整个数组长度,最坏情况下所有位都是9,进位会一路冲到最高位之外,导致数组扩容。这是这道题空间复杂度分析的关键分水岭:

情况例子是否扩容空间复杂度
无进位[1, 2, 3]→[1, 2, 4]否O(1)
局部进位[1, 9, 9]→[2, 0, 0]否O(1)
全9进位[9, 9]→[1, 0, 0]是O(n)

理解这个表,“为什么最优解空间复杂度不是严格O(1)”这个问题就能回答清楚了。

2. 从末尾往前模拟:最符合直觉的加法过程

2.1 步骤拆解:从哪里开始加,加到哪里停

最直接的解法就是模拟手算加法。既然要加一,那就从最低位开始,也就是数组下标n - 1处。先给最低位加1,产生的结果只有两种情况:

  • 结果小于10,当前位直接写回,整个流程立刻结束;
  • 结果等于10,当前位写0,并且产生一个进位carry = 1,要继续处理前一位。

当进位存在时,我们继续向左移动下标,对前一位做同样操作。如果走到下标0的位置仍然产生进位,说明整个数组都是9,需要在数组头部插入一个1。

这个思路看起来简单,但写代码时有一个小诀窍:根本不需要额外的carry变量。因为题目固定是“加一”,所以最多只会有“当前位是9才会继续进位”这一种情况。我们可以直接判断当前位是不是9,如果是9就置0继续循环,如果不是9就加1返回。这样写出来的代码比带carry变量的版本简洁得多,也更不容易出错。

2.2 Python实现:从最朴素版本到精炼版本

先看一个最容易理解的实现:

def plusOne(digits): n = len(digits) carry = 1 for i in range(n - 1, -1, -1): total = digits[i] + carry if total == 10: digits[i] = 0 carry = 1 else: digits[i] = total carry = 0 break if carry == 1: return [1] + digits return digits

这个版本忠实模拟了竖式加法的完整过程,适合在面试时先讲给面试官听,因为它逻辑清晰,人一看就懂。它的时间复杂度是O(n),空间复杂度在大多数情况下是O(1),只有全9情况会新建数组。

不过实际刷题时,我更推荐下面这个更轻量的版本:

def plusOne(digits): for i in range(len(digits) - 1, -1, -1): if digits[i] < 9: digits[i] += 1 return digits digits[i] = 0 return [1] + [0] * len(digits)

这段代码的核心洞察是:只有9才会触发进位,非9位直接加1后,它右侧所有已经置0的9都不用再动了,因为9加1已经变成0并进位完毕。如果循环跑完都没遇到非9元素,说明所有位都是9,直接在最前面补1,后面全部置0即可。

注意:[1] + [0] * len(digits)里的[0] * len(digits)会生成一个长度为原数组长度、元素全为0的新列表。这个技巧在Python里很常用,返回的是新数组,不会修改原数组,符合题目要求的输出语义。

2.3 Java和C++版本:边界处理与内存操作的差异

Java版本要注意的是数组长度固定,无法像Python那样直接拼接,所以全9情况必须显式创建长度加一的新数组:

class Solution { public int[] plusOne(int[] digits) { int n = digits.length; for (int i = n - 1; i >= 0; i--) { if (digits[i] < 9) { digits[i]++; return digits; } digits[i] = 0; } int[] res = new int[n + 1]; res[0] = 1; return res; } }

这里新数组res创建后,默认所有元素都是0,所以只需要把res[0]设为1,其余位置自然就是0,正好表示100...0。

C++版本和Java几乎一样,只是用vector<int>的时候,全9情况可以用insert在头部插入1,但那需要O(n)的移动时间,不如直接创建一个新vector:

class Solution { public: vector<int> plusOne(vector<int>& digits) { int n = digits.size(); for (int i = n - 1; i >= 0; i--) { if (digits[i] < 9) { digits[i]++; return digits; } digits[i] = 0; } vector<int> res(n + 1, 0); res[0] = 1; return res; } };

2.4 复杂度分析:面试时怎么说才完整

完成代码之后,面试官几乎一定会追问时间复杂度和空间复杂度。这里要特别注意表述的严谨性:

  • 时间复杂度毫无疑问是O(n),因为最多遍历一遍数组;
  • 空间复杂度要分情况:没有全9时,我们在原数组上修改,额外空间是O(1);全9时需要创建长度n+1的新数组,额外空间是O(n)。

光是这种“分情况讨论”的意识,就能让你在面试中比那些只会背答案的人高出一截。更进阶的表述是:平均情况下,数组末尾为9的概率是1/10,连续两个9的概率是1/100,所以实际运行时绝大多数输入都只需要处理最后几位,耗时远低于O(n)。这种概率视角并不要求你在代码里做什么优化,但它展现了你对实际问题效率的理解深度。

3. 一个观察让代码缩短一半:不是9就直接返回

3.1 核心观察:非9位加一后整个计算就结束了

为什么上面的精炼代码可以省去carry变量?关键在于“加一”这个操作的特殊性。十进制里,任何一位数字加一后,只有两种情况:要么本身小于9,加一后直接成为最终结果;要么本身是9,加一后变成0并产生进位。

这意味着,一旦我们从右向左扫描时发现某个位置不是9,那这个位置的加一操作不会引发后续进位。它右边的所有位一定是9,并且已经被置为0;它左边的所有位则完全不受影响。此时直接返回数组,就是正确答案。

这个观察等价于:在数组中从右往左找到第一个不为9的数字,把它加一,把右侧所有数字置0。如果整个数组都找不到不为9的数字,也就是全是9,那么答案就是首位为1、其余为0的新数组。

3.2 全9情况的统一处理:为什么长度加一就够了

我见过不少人在全9的情况下纠结:[9, 9]加一不是应该变成[1, 0, 0]吗?那不就是原数组长度加一,然后首位置1,其他位置全置0吗?

对,就这么简单。因为全是9意味着加一之后每一位都要变成0,并且整个数的位数会增加一位,最高位变成1。还想得更深一层,会得到这样一个结论:长度增加的情况只有一种触发条件,就是循环完整跑完都没遇到非9元素。所以代码里return [1] + [0] * len(digits)这一行,恰好覆盖了所有需要扩容的场景。

这个分支被单独拎出来之后,主循环里就无需再关心数组长度变化的问题,逻辑复杂度直接降了一个档次。

3.3 Python的短小实现与适用边界

Python的优势在这种题上体现得非常明显,核心逻辑可以压到五行以内:

def plusOne(digits): i = len(digits) - 1 while i >= 0 and digits[i] == 9: digits[i] = 0 i -= 1 if i < 0: return [1] + digits digits[i] += 1 return digits

这个版本用while循环把“连续9置零”这个动作表达得更直白。i如果变成-1,说明所有位都是9,返回[1] + 全零数组;否则把第一个非9位加1即可。

这个写法还有一个额外好处:它天然把“找第一个非9位”这个语义显式化了。如果你在面试中能先讲这个语义,再写代码,面试官会很明显地感觉到你不是在背题,而是真的理解了这个过程。

当然,短小并不总是等于更好。如果团队里的同事对Python语法不熟,五行版本反而会增加阅读障碍。刷题时理解两种写法,实际工作时选择更直白的那种,才是工程心态。

3.4 观察力如何迁移到其他题型

这道题给我们的最大启发,不是“记住这个解法”,而是“在约束条件里寻找简化杠杆”。我们把carry变量省掉,靠的是“加一”这个特殊约束;把全9分支合并,靠的是“只有全9才会扩容”这个事实。这种从特殊条件出发简化通用流程的思路,在算法题里到处都是。

举个典型的例子,LeetCode上有个题是“二进制求和”,同样是逐位相加,但因为进制是2,每一位只有0和1两种状态,“逢二进一”的判断条件依然清晰,只是不再有“非9直接结束”那么简单,因为任意位上0或1都可以触发进位。再比如“字符串相加”这类题目,进位处理就必须要用carry变量了,因为加数和被加数的每一位都是任意数字。

所以我的建议是:先把带carry的通用版本写熟练,再理解“加一”专属的精简版本,最后再去探索通用大数加法。这个过程本身就是在构建一个“进位处理”的知识树,而不是孤立地背66题的答案。

4. 测试用例怎么设计:别只用示例数据交差

4.1 五类必测用例:从分类出发彻底覆盖

很多刷题的朋友喜欢拿题目给的示例跑一遍,通了就提交,结果一提交就挂在某个边界用例上。这是非常常见的遗憾。我习惯在做完题之后,按“分类”而不是“随机”的方式构造测试数据,因为分类能保证逻辑上的全面覆盖。

先看最常规的分类表:

用例类型输入示例期望输出覆盖的分支
无进位[1, 2, 3][1, 2, 4]末尾非9直接加1
末位为9、前位非9[1, 9][2, 0]单个9进位
连续多个9、最高位非9[1, 9, 9][2, 0, 0]连续进位传播
全部为9[9, 9, 9][1, 0, 0, 0]数组扩容
单元素[0]→[1],[9]→[1, 0]特殊长度边界长度变化与非变化

这五类用例一旦全部通过,代码的正确性基本就有了保证。很多人在第四类上翻车,就是因为在循环外面没有处理“所有位都是9”的情况。

4.2 极端输入:长数组、全9数组、空数组

除了上面分类中的典型用例,我建议再压几个极端输入:

  • 超长数组:比如长度10000,末尾为9的连续段。用来验证没有递归爆栈问题,也顺便观察执行时间;
  • 全9数组:这是最极端的扩容情况,重点验证内存分配是否正确,返回数组长度是原长度加一而不是两倍;
  • 空数组:严格来说不符合题目“非空”约束,但作为防御性编程,最好在函数开头加一行判断:
    if not digits: return [1]
    这样如果未来有人复用代码时传入空数组,也不会崩。

这些极端输入不是面试必问,但在本地自测时多做一步,能帮你建立“边界敏感”,这种敏感度在真实系统开发中极为宝贵。

4.3 测试驱动思路:写完代码第一件事不是提交而是验证

我还想分享一个更“工程化”的习惯:写完题目后,先不要急着提交Online Judge,而是用一小段脚本把上面几张表跑一遍。比如在Python里可以这么写:

cases = [ ([1, 2, 3], [1, 2, 4]), ([1, 9], [2, 0]), ([1, 9, 9], [2, 0, 0]), ([9, 9, 9], [1, 0, 0, 0]), ([0], [1]), ([9], [1, 0]), ] for input_digits, expected in cases: result = plusOne(input_digits) assert result == expected, f"{input_digits} -> {result}, expected {expected}" print("all passed")

这个习惯能帮你把“算法题”变成“小型工程任务”。先定义输入输出规格,再验证实现,这和线上系统里先写测试用例再写业务代码的思路异曲同工。别小看这个步骤,它能在你真正面对“为什么提交后WA(答案错误)”的时候,节省大量时间。

5. 从“加一”到“加法器”:一道题带出的一类题

5.1 变体一:二进制数组加一

如果把十进制加一改成二进制加一,那就是一个非常经典的变体。给定一个由0和1组成的数组,表示一个二进制数,要求加一并返回结果。比如[1, 1, 1]加一变成[1, 0, 0, 0]。

这个变体的核心差异在于“9”变成了“1”。我们不再是遇到9才进位,而是遇到1就进位。于是代码里的判断条件从digits[i] < 9变成了bits[i] < 1,也就是bits[i] == 0时可以直接加1结束,遇到1则置0继续。

这个例子能说明一个问题:解法结构掌握得好不好,不在于能不能默写某道题,而在于换个进制、换个条件后,能不能快速调整。如果你从“加一”题中提炼出的模型是“从右往左找第一个可加位,之前的位全置0”,那么二进制变体不过是替换一下条件而已。

5.2 变体二:字符串形式的大数加一

另一个常见变体是输入不是数组而是一个字符串,比如"129"加一变成"130"。这种场景在真实业务里非常常见,比如订单号自增、优惠券码处理。字符串版本要注意的是不能直接转int,因为长度可能远超64位整数范围。

处理手法也很简单:先从字符串末尾逐位处理进位,最后再判断是否需要整体长度加一。Python里可以用列表来暂存每一位:

def plusOneString(s: str) -> str: digits = list(s) for i in range(len(digits) - 1, -1, -1): if digits[i] < '9': digits[i] = chr(ord(digits[i]) + 1) return ''.join(digits) digits[i] = '0' return '1' + ''.join(digits)

这个变体和原题几乎同构,但需要处理字符和数字之间的转换,稍不留神就会出现类型错误。我自己的经验是:先把数组版本想清楚,再套字符串版本,顺序很重要,否则很容易被字符串API干扰了算法主逻辑。

5.3 变体三:链表形式的加一

链表版本是很多大厂面试爱用的包装形式:给定一个链表,每个节点存一位数字,高位在链表头部,要求返回加一后的链表。由于链表只能从头往后遍历,而加一需要从尾往前处理,所以有两个处理思路。

思路一是先把链表反转,然后按数组的方式从前往后做加一,最后再反转回来。思路二是用递归或栈,先走到链表末尾,处理完进位后再返回。递归版本代码更短,但如果链表很长,要注意栈溢出的风险。

def plusOneLinkedList(head): def dfs(node): if not node: return 1 carry = dfs(node.next) if node.val + carry == 10: node.val = 0 return 1 node.val += carry return 0 carry = dfs(head) if carry: new_head = ListNode(1) new_head.next = head return new_head return head

这个递归写法非常优雅,但并不会比“反转+处理+再反转”更高效。实际工作中,如果链表的长度可控,我偏好反转法,因为它更容易让后续维护代码的人理解。

5.4 通用套路:竖式模拟与carry状态

把上面几个变体放在一起看,你会发现它们共享同一个底层模型:竖式加法模拟。所谓竖式加法,就是维护一个carry状态,从最低位开始逐位相加,当前位结果取模,进位结果做整除,一直到所有位都处理完,如果还有进位就追加一位。

这个模型可以描述十进制加一、二进制加一、字符串大数相加、链表加一,甚至扩展到任意进制、任意加数。如果你能把66题定位成这个模型的一个特例,以后遇到“大数加法”、“二进制求和”、“字符串乘法”之类的问题,就会有很强的迁移感。

我见过很多朋友刷题时急着往后刷数量,反而忽略了对一道经典题做这种“模型提炼”。结果就是刷了300题,遇到新题还是手忙脚乱。与其那样,不如把一道加一题吃透,用它把竖式加法这个基本款练成肌肉记忆,再看任何加法类变体都会觉得“换了个壳而已”。

关于这道题,我在实际刷题和带人过程中最深的体会是:它真正的价值不在于“会做”,而在于“能用一句话讲清楚为什么不能转整数、为什么全9要扩容、为什么空间复杂度不是严格O(1)”。如果这三个问题都能不假思索地回答出来,那你对数组操作和进位处理的理解就已经超过大多数只看过答案解析的人了。

最后再分享一个我自己的小技巧:遇到这种看起来“太简单”的题,我会逼自己用至少三种语言各写一遍,再刻意改造成一个变体(比如二进制版本)跑通。这么做不是炫技,而是为了确认自己掌握的到底是“这道题的答案”,还是“这类题的方法”。如果你也想把基础打扎实,不妨从这个习惯开始。

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

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

立即咨询