先问一个问题:int *p[5]和int (*p)[5],这两个声明放在你面前,你能在一秒钟内说出它们各自是什么吗?
如果你需要犹豫,那这篇文章就是为你写的。很多人在 C/C++ 的指针这条路上走了很远,能玩转链表、能写出各种内存管理技巧,但一碰到这两个声明还是会卡壳。甚至不少工作了三五年的开发者,被面试官问起“数组指针和指针数组的区别”,回答依然是“一个是数组,一个是指针”这种非常模糊的话。说实话,这个知识点确实坑人,因为它们的名字长得像孪生兄弟,但本质是两个完全不同的东西。
我当年学到这里的时候也绕了很久,后来发现只要抓住一个核心——[]的优先级高于*——再配合一套正确的读法,这两个概念就再也忘不掉了。这篇文章我会从语法、内存布局、初始化、应用场景到高频踩坑,完整拆解这两个概念,最后再结合最近讨论度很高的“有序数组原地去重”场景,看看指针操作数组在实际算法题里是怎么发挥作用的。不管是准备面试还是补底层基础,都值得认真看一遍。
1. 一句话先分清:谁是指针,谁是数组
先别急着看复杂的代码,我们用一个最简单的方式建立直觉。
- 指针数组:本质是数组,只是数组里的每个元素都是指针。换句话说,它是一个“装着多个地址的容器”。
- 数组指针:本质是指针,只是这个指针指向了一个数组。换句话说,它是一个“存着某个数组首地址的变量”。
注意这两个定义的差别:一个本身是“容器”,一个本身是“指向容器的工具”。我见过很多人用“数组的指针”和“指针的数组”来记,但我个人更喜欢这个说法——“手里有一张写满了地址的清单”和“手里只有一把能打开某个仓库大门的钥匙”,前者是清单,后者是钥匙。这么说虽然不是特别严格,但对于初学者建立第一印象很有帮助。
再来看标准的声明写法:
int *p[5]; // 指针数组:p 是数组,有 5 个元素,每个元素都是 int* 类型 int (*p)[5]; // 数组指针:p 是指针,指向一个含有 5 个 int 的数组差异就藏在那一对括号里。但为什么一对括号就能改变整个声明的含义?这要回到 C 语言运算符优先级上去看。
2. 语法优先级:为什么括号决定了命运
C 语言里,[](下标运算符)的优先级高于*(解引用/指针声明符)。所以当你写int *p[5]时,p会先和[5]结合,变成“一个有 5 个元素的数组”,然后数组的元素类型才是int*。
而当你在p外加了括号,写成int (*p)[5],括号的优先级最高,p先和*结合,于是p首先是一个指针,然后这个指针指向的东西才是int [5]类型的数组。
很多教材会教“右左法则”,就是从变量名开始,先向右再向左逐层读声明。以int (*p)[5]为例:
p // p (*p) // 是一个指针 (*p)[5] // 指向一个含有 5 个元素的数组 int (*p)[5] // 数组的元素类型是 int读出来就是:p 是一个指向“int 数组(长度 5)”的指针。再看int *p[5]:
p // p p[5] // 是一个含有 5 个元素的数组 *p[5] // 元素类型是指针 int *p[5] // 指针指向的类型是 int读出来就是:p 是一个数组,它有 5 个元素,每个元素都是指向 int 的指针。
如果你想在代码里减少这种可读性灾难,可以用typedef重新包装一下。比如想定义一个数组类型,再定义它的指针:
typedef int IntArray5[5]; // IntArray5 就是一个 int[5] 类型 IntArray5 arr = {1, 2, 3, 4, 5}; IntArray5 *p = &arr; // 这就是数组指针,等价于 int (*p)[5]再比如想定义指针类型,再用它组成数组:
typedef int *IntPtr; // IntPtr 就是 int* IntPtr p[5]; // 这就是指针数组,等价于 int *p[5]用 typedef 包装之后,本质一目了然。我推荐所有初学者在刚接触这两个概念时,都先用 typedef 拆一遍,等熟练了再直接看原始声明。这个过程能帮你把“声明语法”翻译成“真实类型”,远比死记硬背“带括号的是数组指针”要有用得多。
3. 内存模型:两者在内存里到底长什么样
理解了语法,接下来要看内存布局。因为这两者的内存形态差别可以说是天壤之别。
3.1 数组指针的内存视角
数组指针就是一个指针变量,它本身只占据一个指针大小的空间(64 位系统下是 8 个字节)。这个指针变量里存放的是某个数组的首地址。它指向的“目标”是一整块连续的内存区域,也就是那个数组的全部元素。
int arr[5] = {10, 20, 30, 40, 50}; int (*p)[5] = &arr; // p 指向 arr 这个长度 5 的数组此时在内存里,p 是一个独立的指针变量,里面存的是 arr 的首地址。p + 1会跳过一整个数组的大小,也就是5 * sizeof(int) = 20个字节。这就是我们常说的“步长”,数组指针的步长是整个数组的长度,不是单个元素。这一点非常关键,很多人搞混数组指针和普通指针,就是在步长上栽了跟头。
注意,这里要区分arr和&arr。arr在大多数表达式中会退化为指向首元素的指针,类型是int*,对它 +1 会跳过一个 int。而&arr的类型就是int (*)[5],对它 +1 会跳过 5 个 int。同一个地址,因为类型不同,加减运算的跨度完全不同。这也是“数组指针”概念的根基。
3.2 指针数组的内存视角
指针数组本身是一块连续的内存区域,其中存储的是多个指针变量。它的总大小是“元素个数 × 单个指针大小”。每个元素都是一个独立的指针,可以分别指向不同的内存地址。
int a = 1, b = 2, c = 3; int *p[3] = {&a, &b, &c};此时 p 数组里存了三个地址,分别指向 a、b、c。p本身是数组名,它的类型是int*[3],在表达式中退化为int**。p + 1跳过一个int*,也就是 8 个字节,指向数组的第二个元素。
如果你的数组在栈上,它的地址是连续的,但它指向的对象(a、b、c)不能保证连续。这是指针数组最容易理解的地方——它是一个“指针集合”,至于每个指针指向哪里,完全由你控制。
3.3 用 sizeof 和步长把两者彻底分开
我建议你自己写一段测试代码,把下面的结果打印出来,会非常直观:
#include <stdio.h> int main() { int arr[5] = {1, 2, 3, 4, 5}; int *pArr[5]; // 指针数组 int (*pToArr)[5]; // 数组指针 printf("sizeof(int *pArr[5]) = %zu\n", sizeof(pArr)); // 64位系统上通常是 40 printf("sizeof(int (*pToArr)[5]) = %zu\n", sizeof(pToArr)); // 通常是 8 pToArr = &arr; printf("pToArr = %p\n", (void*)pToArr); printf("pToArr + 1 = %p\n", (void*)(pToArr + 1)); // 地址相差 20 字节 return 0; }| 对比维度 | 数组指针int (*p)[5] | 指针数组int *p[5] |
|---|---|---|
| 本质 | 指针 | 数组 |
| 自身大小 | 8 字节(64 位系统) | 40 字节(5 × 8) |
p + 1的步长 | 跳过一整个数组(20 字节) | 跳过一个指针(8 字节) |
| 常见声明类型 | int (*)[5] | int**(退化后) |
| 访问方式 | (*p)[i]或p[0][i] | *p[i] |
我第一次把这段代码跑出来的时候,看到地址的差值是 20 而不是 4,瞬间就明白“步长”这个概念了。建议你也亲手跑一遍,印象绝对比读十遍博客深刻。
4. 初始化与访问:从代码层面彻底打通
概念和内存模型清楚了,接下来落实到具体写法。很多人的痛苦在于:就算知道了定义,也不知道怎么用。
4.1 数组指针的初始化与访问
数组指针最常见的玩法,是用于二维数组的行指针。考虑一个 3×4 的二维数组:
int matrix[3][4] = { {1, 2, 3, 4}, {5, 6, 7, 8}, {9, 10, 11, 12} }; int (*row)[4] = matrix; // matrix 退化为 int(*)[4],指向第一行这里row就是一个数组指针,指向“长度为 4 的 int 数组”。row + 1会跳到第二行,也就是指向 matrix[1]。访问某个元素可以写:
printf("%d\n", (*row)[2]); // 第一行的第三个元素,输出 3 printf("%d\n", row[1][2]); // 第二行的第三个元素,输出 7很多人会觉得row[1][2]这种写法不像指针。但在 C 语言里,row[1]本质上是*(row + 1),它表示第二行数组整体,再[2]就是取第二行下标为 2 的元素。如果你把row理解成一个“行指针”,这个写法的逻辑其实非常自然。
注意,一维数组用数组指针时,访问要稍微小心。比如:
int arr[5] = {1, 2, 3, 4, 5}; int (*p)[5] = &arr; printf("%d\n", (*p)[2]); // 输出 3,等价于 arr[2] printf("%d\n", p[0][2]); // 也输出 3这里用p[1]就会越界,因为p + 1已经跳过整个数组了。所以数组指针更适合用在“二维及以上的数组”场景;如果只是指向一维数组,直接int *p = arr更合适,别为了炫技用数组指针。
4.2 指针数组的初始化与访问
指针数组最常见的场景是存储多个字符串(或者说存储多个字符串的首地址):
const char *week[] = {"Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"};这个week就是一个指针数组,每个元素是const char*,分别指向字符串常量的首字符。访问方式:
printf("%s\n", week[0]); // Monday printf("%c\n", week[0][0]); // M因为week[0]是字符串首地址,week[0][0]就是首字符。这种写法的好处是每个字符串可以长度不同,内存利用灵活。
如果你用char week[7][10]这种二维字符数组存储,虽然也能实现类似效果,但每一行必须预留足够空间,比如最长的 “Wednesday” 是 9 个字符外加结尾符,就需要 10 字节,其他行多出来的空间就浪费了。指针数组则完全不浪费,每个指针指向各自的字符串常量,缺点是字符串常量通常不可修改。想修改的话,要换成char week[][10]这种数组,或者给每个字符串分配可写的内存。
另一个经典场景是命令行参数:
int main(int argc, char *argv[])argv就是一个char*数组,每个元素指向一个命令行参数字符串。argv[0]通常是程序名,argv[1]是第一个参数,依此类推。这是指针数组在系统层面的标准应用,几乎每个 C 程序都会接触。
4.3 二维数组传参的写法
数组指针的一个重要实战价值,是作为二维数组传参时的形参类型。
假如你写了一个函数,要接收一个 3 行 4 列的二维数组,形参应该怎么写?正确写法有两种等价形式:
void process(int (*p)[4], int rows) { for (int i = 0; i < rows; i++) { for (int j = 0; j < 4; j++) { printf("%d ", p[i][j]); } printf("\n"); } } // 调用 process(matrix, 3);或者写成:
void process(int p[][4], int rows)注意,int p[][4]在编译器眼里会自动调整为int (*p)[4]。这两种写法完全等价。很多人把二维数组形参写成int **p,这是错的。原因很简单:二维数组在内存里是连续的一整块,而int**指的是“指向指针的指针”,它期望的目标内存里存的是指针,不是普通整数。强行用int**接收二维数组后,你的访问会直接变成“把数组里的数值当作地址去解引用”,轻则打印乱码,重则段错误。
所以在面试里,考官让你写一个二维数组遍历函数,你直接写出int (*p)[4]或int p[][4],这本身就是加分项。
5. 典型应用场景:你在真实项目里怎么用它们
概念讲完了,来看看两组概念的真正用武之地。说实话,不是为了炫技,而是实际项目里真的绕不开。
5.1 指针数组的典型应用
指针数组最大的价值,在于将“数据实体”和“访问顺序”解耦。当你有一批类型相同的数据,但它们不连续存放,或者你希望通过索引的方式来统一管理它们时,指针数组就派上用场了。
最常见的例子是错误信息表或命令分发表。比如写一个简单的 FTP 服务器,需要把错误码映射到错误描述:
const char *err_msg[] = { [0] = "Success", [1] = "Invalid command", [2] = "Access denied", [3] = "File not found", };这样err_msg[error_code]就能直接拿到对应的字符串,不用写一大串 switch-case。这就是指针数组存放字符串的经典用法。
再来看函数指针数组。在 C 语言里,函数名本身是个地址,所以也能放进数组。比如实现一个简单的计算器:
int add(int a, int b) { return a + b; } int sub(int a, int b) { return a - b; } int mul(int a, int b) { return a * b; } int (*op[])(int, int) = {add, sub, mul}; // 调用 int result = op[0](10, 5); // add(10, 5) = 15这个op数组的元素类型是函数指针int (*)(int, int),它本质上是“指针数组”这个大家族里的一员,只不过元素不是数据指针,而是函数指针。很多状态机、命令解释器、插件系统都是这么干的——用一个表格把“操作码”映射到“函数”。
5.2 数组指针的典型应用
数组指针的核心价值,在于以“行”为单位操作多维数组。写图像处理、矩阵运算、卷积算法的时候,你拿到一个二维数组,最干净的遍历方式就是通过数组指针。
比如实现一个 3×3 矩阵的转置:
void transpose(int (*src)[3], int (*dst)[3], int rows) { for (int i = 0; i < rows; i++) { for (int j = 0; j < 3; j++) { dst[j][i] = src[i][j]; } } }调用时:
int a[3][3] = {{1,2,3},{4,5,6},{7,8,9}}; int b[3][3]; transpose(a, b, 3);这里src和dst都是数组指针。试想,如果不用数组指针,你还能怎么写?用int *p = &a[0][0]然后手动算偏移,也可以,但代码可读性会差很多,而且行宽一变就得全部重写。数组指针把“行”这个语义直接固化在类型系统里,编译器能替你检查行宽是否匹配,这本身就是一种防护。
另外,当函数需要接收“指向数组的指针”时,数组指针也是唯一正确的写法,尤其是三维数组传参:
void process3d(int (*cube)[4][5], int depth) { // cube 是指向 int[4][5] 数组的指针 }如果你直接在函数里用一堆arr[x][y][z]来访问三维数组,形参写成int cube[][4][5],编译器也是把它调整成int (*cube)[4][5]。掌握数组指针,本质上就是掌握了多维数组在 C 语言里的类型语言。
6. 快慢指针扩展:有序数组原地去重中的指针思想
最近有个很热的话题,“JS 快慢指针有序数组原地去重”,其实这个题目不只是 JavaScript 专属,它在 C 语言里也一样经典。它的核心不是“指针数组”或“数组指针”这种声明技巧,而是“用指针操作数组下标”的思想——这正好是我们前面所有分析的一个延伸场景。
题目是这样的:给你一个有序数组,要求原地删除重复出现的元素,使得每个元素只出现一次,并返回新的长度。不能使用额外的数组空间,意思是你必须修改原数组。
思路用两个指针,一快一慢:
- 慢指针 slow 指向“已经处理好的不重复序列的最后一位”,初始为 0。
- 快指针 fast 从 1 开始遍历整个数组,每次发现
nums[fast] != nums[slow],就把 slow 向前移动一位,并把nums[fast]写到nums[slow]位置。
JS 版本:
function removeDuplicates(nums) { if (nums.length === 0) return 0; let slow = 0; for (let fast = 1; fast < nums.length; fast++) { if (nums[fast] !== nums[slow]) { slow++; nums[slow] = nums[fast]; } } return slow + 1; }C 语言版本:
int removeDuplicates(int* nums, int numsSize) { if (numsSize == 0) return 0; int slow = 0; for (int fast = 1; fast < numsSize; fast++) { if (nums[fast] != nums[slow]) { slow++; nums[slow] = nums[fast]; } } return slow + 1; }来走一遍,假设数组是{0, 0, 1, 1, 1, 2, 2, 3}:
- slow = 0,fast = 1,
nums[1] == nums[0],跳过。 - fast = 2,
nums[2] = 1 != nums[0] = 0,slow 变为 1,nums[1] = 1。 - fast = 3,
nums[3] = 1 == nums[1] = 1,跳过。 - fast = 4,
nums[4] = 1 == nums[1] = 1,跳过。 - fast = 5,
nums[5] = 2 != nums[1] = 1,slow 变为 2,nums[2] = 2。 - fast = 6,
nums[6] = 2 == nums[2] = 2,跳过。 - fast = 7,
nums[7] = 3 != nums[2] = 2,slow 变为 3,nums[3] = 3。
最终数组变成{0, 1, 2, 3, 2, 2, 2, 3},前 4 个元素就是去重后的结果,返回长度 4。慢指针 slow 记录的是“新数组的写入位置”,快指针 fast 只负责往前探路,整个复杂度只遍历一遍数组,时间 O(n),额外空间 O(1)。
这个场景里,nums作为函数参数其实已经退化为指针了。所以你会发现,这道算法题的本质,就是在一个指针(数组首地址)之上,通过两个游标(下标)完成局部写入。它虽然没有直接使用“指针数组”或“数组指针”的声明,但它要求的底层能力,恰恰是理解“数组作为指针传递时,下标运算和解引用是同一件事”——nums[slow]本质上就是*(nums + slow)。先把数组指针那一套步长、退化规则搞明白,写这道题的时候你才会真正理解为什么slow++之后写入是生效的。
如果你的项目中想要更通用一点,把去重逻辑封装成“删除所有重复项”或者“保留指定次数”,模板化处理会更复杂一些,但核心仍然是快慢指针这个思路。而且这套思路在链表里也有对应版本,算是“双指针技巧”最基础的一种,非常值得熟练掌握。
7. 高频踩坑与自查清单
最后把这几年我在代码评审和面试里见到的错误集中过一遍。有些坑看着很低级,但实际项目里真的很常见。
7.1 常见错误速查表
| 错误写法 | 问题所在 | 正确理解 |
|---|---|---|
int *p[5]当作数组指针初始化 | p 实际上是指针数组,不是你要的指针 | 要数组指针必须写int (*p)[5] |
int (*p)[5] = arr; | arr 退化为int*,类型不匹配 | 应该写int (*p)[5] = &arr; |
二维数组传参写int **p | 类型不兼容,访问会炸 | 写int (*p)[N]或int p[][N] |
p + 1期望跳过一个元素 | 数组指针步长是整个数组 | 先想清楚指针类型再算步长 |
| 修改字符串常量 | char *s = "abc"; s[0]='x';运行时崩溃 | 用char s[] = "abc";再改 |
混淆arr和&arr | 两者值相同但类型不同,运算结果不同 | arr+1跳一个元素,&arr+1跳整个数组 |
7.2 面试实战对照
如果把这两个概念放到面试场景里,通常会被这样问:
int a[5];那么a和&a有什么区别?a的类型是int*(退化后),&a的类型是int (*)[5]。它们打印出来的地址可能相同,但a+1和&a+1的地址差值不一样。int (*p)[5]和int *p[5]的sizeof分别是多少?在 64 位系统下前者是 8,后者是 40。- 写一个函数,接收任意行列的二维数组,遍历打印所有元素。那么你的形参必须是数组指针,或者行数、列数之一必须是常量。
char *argv[]到底是什么?它是字符指针数组,每个元素指向一个命令行参数。
7.3 理解技巧总结
最后分享一个我自己的记忆方法。每次看到一个复杂的指针声明,先找出变量名,然后看它优先跟[]结合还是跟*结合。跟[]结合,它就是数组;跟*结合,它就是指针。这个“谁离变量名近,谁就决定本质”的原则,可以适用到绝大多数指针声明。
另外,不要被名字骗了。“数组指针”的重点是“指针”,“指针数组”的重点是“数组”。中文靠“的”字位置区分,C 语言靠括号区分,本质是一个道理。
如果还觉得抽象,就写段代码打印地址和步长,用printf把每个表达式的地址差值打出来。我学这个知识点时,就是因为亲眼看到地址跳了 20 个字节,才彻底记住数组指针的步长是整个数组。实践出真知,这句话在 C 语言里永远成立。