不骗你,没读这一篇,你不可能懂二分

举报
兔老大 发表于 2021/04/23 23:49:43 2021/04/23
【摘要】 上篇文章讲动态规划获得了80k浏览,这次的二分也值得你们一看,这个系列是特别用心写的,准备出书的哦   动态规划 3.0 引子 图书馆自习的时候,一女生背着一堆书进阅览室,结果警报响了,大妈让女生看是哪本书把警报弄响了,女生把书倒出来,一本一本的测。大妈见状急了,把书分成两份,第一份过了一下,响了。又把这一份分成两份接着测,三回就找到了,大妈用鄙视的眼神看着...

上篇文章讲动态规划获得了80k浏览,这次的二分也值得你们一看,这个系列是特别用心写的,准备出书的哦

 

动态规划

3.0 引子

图书馆自习的时候,一女生背着一堆书进阅览室,结果警报响了,大妈让女生看是哪本书把警报弄响了,女生把书倒出来,一本一本的测。大妈见状急了,把书分成两份,第一份过了一下,响了。又把这一份分成两份接着测,三回就找到了,大妈用鄙视的眼神看着女生,仿佛在说 O(N)和  O(logN)都分不清。

这就是二分法。(欲知故事后续,请继续往下看)

3.1 经典二分问题

经典二分问题:给定一个 n 个元素有序的(升序)整型数组 nums 和一个目标值 target  。

写一个函数搜索 nums 中的 target,如果目标值存在返回下标,否则返回 -1。
示例 1:

输入: nums = [-1,0,3,5,9,12], target = 9。输出: 4
解释: 9 出现在 nums 中并且下标为 4
示例 2:

输入: nums = [-1,0,3,5,9,12], target = 2。输出: -1
解释: 2 不存在 nums 中因此返回 -1

思路1:我们当然可以一个数一个数的遍历,但是毫无疑问要被大妈鄙视,这可怎么办呢?

思路2:二分查找
二分查找是一种基于比较目标值和数组中间元素的教科书式算法。

如果目标值等于中间元素,则找到目标值。
如果目标值较小,证明目标值小于中间元素及右边的元素,继续在左侧搜索。
如果目标值较大,证明目标值大于中间元素及左边的元素,继续在右侧搜索。

算法代码描述:

初始化指针 left = 0, right = n - 1。
当 left <= right:
比较中间元素 nums[pivot] 和目标值 target 。
如果 target = nums[pivot],返回 pivot。
如果 target < nums[pivot],则在左侧继续搜索 right = pivot - 1。
如果 target > nums[pivot],则在右侧继续搜索 left = pivot + 1。

算法实现:照例贴出三种语言的实现,在Java实现中给出了详细注释


  
  1. class Solution {
  2. public int search(int[] nums, int target) {
  3. //分别准备好左右端点
  4. int left = 0, right = nums.length - 1;
  5. //循环二分
  6. while (left <= right) {
  7. //取中点
  8. int pivot = left + (right - left) / 2;
  9. //找到答案并返回
  10. if (nums[pivot] == target) return pivot;
  11. //向左继续找
  12. if (target < nums[pivot]) right = pivot - 1;
  13. //向右继续找
  14. else left = pivot + 1;
  15. }
  16. //未找到,返回-1
  17. return -1;
  18. }
  19. }

  
  1. class Solution:
  2. def search(self, nums: List[int], target: int) -> int:
  3. left, right = 0, len(nums) - 1
  4. while left <= right:
  5. pivot = left + (right - left) // 2
  6. if nums[pivot] == target:
  7. return pivot
  8. if target < nums[pivot]:
  9. right = pivot - 1
  10. else:
  11. left = pivot + 1
  12. return -1

  
  1. class Solution {
  2. public:
  3. int search(vector<int>& nums, int target) {
  4. int pivot, left = 0, right = nums.size() - 1;
  5. while (left <= right) {
  6. pivot = left + (right - left) / 2;
  7. if (nums[pivot] == target) return pivot;
  8. if (target < nums[pivot]) right = pivot - 1;
  9. else left = pivot + 1;
  10. }
  11. return -1;
  12. }
  13. };

