Leetcode1269:停在原地的方案数(两类方法的详细记录总结,超详细!!)
- 有一个长度为 arrLen 的数组,开始有一个指针在索引 0 处。
- 每一步操作中,你可以将指针向左或向右移动 1 步,或者停在原地(指针不能被移动到数组范围外)。
- 给你两个整数 steps 和 arrLen ,请你计算并返回:在恰好执行 steps 次操作以后,指针仍然指向索引 0 处的方案数。
- 由于答案可能会很大,请返回方案数 模 10^9 + 7 后的结果。
1 <= steps <= 500
1 <= arrLen <= 10^6
方法一:深度优先遍历+剪枝 +记忆化搜索
->这道题需要返回所有可行的方案,中间搜索的过程较复杂,所以第一反应是考虑深度优先遍历(dfs),暴力搜索全部可行路径并筛选可行解。
关键在于如何构造深度优先搜索的树或者结构。先回顾下dfs的通用模板:
result=[];
def backtrack(路径,选择列表)
if 满足结束条件:
result.add(路径)
return;
for 选择 in 选择列表
做选择
backtrack(路径,选择列表)
撤销选择
->根据dfs的模板,我们需要确定选择列表,和结束条件。在本题中选择列表是向前、向后以及原地搜索这三个策略。终止条件是在搜索完给定的步数。为了返回可行解我们需要对路径进行筛选,即返回的路径终点需要在原点。
确定了以上的思路我们可以构建搜索树,对搜索树每个节点向下扩张三个选择列表,分别是-1,0,+1,代表三个方向的搜索,每进入一层搜索树step-1.设置变量np为当前结点下继续下一步搜索后的累计步数,对每个结点处的累计步数进行记录。搜索终止条件是当步数达到要求,即step=0,在此基础上是否加入可行解是判断累计步数或者说指针所在位置坐标~~pos是否等于0(返回原点)。
暴力的dfs会超出时间限制,这是由于算法的时间复杂度为o(3^ steps),因此我们需要减少搜索树的深度,做一个优化。
当arrLen远大于steps时,搜索的边界被steps限制,当steps大于arrLen时,搜索的边界又被arrLen限制。因此可以确定搜索的边界(指针数组下标)为:
max_bond = min(steps, arrLen-1);
上面的方案是不考虑结果,只考虑最大化向右搜索的范围,进一步地优化搜索空间,因为我们需要找到能返回原点的方案,当向右搜索的步长超过steps/2就无法返回原点了。因此限制搜索空间如下:
max_bond = min(1 + steps / 2, arrLen-1);
通过以上的处理可以大大减少搜索树的深度,进一步地,我们还可以对搜索过程做一个剪枝:
if(np < 0 || np >= arrLen) continue; //做了一个剪枝处理,continue 不继续搜索了。
上面代码表示计算完下一步搜索后的累计步数np后,如果np在max_bond的范围之外,直接放弃对这个结点的扩张和搜索。
到此为止对dfs的优化已经初具雏形,假设不考虑时间问题是可以求出结果了。为了让编译通过,我们再思考下是否可以进一步优化。 注意到一个现象,在某一层搜索数中,搜索步数相同,虽然他们前期的搜索路径不同,但是可能存在某几个同层结点走过了相同长度的路径,停留在一个指针位置。这意味着在后续的搜索可行解中我们不用分别讨论,他们的搜索过程可以归类为一种情况。
上面的思考是从上而下搜索的,考虑到dfs是回溯的算法,他的结果是从尾巴逐步递归到上层的。换一种思路,我们可以建立一个二维查找数组vis从下至上记录访问过的结点,再建立一个二维 t 数组保存当前结点的累计方案个数,两个数组与step、pos 建立联系:
vis[step][pos] = 1; //这边记录的是当前step的 pos是累计步数方向计算之和
t[step][pos] = plan_num; //t记录的是当前剩余步数下面,和累计步数方向计算之和下记录的 累计方案。
因为是向上回溯,在每次结点扩张时,本身是打算朝着着这个结点继续走下去,但是如果之前记录的vis数组中,下一层走过当前np的的情况,意味着如果选择这次扩张,那指针会在相同的步数下走到原来走过的位置,那么后续的搜索过程是一样的了。而这个位置步数已经通过t数组记录下来了。因此不需要继续深度优先遍历,直接调用结果即可。整体的框架如下:
for(int i=0;i<3;i++) {
int np = pos + d[i];
if(np < 0 || np >= arrLen) continue; //做了一个剪枝处理,continue 不继续搜索了。
if(vis[step - 1][np])
plan_num = (plan_num + t[step-1][np]) % mod_v; //这里step-1是下一层的
else
plan_num = (plan_num + dfs(np, step-1, arrLen, 0)) % mod_v; //方案的数量是从下往上不断增加出来的。
}
vis[step][pos] = 1; //这边记录的是当前step的 pos是累计步数方向计算之和
t[step][pos] = plan_num; //t记录的是当前剩余步数下面,和累计步数方向计算之和下记录的 累计方案。
return plan_num; //注意到 同一层3个方向的累计方案会汇总叠加然后返回,从plan=plan+就能看出
**在上面的代码中, plan_num记录了从当前结点向下的所有可行方案数。由于是向上回溯,所以刚开始 plan_num=0, plan_num的有效值是从下面传递回来然后通过累加当前结点3个方向搜索得到的方案。这块我说的不清楚,主要是为了帮助大家理解代码,可以看在for循环中,plan_num的变化过程:
plan_num=0,
plan_num=方向1的可行解数量;
plan_num=方向1的可行解数量+ 方向2的可行解数量;
plan_num=方向1的可行解数量+ 方向2的可行解数量+方向3的可行解数量;
最后再谈一句就是代码中 pos和 plan_num以及np的区别。pos代表当前结点所在的指针位置坐标,也是累计步数,多少步就到哪个坐标呗,np是从当前结点往下面走一步的话指针坐标,和pos有点像。plan_num是不断变化的,只能说从for循环出来后plan_num代表当前结点往下所有可行的方案数,在回溯到最后一次,也是第一层时,3个方向的plan_num相加就是最终解。
下面附上完整版代码:
class Solution {
public:
int vis[505][255] = {0}, t[505][255], d[3] = {-1, 0, 1};
const int mod_v = 1000000007;
int dfs(int pos, int step, int arrLen, int plan_num) {
if(step == 0) {
if(pos == 0) {
plan_num = 1;
}
else {
plan_num = 0;
}
vis[step][pos] = 1;
t[step][pos] = plan_num;
return plan_num;
}
for(int i=0;i<3;i++) {
int np = pos + d[i];
if(np < 0 || np >= arrLen) continue; //做了一个剪枝处理,continue 不继续搜索了。
if(vis[step - 1][np])
plan_num = (plan_num + t[step-1][np]) % mod_v; //这里step-1是下一层的
else
plan_num = (plan_num + dfs(np, step-1, arrLen, 0)) % mod_v; //方案的数量是从下往上不断增加出来的。
}
vis[step][pos] = 1; //这边记录的是当前step的 pos是累计步数方向计算之和
t[step][pos] = plan_num; //t记录的是当前剩余步数下面,和累计步数方向计算之和下记录的 累计方案。
return plan_num; //注意到 同一层3个方向的累计方案会汇总叠加然后返回,从plan=plan+就能看出
}
int numWays(int steps, int arrLen) {
int max_bond = min(1 + steps / 2, arrLen); //这边又是一个剪枝。当arrlen长度很长 远大于step时不需要搜索,这个是帮助上面的函数剪枝判断的。
int res;
res = dfs(0, steps, max_bond, 0);
return res;
}
};
方法二:动态规划(dp)
->关于动态规划的解法,在Leetcode官网解答中已经给出了很详细的分析,讲的也很清楚。这边就简单讲下。一些细节分析直接看 Leetcode官网.
这道题目用动态规划会比上述的方法要简单很多。我们以往做的简单动态规划是一维的,先确定转移方程,然后将已知的dp[0]代入状态转移方程,不断向后推导出dp[i]。
dp的大小通常是由我们搜索的数组长度来决定。但是本题用一维的dp搜索不是很容易一下子想到,考虑到我们上面用dfs,也是递归的方式求解,而 递归改动态规划。有几个可变参数决定你维护的dp数组是几维数组。在本题中,可变参数为每次运动时候的当前步数 i 和在在数组上的位置坐标 j 。
那么本题的可以变成二维动态规划问题,同样地,我们需要获得状态转移方程和初始条件。在这里初始条件不再局限于dp[0],而应该是dp[0][j],j=0,1,2,…dp[0].size(),即我们需要获得一个初始的一维值。
那么我们可以确定动态规划数组dp[i][j],其中 dp[i][j] 表示在第i步操作后,指针所处的下标在j的地方,这个数组可以完整记录所有可能的情况。 初始条件: dp[0][0]=1; dp[0][1]=0 ,dp[0][dp[0].size()-1]=0; 然后我们可以建立状态转移方程,注意到动态规划问题的状态转移方程等式右边都是上一次或者之前已经递归求解出来的数组项,在我们的问题中,下一次的搜索方案完全由上一次的搜索方案决定: 对某个i 来说不断 遍历j 要求我们需要知道 i-1条件下的所有初始解,这就符合了之前提到的初始条件:
dp[i][j]=dp[i-1][j-1]+dp[i-1][j]+dp[i-1][j+1];//确定状态转移方程,下一次搜索,在j的位置,可以由上一层获得的dp三个方向结果叠加而来
详细的分析如下:
完整版代码如下:
class Solution {
public:
const int MODULO = 1000000007;
int numWays(int steps, int arrLen) {
int maxColumn = min(arrLen - 1, steps);
vector<vector<int>> dp(steps + 1, vector<int>(maxColumn + 1));
dp[0][0] = 1;
for (int i = 1; i <= steps; i++) {
for (int j = 0; j <= maxColumn; j++) {
dp[i][j] = dp[i - 1][j];
if (j - 1 >= 0) {
dp[i][j] = (dp[i][j] + dp[i - 1][j - 1]) % MODULO;
}
if (j + 1 <= maxColumn) {
dp[i][j] = (dp[i][j] + dp[i - 1][j + 1]) % MODULO;
}
}
}
return dp[steps][0];
}
};
上面提到,在我们的问题中,下一次的搜索方案完全由上一次的搜索方案决定,因此可以优化空间复杂度,将空间复杂度从 o(steps×min(arrLen,steps))减少到 o(min(arrLen,steps));用一维dp来解决:
class Solution {
public:
const int MODULO = 1000000007;
int numWays(int steps, int arrLen) {
int maxColumn = min(arrLen - 1, steps);
vector<int> dp(maxColumn + 1);
dp[0] = 1;
for (int i = 1; i <= steps; i++) {
vector<int> dpNext(maxColumn + 1);
for (int j = 0; j <= maxColumn; j++) {
dpNext[j] = dp[j];
if (j - 1 >= 0) {
dpNext[j] = (dpNext[j] + dp[j - 1]) % MODULO;
}
if (j + 1 <= maxColumn) {
dpNext[j] = (dpNext[j] + dp[j + 1]) % MODULO;
}
}
dp = dpNext;
}
return dp[0];
}
};