0983-最低票价

Raphael Liu Lv10

在一个火车旅行很受欢迎的国度,你提前一年计划了一些火车旅行。在接下来的一年里,你要旅行的日子将以一个名为 days 的数组给出。每一项是一个从 1
365 的整数。

火车票有 三种不同的销售方式

  • 一张 为期一天 的通行证售价为 costs[0] 美元;
  • 一张 为期七天 的通行证售价为 costs[1] 美元;
  • 一张 为期三十天 的通行证售价为 costs[2] 美元。

通行证允许数天无限制的旅行。 例如,如果我们在第 2 天获得一张 为期 7 天 的通行证,那么我们可以连着旅行 7 天:第 2 天、第
3 天、第 4 天、第 5 天、第 6 天、第 7 天和第 8 天。

返回 _你想要完成在给定的列表 days 中列出的每一天的旅行所需要的最低消费 _。

示例 1:

**输入:** days = [1,4,6,7,8,20], costs = [2,7,15]
**输出:** 11
**解释:**
例如,这里有一种购买通行证的方法,可以让你完成你的旅行计划:
在第 1 天,你花了 costs[0] = 2 买了一张为期 1 天的通行证,它将在第 1 天生效。
在第 3 天,你花了 costs[1] = 7 买了一张为期 7 天的通行证,它将在第 3, 4, ..., 9 天生效。
在第 20 天,你花了 costs[0] = 2 买了一张为期 1 天的通行证,它将在第 20 天生效。
你总共花了 11,并完成了你计划的每一天旅行。

示例 2:

**输入:** days = [1,2,3,4,5,6,7,8,9,10,30,31], costs = [2,7,15]
**输出:** 17
**解释:** 例如,这里有一种购买通行证的方法,可以让你完成你的旅行计划: 
在第 1 天,你花了 costs[2] = 15 买了一张为期 30 天的通行证,它将在第 1, 2, ..., 30 天生效。
在第 31 天,你花了 costs[0] = 2 买了一张为期 1 天的通行证,它将在第 31 天生效。 
你总共花了 17,并完成了你计划的每一天旅行。

提示:

  • 1 <= days.length <= 365
  • 1 <= days[i] <= 365
  • days 按顺序严格递增
  • costs.length == 3
  • 1 <= costs[i] <= 1000

📺 视频题解

983. 最低票价.mp4

📖 文字题解

方法一:记忆化搜索(日期变量型)

思路和算法

我们用 dp}(i) 来表示从第 i 天开始到一年的结束,我们需要花的钱。考虑到一张通行证可以让我们在「接下来」的若干天进行旅行,所以我们「从后往前」倒着进行动态规划。

对于一年中的任意一天:

  • 如果这一天不是必须出行的日期,那我们可以贪心地选择不买。这是因为如果今天不用出行,那么也不必购买通行证,并且通行证越晚买越好。所以有 dp}(i) = \textit{dp}(i + 1);

  • 如果这一天是必须出行的日期,我们可以选择买 1,7 或 30 天的通行证。若我们购买了 j 天的通行证,那么接下来的 j - 1 天,我们都不再需要购买通行证,只需要考虑第 i + j 天及以后即可。因此,我们有

    \textit{dp}(i) = \min{\textit{cost}(j) + \textit{dp}(i + j)}, \quad j \in {1, 7, 30}

    其中 cost}(j) 表示 j 天通行证的价格。为什么我们只需要考虑第 i+j 天及以后呢?这里和第一条的贪心思路是一样的,如果我们需要购买通行证,那么一定越晚买越好,在握着一张有效的通行证的时候购买其它的通行证显然是不划算的。

由于我们是倒着进行动态规划的,因此我们可以使用记忆化搜索,减少代码的编写难度。我们使用一个长度为 366 的数组(因为天数是 [1, 365],而数组的下标是从 0 开始的)存储所有的动态规划结果,这样所有的 dp}(i) 只会被计算一次(和普通的动态规划相同),时间复杂度不会增大。

最终的答案记为 dp}(1)。

[sol1-Java]
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
class Solution {
int[] costs;
Integer[] memo;
Set<Integer> dayset;

public int mincostTickets(int[] days, int[] costs) {
this.costs = costs;
memo = new Integer[366];
dayset = new HashSet();
for (int d: days) {
dayset.add(d);
}
return dp(1);
}

public int dp(int i) {
if (i > 365) {
return 0;
}
if (memo[i] != null) {
return memo[i];
}
if (dayset.contains(i)) {
memo[i] = Math.min(Math.min(dp(i + 1) + costs[0], dp(i + 7) + costs[1]), dp(i + 30) + costs[2]);
} else {
memo[i] = dp(i + 1);
}
return memo[i];
}
}
[sol1-Python3]
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:
dayset = set(days)
durations = [1, 7, 30]

@lru_cache(None)
def dp(i):
if i > 365:
return 0
elif i in dayset:
return min(dp(i + d) + c for c, d in zip(costs, durations))
else:
return dp(i + 1)

