开篇:刷题第一天,从数组开始
代码随想录这个系列在程序员圈子里几乎成了刷题标配,尤其是准备面试、补算法基础的朋友,基本都绕不开它。第一天从数组part1切入,看起来是最基础的内容,但数组题恰恰是很多人刷题路上第一个“劝退点”——不是题目难,而是细节多、边界条件多、写出来的代码不是超时就是越界。这篇内容适合所有刚开始刷题的人,也适合那些刷过一遍但总感觉“背了模板却不会用”的人。我会把数组部分的底层原理、核心题型、代码模板和踩坑点一起拆开讲,尽量把“为什么这么写”讲透,而不是只给个能AC的答案。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
1. 内容整体设计与思路拆解
1.1 为什么数组是刷题第一步
数组的定位很特殊:它是最简单的数据结构,却牵扯到内存布局、指针、边界处理、算法优化等一系列底层问题。几乎所有面试算法题都能以数组为载体出现,哪怕是字符串、矩阵、哈希表题目,本质也都是数组操作。
从刷题角度看,数组题锻炼三个核心能力:
- 边界敏感度:二分查找区间的开闭、for循环的终止条件,都是在训练你对索引的精确控制。这种能力是后面写任何算法都需要的。
- 指针思维:数组题里频繁用到的双指针,是对“指针/索引”概念最早的实战演练。理解了双指针在数组里的意义,后面学链表、树遍历会顺畅很多。
- 空间复杂度意识:数组是连续的存储空间,很多题目靠“原地操作”来节省空间。这种意识在面试中尤其加分——能写出O(1)空间复杂度解法,会明显区别于只会暴力解的候选人。
我第一次刷代码随想录时,觉得数组很简单,但做到“移除元素”这类题才发现,原来暴力解法的两层循环里藏了那么多可以优化的点。数组part1真正想传达的,不是让你背几个题,而是建立“用最小成本解决问题”的意识。
1.2 代码随想录的题目编排逻辑
代码随想录第一天选了四道经典题:二分查找、移除元素、有序数组的平方、长度最小的子数组(每本书或每轮计划可能微调,但核心题目类型一致)。
这个编排很有讲究:
- 二分查找训练区间思维和边界控制能力。
- 移除元素是双指针的入门题。
- 有序数组的平方是双指针的进阶应用(左右指针向中间靠拢)。
- 长度最小的子数组引入滑动窗口,从“两个指针”进化到“窗口维护”。
四道题由易到难,从单指针到双指针再到滑动窗口,递进关系非常清晰。很多人刷题喜欢挑题做,遇到不会的跳过,但数组part1的题目设计本身就是一个完整的学习链路,跟着顺序走,每一步都在为后面铺路。
2. 数组理论基础:先把底层逻辑吃透
2.1 数组在内存中长什么样
数组的核心特性是:连续的内存空间 + 相同类型的数据。这决定了它能O(1)随机访问——只要知道首地址和元素大小,直接算偏移量就能取到目标元素。
但反过来,这也带来了代价:
- 插入和删除操作需要移动大量元素,时间复杂度是O(n)。
- 数组大小固定,扩容要重新分配一整块内存,成本很高。
- 访问越界不会像Python那种语言一样直接抛异常,C/C++里是未定义行为,可能踩到垃圾数据或者直接段错误。
我在实际写代码时,有段时间经常困惑:为什么同样的逻辑在Java里没事,在C++里就崩了?后来才彻底想明白——Java数组有length字段做边界检查,C++纯裸数组没有。这不是语言好坏问题,而是底层模型决定的行为差异。
用生活化类比来说:数组就像一栋按门牌号排序的公寓楼,房间号0到n-1。你要找第5个房间,直接走到这一层就行,不需要一扇门一扇门敲。但如果要在第3和第4个房间之间塞一个新房间,就得把4号及以后所有房间都往后挪。
2.2 C++中数组的几种定义方式和初始化陷阱
代码随想录里以C++为主,因为C++的解法最能体现内存控制的细节。数组定义方式其实有好几种,很多人只见过最基础那种:
cpp复制// 方式一:栈上数组,大小必须是编译期常量
int arr1[5] = {1, 2, 3, 4, 5};
// 方式二:不指定大小,由初始化列表推断
int arr2[] = {1, 2, 3, 4, 5};
// 方式三:部分初始化,剩下补0
int arr3[5] = {1, 2}; // arr3[2] = arr3[3] = arr3[4] = 0
// 方式四:动态数组,大小可以是运行时变量
int n = 5;
int *arr4 = new int[n];
// 方式五:vector,现代C++推荐
vector<int> arr5(n, 0);
这里有个经典陷阱:用new int[n]分配的内存,用完必须delete[] arr4;,很多人漏了delete或者写错成delete arr4;,结果就是内存泄漏或未定义行为。另外,C++的vector底层也是数组,但自动管理扩容和释放,刷题时直接用vector基本就够了。曾经有一道算法题用裸数组写,最后因为忘了释放内存导致程序运行越来越慢,中间排查了很久,还是换成vector清清爽爽。
关于二维数组,C++里的声明形式是vector<vector<int>> matrix(m, vector<int>(n, 0)),这样每次初始化时内部每个一维数组长度都相同,访问时用matrix[i][j]。但要注意在C/C++中,matrix[i][j]的本质是*(*(matrix + i) + j)——先偏移行指针,再偏移列索引。理解了这层,后面做动态规划、矩阵旋转、岛屿类题目时会快很多。
2.3 其他语言里的数组差异
代码随想录虽然以C++为主,但用Java、Python、JavaScript刷题的人同样非常多,我在这里把差异点补一下:
- Java:数组是对象,有
length属性,访问越界会抛ArrayIndexOutOfBoundsException,这其实是好事,帮你尽早发现问题。但要注意int[] arr = new int[5]默认全为0;Integer[]则初始为null,拆包时容易空指针。 - Python:
list是动态数组,底层是PyObject指针数组,可以混合类型,代价是内存占用比C++大很多。访问越界抛IndexError。 - JavaScript:
Array类型本质也是动态数组,最常用的操作是push/pop/splice,而且有map/filter/reduce这类函数式方法。做对象数组去重时,很多人喜欢用Set+JSON.stringify,但要注意嵌套对象键顺序问题会导致去重不干净。 - Go:数组长度是类型的一部分,
[5]int和[10]int是不同类型,这常让新手困惑,所以实际开发中用slice(切片)更多。
刷题时语言选择不重要,但你要清楚自己用的语言的“边界检查行为”和“内存管理方式”,这决定了你调试时遇到的报错类型。
3. 核心题目拆解一:二分查找,区间的哲学
3.1 为什么二分查找看起来简单却总写错
二分查找是数组part1里最典型的“看着容易,一写就错”的题目。很多人的第一版代码长这样:
cpp复制int binarySearch(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) left = mid + 1;
else right = mid - 1;
}
return -1;
}
这段代码是对的,但如果你问“为什么是left <= right而不是left < right”,很多人答不上来。代码随想录把这题放在第一天,目的就是强迫你搞清楚这个问题。
关键就两条:
- 区间定义决定循环条件。
[left, right]是左闭右闭区间,当left == right时,区间里还有一个元素要判断,所以必须用<=。如果是[left, right)左闭右开,循环条件就得是while (left < right)。 - 收缩边界决定mid更新方式。左闭右闭时,
nums[mid] > target说明mid不是答案,直接right = mid - 1。但如果是左闭右开,nums[mid] > target时,因为右边界不包含,可以直接right = mid,不需要减1。
这两种写法都能AC,但混着用就一定出错。我刚开始刷题时,经常因为没固定区间定义,今天是闭区间明天是开区间,导致边界判断一团糟。建议你选定一种写法,刷几十道二分题都保持同一习惯,形成肌肉记忆。我自己习惯用左闭右闭,因为代码写起来比较直观,排查时也容易想清楚。
3.2 有符号数溢出问题
很多版本的写法是int mid = (left + right) / 2,这在left和right都接近INT_MAX时可能溢出。比如left是15亿,right是16亿,相加就超过int范围了,变成负数,mid直接错乱。
标准写法是:
cpp复制int mid = left + (right - left) / 2;
这样保证加法始终在left的基础上加一个非负值,最大值不会超过right。这个细节在LeetCode测试用例里很容易遇到,尤其当数组特别长、目标值在末尾时。
类似的,Python就不用担心整数溢出,因为Python的int是任意精度。但Java和C++必须注意。这种“隐形的坑”在面试时如果主动提出来,会是一个加分点。
3.3 二分查找的变体与KMP里的next数组
二分查找学到后面,会有很多变体:
- 找第一个等于target的位置;
- 找最后一个等于target的位置;
- 找第一个大于等于target的位置;
- 找最后一个小于等于target的位置。
这些变体不管怎么变,核心都是“缩小搜索空间 + 收敛边界”。你会发现后面很多算法里都隐约有二分的影子。
热搜词里提到了KMP算法的next数组。next数组的构造过程和二分没有直接关系,但它同样是对数组索引的精确操作。KMP中的next[i]定义通常是“模式串P[0..i]的最长相等前后缀长度”,当模式串是"abacaba"时,可以手算:
| i | P[0..i] | 最长相等前后缀长度 |
|---|---|---|
| 0 | a | 0 |
| 1 | ab | 0 |
| 2 | aba | 1 |
| 3 | abac | 0 |
| 4 | abaca | 1 |
| 5 | abacab | 2 |
| 6 | abacaba | 3 |
next数组在KMP里不是用来二分查找的,而是“当匹配失败时,模式串向右滑动到哪里”。这背后的本质是对数组前缀信息的压缩存储。刷数组题时把next数组构造过程用双指针推导一遍,会加深你对“前缀函数”的理解。这也是我推荐拿到数组题先理解底层逻辑的原因——很多高大上的算法都是数组的巧妙操作。
4. 核心题目拆解二:移除元素与双指针
4.1 暴力解法为何不推荐
题目要求:原地移除所有值为val的元素,返回新数组长度。很多人第一反应是两层循环,找到val就把后面元素整体前移一位。逻辑是对的,但时间复杂度O(n²),在数组规模大时直接超时。
我之前在校招笔试时就吃过这个亏——题目明确要求“原地移除”,我用erase(vector的删除操作)来做,虽然代码短,但它内部也是O(n)的元素移动,而且可能会导致迭代器失效。笔试的测试用例里有一万个元素的数组,暴力法跑了1.5秒,性能不合格。这让我深刻意识到,刷题不是炫技,而是要选择最优解法。
4.2 双指针法精讲
双指针法的思路非常直观:一个慢指针slow指向“新数组的末尾”,一个快指针fast遍历原数组。
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;
}
fast负责找符合条件的元素,slow负责记录“最终保留下来的位置”。第一次看到这个解法的感受是“这居然也能叫双指针,不就是两个索引吗”——但正是这么简单的思想,带来O(n)时间、O(1)空间的优化,效果立竿见影。
双指针法的核心价值在于:它把“删除元素”从“移动多个元素”变成了“覆盖写入”。每个元素最多被写一次,读一次,所以是线性复杂度。这种思想可以推广到很多题目:
- 有序数组去重(保留一个),同样思路。
- 移动零(把非零元素往前放,后面补0)。
- 对撞指针(两端向内逼近,比如两数之和)。
4.3 指针数组、二维数组等边界情况
热搜词里提到“指针数组存放字符串”,这其实是C语言里一个经典的内存布局问题。比如:
cpp复制const char *strArr[3] = {"hello", "world", "cpp"};
这里strArr是数组,每个元素是一个char*,指向字符串字面量的首地址。它和char strArr[3][10]的区别在于:前者只是存放了三个指针,每行长度可变;后者是真正的二维字符数组,每行固定10个字节,内容直接存储在连续空间里。
在做移除元素这类题时,如果数组元素本身是指针(比如字符串数组),用双指针做“逻辑删除”时要注意:你不只是移动指针,还可能涉及内存释放问题。如果是裸指针数组,删掉某个元素后要考虑该指针指向的内存是否需要释放;如果不管不顾直接覆盖,很可能造成内存泄漏。用vector
4.4 双指针的常见变体:合并有序数组
热搜词里还有“java 双指针合并有序数组”。这和移除元素是同一套底层逻辑,只是方向不同。合并两个有序数组通常用三个指针:i指向nums1的有效末尾,j指向nums2末尾,k指向nums1数组的物理末尾(预留空间充足)。
java复制public void merge(int[] nums1, int m, int[] nums2, int n) {
int i = m - 1, j = n - 1, k = m + n - 1;
while (j >= 0) {
if (i >= 0 && nums1[i] > nums2[j]) {
nums1[k--] = nums1[i--];
} else {
nums1[k--] = nums2[j--];
}
}
}
核心逻辑是“从后往前比较,大的放后面”。为什么从后往前?因为nums1后面是空的,正着遍历会覆盖还没比较的元素。这是双指针思想在数组边界处理上的精彩应用——同样是两个指针移动,方向一变,复杂度就从O(m+n)空间降到O(1)空间。
这个思路我去面试时还被追问过:如果nums1没有预留空间,怎么处理?答案是:无法原地合并,必须用额外数组,或者用链表。面试官想考察的是你对空间复杂度的敏感度。
5. 核心题目拆解三:有序数组的平方与滑动窗口
5.1 有序数组的平方——左右指针向中间靠拢
题目描述:给定一个按非递减顺序排序的整数数组nums,返回每个数字的平方组成的新数组,要求也按非递减顺序排序。
最直观的思路是每个元素平方,再整体排序,时间复杂度O(n log n)。但有序数组平方后的规律是:最大值一定在两端(因为负数平方大,正数平方也大),中间反而是最小值。根据这个规律,可以用左右指针从两端往中间扫描。
cpp复制vector<int> sortedSquares(vector<int>& nums) {
int n = nums.size();
vector<int> res(n);
int left = 0, right = n - 1;
int pos = n - 1;
while (left <= right) {
if (nums[left] * nums[left] > nums[right] * nums[right]) {
res[pos--] = nums[left] * nums[left];
left++;
} else {
res[pos--] = nums[right] * nums[right];
right--;
}
}
return res;
}
这个解法的核心是“从大到小倒着填结果数组”。每次比较两端绝对值大小,大的那个一定是当前最大值,放到结果数组的最后一位。这里面有一个细节:如果用nums[left]*nums[left]会溢出吗?对于int,如果元素接近INT_MAX,平方就溢出了。LeetCode测试用例可能没有覆盖到这么大,但理论上应该用long long或者比较绝对值。这个细节也能体现你写代码是否严谨。
5.2 长度最小的子数组——滑动窗口初体验
题目:给定一个含有n个正整数的数组和一个正整数target,找出该数组中满足其和大于等于target的长度最小的连续子数组,并返回其长度。
暴力解法是枚举所有起点和终点,O(n²)。滑动窗口能在O(n)内解决,核心思想是:窗口的右边界不断右移扩大窗口,当窗口内和满足条件时,尝试缩小左边界来寻找更短的满足条件的子数组。
cpp复制int minSubArrayLen(int target, vector<int>& nums) {
int left = 0, sum = 0;
int minLen = INT_MAX;
for (int right = 0; right < nums.size(); right++) {
sum += nums[right];
while (sum >= target) {
minLen = min(minLen, right - left + 1);
sum -= nums[left];
left++;
}
}
return minLen == INT_MAX ? 0 : minLen;
}
这里最容易被忽视的是内层while循环:它不是if,而是不断收缩窗口,直到窗口和小于target。因为收缩一次后可能依然满足条件,还能更短。有人说滑动窗口不就是双指针吗?确实,双指针是更宽泛的概念,滑动窗口是双指针的一种应用模式:两个指针同向移动,形成“窗口”。
这个题的变体也很多,比如:
- 最短覆盖子串:s中包含t所有字符的最短子串。
- 无重复字符的最长子串:用哈希表记录窗口内字符位置。
- 水果成篮、字符串排列等。
核心套路就是:右指针扩张,左指针收缩,窗口内维护一个有效状态。
5.3 树状数组上的二分和最大乘积子数组
热搜词里还有“树状数组上二分”和“怎么计算一个数组的最大乘积子数组”。这两个不完全是part1的内容,但可以提前说一嘴:
树状数组(Fenwick Tree)支持单点修改和前缀和查询,复杂度都是O(log n)。树状数组上二分,通常是在维护一个前缀和结构后,快速找到前缀和第一个大于等于某值的位置。做法是把树状数组当成一个二叉树,从最高位往下逐位确定答案。这依赖树状数组底层二进制的性质,和二分查找的思想类似——在可前缀查询的结构上做二分搜索。
最大乘积子数组则是动态规划题:维护以i结尾的最大乘积imax和最小乘积imin,因为负数会让最大变最小。状态转移时,每次遇到新元素,imax = max(当前元素, imax当前元素, imin当前元素),imin同理取最小。这已经超出数组part1的范畴了,但当你把基础数组题刷熟后,这些进阶题就只是“基础操作+状态设计”的组合。
6. 数组part1的常见问题与避坑实录
6.1 越界与空数组:最容易被忽略的两个点
刷数组题时最常见的两个运行时错误:
- 访问越界:循环条件写成
i <= nums.size()或者应该在left < right时写了left <= right,导致访问nums[n]。C++中这个行为未定义,有时候不崩,有时候崩得莫名其妙。排查方法:在循环开始处打印当前索引和数组长度。 - 空数组:
nums为空时,任何直接访问nums[0]的操作都会崩。很多算法题没考虑空数组,先判断nums.size() == 0再继续会避免大量崩溃。
我自己的习惯是:写任何数组算法前,第一行考虑“如果数组为空或长度为1,我的代码还正确吗?”这两个边界条件处理好了,题目至少过一半的测试用例。
6.2 C++中vector与数组转换、清空问题
热搜词里提到“在QT Creator中C语言对buffer数组清空有哪几种方式”,这个在实际工程里很常用:
cpp复制// 方式一:memset,只适合POD类型
memset(buffer, 0, sizeof(buffer));
// 方式二:fill_n,更适合数组
fill_n(buffer, size, 0);
// 方式三:vector的assign/clear
vector<char> buf;
buf.assign(size, 0);
buf.clear(); // 只是清空逻辑长度,容量不变
注意点:memset按字节填充,对int数组会得到0x3f3f3f3f之类的值,这是有意的技巧(用于无穷大表示),但如果你误以为它能给每个int设为1,那结果就完全不对了。如果数组里有非平凡类型(如std::string),memset要绝对避免,因为会破坏对象内部状态。
至于“C# for循环遍历数组”这类问题,核心思路和C++一致,区别只是语法和边界检查:
csharp复制for (int i = 0; i < nums.Length; i++) {
// 注意C#的Length是属性,跟C++的size()不同
}
6.3 JavaScript数组操作的几个高频问题
热搜词里有一堆JavaScript数组问题,我挑三个高频的讲:
1. 用扩展运算符把一个数组的值添加到另一个数组
javascript复制const arr1 = [1, 2, 3];
const arr2 = [4, 5];
arr1.push(...arr2); // 现在arr1是[1,2,3,4,5]
这是ES6的优雅做法,比concat好在它是原地修改,比循环push简洁得多。
2. 数组去重
javascript复制// 基础去重
const unique = [...new Set(arr)];
// 对象数组去重,得指定key
const objArr = [{id: 1, name: 'a'}, {id: 1, name: 'b'}, {id: 2, name: 'c'}];
const seen = new Set();
const filtered = objArr.filter(item => {
if (seen.has(item.id)) return false;
seen.add(item.id);
return true;
});
注意Set去重是基于===的,对对象类型无效,所以对象数组必须指定去重字段。
3. splice删除元素
javascript复制// 删除下标为i的元素
arr.splice(i, 1);
splice会修改原数组并返回被删除的元素数组。在for循环里用splice时,注意删除后索引要i--,否则会跳过一个元素。这个点经常导致bug。
4. Vue watch数组第一项新旧值一样的问题
热搜词里问为什么watch数组第一项新旧值一样:因为Vue的响应式系统在监听数组索引变化时,默认不会立刻触发深度侦听,newVal和oldVal指向同一个引用。解决办法是用watch: { 'arr[0]': handler }或者deep: true。这是框架层面的坑,但根因是“数组是引用类型”——这和C++里数组退化成指针有某种相似性:你拿到的只是引用,不是值复制。
6.4 二维数组和矩阵操作中的坑
数组part1主要是一维数组,但二维数组的相关问题也是热门。刷五行——不,说重点:
- C++二维vector的初始化:
vector<vector<int>> matrix(m, vector<int>(n, 0));,m行n列,全0。这是最稳妥的方式,避免了“每行长度不一致”的问题。 - Go/Swift等语言里二维数组的切片写法各不相同,但本质都是一个外层数组存内层数组的指针。
- 行列遍历顺序对性能的影响:C++中二维数组按行存储,
matrix[i][j]连续访问是高效的;但按列访问matrix[j][i]会跳内存,缓存命中率低,性能可能差好几倍。刷题时不会很明显,但做图像处理、矩阵运算时这是关键瓶颈。这和数组是连续内存的底层特性直接相关。 - 螺旋矩阵类型题目:模拟顺时针遍历,核心是定义四个边界(top、bottom、left、right),每次走完一个方向就收缩边界。代码随想录的螺旋矩阵II是part2的内容,但提前知道“循环不变量”原则很重要——每一圈四条边都按照“左闭右开”的方式处理,这样就不会出现重复或遗漏。
7. 刷题方法:如何把数组part1吃透
7.1 每道题至少写三遍
代码随想录的建议是“一题多刷、多语言实现、总结套路”。我个人经验是:一道题写三遍,效果远好于写十道不同题。
- 第一遍:看完题先自己思考15分钟,实在没思路再看题解。重点理解思路,不是抄代码。
- 第二遍:合上题解,独立写代码,要求一次AC。这一遍暴露的问题是“想到了思路但写不出来”。
- 第三遍:隔两天再做,要求限时完成,并且能口头讲清楚为什么这样写。
数组part1这四道题,每一道都能套用这个方法。比如二分查找,你要能讲清楚“为什么区间不同,循环条件就不同”,而不只是背住模板。
7.2 建立知识清单和模板库
我在刷数组题时,会维护一份自己的模板清单,格式很简单:
| 题目类型 | 核心模板 | 复杂度 | 易错点 |
|---|---|---|---|
| 二分查找 | left=0, right=n-1; while(left<=right) | O(log n) | 区间一致性、溢出 |
| 双指针移除 | slow/fast同向 | O(n) | 覆盖顺序 |
| 对撞指针 | left/right反向 | O(n) | 结束条件 |
| 滑动窗口 | right扩张、left收缩 | O(n) | while收缩条件 |
| 原地合并 | 从后往前写大值 | O(m+n) | 剩余元素处理 |
每次刷到新题,就更新这个表。到面试前,翻出这个清单看一遍,基本上数组类题目都能覆盖到。
7.3 别怕看题解,但要会“复盘”
很多人刷题有一道坎:“我看题解会不会变笨?”其实不会。关键是看题解后的动作:
- 先理解思路,不看代码,然后自己默写出来。
- 对比你的解法和题解的差异,思考为什么题解更优或更简洁。
- 如果你的思路更复杂,问问自己:是因为没有掌握XX技巧,还是题目理解偏差?
例如移除元素那题,我第一次想到的是“把不是val的元素push到新数组”,空间复杂度O(n)。看题解后才意识到“原地覆盖”可以做到O(1)空间。这不是智商问题,而是“没见过这种套路”。见得多、练得多,自然能举一反三。
8. 写在最后:一个关于心态的建议
数组part1如果认真刷完,大概需要两到三天。不用求快,最重要的是你把每一道题的原理和边界条件都吃透了。
我自己的经历是:第一次刷这四道题,感觉“不过如此”,但到了后面刷链表、二叉树时,发现很多代码反而写不利索了。回头反思才发现,链表题里大量使用的“快慢指针”、“虚拟头节点”都是从数组双指针延伸出来的思维;二叉树遍历涉及边界判断和递归终止条件,也依赖数组题培养的那种“边界敏感度”。所以数组part1绝不只是在学数组,它是在打整个算法进阶的地基。
如果你现在正刚开始刷题,耐心一点。把每道题的边界条件、为什么这样处理都记录下来,哪怕很慢也没关系。地基打得牢,后面的房子才盖得高。关于数组的更多技巧(比如树状数组、前缀和优化、滚动数组),后续可以再展开写。这一篇先到这,希望能帮你把第一天的路走稳。
