zoukankan      html  css  js  c++  java
  • 二分查找法基本原理和实践

    概述

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

    在计算机科学中,二分查找算法(英语: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-/

    树林美丽、幽暗而深邃,但我有诺言尚待实现,还要奔行百里方可沉睡。 -- 罗伯特·弗罗斯特
  • 相关阅读:
    Java自学-数组 创建数组
    Java自学-控制流程 结束外部循环
    Java自学-控制流程 break
    Java自学-控制流程 for
    Java自学-控制流程 continue
    Java自学-控制流程 switch
    Java自学-控制流程 If
    计算机组成原理之流水线处理器
    计算机组成原理之算术逻辑单元
    计算机组成原理之指令系统
  • 原文地址:https://www.cnblogs.com/huansky/p/15054625.html
Copyright © 2011-2022 走看看