数据结构与算法面试宝典 - 前微软资深软件工程师 - 拉勾教育

今天,我们继续尝试从不同的角度(方法)来求解一个题目,通过 “一题多解” 的训练,拓展我们的思维。“搜索类型” 的题目一直是面试考察的重点,其变形非常广,不过万变不离其宗,大部分解题方法仍然逃不开 BFS/DFS 这两个框架。

所以在本讲,我们将以一道经典的搜索题目为引,串联和使用前面学习过的各种知识点,比如:

  • BFS / 双向 BFS
  • DFS
  • Dijkstra

具体介绍这类题目的分析和处理技巧,让你的面试得心应手。让我们马上开始。

题目

字典 wordList 中单词 beginWord 和 endWord 的转换序列是一个按下述规则形成的序列:

  • 序列中第一个单词是 beginWord ;
  • 序列中最后一个单词是 endWord,endWord 需要在 wordList 中;
  • 每次转换只能改变一个字母;
  • 转换过程中的中间单词必须是字典 wordList 中的单词。

给你两个单词 beginWord 和 endWord 和一个字典 wordList ,找到从 beginWord 到 endWord 的最短转换序列中的单词数目 。如果不存在这样的转换序列,返回 0。

输入:beginWord = “hit”, endWord = “cog”, wordList = [“hot”,”dot”,”dog”,”lot”,”log”,”cog”]

输出:5

解释:一个最短转换序列是 “hit” → “hot” → “dot” → “dog” → “cog”,返回它的长度 5。

首先,这里需要重点说一下条件:

  • beginWord != endWord;
  • beginWord 可以不在 wordList 中;
  • endWord 必须要在 wordList 中,如果不在 wordList 中,那么需要返回 0;
  • 所有的单词长度都一样。

预处理

拿到题目,我们要做的第一件事,应该是去挖掘题目中的隐含条件。我们看到题目中有如下条件:

  • 每次转换只能改变一个字母;
  • 转换过程中的中间单词必须是 wordList 里面的单词。

如果将每一次的转换,看成是图中两个点的连接,题目的最终问题就是希望我们找到图中给定两个点的最短距离

如果把单词看成图的点,那么对应图的边又是什么呢?

注意:这里提到的图,都是指算法中的图 Graph,而不是图画 Picture。

边的由来

当我们有 word = “hit”,如果改变其中一个字母,就可以生成 “hat”。但是,我们立马发现 wordList = [“hot”,”dot”,”dog”,”lot”,”log”,”cog”] 并不存在单词 “hat”。

如果从图的角度来看,可以认为 <”hit”, “hat”> 这条边不存在。那么接下来,我们再看一下成功的情况。

当我们有 word = “hit”,如果改变一个字母,生成 “hot”,由于 wordList[0] == “hot”,因此,这种转换 “hit” ←→ “hot” 是合法的,那么,可以认为边 <”hit”, “hot”> 是存在的。

边的无向性

对于单词转换来说,当 word=”hit” 可以转换成 “hot” 的时候,那么反过来 “hot” 也可以转换为 “hit”。因此,当我们得到一条边的时候,这条边就是一条无向边。

接下来我们再分析一下这类题的考点。

考点

在 “13 | 搜索:如何掌握 DFS 与 BFS 的解题套路?” 中,我们学习的大部分关于 “” 的题目,都是明确地知道图的边,或者题目中给出了图的边。

但是,在这个题中,并没有明确地给出图的边。所有的边都需要依赖一定的条件动态生成。我们可以利用伪代码,表示边的生成,代码如下(解析在注释里):

  1. for word in Graph:
  2. startPoint = word
  3. for c in word:
  4. oldChar = c
  5. for toChar in 'a'~'z':
  6. c = toChar
  7. endPoint = word
  8. if endPoint in wordList:
  9. c = oldChar

有了图的重建,再给定输入,代码如下:

  1. beginWord = "hit"
  2. endWord = "cog"
  3. wordList = ["hot","dot","dog","lot","log","cog"]

经过上述操作,就可以得到题目中图的表示:

18 | 单词接龙:如何巧用深搜与广搜的变形? - 图1

转换

