🤗🤗大家好呀,这是我新开的栏目(也是我的学习打卡计划)
【数据结构】的第一篇博客——线性表
🎶🎶博主也是小白,希望可以和大家一起学习进步
✨这里是目录
文章目录
一、线性表:
(概念性的部分,其实可以不需要🤭)
1.线性表的定义
线性表是最基本、最简单、也是最常用的一种数据结构。线性表(linear list)是数据结构中的一种,一个线性表是n个具有相同特性的数据元素的有限序列。
线性表中数据元素之间的关系是一对一的关系,即除了第一个和最后一个数据元素之外,其它数据元素都是首尾相接的(注意,这句话只适用大部分线性表,而不是全部。比如,循环链表逻辑层次上也是一种线性表(存储层次上属于链式存储,但是把最后一个数据元素的尾指针指向了首位结点)
2.线性表的分类
从物理空间的连续性来说,线性表有两种储存结构:顺序储存结构与链式储存结构
这分别就是我们接下来要介绍并实现的顺序表与链表
二、线性表的存储结构及其实现(C语言)
1.顺序存储结构 —— 顺序表
1.1 顺序表的概念与特点
概念:用一段连续的内存单元来依次存储顺序表的每一个数据元素
对于顺序表中的每一个元素,他们的物理顺序和逻辑顺序是相同的。由于物理顺序上的连续性,使得我们在知道一个元素位置的前提下,能够对表中的任意一个元素,在O(1)的时间内进行随机访问。在高级语言中,数组这个类型也具有内存空间连续,并支持随机访问数据元素的的特性,所以我们选择数组来实现线性表。
1.2 顺序表的实现
1.2.1 静态顺序表的结构(用定长数组储存元素)
#define MAX 10
typedef int DataType;
typedef struct SeqList
{
DataType a[MAX];
size_t size; //有效数据的数量
}
因为对于静态的顺序表只适用于我们知道存储数据的数量的情况下,实际情况下,我们往往不知道这个数目。这样一个难题就摆在我们面前:**空间开多大?**开少了会不够用,开多了会造成空间浪费。
所以实际上我们往往利用的是动态的顺序表,按照需求动态分配空间。
所以我们来实现一个动态顺序表(静态数据表相关接口类似)
1.2.2 动态顺序表的结构
typedef int DataType;
typedef struct SeqList
{
DataType* a;
int size; //有效数据数量
int capacity; //数组空间容量
};
此处把数据类型重定义后,日后如果需要修改存储数据的类型,只需改此处重定义的类型!
1.2.3 动态顺序表的相关接口汇总
//顺序表的初始化
void SeqListInit(SeqList* p);
//检查空间是否已满,如果满了,进行扩容
void Check(SeqList* p);
// 尾插
void SeqListPushBack(SeqList* p, DataType x);
// 尾删
void SeqListPopBack(SeqList* p);
// 头插
void SeqListPushFront(SeqList* p, DataType x);
// 头删
void SeqListPopFront(SeqList* p);
// 查找
int SeqListFind(SeqList* p, DataType x);
// 在pos位置插入x
void SeqListInsert(SeqList* p, int pos, DataType x);
// 删除pos位置的值
void SeqListErase(SeqList* p, int pos);
// 顺序表销毁
void SeqListDestory(SeqList* p);
// 顺序表的打印
void SeqListPrint(SeqList* p);
1.2.4 顺序表初始化
void SeqListInit(SeqList* p)
{
assert(p);
p->a = NULL;
p->capacity = 0;
p->size = 0;
}
1.2.5 检查是否需要扩容
void Check(SeqList* p)
{
if (p->capacity == p->size)
{
int newcapacity = p->capacity == 0 ? 4 : p->capacity * 2;
p->a = (DataType*)realloc(p->a, newcapacity * sizeof(DataType));
p->capacity = newcapacity;
}
}
实现细节上的注意点:一开始capacity可能为零,所以此处用三目运算符进行了一个判断否则简单扩大两倍,如果初始容量为0则乘2还是0;
!realloc相关细节补充!
realloc扩容时有两种情况:
- 如果该段内存空间后面的空余空间够用,则直接原地扩容
- 如果后面空余空间不够用,会在内存中重新开辟一段空间,原来位置的数据会被迁移到这段新开辟的内存中,且会将原来的空间释放掉;迁移的过程会有时间的消耗,带来效率上的降低。
1.2.6 尾插
void SeqListPushBack(SeqList* p, DataType x)
{
assert(p);
Check(p);
p->a[p->size++] = x;
}
1.2.7 头插
void SeqListPushFront(SeqList* p, DataType x)
{
assert(p);
Check(p);
for (int i = p->size; i >0; i--)
{
p->a[i] = p->a[i - 1];
}
p->a[0] = x;
p->size++;
}
🤔插入操作得检查是否需要扩容!
1.2.7 尾删
void SeqListPopBack(SeqList* p)
{
assert(p);
if (p->size > 0)
p->size--;
}
1.2.8 头删
void SeqListPopFront(SeqList* p)
{
assert(p->a);
for (int i = 0; i < p->size - 1; i++)
{
p->a[i] = p->a[i + 1];
}
if (p->size > 0)
p->size--;
}
🤔删除操作在size–时要判断一下嗷,至于为什么自己想想 !
1.2.9 查找
int SeqListFind(SeqList* p, DataType x)
{
assert(p);
for (int i = 0; i < p->size; i++)
{
if (p->a[i] == x)
return i;
}
return -1;
}
1.2.10 在任意位置插入
void SeqListInsert(SeqList* p, int pos, DataType x)
{
assert(p);
if (pos > p->size)
{
printf("insert failed! illegal accesss!\n");
return;
}
Check(p);
for (int i = p->size; i > pos; i--)
{
p->a[i] = p->a[i - 1];
}
p->a[pos] = x;
p->size++;
}
🤔插入,嘶,好像前面也有插入操作欸! 哦吼!😃这个在任意位置插入的操作更厉害!😍
那么我们是不是可以用这个来重写一下头插,尾插捏🤔🤔🤔
改进版尾插🤩
void SeqListPushBack(SeqList* p, DataType x) { assert(p); SeqListInsert(p, p->size, x); }
改进版头插🤩
void SeqListPushFront(SeqList* p, DataType x) { assert(p); SeqListInsert(p, 0, x); }
ohhhhhhh!!!😮😆一句话完成,代码复用,感觉真棒有木有
1.2.11 删除任意位置的元素
void SeqListErase(SeqList* p, int pos)
{
assert(p);
if (pos > p->size - 1)
{
printf("Erase failed\n");
return;
}
for (int i = pos + 1; i < p->size; i++)
{
p->a[i - 1] = p->a[i];
}
if (p->size > 0)
p->size--;
}
欸嘿嘿删除🤤🤤🤤,聪明的你应该知道我要咋操作啦
改进版尾删
void SeqListPopBack(SeqList* p) { assert(p); SeqListErase(p, p->size - 1); }
改进版头删
void SeqListPopFront(SeqList* p) { assert(p->a); SeqListErase(p, 0); }
能代码复用,避免冗余逻辑,何乐而不为咧🤭🤭🤭
1.2.12 顺序表销毁
“一出好戏,终将落幕”
void SeqListDestory(SeqList* p)
{
assert(p);
free(p->a);
p->a = NULL;
p->capacity = p->size = 0;
}
注意free后置空的好习惯,避免野指针!!!😮
1.2.13 打印顺序表
void SeqListPrint(SeqList* p)
{
assert(p);
for (int i = 0; i < p->size; i++)
{
printf("%d ", p->a[i]);
}
printf("\n");
}
2.链式存储结构 —— 链表
2.1 链表的概念与特性
概念:链表是一种物理存储结构上非连续、非顺序的存储结构,而数据元素的逻辑顺序是连续的,并通过链表中的指针链接次序实现。
下图为可能物理分布
链表的分类有如下几种:
- 单向链表与双向链表
- 带头与不带头链表(此处头也称为哨兵)
- 循环与非循环链表
由排列组合知识,可得到有八种结构的链表,在次我们仅实现不带头单向非循环链表与带头双向循环链表,这也是我们实际中最常用的两种结构😎
2.2不带头单向循环链表的实现
单链表的示意图
2.2.1 单链表的结构
typedef int DataType;
typedef struct ListNode
{
DataType x; //数据域
struct ListNode* next; //指向后继元素的指针
}Node;
2.2.2 单链表相关接口汇总
// 申请一个新节点
Node* BuyNode(DataType x);
// 打印链表
void ListPrint(Node* phead);
// 尾插
void ListPushBack(Node** pphead, DataType x);
// 头插
void ListPushFront(Node** pphead, DataType x);
// 尾删
void ListPopBack(Node** pphead);
// 头删
void ListPopFront(Node** pphead);
// 查找
Node* ListFind(Node* phead, DataType x);
// 在pos位置之后插入
void ListInsertAfter(Node* pos, DataType x);
// 在pos位置后删除
void ListEraseAfter(Node* pos);
// 在pos位置之前插入
void ListInsertBefore(Node** pphead, Node* pos, DataType x);
// 删除pos位置的节点
void ListErase(Node** pphead, Node* pos);
// 销毁链表
void ListDestroy(Node** pphead);
2.2.3 申请新节点
Node* BuyNode(DataType x)
{
Node* newnode = (Node*)malloc(sizeof(Node));
if (!newnode)
{
printf("malloc failed\n");
exit(-1);
}
else
{
newnode->x = x;
newnode->next = NULL;
return newnode;
}
}
😉养成好习惯把新节点next指针置空
在vs2019上malloc后需要检查是否申请成功,不然会报错😅,故这有一个判断newnode是否为空的逻辑。
2.2.4 打印链表
void ListPrint(Node* phead)
{
Node* cur = phead;
while (cur)
{
printf("%d ", cur->x);
cur = cur->next;
}
printf("\n");
}
2.2.5 尾插
void ListPushBack(Node** pphead, DataType x)
{
assert(pphead);
Node* newnode = BuyNode(x);
if (*pphead == NULL)
{
*pphead = newnode;
}
else
{
Node* tail = *pphead;
while (tail->next)
{
tail = tail->next;
}
tail->next = newnode;
}
}
😮对于尾插操作,链表为空与非空时的逻辑有差异,一定要分开处理
😏不过后期我们有一种方法将其化为同一种逻辑,详见双向带头循环链表
2.2.6 头插
void ListPushFront(Node** pphead, DataType x)
{
assert(pphead);
Node* newnode = BuyNode(x);
newnode->next = *pphead;
*pphead = newnode;
}
2.2.7 尾删
void ListPopBack(Node** pphead)
{
assert(pphead);
if (*pphead == NULL)
return;
else
{
if ( (*pphead)->next == NULL)
{
free(*pphead);
*pphead = NULL;
}
else
{
Node* tail = *pphead;
while (tail->next->next)
{
tail = tail->next;
}
free(tail->next);
tail->next = NULL;
}
}
}
!空表、仅有一个节点的表,与多个节点的表,处理逻辑有差异,思考要全面 😎
2.2.8 头删
void ListPopFront(Node** pphead)
{
assert(pphead);
if (*pphead == NULL)
return;
Node* tmp = *pphead;
(*pphead) = (*pphead)->next;
free(tmp);
}
2.2.9 查找
Node* ListFind(Node* phead, DataType x)
{
Node* cur = phead;
while (cur)
{
if (cur->x == x)
return cur;
cur = cur->next;
}
return NULL;
}
2.2.10 指定位置之后插入
void ListInsertAfter(Node* pos, DataType x)
{
Node* newnode = BuyNode(x);
newnode->next = pos->next;
pos->next = newnode;
}
2.2.11 在指定位置之后删除
void ListEraseAfter(Node* pos)
{
if (pos->next == NULL)
return;
Node* tmp = pos->next;
pos->next = pos->next->next;
free(tmp);
}
2.2.12 在指定位置之前插入
void ListInsertBefore(Node** pphead, Node* pos, DataType x)
{
assert(pphead);
Node* cur = *pphead;
if (cur == pos)
{
ListPushFront(pphead, x);
}
else
{
Node* newnode = BuyNode(x);
while (cur->next != pos)
{
cur = cur->next;
}
cur->next = newnode;
newnode->next = pos;
}
}
2.2.13 删除指定位置的节点
void ListErase(Node** pphead, Node* pos)
{
if (pos == NULL) return;
Node* cur = *pphead;
if (cur == pos)
{
ListPopFront(pphead);
}
else
{
while (cur->next != pos)
{
cur = cur->next;
}
cur->next = pos->next;
free(pos);
}
}
2.2.14 销毁链表
“一出好戏,终将落幕”
void ListDestroy(Node** pphead)
{
assert(pphead);
Node* cur = *pphead;
while (cur)
{
Node* next = cur->next;
free(cur);
cur = next;
}
*pphead = NULL;
}
🙄销毁时置空将二级指针pphead解引用后的值置空,避免野指针
2.2.15 单链表实现上的Q&A(敲黑板)
为什么我们要传二级指针? 传一级指针行吗?
A:不行。对于单链表的操作可能会修改第一个节点,传二级指针才能达到对一级指针的修改,传一级指针只修改形参,不修改实参。
单链表适合在头部操作还是在尾部操作 ?
A:头部操作。头部操作效率高,尾部操作需要遍历链表去找尾,O(N),效率低。
单链表删除指定位置的节点好,还是删除指定位置之后的节点好?
A:指定位置之后的节点更好,因为单向链表没法直接找到前一个节点,只能通过遍历去找到前一个节点。
2.3 双向带头(哨兵)循环链表的实现
2.3.1 结构示意与说明
✨✨补充说明:
双向带头循环链表(circular,double linked list with a sentinel)在next(后继指针)的基础上又加入了prev(前驱指针),又将最后一个节点的next指针指向头(哨兵)节点,使得链表头尾相接实现循环。
上面我们在单链表中提及的统一尾删中两种逻辑的方法即为加入头节点,又称哨兵(sentinel)
我们规定,一个空表仅由一个哨兵构成,这样我们要操作的每一个节点不是链表的“头”,每一个的实现逻辑上都是一样的。
让我们再看看《算法导论》对哨兵的描述:
“哨兵(sentinel)是一个哑对象,其作用是简化边界条件的处理…哨兵的基本不能降低数据结构相关操作的渐进时间线,但可以降低常数因子。在循环语句中,使用哨兵的好处往往在于可以使代码简洁,而非提高速度…我们应当慎用哨兵。假如有许多很短的链表,他们的哨兵所占用的额外的存储空间会造成严重的存储浪费。”
总而言之:双向循环带头链表的结构是非常优秀的🤩🤩
在实际存储数据时我们往往选择用这种链表,在C++的STL库中的list就是双向循环带头链表。
可是这个链表结构好复杂啊,实现起来会不会很复杂啊😔😔
**不复杂!!!一点也不复杂!!!**🤭🤭
甚至由于其优秀的结构,**实现起来比单链表还简单!**不信?咱往下看!😉😉😉
2.3.2 双向带头循环链表的结构
typedef int DataType;
typedef struct Node
{
DataType data; //数据域
struct Node* next; //后继指针
struct Node* prev; //前驱指针
}Node;
2.3.3 相关接口汇总
//申请新节点
Node* BuyNode(DataType x);
//创建一个含哨兵的空链表
Node* ListCreate();
//打印链表
void ListPrint(Node* phead);
//尾插
void ListPushBack(Node* phead, DataType x);
//尾删
void ListPopBack(Node* phead);
//头插
void ListPushFront(Node* phead, DataType x);
//头删
void ListPopFront(Node* phead);
//查找
Node* ListFind(Node* phead, DataType x);
//在pos位置之前进行插入
void ListInsert(Node* pos, DataType x);
//删除pos位置的节点
void ListErase(Node* pos);
//销毁链表
void ListDestroy(Node* phead);
2.3.4 申请新节点
Node* BuyNode(DataType x)
{
Node* newnode = (Node*)malloc(sizeof(Node));
if (!newnode)
{
printf("malloc failed\n");
exit(-1);
}
else
{
newnode->data = x;
newnode->next = NULL;
newnode->prev = NULL;
return newnode;
}
}
2.3.5 创建空表
Node* ListCreate()
{
Node* head = BuyNode(0);
head->next = head;
head->prev = head;
return head;
}
2.3.6 打印链表
void ListPrint(Node* phead)
{
Node* cur = phead->next;
while (cur != phead)
{
printf("%d ", cur->data);
cur = cur->next;
}
printf("\n");
}
2.3.7 尾插
void ListPushBack(Node* phead, DataType x)
{
Node* newnode = BuyNode(x);
newnode->prev = phead->prev;
phead->prev->next = newnode;
newnode->next = phead;
phead->prev = newnode;
}
2.3.8 尾删
void ListPopBack(Node* phead)
{
Node* tail = phead->prev;
if (tail == phead)
return;
tail->prev->next = phead;
phead->prev = tail->prev;
free(tail);
}
2.3.9 头插
void ListPushFront(Node* phead, DataType x)
{
Node* newnode = BuyNode(x);
newnode->next = phead->next;
phead->next->prev = newnode;
phead->next = newnode;
newnode->prev = phead;
}
🤔5、6行和8、9行的顺序不能颠倒哦!
2.3.10 头删
void ListPopFront(Node* phead)
{
Node* target = phead->next;
if (target == phead)
return;
phead->next = target->next;
target->next->prev = phead;
free(target);
}
2.3.11 查找
Node* ListFind(Node* phead,DataType x)
{
Node* cur = phead->next;
while (cur != phead)
{
if (cur->data == x)
return cur;
cur = cur->next;
}
return NULL;
}
2.3.12 指定位置之前插入
void ListInsert(Node* pos, DataType x)
{
Node* newnode = BuyNode(x);
pos->prev->next = newnode;
newnode->prev = pos->prev;
pos->prev = newnode;
newnode->next = pos;
}
在指定位置之前插入,emmm不戳不戳,如果你看过前面顺序表的代码,应该知道我要做什么了😎😎
**没错!代码复用!**😍
改进版尾插
头插不就是在head前面插入嘛😏
void ListPushBack(Node* phead,DataType x) { ListInsert(phead,x); }
改进版头插
头插不就是在head->next之前插入嘛😏
void ListPushFront(Node* phead,DataType x) { ListInsert(phead->next,x); }
2.3.13 删除指定位置的节点
void ListErase(Node* pos)
{
if (pos->next == pos)
return;
pos->prev->next = pos->next;
pos->next->prev = pos->prev;
free(pos);
}
不多说咧,代码复用!😏
改进版尾删
尾删不就是删除head->prev嘛😋
void ListPopBack(Node* phead) { ListErase(phead->prev); }
改进版头删
头删不就是删除head–>next嘛😋
void ListPopFront(Node* phead) { ListErase(phead->next); }
2.3.14 销毁链表
“一出好戏,终将落幕”
void ListDestroy(Node* phead)
{
Node* cur = phead->next;
while (cur != phead)
{
Node* next = cur->next;
free(cur);
cur = next;
}
free(phead);
}
对于链表,代码实现是需要多调试,多画图,才能更好的去理解运用。
三、顺序表与链表的比较
不同点 | 顺序表 | 链表 |
---|---|---|
存储空间上 | 物理上连续 | 逻辑上连续,但是物理空间不一定连续 |
随机访问 | 支持O(1)的随机访问 | 不支持随机访问,访问元素需要O(N) |
任意位置插入删除元素 | 可能需要移动元素,效率低,O(N) | 修改指针指向即可,效率高 |
容量 | 动态顺序表可以扩容 | 没有容量的概念 |
应用 | 元素高效存储以及频繁访问的场景 | 任意位置插入,删除频繁的场景 |
四、写在后面
本章内容是我准备的数据结构的第一篇博客
希望能同大家一起成长,一起进步。
如果你对博客内容有什么疑惑,或者你发现了什么错误,我热烈的欢迎你来同我交流讨论。
有关源码,后续我会传到gitee上
仓库地址:https://gitee.com/admire233/data_-structure.git
本章只是一些简单操作的实现,本着理论结合实践,下期我将更新一篇数组与链表的OJ解析,更加深入的去学习掌握一些更妙更厉害的操作。