Skip to content

Latest commit

 

History

History
536 lines (394 loc) · 33.5 KB

02-二分法.md

File metadata and controls

536 lines (394 loc) · 33.5 KB

二分法

参考1 学习基础模版:我作了首诗,保你闭着眼睛也能写对二分查找

参考2 领会二分思路:写对二分查找不是套模板并往里面填空,需要仔细分析题意

参考3 模版:我写了一个套路,助你随心所欲运用二分搜索

一、二分

二分的本质是「二段性」,并非单调性

  • 满足 01 特性(满足/不满足)的「二段性」可以使用二分
  • 满足 1? 特性(一定满足/不一定满足)也可以二分

二分查找技巧

  • 尽量不要出现 else,而是把所有情况用 else if 写清楚,这样可以清楚地展现所有细节
  • int mid = left + ((right - left) >> 1); 防止溢出
  • 若排序数组中是无重复元素的,果断用「寻找一个数」的左闭右闭模版
  • 实在是颠不对的,有疑惑的地方+1 -1 =,多调试调试,都是玄学

框架

... 标记的部分,就是出现细节问题的地方,是需要重点关注的地方

int binarySearch(vector<int> nums, int target) {
    int left = 0, right = ...;

    while(...) {
        int mid = left + (right - left) / 2;
        if (nums[mid] == target) {
            ...
        } else if (nums[mid] < target) {
            left = ...
        } else if (nums[mid] > target) {
            right = ...
        }
    }
    return ...;
}

搜索区间

两端都闭 [left, right] 和左闭右开 [left, right) 的区别

  • 两端都闭 [2, 3] 会搜索 2,3 [2, 2] 会搜索 2 [3, 2] 时无效跳出循环
  • 左闭右开 [2, 3) 会搜索 2 [2, 2] 时无效跳出循环

两端都闭 [left, right] 搜索区间设置方法:

int left = 0;
int right = nums.size() - 1;
while (left <= right) {return mid;}

左闭右开 [left, right) 搜索区间设置方法:

int left = 0;
int right = nums.size();
while (left < right) {}
return left;

二、三分

「三分」就是使用两个端点将区间分成三份,然后通过每次否决三分之一的区间来逼近目标值

由于峰顶元素为全局最大值,因此我们可以每次将当前区间分为 [left, mid1]、[mid1, mid2] 和 [mid2, right] 三段

  • 如果 arr[mid1] < arr[mid2],说明峰顶元素不可能存在 [left, mid1] 中,让 left = mid1 + 1
  • 如果 arr[mid1] >= arr[mid2],说明峰顶元素不可能存在 [mid2, right] 中,让 right = mid2 - 1

「二分」和「三分」在渐进复杂度上都是一样的,都可以通过换底公式转化为可忽略的常数,因此两者的复杂度都是 O(logn)

因此选择「二分」还是「三分」取决于要解决的是什么问题:

  • 二分通常用来解决单调函数的找 target 问题,但进一步深入我们发现只需要满足「二段性」就能使用「二分」来找分割点;

  • 三分则是解决单峰函数 极值问题

因此一般我们将「通过比较两个端点,每次否决 1/3 区间 来解决单峰最值问题」的做法称为「三分」;而不是简单根据单次循环内将区间分为多少份来判定是否为「三分」。

例题:852-[山脉数组-三分极值]-山脉数组的峰顶索引

三、寻找一个数

搜索一个数,如果存在,返回其索引,否则返回 -1

#include <algorithm>
#include <iostream>
#include <vector>

using namespace std;

int binarySearch(vector<int> nums, int target) {
    int left = 0;
    int right = nums.size() - 1;  // 注意
    while (left <= right) {       // 注意
        int mid = left + ((right - left) >> 1);
        if (nums[mid] == target) {
            return mid;
        } else if (target < nums[mid]) {
            right = mid - 1;  // 注意
        } else if (target > nums[mid]) {
            left = mid + 1;  // 注意
        }
    }
    return -1;
}

int main() {
    vector<int> nums = {1, 2, 3, 4, 5};
    int idx = binarySearch(nums, 3);
    cout << idx << endl;  // 2

    return 0;
}

1、为什么 while 循环的条件中是 <=,而不是 <

因为初始化 right 的为 nums.length - 1,即最后一个元素也要进入循环判断是否是target

所以每次循环的「搜索区间」是「两端都闭区间」 [left, right]

