《数据结构》60’
一、栈(stack)、队列(Queue)、向量(Vector)
1、链表
- 带哨兵节点链表了解清楚
- 链表要会写,会分析。各种链表。
2、栈
LIFO(last in first out)先存进去的数据,最后被取出来,进出顺序逆序。即先进后出,后进先出。
ADT Stack{
数据对象:D= {Ai |Ai属于ElemSet,i = 1,2,3,…,n,n>0}
数据关系:R1 = {<Ai-1,Ai>,Ai>Ai-1,Ai属于D,i = 2,…,n} An端为栈顶,A1为栈底
基本操作:
InitStack(&S) 操作结果:构造一个空栈S。
DestroyStack(&S) 初始条件:栈S已存在 操作结果:栈S被销毁
ClearStack(&S) 初始条件:栈S已存在 操作结果:将S清为空栈
StackEmpty(S) 初始条件:栈S已存在 操作结果:若栈S为空栈,则返回TRUE,否则FALSE
StackLength(S)初始条件:栈S已经在 操作结果:返回S的元素个数,即栈的长度
GetTop(S,&e)初始条件:栈S已存在且非空 操作结果:用e返回S的栈顶元素
Push(&S,e)初始条件:栈S已存在 操作结果:插入元素e为新的 栈顶元素
Pop(&S,e)初始条件:栈S已存在且非空 操作结果:删除S的栈顶元素,并用e返回值
StackTraverse(S,visti()) 初始条件:栈S已存在且非空 操作结果:从栈底到栈顶依次对S的每个数据元素调用函数visit(),一旦visit()失败,则返回操作失败。
}ADT Stack
3、队列
队列是一个线性集合,其元素一端加入,从另一端删除,按照FIFO(先进先出)
处理过程:水平线 一段作为队列的前端(front)也称作队首(head),另一端作为队列的末端(rear)也称队尾(tail)。元素都是从队列末端末端进入,从队列前端退出。
在队列中,其处理过程可以在队列的两端进行,而在栈中,其处理过程只在栈的一端进行,但是两者也有相似之处,与栈形似,队列中也没有操作能让用户“抵达”队列中部,没有操作允许ong户重组或删除多个元素。
ADT
ADT Queue{
数据对象:D={ai|ai属于ElemSet,i = 1, 2, 3, …, n,n>0}
数据关系:R1={<ai-1,ai>|ai-1,ai属于D,i=2,…,n}
约定a1端为队列头,an端为队列尾。
基本操作:
InitQueue(&Q) 操作结果:构造一个空队列Q。
DestoryQueue(&Q)初始条件:队列Q已存在 操作结果:队列Q被销毁
ClearQueue(&Q)初始条件:队列Q已存在 操作结果:队列Q清为空栈
QueueEmpty(Q)初始条件:队列Q已存在 操作结果:若队列Q为空栈,则返回TRUE,否则FALSE
QueueLength(Q)初始条件:队列Q已存在 操作结果:返回Q的元素个数,即队列的长度
GetHead(Q,&e)初始条件:队列Q非空 操作结果:用e返回Q的队头元素
EnQueue(&Q,&e)初始条件:队列Q已存在 操作结果:插入元素e为Q的新队尾元素
DeQueue(&Q,&e)初始条件:队列Q已存在且非空 操作结果:删除Q的队头元素,并用e返回其值
QueueTraverse(Q,visit())初始条件:队列Q已存在且非空 操作结果:从队头到队尾,依次对Q的每个元素调用函数visit(),一旦visit()失败,则返回操作失败
}ADT Queue
队列链表与数组(顺序)的实现
1.链表实现队列:
队列与栈的区别在于,我们必须要操作链表的两端。因此,除了一个指向链表首元素的引用外,还需要跟踪另一个指向链表末元素的引用。再增加一个整型变量count来跟踪队列中的元素个数。综合考虑,我们使用末端入列,前端出列。
2.数组实现队列
固定数组的实现在栈中很高效的,是因为所有的操作(增删等)都是在集合的一端进行的,因而也是在数组的一端进行的,但是在队列 的实现中则不是这样,因为我们是在两端对队列进行操作的,因此固定数组的实现效率不高。
队列的应用实例:模拟售票口
4、向量(Vector)
1.对数组结构进行抽象和扩展之后,就可以得到向量结构,因此向量也称作数组列表(Array list)
2.向量提供一下访问方法,使我们可以通过下标直接访问序列中的元素,也可以将指定下标处的元素删除,或将新元素插入指定下标。为了与通常数据结构的下标(Index)概念区分开来,我们通常将序列的下标称为秩(Rank)
3.假定集合S由n个元素组成,他们依次按照线性次序存放,于是我们就可以直接访问其中的第一个元素、第二个元素、。。。。即,通过[0,n-1]之间的每一个整数,都可以直接访问到唯一的元素e,而这个整数就等于S中位于e之前的元素个数-在此,我们称之为该元素的秩(Rank)
4.不难看出,若元素e的秩为r,则只要e的直接前驱(或直接后继)存在,其秩就是r-1(或r+1)
5.支持通过秩直接访问其中元素的序列,称作向量(Vector)或数组列表(Array List)
6.ADT
操作接口(operate) | 功能(function) | 适用对象 |
size() | 返回向量的总数 | 向量 |
get® | 获取秩为r的元素 | 向量 |
put(r, e) | 用e替换秩为r元素的数值 | 向量 |
insert(r, e) | e作为秩为r元素插入,原后继元素依次后移 | 向量 |
remove® | 删除秩为r的元素,返回该元素中原存放的对象 | 向量 |
disordered() | 判断所有元素是否已按照非降序排列 | 向量 |
sort() | 调整各元素的位置,使之按照非降序排列 | 向量 |
find(e) | 查找等于e且秩最大的元素 | 向量 |
search(e) | 查找目标元素e,返回不大于e且秩最大的元素 | 有序向量 |
deduplicate() | 剔除重复元素 | 向量 |
uniquify() | 剔除重复元素 | 有序向量 |
traverse() | 遍历向量并统一处理所有元素,处理方法由函数对象指定 | 向量 |
二、树
1.树;树的前序,中序,后序,层次序遍历
概念:树(Tree)是n(n>=0)个结点的有限集
术语:
节点的度:一个节点含有的子树的个数称为该节点的度;
叶节点或终端节点:度为0的节点称为叶节点;
非终端节点或分支节点:度不为0的节点;
双亲节点或父节点:若一个节点含有子节点,则这个节点称为其子节点的父节点;
孩子节点或子节点:一个节点含有的子树的根节点称为该节点的子节点;
兄弟节点:具有相同父节点的节点互称为兄弟节点;
树的度:一棵树中,最大的节点的度称为树的度;
节点的层次:从根开始定义起,根为第一层,根的子节点为第2层,以此类推;
树的高度或深度:树中节点的最大层次;
堂兄弟节点:双亲在同一层的节点互为堂兄弟节点;
节点的祖先:从根到该节点所经分支上的所有节点;
子孙:以某节点为根的子树中任一节点都称为该节点的子孙;
森林:由m(m>=0)棵互不相交的树的集合称为森林;
二叉树的遍历(traversing binary tree):按照某种搜索路径巡防树中的每个结点,使每个结点均能被访问一次且仅一次
- 先序遍历二叉树(根>左>右)
- 中序遍历二叉树(左>根>右)
- 后序遍历二叉树(左>右>根)
- 层次遍历二叉树
2.二叉树及性质;普通树与二叉树的转换;
定义:是结点的一个有限集合,该集合或者为空,或者由一个根结点加上两棵分别称为左子树和右子树的、互不相交的二叉树组成。
性质:
1.二叉树第i层上的结点数目最多为2^(i-1)(i>=1)
2.深度为k的二叉树至多有2^(k) - 1个结点(k>=1)
3.包含n个结点的二叉树的高度至少为log2(n+1)
4.在任意一棵二叉树中,若终端结点的个数为n0,度为2的结点数为n2,则n0=n2+1
满二叉树:
1.一棵深度为k且有2^k -1个结点的二叉树
2.可以对满二叉树的结点进行连续编号,约定编号从根开始,自上而下,自左而右
完全二叉树:
深度为k的,有n个结点的二叉树,当且仅当其每一个节点都与深度为k的满二叉树中编号从1到n的结点一一对应时,称为完全二叉树
特点:
1.叶子结点只可能出现在层次最大的两层上
2.对任一结点,若其右分支下子孙的最大层次为I,其左下分支的子孙的最大层次必为I或者I+1
3.深度为k的完全二叉树要第k层最少1个结点,最多2k-1个节点;整棵树最少2k-1个结点,最多2k-2个结点
4.具有n个结点的完全二叉树的深度为[log2 n]+1
二叉树转换
树转换为二叉树过程:
1.树中所有相同双亲结点的兄弟结点之间加一条线;
2.对树中不是双亲结点的第一个孩子的结点,只保留新添加的该结点与左兄弟之间的连线,删除该结点与双亲节点之间的连线
3.整理所有保留的连线,根据连线摆放成二叉树的结构,转换完成
(
个人理解版:
1.同父同母的亲兄弟的结点之间连线
2.一个节点只留大儿子连线,其他儿子都删了
3.搞定,重摆一下,转换完成
)
二叉树转换为树的过程:
1.若某结点是其双亲节点的孩子,则把该节点的右孩子,右孩子的右孩子都与该结点的双亲结点用线连起来;
2.删除原二叉树中所有双亲结点与右孩子结点的连线;
3.根据连线摆放成树的结构,转换完成。
(
个人理解版:
1.有双亲节点且为左孩子,则把他的右孩子和右右孙子与爷爷奶奶连线;
2.把所有双亲结点与右孩子的连线删了;
3.摆放成树,完成;
)
3、树的存储结构,标准形式;完全树(complete tree)的数组形式存储
1.双亲表示定义法:假设以一组连续空间存储数的结点,同时在每个结点中,附设一个指示器指示其双亲结点到链表中的位置。
data(数据域) | parent(指针域) |
存储结点的数据信息 | 存储该结点的双亲所在数组中的下标 |
2.孩子表示法:把每个结点的孩子结点排列起来,以单链表作为存储结构,则n个结点有n个孩子链表,如果是叶子结点则此单链表为空。然后n个头指针又组成一个线性表,采用顺序存储结构,存放进一个一维数组中。
- 孩子链表的孩子结点
child(数据域) | next(指针域) |
存储某个结点在表头数组中的下标 | 存储指向某结点的下一个孩子结点的指针 |
- 表头数组的表头结点
child(数据域) | firstchild(头指针域) |
存储某个结点的数据信息 | 存储该结点的孩子链表的头指针 |
孩子兄弟表示法:任意一棵树,它的结点的第一个孩子如果存在就是唯一的,它的右兄弟存在也是唯一的。因此,设置两个指针,分别指向该节点的第一个孩子和此结点的右兄弟。
data(数据域) | firstchild(指针域) | rightchild(指针域) |
存储结点的数据信息 | 存储该结点的第一个孩子的存储地址 | 存储该结点的右兄弟结点的存储地址 |
4、树的应用,Huffman树定义与应用;
Huffman树:是一类带权路径长度最短的树
基本概念:
1.树的路径长度:从根到每一个结点的路径长度之和。
2.结点的带权路径长度:从该结点到树根之间的路径长度与结点上权的乘积。
3.树的带权路径长度:树中所有叶子结点的带权路径长度之和,通常记作WPL。
Huffman算法:
1.由给定的n个权值{w0,w1,…,wn-1},构造具有n棵二叉树的集合F={T0,T1,…,Tn-1},其中每一棵二叉树Ti只有一个带有权值wi的根结点,其左、右子树均为空。
2.在F中选取两棵根结点的权值最小的二叉树,做为左、右子树构造一棵新的二叉树。置新的二叉树的根结点的权值为其左、右子树上根结点的权值之和。
3.在F中删去这两棵二叉树,加入新得的树。
4.重复2.3,直到F只含一棵树为止。这棵树就是赫夫曼树。
三、查找(search)
1、查找的概念;对线性关系结构的查找,顺序查找,二分查找;
查找定义:根据给定的某个值(Key),在查找表中确定一个其关键字等于给定值的数据元素(或记录)。
查找算法分类:
1.静态查找和动态查找;
注:静态和动态都是针对查找表而言的,动态表指查找表中有删除和插入操作的表。
2.无序查找和有序查找
无序查找:被查找数列有序无序均可
有序查找:被查找数列必须为有序数列
平均查找长度(Average Search Length,ASL):ASL=Pi*Ci的和。
Pi:查找表中第i个数据元素的概率;Ci:找到第i个数据元素时已经比较过的次数。
顺序查找:
说明:
顺序查找适合于存储结构为顺序存储或链接存储的线性表。
基本思想:
顺序查找也称为线性查找,属于无序查找算法。从数据结构线性表的一端开始,顺序扫描,一次将扫描到的结点关键字与给定值k想比较,若相等则表示查找成功;若扫描结束仍没有找到关键字等于k的结点,表示查找失败。
复杂度分析:
查找成功时平均查找长度为:(假设每个数据元素的概率相等)
当查找不成功时,需要n+1次比较,时间复杂度为O(n);所以,顺序查找的时间复杂度为O(n);
二分查找:
说明:
元素必须是有序的,如果是无序的则要先进行排序操作。
基本思想:
也称折半查找,属于有序查找算法。用给定值k先与中间结点的关键字比较,中间结点把线性表分成两个子表,若相等则查找成功;若不相等,再根据k与该中间结点关键字的比较结果确定下一步查找哪个子表,这样递归进行,直到查找到或查找结束发现表中没有这样的结点。
复杂度分析:
最坏情况下,关键词比较次数为log2(n+1),且期望时间复杂度为O(log2n);
注:折半查找的前提条件是需要有序表顺序存储,对于静态查找表,一次排序后不再变化,折半查找能得到不错的效率。但对于需要频繁执行插入或者删除操作的数据集而言,维护有序的排序会带来不小的工作量,那就不建议使用。
2、Hash查找法,常见的Hash函数(直接定址法,随机数法),hash冲突的概念,解决冲突的方法(开散列方法/拉链法,闭散列方法/开址定址法),二次聚集现象;
Hash查找法:
通常我们查找数据是通过一个一个地比较来进行,有一种方法,要寻找的数据与其在数据集中的位置存在一种对应关系,通过这种关系就能找到数据的位置。这个对应关系称为散列函数(哈希函数),因此建立的表为散列表(哈希表)。
散列查找是关键字与在数据集中的位置一一对应,通过这种对应关系能快速地找到数据,散列查找中散列函数的构造和处理冲突的方法尤为重要。
常见Hash函数
散列函数的构造:构造哈希表的前提是要有哈希函数,并且这个函数尽可能地减少冲突
(1)直接定址法(考纲点明)
可以取关键字的某个线性函数值为散列地址,即:
这样的哈希函数简单均匀,不会产生冲突,但问题是这需要事先知道关键字的分布情况,适合查找表较小且连续的情况。
(2)数字分析法
该方法在知道关键字的情况下,取关键字的尽量不重复的几位值组成散列地址。
(3)平方取中法
取关键字平方后的中间几位为散列地址
(4)折叠法
将关键字分为位数相等的几部分,最后一部分的位数可以不等,然后把这几部分的值(舍去进位)相加作为散列地址
(5)除留余数法
该方法为最常用的构造哈希函数方法,对于散列表长为m的散列函数公式为:
f(key) = key mod p (p <=m)
使用除留余数法的一个经验是,若散列表的表长为m,通常p为小于或等于表长的最小质数或不包含小于20质因子的合数。
实践证明,当p取小于散列表长的最大质数时,函数较好。
(6)随机数法(考纲点明)
选择一个随机函数,取关键字的随机函数值作为散列地址。
Hash冲突概念:
对于不同的关键字可能得到同一哈希地址,即key1 != key2,而(key1)=(key2),这种现象称为冲突。
解决冲突的方法:
(1)开放定址法(考纲点明)
一旦发生冲突,就去寻找下一个空的散列地址,只要散列表足够大,空的散列地址总能找到,并记录存入,公式:
fi(key) = (f(key)+di) mod m(di = 1, 2, 3, …, m-1 )
用开放定址法解决冲突的做法是:
当冲突发生时,使用某种探测技术在散列表中形成一个探测序列,沿此序列逐个单元第查找,直到找到给定的关键字,或者遇到一个开放的地址(该地址单元为空)为止(若要插入,在探查到开放的地址,则可将带插入的新节点存入改地址的单元)。查找时探测到开放地址则表明表中无待查的关键字,即查找失败。
e.g.
我们的关键字集合为{12, 67,56,16,25,37,22,29,15,47,48,34},表长为12.我们用散列函数f(key)=key mod 12。
当计算前S个数{12,67,56,16,25}时,都是没有冲突的散列地址,直接存入:计算key=37时,发现f(37)=1,此时就与25所在的位置冲突。
于是我们应用上面的公式f(37)=(f(37)+1)mod12=2。于是将37存入下标为2的位置。这其实就是房子被人买了,于是买下一间的做法。接下来22,29,15,47都没有冲突,正常存入。到了key=48,我们计算得到f(48)=0,与12所在的0位置冲突了,
不要紧,我们再来一次,f(48)= (f(48)+1)mod12 =1,
还是冲突,我日,再来。f(48) = (f(48)+2)mod12 =2。。。我擦还是不行。。。继续。。。f(48) = ((f48)+3)mod12 = 3 …
f(48) = ((f48)+4)mod12 = 4 …
f(48) = ((f48)+5)mod12 = 5 …
f(48)=(f(48)+6)mod12 = 6时,才有空位,机不可失。。。立马存入:我们把这种解决冲突的开放定址法称为线性探测法。
demo:
TODO
二次探测法:
考虑深一步,如果发生这样的情况,当最后一个key=34,f(key)=10,与22所在位置冲突,可能22后面没有空位置了,反而它的前面有个空位置,尽管可以不断地求余数后得到结果,但是效率很差。
因此我们可以改进di = 12,-12,22,-22,…,q2,-q2(q<=m/2),这样就等于是可以双向寻找到可能的空位置。
对于34来说,我们取di即可找到空位置了。另外增加平方运算的目的是为了不让关键字都聚集在某一块区域。我称之为二次探测法。公式如下:
fi(key) = (f(key)+di) MOD m (di = 12,-12,22,-22,…,q2,-q2 <=m/2)
demo:
TODO
随机探测法:
还有一种方法,实在冲突时,对于位移量di采用随机函数计算得到,我们称之为随机探测法。
那么一定有朋友问,既然是随机的,那么查找的时候不也随机生成嘛?如何可以获得相同的地址呢?这个问题吧,这里的随机,其实是伪随机数。伪随机数是说,如果我们设置随机种子相同,则不断调用随机函数可以生成不会重复的数列,我们在查找时,用同样的随机种子,它每次得到的数列是相同的,相同的di当然可以得到相同的散列地址。
fi(key) = (f(key)+di) MOD m(di是一个随机数列)
总而言之,开放定址法只要在散列表未填满时,总能找到不发生冲突的地址,是我们常用的解决冲突的方法。
[注]:
伪随机数:
(2)再哈希法
再哈希法是当散列地址冲突时,用另外一个散列函数再计算一次,这种方法减少了冲突,但增加了计算时间。
Hi = RHi(key),i = 1,2,…,k(k<=m-1)
RHi均是不同的哈希函数,即在同义词产生地址冲突时计算另一个哈希函数地址,直到冲突不再发生。这种方法 不容易产生“聚集”,但是增加了计算时间。
(3)链地址法(拉链法)(必考点)
链地址法解决冲突的做法是:将所有关键字散列地址相同的结点链接再同一个单链表中。若选定的散列表长度是m,则可将散列表定义为一个由m个头指针组成的指针数组T[o…m-1]。凡是散列地址为 i的结点,均插入到以T[i]为头指针的单链表中。T中各分量的初值均为空指针。拉链法中,装填因子α可以大于1,但一般均取α<=1。
demo:
TODO
总结:
前面我们谈到了散列冲突处理的开放定址法,他的思路就是一旦发生了冲突,就去需找下一个空的散列地址。那么,有冲突就一定要换地方吗?我们直接就在原地处理可以吗?
答案是,可以的,于是我们就有个链地址法(拉链法)。将所有关键字散列地址相同的记录存储在一个单链表中,我们称这种表为同义词子表,在散列表只存储所有同义词子表的头指针。
(4)建立公共溢出区
这种方法的基本思想:将散列表分为基本表和溢出表两个表,凡是和基本表发生冲突的元素,一律填入溢出表。
二次聚集现象:
开放定址法会造成二次聚集的现象,对查找不利。我们可以看到一个现象:
当表中i,i+1,i+2位置上已经填有记录时,下一个哈希地址为i,i+2和i+3的记录都将填入i+3的位置,这种在处理冲突过程中发生的两个第一个哈希地址不同的记录争夺同一个后继哈希地址的现象称为“二次聚集”,即在处理同义词的冲突过程中又添加了非同义词的冲突。但另一个方面,用线性探测再散列处理冲突可以保证做到:只要哈希表未填满,总能找到一个不发生冲突的地址HK。而二次探测再散列只有在哈希表长m为形如4j+3(j为整数)的素数时才有可能。
3、BST树定义,性质,ADT及其实现,BST树查找,插入,删除算法;
答:
BST树定义、性质:
二叉排序树(Binary Sort Tree),又称为二叉查找树(Binary Search Tree)。
定义:要么是空树,要么具有入下性质的二叉树:
- 二叉排序树中,如果其根节点有左子树,那么左子树上所有节点都小于其根节点的值
- 二叉排序树中,如果其根节点有右子树,那么右子树上所有节点都大于其根节点的值
- 二叉排序树中的左右子树也要求都是二叉排序树
查找:
二叉排序树中查找某关键字时,查找过程类似于次优二叉树,在二叉排序树不为空树的前提下,首先将被查找值同树的树结点进行比较,会有三种不同的结果:
- 如果相等,查找成功;
- 如果比较结果为根结点的关键字值较大,则说明该关键字可能存在其左子树中;
- 如果比较结果为根结点的关键字值较小,则说明该关键字可能存在其右子树中;
插入:
二叉排序树本身是动态查找表的一种表现形式,有时会在查找过程中插入或者删除表中元素,当因为查找失败而需要插入数据元素时,该元素的插入位置一定位于二叉排序树的叶子结点,并且一定是查找失败时访问的最后一个结点的左孩子或者右孩子。
通过使用二叉排序树对动态查找表做查找和插入的操作,同时在中序遍历二叉树时,可以得到有关所有关键字的一个有序的序列。
一个无序序列可以通过构建一棵二叉排序树,从而变成一个有序序列。
删除:
在查找过程中,如果在使用二叉排序树表示的动态查找表中删除某个数据元素时,需要在成功删除该结点的同时,依旧使这棵树为二叉排序树。
假设要删除的结点为p,则对于二叉排序树来说,需要根据结点p所在不同的位置作不同的操作,有一下三种可能:
- 结点p为叶子结点,此时只需要删除该结点,并修改其双亲结点的指针即可;
- 结点p只有左子树或者只有右子树,此时只需要将其左子树或者右子树直接变为结点p双亲结点的左子树即可;
- 结点p左右子树都有
此时有两种处理方式:
1)令结点p的左子树为其双亲结点的左子树;结点p的右子树为其自身直接前驱结点的右子树
2)用结点p的直接前驱(或直接后继)来替代结点p,同时再二叉排序树中对其直接前驱(或者直接后继)做删除操作。为使用直接前驱代替结点p:
总结
使用二叉排序树在查找表中做查找操作的时间复杂度同建立在二叉树本身的结构有关。即使查找表中各种数据元素完全相同,但是不同的排列顺序,构建出的二叉排序树大不相同。
e.g.
查找表{45,24,53,12,37,93}和表{12,24,37,45,53,93}各自构建的二叉排序树图,下图所示:
不同构造的二叉排序树
使用二叉排序树实现动态查找操作的过程,实际上就是从二叉排序树的根结点到查找元素结点的过程,所以时间复杂度同被查找元素所在树的深度(层次数)有关。
为了弥补二叉排序树构造时产生如图5 右侧所示的影响算法效率的因素,需要对二叉排序树做“平衡化”处理,使其成为一棵平衡二叉树。
4、平衡树(AVL)的定义,性质,ADT及其实现,平衡树查找,插入算法,平衡因子的概念:
答:
平衡二叉树是遵循以下两个特点的二叉树:
- 每棵树中的左子树和右子树的深度差不能超过1
- 二叉树中每棵子树都要求是平衡二叉树
其实就是二叉树的基础上,使树中每棵子树都满足其左子树和右子树的深度差都不超过1.
平衡因子:每个结点都有其各自的平衡因子,表示的就是其左子树深度同右子树深度的差。平衡二叉树中各平衡因子的取值只可能是:0、1、-1。
5、优先队列与堆,堆的定义,堆的生成,调整算法;范围查询;
答:
优先队列
是一个操作受限的线性表,数据只能在一断进入,另一端出去,具有先进先去的性质。有时在队列中需要处理优先级的情况,即后面进入的数据需要提前出来,这里就需要优先队列。
优先队列是至少能够提供插入和删除最小值这两种操作的数据结构。对应于队列的操作,插入相当于入队,删除最小相当于出队。
链表,二叉查找树,都可以提供插入和删除最小这两种操作。
对于链表的实现,插入需要O(1),删除最小需要遍历链表,故需要O(N)。
对于二叉查找树,这两种操作都需要O(logN);而且随着不停地删除最小的操作,二叉查找树会变得非常不平衡;同时使用二叉查找树有些浪费,因此很多操作根本不需要。一种比较好的实现优先队列的方式是二叉堆(以下简称堆)。
堆
堆实质上是满足如下性质的完全二叉树:
树中任一非叶结点的关键字均不大于(或者不小于)其左右孩子(若存在)结点的关键字。首先堆事完全二叉树(只有最下面的两层结点度能够小于2,并且最下面一层的结点都集中在该层最左边的若干位置的二叉树),其次任意节点的左右孩子(若有)值都不小于其父亲,这是小根堆,即最小的永远在上面。相反是大根堆,即大的在上面。
插入:
二叉堆就是一个简单的一维int数组,故不需要初始化,直接插入便可。每次插入都讲新数据放到数组的最后的位置
删除:
堆中每次都是只能删除第一个数据。为了便于重建堆,实际的操作是将最后一个数据的值赋给根结点,然后再从根结点开始进行一次从上向下的调整。调整时先在左右儿子结点中找到最小的,如果父结点比这个最小的子结点还小,说明不需要调整,反之则将父结点和它交换后再考虑后面的结点。相当于从根结点将一个数据的“下沉”过程。
6、查找算法复杂度分析
四、排序(Sort)
1、排序基本概念;
重排一个记录序列,使之成为按关键字有序。
常见排序可以分为以下五类:
- 插入排序(简单插入排序、希尔排序)
- 交换排序(冒泡排序、快速排序)
- 选择排序(简单选择排序、堆排序)
- 归并排序
- 计数排序(多关键字排序)
算法稳定性
- 假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,r[i]=r[j],且r[i]在r[j]之前,而在排序后的序列中,r[i]仍在r[j]之前,则称这种排序算法是稳定的;否则称为不稳定的。
- 例如,对于如下冒泡排序算法,原本是稳定的排序算法,如果将记录交换的条件改成r[j]>=r[j+1],则两个相等的记录就会交换位置,从而变成不稳定的算法。
- 堆排序、快速排序、希尔排序、直接选择排序不是稳定的排序算法,而基数排序、冒泡排序、直接插入排序、折半插入排序、归并排序是稳定的排序算法。
2、插入排序、希尔排序、选择排序、快速排序、合并排序、基数排序等排序算法基本思想,算法代码及基本的时间复杂度分析
1.冒泡排序(Bubble-Sort)
- 交换排序的一种
- 依次比较相邻的两个待排序元素,如果顺序(如从大到小、首字母从Z到A)错误就把他们交换过来,将待排序元素从左至右比较一遍称为一趟“冒泡”
- 每趟冒泡都将待排序列中的最大关键字交换到最后(或者最前)位置
- 直到全部元素有序为止或者直到某次冒泡过程中没有发生交换位置
- 把小的元素往前调(把大的元素往后调)
- 冒泡排序就是把小的元素往前调或者把大的元素往后调。比较是相邻的两个元素比较,交换也发生在这两个元素之间。
原理:
比较两个相邻的元素,将值大的元素交换到右边
1.比较相邻元素。如果第一个比第二个大,就交换他们。
2.对每一对相邻元素做同样的工作,从第一对到结尾的最后一对。在这一点,最后的元素应该会是最大的数。
3.针对所有元素重复以上步骤,除了最后一个。
4.持续每次对越来越少的元素重复上面的步骤,直到没有任何一堆数字需要比较。
思路:
依次比较相邻的两个数,将比较小的数放在前面,比较大的数放在后面。
(1)第一次比较:首先比较第一和第二个数,将小数放在前面,将大数放在后面。
(2)比较第2和第3个数,将小数 放在前面,大数放在后面。
…
(3)如此继续,直到比较到最后的两个数,将小数放在前面,大数放在后面,重复步骤,直至全部排序完成
(4)在上面一趟比较完成后,最后一个数一定是数组中最大的一个数,所以在比较第二趟的时候,最后一个数是不参加比较的。
(5)在第二趟比较完成后,倒数第二个数也一定是数组中倒数第二大数,所以在第三趟的比较中,最后两个数是不参与比较的。
(6)依次类推,每一趟比较次数减少依次
举例:
(1)要排序数组:[10,1,35,61,89,36,55]
(2)第一趟排序:
第一次排序:10和1比较,10大于1,交换位置 [1,10,35,61,89,36,55]
第二趟排序:10和35比较,10小于35,不交换位置 [1,10,35,61,89,36,55]
第三趟排序:35和61比较,35小于61,不交换位置 [1,10,35,61,89,36,55]
第四趟排序:61和89比较,61小于89,不交换位置 [1,10,35,61,89,36,55]
第五趟排序:89和36比较,89大于36,交换位置 [1,10,35,61,36,89,55]
第六趟排序:89和55比较,89大于55,交换位置 [1,10,35,61,36,55,89]
第一趟总共进行了六次比较,排序结果:[1,10,35,61,36,55,89]
(3)第二趟排序:
第一次排序:1和10比较,1小于10,不交换位置 1,10,35,61,36,55,89
第二次排序:10和35比较,10小于35,不交换位置 1,10,35,61,36,55,89
第三次排序:35和61比较,35小于61,不交换位置 1,10,35,61,36,55,89
第四次排序:61和36比较,61大于36,交换位置 1,10,35,36,61,55,89
第五次排序:61和55比较,61大于55,交换位置 1,10,35,36,55,61,89
第二趟总共进行了5次比较,排序结果:1,10,35,36,55,61,89
(4)第三趟排序:
1和10比较,1小于10,不交换位置 1,10,35,36,55,61,89
第二次排序:10和35比较,10小于35,不交换位置 1,10,35,36,55,61,89
第三次排序:35和36比较,35小于36,不交换位置 1,10,35,36,55,61,89
第四次排序:36和61比较,36小于61,不交换位置 1,10,35,36,55,61,89
第三趟总共进行了4次比较,排序结果:1,10,35,36,55,61,89
到目前位置已经为有序的情形了。
算法分析:
(1)由此可见:N个数字要排序完成,总共进行N-1趟排序,每i趟的排序次数为(N-i)次,所以可以用双重循环语句,外层控制循环多少趟,内层控制每一趟的循环次数
(2)冒泡排序的优点:每进行一趟排序,就会少比较一次,因为每进行一趟排序都会找出一个较大值。如上例:第一趟比较之后,排在最后的一个数一定是最大的一个数,第二趟排序的时候,只需要比较除了最后一个数以外的其他的数,同样也能找出一个最大的数排在参与第二趟比较的数后面,第三趟比较的时候,只需要比较除了最后两个数以外的其他的数,以此类推……也就是说,没进行一趟比较,每一趟少比较一次,一定程度上减少了算法的量。
(3)时间复杂度
1.如果我们的数据正序,只需要走一趟即可完成排序。所需的比较次数C和记录移动次数M均达到最小值,即:Cmin=n-1;Mmin=0;所以,冒泡排序最好的时间复杂度为O(n)。
2.如果很不幸我们的数据是反序的,则需要进行n-1趟排序。每趟排序要进行n-i次比较(1≤i≤n-1),且每次比较都必须移动记录三次来达到交换记录位置。在这种情况下,比较和移动次数均达到最大值:
综上所述:冒泡排序总的平均时间复杂度为:O(n^2) ,时间复杂度和数据状况无关。
code:Bubble Sort
2.插入排序(Insertion-Sort)
思路1
- 创建一个空数组,存放排序的数据
- 从原数组中依次选择的数据
- 在新数组中寻找插入点
- 如该点没有数据,就将数据插入该点。否则需把该插入点后面的所有数据向后移动一位,空出位置,插入数据。
1、原理:从整个待排序列中选出一个元素插入到已经有序的子序列中去,得到一个有序的、元素加一的子序列,直到整个序列的待插入元素为0,则整个序列全部有序。
2、思路:
(1)设置监视哨r[0],将待插入的记录值赋值给r[0];
(2)设置开始查找的位置j;
(3)在数组中搜索,搜索 中将第j个记录后移,直到r[0].key>=r[j].key为止
(4)将r[0]插入r[j+1]的位置上。
3、举例
(1)待排序数组:[5,3,4,0,6]
(2)第一趟排序:[5,3,4,0,6]
将r[0]=5设置为监视哨,将1位置上的数3和监视哨5进行比较,3小于5,将5和3交换。
排序结果为:[3,5,4,0,6] 此时0-1范围上的数值大小已经排好了。
(3)第二趟比较:[3,5,4,0,6]
将4和5进行比较,4比5小,交换位置,排序结果为:[3,4,5,0,6]
将4和3进行比较,4比3大,不交换位置。
排序结果为:[3,4,5,0,6] 此时0-2位置上的数值大小已经排列好了
(4)第三趟比较:[3,4,5,0,6]
将0和5做比较,0比5小,交换位置,排序结果为:[3,4,0,5,6]
将0和4做比较,0比4小,交换位置,排序结果为:[3,0,4,5,6]
将0和3做比较,0比3小,交换位置,排序结果为:[0,3,4,5,6]
排序结果为:[0,3,4,5,6],此时0-3位置上的书已经排好序
(5)第四趟比较:[0,3,4,5,6]
6比5大,已经全局有序 ,不用进行任何的交换
- 实现思路:
1.从数组的第二个数据开始往前比较,即一开始用第二个数和他前面的一个比较,如果 符合条件(比前面的大或者小,自定义),则让他们交换位置。
2.然后再用第三个数和第二个比较,符合则交换,但是此处还得继续往前比较,比如有 5个数8,15,20,45, 17
,17比45小,需要交换,但是17也比20小,也要交换,当不需 要和15交换以后,说明也不需要和15前面的数据比较了,肯定不需要交换,因为前 面的数据都是有序的。
3.重复步骤二,一直到数据全都排完。
- 动图演示:
code:Insection-Sort
3.希尔排序(Shell’s-Sort)
- 缩小增量排序
- 其实插入排序的改进版,改进点:减少插入排序(Insertion-Sort)时移动元素次数
基本思想:
先将整个待记录序列分割为若干子序列分别进行直接插入排序,待整个序列的记录“基本有序”时,再对全体记录进行一次直接插入有序。
算法思想
希尔排序是特殊的插入排序,直接插入排序每次插入前的遍历步长为1,而希尔排序是将待排序列分为若干个子序列,对这些子序列分别进行直接插入排序,当每个子序列长度为1时,再进行一次直接插入排序时,结果一定是有序的。常见的划分子序列的方法有:初始步长(两个子序列相应元素相差的距离)为要排的数的一半,之后每执行一次步长折半。
code:Shell’s-Sort
时间复杂度
希尔排序的时间复杂度依赖于增量序列的函数,有人在大量的实验后得出的结论:当n在某个特定的范围后,在最优的情况下,希尔排序的时间复杂度为O(n1.3),在最差的情况下,希尔排序的时间复杂度为:O(n2).
空间复杂度
希尔排序的空间复杂度:O(1).
4.快速排序(Quick-Sort)
基本概念
- 就平均时间而言,快速排序是目前被认为最好的一种内部排序方法,由C.A.R.Hoare发明
- 分治法(devide and conquer)思想体现
- Unix系统函数库所提供的标准排序方法
- C标准函数库的排序方法直接去命名为qsort()
- 轴值(pivot):
- 书上称枢轴
- 用于将记录集“分割”为两个部分的那个键值
- 分割(partition):
- 将记录集分为两个部分,前面部分记录的键值都比轴值小,后面部分的键值都比轴值大
基本思想
快速排序由C. A. R. Hoare在1960年提出。它的基本思想是:通过一趟排序将要排序的数据分割成独立的两部分,其中一部分的所有数据都比另外一部分的所有数据都要小,然后再按此方法对这两部分数据分别进行快速排序,整个排序过程可以递归进行,以此达到整个数据变成有序序列。
原理
设要排序的数组是A[0]……A[N-1],首先任意选取一个数据(通常选
快排图
用数组的第一个数)作为关键数据,然后将所有比它小的数都放到它左边,所有比它大的数都放到它右边,这个过程称为一趟快速排序。值得注意的是,快速排序不是一种稳定的排序算法,也就是说,多个相同的值的相对位置也许会在算法结束时产生变动。 [1]
一趟快速排序的算法是: [1]
1)设置两个变量i、j,排序开始的时候:i=0,j=N-1; [1]
2)以第一个数组元素作为关键数据,赋值给key,即key=A[0]; [1]
3)从j开始向前搜索,即由后开始向前搜索(j–),找到第一个小于key的值A[j],将A[j]和A[i]的值交换; [1]
4)从i开始向后搜索,即由前开始向后搜索(i++),找到第一个大于key的A[i],将A[i]和A[j]的值交换; [1]
5)重复第3、4步,直到ij; (3,4步中,没找到符合条件的值,即3中A[j]不小于key,4中A[i]不大于key的时候改变j、i的值,使得j=j-1,i=i+1,直至找到为止。找到符合条件的值,进行交换的时候i, j指针位置不变。另外,ij这一过程一定正好是i+或j-完成的时候,此时令循环结束)。 [1]
code(Quick-Sort):
5.选择排序(Selection-Sort)
基本思想:
- 是每一次从待排序的数据元素中选出最小(或者最大)的一个元素,存放在序列的起始位置,直到全部待排序的数据元素排完。选择排序是不稳定的排序方法
- 与冒泡排序不同:减少了元素交换次数
选择排序:从小到大的方式开始排序
思路:
第一次循环找到数组中最小的一个元素,放在数组的第一个位置
第二次循环找到数组中第二小的一个元素,放在数组的第二个位置
第三次循环找到数组中第三小的一个元素,放在数组的第三个位置
以此类推,直到走完数组中的所有元素
code(Selection-Sort):
6.计数排序(Counter-Sort)
- 是一种基于非比较的排序算法,该算法于1954年由Harold H.Seward提出。它的优势在于对一定范围内的整数排序时,它的复杂度为O(n+k)(k是整数的范围),快于任何比较排序算法。这是一种牺牲空间换时间的做法。
- 这适合数字类型的排序
基本思想
- 开启额外的空间,来存储数组中的元素
- 旧数组中元素(数字)作为新数组的下标,并记录相同元素的个数
- 最后将新数组反向输出,从而得到有序的数组
动图演示
code(Counter-Sort)
7.桶排序(Bucket-Sort)
基本思想
将数组分到有限数量的桶子里。每个桶子再个别排序(有可能再使用别的排序算法或是以递归方式继续使用桶排序进行排序)
桶排序算法,从小到大的方式开始排序
实现思路:将一个数组尽量拆分成一个个小数组(桶),再对桶里面的元素进行排序,这个排序可以各种类型排序:插入,快排等
和计数排序不同:计数排序只能用于数字,每个元素作为新数组下标。
code(Bucket-Sort)
8.归并排序(Merge-Sort)
基本思想
利用归并思想实现的排序方法,该算法采用经典的分治(divide-and-conquer)策略(分治法将问题分成一些小的问题然后递归求解,而治的阶段则将分的阶段得到的各个答案“修补”在一起,即分而治之)
思路:
- 分阶段
- 类似于二分查找:将一个大数组拆半拆分成2个数组
- 再对这2个数组进行拆半拆分为4个小数组
- …
- 直到小数组(长度=1)不可再拆分
- 最后交换左右2个子数组来排序
- 治阶段
- 将2个子数组合并为一个有序数组
- 创建一个临时temp数组,长度为2个子数组长度和
- 最终完成两个子数组的合并
code(Merge-Sort)
9.基数排序(Redix-Sort)
基数排序是一种非比较型整数排序算法,其原理是将整数按位数切割成不同的数字,然后按每个位数分别比较。由于整数也可以表达字符串(名字或日期)和特定格式的浮点数,所以基数排序也不是只能使用于整数。
动画:
code(Radix-Sort)
10.堆排序(Heap-Sort)
堆排序是指利用堆这种数据结构所设计的一种排序算法。堆积是一种近似完全二叉树的结构,并同时满足堆积的性质:即子结点的键值或索引总是小于(或者大于)它的父节点。堆排序可以说是一种利用堆的概念来排序的选择排序。分为两种:
1.大顶堆:每个节点的值都大于或等于其子节点的值,在堆排序算法中用于升序排列;
2.小顶堆:每个节点的值都小于或等于其子节点的值,在堆排序算法中用于降序排列;
堆排序的平均时间复杂度为Ο(nlogn)。
算法步骤
1.创建一个堆H[o…n-1];
2.把堆首(最大值)和堆尾互换;
3.把堆的尺寸缩小1,并调用shift_down(0),目的是把新的数组顶端数据调整到相应位置;
4.重复步骤2,直到堆的尺寸为1。
动图
code(Heap-Sort)
五、图
1、图的基本概念
答:
**顶点:**使用图表示的每个数据元素称作顶点
顶点之间的关系有两种“有向图和无向图”,如下图所示:
(a)中顶点V1和V2只有单方向的关系,只能通过V1找到V2,反过来行不通,因此两顶点之间的关系表示为:<V1,V2>;
(b)中顶点之间具有双向的关系,之间用直线连通,对于V1和V2顶点来说,既可以通过V1找到V2,也可以通过V2找到V1,两顶点之间的关系表示为:(V1,V2);
“弧”和“边”:
在有向图中,<v,w>表示为从v到w的一条弧;在无向图中,(v,w)表示为顶点v和顶点w之间的一条边。
完全图:
对于无向图来说,如果图中每个顶点都和除自身之外的所有顶点有关系,那么就称这样的无向图为完全图。下图就为一个完全图。
对于有n个顶点的完全图,其中的边的数目为:
2、图的存储结构,邻接矩阵,邻接表
答:
顺序存储结构+三种链式存储结构(邻接表,邻接多重表,十字链表)
(1)数组表示法:(邻接矩阵)
使用数组存储图时,需要使用两个数组,一个数组存放在图中顶点本身的数据(一维数组),另外一个数组用于存储各顶点之间的关系(二维数组)。
不同类型的图,存储方式略有不同,根据图有权无权,可以将图分为两个大类:图和网。
图:
包括无向图和有向图。在使用二维数组存储图中顶点之间的关系时,如果顶点之间存在边或者弧,在相应位置用1表示,反之用0表示。
网:
是指带权的图,包括无向网和有向网。使用二维数组存储网中顶点之间的关系,顶点之间如果有边或者弧的存成,在数组的相应位置存储其权值;反之用∞表示。
(2)邻接表
邻接表是图的一种链式存储结构。使用邻接表存储图时,对于图中的每一个顶点和它相关的邻接点,都存储到一个链表中。每个链表都配有头结点,头结点的数据域不为NULL,而是用于存储顶点本身的数据;后续链表中各个结点存储的是当前顶点的所有邻接点。
所以,采用邻接表存储图时,有多少顶点就会构建多少个链表,为了便于管理这些链表,常用的方法是将所有链表的链表头按照一定的顺序存储在一个数组中(也可以用链表串起来)。
在邻接表中,每个链表的头结点和其他结点的组成成分有略微的不同。
头结点需要存储每个顶点的数据和指向下一个节点的指针,由两部分构成;而在存储邻接接点时,由于每个顶点的数据都存储在数组中,所以每个邻接点只需要存储自己在数组中的位置下标即可。另外还需要一个指向下一个节点的指针。除此之外,如果存储的是网,还需要一个记录权值的信息域。所以表头结点和其他结点的构造分别为:
表结点结构
info域对于无向图来说,本身不具备权值和其他相关信息,就可以根据需要将之删除。
例如:
当存储下图a所示的有向图时,构建的邻接表如下图b所示
有向图和对应的邻接表
3、图的遍历,广度优先遍历和深度优先遍历
答:
深度优先遍历(DFS,Depth First Search)
无向图
深度优先搜索的过程类似于树的先序遍历,首先从例子中体会深度优先搜索。例如上图是一个无向图,采用深度优先算法遍历整个图的过程为:
1.首先任意找一个未被遍历过的顶点,例如从V1开始,由于V1率先访问过了,所以需要标记V1的状态为访问过;
2.然后遍历V1的邻接点,例如访问V2,并做标记,然后访问V2的邻接点,例如V4(做标记),然后V8,然后V5;
3.当继续遍历V5的邻接点时,,根据之前做的标记显示,所有邻接点都被访问过了。此时,从V5回退到V8,看V8是否有未被访问过的邻接点,如果没有,继续回退到V4,V2,V1;
4.通过查看V1,找到一个未被访问过的顶点V3,继续遍历,然后访问V3邻接点V6,然后V7;
5.由于V7没有未被访问的邻接点,所以回退到V6,继续回退到V3,最后达到V1,发现没有未被访问的;
6.最后一步需要判断是否所有顶点都被访问,如果还有未被访问的,以未被访问的顶点为第一个顶点,继续依照上边的方式进行遍历。
所谓深度优先搜索,是从图中的一个顶点出发,每次遍历当前访问顶点的临界点,一直到访问的顶点没有未被访问过的临界点为止。然后采用一次回退的方式,查看来的路上每一个顶点是否有其它未被访问的临界点。访问完成后,判断图中的顶点是否已经全部遍历完成,如果没有,以未访问的顶点为起始点,重复上述过程。
广度优先遍历(BFS,Breadth First Search)
广度优先遍历类似于树的层次遍历。从图的某一个顶点出发,遍历每一个顶点时,一次遍历其所有的邻接点,然后再从这些邻接点出发,同样依次访问它们的邻接点。按照此过程,直到图中所有被访问过的顶点邻接点都被访问到过。
最后还需要做的操作就是查看图中是否已存在尚未被访问的顶点。若有,则以该顶点为起始点,重复上述遍历过程。
总结:
深度优先搜索算法的实现运用主要是回溯法,类似于树的先序遍历算法;广度优先搜索算法借助队列的先进先出特点,类似于树的层次遍历。
----------------------------------------网友版,感觉不错---------------------------------------------------
深度优先搜索(DFS)
深度优先搜索在搜索过程中访问某个顶点后,需要递归地访问此顶点的所有未访问过的相邻顶点。
初始条件下所有节点为白色,选择一个作为起始顶点,按照如下步骤遍历:
a. 选择起始顶点涂成灰色,表示还未访问
b. 从该顶点的邻接顶点中选择一个,继续这个过程(即再寻找邻接结点的邻接结点),一直深入下去,直到一个顶点没有邻接结点了,涂黑它,表示访问过了
c. 回溯到这个涂黑顶点的上一层顶点,再找这个上一层顶点的其余邻接结点,继续如上操作,如果所有邻接结点往下都访问过了,就把自己涂黑,再回溯到更上一层。
d. 上一层继续做如上操作,知道所有顶点都访问过。
用图可以更清楚的表达这个过程:
1.初始状态,从顶点1开始
2.依次访问过顶点1,2,3后,终止于顶点3
3.从顶点3回溯到顶点2,继续访问顶点5,并且终止于顶点5
4.从顶点5回溯到顶点2,并且终止于顶点2
5.从顶点2回溯到顶点1,并终止于顶点1
6.从顶点4开始访问,并终止于顶点4
从顶点1开始做深度搜索:
- 初始状态,从顶点1开始
- 依次访问过顶点1,2,3后,终止于顶点3
- 从顶点3回溯到顶点2,继续访问顶点5,并且终止于顶点5
- 从顶点5回溯到顶点2,并且终止于顶点2
- 从顶点2回溯到顶点1,并终止于顶点1
- 从顶点4开始访问,并终止于顶点4
上面的图可以通过如下邻接矩阵表示:
DFS核心代码如下(递归实现):
非递归实现如下,借助一个栈:
有的DFS是先访问读取到的结点,等回溯时就不再输出该结点,也是可以的。算法和我上面的区别就是输出点的时机不同,思想还是一样的。DFS在环监测和拓扑排序中都有不错的应用。
广度优先搜索(BFS)
广度优先搜索在进一步遍历图中顶点之前,先访问当前顶点的所有邻接结点。
a .首先选择一个顶点作为起始结点,并将其染成灰色,其余结点为白色。
b. 将起始结点放入队列中。
c. 从队列首部选出一个顶点,并找出所有与之邻接的结点,将找到的邻接结点放入队列尾部,将已访问过结点涂成黑色,没访问过的结点是白色。如果顶点的颜色是灰色,表示已经发现并且放入了队列,如果顶点的颜色是白色,表示还没有发现
d. 按照同样的方法处理队列中的下一个结点。
基本就是出队的顶点变成黑色,在队列里的是灰色,还没入队的是白色。
用一副图来表达这个流程如下:
1.初始状态,从顶点1开始,队列={1}
2.访问1的邻接顶点,1出队变黑,2,3入队,队列={2,3,}
3.访问2的邻接结点,2出队,4入队,队列={3,4}
4.访问3的邻接结点,3出队,队列={4}
5.访问4的邻接结点,4出队,队列={ 空}
从顶点1开始进行广度优先搜索:
- 初始状态,从顶点1开始,队列={1}
- 访问1的邻接顶点,1出队变黑,2,3入队,队列={2,3,}
- 访问2的邻接结点,2出队,4入队,队列={3,4}
- 访问3的邻接结点,3出队,队列={4}
- 访问4的邻接结点,4出队,队列={ 空}
结点5对于1来说不可达。
上面的图可以通过如下邻接矩阵表示:
BFS核心代码如下:
4、最小生成树基本概念,Prim算法,Kruskal算法;最短路径问题,广度优先遍历算法,Dijkstra算法,Floyd算法;拓扑排序
答:
(1)最小生成树问题
假设通过综合分析,城市之间的权值如图a所示,对于b的方案中,选择权值总和为7的两种方案最节约经费。
简单的理解就是给定一个带有权值的连通图(连通网),如何从众多的生成树中筛选出权值综合最小生成树,即为该图的最小生成树。
给定一个连通网,求最小生成树的方法有:普里姆(Prim)算法和克鲁斯卡尔(Kruskal)算法。
Prim算法:
普里姆算法在找最小生成树时,将顶点分为两类,一类是在查找的过程中包含在树中的(假设为A类),剩下的是另一类(假设为B类)。
对于给定的连通网,起始状态全部顶点都归为B类。在找最小生成树时,选定任意一个顶点作为起始点,并将之从B类移至A类;然后找出B类中到A类中的顶点之间权值最小的顶点,将之从B类移至A类,如此重复,直到B类中没有顶点为止。所走过的顶点和边就是该连通图的最小生成树。
通过普里姆算法查找上图中(a)的最小生成树的步骤为:
假如从顶点A出发,顶点B、C、D到顶点A的权值分别为2、4、2,所以,对于顶点A来水,顶点B和顶点D到A的权值最小,假设先找到顶点B:
继续分析顶点C和D,顶点C到B的权值为3,到A的权值为4;顶点D到A的权值为2,到B的权值为无穷大(如果之间没有直接通路,设定权值为无穷大)。所以顶点D到A的权值最小:
最后,只剩下顶点C,到A的权值为4,到B的权值和到D的权值一样大,为3.所以该连通图有两个最小生成树:
例子:
此图结果应为:A-C,C-F,F-D,C-B,B-E
普里姆算法的运行效率只与连通网中包含的顶点数有关,而和网所包含的变数无关。所以普里姆算法只适合解决边稠密的网,该算法运行的时间复杂度为:O(n^2)
Kruskal算法:
克鲁斯卡尔算法的具体思路是:
将所有边按照权值的大小进行升序排序,然后从小到大,一一判断,条件为:如果这个边不会与之前选择的所有边组成回路,就可以作为最小生成树的一部分;反之,舍去。直到具有n个顶点的连通网筛选出来n-1条边为止。筛选出来的边和所有的顶点构成此连通网的最小生成树。
判断是否会产生回路的方法为:
在初始状态下给每个顶点赋予不同的标记,对于遍历过程的每条边,都有两个顶点,判断这两个顶点的标记是否一致,如果一致,说明它们本身就处在一棵树中,如果连续连接就会产生回路;如果不一致,说明它们之间还没有任何关系,可以连接。
假设遍历一条由顶点A和B构成的边,而顶点A和顶点B标记不同,此时不仅需要将顶点A的标记更新为顶点B的标记,还需要更改所有和顶点A标记相同的顶点的标记,全部改为顶点B的标记。
连通网
例如,使用克鲁斯卡尔算法找上图的最小生成树的过程为:
首先,在初始状态下,对各顶点赋予不同的标记(用颜色区别),如下图所示:
对所有边按照权值大小进行排序,按照从小到大的顺序进行判断,首先是(1,3),由于顶点1和顶点3标记不同,所以可以构成生成树的一部分,遍历所有顶点,将与顶点3标记相同的全部更改为顶点1的标记,如下图所示:
其次是(4,6)边,两顶点标记不同,所以可以构成生成树的一部分,更新所有顶点的标记为:
其次是(2,5)边,两顶点标记不同,可以构成树的一部分,更新所遇顶点的标记为:
然后最小的是(3,6)边,两者标记不同,可以连接,遍历所有顶点,将与顶点6标记相同的所有顶点更改为顶点1的标记:
继续选择权值最小的边,此时会发现,权值为5的边有3个,其中(1,4)和(3,4)各自两顶点的标记一样,如果连接会产生回路,所以舍去,而(2,3)标记不一样,可以选择,将所有与顶点2标记相同的顶点的标记全部改为同顶点3相同的标记:
当选取的边的数量相比于顶点的数量小1时,说明最小生成树已经生成。所以最终采用克鲁斯卡尔算法得到的最小生成树如上图所示。
总结:
Prim(普里姆)算法,该算法从顶点的角度为出发点,时间复杂度为O(n^2),更适合于解决边的稠密度更高的连通网。
Kruskal(克鲁斯卡尔)算法,从边的角度求网的最小生成树,时间复杂度为O(eloge),和普里姆算法相反,适合求边稀疏的网的最小生成树。
(2)最短路径问题
在一个网(有权图)中,求一个顶点到另外一个顶点的最短路径的计算方式有两种:迪杰斯特拉(Dijkstra)算法和弗洛伊德(Floyd)算法。迪杰斯特拉算法计算的是:有向网中的某个顶点到其余所有顶点的最短路径;弗洛伊德算法计算的是:任意两个顶点之间的最短路径。
迪杰斯特拉(Dijkstra)算法:
迪杰斯特拉算法计算的是从网中一个顶点到其它顶点之间的最短路径问题。
带权有向图
如图所示,一个有向图,在计算V0到其它所有顶点之间的最小路径时,迪杰斯特拉算法的计算方式为:
从V0出发,由于可以直接到达V2和V5,而其它顶点和V0之间没有弧的存在,所以之间的距离设定为无穷大,可以得到下面这个表格:
从表格中可以看到,V0到V2的距离最近,所以迪杰斯特拉算法设定V0-V2为V0到V2之间的最短路径,最短路径的权值和为10.
已经判断V0-V2为最短路径,所以以V2为起始点,判断V2到除了V0以外的其余各点之间的距离,如果对应的权值比前一张表格中记录的数值小,就说明网中有一条更短的路径,直接更新表格;反之表格中的数据不变。可以得到下面这个表格:
例如,表格中V0到V3的距离,发现当通过V2到达V3的距离比之前的∞要小,所以更新表格。
更新之后,发现V0-V4的最短路径的值为30.之后从V4出发,判断到未确定最短路径的其他顶点的距离,继续更新表格:
更新后确认从V0到V3的最短路径为V0-V4-V3,权值为50。然后从V3出发,继续判断:
对于V5来说,通过V0-V4-V3-V5的路径要比之前的权值90还要小,所以更新表格,更新后可以看到,V0-V5的距离此时最短,可以确认V0到V5的最短路径为60.
最后确定V0-V1的最短路径,由于从V0无法到达V1,最终设定V0到V1的最短路径为∞(无穷大)。
在确定了V0与其他所遇顶点的最短路径后,迪杰斯特拉算法才算结束。示例中借用了 有向图对迪杰斯特拉算法进行讲解,实际上无向图的最短路径问题也可以使用迪杰斯特拉算法解决,解决过程与上述过程完全一致。
总结:
迪杰斯特拉算法解决的是从网中一个顶点到所有其他顶点之间的最短路径,算法整体的时间复杂度为O(n^2)。但是如果需要求任意两顶点之间的最短路径,使用迪杰斯特拉算法虽然也可以解决,但是大材小用,相比之下弗洛伊德算法更适合解决此问题。
弗洛伊德(Floyd)算法:
弗洛伊德的核心思想:对于网中的任意两个顶点(例如顶点A到顶点B)来说,之间的最短路径不外乎有两种情况:
1.直接从顶点A到顶点B的弧的权值为顶点A到顶点B的最短路径;
2.从顶点A开始,经过若干顶点,最终达到顶点B,期间经过的弧的权值和为顶点A到顶点B的最短路径
带权图
例如,在使用弗洛伊德算法计算上图中的任意两个顶点之间的最短路径时,具体实施步骤为:
首先,记录顶点之间初始的权值,如下表所示:
依次遍历所有的顶点,假设从V0开始,将V0作为中间点,看每对顶点之间的距离值是否会更小。最终V0对于每对顶点之间的距离没有任何改善。
对于V0来说,由于该顶点只有出度,没有入度,所以没有作为中间点的可能。同理,V1也没有可能。
将V2作为每对顶点的中间点,有影响的为(V0,V3)和(V1,V3):例如,(V0,V3)权值为无穷大,而(V0,V2)+(V2+V3)=60,比之前的值小,相比而言后者的路径更短;同理(V1,V3)也是如此。
更新表格为:
以V3为中间顶点遍历各队顶点,更新表格:
以V4为中间顶点遍历各队顶点,更新表格:
对于顶点V5而言,和顶点V0和V1相类似,所不同的是,V5只有入度,没有出度,所以对各队顶点的距离不会产生影响。最终采用弗洛伊德算法求得的各个顶点之间的最短路径如上图所示。
改算法相比于德杰斯特拉算法在解决此问题上的时间复杂度虽然相同,都是O(n^3),但是弗洛伊德算法的实现形式更简单。
(3)拓扑排序
对有向无环图进行拓扑排序,只需要遵循两个原则:
1.在图中选择一个没有前驱的顶点V;
2.从图中删除顶点V和所有以该顶点为尾的弧。
例如,在对图1中的左图进行拓扑排序时的步骤如图2所示:
图2拓扑排序
有向无环图如果顶点本身具有实际意义,例如用有向无环图表示大学期间所学习的全部课程,每个顶点都表示一门课程,有向边表示课程学习的先后次序,例如先学《程序设计基础》和《离散数学》,然后才能学习《数据结构》。所以用来表示某种活动间的优先关系的有向图简称为“AOV网”。
进行拓扑排序时,首先找到没有前驱的顶点V1,如图2(1)所示;在删除顶点V1以及V1作为起点的弧后,继续查找没有前驱的顶点,此时,V2和V3都符合条件,可以随机选择一个,例如图2(2)所示,选择V2,然后继续重复以上的操作,直至最后找不到没有前驱的顶点。
所以对于图2来说,拓扑排序最后得到的序列有两种:
- V1 ->V2 ->V3 ->V4
- V1 ->V3 ->V2 ->V4
不变。可以得到下面这个表格:
[外链图片转存中…(img-VO9vTdwn-1607958359982)]
例如,表格中V0到V3的距离,发现当通过V2到达V3的距离比之前的∞要小,所以更新表格。
更新之后,发现V0-V4的最短路径的值为30.之后从V4出发,判断到未确定最短路径的其他顶点的距离,继续更新表格:
[外链图片转存中…(img-HPPsCdw0-1607958359982)]
更新后确认从V0到V3的最短路径为V0-V4-V3,权值为50。然后从V3出发,继续判断:
[外链图片转存中…(img-ZU1FYFKC-1607958359983)]
对于V5来说,通过V0-V4-V3-V5的路径要比之前的权值90还要小,所以更新表格,更新后可以看到,V0-V5的距离此时最短,可以确认V0到V5的最短路径为60.
最后确定V0-V1的最短路径,由于从V0无法到达V1,最终设定V0到V1的最短路径为∞(无穷大)。
在确定了V0与其他所遇顶点的最短路径后,迪杰斯特拉算法才算结束。示例中借用了 有向图对迪杰斯特拉算法进行讲解,实际上无向图的最短路径问题也可以使用迪杰斯特拉算法解决,解决过程与上述过程完全一致。
总结:
迪杰斯特拉算法解决的是从网中一个顶点到所有其他顶点之间的最短路径,算法整体的时间复杂度为O(n^2)。但是如果需要求任意两顶点之间的最短路径,使用迪杰斯特拉算法虽然也可以解决,但是大材小用,相比之下弗洛伊德算法更适合解决此问题。
弗洛伊德(Floyd)算法:
弗洛伊德的核心思想:对于网中的任意两个顶点(例如顶点A到顶点B)来说,之间的最短路径不外乎有两种情况:
1.直接从顶点A到顶点B的弧的权值为顶点A到顶点B的最短路径;
2.从顶点A开始,经过若干顶点,最终达到顶点B,期间经过的弧的权值和为顶点A到顶点B的最短路径
[外链图片转存中…(img-LPnDzSfx-1607958359984)]
带权图
例如,在使用弗洛伊德算法计算上图中的任意两个顶点之间的最短路径时,具体实施步骤为:
首先,记录顶点之间初始的权值,如下表所示:
[外链图片转存中…(img-qSVZFNO2-1607958359984)]
依次遍历所有的顶点,假设从V0开始,将V0作为中间点,看每对顶点之间的距离值是否会更小。最终V0对于每对顶点之间的距离没有任何改善。
对于V0来说,由于该顶点只有出度,没有入度,所以没有作为中间点的可能。同理,V1也没有可能。
将V2作为每对顶点的中间点,有影响的为(V0,V3)和(V1,V3):例如,(V0,V3)权值为无穷大,而(V0,V2)+(V2+V3)=60,比之前的值小,相比而言后者的路径更短;同理(V1,V3)也是如此。
更新表格为:
[外链图片转存中…(img-8ZhHaGXV-1607958359985)]
以V3为中间顶点遍历各队顶点,更新表格:
[外链图片转存中…(img-kSJp3Vit-1607958359985)]
以V4为中间顶点遍历各队顶点,更新表格:
[外链图片转存中…(img-p3mDlZhs-1607958359985)]
对于顶点V5而言,和顶点V0和V1相类似,所不同的是,V5只有入度,没有出度,所以对各队顶点的距离不会产生影响。最终采用弗洛伊德算法求得的各个顶点之间的最短路径如上图所示。
改算法相比于德杰斯特拉算法在解决此问题上的时间复杂度虽然相同,都是O(n^3),但是弗洛伊德算法的实现形式更简单。
(3)拓扑排序
对有向无环图进行拓扑排序,只需要遵循两个原则:
1.在图中选择一个没有前驱的顶点V;
2.从图中删除顶点V和所有以该顶点为尾的弧。
例如,在对图1中的左图进行拓扑排序时的步骤如图2所示:
[外链图片转存中…(img-rbewsxFn-1607958359986)]图2拓扑排序
有向无环图如果顶点本身具有实际意义,例如用有向无环图表示大学期间所学习的全部课程,每个顶点都表示一门课程,有向边表示课程学习的先后次序,例如先学《程序设计基础》和《离散数学》,然后才能学习《数据结构》。所以用来表示某种活动间的优先关系的有向图简称为“AOV网”。
进行拓扑排序时,首先找到没有前驱的顶点V1,如图2(1)所示;在删除顶点V1以及V1作为起点的弧后,继续查找没有前驱的顶点,此时,V2和V3都符合条件,可以随机选择一个,例如图2(2)所示,选择V2,然后继续重复以上的操作,直至最后找不到没有前驱的顶点。
所以对于图2来说,拓扑排序最后得到的序列有两种:
- V1 ->V2 ->V3 ->V4
- V1 ->V3 ->V2 ->V4