【C++】【STL】vector_OJ题

🔥 H.莓飛: 个人主页
✨ 心不畏死,踏屍前行。
📦 代码仓库:cocatrice_csdn_code
📖 博主简介:

概览
- 位运算三连:一次、三次、两次都从异或起手
- 137 有两条路:三十二个计数器,或者两个状态位
- 260 先整体异或,再按最低位分成两组
- 118 每一行都由上一行相邻两数相加得到
- 119 只留一行,从右往左原地滚过去
- 26 快慢两个下标,把不重复的挤到前面
- LCR 158 投票,不同就相消,剩下的候选是多数
- 54 四条边界轮流走,每走完一条就向内收一格
- 17 回溯,每层挑一个字母,收完撤销再换下一个
- 九道题都只用按下标读写,没有一个用到迭代器
核心知识
一、提示
分组的依据是解题动作,不是题目难度,也不是题号的先后顺序。位运算那一组只看位,把整个数组扫一遍就在 32 个位上凑出答案;二维表那一组先把形状铺开,再按行列把上一行的结果递推到下一行;原地压缩那一组不申请任何新空间,两个下标在同一个数组上一前一后地挪;回溯那一组把结果集当树来长,走到叶子收一个答案,回头把走过的路径擦掉。
四组手法彼此正交,放在一篇里的理由很直接:vector 的四种基本操作各需要一组题打底。读取一个下标、写入一个下标、在尾部追加、在尾部删掉,这四件事在九道题里都出现了,而且都不是随手写一行就完事的用法。
四组之间也有交叉,但每一组的读写模式是固定的。位运算那一组只扫一遍,读到哪算到哪,从不回头改数据;二维表那一组反过来,每一行都建立在前一行算完的基础上,写进去的值后面还要再读一次;原地压缩那一组写的位置永远落在读的位置之前或者重合,读到的旧值必须在写入新值之前用完;回溯那一组既有追加也有删除,容器长度在递归过程中反复变化,进来与出去两次操作必须对称。这四种模式差不多覆盖了 vector 在刷题里的全部用法,剩下的排序、拷贝、比较交给标准库就行。
第一组三题共用一个起点,那个起点就是异或,区别只在后面怎么用它。136 只用一次异或,137 把异或换成按位计数,260 用异或拿到分组依据再分组做一次。三题连起来读,能看清这条性质在什么情况下还算足够、什么情况下要换成按位处理、什么情况下要先把答案分成两半。
第二组两题考的是同一张表的两种存法,一道铺开,一道只留一行。铺开的写法空间是平方级,代码短,每一格的来历一眼能看明白;只留一行的写法把空间压成线性,代价是内层循环的方向不能随手改。
第三组两题都在原数组上动手,26 删元素,LCR 158 数过半数。前者动的是数据,后者动的是两个计数器,共同的前提是输入里必定有一个答案,不必为找不到答案准备分支。
第四组:一条路径、一个结果集、进去追加、回来撤销。

图 1 九道题按动作分成四组,每组共用一种手法
二、边界值
九道题的输入形状差得远,出错的位置却集中在五处。
第一处是空输入,这一处出现得最多,也最容易被漏掉。numRows 取 0 时一行都不该生成,空数组没有落单的数可找,空矩阵返回空的结果,空字符串不该产出一个空组合。这四种情况里最后一种最容易漏,因为递归的出口条件 idx == digits.size() 在空串上第一次进来就成立,不会崩,只会悄悄多收一个空串。
第二处是 int 的两个端点,负数那一端尤其容易出事。负数的最高位是 1,异或照搬位模式,所以 136 读 -2147483648 也能原样吐回来;张冠李戴的是取相反数这一步,-(-2147483648) 在 32 位里还是它自己,任何拿 x & -x 取最低位的写法在这里都会算错。
第三处是有符号与无符号混着用,两边的规则在转换处撞在一起。size() 是无符号数,size() - 1 在空容器上得到 18446744073709551615;vector 的下标也按无符号处理,把 -1 传进去同样绕回那个值,随后是一次越界读,进程可能当场没,也可能什么都不发生,取决于那块内存当时归谁。
第四处是结果本身溢出,答案的位数一旦超过 32 位就装不下。杨辉三角第 30 行的中间项是 77558760,一行的和是 536870912,都在 int 里;到第 34 行,中间项 2333606220 已经超过 2147483647,int 版开始吐出负数。这类溢出是算出来的,不是撞上的:中间项 2333606220 一旦超过 2147483647,只要行号过 33 就必错,跟输入取什么值无关,可以事先算清。位运算是按位拼答案,不受这条约束,但也正因为不受约束,137 的哈希写法在极值用例上错得毫无征兆。
第五处是形状退化,方方正正的输入突然缩成一行或者一列。矩阵退成一行或者一列时,螺旋的四趟里有两趟会沿着同一条路再走一遍,答案长度直接多出来;数组里所有元素都相同时,删除重复项要保证留下一个而不是零个;多数元素刚好占一半时,摩尔投票的结论不成立。
这五处都不在随机数据的覆盖范围里,靠随机撞是撞不到的。随机用例造得出长度,造不出 -2147483648,也造不出 1 乘 4 的矩阵,只能照着输入的形状一条条单独补。
越界为什么难查,这件事也值得单独说一句,它比算错更隐蔽。这九道题里所有越界读都不崩,读到的是一块相邻的堆内存,值可能是 0,也可能是上一轮留下的别的数组。程序接着往下算,答案会偏一点,偏得像真的一样,用肉眼比对根本发现不了。定位这种事只能靠 valgrind,它会在越界读的那一刻报出 Invalid read of size 4 与那一行的调用栈。九份程序最后都是 0 个错误,靠的就是把它当常规检查跑一遍。
题面给的数据范围是另一条线索,可以和边界一起读。136 与 137 都把数组长度限定在三万上下,可三万条数据量不出异或法与排序法的差距,所以正文里的百万与五百万是另外造的数据,只用来量时间,不参与答案校验。260、26、LCR 158 的合法输入都限制在 int 范围里,17 的结果个数上限 65536 也是题面给的。把范围当成约束一起看,很多边界用例是能提前列出来的,不必等程序跑错再回头补。
三、题目总览
| 题号 | 题目 | 关键动作 | 时间 |
|---|---|---|---|
| 136 | 只出现一次的数字 | 全员异或,成对的自己抵消 | O(N) |
| 137 | 只出现一次的数字 II | 按位取模三,或者两个状态位 | O(32N) |
| 260 | 只出现一次的数字 III | 整体异或后按最低位分组 | O(N) |
| 118 | 杨辉三角 | 上一行相邻两数相加 | O(N 平方) |
| 119 | 杨辉三角 II | 单行从右往左原地滚 | O(N 平方) |
| 26 | 删除有序数组中的重复项 | 快慢双指针原地压缩 | O(N) |
| LCR 158 | 库存管理 II | 摩尔投票,不同就相消 | O(N) |
| 54 | 螺旋矩阵 | 四条边界轮流走并收边 | O(M 乘 N) |
| 17 | 电话号码的字母组合 | 按位回溯,尾部追加再弹掉 | O(4 的 N 次方) |
九道题里只有杨辉三角两题是平方级,其余都在 O(N) 与 O(32N) 这两档上。回溯那一题的时间挂在结果的个数上,一个数字串长度是 8 时最多要吐出 65536 个组合,这已经是输出本身规模的下限,算法再怎么改也绕不过去。
题号是从一个总表里核对出来的,九道题在题库里的顺序与这里排的顺序不一样。正文按手法分组重排,读起来是一条线,从位运算一路走到回溯。CPP09 那篇的九道题与这里的九道题没有一道重合,两篇加起来十八道,覆盖了 vector 与 string 各自最常用的那一批操作。
实例代码
九道题各占一个 .cpp 文件,主解法、用来对照的第二写法、随机比对的驱动都写在同一份文件里。九个程序的编译命令只有文件名不同,按 C++11 标准编译,开两组告警,零告警。
text
g++ -std=c++11 -Wall -Wextra -g -O2 p01.cpp -o p01
目录里放的是这九份文件,编号从 1、3、4 一路跳到 10,中间空出来的是当初给 vector 行为实验留的位置,那些实验归到 vector 使用那篇去了。
text
cpp11/
├── src/
│ ├── exp01__p01_single_number.cpp 136 只出现一次的数字
│ ├── exp03__p02_single_number_ii.cpp 137 只出现一次的数字 II
│ ├── exp04__p03_single_number_iii.cpp 260 只出现一次的数字 III
│ ├── exp05__p04_pascals_triangle.cpp 118 杨辉三角
│ ├── exp06__p05_pascals_triangle_ii.cpp 119 杨辉三角 II
│ ├── exp07__p06_remove_duplicates.cpp 26 删除有序数组中的重复项
│ ├── exp08__p07_majority_element.cpp LCR 158 库存管理 II
│ ├── exp09__p08_spiral_matrix.cpp 54 螺旋矩阵
│ └── exp10__p09_letter_combinations.cpp 17 电话号码的字母组合
└── run6.sh
每份程序的主函数都随机造用例,先算一份可靠的写法当参照,再算主解法,两边逐组比对,不一致就把这一组数打印出来并退出。九道题的比对规模从 500 组到 2000 组不等,回显里全部是 0 组不一致。
九个驱动统一成一套写法,参数类型与比对流程都照同一份模板来。每份文件用 typedef std::vector<int> Vec; 起头,主解法与对照写法都收 const Vec& 或者 Vec&,比对时只换函数名,参数不必重新造。造用例的部分按题目形状各写各的:136 与 137 造长度为奇数、每个数出现两次或三次的数组,260 造两对重复加两个落单,118 与 119 只造行号,26 造按数值聚成若干段的非递减数组,LCR 158 保证目标数占过半,54 造 m 乘 n 的矩阵,17 造长度为 0 到 8 的数字串。比对失败时打印这一组数、两份结果与出错的格子,方便直接定位。
第一题 只出现一次的数字(136)
题目给一个整数数组,里面除了某个元素只出现一次,其余每个元素都出现两次,找出只出现一次的那个。题面额外加了两条:线性时间复杂度,常数额外空间。
不带上那两条限制,能跑通的写法有三种:排完序两两成对,落单的那个一定落在偶数下标上;哈希集合进出,出现两次的进进出出最后被清掉;全员异或。前两种都能过用例,一个要 O(N log N),一个要 O(N) 的堆空间,题面那两条把它们都挡住了。
异或能在这里顶用,靠的是三条性质,缺一条都不成立。任何一个数与它自己异或是 0,与 0 异或是它自己,异或满足交换律与结合律。三条凑在一起,整个数组连着异或,成对出现的数两两抵消,剩下的就是那一个。顺序在这道题里不重要,正着扫与倒着扫得到的结果一样。

