二分查找-模板二

9/9/2022 二分查找

# 模板二

模板二应用范围更广,应用于夹逼寻找满足条件的一个值,比如在一个有序数组中,寻找一个满足某条件(比如大于数组中的某一个值)的值,或在一个无重复数的数组中寻找到一个峰值(可能有多个峰值)。

适合解决区间内符合条件的某个值,而不是求解特定值,即使用夹逼法一步步缩小区间,最终缩小到一个点,就是答案(对于题目有确定答案的情况,如果不一定有答案则需要最后对该点进行判断)

示例:

在一个如上图所示的非递减区间内,其中A点与B点数值相同,通过模板二夹逼法可以找到A点和C点,但是找不到B点,具体可以见LC 34题分析。

# 模板代码

# 代码

// 官方模板,但这个模板并不常用,模板二的强大之处在于夹逼寻找结果
function binarySearch(nums, target) {
  if (nums.length == 0) return -1;

  // 左闭右开区间 [left, right)
  // while循环内会少一次 left = right情况,需要在while循环后判断
  let left = 0;
  let right = nums.length;
  while (left < right) {
    // Prevent (left + right) overflow
    // let mid = left + parseInt((right - left) / 2);
    const mid = (left + right) >>> 1;
    if (nums[mid] == target) return mid;
    if (nums[mid] < target) {
      left = mid + 1;
    } else {
      // 注意这里,保持 [left ,rigt) 左开右闭区间
      right = mid;
    }
  }

  // 循环结束条件 left == right,此时如果 left没有遍历到 nums.length,则需要判断 nums[left] ==  target的情况
  // Post-processing:
  // End Condition: left == right
  if (left != nums.length && nums[left] == target) return left;
  return -1;
}

// 夹逼模板,模板二代码更常用该模板
function binarySearch(nums) {
  let left = 0;
  let right = nums.length - 1;
  while (left < right) {
    // 循环直至区间左右端点相同
    const mid = (left + right) >>> 1; // 防止计算时溢出
    // nums[mid] 与某个值比较得出结果,比如调用外部函数、与nums[mid+1]、nums[right]等比较
    if (compare(nums[mid])) {
      right = mid; // mid可能为答案,需要留在答案区间,答案区间为 [left, mid]
    } else {
      left = mid + 1; // mid 肯定不是答案,需要从答案区间移除,移除后答案区间为 [mid+1, right]
    }
  }
  // 此时有 left == right,区间缩为一个点,如果题目确定有一个答案,此区间点即为答案;如果没有一个确定的答案,需要判断
  return left;
}

# 区分语法

  • 初始条件:left = 0, right = length-1(并不是绝对,根据题目变化)
  • 循环判断条件:left < right,这是核心,不断缩小区间,最后的那个点可能是答案
  • 终止:left == right,如果题目确定有一个答案,left就是答案
  • 向左查找:right = mid此时mid可能是答案,需要留在答案区间
  • 向右查找:left = mid+1此时mid确定不是答案,需要从答案区间移除

注意:这里不可能是 left = mid; right = mid-1;,由于mid为向下去中,考虑[0,1]区间,mid = 0,如果此时 nums[mid] < target,mid会一直停留在left。即对于两个数的区间,mid == left,此时如果left不变化,则会陷入无限循环中。

# 例题

# LC 278. 第一个错误的版本 (opens new window)

Q:

你是产品经理,目前正在带领一个团队开发新的产品。不幸的是,你的产品的最新版本没有通过质量检测。由于每个版本都是基于之前的版本开发的,所以错误的版本之后的所有版本都是错的。

假设你有 n 个版本 [1, 2, ..., n],你想找出导致之后所有版本出错的第一个错误的版本。

你可以通过调用 bool isBadVersion(version) 接口来判断版本号 version 是否在单元测试中出错。实现一个函数来查找第一个错误的版本。你应该尽量减少对调用 API 的次数。

来源:力扣(LeetCode) 链接:https://leetcode.cn/problems/first-bad-version 著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。

A:本题目是经典的模板二应用场景。题目一定有一个错误版本的答案,因此可用模板二夹逼去寻找答案。

/**
 * Definition for isBadVersion()
 * 
 * @param {integer} version number
 * @return {boolean} whether the version is bad
 * isBadVersion = function(version) {
 *     ...
 * };
 */

/**
 * @param {function} isBadVersion()
 * @return {function}
 */
