算法的基本数据结构-图专题

图论,Graph[ɡræf] Theory,是数学的一个分支。

图,是由若干给定的点及连接两点的线所构成的图形,通常用来描述某些事物之间的某种特定关系,用点代表事物,用连接两点的线代表两个事物间具有这种关系。

图是一种最复杂的数据结构,前面所说的各类数据结构,都可以看作是图的特例,既然如此,直接用图就好,为啥还要分这么多类型?

这是因为,大多时候,我们不需要用到这么复杂的功能,与其称之为图的xx特例,不如干脆分离出来。

数据结构是为算法服务的,其目的是为了更高效地存取数据,那么,什么类型的数据适合用图来存储呢?答案很简单,如果你用其他数据结构无法很好地进行存储,就用图
举个栗子:如果我们要存储一种双向的朋友关系,并且这种朋友关系是多对多的,那就要用到图,因为其他数据结构无法很好的模拟。

图,分为 无向图(Undirected Graph)& 有向图(Deriected Graph)

无向图,比如前面提到的二叉树;
有向图,以下重点介绍。

有向图
如果两个事务间的关系是有方向的,就是有向图,反之为无向图;
比如,A认识B,但是B不一定认识A,如果用无向图存储就无法得知到底是A认识B,还是B认识A。

习惯上,我们画图时用箭头标识方向,即,带箭头的为有向图,反之为无向图。

有权图(Weighted Graph)& 无权图(Unweighted Graph)
如果边是有权重的,则为有权图(带权图),反之为无权图(不带权图),该如何理解权重呢?比如,汇率,就是一种有权重的逻辑图。货币A,1个,可以兑换 货币B,7个,那么A和B的边的权重就是7,而上面提到的,朋友关系,这种就属于无权图。

入度(inDegree)&出度(outDegree)
有多少条边指向节点N,那么节点N的入度就是多少;同理,有多少条边从节点N出发,那么节点N的出度就是多少。
如下图所示,所有节点的入度和出度都为 1。

有向图-示例