图 2 每一次读入都把当前数并进 ret,成对的数自己消掉
cpp
int SingleNumber(const Vec& nums)
{
int ret = 0;
for (size_t i = 0; i < nums.size(); ++i)
ret ^= nums[i];
return ret;
}
拿 4、1、2、1、2 这五个数走一遍,看累加结果怎么一步步变。ret 从 0 开始,读 4 得 4,二进制 00000100;读 1 得 5,00000101;读 2 得 7,00000111;第二个 1 进来,最低位翻回去,得 6,00000110;第二个 2 进来,第二位也翻回去,得 4,00000100。五步走完回到 4,跟题目要的答案一致。
实测用两档规模各跑三轮,取微秒级计时器的读数。百万条数据异或法 460 到 566 微秒,均值 0.523 毫秒;排序法 55.5 毫秒;哈希集合 15.3 毫秒。五百万条数据异或法 2.760 毫秒,排序 338.2 毫秒,哈希集合 77.0 毫秒。两档规模下异或法都比排序快两个数量级,比哈希集合快将近三十倍。
另外两种写法也不是没有代价,把时间与空间一起量出来才公平。
异或这一算在位上做,符号位与数值位一视同仁。所以 -2147483648 进来跟别的数一样参与运算,最后原样出来也不会出错;符号位不参与任何判断,这是它能处理负数全部取值的原因。换成先取绝对值再算的写法,-2147483648 这一步就已经越界了。排序版那 55.5 毫秒里有两笔开销,一笔是排序本身,一笔是扫完找出落单的那个;哈希集合 15.3 毫秒基本都花在哈希函数与节点分配上,两百万次插入删除在堆上留下两百万个临时节点。题面把常数空间写进要求,等于直接把这条路封掉;要是题面只管时间,十五毫秒对五百万条也算能用。
计时与校验是分开做的两件事,各自有各自的规模与目的。答案校验用题面量级的随机数组跑两三千组,图的是一组一组比对得清楚;计时另造百万级的输入,只量时间不看答案。同一份程序里两个驱动各跑各的,互不干扰。

图 3 百万与五百万规模上三种解法的耗时
这里有一个必须说清的地方,不说清就会得出相反的结论。早期版本的计时用的是 clock(),这台机器的分辨率只有 10 毫秒,百万条数据读出来是 0.0 毫秒,五百万条连跑三轮得到 0.00、10.00、10.00,全都是 10 的整数倍。0.0 毫秒不代表它真的不花时间,只代表它低于计时器的分辨率。换成 clock_gettime(CLOCK_MONOTONIC) 之后才读出 523 微秒这个数。
时间是 O(N),空间是三个变量,跟输入规模没有关系。valgrind 跑一遍完整的驱动:
常数空间在这道题上有具体意义,不是一句空话。数组本身是输入,不能改;哈希写法额外要一张表,最坏情况装着 n 除以 2 个元素,两百万条就是几十兆字节,比输入本身还大。异或写法只多一个 int,输入多大都只占这几个字节。题面把常数空间写进要求,看的就是这一条。549363 次申请、549363 次释放、17853035 字节,0 个错误、0 字节泄漏。这个申请次数看着夸张,是因为驱动里要造 2000 组随机数组,每组都是一次 vector 分配,再加上排序版与哈希版的内部申请,跟主解法本身无关。
第二题 只出现一次的数字 II(137)
同样的形状,把每个元素的出现次数从两次改成三次,其余都出现三次,找出那一个。
原题链接:leetcode 137. 只出现一次的数字 II
异或在这里直接失效,扫完整个数组什么也得不到。三次异或同一个数得到它自己,不是 0,扫完整个数组剩下的是所有出现三次的数与落单那个数的异或,等于什么都没算出来。
把 32 个位拆开一个一个看,这件事就清楚了,每一位都是独立的。把 32 个位分开处理,每一位上把数组里这一位是 1 的元素个数加起来,出现三次的那些数在每一位上贡献的个数必然是 3 的倍数。总数对 3 取余,余数就是落单那个数在这一位上的值。32 个位各算一次,最后按位拼回一个整数,答案就出来了。
cpp
int SingleNumber(const Vec& nums)
{
int ret = 0;
for (int i = 0; i < 32; ++i)
{
int cnt = 0;
for (size_t j = 0; j < nums.size(); ++j)
cnt += (int)(((unsigned)nums[j] >> i) & 1u);
ret |= (int)((unsigned)(cnt % 3) << i);
}
return ret;
}
这份写法要 O(32N) 次操作,跑起来自然不快。真正好看的是两态自动机:用两个变量记每个位当前出现过一次还是两次,第三次进来时一起清掉。
cpp
int ByState(const Vec& nums)
{
int ones = 0, twos = 0;
for (size_t i = 0; i < nums.size(); ++i)
{
ones = (ones ^ nums[i]) & ~twos;
twos = (twos ^ nums[i]) & ~ones;
}
return ones;
}