请记住这个代码,因为整篇文章,整个二分思想,都和这段代码息息相关,这也是最基础的二分问题。

3.2 基础二分小变形1

给定一个排序数组和一个目标值,在数组中找到目标值,并返回其索引。如果目标值不存在于数组中,返回它将会被按顺序插入的位置。你可以假设数组中无重复元素。

思路:我们注意,这一题和上一题的区别在于,这一题并不存在‘没找到’这种情况,因为要插入到”第一个比目标值大的数“的左边,返回这样一个插入位置,你不可能”找不到“,不可返回-1。

事实上,我们的目标也再不是找到一个相同的数字,而是找到第一个比目标值大的数,也就是插入位置。

而我们第一段代码的返回答案也可以省略了,代码的内部逻辑是”一路找到最后,返回的一定是答案“,而上段代码的逻辑是”边找边判断,找到最后还没有找到,就返回-1“。

下面是代码:


  
  1. public class Solution {
  2. public int searchInsert(int[] nums, int target) {
  3. int len = nums.length;
  4. int left = 0;
  5. int right = len - 1;
  6. while (left <= right) {
  7. int mid = (left + right) / 2;
  8. if (nums[mid] < target) {
  9. left = mid + 1;
  10. } else {
  11. right = mid - 1;
  12. }
  13. }
  14. return left;
  15. }
  16. }

提醒:所以,如果你是一个想注重细节,做到写二分代码不出错,那么你就需要关心最后返回值,while结束条件,while内部是否加判断等等这些细节了。

3.3基础二分小变形2

给定一个按照升序排列的整数数组 nums,和一个目标值 target。找出给定目标值在数组中的开始位置和结束位置。

你的算法时间复杂度必须是 O(log n) 级别。

如果数组中不存在目标值,返回 [-1, -1]。

示例 1:输入: nums = [5,7,7,8,8,10], target = 8输出: [3,4]
示例 2:输入: nums = [5,7,7,8,8,10], target = 6输出: [-1,-1]

思路:两个二分,稍微改一下,就可以找到第一个target或最后一个target;

本元素和前面一个元素不相等,才代表找到了最左边的目标元素。

本元素和后面一个元素不相等,才代表找到了最右边的目标元素。
 


  
  1. class Solution {
  2. public int[] searchRange(int[] nums, int target) {
  3. int[] ans=new int[2];
  4. ans[0]=searchRangeLeft(nums,target);
  5. ans[1]=searchRangeRight(nums,target);
  6. return ans;
  7. }
  8. public int searchRangeLeft(int[] nums, int target) {
  9. int left=0;
  10. int right=nums.length-1;
  11. while(left<=right){
  12. int mid=(left+right)/2;
  13. if(nums[mid]>target){
  14. right=mid-1;
  15. }else if(nums[mid]<target){
  16. left=mid+1;
  17. }else if(mid==0 || nums[mid-1]!=target){
  18. return mid;
  19. }else{
  20. right=mid-1;
  21. }
  22. }
  23. return -1;
  24. }
  25. public int searchRangeRight(int[] nums, int target) {
  26. int left=0;
  27. int right=nums.length-1;
  28. while(left<=right){
  29. int mid=(left+right)/2;
  30. if(nums[mid]>target){
  31. right=mid-1;
  32. }else if(nums[mid]<target){
  33. left=mid+1;
  34. }else if(mid==nums.length-1 || nums[mid+1]!=target){
  35. return mid;
  36. }else{
  37. left=mid+1;
  38. }
  39. }
  40. return -1;
  41. }
  42. }

注意:如果你找到一个target,然后向左向右线性查找是不对的,这样时间会退化为O(N),和二分的本意违背了。

3.4泛化二分的概念

看到第一节,可能大部分人都没有问题;

看到第二节,可能对有些写二分总有bug的同学有一些帮助,会觉得”细节确实需要注意“,二分查找从此要一次就bug free;

