创建父需求子需求构建需求树_1万+字手把手带你手撕面试常考的那些树/图算法(原理+图解+代码)| 收藏...

比财务不自由更痛苦的是,时间不自由!

dc74ac91f0925acdf3e80db2f0864fea.png

作者:书伟

时间:2020/6/27


数据结构与算法 | 系列

第0篇 | 不会数据结构与算法的码农有多牛?

第1篇 | 算法复杂度分析(必会)

第2篇 | 一文复习完7种数据结构(原理+代码)

第3篇 | 一举消灭十大常见(常考)排序算法(原理+动图+代码+)


第4篇 | 1万+字手把手带你手撕面试常考的那些『树/图』算法(原理+图解+代码)

目录    树        二叉树        二叉树的存储        二叉树的遍历     二叉查找树        BST的插入操作        BST的查找操作    AVL树简介     红黑树        红黑树的平衡调整     图的存储        邻接矩阵        邻接表     无权图搜索        广度优先搜索        深度优先搜索     有权图搜索        Dijkstra算法 正文共计11000+字,约50张讲解算法图片,代码n行,所有代码均可在笔者的GitHub(https://github.com/econe-zhangwei/Data-Structures-and-Algorithms)上获取,或者文末左下角
先来简单介绍一下“树”的基础概念 如下这个图就是一个树,节点A是根节点,B是D和E的父节点,F和G分别是C的左子节点和右子节点(没有子节点的节点就叫叶子节点)。 节点的高度:该节点到叶子节点的最长路径/边数
节点的深度:从根节点到该节点的最长路径/边数
节点的层数:节点的深+1
树的高度   :根节点的高度 高度和深度容易混淆,高度其实是从下往上看的,而深度是从上往下看的。比如,我们平时说一栋楼又多高而不说有多深,说悬崖有多深而不说有多高。
8990c2769474e2b84ccdc3720a458b52.png

二叉树

二叉树就是父节点最多有两个叉(叶子节点)。满二叉树是除了叶子节点外,每个节点都有左右两个子节点。如果除了最后一层都是满节点(节点个数达到最大2),且最后一层的节点都靠左排列,这样的二叉树又叫完全二叉树
二叉树的存储
链式存储 用链表来存储二叉树,每个节点有三个字段,分别存储数据和左右叶子节点的指针。拿到根节点之后,就可以得到整棵树[1]。如下图所示:
7b1e953e41bd1a44b1915a2115830d26.png
顺序存储 用数组来存储一个树,为方便计算的话从数组第二个位置开始存,也就是下标为1的位置。优点是节省内存,如下图所示,如果不是完全二叉树的话,中间还是会浪费掉不少存储空间,所以一般完全二叉树就用数组来存储。如上篇文章里说的“堆”这种数据结构就是一种特殊的完全二叉树,用数组来存储。《一文看懂常见的7种数据结构》
505b60d813e504e2695c8086ac296fd0.png
二叉树的遍历
二叉树的遍历有四种方式:前/先序遍历、中序遍历、后序遍历,这三种都属于深度遍历。以及层次遍历,也叫广度遍历。 深度遍历都是根据父节点被访问的次序来命名的。前序遍历就是先访问父节点,再访问左右叶子节点;中序遍历是先访问父节点,再左右叶子节点;后序遍历就是访问完左右叶子节点之后,最后访问父节点。

7de411e31a3564185444d7f98954d07b.png

该图引用于网络[2],侵删 节点的遍历过程其实就是一个递推的过程,递归最关键的就是找到递归关系,写出递归公式。这三种递归公式可如下表示: preOrder = print root → preOrder(root->left) → preOrder(root->right) (先输出根节点,然后遍历左右叶子节点) inOrder = inOrder(root->left) → print root → inOrder(root->right) (先遍历左叶子节点,然后输出根节点,然后遍历右叶子节点) postOrder = postOrder(root->left) → postOrder(root->right) → print root (先遍历完左右叶子节点,然后输出 root) python创建二叉树
class Node(object):"""定义节点类"""def __init__(self, element, lchild=None, rchild=None):
        self.element = element
        self.lchild = lchild
        self.rchild = rchildclass BinaryTree(object):"""定义树类"""def __init__(self):
        self.root = None
        self.queue = []   # 定义一个队列,用来接受和弹出节点,以便找到需要接收的位置def add(self, element):"""不断添加数据,构建一个完整的树"""
        node = Node(element)if self.root is None:     # 若是空树,直接把节点类赋值给根节点
            self.root = nodereturnelse:
            cur_node = self.queue[0]if cur_node.lchild is None:
                cur_node.lchild = node
                self.queue.append(cur_node.lchild)returnelse:
                cur_node.rchild = node
                self.queue.append(cur_node.rchild)
                self.queue.pop(0)   # 左右子节点都满之后,换掉父节点继续添加return
python实现深度遍历
# 前序遍历def preOrder(self, node):    if node is None:        return    else:        print(node.element)        self.preOrder(node.lchild)        self.preOrder(node.rchild)# 中序遍历def inOrder(self, node):    if node is None:        return    else:        self.inOrder(node.lchild)        print(node.element)        self.inOrder(node.rchild)# 后序遍历def postOrder(self, node):    if node is None:        return    else:        self.postOrder(node.lchild)        self.postOrder(node.rchild)        print(node.element)
还有用堆栈来实现的深度遍历,可以参考文后链接 [3] 。 层次遍历(广度遍历)是一层一层往下,从左往右遍历的。如下图所示:
bdcf6e9ad542ca515d011a2700752593.png

python实现层次遍历


  def layerTraverse(self):
    """利用父节点的出队,和叶子节点的出队来遍历实现"""
    if self.root is None:
        return
    queue = [self.root]
    
    while queue:
        cur_node = queue.pop(0)     # 父节点出队
        print(cur_node.element)
        if cur_node.lchild is not None:
            queue.append(cur_node.lchild)     # 叶子节点不为空就依次入队
        if cur_node.rchild is not None:
            queue.append(cur_node.rchild)
时间复杂度 深度遍历过程中,每个节点被遍历的次数最多是2,利用图示走一遍就明白,所以遍历的时间复杂度都是 。层次遍历,每个节点只被访问一次,所以时间复杂度也是 。

二叉查找树

普通的二叉树或一般的数据结构不方便进行动态的插入、删除、查找操作,所以有了二叉查找树(Binary Search Tree,BST),也称二叉搜索树、二叉排序树(中序遍历可以得到有序的数据序列)。如下图所示,满足如下要求:
  • 树的任意节点,若左(右)子树不为空,则左子树(右子树)的所有节点的值都小于(大于)该节点的值
80a1b9ae2a9f188f353f29846795bf8b.png
先来初始化一棵二叉查找树,方便后面对其实现插入、查找、删除操作。

  # 初始化参数
class Node(object):
    """定义节点类"""
    def __init__(self, element, lchild=None, rchild=None):
        self.element = element
        self.lchild = lchild
        self.rchild = rchild
BST 的插入操作
由二叉查找树的概念可知,如果待插入的数据比根节点小,根节点的左子树为空时插入到左子节点的位置,根节点的左子树不为空时,则递归遍历左子树。如果待插入的数据比根节点大,根节点的右子树为空时插入到右子节点的位置,根节点的右子树不为空时,则递归遍历右子树。如下图,如果要插入数据“8”时,需要依次比较“10”、“6”、“9”.
c1b372baf50b6dff2cdccdea0aaf45fb.png
  class BinarySearchTree(object):    # 二叉查找树的插入操作    def insert(self, root, item):        if root is None:            root = Node(item)        elif root.element > item:                 root.lchild = self.insert(root.lchild, item)        else:            root.rchild = self.insert(root.rchild, item)
BST的查找操作
查找的原理很简单,如果要查找的节点就是根节点,那就直接返回True。如果要查找的数据小于根节点就在左子树递归查找,大于根节点就在右子树递归查找。能找到返回True,否则返回False。 比如我要查找“9”这个数据,那就要依次查找“10”“6”“9”.
def search(self, root, item):    node = Node(root)    if node.element is None:        return False    if node.element is item:        return True    elif node.element > item:        return self.search(node.lchild, item)    else:        return self.search(node.rchild, item)
BST 的删除操作
二叉查找树的删除操作稍微复杂些,需要分为三种情况[5]: 1)要删除的节点没有叶子节点,那么直接把该节点删除,该节点的父节点指向NULL即可。如删除左图中的16。
2)要删除的节点只有一个叶子节点,把该节点的父节点指针指向该节点的叶子节点即可。如删除左图的13。
3)要删除的节点有两个叶子节点,先把该节点与右子树中的最小节点交换位置,然后删除该节点。如删除左图中的6。 57e781059663ab5e5f212880c14a96ac.png

