数据结构初阶
前言
首先我们来了解什么是数据结构:
数据结构(Data Structure)是计算机存储、组织数据的方式,指相互之间存在一种或多种特定关系的数据元素的集合。
简要的说:就是计算机中数据存储的结构
我们再来了解什么是算法:
算法(Algorithm):就是定义良好的计算过程,他取一个或一组的值为输入,并产生出一个或一组值作为输出。
简单来说算法就是一系列的计算步骤,用来将输入数据转化成输出结果。
不同的算法效率、使用空间都有优劣。
现在这个阶段我们适合学习一些初级的数据结构:、
-
顺序表
-
链表
-
栈
-
队列
-
堆(二叉树的一种顺序结构)
-
二叉树
然后我们学习常见的排序:
例如: 插入、希尔、选择、堆排、冒泡、快排、归并……
在这个阶段的学习一定要多画图多练习
1. 时间复杂度和空间复杂度
算法在编写成可执行程序后,运行时需要耗费时间资源和空间(内存)资源 。
因此衡量一个算法的好坏,一般是从时间和空间两个维度来衡量的
即**“时间复杂度”和“空间复杂度”**
时间复杂度主要衡量一个算法的运行快慢,而空间复杂度主要衡量一个算法运行所需要的额外空间。
在计算机发展的早期,计算机的存储容量很小。所以对空间复杂度很是在乎。
但是经过计算机行业的迅速发展,计算机的存储容量已经达到了很高的程度。
所以我们如今已经不需要再特别关注一个算法的空间复杂度。
1.1 时间复杂度
大O的渐进表示法:
大O符号(Big O notation):是用于描述函数渐进行为的数学符号。
推导大O阶方法:
-
用常数1取代运行时间中的所有加法常数。
-
在修改后的运行次数函数中,只保留最高阶项。
-
如果最高阶项存在且不是1,则去除与这个项目相乘的常数。得到的结果就是大O阶。
例如:
// 请计算一下Func1中++count语句总共执行了多少次?
void Func1(int N)
{
int count = 0;
for (int i = 0; i < N; ++i)
{
for (int j = 0; j < N; ++j)
{
++count;
}
}
for (int k = 0; k < 2 * N; ++k)
{
++count;
}
int M = 10;
while (M--)
{
++count;
}
}
O(N^2 + 2*N + 10) --> O(N^2)
在这里又要引入:
另外有些算法的时间复杂度存在最好、平均和最坏情况:
最坏情况:任意输入规模的最大运行次数(上界)
平均情况:任意输入规模的期望运行次数
最好情况:任意输入规模的最小运行次数(下界)
例如:在一个长度为N数组中搜索一个数据x
最好情况:1次找到
最坏情况:N次找到
平均情况:N / 2次找到
在实际中一般情况关注的是算法的最坏运行情况,所以数组中遍历搜索数据时间复杂度为O(N)
又例如:
//计算斐波那契递归Fib的时间复杂度?
long long Fib(size_t N)
{
if (N < 3)
return 1;
return Fib(N - 1) + Fib(N - 2);
}
O(2^N) -- 尽管这个不是完全的等比数列,但是它的量级在2^N上
1.2 空间复杂度
空间复杂度也是一个数学表达式,是对一个算法在运行过程中临时占用存储空间大小的量度 。
空间复杂度不是程序占用了多少bytes的空间,因为这个也没太大意义,所以空间复杂度算的是变量的个数。
空间复杂度计算规则基本跟实践复杂度类似,也使用大O渐进表示法。
注意:
函数运行时所需要的栈空间(存储参数、局部变量、一些寄存器信息等)在编译期间已经确定好了,
因此空间复杂度主要通过函数在运行时候显式申请的**“额外”**空间来确定。
例如:
//计算BubbleSort的空间复杂度?
void BubbleSort(int* a, int n)
{
assert(a);
for (size_t end = n; end > 0; --end)
{
int exchange = 0;
for (size_t i = 1; i < end; ++i)
{
if (a[i - 1] > a[i])
{
Swap(&a[i - 1], &a[i]);
exchange = 1;
}
}
if (exchange == 0)
break;
}
}
-- O(1) 只创建了常数个变量
例如:
// 计算Fibonacci的空间复杂度?
// 返回斐波那契数列的前n项
long long* Fibonacci(size_t n)
{
if (n == 0)
return NULL;
long long* fibArray = (long long*)malloc((n + 1) * sizeof(long long));
fibArray[0] = 0;
fibArray[1] = 1;
for (int i = 2; i <= n; ++i)
{
fibArray[i] = fibArray[i - 1] + fibArray[i - 2];
}
return fibArray;
}
O(N)
解释一下:
因为占空间是即用即销毁的,所以函数返回的时候会销毁这个栈空间,但是当下一次递归的时候,这片空间又被重新利用了,利用了重复的空间,因为我们计算的是额外开辟的空间,所以,最多起始开辟了N个栈帧
我们可以这样验证一下:
void Func()
{
int a = 0;
printf("%p\n", &a);
}
int main()
{
Func();
Func(); //两次调用同一个函数
return 0;
}
我们来看运行结果:

我们发现到:
两次a变量的地址都是一样的,说明了:
我们调用两次这个函数都是在同一个栈空间开辟的,也验证了上面我们的说法。
2. 顺序表
2.1 线性表
线性表(linear list)是n个具有相同特性的数据元素的有限序列。
线性表是一种在实际中广泛使用的数据结构,常见的线性表:顺序表、链表、栈、队列、字符串...
线性表在逻辑上是线性结构,也就说是连续的一条直线。
但是在物理结构上并不一定是连续的,线性表在物理上存储时,通常以数组和链式结构的形式存储
例如:

2.2 顺序表概念及分类
概念:
顺序表是用一段物理地址连续的存储单元依次存储数据元素的线性结构,一般情况下采用数组存储。
在数组上完成数据的增删查改。
分类:
-
静态顺序表:使用定长数组存储元素。
-
动态顺序表:使用动态开辟的数组存储。
#include<stdio.h>
#include<stdlib.h>
typedef int SLDataType; //非常好用,想存什么数据改这里即可
#define N 10
//静态顺序表 -- 开少了不够用,开多了浪费;
struct Seqlist
{
SLDataType a[N];
int size;
};
//动态顺序表
typedef struct Seqlist
{
SLDataType* a;
int size; //有效数据个数
int capacity; //空间容量
}SL;
// 这里typedef的含义是: 把struct Seqlist 重命名为: SL
我们希望这个数组长度是我们开辟的。
如图:

