50.【必备】二分答案法与相关题目

本文的网课内容学习自B站左程云老师的算法详解课程,旨在对其中的知识进行整理和分享~

网课链接:算法讲解051【必备】二分答案法与相关题目_哔哩哔哩_bilibili

一.爱吃香蕉的珂珂

题目:爱吃香蕉的珂珂

算法原理

  • 整体思路
    • 这是一个二分查找算法的应用,目的是找到珂珂吃香蕉的最小速度k,使得她能在h小时内吃完所有香蕉。
  • 确定搜索范围
    • 下限l:珂珂吃香蕉的速度最小为1根/小时,所以l = 1
    • 上限r:通过遍历piles数组,找到香蕉堆中香蕉数量最多的那一堆,即r = Math.max(r, pile)。因为如果珂珂以这个最大堆的香蕉数量为速度,肯定能在h小时内吃完所有香蕉(最保守的估计)。
  • 二分查找过程
    • while (l <= r)循环中进行二分查找。
    • 计算中间速度m:使用m = l+((r - l)>>1)来计算中间速度,这样做可以避免整数溢出(相比于m=(l + r)/2)。
    • 检查速度是否达标
      • 调用f(piles, m)函数来计算以速度m吃香蕉需要花费的小时数。如果f(piles, m)<=h,说明速度m是达标的。
        • 在这种情况下,将当前速度m记录为可能的答案(ans = m),然后将搜索范围缩小到左半部分(r = m - 1),因为要找最小的达标速度。
      • 如果f(piles, m)>h,说明速度m不达标,需要将搜索范围缩小到右半部分(l = m + 1),即增加吃香蕉的速度。
  • 计算以特定速度吃完香蕉的小时数(f函数)
    • 遍历piles数组中的每一堆香蕉。
    • 对于每一堆香蕉pile,计算以速度speed吃完这堆香蕉需要的小时数。使用(pile + speed - 1)/speed来向上取整计算小时数。例如,如果pile = 5speed = 3,则(5 + 3 - 1)/3 = 7/3 = 2(向上取整)。
    • 将每一堆香蕉所需的小时数累加起来,最后返回总小时数ans

代码实现

// 爱吃香蕉的珂珂
// 珂珂喜欢吃香蕉。这里有 n 堆香蕉,第 i 堆中有 piles[i] 根香蕉
// 警卫已经离开了,将在 h 小时后回来。
// 珂珂可以决定她吃香蕉的速度 k (单位:根/小时)
// 每个小时,她将会选择一堆香蕉,从中吃掉 k 根
// 如果这堆香蕉少于 k 根,她将吃掉这堆的所有香蕉,然后这一小时内不会再吃更多的香蕉
// 珂珂喜欢慢慢吃,但仍然想在警卫回来前吃掉所有的香蕉。
// 返回她可以在 h 小时内吃掉所有香蕉的最小速度 k(k 为整数)
// 测试链接 : https://leetcode.cn/problems/koko-eating-bananas/
public class Code01_KokoEatingBananas {

    // 时间复杂度O(n * log(max)),额外空间复杂度O(1)
    public static int minEatingSpeed(int[] piles, int h) {
        // 最小且达标的速度,范围[l,r]
        int l = 1;
        int r = 0;
        for (int pile : piles) {
            r = Math.max(r, pile);
        }
        // [l,r]不停二分
        int ans = 0;
        int m = 0;
        while (l <= r) {
            // m = (l + r) / 2
            m = l + ((r - l) >> 1);
            if (f(piles, m) <= h) {
                // 达标!
                // 记录答案,去左侧二分
                ans = m;
                // l....m....r
                // l..m-1
                r = m - 1;
            } else {
                // 不达标
                l = m + 1;
            }
        }
        return ans;
    }

    // 香蕉重量都在piles
    // 速度就定成speed
    // 返回吃完所有的香蕉,耗费的小时数量
    public static long f(int[] piles, int speed) {
        long ans = 0;
        for (int pile : piles) {
            // (a/b)结果向上取整,如果a和b都是非负数,可以写成(a+b-1)/b
            // "讲解032-位图"讲了这种写法,不会的同学可以去看看
            // 这里不再赘述
            ans += (pile + speed - 1) / speed;
        }
        return ans;
    }

}

二.画匠问题

题目:分割数组的最大值