var solution = function (isBadVersion) {
    /**
     * @param {integer} n Total versions
     * @return {integer} The first bad version
     */
    return function (n) {
        let left = 1;
        let right = n;
      	// 肯定有一个正确答案,答案区间从 [left, right] 缩小为一个点 [left, left] 时,left 即为答案
        while (left < right) {
            const mid = (left + right) >>> 1;
            if (isBadVersion(mid)) {
              	// mid 可能是答案,需要留在答案区间内
                right = mid;
            } else {
              	// mid 可以确定不是正确的答案,需要排除在答案区间外
                left = mid + 1;
            }
        }
        return left;
    };
};

用一个例子来理解,如下图所示,共有6个版本,从第4个版本开始为错误的版本,现在就要通过二分法寻找出第4个版本。

  • 第一步,mid = (1 + 6) / 2 = 3,第3个版本为正确的版本,即 mid = 3 肯定不是答案,因此 left = mid + 1,将 mid 排除答案区间,此时可能的答案区间为 [mid + 1, right],即 [4, 6]
  • 第二步,mid = (4 + 6 ) / 2 = 5,第5个版本为错误的版本,即 mid = 5 可能是答案,因此 right = mid,需要将mid留在答案区间,此时答案区间为 [left, mid],即 [4, 5]。(注意,为什么说 mid = 5 可能是答案,因为 区间还有两个数,left 此时等于4,还没有验证过,不能将left排除,从图中也可以很明显的看出,mid = 5 不是正确答案)
  • 第三步,mid = (4 + 5) / 2 = 4,第4个版本为错误的版本,即 mid = 4 可能是正确的答案,因此 right = mid = 4,需要将 mid 留在答案区间,此时答案区间为 [left, mid],即 [4, 4]
  • 第四补,此时答案区间为一个点,不满足 left < right 条件,因此这个点即为正确答案

综上可以发现,模板二是通过一步步缩小答案区间,夹逼寻找答案。需要注意的是:

  1. 为什么 left = mid + 1 ?因为此时 可以确定 mid 不是答案,需要将 mid 移除答案区间
  2. 为什么 right = mid?因为此时 mid可能是答案,需要留在答案区间

# LC 162. 寻找峰值 (opens new window)

Q:

峰值元素是指其值严格大于左右相邻值的元素。

给你一个整数数组 nums,找到峰值元素并返回其索引。数组可能包含多个峰值,在这种情况下,返回 任何一个峰值 所在位置即可。

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

你必须实现时间复杂度为 O(log n) 的算法来解决此问题。

来源:力扣(LeetCode) 链接:https://leetcode.cn/problems/find-peak-element 著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。

A:由于 nums[-1] = nums[n] = -∞,题目一定会有一个正确答案。采用爬坡法,即通过比较 mid 与 mid +1的值,一步步爬坡,最终找到一个坡顶元素就是答案

/**
 * @param {number[]} nums
 * @return {number}
 */
var findPeakElement = function (nums) {
    let left = 0;
    let right = nums.length - 1;
    while (left < right) {
        const mid = (left + right) >>> 1;
      	// 爬坡法,通过比较 mid 与 mid+1 的值,一步步爬坡,最终爬到坡顶
        if (nums[mid] < nums[mid + 1]) {
          	// 此时可以确定,mid 一定不是坡顶,因此把 mid 从答案区间排除
            left = mid + 1;
        } else {
          	// 此时 mid 可能是答案,需要把 mid 留在答案区间
            right = mid;
        }
    }
  	
  	// 任意一个坡顶即为答案,元素区间缩小至 [left, left],
    return left;
};

# LC 153. 寻找旋转排序数组中的最小值 (opens new window)

Q:

已知一个长度为 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 ,它原来是一个升序排列的数组,并按上述情形进行了多次旋转。请你找出并返回数组中的 最小元素 。

你必须设计一个时间复杂度为 O(log n) 的算法解决此问题。

来源:力扣(LeetCode) 链接:https://leetcode.cn/problems/find-minimum-in-rotated-sorted-array 著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。

A:这题与33. 搜索旋转排序数组有点像,都为旋转排序数组,但本题求最小值,并不是明确的某个值的索引。先画出折线图,当mid在左或者右时,nums[left] 始终小于等于nums[mid],没有变化;但是mid在左时,nums[mid] > nums[right];当mid在右时 nums[mid] <= num[right],由此产生了二段性。使用二分法模板二进行夹逼求值。

