最低票价 图解题解
这道题到底在问什么
- 输入
- days=[1,4,6,7,8,20], costs=[2,7,15]
- 输出
- 11 (两张 1 天票 + 一张 7 天票)
最优解:为什么这么做
一句话答案:LeetCode 983 最低票价:按出行日做动态规划,dp[i] 记办妥前 i 个出行日的最低花费,每格枚举 1、7、30 天票、接上票管不到那段的 dp 取最小。时间 O(n)、空间 O(n)。
最低票价这道题到底要我们决定什么
给升序的出行日 days 和三种票价 costs(1 天、7 天、30 天通票),每张票从买入当天起连续覆盖若干天,求盖住所有出行日的最低总花费。示例 days=[1,4,6,7,8,20]、costs=[2,7,15],答案 11:一张 7 天票罩住 1 到 7 号,8 号、20 号再各买一张 1 天票。难点是每个出行日都能挑三种票、长票还跨盖多个出行日。
为什么把每天买什么票都试一遍会算不过来
每个出行日都能挑 1、7、30 天三种票,n 个出行日就有 3ⁿ 种买法,几十个已是天文数字。何况长票跨盖多个出行日,硬枚举里「前面怎么办」被反复重算。既然「办妥前若干个出行日的最低花费」问一次就定死,算一次存起来复用,指数枚举就压成一条线。
状态为什么定成『办妥前 i 个出行日』、还按出行日而不是日历天推
动态规划(DP,办妥前 i 个出行日的最低花费只算一次存 dp[i]、直接复用)在这里把状态定成 dp[i](i 是出行日个数,不是日历第几天),dp[0]=0 是没出行日、不花钱的地基。盯出行日是因为没出行的日子不用买票,排进表全是白算的空格;答案就是最后一格 dp[n]。
三种票各往回望 1、7、30 天,怎么接上更早那段的 dp
填 dp[i] 只盯第 i 个出行日,记它日期为 today。d 天票含当天共 d 天,覆盖起点是 today-d+1:1 天票只盖当天,7 天票往回盖到 today-7+1,30 天票盖到 today-30+1。这张票罩住 today 往前一截出行日,剩下更早、它够不着的又是一个子问题:管不到的是前 j+1 个出行日(j 是往回退时停在的那个出行日的下标),那段花费就是 dp[j+1],接上再加票价 c。三种票取最小:dp[i]=min(三种票的 dp[j+1]+c)。往回退 j,就是往前跳过被这张票盖住的出行日、落到第一个没盖住的。
拿 days=[1,4,6,7,8,20] 把这张表一格格填出来
dp[0]=0 打底。1 号:1 天票 dp[0]+2=2 最省,dp[1]=2。4 号:dp[1]+2=4,dp[2]=4。6 号:dp[2]+2=6,dp[3]=6。到 7 号才有看头:1 天票 dp[3]+2=8,可 7 天票从 7 号往回盖到 1 号、把 1、4、6、7 号全罩住,接 dp[0]+7=7 更便宜,dp[4]=7。8 号:dp[4]+2=9,dp[5]=9。20 号离得远,7 天票只盖到 14 号、30 天票加 15 都不划算,1 天票 dp[5]+2=11,dp[6]=11 就是答案。
覆盖起点写成 today-d 少个 +1,整张表为什么会悄悄接错
覆盖起点是 today-d+1;漏掉 +1 写成 today-d 就把覆盖多算一天,往回退 j 时跳掉本该保留的出行日,dp 接错子问题、答案偏小;退 j 的条件 days[j] >= today-d+1 里那个 +1 正是防它。复杂度每个出行日只对 3 种票各退一小段 j,票数是常数,时间 O(n)(大 O 记号,描述规模变大时操作数怎么涨)、空间 O(n)。两处边界也别弄错:只有一个出行日也得把三种票比一遍取最小;密集连续多日要让一张长票和多张短票正面比价。
▶ 动画逐步走查(共 27 步)——想跟着动画一帧帧对照就展开
- 3记住这句口诀:dp[i] = 三种票里挑最便宜的「这张票的钱 + 这张票管不到的那段的 dp」。下面每填一格都在套它。
- 4dp 表第 0 格固定是 0:还没安排任何出行日,自然花费为 0。绿色这一格是后面所有计算的地基。
- 5给第 1 个出行日(1 号)试买 1 天票:它能往回覆盖到 1 号。更早的日子交给 dp[0](蓝色,=0)。这条方案花 0 + 2 = 2 元。目前最便宜,暂记为候选 2。
- 6给第 1 个出行日(1 号)试买 7 天票:它能往回覆盖到 -5 号。更早的日子交给 dp[0](蓝色,=0)。这条方案花 0 + 7 = 7 元。比当前候选 2 贵,不采用。
- 7给第 1 个出行日(1 号)试买 30 天票:它能往回覆盖到 -28 号。更早的日子交给 dp[0](蓝色,=0)。这条方案花 0 + 15 = 15 元。比当前候选 2 贵,不采用。
- 8第 1 个出行日结算:三种票比下来最便宜的是 1 天票,dp[1] = 2(绿色锁定)。后面更晚的出行日会接着用它。
- 9给第 2 个出行日(4 号)试买 1 天票:它能往回覆盖到 4 号。更早的日子交给 dp[1](蓝色,=2)。这条方案花 2 + 2 = 4 元。目前最便宜,暂记为候选 4。
- 10给第 2 个出行日(4 号)试买 7 天票:它能往回覆盖到 -2 号。更早的日子交给 dp[0](蓝色,=0)。这条方案花 0 + 7 = 7 元。比当前候选 4 贵,不采用。
- 11给第 2 个出行日(4 号)试买 30 天票:它能往回覆盖到 -25 号。更早的日子交给 dp[0](蓝色,=0)。这条方案花 0 + 15 = 15 元。比当前候选 4 贵,不采用。
- 12第 2 个出行日结算:三种票比下来最便宜的是 1 天票,dp[2] = 4(绿色锁定)。后面更晚的出行日会接着用它。
- 13给第 3 个出行日(6 号)试买 1 天票:它能往回覆盖到 6 号。更早的日子交给 dp[2](蓝色,=4)。这条方案花 4 + 2 = 6 元。目前最便宜,暂记为候选 6。
- 14给第 3 个出行日(6 号)试买 7 天票:它能往回覆盖到 0 号。更早的日子交给 dp[0](蓝色,=0)。这条方案花 0 + 7 = 7 元。比当前候选 6 贵,不采用。
- 15给第 3 个出行日(6 号)试买 30 天票:它能往回覆盖到 -23 号。更早的日子交给 dp[0](蓝色,=0)。这条方案花 0 + 15 = 15 元。比当前候选 6 贵,不采用。
- 16第 3 个出行日结算:三种票比下来最便宜的是 1 天票,dp[3] = 6(绿色锁定)。后面更晚的出行日会接着用它。
- 17给第 4 个出行日(7 号)试买 1 天票:它能往回覆盖到 7 号。更早的日子交给 dp[3](蓝色,=6)。这条方案花 6 + 2 = 8 元。目前最便宜,暂记为候选 8。
- 18给第 4 个出行日(7 号)试买 7 天票:它能往回覆盖到 1 号。更早的日子交给 dp[0](蓝色,=0)。这条方案花 0 + 7 = 7 元。目前最便宜,暂记为候选 7。
- 19给第 4 个出行日(7 号)试买 30 天票:它能往回覆盖到 -22 号。更早的日子交给 dp[0](蓝色,=0)。这条方案花 0 + 15 = 15 元。比当前候选 7 贵,不采用。
- 20第 4 个出行日结算:三种票比下来最便宜的是 7 天票,dp[4] = 7(绿色锁定)。后面更晚的出行日会接着用它。
- 21给第 5 个出行日(8 号)试买 1 天票:它能往回覆盖到 8 号。更早的日子交给 dp[4](蓝色,=7)。这条方案花 7 + 2 = 9 元。目前最便宜,暂记为候选 9。
- 22给第 5 个出行日(8 号)试买 7 天票:它能往回覆盖到 2 号。更早的日子交给 dp[1](蓝色,=2)。这条方案花 2 + 7 = 9 元。与当前候选 9 打平;本轮按演示保留先到的候选 9。
- 23给第 5 个出行日(8 号)试买 30 天票:它能往回覆盖到 -21 号。更早的日子交给 dp[0](蓝色,=0)。这条方案花 0 + 15 = 15 元。比当前候选 9 贵,不采用。
- 24第 5 个出行日结算:1 天票 和 7 天票 都做到 9 元、并列最省,本轮按演示保留先到的 1 天票,dp[5] = 9(绿色锁定)。后面更晚的出行日会接着用它。
- 25给第 6 个出行日(20 号)试买 1 天票:它能往回覆盖到 20 号。更早的日子交给 dp[5](蓝色,=9)。这条方案花 9 + 2 = 11 元。目前最便宜,暂记为候选 11。
- 26给第 6 个出行日(20 号)试买 7 天票:它能往回覆盖到 14 号。更早的日子交给 dp[5](蓝色,=9)。这条方案花 9 + 7 = 16 元。比当前候选 11 贵,不采用。
- 27给第 6 个出行日(20 号)试买 30 天票:它能往回覆盖到 -9 号。更早的日子交给 dp[0](蓝色,=0)。这条方案花 0 + 15 = 15 元。比当前候选 11 贵,不采用。
- 28第 6 个出行日结算:三种票比下来最便宜的是 1 天票,dp[6] = 11(绿色锁定)。后面更晚的出行日会接着用它。
- 29表填满,最右一格 dp[6] = 11 就是覆盖全部出行日的最低费用。回看锁定的路径:在第 4 个出行日(7 号)这格用一张 7 天票接 dp[0],它从 1 号起覆盖 1 到 7 号,把出行日 1、4、6、7 全包了;接着 8 号买 1 张 1 天票(dp[5]),20 号再买 1 张 1 天票(dp[6])。合计 7 + 2 + 2 = 11 元。
⚠️ 容易写错的地方
✗ 错:dp 按日历天数(1..365)建表
✓ 对:dp 只按「出行日」建表,长度 n+1
没出行的日子不必决策,按出行日建表更省、下标也更清晰
✗ 错:while 退 j 的条件写成 days[j] ≥ today - d
✓ 对:条件是 days[j] ≥ today - d + 1
d 天票从买入当天起含当天共罩 d 天,最早只到 today - d + 1;写成 ≥ today - d 会把更早一天也当成被覆盖,多退一格、覆盖范围虚增一天
✗ 错:dp 初值设 0
✓ 对:dp[0]=0、其余先当无穷大再取 min
初值 0 会让「还没算的格子」被误当成花费为 0 的合法方案,污染 min
完整代码(Python / C++ / Java)
Python
from typing import List
class Solution:
def mincostTickets(self, days: List[int], costs: List[int]) -> int:
durations = [1, 7, 30]
n = len(days)
dp = [0] * (n + 1)
for i in range(1, n + 1):
dp[i] = 10**9
for d, c in zip(durations, costs):
j = i - 1
while j >= 0 and days[j] >= days[i-1] - d + 1:
j -= 1
dp[i] = min(dp[i], dp[j + 1] + c)
return dp[n]C++
#include <algorithm>
#include <vector>
using namespace std;
class Solution {
public:
int mincostTickets(vector<int>& days, vector<int>& costs) {
vector<int> dur = {1,7,30};
int n = days.size();
vector<int> dp(n + 1);
for (int i = 1; i <= n; ++i) {
dp[i] = 1000000000;
for (int t = 0; t < 3; ++t) {
int j = i - 1;
while (j >= 0 && days[j] >= days[i-1] - dur[t] + 1) j--;
dp[i] = min(dp[i], dp[j + 1] + costs[t]);
}
}
return dp[n];
}
};Java
import java.util.*;
class Solution {
public int mincostTickets(int[] days, int[] costs) {
int[] dur = {1, 7, 30};
int n = days.length;
int[] dp = new int[n + 1];
for (int i = 1; i <= n; i++) {
dp[i] = 1_000_000_000;
for (int t = 0; t < 3; t++) {
int j = i - 1;
while (j >= 0 && days[j] >= days[i - 1] - dur[t] + 1) j--;
dp[i] = Math.min(dp[i], dp[j + 1] + costs[t]);
}
}
return dp[n];
}
}复杂度
时间
O(n)
每个出行日只对 3 种票各退一小段 j,票数是常数,总体线性
空间
O(n)
一维 dp 数组,长度 n+1
看不够?换成动画再走一遍
上面的推演每一步都对应一帧动画。点开交互动画版,能一步步看着 最低票价 的数据怎么变、指针怎么走,还能切 Python / Java / C++ 跟着练。
面试官可能追问
为什么状态按出行日编号,而不是按日历上的第几天?+
因为没有出行的日子根本不用决策买不买票,把它们也排进 dp 就是白算一堆等于前一天的格子。出行日最多几百个,按日历却要铺到最大那一天,大量空格拖慢速度。按出行日编号,dp[i] 只对应真正要覆盖的那些天,表短、转移里往回退 j 也只在出行日之间跳。按日历天写成 dp[d]=到第 d 天的最低花费也做得出、结果一样,只是格子多、白算的多。
三种票在 while 循环里往回退 j,j+1 为什么正好是要接的那格下标?+
while 从 j=i-1 出发,只要 days[j] 还落在这张票的覆盖范围内(days[j] >= today-d+1)就继续往回退,一旦某个 days[j] 早于覆盖起点就停。停下时下标 0 到 j 这 j+1 个出行日都在票的覆盖之外、还没被它管到,正好是「前 j+1 个出行日」这个子问题,它的最低花费就存在 dp[j+1] 里。所以接 dp[j+1]+c,把这张票的钱和更早那段的最优接起来,不重不漏。
这道题和零钱兑换 LeetCode 322 像在哪?+
都是「枚举最后一步的选择、接上更小的子问题」这个 DP 母题。零钱兑换每步枚举用哪种面额的硬币、接 dp[金额-面额];本题每步枚举最后一张买哪种票、接上票管不到那段的 dp。区别是硬币按金额一格格退,本题的票按覆盖天数往回跳、要先把被盖住的出行日跳过。认出「最后一步分类 + 接子问题」,两题就是一个套路。
想听吴师兄把这道题讲给你听?
文字版和动画都随便看。开通图解算法年卡,可以听吴师兄把 最低票价 一步步讲透(全站已上线 905 份讲透,持续扩充), 卡住的地方还有 AI 私教小欧就着动画帮你拆到懂。
把这道题真正拿下
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。