打卡《代码随想录》第一天,数组 part1 学完,最大的感受是:数组这玩意儿,看着谁都认识,一写就崩。尤其是二分查找的边界、双指针的移动时机,还有滑动窗口的收缩逻辑,光看题解觉得懂了,合上屏幕自己写一遍,全是问题。这篇文章就把我第一天的完整学习过程、踩过的坑、以及从热搜词里看到的大家共同困惑整理出来,给同样在刷数组基础题的朋友做个参考。
我用的语言是 C++ 为主,部分题用 JavaScript 对比实现,因为数组操作在不同语言里的表现差异很大,不说清楚这一点,后面写代码会非常难受。这篇文章适合刚开始刷题、或者算法基础不牢想系统过一遍数组专题的人,也适合那些“题目能看懂但代码写不利索”的中间状态选手。
1. 数组第一天:先搞懂内存里那张“连续床位”
很多人在数组这块卡住,根本不是算法题难,而是对数组这个数据结构本身的底层认知不够。所以真正动手刷题之前,我花了半小时重新捋了一遍数组的本质——这部分虽然不直接得分,但它是后面所有题目的地基。
1.1 数组的核心特性:连续、固定、O(1) 访问
数组在内存里是一段连续的空间,每个元素紧挨着下一个元素,就像火车车厢一样,一节连着一节,中间没有空位。因为连续,所以系统可以通过“起始地址 + 下标 × 元素大小”直接算出某个元素的位置,这就是为什么数组按下标访问是 O(1)。
这句话反过来推导,就能理解很多“常识”的代价:
- 既然内存是连续的,那么插入或者删除一个元素,后面所有元素都得整体挪动。最坏情况下,在开头插入元素,所有元素都要往后挪,时间复杂度是 O(n)。所以数组适合“频繁读、偶尔改”的场景,不适合“频繁插入删除”。
- 数组的长度在创建时就已经固定了。C++ 原生数组和 C 语言一样,没有动态扩容能力,但
std::vector封装了动态扩容逻辑,它内部仍然是连续内存,只是当容量不够时会重新分配一块更大的连续空间,把旧数据拷贝过去。这也是为什么vector的扩容代价不小,如果你提前知道大概数量,reserve一下能省下很多无意义的拷贝。
我当时把数组想象成电影院的一排座位:观众必须坐在一起,不能跳着坐。如果中间有人离场,后面所有人都要往左挪一位来补空。这样就很好理解为什么“删除一个元素”这么费劲了。
1.2 语言层面的坑:从热词里看大家真正困惑的点
我写完基础理论后,顺手扫了一眼大家搜得最多的数组相关热词,发现很多高频问题其实都是语法层面的,不是算法层面的。做个简单梳理,后面遇到代码题的时候会少很多障碍。
C++ 环境下的混淆点最多:
- “数组”和“指针”的关系:数组名在很多表达式中会退化成指向首元素的指针,但数组本身不是指针。
sizeof(arr)求的是整个数组的字节数,一旦作为参数传进函数,就退化成指针,sizeof求的就是指针大小了,这是很多 C 语言老手都翻过车的地方。 - “指针数组”和“数组指针”:
int* p[3]是一个数组,里面放了 3 个 int 指针;int (*p)[3]是一个指针,指向包含 3 个 int 的数组。很多热词里都在搜这俩,说明这个区别是普遍痛点。 - “二维数组”:C++ 的二维数组在内存里也是连续的,
int a[3][4]本质是“3 个长度为 4 的一维数组”,访问a[i][j]时编译器按(i * 4 + j) * sizeof(int)计算偏移。以后刷动态规划题时会经常用到二维vector,那时候再回头看这个连续性概念,会对“为什么缓存命中率高”有更深的体会。
JavaScript 环境下的心智模型完全不同:
- JS 的数组其实不是传统意义上的连续内存数组,它更像一个对象,下标是对象的键。这就导致它的
Array可以装任意类型,可以稀疏(new Array(3)会产生空洞),很多操作其实走的是对象属性的逻辑。 - 扩展运算符
[...arr]是浅拷贝,二维数组用这种方式拷贝,里面的子数组还是同一个引用,修改时容易出鬼。热词里“js 怎么用扩展运算符把一个数组里面的值都添加到另外一个数组”,正确写法确实是const newArr = [...arr1, ...arr2],但要注意这是浅拷贝。 - 删除元素有
splice、filter、pop、shift等,各自语义不同。splice是原地修改,filter是生成新数组。刷算法题时如果用了splice,要注意它的时间复杂度是 O(n),在循环里频繁用会退化得很厉害。
不是说第一天就要把这些全掌握,但你至少要知道:你在写 C++ 的时候,面对的是“连续内存 + 手动管理大小”的模型;在写 JS 的时候,面对的是“灵活对象 + 数组方法”的模型。同一个算法思路,两种语言的实现细节很不一样。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 二分查找:为什么你会死循环,边界的数学逻辑拆解
二分查找看起来是数组 part1 里最简单的,实际上是最容易写错的,没有之一。我在群里看到很多第一天打卡的朋友都说:“明明思路没问题,为什么一跑就死循环或者返回错下标?”这个问题的根子,在于你没有把“区间不变量”焊死在代码里。
2.1 左闭右闭写法的隐形条件
我们先用最常见也最容易理解的一种写法:区间定义为 [left, right],左右端点都包含。
核心的“不变量”只有一句话:目标值在整个 [left, right] 区间内查找,区间里的每一个位置都可能是答案。
这句话会直接推导出三个细节:
- 初始化时
left = 0,right = nums.size() - 1,因为末尾元素下标是 size-1,且它可能成为答案,所以必须被包含在区间内。 while循环条件是left <= right,因为当left == right时,区间里还有一个元素没检查,它可能就是目标值,所以“等于”的情况必须进入循环。- 当
nums[mid] > target时,目标值一定在 mid 左边,但 mid 本身已经检查过,不需要再保留,所以下一步right = mid - 1。同理left = mid + 1。
C++ 的参考实现:
cpp复制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) {
right = mid - 1;
} else if (nums[mid] < target) {
left = mid + 1;
} else {
return mid;
}
}
return -1;
}
这里有个值得注意的细节:mid 的计算,为什么写成 left + (right - left) / 2