路径&环

  • 有环图(Cyclic['saɪklɪk] Graph),上面的图就是一个有环图,因为我们可以从任一节点出发,最终回到起点;
  • 无环图(Acyclic[ˌeɪ'saɪklɪk] Graph):参考 二叉树 结构;

连通图&强连通图

  • 无向图中,若任意两个顶点 i 与 j 都有路径相通,则称该无向图为连通图
  • 有向图中,若任意两个顶点 i 与 j 都有路径相通,则称该有向图为强连通图

生成树
一个连通图的生成树是指:一个连通子图,虽然包含构成图的全部n个顶点,但却只有构成一棵树的 n-1 条边,并且,有且仅有 n-1 条边,如果生成树再添加一条边,则必定构成环。
在连通图的所有生成树中,所有边的【代价和最小】的生成树,称为最小生成树,代价和,指所有边的 权重和。

图的建立
一般来说,LC中的图相关题目,都不会给你一个现成的图的数据结构,所以,当你意识到这是一个图类型的题目时,解题的第一步通常是,建图
我们知道,图是由点和边组成的,所以我们只要存储图中的所有边的关系即可,因为边中已经包含了两个点的关系。

两种常见的建图方式:邻接矩阵(常用且重要)& 邻接表

邻接矩阵(Adjacency[ə'dʒeɪsənsɪ] Matrixs)
可以使用数组或哈希表来存储图,这里我们使用二维数组。
用一个 n*n 的矩阵来描述图 graph,其中 graph[i][j] 用来描述边的关系。

一般而言,对于无权图,我们可以使用 graph[i][j] = 1 来表示 顶点 i 和顶点 j 之间有一条边,且边的指向是从 i 到 j;
graph[i][j] = 0 来表示 顶点 i 和顶点 j 之间不存在一条边;
对于有权图来说,我们可以存储其他数字,用来表示权重。

邻接矩阵的优点主要有:

  • 直观,使用起来比较简单;
  • 判断两个顶点是否连接,获取入度、出度,及更新度数,时间复杂度都是 O(1);
    因此,如无特殊必要,所有需要建图的题目都可以都用这种方式。

典型题目-LC-743. 网络延迟时间:

    // 743. 网络延迟时间
    function networkDelayTime($times, $n, $k) {
        $graph = [];
        foreach ($times as $sub) {
            //建立邻接矩阵:From -  To: time
            $graph[$sub[0]][$sub[1]] = $sub[2];
        }
        $timeCost = $this->dijkstra($graph, $n, $k);
        return $timeCost;
    }
    // dijkstra 不适用于带 负权 的计算;具体见如下代码
    function dijkstra($graph, $n, $k) {
        $got = []; // 已获取的,从 k 到 可到达的目标点 v(包含k自身) 的最短路径集合
        $dist = []; // 从 k 到 目标点 v(包含k自身) 的最短路径的权值,即 网络延迟 的最短时间;
        for ($i=1; $i <= $n; $i++) { 
         $dist[$i] = PHP_INT_MAX; // 初始均设为最大值        }
         $dist[$k] = 0; // k 到达自身的最短时间为 0
        $loopCnt = 0;
        while ($loopCnt < $n) {
            // 本次循环得到的【可到达的】最短路径节点,节点为正数,所以 -1 即可;
            $toNode = -1;
            for ($i=1; $i <= $n; $i++) { 
                if (isset($got[$i])) {
                    // 已获取最短路径的节点,跳过
                    continue;
                }
                if ($toNode == -1 || $dist[$i] < $dist[$toNode]) {
                    $toNode = $i;
                }
            }

            $got[$toNode] = 1; // 标记本次获取的最短路径节点放入大集合,下次不再循环

            // 重新计算并入本次获取的最短路径节点后的到达时间
            for ($i=1; $i <= $n; $i++) {
                if (!isset($graph[$toNode][$i])) {
                    // 如果未设置,则代表 本次获取的节点 不可直接到达 i 节点
                    continue;
                }
                // k 到本次获取的最短节点时间 + 本次节点到 i 的时间,即经过本次节点到 i 的最短时间
                $dist[$i] = min($dist[$i], $graph[$toNode][$i] + $dist[$toNode]);
            }
            $loopCnt++;
        }
        $timeCost = max($dist);
        if ($timeCost == PHP_INT_MAX) {
            return -1;
        }
        return $timeCost;
    }

图的遍历

常见的有两种方法:
深度优先遍历(Depth First Search, DFS);
广度优先搜索(Breadth First Search, BFS);
不管是哪种遍历方式, 如果图有环,一定要记录节点的访问情况(visited),防止死循环
DFS 和 BFS 只是一种算法思想,不属于具体的算法,因此其有着很强的适应性,本文讲的图可以用,前文讲的树也可以用。实际上, 只要是非线性的数据结构都可以借用。

常见算法
图类型的题目比较适合套模板,这里介绍几种常见的板子题。主要有:

Dijkstra
Floyd-Warshall
最小生成树(Kruskal & Prim)
A 星寻路算法
二分图(染色法)〔Bipartitie〕
拓扑排序〔Topological Sort〕

这里简单介绍其中一个经典算法:Dijkstra 算法
Dijkstra 算法,俗称,最短距离 或 最短路径 算法;
Dijkstra 这个名字比较难记,可以简单记为DJ 算法。

基本思想是广度优先遍历。实际上搜索的最短路算法基本思想都是广度优先,只不过具体的扩展策略不同而已;
主要解决的是图中任意一点到图中另外任意一个点的最短距离,即单源最短路径。

比如给你几个城市,以及城市之间的距离。让你规划一条最短的从城市 a 到城市 b 的路线。
这个问题,我们就可以先将城市间的距离用图建立出来,然后使用 dijkstra 来做。

DJ 算法的基本思想是贪心。从起点 start 开始,每次都遍历所有邻居,并从中找到距离最小的,本质上是一种广度优先遍历。这里我们借助堆这种数据结构,使得可以在 logN 的时间内找到 cost 最小的点

而如果使用普通的队列的话,其实是图中所有边权值都相同的特殊情况。
比如我们要找从点 start 到点 end 的最短距离。我们期望 dj 算法是这样被使用的:
有如下类型的图:

E -- 1 --> B -- 1 --> C -- 1 --> D -- 1 --> F
 \                                         /\
  \                                        ||
    -------- 2 ---------> G ------- 1 ------

构造邻接矩阵:

G = {
    "B": [["C", 1]],
    "C": [["D", 1]],
    "D": [["F", 1]],
    "E": [["B", 1], ["G", 2]],
    "F": [],
    "G": [["F", 1]],
}
shortDistance = dijkstra(G, "E", "C")
print(shortDistance)  # E -- 3 --> F -- 3 --> C == 6

具体思路:

  • 初始化堆。堆里的数据都是 (cost, v) 的二元祖,其含义是“从 start 走到 v 的距离是 cost”。因此初始情况,堆中存放元组 (0, start);
  • 从堆中 pop 出来一个 (cost, v),第一次 pop 出来的一定是 (0, start)。 如果 v 被访问过了,那么跳过,防止环的产生;
  • 如果 v 是 我们要找的终点,直接返回 cost,此时的 cost 就是从 start 到 该点的最短距离;
  • 否则,将 v 的邻居入堆,即将 (neibor, cost + c) 加入堆。其中 neibor 为 v 的邻居, c 为 v 到 neibor 的距离(也就是转移的代价);
  • 重复执行 2 - 4 步

代码可参考上面的 LC-743. 网络延迟时间

Floyd-Warshall 算法
算法主要思路很简洁,可以参考数学中的交换律:
如果 a->b 且 b->c,则a->c;
如果 a 能到b,b能到c,则a可以到c;
如果是带权图,则上面的判断逻辑追加条件即可,如果取最小路径,可以加判断:【a->b->c 】< 【a->c 】,再赋值即可。
代码主要逻辑为 3 层循环;
伪代码(JavaScript):

function floydWarshall (graph, v) {
  const dist = new Array(v).fill(0).map(() => new Array(v).fill(Number.MAX_SAFE_INTEGER));
  for(let i = 0; i < v; i++){
    for(let j = 0; j < v; j++){
      //两个点相同,距离为0
      if(i === j) dist[i][j] = 0;
      //i 和 j 的距离已知
      else if(graph[i][j]) dist[i][j] = graph[i][j];
      //i 和 j 的距离未知,默认是最大值
      else dist[i][j] = Number.MAX_SAFE_INTEGER;
    }
  }

  //检查是否有一个点 k 使得 i 和 j 之间距离更短,如果有,则更新最短距离
  for(let k = 0; k < v; k++){
    for(let i = 0; i < v; i++){
      for(let j = 0; j < v; j++){
        dist[i][j] = Math.min(dist[i][j], dist[i][k] + dist[k][j])
      }
    }
  }
  return dist;
}

总结

理解图的常见概念,我们就算入门了。接下来就可以做题了。
一般的图题目有两种,一种是搜索题目,一种是动态规划题目

对于搜索类题目:

  • 第一步都是建图
  • 第二步都是基于第一步的图进行遍历以寻找可行解
  • 如果题目说明了是无环图,我们可以不使用 visited 数组,否则大多数都需要 visited 数组。当然也可以选择原地算法减少空间复杂度,具体的搜索技巧会在专题篇的搜索篇进行讨论。

图的题目相对而言比较难,尤其是代码书写层面。

  • 但,搜索类型的图题目,一般套模板就可以解决。因此大家可以多练习模板,多手动敲,确保自己能敲出来。

对于动态规划题目:
一个经典的例子就是 Floyd-Warshall 算法,理解好了之后大家不妨拿 LC-787. K 站中转内最便宜的航班 练习一下。当然这要求大家应该先学习动态规划。

常见的图的板子题有以下几种:

  • 最短路:算法有 DJ 算法, floyd 算法 和 bellman 算法。这其中有的是单源算法,有的是多源算法,有的是贪心算法,有的是动态规划。
  • 拓扑排序:拓扑排序可以使用 bfs ,也可以使用 dfs。相比于最短路,这种题目属于知道了就简单的类型。
  • 最小生成树:最小生成树是这三种题型中出现频率最低的,可以最后突破。
  • A 星寻路和二分图:这两种题目比例非常低,大家可以根据自己的情况选择性掌握。

----END----

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

推荐阅读更多精彩内容