2.3 顺序表的模拟实现
一般来说我们都是要实现:
增删查改
接口:
Seqlist.h 声明
typedef int SLDataType;
#define INIT_CAPACITY 4
#include<stdio.h>
#include<stdlib.h>
#include<assert.h>
//定义结构体:
typedef struct SeqList
{
SLDataType* a;
int size; //有效数据个数
int capacity;//容量
}SL;
void SLInit(SL* ps); //初始化
void SLDestroy(SL* ps); //销毁
void SLprint(SL* ps); //打印
void SLPushBack(SL* ps, SLDataType x); //尾插
void SLPopBack(SL* ps); //尾删
void SLCheckCapacity(SL* ps);//检查容量
void SLPushFront(SL* ps, SLDataType x); //头插
void SLPopFront(SL* ps); //头删
void SLInsert(SL* ps, int pos, SLDataType x);//插入
void SLErase(SL* ps, int pos);//删除
int SLFind(SL* ps, SLDataType x);//找数据
下面我们来逐步实现这些功能:
2.3.1 初始化
多文件,函数和定义分离
//初始化顺序表:
void SLInit(SL* ps) //传入结构体的指针
{
//先开辟一个空间
ps->a = (SLDataType*)malloc(sizeof(SLDataType) * INIT_CAPACITY);
if (ps->a == NULL) //检查是否开辟成功
{
perror("malloc fail");
return;
}
ps->size = 0; //size指这个数组的大小
ps->capacity = INIT_CAPACITY; //初始化容量
}
为这个结构体的成员指针开辟一个空间,并给成员指针赋值
2.3.2 销毁
涉及了动态内存开辟,我们就需要想到:需要使用free释放这个内存
所以需要我们额外实现这个销毁的功能
//释放顺序表:
void SLDestroy(SL* ps)
{
free(ps->a); //释放空间
ps->a = NULL; //置空
ps->capacity = 0;
ps->size = 0;
}
2.3.3 打印
//打印顺序表:
void SLprint(SL* ps)
{
int i = 0;
for (i = 0; i < ps->size; i++)
{
printf("%d ", ps->a[i]);
}
printf("\n");
}
2.3.4 扩容

实际上:
我们的size是大小,始终的占的最后一个下标的另一个。
所以要想尾插是很简单的,但是我们又发现了:
如果size == capacity时,我们就不能再插入了;
这个时候就要使用realloc进行扩容
//检查空间,扩容
void SLCheckCapacity(SL* ps)
{
assert(ps); //断言检查这个指针是否为有效指针
if (ps->size == ps->capacity)
{
SLDataType* tmp = (SLDataType*)realloc(ps->a,sizeof(SLDataType) * (ps->capacity) * 2); //扩两倍
if (tmp == NULL)
{
perror("realloc");
return;
}
ps->a = tmp; //赋值
ps->capacity *= 2; //只有超出了才扩,保持capacity一致
}
}
2.3.5 尾插和尾删
尾插
//末尾增加
void SLPushBack(SL* ps, SLDataType x)
{
assert(ps);
SLCheckCapacity(ps); //检查容量
ps->a[ps->size++] = x;
}
尾删
//末尾删除
void SLPopBack(SL* ps)
{
//暴力检查:
assert(ps->size > 0); //这个顺序表不能为空
//注意不要赋值为0,要直接删除最后一个数
ps->size--;
}
2.3.6 头插和头删
头插和头删是比较麻烦的,它需要挪动整个数组的数据一个位置。
顺序表是不适合头插和头删的
头插
void SLPushFront(SL* ps, SLDataType x)
{
assert(ps);
SLCheckCapacity(ps); //检查容量
int end = ps->size - 1; //选择从最后一个开始挪动,数据会覆盖
while (end >= 0)
{
ps->a[end + 1] = ps->a[end]; //把前面的值赋给后面
end--;
}
ps->a[0] = x;
ps->size++;
}
头删
void SLPopFront(SL* ps)
{
assert(ps);
assert(ps->size > 0);
int begin = 1; //从第一个开始,往前覆盖
while (begin < ps->size)
{
ps->a[begin - 1] = ps->a[begin];
begin++;
}
ps->size--;
SLErase(ps, 0);
}
2.3.7 某个位置插入和删除
插入
// 中间插入
void SLInsert(SL* ps,int pos, SLDataType x) //提供一个pos位置
{
assert(ps);
assert(0 <= pos && pos <= ps->size); //pos是否合法
SLCheckCapacity(ps); //检查容量
int end = ps->size - 1;
while (end >= pos)
{
ps->a[end + 1] = ps->a[end];
end--;
}
ps->a[pos] = x;
ps->size++;
}
删除
//中间删除
void SLErase(SL* ps, int pos)
{
assert(ps);
assert(0 <= pos && pos < ps->size);
int begin = pos + 1;
while (begin < ps->size)
{
ps->a[begin - 1] = ps->a[begin];
begin++;
}
ps->size--;
}
实际上得到某个位置的插入和删除,我们可以实现函数的复用
就不需要额外地写尾插、尾删、头插、头删了
//末尾增加
void SLPushBack(SL* ps, SLDataType x)
{
SLInsert(ps, ps->size, x);
}
//末尾删除
void SLPopBack(SL* ps)
{
assert(ps->size > 0);
ps->size--;
}
//头插
void SLPushFront(SL* ps, SLDataType x)
{
SLInsert(ps, 0, x);
}
//头删
void SLPopFront(SL* ps)
{
SLErase(ps, 0);
}
2.3.8 查找
查找只需要遍历这个动态数组就好了
//查找元素
int SLFind(SL* ps, SLDataType x)
{
assert(ps);
int i = 0;
for (i = 0; i < ps->size; i++)
{
if (ps->a[i] == x)
{
return i;
}
}
return -1;
}
2.4 测试
我们采用的多文件形式,函数的定义与声明分离,记住包含本地头文件
#include"Seqlist.h"
void SLtest3()
{
SL s;
SLInit(&s);
SLPushBack(&s, 1);
SLPushFront(&s, 2);
SLPushBack(&s, 3);
SLPushBack(&s, 4);
SLPushBack(&s, 5);
SLprint(&s);
SLErase(&s, 0);
SLErase(&s, 3);
SLprint(&s);
SLDestroy(&s);
}
int main()
{
SLtest3();
return 0;
}
2.5 小总结
问题思考:
-
中间/头部的插入删除,时间复杂度为O(N)
-
增容需要申请新空间,拷贝数据,释放旧空间。会有不小的消耗。
-
增容一般是呈2倍的增长,势必会有一定的空间浪费。
例如当前容量为100,满了以后增容到200,我们再继续插入了5个数据,后面没有数据插入了,那么就浪费了95个数据空间。
顺序表优点:
-
随机访问便捷。能通过位置,直接访问数组中的元素,时间复杂度为O(1);
-
存储密度大。 是在一片连续的空间存储
顺序表的缺点:
-
插入和删除元素复杂。在顺序表中插入和删除元素都需要挪动数据位置,时间复杂度为O(N)
-
静态大小固定、动态空间浪费。
3. 链表
3.1 链表的概念及分类
概念:
链表是一种物理存储结构上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的 。
例如:
像一根锁链类似的结构(下面是逻辑结构)

