社交网络中的好友关系:图和图的搜索算法

文章摘要

本文主要介绍了图的基本概念及通用存储方式,即邻接矩阵和邻接表,并对比了两种存储方式的优劣势;同时介绍了图的搜索算法——广度优先搜索BFS和深度优先搜索DFS的原理、实现和复杂度;最后以微博好友关系存储为例介绍了图在实际环境中的应用,帮助读者由浅入深循序渐进地理解图的核心原理和使用方法。

图

基本概念
顶点(vertex):图中的元素;
边(edge):图中元素之间的关联关系;
度(degree):图中的元素连接的边的条数;
入度(In-degree):有向图中指向某个顶点的边的条数;
出度(Out-degree):有向图中某个顶点出发指向其他顶点的边的条数。
常见的图类型有:
无向图
社交网络中的好友关系:图和图的搜索算法
有向图
社交网络中的好友关系:图和图的搜索算法
带权图
社交网络中的好友关系:图和图的搜索算法
图的存储
邻接矩阵
本质上是使用二维数组来存储图中元素之间的关系和权重。
对于无向图:如果顶点 i 与顶点 j 之间有边,我们就将 A[i][j]和 A[j][i]标记为 1;
对于有向图:如果顶点 i 到顶点 j 之间,有一条箭头从顶点 i 指向顶点 j 的边,
那我们就将 A[i][j]标记为 1。同理,如果有一条箭头从顶点 j 指向顶点 i 的边,
我们就将 A[j][i]标记为 1;
对于带权图:在有向图存储的基础上,数组中存储相应的权重。
社交网络中的好友关系:图和图的搜索算法
优点
  • 可以非常高效的获取两个顶点之间的关系;
  • 图之间的计算可以转换为矩阵之间的计算,同时借助CPU缓存等,实现高效的计算。
缺点
  • 对于无向图,A[i][j] 和 A[j][i] 都要存储1,只要二者其一存储即可,浪费了一半的存储空间;
  • 对于稀疏图,顶点很多,但每个顶点的边不多的图,邻接矩阵的存储会浪费更多的存储空间。
邻接表
使用散列表和链表来存储图的顶点及边之间关系的数据结构,可考虑使用散列表、跳表、红黑树等替代链表达到优化该结构的目的。
社交网络中的好友关系:图和图的搜索算法
除此之外,我们还可以将链表改成有序动态数组,可以通过二分查找的方法来快速定位两个顶点之间否是存在边。
邻接矩阵 VS 邻接表
邻接矩阵存储方法的缺点是比较浪费空间,但是优点是查询效率高,而且方便矩阵运算。
邻接表存储方法中每个顶点都对应一个链表,存储与其相连接的其他顶点。尽管邻接表的存储方式比较节省存储空间,但链表不方便查找,所以查询效率没有邻接矩阵存储方式高。
针对这个问题,邻接表还有改进升级版,即将链表换成更加高效的动态数据结构,比如平衡二叉查找树、跳表、散列表等。

图搜索