算法原理

  • 整体思路
    • 这是一个二分查找算法的应用,目的是找到将数组nums分成k个非空连续子数组时,使得子数组各自和的最大值最小的那个值。
  • 确定二分查找的范围
    • 左边界l初始化为0,表示子数组和的最大值最小可能为0(虽然实际情况中每个数都是非负整数,所以最小为数组中的最小值,但这里从0开始方便计算)。
    • 右边界r初始化为数组nums的所有元素之和sum,这是子数组和的最大值的最大可能值(即将整个数组作为一个子数组)。
  • 二分查找过程
    • while (l <= r)循环中进行二分查找。
    • 计算中间值m:使用m = l+((r - l)>>1)计算中间值,这样可以避免整数溢出。
    • 检查中间值是否达标
      • 调用f(nums, m)函数,该函数计算在子数组和的最大值不超过m的情况下,数组nums可以被划分成的子数组数量。
      • 如果need = f(nums, m)<=k,说明将子数组和的最大值限制为m时,划分出的子数组数量不超过k个,这个m是达标的。
        • 在这种情况下,将当前的m记录为可能的答案(ans = m),然后将搜索范围缩小到左半部分(r = m - 1),因为要找最小的达标值。
      • 如果need = f(nums, m)>k,说明将子数组和的最大值限制为m时,划分出的子数组数量超过了k个,这个m不达标,需要将搜索范围缩小到右半部分(l = m + 1),即增加子数组和的最大值的限制。
  • 计算划分数量(f函数)
    • 初始化parts = 1表示至少有一个子数组,sum = 0用于记录当前子数组的累加和。
    • 遍历数组arr中的每个元素num
      • 如果num > limit,说明这个元素本身就大于限制值limit,那么按照要求无法划分,直接返回Integer.MAX_VALUE
      • 如果sum + num > limit,说明当前子数组的累加和加上这个元素会超过限制值limit,则需要新划分一个子数组,所以parts加1,并且将sum更新为num(开始新的子数组累加)。
      • 如果sum + num <= limit,则将这个元素加入当前子数组,sum += num
    • 最后返回划分出的子数组数量parts

代码实现

// 分割数组的最大值(画匠问题)
// 给定一个非负整数数组 nums 和一个整数 m
// 你需要将这个数组分成 m 个非空的连续子数组。
// 设计一个算法使得这 m 个子数组各自和的最大值最小。
// 测试链接 : https://leetcode.cn/problems/split-array-largest-sum/
public class Code02_SplitArrayLargestSum {

    // 时间复杂度O(n * log(sum)),额外空间复杂度O(1)
    public static int splitArray(int[] nums, int k) {
        long sum = 0;
        for (int num : nums) {
            sum += num;
        }
        long ans = 0;
        // [0,sum]二分
        for (long l = 0, r = sum, m, need; l <= r;) {
            // 中点m
            m = l + ((r - l) >> 1);
            // 必须让数组每一部分的累加和 <= m,请问划分成几个部分才够!
            need = f(nums, m);
            if (need <= k) {
                // 达标
                ans = m;
                r = m - 1;
            } else {
                l = m + 1;
            }
        }
        return (int) ans;
    }

    // 必须让数组arr每一部分的累加和 <= limit,请问划分成几个部分才够!
    // 返回需要的部分数量
    public static int f(int[] arr, long limit) {
        int parts = 1;
        int sum = 0;
        for (int num : arr) {
            if (num > limit) {
                return Integer.MAX_VALUE;
            }
            if (sum + num > limit) {
                parts++;
                sum = num;
            } else {
                sum += num;
            }
        }
        return parts;
    }

}

三.机器人跳跃问题

题目:机器人跳跃问题

算法原理

  • 整体思路
    • 这是一个二分查找算法的应用,目的是找到机器人开始游戏时所需的最小能量值,使得机器人能够成功从编号为0的建筑到达编号为N的建筑,并且在过程中能量值不为负数。
  • 确定二分查找的范围
    • 左边界l初始化为0,表示机器人开始游戏时的最小可能能量值。
    • 右边界r初始化为所有建筑高度中的最大值(通过遍历arr数组得到,r = Math.max(r, arr[i])),这是机器人开始游戏时能量值的最大可能取值(如果初始能量大于所有建筑高度,理论上一定能通关)。
  • 二分查找过程
    • while (l <= r)循环中进行二分查找。
    • 计算中间值m:使用m = l+((r - l)>>1)计算中间值,这种方式可以避免整数溢出。
    • 检查中间值是否达标
      • 调用f(m, max)函数,其中m是当前假设的机器人初始能量值,max是所有建筑的最大高度。该函数用于判断以能量值m开始游戏是否能够成功通关。
      • 如果f(m, max)返回true,说明以能量值m开始游戏是可以通关的。
        • 在这种情况下,将当前的m记录为可能的答案(ans = m),然后将搜索范围缩小到左半部分(r = m - 1),因为要找最小的能通关的能量值。
      • 如果f(m, max)返回false,说明以能量值m开始游戏不能通关,需要将搜索范围缩小到右半部分(l = m + 1),即增加初始能量值。
  • 判断能否通关(f函数)
    • 遍历从1到n的每一座建筑(因为机器人从编号为0的建筑开始,这里从1开始遍历下一个建筑)。
    • 对于每一座建筑i,根据规则计算机器人到达下一座建筑后的能量值。
      • 如果energy <= arr[i],机器人失去arr[i] - energy的能量值,即energy -= arr[i] - energy
      • 如果energy > arr[i],机器人得到energy - arr[i]的能量值,即energy += energy - arr[i]
    • 在每次计算完能量值后进行检查:
      • 如果energy >= max,由于能量值已经超过了所有建筑的最大高度,后面肯定能通关,直接返回true
      • 如果energy < 0,说明能量值变为负数,不能通关,直接返回false
    • 如果成功遍历完所有建筑都没有返回false,则返回true,表示可以通关。

代码实现

