题目描述
(困难)一个N x N的网格 (grid)
代表了一块樱桃地,每个格子由以下三种数字的一种来表示:
- 0 表示这个格子是空的,所以你可以穿过它。
- 1 表示这个格子里装着一个樱桃,你可以摘到樱桃然后穿过它。
- -1 表示这个格子里有荆棘,挡着你的路。
你的任务是在遵守下列规则的情况下,尽可能的摘到最多樱桃:
- 从位置 (0, 0) 出发,最后到达 (N-1, N-1) ,只能向下或向右走,并且只能穿越有效的格子(即只可以穿过值为0或者1的格子);
- 当到达 (N-1, N-1) 后,你要继续走,直到返回到 (0, 0) ,只能向上或向左走,并且只能穿越有效的格子;
- 当你经过一个格子且这个格子包含一个樱桃时,你将摘到樱桃并且这个格子会变成空的(值变为0);
- 如果在 (0, 0) 和 (N-1, N-1) 之间不存在一条可经过的路径,则没有任何一个樱桃能被摘到。
示例:
输入: grid =
[[0, 1, -1],
[1, 0, -1],
[1, 1, 1]]
输出: 5
解释:
玩家从(0,0)点出发,经过了向下走,向下走,向右走,向右走,到达了点(2, 2)。
在这趟单程中,总共摘到了4颗樱桃,矩阵变成了[[0,1,-1],[0,0,-1],[0,0,0]]。
接着,这名玩家向左走,向上走,向上走,向左走,返回了起始点,又摘到了1颗樱桃。
在旅程中,总共摘到了5颗樱桃,这是可以摘到的最大值了。
解题思路
8月17日华为机试300分第三题,原题内容是码头,其实与摘樱桃属于换汤不换药的关系,现在复盘以下。
题目要求向右或向下的前进,以及向上或向左的返回,其实等价于前进两次。个人认为,这个等价转换才是本题的难点,想明白了这一点,后续通过代码实现起来就比较easy啦。
那么此时这道题就非常的像0-1背包问题了,或者说是背包问题的变种。背包的大小就是从(0, 0)走到(N - 1, N - 1)需要的 2 * (N - 1)
步。物体的大小即为走过当前樱桃地所需的 1
步,价值即其拥有的樱桃个数。每个樱桃地的樱桃只能采摘一次,也就是0-1背包嘛,求容量为2 * (N - 1)
的背包在题目要求的行动规则下能装多少樱桃。
再考虑这么一个问题,如何实现同一个樱桃地的樱桃只能采摘一次的逻辑呢?
为了解决这一问题,同时方便后续dp数组的设计与遍历,我们考虑将两次前进同时进行,即有两个人同时从(0, 0)出发走向(N - 1, N - 1)。两人相遇时,只增加一次樱桃数量。
为什么是两人相遇呢,两人一前一后的到达同一个樱桃地不用考虑吗?事实是不可能出现这种情况,两人一定是同一时间下才可能访问同一片樱桃地。因为在只能向右、向下的行动规则下,每片樱桃地距离起点(0, 0)的距离是一定、唯一的,两人行动的过程中,我们所维护的 k
既可以理解为时间也可以理解为步数。如何在 k
时,第一个人到达A地,那么这也是第二个人能够到达A地的唯一机会,一旦错过,在新的K,K > k
下,第二个人是不可能在花费K
步的前提下,到达只需k
步的A地。
所以对于如何实现同一个樱桃地的樱桃只能采摘一次的逻辑的问题,我们只需在遍历k
时判断是否相遇即可。
剩下就是常见的动态规划四步曲:
- 定义dp数组以及意义: 三维dp数组,
dp[k][x1][x2]
。其中k
是当前走过k步,x1
是第一个人的x值,其对应的y1
为k - x1
。同理,理解x2
。那么dp[k][x1][x2]
的意义为,行动k步后,两人分别抵达(x1, k - x1),(x2, k - x2)时采摘的最多樱桃个数。 - 确定状态转移公式: 此时的(x1, x2)可能由两人分别同时向右、同时向下、一右一下、一下一右得到。对应着
dp[k - 1][x1][x2]
、dp[k - 1][x1 - 1][x2 - 1]
、dp[k - 1][x1][x2 - 1]
、dp[k - 1][x1 - 1][x2]
。后续,需要判断是否相遇,如果相遇,只需加一次樱桃个数,不相遇在上述四种可能的最大值的前提下,加上grid[x1][k - x1] + grid[x2][k - x2]
。 - 初始化dp数组: 根据状态转移公式判断,所有值最终都依赖dp[0][0][0]的值,其值等于
grid[0][0]
。此外,剩下所有位置设置为INT_MIN
,方便遇到荆棘时的处理,可以continue
跳过,相当于对是荆棘的地方设置惩罚项,使其在参与取最大值的比较中自动落选。 - 确定dp数组的遍历顺序: 当前点依赖该点的上以及左,故遍历时自上而下,自左到右。
下面给出具体代码实现。
代码实现
class Solution {
const int dx1[4] = {0, -1, 0, -1};
const int dx2[4] = {0, -1, -1, 0};
public:
int cherryPickup(vector<vector<int>>& grid) {
int n = grid.size();
//1. 定义dp[k][x1][x2],两人同时从(0,0)出发,各走k步,分别到达(x1, k - x1)、(x2, k - x2)时所摘到的樱桃最大值
vector<vector<vector<int>>> dp(2 * n - 1, vector<vector<int>>(n, vector<int>(n, INT_MIN)));
//2. 确定状态转移方程,见上文
//3. 初始化dp数组
dp[0][0][0] = grid[0][0];
//4. 确定遍历顺序
for(int k = 1; k < 2 * n - 1; k++){
for(int x1 = max(k - n + 1, 0); x1 <= min(k, n - 1); x1++){
if(grid[x1][k - x1] == -1) continue;
for(int x2 = x1; x2 <= min(k, n - 1); x2++){
if(grid[x2][k - x2] == -1) continue;
for(int i = 0; i < 4; i++){
int nx1 = x1 + dx1[i];
int nx2 = x2 + dx2[i];
if(nx1 >= 0 && nx2 >= 0){
dp[k][x1][x2] = max(dp[k][x1][x2], dp[k - 1][nx1][nx2]);
}
}
if(x1 != x2) dp[k][x1][x2] += (grid[x1][k - x1] + grid[x2][k - x2]);
else dp[k][x1][x2] += grid[x1][k - x1];
}
}
}
return max(dp[2 * n - 2][n - 1][n - 1], 0);//存在起点与终点之间完全被荆棘隔开,但凡通过荆棘的路径其值都为负数。
}
};
运行结果:
后续,可以考虑利用滚动数组对内存占用进行优化,具体讲优化掉第一维,如下:
class Solution {
const int dx1[4] = {0, -1, 0, -1};
const int dx2[4] = {0, -1, -1, 0};
public:
int cherryPickup(vector<vector<int>>& grid) {
int n = grid.size();
//1. 定义dp[x1][x2],两人同时从(0,0)出发,分别到达(x1, k - x1)、(x2, k - x2)时所摘到的樱桃最大值
vector<vector<int>> dp(n, vector<int>(n, INT_MIN));
//2. 确定状态转移方程,见上文
//3. 初始化dp数组
dp[0][0] = grid[0][0];
//4. 确定遍历顺序
for(int k = 1; k < 2 * n - 1; k++){
for(int x1 = min(k, n - 1); x1 >= max(k - n + 1, 0); x1--){
for(int x2 = min(k, n - 1); x2 >= x1; x2--){
//对荆棘点的处理发生了改变
if(grid[x1][k - x1] == -1 || grid[x2][k - x2] == -1){
dp[x1][x2] = INT_MIN;
continue;
}
int res = INT_MIN;
for(int i = 0; i < 4; i++){
int nx1 = x1 + dx1[i];
int nx2 = x2 + dx2[i];
if(nx1 >= 0 && nx2 >= 0){
res = max(res, dp[nx1][nx2]);
}
}
if(x1 != x2) res += (grid[x1][k - x1] + grid[x2][k - x2]);
else res += grid[x1][k - x1];
dp[x1][x2] = res;
}
}
}
return max(dp[n - 1][n - 1], 0);
}
};
优化后: