数据结构从入门到精通:链表

1. 链表

一、链表的核心概念

1. 顺序表 vs 链表(类似火车车厢连接起来)

特性 顺序表(数组) 链表
物理存储 连续内存 离散内存,用指针连接
随机访问 O (1) 直接下标访问 O (n) 只能遍历
插入 / 删除 平均 O (n),需搬移元素 O (1)(已知结点位置时)
扩容 固定大小,需手动扩容 按需申请结点,无需扩容

2. 链表的本质

  • 结点 :每个结点 包含「数据域 + 指针域 」,单链表中**next 指针指向后继结点**。
  • 头结点 / 哨兵位:额外的不存数据的结点,用来简化空表、首结点操作。

二、单链表的基础定义

1. 结点结构体定义(不能用匿名结构体)

复制代码
typedef int LDataType;
typedef struct ListNode {
    LDataType data;         // 存储数据
    struct ListNode* next;  // 指向后继结点
} LNode, *LinkList;
  • LNode结点类型
  • LinkList结点指针类型 ,等价于**LNode***

2. 不带头结点(没有 "引导员")

  • 结构 :头指针 L 直接指向第一个有效结点(就是存数据的第一个结点)。
  • 空表状态 :链表为空时,L = NULL(头指针啥也不指)。
  • 举个例子 :链表 10 -> 20 -> 30头指针 L 直接指向 10 。空表时,L 就是 NULL,连个占位的都没有。
  • 问题:插入 / 删除第一个结点时,要单独处理空表和首结点的情况,代码容易写错。

3. 带头结点(有 "引导员")

  • 结构 :头指针 L 指向哨兵结点 ,哨兵结点的**next 才指向第一个有效结点**。
  • 空表状态 :链表为空时,哨兵结点永远存在L->next = NULL(哨兵后面啥也不指)。
  • 举个例子 :链表 10 -> 20 -> 30 ,结构是 哨兵 -> 10 -> 20 -> 30 ,头指针**L 指向哨兵** 。空表时,L 一直指向哨兵只是哨兵的 nextNULL
  • 好处:不管链表空不空,插入 / 删除操作的代码逻辑都完全一样,不用单独处理首结点,不容易出错。

三、核心接口实现(带头结点)

1. 创建新结点

复制代码
LNode* BuyListNode(int data) {
    LNode* newNode = (LNode*)malloc(sizeof(LNode));
    if (newNode == NULL) {
        printf("BuyListNode失败!\n");
        exit(-1);
    }
    newNode->data = data;
    newNode->next = NULL;
    return newNode;
}

2. 初始化链表(创建哨兵头结点)

复制代码
LNode* ListInit() {
    LNode* head = BuyListNode(-1); // 哨兵结点,数据无意义
    return head;
}

3. 尾插法(在链表末尾插入)

复制代码
void ListPushBack(LNode* L, LDataType x) {
    assert(L != NULL);
    LNode* cur = L;
    // 找到尾结点
    while (cur->next != NULL) {
        cur = cur->next;
    }
    LNode* newNode = BuyListNode(x);
    cur->next = newNode;
}

4. 头插法(在哨兵结点后插入)

复制代码
void ListPushFront(LNode* L, LDataType x) {
    assert(L != NULL);
    LNode* newNode = BuyListNode(x);
    newNode->next = L->next;
    L->next = newNode;
}

5. 遍历打印链表

复制代码
void ListPrint(LNode* L) {
    assert(L != NULL);
    LNode* cur = L->next; // 从第一个有效结点开始遍历
    while (cur != NULL) {
        printf("%d -> ", cur->data);
        cur = cur->next;
    }
    printf("NULL\n");
}

6. 求链表长度

复制代码
int ListSize(LNode* L) {
    assert(L != NULL);
    int size = 0;
    LNode* cur = L->next;
    while (cur != NULL) {
        size++;
        cur = cur->next;
    }
    return size;
}

7. 查找指定元素

复制代码
LNode* ListLocateElem(LNode* L, LDataType x) {
    assert(L != NULL);
    LNode* cur = L->next;
    while (cur != NULL) {
        if (cur->data == x) {
            return cur; // 找到结点,返回指针
        }
        cur = cur->next;
    }
    return NULL; // 未找到
}

