关于二分查找
二分查找
不同的二分查找
用递归实现的二分查找
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
的关系