查找技术

Mr.Hdd数据结构查找技术大约 13 分钟...

「查找技术」

查找是指在数据集合中查找符合某种条件的元素的过程。查找可根据查找过程中的操作分为两类:

  1. 静态查找:查找不成功时,只返回一个不成功的标志,查找的结果不会改变查找集合,也即查找过后不涉及插入或者删除操作;
  2. 动态查找:查找不成功时,需要被将查找的「记录」插入到集合中,所有插入过程可能会改变集合。

查找技术中的常用查找结构:

  1. 线性表:适用于静态查找,主要采用线性表进行顺序查找,二分查找;

  2. 树表:适用于动态查找,主要采用二叉搜索树AVL树B树 等;

  3. 散列表:静态查找和动态查找都是适用,主要采用 哈希表(散列表) 等。

除了上述常用查找技术,还有「索引查找」

  1. 分块查找
  2. B+ 树

查找中的一些术语

  • 「记录 record」是指查找集合中的数据元素;
  • 「关键码 key」可以用于标识查找集合中的某个记录;(关键码可能不唯一,也可以有组合关键类,类似于双关键字排序)

查找算法的性能

查找算法的基本操作通常是将「记录」和给定值进行比较,所以通常以记录的平均比较次数来度量查找算法的平均时间性能,称为:「平均查找长度 ASL」:

  • 对于查找成功的情况,计算公式如下:

ASL=i=1npi ci ASL = \sum_{i=1}^{n}p_i \ c_i

nn 为问题规模;pip_i 为查找出第 ii 个记录的概率;cic_i 为查找出第 ii 个记录所需的比较次数;其中 pip_i 与查找的元素是否存在有关,与查找算法无关,而 cic_i 与算法有关;若 pip_i 已知,则 ASLASL 就是关于问题规模 nn 的函数。

  • 对于查找不成功的情况,ASLASL 就是查找失败时的比较次数

线性查找

「线性查找 Linear Search」是一种最基础的查找方法,其从使用的线性数据结构的某一端开始,依次访问每个元素,直到另一端后停止。常用作线性查找的数据结构为:

  • 线性表(数组)
  • 链表

算法实现

算法也很简单,就是遍历数据结构的同时判断即可,例如:在 nums 中查找目标元素 target 的对应索引,如下图:

image-20230222150036335

时间复杂度为:O(n)O(n)


当然也可以使用链表进行线性查找:


性能分析

无序线性表下的线性查找

对于 nn 个元素的表,给定值 keykey 与表中第 ii 个元素相等,那么定位第 ii 个元素的时候,需要进行 ni+1n-i+1 次关键字的比较,即 ci=ni+1c_i=n-i+1;但是如果表中无此元素,则与各关键字的比较次数为 n1n-1 次。即:

  • 查找成功时,ASL=i=1npi(ni+1)ASL=\sum_{i=1}^{n}p_i(n-i+1)
  • 查找失败时,ASL=n+1ASL = n+1

当每个元素的查找概率相等时:(即 pi=1np_i = \frac{1}{n})

  • 查找成功时,ASL=i=1npi(ni+1)=1n(1+2+...+n1+n)=1nn(n+1)2=n+12ASL=\sum_{i=1}^{n}p_i(n-i+1)=\frac{1}{n}(1+2+...+n-1+n)=\frac{1}{n}\frac{n(n+1)}{2}=\frac{n+1}{2};
  • 查找失败时,ASL=n+1ASL = n+1 。(与 nn 个元素比较之后都没找到)

相关信息

如果已知每个记录的查找概率,那么就可以根据查找概率对数据进行重排,以得到更好的 ASLASL.

有序线性表下的线性查找

  • 查找成功时,与无序时一致。
  • 查找失败时,并且每个元素的查找概率相等,ASL=j=1nqj(lj1)=1+2+...+n+nn+1=n2+nn+1ASL=\sum_{j=1}^{n}q_j(l_j-1)=\frac{1+2+...+n+n}{n+1}=\frac{n}{2}+\frac{n}{n+1}

其中 qjq_j 表示到达第 jj 个失败结点的概率,在相等查找概率的情况下,qj=1n+1q_j=\frac{1}{n+1}ljl_j 表示第 jj 个失败结点所在的索引值(从1开始)。例如:当 n=6n=6 时,ASLfailure=62+67=3.86ASL_{failure}=\frac{6}{2}+\frac{6}{7}=3.86