8. 销毁链表(释放所有结点)

复制代码
void ListDestroy(LNode* L) {
    assert(L != NULL);
    LNode* cur = L;
    while (cur != NULL) {
        LNode* next = cur->next;
        free(cur);
        cur = next;
    }
}

四、遍历打印和求长度(带头结点)

核心逻辑

  • 遍历本质:通过当前节点的next成员迭代获取下一个节点地址,直到NULL结束。
  • 注意:哨兵位头结点不存储有效数据遍历需从L->next开始

代码实现

  1. ListPrint :遍历并打印链表

    复制代码
    void ListPrint(LNode* L) {
        // 校验哨兵位头指针不为空
        assert(L);
        printf("头结点->");
        // cur指向第一个有效节点
        LNode* cur = L->next;
        // 循环遍历,cur为空则结束
        while (cur) {
            printf("%d->", cur->data);
            // 移动到下一个节点
            cur = cur->next;
        }
        printf("NULL\n");
    }
  2. ListSize :计算链表有效节点数

    复制代码
    int ListSize(LNode* L) {
        assert(L);
        int size = 0;
        LNode* cur = L->next;
        while (cur) {
            size++;
            cur = cur->next;
        }
        return size;
    }

关键疑问解答

  • 为什么不能用**++cur** 代替**cur = cur->next** ?链表节点在内存中不连续++cur只会移动到当前节点的下一块内存地址而非下一个节点的地址,只有**cur->next**存储了下一个节点的真实地址。

五、查找

1. 按值查找:ListLocateElem

核心逻辑

遍历链表,找到第一个数据等于目标值 x 的结点并返回其地址 ;遍历完都没找到则返回 NULL

代码解析

复制代码
LNode* ListLocateElem(LNode* L, LDataType x) {
    assert(L); // 断言:头指针不为空,避免非法访问
    LNode* cur = L->next; // 从第一个数据结点开始遍历
    while (cur) { // cur不为空就继续循环
        if (cur->data == x)
            return cur; // 找到目标值,直接返回当前结点地址
        cur = cur->next; // 没找到,指针后移
    }
    return NULL; // 遍历结束仍未找到,返回空指针
}
  • 时间复杂度:O(N),最坏情况要遍历整个链表
  • 特点:只能找到第一个匹配的结点,适合快速定位已知值的场景

2. 按下标查找:ListGetElem

核心逻辑

从第一个数据结点开始,向后移动 i ,找到i 个结点 (注意:这里下标从 0 开始L->next 是第 0 个结点)。循环条件**iNode != NULL && j < i** 是关键:既要保证还没到目标下标 ,也要保证当前结点不为空,防止访问越界。

代码解析

复制代码
LNode* ListGetElem(LNode* L, int i) {
    assert(L);          // 头指针非空
    assert(i >= 0);     // 下标不能为负数
    int j = 0;          // 计数器,记录当前是第几个结点
    LNode* iNode = L->next; // 从第0个结点开始
    while (iNode != NULL && j < i) {
        ++j;
        iNode = iNode->next; // 指针后移,计数器+1
    }
    return iNode; // 找到则返回结点地址,没找到(下标越界)则返回NULL
}
  • 时间复杂度:O(N)链表不支持随机访问,只能顺序遍历
  • 注意:返回 NULL 有两种情况:要么下标越界 (链表长度不足),要么链表为空

3. 两种查找的对比

操作类型 核心目标 关键逻辑 时间复杂度 适用场景
按值查找 找 "值等于 x" 的结点 遍历 + 值比较 O(n) 已知结点数据,需定位其位置
按下标查找 找 "第 i 个位置" 的结点 遍历 + 计数器 O(n) 已知结点位置,需访问其数据

💡 补充说明

  • 哨兵位头结点的存在 ,让两种查找都能直接从 L->next 开始遍历 ,不用单独处理空链表的特殊情况(除了 assert(L) 断言)。
  • 按下标查找的循环条件 iNode != NULL && j < i 缺一不可:如果只写 j < i,当下标超过链表长度时,iNode 会变成 NULL,再执行 iNode->next 会导致程序崩溃

六、插入

