二分查找法基本原理和实践

概述

前面算法系列文章有写过分治算法基本原理和实践,对于分治算法主要是理解递归的过程。二分法是分治算法的一种,相比分治算法会简单很多,因为少了递归的存在。

在计算机科学中,二分查找算法(英语:binary search algorithm),也称折半搜索算法(英语:half-interval search algorithm)、对数搜索算法(英语:logarithmic search algorithm)[2],是一种在有序数组中查找某一特定元素的搜索算法。搜索过程从数组的中间元素开始,如果中间元素正好是要查找的元素,则搜索过程结束;如果某一特定元素大于或者小于中间元素,则在数组大于或小于中间元素的那一半中查找,而且跟开始一样从中间元素开始比较。如果在某一步骤数组为空,则代表找不到。这种搜索算法每一次比较都使搜索范围缩小一半。

二分查找算法在情况下的复杂度是对数时间。二分查找算法使用常数空间,无论对任何大小的输入数据,算法使用的空间都是一样的。除非输入数据数量很少,否则二分查找算法比线性搜索更快,但数组必须事先被排序。尽管特定的、为了快速搜索而设计的数据结构更有效(比如哈希表),二分查找算法应用面更广。

二分查找算法有许多中变种。比如分散层叠可以提升在多个数组中对同一个数值的搜索。分散层叠有效的解决了计算几何学和其他领域的许多搜索问题。指数搜索将二分查找算法拓宽到无边界的列表。二叉搜索树和B树数据结构就是基于二分查找算法的。

入门 demo 

对二分法的概念了解后,下面来看一道示例:

153. 寻找旋转排序数组中的最小值

已知一个长度为 n 的数组,预先按照升序排列,经由 1 到 n 次 旋转 后,得到输入数组。例如,原数组 nums = [0,1,2,4,5,6,7] 在变化后可能得到:
若旋转 4 次,则可以得到 [4,5,6,7,0,1,2]
若旋转 7 次,则可以得到 [0,1,2,4,5,6,7]
注意,数组 [a[0], a[1], a[2], ..., a[n-1]] 旋转一次 的结果为数组 [a[n-1], a[0], a[1], a[2], ..., a[n-2]] 。

给你一个元素值 互不相同 的数组 nums ,它原来是一个升序排列的数组,并按上述情形进行了多次旋转。请你找出并返回数组中的最小元素 。

示例 1:

输入:nums = [3,4,5,1,2]
输出:1
解释:原数组为 [1,2,3,4,5] ,旋转 3 次得到输入数组。

示例 2:

输入:nums = [4,5,6,7,0,1,2]
输出:0
解释:原数组为 [0,1,2,4,5,6,7] ,旋转 4 次得到输入数组。

示例 3:

输入:nums = [11,13,15,17]

输出:11

解释:原数组为 [11,13,15,17] ,旋转 4 次得到输入数组。 

提示:

n == nums.length
1 <= n <= 5000
-5000 <= nums[i] <= 5000
nums 中的所有整数 互不相同
nums 原来是一个升序排序的数组,并进行了 1 至 n 次旋转

下面来看一下我写的一个失败版的答案,此时的我还没入门二分法:

class Solution {
    public int findMin(int[] nums) {
        int left = 0;
        int right = nums.length-1;
        while (left<=right) {
            int middle = left + (right -left)/2;
            if (nums[middle] > nums[left]) {
                left = middle + 1;
            }else  {
                right = right-1;
            }
        }
        return nums[left];
    }
}

输入:[4,5,6,7,8,9,10,0,1,2,3]

输出:10

结果:0

可以看到结果是不对,那这里的问题是什么呢?都说失败是成功之母,我们只有分析清楚为啥我们的解法会存在问题,才能更好地明白二分法的精髓。

先从答案分析,这里输出 10,为啥会是 10。

看上面这张图,代码逻辑写的是 middle > left,那么  left = middle +1; 这个逻辑这么写是没有问题的。

