一文解决二分查找细节问题

382 阅读12分钟

二分查找可以快速地在一个具有单调性的序列中找到边界。但二分算法的细节问题总是让人头痛,比如

  • 循环条件写成left < right还是left <= right
  • 更新边界时到底是right = mid - 1还是right = mid
  • 为什么会死循环?
  • 为什么下标越界?
  • ……

其实只要弄懂搜索区间这个概念,所有的细节问题都能迎刃而解


二分算法的本质

很多人认为,二分查找可以快速地在一个有序序列中找到目标值。话是没错,但二分算法的强大不止如此

 

从本质上来说:若有任意序列array,若在该序列中恰有一下标index

使得该序列在区间[0, index)上对下标i满足某种单调性f(i)

且在区间[index, array.length)上对下标i满足单调性!f(i)

那么二分查找算法能够以 log2n\log_2n 的时间复杂度找到下标x(右边界)或下标x + 1(左边界)

1.png

二分查找的魔鬼细节

D.E.Knuth大佬曾这样评价二分查找算法

Although the basic idea of binary search is comparatively straightforward, the details can be surprisingly tricky...

其实说是细节很魔鬼,但其实只要你知道了查找区间概念,二分算法也就是个纸老虎

要想要正确的写出二分算法,共有三个细节点需要注意

  • 确定查找区间
  • leftright的更新方式
  • 收尾判断是否越界

这里先写出伪代码

while(...) {  // left < right 还是 left <= right ?
    int mid = left + ((right - left) >> 1);
    if(check(mid)) {	// check(mid)函数检查mid是否符合某种单调性
        left = ...;	// left = mid 还是 left = mid + 1
    } else {
        right = ...; // right = mid 还是 right = mid - 1
    }
}
...	// 跳出循环后该如何判断是否越界?是判断left还是判断right?

计算机科学中的区间

在高中数学中,我们知道开闭区间的概念。这在计算机科学中仍然适用,不过略有不同

比如在数学中,区间[3, 2]是没有意义的。可是在计算机科学中编程中,像这种左边大于右边的区间是有一定语义

比如在数组[2, 0, 0, 5, 0, 1, 0, 1]中,我说区间[3, 2]内的所有数均大于7

这么说是不是感觉很诡异?区间[3, 2]该怎么表示?但如果我换一个常规的例子,你就明白了

 

还是这个数组[2, 0, 0, 5, 0, 1, 0, 1],我说区间[1, 1]上的所有数都是偶数

这个就很显然了,区间[1, 1]上只有一个数0,它是偶数

 

那么我们返回刚才那个例子,我说区间[3, 2]上所有的数都大于7有问题吗?

当然没问题,就是因为在区间[3, 2]中根本连一个数都没有,我当然可以说数组在这个区间中,所有的数都大于7(我还可以说都大于1,都小于5)

也就是说,这种左边大于右边的区间代表一个具有某种语义的范围。只不过这个范围内没有数而已

查找区间

二分算法的第一个细节就是:在循环中,是写left <= right还是left < right

要想解决这个细节,首先你要确定的就是查找区间,这里直接分情况讨论

 

假设现在有数组array,我们约定left = 0right = array.length - 1

当你选择的查找区间为:[left, right]时

那么循环条件那里就要写成:while(left <= right)。此时跳出循环的条件是left == right + 1能够保证二分算法扫描到数组中所有的数

 

那我们不妨推理一下,如果你不加上等号,非要写成while(left < right)呢?

那么我们循环的跳出条件就变成了left == right。对吧?这时我们随便代一个数进去,比如leftright均为5时,此时跳出了循环

也就是说在二分算法处理(跳出循环)后,只剩下了[5, 5]区间没有被扫描。

[5, 5]是一个左闭右闭区间,区间内还有一个数没被二分算法处理过。也就是说漏掉了一个值

因为left == right时,循环被跳出了,都不涉及求mid甚至是后续的判断了。那么此时的下标为5的那个点是不是还没有被处理?

 

所以如果你确定了你的查找区间为[left, right]的左闭右闭区间,那么循环中条件就要写成while(left <= right)。以保证序列中所有的项都能被扫描到

 

假设现在有数组array,我们约定left = 0right = array.length

当你选择的查找区间为:[left, right)时

那么循环条件那里就要写成:while(left < right)。此时跳出循环的条件是left == right能够保证二分算法扫描到数组中所有的数

 

当循环条件为while(left < right)时,我们随便代一个数进去。比如leftright均等于1的时侯,这时跳出了循环

