最小生成树算法

什么是生成树?

连通无向图中的所有顶点且任意两个顶点间只有一条通路的子图。生成树中边的数量 = 顶点数 - 1。如下图均为生成树。

生成树

什么是最小生成树?

最小生成树是指所有生成树中边的权重和最小的生成树。

最小生成树算法

最小生成树算法主要有两个:kruskal(克鲁斯卡尔)算法和prim(普里姆)算法。下面就分别介绍下这两类算法。

1. kruskal 算法

其主要步骤如下:

  • 首先获取所有的边,对这些边进行从小到大的排序
  • 然后不断往图里添加边,并且避免造成环
  • 直到所有点都能互相访问后停止算法

伪代码如下:

function kruskal(graph) {
    // 对图里的边进行排序
    const sortedEdges = graph.edges.sort()

    // 去掉图里的边
    graph.removeAllEdges()

    for (let i = 0; i < sortedEdges.length; i++) {
        // 如果添加的边未造成环,那么就进行添加
        if (!graph.hasCycle()) {
            graph.addEdge(sortedEdges[i])
        }
    }

    return graph
}

算法要点在于怎么判断是否存在环。

在此之前,先说明三个定义:

  • 无向图 G 的一个极大连通子图称为 G 的一个连通分量(或连通分支)。连通图只有一个连通分量,即其自身;非连通的无向图有多个连通分量。连通分量与连通分量之间没有任何边相连。
  • 在无向图中,如果从顶点到顶点有路径,则称和连通。如果图中任意两个顶点之间都连通,则称该图为连通图,否则,将其中较大的连通子图称为连通分量。
  • 在有向图中,如果对于每一对顶点和,从到和从到都有路径,则称该图为强连通图;否则,将其中的极大连通子图称为强连通分量。

kruskal 就可以这样理解:初始时,把图中的个顶点看成是独立的个连通分量,从树的角度看,也是个根节点。选边的标准是这样的:若边上的两个顶点从属于两个不同的连通分量,则此边可取,否则考察下一条权值最小的边。
那么如何判断两个顶点是否属于同一个连通分量呢?这个可以参照并查集的做法解决。它的思路是:如果两个顶点的根节点是一样的,则属于同一个连通分量。这也同样暗示着:在加入新边时,要更新父节点。
先定义边的数据结构:

class Edge(object):

    def __init__(self, start, end, weight):
        """
        定义边,顶点采用 0至顶点个数-1编号
        :param start: 顶点start
        :param end: 顶点end
        :param weight: 顶点start 和顶点end 之间边的权重
        """
        self.start = start
        self.end = end
        self.weight = weight

下面是 Kruskal 算法实现:

class Kruskal(object):

    def __init__(self, edges, node_count=0):
        """
        Kruskal 算法
        :param edges: list:[Edge]
        """
        self.edges = edges
        self.root = list(range(node_count))
        self.path_length = 0

    def is_same_root(self, i, j):
        """
        判断 i 和 j 的根节点是否相同
        :param i:
        :param j:
        :return: bool
        """
        while self.root[i] != i:
            i = self.root[i]
        while self.root[j] != j:
            j = self.root[j]
        return i == j

    def update_root(self, i, j):
        """
        将 j 的根节点更新为 i 的根节点
        """
        if i > j:
            i, j = j, i
        while self.root[i] != i:
            i = self.root[i]
        self.root[j] = i

    def kruskal(self):
        # 先排序
        edges = sorted(self.edges, key=lambda edge: edge.weight)
        for edge in edges:
            i = edge.start
            j = edge.end
            if not self.is_same_root(i, j):
                print("%d 和 %d 是相连的" % (i, j))
                self.update_root(i, j)
                self.path_length += edge.weight
        return self.path_length

其中判断是否是相同根节点也可以利用查并集的方式:

    def find(self, i):
        if self.root[i] != i:
            self.root[i] = self.find(self.root[i])
        return self.root[i]

最后 Kruskal 算法可写为

    def kruskal(self):
        edges = sorted(self.edges, key=lambda edge: edge.weight)
        for edge in edges:
            i = edge.start
            j = edge.end
            w = edge.weight
            root_i, root_j = self.find(i), self.find(j)
            if root_i != root_j:
                print("%d 和 %d 是相连的" % (i, j))
                self.root[root_j] = root_i
                self.path_length += w
        return self.path_length

2. prim 算法

其主要步骤如下:

  • 从一个节点(随机选一个)开始,去找与这个节点相邻最短的边
  • 将找到的边添加到这个节点上,形成一个组件
  • 再从这个组件开始去找相邻权重最小的边,再添加到这个组件上,不断重复直到所有节点都能被访问

伪代码如下