接着看,当不满足  middle > left,说明 middle 处于最小值的右半部分,这时候我们让 right--。那如果 right 就是最小值呢,这时候就会错过最小值。

还有如果 middle 是最大值呢?那么 left= middle +1 就是最小值,此时你再去计算 middle ,就直接把最小值错过了。比如输入数组:[5,6,7,8,9,0,1,2,3,4];

还要考虑一种特殊情况,如果此时只有两个元素了,有两种情况 [1,2],[2,1] ,这时候如果按照 right--,就会直接取到第一个元素。所以在 middle 和 left 相等的时候也要在做额外的判断。

完整版通过代码如下:

class Solution {
    public int findMin(int[] nums) {
        int left = 0;
        int right = nums.length-1;
        while (left<right) {
            int middle = left + (right -left)/2;
            if (nums[middle] > nums[left] && nums[middle] > nums[right]) {
                left = middle +1;
        // 说明最小值就在最右边,此时处于只有两个元素的时候 }
else if(middle == left && nums[left] > nums[right]) { left = right; } else { right = right-1; } } return nums[left]; } }

当你看到这段代码后,你懵逼了,这还是二分法嘛,分析下来这么复杂。

那我们来看下官方给的代码:

class Solution {
    public int findMin(int[] nums) {
        int low = 0;
        int high = nums.length - 1;
        while (low < high) {
            int pivot = low + (high - low) / 2;
       // 最小值一定是在和 high 在一个区间内的,所以这里要判断 pivot 和 high 的大小关系,不能去判断和 low 的关系
if (nums[pivot] < nums[high]) { high = pivot; } else { low = pivot + 1; } } return nums[low]; } } 

是不是觉得官方代码简洁易懂。

那为啥这两个解法的代码会差这么多,答案在于 middle 到底是应该和 left 比较,还是应该和 right 比较。

这也说明了方向的选择的重要性。可是我们应该怎么选择呢。这个主要是在分析问题的时候要想清楚。个人觉得也可以这么理解:

本题是找最小值的。从最小值到最右端,这其实就是单调递增的,因此我们只要关注右半部分,抛弃左半部分就好。

那么本题错误原因就是跟左边进行比较,你再怎么找,最后得出值都不在这一部分上,就导致你得添加很多额外的逻辑来确保可以找到值。

PS:对于二分法要时刻关注只有两个元素的情况。这时候 middle = left。这时候注意 left 和 right 之间的关系。

通过这道题目相信大家已经对二分法有一定的认识了。

二分法思想

二分查找的思想就一个:逐渐缩小搜索区间。 如下图所示,它像极了「双指针」算法,left 和 right 向中间走,直到它们重合在一起:

根据看到的中间位置的元素的值 nums[mid] 可以把待搜索区间分为三个部分:

  • 情况 1:如果 nums[mid] = target,这时候我们直接返回即可。
  • 情况 2: target 在 mid 左半部分 [left..mid - 1],此时分别设置 right = mid - 1 ;
  • 情况 3: target 在 mid 右半部分 [mid+1..right],此时分别设置  left = mid + 1。

这样就可以获得二分法基本模板:

class Solution {
    public int search(int[] nums, int target) {
        int left = 0;
        int right = nums.length - 1; // 确保 left 和 right 都在数组可取范围内
        while (left <= right) { // < 还是 <= 按照自己的习惯即可
            int mid = left + (right -left)/2;
            if (nums[mid] == target) {  // 找到结果就返回
                return mid;
            }else if(nums[mid] > target)  {
                right = mid-1;
            } else {
                left = mid +1;
            }
        }
     // 退出循环就说明没找到
return -1; } }

虽然我们看到的写法有很多,但思想就这一个;为什么总是有朋友觉得二分难?因为有很多二分的写法,虽然都对,但是对于新手朋友们来说有一定干扰,因为不同的写法其实对应着不同的前提和应用场景,比起套用模板,审题、练习和思考更重要。「二分查找」就几行代码,完全不需要记忆,也不应该用记忆的方式解题.

下面解释一些细节:

1、模板的结束条件是 left <= right ,也就是结果一定是在 while 里面找到的。否则就是没找到。

 

2、有些学习资料上说 while (left < right) 表示区间是 [left..rihgt) ,为什么你这里是 [left..rihgt]?

区间的右端点到底是开还是闭,完全由编写代码的人决定,不需要固定。主要还是看你 left 和 right 的取值。 如果 right = nums.length ; 那么其实 right 这个位置是取不到的,也就是开区间了。所以开闭就是看点位能不能取到。

3、有些学习资料给出了三种模板,例如「力扣」推出的 LeetBook 之 「二分查找专题」,应该如何看待它们?

回答:三种模板其实区别仅在于退出循环的时候,区间 [left..right] 里有几个数。

