十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C/C++指针深度解析:从内存模型到智能指针的实战指南

C/C++指针深度解析:从内存模型到智能指针的实战指南 很多学C/C的朋友都跟我说指针是编程路上遇到的第一道坎而且这道坎不迈过去后面学链表、树、操作系统、编译器全都别扭。我自己刚接触指针那会儿也困惑了很久明明一个变量存得好好的为什么非要绕一圈去存它的地址搞个指针变量再去解引用到底图什么后来工作久了啃过几本底层相关的书也排查过不少线上问题才慢慢把指针这条线彻底理顺。这篇总结就是我这些年对指针的理解沉淀不敢说多深但保证都是实际用得上、能帮你把指针这两个字从抽象变具体的内容。不管你是正在学C语言的学生还是写C但一碰到智能指针就犯迷糊的开发者或者只是想搞明白Java里为什么也有双指针这种说法的朋友这篇文章都值得你花点时间看完。1. 指针的本质从变量到地址的思维跃迁1.1 变量到底是什么一间有门牌号的房间要理解指针第一步不是背语法而是理解变量在内存里的存在形式。很多人把变量想象成一个有名字的盒子这个比喻对了一半。真正的模型是内存是一长排编了号的房间每个房间能放一定字节的数据。编译器帮我们做了个映射——你声明了一个变量int a 10实际上是在内存某处划出4个字节假设32位int然后给这4个字节起了一个别名叫a。这里的别名是关键。a不是一个实体它是一个符号是编译器在编译期间用来表示内存地址X处的4字节区域的符号。当你写a 20编译成汇编后其实是这样一条指令把20这个立即数存到某个地址比如ebp-4处。也就是说变量名的本质是地址的别名只不过这个别名只存在于源代码层面到了机器码层面变量名就消失了只剩下地址。明白这点之后指针的定义就顺理成章了指针是一个值等于另一个内存地址的变量。普通变量存的是数据本身指针变量存的是数据的门牌号。为什么需要这个东西因为代码里很多场景下我们只靠房间号就能操作房间里的内容而不需要把整间房的东西搬来搬去。比如函数传参如果你传的是结构体本体那就要把整个结构体的字节拷贝一份传给被调函数代价很大如果你传的是结构体地址那只需要拷贝一个整数大小的地址数据被调函数通过这个地址直接访问原结构体又快又省。1.2 指针的三板斧声明、取址、解引用C语言指针的语法可以说是出了名的诡异int *p、int** pp、int (*p)[10]、int (*fp)(int)新手看到这堆符号头就大。但拆开来看核心操作只有三个声明int *p;表示声明一个指针变量p它指向的类型是int。取址p a;把变量a的地址赋给p。解引用*p 30;通过p中存放的地址访问该地址处的内存单元也就是a。我见过很多教学材料把int *p翻译成p是一个int类型的指针这个说法不够准确容易让新手误以为int *是一种单独的类型。更准确的读法是*p是一个int类型的变量所以p是指向int的指针。这个读法在遇到int (*p)[10]这种复杂声明时特别有用——(*p)是一个有10个int元素的数组所以p是指向包含10个int的数组的指针。关于指针的赋值有一个常见的坑是混淆了修改指针和修改指针指向的值。我给你看一个面试里经常出现的题目void change_ptr(int *p) { p (int *)malloc(sizeof(int)); *p 10; } int main() { int *ptr NULL; change_ptr(ptr); // 这里ptr仍然是NULL return 0; }为什么ptr还是NULL因为在change_ptr内部p是ptr的一个拷贝值传递你修改的是拷贝的值拷贝在函数返回后就销毁了外面的ptr自然没变。如果你想在函数里修改外部的指针本身必须传指针的指针也就是二级指针int **p。这个例子同时说明了指针值传递的本质以及二级指针存在的意义。我把这个例子作为指针和函数参数这一课的开场因为90%以上的人第一次都会被它绕进去。1.3 指针的算术p1到底移动了多少字节p 1是另一个经典陷阱。很多初学者以为p 1就是把地址值加1这是大错特错的。指针的算术运算不是简单地对地址做整型加法而是以指针指向的类型大小为步长进行移动。也就是说char *p; p 1移动1个字节int *p; p 1移动4个字节32位intdouble *p; p 1移动8个字节struct Node *p; p 1移动sizeof(struct Node)个字节这个设计的理由很直接指针算术的目的不是让指针在内存里乱走而是让指针在一段连续的同类型数据数组中线性移动。当你写p 1时实际上表达的是指向下一个int/double/struct的语义所以步长必须是类型大小否则指针指向的位置就落在元素中间解引用就是未定义行为。我在实际项目里见过不少因为指针算术误解导致的内存越界最典型的就是遍历数组时误用p和p 1。其实这两个是一样的问题往往出在边界条件上。比如int arr[5] {1, 2, 3, 4, 5}; int *p arr; for (int i 0; i 5; i) { // 这里i 5是错的 printf(%d\n, *p); p; }数组有5个元素当i 5时p已经指向数组末尾之后的内存区域再解引用就是读取未定义数据。这类错误在C语言里不会直接报错但可能在某次运行中突然崩溃或者更糟——静默地产生一个错误的数值排查起来极其痛苦。2. 指针的实战形态指针数组、函数指针与二级指针2.1 指针数组与数组指针从声明读懂语义在群里聊指针的时候指针数组和数组指针这两个词几乎每次都会被拎出来。说实话刚接触时我也觉得矫情——都差不多有什么好区分的但等到你在项目里写出char *argv[]和int (*matrix)[4]时就明白这两者的区别有多重要了。先看声明int *p[3]由于[]的优先级高于*所以p先跟[]结合说明p是一个数组数组里有3个元素每个元素是int *即指向int的指针。这就是指针数组——它是个数组。int (*p)[3]括号让*先跟p结合说明p是指针它指向一个有3个int的数组。这就是数组指针——它是个指针。搜索引擎热词里有个指针数组存放字符串这在C语言里真的是一个高频且实用的场景。想想看如果你想存储一组字符串比如一周七天的名称用二维字符数组char days[7][10]当然可以但缺点是每个字符串长度如果差异很大就会浪费内存。更优雅的方式是用指针数组char *days[] { Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, Sunday };这里days是一个数组每个元素是一个char *指向字符串常量在内存中的起始位置。Monday这个字符串本身存储在静态存储区指针数组中只存了它的地址。这比二维数组灵活得多字符串长度不再被固定列宽限制排列、排序、交换条目也只需要交换指针不需要搬动字符串内容本身。而数组指针的典型场景是处理二维数组的行。int (*matrix)[4]表示matrix指向包含4个int的行。当你有一个int a[3][4]可以直接把a赋给matrix然后通过matrix[i][j]来访问元素。为什么编译器需要知道每行的长度因为matrix 1要移动到下一行而下一行在内存中偏移量就是4个int的大小。如果不告诉编译器每行多长它就没法计算行与行之间的距离。这就是我在上一节说的指针算术步长由指向类型决定的活例子int (*matrix)[4]的步长是4 * sizeof(int)而不是sizeof(int)。2.2 函数指针把函数当成数据来传递函数指针是一个让很多人觉得指针没用的论据——没事干嘛搞个指针指向函数直接调用函数名不就行了这个想法在写简单工具时没错但当你需要在运行时决定调用哪个函数或者让一个函数库把你自定义的处理逻辑作为参数接收时函数指针就成了唯一正道。函数指针的声明很好记把函数声明里的函数名替换成(*指针名)。比如普通函数int add(int a, int b);其函数指针类型就是int (*func_ptr)(int a, int b);用起来也不复杂int add(int a, int b) { return a b; } int sub(int a, int b) { return a - b; } int main() { int (*op)(int, int); op add; printf(%d\n, op(3, 4)); // 7 op sub; printf(%d\n, op(3, 4)); // -1 return 0; }这段代码的效果是同一个变量op在运行时被赋值为不同的函数地址于是op(3,4)的行为也随之变化。这就是策略模式的C语言表达。真正让我体会到函数指针威力的场景是在写一个事件分发器的时候。某硬件设备会产生各种事件不同的事件对应不同的处理函数。如果不用函数指针就只能写一长串if (event_id 1) handle_1(); else if (event_id 2) handle_2();代码极其冗长且难以维护。用函数指针加查表代码立刻清爽很多typedef void (*event_handler)(void); void handle_start(void); void handle_stop(void); void handle_error(void); event_handler handlers[] { handle_start, handle_stop, handle_error }; // 事件来临时直接查表调用 handlers[event_id]();每次新增事件类型只需要新增一个处理函数并在handlers数组里登记一下。这就是表驱动编程的基本思路底层依赖的正是函数指针。2.3 二级指针修改指针本身前面讲函数参数时已经提到了二级指针int **p的一个用途——在函数里修改外部指针变量本身。这是一个典型场景当我们需要在函数里给外部指针赋新的地址时必须传指针的地址也就是二级指针。最经典的应用之一是链表头节点的插入。如果写一个insert_head(Node **head, int value)函数typedef struct Node { int value; struct Node *next; } Node; void insert_head(Node **head, int value) { Node *new_node (Node *)malloc(sizeof(Node)); new_node-value value; new_node-next *head; *head new_node; // 修改外部指针的指向 }如果你没有这个特性就必须用返回值来更新头节点或者单独封装一个带头节点的链表来避免。二级指针在这个场景下不是炫技而是C语言写链表绕不开的必经之路。还有一种二级指针的常见场景是函数内部动态分配内存然后通过参数把新分配的内存地址返回给调用方。刚才那个change_ptr例子就是典型——函数内malloc函数外用。如果参数只传int *进去分配出来的地址就丢了。当然二级指针也别滥用。能用一级指针解决的问题不要人为加一层。有时候看到一些代码动辄char ***除了增加阅读负担没有任何收益。指针的层数应该严格对应访问数据的间接层级而不是堆上去显得技术高深。3. 指针与寄存器的关系从底层视角看指针3.1 指针变量在CPU层面到底是什么指针与寄存器的关系能出现在热搜词里说明不少人对这个问题有好奇。要回答它得先了解CPU访问数据的途径。CPU本身不直接访问内存内存数据要先加载到CPU内部的寄存器里然后CPU在寄存器上做计算结果再写回内存。比如你要执行a b c大致流程是把b所在内存地址的内容load到寄存器R1把c所在地址的内容load到寄存器R2然后执行ADD R1, R2结果存到寄存器R0最后把R0的内容store到a所在的地址。那指针变量在哪里指针变量本身也是一个变量它的值一个地址也存在某个内存地址里。当你写int *p a;时编译器生成的就是把常量a也就是a的地址store到p所对应的内存单元。当你写*p 10;时编译器生成的指令是先把p内存单元的内容load到寄存器比如R1然后R1里存的就是a的地址接着用间接寻址把10写入R1中地址所指的内存单元。这个先用寄存器拿到地址再通过地址访问内存的动作在汇编层面就叫间接寻址。所以说指针在机器层面没有魔法它就是一条间接寻址的内存访问路径。CPU执行解引用操作时本质上是把一个作为数据存在寄存器里的地址值再次当作地址去访问内存。这也是指针被称为间接寻址的原因。3.2 为什么理解寄存器关系有助于写出更稳的指针代码理解了指针和寄存器的关系之后有几点实际意义第一指针变量的值必须在合法范围内。CPU在解引用时不会检查地址合法性它只管把R1里的数字当作内存地址去访问。如果这个地址不在你的程序有权访问的段内就会触发段错误。这就是野指针崩溃的根本原因——野指针的值是一个随机的非法地址。第二空指针为什么访问就崩溃。空指针NULL的值是0操作系统为了安全通常把内存0地址映射为不可访问区域。一旦CPU尝试对地址0做读或写MMU/操作系统就抛出异常进程直接崩溃。这是好事至少能让你立即发现问题而不是在数据被悄悄修改后很久才察觉。第三指针的大小和机器位数相关。32位系统的地址用4字节表示所以指针变量大小是4字节64位系统地址用8字节表示指针变量是8字节。这个事实解释了为什么sizeof(int *)在不同平台结果不同也解释了为什么共享内存、序列化结构体时不能用裸指针——指针是这个进程的虚拟地址换了进程或程序下次启动地址就失效了。第四寄存器数量的限制决定了指针操作的性能特征。CPU寄存器数量有限x86-64有16个通用寄存器ARM64有31个所以大量指针被反复使用时必然发生寄存器与内存之间的数据搬移。这也是为什么编译器会做优化尽量把频繁使用的指针变量缓存在寄存器中。我们在写代码时减少不必要的解引用层级不仅是为了可读性也是给编译器的寄存器分配减少压力。3.3 指针与内存布局一个经典的C程序内存分布为了更直观地理解指针操作的内存边界我把一个典型的C程序内存布局整理出来。从低地址到高地址大致分为代码段text存放编译后的机器指令通常是只读的。数据段data存放已初始化的全局变量和静态变量。BSS段bss存放未初始化的全局变量和静态变量程序加载时清零。堆heap程序运行时用malloc/new动态分配的内存向高地址增长。栈stack存放函数调用时的局部变量、参数、返回地址向低地址增长。指针变量本身可以放在栈上也可以放在堆上比如Node* p (Node*)malloc(sizeof(Node))分配出来的结构体内含有next指针。而指针指向的对象可以在以上任何一个区段。理解这些段对排查指针类问题很有帮助。比如一个函数返回了局部变量的地址int *foo() { int local 42; return local; // 禁止local是栈变量函数结束后就失效了 }这段代码能编译但运行行为是未定义的。因为local在栈上分配函数结束后栈帧被回收地址处的数据随时可能被下一次函数调用覆盖。这比空指针更可怕——空指针至少是立即崩溃而这种悬垂指针可能好端端跑很久然后在某次随机调用后突然出现诡异数据。4. 指针最容易踩的坑空指针、野指针与排查实战4.1 空指针异常的一次完整排查从Timer到数据库查询热搜词里有一条timer执行查询是报空指针这是个真实且典型的线上事故。我把它当作案例讲完整能帮你建立一套排查空指针的思路。场景回放一个Java服务里用定时器Timer定期执行数据库查询任务某次上线后突然开始频繁报空指针异常。报错栈定位到查询方法里某个resultSet.getString(user_name)那一行看起来是结果集为null。排查第一步先看异常栈。空指针异常的核心信息是哪个对象是null但Java的报错信息往往只告诉你at xxx line 45不告诉你那一行里哪个变量是null。所以需要看源代码把那一行所有的对象引用都列出来逐个打日志或断点确认。在这个案例里查询方法的入参是一个Connection对象方法内部又用了一个PreparedStatement然后执行executeQuery()得到ResultSet。空指针可能出在connection、statement、resultSet三者之一也可能是resultSet.getString()返回值再被使用时为null。实际排查结果很出乎意料空指针发生在resultSet.getString()之后的一行代码上那一行是String name user.getName()——user是另一张表的缓存对象而这缓存对象在定时任务首次启动时还没有被初始化。根本原因是定时任务在Spring容器完全初始化完成之前就触发了第一次执行依赖的缓存Bean还是null。这跟数据库查询本身没有关系是生命周期问题。这个案例给我们的启示有三点第一空指针报错所在行不等于根因所在行异常只是最后引爆的那一刻真正的原因往往要往上追溯几步。第二定时任务、异步线程这类并发生命周期最容易出现容器还没准备好就开始跑的问题需要在任务启动前做依赖检查。第三防御性判断要放在可能为null的边界入口而不是看哪行报错就包扎哪行。就这个案例来说缓存Bean初始化完成后或者加个if (cache ! null)就能解决。4.2 悬垂指针与野指针C/C开发者必须警惕的两类问题悬垂指针和野指针经常被混在一起说但严格来说它们成因不同悬垂指针Dangling Pointer指向的内存已经被释放但指针变量的值没有清除还在被使用。最常见于free(p)之后没有把p NULL或者函数返回了局部变量地址。野指针Wild Pointer指针变量本身未初始化就使用。比如int *p;后直接*p 10;此时p的值是栈上的随机垃圾指向哪完全不可控。两类问题有一个共同点它们的崩溃表现常常是随机的。因为已经释放的内存可能暂时还保留着旧数据下一次解引用可能碰巧还能读出正确的值误导你以为代码没问题。只有当这块内存被重新分配并写入其他数据后问题才暴露出来——查起来极其痛苦。我在实际项目中总结了一套防御策略虽然不能完全避免这类问题但能把风险降到最低指针声明时立即初始化int *p NULL;不给野指针留机会。free之后立即置空free(p); p NULL;这样即使后续误用也是空指针崩溃比悬垂指针好排查得多。用工具辅助检查Valgrind的memcheck是检测悬垂指针、内存泄漏的利器。C方面AddressSanitizerASan在编译时加-fsanitizeaddress就能捕获很多内存错误。尽量用智能指针替代裸指针这一点我们下一节细说。4.3 指针越界不报错不等于没事内存越界是另一类高发问题。C/C不对数组访问做边界检查arr[5]即使数组长度只有5也能编译通过、运行不报错但它读取或写入了相邻内存。这类错误分两种表现一是篡改邻居。比如你在堆上分配了3个int写第4个int时越界可能把malloc管理数据比如chunk头部覆盖了导致下一次free、malloc时崩溃。这种崩溃看起来跟越界点距离很远排查需要经验。二是读取非法数据。比如搜索热词里的多维数组 c 指针当你用指针操作多维数组时越界访问会很隐蔽。假设你有一个3行4列的二维数组用行指针去遍历结果边界算错多走了一步读取到的值就不是你预期的程序却不崩溃。要防这类问题老实的办法是严格检查循环边界。更现代的办法C里用std::array、std::vector的at()方法代替裸数组和裸指针因为它们自带边界检查at()越界会抛异常operator[]不会。这种用语言特性换安全的思路是C相对C最重要的改进方向之一。5. C智能指针把裸指针的危险关进笼子里5.1 智能指针到底解决了什么问题C11引入的智能指针要解决的痛点就三个忘记释放、重复释放、释放后使用。用裸指针写C你每次new都得想着对应delete一旦中间抛异常或者提前returndelete就漏了内存泄漏。如果两个指针指向同一块内存你delete了其中一个另一个就成了悬垂指针再用就未定义行为。智能指针的核心思想是RAIIResource Acquisition Is Initialization简单说就是资源获取在构造函数里完成资源释放在析构函数里完成。因为C保证局部对象在离开作用域时一定会调用析构函数所以只要你把内存的释放逻辑写在析构函数里那么无论代码是正常执行还是抛异常返回内存都会被自动释放。这个方案的好处是它不改变C的栈上对象自动析构语义而是把本来需要手工管理的堆内存包装成一个栈上的对象。对象销毁析构函数执行内存释放滴水不漏。5.2 unique_ptr、shared_ptr、weak_ptr怎么选C11标准库提供了三种智能指针很多人一开始分不清该用哪个。我的经验是优先用unique_ptr需要共享所有权时用shared_ptr防止循环引用时用weak_ptr。unique_ptr独占所有权。同一时间只能有一个unique_ptr指向一块内存不允许拷贝但可以移动转移所有权。适合某个对象只属于一个所有者的资源语义最清晰开销最小跟裸指针一样没有额外的引用计数。shared_ptr共享所有权。内部引用计数多个shared_ptr可以指向同一对象最后一个shared_ptr析构时释放内存。适合多个模块共同持有同一个对象的场景。但引用计数有原子操作的性能开销而且循环引用会导致内存泄漏后面详细说。weak_ptr不改变引用计数不拥有对象。它只能配合shared_ptr使用用于观察对象是否还存活或者临时提升为shared_ptr再访问。主要用途就是打破shared_ptr的循环引用。举一个具体选型例子一个ConfigurationManager整个程序只有一个实例多个模块都要读它的配置。这种情况用shared_ptr合适因为谁都可能持有它谁都不应该单方面销毁它。相反如果一个DBConnection只归属于某个Database类其他类只是临时借用一下那就用unique_ptr其他模块通过裸指针或引用访问不持有所有权所有权归属清晰出问题好追溯。5.3 shared_ptr循环引用与weak_ptr的破解方式shared_ptr有一个坑是循环引用。想象两个对象A和BA持有一个指向B的shared_ptrB持有一个指向A的shared_ptr。当外部对它们的引用都释放后A的引用计数因为B持有它而没降到0B的引用计数因为A持有它也没降到0于是两个对象都不会被释放形成了一个内存泄漏的闭环。我写一个简化的例子#include memory using namespace std; struct B; struct A { shared_ptrB b_ptr; ~A() {} }; struct B { shared_ptrA a_ptr; ~B() {} }; int main() { shared_ptrA a make_sharedA(); shared_ptrB b make_sharedB(); a-b_ptr b; b-a_ptr a; // 函数结束后a和b互相引用引用计数均不为0内存泄漏 return 0; }解决办法是把其中一个方向的shared_ptr换成weak_ptr。比如让B持有weak_ptrA。这样A引用B时B的引用计数加1B引用A时用的是weak_ptr不增加A的引用计数。当外部shared_ptrA释放后A的计数归零对象被销毁B的计数也会随之归零最终全部释放。注意使用weak_ptr访问对象前必须通过lock()方法获取一个临时的shared_ptrauto tmp b-a_ptr.lock(); if (tmp) { // tmp非空说明对象还活着可以安全访问 tmp-doSomething(); } else { // 对象已被销毁 }5.4 自己实现一个迷你智能指针理解原理才是王道对于想深入理解智能指针的人我建议自己动手实现一个简化版。不需要支持线程安全引用计数就把核心机制写出来你会立即明白它为什么有效。核心思路是一个模板类内部维护一个指针和一个计数器template typename T class SmartPtr { private: T *ptr_; int *ref_count_; public: explicit SmartPtr(T *ptr nullptr) : ptr_(ptr), ref_count_(new int(1)) {} SmartPtr(const SmartPtr other) : ptr_(other.ptr_), ref_count_(other.ref_count_) { (*ref_count_); } SmartPtr operator(const SmartPtr other) { if (this ! other) { // 先让当前对象引用计数减1如果归零则释放资源 if (--(*ref_count_) 0) { delete ptr_; delete ref_count_; } // 然后指向新对象 ptr_ other.ptr_; ref_count_ other.ref_count_; (*ref_count_); } return *this; } ~SmartPtr() { if (--(*ref_count_) 0) { delete ptr_; delete ref_count_; } } T *operator-() { return ptr_; } T operator*() { return *ptr_; } };这段代码虽然简陋没有支持数组、没有make_shared、不是线程安全的原子计数但它完整展示了shared_ptr的三大核心要点拷贝构造和拷贝赋值时引用计数加1。析构时引用计数减1减到0才真正释放资源和计数对象。赋值运算符必须先处理老对象再指向新对象并且要处理自赋值的情形if (this ! other)。看完这个迷你实现你就明白shared_ptr为什么叫搬用计数指针也明白为什么shared_ptr比裸指针开销大——因为每次拷贝/销毁都要更新引用计数而且这个计数还必须是原子操作多线程环境下要用std::atomicint原子操作是有性能成本的。这也是我在项目里坚持能用unique_ptr就别用shared_ptr的原因。6. 指针思维的跨界从C/C到Java双指针6.1 Java没有指针语法为什么还要学指针思维很多只写Java的同学看到指针两个字就自动忽略觉得跟自己没关系。但你再仔细看一眼热搜词——java 双指针合并有序数组——说明在Java圈子里双指针也是一个高频面试和算法话题。这就引出一个有意思的问题Java明明没有C语言那种指针语法为什么还有双指针的说法原因在于指针的本质是通过一个不断变化的索引/引用来访问连续的数据结构这个思维模式不需要C语言语法也能表达。在Java里数组的下标本质上就是指针的替代品而双指针就是用两个下标变量以不同的速度或方向遍历数组从而把时间复杂度从O(n²)降到O(n)的经典技巧。Java里没有int *p但有int i 0; i这已经足够实现指针的全部核心优势了。6.2 双指针合并有序数组的完整实现我以合并两个有序数组为例子带你走一遍双指针的完整思路。题目是这样有两个已经排好序的数组nums1 [1, 3, 5]和nums2 [2, 4, 6]要把它们合并成一个新的有序数组。最直觉的做法是两层循环每个元素都和其他所有元素比较时间复杂度O(n*m)。但如果利用两个数组各自有序的特性可以用双指针在线性时间内完成int[] merge(int[] nums1, int[] nums2) { int n nums1.length; int m nums2.length; int[] result new int[n m]; int i 0; // nums1的指针 int j 0; // nums2的指针 int k 0; // result的指针 while (i n j m) { if (nums1[i] nums2[j]) { result[k] nums1[i]; } else { result[k] nums2[j]; } } // 处理剩余元素 while (i n) { result[k] nums1[i]; } while (j m) { result[k] nums2[j]; } return result; }核心思想不复杂因为两个数组都是有序的所以当前最小的元素只可能是nums1当前指针指向的元素或nums2当前指针指向的元素取其中较小的放入结果数组并把对应的指针向后移动。每次迭代都能确定一个元素的最终位置两个指针总共移动nm次时间复杂度O(nm)。你把这个代码和C语言版对比一下会发现逻辑几乎完全一样只是把*p1换成了nums1[i]把p1换成了i。这就是指针思维的跨语言迁移能力语法可以变但用可移动的游标遍历线性结构的思路是通用的。我在带新人时经常强调学C语言的指针不是为了让你以后天天写裸指针而是为了建立内存和游标的直觉。有了这套直觉学Java、Go、Python的切片、索引、迭代器都会快很多。6.3 更多双指针应用快慢指针与对撞指针双指针除了并行推进之外还有两种常见的变体快慢指针一个指针每次走两步一个指针每次走一步。典型应用是判断链表是否有环Floyd判圈算法。如果有环快指针终会追上慢指针如果没有环快指针先到末尾。这个思路在C语言里用结构体指针在Java里用对象引用写法几乎一模一样。boolean hasCycle(ListNode head) { ListNode slow head; ListNode fast head; while (fast ! null fast.next ! null) { slow slow.next; fast fast.next.next; if (slow fast) { return true; // 快慢指针相遇说明有环 } } return false; }对撞指针两个指针分别从数组两端向中间移动。典型应用是有序数组中找两数之和等于targetLeetCode 167。时间复杂度O(n)空间O(1)比双层循环优雅得多。我个人觉得双指针代表了一种利用数据有序性减少冗余比较的思维模式。它跟C语言的裸指针其实已经关系不大了但如果你没有理解过指针的移动、边界、越界这些概念写起双指针来就很容易在边界条件上翻车——这也是为什么很多算法题解里大家讨论最热烈的往往是while循环的退出条件和指针何时自增。7. 写在最后几个我踩过坑之后的经验写到这里指针的核心内容基本覆盖得差不多了。最后分享几个我在实际项目里用真金白银换来的教训。第一写C/C时从源头消灭裸指针滥用。能传引用就不要传指针能用智能指针就不要手动new/delete能用容器就不要手动管理数组。这不是教条而是降低认知负担——你每多用一次裸指针代码里就多一分悬垂、越界、泄漏的风险。第二排查指针问题先想清楚这个指针的值应该是多少现在又是多少。很多指针崩溃问题的根源是指针的值不对而不是指向的内容不对。你可以用调试器看指针变量的值跟预期地址对比往往一眼就能发现是初始化遗漏、释放后未置空、还是越界把指针自身的值覆盖了。第三跨语言理解概念比死记语法更有价值。热搜词里的java 双指针合并有序数组就是一个很好的例子——名字里带着指针但Java里根本没有指针语法。这说明很多编程概念是语言无关的抽象你一旦从C语言里理解了指针的本质地址、间接访问、移动游标到任何语言里都能快速迁移反而不会被某一种语法框住。第四面试也好日常开发也罢指针考察的核心永远是内存模型。面试官问指针不是想考你会不会写int *p a这种简单语法而是想确认你脑中是否有内存地址、生命周期、所有权、边界条件的完整图景。所以准备指针类问题时不要只背API要把数据在内存里怎么放、怎么取、谁负责释放这链条从头到尾想通。指针确实不难难的是你愿不愿意花时间建立那个内存视角。一旦你习惯了用地址偏移间接访问的方式去看程序很多原来觉得玄学的特性——数组名为什么指向首元素、函数参数为什么传指针可以改外部变量、链表节点为什么需要二级指针——都会自动变得理所当然。希望这篇总结能帮你打通最后一层窗户纸。
返回列表