2021-5-08 力扣每日一题

1723完成所有工作的最短时间

虽迟但到,带你一文读懂状态压缩在动态规划中的应用

题目表述:

​ 给你一个整数数组 jobs ,其中 jobs[i] 是完成第 i 项工作要花费的时间。

​ 请你将这些工作分配给 k 位工人。所有工作都应该分配给工人,且每项工作只能分配给一位工人。工人的 工作时间 是完成分配给他们的所有工作花费时间的总和。请你设计一套最佳的工作分配方案,使工人的 最大工作时间 得以 最小化 。

​ 返回分配方案中尽可能 最小 的 最大工作时间 。

示例1:
输入:jobs = [3,2,3], k = 3
输出:3
解释:给每位工人分配一项工作,最大工作时间是 3
示例2:
输入:jobs = [1,2,4,7,8], k = 2
输出:11
解释:按下述方式分配工作:
1 号工人:128(工作时间 = 1 + 2 + 8 = 112 号工人:47(工作时间 = 4 + 7 = 11)
最大工作时间是 11

思路:

这是一个困难题,做起来还是有点难度的。

【贪心】

​ 最初我使用的是一种贪心的思路,具体就是k个工人,像是有k个柱子的柱状图,我们把所有的工作都放进去,让这k个柱子中最高的那个最低即可。

​ 这个思路一下子就让我想到了堆,因为我们想要让最高的最低嘛,所以在新增任务的时候,我就优先分配给最低的那一位任务即可,这个和小根堆的思想是一样的,我们先对任务的数组进行一个排序,优先分配较大份的工作,因为大份的工作一旦留在最后,可能会出现放在哪里都会让高度很高的情况,而如果小份的放在后面,就可以灵活的分配。

​ 我按照这个思路去实现,提交过后发现了一个问题,如果某个问题只有一种解法,并且是需要将较大的几个全部让一个人干的话,那么这种贪心就没办法解出正确答案。因为我们的第一步是将较大的先进行分配,所以一定会将较大的几个分给不同的工人。一开始就错了,结果也一定是错的。

这种感觉是我写背包问题的时候出现的。因为贪心无法解决背包问题,所以要引入动态规划,这个感觉和那时候一模一样,所以我决定朝动态规划的方向思考。

错误的Java代码:

/** 
* @Description: 力扣1723题题解
* @return: 返回结果
* @Author: Mr.Gao
* @Date: 2021/5/8 
*/

public int minimumTimeRequired(int[] jobs, int k) {
    int n = jobs.length;
    Arrays.sort(jobs);
    PriorityQueue<Integer> Queue = new PriorityQueue<Integer>(k-1);
    for (int i = 0; i < k; i++) {
        Queue.add(0);
    }
    for (int i = 0; i < n; i++) {
        Integer temp = Queue.poll();
        temp+=jobs[n-i-1];
        Queue.add(temp);
    }
    int a = -1;
    Iterator<Integer> iterator = Queue.iterator();
    while(iterator.hasNext()){
        int b = iterator.next();
        if(b>a){
            a = b;
        }
        System.out.println(b);
    }
    return a;
}

【动态规划+状态压缩】

​ 既然朝着动态规划的方向思考,那么我们就要用动归的思路来解决问题。

​ 动归的关键就是转移方程的确立。这是一个很难的点,这里分享一些我找转移方程的一些方法。

通过问题寻找转移方程。

​ 因为我们最后要返回的结果通常是转移方程右下角的数字。所以如果我们读懂了问题的描述,转移方程的关键变量就可以确定了。

​ 拿这个题来说,我们的问题可以描述为k个工人完成所有工作的某某某值。我们可以提炼出两个关键字,一个就是工人,一个就是完成工作。这个问题难就难在工人很容易表示,但是完成工作这个关键字应该怎么表示呢?为了表示这个关键字,我们引入了状态压缩的思想。

状态压缩

​ 第一次接触到状态压缩是在一个问题中,叫做金陵十三钗,感兴趣的同学可以看一看百度搜索“算法《金陵十三钗》”即可。

​ 因为很多时候,一些事物的状态只有两种,一个是有,一个是无,那么我们就很容易将其状态转换成二进制的数字来表示,这就是状态压缩的基本概念。

​ 那么怎么转变的?通过这个问题我们来分析一下。

分析过程

​ 我们如何根据状态压缩来表示完成工作的状态呢?因为工作一共有n个,我们可以用一个n位的二进制数来表示各个工作完成的情况,比如说00…01(…表示都是0)就表示第一个工作完成,其他工作都没完成。这样我们使用一个n为二进制数就可以表示我们工作完成的情况,如果工作全部完成,那就全是1,如果没有工作完成,那就全是0。


​ 理解了这一点我们就可以想转移方程如何确定,我们已经确定了两个关键变量,一个是工人,一个就是完成情况,这是根据我们最后需要得到的结果确定的,最后我们要得到的就是在k个工人都用,工作又全部完成的情况下,每个工人需要做的工作的最小值。

​ 方程有了,怎么转移呢?

对于某一种状态而言(这个点非常重要),我们思考到第 i 个工人的时候,如何和第 i - 1个工人产生联系。我们比 i - 1 的时候多了一个工人,为了让我们的最大值最小,那我们可能把之前分配给 i - 1个人的一些工作分配给他。分配的那些工作一共有多少工作量?在 i - 1的状态下少了那些工作后的值又是多少?另外,我们把哪些工作分配给第 i 个工人更合适呢?这些是我们需要计算的。

首先第一个问题我们把原先 i-1个人的工作拿走一部分给第 i 个人,拿走的那部分工作的工作量是多大?我们可以初始化一个数组,保存我们每一种状态的工作(从00…00(全没有分配)到111…11(全部分配))分配给一个人的工作量是多少。这样我们就可以解决第一个问题

其次第二个问题:在 i - 1的 状态下,少了那些工作后,最后的结果是多少?这一点我们可以从转移方程中得到,因为我们的工人是递增的,状态也是递增的,所以我们可以得到在 i - 1的工人数下,少了那些工作后,最后的结果是多少,只要我们有其工作状态就可以。

最后第三个问题:我们如何知道把哪些工作分配给第 i 个人更合适呢?这个并没有什么好的解法,只能暴力搜索了,我们只能遍历每一种方案,去寻找最优解才行。

​ 解决了三个问题,我们的转移方程就来了!

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-eeG15QMr-1620480601361)(F:/%E7%AC%94%E8%AE%B0%E5%9B%BE%E7%89%87%E4%B8%93%E7%94%A8%E6%96%87%E4%BB%B6%E5%A4%B9/image-20210508211437073.png)]

