项目组共有N个开发人员,项目经理接到了M个独立的需求,每个需求的工作量不同,且每个需求只能由一个开发人员独立完成,不能多人合作。假定各个需求直接无任何先后
依赖关系,请设计算法帮助项目经理进行工作安排,使整个项目能用最少的时间交付。
输入描述
第一行输入为M个需求的工作量,单位为天,用逗号隔开。
例如:X1 X2 X3 … Xm 。表示共有M个需求,每个需求的工作量分别为X1天,X2天…Xm天。
其中0<M<30;0<Xm<200
第二行输入为项目组人员数量N
输出描述
最快完成所有工作的天数
用例
输入 | 输出 | 说明 |
---|---|---|
6 2 7 7 9 3 2 1 3 11 4 2 | 28 | 共有两位员工,其中一位分配需求 6 2 7 7 3 2 1共需要28天完成,另一位分配需求 9 3 11 4 共需要27天完成,故完成所有工作至少需要28天。 |
思考
将 N 个员工 抽象为 N 条并行流水线,每条流水线独立处理分配的需求,流水线的处理时间为分配需求的工作量之和。目标是将 M 个需求 分配至 N 条流水线,使得 所有流水线处理时间的最大值最小(即「最长流水线时间」最短)。可以理解成并行流水线的负载均衡问题。那么容易想到的解法是暴力求解组合数,假如为第一个员工选取 k (0 <= k <= M)个需求,则第二个员工只能从 M - k 个需求中继续选。写代码时遍历需求列表 works,对当前需求 work[i] 给出分配或不分配给员工 work[i] 的两种决策,用递归和回溯实现。对于所有组合数求出每种员工的花费的天数,取出员工时间最大值不断更新全局最小的完成需求天数。时间复杂度是指数级别,对于题目给出的规模 0<M<30;0<Xm<200 可能会超时。
利用二分查找最少完成需求天数+回溯分配任务判断可行性能降低复杂度, 可以预先对工作量进行降序排序(优先分配大任务),利用贪心策略优化,这能显著提高回溯算法的效率。
算法过程
-
输入处理:读取任务列表和工人数量,处理边界情况(工人数量为 0 时直接返回 0)。
-
任务排序:将任务按工作量降序排列,优先处理大任务以提高回溯剪枝效率。
-
二分查找框架:
- 初始化左边界
l
为最大单个任务量,右边界r
为所有任务的工作量总和。 - 在
[l, r)
范围内进行二分查找,每次取中间值mid
作为候选最大时间限制。
- 初始化左边界
-
可行性验证(回溯算法):
- 尝试将所有任务分配给工人,使得每个工人的总工作量不超过
mid
。 - 使用回溯法递归分配任务,剪枝条件:
- 若当前任务无法分配给任何工人,立即返回失败。
- 若某工人分配任务后总工作量达到
mid
,不再尝试后续工人(避免对称分配)。 - 若某工人当前无任务,且分配当前任务后无法完成,后续工人无需尝试(避免重复路径)。
- 尝试将所有任务分配给工人,使得每个工人的总工作量不超过
-
调整边界:
- 若存在可行分配方案,缩小右边界
r = mid
。 - 若不存在,增大左边界
l = mid + 1
。
- 若存在可行分配方案,缩小右边界
-
输出结果:二分查找结束时,
l
即为最小的最大完成时间。
时间复杂度分析
-
排序阶段:对
M
个任务进行降序排序,时间复杂度为 \(O(M \log M)\)。 -
二分查找阶段:
- 二分查找的区间为
[max_task, sum_tasks]
,时间复杂度为 \(O(\log \text{sum\_tasks})\)。 - 每次二分需要调用回溯算法验证可行性。
- 二分查找的区间为
-
回溯验证阶段:
- 回溯算法的理论时间复杂度为 \(O(N^M)\)(每个任务有
N
种分配可能)。 - 但通过降序排序和剪枝优化,实际复杂度显著降低,接近 \(O(M \times N)\)。
- 回溯算法的理论时间复杂度为 \(O(N^M)\)(每个任务有
-
总体时间复杂度:\(O(M \log M + \log(\text{sum\_tasks}) \times f(M,N))\),其中 \(f(M,N)\) 为剪枝后的回溯复杂度,通常远小于 \(O(N^M)\)。
参考代码
function solution() {
const tasks = readline().split(" ").map(Number);
const n = parseInt(readline());
if (n === 0) return 0;
// 对工作量进行降序排序,优先处理大任务
tasks.sort((a, b) => b - a);
const backtrack = function(workers, index, limit) {
// 如果所有任务都已分配,则返回true
if (index >= tasks.length) {
return true;
}
// 获取当前任务的工作量
let current = tasks[index];
// 尝试将当前任务分配给每个员工
for (let i = 0; i < workers.length; i++) {
if (workers[i] + current <= limit) {
// 分配任务给当前员工
workers[i] += current;
// 继续尝试分配下一个任务
if (backtrack(workers, index + 1, limit)) {
return true;
}
// 回溯,取消当前任务分配
workers[i] -= current;
}
// 如果当前员工没有任务或者加上当前任务刚好达到时间限制,则不需要尝试其他员工
if (workers[i] === 0 || workers[i] + current === limit) {
break;
}
}
return false;
};
const canFinish = function(limit) {
// 创建一个数组记录每个员工的工作量
let workers = new Array(n).fill(0);
// 使用回溯法检查是否可以完成
return backtrack(workers, 0, limit);
};
let l = tasks[0], r = tasks.reduce((a,b)=>a+b, 0);
while (l < r) {
let mid = l + Math.floor((r -l)/2);
if (canFinish(mid)) {
r = mid;
} else {
l = mid +1;
}
}
console.log(l);
}
const cases = [
`6 2 7 7 9 3 2 1 3 11 4
2`,
];
let caseIndex = 0;
let lineIndex = 0;
const readline = (function () {
let lines = [];
return function () {
if (lineIndex === 0) {
lines = cases[caseIndex]
.trim()
.split("\n")
.map((line) => line.trim());
}
return lines[lineIndex++];
};
})();
cases.forEach((_, i) => {
caseIndex = i;
lineIndex = 0;
solution();
});