目录
前言:
动态规划始终困惑着我们,似乎这是一个令人毫无头绪的递推方式。事实上,这是有迹可循的。通过分析我们可以知道如何定义状态,通过状态我们可以自然而然的书写状态转移方程。
如何分析?
我们以部分题目为讲解例子,由简入繁,从而体会设计的过程。
不同路径
取自leetcode--63.不同路径II
从数学原理上讲,递推和递归都对应着数学归纳法。这种殊途同归、一体两面的特性决定了绝大多数时候,递推和递归是可以相互转化的。
抛开效率来讲,模拟所有情况是最直接的做法。所以我们可以先采用递归设计。
我们可以行为表:
1.向右移动
2.向下移动
3.遇到障碍物,不移动
至此我们就可以设计递归了,代码如下。
class Solution {
private:
//计算obstacleGrid数组中第i行j列位置的可行路径数
int dfs(vector<vector<int>>& obstacleGrid, int i, int j) {
int n = obstacleGrid.size();
if(i >= n || j >= n) return 0;
if(i == n - 1 && j == n - 1) return 1;
if(obstacleGrid[i][j] == 1) return 0;
int ans = 0;
ans += dfs(obstacleGrid, i + 1, j) + dfs(obstacleGrid, i, j + 1);
return ans;
}
public:
int uniquePathsWithObstacles(vector<vector<int>>& obstacleGrid) {
return dfs(obstacleGrid, 0, 0);
}
};
当然,这里面还存在着重复计算。所以你可以设计一个记忆化数组来记录已经计算过的值。
记忆化代码:
class Solution {
private:
//足够大的记忆数组,其中0表示障碍物/未计算
int memory[101][101] = {0};
//计算obstacleGrid数组中第i行j列位置的可行路径数
int dfs(vector<vector<int>>& obstacleGrid, int i, int j) {
int n = obstacleGrid.size();
if(i >= n || j >= n) return 0;
if(i == n - 1 && j == n - 1) return 1;
if(obstacleGrid[i][j] == 1) return 0;
if(memory[i][j]) return memory[i][j]; //非障碍物点,如果之前计算过则直接返回
int ans = 0;
ans += dfs(obstacleGrid, i + 1, j) + dfs(obstacleGrid, i, j + 1);
return memory[i][j] = ans;
}
public:
int uniquePathsWithObstacles(vector<vector<int>>& obstacleGrid) {
return dfs(obstacleGrid, 0, 0);
}
};
那么,我们看到记忆化数组中存在着记忆状态的数组。思考以下,递推或者说动态规划到底是什么。实际上,动态规划就是记录“事实库”的正确事实/已知状态。从这个角度说,记忆化递归的本质就是动态规划。
那么我们就可以按照记忆数组去定义状态数组。
所以有如下代码:
class Solution {
public:
int uniquePathsWithObstacles(vector<vector<int>>& obstacleGrid) {
int m = obstacleGrid.size(), n = obstacleGrid[0].size();
//表示第i行第j列的可行解数量
//边界处理技巧:越界情况初始化为0,0不影响加法运算结果。
vector<vector<int>> dp = vector<vector<int>>(m + 1, vector<int>(n + 1, 0));
for(int i = 1; i <= m; ++i) {
for(int j = 1; j <= n; ++j) {
if(obstacleGrid[i - 1][j - 1] == 1) continue;//遇到障碍物,不执行任何操作
if(i == 1 && j == 1) dp[i][j] = 1;//初始化
else dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
}
}
return dp[m][n];
}
};
换一种角度,如果说动态规划在与记录信息,那么面对这道题,首先我们需要记录点的坐标和可行解数量两个信息。又因为我们需要返回的答案是可行解数量,所以可行解信息应该是我们需要去计算的,记录的。
统计所有可行路径
选自leetcode
如果还是从递归的角度出发,那么我只需要去设计递归,然后优化为记忆化递归即可。
考虑到可以再两个城市之间反复横跳,所以我们每一次检索都要从头到尾的尝试。
递归代码如下:
class Solution {
private:
static constexpr int mod = 1000000007;
public:
int dfs(const vector<int>& locations, int pos, int finish, int rest) {
int ans = 0
//基于辗转耗油量>=直达,剩余油量不够直接到城市,返回可行解为0
if (abs(locations[pos] - locations[finish]) > rest) {
return 0;
}
int n = locations.size();
for (int i = 0; i < n; ++i) {
if (pos != i) {
int cost = abs(locations[pos] - locations[i]);
if (cost <= rest) {
ans += dfs(locations, i, finish, rest - cost);
ans %= mod;
}
}
}
if (pos == finish) {
ans += 1;
ans %= mod;
}
return ans;
}
int countRoutes(vector<int>& locations, int start, int finish, int fuel) {
return dfs(locations, start, finish, fuel);
}
};
怎么设计记忆化?只需要搞清楚,我们到底要记忆什么就可以了。我们需要记忆从起点到j点剩余油量为fuel时的可行解数量。故有如下代码:
class Solution {
private:
static constexpr int mod = 1000000007;
vector<vector<int>> f;
public:
int dfs(const vector<int>& locations, int pos, int finish, int rest) {
//计算过
if (f[pos][rest] != -1) {
return f[pos][rest];
}
//重置成0,表示计算过
f[pos][rest] = 0;
//油量不够,返回0
if (abs(locations[pos] - locations[finish]) > rest) {
return 0;
}
int n = locations.size();
for (int i = 0; i < n; ++i) {
if (pos != i) {
int cost = abs(locations[pos] - locations[i]);
if (cost <= rest) {
f[pos][rest] += dfs(locations, i, finish, rest - cost);
f[pos][rest] %= mod;
}
}
}
if (pos == finish) {
f[pos][rest] += 1;
f[pos][rest] %= mod;
}
return f[pos][rest];
}
int countRoutes(vector<int>& locations, int start, int finish, int fuel) {
f.assign(locations.size(), vector<int>(fuel + 1, -1));
return dfs(locations, start, finish, fuel);
}
};
所以我们可以根据记忆数组来进行设计状态数组。注意:这里我们稍微改写一下状态数组的含义此时并非记录剩余多少油量而是记录消耗多少油量。当然原记忆数组也是可行的,只是状态转移方程稍稍有些不同。
class Solution {
private:
static constexpr int mod = 1000000007;
public:
int countRoutes(vector<int>& locations, int start, int finish, int fuel) {
int n = locations.size();
vector<vector<int>> dp = vector<vector<int>>(n, vector<int>(fuel + 1, 0));
dp[start][0] = 1;//初始化,从start到start消耗0个单位油量方案数为1
for (int crt_fuel = 0; crt_fuel <= fuel; ++crt_fuel) {
for (int pos = 0; pos < n; ++pos) {
for (int i = 0; i < n; ++i) {//从其他点转移到pos
if (i != pos) {
int cost = abs(locations[i] - locations[pos]);
if (cost <= crt_fuel) {
dp[pos][crt_fuel] = (dp[pos][crt_fuel] + dp[i][crt_fuel - cost]) % mod;
}
}
}
}
}
int ans = 0;
for (int i = 0; i <= fuel; ++i) {
ans = (ans + dp[finish][i]) % mod;
}
return ans;
}
};
从分析的角度上来说,这道题我们需要记录的状态的有抵达的城市,油量(消耗的油量/剩余的油量),可行解数量。所以有了以上的状态定义。
但是在状态转移上,方程式未必是唯一的。下面我们将展示一种进阶的思维。也许你在其他blog中会看到动态规划具有一种特性--[无后效性]。这种性质,在数学上对应着弱相关性,也就是状态的变化相关性是弱的,不存在复杂的多对多影响。这也就导致有些blog会说我们可以只关注值,最后的变化值(答案)。
我们观察到,城市的坐标是在一维的。也就是可以在一条数轴上表达。并且我们不是很关心中间的辗转过程,我们更关心从开始城市到结束城市的方案数。拥有这个前提条件,我们可以对原来的locations数组进行一定的排序操作。也正是因为中间过程可以尝试忽略/压缩,所以我们可以从最后来源的方向来定义这个问题,即最后向左移动到finish城市,还是向右移动到finish城市。
也就是说,我们可以认为从“开始”城市到“结束”城市之间进行了若干次折返。一次可以考虑状态
dpL[city][used]表示从“开始”城市抵达city城市消耗used油量,并且最后一次移动是向左移动的方案数。
dpR[city][used]表示从“开始”城市抵达city城市消耗used油量,并且最后一次移动是向右移动的方案数。
如何转移?因为我们引入了方向性!所以很自然就能想到以下表达式。
为了方便表达我们记上一个抵达的城市是city'。
那么如果最后一次移动是向左移动的,那么必然有: locations[city'] > locations[city];
同理,向右移动一定满足:locations[city'] < locations[city]。
为了方便代码操作,以及我们不关心中间的转移过程,所以这里我们可以进行排序。
那么 dp[city][used] = dpL[city][used] + dpR[city][used];
其中dpL的转移满足以下方程式子:
dpL[city][used]=
解释如下:
因为city' 到 city之间有city' - city - 1个城市,而每个城市我们可以选择去与不去。所以总共的方案数是
而转移到当前城市city,需要从上一个城市city'转移过来。转移需要消耗的油量为dist(city',city)
故而得到以上式子。
根据对偶原理有:
dpR[city][used]=
那么,就有
dpL[city][used]=dpR[city+1][used-dist(city',city)]+
而dpL[city+1][used-dist(city',city)]=
所以得到
dpL[city][used]=dpR[city+1][used-dist(city',city)]+2*dpL[city+1][used-dist(city'-city)];
dpR[city][used]=dpL[city-1][used-dist(city',city)]+2*dpR[city-1][used-dist(city'-city)];
class Solution {
private:
static constexpr int mod = 1000000007;
public:
int countRoutes(vector<int>& locations, int start, int finish, int fuel) {
int n = locations.size();
int startPos = locations[start];
int finishPos = locations[finish];
sort(locations.begin(), locations.end());
for (int i = 0; i < n; ++i) {//重新标记开始、结束位置
if (startPos == locations[i]) {
start = i;
}
if (finishPos == locations[i]) {
finish = i;
}
}
vector<vector<int>> dpL(n, vector<int>(fuel + 1));
vector<vector<int>> dpR(n, vector<int>(fuel + 1));
dpL[start][0] = dpR[start][0] = 1;
for (int used = 0; used <= fuel; ++used) {
for (int city = n - 2; city >= 0; --city) {
//这里如果delta为0意味着中转情况无效,中转方案为0,因为要从start出发。
if (int delta = locations[city + 1] - locations[city]; used >= delta) {
dpL[city][used] = ((used == delta ? 0 : dpL[city + 1][used - delta]) * 2 % mod + dpR[city + 1][used - delta]) % mod;
}
}
for (int city = 1; city < n; ++city) {
if (int delta = locations[city] - locations[city - 1]; used >= delta) {
dpR[city][used] = ((used == delta ? 0 : dpR[city - 1][used - delta]) * 2 % mod + dpL[city - 1][used - delta]) % mod;
}
}
}
int ans = 0;
for (int used = 0; used <= fuel; ++used) {
ans += (dpL[finish][used] + dpR[finish][used]) % mod;
ans %= mod;
}
if (start == finish) {
ans = (ans + mod - 1) % mod;
}
return ans;
}
};
总结:
通过以上题目我们可以获得一份总结:
1.通过设计记忆化递归来1:1转换为递归
2.通过记忆化递归的记忆数组来类似定义状态数组
3.或直接通过分析题目的状态信息,对状态信息进行记录,答案存放进数组,其余状态作为下标或数组名。
4.每一次状态改变关系在数学上是弱相关(无后效性)
再看动态规划:
出界路径
取自leetcode
根据题目,我们直接分析。发现每次状态改变关系是弱相关的。题中的状态有足球的坐标,移动次数。
那么我们就很容易设计出来dp[i][j][k]表示足球移动i次后,处于(j,k)的方案数。而我们需要记录的出界数就是(i,j)“不合法”的情况!
所以有代码:
class Solution {
public:
static constexpr int MOD = 1000000007;
int findPaths(int m, int n, int maxMove, int startRow, int startColumn) {
vector<vector<int>> directions = {
{-1, 0}, {1, 0}, {0, -1}, {0, 1}};
int outCounts = 0;
vector<vector<vector<int>>> dp(maxMove + 1, vector<vector<int>>(m, vector<int>(n)));
dp[0][startRow][startColumn] = 1;//初始状态,移动0次处于(startRow,startColumn) 1种
for (int i = 0; i < maxMove; i++) {//第i次移动/移动i次
//枚举所有点
for (int j = 0; j < m; j++) {
for (int k = 0; k < n; k++) {
int count = dp[i][j][k];//i次移动到(j,k)的球数(方案数)
if (count > 0) {//有球则进行以下操作
for (auto &direction : directions) {//有三种方向可以转移
int j1 = j + direction[0], k1 = k + direction[1];
if (j1 >= 0 && j1 < m && k1 >= 0 && k1 < n) {//合法计入对应dp
dp[i + 1][j1][k1] = (dp[i + 1][j1][k1] + count) % MOD;
} else {//不合法,越界求计入outCount
outCounts = (outCounts + count) % MOD;
}
}
}
}
}
}
return outCounts;//输出越界球
}
};
最大得分路径
取自leetcode
直接分析,发现每次的状态转移是弱相关的,题中的状态有“人”的坐标,得分,方案数。
其中得分和方案数是返回值,也就是答案需要存入数组。所以,需要设计一个结构体或者一个长度为2的数组 去记录最大得分和方案。
那么我们就可以设计dp[i][j]表示“人”处于(i,j)时的最大得分及其方案。
所以代码如下:
#define ToDigit(a) ((a) - '0')
#define Ligit(x, n) (0 <= (x) && (x) <= (n))
class Solution {
private:
const int directions[3][2] = { {1,0},{0,1},{1,1} };//三种转移方向
const int moden = 1000000007;
public:
vector<int> pathsWithMaxScore(vector<string>& board) {
int n = board.size();//棋盘边长
vector<vector<vector<int>>> dp = vector<vector<vector<int>>>(n + 1, vector<vector<int>>(n + 1, vector<int>(2, 0)));//dp[i][j][0]--最大得分,dp[i][j][1]--方案
for (int i = n - 1; i >= 0; --i) {
int num;
for (int j = n - 1; j >= 0; --j) {
if (board[i][j] == 'S') {//初始状态,该方案为1
dp[i][j][1] = 1;
continue;
}
else if (board[i][j] == 'E') num = 0;
else if (board[i][j] == 'X') continue;//遇到障碍,不执行任何操作
else num = ToDigit(board[i][j]);
for (int k = 0; k < 3; ++k) {
int nx = i + directions[k][0], ny = j + directions[k][1];
if (Ligit(nx, n) && Ligit(ny, n)) {//合法,则进行以下操作
//是最大值,更新方案和得分
if (dp[nx][ny][0] + num > dp[i][j][0] && dp[nx][ny][1]) {
dp[i][j][0] = (dp[nx][ny][0] + num) % moden;
dp[i][j][1] = dp[nx][ny][1];
}
//等于最大值,更新方案
else if (dp[nx][ny][0] + num == dp[i][j][0]) {
dp[i][j][1] = (dp[nx][ny][1] + dp[i][j][1]) % moden;
}
}
}
}
}
return dp[0][0];//返回答案--人处在(0,0)时的最大得分和方案数
}
};