二分查找

「二分查找 Binary Search」利用数据有序性,通过每轮缩小一半搜索区间来查找目标元素。

使用二分查找的前置条件:

  • 要求输入数据是有序的 (并不严谨),这样才可以通过判断大小关系来排除一半的搜索区间;
  • 二分查找仅适用于数组,因为在二分查找中需要跳跃式(非连续地)访问元素,而数组支持索引访问,这很合适,但是链表不支持索引访问,所以使用链表效率会很低。

关于使用二分时真正需要满足的条件:「二段性

算法实现

给定一个长度为 nn 的排序数组 nums ,元素从小到大排列。数组的索引取值范围为 0,1,2,...,n10, 1, 2,...,n-1 .使用「区间」来表示这个取值范围的方法主要有两种:

  1. 双闭区间 [0,n1][0, n-1] :即两个边界都包含自身;此表示下,区间 [0,0][0, 0] 仍包含一个元素;
  2. 左闭右开 [0,n)[0, n) : 即左边界包含自身、右边界不包含自身;此表示下,区间 [0,0][0,0] 为空。
“双闭区间” 实现

查找过程如下图:

image-20230222154449313


“左闭右开” 实现

使用“左闭右开”的表示方法,写出相同功能的二分查找代码。


上面两种表示方法的对比:

表示方法初始化指针缩小区间循环终止条件
双闭区间 [0,n1][0,n-1]l=0,r=n1l=0,r=n-1l=m+1,r=m1l=m+1,r=m-1l>rl>r
左闭右开 [0,n)[0, n)l=0,r=nl=0,r=nl=m+1,r=ml=m+1,r=ml=rl=r

大数越界问题

当数组长度很大时,加法 l+rl+r 的结果有可能会超出 int 类型的取值范围。在此情况下,需要换一种计算中点的写法。

// (i + j) 有可能超出 int 的取值范围
int m = (l + r) / 2;
// 更换为此写法则不会越界
int m = l + (r - l) / 2;

【优缺点】

二分查找效率很高,体现在:

  • 二分查找时间复杂度低。对数阶在数据量很大时具有巨大优势,例如,当数据大小 n=220n=2^{20} 时,线性查找需要 220=10485762^{20}=1048576 轮循环,而二分查找仅需要 log2220=20log_2⁡2^{20}=20 轮循环。
  • 二分查找不需要额外空间。相对于借助额外数据结构来实现查找的算法来说,其更加节约空间使用。

但并不意味着所有情况下都应使用二分查找,这是因为:

  • 二分查找仅适用于有序数据。如果输入数据是无序的,为了使用二分查找而专门执行数据排序,那么是得不偿失的,因为排序算法的时间复杂度一般为 O(nlogn)O(nlog⁡n) ,比线性查找和二分查找都更差。再例如,对于频繁插入元素的场景,为了保持数组的有序性,需要将元素插入到特定位置,时间复杂度为 O(n)O(n) ,也是非常昂贵的。
  • 二分查找仅适用于数组。由于在二分查找中,访问索引是 “非连续” 的,因此链表或者基于链表实现的数据结构都无法使用。
  • 在小数据量下,线性查找的性能更好。在线性查找中,每轮只需要 1 次判断操作;而在二分查找中,需要 1 次加法、1 次除法、1 ~ 3 次判断操作、1 次加法(减法),共 4 ~ 6 个单元操作;因此,在数据量 n 较小时,线性查找反而比二分查找更快。

性能分析

二分查找的过程中可以使用 平衡的二叉搜索树(AVL) 来表示,下面的二叉搜索树为双闭区间构建过程,需要注意的是:

  • 对于一个有序序列,二叉搜索树并不唯一,只需要满足其中序遍历即可;
  • 二分查找的过程,其构建的是 二分判定树 ,其本身就是一颗平衡的二叉搜索树,并且其表示法唯一。其唯一性是因为其构建过程中的 mid 一直是向下取整这一种策略。

image-20230222160456605

