【算法 - 动态规划】京东面试题 - 洗咖啡杯问题

通过前面多篇文章的学习,相信小伙伴们对动态规划的 分类 以及做题 套路 已经有了清晰的认识。包括从左到右模型、范围尝试模型、样本对应模型。

本文我们继续学习一种新的模型 —— 业务限制模型

咖啡杯变干净

给定数组 arr 和整数 N。 arr[i]的长度代表有几个咖啡机,arr[i] 代表第 i 号咖啡机泡一杯咖啡所需要的时间,N 表示有多少个人正在等待着泡咖啡,每台咖啡机只能轮流泡咖啡。喝完咖啡的杯子有两种方式变干净:用 咖啡机洗 或者 自己挥发 干净。

洗咖啡杯机器只有一台,每次 耗时 a 洗干净 一个咖啡杯,洗完才能洗下一个杯子(串行);每个咖啡杯也可以 耗时 b 自己挥发干净 ,咖啡杯可以并行挥发。

假设所有人拿到咖啡后立刻喝完,求出从开始等待到所有咖啡杯都变干净的最短时间

思路分析

本道题先逆向思考一下,因为洗咖啡杯的时间和咖啡杯自己挥发干净所需要的时间都是固定的,因此,要想求出所有咖啡杯变干净的最短时间。就需要让咖啡杯尽可能的早点空出来等待清洗,也就是,让更多的人尽快喝到咖啡。

因此,就可以将本题划分为两道题目。

  • 问题 1: 如何安排每个人在哪个咖啡机前等咖啡(影响到了什么时候能喝到咖啡并等待清洗)。
  • 问题 2: 当杯子等待清洗时如何选择清理方式:机洗 还是 挥发

我们一个一个的破解。

问题 1 思路

影响每个人什么时候喝到咖啡的因素有两个:咖啡机空闲的时间点做一杯咖啡需要的时间(arr[i])

想象一下,如果你来到咖啡机前喝咖啡想要早点结束,你会去哪个咖啡机呢?很简单,当然是去以上 两个因素相加最小的咖啡机前

因此,对于如何安排人到哪台咖啡机前等待,可以使用小根堆进行计算。

代码

public static class Machine {
    public int timePoint;
    public int workTime;

    public Machine(int t, int w) {
        timePoint = t;
        workTime = w;
    }
}

public static class MachineComparator implements Comparator<Machine> {
    @Override
    // 小根堆
    // 两者总和小的排在前面
    public int compare(Machine o1, Machine o2) {
        return (o1.timePoint + o1.workTime) - (o2.timePoint + o2.workTime);
    }
}

public static int[] minTime(int[] arr, int n, int a, int b) {
    PriorityQueue<Machine> heap = new PriorityQueue<Machine>(new MachineComparator());
    for (int i = 0; i < arr.length; i++) {
        heap.add(new Machine(0, arr[i]));
    }
    int[] drinks = new int[n];
    // 计算出每个顾客的最佳喝咖啡时间
    // 即每个咖啡杯最早空出等待清洗的时间
    for (int i = 0; i < n; i++) {
        Machine cur = heap.poll();
        cur.timePoint += cur.workTime;
        drinks[i] = cur.timePoint;
        heap.add(cur);
    }
    return drinks;
    // 调用问题 2 的暴力递归
    // return bestTime(drinks, a, b, 0, 0);
    // 调用问题 2 的动态规划
    // return bestTimeDp(drinks, a, b);
}

代码解释

当每个顾客来到后,弹出小根堆的堆顶,此时就是最佳的喝咖啡方案,即最早喝完咖啡。做完咖啡后,该咖啡机的工作时间更新为当前时间,即 cur.timePoint += cur.workTime ,也就是该顾客喝完咖啡释放杯子的时间,记录在 drinks[i] 数组中。然后将更新后的咖啡机加入小根堆中,继续为后面的顾客服务,直到所有的 N 个人结束。返回 drinks 数组,表示需要清洗的杯子的到来时间。

问题 2 思路

对于问题 2 ,这就是最熟悉的 从左往右的选择模型 ,对于每个咖啡杯来说,有两种可能的选择,要么选择使用咖啡清洗机清洗要么选择挥发干净。我们按照套路先写出递归版本:

递归的准备

注意是 从左到右 的模型哦!

定义递归函数的功能: 返回 drinks 中 [index...] 范围上咖啡杯都变干净的最早结束时间。

思考递归需要的参数: drinks 数组,清洗杯子的时间 a,挥发干净的时间 b,此时来到的下标 index 以及清洗机器什么时候是空闲的可以使用 free。

明确递归的边界条件:index == drinks.length 时说明已经没有杯子要洗了,根据函数功能可知,返回 0 。

思路

从左到右的尝试模型 ,因此,递归就可以按照 当前位置如何选择 的思路来划分情况:

  • 第 index 号杯子选择 使用机器清洗;
  • 第 index 号杯子选择 挥发干净。

因为要求 最短 时间,因此要返回这两种情况当中的最小值。

代码

// free : 清洗咖啡的机器空闲的时间
public static int bestTime(int[] drinks, int a, int b, int index, int free) {
    if (index == drinks.length) {
        return 0;
    }
    // index 号杯子 选择清洗
    int selfCleanA = Math.max(drinks[index], free) + a;
    int restCleanA = bestTime(drinks, a, b, index + 1, selfCleanA);
    int p1 = Math.max(selfCleanA, restCleanA);

    // index 号杯子 选择挥发
    int selfCleanB = drinks[index] + b;
    int restCleanB = bestTime(drinks, a, b, index + 1, free);
    int p2 = Math.max(selfCleanB, restCleanB);

    return Math.min(p1, p2);
}

