开营第一天的三道题,很多人以为只是“熟悉语法”,实际上这是整个算法训练营里最不该轻视的一课。704.二分查找、27.移除元素、977.有序数组的平方,这三道题放在第一天,不是让你热热身就走的,而是逼你在第一天就把“数组遍历”这件事的底层思维理顺。我见过太多人后面写滑动窗口、写双指针、写前缀和时一脸懵,回头一看,根子就出在第一天这三道题只刷了个皮毛,没真正想明白边界和指针为什么这么动。
我在这篇打卡总结里,不打算只贴代码。我会把这三道题从头到尾拆开,包括每一处边界为什么这么写、每一种解法适合什么场景、我自己写的时候在哪个地方卡过壳,全部讲清楚。如果你今天正好开始刷代码随想录训练营,这篇文章可以直接当你的“开营避坑手册”用。
1. 开营第一天的三道题:看似基础,其实全是指针和边界的基本功
先说个整体感受。704、27、977这三题放到LeetCode里都是“简单”难度,但简单不代表没货。它们恰好覆盖了数组操作里最核心的三个能力:在有序序列里快速定位目标、在原数组上做空间复杂度O(1)的删除或覆盖、利用数组本身的单调性做变换后排序。
这三件事拆开看,分别是二分查找、快慢指针、双指针收缩。而它们的共同点,都是围绕“下标”和“区间”做文章。
我在第一天打卡时的感受是:这三题不是让你背代码,而是帮你建立一套“看到数组题,先想指针怎么走”的条件反射。比如二分查找,核心不是“取中间”,而是“每次循环之后,搜索区间到底是什么”;移除元素,核心不是“删掉”,而是“如何用一个指针覆盖另一个指针的值”;平方排序,核心不是“平方再排序”,而是“两端的数平方一定最大”。
这些思维模型,后面二刷三刷时会被反复用到。所以第一天千万别急着追求AC数量,把这三题吃透,比你把Easy题刷二十道都值。
另外说一句,训练营的打卡节奏是每天几道题加一篇总结。我的经验是,第一天千万别“赶进度式刷题”,因为后面每天都会在这个基础上叠加新的数据结构。第一天的地基如果打歪了,后面补起来很痛苦。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 704.二分查找:循环不变量才是真正的考点
2.1 为什么你的二分查找每次都在边界上翻车
二分查找的代码很短,短到很多新手认为“这不就是取个mid然后比大小嘛”。但一提交就错,而且是那种“看着哪都对、跑起来就死循环或漏目标”的错。
我第一天写这道题时,连续提交了三次,前两次都是边界问题。第一次是 while (left < right) 和 while (left <= right) 搞混,导致最后一个元素永远查不到。第二次是更新边界时写成 left = mid,结果当 left = 3, right = 4 时,mid 算出来还是3,死循环。
这些问题的根源只有一个:写二分之前,你没有先定义清楚“搜索区间”是什么。比如你定义成左闭右闭 [left, right],那 left == right 时区间里还有一个数,必须继续查;如果你定义成左闭右开 [left, right),那 left == right 时区间已经空了,循环就该结束。
这个“先定义区间,再写循环”的思维,就是专业术语里的“循环不变量”。只要区间定义一致,循环体里每一步操作都不破坏这个定义,代码就不会出错。
2.2 左闭右闭写法:我推荐的默认选择
这是我最推荐的写法,因为它最好理解,也最直观。目标数组是 nums,目标值是 target。
java复制public int search(int[] nums, int target) {
int left = 0;
int right = nums.length - 1; // 左闭右闭 [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; // target 在右半边,mid 已经查过了
} else {
right = mid - 1; // target 在左半边,mid 已经查过了
}
}
return -1;
}
几个关键点:
right = nums.length - 1,因为是闭区间,最后一个元素的下标要能取到。while (left <= right),因为当left == right时,区间[left, right]里还有一个数要判断。left = mid + 1和right = mid - 1,因为mid已经比较过了,新的区间必须排除它。
这里还有一个老生常谈的细节:mid 的计算。写成 (left + right) / 2 在极端情况下可能溢出,因为 left + right 超过 Integer.MAX_VALUE。虽然 LeetCode 普通测试用例几乎不会触发,但这是一个好习惯。建议一律写成 left + (right - left) / 2。
2.3 左闭右开写法:理解之后一样顺手
左闭右开的写法是很多源码里的风格,比如 C++ 的 STL 就用左闭右开。理解它有助于你以后读源码不吃力。
java复制public int search(int[] nums, int target) {
int left = 0;
int right = nums.length; // 左闭右开 [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; // target 在右半边,且 mid 已被排除
} else {
right = mid; // target 在左半边,区间右边保持开,所以不-1
}
}
return -1;
}
注意这里的两个差异:
- 初始
right = nums.length,不是length - 1,因为右边界是开的,取不到。 while (left < right),因为left == right时区间为空。- 更新时,
right = mid而不是mid - 1,因为right本身是不包含的,你把mid直接赋给right,相当于新区间[left, mid)天然把mid排除了。
我自己的感受是:刷题阶段用左闭右闭,读源码时用左闭右开。两边都写几遍,你对二分查找的理解才会真正完整。
2.4 我实测过的三个错误示例和对应的调试思路
很多新手看代码觉得“好像懂了”,一上手就懵。我给三个我实际调过的错误示例,你可以对照检查自己有没有犯过同样的错。
错误一:循环条件写成 left < right,但用的是左闭右闭区间初始化。
- 现象:数组
[5],查5,返回 -1。 - 原因:初始化
left = 0, right = 0,left < right不成立,循环直接跳过。 - 解决:要么把循环改成
<=,要么把初始化改成左闭右开。
错误二:left = mid 导致死循环。
- 现象:数组
[1, 3],查3时,某轮left = 0, right = 1, mid = 0,nums[0] < 3,于是left = 0,进入下一轮,left没变化,死循环。 - 原因:
mid已经查过不是目标,但更新时又把它包含进来了。 - 解决:一定要
mid + 1或mid - 1,把已经查过的位置排除出去。
错误三:right = mid - 1 写成了 right = mid,在左闭右闭写法里漏答案。
- 现象:数组
[1, 3, 5],查5,某轮区间变成[2, 2],但right被错误地更新为mid - 1,导致跳过目标。 - 原因:区间边界更新逻辑没和“闭区间”定义保持一致。
调试二分问题,我的建议是在纸上手动模拟一次,或者用 System.out.println(left + ", " + right) 打印每一步区间。你会发现绝大多数错误在两步之内就能暴露出来。
3. 27.移除元素:快慢指针背后其实是“覆盖思维”
3.1 为什么不能用erase或remove硬删
题目要求原地移除所有等于 val 的元素,并且返回新数组长度。很多新手第一反应是:直接调 ArrayList 的 remove() 不就行了?或者 C++ 里 erase 一下?
问题在于三点:
- 题目要求是原地操作,不能用额外数组。
erase/remove的时间复杂度是 O(n),而且它内部是通过把后续元素往前搬实现的。你可能会想,这不就是最终效果吗?问题是在线笔试和面试里,要考察的是你有没有意识到“删除”可以不用真的删,而是用覆盖。- 如果用了额外的容器或频繁调用
erase,空间复杂度或时间复杂度可能不达标。LeetCode 对这道题的特别说明就是“元素的顺序可以改变”,这句话其实在给你暗示:你不需要保持所有元素的原始相对顺序。
理解这一点很关键。这道题表面上叫“移除元素”,实际上考的是“如何高效地把不需要的元素干掉,并让剩下的元素紧凑排列”。
3.2 快慢指针的完整执行过程
快慢指针的写法是这道题的标准答案,也是后面很多“原地操作类”题目的基础。
java复制public int removeElement(int[] nums, int val) {
int slow = 0; // slow 指向下一个要填入的位置
for (int fast = 0; fast < nums.length; fast++) {
if (nums[fast] != val) {
nums[slow] = nums[fast];
slow++;
}
}
return slow;
}
执行过程用一个例子演示:
假设 nums = [3, 2, 2, 3], val = 3。
- 初始
slow = 0,fast = 0,nums[0] == 3,跳过,slow 不动。 fast = 1,nums[1] == 2 != 3,执行nums[0] = 2,slow = 1。fast = 2,nums[2] == 2 != 3,执行nums[1] = 2,slow = 2。fast = 3,nums[3] == 3,跳过。- 结束,返回
slow = 2。数组变成[2, 2, 2, 3],前两个元素是有效的新数组。
整个过程的本质是:fast 负责遍历所有元素,找出“不等于 val 的”,slow 负责记录“下一个可写入的位置”。每一个不等于 val 的元素都会被搬到数组前面,等于 val 的元素自然就被覆盖掉了。
这个思路为什么空间复杂度是 O(1)?因为我们没有开新数组,只是原地覆盖。时间复杂度为什么是 O(n)?因为只遍历了一遍数组。
3.3 一个容易搞混的变体:头尾交换法
如果你去翻题解,会看到另一种双指针写法:一头一尾向中间靠拢。它的逻辑是:把等于 val 的元素和末尾的元素交换,然后缩短右边界。
java复制public int removeElement(int[] nums, int val) {
int left = 0;
int right = nums.length - 1;
while (left <= right) {
if (nums[left] == val) {
nums[left] = nums[right];
right--;
} else {
left++;
}
}
return left;
}
这个写法在某些场景下更快,因为它避免了不必要的复制——快慢指针会把每个不等于 val 的元素都往前搬一次,而头尾交换法只处理“需要被移除”的位置。
但是有个前提被很多人忽略:它改变了元素的相对顺序。如果题目要求保持相对顺序,这个解法就是错的。LeetCode 原题明确说顺序可以改变,所以这个解法是可以过的。但某些变种题,比如“把0移到末尾并保持非零元素顺序”,就必须用快慢指针,不能用头尾交换。
我在第一天就踩过这个坑。一开始图省事用了头尾交换法,然后去做了后面一道保持顺序的同类题,发现自己顺序乱了,回去一查才发现是自己没注意题目里的顺序要求。强烈建议:默认先掌握快慢指针,因为它的通用性更强。
3.4 关于“慢指针快指针”的扩展联想
移除元素是快慢指针最基础的形态。后面你遇到“删除排序数组中的重复项”“移动零”“最长连续递增序列”时,会发现它们本质上是同一个模型:
- 快指针找到“满足某种条件的元素”。
- 慢指针指向“下一个应该被填上的位置”。
比如“移动零”就是把 val 换成 0,然后让慢指针不断写入非零元素,最后再补零。理解了移除元素,这道题基本就是改一行代码的事。
所以说,第一天这道题别看它简单,它几乎是后面所有“原地数组操作题”的万能底座。我建议大家刷完这道题后,立刻去把“移动零”和“删除排序数组中的重复项”也顺手做一下,会很有感触。
4. 977.有序数组的平方:负数让问题变得远比想象中有趣
4.1 最直接的思路为什么不够好
看到这道题,90%的人第一反应是:先每个元素平方,然后排序。代码写出来确实很干净:
java复制public int[] sortedSquares(int[] nums) {
int n = nums.length;
int[] ans = new int[n];
for (int i = 0; i < n; i++) {
ans[i] = nums[i] * nums[i];
}
Arrays.sort(ans);
return ans;
}
但注意,这个解法的时间复杂度是 O(n log n),因为最后的排序是瓶颈。题目给的数组原本是非递减的,平方之后虽然不单调,但它有一个非常强的特性:最大平方数一定出现在两端。既然有这个特性,就有机会把复杂度降到 O(n)。
为什么说这个问题“远比想象中有趣”?因为负数的存在把问题从“无脑排序”变成了“如何利用原数组的单调性”。
4.2 双指针从两端向中间收缩的原理
原数组 nums 本身是升序的,比如 [-4, -1, 0, 3, 10],从左到右是递增。平方之后,左侧负数的平方可能很大(因为绝对值大),右侧正数的平方也可能很大。换句话说,越往外,平方后的值越大。所以最大值一定在两端,第二大值一定在去掉端点后的两端……这不就是天然的双指针收缩场景吗?
我们维护两个指针,一个指向数组头,一个指向数组尾,比较两个指针对应元素的平方,谁大就放到结果数组的末尾,然后移动对应的指针。
java复制public int[] sortedSquares(int[] nums) {
int n = nums.length;
int[] ans = new int[n];
int left = 0;
int right = n - 1;
int index = n - 1; // 从结果数组末尾往前填
while (left <= right) {
int leftSquare = nums[left] * nums[left];
int rightSquare = nums[right] * nums[right];
if (leftSquare > rightSquare) {
ans[index] = leftSquare;
left++;
} else {
ans[index] = rightSquare;
right--;
}
index--;
}
return ans;
}
我用 [-4, -1, 0, 3, 10] 走一遍流程:
- 初始
left = 0, right = 4, index = 4,比较 16 和 100,100 更大,ans[4] = 100,right = 3。 - 比较 16 和 9,16 更大,
ans[3] = 16,left = 1。 - 比较 1 和 9,9 更大,
ans[2] = 9,right = 2。 - 比较 1 和 0,1 更大,
ans[1] = 1,left = 2。 left == right时,比较 0 和 0,ans[0] = 0,循环结束。
最终 ans = [0, 1, 9, 16, 100],正确。整个过程只用了一次遍历,时间复杂度 O(n),空间复杂度 O(n)(结果数组本身也是答案要求)。
4.3 几个容易忽略的小细节
首先是等号问题。当左右平方相等时,随便选一边放进结果数组都可以。我习惯用 >=,这样左边优先。这不是关键,但保持一致的写法有助于减少心智负担。
其次,为什么结果数组要从末尾往前填?因为每次我们找到的是当前区间的最大值,它应该占据结果数组靠后的位置。如果你从前往后填,那就要求每次找当前最小值,不容易直接利用两端最大的特性。反向填充是这个思路最自然的实现方式。
再次,边界条件:nums 只有一个元素时,left = right,while 循环里把唯一的平方放进结果,left 和 right 同时更新出界,循环自然结束,不需要额外判断。
最后,这道题的原数组是“非递减”,也就是说可能有重复元素,也可能全负数。全负数时,比如 [-5, -2, -1],原数组从左到右绝对值是递减的,所以平方后最大的在左端,最小的在右端。双指针依然正常工作,因为算法不关心绝对值分布,只比较两端平方的大小。
4.4 我为什么说这道题值得做三遍
第一遍,用最粗暴的平方加排序,AC之后你觉得“就这”。第二遍,你试着理解双指针反向填充的巧妙之处,手动模拟一遍过程。第三遍,你闭上眼睛,试着从“原数组有序”这个条件推导出“最大值在两端”的结论,然后自己写出代码。
三遍之后,你会形成一种感觉:题目里给的“有序”条件,往往不是让你直接排序,而是暗示你可以利用单调性优化。这个感觉在后面的“合并两个有序数组”“三数之和”“接雨水”里都会用到。
5. 一天的打卡复盘:三道题串联出的数组操作核心框架
5.1 从三题中提炼的两个核心模型
如果第一天只让你记住两件事,那就是“循环不变量”和“双指针覆盖思想”。
704.二分查找 教你的是:在有序区间里,每一轮循环都要明确当前区间的定义,并保证通过更新边界后区间仍然满足这个定义。这是所有区间类题目(二分、滑动窗口、线段树的某些操作)的基础。
27.移除元素 教你的是:在数组原地操作时,可以用快慢指针实现 O(1) 空间的覆盖式删除。这是“原地操作类”题目的万能套路。
977.有序数组的平方 则把这两件事结合了:通过“有序数组两端最大值”这个单调特性,用双指针从两端向内收缩,在 O(n) 时间内完成排序。它既不是二分也不是快慢指针,但它的指针移动逻辑和前两道题是同一个思想:通过可控的指针移动,把问题规模每一步都缩小一点,同时保证状态正确。
这三道题恰好对应了数组操作中最常见的三个场景:查找、删除、变换。你第一天把它们学透,后面遇到的数据结构题,很多都是在这三个场景上叠加更复杂的数据结构而已。
5.2 训练营打卡的实操建议:我踩过的坑
代码随想录训练营的学习模式我不再赘述,重点说说我对“打卡”这个动作的体会。很多人把打卡当成“把代码贴上去”、然后看一下解析、标记“已刷”就完事了。但第一天之后你会发现,题目的难度爬坡比想象中快。到了二叉树、回溯、动态规划,如果你第一天的基础没打牢,听课都听得云里雾里。
我踩过的坑主要有三个,列出来供你参考:
-
只看题解不动手模拟。二分查找的边界错误,你用眼睛看是看不出来的。我强烈建议拿出纸笔,把“左闭右闭”和“左闭右开”各手动跑三组用例。第一天花半小时做这件事,后面你会节省无数次 debug 的时间。
-
不写自己的踩坑记录。训练营的打卡其实很适合记录自己的问题。比如“我在移除元素里用的头尾交换法,为什么题目要求保持原顺序时会错”,这种思考比刷完就忘有价值得多。
-
强行追求“最优解”而忽视最朴素的解法。我第一次写
977时直接写的平方加排序,AC 之后才去学双指针。我不觉得先写暴力有什么问题,能够正确运行的暴力解,是你理解更优解的最好起点。怕的是写出来暴力就结束,不思考优化空间。
5.3 一个延长学习效果的进阶玩法
如果你第一天还剩精力,我建议你把这三道题再做几个变体:
-
把
704的target改成“寻找左边界”和“寻找右边界”。这是二分查找最常见的变种,代码随想录后面在“35.搜索插入位置”和“34.在排序数组中查找元素的第一个和最后一个位置”里会用到。如果你第一天就把二分查找的边界初始化、更新规则吃透,后面会非常轻松。 -
把
27的val改成“0”,要求把所有非零元素保持原有顺序移到前面,这就是283.移动零。你能在 5 分钟内改出来,说明快慢指针是真的懂了。 -
把
977换成“合并两个有序数组”去思考。虽然这是后面88.合并两个有序数组的题,但它们的核心都是“利用有序性,从后往前或从前往后填结果”。
第一天刷三道题,看起来量不大,但如果你把上面的变体都吃了,你的收获会超过很多刷了二十道 Easy 题的人。我始终觉得,算法训练营的意义不在于“刷了多少道”,而在于“每道题有没有把背后的模型装进脑子里”。
最后说一个我亲测有效的习惯:每天打卡前,先把头一天的三道题重新默写一遍。不需要完整跑过测试用例,只需要默写核心代码结构。比如今天你能默写出三个题的框架,说明昨天是真的吸收了,而不是鼠标一划就算过。这个习惯可以帮助你在训练营前期就把基础打得非常扎实,到后面遇到综合性题目时,你会感谢第一天的自己。