如何利用字符串表示图中的点,就需要两个字符串来表示一条边。为了压缩这部分信息,我们采用整数来表示字符串。优点有以下几个方面。

  • 字符串的处理不方便,必须使用哈希表。如果是整数表示图中的点,那么我们可以使用数组记录点的信息。
  • 字符串的运算速度没有整数快。
  • 我们在学习图算法的时候,大部分时候都是使用整数来表示图中的点,相对来说,对代码更加熟悉。

基于以上三个原因,我们决定将 String 表示一个点,转换为用整数表示一个点。转换的思想也比较简单:利用哈希表将不同的字符串映射到不同的整数上即可

这里我们直接给出 “建图”+“转换” 的代码,如下所示(解析在注释里):

  1. class Solution {
  2. private Map<String, Integer> wordID = null;
  3. private List<Integer> Graph[] = null;
  4. boolean buildGraph(String beginWord,
  5. String endWord,
  6. List<String> wordList) {
  7. if (beginWord.compareTo(endWord) == 0) {
  8. return false;
  9. }
  10. wordID = new HashMap<>();
  11. int id = 0;
  12. for (String word: wordList) {
  13. if (!wordID.containsKey(word)) {
  14. wordID.put(word, id++);
  15. }
  16. }
  17. if (!wordID.containsKey(endWord)) {
  18. return false;
  19. }
  20. if (!wordID.containsKey(beginWord)) {
  21. wordID.put(beginWord, id++);
  22. wordList.add(beginWord);
  23. }
  24. Graph = new ArrayList[wordID.size()];
  25. for (int i = 0; i < wordID.size(); i++) {
  26. Graph[i] = new ArrayList<>();
  27. }
  28. for (String word: wordList) {
  29. final int from = wordID.get(word);
  30. byte[] wordBytes = word.getBytes();
  31. for (int i = 0; i < wordBytes.length; i++) {
  32. byte old = wordBytes[i];
  33. for (byte toByte = 'a'; toByte <= 'z'; toByte++) {
  34. wordBytes[i] = toByte;
  35. String toWord = new String(wordBytes);
  36. if (wordID.containsKey(toWord)) {
  37. int to = wordID.get(toWord);
  38. Graph[from].add(to);
  39. }
  40. }
  41. wordBytes[i] = old;
  42. }
  43. }
  44. return true;
  45. }
  46. public int ladderLength(String beginWord,
  47. String endWord,
  48. List<String> wordList) {
  49. if (!buildGraph(beginWord, endWord, wordList)) {
  50. return 0;
  51. }
  52. }
  53. }

注意:在后文的代码中,我不再罗列 buildGraph 函数的详细代码,所有引用到 buildGraph 代码的地方,都是指这里的 buildGraph() 函数。

当我们建好图之后,问题就变成:

  • 给定一个无向图;
  • 如何求图中两个点的最短距离。

不过,根据题意,还需要注意题目要求输出的是 “最短转换序列”:

一个最短转换序列是 “hit” → “hot” → “dot” → “dog” → “cog”,返回它的长度 5。

因此,最短序列等价于最短路径上的点的个数。而我们平时求的最短路径实际上是最短路径上边的数目。因此:

最短转换序列长度 = 最短路径长度 + 1

那么,到这里,我们已经将陌生的题目成功转变成非常熟悉的问题:求图中两个点的最短距离

接下来,我们看一下具体如何破解 “最短路径” 问题,其实我们在“13 | 搜索:如何掌握 DFS 与 BFS 的解题套路?”的 “例 2 和例 4” 都学习过,你可以返回去再复习一下,以便加深对这个经典问题的理解。

BFS 算法

求两个点的最短路径的时候,我们可以直接用 BFS。为什么呢?

你应该还记得,我们在 “13 | 搜索:如何掌握 DFS 与 BFS 的解题套路?” 中提到过 BFS 的特点:

在搜索的时候,若想知道一些关于 “最近 / 最快 / 最少” 之类问题的答案,往往采用 BFS 更加适合。

因此,在这里,我们直接使用 BFS 算法。如果从 beginWord 开始搜索,那么 BFS 的搜索过程可以表达成一个 “雷达波搜索” 的样子——每一轮搜索都会往外扩散一圈。

