二分查找可以快速地在一个具有单调性的序列中找到边界。但二分算法的细节问题总是让人头痛,比如
- 循环条件写成
left < right还是left <= right? - 更新边界时到底是
right = mid - 1还是right = mid? - 为什么会死循环?
- 为什么下标越界?
- ……
其实只要弄懂搜索区间这个概念,所有的细节问题都能迎刃而解
二分算法的本质
很多人认为,二分查找可以快速地在一个有序序列中找到目标值。话是没错,但二分算法的强大不止如此
从本质上来说:若有任意序列array,若在该序列中恰有一下标index
使得该序列在区间[0, index)上对下标i满足某种单调性f(i)
且在区间[index, array.length)上对下标i满足单调性!f(i)
那么二分查找算法能够以 的时间复杂度找到下标x(右边界)或下标x + 1(左边界)
二分查找的魔鬼细节
D.E.Knuth大佬曾这样评价二分查找算法
Although the basic idea of binary search is comparatively straightforward, the details can be surprisingly tricky...
其实说是细节很魔鬼,但其实只要你知道了查找区间概念,二分算法也就是个纸老虎
要想要正确的写出二分算法,共有三个细节点需要注意
- 确定查找区间
left和right的更新方式- 收尾判断是否越界
这里先写出伪代码
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 = 0,right = array.length - 1
当你选择的查找区间为:[left, right]时
那么循环条件那里就要写成:while(left <= right)。此时跳出循环的条件是left == right + 1,能够保证二分算法扫描到数组中所有的数
那我们不妨推理一下,如果你不加上等号,非要写成
while(left < right)呢?
那么我们循环的跳出条件就变成了
left == right。对吧?这时我们随便代一个数进去,比如left和right均为5时,此时跳出了循环
也就是说在二分算法处理(跳出循环)后,只剩下了
[5, 5]区间没有被扫描。
而
[5, 5]是一个左闭右闭区间,区间内还有一个数没被二分算法处理过。也就是说漏掉了一个值因为
left == right时,循环被跳出了,都不涉及求mid甚至是后续的判断了。那么此时的下标为5的那个点是不是还没有被处理?
所以如果你确定了你的查找区间为[left, right]的左闭右闭区间,那么循环中条件就要写成while(left <= right)。以保证序列中所有的项都能被扫描到
假设现在有数组array,我们约定left = 0,right = array.length
当你选择的查找区间为:[left, right)时
那么循环条件那里就要写成:while(left < right)。此时跳出循环的条件是left == right,能够保证二分算法扫描到数组中所有的数
当循环条件为
while(left < right)时,我们随便代一个数进去。比如left和right均等于1的时侯,这时跳出了循环
也就是说在二分算法处理后,只剩下了
[1, 1)区间没有被扫描。
而
[1, 1)是一个左闭右开区间,该区间内不存在任何一个数,所以当跳出循环时,二分算法肯定已经扫描过了所有的数
所以如果你确定了你的查找区间为[left, right)的左闭右开区间,那么循环中条件就要写成while(left < right)。以保证序列中所有的项都能被扫描到
left和right更新时的写法
其实如果确定好了查找区间,这里关于left和right的更新写法也呼之欲出了
我们同样分情况来看
假设现在有数组array,我们约定left = 0,right = 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
同理,
right也是一样,为什么不写成right= mid?因为mid这个点我们已经确定它不满足所求的性质了,所以我们要把查找区间缩小到[left, mid - 1]
那么自然要让
right = mid - 1
所以上面的伪代码已经可以确定两个细节点了
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 = 0,right = 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
而更新
right的方式也是同理,因为mid这个点我们已经确定它不满足所求的性质了,所以我们要把查找区间缩小为[left, mid)
这里注意:左闭右开区间
[left, mid)是取不到mid这个点的
所以要让
right = mid而不是right = mid - 1
于是我们自然能得出如下代码
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]
那么代码首先应该是这样写的
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后跳出循环
那么此时自然是应该判断left与array.length - 1的大小关系,如果left没有越界,再判断array[left]是否满足我们的要求
if (left > array.length - 1) {
// 越界,说明数组中不存在分界点
} else { // 数组中存在分界点,进行后续处理
}
如果我们想求的是右边界
也就是说,最后我们想要判断的是right那个位置的数是否是咱们想要的
那么就难免存在以下可能性:right不断向左移动,直到超出left后跳出循环
那么此时自然是应该判断right与0的关系,如果right没有越界,再判断array[right]是否满足我们的要求
if (right < 0) {
// 越界,说明数组中不存在分界点
} else { // 数组中存在分界点,进行后续处理
}
如果我们选择的查找区间是[0, array.length)
那么代码首先应该是这样写的
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时跳出循环
此时自然是要判断left与array.length - 1的关系,如果left没有越界,再判断array[left]是否满足我们的要求
if (left > array.length - 1) {
// 越界,说明数组中不存在分界点
} else { // 数组中存在分界点,进行后续处理
}
如果我们想求的是右边界
也就是说,最后我们想要判断的是right - 1(或者left - 1)那个位置的数是否是咱们想要的
那么就难免存在以下可能性:right不断向左移动,直到left == right时跳出 循环
此时自然是要判断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 = 0,right = array.length - 1
-
搜索区间为
[left, right],且要搜索的是左边界时因为已经确定了搜索区间是
[left, right],所以自然确定了left的更新方式为left = mid + 1;right的更新方式为right = mid - 1int 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 + 1;right的更新方式为right = mid - 1int 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 = 0,right = array.length
-
搜索区间为
[left, right),且要搜索的是左边界时因为已经确定了搜索区间是
[left, right),所以自然确定了left的更新方式为left = mid + 1;right的更新方式为right = midint 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 + 1;right的更新方式为right = midint 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总的二分模板:二分查找算法模板