链表的引入与概念
链表是一种物理存储结构上非连续、非顺序 的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的。
生活中的例子:淡季时车次的车厢会相应减少,旺季时车次的车厢会额外增加几节。只需要将火车里的某节车厢去掉/加上,不会影响其他车厢,每节车厢都是独立存在的。
2. 单链表(Singly Linked List)
2.1 结点(Node)
链表里的每节"车厢"都是独立申请下来的空间,我们称之为"结点"。
结点的组成主要有两个部分:当前结点要保存的数据 和 保存下一个结点的地址(指针变量)。
2.2 链表的性质
-
链式结构在逻辑上是连续的,在物理结构上不一定连续。
-
结点一般是从堆上申请的。
-
从堆上申请来的空间,是按照一定策略分配出来的,每次申请的空间可能连续,可能不连续。
cpp
// 假设当前保存的结点为整型
struct SListNode
{
int data; // 结点数据
struct SListNode* next; // 指针变量用于保存下一个结点的地址
};
2.3 链表的打印
给定链表结构,如何实现结点从头到尾的打印?
cpp
void SLTPrint(SLTNode* phead) {
SLTNode *pcur = phead;
while (pcur) {
printf("%d ", pcur->data);
pcur = pcur->next;
}
printf("\n");
}
2.4 单链表的实现(SList.h)
cpp
typedef int SLTDataType;
typedef struct SListNode
{
SLTDataType data; // 结点数据
struct SListNode* next; // 指针保存下一个结点的地址
} SLTNode;
void SLTPrint(SLTNode* phead);
// 头部插入删除/尾部插入删除
void SLTPushBack(SLTNode* phead, SLTDataType x);
void SLTPushFront(SLTNode* phead, SLTDataType x);
void SLTPopBack(SLTNode* phead);
void SLTPopFront(SLTNode* phead);
// 查找
SLTNode* SLTFind(SLTNode* phead, SLTDataType x);
// 在指定位置之前插入数据
void SLTInsert(SLTNode* phead, SLTNode* pos, SLTDataType x);
// 删除pos结点
void SLTErase(SLTNode* phead, SLTNode* pos);
// 在指定位置之后插入数据
void SLTInsertAfter(SLTNode* pos, SLTDataType x);
// 删除pos之后的结点
void SLTEraseAfter(SLTNode* pos);
// 销毁链表
void SListDestroy(SLTNode* phead);
3. 链表的分类(8种结构)
链表的结构非常多样,以下情况组合起来就有8种(2×2×2)链表结构:
-
单向 或者 双向
-
带头 或者 不带头
-
循环 或者 不循环
实际中最常用的是两种结构:
-
无头单向非循环链表:结构简单,一般不会单独用来存数据。实际中更多是作为其他数据结构的子结构,如哈希桶、图的邻接表等。在笔试面试中出现很多。
-
带头双向循环链表:结构最复杂,一般用在单独存储数据。实际中使用的链表数据结构,都是带头双向循环链表。
4. 经典算法题:快慢指针(环形链表)
4.1 环形链表 I:判断是否有环
快慢指针:慢指针一次走一步,快指针一次走两步,两个指针从链表起始位置开始运行。如果链表带环则一定会在环中相遇,否则快指针率先走到链表的末尾。
思考1:为什么快指针每次走两步,慢指针走一步可以相遇?
假设slow入环时,fast和slow之间的距离为N。每追击一次,距离缩小1步(N, N-1, N-2 ... 1, 0),最终距离一定会变为0,即追上。
思考2:快指针一次走3步,走4步,...n步行吗?
以走3步为例:每追击一次,距离缩小2步。
-
如果N是偶数,第一轮就追上了。
-
如果N是奇数,第一轮追不上,错过了,距离变成 C-1(C为环周长),进入新一轮追击。
-
若 C-1 是偶数,下一轮追上。
-
若 C-1 是奇数,永远追不上。
结论 :追不上的前提是 N是奇数,C是偶数 。但在数学推导中(如
2L = (x+1)C - N,左边为偶数,若N为奇数,则C必须为奇数),该前提不成立,因此快指针走3步、4步最终也一定可以相遇。实际写代码时,习惯用走2步的方式。
-
4.2 环形链表 II:寻找入环的第一个结点
结论:让一个指针从链表起始位置开始遍历,同时让一个指针从判环时相遇点的位置开始绕环运行,两个指针都是每次均走一步,最终肯定会入口点的位置相遇。
证明 :
设环长为R,H到入口E距离为L,E到相遇点M距离为X。
-
相遇时,slow走的路程:
L + X -
相遇时,fast走的路程:
L + X + nR(n为fast在环内转的圈数) -
因为fast速度是slow的2倍,所以:
2(L + X) = L + X + nR -
化简得:
L = nR - X = (n-1)R + (R-X) -
当 n=1 时,
L = R - X。即:从头结点出发的指针和从相遇点出发的指针,每次走一步,必定在入口点相遇。
cpp
// 判断环并找入口代码示例
typedef struct ListNode ListNode;
ListNode *detectCycle(ListNode *head) {
ListNode *slow = head, *fast = head;
while (fast && fast->next) {
slow = slow->next;
fast = fast->next->next;
if (slow == fast) {
ListNode *ptr1 = head;
ListNode *ptr2 = slow;
while (ptr1 != ptr2) {
ptr1 = ptr1->next;
ptr2 = ptr2->next;
}
return ptr1;
}
}
return NULL;
}
5. 带头双向循环链表(Doubly Linked List)
"带头"链表里的头结点,实际为**"哨兵位"**。哨兵位结点不存储任何有效元素,只是站在这里"放哨的"。
cpp
typedef int LTDataType;
typedef struct ListNode
{
struct ListNode* next; // 指针保存下一个结点的地址
struct ListNode* prev; // 指针保存前一个结点的地址
LTDataType data;
} LTNode;
LTNode* LTInit();
void LTDestroy(LTNode* pthread);
void LTPrint(LTNode* pthread);
bool LTEmpty(LTNode* pthread);
void LTPushBack(LTNode* pthread, LTDataType x);
void LTPopBack(LTNode* pthread);
void LTPushFront(LTNode* pthread, LTDataType x);
void LTPopFront(LTNode* pthread);
// 在pos位置之后插入数据
void LTInsert(LTNode* pos, LTDataType x);
void LTErase(LTNode* pos);
LTNode* LTFind(LTNode* pthread, LTDataType x);
6. 顺序表与链表的对比总结
| 不同点 | 顺序表 | 链表(单链表) |
|---|---|---|
| 存储空间上 | 物理上一定连续 | 逻辑上连续,但物理上不一定连续 |
| 随机访问 | 支持:O(1) | 不支持:O(N) |
| 任意位置插入或删除元素 | 可能需要搬移元素,效率低 O(N) | 只需修改指针指向 |
| 插入 | 动态顺序表,空间不够时需要扩容和空间浪费 | 没有容量的概念,按需申请释放,不存在空间浪费 |
| 应用场景 | 元素高效存储 + 频繁访问 | 任意位置高效插入和删除 |