你可以结合下图展示的 BFS 的搜索过程示意图进一步思考,我们从 beginWord = “hit” 开始搜索,直接到找到 endWord = “cog” 时停止。

18 | 单词接龙:如何巧用深搜与广搜的变形? - 图2

注:这里第 1 圈就是 hit 自身,蓝色圈表示 1 次搜索。

那么在写代码的时候,我们可以使用类似的技巧进行 BFS。在每一层,我们都使用一个 ArrayList 来表示。那么,可以写出基于 BFS 的代码如下(解析在注释里):

  1. class Solution {
  2. public int ladderLength(String beginWord,
  3. String endWord,
  4. List<String> wordList) {
  5. if (!buildGraph(beginWord, endWord, wordList)) {
  6. return 0;
  7. }
  8. final int src = wordID.get(beginWord);
  9. final int dst = wordID.get(endWord);
  10. List<Integer> cur = new ArrayList<>();
  11. cur.add(src);
  12. List<Integer> next = new ArrayList<>();
  13. boolean[] vis = new boolean[wordID.size()];
  14. vis[src] = true;
  15. int step = 0;
  16. while (!cur.isEmpty()) {
  17. next.clear();
  18. step++;
  19. for (Integer curNode : cur) {
  20. if (curNode == dst) {
  21. return step;
  22. }
  23. for (Integer nextNode : Graph[curNode]) {
  24. if (!vis[nextNode]) {
  25. next.add(nextNode);
  26. vis[nextNode] = true;
  27. }
  28. }
  29. }
  30. List<Integer> tmp = cur;
  31. cur = next;
  32. next = tmp;
  33. }
  34. return 0;
  35. }
  36. }

代码:Java/C++/Python

复杂度分析:(假设我们有 N 个单词,每个单词的长度为 M。每个单词需要更改每个位置的字母来生成新的单词)这里时间复杂度需要分为两步。

第一步:预处理建图

1. 时间复杂度

1)一共需要处理 N * M 个字母,每个字母要替换 26 次。替换之后生成的长度为 M 的新单词需要去哈希表中检验,每次去哈希表中检查一个单词需要的时间复杂度为 O(M)。

2)建图时间复杂度为 O(N M M 26),我们可以把常数 26 去掉,因此时间复杂度为 O(N M * M)。

2. 空间复杂度

1)建图时需要建立一个有 N 个 Item,并且每个 Item 长度为 M 的哈希表。因此,哈希表空间复杂度为 O(N * M)。

2)Graph 需要占用 O(N * N) 的空间。

第二步:BFS

1. 时间复杂度

在后面 BFS 搜索的过程中,由于不会访问已访问过的点,相当于所有的点被遍历一遍,所以时间复杂度为 O(N)。

2. 空间复杂度

最差情况下,需要把所有的点都放到 Array 中,此时空间复杂度为 O(N)。

综上,整个问题的时间复杂度为 O(N M M),空间复杂度为 O(max(N2, N * M))。

双向 BFS

如果说前面的 BFS 是 “一个人” 苦苦地用雷达搜索(后文中称为单向 BFS),那么会不会存在从两个方向进行搜索的情况呢?我们尝试分析一下。如果要找的目标也用雷达开启搜索,那么当两者有交互的时候,就可以认为找到了最短路径。

这种方法我们称为双向 BFS。两者的搜索过程如下图所示:

18 | 单词接龙:如何巧用深搜与广搜的变形? - 图3

实际上,我们在写双向 BFS 的时候,两边不会同时开启搜索。而是采用一种策略:优先搜索范围更小的

主要原因在于:

  • 我们写算法的时候,往往不需要多线程;
  • 优先搜索范围更小的,可以节省更多的内存,因为要存放的信息变少了。

