【算法思想·数组】二分搜索实战应用

本文参考labuladong算法小抄[二分搜索运用技巧]

1、原始的二分搜索代码

二分搜索的原型就是在「有序数组」中搜索一个元素target,返回该元素对应的索引。

如果该元素不存在,那可以返回一个什么特殊值,这种细节问题只要微调算法实现就可实现。

还有一个重要的问题,如果「有序数组」中存在多个target元素,那么这些元素肯定挨在一起,这里就涉及到算法应该返回最左侧的那个target元素的索引还是最右侧的那个target元素的索引,也就是所谓的「搜索左侧边界」和「搜索右侧边界」,这个也可以通过微调算法的代码来实现。

在具体的算法问题中,常用到的是「搜索左侧边界」和「搜索右侧边界」这两种场景,很少有让你单独「搜索一个元素」。

因为算法题一般都让你求最值,比如让你求吃香蕉的「最小速度」,让你求轮船的「最低运载能力」,求最值的过程,必然是搜索一个边界的过程,所以后面我们就详细分析一下这两种搜索边界的二分算法代码。

求最值的过程,必然是搜索一个边界的过程,所以后面我们就详细分析一下这两种搜索边界的二分算法代码。

「搜索左侧边界」的二分搜索算法的具体代码实现如下:

// 搜索左侧边界
int left_bound(int[] nums, int target) {
    if (nums.length == 0) return -1;
    int left = 0, right = nums.length;

    while (left < right) {
        int mid = left + (right - left) / 2;
        if (nums[mid] == target) {
            // 当找到 target 时,收缩右侧边界
            right = mid;
        } else if (nums[mid] < target) {
            left = mid + 1;
        } else if (nums[mid] > target) {
            right = mid;
        }
    }
    return left;
}

假设输入的数组nums = [1,2,3,3,3,5,7],想搜索的元素target = 3,那么算法就会返回索引 2。

如果画一个图,就是这样:

图片

「搜索右侧边界」的二分搜索算法的具体代码实现如下:

// 搜索右侧边界
int right_bound(int[] nums, int target) {
    if (nums.length == 0) return -1;
    int left = 0, right = nums.length;

    while (left < right) {
        int mid = left + (right - left) / 2;
        if (nums[mid] == target) {
            // 当找到 target 时,收缩左侧边界
            left = mid + 1;
        } else if (nums[mid] < target) {
            left = mid + 1;
        } else if (nums[mid] > target) {
            right = mid;
        }
    }
    return left - 1;
}

输入同上,那么算法就会返回索引 4,如果画一个图,就是这样:

图片

2、二分搜索问题的泛化

什么问题可以运用二分搜索算法技巧?

首先,你要从题目中抽象出一个自变量x,一个关于x的函数f(x),以及一个目标值target

同时,x, f(x), target还要满足以下条件:

1、f(x)必须是在x上的单调函数(单调增单调减都可以)

2、题目是让你计算满足约束条件f(x) == target时的x的值

上述规则听起来有点抽象,来举个具体的例子:

给你一个升序排列的有序数组nums以及一个目标元素target,请你计算target在数组中的索引位置,如果有多个目标元素,返回最小的索引。

这就是「搜索左侧边界」这个基本题型,解法代码之前都写了,但这里面x, f(x), target分别是什么呢?

我们可以把数组中元素的索引认为是自变量x,函数关系f(x)就可以这样设定:

// 函数 f(x) 是关于自变量 x 的单调递增函数
// 入参 nums 是不会改变的,所以可以忽略,不算自变量
int f(int x, int[] nums) {
    return nums[x];
}

其实这个函数f就是在访问数组nums,因为题目给我们的数组nums是升序排列的,所以函数f(x)就是在x上单调递增的函数。

最后,题目让我们求什么来着?是不是让我们计算元素target的最左侧索引?

是不是就相当于在问我们「满足f(x) == targetx的最小值是多少」?

画个图,如下:

图片

如果遇到一个算法问题,能够把它抽象成这幅图,就可以对它运用二分搜索算法

算法代码如下:

// 函数 f 是关于自变量 x 的单调递增函数
int f(int x, int[] nums) {
    return nums[x];
}

