目录
⭕1.扫雷游戏
题解
1.记忆化搜索
解法一:递归
解法二:记忆化搜索
解法三:动态规划
⭕1.扫雷游戏 (暴力+模拟)
链接:529. 扫雷游戏
让我们一起来玩扫雷游戏!
给你一个大小为 m x n
二维字符矩阵 board
,表示扫雷游戏的盘面,其中:
'M'
代表一个 未挖出的 地雷,'E'
代表一个 未挖出的 空方块,'B'
代表没有相邻(上,下,左,右,和所有4个对角线)地雷的 已挖出的 空白方块,- 数字(
'1'
到'8'
)表示有多少地雷与这块 已挖出的 方块相邻, 'X'
则表示一个 已挖出的 地雷。
给你一个整数数组 click
,其中 click = [clickr, clickc]
表示在所有 未挖出的 方块('M'
或者 'E'
)中的下一个点击位置(clickr
是行下标,clickc
是列下标)。
根据以下规则,返回相应位置被点击后对应的盘面:
- 如果一个地雷(
'M'
)被挖出,游戏就结束了- 把它改为'X'
。 - 如果一个 没有相邻地雷 的空方块(
'E'
)被挖出,修改它为('B'
),并且所有和其相邻的 未挖出 方块都应该被递归地揭露。 - 如果一个 至少与一个地雷相邻 的空方块(
'E'
)被挖出,修改它为数字('1'
到'8'
),表示相邻地雷的数量。 - 如果在此次点击中,若无更多方块可被揭露,则返回盘面。
题解
这题说的很多,其实就是给一个mxn的棋盘
再给一个棋盘坐标,点击这个坐标,把修改后的棋盘返回。
我们要注意一下规则:
- ‘M’ 代表一个 未挖出的 地雷,
- ‘E’ 代表一个 未挖出的 空方块,
- ‘B’ 代表没有相邻(上,下,左,右,和所有4个对角线)地雷的 已挖出的 空白方块,
前面我们都只研究上下左右,这里还要考虑斜对角线4个位置。
- 也就是说如果当前被挖的空格周围没有地雷,就把它标记成B。
- 数字(‘1’ 到 ‘8’)表示有多少地雷与这块 已挖出的 方块相邻,
- 数字 表示这个被挖的格子周围有多少个地雷
- ‘X ’ 则表示一个 已挖出的 地雷。
根据以下规则,返回相应位置被点击后对应的盘面:
- 如果一个地雷(‘M’)被挖出,游戏就结束了- 把它改为 ‘X’ 。
- 也就是说如果刚开始给你的这个位置就是雷的话,把这个位置改成‘X’,直接结束即可!
- 处理 E 的话,存在 两种情况:
- 如果一个 没有相邻地雷 的空方块(‘E’)被挖出,修改它为(‘B’)
- 并且所有和其相邻的 未挖出 方块‘E’ 都应该被递归地 揭露。
- 如果当前被挖的位置周围没有地雷,把它改成’B‘,然后 递归 的往周围走。
- 如果一个 至少与一个地雷相邻 的空方块(‘E’)被挖出
- 修改它为数字(‘1’ 到 ‘8’ ),表示相邻地雷的数量。
- 如果当前被挖的位置周围有地雷,把它修改成周围的地雷数,然后就 不要递归下去了。直接返回。
如果在此次点击中,若无更多方块可被揭露,则返回盘面。
原理:
其实这就是一个模拟,已经告诉怎么去操作了。
当点击这个位置之后,我们要先统计一下点击的这个位置周围有没有地雷。
- 周围没有地雷,就把这个位置改成’B‘,然后递归的把周围所有位置都找一遍。
- 如果周围有地雷的话,就把这个位置改成地雷数,然后就不要从这个位置在递归下去了,返回即可。
- 同理递归进去也是如上面一样先统计周围有没有地雷。。。。
不过这里有个细节问题,我们之前是沿着一个位置上下左右找4个位置。但是这个位置要找一圈8个位置。
- 我们还是和之前一样,我们直接把向量数组扩展一下就可以了。
- 可以写两个-1,1之后然后给它们分别匹配-1,1。
八邻域搜索
初始时只有M和E
- 我们查找到E的时候进行递归
- 标记为B和数字的时候,已经帮我们实现cheak功能啦
E要么变为B(递归),要么变为数字(统计,不递归)
class Solution {
public:int dx[8] = {0,0,1,-1,-1,-1,1,1};int dy[8] = {1,-1,0,0,1,-1,1,-1};int m, n;vector<vector<char>> updateBoard(vector<vector<char>>& board, vector<int>& click) {m = board.size();n = board[0].size();int i = click[0], j = click[1];if (board[i][j] == 'M') {board[i][j] = 'X';return board;}dfs(board, i, j);return board;}void dfs(vector<vector<char>>& board, int i, int j) {int count = 0;//先计算周围地雷数量for (int k = 0; k < 8; ++k) {int x = i + dx[k], y = j + dy[k];if (x >= 0 && x < m && y >= 0 && y < n && board[x][y] == 'M') {++count;}}if (count > 0) {board[i][j] = count + '0'; // 显示地雷数后停止递归} else {board[i][j] = 'B'; for (int k = 0; k < 8; ++k) {int x = i + dx[k], y = j + dy[k];if (x >= 0 && x < m && y >= 0 && y < n && board[x][y]=='E') {dfs(board, x, y); //数字停止递归//B标记以扫描,停止递归//不断对未扫描的E进行扫描}}}}
};
1.记忆化搜索
什么是记忆化搜索,下面我们以一道比较常见的 509. 斐波那契数 来演示一下什么是记忆化搜索。
- 关于这个斐波那契数,我们很早之前就碰到过如循环、递推、递归
- 不过它 还可以用动态规划,记忆化搜索来解决。矩阵快速幂也可以解决。
时间复杂度:
- 循环、递推、递归,时间复杂度O(N^2)
- 动态规划、记忆化搜索,时间复杂度O(N)
- 矩阵快速幂,时间复杂度O(logN)
因为记忆化搜索是基于递归代码来实现的,所以我们先用递归写这道题。
解法一:递归
dfs要干的事情就是给我一个数我把它第n个斐波那契数返回来。
关于函数体怎么写,很简单求。
- F(0) = 0,F(1) = 1,F(n) = F(n - 1) + F(n - 2),其中 n > 1。
- 求第n个斐波那契数先把n-1和n-2斐波那契数求出来。
- 递归出口 n<=1 返回n就可以了。
int fib(int n)
{return dfs(n);
}int dfs(int n){if(n <= 1) return n; return dfs(n-1)+dfs(n-2);}
我们先分析一下这个递归过程。
- 当n=5的时候,我们分析一下这个递归展开过程。
- 就像一颗二叉树。
- 有多少节点就要递归多少次。时间复杂度O(2^N)
我们先分析一下为什么这个递归它会这么慢。
慢其实就慢在我们会重复计算一些问题,如d(3)我们会重复进入两次,但是这两个d(3)的递归展开树是完全一样的!
这两个d(3)向上返回的值是完全一样的!
那我们想一下能不能这样优化一下,来一个备忘录
- 就是当我从d(5)来深度优先遍历的时候,先去左边
- 然后当我从d(3)这颗递归树返回时是一个返回值,(x为返回值)
- 此时把d(3)=x这个f信息丢到备忘录里。以此来避免之后的重复计算
(备忘录可能是一个数组、哈希表)。
- 然后当从左边回来然后去右边的时候,当我们又一次进入d(3)的时候,此时我就不把d(3)展开了。
- 因为此时我去备忘录里找找我发现d(3)=x这个消息在左边就已经计算过了。
- 所以在这里不展开了,直接在备忘录里把x给拿出来(爽了家人们),然后返回就可以了。
当有一个备忘录的时候,相同子树不在展开的时候,是不是就对递归做优化了。
像这样一种方式就叫做记忆化搜索!
解法二:记忆化搜索
在递归过程中,发现有一些 完全相同的问题 时
- 我们就可以把完全相同问题的结果放到备忘录里
- 然后递归进入相同问题的时候直接往备忘录里拿结果就可以了。
- 这就是记忆化搜索,因此又称作带备忘录的递归。
此时你会发现其实并不止d(3)会被做优化其实就是剪枝,d(2)也会被优化。
这么多分支都不用进去。
- 时间复杂度直接从O(2^N)变成O(N)。
- 所以添加一个备忘录可以极大优化我们的搜索过程。
- 这也是记忆化搜索名字的由来,带着记忆去做dfs,这些重复的地方就不要重复进去了就实现了大量的剪枝
时间复杂度从指数级别降到线性级别!
如何实现记忆化搜索呢?
- 添加一个备忘录 memo
- 递归每次返回的时候,将结果放到备忘录里面
- 在每次进入递归的时候,往备忘录里面瞅一瞅
开始前瞅一瞅,返回前存一存~
那添加一个什么样的备忘录呢?
紧盯这样一个原则,先找可变参数,然后将<可变参数,返回值>的映射关系存起来。
- 在这个dfs递归函数里。可变参数就是n,返回值就是第几个斐波那契数。
- 所以在这里仅需<int,int> 前面是第几个斐波那契数的第几,后面是存的是具体的斐波那契数。
这个备忘录搞什么数据结构呢?
- 可以搞一个哈希表,这里我们可以来一个数组就行
- 这个数组可以搞成全局的,也可以当成dfs参数。
有时候备忘录可能需要初始化一下。
- 搞成全局的备忘录里都是0,但是我们备忘录里有一个规则,备忘录里面初始存的值不能跟最终结果的值是一样的。
- 也就是说要去备忘录找这个值的时候,我得先判断一下你这个值是不是已经被计算过了
- 如果这个备忘录里面d(3)本来就存在X值,但是我还没有进入过d(3)里呢,就可能会导致误差。
- 所有要把备忘录初始化为这个dfs里永远都不会出现得返回值!
class Solution {
public:int memo[31];int fib(int n) {memset(memo,-1,sizeof memo);//初始化return dfs(n);}int dfs(int n){//先 备忘录里 瞅瞅if(memo[n]!=-1) return memo[n];//剪枝if(n<=1){memo[n]=n;//return 前就memo存一下return n;}memo[n]=dfs(n-1)+dfs(n-2);return memo[n]; }
};
解法三:动态规划
动态规划我们一般思路是盯着5个方向。
- 确定状态表示
- 推导状态转移方程
- 初始化
- 确定填表顺序
- 确定返回值
解决动态规划问题现创建一个表格。可能是一维的也可能是二维的。称为dp表。
我们会赋予现赋予dp表一个含义。
如果有一个i下标,我们会赋予dp[i] 一个含义。
- 其中这个dp[i]的含义就是状态表示。
- 推导这个状态转移方程就我想求这个dp[i]的值的时候,我们会从前面填的表格的值来推导dp[i]是多少。
- 相当于是避免了重复做一件事情,实现了一个从前到后有逻辑的推导,进行线性的优化计算
具体推导处理的公式就是状态转移方程。
- 初始化就是我们填dp[i]是依赖之前填的表格,但是0这个位置状态没有办法搞。
因此必须先把0这个位置的值先初始化放后序填表。
- 确定填表顺序 如果填dp[i]状态依赖于之前的状态,就必须是从左到右。
- 确定返回值 根据题目要求确定最后返回的是这个表中那一个数。
其实我们可以从之前的递归和记忆化搜索直接推出这5步,因为它们是一一对应的关系。
- dfs函数头就是给我一个数n我返回数n的斐波那契数。
- 填写备忘录的顺序,我们是做了深度优先遍历因此会一直递归下去,所以先会把d(1),dp(0)先放到备忘录里然后在往上返回一依次放。
- 对应dp表就是从左到右。
- 主函数是dfs(n)调用的,对应dp表返回的就是dp[n]的值。
为什么动态规划和记忆化搜索这些都是一一对应的?
因为动态规划和记忆化搜索本质都是一样的:
- 暴力求解(暴搜),动态规划要求dp[i]也要把前面都算出来,也是暴搜。
- 无非就是动态规划和记忆化搜索是对暴搜的优化。
对暴力解法的优化是一样的:把已经计算过的值,存起来。 记忆化搜索算d(5),因为d(4)和d(3)已经放进备忘录里面了。
- 直接去备忘录里找拿d(4)和d(3)就看可以。
- 动态规划求dp[i]的时候是已经把前面dp[i-1]和dp[i-2]的值已经放到这个表里面了,然后求dp[i]的时候,直接去表里拿就就可以了。
《算法导论》这本书就是把记忆化搜索和常规的动态规划 归为 动态规划。
无法就是
- 记忆化搜索是递归(借助 OS 栈来返回)形式的 动态规划
- 而 常规的动态规划 是一个 递推(循环) 存储的 动态规划。
class Solution {
public:int fib(int n) {//dp//方程//初始化//顺序//返回值int dp[31];dp[0]=0,dp[1]=1;for(int i=2;i<=n;i++){dp[i]=dp[i-1]+dp[i-2];}return dp[n];}
};
下面我们总结几个问题。
1.所有的递归(暴搜、深搜),都能改成记忆化搜索吗?
不是的,只有在递归过程中,出现大量完全相同的问题时,才能用记忆化搜索的方式优化。
2.带备忘录的递归、带备忘录的动态规划、记忆化搜索 都是一回事。
3.自顶向下 vs 自底向上 有什么不同
- 无法就是解决一个问题时思考方向不同而已。
- 自顶向下就是思考决策树时是按照从上往下的顺序来思考的,我想求d(5),我要先求出d(4)和d(3) 。。。。。
- 自底向上就是从下往上思考,求d(5),我可以从最初开始看,由0 1 2推出 5
而这两种方式就正好对应记忆化搜索和常规动态规划。
- 记忆化搜索是递归加一个备忘录所以记忆化搜索方式就是从上往下。
- 常规动态规划是递推方式,先求dp[0],自下往上对推导dp[n]是多少。
4.我们在解决这个问题的时候发现了一个流程
我可以先写出暴搜,然后改成记忆化搜索,然后把记忆化搜索东西抽象处理就是动态规划。
- 好像发现解决动态规划问题的全新流程,暴搜->记忆化搜索->动态规划。
- 以前是 常规动态规划。
- 碰到一道动态规划的题先写成暴搜,然后改成记忆化搜索,在抽象成成动态规划。
一般这样搞也没错,但是有些题你直接写出暴搜会比你用常规动态规划成本高的多。
- 暴搜->记忆化搜索->动态规划 和 常规动态规划 都是解决动态规划的方式。
- 那个更好, 因人而异,因题而异。
- 在我看来暴搜可以为我们确定状态表示,提供一个方向。
而且记忆化搜索就已经是一个动态规划了,从暴搜改到记忆化搜索时间复杂度已经是线性级别了,没有必要在搞成动态规划了。