(数据结构与算法)图论


(数据结构与算法之美读书笔记)

总览

涉及图的算法有很多,也非常复杂,比如图的搜索、最短路径、最小生成树、二分图等等。

图和树都是一种非线性表数据结构

我们知道,树中的元素我们称为节点,图中的元素我们就叫作顶点(vertex)。从我画的图中可以看出来,图中的一个顶点可以与任意其他顶点建立连接关系。我们把这种建立的关系叫作边(edge)。拿微信社交网络举例子,整个微信的好友关系就可以用一张图来表示。其中,每个用户有多少个好友,对应到图中,就叫作顶点的度(degree),就是跟顶点相连接的边的条数。
在这里插入图片描述

我们把这种边有方向的图叫作“有向图”。以此类推,我们把边没有方向的图就叫作“无向图”。我们刚刚讲过,无向图中有“度”这个概念,表示一个顶点有多少条边。在有向图中,我们把度分为入度(In-degree)出度(Out-degree)。顶点的入度,表示有多少条边指向这个顶点;顶点的出度,表示有多少条边是以这个顶点为起点指向其他顶点。对应到微博的例子,入度就表示有多少粉丝,出度就表示关注了多少人。
在这里插入图片描述

QQ 中的社交关系要更复杂的一点。QQ中有一个亲密度的功能,这里就要用到另一种图,带权图(weighted graph)。在带权图中,每条边都有一个权重(weight),我们可以通过这个权重来表示 QQ 好友间的亲密度。
在这里插入图片描述

图的储存方法

邻接矩阵存储方法

图最直观的一种存储方法就是,邻接矩阵(Adjacency Matrix)。

邻接矩阵的底层依赖一个二维数组。对于无向图来说,如果顶点 i 与顶点 j 之间有边,我们就将A[i][j] 和 A[j][i] 标记为 1;对于有向图来说,如果顶点 i 到顶点 j 之间,有一条箭头从顶点 i 指向顶点 j 的边,那我们就将 A[i][j] 标记为 1。同理,如果有一条箭头从顶点 j 指向顶点 i 的边,我们就将 A[j][i] 标记为 1。对于带权图,数组中就存储相应的权重。
在这里插入图片描述
因为无向图只有对角线的一半的信息就足够了,所以用邻接矩阵来表示一个图,虽然简单、直观,但是比较浪费存储空间。

邻接表存储方法

我画了一张邻接表的图,你可以先看下。乍一看,邻接表是不是有点像散列表?每个顶点对应一条链表,链表中存储的是与这个顶点相连接的其他顶点。另外我需要说明一下,图中画的是一个有向图的邻接表存储方式,每个顶点对应的链表里面,存储的是指向的顶点。对于无向图来说,也是类似的,不过,每个顶点的链表中存储的,是跟这个顶点有边相连的顶点,你可以自己画
下。
在这里插入图片描述
还记得我们之前讲过的时间、空间复杂度互换的设计思想吗?邻接矩阵存储起来比较浪费空间,
但是使用起来比较节省时间。相反,邻接表存储起来比较节省空间,但是使用起来就比较耗时
间。

就像图中的例子,如果我们要确定,是否存在一条从顶点 2 到顶点 4 的边,那我们就要遍历顶
点 2 对应的那条链表,看链表中是否存在顶点 4。而且,我们前面也讲过,链表的存储方式对缓
存不友好。所以,比起邻接矩阵的存储方式,在邻接表中查询两个顶点之间的关系就没那么高效
了。

在散列表那几节里,我讲到,在基于链表法解决冲突的散列表中,如果链过长,为了提高查找效
率,我们可以将链表换成其他更加高效的数据结构,比如平衡二叉查找树等。我们刚刚也讲到,
邻接表长得很像散列。所以,我们也可以将邻接表同散列表一样进行“改进升级”。

我们可以将邻接表中的链表改成平衡二叉查找树。实际开发中,我们可以选择用红黑树。这样,
我们就可以更加快速地查找两个顶点之间是否存在边了。

举例:微博用户的储存方式

用一个邻接表来存储这种有向图是不够的。我们去查找某个用户关注了哪些用户非常容易,但是
如果要想知道某个用户都被哪些用户关注了,也就是用户的粉丝列表,是非常困难的。

基于此,我们需要一个逆邻接表。邻接表中存储了用户的关注关系,逆邻接表中存储的是用户的
被关注关系。对应到图上,邻接表中,每个顶点的链表中,存储的就是这个顶点指向的顶点,逆
邻接表中,每个顶点的链表中,存储的是指向这个顶点的顶点。如果要查找某个用户关注了哪些
用户,我们可以在邻接表中查找;如果要查找某个用户被哪些用户关注了,我们从逆邻接表中查
找。
在这里插入图片描述
基础的邻接表不适合快速判断两个用户之间是否是关注与被关注的关系,所以我们选择改进版
本,将邻接表中的链表改为支持快速查找的动态数据结构。选择哪种动态数据结构呢?红黑树、
跳表、有序动态数组还是散列表呢?

