数据结构 | 链表超全笔记(单链表+双链表+高频算法题)

这篇笔记把链表从基础到进阶、从单链表到双链表、从基础操作到高频 LeetCode 题,一次性给你讲透,附带可直接跑的代码和易错点标注,帮你彻底啃下链表这块硬骨头。


一、链表基础:为什么链表这么重要?

链表是数据结构中最基础也最核心的线性结构之一,和数组相比,它的特点非常鲜明:

|---------|--------------------|----------------------|
| 特性 | 数组 | 链表 |
| 存储方式 | 连续内存空间 | 离散内存空间,通过指针连接 |
| 访问方式 | 随机访问(下标直接访问,O (1)) | 顺序访问(只能从头遍历,O (n)) |
| 插入 / 删除 | 需移动元素,O (n) | 仅修改指针,O (1)(已知结点位置时) |
| 空间开销 | 固定大小,易造成浪费 / 溢出 | 动态分配,每个结点额外存指针 |

而链表又分很多种,这是最常见的分类方式:

其中带头结点的单链表是我们学习和刷题中最常用的结构,它的优势非常明显:

  1. 头插、尾插、中间插入的逻辑可以统一处理,不需要单独判断头结点为空的情况

  2. 空链表和非空链表的操作方式完全一致,减少边界错误


二、单链表:从基础操作到核心原理

1. 结点定义与初始化

复制代码
typedef int LDataType;
typedef struct LNode {
    LDataType data;
    struct LNode* next;
} LNode;

// 初始化带头结点的单链表
void ListInit(LNode** L) {
    *L = (LNode*)malloc(sizeof(LNode));
    assert(*L);
    (*L)->next = NULL;
}

// 购买新结点(封装函数,避免重复写malloc)
LNode* BuyListNode(LDataType x) {
    LNode* newNode = (LNode*)malloc(sizeof(LNode));
    assert(newNode);
    newNode->data = x;
    newNode->next = NULL;
    return newNode;
}

2. 基础操作全解析

(1)打印链表
复制代码
void ListPrint(LNode* L) {
    assert(L);
    LNode* cur = L->next;
    while (cur != NULL) {
        printf("%d -> ", cur->data);
        cur = cur->next;
    }
    printf("NULL\n");
}

核心:cur 从第一个有效结点(L->next)开始遍历,直到 NULL 结束。

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

易错点:必须先让新结点的 next 指向原头结点,再修改头结点的 next 指向新结点,否则会丢失原链表。

(3)尾插
复制代码
void ListPushBack(LNode* L, LDataType x) {
    assert(L);
    LNode* tail = L;
    while (tail->next != NULL) {
        tail = tail->next;
    }
    LNode* newNode = BuyListNode(x);
    tail->next = newNode;
}

易错点:尾结点的 next 必须是 NULL,否则会形成野指针。

(4)按位置插入
复制代码
void ListInsert(LNode* L, int i, LDataType x) {
    assert(L);
    assert(i >= 0);
    LNode* iNode = L;
    int j = -1;
    while (j < i - 1 && iNode != NULL) {
        iNode = iNode->next;
        ++j;
    }
    assert(iNode); // 确保插入位置合法
    LNode* newNode = BuyListNode(x);
    newNode->next = iNode->next;
    iNode->next = newNode;
}

关键:找到第 i 个结点的前一个结点(第 i-1 个),再修改指针。这也是带头结点的优势:头插时 i=0,iNode=L,逻辑和中间插入完全一致。

(5)头删
复制代码
LDataType ListPopFront(LNode* L) {
    assert(L);
    assert(L->next); // 链表不能为空
    LNode* first = L->next;
    LDataType x = first->data;
    L->next = first->next;
    free(first);
    return x;
}
(6)尾删
复制代码
LDataType ListPopBack(LNode* L) {
    assert(L);
    assert(L->next);
    LNode* prev = L;
    LNode* cur = L->next;
    while (cur->next != NULL) {
        prev = cur;
        cur = cur->next;
    }
    LDataType x = cur->data;
    free(cur);
    prev->next = NULL;
    return x;
}

易错点:尾删必须找到尾结点的前一个结点,否则会导致链表断裂。

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

三、单链表高频算法题(LeetCode 经典)

1. 链表的中间结点(LeetCode 876)