基于这种双向 BFS 的想法,可以写出代码如下(解析在注释里):

  1. class Solution {
  2. public int ladderLength(String beginWord,
  3. String endWord,
  4. List<String> wordList){
  5. if (!buildGraph(beginWord, endWord, wordList)) {
  6. return 0;
  7. }
  8. final int srcNode = wordID.get(beginWord);
  9. final int dstNode = wordID.get(endWord);
  10. Set<Integer> src = new HashSet<>();
  11. src.add(srcNode);
  12. Set<Integer> dst = new HashSet<>();
  13. dst.add(dstNode);
  14. final int srcVisTag = 1;
  15. final int dstVisTag = 2;
  16. int[] vis = new int[wordID.size()];
  17. vis[srcNode] = srcVisTag;
  18. vis[dstNode] = dstVisTag;
  19. int step = 0;
  20. while (!src.isEmpty() && !dst.isEmpty()) {
  21. step++;
  22. for (Integer node : dst) {
  23. if (src.contains(node)) {
  24. return step;
  25. }
  26. }
  27. final int visTag = src.size() < dst.size() ?
  28. srcVisTag : dstVisTag;
  29. Set<Integer> tmp = src.size() < dst.size() ? src : dst;
  30. Set<Integer> next = new HashSet<>();
  31. for (int startNode : tmp) {
  32. for (int nextNode : Graph[startNode]) {
  33. if (vis[nextNode] != visTag) {
  34. vis[nextNode] = visTag;
  35. next.add(nextNode);
  36. }
  37. }
  38. }
  39. if (src.size() < dst.size()) {
  40. src = next;
  41. } else {
  42. dst = next;
  43. }
  44. }
  45. return 0;
  46. }
  47. }

代码:Java/C++/Python

这里,我们将双向 BFS 与单向 BFS 进行一个比较,如下表所示:

18 | 单词接龙:如何巧用深搜与广搜的变形? - 图4

那么,双向 BFS 主要的优化在于:

  • 搜索时需要存放的信息更小了(因为搜索范围更小的优先),因此更加节省内存
  • 由于要处理的信息变少了,那么查找起来也会更快一些。

不过双向 BFS 还是在单向 BFS 上做常数上的优化。最差情况下,时间复杂度与空间复杂度仍然是在一个数量级的。

Dijkstra 算法

一般而言,最短路径问题,有三种:

  • 两点之间的最短路径(BFS 算法 / Dijkstra 算法 / BF 算法,即 Bellman-Ford 算法);
  • 一个点到其他所有点的最短路径(Dijkstra 算法 / BF 算法);
  • 每两点之间的最短路径(Floyd 算法)。

现在,我们先讨论一下,在计算两点间的最短路径的时候,什么时候应该使用 BFS 算法,什么时候应该使用 Dijkstra 算法?

  • 当图中边的权重都是 1 的时候,最好的办法是使用 BFS 算法。
  • 当图中边的权重非负的时候,最好的办法是使用 Dijkstra 算法。
  • 当图中的边的权重存在负值的时候,最好的办法是采用 BF 算法。

实际上,我们可以将权重为 1 的时候,看成权重不同的特例。那么,这里我们应该也可以使用 Dijkstra 算法。根据 Dijkstra 算法的思路(“03 | 优先级队列:堆与优先级队列,筛选最优元素”的 “练习题 7” 用到了 Dijkstra,以及“13 | 搜索:如何掌握 DFS 与 BFS 的解题套路?” 的 “例 5”),我们可以写出代码如下(解析在注释里):

  1. class Solution {
  2. public int ladderLength(String beginWord,
  3. String endWord,
  4. List<String> wordList) {
  5. if (!buildGraph(beginWord, endWord, wordList)) {
  6. return 0;
  7. }
  8. final int src = wordID.get(beginWord);
  9. final int target = wordID.get(endWord);
  10. int[] dist = new int[wordID.size()];
  11. for (int i = 0; i < dist.length; i++) {
  12. dist[i] = wordID.size() * wordID.size() + 100;
  13. }
  14. dist[src] = 0;
  15. Queue<Integer> Q = new PriorityQueue<>(
  16. (v1, v2) -> dist[v1] - dist[v2]);
  17. Q.add(src);
  18. while (!Q.isEmpty()) {
  19. final int startNode = Q.poll();
  20. final int startDist = dist[startNode];
  21. for (int nextNode : Graph[startNode]) {
  22. final int nextDist = startDist + 1;
  23. if (dist[nextNode] > nextDist) {
  24. dist[nextNode] = nextDist;
  25. Q.add(nextNode);
  26. }
  27. }
  28. }
  29. return dist[target] > wordID.size() ?
  30. 0 : dist[target] + 1;
  31. }
  32. }

