侧边栏壁纸
博主头像
LittleAO的学习小站 博主等级

在知识的沙漠寻找绿洲

  • 累计撰写 125 篇文章
  • 累计创建 27 个标签
  • 累计收到 0 条评论

目 录CONTENT

文章目录

数学建模系列(5)——图论和网格优化

LittleAO
2023-08-18 / 0 评论 / 0 点赞 / 6 阅读 / 0 字
温馨提示:
本文最后更新于2023-11-10,若内容或图片失效,请留言反馈。 部分素材来自网络,若不小心影响到您的利益,请联系我们删除。

第五章 图论和网格优化

最短路径

最短路径算法是一类用于找到图或网络中两个节点之间最短路径的算法。在图论中,最短路径指的是两个节点之间经过的边权重之和最小的路径。

最短路径算法有许多著名的算法实现,接下来会一一讲述。

G = np.array([[0, 1, 12, float('inf'), float('inf'), float('inf')],
  [float('inf'), 0, 9, 3, float('inf'), float('inf')],
  [float('inf'), float('inf'), 0,
   float('inf'), 5, float('inf')],
  [float('inf'), float('inf'), 4, 0, 13, 15],
  [float('inf'), float('inf'), float(
      'inf'), float('inf'), 0, 4],
  [float('inf'), float('inf'), float('inf'), float('inf'), float('inf'), 0]])

有向图无负权示例图

有向图无负权示例图

Dijkstra算法

迪杰斯特拉算法主要特点是以起始点为中心向外层扩展,直到扩展到终点为止。迪杰斯特拉算法采用的是贪心策略,将Graph中的节点集分为最短路径计算完成的节点集S和未计算完成的节点集T,每次将从T中挑选V0->Vt最小的节点Vt加入S,并更新V0经由VtT中剩余节点的更短距离,直到T中的节点全部加入S中,迪杰斯特拉算法只支持非负权图,它计算的是单源最短路径,即单个源点到剩余节点的最短路径。

def Dijkstra(G, v0):
    """使用Dijkstra算法,给定一个有权图G和起点v0,求v0到图G中任意点的最短路径

    Parameters
    ----------
    G : np.array
        有权图G
    v0 : int
        起点v0
    """
    T = list(range(len(G)))  # T为未求出最短路径的顶点集合
    S = []  # S为已求出最短路径的顶点集合
    T.remove(v0)  # 从T中移除起点v0
    S.append(v0)  # 将起点v0加入S
    D = G[v0]  # D[i]表示v0到i的最短路径长度
    P = [v0] * len(G)  # P[i]表示v0到i的最短路径上i的前一个顶点
    while len(T) != 0:
        min = np.inf
        for i in T:
            if D[i] < min:
                min = D[i]
                u = i
        T.remove(u)
        S.append(u)
        for i in T:
            if D[u] + G[u][i] < D[i]:
                D[i] = D[u] + G[u][i]
                P[i] = u
    return D, P

Floyd算法

Floyd算法又称为插点法,是一种利用动态规划的思想寻找给定的加权图中多源点之间最短路径的算法,与Dijkstra算法类似。

def Floyd(graph, n, start, end):
    """使用Floyd算法,给定一个有权图G,求图G中任意两点之间的最短路径

    Parameters
    ----------
    G : np.array
        有权图G
    v0 : int
        起点v0

    return
    ------
    dis : np.array
        D[i][j]表示i到j的最短路径长度
    path : np.array
        P[i][j]表示i到j的最短路径
    """
    graph_ = graph.copy()
    path_ = np.array([[None for i in range(n)] for j in range(n)])
    for i in range(n):
        for j in range(n):
            if graph_[i][j] == float('inf'):
                path_[i][j] = None
            else:
                path_[i][j] = i
    for k in range(n):
        for i in range(n):
            for j in range(n):
                if graph_[i][j] > graph_[i][k] + graph_[k][j]:
                    graph_[i][j] = graph_[i][k] + graph_[k][j]
                    path_[i][j] = path_[k][j]
    dis = graph_[start][end]
    path = []
    while start != end:
        path.append(end)
        end = path_[start][end]
    path.append(start)
    path.reverse()
    return dis, path