逻辑结构和物理结构的分析:

链表是由结构体来实现结点的包装的,每个结构体有两个值:
一个是该结点存储的值,一个是指向下一个结点的指针;
-
当这个链表为空,那么我们的头指针就指向NULL;
-
当这个链表不为空,那么我们的尾巴节点指向的下一个节点是NULL
分类:
-
单向 和 双向
-
带头和不带头
-
循坏和不循环
根据以上3种类型任意组合,可以有8种组合(222)
但是这里我们就介绍最简单的一种:
单向不带头不循环的
因为:
这个结构是很多结构的子结构,需要熟悉掌握
3.2 链表的模拟实现
我们仍然采用多文件,写接口的形式
#include<stdio.h>
#include<stdlib.h>
#include<assert.h>
typedef int SLTDateType;
typedef struct SLlistNode
{
SLTDateType data;
//不能的写法:
//SLlistNode* next;
//SLTNode* next;
struct SLlistNode* next;
}SLTNode;
void SLTPrint(SLTNode* phead); //打印
SLTNode* BuySLTNode(SLTDateType x);//创造节点
void SLTPushBakc(SLTNode** pphead, SLTDateType x);//尾插
void SLTPushFront(SLTNode** pphead, SLTDateType x);//头插
void SLTPopBack(SLTNode** pphead);//尾删
void SLTPopFront(SLTNode** pphead);//头删
SLTNode* SLTFind(SLTNode* phead, SLTDateType x);//找节点
void SLTInsertPreve(SLTNode** pphead, SLTNode* pos, SLTDateType x);
//中间前面插入
void SLTInsertAfter(SLTNode* pos, SLTDateType x);//中间后面插入
void SLTErase(SLTNode** pphead, SLTNode* pos); //中间删除
void SLTEraseAfter(SLTNode* pos);//中间后面删除
void SLTDestroy(SLTNode* phead);//销毁
有些地方使用到了二级指针,我们稍后做讲解
3.2.1 打印和创造结点
打印这个这个链表,需要一个指针跟这个这个链表的指针走,直到遇到NULL
//打印数据
void SLTPrint(SLTNode* phead)
{
// 不能断言 -- 因为我们也可能打印空链表
SLTNode* cur = phead; //最好不要用头指针往前走
while (cur != NULL)
{
printf("%d->", cur->data);
cur = cur->next; //不能保证数据是连续存放的;
}
printf("NULL\n"); //结尾
}
我们要打印了过后再让cur这个指针移向下一个位置
由于我们需要频繁创造节点,我们就将创造节点包装成一个函数:
//开辟空间
SLTNode* BuySLTNode(SLTDateType x)
{
//返回开辟空间的地址 + 初始化
SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));
if (newnode == NULL)
{
perror("malloc");
}
newnode->data = x;
newnode->next = NULL; //初始化一些
return newnode; //将这个节点的指针返回
}
因为我们是在堆区上开辟的空间,所以返回这个堆区上的值,是不会被销毁的
3.2.2 尾插和尾删

我们再思考一个问题:
当我们这个链表为空,想要尾插/这个链表只剩一个数据,想要尾删呢?

