1. 为什么算法训练营第一天,几乎都从数组开始
我参加过不少算法训练营,也带过新人的刷题计划,发现一个很有意思的现象:不管训练营的体系怎么设计,第一天几乎翻不开数组这一页。代码随想录算法训练营第一天安排“数组理论基础”,很多人觉得不就是个数组嘛,谁不会啊,直接开刷就完了。但恰恰是这个看起来最不起眼的数据结构,卡住了大量刚开始刷题的人。
先说结论:数组能覆盖的算法题型非常广,二分查找、双指针、滑动窗口、模拟、前缀和、差分,这些高频考点的载体都是数组。而且数组本身还是字符串、矩阵、哈希表甚至树状数组这些进阶结构的底层根基。刷题初期,把数组的“底层逻辑”和“操作边界”想清楚,后面学链表、栈、队列、二叉树会顺很多,否则你在链表里看到的很多“哨兵节点”设计、在树里看到的“递归终止条件”,都会有一种雾里看花的感觉。
整个训练营第一天的内容不算多,核心就三件事:理解数组的内存模型;掌握增删改查的时间代价;把两道经典题目吃透,一道是704二分查找,一道是27移除元素。我当年第一次刷这两题的时候,犯过不少低级错误,比如二分查找的中间下标到底该不该加1、移除元素时直接erase会不会超时、for循环里删除元素后索引会不会越界。这些坑如果你在一开始就有人跟你说清楚,至少能帮你少走两三天的弯路。
我写这篇东西的动机也很简单,想把训练营第一天的学习笔记整理成一份能直接使用的参考,把我自己踩过的坑、验证过的写法、以及为什么这么写的原因都摊开来讲。不管你用的是C++、Java、Python、Go还是C,只要能把第一天这两道题真正吃透,你的数组基础就算打牢了。
2. 数组的核心理论:内存连续与随机访问的代价
2.1 数组的内存模型到底怎么理解
数组在内存中是一段连续的内存空间。这句话很多教程都讲过,但你真得在脑子里建立起“一块连续格子”的画面,而不是把数组当成一个抽象的“盒子”。
我在给新人讲数组时喜欢打一个比方:数组就像一栋走廊两侧的房间,每个房间大小完全一样,房间里能住的数据类型也完全一样。你要找第几号房间,不需要从1号挨个敲门找过去,直接用门牌号计算就行,首地址加上偏移量,一步到位。这就是随机访问。所谓计算地址的过程,其实就是编译器替你做了一次乘法加法,算好偏移量之后直接定位。
这个特点决定了数组的第一个性能优势:按下标访问元素的时间复杂度是O(1)。不管你访问第1个元素还是第100000个元素,耗时基本没有差别。链表就不行,虽然它也是线性结构,但只能顺着指针走,本质上是一个“一条道走到黑”的结构,想访问中间节点就得从头或者从尾遍历。
数组的第二个特点,也是很多新手容易忽略的,是它一旦声明,长度基本就固定了。在C/C++里,普通数组是静态分配的,比如 int arr[100] 就是固定100个int的空间。你往里面塞第101个元素,编辑器可能不报错,但运行时会越界。动态数组(比如C++的vector、Java的ArrayList、Python的list)看起来能动态扩容,但底层的原理也不是说数组可变长了,而是“新开一块更大的内存,把旧数据全部复制过去,再释放旧空间”。这个扩容的过程是O(n)的,虽然均摊下来还是O(1),但如果反复触发扩容,性能就会波动。
2.2 为什么数组增删元素是O(n)而不是O(1)
这一点训练营第一天一定会强调。很多人初学数组时总是记不住“数组删除为什么慢”,按直觉想,删掉中间一个元素,把后面的元素往前挪一步,好像也没多少工作量。但要注意,这个“挪一步”不是常数时间,而是取决于数组长度。如果你删的是第一个元素,后面n-1个元素全部要往前移,这是妥妥的O(n)操作。
插入也是一样的道理。你往数组中间插入一个元素,为了给新元素腾位置,后半段所有元素都得往后挪一位。如果插入位置在开头,那整个数组的元素都得挪一遍,最坏情况就是O(n)。
我刚开始刷题时犯过一个典型错误:在移除元素的题目里看到“erase”,第一反应就是用vector的erase接口直接删。这确实能通过简单的测试用例,但训练营的标准要求是不能依赖库函数改变数组长度,因为你刷的是算法思想,不是在调API。用erase的时间复杂度虽然是O(n),但你自己手动实现一遍双指针移除,才能真正理解“覆盖”和“删除”的关系。后面面试现场手写代码时,你不可能什么都靠库函数,核心逻辑得能自己写出来。
2.3 万物皆可通过下标访问:从一维到多维
训练营第一天通常还会讲到二维数组的内存模型。很多人以为二维数组在内存里也是“格子套格子”,实际并不是。C/C++里的二维数组在内存中是按行优先连续存放的,也就是说 int a[3][4] 本质上是12个连续的int空间,编译器把逻辑上的行、列映射成了物理上的一维偏移量。
这个特性带来的一个实际影响是:二维数组的遍历顺序不同,缓存命中率可能完全不同。如果你按列去遍历一个很大的二维数组,每次访问都要跨行跳很远,Cache的友好度会差很多。虽然刷LeetCode时性能还不够明显,但写竞赛题或者做高性能计算时就是天壤之别。Java里的二维数组其实有点特殊,它本质上是一个“数组的数组”,每个子数组是独立对象,内存不一定连续,这一点跟C/C++的二维数组不一样。Python里用列表模拟二维数组也一样,甚至每一行的长度都可以不一样,或者说“非矩形的二维数组”。这些语言层面的差异,在第一天学二维数组时不必深究,但得知道它们不是一回事。
3. 数组操作的三大难点:边界、越界与遍历顺序
3.1 二分法里的区间不变量:理清边界就不能拍脑袋
很多人在训练营第一天就开始刷704二分查找,但刷的时候脑子里没有“区间”这个概念,而是靠死记“mid = (left + right) / 2;如果target比mid大,left就变成mid+1;如果小,right变成mid-1”。这么背可以应付一道题,但换个场景就露馅。
核心思想是“区间不变量”。你必须在一开始就定义清楚你查找的区间是左闭右闭,还是左闭右开,之后每一步操作都必须保持这个区间的语义不变。我习惯用左闭右闭的方式,也就是 target 的候选区间是 [left, right],左右两个边界都包含。
在这种定义下:
- 当 nums[mid] > target 时,说明 target 一定在 mid 左边,而且 mid 本身不可能是 target,所以更新区间应为 [left, mid - 1],也就是 right = mid - 1;
- 当 nums[mid] < target 时,说明 target 一定在 mid 右边,且 mid 本身不可能,更新为 [mid + 1, right],left = mid + 1;
- 循环的退出条件,因为候选区间里的元素可能只剩一个,所以 left <= right 要继续循环,一旦 left > right 就说明区间空了,查找失败。
这个逻辑听起来简单,但很多人写错的原因就是循环条件写成 left < right,或者边界更新时没有加1减1。关键是你要把“区间不变量”这个思维焊死在脑子里,写完之后停下来检查一遍:当前更新的区间是不是还是左闭右闭?是不是还有元素没查?如果两个边界指向同一个元素,你的循环还进不进得去?
如果采用左闭右开 [left, right),逻辑就完全不一样了,循环条件会变成 left < right,right 初始化成 nums.length,更新时 right = mid。很多人刷题时一会儿闭一会儿开,结果越写越乱。训练营第一天如果能把这两种写法各写一遍,并且说出区别,二分查找这一关就真正过了。
3.2 为什么循环里删除元素会导致索引越界
这道题是27移除元素,题目要求是原地移除所有等于给定值的元素,返回数组的新长度。能不能用for循环加erase一把梭?能,但性能差,而且容易踩越界坑。
用for循环遍历,遇到等于val的元素就执行erase,问题在于:erase之后,后面所有元素都往前移了一位,当前索引位置的元素变成了原来下一位的元素。如果你这时候还用原来的索引继续遍历,就会跳过新移过来的这个元素,而如果刚好这个元素也等于val,它就被漏掉了。反过来,如果你在erase之后又把索引加1,还可能跳过元素。我见过有人用这种写法,然后越界访问,程序直接崩溃。
正确做法是双指针,用fast指针遍历原数组,用slow指针记录“更新后数组”的写入位置。fast遇到不等于val的元素就把它复制到slow的位置,slow加1。等于val的元素直接跳过,相当于被“覆盖”掉了。这样一趟遍历下来,slow正好就是新数组的长度,而且前slow个元素都是不等于val的。
这里有一个很关键的思维转换:移除元素并不等于“物理删除”,而是“逻辑覆盖”。数组结构本身长度保持不变,只是有效长度的定义变了。这个思想后面还会在去重、压缩数组、链表节点删除等场景反复出现。第一天把它吃透,比多做十道简单题都划算。
3.3 数组遍历顺序里的隐藏条件
训练营第一天还会铺垫一个重要的习惯:写数组题之前先想清楚遍历顺序,是从前往后,还是从后往前,还是双端夹逼。
我在刷题初期经常忽略这个问题。比如有序数组里两个数的平方和排序问题,如果从前往后看,负数平方后可能会大于正数的平方,整个序列就失去单调性了;但如果用两个指针分别指向数组两端,从两边往中间收,就能利用“平方最大值一定在两端”这个性质。这类题目虽然训练营第一天不会展开刷,但数组的基础理论里一定会提到“双指针”,所以第一天就养成分析遍历顺序的习惯,后面做滑动窗口、快排、三数之和都会轻松很多。
还有一个点是“元素搬移的方向”。比如从数组中间删除一个元素,往前搬还是往后搬?增删操作一旦涉及搬移,就必须考虑是否覆盖还没处理的元素。很多人写代码时逻辑是对的,但顺序写反了,覆盖了尚未访问的数据,导致结果全错。
4. 训练营第一天标配:704二分查找与27移除元素
4.1 704二分查找的完整实现与逐行解读
先给一个我确认过没有问题的C++实现,用的是左闭右闭的写法:
class Solution { public: int search(vector<int>& nums, int target) { int left = 0; int right = nums.size() - 1; while (left <= right) { int mid = left + (right - left) / 2; if (nums[mid] == target) { return mid; } else if (nums[mid] > target) { right = mid - 1; } else { left = mid + 1; } } return -1; } };有几个细节值得单独拎出来说。
首先,mid为什么不写成 (left + right) / 2?因为当数组非常大时,left和right都接近int类型的上限,两者相加可能溢出。写成 left + (right - left) / 2 可以避免这个问题。这是一道经典面试题,很多人不看标准答案根本想不到这个位置有坑。
其次,循环条件为什么是 left <= right?因为左闭右闭的区间里,当 left == right 时,区间里还有一个元素,必须再判断一次。如果你写的是 left < right,那最后这个元素就被跳过了,target如果正好是这个元素,函数会错误地返回-1。我之前就犯过这种错误,测了半个数组才想起来。
再补一个经常考的变体:如果数组里有重复元素,让你找左边界或者右边界,要怎么改?这是“二分查找进阶版”的问题,核心还是“区间不变量”那一套。找左边界时,当 nums[mid] == target,不能直接返回,而是把 right 缩小到 mid - 1,继续在左边找;最后退出循环时 left 指向的位置就是左边界。同理,找右边界时,当相等时把 left 设为 mid + 1。这种变体我建议第一天就动手写一遍,比自己刷到再崩溃要划算得多。
4.2 27移除元素的双指针写法与复杂度分析
直接上代码:
class Solution { public: 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; } };这段代码的时间复杂度是O(n),空间复杂度是O(1)。fast遍历一遍,slow负责写入非val元素。
我刚开始学双指针时有个疑问:为什么slow可以放心覆盖之前的元素?会不会把还没遍历到的元素盖掉?答案是不会,因为slow永远小于等于fast,fast走到了哪里,slow才可能覆盖到哪里。fast没走过的地方,slow不可能碰到。这也解释了为什么必须先判断fast对应的元素,再写入到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注意,Python的list本身支持del,但这类题考察的是数组的“覆盖”,不是list的删除语法。如果你用del,根本体现不出“原地O(1)”的思路,而且大量删除元素会导致list底层不断搬移内存,效率也差。
训练营里还会有一个进阶变体:删除有序数组中的重复项(26题),核心思路和27一模一样,只是把“不等于val”的判断条件换成了“不等于前一个元素”。我建议第一天就把这两题放在一起做,效果比单独刷一道好得多,因为它们共用同一套双指针思想,只是条件的包装不同。
4.3 两道题做完之后,你需要复盘什么
刷完这两道题,光提交通过可不够,训练营的节奏是要求“每题都要能讲清楚”。我会让自己回答三个问题:
第一,我为什么选择这个写法而不是另一个写法?比如二分查找,我为什么用左闭右闭而不是左闭右开?双指针移除元素,我为什么用快慢指针而不是从后往前覆盖?
第二,代码在最差情况下会发生什么?比如整个数组没有要移除的元素,fast把每个元素都复制了一遍,这个开销能不能接受?如果数组为空,代码还会不会崩?
第三,代码的边界输入是什么?数组只有一个元素时,数组全是相同元素时,目标值在数组里出现多次且连续时,这些情况我的代码还能不能正确运行?
每次复盘都逼自己把这三问写下来,坚持几十题之后,你会发现自己写代码时明显更稳了,不再靠编译器帮你找错,而是动手之前就能推演清楚。
5. 多语言实现差异与数组相关的“热词避坑”指南
5.1 C/C++的数组指针与指针数组是两回事
训练营第一天本来不要求掌握指针数组这种复杂概念,但你在查资料时难免看到“指针数组”和“数组指针”这两个词,很多人直接懵了。我在这简单拆一下,帮你把这两个概念的坑提前填上。
指针数组,本质是一个数组,数组里的每个元素都是指针。比如int* arr[10],定义了一个长度为10的数组,每个元素都是 int* 类型。它常用来存储字符串数组,比如char* strs[] = {"hello", "world"},每个元素指向一个字符串的首字符。
数组指针,本质是一个指针,它指向一个数组。比如int (*p)[10],p是指向“长度为10的int数组”的指针。这个在实际工程里用得相对少,但理解它有助于理解多维数组在函数间传递时的类型问题。
C语言里还有一个高频噩梦:数组作为函数参数时会退化成指针。比如你写void func(int arr[10]),编译器并不会真的把整个数组拷贝进来,而是把它当成int* arr处理。所以你在func内部用sizeof(arr)得到的是指针大小(8字节),而不是数组大小(40字节)。这就是为什么很多教程强调“数组传参会退化为指针”,也解释了为什么你明明传了10个元素的数组,函数里却算不出长度。
5.2 Python数组切片与二维数组的常见误用
Python里没有真正意义上的“数组”,最常用的是list,而list的切片操作是刷题时的高频工具。切片nums[i:j]会生成一个新的列表,这一点很多人会忘记。
训练营里经常有人用切片去模拟“从数组中截取一部分”,比如在做二分查找时写search(nums[:mid])。我强烈不建议这么做,理由有三个:第一,每次切片都复制一份数组,时间复杂度直接变成O(n),二分查找的整体复杂度就从O(log n)退化成了O(n log n);第二,递归时反复切片会带来大量内存分配;第三,面试时展示的是算法的递归深度,而不是Python的语法糖。
正确的做法是保留原数组,用left、right下标控制搜索区间。同理,二维数组在Python里是“列表的列表”,用dp = [[0] * n] * m创建二维数组时,你会得到一个巨大的bug:所有行都是同一个对象的引用,改一格会“连带”改一整列。正确写法是dp = [[0] * n for _ in range(m)]。这个坑我在蓝桥杯训练时见过太多次了,训练营第一天提早知道,后面做动态规划题会省掉很多排查时间。
5.3 Java的ArrayList与C#的Array、动态数组扩容细节
Java和C#等托管语言里,数组大多以容器的形式出现,比如Java的ArrayList、C#的List。它们底层仍然是一块连续数组,只是封装了扩容逻辑。
扩容策略在不同语言里有细微差异。Java的ArrayList默认初始容量是10,每次扩容为原来的1.5倍,也就是说容量不够时调用Arrays.copyOf,把旧数组复制到新数组,再用新数组替换旧的。C#的List默认容量为0,第一次添加元素时直接分配容量4,之后按2倍扩容。这个倍数不是随便定的,扩容翻倍能保证“均摊添加元素的时间复杂度是O(1)”。
训练营第一天不谈容器源码,但我会建议你花10分钟看看ArrayList扩容相关代码,因为它能帮你理解“动态数组”和“静态数组”的本质差别。动态数组的好处是方便,缺点是在大规摸数据下,扩容带来的复制开销和内存碎片都是隐患。刷算法题时,能直接用静态数组就用静态数组,比如C++里能用 int arr[1005] 的就别用vector,这样性能更可控,也少一层封装滤镜。
5.4 树状数组、循环队列、数组去重这些热词,第一天要不要碰
从你搜索的热词看,不少人对树状数组、循环队列这类“进阶数组”话题很感兴趣。我的建议是:第一天先别碰,但可以先建个索引。
树状数组(Binary Indexed Tree)本质上就是一个数组,但它的下标利用二进制的lowbit运算去维护前缀和。这个结构能解决“单点更新,区间查询”的问题,复杂度从O(n)降到O(log n)。这是竞赛和面试高级题目的常客,但新手直接上手会非常难受,因为它对位运算和前缀和思想都有要求。
循环队列用数组模拟队列,核心是rear和front指针绕着数组转圈,怎么判断队满、队空是经典考点。环形队列里通常牺牲一个存储空间来判断队满,比如(rear + 1) % capacity == front就认为队列满了,如果你不预留这个位置,Rear和Front相等时既可能是空也可能是满,就无法区分了。训练营讲到栈和队列时自然会展开。
数组去重则是高频业务题,常见解法包括哈希表去重、排序后去重、双指针原地去重。第一种适合不要求保持原顺序的场景;第二种能保持相同元素相邻,适合后续操作;第三种就是在27题基础上加条件判断。第一天如果能自己推导一遍这三种思路,去重相关的题目基本就通吃一半了。
6. 常见问题速查:训练营第一天最容易踩的坑
我把这些年带训练营和陪新人刷题时最常见的报错和翻车现场整理成一张速查表,对号入座即可。
| 问题 | 错误写法 | 后果 | 正确做法 |
|---|---|---|---|
| 二分查找循环条件写错 | while (left < right)(左闭右闭) | 漏判最后一个元素 | 左闭右闭用left <= right |
| 二分查找更新边界不加1 | right = mid; | 死循环 | 左闭右闭必须right = mid - 1 |
| mid计算溢出 | (left + right) / 2 | 大数组溢出 | left + (right - left) / 2 |
| 循环中删除元素 | for循环里nums.erase(...) | 索引错乱、越界 | 快慢双指针覆盖 |
| Python二维数组错误创建 | [[0] * n] * m | 所有行共享引用 | [[0] * n for _ in range(m)] |
| Python二分递归切片 | search(nums[:mid]) | 复杂度退化、内存飙升 | 传下标,不切片 |
| C/C++数组传参算大小 | sizeof(arr) / sizeof(int) | 得到指针大小 | 额外传长度参数 |
| String转char数组后直接拼接 | 连续对char数组做字符串拼接 | 越界或内存踩踏 | 用安全的字符串处理函数或明确容量 |
这张表里的第八条特别值得展开。C语言里处理字符串时,如果你定义了一个固定大小的char数组,比如char buf[10],然后执行类似strcat的操作,编译器不会帮你检查目标缓冲区是否够大。一旦拼接结果长度超过9个字符(还要留1个给结尾的'\0'),就会发生缓冲区溢出,轻则程序崩溃,重则出现难以排查的内存踩踏。训练营第一天虽然不专门讲字符串,但数组边界的思维是一脉相承的。
另外还要提一个容易被忽视的编译问题:数组越界在C/C++里不一定会立刻报错,这种“安静”的错误最危险。访问arr[10]时,如果内存页还在进程的可访问范围内,程序不会崩溃,你拿到的就是一个越界后的垃圾值。这种错误在算法题里往往表现为“本地跑得好好的,提交后答案随机错”,排查起来非常痛苦。养成写代码时主动检查下标区间的习惯,比什么都重要。
7. 第一天学习节奏与配套练习建议
如果你正打算跟着代码随想录训练营的节奏走,我给你推荐一个经过验证的一天安排,总耗时可以控制在3到4小时,不需要熬夜硬刷。
上午用1小时把数组理论基础过一遍,重点放在内存连续、随机访问、增删代价这三个关键词上。可以先看理论部分,然后手写一张“数组 vs 链表”的对比表,把时间复杂度、空间占用、缓存友好度、扩容方式填进去。这一步看着简单,但很多人学到后面才发现自己对“链表插入为什么快”的理解是刻板记忆,而不是真的从内存布局推出来的。
下午用1.5小时刷两道核心题,也就是704和27。每道题要求自己写出至少两种解法,比如二分查找至少写左闭右闭和左闭右开两种,移除元素至少写快慢指针和首尾交换法。写完之后用上面提到的“复盘三问”检查一遍。
晚上用0.5小时做延伸阅读,把二维数组初始化、C++里vector和数组的选择、Python切片的注意点这三块内容扫一遍,同时顺手了解一下“勒让德数组/数组去重”的开胃题。如果你还有精力,就做一下26题(删除有序数组中的重复项),它跟27题是亲兄弟,第一天做完这三道,数组基础就很扎实了。
我的个人习惯是每道题用一个固定模板记录:题干链接、算法思想、复杂度分析、代码版本、易错点。训练营打卡时也不再需要临时回忆自己当时怎么写的,直接翻模板就行。
8. 写在第一天的结尾:一个从数组出发的好习惯
如果你已经看完以上内容,说明你真的有耐心,愿意在“数组”这个看起来基础的话题上多花一点时间。我特别想强调一件事:刷题不是比谁刷得多,而是比谁脑子里沉淀下来的“套路模板”多。数组这一天的理论,其实是在给你建立一套带约束的思维模型,后面所有数据结构题都会用到类似的思考方式——先把存储结构想清楚,再设计遍历顺序,最后用复杂度分析验证方案。
我自己第一次认真啃数组理论时,花了整整一个晚上在纸上画“左闭右闭和左闭右开”的区间变化图,画到思路完全理清才停。第二天做相关题目时明显比前一天快了很多。所以如果今天的你感觉还没完全消化,别慌,这个速度才是正常的。可以试着把数组的插入删除过程画成线段图,把二分查找的过程画成区间的收缩过程,用图形辅助理解,效果远好于盯着代码反复读。
最后分享一个小技巧:把训练营第一天的学习笔记用“问题+解释”的形式整理,而不是单纯抄录答案。比如“为什么删除数组元素是O(n)”旁边写清楚“因为要搬移后续所有元素,搬移是循环,复杂度跟长度有关”。几天后回看笔记,你会发现自己已经能一眼看出当初困惑的核心了。这个习惯如果能从第一天坚持到毕业那天,你的算法训练营绝对不算白费。