Data Structures and Algorithms-linear list
线性表的定义
线性表(linear_list)是最常用且最简单的一种数据结构。简言之,一个线性表是 $n$ 个 数据元素的有限序列。
线性表中的数据元素可以是各种各样的,但同一线性表中的 元素必定具有相同特性,即属同一数据对象,相邻数据元素之间存在着序偶关系。
线性表的抽象数据类型定义如下:
1 | ADT List{ |
抽象数据类型仅是一个模型的定义,并不涉及模型的具体实现,因此这里描述中所 涉及的参数不必考虑具体数据类型。在实际应用中,数据元素可能有多种类型,到时可根据 具体需要选择使用不同的数据类型。
顺序表及其实现
线性表的顺序表示指的是用一组地址连续的存储单元依次存储线性表的数据元素,这 种表示也称作线性表的顺序存储结构或顺序映像。通常,称这种存储结构的线性表为顺序表 (Sequential List)。其特点是,逻辑上相邻的数据元素,其物理位置也是相邻的。
只要确定了存储线性表的起始位置,线性表中任一数据元素都可随机存取,所 以线性表的顺序存储结构是一种随机存取的存储结构。
1 | // |
预分配内存浪费问题及其解决
1 | SeqList *list = (SeqList *) malloc(sizeof(SeqList)); //预分配内存 |
SeqList *list: 定义了一个指针变量 list,它指向一个 SeqList 类型的结构体。通过malloc函数来为这个结构体动态分配内存。-
malloc(sizeof(SeqList)):malloc分配内存的大小由sizeof(SeqList)决定,也就是SeqList结构体的大小。 (SeqList *): malloc返回的是一个void*类型的指针,需要通过强制类型转换 (SeqList *) 转换为SeqList类型的指针
你提到的
list->data = (int *) malloc(MAXSIZE * sizeof(int));是在初始化顺序表时为数据数组data预分配了一块固定大小的内存。在这个例子中,MAXSIZE代表了最多可以存储的元素个数,而sizeof(int)表示每个int类型所占的内存大小。1. 内存预分配是否会造成浪费?
是的,这种直接分配
MAXSIZE * sizeof(int)的方式确实可能会在某些情况下造成内存浪费,尤其是当你不确定最终会存储多少数据时。比如如果你只使用了很少的空间(比如只存了几个元素),但系统依然为你分配了足够存储MAXSIZE个元素的空间,这部分未使用的内存就浪费了。2. 如何体现动态?
要真正体现动态内存分配,可以根据实际需要逐步增加内存,而不是一开始就分配固定的
MAXSIZE空间。可以采用 动态扩容 的策略。一个常见的做法是:
- 初始时分配一个相对较小的空间(例如10个元素的空间)。
- 当空间不足时,通过 倍增 的方式动态扩展空间。即当数组达到当前容量时,重新分配一块更大的内存(比如原来的2倍),然后将原来的数据拷贝到新分配的空间。
这样可以减少内存浪费,同时在需要的时候自动扩容。
3. 改进方案
你可以实现类似于 动态数组 的结构,采用按需扩展空间的方式。以下是一个改进的例子:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
typedef struct {
int *data;
int length;
int list_size;
} SeqList;
// 初始化顺序表,初始容量为 INIT_SIZE
SeqList* init_seq_list() {
SeqList* list = (SeqList*)malloc(sizeof(SeqList));
list->data = (int*)malloc(INIT_SIZE * sizeof(int)); // 初始分配较小的内存
list->length = 0;
list->list_size = INIT_SIZE;
return list;
}
// 扩展顺序表容量
void expand_seq_list(SeqList* list) {
int new_size = list->list_size * INCREASE_FACTOR; // 扩展为原来的两倍
list->data = (int*)realloc(list->data, new_size * sizeof(int)); // 扩容
if (list->data == NULL) {
printf("内存分配失败!\n");
exit(1);
}
list->list_size = new_size; // 更新容量大小
}
// 添加元素到顺序表
void add_element(SeqList* list, int elem) {
if (list->length >= list->list_size) {
expand_seq_list(list); // 当容量不足时,进行扩容
}
list->data[list->length++] = elem; // 添加元素
}
int main() {
SeqList* list = init_seq_list();
// 测试添加多个元素
for (int i = 0; i < 100; ++i) {
add_element(list, i);
printf("已添加元素: %d, 当前长度: %d, 当前容量: %d\n", i, list->length, list->list_size);
}
// 记得释放内存
free(list->data);
free(list);
return 0;
}4. 改进后的优点
- 节省内存:一开始只分配较小的空间,真正需要时才扩展内存,避免初期的内存浪费。
- 动态扩展:顺序表可以根据需要不断扩展,容量不再固定为
MAXSIZE,而是动态增长。- 高效使用内存:虽然扩展内存时可能有一定的开销(重新分配和拷贝),但这种方式可以有效平衡内存使用和性能。
通过这种方式,你可以实现一个真正的“动态”数组,不再局限于一开始预设的固定内存大小。
各个操作时间复杂度分析
| 操作 | 时间复杂度 | 备注 |
|---|---|---|
| 索引(取值) | $O(1)$ | |
| 查找 | $O(n)$ | 平均查找长度(Average Search Length,ASL)为$\frac{n+1}{2}$ |
| 插入 | $O(n)$ | |
| 删除 | $O(n)$ |
索引是通过根据指定的位置序号i,获取顺序表中第i个数据元素的值。查找是根据指定的元素值e,查找顺序表中第1个值与e相等的元素。
单向链表
为了表示每个数据元素 $a_i$ 与其直接后继数据元 素 $a_i+1$ 之间的逻辑关系,对数据元素 $a_i$ 来说,除了存储其本身的信息之外,还需存储一个指示其直接后继的信息(直接后继的存储位置)。这两部分信息组成数据元素 $a_i$ 的存储映像,称为节点 (node)。它包括两个域:
- 其中存储数据元素信息的域称为数据域;
- 存储直接后继存储位置的域称为指针域。
指针域中存储的信息称作指针或链。$n$ 个节点[ $a_i(1≤i≤n)$ 的存储映像]链接成 一个链表,即为线性表:
的链式存储结构。又由于此链表的每个节点中只包含一个指针域,故又称线性链表或单链表。
根据链表节点所含指针个数、指针指向和指针连接方式,可将链表分为单链表、循环链 表、双向链表、二叉链表、十字链表、邻接表、邻接多重表等。其中单链表、循环链表和双向链表多用于实现线性表的链式存储结构,其他形式多用于实现树和图等非线性结构。
一般情况下,为了处理方便,在单链表的第一个节点之前附设一个节点,称之为头节点。 图2.8所示的单链表增加头节点后如下图所示。
单链表基本操作的实现
如下代码所示:
定义单链表结点类型
1 | // |
为了提高程序的可读性,在此对同一结构体指针类型起了两个名称,
LinkList与LNode *,两者本质上是等价的。通常习惯上用LinkList定义单链表,强调定义的是某个单链表的头指针;用LNode *定义指向单链表中任意节点的指针变量。例如,
若定义LinkList L,则L为单链表的头指针,
若定义LNode *p,则p为指向单链表中某个节点的指针,用*p代表该节点。
当然也可以使用定义LinkList p,这种定义形式完全等价于LNode *p。注意区分指针变量和节点变量两个不同的概念,若定义
LinkList p或LNode *p, 则p为指向某节点的指针变量,表示该节点的地址;而*p为对应的节点变量,表示该节点的 名称。
初始化单向链表
1 | //1.初始化单向链表 |
在 C 语言中,LinkList *L 表示传入的是一个指向指针的指针。*L 用于解引用这个指针,允许函数直接修改调用该函数时传入的头指针。
具体来说:
LinkList *L是一个指向LinkList的指针,也就是指向一个指向LNode的指针。- 使用
*L可以访问并修改这个指针的值,即将新分配的内存地址赋给它。
因此,在初始化链表时,我们需要通过 *L 来给传入的头指针分配内存。这样,链表的头指针在函数外部也能反映这个变化。
销毁单向链表
1 | //2.销毁单向链表 |
查找节点
算法:单链表的按值查找
① 用指针
p指向首元节点。② 从首元节点开始依次顺着链域
next向下查找,只要指向当前节点的指针p不为空,并且p所指节点的数据域不等于给定值e,则循环执行以下操作:p指向下一个节点。③ 返回
p。若查找成功,p此时指向节点的地址值,若查找失败,则p的值为NULL。
1 | LNode* find_node(LinkList L, int value) { //这个时候传入的参数是一个一级指针 |
获取元素(取值)
算法:单链表的取值
① 用指针
p指向首元节点,用j做计数器初值赋为1。② 从首元节点开始依次顺着链域
next向下访问,只要指向当前节点的指针p不为空 (NULL),并且没有到达序号为i的节点,则循环执行以下操作:
p指向下一个节点;计数器
j相应加1。退出循环时,如果指针
p为空,或者计数器j大于i,说明指定的序号i值不合法(i大于表长n或i小于等于0),取值失败返回ERROR;否则取值成 功,此时j = i时,p所指的节点就是要找的第i个节点,用参数e保存当前节点的 数据域,返回1
时间复杂度为 $O(n)$
1 | int get_elem(LinkList L, int i, int *e) { |
因为规定了函数的返回值代表操作成功与否,所以又用了*e 参数来接受获得的值。
插入元素
假设要在单链表的两个数据元素 $a$ 和 $ b$ 之间插入一个数据元素 $ x$,已知p为其单链表存储结构 中指向节点 $a$的指针,如图2.11(a)所示。
算法:单链表的插入
将值为
e的新节点插入表的第i个节点的位置,即插入节点 $a_{i-1}$ 与 $a_i$ 之间,具体插入过程如图所示,图中对应的5个步骤说明如下。① 查找节点 $a_{i-1}$ 并由指针
p指向该节点。② 生成一个新节点
*s。③ 将新节点
*s的数据域置为e。④ 将新节点
*s的指针域指向节点 $a_i$ .⑤ 将节点
*p的指针域指向新节点*s。
时间复杂度为 $O(n)$
1 | int insert_elem(LinkList L, int i, int e) { //这个时候传入的参数是一个一级指针 |
删除元素
删除第 i 个节点,并返回该节点的数据
算法:单链表的删除
删除单链表的第 $i$个节点 $a_i$的具体过程如图所示,图中对应的4个步骤 说明如下。
① 查找节点$a_{i−1}$并由指针
p指向该节点。② 临时保存待删除节点 $a_i$ 的地址在
q中,以备释放。③ 将节点
*p的指针域指向 $ a_i$ 的直接后继节点。④ 释放节点 $ a_i$ 的空间。
时间复杂度为 $O(n)$
1 | int delete_elem(LinkList L, int i, int *e) { |
在 delete_elem 函数中,while 循环中找到的 p 是待删除元素的前一个节点,而不是待删除的元素本身。
具体来说:
p最终指向的是待删除元素的前一个节点,这样才能通过p->next访问待删除的元素。- 待删除的元素
q是通过q = p->next获取的。因此,q是要被删除的节点,而p是其前一个节点。这样才能在删除时更新指针,确保链表的结构不被破坏。
头插法创建链表
头插法(前插法)是通过将新节点逐个插入链表的头部(头节点之后)来创建链表,每次申请一个新 节点,读入相应的数据元素值,然后将新节点插入到头节点之后。
算法2.11
头插法(前插法)创建单链表
① 创建一个只有头节点的空链表。
② 根据待创建链表包括的元素个数$n$,循环$n$次执行以下操作:
- 生成一个新节点
*p;- 输入元素值赋给新节点
*p的数据域;- 将新节点
*p插入到头节点之后。
时间复杂度为 $O(n)$ .
程序如下:
1 | int create_list_by_head(LinkList L, int n) { |
:warning:注意,下面两句是有顺序的.否则会造成链表元素丢失
1 | p->next = L->next;//先让p的指针指向L的后继元素 |
同时,注意输入顺序,如果想创建逻辑顺序为 $a\rightarrow b\rightarrow c\rightarrow d\rightarrow e$ 的单项链表,如果用头插法,输入顺序,应该是 $e\rightarrow d\rightarrow c\rightarrow b\rightarrow a$ ,恰好反过来.
尾插法创建链表
尾插法(后插法)是通过将新节点逐个插入链表的尾部来创建链表。同前插法一样,每次申请一个新 节点,读入相应的数据元素值。不同的是,为了使新节点能够插入表尾,需要增加一个尾指针q 指向链表的尾节点。
算法2.12
尾插法(后插法)创建单链表
① 创建一个只有头节点的空链表。
② 尾指针
q初始化,指向头节点。③ 根据创建链表包括的元素个数 $n$ ,循环 $n$ 次执行以下操作:
生成一个新节点
*p;输入元素值赋给新节点p的数据域;
将新节点
*p插入尾节点*q之后;尾指针r指向新的尾节点
*p。
时间复杂度为 $O(n)$ .
1 |
|
尾插法的优点是链表的构建顺序与输入顺序一致,适合需要从前往后逐步生成链表的情况。如果想创建逻辑顺序为 $a\rightarrow b\rightarrow c\rightarrow d\rightarrow e$ 的单项链表,输入顺序,应该是 $a\rightarrow b\rightarrow c\rightarrow d\rightarrow e$ ,保持一致.
在这种情况下,下面的语句的顺序没有影响:
1 | q->next = NULL;//让q的指针为NULL,使得q为新的尾节点。 |
时间复杂度分析
| 操作 | 时间复杂度 | 备注 |
|---|---|---|
| 销毁单向链表 | $O(n)$ | |
| 按值查找节点 | $O(n)$ | |
| 获取元素(取值) | $O(n)$ | |
| 删除元素 | $O(n)$ | |
| 头插法创建单向链表 | $O(n)$ | |
| 尾插法创建单向链表 | $O(n)$ |
循环链表
循环链表(Circular Linked List)是另一种形式的链式存储结构。其特点是表中最后一个节点的指针域指向头节点,整个链表形成一个环。由此,从表中任一节点出发均可找到表中其他 节点,图2.17所示为单链的循环链表。类似地,还可以有多重链的循环链表。
循环单链表的操作和单链表基本一致,差别仅在于:当链表遍历时,判别当前指针p是否指 向表尾节点的终止条件不同。在单链表中,判别条件为p!=NULL或p->next!=NULL,而循环单链 表的判别条件为p!=L或p->next!=L
在某些情况下,若在循环链表中设立尾指针而不设头指针[见图2.18(a)],可使一些操作 简化。例如,将两个线性表合并成一个表时,仅需将第一个表的尾指针指向第二个表的第一个 节点,第二个表的尾指针指向第一个表的头节点,然后释放第二个表的头节点。当线性表以图 2.18(a)的循环链表作存储结构时,这个操作仅需改变两个指针值即可,主要语句段如下:
1 | p = B->next->next; |
双向链表
双向链表实现大同小异,只需要把prev指针处理好就行:
1 |
|



