第33篇 10道题加深单链表理解(上)
本篇博客内容是为10个单链表的题目:
第一题 移除链表元素
针对 LeetCode 203. 移除链表元素这道经典题目,我们可以从算法复杂度优化的角度进行深度剖析。
在初步分析时,一个直观的思路是复用基础的查找与删除操作。然而,这种组合方式存在显著的性能瓶颈:在单链表中执行一次查找的时间复杂度为 O(N),而由于单链表缺乏前驱指针,每次定位到目标节点后,都需要从头节点开始重新遍历以寻找其前驱节点,这使得单次删除操作的时间复杂度同样为 O(N)。若对链表中所有符合条件的节点执行此操作,整体时间复杂度将急剧退化至 O(N²),这在处理大规模数据时是不可接受的。
为了突破这一性能瓶颈,我们可以采用“尾插法构建新链表”的策略来进行算法优化。具体而言,我们在遍历原链表的过程中,仅将值不等于目标值的节点依次链接到一个新的链表中。
这里需要澄清一个常见的认知误区:虽然逻辑上我们“创建”了一个新链表,但在底层实现中,我们并没有像动态数组那样开辟额外的连续内存空间来拷贝数据。我们仅仅是复用原链表的节点,通过改变指针的指向来重新组织节点间的逻辑关系。因此,该算法的空间复杂度依然保持在 O(1)。与此同时,由于整个处理过程仅需对原链表进行一次线性扫描,时间复杂度被成功优化至 O(N)。这种以指针操作代替数据拷贝的思路,完美地兼顾了时间效率与空间开销,是解决此类链表过滤问题的最优解。
/** * Definition for singly-linked list. * struct ListNode { * int val; * struct ListNode *next; * }; */ typedef struct ListNode* listnode; struct ListNode* removeElements(struct ListNode* head, int val) { listnode phead=NULL; listnode ptail=NULL; while(head) { if(head->val!=val) { //如果新链表为空,初始化头尾指针 if(phead==NULL) { phead=head; ptail=head; } else {// 否则接到尾部并更新尾指针 ptail->next=head; ptail=head; } } // 无论是否删除,原链表指针都要后移 head=head->next; } // 有当新链表不为空时,才断开尾部连接 if(ptail!=NULL) ptail->next=NULL; return phead; }这段代码的核心思路是**“筛选重建法”。它并没有直接在原链表上进行复杂的指针删除操作(这容易导致断链或丢失节点),而是选择遍历原链表,将符合条件的节点“摘”出来,按顺序拼接到一个新的链表中**。
这种方法的优点是逻辑清晰、不易出错,且能很好地处理头节点被删除的情况。以下是详细的编写思路解析:
核心策略:双链表操作
代码维护了两个逻辑上的链表:
- 原链表(由
head驱动):作为数据源,负责从头到尾扫描每一个节点。 - 新链表(由
phead和ptail维护):作为结果容器,只接收那些值不等于val的节点。
变量定义与初始化
listnode phead = NULL;:新链表的头指针。初始化为空,表示新链表还没开始建立。最终函数将返回这个指针。listnode ptail = NULL;:新链表的尾指针。始终指向新链表的最后一个节点,用于快速在尾部追加新节点(O(1) 复杂度)。head:作为游标,负责在原链表中不断向后移动 (head = head->next)。
循环筛选逻辑
这是代码的主体部分,通过while(head)遍历原链表:
判断条件
if (head->val != val)检查当前节点是否需要保留。只有当节点的值不等于目标值val时,才执行后续操作;如果相等,则直接跳过(相当于“删除”了该节点,因为它没有被接入新链表)。
接入新链表
这里分两种情况处理,保证了链表结构的完整性:
新链表为空(第一个有效节点)
if (phead == NULL) { phead = head; // 确立头节点 ptail = head; // 此时头也是尾 }这是起始状态,需要同时初始化头和尾。
新链表已有节点(后续有效节点)
else { ptail->next = head; // 将当前节点挂到旧尾巴后面 ptail = head; // 更新尾巴为当前节点 }利用尾插法,将节点串联起来。
收尾工作
循环结束后,必须处理新链表的结尾,防止野指针问题。
if (ptail != NULL) ptail->next = NULL;- 为什么要做这一步?因为我们是直接把原链表的节点拿过来用的,原链表中这些节点的
next指针可能还指向后面的节点(甚至是被跳过的、值为val的节点)。如果不手动置空,新链表的尾部可能会拖着一条长长的“垃圾尾巴”,导致后续遍历出错或死循环。 - 为什么判空?如果原链表所有节点都被删除了(例如
[6,6], val=6),ptail依然是NULL。此时不能执行ptail->next,否则会导致程序崩溃。
总结
这段代码通过“遍历 -> 筛选 -> 尾插”三步走,巧妙地避开了直接删除节点带来的复杂指针操作。它不仅代码简洁,而且时间复杂度为 O(N),空间复杂度为 O(1)(复用了原节点,未开辟新内存),是非常优秀的解法。
优化
其实这题用‘哨兵节点’写会更优雅,连最后的判空都能省掉:
使用哨兵节点(Sentinel Node),也叫“虚拟头节点”或“哑节点”,是处理链表问题最优雅、最常用的技巧。
它的核心作用是统一操作逻辑:让链表的每一个真实节点都拥有“前驱节点”。这样我们就不需要单独写代码去判断“是否正在删除头节点”x
哨兵节点法完整代码
/** * Definition for singly-linked list. * struct ListNode { * int val; * struct ListNode *next; * }; */ struct ListNode* removeElements(struct ListNode* head, int val) { // 1. 创建哨兵节点(通常分配在栈上即可) // 它的值无所谓,关键是它的 next 指向原链表的头 struct ListNode sentinel; sentinel.next = head; // 2. 定义一个游标指针 prev,初始指向哨兵节点 // prev 始终代表“当前检查节点的前一个节点” struct ListNode* prev = &sentinel; // 3. 开始遍历 while (prev->next != NULL) { struct ListNode* curr = prev->next; // curr 是当前要检查的节点 if (curr->val == val) { // 【情况A:发现目标,执行删除】 // 将 prev 的 next 直接指向 curr 的下一个节点 // 这样 curr 就被从链表中“摘除”了 prev->next = curr->next; // 注意:这里不需要移动 prev! // 因为新的 prev->next 变成了原来的 curr->next, // 下一轮循环我们需要检查这个新接上来的节点。 // (可选) 释放被删除节点的内存,防止内存泄漏 // free(curr); } else { // 【情况B:不是目标,继续向后走】 // 只有当没有发生删除时,prev 才向后移动 prev = curr; } } // 4. 返回哨兵节点的下一个节点作为新的头 // 如果原头节点被删了,sentinel.next 会自动指向第二个节点 // 如果原头节点没被删,sentinel.next 依然指向原头节点 return sentinel.next; }为什么这种方法更好?
消灭了
if-else分支:- 在普通方法中,删除头节点需要
head = head->next,而删除中间节点需要prev->next = curr->next。这是两套逻辑。 - 有了哨兵节点,所有的删除操作都变成了
prev->next = curr->next。哪怕删除的是第一个真实节点,它的前驱也是sentinel,逻辑完全通用。
- 在普通方法中,删除头节点需要
避免了空指针判断:
- 你不需要担心
head是否为空,也不需要担心删除后链表变空的情况。sentinel.next会自然地处理这一切(如果全删光了,它就是NULL)。
- 你不需要担心
代码更简洁:
- 不需要维护
phead和ptail两个指针,只需要一个prev指针就能搞定所有事情。
- 不需要维护
图解执行过程
假设链表为[1] -> [2] -> [6] -> [3],我们要删除6。
初始状态:
sentinel -> [1] -> [2] -> [6] -> [3]prev指向sentinel。检查 1:不等于 6。
prev移动到[1]。检查 2:不等于 6。
prev移动到[2]。检查 6:等于 6!
- 执行
prev->next = curr->next。 - 此时
[2]的next直接连到了[3]。 [6]被孤立(断开连接)。- 关键点:
prev不动,依然指向[2]。
- 执行
下一轮循环:
curr变成了prev->next,也就是[3]。- 检查 3:不等于 6。
prev移动到[3]。
结束:返回
sentinel.next(即[1])。
这种解法是面试里的加分项。
第二题 合并两个有序链表
21. 合并两个有序链表 - 力扣(LeetCode)
核心解法采用双指针迭代合并策略:通过while循环同步遍历两条有序链表,逐节点比对两链表当前指针值域,优先选取数值较小的节点接入结果链表尾部;循环终止后,将剩余未遍历完毕的链表片段直接拼接至合并链表末尾,完成全量节点合并。
struct ListNode* mergeTwoLists(struct ListNode* list1, struct ListNode* list2) { // 处理边界情况 if (list1 == NULL) return list2; if (list2 == NULL) return list1; struct ListNode* newhead = NULL; struct ListNode* newtail = NULL; while (list1 != NULL && list2 != NULL) { if (list1->val < list2->val) { if (newhead == NULL) { // 初始化头节点 newhead = newtail = list1; } else { // 【关键修改】先连接,再移动尾巴 newtail->next = list1; newtail = list1; // 或者 newtail = newtail->next; } list1 = list1->next; // 移动 list1 指针 } else { if (newhead == NULL) { newhead = newtail = list2; } else { // 【关键修改】同上 newtail->next = list2; newtail = list2; } list2 = list2->next; // 移动 list2 指针 } } // 处理剩余节点 if (list1 != NULL) { newtail->next = list1; // 直接把剩下的整串接上去 } if (list2 != NULL) { newtail->next = list2; } return newhead; }在上面的链表操作中,频繁判断头结点是否为空是导致代码冗余和逻辑复杂的原因。
因此引入哨兵节点(Sentinel Node / Dummy Node)是非常专业且优雅的优化手段。它的核心优势在于:
- 统一处理逻辑:无论是插入第一个节点还是后续节点,操作都变成在
dummy.next之后插入,彻底消除了if head is None这类边界判断。 - 防止空指针异常:始终有一个安全的起点,不用担心修改头指针时丢失引用。
- 代码更简洁:主循环或逻辑块里只保留核心操作,可读性大幅提升。
优化
struct ListNode dummy; // 创建一个虚拟节点 struct ListNode* tail = &dummy; // 尾巴从虚拟节点开始 while (l1 && l2) { if (l1->val < l2->val) { tail->next = l1; l1 = l1->next; } else { tail->next = l2; l2 = l2->next; } tail = tail->next; // 统一移动尾巴 } // 接上剩余部分 tail->next = l1 ? l1 : l2; return dummy.next; // 返回虚拟节点的下一个,即真正的头第三题 反转链表
206. 反转链表 - 力扣(LeetCode)
头插法构建新链表
核心策略:采用“头插法”(Head Insertion)重构链表。该方法通过遍历原链表,依次摘取节点并插入到新链表的头部,利用“后进先出”的特性自然实现链表的逆序。算法的时间复杂度为 O(N),空间复杂度为 O(1)(仅复用原节点)。
核心逻辑解析
- 初始化:创建一个
new_head指针,初始化为NULL。这代表一个新的、空的链表。 - 遍历与摘除:使用
while循环遍历原链表。在修改当前节点的指向之前,必须先保存下一个节点的地址(否则链表就断了,找不到后面的路了)。 - 头插操作:
- 让当前节点的
next指向new_head(把它插到新链表的最前面)。 - 更新
new_head为当前节点(它现在变成了新的头)。
- 让当前节点的
- 移动游标:将原链表的遍历指针移向刚才保存的“下一个节点”。
标准代码实现
struct ListNode* reverseList(struct ListNode* head) { // 1. 定义一个新链表的头指针,初始为空 struct ListNode* new_head = NULL; // 2. 遍历原链表 while (head != NULL) { // 【关键步骤】先保存原链表的下一个节点,防止断链后迷路 struct ListNode* next_node = head->next; // 3. 执行“头插”:将当前节点插入到新链表的最前面 head->next = new_head; // 当前节点的next指向旧的新链表头 // 4. 更新新链表的头:当前节点变成了新的头 new_head = head; // 5. 移动原链表指针,继续处理下一个节点 head = next_node; } // 6. 返回新链表的头 return new_head; }图解流程 (以 1->2->3 为例)
初始状态:
head指向 1new_head为 NULL
第一轮循环 (处理节点 1):
- 保存
next_node(即节点 2) 1->next指向new_head(NULL) —— 此时链表变成1 -> NULLnew_head更新为 1head移动到next_node(即节点 2)
- 保存
第二轮循环 (处理节点 2):
- 保存
next_node(即节点 3) 2->next指向new_head(节点 1) —— 此时链表变成2 -> 1 -> NULLnew_head更新为 2head移动到next_node(即节点 3)
- 保存
...以此类推,最终new_head就会指向完全反转后的链表头。
优化
但是这个方法实在是太普通了,接下来我们引入一个非常常见与重要的方法:三指针法
这是面试和工程中最经典、最标准的解法,它的核心在于“原地反转”,不需要创建新链表,空间复杂度为 O(1)。
算法解析:三指针法(原地迭代)
1. 思路
采用“局部反转”策略。我们可以把链表想象成一列火车,我们不需要把车厢拆下来重装,而是站在车厢连接处,依次将每一节车厢的挂钩方向调转。为了保证在调转挂钩时不丢失后续车厢的连接,我们需要三只手(三个指针)协同工作:一只手抓住前一个节点(作为新的指向目标),一只手抓住当前节点(正在操作的节点),一只手提前抓住下一个节点(防止断链后迷路)。
2. 核心逻辑实现
- 初始化指针:定义
prev(前驱指针)初始化为NULL,因为反转后原头节点将变为尾节点,其next应指向空;定义curr(当前指针)指向原链表头节点head。 - 循环遍历:当
curr不为空时进入循环。 - 保存后继:关键步骤。在修改
curr的指向之前,必须先用临时指针next_temp保存curr->next。这是因为一旦修改了curr->next,原链表后续部分就会断开,若不保存将无法继续遍历。 - 执行反转:将
curr->next指向prev。此时,当前节点与前一个节点的连接方向完成逆转。 - 指针推进:
- 将
prev移动到curr的位置(前驱指针后移)。 - 将
curr移动到next_temp的位置(当前指针后移,处理下一个节点)。
- 将
- 返回结果:当循环结束时,
curr为NULL,而prev正好停留在原链表的最后一个节点上,即新链表的头节点。
3. 代码 (C)
struct ListNode* reverseList(struct ListNode* head) { // 1. 初始化三指针中的两个关键指针 struct ListNode* prev = NULL; // 前驱节点,初始为空(反转后的尾节点指向空) struct ListNode* curr = head; // 当前节点,从头开始 // 2. 遍历链表 while (curr != NULL) { // 【关键】先保存下一个节点,防止断链 struct ListNode* next_temp = curr->next; // 3. 核心操作:将当前节点的 next 指向前一个节点(实现反转) curr->next = prev; // 4. 双指针同步后移 prev = curr; // prev 前进到当前位置 curr = next_temp; // curr 前进到原链表的下一个位置 } // 5. 循环结束时,curr 为 NULL,prev 指向原链表尾部(即新链表头部) return prev; }欧克,到这里就优化完了,但是就只有这两种解法吗?当然不是,接下来再来一个更有意思的解法:递归:算法解析:递归法(Recursion)
1. 思路
采用“自底向上”的逆向思维策略。我们不从头开始反转,而是假设当前节点之后的所有节点已经由“小弟(递归子问题)”完美反转完毕,当前节点只需要处理“自己与后继节点”**之间的连接关系即可。整个递归过程分为两个阶段:
- 递推阶段(下楼梯):不断深入链表,直到触达最末尾的节点。
- 回归阶段(上楼梯):从最后一个节点开始,逐层向上回溯,在每一层完成当前节点的反转操作,并将新链表的头节点一路向上返回。
2. 核心逻辑实现(递归三段论)
- 寻找终止条件(Base Case):当链表为空(
head == NULL)或到达最后一个节点(head->next == NULL)时,无需反转,直接返回当前节点。 - 执行递归调用(Delegate):调用自身处理下一个节点
reverseList(head->next),并接收小弟返回的新链表头节点new_head。 - 处理当前层逻辑(Reverse):
- 反转指针:让当前节点的“后继节点”指回自己,即
head->next->next = head;。 - 断开旧连接:将当前节点原本的
next指针置空,防止链表成环,即head->next = NULL;。
- 反转指针:让当前节点的“后继节点”指回自己,即
- 返回最终结果(Return):无论在哪一层,整条反转链表的新头节点始终是递归最深处找到的那个
new_head,因此直接将其返回即可。
3. 代码 (C语言)
struct ListNode* reverseList(struct ListNode* head) { // 【第一步:终止条件】 // 链表为空或只剩最后一个节点时,直接返回该节点 if (head == NULL || head->next == NULL) { return head; } // 【第二步:递归调用】 // 将后续链表交给子问题处理,并接收反转后的新头节点 struct ListNode* new_head = reverseList(head->next); // 【第三步:当前层逻辑】 // 1. 让后继节点指回当前节点(实现反转) head->next->next = head; // 2. 断开当前节点原来的指向(防止死循环) head->next = NULL; // 【第四步:返回值】 // 始终返回新链表的头节点 return new_head; }太棒了!能用三种方法解决同一道经典算法题,说明我们对链表指针操作的底层逻辑已经掌握得非常透彻了。
现在我们将这三种方法(头插法、三指针法、递归法)的核心思路与优缺点进行系统性的对比整理:
1. 头插法(构建新链表)
- 核心思路:采用“头插法”重构链表。依次遍历原链表,将摘取下来的节点不断插入到新链表的最前端。利用“后进先出”的特性,自然实现链表的逆序。
- 优点:
- 思维直观:完全符合人类的正向线性思维,不需要在脑海中模拟复杂的指针旋转,非常适合初学者理解和手写。
- 不易出错:每一步操作都是将节点接向一个已知的新链表头,不容易产生死循环。
- 缺点:
- 思维局限:本质上是一种“拆东墙补西墙”的搬运思维,没有真正触及“原地修改指针”的精髓。
2. 三指针法(原地迭代)
- 核心思路:采用“局部反转”策略。在遍历链表的过程中,使用三个指针协同工作:
prev(前驱)、curr(当前)、next_temp(后继)。在断开当前节点之前,先保存后继节点,然后将当前节点的next指向前驱,最后整体向后推进。 - 优点:
- 极致高效:空间复杂度为严格的 O(1),不需要任何额外的辅助空间。
- 工程首选:这是面试和实际工程中最标准、最优雅的解法,体现了对内存和指针的绝对掌控。
- 缺点:
- 思维难度较高:需要同时维护三个指针的状态,对逻辑严密性要求高,一旦顺序写错(比如没保存
next_temp)极易导致断链或死循环。
- 思维难度较高:需要同时维护三个指针的状态,对逻辑严密性要求高,一旦顺序写错(比如没保存
3. 递归法(自底向上)
- 核心思路:采用“自底向上”的逆向思维。将问题分解为“反转后续链表”和“处理当前节点”两个子问题。先让递归深入到链表末尾,在回溯(上楼梯)的过程中,让后继节点指回当前节点,并断开当前节点的旧连接。
- 优点:
- 代码极简:去除了所有显式的循环和临时指针,代码行数最少,结构最优美。
- 深刻理解:能极大地锻炼对递归树和函数调用栈的理解。
- 缺点:
- 空间开销大:空间复杂度为 O(N),因为需要依赖系统调用栈。
- 栈溢出风险:当链表极长时,深层递归容易导致栈溢出(Stack Overflow)。
- 调试困难:出错时很难像迭代法那样通过断点直观地看到指针的走向。
综合建议与实战选择
| 方法 | 时间复杂度 | 空间复杂度 | 适用场景 |
|---|---|---|---|
| 头插法 | O(N) | O(1) | 快速写出能跑通的代码 |
| 三指针法 | O(N) | O(1) | 面试标准答案、追求极致性能的工程场景 |
| 递归法 | O(N) | O(N) | 链表较短、追求代码优雅、考察递归思维 |
在真实的面试或笔试中,你可以这样应对:
- 首选:直接写出三指针法,展现扎实的基本功。
- 备选:如果面试官要求“能不能用递归写一下”,或者“还有没有其他思路”,你可以抛出递归法或头插法,展现你思维的广度和对算法的深刻理解。
第四题 寻找链表的中间结点
876. 链表的中间结点 - 力扣(LeetCode)
思路1:使用while循环遍历链表求得链表长度,再利用while循环求得中间结点的指针:
struct ListNode* middleNode(struct ListNode* head) { int size=0; struct ListNode*pmid=head; struct ListNode*pcur=head; while(pcur) { size++; pcur=pcur->next; } int mid=size/2; while(mid) { mid--; pmid=pmid->next; } if(head==NULL) return NULL; return pmid; }快慢指针法(双指针法)是解决链表“找中点”、“找倒数第K个节点”或“判断环”等问题的神器。它的核心在于利用速度差来转换距离关系。
针对这道“链表的中间结点”题目,以下是基于快慢指针法的详细解析:
思路
想象两个人在操场跑步,跑道长度就是链表的长度。
- 慢指针(Slow):每次只跑 1 步(移动一个节点)。
- 快指针(Fast):每次跑 2 步(移动两个节点)。
由于快指针的速度是慢指针的2倍,当快指针跑完全程(到达链表末尾NULL)时,慢指针刚好跑了一半的路程,也就正好停在链表的中间位置。
这种方法将原本需要“先遍历算长度,再遍历找中点”的两次遍历,优化为一次遍历即可完成。
核心逻辑实现
- 初始化:定义两个指针
slow和fast,同时指向链表的头节点head。 - 循环条件:只要快指针
fast没走到头,就继续跑。- 注意边界:因为
fast一次走两步,所以必须保证fast和fast->next都不为空,否则访问fast->next->next会报错。
- 注意边界:因为
- 步进操作:
slow = slow->next;(慢指针走一步)fast = fast->next->next;(快指针走两步)
- 终止与返回:当循环结束时,
fast到了终点,此时slow所在的位置就是中间节点,直接返回slow。
代码 (C语言)
struct ListNode* middleNode(struct ListNode* head) { // 1. 定义快慢指针,都从头开始 struct ListNode* slow = head; struct ListNode* fast = head; // 2. 循环条件: // fast != NULL 处理偶数长度情况(fast最终指向尾部节点的下一个空位) // fast->next != NULL 处理奇数长度情况(fast最终指向最后一个节点) while (fast != NULL && fast->next != NULL) { slow = slow->next; // 慢指针走一步 fast = fast->next->next; // 快指针走两步 } // 3. 返回慢指针,即为中点 return slow; }这里还有一个很重要的点:while循环的判定条件是不能写成:while ( fast->next != NULL&&fast != NULL )这样的,因为如果fast指针为空的时候,fast->next就会报错,原本的不会报错是因为先判断其是否为空指针。
这个方法效率很高,但有个小坑:如果链表有环,快慢指针一定会相遇。不过不用急,我们下面就会讲解到带环链表了
第五题 链表分割
链表分割_牛客题霸_牛客网
这段代码的核心思路是“分而治之”,通过构建两个临时的链表,将原链表中的节点按值的大小进行分流,最后再将它们拼接起来。
核心思路:双链表分流法
想象一下,你面前有一条杂乱的队伍(原链表),你的任务是根据一个标准x,把队伍里的人分成两列:
- 左队列 (less):所有数值小于
x的人。 - 右队列 (greater):所有数值大于或等于
x的人。
等所有人都分好队后,你只需要把“左队列”的队尾和“右队列”的队首连接起来,就得到了一条符合要求的、有序的新队伍。
这个方法巧妙地避免了在原链表上进行复杂的节点移动和指针操作,逻辑非常清晰。
class Partition { public: ListNode* partition(ListNode* pHead, int x) { // write code here ListNode*lesshead,*lesstail; ListNode*greathead,*greattail; lesshead=lesstail=(ListNode*)malloc(sizeof(ListNode)); greathead=greattail=(ListNode*)malloc(sizeof(ListNode)); while(pHead) { if(pHead->val<x) { lesstail->next=pHead; lesstail=pHead; } else { greattail->next=pHead; greattail=pHead; } pHead=pHead->next; } greattail->next=NULL; lesstail->next=greathead->next; ListNode*rethead=lesshead->next; free(lesshead); free(greathead); lesshead=greathead=NULL; return rethead; } };代码步骤详解
让我们一步步拆解代码是如何实现这个思路的:
1. 创建两个“哨兵”链表
ListNode* lesshead, *lesstail; ListNode* greathead, *greattail; lesshead = lesstail = (ListNode*)malloc(sizeof(ListNode)); greathead = greattail = (ListNode*)malloc(sizeof(ListNode));- 目的:创建两个临时的、不存储有效数据的“哨兵节点”(或称“虚拟头节点”)。
lesshead和greathead分别是两个新链表的“空头”,它们的作用是提供一个固定的起点,这样在后续插入节点时,就不用判断链表是否为空,简化了逻辑。lesstail和greattail是两个链表的“尾巴”,始终指向链表的最后一个有效节点,方便我们进行尾插操作。
2. 遍历原链表,进行“分流”
while(pHead) { if(pHead->val < x) { // 节点值小于 x,加入 less 链表 lesstail->next = pHead; lesstail = pHead; } else { // 节点值大于等于 x,加入 greater 链表 greattail->next = pHead; greattail = pHead; } pHead = pHead->next; }- 目的:这是算法的核心。我们遍历原始的
pHead链表。 - 操作:对于遍历到的每一个节点,判断它的值
val。- 如果
val < x,就把它“摘下来”,接到less链表的尾部 (lesstail->next),然后更新lesstail指针,让它指向新的尾节点。 - 否则 (
val >= x),就把它接到greater链表的尾部 (greattail->next),并更新greattail指针。
- 如果
- 注意:这里只是改变了节点之间的连接关系,并没有创建新的节点,也没有复制节点的值,所以效率很高。
3. 拼接两个链表并收尾
greattail->next = NULL; lesstail->next = greathead->next;- 目的:将分流后的两个链表连接成一个完整的链表。
- 操作:
greattail->next = NULL;:这一步至关重要!因为greater链表的最后一个节点,在原链表中可能指向任何一个节点。我们必须手动将其next指针置为NULL,以标记新链表的结束,防止形成环或连接到错误的节点。lesstail->next = greathead->next;:将less链表的尾部 (lesstail) 连接到greater链表的第一个有效节点 (greathead->next)。注意,我们跳过greathead这个哨兵节点本身。
4. 确定返回值并清理内存
ListNode* rethead = lesshead->next; free(lesshead); free(greathead); lesshead = greathead = NULL; return rethead;- 目的:返回结果并释放我们申请的临时空间。
- 操作:
ListNode* rethead = lesshead->next;:新的链表头就是less链表的第一个有效节点。如果所有节点都大于等于x,那么lesshead->next会是NULL,这也是正确的。free(...):我们最初用malloc创建了两个哨兵节点,现在任务完成,需要将它们释放掉,避免内存泄漏。return rethead;:返回最终拼接好的链表的头节点。
总而言之,这段代码通过创建两个辅助链表,将复杂的“分割”问题转化为了简单的“遍历-分类-拼接”三个步骤,是一种非常经典且高效的链表处理技巧。
第六题
本来是要写成一篇博客的,但是现在就已经有12000字了,所以还是分成两篇把......
代码写完了,但我想对正在阅读这篇博客的你说几句心里话。
最近身边有很多同学在迷茫、害怕,大体原因是只能靠自己一个人在这个世界打拼,无依无靠;而世界又在不断变化进步,不知道自己的出路在哪里。
我把送给他们的话,也送给正在屏幕前努力的你:
保持乐观,悲观者正确,乐观者前行。
相信时间的力量,就不必问何时才有回报。
拥有坚持的毅力,就不必在意暂时的苟且。
保持每天的进步,就不必对未来感到迷茫。
愿你在代码的世界里披荆斩棘,也在人生的道路上步履不停。
————————————————
版权声明:本文为CSDN博主「东华万里」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/2401_86187158/article/details/162606768
