ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C++实现LeetCode(66.加一运算)

C++实现LeetCode(66.加一运算)

前言


LeetCode 第 66 题「加一」的要求是:给定一个由整数组成的非空数组,数组按最高位在前的顺序表示一个非负整数,把这个整数加一,返回结果数组。例如[1, 2, 3]表示 123,加一后是[1, 2, 4];[9, 9]表示 99,加一后是[1, 0, 0]。


这道题常被当作「数组遍历」的入门题,但它真正考的是进位模型:什么时候可以直接加一并返回,什么时候必须把当前位清零继续向左,什么时候需要在最前面补一位。三个分支对应三种情况,漏掉任何一个都会在特定输入上出错。


初学者最容易犯的错误是把数组转成一个整数再+1,然后拆回数组。这个做法在数组很短时「看起来能用」,但数组长度可能达到一百位,远超int、long long甚至__int128的范围,转换过程本身就是溢出。另一类错误是循环变量用无符号类型,写成for (std::size_t i = digits.size() - 1; i >= 0; --i),当i减到 0 之后再自减会回绕成极大值,循环永远不会结束。


本文从进位模型讲起,给出原地修改与不修改输入两种写法,再延伸到「加任意整数」和「字符串大数相加」,最后列出常见错误。所有复杂度都按标准语义分析,不涉及任何运行时间测量。


一、题意拆解


题目有三个隐含约定,读题时要确认:



  1. 非空:数组至少有一个元素,所以不必处理空数组。

  2. 无前导零:除了数字 0 本身表示为[0],其它情况最高位不为 0。

  3. 最高位在前:digits[0]是最高位,digits[n-1]是个位。


加一运算只影响从个位开始的一段连续 9。具体来说:



  • 如果个位小于 9,直接加一,其余位不变,立刻返回;

  • 如果个位是 9,加一后变成 10,本位记 0,进位 1 传给前一位;

  • 如果连续一段(可能一直到最高位)都是 9,它们全部变 0,并在最前面插入一个 1。


这个观察就是全部算法:从右往左扫描,遇到第一个小于 9 的位就加一并返回;扫描完整个数组都没返回,说明全是 9,在最前面插入 1。


二、为什么不能转成整数


假设数组长度是 100,每一位都是 9。这个数约等于 10 的 100 次方,而 64 位无符号整数的上限约是 1.8×10 的 19 次方。用long long或unsigned long long都装不下,转换时会发生截断或溢出。


即便数组很短,转换也有其它问题:需要额外的乘加循环,复杂度没有变好;有符号整数溢出是未定义行为,不能依赖「溢出后回绕」这种假设。逐位处理没有这些负担,而且天然支持任意长度。




方案时间额外空间长度上限正确性风险



转成long long再加一O(n)O(n)约 19 位长数组必然溢出

逐位进位(原地)O(n)O(1)仅受内存限制无

逐位进位(新数组)O(n)O(n)仅受内存限制无



三、原地修改的写法


// 适用于 C++17
#include <vector>

class Solution {
public:
std::vector<int> plusOne(std::vector<int>& digits) {
// 从个位开始向左扫描
for (int i = static_cast<int>(digits.size()) - 1; i >= 0; --i) {
if (digits[i] < 9) {
++digits[i]; // 不需要进位,直接加一并结束
return digits;
}
digits[i] = 0; // 这一位是 9,加一后变 0,继续向左进位
}
// 走到这里说明所有位都是 9,需要在最高位前补 1
digits.insert(digits.begin(), 1);
return digits;
}
};

几个细节值得说明:



  • 循环变量用int而不是std::size_t。digits.size()是无符号的size_t,减一之后如果数组非空没问题,但循环条件i >= 0对无符号类型永远成立,会死循环。题目保证数组非空,所以static_cast<int>(digits.size()) - 1不会得到负数(前提是长度不超过int的上限,实际题目远达不到)。

  • return digits;返回的是std::vector<int>的副本。函数签名返回类型是值类型,这里会触发一次拷贝;由于返回值优化(RVO)和移动语义,编译器通常会消除或转为移动,但语义上它是一次返回。

  • 全 9 的情况用insert在头部插入。std::vector::insert在头部插入的时间复杂度是 O(n),因为要移动所有已有元素。整个算法最坏情况下也是 O(n),所以总复杂度仍是 O(n)。


如果不想修改输入,可以传值:


// 适用于 C++17
#include <vector>

std::vector<int> plusOneCopy(std::vector<int> digits) {
for (int i = static_cast<int>(digits.size()) - 1; i >= 0; --i) {
if (digits[i] < 9) {
++digits[i];
return digits;
}
digits[i] = 0;
}
digits.insert(digits.begin(), 1);
return digits;
}

参数按值传递,函数内部操作的是副本,调用者的数组不受影响。代价是一次 O(n) 的拷贝,但代码更安全,适合「入参不可变」的接口约定。


四、用反向迭代器改写


如果不喜欢下标与符号的纠缠,可以用反向迭代器:


// 适用于 C++17
#include <vector>

std::vector<int> plusOneIter(std::vector<int> digits) {
for (auto it = digits.rbegin(); it != digits.rend(); ++it) {
if (*it < 9) {
++(*it);
return digits;
}
*it = 0;
}
digits.insert(digits.begin(), 1);
return digits;
}

