数据结构基础
数据结构分类:
- 逻辑结构 : 线性结构 和 非线性结构
- 线性结构: 数组, 链表, 队列, 栈
- 非线性结构: 多为数组, 广义表, 树结构, 图结构
- 物理结构(存储结构): 顺序存储, 链式存储, 索引存储, 散列存储(Hash存储)
常见数据结构:
- 数组(Array)
- 链表(Linked List)
- 调表(Skip List)
- 队列(Queue)
- 栈(Stack)
- 树(Tree)
- 散列表(Hash)
- 堆(Heap)
- 图(Graph)
一、线性结构
定义
- 线性表(List):零个或多个数据元素的有限序列。除了第一个元素外, 每一个元素有且只有一个直接前驱元素,除了最后一个元素外,每一个元素有且只有一个直接后继元素.
1. 顺序表 - 数组(Array)
- 顺序表: 用一组地址连续的存储单元依次存储线性表的数据元素,这种存储结构的线性表称为顺序表——数组
- 逻辑上相邻的数据元素,物理次序也是相邻的。
- 常规操作: 初始化空表; 插入; 删除; 读取某位置元素; 遍历
- 循环数组: 头尾相连的数组,
- 应用场景: go map类型; 约瑟夫问题;
2. 链表(Linked List)
-
单链表: 一个个结点组成的线性表, 每个结点的结构中, 除了要存储数据元素的信息外,还要存储它的后继元素的存储地址.
-
结点: 数据域 + 指针域
-
链表中每个结点中只包含一个指针域, 所以是单链表
-
头指针: 链表中第一个结点的存储位置;
-
头结点: 为了方便对链表进行操作,会在单链表的第一个结点前附设一个节点,称为头结点
-
常规操作: 初始化单链表; 插入; 删除; 遍历; 清空链表; 销毁单链表
-
-
静态链表: 使用数组来描述指针,首先我们让数组的元素都是由两个数据域组成,data和cur。数据域data,用来存放数据元素;游标cur相当于单链表的next指针,存放该元素的后继在数组中的下标。
- 常规操作: 插入, 删除, 遍历
-
**循环链表:将单链表中终端节点的指针端由空指针改为指向头结点,就使整个单链表形成一个环,这种头尾相接的单链表称为单循环链表,简称循环链表 **
- 注意尾结点的指针指向的可能是头结点, 并非单链表第一个元素
- 有个缺点: 可以用O(1)的时间访问第一个节点,但对于最后一个节点,却需要O(n)的时间
-
仅设置尾指针的循环链表: 终端节点用尾指针rear指示,则查找终端节点是O(1),而开始节点,其实就是rear->next->next,其时间复杂度也是O(1)。
- 这样的链表执行两个循环链表的合并操作就很简单
-
**双向链表: 在单链表的每个结点中,再设置一个指向其前驱结点的指针域 **
- 常规操作: 插入; 删除; 遍历;
总结
对比项 | 顺序表 | 链表(单链表) |
---|---|---|
读写方式 | 顺序读写, 随机读写 | 从表头顺序读写元素 |
逻辑结构/五列结构 | 逻辑上相邻的元素,对应的物理存储位置也相邻 | 不一定相邻 |
查找 | 按值查找:无序表: 时间复杂度O(n); 有序表: 二分查找时间复杂度O(log2n); 按序号查找, 支持随机访问,时间复杂度O(1) | 按值查找:无序表: 时间复杂度O(n); 按序号查找, 的平均时间复杂度为O(n) |
插入/删除 | 需要移动操作位置后的元素 | 只需修改相关结点的指针域即可 |
空间分配 | 会出现内存溢出 | 只要内存有空间, 结点空间在需要时就可以分配,操作灵活、高效 |
应用场景 | 排序 |
3. 栈(Stack)
栈的定义
栈: 只允许一段进行插入或删除的线性表. --------------- 线性表; 但限定只能在某一段进行插入和删除操作
栈顶(Top):线性表允许进行插入删除的那一端。
栈底(Bottom):固定的,不允许进行插入和删除的另一端。
空栈:不含任何元素的空表。
栈又称为后进先出(Last In First Out)的线性表,简称LIFO结构
常见操作
-
初始化空栈
-
判断栈是否为空
-
入栈Push
-
出栈Pop
-
读栈顶元素
-
栈销毁
顺序存储
-
利用一组地址连续的存储单元存放自栈底到栈顶的数据元素,同时附设一个指针(top)指示当前栈顶元素的位置。
-
共享栈: 利用栈底位置相对不变的特征,可让两个顺序栈共享一个一维数组空间,将两个栈的栈底分别设置在共享空间的两端,两个栈顶向共享空间的中间延伸,如下图所示:
两个栈的栈顶指针都指向栈顶元素,top0=-1时0号栈为空,top1=MaxSize时1号栈为空;仅当两个栈顶指针相邻(top0+1=top1)时,判断为栈满。
当0号栈进栈时top0先加1再赋值,1号栈进栈时top1先减一再赋值出栈时则刚好相反。
链式存储
-
链栈: 采用链式存储的栈.
-
优点是便于多个栈共享存储空间和提高其效率,且不存在栈满上溢的情况。通常采用单链表实现,并规定所有操作都是在单链表的表头进行的。这里规定链栈没有头节点,Lhead指向栈顶元素,如下图所示。
- 链栈的进栈push和出栈pop操作都很简单,时间复杂度均为O(1)。
顺序栈与链栈 性能对比
-
时间复杂度: 顺序栈与链栈在时间复杂度上是一样的,均为O(1)
-
空间性能
- 顺序栈需要事先确定一个固定的长度,可能会存在内存空间浪费的问题,但它的优势是存取时定位很方便
- 链栈则要求每个元素都有指针域,这同时也增加了一些内存开销,但对于栈的长度无限制
-
如果栈的使用过程中元素变化不可预料,有时很小,有时非常大,那么最好是用链栈,反之,如果它的变化在可控范围内,建议使用顺序栈会更好一些
栈的应用——递归
- 定义: 一个函数、过程或数据结构的定义中又应用了它自身,则这个函数、过程或数据结构称为是递归定义的,简称递归。
erlang尾递归: 一个函数在执行函数内指令的最后, 调用了执行函数.
对一个尾递归的函数进行编译,把语句序列里的最后一次函数调用替换成跳至被调用函数的开头。这就意味着尾递归的函数无需消耗栈空间也能一直循环下去。
-
递归模型要满足的两个条件:
- 递归表达式(递归体)
- 边界条件(递归出口)
-
斐波那契数列(应用)
栈的应用——四则运算表达式求值
表达式求值是程序设计语言编译中一个最基本的问题,它的实现是栈应用的一个典型范例
-
中缀表达式不仅依赖运算符的优先级,而且还要处理括号。
-
后缀表达式的运算符在操作数后面,在后缀表达式中已考虑了运算符的优先级,没有括号,只有操作数和运算符。后缀表达式计算规则:从左到右遍历表达式的每个数字和符号,遇到是数字就进栈,遇到是符号,就将处于栈顶两个数字出栈,进项运算,运算结果进栈,一直到最终获得结果。
例如中缀表达式A + B ∗ (C−D) − E/F
所对应的后缀表达式为A B C D − ∗ + E F / −
。
- 后缀表达式与原运算式对应的表达式树(用来表示算术表达式的二元树)的后序遍历进行比较:
A B C D − ∗ + E F / −
规则: 从左到右遍历中缀表达式的每个数字和符号, 若是数字就输出,即成为后缀表达式的一部分; 若是符号,则判断其与栈顶符号的优先级, 是右括号或优先级低于栈顶符号(乘除优先加减), 则栈顶元素依次出栈并输出,并将当前符号进栈,一直到最终输出后缀表达式为止。
将中缀表达式a + b − a ∗ ( ( c + d ) / e − f ) + g
转化为相应的后缀表达式。
相应的后缀表达式为a b + a c d + e / f − ∗ − g +
.
4. 队列(Queue)
队列的定义
队列: 只允许在一端进行插入操作,而在另一端进行删除操作的线性表
队首(Front):允许进行删除的那一端。
队尾(Rear):允许进行插入的一端。
空队列:不含任何元素的空表。
队列又称为后进先出(First In First Out)的线性表,简称FIFO结构
队列常见操作
-
初始化队列
-
判断队列是否为空
-
入队
-
出队
-
读队首元素
顺序存储结构
-
顺序队列: 会出现"假溢出"现象, 队列出现"上溢出", 然而却不是真正的溢出
-
循环队列: 解决假溢出, 就给头尾相连. 队列的头尾相接的顺序存储结构成为循环队列;
当首指针:
front = MAXSIZE -1
, 在前进一位就自动到0(数组首位), 可以用取余来实现
链式存储结构
- 链队列: 单链表
- 对头指针
- 对尾指针
- 尾进头出
- 双端队列: 允许两端都可以进行入队和出队操作的队列, 其元素的逻辑结构仍是线性结构。将队列的两端分别称为前端和后端,两端都可以入队和出队。
- 特殊的双端队列: 输出受限的双端队列; 输入受限的双端队列
- 双端队列应用: 现实生活中也有应用双端队列的例子:例如我们去电影院排队买票。一个刚刚买完票的人想回来咨询下简单信息,就可以直接回到队伍的头部。某些人正在队尾排队,如果比较赶时间,改变看电影的计划了,就可以从队尾直接离开队伍。
- 计算机科学中,双端队列常见的应用是存储一系列的撤销操作时。每当用户进行一个操作,改操作就会被存放在一个双端队列中。当用户Ctrl+Z时,该操作会被从双端队列弹出,表示它被从后面移除了。
优先队列
首先, 优先队列是一种队列. 与普通队列不同的是, 在优先队列中,元素被赋予优先级, 出队顺序是按照优先级来的. 优先队列具有最高级先出 (first in, largest
**out)**的行为特征。
在优先队列出对前,需要找到元素集合中的最小或者最大元素,可以利用优先队列ADT
来完成操作,优先队列ADT
是一种数据结构,它支持插入和删除最小值操作(返回并删除最小元素)或删除最大值操作(返回并删除最大元素), 这些操作等价于队列的enQueue
和deQueue
操作
优先队列的操作:
- 优先队列的主要操作: 优先队列是元素的容器, 每个元素都有一个相关的键值
insert(key, data)
: 插入键值为key的数据到优先队列中, 元素以其key进行排序deleteMin
/deleteMax
: 删除并返回最小/最大键值的元素;getMinimum/getMaximum
:返回最小/最大键指的元素,但不删除它;
- 优先队列的辅助操作:
- 第k最小/最大元素: 返回优先队列中键值为第k个最小/最大元素
- 大小: 优先队列中元素个数
- 堆排序: 基于键值的优先级将优先队列中的元素进行排序
**优先队列的应用: **
- 数据压缩: 赫夫曼编码算法
- 最短路径计算: Dijkstra算法
- 最小生成树算法: Prim算法
- 事件驱动仿真: 顾客排队算法
- 选择问题: 查找第k个最小元素
二、非线性结构
1. 树(Tree)
1.1 树的定义
树: n(>=0) 个结点构成的有限集合; 当n=0 时被称为空树 ;
任何一棵非空树 应满足:
- 有且只有一个根结点
- 当 n >= 1 时, 其余结点可分为m 个互不相交的有限集合 T1, T2,…,Tm, 其中每个集合本身又是一棵树,并且称为根的子树。
显然,树的定义是递归的,即在树的定义中又用到了自身,树是一种递归的数据结构。
树作为一种逻辑结构,同时也是一种分层结构,具有以下两个特点:
-
树的根结点没有前驱,除根结点外的所有结点有且只有一个前驱。
-
树中所有结点可以有零个或多个后继。
1.2 树的基本概念
-
父结点, 子节点, 兄弟结点
-
度: 结点的度(子结点数量), 树的度(树中结点的最大度),
-
分支节点(度大于0, 有子结点), 叶子结点(没有子结点)
-
结点深度(自顶向下), 高度(自底向上), 层次(从根结点开始), 树的深度和高度 是树中结点的最大层数
-
有序树(各子树从左到右有序), 无序树
-
路径(两个结点间的连线经过的结点序列) 路径长度(路径上的边的个数)
-
森林: m(m>=0)棵互不相交树的集合
1.3 树的性质
-
树中的结点数等于所有结点的度数+1
-
度为m的树中第i层上至多有 m i − 1 m^{i-1} mi−1个结点(i>=1):
-
高度为h的m叉树 至多有 ( m h − 1 ) / ( m − 1 ) (m^h-1)/(m-1) (mh−1)/(m−1)个结点: 高度为4的二叉树, 至多有15个结点
-
具有n个结点的m叉树的最小高度为 [ l o g m ( n ( m − 1 ) + 1 ) ] [log_m(n(m-1)+1)] [logm(n(m−1)+1)]
2. 树的存储结构
- 父结点表示法: 以一组连续空间存储树的结点,同时在每个结点中,附设一个指示器指示其双亲结点到链表中的位置。每个结点除了知道自已的数据,还知道它的父结点在哪里。
-type Node
-
我们可以根据结点的parent 指针很容易找到它的双亲结点,所用的时间复杂度为0(1)
-
直到parent为-1时,表示找到了树结点的根。
-
如果我们要知道结点的孩子是什么,需要遍历整个结构才行
- 子节点表示法: 把每个结点的孩子结点排列起来,以单链表作存储结构,则n个结点有n个孩子链表,如果是叶子结点则此单链表为空。然后n个头指针又组成-一个线性表,采用顺序存储结构,存放进一个一维数组中
为此,设计两种结点结构,一个是孩子链表的孩子结点。 – 其中child是数据域,用来存储某个结点在表头数组中的下标。next 是指针域,用来存储指向某结点的下一个孩子结点的指针。
另一个是表头数组的表头结点。 – data是数据域,存储某结点的数据信息。firstchild
是头指针域,存储该结点的孩子链表的头指针。
-
这样的结构对于我们要查找某个结点的某个孩子,或者找某个结点的兄弟,只需要查找这个结点的孩子单链表即可
-
遍历整棵树也是很方便的,对头结点的数组循环即可
-
如果要知道某个结点的双亲是谁, 需要整棵树遍历才行
- 子结点-兄弟结点表示法: 任意一棵树, 它的结点的第一个孩子如果存在就是唯一的,它的右兄弟如果存在也是唯一的。 因此,我们设置两个指针,分别指向该结点的第一个孩子和此结点的右兄弟。结点的结构如下:
其中data是数据域,firstchild
为指针域,存储该结点的第一个孩子结点的存储地址,rightsib
是指针域,存储该结点的右兄弟结点的存储地址。
通过这种结构,我们就把原来的树变成了这个样子:
其实这个表示法的最大好处就是它把一棵复杂的树变成了一棵二叉树,
3. 二叉树 以及 几种特殊的二叉树
二叉树: 树的度为2; 二叉树的左子树 和 右子树 又分别是一棵二叉树; 二叉树是有序树
斜树
所有结点只有左子结点的二叉树叫左斜树, 所有结点只有右子结点的二叉树叫右斜树
满二叉树
满二叉树, 树中的每层都含有最多的结点; 树中的叶子结点都在最下层; 每个非叶子结点的度都是2;
特征:
- 一棵高度为 h , 含有 2 h − 1 2^h-1 2h−1个结点的二叉树 一定是满二叉树
- 假定以层序遍历的序号进行编号, 从根节点起, 自上而下, 自左向右. 每个编号为i的有双亲的结点, 父结点的编号一定是 i / 2 i/2 i/2; 若有子结点, 左子结点编号 2 i 2i 2i , 右子节点编号 2 i + 1 2i+1 2i+1
完全二叉树
完全二叉树: 二叉树的深度为h
,除第 h 层外
,其它各层 (1~h-1) 的结点数都达到最大个数(即1~h-1层为一个满二叉树)
,第 h 层所有的结点都连续集中在最左边,这就是完全二叉树。
特征:
- 高度为h 、有n 个结点的完全二叉树, 每个结点都与高度为h 的满二叉树中编号为1~n的结点一一对应
- 若 i < = n / 2 i<=n/2 i<=n/2结点i 为分支结点,否则为叶子结点。
- 若有度为1 的结点,则只可能有一个,且该结点只有左孩子而无右孩子, 此结点的层次为 树的深度 -1
- 按层序编号后,一旦出现某结点(编号为i )为叶子结点或只有左孩子,则编号大于i 的结点均为叶子结点。 (大于6的结点全部为叶子结点)
- 若n 为奇数,则每个分支结点都有左孩子和右孩子; 若n 为偶数,则编号最大的分支结点(编号为 n / 2 n/2 n/2 )只有左孩子,没有右孩子,其余分支结点左、右孩子都有。
二叉排序树
左子树上所有结点的关键字均小于根结点的关键字;右子树上的所有结点的关键字均大于根结点的关键字;左子树和右子树又各是一棵二叉排序树。
平衡二叉树
树上任一结点的左子树和右子树的深度之差不超过1
判断平衡二叉树
- 是二叉排序树
- 任何一个结点的左子树 或者 右子树 都是平衡二叉树(左右高度小于1)
相关概念
- 平衡因子 BF
定义:左子树和右子树高度差
一般来说 BF 的绝对值大于 1,平衡树二叉树就失衡,需要「旋转」纠正
- 最小不平衡树
距离插入节点最近的,并且 BF 的绝对值大于 1 的节点为根节点的子树。
「旋转」纠正只需要纠正「最小不平衡子树」即可
两种旋转方式
- 左旋
- 旧根节点为新根节点的左子树
- 新根节点的左子树(如果存在)为旧根节点的右子树
- 右旋:
- 旧根节点为新根节点的右子树
- 新根节点的右子树(如果存在)为旧根节点的左子树
纠正最小不平衡子树
- LL型:插入左孩子的左子树,右旋
- RR型:插入右孩子的右子树,左旋
- LR型:插入左孩子的右子树,先左旋,再右旋
- RL型:插入右孩子的左子树,先右旋,再左旋
LL 型失衡「右旋」
RR 型失衡「左旋」
LR 型失衡「先左旋, 再右旋」
RL 型失衡「先右旋, 再左旋」
B树 和 B+树(B:表示Balance)
- B树
B树: 一种多路自平衡搜索树; B-树是一类树,包括B树、B+树、B*树等,是一棵自平衡的搜索树,它类似普通的平衡二叉树,不同的一点是B树允许每个节点有更多的子节点。
B树的阶: 此B树的节点(除根节点外) 最多 有多少个孩子结点(子树),一般用字母 M 表示阶数。B树子节点(非根节点) 的最小度为 M / 2 M/2 M/2, 最大的度为 M M M
一个 m 阶的B树是一个有以下属性的树:
-
任意非叶子结点最多有m个子节点
-
根结点至少有两个子节点
根节点的子节点数: [2,m]
-
每个非根节点所包含的关键字个数 j 满足: m / 2 − 1 < = j < = m − 1 m/2 - 1 <= j <= m - 1 m/2−1<=j<=m−1;
非叶子结点的关键字:K[1], K[2], …, K[M-1];且K[i] < K[i+1];
非叶子结点的指针:P[1], P[2], …, P[M];其中P[1]指向关键字小于K[1]的子树, P[M]指向关键字大于K[M-1]的子树, 其它P[i]指向关键字属于(K[i-1], K[i])的子树;
-
任意非叶子节点(非根节点)的子节点数(度数/指向子节点的指针数), 正好是关键字总数+1, 故内部子树个数 k 满足: m / 2 < = k < = m m/2 <= k <= m m/2<=k<=m ;
除根结点以外的非叶子结点的儿子数为[M/2, M];
-
所有的叶子节点都在同一层
B-树的特性
- 关键字集合分布在整棵树中
- 任何一个关键字出现且只出现在一个结点中
- 搜索有可能在非叶子结点结束???
- 其搜索特性等价于在关键字全集中做一次二分查找
- 自动层次控制
B-树的搜索
从根结点开始,对结点内的关键字(有序)序列进行二分查找,如果命中则结束,否则进入查询关键字所属范围的子结点;重复,直到所对应的子指针为空,或已经是叶子结点.
- B+树:
B+树是B-树的变体,也是一种多路搜索树, 它与 B- 树的不同之处在于:
-
非叶子结点的子树指针与关键字个数相同
-
非叶子结点的子树指针P[i],指向关键字值属于[K[i], K[i+1])的子树, (B-树是开区间)
-
所有叶子结点增加了一个链指针
-
所有关键字都在叶子结点出现
B+的搜索与B-树也基本相同,区别是B+树只有达到叶子结点才命中(B-树可以在非叶子结点命中),其性能也等价于在关键字全集做一次二分查找;
B+ 树的特性
-
所有关键字都出现在叶子结点的链表中(稠密索引),且链表中的关键字都是有序的
-
不可能在非叶子结点命中
-
非叶子结点相当于是叶子结点的索引(稀疏索引),叶子结点相当于是存储(关键字)数据的数据层
-
更适合文件索引系统
- B*树
B*树是B+树的变体,在B+树的非根和非叶子结点再增加指向兄弟的指针;
B*树 定义了非叶子结点关键字个数至少为 ( 2 / 3 ) ∗ M (2/3) *M (2/3)∗M,即块的最低使用率为2/3 (代替B+树的1/2)
- B+树 和 B*树的分裂对比
B+树的分裂: 当一个结点满时,分配一个新的结点,并将原结点中1/2的数据复制到新结点,最后在父结点中增加新结点的指针;B+树的分裂只影响原结点和父结点,而不会影响兄弟结点,所以它不需要指向兄弟的指针;
B*树的分裂:当一个结点满时,如果它的下一个兄弟结点未满,那么将一部分数据移到兄弟结点中,再在原结点插入关键字,最后修改父结点中兄弟结点的关键字(因为兄弟结点的关键字范围改变了);如果兄弟也满了,则在原结点与兄弟结点之间增加新结点,并各复制1/3的数据到新结点,最后在父结点增加新结点的指针;
总结: B*树分配新结点的概率比B+树要低,空间使用率更高;
- B树的总结(子结点数, 指针, 命中):
B-树:多路搜索树,每个结点存储[M/2-1, M-1]个关键字; 非叶子结点存储指向关键字范围的子结点; 所有关键字在整颗树中出现,且只出现一次,非叶子结点可以命中;
B+树: 所有关键字都在叶子结点中出现,非叶子结点作为叶子结点的索引;为叶子结点增加链表指针; B+树总是到叶子结点才命中;
B*树: 为非叶子结点也增加链表指针,将结点的最低利用率从1/2提高到2/3
B-树 和 B+树对比:
-
B+树有一个最大的好处,方便扫库,B树必须用中序遍历的方法按序扫库,而B+树直接从叶子结点挨个扫一遍就完了。
-
B+ 树 查询时间复杂度固定为 O ( l o g n ) O(log_n) O(logn) ; B-树查询时间复杂度不固定, 最好为 O ( 1 ) O(1) O(1)
-
B+树, 叶节点两两相连可大大增加区间访问性, 支持range-query(区间查询)非常方便,而B树不支持。这是数据库选用B+树的最主要原因。
-
B+树在内部节点上不包含数据信息,因此在内存页中能够存放更多的key, 数据存放的更加紧密,具有更好的空间利用局部性.
那么由于磁盘 IO 数据大小是固定的,在一次 IO 中,单个元素越小,量就越大。这就意味着B+树单次磁盘 IO 的信息量大于B-树
B*树分配新结点的概率比B+树要低,空间使用率更高;
红黑树
要研究红黑树, 一定要搞懂2-3结点, 2-3-4结点的插入删除操作
红黑树的定义:
-
节点颜色有红色和黑色
-
根节点必定为黑色
-
所有叶子节点都是黑色
-
任意节点到叶子节点经过的黑色节点数目相同
红黑树中的共节点是和黑色父结点绑定的, 在2-3树中本来就是同一层的
-
不会有连续的红色节点
每个红色节点必须有两个黑色的子节点
2-3树中本来没有4节点, 2-3-4树中虽然有4节点, 但是要求在红黑树中体现为一黑色节点带两个红色子节点
所需前置知识点:
-
二叉查找树的插入和删除
-
旋转
- 左旋: 将某个节点 存在左子节点, 且为其父结点的右子节点, 其原左子节点变为原父结点的右子节点, 原父节点旋转为此节点的左子节点,
- 右旋: 将某个节点存 在右子节点, 且为其父结点的左子节点, 其原右子节点变为原父结点的左子节点, 原父节点旋转为此节点的右子节点,
红黑树的操作:
-
查询操作: 红黑树是一种自平衡的二叉查找树,既然是二叉查找树的一种,那么查找过程和二叉查找树一样,比较简单
-
插入操作:
-
在插入新节点时,这个节点是红色的
如果插入的节点是黑色,那么这个节点所在路径比其他路径多出一个黑色节点,这个调整起来会比较麻烦.
如果插入的节点是红色,此时所有路径上的黑色节点数量不变,仅可能会出现两个连续的红色节点的情况。这种情况下,通过变色和旋转进行调整即可,比之前的简单多了。对于左倾红黑树的插入一共有三种可能的情况。
-
情况1: 待插入元素比黑父大,插在了黑父的右边,而黑父左边是红色儿子。这种情况会导致在红黑树中出现右倾红节点。
说明: 这种情况对应着2-3树中出现了临时4节点,我们在2-3树中的处理是将这个临时4节点分裂,左右元素各自形成一个2节点,中间元素上升到上层跟父节点结合。
调整操作: 在红黑树中的动作是,将原本红色的左右儿子染黑(左右分裂),将黑父染红(等待上升结合)。
-
情况2: 待插入元素比红父小,且红父自身就是左倾。
说明: 红父和待插入元素同时靠在了左边,形成了连续的红节点。
调整操作:
首先对红父的父亲进行一次右旋,这次右旋不会破坏黑色平衡,但是也没有解决连续红色的问题。
接下来将12所在节点与15所在节点交换颜色,这样的目的是为了消除连续红色,并且这个操作依旧维持了黑色平衡。
现在我们已经得到了情况1的场景,直接按情况1处理即可。
-
情况3: 待插入元素比红父大,且红父自身就是左倾。
说明: 插入的这个节点形成了一个右倾的红色节点
调整操作:
先将红父进行一次左旋,就能使得右倾红节点变为左倾,现在出现了连续的左倾红节点
按情况2处理即可
在插入时,可以体会到左倾红黑树对于左倾的限制带来的好处,因为在原树符合红黑树定义的情况下,如果父亲是红的,那么它一定左倾,同时也不用考虑可能存在的右倾兄弟(如果有,那说明原树不满足红黑树定义)。
-
-
红黑树的删除操作:
左倾红黑树的删除需要借鉴上文中提到的二叉查找树通用的删除策略,当我们要删除某个节点的时候选择它的前驱节点或者后继节点元素来替代它,转而删除它的前驱/后继节点。
…(没写完, 写不动了)
赫夫曼树(Huffman) 自己去找一下
2. 散列表/哈希表(Hash Table)
2.1 散列表基础
散列表, 哈希表: 根据k-v直接访问的数据结构. 通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做散列函数,存放记录的数组叫做散列表。
键 ----->散列函数 ----->散列值
-
散列函数的设计规则:
- 得到的散列值都是一个非负整数
- 两个相同的键,通过散列函数计算出的散列值也相同
- 两个不同的键,计算出的散列值不同 ( 不是废话 )
第三点很多时候不同的key对应的散列值都不一样是不可能的, 即使**
MD5, SHA, CRC 哈希算法
** 也没有办法避免 -
如何避免哈希冲突-------开放寻址算法
开发寻址法就是当我们遇到了哈希冲突,我们就重新探索一个空闲位置,然后插入
- 线性探索: 冲突? —>往后找 —>直到空闲位置
- 二次探索: 二次探索, 步长平方
- 双重散列: 两个散列函数叠加使用, 第一个找不到就找第二个
-
如何避免哈希冲突-------链表法, 更常用, 更简单
在散列表中每个下标位置对应一个链表,所有经过散列函数得到的散列值相同的元素,我们都放到对应下标位置的链表中。
-
插入元素时,经过散列函数得到散列值,然后插入到对应下标位置的链表中即可, 时间复杂度为 O(1)。
-
查找和删除同样的对对应位置的链表进行操作,时间复杂度为O(n)
-
开放寻址法与链表法对比
- 开放寻址:
- 优点是可以有效的利用CPU的缓存加快查询速度(CPU从内存中读数据是读取一个数据块, 所以对数组来说能充分利用CPU缓存加速)
- 缺点:容易产生数据堆积,不适于大规模的数据存储,
- 链表法:
- 有点: 链表法对内存的利用率比开放寻址法要高,处理冲突简单; 且无堆积现象,平均查找长度短;链表中的结点是动态申请的,适合构造表不能确定长度的情况;相对而言, 链表法的指针域可以忽略不计,因此较开放地址法更加节省空间。
- 缺点: 如果存储的对象较小,是比较消耗内存的; 因为链表中的节点在内存中是不连续的,所以对CPU缓存不友好
- 总结: 基于链表的散列冲突处理方法比较适合存储大对象、大数据量的散列表,比起开放寻址法,他更加灵活,支持更多的优化策略
- 开放寻址:
-
装载因子:
装载因子的计算公式是:散列表的装载因子=填入数组中的元素个数/数组长度
装载因子越大,说明空闲位置越少,冲突越多,散列表的性能会下降
-
装在因子过大怎么办:
- 当装载因子触达阈值之后,我们只申请新空间,但并不将老的数据搬移到新散列表中。
- 当有新数据要插入时,我们将新数据插入新散列表中,并且从老的散列表中拿出一个数据放入到新散列表。每次插入一个数据到散列表,我们都重复上面的过程。经过多次插入操作之后,老的散列表中的数据就一点一点全部搬移到新散列表中了。(影子戏法?)
- 查询操作, 为了兼容了新、老散列表中的数据,我们先从新散列表中查找,如果没有找到,再去老的散列表中查找。
-
怎么设计散列函数
- 散列函数不能太复杂
- 散列函数得到的散列值尽可能随机且均匀分布, 减少哈希冲突
2.2 HashMap
-
初始大小:
HashMap
默认的初始大小为16 -
装载因子 和 动态扩容:
最大的装载因子默认是0.75, 当
HashMap
中元素个数超过 0.75*capacity(capacity 表示散列表的容量)的时候,就会启动扩容,每次扩容都会扩容为原来的两倍大小。 -
散列冲突解决方法
HashMap
底层采用链表法来解决冲突. 一旦出现拉链过长,则会严重影响HashMap
的性能。JDK1.8
版本中, 为了对HashMap
做进一步优化,我们引入了红黑树. 当链表长度太长(默认超过 8)时,链表就转换为红黑树。 -
散列函数: 散列函数的设计并不复杂,追求的是简单高效、分布均匀。
2.3 散列表和链表配合使用
LRU缓存淘汰算法
缓存系统的主要操作: 查找数据, 插入数据, 删除数据
因为链表的查询效率低,而插入和删除的效率高。所以, 利用数组(散列表)的查询效率,用散列表的散列函数给双向链表做一个“索引”:
前驱和后继指针是为了将结点串在双向链表中,hnext 指针是为了将结点串在散列表的拉链中。
-
查找一个数据: 散列表查找数据时间复杂度 O ( 1 ) O(1) O(1) . 找到数据后, 还要将它移动到双向链表的尾部
-
删除一个数据: 借助散列表,可以在 $O(1) $ 时间复杂度里找到要删除的结点, 链表是双向链表,双向链表可以通过前驱指针 O(1) 时间复杂度获取前驱结点,所以在双向链表中,删除结点只需要 O(1) 的时间复杂度
-
添加一个数据:需要先看这个数据是否已经在缓存中. 如果已经在其中,需要将其移动到双向链表的尾部;如果不在其中,还要看缓存有没有满。如果满了,则将双向链表头部的结点删除,然后再将数据放到链表的尾部;如果没有满,就直接将数据放到链表的尾部。
所以,这三个操作的时间复杂度都是 O(1)。至此,我们就通过散列表和双向链表的组合使用,实现了一个高效的、支持 LRU 缓存淘汰算法的缓存系统原型。
LinkedHashMap
LinkedHashMap
是通过双向链表和散列表这两种数据结构组合实现的。
和上面的LRU 缓存淘汰算法实现原理一样!(只是,LinkedHashMap支持扩容,和HashMap容量、扩容什么的都一样!)
-
添加数据到双链表尾部
-
删除就用散列表查询到后删除
-
访问元素后移到双向链表尾部
利用散列表访问到元素后,将元素变为双向链表尾结点(但此过程元素仍然串在对应散列表的拉链中)
为什么散列表和链表经常会一起使用?
就是为了散列表存储下的数据能够有序遍历!!!
因为查找、插入、删除这些散列表自己就可以做!!!
2.4 Redis有序集合
在有序集合中,每个成员对象有两个重要的属性,key(键值)和 score(分值)。我们不仅会通过 score 来查找数据,还会通过 key 来查找数据。
散列表配合跳表使用:
1. 利用跳表:按照分值(区间)查找对象(那么链表必须要按分值排序)
2. 再按照键值构建一个散列表:按照 key 来删除、查找一个成员对象(时间复杂度O(1))
对链表加多级索引的数据结构,就是跳表,
3. 堆(Heap)
3.1 概念
如果有一个关键码的集合 K = k 0 , k 1 , k 2 , … , k n − 1 K = {k0,k1, k2,…,kn-1} K=k0,k1,k2,…,kn−1,把它的所有元素按完全二叉树的顺序存储方式存储在一个一维数组中,并满足: K i < = K 2 i + 1 Ki <= K2i+1 Ki<=K2i+1 且 K i < = K 2 i + 2 Ki<=K2i+2 Ki<=K2i+2 ,则称为小堆(或大堆)。
将根节点最大的堆叫做最大堆或大根堆,根节点最小的堆叫做最小堆或小根堆。
堆是一种完全二叉树, 最小堆要求,对于任意一个父结点来说,其子结点的值都大于这个父节点,同理,最大堆就是说,其子节点的值都小于这个父节点.
3.2 性质
- 堆总是一棵完全二叉树
- 堆总是一棵完全二叉树
3.3 堆的实现
堆是一种完全二叉树,完全二叉树有其非常卓越的性质:对于任意一个父节点的序号n来说(这里n从0算),它的子节点的序号一定是2n+1,2n+2,因此可以直接用数组来表示一个堆. 所以对于上图来说,我们可以用 [ 0 , 1 , 3 , 2 , 4 , 6 , 7 , 8 , 9 , 5 , 10 ] [0,1,3,2,4,6,7,8,9,5,10] [0,1,3,2,4,6,7,8,9,5,10] 来表示这个堆。
堆的操作有一个原则: 对堆的所有操作都一定要保持操作完以后,他仍然是个堆
- 插入Insert(push) ------- 向上调整
简单来说,插入一个结点就是将该元素插入到堆的尾部,然后不断上浮调整位置,直至满足堆的条件。
当一个新元素想要插入这个堆的时候,我们首先把他放到这个堆的末尾。然后依据堆的特性,对它的位置进行调整,由于要保持父结点的值要永远少于其子节点的值,而2
的直接父节点6
大于了它,所以要把他们两的位置对换,对换完毕后,再检查这个堆的状态,发现其父节点3
仍然大于自己,所以继续往上和3
对换,结束后,和0
比较,0不大于自己,所以停留在原地不动,插入结束。
- 删除操作pop --------- 向下调整
一句话: 删除堆顶元素后,用末尾元素补上,然后不断下沉,直至满足堆的条件
删除一般指的都是删除堆顶元素,在堆顶元素被拿掉后,将末尾元素置换上来,然后进行下沉操作,由于这是最小堆,堆顶一定是最小元素,首先6
大于左结点1
,需要下沉, 下沉完以后继续和它子节点比较,发现左结点2
小于自身,继续下沉,最后8
和 9
都比6
大,停止下沉。
- 堆排序
基本思想:
-
将待排序序列构造成一个大顶堆
-
然后删除堆顶元素, 调整构造大顶堆
-
反复执行上一步
-
堆的代码实现
需要实现以下函数(方法/操作)
- 堆结构定义
- 交换
- 检查容量
- 向下调整
- 向上调整
- 堆的初始化
- 堆的创建
- 堆的销毁
- 堆的插入
- 堆的删除
- 获取对顶元素
- 判空
- 堆排序
- 堆的输出
4. 图(Graph)
4.1 基本概念
在线性表中,数据元素之间是被串起来的,仅有线性关系,每个数据元素只有一个直接前驱和一个直接后继。
在树形结构中, 数据元组之前有明显的层次关系, 且每一层上的数据元素可能和下一层中多个元素相关, 但只能和上一层中的一个元素相关
图是一种较 线性表 和 树更复杂的数据结构. 在图形结构中, 节点之间的关系可能是任意的, 途中任意两个元素之前可能都相关
1. 图的定义:
图(Graph)是由顶点的有穷非空集合 V ( G ) V(G) V(G) 和 顶点之间边的集合 E ( G ) E(G) E(G) 组成, 通常表示为 G = ( V , E ) G=(V,E) G=(V,E), 其中, G表示图r, V是图G中顶点的集合, E是图G中边的集合. 若 V = v 1 , v 2 , . . . , v n V={v_1,v_2,...,v_n} V=v1,v2,...,vn, 则用 ∣ V ∣ |V| ∣V∣表示图G中顶点的个数, 也称图G的阶, E = { ( u , v ) ∣ u ∈ V , v ∈ V } E=\lbrace(u,v)|u\in V,v\in V\rbrace E={(u,v)∣u∈V,v∈V}, 用 ∣ E ∣ |E| ∣E∣ 表示图G中边的条数
注意:线性表可以是空表,树可以是空树,但图不可以是空图。就是说,图中不能一个顶点也没有,图的顶点集V一定非空,但边集E可以为空,此时图中只有顶点而没有边。
2. 各种图及定义
- 有向图: E是有向边(也称弧)的有限集合时,则图G为有向图
弧是顶点的有序对,记为
<
v
,
w
>
<v, w>
<v,w> ,其中v,w是顶点,v称为弧尾,w称为弧头
,<v,w>称为从顶点v到顶点w的弧,也称v邻接到w,或w邻接自v。
有向图 G 1 G_1 G1可表示为: G 1 = ( V 1 , E 1 ) G_1=(V_1,E_1) G1=(V1,E1) ; V 1 = { 1 , 2 , 3 } V_1=\lbrace1,2,3\rbrace V1={1,2,3} ; E 1 = { < 1 , 2 > , < 2 , 1 > , < 2 , 3 > } E_1=\lbrace<1,2>,<2,1>,<2,3>\rbrace E1={<1,2>,<2,1>,<2,3>}
- 无向图: E是无向边(简称边)的有限集合时, 则图G为无向图。
边是顶点的无序对,记为 ( v , w ) (v, w) (v,w)或 ( w , v ) (w,v) (w,v),因为 ( v , w ) = ( w , v ) (v,w)=(w,v) (v,w)=(w,v), 其中v,w是顶点。可以说顶点w和顶点v互为邻接点。边(v, w)依附于顶点w和v,或者说边(v, w)和顶点v, w相关联
无向图 G 2 G_2 G2可表示为: G 2 = ( V 2 , E 2 ) G_2=(V_2,E_2) G2=(V2,E2) ; V 2 = { 1 , 2 , 3 , 4 } V_2=\lbrace1,2,3,4\rbrace V2={1,2,3,4} ; E 1 = { ( 1 , 2 ) , ( 1 , 3 ) , ( 1 , 4 ) , ( 2 , 3 ) , ( 2 , 4 ) , ( 3 , 4 ) } E_1=\lbrace(1,2),(1,3),(1,4),(2,3),(2,4),(3,4)\rbrace E1={(1,2),(1,3),(1,4),(2,3),(2,4),(3,4)}
-
简单图: 图G若满足: 1. 不存在重复边; 2. 不存在顶点到自身的边 , 则图G为简单图. G 1 G_1 G1和 G 2 G_2 G2均为简单图
-
多重图: 图G 中某两个结点之间的边数多于一条,又允许顶点通过同一条边和自己关联, 则G为多重图. 多重图是和简单图相对的
-
完全图(简单完全图)
- 对于无向图, ∣ E ∣ |E| ∣E∣ 的取值范围是 0到 n ∗ ( n − 1 ) / 2 n*(n-1)/2 n∗(n−1)/2, 有 n ∗ ( n − 1 ) / 2 n*(n-1)/2 n∗(n−1)/2条边的无向图为完全图, 在图的任意两个顶点之间都存在边. G 2 G_2 G2即为完全图
- 对于有向图,
∣
E
∣
|E|
∣E∣ 的取值范围是 0到
n
∗
(
n
−
1
)
n*(n-1)
n∗(n−1), 有
n
∗
(
n
−
1
)
n*(n-1)
n∗(n−1)条弧的有向图为完全图, 在图的任意两个顶点之间都存在方向相反的两条弧.
-
子图: 有两个图 G = ( V , E ) G=(V,E) G=(V,E) 和 G ′ = ( V ′ ) G'=(V') G′=(V′) , 若 是 V ′ V' V′ 是 V V V 的子集, 且 E ′ E' E′ 是 E E E 的子集, 则称 G ′ G' G′ 是 G G G 的子图. 上图 G 3 G_3 G3 是 G 1 G_1 G1的子图
-
连通, 连通图, 连通分量
在无向图中,若从顶点v 到顶点w 有路径存在,则称v 和w 是连通的.
若图G 中任意两个顶点都是连通的,则称图G 为连通图. 无向图中的极大连通子图称为连通分量。图 G 4 G_4 G4有3个连通分量
要区分极大连通子图和极小连通子图,极大连通子图是无向图的连通分量,极大即要求该连通子图包含其所有的边;极小连通子图是既要保持图连通又要使得边数最少的子图。
- 强连通图, 强连通分量
有向图中,若从顶点v 到顶点w 和 从顶点w到顶点v 之间都有路径, 则称这两个顶点是强连通的。
若图中任何一对顶点都是强连通的,则称此图为强连通图。有向图中的极大强连通子图称为有向图的强连通分量. 图 G G G 的强连通分量如下如所示
注意:强连通图、强连通分量只是针对有向图而言的。一般在无向图中讨论连通性,在有向图中考虑强连通性。
- 生成树, 生成森林
连通图的生成树是包含图中全部顶点的一个极小连通子图. 若图中顶点数为n, 若图中顶点数为 n − 1 n-1 n−1 条边
-
顶点的度、入度和出度
图中每个顶点的度定义为以该顶点为一个端点的边的数目。
-
对于无向图,顶点v的度是指依附于该顶点的边的条数, 记为 T D ( v ) TD(v) TD(v),无向图的全部顶点的度的和等于边数的2倍
-
对于有向图,顶点v 的度分为入度和出度,入度是以顶点为终点的有向边的数目, 记为 I D ( v ) ID(v) ID(v) ; 而出度是以顶点v为起点的有向边的数目, 记为 O D ( v ) OD(v) OD(v), 顶点的度等于入度和出度的和, 即 T D ( v ) = I D ( v ) + O D ( v ) TD(v) = ID(v)+OD(v) TD(v)=ID(v)+OD(v) , 有向图的全部顶点的入度之和与出度之和相等,并且等于边数。这是因为每条有向边都有一个起点和终点。
-
-
边的权和网: 在一个图中,每条边都可以标上具有某种含义的数值,该数值称为该边的权值。这种边上带有权值的图称为带权图,也称网。
-
稠密图, 稀疏图
边数很少的图称为稀疏图,反之称为稠密图。稀疏图和稠密图常常是相对而言的
-
路径, 路径长度和回路
路径: 顶点v 和顶点w 之间的顶点和关联的边(通过边连通的), 路径上边的数目称为路径长度。第一个顶点和最后一个顶点相同的路径称为回路或环。
若一个图有 n n n个顶点,并且有大于 n − 1 n − 1 n−1 条边,则此图一定有环
-
简单路径, 简单回路
-
距离: 顶点v 和 顶点u 的最短路径, 成为从v 到u的距离; 不存在路径, 距离为 ∞ \infty ∞
-
有向树: 一个顶点的入度为0、其余顶点的入度均为1的有向图,称为有向树。
4.2 图的存储结构
由于图的结构比较复杂,任意两个顶点之间都可能存在联系,因此无法以数据元素在内存中的物理位置来表示元素之间的关系,也就是说,图不可能用简单的顺序存储结构来表示。而多重链表的方式,要么会造成很多存储单元的浪费,要么又带来操作的不便。
1. 邻接矩阵
图的邻接矩阵(Adjacency Matrix) 存储方式是用两个数组来表示图。一个一维数组存储图中顶点信息,一个二维数组(称为邻接矩阵)存储图中的边或弧的信息。
设图G有n个顶点,则邻接矩阵A 是一个 n ∗ n n*n n∗n的方阵,定义为:
- 下图是一个无向图和它的邻接矩阵:
特点:
- 无向图的邻接矩阵一定是一个对称矩阵(即从矩阵的左上角到右下角的主对角线为轴,右上角的元与左下角相对应的元全都是相等的)。 因此,在实际存储邻接矩阵时只需存储上(或下)三角矩阵的元素。
- 对于无向图,邻接矩阵的第i行(或第i列)非零元素(或非 ∞ \infty ∞元素)的个数正好是第i个顶点的度 T D ( v i ) TD(v_i) TD(vi)
- 求顶点 v i v_i vi的所有邻接点就是将矩阵中第i行元素扫描一遍, A [ i ] [ j ] = 1 A[i][j]=1 A[i][j]=1 就是邻接点
- 下图是有向图和它的邻接矩阵:
特点:
- 主对角线上数值依然为0。但因为是有向图,所以此矩阵并不对称。
- 有向图讲究入度与出度,顶点 v 1 v_1 v1 的入度为1,正好是顶点 v 1 v_1 v1列各数之和。顶点 v 1 v_1 v1 的出度为2,正好是顶点 v 1 v_1 v1行各数之和。
- 与无向图同样的办法,判断顶点 v i v_i vi 和 v j v_j vj 是否存在弧, 只需要查找矩阵中 A [ i ] [ j ] A[i][j] A[i][j]是否为1即可.
- 带权图的邻接矩阵
2. 邻接表
图的邻接表法结合了顺序存储和链式存储方法,大大减少了这种不必要的浪费。
- 无向图
- 有向图
- 带权值的网图,可以在边表结点定义中再增加一个weight的数据域,存储权值信息即可。
图的邻接表存储方法具有以下特点:
- 无向图的边 会在边表中出现两次
- 稀疏图, 采用邻接表 将极大节省空间
- 在邻接表中,给定一顶点,能很容易地找出它的所有邻边,因为只需要读取它的邻接表; 但是,若要确定给定的两个顶点间是否存在边,在邻接表中则需要在相应结点对应的边表中查找另一结点,效率较邻接矩阵低。
- 在有向图的邻接表表示中,求一个给定顶点的出度只需计算其邻接表中的结点个数;但求其顶点的入度则需要遍历全部的邻接表. 因此, 可以采用逆邻接表的存储方式来加速求解给定顶点的入度.
- 图的邻接表示并不唯一, 因为每个顶点的边表中顺序可以不一样
3. 十字链表
十字链表是有向图的一种链式存储结构。
定义定点表结构如下: 其中firstin表示入边表头指针,指向该顶点的入边表中第一个结点(指向顶点的边结点),firstout 表示出边表头指针,指向该顶点的出边表中的第一个结点(指向边表头节点)。
定义边表结点结构如下: tailvex 是指弧起点在顶点表的下标,headvex 是指弧终点在顶点表中的下标,headlink是指入边表指针域,指向终点相同的下一条边,taillink是指边表指针域,指向起点相同的下一条边。
十字链表的好处就是因为把邻接表和逆邻接表整合在了一起, 这样既容易找到以 V 1 V_1 V1为尾的弧,也容易找到以 V 1 V_1 V1为头的弧, 因而容易求得顶点的出度和入度。
4. 邻接多重表
邻接多重表是无向图的另一种链式存储结构
在邻接表中,容易求得顶点和边的各种信息,但在邻接表中求两个顶点之间是否存在边而对边执行删除等操作时,需要分别在两个顶点的边表中遍历,效率较低。
每个顶点也用一个结点表示,它由如下所示的两个域组成; 重新定义的边表结点结构如下表所示。
其中ivex和jvex是与某条边依附的两个顶点在顶点表中下标。ilink 指向依附顶点ivex的下一条边,jlink 指向依附顶点jvex的下一条边。这就是邻接多重表结构。
这里最难理解的是⑥ ⑨ 也就是jvex指针; 可以这么理解, 从结点 V 2 V_2 V2 开始, V 2 V_2 V2 与 V 3 V_3 V3相连, V 1 到 V 2 V_1 到 V_2 V1到V2 是它的第一条临边, V 2 到 V 0 V_2到V_0 V2到V0 是第二条临边, 通过指针⑧⑨连起来的 2 可以 看作是一个整个 V 2 V_2 V2结点, 主线 V 2 , V 3 V_2, V_3 V2,V3, 上一条临线 V 1 , V 2 V_1,V_2 V1,V2, 多出来的线 V 0 , V 2 V_0, V_2 V0,V2
邻接多重表与邻接表的差别: 仅仅是在于同一条边在邻接表中用两个结点表示,而在邻接多重表中只有一个结点。
5. 边集数组
边集数组是由两个一维数组构成。一个是存储顶点的信息;另一个是存储边的信息,这个边数组每个数据元素由一条边的起点下标(begin)、 终点下标(end)和权(weight)组成,
4.3 图的遍历
1. 深度优先遍历(DFS)
DFS算法: 深度优先搜索类似于==树的前序遍历==. 正如其名, 这种搜索算法遵循的原则就是尽可能深入地搜索一个图.
基本思想: 首先访问图中某一起始顶点v,然后由v出发,访问与v邻接且未被访问的任一顶点 w 1 w1 w1, 再访问与 w 1 w1 w1 邻接且未被访问的任一顶点…重复上述过程。当不能再继续向下访问时,依次退回到最近被访问的顶点,若它还有邻接顶点未被访问过,则从该点开始继续上述搜索过程,直至图中所有顶点均被访问过为止。
下面这个无向图为例, 其深度优先遍历的结果为 a c d e h c f g acdehcfg acdehcfg
DFS算法性能分析: DFS算法是一个递归算法,需要借助一个递归工作栈,故其空间复杂度为 O ( V ) O(V) O(V).
对于n个顶点e条边的图来说,邻接矩阵由于是二维数组,要查找每个顶点的邻接点需要访问矩阵中的所有元素,因此时间复杂度 O ( V 2 ) O(V^2) O(V2); 邻接表做存储结构时,找邻接点所需的时间取决于顶点和边的数量,所以是 O ( V + E ) O(V+E) O(V+E); 对于点多边少的稀疏图来说,邻接表结构使得算法在时间效率上大大提高。
深度优先地生成树 和 生成森林:
深度优先搜索会产生一棵深度优先生成树. 对连通图调用DFS才能产生深度优先生成树,否则产生的将是深度优先生成森林,
2. 广度优先遍历(BFS)
BFS算法: 图的深度优先遍历类似树的前序遍历,那么图的广度优先遍历就类似于树的层序遍历
基本思想: 广度优先搜索是一种分层的查找过程,每向前走一步可能访问一批顶点,不像深度优先搜索那样有往回退的情况,因此它不是一个递归的算法
以下面这个无向图为例, 其广度优先遍历的结果 a b c d e f g h abcdefgh abcdefgh
BFS算法性能分析: 无论是邻接表还是邻接矩阵的存储方式,BFS 算法都需要借助一个辅助队列Q, n个顶点均需入队一次,在最坏的情况下,空间复杂度为 O ( V ) O(V) O(V).
采用邻接表存储方式时,每个顶点均需搜索一次(或入队一次), 在搜索任一顶点的邻接点时,每条边至少访问一次,算法总的时间复杂度 O ( V ) O(V) O(V). 采用邻接矩阵存储方式时,查找每个顶点的邻接点所需的时间为 O ( V ) O(V) O(V), 所以算法总的时间复杂度为 O ( V 2 ) O(V^2) O(V2).
图的邻接矩阵表示是唯一的,但对于邻接表来说,若边的输入次序不同,生成的邻接表也不同.
因此,对于同样一个图,基于邻接矩阵的遍历所得到的DFS序列和BFS序列是唯一的,基于邻接表的遍历所得到的DFS序列和BFS序列是不唯一的。
3. 图的遍历与图的连通性
图地遍历算法可以用来判断图的连通性。
对于无向图来说,若无向图是连通的,则从任一结点出发, 仅需一次遍历就能够访问图中的所有顶点;若无向图是非连通的,则从某一个顶点出发,一次遍历只能访问到该顶点所在连通分量的所有顶点,而对于图中其他连通分量的顶点,则无法通过这次遍历访问。
对于有向图来说,若从初始点到图中的每个顶点都有路径,则能够访问到图中的所有顶点,否则不能访问到所有顶点。
4.4 最小生成树
1. 普利姆算法(Prim)
Prim算法构造最小生成树的过程如下图所示。初始时从图中任取一顶点(如顶点加入树T,此时树中只含有一个顶点,之后选择一个与当前T中顶点集合距离最近的顶点,并将该顶点和相应的边加入T,每次操作后T中的顶点数和边数都增1。以此类推,直至图中所有的顶点都并入T,得到的T就是最小生成树。此时T中必然有n-1条边。
通俗点说就是:从一个顶点出发,在保证不形成回路的前提下,每找到并添加一条最短的边,就把当前形成的连通分量当做一个整体或者一个点看待,然后重复“找最短的边并添加”的操作。
为了能描述这个算法,我们构造一个邻接矩阵
2. 克鲁斯卡尔算法(Kruskal)
与Prim算法从顶点开始扩展最小生成树不同,Kruskal 算法是一种按权值的递增次序选择合适的边来构造最小生成树的方法。
Kruskal算法构造最小生成树的过程如下图所示。初始时为只有n个顶点而无边的非连通图
T
=
V
T=V
T=V, 每个顶点自成一个连通分量,然后按照边的权值由小到大的顺序,不断选取当前未被选取过且权值最小的边,若该边依附的顶点落在T中不同的连通分量上,则将此边加入 T, 否则舍弃此边而选择下一条权值最小的边。以此类推,直到T中所有顶点都在一个连通分量上。
为了能描述这个算法,我们将下面左图的邻接矩阵通过程序转化为右图的边集数组
4.5 最短路径
非网图它没有边上的权值,所谓的最短路径,其实就是指两顶点之间经过的边数最少的路径
对于网图来说,最短路径,是指两顶点之间经过的边上权值之和最少的路径,并且我们称路径上的第一个顶点是源点,最后一个顶点是终点。
1. 迪杰斯特拉算法(Dijkstra)
Dijkstra算法用于构建单源点的最短路径—,即图中某个点到任何其他点的距离都是最短的。
通俗点说,这个迪杰斯特拉(Dijkstra) 算法,它并不是一下子求出了 v 0 v_0 v0到 v 8 v_8 v8的最短路径,而是一步步求出它们之间顶点的最短路径,过程中都是基于已经求出的最短路径的基础上,求得更远顶点的最短路径,最终得到你要的结果。
对图6.17中的图应用 Dijkstra算法求从顶点1出发至其余顶点的最短路径的过程,如表6.1所示。算法执行过程的说明如下。
动态规划: d p [ i ] [ j ] = d p [ i ] [ j − 1 ] + e [ j − 1 , j ] dp[i][j] = dp[i][j-1]+e[j-1,j] dp[i][j]=dp[i][j−1]+e[j−1,j][][]
Dijkstra 算法也是基于==贪心策略==的。使用邻接矩阵或者带权的邻接表表示时,时间复杂度为 O ( V 2 ) O(V^2) O(V2)
2. 弗洛伊德算法(Floyd)
最短路径算法——清晰简单的弗洛伊德算法(Floyd) - 知乎 (zhihu.com)
Dijkstra适用于非负权图,并且一次只能从网络中找源点到任何一个节点的最短路径,而Floyd算法的应用更加广泛,可以求网络中任意两点之间的最短路径,而且弗洛伊德算法适用于负权图,这篇文章就用图和表的形式来介绍一下弗洛伊德算法!
- 思想
Floyd算法可以给出网络中任意两个节点之间的最短路径,因此它是比Dijkstra更一般的算法。Floyd算法的思想是将 n n n个节点的网络表示为 n n n行 n n n列的矩阵,而矩阵中的元素 ( i , j ) (i,j) (i,j)表示从节点 i i i到节点 j j j的距离 d i j d_{ij} dij,如果两点直接没有边相连,则相应的元素就是无穷 ( ∞ ) (\infty) (∞).
- 步骤
<1> 第0步:定义初始距离矩阵 D 0 D_0 D0、节点序列矩阵 S 0 S_0 S0,如下表。对角线上用”—“表示不需要从自身到自身。
这里的节点序列矩阵相当于路线表,如下表, S i j = k S_{ij}=k Sij=k表示,从节点 i i i到节点 j j j只需经过节点 k k k即可。 令 k = 1 k=1 k=1.
<2> 一般的第 k k k步:令第 k k k行为枢轴行,第 k k k列为枢轴列。对于矩阵 D k − 1 D_{k-1} Dk−1(上一步完成后的矩阵)中对的每一个元素做三重操作。
如果满足条件: d i k + d k j < d i j d_{ik}+d_{kj} < d_{ij} dik+dkj<dij,其中 ( i ≠ k , j ≠ k , i ≠ j ) (i≠ k,j\neq k,i\neq j) (i=k,j=k,i=j)
则进行下面的操作:
用 d i k + d k j d_{ik}+d_{kj} dik+dkj代替矩阵 D k − 1 D_{k-1} Dk−1中的元素 d i j d_{ij} dij,从而得到矩阵 D k D_k Dk.
用 k k k代替矩阵 S k − 1 S_{k-1} Sk−1中的元素 S i j S_{ij} Sij,从而得到矩阵 S k S_k Sk.
令 k = k + 1 k=k+1 k=k+1,如果 k = n + 1 k=n+1 k=n+1,停止,否则重复<2>.
- 栗子
直接看方法步骤会感觉太抽象,这里用一个例子进行步骤的演示. 对下图中的网络,求任意两个节点之间的最短路径,图中弧上给出了相应节点间的距离。弧(3,5)是有向的,其他边都是双边。
迭代0: 矩阵 D 0 D_0 D0和 S 0 S_0 S0代表初始的网络。可以看到矩阵 D 0 D_0 D0除了 d 53 = ∞ d_{53}=\infty d53=∞外(因为弧(3,5)是单向弧), D 0 D_0 D0是对称的。
迭代1: 令 k = 1 k=1 k=1. D 0 D_0 D0矩阵中的黄色阴影表示的第1行和第1列为枢轴行和枢轴列. 进行三重操作, 可以改进 d 23 , d 32 d_{23}, d_{32} d23,d32
(1)
d
21
+
d
13
=
3
+
10
<
∞
d_{21}+d_{13}=3+10<∞
d21+d13=3+10<∞,则在
d
23
d_{23}
d23中用
13
13
13代替
∞
∞
∞,并令
S
23
=
1
S_{23}=1
S23=1.
(2)
d
31
+
d
12
=
10
+
3
<
∞
d_{31}+d_{12}=10+3<∞
d31+d12=10+3<∞,则在
d
32
d_{32}
d32中用
13
13
13代替
∞
∞
∞,并令
S
32
=
1
S_{32}=1
S32=1.
此时得到 D 1 D_1 D1和 S 1 S_1 S1,得到下表
迭代2: 令 k = 2 k=2 k=2, D 1 D_1 D1矩阵中的黄色阴影表示的第2行和第2列为枢轴行和枢轴列. 进行三重操作, 可以改进 d 14 , d 41 d_{14}, d_{41} d14,d41
(1)
d
12
+
d
24
=
3
+
5
<
∞
d_{12}+d_{24}=3+5<∞
d12+d24=3+5<∞,则在
d
23
d_{23}
d23中用
8
8
8代替
∞
∞
∞,并令
S
14
=
2
S_{14}=2
S14=2.
(2)
d
42
+
d
21
=
5
+
3
<
∞
d_{42}+d_{21}=5+3<∞
d42+d21=5+3<∞,则在
d
32
d_{32}
d32中用
8
8
8代替
∞
∞
∞,并令
S
41
=
2
S_{41}=2
S41=2.
此时得到 D 2 D_2 D2和 S 2 S_2 S2,得到下表
迭代3: 令 k = 3 k=3 k=3, D 2 D_2 D2矩阵中的黄色阴影表示的第3行和第3列为枢轴行和枢轴列. 进行三重操作, 可以改进 d 15 , d 25 d_{15}, d_{25} d15,d25
(1)
d
13
+
d
35
=
10
+
15
<
∞
d_{13}+d_{35}=10+15<∞
d13+d35=10+15<∞,则在
d
15
d_{15}
d15中用
25
25
25代替
∞
∞
∞,并令
S
14
=
3
S_{14}=3
S14=3.
(2)
d
23
+
d
35
=
13
+
15
<
∞
d_{23}+d_{35}=13+15<∞
d23+d35=13+15<∞,则在
d
25
d_{25}
d25中用
28
28
28代替
∞
∞
∞,并令
S
41
=
3
S_{41}=3
S41=3.
此时得到 D 3 D_3 D3和 S 3 S_3 S3,得到下表
迭代4: 令 k = 4 k=4 k=4, D 3 D_3 D3矩阵中的黄色阴影表示的第4行和第4列为枢轴行和枢轴列. 进行三重操作, 可以改进 d 15 , d 23 , d 25 , d 32 , d 35 , d 51 , d 52 , d 53 d_{15}, d_{23}, d_{25}, d_{32}, d_{35}, d_{51}, d_{52}, d_{53} d15,d23,d25,d32,d35,d51,d52,d53
外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传
(1)
d
14
+
d
45
=
8
+
4
<
25
d_{14}+d_{45}=8+4<25
d14+d45=8+4<25,则在
d
15
d_{15}
d15中用
12
12
12代替
25
25
25,并令
S
15
=
4
S_{15}=4
S15=4.
(2)
d
24
+
d
43
=
5
+
6
<
13
d_{24}+d_{43}=5+6<13
d24+d43=5+6<13,则在
d
23
d_{23}
d23中用
11
11
11代替
13
13
13,并令
S
23
=
4
S_{23}=4
S23=4.
(3)
d
24
+
d
45
=
5
+
4
<
28
d_{24}+d_{45}=5+4<28
d24+d45=5+4<28,则在
d
25
d_{25}
d25中用
9
9
9代替
28
28
28,并令
S
25
=
4
S_{25}=4
S25=4.
(4)
d
34
+
d
42
=
6
+
5
<
13
d_{34}+d_{42}=6+5<13
d34+d42=6+5<13,则在
d
32
d_{32}
d32中用
11
11
11代替
13
13
13,并令
S
32
=
4
S_{32}=4
S32=4.
(5)
d
34
+
d
45
=
6
+
4
<
15
d_{34}+d_{45}=6+4<15
d34+d45=6+4<15,则在
d
35
d_{35}
d35中用
10
10
10代替
15
15
15,并令
S
35
=
4
S_{35}=4
S35=4.
(6)
d
54
+
d
41
=
4
+
8
<
∞
d_{54}+d_{41}=4+8<∞
d54+d41=4+8<∞,则在
d
51
d_{51}
d51中用
12
12
12代替
∞
∞
∞,并令
S
51
=
4
S_{51}=4
S51=4.
(7)
d
54
+
d
42
=
4
+
5
<
∞
d_{54}+d_{42}=4+5<∞
d54+d42=4+5<∞,则在
d
52
d_{52}
d52中用
9
9
9代替
∞
∞
∞,并令
S
52
=
4
S_{52}=4
S52=4.
(8)
d
54
+
d
43
=
4
+
6
<
∞
d_{54}+d_{43}=4+6<∞
d54+d43=4+6<∞,则在
d
53
d_{53}
d53中用
10
10
10代替
∞
∞
∞,并令
S
53
=
4
S_{53}=4
S53=4.
此时得到
D
4
D_4
D4和
S
4
S_4
S4,得到下表
迭代5: 令 k = 5 k=5 k=5, D 4 D_4 D4矩阵中的黄色阴影表示的第5行和第5列为枢轴行和枢轴列. 进行三重操作, 已经没有可以改进的元素了
因此,最后得到的矩阵为:
- 路径解释
这两个矩阵包含了网络中任意两个节点最短路径的所有信息。如从矩阵 D D D中可以看出节点1到节点5的最短路径长度为12.从矩阵 S S S中发现,节点1到节点5的中间节点是节点4,即节点1→节点4→节点5,再看节点1→节点4中间是节点2,即节点1需要通过节点2到达节点4,即节点1→节点2→节点4;而节点4可以直接到节点5,中间没有节点,因此可以得到节点1到节点5的最短路径是节点1→节点2→节点4→节点5.
4.6 关键路径
1. 定义
拓扑排序主要是为解决一个工程能否顺序进行的问题,但有时我们还需要解决工程完成需要的最短时间问题。
在带权有向图中,以顶点表示事件,以有向边表示活动,以边上的权值表示完成该活动的开销(如完成活动所需的时间),称之为用边表示活动的网络,简称AOE( Activity On Edge Network)网。
AOE网和AOV网都是有向无环图,不同点: 它们的边和顶点所代表的含义是不同的,AOE网中的边有权值;而AOV网中的边无权值,仅表示顶点之间的前后关系。
AOE网具有以下两个性质:
- ①只有在某顶点所代表的事件发生后,从该顶点出发的各有向边所代表的活动才能开始;
- ②只有在进入某顶点的各有向边所代表的活动都已结束时,该顶点所代表的事件才能发生。
上图的AOE网,在AOE网中仅有一个入度为0的顶点,称为开始顶点(源点),它表示整个工程的开始;
网中也仅存在一个出度为0的顶点,称为结束顶点(汇点),它表示整个工程的结束。
把路径上各个活动所持续的时间之和称为路径长度,
从源点到汇点具有最大长度的路径叫关键路径,
在关键路径上的活动叫关键活动。
2. 算法
求关键路径的算法步骤如下:
-
从源点出发,令 V e ( 源点 ) = 0 V_e(源点)=0 Ve(源点)=0, 按拓扑排序求其余顶点的最早发生时间 V e ( ) V_e() Ve()。
-
从汇点出发,令 V l ( 汇点 ) = V e ( 汇点 ) V_l(汇点)=V_e(汇点) Vl(汇点)=Ve(汇点),按逆拓扑排序求其余顶点的最迟发生时间 V l ( ) V_l() Vl()。
-
根据各顶点的 V e ( ) V_e() Ve()值求所有弧的最早开始时间V_ e ( ) e() e()。
-
根据各顶点的 V l ( ) V_l() Vl() 值求所有弧的最迟开始时间 l ( ) l() l()。
-
求AOE网中所有活动的差额 d ( ) d() d(), 找出所有 d ( ) = 0 d()=0 d()=0 活动构成关键路径.
对于关键路径,需要注意以下几点:
①关键路径上的所有活动都是关键活动,它是决定整个工程的关键因素,因此可通过加快关键活动来缩短整个工程的工期。但也不能任意缩短关键活动,因为一旦缩短到一定的程度,该关键活动就可能会变成非关键活动。
②网中的关键路径并不唯一,
且对于有几条关键路径的网,只提高一条关键路径上的关键活动速度并不能缩短整个工程的工期,只有加快那些包括在所有关键路径上的关键活动才能达到缩短工期的目的。
————————————————
版权声明:本文为CSDN博主「UniqueUnit」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/Real_Fool_/article/details/114141377
4.7 拓扑排序
1. 定义
在一个表示工程的有向图中,用顶点表示活动,用弧表示活动之间的优先关系,这样的有向图为顶点表示活动的网,我们称为AOV网( Activity On Vertex Network)。
若用DAG图(有向无环图)表示一个工程,其顶点表示活动,用有向边 < V i , V j > <V_i, V_j> <Vi,Vj>表示活动 V i V_i Vi 必须先于 V j V_j Vj进行的一种关系. 在AOV网中, 活动 V i V_i Vi是 V j V_j Vj的前驱, 活动 V j V_j Vj是 V i V_i Vi的直接后继, 这种前驱和后继关系具有传递性, 且任何活动 V i V_i Vi 不能以它自己作为前驱和后继, 不能形成环.
**设 G = ( V , E ) G=(V,E) G=(V,E) 是一个具有n个顶点的有向图, V 中顶点序列 V 1 , V 2 , . . . , V n V_1, V_2, ..., V_n V1,V2,...,Vn, 满足若从顶点 V i V_i Vi到 V j V_j Vj有一条路径,则在顶点序列中 V i V_i Vi必在顶点 V j V_j Vj之前. 则这个序列被称为 拓扑序列 **
拓扑排序,其实就是对一个有向图构造拓扑序列的过程
2. 算法
对一个AOV网进行拓扑排序的算法有很多,下面介绍比较常用的一种方法的步骤:
- ①从AOV网中选择一个没有前驱的顶点并输出。
- ②从网中删除该顶点和所有以它为起点的有向边。
- ③重复①和②直到当前的AOV网为空或不存在无前驱的顶点为止。如果输出顶点数少了,哪怕是少了一个,也说明这个网存在环(回路),不是AOV网。
上图所示为拓扑排序过程的示例。每一轮选择一个入度为0的顶点并输出,然后删除该顶点和所有以它为起点的有向边,最后得到拓扑排序的结果为 { 1 , 2 , 4 , 3 , 5 } \lbrace1,2,4,3,5\rbrace {1,2,4,3,5}
由于输出每个顶点的同时还要删除以它为起点的边,故拓扑排序的时间复杂度为 O ( V + E ) O(V+E) O(V+E).
也可以利用深度优先遍历实现拓扑排序.
用拓扑排序算法处理AOV网时,应注意以下问题:
①入度为零的顶点,即没有前驱活动的或前驱活动都已经完成的顶点,工程可以从这个顶点所代表的活动开始或继续。
②若一个顶点有多个直接后继,则拓扑排序的结果通常不唯一;但若各个顶点已经排在一个线性有序的序列中,每个顶点有唯一的前驱后继关系,则拓扑排序的结果是唯一的。
③由于AOV网中各顶点的地位平等,每个顶点编号是人为的,因此可以按拓扑排序的结果重新编号,生成AOV网的新的邻接存储矩阵,这种邻接矩阵可以是三角矩阵;但对于一般的图来说,若其邻接矩阵是三角矩阵,则存在拓扑序列;反之则不一定成立.