【数据结构与算法】2 - 8 七大查找算法(下):分块、散列和树表

§2-8 七大查找算法(下):分块、散列与树表

查找思想:对于元素不完全有序的数组,通过将其分成若干个分块,使得分块中数据不一定有序,而各分块中所含的最大值有序,即后一分块中的所有元素都比前一分块中的元素大,块内无序,块间有序,可以提高查找效率。

查找某一关键字时,只需要先查找其位于哪一分块(查找索引表)中,然后再在分块中顺序查找即可。查找分块的这一过程可以结合二分查找进行。

算法实现

一般而言,分块应当尽可能地少,不应过多,从而影响查找效率。这里以一个给定数组为例,假定分块已人工完成。

public class BlockSearch {
    public static void main(String[] args) {
        // 原数组,人工分块,分块后应当使得后一分块中的所有元素都比前一分块元素大
        int[] arr = {7, 10, 13, 19, 16, 20,
                27, 22, 30, 40, 36,
                43, 50, 48};

        System.out.println(blockSearch(arr, 48));
    }

    public static int blockSearch(int[] arr, int key) {
        // 先分块
        Block b1 = new Block(20, 0, 5);
        Block b2 = new Block(40, 6, 10);
        Block b3 = new Block(50, 11, 13);
        // 建立索引表
        Block[] blocks = {b1, b2, b3};

        // 分块有序,使用二分查找先查找所在分块
        int low = 0;
        int high = blocks.length - 1;
        int mid = 0;
        while (low <= high) {
            // 确定中点
            mid = (low + high) / 2;

            // 折半查找
            if (low < high && key < blocks[mid].getMax()) {
                // 位于左边
                high = mid - 1;
            } else if (low < high && key > blocks[mid].getMax()) {
                // 位于右边
                low = mid + 1;
            } else {
                // 找到分块,分块内无序,使用基本查找
                for (int i = blocks[mid].getStartIndex(); i < blocks[mid].getEndIndex() + 1; i++) {
                    if (key == arr[i])
                        return i;
                }
                // 在分块内找不到元素
                return -1;
            }
        }

        // 不在分块中,则不存在
        return -1;
    }
}

// 分块
class Block {
    private int max;    // 最大值
    private int startIndex; // 分块起始索引
    private int endIndex;   // 分块结束索引

    public Block() {
    }

    public Block(int max, int startIndex, int endIndex) {
        this.max = max;
        this.startIndex = startIndex;
        this.endIndex = endIndex;
    }

    /**
     * 获取
     *
     * @return max
     */
    public int getMax() {
        return max;
    }

    /**
     * 获取
     *
     * @return startIndex
     */
    public int getStartIndex() {
        return startIndex;
    }

    /**
     * 获取
     *
     * @return endIndex
     */
    public int getEndIndex() {
        return endIndex;
    }
}

扩展分块查找:但是仍有一部分情况,数组中的元素无论如何分块都无法满足块内无序,块间有序的条件,数组中的最小值出现在后半部分或最大值出现在前半部分。这时候需要采用另外一种分块方式。

面对这种情况,只需要将数组中的元素做划分(分块),使得分块中的元素所构成的范围互不交集,记录每一个分块中的最大值、最小值、开始索引和结束索引即可。

涉及添加操作的扩展分块查找:在 1 ~ 1000 的范围之间获取 100 个随机数,且要求数据不重复。这时,我们可以利用上述的无交集分块思想,将存放随机数的数组划分为 10 块,依次存放 1~100, 100~200,以此类推的随机数。当获得多个位于同一范围的随机数,只需要将新生成的随机数 “接在” 上一个此范围内的随机数即可。这是一种使用了拉链法的哈希查找。

对于一张具有 \(b\) 个分块的索引表,每一分块长度为 \(s\) 的查找表 \(T\),使用分块查找,成功时的平均查找长度为:

\[\text{ASL}_\text{success} = \log_2 (b+1) + \frac{s}{2} \]

分块查找的效率介于基本查找和二分查找之间。

查找思想:哈希查找是一种适用于哈希表的查找方法。

哈希表(hash table):又称散列表,其基本思路是,设要存储的元素个数为 \(n\),设置一个长度为 \(m(m \geq n)\) 的连续内存单元,以每个元素的关键字 \(k_i(0 \leq i \leq n - 1)\) 为自变量,通过一个哈希函数(hash function),将其映射为内存单元中的地址(或下标) \(h(k_i)\),并把该元素存储到这个内存单元中。把如此构造的线性表存储结构称为哈希表

哈希函数:以每个元素的关键字 \(k_i(0 \leq i \leq n - 1)\) 为自变量,将其映射为内存单元中的地址(或下标)\(h(k_i)\) 的函数称为哈希函数,\(h(k_i)\) 又称哈希地址(hash address)。其构造目标是使得所有元素的哈希地址尽可能均匀分布在 \(m\) 个连续内存单元上,同时运算过程尽可能地简单以节省时间。

