Java-高级主题-五-

73 阅读1小时+

Java 高级主题(五)

原文:Advanced Topics in Java: Core Concepts in Data Structures

协议:CC BY-NC-SA 4.0

九、高级排序

在本章中,我们将解释以下内容:

  • 什么是堆以及如何使用siftDown执行堆排序
  • 如何使用siftUp构建一个堆
  • 如何分析 heapsort 的性能
  • 如何使用堆来实现优先级队列
  • 如何使用快速排序对项目列表进行排序
  • 如何找到列表中第 k 个最小的项目
  • 如何使用外壳(递减增量)排序对项目列表进行排序

在第一章中,我们讨论了两种简单的方法(选择和插入排序)来排序条目列表。在这一章中,我们将详细介绍一些更快的方法——堆排序、快速排序和 Shell(递减增量)排序。

9.1 重头戏

Heapsort 是一种排序方法,将数组中的元素解释为一棵几乎完整的二叉树。考虑下面的数组,它将按升序排序:

9781430266198_unFig09-01.jpg

我们可以把这个数组想象成一棵几乎完整的 12 节点二叉树,如图图 9-1 所示。

9781430266198_Fig09-01.jpg

图 9-1 。一个二叉树视图的数组

假设我们现在要求每个节点的值大于或等于其左右子树中的值,如果存在的话。实际上,只有节点 6 和叶子具有这种属性。简而言之,我们将看到如何重新排列节点,以便所有的节点都满足这个条件。但是,首先,我们给这样的结构一个名字:

一个 是一个几乎完全的二叉树,使得根的值大于或等于左右子树的值,左右子树也是堆。

这个定义的一个直接结果是最大值在根处。这样的堆被称为最大堆 。我们定义一个最小堆 ,用更小的代替更大的。在最小堆中,最小的值位于根。**

*现在让我们将图 9-1 中的二叉树转换成最大堆。

9.1.1 将二叉树转换成最大堆

首先,我们观察到所有的叶子都是堆,因为它们没有孩子。

从最后一个非叶节点(本例中为 6)开始,我们将根在那里的树转换为 max-heap。如果该节点的值大于其子节点的值,则无需采取任何措施。节点 6 就是这种情况,因为 84 大于 32。

接下来,我们转到节点 5。这里的值 48 小于至少一个孩子(在本例中是 56 和 69)。我们首先找到较大的孩子(69)并将其与节点 5 交换。因此,69 在节点 5 结束,48 在节点 11 结束。

接下来,我们去节点 4。较大的孩子 79 被移动到节点 4,65 被移动到节点 9。在这个阶段,树看起来像图 9-2 中的树。

9781430266198_Fig09-02.jpg

图 9-2 。处理完节点 6、5 和 4 后的树

在节点 3 继续,43 必须被移动。较大的孩子是 84,所以我们交换节点 3 和 6 的值。现在节点 6 的值(43)比它的子节点(32)大,所以没什么可做的了。但是,请注意,如果节点 6 的值是 28,那么它必须与 32 交换。

移动到节点 2,25 与其较大的孩子 79 交换。但是现在节点 4 中的 25 小于它在节点 9 中的子节点 65。因此,这两个值必须交换。

最后,在节点 1,37 与其较大的孩子 84 交换。它进一步与它的(新的)更大的孩子 73 交换,得到树,现在是一个堆,如图 9-3 所示。

9781430266198_Fig09-03.jpg

图 9-3 。最后一棵树,现在是一堆

9.1.2 分拣过程

在转换为堆之后,注意最大的值 84 位于树的根处。现在,数组中的值形成了一个堆,我们可以按如下方式按升序对它们进行排序:

  • 将最后一个项目 32 存储在临时位置。接下来,将 84 移动到最后一个位置(节点 12),释放节点 1。然后,假设 32 在节点 1 中,并移动它,使项目 1 到 11 成为一个堆。这将按如下方式完成:
  • 32 与其较大的子节点 79 交换,后者现在移入节点 1。然后,32 进一步与其(新的)更大的子节点 69 交换,该子节点移动到节点 2。

最后 32 和 56 交换,给我们图 9-4 。

9781430266198_Fig09-04.jpg

图 9-4 。在 84 被放置并且堆被重组之后

在此阶段,第二大数量 79 位于节点 1 中。这被放置在节点 11 中,并且 48 从节点 1“向下筛选”,直到项目 1 到 10 形成一个堆。现在,第三大数字 73 将成为根源。这被放置在节点 10 中,以此类推。重复该过程,直到阵列被排序。

在构建了初始堆之后,排序过程可以用下面的伪代码来描述:

        for k = n downto 2 do
           item = num[k]     //extract current last item
           num[k] = num[1]   //move top of heap to current last node
           siftDown(item, num, 1, k-1)  //restore heap properties from 1 to k-1
        end for

其中siftDown(item, num, 1, k-1)假设以下情况成立:

  • num[1]空无一物。
  • num[2]num[k-1]形成一堆。

从位置 1 开始,item被插入,使得num[1]num[k-1]形成一个堆。

在上述排序过程中,每次循环时,当前最后位置(k)中的值存储在item中。节点 1 的值移动到位置k;节点 1 变为空(可用),节点2k-1都满足堆属性。

调用siftDown(item, num, 1, k-1)将添加item,以便num[1]num[k-1]包含一个堆。这确保了下一个最高的数字在节点 1。

关于siftDown(当我们编写它时)的好处是它可以用来从给定的数组创建初始堆。回想一下 9.1.1 节中描述的创建堆的过程。在每个节点(h,比方说),我们“向下筛选值”,这样我们就形成了一个以h为根的堆。为了在这种情况下使用siftDown,我们将其概括如下:

        void siftDown(int key, int num[], int root, int last)

这假设了以下情况:

  • num[root]空无一物。
  • last是数组中的最后一个条目,num
  • num[root*2],如果存在(root*2 ≤ last),就是堆的根。
  • num[root*2+1],如果存在(root*2+1 ≤ last),就是堆的根。

root开始,key被插入,因此num[root]成为一个堆的根。

给定一组值num[1]num[n],我们可以用以下伪代码构建堆:

        for h = n/2 downto 1 do           // n/2 is the last non-leaf node
       siftDown(num[h], num, h, n)

我们现在展示如何写siftDown

9781430266198_Fig09-05.jpg

图 9-5 。一个堆,除了节点 1 和 2

考虑图 9-5 。

除了节点 1 和 2,所有其他节点都满足堆属性,因为它们大于或等于其子节点。假设我们想让节点 2 成为一个堆的根。实际上,值 25 小于它的子值(79 和 69)。我们希望编写siftDown以便下面的调用可以完成这项工作:

        siftDown(25, num, 2, 12)

这里,25keynum是数组,2是根,12是最后一个节点的位置。

此后,节点 2 到 12 中的每一个都将成为堆的根,下面的调用将确保整个数组包含一个堆:

        siftDown(37, num, 1, 12)

siftDown的要旨如下:

        find the bigger child of num[root]; //suppose it is in node m
        if (key >= num[m]) we are done; put key in num[root]
        //key is smaller than the bigger child
        store num[m] in num[root]  //promote bigger child
        set root to m

重复该过程,直到root处的值大于其子值或者没有子值。这里是siftDown:

        public static void siftDown(int key, int[] num, int root, int last) {
           int bigger = 2 * root;
           while (bigger <= last) { //while there is at least one child
              if (bigger < last) //there is a right child as well; find the bigger
                 if (num[bigger+1] > num[bigger]) bigger++;
              //'bigger' holds the index of the bigger child
              if (key >= num[bigger]) break;
              //key is smaller; promote num[bigger]
              num[root] = num[bigger];
              root = bigger;
              bigger = 2 * root;
           }
           num[root] = key;
        } //end siftDown

我们现在可以这样写heapSort:

        public static void heapSort(int[] num, int n) {
           //sort num[1] to num[n]
           //convert the array to a heap
           for (int k = n / 2; k >= 1; k--) siftDown(num[k], num, k, n);

           for (int k = n; k > 1; k--) {
              int item = num[k]; //extract current last item
              num[k] = num[1];   //move top of heap to current last node
              siftDown(item, num, 1, k-1); //restore heap properties from 1 to k-1
           }
        } //end heapSort

我们可以用程序 P9.1 来测试heapSort

程序 P9.1

        import java.io.*;
        public class HeapSortTest {
           public static void main(String[] args) throws IOException {
              int[] num = {0, 37, 25, 43, 65, 48, 84, 73, 18, 79, 56, 69, 32};
              int n = 12;
              heapSort(num, n);
              for (int h = 1; h <= n; h++) System.out.printf("%d ", num[h]);
              System.out.printf("\n");
           }

        public static void heapSort(int[] num, int n) {
           //sort num[1] to num[n]
           //convert the array to a heap
           for (int k = n / 2; k >= 1; k--) siftDown(num[k], num, k, n);

           for (int k = n; k > 1; k--) {
              int item = num[k]; //extract current last item
              num[k] = num[1];   //move top of heap to current last node
              siftDown(item, num, 1, k-1); //restore heap properties from 1 to k-1
           }
        } //end heapSort

           public static void siftDown(int key, int[] num, int root, int last) {
              int bigger = 2 * root;
              while (bigger <= last) { //while there is at least one child
                 if (bigger < last) //there is a right child as well; find the bigger
                    if (num[bigger+1] > num[bigger]) bigger++;
                 //'bigger' holds the index of the bigger child
                 if (key >= num[bigger]) break;
                 //key is smaller; promote num[bigger]
                 num[root] = num[bigger];
                 root = bigger;
                 bigger = 2 * root;
              }
              num[root] = key;
           } //end siftDown

        } //end class HeapSortTest

运行时,程序 P9.1 产生如下输出(num[1]num[12]排序):

        18 25 32 37 43 48 56 65 69 73 79 84

编程 :如前所述,heapSort对一个数组排序,假设 n 个元素从下标1n存储。如果它们从0存储到n-1,则必须进行适当的调整。它们将主要基于以下观察:

  • 根存储在num[0]中。
  • 如果2h+1 < n,节点h的左子节点就是节点2h+1
  • 如果2h+2 < n,节点h的子节点就是节点2h+2
  • 节点h的父节点是节点(h–1)/2(整数除法)。
  • 最后一个非叶节点是(n–2)/2(整数除法)。

你可以使用图 9-6 中所示的树(n = 12)来验证这些观察结果。

9781430266198_Fig09-06.jpg

图 9-6 。存储在从 0 开始的数组中的二叉树

系统会提示您重写heapSort,以便它对数组 num[0..n-1]进行排序。作为提示,请注意siftDown中唯一需要更改的是bigger的计算。我们现在用2 * root + 1代替2 * root

9.2 使用 siftUp 构建堆

考虑向现有堆中添加新节点的问题。具体来说,假设num[1]num[n]包含一个堆。我们想添加一个新的数字,newKey,这样num[1]num[n+1]包含一个包含newKey的堆。我们假设数组中有容纳新密钥的空间。

例如,假设我们有一个如图 9-7 所示的堆,我们想把 T0 添加到这个堆中。当添加新数字时,堆将包含 13 个元素。我们假设40被放在num[13](但是还没有把它存储在那里),并把它和它在num[6]的父43进行比较。由于40较小,满足堆属性;我们将40放在num[13]中,流程结束。

9781430266198_Fig09-07.jpg

图 9-7 。我们将向其中添加新项目的堆

但是假设我们想将80添加到堆中。我们假设80被放在num[13](但实际上还没有把它存储在那里),并把它和它在num[6]中的父43进行比较。由于80更大,我们将43移到num[13],并想象80被放置在num[6]中。

接下来,我们将80与它在num[3]中的父73进行比较。它更大,所以我们将73移到num[6],并想象80被放置在num[3]中。

然后我们将80与它在num[1]中的父84进行比较。它更小,所以我们将80放在num[3]中,流程结束。

注意,如果我们将90添加到堆中,84将被移动到num[3],而90将被插入到num[1]。它现在是堆中最大的数字。

图 9-8 显示了添加80后的堆。

