数据结构和算法
- 数据结构 + 算法 = 程序 —— 高纳德
- 数据结构不是一门研究数据计算的学科,而是研究数据与数据之间的关系的
数据结构
术语
- 数据:能够输入到计算机的描述客观事物的符号
- 数据项:描述事物的其中一项指标
- 数据元素:用于描述一个完整的事物
- 数据结构:由数据元素和元素关系构成的一个整体
- 算法:数据结构所具备的功能(解决问题的方法)
4种基本类型的数据结构(逻辑结构)
-
集合:元素之间没有任何关系
-
线性表:元素之间存在一对一关系(数组)
数组,链表,功能受限的表(栈,队列)
-
树:元素之间存在一对多关系
普通树,二叉树,完全二叉树,满二叉树,有序二叉树
-
图:元素之间存在多对多关系
邻接表,表的遍历(深度优先,广度优先),最短路径
数据结构的存储方式(物理结构)
-
顺序:在一块连续的内存里存储元素与元素之间的关系
- 优先:速度快,不易产生内存碎片。
- 缺点:对内存要求高,添加删除不方便
-
非顺序:元素随机存储在内存空间,元素中存储指向其他元素的地址
- 优点:对内存要求低,添加删除方便,查找速度慢(只能从头逐个遍历)
- 缺点:容易产生内存碎片
逻辑结构和存储结构的关系
逻辑结构 | 存储结构 |
---|---|
表 | 顺序/链式 |
树 | 顺序/链式 |
图 | 混合 |
每种逻辑结构用什么物理结构存储并没有明确规定,通常是根据难易程度,时间,空间上的要求,选择合适的物理结构存储。
数据结构的运算
- 创建
- 销毁
- 添加元素
- 删除元素
- 修改元素
- 查找元素
- 排序
- 遍历
- 访问
表
[ 顺序表 ] (存储结构)
- 设计数据结构
- 分析所具备的算法
- 实现算法
- 使用算法
[ 链式表 ](存储结构)
每个元素都独立存储在内存中的任意位置,
功能受限的表
[ 栈 ](逻辑结构)
限制为只有一个端口进出元素,就导致先进后出的特性。
-
顺序栈:
-
链式栈:
一般用于,表达式解析,内存管理(函数的调用提供支持)。
[ 队列 ](逻辑结构)
限制为有两个端口进出元素,一个进一个出,先进先出。
-
顺序队列:
-
链式队列:
作业1:现有两个序列,1是入栈序,判断2是否是栈的出栈序
bool is_pop(int* in,int* out,size_t len);
作业2:使用两个栈实现一个队列的功能
作业3:链表逆序
作业4:显示链表的倒数第k个值
作业5:删除链表中重复的数据,只保留一份
作业6:检查链表中是否有环
作业7:查找环形链表的入口
作业8:检查两个链表是否是环形链表
作业9:合并两个有序链表,结束依然保持有序
通用链表
void*
万能指针,可与任何类型的指针互换。
int* = void*
void* = int*
需要把链表的数据域换成void类型。
注意:由于存储的类型不确定,因此类型的运算规则不确定,当需要使用到关系运算,需要链表的使用者提供运算(提供一个函数供链表调用),这叫回调
标准库中的qsort
函数:
void qsort(void *base, size_t nmemb, size_t size, int (*compar)(const void *, const void *));
练习1:仿照qsort函数,封装一个bsort函数
树
树是一种元素之间存在一对多关系的数据结构,长用于表示组织结构,辅助排序,查找等。
-
术语:
- 根:树的最顶层元素,有且只有一个。
- 父结点(双亲):上一层元素。
- 子结点(孩子):下一层元素。
- 叶子结点:没有子结点的元素。
- 兄弟结点:具有同一夫结点的元素。
- 度:孩子的数量。
- 高度:树的层数。
- 密度:树的元素的个数。
- 结点:一个元素就是一个结点。
普通树
孩子的数量无限制。
普通树的顺序存储
A(0)
/ | \
B(1) C(2) D(3)
/ | \
E(4) F(5) G(6)
- 对结点存储顺序无要求
下标 数据 父结点下表
0 A -1
1 D 0
2 C 0
3 B 0
4 E 2
5 F 2
6 G 2
- 兄弟结点连续存储
下标 数据 父结点下标 第一个子结点下标
0 A -1 1
1 B 0 -1
2 C 0 4
3 D 0 -1
4 E 2 -1
5 F 2 -1
6 G 2 -1
- 兄弟结点连续存储
下标 数据 父结点下标 第一个子结点下标 最后一个子结点下标
0 A -1 1 3
1 B 0 -1 -1
2 C 0 4 6
3 D 0 -1 -1
4 E 2 -1 -1
5 F 2 -1 -1
6 G 2 -1 -1
普通树的链式存储
typedef struct Node
{
TYPE data;
struct Node* brother;
struct Node* child;
}
二叉树
最多只有两个孩子
-
普通二叉树 :对二叉树的结点没有数量和位置的要求。
-
完全二叉树 :若设二叉树的深度为h,除第h层外,其他各层的结点数都达到最大个数,第h层所有的结点都连续集中在最左边。
-
满二叉树 :除最后一层无子节点外,每一层上的所有结点都有两个子结点。
-
有序二叉树 :左子树的所有结点都比双亲小,右子树的所有结点都比双亲大。
-
平衡二叉树 :左右高度相差不超过1的有序二叉树,所有结点的左右子树。
相关术语
- 前序遍历
- 中序遍历
- 后序遍历
- 层序遍历
根据遍历顺序构建二叉树
前中:已知前序和中序构建二叉树
后中:已知后序和中序构建二叉树
层序:空位置用#表示
二叉树的顺序存储:
公式:
2^(h-1)
= 第h行的结点个数
2^(h-1)-1
= 前(h-1)行所有结点个数和
2^(h-1)+n-2
= 第h行第n个元素下标
二叉树的链式存储
作业
将一颗有序二叉树转换成一个有序双向链表
将一颗二叉树生成它的镜像树,在原树进行调整
有两颗二叉树A,B,判断B是否是A的子树
现有一棵树的前序遍历和中序遍历,重构二叉树
{1,2,4,7,3,5,6,8}{4,7,2,1,5,3,8,6}
判断一个序列是否是二叉树的后续遍历
bool is_post(int arr[],Node* root);
判断以可二叉树是否是平衡二叉树
图
元素之间存在多对多的关系(线性表的元素之间存在前驱和后继,树的元素之间存在符字关系,图的任意 元素之间都可能存在关系)
是由顶点的有穷非空集合和顶点之间边的集合组成的。
在图形数据结构中,数据被称为顶点,数据之间的关系被称为边。
在图中不允许出现没有点,但可以没有边。
G(V,E)
V
表示顶点的集合,E
表示边的集合。
各种图的定义
- 无向图:顶点与顶点之间没有方向,这种边称为无向边,边用无向序偶对表示
(v,v1)
。
V ={A,B,C,D} E={(A,B),(B,C),(C,D),(D,A),(A,C)}
在无向图中,如果任意两个顶点之间都存在边,这种图称为无向完全图
。n*(n-1)/2
- 有向图:顶点之间有方向,这种边称为有向边,也叫弧,用有序偶对表示
<v,v1>
,v1
叫做弧头,v
叫做弧尾。
<A,B>
!= <B,A>
注意:若不存在顶点到自身的边,也不存在重复出现的边,这种图叫做简单图,数据结构课程中讨论的都是简单图。
在有向图中如果任意两个顶点之间存在方向相反的两条弧,这种图叫做有向完全图
。
图中有很少边或弧的图叫做稀疏图
,反之叫稠密图
。
如果图中的边或弧有相关的数据,数据称为权,这种图也叫网
,(带权图)。
如果G(v,E)
和G1(v1,E1)
存在V>=V1
且E>=E1
,那么G1是G的子图
顶点与图的关系
-
顶点的度:指的是顶点相关联的边或弧的条目数。有向图又分为出度和入度
- 入度:其他顶点到该顶点的弧的数量
- 出度:从该出发点到其他顶点的弧的条目数
-
顶点序列:从一个顶点到另一个顶点的路径,路径长度是指路径上的边或者弧的条目数。
连通图的相关术语
- 连通图:
在无向图中,在顶点v
到v1
之间有路径,则称v
到v1
之间是连通的,如果任意两个顶点都是连通的,那么这种图称为连通图。
- 连通分量:
无向图中的极大连通子图称为连通分量:
- 必须是子图
- 子图必须是连通的
- 连通子图含有极大的顶点数
- 强连通图:
在有向图中,任意顶点之间都存在路径,则称其为强连通图。
- 强连通分量:
有向图中的极大连通子图称为有向的强连通分量。
- 有向树:
在有向图图中如果有一个顶的入度为0,其他顶点的入度均为1,则是一颗有向树。
图的存储结构
图的存储主要是两个方面:顶点,边
邻接矩阵
一个一维数组(顶点)和一个二维数组(边,弧)
A--B-C
| /|
| E |
| / /
D---
A | B | C | D | E | |
---|---|---|---|---|---|
A | 0 | 1 | 1 | ||
B | 1 | 0 | 1 | ||
C | 1 | 0 | 1 | 1 | |
D | 1 | 1 | 0 | 1 | |
E | 1 | 1 | 0 |
二维数组[i][i]
都是0,如果是无向图则数组对称(左上到右下的对角线为轴)
- 优点
- 非常容易判定两顶点之间是否有边
- 非常容易计算任意顶的入度和出度
- 非常容易统计邻接点
- 缺点
- 如果存储稀疏图,非常浪费存储空间
邻接表
由顶点表(顶点 邻接点的地址/下标),边表组成
顶点表 | 边表 | ||
---|---|---|---|
顶点 | 下一个邻接点的地址 | 顶点下标 | 下一个邻接点的地址 |
A | -> | [1]-> | [3]->NULL |
B | -> | [2]->NULL | |
C | -> | [4]-> | [3]->NULL |
D | -> | ||
E | -> |
-
优点
- 节省存储空间
- 非常容易计算出度
-
缺点
- 不方便计算入度
十字链表
由于邻接表不能同时兼顾出度和入度,因此我们修改了邻接表的边表结构,使它既存储入度也存储出度。
邻接多重表
由于遍历表时需要一些删边操作,而邻接表在删除边时非常麻烦,因此就设计出邻接多重表。
边集数组
由两个以为数组构成,一个存储顶点信息,另一个存储边的信息(它的每个数据元素都由一条边的起点到终点的下标和权组成),这种存储结构更侧重于边的相关操作(路径,路径长度,最短路径),而统计顶的度需要扫描整个数组,效率不高。
图的遍历
注意:图的深度优先和广度优先都不是唯一的。
A
/|\
C | B
\|
D
箭头从上到下
深度优先
类似树的前序遍历
广度优先
类似树的层序遍历,与树一样也需要
算法
数据结构中的算法,指的是数据结构所具备的功能
解决特定的问题,的方法,他是前辈们的一些优秀的经验总结
分治
把一个大而复杂的问题,分解成很多个小儿简单的问题,利用计算机的强大计算能力来解决问题。
实现分治的方法有:循环,递归。
递归
是函数自己调用自己的一种行为,可以形成循环调用,进而实现分治算法
什么时候使用递归:
- 问题过于复杂,无法拆分成循环语句。
- 问题非线性,而函数的递归,由于借助线内存(函数的每一次调用,就会把数据重新押入到栈空间,它的数据都会保留下来。)可以解决非线性的问题(二叉树的相关算法,汉诺塔问题)。
- 在单线程问题下,自能同时执行一个函数,当函数自己调用自己时(子级),会先执行子级的代码,然后子级执行完成后再返回到上一级继续执行。
如何安全实现递归:
- 使用递归非常有可能造成死循环,非常耗费资源。
- 先写出口,考虑如何调用无限的调用停止下来。
if() return
- 解决一个小问题
- 把剩下的问题交给我的下一级(参数发生变化)
练习1:使用递归计算前n项斐波那契数列
练习2:使用递归解决汉诺塔问题n层盘子的移动过程
把
递归的优点:代码简单,容易理解
递归的缺点:容易形成死循环,耗费内存,执行效率低(参数入栈,出栈,局部变量的定义,销毁)。
查找
顺序查找
从头到尾逐一比较,对于要查找的数据每有要求,方法简单,在小规模的数据中比较常用。
int order_find(int* arr,size_t len, int key)
二分查找
int binary_find(int* arr,size_t len, int key)
前提是数据必须有序,然后从数据的中间位置找起。重复。
循环和递归都能实现
块查找,权重查找
适用于特殊条件下,需要对数据进行排序,分析,总结,归纳。
排序
排序算法的稳定性:当序列中有相等的数据时,算法会不会改变着两个数据的前后位置。
冒泡排序
是一种稳定排序,在排序过程中可以检测到数据是否已经有序(对数据的有序性非常敏感),可以立即停止,如果待排序的数据基本有序,则冒泡排序的效率是非常高的。
void bubble_sort(int* arr,size_t len)
插入排序
当一列数已经有序,再有加入的数据时,适合使用插入排序。
void insert_sort(int* arr,size_t len)
选择排序
是冒泡排序的一种变种,但是它没有冒泡排序对数据有序性的敏感,但它再排序过程中比冒泡少了很多数据交换,因此比冒泡排序快。数据较混乱时使用
void select_sort(int* arr,size_t len)
快速排序
一种基于交换,平均块
void quick_sort(int* arr,size_t len)
堆排序
首先把数组构当作完全二叉树,然后保障根节点最大,然后把根节点与最后一个元素交换,然后再调整二叉树(逐渐减小数组),让根依然保持最大,重复操作
void heap_sort(int* arr,size_t len)
归并排序
不交换数据,但需要借助额外的临时存储空间,所以速度可能比快速和堆更快
void merge_sort(int* arr, size_t len)
算法的时间复杂度
注意,时间复杂度并不指算法运行所需的时间,而是算法执行的次数。
排序算法 | 平均时间复杂度 | 最好 | 最坏 | 空间 | 稳定性 |
---|---|---|---|---|---|
冒泡 | O ( n 2 ) \ O(n^2) O(n2) | O ( n ) \ O(n) O(n) | O ( n 2 ) \ O(n^2) O(n2) | O ( 1 ) \ O(1) O(1) | 稳定 |
直接插入 | O ( n 2 ) \ O(n^2) O(n2) | O ( n ) \ O(n) O(n) | O ( n 2 ) \ O(n^2) O(n2) | O ( 1 ) \ O(1) O(1) | 稳定 |
直接选择 | O ( n 2 ) \ O(n^2) O(n2) | O ( n 2 ) \ O(n^2) O(n2) | O ( n 2 ) \ O(n^2) O(n2) | O ( 1 ) \ O(1) O(1) | 不稳定 |
快速 | O ( n l o g 2 n ) \ O(nlog_2n) O(nlog2n) | O ( n l o g 2 n ) \ O(nlog_2n) O(nlog2n) | O ( n 2 ) \ O(n^2) O(n2) | $\ $ | 不稳定 |
堆 | O ( n l o g 2 n ) \ O(nlog_2n) O(nlog2n) | O ( n l o g 2 n ) \ O(nlog_2n) O(nlog2n) | O ( n l o g 2 n ) \ O(nlog_2n) O(nlog2n) | O ( 1 ) \ O(1) O(1) | 不稳定 |
归并 | O ( n l o g 2 n ) \ O(nlog_2n) O(nlog2n) | O ( n l o g 2 n ) \ O(nlog_2n) O(nlog2n) | O ( n l o g 2 n ) \ O(nlog_2n) O(nlog2n) | $\ $ | 稳定 |
merge_sort(int* arr, size_t len)`
算法的时间复杂度
注意,时间复杂度并不指算法运行所需的时间,而是算法执行的次数。
排序算法 | 平均时间复杂度 | 最好 | 最坏 | 空间 | 稳定性 |
---|---|---|---|---|---|
冒泡 | O ( n 2 ) \ O(n^2) O(n2) | O ( n ) \ O(n) O(n) | O ( n 2 ) \ O(n^2) O(n2) | O ( 1 ) \ O(1) O(1) | 稳定 |
直接插入 | O ( n 2 ) \ O(n^2) O(n2) | O ( n ) \ O(n) O(n) | O ( n 2 ) \ O(n^2) O(n2) | O ( 1 ) \ O(1) O(1) | 稳定 |
直接选择 | O ( n 2 ) \ O(n^2) O(n2) | O ( n 2 ) \ O(n^2) O(n2) | O ( n 2 ) \ O(n^2) O(n2) | O ( 1 ) \ O(1) O(1) | 不稳定 |
快速 | O ( n l o g 2 n ) \ O(nlog_2n) O(nlog2n) | O ( n l o g 2 n ) \ O(nlog_2n) O(nlog2n) | O ( n 2 ) \ O(n^2) O(n2) | $\ $ | 不稳定 |
堆 | O ( n l o g 2 n ) \ O(nlog_2n) O(nlog2n) | O ( n l o g 2 n ) \ O(nlog_2n) O(nlog2n) | O ( n l o g 2 n ) \ O(nlog_2n) O(nlog2n) | O ( 1 ) \ O(1) O(1) | 不稳定 |
归并 | O ( n l o g 2 n ) \ O(nlog_2n) O(nlog2n) | O ( n l o g 2 n ) \ O(nlog_2n) O(nlog2n) | O ( n l o g 2 n ) \ O(nlog_2n) O(nlog2n) | $\ $ | 稳定 |