ARTICLE DETAIL

资讯详情

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

单链表从入门到精通:指针操作、增删改查与常见坑全解析

单链表从入门到精通:指针操作、增删改查与常见坑全解析 很多朋友学数据结构第一关就卡在链表上。数组明明写得挺顺一到链表就各种报错指针指来指去直接绕晕。我当年从C语言课设一路做到面试题链表翻来覆去写过几百遍发现大多数人学不会的原因不是笨而是没把“链表到底是什么”这件事从底层想清楚。这篇博文不整虚的从物理结构讲到手写代码从常见报错讲到面试怎么考全部按我自己的学习路径和教学经验来写力求让哪怕完全零基础的人也能把一个单链表真正搞懂、写对、用起来。这篇内容适合刚学数据结构的学生、准备期末或者考研复习的人也适合那些被“链表反转”“快慢指针”搞得头疼的求职者。看完你会理解链表的前世今生、单链表的完整增删改查实现、常见的段错误和内存泄漏坑以及一套实用的调试套路。1. 链表到底解决什么问题1.1 数组的痛链表的生先想一个问题为什么有了数组还要搞出链表这个东西数组在内存里是一块连续的空间这个特性给了它两个好处一是可以用下标直接访问二是缓存局部性好。但事情都有代价。连续空间意味着你在创建数组的时候就得定好大小就算用动态数组扩容的时候也要把旧数据copy到新位置。更麻烦的是在数组中间插入或者删除一个元素你得把后面的元素统统往右或者往左挪。假设数组有100万个元素你想在第5个位置插个数据后面那999995个元素全要动这一下就是O(n)的时间。链表就是为“频繁插入删除”这个场景设计的。它的核心思想很粗暴不要求所有数据在内存里挨着放每个节点除了存数据再额外存一个指针指向下一个节点。这样数据虽然在内存里散落各处但通过指针一环扣一环逻辑上仍然是一条完整的序列。就好比一群人要在操场上站成一排。数组的做法是让大家按学号坐在连续的座位上来了新生就得所有人挪位置链表的做法是只记住第一个人的位置然后每个人手里拿一张纸条写着下一个人在哪新生加进来只需要改两张纸条就行。这就是链表最底层的设计哲学用“访问变慢”换取“插入删除变快”。链表的节点在内存里不连续所以你不能用下标arr[3]直接取第4个元素必须从头指针开始一个节点一个节点next下去访问第i个节点就是O(n)。但反过来只要你知道插入位置的前驱节点插入和删除只需要改指针压根不用动别的数据时间复杂度O(1)。1.2 链表有哪些形态链表的家族其实不小面试和考试里最常见的三个是单链表每个节点只有一个next指针只能从前往后走。双向链表每个节点有prev和next两个指针既能往前也能往后。Java里的LinkedList就是典型的双向链表。循环链表尾节点的next指向头节点整个链表变成一个环。约瑟夫环问题用的就是循环链表。刚入门阶段单链表是绝对的重点也是我建议你第一个手写的结构。单链表搞明白了双向链表和循环链表都是在其基础上加加减减的事。这篇文章的核心也是单链表后半部分会讲双向链表怎么扩展。2. 单链表核心操作拆解2.1 节点结构定义——基础中的基础在C语言里单链表的节点定义基本上就是这个样子typedef struct Node { int data; // 数据域用来存值 struct Node *next; // 指针域指向下一个节点 } Node, *LinkedList;这里有两个细节值得展开说。第一为什么next的类型是struct Node *而不是别的因为当前节点要指向下一个节点下一个节点的类型和当前节点一模一样所以这个指针的类型就是“指向struct Node的指针”。这里不能用typedef之后的名字Node *来写因为在定义结构体的过程中Node这个名字还没有正式生成C语言不允许你拿一个还没定义完的别名去声明成员。这是新手最常见的编译报错点。第二LinkedList这个别名本质上是Node *的意思。把LinkedList当成“这是一个链表”来理解会让代码意图更清晰。很多教科书把头指针定义成LinkedList L;意思就是“我定义了一条链表”。实际写代码的时候我更喜欢直接用Node *head这种写法因为指针的概念更直白调试的时候不容易绕晕。两种风格看个人习惯考试时按教材写法来就行。2.2 链表的创建与遍历创建链表有两种思路头插法和尾插法。头插法每次把新节点插到头部代码写起来简单但得到的数据顺序是反的。尾插法维护一个尾指针每次把新节点挂在尾部得到的数据顺序和输入一致日常更常用。先看头插法Node *createByHead(int arr[], int n) { Node *head NULL; for (int i 0; i n; i) { Node *newNode (Node *)malloc(sizeof(Node)); newNode-data arr[i]; newNode-next head; // 新节点指向原来的头节点 head newNode; // 更新头指针 } return head; }如果输入数组是{1, 2, 3, 4, 5}头插法得到链表顺序是5 - 4 - 3 - 2 - 1。原因很直观每来一个新节点都把它塞到最前面越晚来的越靠前。再看尾插法Node *createByTail(int arr[], int n) { Node *head NULL, *tail NULL; for (int i 0; i n; i) { Node *newNode (Node *)malloc(sizeof(Node)); newNode-data arr[i]; newNode-next NULL; if (head NULL) { head newNode; tail newNode; } else { tail-next newNode; tail newNode; } } return head; }尾插法有个关键点必须给每个新节点的next赋NULL。很多同学malloc出来一个节点直接往里写数据忘了初始化next结果遍历链表的时候指针一路乱飞最后程序崩了都不知道为什么。malloc申请的内存内容是随机的这个随机值可能不是合法的地址。所以拿到新节点第一件事就是newNode-next NULL养成这个习惯能帮你避开一大半链表bug。遍历链表的逻辑极简单一个循环从头走到尾void printList(Node *head) { Node *cur head; while (cur ! NULL) { printf(%d - , cur-data); cur cur-next; } printf(NULL\n); }这里判断条件用cur ! NULL而不是cur-next ! NULL区别在于前者会打印到最后一个节点后者在最后一个节点上会跳过循环。写遍历这类操作时请默认为cur ! NULL等你对边界条件足够熟悉了再自己权衡。2.3 链表的插入与删除——入门头号难点插入分三种情况头插、尾插、中间插。头插和尾插其实就是上面创建链表时反复做的事中间插是大家真正容易懵的地方。在链表中某个位置插入节点核心就一句话找到前驱节点然后改两条指针。假设我们想在节点p之后插入一个新节点s操作是这样的s-next p-next; p-next s;这两条语句的顺序绝对不能交换。如果先执行p-next s那么p原来的下一个节点就丢了因为此时没有其他任何变量指向它你再把s-next指过去也晚了它指到的是s自己链表就在这里断成了一个环外加一段丢失的数据。所以必须先把s和后面的节点接上再让p指向s。类比一下你在一队人中间插队你得先跟后面的人说“我站你前面”然后让前面的人把手松开把位置留给你。顺序反了后面那哥们儿就走了。删除节点同样要先找前驱。把p的后继节点q删掉Node *q p-next; p-next q-next; free(q);这里有个问题删除末节点或者删除不存在的节点以及链表为空时删除头节点都需要单独处理。成熟的写法应该把这几个场景都考虑进去。我觉得初学者不用一上来就写“完美版”先把分情况的逻辑写出来跑通再优化成统一版本。比如删除指定值的节点int deleteByValue(Node **head, int value) { if (*head NULL) return 0; if ((*head)-data value) { Node *tmp *head; *head (*head)-next; free(tmp); return 1; } Node *p *head; while (p-next ! NULL) { if (p-next-data value) { Node *tmp p-next; p-next tmp-next; free(tmp); return 1; } p p-next; } return 0; // 没找到 }这个函数接收的是Node **head而不是Node *head因为删除头节点时需要修改头指针本身的值。如果只传一级指针过去函数内部的修改影响不到外面的变量这是个非常经典的C语言坑。很多资料里会写“带头节点的链表可以避免修改头指针”这个后面展开说。2.4 带头节点和不带头节点的区别链表还有一个很多教材里都会讲到的变体带头节点的链表。所谓头节点就是第一个不存数据的节点它的next才指向真正的第一个数据节点。带头节点的好处是不管链表是否为空头指针永远指向一个有效节点插入和删除操作逻辑完全统一不需要对“删除头节点”做特殊处理。比如上面的deleteByValue如果不带头节点就得写两种分支带了头节点所有情况都走同一个循环逻辑就行。但代价是多了一个不存数据的节点某些场景下会让人困惑“这个节点算不算链表的一部分”。实际开发中带头节点的写法常用于简化链表管理算法面试和竞赛题里为了节省那个节点的开销基本都写不带头节点。我建议入门阶段两种都写一遍你会对指针的操作理解得更深。3. 从零手写一个完整可用的单链表3.1 完整代码增删改查一把梭说再多理论不如直接上一份能跑的完整代码。下面是我按平时自己的编码习惯整理的一个单链表示例包含创建、遍历、插入、删除、修改、查找、销毁一共七个常用操作。这个代码我是用C语言写的因为C语言最能暴露指针和内存问题适合学习。#include stdio.h #include stdlib.h typedef struct Node { int data; struct Node *next; } Node; // 尾插法创建链表 Node *createList(int arr[], int n) { Node *head NULL, *tail NULL; for (int i 0; i n; i) { Node *newNode (Node *)malloc(sizeof(Node)); newNode-data arr[i]; newNode-next NULL; if (head NULL) { head newNode; tail newNode; } else { tail-next newNode; tail newNode; } } return head; } // 获取链表长度 int getLength(Node *head) { int cnt 0; Node *cur head; while (cur ! NULL) { cnt; cur cur-next; } return cnt; } // 在第pos个位置插入值valuepos从1开始 // 返回值1成功0失败 int insertAt(Node **head, int pos, int value) { if (pos 1) return 0; Node *newNode (Node *)malloc(sizeof(Node)); newNode-data value; newNode-next NULL; if (pos 1) { newNode-next *head; *head newNode; return 1; } Node *p *head; int i 1; while (p ! NULL i pos - 1) { p p-next; i; } if (p NULL) { free(newNode); return 0; // 位置越界 } newNode-next p-next; p-next newNode; return 1; } // 删除第pos个节点 int deleteAt(Node **head, int pos) { if (*head NULL || pos 1) return 0; Node *tmp NULL; if (pos 1) { tmp *head; *head (*head)-next; free(tmp); return 1; } Node *p *head; int i 1; while (p-next ! NULL i pos - 1) { p p-next; i; } if (p-next NULL) return 0; // 越界 tmp p-next; p-next tmp-next; free(tmp); return 1; } // 查找值为value的节点返回节点指针 Node *findNode(Node *head, int value) { Node *cur head; while (cur ! NULL) { if (cur-data value) return cur; cur cur-next; } return NULL; } // 修改第pos个节点的值 int modifyAt(Node *head, int pos, int newValue) { Node *cur head; int i 1; while (cur ! NULL i pos) { cur cur-next; i; } if (cur NULL) return 0; cur-data newValue; return 1; } // 销毁整个链表 void destroyList(Node **head) { Node *cur *head; while (cur ! NULL) { Node *tmp cur; cur cur-next; free(tmp); } *head NULL; } void printList(Node *head) { Node *cur head; while (cur ! NULL) { printf(%d - , cur-data); cur cur-next; } printf(NULL\n); } int main() { int arr[] {10, 20, 30, 40, 50}; Node *list createList(arr, 5); printList(list); insertAt(list, 1, 5); insertAt(list, 3, 25); printList(list); deleteAt(list, 1); deleteAt(list, 4); printList(list); modifyAt(list, 2, 99); printList(list); Node *found findNode(list, 99); if (found) printf(找到了: %d\n, found-data); destroyList(list); return 0; }这段代码我把边界情况基本都处理了你可以直接复制到本地编译运行感受一下链表操作的完整流程。建议在纸上跟着画一遍每执行一次指针改动就画一次节点和箭头画到最后你就会发现链表也没那么玄乎。3.2 代码里的三个核心设计思想第一边界优先。insert和delete开头都先判断position合法性这属于“防御式编程”。链表操作最容易崩的地方就是边界空链表插删、位置越界、删除最后一个节点。把这些case先用if拦住程序就稳了一大截。第二一二级指针的区别。函数内部要修改头指针本身比如头插、删头节点、销毁链表必须传Node **只是读取头指针或者修改节点内部成员传Node *就够了。判断标准很简单看你要不要改变调用者那边的head变量的值。需要改变就上二级指针不需要就一直用一级指针。第三malloc和free必须配对。每次malloc出来的节点用完都要free掉否则内存泄漏。内存泄漏的可怕之处在于它不会立刻报错而是悄悄吃掉你的内存程序跑一晚上就崩。考试可能不查这个实际工程里绝对跑不掉Valgrind工具就是专门干这个的。3.3 链表的遍历和查找别再写错循环条件链表遍历和查找的逻辑可以认为是同一类从头节点出发依次访问每个节点。初学者最爱犯的错就是循环里越界访问。比如按值查找Node *findNode(Node *head, int value) { Node *cur head; while (cur ! NULL cur-data ! value) { cur cur-next; } return cur; // 找不到返回NULL }这种写法比while (cur-next ! NULL)更安全因为cur NULL时循环直接跳出不会出现解引用空指针的问题。说到解引用空指针这是C语言链表崩溃的头号原因。崩溃的本质是你对NULL指针做了-操作这等于往地址0上写东西操作系统当然要给你一个段错误。所以在操作cur-next之前先判断cur是不是NULL这句话请刻在脑子里。4. 链表常见坑和调试技巧4.1 三大经典错误段错误、死循环、改链表断链我在这行折腾这么多年见过的链表bug基本跑不出这三类段错误英文叫Segmentation Fault。出现场景一般是对NULL指针或野指针解引用。比如Node *cur NULL; cur-data 1; // 崩了或者删节点时free了之后还继续用free(tmp); tmp-data 1; // 迷之行为可能崩也可能不崩free之后指针并没有自动变成NULL它还是原来那个地址但这个地址的内存已经不属于你了。这时候再读写就是“野指针”行为完全不可预测。所以养成习惯free完节点后顺手把指针置NULL。死循环表现是程序卡住不动了。常见原因是链表里有环可能是插入时指针顺序写反导致节点指向自己比如s-next p-next; p-next s; // 如果p和s本来就是同一个节点这就是自环或者尾插法忘了把newNode-next置NULL导致末尾指向随机地址。判断这类问题没有捷径老老实实在关键位置加printf打印当前节点的地址和数据看看到底哪一步绕不出来了。断链是改指针时顺序错误造成的。典型的就是我前面讲的插入两条语句顺序颠倒直接丢掉了后半段链表。这种bug在打印链表时能看到一种很诡异的现象链表只输出了前半段后面凭空消失。排查方法也简单插入前后各打印一次链表对比一下断在哪了。4.2 推荐调试方法画图加printf别只用脑子debug写链表代码最忌讳硬看。看一眼代码然后在脑子里模拟一遍模拟不出结果就开始瞎改改完了更乱。我自己的调试习惯是先画图再printf最后才用调试器。画图很简单每个节点画个方框数据写在方框里next画一个箭头指向下一个方框。每一步操作之前把当前所有箭头是什么状态画出来然后看操作应该改变哪根箭头。画完基本就能定位问题在哪。printf调试法则更加直接在关键位置打印变量值和地址。比如插入函数里打印前驱节点的地址、新节点的地址、前驱节点的next值。看输出的变化基本能判断是逻辑错了还是野指针了。真正复杂的链表问题比如环检测我才会用gdb一般用watch命令监视某个指针的变化。但初学者阶段printf完全够用。4.3 养成内存检查的好习惯C语言链表还有个隐蔽的问题内存泄漏。函数退出后一堆malloc节点没人释放。判断泄漏可以简单粗暴看进程的内存占用是否一直涨。真正要精确定位Linux下用Valgrindvalgrind --leak-checkfull ./your_program它会告诉你哪一行malloc出来的内存在退出时没有free。这个工具不是C语言专属C也一样能用。期末上机或者考研复试机考可能没条件跑Valgrind但我建议你平时多练练手写destroyList养成谁申请谁释放的意识这个就够应付大部分场景了。5. 链表的进阶双向链表、循环链表、模板类和面试常考点5.1 双向链表和循环链表怎么从单链表扩展单链表只能从一个方向遍历想找某个节点的前驱只能从头重新走一遍这个效率就很低了。双向链表的解决方案是每个节点多一个 prev 指针指向它的前驱。节点定义变成typedef struct DNode { int data; struct DNode *prev; struct DNode *next; } DNode;双向链表的插入和删除要同时维护两个方向的指针操作数量翻倍但带来一个好处删除节点时只要给到待删节点本身就可以完成删除不需要额外找前驱。这在“根据内容删除节点”的场景下比单链表方便很多。代价是内存开销变大Java的LinkedList就是这样你可以根据需要自由选择方向遍历。循环链表则是在单链表基础上把最后一个节点的next指向头节点或头指针这样一个线性结构就闭合了。约瑟夫环问题就是循环链表的经典应用场景一群人围成一圈报数报到某个数字就出列直到剩最后一个人。算法实现的时候关键是处理好报数到末尾后回到头部的逻辑用tail-next head判断是否回到了起点。5.2 C里用模板类写链表面试也能用很多同学学到C就爱用模板类重写链表。模板的好处是节点里的数据类型不是写死的int而是通用的Ttemplate typename T struct Node { T data; NodeT *next; }; template typename T class LinkedList { private: NodeT *head; public: LinkedList() : head(nullptr) {} void insertHead(const T value); void print() const; // 其他操作... };从实际角度说面试手写算法时用C写链表多数人还是用struct裸指针但会被要求注意delete和析构。真正生产环境里能用std::list就用标准库自己造轮子不出事则已一出事就是内存问题。但学习阶段我强烈建议手写一遍模板类链表它能让你把底层的指针逻辑彻底吃透。模板类链表常见的坑是析构函数忘记释放所有节点导致内存泄漏。编译器不会提醒你只有跑Valgrind才看得到。所以自己实现模板类链表时一定要写析构~LinkedList() { NodeT *cur head; while (cur ! nullptr) { NodeT *next cur-next; delete cur; cur next; } }5.3 链表高频面试题和学习节奏建议数据结构期末和面试里链表题几乎年年见。我自己梳理了一个高频题单按难度从低到高排反转单链表迭代递归链表中倒数第k个节点快慢指针合并两个有序链表有序合并判断链表有没有环快慢指针判断环找到环形链表的入口点删除链表倒数第n个节点链表排序归并排序在链表上的实现找两个链表的交点这里的核心技巧就是快慢指针。一个指针每次走一步另一个指针每次走两步如果链表有环快指针早晚会追上慢指针。听起来很魔法实际上就是跑步套圈的道理还挺容易理解的。我见过很多同学一开始觉得这题很难但真正理解了之后反而觉得比反转链表还简单。建议学习节奏是先照着文章把单链表的基本增删改查各写三遍不要嫌枯燥写到第三遍基本能脱稿。然后画一遍带头节点和不带头节点的对比图理解为什么带头节点能统一操作。最后再去做反转链表和快慢指针题。这套路线走下来数据结构链表部分至少搞定80%的面试题。5.4 拉链表是个完全不搭边的概念查资料的时候你可能搜到“拉链表”这个词它跟数据结构里的链表完全是两码事。拉链表是数据仓库领域处理历史数据的一种表结构通过增加有效期字段来记录数据的历史变化实现“随时间变化也能回溯历史状态”的效果。有人管它叫渐变维度表做事后回溯用的。学习数据结构的时候看到这个词知道它跟指针链表没半点关系别被带着跑偏了就行。6. 从入门到能写我个人的练习路径最后分享一套我当年练链表时用的方法。第一遍照着别人的代码敲每写一行都问自己“这一行是在干什么”。如果回答不上来去画图。我当年敲第一遍链表的时候画了整整两页A4纸的节点图画完才真正懂指针是怎么回事。第二遍关掉参考自己从头写。写到卡壳的地方比如插入、删除的顺序忘了别立刻翻答案先自己在图上推演一遍推演通了再继续往下写。这个“卡壳再思考”的过程记忆效果比看十遍答案都好。第三遍换不同的数据结构和场景去写。比如把链表当成一个栈用头插法实现push用删除头节点实现pop或者用链表做多项式相加。这个阶段是为了把链表从“背代码”变成“用工具”真正灵活起来。链表这个东西说难不难说简单也不简单。它考验的是你心里能不能同时装下“数据的逻辑顺序”和“内存的物理状态”两张图。一旦你跨过了这个坎后面二叉树、图论里那些指针操作你会觉得亲和许多。根据我自己的经验99%的人卡在链表上都卡在“一开始就想写出完美代码”。别这样大胆先写把bug调出来把图画出来再去抠边界条件和内存优化。踩过几个坑之后你再看链表会真心觉得它是个很漂亮的抽象。
返回列表