// 机器人跳跃问题
// 机器人正在玩一个古老的基于DOS的游戏
// 游戏中有N+1座建筑,从0到N编号,从左到右排列
// 编号为0的建筑高度为0个单位,编号为i的建筑的高度为H(i)个单位
// 起初机器人在编号为0的建筑处
// 每一步,它跳到下一个(右边)建筑。假设机器人在第k个建筑,且它现在的能量值是E
// 下一步它将跳到第个k+1建筑
// 它将会得到或者失去正比于与H(k+1)与E之差的能量
// 如果 H(k+1) > E 那么机器人就失去H(k+1)-E的能量值,否则它将得到E-H(k+1)的能量值
// 游戏目标是到达第个N建筑,在这个过程中,能量值不能为负数个单位
// 现在的问题是机器人以多少能量值开始游戏,才可以保证成功完成游戏
// 测试链接 : https://www.nowcoder.com/practice/7037a3d57bbd4336856b8e16a9cafd71
// 请同学们务必参考如下代码中关于输入、输出的处理
// 这是输入输出处理效率很高的写法
// 提交以下的code,提交时请把类名改成"Main",可以直接通过

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.io.StreamTokenizer;

public class Code03_RobotPassThroughBuilding {

    public static int MAXN = 100001;

    public static int[] arr = new int[MAXN];

    public static int n;

    public static void main(String[] args) throws IOException {
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        StreamTokenizer in = new StreamTokenizer(br);
        PrintWriter out = new PrintWriter(new OutputStreamWriter(System.out));
        while (in.nextToken() != StreamTokenizer.TT_EOF) {
            n = (int) in.nval;
            int l = 0;
            int r = 0;
            for (int i = 1; i <= n; i++) {
                in.nextToken();
                arr[i] = (int) in.nval;
                r = Math.max(r, arr[i]);
            }
            out.println(compute(l, r, r));
        }
        out.flush();
        out.close();
        br.close();
    }

    // [l,r]通关所需最小能量的范围,不停二分
    // max是所有建筑的最大高度
    // 时间复杂度O(n * log(max)),额外空间复杂度O(1)
    public static int compute(int l, int r, int max) {
        int m, ans = -1;
        while (l <= r) {
            // m中点,此时通关所需规定的初始能量
            m = l + ((r - l) >> 1);
            if (f(m, max)) {
                ans = m;
                r = m - 1;
            } else {
                l = m + 1;
            }
        }
        return ans;
    }

    // 初始能量为energy,max是建筑的最大高度,返回能不能通关
    // 为什么要给定建筑的最大高度?
    public static boolean f(int energy, int max) {
        // 注意!
        // 如果给的能量值很大,那么后续能量增长将非常恐怖
        // 完全有可能超出long的范围
        // 所以要在遍历时,一定要加入energy >= max的判断
        // 一旦能量超过高度最大值,后面肯定通关了,可以提前返回了
        // 这里很阴
        for (int i = 1; i <= n; i++) {
            if (energy <= arr[i]) {
                energy -= arr[i] - energy;
            } else {
                energy += energy - arr[i];
            }
            if (energy >= max) {
                return true;
            }
            if (energy < 0) {
                return false;
            }
        }
        return true;
    }

}

 四.找到第K小的数对距离

题目:找出第 K 小的数对距离

算法原理

  • 整体思路
    • 该算法的目的是在给定的整数数组nums中找到第k小的数对距离。通过二分查找的思想来实现。
  • 排序数组
    • 首先对nums数组进行排序,这是为了方便后续计算数对距离。排序后的数组在计算数对距离时可以利用有序性提高效率,时间复杂度为O(n/log n)。
  • 确定二分查找范围
    • 左边界l初始化为0,表示数对距离的最小值。
    • 右边界r初始化为nums[n - 1] - nums[0],即数组中最大数与最小数的差值,这是数对距离的最大可能值。
  • 二分查找过程
    • while (l <= r)循环中进行二分查找。
    • 计算中间值m:使用m = l+((r - l)>>1)计算中间值,可避免整数溢出。
    • 检查中间值是否符合要求
      • 调用f(nums, m)函数,该函数计算在数对距离不超过m的情况下,数对的数量。
      • 如果cnt = f(nums, m)>=k,说明数对距离不超过m的数对数量不少于k个,这个m可能是第k小的数对距离。
        • 此时将当前的m记录为可能的答案(ans = m),并将搜索范围缩小到左半部分(r = m - 1),因为要找最小的符合要求的值。
      • 如果cnt = f(nums, m)<k,说明数对距离不超过m的数对数量少于k个,这个m不是第k小的数对距离,需要将搜索范围缩小到右半部分(l = m + 1),即增加数对距离的下限。
  • 计算数对数量(f函数)
    • 初始化ans = 0用于记录数对数量。
    • 使用双指针lr遍历数组arr
      • 对于每个l,通过移动r使得arr[r + 1] - arr[l] <= limit
      • 当满足这个条件时,在arr[l...r]范围内的数与arr[l]组成的数对距离都不超过limit,这样的数对数量为r - l,将其累加到ans中。
    • 最后返回ans,即数对距离不超过limit的数对数量。

代码实现

import java.util.Arrays;

// 找出第K小的数对距离
// 数对 (a,b) 由整数 a 和 b 组成,其数对距离定义为 a 和 b 的绝对差值。
// 给你一个整数数组 nums 和一个整数 k
// 数对由 nums[i] 和 nums[j] 组成且满足 0 <= i < j < nums.length
// 返回 所有数对距离中 第 k 小的数对距离。
// 测试链接 : https://leetcode.cn/problems/find-k-th-smallest-pair-distance/
public class Code04_FindKthSmallestPairDistance {

