算法题中的指针类型题目:核心考点、解题套路与常见误区
发布时间:2026/9/17 3:08:19
分类:文化教育
浏览:1234

最近在刷算法题的朋友应该有感受链表、二叉树的题目十道里有七八道都在折腾指针。尤其是C/C选手写双指针、快慢指针时经常被一两个星号搞得晕头转向——改了指针本身还是改指针指向的内容改完下一个节点该接谁一旦想不清楚代码跑起来不是段错误就是死循环。这篇就把“算法题里的指针类型题目”单独拎出来好好拆一遍聊清楚这类题到底在考什么、解题时怎么想、有哪些百试百爽的套路以及我踩过的那些坑。先说明一下定位这篇文章不教C语言指针的入门语法那个随便一本教材都讲得比我好而是把指针和算法题结合在一起总结出一套能在刷题时直接用的方法论。适合正在刷LeetCode、打算笔试面试、或者因为指针总写错而头疼的人。1. 内容整体设计与思路拆解1.1 指针类型题目到底在考什么先说个很多人没想明白的点算法题里考指针本质上不是在考你会不会用-或者*而是在考你有没有“对象与引用的心智模型”。举个例子很多人背过链表反转的迭代写法ListNode* reverseList(ListNode* head) { ListNode* prev nullptr; ListNode* curr head; while (curr) { ListNode* next curr-next; curr-next prev; prev curr; curr next; } return prev; }背是背下来了但面试官一改题——比如让你反转链表的第m到n个节点——就开始乱。为什么因为没想清楚prev、curr、next这三个指针在不同时刻到底指向哪个节点每一步执行完“谁指向谁”的状态图没有在脑子里建立起来。所以说到底指针类算法题考察的是三件事是否理解指针是“保存地址的变量”而不是对象本身是否能在操作指针时追踪“当前状态”和“下一步状态”是否具备把复杂操作拆成“指针指向调整”的能力这三件事说得直白一点就是考察你有没有把链表当链表、把树当树去想的直觉。你把链表画成方框和箭头把所有指针操作看成“改箭头指向”这类题的正确率会立刻上一个台阶。1.2 常见题型分类从内存视角看解题路径按我刷题的经验算法题里的指针类型题目大致分四类每一类的解题策略都有明显差异题型代表题核心考点典型解法链表节点操作反转链表、删除倒数第N个节点、合并有序链表指针指向调整、虚拟头节点迭代/递归 哑节点双指针/快慢指针环形链表、删除排序数组重复项、三数之和空间复用、指针移动条件快慢指针、对撞指针指针与数组二维数组指针访问、旋转图像、矩阵遍历内存布局理解、指针步长模拟遍历、原地操作函数指针/指针数组根据条件调用不同函数、命令调度类问题抽象与解耦、回调机制函数指针表、策略模式这个分类的价值在于你看到一道新题第一步不是急着写代码而是先判断它的指针类型——它操纵的是链表的“箭头”还是数组的“格子”还是函数的“入口地址”判断对了基本上解题框架就出来了。比如“合并两个有序链表”它属于第一类链表节点操作。你自然知道要用一个哑节点dummy node来串链表用一个cur指针指向新链表的最后一个节点然后不断比较两个原链表头节点值的大小把小的那个接到cur后面。这个框架一旦建立后面的实现细节只是机械操作。1.3 为什么很多人卡在指针题上三个常见误区我观察到一个现象很多人刷指针题代码写得少脑子里的“抽象想象”太多了。一上来就试图在脑子里模拟指针的二进制地址想“指针变量里面到底存的是什么”结果越想越乱。实际上解指针题根本不需要关心地址的具体数值你只需要关心“指针指向哪个对象”“对象的类型是什么”“通过指针能访问到哪些成员”。这就是算法层面看指针和底层层面看指针的区别。第二个误区是不画图。我见过太多人链表反转写错了一边看代码一边挠头。你说“这里next丢了”他半天反应不过来。但只要你把链表画出来把prev、curr、next三个指针用不同颜色的笔画在图上每执行一步就更新箭头出错概率会小一大半。这个方法听着笨但真管用。第三个误区是混用C的引用和指针。刷题时很多人看题解用ListNode*就说看不懂实际上引用就是指针的语法糖只是在编译层面避免了你手动解引用。你理解指针怎么工作理解引用就是“指针的别名”完全没问题。真正到刷题层面我会更推荐统一用指针思维去理解因为题解里两种写法都很多你两种都看得懂才算真的掌握。2. 核心细节解析与实操要点2.1 指针的本质地址只是“抽屉编号”要把指针题做好先得把指针变量本身弄明白。我在给学弟学妹讲的时候经常打一个比方内存就像一栋公寓楼每个房间有门牌号房间里住着数据。指针变量是一个特殊的便利贴便利贴上面写着某个房间的门牌号。注意指针变量自己也是一个房间它也有自己的门牌号。你取地址p得到的就是这个便利贴房间的门牌号而你用int* p x做的事是在便利贴上写了x的门牌号。当你想知道x的值时你拿着便利贴去找对应房间这在语法上就是*p。这个模型对解题有什么用非常有用。比如你写了一个函数想要修改调用方的变量void change(int val) { val 10; } void reallyChange(int* p) { *p 10; }调用change(a)时传入的是a房间里的数据副本改副本对a没影响。调用reallyChange(a)时传入的是a房间的门牌号然后在函数里根据门牌号去找到a房间修改里面的数据。这样一来调用方的a确实变成了10。在算法题中这个区别常见于“递归修改链表”和“递归构建树”的场景。比如你要在一个函数里把树的左孩子替换成某个新节点如果参数是TreeNode*你修改参数本身不会影响调用方的指针但如果参数是TreeNode*或TreeNode**你修改的就是调用方持有的那个指针本身。我建议你把这两句话记牢参数是T* p时函数内修改p本身不影响外部传入的指针变量函数内修改*p会影响外部指针指向的对象。参数是T* p或T** p时函数内修改p本身会让外部传入的指针变量指向别处。2.2 数组指针、指针数组、指针的指针名字绕但记忆有巧劲热搜词里出现了“数组指针”“指针数组”“字符串数组指针”“顶层指针和底层指针可以相互赋值吗”这些名词我估计不少人是被这类“指针和数组的排列组合”绕晕的。先记两条最基本的int* p[3]p先跟[]结合所以是“数组”数组元素是int*叫指针数组。int (*p)[3]p先跟*结合所以是“指针”指向一个“长度为3的int数组”叫数组指针。怎么快速区分看变量名先跟谁结合。*p[3]里[]优先级高于*所以p先跟[]结合成数组(*p)[3]里括号强行让p先跟*结合成指针。就这么简单。那“字符串数组指针”又是什么它是一个指针指向一个数组数组里的每个元素是char*也就是字符串char* strArr[] {hello, world, algorithm}; char* (*p)[3] strArr; // 字符串数组指针访问第一个字符串的第一个字符就是(*p)[0][0]。这里小技巧是p指向整个数组(*p)才是数组本身所以(*p)[i]是数组第i个元素一个char*再往后[0]就是取这个字符串的第一个字符。“顶层指针和底层指针可以相互赋值吗”这个问题稍微进阶一点。顶层指针top-level const指指针本身是const即int* const p——p不能再指向别处底层指针low-level const指指针指向的对象是const即const int* p——p指向的对象不能通过p修改。答案是底层指针和底层指针可以相互赋值顶层指针尤其是非const指针与底层指针相互赋值时有const限定符的差异需要遵循const的兼容规则不能随意把const int*赋给int*否则会丢掉只读约束编译器不允许。反之int*可以赋给const int*。简单记加只读容易去只读不行。2.3 函数指针把函数当数据用有些指针题范围更大直接考函数指针。我记得有一次遇到一道“实现一个计算器根据运算符调用不同函数”的题目最开始写if-else嵌套写得累死后来用函数指针表一行搞定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 (*ops[])(int, int) {add, sub, mul}; char op[] {, -, *}; // 调用 int idx 0; if (c ) idx 0; else if (c -) idx 1; else idx 2; int result ops[idx](a, b);这种做法的好处是新增一种运算比如除法时不需要改调用逻辑只改表的内容和维护映射。这就是函数指针在算法题里的价值——解耦和抽象。类似的还有“根据状态调函数”的状态机题目。写函数指针最需要注意的是类型写法。int (*ops[])(int, int)看起来别扭但你可以先写一个类型别名using OpFunc int (*)(int, int); OpFunc ops[] {add, sub, mul};这样可读性会好很多。刷题时可以用这种写法节省心智负担。2.4 智能指针理解现代C的自动化内存管理热搜词里有“智能指针”“智能指针实现”这对刷题党来说分为两个层面第一刷题时能不能用第二面试时问不问原理在纯算法题中我用智能指针的经验是能用但没必要。LeetCode的链表题节点类型已经定义成了裸指针你非要用shared_ptr去包一层反而别扭。但你自己写工程化一点的算法组件时推荐优先用unique_ptr管理独占所有权用shared_ptr管理共享所有权。面试问到“实现一个shared_ptr”时核心考点是引用计数。思路就是智能指针对象里保存两个东西原始指针和引用计数指针拷贝构造时让计数加一析构时让计数减一减到零才真正释放资源。template typename T class SharedPtr { private: T* ptr_; int* count_; public: SharedPtr(T* ptr nullptr) : ptr_(ptr), count_(new int(1)) {} SharedPtr(const SharedPtr other) : ptr_(other.ptr_), count_(other.count_) { (*count_); } ~SharedPtr() { if (--(*count_) 0) { delete ptr_; delete count_; } } };这就是智能指针的骨架实现。它把“什么时候释放内存”这个极易出错的问题交给对象生命周期去解决避免了普通裸指针常见的悬空、泄漏问题。搞懂这个你理解unique_ptr的move语义、weak_ptr解决循环引用的原理也会顺很多。3. 实操过程与核心环节实现3.1 从删除倒数第N个节点到虚拟头节点的习惯先讲一个我特别想推荐给所有人的习惯凡是涉及删除链表节点的题目优先考虑用虚拟头节点dummy node。以LeetCode 19“删除链表的倒数第N个节点”为例。第一步在head前面加一个哑节点dummydummy-next head。第二步让快指针先走n步。第三步快慢指针同时前进直到快指针到底。第四步慢指针的next指向next-next。核心代码ListNode* removeNthFromEnd(ListNode* head, int n) { ListNode dummy(0); dummy.next head; ListNode* fast dummy; ListNode* slow dummy; for (int i 0; i n; i) fast fast-next; while (fast-next) { fast fast-next; slow slow-next; } slow-next slow-next-next; return dummy.next; }为什么用哑节点因为如果要删除的正好是头节点用普通写法需要特判if (head target)多了不少分支。哑节点的存在让头节点也有了一个“前驱”所有节点统一用同一种逻辑处理代码简洁而且不容易漏边界。我建议你把“涉及链表节点增删操作时先画个dummy”这个习惯固化下来。它虽然不改变算法复杂度但能显著降低实现错误的概率。3.2 快慢指针的边界条件环形链表两道题一起看快慢指针最经典的题就是判断链表是否有环。做法很简单快指针一次走两步慢指针一次走一步如果有环快指针会追上慢指针如果没环快指针会先走到null。bool hasCycle(ListNode* head) { ListNode* slow head; ListNode* fast head; while (fast fast-next) { slow slow-next; fast fast-next-next; if (slow fast) return true; } return false; }这里注意while条件里fast-next的判空。如果链表没有环快指针走到最后一个节点时fast-next为null循环停住不会发生空指针解引用。这个条件写漏了很多题会直接段错误。升级版是“找到环形入口”。先快慢指针直到相遇然后让其中一个指针回到head两个指针每次都走一步再次相遇的位置就是环入口。这背后有个数学推导从头节点到入口距离是a入口到相遇点距离是b环剩余长度是c。相遇时慢指针走了ab快指针走了abk(bc)而快指针路程是慢指针两倍所以abk(bc) 2(ab)整理得a c(k-1)(bc)。也就是说从head走到入口的距离等于从相遇点绕环若干圈后回到入口的距离。所以让一个指针从head走一个指针从相遇点走必然在入口相遇。这个推导是面试问得比较细的点。就算不考数学推导你也要清楚这个结论因为扩展题非常多比如“寻找两链表的交点”“寻找数组中重复数”都能用类似的快慢思想解。3.3 双指针在数组里的应用原地去重的“写指针”思维说完了链表再说指针类型题目中另一种高频考法——数组上的双指针尤其是“原地操作”类。以“删除排序数组中的重复项”为例。因为它要求原地修改不能开新数组所以核心思想是维护一个“末尾指针”或者说“写指针”。读指针遍历整个数组写指针指向下一个不重复元素应该放的位置。int removeDuplicates(vectorint nums) { if (nums.empty()) return 0; int write 0; for (int read 1; read nums.size(); read) { if (nums[read] ! nums[write]) { write; nums[write] nums[read]; } } return write 1; }这个写法里write就是“下一个可写位置的前一个位置”它始终指向已处理区域中最后一个不重复元素。读指针遇到不同值时先把write向前挪一位再把新元素写进来。我特别喜欢这类题的原因是它特别考验“指针指向什么状态”的清晰度。很多人写这类题犯错是因为把write既当“末尾位置”又当“待写位置”逻辑混在一起。你只要想清楚write到底指向哪里代码是一次过的。同样的模型可以平移到很多题上移动零、移除指定元素、合并两个有序数组从后往前写等。核心都是一个指针负责读一个指针负责写读指针永远比写指针跑得快。3.4 字符串与指针C字符串的坑要特别小心热搜词里连续出现了“指针数组存放字符串”“字符串数组指针”说明字符串在指针题里也是个老大难。刷算法题时如果是C的std::string一切都还好说因为string自己管理字符数组你不太需要关心底层指针。但如果是C风格的char*就容易踩坑。比如char* p hello; // 字符串字面量存在只读区不能修改 char arr[] hello; // 可修改的字符数组如果用p[0] H去尝试修改只读区的字符串直接崩溃。这在面试里是个高频陷阱题。记住字符串字面量是const的想要可修改的副本用数组或malloc出来新空间再拷贝。另外C字符串没有内置长度信息以\0结尾。所以遍历字符串指针时循环条件是while (*p ! \0)或者while (*p)。这个和C的std::string差异很大。刷题时如果题目明确说“C风格字符串”一定要把所有越界访问的风险都排查一遍。指针数组存放字符串的典型场景是const char* fruits[] {apple, banana, cherry};这里fruits是一个数组数组元素是const char*。每个元素指向不同的字符串字面量。排序这个数组时其实只是交换指针不需要复制字符串内容。这就是指针数组在算法题中的优势——交换的是地址不是数据效率高代码也不容易出错。3.5 二叉树的指针操作递归与引用二叉树的题目看似不叫“指针题”其实骨子里全是指针。每个节点的左右孩子是指针递归遍历也是在不断传指针。写递归框架时我建议你形成模板化思维。以“翻转二叉树”为例TreeNode* invertTree(TreeNode* root) { if (!root) return nullptr; TreeNode* left invertTree(root-left); TreeNode* right invertTree(root-right); root-left right; root-right left; return root; }这个递归的返回值是一个指针。它表示“翻转完成后把新的子树根节点返回给上一层”。如果你把返回值丢掉或者返回错了节点整棵树就断了。另一种写法是用递归的引用参数。有些题目的解法里会写void traverse(TreeNode* root)尤其是“删除二叉搜索树中的节点”这类需要修改root本身的题目。这时TreeNode*的作用是让函数内修改root指向能直接反映到调用方的变量上。如果你用普通的TreeNode* root对root本身的赋值不会影响外面。这是面试时最容易扣分的地方因为代码能编译、能跑但结果不对你排查半天才发现是这个原因。3.6 经典变式从“相同的树”看指针比较和值比较最后补充一个看着基础但有代表性的题——“判断两棵二叉树是否相同”。它的核心是指针比较和值比较要分清。bool isSameTree(TreeNode* p, TreeNode* q) { if (!p !q) return true; if (!p || !q) return false; if (p-val ! q-val) return false; return isSameTree(p-left, q-left) isSameTree(p-right, q-right); }这里判断p q和判断p-val q-val是不同的。p q比较的是两个指针是否指向同一个地址p-val q-val比较的是两个对象的值。很多人在“判断链表是否相交”的题目里也会犯这个错——不能用值是否相等来判断两个链表是否相交要看指针地址是否相同。我还见过有人问“顶层指针和底层指针可以相互赋值吗”之后就混淆了const指针的比较。说白了指针的比较规则就是同类型的指针才能直接比较不同类型如int*和double*的比较要么报错要么需要强转比较的是地址不是地址处的数据。这个规则在刷题时看似不起眼但在判断节点是否同一节点时极其关键。4. 常见问题与排查技巧实录4.1 一张表理清指针题的经典报错写指针类题目最痛苦的就是debug。不过绝大多数错误都可以归到下面这几类我整理了一个速查表方便你对照排查症状原因解决办法运行时崩溃段错误空指针解引用或访问了已经释放的内存加判空避免访问悬空指针死循环程序跑不完链表有环但没被检测到或指针移动条件写错检查指针步进逻辑快慢指针会检测环结果不对但没有崩溃修改了指针本身而不是指针指向的内容区分p x和*p x编译器提示const相关错误试图把const指针赋值给非const指针理解顶层const和底层const的赋权规则函数体内改指针无效传入的是指针的副本改用T*或T**参数这些是我反复遇到的真实问题。写题的时候看到崩溃先怀疑空指针看到死循环先怀疑步进和终止条件看到结果差一点先画图核对每一步的状态。4.2 悬空指针和野指针快速定位三招悬空指针就是指向的内存已经被释放野指针就是指针变量没有初始化指向一个随机的地址。这两种在算法题里不太常见因为刷题输入数据都合法但在实现数据结构和内存池练习里会遇到。定位方法我总结了三招第一招加打印。在每个疑似出问题的步骤前输出指针地址和值观察什么时候变成异常值。第二招用工具。C可以用AddressSanitizer编译时加-fsanitizeaddress它能在你访问非法内存时立刻告诉你具体是哪一行触发的。第三招代码审查时重点关注谁分配了内存谁释放了内存释放之后还有没有别的指针指向这块内存放到算法题里这第三招就是链表节点是不是被删了释放之后别的指针还能不能访问它比如“删除链表中的节点”这个问题如果面试题要求释放被删节点那么所有仍然指向这个节点的指针都要先修正否则就是悬空指针。4.3 指针自增和下标访问的等价性还有一个新手容易忽略的点*(p i)和p[i]是等价的。很多人在数组指针题里纠结半天其实这就是下标访问的本质——p[i]在编译期就是*(pi)。但要注意这不代表数组和指针完全等价。sizeof(arr)和sizeof(p)的结果不同前者是整个数组的大小后者是指针变量的大小。函数参数中的数组会退化为指针所以void f(int arr[])和void f(int* p)在签名层面等价。刷题时这个知识点怎么用当你在实现“数组旋转”“二维数组遍历”时可以放心用下标因为编译器会在底层自动处理指针算术。但在需要极致性能或底层操作的场景比如图像处理算法题可以考虑直接移动指针来避免重复计算偏移。4.4 递归中指针传参的常见错误递归题目也经常涉及指针。最常见的问题是递归参数到底传TreeNode*还是TreeNode*。我的经验是分场景如果递归的返回值是节点指针通常传TreeNode*即可因为返回值本身会向上传递。如果要在递归过程中更新某个外部指针例如记录深度最大的节点建议传TreeNode*。如果递归的过程中需要修改当前节点的子指针比如删除节点操作建议传TreeNode*。给你看一个错误示例。假设你想让递归函数把root修改为nullvoid resetTree(TreeNode* root) { root nullptr; }这个函数根本不会改变外部传入的root变量。因为root参数是外部指针的副本你把副本改成null外部指针不受影响。想真正修改外部指针必须这样void resetTree(TreeNode* root) { root nullptr; }这个细节在“删除二分搜索树节点”“删除链表的某个节点”这类问题中出现频率极高。面试官特别喜欢在这里设坑因为你代码跑一次可能发现log打出来root还是原来的值却不知道问题出在参数传递上。4.5 函数指针使用时的避坑清单函数指针不是算法题的常客但一旦出现比如“根据运算符计算结果”“命令分发统计”有几个坑值得提前知道函数指针的类型必须和函数签名完全一致包括返回类型和参数类型。int (*)(int, int)只能指向返回int、两个int参数的函数不能指向返回void的函数。取函数地址有两种写法add和add都可以编译器都接受。刷题时直接用函数名最省事。函数指针数组初始化时函数声明要在前确保类型匹配。通过函数指针调用时ops[idx](a, b)和(*ops[idx])(a, b)等价。现代C允许省略解引用但理解底层调用关系有助于调试。函数指针表最大的价值是让代码从“if-else丛林”变成“查表映射”。尤其是做计算器、状态机、命令分发这类题时用函数指针表比写一堆switch分支清爽得多。5. 实操总结与经验心得写到这里指针类型题目的核心方法其实已经讲完了。最后我再分享几条自己这些年刷题和面试中沉淀下来的个人经验不一定多高深但全是实际验证过的。第一刷指针题一定要多画图。用箭头表示指针指向用方框表示节点对象每执行一步更新箭头方向。画个十来道题你对指针操作的理解会有质的飞跃。这比反复读十遍理论都有用。第二遇到“指针指向问题”时先问自己我要修改的是“指针本身”还是“指针指向的对象”这道题里函数参数需要T*还是T*想清楚这两点再动手写代码。第三调试指针错误时不要凭空猜。打印地址、画状态图、用sanitizer找到第一次行为异常的点然后往前倒推。每一次“改代码碰运气”都是在浪费时间。第四经典题的模板背熟没有坏处。链表反转的迭代模板、快慢指针判环模板、双指针原地去重模板、二叉树的递归遍历模板这些是解大量变种题的基础。模板不是让你死记而是让你在紧张时有一个可靠的起点再根据题目要求做修改。最后再讲一点很多人觉得C/C指针既然这么容易出错是不是直接用Java/Python就完事了其实不是。指针思维训练的是对“对象引用”的理解。你用Java写链表题时虽然不需要手动管理内存但节点的next引用怎么改、怎么赋值和C指针的底层逻辑完全一样。学懂了指针不仅C/C题会写其他语言里关于引用、可变性、对象共享的概念也会清晰很多。希望这篇文章能帮你在“链表的箭头世界”和“数组的格子世界”里少踩几个坑。刷题路上最难的不是题难是你明明知道解法代码却因为一个指针写错而反复报错——这种挫败感我太懂了。所以去画图吧去打印地址吧去把每一个指针的“指向”和“状态”都搞清楚你会发现这些题突然变得温顺起来。