文章目录
前言
前不久,我写一篇bfs的进阶版双向bfs:广度优先搜索之双向bfs
这篇文章主要是讲述对于一些特定的bfs问题,可以使用两头齐搜的双向bfs方式来解决其搜索空间爆炸的问题。
同样,对于普通的dfs而言,它的时间复杂度是指数级别,这个时间复杂度是非常恐怖的,很容易就出现最经典的超出时间限制。正是因为如此,所以往往在使用dfs的时候回出现一些剪枝手段,来大幅减少搜索的时间复杂度,其中最有效的就莫过于记忆化dfs。
记忆化dfs,顾名思义就是使用缓存来记录搜索的数据,当再次搜索到相同的状态时直接使用缓存数据即可,从而能够将指数级别的时间复杂度降低到幂数级别的时间复杂度,这可是完全降低了一个档次啊。
同样,接下来我分别详细讲述朴素dfs的实现思路以及记忆化dfs的求解思路,然后结合真题进行实操。
朴素dfs的求解思路
朴素dfs实质上就是递归,在递归的过程中获取我们想要的数据。
记忆化dfs的求解思路
记忆化dfs的核心是在朴素dfs的基础上新增了一个记录递归中间状态和数据的容器,当再次递归到相同的状态时,直接使用容器的缓存结果即可,从而实现剪枝的效果,这也是一种空间换时间的策略。
而记忆化dfs的时间复杂度之所以降低到幂数级别,是因为由于缓存容器的存在,搜索的空间由原来的递归栈变成了容器的大小,简单来说,当容器填满时dfs将不再进行所有的数据都可以从容器中直接获取,所有其时间复杂度变相的变成填满容器的幂数级别。
而记忆化dfs的难点也正是在于其容器的定义,并且在很多情况下,记忆化dfs都能够转变为动态规划,而缓存容器也就是动态规划的dp数组,只不过记忆化dfs填充容器是通过“暴力”搜索来实现,而动态规划填充dp是通过状态转移方程来实现。
下面题目我都只使用dfs进行实现,若大家有兴趣,可以自行尝试将记忆化dfs改造成动态规划。
将整数按权重排序
题目描述
题目描述不是重点,所以我直接用截图,想看原题的可以直接点链接跳转过去。
原题地址: 将整数按权重排序
解题思路
一种很直接的想法:获取到lo到hi之间所有位置的权重,然后按照位置和权重进行排序,最后返回第k个位置是的数据即可。
思路不难,直接上代码:
function getKth(lo: number, hi: number, k: number): number {
const dfs = (num: number) => {
// ...
}
const res: number[][] = [];
for (let i = lo; i <= hi; i++) {
// 类似map的存储方式,便于自定义后续排序
res.push([i, dfs(i)]);
}
res.sort((a, b) => {
if (a[1] !== b[1]) {
return a[1] - b[1];
}
return a[0] - b[0];
});
return res[k - 1][0];
};
接下来就是dfs的实现。
朴素dfs
const dfs = (num: number) => {
if (num === 1) {
return 0;
}
return num % 2 === 0 ? dfs(num / 2) + 1 : dfs(3 * num + 1) + 1;
}
根据条件很容易写出上述朴素版的dfs,但是显然,它一定会超出时间限制。因为我们外层有for循环在计算每一个数字的权重,那么这其中必然会重复计算很多相同的状态,所以此时记忆化dfs登场。
记忆化dfs
const cache = new Array(1000010).fill(0);
const dfs = (num: number) => {
if (num === 1) {
return 0;
}
// 直接获取缓存的数据
if (cache[num] !== 0) {
return cache[num];
}
// 默认当前查找需要一步
let res = 1;
res += num % 2 === 0 ? dfs(num / 2) : dfs(3 * num + 1);
// 保存当前值的计算结果
cache[num] = res;
return res;
}
这里,我们使用cache来记录递归中的重复状态,cache[num]代表数字为num时的权重大小。
小结
一维cache热身结束,接下来我们来试试二维cache。
矩阵中的最长递增路径
题目描述
原题地址:矩阵中的最长递增路径
解题思路
首先,大家不要被这个困难难度所吓倒,其实它并没有想象中的那么难。
我们来梳理一下解题思路:遍历矩阵的每一个点,并获取每一个点的最大递增路径长度,最后返回最大的那个长度即可。
function longestIncreasingPath(matrix: number[][]): number {
const n = matrix.length;
const m = matrix[0].length;
const dfs = (i: number, j: number) => {
//...
}
let max = 0;
for (let i = 0; i < n; i++) {
for (let j = 0; j < m; j++) {
max = Math.max(dfs(i, j), max);
}
}
return max;
};
朴素dfs
const dfs = (i: number, j: number) => {
let max = 1;
for (let dir of dirs) {
const [x, y] = dir;
const newI = i + x;
const newJ = j + y;
if (newI >= 0 && newI < n && newJ >= 0 && newJ < m && matrix[newI][newJ] > matrix[i][j]) {
max = Math.max(max, dfs(newI, newJ) + 1);
}
}
return max;
}
细心的朋友应该有发现这个dfs和一般的递归不太一样,貌似没有递归的终止条件即开头return语句。
其实它是有的,因为它在进行下一次递归之前进行了剪枝,不满足条件的状态不进行下一层递归。
它也可以改造成传统的首部剪枝的递归形式,只不过在本题上如果使用那种方式,dfs还需要额外维护一个参数:上一个位置的值大小。
同样,我们还是需要记忆化dfs来维护重复计算的状态。
记忆化dfs
有了上题的经验,那么这道题的cache应该是什么呢?没错,首先它肯定是一个二维数组,并且cache[i][j]表示矩阵中坐标为i,j点的最长路径长度。
直接上代码:
const cache = new Array(n).fill(0).map(() => new Array(m).fill(0));
const dirs = [[0, 1], [1, 0], [0, -1], [-1, 0]];
const dfs = (i: number, j: number) => {
if (cache[i][j] !== 0) {
return cache[i][j];
}
let max = 1;
for (let dir of dirs) {
const [x, y] = dir;
const newI = i + x;
const newJ = j + y;
if (newI >= 0 && newI < n && newJ >= 0 && newJ < m && matrix[newI][newJ] > matrix[i][j]) {
max = Math.max(max, dfs(newI, newJ) + 1);
}
}
cache[i][j] = max;
return max;
}
小结
是不是突然间感觉困难题也不过如此,是不是感觉不得劲,那么接下来再来一道二维cache的困难题,只不过这题的cache就不是那么容易构造了。
统计所有可行路径
题目描述
原题地址:统计所有可行路径
解题思路
一个比较直接的想法是:从start开始进行dfs,在满足剩余fuel大于0的情况下统计出所有能到达finish的总数。
function countRoutes(locations: number[], start: number, finish: number, fuel: number): number {
const MOD = 1000000007;
const n = locations.length;
const dfs = (res: number, cur: number) => {
//...
}
return dfs(fuel, start);
};
朴素dfs
const dfs = (res: number, cur: number) => {
if (res < 0) {
return 0;
}
let sum = 0;
// 由于finish也可以被执行多次,所以这里不能return
if (cur === finish) {
sum++;
}
for (let i = 0; i < n; i++) {
if (i === cur) {
continue;
}
sum += dfs(res - Math.abs(locations[i] - locations[cur]), i);
sum %= MOD;
}
return sum;
}
每次递归时都遍历locations,把所有的可能性求和。同样,它也会存在大量重复递归的问题。
记忆化dfs
根据前面的经验,我们不难知道很多sum是重复的,我们需要使用cache来保存sum,那么sum究竟是什么呢?sum保存的是cache[cur][res],即在cur位置剩余res油量时的总情况数。
const cache = new Array(n).fill(0).map(() => new Array(fuel + 1).fill(-1));
const dfs = (res: number, cur: number) => {
if (res < 0) {
return 0;
}
if (cache[cur][res] !== -1) {
return cache[cur][res];
}
let sum = 0;
if (cur === finish) {
sum++;
}
for (let i = 0; i < n; i++) {
if (i === cur) {
continue;
}
sum += dfs(res - Math.abs(locations[i] - locations[cur]), i);
sum %= MOD;
}
cache[cur][res] = sum;
return sum;
}
小结
是不是感觉有点上头了,那么接下来试试三维的cache吧。
后面的题目我就不给出解题思路的,具体可参看代码注释。
学生出勤记录 II
题目描述
原题地址:学生出勤记录 II
朴素dfs
function checkRecord(n: number): number {
const MOD = 1000000007;
const dfs = (i: number, j: number, k: number) => {
// 到达指定天数,递归终止
if (i >= n) {
return 1;
}
let sum = 0;
// 正常到场 需要清空k
sum = (sum + dfs(i + 1, j, 0)) % MOD;
if (j < 1) {
// 缺勤 需要清空k
sum = (sum + dfs(i + 1, j + 1, 0)) % MOD;
}
if (k < 2) {
// 迟到 k累加
sum = (sum + dfs(i + 1, j, k + 1)) % MOD;
}
return sum;
}
return dfs(0, 0, 0);
}
记忆化dfs
function checkRecord(n: number): number {
const MOD = 1000000007;
const cache = new Array(n).fill(0).map(() => new Array(2).fill(0).map(() => new Array(3).fill(0)));
const dfs = (i: number, j: number, k: number) => {
// 到达指定天数,递归终止
if (i >= n) {
return 1;
}
// 有缓存数据,直接返回
if (cache[i][j][k] !== 0) {
return cache[i][j][k];
}
let sum = 0;
// 正常到场 需要清空k
sum = (sum + dfs(i + 1, j, 0)) % MOD;
if (j < 1) {
// 缺勤 需要清空k
sum = (sum + dfs(i + 1, j + 1, 0)) % MOD;
}
if (k < 2) {
// 迟到 k累加
sum = (sum + dfs(i + 1, j, k + 1)) % MOD;
}
// 保存当前数据进行缓存
cache[i][j][k] = sum;
return sum;
}
return dfs(0, 0, 0);
}
小结
还有两道虽然是中等题但是难度不亚于困难题的三维cache,这道题我就直接给出记忆化dfs代码仅做参考。
“马”在棋盘上的概率
题目描述
原题地址:“马”在棋盘上的概率
记忆化dfs
function knightProbability(n: number, k: number, row: number, column: number): number {
const dirs = [[-1, -2], [-2, -1], [1, 2], [2, 1], [-1, 2], [2, -1], [-2, 1], [1, -2]];
// cache[i][j][step]表示在step步时位于i,j位置仍在棋盘的概率
const cache = new Array(n).fill(0).map(() => new Array(n).fill(0).map(() => new Array(k + 1).fill(-1)));
const dfs = (i: number, j: number, step: number) => {
if (i < 0 || i >= n || j < 0 || j >= n) {
// 出界,概率为0
return 0;
}
if (step === 0) {
// 最后一步仍未出界,概率为1
return 1;
}
if (cache[i][j][step] !== -1) {
// 有缓存的结果,直接返回
return cache[i][j][step];
}
let sum = 0;
for (let dir of dirs) {
const [x, y] = dir;
sum += dfs(i + x, j + y, step - 1);
}
// 在step步数时当前位置的概率和除以总数即是当前位置的概率
cache[i][j][step] = sum / dirs.length;
return cache[i][j][step];
}
return dfs(row, column, k);
};
骑士拨号器
题目描述
原题地址:骑士拨号器
记忆化dfs
function knightDialer(n: number): number {
const dirs = [[-1, -2], [-2, -1], [1, 2], [2, 1], [-1, 2], [2, -1], [-2, 1], [1, -2]];
const map = [
[1, 2, 3],
[4, 5, 6],
[7, 8, 9],
['', 0, '']
];
const MOD = 1e9 + 7;
const cache = new Array(4).fill(0).map(() => new Array(3).fill(0).map(() => new Array(n).fill(-1)));
const dfs = (i: number, j: number, step: number) => {
if (i < 0 || i >= 4 || j < 0 || j >= 3) {
return 0;
}
if (map[i][j] === '') {
return 0;
}
if (step === 0) {
return 1;
}
if (cache[i][j][step] !== -1) {
return cache[i][j][step];
}
let sum = 0;
for (let dir of dirs) {
const [x, y] = dir;
sum += dfs(i + x, j + y, step - 1);
sum %= MOD;
}
cache[i][j][step] = sum;
return sum;
}
let sum = 0;
for (let i = 0; i < 4; i++) {
for (let j = 0; j < 3; j++) {
if (map[i][j] !== '') {
sum += dfs(i, j, n-1);
sum %= MOD;
}
}
}
return sum;
};
记忆化dfs与棋
能跟到这里,相信大家应该都已经很熟悉记忆化dfs的模板套路了。一般情况化其实朴素dfs已经足够了,那么记忆化dfs究竟是否有其实际的作用呢?这不在棋类算法中,记忆化dfs就充当着不可或缺的重要作用价值。
对于设计棋类算法,我们知道其核心算法是采用Alpha Beta的极大化极小值搜索,那么记忆化dfs的作用在哪呢?
首先,我们知道记忆化dfs的原理在于其使用缓存的方式作用于dfs,直接从缓存中获取结果从而达到剪枝效果。那么在棋类算法中也是同样的道理,以中国象棋为例,不知道大家有发现电脑在开局时的计算速度会非常快,那是因为它直接使用了缓存中的开局库。这些都已经是认证过的最优解可以直接使用。通过残局也是同样的道理。还有一个细节,就是当电脑就算出绝杀棋的时候也会计算得特别快,因为当计算出杀棋时,把其中所以的情形状态路径都放置到缓存容器,然后后续的支招直接从缓存容器中取出即可。
通俗点说,记忆化dfs就是背谱,查谱的速度那肯定比算棋的速度要快得多。
再扩展点来说,以五子棋为例,不管是无禁手还是有禁手,其实执黑都是必胜的,理论上来说,只要把所有的必胜步骤都记录到缓存容器中,那么电脑下棋无需再思考,只用根据棋局直接从缓存中读取下一步必胜方案即可。但是事实上这是不可行的,暂不说算是否有指数级别的缓存空间,哪怕其总的时间复杂度也没有任何变化,原因很简单,把所有指数级别的可能性扁平化放到一个数组里,那么遍历这个数组和前者的递归查找是一模一样的。
结语
到此,本篇有关记忆化dfs的讲述已临近尾声,细细品味,这些高级算法还是挺有意思的,如果大家有疑问,欢迎评论区留言或者私信我。下期再会。