对二分查找的简单理解

166 阅读4分钟

对二分查找的简单理解

二分查找的算法很好理解,但是二分查找的各种模板却比较繁杂,什么时候该用left <= right, 什么时候该用left < right, 什么时候该用left = mid + 1, 什么时候该用left = mid等等问题却常常困扰着我们,尤其是当长时间没使用二分查找的时候更是如此。因此也经常有二分的细节是魔鬼这种说法。
本文的主要目的就是对于这个问题进行一个简单的梳理,从而帮助你摆脱二分查找的条件选择困难。

二分查找条件的关键

其实,二分查找的条件并不是没有依据的,条件的选择其实取决于你对循环条件(即left和right)关系的定义,我们以二分查找常见的两个模板为例:

  • 模板 1
while (left <= right) {    // 定义查找区间为[left,right]
    int mid = (right - left) / 2 + left; // mid = (left + right) / 2, 此处为防止left + right溢出而使用的技巧,其实都是left / 2 + right / 2
    if (nums[mid] > target) {
        right = mid - 1;   // 根据[left,right]区间的定义, mid不可能是答案,所以有效区间变为[left, mid - 1], 因此此处right = mid - 1;
    } else if (nums[mid] < target) {
        left = mid + 1;   // 同理, 根据[left,right]区间的定义, mid不可能是答案,所以有效区间变为[mid + 1, right], 因此此处left = mid + 1;
    } else {             // 此时nums[mid] == target
        return mid;
    }
}

注意:在此模板中,循环条件的定义为left <= right, 也就是说left == right时循环条件仍成立,代表 你选择的查找区间是[left, right]这样一个左闭右闭的区间,因此,在整个循环中,牢记查找的区间是[left, right]这个区间,所有的条件都是根据这个区间来选择的。
结合上面的注释,相信不难理解left和right是如何确定的。

  • 模板2
while (left < right) {      // 定义查找区间为[left, right)
    int mid = (right - left) / 2 + left; // mid = (left + right) / 2, 此处为防止left + right溢出而使用的技巧,其实都是left / 2 + right / 2
    if (nums[mid] > target) {
        right = mid;   // 根据[left,right)区间的定义, mid不可能是答案,所以有效区间变为[left, mid), 由于右边是开区间, 可以取到mid, 因此此处right = mid;
    } else if (nums[mid] < target) {
        left = mid + 1;   // 同理, 根据[left,right)区间的定义, mid不可能是答案,所以有效区间变为[mid + 1, right), 由于左边是闭区间, 不可以取到mid, 因此此处left = mid + 1;
    } else {             // 此时nums[mid] == target
        return mid;
    }
}

注意:在此模板中,循环条件的定义为left < right, 也就是说left == right时循环条件不成立,代表 你选择的查找区间是[left, right)这样一个左闭右开的区间,因此,在整个循环中,牢记查找的区间是[left, right)这个区间,所有的条件都是根据这个区间来选择的。
如上述注释,右端点为开区间,nums[mid] > target 可以确定右端点为mid。左端点为闭区间,nums[mid] < target 可以确定左端点为mid + 1。

二分查找的变化

根据上面的描述, 你可能会猜想: 能否将区间定义为左开右闭,左开右开呢?答案是可以,只要你能确保在整个循环中牢记以你定义的区间去思考端点的变化, 并保证搜索了整个区间,就能得到正确的答案。
二分查找的模板不是一成不变的,根据具体查找条件的不同会产生不同的变化,因此,在使用二分查找的时候你需要做的不是记住模板, 而是记住你定义的有效查找区间, 只要保证你的所有条件产生的端点变化(即left, right)是根据这个有效查找区间变化的,你就能写出正确的二分。
为了更好的理解上面这段话,以leetcode 154 寻找旋转排序数组中的最小值 II这道题的核心代码为例:

        while (left <= right) {      // 定义查找区间为[left, right]
            if (left == right) {     // 特殊情况处理
                return nums[left];
            }
            int mid = (right - left) / 2 + left;
            if (nums[mid] < nums[right]) {
                right = mid;
            } else if (nums[mid] > nums[right]) {
                left = mid + 1;
            } else {
                right--;
            }
        }

简单描述一下这道题的题目:

升序排序的数组在预先未知的某个点上进行了旋转。 例如,数组 [0,1,2,4,5,6,7] 可能变为 [4,5,6,7,0,1,2] 这道题中数字是可能重复的,它的简化版本(没有重复数字是第153题)。
这里不去探究具体的解法,只关心代码中与二分相关的部分。

  1. 此处的写法基于模板1,定义查找的区间为[left, right]。
  2. 我们先看第二个条件:nums[mid] < nums[right], 从这个条件,我们可以判断的是最小数肯定小于等于nums[mid], 因此此时的有效区间是[left, mid], 所以 right = mid, 注意此处与模板一的区别,此处这个条件并不能确定nums[mid]不是最小数下标,因此mid仍为有效区间。这也就是上文说的,条件不是不变的,端点的变化取决于区间,而不是模板
  3. 我们再看第三个条件:nums[mid] > nums[right], 从这个条件,我们可以判断的是最小数肯定小于nums[mid] (至少存在nums[right]小于nums[mid]), 因此此时的有效区间是[left + 1, mid], 所以 left = mid + 1, 还是那句话,端点的变化取决于区间
  4. 我们再看第一个条件:为什么还会有新的条件?这是由于我们对于查询条件的定义导致的,思考一下,由于第二个条件中right = mid并不会一直缩小搜索区间,因此,极端情况下,left == right时,可能需要特殊判断才能保证能够搜索完所有区间,即跳出循环。这对应了上面说的另外一点,确保搜索完所有的区间。当然,这道题中,没有这个判断也能够得到答案。
  5. 最后简要说一下最后一个else判断,这种情况对应nums[left] == nums[mid] == nums[right], 此时,nums[right]肯定不是唯一最小值,因此将right右移以改变搜索区间。这个判断与题目相关,不是二分的关键,不做具体叙述,可以参考这道题目的题解。

二分查找小结

通过上述二分查找的分析,相信你已经知道了二分查找的两个关键点:用搜索区间判断端点以及确保搜索完所有的区间
对于第二个关键点,常出现的情况是搜索条件无法判断0, 1, 2个点,根据具体的情况,选择是否需要对特殊情况进行特殊判断。
牢记这两个关键点,再加上适当的二分题目的练习与理解,就可以自信地面对任何二分的题目了。