def delete(self, root, item):
    ### 初始化节点参数(找到被删除节点及其父节点)
    #(如果要删除16,循环后node节点是16的位置,parent节点是18的位置.)
    node = Node(root)
    parent = None
    while node and node.element is not item:
        parent = node
        # 把右子树(左子树)作为节点树,(注意,node是树节点不是单个元素)
        node = node.rchild if node.element else node.lchild   # 
    if not node: return False
    
    ### 被删除节点没有叶子节点
    if not node.lchild and not node.rchild:
        # 要判断被删除节点是父节点的左子节点还是右子节点
        if parent.lchild == item: parent.lchild = None
        else: parent.rchild = None
            
    ### 被删除的节点只有一个叶子节点
    if node.lchild and not node.rchild:   # 只有左子节点
        if parent.lchild == item: parent.lchild = node.lchild
        else: parent.rchild = node.lchild
    if node.rchild and not node.lchild:   # 只有右子节点
        if parent.lchild == item: parent.lchild = node.rchild
        else: parent.rchild = node.rchild
    
    ### 被删除的节点有两个叶子节点
    if node.lchild and node.rchild:
        # 判断被删除节点是父节点的左子节点还是右子节点,返回节点位置
        if parent.lchild == item: return cur_node = parent.lchild
        else: return cur_node = parent.rchild
        # 查找右子树中的最小值
        min_code = node.rchild
        while min_node:
            if not min_node.lchild:
                min_node = min_node.lchild
        # 交换被删除节点和右子树中的最小节点
        min_node, cur_node = cur_node, min_node
        # 最小节点指向NULL
        min_node = None