1. 链表插入的核心特点

和顺序表不同,链表插入不需要挪动数据 ,只需要修改结点间的链接关系 ,但关键前提是:✅ 要在第 i 个结点之前插入,必须先找到i-1 个结点(前驱结点),否则无法完成链接修改。


2. 插入的关键顺序(绝对不能搞反!)

这是链表插入最容易踩坑的地方!两步操作顺序必须固定

复制代码
// 1. 先让新结点的next指向原来的第i个结点
newNode->next = i_1Node->next;
// 2. 再让前驱结点的next指向新结点
i_1Node->next = newNode;
  • 如果先执行第 2 步,i_1Node->next 会被直接改成 newNode ,原来的i 个结点 就找不到了,会直接导致链表断裂
  • 带头结点的写法可以统一处理头插i=0)和中间 / 尾插,不需要单独写分支,代码更简洁。

3. 完整代码解析

复制代码
// 在链表的第i个下标位置插入元素x
void ListInsert(LNode* L, int i, LDataType x) {
    // 哨兵位头指针不为空
    assert(L);
    assert(i >= 0);

    // 找到下标为i-1的结点(前驱结点)
    int j = -1;
    LNode* i_1Node = L;
    while (i_1Node != NULL && j < i-1) {
        ++j;
        i_1Node = i_1Node->next;
    }

    // 没有第i-1个结点,说明i非法(比如链表长度不足)
    assert(i_1Node != NULL);

    // 3. 插入新结点
    LNode* newNode = BuyListNode(x);
    // 关键两步,顺序不能交换!
    newNode->next = i_1Node->next;
    i_1Node->next = newNode;
}

代码细节拆解

  1. 前驱结点查找

    • 计数器 j 初始为 -1从哨兵位头结点 L 开始遍历 ,直到 j == i-1 ,此时**i_1Node 就是第 i-1 个结点。**
    • 循环条件 i_1Node != NULL && j < i-1,防止下标越界访问空指针
  2. 边界情况处理

    • 头插(i=0) :此时**i-1 = -1** ,循环不执行,i_1Node 直接指向哨兵位头结点 L,后续的两步插入操作自然完成头插,无需单独分支。
    • 尾插(i 等于链表长度) :此时 i_1Node 会走到最后一个结点,插入后 newNode->nextNULL,符合尾结点的定义。

4. 带头结点 vs 不带头结点插入对比

场景 带头结点链表 不带头结点链表
头插(i=0) 统一处理,无需单独分支 必须单独处理,需要用二级指针修改头指针
中间 / 尾插 直接找前驱结点即可 和带头结点逻辑一致
代码复杂度 低,逻辑统一 高,头插需要特殊判断

7. 删除

一、链表删除的核心特点

和插入类似,链表删除不需要像顺序表那样挪动数据仅需修改结点间的链接关系,但必须先找到i-1 个结点(前驱结点) ,才能完成对第 i 个结点的删除。


二、删除的关键步骤(顺序绝对不能搞反!)

这是链表删除最容易踩坑的地方!,三步操作的顺序必须固定:

复制代码
// 1. 先保存要删除的第i个结点的地址(关键!否则会丢失结点)
LNode* iNode = i_1Node->next;
// 2. 让前驱结点的next指向第i+1个结点,跳过被删除结点
i_1Node->next = iNode->next;
// 3. 释放被删除结点的内存,防止内存泄漏
free(iNode);
  • 如果跳过第 1 步直接执行第 2 步,被删除结点的地址会丢失 ,后续无法 free造成内存泄漏;
  • 带头结点的写法可以统一处理头删(i=0)和中间 / 尾删,不需要单独写分支,代码逻辑更简洁。

三、完整代码解析

复制代码
// 删除链表中下标为i的结点,并返回被删除结点的值
LDataType ListDelete(LNode* L, int i) {
    // 1. 参数合法性校验
    assert(L);
    assert(i >= 0);

    // 2. 找到第i-1个结点(前驱结点)
    int j = -1;
    LNode* i_1Node = L;
    while (i_1Node != NULL && j < i-1) {
        ++j;
        i_1Node = i_1Node->next;
    }

    // 校验:前驱结点和要删除的结点都必须存在
    assert(i_1Node != NULL && i_1Node->next != NULL);

    // 3. 执行删除操作(顺序不能乱)
    LNode* iNode = i_1Node->next;   // 保存要删除的结点地址
    i_1Node->next = iNode->next;    // 前驱结点直接指向后继结点,跳过被删结点
    LDataType val = iNode->data;    // 保存被删除结点的值(可选)
    free(iNode);                    // 释放内存
    return val;                     // 返回被删除结点的值
}

