4、指针与多维数组:二维数组的内存布局、行指针与列指针、指针数组与数组指针的区别、用指针操作二维数组

指针和二维数组,这俩东西凑一块,很多人就开始头疼了。说实话,我当年学C语言的时候,也是在这个地方卡了很久。二维数组到底在内存里怎么放的?行指针和列指针又是什么鬼?指针数组和数组指针到底谁是谁?

别急,咱们一个一个拆开看。搞明白了这些,你写底层驱动、图像处理、矩阵运算的时候,会顺手很多。

4.1 二维数组的内存布局

先问个问题:二维数组在内存里真的是“二维”的吗?

当然不是。内存是一维的,线性排列的。二维数组只是C语言给你的一种“语法糖”,让你觉得它像个表格。实际上,它在内存里就是一行接一行地排着。

比如你定义一个 int a[3][4],一共12个int,它们在内存里是连续存放的:

a[0][0] a[0][1] a[0][2] a[0][3] a[1][0] a[1][1] ... a[2][3]

这种存储方式叫“行优先存储”。C语言就是行优先的,Fortran是列优先。嗯,这个区别在跨语言调用的时候特别容易踩坑,我后面会提到。

核心理解:

  • a 是整个二维数组的首地址
  • a[0] 是第0行的首地址
  • &a[0][0] 是第0行第0列的地址
  • 这三个值在数值上相等,但类型不同,意义也不同

类型不同,这个很关键。你想想看,a 的类型是 int (*)[4],也就是“指向包含4个int的数组的指针”。而 a[0] 的类型是 int *,就是普通的int指针。虽然它们指向同一个地址,但 a+1 跳过了16个字节(4个int),而 a[0]+1 只跳过了4个字节(1个int)。

我在项目中遇到过有人把 aa[0] 混用,结果指针运算全乱套了。调试了半天才发现是类型不匹配的问题。

二维数组 int a[3][4] 内存布局(行优先) 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] a[2][1] a[2][2] a[2][3] 地址: 0x1000 0x1004 0x1008 0x100C 0x1010 0x1014 0x1018 0x101C 0x1020 0x1024 0x1028 0x102C a[0] → 第0行首地址 a[1] → 第1行首地址 a[2] → 第2行首地址

4.2 行指针与列指针

这两个概念,说白了就是“跳多大步子”的问题。

  • 行指针:指向一整行。类型是 int (*)[N]。加1,跳过一整行。
  • 列指针:指向一个元素。类型是 int *。加1,跳过一个元素。

看个例子就明白了:

int a[3][4] = {
    {1, 2, 3, 4},
    {5, 6, 7, 8},
    {9, 10, 11, 12}
};

int (*row_ptr)[4] = a;    // 行指针,指向第0行
int *col_ptr = &a[0][0];  // 列指针,指向第0行第0列

// row_ptr + 1 指向 a[1],即第1行
// col_ptr + 1 指向 a[0][1],即第0行第1列

我个人习惯在写矩阵运算的时候,用行指针来遍历行,用列指针来遍历列。这样代码意图更清晰,也不容易出错。

小技巧:

*(*(a + i) + j) 来访问 a[i][j]。这个写法虽然啰嗦,但能帮你理解指针的层级关系。a + i 是行指针,*(a + i) 解引用得到第i行的首地址(列指针),再加j再解引用,就拿到了元素。

4.3 指针数组与数组指针的区别

这个绝对是面试高频题,也是很多人容易搞混的地方。我当年面试的时候就被问过,答得磕磕巴巴的。

记住一句话:“指针数组”是一个数组,里面存的是指针;“数组指针”是一个指针,指向一个数组。

概念 定义方式 本质 典型用途
指针数组 int *p[5] 数组,元素是 int * 存储多个字符串的首地址
数组指针 int (*p)[5] 指针,指向 int [5] 指向二维数组的一行

怎么记?看运算符优先级。[] 的优先级比 * 高。所以 int *p[5] 先和 [] 结合,说明 p 是一个数组。而 int (*p)[5] 加了括号,* 先和 p 结合,说明 p 是一个指针。