2、什么时候应该停止搜索呢

找到了目标值的时候可以终止,nums[mid] == target

没找到目标值时,如左右边界为 [3, 2],这时候区间为空,left > right 了,跳出循环

3、为什么是 left = mid + 1,right = mid - 1

else if (target < nums[mid]) {
    right = mid - 1;  // 注意
} else if (target > nums[mid]) {
    left = mid + 1;  // 注意
}

因为我们的「搜索区间」是[left, right]左闭右闭,当nums[mid]被检测之后,下一步的搜索区间应该去掉mid 分割成两个区间

  • 当 target 大于 nums[mid],由于左边界是闭的,所以 left = mid + 1,[mid + 1, right]
  • 当 target 小于 nums[mid],由于右边界是闭的,所以 right = mid + 1,[left, mid - 1]

4、该算法有什么缺陷

如果有序数组为 nums = [1,2,2,2,3],返回的索引为 2,无法得到左右边界

四、寻找更左侧位置

寻找更左侧边界或在左侧位置插入,例如 [1,2,2,3] 找 2 ,下面的算法返回的是 1

即在 idx=1 的位置上插入 2,[1,2,2,2,3]

同时,更左侧位置,也是原数组中与 target 值相同元素们 左边的位置,但前提是target在原数组中

#include <algorithm>
#include <iostream>
#include <vector>

using namespace std;

int lowerBound(vector<int> nums, int target) {
    int left = 0;
    int right = nums.size();  // 注意
    while (left < right) {    // 注意
        int mid = left + ((right - left) >> 1);
        if (nums[mid] == target) {
            right = mid;
        } else if (target < nums[mid]) {
            right = mid;  // 注意
        } else if (target > nums[mid]) {
            left = mid + 1;  // 注意
        }
    }
    return left;
}

int main() {
    vector<int> nums = {1, 2, 2, 2, 5};

    cout << lowerBound(nums, 0) << endl;  // 0
    cout << lowerBound(nums, 2) << endl;  // 1
    cout << lowerBound(nums, 3) << endl;  // 4
    cout << lowerBound(nums, 6) << endl;  // 5

    return 0;
}

1、为什么 while 中是 < 而不是 <=

因为初始化 right 的为 nums.length,所以每次循环的「搜索区间」是「左闭右开」 [left, right)

while(left < right)终止的条件是left == right,此时搜索区间[left, left)为空,所以可以正确终止。

至于为什么 right 要设为为 nums.length 使得「搜索区间」变成「左闭右开」,因为这是绝大数人认可的写法

2、「左侧边界」有什么特殊含义

对于有序数组 nums = [1,2,2,2], target = 2,算法会返回 1,含义是:nums中小于 2 的元素有 1 个

对于有序数组 nums = [2,3,5,7], target = 1,算法会返回 0,含义是:nums中小于 1 的元素有 0 个

对于有序数组 nums = [2,3,5,7], target = 8,算法会返回 4,含义是:nums中小于 8 的元素有 4 个

返回的 idx,其实代表了 target 插在数组中后,左边有几个元素

所以返回值的范围在 [0, nums.size()]

3、为什么该算法能够搜索左侧边界

关键在于对于 nums[mid] == target 这种情况的处理

if (nums[mid] == target)
    right = mid;

可见,找到 target 时不要立即返回,而是缩小「搜索区间」的上界right,缩小至 mid

在区间[left, mid)中继续搜索,即不断向左收缩,达到锁定左侧边界的目的。

4、为什么右边界的更新变为 right = mid

 else if (nums[mid] < target) {
     left = mid + 1;
 } else if (nums[mid] > target) {
     right = mid; // 注意
 }

因为我们的「搜索区间」是[left, right)左闭右开,当nums[mid]被检测之后,下一步的搜索区间应该去掉mid 分割成两个区间

  • 当 target 大于 nums[mid],由于左边界是闭的,所以还是 left = mid + 1,[mid + 1, right)
  • 当 target 小于 nums[mid],由于右边界是开的,所以 right = mid,[left, mid)

5、为什么返回left而不是right

都是一样的,因为 while 终止的条件是 left == right

五、寻找更右侧位置

寻找更右侧边界或在右侧位置插入,例如 [1,2,2,3] 找 2 ,下面的算法返回的是 3

即在 idx=3 的位置上插入 2,[1,2,2,2,3]