代码细节拆解

  1. 前驱结点查找

    • 计数器 j 初始为 -1,从哨兵位头结点 L 开始遍历,直到 j == i-1,此时 i_1Node 就是第 i-1 个结点。
    • 循环条件 i_1Node != NULL && j < i-1,防止下标越界访问空指针。
  2. 边界情况处理

    • 头删(i=0) :此时 i-1 = -1,循环不执行,i_1Node 直接指向哨兵位头结点 L,后续的删除操作自然完成头删,无需单独分支。
    • 尾删(i 为链表最后一个结点下标) :此时 i_1Node 会指向倒数第二个结点,iNode->nextNULLi_1Node->next = NULL 后,尾结点被成功删除。

四、带头结点 vs 不带头结点删除对比

场景 带头结点链表 不带头结点链表
头删(i=0) 统一处理,无需单独分支 必须单独处理,需要用二级指针修改头指针
中间 / 尾删 直接找前驱结点即可 和带头结点逻辑一致
代码复杂度 低,逻辑统一 高,头删需要特殊判断

2. 一些面试题及其详解

一、链表的中间结点

题目回顾

给定一个单链表,返回它的中间结点。如果有两个中间结点,返回第二个中间结点。

  • 示例 1:1→2→3→4→5中间结点是3
  • 示例 2:1→2→3→4→5→6中间结点是4

两种实现思路对比

思路 核心逻辑 时间复杂度 空间复杂度 优缺点
两次遍历法 第一次遍历求链表长度,第二次走到n/2位置 O(N) O(1) 实现简单,但需要两次遍历
快慢指针法 slow每次走 1 步fast每次走 2 步fast到尾时slow就在中间 O(N) O(1) 一次遍历完成,效率更高

快慢指针法完整代码

复制代码
/**
 * Definition for singly-linked list.
 * struct ListNode {
 *     int val;
 *     struct ListNode *next;
 * };
 */
struct ListNode* middleNode(struct ListNode* head) {
    struct ListNode *slow = head, *fast = head;
    // 循环条件:fast和fast->next都不为空时继续移动
    while (fast != NULL && fast->next != NULL) {
        slow = slow->next;
        fast = fast->next->next;
    }
    return slow;
}

关键细节说明

  1. 循环条件fast != NULL && fast->next != NULL
    • 奇数 长度链表:fast最终指向尾结点 ,退出循环,slow指向正中间
    • 偶数 长度链表:fast最终指向 NULL,退出循环,slow指向第二个中间结点
  2. 无头结点处理题目给的链表不带哨兵位头结点,直接用head作为起始结点即可。如果是带哨兵位的链表需要让slowfasthead->next开始移动。
  3. 为什么这个逻辑有效?fast走的速度是slow的 2 倍时,fast到达终点时,slow刚好走了一半的路程,正好停在中间位置

两次遍历法代码

复制代码
struct ListNode* middleNode(struct ListNode* head) {
    struct ListNode* cur = head;
    int len = 0;
    // 第一次遍历求长度
    while (cur != NULL) {
        len++;
        cur = cur->next;
    }
    cur = head;
    // 第二次遍历走到中间位置
    for (int i = 0; i < len / 2; i++) {
        cur = cur->next;
    }
    return cur;
}

扩展:带哨兵位头结点的写法(学校考试 / 408 风格)

如果题目明确说明链表带头结点,代码需要调整起始位置:

复制代码
// 假设链表定义为:带头结点head,有效结点从head->next开始
struct ListNode* middleNodeWithDummy(struct ListNode* head) {
    struct ListNode *slow = head->next, *fast = head->next;
    while (fast != NULL && fast->next != NULL) {
        slow = slow->next;
        fast = fast->next->next;
    }
    return slow;
}

