☰
代码随想录训练营第一天:二分查找与双指针精讲复盘
2026/10/9 13:04:56 网站建设 项目流程

代码随想录算法训练营第一天,题单是 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]。

边界更新不是随便写的,它必须满足两个原则:

  1. 排除掉已经确定不可能的中位元素;
  2. 保证新区间仍然是合法区间。

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。

fastfast 指向的值操作slow 变化数组内容变化
03等于 val,跳过0[3,2,2,3]
12不等于 val,nums[0] = nums[1]1[2,2,2,3]
22不等于 val,nums[1] = nums[2]2[2,2,2,3]
33等于 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]。

步骤leftrightleft 平方right 平方填入 res[pos]pos 变化
104161001004 → 3
203169163 → 2
3131992 → 1
4121011 → 0
5220000 → -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 题,哪怕多花两小时也值。二分查找这个东西,你今天咬咬牙搞懂边界,后面很多题目都会一帆风顺;反过来,今天含含糊糊过去,之后遇到搜索插入位置、寻找峰值、旋转排序数组之类的问题,你会重新踩进同一个坑里。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询