对于二叉查找树的时间复杂度,无论做什么操作,都是只与树的高度成正比,所以时间复杂度都是 。 前面说的二叉查找树只支持不同数字的操作,如果是要存储相同数据的话,可以利用链表的动态扩容把所有相同的数据都放在一个节点上。也可以把重复数据放在右子树,这样查找或者删除的时候要一直找到最后才可以。

AVL 树

二叉查找树有个很大的缺点就是不平衡,如果每次插入的数据都比上一次数据大。那么最差情况下,二叉查找树就变成了单链表,时间复杂度可能会由 退化到 。 所谓的平衡二叉查找树(AVL,由开发者的名字命名的)的严格定义是:**二叉树中任意一个节点的左右子树的高度不超过1。**在前面看到的满二叉树和完全二叉树,还有前面一篇文章里说过的堆都是平衡的。AVL Tree的详细内容这里不展开说了,有兴趣的话大家自己上网查查。红黑树虽然严格来讲不是AVL树,不过理解了红黑树就自然理解了AVL树。

红黑树

AVL树由于要求比较苛刻,每次插入、删除之后都要调整,操作频繁,所以一般只对小数据量操作。而红黑树(R-B Tree)是一种“弱平衡二叉查找树”,不严格满足AVL树的要求,只要从根节点到叶子节点的最长路径不超过最短路径的两倍长就可以,这不是定义,而是由下面定义得出的推论。这样树的高度依然比较稳定的趋近于 ,维护起来也相对容易得多。 之所以叫红黑树,是因为树中的节点一类被标记为黑色,一类被标记为红色,具体要求如下:
  • 根节点是黑色的;
  • 每个叶子节点都是黑色的空节点(NIL节点);
  • 任何相邻的节点都不能同时为红色;
  • 从任意节点到达其叶子节点的所有路径,都包含相同数目的黑色节点。