所以这两种情况都是要对phead进行改变
进而我们要思考一个问题:
在函数体内,形参改变会影响实参吗? – 肯定是不会的
所以在传参的时候,我们要传phead的指针,也就是传二级指针
总的来说:
-
如果你想在函数内改变int类型,就传int*类型的指针
-
如果你在函数内改变int*类型,就传int**类型的指针
-
……
这里也是一样的道理,我们需要改变SLTNode*类型,就需要传SLTNode**类型的指针
所以:
尾插
//尾插数据:
void SLTPushBakc(SLTNode** pphead, SLTDateType x)
//传的一定是头指针的地址,为了
//1.改变原指针的值 2.为了找尾巴 而且这个值只用第一次
{
//开空间,初始化
assert(pphead);
SLTNode* newcode = BuySLTNode(x);
if (*pphead == NULL) //还需要额外判断
{
*pphead = newcode;
}
else
{
//找尾巴
SLTNode* tail = *pphead;
while (tail->next != NULL)
{
tail = tail->next;
}
tail->next = newcode;
}
//特殊情况:链表为空 -- 野指针的问题
//找尾巴 -- 尾插需要尾巴指向最后一个结点,将先结点的地址给尾巴结点的 next 所以注意循环结束条件
//改变结构体要用结构体的指针 *phead 就是结构体的指针,就可以改变结构体
}
尾删
void SLTPopBack(SLTNode** pphead)
{
//两种写法:
// 1. 找尾巴
// 2. 找倒数第二个
// 检查一下是否为空
assert(pphead);
assert(*pphead);
if ((*pphead)->next == NULL)
{
free(*pphead);
*pphead = NULL;
}
else
{
//方法2:
SLTNode* prevtail = *pphead;
while ((prevtail->next)->next != NULL)
{
prevtail = prevtail->next;
}
free(prevtail->next);
prevtail->next = NULL;
//方法1:
//SLTNode* tail = (*pphead)->next;
//SLTNode* prev = *pphead;
//while (tail->next != NULL)
//{
// prev = tail;
// tail = tail->next;
//}
//free(tail);
//prev->next = NULL;
}
}
3.2.3 头插和头删
和尾插是类似的,这里就不多赘诉了
//头插数据:
void SLTPushFront(SLTNode** pphead, SLTDateType x)
{
//又要开空间所以搞一个函数
//画图三步搞定,并且不需要讨论链表为空
assert(pphead);
SLTNode* newcode = BuySLTNode(x);
newcode->next = *pphead;
*pphead = newcode;
}
//头删数据:不需要处理单独的一个结点
void SLTPopFront(SLTNode** pphead)
{
assert(pphead);
assert(*pphead);
SLTNode* first = *pphead;
*pphead = (*pphead)->next;
free(first);
first = NULL;
}
3.2.4 对于pos位置操作
一般来说都是传的这个位置的指针
- pos位置之前插入
// pos前插入
void SLTInsertPreve(SLTNode** pphead, SLTNode* pos, SLTDateType x)
{
//不能尾插和空插
assert(pphead);
assert(pos);
assert(*pphead);
if (pos == *pphead)//头插
{
SLTPushFront(pphead, x);
}
else
{
SLTNode* prev = *pphead;
while (prev->next != pos)
{
prev = prev->next;
}
SLTNode* newnode = BuySLTNode(x);
newnode->next = pos;
prev->next = newnode;
}
}
- pos位置之后插入
正常的插入一般都是在一个位置之后插入的
// pos之后插入
void SLTInsertAfter(SLTNode* pos, SLTDateType x)
{
//之后插入可以实现之前插入的逻辑,只需要交换两个的值就可以了
//不能实现空链表的插入
assert(pos);
SLTNode* newnode = BuySLTNode(x);
newnode->next = pos->next;
pos->next = newnode;
}
- pos位置删除
// pos位置删除:
void SLTErase(SLTNode** pphead,SLTNode* pos)
{
assert(pphead);
assert(pos);
assert(*pphead);
SLTNode* prev = *pphead;
while (prev->next != pos)
{
prev = prev->next;
}
prev->next = pos->next;
free(pos);
//pos = NULL; 进行没作用,让使用该函数的人置为空
}
- pos位置之后删除
// pos位置后面删除
void SLTEraseAfter(SLTNode* pos)
{
assert(pos);
//两种逻辑, 一种记录删除,一种赋值删除
SLTNode* del = pos->next;
pos->next = del->next;
free(del);
del = NULL;
}
3.2.5 查找数据
这个是配合这中间位置删除、插入的
// 查找数据:
SLTNode* SLTFind(SLTNode* phead, SLTDateType x)
{
SLTNode* cur = phead;
while (cur != NULL)
{
if (cur->data == x)
{
return cur;
}
cur = cur->next;
}
return NULL;
}
3.2.6 销毁
//销毁链表:
void SLTDestroy(SLTNode* phead)
{
SLTNode* cur = phead;
while (cur != NULL)
{
SLTNode* tmp = cur->next;
free(cur);
cur = tmp;
}
}
对于头指针,需要使用者来置为空
3.3 测试
#include"SLnklist.h"
void SLTtest1()
{
SLTNode* plist = NULL;
SLTPushBakc(&plist, 1);
SLTPushBakc(&plist, 2);
SLTPushBakc(&plist, 3);
SLTPushBakc(&plist, 4);
SLTPushBakc(&plist, 5);
SLTPrint(plist);
SLTPopBack(&plist);
SLTPrint(plist);
SLTPopBack(&plist);
SLTPrint(plist);
SLTPopBack(&plist);
SLTPrint(plist);
SLTPopBack(&plist);
SLTPrint(plist);
SLTPopBack(&plist);
SLTPrint(plist);
}
void SLTtest2()
{
SLTNode* plist = NULL;
SLTPushFront(&plist, 5);
SLTPushFront(&plist, 4);
SLTPushFront(&plist, 3);
SLTPushFront(&plist, 2);
SLTPushFront(&plist, 1);
SLTPrint(plist);
SLTPopFront(&plist);
SLTPrint(plist);
SLTPopFront(&plist);
SLTPrint(plist);
SLTPopFront(&plist);
SLTPrint(plist);
SLTPopFront(&plist);
SLTPrint(plist);
SLTPopFront(&plist);
SLTPrint(plist);
}
void SLTtest3()
{
SLTNode* plist = NULL;
SLTPushFront(&plist, 5);
SLTPushFront(&plist, 4);
SLTPushFront(&plist, 3);
SLTPushFront(&plist, 2);
SLTPushFront(&plist, 1);
SLTPrint(plist);
//找到元素值为3的元素然后修改为7
SLTNode* ret = SLTFind(plist, 3);
ret->data = 7;
SLTPrint(plist);
}
void SLTtest4()
{
SLTNode* plist = NULL;
SLTPushFront(&plist, 5);
SLTPushFront(&plist, 4);
SLTPrint(plist);
SLTNode* ret1 = SLTFind(plist, 4);
SLTNode* ret2 = SLTFind(plist, 5);
SLTInsertPreve(&plist, ret1, 3);
SLTInsertAfter(ret1, 8);
SLTInsertAfter(ret2, 8);
SLTInsertPreve(&plist, ret2, 3);
SLTPrint(plist);
SLTNode* ret3 = SLTFind(plist, 5);
SLTNode* ret4 = SLTFind(plist, 8);
SLTErase(&plist, ret3);
ret3 = NULL;
SLTPrint(plist);
SLTEraseAfter(ret4);
SLTPrint(plist);
SLTDestroy(plist);
plist = NULL;
}
int main()
{
SLTtest4();
return 0;
}
3.4 小总结
链表:
缺点:
- 以节点为单位存储,不支持随机访问。访问效率低。
优点:
-
任意位置插入删除时间复杂度为O(1)。
-
没有增容消耗,按需申请节点空间,不用了直接释放。
所以我们发现:
其实链表和顺序表是两种优势互补的数据结构
4. 栈
注意区分:
这个栈是一种数据结构,不是我们的进程空间的栈区
4.1 概念
栈:
一种特殊的线性表,其只允许在固定的一端进行插入和删除元素操作。0进行数据插入和删除操作的一端称为栈顶,另一端称为栈底。栈中的数据元素遵守后进先出LIFO(Last In First Out)的原则。
压栈:栈的插入操作叫做进栈/压栈/入栈,入数据在栈顶。
出栈:栈的删除操作叫做出栈。出数据也在栈顶。


