0%

二分查找

  • begin到first的区间[begin, first)是否对于一个特性是单调的?last到end的区间[last,end)是否针对一个特性是单调的?
  • 是否已知区间的范围?如果不知道,是否可以用单边二分查找来先确定范围?

二分查找三基础:
LC35 Search Insert Position
LC704 Binary Seach
LC34 Find First and Last Position of Element in Sorted Array
[equal_range —> 将 lower_bound中的!(*mid < target)分解为 小于:target < *mid 和 等价:!(*mid < target) && !(*mid > target),将等价的区间分为[first, mid)和[mid, last),再在前面做lower_bound后面做upper_bound。
简单二分查找:
LC278 第一个错误的版本 单边二分。注意下标溢出。记录上一次badVersion的位置,缩小二分区间。
剑指53 - II 0到n-1中缺失的数字
⚠️LC1060 有序数组上缺失的数字 [设计一个函数计算到目前为止缺失的元素个数,记得要找lower_bound前一个元素(最后一个不大于)]
剑指53 - III 数组中数值和下标相等的元素[小心size_t和int的大小比较]

峰值数组:
LC1095 山脉数组中查找目标值
LC162 寻找峰值

旋转数组:
LC153 Find Minimum in Rotated Sorted Array
LC154 Find Minimum in Rotated Sorted Array II [有重复数字,遇到重复直接线性查找]
LC33 Search in Rotated Sorted Array [想要在旋转数组上找某个数字]
矩阵查询:
LC74 Search a 2D Matrix [展开为一维/两次二分/线性]
LC240 Search a 2D Matrix II [线性/对角线二分]
困难二分查找:
⚠️LC4 排序数组的中位数
⚠️LC410 分割数组的最大值
⚠️LC162 寻找峰值
LC222 完全二叉树的节点个数
数学二分:
LC96 Sqrt(x)
LC878 第N个神奇数字

基础知识

二分查找的基本思路就是维护两个循环不变式,直到搜索空间变为空。

求区间中点

  • 不能直接使用 mid = (first + last) / 2,因为first + last的结果可能溢出。
    在区间长度为偶数的时候,存在两个可能的区间中点
  • Mid1 = first + (last - first) / 2;
  • Mid2 = first + (last - first - 1) / 2;

一般我们用Mid1,因为不需要额外-1.

比较符号

按照C++ STL的惯例,只使用operator<()来做比较。

STL中相等(equality)和等价(equivalence)的区别

  • 相等:a == b
  • 等价:!(a < b) && !(b < a)

更多关于STL中【相等】和【等价】的区别,可以参考 Effective STL, Scott Meyers, Item19: Understand the difference between equality and equivalence

实现lower_boundupper_bound

LC35 Search Insert Position

[first, last) 左闭右开区间表示法

lower_bound维护下面的循环不变式:

  1. [begin, first) 中的所有元素 小于 target.
  2. [last , end ) 中的所有元素 不小于 target.
  3. [first, last ) 区间长度大于0.

适用于迭代器为RandomAccessIteratorContainerlower_bound

upper_bound维护下面的循环不变式:

  1. target 不小于 [begin, first) 中的所有元素.
  2. target 小于 [last , end ) 中的所有元素.
  3. [first, last ) 区间长度大于0.

适用于迭代器为RandomAccessIteratorContainerlower_bound


实现binary_seach

通过lower_bound实现binary_seach

LC704 Binary Seach

C++ STL是用过调用lower_bound来实现的。因为lower_bound的返回值it是第一个【不小于(大于等于)】target的iterator,即!(*it < target)所以如果想要测试it是否和target等价,需要加上条件:

  • it != nums.end()
  • !(target < *it)

it非尾迭代器且!(target < *it) && !(*it < target)(STL的等价(equivalent)).

实现equal_range

equal_range维护下面的循环不变式:

  1. target 小于 [last, end) 中的所有元素.
  2. [begin, first) 中的所有元素 小于 target.
  3. [first, last ) 区间长度大于0.

适用于迭代器为RandomAccessIteratorContainerequal_range

LC34 Find First and Last Position of Element in Sorted Array的解:

简单二分查找

单侧二分查找

有一个前面全是0,后面全是1的数组。我们想要找到这个数组的转折点。如果我们知道数组元素个数n,我们可以在这个区间上做二分查找。如果我们不知道元素个数n,我们可以逐步扩大区间(A[1],A[2],A[4],A[8],$\cdots$),直到找到非零值位置,然后在这个区间上做二分查找。
LC278 第一个错误的版本
需要注意的问题:

- 单边二分查找可能会增长超过INT_MAX。如果发现要溢出了,就直接给i = INT_MAX即可。
- 我们在做单边二分查找的时候,可以记录一下上一次仍不是BadVersion的index,这样可以缩小之后的二分查找范围。

有序数组上的次数统计

有序数组上缺失的数字

剑指53 0到n-1中缺失的数字

维护以下的循环不变式

  • [begin, first) 中,具有下标i的所有元素都满足nums[i] == i
  • [last, end ) 中,具有下表j的所有元素都满足nums[j] != j
  • first < last

LC1060 有序数组上缺失的数字

更为复杂版本的数组上缺失的数字,这里没有要求所有数字的范围是0 到 (n - 1), 而是随意的一个范围。

给出一个有序数组 A,数组中的每个数字都是 独一无二的,找出从数组最左边开始的第 K 个缺失数字。

思路:

  • 我们需要知道到数字中每一元素为止,缺失了多少个元素
    • 比如 nums = [4, 7, 9, 10] 这个数组,到7缺失了2个(5,6),到9缺失了3个(5,6,8),到10也是缺失了3个(5, 6, 8)。纸笔演算一下就可以发现,设这个元素的迭代器为it, 这个缺失数应该是:
      *it - *nums.begin() - distance(nums.begin(), it)
    • 也可以用[4,7,9,10]减去[4,5,6,7]就是到每个元素为止缺失元素的个数。
  • 知道了到每个元素为止缺了多少元素,那我们只要找到k的lower_bound,即第一个缺失元素不小于k的元素,他前面那个元素(设before为他的迭代器)就最后一个缺失元素小于k的。
  • 我们只需要计算 (k - 到before为止缺失元素的个数) + *before,就是第k个缺失的元素了

数组中数值和下标相等的元素

剑指53 - III 数组中数值和下标相等的元素

设一个元素的下标为i, 注意到:

  • 如果nums[i] < i,因为数组是单调递增的,且没有重复元素,对于i之前下标为j的任意元素,都有nums[j] < j
  • 对于i < nums[i],类似的结论也成立。

维护下面的循环不变式

  • [begin, first),对于任意的下标i的元素都满足nums[i] < i
  • [last, end ),对于任意的下标i的元素都满足i < nums[i]
  • first < last

注意:一个size_t类型的变量和int类型变量比较大小的时候,一定要强制类型转换后再比较

旋转数组

LC153 Find Minimum in Rotated Sorted Array:不含重复元素
LC154 Find Minimum in Rotated Sorted Array II:含有重复元素
[LC33 Search in Rotated Sorted Array]: 查找指定元素
剑指Offer 11

思路:

  • 一个有序数组被旋转后,第一个元素必然大于最后一个元素:(1,2,3,4,5) —> (4,5,1,2,3)
  • 旋转后的数组可以被分成递增的两部分(4,5)和(1,2,3)
  • 旋转后的数组最小值出现在第二部分的头
  • 大于等于旋转后数组的头的数字,必然比旋转后数组的尾大,因此不属于第二部分 —> 第一部分的数字都大于等于旋转后数组的头
  • 小于等于旋转后数组的尾的数字,必然比旋转后数组的头小,因此不属于第一部分 —> 第二部分的数字都小于等于旋转后数组的尾
  • 每一部分都是单调的 —> 可以采用二分查找

当没有重复数字时,维护下面的循环不变式

- [begin, first) 为前递增数组的元素
- [last,  end  ) 为后递增数组的元素
- 1 < last - first

我们要找的最小值就是迭代结束后的*last。这里需要注意一个边界条件:如果数组只有一个数,会导致last变成尾后迭代器。


当数组中包含重复数字时,如果我们如果发现*mid和头尾的元素相等,我们就无法再通过二分查找找到最小值了。因为这些相等的元素可以被任意分配第一部分和第二部分。直接采用线性搜索即可。


LC33 Search in Rotated Sorted Array

先找到第二区间的头,然后根据target和nums.front()的关系决定它在哪一个区间上。之后在那个区间上再做一次二分查找。

峰值数组

峰值数组和旋转数组的差别在于:峰值数组的山峰一侧是递增的,另一侧是递减的。而旋转数组的最小值左右两边的数组都是递增的。


LC162 寻找峰值

思路一:
该数组中可能有多个峰值。但仍然可以用二分查找来寻找峰值[为什么?]。

思路二:
设这个数组为$a$,数组中的元素为$an$,$a{n} \neq a{n+1}$,我们认为$a{-1} = a_{end + 1} = -\infty$ 不加证明的给出以下结论:

  • 如果$an < a{n+1}$,那么峰值在[n + 1, end)中
  • 如果$an > a{n+1}$,那么峰值在[begin, n+1)$中

这正好对应了二分查找的两个区间的特点。

维护以下的循环不变式:

  • [begin, first)区间中的值不是峰值
  • [last, end]区间中的值不是峰值
  • first < last

注意:

- 不能将last初始化为尾后迭代器,因为`mid`可能会被移动到最后一个元素的位置。在比较`mid`和`mid+1`的时候会导致解引用尾后迭代器。
- 如果想把last初始化成尾后迭代器,也可以在解引用`mid + 1`之前加一个判断,看看是不是`end`

LC1095 山脉数组中查找目标值

首先找到峰值,之后用峰值将数组分割为递增和递减的两部分。在这两部分上分别用二分查找即可。
山脉数组中只有一个峰值,我们可以通过二分查找来找到峰值。

  • [begin, first)之间的元素都属于递增区间
  • [last, end)之间的元素都属于递减区间
  • 判断条件:如果mid == 0(第一个元素) 或 m.get(mid - 1) < m.get(mid)(比左边的元素大),则mid必然处于递增区间。否则mid处于递减区间。据此更新firstlast的位置。

矩阵查找

LC74 Search a 2D Matrix

在一个$m \times n$的二维数组中,每一行从左到右递增,每一列从上到下递增。在这样的二维数组中做查找。

  • 解法1:复杂度为$O(m + n)$

从数组的右上角row = 0; col = matrix.front().size() - 1开始看:

  • 如果这个元素比target小,那么这个元素所在这一行的所有元素都比target小。因此我们可以把这个元素所在这行全部划掉。
  • 如果这个元素比target大,那个这个元素所在这一列的所有元素都比target大。因此我们可以把这个元素所在这一列全部划掉。
  • 如果这个元素和target相等,返回true
  • 如果搜索空间为空,返回false

  • 解法2:展开成一维数组二分查找

复杂度$O(\mathrm{log}(mn))$
思路一:

  • 每行中的整数从左到右按升序排列。
  • 每行的第一个整数大于前一行的最后一个整数。
    这个二维数组,每一行拿出来拼在一起也是一个有序数组。所以我们可以把这个二维数组的坐标(i, j)唯一映射到一个坐标k上,然后在这个区间上做二分查找。

设m为该数组的行数,n为该数组的列数

  • k 对应的横坐标为 k / n
  • k 对应的纵坐标为 k % n
  • k 的范围为 [0, m * n)

  • 解法3:两次二分查找

复杂度$O(\mathrm{log}(m) + \mathrm{log}(n))$

在最后一列上做一次lower_bound,确定这个数应该在哪一行。检查一下该行是否有效。再在这行上做lower_bound确定他在哪一列。 检查这一列是否有效,并检查结果是否的确相等。

注意:
每次用lower_bound做二分查找后,都要检查:

  • 结果是不是end?
  • 结果跟target等不等?

LC240 Search a 2D Matrix II

  • 每行的元素从左到右升序排列。
  • 每列的元素从上到下升序排列。

这个题和上一个题不一样了,所以解法2,3就用不了了。但是解法1还是照常使用。

思路1:同上一题解1。复杂度$O(M + N)$

思路2:对角线二分查找。复杂度$O(\mathrm{log}(M!N!))$ = $O(MN\mathrm{log}(MN))$。大部分情况不如上面的线性搜索好。

  • 矩阵对角线上的元素是递增的:因为一个元素$a{i,j}$小于他右边的元素$a{i,j + 1}$,也小于他下边的元素$a{i - 1,j}$。其对角线上的元素$a{i + 1, j + 1}$大于$a{i,j + 1}$也大于$a{i - 1,j}$。所以对角线上的元素是递增。并且对角线上的元素$a{i,j}$是它和矩阵右下角的元素$a{m - 1, n - 1}$构成的子矩阵的最小值。
  • 我们可以现在该矩阵的对角线元素上做upper_bound,将不合适的子矩阵排除。然后在剩余的对角线元素确定的行和列上分别做lower_bound寻找该元素。

复杂度高,代码复杂导致运行时间隐藏的常数项也大。实现略。

数学上的二分查找

第N个神奇数字

小于等于x,能被A整除的数字有⌊x/A⌋
小于等于x,能被B整除的数字有⌊x/B⌋
小于等于x,能同时被A和B整除的数字有⌊x/lcm(A,B)⌋
其中lcm(A,B)=ABgcd(A,B)AB的最小共倍数。
小于等于x,能被A或B整除的数字有⌊x/A⌋+⌊x/B⌋−⌊x/lcm(A,B)⌋个,记该数字为f(x)
维护循环不变式:

  • [begin, first)区间中的数字,f(x)<N
  • [last, end)区间中的数字,f(x)>=N
  • first < last

不用乘除取余做除法

LC29 Divide Two Integers

因为不允许使用int64_t,导致边界条件判断非常复杂。再加上LeetCode编译器不允许负数位移,可以说是LeetCode恶心之最。TODO

求整数的平方根

LC69 Sqrt(x)
这个题目承诺提供的数字x一定是一个非负整数。

会用接近INT_MAX的数来测试,可能会导致二分

  • 思路1:二分查找

维护以下的循环不变式

  • first左侧区间数字的平方比x小
  • last右侧区间的平方x大
  • first - last > 1

解法:

  • 初始化搜索区间为first = 0last = n
  • 找到区间中点mid
  • 检测中点的平方mid * mid
    • 如果 mid * mid < x移动firstmid (为什么不像以前一样是 mid + 1呢?因为mid * mid < x只能保证$[mid - 1, mid]$的数组的平方比x小)
    • 如果 mid * mid > x移动lastmid
    • 如果 mid * mid == x 返回 mid
      注意:
  • 非lower_bound形式的一般二分搜索,循环结束条件为last - first > 1
  • 思路2:lower_bound

套用lower_bound的一般套路,找到首个平方不小于x的数first,然后判断first的平方是否等于x,否则返回first - 1。

注意:

  • 如果first用int,无法通过INT_MAX的测试,因为无法计算first * first的值,需要全部使用int64_t才行。
  • 思路3:upper_bound

找到首个平方大于x的数first,然后回first - 1即可。


困难二分查找

分割数组的最大值

LC410 分割数组的最大值

思路:

  • 数组nums的子数组各自和的最大值,在$[max(nums), sum(nums)]$之间。
  • 分成的数组越多,和的最大值就越小。分成的数组越少,和的最大值就越大。(未证明)
  • 所以我们可以在前面的范围上做二分查找(未证明)
    • 设以mid为子数组和的最大值,能将原本的数组分成k份
      • 如果 k < m, 那么分的组数少了,说明mid选大了
      • 否则就是mid选小了
        注意:
  • 和要用int64_t,因为有的测试用例会恶心你。
    这个题的关键部分都缺少证明,估计也很难证明这个算法的正确性。所以把他记下来就好了。

排序数组的中位数

LC4 Median of Two Sorted Arrays

中位数的定义:

将一个集合划分为两个长度相等的子集,其中一个子集中的元素总是大于另一个子集中的元素。

假设现在有两个有序集合A, B。其划分后的状态如下,左边集合的元素个数为i - 1 - 0 + 1 + j - 1 - 0 + 1 = i + j个,右边集合的元素个数为m - 1 - i + 1 + n - 1 - j + 1 = m - i + n - j

1
2
3
      left_part          |        right_part
A[0], A[1], ..., A[i-1] | A[i], A[i+1], ..., A[m-1]
B[0], B[1], ..., B[j-1] | B[j], B[j+1], ..., B[n-1]

我们定义:

  • 如果m + n为奇数,集合不能被正好平分。定义中位数为max(A[i - 1], B[j - 1]),即左边集合中的最大值。
  • 如果m + n为偶数,则中位数为(max(A[i - 1], B[j - 1]) + min(A[i], A[j])) / 2,即左边集合的最大值和右边集合最小值的平均值。这样刚好将集合平分。

我们想要寻找的是下标i的值。注意到根据元素个数相等,可以列出等式:

  • 总个数为偶数:i + j = m - i + n - j
  • 总个数为奇数:i + j - 1 = m - i + n - j

采用整数除法,并假设n >= m,则总有下面的式子成立,我们可以通过i的位置来唯一确定j的位置:

j + j = m + n - i - i + 1 <=> j = (m + n + 1 / 2) - i

运用二分查找的思想,[begin, first)的区间上点的都比较小,也就是说满足A[i - 1] < B[j]。我们令mid = i - 1mid2为其在B数组上的对应点,mid2 = (m + n + 1)/2 - mid1,则其对应的j - 1应该是mid2 - 1。因此区间[begin, first)上的点都满足A[mid1] < B[mid2 - 1]。我们找到二分查找的最关键的区间划分不等式。所以整个二分查找过程应该为:

1
2
3
4
5
6
7
8
9
10
11
first = 0
last = m
while: first < last
{
mid1 = first + (last - first) / 2;
mid2 = (m + n + 1 / 2) - mid1;
if(mid1 != m &&
A[mid1] < B[mid2 - 1])
first = mid + 1;
else last = mid;
}

我们可以假设两个数组左边都是INT_MIN,右边都是INT_MAX。在计算出i的值后,我们还需找到中位数。我们需要计算:

  • 左边区间的最大值max(A[i - 1], B[j - 1])
  • 右边区间的最小值min(A[i], B[j])
    但是存在i == m, i == 0, j == 0, j == n的特殊情况。根据上面提出的假设解决这些特殊情况即可。

剩下还需要处理的特殊情况就是较小的数组为空数组,此时会导致下标溢出。直接求长数组的中位数即可。


参考:
LC官方题解

动用二分查找模版来巧妙解决此题

LeetCode 004 详细分析

Binary Search : Median of two sorted arrays of different sizes.


完全二叉树的节点个数

LC222 完全二叉树的节点个数

完全二叉树除了最后一层,其他层都是填满的。设完全二叉树的层号为$0, 1, 2, \cdots n$,则第$n$层的节点数为$2^n$个,之前所有层的节点总数为$2^n - 1$个。所以关键是要找到最后一层有多少个节点。设最后一层的节点序号为$0, 1, \cdots, 2^n - 1$。如果想要检查一个序号为idx的节点是否存在,我们可以通过二分查找的方式找到从根节点到他的路径。即找到区间中点midmid < idxfirst = mid + 1然后往右走,否则last = mid然后往左走。最后区间为空的时候,看看是不是节点是否存在。我们在最后一层的所有序号上再进行一次二分查找,确定最后一个存在的序号即可。

注意:

  • 不知道为什么,在检查最后一层节点是否存在的函数中,last要设置为最后一层的节点数-1,如果按照左闭右开设置为最后一层的节点数,结果不正确。