    // 时间复杂度O(n * log(n) + log(max-min) * n),额外空间复杂度O(1)
    public static int smallestDistancePair(int[] nums, int k) {
        int n = nums.length;
        Arrays.sort(nums);
        int ans = 0;
        // [0, 最大-最小],不停二分
        for (int l = 0, r = nums[n - 1] - nums[0], m, cnt; l <= r;) {
            // m中点,arr中任意两数的差值 <= m
            m = l + ((r - l) >> 1);
            // 返回数字对的数量
            cnt = f(nums, m);
            if (cnt >= k) {
                ans = m;
                r = m - 1;
            } else {
                l = m + 1;
            }
        }
        return ans;
    }

    // arr中任意两数的差值 <= limit
    // 这样的数字配对,有几对?
    public static int f(int[] arr, int limit) {
        int ans = 0;
        // O(n)
        for (int l = 0, r = 0; l < arr.length; l++) {
            // l......r r+1
            while (r + 1 < arr.length && arr[r + 1] - arr[l] <= limit) {
                r++;
            }
            // arr[l...r]范围上的数差值的绝对值都不超过limit
            // arr[0...3]
            // 0,1
            // 0,2
            // 0,3
            ans += r - l;
        }
        return ans;
    }

}

五.同时运行N台电脑的最长时间

题目:同时运行 N 台电脑的最长时间

算法原理

一.maxRunTime1(单纯的二分答案法)
  • 整体思路
    • 这是一个二分查找算法的应用,目的是找到能让num台电脑同时运行的最长时间。
  • 计算电量总和(maxRunTime1函数部分)
    • 首先计算所有电池电量的总和sum,通过遍历数组arr,将每个电池的电量x累加到sum中。这个总和是电脑能运行时间的一个上限值,因为假设所有电量都给一台电脑用,这就是最长运行时间的理论最大值。
  • 二分查找过程(maxRunTime1函数部分)
    • 确定二分查找的范围:左边界l = 0,表示电脑运行时间的最小值;右边界r = sum,即前面计算出的所有电池电量总和,是运行时间的最大可能值。
    • while (l <= r)循环中进行二分查找。
    • 计算中间值m:使用m = l+((r - l)>>1)计算中间值,这种方式可以避免整数溢出。
    • 检查中间值是否可行:调用f1(arr, num, m)函数,该函数判断让num台电脑共同运行m分钟是否可行。
      • 如果f1(arr, num, m)返回true,说明m分钟是可行的运行时间。
        • 在这种情况下,将当前的m记录为可能的答案(ans = m),然后将搜索范围扩大到右半部分(l = m + 1),因为要找最大的可行运行时间。
      • 如果f1(arr, num, m)返回false,说明m分钟不可行,需要将搜索范围缩小到左半部分(r = m - 1)。
  • 判断可行性(f1函数部分)
    • 初始化碎片电量总和sum = 0
    • 遍历电池电量数组arr中的每个电池电量x
      • 如果x > time,说明这个电池电量足够让一台电脑单独运行time分钟。此时可以用这个电池给一台电脑供电,所以num(表示还需要供电的电脑台数)减1。
      • 如果x <= time,这个电池属于碎片电池,将其电量x累加到sum中。
    • 在每次迭代过程中,检查sum >= (long) num * time是否成立。
      • 如果成立,说明碎片电量足够给剩余的num台电脑供电time分钟,所以返回true
      • 如果不成立,说明电量不足,返回false
二.maxRunTime2(二分答案法 + 增加分析(贪心))
  • 整体思路
    • 这个算法基于二分答案法,并结合贪心思想来求解能让num台电脑同时运行的最长时间。
  • 前期计算与初步判断(maxRunTime2函数部分)
    • 首先遍历电池电量数组arr,计算出电池电量的最大值max和所有电池电量的总和sum
    • 然后进行一个初步判断:如果sum > (long) max * num,根据贪心思想,这意味着最终的供电时间一定在>= max的范围内。因为所有电池电量总和足够大,当供电时间达到最大电池电量max时,所有电池都可看作是“碎片拼接”的情况。在这种情况下,直接计算sum / num作为最终的供电时间并返回。
  • 二分查找过程(maxRunTime2函数部分,当sum <= (long) max * num时)
    • 确定更精细的二分查找范围:左边界l = 0,右边界r = max。相比于maxRunTime1[0, sum]的范围,这个范围更精细,能减少二分查找的次数。
    • while (l <= r)循环中进行二分查找。
    • 计算中间值m:使用m = l+((r - l)>>1)计算中间值,避免整数溢出。
    • 检查中间值是否可行:调用f2(arr, num, m)函数,该函数判断让num台电脑共同运行m分钟是否可行。
      • 如果f2(arr, num, m)返回true,说明m分钟是可行的运行时间。
        • 将当前的m记录为可能的答案(ans = m),然后将搜索范围扩大到右半部分(l = m + 1),因为要找最大的可行运行时间。
      • 如果f2(arr, num, m)返回false,说明m分钟不可行,需要将搜索范围缩小到左半部分(r = m - 1)。
  • 判断可行性(f2函数部分)
    • 初始化碎片电量总和sum = 0
    • 遍历电池电量数组arr中的每个电池电量x
      • 如果x > time,说明这个电池电量足够让一台电脑单独运行time分钟,此时可以用这个电池给一台电脑供电,所以num(表示还需要供电的电脑台数)减1。
      • 如果x <= time,这个电池属于碎片电池,将其电量x累加到sum中。
    • 在每次迭代过程中,检查sum >= (long) num * time是否成立。
      • 如果成立,说明碎片电量足够给剩余的num台电脑供电time分钟,所以返回true
      • 如果不成立,说明电量不足,返回false