9781430266198_Fig09-08.jpg

图 9-8 。添加 80 后的堆

以下代码将newKey添加到存储在num[1]num[n]的堆中:

        child = n + 1;
        parent = child / 2;
        while (parent > 0) {
           if (newKey <= num[parent]) break;
           num[child] = num[parent]; //move down parent
           child = parent;
           parent = child / 2;
        }
        num[child] = newKey;
        n = n + 1;

所描述的过程通常被称为筛选。我们可以将这段代码重写为一个函数siftUp。我们假设给了siftUp一个数组heap[1..n],使得heap[1..n-1]包含一个堆,并且heap[n]将被向上筛选,使得heap[1..n]包含一个堆。换句话说,heap[n]在前面的讨论中扮演了newKey的角色。

我们将siftUp显示为程序 P9.2 的一部分,该程序从存储在文件heap.in中的数字中创建一个堆。

程序 P9.2

     import java.io.*;
     import java.util.*;
     public class SiftUpTest {
        final static int MaxHeapSize = 100;
        public static void main (String[] args) throws IOException {
           Scanner in = new Scanner(new FileReader("heap.in"));
           int[] num = new int[MaxHeapSize + 1];
           int n = 0, number;

           while (in.hasNextInt()) {
              number = in.nextInt();
              if (n < MaxHeapSize) { //check if array has room
                 num[++n] = number;
                 siftUp(num, n);
              }
           }

           for (int h = 1; h <= n; h++) System.out.printf("%d ", num[h]);
           System.out.printf("\n");
           in.close();
        } //end main

        public static void siftUp(int[] heap, int n) {
        //heap[1] to heap[n-1] contain a heap
        //sifts up the value in heap[n] so that heap[1..n] contains a heap
           int siftItem = heap[n];
           int child = n;
           int parent = child / 2;
           while (parent > 0) {
              if (siftItem <= heap[parent]) break;
              heap[child] = heap[parent]; //move down parent
              child = parent;
              parent = child / 2;
           }
           heap[child] = siftItem;
        } //end siftUp

     } //end class SiftUpTest

假设heap.in包含以下内容:

    37 25 43 65 48 84 73 18 79 56 69 32

程序 P9.2 将构建堆(如下所述)并打印以下内容:

    84 79 73 48 69 37 65 18 25 43 56 32

372543被读取后,我们将有图 9-9 。

9781430266198_Fig09-09.jpg

图 9-9 。处理后堆 37,25,43

65488473被读取之后,我们将会有图 9-10 。

9781430266198_Fig09-10.jpg

图 9-10 。处理后堆 65、48、84、73

在读取了1879566932之后,我们将得到如图 9-11 中所示的最终堆。

9781430266198_Fig09-11.jpg

图 9-11 。处理后的最终堆 18、79、56、69、32

请注意,图 9-11 中的堆与图 9-3 中的堆不同,尽管它们由相同的数字组成。没变的是最大值84在根。

如果这些值已经存储在数组num[1..n]中,我们可以用下面的方法创建一个堆:

    for (int k = 2; k <= n; k++) siftUp(num, k);

9.3 堆排序的分析

对于创建堆来说,siftUp还是siftDown更好?请记住,任何节点移动的次数最多是 log2n。

siftDown中,我们处理 n /2 个节点,在每一步,我们进行两次比较:一次是寻找更大的子节点,一次是比较节点值和更大的子节点。在一个简单化的分析中,在最坏的情况下,我们将需要进行 2 *n/2 * log2n=nlog2n 的比较。然而,更仔细的分析会表明,我们最多只需要进行 4 次比较。

siftUp中,我们处理 n -1 个节点。在每一步,我们做一个比较:节点和它的父节点。在一个简单化的分析中,在最坏的情况下,我们进行(n-1)log2n的比较。然而,有可能所有的叶子都必须一路旅行到树的顶端。在这种情况下,我们有 n /2 个节点必须经过 log 2 n 的距离,总共有(n/2)log2n的比较。这只是为了树叶。最后,一个更仔细的分析仍然给了我们大约nlog2n 对于siftUp的比较。

性能上的差异取决于以下几点:在siftDown中,一半的节点(树叶)没有工作可做;siftUp为这些节点做的工作最多。

无论我们使用哪种方法来创建初始堆,heapsort 都会对大小为 n 的数组进行排序,最多进行 2 次nlog2n 比较和nlog2n赋值。这非常快。此外,堆排序是稳定的,因为它的性能总是最差 2nlog2n,而不管给定数组中项目的顺序如何。

