怎么查找
要在无序列表 a r r arr arr 中要查找 x x x ,只能遍历该列表,因为无序列表没有任何特征。
如果有排好序的列表呢?(下面文章中假设列表从小到大排序)
随便拿出某个元素。
- 如果值小于 x x x ,那么 x x x 必须在其右侧,左侧的元素就可以放弃查找。
- 如果值大于 x x x ,那么 x x x 必须在其左侧,右侧的元素也可以放弃查找。
那么取哪一个元素来筛选更好?(喊二分的同学先别急)
假设 x x x 出现在每个位置的概率都相等,那么
查看位于从左到右 p % p\% p% 位置的元素,
有
p
%
p\%
p% 的概率
x
x
x 在左侧从而可以抛弃右侧
1
−
p
1-p
1−p 部分的元素;
有
1
−
p
1 - p
1−p 的概率
x
x
x 在右侧从而可以抛弃左侧
p
p
p 部分的元素。
数学来说,每次平均将抛弃 2 p ( 1 − p ) 2 p(1-p) 2p(1−p) 比例的元素。根据一点数学知识, p = 0.5 p=0.5 p=0.5 时平均抛弃元素是最多的。
那么我们得出了最快的的策略:取中间位置的元素比较,大于x则下次在左侧继续取中找,小于x则在右侧继续找;
试写
先来个伪代码吧。
首先划定查找范围
[
l
,
r
]
[l, r]
[l,r] 。(下面记p为返回的位置p=search(...)
,a为返回的元素arr[p]
)
在每次查找中:
- 首先记录中间位置
m = (l + r) / 2
,整形除法自动向下取整 - 查看
arr[m]
- 如果
arr[m] > x
,则r = m-1;
继续找左侧 - 如果
arr[m] < x
,则l = m+1;
继续找右侧 arr[m] == x
,正好是要找的,立刻返回。隐患1
注意这里要加减1,否则 l
和 r
差1时,m
始终等于l
,若 arr[m] < x
,之后 l = m
没变化,就卡死在这里了。
加减1后,最终l
, r
肯定会重叠——用一个 while(l < r)
的循环包裹上面的步骤。
写出最简单的代码:
int binary_search(const int arr[], int size, int x) {
int l = 0;
int r = size - 1;
int m = 0;
while (l < r) { // 重叠就结束了
m = l + (r - l) / 2;
if (arr[m] < x) {
l = m + 1;
}
else if (arr[m] > x) {
r = m - 1;
} else{ // 最后比较相等,相等的情况最少
return m;
}
}
return m;
}
from typing import List, TypeVar
T = TypeVar('T')
def binary_search(arr: List[T], x: T):
l = 0
r = len(l) - 1
while l < r:
m = (l + r) // 2
if arr[m] < x:
l = m + 1
elif arr[m] > x:
r = m - 1
else:
return m
return m
注意如果列表中没有 x
,重叠时将会找到某个 a
,它左侧元素都小于 x
,右侧大于 x
。(x本身大于还是小于不确定-隐患2)
问题改进
上面提到了两个问题,都是关于查找目标x
的:
- 如果列表中有多个
x
,找到的x
可能是它们中的任一个。 - 如果列表中没有
x
,得到的arr[p]
与x
关系不稳定。
有多个x
如果有连续多个
x
,找到的x
可能是它们中的任一个。
问题1在只要求“找到”的情况下不是什么大问题。
但是如果要处理所有“找到”的元素,结果是最左或最右的x相对会更好。
- 方便用单个循环处理。
- 满足某些“首次出现”等算法题的要求
问题出在这里
arr[m] == x
,正好是要找的,立刻返回。
如果不马上返回,此时 arr[l] < x
, arr[r] > x
在这个查找范围里继续找总是能找到开始或末尾的x
。
假如我们想找末尾的x
,一个自然的想法就是:
既然
arr[m] < x
时向右,那arr[m] == x
时也继续向右不就行了?
简单设想现在查找范围里的元素是
x, x, x, m, ?, ?, ?
arr[r] > x
,r
向左移一格,
之后范围里都是x,l
不断向右,最后l
r
重合,得到末尾的x
看来在 arr[m] <= x
时继续向右,我们总是找到末尾的x,成功!
同理,在arr[m] >= x
时继续向左,总是找到开头的x。(同理)
没有x
如果列表中没有x,得到的
arr[p]
与x大小关系不确定。
问题2可以通过判断arr[p]
的大小然后左右移动来解决,反正就一个if的事嘛。
但是在存储寸土寸金的时代,人们追求的是使用尽可能少的代码完成功能。
于是他们思考:
既然步骤2/3中我们比较了arr[m]
和x,那么为什么不在l
r
重叠时再让循环比较一次呢?
将循环条件改成 while(l <= r)
。
没x且l
r
重叠时,元素们是:
(小于x),(不确定的a (
l
r
在这)),(大于x)
在下一次重复循环中:
- 若
arr[m]<x
,
此时l
右移了!
我们将状态确定为了(小于x),(小于x (
r
在这)),(大于x (l
移到这了!)) - 若
arr[m]>x
,
此时r
左移了!
我们将状态确定为了(小于x (
r
移到这了!)),(大于x (l
在这里)),(大于x)
非常完美呢!
现在arr[l]
是第一个大于x的元素,arr[r]
是小于x的最后一个元素,挑其中一个返回即可实现对应功能。
坑
这里重复循环看似优雅,其实额外增加了一个问题:如果待查找在arr[0]
,那么r = mid - 1
之后会变成-1,用unsigned就会溢出跑飞。
用int的话返回-1可以视为“没有比它小的了”的报错。
视情况使用咯。
综合考虑
综合上两部分:
1是改变了等于策略,肯定不会影响2无x时的结果。
无x时,arr[l]
是第一个大于x的元素,arr[r]
是小于x的最后一个元素
那么,考虑一下2多循环一次会不会影响多x时的结果。
还是考察l
r
重叠时的最后一次循环
-
采取
arr[m]<=x
向右,重叠时找到的见上(等于x),(等于x (
l
r
在这)),(大于x)然后
arr[m]<=x
,l
右移
变为(小于等于x),(小于等于x (
r
在这)),(大于x (l
移到这了!))此时
arr[l]
是第一个大于x的元素,arr[r]
是等于x的最后一个元素,
综合无x时,arr[l]
是第一个大于x的元素,arr[r]
是小于等于x的最后一个元素 -
采取
arr[m]<x
向右(即arr[m]>=x
向左),重叠时找到的见上(小于x),(等于x (
l
r
在这)),(等于x)然后
arr[m]>=x
,r
左移
变为(小于x (
r
移到这了!)),(等于x (l
在这)),(等于x)此时
arr[l]
是第一个等于x的元素,arr[r]
是小于x的最后一个元素,
综合无x时,arr[l]
是第一个大于等于x的元素,arr[r]
是小于x的最后一个元素
总结
p = search(l, x);
a = l[p]
挪l时 | arr[m]<x | arr[m]<=x |
---|---|---|
返回l | a>=x(即C++ STL lower_bound ) | a>x(即C++ STL upper_bound ) |
返回r | a<x | a<=x |
最终代码!
int binary_search(const int arr[], unsigned size, int x) {
int l = 0;
int r = size - 1;
while (l <= r) {
int m = l + (r - l) / 2; // 防溢出
if (arr[m] <= x) l = m + 1; ;;;;;;/// 可改成小于,之后挪r时即arr[m]>=x ------- !
else r = m - 1;
}
return l;;;;; /// 可改成r ------- !
}
from typing import List, TypeVar
T = TypeVar('T')
def binary_search(arr: List[T], x: T):
l = 0
r = len(l) - 1
while l <= r: # 可改成小于,之后挪r时即arr[m]>=x ------- !
m = (l + r) // 2
if arr[m] <= x:
l = m + 1
else:
r = m - 1
return l # 可改成r ------- !