比财务不自由更痛苦的是,时间不自由!
作者:书伟
时间: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)上获取,或者文末左下角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] 。 层次遍历(广度遍历)是一层一层往下,从左往右遍历的。如下图所示:
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,利用图示走一遍就明白,所以遍历的时间复杂度都是 。层次遍历,每个节点只被访问一次,所以时间复杂度也是 。
# 初始化参数
class Node(object):
"""定义节点类"""
def __init__(self, element, lchild=None, rchild=None):
self.element = element
self.lchild = lchild
self.rchild = rchild
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)
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)
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
对于二叉查找树的时间复杂度,无论做什么操作,都是只与树的高度成正比,所以时间复杂度都是 。 前面说的二叉查找树只支持不同数字的操作,如果是要存储相同数据的话,可以利用链表的动态扩容把所有相同的数据都放在一个节点上。也可以把重复数据放在右子树,这样查找或者删除的时候要一直找到最后才可以。
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)
# 这里要用到队列来实现,所以直接导入一个内置的函数库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子列表中的每个元素。 最难理解的部分是定义的三个变量 visited
、 queue
、 predecessor
。代码里也有大致的注解,这里再详细说下他们的作用。
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 + '-->')
如果对递归不是很理解的话,可能不太好理解这段代码。我画个图,如果还是理解不了的话,可以先跳过,我会在下篇或者下下篇文章详细讲递归。 最后把t值取出来就ok。 小弟讲的够不够详细大哥大姐们???觉得还可以的话,伸出你们的小手,帮我点个“在(zhuan)看(fa)”哈!!!感谢感谢!!!
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)。理论上能用深度优先搜索求解的问题也能用广度优先搜索求解。
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
把上面用到的无向无权图简单改造一下,变成如下有向有权图。 现在要寻找从起点A(source point)到目的点G(target point)的最短距离。Dijkstra的核心思想就是不断更新“距离表”。 详细过程如下[4][8]:
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种数据结构》)中,说到过一种叫做“优先队列”的数据结构。不和普通队列一样需要遵循“先进先出”的规则,而是最小/大的的都可以优先出队。如以下这个列表中,可以优先最小值依次出队。 而优先队列是用“堆”这种数据结构来实现的,因此我们要做堆优化。这里用到的是最小堆(小顶堆)。如果忘记了什么是堆,强烈建议返回去看一下这篇文章(《一文理解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