可以看到,使用二分查找给定值时的比较次数最多不会超过树的「层数 h」,在查找每个元素的概率相等时:

  • 查找成功时:ASL=1n(1×1+2×2+...+h×2h1)=n+1nlog2(n+1)log2(n+1)1ASL=\frac{1}{n}(1×1+2×2+...+h×2^{h-1})=\frac{n+1}{n}log_2(n+1)≈log_2(n+1)-1
  • 查找失败时:ASL=1n+1(i=2h(hi1)×di)ASL=\frac{1}{n+1}(\sum_{i=2}^{h}(h_i-1)×d_i) ,其中 did_i 表示第 hi1h_i-1 层中查找不成功的结点个数。

如上图中:

  • 查找成功时:ASL=111(1×1+2×2+3×4+4×4)=2811ASL=\frac{1}{11}(1×1+2×2+3×4+4×4) = \frac{28}{11};
  • 查找失败时:ASL=112(3×4+4×8)=113ASL = \frac{1}{12}(3×4+4×8)=\frac{11}{3}

相关信息

当元素为 nn 个时,二分查找所得到的二分判定树的「层数 h」为: log2(n+1)⌈ log_2(n+1)⌉

各种二分实现方式
#include <iostream>
using namespace std;

int n = 7;
int a[] = {1, 4, 5, 9, 23, 23, 89, 100};

// 双闭区间 [l, r] 查找某个值是否存在
bool bsearch(int x) {
    int l = 0, r = n - 1;
    while (l <= r) {
        int mid = (l + r) >> 1;
        if (a[mid] == x) return true;
        else if (a[mid] > x) r = mid - 1;
        else l = mid + 1;
    }
    return false;
}

// 双闭区间 [l, r] 查找某个值是否存在 (递归写法)
bool rec_bsearch(int x, int l, int r) {
    if (l > r) return false;
    int mid = (l + r) >> 1;
    if (a[mid] == x) return true;
    else if (a[mid] > x) rec_bsearch(x, l, mid - 1);
    else rec_bsearch(x, mid + 1, r);
}

// 左闭右闭区间 [l, r] 寻找 >= x 的最小值的索引
int bsearch1(int x) {
    int l = 0, r = n - 1;
    while (l <= r) {
        int mid = l + r >> 1;
        if (a[mid] >= x) r = mid - 1;
        else l = mid + 1;
    }
    return l;
} 

// 左开右闭区间 [l, r) 寻找 >= x 的最小值的索引
int bsearch2(int x) {
    int l = 0, r = n;
    while (l < r) {
        int mid = l + r >> 1;
        if (a[mid] >= x) r = mid;
        else l = mid + 1;
    }
    return l;
} 

// 左开右开区间 (l, r) 寻找 >= x 的最小值的索引
int bsearch3(int x) {
    int l = -1, r = n;
    while (l + 1 < r) {
        int mid = l + r >> 1;
        if (a[mid] >= x) r = mid;
        else l = mid;
    }
    return r;
}

int main() {
    int x = 9;
    cout << bsearch(x) << endl;
    cout << rec_bsearch(x, 0, n - 1) << endl;
    cout << bsearch1(x) << endl;
    cout << bsearch2(x) << endl;
    cout << bsearch3(x) << endl;
    return 0;
}

分块查找

分块查找又称为「索引顺序查找」,其集合了顺序查找和二分查找各自的优点,既有动态结构,又适用于快速查找。

【基本思想】:

  • 将查找表分为若干块,块内元素可以无序,但是块之间是有序的。
  • 块之间有序是指每个块的选出的代表组成的「索引序列」是有序的。
  • 每个块的代表可以时该块中的「最小值」或者「最大值」,一般选择最大值作为代表。

【查找过程】

  1. 查找索引序列,确定元素所在的块;(可以使用顺序查找,也可以使用折半查找)
  2. 然后在块内顺序查找。(由于块内无序,所以只能用顺序查找)

例如:对序列 {24,21,6,11,8,22,32,31,54,72,61,78,88,83}\{24, 21, 6, 11, 8, 22, 32, 31, 54, 72, 61, 78, 88, 83 \} 建立索引表,索引表中存储每个块的【最大元素以及该块内第一个元素的索引值】。

image-20230318220129755

你认为这篇文章怎么样?
  • 0
  • 0
  • 0
  • 0
  • 0
  • 0
评论
  • 按正序
  • 按倒序
  • 按热度
Powered by Waline v2.14.9