数组的顺序存储,说穿了就是把逻辑上的多维表格压成内存里的一根长条。数据结构里讲数组,重点从来不是“怎么声明”,而是“给定下标,怎么在 O(1) 时间算出元素地址”。一维、二维、三维直到 n 维数组的元素地址计算,考研大题、C语言指针题、嵌入式查表、LabVIEW 数组处理、Simulink 读数组,底层都绕不开这个公式。很多人学的时候觉得一维很简单,二维也能画个图,到了三维、n维就靠背,结果一换下标起点、一换行优先列优先,立刻翻车。
我把这类题拆成一句最土的话:元素地址 = 起始地址 + 前面有多少个元素 × 每个元素占几字节。真正要练的,是快速数清楚“前面到底有多少个元素”。下面从一维推到 n 维,配图、配公式、配可运行代码,再把我自己踩过的坑和考研题里最阴的易错点一起摊开。适合准备数据结构期末、考研 408、C语言进阶、嵌入式/工控数据处理的人,也适合只记得“数组就是连续内存”但不会推地址的新手。
1. 数组顺序存储到底在存什么
1.1 连续内存与随机访问
数组的顺序存储,指的是逻辑上相邻的元素,在物理内存里也相邻。假设一个元素占 L 个字节,起始地址为 Base,那么内存长这样:
地址: Base Base+L Base+2L Base+3L Base+4L 下标: 0 1 2 3 4 元素: a[0] a[1] a[2] a[3] a[4]这种“连续铺开”的存法带来两个结果。第一,随机访问很快,给下标就能算地址,不需要从头遍历;第二,插入、删除很贵,因为要保持连续性,平均要移动一堆元素。数组的地址计算之所以成立,就是因为每个元素大小固定、排列顺序固定。
你可以把它想成一排储物柜。每个柜子大小一样,编号连续。知道第一个柜子的编号和柜子宽度,就能立刻算出第 k 个柜子的位置。数组地址计算就是“柜子编号换算”,没有任何神秘之处。
1.2 地址公式本质:数前置元素
不管几维数组,地址公式都遵守同一个骨架:
LOC(a[i]) = 起始地址 + 前置元素个数 × 单个元素字节数
一维数组里,下标 i 前面有 i 个元素,所以 LOC(a[i]) = Base + i × L。二维数组里,如果按行优先存储,a[i][j] 前面有“前 i 行全部元素”加上“本行前 j 个元素”,所以前置个数是 i × 列数 + j。三维数组也一样,先数前面多少页,再数前面多少行,最后数本行前面多少列。
所以别急着背公式。先问自己:这个元素前面完整地跨过了几个维度块?每个维度块里有多少个元素?最后剩多少个零头?把这个问题回答清楚,公式自己就长出来了。
1.3 行优先与列优先到底谁先谁后
行优先,也叫按行存储,意思是先铺第一行,再铺第二行,直到最后一行。对于二维数组,元素变化最快的是列下标 j,列下标跑完一行的长度后,行下标 i 才加一。C、C++、Java、C#、Python 的列表嵌套在逻辑上都常按行优先理解,Fortran、MATLAB、R、Julia 默认按列优先。
列优先则相反:先铺第一列,再铺第二列,列下标变化最慢,行下标变化最快。用一句话记:行优先是“一行一行扫”,列优先是“一列一列扫”。考试题里只要出现“按行优先”“按列优先”,就必须先确认存储顺序,再代公式。很多人算错不是不会公式,而是把行列优先搞反了。
行优先存储 3×3 数组: a[0][0] a[0][1] a[0][2] | a[1][0] a[1][1] a[1][2] | a[2][0] a[2][1] a[2][2] 列优先存储 3×3 数组: a[0][0] a[1][0] a[2][0] | a[0][1] a[1][1] a[2][1] | a[0][2] a[1][2] a[2][2]图中竖线只是帮助分组,真实内存里没有分隔,所有元素仍然是一根连续长条。
2. 一维数组地址计算:所有多维公式的底座
2.1 一维数组地址公式推导
设一维数组 a,起始地址为 Base,每个元素占 L 字节,下标从 0 开始。那么:
LOC(a[i]) = Base + i × L
推导很简单:a[0] 在 Base,a[1] 在 Base + L,a[2] 在 Base + 2L,所以第 i 个元素前面有 i 个元素,每个 L 字节,偏移量就是 i × L。
举例:Base = 1000,L = 4,求 a[3] 的地址。a[3] 前面有 a[0]、a[1]、a[2] 三个元素,偏移 3 × 4 = 12 字节,所以 LOC(a[3]) = 1000 + 12 = 1012。内存图如下:
Base=1000, L=4 a[0] -> 1000 a[1] -> 1004 a[2] -> 1008 a[3] -> 1012 a[4] -> 1016如果下标从 1 开始,比如数组 a[1..n],起始地址 Base 对应 a[1],那么 a[i] 前面有 i - 1 个元素:
LOC(a[i]) = Base + (i - 1) × L
这就是下标起点不同带来的唯一变化。公式本身没有变,仍然是“前面有多少个元素”。
2.2 下标从 0 与从 1 的差异
考试里最容易被坑的地方就是下标起点。有的教材用 0 开头,有的伪代码用 1 开头,C语言真实数组一定从 0 开始。看到一个题,先圈出两个信息:起始地址对应哪个下标、题目要求下标从几开始。
| 下标起点 | 公式 | 说明 |
|---|---|---|
| 从 0 开始 | LOC(a[i]) = Base + i × L | Base 是 a[0] 的地址 |
| 从 1 开始 | LOC(a[i]) = Base + (i - 1) × L | Base 是 a[1] 的地址 |
如果题目说“数组 A[1..10],首地址为 1000,每个元素 2 字节,求 A[6] 地址”,那就用从 1 开始的公式:1000 + (6 - 1) × 2 = 1010。如果你顺手写成 1000 + 6 × 2 = 1012,就正好错一个元素。这个错误在考研选择题里非常常见。
2.3 用 C 语言打印地址验证
理论说完,动手验证最稳。下面这段代码用%p打印地址,用sizeof算元素大小,再用指针差验证偏移。
#include <stdio.h> int main(void) { int a[5] = {10, 20, 30, 40, 50}; int L = sizeof(int); printf("a[0] address = %p\n", (void*)&a[0]); printf("a[3] address = %p\n", (void*)&a[3]); printf("sizeof(int) = %d\n", L); // 指针相减得到的是元素个数,不是字节数 ptrdiff_t diff = &a[3] - &a[0]; printf("a[3] - a[0] = %td 个元素\n", diff); printf("字节偏移 = %td\n", diff * L); return 0; }实测时你会发现,&a[3] - &a[0]输出 3,而字节偏移是 3 × 4 = 12。这里有个细节:指针减法会自动除以元素大小,所以直接打印指针差并不等于字节偏移。排查地址问题时,如果你用%p看十六进制地址,最好转换成十进制再算,否则容易在 0x 前缀里绕晕。
注意:
sizeof(int)在多数桌面平台是 4,但在一些嵌入式平台可能是 2 或 8。地址计算题如果没有给元素大小,通常会说明“每个元素占 d 个字节”,没有说明就不能默认 4。
3. 二维数组地址计算:行优先、列优先与参数推导
3.1 行优先存储公式
设二维数组 A[m][n],m 行 n 列,起始地址 Base,每个元素 L 字节,下标从 0 开始。行优先存储时,内存顺序是:
a[0][0], a[0][1], ..., a[0][n-1], a[1][0], a[1][1], ..., a[1][n-1], ... a[m-1][0], ..., a[m-1][n-1]求 a[i][j] 的地址,先数前面完整的 i 行,每行 n 个元素,共 i × n 个;再数本行前面 j 个元素。所以前置元素个数为 i × n + j:
LOC(a[i][j]) = Base + (i × n + j) × L
如果下标从 1 开始,数组为 A[1..m][1..n],那么 a[i][j] 前面有 (i - 1) 行,每行 n 个,本行前面有 (j - 1) 个:
LOC(a[i][j]) = Base + ((i - 1) × n + (j - 1)) × L
3.2 列优先存储公式
列优先存储时,内存顺序变成先第一列,再第二列:
a[0][0], a[1][0], ..., a[m-1][0], a[0][1], a[1][1], ..., a[m-1][1], ... a[0][n-1], ..., a[m-1][n-1]求 a[i][j],前面有完整的 j 列,每列 m 个元素,共 j × m 个;本列前面有 i 个元素。所以:
LOC(a[i][j]) = Base + (j × m + i) × L
如果下标从 1 开始:
LOC(a[i][j]) = Base + ((j - 1) × m + (i - 1)) × L
注意列优先里,先乘的是列下标 j,再乘行数 m。很多人会写成 j × n + i,这是典型错误。列方向上一列有多长?是行数 m,不是列数 n。
3.3 实例计算:一个 3×4 数组两种存法对比
设int a[3][4],Base = 1000,L = 4,下标从 0 开始。求 a[1][2] 的地址。
行优先:
offset = i × n + j = 1 × 4 + 2 = 6 地址 = 1000 + 6 × 4 = 1024列优先:
offset = j × m + i = 2 × 3 + 1 = 7 地址 = 1000 + 7 × 4 = 1028对比表如下:
| 存储方式 | 前置元素个数 | 地址计算 | 结果 |
|---|---|---|---|
| 行优先 | 1×4+2 = 6 | 1000 + 6×4 | 1024 |
| 列优先 | 2×3+1 = 7 | 1000 + 7×4 | 1028 |
行优先内存展开图:
行优先: a[0][0] a[0][1] a[0][2] a[0][3] | a[1][0] a[1][1] a[1][2] a[1][3] | a[2][0] ... 1000 1004 1008 1012 1016 1020 1024 1028 1032 ^目标列优先内存展开图:
列优先: a[0][0] a[1][0] a[2][0] | a[0][1] a[1][1] a[2][1] | a[0][2] a[1][2] a[2][2] | ... 1000 1004 1008 1012 1016 1020 1024 1028 1032 ^目标同一个 a[1][2],行优先落在 1024,列优先落在 1028。这就是为什么地址计算题必须明确存储顺序。
3.4 二维数组传参为什么必须带列数
热搜里有个很典型的问题:“c语言传参传二维数组要有个数字”。这个数字就是列数。C语言里,二维数组的数组名会退化成指向第一行的指针,形参通常写成:
void func(int a[][4], int row) { // a[i][j] 等价于 *(*(a + i) + j) }编译器要把a[i][j]解释成*(*(a + i) + j)。其中a + i要跳过 i 行,每一行有多大?必须是列数 × sizeof(int)。如果形参只写int a[][],编译器不知道一行多长,就无法计算行跨度,所以编译不过。正确写法还有int (*a)[4],本质上也是指向“含 4 个 int 的数组”的指针。
我见过有人写成int **a去接二维数组,结果访问崩溃。int **和二维数组名不是一回事,前者是指针的指针,后者是指向数组的指针。二维数组传参时,列数不是可选项,而是地址计算的核心参数。
4. 三维数组地址计算:从二维向上扩展
4.1 三维数组的内存布局
设三维数组 A[d1][d2][d3],你可以把它看成 d1 个“页”,每页是一个 d2 行 d3 列的二维数组。行优先存储时,先放第 0 页,再放第 1 页,每页内部按行优先铺开:
页0: a[0][0][0] a[0][0][1] ... a[0][0][d3-1] a[0][1][0] a[0][1][1] ... a[0][1][d3-1] ... 页1: a[1][0][0] a[1][0][1] ... a[1][0][d3-1] ...所以三维数组并没有多神秘,它只是“页、行、列”三级结构。行优先时,最后一维 k 变化最快,然后 j 变化,i 变化最慢。
4.2 行优先公式推导
求 a[i][j][k] 的前置元素个数:
- 前面完整的页有 i 页,每页有 d2 × d3 个元素,共 i × d2 × d3 个。
- 本页前面完整的行有 j 行,每行 d3 个元素,共 j × d3 个。
- 本行前面有 k 个元素。
加起来:
行优先 offset = i × d2 × d3 + j × d3 + k
地址:
LOC(a[i][j][k]) = Base + (i × d2 × d3 + j × d3 + k) × L
如果下标从 1 开始,把 i、j、k 分别换成 i-1、j-1、k-1 代入即可。
4.3 列优先公式推导
列优先时,变化最快的是第一维 i,然后是第二维 j,最后才是第三维 k。内存顺序相当于先固定 k 和 j,把 i 跑完,再换 j,最后换 k。
前置元素个数:
- 完整的 k 层有 k 层,每层有 d1 × d2 个元素,共 k × d1 × d2 个。
- 本层前面完整的 j 列有 j 列,每列 d1 个元素,共 j × d1 个。
- 本列前面有 i 个元素。
所以:
列优先 offset = k × d1 × d2 + j × d1 + i
地址:
LOC(a[i][j][k]) = Base + (k × d1 × d2 + j × d1 + i) × L
这个公式可以记成“越靠前的维度乘得越多”。列优先里 i 是最快维度,所以它只乘 1;j 要乘 d1;k 要乘 d1 × d2。
4.4 实例计算:a[1][1][2] 到底在哪
设int a[2][3][4],Base = 2000,L = 4,下标从 0 开始。求 a[1][1][2]。
行优先:
offset = i × d2 × d3 + j × d3 + k = 1 × 3 × 4 + 1 × 4 + 2 = 12 + 4 + 2 = 18 地址 = 2000 + 18 × 4 = 2072列优先:
offset = k × d1 × d2 + j × d1 + i = 2 × 2 × 3 + 1 × 2 + 1 = 12 + 2 + 1 = 15 地址 = 2000 + 15 × 4 = 2060对比表:
| 存储方式 | 公式代入 | 前置个数 | 地址 |
|---|---|---|---|
| 行优先 | 1×3×4 + 1×4 + 2 | 18 | 2072 |
| 列优先 | 2×2×3 + 1×2 + 1 | 15 | 2060 |
这里再提醒一个坑:不要用 a[1][2][3] 这种靠边的下标去验证公式。因为有些三维数组坐标在行优先和列优先下的偏移量会碰巧相等,比如 1×3×4 + 2×4 + 3 = 23,而 3×2×3 + 2×2 + 1 = 23。碰巧一样不代表公式对了,换 a[1][1][2] 立刻现原形。
5. n 维数组地址计算:一个求和公式统一所有维度
5.1 n 维行优先通式
设 n 维数组 A[d1][d2]...[dn],下标从 0 开始,起始地址 Base,每个元素 L 字节。行优先存储时,第一维变化最慢,第 n 维变化最快。
求 A[i1][i2]...[in] 的前置元素个数,就是依次计算每个维度的贡献:
- i1 每增加 1,跨过后面的 d2 × d3 × ... × dn 个元素。
- i2 每增加 1,跨过后面的 d3 × d4 × ... × dn 个元素。
- ...
- i_{n-1} 每增加 1,跨过 dn 个元素。
- in 每增加 1,跨过 1 个元素。
所以行优先偏移量:
offset = i1 × (d2×d3×...×dn) + i2 × (d3×d4×...×dn) + ... + i_{n-1} × dn + in
用求和符号写:
offset = Σ_{k=1}^{n} i_k × Π_{j=k+1}^{n} d_j
其中当 k = n 时,后面的乘积约定为 1。地址就是:
LOC = Base + offset × L
如果题目下标从 1 开始,把每个 i_k 替换成 i_k - 1 再代入。
5.2 n 维列优先通式
列优先时,第一维变化最快,第 n 维变化最慢。每个维度的步长变成“前面所有维度的乘积”:
- i1 每增加 1,跨过 1 个元素。
- i2 每增加 1,跨过 d1 个元素。
- i3 每增加 1,跨过 d1 × d2 个元素。
- ...
- in 每增加 1,跨过 d1 × d2 × ... × d_{n-1} 个元素。
所以列优先偏移量:
offset = i1 + i2 × d1 + i3 × d1 × d2 + ... + in × (d1 × d2 × ... × d_{n-1})
用求和符号写:
offset = Σ_{k=1}^{n} i_k × Π_{j=1}^{k-1} d_j
同样,k = 1 时前面的乘积约定为 1。地址还是:
LOC = Base + offset × L
5.3 用前缀乘积与后缀乘积快速算
手算 n 维地址时,不要现场展开一长串乘法,容易错。我习惯用“后缀乘积”算行优先,用“前缀乘积”算列优先。
对于维度 d1, d2, ..., dn,行优先的后缀乘积:
suf[1] = d2 × d3 × ... × dn suf[2] = d3 × d4 × ... × dn ... suf[n-1] = dn suf[n] = 1然后:
offset = i1×suf[1] + i2×suf[2] + ... + in×suf[n]列优先的前缀乘积:
pre[1] = 1 pre[2] = d1 pre[3] = d1 × d2 ... pre[n] = d1 × d2 × ... × d_{n-1}然后:
offset = i1×pre[1] + i2×pre[2] + ... + in×pre[n]举个例子:四维数组 A[2][3][4][5],下标从 0 开始,求 A[1][1][2][3] 的行优先偏移。
行优先后缀乘积:
suf[1] = 3×4×5 = 60 suf[2] = 4×5 = 20 suf[3] = 5 suf[4] = 1 offset = 1×60 + 1×20 + 2×5 + 3×1 = 60 + 20 + 10 + 3 = 93列优先前缀乘积:
pre[1] = 1 pre[2] = 2 pre[3] = 2×3 = 6 pre[4] = 2×3×4 = 24 offset = 1×1 + 1×2 + 2×6 + 3×24 = 1 + 2 + 12 + 72 = 87如果 Base = 5000,L = 4,那么行优先地址 = 5000 + 93×4 = 5372,列优先地址 = 5000 + 87×4 = 5348。用前缀/后缀乘积,比直接记公式更不容易漏项。
5.4 代码实现通用地址计算
手算会了,最好再用代码写一遍通用函数。下面给出 Python 版本,支持任意维度、行优先和列优先。
def row_major_offset(indices, dims): """行优先:第一维变化最慢,最后一维变化最快""" n = len(dims) offset = 0 for k in range(n): # 计算后缀乘积 dims[k+1] * ... * dims[n-1] stride = 1 for j in range(k + 1, n): stride *= dims[j] offset += indices[k] * stride return offset def col_major_offset(indices, dims): """列优先:第一维变化最快,最后一维变化最慢""" offset = 0 for k in range(len(dims)): # 计算前缀乘积 dims[0] * ... * dims[k-1] stride = 1 for j in range(0, k): stride *= dims[j] offset += indices[k] * stride return offset dims = [2, 3, 4, 5] idx = [1, 1, 2, 3] print(row_major_offset(idx, dims)) # 93 print(col_major_offset(idx, dims)) # 87C 语言版本更贴近考试和嵌入式场景:
#include <stdio.h> long long row_major_offset(const int idx[], const int dims[], int n) { long long offset = 0; for (int k = 0; k < n; k++) { long long stride = 1; for (int j = k + 1; j < n; j++) { stride *= dims[j]; } offset += (long long)idx[k] * stride; } return offset; } long long col_major_offset(const int idx[], const int dims[], int n) { long long offset = 0; for (int k = 0; k < n; k++) { long long stride = 1; for (int j = 0; j < k; j++) { stride *= dims[j]; } offset += (long long)idx[k] * stride; } return offset; } int main(void) { int dims[] = {2, 3, 4, 5}; int idx[] = {1, 1, 2, 3}; int n = 4; long long base = 5000; int elem_size = 4; long long off_row = row_major_offset(idx, dims, n); long long off_col = col_major_offset(idx, dims, n); printf("row offset = %lld, addr = %lld\n", off_row, base + off_row * elem_size); printf("col offset = %lld, addr = %lld\n", off_col, base + off_col * elem_size); return 0; }这段代码里用long long不是多此一举。高维数组的维度乘积可能非常大,用int很容易溢出。比如 10 维每维长度 10,总元素数就是 10^10,32 位int根本装不下。做动态数组、稀疏矩阵映射、多维查表时,地址计算溢出是很隐蔽的 bug,查起来非常痛苦。
6. 常见问题与排查技巧实录
6.1 常见错误速查表
| 现象 | 可能原因 | 快速排查 |
|---|---|---|
| 行优先算成列优先 | 把 i×n+j 和 j×m+i 搞反 | 先画内存展开图,看谁先变化 |
| 下标从 1 开始却按 0 算 | 少减了 1 | 圈出题目起点,统一替换成 i-1、j-1 |
| 忘乘元素大小 | 只算了元素个数 | 地址 = Base + offset × L |
| 二维数组传参编译失败 | 形参没写列数 | 写成int a[][4]或int (*a)[4] |
| 三维列优先公式写错 | 乘错维度长度 | 列优先乘的是前面维度的乘积 |
| 地址越界 | 维度算错或下标超范围 | 打印 offset 和总元素数对比 |
| 高维数组偏移溢出 | 用了 int | 改用 long long 或 size_t |
C语言int **接二维数组崩溃 | 类型不匹配 | 二维数组名不是二级指针 |
6.2 考研与期末考试易错点
考研数据结构里,地址计算常和“行优先/列优先”“下标从 0/1 开始”“元素字节数”组合出题。典型题:数组 A[1..8][1..10],按行优先存储,首地址 1000,每个元素 2 字节,求 A[5][7] 地址。
先看下标从 1 开始,行优先公式:
offset = (i - 1) × n + (j - 1) = (5 - 1) × 10 + (7 - 1) = 4 × 10 + 6 = 46 地址 = 1000 + 46 × 2 = 1092如果换成列优先:
offset = (j - 1) × m + (i - 1) = (7 - 1) × 8 + (5 - 1) = 6 × 8 + 4 = 52 地址 = 1000 + 52 × 2 = 1104这类题我自己的做题顺序固定为四步:第一步,看存储顺序;第二步,看下标起点;第三步,看元素大小;第四步,套公式并检查 offset 是否小于总元素数。总元素数就是所有维度长度相乘。如果算出的 offset 大于等于总元素数,必定错。这个检查花不了几秒,但能救回很多粗心分。
王道数据结构、数据结构期末复习里还会考“数组是一种随机存取结构”“数组的顺序存储需要连续空间”“多维数组可以看成线性表的推广”。这些概念题背后的支撑就是地址公式。理解公式,概念就不会死记。
6.3 实操排查心得
我调试地址问题时,最常用的一招是“小数组打印地址差”。不要一上来就搞 4 维、5 维,先用int a[2][3]或int a[2][2][2],打印每个元素的地址,看差值是不是 L。然后改变下标,观察地址增长方向。行优先时,a[0][1]和a[0][0]差 L,a[1][0]和a[0][0]差 n×L。列优先的库或语言里,差值方向反过来。
第二招是“画格子标地址”。拿一张纸画两行三列,每个格子写元素名,再在下面标地址。然后按行优先从左到右、从上到下编号;再按列优先从上到下、从左到右编号。编号就是 offset。图画三次,比背十个公式管用。
第三招是“用通用函数反查”。写一个row_major_offset和一个col_major_offset,把考试题的维度、下标、Base、L 都输进去,看结果是否和手算一致。做 C语言动态数组时,经常用一维malloc模拟二维:
int rows = 3, cols = 4; int *a = (int*)malloc(rows * cols * sizeof(int)); #define A(i, j) a[(i) * cols + (j)]这里的(i) * cols + (j)就是二维行优先偏移。如果哪天把cols写成rows,访问就会错位。这个 bug 在图像处理、矩阵运算、LabVIEW 与 Simulink 数据交互里都很常见,因为外部数据经常按行优先传来,而某些数学工具默认列优先。数组转字符串、数组去重、对象数组处理时,如果底层索引换算错了,表现可能是数据串行、图像花屏、查表结果整体偏移,不一定是程序崩溃,排查难度更高。
我的建议很直接:凡是遇到多维数组地址,先确认存储顺序,再确认下标起点,最后用“前面有多少个元素”推一遍。公式可以忘,这个思路不能丢。把一维、二维、三维各画一遍图,n 维公式自然就是“后缀乘积”和“前缀乘积”两种写法。真正到了考场或者项目现场,能救你的不是背下来的长公式,而是你知道每一步在数什么。