同时,更右侧位置减一,是原数组中与 target 值相同元素们 右边的位置,但前提是target在原数组中

#include <algorithm>
#include <iostream>
#include <vector>

using namespace std;

int upperBound(vector<int> nums, int target) {
    int left = 0;
    int right = nums.size();  // 注意
    while (left < right) {    // 注意
        int mid = left + ((right - left) >> 1);
        if (nums[mid] == target) {
            left = mid + 1;  // 注意
        } else if (target < nums[mid]) {
            right = mid;
        } else if (nums[mid] < target) {
            left = mid + 1;
        }
    }
    return left;
}

int main() {
    vector<int> nums = {1, 2, 2, 2, 5};

    cout << upperBound(nums, 0) << endl;  // 0
    cout << upperBound(nums, 2) << endl;  // 4
    cout << upperBound(nums, 3) << endl;  // 4
    cout << upperBound(nums, 6) << endl;  // 5

    return 0;
}

1、为什么该算法能够搜索右侧边界

关键在于对于 nums[mid] == target 这种情况的处理

if (nums[mid] == target)
    left = mid + 1;

找到 target 时不要立即返回,而是缩小「搜索区间」的下界left,缩小至 mid + 1

在区间[left, right)中继续搜索,即不断向右收缩,达到锁定右侧边界的目的。

六、lower 和 upper bound 应用

如果数组为 [1, 2, 2, 4],长度为 n

使用 lower_bound

target 查0 查2 查3 查5
idx 0 1 3 4
val 1 2 4 NULL

使用 upper_bound

target 查0 查2 查3 查5
idx 0 3 3 4
val 1 4 4 NULL

使用 lower_bound 取查一个数或还要用这个值去计算,一定要检查下边界

  • 如果 idx == n,那你可能需要的下标是 idx = n - 1
  • 如果 1 <= idx <= n-1
    • 如果 nums[idx] == target,那需要的下标就是 idx
    • 如果 nums[idx] != target,那需要的下标可能是 idx,也可能是 idx - 1。例如上面数组中查3,可能需要nums[3]=4,也可能是nums[2]=2
  • 如果 idx == 0,那可能需要的下标是 idx = 0

代码实现

int idx = lower_bound(vec.begin(), vec.end(), target) - vec.begin();
if (idx < n) {
    使用 vec[idx];  // vec[idx] 大于等于 target
}
if (idx > 0) {
    使用 vec[idx-1];  // vec[idx-1] 小于 target
}

上面的如果可能有遗漏,但是代码实现没问题

七、可统一为左闭右闭搜索区间

#include <algorithm>
#include <iostream>
#include <vector>

using namespace std;

int leftBound(vector<int> nums, int target) {
    int left = 0, right = nums.size() - 1;
    while (left <= right) {
        int mid = left + (right - left) / 2;
        if (nums[mid] == target) {
            right = mid - 1;
        } else if (target < nums[mid]) {
            right = mid - 1;
        } else if (nums[mid] < target) {
            left = mid + 1;
        }
    }
    return left;
}

int rightBound(vector<int> nums, int target) {
    int left = 0, right = nums.size() - 1;
    while (left <= right) {
        int mid = left + (right - left) / 2;
        if (nums[mid] == target) {
            left = mid + 1;
        } else if (nums[mid] > target) {
            right = mid - 1;
        } else if (nums[mid] < target) {
            left = mid + 1;
        }
    }
    return left;
}

int main() {
    vector<int> nums = {1, 2, 2, 2, 5};

    cout << leftBound(nums, 0) << endl;  // 0
    cout << leftBound(nums, 2) << endl;  // 1
    cout << leftBound(nums, 3) << endl;  // 4
    cout << leftBound(nums, 6) << endl;  // 5

    cout << rightBound(nums, 0) << endl;  // 0
    cout << rightBound(nums, 2) << endl;  // 4
    cout << rightBound(nums, 3) << endl;  // 4
    cout << rightBound(nums, 6) << endl;  // 5

    return 0;
}

八、例题