第二条不好理解,前面说的树,最后的叶子节点都是指向NULL的,在红黑树这里把空节点(NIL就是空的意思)也标记为黑色,空节点不存储数据,这是为了方便实现。第三和第四条,其实是直接限制了红黑树的平衡程度,因为到叶子节点的每条路径都必须包含相同书目的黑色节点,所以就不能在一端无限增加节点数。可以用以下图示来辅助理解一下。(右图是省略了空节点的图,可以看的更清楚些)
de1c46636154a9b9aee026fc57f2d8ef.png
再说一下得出的这个推论“根节点到叶子节点的最长路径不超过最短路径的两倍长”的理由。如果把红色节点去掉,则会变成四叉树,用相同黑色节点构成的二叉树一定比这个四叉树的高度更高,而二叉树的高度是 ,那么这棵四叉树的高度必然 。再根据红黑树的定义知红色节点必然不大于黑色节点数,所以整棵红黑树的高度不大于 。
红黑树的平衡调整
红黑树的查找很方便,也不需要对原红黑树进行调整。但是插入和删除操作会涉及到平衡调整。这就是红黑树最难的点,这里先说下要注意的点: 1)不要忘了红黑树是二叉搜索树的一种,数据存储是有顺序的(中序遍历就是顺序遍历),这就影响到旋转操作过程中的关注节点(用来旋转的节点)是哪一个,以及如何旋转。我的理解是先向左向上,可以理解成递归方向,后面的自然满足红黑树,不需要考虑。 2)注意颜色的转换,要满足前面说的第三条规则。 3)为什么要旋转以及如何旋转这个要清楚。 先介绍下旋转操作,基本的旋转操作有左旋和右旋,还有引申出来的左右旋和右左旋。 左旋 如下图所示,B节点后面插入一个C时,B就是关注节点,A节点围绕B节点左旋,最后平衡。为什么要把B作为关注节点?为什么A要左旋?因为A>B>C,这样操作才能满足平衡二叉搜索树。(这里只是假定A>B>C,为了说明左旋。当然也可以A>C>B,这时候就不是这么旋转的了,下面都是相当于假定一种情况) 507a6a4c18b292a37a7b4775654c98b1.png 右旋 同理,右旋的情况如下图所示。 1615cfeb0d3e6e0d3a7823b96c4449ea.png 左右旋 左右旋就是先左旋然后右旋。主要分析一下左边这个结构为什么要这么操作,首选A>B,BC>B的平衡二叉搜索树。第一步就是B节点要围绕C节点左旋,第二步就是A节点再围绕C节点右旋,最终得到平衡结构。 fe34a950fe1d4fe82f5747396e1a0a14.png 右左旋 右左旋和左右旋刚好相反,同理如下图所示。 4bff51b25094586879e045e52c4a27e0.png 插入与删除操作 红黑树的插入与删除操作之后的平衡调整分好多种情况,这些情况也都是归纳总结出来的,重在理解。限于笔者的水平以及文章篇幅所限,我就不把所有情况都列出来一一说明。点击这个蓝字( 《15分钟彻底搞清红黑树》 )可以跳转另一篇专门些红黑树的文章,写的很不错,链接也放在了文末的参考文献[6]。还有几篇写的不错的,放在参考文献[7],里边包括用红黑树实现的Java里边的TreeMap源码,有兴趣的朋友可以自己学习下。 我简单举个插入节点的例子,一起来感受一下。 如下图所示,在红黑树这样的一个分支中插入红色节点D,这时违反了红黑树规则的第三条。所以第一步,B节点绕D节点左旋。第二步,节点A要绕D节点右旋,还是不满足条件。第三步,把节点A和节点D的颜色互换。 f924779893b46ec59baae263fb77a3ae.png 可能还是非常抽象,不理解的话就看下我推荐的那几条链接,把完整的过程看看。毕竟不是几百字就能讲清楚的。 上面说的树也算图的一种特殊情况,关于图这种非线性数据结构的内容也非常多。 关于图基础概念如有向图、无向图、带权图、顶点、边、顶点的度(入度/出度)等这里就不赘述了。下面先画两个图,大家感受一下。第一个图是无向无权图,第二个图是有向有权图。
fa6450ef96bcd50629d33c95b40942ac.png

图的存储

邻接矩阵
用邻接矩阵(Adjacency Matrix)存储图这种数据非常直观,也容易理解,其底层就是依赖一个二维数组来实现的。如果存储的是无向图,顶点i和j之间有边时,就把A[i][j]和A[j][i]均记为1(无权时用1表示);如果存储的是有向图,有顶点i指向顶点j的边时,A[i][j]用对应i到j方向的权重表示(有权时),有顶点j指向顶点i的边时,A[j][i]用对应j到i方向的权重表示。上面两图的邻接矩阵表示法分别如下两图所示。
57b238bbaad3430f0020511990e72f58.png
5f8618202c4ead6baa634455d82d0cef.png
虽然用邻接矩阵来表示一个图非常简单直观,而且由于是用矩阵表示的,因此计算起来也会很方便高效。但是从我上面表格标的颜色就能看出来,这种表示法是很浪费空间的。 对于是无向图,如果A[i][j]=1,那A[j][i]也是1。也就是说本来用一半的空间就可以完全表示,现在却要浪费一半的内存空间。 再者,无论是有向图还是无向图,如果是稀疏图,也就是顶点比较多,但是相连的边却很少的这种图,邻接矩阵这种存储方式就更加浪费空间了。
邻接表
邻接表(Adjacency List)中的每个顶点到后面对应一条链表,链表中存储的是与该顶点相连的其他顶点。这里把上述无向图的前三个顶点对应的邻接表用图形表示为如下所示:
3e15027e31e390c7fadd0a573ccdb5c6.png
如果是有权图,那么还要把权重加上去。还是以上述两个图为例,具体的邻接表表示法分别如下两图所示:
4aee046ec23a5f4098d508dd55d3c836.png
f811386b9e491c0a051fb91b15036831.png
从以上图可以看出邻接表的特点,最明显就是空间利用率比较高,没那么浪费空间。因为底层是用链表存储的,所以计算/处理起来自然就没有列表那么高效。在这里也能体现出时间复杂度和空间复杂度相互转换的思想。 用链表还有一个好处,那就是可以把链表改造为其他高级数据结构,前面文章讲链表那里就说过这个问题。尤其是当一个顶点和很多顶点都相连的时候,那该节点后面的链表就很长,这时候改造成其他高级数据结构就会更高效。 观察上面的邻接表,我们可以很快找到一个顶点的邻接顶点都有哪些。但是,如果想要找到有哪些顶点的邻接顶点是该节点,那就没办法找了。这时候就得需要“逆邻接表”,所以这里再简要说一下“逆邻接表”。我画了一张图,及其对应邻接表和逆邻接表来辅助理解一下,逆邻接表中,顶点所对应的链表中保存的是指向该顶点的顶点。 这样,我们即可以找到某个顶点的“指向顶点”,也能找到“被指向顶点”。
3e564efed5266f97ceedccd03dff14b3.png
最后,为方便大家从代码上理解,这里给出利用邻接表存储无向图的python代码,后面所有涉及到图的存储,都用的是邻接表这种方式。

