🔗 🔴 2463. Minimum Total Distance Traveled 2454

tags: Weekly Contest 318 陣列(Array) 排序(Sorting) 動態規劃(Dynamic Programming)

題意

在 X 軸上有一些機器人和工廠。給定一個整數陣列 robotrobot,其中 robot[i]robot[i] 是第 ii 個機器人的位置。再給定一個二維整數陣列 factoryfactory,其中 factory[j]=[positionj,limitj]factory[j] = [position_j, limit_j],表示第 jj 個工廠的位置是 positionjposition_j,且第 jj 個工廠最多可以修理 limitjlimit_j 個機器人。

每個機器人的位置是 唯一 的。每個工廠的位置也是 唯一 的。注意,機器人最初可以 與工廠在相同位置

所有機器人最初都是損壞的;它們會朝一個方向持續移動。方向可以是 X 軸的負方向或正方向。當機器人到達一個未達到其限制的工廠時,工廠會修理該機器人,並且機器人停止移動。

在任何時刻,你可以為 某些 機器人設置初始移動方向。你的目標是最小化所有機器人移動的總距離。

返回 所有機器人移動的最小總距離。測試案例生成確保所有機器人都能得到修理。

注意:

  • 所有機器人以相同速度移動。
  • 如果兩個機器人朝相同方向移動,它們永遠不會相撞。
  • 如果兩個機器人朝相反方向移動並在某處相遇,它們不會相撞。它們會交錯而過。
  • 如果機器人經過一個達到其限制的工廠,它會如同該工廠不存在般穿過。
  • 如果機器人從位置 xx 移動到位置 yy,則其移動的距離是 yx|y - x|

約束條件

  • 1robot.length,factory.length1001 \leq \text{robot.length}, \text{factory.length} \leq 100
  • factory[j].length=2factory[j].length = 2
  • 109robot[i],positionj109-10^9 \leq \text{robot[i]}, \text{position}_j \leq 10^9
  • 0limitjrobot.length0 \leq \text{limit}_j \leq \text{robot.length}
  • 測試數據保證所有機器人都能被修理。

思路

為使移動距離最小,每個 factory 應該修一段連續位置上的機器人,可以用反證法證明。假設在能構成最短的一段距離的狀況下,某個 factory 修了不相連的機器人,則可以把其中一個機器人換到其他 factory 修,其他機器人位置不變,這樣能使新的總距離小於等於原來的總距離,與假設矛盾。

因此,我們可以將機器人和工廠按照位置排序,然後考慮每個工廠負責修理其附近的一段連續機器人。這樣可以確保每個工廠修理的機器人彼此間距離最小化,從而總距離也達到最小。

方法一:記憶化搜尋(Memoization)

在得到上述結論後,我們可以寫出遞迴式,來計算每個工廠修理不同數量機器人所需的距離,並使用 記憶化搜尋(Memoization) 來避免計算重複的子問題。

定義 dfs(i,j)dfs(i, j) 為前 i+1i+1 個工廠修理 j+1j+1 個機器人所需的最小距離,則可以透過枚舉第 ii 個工廠修理 k+1k+1 個機器人,來遞迴地計算 dfs(i,j)dfs(i, j)

