跟着代码随想录训练营做Day01的打卡,今天的内容正好落在数组part01。说实话,数组在我印象里一直是"最没存在感"的考点:不就是连续内存、下标从0开始的线性结构嘛?结果第一天就被704.二分查找和27.移除元素这两道题打脸了——代码一眼能看懂,自己动手写时,边界条件能绕得人怀疑人生。这篇文章不会照着题解复述一遍,而是把Day01真正值得消化吸收的东西拆开说说:数组底层的连续内存是怎么决定所有数组题目思路的,二分查找两种边界写法的差别和翻车点,移除元素的快慢指针到底在维护什么。顺手会把第一轮做题踩过的坑、调试时的排查思路整理出来,给刚开始刷算法、准备面试或者想把基础打牢的读者一份能直接落地的参考。
开始之前先说一句:训练营的伙伴里,有人第一次接触这些题,有人是面过几轮了还回来补基础。不管哪种,请务必亲手把代码敲一遍,再走一遍测试用例。看题解和自己写出来,差距约等于看菜谱和亲手做菜。
1. 数组理论基础:连续内存决定了数组的"行为习惯"
1.1 一张内存布局图胜过十句API描述
数组的一切特性,几乎都能从"连续内存"这个前提推出来。定义一个int[] a = new int[7],内存里就是7个连续排布的int空间,每个int占4字节。访问a[3]时,编译器算的是"起始地址 + 3乘以4",所以按下标访问永远O(1)。这也是数组快的原因,也是几乎所有语言的数组下标都从0开始的原因——从1开始意味着每次访问都要多一次减法运算。
数组长度一旦创建就不可变。初始化时内存块的大小已经定死,想"加一个元素"不是真的加,而是新建一个更大的数组,把旧元素拷贝过去,再让引用指向新数组。很多语言里的ArrayList、vector,内部本质还是数组,只是帮你做了扩容和拷贝;扩容那一次确实是O(n),但摊还下来平均每次插入是O(1)。想通这一点,后面遇到"动态数组怎么扩容""为什么ArrayList插入不一定快"之类的面试题,就不会只背结论了。
1.2 增删元素为什么要O(n)
正是因为有"连续"这个前提,在数组中间插入一个元素,后面所有元素都得往后挪;删除一个元素,后面所有元素都得往前挪。挪一次是O(1),挪n次就是O(n)。所以LeetCode上那些"原地删除""就地覆盖"的题,核心从来不是真删,而是用覆盖代替删除,用返回的数值标记有效区间的结束。
这里经常有人问:为什么不能像链表那样remove?因为链表删除只是改指针指向,节点本身没有"搬运"成本;数组在连续内存里没有"断开链接"这种操作,你唯一能做的是把后续元素依次往前搬。做题时写nums[slow] = nums[fast],本质就是在模拟"把不该删的值覆盖到前面",数组的length字段并没有真的缩小,只是逻辑上不再关心slow之后那段旧值。这个认知不建立起来,后面做滑动窗口、原地哈希之类的题都会别扭。
1.3 二维数组的内存布局不是"方方正正的一块"
还有一个高频面试点:二维数组在内存里是不是连续的?答案要看语言。C++里int a[3][4]确实是连续的一块;Java里int[][]定义的是"数组的数组",外层数组每个元素存的是一个一维数组的引用,所以行与行之间不一定在内存里紧挨着。这个区别在做图像处理、矩阵运算这类性能敏感的任务时影响很大,但刷题阶段主要影响你对"数组越界""数组拷贝"的理解。比如Java里System.arraycopy拷贝二维数组,其实只是拷贝了外层引用列表,内层对象没有复制,这点很多人不知道。
1.4 理论基础直接决定了题目的"游戏规则"
把上面几点串起来,你会发现Day01的两道题全是这套底层逻辑的体现。704.二分查找的前提是"数组随机访问O(1)",所以你才能用下标直接取中点;27.移除元素的前提是"数组连续存储、删除要整体搬移",所以题目才要求你原地覆盖并返回新长度。很多题看似花哨,拆到最后都是"连续内存"这四个字在约束你能做什么、不能做什么。这也是为什么代码随想录把数组放在训练营第一天——它不是简单,它是一切题型的底层地基。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 704. 二分查找:写循环之前,先把区间定死
2.1 二分的思想和循环不变量
二分查找的思想很简单:有序数组里,target要么在左边,要么在右边,取中间值比较,扔掉一半。难的是边界条件,尤其是while条件写left < right还是left <= right,right到底要不要减一,这些都是初学者反复出错的地方。
要根治这个问题,必须引入"循环不变量"这个习惯。听着玄乎,其实就是一句话:你选择左闭右闭[left, right],就要保证整个循环过程中left和right指向的位置都可能等于target;选择左闭右开[left, right),就要保证left可能等于target、right一定不等于target。只要这个约定不破,循环就不会写错。每次更新区间时,都是为了保持这个约定,而不是凭感觉改下标。
2.2 左闭右闭写法拆解
先看最常用的左闭右闭写法:
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
几个关键的地方逐一说。right初始化为len(nums) - 1,因为区间是左闭右闭,right可以被查。while条件用left <= right,因为当left等于right时,区间里还有一个元素需要判断。重点在更新:nums[mid] < target说明mid及其左边都可以排除,下一轮搜索范围变成[mid+1, right],所以left = mid + 1;反之nums[mid] > target则right = mid - 1。注意这里不是left = mid或right = mid,否则当区间缩小到相邻两个元素时,mid永远等于left,left永远不走,死循环就来了。
2.3 左闭右开写法拆解与对比
再写左闭右开版本:
python复制class Solution:
def search(self, nums: List[int], target: int) -> int:
left, right = 0, len(nums)
while left < right:
mid = left + (right - left) // 2
if nums[mid] == target:
return mid
elif nums[mid] < target:
left = mid + 1
else:
right = mid
return -1
这个版本里,right初始化为len(nums)而不是len(nums)-1,因为right不在区间内,区间是[left, right)。while条件变成left < right:当left等于right时区间为空,不需要再查。nums[mid] > target时,右边界要更新为mid而不是mid - 1,因为mid本身就不在下一轮区间里,这正是"右开"的含义。
两种写法的区别用表格总结一下,建议贴在笔记里反复看:
| 对比项 | 左闭右闭 [left, right] | 左闭右开 [left, right) |
|---|---|---|
| right初始化 | len(nums) - 1 | len(nums) |
| while条件 | left <= right | left < right |
| 查找失败时left更新 | mid + 1 | mid + 1 |
| 查找失败时right更新 | mid - 1 | mid |
| 区间为空条件 | left > right | left == right |
没有哪种写法永远更好,关键是每次写题都固定用一种,不要混。训练营里有人习惯左闭右开,因为它和很多语言的区间表示一致(比如Python切片就是左闭右开);也有人喜欢左闭右闭,因为直觉上"两端都可能被查"。你选一个,一直用,形成肌肉记忆,比两种都会一点但都写不利索强得多。
2.4 mid的溢出问题与代码习惯
再讲一个被低估的细节:mid到底该怎么算。经典写法是(left + right) // 2,但更稳妥的是left + (right - left) // 2。当left和right都接近int最大值时,两者相加可能溢出,而这个写法则不会。刷LeetCode时数组长度通常不会到那么大,但面试官很爱问这一点,它体现的不是你会不会二分,而是代码习惯好不好。类似的小习惯还包括:每次进入循环先检查区间是否真的有效、命名用left/right而不是l/r、把边界条件用注释写清楚。这些细节在训练营里不会专门讲,但面试时非常加分。
3. 27. 移除元素:数组"删不掉",只能靠覆盖
3.1 题目要求的原地操作意味着什么
27题的题干里有个硬性要求:空间复杂度O(1)。翻译过来就是,你不能new一个新数组,把不等于val的值拷贝进去再返回。这恰好印证了理论基础:数组压根不提供物理意义上的"删除",你能做的就是改造原数组,让别人通过返回值知道有效长度到哪里。
题目最后返回的是新长度,而不是数组本身。LeetCode的判题逻辑是:用你返回的length,检查nums数组的前length个元素是否符合预期。至于nums后面那些旧值,没人关心。这一点很多新手不理解,以为自己写错了,实际上慢指针之后的"脏数据"本来就该被忽略。
3.2 暴力解法:思路直接但藏着坑
暴力解法的思路很好理解:每找到一个等于val的元素,就把后面所有元素往前搬一个位置,数组逻辑长度减一。但写起来很容易翻车。第一次写的人通常会踩这个坑:找到val后循环变量i会立刻+1,导致从后面搬过来的元素没被检查,跳过了正确判断。所以搬移后i必须先保持不动,等下一轮再检查。
python复制def removeElement_brute(nums, val):
size = len(nums)
i = 0
while i < size:
if nums[i] == val:
for j in range(i + 1, size):
nums[j - 1] = nums[j]
size -= 1
else:
i += 1
return size
这段代码能过,但时间复杂度O(n^2)。因为每删一个元素都要把后面所有元素搬一遍,最坏情况下数组全是目标值,等于双重循环跑满。这也是为什么面试官看完暴力解法后,总爱追问一句:"能不能优化到O(n)?"——他知道你早晚要走到快慢指针这一步。
3.3 快慢指针:slow和fast各司其职
快慢指针的解法极其优雅:
python复制def removeElement(nums, val):
slow = 0
for fast in range(len(nums)):
if nums[fast] != val:
nums[slow] = nums[fast]
slow += 1
return slow
核心思想可以总结成一句话:fast负责扫描,slow负责记录"下一个可以存放合法值的位置"。只要fast指向的值不是val,说明它是应该保留的元素,就把它复制到slow指向的位置,然后slow前进一格;如果fast指向val,直接跳过,什么都不做。循环结束后,区间[0, slow)里全是合法值,slow就是新长度。
用具体例子走一遍:nums = [3,2,2,3],val = 3。fast=0指向3,等于val,跳过;fast=1指向2,不等于val,nums[0]=2,slow变1;fast=2指向2,nums[1]=2,slow变2;fast=3指向3,跳过。最后返回2,数组变成[2,2,2,3],前两个位置是正确的。注意我把数组改成了[2,2,2,3]而不是[2,2,3,3],因为fast=2那一步把原来的nums[1]也覆盖了。这些"脏数据"不影响判题,但如果你用print整个数组的方式自查,看到末尾还有旧值,别慌,这是原地操作的正常结果。如果想调试直观,可以在返回前主动把slow之后的无关位置置空或置0,但这只是本地调试习惯,正式提交不需要。
3.4 快慢指针可以延伸出的变体
这道题的变体很多。比如删除有序数组中的重复项,思路几乎一样,只是判断条件从nums[fast] != val变成nums[fast] != nums[fast-1];再比如移动零,要求在保持相对顺序的前提下把所有0移到末尾,也是用双指针,只是fast扫完一遍后,要把slow之后的所有位置补成0。这些题之所以能秒杀,就是因为Day01里建立起了"覆盖"而不是"删除"的思维模型。还有一类变形是"把等于val的元素全部移到数组末尾",这时可以从数组两端同时出发,一个往右找val,一个往左找非val,交换位置,复杂度还是O(n)。面试时能主动说出这个优化,会显得你不是只会背模板。
4. 第一轮做题最容易踩的四个坑
4.1 死循环:mid更新没有跟上区间定义
训练营第一天最常见的翻车现场就是死循环。典型情况是在左闭右闭写法里把left = mid + 1和right = mid - 1误写成left = mid或right = mid。以nums = [1,2],target = 2为例:初始left=0,right=1,mid=0;nums[0]=1<2,如果写left=mid,区间还是[0,1];再算mid还是0,left一直在0,循环永远出不来。正确做法是left = mid + 1,让区间真正缩小到[1,1]。调试死循环没有捷径,最好的办法是准备一个两三个元素的小用例,在纸上把每次left、right、mid的值写出来,跑几轮就知道卡在哪里。
4.2 越界与漏查:边界条件不一致的连锁反应
另一个高频错误是区间定义与初始化不匹配。比如心里想着左闭右闭,却把right写成len(nums),或者while条件写成left < right。在左闭右闭写法里,right = len(nums)意味着区间定义被悄悄改成了左闭右开,但while条件和左右更新却又按左闭右闭逻辑写,前后矛盾,最后往往漏掉最后一个元素或者多查一次导致越界。反过来,左闭右开写法里right初始化为len(nums)-1,区间变成左闭右开却把最后一个元素排除在外,同样漏查。这类bug全靠大脑硬记规则时经常出现,必须回到循环不变量去检查:我定义的区间到底是什么?初始化的left/right是否符合这个定义?每次更新后是否符合?三个问题答上来了,边界就不会错。
4.3 移除元素后的"长度幻觉"不是Bug
做27题时,很多人会在本地打印nums验证结果,一看数组最后还躺着旧值,立刻怀疑代码有bug。其实不是。原地操作返回新长度后,数组物理长度一直没变,只是逻辑有效区变短了。LeetCode只是按你返回的长度去校验前length个位置。理解这个语义,可以减少很多不必要的自我怀疑。同时也提醒一点:题目要求返回int(新长度),不要画蛇添足返回整个数组,或者试图用类似pop的方式在本地真删。真实工程里数组不够用时你会选ArrayList,但算法题要的就是在O(1)空间下用覆盖法完成,这个限制本身才是考点。
4.4 复杂度分析意识比AC更值钱
训练营前两天最容易出现的状态是:题过了,很开心,关掉页面。但第二天、第三天就会发现自己还在用同样的暴力思路应付新题,遇到变形就抓瞎。原因很简单——没做复杂度复盘。每道题AC后,至少要在笔记里写下时间复杂度和空间复杂度,再追问一句:还能不能更好?27题暴力解是O(n^2),快慢指针是O(n),为什么能优化到O(n)?因为slow和fast各自只遍历了一遍数据,没有嵌套。二分查找为什么是O(log n)?因为每次排除一半,执行次数以2为底取对数。这些结论只有亲手推一遍才会长在脑子里,面试时被追问"你这个算法复杂度是多少"才不会卡壳。
5. 训练营Day01复盘:这些思想后面会反复用
5.1 二分思想远不止"有序数组找数"一个场景
Day01学到的二分,后面马上就会被翻倍复用。经典的有"在排序数组中查找元素的第一个和最后一个位置",它要求你在普通二分基础上再处理"重复元素"的边界;还有"搜索旋转排序数组",数组不再全局有序,但被旋转过一次后,总有一半区间保持有序,仍然可以二分;"寻找峰值"更是把二分的判断条件从"和target比大小"换成了"和相邻元素比大小"。这些题看起来各不相同,骨子里都是同一件事:找到一种单调性或者半单调性,把搜索区间一分为二,每次扔掉确定不可能的一半。Day01把区间和循环不变的功夫练扎实,后面的二分题基本就是换汤不换药。
5.2 快慢指针是一整套数组题的原型
移除元素里的快慢指针,其实是很多数组题的原型。删除有序数组的重复项,还是它;移动零,还是它——只是扫完后要把slow后面的位置填0;比较含退格的字符串,也是用类似的逆向双指针来避免额外空间。再往后学滑动窗口,本质上也是双指针,只是left不再是"覆盖位置",而是"窗口左边界"。你会在很多天后恍然大悟:原来Day01里的slow和fast,是后来一堆medium题的骨架。这也是为什么我一直觉得,第一天不要贪快,把27题吃透比连刷三题有用。
5.3 把两种写法默写一遍,是Day01值得留的作业
最后说一个我自己实测有效的习惯:训练营打卡结束后,不要急着做明天的题,先合上题解,把704的两种二分写法和27的快慢指针各默写一遍,再配合一个长度为5、含重复值的小数组做一次手工推演。默写时你会发现,你以为记住的东西,落到笔尖还是会卡。这个卡顿点就是你的薄弱点,当天解决掉,后面几天会顺很多。我第一天就卡在左闭右开的right更新上,多默写两遍以后,再遇到二分题,边界条件基本不用想了。希望这个方法对你有用,也欢迎分享你第一天踩过最隐蔽的坑。
