C语言链表详解:从单链表到双向链表
往期回顾
目录
- 前言
- 一、为什么有了顺序表,还要学习链表?
- 二、什么是链表?
- 三、图解链表
- 四、单链表节点结构设计
- 五、单链表如何遍历打印?
- 六、为什么单链表很多接口要传二级指针?
- 七、单链表常用接口总览
- 八、单链表节点的创建
- 九、单链表尾插和头插
- 十、单链表尾删和头删
- 十一、单链表查找
- 十二、单链表在指定位置插入和删除
- 十三、单链表完整代码实现
- 十四、链表的分类
- 十五、基于单链表实现通讯录
- 十六、双向带头循环链表
- 十七、双向链表常用接口实现
- 十八、顺序表和链表的对比
- 全文总结
前言
书接上回当我们学习顺序表的时候,我们知道顺序表的底层是一段连续空间。
它的优点很明显:
- 支持随机访问
- 下标访问效率高
- 尾插比较方便
- 结构简单,容易理解
但是顺序表也有明显缺点:
- 头部或中间插入、删除需要搬移大量元素
- 动态顺序表空间不够时需要扩容
- 扩容可能导致空间浪费
- 扩容时还可能需要整体搬迁数据
于是我们继续学习另一种非常重要的数据结构:
链表。
链表和顺序表最大的不同在于:
顺序表要求物理空间连续,而链表不要求物理空间连续。
如果说顺序表像一排连在一起的座位,那么链表更像一节一节独立的火车车厢。
每节车厢可以独立存在,只要上一节车厢知道下一节车厢在哪里,整列火车就能连起来。
这篇文章就来系统梳理链表相关知识,包括:
- 链表的基本概念
- 单链表的结构和实现
- 单链表常见增删查改接口
- 链表的分类
- 单链表经典 OJ 题
- 基于单链表实现通讯录
- 双向带头循环链表
- 顺序表和链表的优缺点对比
一、为什么有了顺序表,还要学习链表?
顺序表底层是数组,所以它的物理空间是连续的。
例如:
text
顺序表:
下标: 0 1 2 3 4
+----+----+----+----+----+
数据: | 10 | 20 | 30 | 40 | 50 |
+----+----+----+----+----+
这种结构的好处是:
c
a[3]
可以直接通过下标访问,效率很高。
但是问题也很明显。
1. 中间插入需要搬移元素
如果我们想在 20 和 30 之间插入 25:
text
插入前:
+----+----+----+----+----+
| 10 | 20 | 30 | 40 | 50 |
+----+----+----+----+----+
必须先把 30、40、50 往后挪:
text
移动后:
+----+----+----+----+----+----+
| 10 | 20 | | 30 | 40 | 50 |
+----+----+----+----+----+----+
然后才能插入:
text
插入后:
+----+----+----+----+----+----+
| 10 | 20 | 25 | 30 | 40 | 50 |
+----+----+----+----+----+----+
如果数据很多,移动成本就很高。
2. 空间不够时需要扩容
动态顺序表虽然可以扩容,但扩容并不是免费的。
扩容通常要:
text
申请新空间
拷贝旧数据
释放旧空间
更新指针
如果数据量很大,扩容时搬移数据也会有成本。
3. 链表解决了什么问题?
链表的核心思想是:
数据不要求连续存储,每个节点自己保存下一个节点的位置。
这样插入和删除时,很多情况下不需要整体搬移数据,只需要修改指针指向即可。
二、什么是链表?
链表是一种物理存储结构上非连续、非顺序的存储结构。
它的逻辑顺序不是靠数组下标维护,而是靠节点中的指针维护。
也就是说:
链表在逻辑上是连续的,但在物理内存上不一定连续。
例如:
text
逻辑顺序:
10 -> 20 -> 30 -> 40
但它们在内存中可能是这样:
text
内存地址不一定连续:
0x1000: [10 | 0x3000]
0x3000: [20 | 0x1800]
0x1800: [30 | 0x5000]
0x5000: [40 | NULL]
从逻辑上看,它们是连续的:
text
10 -> 20 -> 30 -> 40 -> NULL
但从物理地址看,它们不一定挨在一起。(因此这里又衍生出来了在现代工程中"链表已死"的说法,这个问题我先挖个坑之后再填)
三、图解链表
链表非常像火车。
每一节车厢都是独立的。
淡季时,可以少挂几节车厢。
旺季时,可以多加几节车厢。
如果想删除某节车厢,只需要把前一节车厢和后一节车厢重新接起来。
如果想增加某节车厢,只需要把它接到合适的位置。
链表也是一样。
text
单链表:
head
|
v
+------+--------+ +------+--------+ +------+--------+
| data | next | -> | data | next | -> | data | NULL |
+------+--------+ +------+--------+ +------+--------+
每个节点就像一节车厢。
节点里通常包含两部分:
text
1. 当前节点保存的数据
2. 下一个节点的地址
四、单链表节点结构设计
如果链表中保存的是整型数据,那么节点可以这样定义:
c
typedef int SLTDataType;
typedef struct SListNode
{
SLTDataType data; // 当前节点保存的数据
struct SListNode* next; // 保存下一个节点的地址
} SLTNode;
这个结构体有两个成员:
1. data
c
SLTDataType data;
用于保存当前节点的数据。
如果链表存的是 int,这里就是整型数据。
如果链表存的是联系人结构体,也可以把 SLTDataType 换成结构体类型。
2. next
c
struct SListNode* next;
用于保存下一个节点的地址。
如果当前节点是最后一个节点,那么:
c
next = NULL;
表示后面没有节点了。
单链表节点图
text
一个节点:
+------+--------+
| data | next |
+------+--------+
|
v
下一个节点
多个节点连接起来:
text
phead
|
v
+------+--------+ +------+--------+ +------+--------+
| 10 | o----|--->| 20 | o----|--->| 30 | NULL |
+------+--------+ +------+--------+ +------+--------+
五、单链表如何遍历打印?
链表不能像数组一样通过下标直接访问:
c
a[i]
因为链表的节点不一定连续。
所以链表遍历只能从头节点开始,一个一个往后走。
遍历思路:
text
1. 定义 cur 指向第一个节点
2. cur 不为空,就访问 cur->data
3. cur = cur->next,走向下一个节点
4. 直到 cur == NULL 停止
代码如下:
c
void SLTPrint(SLTNode* phead)
{
SLTNode* cur = phead;
while (cur != NULL)
{
printf("%d -> ", cur->data);
cur = cur->next;
}
printf("NULL\n");
}
图解:
text
cur 初始指向第一个节点:
cur
|
v
+------+--------+ +------+--------+ +------+--------+
| 10 | o----|--->| 20 | o----|--->| 30 | NULL |
+------+--------+ +------+--------+ +------+--------+
打印 10 后:
cur = cur->next
cur
|
v
+------+--------+ +------+--------+ +------+--------+
| 10 | o----|--->| 20 | o----|--->| 30 | NULL |
+------+--------+ +------+--------+ +------+--------+
六、为什么单链表很多接口要传二级指针?
学习链表的时候大家可能会有一些疑问,那就是:
为什么有些函数参数是
SLTNode* phead,有些却是SLTNode** pphead?
比如:
c
void SLTPrint(SLTNode* phead);
void SLTPushBack(SLTNode** pphead, SLTDataType x);
原因在于:
如果函数内部需要修改头指针本身,就必须传头指针的地址,也就是二级指针。
1. 只遍历,不修改头指针
打印链表时,只需要从头节点往后走,不需要修改外面的 phead。
所以传一级指针就够:
c
void SLTPrint(SLTNode* phead);
2. 插入、删除可能修改头指针
比如空链表尾插第一个节点:
text
插入前:
phead = NULL
插入后:
text
phead -> 新节点
此时函数内部必须改变外面的 phead。
如果只传一级指针,相当于把 phead 的值拷贝一份传进去,函数内部改的是副本,外面的 phead 不会变。
所以要传:
c
SLTNode** pphead
也就是头指针的地址。
二级指针图解
text
外部变量:
phead
|
v
NULL
函数参数:
pphead = &phead
通过:
c
*pphead = newnode;
才能真正修改外面的 phead。
七、单链表常用接口总览
单链表常见接口包括:
c
void SLTPrint(SLTNode* phead);
// 头部插入删除 / 尾部插入删除
void SLTPushBack(SLTNode** pphead, SLTDataType x);
void SLTPushFront(SLTNode** pphead, SLTDataType x);
void SLTPopBack(SLTNode** pphead);
void SLTPopFront(SLTNode** pphead);
// 查找
SLTNode* SLTFind(SLTNode* phead, SLTDataType x);
// 在指定位置之前插入数据
void SLTInsert(SLTNode** pphead, SLTNode* pos, SLTDataType x);
// 删除指定位置节点
void SLTErase(SLTNode** pphead, SLTNode* pos);
// 在指定位置之后插入数据
void SLTInsertAfter(SLTNode* pos, SLTDataType x);
// 删除指定位置之后的节点
void SLTEraseAfter(SLTNode* pos);
// 销毁链表
void SListDestroy(SLTNode** pphead);
这些接口基本覆盖了单链表的增删查改操作。
八、单链表节点的创建
每次插入数据,都要先创建一个新节点。
节点一般从堆区申请:
c
SLTNode* BuySLTNode(SLTDataType x)
{
SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));
if (newnode == NULL)
{
perror("malloc fail");
exit(1);
}
newnode->data = x;
newnode->next = NULL;
return newnode;
}
为什么节点一般从堆上申请?
因为链表节点数量通常不固定。
需要插入一个数据,就申请一个节点。
删除一个数据,就释放一个节点。
这比顺序表的整体扩容更灵活。
九、单链表尾插和头插
1. 尾插
尾插就是在链表最后插入新节点。
分两种情况。
情况 1:链表为空
text
phead = NULL
直接让 phead 指向新节点即可。
text
phead
|
v
+------+------+
| 10 | NULL |
+------+------+
情况 2:链表不为空
要先找到最后一个节点,然后让最后一个节点的 next 指向新节点。
text
插入前:
phead
|
v
+------+--------+ +------+------+
| 10 | o----|--->| 20 | NULL |
+------+--------+ +------+------+
尾插 30:
phead
|
v
+------+--------+ +------+--------+ +------+------+
| 10 | o----|--->| 20 | o----|--->| 30 | NULL |
+------+--------+ +------+--------+ +------+------+
代码:
c
void SLTPushBack(SLTNode** pphead, SLTDataType x)
{
assert(pphead);
SLTNode* newnode = BuySLTNode(x);
if (*pphead == NULL)
{
*pphead = newnode;
}
else
{
SLTNode* tail = *pphead;
while (tail->next != NULL)
{
tail = tail->next;
}
tail->next = newnode;
}
}
尾插需要找尾节点,所以时间复杂度是:
text
O(n)
如果额外维护尾指针,可以优化尾插。
2. 头插
头插就是在链表最前面插入节点。
图解:
text
插入前:
phead
|
v
+------+--------+ +------+------+
| 20 | o----|--->| 30 | NULL |
+------+--------+ +------+------+
头插 10:
phead
|
v
+------+--------+ +------+--------+ +------+------+
| 10 | o----|--->| 20 | o----|--->| 30 | NULL |
+------+--------+ +------+--------+ +------+------+
关键步骤:
c
newnode->next = *pphead;
*pphead = newnode;
代码:
c
void SLTPushFront(SLTNode** pphead, SLTDataType x)
{
assert(pphead);
SLTNode* newnode = BuySLTNode(x);
newnode->next = *pphead;
*pphead = newnode;
}
头插不需要遍历链表,时间复杂度是:
text
O(1)
十、单链表尾删和头删
1. 头删
头删比较简单。
只需要:
text
1. 保存原来的第一个节点
2. 让 phead 指向第二个节点
3. 释放原来的第一个节点
图解:
text
删除前:
phead
|
v
+------+--------+ +------+--------+ +------+------+
| 10 | o----|--->| 20 | o----|--->| 30 | NULL |
+------+--------+ +------+--------+ +------+------+
删除后:
phead
|
v
+------+--------+ +------+------+
| 20 | o----|--->| 30 | NULL |
+------+--------+ +------+------+
代码:
c
void SLTPopFront(SLTNode** pphead)
{
assert(pphead);
assert(*pphead);
SLTNode* del = *pphead;
*pphead = (*pphead)->next;
free(del);
}
时间复杂度:
text
O(1)
2. 尾删
尾删要删除最后一个节点。
分两种情况:
情况 1:只有一个节点
text
phead -> [10 | NULL]
删除后:
text
phead = NULL
情况 2:有多个节点
需要找到尾节点的前一个节点。
text
删除前:
phead
|
v
+------+--------+ +------+--------+ +------+------+
| 10 | o----|--->| 20 | o----|--->| 30 | NULL |
+------+--------+ +------+--------+ +------+------+
删除 30 后:
phead
|
v
+------+--------+ +------+------+
| 10 | o----|--->| 20 | NULL |
+------+--------+ +------+------+
代码:
c
void SLTPopBack(SLTNode** pphead)
{
assert(pphead);
assert(*pphead);
if ((*pphead)->next == NULL)
{
free(*pphead);
*pphead = NULL;
}
else
{
SLTNode* prev = NULL;
SLTNode* tail = *pphead;
while (tail->next != NULL)
{
prev = tail;
tail = tail->next;
}
free(tail);
prev->next = NULL;
}
}
尾删需要遍历找到尾节点,时间复杂度是:
text
O(n)
十一、单链表查找
查找某个值,只能从头开始遍历。
c
SLTNode* SLTFind(SLTNode* phead, SLTDataType x)
{
SLTNode* cur = phead;
while (cur != NULL)
{
if (cur->data == x)
{
return cur;
}
cur = cur->next;
}
return NULL;
}
如果找到了,返回该节点地址。
如果没找到,返回 NULL。
时间复杂度:
text
O(n)
因为链表不支持随机访问。
十二、单链表在指定位置插入和删除
1. 在指定位置之前插入
在 pos 节点之前插入新节点。
如果 pos 是头节点,就等价于头插。
否则需要找到 pos 的前一个节点。
图解:
text
原链表:
10 -> 20 -> 30 -> NULL
在 30 前插入 25:
10 -> 20 -> 25 -> 30 -> NULL
代码:
c
void SLTInsert(SLTNode** pphead, SLTNode* pos, SLTDataType x)
{
assert(pphead);
assert(pos);
if (*pphead == pos)
{
SLTPushFront(pphead, x);
}
else
{
SLTNode* prev = *pphead;
while (prev->next != pos)
{
prev = prev->next;
}
SLTNode* newnode = BuySLTNode(x);
prev->next = newnode;
newnode->next = pos;
}
}
2. 删除指定位置节点
如果 pos 是头节点,就等价于头删。
否则找到 pos 的前一个节点,然后跳过 pos。
图解:
text
原链表:
10 -> 20 -> 30 -> 40 -> NULL
删除 30:
10 -> 20 -> 40 -> NULL
代码:
c
void SLTErase(SLTNode** pphead, SLTNode* pos)
{
assert(pphead);
assert(*pphead);
assert(pos);
if (*pphead == pos)
{
SLTPopFront(pphead);
}
else
{
SLTNode* prev = *pphead;
while (prev->next != pos)
{
prev = prev->next;
}
prev->next = pos->next;
free(pos);
}
}
3. 在指定位置之后插入
这个操作比"在指定位置之前插入"更简单。
因为已经有了 pos,直接改 pos->next 即可。
c
void SLTInsertAfter(SLTNode* pos, SLTDataType x)
{
assert(pos);
SLTNode* newnode = BuySLTNode(x);
newnode->next = pos->next;
pos->next = newnode;
}
4. 删除指定位置之后的节点
c
void SLTEraseAfter(SLTNode* pos)
{
assert(pos);
assert(pos->next);
SLTNode* del = pos->next;
pos->next = del->next;
free(del);
}
这两个 After 操作都不需要找前驱节点,所以非常适合单链表。
十三、单链表完整代码实现
下面给出一个简化版单链表实现。
1. SList.h
c
#pragma once
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
typedef int SLTDataType;
typedef struct SListNode
{
SLTDataType data;
struct SListNode* next;
} SLTNode;
void SLTPrint(SLTNode* phead);
void SLTPushBack(SLTNode** pphead, SLTDataType x);
void SLTPushFront(SLTNode** pphead, SLTDataType x);
void SLTPopBack(SLTNode** pphead);
void SLTPopFront(SLTNode** pphead);
SLTNode* SLTFind(SLTNode* phead, SLTDataType x);
void SLTInsert(SLTNode** pphead, SLTNode* pos, SLTDataType x);
void SLTErase(SLTNode** pphead, SLTNode* pos);
void SLTInsertAfter(SLTNode* pos, SLTDataType x);
void SLTEraseAfter(SLTNode* pos);
void SListDestroy(SLTNode** pphead);
2. SList.c
c
#include "SList.h"
SLTNode* BuySLTNode(SLTDataType x)
{
SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));
if (newnode == NULL)
{
perror("malloc fail");
exit(1);
}
newnode->data = x;
newnode->next = NULL;
return newnode;
}
void SLTPrint(SLTNode* phead)
{
SLTNode* cur = phead;
while (cur != NULL)
{
printf("%d -> ", cur->data);
cur = cur->next;
}
printf("NULL\n");
}
void SLTPushFront(SLTNode** pphead, SLTDataType x)
{
assert(pphead);
SLTNode* newnode = BuySLTNode(x);
newnode->next = *pphead;
*pphead = newnode;
}
void SLTPushBack(SLTNode** pphead, SLTDataType x)
{
assert(pphead);
SLTNode* newnode = BuySLTNode(x);
if (*pphead == NULL)
{
*pphead = newnode;
}
else
{
SLTNode* tail = *pphead;
while (tail->next != NULL)
{
tail = tail->next;
}
tail->next = newnode;
}
}
void SLTPopFront(SLTNode** pphead)
{
assert(pphead);
assert(*pphead);
SLTNode* del = *pphead;
*pphead = (*pphead)->next;
free(del);
}
void SLTPopBack(SLTNode** pphead)
{
assert(pphead);
assert(*pphead);
if ((*pphead)->next == NULL)
{
free(*pphead);
*pphead = NULL;
}
else
{
SLTNode* prev = NULL;
SLTNode* tail = *pphead;
while (tail->next != NULL)
{
prev = tail;
tail = tail->next;
}
free(tail);
prev->next = NULL;
}
}
SLTNode* SLTFind(SLTNode* phead, SLTDataType x)
{
SLTNode* cur = phead;
while (cur != NULL)
{
if (cur->data == x)
{
return cur;
}
cur = cur->next;
}
return NULL;
}
void SLTInsert(SLTNode** pphead, SLTNode* pos, SLTDataType x)
{
assert(pphead);
assert(pos);
if (*pphead == pos)
{
SLTPushFront(pphead, x);
}
else
{
SLTNode* prev = *pphead;
while (prev->next != pos)
{
prev = prev->next;
}
SLTNode* newnode = BuySLTNode(x);
prev->next = newnode;
newnode->next = pos;
}
}
void SLTErase(SLTNode** pphead, SLTNode* pos)
{
assert(pphead);
assert(*pphead);
assert(pos);
if (*pphead == pos)
{
SLTPopFront(pphead);
}
else
{
SLTNode* prev = *pphead;
while (prev->next != pos)
{
prev = prev->next;
}
prev->next = pos->next;
free(pos);
}
}
void SLTInsertAfter(SLTNode* pos, SLTDataType x)
{
assert(pos);
SLTNode* newnode = BuySLTNode(x);
newnode->next = pos->next;
pos->next = newnode;
}
void SLTEraseAfter(SLTNode* pos)
{
assert(pos);
assert(pos->next);
SLTNode* del = pos->next;
pos->next = del->next;
free(del);
}
void SListDestroy(SLTNode** pphead)
{
assert(pphead);
SLTNode* cur = *pphead;
while (cur != NULL)
{
SLTNode* next = cur->next;
free(cur);
cur = next;
}
*pphead = NULL;
}
十四、链表的分类
链表的结构非常多样。
一般可以从三个角度分类:
text
1. 单向 / 双向
2. 带头 / 不带头
3. 循环 / 不循环
这三个条件组合起来,一共有:
text
2 × 2 × 2 = 8
种链表结构。
1. 单向链表和双向链表
单向链表
每个节点只保存下一个节点的地址。
text
d1 -> d2 -> d3 -> NULL
双向链表
每个节点既保存下一个节点的地址,也保存上一个节点的地址。
text
NULL <- d1 <-> d2 <-> d3 -> NULL
2. 带头链表和不带头链表
不带头链表
第一个节点就是有效数据节点。
text
phead -> d1 -> d2 -> d3 -> NULL
带头链表
头节点通常是哨兵位,不存有效数据。
text
head -> d1 -> d2 -> d3
哨兵位的好处是:
统一空链表和非空链表的处理逻辑。
3. 循环链表和非循环链表
非循环链表
最后一个节点指向 NULL。
text
d1 -> d2 -> d3 -> NULL
循环链表
最后一个节点指回头部。
text
d1 -> d2 -> d3
^ |
|___________|
实际最常见的两种链表
虽然链表类型很多,但实际最常见的是:
text
1. 无头单向非循环链表
2. 带头双向循环链表
无头单向非循环链表结构简单,常用于笔试面试题,也常作为其他数据结构的子结构。
带头双向循环链表结构看起来复杂,但实现很多操作时反而更方便。
十六、基于单链表实现通讯录
前面用顺序表实现过通讯录,现在也可以用单链表实现。
通讯录中的每个联系人可以用结构体表示:
c
#define NAME_MAX 100
#define SEX_MAX 10
#define TEL_MAX 20
#define ADDR_MAX 100
typedef struct PersonInfo
{
char name[NAME_MAX];
char sex[SEX_MAX];
int age;
char tel[TEL_MAX];
char addr[ADDR_MAX];
} PeoInfo;
然后把单链表中保存的数据类型改成:
c
typedef PeoInfo SLTDataType;
这样链表中的每个节点就保存一个联系人。
1. 通讯录初始化
如果需要从文件中加载历史数据:
c
void LoadContact(contact** con)
{
FILE* pf = fopen("contact.txt", "rb");
if (pf == NULL)
{
return;
}
PeoInfo info;
while (fread(&info, sizeof(info), 1, pf))
{
SLTPushBack(con, info);
}
fclose(pf);
}
初始化时调用:
c
void InitContact(contact** con)
{
LoadContact(con);
}
2. 添加联系人
c
void AddContact(contact** con)
{
PeoInfo info;
printf("请输入姓名:");
scanf("%s", info.name);
printf("请输入性别:");
scanf("%s", info.sex);
printf("请输入年龄:");
scanf("%d", &info.age);
printf("请输入联系电话:");
scanf("%s", info.tel);
printf("请输入地址:");
scanf("%s", info.addr);
SLTPushBack(con, info);
printf("添加成功!\n");
}
添加联系人本质上就是:
text
把一个联系人结构体尾插到单链表中。
3. 按名字查找联系人
c
contact* FindByName(contact* con, char name[])
{
contact* cur = con;
while (cur)
{
if (strcmp(cur->data.name, name) == 0)
{
return cur;
}
cur = cur->next;
}
return NULL;
}
因为链表不支持随机访问,所以查找只能从头遍历。
时间复杂度:
text
O(n)
4. 删除联系人
c
void DelContact(contact** con)
{
char name[NAME_MAX];
printf("请输入要删除的用户姓名:");
scanf("%s", name);
contact* pos = FindByName(*con, name);
if (pos == NULL)
{
printf("要删除的用户不存在,删除失败!\n");
return;
}
SLTErase(con, pos);
printf("删除成功!\n");
}
删除联系人本质上是:
text
先查找节点,再删除节点。
5. 展示联系人
c
void ShowContact(contact* con)
{
printf("%-10s %-4s %-4s %-15s %-20s\n",
"姓名", "性别", "年龄", "联系电话", "地址");
contact* cur = con;
while (cur)
{
printf("%-10s %-4s %-4d %-15s %-20s\n",
cur->data.name,
cur->data.sex,
cur->data.age,
cur->data.tel,
cur->data.addr);
cur = cur->next;
}
}
6. 保存通讯录
c
void SaveContact(contact* con)
{
FILE* pf = fopen("contact.txt", "wb");
if (pf == NULL)
{
perror("fopen fail");
return;
}
contact* cur = con;
while (cur)
{
fwrite(&(cur->data), sizeof(cur->data), 1, pf);
cur = cur->next;
}
fclose(pf);
}
程序退出时保存数据,下一次启动时再读取。
这样通讯录就可以持久化。
十七、双向带头循环链表
单链表虽然结构简单,但有一个明显问题:
找前一个节点比较麻烦。
比如删除某个节点时,如果只知道当前节点 pos,单链表还要从头遍历找到 pos 的前一个节点。
于是引入双向链表。
双向链表节点里有两个指针:
text
prev:指向前一个节点
next:指向后一个节点
结构体:
c
typedef int LTDataType;
typedef struct ListNode
{
struct ListNode* next;
struct ListNode* prev;
LTDataType data;
} LTNode;
什么是带头双向循环链表?
带头双向循环链表有一个特殊节点:
text
哨兵位头节点
它不存有效数据,只负责站在那里"放哨"。
结构图:
text
+---------------------------------------+
| |
v |
+------+--------+ +------+--------+ +------+--------+
| head | next |<-->| d1 | next |<-->| d2 | next |
+------+--------+ +------+--------+ +------+--------+
^ |
| |
+-------------------------------------------+
更抽象一点:
text
head <-> d1 <-> d2 <-> d3
^ |
|_________________________|
它有几个特点:
text
1. 有哨兵位头节点
2. 每个节点有 prev 和 next
3. 尾节点的 next 指向 head
4. head 的 prev 指向尾节点
哨兵位有什么好处?
哨兵位不存有效数据,但它非常有用。
它可以让很多边界情况变简单:
- 空链表
- 头插
- 尾插
- 头删
- 尾删
- 任意位置插入删除
都可以统一处理。
空链表时:
text
head->next = head
head->prev = head
也就是说:
text
head 自己指向自己
图解:
text
空的带头双向循环链表:
+------+
| head |
+------+
^ |
|__|
这比单链表空指针处理更优雅。
十八、双向链表常用接口实现
1. 初始化
c
LTNode* BuyLTNode(LTDataType x)
{
LTNode* node = (LTNode*)malloc(sizeof(LTNode));
if (node == NULL)
{
perror("malloc fail");
exit(1);
}
node->data = x;
node->next = NULL;
node->prev = NULL;
return node;
}
LTNode* LTInit()
{
LTNode* phead = BuyLTNode(0);
phead->next = phead;
phead->prev = phead;
return phead;
}
初始化后,phead 是哨兵位。
2. 判断是否为空
c
bool LTEmpty(LTNode* phead)
{
assert(phead);
return phead->next == phead;
}
如果头节点的 next 指向自己,说明链表为空。
3. 打印链表
c
void LTPrint(LTNode* phead)
{
assert(phead);
LTNode* cur = phead->next;
while (cur != phead)
{
printf("%d <-> ", cur->data);
cur = cur->next;
}
printf("head\n");
}
注意:
c
cur != phead
因为这是循环链表,不是判断 cur != NULL。
4. 在指定位置之前插入
双向链表插入非常优雅。
假设在 pos 前插入 newnode:
text
prev pos
| |
v v
A <-------> B
插入后:
text
A <-------> newnode <-------> B
代码:
c
void LTInsert(LTNode* pos, LTDataType x)
{
assert(pos);
LTNode* newnode = BuyLTNode(x);
LTNode* prev = pos->prev;
prev->next = newnode;
newnode->prev = prev;
newnode->next = pos;
pos->prev = newnode;
}
这段代码非常重要。
它体现了双向链表的核心:
插入就是修改前后两个节点的指针关系。
5. 尾插和头插
有了 LTInsert,尾插和头插可以直接复用。
尾插是在头节点之前插入:
c
void LTPushBack(LTNode* phead, LTDataType x)
{
assert(phead);
LTInsert(phead, x);
}
为什么在 phead 前插入就是尾插?
因为循环链表里:
text
phead->prev 是尾节点
在 phead 前插入,就相当于插到尾部。
头插是在第一个有效节点之前插入:
c
void LTPushFront(LTNode* phead, LTDataType x)
{
assert(phead);
LTInsert(phead->next, x);
}
6. 删除指定位置节点
c
void LTErase(LTNode* pos)
{
assert(pos);
LTNode* prev = pos->prev;
LTNode* next = pos->next;
prev->next = next;
next->prev = prev;
free(pos);
}
删除操作只需要让前后两个节点重新连接起来。
图解:
text
删除前:
A <-------> pos <-------> B
删除后:
A <---------------------> B
7. 尾删和头删
有了 LTErase,尾删和头删也可以复用。
c
void LTPopBack(LTNode* phead)
{
assert(phead);
assert(!LTEmpty(phead));
LTErase(phead->prev);
}
c
void LTPopFront(LTNode* phead)
{
assert(phead);
assert(!LTEmpty(phead));
LTErase(phead->next);
}
8. 查找
c
LTNode* LTFind(LTNode* phead, LTDataType x)
{
assert(phead);
LTNode* cur = phead->next;
while (cur != phead)
{
if (cur->data == x)
{
return cur;
}
cur = cur->next;
}
return NULL;
}
9. 销毁
c
void LTDestroy(LTNode* phead)
{
assert(phead);
LTNode* cur = phead->next;
while (cur != phead)
{
LTNode* next = cur->next;
free(cur);
cur = next;
}
free(phead);
}
十九、顺序表和链表的对比
最后,我们把顺序表和链表放在一起比较。
| 对比项 | 顺序表 | 链表 |
|---|---|---|
| 物理存储 | 物理空间连续 | 物理空间不一定连续 |
| 逻辑结构 | 逻辑连续 | 逻辑连续 |
| 随机访问 | 支持,O(1) |
不支持,O(n) |
| 头部插入删除 | 需要移动元素,O(n) |
修改指针,通常更方便 |
| 中间插入删除 | 需要移动元素,O(n) |
找到位置后修改指针 |
| 尾插 | 空间足够时 O(1) |
单链表无尾指针时 O(n),双向循环链表可 O(1) |
| 空间利用 | 可能有预留空间浪费 | 按需申请节点 |
| 扩容问题 | 动态顺序表需要扩容 | 没有容量概念 |
| 缓存友好性 | 连续存储,缓存友好 | 节点分散,缓存命中率较低 |
| 适合场景 | 频繁随机访问 | 频繁插入删除 |
顺序表适合什么场景?
顺序表适合:
text
1. 频繁随机访问
2. 尾插较多
3. 数据量相对稳定
4. 需要缓存友好
比如:
- 动态数组
- 需要下标访问的场景
- 排序算法中的数组存储
链表适合什么场景?
链表适合:
text
1. 频繁插入删除
2. 不要求随机访问
3. 数据规模变化较大
4. 不希望频繁扩容搬移数据
比如:
- 哈希桶
- 图的邻接表
- LRU 缓存结构中的节点组织
- 一些需要频繁插入删除的任务
全文总结
1. 链表是什么?
链表是一种物理空间不一定连续的数据结构。
它通过节点中的指针,把逻辑上相邻的数据连接起来。
2. 单链表节点由什么组成?
单链表节点通常包含:
text
1. 当前节点的数据
2. 下一个节点的地址
3. 单链表为什么很多接口要传二级指针?
因为插入、删除可能会修改头指针本身。
如果要在函数内部修改外面的 phead,就要传 &phead。
4. 单链表的优点
- 按需申请空间
- 插入删除不需要整体搬移元素
- 结构简单,适合面试题
- 适合作为其他数据结构的子结构
5. 单链表的缺点
- 不支持随机访问
- 找前驱节点麻烦
- 尾插尾删通常需要遍历
- 指针操作容易出错
6. 双向带头循环链表为什么常用?
因为它有:
text
1. 哨兵位头节点
2. prev 指针
3. next 指针
4. 循环结构
这些设计让插入、删除操作更统一,边界情况更少。
7. 顺序表和链表怎么选?
如果你需要频繁随机访问,优先考虑顺序表。
如果你需要频繁插入删除,优先考虑链表。
但没有一种结构是万能的。
数据结构真正重要的不是背概念,而是理解:
不同结构适合不同场景。