从选择困难到最优策略:我如何用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)的情况下,如何组合投资以实现回报最大化?

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

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

相关推荐
加瓦点灯3 分钟前
面试官: 如何设计一个评论系统?
后端
Chirp4 分钟前
手撕ultralytics,换用Lightning训练yolo模型
算法
郡杰15 分钟前
JavaWeb(4-Filter、Listener 和 Ajax)
后端
white camel19 分钟前
重学SpringMVC一SpringMVC概述、快速开发程序、请求与响应、Restful请求风格介绍
java·后端·spring·restful
蓝倾28 分钟前
小红书获取关键词列表API接口详解
前端·后端·fastapi
明天有专业课33 分钟前
想让客户端出口IP变成服务器IP?WireGuard这样配置就行
后端
Smilejudy34 分钟前
在 RDB 上跑 SQL--SPL 轻量级多源混算实践 1
后端
2301_801821711 小时前
机器学习-线性回归模型和梯度算法
python·算法·线性回归
电院大学僧1 小时前
初学python的我开始Leetcode题-13
python·算法·leetcode
enzeberg1 小时前
全面解析前端领域的算法
算法