Bellman-Ford算法

BellmanFord算法功能:给定一个加权连通图,选取一个顶点,称为起点,求取起点到其它所有顶点之间的最短距离,其显著特点是可以求取含负权图的单源最短路径,可以来判断该图中是否有负权回路或者存在最短路径的点。

def Bellman_Ford(G, start, end):
    """Bellman-Ford算法, 用于计算单源最短路径, 适用于有向图, 可以处理负权边, 但不能处理负权环。

    Parameters
    ----------
    G : np.array
        图的邻接矩阵, G[i][j]表示从i到j的权值, 无边用np.inf表示,可以为负数。
    start : int
        源点。
    end : int
        终点。

    return
    ------
    dis : np.array
        源点到终点的最短距离。
    path : np.array
        源点到终点的最短路径。
    """
    # 初始化
    dis = np.array([np.inf] * len(G))
    dis[start] = 0
    path_ = np.array([start] * len(G))
    # 迭代
    for i in range(len(G) - 1):
        for j in range(len(G)):
            for k in range(len(G)):
                if dis[j] > dis[k] + G[k][j]:
                    dis[j] = dis[k] + G[k][j]
                    path_[j] = k
    # 检测负权环
    for i in range(len(G)):
        for j in range(len(G)):
            if dis[j] > dis[i] + G[i][j]:
                print('存在负权环')
                return
    path = []
    path.append(end)
    while path_[end] != start:
        path.append(path_[end])
        end = path_[end]
    path.append(start)
    path.reverse()
    return dis[end], path
G = np.array([[0, 1, 12, np.inf, np.inf, np.inf],
[np.inf, 0, 9, -3, np.inf, np.inf],
[np.inf, np.inf, 0, np.inf, 5, np.inf],
[np.inf, np.inf, 4, 0, 13, -15],
[np.inf, np.inf, np.inf, np.inf, 0, -4],
[np.inf, np.inf, np.inf, np.inf, np.inf, 0]])

带负权的有向图

带负权的有向图

G = np.array([[0, 1, np.inf, np.inf, np.inf, np.inf],
[np.inf, 0, 9, -3, np.inf, np.inf],
[-12, np.inf, 0, np.inf, 5, np.inf],
[np.inf, np.inf, 4, 0, 13, -15],
[np.inf, np.inf, np.inf, np.inf, 0, -4],
[np.inf, np.inf, np.inf, np.inf, np.inf, 0]])

含有负权环

含有负权环

网络的最大流问题

什么是最大流

如图是一个有向图,其中s为起点,t为终点,每条边代表的数字是最大流量。也就是说把一条条边比作运水的管道,标注为4的就是这个管道的最大流量为4.而求解最大流问题,就是要求解从s到t的最大流量。

notion image

我们将实际流量用红色进行标注,很容易判断出当前网络中的流量为5。

notion image

阻塞流是一种特殊情况,它能将图中的管道都堵住,但却不是最大流。例如右图的情况。你不能再添加更多流量从起点到终点。最大流也是一种阻塞流。

notion image

以下是寻找阻塞流的简单算法:

  • 首先生成一个全为空闲流量的图,此时空闲流量等于一开始的容量。
  • 一直循环,直到没有从起点到终点的简单路径:
    • 找到一条从起点到终点简单路径;
    • 找到该路径中空闲量最小的哪一条边,空闲量记为x
    • 把简单路径中的所有边减去这个x,意味着这条路径的空闲量因为流过了x的水减少了;
  • 最后将容量减去空闲量,就能得出现在网络中阻塞流的流量了。

请注意,这种算法只能找出阻塞流,不一定能够找出最大流。