/**
 * @param {number[]} nums
 * @return {number}
 */
var findMin = function (nums) {
    let left = 0;
    let right = nums.length - 1;
    while (left < right) {
        const mid = (left + right) >>> 1;
      	// 这里使用 < 或 <= 都可以
        if (nums[mid] <= nums[right]) {
          	// mid 线在左边时,此时最小值一定在左侧
          	// 此时 mid 可能为答案,答案区间往左收敛,mid留在答案区间内
            right = mid;
        } else {
          	// mid 线在右边时,此时最小值一定在右侧
          	// 此时mid 肯定不是答案,答案区间往右收敛,将mid移除答案区间
            left = mid + 1
        }
    }
    return nums[left];
};

# LC 34. 在排序数组中查找元素的第一个和最后一个位置 (opens new window)

Q:

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

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

你必须设计并实现时间复杂度为 O(log n) 的算法解决此问题。

来源:力扣(LeetCode) 链接:https://leetcode.cn/problems/find-first-and-last-position-of-element-in-sorted-array 著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。

A:这一题虽然在力扣专题里放到了模板三,但其实用模板二更合适。先画一个折线图,用一个折线图来理解,这个折线图会更容易理解模板二的原理,即通过夹逼法使区间缩小到一个点,然后对该点判断是否是答案。

对于上图中的非递减数组,寻找A点和B点。使用模板二夹逼寻找可以找到A点和C点,但是找不到B点。

寻找A点:


// 非递减数组nums, 寻找target,寻找A点
{
      let left = 0;
      let right = nums.length - 1;
      while (left < right) {
          const mid = (left + right) >>> 1;
        	// 区间不断缩小,如果 target存在,当区间缩小到一个点时 [left, left],则 left 点一定是A点,这块可以用图中示例来一步步计算下方便理解
        	// 注意,这里是用的 < ,即 left 一步步逼近A点
          if (nums[mid] < target) {
            	// 此时mid点一定不是 target,因此把 mid 从可能的答案区间中移除,移除后的答案区间为 [mid+1, right]
              left = mid + 1;
          } else {
              //  >= target
              right = mid;
          }
      }
  
  		// 由于不一定存在target,因此需要对A点判断
      if (nums[left] == target) {
          start = left;
      }

  }

寻找C点:

// 非递减数组nums, 寻找target,寻找C点
{
      let left = 0;
      let right = nums.length - 1;
      while (left < right) {
          const mid = (left + right) >>> 1;
        	
        	// 注意这里,使用的是 <= ,则最终当区间缩小为一个点时, left 一定不是target (最后一位元素是target的情况除外,因此需要对这种情况做判断),即 由于使用的是 <=,那么最终区间缩小到一个点时,left一定是 > target的(最后一位元素是target的特殊情况除外)
        	// 如果最后一位元素是target,则 nums[left] == target,在循环结束后单独对这种情况处理
          if (nums[mid] <= target) {
              left = mid + 1;
          } else {
              // > target
              right = mid
          }
      }

      if (nums[left] == target) {
          // 数组的最后一个元素是target的情况
          end = left;
      } else {
          // 寻找到的是C点,因此B点 = left - 1
          end = left - 1;
      }
  }

最好是一步步把计算过程列出来,会更方便理解。

完整代码:

/**
 * @param {number[]} nums
 * @param {number} target
 * @return {number[]}
 */
var searchRange = function (nums, target) {
    let start = -1;
    let end = -1;
  
  	// 寻找A点
    {
        let left = 0;
        let right = nums.length - 1;
        while (left < right) {
            const mid = (left + right) >>> 1;
            if (nums[mid] < target) {
                left = mid + 1;
            } else {
                //  >= target
                right = mid;
            }
        }
        if (nums[left] == target) {
            start = left;
        }

    }
  
  	// 寻找B点,即先找到C点,然后 B = C - 1
    {
      	// 如果A点不存在,即数组中不存在target,则B点也必然不存在
        if (start != -1) {
          	// 寻找C点
            let left = 0;
            let right = nums.length - 1;
            while (left < right) {
                const mid = (left + right) >>> 1;
                if (nums[mid] <= target) {
                    left = mid + 1;
                } else {
                    // > target
                    right = mid
                }
            }

            if (nums[left] == target) {
                // 数组的最后一个元素是target的情况
                end = left;
            } else {
                // 寻找的是最后一个target的下一个点,即C点,则 B = C - 1
                end = left - 1;
            }
        }

    }

    return [start, end];
};

