【LeetCode 1751. 最多可以参加的会议数目 II】解析
LeetCode中国站原文
题目描述
这道题常常让初学者感到困惑:决策之间相互关联,看似杂乱无章,不知从何下手。但别担心,本文将用一种“盖楼”的比喻,从零开始,一步步构建出问题的最优解。
题目描述
给你一个
events
数组,其中events[i] = [startDayi, endDayi, valuei]
,表示第i
个会议在startDayi
天开始,第endDayi
天结束,如果你参加这个会议,你能得到价值valuei
。同时给你一个整数k
表示你能参加的最多会议数目。你同一时间只能参加一个会议。如果你选择参加某个会议,那么你必须 完整 地参加完这个会议。会议结束日期是包含在会议内的,也就是说你不能同时参加一个开始日期与另一个结束日期相同的两个会议。
请你返回能得到的会议价值 最大和 。
示例1:
输入:events = [[1,2,4],[3,4,3],[2,3,1]], k = 2
输出:7
解释:选择绿色的活动会议 0 和 1,得到总价值和为 4 + 3 = 7 。
示例2:
输入:events = [[1,2,4],[3,4,3],[2,3,10]], k = 2
输出:10
解释:参加会议 2 ,得到价值和为 10 。
你没法再参加别的会议了,因为跟会议 2 有重叠。你 不 需要参加满 k 个会议。
示例3:
输入:events = [[1,1,1],[2,2,2],[3,3,3],[4,4,4]], k = 3
输出:9
解释:尽管会议互不重叠,你只能参加 3 个会议,所以选择价值最大的 3 个会议。
提示:
- 1 <= k <= events.length
- 1 <= k * events.length <= 1 0 6 10^6 106
- 1 <= s t a r t D a y i startDay~i~ startDay i <= endDayi <= 1 0 9 10^9 109
- 1 <= valuei <= 1 0 6 10^6 106
1. 新手入门:打破“选择困难症”
想象一下,你是个时间管理大师,桌上有一堆活动的邀请函。每个活动都有开始时间、结束时间和价值(能带来的快乐)。你的精力有限,最多只能参加 k
场。你怎么选才能让自己最“快乐”?
这就是一个典型的“选择困难症”:
- 选价值最高的?可能它们时间都冲突。
- 选互不冲突的?可能它们的价值都很低。
你做的每一个选择,都会影响到后续的所有选择。这种“牵一发而动全身”的问题,正是动态规划(Dynamic Programming, DP)大展身手的舞台。
别被“动态规划”这个名字吓到,我们可以把它理解成一种 “填表格、做决策” 的聪明策略。我们不一口气解决整个问题,而是一步步来,把每一步的“最优解”都记录在一张表格里,然后利用这些记录,推导出最终的答案。
2. 核心策略:三步奠定胜局
我们的制胜策略分为三步,每一步都至关重要。
第一步:神来之笔——按结束时间排序
这是整个算法的基石!拿到所有活动,我们不按开始时间,也不按价值,而是 把所有活动按照“结束时间”从早到晚重新排列。
为什么要这么做?
因为当我们按这个顺序一个一个地考虑活动时,我们能确保,当前正在考虑的活动,一定是所有已考虑活动里结束得最晚的。这为我们后续做决策提供了巨大的便利,它让我们的“过去”变得井然有序。
第二步:运筹帷幄——构建决策表
我们准备一张大表格,在代码里就是 dp[i][j]
。这张表就是我们的“大脑”,用来记录每一步的思考结果。
- 行
i
代表:只考虑排序后的前i
个活动。 - 列
j
代表:允许自己参加j
ğ场活动。 - 格中数字
dp[i][j]
代表的含义:在前i
个活动中,允许参加j
场”所能得到的最高价值。
我们的最终目标,就是填满这张表,然后找到 dp[n][k]
的值。
第三步:步步为营——循环填表做决策
这是算法的心脏。我们一个一个地考虑排序后的活动,每考虑一个,就填写一行我们的决策表。
当我们考虑第 i
个活动 (events[i-1]
) 时,我们只有两个选择:
- 【不参加】 这个活动。
- 【参加】 这个活动。
我们的任务,就是计算出这两个选择分别能带来多少价值,然后取其中价值更高的那个,填入表格。
3. 代码详解:将策略化为现实
现在,让我们带着上面的思路,来逐行解密这段精妙的代码。
class Solution {
public int maxValue(int[][] events, int k) {
// 第一步:神来之笔!按结束时间排序
Arrays.sort(events, (a, b) -> a - b);
int n = events.length;
// 第二步:构建决策表 dp[i][j]
// dp[i][j] -> 在前 i 个活动中,参加 j 个能获得的最大价值
int[][] dp = new int[n + 1][k + 1];
// 第三步:步步为营,开始填表
for (int i = 1; i <= n; i++) {
// 当前正在考虑的活动是 events[i-1]
int[] currentEvent = events[i - 1];
int start = currentEvent;
int value = currentEvent;
// 为了“参加”这个选项,我们需要找到与当前活动不冲突的最后一个活动 p
// p 是在 events[i-1] 开始前,就已结束的最后一个活动的索引
int p = search(events, i - 1, start);
for (int j = 1; j <= k; j++) {
// ------ 核心决策 ------
// 选择A:不参加 events[i-1]
// 价值等于只考虑前 i-1 个活动、参加 j 场的最大价值
int choiceA = dp[i - 1][j];
// 选择B:参加 events[i-1]
// 价值 = 当前活动价值 + 在不冲突活动中(0到p)参加 j-1 场的最大价值
// dp[p][j-1] -> p是索引,所以对应dp表中的第p+1行
// 这里为了方便理解,可以认为 dp[p][j-1] 代表了那个时刻的最优解
int choiceB = value + dp[p][j - 1]; // p的计算方式让这里可以简化
// 取最优解,填入表格
dp[i][j] = Math.max(choiceA, choiceB);
}
}
return dp[n][k];
}
/**
* 高效的二分查找,用于寻找“边界”
* 在 events 的前 `right` 个元素中,找到结束时间 < `upper` (当前活动开始时间) 的最后一个活动的索引
* @return 最后一个不冲突活动的索引 (如果不存在则为-1,dp表里处理为第0行)
*/
private int search(int[][] events, int right, int upper) {
int left = 0, ans = 0;
while(left <= right){
int mid = left + (right-left)/2;
if(events[mid] < upper){
ans = mid + 1; // 索引+1,正好对应dp表的行号
left = mid + 1;
}else{
right = mid - 1;
}
}
return ans;
}
}
search
函数:高效的“时光机”
你可能注意到了 search
函数。它不是一个普通的查找,而是一个用于寻找边界的二分查找。
- 它的任务:当我们考虑参加
events[i-1]
(它在start
时间开始)时,search
函数会光速地在所有更早结束的活动中,找到那个与它不冲突的、结束得最晚的活动的边界。 - 为什么是二分查找?因为效率!在几十万个活动中,它能把查找时间从线性
O(N)
降低到对数O(logN)
,是算法能否通过的关键。
4. 高手进阶:优雅背后的数学与效率
对于经验丰富的玩家,让我们从更高维度审视这个解法。
状态转移方程
这个算法的核心可以用一个简洁的状态转移方程来表达。设 dp[i][j]
的定义如上,p
是与第 i
个活动不冲突的最后一个活动的索引。
dp[i][j] = max( dp[i-1][j], dp[p][j-1] + events[i-1][2] )
dp[i-1][j]
代表了不选择第i
个活动的情况。dp[p][j-1] + events[i-1][2]
代表了选择第i
个活动的情况。
这个方程完美地体现了动态规划中“最优子结构”和“无后效性”的特点。
复杂度分析
-
时间复杂度: O(N * k)
- 排序:
O(N log N)
。 - 外层循环
N
次,内层循环k
次。 search
函数是二分查找,需要O(log N)
。但由于search
在k
循环之外,所以总的循环部分是N * (logN + k)
。在k
相对较小的情况下,近似为O(N log N + N*k)
。题目限制k*N <= 10^6
,这个复杂度是可以通过的。
- 排序:
-
空间复杂度: O(N * k)
- 我们使用了一个
dp
表来存储所有子问题的解。
- 我们使用了一个
-
空间优化:细心的同学会发现,
dp[i]
的计算只依赖于dp[i-1]
和更早的行。这提示我们可以使用“滚动数组”的思想,将空间复杂度从O(N*k)
优化到O(k)
。这是一个非常棒的进阶优化点,大家可以尝试实现一下!
总结
我们通过一个“填表决策”游戏,将一个复杂的组合优化问题,拆解成了有序的、一步步的简单决策。
记住这个心法:
- 先排序:按结束时间排序是解耦问题的神来之笔。
- 建表格:定义好
dp[i][j]
的清晰含义。 - 做决策:对每个元素,冷静分析“选”与“不选”两种情况,利用已经填好的表格,做出最优选择。