rbegin()指向最后一个元素,rend()指向第一个元素之前的位置。反向迭代器的++是向左移动,正好符合「从个位向高位」的扫描方向,也不会出现无符号下溢。这段代码的可读性通常比下标版本更好。


五、扩展到「加任意整数」


把「加一」推广成「加 k」,进位就不再是 0 或 1,而是一个可能大于 1 的进位值:


// 适用于 C++17
#include <vector>

std::vector<int> addInt(std::vector<int> digits, int k) {
int carry = k;
for (int i = static_cast<int>(digits.size()) - 1; i >= 0 && carry > 0; --i) {
const int sum = digits[i] + carry;
digits[i] = sum % 10;
carry = sum / 10;
}
while (carry > 0) {
digits.insert(digits.begin(), carry % 10);
carry /= 10;
}
return digits;
}

这里有两个循环:第一个处理数组内部的进位,第二个处理「进位超出最高位」的情况。比如[9, 9]加 1,第一个循环结束后digits是[0, 0]、carry是 1,第二个循环把 1 插到最前面。如果 k 很大(比如 12345),第二个循环会依次插入多位,注意插入顺序:每次插入的是carry % 10,然后carry /= 10,最终得到的是正确的高位序列。


这个版本假设 k 非负。如果要支持负数,需要先比较被减数与减数的大小,再决定做加法还是减法,复杂度会上升不少,属于另一类「大数运算」的范畴。


六、复杂度分析




操作时间复杂度说明



从右往左扫描O(n)最多检查 n 位

头部插入 1O(n)需要移动所有元素

整体O(n)最坏情况全 9,扫描加插入

额外空间O(1)原地版本只用几个标量



最坏情况是数组全为 9:扫描 n 位全部置 0,再插入一位并移动 n 个元素,总共约 2n 次操作,仍是 O(n)。最好情况是个位小于 9,只做一次比较和一次自增,O(1)。


常见坑点


1. 把数组转成整数再计算


❌long long v = 0; for (int d : digits) v = v * 10 + d; v += 1;再拆回数组 ✅ 逐位处理进位


数组长度可以到一百位,任何内置整数类型都装不下。转换过程中的乘法本身也会溢出,而有符号溢出是未定义行为。


2. 无符号循环变量导致死循环


❌for (std::size_t i = digits.size() - 1; i >= 0; --i)✅for (int i = static_cast<int>(digits.size()) - 1; i >= 0; --i)或反向迭代器


std::size_t是无符号类型,减到 0 之后再自减会回绕成一个极大值,i >= 0永远为真。这类错误在数组为空时还会直接在size() - 1处下溢。


3. 忘了全 9 需要在头部补位


❌ 循环里把 9 置 0 后直接return digits;✅ 循环结束后执行digits.insert(digits.begin(), 1);


[9, 9]加一的结果是[1, 0, 0],长度增加了一位。如果只在原长度内处理,会得到[0, 0],既少了最高位,数值也完全错了。


4. 加一之后没有立即返回


❌if (digits[i] < 9) { ++digits[i]; }然后继续循环 ✅if (digits[i] < 9) { ++digits[i]; return digits; }


不需要进位的位一旦加一,左边的所有位都不应该再变。继续循环会把前面的位也加一,结果偏大。


5. 认为头部插入是 O(1)


❌ 「insert(begin(), 1)只加一个元素,所以是常数时间」 ✅ 头部插入需要把已有元素整体后移,复杂度是 O(n)


std::vector是连续存储,任何非尾部插入都会移动元素,并且会使所有迭代器失效。分析复杂度时不能只看「插入了几个元素」。


6. 返回局部数组的引用


❌std::vector<int>& plusOne(std::vector<int>& digits) { ... return digits; }用在返回临时对象的场合 ✅ 返回std::vector<int>值类型


返回引用本身在参数是左值时没问题,但一旦传入临时对象或函数内部构造的数组,引用就会悬垂。返回值的写法更通用,编译器也会用 RVO 或移动避免多余拷贝。


7. 处理前导零时误删合法位


❌ 结果里出现[0, 0]就断言「有前导零,需要去掉第一个 0」 ✅ 只有题目明确要求「输出去除前导零」时才处理,且要保留单独的一个 0


本题的输入保证无前导零,输出也不应该出现前导零(除非结果就是 0,但加一之后不可能是 0)。擅自删除元素会改变数值。


8. 修改了入参却对外声称未修改


❌ 文档写「不改变输入」,实现却直接对digits自增 ✅ 需要只读语义时按值传参,或在函数内先复制一份


接口约定与实现不一致,调用方可能复用原数组做后续计算,得到的是被改过的数据。这类问题不会立刻报错,属于最难排查的一类缺陷。


总结




要点结论



核心模型从个位向左找第一个小于 9 的位,加一即止

全 9 处理全部置 0,头部插入 1,长度加一

时间复杂度O(n),最坏情况扫描加头部插入

空间复杂度原地版 O(1),复制版 O(n)

循环变量用有符号下标或反向迭代器,避免无符号下溢

整数转换禁止,长数组必然溢出



这道题的代码只有十几行,但它把三个基本功串在了一起:边界情况(全 9)、类型细节(无符号下溢)、复杂度直觉(头部插入不是常数时间)。把「加一」的进位模型想清楚,再推广到「加任意整数」和「两个大数相加」,就是一套完整的十进制大数运算框架。写这类题时,先在纸上把[9]、[9, 9]、[1, 9, 9]、[8]四个例子走一遍,比直接敲代码更有效率。




RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进