也就是说在二分算法处理后,只剩下了[1, 1)区间没有被扫描。

[1, 1)是一个左闭右开区间,该区间内不存在任何一个数,所以当跳出循环时,二分算法肯定已经扫描过了所有的数

 

所以如果你确定了你的查找区间为[left, right)的左闭右开区间,那么循环中条件就要写成while(left < right)。以保证序列中所有的项都能被扫描到

left和right更新时的写法

其实如果确定好了查找区间,这里关于leftright的更新写法也呼之欲出了

我们同样分情况来看

 

假设现在有数组array,我们约定left = 0right = array.length - 1

查找区间为[left, right]

left的更新写法为left = mid + 1

right的更新写法为right = mid - 1

为什么一定要写成这样?我们来分析一下这样更新后,原来的大区间被分解后的结果

原来的[left, right]区间被分为了[left, mid - 1][mid + 1, right]

我们先讨论更新left的写法,这里为什么不写成left = mid?因为mid这个点我们已经确定它不满足所求的性质了,所以我们要把查找区间缩小到[mid + 1, right]

那么自然要让left = mid + 1

4.1-0.gif  

同理,right也是一样,为什么不写成right= mid?因为mid这个点我们已经确定它不满足所求的性质了,所以我们要把查找区间缩小到[left, mid - 1]

那么自然要让right = mid - 1

4.1-1.gif

所以上面的伪代码已经可以确定两个细节点了

while(left <= right) {  // left <= right
    int mid = left + ((right - left) >> 1);
    if(check(mid)) {	// check(mid)函数检查mid是否符合某种单调性
        left = mid + 1;	// left = mid + 1
    } else {
        right = mid - 1; // right = mid - 1
    }
}
...	// 跳出循环后该如何判断是否越界?是判断left还是判断right?

 

假设现在有数组array,我们约定left = 0right = array.length

查找区间为[left, right)

left的更新写法为left = mid + 1

right的更新写法为right = mid

为什么一定要写成这样?我们来分析一下这样更新后,原来的大区间被分解后的结果

原来的[left, right)区间被分为了[left, mid)[mid + 1, right)

我们先讨论更新left的写法,这里为什么不写成left = mid?因为mid这个点我们已经确定它不满足所求的性质了,所以我们要把查找区间缩小为[mid + 1, right)

所以自然要让left = mid + 1

4.2-0.gif

 

而更新right的方式也是同理,因为mid这个点我们已经确定它不满足所求的性质了,所以我们要把查找区间缩小为[left, mid)

这里注意:左闭右开区间[left, mid)是取不到mid这个点的

所以要让right = mid而不是right = mid - 1

4.2-1.gif

于是我们自然能得出如下代码

while(left < right) {  // left < right
    int mid = left + ((right - left) >> 1);
    if(check(mid)) {	// check(mid)函数检查mid是否符合某种单调性
        left = mid + 1;	// left = mid + 1
    } else {
        right = mid; // right = mid
    }
}
...	// 跳出循环后该如何判断是否越界?是判断left还是判断right?

最后的收尾判断

这里的收尾判断,取决于之前提到的两点

其一是:你选择的查找区间是什么

其二是:你要求的是左边界还是右边界

如果我们选择的查找区间是[0, array.length - 1]

5.1.png

那么代码首先应该是这样写的

int left = 0, right = sizeof(array) / sizeof(array[0]) - 1;
while(left <= right) {  // left <= right
  int mid = left + ((right - left) >> 1);
  if(check(mid)) {	// check(mid)用于判断mid是否满足性质f(x)
      left = mid + 1;	// left = mid + 1
  } else {
      right = mid - 1; // right = mid - 1
  }
}

// 跳出循环后该如何判断是否越界?是判断left还是判断right?

如果我们想求的是左边界

也就是说,最后我们想要判断的是left那个位置的数是否是咱们想要的

那么就难免存在以下可能性:left不断向右移动,直到超出right后跳出循环

5.1.1-0.png

那么此时自然是应该判断leftarray.length - 1的大小关系,如果left没有越界,再判断array[left]是否满足我们的要求

if (left > array.length - 1) {
    // 越界,说明数组中不存在分界点
} else { // 数组中存在分界点,进行后续处理

}

如果我们想求的是右边界

也就是说,最后我们想要判断的是right那个位置的数是否是咱们想要的

那么就难免存在以下可能性:right不断向左移动,直到超出left后跳出循环

5.1.1-1.png

那么此时自然是应该判断right0的关系,如果right没有越界,再判断array[right]是否满足我们的要求