图 4 同一个数读三次,两个状态位走回原点
这两行的先后顺序不能换,换过之后边界用例立刻出错。第一行算完 ones 之后,第二行用的是新的 ones 去清 twos,这样第三次读入同一个数时两个变量同时归零;要是先算 twos 再算 ones,第三次读入会在 twos 里留下残渣,答案偏掉。
拿 2、2、3、2 这组数走一遍,回显把每一步的两个变量都打了出来。第一个 2 进来,ones 是 00000010,twos 是 0;第二个 2 进来,ones 清成 0,twos 变成 00000010;3 进来,ones 记成 00000001,twos 清成 0;第三个 2 进来,ones 从 1 变 3,二进制 00000011,twos 还是 0。四步走完 ones 等于 3,正是落单的那个。
两态自动机只要 O(N) 时间与两个 int。
32 这个系数在复杂度里可以省掉,因为它不跟着输入变:题面把元素限定在 int 范围,位数就固定是 32,换 long long 才是 64。省得掉不等于不花时间,回显里 19.1 毫秒对 1.3 毫秒,差出来的十几倍就是那 32 趟位移与按位与。实测百万条数据:自动机 1.3 毫秒,按位统计 19.1 毫秒,排序 25.0 毫秒,哈希 7.2 毫秒。按位统计那 19.1 毫秒的构成是 3200 万次位移加与运算,回显里专门打了一行 bit ops = 32 * n = 32000000 把这件事摆出来。
哈希写法在这道题上有一个前提隐患,随机用例碰不到它。回显里 int 最小值连排四次的用例,按位统计、自动机、排序三份都给 2147483648 这个答案,哈希版给的是 0:这不是本题的合法输入(它没有落单的数),可它暴露了一件事,哈希版靠的是集合最后剩一个元素,前提一破,*st.begin() 就是在解引用空容器的迭代器。四种写法里只有它会在非法输入上给出一个看起来正常的数。
空间是 O(1),时间是 O(N) 与 O(32N) 两档。valgrind 一遍:
这道题不碰加减法,int 两端都不会溢出来:按位统计只数个数,自动机只做异或与取反。九道题里真正会溢出的只有杨辉三角两题,也只有那两道需要 64 位参与。340218 次申请、340218 次释放、28529728 字节,0 个错误。
第三题 只出现一次的数字 III(260)
这一版有两个数各出现一次,其余都出现两次,要返回这两个数。
原题链接:leetcode 260. 只出现一次的数字 III
整体异或一遍,成对的都消掉了,剩下的是那两个数的异或,记作 x。x 不等于 0,因为两个数不一样,至少有一位不同。找出 x 里最低的那个 1,用它当分组的依据:两个目标数在这一位上必然一个为 1、一个为 0,而所有成对出现的数,两次数值相同,位的取值也相同,一定被分到同一组。两组各自异或一遍,每一组里成对的都消掉,各得一个答案。
cpp
Vec SingleNumber(const Vec& nums)
{
int x = 0;
for (size_t i = 0; i < nums.size(); ++i)
x ^= nums[i];
unsigned u = (unsigned)x;
unsigned lsb = u & (~u + 1u);
int a = 0, b = 0;
for (size_t i = 0; i < nums.size(); ++i)
{
if (((unsigned)nums[i] & lsb) != 0)
a ^= nums[i];
else
b ^= nums[i];
}
Vec r;
r.push_back(a);
r.push_back(b);
return r;
}
取最低位这一步,是整道题最容易写错的地方。常见的写法是 x & -x,在 x 为负数时结果看着对,其实是踩在未定义行为上:-x 要先对 x 取反再加一,x 等于 -2147483648 时取反得 2147483647,加一回绕成它自己,最低位算出来是 0。转成无符号之后再用 ~u + 1u 就没有这个问题,无符号的回绕是定义好的。

图 5 一次异或拿到分组依据,再各扫一遍
拿 1、2、1、3、2、5 这一组走一遍,回显把它们拆成了两个阶段。x 一路走到 00000110,也就是 6;最低位的 1 落在第二位,lsb 等于 2;再扫一遍,1、1、5 的第二位是 0,进 0 组,2、3、2 的第二位是 1,进 1 组。0 组三个数异或得 5,1 组三个数异或得 3,答案是 3 与 5。题面允许按任意顺序返回这两个数,不必再排一次序。
实测百万条:异或分组 1.4 毫秒,排序 28.3 毫秒,哈希集合 15.3 毫秒。
两趟扫描是这道题的下限,想合成一趟做不到。第一趟要读完全部元素才能拿到两个数的异或,第二趟要按分组再读一遍,一趟都省不掉,因为分组依据本身是第一趟的产物。想在读到一半时就把两组分开,就得手里已经有那个最低位,而它只能从整体异或里出来。分组这一趟是第二次扫描,所以它比 136 慢了一倍多,仍然比另外两种写法快一个数量级。
边界单独准备了两条:一条是 -2147483648 与 2147483647 同时出现,这两个数的异或是 11111111 11111111 11111111 11111111,最低位是 1,取出来之后分组正确;一条是 1 与 -2 这种负数组合,分组同样正确。回显里 7 条边界用例全部一致,极值上也分得开。
空间是 O(1),时间是两个 O(N) 的扫描。valgrind:1557154 次申请、1557154 次释放、41479202 字节,0 个错误。这份的申请次数比前两题多出一大截,原因是驱动里的对照写法用排序与集合各跑了一遍 2000 组。
第四题 杨辉三角(118)
题目要求生成杨辉三角的前 numRows 行,返回一个二维数组,第 i 行有 i 加 1 个元素,首尾都是 1,中间每个数等于上一行同列与前一列之和。
原题链接:leetcode 118. 杨辉三角
这道题的难点不在递推,在二维 vector 的写法。std::vector<std::vector<int>> 是一个元素本身也是 vector 的 vector,外层容器只管行,每行的长度各不相同,所以不能像二维数组那样一次申请成矩形。常见的写法有两种:先 resize(numRows) 把行数定下来再逐行填,或者每行构造好之后 push_back 进去。这里走的是后者,每行直接用长度 i 加 1、初值全 1 的构造函数,首尾的 1 就位,只补中间的项。

