二分查找的思想很简单,它是针对于有序数组的,相当于数组(设为int a[N])排成一颗二叉平衡树(左子节点<=父节点<=右子节点),然后从根节点(对应数组下标a[N/2])开始判断,若值<=当前节点则到左子树,否则到右子树。查找时间复杂度是O(logN),因为树的高度是logN。

二分查找分两种:一种是精确查找某个元素x,另一种则是根据比较关系式查找,比如返回i使得对任意j<i均有a[j]<a[i],这用在折半插入排序中。

刚好前天无聊不借助STL手写折半插入排序时,发现自己基本功不扎实,对比了下STL自己实现的二分查找,发现自己还是太嫩了。

函数签名采用C风格的template <typename> T* f(T* a, size_t n, const T& x); 即查找区间为数组T a[n]整个区间,返回找到元素的地址。

下面给出对第一种查找的我第一感觉的实现方法

template <typename T>
T* binary_search(T* a, size_t n, const T& x)
{
size_t low = ;
size_t high = n - ;
size_t mid = (low + high) / ;
while (low < mid)
{
if (x == a[mid])
return a + mid;
else if (x < a[mid]) // x位于[low, mid)区间
high = mid - ; // 缩小查找范围到[low, mid-1]
else // x > a[mid]
low = mid + ; // 缩小查找范围到[mid+1, high]
mid = (low + high) / ;
}
return nullptr; // 查找失败
}

测试代码如下

#include "binary_search.h"
#include <cstdio> int main()
{
const size_t N = ;
int a[N] = {,,,,,,,};
auto p = binary_search(a, N, );
if (p != nullptr)
printf("a[%d] = %d\n", p - a, *p);
return ;
}

结果什么也没输出。调试发现在mid=2时由于low=2此时while循环退出,而此时本应该比较一下x和a[mid]的。

肉眼调试,初始:low=0, high=7, mid=(0+7)/2=3,进入while循环。

low=0<3=mid,a[3]=4>3,设置high=3,计算mid=(0+3)/2=1,进入下次循环

low=0<1=mid,a[1]=2<3,设置low=2,计算mid=(2+3)/2=2,进入下次循环

low=2=mid,跳出循环。返回默认返回值nullptr

可能这里会想说,那把while的条件改成<=不就行了?这样会造成死循环,因为(a+b)/2>=a恒成立。

那么让相等的时候比较一次就退出呢?

也不行,假如low=0,high=1,mid永远等于0,最可怕的是,这时候不会跟a[1]进行比较。

正是第一感觉考虑到这个,所以我没有用while (low < high),因为此时也会死循环。

——根本问题出现了,二分法每次都会对半切分区间,但是有时候区间大小(减1)为奇数,那么两个子区间大小肯定不一样。

对两个数组下标low和high(low<=high),按照(low+high)/2得到的mid把数组划分成哪两部分呢?

对于所有对半缩小区间问题,最后都会变成2种情况:1、low=mid=high;2、low=mid=high-1。

设k为整数:

假如low+high=2k,那么mid=k,左区间[low, mid-1]大小为mid-low=k-low,右区间[mid+1,high]大小为high-k=2k-low-k=k-low,左右区间相等;

假如low+high=2k+1,那么mid=k,左区间大小仍为k-low,右区间大小为2k+1-low-k=k-low+1,比左区间多1个。

也就是如果仍然以while (low<high)来判断,那么在结束循环后,在return nullptr;之前要加下面几行代码判断。

    if (a[mid] == x)  // low == mid
return a + mid;
if (low < high && a[high] == x) // low == mid == high-1
return a + high;

稍微显得不太美观了,能不能一个while循环就搞定还不用做额外判断的呢?

考虑下STL采用的的左闭右开区间[first, last),对数组a[N]而言first=0,last=N,不用特地写N-1。

假如first+last=2k,那么mid=k,左区间[first, mid)大小为mid-first=k-first,右区间[mid+1, last)大小为(2k-first)-(mid+1)=k-first-1,比左区间少1个。

假如first+last=2k+1,两个区间一样大(这里不给出计算流程了)。 给出这种情况下的代码

T* binary_search(T* a, size_t n, const T& x)
{
size_t first = ;
size_t last = n;
size_t mid = (first + last) / ;
while (first < mid)
{
if (x == a[mid])
return a + mid;
else if (x < a[mid])
last = mid; // [first, mid)
else // x > a[mid], [mid+1, last)
first = mid + ;
mid = (first + last) / ;
}
if (a[mid] == x)
return a + mid;
return nullptr;
}

