本文为本人学习邓俊辉教授《数据结构》一书二分查找部分的复习总结,如需转载请注明此书以及本文地址。
0 引言
西游记第28回,孙悟空三打白骨精后被唐僧逐回,于是回到了花果山。
大圣道:“我当时共有四万七千群妖,如今都往哪里去了?”
群猴道:“自从爷爷走后,这山被二郎菩萨点上火,烧杀了大半。我们蹲在井里,钻在涧内,藏于铁板桥下,得了性命。及至火灭烟消,出来时,又没花果养赡(shan),难以存活,别处又去了一半。我们这一半,捱(ai)苦的住在山中,这两年,又被些打猎的抢去一半也。”
老舍先生在其一部表现抗战北平沦陷区普通民众生活与抗战的长篇小说《四世同堂》中描写到:
月亮上来了。星渐渐的稀少,天上空阔起来。和微风匀到一起的光,象冰凉的刀刃儿似的,把宽静的大街切成两半,一半儿黑,一半儿亮。那黑的一半,使人感到阴森,亮的一半使人感到凄凉。
1 基本思想
二分查找(Binary Search)是一种“减而治之”的策略,具体如图1.1所示。
(图待补充)
如果我们要在有序向量AAA中区间A[lo,hi)A[lo, hi)A[lo,hi)的部分查找目标元素eee,对于任意的元素x=A[mi]x=A[mi]x=A[mi]我们可以将区间A[lo,hi)A[lo, hi)A[lo,hi)分成A[lo,mi)A[lo,mi)A[lo,mi)、A[mi]A[mi]A[mi]、A(mi,hi)A(mi,hi)A(mi,hi)三个子区间。根据向量的有序性我们有
A[lo,mi)≤A[mi]≤A(mi,hi)A[lo,mi)≤A[mi]≤A(mi,hi)A[lo,mi)≤A[mi]≤A(mi,hi)
- 如果目标元素e<xe<xe<x,则目标元素必然存在于左侧的子区间A[lo,mi)A[lo, mi)A[lo,mi)或不存在,这时我们可以递归查找子区间A[lo,mi)A[lo, mi)A[lo,mi);
- 如果目标元素e>xe>xe>x,则目标元素必然存在于右侧的子区间A(mi,hi)A(mi, hi)A(mi,hi)或不存在,这时我们可以递归查找子区间A(mi,hi)A(mi, hi)A(mi,hi);
- 如果目标元素e=xe=xe=x,则目标元素已在切分点mimimi处命中,此时查找就可以终止了。
2 朴素算法
2.1 策略
朴素的二分查找算法的切分点S[mi]S[mi]S[mi]选自区间S[lo,hi)S[lo, hi)S[lo,hi)的中点,即mi=⌊(lo+hi)/2⌋mi=\lfloor(lo+hi)/2\rfloormi=⌊(lo+hi)/2⌋。这种策略可以概括为“以当前区间内居中的元素作为目标元素的试探对象”。因为每一步迭代之后无论沿哪个方向深入新问题的规模都将缩小一半,因此从最坏的角度来看,这一策略是最优的。
2.2 实现
一种递归方法的具体实现如下:
//朴素的二分查找——递归版
//在有序向量A的区间[lo, hi)中查找元素额,返回e的秩
template <typename T> int binSearch(T* A, T const& e, int lo, int hi)
{
if(lo >= hi) return -1; //递归基或不合法,查找失败
int mi = (lo + hi) >> 1; //以中点mi为轴点
return (e < A[mi] ? binSearch(A, e, lo, mi) : //分情况讨论
(A[mi] < e ? binSearch(A, e, mi + 1, hi) : mi);
}
通过“递归消除”的方法或改变思考方式,我们还可以得到以下一种迭代版本:
//朴素的二分查找——迭代版
//在有序向量A的区间[lo, hi)中查找元素额,返回e的秩
template <typename T> int binSearch(T* A, T const& e, int lo, int hi)
{
while(lo < hi) //如果区间[lo, hi)还有元素
{
int mi = (lo + hi) >> 1; //以中点mi为轴点
if (e < A[mi]) hi = mi; //深入[lo, mi)继续查找
else if (A[mi] < e) lo = mi + 1; //深入(mi, hi)继续查找
else return mi; //命中
}
return -1; //查找失败
}
我们可能会有如此疑惑:
● 三种情况(e<A[mi]e<A[mi]e<A[mi]、e>A[mi]e>A[mi]e>A[mi]、e=A[mi]e=A[mi]e=A[mi])为何要如此排列?如果调整为(e=A[mi]e=A[mi]e=A[mi]、e<A[mi]e<A[mi]e<A[mi]、e>A[mi]e>A[mi]e>A[mi])(一般书上给出的方案)会如何?
在2.5节我们重点分析这个问题,以阐明为什么将“===”的情况放在最后。
2.3 实例
(待补充)
2.4 性能
根据2.1中的策略描述,有效的查找区间宽度按1/2的几何级数速度递减。因此经过至多log2(hi−lo)\log_2 (hi-lo)log2(hi−lo)步迭代必然终止。因为每次迭代仅需O(1)O(1)O(1)的时间,因此总体时间复杂度不超过O(log2(hi−lo))=O(logn)O(\log_2 (hi-lo))=O(\log n)O(log2(hi−lo))=O(logn)。可以看出,相比于顺序查找,二分查找的优化意义重大。
2.5 定性分析
对于二分查找时间复杂度的计算,主要分为:元素的大小比较、秩的算术运算以及赋值。由于“秩”是无符号的整数,而“元素”通常更为复杂(第7章中有很多这样的例子),甚至比较的复杂度可能不是O(1)O(1)O(1)(如对有序字典向量中进行匹配查找),因此优先考虑比较的权重,整体的效率因此取决于比较操作的次数,这里称作“查找长度”。
2.5.1 平均成功查找长度
对于长度为n的有序向量,不失一般地,假设n=2k−1,k∈N∗n=2^k-1, k∈\mathbb{N}^*n=2k−1,k∈N∗,也就是说kkk为二分查找树的层数,假定查找目标元素等概率分布,因此我们需要求得此时平均成功查找长度为
Caverage(k)=∑i=12k−1xi2C_{average}(k)=\frac{\sum_{i=1}^{2^k-1}{x_i}}{2}Caverage(k)=2∑i=12k−1xi
其中xi{x_i}xi为第iii个元素的查找长度,查找长度总和为C(k)C(k)C(k),我们可以得到关系
C(k)=Caverage(k)⋅n=Caverage(k)⋅(2k−1)
\begin {aligned}
C(k)&=C_{average}(k)·n\\
&=C_{average}(k)·(2^k-1)
\end {aligned}C(k)=Caverage(k)⋅n=Caverage(k)⋅(2k−1)
特别地,当k=1k=1k=1时,成功查找只有一种情况,因此
Caverage(1)=C(1)=2C_{average}(1)=C(1)=2Caverage(1)=C(1)=2
下面用“递推分析”求出C(k)C(k)C(k)与C(k−1)C(k-1)C(k−1)的关系。
对于长度为n=2k−1n=2^k-1n=2k−1的有序向量,每一步迭代都有三种可能:
● 左半边元素:经过111次成功的比较后原问题转换为一个规模2k−1−12^{k-1}-12k−1−1的子问题;
● 右半边元素:经过111次失败的比较和111次成功的比较后(共222次)原问题转换为一个规模2k−1−12^{k-1}-12k−1−1的子问题;
● 中间的元素:经过222次失败的比较后在A[mi]A[mi]A[mi]点命中,算法终止。
根据上面的分析,我们可以得到如下递推式
C(k)=[C(k−1)+1⋅(2k−1−1)]+2⋅1+[C(k−1)+2⋅(2k−1−1)]=2⋅C(k−1)+3⋅2k−1−1
\begin {aligned}
C(k)&=[C(k-1)+1·(2^{k-1}-1)]+2·1+[C(k-1)+2·(2^{k-1}-1)]\\
&=2·C(k-1)+3·2^{k-1}-1
\end {aligned}C(k)=[C(k−1)+1⋅(2k−1−1)]+2⋅1+[C(k−1)+2⋅(2k−1−1)]=2⋅C(k−1)+3⋅2k−1−1
因为C(k)C(k)C(k)指向量中2k−12^k-12k−1个元素的查找长度的总和,因此C(k−1)C(k-1)C(k−1)的系数仅为1。
令F(k)=C(k)−3k⋅2k−1−1F(k)=C(k)-3k·2^{k-1}-1F(k)=C(k)−3k⋅2k−1−1,则F(k−1)=C(k−1)−3⋅(k−1)⋅2k−2−1F(k-1)=C(k-1)-3·(k-1)·2^{k-2}-1F(k−1)=C(k−1)−3⋅(k−1)⋅2k−2−1,因而有
F(k)=2⋅F(k−1)F(k)=2·F(k-1)F(k)=2⋅F(k−1)
对上式做验证:
F(k)=C(k)−3k⋅2k−1−1=2⋅C(k−1)+3⋅2k−1−1−3k⋅2k−1−1=2⋅C(k−1)+6⋅2k−2−6k⋅2k−2−2=2⋅C(k−1)+6⋅(1−k)⋅2k−2−2=2⋅[C(k−1)−3⋅(k−1)⋅2k−2−1]=2F(k−1) \begin {aligned} F(k)&=C(k)-3k·2^{k-1}-1\\ &=2·C(k-1)+3·2^{k-1}-1-3k·2^{k-1}-1\\ &=2·C(k-1)+6·2^{k-2}-6k·2^{k-2}-2\\ &=2·C(k-1)+6·(1-k)·2^{k-2}-2\\ &=2·[C(k-1)-3·(k-1)·2^{k-2}-1]\\ &=2F(k-1) \end {aligned} F(k)=C(k)−3k⋅2k−1−1=2⋅C(k−1)+3⋅2k−1−1−3k⋅2k−1−1=2⋅C(k−1)+6⋅2k−2−6k⋅2k−2−2=2⋅C(k−1)+6⋅(1−k)⋅2k−2−2=2⋅[C(k−1)−3⋅(k−1)⋅2k−2−1]=2F(k−1)
我们可以得到
F(1)=2−3−1=−2F(k)=2⋅F(k−1)=22⋅F(k−2)=23⋅F(k−3)=...=2k−1⋅F(1)=−2k\begin {aligned}
F(1)&=2-3-1=-2\\
F(k)&=2·F(k-1)=2^2·F(k-2)=2^3·F(k-3)=...\\
&=2^{k-1}·F(1)=-2^k
\end {aligned}F(1)F(k)=2−3−1=−2=2⋅F(k−1)=22⋅F(k−2)=23⋅F(k−3)=...=2k−1⋅F(1)=−2k
因此
C(k)=F(k)+3k⋅2k−1+1=−2k+3k⋅2k−1+1=(3k−4)⋅2k−1+1=(32k−2)⋅2k+1=(32k−1)⋅(2k−1)+32k\begin {aligned}
C(k)&=F(k)+3k·2^{k-1}+1\\
&=-2^k+3k·2^{k-1}+1\\
&=(3k-4)·2^{k-1}+1\\
&=(\frac{3}{2}k-2)·2^{k}+1\\
&=(\frac{3}{2}k-1)·(2^{k}-1)+\frac{3}{2}k
\end {aligned}C(k)=F(k)+3k⋅2k−1+1=−2k+3k⋅2k−1+1=(3k−4)⋅2k−1+1=(23k−2)⋅2k+1=(23k−1)⋅(2k−1)+23k
从而有
Caverage(k)=C(k)/(2k−1)=32k−1+3k2⋅(2k−1)=32k−1+O(ε)\begin {aligned}
C_{average}(k)&=C(k)/(2^k-1)\\
&=\frac{3}{2}k-1+\frac{3k}{2·(2^k-1)}\\
&=\frac{3}{2}k-1+O(\varepsilon)
\end {aligned}Caverage(k)=C(k)/(2k−1)=23k−1+2⋅(2k−1)3k=23k−1+O(ε)
亦即平均成功查找长度为O(1.5k)=O(1.5logn)O(1.5k)=O(1.5\log n)O(1.5k)=O(1.5logn)。
2.5.2 平均失败查找长度
根据迭代版代码,失败查找的终止条件是lo≥hilo≥hilo≥hi,亦即有效区间宽度缩减为000时失败告终。不难看出,对于对长度为nnn向量进行的二分查找,失败的情况有n+1n+1n+1种。
可以证明,一般情况下平均失败查找长度不超过1.5⋅log2(n+1)=O(1.5logn)1.5·\log_2(n+1)=O(1.5\log n)1.5⋅log2(n+1)=O(1.5logn)。
我们用数学归纳法来证明这一结论。
(1)朴素情况:当n=1n=1n=1时,平均失败查找长度为(1+2)/2=1.5(1+2)/2=1.5(1+2)/2=1.5,此时结论成立。
(2)一般情况:假设当n≤kn≤kn≤k时结论成立,我们要证明当n=2⋅kn=2·kn=2⋅k和当n=2⋅k+1n=2·k+1n=2⋅k+1时结论也成立。
▲ 当n=2⋅kn=2·kn=2⋅k时,左区间长度为kkk,右区间长度为k−1k-1k−1。
左侧区间向量总共包含k+1k+1k+1种失败情况,根据归纳假设,其平均长度不超过1+1.5⋅log2(k+1)1+1.5·\log_2(k+1)1+1.5⋅log2(k+1);
右侧区间向量总共包含kkk种失败情况,根据归纳假设,其平均长度不超过1+1.5⋅log2k1+1.5·\log_2k1+1.5⋅log2k。
(后续内容待补充)
参考文献
[1] 邓俊辉. 数据结构(C++语言版). 北京:清华大学出版社, 2013年9月第3版, ISBN:9-787-302-330646
[2] 邓俊辉. 数据结构习题解析. 北京:清华大学出版社, 2013年9月第3版, ISBN: 9-787-302-330653