思路:快慢指针法

  • 慢指针 slow 每次走 1 步,快指针 fast 每次走 2 步

  • 当 fast 走到 NULL 时,slow 正好在中间结点

复制代码
struct ListNode* middleNode(struct ListNode* head) {
    struct ListNode *slow = head, *fast = head;
    while (fast && fast->next) {
        slow = slow->next;
        fast = fast->next->next;
    }
    return slow;
}

2. 返回倒数第 k 个结点(剑指 Offer)

思路:快慢指针法

  • 快指针先走 k 步,再和慢指针一起走

  • 当快指针走到 NULL 时,慢指针就是倒数第 k 个结点

复制代码
struct ListNode* getKthFromEnd(struct ListNode* head, int k) {
    struct ListNode *slow = head, *fast = head;
    while (k--) {
        fast = fast->next;
    }
    while (fast) {
        slow = slow->next;
        fast = fast->next;
    }
    return slow;
}

3. 反转链表(LeetCode 206)

思路:头插法

  • 新建一个空链表,遍历原链表,将每个结点头插到新链表
复制代码
struct ListNode* reverseList(struct ListNode* head) {
    struct ListNode* newHead = NULL;
    struct ListNode* cur = head;
    while (cur) {
        struct ListNode* next = cur->next;
        cur->next = newHead;
        newHead = cur;
        cur = next;
    }
    return newHead;
}

4. 合并两个有序链表(LeetCode 21)

思路:归并思想,取小尾插

  • 新建哨兵结点,每次取两个链表中较小的结点尾插到新链表
复制代码
struct ListNode* mergeTwoLists(struct ListNode* list1, struct ListNode* list2) {
    struct ListNode* newHead = (struct ListNode*)malloc(sizeof(struct ListNode));
    newHead->next = NULL;
    struct ListNode* tail = newHead;
    while (list1 && list2) {
        if (list1->val < list2->val) {
            tail->next = list1;
            list1 = list1->next;
        } else {
            tail->next = list2;
            list2 = list2->next;
        }
        tail = tail->next;
    }
    if (list1) tail->next = list1;
    if (list2) tail->next = list2;
    return newHead->next;
}

5. 链表相交(LeetCode 160)

思路:求长度差,长链表先走

  • 先求两个链表的长度,计算长度差 gap

  • 长链表先走 gap 步,再两个链表一起走,相遇点就是交点

复制代码
struct ListNode* getIntersectionNode(struct ListNode *headA, struct ListNode *headB) {
    int lenA = 1, lenB = 1;
    struct ListNode* curA = headA;
    struct ListNode* curB = headB;
    while (curA->next) { curA = curA->next; lenA++; }
    while (curB->next) { curB = curB->next; lenB++; }
    if (curA != curB) return NULL;
    int gap = abs(lenA - lenB);
    struct ListNode *longList = headA, *shortList = headB;
    if (lenA < lenB) { longList = headB; shortList = headA; }
    while (gap--) { longList = longList->next; }
    while (longList != shortList) {
        longList = longList->next;
        shortList = shortList->next;
    }
    return longList;
}

6. 环形链表(LeetCode 141 & 142)

判断是否有环:快慢指针法

  • 慢指针每次 1 步,快指针每次 2 步,有环则一定会相遇
复制代码
bool hasCycle(struct ListNode *head) {
    struct ListNode *slow = head, *fast = head;
    while (fast && fast->next) {
        slow = slow->next;
        fast = fast->next->next;
        if (slow == fast) return true;
    }
    return false;
}

求环的入口点:相遇点与头结点同时出发,相遇点就是入口

复制代码
struct ListNode *detectCycle(struct ListNode *head) {
    struct ListNode *slow = head, *fast = head;
    while (fast && fast->next) {
        slow = slow->next;
        fast = fast->next->next;
        if (slow == fast) {
            struct ListNode* meet = slow;
            struct ListNode* cur = head;
            while (cur != meet) {
                cur = cur->next;
                meet = meet->next;
            }
            return cur;
        }
    }
    return NULL;
}

四、双向循环链表:更高效的链表结构

单链表的缺陷是无法直接访问前驱结点,而双向循环链表完美解决了这个问题,也是实际工程中最常用的链表结构。

1. 结点定义与初始化

复制代码
typedef int DCListDataType;
typedef struct DCListNode {
    DCListDataType data;
    struct DCListNode* prev;
    struct DCListNode* next;
} DCListNode;

