浅谈 DFS(回溯算法)Dijkstra 算法Bellman-Ford 算法

Posted ZSYL

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了浅谈 DFS(回溯算法)Dijkstra 算法Bellman-Ford 算法相关的知识,希望对你有一定的参考价值。

前言

选自LeetCode-787. K 站中转内最便宜的航班

题目描述

有 n 个城市通过 m 个航班连接。每个航班都从城市 u 开始,以价格 w 抵达 v。

现在给定所有的城市和航班,以及出发城市 src 和目的dst,你的任务是找到从 src 到 dst 最多经过 k 站中转的最便宜的价格。
如果没有这样的路线,则输出 -1。

本题是非常典型的图论问题,可以使用的算法有深度优先遍历(回溯算法)Dijkstra 算法Bellman-Ford 算法

DFS(回溯算法)

概念

维基百科中关于回溯算法的介绍是:

回溯算法(backtracking)是暴力搜索算法的一种。

这句话向我们揭示了回溯算法的用途:搜索

因此回溯算法也被称为回溯搜索算法。与“二分查找”、“线性查找”等“查找问题”不同的是,“搜索问题”完成一件事情有可能多种方法,而每一种方法又有多个步骤,回溯算法就是在不断尝试,以得到待求问题的全部的解。

回溯法与深度优先遍历的异同

  • 相同点
    回溯法在实现上也是遵循深度优先的,即一步一步往前探索,而不像广度优先那样,由近及远一片一片地扫。

  • 不同点

    • 1. 访问序
      深度优先遍历
      目的是“遍历”,本质是无序的。也就是说访问次序不重要,重要的是都被访问过了。
      深度优先只需要把从边界起始的’O’全部访问到即可。因此在实现上,只需要对于每个位置记录是否被visited就足够了。
      回溯法:
      目的是“求解过程”,本质是有序的。也就是说必须每一步都是要求的次序。
      因此在实现上,不能使用visited记录,因为同样的内容不同的序访问就会造成不同的结果,而不是仅仅“是否被访问过”这么简单。
      要使用访问状态来记录,也就是对于每个点记录已经访问过的邻居方向,回溯之后从新的未访问过的方向去访问邻居
      至于这点点之前有没有被访问过并不重要,重要的是没有以当前的序进行访问。

    • 2. 访问次数
      深度优先遍历
      已经访问过的节点不再访问,所有点仅访问一次。
      回溯法
      已经访问过的点可能再次访问,也可能存在没有被访问过的点。

剪枝操作

由于回溯算法的时间复杂度很高,因此,在遍历的时候,如果能够提前知道这一条分支不能搜索到满意的结果,这一分支就可以跳过,这一步操作就是在一棵树上剪去一个枝叶,被人们很形象地称之为剪枝

回溯算法会大量应用“剪枝”技巧达到以加快搜索速度。这里有几点提示:

1、有时,需要做一些预处理工作(例如排序)才能达到剪枝的目的。虽然预处理工作虽然也消耗时间,但和剪枝能够节约的时间相比是微不足道的。因此,能预处理的话,就尽量预处理;

2、正是因为回溯问题本身时间复杂度就很高,所以能用空间换时间就尽量使用空间

例题解答

对于该题使用回溯算法,访问每一个有航班的城市,访问经过该城市的路线后,即递归结束,或修改visited,因为可能也有其他路线要经过该点

代码