至此,我们看一下百度百科对二分的定义:

简单来说,我们介绍的就是在顺序存储结构(数组)有序的情况下进行二分查找。

从第四节开始,我们介绍的就不是传统意义上的二分查找了,不局限于”有序“,甚至不局限于线性结构,while循环里判断向左还是向右搜索的条件也不会这么单一,而更可能是这样:

        while (范围没有缩小为0) {
            if (满足某种条件) {
                排除一半答案
            } else {
                排除另一半答案
            }
        }

我们的思想是:只要可以通过正确逻辑,用二分思想正确的缩小查找范围,都称之为二分。

下面就来体会一下什么是二分思想:

我们正在玩一个猜数字游戏。 游戏规则如下:
我从 1 到 n 选择一个数字。 你需要猜我选择了哪个数字。
每次你猜错了,我会告诉你这个数字是大了还是小了。
你调用一个预先定义好的接口 guess(int num),它会返回 3 个可能的结果(-1,1 或 0):

-1 : 我的数字比较小
 1 : 我的数字比较大
 0 : 恭喜!你猜对了!
示例 :

输入: n = 10, pick = 6输出: 6


  
  1. /* The guess API is defined in the parent class GuessGame.
  2. @param num, your guess
  3. @return -1 if my number is lower, 1 if my number is higher, otherwise return 0
  4. int guess(int num); */
  5. public class Solution extends GuessGame {
  6. public int guessNumber(int n) {
  7. int low = 1;
  8. int high = n;
  9. while (low <= high) {
  10. int mid = low + (high - low) / 2;
  11. int res = guess(mid);
  12. if (res == 0)
  13. return mid;
  14. else if (res < 0)
  15. high = mid - 1;
  16. else
  17. low = mid + 1;
  18. }
  19. return -1;
  20. }
  21. }

你看,这就是把条件抽象成一个接口,完全脱离了线性数据结构,更和有序无序没关系,只是二分的思想。

3.5在线性结构上二分的题目积累

例1

峰值元素是指其值大于左右相邻值的元素。给定一个输入数组 nums,其中 nums[i] ≠ nums[i+1],找到峰值元素并返回其索引。

数组可能包含多个峰值,在这种情况下,返回任何一个峰值所在位置即可。

你可以假设 nums[-1] = nums[n] = -∞。

示例 1:输入: nums = [1,2,3,1]输出: 2
解释: 3 是峰值元素,你的函数应该返回其索引 2。
示例 2:输入: nums = [1,2,1,3,5,6,4]输出: 1 或 5 
解释: 你的函数可以返回索引 1,其峰值元素为 2;
     或者返回索引 5, 其峰值元素为 6。
说明:你的解法应该是 O(logN) 时间复杂度的

思路:

可以用二分的要求:线性表能够根据中间元素的特点推测它两侧元素的性质,以达到缩减问题规模的效果即可,不一定非要有序

具体到本题来说,我们如何做到搜索到任何一个“峰值”呢?请看图:

要先有上升的趋势,后有下降的趋势。更通俗一点就是说,要保证前一个数字比峰值小,后一个数字比峰值大,我们只要每次搜索都满足这个条件,搜到最后就一定可以找到某个峰值,因为从递增到递减,肯定是因为中间有峰值的存在所导致的

我们看中点,如果中点向右有递增趋势,我们就继续搜索右边:

反之,有向左递增的趋势,我们就搜左边:

下面给出代码:


  
  1. public class Solution {
  2. public int findPeakElement(int[] nums) {
  3. int l = 0, r = nums.length - 1;
  4. while (l < r) {
  5. int mid = (l + r) / 2;
  6. if (nums[mid] > nums[mid + 1])
  7. r = mid;
  8. else
  9. l = mid + 1;
  10. }
  11. return l;
  12. }
  13. }

我们发现,这个题目的代码逻辑就是第二节的”一路找到最后,返回的一定是答案“,不同于最基础的二分”边找边判断,找到最后还没有找到,就返回-1“。我们是缩小范围到最后,找到了答案l。