图上的搜索算法,最直接的理解就是,在图中找出从一个顶点出发,到另一个顶点的路径。具体方法有很多,两种最简单、最“暴力”的方法就是深度优先、广度优先搜索,还有 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
是一种“地毯式”层层推进的搜索策略,即先查找离起始顶点最近的,然后是次近的,依次往外搜索。
实现代码
public void bfs(int s, int t) {  if (s == t) return;    // 用来记录已经被访问的顶点,用来避免顶点被重复访问。如果顶点 q 被访问,那相应的 visited[q]会被设置为 true  boolean[] visited = new boolean[v];  visited[s]=true;    // 用来存储已经被访问、但相连的顶点还没有被访问的顶点  Queue<Integer> queue = new LinkedList<>();  queue.add(s);    // 用来记录搜索路径。当我们从顶点 s 开始,广度优先搜索到顶点 t 后,prev 数组中存储的就是搜索的路径  // 这个路径是反向存储的,prev[w]存储的是顶点 w 是从哪个前驱顶点遍历过来的,为了正向打印出路径,我们需要递归地来打印,可以看下 print() 函数的实现方式。  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 + " ");}
实现代码中用到了三个辅助变量visited数组、queue队列、prev数组,它们的含义分别是:
visited数组:用来记录已经被访问的顶点,用来避免顶点被重复访问。如果顶点 q 被访问,那相应的 visited[q]会被设置为 true;
queue队列:用来存储已经被访问、但相连的顶点还没有被访问的顶点;
prev数组:用来记录搜索路径。当我们从顶点 s 开始,广度优先搜索到顶点 t 后,prev数组中存储的就是搜索的路径。这个路径是反向存储的,prev[w]存储的是顶点 w 是从哪个前驱顶点遍历过来的,为了正向打印出路径,我们需要递归地来打印。
实现过程图示
社交网络中的好友关系:图和图的搜索算法
社交网络中的好友关系:图和图的搜索算法
社交网络中的好友关系:图和图的搜索算法
复杂度
时间复杂度:O(V+E),其中,V 表示顶点的个数,E 表示边的个数;对于一个连通图——图中的所有顶点都是连通的,E 肯定要大于等于 V-1,时间复杂度也可以认为是 O(E)。
空间复杂度:O(V),visited 数组、queue 队列、prev 数组的大小都不会超过顶点的个数。
深度优先搜索DFS
使用回溯思想和递归思维,沿着某一条路径一直走到头,当发现走不通的时候,回退到上一步选择另一条路径走到头,直到找到正确路径为止。
实现代码
boolean found = false; // 全局变量或者类成员变量
public void dfs(int s, int t) {  found = false;  boolean[] visited = new boolean[v];  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);    }  }}
上述代码中的visited数组、prev数组的定义和DFS实现代码中的类似,特殊说明一下found变量,它的作用是,当我们已经找到终止顶点 t 之后,我们就不再递归地继续查找了。
复杂度
时间复杂度:O(E),每条边最多被访问两次,依次是遍历,依次是回退;
空间复杂度:O(V),消耗内存主要是 visited、prev 数组和递归调用栈,visited、prev 数组的大小跟顶点的个数 V 成正比,递归调用栈的最大深度不会超过顶点的个数。

图的应用

图的实际应用有社交关系网络、互联网通信网络、城市交通网络、知识图谱等。以下面的微博好友关系存储为例,看看图在社交网络中是怎么应用的。
利用图存储微博好友关系
针对微博用户关系,假设我们需要支持下面这样几个操作:
  • 判断用户 A 是否关注了用户 B;
  • 判断用户 A 是否被用户 B关注;
  • 用户 A 关注用户 B;
  • 用户 A 取消关注用户 B;
  • 根据用户名称的首字母排序,分页获取用户的粉丝列表;
  • 根据用户名称的首字母排序,分页获取用户的关注列表。
    
数据结构是为算法服务的,所以具体选择哪种存储方法,与期望支持的操作有关系。已知社交网络是一张稀疏图,因此可以采用邻接表来实现存储:
1. 判断用户A是否关注了用户B + 判断用户A是否被用户B关注,可以使用两张邻接表分别来实现-关注表(邻接表)和被关注表(逆邻接表):
社交网络中的好友关系:图和图的搜索算法
2. 上述的邻接表,不能快速地实现查找两用户之间的关注和被关注的关系,需对链表结构进行升级。根据用户名称的首字母排序,分页获取用户的粉丝列表和关注列表,可以使用跳表来替代链表。
PS:跳表插入、删除、查找都非常高效,时间复杂度是 O(logn),空间复杂度上稍高,是 O(n)。最重要的一点,跳表中存储的数据本来就是有序的了,分页获取粉丝列表或关注列表,就非常高效。
对于小规模数据,可以单机加载到内存中计算存储;对于大规模数据,需要依赖哈希算法将数据分片到多个机器上存储。
社交网络中的好友关系:图和图的搜索算法
除此之外,我们可以利用外部存储的关系型数据库来实现——我用下面这张表来存储这样一个图,为了高效地支持前面定义的操作,我们可以在表上建立多个索引,比如第一列、第二列,给这两列都建立索引。
社交网络中的好友关系:图和图的搜索算法
解决现实问题的时候当存储图有多种选择,可以根据实际情况灵活选择:
  • 内存中使用邻接表;
  • 持久化存储就用数据库;
  • 超大图并且涉及大量图计算,使用专业的图数据库。

 

 

分享到: 文章二维码
© 版权声明

暂无评论

您必须登录才能参与评论!
暂无评论...