dfs(i,j)=min{dfs(i1,j)工廠 i 修 0 個機器人dfs(i1,j(k+1))+p=0krobot[jp]positioni工廠 i 修 k+1 個機器人dfs(i, j) = \min \left\{ \begin{array}{ll} dfs(i - 1, j) & \text{工廠 } i \text{ 修 0 個機器人} \\ dfs(i - 1, j - (k + 1)) + \sum_{p = 0}^{k} |robot[j - p] - position_i| & \text{工廠 } i \text{ 修 } k + 1 \text{ 個機器人} \end{array} \right.

注意到在計算 p=0krobot[jp]positioni\sum_{p = 0}^{k} |robot[j - p] - position_i| 的過程中,從 kkk=k+1k^{\prime} = k + 1 時,只需加上 robot[jk]positioni| robot[j - k^{\prime}] - position_i | 即可,因此可以透過前綴和來優化。此外,第 ii 個工廠最多可以修理 limitilimit_i 個機器人,因此 kk 的範圍是 0kmin(limiti1,j)0 \leq k \leq \min(limit_i - 1, j)

接著考慮邊界條件:

  • i<0i < 0 時,表示已經考慮完所有工廠,如果此時還有機器人未修理,代表不合法,返回 \infty;否則返回 00
  • j<0j < 0 時,表示已經修理完所有機器人,可以提早剪枝,直接返回 00

最後,直接返回 dfs(m1,n1)dfs(m - 1, n - 1) 即可。

複雜度分析

  • 時間複雜度:O(m×n2)\mathcal{O}(m \times n^2),其中 mm 是工廠數量,nn 是機器人數量。
    • 總共有 m×nm \times n 個狀態,每個狀態需要枚舉修理的機器人數量 kk 做轉移,而 kk 的最大值為 nn,因此在轉移時需要處理 O(n)O(n) 種可能。
  • 空間複雜度:O(m×n)\mathcal{O}(m \times n),用於儲存記憶化搜索的結果。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Solution:
def minimumTotalDistance(self, robot: List[int], factory: List[List[int]]) -> int:
n, m = len(robot), len(factory)
robot.sort()
factory.sort()

# 令 dfs(i, j) 為前 i+1 個工廠修了 j+1 個機器人所需要的最小距離
@cache
def dfs(i: int, j: int) -> int:
if i < 0: # 沒有 factory 了
return 0 if j < 0 else float('inf') # 如果還有機器人,則不合法
if j < 0: # 剪枝,沒有機器人了
return 0
res = dfs(i - 1, j) # factory[i] 修 0 個機器人
dist = 0
pos, limit = factory[i]
for k in range(limit): # 枚舉 factory[i] 修 k + 1 個機器人
if j - k < 0:
break
dist += abs(robot[j - k] - pos)
res = min(res, dist + dfs(i - 1, j - (k + 1)))
return res
return dfs(m - 1, n - 1)
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
class Solution {
public:
long long minimumTotalDistance(vector<int>& robot, vector<vector<int>>& factory) {
int n = robot.size(), m = factory.size();
sort(robot.begin(), robot.end());
sort(factory.begin(), factory.end());

// 令 dfs(i, j) 為前 i+1 個工廠修了 j+1 個機器人所需要的最小距離
vector<vector<long long>> memo(m, vector<long long>(n, LLONG_MAX / 2));
auto dfs = [&](auto&& dfs, int i, int j) -> long long {
if (i < 0) return j < 0 ? 0 : LLONG_MAX / 2; // 如果還有機器人,則不合法
if (j < 0) return 0; // 剪枝,沒有機器人了
long long& res = memo[i][j];
if (res != LLONG_MAX / 2) return res;
res = dfs(dfs, i - 1, j); // factory[i] 修 0 個機器人
long long dist = 0;
int pos = factory[i][0], limit = factory[i][1];
for (int k = 0; k < limit && k <= j; ++k) { // 枚舉 factory[i] 修 k + 1 個機器人
dist += abs(robot[j - k] - pos);
res = min(res, dist + dfs(dfs, i - 1, j - (k + 1)));
}
return res;
};
return dfs(dfs, m - 1, n - 1);
}
};

方法二:Bottom-Up DP

同樣地,我們可以透過將 Top-Down 的記憶化搜索改為 Bottom-Up 的動態規劃來解決這個問題。

但我們在遞迴時,是用 i<0i < 0j<0j < 0 來作為邊界條件,因此我們需要將陣列的索引整體偏移 11,並且將 f[0][0]f[0][0] 初始化為 00。定義 f[i][j]f[i][j] 為前 ii 個工廠修理 jj 個機器人所需的最小距離,則可以透過枚舉第 ii 個工廠修理 kk 個機器人,來計算 f[i][j]f[i][j]

f[i][j]=min0kmin(limiti,j){f[i1][jk]+p=1krobot[jp]positioni}f[i][j] = \min_{0 \leq k \leq \min(limit_i, j)} \left\{ f[i-1][j-k] + \sum_{p=1}^k |robot[j-p] - position_i| \right\}

由於在 Top-Down 的記憶化搜索中,我們是從大到小計算,因此 Bottom-Up 的動態規劃中,我們需要從小到大計算。

最後,直接返回 f[m][n]f[m][n] 即可。

複雜度分析

  • 時間複雜度:O(m×n2)\mathcal{O}(m \times n^2),其中 mm 是工廠數量,nn 是機器人數量。
    • 總共有 m×nm \times n 個狀態,每個狀態需要枚舉修理的機器人數量 kk 做轉移,而 kk 的最大值為 nn,因此在轉移時需要處理 O(n)O(n) 種可能。
  • 空間複雜度:O(m×n)\mathcal{O}(m \times n),用於儲存動態規劃的結果。

雖然複雜度相同,但 Bottom-Up 的動態規劃不用使用額外的堆疊空間,此外,也能繼續優化空間複雜度。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Solution:
def minimumTotalDistance(self, robot: List[int], factory: List[List[int]]) -> int:
n, m = len(robot), len(factory)
robot.sort()
factory.sort()

# 令 f[i][j] 為前 i 個工廠修了 j 個機器人所需要的最小距離
f = [[float('inf')] * (n + 1) for _ in range(m + 1)]
f[0][0] = 0
for i, (pos, limit) in enumerate(factory, 1):
for j in range(n + 1):
f[i][j] = f[i - 1][j] # factory[i] 修 0 個機器人
dist = 0
for k in range(1, limit + 1):
if j - k < 0:
break
dist += abs(robot[j - k] - pos)
f[i][j] = min(f[i][j], dist + f[i - 1][j - k])
return f[m][n]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Solution {
public:
long long minimumTotalDistance(vector<int>& robot, vector<vector<int>>& factory) {
int n = robot.size(), m = factory.size();
sort(robot.begin(), robot.end());
sort(factory.begin(), factory.end());

// 令 f[i][j] 為前 i 個工廠修了 j 個機器人所需要的最小距離
vector<vector<long long>> f(m + 1, vector<long long>(n + 1, LLONG_MAX / 2));
f[0][0] = 0;
for (int i = 1; i <= m; ++i) {
int pos = factory[i - 1][0], limit = factory[i - 1][1];
for (int j = 0; j <= n; ++j) {
f[i][j] = f[i - 1][j]; // factory[i] 修 0 個機器人
long long dist = 0;
for (int k = 1; k <= limit && k <= j; ++k) {
dist += abs(robot[j - k] - pos);
f[i][j] = min(f[i][j], dist + f[i - 1][j - k]);
}
}
}
return f[m][n];
}
};

方法二空間優化一:滾動陣列(Rolling Array)

在方法二的 Bottom-Up 動態規劃中,我們需要一個二維陣列 f[i][j]f[i][j] 來存儲狀態。然而,我們可以觀察到,在計算 f[i][j]f[i][j] 時,只需要依賴於前一個工廠的狀態 f[i1][j]f[i-1][j^{\prime}],也就是只會用到前一個橫列的結果,因此我們可以透過 滾動陣列(Rolling Array) 來優化空間複雜度,每次只保留前一個橫列的結果,並且在計算完後更新為當前橫列的結果。

我們保留大部分的程式碼,只將原本的 ff 從二維陣列改為一維陣列,並且額外新增一個 fnewf_{new} 來儲存當前橫列的結果,在計算完當前橫列後更新 fffnewf_{new}

最後,返回 f[n]f[n] 即可。

複雜度分析

  • 時間複雜度:O(m×n2)\mathcal{O}(m \times n^2),與方法二相同。
  • 空間複雜度:O(n)\mathcal{O}(n),只需要 兩個 一維陣列來存儲當前的最小距離結果。

這種優化將空間複雜度從原本的 O(m×n)\mathcal{O}(m \times n) 降低到了 O(n)\mathcal{O}(n)。然而,由於我們只是改變了儲存方式,並沒有改變計算邏輯,所以時間複雜度保持不變。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Solution:
def minimumTotalDistance(self, robot: List[int], factory: List[List[int]]) -> int:
n, m = len(robot), len(factory)
robot.sort()
factory.sort()

# 令 f[i][j] 為前 i 個工廠修了 j 個機器人所需要的最小距離
f = [0] + [float('inf')] * n
for pos, limit in factory:
new_f = f.copy()
for j in range(n + 1):
new_f[j] = f[j] # factory[i] 修 0 個機器人
dist = 0
for k in range(1, limit + 1):
if j - k < 0:
break
dist += abs(robot[j - k] - pos)
new_f[j] = min(new_f[j], dist + f[j - k])
f = new_f
return f[n]
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
class Solution {
public:
long long minimumTotalDistance(vector<int>& robot, vector<vector<int>>& factory) {
int n = robot.size(), m = factory.size();
sort(robot.begin(), robot.end());
sort(factory.begin(), factory.end());

// 令 f[i][j] 為前 i 個工廠修了 j 個機器人所需要的最小距離
vector<long long> f(n + 1, LLONG_MAX / 2);
f[0] = 0;
for (int i = 0; i < m; ++i) {
int pos = factory[i][0], limit = factory[i][1];
vector<long long> new_f = f;
for (int j = 0; j <= n; ++j) {
new_f[j] = f[j]; // factory[i] 修 0 個機器人
long long dist = 0;
for (int k = 1; k <= limit && k <= j; ++k) {
dist += abs(robot[j - k] - pos);
new_f[j] = min(new_f[j], dist + f[j - k]);
}
}
f = new_f;
}
return f[n];
}
};

方法二空間優化二:反向遍歷

在方法二計算 f[i][j]f[i][j] 時,除了轉移來源只依賴於前一個工廠的狀態 f[i1][j]f[i-1][j^{\prime}] 外, 也必定滿足 jjj^{\prime} \leq j,且當 j=jj^{\prime} = j 時,f[i][j]=f[i1][j]f[i][j] = f[i-1][j]。因此,如果我們能控制 jj^{\prime} 的遍歷順序,確保在轉移時不會使用到已經被更新的結果,那麼我們就能只用一個一維陣列來儲存 f[i][j]f[i][j] 的結果。

在上個方法中,我們是從小到大遍歷 jj,因此我們可以改為從大到小遍歷 jj,這樣一來,在計算 f[i][j]f[i][j] 時,就不會使用到已經被更新的結果,也就是只會用到 f[i1][j]f[i-1][j^{\prime}],其中 jjj^{\prime} \leq j,因此在使用 f[i1][j]f[i-1][j^{\prime}] 時,jj^{\prime} 必定還未被更新。

這與背包問題中類似,透過在適當的時候反向遍歷,從而確保在更新時不會使用到被「汙染」的結果。

複雜度分析

  • 時間複雜度:O(m×n2)\mathcal{O}(m \times n^2),與方法二相同。
  • 空間複雜度:O(n)\mathcal{O}(n),只需要 一個 一維陣列來存儲當前的最小距離結果。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution:
def minimumTotalDistance(self, robot: List[int], factory: List[List[int]]) -> int:
n, m = len(robot), len(factory)
robot.sort()
factory.sort()

# 令 f[i][j] 為前 i 個工廠修了 j 個機器人所需要的最小距離
f = [0] + [float('inf')] * n
for pos, limit in factory:
for j in range(n, -1, -1):
dist = 0
for k in range(1, limit + 1):
if j - k < 0:
break
dist += abs(robot[j - k] - pos)
f[j] = min(f[j], dist + f[j - k])
return f[n]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Solution {
public:
long long minimumTotalDistance(vector<int>& robot, vector<vector<int>>& factory) {
int n = robot.size(), m = factory.size();
sort(robot.begin(), robot.end());
sort(factory.begin(), factory.end());

// 令 f[i][j] 為前 i 個工廠修了 j 個機器人所需要的最小距離
vector<long long> f(n + 1, LLONG_MAX / 2);
f[0] = 0;
for (int i = 0; i < m; ++i) {
int pos = factory[i][0], limit = factory[i][1];
for (int j = n; j >= 0; --j) {
long long dist = 0;
for (int k = 1; k <= limit && k <= j; ++k) {
dist += abs(robot[j - k] - pos);
f[j] = min(f[j], dist + f[j - k]);
}
}
}
return f[n];
}
};

寫在最後

Cover photo is remixed from @hans, thanks for their work!