代码解释

1. 选择机器清洗
  • 当一个杯子选择机器清洗时,drinks[index] 表示自己什么时间可以洗,但此时机器可能还没有空闲。因此,需要选择二者的最大值,也就是既能可以开始洗机器也空闲的时间。洗杯子需要 a 的时间,因此杯子变得干净的时间就等于 Math.max(drinks[index], free) + a
  • 因为要返回的是 index 之后所有的杯子均变干净时间,所以还要考虑剩下的杯子变干净时间。
  • bestTime(drinks, a, b, index + 1, selfCleanA); 继续从下一个杯子开始调用递归函数,此时洗杯子机器空闲的时间正是上一个杯子变干净的时间 selfCleanA
  • 返回当前杯子变干净时间和以后所有杯子变干净时间的最大值,才是所有杯子变干净的最早时间。
2. 选择挥发干净
  • 当一个杯子选择挥发干净(并行)时,不会受到其他因素的影响。因此变干净的时间为 drinks[index] + b
  • 剩下的杯子继续递归调用,此时清洗的机器可用时间不变仍是 free
  • 返回当前杯子变干净时间和以后所有杯子变干净时间的最大值,才是所有杯子变干净的最早时间。

最终返回两种情况中最小的时间,即所有杯子变干净的最早时间。


下面对代码进行优化,改出 动态规划 版本。

思路

首先确定可变参数的范围。

递归函数中,可变的参数有 indexfree 两个。index 的范围很好确定:

    if (index == drinks.length) {
        return 0;
    }

由于 index 的值从 0 可以到 drinks.length ,因此设置 dp 表的长度为 drinks.length + 1

麻烦的是 free ,题目中并不能直观的知道清洗机器空闲的最大时间。这就是今天所讲的新模型 —— 业务限制模型不明确的知道一个参数的变化范围,通过业务的限制找到

假设所有的杯子都用机器洗,则能够达到的时间就是 free 能够达到的最大时间,不可能再比这个时间长了!因此,可以先求出这个最大时间,从而设置 dp 数组的大小。

int N = drinks.length;
int maxFree = 0;
for (int i = 0; i < N; i++) {
    maxFree = Math.max(maxFree, drinks[i]) + a;
}

设置二维 dp 表的大小为 dp[N + 1][maxFree + 1]

代码

public static int bestTimeDp(int[] drinks, int a, int b) {
    int N = drinks.length;
    int maxFree = 0;
    for (int i = 0; i < N; i++) {
        maxFree = Math.max(maxFree, drinks[i]) + a;
    }
    int[][] dp = new int[N + 1][maxFree + 1];
    // dp[N][...] 都等于 0
    for (int index = N - 1; index >= 0; index--) {
        for (int free = 0; free <= maxFree; free++) {
            int selfCleanA = Math.max(drinks[index], free) + a;
            // 若超过了最大边界,之后不可能访问到此下标
            if (selfCleanA > maxFree) {
                break;
            }
            // index 号杯子 选择清洗
            int restCleanA = dp[index + 1][selfCleanA];
            int p1 = Math.max(selfCleanA, restCleanA);
            
            // index 号杯子 选择挥发
            int selfCleanB = drinks[index] + b;
            int restCleanB = dp[index + 1][free];
            int p2 = Math.max(selfCleanB, restCleanB);

            dp[index][free] = Math.min(p1, p2);
        }
    }
    return dp[0][0];
}

理解了参数范围的求解方法,修改出动态规划版本的代码就很容易了。

if (index == drinks.length) {
	return 0;
}

由递归的 base case 可知,dp[N][...] 均为 0 (数组中的值默认为 0 ,无需单独设置了)。index 下标均依赖 index + 1 的值,因此需要倒着遍历数组。

需要注意的是可能越界的情况,这些地方在 dp 表中一定不会被访问到,因此直接跳过。由递归调用含义可知,最终返回 dp[0][0] 即为答案。

总结

本文通过一道面试题目了解了什么是 业务限制模型 。至此,关于如何对递归进行分析的四种基本模型都介绍完了。根据不同模型的套路相信小伙伴也能够轻松应对类似的题目了!再来总结一下:

  1. 从左到右模型arr[index ...]index 之前的不用考虑,只考虑后面的该如何选择
  2. 范围尝试模型 :思考 [L ,R] 两端,即 开头和结尾 处分别该如何取舍。
  3. 样本对应模型 :以 结尾位置 为出发点,思考两个样本的结尾都会产生哪些可能性 。
  4. 业务限制模型 :不能够明确的知道一个参数的变化范围,通过业务的限制找到 最差情况 进行估计。

以上四种模型均能够在往期文章中找到对应的题目哦!

~ 点赞 ~ 关注 ~ 不迷路 ~!!!

------------- 往期回顾 -------------
【算法 - 动态规划】从零开始学动态规划!(总纲)
【算法 - 动态规划】原来写出动态规划如此简单!
【算法 - 动态规划】最长公共子序列问题
【算法 - 动态规划】最长回文子序列
【算法 - 动态规划】力扣 691. 贴纸拼词
【堆 - 专题】“加强堆” 解决 TopK 问题!
AC 此题,链表无敌!!!

  • 43
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值