
🔥铅笔小新z:个人主页
🎬博客专栏:数据结构
💫滴水不绝,可穿石;步履不休,能至渊。

目录
- 一、线性表:什么是"线性"
- 二、顺序表:躺在内存里的"连续座位"
- 三、单链表:一节节"车厢"靠指针连起来
- 四、双向带头循环链表:进可攻、退可守的进阶版
- 五、顺序表 vs 链表:到底该用谁
- 六、全文总复习
一、线性表:什么是"线性"
1. 官方定义与大白话翻译
线性表(linear list) 是 n 个具有相同特性 的数据元素的有限序列。
逐词拆开看:
- 相同特性:里面的每一个元素都是"同一类东西"。要么全是整数,要么全是学生结构体,不能一个 int 一个 char 混着来。
- 有限:元素个数 n 是一个有限的数(n 可以等于 0,就是空表)。世界上不存在"无限多个元素"的线性表。
- 序列:有先后顺序。第 1 个、第 2 个......第 n 个,位置是有讲究的,不是一堆无序数据的集合。
生活类比:线性表就像一列排队的人,队伍是"一条直线",前面是谁、后面是谁清清楚楚,人数也是有限的。
2. 常见的线性表有哪些
顺序表、链表、栈、队列、字符串......这些我们后面都会学到,它们都属于线性表的"家族成员"。
有趣的是,栈和队列看起来"奇奇怪怪"(一个后进先出、一个先进先出),但它们本质上也是线性表------只是把"插入/删除"的规则限制死了而已。理解了这一点,后面学栈和队列会轻松很多。
3. 逻辑上连续 vs 物理上连续(本章最重要的概念!)
线性表在逻辑上是线性结构,也就是"连续的一条直线"------第 i 个元素的"逻辑前驱"和"逻辑后继"是唯一的。
但请注意,它在物理上(内存里)并不一定是连续的!
线性表在物理上存储时,通常用两种形式:
- 数组(连续存储):一整块连续的内存,一个挨一个放。这是"顺序表"的存储方式。
- 链式结构:每个元素单独散落在内存各处,用"指针"把它们像锁链一样串起来。这是"链表"的存储方式。
一句话总结:
"逻辑连续"是线性表的特点 (保证它是一条直线);
"物理是否连续"是它的存储方式(决定它到底怎么落地)。
📌 知识点总结:什么是线性表?
线性表是 n 个具有相同特性的数据元素构成的有限序列,它是对"一串有先后顺序的同类型数据"的抽象描述。
它的关键特征有三个:一是所有元素类型相同;二是元素个数 n 有限;三是元素之间具有唯一的前驱和后继关系,构成逻辑上的一条直线。
顺序表、链表、栈、队列、字符串都是线性表的典型代表。要注意区分两个概念:逻辑上线性(即存在唯一前驱/后继、像一条直线)是线性表本身的性质;而物理存储上,线性表既可以借助一片连续的数组空间实现(顺序表),也可以通过指针把分散的结点串起来实现(链表),物理上并不一定连续。
二、顺序表:躺在内存里的"连续座位"
2.1 概念与结构
顺序表 是用一段物理地址连续 的存储单元,依次存储数据元素的线性结构,一般情况下采用数组存储。
换句话说,顺序表就是"把数据一个挨一个地塞进一段连续内存里"。
顺序表和数组有什么区别?
很多小白会问:顺序表不就是数组吗?
答:不一样。要分两层看:
- 顺序表的底层结构确实就是数组。
- 但顺序表是对数组做了一层封装------在数组之上实现了常用的"增、删、改、查"等接口(函数),并且自动管理了"空间够不够"的问题。
打个比方:数组是一块"毛坯地",你自己决定怎么盖;顺序表是"精装房",开发商(封装代码)已经把水电、门禁都装好了,你直接拎包入住(调用接口)。
普通数组: int arr[10]; // 定死 10 个格子,多了放不下,少了浪费
顺序表: SL s; SLInit(&s); // 用的时候随意加,内部自动扩容
SLPushBack(&s, 5); // 往尾部插数据,满了它自己会"搬家"
2.2 顺序表的分类
根据底层数组是"死的"还是"活的",顺序表分为两类。
静态顺序表:使用定长数组存储元素
c
#define N 100 // 把容量写死
typedef int SLDataType;
struct SeqList
{
SLDataType a[N]; // 固定大小的数组,只能存 N 个
int size; // 当前有效数据个数
};
静态顺序表的缺陷(非常致命):
- 空间给少了:数据一多就放不下,直接溢出。
- 空间给多了:明明只存 3 个数据,却开 100 个格子,白白浪费内存。
写死容量 = 既怕不够用,又怕浪费。左右为难。
动态顺序表:按需申请空间(本章重点)
动态顺序表不用定长数组,而是用指针 + 动态内存管理 (malloc/realloc),数据多了就扩容,数据少了也不心疼,真正做到"按需申请"。这就是我们接下来要亲手实现的对象。
2.3 动态顺序表的实现(代码逐行讲)
第一步:设计结构体与接口(SeqList.h)
头文件里放三样东西:类型定义、结构体、函数声明。这是给别人看的"说明书"。
c
// SeqList.h ------ 动态顺序表
#pragma once
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
#define INIT_CAPACITY 4 // 初始容量:先开 4 个格子的空间
typedef int SLDataType; // 顺序表里存的数据类型
// 想改成 double、char 或自定义结构体?
// 只需改这一行,全表跟着变(复用性!)
// 动态顺序表结构体:三个成员共同描述这块"会自动长大的数组"
typedef struct SeqList
{
SLDataType* a; // a 指向堆上动态开辟的数组首地址
int size; // size:当前"有效数据"的个数
int capacity; // capacity:当前"空间容量"(最多能装几个数据)
} SL;
// ------ 初始化和销毁 ------
void SLInit(SL* ps); // 初始化:让结构体处于"空表"状态
void SLDestroy(SL* ps); // 销毁:释放堆上申请的空间,防止内存泄漏
void SLPrint(SL* ps); // 打印:把表中的数据依次显示出来(方便调试)
// ------ 扩容 ------
void SLCheckCapacity(SL* ps);// 核心辅助函数:判断空间满了没,满了就扩容
// ------ 尾插尾删 / 头插头删 ------
void SLPushBack(SL* ps, SLDataType x); // 尾部插入(最常用)
void SLPopBack(SL* ps); // 尾部删除
void SLPushFront(SL* ps, SLDataType x); // 头部插入
void SLPopFront(SL* ps); // 头部删除
// ------ 指定位置插入/删除/查找 ------
void SLInsert(SL* ps, int pos, SLDataType x); // 在下标 pos 之前插入
void SLErase(SL* ps, int pos); // 删除下标为 pos 的元素
int SLFind(SL* ps, SLDataType x); // 查找,返回下标;找不到返回 -1
💡 设计小技巧 :把 SLDataType 抽出来 typedef,是为了"一份代码,多种类型通用"。这也是工程化思维的雏形。
第二步:写实现(SeqList.c)
代码小提示 :编写代码过程中要勤测试,避免写出大量代码后再测试。如果 bug 憋到最后才暴露,问题定位会无从下手------比如你先写 200 行,再运行时崩了,你根本不知道是哪 10 行出的问题。正确姿势是"写一个函数,测一个函数"。
c
// SeqList.c ------ 动态顺序表的实现
#include "SeqList.h"
// 1. 初始化:把结构体三个成员全部清空,得到空表
void SLInit(SL* ps)
{
assert(ps); // 防御:ps 不可能是空指针,否则说明调用方传错了
ps->a = NULL; // 先不申请空间,等第一次插入时再说
ps->size = 0; // 有效数据 0 个
ps->capacity = 0; // 容量 0
}
// 2. 销毁:释放堆空间,并把指针置空(防止悬空指针再次被使用)
void SLDestroy(SL* ps)
{
assert(ps);
free(ps->a); // 释放动态申请的数组空间
ps->a = NULL; // 指针置空,避免"野指针"
ps->size = 0;
ps->capacity = 0;
}
// 3. 打印:从头到尾输出所有有效数据
void SLPrint(SL* ps)
{
assert(ps);
for (int i = 0; i < ps->size; ++i) // 只打印前 size 个有效数据
{
printf("%d ", ps->a[i]);
}
printf("\n");
}
// 4. 扩容:空间满了(size == capacity)就翻倍扩容
// 为什么翻倍?均摊下来每次插入代价是 O(1),效率高;每次只扩一个又会频繁搬运
void SLCheckCapacity(SL* ps)
{
assert(ps);
if (ps->size == ps->capacity) // 满了才需要扩容
{
int newCapacity = (ps->capacity == 0)
? INIT_CAPACITY // 首次扩容:给 4 个
: ps->capacity * 2; // 之后扩容:翻 2 倍
SLDataType* tmp = (SLDataType*)realloc(
ps->a, newCapacity * sizeof(SLDataType));
if (tmp == NULL) // realloc 失败会返回 NULL
{
perror("realloc"); // 打印错误原因
exit(1); // 直接退出程序(教学简化处理)
}
ps->a = tmp; // 让 a 指向新空间(旧数据已被 realloc 自动拷贝)
ps->capacity = newCapacity; // 更新容量
}
}
// 5. 尾部插入:在数组最后面放一个新数据(最快,不需要搬移任何元素)
void SLPushBack(SL* ps, SLDataType x)
{
assert(ps);
SLCheckCapacity(ps); // 先保证空间够用
ps->a[ps->size] = x; // 新数据放到 size 下标处
ps->size++; // 有效个数 +1
}
// 6. 尾部删除:把有效个数减一即可(逻辑删除,旧数据不用真清掉)
void SLPopBack(SL* ps)
{
assert(ps);
assert(ps->size > 0); // 空表不能删,防御
ps->size--; // 相当于"看不见它了"
}
// 7. 头部插入:所有元素必须整体后移一位,把 0 号位置空出来
void SLPushFront(SL* ps, SLDataType x)
{
assert(ps);
SLCheckCapacity(ps);
int end = ps->size - 1; // 从最后一个有效数据开始
while (end >= 0) // 从后往前搬移(顺序不能反!)
{
ps->a[end + 1] = ps->a[end]; // 每个元素往后挪一位
--end;
}
ps->a[0] = x; // 空出的 0 号位放入新数据
ps->size++;
}
// 8. 头部删除:第二个元素起整体前移一位,覆盖掉第一个元素
void SLPopFront(SL* ps)
{
assert(ps);
assert(ps->size > 0);
for (int i = 1; i < ps->size; ++i) // 从前往后搬移
{
ps->a[i - 1] = ps->a[i];
}
ps->size--;
}
// 9. 在指定下标 pos 之前插入一个元素
void SLInsert(SL* ps, int pos, SLDataType x)
{
assert(ps);
assert(pos >= 0 && pos <= ps->size); // pos 合法范围校验
SLCheckCapacity(ps);
for (int i = ps->size; i > pos; --i) // 从后往前,把 [pos, size-1] 全部后移
{
ps->a[i] = ps->a[i - 1];
}
ps->a[pos] = x;
ps->size++;
}
// 10. 删除下标为 pos 的元素
void SLErase(SL* ps, int pos)
{
assert(ps);
assert(pos >= 0 && pos < ps->size);
for (int i = pos; i < ps->size - 1; ++i) // 从 pos 开始,把后面的往前覆盖
{
ps->a[i] = ps->a[i + 1];
}
ps->size--;
}
// 11. 查找:遍历数组找 x,返回第一次出现的下标;找不到返回 -1
int SLFind(SL* ps, SLDataType x)
{
assert(ps);
for (int i = 0; i < ps->size; ++i)
{
if (ps->a[i] == x)
{
return i;
}
}
return -1;
}
看一遍核心流程 :SLInit 建空表 → SLPushBack/SLPushFront/SLInsert 往里塞数据(满了自动扩容)→ SLFind 找位置 → SLPopBack/SLPopFront/SLErase 删数据 → SLDestroy 归还内存。增删改查,齐活。
2.4 顺序表的问题与思考(引出链表!)
顺序表虽好,但用着用着就发现三个"痛点":
-
中间/头部的插入删除,时间复杂度是 O(N) 。
因为插入或删除一个元素,往往要"牵一发而动全身":在头部插一个数,后面 N 个数全都要搬家。数据量大时非常慢。
-
增容要申请新空间、拷贝数据、释放旧空间,消耗不小 。
每次扩容都要
realloc:先申请一块更大的新空间,再把旧数据全部拷贝过去,最后释放旧空间。这一步 O(N) 的搬运是纯开销。 -
增容一般按 2 倍增长,势必存在空间浪费 。
举个例子:当前容量 100,满了以后扩到 200。可你继续插入了 5 个数据就不插了------那么 200 个格子实际只用了 105 个,浪费了 95 个格子。
思考:如何解决以上问题呢?
如果我们能做到------用到多少空间就申请多少、插入删除不搬移别人的数据------是不是就完美了?这正是"链表"存在的意义。下一章的主角登场。
📌 知识点总结:什么是顺序表?它和数组有何不同?有什么缺陷?
顺序表是用一段物理地址连续的存储单元依次存放数据元素的线性结构,其底层本质是一个数组,但它不是裸数组,而是对数组的封装:在数组之上实现了增删改查等接口,并自动管理扩容,让使用者不必关心空间够不够。
顺序表按容量是否固定分为静态与动态两种。静态顺序表用定长数组,容量写死,空间给小了会溢出、给大了又浪费,所以实践中普遍使用动态顺序表------用指针配合 malloc/realloc 动态管理,满了按 2 倍扩容,数据结构中通常用 size 记录有效数据个数、capacity 记录总容量。
顺序表的核心优势是支持下标随机访问,访问任意位置是 O(1);但它有三个明显短板:其一,头部或中间插入删除需要整体搬移元素,代价是 O(N);其二,扩容需要申请新空间并拷贝旧数据,有不小开销;其三,按倍扩容会预留空间造成浪费。正是这些短板催生了链表。
三、单链表:一节节"车厢"靠指针连起来
3.1 概念与结构
链表 是一种物理存储结构上非连续、非顺序 的存储结构,数据元素的逻辑顺序 是通过链表中的指针链接次序实现的。
生活类比:会"加减车厢"的火车
高铁的编组很有意思:淡季时车厢减少,旺季时临时加挂几节。加车厢/去车厢时,不需要把整列火车推倒重排------把某节车厢摘下来或挂上去就行,其它车厢纹丝不动,每节车厢都是独立的存在。
链表就是这个思路:每个数据元素 = 一节独立申请内存的"车厢"。想插入一个数据,就"加挂一节车厢";想删除一个数据,就"摘掉一节车厢"。其它数据完全不用挪地方。
链表的结点(node)
在链表里,每一节"车厢"是独立申请 下来的空间,我们称之为结点。
一个结点由两部分组成:
- 数据域:保存当前结点要存的数据。
- 指针域:保存下一个结点的地址(用一个指针变量来装)。
c
// 链表结点的结构体定义(假设存整型数据)
struct SListNode
{
int data; // data:本结点保存的数据
struct SListNode* next; // next:指向下一个结点的指针(保存下一个结点的地址)
};
为什么结构体里要写 struct SListNode*?
因为 C 语言里结构体还没定义完,不能直接用 SListNode 这个名字,必须带上 struct 关键字。而指针存的是"地址",跟结构体多大无关,所以允许在定义过程中引用自己------这正是"自己指向自己类型"的递归式定义。
怎么从第一个结点走到最后一个结点?
只需要"链式接力":从第一个结点出发,读取它的 next 拿到第二个结点的地址 → 跳到第二个结点 → 再读它的 next ......直到某个结点的 next 为空(NULL),说明到尾巴了。
指针变量 plist 是怎么"指向"的?
假设有个指针变量 plist,它保存的是第一个结点的地址,我们就说 plist 指向第一个结点。想让 plist 指向第二个结点?把 plist 里保存的内容改成第二个结点的地址即可。"指向谁"的本质,就是"存了谁的地址"。
链表的三个重要性质
- 链式结构在逻辑上是连续的 (还是那条"直线"),但在物理结构上不一定连续(各结点散落在内存各处)。
- 结点一般是从堆 上申请的(用
malloc)。 - 从堆上申请来的空间是按一定策略分配的,每次申请的空间可能连续、也可能不连续。
小思考 :当我们想保存的数据类型是字符型、浮点型或自定义类型时,怎么修改上面的结构体?
答:把 int data 改成对应类型即可。更专业的做法是像顺序表一样,用 typedef 定义一个通用类型别名,改一处全表生效。
链表的打印:怎么把一条链表从头到尾打印出来?
c
// 打印单链表:phead 指向第一个结点(链表可能为空)
void SLTPrint(SLTNode* phead)
{
SLTNode* cur = phead; // 用一个"游标" cur 从头开始走
while (cur != NULL) // 只要 cur 还没走到 NULL(没到尾巴后)就继续
{
printf("%d -> ", cur->data); // 打印当前结点的数据
cur = cur->next; // cur 跳到下一个结点(这就是"走一步")
}
printf("NULL\n"); // 最后打印 NULL,表示链表结束
}
走查演示 :链表 1 -> 2 -> 3,cur 依次停在结点 1、2、3,每次打印 data 再后移,最后 cur 变 NULL,循环结束。整个过程一目了然------这就是"顺着 next 链"的思想,几乎所有的链表遍历都长这样。
3.2 实现单链表
接口设计(SList.h)
c
// SList.h ------ 单链表(无头、单向、非循环)
#pragma once
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
typedef int SLTDataType; // 结点数据类型的通用别名
typedef struct SListNode
{
SLTDataType data; // data:本结点保存的数据
struct SListNode* next; // next:指向下一个结点的指针
} SLTNode; // 类型重命名,后续写 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); // 头删
// 查找:返回第一个 data == x 的结点指针;找不到返回 NULL
SLTNode* SLTFind(SLTNode* phead, SLTDataType x);
// 指定位置插入/删除
void SLTInsert(SLTNode** pphead, SLTNode* pos, SLTDataType x); // 在 pos 之前插入
void SLTErase(SLTNode** pphead, SLTNode* pos); // 删除 pos 结点
void SLTInsertAfter(SLTNode* pos, SLTDataType x); // 在 pos 之后插入
void SLTEraseAfter(SLTNode* pos); // 删除 pos 之后的结点
// 销毁链表:把所有结点逐个 free,防止内存泄漏
void SListDestroy(SLTNode** pphead);
为什么这些函数要传"二级指针"?(重点中的重点!)
以"头插"为例:新结点要成为新的第一个结点,意味着必须修改 plist 本身(让它指向新结点)。
- 如果传一级指针
SLTNode* phead,函数里改的是 phead 的形参副本,调用结束后 phead 一销毁,plist 纹丝不动------头插失败! - 要修改实参本身,就得传实参的地址 。plist 本身是一个
SLTNode*,那它的地址就是SLTNode**,也就是二级指针。
c
// 头插:需要修改"头指针本身",所以接收二级指针 pphead
void SLTPushFront(SLTNode** pphead, SLTDataType x)
{
assert(pphead); // 二级指针本身不能为空
SLTNode* newnode = BuySListNode(x); // 1. 申请并初始化一个新结点
newnode->next = *pphead; // 2. 新结点指向原来的头结点
*pphead = newnode; // 3. 让头指针指向新结点(改的是实参!)
}
记忆口诀 :凡是可能改变链表头指针指向 的操作(头插、头删、空表时的尾插、删除头结点、销毁),一律传二级指针 ;只是读 链表或不改头的操作,传一级指针就够了(比如打印、查找、在已知结点之后插入)。
核心函数实现(SList.c)
c
// SList.c ------ 单链表的实现
#include "SList.h"
// 内部辅助:申请一个结点并初始化,返回结点地址
static SLTNode* BuySListNode(SLTDataType x)
{
SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode)); // 从堆上申请一个结点的空间
if (newnode == NULL) // 申请失败要处理
{
perror("malloc");
exit(1);
}
newnode->data = x; // 装入数据
newnode->next = NULL; // 新结点的 next 先指向空
return newnode;
}
// 打印链表
void SLTPrint(SLTNode* phead)
{
SLTNode* cur = phead;
while (cur)
{
printf("%d -> ", cur->data);
cur = cur->next;
}
printf("NULL\n");
}
// 尾插:把新结点挂到链表末尾
void SLTPushBack(SLTNode** pphead, SLTDataType x)
{
assert(pphead);
SLTNode* newnode = BuySListNode(x);
if (*pphead == NULL) // 空链表:新结点就是第一个结点
{
*pphead = newnode; // 必须改头指针,所以这里能修改实参
return;
}
SLTNode* tail = *pphead; // 找尾巴:从头开始一路 next 到最后
while (tail->next != NULL)
{
tail = tail->next;
}
tail->next = newnode; // 把新结点挂到尾巴后面
}
// 头插:新结点插到最前面(不需要遍历,O(1))
void SLTPushFront(SLTNode** pphead, SLTDataType x)
{
assert(pphead);
SLTNode* newnode = BuySListNode(x);
newnode->next = *pphead; // 新结点先指向当前头
*pphead = newnode; // 再更新头指针指向新结点
}
// 头删:删除第一个结点
void SLTPopFront(SLTNode** pphead)
{
assert(pphead);
assert(*pphead != NULL); // 空链表不能删
SLTNode* del = *pphead; // 记下要删的结点
*pphead = (*pphead)->next; // 头指针直接跳到第二个结点
free(del); // 释放被删结点的空间
}
// 尾删:删除最后一个结点(单链表最麻烦的删除,要记录"前一个")
void SLTPopBack(SLTNode** pphead)
{
assert(pphead);
assert(*pphead != NULL);
if ((*pphead)->next == NULL)
{ // 只有一个结点:删完链表变空
free(*pphead);
*pphead = NULL; // 头指针置空
return;
}
SLTNode* tail = *pphead;
SLTNode* prev = NULL; // prev 始终记录 tail 的前一个结点
while (tail->next != NULL) // tail 走到最后一个结点时停下
{
prev = tail; // prev 跟着 tail 移动
tail = tail->next;
}
prev->next = NULL; // 倒数第二个结点的 next 置空(它成了新尾巴)
free(tail); // 释放原来的尾结点
}
// 查找:遍历比较,返回第一个匹配结点的指针
SLTNode* SLTFind(SLTNode* phead, SLTDataType x)
{
SLTNode* cur = phead;
while (cur)
{
if (cur->data == x)
{
return cur; // 找到,返回该结点地址
}
cur = cur->next;
}
return NULL; // 没找到
}
// 在 pos 结点之前插入(默认 pos 是链表里的合法结点)
void SLTInsert(SLTNode** pphead, SLTNode* pos, SLTDataType x)
{
assert(pphead);
assert(pos);
if (pos == *pphead) // 如果 pos 就是头结点 -> 等价于头插
{
SLTPushFront(pphead, x);
return;
}
SLTNode* prev = *pphead; // 找 pos 的前一个结点
while (prev->next != pos)
{
prev = prev->next;
}
SLTNode* newnode = BuySListNode(x);
newnode->next = pos; // 新结点指向 pos
prev->next = newnode; // prev 指向新结点,完成"插入"
}
// 在 pos 之后插入(单链表的最爱:无需遍历,拿到 pos 就能插,O(1))
void SLTInsertAfter(SLTNode* pos, SLTDataType x)
{
assert(pos);
SLTNode* newnode = BuySListNode(x);
newnode->next = pos->next; // 新结点先指向 pos 原来的下一个
pos->next = newnode; // pos 指向新结点
}
// 删除 pos 结点(默认 pos 是链表里的合法结点)
void SLTErase(SLTNode** pphead, SLTNode* pos)
{
assert(pphead);
assert(pos);
if (pos == *pphead) // 删除头结点 -> 等价于头删
{
SLTPopFront(pphead);
return;
}
SLTNode* prev = *pphead; // 找 pos 的前一个
while (prev->next != pos)
{
prev = prev->next;
}
prev->next = pos->next; // 让前一个结点跨过 pos,指向 pos 的下一个
free(pos); // 释放 pos
}
// 删除 pos 之后的结点(不需要前一个结点,O(1))
void SLTEraseAfter(SLTNode* pos)
{
assert(pos);
assert(pos->next != NULL); // pos 后面必须有结点才能删
SLTNode* del = pos->next; // 记下要删的结点
pos->next = del->next; // pos 跨过 del 指向 del 的下一个
free(del);
}
// 销毁整条链表:从头到尾逐个释放结点空间
void SListDestroy(SLTNode** pphead)
{
assert(pphead);
SLTNode* cur = *pphead;
while (cur)
{
SLTNode* next = cur->next; // 先记下下一个结点(否则 free 后就找不到了)
free(cur);
cur = next;
}
*pphead = NULL; // 头指针置空,防止野指针
}
写链表的两条铁律(务必铭记):
- 先记后删 / 先连后断:删结点前先把 next 保存好;改指针前想清楚会不会"弄丢"后面的结点。
- 改头必用二级指针:凡是动头指针的,都要通过二级指针修改实参。
3.3 链表的分类
链表结构非常多样,按三个维度可以组合出 2 × 2 × 2 = 8 种:
| 分类维度 | 两个选项 | 说明 |
|---|---|---|
| 带头 / 不带头 | 有哨兵位头结点 / 没有 | 哨兵位是一个不存数据的"占位"结点 |
| 单向 / 双向 | 只有 next / 有 next 和 prev | 能不能"倒着走" |
| 循环 / 非循环 | 尾结点指向头 / 尾结点指向 NULL | 是不是一个环 |
虽然组合出 8 种,但实际最常用的是两种:
- 无头单向非循环链表 :结构最简单。它一般不单独用来存数据,更多是作为其他更复杂数据结构的"子结构",比如哈希桶、图的邻接表。另外,这种结构在各类考题中出现的频率非常高,所以必须学透。
- 带头双向循环链表 :结构上最复杂。它常用来单独存储数据 。实际工程中使用的链表结构,绝大多数都是带头双向循环链表。有趣的是:它的结构虽然复杂,但代码实现后你会发现,正因为结构"对称而规整",实现反而更简单了------因为不管在哪个位置插入删除,代码逻辑都一样,不用分情况讨论。后面我们亲手实现一遍就有体会了。
📌 知识点总结:什么是链表?单链表如何实现?
链表是物理存储上非连续、非顺序的存储结构,结点的逻辑顺序通过结点中的指针链接次序来维持。每个结点包含数据域和指针域,数据域存数据,指针域存下一个结点的地址,结点一般用 malloc 在堆上独立申请,因此插入删除只需修改指针指向、不必搬移其它元素。
结构体上,单链表结点用"结构体内包含指向自身类型的 next 指针"递归定义。实现单链表时最容易踩坑的是参数设计:凡是要修改链表头指针指向的操作(头插、头删、空链表尾插、删除头结点、销毁链表)都必须传二级指针,否则函数内修改形参无法影响实参;只读或不动头结点的操作(打印、查找、在已知结点后插入)传一级指针即可。
四、双向带头循环链表:进可攻、退可守的进阶版
4.1 概念与结构
先澄清一个容易混淆的概念:
这里的"带头"跟前面单链表阶段说的"头结点"是两个概念 。实际上单链表阶段称呼并不严谨,当时为了便于理解,直接把链表的第一个结点称作"头结点"。
带头链表里的头结点,真正身份是哨兵位 (sentry/dummy node)。哨兵位结点不存储任何有效数据,只是站在这里"放哨"的,用于统一代码逻辑、简化边界处理。
双向 :每个结点有两个指针------next 指向后一个结点,prev 指向前一个结点。既能从头往后走,也能从尾往前走,"进可攻退可守"。
循环 :最后一个结点的 next 不指向 NULL,而是指回头结点 ;头结点的 prev 指向最后一个结点。整条链表首尾相连成一个环。
结构体定义:
c
// List.c ------ 带头双向循环链表
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
#include <stdbool.h>
typedef int LTDataType; // 数据类型的通用别名
typedef struct ListNode
{
struct ListNode* next; // next:指向下一个结点(前进)
struct ListNode* prev; // prev:指向前一个结点(后退)
LTDataType data; // data:本结点数据(哨兵位不存有效数据)
} LTNode;
为什么它"结构最复杂,实现反而最简单"?
因为带头 + 双向 + 循环,三者凑在一起,产生了一个奇妙的性质:
链表中任何一个位置 (包括空链表、第一个、最后一个)的操作,处理方式都完全一样,不用像单链表那样分"是不是头结点、是不是空表、是不是最后一个"一堆情况讨论。
举个对比:单链表尾插要"找尾巴 + 特判空表";带头双向循环链表尾插,就是"在头结点的前一个结点(即尾结点)后面插入"------和普通插入一模一样,空表也成立(因为空表时头结点的前一个就是它自己)。
4.2 实现带头双向循环链表
c
// 内部辅助:创建一个新结点并初始化(next、prev 先都指向 NULL)
LTNode* LTBuyNode(LTDataType x)
{
LTNode* node = (LTNode*)malloc(sizeof(LTNode));
if (node == NULL)
{
perror("malloc");
exit(1);
}
node->data = x;
node->next = NULL;
node->prev = NULL;
return node;
}
// 初始化:创建哨兵位头结点,并让它的 next、prev 都指向自己(空循环链表)
LTNode* LTInit()
{
LTNode* phead = LTBuyNode(-1); // 哨兵位:data 存什么都无所谓,习惯存 -1
phead->next = phead; // 空链表:头结点的下一个是自己
phead->prev = phead; // 头结点的上一个也是自己
return phead; // 返回哨兵位指针(也可以传入二级指针初始化)
}
// 判断链表是否为空:哨兵位的 next 指向自己 => 空
bool LTEmpty(LTNode* phead)
{
assert(phead);
return phead->next == phead;
}
// 打印:从哨兵位的下一个结点开始,遇到哨兵位就停
void LTPrint(LTNode* phead)
{
assert(phead);
LTNode* cur = phead->next; // 第一个有效结点
while (cur != phead)
{ // 走完一圈回到哨兵位就停止
printf("%d ", cur->data);
cur = cur->next;
}
printf("\n");
}
// 核心插入函数:在 pos 结点之后插入新结点
// 这个函数是"万能钥匙":头插、尾插都复用它
void LTInsert(LTNode* pos, LTDataType x)
{
assert(pos);
LTNode* newnode = LTBuyNode(x); // 1. 申请新结点
LTNode* posNext = pos->next; // 2. 先保存 pos 的下一个(防断链)
pos->next = newnode; // 3. 连新结点
newnode->prev = pos;
newnode->next = posNext; // 4. 接回原来的后继
posNext->prev = newnode;
}
// 尾插:在"最后一个结点"(即 phead 的 prev)之后插入
// 因为链表循环,phead->prev 就是尾结点,空表时就是哨兵位自己,天然成立
void LTPushBack(LTNode* phead, LTDataType x)
{
assert(phead);
LTInsert(phead->prev, x); // 尾结点之后插入 = 尾插
}
// 头插:在哨兵位之后插入(第一个有效位置)
void LTPushFront(LTNode* phead, LTDataType x)
{
assert(phead);
LTInsert(phead, x); // 哨兵位之后插入 = 头插
}
// 核心删除函数:删除 pos 结点
// 同样复用:尾删、头删都调它,不用判断"删的是不是头"
void LTErase(LTNode* pos)
{
assert(pos);
LTNode* prev = pos->prev; // 保存前驱
LTNode* next = pos->next; // 保存后继
prev->next = next; // 前驱直接连后继(跨过 pos)
next->prev = prev;
free(pos); // 释放被删结点
}
// 尾删:删除尾结点(phead 的前一个)
void LTPopBack(LTNode* phead)
{
assert(phead);
assert(!LTEmpty(phead)); // 空链表不能删
LTErase(phead->prev);
}
// 头删:删除第一个有效结点(哨兵位的后一个)
void LTPopFront(LTNode* phead)
{
assert(phead);
assert(!LTEmpty(phead));
LTErase(phead->next);
}
// 查找:遍历返回第一个 data == x 的结点
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;
}
// 销毁:先把所有有效结点释放掉,最后释放哨兵位
void LTDestroy(LTNode* phead)
{
assert(phead);
LTNode* cur = phead->next;
while (cur != phead)
{ // 释放所有有效结点
LTNode* next = cur->next;
free(cur);
cur = next;
}
free(phead); // 最后释放哨兵位头结点
}
感受一下优势:从头到尾,头插/尾插/头删/尾删全部"一视同仁",甚至不需要二级指针------因为哨兵位永远在,头指针(指向哨兵位)永不改变,链表再空也有一个"哨兵"立在那里。这对比单链表里一堆"if 空表、if 头结点"的特判,是不是清爽太多了?
📌 知识点总结:带头双向循环链表好在哪?哨兵位是什么?
带头双向循环链表由"哨兵位头结点 + 双向指针 + 首尾循环"三要素构成。哨兵位是一个不存储有效数据的占位结点,作用是统一边界处理;每个结点同时有 next 指向后继、prev 指向前驱,所以可以双向遍历;尾结点的 next 又指回哨兵位,形成循环。
它与单链表最大的区别在于实现难度与代码规整度:单链表由于只能单向访问,头插、尾插、删除都要针对"空表""头结点"等情况分别处理;而带头双向循环链表无论链表是否为空,任何一个位置的操作逻辑都完全一致------比如尾插就是在哨兵位前驱之后插入,空链表时前驱就是哨兵位自己,公式依然成立。所以它"结构最复杂但实现最简单",这也是实际工程存储数据时普遍选用它的原因。
另外,因为哨兵位永不改变且链表永远非"空",这类链表在传参时通常只需要一级指针,不需要像单链表那样为修改头指针而传二级指针。
五、顺序表 vs 链表:到底该用谁
| 不同点 | 顺序表 | 链表(单链表) |
|---|---|---|
| 存储空间上 | 物理上一定连续 | 逻辑上连续,但物理上不一定连续 |
| 随机访问 | 支持,O(1)(按下标直达) | 不支持,只能从头找,O(N) |
| 任意位置插入/删除元素 | 可能需要搬移元素,效率低 O(N) | 只需修改指针指向(前提:已经拿到该位置) |
| 插入时的空间管理 | 动态顺序表空间不够时要扩容,会浪费空间 | 没有容量概念,按需申请、按需释放,不存在空间浪费 |
| 应用场景 | 元素高效存储 + 频繁访问 | 任意位置频繁插入和删除 |
结合前面的实现,把这张表翻译成人话
顺序表的强项:
- 按下标访问是 O(1),想读第 5 个元素直接
a[4],瞬间到位。 - CPU 缓存友好:数据紧挨着存,遍历起来快。
顺序表的软肋:
- 头插/中间插要搬移 O(N) 个元素;
- 扩容要申请新空间 + 拷贝旧数据 + 释放旧空间,有消耗,且预留空间会浪费(容量 100 用到 105 就得开 200 的格子,浪费 95 个)。
链表的强项:
- 插入/删除(已知位置)只改指针,O(1),不搬移任何数据;
- 按需申请,加一个申请一个、删一个释放一个,没有"预留浪费"。
链表的软肋:
- 不能随机访问:找第 5 个结点得从头一个个走,O(N);
- 每个结点要多存 1~2 个指针,有额外内存开销;
- 结点散落各处,对 CPU 缓存不友好。
一句话决策指南
- 需求是"存得多、读得勤、要随机访问 " → 选顺序表(比如数组就是最典型代表)。
- 需求是"频繁在任意位置插入删除 " → 选链表(比如实现 LRU 缓存、哈希表里的链表桶)。
- 没有银弹,根据场景挑武器,这正是"数据结构要学很多种"的原因。
📌 知识点总结:顺序表和链表有什么区别?如何选择?
顺序表与链表的根本区别在于物理存储与访问方式:顺序表底层是连续数组,物理连续,支持 O(1) 下标随机访问,但任意位置的插入删除需要搬移元素,最坏 O(N),且动态扩容会带来拷贝开销与空间浪费;链表结点通过指针在堆上逐个申请,物理不连续,插入删除只需改指针指向(已知位置时 O(1)),按需申请不浪费空间,但无法随机访问,查找只能从头遍历 O(N),且每个结点额外占用指针空间、对缓存不友好。
二者是典型的"时间换空间 / 空间换时间"取舍。判断依据很简单:应用偏重"大量存储 + 频繁随机访问"(如实现数组栈、搜索下标需求强)应选顺序表;偏重"在任意位置频繁插入删除、数据量不确定"(如实现 LRU、内存管理中的空闲块链表)应选链表。实际工程中两种结构经常组合出现,例如哈希表内部就是用数组加链表实现。
六、全文总复习
本章心智地图
线性表(逻辑上是一条直线:有唯一前驱/后继)
│
├── 顺序表:物理连续 = 数组 + 封装
│ ├─ 底层数组,封装增删改查接口
│ ├─ 分类:静态(定长数组,怕不够也怕浪费) / 动态(按需申请 + 翻倍扩容)
│ ├─ 三大痛点:头部/中间插入删除 O(N);扩容有搬运开销;按倍扩容有浪费
│ └─ 强项:下标随机访问 O(1)
│
└── 链表:物理不一定连续 = 结点 + 指针
├─ 结点 = 数据域 + 指针域(next/prev)
├─ 分类:带头/不带头 × 单向/双向 × 循环/非循环 = 8 种
│ 实际常用:无头单向非循环(当子结构/考题宠儿)
│ 带头双向循环(适合单独存数据,代码反而最规整)
├─ 实现要点:改头传二级指针;先连后断、先记后删;申请要判空、用完要 free
├─ 强项:任意位置插入删除(已知位置 O(1)),按需申请不浪费
└─ 弱项:随机访问 O(N),指针有额外开销
高频结论速记卡
| 问题 | 答案 |
|---|---|
| 顺序表随机访问复杂度 | O(1) |
| 顺序表头部/中间插入删除 | O(N)(要搬移) |
| 链表查找某元素 | O(N)(只能从头遍历) |
| 已知结点后插入(链表) | O(1)(改指针即可) |
| 改头指针的操作为什么传二级指针 | 实参是 SLTNode*,要改它就得传它的地址 SLTNode** |
| 判环 | 快慢指针:slow 走 1 步、fast 走 2 步,必在环内相遇 |
| 找环入口 | 相遇点出发一个、表头出发一个,都走 1 步,相遇点即入口 |
| 带头双向循环 vs 单链 | 逻辑更规整:所有位置插入删除同一套代码,无需特判空表/头 |
