# 14 | 树的广度优先搜索(下):为什么双向广度优先搜索的效率更高? 你好,我是黄申。 上一讲,我们通过社交好友的关系,介绍了为什么需要广度优先策略,以及如何通过队列来实现它。有了广度优先搜索,我们就可以知道某个用户的一度、二度、三度等好友是谁。不过,在社交网络中,还有一个经常碰到的问题,那就是给定两个用户,如何确定他们之间的关系有多紧密? 最直接的方法是,使用这两人是几度好友,来衡量他们关系的紧密程度。今天,我就这个问题,来聊聊广度优先策略的一种扩展:双向广度优先搜索,以及这种策略在工程中的应用。 ## 如何更高效地求出两个用户间的最短路径? 基本的做法是,从其中一个人出发,进行广度优先搜索,看看另一个人是否在其中。如果不幸的话,两个人相距六度,那么即使是广度优先搜索,同样要达到万亿级的数量。 那究竟该如何更高效地求得两个用户的最短路径呢?我们先看看,影响效率的问题在哪里?很显然,随着社会关系的度数增加,好友数量是呈指数级增长的。所以,如果我们可以控制这种指数级的增长,那么就可以控制潜在好友的数量,达到提升效率的目的。 如何控制这种增长呢?我这里介绍一种“**双向广度优先搜索**”。它巧妙地运用了两个方向的广度优先搜索,大幅降低了搜索的度数。现在我就带你看下,这个方法的核心思想。 假设有两个人$a$、$b$。 * 我们首先从$a$出发,进行广度优先搜索,记录$a$的所有一度好友$a\_{1}$,然后看点$b$是否出现在集合$a\_{1}$中。 * 如果没有,就再从$b$出发,进行广度优先搜索,记录所有一度好友$b\_{1}$,然后看$a$和$a\_{1}$是否出现在$b$和$b\_{1}$的并集中。 * 如果没有,就回到$a$,继续从它出发的广度优先搜索,记录所有二度好友$a\_{2}$,然后看$b$和$b\_{1}$是否出现在$a$、$a\_{1}$和$a\_{2}$三者的并集中。 * 如果没有,就回到$b$,继续从它出发的广度优先搜索。 * 如此轮流下去,直到找到$a$的好友和$b$的好友的交集。 如果有交集,就表明这个交集里的点到$a$和$b$都是通路。 我们假设$c$在这个交集中,那么把$a$到$c$的通路长度和$b$到$c$的通路长度相加,得到的就是从$a$到$b$的最短通路长(这个命题可以用反证法证明),也就是两者为几度好友。这个过程有点复杂,我画了一张图帮助你来理解。 ![](https://static001.geekbang.org/resource/image/b6/0e/b665fdbd81e7d0fb3245fbcd3b21230e.jpg?wh=1142*856) 思路你应该都清楚了,现在我们来看看如何用代码来实现。 要想实现双向广度优先搜索,首先我们要把结点类Node稍作修改,增加一个变量degrees。这个变量是HashMap类型,用于存放从不同用户出发,到当前用户是第几度结点。比如说,当前结点是4,从结点1到结点4是3度,结点2到结点4是2度,结点3到结点4是4度,那么结点4的degrees变量存放的就是如下映射: ![](https://static001.geekbang.org/resource/image/e2/47/e27d792a83dbe325ad5d0432910ffb47.png?wh=962*302) 有了变量degrees,我们就能随时知道某个点和两个出发点各自相距多少。所以,在发现交集之后,根据交集中的点和两个出发点各自相距多少,就能很快地算出最短通路的长度。理解了这点之后,我们在原有的Node结点内增加degrees变量的定义和初始化。 ``` public class Node { ...... public HashMap degrees; // 存放从不同用户出发,当前用户结点是第几度 // 初始化结点 public Node(int id) { ...... degrees = new HashMap<>(); degrees.put(id, 0); } } ``` 为了让双向广度优先搜索的代码可读性更好,我们可以先实现两个模块化的函数:getNextDegreeFriend和hasOverlap。函数getNextDegreeFriend是根据给定的队列,查找和起始点相距度数为指定值的所有好友。而函数hasOverlap用来判断两个集合是不是有交集。有了这些模块化的函数,双向广度优先搜索的代码就更直观了。 在函数一开始,我们先进行边界条件判断。 ``` /** * @Description: 通过双向广度优先搜索,查找两人之间最短通路的长度 * @param user_nodes-用户的结点;user_id_a-用户a的ID;user_id_b-用户b的ID * @return void */ public static int bi_bfs(Node[] user_nodes, int user_id_a, int user_id_b) { if (user_id_a > user_nodes.length || user_id_b > user_nodes.length) return -1; // 防止数组越界的异常 if (user_id_a == user_id_b) return 0; // 两个用户是同一人,直接返回0 ``` 由于同时从两个用户的结点出发,对于所有,有两条搜索的路径,我们都需要初始化两个用于广度优先搜索的队列,以及两个用于存放已经被访问结点的HashSet。 ``` Queue queue_a = new LinkedList(); // 队列a,用于从用户a出发的广度优先搜索 Queue queue_b = new LinkedList(); // 队列b,用于从用户b出发的广度优先搜索 queue_a.offer(user_id_a); // 放入初始结点 HashSet visited_a = new HashSet<>(); // 存放已经被访问过的结点,防止回路 visited_a.add(user_id_a); queue_b.offer(user_id_b); // 放入初始结点 HashSet visited_b = new HashSet<>(); // 存放已经被访问过的结点,防止回路 visited_b.add(user_id_b); ``` 接下来要做的是,从两个结点出发,沿着各自的方向,每次广度优先搜索一度,并查找是不是存在重叠的好友。 ``` int degree_a = 0, degree_b = 0, max_degree = 20; // max_degree的设置,防止两者之间不存在通路的情况 while ((degree_a + degree_b) < max_degree) { degree_a ++; getNextDegreeFriend(user_id_a, user_nodes, queue_a, visited_a, degree_a); // 沿着a出发的方向,继续广度优先搜索degree + 1的好友 if (hasOverlap(visited_a, visited_b)) return (degree_a + degree_b); // 判断到目前为止,被发现的a的好友,和被发现的b的好友,两个集合是否存在交集 degree_b ++; getNextDegreeFriend(user_id_b, user_nodes, queue_b, visited_b, degree_b); // 沿着b出发的方向,继续广度优先搜索degree + 1的好友 if (hasOverlap(visited_a, visited_b)) return (degree_a + degree_b); // 判断到目前为止,被发现的a的好友,和被发现的b的好友,两个集合是否存在交集 } return -1; // 广度优先搜索超过max_degree之后,仍然没有发现a和b的重叠,认为没有通路 } ``` 你可以同时实现单向广度优先搜索和双向广度优先搜索,然后通过实验来比较两者的执行时间,看看哪个更短。如果实验的数据量足够大(比如说结点在1万以上,边在5万以上),你应该能发现,**双向的方法对时间和内存的消耗都更少**。 为什么双向搜索的效率更高呢?我以平均好友度数为4,给你举例讲解。 左边的图表示从结点$a$单向搜索走2步,右边的图表示分别从结点$a$和$b$双向搜索各走1步。很明显,左边的结点有16个,明显多于右边的8个结点。而且,随着每人认识的好友数、搜索路径的增加,这种差距会更加明显。 ![](https://static001.geekbang.org/resource/image/15/5b/1518aaa073b379b20ba3dca8dde08d5b.jpg?wh=1142*741) 我们假设每个地球人平均认识100个人,如果两个人相距六度,单向广度优先搜索要遍历100^6=1万亿左右的人。如果是双向广度优先搜索,那么两边各自搜索的人只有100^3=100万。 当然,你可能会说,单向广度优先搜索之后查找匹配用户的开销更小啊。的确如此,假设我们要知道结点$a$和$b$之间的最短路径,单向搜索意味着要在$a$的1万亿个好友中查找$b$。如果采用双向搜索的策略,从结点$a$和$b$出发进行广度优先搜索,每个方向会产生100万的好友,那么需要比较这两组100万的好友是否有交集。 假设我们使用哈希表来存储$a$的1万亿个好友,并把搜索$b$是否存在其中的耗时记作x,而把判断两组100万好友是否有交集的耗时记为y,那么通常x