具体算法2 - Dijkstra-最短路径算法

本章关键词

最短路径、最优解、动态规划

问题解析

地图软件你一定用过,如果我想从 A 点走到 B 点,只需要输入相应的起止地点,软件就会帮你计算出最短的路径。你有没有想过这个功能是如何实现的?它的底层又是依赖什么算法呢?

算法解析

这个问题比较复杂,我们需要表现力比较强的数据结构支持,这里,毫无疑问我们选择,我们将一个分岔路口当做一个点,一条路当做一条边,路的行程距离是这条边的权重。综上,我们选择有向有权图这种数据结构。

选择好了数据结构,我们要分析一下使用什么样的算法来实现。先说结论,这个问题适合使用动态规划的思想解决,接下来我们来分析一下我们是如何一步一步确定使用动态规划的:

1.分析模型
任何问题都可以拆分成小问题来解决,如果各个小问题之间的独立性较强,我们使用分治思想;如果各个问题之间有很强的依赖关系,我们需要考虑贪婪、回溯、动态规划的思想。

很明显,一个路径不断延伸的路径就是根据上一步路径扩展而来,这是一个多阶段的决策模型。

对于各个到达终点的路径来说,总存在一个最优路径,所以这个问题存在最优解

没错,这就是一个多阶段最优解模型,对于这种模型,我们需要选择贪婪、回溯、动态规划的思想,而不能使用分治。

2.特点分析
根据问题的特点,我们可以判断它是否适合用某种思想解决:

  • 贪婪:贪婪最大的特点是需要满足局部最优解可以组成全局最优解,很明显,在这个问题中全局最优解并不一定是局部最优解的组合。所以不能使用贪婪。

  • 回溯:基本上符合多阶段最优解模型的问题就可以使用回溯的思想解决,这个问题也不例外。实际上,我们之前学了两种非常简单且暴力的搜索算法:广度优先搜索 和 深度优先搜索,其中后者就是使用了回溯的思想。但是他的时间复杂度是比较高的。
    当然,我们可以考虑使用“剪枝”的技巧减少计算量,这种技巧配合广度优先搜索可以提升一些执行效率。

  • 动态规划:动态规划需要问题具备三个特点:重复子问题、最优子结构、无后效性。后者是显而易见的,在这里就不多说了,我们着重聊聊前两个特点。
    重复子问题:到达一个点不一定只有一种解法,所以这个问题具有重复子问题的特点。对于有重复子问题的问题,关键是分析同一子问题可能出现的不同状态,并从中选取一个最佳状态。对于这个问题,最佳状态就是使用最小的代价到达这个顶点,对应到求解过程中,就可以剪掉非最小代价的路径。
    最优子结构:通过上面的讲解,你已经了解了如何选取最优解,所以自然而然地,你也知道该如何选取子结构的最优解。总结这样的求解过程,我们可以用状态转移方程描述:

min_distance(X) = min( min_distance(X-a) + distance(a->X) ,min_distance(X-b) + distance(b->X),... )

其中 X 代表目的地,a,b 代表上一个路口,distance(a->X) 代表从 a走到X 的路程。

3.算法实现
知道了如何选择最优状态,知道了状态转移方程,我们就可以很轻松地实现最短路径算法了。这里介绍一个最出名的最短路径算法:Dijkstra算法。
算法就不多解释了,我直接复制专栏作者的话给大家,相信你很容易就能看懂:
首先是定义的结构体(类)的代码:

public class Graph { // 有向有权图的邻接表表示
  private LinkedList<Edge> adj[]; // 邻接表
  private int v; // 顶点个数

  public Graph(int v) {
    this.v = v;
    this.adj = new LinkedList[v];
    for (int i = 0; i < v; ++i) {
      this.adj[i] = new LinkedList<>();
    }
  }

  public void addEdge(int s, int t, int w) { // 添加一条边
    this.adj[s].add(new Edge(s, t, w));
  }

  private class Edge {
    public int sid; // 边的起始顶点编号
    public int tid; // 边的终止顶点编号
    public int w; // 权重
    public Edge(int sid, int tid, int w) {
      this.sid = sid;
      this.tid = tid;
      this.w = w;
    }
  }
  // 下面这个类是为了dijkstra实现用的
  private class Vertex {
    public int id; // 顶点编号ID
    public int dist; // 从起始顶点到这个顶点的距离
    public Vertex(int id, int dist) {
      this.id = id;
      this.dist = dist;
    }
  }
}

然后是算法实现的代码:

// 因为Java提供的优先级队列,没有暴露更新数据的接口,所以我们需要重新实现一个
private class PriorityQueue { // 根据vertex.dist构建小顶堆
  private Vertex[] nodes;
  private int count;
  public PriorityQueue(int v) {
    this.nodes = new Vertex[v+1];
    this.count = v;
  }
  public Vertex poll() { // TODO: 留给读者实现... }
  public void add(Vertex vertex) { // TODO: 留给读者实现...}
  // 更新结点的值,并且从下往上堆化,重新符合堆的定义。时间复杂度O(logn)。
  public void update(Vertex vertex) { // TODO: 留给读者实现...} 
  public boolean isEmpty() { // TODO: 留给读者实现...}
}

public void dijkstra(int s, int t) { // 从顶点s到顶点t的最短路径
  int[] predecessor = new int[this.v]; // 用来还原最短路径
  Vertex[] vertexes = new Vertex[this.v];
  for (int i = 0; i < this.v; ++i) {
    vertexes[i] = new Vertex(i, Integer.MAX_VALUE);
  }
  PriorityQueue queue = new PriorityQueue(this.v);// 小顶堆
  boolean[] inqueue = new boolean[this.v]; // 标记是否进入过队列
  vertexes[s].dist = 0;
  queue.add(vertexes[s]);
  inqueue[s] = true;
  while (!queue.isEmpty()) {
    Vertex minVertex= queue.poll(); // 取堆顶元素并删除
    if (minVertex.id == t) break; // 最短路径产生了
    for (int i = 0; i < adj[minVertex.id].size(); ++i) {
      Edge e = adj[minVertex.id].get(i); // 取出一条minVetex相连的边
      Vertex nextVertex = vertexes[e.tid]; // minVertex-->nextVertex
      if (minVertex.dist + e.w < nextVertex.dist) { // 更新next的dist
        nextVertex.dist = minVertex.dist + e.w;
        predecessor[nextVertex.id] = minVertex.id;
        if (inqueue[nextVertex.id] == true) {
          queue.update(nextVertex); // 更新队列中的dist值
        } else {
          queue.add(nextVertex);
          inqueue[nextVertex.id] = true;
        }
      }
    }
  }
  // 输出最短路径
  System.out.print(s);
  print(s, t, predecessor);
}

private void print(int s, int t, int[] predecessor) {
  if (s == t) return;
  print(s, predecessor[t], predecessor);
  System.out.print("->" + t);
}

我们用 vertexes 数组,记录从起始顶点到每个顶点的距离(dist)。起初,我们把所有顶点的 dist 都初始化为无穷大(也就是代码中的 Integer.MAX_VALUE)。我们把起始顶点的 dist 值初始化为 0,然后将其放到优先级队列中。

我们从优先级队列中取出 dist 最小的顶点 minVertex,然后考察这个顶点可达的所有顶点(代码中的 nextVertex)。如果 minVertex 的 dist 值加上 minVertex 与 nextVertex 之间边的权重 w 小于 nextVertex 当前的 dist 值,也就是说,存在另一条更短的路径,它经过 minVertex 到达 nextVertex。那我们就把 nextVertex 的 dist 更新为 minVertex 的 dist 值加上 w。然后,我们把 nextVertex 加入到优先级队列中。重复这个过程,直到找到终止顶点 t 或者队列为空。

以上就是 Dijkstra 算法的核心逻辑。除此之外,代码中还有两个额外的变量,predecessor 数组和 inqueue 数组。

predecessor 数组的作用是为了还原最短路径,它记录每个顶点的前驱顶点。最后,我们通过递归的方式,将这个路径打印出来。打印路径的 print 递归代码我就不详细讲了,这个跟我们在图的搜索中讲的打印路径方法一样。如果不理解的话,你可以回过头去看下那一节。

inqueue 数组是为了避免将一个顶点多次添加到优先级队列中。我们更新了某个顶点的 dist 值之后,如果这个顶点已经在优先级队列中了,就不要再将它重复添加进去了。

下面的图展示了算法的具体执行过程:
Dijkstra算法.jpg

我自己使用python实现了这个算法,代码比较长,但是写了详细的注释,应该能帮助你理解:

class heap:
    def __init__(self, L=[], key=None):
        '''
        堆的构造函数,这个函数可以将一个列表改造成堆(小顶堆)
        :param L: 数据列表,可以为空
        :param key: 一个元素中用于表示优先级的值
        '''

        if key == None:  # 如果没有指定优先值,就默认元素为数字,以数字的大小为优先级
            self.key = lambda x: x
        else:
            self.key = key

        self.data = [None] + L.copy()  # 列表第一项设置为空,使用这种方法可以方便后续计算
        # (如l_child = n * 2,r_childd = n * 2 + 1,parent = n // 2 )
        self.last_index = len(self.data) - 1
        # 这个属性本来是为了简化代码用的,但是后来发现维护这个属性反而很麻烦,但是我又懒得修改代码,所以将就着看吧。

        self.creat_heap()

    def insert(self, node):
        '''
        进行插入操作,具体行为:将value放到数组的最后(将value添加为最后一个节点),随后从下往上堆化
        :param node: 插入的结点
        :return:None
        '''

        self.data.append(node)
        self.last_index = len(self.data) - 1
        added = self.last_index

        while True:
            parent = added // 2

            if parent == 0:
                break

            if self.key(self.data[added]) < self.key(self.data[parent]):
                self.data[added], self.data[parent] = self.data[parent], self.data[added]
                added = parent

            else:
                break

    def creat_heap(self):
        '''
        对已有的数据进行建堆,建堆有两种方法:不断插入数据建堆 和 从后往前堆化。
        :return:
        '''

        # 下面是使用插入的方法建堆
        # temp = self.data.copy()
        # self.data = []
        # self.last_index = 1
        # self.data.append(temp.pop(0))
        # for i in temp:
        #     self.insert(i)

        # 下面是使用从后往前堆化建堆
        root = self.last_index // 2  # 这是 第一个元素为空的完全二叉树的性质:最后一个节点(n)的父节点为 n//2
        while True:
            if root == 0:
                break

            self.heapify(root)

            root -= 1

    def heapify(self, x, last=None):
        '''
        从 x 节点开始进行堆化(从上往下),注意,条件是它的子树已经符合堆的定义
        :param x: 节点索引
        :return:None
        '''
        if not last:
            last = self.last_index

        n = x
        while True:
            l = n * 2
            r = n * 2 + 1
            min_index = n

            if last >= l and self.key(self.data[l]) < self.key(self.data[n]):
                min_index = l

            if last >= r and self.key(self.data[r]) < self.key(self.data[min_index]):
                min_index = r

            if min_index == n:
                break

            self.data[min_index], self.data[n] = self.data[n], self.data[min_index]
            n = min_index

    def pop(self):
        '''
        抛出堆顶元素,具体行为:将堆顶元素抛出,然后将最后一个节点移动放到第一个节点上,随后从第一个节点开始堆化
        :return: 堆顶元素
        '''

        if self.last_index == 0:  # 堆为空
            return None
        elif self.last_index == 1:  # 堆只有一个元素
            res = self.data.pop()
            self.last_index = len(self.data) - 1
            return res
        else:  # 堆有两个及以上元素
            res = self.data[1]
            self.data[1] = self.data.pop()
            self.last_index = len(self.data) - 1
            self.heapify(1)
            return res

    def update(self, vertex, dist):
        '''
        当到达某个顶点的最小距离更新的时候,使用这个函数对堆更新
        :param vertex: 图的节点
        :param dist: 到这个图的最小距离
        :return: None
        '''
        # print('进行update,data为',self.data)
        for data_index, V_node in enumerate(self.data):
            if data_index == 0:
                continue
            if vertex == V_node.vid:
                V_node.dis = dist
                self.heapify(data_index)

    def isEmpty(self):
        if len(self.data) == 1:
            return True
        else:
            return False


class Edge:
    '''
    描述了一条边
    '''

    def __init__(self, s, e, w):
        self.start = s
        self.end = e
        self.weight = w


class Vertex:
    def __init__(self, vid, dis):
        '''
        描述了一个顶点
        :param vid: 顶点值
        :param dis: 到达这个顶点的最短距离,也就是算法分析中的“状态”
        '''
        self.vid = vid
        self.dis = dis


class Graph:
    def __init__(self, n):
        self.v_list = [[] for i in range(n)]  # self.v是一个邻接表,保存的元素为 Edge

    def addEdge(self, s, e, w):
        '''
        添加一条边
        :param s: 起点
        :param e: 终点
        :param w: 权重
        :return: None
        '''

        new_Edge = Edge(s, e, w)
        self.v_list[s].append(new_Edge)

    def __repr__(self):
        return str(self.v_list)

    def dijkstra(self, s, e):
        '''
        dijkstra算法的实现
        :param s: 起点
        :param e: 终点
        :return: None
        '''

        # 这里我需要初始化一些需要用到的辅助变量
        dist_heap = heap([], key=lambda x: x.dis)  # 优先队列(小顶堆)
        vertex_and_dis = [Vertex(vid, 1024) for vid, edge_list in enumerate(self.v_list)]  # 创建一个列表,这个列表用于记录从起始点到该点的距离
        predecessor = [None for i in self.v_list]  # 记录前驱节点
        inqueue = [False for i in self.v_list]  # 记录一个点是否进入过队列

        start_vertex = vertex_and_dis[s]  # 为起点的设置距离,0
        start_vertex.dis = 0
        dist_heap.insert(start_vertex)

        while not dist_heap.isEmpty():
            '''
            1.从队列中取出一个节点,这个节点的含义:到达 vid 需要 dis 的距离,注意,根据代码逻辑,出队列的 dis 必定是最短距离
            2.求这个节点的所有可达节点,求到达这些节点的路径。如果可达节点在队列中,进行update,如果不在,进行添加
            3.重复2,直到终点节点出队列
            '''

            minVertex = dist_heap.pop()
            if minVertex.vid == e:
                break  # 已经到达最终节点

            for i in self.v_list[minVertex.vid]:  # i 为 minVertex 可以到达的所有节点,即 nextVertex
                nextVertex = vertex_and_dis[i.end]

                if minVertex.dis + i.weight < nextVertex.dis:#如果可以构成更短的路径,更新节点信息
                    nextVertex.dis = minVertex.dis + i.weight
                    predecessor[nextVertex.vid] = minVertex.vid

                    if inqueue[nextVertex.vid] == True:
                        dist_heap.update(nextVertex.vid, nextVertex.dis)
                    else:
                        dist_heap.insert(nextVertex)
                        inqueue[nextVertex.vid] = True

        self.print_path(predecessor, s, e)

    def print_path(self, path, s, e):
        '''
        输出路径信息
        :param path: 记录路径信息的数组
        :param s: 起始位置
        :param e: 结束位置
        :return: None
        '''
        if e == s:  # 到头了
            print(e, end='')
            return
        else:
            self.print_path(path, s, path[e])
            print('->' + str(e), end='')


if __name__ == '__main__':
    g = Graph(6)
    g.addEdge(0, 1, 10)
    g.addEdge(1, 2, 15)
    g.addEdge(2, 5, 5)
    g.addEdge(1, 3, 2)
    g.addEdge(3, 2, 1)
    g.addEdge(3, 5, 12)
    g.addEdge(0, 4, 15)
    g.addEdge(4, 5, 10)
    g.dijkstra(0, 5)

4.复杂度分析

在刚刚的代码实现中,最复杂就是 while 循环嵌套 for 循环那部分代码了。while 循环最多会执行 V 次(V 表示顶点的个数),而内部的 for 循环的执行次数不确定,跟每个顶点的相邻边的个数有关,我们分别记作 E0,E1,E2,……,E(V-1)。如果我们把这 V 个顶点的边都加起来,最大也不会超过图中所有边的个数 E(E 表示边的个数)。for 循环内部的代码涉及从优先级队列取数据、往优先级队列中添加数据、更新优先级队列中的数据,这样三个主要的操作。我们知道,优先级队列是用堆来实现的,堆中的这几个操作,时间复杂度都是 O(logV)(堆中的元素个数不会超过顶点的个数 V)。
所以,综合这两部分,再利用乘法原则,整个代码的时间复杂度就是 O(E*logV)。

总结

这个算法的思想来源于动态规划,所以在这里,我想结合动态规划聊一聊感想:

  • 能否使用动态规划,要看他是否符合一个模型,三个特点
  • 动态规划存在最优解,这个最优解就是最理想的状态,在规划到不同阶段时,都会有不同的状态,选择最理想状态是获取最优解的关键
  • 根据动态规划的最优解,和它的子问题,可以得出状态转移公式
  • 有了状态转移公式,你可以从前往后构造状态转移表,或者根据状态转移公式从后向前推导。当然,这不是绝对的,但是不论怎样,在使用动态规划中你必须要注意状态转移 和 转移公式!!!
  • 由于不知道哪个执行状态最终能构成最优解(或子问题的最优解),所以我们必须保持这些未知状态,只有遇到重复子问题的时候,才可以根据最优原则进行剪枝。
  • 在Dijkstra算法中,最有趣的是那个作为辅助变量的优先级队列。堆可以很快速地求得最大值(最小值),而动态规划中的最优解常常是状态值最大(最小)。这可以为动态规划算法的变形提供思路。

以上就是最短路径算法的全部内容,希望你能够有所收获

注:本文章的主要内容来自我对极客时间app的《数据结构与算法之美》专栏的总结,我使用了大量的原文、代码和截图,如果想要了解具体内容,可以前往极客时间

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 217,406评论 6 503
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,732评论 3 393
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 163,711评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,380评论 1 293
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,432评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,301评论 1 301
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,145评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,008评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,443评论 1 314
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,649评论 3 334
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,795评论 1 347
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,501评论 5 345
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,119评论 3 328
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,731评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,865评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,899评论 2 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,724评论 2 354

推荐阅读更多精彩内容