【C++】【STL】vector_OJ题

【C++】【STL】vector_OJ题


🔥 H.莓飛: 个人主页

❄️个人专栏:「C++」「数据结构」「Linux系统编程」

✨ 心不畏死,踏屍前行。

📦 代码仓库: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)

题目给一个整数数组,里面除了某个元素只出现一次,其余每个元素都出现两次,找出只出现一次的那个。题面额外加了两条:线性时间复杂度,常数额外空间。

原题链接:leetcode 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 开始数。

原题链接:leetcode 119. 杨辉三角 II

顺着上一题的写法把整张表铺开再取一行也能过,题目额外要了 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)

题目给一个非递减数组,要求在原地删除重复元素,使每个元素只出现一次,返回删除后数组的新长度。题面还要求把不重复的元素放到数组的前面几个位置,后面的内容不管。

原题链接:leetcode 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 的数字串,按老式手机键盘的映射返回所有可能的字母组合。

原题链接:leetcode 17. 电话号码的字母组合

这道题的答案是枚举出来的,长度是 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 可能让迭代器失效,螺旋与回溯的写法都是一边算一边追加,用迭代器会不停地作废,按下标反而稳。

参考

相关推荐
Rabitebla11 分钟前
【Linux操作系统】进程篇(十一):进程程序替换——从底层原理到 exec 函数族
linux·服务器·c++·笔记·学习·算法
沫璃染墨38 分钟前
从零入门计算机网络系列(十五):TCP可靠传输机制详解——从序号与确认应答到乱序处理
服务器·网络·c++·tcp/ip·tcp
青少儿编程课堂1 小时前
蓝桥杯·全国青少年信息素养大赛·电子学会Python/C++考级:Bellman-Ford与SPFA含负权最短路及负环检测全解析
c++·python·算法·bfs·信息学竞赛
旖旎夜光1 小时前
【C++ 基础入门】类和对象(下):初始化列表、static 成员、友元与内部类
c++·笔记·学习
j7~1 小时前
【C++ 八股文】(篇四)C++11 新特性
开发语言·c++·面试·c++11·八股文
无人生还别怕1 小时前
如何搭建docker仓库(轻量级)registry
linux·运维·docker·云原生·容器·registry·openanolisos
eggwyw2 小时前
C++中大括号的用法合集
开发语言·c++
xiaoye-duck2 小时前
《C++ 项目:手写大模型接入 SDK》本地 Ollama 部署与适配
开发语言·网络·c++·语言模型
无足鸟ICT2 小时前
【RHCA+】agedu命令(查看磁盘使用情况)
linux