先说结论:我本来以为“数组 Day1”这种题就是送分,结果跟着代码随想录的数组专题把第一天内容重新刷完之后,才发现以前很多“会做”其实是“背下来了”。数组这类题目表面上只考增删改查、二分查找、双指针,实际上它把所有算法基本功全串在了一起:循环不变量的把握、区间边界的处理、原地操作的代价、返回值与真实数组长度的区分。这篇博客我会按照代码随想录数组专题第一天的思路,把二分查找、移除元素、有序数组平方这几类最典型的问题拆开讲清楚,包含完整 C++ 实现、每一步为什么要这么写、以及我实际刷题时踩过的坑。适合刚开始刷 LeetCode、或者刷了一段时间但总是边界出错的人。
1. 数组专题第一天,到底在练什么
1.1 连续内存带来的三大特性
数组是所有数据结构里最“朴素”的一种:元素在内存中连续存放,通过下标就能在 O(1) 时间内随机访问任意元素。这个特性看起来简单,但它直接决定了后续所有操作的思维模式。比如“删除一个元素”,数组不像链表那样改一下指针就行,必须把后面所有元素往前覆盖;再比如“查找一个元素”,因为数据连续且通常有序,我们才能高效地使用二分查找而不是每次都遍历。
理解连续内存还有一个容易被忽略的点:数组的长度是固定的。在 C++ 中,栈上原生数组 int a[10] 的长度编译期就确定了;就算用 vector,底层也是一片连续堆内存,扩容时要把旧数据搬到新位置。刷题时很多 bug 的根源就是忘了“数组不能真正删除元素,只能覆盖”,于是越写越乱。
代码随想录把数组专题放在最前面,我觉得很合理。它不要求你掌握复杂数据结构,却逼你先想清楚四个问题:区间定义是什么、指针怎么移动、循环什么时候结束、返回结果到底代表什么。这四个问题一旦解决,后面学链表、二叉树、动态规划时很多边界判断思路都是相通的。
1.2 Day1 题目怎么安排更合理
代码随想录数组第一天的内容一般围绕二分查找、移除元素这类基础题展开,有精力的话还会加上有序数组的平方。我建议按“先理解线性操作,再接触双指针优化”的顺序来安排:
- 先做 704 二分查找,重点练区间边界;
- 再做 27 移除元素,重点练快慢指针和原地操作;
- 有能力再做 977 有序数组的平方,练左右指针和“从后往前填”的思路。
这样做的好处是难度梯度非常顺。二分查找让你感受“循环条件差一个等号就全错”的残酷;移除元素让你从 O(n^2) 的暴力写法进化到 O(n) 的双指针写法;有序数组的平方则把负数平方后顺序变化的问题和双指针结合起来,刚好覆盖数组题里最常见的两类思维模型。Day1 不用贪多,把这三道题做透,比盲目刷十道题更有用。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 二分查找:循环不变量就是你的安全带
2.1 闭区间写法与防溢出模板
先给出最推荐新手掌握的闭区间写法:
cpp复制int search(vector<int>& nums, int target) {
int left = 0;
int right = nums.size() - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (nums[mid] == target) {
return mid;
} else if (nums[mid] < target) {
left = mid + 1;
} else {
right = mid - 1;
}
}
return -1;
}
这个模板的关键点是:我们规定 target 只可能存在于 [left, right] 这样一个闭区间里。所以在 while 判断时使用 left <= right,因为当 left == right 时,区间里还有一个元素需要检查。每次比较完 nums[mid] 和 target 后,必须把 mid 排除在下一轮区间外,所以更新规则是 left = mid + 1 或 right = mid - 1。
计算 mid 时我习惯写 left + (right - left) / 2,而不是 (left + right) / 2。原因很简单:当 left 和 right 都是很大的 int,直接相加可能溢出,这在真实工程里是会出事故的。虽然刷题时数据规模一般不大,但养成这个习惯能少踩很多坑。
2.2 开区间写法为什么容易把人绕晕
也很常见的一种写法是左闭右开区间 [left, right),LeetCode 官方题解有时也这么写。它的模板长这样:
cpp复制int left = 0;
int right = nums.size();
while (left < right) {
int mid = left + (right - left) / 2;
if (nums[mid] == target) {
return mid;
} else if (nums[mid] < target) {
left = mid + 1;
} else {
right = mid;
}
}
return -1;
这套写法的语义是:target 可能在 [left, right) 中,right 本身不参与检查。所以 while 条件变成了 left < right,当 left == right 时区间为空,循环结束。更新时,如果 target 在右半部分,left 可以放心移动到 mid + 1,因为 mid 已经被排除;如果 target 在左半部分,right 不能写成 mid - 1,因为 right 本来就不包含在区间内,写成 right = mid 就刚好把 mid 排除在下一轮区间外。
我刚开始学的时候,两种写法反复横跳,结果经常在 right = mid 还是 mid - 1 上出错。后来我干脆固定只写闭区间,因为它在直觉上更贴合“数组下标从 0 到 size - 1”的认知。闭区间写法唯一要注意的是初始 right = nums.size() - 1,别脑抽写成 nums.size(),否则第一次 mid 就取到越界下标。
2.3 二分查找不只是“查得到”这么简单
实际做题时,二分查找经常变体为“查找第一个大于等于 target 的位置”或“查找最后一个小于等于 target 的位置”。Day1 不需要把这些全做完,但你至少要知道:二分查找的本质不是“找到 target”,而是“在有序数据里用 O(log n) 时间缩小可能解的范围”。
比如 LeetCode 35 搜索插入位置,其实就是在二分查找结束后,返回 left 而不是 -1。因为当循环结束时,left 正好指向第一个大于等于 target 的位置。理解了这一点,你就明白为什么我们要在 left/right 上做排除更新,而不是随便往中间收拢——边界位置就是下一次搜索范围的起点。
刷题时我还习惯在纸上模拟一遍。比如 nums = [1, 3, 5, 6],target = 5,手推几步就会发现 mid 的变化过程。一旦你亲身走过一遍,闭区间和开区间就不只是代码模板,而是一套能推理的逻辑。
3. 移除元素:双指针不是炫技,是省内存
3.1 暴力解法先学会,才能理解优化的意义
LeetCode 27 移除元素:给一个数组 nums 和一个值 val,要求原地移除所有等于 val 的元素,并返回新长度。
最直接的想法是两层循环:外层遍历数组,遇到等于 val 的元素,就把后面所有元素往前移动一位。写成 C++ 大概是:
cpp复制int removeElement(vector<int>& nums, int val) {
int n = nums.size();
for (int i = 0; i < n; i++) {
if (nums[i] == val) {
for (int j = i + 1; j < n; j++) {
nums[j - 1] = nums[j];
}
i--;
n--;
}
}
return n;
}
这个写法能通过,但时间复杂度是 O(n^2),最坏情况比如数组全是 val,每删一个元素都要搬动后面所有数据。而且 i-- 这个操作容易把人绕晕:删完元素后,当前位置变成了原来后面的元素,要继续从当前位置检查,所以必须 i--。很多人写错,就是忘了这一步。
暴力解法的价值在于让你直观感受到“数组删元素 = 覆盖”,而不是像链表一样摘除节点。但既然我们追求更好的解法,就必须引入双指针。
3.2 快慢指针的覆盖过程拆解
快慢指针的核心思路是:一个快指针 fast 负责遍历原数组,一个慢指针 slow 负责记录“下一个可以放入非 val 元素的位置”。
cpp复制int removeElement(vector<int>& nums, int val) {
int slow = 0;
for (int fast = 0; fast < nums.size(); fast++) {
if (nums[fast] != val) {
nums[slow] = nums[fast];
slow++;
}
}
return slow;
}
代码非常短,但理解起来需要一点想象力。fast 走到哪里,就是在检查原数组中“当前看到的元素”;slow 指向的位置,是“目前已经确定保留的元素末尾”。当 nums[fast] != val,说明这个元素要保留,就把它放到 slow 的位置,然后 slow 加一。当 nums[fast] == val,什么都不做,fast 继续往前走。
这个过程看起来很像是“快指针在前面找好东西,慢指针在后面等待放到正确位置”。所有等于 val 的元素会被后面的有效元素覆盖掉。最后 slow 的值就是新数组长度,并且数组前 slow 个元素就是保留后的结果。即使 [1, 2, 2, 3, 4] 这种连续重复元素,也能一次遍历解决,因为 slow 只在非 val 时才会前进。
这里有个很容易忽略的细节:题目只要求返回新长度,并不要求你清除数组尾部残留的元素。所以像 [0,1,2,2,3,0,4,2] 执行完后,nums 变成 [0,1,3,0,4,2,4,2],尾部还留着旧值,这没问题。LeetCode 的判题只检查返回长度内的内容,不理解这一点的人会在本地调试时以为自己写错了。
3.3 C++ 里 erase 和 remove 的坑
有些读者会问:我直接用 vector 的 erase 不行吗?比如这样:
cpp复制for (auto it = nums.begin(); it != nums.end(); ) {
if (*it == val) it = nums.erase(it);
else it++;
}
从结果上看确实能移除元素,但 erase 在 vector 中间删除一个元素时,会把后面所有元素都搬移,时间复杂度仍然是 O(n)。如果 val 出现 m 次,整体就是 O(n*m),和暴力法没有本质区别。而且 iterator 还可能失效,初学者在循环里边删除边遍历特别容易踩坑。
更常见的工程写法是 nums.erase(std::remove(nums.begin(), nums.end(), val), nums.end())。先通过 remove 把不等于 val 的元素挪到前面,返回一个新的逻辑末尾迭代器,再统一 erase 掉后面的部分。这个写法本质上也用了“覆盖”思想,和快慢指针一模一样。
所以我建议刷题时老老实实写双指针。它不仅能让你避开 erase 的效率问题,还能让你把“原地操作”这件事理解得更透,以后遇到“移动零”“删除重复项”这类变体题,你会直接想到同一个模型。
4. 有序数组的平方:正负两边夹出来的大数
4.1 为什么不能直接平方再排序
LeetCode 977 有序数组的平方:给定一个按非递减顺序排序的整数数组 nums,返回每个数字的平方组成的新数组,要求也按非递减顺序排序。
不少人的第一反应是:
cpp复制for (int i = 0; i < nums.size(); i++) nums[i] = nums[i] * nums[i];
sort(nums.begin(), nums.end());
return nums;
这个逻辑完全正确,结果也对,但时间复杂度是 O(n log n)。如果数组里全是负数或者全是正数倒还好,偏偏数组里有正有负时,平方后的大小顺序会被打乱,比如 [-4, -1, 0, 3, 10] 平方后变成 [16, 1, 0, 9, 100],原来的顺序完全没法直接利用。
那么问题来了:有有序数组这个条件,能不能做得比排序更快?答案是能,而且只需要 O(n)。关键在观察负数平方和正数平方的分布。
4.2 从后往前填,大数先落位
有序数组平方后的最大值,一定来自数组两端之一。因为负数的绝对值越大,平方越大;正数同理,越靠右的平方也越大。所以最左边的负数平方和最右边的正数平方,共同构成了整个平方数组的“最大值候选者”。
于是我们可以用两个指针 left 和 right,分别指向数组两端,每次比较 nums[left] 的平方和 nums[right] 的平方,谁大就把谁放到新数组的末尾。填完末尾后,对应指针向中间移动,继续比较。
cpp复制vector<int> sortedSquares(vector<int>& nums) {
int n = nums.size();
vector<int> ans(n);
int left = 0;
int right = n - 1;
int pos = n - 1;
while (left <= right) {
int leftSquare = nums[left] * nums[left];
int rightSquare = nums[right] * nums[right];
if (leftSquare > rightSquare) {
ans[pos--] = leftSquare;
left++;
} else {
ans[pos--] = rightSquare;
right--;
}
}
return ans;
}
这里最核心的设计是“从后往前填”。为什么要从后往前,而不是从前往后?因为平方后的最大值一定在两端的某一边,如果从前往后填,你需要先确定最小值,而最小值既可能在中间靠近 0 的位置,也可能在某个绝对值较小的负数那里,不好找。从后往前填,每次都能确定当前剩余元素中的最大值,直接把最大值放到 pos 指向的位置,逻辑非常自然。
4.3 为什么新开一个数组通常更好
你可能会想:能不能原地操作,不申请额外空间?理论上可以,但会非常麻烦。因为平方后会覆盖原数组,而后面比较时你还需要用到原来的 nums[left] 和 nums[right]。如果直接把 nums[left] 平方后覆盖,下一步比较时原值已经丢了,除非你用额外变量暂存,但这样代码可读性会很差。
算法里“空间换时间”是很常见的选择。这道题申请一个等长的 ans 数组,空间复杂度 O(n),时间复杂度 O(n),已经是足够好的答案。整天纠结原地不原地,反而容易让自己陷入无谓的复杂度。
实用心得:我在做这道题时,经常有人问“如果 left == right 时要不要进循环”。答案是必须进,因为还剩最后一个元素要填进 ans[0]。如果你写成 left < right,最后这个元素会漏掉,ans[0] 没被赋值,输出就少一个数。这个细节和二分查找里的 left <= right 是同一个道理。
5. 数组操作里最容易被忽略的五个细节
5.1 mid 计算和整数溢出
二分查找中最经典的问题就是 (left + right) / 2 可能会溢出。虽然 LeetCode 题目给出的数组长度没那么大,但你要知道 left + right 本身在 int 范围内可能越界。比如 left = 2^31 - 2, right = 2^31 - 1,加起来远超 int 上限,结果变成一个负数,mid 自然错得离谱。
正确写法 left + (right - left) / 2 的本质是避免先把两个端点相加。你还可以用右移 left + ((right - left) >> 1),但我觉得加减乘除的写法更直白,不容易产生运算符优先级问题。这是工程素养,不只是刷题技巧。
5.2 快慢指针的越界与空数组保护
移除元素和有序数组平方都涉及指针移动,空数组、单个元素数组是最容易出问题的边界。比如空数组 nums.size() 为 0,在 int right = nums.size() - 1 这种代码里 right 会变成 -1,如果没做保护,while 循环一进去就读取 nums[right],直接越界。
所以在写这类题时,我习惯先考虑空数组和长度 1 的数组。二分查找里可以加一句:
cpp复制if (nums.empty()) return -1;
移除元素的双指针则一般不用特殊保护,因为 fast 从 0 开始,空数组直接进不了循环,slow 返回 0 也合理。但如果你换一种写法,比如用 while (fast < n) 且 n 是初始 length,就要小心 n 变化后对 fast 的影响。
5.3 返回长度不等于数组真实长度
移除元素返回的是“有效长度”,不是 vector 的 size。很多初学者以为返回 slow 后,调用方会得到一个长度变短的数组,其实不会。原数组长度没变,只是前 slow 个元素是有效内容。LeetCode 判题时只看你返回长度内的内容,所以尾部残留元素不影响结果。
同样的道理也适用于 C 语言里的字符数组清空。很多人问“buffer 数组怎么清空”,如果你只是把 buffer[0] = '\0',那只是把字符串结束符放到了开头,后面内容还在内存里;如果你需要的是把整个缓冲区置为 0,应该用 memset(buffer, 0, sizeof(buffer)) 或者循环赋值。这两种“清空”的含义不同,取决于你想要的是逻辑清空还是物理清空。
5.4 二维数组和指针数组的坑
代码随想录 Day1 虽然以基础一维数组为主,但既然说到数组,就必须提一下二维数组和指针数组的区别,因为后面刷题会频繁遇到。int a[3][4] 在内存中是连续的 12 个 int,访问 a[i][j] 在 C++ 里会被解释成 *(a[i] + j)。而 vector<vector<int>> 则不一定连续,每一行是独立的 vector,行与行之间可能分散在不同内存地址。
还有一种经典混淆:int *p[4] 和 int (*p)[4]。前者是指针数组,里面有 4 个 int 指针;后者是数组指针,p 指向一个包含 4 个 int 的数组。Day1 不必深挖所有指针细节,但至少要建立“二维数组名 + 下标 = 指针偏移计算”的认知,不然以后刷矩阵题时很容易晕。
5.5 字符串数组和动态数组的操作差异
C++ 里字符串数组初始化一般写成 string strs[] = {"one", "two", "three"} 或 vector<string> strs = {"one", "two", "three"}。前者在栈上拥有固定容量,后者可以动态增长。刷题时我更推荐用 vector,因为它提供 size()、push_back、erase 等接口,能避免手动管理长度。
而在 JS 或 Python 中,数组更像“动态数组”,增删方法和 C++ 差别很大。比如 JS 可以用扩展运算符 [...arr1, ...arr2] 合并数组,Python 可以用列表推导式生成新数组。核心算法思路一致,但语法层面各有取舍。刷题时最好固定一种语言把思路吃透,不要频繁切换,否则容易把边界条件搞混。
6. 刷完 Day1 后,我现在的做题习惯
说实话,数组 Day1 的每道题单独拎出来都不难,但放在一起刷完,你会发现自己对“边界”和“循环不变量”的敏感度明显提升。我现在拿到一道数组题,会先问自己三个问题:数组是有序的吗?结果需要保持原数组顺序吗?允许额外空间吗?这三个问题基本决定了该用二分、双指针还是直接新建数组。
做移除元素这类题时,我还会专门提醒自己:不要想着去“清除”被覆盖的位置,只要保证有效长度内的数据正确就够。这个思维在天真的初学者看来不太优雅,但其实非常实用。真实工程里我们往往也不去真的释放每一块内存,而是通过维护有效边界来节约性能。
最后再分享一个小技巧:每刷完一道数组题,我都会在本地把示例数组打印出来,模拟一遍指针变化过程。比如打印 fast、slow 每一步的值,或者打印 left、right、mid 的变化,这样比单纯看题解印象深得多。数组题最怕的就是凭感觉写,写着写着就忘了自己定义的区间到底是开还是闭。你只要把“循环不变量”这五个字记在脑子里,Day1 的任务就算真正完成了。