function prim(graph) {
    const visited = []
    // 从一个节点开始
    const sourceComponent = graph.randomVertex()

    // 如果还有节点不能被访问就继续找
    while (visited.length < graph.vertices.length) {
        // 将相邻权重最小的边作为组件的一部分,且不构成环
        const smallestEdge = sourceComponent.findSmallestEdge()
        sourceComponent.addEdge(smallestEdge)

        visited.push(smallestEdge.toVertex)
    }
}

下面是 prim 算法实现:

class Prim(object):

    def __init__(self, graph):
        """
        :param graph: 邻接表形式的图,具体值表示边的权重
        """
        self.graph = graph

    def prim(self, start=0):
        length = len(self.graph)
        distances = [float('inf')] * length
        visited = [False] * length

        visited[start] = True
        index = start
        path_length = 0
        # 与 start 相连的点 进行初始化
        for i in range(length):
            distances[i] = self.graph[start][i]
        for i in range(length):
            if i == start:
                continue
            minor = float('inf')
            for j in range(length):
                # 未访问的节点中 距离最近的节点
                if not visited[j] and distances[j] < minor:
                    minor = distances[j]
                    index = j
            visited[index] = True
            # print(i, index, minor)
            path_length += minor
            # 更新最近的距离
            for j in range(length):
                if not visited[j] and distances[j] > self.graph[index][j]:
                    distances[j] = self.graph[index][j]
        return path_length

其中寻找距离最近的节点也可以利用堆进行优化:

    def prim(self, start=0):
        length = len(self.graph)
        distances = [float('inf')] * length
        visited = [False] * length

        path_length = 0
        # 元素为 (距离, 下标)
        heap = []
        heapq.heappush(heap, (0, start))
        while heap:
            dist, index = heapq.heappop(heap)
            if visited[index]:
                continue
            visited[index] = True
            path_length += dist
            # 将与index相连的节点加入
            for j in range(length):
                w = self.graph[index][j]
                if not visited[j] and w < distances[j]:
                    distances[j] = w
                    heapq.heappush(heap, (w, j))
        return path_length

3. leetcode 题目

3.1 1584. 连接所有点的最小费用
给你一个points 数组,表示 2D 平面上的一些点,其中 points[i] = [xi, yi] 。 
连接点 [xi, yi] 和点 [xj, yj] 的费用为它们之间的 曼哈顿距离 :|xi - xj| + |yi - yj| ,
其中 |val| 表示 val 的绝对值。 请你返回将所有点连接的最小总费用。
只有任意两点之间 有且仅有 一条简单路径时,才认为所有点都已连接。 

示例 1: 
输入:points = [[0,0],[2,2],[3,10],[5,2],[7,0]]
输出:20

示例 2: 
输入:points = [[3,12],[-2,5],[-4,1]]
输出:18

示例 3: 
输入:points = [[0,0],[1,1],[1,0],[-1,1]]
输出:4

示例 4: 
输入:points = [[-1000000,-1000000],[1000000,1000000]]
输出:4000000

示例 5: 
输入:points = [[0,0]]
输出:0

提示: 
1 <= points.length <= 1000 
-106 <= xi, yi <= 106 
所有点 (xi, yi) 两两不同。 
 
Related Topics 并查集 

题目要求连接所有点,有且仅有一条路径,即最终结果是生成树。因为要求的是最小的总费用,便是最小生成树。我们可以采用前面总结的最小生成树算法来实现。
首先定义曼哈顿距离的计算函数:

def manhattanDistance(points, index1, index2):
    point1 = points[index1]
    point2 = points[index2]
    return abs(point1[0] - point2[0]) + abs(point1[1] - point2[1])

若采用 Kruskal 算法,我们需要将题目给定的一系列点转换成类 Kruskal 初始化需要的格式,并进行初始化,最后求的结果:

if __name__ == '__main__':
    points = [[0, 0], [2, 2], [3, 10], [5, 2], [7, 0]]
    n = len(points)
    edges = []
    for i in range(n):
        for j in range(i+1, n):
            edges.append(Edge(i, j, manhattanDistance(points, i, j)))

    s = Kruskal(edges, node_count=n)
    print(s.kruskal())

若采用 Prim 算法,则需要将题目给定的一系列点转换成邻接表的形式,最终求的结果:

if __name__ == '__main__':
    points = [[0, 0], [2, 2], [3, 10], [5, 2], [7, 0]]
    n = len(points)
    graph = [[0] * n for _ in range(n)]
    for i in range(n):
        for j in range(i+1, n):
            weight = manhattanDistance(points, i, j)
            graph[i][j] = weight
            graph[j][i] = weight

    s = Prim(graph)
    print(s.prim(0))

参考

  1. https://www.jianshu.com/p/c046fcaa190c
  2. https://www.cnblogs.com/dengfaheng/p/9245794.html

你可能感兴趣的:(最小生成树算法)