【数据结构与算法】2 - 7 七大查找算法(上):线性表

§2-7 七大查找算法(上):线性表

2-7.1 查找的基本概念和查找算法

学习查找算法前,我们先来了解一些基本概念:

部分内容引用自:

m数据结构 day18 查找(一)无序表查找(线性查找),有序表查找(二分查找,插值查找, 斐波那契查找)_无序查找 最快_doubleslow;的博客-CSDN博客

《数据结构教程(第6版)》李春葆 著

  • 关键字(key):数据元素中某个数据项的值,又称为键值,用于表示一个数据元素或一个记录的某个字段。
  • 查找表(search table):所有被查找的数据所在的集合,由同一类数据元素或数据记录构成的集合。

查找算法是一种基础算法,用于在含有一定数量个元素的查找表中,找出关键字等于给定关键字的元素。若找到了,返回相关信息,若失败,则给出必要的提示信息。

查找是对已存入计算机中的数据进行的运算,在研究查找方法前,首先应当弄清楚查找方法所需要的数据结构(或存储结构),对查找表中的数据有何要求(有序或无序查找)。

查找算法的分类

  • 静态查找表(static search):查找过程中不涉及查找表的修改,这种查找找表是静态查找表。
  • 动态查找表(dynamic search):查找过程中同时对表做修改操作(如插入或删除)的,这种查找表是动态查找表。
  • 无序查找:在无序表中的查找称为无序查找。
  • 有序查找:在有序表中的查找称为有序查找。

根据查找过程所需要访问的内容不同,可分为内部查找和外部查找。

  • 内部查找(internal search):查找过程在内存当中进行的查找,称为内部查找。

  • 外部查找(external search):查找过程中需要访问外部存储的,称为外部查找。

本节内容介绍的是七大常见的内部查找算法。

查找运算的时间主要耗费在关键字比较上,一般地,可用平均查找长度(average search length, ASL)衡量查找算法的性能。

\[\text{ASL} = \sum_{i = 1}^n p_i c_i \]

其中,\(n\) 为查找表中元素个数,\(p_i\) 是第 \(i\) 个关键字的查找概率,通常假设为表中的每个元素查找概率相等,\(c_i\) 表示找到第 \(i\) 个关键字时所需查找次数。因此,平均查找长度是一个加权平均数。

而根据查找的结果(成功或失败),平均查找长度又可分为成功时的平均查找长度\(\text{ASL}_\text{success}\))和失败时的平均查找长度\(\text{ASL}_\text{failure}\))。

显然,对于任何一个查找算法而言,ASL 越小,性能表现越好。

查找思想:基本查找是一种最简单的查找方法,从表的一端遍历到另一端,匹配、查找某个元素是否存在。这种查找方式又称为顺序查找(sequential search)或线性查找(linear search)。

基本查找是一种无序查找。

算法实现

// 判断存在性的基本查找
public static boolean basicSearch(int[] arr, int key) {
    for (int i = 0; i < arr.length; i++) {
        if (arr[i] == key) {
            return true;
        }
    }

    return false;
}

// 返回索引值的基本查找
// 不考虑重复性
public static int basicIndex(int[] arr, int key) {
    for (int i = 0; i < arr.length; i++) {
        if (arr[i] == key) {
            return i;
        }
    }

    return -1;
}

考虑重复性的查找应当返回一个含有所有满足关键字的元素索引的序列,可以考虑使用 ArrayList<>

// 考虑重复性:使用泛型 + 集合类(列表)
public static ArrayList<Integer> basixIndexes(int[] arr, int key) {
    ArrayList<Integer> res = new ArrayList<>();

    for (int i = 0; i < arr.length; i++) {
        if (arr[i] == key) {
            res.add(i);
        }
    }

    return res;
}

基本查找适用于所有情况,无论查找表中的数据是否有序。对于一张具有 \(n\) 个关键字的查找表 \(T\),其成功时的平均查找长度为:

\[\text{ASL}_\text{success} = \sum_{i=1}^n p_i c_i = \frac{1}{n} \sum_{i=1}^n n = \frac{1}{n} \cdot \frac{n(n+1)}{2} = \frac{n+1}{2} \]

由于失败时需要将表中的所有关键字全都尝试匹配一边,失败时的平均查找长度为:

\[\text{ASL}_\text{failure} = n \]

该算法的时间复杂度为 \(O(n)\)

查找思想:利用二分法逐步缩减范围,实现较高效率的查找,又称折半查找

注意:二分查找是有序查找,仅适用于有序的序列。

算法实现

public static int binarySearch(int[] arr, int key) {
    // 二分查找,定义双指针,确定查找范围
    int low = 0;
    int high = arr.length - 1;

    // 循环的结束条件
    while (low <= high) {
        //取中点,折半
        int mid = (low + high) / 2;

        // 做比较,缩减查找范围
        if (arr[mid] > key) {
            // 位于中点左侧
            high = mid - 1;
        } else if (arr[mid] < key) {
            // 位于中点右侧
            low = mid + 1;
        } else {
            return mid;
        }
    }

    // 关键字不存在,指针错位
    return -1;
}

