1. 折半查找又称二分查找,仅适用于有序顺序表 (可随机访问,链表不行)

  2. 给出如下形式的代码:假设查找表为从小到大的有序表,显然一般而言当 low>highlow>high 时认为查找失败。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    typedef int ElemType;
    typedef struct
    {
    ElemType *elem;
    int tableLen;
    } SSTable;

    int Binary_Search(SSTable L, ElemType key)
    {
    int low = 0, high = L.tableLen - 1, mid;
    while (low <= high)
    {
    mid = (low + high) / 2;
    if (L.elem[mid] == key)
    {
    return mid;
    }
    else if (L.elem[mid] > key) // key在左半区
    {
    high = mid - 1;
    }
    else // key在右半区
    {
    low = mid + 1;
    }
    }
    return -1;
    }
  3. 折半查找判定树构成:首先我们要知道在 c 或 c++语言中,mid=(low+high)/2mid = (low + high) / 2 其实是等同于 mid=low+high2mid=\lfloor \frac{low+high}{2}\rfloor

    1. mid=low+high2mid=\lfloor \frac{low+high}{2}\rfloor 时:每次二分后,highhighlowlow元素个数的奇偶性是可能发生变化的。
      1. highhighlowlow 共有奇数个元素时,则 midmid ​分隔后,左右两边元素个数相等。
      2. highhighlowlow 共有偶数个元素时,则 midmid ​分隔后,左边元素个数比右边少一个。
      3. 对于任意一个判定树结点,右子树结点数-左子树结点数= 00 或者 11
    2. mid=low+high2mid=\lceil \frac{low+high}{2}\rceil ​时:
      1. highhighlowlow 共有奇数个元素时,则 midmid ​分隔后,左右两边元素个数相等。
      2. highhighlowlow 共有偶数个元素时,则 midmid ​分隔后,右边元素个数比左边少一个。
      3. 对于任意一个判定树结点,左子树结点数-右子树结点数= 00 或者 11 ​。
    3. 故而经上面分析,折半查找的判定树一定是平衡二叉树,且除最后一层可能不满以外,其它层都是满的。含有元素个数nn的树高 (不含失败结点) 为h=log2(n+1)h=\lceil log_2 (n+1) \rceil,显然ASL成功<=hASL_{成功}<=hASL失败<=hASL_{失败}<=h,即折半查找时间复杂度o(log2n)o(log_2n)。但是请注意,这不意味着任何情况下折半查找的效率均高于顺序查找,例如查找查找表的第一个元素。
    4. 显然依旧满足:若有nn个结点,则相应地有n+1n+1个查找失败结点 (成功结点的空链域数量)
    5. 下面我们不妨演示一下 mid=low+high2mid=\lfloor \frac{low+high}{2}\rfloor 的判定树构建过程 (下图省略失败结点部分):
      1. 显然按照左子树比右子树少一原则,16164141 因该是右孩子。
      2. 当然我们其实要知道在折半查找的判定树中,左右子树其实就是在折半过程中划分的左右区间。当 mid=13mid=13 时显然 1616 属于右区间内,故而在右子树中,4141 同理可得。
        image-20240411185308019
    6. 不妨判断一下面的二叉树是否可能是折半查找的判定树:显然不是,不可能一会右子树比左子树多,一会儿左子树比右子树多。
      image-20240411185328588
  4. 提醒一点:mid=(left+right)/2存在left+right过大导致溢出的风险,推荐使用mid=left+(right-left)/2

  5. 为了后续分块查找部分,提一嘴。实际上查找关键字并不是二分查找的全部内容,例如如何找到一个比关键字大的最小值、或者找比关键字小的最大值。实际上我们要知道一点:查找失败,lowlowhighhigh会分别指向比目标值稍大和稍小的元素,如下图两种情况的演示。当然也可以直接尝试逻辑分析,当low=highlow=high时,若arr[mid]keyarr[mid] \leq key显然lowlow右移,反之highhigh左移,故而不难看出上述结论。不妨尝试力扣题:搜索插入位置 (由于不存在重复,若找到相等元素时返回当前位置即可)
    image-20240411185347990

    image-20240411185358663

  6. 补充:但是对于上述问题,其存在一个前提查找表中无重复元素。但是当我们允许重复时情况似乎略复杂,即允许存在重复元素,此时我们需要深入理解一下二分法的原理:为了方便叙述,这里暂时使用 arrarr 代替 L.elemL.elem,开启上帝视角,将程序的查找结果(最终返回值)记作 ansans

    1. 前提:关于程序结束条件的讨论

      1. low<high:当循环结束时low=high,此时对于双闭区间([low,high][low,high])而言,我们已经找到一个值hiht=lowhiht=low,下一步就是判断high=lowhigh=low是不是我们要的ansans,不是则没结果。

      2. low<=high:当循环结束时low=high+1,此时对于双闭区间([high,low][high,low])而言,我们找到两个值highlow。这时后我们需要判断,我们ansans​是其中哪一个,还是都不是。

      3. 实际上low<=high就是在low<high的基础上再运行了一遍程序,这个过程其实就可以认为将low=high得到的唯一结果进行验算,判断是否为我们要的ansans

    2. 现在,我们不妨提出以下两个问题:

      1. AA:在一个存在重复元素的查找表中,我们需要找到比keykey大的最小值。
      2. BB:在一个存在重复元素的查找表中,我们需要找到比keykey​小的最大值。
    3. 显然无论是哪个问题,有一点我们需要肯定。当low=high时,我们就要终止程序,即终止条件仍为low<high(这一点通过后面讨论很容易理解)。而一般二分法之所以在low=high后还执行一遍程序,是为了判断最终找到位置的关键字是不是key。参照这个形式,其实可以写出另一种二分法代码如下,这个我们后面再说。

    4. 对于问题 AA,当我们发现 L.elem[mid]keyL.elem[mid] \leq key 时,我们该怎么做?显然我们需要找比 keykey 大的,我们可以直接 low=mid+1low=mid+1 ​。当我们发现 L.elem[mid]>keyL.elem[mid] \gt key 时,我们该怎么做?显然我们需要保留 midmid,因为 midmid 可能就是我们需要的结果,即 high=midhigh=mid。给出下列代码:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      int binary_search(SSTable ST, ElemType key)
      {
      int low = 0, high = ST.tableLen - 1, mid;
      while (low < high)
      {
      mid = (low + high) / 2;
      if (ST.elem[mid] <= key)
      low = mid + 1;
      else
      high = mid;
      }
      return ST.elem[low] > key ? low : -1;
      }
    5. 当然对于上述代码最后返回值可能存在一些小的疑问,为啥不直接返回low?其实可能high一直没动,low一路狂奔追上high,也就是说整个数组可能不存在比key大的数。

    6. 那么若将low<high的条件换为low>=high呢?给出如下代码:显然当high=low时,我们找到可能结果; 此时若low是所求anslow不变,否则low加一则没找则返回low即可(当然也可以推测low+1=high时的情形)。

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      int Search_Bin(SSTable ST, ElemType key)
      {
      int low = 0, high = ST.tableLen - 1, mid;
      while (low <= high)
      {
      mid = (low + high) / 2;
      if (ST.elem[mid] <= key)
      low = mid + 1;
      else
      high = mid;
      }
      return low; // high+1也是可以的
      }
    7. 同样的,对于问题 BB, 当我们发现 L.elem[mid]keyL.elem[mid] \geq key 时,我们该怎么做?显然我们需要找比 keykey 小的,我们可以直接 high=mid1high=mid-1 ​。当我们发现 L.elem[mid]<keyL.elem[mid] \lt key 时,我们该怎么做?显然我们需要保留 midmid,因为 midmid 可能就是我们需要的结果,即 low=midlow=mid。给出下列代码:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      int Search_Bin(SSTable ST, ElemType key)
      {
      int low = 0, high = ST.tableLen - 1, mid;
      while (low < high)
      {
      mid = (low + high) / 2;
      if (ST.elem[mid] < key)
      low = mid;
      else
      high = mid - 1;
      }
      return ST.elem[high] < key ? high : -1;
      }
    8. 同理可以写出BB问题的另一种代码:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      int Search_Bin(SSTable ST, ElemType key)
      {
      int low = 0, high = ST.tableLen - 1, mid;
      while (low <= high)
      {
      mid = (low + high) / 2;
      if (ST.elem[mid] < key)
      low = mid;
      else
      high = mid - 1;
      }
      return high; // low-1也是可以的
      }
    9. 实际上,我们给出折半查找的另一种解释,即排除法解释,上述代码就会变得和蔼可亲。

      1. 先以基本的折半查找为例,当我们发现 arr[mid]>keyarr[mid]>key,显然我们的 ans<midans \lt mid 于是乎排除右区间转向左区间,其代码语言就是 high=mid1high=mid-1。而当我们发现 arr[mid]<keyarr[mid]<key,显然我们的 ans>midans \gt mid 于是乎排除左区间转向右区间,其代码语言就是 low=mid+1low=mid+1

      2. 在次回到 AA 问题中,我们的 arr[ans]>keyarr[ans]>key,当 arr[mid]=keyarr[mid]=key, 显然我们显然需要排除左区间,同理当 arr[mid]<keyarr[mid]<key,我们也需要丢弃左区间,转换为代码语言就是 low=high+1low=high+1。那么当 arr[mid]<keyarr[mid]<key 时,此时 midmid 可能就是 ansans,需要保留 high=midhigh=mid。对于 BB​ 问题同理分析即可。

      3. 排除法的精髓在于,不断排除不可能存在结果的区间直至区间长度为1,那么我们的结果要不就是这玩意,要不就没结果。于是我们根据上述知识得到新的二分代码:当然不难看出,这种形式的效率较低,每次都需要遍历整个表才能得出最后结果,哪怕是中间某个过程中arr[mid]=keyarr[mid]=key

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        int Binary_Search(SSTable L, ElemType key)
        {
        int low = 0, high = L.tableLen - 1, mid;
        while (low < high)
        {
        mid = (low + high) / 2;
        if (L.elem[mid] > key)
        {
        high = mid - 1;
        }
        else
        {
        low = mid;
        }
        }
        return L.elem[low] == key ? low : low - 1;
        }
        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        18
        int Binary_Search(SSTable L, ElemType key)
        {
        int low = 0, high = L.tableLen - 1, mid;
        while (low < high)
        {
        mid = (low + high) / 2;
        if (L.elem[mid] < key)
        {
        low = mid + 1;

        }
        else
        {
        high = mid;
        }
        }
        return L.elem[low] == key ? low : low - 1;
        }
    10. 此外对于 AABB 问题,我们提出另一种略难理解的方式,区间开闭

      1. 给出示意图:其中数组长度为nn
        image-20240411171025543

      2. 对于 AA 问题,我们需要找比 keykey 大的关键字,即 ansans 。那么 ansans​ 在数组中的位置在哪儿?显然存在多种表示:

        1. 0ansn10 \leq ans \leq n-1​,我们称这种为双闭区间,就是我们上面代码所使用的区间法。

        2. 0ans<n0 \leq ans \lt n,我们称这种为左闭右开区间,这种形式可以解决AA​问题:我们在理论上n补上,这样就没有必要在high=low后验证一次,也不能验证,验证就可能导致数组越界。

          1
          2
          3
          4
          5
          6
          7
          8
          9
          10
          11
          12
          13
          int binary_search(SSTable ST, ElemType key)
          {
          int low = 0, high = ST.tableLen, mid;
          while (low < high)
          {
          mid = (low + high) / 2;
          if (ST.elem[mid] <= key)
          low = mid + 1;
          else
          high = mid;
          }
          return low;
          }
        3. 1<ansn1-1 \lt ans \leq n-1,我们称这种为左开右闭区间,这种形式可以解决BB​问题:原理同上。

          1
          2
          3
          4
          5
          6
          7
          8
          9
          10
          11
          12
          13
          int Search_Bin(SSTable ST, ElemType key)
          {
          int low = -1, high = ST.tableLen - 1, mid;
          while (low < high)
          {
          mid = (low + high) / 2;
          if (ST.elem[mid] < key)
          low = mid;
          else
          high = mid - 1;
          }
          return high;
          }
    11. 指鹿为马:实际上,我们前面在二分法(不含有重复元素)中提过:查找失败,lowlowhighhigh会分别指向比目标值稍大和稍小的元素。那对于AA问题,我们倘若将=key=key执行的操作归类到<key<key的操作(将所有=key=key的视作<key<key),那么最终就演变成了最开始不含重复元素且必然查找失败的情景。那对于BB问题同理(将所有=key=key的视作>key>key)

      1. 对于AA​​​​问题:

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        int Search_Bin(SSTable ST, ElemType key)
        {
        int low = 0, high = ST.tableLen, mid; // 左闭右开
        while (low <= high)
        {
        mid = (low + high) / 2;
        if (ST.elem[mid] <= key)
        low = mid + 1;
        else
        high = mid - 1;
        }
        return low;
        }
      2. 对于BB问题:

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        int Search_Bin(SSTable ST, ElemType key)
        {
        int low = -1, high = ST.tableLen-1, mid; // 左开右闭
        while (low <= high)
        {
        mid = (low + high) / 2;
        if (ST.elem[mid] >= key)
        high = mid - 1;
        else
        low = mid + 1;
        }
        return high;
        }
  7. 最终给出一个图和一句话:二分法的关键还是讨论low=high以及其前、后一步的执行情况。
    image-20240411185048785