[10.3.2] 二分搜索怎么用?我又总结了套路

本文总结一套二分搜索算法运用的框架套路,帮你在遇到二分搜索算法相关的实际问题时,能够有条理地思考分析,步步为营,写出答案。
警告:本文略长略硬核,建议清醒时学习。
labuladong 算法小抄

原始的二分搜索代码


二分搜索的原型就是在「有序数组」中搜索一个元素 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;
}
int left_bound(int[] a, int n, int value) {
    int low = 0, high = n - 1;
    while (low <= high) {
        int mid = low + (high - low) / 2;
        if (a[mid] > value)
            high = mid - 1;
        else if (a[mid] < value)
            low = mid + 1;
        else {
            if (mid == 0 || a[mid - 1] != value)
                return mid;
            else
                high = mid - 1;
        }
    }
    return -1;
}

以上两种方法。

假设输入的数组 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;
}
int right_bound(int[] a, int n, int value) {
    int low = 0, high = n - 1;
    while (low <= high) {
        int mid = low + (high - low) / 2;
        if (a[mid] > value)
            high = mid - 1;
        else if (a[mid] < value)
            low = mid + 1;
        else {
            if (mid == n - 1 || a[mid + 1] != value)
                return mid;
            else
                low = mid + 1;
        }
    }
    return -1;
}

以上。

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

好,上述内容都属于复习,我想读到这里的读者应该都能理解。记住上述的图像,所有能够抽象出上述图像的问题,都可以使用二分搜索解决。

二分搜索问题的泛化


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

首先,你要从题目中抽象出一个自变量 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,其实就是多此一举,但是,这样能抽象出二分搜索思想在具体算法问题中的框架。

运用二分搜索的套路框架


想要运用二分搜索解决具体的算法问题,可以从以下代码框架着手思考:

// 函数 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 的取值范围作为二分搜索的搜索区间,初始化 left 和 right 变量。
3. 根据题目的要求,确定应该使用搜索左侧还是搜索右侧的二分搜索算法,写出解法代码。

下面用几道例题来讲解这个流程。

例题一、珂珂吃香蕉

这是力扣第 875 题「爱吃香蕉的珂珂」:

珂珂每小时最多只能吃一堆香蕉,如果吃不完的话留到下一小时再吃;如果吃完了这一堆还有胃口,也只会等到下一小时才会吃下一堆。

他想在警卫回来之前吃完所有香蕉,让我们确定吃香蕉的最小速度 K。函数签名如下:

int minEatingSpeed(int[] piles, int H);

那么,对于这道题,如何运用刚才总结的套路,写出二分搜索解法代码?

按步骤思考即可:

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) 是单调递减的,不要闭眼睛套框架,需要结合上图进行思考,写出代码:

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 if (f(piles, mid) < H) {
            // 需要让 f(x) 的返回值大一些
            right = mid;
        } else if (f(piles, mid) > H) {
            // 需要让 f(x) 的返回值小一些
            left = mid + 1;
        }
    }
    return left;
}

至此,这道题就解决了,现在可以把多余的 if 分支合并一下,最终代码如下:

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) {
    // 见上文
}

PS:我们代码框架中多余的 if 分支主要是帮助理解的,写出正确解法后建议合并多余的分支,可以提高算法运行的效率。

例题二、运送货物

再看看力扣第 1011 题「在 D 天内送达包裹的能力」:

要在 D 天内按顺序运输完所有货物,货物不可分割,如何确定运输的最小载重呢?

函数签名如下:

int shipWithinDays(int[] weights, int days);

和上一道题一样的,我们按照流程来就行:

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 要尽可能小:

这就是搜索左侧边界的二分搜索嘛,结合上图就可写出二分搜索代码:

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;
    }

    while (left < right) {
        int mid = left + (right - left) / 2;
        if (f(weights, mid) == days) {
            // 搜索左侧边界,则需要收缩右侧边界
            right = mid;
        } else if (f(weights, mid) < days) {
            // 需要让 f(x) 的返回值大一些
            right = mid;
        } else if (f(weights, mid) > days) {
            // 需要让 f(x) 的返回值小一些
            left = mid + 1;
        }
    }

    return left;
}

到这里,这道题的解法也写出来了,我们合并一下多余的 if 分支,提高代码运行速度,最终代码如下:

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) {
    // 见上文
}

本文就到这里,总结来说,如果发现题目中存在单调关系,就可以尝试使用二分搜索的思路来解决。搞清楚单调性和二分搜索的种类,通过分析和画图,就能够写出最终的代码。


8级风 啥也瞧不见
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,242评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,769评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,484评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,133评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,007评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,080评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,496评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,190评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,464评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,549评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,330评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,205评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,567评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,889评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,160评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,475评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,650评论 2 335

推荐阅读更多精彩内容