Ford-Fulkerson算法

L.R.Ford and D.R.Fulkerson. Maximal flow through a network. Canadian Journal ofMathematics, 8:399-404,1956.

该算法能够求解出最大流。

该算法与之前的算法类似,只不过添加了一步:

  • 首先生成一个全为空闲流量的图,此时空闲流量等于一开始的容量。
  • 一直循环,直到没有从起点到终点的简单路径:
    • 找到一条从起点到终点简单路径;
    • 找到该路径中空闲量最小的哪一条边,空闲量记为x
    • 把简单路径中的所有边减去这个x,意味着这条路径的空闲量因为流过了x的水减少了;
    • 生成一条路径与该简单路径相反的路径,每条边的权重是x
  • 将我们生成的所有反向路径删掉。
  • 最后将容量减去空闲量,就能得出现在网络中阻塞流的流量了。
import numpy as np
# 最大流算法

def BFS(C, F, s, t):
    """广度优先搜索,寻找简单路径

    Parameters
    ----------
    C : list
        邻接矩阵
    F : list
        流量矩阵
    s : int
        起点
    t : int
        终点

    Returns
    -------
    list
        简单路径
    """
    queue = [s]
    paths = {s: []}
    if s == t:
        return paths[s]
    while queue:
        u = queue.pop(0)
        for v in range(len(C)):
            if (C[u][v] - F[u][v] > 0) and v not in paths:
                paths[v] = paths[u] + [(u, v)]
                if v == t:
                    return paths[v]
                queue.append(v)
    return None

def FordFulkerson(C, s, t):
    """Ford-Fulkerson算法,寻找最大流。

    Parameters
    ----------
    C : list
        邻接矩阵
    s : int
        起点
    t : int
        终点

    Returns
    -------
    int
        最大流
    """
    n = len(C)  # C是邻接矩阵
    F = [[0] * n for i in range(n)]  # F是流量矩阵
    path = BFS(C, F, s, t)
    while path is not None:
        flow = min(C[u][v] - F[u][v] for u, v in path)
        for u, v in path:
            F[u][v] += flow  # 正向流量增加
            F[v][u] -= flow  # 反向流量增加
        path = BFS(C, F, s, t)
    return sum(F[s][i] for i in range(n))

# 例子
if __name__ == '__main__':
    C = [[0, 4, 2, 0, 0, 0],
         [0, 0, 1, 2, 4, 0],
         [0, 0, 0, 0, 2, 0],
         [0, 0, 0, 0, 0, 3],
         [0, 0, 0, 0, 0, 3],
         [0, 0, 0, 0, 0, 0]]
    print(FordFulkerson(C, 0, 5))  # 5

Dinic算法

13-4: Dinic’s Algorithm 寻找网络最大流_哔哩哔哩_bilibili

Yefim Dinitz. Algorithm for solution of a problem of maximum flow in a network with power estimation. Proceedings of the USSR Academy of Sciences, 11:1277-1280,1970.

该算法比Ford-Fulkerson算法更快。

最小费用最大流

最小费用流

假设我们给定的图中,每一条管道(边)不仅有容量,还有途径该管道的费用(权值)。现在我们想要找到一条路径连接起点和终点,应该如何寻找使得总花费最小?

很容易想到的一种方法是,我们使用最短路径算法找到连接起点和终点的最短路径。这里我们使用Bellman-Ford算法,因为该算法能够处理负权边,找到的最短路径就是我们要找的最短费用流。

最小费用最大流

如果我们想要找到花费最小的最大流,需要结合之前学习的最短路径算法和最大流算法,具体做法就是在最大流算法寻找简单路径时,将简单路径替换成最短路径。这样我们最终寻找的是最小费用最大流。注意,增加反向道路时,反向道路的权值为正向道路花费的负数,这样就体现了我们Bellman-Ford算法的好处。