int left_bound(int[] nums, int target) {
    if (nums.length == 0) return -1;
    int left = 0, right = nums.length;

    while (left < right) {
        int mid = left + (right - left) / 2;
        if (f(mid, nums) == target) {
            // 当找到 target 时,收缩右侧边界
            right = mid;
        } else if (f(mid, nums) < target) {
            left = mid + 1;
        } else if (f(mid, nums) > target) {
            right = mid;
        }
    }
    return left;
}

这段代码把之前的代码微调了一下,把直接访问nums[mid]套了一层函数f,其实就是多此一举,但是,这样能抽象出二分搜索思想在具体算法问题中的框架。

3、运用二分搜索的套路框架

二分搜索实战思考框架

// 函数 f 是关于自变量 x 的单调函数
int f(int x) {
    // ...
}

// 主函数,在 f(x) == target 的约束下求 x 的最值
int solution(int[] nums, int target) {
    if (nums.length == 0) return -1;
    // 问自己:自变量 x 的最小值是多少?
    int left = ...;
    // 问自己:自变量 x 的最大值是多少?
    int right = ... + 1;

    while (left < right) {
        int mid = left + (right - left) / 2;
        if (f(mid) == target) {
            // 问自己:题目是求左边界还是右边界?
            // ...
        } else if (f(mid) < target) {
            // 问自己:怎么让 f(x) 大一点?
            // ...
        } else if (f(mid) > target) {
            // 问自己:怎么让 f(x) 小一点?
            // ...
        }
    }
    return left;
}

具体来说,想要用二分搜索算法解决问题,分为以下几步:

1、确定x, f(x), target分别是什么,并写出函数f的代码

2、找到x的取值范围作为二分搜索的搜索区间,初始化leftright变量

3、根据题目的要求,确定应该使用搜索左侧还是搜索右侧的二分搜索算法,写出解法代码

4、实战

875. 爱吃香蕉的珂珂

【算法思路】

按步骤思考即可:

1、确定x, f(x), target分别是什么,并写出函数f的代码

自变量x是什么呢?回忆之前的函数图像,二分搜索的本质就是在搜索自变量。

所以,题目让求什么,就把什么设为自变量,珂珂吃香蕉的速度就是自变量x

那么,在x上单调的函数关系f(x)是什么?

显然,吃香蕉的速度越快,吃完所有香蕉堆所需的时间就越少,速度和时间就是一个单调函数关系。

所以,f(x)函数就可以这样定义:

若吃香蕉的速度为x根/小时,则需要f(x)小时吃完所有香蕉。

代码实现如下:

// 定义:速度为 x 时,需要 f(x) 小时吃完所有香蕉
// f(x) 随着 x 的增加单调递减
int f(int[] piles, int x) {
    int hours = 0;
    for (int i = 0; i < piles.length; i++) {
        hours += piles[i] / x;
        if (piles[i] % x > 0) {
            hours++;
        }
    }
    return hours;
}

target就很明显了,吃香蕉的时间限制H自然就是target,是对f(x)返回值的最大约束。

2、找到x的取值范围作为二分搜索的搜索区间,初始化leftright变量

珂珂吃香蕉的速度最小是多少?多大是多少?

显然,最小速度应该是 1,最大速度是piles数组中元素的最大值,因为每小时最多吃一堆香蕉,胃口再大也白搭嘛。

这里可以有两种选择,要么你用一个 for 循环去遍历piles数组,计算最大值,要么你看题目给的约束,piles中的元素取值范围是多少,然后给right初始化一个取值范围之外的值。

我选择第二种,题目说了1 <= piles[i] <= 10^9,那么我就可以确定二分搜索的区间边界:

public int minEatingSpeed(int[] piles, int H) {
    int left = 1;
    // 注意,right 是开区间,所以再加一
    int right = 1000000000 + 1;

    // ...
}

3、根据题目的要求,确定应该使用搜索左侧还是搜索右侧的二分搜索算法,写出解法代码

现在我们确定了自变量x是吃香蕉的速度,f(x)是单调递减的函数,target就是吃香蕉的时间限制H,题目要我们计算最小速度,也就是x要尽可能小:

这就是搜索左侧边界的二分搜索嘛,不过注意f(x)是单调递减的,不要闭眼睛套框架,需要结合上图进行思考,写出代码:

【Java】