二、返回倒数第K个结点

题目回顾

给定一个单链表,返回倒数第 k 个结点的值(题目保证 k 有效,无需额外校验)。示例:1→2→3→4→5k=2,返回 4


两种思路对比

思路 核心逻辑 时间复杂度 空间复杂度 优缺点
两次遍历法 第一次遍历求链表长度n,第二次走到第n-k个结点 O(N) O(1) 实现简单,但需要两次遍历
快慢指针法 fast先走k步,再和slow同步走,fast到尾时slow就是目标结点 O(N) O(1) 一次遍历完成,效率更高

解法 1:两次遍历法

复制代码
/**
 * Definition for singly-linked list.
 * struct ListNode {
 *     int val;
 *     struct ListNode *next;
 * };
 */
int kthToLast(struct ListNode* head, int k) {
    struct ListNode* cur = head;
    int len = 0;

    // 第一次遍历:计算链表长度
    while (cur != NULL) {
        len++;
        cur = cur->next;
    }

    // 第二次遍历:走到第 len - k 个结点
    cur = head;
    for (int i = 0; i < len - k; i++) {
        cur = cur->next;
    }

    return cur->val;
}

解法 2:快慢指针法(推荐,一次遍历)

复制代码
/**
 * Definition for singly-linked list.
 * struct ListNode {
 *     int val;
 *     struct ListNode *next;
 * };
 */
int kthToLast(struct ListNode* head, int k) {
    struct ListNode *slow = head, *fast = head;

    // 1. fast 先走 k 步
    for (int i = 0; i < k; i++) {
        fast = fast->next;
    }

    // 2. fast 和 slow 同步走,直到 fast 走到 NULL
    while (fast != NULL) {
        slow = slow->next;
        fast = fast->next;
    }

    // 此时 slow 指向倒数第 k 个结点
    return slow->val;
}

关键细节说明

  1. 快慢指针的原理fastslow 之间保持**k 个结点的距离** 。当 fast 走到链表末尾(NULL)时,slow 距离末尾正好是 k ,也就是倒数第 k 个结点。

  2. 为什么题目说 k 是有效的? 这意味着你不需要处理 k > 链表长度k ≤ 0 这类边界情况,代码可以更简洁。

  3. 和带哨兵位链表的区别 题目给的链表是无头结点 的,所以直接用 head 作为起始结点即可 。如果是带哨兵位的链表,需要让 slowfasthead->next 开始移动 ,同时调整 fast 先走的步数。


扩展:带哨兵位头结点的写法(学校考试 / 408 风格)

复制代码
// 假设链表带头结点,有效结点从 head->next 开始
int kthToLastWithDummy(struct ListNode* head, int k) {
    struct ListNode *slow = head->next, *fast = head->next;

    // fast 先走 k 步
    for (int i = 0; i < k; i++) {
        fast = fast->next;
    }

    while (fast != NULL) {
        slow = slow->next;
        fast = fast->next;
    }

    return slow->val;
}

三、 移除链表的元素

题目回顾

删除 链表中所有满足 Node.val == val 的结点,并返回新的头结点。

  • 示例 1:[1,2,6,3,4,5,6]val=6 → 输出 [1,2,3,4,5]
  • 示例 3:[7,7,7,7]val=7 → 输出空链表

核心思路:哨兵位头结点

这道题的关键难点是头结点本身就是要删除的结点 ,如果不用哨兵位,需要单独处理头结点的删除逻辑 ,代码会更复杂。哨兵位头结点的优势

  1. 让所有结点的删除逻辑统一,不用单独处理头结点
  2. 保证 prev 指针永远不为空,避免空指针报错

完整可运行代码

复制代码
/**
 * Definition for singly-linked list.
 * struct ListNode {
 *     int val;
 *     struct ListNode *next;
 * };
 */
struct ListNode* removeElements(struct ListNode* head, int val) {
    // 1. 创建哨兵位头结点
    struct ListNode* newhead = (struct ListNode*)malloc(sizeof(struct ListNode));
    newhead->next = head;

    struct ListNode* prev = newhead;
    struct ListNode* cur = head;

