前端仔的“数据结构与算法”——二分查找

411 阅读15分钟

本篇介绍也是基于王争老师的课程~~
数据结构与算法之美
做一些自己的总结和思考

1、你我都懂的二分思想

它是一种查找数据的算法,技巧。整体的过程简直就是和名字一模一样,大概意思就是,“数据分一半,然后再找”。

万能例子,猜数字~!

你心里想一个0~1000的数字。
我来猜,我每次说出一个数,你告我,我的数字和目标数字的大小关系。
第一下潜意识想,好像挺难猜的,1000个数字,怎么搞也要猜个百来下吧。其实学会这个技巧,最差劲👎也只需要10次,绝对猜出来!!!。

比如,你想的数字是126

第一次猜:500 回答:你说的数字大了。
第二次猜:250 回答:你说的数字还是大了。
第三次猜:125 回答:你说的数字小了(哎呦接近了)
第四次猜:187 回答:大了
第五次猜:156 回答:大了
第六次猜:140 回答:还是大了
第七次猜:132 回答:还是大了
第八次猜:128 回答:还是大了(完了要猜到了)
第九次猜:126 

不管你随便想哪个数字,10次内绝对猜出来。
为什么会这样的?为什么1001个数字,怎么就10次呢?
其实认真看猜的过程,还有看看这文章的题目,二分二分~~~!你可以感受到的。

游戏秘籍

往中间猜!!!!!!

  • 每次都猜已知范围内的中间数就可以了
  • 如果已知范围的所有个数和偶数,比如0~9,你就猜最中间的4(0,1,2,3,4,5,6,7,8,9)
  • 如果范围内所有个数是奇数,比如5~8,你就取中间,向下取整,6

实际开发场景

比如,有1001条订单数据,按照订单的金额,从小到大排序了。我们需要寻找是否有金额是100元的数据。
订单数据存储在数组中。
常理直接遍历数组,金额如果有100,我们就返回true啦。最坏的情况下,我们可能要遍历整个数组,
时间复杂度是O(n).
但是我们用二分法就,每次只找数据的一半,直到我的数据范围只剩一个。
range:1001/2
range:1001/2/2
。。。
range:1
按照这个计算逻辑我们可以知道,最多2的10次方,因为2的10次方是1024.
所以时间复杂度是可以去到O(logn)
二分查找,效率O(logn)

代码逻辑

left指针为左区间,right为右区间,每次猜中间mid

  • mid = Math.floor((left + right)/2)
  • 循环的找已知区间的中间点,其实就是每次按照结果调整区间

2、 二分查找的实现

简单易懂,直接上代码。

查找有序数组中某个值,数组不存在重复元素。

const arr = [1,3,4,5,7,...]
function bsearch(arr,num){
	let left = 0
  let right = arr.length - 1
  
  while(left <= right){
    let mid = left + Math.floor((right - left) / 2)
    if(arr[mid] === num) return mid
    else if (arr[mid] > num){
      right = mid - 1
    }else if (arr[mid] < num){
    	left = mid + 1
    }
  }
  return -1
}
bsearch(arr, 3)

关键逻辑点

  1. 循环条件
    left <= right
    如果是left < right当[1,2],我们要找的数字是2的时候,mid = 0,arr[mid] < 2,left = 0 + 1,此时,left === right,退出了循环。就找不到下标是right的正确结果。
  2. 区间边界的更新
    left和right指针的更新
    right = mid - 1,left = mid + 1
    可能出现会写成right = mid,left = mid的情况,如果是这样当left === right,就陷入了无限循环,无法退出while。

3、 二分查找的局限

  • 二分查找依赖顺序的存储结构,数组
    因为主要的核心逻辑是要找到中间下标的元素,所以数据结构要支持随机访问元素。数组是支持随机访问的,且时间复杂度是O(1)
  • 二分查找算法需要作用于有序的数据
    如果数据无序,我们也无法通过比对大小,进行区间left、right的修改。
  • 合适的数据量
    少量的数据十位数左右,这时直接遍历其实也没差别。但是太多的数据量也会影响内存空间,因为数组结构,内存的申请就需要连续大量的空间

4、 二分查找变形

二分查找的基本实现,对你来说so easy啦。下面👇看看其他的变形逻辑也是很常见的。
下面变形的前提都是
存在重复元素的有序数组中