因为我们需要按照用户名称的首字母排序,分页来获取用户的粉丝列表或者关注列表,用跳表这
种结构再合适不过了。这是因为,跳表插入、删除、查找都非常高效,时间复杂度是 O(logn),
空间复杂度上稍高,是 O(n)。最重要的一点,跳表中存储的数据本来就是有序的了,分页获取
粉丝列表或关注列表,就非常高效。

如果对于小规模的数据,比如社交网络中只有几万、几十万个用户,我们可以将整个社交关系存
储在内存中,上面的解决思路是没有问题的。但是如果像微博那样有上亿的用户,数据规模太
大,我们就无法全部存储在内存中了。这个时候该怎么办呢?

我们可以通过哈希算法等数据分片方式,将邻接表存储在不同的机器上。你可以看下面这幅图,
我们在机器 1 上存储顶点 1,2,3 的邻接表,在机器 2 上,存储顶点 4,5 的邻接表。逆邻接
表的处理方式也一样。当要查询顶点与顶点关系的时候,我们就利用同样的哈希算法,先定位顶
点所在的机器,然后再在相应的机器上查找。
在这里插入图片描述

搜索算法

深度优先搜索算法和广度优先搜索算法都是基于“图”这种数据结构的。
图上的搜索算法,最直接的理解就是,在图中找出从一个顶点出发,到另一个顶点的路径。具体方法有很多,比如今天要讲的两种最简单、最“暴力”的深度优先、广度优先搜索,还有 A*、IDA* 等启发式搜索算法。
先来看无向图的代码

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

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

  public void addEdge(int s, int t) { // 无向图一条边存两次
    adj[s].add(t);
    adj[t].add(s);
  }
}

广度优先搜索(BFS)–迭代

广度优先搜索(Breadth-First-Search),我们平常都把简称为 BFS。直观地讲,它其实就是一
种“地毯式”层层推进的搜索策略,即先查找离起始顶点最近的,然后是次近的,依次往外搜
索。理解起来并不难,所以我画了一张示意图,你可以看下。
在这里插入图片描述
下面给出代码:其中 s 表示起始顶点,t 表示终止顶点。我们搜索一条从 s 到 t 的路径。实际上,这样求得的路径就是从 s 到 t 的最短路径。

public void bfs(int s, int t) {
  if (s == t) return;
  //visited是用来记录已经被访问的顶点,用来避免顶点被重复访问。如果顶点 q 被访问,那相应的 visited[q] 会被设置为 true
  //v是顶点个数
  boolean[] visited = new boolean[v];
  visited[s]=true;
  //queue是一个队列,用来存储已经被访问、但相连的顶点还没有被访问的顶点。
  Queue<Integer> queue = new LinkedList<>();
  queue.add(s);
  //prev用来记录搜索路径。当我们从顶点 s 开始,广度优先搜索到顶点 t 后,prev 数组中存储的就是搜索的路径。
  int[] prev = new int[v];
  for (int i = 0; i < v; ++i) {
    prev[i] = -1;
  }
  while (queue.size() != 0) {
    int w = queue.poll();
    for (int i = 0; i < adj[w].size(); ++i) {
      int q = adj[w].get(i);
      if (!visited[q]) {
        prev[q] = w;
        if (q == t) {
          print(prev, s, t);
          return;
        }
        visited[q] = true;
        queue.add(q);
      }
    }
  }
}

private void print(int[] prev, int s, int t) { // 递归打印 s->t 的路径
  if (prev[t] != -1 && t != s) {
    print(prev, s, prev[t]);
  }
  System.out.print(t + " ");
}

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

深度优先搜索(DFS)–递归

深度优先搜索(Depth-First-Search),简称 DFS。最直观的例子就是“走迷宫”。
我用深度递归算法,把整个搜索的路径标记出来了。这里面实线箭头表示遍历,虚线箭头表示回
退。从图中我们可以看出,深度优先搜索找出来的路径,并不是顶点 s 到顶点 t 的最短路径。
在这里插入图片描述

boolean found = false; // 全局变量或者类成员变量

public void dfs(int s, int t) {
  found = false;
  //记录已经被访问的顶点
  boolean[] visited = new boolean[v];
  //prev用来记录搜索路径
  int[] prev = new int[v];
  for (int i = 0; i < v; ++i) {
    prev[i] = -1;
  }
  recurDfs(s, t, visited, prev);
  print(prev, s, t);
}

private void recurDfs(int w, int t, boolean[] visited, int[] prev) {
  if (found == true) return;
  visited[w] = true;
  if (w == t) {
    found = true;
    return;
  }
  for (int i = 0; i < adj[w].size(); ++i) {
    int q = adj[w].get(i);
    if (!visited[q]) {
      prev[q] = w;
      recurDfs(q, t, visited, prev);
    }
  }
}

题目

leetcode#200 岛的数量
给定一个由 ‘1’(陆地)和 ‘0’(水)组成的的二维网格,计算岛屿的数量。一个岛被水包围,并且它是通过水平方向或垂直方向上相邻的陆地连接而成的。你可以假设网格的四个边均被水包围。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值