    // 2. 遍历链表删除结点
    while (cur != NULL) {
        if (cur->val == val) {
            // 找到要删除的结点:prev直接跳过cur,指向cur的下一个结点
            prev->next = cur->next;
            free(cur); // 释放被删除结点的内存,避免内存泄漏
            cur = prev->next; // cur移动到下一个结点,继续判断
        } else {
            // 不删除的情况:两个指针一起往后走
            prev = cur;
            cur = cur->next;
        }
    }

    // 3. 保存新的头结点,释放哨兵位,避免内存泄漏
    struct ListNode* ret = newhead->next;
    free(newhead);
    return ret;
}

关键细节拆解

  1. 为什么要释放哨兵位 newheadnewhead 是我们手动 malloc 出来的 ,它不是链表的一部分,函数结束前必须 free否则会造成内存泄漏。

  2. 删除结点时 cur 的更新逻辑 当删除 cur 结点后,cur 不能直接 cur = cur->next ,因为 cur 已经被 free 了,访问它的成员会导致野指针报错。正确做法是 cur = prev->next,直接拿到新的下一个结点。

  3. 边界情况处理

    • 空链表(head == NULL):代码依然能正常运行newhead->nextNULL,直接返回空
    • 所有结点都要删除(比如示例 3):cur 会逐个被删除,最后 newhead->nextNULL,返回空链表

易错点提醒

  • 忘记释放被删除的结点和哨兵位,导致内存泄漏
  • 删除结点后,错误地 cur = cur->next访问了已经被释放的内存
  • 不使用哨兵位,单独处理头结点时逻辑混乱,漏删或多删结点

拓展:不使用哨兵位的写法(对比参考)

复制代码
struct ListNode* removeElements(struct ListNode* head, int val) {
    // 先处理头结点本身就是val的情况
    while (head != NULL && head->val == val) {
        struct ListNode* temp = head;
        head = head->next;
        free(temp);
    }

    struct ListNode* cur = head;
    while (cur != NULL && cur->next != NULL) {
        if (cur->next->val == val) {
            struct ListNode* temp = cur->next;
            cur->next = temp->next;
            free(temp);
        } else {
            cur = cur->next;
        }
    }
    return head;
}

这种写法需要先循环处理头结点再处理中间结点逻辑不如哨兵位统一,也更容易出错

四、反转链表

迭代法完整代码(C 语言 反转单链表)(头插法)

复制代码
/**
 * Definition for singly-linked list.
 * struct ListNode {
 *     int val;
 *     struct ListNode *next;
 * };
 */
struct ListNode* reverseList(struct ListNode* head) {
    struct ListNode* prev = NULL;    // 前驱节点
    struct ListNode* curr = head;    // 当前遍历节点
    struct ListNode* next = NULL;    // 临时保存后继节点

    while (curr != NULL) {
        next = curr->next;    // 先存下一个节点,防止断链
        curr->next = prev;    // 当前节点反向指向前驱
        prev = curr;          // 前驱后移
        curr = next;          // 当前节点后移
    }
    return prev; // 循环结束prev是新头结点
}
思路解析(迭代法)
  1. 三指针定义prev存前一个节点(初始空)、curr遍历链表(从 head 开始)、next临时保存原后继。
  2. 循环逻辑
    • 先用next保存curr->next,防止修改指针后丢失后续链表
    • curr->next = prev完成节点反向
    • prevcurr同步向后挪动,重复操作
  3. 返回结果 :遍历结束时curr==NULLprev指向原链表最后一个节点,即反转后的头节点。

补充:递归写法(备选方案)

复制代码
struct ListNode* reverseList(struct ListNode* head) {
    if(head == NULL || head->next == NULL) return head;
    struct ListNode* newHead = reverseList(head->next);
    head->next->next = head;
    head->next = NULL;
    return newHead;
}

递归思路:递归到链表尾作为新表头,回溯时让后一个节点指向自己,逐个反向。

💡 示例验证1→2→3→4→5迭代依次:NULL←1NULL←1←2 → ... → NULL←1←2←3←4←5,最终prev=5作为新表头返回。

五、合并两个有序链表

写法 1:哨兵虚拟头节点迭代(处理空链表不用特判)