public int minEatingSpeed(int[] piles, int H) {
    int left = 1;
    int right = 1000000000 + 1;

    while (left < right) {
        int mid = left + (right - left) / 2;
        if (f(piles, mid) <= H) {
            right = mid;
        } else {
            left = mid + 1;
        }
    }
    return left;
}

// f(x) 随着 x 的增加单调递减
int f(int[] piles, int x) {
    // 见上文
}

【Python】

class Solution:
    def minEatingSpeed(self, piles: List[int], h: int) -> int:
        '''
        数据结构:现有数组
        思路:
            1、二分搜索递减函数的左侧边界
            2、f(x)即消耗时间,x是吃香蕉的速度,target是h
        '''
        def timeNeeded(piles: List, v: int) -> int:
            res = 0
            for pile in piles:
                res += math.ceil(pile / v)
            return res

        piles.sort()
        left, right = 1, piles[-1]

        while left <= right:
            mid = left + (right - left) // 2
            if timeNeeded(piles, mid) > h:
                left = mid + 1
            else:
                right = mid - 1

        return left

1011. 在 D 天内送达包裹的能力

传送带上的包裹必须在 days 天内从一个港口运送到另一个港口。

传送带上的第 i 个包裹的重量为 weights[i]。每一天,我们都会按给出重量(weights)的顺序往传送带上装载包裹。我们装载的重量不会超过船的最大运载重量。

返回能在 days 天内将传送带上的所有包裹送达的船的最低运载能力

示例 1:

输入:weights = [1,2,3,4,5,6,7,8,9,10], days = 5
输出:15
解释:
船舶最低载重 15 就能够在 5 天内送达所有包裹,如下所示:
第 1 天:1, 2, 3, 4, 5
第 2 天:6, 7
第 3 天:8
第 4 天:9
第 5 天:10

请注意,货物必须按照给定的顺序装运,因此使用载重能力为 14 的船舶并将包装分成 (2, 3, 4, 5), (1, 6, 7), (8), (9), (10) 是不允许的。 

示例 2:

输入:weights = [3,2,2,4,1,4], days = 3
输出:6
解释:
船舶最低载重 6 就能够在 3 天内送达所有包裹,如下所示:
第 1 天:3, 2
第 2 天:2, 4
第 3 天:1, 4

示例 3:

输入:weights = [1,2,3,1,1], days = 4
输出:3
解释:
第 1 天:1
第 2 天:2
第 3 天:3
第 4 天:1, 1

提示:

  • 1 <= days <= weights.length <= 5 * 104
  • 1 <= weights[i] <= 500

【算法思路】

1、确定x, f(x), target分别是什么,并写出函数f的代码

题目问什么,什么就是自变量,也就是说船的运载能力就是自变量x

运输天数和运载能力成反比,所以可以让f(x)计算x的运载能力下需要的运输天数,那么f(x)是单调递减的。

函数f(x)的实现如下:

// 定义:当运载能力为 x 时,需要 f(x) 天运完所有货物
// f(x) 随着 x 的增加单调递减
int f(int[] weights, int x) {
    int days = 0;
    for (int i = 0; i < weights.length; ) {
        // 尽可能多装货物
        int cap = x;
        while (i < weights.length) {
            if (cap < weights[i]) break;
            else cap -= weights[i];
            i++;
        }
        days++;
    }
    return days;
}

对于这道题,target显然就是运输天数D,我们要在f(x) == D的约束下,算出船的最小载重。

2、找到x的取值范围作为二分搜索的搜索区间,初始化leftright变量

船的最小载重是多少?最大载重是多少?

显然,船的最小载重应该是weights数组中元素的最大值,因为每次至少得装一件货物走,不能说装不下嘛。

最大载重显然就是weights数组所有元素之和,也就是一次把所有货物都装走。

这样就确定了搜索区间[left, right)

public int shipWithinDays(int[] weights, int days) {
    int left = 0;
    // 注意,right 是开区间,所以额外加一
    int right = 1;
    for (int w : weights) {
        left = Math.max(left, w);
        right += w;
    }

    // ...
}

3、需要根据题目的要求,确定应该使用搜索左侧还是搜索右侧的二分搜索算法,写出解法代码

现在我们确定了自变量x是船的载重能力,f(x)是单调递减的函数,target就是运输总天数限制D,题目要我们计算船的最小载重,也就是x要尽可能小:

图片

【Java】

public int shipWithinDays(int[] weights, int days) {
    int left = 0;
    int right = 1;
    for (int w : weights) {
        left = Math.max(left, w);
        right += w;
    }

    while (left < right) {
        int mid = left + (right - left) / 2;
        if (f(weights, mid) <= days) {
            right = mid;
        } else {
            left = mid + 1;
        }
    }

    return left;
}

int f(int[] weights, int x) {
    int days = 0;
    for (int i = 0; i < weights.length; ) {
        // 尽可能多装货物
        int cap = x;
        while (i < weights.length) {
            if (cap < weights[i]) break;
            else cap -= weights[i];
            i++;
        }
        days++;
    }
    return days;
}

【Python】

class Solution:
    def shipWithinDays(self, weights: List[int], days: int) -> int:
        '''
        数据结构:数组本身
        思路:
            1、二分搜索,对应的f(x)是所需天数,x是每天运载能力,target是days
            2、l = max(weights), r = sum(weights)
        '''
        def daysNeeded(weights: List, capability: int) -> int:
            days = 0
            i = 0
            size = len(weights)
            # 外层while遍历weights
            while i < size:
                cap = capability
                # 内层while旨在尽可能多装货物
                while i < size:
                    if cap < weights[i]:
                        break
                    else:
                        cap -= weights[i]
                    i += 1
                days += 1
            return days

        left = max(weights)
        right = sum(weights)

        while left <= right:
            mid = left + (right - left) // 2
            if daysNeeded(weights, mid) <= days:
                right = mid - 1
            elif daysNeeded(weights, mid) > days:
                left = mid + 1

        return left

410. 分割数组的最大值

【Python】

class Solution:
    '''
    数据结构:数组本身
    思路:【经典二分搜索】
        1、本题与运载货物那题本质一模一样
        2、k是运货所需天数,最大和即为运载能力capacity,nums即为待运输的货物
    '''
    def splitArray(self, nums: List[int], k: int) -> int:
        # 构造处理数组最大值并返回对应数组个数的函数
        # 函数横轴x代表分割后数组的最大和,纵轴f(x)代表可分割成几个子数组
        def getMax(nums: List, m: int) -> int:
            res = 0
            i = 0
            while i < len(nums):
                # 每次进循环即将最大容量m赋予cap
                cap = m
                # 尽可能多容纳数组里的元素
                while i < len(nums):
                    if cap < nums[i]:
                        break
                    else:
                        cap -= nums[i]
                    i += 1
                res += 1
            return res
        # left即所有元素各分一组,此时最大和自然是max(nums)
        # right即原数组即为子数组,此时最大和自然是sum(nums)
        left, right = max(nums), sum(nums)

        while left <= right:
            mid = left + (right - left) // 2
            if getMax(nums, mid) <= k:
                right = mid - 1
            else:
                left = mid + 1

        return left

【算法思路】

这个题目有点类似前文一道经典动态规划题目 高楼扔鸡蛋,题目比较绕,又是最大值又是最小值的。

简单说,给你输入一个数组nums和数字m,你要把nums分割成m个子数组。

肯定有不止一种分割方法,每种分割方法都会把nums分成m个子数组,这m个子数组中肯定有一个和最大的子数组对吧。

我们想要找一个分割方法,该方法分割出的最大子数组和是所有方法中最大子数组和最小的。

请你的算法返回这个分割方法对应的最大子数组和。

我滴妈呀,这个题目看了就觉得难的不行,完全没思路,这题怎么运用我们之前说套路,转化成二分搜索呢?

其实,这道题和上面讲的运输问题是一模一样的,不相信的话我给你改写一下题目

你只有一艘货船,现在有若干货物,每个货物的重量是nums[i],现在你需要在m天内将这些货物运走,请问你的货船的最小载重是多少?

这不就是刚才我们解决的力扣第 1011 题「在 D 天内送达包裹的能力」吗?

货船每天运走的货物就是nums的一个子数组;在m天内运完就是将nums划分成m个子数组;让货船的载重尽可能小,就是让所有子数组中最大的那个子数组元素之和尽可能小。

所以这道题的解法直接复制粘贴运输问题的解法代码即可。

  • 12
    点赞
  • 30
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值