测试代码

    for (int x = ; x <= ; x++)
{
auto p = binary_search(a, N, x);
if (p != nullptr)
printf("a[%d] = %d\n", p - a, *p);
}

结果无误,突然觉得STL采用左闭右开区间有其道理了。虽然感觉代码还是有些丑陋,再删一行的话充其量就是写成while ((mid = (first + last) / 2) > first)的形式,减少了while循环体内的一行重复代码mid = (first + last) / 2;

这样就完了?NO!这种实现健壮性不够,因为会出现溢出!

从最开始我就错了!假设,这里仅仅是假设,size_t的上限为3,即2 bits 无符号整数。当first为1(01),last为3(11)时,两者相加会溢出(超出size_t能表示的范围[0, 4))

理想的结果本来是2,实际结果(假如运算规则是超出最高位的进位直接省略掉)

二进制运算01 + 11 = 100,省略最高位1,变成了00,然后00除以2,结果是00,即0。也就是(1+3)/2的结果不是2,而是0!

当然,像我这种比较随意的简单测试不会出现溢出情况,但是溢出的风险必须考虑!(想起当时刷了道二分法的leetcode题应该就是挂在这里了)

因此:mid = (first + last) / 2要改成mid = first + (last - first) / 2

本着学习的态度,去看了官网上的binary_search的实现http://www.cplusplus.com/reference/algorithm/binary_search/

template <class ForwardIterator, class T>
bool binary_search (ForwardIterator first, ForwardIterator last, const T& val)
{
first = std::lower_bound(first,last,val);
return (first!=last && !(val<*first));
}

调用了lower_bound函数,然后去看下lower_bound。先不看代码,这里我顺便去看了<algorithm>中除了lower_bound还有upper_bound,很直白的意思,下界和上界,按照STL的设计应该也是左闭右开,用测试代码描述如下:

#include <cstdio>
#include <algorithm> int main()
{
const size_t N = ;
int a[N] = { ,,,, };
auto itL = std::lower_bound(a, a + N, );
auto itR = std::upper_bound(a, a + N, );
printf("itL == a[%d]\n", itL - a); // itL == a[1]
printf("itR == a[%d]\n", itR - a); // itR == a[5]
return ;
}

注释部分为该行输出结果,lower_bound返回第1个与查找值相等的迭代器,upper_bound返回lower_bound开始第1个与查找值不等的迭代器。

这么说不太严密,因为数组中可能不存在与查找值相等的值。那么此时会返回什么?

测试代码如下

    const size_t N = ;
double a[N] = { ,,,, };
auto itL = std::lower_bound(a, a + N, );
auto itR = std::upper_bound(a, a + N, );
printf("itL == a[%d]\n", itL - a); // itL == a[0]
printf("itR == a[%d]\n", itR - a); // itR == a[0]
itL = std::lower_bound(a, a + N, 1.5);
itR = std::upper_bound(a, a + N, 1.5);
printf("itL == a[%d]\n", itL - a); // itL == a[1]
printf("itR == a[%d]\n", itR - a); // itR == a[1]

严密又简洁点讲,在lower_bound左边的值都<查找值,在upper_bound左边的值都<=查找值。也就是我在开始提到的二分查找的第二种情况。

好了,那么回顾binary_search的代码,它调用的是lower_bound,第一行返回第1个与查找值相等的迭代器并赋值给first,若不存在则first为第1个大于查找值的迭代器。

第二行返回(first!=last && !(val<*first)),显然两个bool表达式同时成立等价于数组中含有查找值。

lower_bound什么时候表示能找到值?——当然是返回的迭代器对应值等于查找值,因为查找失败时,返回的是比该值大的迭代器,如果是我的话会直接写一句return val==*first。——但是不对,因为如果整个区间[first, last)的值都比查找值小,那么返回的是last,一个无法访问的迭代器,对*first的比较就会出错。所以前面加了一句first != last。那么后面为什么用!(val<*first)(等价于*first<=val)来判断呢?

问题等价于——什么时候查找成功并且*first<val?

关于这点,我仔细思考了一下,不知回答是否正确。

首先,这种情况是不存在的;

其次,这么写是因为这是函数模板,可能进行比较的是类(而非基本数据类型),而这里只要求类重载了operator<用来比较,对于operator==甚至operator>都可有可无

OK,现在来看看lower_bound的实际实现

template <class ForwardIterator, class T>
ForwardIterator lower_bound (ForwardIterator first, ForwardIterator last, const T& val)
{
ForwardIterator it;
iterator_traits<ForwardIterator>::difference_type count, step;
count = distance(first,last);
while (count>)
{
it = first; step=count/; advance (it,step);
if (*it<val) { // or: if (comp(*it,val)), for version (2)
first=++it;
count-=step+;
}
else count=step;
}
return first;
}