代码实现

// 同时运行N台电脑的最长时间
// 你有 n 台电脑。给你整数 n 和一个下标从 0 开始的整数数组 batteries
// 其中第 i 个电池可以让一台电脑 运行 batteries[i] 分钟
// 你想使用这些电池让 全部 n 台电脑 同时 运行。
// 一开始,你可以给每台电脑连接 至多一个电池
// 然后在任意整数时刻,你都可以将一台电脑与它的电池断开连接,并连接另一个电池,你可以进行这个操作 任意次
// 新连接的电池可以是一个全新的电池,也可以是别的电脑用过的电池
// 断开连接和连接新的电池不会花费任何时间。
// 注意,你不能给电池充电。
// 请你返回你可以让 n 台电脑同时运行的 最长 分钟数。
// 测试链接 : https://leetcode.cn/problems/maximum-running-time-of-n-computers/
public class Code05_MaximumRunningTimeOfNComputers {

    // 单纯的二分答案法
    // 提交时把函数名改为maxRunTime
    // 时间复杂度O(n * log(sum)),额外空间复杂度O(1)
    public static long maxRunTime1(int num, int[] arr) {
        long sum = 0;
        for (int x : arr) {
            sum += x;
        }
        long ans = 0;
        // [0, sum],不停二分
        for (long l = 0, r = sum, m; l <= r;) {
            // m中点,让num台电脑共同运行m分钟,能不能做到
            m = l + ((r - l) >> 1);
            if (f1(arr, num, m)) {
                ans = m;
                l = m + 1;
            } else {
                r = m - 1;
            }
        }
        return ans;
    }

    // 让num台电脑共同运行time分钟,能不能做到
    public static boolean f1(int[] arr, int num, long time) {
        // 碎片电量总和
        long sum = 0;
        for (int x : arr) {
            if (x > time) {
                num--;
            } else {
                // x <= time,是碎片电池
                sum += x;
            }
            if (sum >= (long) num * time) {
                // 碎片电量 >= 台数 * 要求
                return true;
            }
        }
        return false;
    }

    // 二分答案法 + 增加分析(贪心)
    // 提交时把函数名改为maxRunTime
    // 时间复杂度O(n * log(max)),额外空间复杂度O(1)
    public static long maxRunTime2(int num, int[] arr) {
        int max = 0;
        long sum = 0;
        for (int x : arr) {
            max = Math.max(max, x);
            sum += x;
        }
        // 就是增加了这里的逻辑
        if (sum > (long) max * num) {
            // 所有电池的最大电量是max
            // 如果此时sum > (long) max * num,
            // 说明 : 最终的供电时间一定在 >= max,而如果最终的供电时间 >= max
            // 说明 : 对于最终的答案X来说,所有电池都是课上讲的"碎片拼接"的概念
            // 那么寻找 ? * num <= sum 的情况中,尽量大的 ? 即可
            // 即sum / num
            return sum / num;
        }
        // 最终的供电时间一定在 < max范围上
        // [0, sum]二分范围,可能定的比较粗,虽然不影响,但毕竟是有点慢
        // [0, max]二分范围!更精细的范围,二分次数会变少
        int ans = 0;
        for (int l = 0, r = max, m; l <= r;) {
            m = l + ((r - l) >> 1);
            if (f2(arr, num, m)) {
                ans = m;
                l = m + 1;
            } else {
                r = m - 1;
            }
        }
        return ans;
    }

    public static boolean f2(int[] arr, int num, int time) {
        // 碎片电量总和
        long sum = 0;
        for (int x : arr) {
            if (x > time) {
                num--;
            } else {
                sum += x;
            }
            if (sum >= (long) num * time) {
                return true;
            }
        }
        return false;
    }

}

六.计算等位时间

题目:计算等位时间(谷歌面试题,没找到测试链接,所以使用了对数器来验证)

给定一个数组arr长度为n,表示n个服务员,每服务一个人的时间。
给定一个正数m,表示有m个人等位,如果你是刚来的人,请问你需要等多久?
假设m远远大于n,比如n <= 10^3, m <= 10^9,该怎么做是最优解?

