目录
前言
什么是数据结构:
百度百科:数据结构是计算机存储、组织数据的方式。数据结构是指相互之间存在一种或多种特定关系的数据元素的集合。
为何要学习数据结构:
通常情况下,精心选择的数据结构可以带来更高的运行或者存储效率。数据结构往往同高效的检索算法和索引技术有关。
一、单向链表
1.简介
链表作为一种基本的数据结构在程序开发过程当中经常会使用到。对C语言来说链表的实现主要依靠结构体和指针;
链表的一个结点如下⬇️:
typedef int SLTDataType;
typedef struct SListNode
{
SLTDataType data;
struct SListNode* next;
}SLTNode;
如图⬇️,为一个基本的单向链表:
掌握单向链表的增 、 删 、 查 、 改 ,为之后的带头、双向、循环链表做铺垫。
2.代码与图解
先看一下实现链表需要的函数⬇️
void SListPrint(SLTNode* phead);
void SListPushBack(SLTNode** pphead, SLTDataType x);
void SListPushFront(SLTNode** pphead, SLTDataType x);
void SListPopFront(SLTNode** pphead);
void SListPopBack(SLTNode** pphead);
SLTNode* SListFind(SLTNode*phead, SLTDataType x);
void SListInsert(SLTNode** pphead,SLTNode*pos, SLTDataType x);
void SListErase(SLTNode** pphead, SLTNode* pos);
①.尾插 1.创建一个结构体指针(newnode),判断头指针是否为空(注意是*pphead);
2.若不为空:备份头指针(tail),用while循环找到链表的尾部,最后将尾部的下一个指向新创建的newnode。
void SListPushBack(SLTNode **pphead, SLTDataType x)
{
SLTNode* newnode = BuySListNode(x);
if (*pphead == NULL)
{
*pphead = newnode;
}
else
{
SLTNode* tail = *pphead;
while (tail->next != NULL)
{
tail = tail->next;
}
tail->next = newnode;
}
}
其中BuySListNode函数即创建并初始化(注意返回值为SLTNode*):
SLTNode*BuySListNode(SLTDataType x)
{
SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));
newnode->data = x;
newnode->next = NULL;
return newnode;
}
(理解了尾插,头插就是小case啦)
②.头插 1. newnode->next ← *pphead; (“旧头”给“新头”的next👌)
2.*pphead ← newnode;(将“新头”的地址给“旧头”,及把newnode变成新头);
void SListPushFront(SLTNode** pphead, SLTDataType x)
{
SLTNode* newnode = BuySListNode(x);
newnode->next = *pphead;
*pphead = newnode;
}
③.头删 ※1.若将头一刀砍,身子就找不到取出了🤣,意思是要将头指向的下一个保存起来(next);
2.free掉旧头,*pphead的地址变为next的地址(及将链表的第二个数变成新头)*pphead ← next。
void SListPopFront(SLTNode** pphead)
{
SLTNode*next= ( * pphead)->next;
free(*pphead);
*pphead = next;
}
④尾删 (别以为头删简单,就轻视尾删)
1.两种特殊情况:链表为空 和 只有一个头,想到了后面删除就简单了;
2.若有两个及以上:同样第一步是找到链表的尾部,※但是直接将尾部置为空,那么倒数第二个的next将无家可归🤦♂️
所以,定义一个prev,当next的小老弟,跟在next的后面,当next指向尾部时,prev就自然是倒数第二了!
void SListPopBack(SLTNode** pphead){
if (*pphead = NULL)
return;
else if ((*pphead)->next = NULL)
{
free(*pphead);
*pphead = NULL;
}
else
{
SLTNode* prev = NULL;
SLTNode* tail = *pphead;
while (tail->next != NULL)
{
prev = tail;
tail = tail->next;
}
free(tail);
prev->next = NULL;
}
}
重头戏来了🙌
⑤插入 1.往pos之前插入,自然需要找到pos的位置(查找函数在下方),再将pos转入SListInsert函数之内,最后将需要插 入的值传入;
2.如果pos的地址就为pphead,那么就是上面说的头插了;
3.同样定义prev,用它借助while循环找到pos的前一位,之后开辟一个newnode,通过next将prev----newnode---- pos三者连接起来。
void SListInsert(SLTNode** pphead, SLTNode* pos, SLTDataType x) {
if (pos == *pphead)
SListPushFront(pphead, x);
else
{
SLTNode* prev = *pphead;
while (prev->next != pos)
{
prev = prev->next;
}
SLTNode* newnode = BuySListNode(x);
prev->next = newnode;
newnode->next = pos;
}
}
⑥删除 1.思路与 插入 类似:如果pos的地址就为pphead,那么就算是尾删了;
2.举一反三:定义prev,用它找到pos的前一位并用prev指向它,此时用next将prev与pos的下一位(pos->next)链接起来就能将pos指向的位置给丢掉啦!(别玩了用free掉pos,将他丢干净😎)
void SListErase(SLTNode** pphead, SLTNode* pos) {
if (pos == *pphead)
SListPopBack(pphead);
else
{
SLTNode* prev = *pphead;
while (prev->next != pos)
{
prev = prev->next;
}
prev->next = pos->next;
free(pos);
}
}
补充
①查找(pos) 1.※( *phead是一级指针),传入x,查找data为x的指针cur并将其返回(return cur)。
SLTNode* SListFind(SLTNode* phead, SLTDataType x)
{
SLTNode* cur = phead;
while (cur)
{
if (cur->data == x) //特别注意= 与 ==的区别;
{
return cur;
}
cur = cur->next;
}
return NULL;
}
②输出 有了上述的理解,这儿就显得很easy啦!
void SListPrint(SLTNode* phead)
{
SLTNode* cur = phead;
while (cur != NULL)
{
printf("%d->", cur->data);
cur = cur->next;
}
printf("NULL");
}
二、带头双向循环链表
1.简介
学习了单向链会发现它理解起来容易但是实现起来复杂,而接下来的链表理解起来相对复杂,实现起来却很容易;
这条链表的一个结点如下:
typedef int LTDataType;
typedef struct ListNode
{
struct ListNode* next;
struct ListNode* prev;
LTDataType data;
}ListNode;
如图⬇️,为一个基本的单向链表:
※ 带头:这里的phead为一个不存数据的指针 ,所以传一级指针;可以说phead为假头,phead->next才是真头;
※ 双向:eg.之前尾删需要两个指针,这里的优点就非常明显了,一举两得,得到一个节点可以找到它的前一个(->prev)和后一个(->next);
※ 循环:尾结点的下一个节点不为空,而是假头(tail->next == phead);同样phead->prev == tail,这样能直接找到尾指针。
2.代码与图解
忽略掉简单的初始化、打印、销毁,需实现的函数如下:
void ListPushBack(ListNode* phead, LTDataType x);
void ListPushFront(ListNode* phead, LTDataType x);
void ListPopFront(ListNode* phead);
void ListPopBack(ListNode* phead);
void ListInsert(ListNode* pos, LTDataType x); //插入
void ListErase(ListNode* pos); //删除
这里我们先写出插入与删除,之后有了上文的优点,其他的会变得非常简单:
①.插入
图解⬇️: 找到pos前的first,再将newnode与两者双向链接。
代码如下:
void ListInsert(ListNode* pos, LTDataType x)
{
assert(pos);
ListNode* first = pos->prev;
ListNode* newnode = BuyListNode(x);
first->next = newnode;
newnode->prev = first;
newnode->next = pos;
pos->prev = newnode;
}
②.删除
图解⬇️:
若直接删除pos这个结点,这个链表就一刀两段了,所以要找到pos的头尾,再将它们链接起来。
代码如下:
void ListErase(ListNode* pos)
{
assert(pos);
ListNode* next = pos->next;
ListNode* prev = pos->prev;
prev->next = next;
next->prev = prev;
free(pos);
pos = NULL;
}
有了插入与删除,接下来的代码就会非常简单了;
③ 尾 插:
参考 ①插入 这里传phead过去,到ListInsert函数里pos指向phead,因为是循环链表,可以往phead前找到first(及为链尾),再往first与phead之间插入就实现尾插了。⬇️
void ListPushBack(ListNode* phead, LTDataType x)
{
assert(phead);
ListInsert(phead, x);
}
④ 头 插:
对比尾插,思考一下🤔🤔🤔,这里往ListInsert函数里传入的就是 phead->next 了,
也就是在phead与phead->next之间插入;
void ListPushFront(ListNode* phead, LTDataType x)
{
assert(phead);
ListInsert(phead->next, x);
}
⑤&⑥ 头 删 与 尾 删
参考 ② 删除 若要实现头删,很好想象,就是传假头指向的真头(phead->next)
void ListPopFront(ListNode* phead)
{
assert(phead);
assert(phead->next != phead);
ListErase(phead->next);
}
同样,因为链表循环,尾删 就是删除 phead->prev;
void ListPopBack(ListNode* phead)
{
assert(phead);
assert(phead->next != phead);
ListErase(phead->prev);
}
3.补充
①. 掌握带头 双向 循环 链表也就是掌握了2 × 2 × 2 = 8种链表;
②. 简单的查找与打印就是遍历链表,这里就不说了;
③.Destroy:建立两个指针,将phead之后的结点都free掉,最后将free(phead);
void ListDestory(ListNode* phead)
{
assert(phead);
ListNode* cur = phead->next;
while (cur != phead)
{
ListNode* next = cur->next;
free(cur);
cur = NULL;
cur = next;
}
free(phead);
phead = NULL;
}
三、栈
1.简介
①. 栈,线性表的一种特殊的存储结构。与学习过的线性表的不同之处在于栈只能从表的固定一端对数据进行插入和删除操作,另一端是封死的;
②. 栈的“先进后出”原则:先进:数据元素用栈的数据结构存储起来,称为“入栈”,也叫“压栈”,先进去的被压在最底端;
后出:数据元素从栈结构中提取出来,称为“出栈”,也叫“弹栈”,也就是后进入的比先进入的先出来;
③. 如下图⬇️,我们可以把栈比作手枪的弹夹,看得出来,先被压进弹夹的子弹是后弹出弹夹的;
4.我们用数组存储栈的数据,为了知道栈是否溢出,需要一个变量记录数组的容量capacity,最后用变量top记录栈顶,以实现出栈。将三者放入一个结构体内形成栈⬇️
typedef int STDataType;
typedef struct Stack
{
STDataType* a;
int top;
int capacity;
}ST;
(参考书籍 《大话数据结构》 )
2.代码与图解
先看一下栈实现需要的函数⬇️
void StackInit(ST* ps); //初始化操作,建立一个空栈(ps)
void StackDestory(ST* ps); //若栈存在,则销毁它,并清空
void StackPush(ST* ps,STDataType x); // ※ 插入新数据x到栈ps中并成为栈顶数据
void StackPop(ST* ps); //删除栈S中栈顶数据
STDataType StackTop(ST* ps); //若栈存在且非空,返回ps的栈顶数据
int StackSize(ST* ps); //返回栈的数据个数
bool StackEmpty(ST* ps); //栈为空,返回true,否则返回false
①.初始化:
void StackInit(ST* ps){
assert(ps);
ps->a = (STDataType*)malloc(sizeof(STDataType) * 4);
ps->capacity = 4;
ps->top = 0;
}
②入栈: 1.判断是否入栈之后会出现栈溢出,若栈顶 == 容量(capacity),则relloc对数组a增容,再改变capacity记录下现在的容量;
void StackPush(ST* ps, STDataType x){
assert(ps);
if (ps->capacity == ps->top)
{
STDataType* tmp = (STDataType*)realloc(ps->a, ps->capacity * 2 * sizeof(STDataType));
if (tmp == NULL)
{
printf("realloc fail\n");
exit(-1);
}
else
{
ps->a = tmp;
ps->capacity *= 2;
}
}
ps->a[ps->top] = x;
ps->top++;
}
③.出栈 ※只需要将栈顶(top)-1就可以了,当再次入栈时,新数据会替代top-1之后指向的位置
void StackPop(ST* ps){
assert(ps);
assert(ps->top> 0);
ps->top--;
}
④.栈顶数据、栈大小、判断是否为空栈,均用top实现,较为容易,这里就不详述了,代码可进我Gitee查询。
四、队列
1.简介
①. 队列(queue)是只允许在一端进行插入操作,而在另一端进行删除操作的线性表;
②. 遵循先进先出(First In First Out)原则,简称FIFO;
③. 从名字就能想象,队列与我们平常生活中排队是一样的(但是没有插队这一说法😅)⬇️
④. 要实现一头出,一头进,并且省去从头遍历找到尾,我们定义两个指针,head与tail;
⑤. 队列可以像栈写成数组也可以像链表写成结点,这里区分栈,我把它写成结点,
这儿又有与链表不同的创建结构体方式⬇️:
像左边这样,函数只需要用一级指针接收;
接下来通过代码再次理解。
2.代码与图解
先看一下队列实现需要的函数⬇️
void QueueInit(Queue* pq); //初始化
void QueueDestroy(Queue* pq); //销毁队列
void QueuePush(Queue* pq, QDataType x); //若队列存在,插入新数据x到队列中并成为队尾元素
void QueuePop(Queue* pq); //若队列存在,删除对头数据
QDataType QueueFront(Queue* pq); //返回对头数据
QDataType QueueBack(Queue* pq); //返回队尾数据
int QueueSize(Queue* pq); //返回队列长度
bool QueueEmpty(Queue* pq); //判断队列是否为空(true/false)
①. 尾插
1.※因为创建了两个结构体,所以这里要多加注意结构体指针的类型(QNode和Queue)
2. 同时,老套路了,要考虑队列是否为空;
void QueuePush(Queue* pq, QDataType x)
{
assert(pq);
QNode* newnode = (QNode*)malloc(sizeof(QNode));
newnode->data = x;
newnode->next = NULL;
if (pq->head == NULL){
pq->head = pq->tail = newnode;
}
else{
pq->tail->next = newnode;
pq->tail = pq->tail->next;
}
}
②. 头删
1.若只有head一个结点(也就是head与tail为同一个结点),直接删除掉head;
2.若有多个结点,※不能直接删除头节点,之前也讲到过;定义next指向head->next,再删除head,
将next作为新的头节点。
void QueuePop(Queue* pq)
{
assert(pq);
assert(pq->head);
if (pq->head->next == NULL)
{
free(pq->head);
pq->head = pq->tail = NULL;
}
else
{
QNode* next = pq->head->next;
free(pq->head);
pq->head = next;
}
}
③.之后的函数也与前面极其类似,这里也不占用时间了。(详细代码请看Gitee)
五、二叉树
1.了解递归和递归与栈的关系
(参考书籍 《大话数据结构》、《数据结构与算法图解》)
①. 函数调用自身,就叫作递归,可以将其大概理解成一种特殊的循环;
②. 既然是函数调用自己,那么如果无止境的调用将会是一件非常可怕的事,这就需要一个判断来将这个自我调用往回走(return);
接下来用一个经典的例子:斐波那契数列 来见识一下递归⬇️
数学上可表示为:F(0)=0,F(1)=1,F(n)=F(n-1)+F(n-2)(n≥2,n∈N*)。第0项是0,第1项是第一个1。此数列从第2项开始,每一项都等于前两项之和。(其中的F()也就和C语言中的函数一样,将其转化成代码如下⬇️)
③. 当 i= 0 或 i = 1时,很好理解,传入Fbi函数返回0或1(※这就是函数不断调用自己到最后返回来的条件),但是当 i 是一个较大的数(i = 5)时又该怎么理解呢?我们画图分析⬇️:
注意:图中圆圈里的数字是n,而不是返回值,往回递归时:圆圈里执行的是两个返回值相加,再对结果返回上一个递归中
※补充④ .递归与栈的关系:简单来说,递归分为两个部分(正序与逆序),那和栈怎么就搭边了呢?
在计算机系统内部,在递归正序时存储某些数据,并在后面又以存储的逆序恢复这些数据,以提供之后使用的需求,与栈中的入栈与出栈恰恰相似,因此,编译器会用栈实现递归。
2.树的基本概念
①. 前言:之前学习的都是链表和顺序表,二树由根与子树构成,是一个一对多的结点类型的结构;这里将我们学过的递归思想运用到接下来的学习中
②树的注意事项:
※根结点唯一,如图⬇️,I 的根节点有两个,分别是 D 和 E;
※子树互不相交,如图⬇️,D和E分别是B和C的子树,而他们相交是错误的;
③. 关于树的其他概念较为繁琐,这里就暴力阐述了⬇️
3.树的存储结构(待更)
4.二叉树
①. 二叉树是一种特殊的树,只有根、左子树、右子树组成(可以只有左子树,也可以只有右子树),每个结点的子结点最多不超过两个;
②. ※满二叉树: 若层数为n,那么最后一层的结点个数为 2^n-1 ,通俗来讲就是最后一层的“叶子”一片不少⬇️
※完全二叉树:若层数为n,那么最后一层的结点个数x满足 1≤ x ≤ 2^n-1,就是被摘了几片叶子的满二叉树⬇️
③. 二叉树的性质:
※.若规定根节点的层数为1,则一棵非空_二叉树的第i层上最多有 2^(i-1) 个结点;✔️
※.若规定根节点的层数为1,则深度为h的二叉树的最大结点数是 2^h- 1;✔️
※.对任何一棵二叉树,如果度为0的叶结点个数为n0,度为2的分支结点个数为n2则有 n0= n2+ 1,eg.⬇️
※.若规定根节点的层数为1,具有n个结点的满二叉树的深度, h=logN;✔️
5.前、中、后序遍历(代码与图解)
①. 数组:我们可以简单地用数组存储二叉树的数据,再用下标表示结点位置(一颗满二叉树从根开始,每一层从左到右一次增大),但当不是结构整齐的满二叉树时(eg.只有左子树),也要按照满二叉树给数组分配空间,这样很不划算;
②. 结构体:从根开始,用两个指针分别指向左子树和右子树,再存储该结点的数据,就像链表中的一个结点一样,不同的是二叉树的一个结点指向了两个子结点,我们把这样的结构称为二叉链表,不废话了,看图⬇️
typedef char BTDataType;
typedef struct BinaryTreeNpod
{
struct BTNode* left;
struct BTNode* right;
BTDataType data;
}BTNode;
二叉树遍历
①. 前序:传入根结点,,从根结点开始,递归遍历左子树,若不为空,则继续递归遍历左子树,若为空,返回;之后递归遍历右子树,(根 -> 左子树 -> 右子树)
void PrevOrder(BTNode* root)//前序
{
if (root == NULL)
{
printf("NULL ");
return;
}
printf("%c ", root->data);
PrevOrder(root->left);
PrevOrder(root->right);
}
图解:⬇️
②. 中序:传入根结点,递归遍历左子树,到最后为空返回,访问根,之后遍历右子树;(左子树 -> 根 -> 右子树)
void InOrder(BTNode* root)//中序
{
if (root == NULL)
{
printf("NULL ");
return;
}
InOrder(root->left);
printf("%c ", root->data);
InOrder(root->right);
}
③. 后序:传入根结点 ,递归遍历左子树,到最后为空返回,再递归遍历右子树,最后访问根;(左子树 -> 右子树 -> 根)
void PostOrder(BTNode* root)//后序
{
if (root == NULL)
{
printf("NULL ");
return;
}
PostOrder(root->left);
PostOrder(root->right);
printf("%c ", root->data);
}
图解:⬇️
④. 层序:(待更)
6.补充
①. 计算树总共的结点个数:※其中记录个数的 size 要定义为全局变量,因为每次递归局部变量会因出函数而销毁;或者用传指针;
int size = 0;
void TreeSize(BTNode* root)
{
if (root == NULL)
{
return;
}
else
{
size++;
}
TreeSize(root->left);
TreeSize(root->right);
}
②. ※计算叶结点的总数:
nt TreeLeafSize(BTNode* root)
{
if (root == NULL)
{
return 0;
}
else
return root->left ==NULL&&root->right == NULL ? 1 : TreeLeafSize(root->left)+TreeLeafSize(root->right);
}
Summery💐
• 以上就是大部分基本的数据结构了,文章加上代码的字数一共有一万字了,完全看完是不太现实的,找到自己的不足及时去弥补才是最重要的;
• 以后有学到更复杂的数据结构我会及时更新博客🙈
• 接下来我们一起来学习一些简单的排序算法⬇️
https://blog.csdn.net/Dusong_/article/details/127749130?spm=1001.2014.3001.5502