翻译成我之前的函数签名即如下代码(主要用于表达意思,没考虑细枝末叶的优化)

template <typename T>
T* binary_search(T* a, size_t n, const T& x)
{
T* first = a; // 搜索区间起始位置(左闭)
T* last = a + n; // 搜索区间结束位置(右开)
ptrdiff_t count = n; // 搜索区间元素数量
while (count > )
{
ptrdiff_t step = count / ;
T* mid = first + step; // 二分点
if (*mid < x) { // 继续查找[mid+1, last)
first = mid + ;
count -= step + ;
}
else
count = step; // 继续查找[first, mid)
}
return first;
}

这里的关键点是while循环里的变成了区间数量count,而count不能简单地用last-first来代替,即使它的初始值为last-first!

依旧考虑特殊情况:对数组a[2],first=0,last=2,count=2-0=2,进入while循环

count=2>0,计算step=2/2=1;mid=first+1,*mid即a[1];

若a[1]<x则需要查找[2,2),此时first变为mid+1=first+2,count变为2-(1+1)=0,last-first等于count;

否则,需要查找[0,1),此时count变为1,但是last-first=2不等于count!

说白了是把用first和last表示区间[first, last)改成用first和count表示区间[first, first+count)

这么一想,用first和last一样能写出这样的代码啊!于是我尝试着改了下

template <typename T>
T* binary_search(T* a, size_t n, const T& x)
{
T* first = a; // 搜索区间起始位置(左闭)
T* last = a + n; // 搜索区间结束位置(右开)
while (last - first > )
{
ptrdiff_t step = (last - first) / ;
if (first[step] < x) // first + step为二分点mid
first += (step + ); // 继续查找[mid+1, last)
else
last = first + step; // 继续查找[first, mid)
}
return first;
}

这样的代码看起来思路更加自然,而且没有什么漏洞(不考虑对类型T的要求的话)

再反思之前的做法,用起始位置和mid比较是不合适的,迭代的终止条件应该是搜索子区间元素数量大于0

我的实现思路从第一步就错了!也没必要去计算左区间大还是右区间大!因为终止条件是“当前区间”不为空!而不需要比较二分点和下界或者上界。

最后给出最终的二分搜索代码(使用first和last表示左闭右开区间)

/**
* 功能: 在升序排好的数组a[n]用二分法查找元素x的位置
* 参数:
* @a 数组首地址
* @n 数组元素数量
* @x 要查找的元素
* 返回: 若a[n]中存在元素x,返回任意一个与x相等的数组元素地址; 否则返回nullptr.
*/
template <typename T>
T* binary_search(T* a, size_t n, const T& x)
{
size_t first = ;
size_t last = n;
while (last - first > )
{
size_t mid = first + (last - first) / ;
if (a[mid] == x)
return a + mid;
else if (a[mid] < x)
first = mid + ;
else
last = mid;
}
return nullptr;
}

用闭区间low和high就稍微复杂点,因为high可能为-1,如果不把high的类型改为int,while里面需要额外判断high!=-1,即while (high - low >= 0 && high != -1)

测试代码和测试结果如下

#include "binary_search.h"
#include <cstdio> int main()
{
const size_t N = ;
int a[N] = { ,,,,,,, };
for (size_t i = ; i < N; i++)
a[i] = i + ;
for (int x = ; x <= ; x++)
{
auto p = binary_search(a, N, x);
if (p != nullptr)
printf("a[%d] = %d\n", p - a, *p);
else
printf("%d not found!\n", x);
}
return ;
}

这篇博客虽然很啰嗦,而且基本功稍微扎实的都能看出我讲了一堆废话,但是主要目的还是记录我从错误的思路转向正确的过程,顺便温故了STL关于二分查找的函数。

