Files
CategoryResourceRepost/极客时间专栏/数据结构与算法之美/基础篇/31 | 深度和广度优先搜索:如何找出社交网络中的三度好友关系?.md
louzefeng d3828a7aee mod
2024-07-11 05:50:32 +00:00

185 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<audio id="audio" title="31 | 深度和广度优先搜索:如何找出社交网络中的三度好友关系?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/1e/dd/1ee276db3427735b231a2fe19e9537dd.mp3"></audio>
上一节我们讲了图的表示方法,讲到如何用有向图、无向图来表示一个社交网络。在社交网络中,有一个[六度分割理论](https://zh.wikipedia.org/wiki/%E5%85%AD%E5%BA%A6%E5%88%86%E9%9A%94%E7%90%86%E8%AE%BA),具体是说,你与世界上的另一个人间隔的关系不会超过六度,也就是说平均只需要六步就可以联系到任何两个互不相识的人。
一个用户的一度连接用户很好理解,就是他的好友,二度连接用户就是他好友的好友,三度连接用户就是他好友的好友的好友。在社交网络中,我们往往通过用户之间的连接关系,来实现推荐“可能认识的人”这么一个功能。今天的开篇问题就是,**给你一个用户,如何找出这个用户的所有三度(其中包含一度、二度和三度)好友关系?**
这就要用到今天要讲的深度优先和广度优先搜索算法。
## 什么是“搜索”算法?
我们知道,算法是作用于具体数据结构之上的,深度优先搜索算法和广度优先搜索算法都是基于“图”这种数据结构的。这是因为,图这种数据结构的表达能力很强,大部分涉及搜索的场景都可以抽象成“图”。
图上的搜索算法最直接的理解就是在图中找出从一个顶点出发到另一个顶点的路径。具体方法有很多比如今天要讲的两种最简单、最“暴力”的深度优先、广度优先搜索还有A*、IDA*等启发式搜索算法。
我们上一节讲过,图有两种主要存储方法,邻接表和邻接矩阵。今天我会用邻接表来存储图。
我这里先给出图的代码实现。需要说明一下,深度优先搜索算法和广度优先搜索算法,既可以用在无向图,也可以用在有向图上。在今天的讲解中,我都针对无向图来讲解。
```
public class Graph { // 无向图
private int v; // 顶点的个数
private LinkedList&lt;Integer&gt; adj[]; // 邻接表
public Graph(int v) {
this.v = v;
adj = new LinkedList[v];
for (int i=0; i&lt;v; ++i) {
adj[i] = new LinkedList&lt;&gt;();
}
}
public void addEdge(int s, int t) { // 无向图一条边存两次
adj[s].add(t);
adj[t].add(s);
}
}
```
## 广度优先搜索BFS
广度优先搜索Breadth-First-Search我们平常都简称BFS。直观地讲它其实就是一种“地毯式”层层推进的搜索策略即先查找离起始顶点最近的然后是次近的依次往外搜索。理解起来并不难所以我画了一张示意图你可以看下。
<img src="https://static001.geekbang.org/resource/image/00/ea/002e9e54fb0d4dbf5462226d946fa1ea.jpg" alt="">
尽管广度优先搜索的原理挺简单,但代码实现还是稍微有点复杂度。所以,我们重点讲一下它的代码实现。
这里面bfs()函数就是基于之前定义的图的广度优先搜索的代码实现。其中s表示起始顶点t表示终止顶点。我们搜索一条从s到t的路径。实际上这样求得的路径就是从s到t的最短路径。
```
public void bfs(int s, int t) {
if (s == t) return;
boolean[] visited = new boolean[v];
visited[s]=true;
Queue&lt;Integer&gt; queue = new LinkedList&lt;&gt;();
queue.add(s);
int[] prev = new int[v];
for (int i = 0; i &lt; v; ++i) {
prev[i] = -1;
}
while (queue.size() != 0) {
int w = queue.poll();
for (int i = 0; i &lt; 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-&gt;t的路径
if (prev[t] != -1 &amp;&amp; t != s) {
print(prev, s, prev[t]);
}
System.out.print(t + &quot; &quot;);
}
```
这段代码不是很好理解里面有三个重要的辅助变量visited、queue、prev。只要理解这三个变量读懂这段代码估计就没什么问题了。
**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。为了正向打印出路径我们需要递归地来打印你可以看下print()函数的实现方式。
为了方便你理解,我画了一个广度优先搜索的分解图,你可以结合着代码以及我的讲解一块儿看。
<img src="https://static001.geekbang.org/resource/image/4f/3a/4fea8c4505b342cfaf8cb0a93a65503a.jpg" alt=""><img src="https://static001.geekbang.org/resource/image/ea/23/ea00f376d445225a304de4531dd82723.jpg" alt=""><img src="https://static001.geekbang.org/resource/image/4c/39/4cd192d4c220cc9ac8049fd3547dba39.jpg" alt="">
掌握了广度优先搜索算法的原理,我们来看下,广度优先搜索的时间、空间复杂度是多少呢?
最坏情况下终止顶点t离起始顶点s很远需要遍历完整个图才能找到。这个时候每个顶点都要进出一遍队列每个边也都会被访问一次所以广度优先搜索的时间复杂度是O(V+E)其中V表示顶点的个数E表示边的个数。当然对于一个连通图来说也就是说一个图中的所有顶点都是连通的E肯定要大于等于V-1所以广度优先搜索的时间复杂度也可以简写为O(E)。
广度优先搜索的空间消耗主要在几个辅助变量visited数组、queue队列、prev数组上。这三个存储空间的大小都不会超过顶点的个数所以空间复杂度是O(V)。
## 深度优先搜索DFS
深度优先搜索Depth-First-Search简称DFS。最直观的例子就是“走迷宫”。
假设你站在迷宫的某个岔路口,然后想找到出口。你随意选择一个岔路口来走,走着走着发现走不通的时候,你就回退到上一个岔路口,重新选择一条路继续走,直到最终找到出口。这种走法就是一种深度优先搜索策略。
走迷宫的例子很容易能看懂,我们现在再来看下,如何在图中应用深度优先搜索,来找某个顶点到另一个顶点的路径。
你可以看我画的这幅图。搜索的起始顶点是s终止顶点是t我们希望在图中寻找一条从顶点s到顶点t的路径。如果映射到迷宫那个例子s就是你起始所在的位置t就是出口。
我用深度递归算法把整个搜索的路径标记出来了。这里面实线箭头表示遍历虚线箭头表示回退。从图中我们可以看出深度优先搜索找出来的路径并不是顶点s到顶点t的最短路径。
<img src="https://static001.geekbang.org/resource/image/87/85/8778201ce6ff7037c0b3f26b83efba85.jpg" alt="">
实际上,深度优先搜索用的是一种比较著名的算法思想,回溯思想。这种思想解决问题的过程,非常适合用递归来实现。回溯思想我们后面会有专门的一节来讲,我们现在还回到深度优先搜索算法上。
我把上面的过程用递归来翻译出来就是下面这个样子。我们发现深度优先搜索代码实现也用到了prev、visited变量以及print()函数它们跟广度优先搜索代码实现里的作用是一样的。不过深度优先搜索代码实现里有个比较特殊的变量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 &lt; 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 &lt; adj[w].size(); ++i) {
int q = adj[w].get(i);
if (!visited[q]) {
prev[q] = w;
recurDfs(q, t, visited, prev);
}
}
}
```
理解了深度优先搜索算法之后,我们来看,深度优先搜索的时间、空间复杂度是多少呢?
从我前面画的图可以看出每条边最多会被访问两次一次是遍历一次是回退。所以图上的深度优先搜索算法的时间复杂度是O(E)E表示边的个数。
深度优先搜索算法的消耗内存主要是visited、prev数组和递归调用栈。visited、prev数组的大小跟顶点的个数V成正比递归调用栈的最大深度不会超过顶点的个数所以总的空间复杂度就是O(V)。
## 解答开篇
了解了深度优先搜索和广度优先搜索的原理之后,开篇的问题是不是变得很简单了呢?我们现在来一起看下,如何找出社交网络中某个用户的三度好友关系?
上一节我们讲过社交网络可以用图来表示。这个问题就非常适合用图的广度优先搜索算法来解决因为广度优先搜索是层层往外推进的。首先遍历与起始顶点最近的一层顶点也就是用户的一度好友然后再遍历与用户距离的边数为2的顶点也就是二度好友关系以及与用户距离的边数为3的顶点也就是三度好友关系。
我们只需要稍加改造一下广度优先搜索代码,用一个数组来记录每个顶点与起始顶点的距离,非常容易就可以找出三度好友关系。
## 内容小结
广度优先搜索和深度优先搜索是图上的两种最常用、最基本的搜索算法比起其他高级的搜索算法比如A*、IDA*等,要简单粗暴,没有什么优化,所以,也被叫作暴力搜索算法。所以,这两种搜索算法仅适用于状态空间不大,也就是说图不大的搜索。
广度优先搜索通俗的理解就是地毯式层层推进从起始顶点开始依次往外遍历。广度优先搜索需要借助队列来实现遍历得到的路径就是起始顶点到终止顶点的最短路径。深度优先搜索用的是回溯思想非常适合用递归实现。换种说法深度优先搜索是借助栈来实现的。在执行效率方面深度优先和广度优先搜索的时间复杂度都是O(E)空间复杂度是O(V)。
## 课后思考
<li>
我们通过广度优先搜索算法解决了开篇的问题,你可以思考一下,能否用深度优先搜索来解决呢?
</li>
<li>
学习数据结构最难的不是理解和掌握原理,而是能灵活地将各种场景和问题抽象成对应的数据结构和算法。今天的内容中提到,迷宫可以抽象成图,走迷宫可以抽象成搜索算法,你能具体讲讲,如何将迷宫抽象成一个图吗?或者换个说法,如何在计算机中存储一个迷宫?
</li>
欢迎留言和我分享,也欢迎点击“请朋友读”,把今天的内容分享给你的好友,和他一起讨论、学习。