从选择困难到最优策略:我如何用DP搞定“鱼和熊掌兼得”的排程难题(1751. 最多可以参加的会议数目 II)

从选择困难到最优策略:我如何用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]]

如果按价值贪心:

  1. 选择价值 100[1, 10]
  2. 然后...就没有然后了。剩下的两个研讨会 [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) 函数里,我们做选择题:

  1. 不参加 :跳过第 index 个会议,问题变成 solve(index + 1, count)
  2. 参加 :获得 events[index] 的价值,并去寻找下一个不冲突的会议 nextIndex,问题变成 events[index][2] + solve(nextIndex, count - 1)

为了快速找到 nextIndex,我们用二分查找来提速!

第四步:记忆化 为了避免重复计算,我们用一个 Integer[][] memo 数组作为备忘录,存储计算过的结果。使用Integernull来代表"未计算",完美区分了"未计算"和"已计算但结果为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次机会能获得的最大价值"。
  • 我们从后向前填充这张表(in-10),这样当我们计算 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)。当 Nk 都很大时,内存可能会成为瓶颈。我们能做得更好吗?答案是肯定的!

我们可以通过改变排序方式和DP定义,将空间复杂度优化到 O(N)

核心思路:

  1. 按结束时间排序:这是这个解法的关键。它方便我们快速找到在当前会议开始之前,所有已经结束的会议。
  2. 新的DP定义dp[i] 在第j次迭代中,表示events[0...i] 中选择 j 个不冲突会议所能获得的最大价值和。注意,这里不再是"从i开始",而是"到i为止"。
  3. 迭代与状态转移 :
    • 我们外层循环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)的情况下,如何组合投资以实现回报最大化?

如果你想继续打磨这项技能,下面这些力扣题目是绝佳的练习材料:

希望今天的分享,能让你感受到算法并不是高高在上的屠龙之技,而是我们程序员工具箱里一把解决复杂问题的利刃。下次当你遇到需要在众多选项中做出最优决策的场景时,别忘了,动态规划可能就是你的答案!😉

相关推荐
小信啊啊12 分钟前
Go语言切片slice
开发语言·后端·golang
tang&24 分钟前
滑动窗口:双指针的优雅舞步,征服连续区间问题的利器
数据结构·算法·哈希算法·滑动窗口
拼命鼠鼠28 分钟前
【算法】矩阵链乘法的动态规划算法
算法·矩阵·动态规划
LYFlied44 分钟前
【每日算法】LeetCode 17. 电话号码的字母组合
前端·算法·leetcode·面试·职场和发展
式5161 小时前
线性代数(八)非齐次方程组的解的结构
线性代数·算法·机器学习
Victor3562 小时前
Netty(20)如何实现基于Netty的WebSocket服务器?
后端
缘不易2 小时前
Springboot 整合JustAuth实现gitee授权登录
spring boot·后端·gitee
橘颂TA2 小时前
【剑斩OFFER】算法的暴力美学——翻转对
算法·排序算法·结构与算法
叠叠乐2 小时前
robot_state_publisher 参数
java·前端·算法
Kiri霧2 小时前
Range循环和切片
前端·后端·学习·golang