例2:

假设按照升序排序的数组在预先未知的某个点上进行了旋转。( 例如,数组 [0,1,2,4,5,6,7] 可能变为 [4,5,6,7,0,1,2] )。

请找出其中最小的元素。

你可以假设数组中不存在重复元素。

示例 1:

输入: [3,4,5,1,2]输出: 1
示例 2:

输入: [4,5,6,7,0,1,2]输出: 0

分析:

如果数组没有翻转,即 nums[left] <= nums[right],则 nums[left] 就是最小值,直接返回。

如果数组翻转,需要找到数组中第二部分的第一个元素:

下面讨论数组翻转的情况下,如何收缩区间以找到这个元素:

若 nums[left] <= nums[mid],说明区间 [left,mid] 连续递增,则最小元素一定不在这个区间里,可以直接排除。因此,令 left = mid+1,在 [mid+1,right] 继续查找。

否则,说明区间 [left,mid] 不连续,则最小元素一定在这个区间里。因此,令 right = mid,在 [left,mid] 继续查找
[left,right] 表示当前搜索的区间。

注意 right 更新时会被设为 mid 而不是 mid-1,因为 mid 无法被排除。


  
  1. class Solution {
  2. public int findMin(int[] nums) {
  3. if(nums.length==1)return nums[0];
  4. if(nums[0]<nums[nums.length-1])return nums[0];
  5. int left=0;
  6. int right=nums.length-1;
  7. int mid=0;
  8. while(left<right){
  9. mid=(left+right)/2;
  10. if(nums[mid]>=nums[0]){
  11. left=mid+1;
  12. }else{
  13. right=mid;
  14. }
  15. }
  16. return nums[right];
  17. }
  18. }

例3:其它条件和例2一样,例3要搜索一个给定的目标值,如果数组中存在这个目标值,则返回它的索引,否则返回 -1 。

思路:(请先自行思考)

先通过例2的方法找到分割点,再对左右某一边进行二分即可,代码请自行书写。

3.6二维数组的二分查找

编写一个高效的算法来判断 m x n 矩阵中,是否存在一个目标值。该矩阵具有如下特性:

每行中的整数从左到右按升序排列。
每行的第一个整数大于前一行的最后一个整数。
示例 1:
matrix = [
  [1,   3,  5,  7],
  [10, 11, 16, 20],
  [23, 30, 34, 50]
]
target = 3
输出: true
示例 2:
matrix = [
  [1,   3,  5,  7],
  [10, 11, 16, 20],
  [23, 30, 34, 50]
]
target = 13
输出: false

仔细观察我们发现,其实这个二维数组整体就是有序的,所以当成一维数组二分查找即可,但是要注意二维数组的操作,需要一点功底。


  
  1. class Solution {
  2. public boolean searchMatrix(int[][] matrix, int target) {
  3. if (matrix == null || matrix.length == 0) {
  4. return false;
  5. }
  6. int row = matrix.length;
  7. int col = matrix[0].length;
  8. int start = 0;
  9. int end = row * col - 1;
  10. while (start <= end) {
  11. int mid = start + (end - start) / 2;
  12. if (matrix[mid / col][mid % col] == target)return true;
  13. else if (matrix[mid / col][mid % col] > target)end = mid - 1;
  14. else start = mid + 1;
  15. }
  16. return false;
  17. }
  18. }

注意:这里有个细节:正规的二分其实都应该这么写,之前的写法可能会溢出。

3.7二叉树上二分的题目积累

我们刚才学会了一些一维二维数组的二分操作,下面我们再去其它数据结构试试看,继续养成二分的思想。

例1:先看一道简单的在二叉树上查找值。

给定一个不为空的二叉搜索树和一个目标值 target,请在该二叉搜索树中找到最接近目标值 target 的数值。

注意:

给定的目标值 target 是一个浮点数,题目保证在该二叉搜索树中只会存在一个最接近目标值的数。
示例:

输入: root = [4,2,5,1,3],目标值 target = 3.714286

    4
   / \
  2   5
 / \
1   3

输出: 4

思路:二分,当前节点比target大就往左边搜(因为右边的差距更大),当前节点比target小就往右搜(因为左边的差距更大)。
 


  
  1. /**
  2. * Definition for a binary tree node.
  3. * public class TreeNode {
  4. * int val;
  5. * TreeNode left;
  6. * TreeNode right;
  7. * TreeNode(int x) { val = x; }
  8. * }
  9. */
  10. class Solution {
  11. public int closestValue(TreeNode root, double target) {
  12. int val, closest = root.val;
  13. while (root != null) {
  14. val = root.val;
  15. closest = Math.abs(val - target) < Math.abs(closest - target) ? val : closest;
  16. root = target < root.val ? root.left : root.right;
  17. }
  18. return closest;
  19. }
  20. }

例2

给出一个完全二叉树,求出该树的节点个数。

说明:

完全二叉树的定义如下:在完全二叉树中,除了最底层节点可能没填满外,其余每层节点数都达到最大值,并且最下面一层的节点都集中在该层最左边的若干位置。若最底层为第 h 层,则该层包含 1~ 2h 个节点。

示例:

输入: 
    1
   / \
  2   3
 / \  /
4  5 6

输出: 6

思路:如果不考虑最后一层,节点数完全可以算出来,每一层分别为1,2,4,8....你会发现是2的n次方,那么总和也很好求,所以问题就在于如何知道最后一层有多少节点,换句话说,最后一层的最右边的那个节点在哪里?我们需要二分搜索。

目标:找箭头指向的结点。

我们采用二分法:

1)找到右子树的最左结点

我们知道总深度为4,如果右子树深度为3(4-1),说明图中最后的1是存在的(说明最后一行最右结点一定来自右子树),否则,如果我们修改一下如下图:

右子树深度为2!=4-1,不存在最后一行的结点。(说明最后一行最右结点一定来自左子树).

2)判断之后,如果是这种情况,我们排除了左子树,计算排除的结点个数,并对右子树(如图2)做相同的处理。

 

更新结点数(未被框起的部分,满二叉树公式+1)+1是根结点

3)对方框内重复此过程。

我们继续看右子树,发现右子树深度为1!=3-1.

说明最深层最右结点来自于左子树。所以对左子树重复上述过程

我们发现,右子树深度=1=2(整棵树深度)-1,说明最深层最右结点来自于右子树,所以对右子树重复此过程。

最终找到它。


  
  1. public class Demo {
  2. public static class Node {
  3. public int value;
  4. public Node left;
  5. public Node right;
  6. public Node(int data) {
  7. this.value = data;
  8. }
  9. }
  10. //返回结点个数
  11. public static int nodeNum(Node head) {
  12. if (head == null) {
  13. return 0;
  14. }
  15. return bs(head, 1, mostLeftLevel(head, 1));
  16. }
  17. //返回根为node,当前层数为l,总深度为h的结点个数
  18. public static int bs(Node node, int l, int h) {
  19. if (l == h) {
  20. return 1;
  21. }
  22. if (mostLeftLevel(node.right, l + 1) == h) { //右子树最深一行最左为空
  23. return (1 << (h - l)) + bs(node.right, l + 1, h); //右bs+左子树结点个数
  24. } else { //右子树最深一行最左不为空
  25. return (1 << (h - l - 1)) + bs(node.left, l + 1, h);//左bs+右子树结点个数
  26. }
  27. }
  28. //计算树的高度
  29. public static int mostLeftLevel(Node node, int level) {
  30. while (node != null) {
  31. level++;
  32. node = node.left;
  33. }
  34. return level - 1;
  35. }
  36. public static void main(String[] args) {
  37. Node head = new Node(1);
  38. head.left = new Node(2);
  39. head.right = new Node(3);
  40. head.left.left = new Node(4);
  41. head.left.right = new Node(5);
  42. head.right.left = new Node(6);
  43. System.out.println(nodeNum(head));
  44. }
  45. }

 我们再回忆第4节泛化概念时给的伪代码:

        while (范围没有缩小为0) {
            if (满足某种条件) {
                排除一半答案
            } else {
                排除另一半答案
            }
        }