图 6 第 k 行的元素个数是 k 加 1,尾部补 1 之后中间项逐个相加
cpp
Mat Generate(int numRows)
{
Mat res;
for (int i = 0; i < numRows; ++i)
{
Vec row(i + 1, 1);
for (int j = 1; j < i; ++j)
row[j] = res[i - 1][j - 1] + res[i - 1][j];
res.push_back(row);
}
return res;
}
内层循环的边界要从 1 起、到 i 之前结束,两头留给已经是 1 的位置。第 0 行与第 1 行进来时这个循环一次都不执行,所以不会读到 res[-1]。这一步看着不起眼,把起点写成 0 就会在第一行直接越界,而 operator[] 不检查下标,读出来的是一块相邻内存,程序照跑不误,答案是垃圾。
拿 numRows 等于 5 走一遍,回显把每一步的上一行与本行都列了出来:
text
row 0 | 上一行 (无) | 本行 [ 1 ] | 中间项 无
row 1 | 上一行 [ 1 ] | 本行 [ 1 1 ] | 中间项 无
row 2 | 上一行 [ 1 1 ] | 本行 [ 1 2 1 ] | 中间项 2=1+1
row 3 | 上一行 [ 1 2 1 ] | 本行 [ 1 3 3 1 ] | 中间项 3=1+2, 3=2+1
row 4 | 上一行 [ 1 3 3 1 ] | 本行 [ 1 4 6 4 1 ] | 中间项 4=1+3, 6=3+3, 4=3+1
row sums: 1 2 4 8 16
最后一行那行和值得看一眼:1、2、4、8、16,每一行的和都是上一行的两倍。这不是巧合,第 k 行的和等于 2 的 k 次方,靠的是组合数恒等式。
对照写法走的是组合数递推,第 i 行第 j 项直接按 C(i, j) 算,用前一项乘一下再除一下。回显里 numRows 等于 6 的两份结果逐格比对,差异 0 格;numRows 等于 20 时逐行比对,坏行 0 行。
组合数那一版走的是另一条路,利用的是相邻两项的比值:从行首的 1 出发,乘上 (i 减 j 加 1) 再除以 j,就得到下一项。乘在除之前,中间量会短暂超过 32 位,累乘那一步必须放进 64 位里做,否则第 20 行附近就开始歪。回显里那份写成 (long long)row[j - 1] * (i - j + 1) / j,先乘后除,中间量在 long long 里,算完再转回 int。
边界与规模放在一起看,能看出这道题的上限落在哪里。numRows 取 0、1、2、3 时行数分别是 0、1、2、3,都正确;取 30 时是 30 行,最后一行 30 个元素,首尾都是 1,中间最大的一项 77558760,整行加起来 536870912。这个和已经贴着 2 的 29 次方,离 int 的上限还有三倍多的余量,再往上就危险了。
时间是 O(N 平方),空间也是 O(N 平方),两样都挂在输出本身的大小上。valgrind 跑一遍:242 次申请、242 次释放、85010 字节,0 个错误。九份程序里这一份的申请次数最少,因为二维表是逐行构造的,每行一次分配,没有排序或者集合那样的反复进出。
第五题 杨辉三角 II(119)
同一张表,这一次只要第 rowIndex 行,行号从 0 开始数。
顺着上一题的写法把整张表铺开再取一行也能过,题目额外要了 O(rowIndex) 的空间,意思是不许铺表。办法是只留一行,每轮在这一行上把自己更新成下一行:先把整行填成 1,然后从右端往左,逐项加上左边相邻的那一项。
从右往左是这个写法的全部要害,方向反了整道题就废了。第 j 项要用的 row[j - 1] 必须是上一行的旧值,从右往左扫的时候,第 j 项右边的位置已经更新完了,左边的还都是旧值,所以拿到的一定是对的;从前往后扫就不行,row[j - 1] 刚被本行的新值覆盖过,加出来的是新值。

图 7 从右往左是为了不破坏右边要用的旧值
cpp
Vec GetRow(int rowIndex)
{
Vec row(rowIndex + 1, 1);
for (int i = 1; i <= rowIndex; ++i)
{
for (int j = i - 1; j >= 1; --j)
row[j] += row[j - 1];
}
return row;
}
拿 rowIndex 等于 4 走一遍,回显把每一步的下标与数组状态都列了出来:
text
init | [ 1 1 1 1 1 ] | 整行先填 1
i=2 j=1 | [ 1 (2) 1 1 1 ] | row[1] 1 + 1 = 2
i=3 j=2 | [ 1 2 (3) 1 1 ] | row[2] 1 + 2 = 3
i=3 j=1 | [ 1 (3) 3 1 1 ] | row[1] 2 + 1 = 3
i=4 j=3 | [ 1 3 3 (4) 1 ] | row[3] 1 + 3 = 4
i=4 j=2 | [ 1 3 (6) 4 1 ] | row[2] 3 + 3 = 6
i=4 j=1 | [ 1 (4) 6 4 1 ] | row[1] 3 + 1 = 4
六步走完得到 1、4、6、4、1,跟第四题铺整张表取下来的那一行一模一样。回显里另外跑了一遍 0 到 30 行,两份结果逐项比对,差异 0 项。
把方向写反的后果可以直接算出来,不必真跑一遍。同样是第 4 行,从前往后更新时 row[j] 加的是刚改过的 row[j - 1],一路滚下去得到的是 1、2、3、4、5,一条等差线,看着还挺整齐,跟杨辉三角一点关系都没有。
这道题的 int 版能过题,是因为题面把行号限制在 33 以内。第 33 行中间项是 1166803110,还在 int 里;到第 34 行,中间项最大到 2333606220,回显里 int 版直接吐出 -2091005866、2080961085 这样的负数,换 64 位版本才拿到 2203961430、2333606220 这些正确的值。行号一旦放开,这份写法就得跟着换成 long long。
实测一千行:原地滚动 0.23 毫秒,两行交替的写法 0.54 毫秒。两行交替要多一次整行拷贝,多出来的时间大概就是这一次拷贝的量。
两行交替这一版适合当对照,结果对不对一眼能看出来。它开两个长度相同的 vector,每轮拿上一行算出下一行,算完把两个容器换一下,比原地滚动多出一整行的读写;好处是内层循环的方向随便写,不会踩到覆盖的坑。一千行的规模下两份结果逐项一致,差异只在时间上,差的正是那一次整行拷贝。
时间是 O(N 平方),空间只有一行,这一行被反复覆盖。valgrind:401507 次申请、401507 次释放、803325902 字节,0 个错误。这份的字节数在九份里最大,八亿字节,来自回显里那条 0 到 30 行的逐行比对,每一行都要跟铺表版对照一次,比对的规模是按平方累上去的。
第六题 删除有序数组中的重复项(26)
题目给一个非递减数组,要求在原地删除重复元素,使每个元素只出现一次,返回删除后数组的新长度。题面还要求把不重复的元素放到数组的前面几个位置,后面的内容不管。
有序是这道题的关键前提,去掉它这套写法就不成立。数组排好序之后,重复的元素一定挨在一起,不需要记住谁出现过,只要跟已经确认的最后一个比一比就够了。用一个慢下标指向最后一个确认的位置,快下标一路往后读,读到跟慢下标不一样的数就把它写到慢下标的下一格。
cpp
int RemoveDuplicates(Vec& nums)
{
if (nums.empty())
return 0;
size_t slow = 0;
for (size_t fast = 1; fast < nums.size(); ++fast)
{
if (nums[fast] != nums[slow])
{
++slow;
nums[slow] = nums[fast];
}
}
return (int)slow + 1;
}