return dp(1)
[sol1-C++]
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
class Solution {
unordered_set<int> dayset;
vector<int> costs;
int memo[366] = {0};

public:
int mincostTickets(vector<int>& days, vector<int>& costs) {
this->costs = costs;
for (int d: days) {
dayset.insert(d);
}
memset(memo, -1, sizeof(memo));
return dp(1);
}

int dp(int i) {
if (i > 365) {
return 0;
}
if (memo[i] != -1) {
return memo[i];
}
if (dayset.count(i)) {
memo[i] = min(min(dp(i + 1) + costs[0], dp(i + 7) + costs[1]), dp(i + 30) + costs[2]);
} else {
memo[i] = dp(i + 1);
}
return memo[i];
}
};
[sol1-Golang]
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
func mincostTickets(days []int, costs []int) int {
memo := [366]int{}
dayM := map[int]bool{}
for _, d := range days {
dayM[d] = true
}

var dp func(day int) int
dp = func(day int) int {
if day > 365 {
return 0
}
if memo[day] > 0 {
return memo[day]
}
if dayM[day] {
memo[day] = min(min(dp(day + 1) + costs[0], dp(day + 7) + costs[1]), dp(day + 30) + costs[2])
} else {
memo[day] = dp(day + 1)
}
return memo[day]
}
return dp(1)
}

func min(x, y int) int {
if x < y {
return x
}
return y
}

复杂度分析

  • 时间复杂度:O(W),其中 W = 365 是旅行计划中日期的最大值,我们需要计算 W 个解,而每个解最多需要查询 3 个其他的解,因此计算量为 O(3 * W)=O(W)。

  • 空间复杂度:O(W),我们需要长度为 O(W) 的数组来存储所有的解。

方法二:记忆化搜索(窗口变量型)

思路

方法一需要遍历一年中所有的天数,无论 days 的长度是多少。

但是观察方法一的递推式,我们可以看到,如果我们查询 dp}(i),而第 i 天我们又不需要出行的话,那么 dp 函数会一直向后计算 dp}(i + 1) = \textit{dp}(i + 2) = \textit{dp}(i + 3) 一直到一年结束或者有一天我们需要出行为止。那么我们其实可以直接跳过这些不需要出行的日期,直接找到下一个需要出行的日期。

算法

现在,我们令 dp}(i) 表示能够完成从第 days}[i] 天到最后的旅行计划的最小花费(注意,不再是第 i 天到最后的最小花费)。令 j_1 是满足 days}[j_1] >= \textit{days}[i] + 1 的最小下标,j_7 是满足 days}[j_7] >= \textit{days}[i] + 7 的最小下标, j_{30 是满足 days}[j_{30}] >= \textit{days}[i] + 30 的最小下标,那么就有:

\textit{dp}(i) = \min(\textit{dp}(j_1) + \textit{costs}[0], \textit{dp}(j_7) + \textit{costs}[1], \textit{dp}(j_{30}) + \textit{costs}[2])

[sol2-Java]
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
class Solution {
int[] days, costs;
Integer[] memo;
int[] durations = new int[]{1, 7, 30};

public int mincostTickets(int[] days, int[] costs) {
this.days = days;
this.costs = costs;
memo = new Integer[days.length];
return dp(0);
}

public int dp(int i) {
if (i >= days.length) {
return 0;
}
if (memo[i] != null) {
return memo[i];
}
memo[i] = Integer.MAX_VALUE;
int j = i;
for (int k = 0; k < 3; ++k) {
while (j < days.length && days[j] < days[i] + durations[k]) {
j++;
}
memo[i] = Math.min(memo[i], dp(j) + costs[k]);
}
return memo[i];
}
}
[sol2-Python3]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Solution:
def mincostTickets(self, days: List[int], costs: List[int]) -> int:
N = len(days)
durations = [1, 7, 30]

@lru_cache(None)
def dp(i):
if i >= N:
return 0
ans = 10**9
j = i
for c, d in zip(costs, durations):
while j < N and days[j] < days[i] + d:
j += 1
ans = min(ans, dp(j) + c)
return ans

return dp(0)
[sol2-C++]
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
class Solution {
private:
vector<int> days, costs;
vector<int> memo;
int durations[3] = {1, 7, 30};

public:
int mincostTickets(vector<int>& days, vector<int>& costs) {
this->days = days;
this->costs = costs;
memo.assign(days.size(), -1);
return dp(0);
}

int dp(int i) {
if (i >= days.size()) {
return 0;
}
if (memo[i] != -1) {
return memo[i];
}
memo[i] = INT_MAX;
int j = i;
for (int k = 0; k < 3; ++k) {
while (j < days.size() && days[j] < days[i] + durations[k]) {
++j;
}
memo[i] = min(memo[i], dp(j) + costs[k]);
}
return memo[i];
}
};
[sol2-Golang]
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
func mincostTickets(days []int, costs []int) int {
memo := [366]int{}
durations := []int{1, 7, 30}

var dp func(idx int) int
dp = func(idx int) int {
if idx >= len(days) {
return 0
}
if memo[idx] > 0 {
return memo[idx]
}
memo[idx] = math.MaxInt32
j := idx
for i := 0; i < 3; i++ {
for ; j < len(days) && days[j] < days[idx] + durations[i]; j++ { }
memo[idx] = min(memo[idx], dp(j) + costs[i])
}
return memo[idx]
}
return dp(0)
}

func min(x, y int) int {
if x < y {
return x
}
return y
}

复杂度分析

  • 时间复杂度:O(N),其中 N 是出行日期的数量,我们需要计算 N 个解,而计算每个解的过程中最多将指针挪动 30 步,计算量为 O(30 * N)=O(N)。

  • 空间复杂度:O(N),我们需要长度为 O(N) 的数组来存储所有的解。

 Comments
On this page
0983-最低票价