题目描述:
任务调度优化是计算机性能优化的关键任务之一。在任务众多时,不同的调度策略可能会得到不同的总体执行时间,因此寻求一个最优的调度方案是非常有必要的。
通常任务之间是存在依赖关系的,即对于某个任务,你需要先完成他的前导任务(如果非空),才能开始执行该任务。我们保证任务的依赖关系是一棵二叉树,其中 root 为根任务,root.left 和 root.right 为他的两个前导任务(可能为空),root.val 为其自身的执行时间。
在一个 CPU 核执行某个任务时,我们可以在任何时刻暂停当前任务的执行,并保留当前执行进度。在下次继续执行该任务时,会从之前停留的进度开始继续执行。暂停的时间可以不是整数。
现在,系统有两个 CPU 核,即我们可以同时执行两个任务,但是同一个任务不能同时在两个核上执行。给定这颗任务树,请求出所有任务执行完毕的最小时间。
示例 1:
输入:root = [47, 74, 31]
输出:121
解释:根节点的左右节点可以并行执行31分钟,剩下的43+47分钟只能串行执行,因此总体执行时间是121分钟。
示例 2:
输入:root = [15, 21, null, 24, null, 27, 26]
输出:87
示例 3:
输入:root = [1,3,2,null,null,4,4]
输出:7.5
限制:
1 <= 节点数量 <= 1000
1 <= 单节点执行时间 <= 1000
分析
这题也是实打实的hard难度问题,一开始看见题目觉得挺简单的,一看用例3出来个7.5就懵了,在看评论了解到7.5是怎么来的后还是没有思路,看了若干题解后还是没有理解解题思路,最后自己手动推导,才把这题给整出来,思维难度妥妥的五颗星。
系统有两个 CPU 核,即我们可以同时执行两个任务,但是同一个任务不能同时在两个核上执行。这句话是本题的核心,如果没有用例三,百分之九十的人都会觉得只要求出左右子树串行执行的时间和并行执行的时间,简单运算后就能推出整棵树的最短执行时间了。有两个CPU核,那么所有任务执行期间要么是两个核同时执行不同的任务,要么是只有一个核在工作,两个核同时工作的时间我们称为并行时间,单个核工作的时间我们称为串行时间。对于以root为根的树,设其左子树任务执行的串行时间是a,并行时间是b;右子树的串行时间是c,并行时间是d。我们第一印象就是把两个子树的串行执行时间利用起来,比如a > c,我们用c的时间并行执行,这样左右子树串行的时间就只剩下a - c了,加上root的串行执行时间,再加上所有的并行时间就是总时间了。比如用例3,先并行执行4 4,耗时4,再尝试并行执行原本需要串行执行的3和2,并行执行2时间,串行执行1时间,最后再串行执行1,这样得到的总时间是4 + 2 + 1 + 1 = 8,大于用例的输出7.5,说明这种方法不是最优的。
那么用例3是怎么执行起来的呢?1的孩子是3,2,2的孩子是4,4。我们之前的思路并行执行3,2时候2执行完3还剩下1时间只能串行执行了,因为两个核不能同时执行同一个的任务。题目里有“我们可以在任何时刻暂停当前任务的执行,并保留当前执行进度”,这样我们能不能先把3的1时间给消耗掉,然后暂停,最后再和2一起并行执行呢?答案是可以的。前0.5时间我们并行执行3和4,之后一个核换另一个4来执行,这样1时间过去了,2的两个孩子的任务时间就变成了3.5, 3的任务时间变成了2.之后3.5时间执行我们并行执行两个3.5的任务,之后的2时间我们并行执行2和3(任务时间只剩下了2),最后串行执行1,总的时间就是1 + 3.5 + 2 + 1 = 7.5。
对用例3的模拟能够帮助我们解决本题,如果只看上面的模拟过程,就会疑惑,为什么要先把3拆两个0.5的时间和4去并行执行,0.5是怎么来的?求解本题时我们要怎么去拆分时间呢?不妨宏观的看待这一过程,1是根节点,首先考察其左子树3的执行最短时间,由于3没有孩子节点,只能串行执行,所以耗时3执行完,其中串行时间(也就是一个核执行时间)是3,并行时间(双核执行时间)是0,用前面的字母表示就是a = 3,b = 0;1的右子树串行执行时间是c = 2,并行执行时间的d = 4。这是显然的,按照我们一开始的思路,首先把两个子树的串行时间并行起来,也就是串行时间a和c并行执行,这样其中的2时间就变成了并行时间,还剩1时间我们尝试用1的右子树的并行执行时间消耗掉。怎么消耗掉?相当于我们两只手不能拿同类东西,现在有三类物品,第一类和第二类各有4kg,第三类物品有1kg,我们如果开始拿1,2类物品,耗时4拿完,之后1时间一只手拿第三类物品;如果先拿一半3和同样多的1,再拿另一半3和同样多的2,这样3物品拿完了,剩下两种物品都只剩下3.5kg了,继续双手拿完,拿物品顺序的改变就使得全程我们双手都没有休息,执行时间减少。
总结下本题的思路,对于以root为根的树,设其左子树任务执行的串行时间是a,并行时间是b;右子树的串行时间是c,并行时间是d。不妨设a > c,为了使得任务执行时间最短,我们将其中c的串行时间用于a和c并行,剩下的a - c的串行时间尝试用并行时间d消耗掉,也就是说用右子树的串行时间和并行时间去消耗掉左子树的串行时间,为什么不用左子树的并行时间消耗呢?因为我们求出的a和b已经是左子树的最小串行和并行时间了,没法再从左子树本身去减少串行时间了。d能够消耗掉多少a -c的串行时间呢?
我们以用例1为例
我们递归的求出了47的左子树的串行时间a = 74,并行时间b = 0;右子树串行时间c = 31,并行时间d = 0.按照上面的理论,先用右子树串行时间31去消耗左子树的串行时间,增加了整棵树的并行时间31.这时候左子树还剩下43的串行时间,但是由于右子树的并行时间是0,没有任务可以和74所在的任务继续并行执行了,所以串行时间不能再减少了。如果给31增加两个15时间的孩子节点,这样右孩子的并行时间d = 15了,我们先用15时间去执行74任务和一个15任务;再用15时间去执行74任务和另一个15任务,这样一来两个并行的15任务执行完之后74的串行时间又减少了30,43 - 30 = 13,d = 15时为74再次减少了30的串行时间;如果右子树的并行时间是d = 21.5呢,那么两个21.5的任务依次去和74任务并行执行,恰好能够消耗掉剩下的43串行时间。如果d继续增加呢,比如加到30,我们依旧只能用21.5的时间轮流和74任务并行,74任务执行完后就没有串行任务了,剩下的右子树的并行任务继续执行。根据这个例子我们可以得出,左子树的串行时间a其中的c时间用右子树的串行时间c消耗掉,剩下的a -c的串行时间可以尝试用右子树的并行时间d消耗掉。
如果a - c <= 2 * d,那么两个执行时间为d的任务可以轮流消耗掉a - c的串行时间;
如果a - c > 2 * d,那么两个执行时间为d的任务只能消耗掉2d的串行时间。
解题思路已经显然了,递归的求出root左右子树的串、并行时间ab和cd,如果c > a就交换a和c、b和d,保证下左子树的串行时间比较大。之后求以root为根的树的串行时间和并行时间,串行时间首先要加上root自身的执行时间root->val,然后加上左、右子树的串行时间,假设d能够消耗掉左子树的串行时间是t,加上c消耗掉的串行时间c,那么左右子树剩下的串行时间就是a - c - t。并行时间则等于原本左右子树的并行时间b + d,加上原本c时间串行转换成的并行时间,再加上用d消耗掉的左子树的串行时间t / 2,为什么t要除以2?回到a - c = 1,d = 4的这个例子,如果两个核能够执行相同的任务,那么串行时间1只需要并行执行0.5就可以完成了。改变执行顺序的结果是等价于并行执行a -c这个任务的,0.5时间执行4和1, 0.5时间执行另一个4和1,消耗1时间,剩下的3.5时间执行完两个4剩下的,这样的并行时间等于4.5,相比于原来的d = 4增加的恰好是0.5.所以串行任务改为并行执行后,增加的并行时间自然等于串行时间 / 2。
最后,整个树的执行时间等于串行时间+并行时间。
代码
/**
* Definition for a binary tree node.
* struct TreeNode {
* int val;
* TreeNode *left;
* TreeNode *right;
* TreeNode(int x) : val(x), left(NULL), right(NULL) {}
* };
*/
class Solution {
public:
pair<int,double> dfs(TreeNode* root){
if(!root) return {0,0};
auto x = dfs(root->left);
auto y = dfs(root->right);
if(x.first < y.first) swap(x,y);
int a_c = x.first - y.first;//减去串行并行后的串行时间
int t = a_c;//剩下的串行时间能够用于并行的时间
if(t > 2 * y.second) t = 2 * y.second;
int a = root->val + a_c - t;
double b = x.second + y.second + y.first + t / 2.0;
return {a,b};
}
double minimalExecTime(TreeNode* root) {
auto res = dfs(root);
return res.first + res.second;
}
};