class Solution {
    int[][] graph;
    boolean[] visited;
    int res = Integer.MAX_VALUE;
    public int findCheapestPrice(int n, int[][] flights, int src, int dst, int K) {
        // 初始化图
        graph = new int[n][n];
        visited = new boolean[n];
        // 将航班信息存储图中
        for (int[] flight : flights)
            graph[flight[0]][flight[1]] = flight[2];
        
        // k+1包含src 后最大经过的城市数
        dfs(src, dst, K+1, 0);
        if (res == Integer.MAX_VALUE)
            return -1;
        return res;
    }
    public void dfs(int src, int dst, int k, int cost) {
        // 到达终点直接返回
        if (src == dst) {
            res = cost;
            return;
        }
        
        // 如果达到最大途径数,直接返回
        if (k == 0)
            return;
        
        // 遍历图,找到与src相连的点,即有航班
        for (int i = 0; i < graph[src].length; i++) {
            // 如果 sec 与 i有航班,并且 i没有访问过
            if (graph[src][i] > 0) {
                // 如果访问过就直接跳过
                if (visited[i])
                    continue;
                
                // 剪枝操作:跳过可能产生较高费用的路径,从而选出最少价格
                if (cost + graph[src][i] > res)
                    continue;
                
                visited[i] = true;
                dfs(i, dst, k-1, cost + graph[src][i]);
                // 最后递归完回溯
                visited[i] = false;  
            }
        }
    }
}

Dijkstra 算法

概念

Dijkstra(迪杰斯特拉)算法是典型的单源最短路径算法,用于计算一个节点到其他所有节点的最短路径。主要特点是以起始点为中心向外层层扩展,直到扩展到终点为止。Dijkstra算法是很有代表性的最短路径算法,在很多专业课程中都作为基本内容有详细的介绍,如数据结构,图论,运筹学等等。注意该算法要求图中不存在负权边。

问题描述:在无向图 G=(V,E) 中,假设每条边 E[i] 的长度为 w[i],找到由顶点 V0 到其余各点的最短路径。(单源最短路径)

算法描述
matrix[i][j]:代表i与j之间的权值,INF代表没有关联
visited[i]:代表起点到 i 顶点的最短距离已经获得
prev[i]:前驱顶点数组,即prev[i]的值是顶点vs到顶点i的最短路径所经历的全部顶点中,位于顶点i之前的那个顶点
dist[i]:长度数组,即 dist[i] 是顶点vs到顶点 i 的最短路径长度

每次都寻找当前未获得的顶点中距离vs最近的点,之后从该点继续延伸,找到vs经过该点后到下一个点距离

代码

package graph;

/**
 * Dijkstra最短路径:统计图中顶点V到其他顶点的最短路径
 * prev--前驱顶点数组,即prev[i]的值是顶点vs到顶点i的最短路径所经历的全部顶点中,位于顶点i之前的那个顶点
 * dist--长度数组,即 dist[i] 是顶点vs到顶点i的最短路径长度
 */

public class Digkstra {
    private int mEdgNum;  // 边的数目
    private char[] mVex;  // 顶点集合
    private int[][] mMatrix;  // 邻接矩阵
    private static final int INF = Integer.MAX_VALUE;  // 最大值

    public void dijkstra(int vs, int[] prev, int[] dist) {
        // flag[i] = true表示顶点vs到顶点i的最短路径已经成功获取
        boolean[] flag = new boolean[mVex.length];

        // 初始化
        for (int i = 0; i < mVex.length; i++) {
            flag[i] = false;  // 顶点i的最短路径还未获得
            prev[i] = 0;  	// 顶点的前驱顶点为 0
            dist[i] = mMatrix[vs][i];  // 顶点i的最短路径为顶点vs到顶点i的权值
        }

        // 对顶点vs自身进行初始化
        flag[vs] = true;
        dist[vs] = 0;

        // 遍历顶点数-1次,每次找出一个顶点的最短路径
        int k = 0;
        for (int i = 1; i < mVex.length; i++) {
            // 寻找当前最短路径
            // 在未获取的顶点中找到距离vs最近的顶点k
            int min = INF;
            for (int j = 0; j < mVex.length; j++) {
                if (!flag[j] && dist[j] < min) {
                    min = dist[j];
                    k = j;
                }
            }
            // 标记顶点k为获取最短路径
            flag[k] = true;

            // 修正当前最短路径和前驱顶点
            // 当已经得到顶点k的最短路径之后,更新未获取的顶点的最短路径和前驱顶点
            for (int j = 0; j < mVex.length; j++) {
                int tem = (mMatrix[k][j] == INF) ? INF : (min+mMatrix[k][j]);
                if (!flag[j] && tem < dist[j]) {
                    dist[j] = tem;
                    prev[j] = k;
                }
            }
        }
    }
}

