从选择困难到最优策略:我如何用DP搞定"鱼和熊掌兼得"的排程难题 😎
大家好,我是你们的老朋友,一位热衷于用代码解决现实世界难题的开发者。今天,我想和大家分享一个最近在项目中遇到的硬核挑战,以及我是如何用动态规划(Dynamic Programming)这个强大的武器,将一个看似无解的排程问题,变成一个优雅高效的解决方案的。这趟旅程有"踩坑"的痛苦,更有"恍然大悟"的喜悦,相信你一定会喜欢!
我遇到了什么问题?
想象一下,我们正在为公司的旗舰产品开发一个"VIP客户增值服务平台"。其中一个 killer feature 是为VIP客户提供一系列高端线上研讨会(Workshops)。每个研讨会都有开始时间、结束时间,以及一个"价值评分"(比如内容稀缺性、主讲人名气等)。
平台规定,每位VIP客户最多只能报名参加 k
场研讨会。当然,同一时间不能分身,所以参加的研讨会时间上不能重叠。
项目初期,我天真地以为,这不是个简单事儿吗?然而,当产品经理把需求拍在我桌上时,我看到了那个关键目标:系统需要自动为客户推荐一个参会组合,使得总价值最大化!
看着一堆 workshops = [[startTime, endTime, value], ...]
和一个限制 k
,我脑子里警铃大作 🤯。这不是简单的先到先得,也不是无脑选价值最高的就行。这简直就是一道活生生的算法题!
这个问题在力扣上也有它的"亲兄弟":1751. 最多可以参加的会议数目 II。题目的提示告诉我们 k * events.length
的规模可能达到 10^6
,而日期范围更是高达 10^9
。这直接宣告了任何基于天数的暴力枚举方法的死刑,也暗示了我们算法的复杂度最好不要超过 O(N*k)
或 O(N*k*logN)
。
我是如何用动态规划解决的
当问题变成"如何做出系列选择以达到全局最优"时,我的"算法雷达"立刻锁定了动态规划 (Dynamic Programming, DP)。
"踩坑"的经历:贪心为何不可行?
我最开始的直觉是:"贪心大法好!",要不,我按价值从高到低排序,无脑选最贵的?
很快我就发现自己太年轻了。看这个例子: k = 2
, workshops = [[1, 10, 100], [2, 3, 60], [4, 5, 60]]
如果按价值贪心:
- 选择价值
100
的[1, 10]
。 - 然后...就没有然后了。剩下的两个研讨会
[2, 3]
和[4, 5]
都和它时间冲突。最终总价值只有100
。
但明眼人一看就知道,最佳策略是放弃那个"巨无霸",选择后面两个价值 60
的,总价值是 60 + 60 = 120
!
恍然大悟的瞬间 😉:这个问题的本质是权衡与选择。每一个选择都会影响未来的可能性。选择了一个时间很长的会议,就等于放弃了在此期间所有其他的可能性。这种需要"深思熟虑"的决策过程,正是DP的用武之地。
解法一:最直观的思考 - 递归与记忆化
最贴近人类思考方式的,就是把决策过程直接翻译成代码。
第一步:让一切井然有序 为了让决策能按部就班地进行,我们首先得给这些会议排个序。按开始时间排序是最自然的选择。
java
// 使用 Arrays.sort 配合 Lambda 表达式,按开始时间排序
Arrays.sort(events, (a, b) -> a[0] - b[0]);
第二步:定义递归函数 我定义了一个函数 solve(index, count)
,意思是:"如果我现在站在第 index
个会议面前,手里还有 count
次参会机会,从现在开始往后看,我能获得的最大总价值是多少?"
第三步:状态转移与决策 在 solve(index, count)
函数里,我们做选择题:
- 不参加 :跳过第
index
个会议,问题变成solve(index + 1, count)
。 - 参加 :获得
events[index]
的价值,并去寻找下一个不冲突的会议nextIndex
,问题变成events[index][2] + solve(nextIndex, count - 1)
。
为了快速找到 nextIndex
,我们用二分查找来提速!
第四步:记忆化 为了避免重复计算,我们用一个 Integer[][] memo
数组作为备忘录,存储计算过的结果。使用Integer
的null
来代表"未计算",完美区分了"未计算"和"已计算但结果为0"两种情况。
java
// 解法一:自顶向下DP + 记忆化 + 二分查找
import java.util.Arrays;
class Solution1 {
private int[][] events;
private int n;
// 使用 Integer 类型而非 int,是为了用 null 来区分"未计算"和"已计算但结果为0"的状态
private Integer[][] memo;
public int maxValue(int[][] events, int k) {
Arrays.sort(events, (a, b) -> a[0] - b[0]);
this.events = events;
this.n = events.length;
this.memo = new Integer[n][k + 1];
return solve(0, k);
}
private int solve(int index, int count) {
if (count == 0 || index == n) return 0;
if (memo[index][count] != null) return memo[index][count];
// 决策1:不参加
int skipValue = solve(index + 1, count);
// 决策2:参加
int nextIndex = binarySearchNext(events[index][1]);
int attendValue = events[index][2] + solve(nextIndex, count - 1);
return memo[index][count] = Math.max(skipValue, attendValue);
}
private int binarySearchNext(int targetEndDay) {
int left = 0, right = n - 1, nextIndex = n;
while (left <= right) {
int mid = left + (right - left) / 2;
if (events[mid][0] > targetEndDay) {
nextIndex = mid;
right = mid - 1;
} else {
left = mid + 1;
}
}
return nextIndex;
}
}
解法二:摆脱递归 - 经典的迭代DP
虽然递归很直观,但有时我们想避免递归深度过大的风险,或者更喜欢迭代的确定性。这时,我们可以用自底向上的方式来填充一张DP表。
核心思路:
- 我们创建一个
dp[i][j]
表,它的含义和solve(i, j)
完全一样:"从第i
个会议开始往后,还有j
次机会能获得的最大价值"。 - 我们从后向前填充这张表(
i
从n-1
到0
),这样当我们计算dp[i]
时,dp[i+1]
和后面所需的状态都已经计算好了。 - 状态转移方程和递归版本如出一辙:
dp[i][j] = max(不参加: dp[i+1][j], 参加: events[i][2] + dp[nextIndex][j-1])
java
// 解法二:自底向上DP + 二分查找
import java.util.Arrays;
class Solution2 {
public int maxValue(int[][] events, int k) {
Arrays.sort(events, (a, b) -> a[0] - b[0]);
int n = events.length;
// dp[i][j]: 从索引 i 到 n-1 的会议中,选择 j 个能获得的最大价值
int[][] dp = new int[n + 1][k + 1];
for (int i = n - 1; i >= 0; i--) {
for (int j = 1; j <= k; j++) {
// 不参加会议 i
int skipValue = dp[i + 1][j];
// 参加会议 i
int nextIndex = binarySearchNext(events, i + 1, events[i][1]);
int attendValue = events[i][2] + dp[nextIndex][j - 1];
dp[i][j] = Math.max(skipValue, attendValue);
}
}
return dp[0][k];
}
private int binarySearchNext(int[][] events, int startIndex, int targetEndDay) {
int left = startIndex, right = events.length - 1;
int nextIndex = events.length;
while (left <= right) {
int mid = left + (right - left) / 2;
if (events[mid][0] > targetEndDay) {
nextIndex = mid;
right = mid - 1;
} else {
left = mid + 1;
}
}
return nextIndex;
}
}
解法三:极致优化 - 挑战空间复杂度
上面两种方法,空间复杂度都是 O(N*k)
。当 N
和 k
都很大时,内存可能会成为瓶颈。我们能做得更好吗?答案是肯定的!
我们可以通过改变排序方式和DP定义,将空间复杂度优化到 O(N)
!
核心思路:
- 按结束时间排序:这是这个解法的关键。它方便我们快速找到在当前会议开始之前,所有已经结束的会议。
- 新的DP定义 :
dp[i]
在第j
次迭代中,表示从events[0...i]
中选择j
个不冲突会议所能获得的最大价值和。注意,这里不再是"从i开始",而是"到i为止"。 - 迭代与状态转移 :
- 我们外层循环
k
次。 - 在第
j
轮迭代,我们计算dp[i]
时依赖于上一轮(j-1
次机会)的结果last_dp
。 dp[i] = max(不参加会议i, 参加会议i)
- 不参加 : 价值等于在
0...i-1
中选j
个会议的最大价值,即dp[i-1]
。 - 参加 : 价值等于
events[i][2]
加上 "在i
开始前就已结束的所有会议中选j-1
个的最大价值"。这个最大价值可以通过二分查找找到不冲突的最后一个会议p
,然后取last_dp[p]
。
- 不参加 : 价值等于在
- 我们外层循环
这个方法巧妙地用一维数组滚动更新,大大节省了空间。
java
// 解法三:DP + 结束时间排序 + 二分查找(空间优化)
import java.util.Arrays;
class Solution3 {
public int maxValue(int[][] events, int k) {
Arrays.sort(events, (a, b) -> a[1] - b[1]);
int n = events.length;
// dp[i] 在第 j 轮迭代中,表示从 events[0...i] 中选 j 个会议的最大价值
int[] dp = new int[n];
for (int j = 1; j <= k; j++) {
// 用 last_dp 保存上一轮(j-1次机会)的结果
int[] last_dp = dp.clone();
dp = new int[n]; // 新一轮dp
for (int i = 0; i < n; i++) {
int startDay = events[i][0];
int value = events[i][2];
// 找到在 i 开始之前结束的最后一个会议 p
int p = binarySearchPrev(events, i, startDay);
// 参加会议i的价值 = 当前价值 + (在p及之前选j-1个会议的最大价值)
int attendValue = value + (p == -1 ? 0 : last_dp[p]);
// 不参加会议i的价值 = (在前i-1个会议中选j个会议的最大价值)
int skipValue = (i == 0 ? 0 : dp[i-1]);
dp[i] = Math.max(attendValue, skipValue);
}
}
return dp[n - 1];
}
// 二分查找在 i 之前,最后一个 endDay < targetStartDay 的会议索引
private int binarySearchPrev(int[][] events, int endIndex, int targetStartDay) {
int left = 0, right = endIndex - 1, result = -1;
while(left <= right) {
int mid = left + (right - left) / 2;
if(events[mid][1] < targetStartDay) {
result = mid;
left = mid + 1;
} else {
right = mid - 1;
}
}
return result;
}
}
举一反三,触类旁通
这种"带价值的区间调度"或者说"0/1背包变种"问题,在实际开发中非常常见:
- 广告投放系统 :一个广告位在不同时间段有不同的报价(价值),你有固定的预算(相当于
k
),需要选择一个时间组合来投放广告,以获得最大的曝光量或收益。 - 云计算资源分配 :一个服务器上有很多待处理的任务,每个任务有起止时间、需要消耗的资源,以及完成后的收益。系统需要在有限的资源(相当于
k
)下,接受并执行哪些任务能让总收益最大。 - 个人投资决策 :你有好几个投资项目可选,每个项目都有投资周期和预期回报。在资金和精力有限(
k
)的情况下,如何组合投资以实现回报最大化?
如果你想继续打磨这项技能,下面这些力扣题目是绝佳的练习材料:
- 1235. 规划兼职工作:和本题几乎一样,只是不限制参加的会议数(
k
无限)。 - 435. 无重叠区间:经典的区间调度入门题,帮你理解贪心策略的应用边界。
- 0/1 背包问题:理解"选择/不选择"这个DP核心思想的根源。
希望今天的分享,能让你感受到算法并不是高高在上的屠龙之技,而是我们程序员工具箱里一把解决复杂问题的利刃。下次当你遇到需要在众多选项中做出最优决策的场景时,别忘了,动态规划可能就是你的答案!😉