图 8 写指针只在读指针拿到新值时前进一步,前 k 个位置就是答案
拿 0、0、1、1、1、2、3、3 走一遍。第一个元素必然保留,慢下标停在 0;快下标读到下标 1 的 0,跟 nums[0] 相等,跳过;读到下标 2 的 1,不相等,慢下标加到 1 并写入 1;下标 3 与 4 都是 1,连续跳过两个;下标 5 的 2 写进慢下标的下一格,下标 6 的 3 再写一格,下标 7 的 3 跳过。七步走完,慢下标停在 3,返回 4,前四个位置是 0、1、2、3。
要注意写入的位置与读到的位置会错开,错开的那一截是脏数据。回显里下标 5 读到 2 的时候,数组已经变成 0、1、2、1、1、2、3、3,位置 2 上的旧 1 被 2 覆盖了,位置 3 往后还是旧内容。这不影响结果,题面只认前 k 个位置,后面那一截是脏的也无所谓。
实测两百万条随机数据:快慢指针 7.15 毫秒,新数组版 12.37 毫秒,std::unique 6.92 毫秒,三种写法给出的长度都是 1333897。新数组版慢的原因很直接,它要为两百万个元素额外申请一块空间,再把结果拷一遍;std::unique 做的就是同一个快慢指针,只是把它写成了模板,所以快一点点。
边界一共七条,其中空数组那条是入口挡掉的。不加那个判断的话,nums[slow] 在空容器上就是一次越界读,返回的是从容器外面捡来的一个数,程序不会崩,valgrind 会报 Invalid read of size 4。
时间是 O(N),空间是 O(1),两样都不随数据量涨。valgrind:27403 次申请、27403 次释放、132105477 字节,0 个错误。
返回值的写法也要留意,少一个或者绕成无符号都是灾难。慢下标从 0 起,只在读到新值时加一,最后元素个数是 slow + 1,写成 return slow 会少一个;空数组上这条更为难,slow 根本没有意义,得在入口就把 0 返回掉。这两处错一起犯的时候,长度会变成 18446744073709551615 那一类数,回显里一眼就能看出来。
还有一件事顺带说清:原地删不是真的删除。erase 掉一个元素,后面的元素要整体前移一格,重复项有 m 个就得前移 m 次,合计是平方级的搬运;两百万条数据里不同值只有一百三十三万,将近七十万个重复项,一个一个删下去早就超时了。快慢指针把这个动作换成覆盖写,每个被保留的元素只写一次,总写入次数等于不重复的个数。
第七题 库存管理 II(LCR 158)
题目给一个数组,其中有一个数出现的次数超过数组长度的一半,找出这个数。
原题链接:leetcode LCR 158. 库存管理 II
这条超过了长度一半,是这道题的全部条件。顺着这条条件往下走,有两类互不相同的写法。一类是排序后取中间那个,超过一半的元素不管怎么排,中位数都必然是它;一类是哈希表数一遍,谁先过半谁就是了。两种都要 O(N log N) 或者 O(N) 的额外空间。
排序取中位能成立,是因为超过一半的元素在排好序之后必然覆盖中间那个位置,不管它落在哪一段。哈希那一条更直白,一边数一边判,谁的计数超过长度的一半就当场返回,最好的情况读到中间就停。两条路都不需要理解抵消,写起来短,代价落在时间或者空间上。
摩尔投票把空间压到常数,只留候选人与票数两个变量。把数组当成一次投票,候选人与票数两个变量,遇到跟候选人相同的数就加一票,遇到不同的就减一票,票数掉到 0 就换候选人。这相当于把不同的两两配对抵消,因为目标数超过一半,抵消到最后剩下的候选人只可能是它。

图 9 票数只在遇到相同值时加一,遇到不同值时减一
cpp
int MajorityElement(const Vec& nums)
{
int cand = 0;
int votes = 0;
for (size_t i = 0; i < nums.size(); ++i)
{
if (votes == 0)
{
cand = nums[i];
votes = 1;
}
else if (nums[i] == cand)
{
++votes;
}
else
{
--votes;
}
}
return cand;
}
拿 1、2、3、2、2、2、5、4、2 走一遍,候选人换了两次。第一个 1 上任,第二个 2 把票打掉,票数归零;3 上任,第三个位置的 2 又把票打掉;到第四个位置的 2,票数归零,它自己上任,之后连着两个 2 把票数加到 2,5 与 4 各减一,票数掉回 0,最后一个 2 再上任,票数 1。九步走完候选人是 2,票数只剩 1,跟出现次数不是一回事。
这个票数不能当出现次数来用,两者的含义完全不同。回显里最终的票数是 1,而 2 实际出现了五次,票数只是抵消之后的结余。
实测两百万条随机数据:投票 1.56 毫秒,排序 47.3 毫秒,哈希 51.7 毫秒,随机抽样 1.19 毫秒。抽样版最快,它随便挑 40 个位置,谁出现得多谁就是答案,抽 40 次全部落空的概率是 2 的负 40 次方,实际用起来不太会错,但它在理论上不保证,属于能跑不能信的那一类。
边界有八条,包括长度 1、两个相同、全部相同、目标数在数组开头与结尾、以及 -2147483648 出现三次的组合。投票版只做相等比较,不碰算术,极值上不会出问题,八条全部一致。
时间是 O(N),空间是两个变量,跟数组长度无关。valgrind:2042168 次申请、2042168 次释放、115706103 字节,0 个错误。申请次数是九份里第二多的,那是排序与哈希两份对照写法跑 2000 组留下的。
第八题 螺旋矩阵(54)
题目给一个 m 乘 n 的矩阵,要求按顺时针螺旋的顺序返回所有元素。
原题链接:leetcode 54. 螺旋矩阵
螺旋可以拆成四趟:上边从左到右、右边从上到下、下边从右到左、左边从下到上,走完一圈四条边界各向内收一格,直到上下交叉或者左右交叉为止。四条边界用四个整数就够,不需要额外开一个同尺寸的标记矩阵。
麻烦的地方在退化形状,方阵走得很顺,一行一列就开始重复。矩阵只剩一行的时候,上边那一趟走完,下边那一趟会沿着同一行从右往左再走回来一遍;只剩一列时右边与左边也会重复。所以下边那一趟之前要判 top <= bottom,左边那一趟之前要判 left <= right,两条判断缺一不可。

