ARTICLE DETAIL

资讯详情

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

C语言链表操作全解析:从单链表到循环链表与逆序实现

C语言链表操作全解析:从单链表到循环链表与逆序实现 写链表操作之前我想先说点实在的如果你在准备数据结构考试、复试机考或者在刷算法题链表几乎是你绕不开的第一个坎。它比数组多了一层指针跳转很多新手写代码时感觉“每一步都明白一运行就崩”。这篇内容把所有常见操作整理成一套可以直接参考的做法从结构体定义到单链表建立、遍历、插入、删除、清空再到循环链表、双链表、逆序最后附上我在实训和笔试里常用的排查思路希望能帮你少走点弯路。1. 链表到底是什么为什么要自己手写这些操作1.1 从数组的痛点说起数组的缺点是“连续内存”。你想在中间塞一个元素得把后面的元素全部往后挪时间复杂度是O(n)。更麻烦的是数组的容量在声明时就固定了想动态扩展得自己手动搬数据。链表就没有这个问题它每个节点可以散落在内存的任何位置靠着指针串成一条链插入和删除只需要改动指针指向不用搬动其他元素。用生活里的例子来想数组像电影院里连排的座位座位号固定有人要坐进中间后面的人全得站起来挪。链表像一列火车车厢和车厢之间靠挂钩连接。你要在第二节和第三节之间加一节车厢只需要解开挂钩、把新车厢挂进去、再连到后面那一节就行其他车厢根本不用动。链表的核心代价是为了获得这种灵活性你得额外存一个“指向下一节车厢”的指针。另外它不支持随机访问想找第5个节点必须从第一个节点开始往下数。这就是“时间换空间结构换灵活”的经典取舍。1.2 为什么教程反复强调“基本操作”你可以把链表想象成一堆积木创建、遍历、插入、删除、清空、逆序就是积木的基础拼法。实际项目里直接手写链表的场景不太多标准库里有list等封装好的容器但这不代表你不需要懂原理。原因有三第一考研、面试、机考链表是最常见的代码题。很多看似复杂的题目比如LRU缓存、约瑟夫环、反转区间链表底层全是这些基本操作的组合。第二封装好的容器内部就是链表你理解原理后看它的时间复杂度、内存占用、迭代器失效问题才能判断什么时候该用它什么时候该换数组。第三嵌入式、驱动、操作系统内核里链表依然被大量直接使用。比如Linux内核里的list_head结构就是一套非常讲究的双向循环链表。你如果只会调用STL容器到这些场景会直接傻眼。我自己带过不少实习生最常见的现象是用C的vector用得飞起一让写一个Node结构体就不知道头结点怎么表达了。所以这篇虽然内容基础但值得一步一步跟着敲一遍别眼高手低。2. 核心操作背后的思路拆解2.1 带头结点和不带头结点到底差在哪这个知识点是新手最容易困惑的地方。所谓头结点就是链表最前面那个不存数据的节点单纯为了操作方便。我举个实际区别不带头结点的链表第一个节点就是数据节点。往头部插入一个节点时你要把头指针本身改掉。在C语言里这意味着你需要用二级指针Node**或者让函数返回新的头指针否则头指针的修改传不出去外面依然指向旧头。带头结点的链表就省心多了头指针永远指向那个空数据节点插入新节点在头部时只需要改头结点的next头指针本身不用动。用生活类比头结点就像一个固定的门卫亭不管里面来多少人门卫亭的位置永远不变。不带头结点的话第一间屋子既是住人的又是门卫你要换第一间整个人口布局都得变。代码上最明显的差距在这里——不带头结点往头部插Node* insertAtHead(Node* head, int data) { Node* newNode (Node*)malloc(sizeof(Node)); newNode-data data; newNode-next head; // 新节点指向旧头 return newNode; // 必须把新头传出去 }带头结点往头部插void insertAtHead(Node* head, int data) { Node* newNode (Node*)malloc(sizeof(Node)); newNode-data data; newNode-next head-next; // 新节点指向原第一个数据节点 head-next newNode; // 头结点next指向新节点 }看出区别了吗带头结点的函数参数只需要Node*直接改内容就行。不带头结点的得返回值或者用二级指针。实训课上老师让你写“不带头结点的单链表”绝对不是为了刁难你是为了让你真正理解指针传参的机制。2.2 为什么循环单链表要单独拎出来讲普通单链表的尾节点next是NULL遍历到头就知道结束了。循环单链表把尾节点的next指回头结点整个链表转成一个环。这么做的意义在哪最直接的好处是从任意一个节点出发都能遍历到所有节点。普通链表如果丢了头指针整条链就断在内存里找不回来了循环链表从任意节点都能兜回来。经典应用是约瑟夫环问题一群人围成一圈报数报到某个数字的人出列然后从下一个人继续报。这个场景天然就是循环链表剩下的节点始终围成一个圈删除一个节点后自动衔接。如果你用普通链表每删除一个人都要从头重新走一遍逻辑别扭且容易错。另外一个容易被忽略的优点循环链表的尾插法非常快。普通单链表要在尾部插入需要从头遍历到最后一个节点O(n)。循环链表只要记录尾指针tailtail-next就是头插入新节点只需要操作tail和它后面那个头节点O(1)。2.3 双链表到底解决了什么问题单链表的指针只有一个方向从当前节点能知道下一个在哪但不知道上一个在哪。带来的直接后果是删除节点时除非你始终维护着前一个节点的指针否则删完当前节点就找不回前驱了。双链表每个节点多了一个prev指针指向前一个节点。这样从任何一个节点都能向前、向后走。代价是每个节点多占一个指针的内存插入删除时多操作一个指针。工程里用得更多的是双向链表。Windows内核的LIST_ENTRY、Linux内核的list_head都是双向的普通业务代码里的LinkedList绝大多数也是双向。原因是删除操作在双向链表里不需要从头找前驱O(1)就能完成而且很多场景需要倒序遍历双链表可以直接从尾节点往前扫。还有一个细节双链表删除当前节点时你只需要操作三个节点的指针当前节点的prev指向当前节点的next当前节点的next指向当前节点的prev然后释放当前节点。这在单链表里是不可能的单链表必须要知道前一个节点才能把前一个的next接到当前节点的next上。3. C语言实现全套基础操作3.1 结构体定义与创建单个节点C语言里链表节点通常用结构体定义。这是整个实验的地基我建议你闭着眼睛都能写出来#include stdio.h #include stdlib.h typedef struct Node { int data; // 数据域 struct Node* next; // 指针域指向下一个节点 } Node;注意这里有个经典易错点结构体内部引用自己必须写struct Node*不能直接写Node*因为typedef别名要等到结构体定义结束后才生效。C就没有这个问题但C里必须严格按上面的写法来。创建节点时我习惯单独抽一个函数方便后面重复调用Node* createNode(int data) { Node* newNode (Node*)malloc(sizeof(Node)); if (newNode NULL) { printf(内存分配失败\n); return NULL; } newNode-data data; newNode-next NULL; return newNode; }这里有一个很多教程不会强调的细节malloc之后一定检查返回值是否为NULL。新手写实验时内存分配基本不会失败但这个习惯在真实工程里能救命。嵌入式环境内存小分配失败很常见就算在PC上万一系统内存紧张你不检查就直接用NULL指针崩得毫无预兆。3.2 头插法和尾插法建立单链表建立单链表有两种主流方式头插法和尾插法。很多人第一次写都会搞混我帮你把逻辑彻底捋清。头插法每次新节点都插到最前面。这样后插入的节点会排在链表的前面也就是说输入顺序和链表顺序是相反的常用于逆序建立一个链表。逻辑非常简单Node* buildByHeadInsert(int arr[], int n) { Node* head NULL; // 不带头结点的写法 for (int i 0; i n; i) { Node* newNode createNode(arr[i]); newNode-next head; head newNode; } return head; }尾插法每次新节点都追加到链表的末尾。这样链表的顺序和输入顺序一致符合我们日常直觉。但需要注意尾插法必须维护一个尾指针否则每次插入都要从头遍历到末尾时间复杂度变成O(n^2)Node* buildByTailInsert(int arr[], int n) { Node* head NULL; Node* tail NULL; for (int i 0; i n; i) { Node* newNode createNode(arr[i]); if (head NULL) { head newNode; tail newNode; } else { tail-next newNode; tail newNode; } } return head; }尾指针的作用就是让尾插变成O(1)。每当新节点加入更新tail为新节点下次再加就直接用tail-next newNode不用从头扫描。如果要带头结点的尾插法代码会更简洁因为头结点让“第一次插入”这种特殊情况消失了Node* buildByTailInsertWithHead(int arr[], int n) { Node* head createNode(-1); // 头结点data可以随意 Node* tail head; for (int i 0; i n; i) { Node* newNode createNode(arr[i]); tail-next newNode; tail newNode; } return head; }这里是带头结点版本的核心优势无论链表是否为空tail始终存在不需要判断head NULL代码里少一条分支就少一个出错点。3.3 链表遍历遍历是理解链表结构的入口说白了就是从头到尾挨个访问。代码极简单void printList(Node* head) { Node* current head; while (current ! NULL) { printf(%d , current-data); current current-next; } printf(\n); }注意这里我用了一个current指针作为游标。为什么要额外定义一个指针而不是直接修改head因为head是整个链表的入口你如果直接head head-next遍历完这个链表就找不回头了后续有别的操作全部抓瞎。我亲眼看过同学图省事遍历完就用head操作结果链表只剩下最后一个节点前面全部丢失。链表是动态分配在堆上的丢了头指针那些节点就成了悬空的内存既没法访问也没法释放妥妥的内存泄漏。如果是带头结点的链表遍历要从head-next开始void printListWithHead(Node* head) { Node* current head-next; while (current ! NULL) { printf(%d , current-data); current current-next; } printf(\n); }这是很典型的差别面试官问“带头结点和不带头结点的遍历有什么不同”答案就在这一行。3.4 在指定位置插入节点在指定位置插入我认为是单链表里最重要的操作没有之一。很多题目绕来绕去最后都在考你的指针操作是否熟练。先理清需求给定一个链表要在第pos个位置插入一个值为data的新节点假设pos从1开始计数。第一步要找到第pos-1个节点也就是目标位置的前驱节点。然后执行新的两步操作int insertAtPos(Node* head, int pos, int data) { if (pos 1) return -1; Node* current head; int count 1; // 找到第pos-1个节点 while (current ! NULL count pos - 1) { current current-next; count; } if (current NULL) return -1; // 位置超出链表长度 Node* newNode createNode(data); newNode-next current-next; current-next newNode; return 0; }这段代码理解起来有两个关键点第一为什么找第pos-1个节点而不是第pos个因为单链表单向移动你要把新节点挂在第pos-1个节点后面就必须知道第pos-1个节点是谁。它就像列车的挂钩位置你必须在它那里解开和重挂。第二为什么要先执行newNode-next current-next再执行current-next newNode顺序不能反。如果先执行current-next newNode那原来第pos个节点就丢了因为current-next已经被覆盖成newNode你再也找不到原来那个节点。我用口诀教你记先接后断。新节点先指到正确的位置再去修改前驱的next。断线永远在接好后进行。如果是不带头结点的链表在第1个位置插入时要修改的是head指针本身所以要么用返回值要么用二级指针Node* insertAtPosNoHead(Node* head, int pos, int data) { if (pos 1) return head; Node* newNode createNode(data); if (pos 1) { newNode-next head; return newNode; } Node* current head; int count 1; while (current ! NULL count pos - 1) { current current-next; count; } if (current NULL) { free(newNode); return head; } newNode-next current-next; current-next newNode; return head; }这种“插入位置为头”的特殊分支就是带头结点版本帮你抹平的特殊情况。做实训题时如果题目没有特殊要求我强烈建议带头结点实现省心得多。3.5 删除指定节点删除操作和插入在思路上完全对称找到目标节点的前驱让前驱的next指向目标节点的next然后释放目标节点。int deleteByValue(Node* head, int value) { if (head NULL) return -1; Node* prev head; Node* current prev-next; // 如果头结点就是要删的值不带头结点的情况下 if (prev-data value) { Node* toDelete prev; head prev-next; free(toDelete); return 0; } while (current ! NULL current-data ! value) { prev current; current current-next; } if (current NULL) return -1; // 没找到 prev-next current-next; free(current); return 0; }这个函数返回int是为了向调用方传达结果0表示删除成功-1表示没找到。如果你只写void调用方想判断删除成不成功就没有办法。我在实训里见过很多同学用void写删除函数然后debug半天不知道是没找到还是删错了。注意free(current)之后一定不要再用current指针的任何内容。这是个特别容易犯的错尤其是后面还想打印链表的时候你会下意识地去访问current-data。指针释放后变成悬空指针运气好读出脏数据运气差直接段错误。这不是危言耸听我用gdb调试时见过无数这种崩溃。3.6 单链表的清空清空和删除单个节点最大的区别是清空要遍历并释放所有节点最后把头指针置为NULL。void clearList(Node** head) { Node* current *head; while (current ! NULL) { Node* toFree current; current current-next; free(toFree); } *head NULL; }这里有一点我反复提醒学生注意先把current的next保存下来再释放。很多新手写成while (current ! NULL) { free(current); current current-next; // 错current已经被释放了 }这句话的行尾注释就是问题所在。你free掉了current紧接着还想访问current-next这块内存已经还给操作系统了读出来的是什么完全不可控。正确做法是上面那种先用一个临时指针toFree记住要释放的节点current先往next走然后释放toFree。这也是我推荐用二级指针清理链表的原因。如果你用一级指针函数里把head置成NULL函数外部的head依然指向原来的地址调用方如果继续访问就会踩内存。二级指针直接修改调用方的变量从根上解决。3.7 C里用结构体的写法C里链表节点的定义方式和C略有不同但基本骨架相似。区别在于C可以用struct加构造函数顺手把初始化封装进去#include iostream struct Node { int data; Node* next; // 构造函数方便创建节点后直接赋值 Node(int val) : data(val), next(nullptr) {} }; void printList(Node* head) { Node* current head; while (current ! nullptr) { std::cout current-data ; current current-next; } std::cout std::endl; } int main() { Node* head new Node(1); head-next new Node(2); head-next-next new Node(3); printList(head); return 0; }在这个例子里临时变量创建在栈上new出来的对象创建在堆上。理解这一点很重要因为链表节点如果想在函数结束后还能被访问就必须分配在堆上栈上的变量在函数返回后就失效了。还有一个C特有的坑用new创建节点后记得用delete释放。如果你用C的malloc分配却用C的delete释放行为是未定义的。虽然大多数编译器能容忍但严格来说这是错误代码。4. 从单链表进阶到循环链表和双链表4.1 循环单链表的实现细节说了半天单链表现在过一遍循环链表。定义上循环单链表和普通单链表节点结构相同差异只在尾巴的处理尾节点的next不指向NULL而是指回头节点。typedef struct CircularNode { int data; struct CircularNode* next; } CircularNode; // 建立带头结点的循环单链表head-next等于head时为空表 CircularNode* createCircularList(int arr[], int n) { CircularNode* head (CircularNode*)malloc(sizeof(CircularNode)); if (head NULL) return NULL; head-next head; // 空表时指回自己关键区别 CircularNode* tail head; for (int i 0; i n; i) { CircularNode* newNode (CircularNode*)malloc(sizeof(CircularNode)); newNode-data arr[i]; newNode-next head; // 新节点指向头节点 tail-next newNode; // 尾部衔接 tail newNode; } return head; }注意这里的两行关键代码空表时head-next head插入时newNode-next head。循环链表判断空表的条件和普通链表完全不同——普通链表是head NULL循环链表是head-next head。很多人在遍历循环链表时写while (current ! NULL)直接把浏览器跑死机因为current永远不会是NULL它在环里无限转圈。正确的循环链表遍历要判断current是否回到头节点void printCircularList(CircularNode* head) { if (head NULL || head-next head) return; CircularNode* current head-next; while (current ! head) { printf(%d , current-data); current current-next; } printf(\n); }这就是为什么我在前面说循环链表要从任意节点出发都能遍历到所有节点但代价是你要自己控制终止条件。普通链表的终止条件是NULL这个天然屏障循环链表没有屏障你必须用“回到起点”作为结束标志。4.2 约瑟夫环的链表解法约瑟夫环是循环链表的经典应用几乎每个数据结构的课程设计里都有它的身影。题目一般描述为N个人围成一圈从第一个人开始报数报到M的人出列然后从出列的下一个人重新报数直到剩最后一个人求最终留下的是几号。我用循环单链表来模拟这个过程的思路是这样的int josephus(int n, int m) { if (n 0 || m 0) return -1; // 初始化带头结点的循环链表节点data就是编号 Node* head createNode(-1); Node* tail head; for (int i 1; i n; i) { Node* newNode createNode(i); tail-next newNode; tail newNode; } tail-next head; // 环闭合 // 从第一个节点开始报数 Node* prev head; Node* current head-next; int count 1; while (head-next ! head) { // 链表中还有人 if (count m) { // 回到起点说明这个人该出局 if (current head) { current current-next; continue; } printf(%d 出局\n, current-data); prev-next current-next; Node* toDelete current; current current-next; free(toDelete); count 1; } else { prev current; current current-next; count; } } int result current-data; free(current); free(head); return result; }我第一次写这个算法时踩了一个隐蔽的坑报数过程中current可能会移动到head这个头结点上。因为头结点不存数据你不能让它出局也不能让它参与报数所以代码里要特殊判断if (current head)遇到头结点就跳过。这个细节暴露了带头结点循环链表的一个短板——头结点混在环里处理时要额外绕开。如果想避开这个麻烦也可以把头结点从环中摘除只用尾指针标记环的起点但代码会更绕我建议初学者先用我上面这个版本逻辑直观。4.3 双链表的插入删除双链表的节点定义多一个指向前的指针typedef struct DNode { int data; struct DNode* prev; // 指向前驱 struct DNode* next; // 指向后继 } DNode;双链表往指定节点p后面插入新节点s的操作是标准四步void insertAfter(DNode* p, DNode* s) { if (p NULL || s NULL) return; s-next p-next; if (p-next ! NULL) { p-next-prev s; } p-next s; s-prev p; }这段代码的关键在于分两步更新双向关系。第一步先把s和p-next之间的双向关系建立起来。第二步再把p和s之间的双向关系建立起来。顺序上有讲究如果先把p-next设为s再用p-next-prev s这里的p-next已经变成s了原来的后继就丢了。双链表删除节点pvoid deleteNode(DNode* p) { if (p NULL) return; if (p-prev ! NULL) { p-prev-next p-next; } if (p-next ! NULL) { p-next-prev p-prev; } free(p); }这里不需要额外找前驱因为p-prev直接就能拿到。这正是双链表对比单链表的最大优势删任意节点O(1)。在单链表里删除节点你必须从头遍历到它的前驱O(n)。工程上如果删除操作非常频繁双链表几乎是必然选择。5. 单链表逆序的两种实现路线5.1 C语言三指针迭代法单链表逆序也叫反转是我见过考频最高的链表操作笔试、面试、实训几乎必考。思想说穿了就是遍历一次链表每经过一个节点就把它的next指向前一个节点。三个指针的搭配是经典prev指向前一个节点current指向当前节点nextNode用于保存当前节点的下一个节点防止把next改向后后面的节点找不到了。Node* reverseList(Node* head) { Node* prev NULL; Node* current head; while (current ! NULL) { Node* nextNode current-next; // 先保存后继 current-next prev; // 掉头指向前驱 prev current; // prev前移 current nextNode; // current前移 } return prev; // 全部走完后prev就是新头节点 }我把这段过程具象化一下第一轮迭代时nextNode保存当前节点的下一个。current-next从指向下一个改成指向prev初始是NULL这样原来的头节点变成新链表的尾节点。prev变成current。current变成nextNode。重复这个循环相当于把面条一根一根倒着拎起来。等current走到NULL说明所有节点都反转完了此时prev指向的正是原链表的最后一个节点也就是新链表的头节点。很多人问为什么返回prev不是current因为在循环结束时current已经变成NULLprev才是最后一个有效节点。这段代码真的值得反复手写三遍以上。我自己教学生时发现光看代码很容易懂但关了屏幕默写很多人第一轮就漏掉nextNode这一行。没有nextNode你把current-next改掉之后下一轮循环current就不知道自己该去哪了链表断成两截。5.2 Python的实现和递归路线Python里定义链表节点和C略有不同但思路完全一致。我用Python写一个迭代版本再用递归版本做对比。递归版本在LeetCode等刷题平台上经常被要求手写理解它有助于加深对递归栈调用的感觉。class ListNode: def __init__(self, val0, nextNone): self.val val self.next next # 迭代法 def reverseListIterative(head: ListNode) - ListNode: prev None current head while current: next_node current.next current.next prev prev current current next_node return prev # 递归法 def reverseListRecursive(head: ListNode) - ListNode: if head is None or head.next is None: return head new_head reverseListRecursive(head.next) head.next.next head # 让当前节点的下一个节点反过来指向自己 head.next None # 断开原来的正向链接 return new_head递归版本怎么理解我习惯这样拆解reverseListRecursive(head.next)返回的是从head.next开始反转后的新头也就是原链表的最后一个节点。此时head.next指向的节点已经变成了逆序链表的尾节点这个尾节点的next本来是NULL我们把它改为指向head就相当于head也接了上去。然后head.next置为NULL因为head现在是新的尾节点。你可以画个三节点的例子一步步推1 - 2 - 3。递归到最深层处理33的next是NULL直接返回3。回到2这一层head是2head.next是3执行3-next 2然后2-next NULL这样子链表变成3 - 2返回new_head还是3。再回到1这一层head是1head.next是2执行2-next 1然后1-next NULL最终得到3 - 2 - 1。和迭代法殊途同归。递归法的优点是代码短、结构优美缺点是如果链表很长递归深度太大可能导致栈溢出。工程上我优先选迭代法笔试时两种都要能写出来因为面试官会问你“递归和迭代各自的优劣势”答得上才加分。6. 实训和笔试中的常见问题排查6.1 最容易翻车的几个错误场景我在带学生做实训时整理了一张高频错误清单每次debug前先对着这个单子排查能省一半时间。第一忘记释放内存。malloc和new出来的节点不会自动回收链表的清理、删除操作如果不写free/delete程序跑完内存泄漏。短时间内看不出来循环一万次创建节点后系统逐渐卡死。我一个学生在课程设计里循环创建了十万个节点没有释放最后进程内存飙升到几个GB直接被系统杀掉。第二指针前进时机出错。遍历链表时current current-next这行代码放在哪个位置会直接影响结果。如果放在free之前会访问已释放内存如果放在循环末尾而循环体内又修改了current可能漏掉节点或死循环。第三边界条件遗漏。空链表、单节点链表、插入位置是头、删除第一个节点、删除最后一个节点这五类情况最容易漏。经验是写完代码后专门跑这几组用例比随机造数据有效得多。我在代码注释里都会写清楚“这个分支处理的是边界”形成习惯后错误率明显下降。第四头指针被修改但外部不知道。不带头结点的链表插入头部或删除头部函数内部改了head外部如果不做同步后续所有操作从旧头开始访问轻则数据错乱重则崩溃。6.2 排查问题的思考路线遇到链表代码崩溃我推荐按这个顺序排查第一步打印头指针地址。如果地址是0x0或者奇怪的值说明初始化有问题很可能malloc之前没有置NULL或者malloc失败后没有检查就直接使用了。第二步检查是否有死循环。在while循环里加一个计数器超过链表长度就打印日志跳出。循环链表和普通链表代码写串了经常出现这种问题。第三步检查free后是否继续访问。把所有free的调用点列出来逐个检查free之后的操作。悬空指针造成的崩溃通常时候时好时坏很磨人。第四步用调试器看内存。gdb或集成开发环境的调试器打断点后逐一查看每个节点的data和next地址对比逻辑上应该是什么。画链表图也是好办法纸上画出来数据流向比盯着代码干想快很多。6.3 实训题的两个通用解题套路实训题《链表应用》这类编程题很多是“看起来新、实际旧”的变形题。我总结两个万能套路第一个套路是“建一个辅助链表”。比如题目要求把链表中的奇数节点和偶数节点分别排到两边不要在原链表上反复插入删除直接创建两个新链表遍历原链表时分别尾插到对应链表最后串起来。时间复杂度和空间复杂度都可控而且代码逻辑清晰不容易写错。第二个套路是“双指针”。很多链表题可以用快慢指针解决比如找中间节点快指针每走两步慢指针走一步快指针到末尾时慢指针就在中间判断链表有环快慢指针总会相遇。这些方法不需要额外空间而且非常好记。遇到一个链表题不知道怎么做时试试双指针经常能打开思路。这些套路的价值在于它们让你不用从零发明算法而是把问题归约到已掌握的模式上。实训课时间有限与其在现场拍脑袋想不如把常见套路练熟再上阵。7. 最后一点自己的体会链表操作这块内容确实基础但如果你把它当成“背代码”学完很快就忘。我自己的做法是每学一个操作都手动推演一遍指针变化的过程在纸上画出每一步的链表状态。推演完再上机敲代码敲完故意改错一两处指针顺序观察程序会出现什么现象。这个过程既加深理解也让你真正见识到“指针顺序错了”到底会发生什么以后遇到类似的报错就能快速定位。另外建议你把C语言版和Python版各写一遍因为两种语言的惯性思维不一样写一遍能发现很多你以为懂了但其实没懂的地方。链表这道门槛过去后面的树、图学起来会顺畅很多因为它们的很多思想都建立在链表的基础操作之上。希望这篇整理对你有实际帮助。
返回列表