复制代码
/**
 * Definition for singly-linked list.
 * struct ListNode {
 *     int val;
 *     struct ListNode *next;
 * };
 */
struct ListNode* mergeTwoLists(struct ListNode* list1, struct ListNode* list2) {
    // 虚拟哨兵头,不存数值,统一链表头部逻辑
    struct ListNode dummy;
    dummy.next = NULL;
    struct ListNode* tail = &dummy;

    // 两个链表都有剩余节点
    while(list1 != NULL && list2 != NULL)
    {
        if(list1->val < list2->val)
        {
            tail->next = list1;
            list1 = list1->next;
        }
        else
        {
            tail->next = list2;
            list2 = list2->next;
        }
        tail = tail->next;
    }
    // 剩余链表直接拼在尾部
    tail->next = list1 ? list1 : list2;

    return dummy.next;
}

思路说明💡

  1. dummy哨兵规避list1/list2其中一个为空的边界判断;tail永远指向新链表末尾用来拼接节点
  2. 循环每次选取更小值的节点接到tail后面 ,对应样例[1,2,4]、[1,3,4]逐步拼接:1→1→2→3→4→4
  3. 任一链表遍历完毕,剩下整条链表直接挂载尾部。

写法 2:无哨兵版合并有序链表(手动处理首节点)

复制代码
struct ListNode* mergeTwoLists(struct ListNode* list1, struct ListNode* list2) {
    // 边界:某一条链表为空直接返回另一条
    if(list1 == NULL) return list2;
    if(list2 == NULL) return list1;

    struct ListNode* head = NULL; // 最终新链表头
    struct ListNode* tail = NULL; // 新链表尾指针

    // 第一步:先选出整体最小节点作为新链表首元素(代替哨兵作用)
    if(list1->val < list2->val){
        head = tail = list1;
        list1 = list1->next;
    }else{
        head = tail = list2;
        list2 = list2->next;
    }

    // 正常尾插剩余节点
    while(list1 && list2){
        if(list1->val < list2->val){
            tail->next = list1;
            list1 = list1->next;
        }else{
            tail->next = list2;
            list2 = list2->next;
        }
        tail = tail->next; // 尾巴后移
    }
    // 剩余链表直接拼接
    tail->next = list1 ? list1 : list2;

    return head;
}

核心逻辑拆解💡

  1. 无哨兵的痛点:必须单独选第一个节点 哨兵dummy可以天然充当初始tail;去掉哨兵后,要手动挑最小节点作为head和初始tail,确定链表开头。

示例l1[1,2,4],l2[1,3,4]:首次两节点都是 1,任选一个做 head=1,tail=1。

  1. 后续逻辑和哨兵版完全一致:尾插tail->next = 更小节点 tail = tail->next,持续在末尾追加。

  2. 边界处理必须前置 如果list1/l2有一个是空,直接返回另一个,否则取->val会空指针报错。

相关推荐
邪修king1 小时前
C++map_set封装 : 红黑树底层迭代器以及仿函数的运用
android·c语言·数据结构·c++·b树
醉颜凉1 小时前
Elasticsearch 核心数据结构:FST 原理与应用场景全解析
数据结构·elasticsearch·jenkins
love_muming2 小时前
从 ArrayList 到 LinkedList:Java 集合中数组与链表的深度对比
java·数据结构·链表
05候补工程师2 小时前
【408数据结构】核心考点:图(Graph)精炼笔记与算法直觉
数据结构·经验分享·笔记·考研·算法·图论
并不喜欢吃鱼2 小时前
从零开始 C++------ 十四【C++ 数据结构】unordered_map/unordered_set 全解析:从使用到底层模拟实现
开发语言·数据结构·c++
小欣加油2 小时前
leetcode3633 最早完成陆地和水上游乐设施的时间I
数据结构·c++·算法·leetcode
啦啦啦啦啦zzzz2 小时前
数据结构:二叉排序树(递归与非递归函数的全部实现)
数据结构·c++·二叉排序树
兰令水2 小时前
leecodecode【二叉树排序+最近公共祖先】【2026.6.2打卡-java版本】
java·数据结构·算法·leetcode
晚风吹红霞3 小时前
C++ list 容器完全指南:从入门到手撕双向链表
c++·链表·list