算法原理

  • 整体思路
    • 该算法旨在解决在给定服务员服务时间数组arr和等位人数m的情况下,计算新来的人需要等待多久的问题。提供了两种方法,waitingTime1使用堆模拟,waitingTime2使用二分答案法(最优解)。
  • waitingTime1(堆模拟)算法原理
    • 构建小根堆
      • 创建一个PriorityQueue(小根堆),堆中元素是int[]类型,每个元素表示一个服务员的状态,包含[醒来时间,服务一个客人要多久]
      • 遍历服务员服务时间数组arr,将每个服务员的初始状态[0, arr[i]]加入堆中。
    • 模拟服务过程
      • 对于等位的m个人,每次从堆中取出一个服务员(堆顶元素),这个服务员的醒来时间最短。
      • 该服务员为一个客人服务后,更新其醒来时间(cur[0] += cur[1]),然后再将这个服务员放回堆中。
    • 获取结果
      • 重复上述步骤m次后,堆顶元素的醒来时间就是新来的人需要等待的时间,通过heap.peek()[0]获取。
    • 时间复杂度和空间复杂度
      • 时间复杂度O(mlog n),其中m是等位人数,n是服务员数量。每次从堆中取出或放入元素都需要O(log n)的时间,总共进行m次操作。
      • 空间复杂度为O(n),因为堆中最多存储n个服务员的状态。
  • waitingTime2(二分答案法 - 最优解)算法原理
    • 确定二分查找范围
      • 首先找到服务员服务时间数组arr中的最小值min
      • 二分查找的左边界l = 0,右边界r = min * w,这里的w是等位人数m(代码中变量名使用w可能是一种混淆,实际意义是等位人数m)。
    • 二分查找过程
      • while (l <= r)循环中进行二分查找。
      • 计算中间值m:使用m = l+((r - l)>>1)计算中间值,避免整数溢出。
      • 检查中间值是否满足条件:调用f(arr, m)函数,该函数计算如果每个服务员工作m时间,可以接待的客人数量。如果接待的客人数量f(arr, m) >= w + 1(要接待等位的m个人以及正在服务的1个人),说明这个m时间可能是等待时间。
        • 将当前的m记录为可能的答案(ans = m),然后将搜索范围缩小到左半部分(r = m - 1),因为要找最小的满足条件的时间。
        • 如果f(arr, m) < w + 1,说明m时间不够,将搜索范围扩大到右半部分(l = m + 1)。
    • 计算接待客人数量(f函数)
      • 遍历服务员服务时间数组arr中的每个元素num
      • 计算每个服务员在time时间内可以服务的客人数量(time / num),再加上1(表示当前正在服务的客人),将结果累加到ans中。
    • 时间复杂度和空间复杂度
      • 时间复杂度为O(nlog(min * w)),其中n是服务员数量,min是服务员服务时间的最小值,w是等位人数(m)。每次计算f(arr, m)需要O(n)的时间,二分查找最多需要O(log(min * w))次。
      • 空间复杂度为O(1),因为只使用了常数级别的额外空间。

代码实现

import java.util.PriorityQueue;

// 计算等位时间
// 给定一个数组arr长度为n,表示n个服务员,每服务一个人的时间
// 给定一个正数m,表示有m个人等位,如果你是刚来的人,请问你需要等多久?
// 假设m远远大于n,比如n <= 10^3, m <= 10^9,该怎么做是最优解?
// 谷歌的面试,这个题连考了2个月
// 找不到测试链接,所以用对数器验证
public class Code06_WaitingTime {

    // 堆模拟
    // 验证方法,不是重点
    // 如果m很大,该方法会超时
    // 时间复杂度O(m * log(n)),额外空间复杂度O(n)
    public static int waitingTime1(int[] arr, int m) {
        // 一个一个对象int[]
        // [醒来时间,服务一个客人要多久]
        PriorityQueue<int[]> heap = new PriorityQueue<>((a, b) -> (a[0] - b[0]));
        int n = arr.length;
        for (int i = 0; i < n; i++) {
            heap.add(new int[] { 0, arr[i] });
        }
        for (int i = 0; i < m; i++) {
            int[] cur = heap.poll();
            cur[0] += cur[1];
            heap.add(cur);
        }
        return heap.peek()[0];
    }

    // 二分答案法
    // 最优解
    // 时间复杂度O(n * log(min * w)),额外空间复杂度O(1)
    public static int waitingTime2(int[] arr, int w) {
        int min = Integer.MAX_VALUE;
        for (int x : arr) {
            min = Math.min(min, x);
        }
        int ans = 0;
        for (int l = 0, r = min * w, m; l <= r;) {
            // m中点,表示一定要让服务员工作的时间!
            m = l + ((r - l) >> 1);
            // 能够给几个客人提供服务
            if (f(arr, m) >= w + 1) {
                ans = m;
                r = m - 1;
            } else {
                l = m + 1;
            }
        }
        return ans;
    }

    // 如果每个服务员工作time,可以接待几位客人(结束的、开始的客人都算)
    public static int f(int[] arr, int time) {
        int ans = 0;
        for (int num : arr) {
            ans += (time / num) + 1;
        }
        return ans;
    }

    // 对数器测试
    public static void main(String[] args) {
        System.out.println("测试开始");
        int N = 50;
        int V = 30;
        int M = 3000;
        int testTime = 20000;
        for (int i = 0; i < testTime; i++) {
            int n = (int) (Math.random() * N) + 1;
            int[] arr = randomArray(n, V);
            int m = (int) (Math.random() * M);
            int ans1 = waitingTime1(arr, m);
            int ans2 = waitingTime2(arr, m);
            if (ans1 != ans2) {
                System.out.println("出错了!");
            }
        }
        System.out.println("测试结束");
    }

    // 对数器测试
    public static int[] randomArray(int n, int v) {
        int[] arr = new int[n];
        for (int i = 0; i < n; i++) {
            arr[i] = (int) (Math.random() * v) + 1;
        }
        return arr;
    }

}

 七.刀砍毒杀怪兽问题

