1. 训练营第一天为什么安排这三道题1.1 三道题背后的知识点串联第一天进代码随想录算法训练营很多人第一反应是先截图打卡、问用什么语言、要不要装环境。但我建议先花十分钟把 704、27、977 这三道题当成一个整体来看。它们的编号不同、难度都偏入门但本质上是同一个知识体系的三个切面数组操作、区间维护和双指针思想。704 二分查找是很多人第一次真正接触 O(logN) 这个量级。它不是背模板的问题而是理解为什么区间不断减半会导致搜索次数只跟 log2(N) 有关。27 移除元素则是从值的角度操作数组开始引入快慢指针这一对黄金搭档。977 有序数组的平方同样用双指针但推进方向反过来了是从数组两端向中间靠拢。三题都围绕数组这一个容器却覆盖了三种完全不同的处理思路。代码随想录在题目编排上有一个很明显的特点它不是按题号顺序刷而是按知识点难度递进。第一天就把这三种最基础的数组操作串进去目的就是让初学者在第一天建立同一道题可以有多种解法、不同解法背后是不同的复杂度的意识。有了这个意识后面刷链表、哈希表、字符串思路会顺很多。1.2 适合谁来参考、怎么写最有效这三道题适合零基础、或者刷题断档很久想重新捡起来的人。我建议不要只盯着 AC 率看也不要急着看题解代码。第一天最有效的方式是先自己写一版能跑出来的代码哪怕时间复杂度是 O(N) 甚至 O(N logN)再去对照代码随想录的讲解优化。因为只有自己先写出了笨方法才能真正理解聪明方法省掉的到底是哪些操作。我的习惯是用 C 写主代码Python 看思路毕竟语言只是工具。正文里我会给出两种语言的参考实现。需要提醒一下如果你是刚开始刷题第一天不需要追求每题三种解法先把一种解法吃透、知道为什么选它就够了。2. 704 二分查找把 O(logN) 刻进肌肉记忆2.1 二分查找到底在做什么题目要求很直白给定一个升序数组 nums 和一个目标值 target找出 target 在数组中的下标不存在就返回 -1。暴力做法从左到右扫一遍最坏情况是数组长度为 N 且 target 在最后需要比较 N 次。二分查找不一样它每次通过中间值把待搜索区间对半切掉一次比较就能排除一半的元素。这个场景特别像查字典你打开一本 1000 页的字典找猫字不会从第一页翻起而是直接翻到中间发现中间是马字就知道猫应该在前面半本于是再对折一次。每翻一次候选页数就减半。数组里也是一样的逻辑前提是数组必须有序否则中间值跟目标比较这个判断就对排除区间没有指导意义。二分的核心是维护一个还可能有答案的区间。每轮循环我们只关注这个区间通过 mid 把区间切掉一半。代码本身很短难点全在边界上区间是左闭右闭 [left, right]还是左闭右开 [left, right)这决定了你的 while 条件、mid 归属和每次缩减区间时能不能 1、要不要 -1。2.2 左闭右闭和左闭右开到底差在哪代码随想录里最经典的一个对比就是这两种区间写法。左闭右闭表示 left 和 right 指向的元素都有可能在后续循环中被检查所以 while 条件必须写 left right。因为当 left right 时这个位置还没有被处理过是需要进入循环检查的。检查完 nums[mid] target 的话直接返回不相等就要把区间缩掉 midtarget 比 mid 小说明答案在左边right 要为 mid - 1target 比 mid 大说明答案在右边left 要为 mid 1。左闭右开则是 right 指向的元素不会被检查它只是一个哨兵因此 while 条件写 left right。当 left right 时区间里已经没有可检查的元素了循环可以停。缩区间的时候因为 right 天然不包含有效元素所以改 right mid 就行不需要 mid - 1而 left 既然还是闭区间就必须改成 mid 1 才能把已经检查过 mid 排除掉。我当年初学的时候老是把两者混着写结果要么死循环要么漏答案。后来找到一个记忆方法闭区间要右边界要-1开区间不要右边界直接取 mid。把这两句话背下来比现场推理快得多等熟练之后再回去理解本质。2.3 可抄作业的参考实现C 版本class Solution { public: int search(vectorint nums, int target) { int left 0, 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; } };Python 版本class Solution: def search(self, nums: List[int], target: int) - int: left, right 0, len(nums) - 1 while left right: mid left (right - left) // 2 if nums[mid] target: return mid elif nums[mid] target: left mid 1 else: right mid - 1 return -1两个版本几乎一模一样只是语法差异。细心的朋友会注意到我算 mid 用的是 left (right - left) / 2而不是 (left right) / 2。这是因为 left right 在极端情况下可能会超出 int 上限虽然刷题时数组长度一般不会那么大但养成这个习惯能避免很多隐蔽 bug。这也是面试官很喜欢追问的一个细节。2.4 第一天最该背住的三个边界结论第一必须有区间定义一致的意识。你选了左闭右闭那 left 和 right 的每一次变化都要符合这个定义不能一会儿 left mid 1一会儿又 left mid这样逻辑会乱。第二mid 位置在比较完大小之后绝对不能留在下一轮区间里。这个很多人会忘。拿左闭右闭举例当 nums[mid] target 时答案只可能在中点的右边mid 本身已经被比过了所以 left 至少是 mid 1。如果你写成 left mid下一轮区间比之前只缩了不到一半极端情况下 left 永远不前进直接死循环。第三循环结束没返回就说明整个区间已经被搜空了。此时返回 -1不用怀疑是不是边界漏了。只要边界写法符合区间定义这个 -1 就是正确答案。3. 27 移除元素快慢指针的破冰题3.1 暴力解法为什么被嫌弃题目要求原地移除所有数值等于 val 的元素最后返回移除后数组的新长度。注意它不要求你把后面的元素真的清空也不要求你保持数组的物理长度不变只要求数组前 k 个元素是不等于 val 的元素并且顺序可以发生改变吗这套题目原题说可以改变顺序但代码随想录里主要讲的是保持顺序的快慢指针。两种都能过只是实现思路不一样。暴力解法是什么思路每找到一个等于 val 的元素就把后面的所有元素往前挪一位。这样做的复杂度是 O(N^2)第一层遍历数组找 val第二层移动元素。最坏场景比如数组全是 val那每一轮都要移动几乎整个数组。我在第一次写这道题的时候觉得暴力解法顺理成章提交之后发现勉强能过但一旦数组长度拉满就明显感觉到时间开销大。更重要的是暴力解法的移动整个数组本质上是在做很多次冗余赋值。既然题目只要求区间整体覆盖而不要求真的删除那就完全可以用指针来完成覆盖一次遍历就处理完。3.2 快慢指针为什么能一次遍历搞定快慢指针的核心思想是不删元素只把不等于 val 的元素一个个搬到数组前面。slow 指针指向下一个要放置合法元素的位置fast 指针负责遍历整个数组。具体流程slow 和 fast 都从 0 开始fast 快速往前跑。如果 nums[fast] 不等于 val说明它是一个有用的元素我们把 nums[fast] 赋值到 nums[slow]然后 slow 加一。如果 nums[fast] 等于 val说明它要被跳过slow 不动fast继续前进。等 fast 走到数组末尾所有合法的元素都已经被搬到前面slow 的值就是新数组长度。这个思路的巧妙之处在于我们没有真正删除任何元素只是把不需要的元素挡在数组末尾。覆盖的过程有点像写作业时把错题划掉再拿新内容贴在错题上面最后最上面的那部分就是干净的答案。3.3 参考实现与返回值的理解C 版本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; } };Python 版本class Solution: def removeElement(self, nums: List[int], val: int) - int: slow 0 for fast in range(len(nums)): if nums[fast] ! val: nums[slow] nums[fast] slow 1 return slow理解 return slow 的原理也很关键。slow 从 0 开始每放一个合法元素就自增所以最终 slow 的值就是合法元素的数量。比如数组 [3,2,2,3] 要移除 3第一次 fast0 时 nums[0] 等于 3跳过fast1 时 nums[1]2放进 nums[0]slow 变 1fast2 时 nums[2]2放进 nums[1]slow 变 2fast3 时 nums[3]3跳过。最后返回 2新数组前两位是 [2,2]完全正确。3.4 面试时容易被追问的三个点第一个点是为什么不用 erase。这是很常见的问题很多人一看到移除元素就想到 vector 的 erase。但实际上 erase 本身是 O(N) 的因为删除中间元素后要移动后续元素而且你在 for 循环里 erase 还会导致迭代器失效和下标的错乱。在这个题目场景下快慢指针用 O(N) 时间、O(1) 空间就完成了同样的效果还不用额外分配内存。第二个点是如果要求保持原数组顺序怎么办。快慢指针的方法天然保持元素之间的相对顺序因为 fast 是按原顺序扫描的slow 也是按原顺序放置。如果题目允许打乱顺序还有另一种双指针做法左边找等于 val 的元素右边找不等于 val 的元素交换以后继续往里缩每一轮只交换一次但结果顺序会变。原题允许打乱顺序的场景下这种解法也能过但我更推荐快慢指针因为它更通用后面在链表、数组的很多题目里都能复用。第三个点是边界情况。val 如果等于 nums 里所有元素slow 最终为 0返回 0 就对了。val 如果根本不在数组里slow 会走到数组末尾返回原长度。这两种场景在测试用例里很容易出现写的时候要保证快慢指针逻辑不会越界。4. 977 有序数组的平方从两边大中间小想到双指针4.1 直接排序的问题出在哪题目把原数组先平方再排序最简单的方法自然是先对每个元素求平方然后用 sort 排序。C 里一行代码就能搞定Python 里也是先列表推导求平方再 sorted。这样时间复杂度的关键在排序一般是 O(N logN)。但数组本身是非递减的也就是说它原本就是有序的平方之后数据分布发生了规律性的改变我们可以利用这个规律做到 O(N)。为什么平方之后数据会有规律如果一个数绝对值较大它的平方就大绝对值较小平方就小。一个非递减数组负数在左边正数在右边绝对值的大致趋势是两头大、中间小。比如 [-5,-1,0,3,6]平方后是 [25,1,0,9,36]最大值确实在两端最小值在中间。既然最大值只可能出现在原数组两端那我们从两端各放一个指针每次比较两端平方的大小把大的那个放进新数组末尾然后向中间收缩就可以一步步从大到小填满新数组。整个过程每个元素只访问一次复杂度 O(N)。4.2 双指针法的完整推演详细走一遍 [-5,-1,0,3,6] 的流程。i 指向最左边元素 -5j 指向最右边元素 6k 指向新数组最后一个位置。第一步比较平方 25 和 3636 更大所以 result[4] 36j 向左移到 3。 第二步比较平方 25 和 925 更大result[3] 25i 向右移到 -1。 第三步比较平方 1 和 99 更大result[2] 9j 向左移到 0。 第四步比较平方 1 和 01 更大result[1] 1i 向右移到 0。 第五步此时 i j两个指针指向同一个元素 0循环条件如果写成 i j 就会把 0 也填进去result[0] 0。循环结束新数组是 [0,1,9,25,36]。这个例子说明一件事两个指针相遇的位置不一定非要在正中间它只是遍历结束的标志。边界条件写成 i j 而不是 i j是为了保证两个指针重叠时那个还没有被处理的元素也能被填进结果数组。如果你写成 i j就会漏掉正中间的那个元素。4.3 可参考的实现代码C 版本class Solution { public: vectorint sortedSquares(vectorint nums) { int n nums.size(); vectorint result(n); int i 0, j n - 1, k n - 1; while (i j) { int leftSquare nums[i] * nums[i]; int rightSquare nums[j] * nums[j]; if (leftSquare rightSquare) { result[k--] leftSquare; i; } else { result[k--] rightSquare; j--; } } return result; } };Python 版本class Solution: def sortedSquares(self, nums: List[int]) - List[int]: n len(nums) result [0] * n i, j, k 0, n - 1, n - 1 while i j: left_square nums[i] * nums[i] right_square nums[j] * nums[j] if left_square right_square: result[k] left_square i 1 else: result[k] right_square j - 1 k - 1 return result注意 result 数组是从最大位置开始往前填的。为什么不是从前往后因为从前往后需要选择当前两端更小的平方但小值在数组里的相对位置有很多种可能处理起来麻烦从后往前只需要比较两端谁更大思路清晰很多。这个小技巧在合并排序数组的类似题里也很常见。4.4 三道题是如何闭环的704 教会你有序数组配合二分查找可以加速搜索27 教会你用快慢指针原地操作数组977 教会你利用数组本身的顺序特征设计双指针。三题做完你会发现它们其实都在回答一个问题怎么在不浪费数组有序性的前提下减少遍历次数。到了这一步第一天的主线任务就算完成了。如果想练得更深建议把 34 题也做了。它不是训练营第一天必须完成的题但它其实是二分查找的升级版考察的是如何用一次二分定位左边界、再一次二分定位右边界。5. 附加题 34第一个和最后一个位置怎么一次命中5.1 为什么我说这道题值得提前做34 在力扣上的名字是在排序数组中查找元素的第一个和最后一个位置输入也是有序数组和一个 target返回的是下标区间找不到就返回 [-1,-1]。这道题表面上是 704 的姊妹题但难度高了一个级别因为它不再问target 在不在而是问target 出现的最左位置和最右位置。如果还用普通二分找到任意一个等于 target 的位置之后你并不知道它是第一个还是最后一个得往左往右扩展。最坏情况比如数组全是 target扩展一步扫描就退化成了 O(N)。所以正规解法是写两次二分一次找第一个不小于 target 的位置一次找第一个大于 target 的位置。理清楚了之后你其实是把二分从单点查找升级成了边界查找这为后续刷 35、69 这类题目打下了很好的基础。代码随想录里习惯把这类问题归为二分法的变式第一周做它不算超纲。5.2 两次二分查找的思路拆解第一次二分找第一个 target 的位置。也就是说我们要在数组中找最左边的索引使得 nums[index] target。判断的时机是如果 nums[mid] target说明答案可能在 mid 或者更左边记录 mid然后把右边界缩到 mid - 1。如果 nums[mid] target说明答案在更右边让 left mid 1。循环结束后变量 left 保存的就是第一个 target 的位置。如果这个位置越界或者 nums[left] ! target那说明数组中根本没有 target直接返回 [-1,-1]。第二次二分找第一个 target 的位置。逻辑类似只是判断条件改成 nums[mid] target。结束后right 边界指向的 left 减一就是最后一个等于 target 的位置。如果左边界 right 都能找到那么第一个位置就是 left最后一个位置就是 right - 1。很多人容易在这里搞混第二次二分找的是大于 target 的第一个位置它其实是最后一个等于 target 的位置 1所以最后要记得减一。5.3 参考实现与调试记录C 版本class Solution { public: vectorint searchRange(vectorint nums, int target) { int left searchLeft(nums, target); int right searchRight(nums, target); if (left -1 || right -1) return {-1, -1}; return {left, right}; } int searchLeft(vectorint nums, int target) { int l 0, r nums.size() - 1; int ans -1; while (l r) { int mid l (r - l) / 2; if (nums[mid] target) { ans mid; r mid - 1; } else { l mid 1; } } return ans; } int searchRight(vectorint nums, int target) { int l 0, r nums.size() - 1; int ans -1; while (l r) { int mid l (r - l) / 2; if (nums[mid] target) { ans mid; l mid 1; } else { r mid - 1; } } return ans; } };Python 版本class Solution: def searchRange(self, nums: List[int], target: int) - List[int]: left_idx self.find_left(nums, target) right_idx self.find_right(nums, target) if left_idx -1 or right_idx -1: return [-1, -1] return [left_idx, right_idx] def find_left(self, nums, target): left, right 0, len(nums) - 1 ans -1 while left right: mid left (right - left) // 2 if nums[mid] target: ans mid right mid - 1 else: left mid 1 return ans def find_right(self, nums, target): left, right 0, len(nums) - 1 ans -1 while left right: mid left (right - left) // 2 if nums[mid] target: ans mid left mid 1 else: right mid - 1 return ans这段代码我自己在调试时踩过一个坑如果先求左边界并提前判断 nums[left] 是否等于 target再求右边界写法没问题。但如果你同时用 ans 变量记录边界最后判断 ans 是否为 -1 时要小心左边界的 ans 和右边界的 ans 可能一个为 -1 另一个不是。所以我在主函数里统一判断两个返回值只要有一个 -1 就返回 [-1,-1]逻辑更安全。5.4 高频踩坑死循环和左右边界写反第一种高频坑是死循环。如果你搜索左边界时用的是 while (l r) 这样的开区间写法又没注意 l 和 r 的更新很容易陷入 l 一直不变。我的建议是第一天统一用左闭右闭写法写熟了再去尝试其他变体。第二种高频坑是边界写反。搜索第一个大于 target 的位置时有人会把条件写成 nums[mid] target这样求出来的是第一个小于等于 target 的位置最后算右边界时又没做修正结果自然不对。建议每次写完先跑三个用例target 在中间出现一次、连续出现多次、完全不存在基本就能测出边界问题。第三种经典问题是空数组。nums 为空时searchLeft 和 searchRight 都进不了循环ans 都是 -1返回 [-1,-1] 没问题。6. 第一天复盘常见问题与我的心得6.1 第一天最容易被问到的几个问题我把几个训练群里高频的问题整理成一张表方便对照自查问题原因解决方案704 死循环边界更新没排除 mid记住 mid 比较完必须被移出下一轮区间闭区间 right 要 mid-1704 溢出(left right) / 2 可能超 int写成 left (right - left) / 227 返回长度不对slow 没理解成下一个合法位置在每轮 fast 循环中模拟一遍 slow 变化27 用了 erase误以为要真正删除元素快慢指针覆盖即可不需要改物理长度977 结果顺序反了没想清楚为什么要从后往前填最大平方一定在两端从后往前填每轮选最大977 漏掉中间元素循环条件写 i j改成 i j34 找不到 target 却返回非 -1左/右边界没判空返回前检查索引是否越界、nums 该位置是否等于 target这些问题的共同根源都是对区间定义没形成习惯。704 是区间27 是快慢指针的移动边界977 是双指针相遇条件34 是两次二分的边界维护。第一天如果把区间定义一致这件事想通了后面有问题会少一大半。6.2 我踩过几次坑之后的实操心得先说时间安排。第一天做四道题看起来不多但如果你是从零开始至少留出两小时。第一小时看题 自行尝试第二小时对照代码随想录题解整理边界写法。不要一开始就背代码背代码只是肌肉记忆不理解边界的话换个题目稍微变形就废了。再说语言选择。我个人建议主力语言用 C 或 Java 这类静态类型语言因为要对数组的区间、指针、迭代器有更明确的感知。Python 写起来太顺滑初学者容易跳过很多细节。比如 27 题里 Python 的 for fast in range(len(nums)) 和 C 的 for 循环几乎一致一旦你想着用 Python 的 remove 函数就会绕开题目真正想考的原地操作。最后分享一个小技巧写完每道题后在提交之前把所有边界用例单独列一遍。704 我常测 [1] 和 [1,3,5,6]27 测目标值在头尾和不在数组里977 测全负数、全正数、正负混合34 测空数组、target 只出现一次、target 出现多次。这套习惯第一周坚持下来你会发现很多 AC 不是因为运气而是因为边界早就被想到了。第一天最让我有收获的不是 AC 了四道题而是我开始反复追问为什么这个边界要这样写。从 704 的 while 条件到 977 的从后往前填再到 34 的两次二分每道题背后都有一条清晰的逻辑链。这是一套值得慢慢建立的思维框架比今天多刷十道简单题要有用得多。