单链表从原理到代码:C语言指针操作与常见错误详解
第一次学数据结构十有八九都会在单链表这里卡一跟头。别怕这东西看着像天书其实就是“一堆节点手拉手排成队”的直白结构难点全在 C 语言指针的用法上。很多教材把原理讲得很绕代码又一股脑贴出来初学者看完等于没看。这篇文章换个思路从单链表到底解决什么问题讲起再把接口设计、每个核心操作的实现逻辑、常见错误和调试方法掰开揉碎写清楚。代码以 C 语言为主适合刚学完指针、开始啃数据结构的大学生也适合准备面试要快速复习链表操作的人。读的时候建议旁边放张纸把链表画出来跟着画一遍比盯着代码看十遍都管用。1. 链表到底是什么先把它讲明白1.1 从数组到链表为什么需要单链表数组最大的优点是随机访问知道下标直接就能算出元素地址一步到位。但这个优点是用连续内存换来的。连续内存意味着数组大小得提前定死要么开小了不够用要么开大了浪费空间。更麻烦的是插入和删除在数组中间插一个元素后面的所有元素都得往后挪删除也是成本高得吓人。单链表恰恰把问题反过来。它不要求内存连续每个节点可以散落在内存的各个角落。节点里除了数据还用一个指针记录“下一个节点在哪”。这样一来插入和删除只需要改动指针指向不需要搬动任何数据。代价是访问节点必须从头一个个顺着指针找失去了随机访问的能力。你可以把数组想象成电影院连排座位每个人都有固定编号找 15 号直接走过去链表则是排队玩“找下一个”的游戏每个人只告诉你下一站在哪想知道第 15 个人是谁只能从第一个人开始一个个问。所以单链表的核心价值不是“快”而是“灵活”。它用 O(1) 的插入删除操作换取了 O(n) 的查找速度。在做课程设计、写操作系统内核、实现哈希表拉链法这些场景里这个交换非常值。1.2 节点与指针单链表的骨架单链表的基本单位是节点C 语言里通常定义成结构体。初学者最容易栽在定义这里结构体的成员里有个指针指向“自己这种结构体”看起来像套娃但其实不复杂。typedef struct Node { int data; struct Node *next; } Node;注意看next 的类型是struct Node *不是Node *。原因是 typedef 别名Node要到结构体声明完整结束之后才生效在结构体内部还没法用。很多人第一次写的时候顺手写成Node *next;编译直接报错。这个坑我踩过不止一次。有了这个结构体链表就活了头指针指向第一个节点第一个节点的 next 指向第二个节点第二个节点的 next 指向第三个……最后一个节点的 next 指向 NULL。NULL 就是链表的终点标志就像队伍最后一个人告诉你“后面没人了”。一个节点也能成链表空链表就是头指针直接指向 NULL。理解这个结构之后后面所有操作都是在“改箭头”。1.3 带头结点和不带头结点为什么我建议先带头链表还有个大分叉头结点要不要单独占一个不存有效数据的节点。所谓带头结点就是链表头部有一个哨兵节点它的 data 域一般不用next 指向真正的第一个有效节点。不带头结点则头指针直接指向第一个有效节点链表为空时头指针为 NULL。对比项带头结点不带头结点空表判断head-next NULLhead NULL头插/头删不需要修改头指针需要修改头指针要传二级指针或返回新头遍历起始从头结点的 next 开始从 head 本身开始代码统一性插入删除逻辑统一边界情况要单独处理带头结点最大的好处是统一操作。比如删除第一个有效节点时带头的链表可以直接找到头结点作为前驱改动头结点的 next 就行不带头结点的链表却要修改头指针本身函数的参数得写成Node **head或者返回新的头指针。对于刚入门的人我强烈建议先把带头结点的版本写熟练再去处理不带头结点的边界问题。本文后面所有实现都基于带头结点的单链表但我会在关键位置提醒不带头结点时哪里有区别。2. 动手前的设计接口、宏与内存约定2.1 数据域用 int 还是 void*先想清楚再写链表的 data 域类型需要提前决定。教材例题普遍用int因为简单直观适合练逻辑。实际项目里链表节点要存的是各种结构体、字符串、对象引用这时候用int就不够用了。比较常见的做法是把 data 声明成void *也就是一个通用的指针指向任意类型的数据。代价是类型安全没了取数据时要自己强转还要自己负责那块数据内存的释放。我自己写练习代码时默认用int但会在设计接口时留一个心眼涉及 data 的操作尽量封装成函数后面要换类型只改结构体和少量代码不用推翻重来。这个习惯很重要。比如删除操作按值删除时if (cur-data value)这个判断就是和数据类型耦合的地方将来如果改成字符串至少要单独写一个比较函数。提前做好这个准备能省掉大量重构时间。2.2 函数清单一个链表模块需要哪些接口写代码前最好先列接口清单就像做菜先备菜。一个单链表模块常见接口大概长这样Node *list_init(void); // 创建带头结点的空链表 Node *create_node(int data); // 创建一个新节点 void list_insert_head(Node *head, int data); // 头插 void list_insert_tail(Node *head, int data); // 尾插 int list_insert_pos(Node *head, int pos, int data); // 指定位置插入 int list_delete_by_pos(Node *head, int pos); // 按下标删除 int list_delete_by_value(Node *head, int data); // 按值删除 Node *list_find(Node *head, int data); // 按值查找返回节点指针 void list_traverse(Node *head); // 遍历打印 int list_length(Node *head); // 返回长度 void list_clear(Node *head); // 清空所有有效节点保留头结点 void list_destroy(Node **head); // 销毁整个链表头结点也没了这些接口不是越多越好但上面这几个基本覆盖了日常需求。设计的时候尽量让每个函数只干一件事比如list_clear和list_destroy就要分开因为有时候你只是想把链表清空继续复用不想把头结点也释放掉。2.3 内存归属谁创建谁释放避免一堆野指针C 语言链表操作里最让人崩溃的问题就是内存管理。malloc 出来的节点必须保证在合适时机用 free 释放。这个“合适时机”需要提前定好规矩。我一般遵循三条第一谁负责创建谁负责释放。插入操作 create_node 分配节点那么对应的删除操作就必须把这个节点 free 掉。第二链表销毁时要先把所有有效节点释放干净再释放头结点顺序不能反。第三free 之后立即把相应指针置为 NULL防止出现悬空指针。比如free(tmp); tmp NULL;否则后面万一不小心访问到这片已经归还内存的地址程序就神不知鬼不觉地错了。这三条规矩听着简单但真写起来特别容易漏。尤其是写完插入没写删除、写删除忘了 free、free 完之后还在用这个指针这三个错几乎是链表新手全部 bug 的来源。3. 核心操作实现从建表到增删改查3.1 初始化链表先让头结点找到一个安全的 NULL不管带头结点还是不带头结点初始化都是第一步。带头结点的初始化是这样Node *list_init(void) { Node *head (Node *)malloc(sizeof(Node)); if (head NULL) { printf(malloc failed\n); return NULL; } head-data 0; head-next NULL; return head; }malloc 的返回值必须判断。它失败时会返回 NULL如果直接拿 NULL 当链表头去操作后面全是野指针访问段错误躲都躲不掉。head-next NULL这一行也不能省因为 malloc 返回的内存内容是随机的不置空的话头结点 next 就是个野地址遍历链表时第一次判断就翻车。所有“初始化”类代码目的都是给结构体成员一个确定的初始值链表也不例外。3.2 头插与尾插两种建表方式的取舍头插法也叫前插法代码很简洁void list_insert_head(Node *head, int data) { Node *new_node create_node(data); if (new_node NULL) return; new_node-next head-next; head-next new_node; }核心就两步新节点先指向原来第一个有效节点然后头结点指向新节点。注意顺序必须是“先搭上新节点和后继的线再改头结点的线”。如果反过来先让head-next new_node原来的第一个节点就丢了再也找不回来。尾插法稍微麻烦一点要找到链表当前最后一个节点void list_insert_tail(Node *head, int data) { Node *new_node create_node(data); if (new_node NULL) return; Node *p head; while (p-next ! NULL) { p p-next; } p-next new_node; }这个 while 循环就是“顺着指针走到队伍末尾”的过程。用尾插法输入一串数据链表顺序就是输入顺序用头插法则正好相反输入 1 2 3链表实际是 3 2 1。很多题目要求“按输入顺序建立单链表”那就得用尾插。尾插每次都要从头走到尾建一个 n 节点链表的时间复杂度是 O(n²)。想优化的话可以额外维护一个尾指针 tail每次插入时直接挂在 tail 后面再更新 tail这样能降到 O(n)。不过那个属于进阶优化先把基础版本写明白再说。3.3 指定位置插入先找到前驱节点再动手这是初学者最容易晕的一个操作。难点有两个位置下标从 0 还是从 1 开始怎么找到正确的插入位置。我统一按“从 0 开始”讲这和数组下标习惯一致。位置pos表示新节点最终变成第pos个有效节点比如pos 0就插在最前面pos length就插在末尾。int list_insert_pos(Node *head, int pos, int data) { if (pos 0) return 0; Node *p head; // 从头结点开始找前驱 int i 0; while (i pos p-next ! NULL) { p p-next; i; } if (i ! pos) return 0; // 位置超长 Node *new_node create_node(data); if (new_node NULL) return 0; new_node-next p-next; p-next new_node; return 1; }核心思想想要在第 pos 个有效节点前插入需要找到它的前驱节点。“前驱”就是链表中它前面那个节点。如果 pos 是 0前驱就是头结点如果 pos 是 length要找的前驱是当前最后一个节点插入之后新节点成为新的末尾。找到前驱之后依然是“先搭后线再改前线”的插入套路。这段代码里 while 循环的终止条件写了p-next ! NULL是为了防止 pos 超大时一直往下走走到 NULL 才停。循环结束后如果i ! pos说明链表没那么长这个位置非法。不带头结点的时候这个插入函数的麻烦事就来了当 pos 等于 0 时你需要修改的是头指针本身参数就得传Node **head逻辑要单独分一支处理。你看带头结点的统一性优势在这里体现得特别明显。3.4 删除节点先稳住后继再释放内存删除按位置删和按值删两种本质是一样的找到目标节点的前驱让前驱跳过目标节点然后把目标节点 free 掉。按位置删除代码int list_delete_by_pos(Node *head, int pos) { if (pos 0 || head-next NULL) return 0; Node *p head; int i 0; while (i pos p-next ! NULL) { p p-next; i; } if (p-next NULL) return 0; // 当前位置没有节点可删 Node *del p-next; p-next del-next; free(del); return 1; }这代码的要点是先判断链表是否为空。空链表head-next NULL没有节点可删直接返回 0。然后找前驱逻辑和插入类似。找到之后del p-next就是要删除的节点p-next del-next相当于让前驱绕过它最后free(del)释放内存。顺序上必须先让前驱的 next 指向后继再 free。因为 free 之后 del 里的 next 成员虽然还能读但那是未定义行为不能依赖它。删除链表中间节点的时间复杂度是 O(n)因为找前驱需要遍历但一旦找到前驱改指针和释放内存都是 O(1)。这也就是链表“删除效率高”这句话成立的前提你得已经知道前驱在哪。3.5 查找、修改、遍历与长度日常四件套这四个操作写起来都不难但它们最能检验你对 next 的理解。查找按值返回第一个匹配节点Node *list_find(Node *head, int data) { Node *p head-next; while (p ! NULL p-data ! data) { p p-next; } return p; // 没找到时返回 NULL }遍历打印的思路完全一样一个 while 循环从头走到尾。注意判断条件如果写成while (p-next ! NULL)那么循环体里能处理的是除最后一个节点外的所有节点最后一个节点会被漏掉。遍历和查找通常要对所有节点都操作一遍所以判断应该用p ! NULL。链表长度我用一个 length 函数或者维护一个计数器都可以。函数方式每次 O(n)维护计数器需要所有插入删除操作都同步更新容易漏。练习阶段建议用函数后面做工程再想优化的事。修改操作最隐蔽但也很简单先 find 再改 data 就行。找到节点后直接改不需要任何指针操作。这个 조작在小项目里很少单独写一个函数都是查到了就顺手改。3.6 清空与销毁不能只 free 一个头结点清空是释放全部有效节点但保留头结点。销毁是连头结点一起释放最终让外部头指针变成 NULL。清空代码void list_clear(Node *head) { Node *p head-next; while (p ! NULL) { Node *tmp p; p p-next; free(tmp); } head-next NULL; }这里的顺序非常关键。必须在 free 之前先把p-next保存到 p 变量里。如果先free(p)再去p p-next下一次循环就会访问到已经释放的内存属于经典的 use-after-free。这个错误几乎每个写过链表的人都踩过而且这种 bug 不一定会立刻崩溃但表现极其诡异。销毁函数void list_destroy(Node **head) { if (head NULL || *head NULL) return; list_clear(*head); free(*head); *head NULL; }不带头结点的清空销毁其实逻辑差不多但因为头指针指向的是第一个有效节点清空后要把*head NULL细节上要更小心。另外提醒一句销毁之后主程序里对 head 的再次使用都会崩所以别忘了在调用处给 head 置 NULL。4. 进阶玩法逆序、排序、合并与循环链表4.1 链表逆序三指针迭代把箭头全部调头链表逆序是个经典面试题思路说穿了一点都不难从头到尾遍历把每个节点的 next 从“指向后面”改成“指向前一个”。因为改完之后原来的后继找不到了所以需要一个指针提前保存下一个节点。这就是三指针prev、cur、next的由来。void list_reverse(Node *head) { Node *prev NULL; Node *cur head-next; while (cur ! NULL) { Node *next cur-next; cur-next prev; prev cur; cur next; } head-next prev; }自己画图验证一下开始时 prev 为 NULLcur 指向第一个有效节点。第一次循环里第一个节点的 next 改成 NULL它就变成新链表的尾巴了。接下来 prev 前移变成第一个节点cur 变成原来的第二个节点。一直走下去最后一个节点处理完之后 cur 变成 NULL此时 prev 停在原链表的最后一个节点也就是新链表的第一个有效节点。最后让头结点的 next 指向 prev完成连接。也可以用头插法重新建表实现逆序思路是通过不断取原链表的头节点用头插法插到新链表上。两种都行我推荐先掌握三指针法因为它能帮你更深理解指针是怎么沿着链表移动的。4.2 链表排序冒泡思路下交换 data 比交换节点省心很多人刚学完数组冒泡排序就急着给链表排序。如果用“交换相邻节点的指针”去实现冒泡单链表会特别痛苦因为交换两个相邻节点需要同时改三个指针还要考虑头尾边界。初学者十有八九写着写着把自己绕进去。更聪明的是交换 data 值。反正链表节点里存的就是数据交换数据不改变链表结构只需要两个指针在前驱、后继之间照常移动。冒泡排序的代码如下void list_sort(Node *head) { if (head-next NULL) return; int len list_length(head); for (int i 0; i len - 1; i) { Node *p head-next; for (int j 0; j len - 1 - i; j) { Node *q p-next; if (p-data q-data) { int temp p-data; p-data q-data; q-data temp; } p p-next; } } }交换 data 的代价是只适合 int、double 这类轻量数据类型。如果节点的 data 是重量级结构体每次交换都要整体拷贝效率就很差。那时候就得学真正的节点交换或者链表归并排序了。但作为入门练习这个版本逻辑清晰非常值得先写一遍。单链表排序的更优方案其实是归并排序时间复杂度 O(n log n)不需要额外空间这在很多算法面试里都考过。如果已经能把插入删除写熟建议去挑战一下链表的归并排序。4.3 合并两个有序链表复用节点不申请新内存合并两个升序链表是另一个高频面试题。最朴素的方式是新建一个链表不断比较两个旧链表的头节点把较小的 data 复制到新节点里。这样做需要申请 nm 个新节点挺浪费。更地道的做法是复刻归并思路直接复用旧节点只用指针把它们串起来。Node *list_merge(Node *La, Node *Lb) { Node *Lc list_init(); Node *pa La-next; Node *pb Lb-next; Node *tail Lc; while (pa ! NULL pb ! NULL) { if (pa-data pb-data) { tail-next pa; pa pa-next; } else { tail-next pb; pb pb-next; } tail tail-next; } tail-next (pa ! NULL) ? pa : pb; La-next NULL; Lb-next NULL; return Lc; }这段代码的巧妙之处在于没有 malloc 任何新节点只是把两个旧链表里的节点重新串起来。要留意的是合并完成后 La 和 Lb 这两个头结点还在但它们原来的所有有效节点已经被拆走了所以要把La-next和Lb-next都置空避免它们仍然指向已归属 Lc 的节点造成误操作。如果你不想破坏原链表那才需要走“复制节点”的路线。4.4 循环单链表绕圈之后判空条件就变了循环单链表就是把尾节点的 next 从 NULL 改成指向头结点带头结点而言。这一改遍历条件就全变了不能再靠p NULL判断结束而要靠p head判断是否绕回到头结点。void list_traverse_circle(Node *head) { Node *p head-next; while (p ! head) { printf(%d , p-data); p p-next; } printf(\n); }注意如果链表为空时head-next head这个遍历函数会直接不进入循环结果正确。循环链表特别适合处理需要循环轮询的场景比如操作系统的进程调度、约瑟夫问题。约瑟夫问题用循环链表解决非常自然一圈圈报数数到的人删除节点然后从下一个继续数本质上就是“遍历到某一位置删除节点”的循环应用。写循环链表最怕什么死循环。因为判断条件不再是 NULL一旦指针跳过 head 没停住就会无限转圈。调试时可以先加一个计数器限制次数比如最多走 100 步就停防止程序卡死。还有删除操作删的是尾节点时要让它的前驱直接指向 head形成新环。这些细节都要动手写一遍才能记住。5. 常见问题与排查技巧实录5.1 段错误十次有八次是指针没初始化或没判空段错误在链表练习里如同家常便饭。最常见的原因有三个结构体指针声明了却没赋初值malloc 失败后没判断操作前没检查链表是否为空。比如Node *head; // 野指针 list_insert_head(head, 1); // 直接崩正确写法是Node *head NULL;或者先调用list_init()。还有一个隐藏很深的场景删除一个节点后没把外部指向它的指针置空后续再用这个指针访问节点数据它可能读到一片随机内存。解决套路很朴素每次声明指针就初始化每次操作链表先判空每次 free 完就置 NULL。这三个习惯养成之后段错误出现的频率会直线下降。5.2 死循环循环条件写错到底长什么样链表死循环多是循环条件或循环步进写错。最典型的是遍历时写了while (p-next ! NULL)但循环体里没有更新 p于是卡在同一个节点上。另一个典型是修改指针顺序错误形成环比如插入时先改了head-next导致链表从某处成环遍历永远走不完。出现死循环时先用上一小节说的“计数器限步”技巧把循环改成最多跑 100 次打印每次访问的节点地址。如果打印出来的地址来回重复说明链表成环了如果地址固定不变说明循环步进丢了。排查死循环不要盯着代码空想直接把节点地址打出来看效率会高很多。5.3 内存泄漏malloc 和 free 没配对程序迟早出事内存泄漏不像段错误那么刺眼它不会立刻让你看到崩溃而是让程序内存占用一点点涨上去。给你的链表写一个“创建 10 万个节点再删除 10 万个节点”的测试配合 valgrind 检查是特别好的实验。在 Linux 下用valgrind --leak-checkfull ./a.out它会清晰地告诉你哪些 malloc 没有配对 free。Windows 下可以用 Visual Studio 的 CRT 内存泄漏检测也可以就把代码写成反复创建销毁一千万次然后看任务管理器内存曲线涨上去不降下来的基本就是漏了。链表实现里最典型的泄漏是只删除了一个指向节点的指针却没 free 对应的节点或者 clear 的时候没把整个链表走完只 free 了头结点。记住删除操作的唯一标准是“每个 malloc 出来的节点都有对应的一次 free”。5.4 调链表的三板斧打印、画图、看指针值我见过很多学生对着链表代码发懵其实不是逻辑不懂是缺少调试工具。第一个工具是打印函数把链表从头到尾打印一遍每次操作后都打印立刻就能看到结果对不对。第二个工具是纸和笔我曾经在所有链表题目上都坚持画图画节点方块画箭头演示每一步操作。画过一次链表逆序胜读十遍书。第三个工具是调试器比如 gdb 里p head-next、p head-next-next直接看指针值。你也可以在代码里临时加一行printf(%p\n, p);打印节点地址看指针移动是否符合预期。还有一个妙招写一个辅助函数把整个链表以地址[data] - 地址[data] - NULL的格式打出来。大量排查工作都可以靠它解决以后所有链表题目都能复用这个函数。6. 学习建议与后续扩展别急着写花活6.1 先闭嘴手写一百遍再谈优化单链表能不能真的掌握最大的分水岭不是能不能看懂而是能不能合上书写出全部代码。我的亲身体验是刚开始抄了好几遍插入删除感觉懂了结果合上书一写还是错。后来我给自己定了个规矩每天默写一遍单链表的全部基础操作连着写一周之后在任何场合写链表都跟喝水一样自然。这个笨办法非常有效。不要一开始就去追求循环链表、双向链表、跳表这些花活先把带头结点的单链表从 init 到 destroy 的整个生命周期写顺。能不用参考代码一次写出无编译错误、无逻辑错误的全套函数才算过关。6.2 从单链表出发还能往哪些方向走单链表是后续一堆数据结构的基石。双向链表就是每个节点多一个 prev 指针删除时可以不用找前驱循环链表解决轮询和约瑟夫问题内核里的侵入式链表把 next 指针直接嵌进业务结构体避免了 void* 强转的类型负担。学会了单链表你再去看 LRU 缓存、哈希表拉链法、图的邻接表都会轻松不少。我个人的建议是趁热打铁把带头结点的双向循环链表写一遍再把二叉树的先序、中序、后序遍历用递归和非递归各写一遍这套组合练下来指针和递归这两个老大难基本就同时拿下了。单链表是数据结构的起点也是锻炼 C 语言指针、内存管理、逻辑拆解能力最好的练兵场。你今天花在这上面的每一分钟后面学树、图、算法都会加倍还回来。别急一个节点一个节点地写一个指针一个指针地捋画图画到顺手代码写到肌肉记忆这条路走完了你会发现 C 语言里最唬人的指针其实就那么回事。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →