数据结构——线性表
线性表
一、什么是线性表?
线性表(Linear List)是最常见、最常用的一种数据结构。现实中有很多数据天然就是"排成一队"的,比如:
- 学生按学号排列的名单
- 火车的一节节车厢
- 播放器中的歌曲播放列表
- 在食堂排队打饭的队伍
这些场景的共同特点是:数据元素一个接一个排成一条线,每个元素都有明确的前后位置。
二、线性表的定义
1. 逻辑结构
线性表是 $n$($n \ge 0$)个具有相同特性的数据元素组成的有限序列。记为:
$$(a_1, a_2, a_3, \dots, a_n)$$
其中:
- $a_1$ 是第一个元素,$a_n$ 是最后一个元素
- 对于任意 $a_i$($2 \le i \le n-1$),$a_{i-1}$ 称为 $a_i$ 的直接前驱,$a_{i+1}$ 称为 $a_i$ 的直接后继
- $a_1$ 没有前驱,$a_n$ 没有后继
- 元素之间是一对一的线性关系
当 $n = 0$ 时,称为空表。
对比生活中的例子:就像排队打饭,排在第一位的人前面没人,最后一位后面没人,中间每个人都知道自己前面和后面是谁。
2. 基本运算
线性表上常见的操作有:
| 运算 | 含义 | 类比生活 |
|---|---|---|
置空表 InitList(&L) |
初始化一个空的线性表 | 新开一个空窗口,还没人排队 |
求长度 ListLength(L) |
返回线性表中元素的个数 | 数一数队伍有多少人 |
取结点 GetElem(L, i) |
获取第 $i$ 个位置上的元素 | 喊"第 5 个人出列" |
定位 LocateElem(L, e) |
按值查找元素 $e$ 的位置 | 找出"张三"排在第几位 |
插入 ListInsert(&L, i, e) |
在第 $i$ 个位置插入新元素 $e$ | 有人插队到第 3 个位置 |
删除 ListDelete(&L, i) |
删除第 $i$ 个位置的元素 | 队伍中第 3 个人离开了 |
其中
&L表示需要修改线性表本身,不加&表示只读取不修改。
三、顺序表
1. 什么是顺序表?
顺序表(Sequential List)是将线性表中的结点按照逻辑顺序依次存放在一组地址连续的存储单元中。简单说就是:用数组来存线性表。
因为元素在内存中是连续排放的,所以只要知道了第一个元素的地址,就能推算出任意元素的地址——这就是顺序表的随机存取特性:
$$\text{Loc}(a_i) = \text{Loc}(a_1) + (i-1) \times \text{sizeof(ElemType)}$$
随机存取中的"随机"不是说"随意、碰运气",而是指"任意、直接"。它可以直接跳到任意一个位置去访问,不需要从头一个个找过去。与之对应的是顺序存取,这种情况下必须从头开始一个个找。
2. 顺序表的定义
在 C 语言中,可以使用一维数组及结构体来定义顺序表:
#define MAXSIZE 100 // 顺序表的最大容量
typedef struct {
int data[MAXSIZE]; // 用数组存储数据元素
int length; // 当前线性表的长度
} SqList; // SqList 即 Sequential List
data数组存放实际的元素,MAXSIZE是总容量(最多能存多少)length记录当前实际有多少个元素,且length ≤ MAXSIZE
3. 顺序表的基本运算
(1)插入操作
在顺序表的第 $i$ 个位置插入一个新元素 $e$ 的步骤:
- 检查 $i$ 是否合法($1 \le i \le \text{length}+1$),检查表是否已满
- 将第 $i$ 到第 $\text{length}$ 个元素依次后移一个位置
- 将新元素 $e$ 放到第 $i$ 个位置
- 表长
length加 1
int ListInsert(SqList *L, int i, int e) {
if (i < 1 || i > L->length + 1) return 0; // i 不合法
if (L->length >= MAXSIZE) return 0; // 表已满
for (int j = L->length; j >= i; j--) // 从后往前依次后移
L->data[j] = L->data[j - 1];
L->data[i - 1] = e; // 插入新元素
L->length++;
return 1;
}
(2)删除操作
删除顺序表中第 $i$ 个元素的步骤:
- 检查 $i$ 是否合法($1 \le i \le \text{length}$)
- 将第 $i+1$ 到第 $\text{length}$ 个元素依次前移一个位置
- 表长
length减 1
int ListDelete(SqList *L, int i) {
if (i < 1 || i > L->length) return 0; // i 不合法
for (int j = i; j < L->length; j++) // 从前往后依次前移
L->data[j - 1] = L->data[j];
L->length--;
return 1;
}
(3)按位置查找
直接通过下标访问,时间复杂度为 $O(1)$:
int GetElem(SqList L, int i) {
if (i < 1 || i > L.length) return -1; // i 不合法
return L.data[i - 1]; // 数组下标从 0 开始
}
时间复杂度
时间复杂度用来衡量一个算法的运行时间随数据规模 $n$ 增长的变化趋势,记作 $O(f(n))$。它不关心具体的运行秒数(那取决于硬件和编译器),而是关心当 $n$ 变大时,时间增长得快不快。
记法 含义 举例 $O(1)$ 常数时间:无论数据量多大,执行时间都固定不变 电影院座位有编号,不论是第几排,可以直接走过去,跟影院有多少座位无关 $O(n)$ 线性时间:执行时间与 $n$ 成正比 在一条队伍里找人,队伍越长,找得越久 $O(n^2)$ 平方时间:$n$ 翻倍,时间变 4 倍 双层循环嵌套,比如冒泡排序 在顺序表中:
- 按位置查找是 $O(1)$——直接算地址,一步到位
- 插入、删除、按值查找是 $O(n)$——最坏情况要移动或比较所有元素
(4)按值查找
需要遍历整个表,找到第一个匹配的元素:
int LocateElem(SqList L, int e) {
for (int i = 0; i < L.length; i++) {
if (L.data[i] == e) return i + 1; // 返回位序(从 1 开始)
}
return 0; // 未找到
}
四、顺序表的性能分析
| 操作 | 时间复杂度 | 说明 |
|---|---|---|
| 按位置查找 | $O(1)$ | 随机存取,直接通过下标 |
| 按值查找 | $O(n)$ | 最坏情况需要遍历整个表 |
| 插入 | $O(n)$ | 平均需要移动一半元素 |
| 删除 | $O(n)$ | 平均需要移动一半元素 |
优点:
- 支持随机存取,访问任意元素速度极快
- 存储密度高:只存储数据元素本身,不像链表那样需要额外存储指针,内存利用率更高
- 实现简单,C 语言数组直接支持
缺点:
- 插入和删除需要移动大量元素,效率低
- 表的大小一经确定难以扩展(需要预先分配足够空间)
- 空间分配不灵活,太大浪费内存,太小可能溢出
一句话总结:顺序表适合频繁查找、很少插入删除的场景;如果插入删除操作很多,就应该考虑另一种实现方式——链表。
五、链表
1. 什么是链表?
顺序表是用一组连续的存储单元依次存储线性表中的结点,而链表(Linked List)则是用一组任意的存储单元来存储线性表中的结点。
这意味着结点的逻辑顺序和存储顺序不一定相同——它们在内存中可能东一个西一个,并不挨在一起。为了体现逻辑关系,每个结点在存储数据的同时,还需要额外存储与之关联的相邻结点的地址信息,这个信息称为指针(Pointer)或链(Link)。
2. 单链表
单链表(Singly Linked List)是最简单的一种链表,每个结点只包含一个指向后继结点的指针。
(1)结点结构
每个结点由两部分组成:
- 数据域(
data):存储数据元素本身 - 指针域(
next):存储下一个结点的地址
在 C 语言中定义如下:
typedef struct Node {
int data; // 数据域
struct Node *next; // 指针域,指向下一个结点
} Node;
此外,通常还会设置一个头指针(head)指向链表的第一个结点。如果链表为空,head = NULL。
有时还会引入一个头结点(头结点是链表中第一个结点之前的那个结点,它的数据域一般不存储有效数据,只是为了方便操作而设):
head → [ 头结点 ] → [ a₁ ] → [ a₂ ] → ... → [ aₙ ] → NULL
头结点不是必须的,但有了它之后,在第一个位置插入或删除时就不需要特殊处理头指针了,代码更统一。
(2)建立单链表
头插法
每次将新结点插入到链表的头部(头结点之后):
void CreateListHead(Node **head, int arr[], int n) {
*head = (Node *)malloc(sizeof(Node)); // 创建头结点
(*head)->next = NULL;
for (int i = 0; i < n; i++) {
Node *s = (Node *)malloc(sizeof(Node));
s->data = arr[i];
s->next = (*head)->next; // 新结点指向原来的第一个结点
(*head)->next = s; // 头结点指向新结点
}
}
头插法得到的链表顺序与输入顺序相反。比如输入
[1,2,3],得到的链表为head→3→2→1→NULL。
尾插法
每次将新结点插入到链表的尾部,需要用一个 rear 指针始终指向最后一个结点:
void CreateListTail(Node **head, int arr[], int n) {
*head = (Node *)malloc(sizeof(Node)); // 创建头结点
(*head)->next = NULL;
Node *rear = *head; // rear 始终指向尾结点
for (int i = 0; i < n; i++) {
Node *s = (Node *)malloc(sizeof(Node));
s->data = arr[i];
s->next = NULL;
rear->next = s; // 尾结点指向新结点
rear = s; // 更新尾结点
}
}
尾插法得到的链表顺序与输入顺序相同。
(3)查找运算
单链表不支持随机存取,要查找第 $i$ 个结点,必须从头指针开始,沿着 next 指针一个个跳转,这就是顺序存取:
Node *GetElem(Node *head, int i) {
if (i < 0) return NULL; // 位置不能为负
Node *p = head; // p 从头结点开始(头结点是第 0 个结点)
int j = 0;
while (p && j < i) { // 沿着 next 指针往后跳
p = p->next;
j++;
}
return p; // 返回第 i 个结点(若不存在则返回 NULL)
}
举例:链表为
head → [10] → [20] → [30] → [40] → NULL,调用GetElem(head, 3):
轮次 j的值p指向p非空?j < 3?继续循环? 初始 0 head(头结点)是 是(0<3) — 第 1 次 0→1 head→[10]是 是(1<3) 是,继续 第 2 次 1→2 [10]→[20]是 是(2<3) 是,继续 第 3 次 2→3 [20]→[30]是 否(3<3 不成立) 否,停止 此时
p指向[30],恰好就是第 3 个结点,返回p。如果 $i$ 太大: 比如
GetElem(head, 10),链表只有 4 个数据结点:
轮次 j的值p指向p非空?j < 10?... ... ... 是 是 第 5 次 4→5 [40]→NULL否(NULL) 是 此时
p变成了NULL,条件p不成立,循环终止。返回NULL表示没找到。
按位置查找的时间复杂度为 $O(n)$,而顺序表是 $O(1)$——这就是顺序存取和随机存取的性能差异。
按值查找也需要遍历:
Node *LocateElem(Node *head, int e) {
Node *p = head->next;
while (p && p->data != e)
p = p->next;
return p; // 找到返回该结点,否则返回 NULL
}
(4)插入运算
在单链表的第 $i$ 个位置插入新结点,关键操作是修改指针的指向,不需要移动数据:
int ListInsert(Node *head, int i, int e) {
Node *p = GetElem(head, i - 1); // 找到第 i-1 个结点
if (!p) return 0; // 位置不合法
Node *s = (Node *)malloc(sizeof(Node));
s->data = e;
s->next = p->next; // 新结点指向第 i 个结点
p->next = s; // 第 i-1 个结点指向新结点
return 1;
}
对比顺序表:顺序表插入需要移动后面所有元素($O(n)$),而链表插入只需要修改指针($O(1)$),这是链表最大的优势。
(5)删除运算
删除第 $i$ 个结点,同样只需修改指针:
int ListDelete(Node *head, int i) {
Node *p = GetElem(head, i - 1); // 找到第 i-1 个结点
if (!p || !p->next) return 0; // 位置不合法
Node *q = p->next; // q 指向要删除的结点
p->next = q->next; // 跳过 q
free(q); // 释放 q 的内存
return 1;
}
删除操作本身也是 $O(1)$,但找到第 $i-1$ 个结点需要 $O(n)$ 时间。如果已知要删的是哪个结点(已经有指针指向它),则删除操作可以做到 $O(1)$。
3. 循环链表
循环链表(Circular Linked List)是单链表的一种变体,它将最后一个结点的 next 指针指向头结点(而不是 NULL),形成一个环:
循环链表的优点
- 从任意结点出发都能遍历整个链表——只要沿着
next一直走,总会回到起点。这在普通单链表中做不到(从中间结点出发,后半段就找不到了) - 操作尾结点很方便:尾结点的
next指向头结点,因此rear->next就能直接得到头结点,不需要遍历
循环链表的基本运算
循环链表的插入、删除算法与单链表基本一致,唯一区别是循环条件从 p != NULL 变为 p != head。例如查找运算:
Node *GetElem(Node *head, int i) {
if (i < 0) return NULL;
Node *p = head;
int j = 0;
while (p->next != head && j < i) { // 循环条件变为 p->next != head
p = p->next;
j++;
}
return p;
}
4. 双链表
双链表(Doubly Linked List)在单链表的基础上,给每个结点增加了一个指向前驱的指针 prior,使结点可以双向移动。
(1)结点定义
在 C 语言中定义如下:
typedef struct DNode {
int data; // 数据域
struct DNode *prior; // 指向前驱结点
struct DNode *next; // 指向后继结点
} DNode;
(2)建立双链表
双链表的建立同样有头插法和尾插法,但比单链表多维护一个 prior 指针。
头插法
每次将新结点插入到头结点之后,新结点的 prior 指向头结点:
void CreateDListHead(DNode **head, int arr[], int n) {
*head = (DNode *)malloc(sizeof(DNode));
(*head)->prior = NULL;
(*head)->next = NULL;
for (int i = 0; i < n; i++) {
DNode *s = (DNode *)malloc(sizeof(DNode));
s->data = arr[i];
s->next = (*head)->next; // 新结点的 next 指向原来的第一个结点
s->prior = *head; // 新结点的 prior 指向头结点
if ((*head)->next) // 如果原来有第一个结点
(*head)->next->prior = s; // 让它的 prior 指向新结点
(*head)->next = s; // 头结点的 next 指向新结点
}
}
尾插法
每次将新结点插入到链表尾部:
void CreateDListTail(DNode **head, int arr[], int n) {
*head = (DNode *)malloc(sizeof(DNode));
(*head)->prior = NULL;
(*head)->next = NULL;
DNode *rear = *head; // rear 始终指向尾结点
for (int i = 0; i < n; i++) {
DNode *s = (DNode *)malloc(sizeof(DNode));
s->data = arr[i];
s->next = NULL;
s->prior = rear; // 新结点的 prior 指向原来的尾结点
rear->next = s; // 原来的尾结点指向新结点
rear = s; // 更新尾结点
}
}
头插法得到的链表顺序与输入顺序相反,尾插法顺序相同,这一点与单链表一致。
(3)双链表的优点
单链表只能从前往后找,要找前驱结点必须从头遍历,时间复杂度 $O(n)$。而双链表:
- 向前查找:
p->prior一步到位,$O(1)$ - 向后查找:
p->next一步到位,$O(1)$ - 删除操作更高效:已知要删除的结点
p时,不需要找前驱,直接通过p->prior就能得到
(4)双链表的查找运算
双链表的 DGetElem 与单链表逻辑相同——都是从头结点开始沿着 next 指针向后跳,只是结点类型变为 DNode:
DNode *DGetElem(DNode *head, int i) {
if (i < 0) return NULL;
DNode *p = head;
int j = 0;
while (p && j < i) { // 与单链表完全相同的遍历方式
p = p->next;
j++;
}
return p;
}
虽然双链表有
prior指针可以向前移动,但按位序查找仍然是从头开始向后遍历。prior的主要优势体现在已知某个结点指针的操作上(如删除、插入前驱),而不是按位序查找。拓展:循环双链表——双链表也像单链表一样,可以让尾结点的
next指向头结点,同时头结点的prior指向尾结点,形成一个双向的环。此时DGetElem的循环条件也要从while (p && j < i)改为while (p != head && j < i),与循环单链表同理。
(5)双链表的插入运算
在双链表中,在第 $i$ 个位置插入新结点需要修改四个指针:
int DListInsert(DNode *head, int i, int e) {
DNode *p = DGetElem(head, i - 1); // 找到第 i-1 个结点
if (!p) return 0;
DNode *s = (DNode *)malloc(sizeof(DNode));
s->data = e;
s->prior = p; // 新结点的前驱指向 p
s->next = p->next; // 新结点的后继指向 p 的下一个
if (p->next) // 如果 p 后面还有结点
p->next->prior = s; // 让那个结点的前驱指向新结点
p->next = s; // p 的后继指向新结点
return 1;
}
注意,双链表插入要先处理新结点的前驱和后继,再去修改已有结点的指针,否则会丢失结点。
(6)双链表的删除运算
int DListDelete(DNode *head, int i) {
DNode *p = DGetElem(head, i); // 找到要删除的第 i 个结点
if (!p) return 0;
p->prior->next = p->next; // 前驱结点的 next 跳过 p
if (p->next) // 如果 p 后面还有结点
p->next->prior = p->prior; // 后继结点的 prior 跳过 p
free(p);
return 1;
}
与单链表不同,双链表删除不需要查找前驱,直接通过被删结点的
prior就能拿到前驱,$O(1)$(将在后面的对比表中统一汇总)。
5. 三种链表的对比
| 对比维度 | 单链表 | 循环链表 | 双链表 |
|---|---|---|---|
| 尾结点指向 | NULL |
头结点 | NULL |
| 判空条件 | head->next == NULL |
head->next == head |
head->next == NULL |
| 从中间遍历 | 只能走后半段 | 可绕一圈走完全部 | 只能走后半段 |
| 前驱查找 | 需遍历($O(n)$) | 需遍历($O(n)$) | 直接取 prior($O(1)$) |
| 内存开销 | 1 个指针/结点 | 1 个指针/结点 | 2 个指针/结点 |
| 指针修改(插入/删除) | 改 1~2 个指针 | 改 1~2 个指针 | 改 2~4 个指针 |
选型建议:需要双向移动选双链表;需要从任意结点遍历整个表选循环链表;一般场景单链表就够用。
6. 顺序表 vs 链表对比
经过前面的讨论,现在可以将两种线性表的实现方式做一个全方位的对比:
| 对比维度 | 顺序表 | 链表(单链表为代表) |
|---|---|---|
| 存储方式 | 连续存储 | 任意存储(通过指针串联) |
| 存取方式 | 随机存取($O(1)$) | 顺序存取($O(n)$) |
| 按位置查找 | $O(1)$(直接算地址) | $O(n)$(需遍历) |
| 插入/删除 | $O(n)$(需移动元素) | $O(1)$(只需改指针)【注】 |
| 存储密度 | 高(只存数据) | 低(额外存指针) |
| 空间分配 | 预分配,大小固定 | 动态分配,按需增长 |
| 逻辑结构 | 物理顺序 = 逻辑顺序 | 物理顺序 ≠ 逻辑顺序 |
【注】链表插入/删除的 $O(1)$ 是指"修改指针"这一步,查找前驱结点仍需 $O(n)$。如果已知操作位置的前驱指针,则整体为 $O(1)$。
一句话总结:顺序表适合频繁按位置查找、数据规模相对固定的场景;链表适合频繁插入删除、数据规模动态变化的场景。没有绝对的好与坏,只有适合不适合。

浙公网安备 33010602011771号