C++数组指针字符串:从内存视角彻底搞懂核心难点

发布时间:2026/10/13 11:01:03
C++数组指针字符串:从内存视角彻底搞懂核心难点
我知道很多人第一次翻开郑莉这本《C语言程序设计》到第六章数组、指针和字符串就开始心里打鼓。说实话我当年也是这样前面的流程控制、函数调用照着抄总能写出来可数组和指针一上来什么地址解引用越界越想越玄。但后来我自己教这门课教了好几年可以负责任地告诉你这一章是整本书最重要的分水岭。数据结构的基础是数组C底层操作的灵魂是指针而字符串在C风格时代就是数组加指针的合体。你把这章啃下来后面学函数重载、学类、学STL都会顺畅很多。这篇文章我会尽量少讲教科书套话更多讲那些书上一笔带过、但考试和工程里反复遇到的细节——比如为什么数组名不是指针、为什么两数交换必须传指针、指针数组和数组指针到底怎么区分、C风格字符串的坑在哪。适合两类人看一是刚学完函数、正被传值还是传址折磨的初学者二是学过C但没系统梳理过想在C里把概念重新校准一遍的读者。建议至少读两遍第一遍跟着主线走第二遍再回课本对照代码。1. 先别急着敲代码搞清楚这三样东西为什么被放在同一章我上课有个习惯讲这一章之前先问一个问题你们觉得字符串是什么很多人脱口而出字符串就是一串字符呗。然后我再问那这串字符存在哪你怎么把它交给一个函数台下就安静了。数组、指针、字符串之所以编在同一个章节是因为它们在底层是同一套东西数组是一片连续内存指针是这片内存的入口而字符串——尤其是在C风格时代——不过是一个以\0结尾的特殊字符数组。你搞懂了数组的内存布局指针就只是存着地址的变量你搞懂了指针的运算规则字符串的逆序、查找、分割本质上都是在跟地址较劲。1.1 这一章的知识密度为什么突然变大前面几章学的是数据类型运算符流程控制这些概念在几乎所有语言里都是共通的你只要记住语法就能写。但数组和指针不一样它们直接面对内存。你得开始习惯一套新的思维方式变量不只是有名字的值它还有一个地址函数参数传的不是数据本身而是数据的拷贝数组名在表达式里代表首元素地址。这种思维转变需要时间所以我不建议你一口气把整章吞下去。我一般会让学生分三轮第一轮理解主线知道概念是什么第二轮回课本逐行读代码搞清楚每一行为什么这么写第三轮独立做课后题卡住了再回来看。三轮下来这章才算真正过手。1.2 一个贯穿全章的调试观念会看内存从这一章开始我强烈建议你学会看内存。如果你用的是VSCode配的C/C调试插件或者Keil这类嵌入式IDE里头的监视窗口都能直接看变量的地址和值。比如你声明一个int a[5]在调试器里能看到这5个int的地址是连续的还能看到某个指针变量里存的那串十六进制数字就是地址本身。可视化地看到指针变量里存的是地址这件事比背十遍定义都有用。很多同学写指针代码像在碰运气就是因为脑子里没有地址这个画面。记住一句话指针变量和普通变量一样占内存只不过它里面装的内容恰好是另一个内存单元的编号。2. 数组先从内存视角看一维和二维2.1 一维数组的声明、初始化与默认值陷阱一维数组的声明人人都会int a[5];但这一行背后的信息量其实很大它告诉编译器请分配5个连续的int大小的内存块。至于这5个int里存的是什么在函数内局部声明的数组里面是随机垃圾值。这是新手第一坑——很多人以为没初始化就是0结果打出来的数神出鬼没。初始化方式我建议按优先级记int a[5] {0}; // 推荐第一个元素赋0其余自动补0 int a[5] {}; // C11起可用全部为0 int a[] {1, 2, 3}; // 由初始化列表推导长度为3int a[5] {0}在面试里是一个高频考点它的真正含义是第一个元素初始化成0后面的元素值初始化value-initialization为0结果上是整个数组清零但原理不是很多人以为的编译器会自动清空。至于int a[5];不初始化局部变量就是垃圾值全局变量自动为0——这是C的既定规则建议当常识记住。调试时能帮你省很多时间我见过有人对着一个未初始化的局部数组排查了半天最后发现数组本身没毛病纯粹是没初始化。2.2 二维数组的本质它是数组的数组不是矩阵二维数组int b[3][4]不少人是按3行4列的矩阵来理解的这没错但对底层存储的理解容易出偏差。真相是b是一个长度为3的数组每个元素又是一个长度为4的int数组。所以在内存里它是一段连续的12个int按行优先排列——先存完第0行的4个再存第1行的4个。int b[3][4] {0}; // 内存布局: b[0][0], b[0][1], b[0][2], b[0][3], b[1][0], ..., b[2][3]这个行优先特性有不少实战用处。比如初始化二维数组可以取首元素地址然后用一维的方式暴力填充int b[3][4]; int* p b[0][0]; // 取首元素地址 for (int i 0; i 12; i) p[i] i;这样写合法因为二维数组的内存是连续的。当然实际工程里我更推荐用std::vectorstd::vectorint但教材和考试场景下你必须知道b[3][4]的真实内存布局因为后面学数组指针、多维数组传参全都建立在这个认知上。热词里有人搜c 多维数组 指针根子就在二维数组是数组的数组这一句话上。2.3 数组越界不报错但会出灵异事件数组越界是个特别阴的坑。别的语言Java、Python越界会抛异常C不会。你写int a[5]; a[5] 100;编译器不报错运行大概率也不崩但它写到了数组后面的内存里——如果那块内存恰好是另一个变量的地盘你的程序就开始灵异起来了。我在实际调试中遇到过一个学生循环里写成了i 10数组长度10结果第11次赋值把栈上紧挨着的另一个变量覆盖了。程序表现是函数返回值莫名其妙变成负数查了半小时才定位到越界。所以关于数组边界我只说两点第一循环条件统一用i n不要用i n-1少一个思考负担第二长度需要动态变化的场景直接用std::vector别自己管理裸数组。这是现代C的基本建议也是踩过坑之后的经验之谈。3. 指针核心是搞清楚它存的是什么3.1 指针变量的本质与两个运算符的分工指针变量也是一个变量但它存的东西特殊——不是普通数值而是地址。打个比方普通变量是直接装货的仓库指针变量是抄着门牌号的小纸条。你可以通过纸条找到仓库也可以把纸条递给别人让他去取货。两个运算符必须刻进脑子取地址从仓库拿到门牌号*解引用拿着门牌号去仓库取货int x 10; int* p x; // p 存的是 x 的地址 *p 20; // 通过 p 修改 x 的值 cout x; // 输出 20这里有个声明细节我得单独说int* p这种写法容易让人以为int*是一个整体类型于是写成int* p, q;以为 p 和 q 都是指针。但C的语法规则是*只作用于紧挨着它的那一个变量名int* p, q;等价于int *p; int q;——q 是普通int。这行代码是C笔试的高频陷阱题建议声明时每个指针单独一行别偷懒。3.2 数组名和指针像但不是一回事数组名就是指针这句话流传很广但严格说是有条件的。数组名在绝大多数表达式里会退化为指向首元素的指针decay但有两个场景它不退化必须记住sizeof(数组名)得到的是整个数组的字节数数组名得到的是整个数组的地址类型是指向数组的指针int a[5]; cout sizeof(a); // 20不是8假设int占4字节指针占8字节 cout sizeof(a); // 8这是地址本身的大小这个区别是面试的经典送分题但每年都能放倒一群人。实际写代码时数组名作为实参传给函数会退化成指针因此函数里再sizeof就失效了。这也是C风格代码容易埋bug的地方第6节会展开。3.3 指针运算p1 不是地址1指针能加整数但规则是p 1表示跳过1个元素不是跳过1个字节。假设int* p指向地址 1000那么p 1指向 1004在int占4字节的机器上p 2指向 1008。这一点特别反直觉但其实是设计上的巨大便利你不需要关心元素大小编译器按类型帮你算好了偏移。同理p[i]本质上就是*(p i)这是下标运算的本质是指针运算这句话的来源。int a[5] {10, 20, 30, 40, 50}; int* p a; // 等价于 int* p a[0] cout *(p 2); // 输出 30 cout p[2]; // 也是 30下标其实是语法糖不过别高兴太早指针运算有边界只有p i落在数组的合法范围内0到n之间注意pn指向刚好越界一个位置在C里是合法的但只能比较、不能解引用行为才是确定的。越界做指针运算本身是未定义行为别挑战编译器。3.4 指针做函数参数为什么两数交换必须传指针几乎每个学过C/C的人都被两数交换折磨过。最经典的错误版本void swap(int a, int b) { int temp a; a b; b temp; } int main() { int x 3, y 5; swap(x, y); // x、y 没变 }原因极简单函数参数是值传递a、b是x、y的拷贝你在拷贝上交换原件自然不动。改成指针版本void swap(int* a, int* b) { int temp *a; *a *b; *b temp; } int main() { int x 3, y 5; swap(x, y); // 把地址传进去通过地址改原变量 }这里我要说一个很多教材没讲透的点传指针的本质也是值传递——传的是地址的拷贝但因为地址指向的是原变量的内存所以通过拷贝的地址去访问就能改到原件。这个概念理解透了你就能明白为什么函数里给指针参数重新赋值不会影响外面void func(int* p) { p new int(10); // 只改变了 p 这个拷贝的指向外面的 p 不变 }如果想在函数里改变指针本身的指向就得传指针的指针或者用引用。这正好引出第5节的内容。4. 字符串一半靠数组一半靠指针4.1 C风格字符串字符数组加上\0的约定C里有std::string但郑莉第六章的核心仍然放在C风格字符串。它的定义很简单一个以空字符\0结尾的字符数组。char str1[] hello; // 实际占6个字节: h e l l o \0 char str2[6] hello; // 显式给足大小 const char* str3 hello; // 字符串常量指向只读区这里有个重要区别str1和str2是数组内容存在栈上可以修改str3是一个指针指向字符串字面量只读区不能修改。如果你尝试str3[0] H在大多数平台上是段错误或未定义行为。这个点我在代码评审里见人踩过好几次而且编译期不一定报错。strlen和sizeof的区别也是必考char str[] hello; cout strlen(str); // 5数到 \0 之前的字符个数 cout sizeof(str); // 6包含结尾的 \0很多初学者在这里绕晕我的记法是strlen是数内容sizeof是量箱子。箱子比内容至少大1因为要放结尾的\0。4.2 C风格字符串的拷贝、拼接、比较三件套里全是坑C风格字符串的复制、拼接、比较用的是cstring里的一票函数strcpy、strcat、strcmp。这些函数有几个共性坑strcpy不检查目标缓冲区够不够大拷贝超长字符串会造成缓冲区溢出这是C语言安全漏洞的主要来源之一。现代代码要么用strncpy但要注意它不自动补\0的行为要么直接上std::string。strcat(dest, src)要求 dest 本身有足够空间且不能和 src 重叠。strcmp返回的是第一个不同字符的 ASCII 差值千万别写成if (strcmp(a, b))这种当相等的错误用法——相等时返回0在条件判断里0是false。正确写法是if (strcmp(a, b) 0)。如果你问我实际项目里怎么选能用std::string就用std::string。C风格字符串的学习价值在于理解底层不在于日常开发里手写。MFC时代常听到的字符串内存泄漏很多就是C风格字符串手动管理new char[]后忘了delete[]造成的这也是后来大家都转向std::string和CString的原因。4.3 std::string现代C的字符串正道教材的主线是C风格但作为C学习者你最好同步把std::string用熟因为它是工业界的默认选择。几个最常用的操作#include string std::string s hello; s.length(); // 5 s.size(); // 也是5 s world; // 拼接 std::string sub s.substr(6, 5); // world size_t pos s.find(world); // 找不到时返回 std::string::npos字符串和数字互转也是热搜里出现过的需求#include string #include cstdlib int num std::stoi(123); // C11起 std::string s std::to_string(456);注意std::stoi遇到非法字符时会抛std::invalid_argument或std::out_of_range异常不像C语言的atoi直接返回0然后假装无事发生。想判断一个字符串能不能转成数字时别裸调stoi先试试遍历检查或者用std::from_charsC17做无损解析。这一点和db2 sql判断数字字符串函数oracle 过滤不可转为数字的字符串这类SQL问题是同一个思路先验证再转换。4.4 两个经典题目逆序输出与按空格分割字符串逆序输出网上搜字符串逆序输出c能出来一堆写法但核心思路其实就双指针一个指针指向开头一个指向结尾交换往中间走。void reverse(char* s) { char* left s; char* right s strlen(s) - 1; while (left right) { char t *left; *left *right; *right t; left; right--; } }这个写法把指针运算和字符串操作结合得特别好是我建议所有初学者手写三遍的题目。写成char* s而不是char s[]两者在参数列表里其实完全相同——这正好呼应前面说的数组在形参里退化成指针。按空格分割字符串在C风格下要自己动手。最简单的方案是用strtok#include cstring char input[] hello world test; char* token strtok(input, ); while (token ! nullptr) { printf(%s\n, token); token strtok(nullptr, ); }注意strtok会修改原字符串把分隔符替换成\0而且它不是线程安全的。日常开发里我更推荐用std::istringstream#include sstream #include string #include vector std::vectorstd::string split(const std::string s, char delim) { std::istringstream iss(s); std::string token; std::vectorstd::string result; while (std::getline(iss, token, delim)) { if (!token.empty()) result.push_back(token); } return result; }这个函数能通吃c语言将一个字符串按照里面的空格分开这类需求比手工循环好维护得多。5. 指针数组、数组指针与多级指针最容易绕晕的组合5.1 优先级决定一切[] 比 * 高这一节是第六章的隐藏难点。先背一条规则下标运算符[]的优先级高于解引用运算符*。于是int* p[5]p 先跟[]结合p 是长度为5的数组数组元素是int*所以这是指针数组。int (*p)[5]加括号后 p 先跟*结合p 是指向一个长度为5的 int 数组的指针这是数组指针。读法是从变量名出发按优先级选择先结合的部分。我给学生的口诀是看见[]先抢除非括号把*抱住了。这俩名字只差一个括号含义天差地别考卷里最常见的辨析题就是它。5.2 指针数组存字符串const char* 数组的实用场景指针数组最常见的用途之一就是存放若干字符串严格说是字符串字面量的地址const char* weeks[] {Mon, Tue, Wed, Thu, Fri, Sat, Sun}; for (int i 0; i 7; i) { cout weeks[i] endl; }这里weeks是一个长度为7的数组每个元素是const char*指针各指向一个只读字符串。用二维数组char weeks[7][4]也能达到类似效果但每行长度固定内存浪费明显指针数组的好处是每行按实际字符串长度紧凑存放。代价是这些字符串不能修改所以声明时一定加const。热搜词里指针数组存放字符串指的就是这个套路在命令行参数表、菜单命令表这类场景里非常常见。5.3 数组指针与二维数组名传参的完整解法数组指针int (*p)[4]的经典场景是作为二维数组的函数参数。二维数组名b的类型实际上是int (*)[4]——指向长度为4的int数组的指针。void printMatrix(int (*m)[4], int rows) { for (int i 0; i rows; i) { for (int j 0; j 4; j) { cout m[i][j] ; } cout endl; } } int b[3][4] {0}; printMatrix(b, 3); // b 自动退化为 int (*)[4]这里的m[i][j]怎么理解m是指向数组的指针m[i]先按指针运算*(m i)得到第 i 行那个长度为4的数组它再退化成int*然后[j]取第 j 个元素。写代码时可以不管这些但编译器报类型不匹配时你得看得懂报错信息说的是哪一层。5.4 多级指针int** 到底能干什么int**常被称为指向指针的指针。经典用途有两个一是在函数里修改外部指针的指向二是模拟二维动态数组。void allocate(int** p, int n) { *p new int[n]; // 修改外部指针的指向 } int main() { int* arr nullptr; allocate(arr, 10); // arr 现在指向一块动态数组 arr[0] 42; delete[] arr; }如果你用int*做参数arr本身的指向在函数里改了外面并不会变原因见3.4。所以需要int**——传进来的是int*变量的地址通过两级解引用就能改动最外层的指针。动态二维数组的经典写法也依赖这个思路int** matrix new int*[rows]; for (int i 0; i rows; i) { matrix[i] new int[cols]; } // 使用完后逐行释放 for (int i 0; i rows; i) delete[] matrix[i]; delete[] matrix;这里的matrix本身是一个指针数组每个元素是int*这是理解动态数组这个概念背后原理的最好例证。不过现代C里我更推荐vectorvectorint或者vectorint加行列索引换算代码会安全得多。热搜里c#调用c出现access violation c0000005这类崩溃十有八九就是动态内存这块出了问题——要么越界要么二次释放要么泄漏。底层原理还是要懂的。6. 这章最容易踩的五个坑从野指针到缓冲区溢出6.1 野指针和空指针初始化与判空是救命稻草野指针就是指向未知内存的指针。最典型的制造方式只声明不初始化然后直接解引用。int* p; // 未初始化p 是野指针 *p 10; // 危险p 可能指向任意地址规范做法是声明时就初始化没有目标就置空int* p nullptr; if (p ! nullptr) { *p 10; }nullptr是C11引入的关键字替代了C时代的NULL和0推荐一律用nullptr。另一个制造野指针的常见方式是函数返回了局部变量的地址。int* bad() { int local 100; return local; // 函数结束local 生命周期结束返回的是悬空地址 }这属于未定义行为实际运行可能碰巧正常也可能随机崩溃。判断一个函数能不能返回局部变量的地址只需要问一句这个变量的生命周期在函数返回后还存不存在动态分配new出来的可以栈上的不行。嵌入式的朋友在Keil MDK里跟踪指针变量时如果看到指针的地址飘忽不定也可以从是否返回局部变量地址这个方向去查。6.2 动态内存的释放原则new 和 delete 必须成对new出来的内存不会自动回收必须手动delete。规则对应为new int配deletenew int[n]配delete[]int* p new int(42); delete p; int* arr new int[100]; delete[] arr;混用delete和delete[]是未定义行为有的平台不报错有的平台直接崩。每次写new之后马上写对应的delete是我建议的肌肉记忆。内存泄漏的隐蔽性在于程序不崩但内存一点点涨跑个服务几天后明显变卡。MFC项目里字符串内存泄漏的段子一大把根因基本都是C风格字符串动态分配后忘了释放。std::string之所以受欢迎不只是因为好用更因为它帮你把new/delete这层手抖空间给堵上了。6.3 浅拷贝问题指针赋值不是复制数据两个指针指向同一块内存时int* a new int(10); int* b a; // b 和 a 指向同一块内存不是拷贝了一份数据 delete a; // b 现在是悬空指针b a复制的是地址不是值。如果你以为有了两个独立的变量后面就会出大问题。用std::string、std::vector这类现代容器时拷贝构造会做深拷贝但这背后的指针复制≠数据复制意识必须保留——这也是理解后面章节里拷贝构造、移动语义、智能指针的起点。热搜词里指针赋值搜得人这么多说明这个概念卡住了不少人。6.4 用引用代替指针是写新代码的更优解郑莉的第六章按教学体系以指针为主但我得给读者补一句现代C的方向能用引用就用引用。两数交换用引用写void swap(int a, int b) { int temp a; a b; b temp; }引用是变量的别名没有指针那么灵活的运算不能改绑、不能做指针算术但也因此更安全。做函数参数时const std::string传参既能避免拷贝大对象的开销又不用担心函数把参数改掉。这一章的指针是必须学的底层基础但写新代码时优先考虑引用和STL容器是对自己负责也是对后面接手你代码的人负责。7. 串起来练一遍四道题吃透第六章7.1 两数交换的三种写法对比把值传递、指针传递、引用传递放一起看void swap1(int a, int b) { // 改了拷贝无用 int temp a; a b; b temp; } void swap2(int* a, int* b) { // 传地址可以 int temp *a; *a *b; *b temp; } void swap3(int a, int b) { // 传引用可以 int temp a; a b; b temp; }笔试大概率考的是 swap2 的变体比如问你为什么必须传指针。回答核心是一句话值是单向拷贝指针给的是原变量的访问入口。如果面试官再深问一句那传指针是不是就不是值传递了你要能答上来传指针仍然是值传递只是这个值是地址。7.2 冒泡排序下标版和指针版完全等价冒泡排序的核心是相邻元素比较交换。用下标写一遍再用指针写一遍能直观感受到两者的等价关系// 下标版 void bubbleSort(int arr[], int n) { for (int i 0; i n - 1; i) { for (int j 0; j n - 1 - i; j) { if (arr[j] arr[j 1]) { int t arr[j]; arr[j] arr[j 1]; arr[j 1] t; } } } } // 指针版 void bubbleSortPtr(int* arr, int n) { for (int i 0; i n - 1; i) { for (int j 0; j n - 1 - i; j) { if (*(arr j) *(arr j 1)) { int t *(arr j); *(arr j) *(arr j 1); *(arr j 1) t; } } } }两个版本完全等价指针版更接近机器视角下标版更好读。热搜词里数组分割并显示包含某一字符字符串排序其实都在这个框架内先想清楚数据怎么组织再想怎么比较、怎么移动。我建议初学者先写下标版理解算法再用指针版把a[j]就是*(aj)这个概念焊死在脑子里。7.3 前缀和数组预处理换查询时间最后说一个竞赛和面试里出现频率极高的数组应用前缀和。它解决的问题是频繁查询数组某个区间的和每次都从头加一遍太慢所以预处理一个前缀和数组prefix[i]表示前 i 个元素的和查询[l, r]区间和时直接算prefix[r1] - prefix[l]。#include vector std::vectorint buildPrefix(const std::vectorint nums) { std::vectorint prefix(nums.size() 1, 0); for (size_t i 0; i nums.size(); i) { prefix[i 1] prefix[i] nums[i]; } return prefix; } int rangeSum(const std::vectorint prefix, int l, int r) { return prefix[r 1] - prefix[l]; }前缀和本身不复杂但它能帮你建立通过预处理换查询时间的思路是后面树状数组、线段树的前置基础。热词里c 前缀和树状数组模板背后的线都从数组的连续存储特性出发——你只有理解了数组是连续内存、可以快速随机访问才能理解为什么前缀和能O(1)查询。7.4 综合小练习用指针数组实现命令名表如果你想验证自己是不是真懂了指针数组可以试试这个小任务写一个命令分发的雏形输入字符串匹配到对应的命令名。核心数据结构就是指针数组#include iostream #include cstring void actionA() { std::cout action A\n; } void actionB() { std::cout action B\n; } void actionC() { std::cout action C\n; } struct Command { const char* name; void (*handler)(); // 函数指针第七章的内容这里先瞄一眼 }; Command commands[] { {start, actionA}, {stop, actionB}, {reset, actionC}, }; void dispatch(const char* input) { for (size_t i 0; i sizeof(commands) / sizeof(commands[0]); i) { if (strcmp(input, commands[i].name) 0) { commands[i].handler(); return; } } std::cout unknown command\n; }这个例子把const char*指针数组、strcmp、sizeof / sizeof(元素)这套第六章的核心知识全串起来了。写不出来也没关系对照着理解了再自己敲一遍比搂十遍书都管用。这是我教这一章时最常用的一套验收题能独立写出dispatch说明你对数组、指针、字符串三者的联动是真过关了而不是背住了几个概念。剩下的就是多写、多看、多踩坑。指针这个东西光看永远觉得玄亲手把内存地址打印出来、亲手把一个野指针修好一次你就再也不会怕它了。