先问一个问题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 × 8p 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 0fast 1nums[1] nums[0]跳过。fast 2nums[2] 1 ! nums[0] 0slow 变为 1nums[1] 1。fast 3nums[3] 1 nums[1] 1跳过。fast 4nums[4] 1 nums[1] 1跳过。fast 5nums[5] 2 ! nums[1] 1slow 变为 2nums[2] 2。fast 6nums[6] 2 nums[2] 2跳过。fast 7nums[7] 3 ! nums[2] 2slow 变为 3nums[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两者值相同但类型不同运算结果不同arr1跳一个元素arr1跳整个数组7.2 面试实战对照如果把这两个概念放到面试场景里通常会被这样问int a[5];那么a和a有什么区别a的类型是int*退化后a的类型是int (*)[5]。它们打印出来的地址可能相同但a1和a1的地址差值不一样。int (*p)[5]和int *p[5]的sizeof分别是多少在 64 位系统下前者是 8后者是 40。写一个函数接收任意行列的二维数组遍历打印所有元素。那么你的形参必须是数组指针或者行数、列数之一必须是常量。char *argv[]到底是什么它是字符指针数组每个元素指向一个命令行参数。7.3 理解技巧总结最后分享一个我自己的记忆方法。每次看到一个复杂的指针声明先找出变量名然后看它优先跟[]结合还是跟*结合。跟[]结合它就是数组跟*结合它就是指针。这个“谁离变量名近谁就决定本质”的原则可以适用到绝大多数指针声明。另外不要被名字骗了。“数组指针”的重点是“指针”“指针数组”的重点是“数组”。中文靠“的”字位置区分C 语言靠括号区分本质是一个道理。如果还觉得抽象就写段代码打印地址和步长用printf把每个表达式的地址差值打出来。我学这个知识点时就是因为亲眼看到地址跳了 20 个字节才彻底记住数组指针的步长是整个数组。实践出真知这句话在 C 语言里永远成立。