
1. 三道题为什么值得第一天就练1.1 它们是数组技巧的“最小闭环”代码随想录训练营的第一天安排的是 704. 二分查找、27. 移除元素、977. 有序数组的平方 这三道题。我不止一次在私下和读者交流时说过第一天这几道题选得相当巧妙——它们看上去各自独立实际上串起来刚好是数组操作的一条主线有序性、原地修改、双指针。这三样东西基本就是算法面试里数组类问题的全部家底。先看考点分布。704 考的是二分查找核心是O(log n) 的搜索效率和边界处理27 考的是原地移除核心是快慢双指针的经典套路977 考的是有序数组平方后再排序最优解又是一个从两端向中间收拢的双指针。三题连在一起你其实在第一天就能摸到“双指针”这个高频套路的一半分支——同向移动的双指针、反向收缩的双指针全都见过了。很多新手容易犯一个认知错误以为算法训练营就是刷题打卡题目做完就完了。实际上这三道题真正的训练价值在于让你建立两个习惯拿到题先想“能不能用指针代替重复扫描”以及写循环之前先把区间定义想清楚。这两个习惯只要在第一天立住了后面学到哈希表、链表、滑动窗口都会轻松很多。1.2 原题到底长什么样如果你还没开始刷建议把这三道题在 LeetCode 上打开先把题目原文读一遍再往下看。我在这里把题意用比较直白的方式转述一遍方便先有个整体印象。704. 二分查找给定一个n个元素升序排列的整数数组nums和一个目标值target在数组里找target的下标。找到了返回下标找不到返回-1。要求时间复杂度是O(log n)。27. 移除元素给你一个数组nums和一个值val你需要原地移除所有数值等于val的元素并返回移除后数组的新长度。不要使用额外的数组空间元素的顺序可以改变你只需要保证新长度之前的元素都“不是 val”就行。977. 有序数组的平方给你一个按非递减顺序排序的整数数组nums返回每个数字的平方组成的新数组要求也按非递减顺序排序。举个例子输入[-4, -1, 0, 3, 10]输出应该是[0, 1, 9, 16, 100]。这三道题在 LeetCode 上的编号分别是 704、27、977难度都是“简单”或者接近“简单”。但简单题恰恰最容易看出基础扎不扎实——尤其是二分查找很多人刷了三五遍还是会跪在边界上。2. 704. 二分查找边界条件是二分法的灵魂2.1 为什么有序数组就能二分二分查找的底层逻辑其实就是一个生活常识猜数字游戏里对方说范围是 1 到 100你会先猜 50而不是从 1 开始一个个试。因为 50 能一次性把范围砍半最多只需要猜log2(100)次就能锁定答案。同理在有序数组里找一个数每次拿中间元素跟目标值比较如果中间值比目标值小那目标只可能出现在右半部分左半部分直接丢掉如果中间值比目标值大情况反过来。这样每比较一次搜索范围就缩小一半所以时间复杂度是O(log n)。关键前提有两个数组必须是有序的以及支持按下标随机访问。顺序存储的数组完美满足这两个条件所以二分查找天然和数组绑定在一起。链表的定位做不到O(1)按下标访问二分在链表上就没法直接用。2.2 两种区间写法左闭右闭 vs 左闭右开打开各种教程二分查找的写法五花八门其实核心就一个分歧点你维护的搜索区间到底包不包含右边界。while (left right)对应的是左闭右闭区间[left, right]while (left right)对应左闭右开区间[left, right)。我第一次认真整理这两种写法是反复在“数组只有一个元素时会不会死循环”这个坑里跌倒之后。先说我最常用的左闭右闭版本代码长这样class Solution { public: int search(vectorint nums, int target) { int left 0; int right nums.size() - 1; // 定义 right 指向最后一个有效下标 while (left right) { // 左闭右闭区间 [left, right] int mid left (right - left) / 2; if (nums[mid] target) { return mid; } else if (nums[mid] target) { left mid 1; // target 在右半区收缩左边界 } else { right mid - 1; // target 在左半区收缩右边界 } } return -1; } };如果改成左闭右开区间right初始值应该是nums.size()因为right本身不参与比较它仅仅是一个“右边界之后的第一次越界位置”。对应代码是int left 0; int right nums.size(); // 左闭右开区间 [left, right) while (left right) { // 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; }这两种写法复杂度相同但你必须选一个作为默认习惯不要每次凭感觉混用。为什么我强调这一点因为边界条件一旦定下来后面所有分支的收缩方式都必须自洽。左闭右闭时right mid - 1左闭右开时right mid。搞混一个符号轻则死循环重则漏答案。2.3 写二分最容易踩的三个坑第一个坑是mid 的计算溢出。老手可能觉得这是老生常谈但实际面试里真有人直接写(left right) / 2。如果left和right都接近 2^31 - 1相加会溢出成负数。稳妥写法是left (right - left) / 2这一步在 Java、C 里都非常必要。第二个坑是死循环。常见原因有两种一是区间为空时循环还没退出比如左闭右闭却写成while (left right)数组里只有一个元素时直接不进入循环二是更新边界时没老老实实mid 1/mid - 1导致某一轮left或right原地踏步。第三个坑是题目没看清。704 是精确查找返回下标题目说找不到返回 -1但有的二分变体是找“第一个大于等于 target 的位置”返回的就不是 -1 而是插入位置。记住题目要求什么就返回什么不要习惯性照抄模板。提示训练营第一天建议你手动模拟一遍二分查找的过程拿[1, 3, 5, 7, 9]找 5把每一轮left、right、mid的值写下来。模拟两轮之后边界条件就再也不是玄学了。3. 27. 移除元素快慢指针的第一次亲密接触3.1 为什么不能直接用 erase这道题在 LeetCode 上有个人人都会踩的经典大坑以为用 C 的vector直接erase删掉等于val的元素就行。暴力删法能过测试用例但完全不符合题目内核因为vector::erase的时间复杂度是 O(n)而且你每删除一个元素后面所有元素都要前移整体最坏能达到 O(n^2)。更重要的是面试官问这道题真正想看的是你能不能做到一趟扫描、原地修改。所以正确姿势是双指针而且是快慢指针。3.2 快慢指针原地覆盖原理快慢指针的思路非常朴素fast指针负责探路每轮往后走一格找到“不该被删掉”的元素。slow指针负责写入指向当前可以在原数组覆盖的位置。当fast指向的元素不等于val时我们把它复制到slow位置然后slow。当fast指向val时跳过它什么都不写。这样一轮走完slow恰好就是不等于val的元素个数而且数组前slow个位置正好是所有保留下来的元素。代码实现如下class Solution { public: int removeElement(vectorint nums, int val) { int slow 0; for (int fast 0; fast nums.size(); fast) { if (nums[fast] ! val) { nums[slow] nums[fast]; } } return slow; } };很多人第一次看这段代码会觉得“这也太简单了”。但简单背后有一个重要的思想用覆盖代替删除。这等于把“先删后补”的两步操作合并成了一趟遍历时间复杂度降到了 O(n)。这种思路在后面的很多题目里都会反复出现比如“移动零”“比较含退格的字符串”。3.3 顺手练几个变形题训练营复盘的时候我强烈建议你趁热打铁做几个同源变形题因为它们换汤不换药283. 移动零把数组中所有 0 移到末尾非零元素保持原有顺序。核心就是把“不等于 val”换成了“不等于 0”。26. 删除有序数组中的重复项双指针思想一样只是判断条件从“不等于指定 val”变成了“和上一个保留元素不相等”。844. 比较含退格的字符串这道题进阶一点需要从后往前处理但本质还是在模拟“删除”动作。做这三道变形题的时候你会发现模板一旦吃透完全不需要背题只需要改判断条件。这种“举一反三”的迁移能力才是刷题训练营真正想培养的东西。注意27 题还有一个细节点题目说“元素的顺序可以改变”所以如果只求数量还有一种交换删除的写法把要删除的元素直接和末尾元素交换然后减少长度。但训练营第一天的重点是把“快慢指针覆盖法”练熟因为这种写法适用范围更广遇到需要保持相对顺序的题目也不会抓瞎。4. 977. 有序数组的平方双指针从两端收拢4.1 暴力解法的问题在哪看到“有序数组的平方”很多人第一反应是先算出每个元素的平方然后排序。这种写法当然能通过测试代码大概长这样vectorint squares nums; for (int n : squares) n n * n; sort(squares.begin(), squares.end()); return squares;时间复杂度是O(n log n)问题出在你浪费了题目给的一个关键信息原数组本身是有序的。这个信息不用白不用能帮我们把复杂度压到 O(n)。但也有一个容易忽略的坑原数组有序不代表平方后有序。比如[-5, -3, -1, 2, 4]平方后变成[25, 9, 1, 4, 16]显然不是单调的。为什么会这样因为负数平方后会翻到正数区间越靠左的负数平方后可能越大。所以最值一定出现在两端而不是中间——这个观察正是双指针用法的来源。4.2 两端向中间的双指针写法最高效的思路是用一个新数组result然后用两个指针分别指向原数组的头和尾比较两个位置的平方大小把更大的那个放进result的末尾从后往前填然后移动对应的指针。因为原数组有序平方后的最大值只可能出现在最左端或最右端每次取走一个最大值剩下的区间依然保持同样的性质。class Solution { public: vectorint sortedSquares(vectorint nums) { int n nums.size(); vectorint result(n); int left 0; int right n - 1; int pos n - 1; while (left right) { int a nums[left] * nums[left]; int b nums[right] * nums[right]; if (a b) { result[pos--] a; left; } else { result[pos--] b; right--; } } return result; } };每次循环取走一个元素所以整体只需扫一遍时间复杂度O(n)空间复杂度O(n)用来存结果数组。这里有个细节值得停下来想清楚为什么要从后往前填result因为两个端点的平方是当前区间里最大的如果你从前往后填小值先进来后面大值就得插队根本没法一趟完成。从后往前填天然保证了结果数组的非递减顺序。4.3 这道题和 27 题的隐藏联系很多人刷完 977 会和 27 题割裂来看但我建议你把两道题放在一起思考。27 的快慢指针是同向运动——fast探路slow落位977 的双指针是相向运动——left从头部往中间走right从尾部往中间走。这两种双指针都是 O(n) 原地/半原地处理数组的高频套路后续很多题目都会用到。比如“三数之和”里就有一个指针从左边走、一个指针从右边走的过程“盛最多水的容器”也是两个指针从两端向中间逼近。把 977 练熟等于你提前预习了后面一大半双指针专题的思考方式。实操心得977 还有一个容易写错的地方是循环结束条件。有人写成left right结果在left right时漏掉最后一个元素。记住中间这个元素也要放进result所以循环必须是while (left right)。这道题我见过好几个刷了三遍的人还在这里翻车。5. 第一天刷完之后的复盘与进阶路线5.1 复盘该复盘什么训练营第一天通常要求打卡提交代码和心得体会但单纯“把题做出来”是不够的。我建议你至少复盘四个维度第一能否不看题解把每道题的解题思路用两句话说清楚。比如 704 就是“维护一个搜索区间每次拿中间值和目标比较缩小一半区间直到区间为空”27 是“慢指针指向保留位置快指针找不用删的元素扫描一遍覆盖上去”977 是“平方后最大的一定来自两端两个指针从两端向中间移动结果倒着填”。第二能否把边界条件的变化规律讲明白。704 是边界最敏感的一题你需要能解释为什么right mid - 1、为什么while (left right)。如果讲不明白说明还没真正理解。第三有没有尝试过多种写法。704 至少要有两种区间写法977 可以试试“先平方再排序”和“双指针”两种写法对比复杂度27 可以试试“覆盖法”和“交换法”两种实现。每种写法都跑一遍你才会真正理解它们各自的适用场景。第四代码风格是否规范。比如变量命名left/right/mid/slow/fast这些名字比l/r/m好读得多。面试手撕代码的时候变量名也是隐性评分点。5.2 第二、三天的衔接题目训练营是一个连续的学习周期第一天的双指针会在后面持续出现。如果你时间精力允许第二、三天可以接着刷这些题目保持手感35. 搜索插入位置704 的直接拓展在有序数组中找目标值的插入点。34. 在排序数组中查找元素的第一个和最后一个位置二分查找的升级版需要两次二分分别找左边界和右边界是 704 思路的进阶考核。15. 三数之和需要先排序再用双指针从两端往中间收缩和 977 的指针移动方式很接近。209. 长度最小的子数组滑动窗口的入门题本质也是双指针同向移动的变体。不建议一天刷太多算法能力的提升是“少量题 高频回顾”的结果不是“题海战术”的结果。第一天这三道题值得你每周回来看一遍尤其 704 的边界写法每次重新手写都会有新的理解。5.3 常见问题速查我在和读者交流训练营日常的过程中收集过几个出现频率极高的问题整理在这里问题现象原因解决办法704 运行超时或死循环边界更新没 1/-1统一使用一种区间定义按定义更新边界704 找不到 / -1 返回错误循环条件写错左闭右闭必须左闭右开必须27 返回长度正确但结果数组顺序不对覆盖逻辑中 slow 递增位置错误检查slow是否在赋值后执行27 用了 erase 导致超时复杂度太高改用快慢指针原地覆盖977 结果少了最后一个元素循环写了left right改为left right977 结果顺序反了从前往后填 result倒着从 result 末尾填还有一个经验之谈提交前先手推一个简单测试用例。比如 27 题拿[3, 2, 2, 3]删3977 拿[-4, -1, 0, 3, 10]704 拿[1, 3, 5, 7, 9]找 5。手推一遍能拦下至少一半的粗心错误。我个人在实际训练营带刷过程中的体会是第一天最重要的不是把题解背下来而是把**“数组有序性如何被利用”**这个思维模型刻进脑子里。二分查找利用的是有序性减少搜索范围977 利用的是有序性确定最值出现的位置27 则是用指针消除重复扫描。这三道题背后的模型吃透了你会发现后面很多所谓的中等题、困难题底层也不过就是这些基础招式的组合。第一天把地基打牢后面进度才能真正快起来。