例题解答

该题不需要使用visited数组和dis数组,因为有 K 在约束。

代码

class Solution {
    int[][] graph;
    boolean[] visited;
    int res = Integer.MAX_VALUE;
    public int findCheapestPrice(int n, int[][] flights, int src, int dst, int K) {
        // 初始化图
        graph = new int[n][n];
        visited = new boolean[n];
        // 将航班信息存储图中
        for (int[] flight : flights)
            graph[flight[0]][flight[1]] = flight[2];
        
        // 定义优先队列
        PriorityQueue<int[]> minHeap = new PriorityQueue<>(Comparator.comparingInt(a -> a[1]));
        // 向集合添加一个记录(起点,费用,站数限制)的数组,K+1 表示可以走过站点的个数
        minHeap.offer(new int[]{src, 0, K+1});

        while (!minHeap.isEmpty()) {
            int[] front = minHeap.poll();
            int v = front[0];
            int price = front[1];
            int k = front[2];

            if (v == dst) 
                return price;
            
            // 如果还可以中转其他战
            if (k > 0) {
                for (int i = 0; i < n; i++) {
                    // 并且存在一条有向边
                    if (graph[v][i] > 0) {
                        // 优先队列中存入:有向边指向的顶点 i、从起点 src 到 i 的总路径长度、还有多少站可以中转
                        minHeap.offer(new int[]{i, price+graph[v][i], k-1});
                    }
                }
            }
        }
        return -1;
    }
}

Bellman-Ford 算法

简介

贝尔曼-福特算法(英语:Bellman–Ford algorithm),求解单源最短路径问题的一种算法,由理查德·贝尔曼(Richard
Bellman) 和莱斯特·福特创立的。有时候这种算法也被称为 Moore-Bellman-Ford 算法,因为Edward F.Moore也为这个算法的发展做出了贡献。

它的原理是对图进行 V-1 次松弛操作,得到所有可能的最短路径。其优于迪科斯彻算法的方面是边的权值可以为负数、实现简单,缺点是时间复杂度过高,高达O(VE)。但算法可以进行若干种优化,提高了效率。

来源百度百科

算法

贝尔曼-福特算法与迪科斯彻算法类似,都以松弛操作为基础,即估计的最短路径值渐渐地被更加准确的值替代,直至得到最优解。

在两个算法中,计算时每个边之间的估计距离值都比真实值大,并且被新找到路径的最小长度替代。

然而,迪科斯彻算法以贪心法选取未被处理的具有最小权值的节点,然后对其的出边进行松弛操作;
而贝尔曼-福特算法简单地对所有边进行松弛操作,共 V-1 次,其中是图的点的数量。在重复地计算中,已计算得到正确的距离的边的数量不断增加,直到所有边都计算得到了正确的路径。这样的策略使得贝尔曼-福特算法比迪科斯彻算法适用于更多种类的输入。

最短路径

最短路径:是指连接图中两个顶点的路径中,所有边构成的权值之和最小的路径。

最短路径中不能包含负权回路,因为每次经过负权回路,路径的权值会减少,所以这种情况下不存在最短路径。有些图结构中会存在负权边,用于表达通过某条途径可以降低总消耗
在有向图中,负权边不一定会形成负权回路,所以在一些计算最短路径算法中,负权边也可以计算出最短路径;
在无向图中,负权边就意味着负权回路,所以无向图中不能存在负权边。

后续的所有讨论都设定图中不存在负权回路的情况。

摘自简书 Blog

松弛函数

对边集合 E 中任意边,以 w(u,v) 表示顶点 u 出发到顶点 v 的边的权值,以 d[v] 表示当前从起点 s 到顶点 v 的路径权值

若存在边 w(u,v),使得
d[v] > d[u] + w(u,v)

