本章我们来学习一下数据结构中的线性表,在这个部分我们更重要的是理解数据结构的思想,当然代码也要练习,为了不占用文章的大量篇幅,在正文中小编就只会展现完成一个概念需要写的接口有哪些,完整代码小编会在附件中给出。
目录
一. 线性表
1. 概念:
线性表(linear list)是n个具有相同特性的数据元素的有限序列。 线性表是一种在实际中广泛使 用的数据结构,常见的线性表:顺序表、链表、栈、队列、字符串...
线性表在逻辑上是线性结构,也就说是连续的一条直线。但是在物理结构上并不一定是连续的, 线性表在物理上存储时,通常以数组和链式结构的形式存储。
二. 线性表中的顺序表和链表
2. 顺序表
2.1 概念及结构
顺序表是用一段物理地址连续的存储单元依次存储数据元素的线性结构, 一般情况下采用数组存储。在数组上完成数据的增删查改。
顺序表一般可以分为:
(1) 静态顺序表:使用定长数组存储元素。
(2) 动态顺序表:使用动态开辟的数组存储。
2.2 接口实现
静态顺序表只适用于确定知道需要存多少数据的场景。静态顺序表的定长数组导致N定大了,空间开多了浪费,开少了不够用。所以现实中基本都是使用动态顺序表,根据需要动态的分配空间大小,所以下面我们实现动态顺序表。
typedef int SLDataType;
// 顺序表的动态存储
typedef struct SeqList
{
SLDataType* array; // 指向动态开辟的数组
size_t size ; // 有效数据个数
size_t capicity ; // 容量空间的大小
}SeqList;
// 基本增删查改接口
// 顺序表初始化
void SeqListInit(SeqList* psl);
// 检查空间,如果满了,进行增容
void CheckCapacity(SeqList* psl);
// 顺序表尾插
void SeqListPushBack(SeqList* psl, SLDataType x);
// 顺序表尾删
void SeqListPopBack(SeqList* psl);
// 顺序表头插
void SeqListPushFront(SeqList* psl, SLDataType x);
// 顺序表头删
void SeqListPopFront(SeqList* psl);
// 顺序表查找
int SeqListFind(SeqList* psl, SLDataType x);
// 顺序表在pos位置插入x
void SeqListInsert(SeqList* psl, size_t pos, SLDataType x);
// 顺序表删除pos位置的值
void SeqListErase(SeqList* psl, size_t pos);
// 顺序表销毁
void SeqListDestory(SeqList* psl);
// 顺序表打印
void SeqListPrint(SeqList* psl);
由于一个完整的顺序表的代码长度不短,在这里我就大致写出实现一个顺序表所需要实现的函数,代码我会在本文附件中给出。
3. 链表
3.1 链表的概念及结构
概念:链表是一种物理存储结构上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的 。
现实中 数据结构中
3.2 链表的分类
实际中链表的结构非常多样,以下情况组合起来就有8种链表结构:
1. 单向或者双向
2. 带头或者不带头
3. 循环或者非循环
虽然有这么多的链表的结构,但是我们实际中最常用还是两种结构:
1. 无头单向非循环链表: 结构简单 ,一般不会单独用来存数据。实际中更多是作为其他数据结 构的子结构,如哈希桶、图的邻接表等等。另外这种结构在笔试面试中出现很多。
2. 带头双向循环链表: 结构最复杂 ,一般用在单独存储数据。实际中使用的链表数据结构,都 是带头双向循环链表。另外这个结构虽然结构复杂,但是使用代码实现以后会发现结构会带来很多优势,实现反而简单了,后面我们代码实现了就知道了。
3.3 链表的实现
// 1、无头+单向+非循环链表增删查改实现
typedef int SLTDateType;
typedef struct SListNode
{
SLTDateType data;
struct SListNode* next;
}SListNode;
// 动态申请一个结点
SListNode* BuySListNode(SLTDateType x);
// 单链表打印
void SListPrint(SListNode* plist);
// 单链表尾插
void SListPushBack(SListNode** pplist, SLTDateType x);
// 单链表的头插
void SListPushFront(SListNode** pplist, SLTDateType x);
// 单链表的尾删
void SListPopBack(SListNode** pplist);
// 单链表头删
void SListPopFront(SListNode** pplist);
// 单链表查找
SListNode* SListFind(SListNode* plist, SLTDateType x);
// 单链表在pos位置之后插入x
// 分析思考为什么不在pos位置之前插入?
void SListInsertAfter(SListNode* pos, SLTDateType x);
// 单链表删除pos位置之后的值
// 分析思考为什么不删除pos位置?
void SListEraseAfter(SListNode* pos);
3.4 双向链表的实现
// 2、带头+双向+循环链表增删查改实现
typedef int LTDataType;
typedef struct ListNode
{
LTDataType _data;
struct ListNode* next;
struct ListNode* prev;
}ListNode;
// 创建返回链表的头结点 .
ListNode* ListCreate();
// 双向链表销毁
void ListDestory(ListNode* plist);
// 双向链表打印
void ListPrint(ListNode* plist);
// 双向链表尾插
void ListPushBack(ListNode* plist, LTDataType x);
// 双向链表尾删
void ListPopBack(ListNode* plist);
// 双向链表头插
void ListPushFront(ListNode* plist, LTDataType x);
// 双向链表头删
void ListPopFront(ListNode* plist);
// 双向链表查找
ListNode* ListFind(ListNode* plist, LTDataType x);
// 双向链表在pos的前面进行插入
void ListInsert(ListNode* pos, LTDataType x);
// 双向链表删除pos位置的结点
void ListErase(ListNode* pos);
3.5 带环链表
这是属于链表的一种特殊的链表,之所以我将其单独讲解是因为我觉得有必要深化一下我对这种链表的如何判断和圆的开始节点判断方法的印象。
如下是常见的带环链表中的一种,当然,该类型链表尾节点next指针可能指向链表中的任意节点:
(1) 给定一个链表,判断链表中是否有环
方法: 快慢指针,即慢指针一次走一步,快指针一次走两步,两个指针从链表其实位置开始运行, 如果链表 带环则一定会在环中相遇,否则快指针率先走到链表的末尾。
为什么快指针每次走两步,慢指针走一步可以?
假设链表带环,两个指针最后都会进入环,快指针先进环,慢指针后进环。当慢指针刚进环时,可能就和快指针相遇了,最差情况下两个指针之间的距离刚好就是环的长度。此时,两个指针每移动一次,之间的距离就缩小一步,不会出现每次刚好是套圈的情况,因此:在满指针走到一圈之前,快指针肯定是可以追上慢指针的,即相遇。
快指针一次走3步,走4步,...n步行吗?
(2)如何判断进入环的第一个节点
结论:让一个指针从链表起始位置开始遍历链表,同时让一个指针从判环时相遇点的位置开始绕环运行,两个指针都是每次均走一步,最终肯定会在入口点的位置相遇。
证明:
4. 顺序表和链表的区别
不同点 | 顺序表 | 链表 |
存储空间上 | 物理上一定连续 | 逻辑上连续,但物理上不一定 连续 |
随机访问 | 支持O(1) | 不支持: O(N) |
任意位置插入或者删除 元素 | 可能需要搬移元素,效率低 O(N) | 只需修改指针指向 |
插入 | 动态顺序表,空间不够时需要 扩容 | 没有容量的概念 |
应用场景 | 元素高效存储+频繁访问 | 任意位置插入和删除频繁 |
缓存利用率 | 高 | 低 |
备注:缓存利用率参考存储体系结构 以及 局部原理性。
三. 线性表中的栈和队列
5. 栈
5.1 栈的概念及结构
栈: 一种特殊的线性表,其只允许在固定的一端进行插入和删除元素操作。 进行数据插入和删除操作的一端 称为栈顶,另一端称为栈底。 栈中的数据元素遵守后进先出LIFO( Last In First Out)的原则。
压栈:栈的插入操作叫做进栈/压栈/入栈, 入数据在栈顶。
出栈:栈的删除操作叫做出栈。 出数据也在栈顶。
5.2 栈的实现
栈的实现一般可以使用顺序表即数组(顺序栈)或者链表(链表栈)实现,相对而言数组的结构实现更优一些。因为数组在尾上插入数据的代价比较小,以下是一个数组栈的代码。
// 下面是定长的静态栈的结构,实际中一般不实用,所以我们主要实现下面的支持动态增长的栈
typedef int STDataType;
#define N 10
typedef struct Stack
{
STDataType _a[N];
int _top; // 栈顶
}Stack;
// 支持动态增长的栈
typedef int STDataType;
typedef struct Stack
{
STDataType* _a;
int _top; // 栈顶
int _capacity; // 容量
}Stack;
// 初始化栈
void StackInit(Stack* ps);
// 入栈
void StackPush(Stack* ps, STDataType data);
// 出栈
void StackPop(Stack* ps);
// 获取栈顶元素
STDataType StackTop(Stack* ps);
// 获取栈中有效元素个数
int StackSize(Stack* ps);
// 检测栈是否为空,如果为空返回非零结果,如果不为空返回0
int StackEmpty(Stack* ps);
// 销毁栈
void StackDestroy(Stack* ps);
5.3顺序栈和链表栈优缺点对比
5.3.1链栈
(1)优点:
动态性: 链栈的节点可以在运行时动态分配,不需要事先确定栈的最大容量。这使得链栈更加灵活,可以适应不同规模的数据。
无需预先分配固定大小的空间: 由于链栈的节点可以动态分配,不需要预先分配一个固定大小的数组。这对于处理不确定数据规模的情况更为方便。
不浪费空间: 链栈在使用时动态分配内存,不会存在在初始化时就分配过大空间导致的空间浪费问题。
容易实现栈的动态增长: 由于链栈可以动态分配内存,因此在需要时更容易实现栈的动态增长,而不需要重新分配更大的数组。
插入和删除操作更高效: 链栈中插入和删除元素只需要调整指针,而不需要移动元素。这使得链栈在执行插入和删除操作时相对更高效。
(2)缺点:
空间开销: 每个节点都需要额外的空间来存储指针,这会增加一定的空间开销。
访问速度相对较慢: 由于链栈中的元素在内存中不是连续存储的,对于大量元素的访问可能导致缓存不命中,降低访问速度。
5.3.3 顺序栈
(1)优点:
空间效率高: 顺序栈使用数组实现,元素在内存中是连续存储的,不需要额外的指针空间。这使得顺序栈在空间效率上更有优势。
访问速度相对较快: 由于顺序栈中的元素在内存中是连续存储的,对于大量元素的访问速度相对较快。
(2)缺点:
固定大小: 顺序栈在初始化时需要确定固定的大小,如果栈的大小超过了预先分配的空间,可能导致栈溢出。
不灵活: 由于顺序栈的大小是固定的,不太容易动态调整,因此在处理不确定数据规模或需要频繁执行插入和删除操作的情况下可能不太灵活。
总的来说,选择使用链栈还是顺序栈取决于具体的应用场景和需求。链栈更适合处理动态数据,而顺序栈则在空间效率和对元素的频繁访问速度上有一些优势,和顺序栈相比,链栈有一个比较明显的优势是 不会出现栈满频繁扩容的现象。
6. 队列
6.1 队列的概念及结构
队列:只允许在一端进行插入数据操作,在另一端进行删除数据操作的特殊线性表,队列具有先进先出。
FIFO(First In First Out) 入队列:进行插入操作的一端称为队尾 出队列:进行删除操作的一端称为队头。
6.2 队列的实现
队列也可以数组和链表的结构实现,使用链表的结构实现更优一些,因为如果使用数组的结构,出队列在数组头上出数据,效率会比较低,这里我们使用单链表的结构进行实现。
// 链式结构:表示队列
typedef struct QListNode 3 {
struct QListNode* _pNext;
QDataType _data;
}QNode;
// 队列的结构
typedef struct Queue;
{
QNode* _front;
QNode* _rear;
}Queue;
// 初始化队列
void QueueInit(Queue* q);
// 队尾入队列
void QueuePush(Queue* q, QDataType data);
// 队头出队列
void QueuePop(Queue* q);
// 获取队列头部元素
QDataType QueueFront(Queue* q);
// 获取队列队尾元素
QDataType QueueBack(Queue* q);
// 获取队列中有效元素个数
int QueueSize(Queue* q);
// 检测队列是否为空,如果为空返回非零结果,如果非空返回0
int QueueEmpty(Queue* q);
// 销毁队列
void QueueDestroy(Queue* q);
6.3 顺序队列和链表队列优缺点
顺序队列(基于数组实现的队列)和链表队列(基于链表实现的队列)各自具有一些优点和缺点,以下是它们的比较:
6.3.1 顺序队列
(1)优点:
随机访问: 由于顺序队列使用数组实现,可以通过索引直接访问队列中的任意元素,因此具有随机访问的特性。
空间效率高: 顺序队列中的元素在内存中是连续存储的,不需要额外的指针空间,因此具有较高的空间效率。
简单易实现: 顺序队列的实现相对简单,只需要使用一个数组和两个指针即可。
(2)缺点:
固定大小: 顺序队列在初始化时需要确定固定的大小,如果队列的大小超过了预先分配的空间,可能导致队列溢出。
插入和删除操作开销大: 在顺序队列中,如果需要在队列的中间或头部插入或删除元素,需要移动其他元素,导致开销较大。
可能存在空间浪费: 如果顺序队列的实际使用空间小于分配的大小,可能存在空间浪费的情况。
6.3.2 链表队列
(1)优点:
动态性: 链表队列的节点可以在运行时动态分配,不需要事先确定队列的最大容量,使得链表队列更加灵活。
无需预先分配固定大小的空间: 由于链表队列的节点可以动态分配,不需要预先分配一个固定大小的数组。这对于处理不确定数据规模的情况更为方便。
插入和删除操作高效: 在链表队列中,插入和删除元素只需要调整指针,而不需要移动元素。这使得链表队列在执行插入和删除操作时相对较高效。
(2)缺点:
空间开销: 每个节点都需要额外的空间来存储指针,这会增加一定的空间开销。
访问速度相对较慢: 由于链表中的元素在内存中不是连续存储的,对于大量元素的访问可能导致缓存不命中,降低访问速度。
不支持随机访问: 链表队列不支持直接通过索引随机访问元素,需要从头节点开始逐个遍历。
总的来说,选择使用顺序队列还是链表队列通常取决于具体的应用场景和需求。顺序队列在对元素的频繁访问速度和空间效率上有一些优势,而链表队列更适用于处理动态数据和频繁执行插入和删除操作的情况。
6.4 循环队列
另外扩展了解一下,实际中我们有时还会使用一种队列叫循环队列。如操作系统课程讲解生产者消费者模型时可以就会使用循环队列。环形队列可以使用数组实现,也可以使用循环链表实现。
本章结束!