二分查找的过程可使用二叉树可视化。每次把当前查找区间的中点作为当前子树的根结点,左右区间分别为根结点的左右子树,并以相同的方式组织左右子树。这样,所生成的二叉树即为一棵描述二分查找过程的二叉判定树(binary decision tree)或二叉比较树(binary comparison tree)。考虑到左右子树结点和根结点的大小关系,所生成的判定树正好满足二叉排序树的性质。

二叉判定树刻画了二分查找在所有情况下的查找过程。查找长度取决于目标结点在树中的层次。

对于一张具有 \(n\) 个关键字的有序查找表 \(T\),其成功时的平均查找长度和失败时的平均查找长度相近。

为方便起见,设该有序表形成的二叉判定树高度为 \(h\),且有关系 \(n = 2^h - 1\),即将判定树近似看作一棵高度为 \(h = \log_2 (n+1)\) 的满二叉树(不含外部空结点)。树中第 \(i\) 层上的结点个数为 \(2^{i-1}\),查找该层上的每个结点正好要 \(i\) 次比较。

\[\text{ASL}_\text{success} = \sum_{i=1}^n p_i c_i = \frac{1}{n} \sum_{i=1}^n p_i = \frac{1}{n} \sum_{i=1}^h 2^{i-1} \times i = \frac{n+1}{n} \times \log_2(n+1) - 1 \approx \log_2 (n+1) - 1 \]

\(n \not= 2^h - 1\) 时,判定树中度数小于 2 的结点只可能在最下面两层上(即一棵完全二叉树,不计外部结点),该判定树的高度为 \(\lceil\log_2(n+1)\rceil\)

该算法的时间复杂度为 \(O(\log n)\)

查找思想:插值查找是二分查找的一种优化,其基本思想与二分查找相同,不同点在于 “中点“ 的取值方法。插值查找的中点取值方法使用了插值公式。插值查找能尽可能地使得中点更靠近想要查找的元素。

插值公式:

\[\text{mid} = \text{low} + \frac{\text{key} - \text{arr[low]}}{\text{arr[high] - arr[low]}} \cdot (\text{high - low}) \]

注意:同二分查找一样,插值查找也需要序列有序,且插值查找对于数据分布均匀的序列查找效率更高。

算法实现

// 插值查找,也需要数组有序
public static int interpolationSearch(int[] arr, int key) {
    // 插值查找是二分查找的优化,重点在于中点的取值不同
    // 插值查找适用于分布均匀的数据,使用数据归一化的方法获取中点
    int low = 0;
    int high = arr.length - 1;

    while (low <= high) {
        int mid = low + (key - arr[low]) / (arr[high] - arr[low]) * (high - low);

        if (arr[mid] > key) {
            high = mid - 1;
        } else if (arr[mid] < key) {
            low = mid + 1;
        } else {
            return mid;
        }
    }

    // 不存在关键字
    return -1;
}

该算法的平均查找长度与实际表中的数据分布情况有关。该算法的时间复杂度位于 \(O(\log\log n)\)\(O(\log n)\) 之间。

讲解来自于

084-尚硅谷-图解Java数据结构和算法-斐波那契查找代码实现.avi_哔哩哔哩_bilibili

查找思想:斐波那契查找也是二分查找的一种优化,借助黄金分割比例来作为分割的依据。

黄金分割比:是指事物各部分间的一个比例关系。将整体一分为二,较大部分与较小部分之比,与整体与较大部分之比的比值相等,这个比就是黄金分割比,其比值约为 \(1:0.618\)\(1.618:1\)

与斐波那契数列的联系:而在斐波那契数列中,相邻两个数之比将无限靠近这个黄金分割比,因此可以通过斐波那契数列来获得黄金分割比,作为分割的依据。以下述的斐波那契数列为例:

\[\{1,1,2,3,5,8,13,21,34,55, \cdots\} \]

其中,\({8\over13} \approx 0.615\)\({13\over21} \approx 0.619\) …… 因此,在排序前,首先需要获取斐波那契数列。

斐波那契数列:斐波那契数列是一个首两项为 1,其余项为前两项之和的一个数列,即从第 3 项开始,满足递推公式 \(f(k) = f(k-1) + f(k-2)\)。由该式,又可以得到 \(f(k)-1 = f(k-1)-1+f(k-2)-1+1\)

从这两个式子来看,若一个长度恰好为 \(f(k)\) 的数组,由斐波那契数列的递推公式,该数组就可以被分割成两个长度分别为 \(f(k-1)\)\(f(k-2)\) 的左、右子数组。此时,中点索引 mid 即为 low + f(k-1) - 1(考虑到数组长度与最大索引的差值关系,这里的 low 为低位索引,high 为高位索引,同前文)。这就是斐波那契查找的分割依据

