搜索通常指在有限的状态空间中进行枚举,经过穷尽全部的可能来找到符合条件的解或者解的个数。根据搜索方式的不一样,搜索算法能够分为 DFS,BFS,A*算法等。这里只介绍 DFS 和 BFS,以及发生在 DFS 上一种技巧-回溯。前端
搜索问题覆盖面很是普遍,而且在算法题中也占据了很高的比例。我甚至还在公开演讲中提到了 前端算法面试中搜索类占据了很大的比重,尤为是国内公司。git
搜索专题中的子专题有不少,而你们所熟知的 BFS,DFS 只是其中特别基础的内容。除此以外,还有状态记录与维护,剪枝,联通份量,拓扑排序等等。这些内容,我会在这里一一给你们介绍。github
另外即便仅仅考虑 DFS 和 BFS 两种基本算法,里面能玩的花样也很是多。好比 BFS 的双向搜索,好比 DFS 的前中后序,迭代加深等等。面试
关于搜索,其实在二叉树部分已经作了介绍了。而这里的搜索,其实就是进一步的泛化。数据结构再也不局限于前面提到的数组,链表或者树。而扩展到了诸如二维数组,多叉树,图等。不过核心仍然是同样的,只不过数据结构发生了变化而已。算法
实际上搜索题目本质就是将题目中的状态映射为图中的点,将状态间的联系映射为图中的边。根据题目信息构建状态空间,而后对状态空间进行遍历,遍历过程须要记录和维护状态,并经过剪枝和数据结构等提升搜索效率。api
状态空间的数据结构不一样会致使算法不一样。好比对数组进行搜索,和对树,图进行搜索就不太同样。数组
再次强调一下,我这里讲的数组,树和图是状态空间的逻辑结构,而不是题目给的数据结构。好比题目给了一个数组,让你求数组的搜索子集。虽然题目给的线性的数据结构数组,然而实际上咱们是对树这种非线性数据结构进行搜索。这是由于这道题对应的状态空间是非线性的。数据结构
对于搜索问题,咱们核心关注的信息有哪些?又该如何计算呢?这也是搜索篇核心关注的。而市面上不少资料讲述的不是很详细。搜索的核心须要关注的指标有不少,好比树的深度,图的 DFS 序,图中两点间的距离等等。这些指标都是完成高级算法必不可少的,而这些指标能够经过一些经典算法来实现。这也是为何我一直强调必定要先学习好基础的数据结构与算法的缘由。app
不过要讲这些讲述完整并不是容易,以致于若是完整写完可能须要花不少的时间,所以一直没有动手去写。svg
另外因为其余数据结构均可以看作是图的特例。所以研究透图的基本思想,就很容易将其扩展到其余数据结构上,好比树。所以我打算围绕图进行讲解,并逐步具象化到其余特殊的数据结构,好比树。
结论先行:状态空间其实就是一个图结构,图中的节点表示状态,图中的边表示状态以前的联系,这种联系就是题目给出的各类关系。
搜索题目的状态空间一般是非线性的。好比上面提到的例子:求一个数组的子集。这里的状态空间实际上就是数组的各类组合。
对于这道题来讲,其状态空间的一种可行的划分方式为:
而如何肯定上面全部的子集呢。
一种可行的方案是能够采起相似分治的方式逐一肯定。
好比咱们能够:
如何肯定第一个数,第二个数。。。呢?
暴力枚举全部可能就能够了。
这就是搜索问题的核心,其余都是辅助,因此这句话请务必记住。
所谓的暴力枚举全部可能在这里就是尝试数组中全部可能的数字。
据此,你能够画出以下的决策树。
(下图描述的是对一个长度为 3 的数组进行决策的部分过程,树节点中的数字表示索引。即肯定第一个数有三个选择,肯定第二个数会根据上次的选择变为剩下的两个选择)
决策过程动图演示:
一些搜索算法就是基于这个朴素的思想,本质就是模拟这个决策树。这里面其实也有不少有趣的细节,后面咱们会对其进行更加详细的讲解。而如今你们只须要对解空间是什么以及如何对解空间进行遍历有一点概念就好了。 后面我会继续对这个概念进行加深。
这里你们只要记住状态空间就是图,构建状态空间就是构建图。如何构建呢?固然是根据题目描述了 。
DFS 和 BFS 是搜索的核心,贯穿搜索篇的始终,所以有必要先对其进行讲解。
DFS 的概念来自于图论,可是搜索中 DFS 和图论中 DFS 仍是有一些区别,搜索中 DFS 通常指的是经过递归函数实现暴力枚举。
若是不使用递归,也可使用栈来实现。不过本质上是相似的。
首先将题目的状态空间映射到一张图,状态就是图中的节点,状态之间的联系就是图中的边,那么 DFS 就是在这种图上进行深度优先的遍历。而 BFS 也是相似,只不过遍历的策略变为了广度优先,一层层铺开而已。因此BFS 和 DFS 只是遍历这个状态图的两种方式罢了,如何构建状态图才是关键。
本质上,对上面的图进行遍历的话会生成一颗搜索树。为了避免重复访问,咱们须要记录已经访问过的节点。这些是全部的搜索算法共有的,后面再也不赘述。
若是你是在树上进行遍历,是不会有环的,也天然不须要为了避免环的产生记录已经访问的节点,这是由于树本质上是一个简单无环图。
这里的 stack 能够理解为自实现的栈,也能够理解为调用栈
下面咱们借助递归来完成 DFS。
const visited = {} function dfs(i) { if (知足特定条件){ // 返回结果 or 退出搜索空间 } visited[i] = true // 将当前状态标为已搜索 for (根据i能到达的下个状态j) { if (!visited[j]) { // 若是状态j没有被搜索过 dfs(j) } } }
DFS 常见的形式有前序和后序。两者的使用场景也是大相径庭的。
上面讲述了搜索本质就是在状态空间进行遍历,空间中的状态能够抽象为图中的点。那么若是搜索过程当中,当前点的结果须要依赖其余节点(大多数状况都会有依赖),那么遍历顺序就变得重要。
好比当前节点须要依赖其子节点的计算信息,那么使用后序遍历自底向上递推就显得必要了。而若是当前节点须要依赖其父节点的信息,那么使用先序遍历进行自顶向下的递归就不难想到。
好比下文要讲的计算树的深度。因为树的深度的递归公式为: $f(x) = f(y) + 1$。其中 f(x) 表示节点 x 的深度,而且 x 是 y 的子节点。很明显这个递推公式的 base case 就是根节点深度为一,经过这个 base case 咱们能够递推求出树中任意节点的深度。显然,使用先序遍历自顶向下的方式统计是简单而又直接的。
再好比下文要讲的计算树的子节点个数。因为树的子节点递归公式为: $f(x) = sum_{i=0}^{n}{f(a_i)}$ 其中 x 为树中的某一个节点,$a_i$ 为树中节点的子节点。而 base case 则是没有任何子节点(也就是叶子节点),此时 $f(x) = 1$。 所以咱们能够利用后序遍历自底向上来完成子节点个数的统计。
关于从递推关系分析使用何种遍历方法, 我在《91 天学算法》中的基础篇中的《模拟,枚举与递推》子专题中对此进行了详细的描述。91 学员能够直接进行查看。关于树的各类遍历方法,我在树专题中进行了详细的介绍。
迭代加深本质上是一种可行性的剪枝。关于剪枝,我会在后面的《回溯与剪枝》部分作更多的介绍。
所谓迭代加深指的是在递归树比较深的时候,经过设定递归深度阈值,超过阈值就退出的方式主动减小递归深度的优化手段。这种算法成立的前提是题目中告诉咱们答案不超过 xxx,这样咱们能够将 xxx 做为递归深度阈值,这样不只不会错过正确解,还能在极端状况下有效减小没必要须的运算。
具体地,咱们可使用自顶向下的方式记录递归树的层次,和上面介绍如何计算树深度的方法是同样的。接下来在主逻辑前增长当前层次是否超过阈值的判断便可。
主代码:
MAX_LEVEL = 20 def dfs(root, level): if level > MAX_LEVEL: return # 主逻辑 dfs(root, 0)
这种技巧在实际使用中并不常见,不过在某些时候能发挥意想不到的做用。
有时候问题规模很大,直接搜索会超时。此时能够考虑从起点搜索到问题规模的一半。而后将此过程当中产生的状态存起来。接下来目标转化为在存储的中间状态中寻找知足条件的状态。进而达到下降时间复杂度的效果。
上面的说法可能不太容易理解。 接下来经过一个例子帮助你们理解。
https://leetcode-cn.com/probl...
给你一个整数数组 nums 和一个目标值 goal 。 你须要从 nums 中选出一个子序列,使子序列元素总和最接近 goal 。也就是说,若是子序列元素和为 sum ,你须要 最小化绝对差 abs(sum - goal) 。 返回 abs(sum - goal) 可能的 最小值 。 注意,数组的子序列是经过移除原始数组中的某些元素(可能所有或无)而造成的数组。 示例 1: 输入:nums = [5,-7,3,5], goal = 6 输出:0 解释:选择整个数组做为选出的子序列,元素和为 6 。 子序列和与目标值相等,因此绝对差为 0 。 示例 2: 输入:nums = [7,-9,15,-2], goal = -5 输出:1 解释:选出子序列 [7,-9,-2] ,元素和为 -4 。 绝对差为 abs(-4 - (-5)) = abs(1) = 1 ,是可能的最小值。 示例 3: 输入:nums = [1,2,3], goal = -7 输出:7 提示: 1 <= nums.length <= 40 -10^7 <= nums[i] <= 10^7 -10^9 <= goal <= 10^9
从数据范围能够看出,这道题大几率是一个 $O(2^m)$ 时间复杂度的解法,其中 m 是 nums.length 的一半。
为何?首先若是题目数组长度限制为小于等于 20,那么大几率是一个 $O(2^n)$ 的解法。
若是这个也不知道,建议看一下这篇文章 https://lucifer.ren/blog/2020... 另外个人刷题插件 leetcode-cheatsheet 也给出了时间复杂度速查表供你们参考。
将 40 砍半刚好就能够 AC 了。实际上,40 这个数字就是一个强有力的信号。
回到题目中。咱们能够用一个二进制位表示原数组 nums 的一个子集,这样用一个长度为 $2^n$ 的数组就能够描述 nums 的全部子集了,这就是状态压缩。通常题目数据范围是 <= 20 都应该想到。
这里 40 折半就是 20 了。
若是不熟悉状态压缩,能够看下个人这篇文章 状压 DP 是什么?这篇题解带你入门
接下来,咱们使用动态规划求出全部的子集和。
这也不难求出,转移方程为 : dp[(1 << i) + j] = dp[j] + A[i]
,其中 j 为 i 的子集,i 和 j 都是数字,i 和 j 的二进制表示的是 nums 的选择状况。
动态规划求子集和代码以下:
def combine_sum(A): n = len(A) dp = [0] * (1 << n) for i in range(n): for j in range(1 << i): dp[(1 << i) + j] = dp[j] + A[i] return dp
接下来,咱们将 nums 平分为两部分,分别计算子集和:
n = len(nums) c1 = combine_sum(nums[: n // 2]) c2 = combine_sum(nums[n // 2 :])
其中 c1 就是前半部分数组的子集和,c2 就是后半部分的子集和。
接下来问题转化为:在两个数组 c1 和 c2中找两个数,其和最接近 goal
。而这是一个很是经典的双指针问题,逻辑相似两数和。
只不过两数和是一个数组挑两个数,这里是两个数组分别挑一个数罢了。
这里其实只须要一个指针指向一个数组的头,另一个指向另一个数组的尾便可。
代码不难写出:
def combine_closest(c1, c2): # 先排序以便使用双指针 c1.sort() c2.sort() ans = float("inf") i, j = 0, len(c2) - 1 while i < len(c1) and j >= 0: _sum = c1[i] + c2[j] ans = min(ans, abs(_sum - goal)) if _sum > goal: j -= 1 elif _sum < goal: i += 1 else: return 0 return ans
上面这个代码不懂的多看看两数和。
代码支持:Python3
Python3 Code:
class Solution: def minAbsDifference(self, nums: List[int], goal: int) -> int: def combine_sum(A): n = len(A) dp = [0] * (1 << n) for i in range(n): for j in range(1 << i): dp[(1 << i) + j] = dp[j] + A[i] return dp def combine_closest(c1, c2): c1.sort() c2.sort() ans = float("inf") i, j = 0, len(c2) - 1 while i < len(c1) and j >= 0: _sum = c1[i] + c2[j] ans = min(ans, abs(_sum - goal)) if _sum > goal: j -= 1 elif _sum < goal: i += 1 else: return 0 return ans n = len(nums) return combine_closest(combine_sum(nums[: n // 2]), combine_sum(nums[n // 2 :]))
复杂度分析
令 n 为数组长度, m 为 $\frac{n}{2}$。
相关题目推荐:
这道题和双向搜索有什么关系呢?
回一下开头个人话:有时候问题规模很大,直接搜索会超时。此时能够考虑从起点搜索到问题规模的一半。而后将此过程当中产生的状态存起来。接下来目标转化为在存储的中间状态中寻找知足条件的状态。进而达到下降时间复杂度的效果。
对应这道题,咱们若是直接暴力搜索。那就是枚举全部子集和,而后找到和 goal 最接近的,思路简单直接。但是这样会超时,那么就搜索到一半, 而后将状态存起来(对应这道题就是存到了 dp 数组)。接下来问题转化为两个 dp 数组的运算。该算法,本质上是将位于指数位的常数项挪动到了系数位。这是一种常见的双向搜索,我姑且称为 DFS 的双向搜索。目的是为了和后面的 BFS 双向搜索进行区分。
BFS 也是图论中算法的一种。不一样于 DFS, BFS 采用横向搜索的方式,从初始状态一层层展开直到目标状态,在数据结构上一般采用队列结构。
具体地,咱们不断从队头取出状态,而后将此状态对应的决策产生的全部新的状态推入队尾,重复以上过程直至队列为空便可。
注意这里有两个关键点:
最简单的 BFS 每次扩展新的状态就增长一步,经过这样一步步逼近答案。其实也就等价于在一个权值为 1 的图上进行 BFS。因为队列的单调性和二值性,当第一次取出目标状态时就是最少的步数。基于这个特性,BFS 适合求解一些最少操做的题目。
关于单调性和二值性,我会在后面的 BFS 和 DFS 的对比那块进行讲解。
前面 DFS 部分提到了无论是什么搜索都须要记录和维护状态,其中一个就是节点访问状态以防止环的产生。而 BFS 中咱们经常用来求点的最短距离。值得注意的是,有时候咱们会使用一个哈希表 dist 来记录从源点到图中其余点的距离。这个 dist 也能够充当防止环产生的功能,这是由于第一次到达一个点后再次到达此点的距离必定比第一次到达大,利用这点就可知道是不是第一次访问了。
从队列中取出第一个节点,并检验它是否为目标。
const visited = {} function bfs() { let q = new Queue() q.push(初始状态) while(q.length) { let i = q.pop() if (visited[i]) continue for (i的可抵达状态j) { if (j 合法) { q.push(j) } } } // 找到全部合法解 }
https://leetcode-cn.com/probl...
按字典 wordList 完成从单词 beginWord 到单词 endWord 转化,一个表示此过程的 转换序列 是形式上像 beginWord -> s1 -> s2 -> ... -> sk 这样的单词序列,并知足: 每对相邻的单词之间仅有单个字母不一样。 转换过程当中的每一个单词 si(1 <= i <= k)必须是字典 wordList 中的单词。注意,beginWord 没必要是字典 wordList 中的单词。 sk == endWord 给你两个单词 beginWord 和 endWord ,以及一个字典 wordList 。请你找出并返回全部从 beginWord 到 endWord 的 最短转换序列 ,若是不存在这样的转换序列,返回一个空列表。每一个序列都应该以单词列表 [beginWord, s1, s2, ..., sk] 的形式返回。 示例 1: 输入: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" 示例 2: 输入:beginWord = "hit", endWord = "cog", wordList = ["hot","dot","dog","lot","log"] 输出:[] 解释:endWord "cog" 不在字典 wordList 中,因此不存在符合要求的转换序列。 提示: 1 <= beginWord.length <= 7 endWord.length == beginWord.length 1 <= wordList.length <= 5000 wordList[i].length == beginWord.length beginWord、endWord 和 wordList[i] 由小写英文字母组成 beginWord != endWord wordList 中的全部单词 互不相同
这道题就是咱们平常玩的成语接龙游戏。即让你从 beginWord 开始, 接龙的 endWord。让你找到最短的接龙方式,若是有多个,则所有返回。
不一样于成语接龙的字首接字尾。这种接龙须要的是下一个单词和上一个单词仅有一个单词不一样。
咱们能够对问题进行抽象:即构建一个大小为 n 的图,图中的每个点表示一个单词,咱们的目标是找到一条从节点 beginWord 到节点 endWord 的一条最短路径。
这是一个彻彻底底的图上 BFS 的题目。套用上面的解题模板能够轻松解决。惟一须要注意的是如何构建图。更进一步说就是如何构建边。
由题目信息的转换规则:每对相邻的单词之间仅有单个字母不一样。不难知道,若是两个单词的仅有单个字母不一样 ,就说明二者之间有一条边。
明白了这一点,咱们就能够构建邻接矩阵了。
核心代码:
neighbors = collections.defaultdict(list) for word in wordList: for i in range(len(word)): neighbors[word[:i] + "*" + word[i + 1 :]].append(word)
构建好了图。 BFS 剩下要作的就是明确起点和终点就行了。对于这道题来讲,起点是 beginWord,终点是 endWord。
那咱们就能够将 beginWord 入队。不断在图上作 BFS,直到第一次遇到 endWord 就行了。
套用上面的 BFS 模板,不难写出以下代码:
这里我用了 cost 而不是 visitd,目的是为了让你们见识多种写法。下面的优化解法会使用 visited 来记录。
class Solution: def findLadders(self, beginWord: str, endWord: str, wordList: List[str]) -> List[List[str]]: cost = collections.defaultdict(lambda: float("inf")) cost[beginWord] = 0 neighbors = collections.defaultdict(list) ans = [] for word in wordList: for i in range(len(word)): neighbors[word[:i] + "*" + word[i + 1 :]].append(word) q = collections.deque([[beginWord]]) while q: path = q.popleft() cur = path[-1] if cur == endWord: ans.append(path.copy()) else: for i in range(len(cur)): for neighbor in neighbors[cur[:i] + "*" + cur[i + 1 :]]: if cost[cur] + 1 <= cost[neighbor]: q.append(path + [neighbor]) cost[neighbor] = cost[cur] + 1 return ans
当终点能够逆向搜索的时候,咱们也能够尝试双向 BFS。更本质一点就是:若是你构建的状态空间的边是双向的,那么就可使用双向 BFS。
和 DFS 的双向搜索思想是相似的。咱们只须要使用两个队列分别存储中起点和终点进行扩展的节点(我称其为起点集与终点集)便可。当起点和终点在某一时刻交汇了,说明找到了一个从起点到终点的路径,其路径长度就是两个队列扩展的路径长度和。
以上就是双向搜索的大致思路。用图来表示就是这样的:
如上图,咱们从起点和重点(A 和 Z)分别开始搜索,若是起点的扩展状态和终点的扩展状态重叠(本质上就是队列中的元素重叠了),那么咱们就知道了一个从节点到终点的最短路径。
动图演示:
看到这里有必要暂停一下插几句话。
为何双向搜索就快了?什么状况都会更快么?那为何不都用双向搜索?有哪些使用条件?
咱们一个个回答。
能够看出搜索树大了不少,以致于不少点我都画不下,只好用 ”。。。“ 来表示。
如图使用单向搜索仍是双向搜索都是同样的。
让咱们继续回到这道题。为了可以判断二者是否交汇,咱们可使用两个 hashSet 分别存储起点集合终点集。当一个节点既出现起点集又出如今终点集,那就说明出现了交汇。
为了节省代码量以及空间消耗,我没有使用上面的队列,而是直接使用了哈希表来代替队列。这种作法可行的关键仍然是上面提到的队列的二值性和单调性。
因为新一轮的出队列前,队列中的权值都是相同的。所以从左到右遍历或者从右到左遍历,甚至是任意顺序遍历都是无所谓的。(不少题都无所谓)所以使用哈希表而不是队列也是能够的。这点须要引发你们的注意。但愿你们对 BFS 的本质有更深的理解。
那咱们是否是不须要队列,就用哈希表,哈希集合啥的存就好了?非也!我会在双端队列部分为你们揭晓。
这道题的具体算法:
Python3 Code:
class Solution: def findLadders(self, beginWord: str, endWord: str, wordList: list) -> list: # 剪枝 1 if endWord not in wordList: return [] ans = [] visited = set() q1, q2 = {beginWord: [[beginWord]]}, {endWord: [[endWord]]} steps = 0 # 预处理,空间换时间 neighbors = collections.defaultdict(list) for word in wordList: for i in range(len(word)): neighbors[word[:i] + "*" + word[i + 1 :]].append(word) while q1: # 剪枝 2 if len(q1) > len(q2): q1, q2 = q2, q1 nxt = collections.defaultdict(list) for _ in range(len(q1)): word, paths = q1.popitem() visited.add(word) for i in range(len(word)): for neighbor in neighbors[word[:i] + "*" + word[i + 1 :]]: if neighbor in q2: # 从 beginWord 扩展过来的 if paths[0][0] == beginWord: ans += [path1 + path2[::-1] for path1 in paths for path2 in q2[neighbor]] # 从 endWord 扩展过来的 else: ans += [path2 + path1[::-1] for path1 in paths for path2 in q2[neighbor]] if neighbor in wordList and neighbor not in visited: nxt[neighbor] += [path + [neighbor] for path in paths] steps += 1 # 剪枝 3 if ans and steps + 2 > len(ans[0]): break q1 = nxt return ans
我想经过这道题给你们传递的知识点不少。分别是:
上面提到了 BFS 本质上能够看作是在一个边权值为 1 的图上进行遍历。实际上,咱们能够进行一个简单的扩展。若是图中边权值不全是 1,而是 0 和 1 呢?这样其实咱们用到双端队列。
双端队列能够在头部和尾部同时进行插入和删除,而普通的队列仅容许在头部删除,在尾部插入。
使用双端队列,当每次取出一个状态的时候。若是咱们能够无代价的进行转移,那么就能够将其直接放在队头,不然放在队尾。由前面讲的队列的单调性和二值性不可贵出算法的正确性。而若是状态转移是有代价的,那么就将其放到队尾便可。这也是不少语言提供的内置数据结构是双端队列,而不是队列的缘由之一。
以下图:
上面的队列是普通的队列。 而下面的双端队列,能够看出咱们在队头插队了一个 B。
动图演示:
思考:若是图对应的权值不出 0 和 1,而是任意正整数呢?
前面咱们提到了是否是不须要队列,就用哈希表,哈希集合啥的存就好了? 这里为你们揭秘。不能够的。由于哈希表没法处理这里的权值为 0 的状况。
BFS 和 DFS 分别处理什么样的问题?二者究竟有什么样的区别?这些都值得咱们认真研究。
简单来讲,无论是 DFS 仍是 BFS 都是对题目对应的状态空间进行搜索。
具体来讲,两者区别在于:
以下图,咱们遍历到 A,有三个选择。此时咱们能够任意选择一条,好比选择了 B,程序会继续往下进行选择分支 2,3 。。。
以下动图演示了一个典型的 DFS 流程。后面的章节,咱们会给你们带来更复杂的图上 DFS。
以下图,广度优先遍历会将搜索的选择所有选择一遍会才会进入到下一层。和上面同样,我给你们标注了程序执行的一种可能的顺序。
能够发现,和我上面说的同样。右侧的队列始终最多有两层的节点,而且相同层的总在一块儿,换句话说队列的元素在层上知足单调性。
以下动图演示了一个典型的 BFS 流程。后面的章节,咱们会给你们带来更复杂的图上 BFS。
以上就是《搜索篇(上)》的全部内容了。总结一下搜索篇的解题思路:
咱们花了大量的篇幅对 BFS 和 DFS 进行了详细的讲解,包括两个的对比。
核心点须要你们注意:
双向搜索的本质是将复杂度的常数项从一个影响较大的位置(好比指数位)移到了影响较小的位置(好比系数位)。
搜索篇知识点比较密集,但愿你们多多总结复习。
下一节,咱们介绍:
经常使用的指标与统计方法。具体包括:
下节内容会首发在《91 天学算法》。想参加的能够戳这里了解详情: https://lucifer.ren/blog/2021...