  • while (left <= right) :退出循环的时候,right 在左,left 在右,区间为空区间,所以要讨论返回 left 和 right;

  • while (left < right) :退出循环的时候,left 与 right 重合,区间里只有一个元素,这一点是我们很喜欢的;

  • while (left + 1 < right) :退出循环的时候,left 在左,right 在右,区间里有 2 个元素,需要编写专门的逻辑。这种写法在设置 left 和 right 的时候不需要加 1 和减 1。

看似简化了思考的难度,但实际上屏蔽了我们应该且完全可以分析清楚的细节。退出循环以后一定要讨论返回哪一个,也增加了编码的难度。

我个人的经验是:

  • while (left <= right) 用在要找的数的性质简单的时候,把区间分成三个部分,在循环体内就可以返回;

  • while (left < right) 用在要找的数的性质复杂的时候,把区间分成两个部分,在退出循环以后才可以返回;

  • 完全不用 while (left + 1 < right) ,理由是不会使得问题变得更简单,反而很累赘。

很多题目在二分法的基础上有变化,我们要学会灵活变化。还要理解题意。

示例:

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

请必须使用时间复杂度为 O(log n) 的算法。

示例 1:

输入: nums = [1,3,5,6], target = 5
输出: 2

示例 2:

输入: nums = [1,3,5,6], target = 2
输出: 1

示例 3:

输入: nums = [1,3,5,6], target = 7
输出: 4

示例 4:

输入: nums = [1,3,5,6], target = 0
输出: 0

示例 5:

输入: nums = [1], target = 0
输出: 0

提示:

  • 1 <= nums.length <= 104
  • -104 <= nums[i] <= 104
  • nums 为无重复元素的升序排列数组
  • -104 <= target <= 104
class Solution {
    public int searchInsert(int[] nums, int target) {
        int left =0;
        int right = nums.length -1;
        while (left<=right) {
            int mid = left + (right-left)/2;
            if (nums[mid] == target) {
                return mid;
            }
            if (nums[mid]>target) {
                right  = mid-1;
            }else {
                left = mid+1;
            }
        }
        // 没找到,那么 left 就是它所处的位置
        return left;
    }
}

注意一点:二分法只是用于有序数组,如果是无序的,此时是无法确定边界的,这时候我们就需要自己创造条件,找到数组的有序部分。

比如下面两道,大家可以自己找二分法题目去练习。

33. 搜索旋转排序数组

81. 搜索旋转排序数组 II

关于二分法的理论就讲到这里了,剩下的就是靠大家多多练习了。

 

算法系列文章:

滑动窗口算法基本原理与实践

广度优先搜索原理与实践

深度优先搜索原理与实践

双指针算法基本原理和实践

分治算法基本原理和实践

动态规划算法原理与实践

算法笔记

 

参考文章

https://leetcode-cn.com/problems/search-insert-position/solution/te-bie-hao-yong-de-er-fen-cha-fa-fa-mo-ban-python-/

 

posted @ 2021-07-25 23:20  huansky  阅读(3644)  评论(0编辑  收藏  举报