ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

数据结构三要素拆解:链表12个基础操作与代码详解

数据结构三要素拆解:链表12个基础操作与代码详解 看多了网上那种“数据结构三要素背下来链表操作背下来”的学习方法我想说一句实在话链表操作根本不是背代码的活。如果一开始就用数据结构三要素的视角去拆解你会发现链表那套“增删改查”其实是顺着逻辑自己长出来的。这篇文章我花点时间把《数据结构三要素解析 链表的12个基础操作代码详解》这个主题彻底展开结合我早年刚啃数据结构时踩过的坑把“既要懂原理又要能写代码”这条路走通。不管你是正准备期末的在校生还是想补内功的转行开发者这篇都值得从头到尾看完。1. 数据结构三要素先跳出来再钻进去很多人在学链表之前其实没弄明白“数据结构到底在学什么”。我在初学阶段也干过蠢事——拿着教材从头背到尾定义、特性、时间复杂度背得滚瓜烂熟结果一到写代码就抓瞎。后来一位算法方向的前辈点了我一句“你先把数据结构的三要素搞清楚你就知道每段代码在干嘛了。”1.1 逻辑结构数据之间的关系是什么三要素的第一条是逻辑结构。所谓逻辑结构就是数据元素之间存在什么关系。这个关系是抽象的不管你用数组、链表还是树形结构去实现逻辑关系本身是独立的。链表对应的逻辑结构是线性表它的特点是数据元素之间存在一对一的前驱后继关系。说得直白一点就是每个节点都有且只有一个直接前驱和一个直接后继头部节点没有前驱尾部节点没有后继。当初理解“逻辑结构”这个概念的时候我用了一个特别生活化的类比排队买奶茶。你前面那个人是你的直接前驱你后面那个人是你的直接后继队首没有前驱队尾没有后继。这个前后关系就是线性表的逻辑结构。至于大家是站在一条水泥地上的队伍里数组还是每个人手里拿着前一个人的衣角串成一串链表那是存储结构的事逻辑关系没变。1.2 存储结构关系如何落到内存三要素的第二条是存储结构也叫物理结构。它解决的是“逻辑关系在计算机里怎么表示”的问题。线性表有两种经典的存储结构顺序存储和链式存储。顺序存储就是数组逻辑相邻的元素在内存里也物理相邻。它的特点是“随机存取”——给一个下标就能直接算出地址访问第5个元素的代价和访问第5000个元素是一样的。但它的短板也很明显插入和删除要搬动大量元素而且需要一整块连续内存。链式存储就是链表。它允许元素在内存里东一个西一个每个节点除了存数据还要存一个指针指向下一个节点的地址。这样逻辑相邻的元素不需要物理相邻靠指针就能串起来。付出的代价是不能“随机存取”想找第5个节点必须从头往后一个个跳。两者对比下来一句话就能说清数组是“物理相邻换随机访问”链表是“物理离散换灵活增删”。1.3 数据运算一切操作的入口定义三要素的第三条是数据运算也就是针对这个结构我们要支持哪些操作。在。在教材层面运算包括建立、销毁、查找、插入、删除、修改、遍历、求长度、判空这些基本动作。学习的关键在于这些运算不是孤立的背代码它们全部建立在前两个要素之上。比如“插入”这个运算在数组里是先移动元素再放入在链表里是改两个指针——为什么不一样就是因为存储结构不同。搞清楚了存储结构运算方式就是顺理成章的事。我经常遇到读者问我“为什么链表插入的时间复杂度是O(1)但实际写代码好像还要先查找到那个位置”这个问题问得很好。要解释清楚就必须区分两种操作如果你已经有了当前节点的指针那插入一个新节点确实是O(1)但如果你只有位置序号第一步还要从头遍历找节点遍历是O(n)所以“按位置插入”的总代价是O(n)。这就是数据运算和逻辑结构交织在一起的典型案例。有了这三要素的框架再看链表的所有基础操作你会发现每一段代码都有它的“因”。下面我把整个链表操作的代码工程拆开来讲所有代码均基于C语言这是数据结构教材最常见也最便于理解指针操作的载体。2. 链表操作前的三道基础题结构体设计、头结点选择、公共约定在动手写12个基础操作之前必须先做三件事。很多初学者代码写了半天编译不过或者各种段错误90%的问题出在这三个前置选择上。2.1 结构体的定义与头结点之争C语言里单链表节点的定义是这样的// 单链表节点定义 typedef struct LNode { int data; // 数据域这里以int为例 struct LNode *next; // 指针域指向下一个节点 } LNode; // 使用别名简化声明LNode* 表示指向节点的指针这段代码本身没有太多悬念。真正的分歧在“要不要头结点”这个问题上。头结点注意不是头指针是链表第一个节点之前额外增加的一个节点。它不存有效数据placeholder只是为了统一操作逻辑。带头结点的链表头指针永远指向那个哨兵节点不带头结点的链表头指针直接指向第一个数据节点。我从实际工程经验和考试两个角度看都建议带头结点。理由很简单带头结点之后在头部插入和删除就不需要特殊对待了整个链表的所有插入删除逻辑可以完全统一。这对代码的健壮性和可读性都有很大提升。2.2 12个基础操作怎么选出来的既然标题叫“12个基础操作”我先列个清单。对单链表来说真正“基础”的操作围绕增删改查四条线展开初始化链表创建带头结点的空链表判断链表是否为空求链表长度按位查找找第i个节点按值查找找第一个值等于x的节点头插法插入节点尾插法插入节点指定位置插入节点按位删除节点按值删除节点修改指定位置节点的值遍历打印链表这12个操作覆盖了一个基本线性表的所有日常需求。你会发现它们之间有清晰的依赖关系查找是后面插入删除的基础长度和判空是通用工具头尾插入是构造链表的两种主要方式。先掌握它们后面的逆转、排序、合并等进阶操作才能顺手。2.3 每个操作都要遵守的公共约定在写任何一个操作之前先定下约定。不然12个函数各有各的返回值风格写着写着就乱了。我的约定如下函数内部操作失败返回0或-1成功返回1布尔语义。查找类的函数找到返回节点指针找不到返回NULL。传入的头指针如果为NULL直接返回失败或NULL不继续执行。位置参数从1开始计数这是教材和大多数考试的统一规则避免和数组下标混淆。这些约定看起来琐碎但它保证了12个函数在组合调用的时候逻辑一致。比如“在第i个位置插入”内部会先调“按位查找第i-1个节点”两者通过位置语义保持一致就很少出错。3. 增删改查四类基础操作的代码拆解前置工作做完开始写核心代码。这一节是全文的重头戏我会把12个操作按依赖关系层层拆开。每一段代码都带注释注释里写清“为什么这么做”。3.1 按位查找与按值查找一切操作的地基查找操作是整个链表的基石。插入和删除的前置步骤基本都是“找到目标节点的前驱”。按位查找的代码// 按位查找返回第i个节点的指针 // 约定i从1开始计数不包括头结点 LNode* GetElem(LNode L, int i) { if (i 1) { return NULL; // 位置不合法 } LNode *p L; // p指向当前扫描到的节点从第0个头结点开始 int j 0; // j记录p是第几个节点 while (p ! NULL j i) { p p-next; j; } // 如果p为NULL说明i超出了链表长度否则p就是第i个节点 return p; }这里有个细节值得说透为什么从第0个头结点而不是第1个节点开始跳因为查找第i个节点需要移动i次。带头结点的情况下从head出发走i步正好停在第i个节点上。这个“偏移量”思维是整个链表操作最核心的数学基础。按值查找的代码// 按值查找返回第一个值为x的节点指针 LNode* LocateElem(LNode L, int x) { LNode *p L-next; // 从第一个数据节点开始 while (p ! NULL) { if (p-data x) { return p; // 找到返回指针 } p p-next; } return NULL; // 没找到 }这段代码没有什么高深技巧但注意一点遍历时不要把p移动完再判断要边移动边判断边退出。习惯了循环写全量的同学很容易把结束条件写成遍历完整个链表白白浪费CPU还丢失了“第一个匹配项”的语义。3.2 头插法、尾插法与指定位置插入三个层次的插入插入操作是链表灵活性的集中体现。我按“头、尾、指定位置”三个层次来讲。头插法在头结点后面直接插入新节点// 头插法在头结点后插入新节点插到链表最前面 int HeadInsert(LNode *L, int x) { LNode *s (LNode*)malloc(sizeof(LNode)); // 创建新节点 if (s NULL) { return 0; // 内存分配失败 } s-data x; s-next L-next; // 新节点指向原来的第一个数据节点 L-next s; // 头结点指向新节点 return 1; }这段代码的讲解难点在于两行指针操作的顺序。很多人初学时容易写成“先把L-next赋给s-next再让L-next指向s”——其实顺序是反的。这里有个口诀先接后面的线再断前面的线。先让新节点的next指向原第一个节点再让头结点的next指向新节点顺序绝对不能反。如果先执行L-next s原来的第一个节点就丢了整条链表从中断开。尾插法在链表尾部接上新节点// 尾插法在链表末尾插入新节点 int TailInsert(LNode *L, int x) { LNode *s (LNode*)malloc(sizeof(LNode)); if (s NULL) { return 0; } s-data x; s-next NULL; // 找到当前尾节点 LNode *p L; while (p-next ! NULL) { p p-next; } p-next s; // 尾节点指向新节点新节点成为新的尾节点 return 1; }尾插法的时间复杂度是O(n)因为每次都要从头遍历到尾。大量使用尾插法构造链表时更高效的做法是设置一个尾指针。实际构造链表时也常常让用户自定义建表方式这里为了操作展示保持简单没有优化成尾指针形式如果你要写一个高频插入的模块务必自己加尾巴指针。指定位置插入这算是插入操作的综合应用了// 指定位置插入在第i个位置插入新节点i从1开始 int InsertAt(LNode *L, int i, int x) { LNode *p GetElem(L, i - 1); // 找到第i-1个节点它是新节点的前驱 if (p NULL) { return 0; // 前驱不存在位置非法 } LNode *s (LNode*)malloc(sizeof(LNode)); if (s NULL) { return 0; } s-data x; s-next p-next; // 先接后面的线 p-next s; // 再断前面的线 return 1; }这个操作把“按位查找”和“插入”组合起来了。注意细节调用GetElem找的是第i-1个节点。为什么不是第i个因为插入的本质是在第i-1个节点和第i个节点之间塞一个节点你手里需要有前驱节点的指针。这个“找前驱”的思维后边的删除操作也会用到属于链表操作里最关键的思维模式之一。3.3 按位删除与按值删除先接后断的完整应用删除操作的代码量不大但它是考察指针操作理解程度的试金石。按位删除// 按位删除删除第i个节点并用e返回其数据 int DeleteAt(LNode *L, int i, int *e) { if (i 1) { return 0; } LNode *p GetElem(L, i - 1); // 找到被删节点的前驱 if (p NULL || p-next NULL) { return 0; // 前驱不存在或没有第i个节点 } LNode *q p-next; // q指向被删除的节点 *e q-data; // 带回数据 p-next q-next; // 前驱直接跨过q连到q的下一个节点 free(q); // 释放被删除节点的内存 return 1; }这里同样遵循“先接后断”的原则但删操作里是先断前面的线p-next q-next再释放节点。注意一个小细节调用GetElem找第i-1个节点找到后还要再确认p-next ! NULL因为有可能i比链表长度大1此时p不是NULL但p-next是NULL表示没有第i个节点删无可删。按值删除// 按值删除删除第一个值等于x的节点 int DeleteByValue(LNode *L, int x) { LNode *p L; // p用于扫描前驱 while (p-next ! NULL) { if (p-next-data x) { LNode *q p-next; p-next q-next; free(q); return 1; // 删除成功 } p p-next; } return 0; // 没有找到值为x的节点 }按值删除的扫描方式稍微有点特殊我让p停在被删节点的前驱位置判断p-next-data是否等于x。这样一旦找到不需要再额外找前驱直接操作p就能完成删除。这个“让指针停在当前位置用p-next来操作”的写法在链表中非常实用需要熟到形成肌肉记忆。3.4 遍历打印、求长度、判空与修改基础操作里的日常工具剩下几个操作属于“工具型操作”虽然代码短但使用频率极高我没法不把它们收进12个操作里。遍历打印// 遍历打印链表所有节点的值 void PrintList(LNode *L) { LNode *p L-next; while (p ! NULL) { printf(%d , p-data); p p-next; } printf(\n); }求链表长度// 求链表长度不含头结点 int Length(LNode *L) { int cnt 0; LNode *p L-next; while (p ! NULL) { cnt; p p-next; } return cnt; }判空与初始化// 初始化链表创建带头结点的空链表 LNode* InitList() { LNode *L (LNode*)malloc(sizeof(LNode)); if (L NULL) { return NULL; } L-next NULL; return L; } // 判断链表是否为空 int IsEmpty(LNode *L) { return L-next NULL; }修改节点值// 修改第i个节点的值为新值 int ModifyAt(LNode *L, int i, int newVal) { LNode *p GetElem(L, i); if (p NULL) { return 0; } p-data newVal; return 1; }到这里12个操作全部覆盖。你会发现这些代码之间几乎没有“新东西”除了查找之外增删改全是三两行指针操作。这正是链表的特性逻辑简单但边界极其容易出错。所以下面这个章节才是真正的考验场。4. 链表的进阶操作逆转、排序、合并、去重、环检测标题虽然写的是“12个基础操作”但只掌握12个基础操作只能应付教材上的练习题。面试和实际生产中链表的高频考察点几乎全部落在进阶操作上。这些操作也正好是检验你是否真正理解了“指针指向变化”的最好试金石。4.1 链表逆转迭代三指针法链表的逆转说白了就是把每个节点的next指向前一个节点。难点在于当你把一个节点的next改掉之后原来的下一个节点就找不到了所以需要一个指针提前保存它。// 单链表原地逆转带头结点 void ReverseList(LNode *L) { LNode *prev NULL; // 前驱初始为空逆转后原来的头节点变尾指向NULL LNode *cur L-next; // 从第一个数据节点开始 while (cur ! NULL) { LNode *next cur-next; // 先保存下一个节点防止丢失 cur-next prev; // 翻转指针方向 prev cur; // 前驱前移 cur next; // 当前节点前移 } L-next prev; // 最后一个prev就是逆转后的第一个节点 }这个三指针法是链表逆转的标准解法记忆时可以想象成“三个手指头交替往前挪”。还有递归解法但在链表长度较大时递归有栈溢出风险实战中我基本都用三指针法。4.2 两个有序链表的合并归并思想从这入门合并两个升序链表输出还是升序这是归并排序在链表上的一个前置操作。实现思路很简单两个指针分别指向两个链表头部谁小谁先接入新链表然后对应指针后移。// 合并两个升序链表返回新链表的头指针不破坏原链表 LNode* MergeSortedList(LNode *L1, LNode *L2) { LNode *dummy (LNode*)malloc(sizeof(LNode)); // 新链表带头结点 dummy-next NULL; LNode *tail dummy; // 尾指针接新节点用 LNode *p L1-next; LNode *q L2-next; while (p ! NULL q ! NULL) { if (p-data q-data) { tail-next p; p p-next; } else { tail-next q; q q-next; } tail tail-next; } // 把剩余部分整体接上 tail-next (p ! NULL) ? p : q; return dummy; }这里为什么允许直接接上剩余部分因为合并操作只是重新组织节点之间的next关系每个节点本身的内存没变。剩余的那个链表片段天然有序直接挂上去即可。这个“整体接入”的思路比逐节点拷贝高效得多。4.3 链表排序冒泡与归并的选择如果想对一个无序链表排序最省事的是冒泡排序。虽然时间复杂度是O(n^2)但胜在代码简单、空间复杂度为O(1)适合链表这种不支持随机访问的结构。// 链表冒泡排序只交换数据域不修改指针关系 void BubbleSortList(LNode *L) { if (L NULL || L-next NULL) { return; } int swapped; LNode *p; LNode *end NULL; // 指向已排序部分的尾部标记 do { swapped 0; p L-next; while (p-next ! end) { if (p-data p-next-data) { // 交换两个节点的数据域 int tmp p-data; p-data p-next-data; p-next-data tmp; swapped 1; } p p-next; } end p; // 上一层循环结束时p指向无序区最后一个节点下一轮无序区缩短 } while (swapped); }注意我选择的是“只交换数据不改指针”的方案。为什么不移动节点本身因为移动节点需要维护前驱关系涉及大量细节而数据交换在单链表里实现极其简单。如果数据域体积大比如每个节点存一个几百字节的结构体那交换数据的代价就很高不如改成指针移动的排序。实际项目中根据场景取舍。追求更优性能的话链表归并排序是更好的选择它可以做到O(n log n)且空间复杂度为O(log n)递归栈。这里不展开完整的归并排序代码上面的MergeSortedList已经是它的核心组件。4.4 无序链表去重哈希表与双重循环无序链表去重最直观的做法是双重循环外层遍历每一个节点内层扫描它后面的所有节点删除重复值。// 无序链表去重双重循环版空间复杂度O(1) void RemoveDuplicate(LNode *L) { LNode *outer L-next; while (outer ! NULL) { LNode *prev outer; // prev是内层扫描时“外层节点之后”部分的前驱 LNode *inner outer-next; while (inner ! NULL) { if (inner-data outer-data) { // 删除inner节点 prev-next inner-next; free(inner); inner prev-next; // 注意删除后inner要重新接到prev的后面 } else { prev inner; inner inner-next; } } outer outer-next; } }这个代码的陷阱在于删除节点后的指针移动。删除inner后原来的inner已经被释放不能再访问inner-next所以要把inner重新赋值为prev-next。这种“删完当前节点后指针怎么挪”的问题在链表删除类题目里反复出现值得单独标注。如果允许额外空间用一个哈希表记录出现过的值可以让时间复杂度从O(n^2)降到O(n)。工程上数据量大时优先考虑哈希表方案但考场上如果明确限制空间双重循环才是标准答案。4.5 快慢指针检测环为什么慢指针一定能追上检测链表是否有环经典解法是快慢指针。快指针一次走两步慢指针一次走一步。如果链表有环快指针一定会最终追上慢指针如果没有环快指针先走到尾部NULL。// 检测链表是否有环有环返回1无环返回0 int HasCycle(LNode *L) { LNode *fast L; LNode *slow L; while (fast ! NULL fast-next ! NULL) { fast fast-next-next; // 快指针走两步 slow slow-next; // 慢指针走一步 if (fast slow) { return 1; // 相遇说明有环 } } return 0; // 快指针到达尾部说明无环 }为什么快指针一定能追上慢指针直观解释是进入环之后快指针在和慢指针的速度差是每单位时间一个节点而环的长度是有限的快指针相对慢指针每次缩短1个节点距离最多走一圈多就能追上。这不是什么玄学而是一个简单的追及问题。如果进一步要求找出环的入口节点可以这样操作快慢相遇后把快指针重新指向头结点然后把两个指针都改成一次走一步再次相遇的位置就是环的入口。这个证明涉及一点数学推导感兴趣的话可以自己证明一下结论非常优雅。4.6 求链表的中间节点奇数偶数的统一解法求中间节点朴素做法是先遍历一遍求长度再走一半。快慢指针可以一趟解决快指针到尾部时慢指针恰好在中点。// 求链表的中间节点偶数个节点时返回后一个中间节点 LNode* FindMiddle(LNode *L) { LNode *fast L-next; LNode *slow L-next; while (fast ! NULL fast-next ! NULL) { fast fast-next-next; slow slow-next; } return slow; }这里有一个细节如果链表有偶数个节点例如4个快指针走两步到达第3个节点慢指针在第2个再走两步快指针变成NULL慢指针在第3个。也就是说返回的是“后一个中间节点”。如果你需要偶数时返回前一个只需要把循环条件改成fast-next ! NULL fast-next-next ! NULL。这种细微的语义差异考试中经常被拿来考自己实现时也要根据需求明确。5. 链表调试的真实踩坑记录代码能跑通是第一步能调试才是真本事。链表这种结构一旦出问题经常是段错误或者死循环肉眼很难看出来。这一节我把我自己实际写链表代码时踩过的坑整理出来希望能帮你少走弯路。5.1 空指针与野指针段错误的头号元凶我早期写的链表代码里段错误绝大多数源于空指针或野指针访问。典型场景是查找函数返回NULL但调用方没有判断就直接访问p-data。// 错误示范没有判断GetElem返回值 LNode *p GetElem(L, 5); p-data 100; // 如果第5个节点不存在这里直接段错误预防措施是约定公共接口必须检查返回值但这个约定光写在文档里没用要在代码层面强约束。我的习惯是任何从函数返回的指针在解引用之前先问一句“它为NULL时应该怎么办”。不能笃定“这个函数一定能返回非NULL”链表的节点本来就会被合法地删空。5.2 指针移动顺序错了先接后断的教训插入操作中如果先执行了“前驱指向新节点”的操作原链表就断了。这个过程我初学时的记忆方法就是一句口诀“先接后面的线再断前面的线。”所有插入、删除、逆转操作本质上都在调整线头的连接顺序顺序一错链表结构就会被破坏。举个具体的例子第i个位置插入时如果写反// 错误示范顺序反了 p-next s; // p原来指向的后继节点被丢弃 s-next p-next; // 此时p-next已经是s了s指向自己形成环这样不仅丢掉了原链表的一部分还会让新节点指向自己产生一个环。这种错误的后果比段错误还隐蔽因为你可能不会立刻崩溃而是在遍历时陷入死循环。5.3 边界条件的检测套路链表的边界条件总结起来就三种空链表头结点下一个节点就是NULL。只含一个节点第一个节点操作完直接变空。操作位置在头尾头部插入删除和尾部插入删除是否还能正常走通。我调试链表代码时有一个固定仪式找一个最简单的数据比如3个节点的链表手动把插入、删除、查找、逆转全部走一遍。只要3个节点的场景全部正确90%的边界问题都能暴露出来。不要一上来就测几千个节点的大数据调试成本太高问题也不容易定位。5.4 调试链表的三板斧第一板斧是打印法。在每个关键步骤后打印链表内容和节点地址观察指针关系是否符合预期。具体可以打印出每个节点的值和指向下一个节点的地址。第二板斧是画图法。拿一张纸把链表节点画出来模拟指针的移动和修改。这个办法看起来笨但对理解“先接后断”的本质特别有效。说句实话我现在遇到复杂的链表问题还是会用纸笔画一画。第三板斧是画图加断言。C语言里可以加assert(p ! NULL)等断言在调试版里强制检查指针状态。不过断言只在调试期有效发布版最好关掉量产代码里还是要靠手动检查返回值。最后分享一个我自己总结的调试思想写链表代码不要图快要一个函数一个函数地验证每写一个操作就用最标准的数据测一遍。很多初学者喜欢照着网上的代码一次性抄完十几个函数然后一起编译一起跑结果报错之后完全不知道从哪里查。链表这种结构它的调试必须“一次只动一个地方”把每个操作的可靠性攒起来最后组合使用时才不会翻车。希望这篇拆解能帮你把数据结构三要素落到实处把链表的每一个操作都变成自然而然的选择而不是死记硬背的代码片段。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表