题目:刀砍毒杀怪兽问题(大厂算法笔试题,没有找到测试连接,所以使用对数器来验证)

怪兽的初始血量是一个整数hp,给出每一回合刀砍和毒杀的数值cuts和poisons
第i回合如果用刀砍,怪兽在这回合会直接损失cuts[i]的血,不再有后续效果
第i回合如果用毒杀,怪兽在这回合不会损失血量,但是之后每回合都损失poisons[i]的血量
并且你选择的所有毒杀效果,在之后的回合都会叠加
两个数组cuts、poisons,长度都是n,代表你一共可以进行n回合
每一回合你只能选择刀砍或者毒杀中的一个动作
如果你在n个回合内没有直接杀死怪兽,意味着你已经无法有新的行动了
但是怪兽如果有中毒效果的话,那么怪兽依然会在血量耗尽的那回合死掉
返回至少多少回合,怪兽会死掉
数据范围 :
1 <= n <= 10^5
1 <= hp <= 10^9
1 <= cuts[i]、poisons[i] <= 10^9

算法原理

  • 整体思路
    • 这个算法主要解决在给定刀砍和毒杀效果数组cutspoisons以及怪兽初始血量hp的情况下,求出至少多少回合怪兽会死掉的问题。提供了两种方法,fast1(基于动态规划,主要用于验证)和fast2(二分答案法,最优解)。
  • fast1(动态规划方法 - 用于验证)算法原理
    • 动态规划状态定义
      • 定义三维数组dpdp[i][r][p]表示在第i个回合,怪兽剩余血量为r,当前中毒效果累计为p时,最少还需要多少回合能杀死怪兽。
    • 递归计算(f1函数)
      • 首先对剩余血量r减去当前中毒效果pr -= p),如果r <= 0,表示怪兽已经死亡,返回已经进行的回合数i + 1
      • 如果已经到了最后一个回合(i == cuts.length),如果p == 0(没有中毒效果),返回Integer.MAX_VALUE表示无法杀死怪兽;如果p!=0,计算还需要多少回合(cuts.length + 1+(r + p - 1)/p)。
      • 如果dp[i][r][p]已经有计算结果,直接返回这个结果。
      • 计算两种选择(刀砍或毒杀)下的最少回合数:
        • 对于刀砍(p1),如果r <= cuts[i],则只需要当前回合就可以杀死怪兽(i + 1),否则递归调用f1函数计算下一个回合的情况(f1(cuts, poisons, i + 1, r - cuts[i], p, dp))。
        • 对于毒杀(p2),递归调用f1函数计算下一个回合的情况(f1(cuts, poisons, i + 1, r, p + poisons[i], dp))。
      • 取刀砍和毒杀两种选择中的最小值作为dp[i][r][p]的值,并返回这个最小值。
    • 时间复杂度和空间复杂度
      • 时间复杂度:由于有三个维度的状态,每个维度的大小分别为cuts.lengthhp + 1sum + 1sumpoisons数组元素之和),所以时间复杂度非常高,在血量较大时会超时。
      • 空间复杂度:需要创建三维数组dp,空间复杂度为O(cuts.length\times(hp + 1)\times(sum + 1))。
  • fast2(二分答案法 - 最优解)算法原理
    • 二分查找范围
      • 左边界l = 1,因为至少需要1回合;右边界r = hp + 1,因为最多需要hp回合(每回合刀砍1点血的极端情况)再加1(保证右边界能被包含在查找范围内)。
    • 二分查找过程
      • while (l <= r)循环中进行二分查找。
      • 计算中间值m:使用m = l+((r - l)>>1)计算中间值,避免整数溢出。
      • 检查中间值是否可行:调用f(cuts, posions, hp, m)函数,该函数判断在m回合内是否能杀死怪兽。
        • 如果f(cuts, posions, hp, m)返回true,说明m回合可以杀死怪兽。将当前的m记录为可能的答案(ans = m),然后将搜索范围缩小到左半部分(r = m - 1),因为要找最小的满足条件的回合数。
        • 如果f(cuts, posions, hp, m)返回false,说明m回合不能杀死怪兽,将搜索范围扩大到右半部分(l = m + 1)。
    • 判断是否能在限制回合内杀死怪兽(f函数)
      • 确定实际参与计算的回合数n,取cuts.lengthlimit(这里的limit就是二分查找中的m)中的较小值。
      • 遍历前n个回合,对于每个回合i
        • 计算本回合对怪兽血量的削减量,取刀砍的削减量cuts[i]和毒杀的削减量(limit - j)*poisons[i]j从1开始计数,表示回合数的偏移量)中的最大值,然后从怪兽血量hp中减去这个削减量(hp -= Math.max((long) cuts[i], (long) (limit - j)*(long) poisons[i]);)。
        • 如果hp <= 0,说明怪兽已经死亡,返回true
      • 如果遍历完所有回合后hp > 0,说明怪兽未死亡,返回false
    • 时间复杂度和空间复杂度
      • 时间复杂度:每次计算f(cuts, posions, hp, m)需要O(n)的时间(ncutspoisons数组的长度),二分查找最多需要O(log(hp))次,所以总的时间复杂度为O(nlog(hp))。
      • 空间复杂度:只使用了常数级别的额外空间,所以空间复杂度为O(1)。

