关于二分查找
二分查找
不同的二分查找
用递归实现的二分查找
static int BinarySearch<T>(IList<T> list, T target)
where T : IComparable<T>
{
return BinarySearch(list, 0, list.Count - 1, target);
}
static int BinarySearch<T>(IList<T> list, int low, int high, T target)
where T : IComparable<T>
{
if (low > high)
return -1;
int mid = (high - low) / 2 + low;
int comp = list[mid].CompareTo(target);
if (comp == 0)
return mid;
if (comp < 0)
return BinarySearch(list, mid + 1, high, target);
else
return BinarySearch(list, low, mid - 1, target);
}
用迭代实现的二分查找
public static int BinarySearch<T>(IList<T> list, T target)
where T : IComparable<T>
{
int low = 0, high = list.Count - 1;
while (low <= high) {
int mid = (high - low) / 2 + low;
int comp = list[mid].CompareTo(target);
if (comp == 0)
return mid;
if (comp < 0)
low = mid + 1;
else
high = mid - 1;
}
return -1;
}
一些疑惑
- 为什么要使用
comp保存CompareTo的结果?
对T来说,CompareTo的开销可能较大,用comp保存结果可以减少一次比较 - 为什么要用
(high - low) / 2 + low而非(high + low) / 2?
(high + low) / 2有溢出的风险,而经过计算我们可以发现(high - low) / 2 + low恒等于(high + low) / 2 - 为什么使用
[low, high](闭区间)作为范围?
这是便于理解的需要,选用[low, high]或是[low, high)取决于你会不会访问list[high]
一些问题
- 如果有多个
target存在于list中,那么返回哪一个是不确定的
我们希望返回第一个或返回最后一个 - 如果
target不存在于list中,只返回-1
毋宁说,如果target寻找失败,就返回一个负数,那么我们为什么不让这个负数携带更多信息呢
最常见的是返回的这个负数k,有list[n less than ~k] < target < list[n greater than ~k](~k代表按位取反,~k总非负),即返回target应该插入的位置
更好的二分查找
依照上面的思路,写出代码
static int BinarySearch<T>(IList<T> list, T target)
where T : IComparable<T>
{
int low = 0, high = list.Count - 1;
while (low <= high)
{
int mid = (high - low) / 2 + low;
int comp = list[mid].CompareTo(target);
if (comp == 0)
{
while (mid > 0 && list[mid - 1].CompareTo(target) == 0)
--mid;
return mid;
}
if (comp < 0)
low = mid + 1;
else
high = mid - 1;
}
if (list[high].CompareTo(target) < 0)
++high;
return ~high;
}
我们给这段代码加了两个东西
comp == 0时一直递减mid直到这(可能的)一片target的开头- 从
while中退出(这意味着target不在list中),那么list[high] != target,至于大于还是小于则另需比较,最终得出target应该插入的位置
但这真的更好吗?
看看下面的代码吧
public static int BinarySearch<T>(IList<T> list, T target)
where T : IComparable<T>
{
int low = -1, high = list.Count;
while (low + 1 != high) // assert: list[high] >= target
{
int mid = (high - low) / 2 + low;
if (list[mid].CompareTo(target) < 0)
low = mid;
else
high = mid;
}
if (high == list.Count || list[high].CompareTo(target) != 0)
return ~high;
return high;
}
事实上,这两个函数的结果相同的,但你不觉得下面的这个更“漂亮”吗?
下面的循环不变式论证会解释它的可行性
为什么二分查找是可行的?
使用数学归纳法论证(前三个二分查找)
递归最重要的两点,我把它称作函数作用和递归终点
-
明确函数作用
BinarySearch<T>(IList<T> list, int low, int high, T target)的作用是————在[low, high]中搜索target
那么就大胆使用它,递归实现的二分查找中,我们就调用了BinarySearch(list, mid + 1, high, target)和BinarySearch(list, low, mid - 1, target),这代表着在[mid + 1, high]和[low, mid - 1]中搜索target不用管还没有实现
BinarySearch呢,我只知道它可以在[mid + 1, high]和[low, mid - 1]中搜索target,所以我就写上了 -
明确递归终点
此时考虑极限情况,比如:只有一个元素,甚至一个元素都没有
这里我考虑的是一个元素都没有,此时有low < high,自然返回-1或者退出循环因为
mid != mid + 1 && mid != mid - 1,所以每次递归都使至少一个元素被剔除,所以必然会到达递归终点
然后我们倒着考虑问题
明确递归终点确定了,意味着:没有元素的情况成立了,只有一个元素的情况成立了
明确函数作用确定了,意味着:当元素为一的情况成立,则元素为二的情况也成立(因为元素为二是由元素为一组成的),继而元素为三,为四,为五……
最终,函数成立
当心调用自身和其他无法抵达递归终点的情况
使用循环不变式论证(第四个二分查找)
我们不妨规定list[-1] = -inf, list[list.Count] = inf。事实上这是可行的,因为我们永远不会真正访问它们
同时我们动用循环不变式来论证我们的逻辑
- 开始时
list[high] = inf >= target成立 - 经过一轮判断,若有
list[mid] >= target则list[high = mid] >= target - 所以始终
list[high] >= target - 同理
list[low] < target恒成立 - 因为
low + 1 < high所以low < mid = (high - low) / 2 + low = (high + low) / 2 < high,所以每次循环high - low必定减小,即范围逐渐缩小 - 最终
low + 1 == high且list[low] < target && list[high] >= target此时比较list[high]和target,若相同则返回high,若不同则返回~high(high == list.Count意味着list[high] == inf)
注意此时high总是target第一次出现的位置,如果想要最后一个位置也很简单,只需要改为list[low] <= target < list[high]恒成立就行了
总结出三个重要因素
- 对
list[-1]和list[list.Count]的规定(辅助我们论证) - 始终
list[low] <= target < list[high] - 范围缩小(甚至于我敢于使用
low + 1 != high而非low + 1 < high),说明必定会到达终点
循环不变式是论证算法的重要手段之一
选择哪种二分查找?
按顺序我将上述四种二分搜索记为1, 2, 3, 4
这取决于你的需要,如果没有其它条件的话我更倾向于使用最后的那种
但如果有其他情况的话,我总结了两点
-
有类似于
CompareTo的比较函数吗虽然
4很好,但可惜的是它并没能利用充分CompareTo的返回值的信息,CompareTo返回的是int而非bool,这意味着什么?3中low = mid + 1; high = mid - 1;而在4中是low = mid; high = mid;
发现了吗,3比4“多跨出一步”!利用comp保存信息,不论是3还是4都只进行了一次对T比较,而对int的比较的开销又可以忽略不计,3得以在这方面占优于4 -
你有返回
target第一次(或最后一次)出现位置的需要吗
如果没有,你可以开心地把3中恼人的while去掉了,直接返回mid。事实上,结合上一点,3甚至可以快于4!
比4更快的二分查找,取为5
static int BinarySearch<T>(IList<T> list, T target)
where T : IComparable<T>
{
int low = 0, high = list.Count - 1;
while (low <= high)
{
int mid = (high - low) / 2 + low;
int comp = list[mid].CompareTo(target);
if (comp == 0)
return mid;
if (comp < 0)
low = mid + 1;
else
high = mid - 1;
}
if (list[high].CompareTo(target) < 0)
++high;
return ~high;
}
C++的支持
C++20推出的新运算符:三路比较运算符operator <=>类似于CompareTo
运用元编程写一个二分查找
// --------------函数实现---------------
template <typename Iter> // 模板参数为random_access_iterator时成功
using RequireRandomAccessIter = typename std::enable_if<
std::is_convertible<typename std::iterator_traits<Iter>::iterator_category,
std::random_access_iterator_tag>::value>::type;
template <typename Iter> // 获取iterator指向的value_type,并转化为const value_type reference
using const_reference_t = const typename std::iterator_traits<Iter>::value_type &;
// Iter为random_access_iterator时成功
template <typename Iter, typename = RequireRandomAccessIter<Iter>>
int binarySearch(Iter begin, Iter end,
const_reference_t<Iter> target)
{
Iter low = begin - 1, high = end;
Iter mid = (high - low) / 2 + low;
while (low + 1 != high)
{
if (*mid < target)
low = mid;
else
high = mid;
mid = (high - low) / 2 + low;
}
if (high == end || *high != target)
return ~(high - begin);
return high - begin;
}
// --------------const_container----------------
template <typename Container, typename = void>
struct is_const_container : std::false_type { };
template <typename Container>
struct is_const_container<Container,
std::__void_t<decltype(std::declval<Container &>().cbegin()),
decltype(std::declval<Container &>().cend()),
typename Container::value_type>>
: std::true_type // Container必须拥有cbegin(), cend()和value_type
{ typedef typename Container::value_type value_type; };
template <typename T, size_t N>
struct is_const_container<const T[N]> : std::true_type // 支持C类型的数组
{ typedef const T value_type; };
// --------------container----------------
template <typename Container, typename = void>
struct is_container : std::false_type { };
template <typename Container>
struct is_container<Container,
std::__void_t<decltype(std::declval<Container &>().begin()),
decltype(std::declval<Container &>().end()),
typename Container::value_type>>
: std::true_type // Container必须拥有begin(), end()和value_type
{ typedef typename Container::value_type value_type; };
template <typename T, size_t N>
struct is_container<T[N]> : std::true_type // 支持C类型的数组
{ typedef T value_type; };
// 获取is_const_container和is_container的value_type
template <typename Container>
using is_container_t = typename is_container<Container>::value_type;
template <typename Container>
using is_const_container_t = typename is_const_container<Container>::value_type;
// 为容器提供的转发
template <typename Container>
std::enable_if_t<is_const_container<Container>::value, int>
binarySearch(const Container &container,
const is_const_container_t<Container> &target)
{
return binarySearch(std::cbegin(container), std::cend(container), target);
}
template <typename Container> // 如果is_const_container不通过,那么会继续判断这个
std::enable_if_t<!is_const_container<Container>::value && is_container<Container>::value, int>
binarySearch(const Container &container,
const is_container_t<Container> &target)
{
return binarySearch(std::begin(container), std::end(container), target);
}
练习
-
用循环不变式解释其余用迭代实现的二分搜索
-
在你的电脑上,测试不同二分查找的运行时间(在我的电脑上,
5快于4\(15\%\),4快于3\(20\%\)) -
用自己的语言说一说为什么
5比4快 -
有一种形似于
3而实质为4的二分查找,用循环不变式解释它public static int BinarySearch<T>(IList<T> list, T target) where T : IComparable<T> { int low = 0; int high = list.Count - 1; while (low <= high) { int mid = (high - low) / 2 + low; if (list[mid].CompareTo(target) < 0) low = mid + 1; else high = mid - 1; if (low < list.Count && list[low].CompareTo(target) == 0) return low; return ~low; }提示:
low - 1; high + 1和while退出时low和high的关系

浙公网安备 33010602011771号