极客时间-数据结构与算法之美(五)

30 | 图的表示:如何存储微博、微信等社交网络中的好友关系?

如何理解“图”?

(Graph)和树比起来,这是一种更加复杂的非线性表结构。

树中的元素称为节点,图中的元素叫作顶点(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。对于带权图,数组中就存储相应的权重。

 

用邻接矩阵来表示一个图,虽然简单、直观,但是比较浪费存储空间。

对于无向图来说,如果 A[i] [j] 等于 1,那 A[j] [i] 也肯定等于 1。实际上,只需要存储一个。也就是说,无向图的二维数组中,如果将其用对角线划分为上下两部分,那只需要利用上面或者下面这样一半的空间就足够了,另外一半白白浪费掉了。

如果存储的是稀疏图,也就是说,顶点很多,但每个顶点的边并不多,那邻接矩阵的存储方法就更加浪费空间了。比如微信有好几亿的用户,对应到图上就是好几亿的顶点。但是每个用户的好友并不会很多,一般也就三五百个而已。如果用邻接矩阵来存储,那绝大部分的存储空间都被浪费了。

邻接矩阵的存储方法的优点。首先,邻接矩阵的存储方式简单、直接,因为基于数组,所以在获取两个顶点的关系时,就非常高效。其次,用邻接矩阵存储图的另外一个好处是方便计算。这是因为,用邻接矩阵的方式存储图,可以将很多图的运算转换成矩阵之间的运算。比如求解最短路径问题时会提到一个Floyd-Warshall 算法,就是利用矩阵循环相乘若干次得到结果。

邻接表存储方法

邻接表有点像散列表,每个顶点对应一条链表,链表中存储的是与这个顶点相连接的其他顶点。对于有向图,每个顶点对应的链表里面,存储的是指向的顶点。对于无向图来说,也是类似的,不过,每个顶点的链表中存储的,是跟这个顶点有边相连的顶点。

邻接矩阵存储起来比较浪费空间,但是使用起来比较节省时间。相反,邻接表存储起来比较节省空间,但是使用起来就比较耗时间。

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

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

可以将邻接表中的链表改成平衡二叉查找树。实际开发中,可以选择用红黑树。这样,就可以更加快速地查找两个顶点之间是否存在边了。这里的二叉查找树可以换成其他动态数据结构,比如跳表、散列表等。还可以将链表改成有序动态数组,通过二分查找来快速定位两个顶点之间否是存在边。

如何存储微博、微信等社交网络中的好友关系?

微博、微信是两种“图”,前者是有向图,后者是无向图。两者的解决思路差不多,拿微博来讲解。

针对微博用户关系,假设需要支持下面这样几个操作:

  • 判断用户 A 是否关注了用户 B;

  • 判断用户 A 是否是用户 B 的粉丝;

  • 用户 A 关注用户 B;

  • 用户 A 取消关注用户 B;

  • 根据用户名称的首字母排序,分页获取用户的粉丝列表;

  • 根据用户名称的首字母排序,分页获取用户的关注列表。

因为社交网络是一张稀疏图,使用邻接矩阵存储比较浪费存储空间。所以采用邻接表来存储。

查找某个用户关注了哪些用户非常容易,但是如果要想知道某个用户都被哪些用户关注了,也就是用户的粉丝列表,是非常困难的。

基于此,需要一个逆邻接表。邻接表中存储了用户的关注关系,逆邻接表中存储的是用户的被关注关系。对应到图上,邻接表中,每个顶点的链表中,存储的就是这个顶点指向的顶点,逆邻接表中,每个顶点的链表中,存储的是指向这个顶点的顶点。如果要查找某个用户关注了哪些用户,可以在邻接表中查找;如果要查找某个用户被哪些用户关注了,从逆邻接表中查找。

选择改进版本,将邻接表中的链表改为支持快速查找的动态数据结构。

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

如果对于小规模的数据,比如社交网络中只有几万、几十万个用户,可以将整个社交关系存储在内存中。但是如果像微博那样有上亿的用户,数据规模太大,就无法全部存储在内存中了。

可以通过哈希算法等数据分片方式,将邻接表存储在不同的机器上。当要查询顶点与顶点关系的时候,就利用同样的哈希算法,先定位顶点所在的机器,然后再在相应的机器上查找。

除此之外,还有另外一种解决思路,就是利用外部存储(比如硬盘),因为外部存储的存储空间要比内存会宽裕很多。数据库是经常用来持久化存储关系数据的。

关于图,需要理解这样几个概念:无向图、有向图、带权图、顶点、边、度、入度、出度。图的两个主要的存储方式:邻接矩阵和邻接表

邻接矩阵存储方法的缺点是比较浪费空间,但是优点是查询效率高,而且方便矩阵运算。邻接表存储方法中每个顶点都对应一个链表,存储与其相连接的其他顶点。尽管邻接表的存储方式比较节省存储空间,但链表不方便查找,所以查询效率没有邻接矩阵存储方式高。针对这个问题,邻接表还有改进升级版,即将链表换成更加高效的动态数据结构,比如平衡二叉查找树、跳表、散列表等。

课后思考

  1. 关于开篇思考题,像微信这种无向图,应该怎么存储呢?

    • 微信好友关系存储方式。无向图,也可以使用邻接表的方式存储每个人所对应的好友列表。为了支持快速查找,好友列表可以使用红黑树存储。

    • 微信社交关系的存储方式

      因为顶点的数量大且关系相对少,所以不适合用邻接矩阵来存储,应该用邻接表来存储。

      微信社交关系的相关操作:1. 判断A、B是否为好友关系 2. A删除B,断开与B的好友关系 3. 展示出A的所有好友,并按名称首字母进行排序

      因为微信的好友关系是稀疏矩阵,为了减少空间浪费,使用邻接表;为了提高查找效率,将邻接表中的链表改为支持快速查找的动态数据结构,这里使用红黑树、跳表都可以,考虑到好友列表是按照字母排序的,可以使用跳表。最后,若有n台机器可供使用,那么可以对n取余来划分这些数据到不同的机器上,毕竟微信的用户量太大,一个机器的内存应该是不够用的。

  2. 除了社交网络可以用图来表示之外,符合图这种结构特点的例子还有很多,比如知识图谱(Knowledge Graph)。关于图这种数据结构,你还能想到其他生活或者工作中的例子吗?

    • 生活工作中应用图的例子。很多,互联网上网页之间通过超链接连接成一张有向图;城市乃至全国交通网络是一张加权图;人与人之间的人际关系够成一张图,著名的六度分割理论据说就是基于这个得到的。

    • 图的例子还有:操作系统的资源分配图是有向图,用来分析死锁问题。

    • 解决现实问题的时候当存储图有多种选择,例如: 1.用邻接表自己存 2.关系型库 3.图数据库 1 内存中用邻接表 2 要持久化存储就用数据库 3 超大图 并且涉及大量图计算。用专业的图数据库

31 | 深度和广度优先搜索:如何找出社交网络中的三度好友关系?

什么是“搜索”算法?

深度优先搜索算法和广度优先搜索算法都是基于“图”这种数据结构的。

图上的搜索算法,最直接的理解就是,在图中找出从一个顶点出发,到另一个顶点的路径。具体方法有很多,比如两种最简单、最“暴力”的深度优先、广度优先搜索,还有 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。它其实就是一种“地毯式”层层推进的搜索策略,即先查找离起始顶点最近的,然后是次近的,依次往外搜索。这样求得的路径就是最短路径。

public void bfs(int s, int t) {
if (s == t) return;
boolean[] visited = new boolean[v];
visited[s]=true;
Queue<Integer> queue = new LinkedList<>();
queue.add(s);
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是用来记录已经被访问的顶点,用来避免顶点被重复访问。如果顶点 q 被访问,那相应的 visited[q] 会被设置为 true。

queue是一个队列,用来存储已经被访问、但相连的顶点还没有被访问的顶点。因为广度优先搜索是逐层访问的,也就是说,我们只有把第 k 层的顶点都访问完成之后,才能访问第 k+1 层的顶点。当我们访问到第 k 层的顶点的时候,我们需要把第 k 层的顶点记录下来,稍后才能通过第 k 层的顶点来找第 k+1 层的顶点。所以,用这个队列来实现记录的功能。

prev用来记录搜索路径。当从顶点 s 开始,广度优先搜索到顶点 t 后,prev 数组中存储的就是搜索的路径。不过,这个路径是反向存储的。prev[w] 存储的是,顶点 w 是从哪个前驱顶点遍历过来的。比如,通过顶点 2 的邻接表访问到顶点 3,那 prev[3] 就等于 2。为了正向打印出路径,需要递归地来打印。

最坏情况下,终止顶点 t 离起始顶点 s 很远,需要遍历完整个图才能找到。这个时候,每个顶点都要进出一遍队列,每个边也都会被访问一次,所以,广度优先搜索的时间复杂度是 O(V+E),其中,V 表示顶点的个数,E 表示边的个数。当然,对于一个连通图来说,也就是说一个图中的所有顶点都是连通的,E 肯定要大于等于 V-1,所以,广度优先搜索的时间复杂度也可以简写为 O(E)。

广度优先搜索的空间消耗主要在几个辅助变量 visited 数组、queue 队列、prev 数组上。这三个存储空间的大小都不会超过顶点的个数,所以空间复杂度是 O(V)。

深度优先搜索(DFS)

深度优先搜索(Depth-First-Search),简称 DFS。最直观的例子就是“走迷宫”。

假设站在迷宫的某个岔路口,然后想找到出口。随意选择一个岔路口来走,发现走不通的时候,就回退到上一个岔路口,重新选择一条路继续走,直到最终找到出口。这就是一种深度优先搜索策略。

如何在图中应用深度优先搜索,来找某个顶点到另一个顶点的路径。深度优先搜索找出来的路径,并不是最短路径。

实际上,深度优先搜索用的是回溯思想,非常适合用递归来实现。

深度优先搜索代码实现里,found的作用是,当已经找到终止顶点 t 之后,就不再递归地继续查找了。

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) 
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值