查找第一个出现给定值的元素

直接看代码再讲

function bsearch(arr,num){
	let left = 0
  let right = arr.length - 1
  
  while(left <= right){
    let mid = left + Math.floor((right - left) / 2)
    if (arr[mid] > num){
      right = mid - 1
    }else if (arr[mid] < num){
    	left = mid + 1
    }else {
      if(mid === 0 || arr[mid - 1] < num) return mid
      else right = mid - 1
    }
  }
  return -1
}

这个逻辑和基本的二分法不同之处就是

if (arr[mid] === num){
    if(mid === 0 || arr[mid - 1] < num) return mid
    else right = mid - 1
}

当找到相同值时,不着急返回,而是再判断一下是不是第一个元素,或者之前的元素是不是比给定值小,如果是的话,证明就是第一个出现的元素。

查找最后一个出现给定值的元素

function bsearch(arr,num){
	let left = 0
  let right = arr.length - 1
  
  while(left <= right){
    let mid = left + Math.floor((right - left) / 2)
    if (arr[mid] > num){
      right = mid - 1
    }else if (arr[mid] < num){
    	left = mid + 1
    }else {
      if(mid === arr.length - 1 || arr[mid + 1] > num) return mid
      else left = mid + 1
    }
  }
  return -1
}

查找第一个大于等于给定值的元素

function bsearch(arr,num){
	let left = 0
  let right = arr.length - 1
  
  while(left <= right){
    let mid = left + Math.floor((right - left) / 2)
    if (arr[mid] >= num){
      if(mid === 0 || arr[mid - 1] < num){
      	return mid  
      }
      right = mid - 1
    }else if (arr[mid] < num){
    	left = mid + 1
    }
  }
  return -1
}

leetcode实战

凡是遇到有序的数据,可支持随机下标访问的,查找某个元素。这时候就可以想到二分法。

凡是遇到有序的数据,可支持随机下标访问的,查找某个元素。这时候就可以想到二分法。

凡是遇到有序的数据,可支持随机下标访问的,查找某个元素。这时候就可以想到二分法。

69. x 的平方根👈

先看一到简单题,坑点也不少。

问题:

实现 int sqrt(int x) 函数。 计算并返回 x 的平方根,其中 x 是非负整数。 由于返回类型是整数,结果只保留整数的部分,小数部分将被舍去。

示例:

输入: 4
输出: 2

输入: 8
输出: 2
说明: 8 的平方根是 2.82842..., 
     由于返回类型是整数,小数部分将被舍去。

思路:

拆解一下题意

  • 找某个数字N
  • 因为查找的值小数部分省略,则N*N <= X
  • X >= 0

数字,天然的就是一个有序的数据,那不就是从0~X,找出一个数字 N*N <= X
先从简单的来。
如果是找N*N === X,找到返回,找不到返回 -1,就是最基础的二分法

var mySqrt = function (x) {
    let [left, right] = [0, x]
    while (left <= right) {
       let mid = Math.floor((right - left) / 2) + left
        if (mid * mid < x) {
            left = mid + 1
        } else if (mid * mid > x) {
            right = mid - 1
        } else {
            return mid
        }
    }
    return -1
};

但是如果没找到我们也要返回一个N*N <= X的N值。
那想想,退出循环前的最后逻辑肯定是left = mid + 1right = mid - 1,关键点都是mid。
如果mid * mid > X, 退出循环。则最接近的值肯定是 mid - 1。
如果mid * mid < X, left > right,超过区间退出循环。那只能是mid了。

so,👇代码:

var mySqrt = function (x) {
    // 找一个数字n, n*n最接近x,但不能大于
    let [left, right] = [0, x]
    let mid
    while (left <= right) {
        mid = Math.floor((right - left) / 2) + left
        if (mid * mid < x) {
            left = mid + 1
        } else if (mid * mid > x) {
            right = mid - 1
        } else {
            return mid
        }
    }
    return mid * mid > x ? mid - 1 : mid
};

81. 搜索旋转排序数组 II👈

问题:

已知存在一个按非降序排列的整数数组 nums ,数组中的值不必互不相同。 ​
在传递给函数之前,nums 在预先未知的某个下标 k(0 <= k < nums.length)上进行了 旋转 ,使数组变为 [nums[k], nums[k+1], ..., nums[n-1], nums[0], nums[1], ..., nums[k-1]](下标 从 0 开始 计数)。例如, [0,1,2,4,4,4,5,6,6,7] 在下标 5 处经旋转后可能变为 [4,5,6,6,7,0,1,2,4,4] 。 ​
给你 旋转后 的数组 nums 和一个整数 target ,请你编写一个函数来判断给定的目标值是否存在于数组中。如果 nums 中存在这个目标值 target ,则返回 true ,否则返回 false 。

示例:

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

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

提示:
  1 <= nums.length <= 5000
  -104 <= nums[i] <= 104
  题目数据保证 nums 在预先未知的某个下标上进行了旋转
  -104 <= target <= 104

思路:

一看有序数组,找元素。二分法呀~~!往下看看,这是其中一种解题思路
但是题目那么长,还是有下面两个疑惑的点的

  • 题目将数组在某个点旋转了。这样数组就不是一个完整的有序数组了。
  • 会有重复元素,但是题目却直接让我们找给定值是否存在?迷惑行为。这根本也不会影响二分法的逻辑,最基本二分法就可以实现。如果是重复元素,找第一个出现的,可能才会涉及二分法的变形。

那我们就想办法解决第一点疑惑就好了。
nums = [2,5,6,0,0,1,2]
看看这个示例数组,其实如果我们先用二分法分成两个区间会变成什么?
[2,5,6,0] [0,1,2],哎,这里面至少有一个数组是有序的,因为题目只是在某一个下标进行了“旋转”。这样其中一个有序的数组,我们就可以用二分法解决了。再看看左边的区间怎么搞。
那还不是一样,在二分呗。
[2,5] [6,0]直到我们分割到区间只有一个两个元素时,直接判断返回就好。这里也回归复习一下递归的思想。
思路总计一下

  1. 将数组二分
  2. 判断其中一组是有序的,用基础二分法直接搜寻元素返回结果
  3. 另一组不是有序的,如果长度很小,一到两位,可以直接进行判断返回。如果长度较长,重复第一步逻辑。

代码:

var search = function (nums, target) {
  // 二分数组,找出一段有序,一段无序
    function mySplit(l, r) {
        if(l > r)return false
        let mid = Math.floor(l + (r - l) / 2)
        if(nums[mid] === target) return true
      // 尾部比头部大,肯定是有序区间
        if (nums[l] < nums[mid]) {
          // 有序
            const res = find(l, mid - 1)
            if (res) return res
        } else {
          // 无序,递归
            const res = mySplit(l, mid - 1)
            if (res) return res
        }
        if (nums[mid + 1] < nums[r]) {
            const res = find(mid + 1, r)
            if (res) return res
        } else {
            const res = mySplit(mid + 1, r)
            if (res) return res
        }
        return false
    }
    function find(start, end) {
        if (nums[start] > target || nums[end] < target) return false
        while (start <= end) {
            let mid2 = Math.floor(start + (end - start) / 2)
            if (nums[mid2] === target) {
                return true
            }
            if (nums[mid2] > target) end = mid2 - 1
            if (nums[mid2] < target) start = mid2 + 1
        }
        return false
    }
  // 接收首尾下标进行分割
    return mySplit(0, nums.length - 1)
};

74. 搜索二维矩阵

问题:

编写一个高效的算法来判断 m x n 矩阵中,是否存在一个目标值。该矩阵具有如下特性: ​
每行中的整数从左到右按升序排列。 每行的第一个整数大于前一行的最后一个整数。

示例:

image.png

输入:matrix = [[1,3,5,7],[10,11,16,20],[23,30,34,60]], target = 3 输出:true

输入:matrix = [[1,3,5,7],[10,11,16,20],[23,30,34,60]], target = 13 输出:false

思路:

有序数组,找元素,做了几题了。这好不容易。
但是是个二维数组,这里有两种方式解题。

方式一

因为题意,每一行第一个数字大于上一行最后一个数字。这样直接将每行首尾链接,就是一个完整有序的数据了。然后直接基础二分法求解。

方式二

第一列,肯定是这一行最小的数字。所以我们先找第一列,找的最后一个小于等于目标值的那个。二分查找的变形,这没问题吧。然后目标值只可能出现在那一行,对那一行的数据进行二分查找就好了。
比如:我们要找17.
先对第一列找到最后一个小于等于目标值的那行。那就是第二行[10,11,16,20],然后二分查找。so easy

