深度优先搜索之记忆化dfs

前言

前不久,我写一篇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的讲述已临近尾声,细细品味,这些高级算法还是挺有意思的,如果大家有疑问,欢迎评论区留言或者私信我。下期再会。

  • 12
    点赞
  • 34
    收藏
    觉得还不错? 一键收藏
  • 3
    评论
深度优先搜索DFS)是一种在图或树中遍历所有节点的算法。其基本思想是从起点开始,沿着一条路径一直走到底,直到无法继续为止,然后回溯到上一个节点,再沿着另一条路径继续走到底,直到所有节点都被访问过为止。DFS使用栈(递归)来存储待处理节点,每个节点仅被访问一次。处理一个节点时,先访问它的一个未被访问的相邻节点,若不存在这样的节点则回溯到上一层节点。DFS算法复杂度为O(V+E),V为节点数,E为边数。 以下是一个Python实现的DFS算法的例子: ```python # 定义一个图的类 class Graph: def __init__(self, graph_dict=None): if graph_dict is None: graph_dict = {} self.__graph_dict = graph_dict # 添加节点 def add_node(self, node): if node not in self.__graph_dict: self.__graph_dict[node] = [] # 添加边 def add_edge(self, edge): edge = set(edge) (node1, node2) = tuple(edge) if node1 in self.__graph_dict: self.__graph_dict[node1].append(node2) else: self.__graph_dict[node1] = [node2] # 获取所有节点 def get_nodes(self): return list(self.__graph_dict.keys()) # 获取所有边 def get_edges(self): edges = [] for node in self.__graph_dict: for neighbour in self.__graph_dict[node]: if {neighbour, node} not in edges: edges.append({node, neighbour}) return edges # 定义DFS算法 def dfs(self, start_node): visited = set() self.__dfs(start_node, visited) def __dfs(self, node, visited): visited.add(node) print(node, end=' ') for neighbour in self.__graph_dict[node]: if neighbour not in visited: self.__dfs(neighbour, visited) ```

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值