4.2 栈的模拟实现
一般来说:栈的模拟实现是可以通过链表和数组来实现
由于数组的压栈的代价小一点,所以我们一般用数组
#include<stdio.h>
#include<assert.h>
#include<stdlib.h>
#include<stdbool.h>
#define INIT_CAPACITY 4
typedef int StackDataType;
typedef struct Stack
{
StackDataType* a;
int top;
int capacity;
}ST;
void InitStack(ST* p); //初始化
void DestroyStack(ST* p); //销毁
void PushStack(ST* p, StackDataType x);//压栈
bool StackEmpty(ST* p);//判空
void PopStack(ST* p);//出栈
int StackSize(ST* p);//栈大小
StackDataType StTop(ST* p);//栈顶数据
4.2.1 初始化和销毁
因为我们采用的是动态数组,所以还是需要初始化和销毁空间的
// 1. 初始化
void InitStack(ST* p)
{
assert(p);
StackDataType* tmp = (StackDataType*)malloc(sizeof(StackDataType) * INIT_CAPACITY);
if (tmp == NULL)
{
perror("malloc");
return;
}
p->a = tmp;
p->capacity = INIT_CAPACITY;
p->top = 0; //这里我们直接将top定为我们的数据大小
//给成0或-1都可以,后面逻辑不一样而已
}
//2. 销毁
void DestroyStack(ST* p)
{
assert(p);
free(p->a);
p->a = NULL;
p->top = 0;
p->capacity = 0;
}
4.2.2 压栈和出栈
//3.压数据
void PushStack(ST* p, StackDataType x)
{
assert(p);
//检查容量
if (p->top == p->capacity)
{
StackDataType* tmp = (StackDataType*)realloc(p->a,sizeof(StackDataType) * (p->capacity) * 2);
if (tmp == NULL)
{
perror("realloc");
return;
}
p->a = tmp;
p->capacity *= 2;
}
// 压数据
p->a[p->top] = x;
p->top++;
}
//4.销数据
void PopStack(ST* p)
{
assert(p);
assert(!StackEmpty(p));
p->top--; //top--就可以了
}
4.2.3 出数据、判空、数据个数
// 5. 检查是否为空
bool StackEmpty(ST* p)
{
assert(p);
return p->top == 0;
}
//6.数据个数
int StackSize(ST* p)
{
assert(p);
return p->top;
}
//7.出数据
StackDataType StTop(ST* p)
{
assert(p);
assert(!StackEmpty(p));
return p->a[p->top - 1];
}
上面这些代码中,有关top的地方和我们初始化给的数据是密切相关的
4.3 测试
#include"Stack.h"
void Stacktest1()
{
ST s;
InitStack(&s);
PushStack(&s, 1);
PushStack(&s, 2);
PushStack(&s, 3);
PushStack(&s, 4);
PushStack(&s, 5);
while (!StackEmpty(&s))
{
printf("%d ", StTop(&s));
PopStack(&s);
}
DestroyStack(&s);
}
void Stacktest2()
{
ST s;
InitStack(&s);
PushStack(&s, 1);
PushStack(&s, 2);
while (!StackEmpty(&s))
{
printf("%d ", StTop(&s));
PopStack(&s);
}
PushStack(&s, 3);
PushStack(&s, 4);
while (!StackEmpty(&s))
{
printf("%d ", StTop(&s));
PopStack(&s);
}
PushStack(&s, 5);
while (!StackEmpty(&s))
{
printf("%d ", StTop(&s));
PopStack(&s);
}
DestroyStack(&s);
}
int main()
{
Stacktest2();
return 0;
}
5. 队列
队列就是和栈有点相反意味的数据结构了
5.1 概念
队列:
只允许在一端进行插入数据操作,在另一端进行删除数据操作的特殊线性表,队列具有先进先出FIFO(First In First Out)
入队列:进行插入操作的一端称为队尾
出队列:进行删除操作的一端称为队头


5.2 队列的模拟实现
实际上队列也可以通过链表和数组来实现,根据我们前面的经验来说:数组在尾端的插入删除效率高,所以我们才有链表来模拟实现队列。
我们用链表来模拟实现队列,就需要我们每次去找到尾指针,这样未免太过麻烦,所以我们可以采用再定义一个结构体,将头尾指针都包装在里面
#include<stdio.h>
#include<stdlib.h>
#include<assert.h>
#include<stdbool.h>
typedef int QDataType;
typedef struct QueueNode //队列结点
{
struct QueueNode* next;
QDataType data;
}QNode;
typedef struct Queue //队列
{
QNode* head; //头指针
QNode* tail; //尾指针
int size; //数据个数
}Que;
void InitQue(Que* p); //初始化
void DestroyQue(Que* p); //销毁
void PushQue(Que* p, QDataType x); //入队列
void PopQue(Que* p); //出队列
int QueSize(Que* p); //数据个数
bool QueEmpty(Que* p); //判空
QDataType QueFront(Que* p);//队列头
QDataType QueBack(Que* p); //队列尾 -- 有些地方会用到
5.2.1 初始化和销毁
#include"Queue.h"
// 初始化
void InitQue(Que* p) //将包装的结构体传过来
{
assert(p);
p->head = p->tail = NULL;
p->size = 0;
}
void DestroyQue(Que* p)
{
assert(p);
QNode* cur = p->head;
while (cur != NULL) //将开辟的节点挨个释放
{
QNode* del = cur;
cur = cur->next;
free(del);
}
p->head = p->tail = NULL;
p->size = 0;
}
大概关系如下:

5.2.2 入队列和出队列
类似于链表的头删和尾插了
// 入队列
void PushQue(Que* p, QDataType x)
{
assert(p);
//开辟新节点
QNode* tmp = (QNode*)malloc(sizeof(QNode));
if (tmp == NULL)
{
perror("malloc");
return;
}
QNode* newcode = tmp;
newcode->next = NULL;
newcode->data = x;
//链接
if (p->head == NULL)
{
p->head = p->tail = newcode;
}
else
{
p->tail->next = newcode;
p->tail = newcode;
}
p->size++;
}
//出队列
void PopQue(Que* p)
{
assert(p);
assert(p->head != NULL);
QNode* next = p->head->next;
free(p->head);
p->head = next;
if (p->head == NULL)
{
p->tail = NULL;
}
p->size--;
}
5.2.3 队列头和尾
把数据拿出来即可
QDataType QueFront(Que* p)
{
assert(p);
assert(!QueEmpty(p));
return p->head->data;
}
QDataType QueBack(Que* p)
{
assert(p);
assert(!QueEmpty(p));
return p->tail->data;
}
5.2.4 判空和大小
int QueSize(Que* p)
{
return p->size;
}
bool QueEmpty(Que* p)
{
assert(p);
return p->size == 0;
//return p->head == NULL && p->tail == NULL;
}
5.3 测试
#include"Queue.h"
void queue_test1()
{
Que p;
InitQue(&p);
PushQue(&p, 1);
PushQue(&p, 2);
PushQue(&p, 3);
PushQue(&p, 4);
PushQue(&p, 5);
printf("%d\n", QueSize(&p));
while (!QueEmpty(&p))
{
printf("%d ", QueFront(&p));
PopQue(&p);
}
printf("\n");
printf("%d\n", QueSize(&p));
PushQue(&p, 2);
PushQue(&p, 2);
PopQue(&p);
PushQue(&p, 2);
PushQue(&p, 2);
PushQue(&p, 2);
printf("%d\n", QueSize(&p));
while (!QueEmpty(&p))
{
printf("%d ", QueFront(&p));
PopQue(&p);
}
printf("\n");
printf("%d\n", QueSize(&p));
PushQue(&p, 3);
PushQue(&p, 4);
printf("%d\n", QueSize(&p));
while (!QueEmpty(&p))
{
printf("%d ", QueFront(&p));
PopQue(&p);
}
printf("\n");
printf("%d\n", QueSize(&p));
DestroyQue(&p);
}
int main()
{
queue_test1();
return 0;
}
5.4 小总结
对于栈和队列的结构要特别熟悉
在一些OJ题中都会体现栈和队列的结构思想,一定要特别熟悉
6. 二叉树
6.1 树
在介绍二叉树之前,我们需要先了解一些关于树的知识
树是一种非线性的数据结构,它是由n(n>=0)个有限结点组成一个具有层次关系的集合。把它叫做树是因为它看起来像一棵倒挂的树,也就是说它是根朝上,而叶朝下的。
有一个特殊的结点,称为根结点,根节点没有前驱结点
除根节点外,其余结点被分成M(M>0)个互不相交的集合T1、T2、……、Tm,其中每一个集合Ti(1<= i <= m)又是一棵结构与树类似的子树。每棵子树的根结点有且只有一个前驱,可以有0个或多个后继
因此,树是递归定义的。
给图:

但是,注意:
树形结构中,子树之间不能有交集,否则就不是树形结构
相关概念:(!是比较重要的)
-
节点的度:一个节点含有的子树的个数称为该节点的度;
-
!叶节点或终端节点:d度为0的节点称为叶节点;
-
非终端节点或分支节点:度不为0的节点;
-
!双亲节点或父节点: 若一个节点含有子节点,则这个节点称为其子节点的父点;
-
!孩子节点或子节点:一个节点含有的子树的根节点称为该节点的子节点;
-
兄弟节点(亲兄弟):具有相同父节点的节点互称为兄弟节点;
-
树的度:一棵树中,最大的节点的度称为树的度;
-
节点的层次:从根开始定义起,根为第1层,根的子节点为第2层,以此类推;
-
!树的高度或深度:树中节点的最大层次;
-
堂兄弟节点:双亲在同一层的节点互为堂兄弟;
-
!节点的祖先:从根到该节点所经分支上的所有节点;
-
!子孙:以某节点为根的子树中任一节点都称为该节点的子孙。
-
森林:由m(m > 0)棵互不相交的树的集合称为森林
树的表示
对于一颗树来说,只有它的根节点,但是我们并不知道,它的孩子有多少个,如果仅仅将结构体包装成父亲孩子,那我们就跟本不知道它有多少个孩子
这个时候,有人提出来一个很厉害的结构:孩子兄弟表示法
typedef int DataType;
struct Node
{
struct Node* _firstChild1; // 第一个孩子结点
struct Node* _pNextBrother; // 指向其下一个兄弟结点
DataType _data; // 结点中的数据域
};

上面那个结构就很简单地转化为了可访问的结构了
实际上:
我们的文件目录就类似于一根树的形式,大家可以仔细思考一下
一级文件下的目录是不是就是它的孩子?
6.2 二叉树
概念:
一棵二叉树是结点的一个有限集合,该集合:
-
或者为空
-
由一个根节点加上两棵别称为左子树和右子树的二叉树组成
例如:

我们可以看到:
-
二叉树不存在度大于2的结点
-
二叉树的子树有左右之分,次序不能颠倒,因此二叉树是有序树
注意:对于任意的二叉树都是由以下几种情况复合而成的:
-
空树
-
只有根节点
-
只有左子树/只有右子树/左右子树都有。

特殊的二叉树:
- 满二叉树(深度为 h 的总个数为 2h - 1):
一个二叉树,如果每一个层的结点数都达到最大值,则这个二叉树就是满二叉树。
也就是说,如果一个二叉树的层数为K,且结点总数是2k - 1 ,则它就是满二叉树。 - 完全二叉树:
完全二叉树是效率很高的数据结构,完全二叉树是由满二叉树而引出来的。
对于深度为K的,有n个结点的二叉树,当且仅当其每一个结点都与深度为K的满二叉树中编号从1至n的结点一一对应时称之为完全二叉树。
要注意的是满二叉树是一种特殊的完全二叉树。
!!前n-1都是满的,最后一层要求从左到右是连续的。(最后一层最多是满,最少是 1 个)
高度为h的完全二叉树,节点数量的范围: [ 2(h-1) ,2h-1]