# LC 658. 找到 K 个最接近的元素 (opens new window)

Q:

给定一个 排序好 的数组 arr ,两个整数 k 和 x ,从数组中找到最靠近 x(两数之差最小)的 k 个数。返回的结果必须要是按升序排好的。

整数 a 比整数 b 更接近 x 需要满足:

|a - x| < |b - x| 或者 |a - x| == |b - x| 且 a < b

提示:

1 <= k <= arr.length 1 <= arr.length <= 104 arr 按 升序 排列 -104 <= arr[i], x <= 104

来源:力扣(LeetCode) 链接:https://leetcode.cn/problems/find-k-closest-elements 著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。

A:这道题值得好好讲一讲,很多算法题都需要变通,即 很多算法题都遵循该公式:算法题 = 基础算法 + 题目变形。因此很多算法题都需要以下两步思考:

  1. 找出基础算法,即题目考察的核心算法,比如本题中,在数组中寻找符合条件的k个数,本质就是数组中寻找符合条件的某个数,一般来说,应用二分法模板二
  2. 找出题目变形,即题目在基础算法的基础上做了某些变化,需要对基础算法做改进才行,因此要先找出题目的变化点。例如本题中,条件为:更靠近x的k个数,即求对x差值的绝对值最小的k个数(左侧优先),那么我们可以把数组转变为对 x差值的绝对值数组。

结合一个例子来分析,对于数组[1,2,3,4,5,6,7,8,9,10],寻找x=6的k个数。左图为原数组折线,右图为每个值与x=6求差值绝对值的数组折线

原数组折线图:

原数组

绝对差值数组折线图:

绝对差值数组

可以发现,折线变成了一个V型图(还有两种边界情况,一条斜向下的折线或一条斜向上的折线,分析方法是一样的),那么题目就可以转化为:对差值绝对值数组(右边V型图)中,求离x=6(左右相等时,左边优先)的k个原始点。

则可明显的看出,是求绝对差值数组V型图中最底部k个点(左边点优先),至此已经完成了两步分析:

  • 基本算法,为二分法模板二
  • 题目变形,是求绝对差值数组V型图中最底部k个点(左边点优先)

假设k=4,即求离x=6最近的4个点,则步骤就类似下面两张图,从第一张图逼近到第二张图,第二张图的 [A,B)个点就是离x=6最近的4个点

/**
 * @param {number[]} arr
 * @param {number} k
 * @param {number} x
 * @return {number[]}
 */
var findClosestElements = function (arr, k, x) {
  	const n = arr.length;
    let left = 0;
    // 这里需要保证 mid + k 不出界限,当mid = max_right时,也要满足mid + k < n,因此直接让右边界缩减k-1个数,
    let right = n - k;
    while (left < right) {
        const mid = (left + right) >>> 1;
        // 集合上面两张图理解,在绝对差值数组中,符合条件的k个点一定是 最左侧的点 <= 最右侧的点
      	// 左侧绝对差值点可以用 x - arr[mid] 表达,右侧绝对差值点可以用 arr[mid + k] - x 表达,注意,这里并没有用绝对差值,用差值表示出相对关系即可
        if (x - arr[mid] > arr[mid + k] - x) {
            left = mid + 1;
        } else {
            right = mid;
        }
    }
  	
  	// 最后结果区间就为 [left, left+k) 这 k个点,即 left 最终到达符合条件的A点
    return arr.slice(left, left + k);
};

注意点:

  1. right = n - k 的理解,由于需要保证 mid + k < n,则 mid < n - k,由于 while(left < right),结合mid向下取中的特性,mid的最大值为 right -1,即 right - 1 < n - k,则 right < n - k -1,即right的最大值为 n - k
  2. x - arr[mid] > arr[mid + k] - x的理解,结合上面两个图的AB点理解

注:这个题目很经典,是模板二的进阶,需要好好理解。

# 小结

  • 模板二非常强大,多用于在一个区间内寻找符合条件的某个数,即通过 while (left < right)使区间缩小到一个点 [left, left],对left这个点进行判断是否是答案。
  • LC34题更适合用模板二,且用该题更容易理解模板二的原理。
  • 通过模板二能直接找到A点和C点(对一个非递减区间,寻找A点和B点,且A == B)
Last Updated: 11/9/2022, 5:39:24 PM