1. 什么是链表
链表是一种物理存储单元上非连续、非顺序的线性数据结构,它由一系列节点(Node)组成。每个节点包含两部分:一部分是存储数据元素的数据域(data),另一部分是存储下一个节点地址的指针域(next)。
与数组不同,链表中的各个节点在内存中并不需要连续存放,节点之间通过指针相互连接,形成一个链式结构。链表的第一个节点称为头节点(head),最后一个节点的指针域指向空(NULL),表示链表的结束。
链表的核心思想是:用指针把分散在内存各处的节点串联起来,从而在逻辑上保持线性顺序。这种设计使得链表在插入和删除操作上具有天然的优势,因为只需要修改指针的指向,而不需要移动大量数据。
2. 链表的作用
链表在程序设计中主要解决以下几类问题:
- 动态内存管理:链表可以在运行时按需分配和释放节点内存,不需要预先知道数据总量,适合数据规模不确定的场景。
- 高效的插入与删除:在已知位置插入或删除节点时,链表只需要修改指针,时间复杂度为 O(1),而数组需要移动大量元素,时间复杂度为 O(n)。
- 内存碎片利用:链表不要求连续内存空间,可以充分利用内存中的零散空闲区域。
- 实现复杂数据结构的基础:链表是栈、队列、哈希表(链地址法)、图的邻接表等许多高级数据结构的基础构件。
3. 链表与数组的区别
数组和链表是两种最基础的线性数据结构,它们在内存布局、操作效率和适用场景上有显著差异。下面通过一个表格进行对比:
| 对比维度 | 数组 | 链表 |
|---|---|---|
| 内存布局 | 连续内存空间 | 非连续,节点分散存储 |
| 内存分配 | 静态分配或动态一次性分配 | 动态按需分配,逐个创建节点 |
| 访问方式 | 支持随机访问,通过下标 O(1) 定位 | 只能顺序访问,需从头遍历 O(n) |
| 插入/删除 | 需要移动大量元素,O(n) | 只需修改指针,O(1)(已知位置时) |
| 空间开销 | 无额外指针开销,但可能浪费预分配空间 | 每个节点额外存储指针,空间开销较大 |
| 缓存友好性 | 连续存储,CPU 缓存命中率高 | 节点分散,缓存命中率低 |
| 扩容 | 需要重新分配更大的内存并复制数据 | 直接新增节点即可,无需整体搬迁 |
简单来说:数组擅长随机访问和缓存友好,但插入删除代价高;链表擅长频繁插入删除,但随机访问能力弱。选择哪种结构,取决于具体业务场景对操作类型的侧重。
4. 链表的常见应用场景
链表在真实项目和系统中有广泛的应用,主要包括:
- 操作系统内存管理:空闲内存块通常用链表组织,便于分配和回收。
- 文件系统:文件分配表(FAT)和部分日志结构文件系统使用链表思想管理存储块。
- 哈希表冲突解决:链地址法用链表存储哈希冲突的元素。
- 图的邻接表:每个顶点的邻接边用链表存储,节省空间。
- LRU 缓存淘汰:哈希表 + 双向链表实现 O(1) 的访问和淘汰。
- 多项式运算:稀疏多项式用链表存储非零项,节省存储空间。
- 大数运算:超出基本类型范围的大整数用链表按位存储。
- 栈和队列的实现:链表可实现动态扩容的栈和队列。
5. C 语言实现单链表
下面用 C 语言实现一个完整的单链表,包含创建、插入、删除、遍历和销毁等基本操作。
c
#include <stdio.h>
#include <stdlib.h>
// 定义链表节点结构体
typedef struct Node {
int data; // 数据域
struct Node *next; // 指针域,指向下一个节点
} Node;
// 创建新节点
Node* createNode(int value) {
Node *newNode = (Node*)malloc(sizeof(Node));
if (newNode == NULL) {
printf("内存分配失败\n");
return NULL;
}
newNode->data = value;
newNode->next = NULL;
return newNode;
}
// 头插法:在链表头部插入节点
Node* insertAtHead(Node *head, int value) {
Node *newNode = createNode(value);
newNode->next = head; // 新节点指向原头节点
return newNode; // 新节点成为新的头节点
}
// 尾插法:在链表尾部插入节点
Node* insertAtTail(Node *head, int value) {
Node *newNode = createNode(value);
if (head == NULL) {
return newNode; // 空链表,新节点即头节点
}
Node *current = head;
while (current->next != NULL) {
current = current->next; // 遍历到最后一个节点
}
current->next = newNode; // 尾节点指向新节点
return head;
}
// 删除指定值的第一个节点
Node* deleteNode(Node *head, int value) {
if (head == NULL) return NULL;
// 如果要删除的是头节点
if (head->data == value) {
Node *temp = head;
head = head->next;
free(temp);
return head;
}
// 遍历查找目标节点的前一个节点
Node *current = head;
while (current->next != NULL && current->next->data != value) {
current = current->next;
}
if (current->next != NULL) {
Node *temp = current->next;
current->next = temp->next; // 跳过目标节点
free(temp); // 释放内存
}
return head;
}
// 遍历打印链表
void printList(Node *head) {
Node *current = head;
while (current != NULL) {
printf("%d -> ", current->data);
current = current->next;
}
printf("NULL\n");
}
// 释放整个链表
void freeList(Node *head) {
Node *current = head;
while (current != NULL) {
Node *temp = current;
current = current->next;
free(temp);
}
}
int main() {
Node *head = NULL;
// 头插法构建链表:3 -> 2 -> 1 -> NULL
head = insertAtHead(head, 1);
head = insertAtHead(head, 2);
head = insertAtHead(head, 3);
printf("头插法结果: ");
printList(head);
// 尾插法追加节点:3 -> 2 -> 1 -> 4 -> 5 -> NULL
head = insertAtTail(head, 4);
head = insertAtTail(head, 5);
printf("尾插法结果: ");
printList(head);
// 删除值为 2 的节点
head = deleteNode(head, 2);
printf("删除节点 2 后: ");
printList(head);
// 释放链表内存
freeList(head);
return 0;
}
运行结果如下:
text
头插法结果: 3 -> 2 -> 1 -> NULL
尾插法结果: 3 -> 2 -> 1 -> 4 -> 5 -> NULL
删除节点 2 后: 3 -> 1 -> 4 -> 5 -> NULL
这段代码演示了链表最核心的操作:头插法利用新节点指向原头节点实现 O(1) 插入;尾插法需要遍历到末尾再连接;删除节点时先找到目标节点的前驱,再修改指针并释放内存。注意每次 malloc 分配节点后,使用完毕必须 free 释放,否则会造成内存泄漏。
6. C++ 实现双向链表
C++ 中除了可以用 struct 手动实现链表,标准库还提供了 std::list(双向链表)和 std::forward_list(单向链表)。下面先手动实现一个双向链表,再展示标准库的用法。
cpp
#include <iostream>
using namespace std;
// 双向链表节点
struct DNode {
int data;
DNode *prev; // 指向前驱
DNode *next; // 指向后继
DNode(int val) : data(val), prev(nullptr), next(nullptr) {}
};
// 双向链表类
class DoublyLinkedList {
private:
DNode *head;
DNode *tail;
public:
DoublyLinkedList() : head(nullptr), tail(nullptr) {}
// 在尾部插入
void pushBack(int value) {
DNode *newNode = new DNode(value);
if (tail == nullptr) {
head = tail = newNode; // 空链表
} else {
tail->next = newNode;
newNode->prev = tail;
tail = newNode;
}
}
// 在头部插入
void pushFront(int value) {
DNode *newNode = new DNode(value);
if (head == nullptr) {
head = tail = newNode;
} else {
newNode->next = head;
head->prev = newNode;
head = newNode;
}
}
// 删除指定值的第一个节点
void remove(int value) {
DNode *current = head;
while (current != nullptr) {
if (current->data == value) {
if (current->prev != nullptr) {
current->prev->next = current->next;
} else {
head = current->next; // 删除的是头节点
}
if (current->next != nullptr) {
current->next->prev = current->prev;
} else {
tail = current->prev; // 删除的是尾节点
}
delete current;
return;
}
current = current->next;
}
}
// 正向遍历
void printForward() {
DNode *current = head;
while (current != nullptr) {
cout << current->data << " <-> ";
current = current->next;
}
cout << "NULL" << endl;
}
// 反向遍历
void printBackward() {
DNode *current = tail;
while (current != nullptr) {
cout << current->data << " <-> ";
current = current->prev;
}
cout << "NULL" << endl;
}
// 析构函数:释放所有节点
~DoublyLinkedList() {
DNode *current = head;
while (current != nullptr) {
DNode *temp = current;
current = current->next;
delete temp;
}
}
};
int main() {
DoublyLinkedList list;
list.pushBack(10);
list.pushBack(20);
list.pushBack(30);
list.pushFront(5);
cout << "正向遍历: ";
list.printForward();
cout << "反向遍历: ";
list.printBackward();
list.remove(20);
cout << "删除 20 后正向遍历: ";
list.printForward();
return 0;
}
双向链表每个节点多了一个指向前驱的指针,因此可以双向遍历,删除节点时不需要像单链表那样寻找前驱节点,代码更简洁。代价是每个节点多占用一个指针的内存空间。
7. C++ 标准库链表用法
在实际开发中,除非有特殊需求,通常直接使用 C++ 标准库提供的链表容器,避免重复造轮子。下面演示 std::list 的常用操作。
cpp
#include <iostream>
#include <list>
using namespace std;
int main() {
// 创建双向链表
list<int> myList;
// 尾部插入
myList.push_back(10);
myList.push_back(20);
myList.push_back(30);
// 头部插入
myList.push_front(5);
// 遍历输出
cout << "链表元素: ";
for (int val : myList) {
cout << val << " ";
}
cout << endl;
// 在指定位置插入(在第二个位置插入 15)
auto it = myList.begin();
advance(it, 2); // 迭代器前进 2 步
myList.insert(it, 15);
cout << "插入 15 后: ";
for (int val : myList) {
cout << val << " ";
}
cout << endl;
// 删除指定值
myList.remove(20);
cout << "删除 20 后: ";
for (int val : myList) {
cout << val << " ";
}
cout << endl;
// 获取链表大小
cout << "链表大小: " << myList.size() << endl;
// 判断是否为空
cout << "是否为空: " << (myList.empty() ? "是" : "否") << endl;
return 0;
}
运行结果如下:
text
链表元素: 5 10 20 30
插入 15 后: 5 10 15 20 30
删除 20 后: 5 10 15 30
链表大小: 4
是否为空: 否
std::list 是双向链表,支持 O(1) 的头部和尾部插入删除,也支持在任意已知迭代器位置插入删除。需要注意的是,std::list 不支持随机访问,不能直接用下标取值,必须通过迭代器遍历。
8. 链表常见变体
除了最基本的单链表和双向链表,还有几种常见的链表变体,在不同场景下各有优势:
- 循环链表:尾节点的 next 指向头节点,形成环形结构。适合需要循环遍历的场景,如操作系统的进程调度轮转、约瑟夫环问题。
- 双向循环链表 :头节点的 prev 指向尾节点,尾节点的 next 指向头节点。STL 的
std::list内部就是这种结构,便于从任意一端快速遍历。 - 带头节点的链表:在真正的头节点之前增加一个不存储数据的哨兵节点,统一了空表和非空表的操作逻辑,避免大量判空分支。
- 跳表(Skip List):在有序链表上增加多层索引,实现 O(log n) 的查找效率,是 Redis 有序集合的底层实现之一。
9. 总结
链表是一种通过指针串联节点的线性数据结构,它的核心价值在于动态内存管理和高效的插入删除操作。与数组相比,链表牺牲了随机访问能力,换来了更灵活的内存使用和更低的插入删除成本。
在实际开发中,选择数组还是链表,需要根据业务场景判断:如果以随机访问为主、数据规模相对固定,优先选择数组;如果数据规模动态变化大、插入删除频繁,链表是更合适的选择。C 语言中需要手动管理节点内存,C++ 则可以直接使用 std::list 或 std::forward_list 标准库容器,兼顾效率和安全性。