打卡《代码随想录》第一天,数组 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 而不是 (left + right) / 2?因为后者在 left 和 right 都很大时会溢出,虽然刷题时很少遇到,但这是面试官喜欢问的点。
我拿一个具体例子走一遍:
code复制nums = [1, 3, 5, 7, 9], target = 5
初始: left=0, right=4, mid=2, nums[2]=5, 直接命中, 返回 2
那如果 target 不存在,比如 target=4 呢?最终会怎样退出循环?答案是当 left 超过 right 时退出,因为此时区间为空,必然不存在。这段代码退出时 left 指向的位置正好是“第一个大于 target 的元素”的位置,这其实是后话,很多变体题会用到这个性质。
2.2 左闭右开写法与死循环现场
另一种常见写法是区间定义为 [left, right),左闭右开。这种写法的“不变量”是:目标值在 [left, right) 区间内查找,right 位置不参与查找。
于是每个细节都要跟着变:
- 初始化
right = nums.size(),因为 right 本身不包含,所以可以指向数组末尾的“下一个位置”。 while循环条件是left < right,因为当left == right时区间已经空了,没有未检查的元素。- 当
nums[mid] > target时,下一步right = mid,因为 mid 虽然不可能是答案,但 mid 本身已经检查过,而当前区间不包含 mid 作为端点时,把 right 设成 mid,正好把 mid 排除在区间外;如果你写成mid - 1,就会把一个未检查的元素直接划走,导致漏判。 - 当
nums[mid] < target时,left = mid + 1,这个和左闭右闭一样,因为 left 是包含端点,mid 检查过就必须越过它。
很多人死循环就死在 right = mid 还是 right = mid - 1 上。我的建议是:不要靠背,而是每次写代码前,先用一小段区间推演一遍。比如假设现在区间是 [0, 1),里面只有下标 0 一个元素,mid = 0。如果 nums[0] > target,你写成 right = mid - 1,right 变成 -1,区间被清空,循环退出,表面上没有死循环,但因为漏掉了可能藏在中间的某个元素,返回结果可能是错的。更隐蔽的是某些变体里你写完 left = mid,在区间长度为 2 时 mid 永远等于 left,left 永远不动,死循环。
左闭右开 JS 参考:
javascript复制const search = (nums, target) => {
let left = 0;
let right = nums.length;
while (left < right) {
const mid = left + Math.floor((right - left) / 2);
if (nums[mid] === target) return mid;
if (nums[mid] < target) left = mid + 1;
else right = mid;
}
return -1;
};
2.3 变体与边界业务:第一个/最后一个目标值,插入位置
第一天如果把标准二分写稳了,可以顺手看看两个变体:
- 查找第一个等于 target 的位置:找到等于 target 的 mid 后不要立即返回,而是把
right继续往左缩,继续逼近左边界。 - 查找最后一个等于 target 的位置:找到等于 target 的 mid 后,把
left继续往右缩,逼近右边界。
这两个变体在实际业务里非常常见,比如“统计某个分数段的人数”“找出日志里最后一次出现某错误码的位置”。我第一天练的时候,发现只要把握住“区间不变量”这个核心,变体不至于慌乱。你甚至可以推导出一个通用公式:当 nums[mid] >= target 时收缩右边界 right = mid,最后 left 就是第一个大于等于 target 的位置,这个位置就是标准库 lower_bound 的行为。
还有一点很重要:别把二分查找限定在“查找数字”这一个场景。热词里出现了“树状数组上二分”,看起来很高端,本质就是在树状数组维护的 prefix sum 结构上做二分定位,比如“找到前缀和刚好超过某个阈值的最小下标”,这种能力其实是你第一天二分基础的地基。后面学到线段树、树状数组时,你会发现它们能解决的区间查询问题里,经常要靠二分去定位某个位置。所以第一天把二分写扎实,绝对不是浪费时间。
2.4 为什么面试总考它
二分查找除了本身的应用,更重要的是它的复杂度分析思路——把 O(n) 的线性查找降到 O(log n)。这个思维模式会贯穿整个算法学习:
- 在有序数组里找一个数:二分。
- 在一个单调函数上找一个阈值:二分答案。
- 在树状数组上定位前缀和达到阈值的位置:二分套树状数组。
我第一天最大的收获之一,就是意识到“有序”是一个强条件,不要浪费它。很多题目给你一个有序数组,你第一反应如果还是 for 循环从头遍历,大概率不是最优解。
3. 移除元素:双指针法的直觉来源与代码落地
第一天的第二个经典题是“移除元素”,要求原地删除所有等于某个值的元素,返回新长度。题目看起来比二分还简单,但它是双指针思想的启蒙题,而且热词里“js 数组删除元素”“数组去重”这些高频搜索,本质上要解决的痛点都和这道题相关。
3.1 先说暴力法:为什么 O(n²) 这么慢
最直接的想法是遍历数组,遇到等于 val 的元素就把它删掉,把后面的元素整体往前移。C++ 里你可以用 vector::erase,但它本身就是 O(n) 的操作,你在一个 for 循环里频繁 erase,外层 O(n) 内层 O(n),整体就是 O(n²)。
很多新手会写成这样:
cpp复制int removeElement(vector<int>& nums, int val) {
for (int i = 0; i < nums.size(); ) {
if (nums[i] == val) {
nums.erase(nums.begin() + i);
} else {
i++;
}
}
return nums.size();
}
这段代码逻辑本身没问题,但性能很差。更重要的是,它用的是库函数,算法的核心被掩盖了。面试官想看到的不是你调用 erase,而是你能不能手动实现“覆盖”这个动作。热词里“c 语言数组删除元素”“c# for循环遍历数组”这类搜索非常多,说明大量人在处理“删除”时都卡在了“遍历过程中删元素,下标会错位”这个点上。双指针就是来解决这个事的。
3.2 快慢指针:一个负责走位,一个负责占坑
双指针法的直觉来源很简单:既然删除元素要移动后面所有元素,那能不能不真的“删”,而是用“覆盖”的方式,把所有不等于 val 的元素按顺序写到数组前部?
这就是快慢指针:
- 快指针
fast负责遍历原数组,它永远往前走,检查每个元素。 - 慢指针
slow负责维护“新数组”的尾部位置,只有遇到不等于 val 的元素时,才把该元素写到nums[slow],然后slow++。
C++ 实现:
cpp复制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 为什么会恰好指向正确的位置?你仔细模拟一遍就明白了。假设 nums = [3, 2, 2, 3], val = 3:
- fast=0,nums[0]=3,等于 val,跳过,slow 不动。
- fast=1,nums[1]=2,不等于 val,
nums[0]=2,slow=1。 - fast=2,nums[2]=2,不等于 val,
nums[1]=2,slow=2。 - fast=3,nums[3]=3,等于 val,跳过。
返回 slow=2,此时 nums 前两位是 [2, 2],后面的 [2, 3] 可以忽略,因为题目只要求返回新长度,不在乎数组后面残留什么。这其实非常贴近实际业务:你“逻辑删除”了某些数据后,把它们覆盖掉,而不是真的物理清空,代价小得多。
这个写法的时间复杂度是 O(n),空间 O(1)。它和“数组去重”的经典解法(有序数组去重)几乎是同一个模板:一个指针维护结果区,另一个指针扫描原区,条件满足就写入。
3.3 相向双指针:改变元素相对顺序的思路
快慢指针的移动次数不一定最少。例如 nums = [1, 2, 3, 4, 5], val = 3,快慢指针需要把 4 和 5 各向前移动一次,一共移动两次。有没有办法更省?有,那就是相向双指针:
- 一个指针
left从头部开始,一个指针right从尾部开始。 - 左指针找“等于 val 的位置”,右指针找“不等于 val 的位置”,然后交换,或者直接把右指针的值覆盖到左指针位置。
这种做法把“不等于 val 的元素”尽可能保留在原地,每次覆盖都把一个需要保留的元素从右侧搬回左侧,从而减少移动次数。它有一个隐藏的副作用——改变了元素的相对顺序。如果题目要求顺序不能变,就不能用这种写法。所以刷题时要看清题目的约束条件,不要一个模板套到底。
3.4 联系现实:热词里的删除与去重问题
我在整理热搜词的时候发现,“js 数组删除元素”“对象数组去重”“数组去重”高频出现。这些看起来是在问 JS 语法,但背后有一个共通的核心:你是在“原地修改”还是“生成新数组”?如果用 filter,生成新数组最方便,代码也最优雅,但它需要额外空间。如果要求手写原地算法,你就得回到双指针这套逻辑。
比如“有序数组去重”这道题,要求原地移除重复元素,返回新长度,和移除元素这道题的解法几乎一脉相承,只是判断条件从“不等于 val”变成“不等于前一个元素”。这就是我说的:模板的价值不在于背,而在于理解它的“快慢指针维护结果区”的底层直觉,你就能举一反三。
4. 有序数组的平方:从两边向中间收缩的“反常识”解法
第三道经典题是“有序数组的平方”。题目给定一个按非递减顺序排序的整数数组,要求返回每个数字平方组成的新数组,并且也按非递减顺序排序。这道题我第一次做的时候,第一反应就是“直接平方,然后 sort 一遍不就完了?”确实能过,但完全没吃到这道题想喂给你的营养。
4.1 直接平方再排序的问题
先看最朴素的解法:
cpp复制vector<int> sortedSquares(vector<int>& nums) {
for (int& x : nums) x = x * x;
sort(nums.begin(), nums.end());
return nums;
}
时间复杂度 O(n log n),空间 O(1)(sort 通常使用原地排序)。如果数组长度不大,这个方案完全够用。但题目给的是“有序数组”,这个条件你只用到了“最后的 sort”,前面的有序性白白浪费了。更重要的是,这道题真正的核心考点是:负数的平方可能变成大数,所以平方后最小的数一定在中间附近,最大的数一定在两端。
这意味着你不能简单地从左到右构造结果,因为左边的负数平方后可能非常大,右边的正数平方后也可能非常大,而中间的数平方后反而小。要从“结果数组的最后一个位置”开始倒着填。
4.2 为什么最大平方数一定在两端
数学直觉是这样的:平方函数在正数范围内单调递增,在负数范围内单调递减。给定一个有序数组,最左边的数负得最厉害,平方后可能很大;最右边的数正得最大,平方后也可能很大;越靠近中间,绝对值越小,平方越小。所以整组平方数的最大值一定出现在左端或者右端,不可能出现在中间。
那么思路就顺了:用两个指针 left 和 right 分别指向原数组的两端,比较 nums[left]^2 和 nums[right]^2,谁大就把谁放到结果数组的末尾(从后往前填),然后移动对应指针。如此反复,直到两个指针相遇。
C++ 实现:
cpp复制vector<int> sortedSquares(vector<int>& nums) {
int n = nums.size();
vector<int> result(n);
int left = 0;
int right = n - 1;
for (int i = n - 1; i >= 0; i--) {
int lsq = nums[left] * nums[left];
int rsq = nums[right] * nums[right];
if (lsq > rsq) {
result[i] = lsq;
left++;
} else {
result[i] = rsq;
right--;
}
}
return result;
}
时间复杂度 O(n),空间 O(n)(结果数组),并且没有改变原数组的顺序,因为是从两端向中间比较,填到新数组里天然有序。
4.3 代码实现与复杂度分析
如果不想初始化一个固定长度的 vector,也可以用 push_back,但那样就必须把结果倒序再反转,反而不如直接倒着填优雅。我在写的时候就踩过一个坑:把 result[i] = ... 写成了 result.push_back(...),结果顺序全反了,还得再多写一个 reverse。其实“从后往前填”这个动作本身,就是在利用你预先知道结果长度的优势。
JS 版本也贴一下,方便你对比:
javascript复制const sortedSquares = (nums) => {
const n = nums.length;
const result = new Array(n);
let left = 0;
let right = n - 1;
for (let i = n - 1; i >= 0; i--) {
const lsq = nums[left] * nums[left];
const rsq = nums[right] * nums[right];
if (lsq > rsq) {
result[i] = lsq;
left++;
} else {
result[i] = rsq;
right--;
}
}
return result;
};
这道题可以和“合并两个有序数组”放在一起看:从后往前填充的原因都是“数组尾部有空位或者不担心覆盖”,这样可以在 O(n) 时间完成合并,而不需要额外空间。第一天如果能把“从后往前”的思维练出来,后面很多题目会非常舒服。
4.4 这道题教会我的东西
我最大的收获是:遇到有序数组,除了二分,还要想“指针能不能在两端做文章”。有序这个条件,不只是可以二分,还可以引导出很多 O(n) 的线性做法。比如后面你会遇到的“三数之和”,就在有序数组上用双指针从两端向中间逼近,才能把 O(n³) 降到 O(n²)。如果你第一天就把“两端指针”这个模型建立起来,后面学三数之和几乎是顺水推舟。
5. 最小子数组长度:滑动窗口的收缩时机才是难点
第一天的最后一题是“长度最小的子数组”。题目给定一个正整数数组和一个目标值 target,找到和大于等于 target 的长度最小的连续子数组,返回其长度。如果不存在就返回 0。
这道题我第一次看的时候觉得很简单,不就是一个 for 循环套一个 while 吗?但真写起来,收缩时机总是错。它真正的难点在于:窗口的左边界什么时候移动,以及移动时更新答案的时机。
5.1 暴力法:两重循环为何会超时
暴力法就是枚举每个起点,然后从起点开始累加,直到和 >= target,记录长度,尝试下一个起点。伪代码如下:
cpp复制int result = INT_MAX;
for (int i = 0; i < n; i++) {
int sum = 0;
for (int j = i; j < n; j++) {
sum += nums[j];
if (sum >= target) {
result = min(result, j - i + 1);
break;
}
}
}
正确性没问题,但最坏情况下是 O(n²),因为每个起点都可能遍历到数组末尾。遇到长数组,比如 nums 里有十万个数,这个复杂度基本就跑不动了。
那怎么优化?核心在于,暴力的外层循环在换起点时,内层循环又从头累加了,但实际上很多累加是可以复用的。
5.2 滑动窗口三要素与“窗口收缩”推导
滑动窗口本质上是一种“双指针”思路,它维护一个动态区间,右指针不断向右扩展,左指针在满足条件时向右收缩。三个要素是:
- 窗口内容是什么:一个连续的子数组,用
[left, right]表示。 - 窗口的起始位置如何移动:当前窗口的和已经 >= target 时,为了寻找更短的窗口,
left向右移动,窗口缩小。 - 窗口的结束位置如何移动:
right在 for 循环中自动右移,每次移动后把nums[right]加入窗口和。
核心难点是“窗口收缩的时机”:不是每次右指针移动后都收缩,而是当 sum >= target 时,开始收缩。收缩时每次 left 右移一格,都要更新一次答案,因为收缩一次就得到一个更短的合法窗口。
C++ 实现:
cpp复制int minSubArrayLen(int target, vector<int>& nums) {
int n = nums.size();
int ans = INT_MAX;
int sum = 0;
int left = 0;
for (int right = 0; right < n; right++) {
sum += nums[right];
while (sum >= target) {
ans = min(ans, right - left + 1);
sum -= nums[left];
left++;
}
}
return ans == INT_MAX ? 0 : ans;
}
为什么 while 而不是 if?因为收缩一次后,sum 可能仍然 >= target,此时还有可能得到更短的窗口,所以要持续收缩,直到不满足条件为止。这也是滑动窗口题里最常见的 bug:写成了 if (sum >= target),导致窗口没有收缩到最短。
5.3 代码实现与典型坑
有一个非常隐蔽的坑:结果初始值的选择。如果初始值是 0,那么 min(ans, ...) 永远会是 0。所以必须初始化为一个很大的值,比如 C++ 里的 INT_MAX,JS 里的 Infinity,最后再判断是否被更新过。如果从来没有进入过 while,说明整个数组的和都达不到 target,此时返回 0。
JS 版本:
javascript复制const minSubArrayLen = (target, nums) => {
let ans = Infinity;
let sum = 0;
let left = 0;
for (let right = 0; right < nums.length; right++) {
sum += nums[right];
while (sum >= target) {
ans = Math.min(ans, right - left + 1);
sum -= nums[left];
left++;
}
}
return ans === Infinity ? 0 : ans;
};
还有一个容易忽略的点:right - left + 1 每次计算的是当前窗口长度。当 left 移动后,窗口长度变短,但 right 没有变,所以这个长度是“当前以 right 结尾且满足条件的最短窗口长度”。这里必须更新答案,因为窗口长度变小了,可能刷新历史最小值。
5.4 窗口思想在后续题目中怎么用
滑动窗口的思想在后面的字符串题里用得特别多,比如“无重复字符的最长子串”“字符串的排列”等。它们共同的核心是:维护一个窗口,通过左右指针移动来保证窗口内的性质,从而在线性时间内穷举所有可能的窗口。这个方法在数组和字符串场景下都适用,第一天把“收缩时机”这个感觉练好,后面就只是换了判断条件而已。
6. 实操排错:语言差异、越界陷阱与调试手法
第一天我除了刷题,还花了不少时间在“调试”上。数组相关代码的报错,很多时候不是逻辑错,而是语言细节引发的。以下这几个问题,我都在真实环境中踩过,专门列出来给大家当参考。
6.1 数组越界:C++ 和 JS 的不同报错体验
C++ 的数组越界是未定义行为。什么意思?就是程序不一定会崩溃,它可能读到相邻内存里的垃圾值,也可能恰好读到旧数据,甚至可能在特定编译器下“正常工作”,然后换一台机器就炸。这种 bug 非常难排查,因为是间歇性的。标准库的 vector::operator[] 也是不检查边界的,at() 才检查,但 at() 有额外开销。所以 C++ 刷题时,养成“动手前先算好下标范围”的习惯特别重要。
JS 的数组越界则比较温和,访问不存在的下标返回 undefined,不会立刻报错。但这种温和反而容易掩盖问题:你可能在 undefined 上做算术,得到 NaN,然后整个结果错误,但你很难第一时间想到是下标越界。所以无论哪种语言,数组越界都是第一优先级要避免的。
我个人的笨办法是在写循环前,先用注释把区间写出来,比如 // [left, right],然后每一步对照这个区间检查。不要觉得这很啰嗦,它能帮你从根上避免很多二分查找的越界和死循环问题。
6.2 vector/Array 的 erase 到底多慢
我前面提过 erase 是 O(n) 的,这里再说残酷一点:在循环里反复 erase,实际成本比想象的还高,因为 vector 删除中间元素需要把后面所有元素整体前移,还可能触发容器的重分配逻辑。JS 的 splice 同理,它在循环里频繁删除是很多性能问题的元凶。
所以当你需要在数组中“删除一批符合某个条件的元素”时,最优手段基本都是这几种:
- 用一个变量维护写入位置,用“覆盖”代替“删除”(双指针思想)。
- 需要保留原数组时,遍历原数组,把符合条件的元素放到新数组里(空间换时间)。
- 如果顺序无关且不需要保留原数组,可以和最后一个元素交换后
pop(O(1) 删除技巧,这个在后续题目里会用到)。
记住这个原则:能覆盖就不删除,能 O(1) 就不 O(n),能用下标就用下标。
6.3 调试手法:用打印区间和断言代替瞎猜
我在第一天学二分和滑动窗口时,把下面这套调试手法当成了固定套路:
- 在
while循环里打印left、mid、right(二分)或left、right、sum(滑动窗口)。每次循环只打印一行,别贪多,不然信息量太大反而看不清。 - 用最简单的输入样例跑一遍,比如
nums = [1,2,3,4,5]这种短数组,人工手推一遍期望结果。 - 如果出现死循环,立刻检查指针移动是否真的在向“区间缩小”的方向走。二分的
mid必须让区间长度严格缩小;滑动窗口的left只有在while内移动才会变,如果while条件永远为真,那就看sum -= nums[left]有没有执行到。
还有一个很实用的技巧:在关键位置加 assert。C++ 里 assert(mid < nums.size()),JS 里可以用 console.assert。这些断言一旦触发,能比打印更早定位问题。
6.4 从热搜词看“数组新手”共同卡点:二维数组与指针的疑惑
我在整理热词时发现,除了基础算法题,大家搜得多的还有二维数组和指针相关的问题。这说明很多人在刷题过程中,不是算法思路卡住,而是“容器/语法”卡住。这里稍微展开一下,给同样被卡住的朋友一些方向。
以二维数组为例,C++ 里最常见的有两种写法:
vector<vector<int>> matrix(m, vector<int>(n, 0)):外层 vector 的每个元素又是一个 vector,每一行内存是独立的连续段,行与行之间不保证连续。- 原生二维数组
int matrix[m][n]:整体是一块连续内存,按行优先排列。
很多人纠结“多维数组 c++ 指针”是因为:把二维数组传进函数时,int** 并不能直接接受 int matrix[][n]。函数参数要写成 int (*matrix)[n] 才能匹配原生二维数组。这也是为什么刷题时我更推荐用 vector<vector<int>>,它能避免大量 C 语言历史遗留问题。int** 并不是二维数组的等价物,它是一个“指向指针的指针”,用来模拟二维结构时还要自己管理内存,很容易漏 delete。
JS 里创建二维数组也要小心:
javascript复制const matrix = new Array(m).fill(new Array(n).fill(0));
这种写法有个天坑:fill 填充的是同一个数组引用,也就是说每一行其实是同一个数组,改一行等于改所有行。正确写法是:
javascript复制const matrix = Array.from({ length: m }, () => new Array(n).fill(0));
这些都是非常实用的细节。算法题的逻辑再漂亮,如果容器用错,照样跑不出正确结果。第一天把这些小坑填平,对后续动态规划、图论里大量二维数组的操作帮助很大。
7. 结尾:第一天的收获与下一步建议
刷完数组 part1,我的整体感受是:内容量不算大,但每个都是“看似简单,实则暗藏杀机”。二分查找考的是区间不变量,移除元素考的是覆盖思想,有序数组的平方考的是两端指针,滑动窗口考的是收缩时机。四道题各有各的“坎”,但练完之后,你会发现它们有一个统一的底层逻辑:都建立在“连续内存 + 下标访问”的数组特性之上。
我个人的建议是:第一天别急着往前赶,把这四道题反复写三遍。第一遍照着题解抄,第二遍合上书自己默写,第三遍把语言换掉(比如 C++ 换 JS)再写一遍。这样做的目的不是练肌肉记忆,而是让你在不同表达方式中,剥离出算法的骨架。我自己在换语言重写时,才发现之前对“区间不变量”的理解其实还停留在背代码层面,换成另一种语言后一切又模糊了。
再分享一个小技巧:每写完一道题,在注释里写一句话,用大白话总结它的核心思路。比如二分查找可以写“每次砍掉一半,关键在于确认哪一半不可能有答案”;滑动窗口可以写“右指针无条件前进,左指针在满足条件时收缩,每次都检查窗口长度”。这句话不是写给未来的读者,是写给一周后的自己。刷题最怕的是“看着眼熟,却写不出”,有了这句话,你第二次复习时几分钟就能捡起来。
后续的链表部分,其实也和数组强相关——链表和数组的对比经常会问:为什么数组插入删除慢,链表插入删除快?如果你在数组这一节把“连续内存带来的代价”理解透了,这个对比题就是送分题。祝大家第一天打卡顺利,数组地基越牢,后面的房子才能盖得越高。