其实真的就是这样的,一个终止条件(本题就是l==h),一个if判断条件(本题就是mostLeftLevel(node.right, l + 1) == h),注意细节,就可以进行各种广义的二分啦。

3.8数学中的二分

实现 int sqrt(int x) 函数。

计算并返回 x 的平方根,其中 x 是非负整数。

由于返回类型是整数,结果只保留整数的部分,小数部分将被舍去。

示例 1:

输入: 4输出: 2
示例 2:

输入: 8输出: 2
说明: 8 的平方根是 2.82842..., 
     由于返回类型是整数,小数部分将被舍去。
思路依旧是二分,格式和上文都一样。


  
  1. public class Solution {
  2. public int mySqrt(int x) {
  3. long left = 0;
  4. long right = Integer.MAX_VALUE;
  5. while (left < right) {
  6. long mid = (left + right + 1) >>> 1;
  7. long square = mid * mid;
  8. if (square > x) {
  9. right = mid - 1;
  10. } else {
  11. left = mid;
  12. }
  13. }
  14. return (int) left;
  15. }
  16. }

 

3.9最大化或最小化问题

如果在求解最大最小问题中,能比较简单的判断某个解是否满足条件(这里的简单一般指的是o(n)及以下,视具体数据范围而定),使用二分搜索答案就能很好的解决问题。

举个例子,POJ1064

题目链接:http://poj.org/problem?id=1064

题目大意:有n条绳子,长度分别为L[i]。如果从他们中切割出k条长度相同的绳子的话,这k条绳子每条最长能有多长?(答案保留小数点后两位,规定1单位长度的绳子最多可以切割成100份)。

思路:二分搜索答案,每条最短0,最大设置一个较大的数,然后开始二分答案并依次判断,判断也很简单,判断每个绳子的长度整除答案的累加和是不是大于k就好了。

 


  
  1. #include <cstdio>
  2. #include <cmath>
  3. using namespace std;
  4. const int M=10005;
  5. const double inf=200005.0;
  6. double L[M];
  7. int n,k;
  8. bool judge(double x)//判断解是否可行
  9. {
  10. int num=0;
  11. for(int i=0;i<n;i++)num+=(int)(L[i]/x);
  12. return num>=k;
  13. }
  14. void solve()//二分答案
  15. {
  16. double left=0,right=inf;
  17. for(int i=0;i<100;i++) //代替while(r>l) 避免了精度问题
  18. { //1次循环可以把区间缩小一半,100次可以达到10^(-30)的精度
  19. double mid=(left+right)/2;
  20. if(judge(mid)) left=mid;
  21. else right=mid;
  22. }
  23. printf("%.2f\n",floor(right*100)/100);
  24. }
  25. int main()
  26. {
  27. while(scanf("%d%d",&n,&k)!=-1)
  28. {
  29. for(int i=0;i<n;i++)scanf("%lf",&L[i]);
  30. solve();
  31. }
  32. }

POJ2456

题意:

有n个牛栏,选m个放进牛,相当于一条线段上有 n 个点,选取 m 个点,

使得相邻点之间的最小距离值最大

思路:和上一道题类似,二分答案,判断答案也很简单,贪心即可,遍历,遇到大于枚举的距离就放一只牛,看最后能不能放得下。

3.10总结

其实还有一些用到二分的,比如最大化平均值问题,但是由于篇幅和大部分读者水平和时间有限,暂时不写了,如果有时间还是会持续更新,

文章来源: fantianzuo.blog.csdn.net,作者:兔老大RabbitMQ,版权归原作者所有,如需转载,请联系作者。

原文链接:fantianzuo.blog.csdn.net/article/details/115066499

【版权声明】本文为华为云社区用户转载文章,如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。