代码:Java/C++/Python

复杂度分析:时间复杂度,由于 Dijkstra 算法的时间复杂度在有 N 个点的情况下,复杂度为 O(NlgN)。但是,整个题目的时间复杂度与空间复杂度仍然由 buildGraph 函数主导。与 BFS 的时间复杂度相同。

我们再对 Dijkstra 算法做个小小的总结,在使用 Dijkstra 算法的时候,有以下特点:

  • 并没有使用 vis 数组来进行标记;
  • 而是当发现一个点的最小距离变得更小的时候,就需要放到优先级队列中,然后重新展开搜索。

本讲中,我们提到了 BF 算法。不过 BF 算法在有 N 个点,E 条边的情况下,时间复杂度会达到 O(N x E)。在本题中,当单词长度为 M 时,最差情况下,一个单词可以有 M x 26 条边。一个图中的边可以达到 N x M x 26。此时,时间复杂度达到 O(N x N x M x 26),会出现超时的情况。关于这种情况,你可以自己求解一下下面这道练习题,本讲不再详细讨论。

练习题 1:有 N 个网络结点,标记为 1 到 N。给定一个列表 times,表示信号经过有向边的传递时间。 times[i] = (u, v, w),其中 u 是源结点,v 是目标结点,w 是一个信号从源结点传递到目标结点的时间。

现在,我们从某个结点 K 发出一个信号。需要多久才能使所有结点都收到信号?如果不能使所有结点收到信号,则返回 -1。

代码:Java/C++/Python

DFS 算法

不知道你有没有从 Dijkstra 的算法中找到灵感?在遍历的时候,我们不再使用 vis 数组来记录一个点是否被访问,而是利用最小距离是否被更新作为条件

那么在 DFS 的时候,是不是也可以这样操作?比如:

  1. private void dfs(List<Integer> G[], int start, int[] dist)
  2. {
  3. for (int nextNode : G[start]) {
  4. final int nextDist = dist[start] + 1;
  5. if (nextDist < dist[nextNode]) {
  6. dist[nextNode] = nextDist;
  7. dfs(G, nextNode, dist);
  8. }
  9. }
  10. }

那么,基于这种距离更新,就重新展开 DFS 的搜索方法,我们也可以写出新的 DFS 算法来解决这道题,代码如下(解析在注释里):

  1. class Solution {
  2. private void dfs(List<Integer> G[], int start, int[] dist)
  3. {
  4. for (int nextNode : G[start]) {
  5. final int nextDist = dist[start] + 1;
  6. if (nextDist < dist[nextNode]) {
  7. dist[nextNode] = nextDist;
  8. dfs(G, nextNode, dist);
  9. }
  10. }
  11. }
  12. public int ladderLength(String beginWord,
  13. String endWord,
  14. List<String> wordList) {
  15. if (!buildGraph(beginWord, endWord, wordList)) {
  16. return 0;
  17. }
  18. final int src = wordID.get(beginWord);
  19. final int dst = wordID.get(endWord);
  20. int[] dist = new int[wordID.size()];
  21. int maxPathLength = wordID.size() + 1024;
  22. for (int i = 0; i < dist.length; i++) {
  23. dist[i] = maxPathLength;
  24. }
  25. dist[src] = 0;
  26. dfs(Graph, src, dist);
  27. return dist[dst] >= maxPathLength ? 0 : dist[dst] + 1;
  28. }
  29. }

代码:Java/C++/Python

复杂度分析:假设一共有 N 个点,那么最差情况下,每个点会被其他点最多更新 N 次。因此,最差时间复杂度为 O(N2)。但是就整个题目而言,时间复杂度由构建图 buildGraph 的部分主导。综上,时间复杂度为 O(N M M),空间复杂度为 O(max(N2N * M))。

那么,接下来,我们考虑一下,Dijkstra 算法与 DFS 算法不同的地方。

