代码随想录算法训练营第一天,题单是 704 二分查找、27 移除元素、977 有序数组的平方,外加一道标注了“可选”的加餐 34 在排序数组中查找元素的第一个和最后一个位置。我一开始觉得这四道题都是 LeetCode 经典题,难度看着也都不算高,真正坐下来写完一遍才发现,训练营第一天的安排很有心机:它其实不是让你一次学会四个孤立知识点,而是用同一套“区间收缩 + 指针移动”的思维,把数组题里最典型的两类解法串了一遍。如果你和我一样刚开始刷题,或者之前刷过但总是写得磕磕绊绊,这篇文章可以给你一条比较完整的复盘路线。
1. 第一天题单背后的设计逻辑
1.1 四道题其实是两族核心技巧
很多人第一天拿到题单,看到 704 和 34 就默认这是一组二分查找,看到 27 和 977 就默认这是一组双指针,这样理解不算错,但有点浪费题目。
我实际把四道题做完后,更愿意这样分:
- 704 和 34 是一族,核心是“二分查找算法”的边界控制,只是 34 把目标值变成一个区间,要求你同时会找左边界和右边界。
- 27 和 977 是一族,核心是“双指针”,但方向相反:27 是快慢指针从同一侧往前走,977 是对撞指针从数组两端往中间走。
第一天就设计成这样,本质上是想让你意识到:刷题不是背题,而是训练一种“在不同题目里识别同一套模式”的能力。比如 704 的while (left <= right)一旦理解透,34 的两个 helper 函数基本就是复制粘贴再改一下比较符号;27 的快慢指针一动,977 的左右指针也不难理解。
1.2 难度阶梯与时间预期
我按自己真实做题的速度估算了一下,大家可以对号入座:
| 题号 | 题目 | 难度 | 核心方法 | 我第一次完成耗时 |
|---|---|---|---|---|
| 704 | 二分查找 | 简单 | 二分法、区间收缩 | 约 15 分钟 |
| 27 | 移除元素 | 简单 | 快慢双指针,原地覆盖 | 约 10 分钟 |
| 977 | 有序数组的平方 | 简单 | 首尾双指针,反向填充 | 约 20 分钟 |
| 34 | 查找元素首末位置 | 中等 | 两次二分法找边界 | 约 40 分钟 |
如果你 704 没超过 20 分钟,说明基本二分框架已经掌握了,接下来重点看 34 就行;如果 27 一开始写成了双重循环,那也别慌,正常,暴力解法是大多数人的第一反应,后面我会专门拆解为什么不推荐。
1.3 我推荐的执行顺序
官方题单看起来是按 704 → 27 → 977 → 34 的顺序,可我实际做下来,建议你先写 704,然后跳过 34,先把 27 和 977 做掉,回头再看 34。
为什么不按顺序一口气做完 704 直接做 34?因为 34 不是 704 的简单升级,它牵扯到“左边界”和“右边界”两个子问题,新手容易卡住。你刚学完标准二分就马上去写边界二分,会有一个常见心理现象:明明看懂了,一动手又回到while (left <= right)的默认写法,然后对着样例输出怎么都不对。先从 27 和 977 换一下脑子,让“收缩思想”沉淀一下,再回来啃 34,效率更高。
所以我的顺序是:704 → 27 → 977 → 34。如果时间实在紧张,34 可以先只看思路,等二刷再写完整代码。
2. 704 二分查找:最基础也最容易写错的一题
2.1 二分查找的前提条件
704 的题目很标准:给定一个n个元素有序的升序整型数组nums和一个目标值target,写一个函数搜索nums中的target,如果下标存在就返回它的下标,否则返回-1。
这里有个非常容易忽略的点:二分查找能成立的前提是“数组有序”。这句话背书都会说,但我见过不少人拿到一个没排序的数组就准备二分,甚至在面试里也犯这个错。
注意:数组必须有序,或者说必须满足“二分性”——即存在一个分界点,使得左侧元素都小于 target,右侧元素都大于等于 target。LeetCode 704 保证了输入有序,但真实业务中你可能需要对数据先排序,这是在使用二分查找算法时必须先确认的前提。
2.2 左闭右闭区间写法全拆解
704 最经典的写法是“左闭右闭”,也就是left和right都指向真实存在的数组下标。代码长这样:
int search(vector<int>& nums, int target) { int left = 0; int right = nums.size() - 1; while (left <= right) { int mid = left + ((right - left) >> 1); if (nums[mid] == target) { return mid; } else if (nums[mid] < target) { left = mid + 1; } else { right = mid - 1; } } return -1; }很多刚学二分的人会困惑:为什么left <= right,而不是left < right?
原因很简单,我们维护的是一个左闭右闭区间。当left == right时,区间里还有一个元素nums[left]没被检查,这个元素必须再进循环判断一次。如果写成left < right,循环一退出,那个唯一剩下的元素就被漏掉了。
同理,nums[mid] < target时,说明mid这个位置以及它左边的所有位置都不可能等于 target,所以下一步搜索区间变成[mid + 1, right],即left = mid + 1。当nums[mid] > target时,说明mid及右边都不可能了,区间变成[left, mid - 1]。
边界更新不是随便写的,它必须满足两个原则:
- 排除掉已经确定不可能的中位元素;
- 保证新区间仍然是合法区间。
2.3 mid 计算里的溢出细节
mid = (left + right) / 2这种写法在很多教科书里都有,但在 C++ 或 Java 里,当left和right都很大时,left + right可能整型溢出,导致 mid 变成负数。
所以更稳妥的写法是:
int mid = left + ((right - left) >> 1);这样变成先算区间长度再除以 2,再加到 left 上,数值范围安全得多。
如果你在 PTA 平台做 C 语言函数题,接口可能是int search(int* nums, int numsSize, int target),核心逻辑完全一样,只是把nums.size()换成numsSize而已。二分查找 C 语言版本同样要注意left + right溢出,这个习惯我从写 C 语言版本时就养成了。
3. 27 移除元素:双指针如何做到原地操作
3.1 原地删除问题不是真的删除
27 题的描述是:给你一个数组nums和一个值val,你需要原地移除所有数值等于val的元素,并返回移除后数组的新长度。不要使用额外的数组空间。
第一个需要想明白的点:数组的“删除”和链表不一样。数组删除一个元素,本质是把后面的元素整体往前挪一位,这在时间复杂度上是 O(n) 的操作。如果一次删多个,跑双层循环就是 O(n^2)。
但在算法题里,题目并没有要求你真正把多余的尾巴截掉。它验证结果的方式一般是:检查返回的新长度k以及数组的前k个元素。也就是说,你只要保证数组前k个位置不含 val,后面残留什么,题目根本不关心。
我刚开始刷题时总想着把数组“变短”,甚至想去调用erase或pop_back,其实这是理解偏了。
3.2 快慢指针解法执行过程
双指针解法的思路是:准备一个慢指针slow和一个快指针fast,快指针负责遍历整个数组,慢指针负责记录“下一个可以放非 val 元素的位置”。
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]; } } return slow; }用生活里的类比来理解就是:你在整理一排箱子,要求把写着 val 的箱子全部清走,但你又不能把它们扔到别处,只能在不额外占地方的情况下腾位置。slow 就像是“整理完成区的边界”,fast 则像一个扫描器,一格一格往前推。遇到不需要搬走的箱子,就把它放到 slow 的位置,然后整理完成区扩大一格;遇到 val 就直接跳过。
用一个简单例子走一遍:nums = [3, 2, 2, 3], val = 3。
| fast | fast 指向的值 | 操作 | slow 变化 | 数组内容变化 |
|---|---|---|---|---|
| 0 | 3 | 等于 val,跳过 | 0 | [3,2,2,3] |
| 1 | 2 | 不等于 val,nums[0] = nums[1] | 1 | [2,2,2,3] |
| 2 | 2 | 不等于 val,nums[1] = nums[2] | 2 | [2,2,2,3] |
| 3 | 3 | 等于 val,跳过 | 2 | [2,2,2,3] |
最后返回 slow = 2,数组前两个元素是 [2, 2],完全正确。后面的 2 和 3 都是残留,不影响结果。
3.3 最容易犯的三个错误
第一个常见错误是把slow的更新放在if外面,也就是不管 fast 指向什么,slow 都自增。这样最后返回的 k 会等于数组长度,等于没有移除任何东西。
第二个常见错误是写反变量:有人会把赋值写成nums[fast] = nums[slow],这会把尚未扫描的元素覆盖掉,完全破坏后续判断。
第三个常见错误是执着于“原地交换”,比如遇到 val 就和末尾交换,然后缩小区间。这种做法也能做,但代码更绕,而且要额外维护一个“末尾指针”。快慢指针方案是这类问题里更通用、更好解释的模板。
实操心得:如果你想把双指针练熟,建议在 IDE 里自己把这道题按上面表格的过程手推一遍。别嫌慢,推一次之后再写原题,基本能直接写对。
4. 977 有序数组的平方:排序不是唯一方案
4.1 为什么不能简单平方后排序
977 的题目是:给你一个按非递减顺序排序的整数数组nums,返回每个数字的平方组成的新数组,要求也按非递减顺序排序。
看到这道题,大部分人第一反应是:
vector<int> sortedSquares(vector<int>& nums) { for (int& x : nums) { x = x * x; } sort(nums.begin(), nums.end()); return nums; }这个做法提交上去能通过,时间复杂度是 O(n log n)。但它有两个问题:第一,它没有利用原数组已经有序这个信息,属于“蛮力解法”;第二,这题出现在训练营第二天后的双指针背景下,几乎就是在暗示你应该写出 O(n) 的最优解。
为什么原数组有序也帮不上忙?因为数组中可能同时存在负数和非负数。[-5, -3, 0, 1, 4]平方后变成[25, 9, 0, 1, 16],顺序完全被打乱。如果直接平方后再排序,确实可行,但训练营的意图是让你发现另一条路:平方后的最大值一定在数组两端。
4.2 双指针从两端向中间填结果
道理很简单:一个数的平方大小,取决于它的绝对值大小。对一个有序数组来说,绝对值最大的元素,要么在最左边(如果负数很多),要么在最右边(如果正数很多),不可能出现在中间。
所以我们可以这样设计:
- 设置指针
left = 0,right = nums.size() - 1; - 设置一个结果数组
res,长度和原数组一样; - 用一个指针
pos从res末尾往前填; - 比较
nums[left]和nums[right]的平方大小,谁大就先填到res[pos],然后把对应指针往中间移动一位。
vector<int> sortedSquares(vector<int>& nums) { int n = nums.size(); vector<int> res(n); int left = 0, right = n - 1; int pos = n - 1; while (left <= right) { long long l2 = 1LL * nums[left] * nums[left]; long long r2 = 1LL * nums[right] * nums[right]; if (l2 > r2) { res[pos--] = l2; left++; } else { res[pos--] = r2; right--; } } return res; }这里为什么pos是从后往前填?因为大平方数一定比小平方数在结果里更靠后。如果你非要从前往后填,就得先找最小的平方数,那需要决定到底是左边的数还是右边的数更小,反而绕了。反过来填,每次取的都是当前剩余数中最大的平方数,位置天然正确。
用一个例子走一遍:nums = [-4, -1, 0, 3, 10]。
| 步骤 | left | right | left 平方 | right 平方 | 填入 res[pos] | pos 变化 |
|---|---|---|---|---|---|---|
| 1 | 0 | 4 | 16 | 100 | 100 | 4 → 3 |
| 2 | 0 | 3 | 16 | 9 | 16 | 3 → 2 |
| 3 | 1 | 3 | 1 | 9 | 9 | 2 → 1 |
| 4 | 1 | 2 | 1 | 0 | 1 | 1 → 0 |
| 5 | 2 | 2 | 0 | 0 | 0 | 0 → -1 |
最后 res 是 [0, 1, 9, 16, 100],正好有序。
4.3 溢出防不胜防
这道题隐藏的坑在溢出。LeetCode 的数组元素范围是[-10^5, 10^5],平方后最大值是10^10,明显超过 32 位 int 能表示的2^31 - 1 ≈ 2.1 * 10^9。
如果你在 C++ 里直接写int l2 = nums[left] * nums[left],一旦nums[left]是 100000,这个乘法已经溢出了,连sort版本都会出问题。
处理方式有两种:
- 用
long long接收平方结果; - 先比较绝对值:
if (abs(nums[left]) > abs(nums[right])),这样避免直接算平方。
我个人更喜欢用long long接收平方,因为题目后面很多题会牵扯到乘法,把“乘法可能溢出”这个意识练成肌肉记忆更重要。
5. 加餐 34:在排序数组中查找元素的第一个和最后一个位置
5.1 为什么第一天的加餐是这道题
34 题问题很经典:给定一个按照升序排列的整数数组nums和一个目标值target,找出给定目标值在数组中的开始位置和结束位置。如果不存在,返回[-1, -1]。
如果在第一天直接把 34 作为主线题,很多人会觉得很挫败。但你如果已经写过 704,再来看 34,会发现它并不是新题,而是同一个二分查找函数,只是把问题分成两层:找左边界,找右边界。
这其实就是“代码随想录算法训练营”这种题单安排里最值得学的东西——加餐题往往是用来拔高和串联的,它逼你多走一步,而不是停留在“我会写标准二分”的舒适区。
5.2 用两次二分查两边界的模板
我的写法是拆成两个函数,一个找第一个大于等于 target 的位置,一个找最后一个小于等于 target 的位置。这个模板的好处是逻辑直观,容易调试:
int lowerBound(vector<int>& nums, int target) { int left = 0, right = nums.size() - 1; int ans = -1; while (left <= right) { int mid = left + ((right - left) >> 1); if (nums[mid] >= target) { ans = mid; right = mid - 1; } else { left = mid + 1; } } return ans; } int upperBound(vector<int>& nums, int target) { int left = 0, right = nums.size() - 1; int ans = -1; while (left <= right) { int mid = left + ((right - left) >> 1); if (nums[mid] <= target) { ans = mid; left = mid + 1; } else { right = mid - 1; } } return ans; } vector<int> searchRange(vector<int>& nums, int target) { int left = lowerBound(nums, target); int right = upperBound(nums, target); if (left == -1 || right == -1 || left > right) { return {-1, -1}; } return {left, right}; }注意 lowerBound 和 704 标准二分的区别:当nums[mid] == target时,标准二分直接 return mid,但这里不能 return,因为左边可能还有一样的值。正确做法是“记录当前答案,然后把搜索区间继续向左收缩”,也就是right = mid - 1。
upperBound 同理,等于 target 时记录答案,然后向右收缩,把左边界往右推。
一句话总结:标准二分找的是“有没有”,边界二分找的是“最左/最右”。
5.3 边界测试清单
刷题时死磕样例没有意义,关键是要自己会测这些边界:
| 测试场景 | 输入示例 | 预期结果 |
|---|---|---|
| 空数组 | [], target = 0 | [-1, -1] |
| 只有一个匹配 | [1,3], target = 2 | [-1, -1] |
| 数组中全是 target | [2,2,2], target = 2 | [0, 2] |
| target 在最左端 | [1,2,2,3], target = 1 | [0, 0] |
| target 在最右端 | [1,2,2,3], target = 3 | [3, 3] |
| target 不在数组中,但夹在中间 | [3,5], target = 4 | [-1, -1] |
最后一个场景特别容易出错。如果只写一个二分再往两边扩展,极端情况下会退化成 O(n),比如所有元素都是 target 时,往两边扩散会扫完整个数组,就失去二分意义了。所以 34 题最好的做法就是两次独立二分,时间复杂度稳定在 O(log n)。
6. 第一天的常见问题与调试实录
6.1 704:循环边界不对,导致死循环或漏结果
我见过最有代表性的错误写法是:
while (left < right) { ... }然后思考退出条件时就很纠结。比如nums = [1, 3, 5, 6], target = 5,如果用left < right,第一次 mid = 1,nums[1] = 3 < 5,left = mid + 1 = 2;第二次 mid = 2 + ((3 - 2) >> 1) = 2,nums[2] = 5,返回正确。看起来好像碰巧能过。
但换nums = [1, 3, 5, 6], target = 6试试,当 left = 2, right = 3 时,mid = 2,nums[2] = 5 < 6,left = 3;下次循环 left == right,循环直接退出,永远检查不到 nums[3] 这个元素。这就是边界没写对的典型症状。
我的建议是,初学者先无脑统一使用左闭右闭写法:while (left <= right)、left = mid + 1、right = mid - 1。把这个写法练到滚瓜烂熟,再去研究左边界的变体。
6.2 27:快慢指针的先后顺序弄反
有一种错误是先把 slow 赋值成 fast,再判断,结果把 val 也复制回去了。还有人在循环里让 slow 跟着 fast 一起走,这样等于没有慢指针,最后返回长度还是原来的长度。
调试时可以打印每一轮的 slow、fast、数组内容,一眼就能看出赋值逻辑有没有错。
6.3 977:结果数组顺序颠倒
有人会先把pos设为 0,然后每次都往res[0]填最小值,结果发现填错了。这个问题的根源是没有想清楚“大平方数放后面,小平方数放前面”的最终顺序。
我个人的习惯是从后往前填,因为这样可以让“比较当前最大数”的思维和“结果数组从后往前扩张”的思维保持一致。如果你硬要从前往后填,就需要改成每次比较“当前最小平方数”,代码会别扭很多。
6.4 34:只写一次二分然后左右扩散
这种写法在防御测试用例时非常脆弱。比如nums = [1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 3], target = 2,如果从某个 mid 位置往左右扩散,最坏情况下要遍历数组的一半以上的元素,复杂度从 O(log n) 退化到 O(n)。
所以我一再强调,34 的正解不是“找到一个位置后再扩展”,而是“用两个二分分别圈定左右边界”。区间答案由两个函数各自返回的位置共同确定,这样不管数组里有多少个重复元素,性能都稳定。
7. 第一天复盘心得
训练营第一天的题量并不大,但它对“思维惯性”的冲击比我预想的大。704 让我意识到自己平时写二分时总是凭记忆,而不是凭区间定义写代码;27 让我重新理解了“原地操作”在算法题里到底是什么意思;977 教会我“有序数组的平方最大值一定在两端”这个看起来简单但很容易忽略的性质;34 则是对二分边界的一种高强度训练。
有一个心得我特别想分享:第一天结束后,我给自己定了一条规矩,每道题写完不是立刻开下一题,而是先用文字把“这题的核心矛盾是什么”写在笔记里。比如 704 的核心矛盾是“区间怎么收缩”,977 的核心矛盾是“平方后的最大值在哪里”。写完后我发现,这些文字比代码本身更值钱,因为第二天看到 209 长度最小的子数组、59 螺旋矩阵这些题时,我还能凭借第一天的总结快速找到解法方向。
如果你也刚开始这个训练营,建议别急着追求一天刷很多新题,把第一天这四题吃透,尤其是 34 题,哪怕多花两小时也值。二分查找这个东西,你今天咬咬牙搞懂边界,后面很多题目都会一帆风顺;反过来,今天含含糊糊过去,之后遇到搜索插入位置、寻找峰值、旋转排序数组之类的问题,你会重新踩进同一个坑里。