class Undigraph(object):"""用邻接表存储无向图(Undirected graph)"""def __init__(self, vertex_num):
        self.v_num = vertex_num
        self.adj_list = [[] for _ in range(vertex_num+1)]#初始化邻接表[[] [] [] []……]# 不同顶点之间添加边def add_edge(self, source, target):
        s, t = source, targetif s > self.v_num or t > self.v_num:return False
        self.adj_list[s].append(t)   
        self.adj_list[t].append(s)

无权图搜索

基于图的搜索,其实就是找图中两个顶点之间的路径,最简单的搜索策略就是深度优先搜索和广度优先搜索(主要针对无权图)。更重要是,这是两种“搜索”算法的基本思想,所以必须得掌握。
广度优先搜索
广度优先搜索(Breadth First Search)简称BFS,在前面讲二叉树遍历的时候也有简单提到。核心原理就是层层递进,一层一层往下搜搜(与某层顶点直接相邻的顶点为其上/下层顶点)。再把上面那个无向图放到这里做简要说明。第一层就是0,第二层就是1和2,第三层就是3和4。
cdb621ecf67b1b740fa00c4dec8c1abc.png
关于BFS的原理就是这么简单,但是如何用代码实现,找出两个顶点之间的最短路径是重难点。只有理解的代码实现才算真正理解。所以下面我会先给出代码,然后讲一下这段代码的核心点。 还是以无向图为例,这样上下文代码都是一个体系,方便理解。