# 定义边结构
class Edge:
        """Edge类的初始化函数,用于初始化Edge类的各个属性。

        Parameters
        ----------
        u : int
            边的起点
        v : int
            边的终点
        c : int
            边的容量
        w : int
            边的花费
        """
    def __init__(self, u, v, c, w):
        self.u = u
        self.v = v
        self.c = c
        self.w = w

if __name__ == '__main__':
    # 例子
    n = 6   # 顶点数
    m = 8   # 边数
    s = 0   # 源点
    t = 5   # 汇点
    # 边集
    edges = []
    edges.append(Edge(0, 1, 4, 3))
    edges.append(Edge(0, 2, 2, 6))
    edges.append(Edge(1, 2, 1, 10))
    edges.append(Edge(1, 3, 2, 7))
    edges.append(Edge(1, 4, 4, 2))
    edges.append(Edge(2, 4, 2, 8))
    edges.append(Edge(3, 5, 3, 7))
    edges.append(Edge(4, 5, 3, 6))

黑色为容量,蓝色为花费

黑色为容量,蓝色为花费

最小生成树

在无向图中,生成树代表着的是能够联通所有节点所用的不成环的树。根据树的定义,当图中存在n个顶点的时候,必定存在n-1边组成一棵树。而最小生成树代表着总权值最小的一颗生成树。

class Edge:
    """生成一条边,包含起点,终点,权重,用于构建图。

        Parameters
        ----------
        start : int
            起点
        end : int
            终点
        weight : int
            权重
        """
    def __init__(self, start, end, weight):
        
        self.start = start
        self.end = end
        self.weight = weight

if __name__ == "__main__":
    edges = []
    edges.append(Edge(0, 1, 4))
    edges.append(Edge(0, 7, 8))
    edges.append(Edge(1, 2, 8))
    edges.append(Edge(1, 7, 11))
    edges.append(Edge(2, 3, 7))
    edges.append(Edge(2, 8, 2))
    edges.append(Edge(2, 5, 4))
    edges.append(Edge(3, 4, 9))
    edges.append(Edge(3, 5, 14))
    edges.append(Edge(4, 5, 10))
    edges.append(Edge(5, 6, 2))
    edges.append(Edge(6, 7, 1))
    edges.append(Edge(6, 8, 6))
    edges.append(Edge(7, 8, 7))

示例图

示例图

示例图的最小生成树

示例图的最小生成树

Kruskal算法

kruskal算法先对每条边的权重进行排列。然后从小到大依次将边加入到去掉所有边的图中。如果加入的这个边连成了环,那么就删除这条边。直到有n-1条边加入了图中。这时候生成的树就是我们所得到的最小生成树。

def kruskal(edges):
    """最小生成树算法,使用kruskal算法。

    Parameters
    ----------
    edges : list
        边的集合,每个元素是一个Edge对象

    Returns
    -------
    list
        最小生成树的边集合
    """
    edges.sort(key=lambda x: x.weight)
    parent = [i for i in range(len(edges))]
    result = []
    for edge in edges:
        if find(parent, edge.start) != find(parent, edge.end):
            union(parent, edge.start, edge.end)
            result.append(edge)
    return result

def find(parent, index):
    """查找index的根节点

    Parameters
    ----------
    parent : list
        parent[i]表示节点i的父节点
    index : int
        要查找的节点

    Returns
    -------
    int
        index的根节点
    """
    if parent[index] != index:
        parent[index] = find(parent, parent[index])
    return parent[index]

def union(parent, index1, index2):
    """合并index1和index2所在的集合

    Parameters
    ----------
    parent : list
        parent[i]表示节点i的父节点
    index1 : int
        节点1
    index2 : int
        节点2
    """
    parent[find(parent, index1)] = find(parent, index2)

Prim算法