我曾经在项目中用指针数组来管理多个传感器的数据缓冲区,每个缓冲区大小不同,用指针数组存它们的首地址,遍历起来非常方便。

// 指针数组:存储3个字符串
char *str_arr[3] = {"Hello", "World", "C Language"};

// 数组指针:指向一个包含4个int的数组
int arr[4] = {1, 2, 3, 4};
int (*p_arr)[4] = &arr;

4.4 用指针操作二维数组

掌握了上面的概念,用指针操作二维数组就水到渠成了。我一般用三种方式:

方式一:用数组下标(最直观)

for (int i = 0; i < 3; i++) {
    for (int j = 0; j < 4; j++) {
        printf("%d ", a[i][j]);
    }
}

方式二:用行指针 + 列指针(灵活)

int (*row)[4] = a;
for (int i = 0; i < 3; i++) {
    int *col = *row;  // 或者 row[0]
    for (int j = 0; j < 4; j++) {
        printf("%d ", col[j]);
    }
    row++;
}

方式三:用一级指针直接遍历(效率高)

int *p = &a[0][0];
for (int i = 0; i < 12; i++) {
    printf("%d ", p[i]);
}

第三种方式效率最高,因为少了一层间接寻址。但要注意,它把二维数组当成一维数组来用了,你得自己保证不越界。

避坑指南:

我曾经在嵌入式项目里用一级指针遍历二维数组,结果数组维度是 [3][4],我循环写了 i < 12,没问题。但后来需求变更,数组改成了 [4][4],我忘了改循环条件,还是 i < 12,结果漏掉了最后4个元素。嗯,从那以后我养成了用 sizeof 计算总元素数的习惯:sizeof(a) / sizeof(a[0][0])

4.5 实际应用场景

说几个我在项目中用到的场景:

  • 图像处理:一张灰度图就是一个二维数组。用行指针遍历每一行,用列指针处理每个像素。
  • 矩阵运算:矩阵乘法、转置,用指针操作比下标快不少,尤其是大矩阵。
  • 多通道数据采集:每个通道的数据是一维数组,多个通道组成二维数组。用指针数组管理每个通道的起始地址。

举个例子,图像处理中常用的“卷积操作”,本质上就是用指针在二维数组上滑动窗口:

// 3x3 卷积核,对图像做边缘检测
int kernel[3][3] = {
    {-1, -1, -1},
    {-1,  8, -1},
    {-1, -1, -1}
};

// 用指针遍历图像(假设图像是 640x480)
int (*img)[640] = image;  // 行指针
for (int y = 1; y < 479; y++) {
    int *row = img[y];
    for (int x = 1; x < 639; x++) {
        // 用指针访问3x3邻域
        int sum = 0;
        for (int ky = -1; ky <= 1; ky++) {
            int *krow = img[y + ky];
            for (int kx = -1; kx <= 1; kx++) {
                sum += krow[x + kx] * kernel[ky + 1][kx + 1];
            }
        }
        // 处理结果...
    }
}

你看,用行指针 img[y] 拿到当前行的首地址,然后通过 krow[x + kx] 访问邻域像素,代码既清晰又高效。

好了,二维数组和指针的关系,说白了就是“怎么在内存里找到你要的数据”。搞清楚了内存布局,理解了行指针和列指针的区别,再分清楚指针数组和数组指针,你就能自如地操作二维数组了。这些东西在底层开发中天天用,值得花时间彻底搞明白。

本章要点回顾:

  • 二维数组在内存中是行优先连续存储的
  • 行指针 int (*p)[N] 指向一整行,列指针 int *p 指向一个元素
  • 指针数组 int *p[N] 是数组,数组指针 int (*p)[N] 是指针
  • 用指针操作二维数组有三种方式:下标、行指针+列指针、一级指针遍历
  • 实际项目中,指针操作比下标操作更灵活、效率更高