文章目录
第二章 线性表
三、线性表概念
- 线性表定义:线性表是具有相同数据类型的 n n n 个数据的有限序列。
- 数组下标是从 0 开始的,位序是从 1 开始的。
四、顺序表细节操作
-
可以随机访问、占用存储空间连续、顺序表的插入删除,需要移动多个元素、存储密度高。
-
插入操作
-
思想:在顺序表 L 的第 i 个位置插入新元素 e 时,如果输入的 i 不合法,则返回 false,表示插入失败。否则,将顺序表中第 i 个元素及其后面的所有元素向后移动一个位置(从后向前处理),以腾出空位插入新元素 e,顺序表的长度增加 1,最后返回 true。
-
核心代码
for(int j = L.length; j >= i; j--){ L.data[j] = L.data[j-1]; } L.data[i-1] = e;
-
循环初始化:
L.length
:表示顺序表当前的长度。i
:表示要插入新元素e
的位置。注意在代码中,位置i
是从 1 开始的。
-
循环条件:
-
j = L.length
:我们从顺序表的最后一个元素开始(即L.data[L.length-1]
)。 -
j >= i
:我们需要一直移动元素直到到达插入位置i
(这个位置之前的所有元素都需要右移一位)。 -
每次循环,
j
会递减 1。
-
-
元素移动:
-
在循环体内,
L.data[j] = L.data[j-1];
这一行的作用是将顺序表中索引为j-1
的元素移动到索引j
的位置。这意味着我们将所有元素从插入位置开始,向后移动一个位置,以腾出插入位置。例如,如果我们要在位置
i
插入新元素e
,则: -
第一次循环(
j = L.length
)会将L.data[L.length - 1]
移动到L.data[L.length]
。 -
第二次循环(
j = L.length - 1
)会将L.data[L.length - 2]
移动到L.data[L.length - 1]
。 -
以此类推,直到
j
等于i
。
-
-
插入新元素:
L.data[i-1] = e;
:在完成元素移动后,L.data[i-1]
(即目标插入位置)被赋值为新元素e
。注意这里的i-1
是因为我们需要将输入的 1-based 索引转换为 0-based 索引。
-
-
时间复杂度分析
-
在最坏情况下(当我们在表头插入时),需要移动所有
n
个元素,因此时间复杂度为 ( O(n) )。 -
在表尾插入时,由于不需要移动元素,时间复杂度为 ( O(1) )。
-
这样的实现方式适合于相对较小的数据量,若数据量较大,可能需要考虑更高效的数据结构,比如链表。
-
-
删除操作
-
思想:在顺序表 L 中删除第 i 个位置的元素时,如果输入的 i 不合法,则返回 false,表示删除失败。否则,将第 i 个元素的值赋给 e,然后将第 i 个位置的元素及其后面的所有元素向左移动一个位置(从前向后处理),顺序表的长度减少 1。删除成功后返回 true。
-
核心代码
e = L.data[i-1]; for(int j = i;j<L.length;++j){ L.data[j-1] = L.data[j]; }
-
提取要删除的元素:
e = L.data[i-1];
:将顺序表中第 i 个位置的元素(1-based 索引)赋值给变量 e。这一步确保我们能够保留被删除的元素值。
-
移动元素:
for(int j = i; j < L.length; ++j)
:循环从第 i + 1 个元素开始,直到顺序表的最后一个元素。L.data[j-1] = L.data[j];
:在每次迭代中,将当前元素L.data[j]
的值复制到前一个位置L.data[j-1]
。这相当于将所有后续元素向左移动一个位置,以覆盖要删除的元素。
假设顺序表 L 初始状态如下(
L.length = 5
,表示有 5 个元素):索引 0 1 2 3 4 值 A B C D E 如果我们要删除第 3 个位置的元素(即
C
),执行过程如下:-
提取要删除的元素:
e = L.data[2];
结果:e = C
。
-
移动元素:
for(int j = 3; j < 5; ++j)
开始循环:- 当
j = 3
时:L.data[2] = L.data[3];
→L.data[2] = D
。 - 当
j = 4
时:L.data[3] = L.data[4];
→L.data[3] = E
。
- 当
最终顺序表如下:
索引 0 1 2 3 4 值 A B D E E 顺序表的长度需要更新为
4
。 -
-
时间复杂度分析
-
在删除操作中,最坏情况下(当我们删除的是表头元素,即第 1 个位置)时,需将后续的所有
n - 1
个元素左移,因此时间复杂度为 ( O(n) )。 -
当删除的是表尾元素(即第
n
个位置),在这种情况下,只需将顺序表的长度减少 1,而不需要移动其他元素,因此时间复杂度为 ( O(1) )。
-
-
五、链表的细节操作
-
链表的插入和删除操作不需要移动元素,只需修改指针,从而提高了效率。
-
单链表特点
-
非随机存取的存储结构:单链表是一种线性数据结构,但其元素存储在内存中并不连续。要查找某个特定元素,需要从头节点开始逐个遍历,时间复杂度为 ( O(n) )。
-
额外的指针域:每个节点除了存储数据外,还包含一个指向下一个节点的指针。这种设计虽然引入了额外的空间开销,但为动态插入和删除操作提供了便利。
-
解决了顺序表的缺点:在顺序表中,插入和删除操作需要移动大量元素,而链表的插入和删除操作只需调整指针,因此在频繁修改数据的场景下表现更优。
-
-
头结点与头指针
-
头结点定义:头结点是指在单链表的第一个元素节点之前额外附加的一个节点。其指针域指向单链表的第一个元素节点。引入头结点的原因在于,它使得对链表第一个位置上的操作与对其他位置的操作一致,无需特别处理。这种统一的处理方式使得空表和非空表的处理变得简单。
-
头指针:头指针始终指向链表的第一个节点,无论链表是否包含头结点。当链表有头结点时,头指针指向头结点;当链表没有头结点时,头指针直接指向第一个元素节点。这样设计的好处是,无论链表的状态如何,头指针始终提供了访问链表的起始点。
-
-
单链表操作
头插法建立单链表
头插法是通过在链表的头部插入新节点来构建链表。这种方法的特点是可以实现链表的逆序。
s = (LinkList)malloc(sizeof(LNode)); // 分配新节点的内存 s->data = x; // 将数据赋值给新节点 s->next = L->next; // 将新节点的指针指向原链表的第一个元素 L->next = s; // 将头节点的指针指向新节点
解释:
s = (LinkList)malloc(sizeof(LNode));
:分配内存为新节点s
,并将其类型转换为链表节点类型。s->data = x;
:将要插入的数据x
赋值给新节点s
。s->next = L->next;
:新节点的next
指针指向当前链表的第一个元素,这样新节点s
成为链表的第一个元素。L->next = s;
:将头节点的next
指针指向新节点s
,从而更新链表的头部。
尾插法建立单链表
尾插法是通过在链表的尾部插入新节点来构建链表。读入数据的顺序与链表中元素的顺序一致。
s = (LinkList)malloc(sizeof(LNode)); // 分配新节点的内存 s->data = x; // 将数据赋值给新节点 r->next = s; // 将当前尾节点的指针指向新节点 r = s; // 更新尾节点为新节点 r->next = NULL; // 新节点的指针指向 NULL,表示新节点为尾节点
解释:
s = (LinkList)malloc(sizeof(LNode));
:分配内存为新节点s
。s->data = x;
:将数据x
赋值给新节点s
。r->next = s;
:将当前尾节点r
的next
指针指向新节点s
。r = s;
:更新尾节点r
为新插入的节点s
。r->next = NULL;
:将新节点的next
指针设为NULL
,表示该节点为链表的尾部。
插入节点
在给定节点
p
之后插入新节点s
。s->next = p->next; // 将新节点的指针指向 p 的后继节点 p->next = s; // 将 p 的指针指向新节点
解释:
s->next = p->next;
:将新节点s
的next
指针指向节点p
的后继节点。p->next = s;
:将节点p
的next
指针更新为新节点s
,实现插入操作。
删除节点
删除节点
p
的后继节点。q = p->next; // 将后继节点赋值给 q p->next = q->next; // 将 p 的指针指向 q 的后继节点 free(q); // 释放 q 节点的内存
解释:
q = p->next;
:获取节点p
的后继节点q
。p->next = q->next;
:将节点p
的next
指针指向q
的后继节点,从而跳过节点q
,实现删除操作。free(q);
:释放节点q
的内存,避免内存泄漏。
-
双链表
- 双链表允许通过每个节点访问其直接前驱和后继。这种结构使得在链表中进行插入和删除操作更加灵活和高效。双链表的每个节点包含三个部分:数据域、指向前驱节点的指针和指向后继节点的指针。
双链表插入操作
s->next = p->next; p->next->prior = s; s->prior = p; p->next = s;
解释:
-
s->next = p->next;
- 将新节点
s
的next
指针指向节点p
的后继节点。这样做是为了将新节点插入到p
之后,并连接到p
后面的节点。
- 将新节点
-
p->next->prior = s;
- 如果
p
的后继节点存在,更新该后继节点的prior
指针,使其指向新插入的节点s
。这确保了后继节点的前驱指针正确指向新节点。
- 如果
-
s->prior = p;
- 将新节点
s
的prior
指针指向节点p
,建立前驱关系,指示s
的前驱节点是p
。
- 将新节点
-
p->next = s;
- 更新节点
p
的next
指针,使其指向新节点s
,完成插入操作。
- 更新节点
双链表删除操作
p->next = q->next; q->next->prior = p; free(q);
解释:
-
p->next = q->next;
- 将节点
p
的next
指针更新为q
的后继节点。这一步跳过了节点q
,实现了删除操作。
- 将节点
-
q->next->prior = p;
- 如果
q
的后继节点存在,更新该后继节点的prior
指针,使其指向节点p
。这样确保了后继节点的前驱指针正确指向p
,保持了链表的完整性。
- 如果
-
free(q);
- 释放节点
q
的内存,避免内存泄漏,完成删除操作。
- 释放节点
-
循环单链表
-
定义:循环单链表是一种特殊的单链表,其中最后一个节点的指针指向链表的头节点,形成一个闭合的循环结构。
-
结构:每个节点包含一个数据域和一个指向下一个节点的指针。链表的最后一个节点指向链表的第一个节点,而不是指向
NULL
。 -
访问:可以从任意节点开始遍历整个链表,因为最后一个节点指向第一个节点,形成了一个环。
-
优点:可以在没有头节点的情况下实现循环结构,适合需要循环访问的场景,比如约瑟夫问题。
-
示例:
typedef struct Node { int data; struct Node *next; } Node; Node *createCircularLinkedList(int arr[], int size) { Node *head = (Node *)malloc(sizeof(Node)); head->data = arr[0]; Node *current = head; for (int i = 1; i < size; i++) { Node *newNode = (Node *)malloc(sizeof(Node)); newNode->data = arr[i]; current->next = newNode; current = newNode; } current->next = head; // 形成循环 return head; }
-
-
循环双链表
-
定义:循环双链表是一种特殊的双链表,其中最后一个节点的后继指针指向头节点,头节点的前驱指针指向最后一个节点,形成一个闭合的循环结构。
-
结构:每个节点包含数据域、指向前驱节点的指针和指向后继节点的指针。头节点和尾节点相互连接,形成一个环。
-
访问:从任意节点开始都可以遍历整个链表,因为头节点和尾节点互相指向。
-
优点:允许双向遍历,并且支持循环访问,适合需要双向访问的场景,如某些操作系统的任务调度。
-
示例:
typedef struct DNode { int data; struct DNode *prior; struct DNode *next; } DNode; DNode *createCircularDoublyLinkedList(int arr[], int size) { DNode *head = (DNode *)malloc(sizeof(DNode)); head->data = arr[0]; DNode *current = head; for (int i = 1; i < size; i++) { DNode *newNode = (DNode *)malloc(sizeof(DNode)); newNode->data = arr[i]; current->next = newNode; newNode->prior = current; current = newNode; } current->next = head; // 形成循环 head->prior = current; // 尾节点的前驱指向头节点 return head; }
-
-
静态链表
-
定义:静态链表是一种基于数组实现的链表结构,每个元素在数组中占用一个固定的空间,通过下标来实现链表节点之间的连接。
-
结构:使用一个数组来存储节点,每个节点包含数据域和一个指向下一个节点的索引(而不是指针)。
-
优点:避免了动态内存分配的开销,适合在已知节点数量的情况下使用。
-
缺点:数组大小固定,无法动态扩展,插入和删除操作复杂。
-
示例:
#define MAX_SIZE 100 typedef struct StaticNode { int data; int next; // 存储下一个节点的索引 } StaticNode; typedef struct StaticLinkedList { StaticNode nodes[MAX_SIZE]; int head; // 头节点的索引 int size; // 当前链表的大小 } StaticLinkedList; void initStaticLinkedList(StaticLinkedList *list) { list->head = -1; // 初始化头节点为 -1,表示空链表 list->size = 0; }
-
-
指针本身与指针指向的结点
-
指针的定义:指针是一个变量,其值为另一个变量的地址。指针可以用来直接访问存储在该地址中的数据。在链表结构中,指针通常用于链接节点。
-
指针本身
-
定义:
指针本身是一个变量,它存储一个内存地址。这个地址通常是其他变量(如链表节点)的地址。 -
声明:
在C语言中,指针的声明方式如下:Node *ptr; // ptr 是一个指向 Node 类型的指针
这里,
Node
是结构体的类型,ptr
是指针变量的名称。 -
使用:
通过指针,我们可以访问指向的变量或结构的值。可以使用取值操作符*
来访问指针指向的内容:Node temp = *ptr; // 通过 ptr 获取指向的 Node 结构体的值
-
-
指针指向的节点
-
节点结构:
在链表中,每个节点通常由数据域和指针域组成。指针域指向下一个节点。typedef struct Node { int data; // 数据域 struct Node *next; // 指向下一个节点的指针 } Node;
-
指针的作用:
指针指向的节点是指针所引用的实际内存位置。在链表中,每个节点的next
指针指向下一个节点,这样可以通过指针进行遍历。
-
-
指针与节点的关系
-
创建节点:
当创建一个节点时,通常会分配内存并将指针指向这个节点。Node *newNode = (Node *)malloc(sizeof(Node)); // 动态分配内存 newNode->data = 10; // 设置数据域 newNode->next = NULL; // 初始化指针域
-
链表连接:
在链表中,指针的作用是将节点连接在一起。通过指针,可以形成一个链式结构。Node *head = newNode; // 头指针指向第一个节点 Node *secondNode = (Node *)malloc(sizeof(Node)); secondNode->data = 20; secondNode->next = NULL; newNode->next = secondNode; // 将第一个节点的指针指向第二个节点
-
-
访问和修改节点
-
访问节点数据:
通过指针,可以轻松访问或修改节点的数据。printf("%d\n", head->data); // 访问头节点的数据 head->data = 30; // 修改头节点的数据
-
遍历链表:
使用指针可以遍历整个链表,从头节点开始,依次访问每个节点。Node *current = head; // 指向当前节点 while (current != NULL) { printf("%d ", current->data); current = current->next; // 移动到下一个节点 }
-
-
六、链表与顺序表的比较与选择
1. 内存使用
- 顺序表(数组):
- 连续内存:顺序表在内存中占用一段连续的空间。
- 固定大小:在创建时通常需要确定大小,动态扩展需要重新分配内存并复制数据。
- 内存利用率:可能存在空间浪费(预留的未使用空间)或空间不足(需要扩展)。
- 链表:
- 非连续内存:链表的节点在内存中可以分散存储,通过指针连接。
- 动态大小:可以根据需要动态增加或减少节点,内存利用更灵活。
- 额外空间开销:每个节点需要额外的指针(单链表一个指针,双链表两个指针),增加了空间开销。
2. 访问时间
-
顺序表:
- 随机访问:支持常数时间 ( O(1) ) 的随机访问,通过索引直接访问任意元素。
- 遍历效率高:由于内存连续,遍历时访问速度快。
-
链表:
- 顺序访问:只能从头节点开始,依次访问每个节点,访问任意元素的时间复杂度为 ( O(n) )。
- 不支持随机访问:无法通过索引直接访问元素,需要逐一遍历。
3. 插入和删除
-
顺序表:
- 插入和删除:在中间位置插入或删除元素需要移动大量元素,时间复杂度为 ( O(n) )。
- 表尾插入高效:在表尾插入元素,如果有足够空间,时间复杂度为 ( O(1) )。
-
链表:
- 插入和删除高效:只需修改指针,时间复杂度为 ( O(1) )(前提是已知插入或删除的位置)。
- 无需移动元素:节点的插入和删除不会影响其他节点的位置。
4. 缓存性能
-
顺序表:
- 缓存友好:由于内存连续,顺序表在遍历时更好地利用了 CPU 缓存,提升访问速度。
-
链表:
- 缓存不友好:节点分散存储,访问时可能频繁发生缓存未命中,导致访问速度较慢。
5. 内存分配
-
顺序表:
- 静态内存分配:通常在编译时确定大小,或者通过动态数组在运行时分配。
- 重新分配成本高:当需要扩展数组时,需要分配新内存并复制数据,开销较大。
-
链表:
- 动态内存分配:节点按需分配,灵活应对不同大小需求。
- 分配效率:每次插入只需分配一个节点,避免了大规模内存复制。
6. 实现复杂度
-
顺序表:
- 实现简单:数组的实现和操作较为直接,代码简洁。
- 操作有限:插入和删除等操作需要处理元素移动,代码相对复杂。
-
链表:
- 实现较复杂:需要管理指针,确保节点连接正确,容易出现指针错误。
- 灵活性高:支持高效的插入和删除操作,适应性强。
7. 灵活性
-
顺序表:
- 灵活性较低:大小固定或需要重新分配内存,扩展性受限。
- 适合静态数据:当数据量和需求相对稳定时,顺序表表现良好。
-
链表:
- 高度灵活:动态调整大小,适应数据量变化。
- 适合动态数据:当数据频繁变化,插入和删除操作频繁时,链表更具优势。
8. 适用场景
-
顺序表适用场景:
- 需要频繁随机访问:如实现栈、队列、查找表等。
- 数据量相对固定:如存储常量集合、静态数据等。
- 内存连续性重要:如需要高效缓存利用的场景。
-
链表适用场景:
- 需要频繁插入和删除:如实现动态列表、图的邻接表等。
- 数据量动态变化:如实时数据处理、动态任务调度等。
- 内存分散可接受:不要求数据连续存储。
9. 性能比较表
特性 | 顺序表(数组) | 链表 |
---|---|---|
内存使用 | 连续内存,固定或动态大小,可能有空间浪费 | 非连续内存,动态大小,需额外指针空间 |
访问时间 | 随机访问 ( O(1) ),遍历效率高 | 顺序访问 ( O(n) ),遍历效率较低 |
插入和删除 | 中间插入删除需移动元素 ( O(n) ),表尾插入 ( O(1) ) | 插入删除只需修改指针 ( O(1) ) |
缓存性能 | 高,利用 CPU 缓存更好 | 低,节点分散导致缓存未命中 |
内存分配 | 需要预分配或动态扩展,扩展成本高 | 动态分配,插入删除开销小 |
实现复杂度 | 实现简单,操作直接 | 实现复杂,需管理指针关系 |
灵活性 | 低,大小固定或需重新分配内存 | 高,动态调整大小 |
适用场景 | 需要频繁随机访问,数据量相对固定,内存连续性重要 | 需要频繁插入删除,数据量动态变化,内存分散可接受 |
选择指南
-
选择顺序表:
- 当需要频繁的随机访问,且插入删除操作较少。
- 当数据量相对固定或变化不大,且内存连续性有利于性能优化。
- 如实现数组、栈、队列、查找表等数据结构。
-
选择链表:
- 当需要频繁的插入和删除操作,且对随机访问要求不高。
- 当数据量动态变化,且不希望频繁重新分配内存。
- 如实现动态列表、图的邻接表、任务调度等应用。