菜鸟笔记
提升您的技术认知

深入理解链表:从基础概念到 C++ 实现

链表是计算机科学中最基础、最常用的线性数据结构之一,与数组并称线性结构的 “两大基石”。它弥补了数组在动态插入、删除场景下的性能短板,也是面试中必考的基础考点—— 从基础实现到环、相交等变形问题,考察的核心是对指针操作、逻辑思维和边界条件的把控。
本文将从基础概念出发,讲解链表的定义、类型和优缺点,再通过 C++ 实现单链表和双链表的核心操作。

一、什么是链表?

链表是一种物理存储上非连续而逻辑连续的线性数据结构。它的核心组成单元是节点(Node),每个节点包含两个部分:

  1. 数据域:存储当前节点的实际数据(如 int、string、自定义对象等);
  2. 指针域:存储下一个(双链表还包含上一个)节点的内存地址,通过指针将分散的节点 “链” 起来,形成逻辑上的线性结构。

简单来说:数组是靠连续的内存地址保证顺序,链表是靠节点间的指针指向保证顺序。
链表的分类
根据指针的指向方式,链表主要分为以下几种类型:

  1. 单链表:最基础的类型,每个节点只有一个指针域,指向后继节点,尾节点的指针域为NULL(C++11 后推荐nullptr),只能从头节点开始单向遍历;
  2. 双向链表:每个节点有两个指针域,分别指向前驱节点和后继节点,支持双向遍历,插入 / 删除时需要同时维护两个指针,STL 中的std::list就是双向链表实现;
  3. 循环链表:单链表 / 双链表的变形,尾节点的指针域不指向nullptr,而是指向头节点,形成一个环形结构,可实现循环遍历,常用于环形队列、约瑟夫环问题。

二、链表的优缺点及适用场景

链表的所有特性都源于其物理非连续、指针链接的存储方式,优缺点与数组形成鲜明对比,选择时需根据业务场景权衡。

1. 优点(对比数组)

  1. 动态扩容,无需预先分配内存:数组在声明时需要指定固定大小(静态数组),或动态数组(如 C++ vector)扩容时需重新分配内存并拷贝数据;链表的节点按需分配,新增节点只需申请一块内存,修改指针指向即可,无扩容开销。
  2. 插入 / 删除操作效率高:找到目标节点后,只需修改指针指向,时间复杂度为O(1);而数组插入 / 删除需要移动后续元素,时间复杂度为O(n)(n 为元素个数)。
  3. 内存利用率高:仅为实际存储的数据分配节点内存,不会出现数组 “预先分配的内存未使用” 的浪费情况。

2. 缺点(对比数组)

  1. 不支持随机访问:要访问第 k 个节点,必须从表头开始逐个遍历,时间复杂度为O(n);而数组通过下标可直接访问,时间复杂度 O (1)。
  2. 额外的内存开销:每个节点都需要额外的指针域存储地址,当数据域较小时(如存储 int),指针的内存开销占比会很高。
  3. CPU 缓存不友好:CPU 缓存的核心原理是 “空间局部性”,即连续内存的数体会被预加载;而链表节点物理地址分散,无法利用缓存,遍历效率低于数组。
  4. 指针操作易出错:开发中容易出现野指针、空指针解引用、链表断裂等问题,对代码编写的严谨性要求高。

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; // 返回最后一个数据节点(非哨兵)
	}
};
  • 代码关键说明
    1. 节点结构与哨兵设计
      • 双向链表节点比单链表多一个 prev 指针,用于指向前面的节点;
      • 头 / 尾双哨兵节点是双向链表实现的 “关键技巧”:空链表时头尾哨兵互相指向,插入 / 删除时无需判断 “是否是头 / 尾节点”,彻底规避空指针风险。
    2. 插入操作(头插 / 尾插)
      插入的核心是维护 4 个指针指向(以尾插为例):
      • 新节点的 prev 指向原最后一个数据节点;
      • 新节点的 next 指向尾哨兵;
      • 原最后一个数据节点的 next 指向新节点;
      • 尾哨兵的 prev 指向新节点。
        所有插入操作都是 O (1) 时间复杂度(单链表尾插是 O (n)),这是双向链表的核心优势。
    3. 删除操作
      删除的核心是让目标节点的前驱和后继互相指向,跳过目标节点:
      • target->prev->next = target->next:前驱节点的 next 指向后继;
      • target->next->prev = target->prev:后继节点的 prev 指向前驱;
      • 最后释放目标节点内存,避免泄漏。
    4. 反向遍历
      双向链表特有功能,通过尾哨兵的 prev 指针从后往前遍历,无需额外处理,体现了双向链表的灵活性。

总结

链表是数据结构的基础,也是面试的 “敲门砖”,其核心价值在于动态的插入 / 删除性能,弥补了数组的短板。本文从基础到实战,梳理了链表的核心知识点:

  1. 链表是物理非连续、逻辑连续的线性结构,由节点组成,核心类型为单链表、双链表、循环链表;
  2. 链表的优点是动态扩容、插入删除 O (1),缺点是不支持随机访问、有指针开销、缓存不友好;
  3. C++ 实现单链表的关键是哨兵节点(简化边界)和手动内存管理(避免泄漏),模板实现可提高通用性;

链表的知识点看似简单,但越基础的内容,越能考察程序员的代码功底和逻辑思维 —— 这也是面试官偏爱考察链表的根本原因。明天讲解关于链表的面试高频考点。