代码:

方式一
其实我们也不用真的创建一个数组,将首尾行拼接起来。其实我们只要能根据数组下标访问到对应的元素就好了。
mn的矩阵,所有元素的区间是0~mn-1.

  • 对下标除每行的个数,向下取整就知道行数啦
  • 对下标取余,就知道当前行的第几个元素
var searchMatrix = function (matrix, target) {
    // 将每一行拼接起来(下一行开头拼接上一行结尾),形成有序数组
    function bsearch(left, right) {
        while (left <= right) {
            let mid = left + Math.floor((right - left) / 2)
            // 注意这里mid对应的元素怎么取的
            let x = Math.floor(mid / matrix[0].length)
            let y = mid % matrix[0].length
            let element = matrix[x][y]
            // -------------
            if (element === target) return true
            if (element > target) {
                right = mid - 1
            }
            if (element < target) {
                left = mid + 1
            }
        }
        return false
    }
  // 传递
    return bsearch(0, matrix.length * matrix[0].length -1)
};

方式二

var searchMatrix = function (matrix, target) {
    function bsearch(left, right, sign, index) {
        while (left <= right) {
            let mid = left + Math.floor((right - left) / 2)
            let element = sign === 'row' ? matrix[index][mid] : matrix[mid][index]
            if (element === target) return true
            if (element > target) {
                right = mid - 1
            }
            if (element < target) {
                if (sign === 'column' && (mid === matrix.length - 1 || matrix[mid + 1][0] > target)) {
                    return mid
                }
                left = mid + 1
            }
        }
        return false
    }
    // 先找出第一列,最后一个小于等于指定元素的值的index
    const columnIndex = bsearch(0, matrix.length - 1, 'column', 0)
    if (typeof columnIndex === 'boolean') return columnIndex
  	// 然后找到那一行,对那一行再进行二分查找
    if (columnIndex > -1) {
        return bsearch(0, matrix[columnIndex].length - 1, 'row', columnIndex)
    } else return false
};

4. 寻找两个正序数组的中位数

重点题来啦,hard还是可以尝试一下的。

问题:

给定两个大小分别为 m 和 n 的正序(从小到大)数组 nums1 和 nums2。请你找出并返回这两个正序数组的 中位数 。 ​
提示: nums1.length == m nums2.length == n 0 <= m <= 1000 0 <= n <= 1000 1 <= m + n <= 2000 -106 <= nums1[i], nums2[i] <= 106   进阶:你能设计一个时间复杂度为 O(log (m+n)) 的算法解决此问题吗?

示例:

输入:nums1 = [1,3], nums2 = [2]
输出:2.00000
解释:合并数组 = [1,2,3] ,中位数 2

输入:nums1 = [1,2], nums2 = [3,4]
输出:2.50000
解释:合并数组 = [1,2,3,4] ,中位数 (2 + 3) / 2 = 2.5

输入:nums1 = [0,0], nums2 = [0,0]
输出:0.00000

思路:

我们要找中位数,其实就是取中间数组下标的元素就好了。
只不过这里有两个有序数组,没有合成一个。
题目要求O(log (m+n)) 的算法,想想也不多。堆排序,归并排序,
堆排序明显不适合,归并排序呢?其实这里也不用归并排序了,直接遍历数组合并。严格来说时间复杂度O(m+n)了。不太符合题目要求。

符合O(log (m+n))的算法,二分肯定是可以考虑的。可以利用二分的思想,去找元素。直到找到需要的下标元素为止。如两个数字长度是m+n,中间数是第k个元素。
则我们就要找到最小的第k个元素,最小的第k个元素!

二分思想加入

我们每次在每个数组中找一半,则第一次我们找第k个最小的元素就好了。
那我们平摊到两个数组,每个数组找k/2个。则A[k/2 - 1]就是A数组找k/2个元素中最大的,因为数组有序嘛。
B[k/2 -1]就是B数组找出来最大的。
那两个数组都找出来了。怎么搞呢,我们怎么确定哪些数字是最小的0~k个呢?
直接比他们的最大值就好了
两个最大的对比。A[k/2 - 1]和B[k/2 -1]对比,

  • 如果a>=b,
    那肯定B[0]~B[k/2 -1]这几个数字肯定在范围内。那B[0]~B[k/2 -1]这几个数字找到了。目前找到k/2个元素,其中最大的是B[k/2 -1]。

  • 如果b>a,
    那就是A[0]~A[k/2 -1]这几个数字找到,最大的是A[k/2 -1]。