Prim算法与Kruskal算法不同的是,它将主要的操作对象从边移到了顶点。Prim先选中一个顶点加入一个已查找集合,找到与它连接最小权值的边,将对应的边的另一个顶点加入集合,再次寻找这个集合中相邻的最小权值的边,直到将所有顶点加入集合,算法结束。

def prim(edges):
    """Prim算法,返回最小生成树的边集合。

        Parameters
        ----------
        edges : list
            边集合

        Returns
        -------
        list
            最小生成树的边集合
        """
    remain = set()
    for edge in edges:
        remain.add(edge.start)
        remain.add(edge.end)
    # 用于存储最小生成树的边集合
    result = []
    # 用于存储已经加入最小生成树的顶点
    added = [0]
    remain.remove(0)
    # 用于存储未加入最小生成树的顶点
    while len(remain) > 0:
        # 用于存储权重最小的边
        min_edge = None
        for edge in edges:
            if (edge.start in added and edge.end in remain) or (edge.end in added and edge.start in remain):
                if min_edge is None:
                    min_edge = edge
                elif min_edge.weight > edge.weight:
                    min_edge = edge
        result.append(min_edge)
        if min_edge.start in added:
            added.append(min_edge.end)
            remain.remove(min_edge.end)
        else:
            added.append(min_edge.start)
            remain.remove(min_edge.start)
    return result

旅行商问题(TSP)

旅行商问题通俗而言它是指对于给定的一系列城市和每对城市之间的距离,找到访问每一座城市仅一次并回到起始城市的最短回路。

该问题的求解时间复杂度不是多项式时间,比较复杂。这种类型的题目与最小生成树有异曲同工之妙,但是这里要求的路径必须是一条回路,比最小生成树更加复杂。

旅行商问题可以用动态规划的思想进行求解。下面用一道例题来解释。

动态规划解法

class Edge:
    def __init__(self, u, v, w):
        """边类,用于表示两个城市之间的距离,u和v为城市编号,w为距离。

        Parameters
        ----------
        u : int
            城市编号
        v : int
            城市编号
        w : int
            距离
        """
        self.u = u
        self.v = v
        self.w = w

class Graph:
    def __init__(self, n):
        """图类,用于表示城市之间的距离。

        Parameters
        ----------
        n : int
            城市数量
        """
        self.n = n
        self.edges = []
    
    def add_edge(self, u, v, w):
        """添加边,为无向图,所以添加两次。

        Parameters
        ----------
        u : int
            城市编号
        v : int
            城市编号
        w : int
            距离
        """
        self.edges.append(Edge(u, v, w))
        self.edges.append(Edge(v, u, w))

		def get_edge(self, u, v):
        """获取边长。

        Parameters
        ----------
        u : int
            城市编号
        v : int
            城市编号

        Returns
        -------
        Edge
            边
        """
        for edge in self.edges:
            if edge.u == u and edge.v == v:
                return edge.w
        return float('inf')

if __name__ == '__main__':
    g = Graph(5)
    g.add_edge(0, 1, 3)
    g.add_edge(0, 3, 8)
    g.add_edge(0, 4, 9)
    g.add_edge(1, 2, 3)
    g.add_edge(1, 3, 10)
    g.add_edge(1, 4, 5)
    g.add_edge(2, 3, 4)
    g.add_edge(2, 4, 3)
    g.add_edge(3, 4, 20)

示例

示例

答案

答案

旅行商问题(动态规划方法,超级详细的)_仁者乐山智者乐水的博客-CSDN博客

假设d(i,V)表示总顶点i出发经过V(点集)中的各个顶点,每个顶点只经过一次,最后回到出发点s的最短路径长度。

分类讨论:

  • V为空,d(i,V)=0,表示不需要出发就直接从原点回到了原点。

  • V不为空,变成了对子问题的最优求解:

    d(i,V)=\min\{C_{ik}+d(k,V-(k))\}

这时我们已经将边界条件和状态转移方程求出来了,下面来分析求解路径。已d(0,\{1,2\})为例:

notion image

V集的所有节点都要从边界条件进行求解,所有求解结果加入到V集中等待下一次求解。

分析数据结构dp表,设dp[i][j]中为d(i,V)中的i,j为d(i,V)中的j,j从空集开始依次打表,知道我们求解出最终结果。

def TSP(Graph, s):
    """旅行商问题,使用动态规划的方法,时间复杂度为O(n^2*2^n)。

    Parameters
    ----------
    Graph : Graph
        图
    s : int
        起始城市编号

    Returns
    -------
    int
        最短路径长度
    """
    n = Graph.n
    # dp[i][j]表示从城市i出发,经过集合j中的城市,最后回到起点的最短路径长度
    dp = [[float('inf')] * (1 << n) for _ in range(n)]
    # 初始化,从城市i出发,经过集合j中的城市,最后回到起点的最短路径长度为从城市i到城市j的距离
    for i in range(n):
        dp[i][1 << i] = Graph.edges[i].w
    # 遍历所有集合
    for j in range(1, 1 << n):
        # 遍历所有城市
        for i in range(n):
            # 如果城市i在集合j中
            if j & (1 << i):
                # 遍历所有城市
                for k in range(n):
                    # 如果城市k在集合j中
                    if j & (1 << k):
                        # 更新dp[i][j]
                        dp[i][j] = min(
                            dp[i][j], dp[k][j - (1 << i)] + Graph.get_edge(k, i))
    # 遍历所有城市,找到最短路径
    return dp[s][(1 << n) - 1]

遗传算法

对于多数据的处理,动态规划算法的时间复杂度过高,这时我们可以采用高级算法,例如遗传算法。

https://github.com/Agwave/Artificial-Intelligence-Course-Algorithm/blob/master/cities.txt

这个项目仅需提供不同城市的坐标,根据欧式几何就能求解出TSP问题。

图着色问题

来看下面一个问题:

给定无向连通图G=(V,E)m种不同的颜色,用这些颜色为图G的各顶点着色,每个顶点着一种颜色。

在这里引出一个定理:四色定理,每个平面地图都可以只用四种颜色来染色,而且没有两个邻接的区域颜色相同。放在无向连通图就是只需四种颜色可以完成边相邻的节点颜色不同。

回溯法

notion image

# 图的着色问题,回溯法
def coloring(G, k, vcolor):
    """图的着色问题,回溯法

    Parameters
    ----------
    G : list
        图的邻接矩阵
    k : int
        当前处理的节点
    vcolor : list
        存放当前节点的颜色

    Returns
    -------
    list or bool
        返回可行解,如果没有解则返回False
    """
    n = len(G)
    if k == n:
        return vcolor
    else:
        for color in range(1, m + 1):
            if is_ok(G, k, vcolor, color):
                vcolor[k] = color
                result = coloring(G, k + 1, vcolor)
                if result:
                    return result
                else:
                    vcolor[k] = 0
        return False

def is_ok(G, k, vcolor, color):
    """判断当前节点是否可以着色

    Parameters
    ----------
    G : list
        图的邻接矩阵
    k : int
        当前处理的节点
    vcolor : list
        存放当前节点的颜色
    color : int
        当前节点的颜色

    Returns
    -------
    bool
        是否可以着色
    """
    for i in range(k):
        if G[i][k] == 1 and vcolor[i] == color:
            return False
    return True

if __name__ == '__main__':
    G = [[0, 1, 1, 1, 0, 0],
         [1, 0, 1, 0, 1, 1],
         [1, 1, 0, 1, 0, 1],
         [1, 0, 1, 0, 1, 0],
         [0, 1, 0, 1, 0, 1],
         [0, 0, 1, 0, 1, 0]]
    m = 3
    vcolor = [0] * len(G)
    if coloring(G, 0, vcolor):
        print(vcolor)
    else:
        print('No solution.')
0

评论区