【学习记录】二分查找的C++实现,代码逐步优化的更多相关文章

  1. Java学习之二分查找算法

    好久没写算法了.只记得递归方法..结果测试下爆栈了. 思路就是取范围的中间点,判断是不是要找的值,是就输出,不是就与范围的两个临界值比较大小,不断更新临界值直到找到为止,给定的集合一定是有序的. 自己 ...

  2. 算法学习之二分查找算法的python实现

    ——参考自<算法图解> 我们假设需要查找的数组是有序的(从大到小或者从小到大),如果无序,可以在第四行后插入一句 my_list.sort() 完整代码如下 def binary_sear ...

  3. 《JavaScript算法》二分查找的思路与代码实现

    二分查找的思路 首先,从有序数组的中间的元素开始搜索,如果该元素正好是目标元素(即要查找的元素),则搜索过程结束,否则进行下一步. 如果目标元素大于或者小于中间元素,则在数组大于或小于中间元素的那一半 ...

  4. git学习记录——远程仓库(说白了就是代码放到githup上)

    远程仓库 现在讲述的这些SVN都已经做到了,并没什么稀奇的地方 所以这节课赘述的是杀手级的东西——远程仓库githup ssh-keygen -t rsa  -C "xxxxxxxxxxx@ ...

  5. python学习记录_中断正在执行的代码,执行剪切板中的代码,键盘快捷键,魔术命令,输入和输出变量,记录输入和输出变量_

    2018-03-28 00:56:39 中断正在执行的代码 无论是%run执行的脚本还是长时间运行的命令ctrl + cIn [1]: KeyboardInterrupt 执行剪切板中的代码 ctrl ...

  6. Holedox Eating HDU - 4302 2012多校C 二分查找+树状数组/线段树优化

    题意 一个长度$n<=1e5$的数轴,$m<=1e5$个操作 有两种一些操作 $0$  $x$ 在$x$放一个食物 $1$ 一个虫子去吃最近的食物,如果有两个食物一样近,不转变方向的去吃 ...

  7. Java的二分查找

    今天学习了二分查找,虽然代码简单,但还是要有必要,记录一下今天的学习的. public class TestBrinarySeach { public static void main(String[ ...

  8. HDU 5878 I Count Two Three (预处理+二分查找)

    题意:给出一个整数nnn, 找出一个大于等于nnn的最小整数mmm, 使得mmm可以表示为2a3b5c7d2^a3^b5^c7^d2​a​​3​b​​5​c​​7​d​​. 析:预处理出所有形为2a3 ...

  9. 【算法训练营day1】LeetCode704. 二分查找 LeetCode27. 移除元素

    [算法训练营day1]LeetCode704. 二分查找 LeetCode27. 移除元素 LeetCode704. 二分查找 题目链接:704. 二分查找 初次尝试 看到题目标题是二分查找,所以尝试 ...

随机推荐

  1. linux-shutdown命令说明

    showdown命令: -k  不是真正关闭电脑,只是警告. -h 关闭后暂停 -r 关闭后重新引导 -c 取消已经运行的关闭操作 -n 不通过init直接关闭 -f 快速重新引导 time 关闭的时 ...

  2. ZOJ 2965 Accurately Say "CocaCola"!(预处理)

    Accurately Say "CocaCola"! Time Limit: 2 Seconds      Memory Limit: 65536 KB In a party he ...

  3. 备注下Windows可能会用到的运行命令

    因为有几个命令不常用忘记了,所以备注下Windows可能会用到的运行命令: 1.cleanmgr:打开磁盘清理工具2.compmgmt.msc:计算机管理3.charmap:启动字符映射表4.calc ...

  4. Linux:更改hostname主机名

    更改hostname主机名 查看主机名 hostname 临时更改主机名 hostname youname 更改永久生效主机名 1)更改配置文件 vi /etc/sysconfig/network 2 ...

  5. pdi vcard-2.1

    vCard The Electronic Business Card Version 2.1 A versit Consortium Specification September 18, 1996 ...

  6. 我也说说Emacs吧(1) - Emacs和Vi我们都学

    好友幻神的<Emacs之魂>正在火热连载中,群里人起哄要给他捧捧场. 作为一个学习Emacs屡败屡战的用户,这个场还是值得捧一下的.至少我是买了HHKB键盘的... 从我的键盘说起 - 有 ...

  7. 如何制作dll库的API文档,自动生成微软风格的chm文件 Sandcastle Help File Builder 使用方法

    当你开发了一个库的时候,就需要给库开发一个api文档,微软提供了一个C#库的自动生成工具.我在使用的过程中记录了相关的信息,以供大家学习和查阅,如有不正之处,欢迎指出. 首先先下载一个软件,下载地址在 ...

  8. windows上操作git基本命令

    今天准备整理一下代码,重温一下Git的基本命令,好久不用忘得差不多了. 1. 进入某个目录: 进入D盘,然后进入D盘的名为git的文件夹: $ cd D: $ cd Git 2. 返回上一级目录: $ ...

  9. iOS-分组UITableView删除崩溃问题(当删除section中最后一条数据崩溃的情况)

    错误: The number of sections contained in the table view after the update (1) must be equal to the num ...

  10. HDU2825 Wireless Password 【AC自动机】【状压DP】

    HDU2825 Wireless Password Problem Description Liyuan lives in a old apartment. One day, he suddenly ...