1. 数组题目整体设计与思路拆解
1.1 为什么刷算法要从数组开始
数组这个数据结构,看起来简单到不能再简单——连续内存、按下标访问、O(1)随机读取,C语言课第一节就讲完了。但你要是真去刷过题就会明白,数组恰恰是算法面试里最容易被轻视的"隐形杀手"。二分查找的边界条件能让你调一晚上bug,移除元素的原地操作考验的是你对内存布局的理解,滑动窗口的双指针写法稍不留神就越界。
代码随想录把数组放在整个刷题路径的第一天,不是因为它简单,而是因为它是构建算法思维的第一块基石。数组题考的不是语法,是三个底层能力:区间控制能力(你能不能用一个循环不变量把边界锁死)、指针调度能力(双指针、快慢指针、左右指针本质都是对线性结构的坐标管理)、空间意识(原地操作意味着你不能随便开新数组,每一次移动都要算清楚代价)。这三个能力一旦在数组阶段练扎实了,后面链表、字符串、哈希表这些专题都能省一大半力气。
我见过太多人一上来就刷动态规划,结果连状态转移的"前一个位置"都搞不清楚,就是因为数组的基本功没到位。数组是唯一一个你可以在一个下午内把所有经典套路全过一遍的专题,性价比极高。代码随想录day1的安排,本质上就是让你在最快的时间内建立"区间+指针+模拟"这三板斧,后续所有数据结构题目都是在这些底子上加复杂度。
1.2 数组题目的三条主线:查找、移动、模拟
把数组题归类之后你会发现,无论LeetCode上题目怎么变,核心套路就三条线。
第一条线是查找,代表题是二分查找(704)。它的本质是你已经知道数据是有序的,需要利用有序性把O(n)的线性扫描降成O(log n)的折半搜索。难点不是"折半"本身,而是区间的定义——你维护的left和right是闭区间还是开区间?这决定了每一次mid的比较之后,left和right的更新方式必须保持区间语义不变化。这就是代码随想录反复强调的"循环不变量"思想,它贯穿所有二分变种题。
第二条线是移动,代表题是移除元素(27)。它的本质是在不额外申请内存的情况下,把满足条件的元素"搬运"到数组前段。最容易想到的方法是暴力双重循环——每删一个元素,后面的整体前移一位,时间复杂度O(n²)。而双指针(快慢指针)把一次遍历里的"找目标"和"前移元素"两个动作合并,时间复杂度直接降到O(n)。表面上看是优化,实际上是在训练你"用一个指针控制遍历节奏、另一个指针控制写入位置"的能力,这个能力在链表快慢指针、滑动窗口里都通用。
第三条线是模拟,代表题是螺旋矩阵(59)。这种题目没有算法难度,纯粹考验你能不能把"转圈填充"这个过程拆成一个个循环不变式,用四个边界变量(top、bottom、left、right)守住四条边,在循环里逐层收缩。模拟题的训练目标很明确:把复杂过程拆解成简单步骤,并保证每一步的边界条件不重不漏。真实业务里的报表生成、图像处理、网格遍历大量依赖这种能力。
代码随想录day1选的是704(二分查找)和27(移除元素),恰好对应查找和移动两条主线。把这两个题吃透,等于同时拿下了区间思维和双指针思维,再看其他数组题会轻松非常多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心方法拆解:二分查找与双指针的底层逻辑
2.1 二分查找的区间定义:左闭右闭 vs 左闭右开
很多初学者二分查找写不对,根本原因是区间语义混乱。你初始化left = 0, right = nums.size() - 1,那么你维护的就是一个左闭右闭区间 [left, right];如果你初始化right = nums.size(),那维护的就是左闭右开区间 [left, right)。这两个写法都能跑通,但必须在while循环和边界更新时保持一致,混着用必然死循环或者漏判。
代码随想录标准模板用的是左闭右闭:
cpp复制int search(vector<int>& nums, int target) {
int left = 0, right = nums.size() - 1; // 注意:左闭右闭
while (left <= right) { // 左闭右闭需要 <=
int mid = left + (right - left) / 2; // 防止溢出
if (nums[mid] > target) {
right = mid - 1; // 因为mid已经查过了,右边界收缩到mid-1
} else if (nums[mid] < target) {
left = mid + 1;
} else {
return mid;
}
}
return -1;
}
这里几个关键点你得想明白,不然背模板也没用。
第一个是 while (left <= right) 的条件。左闭右闭区间里,left == right 意味着区间里还有一个元素(比如 [1, 1] 表示index=1这一个元素),它有可能是target,所以必须用 <= 把它也查一遍。如果你写成 left < right,那么在区间收缩到只剩一个元素时,循环就跳出了,而这个元素可能正是target,结果就是漏判。
第二个是 right = mid - 1 而不是 right = mid。因为在左闭右闭区间里,nums[mid]已经被比较过了,既然它大于target,它绝不可能是目标元素,所以搜索区间应该是 [left, mid-1],如果你写成 [left, mid],等于把一个已经排除的元素又放回去,可能导致死循环(虽然这道题里不一定死循环,但在其他变种题里是个隐患)。
第三个是 mid = left + (right - left) / 2,而不是 (left + right) / 2。原因很朴实:当left和right都很大时,left + right可能超过int的最大值,造成整数溢出,这是一个经典bug。用减法计算差值再除以2,是写二分查找的必备习惯。
左闭右开是另一种写法:
cpp复制int search(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; // 右开区间,mid也排除了
} else if (nums[mid] < target) {
left = mid + 1;
} else {
return mid;
}
}
return -1;
}
左闭右开区间 [left, right) 表示right本身不在搜索范围内,所以当nums[mid] > target时,新的搜索范围是 [left, mid),因为mid已经被排除,所以 right = mid(这个mid本身是新的右边界,不包含)。while条件用 < 是因为 left == right 时区间为空。如果你把左闭右闭模板里的 <= 和 mid-1 照搬到左闭右开里,就会漏掉边界元素。
我的建议是:选定一种区间定义,永远用那一种。代码随想录标准模板用的是左闭右闭,我个人也更推荐这个,因为它是人类直觉最自然的选法,调试时更容易推理。面试时就用这一种,别在两种写法之间反复横跳。
2.2 双指针法的本质:快慢指针的分工协作
移除元素这道题,题目要求原地删除所有值等于val的元素,返回新数组长度。暴力法就是外层循环找val,内层循环把后续元素整体前移,每次删除都是O(n)操作,最坏O(n²)。双指针法的精妙在于,用一个快指针遍历原数组,用一个慢指针维护"处理好的区间末尾",一趟遍历完成搬迁。
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];
slow++;
}
}
return slow;
}
这段代码只有几行,但背后藏着几个值得琢磨的点。
为什么是nums[slow] = nums[fast],而不是nums[fast] = nums[slow]? 因为slow指向的是"新数组下一个要写入的位置",fast指向的是"当前遍历到的新元素"。当fast遇到不等于val的元素时,应该把这个元素写进新数组,所以是快指针的值写入慢指针的位置。反过来就错了。
为什么慢指针只在写入时++? 因为slow标记的是"新数组的长度",只有当真正写入一个有效元素时,长度才会增加。遇到等于val的元素时,fast继续走,slow原地等待,这样当fast越界时,slow恰好是新数组的最后一个有效位置的下一位,直接返回slow就是新长度。
这个算法会不会覆盖掉还没遍历的元素? 这是初学者最担心的问题。其实fast始终在slow的前面(或相等),slow指向的位置要么已经被fast遍历过,要么正好是fast当前所在位置,所以覆盖是安全的。最坏情况是数组里没有val,fast和slow同步走,每一个元素都被自己复制了一遍,结果数组不变,正确。
双指针法的核心思维是把"查找"和"移动"两个操作解耦:快指针负责遍历原数组(查找),慢指针负责维护新数组(移动)。这个理念在后面的有序数组平方、长度最小的子数组、链表倒数第k个节点、容器盛水问题里一而再再而三地出现。我把这个pattern称为"一个负责看路,一个负责铺路",以后凡是遇到"原地操作+保持相对顺序"的问题,优先想双指针。
2.3 从一道题延伸到一类题:二分和双指针的变种地图
学完704和27,你自己应该能推导出一堆变种题。
二分查找的变种至少有这么几类:
- 查找第一个等于target的位置(左边界问题)
- 查找最后一个等于target的位置(右边界问题)
- 查找第一个大于等于target的位置(lower_bound)
- 在旋转有序数组中查找target
- 在有序矩阵中搜索目标值
这些题的本质没有变,都是维护区间不变量,只是把"找到就返回"改成了"找到后继续收缩左/右边界"。你要是把704的左右开闭区间彻底搞懂了,这些题做起来就是改两行代码而已。
双指针的变种就更丰富了:
- 27移除元素 → 26删除排序数组中的重复项 → 283移动零
- 977有序数组的平方(左右双指针从两端往中间走)
- 209长度最小的子数组(滑动窗口,同样是快慢指针思路)
- 15三数之和、18四数之和(排序后左右指针夹逼)
你会发现,这些题看着五花八门,其实核心思路完全一致。数组题的练习价值就在这里:用少量高频题把核心思维内化,让它在你看新题时自动触发。这比盲目刷300道题却不做总结高效得多。
3. 实操过程与核心环节实现
3.1 环境准备与题目复现
刷算法题的实操,我建议一开始就养成固定的流程:本地写代码 → 编译运行 → 调试边界 → 提交验证。不要直接在LeetCode页面上写,因为最终面试场景是需要你自己开IDE写完整代码的,而且本地调试能帮你看到每一步变量的变化,理解远比在线提交深刻。
我这边用C++演示(代码随想录的标准语言),你装好g++或者用VS Code配好编译环境即可。先把题目在本地重现出来,用一个main函数驱动,方便调试。
以704二分查找为例,本地测试代码长这样:
cpp复制#include <iostream>
#include <vector>
using namespace std;
class Solution {
public:
int search(vector<int>& nums, int target) {
int left = 0, 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;
}
};
int main() {
vector<int> nums = {-1, 0, 3, 5, 9, 12};
Solution solution;
cout << solution.search(nums, 9) << endl; // 期望输出4
cout << solution.search(nums, 2) << endl; // 期望输出-1
return 0;
}
我建议把测试用例分几组:目标在数组开头、在末尾、在中间、不存在但介于某两个值之间、数组只有一个元素、数组为空。这几组用例跑通了,说明你的二分查找逻辑稳了。
3.2 手把手调试:用打印观察区间收缩过程
二分查找最容易折的地方是边界条件。我带你实际看一下区间是怎么收缩的,这比单纯背模板有用得多。
在循环体里加打印:
cpp复制while (left <= right) {
int mid = left + (right - left) / 2;
cout << "left=" << left << " right=" << right << " mid=" << mid
<< " nums[mid]=" << nums[mid] << endl;
// ...
}
用 nums = {-1, 0, 3, 5, 9, 12},target = 9 跑一遍:
code复制left=0 right=5 mid=2 nums[mid]=3 // 3 < 9,左边界收缩到mid+1
left=3 right=5 mid=4 nums[mid]=9 // 找到,返回4
再跑一个 target = 2(不存在):
code复制left=0 right=5 mid=2 nums[mid]=3 // 3 > 2,右边界收缩到mid-1
left=0 right=1 mid=0 nums[mid]=-1 // -1 < 2,左边界收缩到mid+1
left=1 right=1 mid=1 nums[mid]=0 // 0 < 2,左边界收缩到mid+1
left=2 right=1 // left > right,循环退出,返回-1
注意第三次循环时 left == right == 1,这个区间还包含 index=1 这个元素,也就是0,它依然会被检查。这就是 while (left <= right) 的意义。如果这里你写了 left < right,循环会在第二次迭代后直接退出,虽然这次target=2依然是-1(不影响结果),但在某些边界场景下就会漏掉目标。
再试一个单元素数组 nums = {5}, target = 5:
code复制left=0 right=0 mid=0 nums[mid]=5 // 找到,返回0
如果 target = 3:
code复制left=0 right=0 mid=0 nums[mid]=5 // 5 > 3,right = -1
此时 left=0, right=-1,区间为空,退出循环,返回-1。一切正常。你要是把区间改成左闭右开,注意 right 可能等于 -1 这个情况是不允许的(左闭右开一般 right 初始化为 size,不会出现负值),两种区间写法在边界行为上有微妙差异,这也解释了为什么不能混用。
3.3 removeElement 核心实现与性能对比
移除元素的实现前面已经给了,这里看一个完整版本和暴力版本的性能对比。
暴力版本:
cpp复制int removeElementBruteForce(vector<int>& nums, int val) {
int size = nums.size();
for (int i = 0; i < size; i++) {
if (nums[i] == val) {
for (int j = i + 1; j < size; j++) {
nums[j - 1] = nums[j];
}
i--; // 因为下标i的元素被删除了,需要回退一位
size--;
}
}
return size;
}
这个版本的思路是:每次找到目标元素,就把后面所有元素都往前挪一位,数组长度减一。有两个隐藏的坑:一是外层循环 i 不能无脑递增,因为你删掉一个元素后,当前 i 位置被原来的 i+1 位置补上了,需要 i-- 才能保证下次循环检查的是新移过来的元素;二是循环终止条件必须用 size 而不是 nums.size(),因为数组在动态缩短。
我实际用 10 万个元素的随机数组测试过:暴力法耗时大概在毫秒级到十毫秒级,双指针法基本在 0.1 毫秒以下。数据量小的时候差别不明显,一旦数据规模上了量级,O(n²)和O(n)的差距就是天壤之别。面试的时候,你应该先给一个暴力方案,然后主动分析它的时间复杂度缺陷,再给出双指针优化——这是标准的答题节奏,能让面试官看到你的问题分析能力。
双指针这里还有一个变体值得了解:当题目不要求保持元素相对顺序时,可以用"首尾双指针"进一步优化。思想是用左边的指针找等于val的元素,用右边的指针找不等于val的元素,交换位置。这样每个元素最多被移动一次,而且不会出现"快慢指针法里大量自赋值"的情况。LeetCode 27的标准双指针正好是快慢指针版本,但你要知道变形方向,后续很多题会用到:
cpp复制int removeElementOptimized(vector<int>& nums, int val) {
int left = 0, right = nums.size() - 1;
while (left <= right) {
if (nums[left] == val) {
nums[left] = nums[right]; // 把右边元素搬到左边
right--; // 右指针左移
} else {
left++; // 左边元素没问题,左指针右移
}
}
return left;
}
注意这个写法改变了数组元素的相对顺序(因为右边的元素可能被搬到左边),但它省去了大量无意义的自赋值操作,在val出现频率很高的场景下更高效。面试时可以先说快慢指针版本(保持相对顺序),再提这个优化版(不保持顺序时更快),展示你对算法细节的理解深度。
3.4 数组越界与空数组的防御性处理
不管哪道题,代码里都要考虑边界输入。二分查找的 nums 可能是空数组,移除元素的 nums 也可能为空。虽然leetcode的测试用例一般会保证非空,但养成防御性习惯没有坏处。
cpp复制int search(vector<int>& nums, int target) {
if (nums.empty()) return -1; // 空数组直接返回
int left = 0, right = nums.size() - 1;
// ...
}
int removeElement(vector<int>& nums, int val) {
int slow = 0;
for (int fast = 0; fast < nums.size(); fast++) {
// nums.size() 返回 size_t 类型,fast 是 int 类型
// 这里可能触发有符号/无符号比较警告
}
}
第二个例子引出C++一个隐蔽的坑:nums.size() 返回的是 size_t(无符号类型),和 int 类型比较时可能产生警告甚至错误。虽然实际上因为数组长度不会超过 int 范围,一般能正常运行,但编译时的 warning 很烦。习惯写法是:
cpp复制for (int fast = 0; fast < (int)nums.size(); fast++) {
或者干脆用 size_t 定义循环变量,但那样右边界判断在某些编译器下可能有类型不一致的问题。我个人的做法是用 int 并显式强制转换,代码干净且无警告。养成这个习惯后,你在处理二维数组的嵌套循环时也能少踩很多坑。
4. 常见问题与排查技巧实录
4.1 高频Bug速查表:死循环、漏判与越界
我整理了刷数组题以来最常遇到的bug,每一个都是我或者我身边人真实踩过的坑,贴出来供你对照排查。
| 问题现象 | 根本原因 | 解决方案 |
|---|---|---|
| 二分查找死循环 | 区间收缩时不变量被破坏,比如用了 left = mid 而不是 left = mid + 1 | 每次收缩必须排除mid,确保区间严格缩小 |
| 二分查找漏掉边界元素 | while条件用了 < 而不是 <=(在左闭右闭区间下) | 左闭右闭区间必须用 <= |
| mid计算溢出 | 直接写 (left + right) / 2 | 改写成 left + (right - left) / 2 |
| 移除元素结果多一个 | slow初始化为1或者循环边界用错了 | slow从0开始,遍历完整个数组 |
| 元素移动后数组出现重复值 | 快慢指针的赋值方向反了 | nums[slow] = nums[fast](快赋给慢) |
| 循环里改了正在遍历的容器 | 在遍历时删除元素,迭代器失效 | 改用双指针原地覆盖,或者倒序遍历 |
你如果刷题时遇到"死循环"和"结果总是差一个",99%是区间条件写错了。我的排查经验是:别盯着代码看,动手把区间收缩过程在纸上画出来,用一个小数组走一遍循环,比冥思苦想快得多。
4.2 从连续内存到二维数组:C++数组实现的几个易错点
数组题刷到后面一定会遇到二维数组、指针数组、数组指针这些概念。代码随想录的day1虽然不直接考C++语法,但如果你用C++刷题,这些概念会直接影响你能不能写出正确的代码。
先区分三个最容易混的概念:
普通数组 int arr[10],是连续存储的10个int,sizeof(arr) / sizeof(arr[0]) 可以算出长度。
指针数组 int* arr[10],是一个数组,数组的每个元素都是指针。它常用来存放字符串指针,比如 const char* strArr[] = {"hello", "world"},这时每个元素都是指向字符常量的指针。
数组指针 int (*arr)[10],是一个指针,指向一个有10个int的数组。它常用于二维数组的行指针。比如 int matrix[3][4],matrix 本身是数组指针类型,指向第一行(含有4个int的数组)。
在LeetCode刷题时,二维数组通常用 vector<vector
还有一个高频问题:C++ 字符串数组初始化。很多人会写:
cpp复制string arr[3];
arr[0] = "hello";
arr[1] = "world";
没问题。但如果写:
cpp复制char* arr[3] = {"hello", "world", "cpp"};
这在C++里会报错,因为字符串字面量是 const char[6] 类型,不能直接赋值给 char*。正确写法:
cpp复制const char* arr[3] = {"hello", "world", "cpp"};
或者直接:
cpp复制string arr[3] = {"hello", "world", "cpp"};
这些都是刷题过程中会碰见的C++细节,早踩坑早记住。
4.3 数组与指针的纠缠:函数传参时的退化问题
C++里数组作为函数形参时有一个著名的"退化"(decay)现象:数组名会被当成指针传递,你传进去的只是一个指向首元素的指针,数组长度信息丢失了。
cpp复制void printArray(int arr[]) {
int size = sizeof(arr) / sizeof(arr[0]); // 错误!arr在这里是int*,sizeof(arr)是8
}
这是刷题时让很多人头疼的问题。你在main函数里写 sizeof(arr)/sizeof(arr[0]) 能得到正确的数组长度,但一旦传给函数,sizeof(arr) 就变成指针大小了。C++刷题时解决方案很简单:用 vector 代替原生数组,vector.size() 可以安全获取长度。这也是为什么我推荐刷LeetCode用C++且用vector,而不是原生数组。
另一个相关概念是"指针数组"和"数组指针"的区别。我遇到很多人在面试手写代码时栽在这上面,比如要求实现一个函数,传入一个二维数组,返回每行元素之和。正确写法是这样的:
cpp复制int rowSum(int (*arr)[4], int rows) {
// arr 是指向"含有4个int的数组"的指针
int sum = 0;
for (int i = 0; i < rows; i++) {
for (int j = 0; j < 4; j++) {
sum += arr[i][j];
}
}
return sum;
}
如果写成 int* arr[4],语义就完全变了,它是"一个有4个指针的数组",不是一个指向二维数组的指针。二分之一的人会在这里出错,这个知识点值得单独花10分钟彻底搞明白。
4.4 多语言刷题视角:JS数组方法的便利与陷阱
代码随想录的题解以C++为主,但现在很多人用JavaScript刷题,JS的数组API确实方便,但也埋了不少坑。
常用API里值得记住的有:
- push/pop 在数组尾部增删,O(1)复杂度
- shift/unshift 在数组头部增删,O(n)复杂度(因为所有元素都要移动)
- splice 可以在任意位置增删,也是O(n)
- slice 返回一个新数组,不修改原数组
- forEach/map/filter 是遍历和生成新数组的利器
刷LeetCode时要注意一个JS特有的坑:split('')会把字符串拆成字符数组,但是遇到emoji或者特殊Unicode字符时可能拆错。比如"😄".split('')会得到两个乱码字符,因为split是按UTF-16码元拆的。处理这类问题要用 Array.from(str) 或者展开运算符 [...str]。
Array.from 还有一个妙用:生成指定范围的数组。刷二分查找的题目时需要生成一个1到n的有序数组作为测试数据,用 Array.from({length: n}, (_, i) => i + 1) 一行搞定,比手动for循环填充简洁得多。
JS的数组去重也是高频问题,面对对象数组去重时要特别注意引用类型比较的是引用而不是值。基本数组去重用 [...new Set(arr)] 即可,但对象数组必须用 map 按某个唯一属性去重:
javascript复制const uniqueById = arr.filter((item, index, self) =>
self.findIndex(v => v.id === item.id) === index
);
这些属于语言级工具,不在代码随想录的范围内,但它是你真正动手写代码时的"基础设施"。我建议每个语言都掌握一套自己最顺手的数组操作套路,刷题时少为语法分心,把注意力留给算法本身。
4.5 数组转字符串、动态扩容与位操作的实际场景
数组题刷完不能只是在LeetCode里打转,我讲几个工作中真实遇到的数组操作场景,你会发现面试题和业务代码其实是同一套思维。
数组转字符串。前端把数据拼成URL参数、后端拼接日志时都常用。JS里是 arr.join(','),C++里要用循环拼接,Java里用 String.join(",", arr)。这个操作在业务里的频率高得吓人,但很多人在刷题时没意识到这背后其实就是数组遍历+字符串追加,时间复杂度O(n)逃不掉。而如果涉及频繁拼接字符串,C++的string +=、Java的StringBuilder、JS的数组join,都是为了避免重复创建字符串对象,这跟双指针优化的思路一脉相承——减少不必要的重复操作。
动态数组。C++的vector、Java的ArrayList、JS的数组都是动态扩容的。vector的扩容策略通常是在容量不足时翻倍,所以均摊时间复杂度是O(1),但单次扩容是O(n)的copy操作。刷题的时候如果你能提前预估数组大小,用 reserve 提前分配好内存,会省掉多次扩容的损耗。LeetCode虽然不考这个,工作效率上却很有用。
位操作与数组。数组的位操作应用场景包括布隆过滤器(用位数组节省内存)、状态压缩(用一个int表示一组布尔状态)。比如 LeetCode 78 求子集,就可以用二进制的每一位表示某个元素是否被选中,从0遍历到(1<<n)-1,一次性枚举所有子集。这也是数组和位运算结合的经典思路,扩宽视野后你会觉得数组远不止"存数据"这么简单。
5. 从Day1到完整刷题路线的扩展建议
5.1 双指针思维在后续题目中的复用
学习一定要有"滚雪球"意识。Day1的移除元素看起来小,它的双指针思想直接迁移到下面这些题:
26. 删除排序数组中的重复项。几乎是27题的复制粘贴版,把"不等于val"的判断改成"不等于前一个元素"。
283. 移动零。把非零元素用快慢指针排到前面,后面的位置补0。思路一模一样。
977. 有序数组的平方。这个题需要一个新视角:既然数组是排序的,平方后最大的数一定在两端,所以用两个指针从两端向中间移动,把较大的平方值放到结果数组末尾。这是双指针的另一种形态——左右夹逼。
209. 长度最小的子数组。这是滑动窗口的入门题,快慢指针变成了窗口的左右边界,通过移动右指针扩大窗口、移动左指针缩小窗口,找到满足条件的最小窗口长度。这里的快指针和慢指针不再是一个"遍历"一个"写入",而是共同维护一个可变区间,思维上又进了一步。
建议你按这个顺序刷题,每做一道新题,先想"这道题能复用我学过的哪个模式"。如果你能在看见题目的第一眼就想出"这题跟27移除元素是一家的,用双指针",说明你已经把模式内化了。
5.2 二分查找的进阶挑战:边界收敛的精细控制
704是最基础的二分,但它练好的"边界收敛"能力是所有二分变种题的地基。举个例子,力扣34题——在排序数组中查找元素的第一个和最后一个位置。它的解法是写两个二分函数:
cpp复制int findFirst(vector<int>& nums, int target) {
int left = 0, right = nums.size() - 1;
int ans = -1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (nums[mid] >= target) {
right = mid - 1;
if (nums[mid] == target) ans = mid;
} else {
left = mid + 1;
}
}
return ans;
}
int findLast(vector<int>& nums, int target) {
int left = 0, right = nums.size() - 1;
int ans = -1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (nums[mid] <= target) {
left = mid + 1;
if (nums[mid] == target) ans = mid;
} else {
right = mid - 1;
}
}
return ans;
}
这个变种的关键是:找到目标后不立即返回,而是继续收缩区间,直到区间为空才返回最后一次记录的位置。写完这两个函数,你对"区间边界和循环终止条件如何配合"的理解会上升一个台阶。
接下来还可以挑战力扣33(搜索旋转排序数组)、力扣35(搜索插入位置)、力扣69(x的平方根)。这几个都只需要在704模板上做很小的改动,却能帮你把"二分退化到线性"的各种边界情况吃透。
5.3 模拟题的训练价值:用边界变量控制过程
螺旋矩阵(力扣59)是数组模拟类题目的经典代表,经常出现在面试的笔试环节。它没有复杂算法,但非常考验对边界的控制力。
cpp复制vector<vector<int>> generateMatrix(int n) {
vector<vector<int>> nums(n, vector<int>(n, 0));
int top = 0, bottom = n - 1, left = 0, right = n - 1;
int num = 1;
while (top <= bottom && left <= right) {
// 从左到右填充上边
for (int i = left; i <= right; i++) {
nums[top][i] = num++;
}
top++;
// 从上到下填充右边
for (int i = top; i <= bottom; i++) {
nums[i][right] = num++;
}
right--;
// 从右到左填充下边(防止只剩一列时重复)
if (top <= bottom) {
for (int i = right; i >= left; i--) {
nums[bottom][i] = num++;
}
bottom--;
}
// 从下到上填充左边(防止只剩一行时重复)
if (left <= right) {
for (int i = bottom; i >= top; i--) {
nums[i][left] = num++;
}
left++;
}
}
return nums;
}
这里的核心思想是:用 top/bottom/left/right 四个变量守住四条边界,每填完一条边就收缩一个边界,下一轮循环继续。中间两个 if 是防止 n 为奇数时最后只剩一行或一列造成重复填充的关键。这种写法比"一圈一圈转"直观且不容易错。
模拟题的训练意义在于:它逼着你在动手写代码之前把过程拆清楚,画图明确每一步的边界,然后严格按照"边界变量+方向控制"的思路去实现。很多复杂业务逻辑的代码质量都取决于这个能力——你实现的东西越复杂,越需要清晰的边界控制,否则写出来的就是一团乱麻。
6. 最后再聊几句实操心得
刷数组第一天,我特别想强调一个容易忽略的点:题不在多,在于你把每一步的"为什么"想清楚。704这道题,有人十几分钟能写完,有人写完了第二天又忘了。差别不在于天赋,而在于有没有把"为什么用 <=""为什么 right = mid - 1""为什么左闭右开也能跑"这几个问题彻底想明白。我见过太多人用"背模板"的方式刷题,当时全对,过了两周再写同样的题就卡壳,因为他们脑子里装的是"那条代码长什么样",而不是"这个区间怎么收缩才不违反不变量"。
我个人的建议是:每做完一道数组题,试着不看答案复写一遍,并在旁边用注释写下每一步变量变化的原因。比如:
cpp复制while (left <= right) { // left==right时区间还有效,必须检查
int mid = left + (right - left) / 2; // 防溢出
if (nums[mid] > target) {
right = mid - 1; // mid已排除,新区间是[left, mid-1]
}
// ...
}
这种注释习惯会让你的代码思路异常清晰,面试甚至可以直接跟面试官讲"这段代码的循环不变量是什么",这比支支吾吾说"我是背的模板"要强太多。
最后说一个小技巧:刷数组题时,准备一张草稿纸。别只在脑子里转,把数组画出来,标明left、right、mid或者fast、slow的实时位置,一遍遍走流程。我训练过的小白,凡是愿意动笔的,理解边界条件的速度至少是纯看代码的两倍。数组作为一切数据结构的基础,这些思维习惯养成了,后面链表、树、图都是水到渠成的事。
今天先把 704 和 27 这两道题吃透,然后按我上面推荐的延伸顺序刷一遍,你会发现自己的代码直觉明显不一样。数组Day1,确实是整个刷题旅程里性价比最高的一天。
