关于二分查找

二分查找

不同的二分查找

用递归实现的二分查找

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;
}

我们给这段代码加了两个东西

  1. comp == 0时一直递减mid直到这(可能的)一片target的开头
  2. 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;
}

事实上,这两个函数的结果相同的,但你不觉得下面的这个更“漂亮”吗?

下面的循环不变式论证会解释它的可行性

为什么二分查找是可行的?

使用数学归纳法论证(前三个二分查找)

递归最重要的两点,我把它称作函数作用递归终点

  1. 明确函数作用
    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,所以我就写上了

  2. 明确递归终点
    此时考虑极限情况,比如:只有一个元素,甚至一个元素都没有
    这里我考虑的是一个元素都没有,此时有low < high,自然返回-1或者退出循环

    因为mid != mid + 1 && mid != mid - 1,所以每次递归都使至少一个元素被剔除,所以必然会到达递归终点

然后我们倒着考虑问题
明确递归终点确定了,意味着:没有元素的情况成立了,只有一个元素的情况成立了
明确函数作用确定了,意味着:当元素为一的情况成立,则元素为二的情况也成立(因为元素为二是由元素为一组成的),继而元素为三,为四,为五……

最终,函数成立

当心调用自身和其他无法抵达递归终点的情况

使用循环不变式论证(第四个二分查找)

我们不妨规定list[-1] = -inf, list[list.Count] = inf。事实上这是可行的,因为我们永远不会真正访问它们

同时我们动用循环不变式来论证我们的逻辑

  1. 开始时list[high] = inf >= target成立
  2. 经过一轮判断,若有list[mid] >= targetlist[high = mid] >= target
  3. 所以始终list[high] >= target
  4. 同理list[low] < target恒成立
  5. 因为low + 1 < high所以low < mid = (high - low) / 2 + low = (high + low) / 2 < high,所以每次循环high - low必定减小,即范围逐渐缩小
  6. 最终low + 1 == highlist[low] < target && list[high] >= target此时比较list[high]target,若相同则返回high,若不同则返回~highhigh == list.Count意味着list[high] == inf

注意此时high总是target第一次出现的位置,如果想要最后一个位置也很简单,只需要改为list[low] <= target < list[high]恒成立就行了

总结出三个重要因素

  1. list[-1]list[list.Count]的规定(辅助我们论证)
  2. 始终list[low] <= target < list[high]
  3. 范围缩小(甚至于我敢于使用low + 1 != high而非low + 1 < high),说明必定会到达终点

循环不变式是论证算法的重要手段之一

选择哪种二分查找?

按顺序我将上述四种二分搜索记为1, 2, 3, 4

这取决于你的需要,如果没有其它条件的话我更倾向于使用最后的那种

但如果有其他情况的话,我总结了两点

  • 有类似于CompareTo的比较函数吗

    虽然4很好,但可惜的是它并没能利用充分CompareTo的返回值的信息,CompareTo返回的是int而非bool,这意味着什么?

    3low = mid + 1; high = mid - 1;而在4中是low = mid; high = mid;
    发现了吗,34“多跨出一步”!利用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);
}

练习

  1. 循环不变式解释其余用迭代实现的二分搜索

  2. 在你的电脑上,测试不同二分查找的运行时间(在我的电脑上,5快于4\(15\%\)4快于3\(20\%\)

  3. 用自己的语言说一说为什么54

  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 + 1while退出时lowhigh的关系

posted @ 2022-10-28 11:30  Violeshnv  阅读(25)  评论(0编辑  收藏  举报