链表是计算机科学中最基础、最常用的线性数据结构之一,与数组并称线性结构的 “两大基石”。它弥补了数组在动态插入、删除场景下的性能短板,也是面试中必考的基础考点—— 从基础实现到环、相交等变形问题,考察的核心是对指针操作、逻辑思维和边界条件的把控。
本文将从基础概念出发,讲解链表的定义、类型和优缺点,再通过 C++ 实现单链表和双链表的核心操作。
一、什么是链表?
链表是一种物理存储上非连续而逻辑连续的线性数据结构。它的核心组成单元是节点(Node),每个节点包含两个部分:
- 数据域:存储当前节点的实际数据(如 int、string、自定义对象等);
- 指针域:存储下一个(双链表还包含上一个)节点的内存地址,通过指针将分散的节点 “链” 起来,形成逻辑上的线性结构。
简单来说:数组是靠连续的内存地址保证顺序,链表是靠节点间的指针指向保证顺序。
链表的分类
根据指针的指向方式,链表主要分为以下几种类型:
- 单链表:最基础的类型,每个节点只有一个指针域,指向后继节点,尾节点的指针域为NULL(C++11 后推荐nullptr),只能从头节点开始单向遍历;
- 双向链表:每个节点有两个指针域,分别指向前驱节点和后继节点,支持双向遍历,插入 / 删除时需要同时维护两个指针,STL 中的std::list就是双向链表实现;
- 循环链表:单链表 / 双链表的变形,尾节点的指针域不指向nullptr,而是指向头节点,形成一个环形结构,可实现循环遍历,常用于环形队列、约瑟夫环问题。
二、链表的优缺点及适用场景
链表的所有特性都源于其物理非连续、指针链接的存储方式,优缺点与数组形成鲜明对比,选择时需根据业务场景权衡。
1. 优点(对比数组)
- 动态扩容,无需预先分配内存:数组在声明时需要指定固定大小(静态数组),或动态数组(如 C++ vector)扩容时需重新分配内存并拷贝数据;链表的节点按需分配,新增节点只需申请一块内存,修改指针指向即可,无扩容开销。
- 插入 / 删除操作效率高:找到目标节点后,只需修改指针指向,时间复杂度为O(1);而数组插入 / 删除需要移动后续元素,时间复杂度为O(n)(n 为元素个数)。
- 内存利用率高:仅为实际存储的数据分配节点内存,不会出现数组 “预先分配的内存未使用” 的浪费情况。
2. 缺点(对比数组)
- 不支持随机访问:要访问第 k 个节点,必须从表头开始逐个遍历,时间复杂度为O(n);而数组通过下标可直接访问,时间复杂度 O (1)。
- 额外的内存开销:每个节点都需要额外的指针域存储地址,当数据域较小时(如存储 int),指针的内存开销占比会很高。
- CPU 缓存不友好:CPU 缓存的核心原理是 “空间局部性”,即连续内存的数体会被预加载;而链表节点物理地址分散,无法利用缓存,遍历效率低于数组。
- 指针操作易出错:开发中容易出现野指针、空指针解引用、链表断裂等问题,对代码编写的严谨性要求高。
3. 适用场景
适合频繁进行插入 / 删除操作、数据量不固定、无需频繁随机访问的场景,例如:
- 实现 LRU 缓存、LFU 缓存(缓存数据淘汰算法);
- 消息队列、任务队列的底层实现;
- 操作系统的内存管理、进程调度。
不适用场景:需要频繁按索引访问、数据量固定且较小的场景(优先用数组 /vector)。
三、C++ 实现
1. 单向链表的基本实现
- 核心设计思路
- 用结构体定义链表节点,包含模板类型的数据域和指向自身的指针域;
- 链表类中维护一个头节点指针(head),头节点为哨兵节点(不存储实际数据),简化边界条件处理(如空链表、头插 / 头删);
- 实现核心操作时,重点把控空指针判空和指针指向修改,避免链表断裂和内存泄漏;
- 提供销毁函数,手动释放所有节点的内存(C++ 无自动垃圾回收,必须手动释放)。
#include <iostream>
#include <string>
using namespace std;
// 模板实现通用单链表,T为数据域类型
template <typename T>
struct ListNode {
T val; // 数据域
ListNode* next; // 指针域,指向下一个节点
// 节点构造函数,简化节点创建
ListNode(T value) : val(value), next(nullptr) {
}
};
template <typename T>
class SinglyLinkedList {
private:
ListNode<T>* head; // 头节点(哨兵节点,不存储数据)
int nSize; // 链表元素个数,方便快速获取长度
public:
// 构造函数:初始化空链表
SinglyLinkedList() {
head = new ListNode<T>(T()); // 哨兵头节点,数据域为默认值
nSize = 0;
}
// 析构函数:销毁链表,释放所有内存
~SinglyLinkedList() {
clear();
delete head; // 释放哨兵头节点
head = nullptr;
}
// 清空链表(释放所有数据节点,保留哨兵头节点)
void clear() {
ListNode<T>* cur = head->next;
while (cur != nullptr) {
ListNode<T>* temp = cur; // 临时保存当前节点,避免释放后丢失指针
cur = cur->next;
delete temp;
temp = nullptr;
}
head->next = nullptr;
nSize = 0;
}
// 获取链表元素个数
int size() const {
return nSize;
}
// 判断链表是否为空(仅含哨兵头节点)
bool isEmpty() const {
return nSize == 0;
}
// 头插法:在表头插入元素,时间复杂度O(1)
void pushFront(T val) {
ListNode<T>* newNode = new ListNode<T>(val);
newNode->next = head->next; // 新节点指向原表头第一个节点
head->next = newNode; // 哨兵头节点指向新节点
nSize++;
}
// 尾插法:在表尾插入元素,时间复杂度O(1)(若维护尾节点)/O(n)(本实现为简化,遍历到尾)
void pushBack(T val) {
ListNode<T>* newNode = new ListNode<T>(val);
ListNode<T>* cur = head;
// 遍历到尾节点(next为nullptr的节点)
while (cur->next != nullptr) {
cur = cur->next;
}
cur->next = newNode;
nSize++;
}
// 查找元素:返回第一个匹配元素的节点指针,未找到返回nullptr,时间复杂度O(n)
ListNode<T>* find(T val) const {
ListNode<T>* cur = head->next;
while (cur != nullptr) {
if (cur->val == val) {
return cur;
}
cur = cur->next;
}
return nullptr;
}
// 查找元素:返回第一个匹配元素的索引,未找到返回-1,时间复杂度O(n)
int findIndex(T val) const {
ListNode<T>* cur = head->next;
int res = 0;
while (cur != nullptr) {
if (cur->val == val) {
return res;
}
cur = cur->next;
res++;
}
return -1;
}
// 删除第一个匹配的元素,成功返回true,失败返回false,时间复杂度O(n)
bool remove(T val) {
ListNode<T>* pre = head; // 前驱节点
ListNode<T>* cur = head->next; // 当前节点
while (cur != nullptr) {
if (cur->val == val) {
pre->next = cur->next; // 前驱节点指向后继节点,跳过当前节点
delete cur; // 释放当前节点内存
cur = nullptr;
nSize--;
return true;
}
pre = cur;
cur = cur->next;
}
return false; // 未找到元素
}
// 遍历链表并打印,时间复杂度O(n)
void traverse() const {
ListNode<T>* cur = head->next;
if (cur == nullptr) {
cout << "链表为空" << endl;
return;
}
while (cur != nullptr) {
cout << cur->val;
if (cur->next != nullptr) {
cout << " -> ";
}
cur = cur->next;
}
cout << endl;
}
// 暴露头节点指针(供后续面试题使用,实际开发中应封装为私有,提供接口)
ListNode<T>* getHead() const {
return head->next;
}
};
- 代码关键说明
- 模板设计:通过template 实现通用链表,支持int、string、自定义对象等任意可比较的类型
- 哨兵头节点:避免了空链表、头插 / 头删时的边界判断(如_head为nullptr的情况),简化代码;
- 内存管理:析构函数和clear函数手动释放所有节点,避免内存泄漏,这是 C++ 实现链表的重点和易错点;
- 接口封装:将核心操作封装为类的成员函数,对外提供简洁的接口,符合面向对象的设计思想。
2. 双向链表的基本实现
- 核心设计思路
- 节点结构:每个节点包含 prev(指向前驱节点)、val(数据域)、next(指向后继节点);
- 哨兵节点:设计头哨兵(head) 和尾哨兵(tail),两个哨兵互相指向(空链表时 head->next = tail,tail->prev = head),彻底简化空链表、头插 / 尾插 / 头删 / 尾删的边界条件;
- 核心操作:插入 / 删除时必须同时维护 prev 和 next 指针,避免链表断裂;尾插 / 尾删直接通过尾哨兵实现 O (1) 时间复杂度;
- 内存管理:手动释放所有数据节点和哨兵节点,避免 C++ 内存泄漏;
- 通用性:基于模板实现,支持任意可比较的数据类型。
template <typename T>
class DoublyLinkedList {
private:
DListNode<T>* head; // 头哨兵节点(不存储数据)
DListNode<T>* tail; // 尾哨兵节点(不存储数据)
int nSize; // 链表有效元素个数
public:
// 构造函数:初始化空双向链表
DoublyLinkedList() {
head = new DListNode<T>(T()); // 头哨兵
tail = new DListNode<T>(T()); // 尾哨兵
head->next = tail; // 空链表:头哨兵next指向尾哨兵
tail->prev = head; // 尾哨兵prev指向头哨兵
nSize = 0;
}
// 析构函数:销毁链表,释放所有内存
~DoublyLinkedList() {
clear(); // 释放所有数据节点
delete head; // 释放头哨兵
delete tail; // 释放尾哨兵
head = nullptr;
tail = nullptr;
}
// 清空链表(仅保留哨兵节点)
void clear() {
DListNode<T>* cur = head->next; // 从第一个数据节点开始
while (cur != tail) {
// 遍历到尾哨兵前结束
DListNode<T>* temp = cur;
cur = cur->next; // 先移动指针,再释放
delete temp;
temp = nullptr;
}
// 重置哨兵指向,恢复空链表状态
head->next = tail;
tail->prev = head;
nSize = 0;
}
// 获取链表有效元素个数
int size() const {
return nSize;
}
// 判断链表是否为空
bool isEmpty() const {
return nSize == 0;
}
// 头插法:在表头(第一个数据节点前)插入元素,O(1)
void pushFront(T val) {
DListNode<T>* newNode = new DListNode<T>(val);
DListNode<T>* firstNode = head->next; // 原第一个数据节点(可能是尾哨兵)
// 1. 新节点prev指向头哨兵
newNode->prev = head;
// 2. 新节点next指向原第一个节点
newNode->next = firstNode;
// 3. 原第一个节点prev指向新节点
firstNode->prev = newNode;
// 4. 头哨兵next指向新节点
head->next = newNode;
nSize++;
}
// 尾插法:在表尾(最后一个数据节点后)插入元素,O(1)
void pushBack(T val) {
DListNode<T>* newNode = new DListNode<T>(val);
DListNode<T>* lastNode = tail->prev; // 原最后一个数据节点(可能是头哨兵)
// 1. 新节点prev指向原最后一个节点
newNode->prev = lastNode;
// 2. 新节点next指向尾哨兵
newNode->next = tail;
// 3. 原最后一个节点next指向新节点
lastNode->next = newNode;
// 4. 尾哨兵prev指向新节点
tail->prev = newNode;
nSize++;
}
// 正向查找元素:返回第一个匹配的节点指针,未找到返回nullptr,O(n)
DListNode<T>* find(T val) const {
DListNode<T>* cur = head->next; // 从第一个数据节点开始遍历
while (cur != tail) {
if (cur->val == val) {
return cur;
}
cur = cur->next;
}
return nullptr; // 未找到
}
// 正向查找元素:返回第一个匹配的元素索引,未找到返回-1,O(n)
int findIndex(T val) const {
DListNode<T>* cur = head->next; // 从第一个数据节点开始遍历
int res = 0;
while (cur != tail) {
if (cur->val == val) {
return res;
}
cur = cur->next;
res++;
}
return -1; // 未找到
}
// 反向查找元素:从尾到头查找,返回第一个匹配的节点指针,未找到返回nullptr,O(n)
DListNode<T>* findReverse(T val) const {
DListNode<T>* cur = tail->prev; // 从最后一个数据节点开始
while (cur != head) {
if (cur->val == val) {
return cur;
}
cur = cur->prev;
}
return nullptr; // 未找到
}
// 反向查找元素:从尾到头查找,返回第一个匹配的元素索引,未找到返回-1,O(n)
int findReverseIndex(T val) const {
DListNode<T>* cur = tail->prev; // 从最后一个数据节点开始
int res = 0;
while (cur != head) {
if (cur->val == val) {
return nSize - 1 - res;
}
cur = cur->prev;
res++;
}
return -1; // 未找到
}
// 删除第一个匹配的元素,成功返回true,失败返回false,O(n)
bool remove(T val) {
DListNode<T>* target = find(val);
if (target == nullptr) {
return false; // 无匹配元素
}
// 1. 前驱节点的next指向后继节点
target->prev->next = target->next;
// 2. 后继节点的prev指向前驱节点
target->next->prev = target->prev;
// 3. 释放目标节点
delete target;
target = nullptr;
nSize--;
return true;
}
// 正向遍历:从头到尾打印,O(n)
void traverseForward() const {
if (isEmpty()) {
cout << "链表为空" << endl;
return;
}
DListNode<T>* cur = head->next;
while (cur != tail) {
cout << cur->val;
if (cur->next != tail) {
// 最后一个节点后不加分隔符
cout << " <-> ";
}
cur = cur->next;
}
cout << endl;
}
// 反向遍历:从尾到头打印(双向链表特有),O(n)
void traverseReverse() const {
if (isEmpty()) {
cout << "链表为空" << endl;
return;
}
DListNode<T>* cur = tail->prev;
while (cur != head) {
cout << cur->val;
if (cur->prev != head) {
// 第一个节点后不加分隔符
cout << " <-> ";
}
cur = cur->prev;
}
cout << endl;
}
// 暴露头指针(供扩展使用,实际开发建议封装)
DListNode<T>* getHead() const {
return head->next; // 返回第一个数据节点(非哨兵)
}
// 暴露尾指针(供扩展使用)
DListNode<T>* getTail() const {
return tail->prev; // 返回最后一个数据节点(非哨兵)
}
};
- 代码关键说明
- 节点结构与哨兵设计
- 双向链表节点比单链表多一个 prev 指针,用于指向前面的节点;
- 头 / 尾双哨兵节点是双向链表实现的 “关键技巧”:空链表时头尾哨兵互相指向,插入 / 删除时无需判断 “是否是头 / 尾节点”,彻底规避空指针风险。
- 插入操作(头插 / 尾插)
插入的核心是维护 4 个指针指向(以尾插为例):- 新节点的 prev 指向原最后一个数据节点;
- 新节点的 next 指向尾哨兵;
- 原最后一个数据节点的 next 指向新节点;
- 尾哨兵的 prev 指向新节点。
所有插入操作都是 O (1) 时间复杂度(单链表尾插是 O (n)),这是双向链表的核心优势。
- 删除操作
删除的核心是让目标节点的前驱和后继互相指向,跳过目标节点:- target->prev->next = target->next:前驱节点的 next 指向后继;
- target->next->prev = target->prev:后继节点的 prev 指向前驱;
- 最后释放目标节点内存,避免泄漏。
- 反向遍历
双向链表特有功能,通过尾哨兵的 prev 指针从后往前遍历,无需额外处理,体现了双向链表的灵活性。
- 节点结构与哨兵设计
总结
链表是数据结构的基础,也是面试的 “敲门砖”,其核心价值在于动态的插入 / 删除性能,弥补了数组的短板。本文从基础到实战,梳理了链表的核心知识点:
- 链表是物理非连续、逻辑连续的线性结构,由节点组成,核心类型为单链表、双链表、循环链表;
- 链表的优点是动态扩容、插入删除 O (1),缺点是不支持随机访问、有指针开销、缓存不友好;
- C++ 实现单链表的关键是哨兵节点(简化边界)和手动内存管理(避免泄漏),模板实现可提高通用性;
链表的知识点看似简单,但越基础的内容,越能考察程序员的代码功底和逻辑思维 —— 这也是面试官偏爱考察链表的根本原因。明天讲解关于链表的面试高频考点。

菜鸟笔记