虽然 Dijkstra 与 DFS 都不会再用到 vis 数组,并且都在点 nextNode 的距离被更新,然后重新展开搜索。但是依然存在不同的地方。

  • Dijkstra 算法是从优先级队列中拿出最优的点重新展开搜索。而且 Dijkstra 算法在用一个点更新的时候,会把这个点相邻的所有点更新之后,再重新展开搜索。
  • 而 DFS 算法却是立马从点 nextNode 重新展开搜索。

那么,有没有可能将 DFS 也改成 Dijkstra 这样呢?我们是不是发明了 DFS 也可以实现 Dijkstra 算法呢?基于这种思路,对 DFS 算法进行一下改写,代码如下(解析在注释里):

  1. class Solution {
  2. private int[] dist = null;
  3. private Queue<Integer> Q =
  4. new PriorityQueue<>((v1, v2) -> dist[v1] - dist[v2]);
  5. private void dfs() {
  6. if (Q.isEmpty()) {
  7. return;
  8. }
  9. final int startNode = Q.poll();
  10. for (int nextNode : Graph[startNode]) {
  11. final int nextDist = dist[startNode] + 1;
  12. if (nextDist < dist[nextNode]) {
  13. dist[nextNode] = nextDist;
  14. Q.add(nextNode);
  15. }
  16. }
  17. dfs();
  18. }
  19. public int ladderLength(String beginWord,
  20. String endWord,
  21. List<String> wordList) {
  22. if (!buildGraph(beginWord, endWord, wordList)) {
  23. return 0;
  24. }
  25. final int src = wordID.get(beginWord);
  26. final int dst = wordID.get(endWord);
  27. dist = new int[wordID.size()];
  28. int maxPathLength = wordID.size() + 1024;
  29. for (int i = 0; i < dist.length; i++) {
  30. dist[i] = maxPathLength;
  31. }
  32. dist[src] = 0;
  33. Q.add(src);
  34. dfs();
  35. return dist[dst] >= maxPathLength ? 0 : dist[dst] + 1;
  36. }
  37. }

代码:Java/C++/Python

我们发现,经过上述处理的 DFS,本质上与 Dijkstra 算法是一样的。在这里,我们将不同的算法的特点加以迁移(从原本是 Dijkstra 的特点,迁移到 DFS 算法),让不同的算法可以取得同样的效果。

总结

这一讲中,我们再次通过一个题目,挖掘了题目的信息 + 考点。

  • 信息:需要通过一定的条件生成边。
  • 考点:两点的最短路径。

当拿到这两部分信息之后,我们首先进行题目的预处理:建图。通过建图,让题目回到了一个我们非常熟悉的知识点:两点最短路径。接来下就是匹配到了已经学过的各种知识点,轮番上阵,也就展开了不同的破题方法。最后,我把这个题目中用到的知识点整理在下面这张思维导图中,你可以参考下图梳理一遍今天学到的重点知识。

18 | 单词接龙:如何巧用深搜与广搜的变形? - 图5

思考题

我再给你留一道思考题:在本讲介绍的题目基础上,我们找到最短的转换序列的长度之后。如果要输出所有的最短转换序列,应该怎么办呢?

输入:beginWord = “hit”, endWord = “cog”, wordList = [“hot”,”dot”,”dog”,”lot”,”log”,”cog”]

输出:[[“hit”,”hot”,”dot”,”dog”,”cog”],[“hit”,”hot”,”lot”,”log”,”cog”]]

解释:存在 2 种最短的转换序列:

“hit” -> “hot” -> “dot” -> “dog” -> “cog”

“hit” -> “hot” -> “lot” -> “log” -> “cog”

代码:Java/C++/Python

你可以自己尝试求解这道题目,把答案写在留言区,我们一起讨论。关于单词转换的题目就介绍到这里。接下来,下一讲介绍 “19 | 最小体力消耗路径:如何突破经典题型,掌握解题模板”,让我们继续前进。

附录:题目出处和代码汇总

| 题目 | 测试链接 | BFS 代码:Java/C++/Python
双向 BFS 代码:Java/C++/Python
Dijkstra 代码:Java/C++/Python
DFS + Q 代码:Java/C++/Python |
| —- | —- | —- |
| 练习题 1 | 测试链接 | 代码:Java/C++/Python |
| 思考题 | 测试链接 | 代码:Java/C++/Python |