但并不是所有数组的长度都满足 arr.length > f(k) (即 high > f(k) - 1)的条件,而我们希望数组的长度能够恰好大于等于 f(k),因此,我们往往会选择扩充数组,使得新数组长度能够满足上述关系。

使用 Arrays.copyOf() 方法可以满足这一需求,但值得注意的是,新增的索引处会用默认值(整型为 0)补全,这会破坏数组的有序性。为恢复数组的有序性,还应当用原数组的最大值(最高位)补全新数组的新增位。至此,排序前的准备工作完成。

排序过程:准备工作完成后,实际的查找过程与二分查找、插值查找十分相似。首先要确定中点索引 mid,即 mid = low + f(k-1) + 1,随后的查找发生在新数组中。

若关键字位于左子区间,低位索引不变,高位索引的移动方式同二分查找和插值查找,即为 high = mid - 1。下一次查找时,只需要在这个长度为这个长度为 f(k-1) 的子区间(数组)内查找即可。为了维持待查找范围和斐波那契数的关系(high > f(k) - 1),使得下一次查找仍能够使用斐波那契查找继续进行,斐波那契数列索引 k 也应当同步更新,只需 k-- 即可。

若关键字位于右子区间,高位索引不变,低位索引的移动方式同二分查找和插值查找,即为 low = mid + 1。下一次查找时,只需要在这个长度为 f(k-2) 的子区间(数组)内查找即可。为了维持待查找范围和斐波那契数的关系(high > f(k) - 1),使得下一次查找仍能够使用斐波那契查找继续进行,斐波那契数列索引 k 也应当同步更新,此时应当为 k -= 2

若上述两个区间都不满足,关键字同 mid 处索引时,考虑到这是在新数组中进行的查找,返回新数组的索引没有意义,返回值应当选择 midhigh 二者最小值。

上述过程发生在循环体中,循环条件同二分查找和插值查找(low <= high),若循环结束时都没有遇到 return 语句,则关键字不存在,返回 -1 即可。

注意:斐波那契查找也是有序查找。

算法实现

// 斐波那契数列长度
private static final int maxSize = 20;

// 获取斐波那契数列
private static int[] fib() {
    // 斐波那契数列,深度由 maxSize 决定
    int[] f = new int[maxSize];
    // 数列前2项为定值 1
    f[0] = 1;
    f[1] = 1;
    // 递推公式使用非递归的方法进行
    for (int i = 2; i < f.length; i++) {
        f[i] = f[i - 1] + f[i - 2];
    }

    // 返回该数列
    return f;
}

// 查找入口
public static int fibonacciSearch(int[] arr, int key) {
    // 获取高低位
    int low = 0;
    int high = arr.length - 1;
    // 获取分割中点索引,初始化为 0
    int mid = 0;

    // 获取斐波那契数列
    int[] f = fib();
    // 使用循环获得与待查找数组长度最相近的斐波那契数索引
    int k = 0;
    while (high > f[k] - 1) {
        k++;
    }
    // 防止数组长度不足,应当扩容数组以满足斐波那契查找的条件
    int[] tempArr = Arrays.copyOf(arr, f[k]);
    // 恢复新数组的有序性,使用原数组的最高位填充新增位
    for (int i = high + 1; i < tempArr.length; i++) {
        tempArr[i] = arr[high];
    }

    // 同二分查找和插值查找的循环方法
    while (low <= high) {
        // 获取中点索引(注意数组长度与最大索引的关系)
        mid = low + f[k - 1] - 1;

        // 在新数组中查找
        // 若关键字位于左子区间(长度为 f[k-1]
        if (key < tempArr[mid]) {
            // 低位索引不变,移动高位索引
            high = mid - 1;
            // 下一次查找在左半区间内进行,继续使用斐波那契查找
            // 则应当满足斐波那契查找的数组长度与斐波那契数的关系
            k--;
        } else if (key > tempArr[mid]) {    //位于右子区间,长度 f[k-2]
            // 高位索引不变,移动低位索引
            low = mid + 1;
            // 下一次查找在右半区间内进行,继续使用斐波那契查找
            // 则应当满足斐波那契查找的数组长度与斐波那契数的关系
            k -= 2;
        } else {
            // 位于中点处,考虑返回索引
            // 返回新数组的索引没有意义,应当返回 mid 和 high 的最小值
            if (mid <= high) {
                return mid;
            } else {
                return high;
            }
        }
    }

    // 循环体结束,则不存在该关键字
    return -1;
}

一般认为,斐波那契查找的平均查找长度介于二分查找和线性查找之间,具体性能取决于选取的斐波那契数列。理想情况下,斐波那契数查找的平均查找长度趋近于 \(O(\log n)\),最坏情况下可能退化至 \(O(n)\)

posted @ 2023-08-01 00:27  Zebt  阅读(124)  评论(0)    收藏  举报