粘自力扣题解

这里的C[ j j ’ ]表示集合j中关于j‘的补集,sum[ j ’ ]表示在j’的状态下,把工作分配给一个人需要的工作量(就是我们需要初始化的量)。

细节

​ 按理来说,有了转移方程,逻辑也清楚了,我们就可以写代码了,但是这个问题还有一些细节需要交代一下

第一 我们怎么求一个状态的子集。

​ 例如某一次数字的状态为 10001010,我们如何求他的子集。首先我们要明白一点就是,子集一定比原来的集合要小,用二进制表示也同样如此,我们可以将这个状态-1,得到的数值再和元状态做与运算。这里就是

10001001&

10001010=

10001000

​ 显然结果一定是离原状态最近的一个子集!!这一点要好好理解一下


​ 懂了上面那一点,我们就可以求出某一个状态的所有子集了。

第二 怎么求补集。

​ 还拿上面的例子来看,怎么求全集100010001关于其某个子集10001000的补集,这个简单,直接将两个集合相减就行了。最后得到的结果就是00000001,刚好就是补集。


​ 还有一个问题是关于初始化,我们如何才能将sum的初始化做的最简单呢?因为如果暴力的话,我们需要计算大量的东西,这里其实也可以使用动态规划的思想,具体的实现过程,我在代码的注释中写了。

Java代码

/**
 * @Description: 力扣1723题题解2
 * @return: 返回结果
 * @Author: Mr.Gao
 * @Date: 2021/5/8
 */
public int minimumTimeRequired2(int[] jobs, int k){
    int n = jobs.length;
    int[] sum = new int [1<<n];
    for(int i=1;i<(1<<n);i++){
        //返回i的二进制从低位开始0的个数(遇到1就停止)
        int x = Integer.numberOfTrailingZeros(i);
        int y = i-(1<<x);

        //当i=1的时候,这里的x=0,y=0,此时计算的是
        // sum[00..1] = sum[00..0]+jobs[0];
        //表示只有第一个工作被分配的时候的工作量,
        //等于没有工作被分配的时候的工作量加上第一个工作的工作量

        //同理,当i = 2的时候,这里的x = 1,y = 0
        //表示sum[00..10] = sum[00..0]+jobs[1];
        //表示只有第二个工作被分配时的工作量
        //等于没有工作被分配时的工作量加上第二个工作的工作量

        //看一个分配两个的案例,当i = 3的时候
        //这里的x = 0,y = 2
        //表示sum[00..11] = sum[00..10]+job[0];
        //表示当工作1和工作2被分配的时候的工作量
        //等于工作2被分配时候的工作量加上工作1被分配时候的工作量

        //总结!这里使用了一个巧妙的方法,先统计出第一个1所在的位置
        //再减去第一个1的那种状态,得到另一种状态,而这一种状态一定是我们之前求过的
        //所以这样就可以优化时间成本。

        //换句话来说就是00000110 = 00000100+00000010
        //这是状态的加法,前面一种状态可以由后面两种状态表示,因为后面的前一种状态一定是我们之前求过的
        //所以这里就可以节约时间成本
        sum[i] = sum[y]+jobs[x];
    }

    int[][] dp = new int[k][1<<n];

    //初始化,如果给0号工人分配工作的话,将其值初始化为分配工作状态下的总成本
    //因为就他一个人干活嘛,所以总成本就是分配工作的总和
    for(int i=0;i<1<<n;i++){
        dp[0][i] = sum[i];
    }
    //从1开始遍历,表示给第一个工人分配完之后,开始给第二个,第三个工人分配
    for(int i=1;i<k;i++){
        //这里的j就表示状态,遍历所有状态。所求的dp[i][j]就等于
        //给前i个人分配工作的状态为j时,其最短时间
        for(int j=0;j<(1<<n);j++){
            //初始化
            int minn = Integer.MAX_VALUE;
            //这里的x表示j的子集,也就是j的子状态
            for(int x = j;x!=0;x = (x-1)&j){
                
                minn = Math.min(minn,Math.max(dp[i-1][j-x],sum[x]));
            }
            dp[i][j] = minn;
        }
    }
    return dp[k-1][(1<<n)-1];
}
  • 码字不易,还望素质三连
  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 4
    评论
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值