今天是代码随想录算法训练营的 Day01,主题是数组 part01。说实话,数组这个知识点我自认为早就“会了”,但真跟着训练营重新过一遍,才发现从前很多理解都是浮在表面——比如为什么数组的增删是 O(n) 但查询是 O(1)?为什么二分查找的边界条件能写错一整天?为什么明明“会双指针”却做不对移除元素?这篇笔记我打算把 Day01 的完整思路、代码实现和踩坑记录都摊开讲清楚,给同样在刷算法、准备面试或者纯粹想打牢数据结构地基的朋友一个可以直接照着走的地图。
如果你正准备刷 LeetCode,或者被各种笔试面试题虐得怀疑人生,我强烈建议你从头把数组这块的地基夯实。别急着去刷什么难题怪题,二分查找能闭着眼写对、双指针能讲明白复杂度,你后面学链表、哈希表、滑动窗口都会轻松很多。这篇文章不假设你有任何基础,但也不会废话连篇——每个概念我都会拆开揉碎,配上代码、对比表和我自己实际写错过的现场,争取让你看完就能上手。
1. 数组理论基础:学算法前先把底层逻辑盘明白
1.1 为什么数组是这个世界上“最自然”的数据结构
数组的底层原理一句话就能讲完:内存中一段连续的空间,按顺序排布着同类型的数据。听起来简单,但这三个关键词——“连续”“有序”“同类型”——决定了数组的一切优缺点。
先说“连续”。你可以把内存想象成一排编好号的储物柜,数组就是在其中“包”下连续的一整排柜子。因为连续,所以知道了第一个元素的位置(首地址),再知道每个元素占多大空间,第 i 个元素的位置可以直接算出来:首地址 + i × 单个元素大小。这个计算没有任何循环、没有任何跳转,所以数组的随机访问时间复杂度是 O(1)。这就是为什么数组的“查询”快得离谱。
但“连续”也带来了最大的代价:如果你想在数组中间插入或删除一个元素,你没法只动那一个位置,必须把后面的所有元素整体往后挪或往前挪。这意味着增删操作的时间复杂度是 O(n)。这就好比电影院连坐票,你买了一个中间的座位,后面来个人非要坐你旁边,所有人都得起身挪一个位。
再说“同类型”。数组里存的每个元素大小必须一样,否则“首地址 + i × 元素大小”这个公式就不成立了。这也是为什么在 C/C++ 里数组不能混存 int 和 string,而在 JS 这类弱类型语言里数组可以混存——因为 JS 数组本质上已经不是传统意义上的数组了,这个我后面会专门讲。
1.2 数组初始化与内存布局:静态、动态、堆区三兄弟
“数组初始化”这个热搜词看着基础,实际里面满是坑。很多初学者面试的时候被问“int arr[10] 和 int* arr = new int[10] 有什么区别”,当场就懵。我帮你把三种常见写法一次理清:
静态数组(栈区):int arr[5] = {1, 2, 3, 4, 5};。这是在栈上分配内存,大小必须是编译期常量,函数执行完自动释放,不需要你手动管。
动态数组(堆区):int* arr = new int[5];。这是在堆上分配内存,运行期决定大小,用完必须delete[] arr,否则内存泄漏。这里有个经典考点:new int[5]返回的是 int* 指针,所以很多人误以为“指针就是数组”,这是天大的误会。
静态存储区数组:static int arr[5];或者全局数组。不在栈上也不在堆上,而是放在静态存储区,默认值会被初始化为 0。
还有一个大坑是“数组大小必须是编译期常量”。int n; cin >> n; int arr[n];这种写法在 C++ 里其实是非标准的( VL A 变长数组是 C99 的特性,C++ 并不支持),你用某些编译器可能侥幸通过了,但换台机器就崩。正规做法是:
int n; cin >> n; int* arr = new int[n]; // 堆区动态数组 // 用完记得 delete[] arr;或者更 C++ 风格的做法,直接用容器:
vector<int> arr(n); // vector 本质上就是动态数组我漏过这个坑一次:实习时用int arr[n]写了个功能,本机 GCC 跑得好好的,上 Linux 服务器一编译直接报错 “expression must have a constant value”。从那时候起我就记住了,运行时才知道大小的数组,老老实实用 vector 或 new。
说到 vector,它就是“动态数组”的典型代表。和普通数组比,vector 最大的好处是能自动扩容、自动管理内存,而且它仍然保证元素在内存中是连续存放的——这意味着你依然可以用 O(1) 随机访问,同时享受“不用手动管理内存”的现代 C++ 体验。扩容的时候,vector 会重新找一块更大的连续内存,把所有元素拷贝过去,这个过程均摊下来是 O(1),但单次的代价其实是 O(n)。这也是为什么高频插入时很多人会直接预分配reserve。
1.3 二维数组真的“二维”吗
一维数组是线性的,二维数组就变成了“表格”。但二维数组底层到底怎么存?这是面试高频题。
C/C++ 的二维数组:int arr[3][4],物理上其实就是一维排列,12 个 int 按行优先(row-major)连续排在内存里:先存第 0 行的 4 个,再存第 1 行的 4 个,最后存第 2 行的 4 个。所以二维数组arr[i][j]的地址计算公式是首地址 + (i × 列数 + j) × 元素大小。这也是为什么“多维数组”可以通过指针伪装成一维数组来遍历,因为内存上它本来就是连续的。
Java 的二维数组:int[][] arr = new int[3][4],这其实是一个“数组的数组”——arr本身是一个保存了 3 个引用的数组,每个引用指向一个长度为 4 的一维 int 数组。所以 Java 的二维数组每一行的内存地址可能是分散的,不是一整块连续内存。这就产生了一个十分经典的面试题:C++ 的二维数组能通过int* p = arr[0]然后线性遍历 12 个元素,Java 为什么不能?答案就是 Java 的二维数组不是连续存储的,无法用“首地址 + 偏移量”的方式去线性计算整块位置。
这个差异直接影响了你在不同语言里做算法题的策略。比如要用动态规划处理二维 DP 数组,Java 里你创建一个int[n][m]其实创建了 n+1 个对象,GC 压力比 C++ 的连续内存数组要大,但写起来确实方便。面试的时候能把这段差异讲清楚,绝对是一个加分项。
1.4 指针数组与数组指针:C 系语言的世纪难题
热词里出现了“指针数组”和“c++ 多维数组 指针”,这其实是同一个知识块里的两个概念,很多人混了三年还没搞清楚。
指针数组(Array of Pointers):本质是一个“元素是指针”的数组。写法是int* arr[5],读法是“arr 是一个数组,数组里有 5 个 int*”。比如字符串数组const char* names[3] = {"Alice", "Bob", "Cindy"};,这里names就是一个指针数组,每个元素指向一个字符串常量。
数组指针(Pointer to Array):本质是一个“指向数组”的指针。写法是int (*arr)[5],读法是“arr 是一个指针,指向一个含 5 个 int 的数组”。它的典型应用场景是二维数组的函数传参:
void printMatrix(int (*matrix)[4], int rows) { for (int i = 0; i < rows; i++) { for (int j = 0; j < 4; j++) { cout << matrix[i][j] << " "; } cout << endl; } } int main() { int matrix[3][4] = {0}; printMatrix(matrix, 3); // 数组名退化为指向首个元素的指针,首元素是 int[4] 数组 return 0; }怎么区分这两种要命的写法?我教你一个野路子:先找变量名,然后看它先跟谁结合。int *arr[5],变量名是 arr,它先跟[5]结合(因为中括号优先级高于星号),所以 arr 先是一个数组,然后数组的元素是 int*。int (*arr)[5],因为强制加了括号,arr 先跟*结合,所以 arr 先是一个指针,这个指针指向 int[5] 类型。
一句话总结记忆:指针数组是“装着指针的数组”,数组指针是“指向数组的指针”。做题的时候遇到二维数组传参,优先用数组指针;遇到要存多个动态分配的数组地址,用指针数组。
1.5 语言特性对照:JS 数组和 C++ 数组不是一回事
热词里有大量 JS 相关的内容,比如“js数组排序的几种方法”“数组转字符串”“数组去重”“数组方法”等。我必须强调一句:在做算法题的时候,不同语言的“数组”根本不是同一个物种。
- C/C++ 数组:定长、连续、同类型,是真正的传统数组。
- Python 的 list:本质上是一个“对象指针数组”,存的是元素的引用,所以它可以混合类型。
list的连续性是“引用连续”,不是“元素对象连续”。 - JavaScript 的 Array:那就更是“万物皆可存”了,底层实现甚至可能是哈希表。V8 引擎会针对不同情况自动在“快数组(连续存储)”和“慢数组(字典存储)”之间切换。
所以遇到数组题,别被语言特性带偏。比如 JS 的sort(),默认是把元素转成字符串再按字典序排序,你排序数字[10, 9, 100]会得到[10, 100, 9],不传比较函数直接翻车。我刚开始刷题时用 JS 写排序题,查了半天 bug,最后发现是sort的默认行为坑了我。
另外,C++ 的数组是“值类型”的存储实体,Java 的数组是“引用类型”的对象,JS 的数组是一个“对象”。这直接决定了你在函数里修改数组时,到底传的是值还是引用。C++ 传数组给函数时数组名会退化为指针,函数内修改直接影响原数组;Java 传数组其实传的是引用地址,也一样影响原数组;但 JS 里你如果把整个数组重新赋值给一个新数组,原数组是不变的。
2. 二分查找:十倍速刷题法,先攻克最经典的 704
2.1 题面与核心思路:二分不是“猜”,是不断缩小搜索空间
Day01 的数组 part01 通常配套的经典题目是 LeetCode 704(二分查找)和 27(移除元素)。我们先看 704:给定一个升序的整数数组和一个目标值,返回目标值的下标,不存在则返回 -1。
很多新手看到“升序 + 查找”第一反应是直接遍历嘛,O(n) 也不慢。但如果数组有十亿个元素呢?遍历十亿次和二十次,差距是天上地下。二分的核心逻辑其实就是一个“猜数字游戏”的计算机化版本:你在 1 到 100 之间猜一个数,每次告诉你猜大了还是小了,最优策略永远是猜中间值,一次能排除一半的选项。这就是二分查找——每比较一次,搜索区间缩小一半,所以时间复杂度 O(log n)。
但为什么这么“简单”的算法,LeetCode 评论区却被称为“思路十秒,调 bug 一小时”?因为二分查找的难点从来不是“懂不懂折半”,而是边界条件——当左右指针撞在一起的时候,到底是left < right还是left <= right?区间收缩时是mid还是mid + 1?这些细节错了,程序要么死循环,要么漏掉目标值。
2.2 左闭右闭还是左闭右开:边界条件的唯一正解
写二分查找之前,第一件事是明确区间的定义。所谓区间,就是你的搜索范围。两种最主流的写法:
写法一:左闭右闭 [left, right]
- 初始化:
left = 0; right = nums.size() - 1; - 循环条件:
while (left <= right),因为 left 和 right 都是有效的下标,二者相等时当前元素仍然要检查 - 收缩规则:当
nums[mid] > target时,说明目标在左边,right = mid - 1;当nums[mid] < target时,left = mid + 1 - 退出循环时:
left > right,说明整个区间已经被搜空,return -1
写法二:左闭右开 [left, right)
- 初始化:
left = 0; right = nums.size(); - 循环条件:
while (left < right),因为 right 本身不指向有效元素,当 left 和 right 相等时,区间为空 - 收缩规则:当
nums[mid] > target时,right = mid(因为 mid 已经在右边区间之外,但 mid 本身不包含在 [left, mid) 中);当nums[mid] < target时,left = mid + 1 - 退出循环时:
left == right,区间为空,return -1
两种写法都完全正确,区别只是区间的哲学。但你要记住:不要混用。如果你初始化是左闭右闭,收缩却用right = mid,那么当mid == right时会陷入死循环;如果你初始化是左闭右开,收缩却用right = mid - 1,那你可能会错过边界元素。
我个人推荐新手用“左闭右闭”,因为它的定义最直白,所有下标都在数组有效范围内,调试的时候打印left和right也不会出界。左闭右开的好处在于和 C++ STL 的迭代器区间风格一致,很多标准库算法都这么写。你只需要选一种,练到形成肌肉记忆。
2.3 核心细节:mid 计算、循环条件、区间更新三件套
二分查找有三个核心细节,任何一个写错都是灾难:
细节一:mid 的计算。很多人写mid = (left + right) / 2。这在整数溢出的情况下会出问题——如果 left 和 right 都是很大的 int,两者相加可能超过 int 能表示的最大值(2^31 - 1)。正确写法是mid = left + (right - left) / 2。这个公式的本质是先算区间长度的一半,再加到 left 上,避免直接相加溢出。很多老工程师写二分也是这个习惯。
细节二:循环条件。左闭右闭写while (left <= right),左闭右开写while (left < right)。判断自己写没写错的办法是:想想循环退出时区间里还有没有元素。左闭右闭里left == right时区间里还有一个元素,必须检查,所以用<=;左闭右开里left == right时区间已经空了,所以用<。
细节三:区间更新。核心心法:mid 已经被检查过了,所以无论如何都要把它排除在新区间之外。左闭右闭时,既然 mid 不在新区间,那么如果 target 在左边,新区间的右边界只能是mid - 1;如果 target 在右边,左边界只能是mid + 1。左闭右开时,右边界本来就是开区间,所以可以是mid,但左边界是闭区间,所以还是要mid + 1。
我把 704 的完整代码写一遍(C++ 左闭右闭版):
class Solution { public: int search(vector<int>& nums, int target) { int left = 0; int right = nums.size() - 1; // 左闭右闭区间 [left, right] while (left <= right) { // 区间不为空就继续 int mid = left + (right - left) / 2; // 防溢出写法 if (nums[mid] > target) { right = mid - 1; // target 在左半边,mid 排除 } else if (nums[mid] < target) { left = mid + 1; // target 在右半边,mid 排除 } else { return mid; // 找到了 } } return -1; // 区间空,没找到 } };有的同学问:为什么数组是升序的才能二分?因为只有升序才能保证“mid 左边都比它小、右边都比它大”,这样你才能通过一次比较排除一半。如果是无序数组,二分就失效了,得先排序。这也是“二分查找”类题目的前提条件——有序。
2.4 常见变体与延伸:搜索左边界右边界的问题
热词里提到“算法工程师面试”,那你就应该知道,面试考二分绝不会只考 704 这种原题。常见的变体有三个:
变体一:查找第一个等于 target 的位置(左边界)。思路是:即使nums[mid] == target,也不急着返回,而是把 right 继续往左缩,直到区间为空,最后 left 就是第一个等于 target 的位置。左闭右开写法:
// 搜索左边界,没找到返回 -1 int leftBound(vector<int>& nums, int target) { int left = 0, right = nums.size(); while (left < right) { int mid = left + (right - left) / 2; if (nums[mid] >= target) { right = mid; // 等于 target 也继续往左收缩 } else { left = mid + 1; } } if (left < nums.size() && nums[left] == target) return left; return -1; }变体二:查找最后一个等于 target 的位置(右边界)。反过来,等于 target 时把 left 往右缩,最后 right - 1 就是最后位置。
变体三:查找第一个大于 target 的位置。这是二分法在很多排序场景中的“母题”,比如 C++ 标准库的lower_bound就干这个事。
面试中被问“二分查找”相关的题目,你要能主动说出:二分不是只能查“等于”,还能查“第一个/最后一个满足某条件的元素”。这就是“二分答案”思想的起点——不光是数组,只要问题的解空间是单调的,都可以用二分来逼近。LeetCode 上 35(搜索插入位置)、34(在排序数组中查找元素的第一个和最后一个位置)都是这套思路的直接应用。
3. 移除元素:暴力到双指针的进化之路
3.1 题目分析与暴力解法的效率陷阱
第二道经典题是 LeetCode 27 移除元素:给你一个数组 nums 和一个值 val,你需要原地移除所有数值等于 val 的元素,返回移除后数组的新长度,且不需要考虑数组中超出新长度后面的元素。
注意题目的两个关键词:原地和不考虑超出新长度后面的元素。这就意味着你不能开一个新数组来装结果,必须直接在原数组上“动手”。很多新手第一反应是用库函数,比如 C++ 的std::remove或 JS 的splice,但刷题的核心目的是练思想,不是调 API,所以我建议你先自己实现一遍。
暴力解法其实很直白:从头遍历,遇到等于 val 的元素,就把后面的所有元素整体往前挪一位,然后数组逻辑长度减 1。每删一个元素,最坏情况下要移动 O(n) 个元素,外层遍历又是 O(n),所以暴力解法的时间复杂度是 O(n²)。我在没学双指针以前,用这种写法写 27 题,提交能过但是耗时惨不忍睹,因为 LeetCode 的测试用例可能有一个几万长度的数组,只要 val 在开头附近,后面全是地动山摇的移动。
3.2 快慢指针法:一个循环干完两件事
双指针法里的“快慢指针”思路极其优雅:用两个下标,一个慢指针 slow 指向“下一个可以放新元素的位置”,一个快指针 fast 遍历整个数组。fast 每次往前走,如果发现nums[fast] != val,就把nums[fast]的值写到nums[slow]的位置,然后 slow 也往前走一步;如果nums[fast] == val,slow 原地不动,fast 继续扫描。
代码是这样的:
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]; } } return slow; // slow 恰好就是新数组的长度 } };你品一下这段代码的精髓:快慢指针本质上就是用“一个循环”同时完成“扫描”和“写回”两件事。按常理,你要先扫描找出哪些该保留,再决定把它们放哪——那至少得两个循环或者一堆临时数组。但慢指针提供了一个“游标”,快指针每发现一个不该删的值,就直接把它放到慢指针指向的位置,慢指针再往前推进。因为慢指针永远领先不过快指针,所以永远不会覆盖还没处理的元素。
时间复杂度 O(n),空间复杂度 O(1)。跟暴力解法的 O(n²) 相比,完全是降维打击。这也解释了为什么“ removeElement ”这类题是双指针的入门代表——你写完这个,后面链表的快慢指针、滑动窗口的双指针,基本都能慢慢理解了。
我想提醒一个细节:返回的 slow 既是新数组的长度,也是下一轮操作可用的“写入位置”,这类“长度即指针”的写法在 C/C++ 里很常见。很多同学会写成slow += 1放最后,逻辑没错,但不如nums[slow++] = nums[fast]这种一步到位干净,训练营打卡阶段建议尽早养成这种紧凑但清晰的编码习惯。
3.3 相向双指针:极致优化的另一个视角
快慢指针解决了“保留元素相对顺序不变”的需求。但 27 题里并没有说“相对顺序必须保持不变”,只要求移除所有等于 val 的元素。这时候还有另一种写法——相向双指针(左右指针)。
思路是把左指针 left 放在数组开头,右指针 right 放在数组末尾。从左往右找第一个等于 val 的位置,从右往左找第一个不等于 val 的位置,然后交换或覆盖。本质上是用右边的“好元素”去填补左边的“坏元素”位置。
class Solution { public: int removeElement(vector<int>& nums, int val) { int left = 0, right = nums.size(); while (left < right) { if (nums[left] == val) { nums[left] = nums[right - 1]; // 用右边的元素覆盖 right--; } else { left++; } } return left; } };这种写法的最坏情况时间复杂度也是 O(n),但优势是交换次数更少——右边那些不等于 val 的元素直接搬到左边“补位”,等于 val 的元素则被甩到数组末尾不管了,不需要像快慢指针那样把每个非 val 元素都搬一次。不过它的代价是改变了元素的相对顺序。
实际面试中,如果题目没有对顺序有要求,用相向双指针其实更高效;如果题目要求保持相对顺序(比如“stable remove”),就必须用快慢指针。这个判断能力本身就是面试官想考察的点。
3.4 为什么不能依赖库函数:以 JS 的 splice/filter 为例
很多 JS 选手写移除元素会想到splice或filter:
// 错误示范:在循环中 splice 删除元素 for (let i = 0; i < nums.length; i++) { if (nums[i] === val) { nums.splice(i, 1); i--; // 删完后下标要回退,否则会跳过一个元素 } }这里至少有两个坑。第一,splice本身是 O(n) 操作,每次删除都会把后面的元素集体搬移,最坏情况还是 O(n²),跟你手写暴力解法没有本质区别。第二,循环里删除后如果不手动i--,会跳过被删除位置后移过来的那个元素,造成漏删。我当年就是这么翻车的,查了半天才想明白。
filter倒是简洁:
nums = nums.filter(x => x !== val);但题目要求“原地”,filter返回的是一个全新的数组,虽然代码短,但本质上不符合题意,空间复杂度是 O(n)。
所以刷算法题时请记住:API 能用,但你要能讲清楚它内部干了什么、复杂度是多少。训练营打卡的意义就是逼你从底层实现一遍,把内功练好。等面试考“你会不会数组去重”的时候,你如果能手写双指针版本,再补一句“如果用 JS 内置的 Set/Array.from 也能去重但空间 O(n),双指针排序后可以做到 O(1) 空间”,那就是降维打击。
4. 刷题路上的坑与排查:数组相关的常见问题速查
4.1 越界、死循环、忘更新指针:三个高频 Bug 现场
数组题最常见的 bug 就三类:越界、死循环、指针没更新。我把真实的踩坑现场摆出来:
越界现场。C/C++ 的数组越界不一定会直接崩溃,因为系统不检查边界,你读的是“那块地址上的内存”,但如果恰好碰上内存保护页,程序就段错误。更可怕的是“写越界”——它可能不会立刻报错,而是悄悄破坏相邻地址的数据,调试的时候你根本找不到源头。所以自己写代码时一定要提前想清楚边界:for (int i = 0; i < nums.size(); i++)里,如果循环体里出现了nums[i + 1],就要小心 i 到size() - 1时会不会越界。二分查找里mid = left + (right - left) / 2也要保证 left 和 right 的区间始终有效。大部分二分死循环的根因就是 mid 没排除干净,导致区间始终不变。
死循环现场。我写二分时最常见的问题是while (left <= right)里,更新写成right = mid而不是right = mid - 1。假设left = 0, right = 1, mid = 0,如果nums[mid] > target,理论上新区间应该是 [0, -1] 空区间,但你写成right = mid后,新区间还是 [0, 0] 非空,下一次还是一样的状态,无限循环。解决口诀就一句:检查过的 mid 必须排除,左闭右闭用 mid ± 1,左闭右开右边界才能用 mid。
忘更新指针现场。移除元素的双指针法里,很多新手写了nums[slow] = nums[fast]却忘了slow++,结果数组是被覆盖了,但 slow 一直停在 0,返回的长度永远是 1。调试时打印 slow 和 fast 的值立刻就能看出来,关键是写代码时心里要有“游标”的概念:slow 和 fast 不是摆设,每次动作都要动。
4.2 数组去重、转字符串、排序:面试中的高频小操作
热词里有一堆数组小操作——去重、排序、转字符串、切片,这些在面试里经常被当作“前菜”随手考,但最容易被基本功不扎实的人卡住。我把主流语言的做法和复杂度整理一遍:
数组去重:
- JS:
[...new Set(arr)],O(n) 时间 O(n) 空间,代价是 Set 本身有额外开销;面试时如果被要求“原地去重且有序”,实际上是对已排序数组用双指针——一个指针扫描,一个指针指向“下一个不重复元素的位置”。 - Python:
list(set(arr))会丢失顺序,想要保持顺序可以用dict.fromkeys(arr)或者列表推导 + set 判断。 - C++:
sort(arr.begin(), arr.end()); arr.erase(unique(arr.begin(), arr.end()), arr.end());,这里面 unique 只负责把不重复的挪到前面并返回迭代器,真正“删尾巴”的是 erase。这个组合是 C++ 面试高频套路。
数组转字符串:
- JS:
arr.join(',')(注意arr.toString()和join(',')在嵌套数组时表现不同,toString会把所有嵌套层都拍平,join只处理当前层)。这细节在笔试时坑过我好几次。 - Python:
','.join(map(str, arr)),注意 join 要求所有元素都是字符串,否则要先 map。 - C++:C++ 标准库没有直接的 “join”,需要自己写循环拼接或者用 accumulate 加函数对象。
数组排序(重点讲 JS):arr.sort()的默认规则是把元素转字符串再比字典序,所以数字排序必须写arr.sort((a, b) => a - b)。这个坑我已经说过,但每次看到还是有人踩,因为它太反直觉了。另外要注意 sort 是原地排序,会改变原数组;如果不想影响原数组,先[...arr].sort(...)。
数组切片:
- Python:
arr[1:4]是左闭右开,范围是下标 1 到 3。这个语法太顺滑,以至于我转写 JS 时老是把arr.slice(1, 4)也当成左闭右开——好消息是 JS 的 slice 一样是左闭右开。 - JS:
arr.slice(1, 4)不修改原数组,返回新数组;arr.splice(1, 3)是删除并修改原数组,别把这两个搞混。面试手写题前一定先确认题目让不让你修改原数组。
4.3 一页纸速查表:数组核心操作与复杂度对照
我把 Day01 涉及的数组操作整理成了一张速查表,刷题时可以直接对照:
| 操作 | 静态数组 / C 数组 | C++ vector | Java ArrayList | JS Array | 时间复杂度 |
|---|---|---|---|---|---|
| 随机访问 arr[i] | 支持 | 支持 | 支持 | 支持 | O(1) |
| 末尾追加 push_back / add / push | 不支持(定长) | 支持 | 支持 | 支持 | O(1) 均摊 |
| 中间插入 insert | 不支持 | 支持(O(n)) | 支持(O(n)) | splice(O(n)) | O(n) |
| 中间删除 erase | 不支持 | 支持(O(n)) | 支持(O(n)) | splice(O(n)) | O(n) |
| 查找指定值 | 手写循环 | find(O(n)) | indexOf / contains(O(n)) | indexOf / includes(O(n)) | O(n) |
| 排序 | 手写快排 | sort(O(n log n)) | Collections.sort(O(n log n)) | sort + 比较函数(O(n log n)) | O(n log n) |
从上表可以看出一条主线:凡是要在数组中间动元素的操作,代价都是 O(n),因为连续存储决定了你需要挪动后续所有元素。理解了这一条,你就能解释为什么很多算法题要追求“原地”“双指针”“一次遍历”这些优化——因为大多数时候,我们其实不需要真的“删”元素,只需要用指针把逻辑上的新数组划分出来,从而把 O(n²) 变成 O(n)。
我还想补充一句关于“循环队列”的热词:有同学搜“假设以数组 q[m] 存放循环队列中的元素”,这是数据结构考试里的经典应用题。它本质上是把一维数组“掰弯”成环形,通过(rear + 1) % m这样的模运算实现队尾和队头的循环追赶。循环队列存在的意义就是避免假溢出——线性队列出队后前面的空间没法复用,而循环队列能把“逻辑上已删除”的位置重新用于入队。这块基础对后面学栈、队列、滑动窗口非常有帮助,如果你连数组都不熟,看到% m取模操作会一头雾水。
5. 学习打卡节奏与训练营复盘心得
Day01 的内容看起来不多,就两题加一堆理论,但训练营真正的意义在于把散装知识点串成体系。我建议第一天不要贪多,老老实实把这两道题的两种写法(二分左右边界、快慢和相向双指针)各写三遍以上,写到不卡壳为止。算法能力不是看会的,是写会的。
我个人体会最深的一点是:“看懂了”和“能写对”中间隔着一条巨大的鸿沟。看题解五分钟就懂了,合上书自己写,边界条件、指针更新全崩。这不是因为你笨,是因为算法题的输出不仅是“思路”,还是“对细节的肌肉记忆”。所以哪怕你今天只是把数组理论复习了一遍、把两题各 AC 了一遍,已经比 90% 只收藏不看的人强很多了。
还有一个每天都能用上的小技巧:写完题之后,顺手在代码注释里写一句“这道题的坑在哪”,比如“二分右边界注意 mid-1 还是 mid”。隔一周再回头看,这句话比任何笔记都管用。我翻自己两周前的注释,常常会心一笑——当时卡了一小时的点,现在一眼就能看穿。这就是成长。
如果你今天也在跟着训练营打卡,建议给自己定个规矩:每天学完后用一句话说出今天最核心的心得,比如“数组是连续内存,所以中间操作 O(n),但随机访问 O(1)”。别小看这一句话,坚持三十天,你积累下来的就是一套自己的算法知识图谱。Day01 的数组 part01 就到这里,我准备去做 Day02 了,希望这份拆解对你有实打实的帮助。