众里寻她千百度
常见查找算法
深度优先查找DFS(不撞南墙不回头)
基本模型
递归
1 | void dfs(int step){ |
graph TB node("开始") judge1{"已遍历所有节点?"} node2("结束") dfs["进行深度优先搜索"] node-->judge1 judge1-->|N|dfs dfs-->judge1 judge1-->|Y|node2
迭代
深度优先搜索通常使用递归形式实现,如果要改为迭代形式,那么需要利用栈。
1 | void dfs(int step){ |
难点总结
理解dfs
在dfs中,每进行一次递归,就相当于深入一层。有些情况下可能会有不同路径,例如在迷宫类问题中,有上下左右四个方向,此时需要进行四次递归,相当于走了四条路径,构成了一个四叉树。
dfs思路
dfs分为两个阶段,在写dfs时要考虑清楚每个阶段的过程:
- 自上而下搜索
- 自下而上计算结果并返回
- 在搜索的叶节点处直接返回叶节点值
- 在非叶节点处根据下层计算结果与当前节点值进行计算
这里以leetcode120. 三角形最小路径和为例,对dfs思路进行整理。给定一个三角形,找出自顶向下的最小路径和。每一步只能移动到下一行中相邻的结点上。
例如,给定三角形:
1 | [ |
自顶向下的最小路径和为 11
(即,2 + 3 + 5 + 1 = 11)。
1 | int helper(vector<vector<int>>& triangle, int row, int col) { |
搜索入口
为了保证搜索覆盖的全面性,我们需要对所有的待搜索的点进行搜索,例如我们对一个有$n$个节点的图进行搜索,代码框架如下:
1 | for (int i = 0; i < n; ++i) { |
即使图中有不相连的部分,因为我们经过了遍历,所以深度优先搜索能保证所有的节点都访问到。
边界判断
边界判断是DFS的重要部分,保证了DFS能够顺利退出递归,以下总结一些常见的DFS退出条件。首先,由于DFS是递归算法,所以很可能会搜索到重复的位置,因此往往需要一个visited向量,用于保存已经访问的位置,如果已经visited,那么直接返回。
- 指针类DFS
多见于处理链表或二叉树问题的DFS中,常见退出条件如下:
- head == nullptr:return head;
- visited(head) == true:return head;
- 数组类DFS
常见退出条件如下:
- 范围超过数组边界:
if(cur_m < 0 || cur_n < 0 ||cur_m >= m || cur_n >= n)
- 已经访问过指定位置:
if(visited[cur_m][cur_n] == 1)
- 所在位置不满足搜索要求:
if(int_to_sum(cur_m) + int_to_sum(cur_n) != k)
- 给定数组搜索范围
[left,right)
(左开右闭区间),那么搜索结束的标志为left == right,见leetcode从前序与中序遍历序列构造二叉树
返回值
递归由于涉及到多次函数调用,因此返回值的计算可能会比较麻烦,这里针对dfs给出一个比较套路化的返回规则,首先,如果不满足条件,那么就在进入递归函数后立刻返回;其次,最终的返回值应当放在递归函数结尾给出。
1 | int traverse(TreeNode* root){ |
何时增加深度
当明确有一条搜索路径时,我们再去增加递归深度,例如在二叉树搜索中,如果我们明确了某个子节点不为空,再向这条子节点进行搜索,这样可以减少递归深度,提高效率:
1 | if(root->left!=nullptr){ //左节点存在时,再进行深度搜索哦 |
被搜索节点的状态
在深度优先搜索中,一个被搜索节点一般有三个状态:
- 未搜索:该节点尚未被搜索
- 搜索中:我们正在搜索该节点和相邻的节点,但是该节点相邻的节点还未搜索完成
- 已完成:我们搜索过所有的相邻节点,回溯了该节点,搜索已完成
剪枝
在某些情况下,我们需要剪枝操作,例如当前位置和前一个位置相同,那么我们直接跳过该步骤即可,剪枝的操作代码如下:
1 | if(i != start && s[i] == s[i-1]) continue; //去除重复 |
将上述语句放入dfs的for循环中,即可实现剪枝操作。
常见DFS类型总结及模板
判断搜索路径是否存在
此类问题要求我们判断搜索树中是否存在一条符合条件的搜索路径,因此搜索过程中,一旦找到了路径,我们应当立刻返回,以免不必要的递归造成效率的降低。这里以面试题 04.01. 节点间通路为例,对此类问题模板进行总结。
思路
我们希望找到答案后直接返回,而找到答案的可能性有两种,一种是在本层中直接找到答案;另一种是在深层递归中找到答案。因此我们需要进行两次判断,当在本层中找到答案后直接返回;当在下层搜索后,判断是否找到答案,然后根据情况中断搜索,模板如下:
1 | bool search(int start,int target){ |
代码
1 | bool search(int start,int target){ |
从上面的代码中我们可以看到,此时判断是否退出的条件放在了循环体中,意味着我们找到答案后会立刻退出,另外递归后也加了一条判断语句,确保找到答案立刻退出。
获取累加值
有些dfs问题需要我们记录累加值,这种情况下,我们需要在dfs开始时设置一个临时变量,然后结尾处返回该临时变量,每个递归函数都会定义自己的局部变量,而第一次递归时定义的局部变量即为最终的计算结果。一个框架如下所示:
1 | int dfs(int depth){ |
计算递归深度
在有些情况下,我们需要计算递归树的深度,例如计算二叉树的深度,其代码如下:
1 | int maxDepth(TreeNode* root) { |
以此类推,我们可以延伸到计算任意递归函数的深度,只需要循环遍历所有分支,找到分支最大深度即可。例如leetcode364. 加权嵌套序列和 II,计算递归深度的代码如下:
1 | int getSum(vector<NestedInteger>& nestedList, int depth){ |
获得所有可能解的DFS
例题
删除最小数量的无效括号,使得输入的字符串有效,返回所有可能的结果。说明: 输入可能包含了除 (
和 )
以外的字符。
思路
这个问题可以划分为三个子问题:
- 判断括号是否合法
- 计算有多少个左右括号需要删除
- 递归删除括号,得到结果后进行保存
前两个子问题我们可以通过栈实现,后一个可以通过DFS实现 ,重点是第三个问题,第三个问题中,我们又可以列举出如下几个问题:
- 先删除哪种括号?先删除右括号,如果先删除左括号,可能导致前缀非法,例如对于
())(
,如果我们先删除了第一个左括号,那么会变成))(
,原来合法的前缀反而不合法了。这样会构成额外的枝。 - 如何避免重复解 ,例如对于
(()
,删掉第一个和第二个左括号,得到的结果都为()
,所以我们需要剪枝操作
例子
我们以s = ")())("
为例,需要删除两个右括号,一个左括号,那么我们的递归函数为dfs(s, l=1, r=2)
,删除过程的递归树如下:
graph TB node["dfs( s = )())( , l=1, r=2)"] node1["dfs(s = *())( , l=1, r=1)"] node2["dfs(s = )(*)( , l=1, r=1)"] node3["dfs(s = )()*( , l=1, r=2)"] node4["dfs(s = *(*)( , l=1, r=0)"] node5["dfs(s = *(*)* , l=0, r=0): 合法"] node6["dfs(s = )(**( , l=1, r=0)"] node7["dfs(s = )(*** , l=0, r=0): 非法"] node---node1 node---node2 node--冗余枝条---node3 node1---node4 node4---node5 node2---node6 node6---node7
可以看出递归深度为$l+r$,所以时间复杂度为$2^{l+r}$
代码
1 | bool isValid(const string& s){ |
DFS优化
动态规划与DFS结合
在深度优先搜索中,可能会产生大量重复计算,如果我们能像动态规划那样,牺牲一些空间来保存中间结果,能够有效减少计算时间,DFS与DP结合的代码框架如下,其中有两个关键步骤,记录与提取:
1 | int helper(vector<vector<int>>& board, |
给定一个整数矩阵,找出最长递增路径的长度。对于每个单元格,你可以往上,下,左,右四个方向移动。 你不能在对角线方向上移动或移动到边界外(即不允许环绕)。
1 | 示例 1: |
代码
1 | class Solution { |
广度优先搜索BFS
特点
广度优先搜索能够保证在搜索到d+1
距离的位置时,距离为d
的位置都已经搜索过,所以可以使用广度优先搜索处理例如迷宫中最短路径等问题。
实现
- 创建队列保存搜索点
- 保存初始搜索点
- 弹出搜索点,并以搜索点为基准,遍历所有搜索分支,例如在迷宫当中,有上下左右四个方向,即四个搜索分支
- 每个搜索分支下,添加新的搜索点,扩大搜索范围
基本模型
1 | //广度优先通常采用迭代形式 |
广度优先搜索一般要利用队列,来实现对于一层的存储。
难点及要点总结
广度优先过程中会出现重复搜索的问题,即相邻两个待搜索对象,第一个时刻由对象1搜索到了对象2,第二个时刻又由对象2搜索到了对象1,解决方法是使用visited矩阵或集合进行标记,已经搜索过的就直接跳过。
DFS与BFS的比较
这张图直观地对DFS与BFS进行了比较:
专题:图的DFS和BFS搜索
DFS搜索
1 | //在图中搜索所有为'O'的格子,然后将格子置为'#' |
BFS搜索
1 | Queue<int[]> q = new LinkedList<>(); |
如果用画图的形式解释BFS在图中的搜索,那么其形式如下:
1 | //以传染病模型为例,其中1是被感染的人,一开始他会将上下左右四个方向上的人全部感染,产生四个新感染的人,即四个新的搜索点 |
专题:岛屿问题
岛屿问题是经典的二维搜索问题,本节将对常见的岛屿问题进行总结。
求连通域数目
- leetcode 695
求4-连通域最大面积,这个问题很经典,需要记住代码
1 | [[0,0,1,0,0,0,0,1,0,0,0,0,0], |
答案
1 | class Solution { |
130. 被围绕的区域搜索
给定一个二维的矩阵,包含 ‘X’ 和 ‘O’(字母 O)。找到所有被 ‘X’ 围绕的区域,并将这些区域里所有的 ‘O’ 用 ‘X’ 填充。
1 | X X X X |
运行函数后,矩阵变为:
1 | X X X X |
思路
如果我们认为O所在位置为海岛,那么该问题的本质就是求所有与边界不联通的海岛的位置,然后进行替换。所以我们应当从边界位置处的海岛O开始搜索,找到所有的和边界相连的海岛,那么剩下的海岛就一定是被X包围的。
任务
- 找到边界岛屿
- 从找到的边界岛屿处开始搜索(每一个边界岛屿都要作为搜索的起始点)
- 搜索过程中,将与边界相连的海岛替换为#(方便后续恢复)
- 遍历棋盘,将不与边界相连的海岛淹没,将与边界相连的海岛恢复
代码
解法1:深度优先搜索
1 | class Solution { |
专题:迷宫问题
迷宫问题中,我们可能遇到有效路径、遍历迷宫以及最短路径等问题,本节将针对常见的迷宫问题进行总结
寻找迷宫中的门
你被给定一个 m × n 的二维网格,网格中有以下三种可能的初始化值:
-1
表示墙或是障碍物0
表示一扇门INF
无限表示一个空的房间。然后,我们用231 - 1 = 2147483647
代表INF
。你可以认为通往门的距离总是小于2147483647
的。你要给每个空房间位上填上该房间到 最近 门的距离,如果无法到达门,则填
INF
即可。例如,给定迷宫
1
2
3
4
5 >INF -1 0 INF
>INF INF INF -1
>INF -1 INF -1
> 0 -1 INF INF
>
>
输出为:
1
2
3
4
5 > 3 -1 0 1
> 2 2 1 -1
> 1 -1 2 -1
> 0 -1 3 4
>
思路
从门使用BFS进行搜索,能够以扩散传播的方式,对周边区域进行搜索,从而确保周围的房间能够到达最近的门
任务
- 找到门坐标
- 从门坐标开始,进行深度优先搜索
代码
1 | void wallsAndGates(vector<vector<int>>& rooms) { |
判断是否能走到终点
迷宫三问
迷宫II是对迷宫I的问题的延伸,我们不仅要判断能否到达终点,还要记录从起点到终点的最短路径。这里我们定义了一个distance矩阵,把每一个可以到达的方块距离起点的最短距离记录下来,然后返回目标点所在的值即可
1 |
|
迷宫遍历
有些问题需要我们遍历迷宫中所有可以行走的路径,例如489. 扫地机器人
房间(用格栅表示)中有一个扫地机器人。格栅中的每一个格子有空和障碍物两种可能。扫地机器人提供4个API,可以向前进,向左转或者向右转。每次转弯90度。当扫地机器人试图进入障碍物格子时,它的碰撞传感器会探测出障碍物,使它停留在原地。请利用提供的4个API编写让机器人清理整个房间的算法。
1 | interface Robot { |
思路
由于我们不清楚起始位置在哪里,因此只能使用相对信息对位置坐标进行记录,令起始点坐标为(0,0),然后实施dfs
代码
1 | public class Pair<F, S> { |
二分查找(常常和二叉树一起出现)
基本原理
每一次循环都能排除掉一半以上的元素,最后把区间限定在一个元素,二分查找要求被查找的对象必须经过排序。循环条件为left < right,如果被查找元素未经排序,那么循环条件可能发生改变,例如LeetCode154旋转数组。
算法要求
- 必须采用顺序存储结构
- 必须按照关键字大小有序排列
算法适用场景及每个场景对应框架(重要!)
二分查找最适用的场景包括三个:寻找一个数、寻找左侧边界、寻找右侧边界。大部分涉及寻找特定值$x$的问题,都可以使用二分查找进行解决,例如寻找一个数的整数开方问题等。
二分查找基本框架2
1 | int binarySearch(vector<int>& nums, int target) { |
寻找一个数
1 | int binary_search(vector<int>& nums, int target) { |
寻找左侧边界的二分查找
1 | int left_bound(vector<int>& nums, int target) { |
寻找右侧边界的二分查找
1 | int right_bound(int[] nums, int target) { |
算法要点及难点
本节将对二分查找的一些难点及要点进行总结。
二维矩阵中的二分查找
有些情况下,我们需要对二维矩阵进行二分查找的操作,如果我们按照先分行再分列的方法,搜索效率很低,因此我们采用整体搜索法,假设矩阵大小为$m×n$,那么待搜索的元素的个数为$m×n$,所以left = 0, right = m*n-1
,在矩阵中进行二分查找的框架如下所示,时间复杂度为$O(log(mn))$:
1 | int left = 0, right = m * n - 1; |
算法讲解
注:以下内容在难以判断循环结束后left是否为搜索位置时较为好用
- 取中位数索引
二分法第一个麻烦的问题就是对中位数索引的判断,如果按照如下方式写,可能发生越界情况:
1 | int mid = (left + right)/2; |
所以可以写为:
1 | int mid = left + (right - left)/2; |
一个更好的写法是:
1 | int mid = unsigned(left+right)>>2; |
中位数的处理方法
根据元素个数为奇数还是偶数,中位数的选取也会比较麻烦,当元素个数为偶数时,有左右中位数的区分
- 当数组的元素个数是偶数的时候:
使用 int mid = unsigned(left + right) >> 1
得到左中位数的索引;
使用int mid = unsigned(left + right + 1) >> 1
得到右中位数的索引。
- 边界选取
左右边界选取时要注意,如果左右边界不包括目标数值,会导致错误,例如LeetCode 第 35 题:“搜索插入位置” ,当 target 比数组中的最后一个数字还要大(不能等于)的时候,插入元素的位置就是数组的最后一个位置 + 1,即 (len - 1 + 1 ) = len,如果忽略掉这一点,把右边界定为 len - 1 ,代码就不能通过在线测评1。所以left = 0
,right = len
而不是len-1。
- 循环条件
循环条件一般可以写为:left < right
,当循环结束时,一定有left == right
,返回left或者right都可以,但是,退出循环的时候遗漏了一个元素没有看,那就是left或right索引上的值。
- 分支逻辑编写
二分法的分支逻辑很简单,这里有一个关键是,在分支逻辑中,一个分支是包括中位数的,另一个是排除中位数的。根据上面的分支逻辑的分析,那么会有两种情况出现:
1 | /******1******/ |
- 根据分支逻辑选择左右中位数,选择标准是避免死循环
死循环容易发生在区间只有两个元素的时候
1 | /*下面的代码中,如果只有两个元素,一旦进入左边界,那么左边界不收缩,如此下去会进入死循环,因此需要换成右中位数*/ |
- 后处理
当退出循环后,我们可能需要对两边夹逼剩下的那个数做一个单独的处理,何时要进行后处理呢:
如果你的业务逻辑保证了你要找的数一定在左边界和右边界所表示的区间里出现,那么可以放心地返回 left 或者 right,无需再做判断;
如果你的业务逻辑不能保证你要找的数一定在左边界和右边界所表示的区间里出现,那么只要在退出循环以后,再针对 nums[left] 或者 nums[right] (此时 nums[left] == nums[right])单独作一次判断,看它是不是你要找的数即可,这一步操作常常叫做“后处理”。
例如
LeetCode 第 704 题:二分查找
给定一个 n 个元素有序的(升序)整型数组 nums 和一个目标值 target ,写一个函数搜索 nums 中的 target,如果目标值存在返回下标,否则返回 -1。
分析:因为目标数有可能不在数组中,当候选区间夹逼成一个数的时候,要单独判断一下这个数是不是目标数,如果不是,返回 -1。
模板(来自例题1)
二分查找的一个难点主要是边界条件的控制,如果控制不好,可能会陷入死循环当中。
1 | int findDuplicate(vector<int> &nums) { |
说了这么多,总结一下常用模板
1 | while(left < right){ |
例题
给定一个包含 n + 1 个整数的数组 nums,其数字都在 1 到 n 之间(包括 1 和 n),可知至少存在一个重复的整数。假设只有一个重复的整数,找出这个重复的数。
1 | 输入: [1,3,4,2,2] |
思路:使用二分查找的方式,把1~n个数字从中间数m分为两部分,统计每个区间中数字的个数,如果某个区间数字个数大于m,那么一定存在重复数字。下面是一个二分法的模板,可以
1 | class Solution { |
二、LeetCode 35
三、最大值最小化问题
考虑下列问题,把一个包含$n$个正整数的序列划分为$m$个连续子序列,设第$i$个序列各数之和为$S(i)$,你的任务是让所有$S(i)$的最大值尽量小,例如序列1 2 3 2 5 4,划分为3个序列的最优方案为1 2 3| 2 5 | 4,其中$S(1)$、$S(2)$和$S(3)$分别为6、7、4,最大值为7,如果划分为1 2|3 2|5 4,最大值为9,不如刚才的好。
思路
最大值尽量小是一种常见的优化问题,我们考虑一个新问题,能否把输入序列划分为$m$个连续子序列,使所有$S(i)$均不超过$x$?那么让答案为真的最小的$x$就是原题的答案,接下来随便猜一个数字$x_0$,如果不满足,那么说明答案比$x_0$大,否则答案小于等于$x_0$。
代码
1 |
|
常见搜索算法应用场景
BFS及DFS应用
迷宫类题目
深度及广度优先搜索有一个重要的应用就是在迷宫中寻找路径。本节将针对该类问题进行一个总结。
- 处理迷宫方向
我们可以使用方向向量,处理迷宫中任务的移动方向,方向数组如下:
1 | int dx[2] = {0, 1, -1, 0}; //下、右、左、上 |