接着标记一下A数组找到了 k/2个或B数组找到了K/2个。下次直接从标记处找
下一轮继续找,我们要找的数字还剩几个?k-k/2 = k/2。
就继续平摊到两个数组,每个需要找k/4个元素出来,拿最大的比较。
直到把第k个元素或者k-1个元素找出来。我们就知道中间数了。

画图,从头走一遍!!

前提是两个数组是有序,升序的哈
A:[1,2,3,4,5] B:[2,3,4]image.png

  1. 一共有8个元素,中间数是第4、第5个数。那k=5

  2. 两个数组分别找出k/2 ~= 2个元素对比
    image.png

  3. 比较找出元素的最后一个,3>2,所以A找到的两个元素肯定没问题,它们都是排在最小的第k个元素前面。这时候标记一下a1=2,再记录一下当前找到最大的元素是pastMax=2,因为每次找到的最大元素,你要对比和已收集的最大元素的关系,才知道谁排在最后,这样已收集元素等于k或k-1时,你就知道元素的具体值了。
    image.png

  4. 继续下一轮,我们需要找k-2=3,则平摊到两个数组,每个数组需要往后找一个元素。
    image.png

  5. 3>2,那B数组找到一个小的元素,b1=1,之前maxpast=2,现在找到的元素也是2,那谁排前后无所谓的。此时已收集3个数字[1,2,2]
    image.png

  6. 下一轮,我们需要找k-3=2,则每个数组继续找一个元素。
    image.png

  7. 3>=3,则我们选择把其中一个数组的元素加入已收集,就加B好了。则b1=2,pastMax易主为3。此时,我们已经找到4个元素拉!!,第4个元素小的元素就是刚才的pastMax,它是目前找到4个元素最大的,那当然是第4个小的元素了。
    image.png

  8. 下一轮,k-2-2=1。这种情况下,应该也是每个数组找一个出来对比,小的就是我们要找到的元素。但是特殊情况其中一个数组已经搜寻完毕。我们直接取A数组的下一个元素就好了。那就是3

  9. 👌 return (3+3)/2 = 3

var findMedianSortedArrays = function (nums1, nums2) {
    let finnal = Math.floor((nums1.length + nums2.length) / 2) + 1
    let point1 = 0
    let point2 = 0
    let pastMax = -Infinity
    const res = []
    while (finnal > 0) {
        let half = Math.max(Math.floor(finnal / 2), 1)
        let num1, num2
        // 边界情况,需要找的往后找到数字长度超过数组长度了
        let more1 = Math.min(half, nums1.length - point1)
        num1 = nums1[point1 + more1 - 1]
        let more2 = Math.min(half, nums2.length - point2)
        num2 = nums2[point2 + more2 - 1]
      	// 边界情况,其中一个数组已经找完了。
        if (nums1.length === point1) {
          	// ~~~这里比较特别,如果其中一个找完了,也不能直接取另一个数组的元素,要和pastMax对比
						// 谁才是目前找到元素最大的
            if (finnal > 2 && nums2[point2 + finnal - 2] >= pastMax) {
                res.push(nums2[point2 + finnal - 2], nums2[point2 + finnal - 1])
                break
            }
            num1 = Infinity
        }
        if (nums2.length === point2) {
            if (finnal > 2 && nums1[point1 + finnal - 2] >= pastMax) {
                res.push(nums1[point1 + finnal - 2], nums1[point1 + finnal - 1])
                break
            }
            num2 = Infinity 
        }
        if (num1 >= num2) {
            point2 += more2
            finnal -= more2
            pastMax = Math.max(num2, pastMax)
        } else {
            point1 += more1
            finnal -= more1
            pastMax = Math.max(num1, pastMax)
        }
        if (finnal === 1 || finnal === 0) {
            res.push(pastMax)
        }
    }
    if ((nums1.length + nums2.length) % 2 === 0) {
        return (res[0] + res[1]) / 2
    } else {
        return res[1] || res[0]
    }
};