题目 题解 难度 推荐指数
4. 寻找两个正序数组的中位数 LeetCode 题解链接 困难 🤩🤩🤩🤩
29. 两数相除 LeetCode 题解链接 中等 🤩🤩🤩
33. 搜索旋转排序数组 LeetCode 题解链接 中等 🤩🤩🤩🤩🤩
34. 在排序数组中查找元素的第一个和最后一个位置 LeetCode 题解链接 中等 🤩🤩🤩🤩🤩
35. 搜索插入位置 LeetCode 题解链接 简单 🤩🤩🤩🤩🤩
74. 搜索二维矩阵 LeetCode 题解链接 中等 🤩🤩🤩🤩
81. 搜索旋转排序数组 II LeetCode 题解链接 中等 🤩🤩🤩🤩
153. 寻找旋转排序数组中的最小值 LeetCode 题解链接 中等 🤩🤩🤩
154. 寻找旋转排序数组中的最小值 II LeetCode 题解链接 困难 🤩🤩🤩
162. 寻找峰值 LeetCode 题解链接 中等 🤩🤩🤩🤩🤩
220. 存在重复元素 III LeetCode 题解链接 中等 🤩🤩🤩
240. 搜索二维矩阵 II LeetCode 题解链接 中等 🤩🤩🤩🤩
274. H 指数 LeetCode 题解链接 中等 🤩🤩🤩
275. H 指数 II LeetCode 题解链接 中等 🤩🤩🤩
278. 第一个错误的版本 LeetCode 题解链接 简单 🤩🤩🤩🤩
334. 递增的三元子序列 LeetCode 题解链接 中等 🤩🤩🤩🤩
352. 将数据流变为多个不相交区间 LeetCode 题解链接 困难 🤩🤩🤩🤩
354. 俄罗斯套娃信封问题 LeetCode 题解链接 困难 🤩🤩🤩
363. 矩形区域不超过 K 的最大数值和 LeetCode 题解链接 困难 🤩🤩🤩
367. 有效的完全平方数 LeetCode 题解链接 简单 🤩🤩🤩🤩🤩
373. 查找和最小的K对数字 LeetCode 题解链接 中等 🤩🤩🤩🤩🤩
374. 猜数字大小 LeetCode 题解链接 简单 🤩🤩🤩
441. 排列硬币 LeetCode 题解链接 简单 🤩🤩🤩
475. 供暖器 LeetCode 题解链接 中等 🤩🤩🤩🤩
528. 按权重随机选择 LeetCode 题解链接 中等 🤩🤩🤩🤩
540. 有序数组中的单一元素 LeetCode 题解链接 中等 🤩🤩🤩🤩
611. 有效三角形的个数 LeetCode 题解链接 中等 🤩🤩🤩🤩
704. 二分查找 LeetCode 题解链接 简单 🤩🤩🤩🤩🤩
778. 水位上升的泳池中游泳 LeetCode 题解链接 困难 🤩🤩🤩
786. 第 K 个最小的素数分数 LeetCode 题解链接 中等 🤩🤩🤩
852. 山脉数组的峰顶索引 LeetCode 题解链接 简单 🤩🤩🤩🤩🤩
911. 在线选举 LeetCode 题解链接 中等 🤩🤩🤩🤩🤩
981. 基于时间的键值存储 LeetCode 题解链接 中等 🤩🤩🤩🤩
1004. 最大连续1的个数 III LeetCode 题解链接 中等 🤩🤩🤩
1011. 在 D 天内送达包裹的能力 LeetCode 题解链接 中等 🤩🤩🤩🤩
1044. 最长重复子串 LeetCode 题解链接 困难 🤩🤩🤩🤩
1208. 尽可能使字符串相等 LeetCode 题解链接 中等 🤩🤩🤩
1337. 矩阵中战斗力最弱的 K 行 LeetCode 题解链接 简单 🤩🤩🤩
1414. 和为 K 的最少斐波那契数字数目 LeetCode 题解链接 中等 🤩🤩🤩🤩
1438. 绝对差不超过限制的最长连续子数组 LeetCode 题解链接 中等 🤩🤩🤩
1482. 制作 m 束花所需的最少天数 LeetCode 题解链接 中等 🤩🤩🤩
1707. 与数组中元素的最大异或值 LeetCode 题解链接 困难 🤩🤩🤩
1713. 得到子序列的最少操作次数 LeetCode 题解链接 困难 🤩🤩🤩
1751. 最多可以参加的会议数目 II LeetCode 题解链接 困难 🤩🤩🤩
1818. 绝对差值和 LeetCode 题解链接 中等 🤩🤩🤩🤩🤩
1838. 最高频元素的频数 LeetCode 题解链接 中等 🤩🤩🤩
1894. 找到需要补充粉笔的学生编号 LeetCode 题解链接 中等 🤩🤩🤩🤩
1984. 学生分数的最小差值 LeetCode 题解链接 简单 🤩🤩🤩🤩🤩
剑指 Offer 53 - I. 在排序数组中查找数字 I LeetCode 题解链接 简单 🤩🤩🤩🤩🤩
剑指 Offer II 069. 山峰数组的顶部 LeetCode 题解链接 简单 🤩🤩🤩🤩🤩

