1. 问题背景与理解
- Minimum Difficulty of a Job Schedule 是 LeetCode 上一道经典的动态规划问题,属于中等偏上难度。题目描述的是如何安排工作计划,使得在给定天数内完成所有工作的总难度最小。
这个问题的实际场景非常常见:假设你是一个项目经理,手头有一系列任务需要分配给团队成员完成。每个任务都有不同的难度,你需要将这些任务分成若干天完成,每天至少完成一个任务。我们的目标是找到一个分配方案,使得所有天的最大难度之和最小。
举个例子:
- 工作难度数组:[6,5,4,3,2,1]
- 天数d=2
- 最佳分配方案:
- 第一天完成[6,5,4,3,2],最大难度6
- 第二天完成[1],最大难度1
- 总难度=6+1=7
2. 动态规划思路解析
2.1 基础情况分析
首先我们需要考虑几个基础情况:
- 如果工作数量n小于天数d,显然无法完成(每天至少一个工作),直接返回-1
- 如果n等于d,那么每天必须完成一个工作,总难度就是所有工作难度之和
- 其他情况需要使用动态规划来解决
2.2 动态规划状态定义
我们定义dp[i][j]表示前i个工作(jobDifficulty[0..i-1])在j天内完成的最小总难度。
关键点在于理解这个状态转移:
- 在第j天,我们可以选择完成1个、2个...直到i-j+1个工作(因为前面j-1天每天至少完成1个工作)
- 我们需要枚举所有可能的划分点k,使得前k个工作在前j-1天完成,剩下的i-k个工作在第j天完成
2.3 状态转移方程
状态转移的核心思想是:
dp[i][j] = min(dp[k][j-1] + max(jobDifficulty[k..i-1]))
其中k的范围是[j-1, i-1]
解释:
- dp[k][j-1]:前k个工作在前j-1天完成的最小总难度
- max(jobDifficulty[k..i-1]):第j天完成工作k到i-1的最大难度
- 我们需要找到使两者之和最小的k值
2.4 边界条件处理
有几个特殊情况需要注意:
- 初始化:dp[0][0] = 0(0个工作0天完成难度为0)
- 当i == j时,每天必须完成一个工作,所以dp[i][i] = dp[i-1][i-1] + jobDifficulty[i-1]
- 其他dp[i][j]初始化为一个较大值(这里用INT_MAX/10)
3. 代码实现详解
3.1 基础情况处理
cpp复制int n = jobDifficulty.size(), sum = 0, mx;
if(n < d) return -1;
if(n == d) {
for(int& i : jobDifficulty) sum += i;
return sum;
}
这部分代码处理了两个基础情况:
- 工作数小于天数,直接返回-1
- 工作数等于天数,返回所有工作难度之和
3.2 DP表初始化
cpp复制vector<vector<int>> dp(n + 1, vector<int>(d+1, INT_MAX/10));
dp[0][0] = 0;
我们创建了一个(n+1)×(d+1)的DP表,初始值设为INT_MAX/10(避免加法溢出),然后设置dp[0][0]=0作为初始条件。
3.3 填充DP表
cpp复制for(int i = 1; i <= n; i++) {
// 处理i==j的情况
if(i <= d) {
dp[i][i] = dp[i-1][i-1] + jobDifficulty[i-1];
}
// 处理一般情况
for(int dd = 1; dd <= min(i - 1, d); dd++) {
mx = INT_MIN;
for(int k = i-1; k >= dd-1; k--) {
mx = max(mx, jobDifficulty[k]);
dp[i][dd] = min(dp[i][dd], dp[k][dd-1] + mx);
}
}
}
这部分是算法的核心:
- 外层循环遍历工作数量i
- 处理i==j的特殊情况
- 内层循环遍历天数dd(从1到min(i-1,d))
- 对于每个i和dd,我们需要找到最优的划分点k
- 从i-1倒序枚举k到dd-1
- 维护当前区间的最大值mx
- 更新dp[i][dd]为最小值
3.4 为什么倒序枚举k?
这里k从i-1倒序枚举到dd-1,而不是正序枚举,是为了高效计算区间最大值mx:
- 每次k减1时,只需要将jobDifficulty[k]与当前mx比较即可
- 如果是正序枚举,需要重新计算整个区间的最大值,效率更低
4. 复杂度分析
4.1 时间复杂度
算法的时间复杂度主要由三重循环决定:
- 外层循环:O(n)
- 中层循环:O(min(n,d)) ≈ O(d)
- 内层循环:最坏O(n)
总时间复杂度为O(n²×d)
4.2 空间复杂度
我们使用了一个n×d的二维数组存储DP状态,所以空间复杂度是O(n×d)
5. 优化思路
虽然这个解法已经可以AC,但还可以考虑一些优化:
5.1 空间优化
注意到dp[i][j]只依赖于dp[0..i-1][j-1],所以可以将空间优化到O(n):
- 使用两个一维数组,一个保存前一天的结果,一个计算当前天的结果
5.2 提前终止
在某些情况下可以提前终止内层循环:
- 如果发现mx已经大于当前最小总难度,可以提前break
6. 常见错误与调试技巧
6.1 初始化问题
常见错误:
- 忘记初始化dp[0][0]=0
- 初始值设置过小导致加法溢出(所以用INT_MAX/10)
调试技巧:
- 打印出整个DP表,检查初始值是否正确
6.2 边界条件处理
常见错误:
- 没有正确处理n<d和n==d的情况
- 在i==j时忘记更新dp[i][i]
调试技巧:
- 单独测试边界情况
- 使用小例子验证(如n=1,d=1等)
6.3 区间最大值计算
常见错误:
- 正序计算mx导致时间复杂度升高
- mx初始化不正确
调试技巧:
- 在循环中打印mx的值,确保其正确性
7. 实际应用与扩展
这个问题在实际中有很多应用场景:
- 项目管理中的任务分配
- 课程安排
- 生产计划调度
可以扩展的问题:
- 如果每天的工作数量有上限怎么办?
- 如果考虑工作之间的依赖关系怎么办?
- 如果每个工作有不同的处理时间怎么处理?
8. 个人解题心得
这道题是一个典型的动态规划问题,关键在于如何定义状态和状态转移。我在解决这个问题时有几点体会:
-
定义状态时,dp[i][j]表示前i个工作j天完成的最小难度,这个定义很关键。一开始我尝试用dp[i][j]表示第i天完成前j个工作,结果状态转移变得很复杂。
-
区间最大值的计算技巧很重要。倒序枚举k可以高效维护当前区间的最大值,这个技巧在很多区间DP问题中都有应用。
-
边界条件的处理要小心。特别是当i==j时,必须每天完成一个工作,这个特殊情况需要单独处理。
-
在实现时,初始值的设置也很重要。使用INT_MAX/10而不是INT_MAX可以避免加法溢出。
-
对于动态规划问题,先想清楚状态定义和转移方程再写代码,比直接开始写代码然后调试要高效得多。