钱老板赶工
钱老板赶工
https://www.nowcoder.com/questionTerminal/ec64f266f4c44c63a38d3d11024e5230
递归和迭代实现, python和C++实现. 方法相同, C++代码通过, python代码超时.
题目链接:https://www.nowcoder.com/questionTerminal/ec64f266f4c44c63a38d3d11024e5230?answerType=1&f=discussion
题目描述:
钱老板去国外度了个假,刚回到公司就收到了 n 封催促工作完成的邮件。每项工作都有完成截止日期 deadline,钱老板做每项工作都会花去cost天,而且不能中断。请你帮钱老板安排一下完成工作的顺序,以减少总的工作推迟时间。
输入描述:
第一行包含一个正整数 n(1<=n<=20),表示工作数量。
接下来 n 行,每行包含两个整数,分别表示第 i 项工作的 deadline 和 cost。
输出描述:
一个数字,表示钱老板最少需要推迟几天才能完成所有工作。
示例:
输入
3 3 3 8 1 3 2
输出:
2
说明
输入样例表示有 3 项工作。
第一项工作截止时间在第 3 天,需要 3 天时间完成。
第二项工作截止时间在第 8 天,需要 1 天时间完成。
第三项工作截止时间在第 3 天,需要 2 天时间完成。
因此合理的顺序是 1->3->2 或 3->1->2,均推迟 2 天。
分析
假设有N项工作, 编号为1,2,...,N; 那么一定存在一个完成工作的序列, 按照这个序列完成所有工作, 推迟的天数最少.
假设我们已经完成了K项工作, 剩余M=N-K项工作, 那么怎么安排剩余的M项工作, 使得这M项工作超期的天数之和最小呢?
假设K项工作耗时为previous_cost, 在剩余的M项工作中任意选择一项, 假设其编号为i, 其耗时为costs[i], 截止日期为deadlines[i], 那么剩余的M-1项工作就构成了一个子问题:
已经完成K+1项工作, 已经耗时previous_cost+costs[i], 剩余M-1项工作, 求一个最佳序列使得这M-1项工作超期天数最小.
看到这里已经基本清楚了, 对于初始的情况, 已完成的工作为0, previous_cost=0, 剩余工作集合work_set大小为N.
我们将所有的工作按照输入的顺序进行编号, 假设为{1,2,...N}, 那么我们要求的问题可以归纳为, 从时刻0开始, 剩余的工作集合work_set={1,2,...,N}, 求最少的推迟时间.
划分子问题
假设有这样一个函数dp, 给它输入起始时间, 还有剩余的工作编号的集合, 它就能自动返回最优的结果.def dp(t_start, work_set): ## 黑盒 return minimal_postpone ## 返回最小推迟时间
那么我们是不是只要返回 dp(0, work_set)就行了? 当然不行, 继续往下看.
我们可以将问题分解, 从work_set中任意抽取一项工作 i, 只计算它的推迟时间:postpone_i, 然后将剩余的工作的集合work_set-{i}扔给dp函数计算, 得到postpone_remain, 那么postpone_i+postpone_remain就是完成work_set中所有工作的一个推迟时间, 但是不一定时最优的, 必须遍历整个集合进行比较. 于是有以下代码:def dp(t_start, work_set): postpone_min = INT_MAX for i in workset: postpone_remain = dp(0+cost[i], workset-{i}) ## 计算剩余N-1项工作的最优解 postpone_i = max(costs[i]-deadlines[i],0) ## 计算工作i的推迟时间, 如果没有推迟, 那就是0 postpone_min = min(postpone_min, postpone_i+postpone_remain) ## 记录最小的推迟时间
但是一直递归肯定有问题, 我们必须指定终止条件. 显然, 当work_set为空时, 自然应该停止. 那么为空时返回什么值呢? 返回0, 因为没有工作, 自然也没有推迟.
```
def dp(t_start, work_set):
if 0 == len(work_set):return 0
postpone_min = INT_MAX
for i in workset:postpone_remain = dp(0+cost[i], workset-{i}) ## 计算剩余N-1项工作的最优解 postpone_i = max(costs[i]-deadlines[i],0) ## 计算工作i的推迟时间, 如果没有推迟, 那就是0 postpone_min = min(postpone_min, postpone_i+postpone_remain) ## 记录最小的推迟时间
时间复杂度优化, 使用备忘录
现在代码逻辑上是没有什么问题了, 但是代码的效率却堪忧. 聪明人一眼可以看出来, 它本质上是将长度为N的序列进行了全排列, 然后求每个排列的推迟时间, 时间复杂度为O(N!). 那么怎么优化呢? 学过动态规划的一定知道一个东西叫做备忘录memo, 它可以解决递归过程中的大量重复计算问题. 就是说, 某一个子问题被重复计算了多次.
比如在这个问题中, 假设剩余的工作集合为{1,2,3,4}, 那么它被分为四个子问题{1,2,3}, {1,2,4}, {1,3,4}, {2,3,4}; 在解决子问题{1,2,3}时, 会解决掉子问题{1,2},{1,3},{2,3}, 但是在解决子问题{1,2,4}时, 又会重复计算{1,2}, 在解决子问题{1,3,4}时, 会再一次计算{1,3}. 类似的重复计算的数量时惊人的.
这时候备忘录就很有用了, 备忘录一般情况下是一个哈希表, 它可以将你计算过的子问题和结果以(key,value)的形式记录下来. 每当你计算一个问题时, 可以先访问这个备忘录, 如果有结果, 直接返回即可.
这样的话, 这个问题的时间复杂度变成了. 原因是, 我们将排列问题变成了组合问题: 已经计算过的集合{
}, 一定已经是最优的了. 那么从问题规模从N到1, 子问题的数量总和为
, 因此时间复杂度为
. 空间复杂度同样为
, 因为每个子问题都需要记录到哈希表中.
进一步优化
通常带备忘录的递归方法, 可以改为迭代的方式, 这样可以节省下递归调用栈压栈的开销, 而且也能避免超过系统允许的调用栈深度.
具体看代码即可.
代码
C++ 带备忘录递归, 通过
#include <iostream> #include <algorithm> #include <vector> #include <climits> using namespace std; struct Args{ int N; int* deadlines; int* costs; Args(int N, int* deadlines, int* costs):N(N),deadlines(deadlines),costs(costs){ } }; int recur(Args &args, int previous_costs, int code, vector<int>& memo){ if(memo[code] != INT_MAX) return memo[code]; int postpone_min = INT_MAX; int current_postpone = 0; int future_postpone = 0; int mask = 0; int i = 0; while(i<args.N){ mask = 1<<i; if((mask&code) != 0){ current_postpone = max(previous_costs + args.costs[i] - args.deadlines[i], 0); future_postpone = recur(args, previous_costs + args.costs[i], code-mask, memo); postpone_min = min(postpone_min, current_postpone + future_postpone); // cout<<"code:"<<code<<" previous_costs:"<<previous_costs<<" current_postpone:"<<current_postpone<<" future_postpone:"<<future_postpone<<" postpone_min"<<postpone_min<<endl; } i += 1; } memo[code] = postpone_min; return postpone_min; } int main(){ int N = 0; int deadlines[20] = {0}; int costs[20] = {0}; cin >> N; for(int i=0; i<N; ++i){ cin>>deadlines[i]>>costs[i]; } Args args(N, deadlines, costs); vector<int> memo(1<<N, INT_MAX); memo[0] = 0; int postpone = recur(args, 0, (1<<N)-1, memo); cout << postpone <<endl; return 0; }
C++迭代, 通过
#include <iostream> #include <algorithm> #include <cmath> #include <vector> #include <climits> using namespace std; vector<int> get_combination(int k, int L, int H); int sum(vector<int>& costs, int combination); int main(){ int N = 0; int TOTAL_COST = 0; cin >> N; vector<int> deadlines(N, 0); vector<int> costs(N, 0); for(int i=0; i<N; ++i){ cin>>deadlines[i]>>costs[i]; TOTAL_COST += costs[i]; } vector<int> dp(1<<N, INT_MAX); dp[0] = 0; vector<int> combinations; int previous_cost = 0; int comb_copy = 0; int bi_index = 0; int i = 0; int postpone_i = 0; for(int k = 1; k<=N; ++k){ combinations = get_combination(k, 0, N-1); for(auto combination:combinations){ previous_cost = TOTAL_COST - sum(costs, combination); comb_copy = combination; while(comb_copy){ bi_index = comb_copy&(-comb_copy); i = round(log(bi_index)/log(2)); postpone_i = max(previous_cost+costs[i]-deadlines[i], 0); dp[combination] = min(dp[combination], postpone_i+dp[combination-bi_index]); comb_copy -= bi_index; } } } cout << dp[(1<<N)-1] <<endl; return 0; } /* 返回从[L,H]区间抽取k个数字的所有组合, 当然每个组合都是一个数字, 它表示的组合是在二进制表示中有k个1, 比如0b0101, 这是一个k为2的组合, 它的十进制数是5 */ vector<int> get_combination(int k, int L, int H){ if(k==0){ vector<int> combinations(1, 0); return combinations; } if(k>=1){ vector<int> combinations; vector<int> sub_combnations; for(int i=L; i<=H-k+1; ++i){ sub_combnations = get_combination(k-1, i+1, H); for(vector<int>::iterator iIter = sub_combnations.begin(); iIter!= sub_combnations.end(); iIter++){ *iIter += 1<<i; } combinations.insert(combinations.end(), sub_combnations.begin(), sub_combnations.end()); } return combinations; } } int sum(vector<int>& costs, int combination){ int cum = 0; while(combination){ auto bi_index = combination&(-combination); auto i = round(log(bi_index)/log(2)); cum += costs[i]; combination -= bi_index; } return cum; }
python, 带备忘录的递归, 超时, 通过率80%
import sys ## 处理输入 N = int(sys.stdin.readline().strip()) deadlines = [] costs = [] for _ in range(N): deadline, cost = list(map(int, sys.stdin.readline().strip().split())) deadlines.append(deadline) costs.append(cost) ## 递归 memo = {} def recur(t_start, seq): ##终止条件 if len(seq) == 0: return 0 else: ## seq是set类型, 不能直接哈希, 先转为tuple类型 key = tuple(seq) if key in memo: return memo[key] T_min = float('inf') ## 遍历所有情况 for i in seq: t_i = t_start+costs[i] Ti = max(t_i-deadlines[i],0) ## 求解子问题 T_rem = recur(t_i, seq-{i}) T_min = min(T_min, Ti+T_rem) memo[key] = T_min return T_min ## 调用递归函数, 输出结果 seq = set(range(N)) postpone = recur(0, seq) print(postpone)
python, 迭代法, 超时, 通过率85%
import sys from itertools import combinations ## 处理输入 N = int(sys.stdin.readline().strip()) deadlines = {} costs = {} TOTAL_COST = 0 ## 对工作进行编号, 为了方便组合, 第i个工作的编号为1<<i位, 这样N位大小的数字可以表示所有的组合; ## 比如N=3, 二进制0b111表示工作集合{1,2,3}, 0b011表示工作集合{1,2}; 从集合中去掉某一项工作也很简单, ## {1,2,3}-{1}可以表示为0b111-0b001=0b110, 即7-1=6. for i in range(N): deadline, cost = list(map(int, sys.stdin.readline().strip().split())) deadlines[1<<i] = deadline costs[1<<i] = cost TOTAL_COST += cost ## 所有工作的集合 workset = [1<<i for i in range(N)] ## dp数组, 大小为2^N, 初始值设置为可能的最大推迟时间 dp = [TOTAL_COST]*(2**N) ## 没有工作时, 推迟时间为0 dp[0] = 0 ## 问题规模从小到大, 从1项工作的组合, 到N项工作的组合 for k in range(1,N+1): for combination in combinations(workset, k): code = sum(combination) previous_cost = TOTAL_COST-sum([costs[i] for i in combination]) for i in combination: dp[code] = min(dp[code], max(previous_cost+costs[i]-deadlines[i],0)+dp[code-i]) print(dp[(1<<N)-1])