题型一:二分求下标

在数组中查找符合条件的元素的下标

题目 说明
704. 二分查找 二分查找的最原始问题,使用两边夹的二分查找方法需要后处理(退出循环以后,还需要判断 left 或 right 位置的值是不是问题的答案)
35. 搜索插入位置 lower_bound
34. 在排序数组中查找元素的第一个和最后一个位置 lower_bound 先找左边届,有左边届的情况下再 upper_bound 找右边届
611. 有效三角形的个数 二分 或 双指针
4. 寻找两个正序数组的中位数 二分查找里最难的问题,重点在于理解:① 为什么是在短数组里找边界;② 深刻理解搜索边界的意义

使用二分查找的前提不一定非要是「有序数组」。旋转有序数组(下表前 4 题)、山脉数组(下表后 2 题)里的查找问题也可以使用「二分查找」。这些问题的解决思路是:利用 局部单调性,逐步缩小搜索区间。

题目 说明
33. 搜索旋转排序数组 旋转有序数组
81. 搜索旋转排序数组 II 旋转有序数组
153. 寻找旋转排序数组中的最小值 旋转有序数组
154. 寻找旋转排序数组中的最小值 II 旋转有序数组
162. 寻找峰值 山脉数组
852. 山脉数组的峰顶索引 山脉数组
1095. 山脉数组中查找目标值 山脉数组

题型二:二分答案

在一个有范围的区间里搜索一个整数

如果题目要我们找一个整数,这个整数有确定的范围,可以通过二分查找逐渐缩小范围,最后逼近到一个数。

定位一个有范围的整数,这件事情也叫「二分答案」或者叫「二分结果」。如果题目要求的是一个整数,这个整数有明确的范围,可以考虑使用二分查找。

事实上,二分答案是我们最早接触的二分查找的场景。「幸运 52」里猜价格游戏,就是「二分查找」算法的典型应用:先随便猜一个数,如果猜中,游戏结束。如果猜大了,往小猜;如果猜小了,往大猜。

题目 说明
69. x 的平方根 在一个整数范围里查找一个整数,也是二分查找法的应用场景
287. 寻找重复数
374. 猜数字大小
275. H 指数 II 这个问题题解题意得花很多时间,可以跳过不做
1283. 使结果不超过阈值的最小除数
1292. 元素和小于等于阈值的正方形的最大边长

题型三:二分答案的升级版

每一次缩小区间的时候都需要遍历数组

说明:这一类问题本质上还是「题型二」(二分答案),但是初学的时候会觉得有一些绕。这一类问题的问法都差不多,关键字是「连续」、「正整数」,请大家注意捕捉题目中这样的关键信息。

这里给出的问题解法都一样,会一题等于会其它题。问题的场景会告诉我们:目标变量和另一个变量有相关关系(一般是线性关系),目标变量的性质不好推测,但是另一个变量的性质相对容易推测(满足某种意义上的单调性)。这样的问题的判别函数通常会写成一个函数的形式。

这一类问题可以统称为「 最大值极小化 」问题,最原始的问题场景是木棍切割问题,这道题的原始问题是「力扣」第 410 题(分割数组的最大值(困难))。

思路是这样的:

  • 分析出题目要我们找一个整数,这个整数有范围,所以可以用二分查找;

  • 分析出 单调性,一般来说是一个变量 a 的值大了,另一个变量 b 的值就变小,而「另一个变量的值」 b 有限制,因此可以通过调整 a 的值达到控制 b 的效果;

  • 这一类问题的题目条件一定会给出 连续、正整数 这样的关键字。如果没有,问题场景也一定蕴含了这两个关键信息。

参考资料:

以下给出的问题无一例外。

题目 说明
875. 爱吃香蕉的珂珂
410. 分割数组的最大值
LCP 12. 小张刷题计划
1011. 在 D 天内送达包裹的能力
1482. 制作 m 束花所需的最少天数
1552. 两球之间的磁力