// 初始化带头结点的双向循环链表
DCListNode* DCListInit() {
    DCListNode* head = (DCListNode*)malloc(sizeof(DCListNode));
    assert(head);
    head->prev = head;
    head->next = head;
    return head;
}

// 购买新结点
DCListNode* BuyDCListNode(DCListDataType x) {
    DCListNode* newNode = (DCListNode*)malloc(sizeof(DCListNode));
    assert(newNode);
    newNode->data = x;
    newNode->prev = NULL;
    newNode->next = NULL;
    return newNode;
}

2. 核心操作

(1)尾插
复制代码
void DCListPushBack(DCListNode* head, DCListDataType x) {
    assert(head);
    DCListNode* newNode = BuyDCListNode(x);
    DCListNode* tail = head->prev;
    tail->next = newNode;
    newNode->prev = tail;
    newNode->next = head;
    head->prev = newNode;
}
(2)按位置插入
复制代码
void DCListInsert(DCListNode* pos, DCListDataType x) {
    assert(pos);
    DCListNode* newNode = BuyDCListNode(x);
    DCListNode* posPrev = pos->prev;
    posPrev->next = newNode;
    newNode->prev = posPrev;
    newNode->next = pos;
    pos->prev = newNode;
}
(3)删除指定结点
复制代码
void DCListErase(DCListNode* pos) {
    assert(pos);
    DCListNode* posPrev = pos->prev;
    DCListNode* posNext = pos->next;
    posPrev->next = posNext;
    posNext->prev = posPrev;
    free(pos);
}
(4)打印链表
复制代码
void DCListPrint(DCListNode* head) {
    assert(head);
    DCListNode* cur = head->next;
    while (cur != head) {
        printf("%d <-> ", cur->data);
        cur = cur->next;
    }
    printf("HEAD\n");
}

五、链表易错点总结(避坑指南)

  1. 野指针问题:修改指针时一定要先保存下一个结点的地址,避免链表断裂

  2. 边界判断:空链表、头结点、尾结点的情况一定要单独考虑(带头结点的链表可以减少这类问题)

  3. 内存泄漏:malloc 的结点必须 free,尤其是在删除、销毁操作中

  4. 循环链表终止条件 :遍历循环链表时,终止条件是cur != head,而不是cur != NULL

  5. 快慢指针的循环条件fast && fast->next,避免 fast->next->next 访问空指针


六、写在最后:链表的学习建议

链表的核心难点不是代码,而是指针操作的逻辑。建议你这样学习:

  1. 先画链表的逻辑图,搞清楚每个指针的指向变化,再写代码

  2. 从单链表的基础操作开始,把头插、尾插、插入、删除练熟

  3. 再刷 LeetCode 的链表题,重点掌握快慢指针、头插法、归并思想

  4. 最后学习双向循环链表,理解它和单链表的区别与优势

相关推荐
二哈赛车手1 小时前
新人笔记---最终版智能体图片分析完整方案,包括一些总结于经验,以及各种优化点讲解
java·笔记·spring·ai·springboot
_李小白1 小时前
【智能驾驶:视觉感知后处理 阅读笔记】Day4: 相机成像模型与畸变
笔记·数码相机
十月的皮皮2 小时前
C语言学习笔记20260615-有序升序序列合并
c语言·笔记·学习
牛油果子哥q2 小时前
STL set与map底层精讲,红黑树适配原理、有序去重特性、迭代器遍历、API实战与面试核心考点全解
开发语言·数据结构·c++·面试
一切皆是因缘际会4 小时前
LLM轻量化联邦微调机理
数据结构·人工智能·数学建模·ai
玖玥拾4 小时前
C/C++ 数据结构(六)链表迭代器与底层
c语言·数据结构·c++·链表·stl库
辣香牛肉面4 小时前
CintaNotes个人笔记管理软件v3.14(v3.13.0 绿色汉化版)
笔记
牛油果子哥q4 小时前
AVL平衡树与红黑树深度精讲对比,平衡因子、四大旋转原理、着色规则、平衡策略、性能差异与面试手撕全解
数据结构·c++·面试
Irissgwe5 小时前
map/set/multimap/multiset 的底层逻辑与实现
数据结构·c++·算法·二叉树·stl·c·红黑树