则更新 d[v] 值
d[v] = d[u]+w(u,v)

所以松弛函数的作用,就是判断是否经过某个顶点,或者说经过某条边,可以缩短起点到终点的路径权值。

为什么将缩短距离的操作称之为“松弛”,不妨理解为,选择某种方式后,到达目的的总代价降低了。什么名字无关紧要,不必纠结。

松弛函数代码示例

// 修正当前最短路径和前驱顶点
// 当已经得到顶点k的最短路径之后,更新未获取的顶点的最短路径和前驱顶点
for (int j = 0; j < mVex.length; j++) {
    int tem = (mMatrix[k][j] == INF) ? INF : (min+mMatrix[k][j]);
    if (!flag[j] && tem < dist[j]) {
        dist[j] = tem;
        prev[j] = k;
    }
}

例题解答

注意到题目限制 K 次中转,这件事情特别像「Bellman-Ford 算法」描述中的操作:「我们对图中的每条边执行顶点次数−1次松弛操作,看看多绕一个顶点会不会使得最短路径短」。

因此,我们使用 dp[i][j] 表示从顶点 src 到其它顶点 i 经过了 j 次松弛操作(也就是经过了 j 个顶点)以后得到的最短路径。

d[i][j]:也可以这样理解:是起点经过k个中转站后到达站 i 的最小费用

import java.util.Arrays;

public class Solution {

    public int findCheapestPrice(int n, int[][] flights, int src, int dst, int K) {
        int maxPrice = 1_000_000_000;

        int[][] dp = new int[n][K + 1];
        // 初始化 1:由于找最短路径,因此初始化的时候赋值成为一个不可能的较大的值
        for (int i = 0; i < n; i++) {
            Arrays.fill(dp[i], maxPrice);
        }
        // 自己到自己,不管经过几个顶点,最短路径都是 0,初始化dst = src的行
        for (int i = 0; i <= K; i++) {
            dp[src][i] = 0;
        }

        // 第 1 轮松弛操作,只需要对从 src 出发的边进行松弛操作
        // 利用flights中的信息初始化src可直达的班次
        for (int[] flight : flights) {
            if (flight[0] == src) {
                dp[flight[1]][0] = flight[2];
            }
        }

        // 第 2 轮到第 K + 1 轮松弛操作,最后一轮松弛操作是为了检测是否可达
        // 直达的已经初始化了(即k = 0的情况),现在从k = 1 的开始,即只有一个中转站开始
        for (int i = 1; i <= K; i++) {
            for (int[] flight : flights) {
                int from = flight[0];
                int to = flight[1];
                // 每一次松弛操作的结果是互相独立的,因此只有在上一轮(第 i - 1 轮)得到单源最短路径的顶点,才需要执行松弛操作
                // 判断src到from(当前班次的起点)经过i-1个城市,是否可以到达
                if (dp[from][i - 1] != maxPrice) {
                	// 更新src到to的经过i个城市的最小花费,比较src到from+from到to的班次的花费与src直接到to的最小花费.更新操作
                    dp[to][i] = Math.min(dp[from][i - 1] + flight[2], dp[to][i]);
                }
            }
        }

        // 如果不可达,返回 -1
        if (dp[dst][K] == maxPrice) {
            return -1;
        }
        return dp[dst][K];
    }
}

感谢 LeetCode大佬评论+官方题解

代码来自摘抄,学习而非商业用途,感谢大神!

加油!

以上是关于浅谈 DFS(回溯算法)Dijkstra 算法Bellman-Ford 算法的主要内容,如果未能解决你的问题,请参考以下文章

邻接表实现Dijkstra算法以及DFS与BFS算法

DFS ( 深度优先/回溯算法 ) 一

(王道408考研数据结构)第六章图-第三节:图的遍历(DFS和BFS)

算法整理-回溯和DFS

蓝桥杯 算法提高 8皇后·改 -- DFS 回溯

LeetCode 140. 单词拆分 II(回溯算法和DFS解决)