二叉树的性质
-
若规定根节点的层数为1,则一棵非空二叉树的第i层上最多有2i-1个结点.
-
若规定根节点的层数为1,则深度为h的二叉树的最大结点数是2h-1.
-
对任何一棵二叉树, 如果度为0其叶结点个数为 n1 , 度为2的分支结点个数为 n2 , 则有 n0= n2 +1(!!节点个数 度为0的永远比度为2的多一个)
-
若规定根节点的层数为1,具有n个结点的满二叉树的深度:h =log2(n+1)
-
对于具有n个结点的完全二叉树,如果按照从上至下从左至右的数组顺序对所有节点从0开始编号,则对
于序号为i的结点有:-
若i > 0,i位置节点的双亲序号:(i - 1) / 2;i = 0,i为根节点编号,无双亲节点
-
若2i + 1 < n,左孩子序号:2i + 1,如果,2i + 1 >= n,则无左孩子
-
若2i + 2 < n,右孩子序号:2i + 2,如果,2i + 2 >= n,则无右孩子
-
-
度与节点个数之间的关系:n - 1 = n0 * 0 + n1 * 1 + n2 * 2
其它树也是类似的关系
二叉树的存储结构
- 顺序结构:
顺序结构存储就是使用数组来存储,一般使用数组只适合表示完全二叉树,因为不是完全二叉树会有空间的浪费。而现实中使用中只有堆才会使用数组来存储。二叉树顺序存储在物理上是一个数组,在逻辑上是一颗二叉树。

- 链式结构
二叉树的链式存储结构是指,用链表来表示一棵二叉树,即用链来指示元素的逻辑关系。 通常的方法是链表中每个结点由三个域组成,数据域和左右指针域,左右指针分别用来给出该结点左孩子和右孩子所在的链结点的存储地址 。

6.3 链式结构的遍历
我们想要遍历一颗二叉树,有几种方法:
-
前序遍历
-
中序遍历
-
后序遍历
-
层序遍历
自己先构造一颗二叉树:
#include<stdio.h>
#include<assert.h>
#include<stdlib.h>
typedef int BTDataType;
typedef struct BinaryTreeNode
{
BTDataType data;
struct BinaryTreeNode* left;
struct BinaryTreeNode* right;
}BTNode;
BTNode* BuyBTNode1(BTDataType x)
{
BTNode* tmp = (BTNode*)malloc(sizeof(BTNode));
if (tmp == NULL)
{
perror("malloc");
return NULL;
}
tmp->left = tmp->right = NULL;
tmp->data = x;
return tmp;
}
BTNode* CreatTree1()
{
BTNode* n1 = BuyBTNode1(1);
BTNode* n2 = BuyBTNode1(2);
BTNode* n3 = BuyBTNode1(3);
BTNode* n4 = BuyBTNode1(4);
BTNode* n5 = BuyBTNode1(5);
BTNode* n6 = BuyBTNode1(6);
n1->left = n2;
n1->right = n4;
n2->left = n3;
n4->right = n6;
n4->left = n5;
return n1;
}
6.3.1 深度优先遍历
首先我们来了解前面三种遍历方式:
-
前序遍历:根节点->左子树->右子树
-
中序遍历:左子树->根节点->右子树
-
后序遍历:左子树->右子树->根节点
我们都知道二叉树是递归定义的
对于这三种遍历方式我们采用递归
//前序遍历 -- 先根、左子树、右子树
void PreOrder(BTNode* proot)
{
if (proot == NULL)
{
printf("NULL ");
return;
}
//递归查找
printf("%d ", proot->data);//根
PreOrder(proot->left);//左子树
PreOrder(proot->right);//右子树
}
//中序遍历
void InOrder(BTNode* proot)
{
if (proot == NULL)
{
printf("NULL ");
return;
}
//递归查找
InOrder(proot->left);//左子树
printf("%d ", proot->data);//根
InOrder(proot->right);//右子树
}
//后序遍历
void PostOrder(BTNode* proot)
{
if (proot == NULL)
{
printf("NULL ");
return;
}
//递归查找
PostOrder(proot->left);//左子树
PostOrder(proot->right);//右子树
printf("%d ", proot->data);//根
}
我们来看打印结果:
我们将NULL也打印出来,方便观察遍历结果

这里是递归展开图(前序遍历)

6.3.2 广度优先遍历
层序遍历:就是一层一层地往下遍历
思路:采用队列,根出队列,左右孩子入队列,为空的孩子不压入,直到队列为空即可。
void LevelOrder(BTNode* proot)
{
Que q;
InitQue(&q);
if (proot != NULL)
{
PushQue(&q, proot);
}
while (!QueEmpty(&q))
{
BTNode* front = QueFront(&q); //保存队列头
PopQue(&q); //释放的是队列头
printf("%d ", front->data);
//左右孩子不为空的压入
if (front->left != NULL)
{
PushQue(&q, front->left);
}
if (front->right != NULL)
{
PushQue(&q, front->right);
}
}
DestroyQue(&q);
}
结果就是:1 2 4 3 5 6
7. 堆
堆总是一颗完全二叉树 -- 是一种顺序存储的结构
7.1 概念
如果有一个关键码的集合K = { , , ,…, },把它的所有元素按 “完全二叉树” 的顺序存储方式存储在一个一维数组中,
并满足: ki <= k(2*i+1) 且 ki <=k(2*i+2)( ki>=k(2*i+1) 且 ki >=k(i*2+2)) i = 0,1,2…,则称为小堆(或大堆)。
将根节点最大的堆叫做最大堆或大根堆,根节点最小的堆叫做最小堆或小根堆。
分为两类:
-
小根堆:树中所有的父亲小于或者等于孩子
-
大根堆:树中所有的父亲大于或者等于孩子
堆的性质:
-
堆中某个节点的值总是不大于或不小于其父节点的值;
-
堆总是一棵完全二叉树。