if (right < 0) {
    // 越界,说明数组中不存在分界点
} else { // 数组中存在分界点,进行后续处理

}

如果我们选择的查找区间是[0, array.length)

5.2.png

那么代码首先应该是这样写的

int left = 0, right = sizeof(array) / sizeof(array[0]);
while(left < right) {  // left < right
  int mid = left + ((right - left) >> 1);
  if(check(mid)) {	// check(mid)用于判断mid是否满足性质f(x)
      left = mid + 1; // left = mid + 1
  } else {
      right = mid; // right = mid
  }
}

// 跳出循环后该如何判断是否越界?是判断left还是判断right?

如果我们想求的是左边界

也就是说,最后我们想要判断的是left那个位置的数是否是咱们想要的

那么就难免存在以下可能性:left不断向右移动,直到left == right时跳出循环

5.2.1-0.png

此时自然是要判断leftarray.length - 1的关系,如果left没有越界,再判断array[left]是否满足我们的要求

if (left > array.length - 1) {
    // 越界,说明数组中不存在分界点
} else { // 数组中存在分界点,进行后续处理
    
}

如果我们想求的是右边界

也就是说,最后我们想要判断的是right - 1(或者left - 1)那个位置的数是否是咱们想要的

那么就难免存在以下可能性:right不断向左移动,直到left == right时跳出 循环

5.2.1-1.png

此时自然是要判断right - 1(或left - 1)0的关系,如果right - 1(或left - 1)没有越界,再判断array[right - 1](或array[left - 1])是否满足我们的要求

if (left - 1 < 0) { // 或者 if(right - 1 < 0)   
    // 越界,说明数组中不存在分界点
} else { // 数组中存在分界点,进行后续处理
    
}

二分算法模板

所以,我们已经能够总结出四套二分模板了(其实本质上就两套,只是最后的边界判断不同而已)

 

假设现在有数组array,我们约定left = 0right = array.length - 1

  • 搜索区间为[left, right],且要搜索的是左边界时

    因为已经确定了搜索区间是[left, right],所以自然确定了left的更新方式为left = mid + 1right的更新方式为right = mid - 1

    int left = 0, right = sizeof(array) / sizeof(array[0]) - 1;
    while(left <= right) {
        int mid = left + ((right - left) >> 1);
        if(check(mid)) {
            left = mid + 1;
        } else {
            right = mid - 1;
        }
    }
    if (left > array.length - 1) {    
      // 越界,数组中没有分界点
    } else {
      // 后续处理
    }
    
  • 搜索区间为[left, right],且要搜索的是右边界时

    因为已经确定了搜索区间是[left, right],所以自然确定了left的更新方式为left = mid + 1right的更新方式为right = mid - 1

    int left = 0, right = sizeof(array) / sizeof(array[0]) - 1;
    while(left <= right) {
        int mid = left + ((right - left) >> 1);
        if(check(mid)) {
            left = mid + 1;
        } else {
            right = mid - 1;
        }
    }
    if (right < 0) {
      // 越界,数组中没有分界点
    } else {
      // 后续处理
    }
    

假设现在有数组array,我们约定left = 0right = array.length

  • 搜索区间为[left, right),且要搜索的是左边界时

    因为已经确定了搜索区间是[left, right),所以自然确定了left的更新方式为left = mid + 1right的更新方式为right = mid

    int left = 0, right = sizeof(array) / sizeof(array[0]);
    while(left < right) {
        int mid = left + ((right - left) >> 1);
        if(check(mid)) {
            left = mid + 1;
        } else {
            right = mid;
        }
    }
    if (left > array.length - 1) {
      // 越界,数组中没有分界点
    } else {
      // 后续处理
    }
    
  • 搜索区间为[left, right),且要搜索的是右边界时

    因为已经确定了搜索区间是[left, right),所以自然确定了left的更新方式为left = mid + 1right的更新方式为right = mid

    int left = 0, right = sizeof(array) / sizeof(array[0]);
    while(left < right) {
        int mid = left + ((right - left) >> 1);
        if(check(mid)) {
            left = mid + 1;
        } else {
            right = mid;
        }
    }
    if (left - 1 < 0) {
      // 越界,数组中没有分界点
    } else {
      // 后续处理
    }
    

至此,二分查找的所有细节都已陈述完毕

参考资料

labuladong大佬的二分讲解:我写了首诗,把二分搜索算法变成了默写题 | labuladong 的算法笔记 (gitee.io)

AcWing站长y总的算法公开课:算法基础课(试听课)

AcWing站长y总的二分模板:二分查找算法模板