为了了解 heapsort(以及所有顺序为 O(nlog2n)的排序方法,如 quicksort 和 mergesort)有多快,让我们将其与 selection sort 进行比较,selection sort 大致对n2 条目(表 9-1 进行比较。

表 9-1 。堆排序和选择排序的比较

Tab09-01.jpg

第二列和第三列显示了每种方法进行的比较次数。最后两列显示了每种方法的运行时间(秒),假设计算机每秒可以处理一百万次比较。例如,对 100 万个项目进行排序,选择排序将花费 500,000 秒(差不多 6 天!),而 heapsort 会在不到 40 秒的时间内完成。

9.4 堆和优先级队列

一个优先级队列是这样的队列,其中每个项目都被分配了一些“优先级”,并且它在队列中的位置是基于这个优先级的。优先级最高的项目被放在队列的最前面。以下是可以在优先级队列上执行的一些典型操作:

  • 移除(服务)具有最高优先级的项目
  • 添加具有给定优先级的项目
  • 从队列中删除项目
  • 更改项目的优先级,根据新的优先级调整其位置

我们可以把优先级想象成一个整数——整数越大,优先级越高。

很快,我们可以推测,如果我们将队列实现为 max-heap,那么优先级最高的项将位于根,因此可以很容易地将其移除。重新组织堆只需要从根中“筛选”出最后一项。

添加一个项目将涉及到将该项目放置在当前最后一个项目之后的位置,并对其进行筛选,直到找到正确的位置。

要从队列中删除任意一项,我们需要知道它的位置。删除它将涉及到用当前最后一个项目替换它,并向上或向下筛选它以找到它的正确位置。堆将减少一项。

如果我们改变一个项目的优先级,我们可能需要向上或向下筛选来找到它的正确位置。当然,它也可能保持在原来的位置,这取决于变化。

在许多情况下(例如,一台多任务计算机上的一个作业队列),一个作业的优先级可能会随着时间的推移而增加,以便它最终得到服务。在这些情况下,随着每次改变,作业向堆的顶部移动得更近;因此,只需要向上筛选。

在典型的情况下,关于优先级队列中的项目的信息保存在另一个可以快速搜索的结构中,例如二叉查找树。节点中的一个字段将包含用于实现优先级队列的数组中项的索引。

使用作业队列示例,假设我们想要向队列中添加一个项目。比方说,我们可以通过作业编号搜索树,并将项目添加到树中。它的优先级数用于确定它在队列中的位置。该位置存储在树节点中。

如果后来优先级改变了,则该项在队列中的位置被调整,并且这个新位置被存储在树节点中。请注意,调整此项可能还涉及到更改其他项的位置(当它们在堆中上移或下移时),并且还必须为这些项更新树。

9.5 使用快速排序对项目列表进行排序

快速排序的核心是相对于一个叫做枢纽 的值来划分列表的概念。例如,假设给我们以下要排序的列表:

9781430266198_unFig09-02.jpg

我们可以用第一个值 53 来划分 T2。这意味着将 53 放在这样一个位置,它左边的所有值都小于它,右边的所有值都大于或等于它。简而言之,我们将描述一种算法,该算法将如下划分num:

9781430266198_unFig09-03.jpg

数值 53 用作枢轴。它被放置在位置 6。53 左边的所有值都小于 53,右边的所有值都大于 53。支点所在的位置称为分界点 ( dp,比方说)。根据定义,53 处于其最终排序位置。

如果我们可以对num[1..dp-1]num[dp+1..n]进行排序,我们就已经对整个列表进行了排序。但是我们可以使用相同的过程来对这些片段进行排序,这表明递归过程是合适的。

假设有一个函数partition可以将分割成一个数组的给定部分,并返回分割点,我们可以将quicksort写成如下形式:

        public static void quicksort(int[] A, int lo, int hi) {
        //sorts A[lo] to A[hi] in ascending order
           if (lo < hi) {
              int dp = partition(A, lo, hi);
              quicksort(A, lo, dp-1);
              quicksort(A, dp+1, hi);
           }
        } //end quicksort

调用quicksort(num, 1, n)将按照升序对num[1..n]进行排序。

我们现在来看看partition可能是如何写的。考虑以下阵列:

9781430266198_unFig09-04.jpg

我们将通过一次遍历数组,相对于num[1],53(支点)对其进行划分。我们将依次查看每个数字。如果它比支点大,我们什么也不做。如果它比较小,我们把它移到数组的左边。最初,我们将变量lastSmall设置为1;随着方法的进行,lastSmall将是已知小于枢纽的最后一个项目的索引。我们对num分区如下:

  1. 比较1253;它更小,所以将1加到lastSmall(使其成为2)并将num[2]与其自身互换。

  2. 比较9853;它更大,所以继续前进。

  3. 比较6353;它更大,所以继续前进。

  4. Compare 18 with 53; it is smaller, so add 1 to lastSmall (making it 3) and swap num[3], 98, with 18.

    在这个阶段,我们有这个:

    9781430266198_unFig09-05.jpg

  5. 比较3253;它比较小,所以把1加到lastSmall(使之成为4),把num[4]6332互换。

  6. 比较8053;它更大,所以继续前进。

  7. Compare 46 with 53; it is smaller, so add 1 to lastSmall (making it 5) and swap num[5], 98, with 46.

    在此阶段,我们有以下内容:

    9781430266198_unFig09-06.jpg

  8. 比较7253;它更大,所以继续前进。

  9. 比较2153;它比较小,所以把1加到lastSmall(使之成为6),把num[6]6321互换。

  10. 我们已经到了数组的末尾;交换num[1]num[lastSmall];这将枢轴移动到其最终位置(在本例中为6)。

我们以此结束:

9781430266198_unFig09-07.jpg

分割点用lastSmall ( 6)表示。

我们可以将刚刚描述的方法表示为函数partition1。该功能显示为程序 P9.3 的一部分,我们编写该程序来测试quicksortpartition1

程序 P9.3

        import java.io.*;
        public class QuicksortTest {

           public static void main(String[] args) throws IOException {
              int[] num = {0, 37, 25, 43, 65, 48, 84, 73, 18, 79, 56, 69, 32};
              int n = 12;
              quicksort(num, 1, n);
              for (int h = 1; h <= n; h++) System.out.printf("%d ", num[h]);
              System.out.printf("\n");
           }

           public static void quicksort(int[] A, int lo, int hi) {
           //sorts A[lo] to A[hi] in ascending order
              if (lo < hi) {
                 int dp = partition1(A, lo, hi);
                 quicksort(A, lo, dp-1);
                 quicksort(A, dp+1, hi);
              }
           } //end quicksort

           public static int partition1(int[] A, int lo, int hi) {
           //partition A[lo] to A[hi] using A[lo] as the pivot
              int pivot = A[lo];
              int lastSmall = lo;
              for (int j = lo + 1; j <= hi; j++)
                 if (A[j] < pivot) {
                    ++lastSmall;
                    swap(A, lastSmall, j);
                 }
              //end for
              swap(A, lo, lastSmall);
              return lastSmall;  //return the division point
           } //end partition1

           public static void swap(int[] list, int i, int j) {
           //swap list[i] and list[j]
              int hold = list[i];
              list[i] = list[j];
              list[j] = hold;
           }

        } //end class QuicksortTest

运行时,程序 P9.3 产生如下输出(num[1]num[12]排序):

    18 25 32 37 43 48 56 65 69 73 79 84

Quicksort 是一种性能从非常快到非常慢的方法。通常情况下,它的顺序为 O(nlog2n),对于随机数据,比较的次数在nlog2n 和 3nlog2n 之间变化。然而,事情可能会变得更糟。

分区背后的思想是将给定的部分分成两个相当相等的部分。这种情况是否会发生,在很大程度上取决于被选作中枢的值。

在函数中,我们选择第一个元素作为支点。这在大多数情况下都能很好地工作,尤其是对于随机数据。但是,如果第一个元素恰好是最小的,那么划分操作就变得几乎没有用了,因为划分点仅仅是第一个位置。“左”块将是空的,“右”块将只比给定的子列表小一个元素。如果枢轴是最大的元素,类似的评论也适用。

虽然该算法仍然可以工作,但速度会大大降低。例如,如果给定的数组被排序,快速排序将变得和选择排序一样慢。

避免这个问题的一个方法是选择一个随机元素作为支点,而不仅仅是第一个。虽然这种方法仍有可能选择最小的(或最大的),但这种选择只是偶然的。

还有一种方法是选择第一个(A[lo])、最后一个(A[hi])和中间(A[(lo+hi)/2])项目的中间值作为枢纽。

建议你尝试各种选择支点的方法。

我们的实验表明,选择一个随机元素作为中枢是简单而有效的,即使对于排序后的数据也是如此。事实上,在许多情况下,这种方法处理排序数据比处理随机数据要快,这对于快速排序来说是一个不寻常的结果。

quicksort 的一个可能的缺点是,根据被排序的实际数据,递归调用的开销可能很高。我们将在 9.5.2 节中看到如何最小化这种情况。有利的一面是,quicksort 使用很少的额外存储空间。另一方面,mergesort(也是递归的)需要额外的存储空间(与被排序的数组大小相同)来促进排序后的片段的合并。Heapsort 没有这些缺点。它是而不是递归的,并且使用非常少的额外存储。正如 9.3 节所提到的,堆排序是稳定的,因为它的性能总是最差 2nlog2n,而不管给定数组中项目的顺序如何。

9.5.1 另一种分区方式

有许多方法可以实现分区的目标——将列表分成两部分,使左边部分的元素比右边部分的元素小。我们的第一个方法,如前所示,将枢轴放置在最终位置。为了多样化,我们将看看另一种分区方式。虽然这种方法仍然对一个枢纽进行分区,但是它不会而不是将枢纽放置在其最终的排序位置。正如我们将看到的,这不是一个问题。

再次考虑数组num[1..n],其中n = 10

9781430266198_unFig09-08.jpg

我们选择 53 作为支点。总的想法是从右边扫描,寻找小于或等于枢轴的键。然后,我们从左侧扫描大于或等于主元的键。我们交换这两个值;这个过程有效地将较小的值放在左边,将较大的值放在右边。

我们用两个变量,lohi,来标记我们在左边和右边的位置。最初,我们将lo设置为0,将hi设置为11 ( n+1)。然后我们循环如下:

  1. hi中减去1(使其成为10)。
  2. 比较num[hi]21,与53;它变小了,所以用hi = 10停止从右边扫描。
  3. 1添加到lo(使其成为1)。
  4. 比较num[lo]53,与53;它没有变小,所以用lo = 1停止从左边扫描。
  5. lo ( 1)小于hi ( 10),所以互换num[lo]num[hi]
  6. hi中减去1(使其成为9)。
  7. 比较num[hi]72,与53;它更大,所以减少hi(使其成为8)。比较num[hi]46,与53;它变小了,所以用hi = 8停止从右边扫描。
  8. 1添加到lo(使其成为2)。
  9. 比较num[lo]12,与53;它更小,所以在lo上加上1(使其成为3)。比较num[lo]98,与53;它比较大,所以用lo = 3停止从左边扫描。
  10. lo (3) is less than hi (8), so swap num[lo] and num[hi].
在这个阶段,我们有`lo` = `3``hi` = `8``num`如下:
![9781430266198_unFig09-09.jpg](https://p9-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/156745b016b24536b1b6d92409de82ee~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg5biD5a6i6aOe6b6Z:q75.awebp?rk3s=f64ab15b&x-expires=1770713858&x-signature=2Iey2X0VHQ%2Fo%2Fc8m0qlR%2BhcaSxM%3D)

11. 从hi中减去1(使其成为7)。 12. 比较num[hi]80,与53;它更大,所以减少hi(使其成为6)。比较num[hi]32,与53;它变小了,所以用hi = 6停止从右边扫描。 13. 将1添加到lo(使其成为4)。 14. 比较num[lo]63,与53;它比较大,所以用lo = 4停止从左边扫描。 15. lo (4) is less than hi (6), so swap num[lo] and num[hi], giving this:

![9781430266198_unFig09-10.jpg](https://p9-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/003f59692dd8487d8a26717efd03b13d~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg5biD5a6i6aOe6b6Z:q75.awebp?rk3s=f64ab15b&x-expires=1770713858&x-signature=X2UNWKg3onr1Ni2R8WKuvdeXVVs%3D)

16. 从hi中减去1(使其成为5)。 17. 比较num[hi]18,与53;它变小了,所以用hi = 5停止从右边扫描。 18. 将1添加到lo(使其成为5)。 19. 比较num[lo]18,与53;它更小,所以在lo上加上1(使其成为6)。比较num[lo]63,与53;它比较大,所以用lo = 6停止从左边扫描。 20. lo ( 6)比hi ( 5)少,算法结束。

hi的值使得num[1..hi]中的值小于num[hi+1..n]中的值。这里,num[1..5]中的值小于num[6..10]中的值。注意53不在其最终分类位置。然而,这不是问题,因为为了对数组进行排序,我们需要做的就是对num[1..hi]num[hi+1..n]进行排序。

我们可以将刚才描述的过程表示为partition2:

        public static int partition2(int[] A, int lo, int hi) {
        //return dp such that A[lo..dp] <= A[dp+1..hi]
           int pivot = A[lo];
           --lo; ++hi;
           while (lo < hi) {
              do --hi; while (A[hi] > pivot);
              do ++lo; while (A[lo] < pivot);
              if (lo < hi) swap(A, lo, hi);
           }
           return hi;
        } //end partition2

有了这个版本的分区,我们可以把quicksort2写成如下:

        public static void quicksort2(int[] A, int lo, int hi) {
        //sorts A[lo] to A[hi] in ascending order
           if (lo < hi) {
              int dp = partition2(A, lo, hi);
              quicksort2(A, lo, dp);
              quicksort2(A, dp+1, hi);
           }
        }

partition2中,我们选择第一个元素作为支点。然而,正如所讨论的,选择一个随机的元素会给出更好的结果。我们可以用下面的代码做到这一点:

        swap(A, lo, random(lo, hi));
        int pivot = A[lo];

在这里,random可以这样写:

        public static int random(int m, int n) {
        //returns a random integer from m to n, inclusive
           return (int) (Math.random() * (n - m + 1)) + m;
        }

9.5.2 非递归快速排序

在前面显示的quicksort版本中,在子列表被划分后,我们调用quicksort,左边部分跟着右边部分。在大多数情况下,这将工作得很好。然而,对于大的n,挂起的递归调用的数量可能会变得很大,以至于产生一个“递归栈溢出”错误。

在我们的实验中,如果给定的数据已经排序,并且第一个元素被选为枢纽,那么这种情况会发生在n = 7000 的情况下。然而,如果选择一个随机元素作为支点,即使对于n = 100000 也没有问题。

另一种方法是非递归地编写quicksort。这需要我们将列表中需要排序的部分堆叠起来。可以看出,当一个子列表被细分时,如果我们首先处理较小的子列表,那么栈元素的数量将被限制为最多 log2n。

举个例子,假设我们正在排序A[1..99],第一个分割点是 40。假设我们使用的是partition2,它不会将轴心放到最终的排序位置。因此,我们必须对A[1..40]A[41..99]进行排序以完成排序。我们将栈(41,99)并首先处理A[1..40](较短的子列表)。

假设A[1..40]的分割点是 25。我们将栈(1,25)并首先处理A[26..40]。在这个阶段,我们在栈上有两个子列表—(41,99)和(1,25)—需要排序。试图对A[26..40]进行排序将导致另一个子列表被添加到栈中,等等。在我们的实现中,我们还会将较短的子列表添加到栈中,但这将被立即移除并进行处理。

这里提到的结果向我们保证,在任何给定的时间,栈上绝不会有超过 log 2 99 = 7(取整)的元素。即使对于n = 1,000,000,我们也保证栈项数不会超过 20。

当然,我们必须自己操作栈。每个栈元素将由两个整数组成(比如说leftright,这意味着从leftright的列表部分仍有待排序。我们可以将NodeData定义如下:

        class NodeData {
           int left, right;

           public NodeData (int a, int b) {
              left = a;
              right = b;
           }

           public static NodeData getRogueValue() {return new NodeData(-1, -1);}

        } //end class NodeData

我们将使用 4.3 节中的栈实现。我们现在根据前面的讨论写quicksort3。它显示为独立程序 P9.4 的一部分。这个程序从文件quick.in中读取数字,使用quicksort3对数字进行排序,并打印排序后的数字,每行 10 个。

程序 P9.4

        import java.io.*;
        import java.util.*;
        public class Quicksort3Test {
           final static int MaxNumbers = 100;
           public static void main (String[] args) throws IOException {
              Scanner in = new Scanner(new FileReader("quick.in"));
              int[] num = new int[MaxNumbers+1];
              int n = 0, number;

              while (in.hasNextInt()) {
                 number = in.nextInt();
                 if (n < MaxNumbers) num[++n] = number; //store if array has room
              }

              quicksort3(num, 1, n);
              for (int h = 1; h <= n; h++) {
                 System.out.printf("%d ", num[h]);
                 if (h % 10 == 0) System.out.printf("\n"); //print 10 numbers per line
              }
              System.out.printf("\n");
           } //end main

           public static void quicksort3(int[] A, int lo, int hi) {
              Stack S = new Stack();
              S.push(new NodeData(lo, hi));
              int stackItems = 1, maxStackItems = 1;

              while (!S.empty()) {
                 --stackItems;
                 NodeData d = S.pop();
                 if (d.left < d.right) { //if the sublist is > 1 element
                    int dp = partition2(A, d.left, d.right);
                    if (dp - d.left + 1 < d.right - dp) {  //compare lengths of sublists
                       S.push(new NodeData(dp+1, d.right));
                       S.push(new NodeData(d.left, dp));
                    }
                    else {
                       S.push(new NodeData(d.left, dp));
                       S.push(new NodeData(dp+1, d.right));
                    }
                    stackItems += 2;   //two items added to stack
                 } //end if
                 if (stackItems > maxStackItems) maxStackItems = stackItems;
              } //end while
              System.out.printf("Max stack items: %d\n\n", maxStackItems);
           } //end quicksort3

           public static int partition2(int[] A, int lo, int hi) {
           //return dp such that A[lo..dp] <= A[dp+1..hi]
              int pivot = A[lo];
              --lo; ++hi;
              while (lo < hi) {
                 do --hi; while (A[hi] > pivot);
                 do ++lo; while (A[lo] < pivot);
                 if (lo < hi) swap(A, lo, hi);
              }
              return hi;
           } //end partition2

           public static void swap(int[] list, int i, int j) {
           //swap list[i] and list[j]
              int hold = list[i];
              list[i] = list[j];
              list[j] = hold;
           } //end swap

        } //end class Quicksort3Test

        class NodeData {
           int left, right;

           public NodeData(int a, int b) {
              left = a;
              right = b;
           }

           public static NodeData getRogueValue() {return new NodeData(-1, -1);}

        } //end class NodeData

        class Node {
           NodeData data;
           Node next;

           public Node(NodeData d) {
              data = d;
              next = null;
           }

        } //end class Node

        class Stack {
           Node top = null;

           public boolean empty() {
              return top == null;
           }

           public void push(NodeData nd) {
              Node p = new Node(nd);
              p.next = top;
              top = p;
           } //end push

           public NodeData pop() {
              if (this.empty())return NodeData.getRogueValue();
              NodeData hold = top.data;
              top = top.next;
              return hold;
           } //end pop

        } //end class Stack

quicksort3中,当partition2返回时,比较两个子列表的长度,长的先放入栈中,短的放入栈中。这确保了较短的一个将首先被取下,并在较长的一个之前被处理。

我们还在quicksort3中添加了语句,以跟踪在任何给定时间栈上的最大项目数。当用于对 100000 个整数进行排序时,栈项目的最大数量是 13。这小于理论上的最大值,log 2 100000 = 17,向上取整。

假设quick.in包含以下数字:

        43 25 66 37 65 48 84 73 60 79 56 69 32 87 23 99 85 28 14 78 39 51 44 35
        46 90 26 96 88 31 17 81 42 54 93 38 22 63 40 68 50 86 75 21 77 58 72 19

当程序 P9.4 运行时,产生如下输出:

Max stack items: 5

14 17 19 21 22 23 25 26 28 31
32 35 37 38 39 40 42 43 44 46
48 50 51 54 56 58 60 63 65 66
68 69 72 73 75 77 78 79 81 84
85 86 87 88 90 93 96 99

如前所述,即使一个子列表只包含两个条目,该方法也会经历调用 partition、检查子列表的长度以及堆叠两个子列表的整个过程。这似乎是一个可怕的工作排序两个项目。

我们可以通过使用一种简单的方法(比如插入排序)对短于某个预定义长度(比如 8)的子列表进行排序,从而使quicksort更加有效。我们敦促您使用这一更改来编写quicksort,并尝试不同的预定义长度值。

9.5.3 找出第 k 个最小的数字

考虑在一列 n 数中找到第k第个最小数的问题。一种方法是对第 n 个数字进行排序,挑出第 k个。如果数字存储在数组A[1..n]中,我们只需在排序后检索A[k]

另一种更有效的方法是使用分区的思想。我们将使用那个版本的partition,它将枢纽放在其最终的排序位置。考虑一个数组A[1..99],假设对partition的调用返回一个 40 的分割点。这意味着枢轴已经放置在A[40]中,较小的数字在左边,较大的数字在右边。换句话说,第 40 个最小的数字被放到了A[40]。所以,如果 k 是 40,我们马上就有答案了。

如果 T2 是 59 岁会怎么样?我们知道 40 个最小的数字占据了一个[1..40].所以,第 59 个一定是在一个【41..99],我们可以将搜索限制在数组的这一部分。换句话说,通过对partition的一次调用,我们可以从考虑中排除 40 个号码。这个想法类似于二分搜索法

假设对partition的下一次调用返回 65。我们现在知道第 65 个个最小的数字,第 59 个个将在A[41..64]中;我们已经将A[66..99]排除在考虑范围之外。我们每次都重复这个过程,减小包含第 59 个最小数字的部分的大小。最终,partition将返回 59,我们将得到我们的答案。

以下是kthSmall的一种写法;它使用partition1:

        public static int kthSmall(int[] A, int k, int lo, int hi) {
        //returns the kth smallest from A[lo] to A[hi]
           int kShift = lo + k - 1; //shift k to the given portion, A[lo..hi]
           if (kShift < lo || kShift > hi) return -9999;
           int dp = partition1(A, lo, hi);
           while (dp != kShift) {
              if (kShift < dp) hi = dp - 1; //kth smallest is in the left part
              else lo = dp + 1;             //kth smallest is in the right part
              dp = partition1(A, lo, hi);
           }
           return A[dp];
        } //end kthSmall

例如,调用kthSmall(num, 59, 1, 99)将从num[1..99]返回第 59 个个最小的数字。然而,请注意,调用kthSmall(num, 10, 30, 75)将从num[30..75]返回第 10 个最小的数字。

作为练习,编写递归版本的kthSmall

9.6 外壳(递减增量)排序

Shell sort(以 Donald Shell 命名)使用一系列的增量来管理排序过程。它对数据进行多次传递,最后一次传递与插入排序相同。对于其他遍,使用与插入排序相同的技术对相距固定距离(例如,相距 5)的元素进行排序。

例如,为了对下面的数组进行排序,我们使用三个增量—8、3 和 1:

9781430266198_unFig09-11.jpg

增量大小递减(因此术语递减增量排序 ),最后一个是 1。

使用增量 8,我们对数组进行八排序。这意味着我们对相距 8 的元素进行排序。我们对元素 1 和 9,2 和 10,3 和 11,4 和 12,5 和 13,6 和 14,7 和 15,8 和 16 进行排序。这将把num转换成这样:

9781430266198_unFig09-12.jpg

接下来,我们对数组进行三排序;也就是说,我们对相距三的元素进行排序。我们对元素进行排序(1,4,7,10,13,16),(2,5,8,11,14),和(3,6,9,12,15)。这为我们提供了以下信息:

9781430266198_unFig09-13.jpg

注意,在每一步,数组都离排序更近了一点。最后,我们执行单排序,对整个列表进行排序,给出最终的排序顺序:

9781430266198_unFig09-14.jpg

你可能会问,为什么我们不从一开始就做一次排序,然后对整个列表进行排序呢?这里的想法是,当我们到达进行单排序的阶段时,数组或多或少是有序的,如果我们使用一种更好地处理部分有序数据的方法(比如插入排序),那么排序可以快速进行。回想一下,插入排序可以进行少至 n 次比较(如果数据已经排序)或多至 n 次2 次比较(如果数据以降序排序,而我们想要升序)来排序一列 n 项。

当增量较大时,要排序的块较小。在本例中,当增量为 8 时,每个片段只包含两个元素。想必可以快速排序一个小列表。当增量较小时,要排序的块较大。然而,当我们到达小的增量时,数据是部分排序的,如果我们使用一种利用数据中的顺序的方法,我们可以快速地对片段进行排序。

我们将使用插入排序的一个略微修改的版本来对相距 h 的元素进行排序,而不是相距一个元素。

在插入排序中,当我们处理num[k]时,我们假设num[1..k-1]被排序,并在前面的项目中插入num[k],这样num[1..k]被排序。

假设增量是h,考虑我们如何处理num[k],其中k是任何有效的下标。记住,我们的目标是对相距h的项目进行排序。因此,我们必须根据num[k-h]num[k-2h]num[k-3h]等对num[k]进行排序,前提是这些元素都在数组中。当我们开始处理num[k]时,如果前面分开的h项在它们之间被排序,我们必须简单地在那些项之间插入num[k],以便结束于num[k]的子列表被排序。

为了说明,假设h = 3、k = 4。在num[4]之前只有一个元素是三远,那就是num[1]。因此,当我们开始处理num[4]时,我们可以假设num[1]本身已经排序。我们相对于num[1]插入num[4],以便对num[1]num[4]进行排序。

同样的,num[5]之前只有一个元素是三远的,也就是num[2]。因此,当我们开始处理num[5]时,我们可以假设num[2]本身已经排序。我们相对于num[2]插入num[5],以便num[2]num[5]被排序。类似的评论也适用于num[3]num[6]

当我们到达num[7]时,在num[7]之前的两个项目(num[1]num[4])被排序。我们插入num[7],以便对num[1]num[4]num[7]进行排序。

当我们到达num[8]时,在num[8]之前的两个项目(num[2]num[5])被排序。我们插入num[8],以便对num[2]num[5]num[8]进行排序。

当我们到达num[9]时,在num[9]之前的两个项目(num[3]num[6])被排序。我们插入num[9],以便对num[3]num[6]num[9]进行排序。

当我们到达num[10]时,在num[10]之前的三个项目(num[1]num[4]num[7])被排序。我们插入num[10],以便对num[1]num[4]num[7]num[10]进行排序。

等等。从h+1开始,我们遍历数组,相对于距离h的倍数的先前项目来处理每个项目。

在例子中,当h = 3 时,我们说必须对元素(1,4,7,10,13,16),(2,5,8,11,14),(3,6,9,12,15)进行排序。这是真的,但是我们的算法不会先对项目(1,4,7,10,13,16)进行排序,再对项目(2,5,8,11,14)进行排序,然后对项目(3,6,9,12,15)进行排序。

相反,它将按照以下顺序对这些片段进行并行排序:(1,4),(2,5),(3,6),(1,4,7),(2,5,8),(3,6,9),(1,4,7,10),(2,5,8,11),(3,6,9,12),(1,4,7,10,13),(2,5,8,11,14),(3,6,9,12,15),最后是(1,4,7,10,13,13 这听起来可能更困难,但实际上更容易编码,因为我们只需要从h+1开始遍历数组。

下面将对A[1..n]执行h排序:

        public static void hsort(int[] A, int n, int h) {
           for (int k = h + 1; k <= n; k++) {
              int j = k – h;
              int key = A[k];
              while (j > 0 && key < A[j]) {
                 A[j + h] = A[j];
                 j = j – h;
              }
              A[j + h] = key;
           }
        } //end hsort

警觉的读者会意识到,如果我们将h设置为 1,这就变成了插入排序。

编程注意事项:如果要对A[0..n-1]进行排序,必须将for语句改为如下,并在while语句中使用j >= 0:

        for (int k = h; k < n; k++)

给定一系列增量 h t ,h t-1 ,...,h 1 = 1,我们简单地用每个增量调用hsort,从最大到最小,来实现排序。

我们编写程序 P9.5 ,它从文件shell.in中读取数字,使用 Shell sort 对它们进行排序(有三个增量——8、3 和 1),并打印排序后的列表,每行十个数字。

程序 P9.5

        import java.io.*;
        import java.util.*;
        public class ShellSortTest {
           final static int MaxNumbers = 100;
           public static void main (String[] args) throws IOException {
              Scanner in = new Scanner(new FileReader("shell.in"));
              int[] num = new int[MaxNumbers+1];
              int n = 0, number;
              while (in.hasNextInt()) {
                 number = in.nextInt();
                 if (n < MaxNumbers) num[++n] = number; //store if array has room
              }

              //perform Shell sort with increments 8, 3 and 1
              hsort(num, n, 8);
              hsort(num, n, 3);
              hsort(num, n, 1);

              for (int h = 1; h <= n; h++) {
                 System.out.printf("%d ", num[h]);
                 if (h % 10 == 0) System.out.printf("\n"); //print 10 numbers per line
              }
              System.out.printf("\n");
           } //end main

           public static void hsort(int[] A, int n, int h) {
              for (int k = h + 1; k <= n; k++) {
                 int j = k - h;
                 int key = A[k];
                 while (j > 0 && key < A[j]) {
                    A[j + h] = A[j];
                    j = j - h;
                 }
                 A[j + h] = key;
              } //end for
           } //end hsort

        } //end class ShellSortTest

假设shell.in包含以下数字:

        43 25 66 37 65 48 84 73 60 79 56 69 32 87 23 99 85 28 14 78 39 51 44 35
        46 90 26 96 88 31 17 81 42 54 93 38 22 63 40 68 50 86 75 21 77 58 72 19

当程序 P9.5 运行时,产生如下输出:

14 17 19 21 22 23 25 26 28 31
32 35 37 38 39 40 42 43 44 46
48 50 51 54 56 58 60 63 65 66
68 69 72 73 75 77 78 79 81 84
85 86 87 88 90 93 96 99

顺便说一下,我们注意到,如果增量存储在一个数组中(incr),并且用数组中的每个元素依次调用hsort,我们的代码会更加灵活。例如,假设incr[0]包含增量数(m),而incr[1]incr[m]包含以incr[m] = 1递减的增量。我们可以调用hsort,每次递增如下:

        for (int i = 1; i <= incr[0]; i++) hsort(num, n, incr[i]);

出现的一个问题是,我们如何决定给定的n使用哪个增量?已经提出了许多方法;以下给出了合理的结果:

        let h`1`= 1
        generate h`s+1`= 3h`s`+ 1, for s = 1, 2, 3,...
        stop when h`t+2` ≥ n; use h`1`to h`t`as the increments for the sort

换句话说,我们生成序列的项,直到某项大于或等于n。丢弃最后两个,使用其他的作为排序的增量。

举个例子,如果n = 100,我们生成 h 1 = 1,h 2 = 4,h 3 = 13,h 4 = 40,h 5 = 121。由于 h519】100,我们以 h 1 ,h 2 ,h 3 为增量对 100 个项目进行排序。

Shell 排序的性能介于简单的 O( n 2 )方法(插入、选择)和 O(nlog2n)方法(堆排序、快速排序、合并排序)之间。其顺序约为 O( n 1.3 )对于 n 在一个实际范围内趋于 O(n(log2n)2)随着 n 趋于无穷大。

作为练习,编写一个程序使用 Shell sort 对一个列表进行排序,计算在对列表排序时进行的比较和赋值的次数。

练习 9

  1. Write a program to compare the performance of the sorting methods discussed in this chapter with respect to “number of comparisons” and “number of assignments”. For quicksort, compare the performance of choosing the first element as the pivot with choosing a random element.

    运行程序以(I)对随机提供的 10、100、1000、10000 和 100000 个元素进行排序,以及(ii)对已经排序的 10、100、1000、10000 和 100000 个元素进行排序。

  2. 函数makeHeap被传递一个整数数组A。如果A[0]包含n,那么A[1]A[n]包含任意顺序的数字。编写makeHeap使得A[1]A[n]包含一个 max-heap(根处的最大值)。您的函数必须按照A[2]A[3]、...,A[n]

  3. 堆存储在一维整数数组num[1..n]中,最大的值位于位置 1。给出一个有效的算法,删除根元素,重新排列其他元素,使堆现在占据num[1]num[n-1]

  4. 堆存储在一维整数数组A[0..max]中,其中最大值位于位置 1。A[0]指定任意时刻堆中元素的数量。写一个函数向堆中添加一个新值v。如果堆最初是空的,你的函数应该工作,如果没有空间存储v,应该打印一条消息。

  5. Write code to read a set of positive integers (terminated by 0) and create a heap in an array H with the smallest value at the top of the heap. As each integer is read, it is inserted among the existing items such that the heap properties are maintained.  At any time, if n numbers have been read then H[1..n] must contain a heap. Assume that H is large enough to hold all the integers.

    给定数据:51 26 32 45 38 89 29 58 34 23 0

    在每个数字被读取和处理后,显示 H 的内容。

  6. 一个函数被赋予一个整数数组A和两个下标mn。该函数必须重新排列元素A[m]A[n]并返回下标d,使得d左边的所有元素都小于或等于A[d],而d右边的所有元素都大于A[d]

  7. 编写一个函数,给定一个整数数组num和一个整数n,使用 Shell sort 对元素num[1]num[n]进行排序。该函数必须返回在执行排序时进行的键比较的数量。您可以使用任何合理的方法来确定增量。

  8. 单个整数数组A[1..n]包含以下内容:A[1..k]包含最小堆,A[k+1..n]包含任意值。编写有效的代码来合并这两个部分,以便A[1..n]包含一个最小堆。难道没有使用任何其他数组。

  9. An integer max-heap is stored in an array (A, say) such that the size of the heap (n, say) is stored in A[0] and A[1] to A[n] contain the elements of the heap with the largest value in A[1].

    (I)编写一个函数deleteMax,在给定一个类似于A的数组的情况下,删除最大的元素并重组该数组,使其仍然是一个堆。

    (ii)给定如上所述包含堆的两个数组AB,编写编程代码以将AB的元素合并到另一个数组C中,使得C按升序排列。您的方法必须通过比较A中的一个元素和B中的一个元素来进行。你可以假设deleteMax是可用的。

  10. 编写一个递归函数,在一个由 n 个数字组成的数组中寻找第 k 个最小的数字,不需要对数组进行排序。

  11. 使用二分搜索法编写插入排序,以确定A[j]在排序后的子列表A[1..j-1]中的插入位置。

  12. 如果相等的键在排序后保持它们原来的相对顺序,那么一个排序算法被称为是稳定的。讨论的排序方法中哪些是稳定的?

  13. You are given a list of n numbers. Write efficient algorithms to find (i) the smallest (ii) the largest (iii) the mean (iv) the median (the middle value) and (v) the mode (the value that appears most often).

写一个高效的算法,求全部五个值。
  1. It is known that every number in a list of n distinct numbers is between 100 and 9999. Devise an efficient method for sorting the numbers.
如果列表中可能包含重复的数字,请修改列表的排序方法。
  1. 修改合并排序(第五章)和快速排序,以便如果要排序的子列表小于某个预定义的大小,则使用插入排序进行排序。
  2. You are given a list of n numbers and another number x. You must find the smallest number in the list that is greater than or equal to x. You must then delete this number from the list and replace it by a new number y, retaining the list structure. Devise ways of solving this problem using (i) unsorted array (ii) sorted array (iii) sorted linked list (iv) binary search tree (v) heap.
这些中哪一个是最有效的?
  1. 给你一份(很长的)英语单词表。写一个程序来确定这些单词中哪些是彼此的变位词。输出由每组变位词(两个或更多单词)组成,后跟一个空行。两个单词如果由相同的字母组成,如(老师,骗子),(妹子,反抗),就是变位词。
  2. Each value in A[1..n] is either 1, 2 or 3. You are required to find the minimal number of exchanges to sort the array. For example, the array
![9781430266198_unFig09-15.jpg](https://p9-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/69ddeeb6466f4a9a8ee7232549cb27c2~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg5biD5a6i6aOe6b6Z:q75.awebp?rk3s=f64ab15b&x-expires=1770713858&x-signature=6erjP6HVLTolfEPczhRYeN%2FIwDU%3D)
可以用四个交换排序,依次为:(1,3) (4,7) (2,9) (5,9)。另一种解法是(1,3) (2,9) (4,7) (5,9)。少于四次交换无法对数组进行排序。*

十、哈希

在本章中,我们将解释以下内容:

  • 哈希所基于的基本思想
  • 如何使用哈希解决搜索和插入问题
  • 如何从哈希表中删除项目
  • 如何使用线性探测解决冲突
  • 如何使用二次探测解决冲突
  • 如何使用链接解决冲突
  • 如何使用双哈希线性探测解决冲突
  • 如何使用数组按顺序链接项目

10.1 哈希基础知识

在(大)表中搜索一个项目是许多应用中的常见操作。在本章中,我们将讨论哈希,这是一种执行这种搜索的快速方法。哈希背后的主要思想是使用项目的密钥(例如,车辆记录的车辆注册号码)来确定项目存储在表中的(哈希表 )的*。这个键首先被转换成一个数字(如果它还不是一个数字的话),然后这个数字被映射(我们称之为哈希)到一个表位置。用于将键转换为表位置的方法称为哈希函数 。*

当然,两个或更多的键哈希到同一个位置是完全可能的。当这种情况发生时,我们说我们有一个碰撞 ,我们必须找到一个解决碰撞的方法。哈希的效率(或其他方面)在很大程度上取决于用于解决冲突的方法。这一章的大部分时间都在讨论这些方法。

10.1.1 搜索和插入问题

搜索和插入问题的经典陈述如下:

给定一个项目列表(该列表最初可能为空),在列表中搜索给定的项目。如果找不到该项目,请将其插入列表。

项目通常可以是数字(学生、帐户、员工、车辆等等)、名称、单词或字符串。例如,假设我们有一组整数,不一定是不同的,我们想知道有多少个不同的整数。

我们从一个空列表开始。对于每个整数,我们在列表中查找。如果没有找到,它将被添加到列表中并进行计数。如果被发现了,那也没办法。

在解决这个问题时,一个主要的设计决策是如何搜索列表,这反过来又取决于如何存储列表以及如何添加新的整数。以下是一些可能性:

  1. 列表存储在一个数组中,一个新的整数放在数组中的下一个可用位置。这意味着必须使用顺序搜索来查找传入的整数。这种方法具有简单和易于添加的优点,但是随着列表中的数字越来越多,搜索时间会越来越长。
  2. 列表存储在一个数组中,并以列表总是有序的方式添加一个新的整数。这可能需要移动已经存储的号码,以便新号码可以插入正确的位置。
  3. 但是,由于列表是有序的,所以可以使用二分搜索法来搜索传入的整数。对于这种方法,搜索速度更快,但是插入速度比前一种方法慢。因为一般来说,搜索比插入更频繁,所以这种方法可能优于前一种方法。
  4. 这里的另一个优点是,在最后,整数将按顺序排列,如果这很重要的话。如果使用方法 1,则必须对数字进行排序。
  5. 该列表存储为未排序的链表,因此必须按顺序搜索。因为如果一个输入号码不存在,必须遍历整个列表,所以可以在开头或结尾添加新号码;两者同样容易。
  6. 该列表存储为排序的链表。必须在“适当的位置”插入一个新号码,以保持顺序。一旦找到位置,插入就很容易了。如果来电号码不存在,则不必遍历整个列表,但是我们仍然受限于顺序搜索。
  7. 该列表存储在二叉查找树中。如果树不变得太不平衡,搜索会相当快。添加号码很容易——只需设置几个链接。如果需要的话,对树的有序遍历将给出排序后的数字。

还有一种可能是称为哈希的方法。正如我们将看到的,这具有搜索速度极快和易于插入的优点。

10.2 通过哈希解决搜索和插入问题

我们将通过解决整数列表的“搜索和插入”问题来说明哈希是如何工作的。该列表将存储在数组num[1]num[n]中。在我们的例子中,我们将假设n12

9781430266198_unFig10-01.jpg

最初,列表中没有数字。假设第一个来电号码是52。哈希背后的想法是将52(通常称为)转换成有效的表位置(比如说k)。这里,有效的工作台位置是112

如果num[k]中没有数字,那么52被存储在那个位置。如果num[k]被另一个键占用,我们说发生了碰撞 ,我们必须找到另一个位置来尝试放置52。这叫做解决 碰撞

用来将一个键转换成一个表位置的方法被称为哈希函数(比如说H)。可以使用任何产生有效表位置(数组下标)的计算,但是,正如我们将看到的,有些函数比其他函数给出更好的结果。

例如,我们可以使用H1(key) = key % 10 + 1。换句话说,我们在密钥的最后一位数字上加 1。因此,52将哈希到3。注意H1仅产生 1 到 10 之间的位置。比方说,如果这个表有 100 个位置,那么这个函数将是有效的,但是它可能不是一个好的函数。

还要注意,H(key) = key % 10在这里不是一个合适的哈希函数,因为,例如,50将哈希到0,并且没有表位置0。当然,如果位置从下标0开始,那么key % 10将是有效的,只要有至少十个位置。

另一个功能是H2(key) = key % 12 + 1。表达式key % 12产生一个介于011之间的值;添加1得到112之间的值。一般来说,key % n + 1产生的值在1n之间,包括这两个值。我们将在我们的例子中使用这个函数。

H2(52) = 52 % 12 + 1 = 5。我们说,“52哈希到位置5由于num[5]为空,我们将52放在num[5]中。

假设,后来,我们在搜索52。我们首先应用哈希函数,得到5。我们比较num[5]52;它们匹配,所以我们只通过一次比较就找到了52

现在假设以下键按给定的顺序出现:

        52  33  84  43  16  59  31  23  61
  • 52放在 num[5]中。
  • 33哈希到10num[10]是空的,所以33放在num[10]
  • 84哈希到1num[1]是空的,所以84放在num[1]
  • 43哈希到8num[8]是空的,所以43放在num[8]

在这个阶段,num可以这样描绘:

9781430266198_unFig10-02.jpg

  • 16哈希到5num[5]被占领,而不是被16占领——我们发生了碰撞。为了解决这个冲突,我们必须找到另一个放置16的位置。一个显而易见的选择是尝试下一个位置6num[6]为空,所以16放在num[6]里。
  • 59哈希到12num[12]是空的,所以59放在num[12]
  • 31哈希到8num[8]被占领,而不是被31占领——我们发生了碰撞。我们试试下一个地点,9num[9]为空,所以31放在num[9]里。

在这个阶段,num看起来是这样的:

9781430266198_unFig10-03.jpg

  • 23哈希到12num[12]被占领,而不是被23占领——我们发生了碰撞。我们必须尝试下一个位置,但是这里的下一个位置是什么?我们假设桌子是“圆形”的,因此位置1跟随位置12。然而,num[1]被占用而不是被23占用。所以,我们试试num[2]num[2]为空,所以23放在num[2]中。
  • 最后,61哈希到2num[2]被占领,而不是被61占领——我们发生了碰撞。我们试试下一个地点,3num[3]为空,所以61放在num[3]里。

下面显示了插入所有数字后的数组:

9781430266198_unFig10-04.jpg

请注意,如果数组中已经有一个数字,该方法会找到它。例如,假设我们正在搜索 23。

  • 23哈希到12
  • num[12]被占用,未被23占用。
  • 我们试试下一个位置,1num[1]23占领而不是被【】占领。
  • 我们接下来试试num[2]num[2]23占领——我们找到了。

假设我们正在搜索3333哈希到10,num[10]包含33——我们立即找到它。

作为练习,在使用哈希函数H1(key) = key % 10 + 1添加了之前的数字之后,确定num的状态。

我们可以用下面的算法来总结这个过程:

        //find or insert 'key' in the hash table, num[1..n]
        loc = H(key)
        while (num[loc] is not empty && num[loc] != key) loc = loc % n + 1
        if (num[loc] is empty) { //key is not in the table
           num[loc] = key
           add 1 to the count of distinct numbers
        }
        else print key, " found in location ", loc

请注意表示转到下一个位置的表达式loc % n + 1。如果loc小于n,则loc % n简单来说就是loc,表达式与loc + 1相同。如果loc nloc % n为 0,表达式求值为1。无论哪种情况,loc都采用下一个位置的值。

机警的读者会意识到,当num[loc]为空或包含密钥时,我们退出while循环。如果这两种情况都没有发生,那么while循环永远不会退出,该怎么办?如果表完全满了(没有空的位置)并且不包含我们要搜索的键,就会出现这种情况。

然而,在实践中,我们从不允许哈希表变得完全满。我们总是确保有一些“额外的”位置没有被键填充,这样while语句将在某个点退出。一般来说,当表中有更多空闲位置时,哈希技术工作得更好。

算法如何判断一个位置何时是“空”的?我们需要用表示“空”的值来初始化数组。例如,如果键是正整数,我们可以使用0-1作为空值。

让我们编写程序 P10.1 ,它从文件中读取整数numbers.in,并使用哈希技术来确定文件中不同整数的数量。

程序 P10.1

        import java.util.*;
        import java.io.*;
        public class DistinctNumbers {
           final static int MaxDistinctNumbers = 20;
           final static int N = 23;
           final static int Empty = 0;

           public static void main(String[] args) throws IOException {
              Scanner in = new Scanner(new FileReader("numbers.in"));
              int[] num = new int[N + 1];
              for (int j = 1; j <= N; j++) num[j] = Empty;
              int distinct = 0;
              while (in.hasNextInt()) {
                 int key = in.nextInt();
                 int loc = key % N + 1;
                 while (num[loc] != Empty && num[loc] != key) loc = loc % N + 1;

                 if (num[loc] == Empty) { //key is not in the table
                    if (distinct == MaxDistinctNumbers) {
                       System.out.printf("\nTable full: %d not added\n", key);
                       System.exit(1);
                    }
                    num[loc] = key;
                    distinct++;
                 }
              } //end while
              System.out.printf("\nThere are %d distinct numbers\n", distinct);
              in.close();
           } //end main

   } //end class DistinctNumbers

假设numbers.in包含这些数字:

    25 28 29 23 26 35 22 31 21 26 25 21 31 32 26 20 36 21 27 24 35 23 32 28 36

运行时,程序 P10.1 打印以下内容:

        There are 14 distinct numbers

以下是关于程序 P10.1 的一些说明:

  • MaxDistinctNumbers ( 20)是满足不同号码的最大数量。
  • N ( 23)是哈希表的大小,比MaxDistinctNumbers稍大一点,因此表中至少有三个空闲位置。
  • 哈希表占用num[1]num[N]。如果你愿意,可以使用num[0];在这种情况下,哈希函数可以简单地是key % N
  • 如果key不在表中(遇到空位置),我们首先检查条目数是否达到了MaxDistinctNumbers。如果有,我们声明该表已满,并且不添加key。否则,我们把key放在表中计数。
  • 如果找到了key,我们就继续读取下一个数字。

哈希函数

在上一节中,我们看到了如何将一个整数键“哈希”到一个表位置。事实证明,“余数”运算(%)对于这样的键通常会给出很好的结果。但是,如果键是非数字的,例如单词或名字,该怎么办呢?

第一项任务是将非数字键转换为数字,然后应用“余数”假设关键是一个词。也许最简单的方法就是把单词中每个字母的数值加起来。如果单词存储在字符串变量word中,我们可以这样做:

        int wordNum = 0;
        for (int h = 0; h < word.length(); h++) wordNum += word.charAt(h);
        loc = wordNum % n + 1; //loc is assigned a value from 1 to n

这种方法是可行的,但是有一个问题是包含相同字母的单词会哈希到相同的位置。比如队友都会哈希到同一个位置。在哈希过程中,我们必须尽量避免故意将键哈希到同一个位置。解决这个问题的一种方法是根据每个字母在单词中的位置给它分配一个权重。

我们可以任意分配权重——主要目标是避免将具有相同字母的键哈希到相同的位置。例如,我们可以将 3 分配给第一个位置,5 分配给第二个位置,7 分配给第三个位置,依此类推。下面显示了如何操作:

        int wordNum = 0;
        int w = 3;
        for (int h = 0; h < word.length(); h++) {
           wordNum += word.charAt(h) * w;
           w = w + 2;
        }
        loc = wordNum % n + 1; //loc is assigned a value from 1 to n

如果一个键包含任意字符,同样的技术也适用。

在哈希中,我们希望键分散在整个表中。例如,如果键被哈希到表的一个区域,我们可能会以不必要的大量冲突而告终。为此,我们应该尽量使用键的所有。例如,如果键是字母键,那么将所有以相同字母开头的键映射到相同的位置是不明智的。换句话说,我们应该避免系统地击中同一个位置。

由于哈希意味着快速,哈希函数应该相对容易计算。如果我们花太多时间计算哈希位置,速度优势将会减弱。

10.2.2 从哈希表中删除项目

再次考虑插入所有样本号后的数组:

9781430266198_unFig10-05.jpg

回想一下,4331最初都被哈希到位置8。假设我们要删除43。第一个想法可能是将其位置设置为空。假设我们这样做了(将num[8]设置为空),现在正在寻找31。这将哈希到8;但是由于num[8]为空,我们会错误地得出结论,认为31不在表中。因此,我们不能简单地通过将一个项目的位置设置为空来删除它,因为其他项目可能变得无法访问。

最简单的解决方案是将其位置设置为一个删除了值——这个值不能与或一个键混淆。在这个例子中,如果键是正整数,我们可以用0代表Empty,用-1代表Deleted

现在,当搜索时,我们仍然检查关键字或空位置;删除的位置将被忽略。一个常见的错误是在删除的位置停止搜索;这样做会导致错误的结论。

如果我们的搜索发现一个传入的键不在表中,那么这个键可以被插入到一个空的位置或者一个被删除的位置,如果在这个过程中遇到一个这样的位置的话。例如,假设我们通过将num[8]设置为-1删除了43。如果我们现在搜索55,我们将检查位置891011。由于num[11]是空的,我们推断55不在表中。

如果我们愿意,我们可以将num[11]设置为55。但是我们可以写我们的算法来记住在8被删除的位置。如果我们这样做了,那么我们可以在num[8]中插入55。这是更好的,因为我们会发现55num[11]更快。我们还将通过减少删除位置的数量来更好地利用我们的可用位置。

如果沿途有几个被删除的位置呢?最好使用遇到的第一个,因为这将减少密钥的搜索时间。有了这些想法,我们可以如下重写我们的搜索/插入算法:

        //find or insert 'key' in the hash table, num[1..n]
        loc = H(key)
        deletedLoc = 0
        while (num[loc] != Empty && num[loc] != key) {
           if (deletedLoc == 0 && num[loc] == Deleted) deletedLoc = loc
           loc = loc % n + 1
        }

        if (num[loc] == Empty) { //key not found
           if (deletedLoc != 0) loc = deletedLoc
           num[loc] = key
        }
        else print key, " found in location ", loc

请注意,我们仍然搜索,直到我们找到一个空的位置或关键。如果我们遇到一个被删除的位置并且deletedLoc0,这意味着它是第一个。当然,如果我们从来没有遇到一个被删除的位置,并且这个键不在表中,它将被插入一个空的位置。

10.3 解决冲突

在程序 P10.1 中,我们通过查看表中的下一个位置来解决冲突。这也许是解决冲突最简单的方法。我们说我们使用线性探测来解决冲突,我们将在下一节更详细地讨论这个问题。在这之后,我们将看看解决冲突的更复杂的方法。其中有二次探测链接双重哈希

10.3.1 线性探测

线性探测的特点是陈述 loc = loc + 1。再次考虑九个数字相加后num的状态:

9781430266198_unFig10-06.jpg

如您所见,随着表的填满,将新键哈希到空位置的机会减少了。

假设一个键哈希到位置12。试完12123后,放在num[4]。事实上,任何哈希为121234的新键都将以num[4]结束。当这种情况发生时,我们将有一个从位置12到位置6的长的、不间断的密钥链。任何新的哈希到这个链的密钥都将在num[7]中结束,创建一个更长的链。

这种群集 的现象是线性探测的主要缺点之一。长链倾向于变得更长,因为哈希到长链的概率通常大于哈希到短链的概率。两条短链也很容易连接起来,形成一条更长的链,而这条链又会变得更长。例如,任何以num[7]结尾的键都会创建一个从位置510的长链。

我们定义了两种类型的聚类。

  • 当哈希到不同位置的关键字在寻找空位置时跟踪相同的序列时,发生初级聚类 。线性探测展示了初级聚类,因为哈希到5的关键字将跟踪56789等等,哈希到6的关键字将跟踪6789等等。
  • 当哈希到相同位置的关键字在寻找空位置时跟踪相同序列时,发生二次聚类 。线性探测展示了二次聚类,因为哈希到5的关键字将跟踪相同的序列56789等等。

解决冲突的方法希望改进线性探测,目标是消除初级和/或次级聚类。

您可能想知道使用loc = loc + k,其中k是一个大于 1 的常数(例如,3)是否会给出比loc = loc + 1更好的结果。事实证明,这不会改变群集现象,因为仍然会形成k分开的键组。

此外,它甚至可能比当k为 1 时更糟,因为可能不会生成所有位置。

假设表的大小是12k3,一个键哈希到5。跟踪的位置顺序将是58112 ( 11 + 3 - 12)、5,并且该顺序重复自身。换句话说,在寻找空位置时,只有相对较少的位置将被探测。相比之下,当k1时,生成所有位置。

然而,这并不是一个真正的问题。如果表的大小是m并且km是“互质的”(它们唯一的公因数是 1),那么所有的位置都被生成。如果两个数中一个是素数,另一个不是它的倍数,比如 5 和 12,那么这两个数就是相对素数。但是素数不是必要条件。数字 21 和 50(两者都不是质数)是相对质数,因为它们除了 1 之外没有公因数。

如果k5m12,一个哈希到5的键会按照510381611492712的顺序追踪,所有的位置都会生成。哈希到任何其他位置的密钥也将生成所有位置。

在任何情况下,能够生成所有位置都是理论上的,因为如果我们不得不跟踪许多位置来找到一个空的位置,搜索将会太慢,并且我们可能需要使用另一种方法。

尽管我们刚刚说过,但结果是loc = loc + k,其中k 随密钥变化,为我们提供了实现哈希的最佳方式之一。我们将在 10.3.4 节中看到如何实现。

那么,线性方法有多快呢?我们感兴趣的是平均搜索长度,也就是为了找到或插入一个给定的键而必须检查的位置的数量。在上例中,33的搜索长度为1,61的搜索长度为2,23的搜索长度为3

搜索长度是表中负载系数f 的函数,其中

pg294.jpg

对于成功的搜索,平均比较次数为pg294a.jpg,对于不成功的搜索,平均比较次数为pg294b.jpg。注意,搜索长度只取决于填充的表格的分数,不取决于表格的大小。

表 10-1 显示了当表格填满时,搜索长度是如何增加的。

表 10-1 。随着表格填满,搜索长度会增加

|

f

|

成功的 搜索长度

|

不成功的 搜索长度

| | --- | --- | --- | | Zero point two five | One point two | One point four | | Zero point five | One point five | Two point five | | Zero point seven five | Two point five | Eight point five | | Zero point nine | Five point five | Fifty point five |

在 90%满的情况下,平均成功搜索长度是合理的 5.5。但是,确定一个新的键不在表中可能需要相当长的时间(50.5 次探测)。如果使用线性探头,明智的做法是确保表不会超过 75%。这样,我们可以用简单的算法保证良好的性能。

10.3.2 二次探测

在这个方法中,假设一个进来的键在位置loc与另一个发生冲突;我们前进 ai + bi 2 其中 ab 是常数,并且 i 对于第一次碰撞取值 1,如果键再次碰撞取值 2,如果再次碰撞取值 3,等等。例如,如果我们让 a = 1, b =1,我们从位置loc向前I+I2。假设初始哈希位置是 7,并且存在冲突。

我们用 i = 1 计算I+I2;这样得到 2,所以我们向前移动 2,检查位置 7 + 2 = 9。

如果还是有碰撞,我们用 i = 2 计算I+I2;这给出了 6,因此我们向前移动 6 并检查位置 9 + 6 = 15。

如果还有碰撞,我们用 i = 3 计算I+I2;这样得到 12,所以我们向前移动 12,检查位置 15 + 12 = 27。

等等。每次发生碰撞,我们都将 i 加 1,并重新计算这次我们必须前进多少。我们继续这样,直到我们找到钥匙或一个空的位置。

如果,在任何时候,前进使我们超越了桌子的末端,我们回到开始。例如,如果表的大小是 25,我们前进到位置 27,我们绕到位置 27–25,即位置 2。

对于下一个传入的键,如果在初始哈希位置有冲突,我们将 i 设置为 1,并按照前面的解释继续。值得注意的是,对于每个键,“增量”的顺序将是 2、6、12、20、30....当然,我们可以通过为 ab 选择不同的值来得到不同的序列。

我们可以用下面的算法总结刚才描述的过程:

        //find or insert 'key' in the hash table, num[1..n]
        loc = H(key)
        i = 0
        while (num[loc] != Empty && num[loc] != key) {
           i = i + 1
           loc = loc + a * i + b * i * i
           while (loc > n) loc = loc – n    //while instead of if; see note below
        }
        if (num[loc] == Empty) num[loc] = key
        else print key, " found in location ", loc

image 注意我们使用while而不是if来执行“回绕”,以防新位置超过表大小的两倍。例如,假设 n 是 25,增量是 42,我们从位置 20 前进。这会把我们带到 62 号地点。如果我们使用了if,“回绕”位置将是 62–25 = 37,这仍然在表的范围之外。使用while,我们将得到有效位置 37–25 = 12。

我们可以使用loc % n而不是while循环吗?在这个例子中,我们将得到正确的位置,但是如果新位置是n的倍数,loc % n将给出0。如果工作台从1开始,这将是一个无效位置。

对于二次探测,哈希到不同位置的键跟踪不同的序列;因此,初级聚类被消除。但是,哈希到相同位置的键将跟踪相同的序列,因此保留了二级聚类。

以下是需要注意的其他几点:

  • 如果 n 是 2 的幂,即对于某些 m 来说,n= 2m,这种方法只探索了表中的一小部分位置,因此不是很有效。
  • 如果 n 是质数,该方法可以到达表中一半的位置;对于大多数实际目的来说,这通常是足够的。

10.3.3 链接

在这个方法中,所有哈希到相同位置的项都保存在一个链表中。这样做的一个直接好处是,彼此哈希“接近”的项目不会相互干扰,因为它们不会像线性探测那样争用表中的相同空闲空间。实现链接的一种方法是让哈希表包含“列表顶部”指针。例如,如果hash[1..n]是哈希表,那么hash[k]将指向所有哈希到位置k的项目的链表。一个项目可以被添加到链表的头部、尾部或列表有序的位置。

为了说明该方法,假设这些项是整数。每个链表项将由一个整数值和一个指向下一项的指针组成。我们使用下面的类来创建链表中的节点:

        class Node {
           int num;
           Node next;

           public Node(int n) {
              num = n;
              next = null;
           }
        } //end class Node

我们现在可以将数组hash定义如下:

        Node[] hash = new Node[n+1]; //assume n has a value

我们用这个初始化它:

        for (int h = 1; h <= n; h++) hash[h] = null;

假设一个传入的关键字inKey哈希到位置k。我们必须在hash[k]指向的链表中搜索inKey。如果没有找到,我们必须将它添加到列表中。在我们的程序中,我们将添加它,使列表按升序排列。

我们编写程序 P10.2 来计算输入文件numbers.in中不同整数的数量。该程序使用哈希和链接。最后,我们打印哈希到每个位置的数字列表。

程序 P10.2

        import java.util.*;
        import java.io.*;
        public class HashChain {
           final static int N = 13;
           public static void main(String[] args) throws IOException {
              Scanner in = new Scanner(new FileReader("numbers.in"));

              Node[] hash = new Node[N+1];
              for (int h = 1; h <= N; h++) hash[h] = null;
              int distinct = 0;
              while (in.hasNextInt()) {
                 int key = in.nextInt();
                 if (!search(key, hash, N)) distinct++;
              }
              System.out.printf("\nThere are %d distinct numbers\n\n", distinct);
              for (int h = 1; h <= N; h++)
                 if (hash[h] != null) {
                    System.out.printf("hash[%d]:  ", h);
                    printList(hash[h]);
                 }
              in.close();
           } //end main

           public static boolean search(int inKey, Node[] hash, int n) {
           //return true if inKey is found; false, otherwise
           //insert a new key in its appropriate list so list is in order
              int k = inKey % n + 1;
              Node curr = hash[k];
              Node prev = null;

              while (curr != null && inKey > curr.num) {
                 prev = curr;
                 curr = curr.next;
              }
              if (curr != null && inKey == curr.num) return true; //found
              //not found; inKey is a new key; add it so list is in order
              Node np = new Node(inKey);
              np.next = curr;
              if (prev == null) hash[k] = np;
              else prev.next = np;
              return false;
           } //end search

           public static void printList(Node top) {
              while (top != null) {
                 System.out.printf("%2d ", top.num);
                 top = top.next;
              }
              System.out.printf("\n");
           } //end printList

        } //end class HashChain

        class Node {
           int num;
           Node next;

           public Node(int n) {
              num = n;
              next = null;
           }
        } //end class Node

假设numbers.in包含以下数字:

    24 57 35 37 31 98 85 47 60 32 48 82 16 96 87 46 53 92 71 56
    73 85 47 46 22 40 95 32 54 67 31 44 74 40 58 42 88 29 78 87
    45 13 73 29 84 48 85 29 66 73 87 17 10 83 95 25 44 93 32 39

运行时,程序 P10.2 产生以下输出:

    There are 43 distinct numbers

    hash[1]: 13 39 78
    hash[2]: 40 53 66 92
    hash[3]: 54 67 93
    hash[4]: 16 29 42
    hash[5]: 17 56 82 95
    hash[6]: 31 44 57 83 96
    hash[7]: 32 45 58 71 84
    hash[8]: 46 85 98
    hash[9]: 47 60 73
    hash[10]: 22 35 48 74 87
    hash[11]: 10 88
    hash[12]: 24 37
    hash[13]: 25

如果m个键已经存储在链表中,并且有n个哈希位置,那么一个链表的平均长度是pg298.jpg,由于我们必须顺序搜索链表,所以平均成功搜索长度是pg298a.jpg。可以通过增加哈希位置的数量来缩短搜索长度。

用链接实现哈希的另一种方式是使用单个数组并使用数组下标作为链接。我们可以使用以下声明:

        class Node {
           int num;    //key
           int next;   //array subscript of the next item in the list
        }

        Node[] hash = new Node[MaxItems+1];

比方说,表的第一部分hash[1..n]被指定为哈希表,其余位置被用作溢出表,如图图 10-1 所示。

9781430266198_Fig10-01.jpg

图 10-1 。链接的数组实现

这里,hash[1..5]是哈希表,hash[6..15]是溢出表。

假设key哈希到哈希表中的位置k:

  • 如果hash[k].num为空(比如说0,我们将其设置为key,并将hash[k].next设置为−1,比如说,以指示一个空指针。
  • 如果hash[k].num不是0,我们必须在从k开始的列表中搜索key。如果没有找到,我们把它放在溢出表中的下一个空闲位置(f),并从hash[k]开始把它链接到列表。一种链接方式如下:
             hash[f].next = hash[k].next;
             hash[k].next = f;
  • 链接新键的另一种方法是将其添加到列表的末尾。如果L是列表中最后一个节点的位置,这可以通过以下方式完成:
             hash[L].next = f;
             hash[f].next = -1;   //this is now the last node

如果我们不得不考虑删除,我们将不得不决定如何处理被删除的位置。一种可能性是在溢出表中保存所有可用位置的列表。当需要存储一个键时,就从列表中检索它。当一个项目被删除时,它的位置被返回到列表中。

最初,我们可以将溢出表中的所有项链接起来,如图图 10-2 所示,让变量free指向列表中的第一项;这里,free = 6。第 6 项指向第 7 项,第 7 项指向第 8 项,依此类推,第 15 项位于列表的末尾。

9781430266198_Fig10-02.jpg

图 10-2 。链接溢出表中的项目以形成“自由列表”

假设37哈希到位置2。这是空的,所以37存储在hash[2].num中。如果另一个数(24,比方说)哈希到2,它必须存储在溢出表中。首先,我们必须从“空闲列表”中获得一个位置这可以通过以下方式实现:

        f = free;
        free = hash[free].next;
        return f;

这里,6被返回,free被设置为7。数字24存储在位置6中,并且hash[2].next被设置为6。在这个阶段,我们有free = 7,表中的值如图 10-3 中的所示。

9781430266198_Fig10-03.jpg

图 10-3 。在将 24 加到溢出表之后

现在,考虑如何删除一个项目。有两种情况需要考虑:

  • 如果要删除的条目在哈希表中(比如说在k,我们可以用这个来删除它:

    if (hash[k].next == -1) set hash[k].num to Empty  //only item in the list
    else { //copy an item from the overflow table to the hash table
       h = hash[k].next;
     hash[k] = hash[h];   //copy information at location h to location k
       return h to the free list   //see next
    }
    
  • 我们可以用这个:

    hash[h].next = free;
    free = h;
    

    将一个位置(h)返回到空闲列表

  • 如果要删除的项目在溢出表中(比如说在curr),并且prev是指向要删除的项目的位置,我们可以用下面的代码删除它:

    hash[prev].next = hash[curr].next;
    return curr to the free list
    

现在考虑如何处理传入的键。假设free9并且数字52哈希到位置2。我们从2开始搜索52的列表。没有找到,所以52被存储在下一个空闲位置9。位置6包含列表中的最后一项,因此hash[6].next被设置为9,而hash[9].next被设置为-1

一般来说,我们可以对key进行搜索,如果没有找到,用下面的伪代码将其插入列表的末尾:

        k = H(key)   //H is the hash function
        if (hash[k].num == Empty) {
           hash[k].num = key
           hash[k].next = -1
        }
        else {
           curr = k
           prev = -1
           while (curr != -1 && hash[curr].num != key) {
              prev = curr
              curr = hash[curr].next
           }
           if (curr != -1) key is in the list at location curr
           else {  //key is not present
              hash[free].num = key   //assume free list is not empty
              hash[free].next = -1
              hash[prev].next = free
              free = hash[free].next
           } //end else
        } //end else

10.3.4 线性探测用双哈希 1

在第 10.3.1 节中,我们看到使用loc = loc + k,其中k是一个大于 1 的常数,并不会比k为 1 时给我们带来更好的性能。然而,通过让k随键而变,我们可以得到极好的结果,因为与线性和二次探测不同,哈希到相同位置的键将在搜索空的位置时探测不同的位置序列。

k随密钥变化的最自然的方法是使用第二个哈希函数。第一个哈希函数将生成初始表位置。如果有冲突,第二个哈希函数将生成增量k。如果桌子位置从1n,我们可以使用如下:

        convert key to a numeric value, num (if it is not already numeric)
        loc = num % n + 1        //this gives the initial hash location
        k = num % (n – 2) + 1    //this gives the increment for this key

我们之前提到过,选择n(表格大小)作为质数是明智的。在这种方法中,如果n-2也是素数,我们会得到更好的结果(在这种情况下,nn-2被称为孪生素数 ,例如 103/101,1021/1019)。

除了k不固定之外,方法与线性探测相同。我们用两个哈希函数来描述它,H1H2\. H1产生初始哈希位置,一个在1n之间的值,包括这两个值。H2产生增量,一个在1n - 1之间的值,与n互质;这是所希望的,这样,如果需要,许多位置将被探测。如前所述,如果n是质数,那么1n - 1之间的任何值对它来说都是相对质数。在前面的例子中,第二个哈希函数产生一个在1n-2之间的值。下面是算法:

        //find or insert 'key' using "linear probing with double hashing"
        loc = H1(key)
        k = H2(key)
        while (hash[loc] != Empty && hash[loc] != key) {
           loc = loc + k
           if (loc > n) loc = loc – n
        }
        if (hash[loc] == Empty) hash[loc] = key
        else print key, " found in location ", loc

和以前一样,为了确保while循环在某个点退出,我们不允许表完全变满。如果我们想要迎合MaxItems,比方说,我们声明表的大小大于MaxItems。一般来说,表中的空闲位置越多,哈希技术的效果就越好。

然而,使用双重哈希,我们不需要像普通线性探针那样多的空闲位置来保证良好的性能。这是因为双重哈希消除了主要和次要聚类。

因为哈希到不同位置的关键字将生成不同的位置序列,所以消除了主聚类。消除了二次聚类,因为哈希到相同位置的不同关键字将生成不同的序列。这是因为,一般来说,不同的键会产生不同的增量(算法中的k)。两个键同时被H1H2哈希为相同的值,这确实是一种罕见的巧合。

在实践中,任何哈希应用的性能都可以通过保存每个密钥的访问频率信息来提高。如果我们事先有了这些信息,我们可以简单地将最流行的条目放在最前面,最不流行的条目放在最后。这将降低所有键的平均访问时间。

如果我们事先没有这些信息,我们可以为每个键保留一个计数器,并在每次访问该键时递增。经过一段预定的时间后(比如一个月),我们首先重新载入最受欢迎的条目,最后载入最不受欢迎的条目。然后,我们重置计数器并收集下个月的统计数据。这样,我们可以确保应用保持微调,因为不同的项目可能会在下个月变得流行。

10.4 示例:词频统计

再一次考虑写一个程序来计算一篇文章中单词的频率的问题。输出由单词及其频率的字母列表组成。现在,我们将使用带有双重哈希的线性探测将单词存储在哈希表中。

表格中的每个元素由三个字段组成— wordfreqnext。我们将使用下面的类来创建要存储在表中的对象:

        class WordInfo {
           String word = "";
           int freq = 0;
           int next = -1;
        } //end class WordInfo

我们用以下语句声明并初始化该表:

        WordInfo[] wordTable = new WordInfo[N+1]; //N – table size
        for (int h = 1; h <= N; h++) wordTable[h] = new WordInfo();

在表中搜索每个输入的单词。如果没有找到这个词,它将被添加到表中,并且它的频率计数被设置为1。如果找到了这个词,那么它的频率计数就会增加1

此外,当一个单词被添加到表中时,我们设置链接,以便按照字母顺序维护单词的链表。变量first按顺序指向第一个单词。例如,假设哈希表中存储了五个单词。我们通过next将它们联系起来,如图图 10-4 所示,first = 6

9781430266198_Fig10-04.jpg

图 10-4 。按字母顺序链接的单词(第一个= 6)

于是,第一个字是boy,指向for ( 1,指向girl ( 7,指向man ( 4),指向the ( 3,不指向任何东西(-1)。单词按字母顺序链接:boy for girl man the。请注意,无论哈希算法将一个单词放在哪里,链接都会起作用。

哈希算法首先放置单词。然后,不管它被放置在哪里,该位置被链接以保持单词的顺序。例如,假设新单词kid哈希到位置2。然后kid的链接会被设置为4(指向man),而girl的链接会被设置为2(指向kid)。

我们通过遍历链表来打印按字母顺序排列的列表。程序 P10.3 显示所有细节。

程序 P10.3

        import java.io.*;
        import java.util.*;
        public class WordFrequencyHash {
           static   Scanner in;
           static   PrintWriter out;
           final static int N = 13; //table size
           final static int MaxWords = 10;
           final static String Empty = "";

           public static void main(String[] args) throws IOException {
              in = new Scanner(new FileReader("wordFreq.in"));
              out = new PrintWriter(new FileWriter("wordFreq.out"));

              WordInfo[] wordTable = new WordInfo[N+1];
              for (int h = 1; h <= N; h++) wordTable[h] = new WordInfo();

              int first = -1; //points to first word in alphabetical order
              int numWords = 0;

              in.useDelimiter("[^a-zA-Z]+");
              while (in.hasNext()) {
                 String word = in.next().toLowerCase();
                 int loc = search(wordTable, word);
                 if (loc > 0) wordTable[loc].freq++;
                 else //this is a new word
                    if (numWords < MaxWords) { //if table is not full
                       first = addToTable(wordTable, word, -loc, first);
                       ++numWords;
                    }
                    else out.printf("'%s' not added to table\n", word);
              }
              printResults(wordTable, first);
              in.close();
              out.close();
           } // end main

           public static int search(WordInfo[] table, String key) {
           //search for key in table; if found, return its location; if not,
           //return -loc if it must be inserted in location loc
              int wordNum = convertToNumber(key);
              int loc = wordNum % N + 1;
              int k = wordNum % (N - 2) + 1;

              while (!table[loc].word.equals(Empty) && !table[loc].word.equals(key)) {
                 loc = loc + k;
                 if (loc > N) loc = loc - N;
              }
              if (table[loc].word.equals(Empty)) return -loc;
              return loc;
           } // end search

           public static int convertToNumber(String key) {
              int wordNum = 0;
              int w = 3;
              for (int h = 0; h < key.length(); h++) {
                 wordNum += key.charAt(h) * w;
                 w = w + 2;
              }
              return wordNum;
           } //end convertToNumber

           public static int addToTable(WordInfo[] table, String key, int loc, int head) {
           //stores key in table[loc] and links it in alphabetical order
              table[loc].word = key;
              table[loc].freq = 1;
              int curr = head;
              int prev = -1;
              while (curr != -1 && key.compareTo(table[curr].word) > 0) {
                 prev = curr;
                 curr = table[curr].next;
              }
              table[loc].next = curr;
              if (prev == -1) return loc; //new first item
              table[prev].next = loc;
              return head; //first item did not change
           } //end addToTable

           public static void printResults(WordInfo[] table, int head) {
              out.printf("\nWords        Frequency\n\n");
              while (head != -1) {
                 out.printf("%-15s %2d\n", table[head].word, table[head].freq);
                 head = table[head].next;
              }
           } //end printResults

        } //end class WordFrequencyHash

        class WordInfo {
           String word = "";
           int freq = 0;
           int next = -1;
        } //end class WordInfo

假设wordFreq.in包含以下内容:

        If you can trust yourself when all men doubt you;
        If you can dream - and not make dreams your master;

使用 13 的表大小和设置为 10 的MaxWords,当运行程序 P10.3 时,它在文件wordFreq.out中产生如下输出:

'and' not added to table
'not' not added to table
'make' not added to table
'dreams' not added to table
'your' not added to table
'master' not added to table

Words        Frequency

all              1
can              2
doubt            1
dream            1
if               2
men              1
trust            1
when             1
you              3
yourself         1

练习 10

  1. 使用主哈希函数h1(k) = 1 + k mod 11将整数插入哈希表H[1..11]。使用(a)线性探测,(b)使用探测函数i + i2的二次探测,以及(c)使用h2(k) = 1 + k mod 9的双重哈希,显示插入关键字10223141528178858后数组的状态。

  2. Integers are inserted in an integer hash table list[1] to list[n] using linear probe with double hashing. Assume that the function h1 produces the initial hash location and the function h2 produces the increment. An available location has the value Empty, and a deleted location has the value Deleted.

    编写一个函数来搜索给定值key。如果找到,该函数返回包含key的位置。如果没有找到,该函数将key插入到搜索key时遇到的第一个删除位置(如果有)或Empty位置,并返回key插入的位置。你可以假设list包含了一个新整数的空间。

  3. 在哈希应用中,密钥由一串字母组成。编写一个哈希函数,给定一个键和一个整数max,返回 1 和max之间的哈希位置,包括 1 和 T1。您的函数必须使用键的所有*,并且不应该故意为由相同字母组成的键返回相同的值。*

  4. 一个大小为n的哈希表包含两个字段——一个整数数据字段和一个整数链接字段——称为数据和下一个的*。下一个字段用于以升序链接哈希表中的数据项。值-1 表示列表结束。变量top(初始设置为-1)表示最小数据项的位置。使用哈希函数h1和线性探测将整数插入哈希表。可用位置的data字段具有值Empty,并且不会从表中删除任何项目。编写程序代码以搜索给定值key。如果发现,什么也不做。如果找不到,将key插入表格中,并将链接到其指定位置。您可能会认为该表中有容纳新整数的空间。*

  5. In a certain application, keys that hash to the same location are held on a linked list. The hash table location contains a pointer to the first item on the list, and a new key is placed at the end of the list. Each item in the linked list consists of an integer key, an integer count, and a pointer to the next element in the list. Storage for a linked list item is allocated as needed. Assume that the hash table is of size n and the call H(key) returns a location from 1 to n, inclusive.

    编写编程代码来初始化哈希表。

    编写一个函数,给定键nkey,如果找不到就搜索它,在适当的位置添加nkey,并将count设置为0。如果找到,将1加到count;如果计数达到10,则从当前位置删除该节点,将其放在列表的开头,并将count设置为0

  6. Write a program to read and store a thesaurus as follows:

    程序的数据由输入行组成。每行包含(可变)数量的不同单词,所有这些单词都是同义词。你可以假设单词只由字母组成,并由一个或多个空格分隔。可以使用大小写字母的任意组合来拼写单词。所有的字都将存储在一个哈希表中,使用开放寻址和双重哈希。一个单词可以出现在多行上,但是每个单词在表格中只能插入一次。如果一个单词出现在多行上,那么这些行上的所有单词都是同义词。这部分数据由包含单词EndOfSynonyms的行终止。

    必须组织数据结构,以便给定任何单词,都可以快速找到该单词的所有同义词。

    数据的下一部分由几个命令组成,每行一个。有效命令由PADE指定。

    P 单词按字母顺序打印单词的所有同义词。

    A word1 word2word1 添加到 word2 的同义词列表中。

    D 单词从同义词库中删除单词

    E ,单独一行,表示数据结束。

  7. Write a program to compare quadratic probing, linear probing with double hashing, and chaining. Data consists of an English passage, and you are required to store all the distinct words in the hash tables. For each word and each method, record the number of probes required to insert the word in the hash table.

    迎合 100 字。对于二次探测和双重哈希,使用大小为 103 的表。对于链接,使用两种表格大小—23 和 53。对于这四种方法中的每一种,使用相同的基本哈希函数。

    打印四种方法中每种方法的单词和探针数量的字母列表。组织您的输出,以便可以很容易地比较这些方法的性能。

1 该技术有时被称为双哈希开放式寻址