数组基础这个主题,放在“day2”来学其实挺有意思。很多教程都把它当成第一天,但真正刷过题之后你会发现,数组远不是“会写 for 循环”就够了。它既是面试题里最朴素的存储结构,也是二分查找、滑动窗口、双指针这些进阶技巧的载体。这篇文章我想聊得更细一点,从内存模型一直讲到实战里的各种坑,适合刚开始学数据结构、准备刷题的同学,也适合看过一遍但觉得自己“只会写但不太懂原理”的朋友。
1. 从存储模型理解数组:为什么下标访问能做到 O(1)
1.1 数组是离内存最近的数据结构
如果让我给各种数据结构排个序,数组一定是最接近计算机真实内存形态的那一个。变量相当于一个带有标签的储物格,数组则是一排相邻、编号连续的储物格,中间没有任何空隙。链表、树、图这些结构,本质上都是用“指针”把零散的内存块串起来,而数组一上来就占据一块连续内存。
正因为连续,数组拥有了一个其他结构羡慕不来的特性:只要知道起始地址和下标,就能直接算出目标元素的位置,不需要从头一个个找。这也是数组访问能做到 O(1) 的根本原因。很多人背过“数组支持随机访问”,但说不清楚为什么随机访问快,其实就是这个连续内存模型在起作用。
理解到位之后,很多问题会自然浮现:既然数组这么好用,为什么还需要链表?为什么数组中插入、删除这么麻烦?这些疑问都源自同一个事实——数组要保住“连续”这个特性,任何破坏连续性的操作都得付出代价。
1.2 下标访问背后的地址计算
假设有一个数组arr,起始内存地址是base,每个元素占size个字节,那么下标i对应元素的地址就是:
base + i * size这是一个非常简单的乘法和加法运算,和数组有多少个元素无关。所以不管是访问arr[0]还是arr[999999],计算过程都是一条公式的事,时间复杂度自然就是 O(1)。
C/C++ 里这个关系体现得最直接:
int arr[5] = {10, 20, 30, 40, 50}; int *p = arr; // 数组名在表达式中退化为首元素地址 printf("%d\n", *(p + 3)); // 等价于 arr[3]其实arr[3]在编译器眼里就是*(arr + 3)。很多从 Python 入门的朋友可能觉得下标访问理所当然,但你理解了这层地址换算之后,以后看指针、看底层优化都会顺畅很多。
Python 虽然不直接暴露地址,但列表底层也是一个数组加一层对象引用:
a = [10, 20, 30, 40, 50] print(a[3]) # 同样是基于连续地址表的定位区别在于 Python 的列表存的是“对象引用”,比 C 的 int 数组多了一层间接性,灵活性变高了,但内存占用和随机访问的常数开销也变大了。这就是为什么做性能敏感的程序时,C/C++ 的数组仍然是首选。
1.3 下标从 0 开始不是随意设计
很多初学者会问:为什么数组下标从 0 开始,不从 1 开始?你说“因为 C 语言这么定”,其实背后的原因是地址计算更自然。如果下标从 1 开始,地址公式就变成了base + (i - 1) * size,每次访问都要多一次减法。从 0 开始时,下标本身就是“距离首元素的偏移量”,不需要任何额外换算。
更重要的是,从 0 开始的区间表示在算法里特别方便。比如想表示“从下标 2 到下标 5 的元素”,用左闭右开区间[2, 6),长度直接是 6 - 2 = 4,遍历时条件写i < 6,天然防止越界。很多语言的切片语法(包括 Python)都采用这种左闭右开设计,用习惯了你会觉得这是世界上最自然的事。
所以下次看到for i in range(len(arr)),不要再想着“为什么不从 1 开始”,这不是历史包袱,而是一个经得起推敲的设计。
2. 数组常见操作的复杂度与性能真相
2.1 遍历数组:缓存友好是如何影响速度的
遍历是数组最基础的操作,大部分人上来就是:
for i in range(len(nums)): print(nums[i])这段代码没什么问题,但如果你稍微深入一点,会发现遍历顺序对性能的影响比想象中大得多。CPU 从内存读数据时,不是按字节一个个拿,而是按“缓存行”批量加载的,一次可能加载 64 字节甚至更多。当你按顺序访问数组时,刚加载进缓存的数据立刻就能用上,命中率极高;如果你跳着访问、随机访问,缓存经常落空,CPU 就得频繁回到内存取数,速度掉好几倍。
二维数组的遍历顺序能更明显地体现场景:
matrix = [[1, 2, 3], [4, 5, 6], [7, 8, 9]] # 按行遍历(推荐) for row in matrix: for val in row: print(val) # 按列遍历(Cache Miss 会多很多) for j in range(len(matrix[0])): for i in range(len(matrix)): print(matrix[i][j])在数据量大时,这两种写法耗时可能差出好几倍。初学阶段不用过度关注这个,但心里要有个概念:数组是连续内存,遍历时尽量顺着内存方向走,这是免费的性能优化。
2.2 查找:有序和无序是两个世界
无序数组里找一个目标值,唯一的办法就是从头到尾比一遍,复杂度 O(n)。如果数组有 100 万个元素,运气差要比较 100 万次;即使运气好,平均也要比较约 50 万次。
但有序数组就不一样了。你拿中间元素和目标比,一次就能排除掉一半,这种折半策略就是二分查找,复杂度只有 O(log n)。100 万个有序元素,二分查找最多只需要约 20 次比较。10 万倍以上的差距,是不是很夸张?
前提是数组必须有序。这就涉及一个工程权衡:如果你需要频繁查找、但很少插入删除,那维护一个有序数组很合算;如果你的数据无时无刻不在变动,那每次插入后都重新排序就是灾难,这时候就该考虑树形结构或者跳表了。
很多经典题目其实就是在考这个“有序”的价值。比如“两数之和”的进阶版,数组有序时,用双指针从两边往中间靠,一次遍历就能找到答案。没有“有序”这个前提,你就只能上哈希表。看清前提,往往比会背模板更重要。
2.3 插入与删除:数组的真正软肋
数组能随机访问,但代价是插入和删除很痛苦。在数组头部插入一个元素,为了维持连续性和相对顺序,所有已有元素都得往后挪一位。假设数组有 n 个元素,头部插入要移动 n 个元素,复杂度 O(n)。中间插入也类似,平均要移动 n/2 个元素。
删除同理,头部删一个元素,后面所有元素都要往前补位。唯一“便宜”的操作是在尾部插入或删除,因为不需要移动其他元素,复杂度 O(1)。
举个例子,一个 10 万元素的数组,往头部插一个新元素,就意味着后面 10 万个元素全部要搬到新地址,这是实打实的内存复制操作。在 C 里你可以用 memmove 快速搬移,但整体耗时仍然是线性的。而如果你用的是链表,头部插入只需要改一次指针,O(1)。这就是为什么链表在某些场景不可替代。
不过也别急着给数组判死刑。实际工程中,我们经常用一个技巧:如果只是频繁从头部移除,但又想避免大规模搬移,可以维护一个“起始下标”,逻辑上把头元素标记为已删除,数组空间不立刻释放。这种思路在滑动窗口、队列实现里非常常见。所以数组的缺点是固定的,但人的设计是灵活的。
2.4 操作复杂度速查表
| 操作 | 无序数组 | 有序数组 | 备注 |
|---|---|---|---|
| 访问下标 i | O(1) | O(1) | 地址直接计算 |
| 查找某个值 | O(n) | O(log n) | 有序可二分 |
| 尾部插入 | O(1) | O(1) | 最理想的情况 |
| 头部插入 | O(n) | O(n) | 全部元素后移 |
| 中间插入 | O(n) | O(n) | 平均移动 n/2 个元素 |
| 删除头部 | O(n) | O(n) | 全部元素前移 |
| 删除尾部 | O(1) | O(1) | 直接减小长度 |
这张表建议打印出来贴墙上,不是真让你背,而是做题时用来判断“为什么这道题该用数组”或者“为什么不该用”。比如题目要求在头部频繁增删,你第一反应就该怀疑数组是否合适;如果只是反复按下标访问、偶尔追加元素,数组就是最优解。
3. 实战里最容易翻车的三个数组细节
3.1 越界访问:最可怕的不是报错
越界是数组初学者最容易踩的坑,而且不同语言表现完全不同。C/C++ 里越界访问不一定崩溃,编译器也不报警,你访问arr[3]时拿到的只是一个“相邻内存里的旧数据”,可能是别的变量,也可能是一段无意义的垃圾值。
这非常可怕,因为程序还能继续跑,只是结果静悄悄地错了。我见过一个调试了大半天的问题,最后发现是数组越界把相邻变量的值改掉了。这类 bug 不出来则以,一出来就是最难查的那一类。Java、Python 在这方面就好很多,越界直接抛异常,问题是哪个程序员愿意在线上环境看到异常呢?
预防方法很简单,但很多人就是懒得做:
- 写循环前先确认边界,条件用
< length,不要用<= length; - 涉及下标加减时,先判断结果是否落在
[0, length)区间内; - 在 C/C++ 里可以用
assert或者编译器的地址检查选项辅助调试; - 刷题时更要注意:很多隐藏的错误就是数组下标计算差了一位。
3.2 动态数组扩容:不要被“均摊O(1)”麻痹
Python 的list、Java 的ArrayList、C++ 的vector,本质上都是“动态数组”。它们能自动扩容,但这不代表扩容没有成本。扩容的策略一般是按倍数增长,比如容量满了就翻倍。翻倍时要把旧数组的全部元素复制到新数组里,是一次 O(n) 的操作。
那为什么我们说动态数组 append 的复杂度是“均摊 O(1)”?因为扩容不是每次 append 都发生,按翻倍策略,扩容 k 次的总复制量是1 + 2 + 4 + ... + 2^k ≈ 2n,平摊到 n 次 append 上,每次大约 O(1)。这个数学结论没问题,但它掩盖了一个工程细节:某一次 append 突然触发了扩容,那一刻的卡顿是真实的。
如果你预先知道数据规模很大,许多语言都支持预留容量,比如 C++ 的vector::reserve。示例:
#include <vector> std::vector<int> nums; nums.reserve(1000000); // 后续 push_back 基本不会触发扩容我在实际写代码时的习惯是:凡是循环里大量push_back的场景,先算一个大概数量并 reserve,既省时间又省内存碎片。这个细节在面试题里不容易考到,但真实项目中可以帮你省掉不少性能排查的时间。
3.3 初始化默认值:一个隐蔽的bug来源
数组不初始化就直接使用,在各种语言里都是事故高发区。C/C++ 里你定义一个局部数组却不赋值,里面可能装的是“上次这块内存被使用后残留的值”,不是一个确定的 0。很多新人以为 int 默认是 0,其实在 C 的局部变量里完全不是那么回事。
可靠做法是显式初始化:
int arr[10] = {0}; // 全部初始化为 0 int arr[5] = {1, 2, 3}; // 前三个是给定值,其余自动为 0Python 里也藏着一个经典误区:[0] * 3能创建一个包含 3 个 0 的列表,但[ [0] * 3 ] * 3得到的矩阵,三行其实是同一个对象,改一个“看似只改一行”的值,实际三行全变了。正确做法是列表推导式:
matrix = [[0] * 3 for _ in range(3)]Java 的数组默认值是 0 或者 null,相对安全,但如果你创建的是一个对象数组,里面每个元素默认都是 null,调用任何方法都会空指针。这些都是“数组基础”里最容易被忽略、却最容易拉开代码质量差距的细节。
4. 把数组用活:刷题常见套路与后续学习路线
4.1 滑动窗口:用两个下标管理问题边界
数组题目里出镜率最高的技巧之一就是滑动窗口。核心思想很简单:维护两个指针 left 和 right,right 负责往前扩展窗口,left 负责在窗口状态不满足时收缩。窗口内部的状态不需要重新计算,只需在指针移动时增量更新,整体复杂度可以达到 O(n)。
举个例子,求连续子数组的最大和,可以先固定 right 累加,当窗口和小于 0 时移动 left 重新开始。另一个经典题目是“无重复字符的最长子串”,用窗口配合哈希表或数组标记来判断是否重复。
滑动窗口好用,是因为它利用了数组连续子结构的特性:窗口每移动一格,只在边界上做一次加减法,中间那些元素不需要重复遍历。如果你每次都重新算一遍窗口内容,那就退化成了 O(n²) 或更差。所以写这类题时,一定要问自己:窗口移动时,边界变化的增量是多少?这个增量维护得住,才叫真正的滑动窗口。
用伪代码概括窗口框架:
left = 0 state = 初始化状态 for right in range(len(nums)): 更新 state,纳入 nums[right] while 窗口不满足条件: 更新 state,移除 nums[left] left += 1 记录/更新答案这个框架适用于大量子数组、子串问题,建议手动敲三遍,比看任何教程都管用。
4.2 双指针:有序数组的黄金搭档
双指针和滑动窗口有重叠,但侧重点不太一样。滑动窗口更多是“一前一后同一方向”,双指针则经常是“从两端往中间走”。
典型场景:给定一个有序数组和一个目标值,找出数组中两个数等于目标值。朴素做法是两层循环 O(n²)。双指针思路是左指针在最左,右指针在最右,计算两数之和,如果和偏大右指针往左移,和偏小左指针往右移,直到相遇。整个过程只遍历一遍数组,O(n)。
还有些题目要求原地删除数组中的某些元素,也可以用快慢指针,快指针负责扫描,慢指针负责记录结果位置,一次遍历完成操作。比如删除有序数组中的重复项,快指针发现新值,就交给慢指针存储,最后数组前部分就是去重结果。
这类技巧的本质,是用“位置关系”减少无效枚举。遇到有序数组、回文串、区间合并之类的题,先想一想能不能用双指针去掉一层循环,往往比直接暴力效果好得多。
4.3 用哈希表思维给数组问题提速
数组里经常要判断“某个值是否出现过”或者“某个值出现了几次”。如果只用数组本身来做,每查一次就是 O(n)。这时候可以把数组里的值塞进哈希表(字典),把判断存在性变成 O(1)。
经典的两数之和,如果用两层循环查找补数,是 O(n²)。换成哈希表以后,一次遍历就能完成:每到一个元素,就看 target 减去当前值是否已经在表里。没有就把他当前元素的位置存进哈希表。
哈希表的代价是额外空间和计算哈希的开销,它本质上是“用空间换时间”。但很多时候,这个交换非常划算。另一个更巧妙的地方在于:当数值范围有限且比较稠密时,甚至不需要哈希表,直接用另一个数组做“计数数组”。比如统计字符串中每个字符出现次数,因为字符范围只有有限多个,直接开一个大小为 26 的 int 数组,比哈希表更快、更省内存。这就是“数组模拟哈希”的思路,刷题时经常能用到,是基础中的精华。
4.4 二维数组与矩阵:数组的下一站
数组学到后面一定会碰到二维数组,它本质上是“数组的数组”。在内存里,很多语言的二维数组是逐行存储的,所以遍历时的顺序会对性能产生影响。如果你以后接触图像处理、棋盘类题目、矩阵旋转、路径搜索,二维数组就是绕不开的建模工具。
遇到二维数组问题,我习惯先做三件事:第一,明确行列的定义,matrix[i][j]里 i 是行还是列;第二,把方向数组列出来,比如上下左右四个方向的增量;第三,在纸上画一个 3×3 的矩阵,手动走一遍坐标变化。比如矩阵螺旋遍历这类题,最核心就是搞清楚每一圈的边界怎么收缩,边界条件写对,代码就成功了一大半。
二维数组还经常和 DFS、BFS 结合,进行岛屿数量、迷宫路径之类的搜索题。虽然是“基础之后的难题”,但根子还是数组,只不过多了一层方向遍历。能把一维数组的思想迁移到二维,才算真正把一个数据结构学到位了。
最后说一个我个人很受用的小技巧:刷数组题时,把循环变量统一命名成left、right、index,而不是i、j、k满天飞。这样做最大的好处是,每次写完代码回看时,自己都能立刻知道哪个变量代表窗口边界、哪个代表遍历指针,排查边界 bug 会快很多。数组题目看似简单,但越是基础的东西,越值得用严谨的习惯去对待。