文章目录
上一章针对于线性表中的顺序表我们做了详细的了解和代码的教程,那么在上一章还提到了链表与顺序表的区别,那么本章我们就从链表开始进行。
链表也是一种线性表。是一种链式的存储结构,我们可以把它想象为一条铁链。每一个节点都有当前节点与下一个节点的地址值。由此串联形成了一条链表。
链表根据组成,类别可以分类为无头结点,有头结点;单向链表与双向链表;循环和不循环链表。具体的解释如下:
链表的基本概念
对于链表,我们首先要明确一点,它和顺序表一样,在逻辑结构上同样属于线性表。也就是说,链表中的数据元素依然是一个接一个线性排开的,前后关系依然非常明确。只不过它和顺序表最大的不同在于,顺序表是把这些元素连续地放在一段内存空间里,而链表则不是这样。
链表采用的是链式存储结构。所谓链式存储,简单来说,就是每个结点单独申请空间,然后通过结点中的地址信息把这些结点一个一个连接起来。也正因为这样,链表并不要求这些结点在内存中连续存放。
我们可以把最简单的链表理解成下面这种形式:
text
[data|next] -> [data|next] -> [data|next] -> null
这里每一个方框就表示一个结点,而每一个结点一般都包含两部分内容:
1.数据域:用来存放当前结点的数据
2.指针域:用来存放下一个结点的地址或者前后结点的地址
因此,链表真正重要的并不是"数据挨着放",而是"结点之间能不能连起来"。只要地址关系没有断,那么这条链表在逻辑上就是成立的。
从这一点也能看出,链表和顺序表虽然同属线性表,但它们在底层实现方式上已经完全不同了。
链表的分类
链表的类型其实不少,刚开始学的时候很容易混。所以这一部分我们先把链表的分类系统地梳理一下。通常来说,链表主要可以从下面三个角度进行分类。
按照是否有头结点分类
无头结点链表
无头结点链表指的是链表最前面没有额外的辅助结点,第一个结点本身就是有效数据结点。
例如:
text
[10|next] -> [20|next] -> [30|next] -> null
这里的 10 所在的结点就是这条链表的第一个有效结点。
无头结点链表的优点是结构比较直接,没有多余结点,看起来也比较自然。但是它的缺点也比较明显,那就是在插入第一个结点、删除第一个结点、判断链表是否为空等边界情况时,代码往往要单独处理。
有头结点链表
有头结点链表则是在链表最前面额外加上一个结点,这个结点通常不存储有效数据,而是作为辅助结点存在。
例如:
text
[head] -> [10|next] -> [20|next] -> [30|next] -> null
这里的 head 就是头结点,它更多起到的是统一操作和简化边界处理的作用。
有头结点链表的好处就在于:
- 对第一个有效结点的操作更统一
- 插入删除时边界条件更少
- 代码写起来通常更顺
所以在教学过程中,单向带头结点链表是非常常见的一种入门实现方式。
按照方向分类
单向链表
单向链表中的每一个结点通常只有一个指针域,也就是 next,用来指向下一个结点。
例如:
text
[10|next] -> [20|next] -> [30|next] -> null
单向链表的特点很明显:
- 只能从前往后走
- 不能直接找到前一个结点
- 结构简单,实现相对容易
双向链表
双向链表中的每一个结点通常会有两个指针域:
prev:指向前一个结点next:指向后一个结点
例如:
text
null <- [prev|10|next] <-> [prev|20|next] <-> [prev|30|next] -> null
双向链表的优点就在于它的操作更灵活。因为每个结点既知道前驱,也知道后继,所以在插入和删除时通常会更方便一些。
当然,双向链表也有代价,那就是:
- 每个结点需要多存一个指针
- 空间开销更大
- 实现复杂度更高
按照是否循环分类
不循环链表
不循环链表指的是最后一个结点的指针域会指向 null,表示链表在这里结束。
例如:
text
[10] -> [20] -> [30] -> null
这是最常见,也最基础的一种链表形式。
循环链表
循环链表指的是最后一个结点不会指向 null,而是会重新连回链表的某个位置。最常见的情况就是最后一个结点重新指向头结点或者第一个有效结点。
例如:
text
[10] -> [20] -> [30]
^ |
|_______________|
这种结构就叫循环链表。
循环链表的特点是首尾相接,因此在某些特殊问题中会特别方便,比如约瑟夫环问题等。但是循环链表在遍历时一定要小心,因为如果结束条件写得不对,就很容易出现死循环。
链表的操作
虽然链表的种类比较多,但是围绕链表展开的基本操作其实是比较固定的。无论是自己手写单链表、双链表,还是后面分析 Java 中的 LinkedList 以及 C++ 中的 list,本质上都离不开下面这些操作。
初始化
链表在使用之前,首先要初始化。
- 对于无头结点链表来说,通常就是让头指针为空
- 对于有头结点链表来说,通常要先创建头结点
这个步骤虽然简单,但是会影响整个链表后续操作的写法。
插入操作
链表中的插入一般包括:
1.头插
2.尾插
3.任意位置插入
链表在插入这一点上比顺序表更有优势。因为顺序表插入时经常需要整体搬移数据,而链表通常只需要修改若干个指针关系。
例如在单链表中,如果要在某个结点后面插入一个新结点,本质上只需要做两件事:
1.让新结点指向原来的后继
2.让前面的结点指向新结点
所以说,链表的插入本质上并不是移动元素,而是重建连接关系。
如果把过程再展开一点来理解,那么链表的"增"一般可以这样看:
头插的详细过程
假设原来链表是:
text
head -> 10 -> 20 -> 30 -> null
现在想插入一个新结点 5,那么步骤一般是:
1.先创建新结点 5
2.让 5 的 next 指向原来第一个有效结点 10
3.再让 head 指向 5
这样结果就变成了:
text
head -> 5 -> 10 -> 20 -> 30 -> null
这里一定要注意顺序,必须先让新结点连上后面,再改前面的指向。因为如果一上来就直接改 head,原来后面的链就可能找不到了。
尾插的详细过程
如果是尾插,那么过程一般是:
1.先从头开始找到最后一个结点
2.创建一个新结点
3.让原来最后一个结点的 next 指向新结点
4.让新结点的 next 置为 null
例如原来是:
text
head -> 10 -> 20 -> 30 -> null
插入 40 以后就会变成:
text
head -> 10 -> 20 -> 30 -> 40 -> null
任意位置插入的详细过程
任意位置插入本质上和头插、尾插是一样的,只不过多了一个"先找到插入位置前驱结点"的过程。
也就是说,通常要分成下面几步:
1.先找到插入位置前面的那个结点
2.创建新结点
3.让新结点先连上原来的后继
4.再让前驱结点连上新结点
只要把这个过程想清楚,链表的插入其实就不难了。
删除操作
链表中的删除一般包括:
1.头删
2.尾删
3.删除指定结点
4.删除指定关键字结点
删除操作和插入操作类似,本质上也不是搬移数据,而是把原来连接着的几段关系重新接起来。
例如在单链表中,要删除某个结点,通常需要先找到它的前驱结点,然后让前驱结点越过它,直接连到它的后继结点上。
在这里还要注意一点:
- C语言中删除结点以后要释放空间
- Java 中不需要手动
free,但是也要把引用关系处理好
如果把删除过程再拆开来看,那么链表的"删"一般可以这样理解:
头删的详细过程
假设原来链表是:
text
head -> 10 -> 20 -> 30 -> null
如果要删除第一个有效结点 10,那么一般步骤就是:
1.先记录第一个有效结点是谁
2.让 head 直接跳过它,去指向 20
3.把原来那个结点释放掉或者断开引用
结果就变成:
text
head -> 20 -> 30 -> null
尾删的详细过程
尾删相对麻烦一点,尤其是在单链表里,因为单链表不能直接找到最后一个结点的前驱。
所以一般步骤是:
1.从头开始遍历
2.找到最后一个结点
3.同时记住最后一个结点前面的那个结点
4.让前驱结点的 next 变成 null
5.释放最后一个结点
也正因为这个过程要找前驱,所以单链表尾删往往没有头删方便。
删除指定结点的详细过程
如果删除的是中间某个结点,那么通常步骤是:
1.先找到目标结点的前驱
2.再找到目标结点的后继
3.让前驱直接连到后继
4.把目标结点释放或者断开
例如:
text
head -> 10 -> 20 -> 30 -> 40 -> null
如果删除 30,那么本质上就是让 20 直接连到 40。
所以说,链表删除真正关键的不是"把值抹掉",而是"把连接关系改正确"。
查找操作
链表最大的特点之一就是不支持随机访问,因此查找某个元素时,通常都只能从头开始一个一个往后比对。
例如要查找值为 x 的结点,一般就要:
1.从头开始遍历
2.依次比较当前结点数据
3.找到则返回
4.到链表末尾还没找到则说明不存在
这也决定了链表在查找方面通常不如顺序表方便。
如果把过程说得更细一点,那么链表的"查"通常就是一种顺序查找:
1.准备一个指针,从头结点或者第一个有效结点开始
2.查看当前结点的数据是不是目标值
3.如果不是,就让指针继续往后走
4.如果走到 null 还没找到,就说明不存在
这也说明了为什么链表不支持随机访问。因为它不像数组那样能直接跳到第 i 个位置,而通常只能老老实实一个一个找过去。
修改操作
如果已经找到了对应结点,那么修改数据其实并不麻烦,直接改结点中的数据域即可。
真正麻烦的往往不是"改",而是"找到这个位置"。
也就是说,链表的"改"其实往往分成两步:
1.先查
2.再改
例如想把值为 20 的结点改成 200,那么过程通常是:
1.先从头开始遍历
2.找到值为 20 的结点
3.把这个结点中的数据域改成 200
所以链表中的修改,本质上通常不是一个独立的难操作,而是建立在查找基础之上的。
遍历操作
链表中的很多操作,其实都离不开遍历,比如:
- 打印链表
- 查找元素
- 统计长度
- 找尾结点
因此,遍历是链表最基础的一项能力。
这里尤其要注意:
- 普通链表遍历时一般以
null作为结束条件 - 循环链表遍历时一般以"重新回到起点"作为结束条件
这一点非常关键,写错了就很容易出问题。
优缺点
链表和顺序表一样,都有各自适合的场景。它既不是全都好,也不是全都差。下面我们就来系统分析一下链表的优缺点。
优点
插入和删除灵活
这一点是链表最明显的优势。
顺序表在中间位置插入或者删除时,通常要移动大量元素;而链表一般只需要修改结点之间的连接关系。因此在插入删除频繁的场景中,链表通常更有优势。
不要求连续空间
顺序表依赖一整块连续空间,而链表中的每个结点都可以单独申请空间。只要还能申请到新的结点,理论上链表就还能继续扩展。
扩展比较自然
链表不像顺序表那样需要频繁考虑扩容问题。顺序表扩容时通常要重新申请更大空间,再把原有数据拷贝过去,而链表只需要申请新的结点并接到后面即可。
缺点
不支持随机访问
链表最明显的短板就是不能像数组一样通过下标直接访问第 i 个元素。想访问某个位置时,通常只能从头开始一步一步往后走。
空间开销更大
链表中的每个结点除了要存数据本身之外,还要额外存储指针域。因此和顺序表相比,链表在空间上通常要付出更多代价。
实现更容易出错
链表中的很多问题并不是出在算法思路上,而是出在指针关系上。一旦连接顺序处理错了,整条链表都有可能被弄乱,甚至直接断掉。
因此,链表这部分通常是数据结构入门中的一个重点难点。
复杂度分析
对于链表,我们不只是要知道"能不能做",还要知道"这样做快不快"。下面就来分析一下链表常见操作的时间复杂度。
头插
如果已经有头指针或者头结点,那么头插通常只需要改动少量指针,因此时间复杂度一般是:
text
O(1)
头删
头删和头插类似,通常也只需要改动少量连接关系,因此时间复杂度一般也是:
text
O(1)
尾插
尾插要分情况讨论。
如果没有额外维护尾指针,那么单链表尾插通常要先走到尾结点,因此时间复杂度一般为:
text
O(n)
如果维护了尾指针,或者本身是双向循环链表这种结构,那么尾插就可能优化到:
text
O(1)
尾删
尾删通常比尾插更麻烦一些。
对于单链表来说,想删除最后一个结点,往往还要知道它前面的结点是谁,因此通常也需要遍历,时间复杂度一般为:
text
O(n)
双向链表由于有前驱指针,所以尾删通常会更方便一些。
查找
链表不支持随机访问,所以查找一般都要从头开始遍历,时间复杂度通常为:
text
O(n)
任意位置插入和删除
如果已经找到了目标位置或者目标结点,那么真正插入和删除那一步往往只是若干次指针修改,可以视为:
text
O(1)
但是如果在这之前还要从头开始找这个位置,那么整体复杂度一般还是:
text
O(n)
所以说,链表插删快,其实是有前提的,那就是你已经拿到了正确的位置。
C和Java中手动实现
前面对于链表主要是从概念、分类和性质上进行了说明。但是如果只是停留在概念层面,那么对链表的理解其实还不够扎实。链表这一章真正难的地方,往往不在于定义,而在于结点之间到底怎么连、怎么断、怎么改。
所以在这里,我们还是按照课件中比较常见的方式,补上一部分"手动造轮子"的内容。也就是说,不直接调用现成容器,而是自己把链表基本结构写出来。
这里不把所有链表类型都再重复一遍,而是抓两种最有代表性的:
1.单向带头结点链表
2.双向循环无头结点链表
C语言手动实现链表
在 C 语言里手写链表时,最核心的其实就是两件事:
1.定义结点结构
2.处理指针关系
例如单向带头结点链表,一般结点可以这样定义:
c
typedef int SLTDataType;
typedef struct SListNode
{
SLTDataType data;
struct SListNode* next;
}SLTNode;
这里的 data 用来保存数据,next 用来指向后继结点。
如果采用带头结点写法,那么初始化时通常会先额外创建一个头结点:
c
SLTNode* SListInit()
{
SLTNode* head = (SLTNode*)malloc(sizeof(SLTNode));
if(head == NULL)
{
perror("malloc fail");
exit(-1);
}
head->next = NULL;
return head;
}
这样一来,后续头插、头删、判空这些操作会更统一一些。
例如头插:
c
void SListPushFront(SLTNode* phead, SLTDataType x)
{
SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));
newnode->data = x;
newnode->next = phead->next;
phead->next = newnode;
}
尾插:
c
void SListPushBack(SLTNode* phead, SLTDataType x)
{
SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));
newnode->data = x;
newnode->next = NULL;
SLTNode* cur = phead;
while(cur->next)
{
cur = cur->next;
}
cur->next = newnode;
}
从这里其实就能很明显地看出来,单链表手写的关键并不在于语法,而在于:
- 当前结点是谁
- 新结点要插到哪里
- 指针顺序应该先改谁
如果要写双向循环无头结点链表,那么结点通常会变成这样:
c
typedef int LTDataType;
typedef struct ListNode
{
LTDataType data;
struct ListNode* prev;
struct ListNode* next;
}LTNode;
这里比单链表多了一个 prev,所以操作起来更灵活,但同时也更容易在指针细节上出错。
例如尾插时,核心思想一般是:
1.先找到尾结点
2.让新结点连上头和尾
3.再让头尾也连上新结点
这部分代码虽然不一定很长,但如果没有画图,很多同学第一次写的时候还是很容易把顺序写乱。
所以说,C 语言手写链表这一环节,本质上就是在训练:
- 对结点结构的理解
- 对前驱和后继关系的理解
- 对边界情况的处理能力
Java中手动实现链表
在 Java 里面,虽然没有 malloc 和 free,但是手写链表的思想其实并没有变。只不过 C 语言里操作的是指针,Java 里操作的是对象引用。
例如单链表的结点类,一般可以写成:
java
class ListNode {
public int val;
public ListNode next;
public ListNode(int val) {
this.val = val;
}
}
如果采用带头结点的写法,那么链表类中通常会维护一个头结点:
java
public class MySingleList {
private ListNode head;
public MySingleList() {
head = new ListNode(-1);
}
}
这里的 head 本身不存有效数据,它只是一个辅助结点。
例如头插可以写成:
java
public void addFirst(int data) {
ListNode node = new ListNode(data);
node.next = head.next;
head.next = node;
}
尾插可以写成:
java
public void addLast(int data) {
ListNode node = new ListNode(data);
ListNode cur = head;
while (cur.next != null) {
cur = cur.next;
}
cur.next = node;
}
而如果是双向循环无头结点链表,那么 Java 中通常也会定义成:
java
class Node {
public int data;
public Node prev;
public Node next;
public Node(int data) {
this.data = data;
}
}
只不过和 C 语言不同的是,Java 里删除结点之后不需要自己释放空间,垃圾回收机制会在后续合适的时候处理这些不再使用的对象。
所以从学习角度来看:
- C 语言手写链表,更适合理解底层指针关系
- Java 手写链表,更适合理解面向对象封装下的数据结构实现
这两者虽然写法不同,但底层思想其实是一样的。
Java中LinkedList的分析
讲完链表本身以后,我们再来看一下 Java 中的 LinkedList。
很多同学一看到 LinkedList,就会直接把它理解成"Java里的链表"。这个说法大方向没有问题,但如果要说得更准确一些,那么我们可以认为:
LinkedList是 Java 集合框架中基于链表思想实现出来的一种线性容器。
LinkedList的底层特征
从底层思想上看,LinkedList 更接近于双向链表结构。也就是说,它的每一个结点通常都会保存:
- 当前结点的数据
- 前驱结点引用
- 后继结点引用
也正因为这样,LinkedList 在头部和尾部插入删除时会比较方便。
LinkedList的优点
头尾增删方便
由于 LinkedList 更接近双向链表,所以在头部和尾部做插入删除时比较灵活。
适合做队列或双端队列
LinkedList 在 Java 中除了能当普通线性表来使用外,还经常被当成:
- 队列
- 双端队列
这也是课件中为什么经常把它放在链表和队列之间进行分析的一个原因。
LinkedList的缺点
下标访问效率不高
虽然 LinkedList 也支持按位置获取元素,比如 get(index),但是它的底层并不像数组那样可以直接通过地址定位,而通常还是要沿着链表一步一步走过去,因此效率并不高。
结点额外开销较大
每个结点除了数据,还要额外保存引用信息,因此空间开销会比顺序表结构更大。
LinkedList和ArrayList的选择
这个问题其实本质上还是链表和顺序表的选择问题。
如果场景更偏向于:
- 频繁下标访问
- 更重视查找效率
- 更依赖连续空间
那么 ArrayList 往往更合适。
如果场景更偏向于:
- 头尾增删较多
- 不怎么依赖随机访问
- 更看重结构调整
那么 LinkedList 更有优势。
不过在实际 Java 开发中,ArrayList 往往使用得更频繁一些,因为它的综合表现通常更好。所以 LinkedList 并不是"全方面更强",而是适合特定场景。
C++中对于list的分析
在 Java 中有 LinkedList,那么在 C++ 标准模板库 STL 中,也有一个非常典型的链表容器,那就是 list。
list的本质
list 本质上也是一个链式容器。它和 vector 的关系,其实就有点类似于链表和顺序表的关系。
vector更像顺序表list更像链表
因此,list 最核心的特点也依然围绕链表展开。
list的特点
插入删除灵活
如果已经拿到了某个位置的迭代器,那么 list 在该位置附近进行插入和删除通常是比较方便的。因为它不需要像 vector 那样整体搬移后面的元素。
不支持随机访问
这是 list 和 vector 一个非常关键的区别。
vector 支持随机访问,而 list 不支持。也就是说,list 的迭代器不能像数组那样直接跳很多步。本质原因还是那句话:链表不支持随机访问。
更适合频繁插删,不适合频繁定位
如果一个问题中经常需要中间插入删除元素,那么 list 可能更合适;但如果经常要访问第 i 个元素,或者特别依赖连续内存,那么 list 就往往不是最优选择。
list和vector怎么选
这一点和 Java 中 LinkedList 和 ArrayList 的选择逻辑其实很像。
如果更看重:
- 随机访问
- 连续空间
- 更高的遍历局部性
那么 vector 往往更好。
如果更看重:
- 结构调整
- 中间插删
- 不想整体搬移元素
那么 list 会更适合。
不过在实际 C++ 开发中,vector 的使用频率也通常更高,只有在确实需要链表特性的时候,list 才更能体现价值。
链表与顺序表的再比较
讲到这里,其实我们可以把顺序表和链表再完整地对比一下。
| 对比项 | 顺序表 | 链表 |
|---|---|---|
| 存储方式 | 顺序存储 | 链式存储 |
| 空间要求 | 需要连续空间 | 不要求连续空间 |
| 随机访问 | 支持 | 不支持 |
| 中间插删 | 较慢 | 较快 |
| 空间开销 | 较小 | 较大 |
| 实现难度 | 较低 | 较高 |
从这里也可以很清楚地看出来:
- 顺序表更偏向于查找方便
- 链表更偏向于插删方便
所以两者并不是谁完全替代谁,而是适用场景不同。
本章小结
链表也是一种线性表,只不过它采用的是链式存储结构,不再依赖连续空间,而是通过结点与结点之间的地址关系把数据串联起来。也正因为这样,链表在插入和删除方面往往比顺序表更灵活,但在随机访问和查找某个位置元素时通常不如顺序表方便。
本章主要从链表的基本概念、分类、常见操作、优缺点以及复杂度分析这几个方面进行了介绍,同时也进一步分析了 Java 中的 LinkedList 和 C++ 中的 list。虽然这些容器并不等同于我们手写的链表,但它们的底层思想和链表的核心特点是高度一致的。
学习链表这一章,最重要的其实不是背下来几个函数名字,而是要真正理解结点之间到底是怎么连起来的。只要把连接关系理清楚,那么链表中的插入、删除、遍历等操作也就不难理解了。
好的,那么本章针对于链表的基础介绍就先到这里。下一章我们通常会继续学习栈和队列这样的线性结构拓展内容。等把顺序表、链表、栈、队列这些内容都串起来以后,后面的树、堆和图也就更容易理解了。