代码实现

// 刀砍毒杀怪兽问题
// 怪兽的初始血量是一个整数hp,给出每一回合刀砍和毒杀的数值cuts和poisons
// 第i回合如果用刀砍,怪兽在这回合会直接损失cuts[i]的血,不再有后续效果
// 第i回合如果用毒杀,怪兽在这回合不会损失血量,但是之后每回合都损失poisons[i]的血量
// 并且你选择的所有毒杀效果,在之后的回合都会叠加
// 两个数组cuts、poisons,长度都是n,代表你一共可以进行n回合
// 每一回合你只能选择刀砍或者毒杀中的一个动作
// 如果你在n个回合内没有直接杀死怪兽,意味着你已经无法有新的行动了
// 但是怪兽如果有中毒效果的话,那么怪兽依然会在血量耗尽的那回合死掉
// 返回至少多少回合,怪兽会死掉
// 数据范围 :
// 1 <= n <= 10^5
// 1 <= hp <= 10^9
// 1 <= cuts[i]、poisons[i] <= 10^9
// 本题来自真实大厂笔试,找不到测试链接,所以用对数器验证
public class Code07_CutOrPoison {

    // 动态规划方法(只是为了验证)
    // 目前没有讲动态规划,所以不需要理解这个函数
    // 这个函数只是为了验证二分答案的方法是否正确的
    // 纯粹为了写对数器验证才设计的方法,血量比较大的时候会超时
    // 这个方法不做要求,此时并不需要理解,可以在学习完动态规划章节之后来看看这个函数
    public static int fast1(int[] cuts, int[] poisons, int hp) {
        int sum = 0;
        for (int num : poisons) {
            sum += num;
        }
        int[][][] dp = new int[cuts.length][hp + 1][sum + 1];
        return f1(cuts, poisons, 0, hp, 0, dp);
    }

    // 不做要求
    public static int f1(int[] cuts, int[] poisons, int i, int r, int p, int[][][] dp) {
        r -= p;
        if (r <= 0) {
            return i + 1;
        }
        if (i == cuts.length) {
            if (p == 0) {
                return Integer.MAX_VALUE;
            } else {
                return cuts.length + 1 + (r + p - 1) / p;
            }
        }
        if (dp[i][r][p] != 0) {
            return dp[i][r][p];
        }
        int p1 = r <= cuts[i] ? (i + 1) : f1(cuts, poisons, i + 1, r - cuts[i], p, dp);
        int p2 = f1(cuts, poisons, i + 1, r, p + poisons[i], dp);
        int ans = Math.min(p1, p2);
        dp[i][r][p] = ans;
        return ans;
    }

    // 二分答案法
    // 最优解
    // 时间复杂度O(n * log(hp)),额外空间复杂度O(1)
    public static int fast2(int[] cuts, int[] poisons, int hp) {
        int ans = Integer.MAX_VALUE;
        for (int l = 1, r = hp + 1, m; l <= r;) {
            // m中点,一定要让怪兽在m回合内死掉,更多回合无意义
            m = l + ((r - l) >> 1);
            if (f(cuts, poisons, hp, m)) {
                ans = m;
                r = m - 1;
            } else {
                l = m + 1;
            }
        }
        return ans;
    }

    // cuts、posions,每一回合刀砍、毒杀的效果
    // hp:怪兽血量
    // limit:回合的限制
    public static boolean f(int[] cuts, int[] posions, long hp, int limit) {
        int n = Math.min(cuts.length, limit);
        for (int i = 0, j = 1; i < n; i++, j++) {
            hp -= Math.max((long) cuts[i], (long) (limit - j) * (long) posions[i]);
            if (hp <= 0) {
                return true;
            }
        }
        return false;
    }

    // 对数器测试
    public static void main(String[] args) {
        // 随机测试的数据量不大
        // 因为数据量大了,fast1方法会超时
        // 所以在数据量不大的情况下,验证fast2方法功能正确即可
        // fast2方法在大数据量的情况下一定也能通过
        // 因为时间复杂度就是最优的
        System.out.println("测试开始");
        int N = 30;
        int V = 20;
        int H = 300;
        int testTimes = 10000;
        for (int i = 0; i < testTimes; i++) {
            int n = (int) (Math.random() * N) + 1;
            int[] cuts = randomArray(n, V);
            int[] posions = randomArray(n, V);
            int hp = (int) (Math.random() * H) + 1;
            int ans1 = fast1(cuts, posions, hp);
            int ans2 = fast2(cuts, posions, hp);
            if (ans1 != ans2) {
                System.out.println("出错了!");
            }
        }
        System.out.println("测试结束");
    }

    // 对数器测试
    public static int[] randomArray(int n, int v) {
        int[] ans = new int[n];
        for (int i = 0; i < n; i++) {
            ans[i] = (int) (Math.random() * v) + 1;
        }
        return ans;
    }

}

八.总结

二分答案法:

1)估计最终答案可能的范围是什么

2)分析问题的答案给定条件之间的单调性,大部分时候只需要用到自然智慧

3)建立一个f函数,当答案固定的情况下,判断给定的条件是否达标

4)在最终答案可能的范围上不断二分搜索,每次用f函数判断,直到二分结束,找到最合适的答案

核心点:分析单调性、建立f函数

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值