1631 最小体力消耗路径:你准备参加一场远足活动。给你一个二维 rows x cols 的地图 heights ,其中 heights[row][col] 表示格子 (row, col) 的高度。一开始你在最左上角的格子 (0, 0),且你希望去最右下角的格子 (rows-1, cols-1) (注意下标从 0 开始编号)。你每次可以往上、下、左、右四个方向之一移动,你想要找到耗费体力最小的一条路径。

一条路径耗费的体力值是 路径上相邻格子之间高度差的绝对值的最大值 决定的。请你返回从左上角走到右下角的最小体力消耗值。

1
2
3
4
5
6
7
8
9
输入:heights = [
[1,2,1,1,1],
[1,2,1,2,1],
[1,2,1,2,1],
[1,2,1,2,1],
[1,1,1,2,1]
]
输出:0
解释:按着值为 1 的格子走,不需要消耗任何体力。

思考

这个题,跟以往常见的移动规则不太一样。常见的是只能向下、向右移动,而此题可以向四个方向移动。如果是只能向下、向右移动,那么可以用动态规划解决,其状态转移方程为:

dp[i][j]=min(max(dp[i1][j],abs(h[i][j]h[i1][j])),max(dp[i][j1],abs(h[i][j]h[i][j1])));dp[i][j] = min(max(dp[i - 1][j], abs(h[i][j] - h[i - 1][j])), max(dp[i][j - 1], abs(h[i][j] - h[i][j - 1])));

其中,dp[i][j]dp[i][j] 表示移动到格子 (i,j)(i, j) 时消耗的最小体力,它可以从上方的格子到达该格子、也可以从左侧的格子到达该格子。所以 dp[i][j]dp[i][j] 会选择两者中体力消耗较少的那个格子作为经过点。

只能向下、向右移动的动态规划解法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
int minimumEffortPathRightDown(int** heights, int heightsSize, int* heightsColSize) {
int m = heightsSize, n = *heightsColSize;
int dp[m][n];

for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
if (i == 0 && j > 0) {
dp[i][j] = fmax(dp[i][j - 1], fabs(heights[i][j] - heights[i][j - 1]));
} else if (j == 0 && i > 0) {
dp[i][j] = fmax(dp[i - 1][j], fabs(heights[i][j] - heights[i - 1][j]));
} else if (i > 0 && j > 0){
dp[i][j] = fmin(fmax(dp[i - 1][j], fabs(heights[i][j] - heights[i - 1][j])), fmax(dp[i][j - 1], fabs(heights[i][j] - heights[i][j - 1])));
} else {
dp[0][0] = 0;
}
}
}

return dp[m - 1][n - 1];
}

问题转换

我们可以将本题抽象成如下的一个 图论模型

  • 我们将地图中的 每一个格子看成图中的一个节点

  • 我么将两个相邻(左右相邻或者上下相邻)的 两个格子对应的节点之间连接一条无向边,边的权值为这两个格子的高度差的绝对值;

  • 我们需要找到一条从左上角到右下角的「最短路径」,其中一条路径的长度定义为其经过的 所有边权的最大值

一般地,「图」中的所有节点的编号都是不一样的。所以我们需要将二维的坐标点,转换成唯一的编号。这可以按 id=rowsm+colsid = rows * m + cols 来表示。比如,对于 m=4,n=3m = 4, n = 3,从 (3,2)(3, 2) 向上移动到 (2,2)(2, 2) 就是从节点 1414 移动到节点 1010

并查集解法

我们可以将地图中的 mn 个格子(节点)放入并查集中,初始时,所有节点各为一个集合。随着「路径的形成」,路径上的节点将会汇集在不同的集合中(不加干预的话,最终所有的点会汇聚成一个集合)。

因此,如果左上角和右下角所对应的节点,被合并到同一个集合内,就可以说明形成了一条从左上角到右下角路径,路径被连通。

但是,我们要找的是一条从左上角到右下角的「最短路径」。所以,我们 可以按边权从小到大的顺序,将这条边的两个点合并到集合中。一旦出现左上角和右下角在同一个集合中,那便是从左上角到右下角的「最短路径」(这次移动的高度差就是首尾连通路径上的最大高度差,但同时也是所有可能的首尾连通路径上的最小高度差)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
typedef struct {
int from; // 从哪个坐标
int to; // 移动到哪个坐标
int h; // 移动的高度差的绝对值
} Edge;

void init(int parent[], int rank[], int size) {
for (int i = 0; i < size; ++i) {
parent[i] = i;
rank[i] = 0;
}
}

int findSet(int parent[], int x) {
if (parent[x] == x) {
return x;
} else {
// 路径压缩
parent[x] = findSet(parent, parent[x]);
return parent[x];
}
}

void unionSet(int parent[], int rank[], int x, int y) {
int rootX = findSet(parent, x);
int rootY = findSet(parent, y);
// 属于不同集合
if (rootX != rootY) {
// 将深度较小的树连到另一棵树
if (rank[rootX] < rank[rootY]) {
parent[rootX] = rootY;
} else if (rank[rootX] > rank[rootY]) {
parent[rootY] = rootX;
} else {
// 将前者所在的树的根节点连接到后者所在的树的根节点上
// 被连接的树的高度将会加一, 对应的 rank 值加一
parent[rootX] = rootY;
rank[rootY]++;
}
}
}

int isSameSet(int parent[], int x, int y) {
return findSet(parent, x) == findSet(parent, y);
}

int cmp(const void *pa, const void *pb) {
return ((Edge *)pa)->h - ((Edge *)pb)->h;
}

int minimumEffortPath(int** heights, int heightsSize, int* heightsColSize){
int m = heightsSize, n = *heightsColSize;
if (m * n <= 1) {return 0;}

// 在矩阵中向右、向下移动,移动可以产生这么多线段(最后一行和最后一列不能移动)
Edge edgeInfo[m * n * 2]; // 实际为 m * n * 2 - m - n;
int edgeSize = 0, id = 0;
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j ++) {
id = i * n + j; // 将二维坐标按行转换为一维坐标点
if (i < m - 1) { // 向右(对于目的点到源点就是向左)
edgeInfo[edgeSize].from = id;
edgeInfo[edgeSize].to = id + n;
edgeInfo[edgeSize].h = fabs(heights[i][j] - heights[i + 1][j]);
edgeSize++;
}
if (j < n - 1) { // 向下(对于目的点到源点就是向上)
edgeInfo[edgeSize].from = id;
edgeInfo[edgeSize].to = id + 1;
edgeInfo[edgeSize].h = fabs(heights[i][j] - heights[i][j + 1]);
edgeSize++;
}
}
}
qsort(edgeInfo, edgeSize, sizeof(Edge), cmp); // 按高度差的绝对值排序

int ufSize = m * n; // 用实际的 edgeSize 会有问题(考虑仅一次移动时,有两个坐标点)
int parent[ufSize], rank[ufSize];
init(&parent, &rank, ufSize);
for (int i = 0; i < edgeSize; i++) { // 现在按高度顺序遍历每一次移动
unionSet(&parent, &rank, edgeInfo[i].from, edgeInfo[i].to); // 合并该次移动
// 经过数次移动后,如果左上角和右下角的点在同一个集合中,那么首尾已经连通,即为答案
// 因为,这次移动的高度差就是首尾连通路径上的最大高度差,但同时也是所有可能的首尾连通路径上的最小高度差
if (isSameSet(&parent, 0, m * n - 1)) {
return edgeInfo[i].h;
}
}
return ~(0x1U << 31); // 无效返回
}

本题有多种解法,如二分查找、并查集、dijkstra 算法,有时间再补充。