
1. 数组和指针一对总被误解的“双胞胎”1.1 数组名不是指针但为什么大家都这么说先问一个基础问题数组和指针是一回事吗答案是不但很多人学完依然分不清。原因很现实——在绝大多数使用场景里数组名和指针变量确实可以“互换着用”。比如声明int a[5]之后a[i]和*(ai)完全等价传给函数时int arr[]和int *arr也几乎没差别。正因这种高频出现的等价性初学者很容易得出“数组名就是指针”的错误结论。但结论是错的。数组名是一个标识符它代表一整块连续内存类型是“长度为N的数组”比如int [5]。指针是一个变量它存储某个内存地址类型是“指向int的指针”比如int *。两者的本质区别看sizeof就一目了然#include stdio.h int main(void) { int a[5] {1, 2, 3, 4, 5}; int *p a; printf(sizeof(a) %zu\n, sizeof(a)); // 20 (5 * 4) printf(sizeof(p) %zu\n, sizeof(p)); // 8 (64位系统上指针占8字节) return 0; }sizeof(a)返回整个数组占用的字节数sizeof(p)返回指针本身占用的字节数。同一块内存从“数组”的角度看是20字节从“指针”的角度看是8字节。这说明数组名带的“数组类型”信息并没丢它不是指针变量的别名。那为什么几乎所有地方都可以互换因为C语言设计了一条极其重要的规则在表达式中数组名会退化为指向首元素的指针。这条规则叫“数组退化”array decay。int a[5]在参与运算时a会被转换成a[0]类型变为int *。于是a[i]、*(ai)、p[i]全都对上了。真正让人晕的是退化不是绝对的有三个场景数组名不退化这也成了面试和笔试的高频考点。1.2 三个不退化的关键时刻sizeof、取地址、字符串字面量第一个不退化场景就是刚说的sizeof(a)。这里a保留完整的数组类型所以能算出总字节数。第二个场景是a。a的类型不是int *而是int (*)[5]即“指向长度为5的int数组的指针”。从数值上看a和a地址相同但类型完全不同直接导致a 1和a 1的移动步长不一样。看这个例子#include stdio.h int main(void) { int a[5] {1, 2, 3, 4, 5}; printf(a %p\n, (void*)a); printf(a 1 %p\n, (void*)(a 1)); printf(a %p\n, (void*)a); printf(a1 %p\n, (void*)(a 1)); return 0; }假设数组首地址是0x1000那么a 1是0x1004因为a退化为int *步长是4字节而a 1是0x1014因为它的类型是“指向int[5]的指针”步长整整20字节。很多人用a 1去往后跳“一个元素”结果直接跳出了整个数组这是典型的踩坑点。第三个场景是字符数组初始化。char *s hello和char s[] hello看起来都是字符串但机制完全不同前者是指针指向只读字符串字面量字符串存在静态常量区s[0] H在多数平台上是未定义行为崩溃或静默失败后者是数组在栈上拷贝了一份字符串可以安全修改。一个“可改”、一个“不可改”写代码前必须分清楚。从这三个不退化场景能提炼出什么**退化发生在“需要值”的上下文里而发生在“需要类型身份”的上下文里时数组名就保留原型。**理解这条底层逻辑比死记sizeof和两个特例更有用。1.3 数组名和指针在“赋值”上的根本不同数组名不能自增、不能自减、不能被重新赋值因为它是“常量地址”而非“指针变量”。a编译不过a p也编译不过但p a、p都是合法操作。这一点也解释了为什么函数内部无法通过arr修改“数组本身”的指向——参数arr虽然看起来是数组实际已经退化成指针它可以自增但改变的是指针变量本身调用方的数组不受影响。写一段代码验证void change(int arr[]) { arr[0] 99; // 可以修改数组元素 arr; // 可以因为arr在这个函数里就是指针变量 } int main(void) { int a[3] {1, 2, 3}; change(a); // a[0] 变成 99 return 0; }change的参数看似int arr[]实际上编译器和int *arr完全一致。所以“数组作为参数传递时函数内不知道数组长度”这个经典问题出现了——sizeof(arr)在函数内算出来是8不是12。怎么解决没有魔法标准做法是额外传一个长度参数或者用结构体封装数组和长度再或者C里直接用std::array/std::vector。把这种关系用一个表格记住复习时一目了然场景数组名指针变量存储本质一段连续内存的标识符保存地址的变量sizeof数组总字节数指针本身字节数参与运算自动退化为首元素指针直接使用是否可自增否是可重新赋值否是作为函数参数退化为指针本身就是指针2. 一维数组的指针视角从下标到地址的换算2.1a[i]的本质是*(a i)很多教材说“数组下标就是指针偏移的语法糖”这句话至少帮我们看透一件事a[i]这个写法在编译器眼里就是*(a i)。它先把a退化成首元素地址再按指针算术偏移i个元素最后解引用。于是可以出现一些违反直觉但完全合法的写法比如i[a]。既然a[i]等价于*(a i)而加法满足交换律所以*(a i)等价于*(i a)也就是i[a]。我自己在给团队做培训时经常写2[a]来考大家每次都能看到有人愣住——但道理说透了这根本不是奇技淫巧只是语法糖的必然结果。这个等价关系也有很多实际意义。当你在写代码时想表达“从第 i 个位置开始”指针写法往往比下标写法更直白int sum_from(int *start, int count) { int sum 0; for (int i 0; i count; i) { sum *(start i); } return sum; }调用时传a或者a 2都行数组退化为指针后完全统一。这种写法也能让你意识到其实没有“特殊的数组下标运算”所有下标都是指针算术理解了这一点后面学迭代器、学STL的随机访问迭代器都会顺畅很多。2.2 指针加减运算的“步长”是类型决定的指针加1到底加几个字节答案取决于指针指向的类型。int *p加1跳过4字节double *p加1跳过8字节struct Large *p加1跳过整个结构体大小。这就是指针的“步长”概念严格定义是指针加 n等于地址值加n * sizeof(指向类型)。用代码体感一下#include stdio.h int main(void) { int a[4] {10, 20, 30, 40}; int *p a; printf(%d\n, *p); // 10 printf(%d\n, *(p 1)); // 20 printf(%zu\n, (char*)(p 1) - (char*)p); // 4 return 0; }把p 1和p都转成char *再相减得到4证明步长是4字节。实际工程里这种步长特性经常被用来遍历结构体数组也经常因为“类型没对上”产生隐蔽bug。常见错误是想遍历char数据却用了int *结果一次跳4字节漏掉3/4的字节或者反过来用char *遍历int数组一次跳1字节读出来的数据不对。避免问题的核心原则是指针的类型必须和它指向的对象的类型匹配除非你有充分的理由做强制转换。2.3 数组退化的副作用函数内算不出数组长度有经验的C程序员都遇到过这个场景写了一个sort_array(int arr[])想在函数里用sizeof(arr) / sizeof(arr[0])算长度结果排序只处理了部分数据或者干脆越界。原因就是参数退化成指针sizeof(arr)是8不是整个数组大小。一个直观的现场还原#include stdio.h void print_length(int arr[]) { printf(inside function: %zu\n, sizeof(arr) / sizeof(arr[0])); } int main(void) { int arr[10]; printf(outside function: %zu\n, sizeof(arr) / sizeof(arr[0])); print_length(arr); return 0; }输出outside function: 10 inside function: 2在64位机器上sizeof(arr)是8除以4等于2看起来就像数组长度是2实则是两个指针的错觉。这个坑几乎每个C语言学习者都踩过。解决办法没有黑魔法要么传入长度参数要么用宏要么在C里用模板推导数组长度template size_t N void print_length(const int (arr)[N]) { std::cout N std::endl; }这里const int (arr)[N]是“数组的引用”参数不会退化所以能推导出真正的长度。从C的角度看这是最干净的写法也是复习时值得留意的对比C语言靠传长度C可以用引用保长度。3. 二维数组与指针最容易绕晕的迷宫3.1 二维数组的内存布局其实是一维的很多人初学二维数组时会画成一张“表格”行和列都清清楚楚。这个模型对理解下标有帮助但若照搬到内存模型上就容易误解——以为二维数组在内存里也是“按行分块、块内又连续”。实际上C语言的二维数组在内存中完全是按行优先连续排列的一维线性空间。比如int matrix[3][4]本质就是连续12个int排列顺序是第0行的4个、第1行的4个、第2行的4个。我们叫它“数组的数组”更准确matrix是3个元素组成的数组每个元素是int[4]类型的数组。这个布局解释了很多现象matrix在表达式中退化为int (*)[4]类型即“指向长度为4的int数组的指针”而不是int **。matrix 1跳过一整行也就是4个int、16字节。*(matrix 1) 2指向第1行第2列的元素*(*(matrix 1) 2)取出值。访问越界时比如matrix[2][5]很可能落到第3行之后的无关数据或崩溃因为实际是把指针算到了第2行偏移5个int的位置。用一张表记录几级解引用的含义会很清晰表达式类型含义matrixint (*)[4]指向第0行一个长度为4的数组matrix 1int (*)[4]指向第1行*(matrix 1)int *第1行首元素地址*(matrix 1) 2int *第1行第2个元素地址*(*(matrix 1) 2)int第1行第2个元素值3.2int (*p)[N]与int *p[N]到底谁是指针谁是数组这是C语言里出了名的“左右之辩”。区别其实只在于运算符优先级。[]的优先级高于*int *p[N]p先和[N]结合说明p是数组数组元素是int *所以叫指针数组——一个数组里面存指针。int (*p)[N]括号让*先和p结合说明p是指针指向一个int[N]数组所以叫数组指针——一个指针指向数组。理解这个区分在实际代码里能避免很多编译告警。比如想用指针遍历二维数组应该这样int matrix[3][4] {{1, 2, 3, 4}, {5, 6, 7, 8}, {9, 10, 11, 12}}; int (*row)[4] matrix; // 指向一行的指针 for (int i 0; i 3; i) { for (int j 0; j 4; j) { printf(%d , row[i][j]); // 或 *(*(row i) j) } }但如果错误声明成int *row[4]编译器立刻报类型不匹配因为int *[4]是“4个int指针组成的数组”和int (*)[4]毫无关系。这个类型系统虽然繁琐但强制你明确每一层的含义一旦想清楚反而不容易错。3.3 二级指针不等于二维数组一个常见误区用int **pp去指向int matrix[3][4]觉得“二级指针嘛就是指向指针的指针二维数组不就是指针的数组吗”——这是错误的。int **指向的对象是int *也就是一个指向int的指针变量而二维数组名退化后的类型是int (*)[4]两者底层布局完全不同。为什么很多人混淆因为动态分配二维数组时确实会用到int **int **m malloc(rows * sizeof(*m)); for (int i 0; i rows; i) { m[i] malloc(cols * sizeof(**m)); }这种“指针数组 每行单独分配”的结构看起来像矩阵但每行内存并不连续行与行的地址也不一定是相邻的。它和真正的二维数组在内存布局上一个“每行独立分配、整体不连续”一个“全部连续”。这个差异直接影响性能——连续版本对缓存友好遍历速度快分散版本可能每次切行都cache miss。如果想让动态分配的二维数组也连续可以这样int *m malloc(rows * cols * sizeof(*m)); // 访问第i行第j列用 m[i * cols j]用一个int *模拟二维下标牺牲一点可读性换来连续内存和高缓存命中率。这种写法在图像处理、矩阵运算里非常常见复习数组和指针时值得留意。4. 指针的高级形态指针数组、函数指针与指针的指针4.1 指针数组的实际用途字符串管理和表驱动指针数组最经典的应用是字符串管理。C语言没有真正的字符串类型字符串只能用字符数组或字符指针表达。当你想管理多个字符串时如果都用二维字符数组列数必须固定为最长字符串的长度既浪费空间又僵化。用指针数组则灵活得多const char *skill_names[] { C, C, Python, JavaScript, SQL };每个元素是const char *指向一个字符串字面量长度各不相同互不干扰。按字典序排序时只需要交换指针不需要搬动字符串数据成本低得多。这也直接解决了热搜里“c语言排序数组”和“指针数组存放字符串”的一个很现实的场景数据量一大搬数据代价高换指针便宜。另一个场景是表驱动编程。用指针数组把函数或配置集中管理typedef struct { const char *name; int (*handler)(int, int); } Command; Command commands[] { {add, add_handler}, {sub, sub_handler}, {mul, mul_handler}, };查表时通过名字找到对应处理函数避免一长串if-else。这种写法在嵌入式命令解析、解释器、测试框架里很常见是数组、指针、函数指针结合起来的典型用法。4.2 函数指针把函数当数据用函数指针可能是“复习系列”里最容易被跳过、实战中又特别重要的一环。它的声明读法也用到那个顺序规则先括号、再解引用、最后参数列表。int (*func_ptr)(int, int);func_ptr先和*结合是指针接着是参数列表(int, int)表示它指向的函数接收两个int最前面int是返回类型。读法是func_ptr是指向“接收两个int、返回int的函数”的指针。用函数指针干什么最常用的是回调。比如C标准库的qsort#include stdlib.h #include stdio.h int compare_int(const void *a, const void *b) { int x *(const int *)a; int y *(const int *)b; return (x y) - (x y); } int main(void) { int arr[] {5, 2, 9, 1, 7}; size_t n sizeof(arr) / sizeof(arr[0]); qsort(arr, n, sizeof(int), compare_int); for (size_t i 0; i n; i) { printf(%d , arr[i]); } return 0; }qsort不认识int它只知道每个元素占几个字节排序规则由调用方通过函数指针传入。这种“算法与数据解耦”的思路在C语言里全靠函数指针实现。理解了函数指针再看面向对象里的回调、事件驱动、策略模式都会觉得似曾相识。4.3 指针的指针到底解决什么问题int **pp最常见的含义是“指向指针的指针”。它的应用场景主要有两类一是字符串数组就是前面提到的argvchar **argv二是“在函数里修改外部指针变量”。第二类特别值得展开。假设你想让函数给一个指针重新分配内存如果传的是指针本身函数内部修改的是保护性拷贝外部指针不会变#include stdlib.h void bad_alloc(int *p) { p malloc(10 * sizeof(int)); // 只改了副本 } void good_alloc(int **p) { *p malloc(10 * sizeof(int)); // 改的是外部指针变量 } int main(void) { int *p NULL; bad_alloc(p); // p还是NULL good_alloc(p); // p现在指向分配的内存 free(p); return 0; }看到区别了吗bad_alloc传的是p的值函数内新建了一个副本给副本赋值p本人毫无变化good_alloc传的是p的地址函数通过二级指针修改*p也就是修改了调用方的p变量本身。这个模式在链表插入头节点、树插入节点时特别常用如果不理解头插法很容易写出“链断了”或“函数返回后节点丢失”的bug。5. 数组和指针在实际项目中的典型应用5.1 字符串数组的排序地址交换和数据搬运的区别热搜词里“c语言排序数组”出现频率很高。基础版是排int进阶版就是排字符串数组。排字符串数组时如果用二维字符数组排序交换的是整行字符内存拷贝量随字符串长度增长如果用字符指针数组排序只需交换指针成本固定为sizeof(char *)。方便理解看两段思路对比// 易错版二维字符数组排序 char names[][16] {banana, apple, cherry}; // 交换时需要 char tmp[16]; strcpy(tmp, names[i]) ... // 字符串越长交换越慢 // 推荐版指针数组排序 const char *name_ptrs[] {banana, apple, cherry}; // 交换时只需要 const char *tmp name_ptrs[i];前者适合固定长度、少量数据后者适合不定长字符串、数据量较大的情况。另外对qsort排字符串数组时比较函数的参数类型是const void *但指向的元素类型是char *所以比较函数内要做两层转换先把void *转成char **再解引用拿char *字符串最后套strcmp。这个细节特别容易错很多人的排序结果不对就是卡在这一层。5.2 数组参数传递的三种姿态数组作为参数在C/C里本质就是传地址所以函数内可以修改实参数组元素。但“传整个数组”的方式有几种整理下来便于对照退化成指针void f(int arr[], int n)最常用配合长度参数。数组引用Ctemplatesize_t N void f(int (arr)[N])保留长度适合模板场景。结构体封装把数组和长度包进结构体传递起来更像“一个对象”。第三种在C语言里很常见也是后来C封装出std::vector的原型思路typedef struct { int *data; size_t length; } IntArray; void init_array(IntArray *arr, size_t len) { arr-data malloc(len * sizeof(int)); arr-length len; }这种“数据 元数据”的封装方式让你不再一边传数组一边传长度函数签名清晰得多。排查空指针、越界时也更容易统一检查。5.3 快慢指针在原地去重中的妙用热搜里有“js快慢指针有序数组原地去重”其实这个思路在C/C里同样经典。给定一个升序数组要求原地去重且不额外分配大块内存用快慢指针一次遍历就能搞定int remove_duplicates(int *nums, int numsSize) { if (numsSize 0) return 0; int slow 1; for (int fast 1; fast numsSize; fast) { if (nums[fast] ! nums[slow - 1]) { nums[slow] nums[fast]; slow; } } return slow; }核心思想是fast负责扫描把不重复的元素往前搬slow指向下一个不重复元素应放的位置。这个技巧底层用的还是数组下标和指针偏移的等价性说明“数组理解了指针算法才能玩得转”。再看两个热搜词“数组去重”和“所有n-1条最短路径可以用二维数组path”。前者是工程常见题后者是图论算法里二维数组存路径的经典案例。这些热词共同指向一件事数组和指针不是孤立知识点而是所有数据结构算法的地基。链表里每个节点的next本质上就是个指针语义的数组链接图论里用二维数组存邻接矩阵本质是用连续内存表达关系。5.4 数组转字符串、字符串数组参数的野路子与正经做法“数组转字符串”这个热搜往往出现在C/C新手群里。C语言没有内置这个操作最常见的办法是循环加sprintf或者用snprintf做格式化int numbers[] {1, 2, 3, 4}; char buf[64] {0}; size_t offset 0; for (size_t i 0; i 4; i) { int written snprintf(buf offset, sizeof(buf) - offset, %d%c, numbers[i], i 3 ? \0 : ,); if (written 0) break; offset written; }注意snprintf返回的是“应写入的字符数”不是实际写入数缓冲区快满时要看返回值和剩余空间否则会截断。用指针往后移动再拼字符串是C风格字符串处理的基本功也是一种“指针即游标”的实践。“c语言字符串数组怎么定义”也是高频问题。两种方式务必分清char a[][8]二维字符数组每行固定8字节可修改内容。char *a[]字符指针数组可以指向不同的字符串灵活但要注意字面量只读问题。同样声明成char *是“指向只读字面量”还是“指向可写数组”取决于初始化方式这个坑我在面试里反复问过仍然很多人中招。6. 现代C还需要学原生数组和指针吗6.1 std::array、std::vector 与原生数组的取舍很多新手学完C语言的数组进了C就听说“用std::vector别用原生数组”于是彻底放弃理解数组。这个说法大方向没错但不该变成“直接跳过基础”。恰恰相反只有理解了原生数组的内存布局才知道std::vector为什么在动态扩容时要搬数据、为什么std::array能保留栈上固定大小、为什么std::vectorint的data()方法返回的指针可以兼容C接口。取舍标准大致可以这样看需求推荐固定大小、高性能、栈上分配std::array需要动态增长std::vector与C接口交互、底层内存控制原生数组或data()需要编译期长度推导std::array配合模板#include array #include vector void process(const int *data, size_t n); int main() { std::arrayint, 4 a {1, 2, 3, 4}; std::vectorint v {1, 2, 3, 4}; process(a.data(), a.size()); process(v.data(), v.size()); return 0; }data()返回的就是指向底层数组首元素的裸指针这也再次说明不管外面包了多少层底层仍然是数组和指针的模型。6.2 智能指针是否意味着可以放弃裸指针std::unique_ptr、std::shared_ptr、std::weak_ptr的出现确实让很多以前用裸指针管理生命周期的问题得到解决。但“放弃裸指针”不等于“放弃理解指针”。智能指针的get()返回裸指针operator-和operator*也模拟裸指针语义。如果你不理解指针的本质用智能指针也容易出错比如auto p std::make_sharedint(42); int *raw p.get(); delete raw; // 错误会导致双重delete或悬垂另一个典型问题是用shared_ptr管理数组时默认删除器会调用delete而非delete[]除非显式指定std::shared_ptrint sp(new int[10], [](int *p) { delete[] p; });这类细节都要求你对“指针指向的对象类型”“所有权归谁”“释放方式是什么”有清晰认知。所以我的看法是现代C允许你少写裸指针但绝不意味着可以不懂它。6.3 复习阶段最该做的一次“指针自检”复习数组和指针最高效的方式不是一遍遍看书而是自测几个“看起来简单、实际上容易翻车”的问题。我把它们列在下面如果你都能答对并且能解释清楚这部分基本过关了int a[5];中a与a类型分别是什么sizeof(a)和sizeof(a[0])的结果一样吗函数参数写成int arr[]和int *arr有区别吗为什么int *p[3]和int (*p)[3]有什么区别声明时怎么读malloc返回void *为什么C语言中可以直接赋给int *C却不行char *s hello和char s[] hello修改s[0]会有什么不同qsort的比较函数为什么参数是const void *和函数指针是什么关系int **pp能直接指向二维数组吗如果不能为什么这些问题覆盖了退化、步长、优先级、类型系统、函数指针、字符串字面量等核心考点。能把每个问题用代码验证一遍比囫囵吞枣刷十道题有效。我在给团队做Code Review时发现很多线上bug表面上是逻辑问题挖到底往往是某个数组越界、某个指针类型写错、某个字符串字面量被误修改。数组和指针这两个话题入门时觉得“不过如此”工作越久越觉得它们才是C/C项目里最需要敬畏的地方内存布局决定了性能类型系统决定了正确性而这两者恰好都压在数组和指针身上。把这部分复习扎实后面再去看链表、树、图、动态规划、STL源码都会轻松一大截。