7.2 堆的模拟实现
这里要用到两种算法: 向下调整和向上调整
#include<stdio.h>
#include<stdbool.h>
#include<stdlib.h>
#include<assert.h>
//大堆实现
#define INIT_CAPACITY 2
typedef int HPDataType;
typedef struct Heap
{
HPDataType* a;
int size;
int capacity;
}Hp;
void HeapInit(Hp* p); //初始化
void HeapDestroy(Hp* p);//销毁
void HeapPush(Hp* p,, HPDataType x);//压数据
void HeapPop(Hp* p);//销数据
void Swap(HPDataType* x, HPDataType* y);//交换
void AdjustUp(HPDataType* p, int children);//向上调整
void AdjustDown(HPDataType* p, int size,int parents);//向下调整
HPDataType HearpTop(Hp* p);//堆顶
bool HeapEmpty(Hp* p);//堆判空
int HeapSize(Hp* p);//堆数据个数
7.2.1 初始化和销毁
// 1.初始化
void HeapInit(Hp* p)
{
assert(p);
//开辟空间:
HPDataType* tmp = (HPDataType*)malloc(sizeof(HPDataType) * INIT_CAPACITY);
if (tmp == NULL)
{
perror("malloc");
return;
}
p->a = tmp;
p->capacity = INIT_CAPACITY;
p->size = 0;
}
// 2. 销毁堆
void HeapDestroy(Hp* p)
{
assert(p);
free(p->a);
p->a = NULL;
p->capacity = 0;
p->size = 0;
}
7.2.2 压数据、销毁和调整算法
我们之前提到了:
堆的结构是有要求的,所以当我们将数据压入堆中后,结构已经被打乱了,所以我们需要向上调整这个数据的位置,从而满足堆的要求 --下面图片是小堆(我们建大堆):

同时销毁数据也是有要求的,对于堆我们通常销毁堆顶的数据,方法是这样的:
首先将堆顶数据和最好一个数据交换,然后销毁最后一个数据,在通过向下调整的算法,将刚刚的数据放到合适的位置 – 下面图片是小堆(我们建大堆)

// 4. 压数据
void HeapPush(Hp* p, HPDataType x)
{
assert(p);
//在压数据之前要检查是否需要扩容:
if (p->size == p->capacity)
{
//扩容:
HPDataType* tmp = (HPDataType*)realloc(p->a,sizeof(HPDataType) * p->capacity * 2);
if (tmp == NULL)
{
perror("realloc");
return;
}
p->a = tmp;
p->capacity *= 2;
}
//数据入堆
p->a[p->size] = x;
p->size++;
//调整堆(给数组地址和最后一个数据的下标):
AdjustUp(p->a, p->size - 1);
}
// 5. 向上调整
// 这个要求:除了最后(children)一个位置,其余位置是堆
void AdjustUp(HPDataType* pa, int children)
{
assert(pa);
int parent = (children - 1) / 2;
while (children > 0)//条件只需要大于0即可
{
if (pa[parent] < pa[children])
{
Swap(&pa[parent], &pa[children]);
//记得迭代 -- 将父亲位置给孩子
children = parent;
parent = (children - 1) / 2;
}
else
{
break;
}
}
}
// 6. 数据交换
void Swap(HPDataType* x, HPDataType* y)
{
HPDataType tmp = *x;
*x = *y;
*y = tmp;
}
// 7.销毁数据
void HeapPop(Hp* p)
{
assert(p);
assert(!HeapEmpty(p));
//交换第一个元素和最后一个元素
Swap(&p->a[0], &p->a[p->size - 1]);
p->size--; //直接干掉最后一个数据
//然后进行向下调整:
//传第一个位置的下标,和元素的个数
AdjustDown(p->a, p->size, 0);
}
// 8.向下调整(除了第一个位置 -- 其余位置成堆)
//传的都是数组的地址 -- 堆排序的核心逻辑
void AdjustDown(HPDataType* pa, int size, int parent)
{
assert(pa);
//假设左边的孩子大
int children = parent * 2 + 1;
//开始向下调整
while (children < size)
{
//选出左右孩子中更大的一个
if (children + 1 < size && pa[children] < pa[children + 1])
// children + 1 不能越界
{
children += 1; //调正比较的孩子
}
if (pa[parent] < pa[children])
{
Swap(&pa[parent], &pa[children]);
//迭代
parent = children;
children = children * 2 + 1;
}
else
{
break;
}
}
}
7.2.3 堆顶元素、元素个数和判空
比较简单不多介绍
// 9.返回堆顶数据
HPDataType HearpTop(Hp* p)
{
assert(p);
assert(!HeapEmpty(p));
return p->a[0];
}
// 10.判断堆是否为空
bool HeapEmpty(Hp* p)
{
assert(p);
return p->size == 0;
}
// 11. 返回堆的元素个数
int HeapSize(Hp* p)
{
assert(p);
return p->size;
}
7.3 堆的运用
7.3.1 堆排序
堆排序的话 – 就是运用的堆的特性
实际上:
对于建堆来说,向下调整建堆的时间复杂度是小于向上建堆的。
值得注意的是:
-
排升序建大堆
-
排降序建小堆
后面排序算法将详细介绍
7.3.2 Top-K问题
TOP-K问题:即求数据结合中前K个最大的元素或者最小的元素,一般情况下数据量都比较大。
比如:专业前10名、世界500强、富豪榜、游戏中前100的活跃玩家等。
对于Top-K问题,能想到的最简单直接的方式就是排序,但是:如果数据量非常大,排序就不太可取了(可能数据都不能一下子全部加载到内存中)。
最佳的方式就是用堆来解决,基本思路如下:
-
用数据集合中前K个元素来建堆
前k个最大的元素,则建小堆
前k个最小的元素,则建大堆 -
用剩余的N-K个元素依次与堆顶元素来比较,不满足则替换堆顶元素,将剩余N-K个元素依次与堆顶元素比完之后,堆中剩余的K个元素就是所求的前K个最小或者最大的元素。
7.3.3 建堆方式的时间复杂度分析


完
以上图片部分来自网络 – 如有侵权,删

1万+

被折叠的 条评论
为什么被折叠?