# 这里要用到队列来实现,所以直接导入一个内置的函数库from collections import deque  # 双端队列(double-ended queue)def bfs(self, s, t):'''
    s: source point
    t: target point
    '''if s == t: return# visited: 布尔变量,标记已被访问的顶点
    visited = [False] * self.v_num
    visited[s] = True# queue存储最后一层被访问的顶点
    queue = deque()
    queue.append(s)# 记录搜索路径,predecessor[3]=1表示顶点3的前驱顶点是1.
    predecessor = [None] * self.v_numwhile queue:
        v = queue.popleft()  # popleft()相当于pop(0),不过效率更高for neighbour in self.adj_list[v]: # 遍历每个顶点的邻接表if not visited[neighbour]:  # 若该顶点的邻接表中元素没有被访问过,更新参数列表
                visited[neighbour] = True  
                queue.append(neighbour)
                predecessor[neighbour] = v# 如果达到目的顶点,则打印路径if neighbour == t:# 定义print_path(s,t,predecessor)函数用来打印最短路径
                    self.print_path(s,t,predecessor)return
代码实现不是很难,但是逻辑还是不是很好理解,我先把重点步骤解释一下,然后配合图解,相信大家就都能理解了。 上述无向图如果用邻接表存储的话,最后得到 adj_list=[ [1 2] [0 2 3] [0 1 4] [1 4] [2 3 5] [4 6] [5] ] 。 BFS的核心之处就是循环体部分。其中 while 循环的作用是遍历每个顶点,即adj_list的子列表; for 循环的作用是遍历某个顶点的邻接顶点,即adj_list子列表中的每个元素。 最难理解的部分是定义的三个变量 visitedqueuepredecessor 。代码里也有大致的注解,这里再详细说下他们的作用。
  • visited:用布尔值来记录被访问的顶点,如果某顶点已经被访问过,就标记为True,否则就是False。
  • queue:记录最后一层被访问的顶点(因为BFS就是一层一层的方式遍历),方便继续访问下一层没被访问过的顶点。这里用双端队列来处理,左边入队,右边出队。
  • predecessor:前驱顶点表,用来记录搜索路径。就是说在上述无向图中,如果我搜索到3这个顶点,那它的前驱节点是1呢还是4呢,得记录下来。对每个顶点都记录其前驱顶点,那自然就找到最短路径了。
假设起始顶点(s: source point)是0,目的顶点(t: target point)是5,这里图解整个过程,辅助理解。
6e3c383327d0984edb096fb014723a7a.png
51e820477193371d7b4355edea8f42ce.png
至此,我们找到目的点,然后得到一个记录路径信息的列表 predecessor ,这个列表表明0的前驱顶点为空,1和2的前驱顶点是0,3的前驱顶点是1,4的前驱顶点是2,5的前驱顶点是4。 那如何从这个predecessor中找到最短路径呢?从描述中其实就可以发现,该列表是反向存储的,所以只要递归来打印就能得到最短路径,上端代码中出现的打印函数如下:

def print_path(s, t, predecessor):if predecessor[t] != None and t != s:
        print_path(s, predecessor[t], predecessor)
    print(t + '-->')
如果对递归不是很理解的话,可能不太好理解这段代码。我画个图,如果还是理解不了的话,可以先跳过,我会在下篇或者下下篇文章详细讲递归。
645bfffa6ef944c63da62405e4bdec8c.png
最后把t值取出来就ok。 小弟讲的够不够详细大哥大姐们???觉得还可以的话,伸出你们的小手,帮我点个“在(zhuan)看(fa)”哈!!!感谢感谢!!!
深度优先搜索
深度优先搜索(Deep First Search)简称DFS,如果上面BFS已经理解的话,DFS就很容易理解了。最大的不同之处是DFS需要用递归调用栈来实现,而BFS是用队列来实现的。 顾名思义,DFS就是从上往下一个顶点一个顶点来搜索,类似于前面讲的二叉树的“先序遍历”,关于二叉树的那三种搜索方式前面也说了,其实都属于广义的深度优先搜索策略。咱们还是用图示来说说明。
2e128779e70c7fd572b5001512ae4cbf.png
db3b745b7935105bd14eb2073b1e87c5.png
从上面递归过程来看,如果遇到走不通的顶点(邻接顶点都被访问),就得返回上一步,重新找没被访问过的顶点,这其实就是一个回溯的过程,关于回溯算法下篇文章详解。所以DFS有点“不撞南墙不回头”的意思。由此可见,DFS找到的路径不一定是最短路径。 代码实现如下:

def dfs(self, s, t):"""
    s: source point
    t: target point
    """
    visited = [False] * self.v_num
    predecessor = [None] * self.v_num# 从初始点开始深度向下搜索def d_f_s(s):
        visited[s] = Trueif s == t: return# 遍历每个顶点的邻接顶点for neighbour in self.adj_list[s]:if not visited[neighbour]:
                predecessor[neighbour] = s
                d_f_s(neighbour)
    d_f_s(s)
    self.print_path(s, t, predecessor)
总而言之,对每个顶点都要递归遍历其邻接顶点,知道找到目的顶点就返回。 BFS和DFS时间复杂度: BFS和DFS都需要把所有顶点都遍历一遍,所以两者的时间复杂度都和顶点之间边(E)的个数成正比,空间复杂度都和顶点的个数(V)成正比。即时间复杂度为O(E),空间复杂度为O(V)。理论上能用深度优先搜索求解的问题也能用广度优先搜索求解。

有权图搜索

以上BFS和DFS就算说完了,但是因为这两种搜索算法主要针对无权图。比如广度优先搜索虽然能搜索到最短路径,但是如果有权值,这时候的最短路径就不一定是层数最少的路径了。因此我们还得知道一点关于有权图的搜索算法。最广为人知的就是Dijkstra(迪杰斯特拉)算法了,求解单源最短路径(从一个顶底到另一个顶点)的算法。当然还有很多,比如A*算法就是Dijkstra的其中一个改进版本。
Dijkstra算法
在理解Dijkstra算法之前,先定义一下顶点和边的代码实现(这里用的是有向有权图)[4],因为后面代码需要用到。这段代码个别地方不理解的话,可以先看Dijkstra的实现过程,再返回来看就明白了。

class Graph:"""有向有权图"""def __init__(self, vertex_num):# 初始化邻接表
        self.adj_list = [[] for _ in range(vertex_num)]def add_edge(self, source, target, weight):
        edge = Edge(source, target, weight)
        self.adj_list[source].append(edge)def __len__(self):return len(self.adj_list)class Vertex:"""顶点类,包含顶点位置和顶点距离表"""def __init__(self, vertex, distance):
        self.id = vertex   # 顶点的下标位置,如self.id=0表示A点,等于6表示G点
        self.dist = distance   # 距离表,存储每个顶点到source point之间的距离class Edge:"""边类,包含起止点和对应的权重"""def __init__(self, source, target, weight):
        self.s = source
        self.t = target
        self.w = weight
把上面用到的无向无权图简单改造一下,变成如下有向有权图。
395d29b2cc87643535ea27c99812097a.png
现在要寻找从起点A(source point)到目的点G(target point)的最短距离。Dijkstra的核心思想就是不断更新“距离表”。 详细过程如下[4][8]:
  1. 首先创建距离表和前驱顶点表
距离表的key是顶点数据(在代码中均用下标位置体现,如self.id=0表示A),value值表示从起点A到对应顶点的的最短距离,初始值设置为无穷大。 前驱顶点中的key表示顶点数据,value中存储的是对应顶点的前驱顶点的下标位置。初始值设为None(或者用-1表示也可)。关于前驱顶点表,在前面讲到的DFS和BFS中都有用到过,所以这里就不再赘述了。
cd1d250e08a90732a7052732bd937087.png
  1. 第一步
遍历起始顶点A,找到A的邻接顶点B和C,B和C到A的距离分别是3和7,把该距离值刷新到距离表当中。 B和C的前驱顶点都是A,所以在前驱顶点表对应位置的值更新为A的下标位置"0"。
5240bbbee8d11173410df358d6beaa3e.png
  1. 第二步
找到距离表中与A距离最近的值,即顶点B。遍历顶点B,找到B的邻接顶点C和D(单向路径),分别计算C和D到A的距离。此时C到A的距离为3+2=5,D到A的距离为3+9=12。更新距离表。 C和D的前驱顶点都是B,把前驱顶点表对应位置的值更新为B的下标位置"1"。
1a104da1d0a2fbb00e1369819a8c56cb.png
  1. 第三步,重复上步操作。
遍历距离表中距离A最近的值,因为B已经遍历过了,不需要再考虑。(代码中需要一个变量保存已经被遍历过的顶点,以免重复遍历。)所以接下来就是遍历顶点C,C的邻接顶点是D和E。因为此时距离表中保存的C到A的距离是5,所以D到A的距离为5+7=12,E到A的距离为5+5=10。小于原距离表中的值就需要更新,否则不更新距离表(D不更新,E更新)。 同时更新前驱顶点表中E对应的值为C的下标"2"。
876e033017f24639b31904f1b50d3306.png
  1. 第四步,重复操作
继续遍历距离表中距离A最近的顶点,已经遍历过的顶点不再考虑。即需要遍历顶点E,其邻接顶点为D和G。因为E到A的距离是10,所以重新计算后D到A的距离为10+1=11,G到A的距离为10+10=20,更新距离表中的顶点D和G。 更新对应前驱顶点表中D和G的值为E的下标"4"。
c9d9c9616d81d17f00b11df0c25f73af.png
  1. 第五步,重复操作
接下来距离表中未被遍历过的顶点中,D到A的距离最小,遍历顶点D,其邻接顶点是F和G。因为此时A到D的距离为11,所以重新计算后,F到A的距离为11+2=13,G 到A的距离为11+4=15。更新距离表。 更新前驱顶点表,F和G对应的值更新为D的下标"3"。
ce4c1e4fd9800fc4741b8704ed01c756.png
  1. 第六步,重复操作
遍历距离表中距离最小值对应的顶点F,只有一个邻接顶点G。因为此时F到A的距离为13,重新计算后,G到A的距离为13+4=17。大于原距离表中G到A的距离15,所以不更新距离表。 前驱顶点表也不需要更新。不过,顶点F已经遍历过了,需要记录下来。
b24fb50d9179044b5b088089b94594db.png
  1. 第七步
由于我们的目标顶点就是G,所以到G就停止遍历。于是我们得到最终的距离表和前驱顶点表。 0f56a0baee8f9bc4ff7fda43afe7b654.png 到这里,Dijkstra算法实现过程就结束了。从最终的距离表中,可以看到,目标顶点G到起始顶点A的最短距离是15。对应的最短路径保存在前驱顶点表中。 顶点G的前驱顶点下标是3,即顶点D。顶点D的前驱顶点下标是4,即顶点E。顶点E的前驱顶点下标是2,即顶点C。顶点C的前驱顶点下标是1,即顶点B。顶点B的前驱顶点下标是0,即起始顶点A。 由此可见,前驱顶点表中存储的是最短路径顶点的倒序,即 G → D → E → C → B → A 。所以这里就需要倒序打印。在BFS那里,已经写过倒序打印的函数,贴过来再对应看一下。

def print_path(s, t, predecessor):if predecessor[t] != None and t != s:
        print_path(s, predecessor[t], predecessor)
    print(t + '-->')
理解了Dijkstra算法的原理之后,再来看代码实现过程,肯定很容易就理解了。不过看Dijkstra算法代码实现之前,还得简单说一下关于Dijkstra算法优化的问题。 上面算法实现过程中,有个很重要的步骤,就是需要从距离表中找到距离A最近的顶点。那怎么去做呢?当然,把这些值都保存到一个数组中,每次需要找最小距离值,就直接for循环遍历数组找最小值就ok。不过,实现这个步骤所需要的时间复杂度是O(n)。如果数据太多,那这就比较低效了。 所以要在这个地方进行优化。回想在前面讲过的一篇文章(《一文理解7种数据结构》)中,说到过一种叫做“优先队列”的数据结构。不和普通队列一样需要遵循“先进先出”的规则,而是最小/大的的都可以优先出队。如以下这个列表中,可以优先最小值依次出队。
6dd3efcd393ea7df251ad0fbe6eed752.png
而优先队列是用“堆”这种数据结构来实现的,因此我们要做堆优化。这里用到的是最小堆(小顶堆)。如果忘记了什么是堆,强烈建议返回去看一下这篇文章(《一文理解7种数据结构》)里的解释。堆(优先队列)优化之后,这部分的时间复杂度变成O(logn)。

class PriorityQueue:   # python中的heapq库默认实现的是小顶堆"""优先队列,(小顶堆min-heap)"""def __init__(self):
        self._vertices = []def push(self, value):# 元素入堆操作return heapq.push(self._vertices, value)def pop(self):# 出堆操作,,返回最小值return heapq.heappop(self._vertices)def __len__(self):return len(self._vertices)
Dijkstra算法实现代码如下:

def dijkstra(graph, source, target):
    size = len(graph)
    pq = PriorityQueue()   # 优先队列
    visited = [False] * size   # 标记已经遍历(入队)的顶点
    vertices = [Vertex(vertex, flout('inf')) for vertex in range(size)]   # 顶点列表,包含顶点位置(id)和距离(dist)
    predecessor = [None] * size   # 前驱顶点表,保存前驱顶点位置,该表的index表示当前顶点的位置
    vertices[source].dist = 0   # 起始点的距离为0
    pq.push(vertices[source])   # 起始顶点放入队列
    visited[source] = True   # 入队标记while len(pq):
        vertex = pq.pop()   # 最小距离的顶点出队if vertex.id == target:   # 遍历到目的顶点时候退出循环breakfor edge in graph.adj_list(vertex.id):   # Graph adj_list格式: [[(s1,t1,w1) (s1,t2,w2)···] ··· ],即edge=(s,t,w)if vertex.dist + edge.w # 重新计算后的距离和原距离表对应位置作比较
                vertices[edge.t].dist = vertex.dist + edge.w   # 更新距离表
                predecessor[edge.t] = vertex.id   # 更新前驱顶点表if not visited[edge.t]:
                pq.push(verticex[edge.t])   # 邻接顶点入队
                visited[edge.t] = True
    print_path(source, target, prodecessor)return vertices[target].dist
从以上代码可以看出, while 循环之前就是做一些初始化操作,对应于前面步骤的第0步,整个循环对应于第1~7步。 至此,Dijkstra算法就结束了,最后再来看下时间复杂度。代码中复杂度最高的地方就是while循环和for循环嵌套的部分。其中,while循环遍历的是顶点的个数(V),而且顶点的插入和删除都是利用优先队列(堆)来实现的,所以这部分时间复杂度是O(logV)。for循环里,遍历的是每个顶点的邻接顶点,而邻接顶点的个数和该顶点所连的边数相关,边数最大也不会操作图中所有边的条数(E),所以这部分的时间复杂度是O(E)。 综上,Dijkstra算法的时间复杂度为。 参考资料: [1].https://blog.csdn.net/gavin_john/article/details/72628965 [2].https://zhuanlan.zhihu.com/p/56895993 [3].https://blog.csdn.net/Bone_ACE/article/details/46718683 [4].https://mp.weixin.qq.com/s/ALQntqQJkdWf4RbPaGOOhg [5].https://github.com/wangzheng0822/algo/blob/master/python/24_tree/binary_search_tree.py [6].https://mp.weixin.qq.com/s/rXh_8sAPsvRxQN5ArdPcag [7].https://blog.csdn.net/abcdef314159/article/details/77193888 https://blog.csdn.net/eson_15/article/details/51144079 https://blog.csdn.net/fei33423/article/details/79132930 [8]https://blog.csdn.net/yalishadaa/article/details/55827681
ee7d14572023fc94f1d13431bc535143.png 私人微信 a67374424fc7f4ed131f5fe96259f4b6.png 原创不易,您的 在看 就是支持我最大的动力! 73b941427a44876b66d59185f9d8a14c.png73b941427a44876b66d59185f9d8a14c.png73b941427a44876b66d59185f9d8a14c.pngb05d9edf02d776bbb15b32c87937c314.pngb05d9edf02d776bbb15b32c87937c314.pngb05d9edf02d776bbb15b32c87937c314.png
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值