很多准备跳槽或者刚转行的朋友问我,算法到底怎么开始刷。网上的题单一抓一大把,但质量参差不齐,有的上来就让你啃动态规划,一上来就把人劝退了。如果你正在找一份能照着走、不绕弯路的刷题路线,大概率听过“代码随想录”这个名字。这套资料厉害的地方在于它不是单纯堆题,而是按知识点把题目串成一条线,每一题都讲透了为什么这么做、还有哪些坑。
Day 1是这条路线的起点,主题是数组。看起来简单,但数组是后面所有数据结构的地基,很多人刷了几个月算法,最后发现卡住的地方全是基础没打牢。这篇文章就带你完整走一遍Day 1的内容:数组的理论基础、两道核心题目——二分查找和移除元素,以及第一天最容易踩的坑。无论你是刚打开LeetCode的新手,还是刷了一阵子但总觉得思路混乱,这篇文章都能帮你把地基夯实。
1. 内容整体设计与思路拆解
1.1 为什么算法路线要从数组开始
数组几乎是所有编程语言里最基础的数据结构,但“基础”不等于“简单”。代码随想录把数组放在第一天,背后是有逻辑的。数组的特点是连续内存、随机访问,这两个特性决定了你能用哪些技巧去优化代码。比如二分查找依赖的是数组的有序性和随机访问能力,双指针法依赖的是数组的连续内存结构。如果连数组的这些底层特性都吃透,后面学链表、哈希表、字符串、滑动窗口都会遇到障碍。
很多人一开始就直接刷“Top 100热题”,结果发现题目涉及的知识点互相交叉,一道题可能需要同时掌握递归、哈希、双指针、前缀和等多个技巧。代码随想录的做法完全相反,它先按数据结构分类,每个分类里再按题型细分,比如数组这一章分了二分查找、双指针、滑动窗口、模拟行为等几类。每一类题型集中刷3到5道题,你就会发现套路其实就那几种,换汤不换药。
Day 1安排的是二分查找和移除元素,一个代表“查找”场景的优化思路,一个代表“原地修改”场景的双指针思路。这两道题一静一动,分别是数组操作里最典型的两种模式。把它们放在同一天学,是因为它们的代码量都不大,但细节极多,非常适合用来建立“看题不慌”的信心。
1.2 第一天学习目标与预期效果
Day 1不是让你一天之内把数组所有题型都刷完,那不现实。它的目标非常克制:理解数组的底层存储特性,掌握二分查找的两套边界写法,能自己写出移除元素的双指针解法。这三件事做完,你就具备了分析一道数组题目的基本框架。
很多人刷题有个误区,觉得一天刷十道题才叫高效。实际上如果一道题你能把边界条件、复杂度分析、暴力解法到优化解法的演进过程全部想明白,这一天的收获比盲目刷十道题大得多。代码随想录里的题目讲解都会先给暴力解法,再引入优化思路,这个安排是有意的。因为面试时考官经常先问“暴力怎么做”,再引导你优化,你要是不清楚暴力解法的瓶颈在哪,直接给出最优解,反而显得像背题。
所以Day 1的学习节奏应该这样安排:上午看理论基础,把数组的内存模型和常用操作的时间复杂度搞清楚。下午自己做二分查找和移除元素两题,每一题先尝试独立写15分钟,写不出来再看解析。晚上对照代码随想录的总结,用自己的话把两套解法的思路写出来。按这个节奏走,第一天就能把数组最核心的两种操作模型刻进脑子里。
2. 核心细节解析与实操要点
2.1 数组理论基础:连续内存带来的能力与限制
数组的全称是“顺序存储的线性表”,它的核心特点有三条:内存连续、元素同类型、随机访问。这三条特性看着简单,但每一条都对应着实际操作中的能力或限制。内存连续意味着你可以通过首地址加偏移量直接算出任意元素的位置,这就是随机访问,时间复杂度是O(1)。但代价是插入和删除需要搬移大量元素,平均时间复杂度是O(n)。
很多新手弄不清“数组删除元素”到底删的是什么。数组的长度是固定的,所谓删除,本质上是用后续元素覆盖前一个元素的位置,最后把逻辑长度减一。比如数组[1, 2, 3, 4, 5],要删掉值为3的元素,实际操作用4覆盖3的位置,用5覆盖4的位置,数组变成了[1, 2, 4, 5, 5],逻辑上我们认为长度是4,最后一个位置的5不予理会。这是理解移除元素这道题的前提。
另一个重要的理论基础是数组与链表的对比。数组的随机访问是O(1),链表的随机访问是O(n);数组的插入删除是O(n),链表如果已知前驱节点则是O(1)。这也是为什么很多算法题里,需要频繁随机访问就选数组,需要频繁插入删改就选链表。代码随想录在后续章节讲链表时也会反复用到这个对比,第一天把数组的特性吃透,后面学链表时就会轻松很多。
关于数组的底层存储,还有一个值得注意的细节是在C++里数组是栈内存还是堆内存,取决于你声明的方式。在Java里数组是对象,引用存储在栈上,实际数据存储在堆上。这些语言层面的差异不影响算法思路,但会影响你对内存消耗的分析。面试时如果被问到空间复杂度,一定要能说清楚你声明的数组占了多少额外内存,而不是笼统说“O(n)”。
2.2 二分查找的两种写法与循环不变量
二分查找是Day 1的重头戏,也是面试里出现频率极高的题目。它的思路一句话就能说清:每次把搜索区间缩小一半。但“思路一句话”和“代码一次写对”之间,隔着无数个因边界条件导致的死循环或越界。
代码随想录特别强调了“循环不变量”这个概念,这是理解二分查找的关键。所谓循环不变量,就是你在每轮循环里都要维护的一个区间定义。常见写法分两种:左闭右闭[left, right]和左闭右开[left, right)。这两种写法没有谁对谁错,但你在一道题里必须从头到尾坚持同一种定义,不能混用。
左闭右闭的写法里,right的初始值是数组长度减一。每轮循环里,如果target大于中间值,说明target在右半部分,此时left指针更新为middle加一,因为middle已经比较过且不等于target,没有理由再留在区间里。反过来如果target小于中间值,right更新为middle减一。循环条件要写成left <= right,因为当left等于right时,当前区间里还有一个元素需要检查。
左闭右开的写法里,right的初始值是数组长度。同理,如果target大于中间值,left更新为middle加一;如果target小于中间值,right更新为middle,因为区间是左闭右开,right本身不包含在区间内,但middle这个位置已经被排除了,所以直接让right等于middle就能把区间变成[left, middle)。循环条件是left < right,因为当left等于right时区间为空,不需要再检查。
这两种写法的代码只差了几个等号和加减一,但混用就会出大问题。最常见的错误是在左闭右闭的循环里把right更新成middle,这样当区间缩小到只剩两个元素时,会陷入死循环。理解循环不变量之后,这类错误就能从根源上避免,因为你知道自己维护的是哪种区间定义,每一步更新都是唯一确定的。
2.3 移除元素的双指针法与暴力解法对比
移除元素的题目描述很简单:给你一个数组nums和一个值val,需要原地移除所有数值等于val的元素,返回移除后数组的新长度。注意“原地”这两个字,它直接决定了你不能新建一个数组来过滤元素。
这道题的暴力解法是两层循环,外层遍历数组找到等于val的元素,内层把后续所有元素整体前移覆盖。时间复杂度是O(n^2),空间复杂度是O(1)。这个解法能通过部分测试用例,但性能很差,在数据量大的时候会超时。
双指针法能把时间复杂度降到O(n)。思路是设置一个慢指针slow和一个快指针fast,快指针负责遍历整个数组,慢指针负责记录下一个可以放置非目标元素的位置。当快指针指向的元素不等于val时,把它赋值给慢指针指向的位置,然后两个指针同时前进。当快指针指向的元素等于val时,只有快指针前进,慢指针原地等待。这样一趟遍历下来,慢指针的位置就是新数组的逻辑长度。
双指针法之所以高效,是因为它把“遍历”和“覆盖”这两个操作合并在了一起。暴力解法里每发现一个目标元素就要进行一次O(n)的搬移操作,而双指针法里每个元素最多被赋值一次,整体时间复杂度只有O(n)。这个“一个指针负责探索、一个指针负责定位”的思路,在后面很多题目里都会反复用到,比如删除有序数组中的重复项、移动零、有序数组的平方,本质上都是同一种套路。
3. 实操过程与核心环节实现
3.1 环境准备与刷题工具配置
动手刷题之前,先把环境准备好。LeetCode是多数人的首选,它支持的语言够全,测试用例也足够覆盖边界情况。你需要做的第一件事是建立自己的代码模板,比如用C++就提前在本地编辑器里配置好常用的头文件和main函数模板,用Java就配置好Solution类的外壳。这样每次刷题只需要把注意力集中在核心方法的实现上,不需要每次重写一遍框架。
我个人比较推荐的流程是:先在LeetCode的网页版把题做一遍,然后在本地IDE里再敲一遍,最后把通过的代码整理到自己的题解仓库里。网页版做题的好处是能即时看到测试结果和运行时间,本地IDE的好处是可以用调试器逐步查看变量变化,特别是数组下标容易搞混的题,单步调试比肉眼检查代码高效得多。
还有一个很多人忽略的工具是复杂度分析。LeetCode的编辑器下方会显示运行时间,但这只是针对特定测试用例的实测值,不能替代手动的复杂度分析。每次提交完代码,建议在题解笔记里写清楚时间复杂度是O(n)还是O(log n)以及空间复杂度是多少。写得多了,复杂度分析就会变成一种本能反应。
3.2 二分查找代码逐行实现与易错点
我以左闭右闭写法为例,给出完整代码并逐行解释。先看C++版本:
int search(vector<int>& nums, int target) { int left = 0; int right = nums.size() - 1; while (left <= right) { int middle = left + (right - left) / 2; if (nums[middle] > target) { right = middle - 1; } else if (nums[middle] < target) { left = middle + 1; } else { return middle; } } return -1; }这里的陷阱集中在三处。第一处是middle的计算方式,很多人写成(left + right) / 2,这在两个数都是很大的int时可能溢出,用left + (right - left) / 2就能彻底避免。第二处是循环条件left <= right,必须带等号,否则当数组只有一个元素时会漏查。第三处是right = middle - 1而不是right = middle,因为在左闭右闭的区间里middle已经被检查过不等于target,保留它会带来多余的比较,但更严重的是在某些情况下会死循环。
再看左闭右开的版本:
int search(vector<int>& nums, int target) { int left = 0; int right = nums.size(); while (left < right) { int middle = left + (right - left) / 2; if (nums[middle] > target) { right = middle; } else if (nums[middle] < target) { left = middle + 1; } else { return middle; } } return -1; }注意右开写法里right初始值就是nums.size(),循环条件是left < right,right更新成middle。这些都与闭区间版本的规则一一对应。有些题解会把两种写法混着讲,让你第一遍记规则第二遍凭感觉写,这不是好习惯。我建议你选定一种写熟练,另一种看懂即可,绝大多数面试场景下你只要能流畅写出一种并对边界条件自圆其说就够了。
为了自测代码是否正确,我建议你用边界用例跑一遍:数组长度为1、目标值在开头、目标值在结尾、目标值不存在于数组但介于中间范围、目标值小于所有元素、目标值大于所有元素。这六种情况能覆盖几乎所有边界条件,跑通了就说明你的代码基本没有问题。
3.3 移除元素代码实现与思路演变
同样先给双指针法的C++代码:
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]; slow++; } } return slow; }这段代码的精髓在于用fast指针遍历一遍数组,凡是遇到不等于val的元素,就把它“搬到”slow指针指向的位置。slow从0开始,每接收一个元素就加一。最后slow的值就是新数组的长度,而nums的前slow个位置正好是按原顺序排列的所有非val元素。
理解这段代码时不要把slow和fast想成两个“指针”,而是想成一个“写入位置”和一个“读取位置”。slow指向下一个要写入的位置,fast指向当前正在读取的位置。当fast读取到的元素为非目标值时,就执行“写入”;当fast读取到目标值时,就跳过不写入。这个过程类似物理世界里用两个手指在一串卡片上移动,一个负责找能用的卡片,一个负责把卡片放到前面的空位里。
还有一个变体值得了解,就是不改变元素相对顺序、但允许新数组元素顺序调整的解法。这种解法用左右指针,左指针找到等于val的元素就停下,右指针把末尾的非val元素搬过来覆盖左指针的位置,然后左指针继续前进。这种解法减少了赋值次数,在特殊情况比如数组里val很少时会更快,但它不保留原顺序。面试时如果题目不要求顺序,可以提这种优化;如果要求保持相对顺序,就只能用快慢指针法。
我在实际写这道题时踩过一个坑,就是忘记处理val出现次数极多的情况。比如数组是[1, 1, 1, 1],val是1,按理说返回长度应该是0。有些人在for循环里用nums.size()作为边界,但在循环体内又擅自修改数组内容,结果导致下标越界。记住,你的循环边界始终基于“原数组长度”,即使你覆盖了前面的元素,nums.size()这个值也不会变,没必要也不应该通过动态调整数组内容来影响循环次数。
4. 常见问题与排查技巧实录
4.1 二分查找死循环与边界错乱的实战排查
二分查找最常见的报错就是“超时”,本质上就是死循环。我和不少朋友交流过,大家卡住的地方几乎都一样:循环条件到底写left <= right还是left < right,以及right更新的时候到底减不减一。
排查死循环的方法是手动走一遍小规模用例。比如数组是[2, 5],target是5,使用左闭右闭写法,把循环条件写成left < right。第一轮循环:left是0,right是1,middle是0,nums[0]是2小于5,于是left变成1。第二轮循环条件判断1 < 1为假,循环结束,结果返回-1。但实际target明明在数组里。这就是循环条件少了等号导致的漏查。
另一个典型错误是right更新成middle。还是左闭右闭写法,数组[2, 5],target是5,循环条件是left <= right。第一轮:middle是0,nums[0]是2小于5,left变成1。第二轮:middle是1,nums[1]是5,直接返回1,没问题。但如果target是数组里不存在的值,比如6,第一轮后left变成1,第二轮middle是1,nums[1]是5小于6,left变成2。循环结束,返回-1,看似没毛病。再把target换成一个落在区间中部但不存在于数组的值,比如3,第一轮后left变1,第二轮middle是1,nums[1]是5大于3,如果此时right被错误更新成middle也就是1而非middle-1也就是0,就会出现left是1、right是1的情况,在循环条件left <= right下还会进入第三轮。第三轮middle是1,nums[1]还是5大于3,right又被更新成1,left和right永远相等,循环进入死循环。这就是right必须减一的原因。
我建议你排查二分问题时就写一个带left和right老式调试输出的小脚本,每一轮循环打一遍left、middle、right的值,一旦出现过left和right不按预期收敛的情况,立刻就能定位是哪行代码写错了。这个方法比望着屏幕干瞪眼高效得多。
4.2 移除元素数组越界与逻辑遗漏的典型情况
移除元素常见的运行时错误是数组越界。有一个场景经常遇到:把快慢指针法和左右指针法搞混。快慢指针法里fast只递增,永远不会越界;但左右指针法里右指针不断左移,如果写代码时没控制好循环条件,比如while (left < right)但内部又在left位置写入后直接把right减一,当左指针越过右指针后,下一轮循环还会访问一个已经不存在的下标。
逻辑遗漏则更隐蔽。比如题目要求返回的是“新长度”,很多人写对了新长度的计算,但忘记把数组末尾的残留元素处理掉。实际上算法题并不要求你清空或处理新长度之后的元素,LeetCode的检查逻辑只关心数组前新长度个元素是否满足条件。但在面试的追问环节,面试官可能会问“新长度之后的元素是什么”,你需要能回答出来它们仍然保留着旧值,只是逻辑上不属于新数组罢了。
还有一类问题是空数组和全量删除。空数组时快指针根本不进入循环,slow是0,返回0,正确。全量删除时每个元素都等于val,快指针一路跳过,slow始终是0,返回0,正确。这两种边界用例在提交前值得跑一遍,能快速排除最基本的笔误。
4.3 第一天刷题后的复盘方法
很多人的刷题节奏是一路猛做新题,从不复盘,结果一周后回头看第一天的题目已经认不出来了。代码随想录的学习路线里虽然没有强制安排复习计划,但根据记忆曲线,第一天学的内容如果第三天不复习,留存率会直线下降。我有一个实操下来很有用的复盘方式:当天写完代码后,把题解仓库里的笔记分成“思路摘要”“代码要点”“踩坑记录”三栏,第二天做题前花五分钟重看一遍前一天的“踩坑记录”。这样每一题至少经历“独立做、看解析、重写、隔天复习”四个环节,记忆牢固程度远超一次通过就不管的模式。
更进一步,我会在三天后再次用全新空白文件重写一次这两道题,不做任何参考。如果能在十五分钟内写出正确代码并解释清楚边界条件,才算真正掌握。这一步听起来有些耗时,但它省去了日后重复刷同类型题目时重新摸索边界条件的隐性时间成本。二分查找和移除元素作为数组篇的开头,如果打成了熟肌肉记忆,后面做滑动窗口和螺旋矩阵时你就会发现很多思路是可以直接迁移的。
5. 工具选型与学习资源搭配
5.1 如何高效利用代码随想录配套资源
代码随想录不止有题解文章,还有配套的B站视频讲解、PDF版本以及算法公开课。这些资源的形式不同,适用的场景也不一样。刷题入门阶段,先看文字版题解,因为文字版能精确表达边界条件和复杂度分析,方便反复查阅。如果你的算法基础比较薄弱,或者看文字无法理解“为什么right更新成middle减一”,可以配合视频讲解,视频里画图演示区间缩小的过程会直观得多。
配套资源里还有一个容易被忽略的部分,就是每道题下方的“扩展题目”和“相关题目推荐”。不少人在LeetCode上做完题就急着标记“已通过”,其实通过只代表代码写对了,不代表思路吃透了。代码随想录把相似题目串起来是有用意的,你在Day 1做了二分查找后,紧接着可以把“搜索插入位置”和“在排序数组中查找元素的第一个和最后一个位置”拉出来做,这三道题是二分查找的同一家族,边界条件稍有变化,但底层逻辑完全一致。
关于是否需要报培训班或购买课程,我的建议是先别急。代码随想录本身已经提供了足够完整的免费内容,你需要的只是执行力。如果确实卡在某类题型上多日无进展,再考虑付费课程也不迟。学习算法最稀缺的资源从来不是资料本身,而是你肯花在思考上的时间。
5.2 本地IDE与算法笔记仓库的搭建建议
刷题这件事,长期看一定要建立自己的笔记体系。LeetCode的收藏和提交记录在云端,但那是平台的数据,不是你自己的知识沉淀。我用的是Git仓库管理自己的题解,每个题解文件的三段式结构已经完全固定:题目描述、思路分析、代码实现。思路分析里会有从暴力到优化的演进过程,以及当时自己犯过的错误。
本地IDE我推荐VSCode搭配对应的语言插件。不需要配置复杂的调试环境,但至少要保证能一键运行代码。算法题很多时候需要你反复修改边界条件来验证想法,如果每次都要复制粘贴到LeetCode网页上才能看到结果,效率会低一半。在本地跑代码还有一个额外的好处:你可以随手写一段测试代码,比如随机生成大量数组来比较暴力解法和优化解法的结果是否一致。这种对拍验证虽然对面试帮助不大,但能极大增强代码正确性的信心。
还有一个实用小技巧是给题解仓库加一个README索引,按照“数组-二分查找-双指针-滑动窗口-模拟行为”这种结构把题目链接和难度标记清楚。每周花十分钟看一眼索引,就能直观地看到自己覆盖了哪些题型、哪些题型还是空白。这种可视化的进度反馈对维持长期刷题动力很有效。
6. 常见面试追问与扩展思考
6.1 二分查找的边界条件为何是面试考察重点
面试之所以高频考察二分查找,不是因为这道题要靠背代码,而是因为它能一眼区分“背过题”和“真正理解”。有过面试官经验的人都知道,候选人照着模板写出二分查找并不稀有,但只要把题目稍微改一下,比如数组里有重复元素、要求返回第一个出现的下标,很多人立刻就会出bug。原因就是他们不理解循环不变量,只记住了固定写法。代码随想录把“循环不变量”单独拎出来讲,就是希望你从底层逻辑出发理解这段代码,而不只是被动记忆。
我自己被问到过一个变种题:在一个有序数组里查找第一个大于等于target的元素。这个其实就是在实现标准库里的lower_bound函数。如果你理解了左闭右闭区间里right的更新规则,这个变种题只需要在nums[middle] >= target时更新right = middle,循环结束后返回left即可。但如果不理解区间维护,面对这个变种题就会懵掉,可能需要现场推导很长时间。刷题时把这些变种一起做了,面试时就会有底气。
6.2 双指针思想在后续章节的延伸
移除元素里的快慢指针思想会在后续多个章节反复出现,值得你当天就建立好知识关联。链表章节里有“删除链表的倒数第N个节点”,用的是快慢指针相隔N步的思路;字符串章节里有“反转字符串里的单词”,本质上也是双指针定位单词边界;数组的滑动窗口章节里,左指针和右指针的协作逻辑也与快慢指针一脉相承。
代码随想录在数组这一章的后面还会有“有序数组的平方”和“长度最小的子数组”两道题,前者用双指针从两端向中间逼近,后者用滑动窗口动态调整左右边界。这些题和Day 1的移除元素放在同一章不是巧合,它们都解决了“如果用暴力解法会重复扫描”的问题,而双指针和滑动窗口能保证每个元素最多被访问常数次,从O(n^2)降到O(n)。第一天把双指针的底层思想吃透,后面再遇到这些题目就会有一种“原来是老朋友”的感觉。
另一个扩展方向是“原地操作”类题目。现实中会给一些资源受限的场景,比如你无法申请额外内存,必须在一个数组上完成某种修改。移除元素是这类问题的原型,理解了它,你就能理解为什么有些语言里数组的“删除”操作如此尴尬,为什么需要引入所谓的“懒删除”和“逻辑长度”这些概念。这些理解在对系统设计和其他语言源码的阅读中都会受益。
6.3 Day 1之后的后续学习路线建议
完成Day 1之后,不要急着推进度。把当天的内容再花一小时巩固,然后才开始下一章。Day 2和Day 3的题目会继续围绕数组展开,大概是有序数组的平方、长度最小的子数组和螺旋矩阵。这三道题分别对应双指针的另一种用法、滑动窗口和模拟行为,难度逐步递增,但都建立在你第一天掌握的数组基础之上。
我给一个具体的进阶时间表供参考:Day 1到Day 3集中刷完数组章节,Day 4回顾并重写全部四到五道题,Day 5进入链表章节。链表和数组天然是对比结构,有了数组的坚实基础,链表的学习就会容易得多,因为你会自然而然地对比两者的优缺点。很多人觉得算法越学越乱,本质上是基础章节没有形成完整框架就开始学后面的内容,导致知识点在脑子里都是孤岛。代码随想录的路线是线性的,知识点之间环环相扣,只要你不偷懒跳过基础,后面即使遇到难题也能顺藤摸瓜找到对应的知识点。
我个人在这个路线上走下来最大的体会是:第一天的两题虽然代码量不大,但它们教给你的思维模式——循环不变量、快慢指针、原地操作——会陪伴你整个算法学习周期。与其一天做十道题然后全部忘光,不如一天精做两题然后形成永久记忆。如果你正在考虑开始刷算法,真心建议从跟随代码随想录的节奏开始,把地基打牢之后再考虑挑战更复杂的题目。刷题这件事没有捷径,但沿着一条设计好的路线走,能让你少走很多弯路。