整数类型关键字的哈希函数的常用构造方法一般有:

  • 直接定址法:以关键字 \(k\) 本身或关键字加上某个常量 \(c\) 作为哈希地址的方法,即 \(h(k) = k + c\);计算简单,关键字基本连续分布时,可选用此法;
  • 除留余数法:用关键字 \(k\) 模以某个不大于哈希表长度 \(m\) 的整数 \(p\),其结果作为哈希地址,即 \(h(k) = k \mod{p}(p \leq m)\);计算简单,适用范围广,最经常使用;当 \(p\) 取不大于 \(m\) 的素数时,效果最好;
  • 数字分析法:提取关键字中取值较均匀的数字位作为哈希地址,适用于所有关键字都已知的情况,需要对关键字的每一位取值分布情况作分析;
  • 平方取中法:取关键字平方后分布均匀的几位作为哈希地址的方法;
  • 折叠法:先把关键字中的若干段作为一小组,然后把各小组折叠相加后分布均匀的几位作为哈希地址的方法;

哈希冲突(hash collisions):构建哈希表时可能存在一个问题,对于两个不同的关键字 \(k_i, k_j(k_i \not= k_j)\),会出现 \(h(k_i) = h(k_j)\) 的情况。这种情况就称为哈希冲突

哈希冲突的解决方法:

  • 开放定址法(open addressing):出现哈希冲突时,在哈希表中寻找一个新的空闲位置存放元素;

    • 线性探测法(linear probing):从发生冲突的地址(\(d_0\))开始,依次探测 \(d_0\) 的下一个地址(到达哈希表尾时,下一探测地址为表首 \(0\)),直到找到一个空闲单元为止(\(m \geq n\) 时一定能够找到一个空闲单元)。其数学表达为:

      \[\begin{split} d_0 & = h(k) \\ d_i & = (d_{i - 1} + 1) \mod{m} \quad (0 \leq i \leq m - 1) \end{split} \]

      \(m\) 是为了保证找到的位置位于 \(0 \sim m - 1\) 的有效空间中。该方法容易导致堆积现象,两个具有不同哈希函数值的元素因为争夺同一个哈希地址而出现堆积现象,称为非同义词冲突。

    • 平方探测法(square probing):发生冲突的地址为 \(d_0\),该方法的探测序列为 \(d_0 + 1^2\)\(d_0 - 1^2\)\(d_0 + 2^2\)\(d_0 - 2^2\),……其数学表达为:

      \[\begin{split} d_0 & = h(k) \\ d_i & = (d_0 \pm i^2) \mod{m} \quad (0 \leq i \leq m - 1) \end{split} \]

      开放地址法的空闲单元既向同义词开放,也向发生冲突的非同义词开放。

  • 拉链法(chaining):把所有的同义词用单链表链接起来的方法;前文涉及添加操作的扩展分块查找例子就是使用了这种方法;

同义词(synonym):把具有不同关键字但具有相同哈希地址的元素称为同义词,这种冲突也称为同义词冲突。在哈希表存储结构中,同义词冲突难以避免。

哈希表中的冲突很难避免,发生冲突的可能性也有大有小,影响哈希查找的性能。影响哈希查找性能的 3 个主要因素为:

  • 装填因子(load factor) \(\alpha\):装填因子 \(\alpha\) 是指哈希表中已存入的元素个数 \(n\) 与哈希表长度 \(m\) 的比值,即 \(\alpha = {n\over m}\)。一般而言,该比值越小,空间利用率越低,冲突可能性越小;该比值越大,空间利用率越高,冲突可能性越高。为了兼顾减少冲突和提高空间利用率两方面,\(\alpha\) 建议控制在 \(0.6 \sim 0.9\) 的范围内;
  • 采用的哈希函数:若哈希函数设计得当,可以使得哈希地址尽可能地均匀分布在哈希地址空间上,减少冲突发生;否则,哈希地址集中分布在某一区域上,加大冲突发生。为了提高查找效率,对频繁进行查找的关键字,应当尽可能地设计一个完美的哈希函数;
  • 解决哈希冲突的方法:出现哈希冲突时,解决哈希冲突的方法也会影响性能;

适合使用哈希表存储的数据:当一组数据的关键字与存储地址存在某种映射关系时,这组数据适合采用哈希表存储。

哈希表的运算算法:哈希表的常见运算有插入、建表、删除和查找等。一个哈希表由哈希函数和解决哈希冲突的方法构成。

由于不同的实际问题所采用的哈希函数算法和冲突解决方法不同,这里只介绍哈希表以及哈希查找的基本思想。

对于一张具有 \(n\) 个关键字,表长为 \(m\),采用除留余数法(\(p\))和线性探测法的哈希表而言,其成功时的平均查找长度为

\[\text{ASL}_\text{success} = \frac{1}{n} \times \sum_{i=1}^c C_i \times i \]

其中,\(C_i\) 表示探测 \(i\) 次的关键字个数。即,成功时的平均查找长度是查找到表中已有关键字的平均探测次数。

失败时的平均查找长度,即在表中查找不到待查元素,最后找到空位置时的探测次数平均值,为

\[\text{ASL}_\text{failure} = \frac{1}{p} \times \text{Total failure probing times} \]

查找思想:使用二叉查找树的数据结构,查找最适合的范围,具有很高的查找效率。但是使用这个查找方法必须要对数据建立一棵二叉树。

二叉查找树(binary search tree, BST):又称二叉排序树(binary sort tree),其特点是任意根结点的值大于所有左子结点的值(若非空),但小于所有右子结点的值(若非空),任意节点的子结点都是一棵二叉查找树。

为了能在最坏的情况下仍有较好的时间复杂度,可以基于二叉查找树进行优化,得到平衡查找树(balanced binary tree, AVL tree)、红黑二叉树(red-black tree)等高效算法。

posted @ 2023-08-01 22:01  Zebt  阅读(131)  评论(0)    收藏  举报