🔗 🟡 983. Minimum Cost For Tickets 1786

tags: Weekly Contest 121 陣列(Array) 動態規劃(Dynamic Programming) 記憶化搜尋(Memoization)

題意

你已經提前一年計劃了搭火車的旅行。你將旅行的那些天數由一個整數陣列 daysdays 給定,其中每一天都是 11365365 之間的整��。

火車票以 三種不同的方式 出售:

  • 1天 通行證售價為 costs[0]\text{costs[0]} 元,
  • 7天 通行證售價為 costs[1]\text{costs[1]} 元,
  • 30天 通行證售價為 costs[2]\text{costs[2]} 元。

通行證在期限內可以無限次搭乘。例如,如果我們在第 22 天獲得 7天 通行證,那麼我們可以旅行 77 天:22334455667788

返回在給定的天數列表中的每天旅行所需的 最低花費

思路:動態規劃(Dynamic Programming)

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

首先定義問題,令 f(i)f(i) 表示從第 ii 天開始旅行的最小花費。若在第 ii 天需要買票,則有以下三種選擇:

  1. 購買 1天 通行證,花費為 costs[0]\text{costs[0]},並遞迴計算 f(i+1)f(i + 1) 的最小花費。
  2. 購買 7天 通行證,花費為 costs[1]\text{costs[1]},並遞迴計算 f(i+7)f(i + 7) 的最小花費。
  3. 購買 30天 通行證,花費為 costs[2]\text{costs[2]},並遞迴計算 f(i+30)f(i + 30) 的最小花費。

我們選擇這三種方案中的最小花費作為當前天數的最小花費。此外,若 ii 不在 days\text{days} 中,則我們可以跳過這一天,直接遞迴計算 f(i+1)f(i + 1) 的最小花費。

故我們可以得到遞迴關係式:

f(i)={min(f(i+1)+costs[0],f(i+7)+costs[1],f(i+30)+costs[2])if idaysf(i+1)if idaysf(i) = \begin{cases} \min(f(i + 1) + \text{costs[0]}, f(i + 7) + \text{costs[1]}, f(i + 30) + \text{costs[2]}) & \text{if } i \in \text{days} \\ f(i + 1) & \text{if } i \notin \text{days} \end{cases}

而若 ii 超過日期的範圍,則我們不需要再買票,故 f(i)=0if i>Df(i) = 0 \quad \text{if } i > D,在本題中 D=365D = 365 ,作為遞迴的邊界條件。而所求為 f(1)f(1),即從第 11 天開始旅行的最小花費。

由於 daysdays 是從小到大排序的,故需要旅行的第一天 first_day=days[0]first\_day = days[0] 和最後一天 last_day=days[1]\text{last\_day} = days[-1]。因此遞迴邊界的條件也能寫成 f(i)=0if i>last_dayf(i) = 0 \quad \text{if } i > \text{last\_day};所求為 f(first_day)f(first\_day)

最後,由於存在重複計算的子問題,我們可以使用 記憶化搜尋(Memoization) 來優化遞迴過程。在 Python 中,我們可以使用 @cache 裝飾器來實現記憶化搜索;而在 C++ 中,我們可以使用 memo 陣列來實現。

複雜度分析

  • 時間複雜度:O(D)\mathcal{O}(D),其中 DD 是日期的範圍,也可以認為是最後一天的日期值。在最壞情況下,我們需要計算從第一天到最後一天每一天的最小花費。
  • 空間複雜度:O(D)\mathcal{O}(D),這是記憶化搜索使用的記憶陣列(或字典)的大小,以及遞迴調用堆疊的深度。

雖然時間和空間複雜度看起來與輸入大小 NN(旅行天數)無關,但實際上由於 DD 最大為 365365,所以這個演算法在實踐中仍然為高效。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Solution:
def mincostTickets(self, days: List[int], costs: List[int]) -> int:
days_set = set(days)
first_day, last_day = days[0], days[-1]

@cache
def dfs(i): # dfs(i) 表示從第 i 天開始旅行的最小花費
if i > last_day:
return 0
if i in days_set: # 第 i 天需要買票,則比較三種買票方案的花費,取最小值
return min(costs[0] + dfs(i + 1), costs[1] + dfs(i + 7), costs[2] + dfs(i + 30))
else: # 第 i 天不需要買票,可以跳過
return dfs(i + 1)

return dfs(first_day)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Solution {
public:
int mincostTickets(vector<int>& days, vector<int>& costs) {
unordered_set<int> days_set(days.begin(), days.end());
int first_day = days[0], last_day = days.back();

// memo[i] 表示從第 i 天開始旅行的最小花費
vector<int> memo(last_day + 1, -1);
function<int(int)> dfs = [&](int i) -> int {
if (i > last_day) return 0;
int &res = memo[i];
if (res != -1) return res;
if (days_set.count(i)) { // 第 i 天需要買票,則比較三種買票方案的花費,取最小值
res = min({costs[0] + dfs(i + 1), costs[1] + dfs(i + 7), costs[2] + dfs(i + 30)});
} else { // 第 i 天不需要買票,可以跳過
res = dfs(i + 1);
}
return res;
};
return dfs(first_day);
}
};

方法二:填表法(Tabulation)

在方法一中我們使用的是 Top-Down 的記憶化搜索,同樣地,對於動態規劃問題,我們也可以使用 Bottom-Up 的 填表法(Tabulation) 。對於問題,我們使用相同的問題定義,但與記憶化搜索的自頂向下不同,我們將從最後一天開始逆向填表,逐步計算每一天的最小花費,直到到達旅行的第一天。

具體步驟如下:

  1. 初始化動態規劃表:建立一個大小為 last_day+31last\_day + 31 的陣列 dpdp,其中 dp[i]dp[i] 表示從第 ii 天開始旅行的最小花費。這裡多開了 30 天的空間,是為了避免在計算 i+30i + 30 時發生索引越界的情況。

  2. 逆序計算:從 last_daylast\_day 開始,依次向前迭代到 first_dayfirst\_day。對於每一天 ii

    • 如果 ii 是旅行的某一天(即 iidays_setdays\_set 中),則計算購買 1天7天30天 通行證後的總花費,並選擇其中的最小值賦給 dp[i]dp[i]
    • 如果 ii 不是旅行的某一天,則 dp[i]dp[i] 等於 dp[i+1]dp[i + 1],表示不需要額外花費。
  3. 返回結果:最後,dp[first_day]dp[first\_day] 即為從第一天開始旅行的最小花費。

這種方法避免了遞迴調用的額外開銷,通過迭代的方式有效地計算出每一天的最小花費。

複雜度分析

  • 時間複雜度:O(D)\mathcal{O}(D)
  • 空間複雜度:O(D)\mathcal{O}(D),但方法二不需要額外的遞迴調用堆疊空間。
1
2
3
4
5
6
7
8
9
10
11
12
class Solution:
def mincostTickets(self, days: List[int], costs: List[int]) -> int:
days_set = set(days)
first_day, last_day = days[0], days[-1]
# dp[i] 表示從第 i 天開始旅行的最小花費
dp = [0] * (last_day + 31) # 多開 30 天,避免索引越界
for i in range(last_day, first_day - 1, -1):
if i in days_set:
dp[i] = min(costs[0] + dp[i + 1], costs[1] + dp[i + 7], costs[2] + dp[i + 30])
else:
dp[i] = dp[i + 1]
return dp[first_day]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution {
public:
int mincostTickets(vector<int>& days, vector<int>& costs) {
unordered_set<int> days_set(days.begin(), days.end());
int first_day = days[0], last_day = days.back();
// dp[i] 表示從第 i 天開始旅行的最小花費
vector<int> dp(last_day + 31, 0); // 多開 30 天,避免索引越界
for (int i = last_day; i >= first_day; --i) {
if (days_set.count(i)) {
dp[i] = min({costs[0] + dp[i + 1], costs[1] + dp[i + 7], costs[2] + dp[i + 30]});
} else {
dp[i] = dp[i + 1];
}
}
return dp[first_day];
}
};

方法三:三指標優化

在方法一和方法二中,時間複雜度都是基於值域 DD 的,而實際上我們只需要遍歷實際的旅行天數,而不需要遍歷整個日期範圍。因此,我們可以使用 指標優化 的填表法來減少不必要的計算。

在方法二的基礎上,我們可以進一步優化動態規劃的實現方式,通過使用指標來跟蹤購買 7天30天 通行證後的有效期,可以減少不必要的計算。令 jjkk 分別表示若當前購買 7天30天 通行證,則可以不用買票的「最大」日期下標。初始化 jjkkn1n - 1,即最後一天的索引。在迭代過程中,對於每個當前的旅行日 days[i]days[i],通過移動 jjkk 指標來找到下一個需要購票的日期的位置。具體而言:

  • jj 指標移動到第一個不在 days[i]+7days[i] + 7 天範圍內的日期,則 j+1j+1 是在 days[i]days[i] 購買 7天 通行證後,下次需要買票的日期下標
  • kk 指標移動到第一個不在 days[i]+30days[i] + 30 天範圍內的日期,則 k+1k+1 是在 days[i]days[i] 購買 30天 通行證後,下次需要買票的日期下標

在轉移方程中,我們只需要考慮 jjkk 指標所指向的日期,而不需要遍歷整個日期範圍。

dp[i]=min(costs[0]+dp[i+1],costs[1]+dp[j+1],costs[2]+dp[k+1])dp[i] = \min(costs[0] + dp[i + 1], costs[1] + dp[j + 1], costs[2] + dp[k + 1])

接著考慮如何移動 jjkk 指標,對於越左邊的日期下標 ii ,顯然 jjkk 只會越來越小,因此我們只需要在 jjkk 指標所指向的日期小於 days[i]+7days[i] + 7days[i]+30days[i] + 30 時,不斷遞減指標即可。

最後,dp[0]dp[0] 即為從第 days[0]days[0] 天開始旅行的最小花費。

複雜度分析

  • 時間複雜度:O(n)\mathcal{O}(n),其中 nndaysdays 的長度。
    • 雖然有兩個內部的 while 循環,但 jjkk 都最多只會從 n1n-1 減少到 00,所以總的時間複雜度仍然是 O(n)O(n)
  • 空間複雜度:O(n)\mathcal{O}(n)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Solution:
def mincostTickets(self, days: List[int], costs: List[int]) -> int:
n = len(days)
# dp[i] 表示從 days[i] 開始旅行的最小花費
dp = [0] * (n + 1)
# j 和 k 分別表示若當前購買 7 天票和 30 天票,則可以不用買票的「最大」日期下標
j = k = n - 1
for i in range(n - 1, -1, -1):
d = days[i]
while days[j] >= d + 7:
j -= 1
while days[k] >= d + 30:
k -= 1
# 轉移方程,j + 1 和 k + 1 是下次需要買票的���期
dp[i] = min(costs[0] + dp[i + 1], costs[1] + dp[j + 1], costs[2] + dp[k + 1])
return dp[0]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution {
public:
int mincostTickets(vector<int>& days, vector<int>& costs) {
int n = days.size();
// dp[i] 表示從 days[i] 開始旅行的最小花費
vector<int> dp(n + 1, 0);
// j 和 k 分別表示若當前購買 7 天票和 30 天票,則可以不用買票的「最大」日期下標
int j = n - 1, k = n - 1;
for (int i = n - 1; i >= 0; --i) {
while (days[j] >= days[i] + 7) --j;
while (days[k] >= days[i] + 30) --k;
// 轉移方程,j + 1 和 k + 1 是下次需要買票的日期
dp[i] = min({costs[0] + dp[i + 1], costs[1] + dp[j + 1], costs[2] + dp[k + 1]});
}
return dp[0];
}
};

參考資料


寫在最後

Cover photo is remixed from @吃肥皂泡泡, thanks for their work!

本以為是經典的動態規劃題,看了靈神的題解後才知道還有基於日期的三指標優化寫法,又學到了!