图 10 单行或单列时,反向的那一趟会重复,必须先判边界
cpp
Vec SpiralOrder(const Mat& m)
{
Vec res;
if (m.empty() || m[0].empty())
return res;
int top = 0, bottom = (int)m.size() - 1;
int left = 0, right = (int)m[0].size() - 1;
while (top <= bottom && left <= right)
{
for (int j = left; j <= right; ++j)
res.push_back(m[top][j]);
++top;
for (int i = top; i <= bottom; ++i)
res.push_back(m[i][right]);
--right;
if (top <= bottom)
{
for (int j = right; j >= left; --j)
res.push_back(m[bottom][j]);
}
--bottom;
if (left <= right)
{
for (int i = bottom; i >= top; --i)
res.push_back(m[i][left]);
}
++left;
}
return res;
}
拿 3 乘 3 的矩阵走一遍,回显按四趟分别打了边界与本次取到的元素:
text
圈 1 上边 | top=0 bottom=2 left=0 right=2 | 1 2 3
圈 1 右边 | top=1 bottom=2 left=0 right=2 | 6 9
圈 1 下边 | top=1 bottom=2 left=0 right=1 | 8 7
圈 1 左边 | top=1 bottom=1 left=0 right=1 | 4
圈 2 上边 | top=1 bottom=1 left=1 right=1 | 5
圈 2 右边 | top=2 bottom=1 left=1 right=1 |
第四趟取到 4 之后,左边界加到 1、右边界已经收到 1、上边界与下边界都停在 1,第二圈只剩中间那一格,上边那一趟取到 5 就结束了。随后上边界加到 2 越过下边界,旁边那一趟循环条件不满足,什么都不取。全程九个数,一个不重一个不漏,四趟正好把矩阵取完。
把两条判断去掉,退化形状立刻露馅,答案长度直接翻倍。回显里专门留了三组对照:1 乘 4 的矩阵正确结果是 1、2、3、4,缺判断的版本是 1、2、3、4、3、2、1;4 乘 1 的矩阵正确结果是 1、2、3、4,缺判断的版本是 1、2、3、4、3、2;2 乘 3 的矩阵缺判断的版本多跑出一趟,变成 1、2、3、6、5、4、5、4。
实测 1200 乘 1200,也就是一百四十四万个格子:四边界写法 8.32 毫秒,访问标记写法 8.01 毫秒,两份结果逐元素一致。访问标记那一版要多开一个同样大小的 bool 矩阵,一百四十四万字节,跑起来反而略快,原因是它不用维护四个边界变量之间的加加减减。
访问标记的思路与四边界完全不同:从左上角出发,按当前方向一格一格走,撞到边界或者撞到走过的格子就右转,走满 M 乘 N 步正好结束。它不需要推导边界怎么收,形状退化时也不会重复,因为走过的格子上都留着标记。代价是多一块同尺寸的数组,矩阵很大时这笔开销不小。
返回的是一个一维 vector,长度是 M 乘 N,每取一格就 push_back 一次。元素到几十万时,push_back 会在中途触发扩容,把已经收好的元素搬到新地址;提前 reserve(M * N) 能省掉这几次搬家。本文的回显没有单独测这一项,八毫秒里它占的份额不大,所以没有加。
时间是 O(M 乘 N),每格进出一次;空间是 O(1) 的四个边界。valgrind:63865 次申请、63865 次释放、113938999 字节,0 个错误。
第九题 电话号码的字母组合(17)
题目给一个只含 2 到 9 的数字串,按老式手机键盘的映射返回所有可能的字母组合。
这道题的答案是枚举出来的,长度是 N 的数串,结果个数最多是 4 的 N 次方。回溯是这条路里最短的写法:用一个字符串记当前路径,每深一层挑一个字母追加到尾部,走到末尾就把路径收进结果集,收完把尾字符删掉,回到上一层换下一个字母。
映射表就一行,十个字符串按数字下标排,0 与 1 留空。
cpp
const char* kKeys[10] = {"", "", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz"};
void Dfs(const std::string& digits, size_t idx, std::string& cur, StrVec& out)
{
if (idx == digits.size())
{
out.push_back(cur);
return;
}
const char* keys = kKeys[digits[idx] - '0'];
for (int i = 0; keys[i] != '\0'; ++i)
{
cur.push_back(keys[i]);
Dfs(digits, idx + 1, cur, out);
cur.erase(cur.size() - 1);
}
}

图 11 递归每深一层就追加一个字符,返回时删掉它
拿 23 走一遍,回显把每一次追加、每一次输出、每一次撤销都记了下来,一共三十行。前几步是选 a、进第二层选 d、输出 ad、撤销 d、选 e、输出 ae、撤销 e、选 f、输出 af、撤销 f,然后回到第一层撤销 a。第一层换成 b 再走同样三步,得到 bd、be、bf,最后是 cd、ce、cf,一共九个。
数字串换成 79 时得到十六个组合,从 pw 一路到 rz。这里的 16 是 4 乘 4,7 与 9 各自对应四个字母。
撤销那一步删的是一个字符,不是整个路径。每层只往尾部追加一个字符,返回前把它删掉,路径就退回到进这一层之前的状态。漏了这一步,字符会一层层攒着,第二轮循环带着上一轮的尾巴,答案长度乱七八糟;删多了更糟,会把上一层的字符也抹掉。
实测同一份驱动跑两档长度,同一档跑三轮。长度 6 时回溯 0.06 毫秒、迭代 0.05 毫秒、传值版 0.12 毫秒;长度 8 时回显里两轮分别是 0.49 与 0.49 毫秒,另一轮是 5.35 与 5.15 毫秒,传值版 1.26 与 11.69 毫秒。同一份代码在两轮里差了十倍,说明这个规模已经小到被计时器粒度与机器状态左右,绝对数值不必当真,能确定的是传值版一直是最慢的那个,因为它每递归一层都要把当前路径整个拷一份。
还有一条路是迭代,不用递归也能把结果集一层层长出来。结果集从只含一个空串的数组开始,每读一个数字,就把当前结果集里每个串与这个数字对应的每个字母各拼一次,拼出下一层的结果集。它没有递归,代码比回溯还短,代价是每一层都要把整个结果集重建一遍,长度到 8 时等于把六万多个字符串反复拼接。
边界一共十一条,覆盖了长度、映射宽度与空串三种形状。空串那条返回 0 个组合,不是 1 个;长度 5 的全 9 返回 1024 个;长度 7 的全 2 返回 2187 个;长度 8 的全 7 返回 65536 个。这几个数都是 3 或 4 的幂,跟映射表对得上。
时间是结果个数乘上每层的增量,空间是递归深度加结果集本身。
递归深度等于数字串长度,最长 8 层,栈上只占几百字节。再往上加时,栈还早得很,先撞到的是结果集:长度 12 的全 7 能吐出 1600 万个组合,光字符串就要占掉几百兆内存,这时候怎么写都会先撞到内存。valgrind:2016188 次申请、2016188 次释放、97344201 字节,0 个错误。申请次数多是因为每个结果都是一次字符串分配,65536 个组合就是六万多次。
九道题的实测耗时
九道题的读数整理成一张表,前六道正好有可以对照的第二写法,后三道给出规模与一致性。
| 题目 | 数据规模 | 耗时 | 对照写法 |
|---|---|---|---|
| 136 | 百万条 / 五百万条随机 | 0.523 毫秒 / 2.760 毫秒 | 排序 55.5 毫秒 / 338.2 毫秒 |
| 137 | 百万条随机 | 1.3 毫秒 | 按位统计 19.1 毫秒 |
| 260 | 百万条随机 | 1.4 毫秒 | 排序 28.3 毫秒 |
| 118 | 三十行,二十组随机 | 坏行 0 行 | 组合数公式 |
| 119 | 一千行 | 0.23 毫秒 | 两行滚动 0.54 毫秒 |
| 26 | 两百万条随机 | 7.15 毫秒 | 新数组 12.37 毫秒 |
| LCR 158 | 两百万条随机 | 1.56 毫秒 | 排序 47.3 毫秒 |
| 54 | 1200 乘 1200 | 8.32 毫秒 | 访问标记 8.01 毫秒 |
| 17 | 八位数字串 | 5.35 毫秒 | 传值回溯 11.69 毫秒 |
把规模与耗时摆在一条线上看,位运算三题最快,百万条都在 1.5 毫秒以内,它们每一格只做一次异或或者一次位移。26 与 LCR 158 处理的是两百万条,耗时反而更长一点,原因在于它们每一格要做比较加写入,而且写入会碰到内存。54 的单位代价最高,一百四十四万格用了 8 毫秒,它每取一个元素都要按下标算一次二维偏移。
用耗时反推每次操作的成本,几个数能互相印证。26 处理两百万元素用了 7.15 毫秒,摊到每个元素上大约 3.6 纳秒,一次比较加一次可能的写入,差不多就是这个量级;137 的两态自动机每元素 1.3 纳秒,只做了几次异或与取反;54 每格 5.8 纳秒,多出来的是二维下标的乘法与两次内存跳转。这几个数摆在一起,大致能画出这台机器一次循环迭代的成本线。
顺便把编译环境交代清楚,读数才有可比的余地。这九份是在一台 CentOS 7.9 的机器上跑的,只装了 g++ 4.8.5,C++11 能编译,C++14 的选项直接报 unrecognized command line option。九份代码里用到的最新写法就是 auto 与 unordered_set,其余全是 C++98 那一套。换机器、换编译器,绝对数值会变,写法之间的相对快慢不会变。
杨辉三角两题没有单独做规模计时,它们的时间跟着输出的大小走。
两题差出的倍数也能对上账,多出来的开销落在每一格的动作上。最慢的一项是 54 的 8.32 毫秒,最快的一项是 136 的 0.523 毫秒,相差十六倍,可工作量只是一百四十四万格对一百万格。差距落在每格的动作数量上:54 每格要算一次二维偏移、比一次边界、再 push_back 一次,136 每格只有一次异或。第 30 行的整行和是 536870912,这个数只说明中间项的量级,不说明耗时;真正能证明写法对的是与组合数公式逐格比对的结果,二十组随机用例 0 行不一致。
valgrind 在九份上各跑一遍,全部是 0 个错误、0 字节泄漏,申请与释放两边配平。字节数最大的是 119 的八亿,最小的是 118 的八万五,前者带着 0 到 30 行的逐行比对,后者只生成一张表。
踩坑点
-
把 0.0 毫秒当成 0 毫秒,性能结论直接写反。136 的驱动最初用
clock()计时,这台机器的分辨率是 10 毫秒,百万条数据读出来是 0.0 毫秒,五百万条三轮读到 0.00、10.00、10.00。看上去异或法是零开销,实际上是读数落进了分辨率以下。换成clock_gettime(CLOCK_MONOTONIC)之后才有 460 到 566 微秒这种数,正文里的所有耗时都以微秒级读数为准。 -
拿对照写法当标准答案,边界用例上参照先错。137 的边界里有一组 int 最小值连排四次,前提不成立(没有落单的元素),按位统计与自动机都给 -2147483648,哈希版给 0。随机比对时它以哈希版为准,这一组会被判成三份不一致,删掉边界才跑通。对照组只在对前提成立的输入上可信,边界要按题面规则单独判。
-
取最低位写成
x & -x,在负的最小值上回绕。260 的-x要先取反再加一,x等于 -2147483648 时加一回绕成它自己,最低位算出来是 0,分组依据没了,两个答案挤进同一组。改成先转无符号再算u & (~u + 1u),无符号的回绕是定义好的,这一位稳定取到 0x80000000。 -
分组判定写成大于 0,最高位上的 lsb 会把判断翻掉。260 的分组条件原本写的是
(nums[i] & lsb) > 0,lsb 落在最高位时,与运算的结果是一个负数,大于 0 不成立,两个目标数全掉进 0 组,答案变成 0 与 0。改成!= 0之后就对了,判定只看这一位是不是 1,不看符号。 -
二维表的内层循环从 0 起,第一行读到
res[-1]。118 的中间项要从上一行取两个相邻的数,起点写成 0 时第一行就会去读一个不存在的前一行,vector 的operator[]不检查下标,读到的是一块相邻内存,程序照跑,答案是垃圾。起点必须从 1 起,把两头留给已经是 1 的位置。 -
从前往后滚单行,杨辉三角变成等差数列。119 一开始把内层循环写成从 1 递增到 i,每一格加上左边刚被改过的值,第 4 行滚成 1、2、3、4、5,看着挺整齐,跟杨辉三角毫无关系。必须从 i 减 1 递减到 1,第 j 项要用的左边那一格才还是本行的旧值。
-
空数组没有单独挡掉,慢下标读到了容器外面。26 的
nums[slow]在空容器上是越界读,valgrind 报Invalid read of size 4,进程不会崩,返回的长度是个随机数。入口补一句if (nums.empty()) return 0;就干净了。 -
把票数当成出现次数。LCR 158 的摩尔投票跑完,票数是抵消之后的结余,样例里候选人 2 出现五次,最终票数只有 1。拿票数去判过半必然失败;这道题只要候选人,不需要次数,真要次数还得再扫一遍数出来。
-
螺旋漏掉下边与左边的判断,单行单列倒着走一遍。54 缺了
top <= bottom与left <= right之后,1 乘 4 的矩阵返回 1、2、3、4、3、2、1,长度是正确值的两倍。两条判断分别管着一次反向遍历要不要执行,缺一条就多一趟。 -
回溯里忘了撤销,路径越滚越长。17 的
cur.erase漏掉之后,第二轮循环带着上一轮留下的字符,长度 2 的数字串吐出 9 个长度 3 的结果。位运算篇里的这个毛病和这里一模一样:只要用的是同一个容器,进去之前什么样,回来就得什么样。 -
空串多收了一个空组合。
-
计数的变量用了无符号类型,减到 0 之后再判断等于 0 永远不成立。LCR 158 的票数一开始声明成
size_t,票数 0 时来一个不同的数,--votes绕回 18446744073709551615,后面再判votes == 0就再也不会成立,候选人从此不再更换,答案停在数组第一个数上。票数必须用 int 存,它会被减到 0 但也只到 0。 -
返回值少一个,长度绕成一个巨大的无符号数。26 的慢下标从 0 起,元素个数是
slow + 1,写成return slow会少一;空数组上更别扭,slow没有意义。两处一起错的时候,返回的长度会打印成 18446744073709551615 那一类数,回显里一眼能看出来。 -
有符号右移在负数上是实现定义的行为。137 的按位统计最初写成
(nums[j] >> i) & 1,负数进来时算术右移会带进一串 1,虽然取最低位之后结果还对,但这一步在不同编译器上不保证一样。统一先转unsigned再移位,代码里现在写的是((unsigned)nums[j] >> i) & 1u。17 的递归出口是
idx == digits.size(),空串第一次进来就成立,直接往结果里塞了一个空字符串,返回的长度是 1 而不是 0。题面要的是空数组,入口得先把digits.empty()挡掉。
本篇总结(模拟面试问题)
问:异或为什么能把出现两次的数抹掉,出现三次为什么就不行了?
核心要点:异或是按位做的加法,不进位。同一个位上加两次回到 0,加三次回到 1,加四次回到 0,周期是 2。出现两次的数正好走完一个周期,无论它在数组里排第几,贡献都归零;出现三次的数走了一个半周期,留下一个 1,跟落单的数混在一起,扫完分不开谁是谁。要处理三次,得换一个周期为 3 的计数方式,要么一位一位地数个数取模,要么用两个变量记 1 次与 2 次两个状态。
问:137 的两态自动机为什么两个变量就够,两个变量的含义到底是什么?
核心要点:每个位上的计数只需要区分三种状态:出现过 0 次、1 次、2 次,第三次进来回到 0 次,所以两个二进制位就够编码三个状态,ones 与 twos 分别记这个位的两种非零状态,两个都为 0 就是出现 0 次或者 3 的倍数次。更新时先把新来的数并进 ones,再把已经进过两次的那些位从 ones 里清掉;twos 同理,用它清 ones,用新的 ones 清 twos,两个变量互为对方的清理依据,一轮走完状态自然回到原点。
问:260 为什么用最低位的 1 分组,用最高位的 1 行不行?
核心要点:两层都要成立才分得开。这一位要在 x 里是 1,这样两个目标数在这一位上必然一个 1 一个 0;同时这一位要对所有成对出现的数不产生分裂,成对的数两次数值相同,任何一位上的取值都相同,所以这一点自然满足。最低位的 1 与最高位的 1 都满足这两条,用哪个都能分开,区别在写法:最低位用 u & (~u + 1u) 一次就算出来,最高位要先数位数或者用编译器内建函数,多一步。取最低位只是省掉数位数这一步。
问:119 为什么必须从右往左更新,从前往后会得到什么?
核心要点:第 j 项要用的两个加数是本行的旧值,右边那一项在更新第 j 项时还没轮到,天然是旧的;左边那一项如果从前往后扫,刚刚在前一步被改成本行的新值,加进去的就是新值。第 4 行从前往后滚出来是 1、2、3、4、5,每一格都是 1 加上左边刚算出来的数,整行退化成等差数列。从右往左扫时左边那一格永远是旧的,右边已经更新过但这道题不用它,方向就选对了。
问:26 为什么返回 slow + 1 而不是 slow,空数组为什么必须先挡掉?
核心要点:慢下标指向的是最后一个被确认保留的元素,它从 0 开始,所以下标本身比个数少一,长度要加一才对。空数组是另一件事:没有元素可确认,慢下标没有意义,而 nums[slow] 这一句在空容器上就是一次越界读,vector 不检查下标,读到什么全看那块内存当时归谁,返回的长度会是一个随机数,valgrind 才报得出来。
问:摩尔投票为什么在超过一半的前提下一定对,不满足前提会怎样?
核心要点:把每次不同值的相遇看成一次配对抵消,一对不同的数互相抵掉,剩下没被抵掉的元素集合里,目标数的个数仍然严格多于其余所有元素的总和,因为目标数超过一半,抵消一对最多带走一个目标数,抵到最后不可能把它抵光。候选人一路换下来,最后停住的那个必然是它。前提一旦松开,比如目标数刚好占一半,抵消可以把票数打到 0,候选人可能停在一个只出现一次的值上,答案就错了。
问:54 为什么下边与左边要先判边界,上边与右边为什么不判?
核心要点:四条边界在第一圈都有富余,走完一趟再收一格,下一趟的起点自动避开已经取过的元素。到了最后一圈,形状可能退成一行或者一列,这时上边那一趟取完整行,下边那一趟的区间已经空了,但按原来的下标算它还会从右往左扫一遍,扫的正是刚取过的元素。上边与右边之所以不判,是因为它们每圈都从当前边界出发,前面的收边动作已经保证了区间非空,而反向的两趟在退化形状上会跟正向那趟重叠。
问:17 的回溯里 push_back 与 erase 为什么必须成对,删一个为什么就够?
核心要点:路径变量是全程共用的一个字符串,每一层往尾部加一个字符,等于把这一层的选择记在路径末尾。递归返回之后这一层的选择已经作废,必须把它删掉,下一轮换字母时路径才回到进这一层之前的样子。每层只加一个字符,所以每层也只删一个,删多了会把上一层记下的字符也抹掉,删少了会留下残留,两种错都会让结果的长度与个数一起乱掉。
问:这九道题里 vector 的哪个性质最容易埋坑?
核心要点:operator[] 不做越界检查。九道题里有三处越界是能悄悄跑过去的:118 的第一行读到 res[-1]、26 的空数组读到 nums[0]、119 的 row[j - 1] 在 j 取 1 时读到下标 0 之外的位置,前两处都只在 valgrind 里露出来,程序输出看着还挺正常。除此之外 size() 是无符号数,减一减过头会回绕成 18446744073709551615,用它当下标同样是一次越界读。写这批题的时候,凡是下标参与减法,都要先想一遍最小值会不会绕回去。
问:118 与 119 是同一张表,为什么一道铺成二维、一道只留一行?
核心要点:看返回值要什么。118 要把整张表交出去,输出本身就有 N 平方个元素,空间压不下去,铺开写最直白;119 只要一行,输出只有 N 个元素,题面又限了空间,只能留一行。还有一条隐含条件:119 每一格只依赖相邻两项,递推方向是明确的,单行覆盖才安全;要是依赖跨得很远,单行就撑不住了。
问:这九道题里为什么一个迭代器都没用,全都按下标访问?
问:二维 vector 的每一行长度不同,为什么不能像二维数组那样一次性申请成矩形?
核心要点:std::vector<std::vector<int>> 的外层容器只管行,每行的长度由每行自己管,行与行之间没有打包在一起,所以能长短不一。一次性申请成矩形得用一块连续内存加一句下标乘法,那是二维数组的写法;用 vector 的好处是不必预先知道最大行宽,坏处是每一行都是一次单独的分配,行数多的时候分配次数跟着涨。
核心要点:这些题都要拿到具体位置。26 要把值写到指定的下标上,快慢指针本身就是两个下标;54 要按行列两个下标取元素;119 的更新方向依赖下标之间的先后关系。迭代器擅长的是顺序遍历,表达不了我要写到第几格这件事。另外 push_back 可能让迭代器失效,螺旋与回溯的写法都是一边算一边追加,用迭代器会不停地作废,按下标反而稳。