Java-高级主题-全-
Java 高级主题(全)
原文:Advanced Topics in Java: Core Concepts in Data Structures
零、前言
这本书假设你有基本编程概念的工作知识,如变量、常数、赋值、选择(如果...else),以及循环(while,for)。它还假设您熟悉编写函数和使用数组。如果你不是,建议你先学习 Java 编程:初学者课程 ( www.amazon.com/Java-Programming-Beginners-Noel-Kalicharan/dp/1438265182/
)或任何其他介绍 Java 编程的书籍,然后再阅读本书中的内容。
重点不是教授 Java 语言的高级概念,而是使用 Java 来教授任何有抱负的程序员都应该知道的高级编程概念。涵盖的主要主题包括基本排序方法(选择、插入)、搜索(顺序、二进制)、合并、指针(在 Java 中称为引用)、链表、栈、队列、递归、随机数、文件(文本、二进制、随机访问、索引)、二叉树、高级排序方法(堆排序、快速排序、合并排序、外壳排序)和哈希(一种非常快速的搜索方法)。
第一章复习一些你应该知道的基本概念。它处理使用选择和插入排序对列表进行排序,使用顺序和二分搜索法搜索列表,以及合并两个有序列表。
Java 被认为是一种面向对象的编程语言。其存在的核心是类和对象的概念。第二章详细介绍了这些概念。
第三章处理链表——它本身是一种重要的数据结构,但也是树和图等更高级结构的基础。我们将解释如何创建一个链表,如何添加和删除一个链表,如何建立一个排序链表,以及如何合并两个排序链表。
第四章专门讨论栈和队列,也许是最有用的线性列表。它们在计算机科学中有重要的应用。我们将向您展示如何使用数组和链表实现栈和队列,以及如何通过将算术表达式转换为后缀形式来对其求值。
第五章介绍了一种强大的编程方法——递归。毫无疑问,递归需要一点时间来适应。但是,一旦掌握了,你将能够解决一个全新的世界的问题,这些问题用传统的非递归技术很难解决。在其他有趣的问题中,我们将向您展示如何解决汉诺塔问题以及如何逃离迷宫。
我们都喜欢玩游戏。但是在这些玩游戏的程序中潜伏着什么呢?随机数。第六章向你展示如何使用随机数来玩一些简单的游戏,模拟现实生活中的情况。我们将解释如何编写一个算术训练程序,如何玩 Nim 的完美游戏,以及如何模拟在超市收银台或银行排队,等等。我们还将讨论随机数的一种新用途——估计数值,如π (pi)。
几乎所有我们需要存储在计算机上的东西都必须存储在一个文件中。我们使用文本文件来存储我们用文本编辑器或文字处理器创建的各种文档。我们使用二进制文件来存储照片图像文件、声音文件、视频文件和记录文件。第七章展示了如何创建和操作文本和二进制文件。它还解释了如何处理两种最有用的文件——随机存取和索引文件。
第八章介绍了最通用的数据结构——二叉树。二叉树结合了数组和链表的优点而没有缺点。例如,二叉查找树允许我们在“二分搜索法时间”内进行搜索(就像一个排序数组一样),并利用链表的功能进行插入/删除。
第一章中讨论的排序方法(选择、插入)很简单,因为它们完成了工作,但是如果给定大量的项目(比如一百万个)要排序,就会很慢。例如,他们将需要大约六天的时间(!)在每秒能处理一百万次比较的计算机上对一百万个项目进行排序。第九章讨论了一些更快的排序方法——堆排序、快速排序和 Shell 排序。Heapsort 和 quicksort 可以在不到一分钟的时间内对同一台计算机上的百万个项目进行排序,而 Shell sort 则需要一分多钟。
第十章介绍哈希,这是最快的搜索方法之一。它涵盖了哈希基础知识,并讨论了解决冲突的几种方法,这是任何哈希算法性能的关键。
我们的目标是提供对高级编程技术的良好掌握,以及对重要数据结构(链表、栈、队列、二叉树)的基本理解,以及如何在 Java 中实现它们。我们希望这将激起你对计算机科学这一令人兴奋的重要领域进行更深入研究的兴趣。
许多练习要求你写一个程序。不回答这些问题是故意的。我的经验是,在我们的快餐文化中,当有答案时,学生不会花必要的时间去找出解决方案。无论如何,编程练习的目的是让你编写程序。
编程是一个迭代的过程。当您编译并运行您的解决方案时,您将知道它们是否正确。如果不是这样,你必须找出程序不工作的原因,进行改进,然后再试一次。学好编程的唯一方法就是写程序解决新问题。提供答案会缩短这个过程,没有任何好处。
Java 语言的高级主题中的程序可以用 5.0 及更高版本的 Java 开发工具包(JDK)编译。这些程序是独立的。例如,它们不需要有人提供一个类来完成基本的输入/输出。它们将“开箱即用”
本书中所示示例的代码可在 Apress 网站www.apress.com
上获得。在该书的信息页面上的源代码/下载选项卡上可以找到一个链接。该选项卡位于页面的相关标题部分。
感谢您花时间阅读和研究这本书。我们相信您会喜欢在这里的经历,并且能够以一种轻松、愉快、愉快和有益的方式继续您的编程之旅。
—诺埃尔·卡利查兰
一、排序、搜索和合并
在本章中,我们将解释以下内容:
- 如何使用选择排序对项目列表进行排序
- 如何使用插入排序对项目列表进行排序
- 如何向排序列表中添加一个新项目,使列表保持排序
- 如何对字符串数组进行排序
- 如何对相关(并行)数组进行排序
- 如何使用二分搜索法搜索排序列表
- 如何搜索字符串数组
- 如何写一个程序来计算一篇文章中单词的频率
- 如何合并两个排序列表以创建一个排序列表
1.1 排序一个数组:选择排序
S orting 是将一组数值按升序或降序排列的过程。排序的原因有很多。有时我们排序是为了产生更可读的输出(例如,产生一个按字母顺序排列的列表)。教师可能需要按姓名或平均分对学生进行排序。如果我们有一个很大的值集,并且我们想要识别重复项,我们可以通过排序来实现;重复的值将一起出现在排序列表中。
排序的另一个优点是,对于排序后的数据,一些操作可以更快更有效地执行。例如,如果数据已经排序,可以使用二分搜索法搜索,这比使用顺序搜索要快得多。此外,合并两个单独的项目列表可以比列表未排序时快得多。
排序的方式有很多种。在这一章中,我们将讨论两种“简单”的方法:选择和插入排序。在第九章中,我们将会看到更复杂的排序方式。我们从选择排序开始。
考虑以下存储在 Java 数组中的数字列表,num
:
使用选择排序按升序对num
进行排序的过程如下:
1STT5pass
-
从位置
0
到6
找到整个列表中最小的数字;最小的是15
,位于4
位置。 -
Interchange the numbers in positions
0
and4
. This gives us the following:
22通过
-
找出位置
1
到6
的最小数字;最小的是33
,位于5
位置。 -
Interchange the numbers in positions
1
and5
. This gives us the following:
3rdpass
-
找出位置
2
到6
的最小数字;最小的是48
,位于5
位置。 -
Interchange the numbers in positions
2
and5
. This gives us the following:
4 次3 次 5 次过关
-
找出位置
3
到6
的最小数字;最小的是52
,位于6
位置。 -
Interchange the numbers in positions
3
and6
. This gives us the following:
5 次3 次 过关
-
找出位置
4
到6
的最小数字;最小的是57
,位于4
位置。 -
Interchange the numbers in positions
4
and4
. This gives us the following:
6 次3 次 过关
-
找出位置
5
到6
的最小数字;最小的是65
,位于6
位置。 -
Interchange the numbers in positions
5
and6
. This gives us the following:
数组现在已经完全排序了。注意,一旦第 6 个最大的被放置在其最终位置(5),最大的(79)将自动处于最后位置(6)。
在本例中,我们进行了六次传递。我们将通过让变量h
从0
到5
来计算这些通道。在每一遍中,我们找到从位置h
到6
的最小数字。如果最小的数字在位置s
,我们交换位置h
和s
的数字。
一般来说,对于大小为n
的数组,我们进行n-1
遍。在我们的例子中,我们对6
通道中的7
号进行了排序。下面是排序num[0..n-1]
的算法的伪代码概要:
for h = 0 to n - 2
s = position of smallest number from num[h] to num[n-1]
swap num[h] and num[s]
endfor
我们可以使用通用参数list
如下实现该算法:
public static void selectionSort(int[] list, int lo, int hi) {
//sort list[lo] to list[hi] in ascending order
for (int h = lo; h < hi; h++) {
int s = getSmallest(list, h, hi);
swap(list, h, s);
}
}
for
循环中的两个语句可以替换为以下内容:
swap(list, h, getSmallest(list, h, hi));
我们可以把getSmallest
和swap
写成:
public static int getSmallest(int list[], int lo, int hi) {
//return location of smallest from list[lo..hi]
int small = lo;
for (int h = lo + 1; h <= hi; h++)
if (list[h] < list[small]) small = h;
return small;
}
public static void swap(int list[], int i, int j) {
//swap elements list[i] and list[j]
int hold = list[i];
list[i] = list[j];
list[j] = hold;
}
为了测试selectionSort
是否正常工作,我们编写程序 P1.1 。仅显示了main
。要完成程序,只需添加selectionSort
、getSmallest
和swap
。
程序 p 1.1
import java.util.*;
public class SelectSortTest {
final static int MaxNumbers = 10;
public static void main(String[] args) {
Scanner in = new Scanner(System.in);
int[] num = new int[MaxNumbers];
System.out.printf("Type up to %d numbers followed by 0\n", MaxNumbers);
int n = 0;
int v = in.nextInt();
while (v != 0 && n < MaxNumbers) {
num[n++] = v;
v = in.nextInt();
}
if (v != 0) {
System.out.printf("\nMore than %d numbers entered\n", MaxNumbers);
System.out.printf("First %d used\n", MaxNumbers);
}
if (n == 0) {
System.out.printf("\nNo numbers supplied\n");
System.exit(1);
}
//n numbers are stored from num[0] to num[n-1]
selectionSort(num, 0, n-1);
System.out.printf("\nThe sorted numbers are\n");
for (v = 0; v < n; v++) System.out.printf("%d ", num[v]);
System.out.printf("\n");
} //end main
// selectionSort, getSmallest and swap go here
} //end class SelectSortTest
程序请求最多 10 个数字(由MaxNumbers
定义),将它们存储在数组num
中,调用selectionSort
,然后打印排序后的列表。
以下是该程序的运行示例:
Type up to 10 numbers followed by 0
57 48 79 65 15 33 52 0
The sorted numbers are
15 33 48 52 57 65 79
请注意,如果用户输入了十个以上的数字,程序会识别出来,并且只对前十个进行排序。
1.1.1 选择排序分析
为了找到最小的 k 项,我们进行 k -1 比较。在第一遍中,我们进行 n -1 次比较,以找到 n 项中最小的一项。在第二遍中,我们进行第 n -2 次比较,以找到第 n -1 项中最小的一项。以此类推,直到最后一遍,我们进行一次比较,找出两个项目中较小的一个。一般来说,在第 j 遍时,我们进行 n-j 比较,以找到 n-j +1 项中最小的一项。因此,我们有了这个:
总比较次数= 1+2+…+n-1 =n(n-1)≈n2
我们说选择排序的顺序是 O( n 2 )(“大 on 的平方”)。常数在“大 O”符号中并不重要,因为随着 n 变得非常大,常数变得无关紧要。
每一次,我们用三个任务交换两个项目。因为我们做了 n -1 遍,所以我们总共做了 3( n -1)次分配。使用“大 O”符号,我们说赋值的个数是 O( n )。常数3
和1
并不重要,因为 n 变大了。
如果数据是有序的,选择排序的性能会更好吗?不。一种方法是给它一个排序列表,看看它做什么。如果你完成了这个算法,你会发现这个方法不考虑数据的顺序。不管数据如何,它每次都会进行相同次数的比较。
正如我们将看到的,一些排序方法(mergesort 和 quicksort 参见第五章和第九章需要额外的数组存储来实现它们。请注意,选择排序是在给定数组中“就地”执行的,不需要额外的存储。
作为一个练习,修改程序代码,使其计算使用选择排序对列表进行排序时的比较和赋值次数。
1.2 数组排序:插入排序
考虑与之前相同的阵列:
现在,把数字想象成桌子上的卡片,按照它们在数组中出现的顺序一次拿一张。因此,我们首先拿起57
,然后是48
,然后是79
,以此类推,直到我们拿起52
。然而,当我们拿起每一个新的数字时,我们把它加到我们手上,这样我们手上的数字都被排序了。
当我们拿起57
时,我们手中只有一个数字。我们认为有一个数字需要排序。
当我们拿起48
时,我们将它添加到57
前面,因此我们的手包含以下内容:
48 57
当我们拿起79
时,我们把它放在57
之后,所以我们的手包含这个:
48 57 79
当我们拿起65
时,我们把它放在57
之后,所以我们的手包含这个:
48 57 65 79
在这个阶段,四个数字已经被捡起来了,我们的手按排序顺序包含它们。
当我们拿起15
时,我们把它放在48
之前,所以我们的手包含这个:
15 48 57 65 79
当我们拿起33
时,我们把它放在15
之后,所以我们的手包含这个:
15 33 48 57 65 79
最后,当我们拿起52
时,我们把它放在48
之后,所以我们的手包含这个:
15 33 48 52 57 65 79
这些数字已经按升序排序。
所描述的方法说明了插入排序 背后的思想。从左到右,一次处理一个数组中的数字。这相当于从表中一次选取一个数字。由于第一个数字本身是已排序的,我们将从第二个数字开始处理数组中的数字。
当我们开始处理num[h]
时,我们可以假设num[0]
到num[h-1]
被排序。我们在num[0]
到num[h-1]
中插入num[h]
,这样num[0]
到num[h]
就被排序了。然后我们继续处理num[h+1]
。当我们这样做时,我们的假设num[0]
到num[h]
被排序将为真。
使用插入排序按升序对num
排序的过程如下:
1STT5pass
-
Process
num[1]
, that is,48
. This involves placing48
so that the first two numbers are sorted;num[0]
andnum[1]
now contain the following:
数组的其余部分保持不变。
22通过
-
Process
num[2]
, that is,79
. This involves placing79
so that the first three numbers are sorted;num[0]
tonum[2]
now contain the following:
数组的其余部分保持不变。
3rdpass
-
Process
num[3]
, that is,65
. This involves placing65
so that the first four numbers are sorted;num[0]
tonum[3]
now contain the following:
数组的其余部分保持不变。
4 次3 次 5 次过关
-
Process
num[4]
, that is,15
. This involves placing15
so that the first five numbers are sorted. To simplify the explanation, think of 15 as being taken out and stored in a simple variable (key
, say) leaving a “hole” innum[4]
. We can picture this as follows:
将15
插入其正确位置的过程如下:
-
Compare
15
with79
; it is smaller, so move79
to location4
, leaving location3
free. This gives the following: -
Compare
15
with65
; it is smaller, so move65
to location3
, leaving location2
free. This gives the following: -
Compare
15
with57
; it is smaller, so move57
to location2
, leaving location1
free. This gives the following: -
Compare
15
with48
; it is smaller, so move48
to location1
, leaving location0
free. This gives the following: -
There are no more numbers to compare with
15
, so it is inserted in location0
, giving the following: -
我们可以把
15
(key
)的摆放逻辑用它和它左边的数字比较来表达,从最近的一个开始。只要key
小于num[k]
,对于某些k
,我们就把num[k]
移到num[k + 1]
位置,继续考虑num[k-1]
,前提是它存在。当k
实际上是0
的时候就不会存在了。在这种情况下,过程停止,并且key
插入位置0
。
5 次3 次 过关
-
流程
num[5]
,即33
。这包括放置33
,以便对前六个数字进行排序。这是按如下方式完成的: -
将
33
存储在key
中,留下位置5
空闲。 -
比较
33
和79
;它变小了,所以把79
移到位置5
,留下位置4
空闲。 -
比较
33
和65
;它变小了,所以把65
移到位置4
,留下位置3
空闲。 -
比较
33
和57
;它变小了,所以把57
移到位置3
,留下位置2
空闲。 -
比较
33
和48
;它变小了,所以把48
移到位置2
,留下位置1
空闲。 -
Compare
33
with15
; it is bigger, so insert33
in location1
. This gives the following: -
我们可以通过与它左边的数字比较来表达放置
33
的逻辑,从最近的一个开始。只要key
小于num[k]
,对于某些k
,我们就把num[k]
移到位置num[k + 1]
,继续考虑num[k-1]
,前提是它存在。如果某些k
的key
大于或等于num[k]
,则key
插入k+1
位置。这里,33
大于num[0]
,所以插入num[1]
。
6 次3 次 过关
-
流程
num[6]
,即52
。这包括放置52
,以便对前七个(所有)数字进行排序。这是按如下方式完成的: -
将
52
存储在key
中,留下位置6
空闲。 -
比较
52
和79
;它变小了,所以把79
移到位置6
,留下位置5
空闲。 -
比较
52
和65
;它变小了,所以把65
移到位置5
,留下位置4
空闲。 -
比较
52
和57
;它变小了,所以把57
移到位置4
,留下位置3
空闲。 -
Compare
52
with48
; it is bigger; so insert52
in location3
. This gives the following:
数组现在已经完全排序了。
以下是如何使用插入排序对数组num
的前n
个元素进行排序的概述:
for h = 1 to n - 1 do
insert num[h] among num[0] to num[h-1] so that num[0] to num[h] are sorted
endfor
使用这个大纲,我们使用参数list
编写函数insertionSort
。
public static void insertionSort(int list[], int n) {
//sort list[0] to list[n-1] in ascending order
for (int h = 1; h < n; h++) {
int key = list[h];
int k = h - 1; //start comparing with previous item
while (k >= 0 && key < list[k]) {
list[k + 1] = list[k];
--k;
}
list[k + 1] = key;
} //end for
} //end insertionSort
while
语句是该排序的核心。它声明,只要我们在数组(k >= 0
)中,并且当前数字(key
)小于数组(key < list[k]
)中的数字,我们就将list[k]
向右移动(list[k+1] = list[k]
),并继续移动到左边的下一个数字(--k
)。
对于某些k
,如果k
等于-1
或者如果key
大于或等于list[k]
,我们退出while
循环。无论哪种情况,key
都被插入到list[k+1]
中。
如果k
为-1
,则表示当前数字小于列表中所有之前的数字,必须插入到list[0]
中。但是list[k + 1]
是 list[0]
当k
是-1
时,那么key
在这种情况下是正确插入的。
该函数按升序排序。要按降序排序,我们所要做的就是将while
条件中的<
改为>
,就像这样:
while (k >= 0 && key > list[k])
现在,如果比大,一个键会向左移动。
我们编写程序 P1.2 来测试insertionSort
是否正常工作。仅显示了main
。添加功能insertionSort
完成程序。
程序 P1.2
import java.util.*;
public class InsertSortTest {
final static int MaxNumbers = 10;
public static void main(String[] args) {
Scanner in = new Scanner(System.in);
int[] num = new int[MaxNumbers];
System.out.printf("Type up to %d numbers followed by 0\n", MaxNumbers);
int n = 0;
int v = in.nextInt();
while (v != 0 && n < MaxNumbers) {
num[n++] = v;
v = in.nextInt();
}
if (v != 0) {
System.out.printf("\nMore than %d numbers entered\n", MaxNumbers);
System.out.printf("First %d used\n", MaxNumbers);
}
if (n == 0) {
System.out.printf("\nNo numbers supplied\n");
System.exit(1);
}
//n numbers are stored from num[0] to num[n-1]
insertionSort(num, n);
System.out.printf("\nThe sorted numbers are\n");
for (v = 0; v < n; v++) System.out.printf("%d ", num[v]);
System.out.printf("\n");
} //end main
public static void insertionSort(int list[], int n) {
//sort list[0] to list[n-1] in ascending order
for (int h = 1; h < n; h++) {
int key = list[h];
int k = h - 1; //start comparing with previous item
while (k >= 0 && key < list[k]) {
list[k + 1] = list[k];
--k;
}
list[k + 1] = key;
} //end for
} //end insertionSort
} //end class InsertSortTest
程序请求最多十个数字(由MaxNumbers
定义),将它们存储在数组num
中,调用insertionSort
,然后打印排序后的列表。
以下是该程序的运行示例:
Type up to 10 numbers followed by 0
57 48 79 65 15 33 52 0
The sorted numbers are
15 33 48 52 57 65 79
请注意,如果用户输入了十个以上的数字,程序会识别出来,并且只对前十个进行排序。
我们可以很容易地推广insertionSort
来排序列表的部分。为了举例说明,我们重写了insertionSort
(称之为insertionSort1
)来将list[lo]
排序为list[hi]
,其中lo
和hi
作为参数传递给函数。
由于元素lo
是第一个,我们从lo+1
开始处理元素,直到元素hi
。这在for
语句中有所体现。还有现在,下标最低的是lo
,而不是0
。这反映在while
状态k >= lo
中。其他一切都和以前一样。
public static void insertionSort1(int list[], int lo, int hi) {
//sort list[lo] to list[hi] in ascending order
for (int h = lo + 1; h <= hi; h++) {
int key = list[h];
int k = h - 1; //start comparing with previous item
while (k >= lo && key < list[k]) {
list[k + 1] = list[k];
--k;
}
list[k + 1] = key;
} //end for
} //end insertionSort1
我们可以用程序 P1.2a 来测试insertionSort1
。
程序 p 1.2a
import java.util.*;
public class InsertSort1Test {
final static int MaxNumbers = 10;
public static void main(String[] args) {
Scanner in = new Scanner(System.in);
int[] num = new int[MaxNumbers];
System.out.printf("Type up to %d numbers followed by 0\n", MaxNumbers);
int n = 0;
int v = in.nextInt();
while (v != 0 && n < MaxNumbers) {
num[n++] = v;
v = in.nextInt();
}
if (v != 0) {
System.out.printf("\nMore than %d numbers entered\n", MaxNumbers);
System.out.printf("First %d used\n", MaxNumbers);
}
if (n == 0) {
System.out.printf("\nNo numbers supplied\n");
System.exit(1);
}
//n numbers are stored from num[0] to num[n-1]
insertionSort1(num, 0, n-1);
System.out.printf("\nThe sorted numbers are\n");
for (v = 0; v < n; v++) System.out.printf("%d ", num[v]);
System.out.printf("\n");
} //end main
// insertionSort1 goes here
} //end class InsertSort1Test
1.2.1 插入排序分析
在处理项目 j 时,我们可以少做 1 次比较(如果num[j]
大于num[j-1]
)或多做 j -1 次比较(如果num[j]
小于前面所有项目)。对于随机数据,我们期望平均进行( j -1)次比较。因此,对 n 项进行排序的平均总比较次数为:
我们说插入排序的顺序是 O(n 2 )(“大 O ^ n 的平方”)。当 n 变大时,常数并不重要。
每次我们做一个比较,我们也做一个分配。因此,分配的总数也是 n(n-1) ≈ n 2 。
我们强调这是随机数据的平均值。与选择排序不同,插入排序的实际性能取决于所提供的数据。如果给定的数组已经排序,插入排序将通过进行 n-1 次比较来快速确定这一点。在这种情况下,它以 O(n)时间运行。人们会认为,数据中的顺序越多,插入排序的性能就越好。
如果给定的数据是降序排列的,插入排序的性能最差,因为每个新数字都必须一直移动到列表的开头。在这种情况下,比较的次数是 n(n-1) ≈ n 2 。分配数也是 n(n-1) ≈ n 2 。
因此,通过插入排序进行比较的次数从 n-1(最佳)到 n 2 (平均)到 n 2 (最差)。赋值的次数总是与比较的次数相同。
与选择排序一样,插入排序的实现不需要额外的数组存储。
作为一个练习,修改程序代码,使其计算使用插入排序对列表进行排序时的比较和赋值次数。
1.3 在 处插入一个元素
插入排序使用向已经排序的列表中添加新元素的思想,以便列表保持排序。我们可以把它本身当作一个问题(与插入排序无关)。具体来说,给定一个从list[m]
到list[n]
的排序列表,我们想要向列表中添加一个新的条目(比如说newItem
),以便对list[m]
到list[n+1]
进行排序。
添加新项目会使列表的大小增加 1。我们假设数组有空间容纳新的项目。我们编写函数insertInPlace
来解决这个问题。
public static void insertInPlace(int newItem, int list[], int m, int n) {
//list[m] to list[n] are sorted
//insert newItem so that list[m] to list[n+1] are sorted
int k = n;
while (k >= m && newItem < list[k]) {
list[k + 1] = list[k];
--k;
}
list[k + 1] = newItem;
} //end insertInPlace
使用insertInPlace
,我们可以将insertionSort
(称之为insertionSort2
)重写如下:
public static void insertionSort2(int list[], int lo, int hi) {
//sort list[lo] to list[hi] in ascending order
for (int h = lo + 1; h <= hi; h++)
insertInPlace(list[h], list, lo, h - 1);
} //end insertionSort2
我们可以用程序 P1.2b 来测试insertionSort2
。
程序 p 1.2b
import java.util.*;
public class InsertSort2Test {
final static int MaxNumbers = 10;
public static void main(String[] args) {
Scanner in = new Scanner(System.in);
int[] num = new int[MaxNumbers];
System.out.printf("Type up to %d numbers followed by 0\n", MaxNumbers);
int n = 0;
int v = in.nextInt();
while (v != 0 && n < MaxNumbers) {
num[n++] = v;
v = in.nextInt();
}
if (v != 0) {
System.out.printf("\nMore than %d numbers entered\n", MaxNumbers);
System.out.printf("First %d used\n", MaxNumbers);
}
if (n == 0) {
System.out.printf("\nNo numbers supplied\n");
System.exit(1);
}
//n numbers are stored from num[0] to num[n-1]
insertionSort2(num, 0, n-1);
System.out.printf("\nThe sorted numbers are\n");
for (v = 0; v < n; v++) System.out.printf("%d ", num[v]);
System.out.printf("\n");
} //end main
public static void insertionSort2(int list[], int lo, int hi) {
//sort list[lo] to list[hi] in ascending order
for (int h = lo + 1; h <= hi; h++)
insertInPlace(list[h], list, lo, h - 1);
} //end insertionSort2
public static void insertInPlace(int newItem, int list[], int m, int n) {
//list[m] to list[n] are sorted
//insert newItem so that list[m] to list[n+1] are sorted
int k = n;
while (k >= m && newItem < list[k]) {
list[k + 1] = list[k];
--k;
}
list[k + 1] = newItem;
} //end insertInPlace
} //end class InsertSort2Test
1.4 对字符串数组进行排序
考虑按字母顺序排列姓名列表的问题。在 Java 中,一个名字存储在一个String
变量中,我们需要一个String
数组来存储这个列表。在大多数情况下,我们可以像处理一个原始类型一样处理String
,但是有时候记住严格来说,它是一个类是很有用的。在必要的地方,我们会指出区别。
这里我们关心的一个区别是,我们不能使用关系运算符(==
、<
、>
等等)来比较字符串。我们必须使用String
类中的函数(或者自己编写)。常用功能有equals
、equalsIgnoreCase
、compareTo
、compareToIgnoreCase
。我们编写一个函数,使用插入排序对字符串数组进行排序。我们称之为insertionSort3
。
public static void insertionSort3(String[] list, int lo, int hi) {
//sort list[lo] to list[hi] in ascending order
for (int h = lo + 1; h <= hi; h++) {
String key = list[h];
int k = h - 1; //start comparing with previous item
while (k >= lo && key.compareToIgnoreCase(list[k]) < 0) {
list[k + 1] = list[k];
--k;
}
list[k + 1] = key;
} //end for
} //end insertionSort3
除了声明list
和使用compareToIgnoreCase
来比较两个字符串之外,该函数与前面的函数非常相似。如果情况紧急,你可以使用compareTo
。
我们用程序 P1.3 测试insertionSort3
。
程序 P1.3
import java.util.*;
public class SortStrings {
final static int MaxNames = 8;
public static void main(String[] args) {
String name[] = {"Graham, Ariel", "Perrott, Chloe",
"Charles, Kandice", "Seecharan, Anella", "Reyes, Aaliyah",
"Graham, Ashleigh", "Reyes, Ayanna", "Greaves, Sherrelle" };
insertionSort3(name, 0, MaxNames - 1);
System.out.printf("\nThe sorted names are\n\n");
for (int h = 0; h < MaxNames; h++)
System.out.printf("%s\n", name[h]);
} //end main
// insertionSort3 goes here
} //end class SortStrings
运行时,程序 P1.3 产生如下输出:
The sorted names are
Charles, Kandice
Graham, Ariel
Graham, Ashleigh
Greaves, Sherrelle
Perrott, Chloe
Reyes, Aaliyah
Reyes, Ayanna
Seecharan, Anella
1.5 排序并行数组
在不同的数组中有相关的信息是很常见的。例如,假设除了name
,我们还有一个整数数组id
,使得id[h]
是与name[h]
关联的标识号,如图图 1-1 所示。
图 1-1 。包含相关信息的两个数组
考虑按字母顺序排列名字的问题。最后,我们希望每个名字都有正确的 ID 号。因此,例如,在排序完成后,name[0]
应该包含“查尔斯,坎蒂斯”,id[0]
应该包含4455
。
为此,在排序过程中,每次移动一个姓名时,相应的 ID 号也必须移动。因为姓名和 ID 号必须“并行”移动,所以我们说我们正在进行“并行排序”或者我们正在排序“并行数组”。
我们重写insertionSort3
来说明如何对并行数组进行排序。我们只需添加代码,以便在移动名称时移动 ID。我们称之为parallelSort
。
public static void parallelSort(String[] list, int id[], int lo, int hi) {
//Sort the names in list[lo] to list[hi] in alphabetical order,
//ensuring that each name remains with its original id number.
for (int h = lo + 1; h <= hi; h++) {
String key = list[h];
int m = id[h]; // extract the id number
int k = h - 1; //start comparing with previous item
while (k >= lo && key.compareToIgnoreCase(list[k]) < 0) {
list[k + 1] = list[k];
id[k+ 1] = id[k]; //move up id number when we move a name
--k;
}
list[k + 1] = key;
id[k + 1] = m; //store the id number in the same position as the name
} //end for
} //end parallelSort
我们通过编写程序 P1.4 来测试parallelSort
。
程序 P1.4
import java.util.*;
public class ParallelSort {
final static int MaxNames = 8;
public static void main(String[] args) {
String name[] = {"Graham, Ariel", "Perrott, Chloe",
"Charles, Kandice", "Seecharan, Anella", "Reyes, Aaliyah",
"Graham, Ashleigh", "Reyes, Ayanna", "Greaves, Sherrelle" };
int id[] = {3050,2795,4455,7824,6669,5000,5464,6050};
parallelSort(name, id, 0, MaxNames - 1);
System.out.printf("\nThe sorted names and IDs are\n\n");
for (int h = 0; h < MaxNames; h++)
System.out.printf("%-20s %d\n", name[h], id[h]);
} //end main
// parallelSort goes here
} //end class ParallelSort
当程序 P1.4 运行时,它产生以下输出:
The sorted names and IDs are
Charles, Kandice 4455
Graham, Ariel 3050
Graham, Ashleigh 5000
Greaves, Sherrelle 6050
Perrott, Chloe 2795
Reyes, Aaliyah 6669
Reyes, Ayanna 5464
Seecharan, Anella 7824
我们顺便注意到,如果我们有几组相关的项目要处理,那么将每一组存储在一个单独的数组中并不是最好的处理方式。最好将项目分组到一个类中,并像处理单个项目一样与该组一起工作。我们将在 2.14 节向您展示如何做到这一点。
1.6 二分搜索法
是一种快速搜索给定项目列表的方法,提供列表排序(升序或降序)。为了说明该方法,考虑一个由 13 个数字组成的列表,按升序排序并存储在数组num[0..12]
中。
*
假设我们要搜索66
。搜索过程如下:
- 我们找到了列表中的中间项。这是在位置
6
的56
。我们将66
与56
相比较。由于66
更大,我们知道如果66
在列表中,那么一定是在位置6
之后的,因为数字是按升序排列的。下一步,我们将搜索范围限制在位置7
到12
。 - 我们从位置
7
到12
找到中间的项目。在这种情况下,我们可以选择项目9
或项目10
。我们要写的算法会选择9
项,也就是78
。 - 我们比较
66
和78
。由于66
较小,我们知道如果66
在列表中,那么必须在位置9
之前,因为数字是按升序排列的。下一步,我们将搜索范围限制在位置7
到8
。 - 我们从位置
7
到8
找到中间的项目。在这种情况下,我们可以选择项目7
或项目8
。我们要写的算法会选择7
项,也就是66
。 - 我们比较
66
和66
。由于它们是相同的,我们的搜索成功结束,在位置7
找到了所需的项目。
假设我们正在搜索70
。搜索将如上所述继续进行,直到我们将70
与66
(在位置7
)进行比较。
- 由于
70
更大,我们知道如果70
在列表中,那么必须在位置7
之后,因为数字是升序排列的。下一步,我们将搜索范围限制在8
到8
的地点。这只是一个地点。 - 我们将
70
与8
项进行比较,即72
。由于70
较小,我们知道如果70
在列表中,那么必须在位置8
之前在位置。由于它不可能在位置7
之后和位置8
之前,所以我们断定它不在列表中。
在搜索的每个阶段,我们将搜索限制在列表的某个部分。让我们使用变量lo
和hi
作为定义这一部分的下标。换句话说,我们的搜索将局限于num[lo]
到num[hi]
。
最初,我们想要搜索整个列表,因此在本例中,我们将把lo
设置为0
,把hi
设置为12
。
我们如何找到中项的下标?我们将使用以下计算方法:
mid = (lo + hi) / 2;
因为将执行整数除法,所以分数(如果有的话)将被丢弃。例如,当lo
为0
、hi
为12
时,mid
变为6
;当lo
为7
且hi
为12
时,mid
变为9
;当lo
为7
且hi
为8
时,mid
变为7
。
只要lo
小于或等于hi
,它们就定义了要搜索的列表的非空部分。当lo
等于hi
时,它们定义了要搜索的单个项目。如果lo
变得比hi
大,这意味着我们已经搜索了整个列表,但没有找到该项目。
基于这些想法,我们现在可以编写一个函数binarySearch
。更一般地说,我们将编写它,以便调用例程可以指定它希望搜索数组的哪个部分来查找该项。
因此,必须给该函数指定要搜索的项(key
)、数组(list
)、搜索的开始位置(lo
)和搜索的结束位置(hi
)。例如,为了在上面的数组num
中搜索数字66
,我们可以发出调用binarySearch(66, num, 0, 12)
。
这个函数必须告诉我们搜索的结果。如果找到了该项,该函数将返回它的位置。如果没有找到,将返回-1
。
public static int binarySearch(int key, int[] list, int lo, int hi) {
//search for key from list[lo] to list[hi]
//if found, return its location; otherwise, return -1
while (lo <= hi) {
int mid = (lo + hi) / 2;
if (key == list[mid]) return mid; // found
if (key < list[mid]) hi = mid - 1;
else lo = mid + 1;
}
return -1; //lo and hi have crossed; key not found
}
如果item
包含一个要搜索的数字,我们可以编写如下代码:
int ans = binarySearch(item, num, 0, 12);
if (ans == -1) System.out.printf("%d not found\n", item);
else System.out.printf("%d found in location %d\n", item, ans);
如果我们想从位置i
到j
搜索item
,我们可以写如下:
int ans = binarySearch(item, num, i, j);
我们可以用程序 P1.5 来测试binarySearch
。
程序 p 1.5
public class BinarySearchTest {
public static void main(String[] args) {
int[] num = {17, 24, 31, 39, 44, 49, 56, 66, 72, 78, 83, 89, 96};
int n = binarySearch(66, num, 0, 12);
System.out.printf("%d\n", n); //will print 7; 66 in pos. 7
n = binarySearch(66, num, 0, 6);
System.out.printf("%d\n", n); //will print -1; 66 not in 0 to 6
n = binarySearch(70, num, 0, 12);
System.out.printf("%d\n", n); //will print -1; 70 not in list
n = binarySearch(89, num, 5, 12);
System.out.printf("%d\n", n); //will print 11; 89 in pos. 11
} //end main
// binarySearch goes here
} //end class BinarySearchTest
运行时,该程序将打印以下内容:
7
-1
-1
11
1.7 搜索字符串数组
我们可以使用与搜索整数数组相同的技术来搜索字符串的排序数组(比如按字母顺序排列的名称)。主要区别在于数组的声明和使用String
函数compareTo
,而不是==
或<
,来比较两个字符串。以下是binarySearch
的字符串版本:
public static int binarySearch(String key, String[] list, int lo, int hi) {
//search for key from list[lo] to list[hi]
//if found, return its location; otherwise, return -1
while (lo <= hi) {
int mid = (lo + hi) / 2;
int cmp = key.compareTo(list[mid]);
if (cmp == 0) return mid; // search succeeds
if (cmp < 0) hi = mid -1; // key is ‘less than’ list[mid]
else lo = mid + 1; // key is ‘greater than’ list[mid]
}
return -1; //lo and hi have crossed; key not found
} //end binarySearch
因为我们需要知道一个字符串是等于还是小于另一个,所以最好使用compareTo
方法。
注意,我们只调用了一次compareTo
。返回值(cmp
)告诉了我们需要知道的一切。如果我们在比较单词或名字,并且希望在比较中忽略字母的大小写,我们可以使用compareToIgnoreCase
。
可以用 P r
程序 P1.6 测试该功能。
程序 p 1.6
import java.util.*;
public class BinarySearchString {
final static int MaxNames = 8;
public static void main(String[] args) {
String name[] = {"Charles, Kandice", "Graham, Ariel",
"Graham, Ashleigh", "Greaves, Sherrelle", "Perrott, Chloe",
"Reyes, Aaliyah", "Reyes, Ayanna", "Seecharan, Anella"};
int n = binarySearch("Charles, Kandice", name, 0, MaxNames - 1);
System.out.printf("%d\n", n);
//will print 0, location of Charles, Kandice
n = binarySearch("Reyes, Ayanna", name, 0, MaxNames - 1);
System.out.printf("%d\n", n);
//will print 6, location of Reyes, Ayanna
n = binarySearch("Perrott, Chloe", name, 0, MaxNames - 1);
System.out.printf("%d\n", n);
//will print 4, location of Perrott, Chloe
n = binarySearch("Graham, Ariel", name, 4, MaxNames - 1);
System.out.printf("%d\n", n);
//will print -1, since Graham, Ariel is not in locations 4 to 7
n = binarySearch("Cato, Brittney", name, 0, MaxNames - 1);
System.out.printf("%d\n", n);
//will print -1 since Cato, Brittney is not in the list
} //end main
// binarySearch goes here
} //end class BinarySearchString
这将按照字母顺序设置名称数组name
。然后它用不同的名字调用binarySearch
并打印每个搜索的结果。
人们可能想知道这样的呼叫会发生什么:
n = binarySearch("Perrott, Chloe", name, 5, 10);
这里,我们告诉binarySearch
在给定数组的位置5
到10
中寻找"Perrott, Chloe"
。然而,位置8
到10
在数组中不存在。搜索的结果将是不可预测的。程序可能会崩溃或返回不正确的结果。确保用有效的参数调用binarySearch
(或任何其他函数)是调用程序的责任。
1.8 示例:词频统计
让我们写一个程序来阅读一篇英语文章,并统计每个单词出现的次数。输出由单词及其频率的字母列表组成。
我们可以使用以下大纲来开发我们的程序:
while there is input
get a word
search for word
if word is in the table
add 1 to its count
else
add word to the table
set its count to 1
endif
endwhile
print table
这是典型的“搜索并插入”情况。我们在目前存储的单词中搜索下一个单词。如果搜索成功,唯一要做的就是增加它的计数。如果搜索失败,该单词将被放入表中,并且其计数设置为 1。
这里的一个主要设计决策是如何搜索表,这反过来又取决于新单词在表中的插入位置和插入方式。以下是两种可能性:
- 在表格的下一个空闲位置插入一个新单词。这意味着必须使用顺序搜索来查找输入的单词,因为这些单词没有任何特定的顺序。这种方法具有简单和易于插入的优点,但是随着更多的单词被放入表中,搜索需要更长的时间。
- 在表格中插入一个新单词时,单词总是按字母顺序排列。这可能需要移动已经存储的单词,以便可以将新单词插入正确的位置。但是,由于表是有序的,所以可以使用二分搜索法来搜索输入的单词。
对于(2),搜索速度更快,但插入速度比(1)慢。因为一般来说,搜索比插入更频繁,(2)可能更好。
(2)的另一个优点是,在最后,单词已经按字母顺序排列,不需要排序。如果使用(1),则需要对单词进行排序,以获得字母顺序。
我们将使用(2)中的方法编写程序。完整的程序如程序 P1.7 所示。
程序 P1.7
import java.io.*;
import java.util.*;
public class WordFrequency {
final static int MaxWords = 50;
public static void main(String[] args) throws IOException {
String[] wordList = new String[MaxWords];
int[] frequency = new int[MaxWords];
FileReader in = new FileReader("passage.txt");
PrintWriter out = new PrintWriter(new FileWriter("output.txt"));
for (int h = 0; h < MaxWords; h++) {
frequency[h] = 0;
wordList[h] = "";
}
int numWords = 0;
String word = getWord(in).toLowerCase();
while (!word.equals("")) {
int loc = binarySearch(word, wordList, 0, numWords-1);
if (word.compareTo(wordList[loc]) == 0) ++frequency[loc]; //word found
else //this is a new word
if (numWords < MaxWords) { //if table is not full
addToList(word, wordList, frequency, loc, numWords-1);
++numWords;
}
else out.printf("'%s' not added to table\n", word);
word = getWord(in).toLowerCase();
}
printResults(out, wordList, frequency, numWords);
in.close();
out.close();
} // end main
public static int binarySearch(String key, String[] list, int lo, int hi){
//search for key from list[lo] to list[hi]
//if found, return its location;
//if not found, return the location in which it should be inserted
//the calling program will check the location to determine if found
while (lo <= hi) {
int mid = (lo + hi) / 2;
int cmp = key.compareTo(list[mid]);
if (cmp == 0) return mid; // search succeeds
if (cmp < 0) hi = mid -1; // key is 'less than' list[mid]
else lo = mid + 1; // key is 'greater than' list[mid]
}
return lo; //key must be inserted in location lo
} //end binarySearch
public static void addToList(String item, String[] list, int[] freq, int p, int n) {
//adds item in position list[p]; sets freq[p] to 1
//shifts list[n] down to list[p] to the right
for (int h = n; h >= p; h--) {
list[h + 1] = list[h];
freq[h + 1] = freq[h];
}
list[p] = item;
freq[p] = 1;
} //end addToList
public static void printResults(PrintWriter out, String[] list, int freq[], int n) {
out.printf("\nWords Frequency\n\n");
for (int h = 0; h < n; h++)
out.printf("%-20s %2d\n", list[h], freq[h]);
} //end printResults
public static String getWord(FileReader in) throws IOException {
//returns the next word found
final int MaxLen = 255;
int c, n = 0;
char[] word = new char[MaxLen];
// read over non-letters
while (!Character.isLetter((char) (c = in.read())) && (c != -1)) ;
//empty while body
if (c == -1) return ""; //no letter found
word[n++] = (char) c;
while (Character.isLetter(c = in.read()))
if (n < MaxLen) word[n++] = (char) c;
return new String(word, 0, n);
} // end getWord
} //end class WordFrequency
假设以下数据存储在passage.txt
中:
Be more concerned with your character than your reputation,
because your character is what you really are,
while your reputation is merely what others think you are.
Our character is what we do when we think no one is looking.
当程序 P1.7 运行时,它将其输出存储在output.txt
中。以下是输出:
Words Frequency
are 2
be 1
because 1
character 3
concerned 1
do 1
is 4
looking 1
merely 1
more 1
no 1
one 1
others 1
our 1
really 1
reputation 2
than 1
think 2
we 2
what 3
when 1
while 1
with 1
you 2
your 4
以下是对程序 P1.7 的一些评论:
- 出于我们的目的,我们假设一个单词以字母开头,并且只由字母组成。如果您想包含其他字符(如连字符或撇号),您只需更改
getWord
功能。 MaxWords
表示满足的不同单词的最大数量。为了测试程序,我们使用了50
作为这个值。如果文章中不同单词的数量超过了MaxWords
(比如说 50),那么第 50 个之后的所有单词都将被读取,但不会被存储,并且会打印一条大意如此的消息。然而,如果再次遇到,已经存储的单词的计数将增加。main
将频率计数初始化为0
并将String
数组中的项目初始化为空字符串。然后,它根据 1.8 节开头所示的大纲处理文章中的单词。getWord
读取输入文件并返回找到的下一个单词。- 所有单词都被转换成小写,例如,
The
和the
被视为同一个单词。 binarySearch
是这样写的,如果找到这个单词,就返回它的位置。如果没有找到该单词,则返回其应该插入的位置。函数addToList
被赋予插入新单词的位置。该位置右侧的单词(包括该位置)将移动一个位置,以便为新单词腾出空间。
1.9 合并有序列表
合并是将两个或多个有序列表合并成一个有序列表的过程。例如,给定两个数字列表,A
和B
,如下所示:
A: 21 28 35 40 61 75
B: 16 25 47 54
它们可以组合成一个有序列表,C:
C: 16 21 25 28 35 40 47 54 61 75
列表C
包含列表A
和B
中的所有数字。如何执行合并?
一种思考方式是想象给定列表中的数字存储在卡片上,每张卡片一个,卡片面朝上放在桌子上,最小的放在顶部。我们可以想象列表A
和B
如下:
21 16
28 25
35 47
40 54
61
75
我们看最上面的两张卡,21
和16
。较小的16
被移除并放置在C
中。这就暴露了25
这个数字。
现在最上面的两张卡是21
和25
。较小的21
被移除并添加到C
,?? 现在包含了16 21
。这就暴露了数字28
。
现在最上面的两张卡是28
和25
。较小的25
被移除并添加到C
,?? 现在包含了16 21 25
。这就暴露了数字47
。
现在最上面的两张卡是28
和47
。较小的28
被移除并添加到C
,?? 现在包含了16 21 25 28
。这就暴露了数字35
。
现在最上面的两张卡是35
和47
。较小的35
被移除并添加到C
,?? 现在包含了16 21 25 28 35
。这就暴露了数字40
。
现在最上面的两张卡是40
和47
。较小的40
被移除并添加到C
,?? 现在包含了16 21 25 28 35 40
。这就暴露了数字61
。
现在最上面的两张卡是61
和47
。较小的47
被移除并添加到C
,?? 现在包含了16 21 25 28 35 40 47
。这就暴露了数字54
。
现在最上面的两张卡是61
和54
。较小的54
被移除并添加到C
,?? 现在包含了16 21 25 28 35 40 47 54
。列表B
没有更多数字。
我们将A
的剩余元素(61 75
)复制到C
,现在包含以下内容:
16 21 25 28 35 40 47 54 61 75
合并已完成。
在合并的每一步,我们将最小剩余数A
与最小剩余数B
进行比较。其中较小的被添加到C
。如果较小的数字来自于A
,我们继续前进到A
的下一个数字;如果较小的数字来自B
,我们将继续处理B
中的下一个数字。
重复这一过程,直到使用完A
或B
中的所有号码。如果A
中的所有号码都已被使用,我们将从B
到C
的剩余号码相加。如果B
中的所有数字都已被使用,我们将从A
到C
的剩余数字相加。
我们可以将合并的逻辑表达如下:
while (at least one number remains in both A and B) {
if (smallest in A < smallest in B)
add smallest in A to C
move on to next number in A
else
add smallest in B to C
move on to next number in B
endif
}
if (A has ended) add remaining numbers in B to C
else add remaining numbers in A to C
1.9.1 实现合并
假设数组A
包含存储在A[0]
到A[m-1]
中的 m 个数,数组B
包含存储在B[0]
到B[n-1]
中的 n 个数。假设数字按升序存储。我们希望将A
和B
中的数字合并到另一个数组C
中,这样C[0]
到C[m+n-1]
就包含了A
和B
中按升序排序的所有数字。
我们将使用整数变量i
、j
和k
来分别下标数组A
、B
和C
。在数组中“移动到下一个位置”可以通过给下标变量加上1
来完成。我们可以用这个函数实现合并:
public static int merge(int[] A, int m, int[] B, int n, int[] C) {
int i = 0; //i points to the first (smallest) number in A
int j = 0; //j points to the first (smallest) number in B
int k = -1; //k will be incremented before storing a number in C[k]
while (i < m && j < n) {
if (A[i] < B[j]) C[++k] = A[i++];
else C[++k] = B[j++];
}
if (i == m) ///copy B[j] to B[n-1] to C
for ( ; j < n; j++) C[++k] = B[j];
else // j == n, copy A[i] to A[m-1] to C
for ( ; i < m; i++) C[++k] = A[i];
return m + n;
} //end merge
该函数接受参数A
、m
、B
、n
和C
,执行合并,并返回C
中的元素数量m + n
。
程序 P1.8 显示了一个简单的main
函数,用于测试merge
的逻辑。它设置数组A
和B
,调用merge
,打印C
。运行时,该程序打印以下内容:
16 21 25 28 35 40 47 54 61 75
程序 P1.8
public class MergeTest {
public static void main(String[] args) {
int[] A = {21, 28, 35, 40, 61, 75}; //size 6
int[] B = {16, 25, 47, 54}; //size 4
int[] C = new int[20]; //enough to hold all the elements
int n = merge(A, 6, B, 4, C);
for (int j = 0; j < n; j++) System.out.printf("%d ", C[j]);
System.out.printf("\n");
} //end main
// merge goes here
} //end class MergeTest
有趣的是,我们也可以如下实现merge
:
public static int merge(int[] A, int m, int[] B, int n, int[] C) {
int i = 0; //i points to the first (smallest) number in A
int j = 0; //j points to the first (smallest) number in B
int k = -1; //k will be incremented before storing a number in C[k]
while (i < m || j < n) {
if (i == m) C[++k] = B[j++];
else if (j == n) C[++k] = A[i++];
else if (A[i] < B[j]) C[++k] = A[i++];
else C[++k] = B[j++];
}
return m + n;
}
while
循环表达了以下逻辑:只要在A
或 B
中至少有一个元素要处理,我们就进入循环。如果我们完成了A
( i == m
,从B
到C
复制一个元素。如果我们完成了B
( j == n
),将一个元素从A
复制到C
。否则,将A[i]
和B[j]
中较小的一个复制到C
。每当我们从一个数组中复制一个元素,我们就给这个数组的下标加 1。
虽然以前的版本以一种简单的方式实现了合并,但是说这个版本更简洁似乎是合理的。
练习 1
-
A survey of 10 pop artists is made. Each person votes for an artist by specifying the number of the artist (a value from 1 to 10). Each voter is allowed one vote for the artist of their choice. The vote is recorded as a number from
1
to10
. The number of voters is unknown beforehand, but the votes are terminated by a vote of0
. Any vote that is not a number from1
to10
is a spoiled vote. A file,votes.txt
, contains the names of the candidates. The first name is considered as candidate 1, the second as candidate 2, and so on. The names are followed by the votes. Write a program to read the data and evaluate the results of the survey.按艺术家姓名的字母顺序和收到的票数顺序打印结果(票数最多者优先)。打印所有输出到文件,
results.txt
。 -
写一个程序将名字和电话号码读入两个数组。询问对方的姓名,并打印出他的电话号码。使用二分搜索法查找该名称。
-
写一个程序,把英语单词和它们对应的西班牙语单词读入两个数组。要求用户键入几个英语单词。对于每一项,打印相应的西班牙语单词。选择合适的数据结束标记。使用二分搜索法搜索键入的单词。修改程序,让用户键入西班牙语单词。
-
一组 n 个数(不一定是截然不同的)的中位数是将数按顺序排列,取中间的数得到的。如果 n 是奇数,则有一个唯一的中间数。如果 n 是偶数,那么两个中间值的平均值就是中位数。写程序读取一组 n 正整数(假设n100)并打印它们的中位数; n 未给出,但
0
表示数据结束。 -
一组 n 数字的模式是出现频率最高的数字。比如
7 3 8 5 7 3 1 3 4 8 9
的模式是3
。写程序读取一组 n 正整数(假设n100)并打印出它们的模式; n 未给出,但0
表示数据结束。 -
数组
chosen
包含n
个不按特定顺序排列的不同整数。另一个名为winners
的数组包含按照升序排列的m
个不同的整数。编写代码来确定chosen
中有多少个数字出现在winners
中。 -
A multiple-choice examination consists of 20 questions. Each question has five choices, labeled A, B, C, D, and E. The first line of data contains the correct answers to the 20 questions in the first 20 consecutive character positions, for example:
BECDCBAADEBACBAEDDBE
随后的每一行都包含候选人的答案。一行中的数据由一个候选数字(一个整数)组成,后面是一个或多个空格,再后面是候选人在接下来的 20 个连续字符位置给出的 20 个答案。如果候选人没有回答特定的问题,则使用 X。您可以假设所有数据都是有效的,并存储在文件
exam.dat
中。示例行如下:4325 BECDCBAXDEBACCAEDXBE
最多有 100 名候选人。包含“候选编号”
0
的行仅表示数据的结束。一个问题的分值授予如下—正确答案:4 分;回答错误:-1 分;没有回答:0 分。
编写一个程序来处理数据,并打印一份报告,该报告由候选人编号和候选人获得的总分数组成,按候选人编号升序排列。最后,打印候选人获得的平均分数。
-
A
是按降序排序的数组。B
是一个降序排列的数组。将A
和B
合并成C
,使C
按照递减的顺序。 -
A
是按降序排序的数组。B
是一个降序排列的数组。将A
和B
合并为C
,使C
按照升序排列。 -
A
是按升序排序的数组。B
是一个降序排列的数组。将A
和B
合并为C
,使C
按照升序排列。 -
An array A contains integers that first increase in value and then decrease in value. Here’s an example:
![9781430266198_unFig01-21.jpg](https://gitee.com/OpenDocCN/vkdoc-java-zh/raw/master/docs/adv-topic-java/img/9781430266198_unFig01-21.jpg)
不知道从哪一点开始数量开始减少。编写高效的代码将 A 中的数字复制到另一个数组 B 中,使 B 按升序排序。您的代码必须利用数字在。
- Two words are anagrams if one word can be formed by rearranging all the letters of the other word, for example: section, notices. Write a program to read two words and determine whether they are anagrams.
编写另一个程序来读取单词列表并找到所有单词集合,使得一个集合中的单词是彼此的变位词。*
二、对象简介
在本章中,我们将解释以下内容:
- 什么是类、对象、字段和方法
- 对象变量不保存对象,而是保存一个指向对象实际位置的指针(或引用)
- 类变量(也称为静态变量)和实例变量(也称为非静态变量)之间的区别
- 类方法(也称为静态方法)和实例方法(也称为非静态方法)之间的区别
- 访问修饰符 public、private 和 protected 是什么意思
- 什么是信息隐藏
- 如何引用类和实例变量
- 如何初始化类和实例变量
- 什么是构造函数以及如何编写构造函数
- 超载是什么意思
- 什么是数据封装
- 如何编写访问器和赋值器方法
- 如何以各种方式打印对象的数据
- 为什么
tostring()
方法在 Java 中是特殊的 - 当我们将一个对象变量赋给另一个对象变量时会发生什么
- 将一个对象变量与另一个对象变量进行比较意味着什么
- 如何比较两个对象的内容
- 函数如何使用对象返回多个值
2.1 对象
Java 被认为是一种面向对象的编程语言。设计者创造它是为了让物体成为关注的焦点。Java 程序创建和操作对象,试图模拟现实世界是如何运作的。出于我们的目的,对象是一个拥有状态和方法来操作该状态的实体。对象的状态由其属性决定。
比如,我们可以把一个人想象成一个物体。一个人有姓名、年龄、性别、身高、头发颜色、眼睛颜色等属性。在一个程序中,每个属性都由一个适当的变量来表示;例如,String
变量可以代表的名字,一个int
变量可以代表的年龄,一个char
变量可以代表的性别,一个double
变量可以代表的身高,等等。
我们通常使用术语字段名(或者简单地说,字段)来指代这些变量。因此,对象的状态由其字段中的值定义。此外,我们将需要方法来设置和/或更改字段的值,以及检索它们的值。例如,如果我们对一个人的身高感兴趣,我们将需要一个方法来“观察”该对象并返回高度字段的值。
汽车是物体的另一个常见例子。它具有制造商、型号、座位容量、燃料容量、油箱中的实际燃料、里程、音乐设备的类型和速度等属性。book 对象具有诸如作者、标题、价格、页数、装订类型(精装、平装、螺旋)以及是否有货等属性。一个人、一辆车和一本书都是具体物体的例子。但是,请注意,一个对象也可以表示一个抽象的概念,例如公司的一个部门或大学的一个系。
在前面的例子中,我们没有提到某个特定的人。相反,我们谈到了一个总的范畴“人”,这个范畴中的每个人都具有所提到的属性。(类似的言论也适用于汽车和书籍。)在 Java 术语中,“人”是一个类。我们认为类是一个通用的类别(模板),从中我们可以创建特定的对象。
那么,一个对象就是一个类的实例;在这个例子中,Person
对象指的是一个特定的人。为了处理两个Person
对象,我们需要从Person
的类定义中创建两个对象。每个对象都有自己的字段变量副本(也称为实例变量);一个对象中的变量值可能与另一个对象中的变量值不同。
2.2 定义类和创建对象
最简单的 Java 程序只包含一个类。在类中,我们编写一个或多个方法/函数来执行一些任务。程序 P2.1 显示了一个例子。
程序 P2.1
//prompt for two numbers and find their sum
import java.util.*;
public class Sum {
public static void main(String[] args) {
Scanner in = new Scanner(System.in);
System.out.printf("Enter first number: ");
int a = in.nextInt();
System.out.printf("Enter second number: ");
int b = in.nextInt();
System.out.printf("%d + %d = %d\n", a, b, a + b);
}
} //end class Sum
程序由一个类(ProgramP1_1
)和该类中的一个方法(main
)组成。这个类只是用来作为编写程序逻辑的框架。我们现在将展示如何定义和使用一个类来创建(我们说实例化)对象。
在 Java 中,每个对象都属于某个类,并且只能从类定义中创建。考虑类Book
的以下(部分)定义:
public class Book {
private static double Discount = 0.25; //class variable
private static int MinBooks = 5; //class variable
private String author; // instance variable
private String title; // instance variable
private double price; // instance variable
private int pages; // instance variable
private char binding; // instance variable
private boolean inStock; // instance variable
// methods to manipulate book data go here
} //end class Book
类标题(第一行)由以下内容组成:
- 可选的访问修饰符;
public
在示例中使用,并且将用于我们的大多数类。本质上,这意味着该类可供任何其他类使用;也可以扩展创建子类。其他访问修饰符是abstract
和final
;我们不会在这本书里讨论这些。 - 关键词
class
。 - 类名的用户标识符;例中使用了
Book
。
大括号将类的主体括起来。一般而言,该机构将包括以下声明:
- 静态变量(类变量);整个类将有一个副本,所有对象将共享这个副本。使用单词
static
声明一个类变量。如果我们省略static
这个词,变量就是实例。 - 非静态变量(实例变量);创建的每个对象都有自己的副本。组成对象数据的是实例变量。
- 静态方法(类方法);这些在类加载时加载一次,无需创建任何对象即可使用。静态方法访问非静态变量(属于对象)是没有意义的,所以 Java 禁止。
- 非静态方法(实例方法);这些只能通过从类中创建的对象使用。操纵对象中数据(非静态字段)的是非静态方法。
String
类是在 Java 中预定义的。如果word
是String
(准确地说是一个String
对象)并且我们编写了word.toLowerCase()
,我们要求将String
类的实例方法toLowerCase
应用到String
对象word
。该方法将用于调用它的(String
)对象中的大写字母转换成小写字母。- 同样,如果
in
是一个Scanner
对象(我们说new Scanner...
时创建的),表达式in.nextInt()
将实例方法nextInt
应用于对象in
;这里,它从与in
相关的输入流中读取下一个整数。
在Book
类中,我们声明了两个类变量(Discount
和MinBooks
,用static
声明)和六个实例变量;默认情况下它们是实例(省略了static
这个词)。
2.2.1 访问类和实例变量
除了static
,还可以使用可选的访问修饰符private
、public
或protected
来声明一个字段。在Book
类中,我们使用private
声明了所有的实例变量。关键字private
表示变量只在类中“已知”,并且只能由类中的方法直接操作。换句话说,类外的任何方法都不能直接访问private
变量。然而,我们很快就会看到,我们可以提供其他类可以用来设置和访问private
变量的值的public
方法。这样,我们确保了类数据只能由类中的方法来更改。
声明一个变量public
意味着可以从类外部直接访问它。因此,其他类可以用一个public
变量“为所欲为”。例如,如果Discount
被声明为public
,那么任何其他类都可以使用Book.Discount
来访问它,并以它喜欢的任何方式更改它。通常不鼓励这样做,因为这样类就会失去对其数据的控制。
在大多数情况下,我们将使用private
声明一个类的字段。这样做是实现信息隐藏概念的第一步,这是面向对象编程哲学的一部分。其思想是对象的用户不能直接处理对象的数据;它们应该通过对象的方法来实现。
声明一个变量protected
意味着它可以直接从类和它的任何子类中访问,也可以从同一个包中的其他类中访问。在这个介绍中,我们将不使用protected
变量。
如果没有指定访问修饰符,那么这个变量只能被同一个包中的其他类直接访问。
一个类中的方法可以简单地通过名字引用类中的任何变量(static
或非static
、public
或private
)。(一个例外是静态方法不能访问非静态变量。)如果一个静态变量在类之外是已知的(也就是说,不是private
,那么通过用类名限定变量来引用它,就像在Book.Discount
和Book.MinBooks
中一样。
在类的外部,一个非私有的实例变量只能通过它所属的对象来引用;下一节将对此进行说明。然而,如上所述,良好的编程实践表明,大多数情况下,我们的变量将被声明为private
,因此不会出现从类外部直接访问的概念。
2.2.2 初始化类和实例变量
当Book
类被加载时,存储立即被分配给类变量Discount
和MinBooks
;然后,它们分别被赋予初始值0.25
和 5。这些变量背后的含义是,如果一本书售出五本或更多,那么就给予 25%的折扣。因为这些值适用于所有的书,把它们和每本书的数据存储在一起会浪费存储空间,因此把它们声明为static
变量。所有 book 对象都可以访问这些变量的单个副本。(但是,请注意,如果我们想在不同的书中改变这些值,那么它们将成为特定书的属性,并且必须声明为非静态的。)
当第一次加载类时,没有存储空间分配给实例(非静态)变量。此时,我们只有实例变量的规范,但是实际上还没有。当从类中创建一个对象时,它们就存在了。对象的数据由实例变量决定。当一个对象被“创建”时,存储空间被分配给类中定义的所有实例变量;创建的每个对象都有自己的实例变量副本。要创建一个对象,我们使用关键字new
,如下所示:
Book b;
b = new Book();
第一条语句将b
声明为Book
类型的变量。由此,我们看到一个类名被认为是一种类型(类似于int
或char
),可以用来声明变量。我们说b
是一个Book
类型的对象变量。
b
的声明不创建对象;它只是创建一个变量,该变量的值最终将成为一个对象的指针。如图所示声明时,其值是未定义的。
第二条语句找到一些可以存储Book
对象的可用内存,创建该对象,并将该对象的地址存储在b
中。(把地址想象成对象占用的第一个内存位置。如果对象占据位置2575
到2599
,那么它的地址就是2575
。)我们说b
包含一个指向该对象的引用或指针。因此,对象变量的值是一个内存地址,而不是一个对象。如图 2-1 中的所示。
图 2-1 。书籍对象的实例
作为一种快捷方式,我们可以在一条语句中声明b
并创建一个 book 对象,如下所示:
Book b = new Book();
认为Book
变量b
可以容纳Book
对象是一个常见的错误。它不能;它只能保存对一个Book
对象的引用。(以类似的方式,我们应该熟悉这样的想法:一个String
变量并不保存一个字符串,而是保存字符串存储位置的地址。)然而,在区别(对象和对对象的引用之间的区别)无关紧要的地方,我们将认为b
持有一个Book
对象。
一旦对象b
被创建,我们可以像这样引用它的实例字段:
b.author b.title b.price
b.pages b.binding b.inStock
然而,只有当字段被声明为public
时,我们才能从类外的中这样做。我们将在后面看到当字段被声明为private
时如何间接访问它们。
当创建一个对象时,除非我们另有说明,否则它的实例字段初始化如下:
- 数值字段被设置为
0
。 - 字符字段被设置为
'\0'
(准确地说是 Unicode'\u0000'
)。 - 布尔字段被设置为
false
。 - 对象字段被设置为
null
。(值为null
的变量意味着它不引用或指向任何东西。)
在我们的示例中,会发生以下情况:
b.author
(类型String
)设置为null
;记住String
是一个对象类型。b.title
(类型String
)被设置为null
。b.price
(类型double
)被设置为0.0
。b.pages
(类型int
)被设置为0
。b.binding
(类型char
)被设置为'\0'
。b.inStock
(类型boolean
)被设置为false
。
我们可以在声明一个实例变量时指定一个初始值。考虑以下代码:
public class Book {
private static double Discount = 0.25;
private static int MinBooks = 5;
private String author = "No Author";
private String title;
private double price;
private int pages;
private char binding = 'P'; // for paperback
private boolean inStock = true;
}
现在,当创建一个对象时,author
、binding
和inStock
将被设置为指定值,而title
、price
和pages
将采用默认值。只有在没有明确赋值的情况下,变量才会被赋予默认值。假设我们用这个创建一个对象b
:
Book b = new Book();
这些字段将被初始化如下:
author
设置为"No Author"
。//在声明中指定title
设置为null
。//默认为(String
)对象类型price
设置为0.0
。//数值类型的默认值pages
设置为0
。//数值类型的默认值binding
设置为'P'
。//在声明中指定inStock
设置为true
。//在声明中指定
2.3 构造函数
构造函数提供了更灵活的方法来初始化创建时的对象状态。在下面的陈述中,Book()
被称为构造器:
Book b = new Book();
它类似于方法调用。但是,你可能会说,我们没有在类定义中编写任何这样的方法。没错,但在这种情况下,Java 提供了一个默认构造函数——一个没有参数的构造函数(也称为无参数构造函数)。默认的构造函数非常简单;它只是将实例变量的值设置为它们默认的初始值。稍后,我们可以为对象的字段分配更有意义的值,如下所示:
b.author = "Noel Kalicharan";
b.title = "DigitalMath";
b.price = 29.95;
b.pages = 200;
b.binding = 'P'; //for paperback
b.inStock = true; //stock is available
现在假设当我们创建一个 book 对象时,我们希望 Java 自动分配作者和标题。我们希望能够使用如下语句来创建新的 book 对象:
Book b = new Book("Noel Kalicharan", "DigitalMath");
我们可以这样做,但是我们必须首先编写一个适当的构造函数,一个用两个参数定义的构造函数。下面展示了如何实现这一点:
public Book(String a, String t) {
author = a;
title = t;
}
以下是一些需要注意的要点:
- 一个类的构造函数和这个类有相同的名字。我们班叫
Book
;因此,必须调用构造函数Book
。因为构造函数是要被其他类使用的,所以它被声明为public
。 - 构造函数可以有零个或多个参数。调用时,必须为构造函数提供适当数量和类型的参数。在我们的例子中,构造函数是用两个参数
String
、a
和t
声明的。调用构造函数时,必须提供两个String
参数。 - 构造函数的主体包含调用构造函数时要执行的代码。我们的示例将实例变量
author
设置为第一个参数,将title
设置为第二个参数。一般来说,除了设置实例变量的值之外,我们还可以使用其他语句。例如,我们可以在将提供的值赋给字段之前验证它。我们将在下一节看到一个这样的例子。 - 构造函数没有返回类型,甚至没有
void
。 - 如果在声明中为实例变量提供了初始值,那么这些值将在调用构造函数之前存储。
例如,假设类Book
现在声明如下:
public class Book {
private static double Discount = 0.25;
private static int MinBooks = 5;
private String author = "No Author";
private String title;
private double price;
private int pages;
private char binding = 'P'; // for paperback
private boolean inStock = true;
public Book(String a, String t) {
author = a;
title = t;
}
} //end class Book
该声明
Book b = new Book("Noel Kalicharan", "DigitalMath");
将按如下方式执行:
-
为一个
Book
对象找到存储器,存储器的地址存储在 b 中。 -
这些字段设置如下:
author is set to "No Author"; // specified in the declaration title is set to null; // default for (String) object type price is set to 0.0; // default for numeric type pages is set to 0; // default for numeric type binding is set to 'P'; // specified in the declaration inStock is set to true. // specified in the declaration
-
用参数
"Noel Kalicharan"
和"DigitalMath"
调用构造函数;这将author
设置为"Noel Kalicharan"
,将title
设置为"DigitalMath"
,其他字段保持不变。当构造函数完成时,这些字段将具有以下值:author "Noel Kalicharan" title "DigitalMath" price 0.0 pages 0 binding 'P' inStock true
2.3.1 重载构造函数
Java 允许我们有不止一个构造函数,假设每个构造函数都有不同的签名。当几个构造函数可以有相同的名字时,这被称为重载构造函数。假设我们希望能够使用无参数的构造函数以及带有作者和标题参数的构造函数。我们可以像这样在类声明中包含这两者:
public class Book {
private static double Discount = 0.25;
private static int MinBooks = 5;
private String author = "No Author";
private String title;
private double price;
private int pages;
private char binding = 'P'; // for paperback
private boolean inStock = true;
public Book() { }
public Book(String a, String t) {
author = a;
title = t;
}
} //end class Book
请注意,无参数构造函数的主体由一个空块组成。当执行下面的语句时,实例变量被设置为它们的初始值(指定值或默认值),并且执行构造函数。在这种情况下,不会发生进一步的事情。
Book b = new Book();
请注意,当我们提供构造函数时,默认的无参数构造函数不再可用。如果我们也想使用无参数构造函数,我们必须显式地编写它,就像前面的例子一样。当然,我们可以自由地在正文中写任何我们想写的东西,包括什么都不写。
作为最后一个例子,我们提供了一个构造函数,让我们在创建对象时显式地设置所有字段。这是:
public Book(String a, String t, double p, int g, char b, boolean s) {
author = a;
title = t;
price = p;
pages = g;
binding = b;
inStock = s;
}
如果b
是类型为Book
的变量,调用示例如下:
b = new Book("Noel Kalicharan", "DigitalMath", 29.95, 200, 'P', true);
这些字段将被赋予以下值:
author "Noel Kalicharan"
title "DigitalMath"
price 29.95
pages 200
binding 'P'
inStock true
2.4 数据封装、访问器和赋值器方法
我们将使用术语用户类来表示其方法需要访问另一个类的字段和方法的类。
当一个类的字段被声明为public
时,任何其他的类都可以通过名字直接访问该字段。考虑以下类别:
public class Part {
public static int NumParts = 0; // class variable
public String name; // instance variable
public double price; // instance variable
}
这里,我们将一个静态(或类)变量和两个实例变量定义为public
。任何用户类都可以使用Part.NumParts
访问静态变量,并且可以包含如下语句:
Part.NumParts = 25;
这可能是不可取的。假设NumParts
是为了计算从Part
创建的对象的数量。任何外部类都可以将它设置为它喜欢的任何值,所以类Part
的作者不能保证它总是反映所创建对象的数量。
和往常一样,实例变量只能通过对象来访问。当一个用户类创建一个类型为Part
的对象p
时,它可以使用p.price
(或p.name
)直接引用实例变量,如果需要的话,可以用一个简单的赋值语句来改变它。没有什么可以阻止用户类将变量设置为不合理的值。例如,假设所有价格都在 0.00 到 99.99 之间。用户类可以包含以下语句,这会损害价格数据的完整性:
p.price = 199.99;
要解决这些问题,我们必须使数据字段private
;我们说我们必须隐藏数据。然后我们为其他人提供public
方法来设置和检索字段中的值。私有数据和公共方法是数据封装的精髓。设置或更改字段值的方法称为 mutator 方法。检索字段中的值的方法称为访问器方法。
让我们展示一下如何解决提到的两个问题。首先,我们将字段重新定义为private
:
public class Part {
private static int NumParts = 0; // class variable
private String name; // instance variable
private double price; // instance variable
}
现在它们是private
,没有其他的职业可以访问它们。如果我们想让NumParts
反映从类中创建的对象的数量,我们需要在每次调用构造函数时递增它。例如,我们可以编写一个无参数构造函数,如下所示:
public Part() {
name = "NO PART";
price = -1.0; // we use –1 since 0 might be a valid price
NumParts++;
}
每当用户类执行如下语句时,就会创建一个新的Part
对象并将 1
添加到NumParts
:
Part p = new Part();
因此,NumParts
的值将总是所创建的Part
个对象的数量。进一步说,这是改变其值的唯一方式;类Part
的作者可以保证NumParts
的值总是被创建的对象的数量。
当然,用户类可能需要在任何给定时间知道NumParts
的值。既然它没有对NumParts
的访问,我们必须提供一个公共访问器方法 ( GetNumParts
,比方说;我们使用大写的G
作为静态访问器,因为它提供了一种快速区分静态和非静态的方法,后者返回值。方法如下:
public static int GetNumParts() {
return NumParts;
}
该方法被声明为static
,因为它只对一个static
变量进行操作,不需要调用一个对象。它可以用Part.GetNumParts()
来称呼。如果p
是一个Part
对象,Java 允许你用p.GetNumParts()
调用它。然而,这意味着GetNumParts
是一个实例方法(通过对象调用并对实例变量进行操作的方法),所以这可能会引起误解。我们建议通过类名调用类(静态)方法,而不是通过类中的对象。
作为一个练习,向Book
类添加一个字段来计算创建的 book 对象的数量,并更新构造函数来增加这个字段。
2.4.1 改进的构造器
我们可以不使用无参数构造函数,而是采用更实际的方法,编写一个构造函数,让用户在创建对象时分配名称和价格,如下所示:
Part af = new Part("Air Filter", 8.75);
我们可以将构造函数写成:
public Part(String n, double p) {
name = n;
price = p;
NumParts++;
}
除了用户仍然可以为某个零件设置无效价格之外,这种方法是可行的。没有任何东西可以阻止用户编写这个语句:
Part af = new Part("Air Filter", 199.99);
构造函数会忠实地将price
设置为无效值199.99
。然而,我们可以在构造函数中做更多的事情,而不仅仅是给变量赋值。如果有必要,我们可以测试一个值并拒绝它。我们认为,如果提供了一个无效的价格,对象仍将被创建,但会打印一条消息,价格将被设置为–1.0
。下面是构造函数的新版本:
public Part(String n, double p) {
name = n;
if (p < 0.0 || p > 99.99) {
System.out.printf("Part: %s\n", name);
System.out.printf("Invalid price: %3.2f. Set to -1.0.\n", p);
price = -1.0;
}
else price = p;
NumParts++;
} //end constructor Part
作为一种良好的编程风格,我们应该将价格限制(0.00
和99.99
)和“空”价格(-1.0
)声明为类常量。我们可以使用以下内容:
private static final double MinPrice = 0.0;
private static final double MaxPrice = 99.99;
private static final double NullPrice = -1.0;
这些标识符现在可以在构造函数中使用。
2.4.2 访问器方法
由于用户类可能需要知道一个商品的名称或价格,我们必须为name
和price
提供公共访问器方法。访问器方法只是返回特定字段中的值。按照惯例,我们在这些方法的名称前面加上get
这个词。这些方法如下:
public String getName() { // accessor
return name;
}
public double getPrice() { // accessor
return price;
}
请注意,访问器的返回类型与字段的类型相同。例如,getName
的返回类型是String
,因为name
的类型是String
。
因为访问器方法返回实例字段中的值,所以只在与特定对象相关时调用它是有意义的(因为每个对象都有自己的实例字段)。如果p
是类型为Part
的对象,那么p.getName()
返回p
的name
字段中的值,而p.getPrice()
返回p
的price
字段中的值。
作为练习,为Book
类的所有字段编写访问器方法。
这些访问器是非静态或实例方法的例子(在它们的声明中没有使用单词static
)。我们可以认为每个对象在一个类中都有自己的实例方法副本。然而实际上,这些方法仅仅是对一个对象可用的 ??。将有一个方法的副本,并且当在对象上调用该方法时,该方法将被绑定到一个特定的对象。
假设一个Part
对象p
存储在位置725
,我们可以把这个对象描绘成如图 2-2 中的所示。
图 2-2 。带有字段和访问器的部件对象
想象一下字段name
和price
被锁在一个盒子里,外界看到它们的唯一方式是通过方法getName
和getPrice
。
2.4.3 赋值函数方法
作为该类的作者,我们必须决定是否允许用户在对象创建后更改其名称或价格。有理由假设用户可能不想更改名称。然而,价格是变化的,所以我们应该提供一种(或多种)改变价格的方法。例如,我们编写了一个用户类可以调用的公共赋值函数方法 ( setPrice
),如下所示:
p.setPrice(24.95);
这将Part
对象p
的价格设置为24.95
。和以前一样,该方法不允许设置无效的价格。它将验证所提供的价格,并在必要时打印适当的消息。使用第 2.4.1 节中声明的常数,这里是setPrice
:
public void setPrice(double p) {
if (p < MinPrice || p > MaxPrice) {
System.out.printf("Part: %s\n", name);
System.out.printf("Invalid price: %3.2f; Set to %3.2f\n", p, NullPrice);
price = NullPrice;
}
else price = p;
} //end setPrice
有了这个加法,我们可以认为Part p
如图图 2-3 。
图 2-3 。添加了 setPrice()的零件对象
观察setPrice
的箭头方向;一个值正从外部世界发送到对象的私有字段。
同样,我们强调声明字段private
并为其提供赋值函数/访问函数方法的优越性,这与声明字段public
并让用户类直接访问它相反。
我们还可以提供一些方法,以给定的数量或给定的百分比来提高或降低价格。这些都是作为练习留下的。
作为另一个练习,为Book
类的price
和inStock
字段编写赋值函数方法。
2.5 打印对象的数据
为了验证我们的部件被赋予了正确的值,我们需要某种方法来打印对象字段中的值。
2.5.1 使用实例方法 (首选方式)
一种方法是编写一个实例方法(printPart
),当调用一个对象时,它将打印那个对象的数据。为了打印Part p
的数据,我们将这样写:
p.printPart();
方法如下:
public void printPart() {
System.out.printf("\nName of part: %s\n", name);
System.out.printf("Price: $%3.2f\n", price);
} //end printPart
假设我们用这个创建一个零件:
Part af = new Part("Air Filter", 8.75);
表达式af.printPart()
将显示以下内容:
Name of part: Air Filter
Price: $8.75
当通过af
调用printPart
时,printPart
中对字段name
和price
的引用变成对af
字段的引用。这在图 2-4 中进行了说明。
图 2-4 。名称和价格指的是 af 字段
2.5.2 使用静态方法
如果我们愿意,我们可以将printPart
写成一个static
方法,这个方法将与p
一起被调用,作为一个参数,以便打印它的字段。在这种情况下,我们将编写以下内容:
public static void printPart(Part p) {
System.out.printf("\nName of part: %s\n", p.name);
System.out.printf("Price: $%3.2f\n", p.price);
}
字段名必须用对象变量p
限定。如果没有p
,我们会遇到静态方法引用非静态字段的情况,这是 Java 所禁止的。
如果c
是在用户类中创建的一个Part
对象,我们将不得不使用下面的代码来打印它的字段:
Part.printPart(c);
这比前面显示的使用实例方法稍微麻烦一些。相比之下,您可以使用Character.isDigit(ch)
来访问标准 Java 类Character
中的静态方法isDigit
。
2.5.3 使用 toString()方法
toString
方法返回一个String
并且在 Java 中是特殊的。如果我们在需要字符串的上下文中使用一个对象变量,那么 Java 将试图从该对象所属的类中调用toString
。例如,假设我们写下如下,其中p
是一个Part
变量:
System.out.printf("%s", p);
由于不清楚打印任意对象意味着什么,Java 将在类本身中寻找指导。据推测,这个类将知道如何打印它的对象。如果它提供了一个toString
方法,Java 就会使用它。(如果没有,Java 将打印一些通用的东西,比如类名和十六进制的对象地址,例如:Part@72e15c32
。)在我们的示例中,我们可以将以下内容添加到类Part
:
public String toString() {
return "\nName of part: " + name + "\nPrice: $" + price + "\n";
}
如果af
是空气过滤器部件,那么下面的语句将调用调用af.toString()
:
System.out.printf("%s", af);
实际上,printf
变成了这样:
System.out.printf("%s", af.toString());
af.toString()
将返回此:
"\nName of part: Air Filter \nPrice: $8.75\n"
结果是printf
将打印如下内容:
Name of part: Air Filter
Price: $8.75
2.6 类部分
将所有的更改放在一起,类Part
现在看起来像这样:
public class Part {
// class constants
private static final double MinPrice = 0.0;
private static final double MaxPrice = 99.99;
private static final double NullPrice = -1.0;
private static int NumParts = 0; // class variable
private String name; // instance variable
private double price; // instance variable
public Part(String n, double p) { // constructor
name = n;
if (p < MinPrice || p > MaxPrice) {
System.out.printf("Part: %s\n", name);
System.out.printf("Invalid price: %3.2f; Set to %3.2f\n", p, NullPrice);
price = NullPrice;
}
else price = p;
NumParts++;
} //end constructor Part
public static int GetNumParts() { // accessor
return NumParts;
}
public String getName() { // accessor
return name;
}
public double getPrice() { // accessor
return price;
}
public void setPrice(double p) { // mutator
if (p < MinPrice || p > MaxPrice) {
System.out.printf("Part: %s\n", name);
System.out.printf("Invalid price: %3.2f; Set to %3.2f\n", p, NullPrice);
price = NullPrice;
}
else price = p;
} //end setPrice
public void printPart() {
System.out.printf("\nName of part: %s\n", name);
System.out.printf("Price: $%3.2f\n", price);
}
public String toString() {
return "\nName of part: " + name + "\nPrice: $" + price + "\n";
}
} // end class Part
2.6.1 测试类别部分
当我们编写一个类时,我们必须测试它以确保它正常工作。对于类Part
,我们必须检查构造函数是否正常工作,换句话说,访问器方法是否返回正确的值,以及赋值器方法是否正确设置了(新的)价格。
我们还必须检查该类是否正确处理了无效价格。在程序 P2.2 中,我们创建三个零件对象(其中一个具有无效价格)并打印它们的名称/价格信息。然后,我们打印通过调用GetNumParts
创建的零件数量。在我们运行测试程序之前,我们应该计算出预期的输出,这样我们就可以预测一个正确的程序应该输出什么。如果输出符合我们的预测,很好;如果不是,就有一个必须解决的问题。
程序 P2.2
public class PartTest{
// a program for testing the class Part
public static void main(String[] args) {
Part a, b, c; // declare 3 Part variables
// create 3 Part objects
a = new Part("Air Filter", 8.75);
b = new Part("Ball Joint", 29.95);
c = new Part("Headlamp", 199.99); // invalid price
a.printPart(); // should print Air Filter, $8.75
b.printPart(); // should print Ball Joint, $29.95
c.printPart(); // should print Headlamp, $-1.0
c.setPrice(36.99);
c.printPart(); // should print Headlamp, $36.99
// print the number of parts; should print 3
System.out.printf("\nNumber of parts: %d\n", Part.GetNumParts());
} //end main
} // end class PartTest
当程序 P2.2 运行时,产生如下输出:
Part: Headlamp
Invalid price: 199.99; Set to -1.0
Name of part: Air Filter
Price: $8.75
Name of part: Ball Joint
Price: $29.95
Name of part: Headlamp
Price: $-1.0
Name of part: Headlamp
Price: $36.99
Number of parts: 3
这是预期的输出,所以我们确信这个类会正常工作。
这里是关于Part
类的最后一句话。如果出于某种奇怪的原因,类Part
没有提供printPart
或toString
方法,用户类可以编写自己的方法来打印部件的字段。然而,它必须使用Part
的访问器方法来获取对象的数据,因为它不能直接引用private
字段。下面显示了如何做到这一点:
public static void printPart(Part p) {
// a method in a user class
System.out.printf("\nName of part: %s\n", p.getName());
System.out.printf("Price: $%3.2f\n", p.getPrice());
}
从用户类,我们可以这样写:
Part af = new Part("Air Filter", 8.75);
printPart(af);
将打印以下内容:
Name of part: Air Filter
Price: $8.75
2.7 如何命名 Java 文件
如果我们的程序由一个单独的public
类组成,Java 要求我们将这样一个类存储在一个名为的文件中。所以如果类是Palindrome
,我们必须调用文件Palindrome.java
。
在Part
示例中,我们必须将Part
类存储在名为Part.java
的文件中,并将PartTest
类存储在名为PartTest.java
的文件中。我们可以用以下命令编译这些类:
javac Part.java
javac PartTest.java
然后,我们可以使用以下命令运行测试:
java PartTest
回想一下,这将从类PartTest
中执行main
。请注意,这样的尝试是没有意义的:
java Part
如果我们这样做,Java 会简单地抱怨在类Part
中没有main
方法。
如果我们愿意,我们可以将两个类放在一个文件中。但是,只能将其中一个类指定为public
。因此,例如,我们可以让类PartTest
保持原样,并简单地从public class Part
中删除单词public
。我们现在可以将这两个类放在一个文件中,其中的必须被命名为PartTest.java
,因为PartTest
是public
类。
当我们编译PartTest.java
时,Java 会产生两个文件——PartTest.class
和Part.class
。然后我们可以用这个来运行测试:
java PartTest
2.8 使用对象
到目前为止,我们已经看到了如何使用构造函数定义一个类并从该类创建对象。我们还看到了如何使用访问器方法从对象中检索数据,以及如何使用 mutator 方法更改对象中的数据。我们现在来看看在使用对象时出现的一些问题。
2.8.1 将一个对象变量分配给另一个
一个对象变量(p
,比方说)使用一个类名(Part
,比方说)来声明,如下所示:
Part p;
我们再次强调,p
不能保存一个对象,而是一个指向对象的指针(或引用)。p
的值是一个内存地址——存储一个Part
对象的位置。请考虑以下几点:
Part a = new Part("Air Filter", 8.75);
Part b = new Part("Ball Joint", 29.95);
假设空气过滤器对象存储在位置3472
,球窝接头对象存储在位置5768
。那么a
的值将是3472
,而b
的值将是5768
。创建两个对象后,我们会看到图 2-5 所示的情况。
图 2-5 。创建两个零件对象后
假设我们将a
赋值给c
,就像这样:
Part c = a; // assign 3472 to c
这将值3472
分配给c
;实际上,c
(以及a
)现在指向空气过滤器对象。我们可以使用任何一个变量来访问对象。例如,下面将空气过滤器对象的价格设置为9.50
:
c.setPrice(9.50);
我们有图 2-6 所示的情况。
图 2-6 。将 a 赋值给 c 后
如果我们现在用以下内容检索对象a
的价格,将返回空气过滤器的(新)价格:
a.getPrice(); // returns the price 9.50
假设我们写下这个语句:
c = b; // assign 5768 to c
c
被赋值5768
现在指向球关节对象。它不再指向空气过滤器。我们可以使用b
或c
来访问球节数据。如果我们有了一个对象的地址,我们就有了操作这个对象所需的所有信息。
2.8.2 失去对对象的访问
请考虑以下几点:
Part a = new Part("Air Filter", 8.75);
Part b = new Part("Ball Joint", 29.95);
假设这些陈述产生了图 2-7 中所示的情况。
图 2-7 。创建两个零件对象后
假设我们执行以下语句:
a = b;
情况变为图 2-8 中的所示。
图 2-8 。将 b 分配给 a 后
现在,a
和b
具有相同的值5768
。它们都指向球关节对象。实际上,当我们改变a
的值时,我们失去了对空气过滤器对象的访问。当没有变量指向某个对象时,该对象不可访问,也不能使用。对象占用的存储将被系统垃圾收集,并返回到可用存储池。这是自动发生的,不需要程序的任何动作。
然而,假设我们这样写:
c = a; // c holds 3472, address of "Air Filter"
a = b; // a, b hold 5768, address of "Ball Joint"
现在,我们仍然可以通过c
访问空气过滤器。
比较对象变量
考虑以下创建两个相同但独立的对象并将它们的地址存储在a
和b
中的情况:
Part a = new Part("Air Filter", 8.75);
Part b = new Part("Air Filter", 8.75);
假设这些陈述产生了图 2-9 中所示的情况。
图 2-9 。创建两个相同的对象后
由于对象是相同的,所以下面的条件是false
可能会令人惊讶:
a == b
但是,如果你记得a
和b
包含地址而不是对象,那么我们是在比较a
( 2000
)中的地址和b
( 4000
)中的地址。既然这些不一样,那就比较false
。
只有当两个对象变量包含相同的地址时,它们才会比较相等(在这种情况下,它们指向同一个对象)。例如,当我们将一个对象变量赋给另一个对象变量时,就会发生这种情况。
当然,我们需要知道两个物体是否相同。也就是说,如果a
和b
指向两个对象,那么这些对象的内容是否相同?为此,我们必须编写自己的方法来逐个比较字段。
以类Part
为例,我们编写了一个方法equals
,如果一个对象与另一个对象相同,则返回true
,否则返回false
。比较a
和b
指向的Part
对象的方法如下:
if (a.equals(b)) ...
该方法简单地检查两个对象的name
字段和price
字段是否相同。由于name
字段是String
对象,我们调用String
类的equals
方法来比较它们。 1
public boolean equals(Part p) {
return name.equals(p.name) && (price == p.price);
}
在该方法中,变量name
和price
(未被限定)指的是通过其调用该方法的对象的字段。假设我们使用了下面的表达式:
a.equals(b)
变量指的是字段a.name
和a.price
。当然,p.name
和p.price
指的是equals
(例子中的b
)的参数字段。实际上,return
语句变成了这样:
return a.name.equals(b.name) && (a.price == b.price);
现在,假设我们有这些陈述:
Part a = new Part("Air Filter", 8.75);
Part b = new Part("Air Filter", 8.75);
(a == b)
是false
(由于a
和b
持有不同的地址),而a.equals(b)
是true
(由于它们指向的对象内容相同)。
2.9 空指针
对象变量的声明如下例所示:
Part p;
最初,它是未定义的(就像原始类型的变量一样)。给p
赋值最常见的方法是创建一个Part
对象,并使用new
操作符将其地址存储在p
中,如下所示:
p = new Part("Air Filter", 8.75);
Java 还提供了一个特殊的指针值,用null
表示,可以赋给任何对象变量。我们可以编写下面的代码将null
赋给Part
变量p
:
Part p = null;
实际上,这意味着p
有一个定义好的值,但是它没有指向任何东西。如果p
的值为null
,则试图引用由p
指向的对象是错误的。换句话说,如果p
是null
,谈论p.name
或p.price
是没有意义的,因为p
没有指向任何东西。
如果两个对象变量p
和q
都是null
,我们可以拿它们和==
比较,结果会是true
。另一方面,如果p
指向某个对象,而q
是null
,那么,不出所料,比较的是false
。
当我们需要初始化一列对象变量时,空指针是很有用的。当我们创建链表或二叉树等数据结构时,我们也会用到它们,我们需要一个特殊的值来表示一个列表的结束。我们将在下一章看到如何使用空指针。
2.10 将对象作为参数传递
对象变量保存一个地址——一个实际对象的地址。当我们使用对象变量作为方法的参数时,传递给方法的是一个地址。因为 Java 中的参数是“按值”传递的,所以实际传递的是包含变量值的临时位置。在第 2.6.1 节中,我们遇到了用于打印零件的类Part
中的静态方法printPart
。这是:
public static void printPart(Part p) {
System.out.printf("\nName of part: %s\n", p.name);
System.out.printf("Price: $%3.2f\n", p.price);
}
此外,假设用户类包含以下语句:
Part af = new Part("Air Filter", 8.75);
printPart(af);
假设第一条语句将地址4000
分配给af
。当调用printPart
时,4000
被复制到一个临时位置,这个位置被传递给printPart
,在那里它被称为p
,即形参的名字。由于p
的值是4000
,实际上它可以访问原始对象。在本例中,该方法只是打印实例变量的值。但如果它愿意,也可以改变它们。
考虑类Part
中的以下方法,该方法将amount
添加到零件的价格中:
public static void changePrice(Part p, double amount) {
p.price += amount;
}
用户类可以通过以下调用将1.50
添加到空气过滤器的价格中:
Part.changePrice(af, 1.50);
如前所述,参数p
可以访问原始对象。对由p
指向的对象的任何改变实际上都是对原始对象的改变。
我们强调方法不能改变实参af
的值(因为它没有访问它的权限),但是它可以改变af
指向的对象。
顺便说一下,注意我们使用这个例子主要是为了说明的目的。实际上,编写一个实例方法来改变一个Part
对象的价格可能会更好。
2.11 对象的数组
在 Java 中,String
是一个对象。因此,String
的数组实际上是对象的数组。然而,String
在 Java 中是一种特殊的对象,在某些方面与其他对象有所不同。首先,一个String
是不可变的;我们不能改变它的价值。另一方面,我们认为String
只有一个字段——字符串中的字符——而一个典型的对象会有几个字段。出于这些原因,我们来看看除了String
之外的对象数组。
考虑之前定义的类Part
。该类包含两个实例变量,定义如下:
public class Part {
private String name; // instance variable
private double price; // instance variable
// methods and static variables
} //end class Part
回忆一下当我们如下声明一个Part
变量p
时会发生什么是很有帮助的:
Part p;
首先,记住p
可以保存一个Part
对象的地址,而不是一个对象本身。该声明只是为 p 分配了存储空间,但是没有定义。我们可以将null
值赋给p
,如下所示:
p = null;
我们还可以创建一个Part
对象,并使用以下语句将其地址分配给p
:
p = new Part("Air Filter", 8.75);
现在考虑下面的声明:
Part[] part = new Part[5];
这声明了一个名为part
的数组,该数组包含5
个元素。因为它们是对象变量,所以 Java 保证这些元素被设置为null
。到目前为止,还没有创建任何Part
对象。我们可以创建对象并将它们分别分配给part
的每个元素,如下所示:
part[0] = new Part("Air Filter", 8.75);
part[1] = new Part("Ball Joint", 29.95);
part[2] = new Part("Headlamp", 36.99);
part[3] = new Part("Spark Plug", 5.00);
part[4] = new Part("Disc Pads", 24.95);
数组part
现在可以如图图 2-10 所示。
图 2-10 。零件对象的数组
part
的每个元素都包含对应对象的地址。
请记住,一般来说,数组的每个元素都可以像数组类型的简单变量一样处理。例如,part[2]
可以用与上述p
相同的方式处理。而且就像我们可以写p.setPrice(40.00)
一样,我们可以写part[2].setPrice(40.00)
把头灯的价格改成40.00
。
我们如何引用一个Part
对象的字段?通常,这取决于代码是写在类Part
内部还是外部。如果在内部,代码可以直接访问实例变量name
和price
,例如part[2].name
。如果在外部,它必须使用 accessor 和 mutator 方法来获取和设置字段中的值,例如part[2].getName()
。
如果我们必须处理数百个零件,最好将零件的数据存储在一个文件中(parts.dat
),并使用一个for
或while
循环将它们读入数组。假设上面的数据像这样存储在文件中(我们将部件名写成一个单词,这样就可以用Scanner
类中的next
来读取它):
AirFilter 8.75
BallJoint 29.95
Headlamp 36.99
Spark Plug 5.00
DiscPads 24.95
我们可以用下面的代码设置part
数组:
Scanner in = new Scanner(new FileReader("parts.dat"));
Part[] part = new Part[5];
for (int h = 0; h < part.length; h++)
part[h] = new Part(in.next(), in.nextDouble());
这段代码更好,也更灵活。要读取 1000 个零件,我们只需要改变part
的声明,并提供文件中的数据。上面的代码保持不变。像往常一样,我们没有有用零件数据填充整个数组。我们可以读取数据,直到到达某个数据结束标记(End
)为止。
如果我们需要打印零件的数据,我们可以使用以下内容:
for (int h = 0; h < part.length; h++) part[h].printPart();
假设我们想要交换数组中的两个部分,例如,part[2]
和part[4]
。我们用同样的方法交换任何两个相同类型的变量的值,就像这样:
Part p = part[2];
part[2] = part[4];
part[4] = p;
值得注意的是,实际对象保留在它们最初存储的地方。我们在这里所做的只是交换存储在part[2]
和part[4]
中的地址。在图 2-10 中,认为箭头被互换了。
2.11.1 找到价格最低的零件
假设我们想找到价格最低的部分(从某种意义上说,我们想找到“最小”的对象)。假设我们在类Part
之外编写这个代码,我们可以编写getLowestPrice
来返回价格最低的部分的位置,如下所示:
public static int getLowestPrice(Part[] part, int lo, int hi) {
// return the position of the part with the lowest price
// from part[lo] to part[hi], inclusive
int small = lo;
for (int h = lo + 1; h <= hi; h++)
if (part[h].getPrice() < part[small].getPrice()) small = h;
return small;
} //end getLowestPrice
如果我们在类Part
内部编写,我们可以让方法保持原样。但是由于我们现在可以直接访问实例变量,我们可以用下面的语句替换if
语句:
if (part[h].price < part[small].price) small = h;
要打印价格最低的零件的名称,我们可以这样写:
System.out.printf("\nPart with lowest price: %s\n",
part[getLowestPrice(part, 0, part.length-1)].getName());
作为练习,编写一个函数来返回价格最高的商品。
2.12 搜索一组对象
我们假设您知道如何在基元类型数组或字符串数组中搜索一个项目。这里,我们考虑如何搜索具有多个字段的对象数组(更准确地说,是对对象的引用)。例如,假设我们有一个由以下内容(部分)定义的Person
类:
public class Person {
String name;
int age;
char gender;
// constructors, static fields and other methods
} //end class Person
我们希望在包含类型为Person
的对象的数组person
中搜索一个具有给定名称key
的对象。在搜索基本类型或字符串的情况下,搜索关键字的类型与数组中元素的类型相同。在搜索具有多个字段的对象数组的情况下,搜索关键字的类型与对象的字段中的相同。
我们的搜索方法必须将key
与正确的字段进行比较。在这个例子中,我们比较了key
和person[h].name
。下面的方法在数组Person
中搜索给定的名称。我们使用equalsIgnoreCase
,这样键和数组的大小写差异就无关紧要了;Mary
将与mary
相同。
// search for key in the first n elements of the array person;
// if found, return the position, else return -1
public static int sequentialSearch(String key, Person[] person, int n) {
for (int h = 0; h < n; h++)
if (key.equalsIgnoreCase(person[h].name)) return h;
return -1;
}
如果我们想搜索给定了age
的人,我们只需要将key
声明为int
,并将if
语句改为:
if (key == person[h].age) return h;
注意,这将返回它找到的第一个具有给定年龄的人。我们编写程序 P2.3 来测试这个函数。
程序 P2.3
import java.util.*;
public class SearchTest {
public static void main(String[] args) {
// set up an array with 7 persons
Person[] person = new Person[7];
person[0] = new Person("Gary", 25, 'M');
person[1] = new Person("Inga", 21, 'F');
person[2] = new Person("Abel", 30, 'M');
person[3] = new Person("Olga", 36, 'F');
person[4] = new Person("Nora", 19, 'F');
person[5] = new Person("Mary", 27, 'F');
person[6] = new Person("Bert", 32, 'M');
Scanner in = new Scanner(System.in);
String s;
System.out.printf("Enter names, one at a time, and I'll tell you\n");
System.out.printf("their age and gender. To end, press Enter\n\n");
while (!(s = in.nextLine()).equals("")) {
int n = sequentialSearch(s, person, person.length);
if (n >= 0)
System.out.printf("%d %c\n\n", person[n].age, person[n].gender);
else System.out.printf("Not found\n\n");
}
} // end main
// search for key in the first n elements of the array person ;
// if found, return the position, else return -1
public static int sequentialSearch(String key, Person[] person, int n) {
for (int h = 0; h < n; h++)
if (key.equalsIgnoreCase(person[h].name)) return h;
return -1;
} // end sequentialSearch
} // end class SearchTest
class Person {
String name;
int age;
char gender;
Person(String n, int a, char g) {
name = n;
age = a;
gender = g;
}
} //end class Person
main
方法建立了一个名为person
的数组,包含七个人的数据。然后,它要求用户输入姓名。对于每一个名字,sequentialSearch
被称为;比方说,它返回一个值n
。如果找到(n >= 0
,则打印出该人的年龄和性别。如果不是,则打印消息Not found
。以下是运行示例:
Enter names, one at a time, and I'll tell you
their age and gender. To end, press Enter
Olga
36 F
Bart
Not found
bert
32 M
INGA
21 F
注意我们是如何定义类Person
的。我们省略了单词public
,这样我们可以把它和SearchTest
放在同一个文件中。为了多样化,我们在字段名— name
、age
、gender
上不使用访问修饰符(public
或private
)。当我们这样做时,同一个文件中的其他类可以直接引用字段名;比如在main
中,我们指的是person[n].age
和person[n].gender
。
我们还可以对一组对象使用二分搜索法,前提是这些对象是根据我们想要搜索的字段进行排序的。例如,如果对象按名称顺序排列,我们可以在person
数组中查找名称。下面是函数:
// search for a person with name key in the first n elements of the
// array person ; if found, return the position, else return -1
public static int binarySearch(String key, Person[] person, int n) {
int lo = 0, hi = n - 1;
while (lo <= hi) { // as long as more elements remain to consider
int mid = (lo + hi) / 2;
int cmp = key.compareToIgnoreCase(person[mid].name);
if (cmp == 0) return mid; // search succeeds
if (cmp < 0) hi = mid - 1; // key is ‘less than’ person[mid].name
else lo = mid + 1; // key is ‘greater than’ person[mid].name
}
return -1; // key is not in the array
} // end binarySearch
作为练习,编写一个类似于程序 P2.3 的程序来测试binarySearch
。
2.13 排序一个对象数组
我们假设您知道如何使用选择和插入排序对字符串或基本类型的数组进行排序。下面显示了如何使用选择排序按升序通过name
对对象数组Person
进行排序:
public static void selectionSort(Person[] list, int lo, int hi) {
// sort list[lo] to list[hi] using selection sort
for (int h = lo; h <= hi; h++)
swap(list, h, getSmallest(list, h, hi));
} //end selectionSort
public static int getSmallest(Person[] list, int lo, int hi) {
// return the position of the ‘smallest’ name from list[lo] to list[hi]
int small = lo;
for (int h = lo + 1; h <= hi; h++)
if (list[h].name.compareToIgnoreCase(list[small].name) < 0) small = h;
return small;
} //end getSmallest
public static void swap(Person[] list, int h, int k) {
// swaps list[h] with list[k]
Person hold = list[h];
list[h] = list[k];
list[k] = hold;
} //end swap
在getSmallest
中,我们将一个数组元素的name
字段与另一个数组元素的name
字段进行比较。
我们可以通过下面的调用对来自程序 P2.1 的数组person
进行排序:
selectionSort(person, 0, person.length - 1);
然后我们可以打印数组person
,如下所示:
for (int h = 0; h < person.length; h++) person[h].printPerson();
其中printPerson
在类别Person
中定义如下:
void printPerson() {
System.out.printf("%s %d %c\n", name, age, gender);
}
对于程序 P2.3 中的数组,这将打印以下输出:
Abel 30 M
Bert 32 M
Gary 25 M
Inga 21 F
Mary 27 F
Nora 19 F
Olga 36 F
我们还可以使用插入排序对一组Person
对象进行排序,如下所示:
public static void insertionSort(Person[] list, int lo, int hi) {
//sort list[lo] to list[hi] in ascending order by name
for (int h = lo + 1; h <= hi; h++) {
Person hold = list[h];
int k = h - 1; //start comparing with previous item
while (k >= 0 && hold.name.compareToIgnoreCase(list[k].name) < 0) {
list[k + 1] = list[k];
--k;
}
list[k + 1] = hold;
} //end for
} //end insertionSort
我们可以用下面的调用按名称对来自程序 P2.3 的数组person
进行排序:
insertionSort(person, 0, person.length - 1);
在while
条件下,我们将被处理的人的name
字段(在h
位置的人)与数组元素的name
字段进行比较。
2.14 使用类对数据进行分组:词频计数
在 1.8 节中,我们编写了一个程序(程序 P1.7 )来统计一篇文章中单词的频率。在这里,我们使用一个String
数组(wordlist
)来保存单词,使用一个int
数组(frequency
)来保存频率。代码是这样写的,frequency[i]
保存了wordlist[i]
中单词的计数。我们现在展示如何通过使用类以稍微不同的方式解决相同的问题。
我们可以把文章中的每个单词想象成一个具有两种属性的物体——单词中的字母和它出现的次数。我们将定义一个类WordInfo
,从中我们将创建“word 对象”
class WordInfo {
String word;
int freq = 0;
WordInfo(String w, int f) {
word = w;
freq = f;
}
void incrFreq() {
freq++;
}
} //end class WordInfo
该类有两个字段:word
和freq
。它有一个构造函数将一个WordInfo
对象初始化为给定的单词和频率。它还有一个方法,就是给一个词的频率加 1。假设wo
是用这条语句创建的WordInfo
对象:
WordInfo wo = new WordInfo(aWord, 1); //String aWord
wo.word
指词,wo.freq
是其频率。我们可以用wo.incrFreq()
给它的频率加 1。
接下来,我们将定义一个WordInfo
数组;每个元素将保存关于一个单词的信息。
WordInfo[] wordTable = new WordInfo[MaxWords + 1];
MaxWords
表示满足的不同单词的最大数量。为了测试程序,我们使用了50
作为这个值。如果文章中不同单词的数量超过了MaxWords
(比如说 50),那么第 50 个之后的所有单词都将被读取,但不会被存储,并且会打印一条大意如此的消息。然而,如果再次遇到,已经存储的单词的计数将增加。
这些想法的实现如程序 P2.4 所示。
程序 P2.4
import java.io.*;
import java.util.*;
public class P2_4WordFrequency {
final static int MaxWords = 50;
public static void main(String[] args) throws IOException {
WordInfo[] wordTable = new WordInfo[MaxWords];
FileReader in = new FileReader("passage.txt");
PrintWriter out = new PrintWriter(new FileWriter("output.txt"));
for (int h = 0; h < MaxWords; h++) wordTable[h] = new WordInfo("", 0);
int numWords = 0;
String word = getWord(in).toLowerCase();
while (!word.equals("")) {
int loc = binarySearch(word, wordTable, 0, numWords-1);
if (word.compareTo(wordTable[loc].word) == 0) wordTable[loc].incrFreq();
else //this is a new word
if (numWords < MaxWords) { //if table is not full
addToList(word, wordTable, loc, numWords-1);
++numWords;
}
else out.printf("'%s' not added to table\n", word);
word = getWord(in).toLowerCase();
}
printResults(out, wordTable, numWords);
in.close();
out.close();
} //end main
public static int binarySearch(String key, WordInfo[] list, int lo, int hi) {
//search for key from list[lo] to list[hi]
//if found, return its location;
//if not found, return the location in which it should be inserted
//the calling program will check the location to determine if found
while (lo <= hi) {
int mid = (lo + hi) / 2;
int cmp = key.compareTo(list[mid].word);
if (cmp == 0) return mid; // search succeeds
if (cmp < 0) hi = mid -1; // key is 'less than' list[mid].word
else lo = mid + 1; // key is 'greater than' list[mid].word
}
return lo; //key must be inserted in location lo
} //end binarySearch
public static void addToList(String item, WordInfo[] list, int p, int n) {
//sets list[p].word to item; sets list[p].freq to 1
//shifts list[n] down to list[p] to the right
for (int h = n; h >= p; h--) list[h + 1] = list[h];
list[p] = new WordInfo(item, 1);
} //end addToList
public static void printResults(PrintWriter out, WordInfo[] list, int n) {
out.printf("\nWords Frequency\n\n");
for (int h = 0; h < n; h++)
out.printf("%-20s %2d\n", list[h].word, list[h].freq);
} //end printResults
public static String getWord(FileReader in) throws IOException {
//returns the next word found
final int MaxLen = 255;
int c, n = 0;
char[] word = new char[MaxLen];
// read over non-letters
while (!Character.isLetter((char) (c = in.read())) && (c != -1)) ;
//empty while body
if (c == -1) return ""; //no letter found
word[n++] = (char) c;
while (Character.isLetter(c = in.read()))
if (n < MaxLen) word[n++] = (char) c;
return new String(word, 0, n);
} //end getWord
} //end class P2_4WordFrequency
class WordInfo {
String word;
int freq = 0;
WordInfo(String w, int f) {
word = w;
freq = f;
}
void incrFreq() {
freq++;
}
} //end class WordInfo
假设文件passage.txt
包含以下数据:
Strive not to be a success, but rather to be of value.
Whatever the mind can conceive and believe, it can achieve.
There is only one way to avoid criticism: do nothing, say nothing,
and be nothing.
运行时,程序 P2.4 将其输出存储在文件output.txt
中。以下是输出:
Words Frequency
a 1
achieve 1
and 2
avoid 1
be 3
believe 1
but 1
can 2
conceive 1
criticism 1
do 1
is 1
it 1
mind 1
not 1
nothing 3
of 1
one 1
only 1
rather 1
say 1
strive 1
success 1
the 1
there 1
to 3
value 1
way 1
whatever 1
2.15 如何返回多个值:投票
这个例子将用来说明关于类和对象的使用的几个问题。我们将再次使用一个类来分组数据,我们将展示一个函数如何使用一个对象返回多个值。
- 问题:一次选举,有七个候选人。每个选民都被允许为他们选择的候选人投一票。投票记录为从
1
到7
的数字。投票人的数量事先不得而知,但投票会因0
的投票而终止。任何不是从1
到7
的数字的投票都是无效(作废)票。 - 一个文件,
votes.txt
,包含了候选人的名字。第一个名字被视为候选人 1,第二个被视为候选人 2,依此类推。名字后面是投票。写一个程序来读取数据并评估选举的结果。将所有输出打印到文件results.txt
。 - 您的输出应该指定总投票数、有效投票数和无效投票数。接下来是每位候选人和选举获胜者获得的票数。
给定votes.txt
中的数据:
Nirvan Singh
Denise Duncan
Avasa Tawari
Torrique Granger
Saskia Kalicharan
Dawren Greenidge
Jordon Cato
3 1 6 5 4 3 5 3 5 3 2 8 1 6 7 7 3 5
6 9 3 4 7 1 2 4 5 5 1 4 0
您的程序应该向results.txt
发送以下输出:
Invalid vote: 8
Invalid vote: 9
Number of voters: 30
Number of valid votes: 28
Number of spoilt votes: 2
Candidate Score
Nirvan Singh 4
Denise Duncan 2
Avasa Tawari 6
Torrique Granger 4
Saskia Kalicharan 6
Dawren Greenidge 3
Jordon Cato 3
The winner(s)
Avasa Tawari
Saskia Kalicharan
我们将使用以下大纲来解决这个问题:
get the names and set the scores to 0
process the votes
print the results
我们需要存储七个候选人的名字和每个人得到的分数。我们可以使用一个String
数组来表示名字,使用一个int
数组来表示分数。但是如果我们需要存储候选人更多的属性呢?对于每个属性,我们需要添加另一个适当类型的数组。为了适应这种可能性并使我们的程序更加灵活,我们将创建一个类Person
并使用一个数组Person
。
这个Person
类会是什么样子?对于我们的问题,它将有两个实例字段,name
和numVotes
。我们将其定义如下:
class Person {
String name;
int numVotes;
Person(String s, int n) {
name = s;
numVotes = n;
}
} //end class Person
为了迎合七个候选对象,我们将符号常数MaxCandidates
设置为7
,并将Person
数组candidate
声明如下:
Person[] candidate = new Person[MaxCandidates+1];
我们将使用candidate[h]
来存储候选人h
、h
= 1
、7
的信息;我们不会用candidate[0]
。这将使我们能够比使用candidate[0]
更自然地处理投票。例如,如果有候选人 4 的投票,我们希望增加candidate[4]
的投票数。如果我们使用candidate[0]
存储第一个候选人的信息,我们将不得不增加candidate[3]
的计数,假设投票数为 4。这可能会误导和令人不安。
假设in
声明如下:
Scanner in = new Scanner(new FileReader("votes.txt"));
我们将读取姓名,并用以下代码将分数设置为 0:
for (int h = 1; h <= MaxCandidates; h++)
candidate[h] = new Person(in.nextLine(), 0);
当执行这段代码时,我们可以描绘出如图图 2-11 所示的candidate
。记住,我们没有使用candidate[0]
。
图 2-11 。读取名称并将分数设置为 0 后的候选数组
接下来,我们必须处理投票。我们将把它委托给函数processVotes
。这将读取每张选票,并为合适的候选人增加 1 分。因此,如果投票数为 5,则候选人 5 的得分必须加 1。
该函数的另一个任务是统计有效和无效投票的数量,并将这些值返回给main
。但是一个函数如何返回多个值呢?嗯,它可以返回一个值——一个对象——这个对象可以包含许多字段。
在这个例子中,我们可以声明一个带有两个字段和一个构造函数的类(VoteCount
),如下所示:
class VoteCount {
int valid, spoilt;
VoteCount(int v, int s) {
valid = v;
spoilt = s;
}
}
下面的语句将创建一个名为votes
的对象,并将votes.valid
和votes.spoilt
设置为0
。
VoteCount votes = new VoteCount(0, 0);
我们也可以省去构造函数,用下面的语句创建对象:
VoteCount votes = new VoteCount();
这个将将字段初始化为0
,但是最好显式地这样做,就像这样:
votes.valid = votes.spoilt = 0;
当我们读到一个有效的投票时,++votes.valid
给votes.valid
加 1,当我们读到一个无效的投票时,++votes.spoilt
给votes.spoilt
加 1。最后,该函数将返回votes
——一个包含两个计数的对象。
最后,我们必须编写printResults
,它以前面指定的格式打印结果。首先,我们打印有效和无效投票的总票数。然后,使用一个for
循环,我们打印个人得分。
接下来,它通过调用getLargest
找到其numVotes
字段最大的候选项来确定获胜分数。这是通过以下语句实现的:
int win = getLargest(list, 1, MaxCandidates);
int winningVote = list[win].numVotes;
这里,list
是Person
数组。使用winningVote
,它再次遍历数组,寻找具有这个分数的候选者。这确保了如果获胜者有平局,所有的都将被打印。程序 P2.5 是解决这个投票问题的完整解决方案。
程序 P2.5
import java.util.*;
import java.io.*;
public class Voting {
final static int MaxCandidates = 7;
public static void main(String[] args) throws IOException {
Scanner in = new Scanner(new FileReader("votes.txt"));
PrintWriter out = new PrintWriter(new FileWriter("results.txt"));
Person[] candidate = new Person[MaxCandidates+1];
//get the names and set the scores to 0
for (int h = 1; h <= MaxCandidates; h++)
candidate[h] = new Person(in.nextLine(), 0);
VoteCount count = processVotes(candidate, MaxCandidates, in, out);
printResults(out, candidate, MaxCandidates, count);
in.close();
out.close();
} //end main
public static VoteCount processVotes(Person[] list, int max, Scanner in, PrintWriter out) {
VoteCount votes = new VoteCount(0, 0); //set valid, spoilt counts to 0
int v = in.nextInt();
while (v != 0) {
if (v < 1 || v > max) {
out.printf("Invalid vote: %d\n", v);
++votes.spoilt;
}
else {
++list[v].numVotes;
++votes.valid;
}
v = in.nextInt();
} //end while
return votes;
} //end processVotes
public static void printResults(PrintWriter out, Person[] list, int max, VoteCount votes) {
out.printf("\nNumber of voters: %d\n", votes.valid + votes.spoilt);
out.printf("Number of valid votes: %d\n", votes.valid);
out.printf("Number of spoilt votes: %d\n", votes.spoilt);
out.printf("\nCandidate Score\n\n");
for (int h = 1; h <= MaxCandidates; h++)
out.printf("%-18s %3d\n", list[h].name, list[h].numVotes);
out.printf("\nThe winner(s)\n");
int win = getLargest(list, 1, MaxCandidates);
int winningVote = list[win].numVotes;
for (int h = 1; h <= MaxCandidates; h++)
if (list[h].numVotes == winningVote) out.printf("%s\n", list[h].name);
} //end printResults
public static int getLargest(Person[] list, int lo, int hi) {
int big = lo;
for (int h = lo + 1; h <= hi; h++)
if (list[h].numVotes > list[big].numVotes) big = h;
return big;
} //end getLargest
} //end class Voting
class Person {
String name;
int numVotes;
Person(String s, int n) {
name = s;
numVotes = n;
}
} //end class Person
class VoteCount {
int valid, spoilt;
VoteCount(int v, int s) {
valid = v;
spoilt = s;
}
} //end class VoteCount
如果我们想按字母顺序打印结果,我们可以用下面的语句调用selectionSort
(第 2.13 节):
selectionSort(candidate, 1, MaxCandidates);
我们可以通过像这样调用insertionSort
(第 2.13 节)来获得相同的结果:
insertionSort(candidate, 1, MaxCandidates);
但是假设我们想按收到的票数从到的顺序打印候选人的名字,也就是说,首先打印获胜的候选人。为此,对象数组candidate
必须使用numVotes
字段控制排序,以降序排序。这可以通过下面的调用来完成,其中sortByVote
使用插入排序(任何排序都可以),并使用形参list
来编写:
sortByVote(candidate, 1, MaxCandidates);
我们可以这样写sortByVote
:
public static void sortByVote(Person[] list, int lo, int hi) {
//sort list[lo] to list[hi] in descending order by numVotes
for (int h = lo + 1; h <= hi; h++) {
Person hold = list[h];
int k = h - 1; //start comparing with previous item
while (k >= lo && hold.numVotes > list[k].numVotes) {
list[k + 1] = list[k];
--k;
}
list[k + 1] = hold;
} //end for
} //end sortByVote
假设我们将sortByVote
添加到程序 P2.5 中,我们插入以下语句:
sortByVote(candidate, 1, MaxCandidates);
就在这个之前:
printResults(out, candidate, MaxCandidates, count);
如果我们使用与之前相同的数据运行程序,它将产生以下输出。首先打印候选人的最高分,最后打印最低分。
Invalid vote: 8
Invalid vote: 9
Number of voters: 30
Number of valid votes: 28
Number of spoilt votes: 2
Candidate Score
Avasa Tawari 6
Saskia Kalicharan 6
Nirvan Singh 4
Torrique Granger 4
Dawren Greenidge 3
Jordon Cato 3
Denise Duncan 2
The winner(s)
Avasa Tawari
Saskia Kalicharan
练习 2
- 对象的状态是什么意思?什么决定了一个物体的状态?
- 区分类和对象。
- 区分类变量和实例变量。
- 区分类方法和实例方法。
- 区分公共变量和私有变量。
- 解释执行语句
String S = new String("Hi")
时会发生什么。 - 创建对象时,实例字段初始化为什么值?
- 什么是无参数构造函数?它是如何提供给一个班级的?
- 你已经为一个类写了一个构造函数。使用无参数构造函数需要做什么?
- 术语数据封装是什么意思?
- "对象变量不包含对象."解释一下。
- 解释 Java 中
toString()
方法的作用。 - 写一个程序将名字和电话号码读入一个对象数组。询问对方的姓名,并打印出他的电话号码。
- 写一个程序,把英语单词和它们对应的西班牙语单词读入一个对象数组。要求用户键入几个英语单词。对于每一项,打印相应的西班牙语单词。选择合适的数据结束标记。使用二分搜索法搜索键入的单词。
- 日期由日、月和年组成。编写一个类来创建日期对象和操作日期。例如,编写一个函数,给定两个日期
d1
和d2
,如果d1
在d2
之前,则返回-1
,如果d1
与d2
相同,则返回0
,如果d1
在d2
之后,则返回1
。此外,编写一个返回d2
比d1
早多少天的函数。如果d2
在d1
之前,返回一个负值。并编写一个方法以您选择的格式打印日期。 - 24 小时制格式的时间由两个数字表示;例如,16 45 表示时间 16:45,即下午 4:45。使用一个对象表示时间,编写一个函数,给定两个时间对象
t1
和t2
,返回从t1
到t2
的分钟数。例如,如果两个给定时间是16 45
和23 25
,你的函数应该返回400
。 - 考虑使用分数的问题,其中分数由两个整数值表示,一个表示分子,另一个表示分母。例如,5/9 由两个数字 5 和 9 表示。写一个处理分数的类。例如,编写加减乘除分数的方法。此外,写一个方法,以减少一个分数,以其最低的条款;你需要找到两个整数的 HCF。
- 书商需要存储关于书籍的信息。对于每本书,他都希望存储作者、书名、价格和库存数量。他还需要随时知道已经创建了多少图书对象。根据以下内容为类
Book
编写 Java 代码:
* 编写一个无参数构造函数,将作者设置为无作者,将标题设置为无标题,将价格和库存数量设置为 0。
* 编写一个构造函数,给定四个参数——作者、标题、价格和数量——用给定的值创建一个Book
对象。价格必须至少为 5 美元,数量不能为负。如果违反这些条件中的任何一个,价格和数量都被设置为 0。
* 为 author 和 price 字段编写访问器方法。
* 写一个方法,将一本书的价格设置为一个给定值。如果给定的价格不是至少 5 美元,价格应该保持不变。
* 编写一个方法,将库存数量减少给定的数量。如果这样做会使数量为负,则应打印一条消息,数量保持不变。
* 编写一个实例方法,打印书籍的数据,每行一个字段。
* 编写一个toString()
方法,返回一个字符串,如果打印,将打印一本书的数据,每行一个字段。
* 编写一个equals
方法,如果两个Book
对象的内容相同,则返回 true,否则返回 false。
* 编写一个Test
类,创建您选择的三个Book
对象,打印它们的数据,并打印所创建的Book
对象的数量。 - A multiple-choice examination consists of 20 questions. Each question has five choices, labeled
A
,B
,C
,D
, andE
. The first line of data contains the correct answers to the 20 questions in the first 20 consecutive character positions. Here’s an example:
BECDCBAADEBACBAEDDBE
随后的每一行都包含候选人的答案。一行中的数据由一个候选数字(整数)组成,后面是一个或多个空格,再后面是候选人在接下来的 20 个*连续*字符位置给出的 20 个答案。如果候选人没有回答某个特定的问题,则使用`X`。您可以假设所有数据都是有效的,并存储在一个名为`exam.dat`的文件中。示例行如下:
`4325 BECDCBAXDEBACCAEDXBE`
最多有 100 名候选人。包含“候选编号”`0`的行仅表示数据的结束。
一个问题的积分奖励如下:正确答案:4 分;回答错误:-1 分;没有回答:0 分。
编写一个程序来处理数据,并打印一份报告,该报告由候选人编号和候选人获得的总分数组成,按候选人编号升序排列。(这个问题也在第一章中,但是这次你将使用对象来解决它)。
- 一个数据文件包含六个课程的注册信息—CS20A、CS21A、CS29A、CS30A、CS35A 和 CS36A。每一行数据都由一个七位数的学生注册号后跟六个(有序)值组成,每个值都是
0
或1
。值1
表示学生注册了相应的课程;0
表示学生不是。因此,1 0 1 0 1 1
意味着学生注册了 CS20A、CS29A、CS35A 和 CS36A,但没有注册 CS21A 和 CS30A。你可以假设学生不超过 100 人,一个注册号0
结束数据。编写一个程序来读取数据,并为每门课程生成一个课程表。每个列表都从一个新的页面开始,由参加该课程的学生的注册号组成。
1 使用相同的名称equals
来比较Part
s 和String
s 没有冲突,如果equals
是通过Part
对象调用的,那么就使用Part
类中的equals
方法。如果通过一个String
对象调用equals
,那么就使用来自String
类的equals
方法。
三、集合框架
在本章中,我们将解释以下内容:
- 链表的概念
- 如何编写使用链表的声明
- 如何计算链表中的节点数
- 如何在链表中搜索项目
- 如何找到链表中的最后一个节点
- 静态存储和动态存储分配的区别
- 如何通过在列表末尾添加一个新项目来构建一个链表
- 如何在链表中插入节点
- 如何通过在列表的头部添加一个新项目来构建一个链表
- 如何从链表中删除项目
- 如何通过添加一个新项来构建一个链表,使得链表总是排序的
- 如何组织你的 Java 文件
- 如何使用链表来确定一个短语是否是回文
- 如何保存一个链表
- 使用链表和数组存储项目列表的区别
- 如何用数组表示一个链表
- 如何合并两个排序的链表
- 循环链表和双向链表的概念
3.1 定义链表
当值存储在一维数组中时(比如说, x [0]到 x [n]),它们可以被认为是一个“线性列表”将数组中的每一项视为一个节点。线性列表意味着节点以线性顺序排列,从而满足以下条件:
*x*[1] is the first node
*x*[n] is the last node
if 1 <*k*<= n, then*x*[*k*] is preceded by*x*[*k*- 1]
if 1 <=*k*< n then*x*[*k*] is followed by*x*[*k*+ 1]
因此,给定一个节点,假定“下一个”节点在数组中的下一个位置,如果有的话。节点的顺序是它们在数组中出现的顺序,从第一个开始。考虑在两个现有节点之间插入一个新节点的问题, x [ k 和 x [ k + 1】。
只有当 x [ k + 1]及其后的节点被移动以给新节点腾出空间时,才能做到这一点。同样, x k 的删除也涉及到节点 x [ k +1】、 x [ k + 2】的移动等等。访问任何给定的节点都很容易;我们所要做的就是提供适当的下标。
在许多情况下,我们使用数组来表示一个线性列表。但是我们也可以通过使用一种组织来表示这样的列表,其中列表中的每个节点都明确地指向下一个节点。这个新的组织被称为链表。
在(单一)链表中,每个节点都包含一个指向列表中下一个节点的指针。我们可以把每个节点想象成一个由两部分组成的单元,就像这样:
![9781430266198_unFig03-01.jpgdata
项实际上可以是一个或多个字段(取决于节点中需要存储什么),而next
则“指向”列表的下一个节点。(你可以用任何你想要的名字,而不是data
和next
。)由于最后一个节点的next
字段没有指向任何东西,我们必须将其设置为一个特殊值,称为空指针。在 Java 中,空指针值由null
表示。除了列表的单元格之外,我们还需要一个对象变量(top
),它“指向”列表中的第一项。如果列表为空,则top
的值为null
。形象地说,我们表示一个链表,如图 3-1 所示。
图 3-1 。一个链表
电气接地符号用于表示零指针。
遍历链表就像寻宝一样。你被告知第一件物品在哪里。这就是top
所做的。当您到达第一个项目时,它会指引您到达第二个项目所在的位置(这就是next
的目的)。当你到达第二个项目时,它会告诉你第三个项目在哪里(通过next
,以此类推。当您到达最后一项时,它的空指针告诉您这是搜索的结束(列表的结尾)。
如何在 Java 程序中表示一个链表?由于每个节点至少包含两个字段,我们将需要使用一个class
来定义节点的格式。data
组件可以由一个或多个字段组成(每个字段本身可以是一个有许多字段的对象)。这些字段的类型将取决于需要存储哪种数据。
但是next
字段的类型是什么呢?我们知道这是一个指针,但是指向什么的指针?这是一个指向一个对象的指针,就像正在被定义的对象一样!这通常被称为自引用结构。举个例子,假设每个节点的数据都是正整数。我们可以如下定义从中创建节点的类(使用num
而不是data
):
class Node {
int num;
Node next;
}
变量top
现在可以声明为一个Node
变量,如下所示:
Node top;
如前所述,top
的声明为top
分配存储,但不为任何节点分配存储。top
的值可以是一个Node
对象的地址,但是,到目前为止,列表中没有节点。众所周知,我们可以用下面的语句创建一个Node
对象并将它的地址分配给top
:
top = new Node();
这将创建以下内容:
回想一下,当创建一个对象时,除非另外指定,否则 Java 会将一个数值字段设置为0
,将一个对象字段设置为null
。
稍后我们将看到如何创建链表,但是首先我们来看一些可以在链表上执行的基本操作。
3.2 对链表的基本操作
为了便于说明,我们假设我们有一个整数链表。我们暂时忽略如何建立这个列表。
3.2.1 计数链表中的节点
也许最简单的操作是计算列表中节点的数量。举例来说,我们编写一个函数,给定一个指向链表的指针,返回链表中节点的数量。
在编写函数之前,让我们看看如何从第一个项目开始遍历列表中的项目。假设top
指向列表的头部。考虑以下代码:
Node curr = top;
while (curr != null) curr = curr.next;
最初,curr
指向列表中的第一项,如果有的话。如果不是null
,则执行以下语句:
curr = curr.next;
这将设置curr
指向“当前节点所指向的任何东西”,实际上是下一个节点。例如,考虑以下列表:
- 最初,
curr
指向(包含)36
的节点。由于curr
不是null
,所以设置为36
指向什么就指向什么,也就是(包含)15
的节点。 - 再次测试
while
条件。由于curr
不是null
,执行curr = curr.next
,设置curr
指向15
指向的任何东西,即52
。 - 再次测试
while
条件。由于curr
不是null
,执行curr = curr.next
,设置curr
指向52
指向的任何东西,即23
。 - 再次测试
while
条件。由于curr
不是null
,执行curr = curr.next
,设置curr
指向23
指向的任何东西,即null
。 - 再次测试
while
条件。由于curr
是null
,不再执行while
循环。
注意,每次curr
不是null
,我们就进入while
循环。但是curr
是不是 null
的次数与列表中的项目数完全相同。所以,为了计算列表中的项目数,我们只需要计算while
体被执行了多少次。
为此,我们使用一个初始化为0
的计数器,并在while
循环中用1
递增它。我们现在可以将函数编写如下(我们称之为length
):
public static int length(Node top) {
int n = 0;
Node curr = top;
while (curr != null) {
n++;
curr = curr.next;
}
return n;
}
注意,如果列表为空,curr
第一次为null
,不会执行while
循环。该函数将返回正确的结果0
。
严格来说,变量curr
不是必须的。如果我们在函数中省略curr
并用top
代替curr
,该函数将正常工作。在功能执行结束时,top
将变为null
。
您可能会担心自己无法访问该列表,但不必担心。记住length
中的top
是调用函数中指向列表的任何变量(比如说head
)的副本。改变top
对head
没有任何影响。当length
返回时,head
仍然指向列表中的第一项。
3.2.2 查找链表
另一个常见的操作是在链表中搜索给定的项目。例如,给定下面的列表,我们可能想要搜索数字52
:
我们的搜索应该能够告诉我们52
在列表中。另一方面,如果我们搜索25
,我们的搜索应该报告25
不在列表中。
假设我们要搜索的数字存储在变量key
中。从第一个数字开始,通过比较key
和列表中的每个数字进行搜索。如果key
符合任何项目,我们已经找到了。如果我们到达列表的末尾并且key
不匹配任何项目,我们可以断定key
不在列表中。
我们必须编写这样的逻辑,如果我们找到一个匹配的或,搜索就结束,我们到达列表的末尾。换句话说,如果我们没有到达列表的末尾并且我们没有匹配,则搜索继续。如果curr
指向列表中的某个项目,我们可以将这个逻辑表达如下:
while (curr != null && key != curr.num) curr = curr.next;
Java 保证从左到右对&&
的操作数求值,一旦知道表达式的真值,求值就停止,在这种情况下,只要一个操作数求值为false
或整个表达式求值完毕。我们利用这一点,首先编写条件curr != null
。如果curr
为 null
,则&&
立即为false
,不计算第二个条件key != curr.num
。
如果我们写了以下代码,而curr
恰好是null
,当我们的程序试图检索curr.num
时,它将崩溃:
while (key != curr.num && curr != null) curr = curr.next; //wrong
实际上,这要求由curr
指向的数字,但是如果curr
是null
,它不指向任何东西。我们说我们正试图“解引用一个null
指针”,这是一个错误。
让我们把搜索写成一个函数,给定一个指向列表和key
的指针,如果找到包含key
的节点,就返回这个节点。如果没有找到,函数返回null
。
我们假设来自上一节的Node
声明。我们的函数将返回一个类型为Node
的值。这是:
public static Node search(Node top, int key) {
while (top != null && key != top.num)
top = top.next;
return top;
}
如果key
不在列表中,top
将变成null
,返回null
。如果key
在列表中,当key
等于top.num
时,退出while
循环;在这个阶段,top
指向包含key
的节点,返回top
的这个值。
3.2.3 找到链表中的最后一个节点
有时,我们需要找到指向列表中最后一个节点的指针。回想一下,列表中的最后一个节点由来区分,其 next
指针为null
。下面是一个函数,它返回一个指向给定列表中最后一个节点的指针。如果列表为空,函数返回null
。
public static Node getLast(Node top) {
if (top == null) return null;
while (top.next != null)
top = top.next;
return top;
}
如果top
不是null
,我们得到while
语句。因此,询问top.next
是有意义的。如果该不是null
,则进入循环,并且top
被设置为该非null
值。这确保了下一次执行时定义了while
条件。当top.next
为null
时,top
指向最后一个节点,返回top
的这个值。
3.3 建立链表:在尾部增加一个新项目
考虑按照正整数给出的顺序构建正整数链表的问题。假设输入的数字如下(0
终止数据):
36 15 52 23 0
我们想要建立下面的链表:
出现的一个问题是,列表中有多少个节点?这当然取决于提供了多少个号码。使用数组存储线性列表的一个缺点是数组的大小必须事先指定。如果当程序运行时,发现它需要存储的项目超过了这个大小所允许的,它可能必须被中止。
使用链表方法,每当必须向列表中添加新节点时,都会为该节点分配存储空间,并设置适当的指针。因此,我们为列表分配了恰到好处的存储空间——不多也不少。
我们确实为指针使用了额外的存储空间,但是通过更有效地使用存储空间以及方便的插入和删除,这种情况得到了很大的补偿。“按需”分配存储通常称为动态存储分配 。(另一方面,数组存储被称为静态存储。)
在我们前面描述的构建列表的解决方案中,我们从一个空列表开始。我们的程序将通过以下声明反映这一点:
top = null;
当我们读取一个新的数字时,我们必须做到以下几点:
- 为节点分配存储
- 将数字放入新节点
- 使新节点成为列表中的最后一个节点
使用 3.1 节中的Node
类,让我们写一个构造函数,给定一个整数参数,将num
设置为整数,将next
设置为null
。
public Node(int n) {
num = n;
next = null;
}
考虑以下语句:
Node p = new Node(36);
首先,为新节点分配存储。假设一个int
占用 4 个字节,一个指针占用 4 个字节,Node
的大小就是 8 个字节。因此,8
字节从地址4000
开始分配。36
存储在num
字段,null
存储在next
字段,如下所示:
然后将值4000
分配给p
;实际上,p
正在指向刚刚创建的对象。由于实际地址4000
通常并不重要,我们通常将其描述如下:
换句话说,p
指向对象,无论它在哪里。
当我们读取第一个数字时,我们必须为它创建一个节点,并将top
设置为指向新节点。在我们的示例中,当我们读取36
时,我们必须创建以下内容:
如果n
包含新的数字,可以通过以下方式实现:
if (top == null) top = new Node(n);
计算机内部没有箭头,但效果是通过以下方式实现的(假设新节点存储在位置4000
):
对于每个后续的数字,我们必须设置当前最后一个节点的next
字段指向新的节点。新节点成为最后一个节点。假设新号码是15
。我们必须创造这个:
但是我们如何找到现有列表的最后一个节点呢?一种方法是从列表的顶部开始,跟随next
指针,直到我们遇到null
。如果我们必须对每个新号码都这样做,这是非常耗时的。一个更好的方法是保留一个指向列表最后一个节点的指针(last
)。该指针随着新节点的添加而更新。这方面的代码可以这样写:
np = new Node(n); //create a new node
if (top == null) top = np; //set top if first node
else last.next = np; //set last.next for other nodes
last = np; //update last to new node
假设列表中只有一个节点;这也是最后一个节点。在我们的例子中,last
的值将是4000
。假设包含15
的节点存储在位置2000
。我们有以下情况:
上面的代码会将位置4000
处的next
字段设置为2000
,并将last
设置为2000
。以下是结果:
现在top
( 4000
)指向包含36
的节点;这个节点的next
字段是2000
,因此指向包含15
的节点。该节点的next
字段为null
,表示列表结束。last
的值是2000
,列表中最后一个节点的地址。
程序 P3.1 读取数字,并按照讨论创建链表。为了验证列表已经被正确构建,我们应该打印它的内容。函数printList
从第一个节点到最后一个节点遍历列表,在每个节点打印数字。
程序 P3.1
import java.util.*;
public class BuildList1 {
public static void main(String[] args) {
Scanner in = new Scanner(System.in);
Node top, np, last = null;
top = null;
System.out.printf("Enter some integers ending with 0\n");
int n = in.nextInt();
while (n != 0) {
np = new Node(n); //create a new node containing n
if (top == null) top = np; //set top if first node
else last.next = np; //set last.next for other nodes
last = np; //update last to new node
n = in.nextInt();
}
System.out.printf("\nThe items in the list are\n");
printList(top);
} //end main
public static void printList(Node top) {
while (top != null) { //as long as there's a node
System.out.printf("%d ", top.num);
top = top.next; //go on to the next node
}
System.out.printf("\n");
} //end printList
} //end class BuildList1
class Node {
int num;
Node next;
public Node(int n) {
num = n;
next = null;
}
} //end class Node
为了验证列表已经被正确构建,我们应该打印它的内容。方法printList
从第一个节点到最后一个节点遍历列表,在每个节点打印数字。以下是程序 P3.1 的运行示例:
Enter some integers ending with 0
9 1 8 2 7 3 6 4 5 0
The items in the list are
9 1 8 2 7 3 6 4 5
3.4 插入到链表中
一个每个节点都有一个指针的链表叫做单向,或者叫做单向、链表。这种列表的一个重要特征是通过“列表顶部”指针和每个节点中的指针字段来访问节点。(但是,其他显式指针可能指向列表中的特定节点,例如,前面显示的指针last
,它指向列表中的最后一个节点。)这意味着访问被限制为顺序的。
比方说,到达节点 4 的唯一途径是通过节点 1、2 和 3。因为我们不能直接访问第 k 个节点,例如,我们不能在链表上执行二分搜索法。链表的最大优点是它允许在列表中的任何地方进行简单的插入和删除。
假设我们想在第二个和第三个节点之间插入一个新节点。我们可以简单地将其视为在第二个节点之后的插入。例如,假设prev
指向第二个节点,np
指向新节点,如图图 3-2 所示。
图 3-2 。在链表中插入新节点
我们可以通过设置它的next
字段指向第三个节点和第二个节点的next
字段指向新节点来插入新节点。注意,我们需要做的只是插入第二个节点;它的next
场会给我们第三个节点。插入可以这样完成:
np.next = prev.next;
prev.next = np;
第一个语句说,“让新节点指向第二个节点指向的任何东西,换句话说,就是第三个节点。”第二条语句说,“让第二个节点指向新节点。”最终结果是新节点被插入到第二个和第三个节点之间。新节点成为第三个节点,原来的第三个节点成为第四个节点。这就将图 3-2 中的变为图 3-3 中的。
图 3-3 。插入新节点后
如果prev
指向最后一个节点,那么我们实际上是在最后一个节点之后插入,这段代码会起作用吗?是的。如果prev
是最后一个节点,那么prev.next
就是null
。因此,下面的语句将np.next
设置为null
,使得新节点成为最后一个节点:
np.next = prev.next;
和前面一样,prev.next
被设置为指向新节点。这可以通过更改以下内容来说明:
对此:
在许多情况下,需要在列表的开头插入一个新的节点。也就是说,我们希望将新节点作为第一个节点。假设np
将指向新的节点,我们希望将它转换为:
对此:
这可以通过下面的代码来完成:
np.next = top;
top = np;
第一条语句将新节点设置为指向top
所指向的任何节点(即第一个节点),第二条语句更新top
以指向新节点。
您应该观察到,即使列表最初是空的(也就是说,如果top
是null
),代码也能工作。在这种情况下,它将此转换为:
对此:
3.5 构建链表:在头部添加一个新项目
再次考虑构建正整数链表的问题,但是这一次,我们将每个新数字插入到列表的开头,而不是末尾。结果列表中的数字顺序与给出的顺序相反。假设输入的数字如下(0
终止数据):
36 15 52 23 0
我们希望建立以下链接列表:
以逆序构建列表的程序实际上比前一个更简单。它与程序 P3.1 几乎相同。唯一的变化是在while
循环中。当读取每个新数字时,我们将它的链接设置为指向第一个节点,并将top
设置为指向新节点,使其成为(新的)第一个节点。这些变化被并入程序 P3.2 ,写成BuildList2
。
程序 P3.2
import java.util.*;
public class BuildList2 {
public static void main(String[] args) {
Scanner in = new Scanner(System.in);
Node top, np, last = null;
top = null;
System.out.printf("Enter some integers ending with 0\n");
int n = in.nextInt();
while (n != 0) {
np = new Node(n); //create a new node containing n
np.next = top; //set it to point to first node
top = np; //update top to point to new node
n = in.nextInt();
}
System.out.printf("\nThe items in the list are\n");
printList(top);
} //end main
public static void printList(Node top) {
while (top != null) { //as long as there's a node
System.out.printf("%d ", top.num);
top = top.next; //go on to the next node
}
System.out.printf("\n");
} //end printList
} //end class BuildList2
class Node {
int num;
Node next;
public Node(int n) {
num = n;
next = null;
}
} //end class Node
以下是程序 P3.2 的运行示例:
Enter some integers ending with 0
9 1 8 2 7 3 6 4 5 0
The items in the list are
5 4 6 3 7 2 8 1 9
程序 P3.1 在列表末尾插入来电号码。这是一个向队列添加项目的示例。一个队列是一个线性列表,其中插入发生在一端,删除(见下一节)发生在另一端。
程序 P3.2 在列表的开头插入来电号码。这是一个向栈中添加项目的示例。一个栈是一个线性列表,其中插入和删除发生在同一端的。在栈术语中,当我们添加一个项目时,我们说这个项目是被推到栈上的。从栈中删除一个项目被称为弹出栈。
我们将在第四章中更全面地讨论栈和队列。
3.6 从链表中删除
从链表顶部删除一个节点是通过以下语句完成的:
top = top.next;
这表示让top
指向第一个节点指向的任何内容(即第二个节点,如果有的话)。由于top
现在指向第二个节点,实际上第一个节点已经从列表中删除了。该语句更改了以下内容:
对此:
当然,在我们删除之前,我们应该检查一下是不是有要删除,换句话说,就是top
不是null
。
如果列表中只有一个节点,删除它将导致空列表;top
会变成null
。
从链表中删除任意节点需要更多信息。假设curr
(代表“当前”)指向要删除的节点。删除这个节点需要我们改变前一个节点的next
字段。这意味着我们必须知道指向前一个节点的指针;假设是prev
(为“前”)。那么删除节点curr
可以通过这条语句来完成:
prev.next = curr.next;
这将改变以下内容:
对此:
实际上,curr
指向的节点已经不在列表中了——它已经被删除了。
人们可能想知道被删除的节点会发生什么。在我们的讨论中,删除意味着“逻辑删除”也就是说,就处理列表而言,被删除的节点不存在。但是节点仍然在内存中,占用存储空间,即使我们可能已经丢失了指向它们的指针。
如果我们有一个很大的列表,其中发生了许多删除,那么就会有许多“已删除”的节点分散在整个内存中。这些节点占用存储空间,即使它们永远不会也无法被处理。
Java 解决这个问题的方法是自动垃圾收集。Java 会不时地检查这些“不可到达”的节点并删除它们,回收它们所占用的存储空间。程序员永远不必担心这些“删除”的节点。
3.7 建立排序链表
作为第三种可能性,假设我们想要构建一个列表,使得数字总是按照升序排序。假设输入的数字如下(0
终止数据):
36 15 52 23 0
我们希望建立以下链接列表:
当一个新号码被读取时,它被插入到现有列表(最初是空的)的适当位置。第一个数字只是添加到空列表中。
每个后续数字都与现有列表中的数字进行比较。只要新数字大于列表中的数字,我们就向下移动列表,直到新数字小于或等于现有数字,或者到达列表的末尾。
为了便于插入新号码,在我们离开一个节点并移动到下一个节点之前,我们必须保存指向该节点的指针,以防新号码必须插入到该节点之后。然而,只有当我们将新的数字与下一个节点中的数字进行比较时,我们才能知道这一点。
为了说明这些想法,考虑下面的排序列表,假设我们想要向列表添加一个新的数字(30
),以便它保持排序:
假设节点上方的数字是该节点的地址。因此,top
的值就是400
。
首先,我们比较一下30
和15
。它更大,所以我们继续下一个号码,23
,记住了15
的地址(400
)。
接下来,我们比较一下30
和23
。它更大,所以我们继续下一个号码,36
,记住了23
的地址(200
)。我们不再需要记住15
的地址(400
)。
接下来,我们比较一下30
和36
。它更小,所以我们在前找到了数字,我们必须插入30
。这与在 23
后插入30
是一样的。由于我们已经记住了23
的地址,现在我们可以执行插入了。
我们将使用以下代码来处理新号码,n
:
prev = null;
curr = top;
while (curr != null && n > curr.num) {
prev = curr;
curr = curr.next;
}
最初,prev
是null
,curr
是400
。30 的插入过程如下:
30
是与curr.num
、15
相比较的。比较大,所以我们把prev
设为curr
(400
),把curr
设为curr.next
,200
;curr
不是null
。30
是与curr.num
、23
相比较的。比较大,所以我们把prev
设为curr
(200
),把curr
设为curr.next
,800
;curr
不是null
。30
是与curr.num
、36
相比较的。它更小,所以我们退出while
循环,其中prev
为200
,而curr
为800
。
我们有以下情况:
如果新的数字存储在np
指向的节点中,我们现在可以把它添加到列表中(头部除外;见下一节)用下面的代码:
np.next = curr; //we could also use prev.next for curr
prev.next = np;
这将改变以下情况:
对此:
作为练习,如果要添加的数字大于列表中的所有数字,请验证此代码是否有效。提示:while
循环什么时候退出?
如果要添加的数字比列表中所有数字的小,则必须将其添加到列表的开头,成为列表中新的第一个节点。这意味着top
的值必须更改为新节点。
前面显示的while
循环也适用于这种情况。在第一次测试时,while
条件将是false
(因为n
将小于curr.num
)。在退出时,我们简单地测试prev
是还是null
;如果是,新节点必须插入列表的顶部。
如果列表最初为空,while
循环将立即退出(因为curr
将是null
)。在这种情况下,新节点也必须插入到列表的顶部,成为列表中唯一的节点。
程序 P3.3 包含所有细节。将新节点插入到列表中的适当位置被委托给函数addInPlace
。这个函数返回一个指向修改列表顶部的指针。
程序 P3.3
import java.util.*;
public class BuildList3 {
public static void main(String[] args) {
Scanner in = new Scanner(System.in);
Node top, np, last = null;
top = null;
System.out.printf("Enter some integers ending with 0\n");
int n = in.nextInt();
while (n != 0) {
top = addInPlace(top, n);
n = in.nextInt();
}
printList(top);
} //end main
public static Node addInPlace(Node top, int n) {
// This functions inserts n in its ordered position in a (possibly empty)
// list pointed to by top, and returns a pointer to the new list
Node np, curr, prev;
np = new Node(n);
prev = null;
curr = top;
while (curr != null && n > curr.num) {
prev = curr;
curr = curr.next;
}
np.next = curr;
if (prev == null) return np; //top of list is now the new node
prev.next = np;
return top; //the top of the list has not changed
} //end addInPlace
public static void printList(Node top) {
while (top != null) { //as long as there's a node
System.out.printf("%d ", top.num);
top = top.next; //go on to the next node
}
System.out.printf("\n");
} //end printList
} //end class BuildList3
class Node {
int num;
Node next;
public Node(int n) {
num = n;
next = null;
}
} //end class Node
运行时,程序 P3.3 从提供的数字中构建一个排序链表,然后按照数字在列表中出现的顺序打印数字。下面显示了一些示例输出:
Enter some integers ending with 0
9 1 8 2 7 3 6 4 5 0
1 2 3 4 5 6 7 8 9
3.8 一个链表类
我们已经讨论了链表处理中涉及的许多基本思想,并且我们已经看到了如何在链表上实现常见的操作。我们使用了static
方法 ( printList
,addInPlace
),并将链表的“头节点”作为参数传递。
现在让我们稍微改变一下我们的观点。我们的目标是编写一个“链表类”,从中我们可以创建“链表对象”,我们可以用它来处理链表。
要回答的第一个问题是,“什么定义了链表?”那很简单。它是(object)变量,本质上是一个指针,指向列表中的第一个节点。所以,我们的课将如下开始:
public class LinkedList {
Node head = null;
.
.
} //end class LinkedList
我们将使用head
作为我们的“列表顶部”变量。当我们使用如下语句时,Java 会将head
初始化为null
,但我们这样做是为了引起对其初始值的注意:
LinkedList LL = new LinkedList();
我们如何定义Node
?嗯,这取决于我们想要在列表中存储的项目(“数据”)的种类。如果我们想要一个整数列表,我们可以用这个:
class Node {
int num;
Node next;
}
如果我们想要一个字符列表,我们可以使用这个:
class Node {
char ch;
Node next;
}
如果我们想要一个零件列表,我们可以用这个:
class Node {
Part part;
Node next;
}
如你所见,每次我们需要不同类型的链表时,我们都需要改变Node
的定义。但是如果一个方法的代码依赖于列表中的条目,我们也需要改变这个方法。例如,考虑在整数列表的开头添加一个新节点的方法。
public void addHead(int n) {
Node p = new Node(n); //assume Node has the appropriate constructor
p.next = head;
head = p;
}
例如,这可以如下使用(LL
是LinkedList
):
LL.addHead(25);
这将在列表LL
的开头添加一个包含25
的节点。
但是如果我们需要一个字符列表,我们需要将标题改为:
public void addHead(char c)
对于一个Part
对象列表,如下所示:
public void addHead(Part p)
如果类中有许多方法,那么每当我们需要改变存储在列表中的数据类型时,这些改变就会变得非常繁琐。
我们将使用一种方法来最小化LinkedList
中所需的更改。
让我们将类Node
定义如下:
class Node {
NodeData data;
Node next;
public Node(NodeData nd) {
data = nd;
next = null;
}
} //end class Node
我们根据一个尚未指定的数据类型NodeData
来编写这个类。有两个字段,data
和next
。在对NodeData
一无所知的情况下,我们可以把addHead
写成如下:
public void addHead(NodeData nd) {
Node p = new Node(nd);
p.next = head;
head = p;
}
一个想要使用LinkedList
的类(TestList
)必须提供一个对LinkedList
可用的NodeData
的定义。假设我们想要一个整数链表。我们可以将NodeData
定义如下(我们将很快解释toString
的必要性):
public class NodeData {
int num;
public NodeData(int n) {
num = n;
}
public String toString() {
return num + " ";
//" " needed to convert num to a string; may also use "" (empty string)
}
} //end class NodeData
我们可以用如下代码以相反的顺序构建一个链表:
LinkedList LL = new LinkedList();
System.out.printf("Enter some integers ending with 0\n");
int n = in.nextInt();
while (n != 0) {
LL.addHead(new NodeData(n)); //NodeData argument required
n = in.nextInt();
}
注意,由于addHead
需要一个NodeData
参数,我们必须用整数n
创建一个NodeData
对象;这个对象被传递给addHead
。
我们如何打印列表中的项目?大概,我们希望在LinkedList
类中有一个方法(printList
)来完成这项工作。但是由于LinkedList
不知道NodeData
可能包含什么(每次运行都可能不同),它如何打印节点中的数据呢?
诀窍是让NodeData
使用toString
方法打印自己。下面是写printList
的一种方法:
public void printList() {
Node curr = head;
while (curr != null) {
System.out.printf("%s", curr.data); //invokes curr.data.toString()
curr = curr.next;
}
System.out.printf("\n");
} //end printList
回想一下,curr.data
是一个NodeData
对象。因为我们在需要字符串的上下文中使用它,Java 将在NodeData
类中寻找toString
方法。既然它找到了一个,它就会用它来打印curr.data
。在我们显式调用toString
的地方,printf
语句也可以写成如下形式:
System.out.printf("%s ", curr.data.toString());
如果LL
是一个LinkedList
,列表可以打印如下语句:
LL.printList();
到目前为止,我们的LinkedList
类由以下内容组成:
public class LinkedList {
Node head = null;
public void addHead(NodeData nd) {
Node p = new Node(nd);
p.next = head;
head = p;
}
public void printList() {
Node curr = head;
while (curr != null) {
System.out.printf("%s", curr.data); //invokes curr.data.toString()
curr = curr.next;
}
System.out.printf("\n");
} //end printList
} //end class LinkedList
我们可以添加一个方法来检查链表是否为空。
public boolean empty() {
return head == null;
}
如果LL
是一个LinkedList
,我们可以用empty
如下:
while (!LL.empty()) { ...
现在,假设我们想要添加一个方法,该方法将按照“排序顺序”构建一个链表同样,由于LinkedList
不知道NodeData
可能包含什么,我们如何在LinkedList
中定义“排序顺序”?同样,解决方案是让NodeData
告诉我们一个NodeData
项目何时小于、等于或大于另一个NodeData
项目。
我们可以通过在NodeData
中编写一个实例方法(我们称之为compareTo
)来做到这一点。这是:
public int compareTo(NodeData nd) {
if (this.num == nd.num) return 0;
if (this.num < nd.num) return -1;
return 1;
}
这里,我们第一次使用 Java 关键字this
。如果a
和b
是两个NodeData
对象,记住我们可以用a.compareTo(b)
调用方法。在方法中,this
指的是用来调用它的对象。于是,this.num
指的就是a.num
。我们注意到该方法在没有this
的情况下也同样有效,因为num
本身确实引用了a.num
。
因为我们使用的NodeData
类有一个整数字段,num
,compareTo
简化为比较两个整数。如果a.num
等于b.num
,则表达式a.compareTo(b)
返回0
,如果a.num
小于b.num
,则返回-1
,如果a.num
大于b.num
,则返回1
。
使用compareTo
,我们可以将addInPlace
写成如下:
public void addInPlace(NodeData nd) {
Node np, curr, prev;
np = new Node(nd);
prev = null;
curr = head;
while (curr != null && nd.compareTo(curr.data) > 0) { //new value is bigger
prev = curr;
curr = curr.next;
}
np.next = curr;
if (prev == null) head = np;
else prev.next = np;
} //end addInPlace
如果LL
是一个LinkedList
,我们可以如下使用:
LL.addInPlace(new NodeData(25));
这将创建一个带有包含25
的NodeData
对象的Node
,并将该节点插入到列表中,以便列表按升序排列。
程序 P3.4 读取整数,按升序建立链表,打印排序后的列表。你会发现我们已经从NodeData
、Node
和LinkedList
类中去掉了单词public
。这仅仅是为了让我们将整个程序存储在一个文件中,这个文件必须叫做LinkedListTest.java
,因为公共类的名字是LinkedListTest
。回想一下,Java 要求一个文件只包含一个公共类。我们将在 3.9 节对此进行详细阐述。
程序 P3.4
import java.util.*;
public class LinkedListTest {
public static void main(String[] args) {
Scanner in = new Scanner(System.in);
LinkedList LL = new LinkedList();
System.out.printf("Enter some integers ending with 0\n");
int n = in.nextInt();
while (n != 0) {
LL.addInPlace(new NodeData(n));
n = in.nextInt();
}
LL.printList();
} //end main
} //end LinkedListTest
class NodeData {
int num;
public NodeData(int n) {
num = n;
}
public int compareTo(NodeData nd) {
if (this.num == nd.num) return 0;
if (this.num < nd.num) return -1;
return 1;
} //end compareTo
public String toString() {
return num + " ";
//" " needed to convert num to a string; may also use "" (empty string)
}
} //end class NodeData
class Node {
NodeData data;
Node next;
public Node(NodeData nd) {
data = nd;
next = null;
}
} //end class Node
class LinkedList {
Node head = null;
public boolean empty() {
return head == null;
}
public void addHead(NodeData nd) {
Node p = new Node(nd);
p.next = head;
head = p;
}
public void addInPlace(NodeData nd) {
Node np, curr, prev;
np = new Node(nd);
prev = null;
curr = head;
while (curr != null && nd.compareTo(curr.data) > 0) { //new value is bigger
prev = curr;
curr = curr.next;
}
np.next = curr;
if (prev == null) head = np;
else prev.next = np;
} //end addInPlace
public void printList() {
Node curr = head;
while (curr != null) {
System.out.printf("%s", curr.data); //invokes curr.data.toString()
curr = curr.next;
}
System.out.printf("\n");
} //end printList
} //end class LinkedList
以下是程序 P3.4 的运行示例。
Enter some integers ending with 0
9 1 8 2 7 3 6 4 5 0
1 2 3 4 5 6 7 8 9
3.9 如何组织 Java 文件
在上一节中,我们处理了四个类——LinkedList
、Node
、NodeData
和LinkedListTest
——并且注意到为了将它们存储在一个文件中(作为程序 P3.4 ),我们必须从除了LinkedListTest
之外的所有类中去掉单词public
。在这里,我们回顾一下我们之前的一些评论,并解释每个类如何存储在自己的文件中。
我们可以将LinkedListTest
类存储在一个文件中,这个文件必须叫做LinkedListTest.java
。记住一个public
类x
必须存储在一个名为x.java
的文件中。我们可以将其他类存储在相同的文件中,只要我们将类头写为class xxx
而不是public class xxx
。
然而,为了使这些类可以被其他类使用,我们将对它们进行不同的组织。我们将把NodeData
类声明为public
,并将其单独存储在一个文件中。该文件必须名为NodeData.java
,到目前为止,将包含以下内容:
public class NodeData {
int num;
public NodeData(int n) {
num = n;
}
public int compareTo(NodeData nd) {
if (this.num == nd.num) return 0;
if (this.num < nd.num) return -1;
return 1;
}
public String toString() {
return num + " ";
//" " needed to convert num to a string; may also use "" (empty string)
}
} //end class NodeData
我们将把LinkedList
类声明为public
,并将其存储在一个名为LinkedList.java
的文件中。由于Node
类仅由LinkedList
类使用,我们将省略单词public
并将其存储在同一个文件中,到目前为止,该文件将包含以下内容:
public class LinkedList {
Node head = null;
public boolean empty() {
return head == null;
}
public void addHead(NodeData nd) {
Node p = new Node(nd);
p.next = head;
head = p;
}
public void addInPlace(NodeData nd) {
Node np, curr, prev;
np = new Node(nd);
prev = null;
curr = head;
while (curr != null && nd.compareTo(curr.data) > 0) { //nd is bigger
prev = curr;
curr = curr.next;
}
np.next = curr;
if (prev == null) head = np;
else prev.next = np;
} //end addInPlace
public void printList() {
Node curr = head;
while (curr != null) {
System.out.printf("%s", curr.data); //invokes curr.data.toString()
curr = curr.next;
}
System.out.printf("\n");
} //end printList
} //end class LinkedList
class Node {
NodeData data;
Node next;
public Node(NodeData d) {
data = d;
next = null;
}
} //end class Node
我们注意到,如果另一个类需要Node
类,最好声明它public
,并把它放在一个名为Node.java
的文件中。
3.10 扩展 LinkedList 类
为了准备下一个例子,我们将用下面的方法扩展LinkedList
类。函数getHeadData
返回列表中第一个节点的data
字段(如果有的话)。
public NodeData getHeadData() {
if (head == null) return null;
return head.data;
}
方法deleteHead
删除列表中的第一个节点,如果有的话。
public void deleteHead() {
if (head != null) head = head.next;
}
addTail
方法在列表末尾添加一个新节点。它找到最后一个节点(其中next
是null
)并将其设置为指向新节点。
public void addTail(NodeData nd) {
Node p = new Node(nd);
if (head == null) head = p;
else {
Node curr = head;
while (curr.next != null) curr = curr.next;
curr.next = p;
}
} //end addTail
函数copyList
复制一份用于调用它的列表并返回副本。
public LinkedList copyList() {
LinkedList temp = new LinkedList();
Node curr = this.head;
while (curr != null) {
temp.addTail(curr.data);
curr = curr.next;
}
return temp;
} //end copyList
方法reverseList
颠倒给定列表中节点的顺序。它作用于原始列表,而不是副本。
public void reverseList() {
Node p1, p2, p3;
if (head == null || head.next == null) return;
p1 = head;
p2 = p1.next;
p1.next = null;
while (p2 != null) {
p3 = p2.next;
p2.next = p1;
p1 = p2;
p2 = p3;
}
head = p1;
} //end reverseList
函数equals
比较两个链表。如果L1
和L2
是两个链表,那么如果它们包含相同顺序的相同元素,表达式L1.equals(L2)
就是true
,否则就是false
。
public boolean equals(LinkedList LL) {
Node t1 = this.head;
Node t2 = LL.head;
while (t1 != null && t2 != null) {
if (t1.data.compareTo(t2.data) != 0) return false;
t1 = t1.next;
t2 = t2.next;
}
if (t1 != null || t2 != null) return false; //if one ended but not the other
return true;
} //end equals
3.11 示例:回文
考虑确定给定字符串是否是一个回文的问题(向前或向后拼写都一样)。以下是回文的示例(忽略大小写、标点和空格):
civic
Racecar
Madam, I'm Adam.
A man, a plan, a canal, Panama.
如果所有的字母大小写都一样(大写或小写),并且字符串(word
)不包含空格或标点符号,我们可以用解决如下问题:
compare the first and last letters
if they are different, the string is not a palindrome
if they are the same, compare the second and second to last letters
if they are different, the string is not a palindrome
if they are the same, compare the third and third to last letters
我们继续下去,直到我们找到一个不匹配的对(这不是一个回文),或者没有更多的对进行比较(这是一个回文)。
这种方法是高效的,但是它要求我们能够直接访问单词中的任何字母。如果单词存储在数组中,并且我们使用下标访问任何字母,这是可能的。但是,如果单词的字母存储在一个链表中,我们就不能使用这种方法,因为我们只能按顺序访问字母。
为了说明如何操作链表,我们将使用链表来解决这个问题,其思路如下:
- 将原始短语存储在一个链表中,每个节点一个字符。
- 创建另一个列表,只包含短语的字母,全部转换为小写。把这个叫做
list1
。 - 反转
list1
得到list2
。 - 逐个节点地比较
list1
和list2
,直到我们得到一个不匹配(短语不是回文)或者我们到达列表的末尾(短语是回文)。
考虑一下短语Damn Mad!
;这将按如下方式存储:
步骤 2 会将其转换为:
步骤 3 将反转该列表,得到以下内容:
比较list1
和list2
会发现Damn Mad!
是一个回文。
我们将编写一个程序,提示用户键入一个短语,并告诉她这是否是一个回文。然后它会提示输入另一个短语。要停止,用户必须按回车键。以下是运行示例:
Type a phrase. (To stop, press "Enter" only): Damn Mad!
is a palindrome
Type a phrase. (To stop, press "Enter" only): So Many Dynamos!
is a palindrome
Type a phrase. (To stop, press "Enter" only): Rise to vote, sir.
is a palindrome
Type a phrase. (To stop, press "Enter" only): Thermostat
is not a palindrome
Type a phrase. (To stop, press "Enter" only): A Toyota’s a Toyota.
is a palindrome
Type a phrase. (To stop, press "Enter" only):
之前,我们使用的是整数链表。但是,现在,我们需要一个字符链表。如果我们做得对,我们应该只需要对NodeData
类进行修改。我们不应该改变LinkedList
类中的任何东西,我们也不会。这里是NodeData
应该有的样子:
public class NodeData {
char ch;
public NodeData(char c) {
ch = c;
}
public char getData() {return ch;}
public int compareTo(NodeData nd) {
if (this.ch == nd.ch) return 0;
if (this.ch < nd.ch) return -1;
return 1;
}
public String toString() {
return ch + "";
}
} //end class NodeData
我们添加了一个访问器getData
,用于返回唯一的数据字段ch
中的值。其他的变化主要是将int
改为char
。
我们将编写一个函数getPhrase
,它将读取数据并将短语的字符存储在一个链表中,每个节点一个字符。该函数返回新创建的列表。这个函数必须按照用户输入字符的顺序构建链表——每个新字符都被添加到列表的末尾。
该函数首先通过使用nextLine
将整个短语读入一个String
变量来实现这一点。然后,从最后一个字符开始向后,在链表的头处插入每个新字符。(我们也可以从第一个字符开始,在列表的尾部添加每个新字符,但是这需要更多的工作。)下面是函数:
public static LinkedList getPhrase(Scanner in) {
LinkedList phrase = new LinkedList();
String str = in.nextLine();
for (int h = str.length() - 1; h >= 0; h--)
phrase.addHead(new NodeData(str.charAt(h)));
return phrase;
} //end getPhrase
接下来,我们编写一个函数lettersLower
,在给定一个字符链表的情况下,它创建另一个只包含字母的列表,所有字母都被转换成小写。当遇到每个字母时,它被转换成小写,并使用addTail
添加到新列表的尾。这里是lettersLower
:
public static LinkedList lettersLower(LinkedList phrase) {
LinkedList word = new LinkedList();
while (!phrase.empty()) {
char ch = phrase.getHeadData().getData();
if (Character.isLetter(ch)) word.addTail(new NodeData(Character.toLowerCase(ch)));
phrase.deleteHead();
}
return word;
} //end lettersLower
表达式phrase.getHeadData()
返回列表中第一个节点的data
字段(类型为NodeData
)。NodeData
类中的访问器getData
返回存储在节点中的字符。
我们现在拥有了编写程序 P3.5 所需的一切,它解决了回文问题。它假设NodeData
和LinkedList
类被声明为public
,并存储在不同的文件中。
程序 P3.5
import java.util.*;
public class P3_5Palindrome {
public static void main(String[] args) {
Scanner in = new Scanner(System.in);
System.out.printf("Type a phrase. (To stop, press 'Enter' only): ");
LinkedList aPhrase = getPhrase(in);
while (!aPhrase.empty()) {
LinkedList w1 = lettersLower(aPhrase);
System.out.printf("Converted to: ");
w1.printList();
LinkedList w2 = w1.copyList();
w2.reverseList();
if (w1.equals(w2)) System.out.printf("is a palindrome\n");
else System.out.printf("is not a palindrome\n");
System.out.printf("Type a phrase. (To stop, press 'Enter' only): ");
aPhrase = getPhrase(in);
}
} //end main
public static LinkedList getPhrase(Scanner in) {
LinkedList phrase = new LinkedList();
String str = in.nextLine();
for (int h = str.length() - 1; h >= 0; h--)
phrase.addHead(new NodeData(str.charAt(h)));
return phrase;
}
public static LinkedList lettersLower(LinkedList phrase) {
LinkedList word = new LinkedList();
while (!phrase.empty()) {
char ch = phrase.getHeadData().getData();
if (Character.isLetter(ch)) word.addTail(new NodeData(Character.toLowerCase(ch)));
phrase.deleteHead();
}
return word;
}
} //end class P3_5Palindrome
注这个解决方案主要用来展示链表是如何操作的。使用字符数组或字符串可以更有效地解决这个问题,因为我们可以直接访问给定短语中的任何字符。例如,我们可以直接比较第一个和最后一个字母。即使在这里给出的解决方案中,我们也可以通过只保留字母并将它们转换成小写字母来清除输入的短语。作为练习,写一个程序用数组解决这个问题。
3.12 保存链接列表
当我们创建一个链表时,一个节点中实际的“指针”值是在运行时根据该节点在内存中的存储位置决定的。每次程序运行时,指针值都会改变。那么,如果已经创建了一个链表,我们需要保存它以备后用,我们该怎么办呢?
因为保存指针值是没有用的,所以我们必须保存节点的内容,以便在需要时能够重新创建列表。最简单的方法是将条目按照它们在链表中出现的顺序写入一个文件(见第八章)。稍后,我们可以读取文件,并在读取每个项目时重新创建列表。
有时候,我们可能想把一个链表压缩成一个数组。一个原因可能是链表是排序的,我们想快速搜索它。由于我们被限制在一个链表上进行顺序搜索,我们可以将条目转移到一个数组中,在那里我们可以使用二分搜索法。
例如,假设我们有一个由top
指向的最多 50 个整数的链表。如果num
和next
是一个节点的字段,我们可以用下面的代码将整数存储在一个数组saveLL
中:
int saveLL[50], n = 0;
while (top != null & n < 50) {
saveLL[n++] = top.num;
top = top.next;
}
完成后,n
的值将指示保存了多少个数字。它们将被存储在saveLL[0..n-1]
中。
3.13 数组与链表
数组和链表是存储线性链表的两种常见方式,各有优缺点。
两者最大的区别是,我们可以通过使用下标直接访问数组的任何元素,而要访问链表的任何元素,我们必须从顶部开始遍历链表。
如果条目列表是未排序的,我们必须使用顺序搜索来搜索列表,无论条目是存储在数组中还是链表中。如果列表已排序,可以使用二分搜索法搜索数组。因为二分搜索法要求直接访问元素,所以我们不能在链表上执行二分搜索法。搜索链表的唯一方法是顺序搜索。
在存储在数组中的列表的尾部插入一个项很容易(假设有空间),但是在头部插入一个项需要移动所有其他项来为新项腾出空间。在中间插入一个项目需要移动大约一半的项目,以便为新项目腾出空间。在链表中的任何地方插入一个条目都很容易,因为它只需要设置/改变几个链接。
类似地,从链表中删除一个条目也很容易,不管这个条目位于哪里(头、尾、中间)。从数组中删除一个项很容易,只要它是最后一个;删除任何其他项目将需要移动其他项目来“关闭”先前被删除项目占据的空间。
按照排序的顺序维护数组(当添加新的项时)是很麻烦的,因为每个新的项都必须“在适当的位置”插入,正如我们已经看到的,这通常需要移动其他的项。但是,使用二分搜索法可以快速找到插入该项的位置。
必须使用顺序搜索来找到在排序链表中插入新项目的位置。然而,一旦找到位置,就可以通过设置/更改几个链接来快速插入项目。
表 3-1 总结了在数组中存储条目列表和在链表中存储条目的优缺点。
表 3-1 。在数组和链表中存储项目列表
|
排列
|
合框架
|
| --- | --- |
| 直接访问任何元素 | 必须遍历列表才能到达元素 |
| 如果未排序,则进行顺序搜索 | 如果未排序,则进行顺序搜索 |
| 如果排序,二分搜索法 | 如果排序,顺序搜索 |
| 易于在列表末尾插入项目 | 易于在列表中的任何位置插入项目 |
| 必须移动项目以插入除尾部以外的任何位置 | 易于在列表中的任何位置插入项目 |
| 删除(除了最后一个)需要移动项目 | 删除任何项目都很容易 |
| 向排序列表添加新项目时需要移动项目 | 向排序的链表中添加一个新的条目很容易 |
| 可以使用排序列表中的二分搜索法来查找插入新项目的位置 | 必须使用顺序搜索来查找在排序链表中插入新项的位置 |
3.14 使用数组存储链表
我们已经看到了如何使用动态存储分配创建一个链表。当我们需要向链表中添加另一个节点时,我们请求该节点的存储。如果我们需要从链表中删除一个节点,我们首先通过改变指针在逻辑上删除它,然后通过释放节点占用的存储空间在物理上删除它。
也可以用数组来表示一个链表。再次考虑下面的链表:
我们可以将它存储如下:
这里,链接(指针)仅仅是数组下标。由于数组下标只是一个整数,top
是一个int
变量,next
是一个int
数组。在这个例子中,数据碰巧是整数(所以data
是一个int
数组),但是它可以是任何其他类型,甚至是一个对象。
top
的值是 5,所以这表示列表中的第一项在数组索引 5 处找到;data[5]
保存数据(本例中为 36),而next[5]
(本例中为 1)告诉我们在哪里可以找到列表中的下一个(第二个)条目。
因此,第二项在数组索引 1 处找到;data[1]
保存数据(15),而next[1]
(7)告诉我们在哪里可以找到列表中的下一个(第三个)条目。
第三项在数组索引 7 处找到;data[7]
保存数据(52),而next[7]
(3)告诉我们在哪里可以找到列表中的下一个(第四个)条目。
第四项位于数组索引 3 处;data[3]
保存数据(23),而next[3]
(-1)告诉我们在哪里找到列表中的下一项。这里,我们使用-1 作为空指针,所以我们已经到了列表的末尾。任何不能与有效数组下标混淆的值都可以用来表示空指针,但是通常使用-1
。
本章中描述的所有操作(例如,添加、删除和遍历)都可以以类似的方式在使用数组存储的链表上执行。主要区别在于,以前,如果curr
指向当前节点,curr.next
指向下一个节点。现在,如果curr
指向当前节点,next[curr]
指向下一个节点。
使用数组存储链表的一个缺点是,为了声明数组,你必须知道链表有多大。另一个问题是不能释放或垃圾收集已删除项的存储空间。但是,存储可以重新用于存储新项目。
3.15 合并两个排序后的链表
在 1.10 节中,我们考虑了合并两个有序列表的问题。在那里,我们展示了当列表存储在数组中时如何解决这个问题。现在我们将展示当列表被存储为链表时如何解决同样的问题。我们考虑合并两个有序链表产生一个有序链表的问题。
假设给定的列表如下:
和
我们想创建一个链表,所有的数字按升序排列,因此:
我们将通过为添加到列表C
中的每个数字创建一个新节点来创建合并列表;我们保留列表A
和B
不变。我们将使用 1.10 节中使用的相同算法。这里是为了便于参考:
while (at least one number remains in both A and B) {
if (smallest in A < smallest in B)
add smallest in A to C
move on to next number in A
else
add smallest in B to C
move on to next number in B
endif
}
//at this stage, at least one of the lists has ended
while (A has numbers) {
add smallest in A to C
move on to next number in A
}
while (B has numbers) {
add smallest in B to C
move on to next number in B
}
由于我们的列表包含整数,我们将不得不使用NodeData
的int
版本。
如果A
和B
属于LinkedList
类型,我们将在LinkedList
类中编写一个实例方法merge
,这样A.merge(B)
将返回一个包含A
和B
合并元素的LinkedList
。下面是merge
:
public LinkedList merge(LinkedList LL) {
Node A = this.head;
Node B = LL.head;
LinkedList C = new LinkedList();
while (A != null && B != null) {
if (A.data.compareTo(B.data) < 0) {
C.addTail(A.data);
A = A.next;
}
else {
C.addTail(B.data);
B = B.next;
}
}
while (A != null) {
C.addTail(A.data);
A = A.next;
}
while (B != null) {
C.addTail(B.data);
B = B.next;
}
return C;
} //end merge
正如所实现的,addTail
必须遍历整个列表,在添加每个新节点之前找到末尾。这是低效的。我们可以保留一个指向列表末尾的指针(tail
,以便于在末尾添加一个节点。但是这在这个阶段会使类变得不必要的复杂。
由于在头部添加一个节点是一个简单、高效的操作,所以最好在头部添加一个新节点,并在合并完成后反转列表。我们将通过用addHead
替换addTail
来修改merge
,并且就在return C
之前,我们插入语句C.reverseList();
。
为了测试merge
,我们编写程序 P3.6 。它要求用户为两个列表输入数据。数据可以以任何顺序输入。这些列表将通过“就地”添加每个新数字来按排序顺序构建
我们提醒您,这个程序需要NodeData
类的int
版本,它被声明为public
,并存储在文件NodeData.java
中。它还要求将函数merge
放在LinkedList
类中,该类被声明为public
并存储在文件LinkedList.java
中。当然,程序 P3.6 存储在MergeLists.java
文件中。
程序 P3.6
import java.util.*;
public class MergeLists {
public static void main(String[] args) {
Scanner in = new Scanner(System.in);
LinkedList A = createSortedList(in);
LinkedList B = createSortedList(in);
System.out.printf("\nWhen we merge\n");
A.printList();
System.out.printf("with\n");
B.printList();
System.out.printf("we get\n");
A.merge(B).printList();
} //end main
public static LinkedList createSortedList(Scanner in) {
LinkedList LL = new LinkedList();
System.out.printf("Enter some integers ending with 0\n");
int n = in.nextInt();
while (n != 0) {
LL.addInPlace(new NodeData(n));
n = in.nextInt();
}
return LL;
} //end createSortedList
} //end MergeLists
以下是程序 P3.6 的运行示例:
Enter some integers ending with 0
8 4 12 6 10 2 0
Enter some integers ending with 0
5 7 15 1 3 0
When we merge
2 4 6 8 10 12
with
1 3 5 7 15
we get
1 2 3 4 5 6 7 8 10 12 15
3.16 循环和双向链表
到目前为止,我们的讨论主要是关于单向(单链表)的。每个节点包含一个指针,告诉我们下一个项目的位置。最后一个节点有一个空指针,表示列表的结尾。虽然这是最常用的列表类型,但两种常见的变体是循环列表和双向(或双向链接)列表。
3.16.1 循环列表
在循环列表中,我们让最后一项指向第一项,如下所示:
现在,没有空指针告诉我们什么时候到达了列表的末尾,所以我们在遍历时必须小心,不要陷入无限循环。换句话说,假设我们要写这样的东西:
Node curr = top;
while (curr != null) {
//do something with node pointed to by curr
curr = curr.next;
}
这个循环将永远不会终止,因为curr
永远不会变成null
。为了避免这个问题,我们可以保存起始节点的指针,并识别何时返回到这个节点。这里有一个例子:
Node curr = top;
do {
//do something with node pointed to by curr
curr = curr.next;
} while (curr != top) {
机警的读者会注意到,由于一个do...while
循环的主体至少被执行一次,我们应该在进入循环并试图取消引用一个空指针之前确保列表不为空。
循环列表对于表示循环的情况很有用。例如,在玩家轮流玩的纸牌或棋盘游戏中,我们可以使用循环列表来表示游戏的顺序。如果有四个玩家,他们将按照 1、2、3、4、1、2、3、4、1、2 等顺序进行游戏。最后一个人玩完后,轮到第一个人。
在儿童游戏报数中,孩子们被排成一个圆圈,并出现“eenie,meenie,mynie,mo;抱歉,孩子,你得走了”用来一次消灭一个孩子。最后剩下的孩子赢得游戏。
我们将编写一个程序,使用循环列表来查找游戏的获胜者,描述如下:
清点游戏 : n 个孩子(编号为 1 到 n )排成一圈。用一个由 m 个单词组成的句子,一次消灭一个孩子,直到剩下一个孩子。从子代 1 开始,子代从 1 计数到第 m 个,第 m 个子代被消除。从刚被淘汰的孩子开始,从 1 到第 m 个孩子开始计数,第 m 个孩子被淘汰。如此反复,直到剩下一个孩子。计数循环进行,被淘汰的孩子不计算在内。写一个程序读取 n 和 m ( > 0)的值,按照描述玩游戏,并打印最后剩下的孩子的号码。
可以用一个数组(child
,比方说)来解决这个问题。为了声明数组,我们需要知道要满足的孩子的最大数量(max
)。我们可以设置child[1]
到child[n]
到1
来表示所有的 n 孩子最初都在游戏中。当一个孩子(h
)被淘汰时,我们会将child[h]
设置为0
,并开始从游戏中的下一个孩子开始计数。
随着游戏的进行,child
中的几个条目会被设置为0
,我们在计数的时候一定要保证0
s 不被计数。换句话说,即使一个孩子已经被淘汰,我们仍然必须检查数组项目,如果0
则跳过它。随着更多的孩子被淘汰,我们将需要检查和跳过更多的零条目。这是使用数组解决这个问题的主要缺点。
我们可以使用循环链表来编写一个更有效的解决方案。首先,我们创建一个有 n 个节点的列表。每个节点的值是子节点的编号。对于 n = 4,列表将如下所示,假设curr
指向第一个孩子:
假设 m = 5。我们从 1 开始计数;当我们达到 4 时,5 的计数将我们带回到子 1,它被消除。该列表将如下所示:
如图所示,孩子 1 不再在列表中;这个节点的存储最终会被 Java 回收。我们从孩子 2 开始,再数到 5。计数在孩子 3 处结束,通过将孩子 2 的指针设置为指向孩子 4 来消除计数。该列表将如下所示:
最后,我们从第 4 个孩子开始数到 5。计数在孩子 4 处结束,孩子 4 被消除。孩子 2 是赢家。
请注意,这种解决方案(与数组版本相反)确实通过删除节点从游戏中删除了一个孩子。淘汰的孩子既然走了,既不检查也不统计!这更符合游戏的玩法。
程序 P3.7 玩游戏,并使用链表表示法找到获胜者。我们保持解决方案简单并且忠实于游戏的描述。因此,我们不使用LinkedList
类。相反,我们使用一个有两个字段的Node
类:一个int
保存一个孩子的号码,一个指针指向下一个孩子。
在得到孩子的数量和计数长度之后,程序调用linkCircular
来创建一个孩子的循环链表,然后调用playGame
来删除所有的孩子,只留下一个。
程序 P3.7
import java.util.*;
public class CountOut {
public static void main(String[] args) {
Scanner in = new Scanner(System.in);
int m, n;
do {
System.out.printf("Enter number of children and length of count-out: ");
n = in.nextInt();
m = in.nextInt();
} while (n < 1 || m < 1);
Node last = linkCircular(n); //link children in a circular list
Node winner = playGame(last, n-1, m); //eliminate n-1 children
System.out.printf("The winning child: %d\n", winner.num);
} //end main
public static Node linkCircular(int n) {
//link n children in a circular list;
//return pointer to last child; this will point to the first
Node first, np;
first = np = new Node(1); //first child
for (int h = 2; h <= n; h++) { //link the others
np.next = new Node(h);
np = np.next;
}
np.next = first; //set last child to point to first
return np;
} //end linkCircular
public static Node playGame(Node last, int x, int m) {
//Eliminate x children with countout length of m;
//last points to the last child which points to the first child
Node prev = last, curr = last.next; //curr points to first child
//eliminate x children
for (int h = 1; h <= x; h++) {
//curr is pointing at the first child to be counted;
//count m-1 more to get to the mth child
for (int c = 1; c < m; c++) {
prev = curr;
curr = curr.next;
}
//delete the mth child
prev.next = curr.next;
curr = prev.next; //set curr to the child after the one eliminated
}
return curr;
} //end playGame
} //end class CountOut
class Node {
int num;
Node next;
public Node(int n) {
num = n;
next = null;
}
} //end class Node
以下是程序 P3.7 的运行示例:
Enter number of children and length of count-out: 9 10
The winning child: 8
3.16.2 双向(双重链接)列表
顾名思义,每个节点将包含两个指针;一个指向下一个节点,另一个指向上一个节点。虽然这需要更多的工作来实现和维护,但还是有一些好处的。
显而易见的是,现在可以从任意一端开始双向遍历列表。如果需要,反转列表现在是一个简单的操作。
如果我们到达一个单链表中的一个节点(当前节点),就没有办法到达(或知道)前一个节点,除非在遍历链表时保存了该信息。有了双向链表,我们就有了一个指向前一个节点的指针,所以我们可以向两个方向移动。
一个可能的缺点是额外的链接需要更多的存储空间。另一个原因是添加和删除节点更加复杂,因为需要设置更多的指针。
练习 3
-
在
LinkedList
类中编写一个实例方法,如果列表按升序排序,则返回true
,否则返回false
。 -
编写一个实例方法,通过创建一个新的链表来反转链表的节点。方法返回新创建的列表。
-
Write a method to sort a linked list of integers as follows:
(a)找出列表中的最大值。
(b)将其从其位置上删除,并将其插入列表的开头。
(c)从现在的第二个要素开始,重复(a)和(b)。
(d)从现在的第三个要素开始,重复(a)和(b)。
继续操作,直到列表排序完毕。
-
编写一个函数,它有三个参数——一个指向整数链表的指针和两个整数
n
和j
——并在链表的第j
个元素后插入n
。如果j
是0
,则n
被插入列表的开头。如果j
大于列表中元素的数量,则n
会被插入到最后一个元素之后。 -
The characters of a string are held on a linked list, one character per node.
(a)写一个方法,给定一个指向字符串的指针和两个字符,
c1
和c2
,用c2
替换所有出现的c1
。(b)编写一个函数,给定一个指向字符串的指针和一个字符
c
,从字符串中删除所有出现的c
。返回一个指向修改后的字符串的指针。(c)编写一个函数,创建一个新的列表,只包含给定列表中的字母,所有字母都转换成小写,并按字母顺序存储。返回指向新列表的指针。
(d)编写一个函数,给定指向两个字符串的指针,如果第一个字符串是另一个字符串的子字符串,则返回 true,否则返回 false。
-
编写一个函数,给定一个整数
n
,将n
转换为二进制,并将每个位存储在一个链表的一个节点中,其中最低有效位位于链表的头部,而最高有效位位于链表的尾部。例如,给定13
,比特按照1 0 1 1
的顺序从头到尾存储。返回一个指向列表头部的指针。 -
写一个函数,给定一个指向如 6 中存储的位链表的指针,遍历链表一次并返回二进制数的十进制等效值。
-
You are given two pointers,
b1
andb2
. Each points to a binary number stored as in question 6. You must return a pointer to a newly created linked list representing the binary sum of the given numbers with the least significant bit at the head of the list and the most significant bit at the tail of the list. Write functions to do this in two ways:(I)使用 6 和 7 中的功能
㈡执行“一点一点”加法
-
重复练习 6、7 和 8,但这一次,将最高有效位放在列表的开头,最低有效位放在列表的末尾。**
-
Two words are anagrams if one word can be formed by rearranging all the letters of the other word, for example: treason, senator. A word is represented as a linked list with one letter per node of the list.
写一个函数,给定`w1`和`w2`,每个都指向一个小写字母的单词,如果单词是变位词,则返回`1`,如果不是,则返回`0`。让你的算法基于以下:对于`w1`中的每个字母,搜索`w2`来找到它;如果找到,删除并继续;否则,返回`0`。
- 重写计数程序,但是,这一次,将子元素存储在一个数组中。您的程序应该使用与程序 P3.7 相同的逻辑,除了您必须使用数组存储来实现循环表和所需的操作。
- 整数的数字以相反的顺序保存在链表中,每个节点一个数字。编写一个函数,在给定指向两个整数的指针的情况下,执行逐位相加,并返回一个指向以相反顺序存储的和的数字的指针。注意:这种思想可以用来加任意大的整数。
四、栈和队列
在本章中,我们将解释以下内容:
- 抽象数据类型的概念
- 什么是栈
- 如何使用数组实现栈
- 如何使用链表实现栈
- 如何创建供其他程序使用的头文件
- 如何实现通用数据类型的栈
- 如何将表达式从中缀转换成后缀
- 如何计算一个算术表达式
- 什么是队列
- 如何使用数组实现队列
- 如何使用链表实现队列
4.1 抽象数据类型
我们熟悉声明给定类型的变量(double
)然后对这些变量执行操作(例如,加、乘和赋值)的概念,而不需要知道这些变量是如何存储在计算机中的。在这种情况下,编译器设计者可以改变一个double
变量的存储方式,而程序员不必改变任何使用double
变量的程序。这是一个抽象数据类型的例子。
抽象数据类型允许用户在不知道数据类型在计算机中如何表示的情况下操作数据类型。换句话说,就用户而言,他需要知道的只是可以对数据类型执行的操作。实现该数据类型的人可以自由地更改其实现,而不会影响用户。
在这一章中,我们将展示如何将栈和队列作为抽象数据类型来实现。
4.2 栈
一个栈作为一个线性列表,其中项目在一端被添加,从同一端被删除。这个想法是通过一叠叠放在桌子上的“盘子”来说明的。当需要一个盘子时,就从盘子堆的顶部拿走。当一个盘子被清洗时,它被添加到堆叠的顶部。注意,如果现在需要一个板,这个“最新的”板就是被取用的板。栈展示了“后进先出”的特性。
为了说明栈思想,我们将使用一个整数栈。我们的目标是定义一个名为Stack
的数据类型,这样用户就可以声明这种类型的变量,并以各种方式操纵它们。这些方法有哪些?
如前所述,我们需要向栈中添加一个项目;常用的说法是推。我们还需要从栈中取出一个项目;常用的术语是 pop 。
在我们尝试从栈中取出一些东西之前,最好确保栈上有一些东西,换句话说,它不是空的。我们将需要一个测试栈是否为空的操作。
给定这三个操作——按下、弹出和清空——让我们来说明如何使用它们来读取一些数字并以相反的顺序打印它们。例如,假设我们有这些数字:
36 15 52 23
假设我们想要打印以下内容:
23 52 15 36
我们可以通过将每个新数字添加到栈顶S
来解决这个问题。将所有数字放入栈后,我们可以将栈描述如下:
23 (top of stack)
52
15
36 (bottom of stack)
接下来,我们一次删除一个数字,并在删除时打印每个数字。
我们需要一种方法来判断所有的数字何时被读取。我们将使用0
来结束数据。解决这个问题的逻辑可以表达为如下:
create an empty stack, S
read(num)
while (num != 0) {
push num onto S
read(num)
}
while (S is not empty) {
pop S into num //store the number at the top of S in num
print num
}
我们现在展示如何实现整数栈及其操作。
4.2.1 使用数组 实现栈
为了简化基本原理的介绍,我们将使用整数栈。稍后,我们将看到如何实现一个通用数据类型的栈。
在(整数的)栈的数组实现中,我们使用一个整数数组(ST
)来存储数字,使用一个整数变量(top
)来包含栈顶项目的下标。
因为我们使用了一个数组,所以我们需要知道它的大小来声明它。我们需要一些关于这个问题的信息来确定阵列的合理大小。我们将使用符号常量MaxStack
。如果我们试图将超过MaxStack
个元素推入栈,将会报告一个栈溢出错误。
我们开始定义类Stack
如下:
public class Stack {
final static int MaxStack = 100;
int top = -1;
int[] ST = new int[MaxStack];
//the rest of the class goes here
} //end class Stack
top
的有效值范围从0
到MaxStack-1
。当我们初始化一个栈时,我们将把top
设置为无效的下标-1
。
我们现在可以用下面的语句声明一个栈变量S
:
Stack S = new Stack();
执行该语句时,内存中的情况可以用图 4-1 表示。
图 4-1 。内存中栈的数组表示
这表示一个空栈。我们需要一个函数来告诉我们栈是否为空。我们可以将下面的实例方法添加到Stack
类中:
public boolean empty() {
return top == -1;
}
这只是检查top
是否具有值-1
。
栈上的主要操作是推和弹出。要将项目n
推入栈,我们必须将它存储在ST
中,并更新top
以指向它。基本想法如下:
add 1 to top
set ST[top] to n
然而,当栈已经满了的时候,我们必须防止试图向栈中添加东西。当top
的值为MaxStack - 1
(最后一个元素的下标)时,栈已满。在这种情况下,我们将报告栈已满并暂停程序。下面是Stack
类中的实例方法push
:
public void push(int n) {
if (top == MaxStack - 1) {
System.out.printf("\nStack Overflow\n");
System.exit(1);
}
++top;
ST[top] = n;
} //end push
举例来说,在数字 36、15、52 和 23 被推送到S
之后,我们在内存中的图像看起来像图 4-2 。
图 4-2 。按下 36、15、52 和 23 后的栈视图
最后,为了从栈中弹出一个项目,我们返回位置top
中的值,并将top
减少1
。基本想法如下:
set hold to ST[top]
subtract 1 from top
return hold
同样,我们必须防止试图从空栈中取出一些东西。栈为空,调用了pop
怎么办?我们可以简单地报告一个错误并停止程序。然而,返回一些“流氓”值可能更好,表明栈是空的。我们在函数pop
中采用后一种方法。下面是Stack
类中的实例方法pop
:
public int pop() {
if (this.empty())return RogueValue; //a symbolic constant
int hold = ST[top];
--top;
return hold;
}
注意,即使我们已经编写了pop
来做一些合理的事情,如果它被调用并且栈是空的,如果程序员在调用pop
之前确定栈是而不是空的(使用empty
函数)会更好。
给定类Stack
,我们现在可以编写程序 P4.1 ,它读取一些数字,以0
终止,并以相反的顺序打印出来。注意,为了将整个程序存储在一个文件StackTest.java
中,从类Stack
中删除了单词public
。
程序 P4.1
import java.util.*;
public class StackTest {
public static void main(String[] args) {
Scanner in = new Scanner(System.in);
Stack S = new Stack();
System.out.printf("Enter some integers ending with 0\n");
int n = in.nextInt();
while (n != 0) {
S.push(n);
n = in.nextInt();
}
System.out.printf("\nNumbers in reverse order\n");
while (!S.empty())
System.out.printf("%d ", S.pop());
System.out.printf("\n");
} //end main
} //end StackTest
class Stack {
final static int MaxStack = 100;
final static int RogueValue = -999999;
int top = -1;
int[] ST = new int[MaxStack];
public boolean empty() {
return top == -1;
}
public void push(int n) {
if (top == MaxStack - 1) {
System.out.printf("\nStack Overflow\n");
System.exit(1);
}
++top;
ST[top] = n;
} //end push
public int pop() {
if (this.empty())return RogueValue; //a symbolic constant
int hold = ST[top];
--top;
return hold;
}
} //end class Stack
以下显示了程序 P4.1 的运行示例:
Enter some integers ending with 0
1 2 3 4 5 6 7 8 9 0
Numbers in reverse order
9 8 7 6 5 4 3 2 1
重要的是观察到main
中使用栈的代码通过函数push
、pop
和empty
这样做,并且没有假设如何存储栈元素。这是抽象数据类型的标志——用户不需要知道它是如何实现的就可以使用它。
接下来,我们将使用一个链表实现栈,但是main
将保持不变,以解决逆序打印数字的问题。
4.2.2 使用链表 实现栈
栈的数组实现具有简单高效的优点。然而,一个主要的缺点是需要知道声明数组的大小。必须进行一些合理的猜测,但这可能会变得太小(程序不得不暂停)或太大(存储被浪费)。
为了克服这个缺点,可以使用链表。现在,我们将只在需要时为元素分配存储。
栈被实现为一个链表,在链表的头部添加新的条目。当我们需要弹出栈时,位于头部的项目将被移除。
同样,我们用一堆整数来说明这些原理。首先,我们需要定义一个用于为列表创建节点的Node
类。我们将使用以下声明:
class Node {
int data;
Node next;
public Node(int d) {
data = d;
next = null;
}
} //end class Node
接下来,我们将编写类Stack
,开头如下:
class Stack {
Node top = null;
public boolean empty() {
return top == null;
}
...
有一个类型为Node
的实例变量top
。它被初始化为null
来表示空栈。函数empty
简单地检查top
是否为null
。空栈S
如图图 4-3 所示。
图 4-3 。空栈
方法push
只是在栈头添加一个项,可以写成如下:
public void push(int n) {
Node p = new Node(n);
p.next = top;
top = p;
} //end push
将36
、15
、52
、23
(按此顺序)压入栈S
后,我们可以描绘出如图图 4-4 所示的画面。S
是指向top
的指针,?? 是指向栈元素链表的指针。
图 4-4 。按下 36、15、52 和 23 后的栈视图
要从栈中弹出一个项目,我们首先检查栈是否为空。如果是,则返回一个错误值。如果不是,则返回列表头部的项目,并从列表中删除包含该项目的节点。这里是pop
:
public int pop() {
if (this.empty()) return RogueValue; //a symbolic constant
int hold = top.data;
top = top.next;
return hold;
} //end pop
我们将程序 P4.1 改写为程序 P4.2 。类别StackTest
和以前一样,但是类别Stack
使用了我们对empty
、push
和pop
的新定义。我们再次强调,即使栈的实现已经从使用数组变为使用链表,使用栈的代码(main
)仍然保持不变。
程序 P4.2
import java.util.*;
public class StackTest {
public static void main(String[] args) {
Scanner in = new Scanner(System.in);
Stack S = new Stack();
System.out.printf("Enter some integers ending with 0\n");
int n = in.nextInt();
while (n != 0) {
S.push(n);
n = in.nextInt();
}
System.out.printf("\nNumbers in reverse order\n");
while (!S.empty())
System.out.printf("%d ", S.pop());
System.out.printf("\n");
} //end main
} //end StackTest
class Node {
int data;
Node next;
public Node(int d) {
data = d;
next = null;
}
} //end class Node
class Stack {
final static int RogueValue = -999999;
Node top = null;
public boolean empty() {
return top == null;
}
public void push(int n) {
Node p = new Node(n);
p.next = top;
top = p;
} //end push
public int pop() {
if (this.empty()) return RogueValue; //a symbolic constant
int hold = top.data;
top = top.next;
return hold;
} //end pop
} //end class Stack
以下显示了程序 P4.2 的运行示例。正如所料,其工作方式与程序 P4.1 相同。
Enter some integers ending with 0
1 2 3 4 5 6 7 8 9 0
Numbers in reverse order
9 8 7 6 5 4 3 2 1
4.3 一般栈类型
为了简化我们的演示,我们使用了整数栈。我们提醒你那些与使用整数的决定相关的地方。
- 在
Node
的声明中,我们声明了一个叫做num
的int
。 - 在
push
中,我们传递一个int
参数。 - 在
pop
中,我们返回一个int
结果。
这意味着如果我们需要一堆字符,比方说,我们必须在所有这些地方将int
改为char
。其他类型的 也要做类似的改动。
如果在需要不同类型的栈时,我们能够最小化所需的更改,那就太好了。我们现在展示如何做到这一点。
首先,我们将Node
定义如下:
class Node {
NodeData data;
Node next;
public Node(NodeData d) {
data = d;
next = null;
}
} //end class Node
节点上的数据由通用类型NodeData
组成。当用户定义NodeData
类时,他将决定什么样的项目将被存储在栈中。
Stack
级和之前一样开始:
public class Stack {
Node top = null;
public boolean empty() {
return top == null;
}
...
但是现在,push
需要一个NodeData
参数,可以写成如下形式:
public void push(NodeData nd) {
Node p = new Node(nd);
p.next = top;
top = p;
} //end push
同样,我们把pop
写成如下。由于只有NodeData
应该知道被定义的数据类型,我们将让它告诉我们什么是流氓值。
public NodeData pop() {
if (this.empty())return NodeData.getRogueValue();
NodeData hold = top.data;
top = top.next;
return hold;
} //end pop
细心的读者会注意到,到目前为止,我们所做的只是将Node
、push
和pop
中的int
改为NodeData
。
如果我们想实现一个整数栈,我们可以如下定义NodeData
类。除了增加了访问器getData()
之外,它和以前一样。
public class NodeData {
int num;
public NodeData(int n) {
num = n;
}
public int getData() {return num;}
public static NodeData getRogueValue() {return new NodeData(-999999);}
public int compareTo(NodeData nd) {
if (this.num == nd.num) return 0;
if (this.num < nd.num) return -1;
return 1;
}
public String toString() {
return num + " ";
//" " needed to convert num to a string; may also use "" (empty string)
}
} //end class NodeData
尽管对Node
、Stack
和NodeData
进行了所有这些更改,但如果我们将S.push(n)
更改为S.push(new NodeData(n))
并将S.pop()
更改为S.pop().getData()
,程序 P4.1 和 P4.2 的类StackTest
将像以前一样工作,如程序 P4.3 所示。注意,对于这个程序,我们不需要NodeData
类中的compareTo
和toString
,所以省略了它们。像往常一样,我们从类头中省略了public
(除了StackTest
),这样整个程序可以保存在一个文件中。
程序 P4.3
import java.util.*;
public class StackTest {
public static void main(String[] args) {
Scanner in = new Scanner(System.in);
Stack S = new Stack();
System.out.printf("Enter some integers ending with 0\n");
int n = in.nextInt();
while (n != 0) {
S.push(new NodeData(n));
n = in.nextInt();
}
System.out.printf("\nNumbers in reverse order\n");
while (!S.empty())
System.out.printf("%d ", S.pop().getData());
System.out.printf("\n");
} //end main
} //end StackTest
class NodeData {
int num;
public NodeData(int n) {
num = n;
}
public int getData() {return num;}
public static NodeData getRogueValue() {return new NodeData(-999999);}
} //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
如果我们需要处理一堆字符,我们只需要将NodeData
类改为如下:
public class NodeData {
char ch;
public NodeData(char c) {
ch = c;
}
public char getData() {return ch;}
public static NodeData getRogueValue() {return new NodeData('$');}
public int compareTo(NodeData nd) {
if (this.ch == nd.ch) return 0;
if (this.ch < nd.ch) return -1;
return 1;
}
public String toString() {
return ch + "";
}
} //end class NodeData
4.3.1 示例:十进制转换为二进制
考虑将正整数从十进制转换为二进制的问题。我们可以使用整数栈S
,通过重复除以 2 并保存余数来实现这一点。算法是这样的:
initialize S to empty
read the number, n
while (n > 0) {
push n % 2 onto S
n = n / 2
}
while (S is not empty) print pop(S)
该算法在程序 P4.4 中实现。仅显示了类别DecimalToBinary
。类别NodeData
、Node
和Stack
与程序 P4.3 中的相同。
程序 P4.4
import java.util.*;
public class DecimalToBinary {
public static void main(String[] args) {
Scanner in = new Scanner(System.in);
Stack S = new Stack();
System.out.printf("Enter a positive integer: ");
int n = in.nextInt();
while (n > 0) {
S.push(new NodeData(n % 2));
n = n / 2;
}
System.out.printf("\nIts binary equivalent is ");
while (!S.empty())
System.out.printf("%d", S.pop().getData());
System.out.printf("\n");
} //end main
} //end class DecimalToBinary
以下是程序 P4.4 的运行示例:
Enter a positive integer: 99
Its binary equivalent is 1100011
4.4 如何从中缀转换成后缀
栈的一个经典用途是对算术表达式求值。我们通常编写算术表达式(中缀形式)的方式的一个问题是,它不便于计算机求值。对于这样的评估,一种方法是首先将表达式转换为后缀形式。我们首先展示如何进行这种转换,然后解释如何计算表达式。
考虑一下表达式 7 + 3 * 4。它的价值是什么?在不知道应该先执行哪个操作的情况下,我们可能会从左到右计算出(7 + 3 = 10) * 4 = 40 的值。然而,普通的算术规则表明乘法比加法具有更高的优先级。这意味着,在类似 7 + 3 * 4 的表达式中,乘法(*)在加法(+)之前执行。知道了这个,值就是 7 + 12 = 19。
当然,我们可以通过使用括号强制先执行加法,如(7 + 3) * 4 所示。在这里,括号表示+首先完成。
这些是中缀表达的例子;运算符(+,)放在其操作数之间。中缀表达式的一个缺点是需要使用括号来覆盖普通的优先规则*。
表示表达式的另一种方式是使用后缀符号 ??。这里,操作符出现在操作数的之后的,不需要用括号来指定先执行哪个操作。例如,后缀形式的
7 + 3 * 4 is 7 3 4 * +
和后缀形式的
(7 + 3) * 4 is 7 3 + 4 *
一个有用的观察是操作数在中缀和后缀形式中都以相同的顺序出现,但是它们在操作符的顺序和位置上有所不同。
后缀表示法为什么有用?如前所述,我们不需要括号来指定操作符的优先级。然而,更重要的是,它是计算表达式的一种方便形式。
给定表达式的后缀形式,它可以被评估如下:
initialize a stack, S, to empty
while we have not reached the end of the expression
get the next item, x, from the expression
if x is an operand, push it onto S
if x is an operator, pop its operands from S, apply the operator and
push the result onto S
endwhile
pop S; // this is the value of the expression
考虑后缀形式为7 3 + 4 *
的表达式(7 + 3) * 4
。通过从左到右遍历来评估。
- 下一项是
7
;将7
推到S
上;S
包含7
。 - 下一项是
3
;将3
推到S
上;S
包含7 3
(右上)。 - 下一项是
+
;从S
弹出3
和7
;将+
应用到7
和3
,给出10
;将10
推到S
上;S
包含10
。 - 下一项是
4
;将4
推到S
上;S
包含10 4
。 - 下一项是
*
;从S
弹出4
和10
;将*
应用到10
和4
,给出40
;将40
推到S
上;S
包含40
。 - 我们已经到达表达式的末尾;我们弹出
S
,得到40
—表达式的结果。
注意,当操作数从栈中弹出时,第一个弹出的是第二个操作数,第二个弹出的是第一个操作数。这对加法和乘法无关紧要,但对减法和除法很重要。作为一个练习,将下面的代码转换成后缀形式,并使用上面的算法对其求值:(7 – 3) * (9 – 8 / 4)
。
当然,最大的问题是我们如何让计算机将一个中缀表达式转换成后缀?在介绍该算法之前,我们注意到它将使用一个操作符栈。我们还需要一个优先级表,给出操作符的相对优先级。给定任意两个操作符,该表将告诉我们它们是否具有相同的优先级(比如+
和-
),如果不是,那么哪个优先级更高。
随着算法的进行,它将输出给定表达式的后缀形式。
下面是算法:
-
初始化一堆操作符
S
,清空。 -
从中缀表达式中获取下一项
x
;如果没有,转到第 8 步(x
是操作数、左括号、右括号或运算符)。 -
如果
x
是操作数,则输出x
。 -
如果
x
是左支架,将其推到S
上。 -
如果
x
是右括号,则弹出S
项并输出弹出项,直到S
上方出现一个左括号;弹出左支架并丢弃。 -
如果
x
是一个操作符,那么执行以下操作:while (S is not empty) and (a left bracket is not on top of S) and (an operator of equal or higher precedence than x is on top of S) pop S and output popped item push x onto S
-
从步骤 2 开始重复。
-
弹出
S
并输出弹出的项目,直到S
为空。
建议您逐步完成以下表达式的算法:
3 + 5
7 – 3 + 8
7 + 3 * 4
(7 + 3) * 4
(7 + 3) / (8 – 2 * 3)
(7 – 8 / 2 / 2) * ((7 – 2) * 3 – 6)
让我们写一个程序来读取一个简化的中缀表达式并输出它的后缀形式。我们假设一个操作数是一个单位整数。操作员可以是+
、–
、*
或/
中的一个。允许使用括号。通常运算符的优先级适用:+
和–
的优先级相同,低于*
和/
的优先级。左括号作为优先级很低的运算符处理,比+
和–
的优先级低。
我们将把它实现为一个函数precedence
,给定一个操作符,返回一个表示其优先级的整数。只要保持运算符的相对优先级,返回的实际值并不重要。我们将使用以下内容:
public static int precedence(char c) {
if (c == '(') return 0;
if (c == '+' || c == '-') return 3;
if (c == '*' || c == '/') return 5;
return -99; //error
}
我们也可以使用如下的switch
语句来编写precedence
:
public static int precedence(char c) {
switch (c) {
case '(': return 0;
case '+':
case '-': return 3;
case '*':
case '/': return 5;
}//end switch
} //end precedence
实际值 0、3 和 5 并不重要。可以使用任何值,只要它们代表运算符的相对优先级。
我们需要一个函数来读取输入并返回下一个非空字符。如有必要,该函数将跳过零个或多个空格。行尾字符将指示表达式的结束。下面是函数(我们称之为getToken
):
public static char getToken() throws IOException {
int n;
while ((n = System.in.read()) == ' ') ; //read over blanks
if (n == '\r' || n == '\n') return '\0';
//'\r' on Windows, MacOS and DOS; '\n' on Unix
return (char) n;
} //end getToken
操作符栈只是一个简单的字符栈,我们将使用 4.3 节末尾定义的NodeData
类来实现。这显示在程序 P4.5 中。
算法的第 6 步要求我们比较栈顶操作符和当前操作符的优先级。如果我们可以“偷看”栈顶的元素而不用把它拿下来,这就很容易了。为此,我们编写下面的实例方法,peek
,并将其添加到Stack
类中:
public NodeData peek() {
if (!this.empty()) return top.data;
return null;
} //end peek
将所有这些放在一起,我们编写了程序 P4.5 ,它实现了将中缀表达式转换为后缀的算法。类别Node
与程序 P4.3 中的类别相同。类别Stack
与程序 P4.3 中的类别相同,但增加了peek()
。
程序 P4.5
import java.io.*;
public class InfixToPostfix {
public static void main(String[] args) throws IOException {
char[] post = new char[255];
int n = readConvert(post);
printPostfix(post, n);
} //end main
public static int readConvert(char[] post) throws IOException {
//Read the expression and convert to postfix. Return the size of postfix.
Stack S = new Stack();
int h = 0;
char c;
System.out.printf("Type an infix expression and press Enter\n");
char token = getToken();
while (token != '\0') {
if (Character.isDigit(token)) post[h++] = token;
else if (token == '(') S.push(new NodeData('('));
else if (token == ')')
while ((c = S.pop().getData()) != '(') post[h++] = c;
else {
while (!S.empty() &&
precedence(S.peek().getData()) >= precedence(token))
post[h++] = S.pop().getData();
S.push(new NodeData(token));
}
token = getToken();
}
while (!S.empty()) post[h++] = S.pop().getData();
return h;
} //end readConvert
public static void printPostfix(char[] post, int n) {
System.out.printf("\nThe postfix form is \n");
for (int h = 0; h < n; h++) System.out.printf("%c ", post[h]);
System.out.printf("\n");
} //end printPostfix
public static char getToken() throws IOException {
int n;
while ((n = System.in.read()) == ' ') ; //read over blanks
if (n == '\r') return '\0';
return (char) n;
} //end getToken
public static int precedence(char c) {
//Returns the precedence of the given operator
if (c == '(') return 0;
if (c == '+' || c == '-') return 3;
if (c == '*' || c == '/') return 5;
return -99; //error
} //end precedence
} //end class InfixToPostfix
class NodeData {
char ch;
public NodeData(char c) {
ch = c;
}
public char getData() {return ch;}
public static NodeData getRogueValue() {return new NodeData('$');}
} //end class NodeData
读取表达式并转换为后缀的工作委托给函数readConvert
。这将后缀形式输出到一个字符数组post
。为了避免错误检查造成代码混乱,我们假设post
足够大,可以容纳转换后的表达式。该函数返回后缀表达式中元素的数量。
函数printPostfix
只是打印后缀表达式。
以下是程序 P4.5 的运行示例:
Type an infix expression and press Enter
(7 – 8 / 2 / 2) * ((7 – 2) * 3 – 6)
The postfix form is
7 8 2 / 2 / - 7 2 – 3 * 6 - *
请注意,输入表达式时可以使用或不使用空格来分隔运算符和操作数。例如,如果样本运行中的表达式输入如下,将产生正确的后缀形式:
(7 – 8/2/ 2)*((7–2) *3 – 6)
程序 P4.5 假设给定的表达式是有效的。但是,可以很容易地对其进行修改,以识别某些类型的无效表达式。例如,如果一个右括号不见了,当我们到达表达式的末尾时,在栈上将会有一个左括号。(如果括号匹配,则没有。)类似地,如果一个左括号丢失了,当遇到一个右括号并且我们正在扫描栈寻找(丢失的)左括号时,我们将找不到它。
敦促您修改程序 P4.5 来捕捉带有不匹配括号的表达式。您还应该修改它来处理任何整数操作数,而不仅仅是个位数。另一个修改是处理其他操作,例如%
、sqrt
(平方根)、sin
(正弦)、cos
(余弦)、tan
(正切)、log
(对数)、exp
(指数),等等。
4.4.1 对算术表达式 求值
程序 P4.5 将表达式的后缀形式存储在字符数组post
中。我们现在编写一个函数,给定post
,计算表达式并返回其值。该函数使用 4.4 节开头的算法。
我们将需要一个整数栈来保存操作数和中间结果。回想一下,我们需要一个字符栈来存放操作符。如果我们将NodeData
定义如下,我们可以灵活地处理这两种栈:
public class NodeData {
char ch;
int num;
public NodeData(char c) {
ch = c;
}
public NodeData(int n) {
num = n;
}
public NodeData(char c, int n) {
ch = c;
num = n;
}
public char getCharData() {return ch;}
public int getIntData() {return num;}
public static NodeData getRogueValue() {
return new NodeData('$', -999999); //the user will choose which one is needed
}
} //end class NodeData
我们将char
字段用于操作符栈,将int
字段用于操作数栈。注意用于设置和检索ch
和num
的三个构造函数和三个访问器。
使用NodeData
、的定义,如果我们简单地用getCharData
替换所有出现的getData
,程序 P4.5 将工作良好。
函数eval
对给定后缀形式的表达式求值,显示为程序 P4.6 的一部分。我们通过将以下语句作为main
中的最后一条语句来测试eval
:
System.out.printf("\nIts value is %d\n", eval(post, n));
程序 P4.6 中未显示类别Node
和Stack
。类别Node
与程序 P4.3 中的类别相同。类别Stack
与程序 P4.3 中的类别相同,但增加了peek()
。
程序 P4.6
import java.io.*;
public class EvalExpression {
public static void main(String[] args) throws IOException {
char[] post = new char[255];
int n = readConvert(post);
printPostfix(post, n);
System.out.printf("\nIts value is %d\n", eval(post, n));
} //end main
public static int readConvert(char[] post) throws IOException {
//Read the expression and convert to postfix. Return the size of postfix.
Stack S = new Stack();
int h = 0;
char c;
System.out.printf("Type an infix expression and press Enter\n");
char token = getToken();
while (token != '\0') {
if (Character.isDigit(token)) post[h++] = token;
else if (token == '(') S.push(new NodeData('('));
else if (token == ')')
while ((c = S.pop().getCharData()) != '(') post[h++] = c;
else {
while (!S.empty() &&
precedence(S.peek().getCharData()) >= precedence(token))
post[h++] = S.pop().getCharData();
S.push(new NodeData(token));
}
token = getToken();
}
while (!S.empty()) post[h++] = S.pop().getCharData();
return h;
} //end readConvert
public static void printPostfix(char[] post, int n) {
System.out.printf("\nThe postfix form is \n");
for (int h = 0; h < n; h++) System.out.printf("%c ", post[h]);
System.out.printf("\n");
} //end printPostfix
public static char getToken() throws IOException {
int n;
while ((n = System.in.read()) == ' ') ; //read over blanks
if (n == '\r') return '\0';
return (char) n;
} //end getToken
public static int precedence(char c) {
//Returns the precedence of the given operator
if (c == '(') return 0;
if (c == '+' || c == '-') return 3;
if (c == '*' || c == '/') return 5;
return -99; //error
} //end precedence
public static int eval(char[] post, int n) {
//Given the postfix form of an expression, returns its value
int a, b, c;
Stack S = new Stack();
for (int h = 0; h < n; h++) {
if (Character.isDigit(post[h]))
S.push(new NodeData(post[h] - '0'));
else {
b = S.pop().getIntData();
a = S.pop().getIntData();
if (post[h] == '+') c = a + b;
else if (post[h] == '-') c = a - b;
else if (post[h] == '*') c = a * b;
else c = a / b;
S.push(new NodeData(c));
} //end if
} //end for
return S.pop().getIntData();
} //end eval
} //end class EvalExpression
class NodeData {
char ch;
int num;
public NodeData(char c) {
ch = c;
}
public NodeData(int n) {
num = n;
}
public NodeData(char c, int n) {
ch = c;
num = n;
}
public char getCharData() {return ch;}
public int getIntData() {return num;}
public static NodeData getRogueValue() {
return new NodeData('$', -999999);
}
} //end class NodeData
以下是程序 P4.6 的运行示例:
Type an infix expression and press Enter
(7 – 8 / 2 / 2) * ((7 – 2) * 3 – 6)
The postfix form is
7 8 2 / 2 / - 7 2 – 3 * 6 - *
Its value is 45
4.5 队列
一个队列 是一个线性列表,其中项目在一端被添加,在另一端被删除。常见的例子是在银行、超市、音乐会或体育赛事中排队。人们应该从后面排队,从前面离开。我们期望队列数据结构对于模拟这些真实的队列是有用的。
计算机内部也有队列。可能有几个等待执行的作业,它们被放在一个队列中。例如,几个人可能每个人都要求在网络打印机上打印一些东西。由于打印机一次只能处理一项工作,所以其他工作必须排队。
这些是我们想要在队列上执行的基本操作:
- 向队列中添加一个项目(我们称之为入队)
- 从队列中删除一个项目(我们称之为出列)
- 检查队列是否为空
- 检查队列最前面的物品
与栈一样,我们可以使用数组或链表轻松实现队列数据结构。出于说明的目的,我们将使用整数队列。
4.5.1 使用数组 实现队列
在(整数的)队列的数组实现中,我们使用一个整数数组(QA
)来存储数字和两个整数变量(head
和tail
),这两个变量分别表示队列头的项和队列尾的项。
因为我们使用了一个数组,所以我们需要知道它的大小来声明它。我们需要一些关于这个问题的信息来确定阵列的合理大小。我们将使用符号常量MaxQ
。在我们的实现中,如果队列中有MaxQ-1
个元素,并且我们试图添加另一个元素,那么队列将被声明为已满。
我们开始定义类Queue
如下:
public class Queue {
final static int MaxQ = 100;
int head = 0, tail = 0;
int[] QA = new int[MaxQ];
...
head
和tail
的有效值范围从0
到MaxQ-1
。当我们初始化一个队列时,我们会将head
和tail
设置为0
;稍后,我们将看到为什么这是一个好的值。
像往常一样,我们可以用下面的代码创建一个空队列Q
:
Queue Q = new Queue();
执行该语句时,内存中的情况可以表示为图 4-5 所示。
图 4-5 。队列的数组表示
这表示空队列。在处理队列时,我们需要一个函数来告诉我们队列是否为空。我们可以使用以下方法:
public boolean empty() {
return head == tail;
}
简而言之,我们将看到,给定我们将实现入队和出队操作的方式,每当head
和tail
具有相同的值时,队列将为空。这个值不一定是0
。其实可能是0
到MaxQ-1
的任意一个值,都是QA
的有效下标。
考虑如何将一个项目添加到队列中。在真正的队列中,一个人排在最后。这里我们将做同样的事情,增加tail
并将项目存储在由tail
指示的位置。
例如,为了将36
添加到队列中,我们将tail
增加到1
,并将36
存储到QA[1]
;head
保持在0
。
如果我们随后将15
添加到队列中,它将被存储在QA[2]
中,而tail
将成为2
。
如果我们现在将52
添加到队列中,它将被存储在QA[3]
中,tail
将成为3
。
我们在内存中的图片会看起来像图 4-6 。
图 4-6 。添加 36、15 和 52 后的队列状态
请注意,head
指向该项的“正前方”,它实际上位于队列的头部,而tail
指向队列中的最后一项。
现在考虑从队列中删除一些东西。要取下的物品是头部的那个。要移除它,我们必须先用递增head
,然后返回head
指向的值。
*比如我们去掉36
,head
就会变成1
,它指向15
的“正前方”,现在在头部的项目。注意,36
仍然留在数组中,但是实际上,它不在队列中。
假设我们现在将23
添加到队列中。它将被放置在位置4
,其中tail
为4
,而head
为1
。
图片现在看起来像图 4-7 。
图 4-7 。删除 36 和添加 23 后的队列状态
队列中有三个项目:15
在头,23
在尾。
考虑一下,如果我们不断地向队列中添加项目而不删除任何项目,会发生什么情况。tail
的值将一直增加,直到达到QA
的最后一个有效下标MaxQ-1
。如果需要添加另一个项目,我们该怎么办?
我们可以说队列已满并停止程序。但是,有两个空闲位置,0
和1
。最好尝试使用其中的一种。这让我们想到了循环队列的概念。这里,我们认为数组中的位置排列成一个圆圈:位置MaxQ-1
后面跟着位置0
。
因此,如果tail
具有值MaxQ-1
,增加它将设置它为0
。
假设我们没有从队列中取走任何项目。head
的值仍然是0
。现在,如果在尝试添加一个项目时,tail
从MaxQ-1
增加到0
会怎么样?它现在具有与head
相同的值。在这种情况下,我们声明队列已满。
即使位置0
中没有存储任何内容,我们也要这样做,因此位置0
可用于保存另一个项目。采用这种方法的原因是,它简化了我们检测队列何时为空、何时为满的代码。这也是我们最初将head
和tail
都设置为0
的原因。如果连续插入项目,它使我们能够容易地检测到队列何时已满。
强调一下,当队列被声明为满时,它包含 MaxQ-1
项。
我们现在可以编写enqueue
,一个实例方法来将一个项目添加到队列中。
public void enqueue(int n) {
tail = (tail + 1) % MaxQ; //increment tail circularly
if (tail == head) {
System.out.printf("\nQueue is full\n");
System.exit(1);
}
QA[tail] = n;
} //end enqueue
我们先递增tail
。如果通过这样做,它具有与head
相同的值,我们声明队列已满。如果没有,我们将新的项目存储在位置tail
。
考虑图 4-7 。如果我们删除15
和52
,则变为图 4-8 。
图 4-8 。移除后的队列 15,52
现在,head
具有值3
, tail
具有值4
,并且在位置4
的队列中有一个项目23
。如果我们删除最后一项,head
和tail
的值都是4
,队列将为空。这表明当head
具有与tail
相同的值时,我们有一个空的队列。
但是等等!刚才不是说当head
和tail
的值相同时,队列已满吗?是的,但是有区别。在任何时候,如果head == tail
,队列就空。然而,如果在之后增加tail
以添加一个项目,它变得与head
相同,则队列已满。
我们现在可以编写dequeue
,一个从队列中移除一个条目的方法。
public int dequeue() {
if (this.empty()) {
System.out.printf("\nAttempt to remove from an empty queue\n");
System.exit(2);
}
head = (head + 1) % MaxQ; //increment head circularly
return QA[head];
} //end dequeue
如果队列为空,则会报告一个错误,并且程序会暂停。如果没有,我们递增head
并返回位置head
中的值。再次注意,如果head
的值为MaxQ -1
,递增它会将其设置为0
。
为了测试我们的队列操作,我们编写了程序 P4.7 ,它读取一个整数并以相反的顺序打印它的数字。例如,如果读取了12345
,程序将打印54321
。从右侧提取数字,并存储在队列中。队列中的项目被取出,一次一个,并被打印。
程序 P4.7
import java.util.*;
public class QueueTest {
public static void main(String[] args) {
Scanner in = new Scanner(System.in);
Queue Q = new Queue();
System.out.printf("Enter a positive integer: ");
int n = in.nextInt();
while (n > 0) {
Q.enqueue(n % 10);
n = n / 10;
}
System.out.printf("\nDigits in reverse order: ");
while (!Q.empty())
System.out.printf("%d", Q.dequeue());
System.out.printf("\n");
} //end main
} //end QueueTest
class Queue {
final static int MaxQ = 100;
int head = 0, tail = 0;
int[] QA = new int[MaxQ];
public boolean empty() {
return head == tail;
}
public void enqueue(int n) {
tail = (tail + 1) % MaxQ; //increment tail circularly
if (tail == head) {
System.out.printf("\nQueue is full\n");
System.exit(1);
}
QA[tail] = n;
} //end enqueue
public int dequeue() {
if (this.empty()) {
System.out.printf("\nAttempt to remove from an empty queue\n");
System.exit(2);
}
head = (head + 1) % MaxQ; //increment head circularly
return QA[head];
} //end dequeue
} //end class Queue
以下是程序 P4.7 的运行示例:
Enter a positive integer: 192837465
Digits in reverse order: 564738291
4.5.2 使用链表 实现队列
与栈一样,我们可以使用链表来实现队列。这样做的好处是我们不必事先决定要供应多少食物。我们将使用两个指针,head
和tail
,分别指向队列中的第一项和最后一项。图 4-9 显示了四个项目(36、15、52 和 23)加入队列时的数据结构。
图 4-9 。队列的链表表示
我们将实现队列,这样它就可以使用我们称之为NodeData
的通用数据类型。队列中的每个节点都将从一个Node
类中创建,我们定义如下:
class Node {
NodeData data;
Node next;
public Node(NodeData d) {
data = d;
next = null;
}
} //end class Node
当用户定义NodeData
类时,他将决定什么样的项目将被存储在队列中。
Queue
类按如下方式开始:
public class Queue {
Node head = null, tail = null;
public boolean empty() {
return head == null;
}
...
我们可以用下面的语句创建一个空队列:
Queue Q = new Queue();
这将创建如图 4-10 所示的结构。
图 4-10 。空队列(链表表示)
要将一个项目添加到队列中,我们必须将它添加到列表的末尾。这里是enqueue
:
public void enqueue(NodeData nd) {
Node p = new Node(nd);
if (this.empty()) {
head = p;
tail = p;
}
else {
tail.next = p;
tail = p;
}
} //end enqueue
如果队列为空,则新项目成为队列中的唯一项目;head
和tail
设置为指向它。如果队列不为空,尾部的项被设置为指向新的项,更新tail
指向新的项。
为了从队列中取出一个项目,我们首先检查队列是否为空。如果是,我们打印一条消息并结束程序。如果不是,则返回队列头部的项目,并删除包含该项目的节点。
如果通过删除一个项目,head
变成了null
,这意味着队列是空的。在这种情况下,tail
也被设置为null
。这里是dequeue
:
public NodeData dequeue() {
if (this.empty()) {
System.out.printf("\nAttempt to remove from an empty queue\n");
System.exit(1);
}
NodeData hold = head.data;
head = head.next;
if (head == null) tail = null;
return hold;
} //end dequeue
要使用Queue
类,用户只需要声明他希望NodeData
是什么。举例来说,假设他想要一个整数队列。他可以这样定义NodeData
:
public class NodeData {
int num;
public NodeData(int n) {
num = n;
}
public int getIntData() {return num;}
} //end class NodeData
之前,我们编写了程序 P4.7 ,它读取一个整数并以相反的顺序打印它的数字。我们现在使用新的Node
、Queue
和NodeData
类将它重写为程序 P4.8 。
程序 P4.8
import java.util.*;
public class QueueTest {
public static void main(String[] args) {
Scanner in = new Scanner(System.in);
Queue Q = new Queue();
System.out.printf("Enter a positive integer: ");
int n = in.nextInt();
while (n > 0) {
Q.enqueue(new NodeData(n % 10));
n = n / 10;
}
System.out.printf("\nDigits in reverse order: ");
while (!Q.empty())
System.out.printf("%d", Q.dequeue().getIntData());
System.out.printf("\n");
} //end main
} //end QueueTest
class NodeData {
int num;
public NodeData(int n) {
num = n;
}
public int getIntData() {return num;}
} //end class NodeData
class Node {
NodeData data;
Node next;
public Node(NodeData d) {
data = d;
next = null;
}
} //end class Node
class Queue {
Node head = null, tail = null;
public boolean empty() {
return head == null;
}
public void enqueue(NodeData nd) {
Node p = new Node(nd);
if (this.empty()) {
head = p;
tail = p;
}
else {
tail.next = p;
tail = p;
}
} //end enqueue
public NodeData dequeue() {
if (this.empty()) {
System.out.printf("\nAttempt to remove from an empty queue\n");
System.exit(1);
}
NodeData hold = head.data;
head = head.next;
if (head == null) tail = null;
return hold;
} //end dequeue
} //end class Queue
以下是程序 P4.8 的运行示例:
Enter a positive integer: 192837465
Digits in reverse order: 564738291
栈和队列对系统程序员和编译器编写者来说很重要。我们已经看到了栈是如何用于算术表达式的求值的。它们还用于实现函数的“调用”和“返回”机制。考虑函数A
调用函数C
的情况,函数C
调用函数B
,函数【】调用函数D
。当一个函数返回时,计算机如何计算出返回到哪里?我们展示了如何使用栈来实现这一点。
假设我们有以下情况,其中一个数字,如100
,代表返回地址,它是函数返回时要执行的下一条指令的地址:
function A function B function C function D
. . . .
C; D; B; .
100: 200: 300:
. . . .
当A
调用C
时,地址100
被压入栈S
。当C
调用B
时,300
被推到S
上。当B
调用D
时,200
被推到S
上。在此阶段,栈如下所示,控制在D
中:
(bottom of stack) 100 300 200 (top of stack)
当D
结束并准备返回时,弹出栈顶地址(200
),在此地址继续执行。请注意,这是呼叫D
后的地址。
接下来,当B
结束并准备返回时,弹出栈顶地址(300
),在此地址继续执行。请注意,这是呼叫B
后的地址。
最后,当C
结束并准备返回时,弹出栈顶地址(100
),在这个地址继续执行。请注意,这是呼叫C
后的地址。
自然,队列数据结构用于模拟现实生活中的队列。它们也用于在计算机中实现队列。在多道程序环境中,几个作业可能必须排队等候某一特定资源,如处理器时间或打印机。
栈和队列也广泛用于处理更高级的数据结构,如树和图。我们将在第八章中讨论树。
练习 4
-
什么是抽象数据类型?
-
什么是栈?可以在栈上执行的基本操作是什么?
-
什么是队列?可以对队列执行的基本操作是什么?
-
修改程序 P4.5 识别括号不匹配的中缀表达式。
-
程序 P4.5 处理个位数操作数。修改它以处理任何整数操作数。
-
修改程序 P4.5 来处理带有
%
、平方根、正弦、余弦、正切、对数、指数等运算的表达式。 -
编写声明/函数来实现一堆
double
值。 -
编写声明/函数来实现一个
double
值队列。 -
An integer array
post
is used to hold the postfix form of an arithmetic expression such that the following items are true:正数代表一个操作数
-1 代表+
-2 代表-
-3 代表*
-4 代表/
0 表示表达式的结尾
为表达式
(2 + 3) * (8 / 4) - 6
显示post
的内容。写一个函数
eval
,给定post
,返回表达式的值。 -
输入行包含一个仅由小写字母组成的单词。解释如何使用栈来确定单词是否是回文。
-
展示如何使用两个栈实现队列。
-
展示如何使用两个队列实现栈。
-
A priority queue is one in which items are added to the queue based on a priority number. Jobs with higher-priority numbers are closer to the head of the queue than those with lower-priority numbers. A job is added to the queue in front of all jobs of lower priority but after all jobs of greater or equal priority.
编写类来实现优先级队列。队列中的每个项目都有一个作业号(整数)和一个优先级号。至少实现以下操作:(1)在队列中适当的位置添加一个作业,(2)删除队列头的作业,(3)给定一个作业号,从队列中删除该作业。
确保无论队列状态如何,您的方法都能正常工作。
- 一个栈,
S1
,包含一些任意顺序的数字。使用另一个栈S2
作为临时存储,展示如何对S1
中的数字进行排序,使得最小的在S1
的顶部,最大的在底部。*
五、递归
在本章中,我们将解释以下内容:
- 什么是递归定义
- 如何用 Java 编写递归函数
- 如何从十进制转换成二进制
- 如何逆序打印链表
- 如何解决河内塔
- 如何写一个高效的幂函数
- 如何使用合并排序进行排序
- 如何使用递归来跟踪未决的子问题
- 如何通过在迷宫中寻找路径使用递归实现回溯
5.1 递归定义
递归定义是根据自身定义的定义。也许最常见的例子是阶乘函数。非负整数的阶乘, n (写成 n !),定义如下:
0! = 1
*n*! =*n*(*n*- 1)!,*n*> 0
在这里, n !是用( n - 1)来定义的!,但什么是( n - 1)!确实如此。为了找出答案,我们必须应用阶乘的定义!在这种情况下,我们有这个:
(*n*- 1)! = 1, if (*n*- 1) = 0
(*n*- 1)! = (*n*- 1)(*n*- 2)! if (*n*- 1) > 0
什么是 3!现在吗?
- 因为 3 > 0,所以是 3×2!。
- 因为 2 > 0,2!就是 2×1!,和 3!变成了 3×2×1!。
- 因为 1 > 0,1!就是 1×0!,和 3!变成了 3×2×1×0!。从 0 开始!是 1,我们有 3!= 3×2×1×1 = 6.
不严谨地说,我们说 n !是从 1 到 n 的所有整数的乘积。
让我们用编程符号重写定义;我们称之为fact
。
fact(0) = 1
fact(n) = n * fact(n - 1), n > 0
函数的递归定义由两部分组成。
- 基本情况,给出特定参数的函数值。这也被称为锚、结束情况或终止情况,它允许递归最终终止。
- 函数根据自身定义的递归(或一般)情况。
简而言之,我们将把fact
写成一个 Java 函数。在此之前,我们给出一个递归定义的非数学例子。考虑如何定义的祖先。不严格地说,我们可以说祖先是一个人的父母、祖父母、曾祖父母等等。但是我们可以更精确地表述如下:
*a*is an ancestor of*b*if
(1)*a*is a parent of*b*, or
(2)*a*is an ancestor of*c*and*c*is a parent of*b*
(1)
是基本情况,(2)
是一般的递归情况,其中祖先是根据自身定义的。
一个不太严重的例子是首字母缩写词 LAME 的含义。它代表 LAME,另一个 MP3 编码器。扩展 LAME,我们得到 LAME,另一个 MP3 编码器,另一个 MP3 编码器,等等。我们可以说 LAME 是递归首字母缩略词。尽管这不是一个真正的递归定义,因为它没有基本情况。
5.2 用 Java 编写递归函数
我们见过很多函数调用其他函数的例子。我们没有看到的是一个调用自己的函数——一个递归函数。我们从fact
开始。
public static int fact(int n) {
if (n < 0) return 0;
if (n == 0) return 1;
return n * fact(n - 1);
}
在函数的最后一个语句中,我们调用了函数fact
,我们正在编写的函数。该函数调用自身。
请考虑以下几点:
int n = fact(3);
它按如下方式执行:
3
被复制到一个临时位置,这个位置被传递到fact
,在这里它成为n
的值。- 执行到达最后一条语句,
fact
试图返回3 * fact(2)
。但是,fact(2)
必须在返回值已知之前计算出来。把这看作是对带有参数2
的函数fact
的调用。 - 像往常一样,
2
被复制到一个临时位置,这个位置被传递给fact
,在这里它成为n
的值。如果fact
是不同的函数,就没有问题。但是既然是同函数,n
的第一个值会怎么样呢?它必须被保存在某个地方,并在这个调用结束时恢复。 - 该值保存在一个叫做运行时栈的东西上。每次函数调用自己时,在新的参数生效之前,它的参数(和局部变量,如果有的话)都存储在栈中。此外,对于每个调用,都会创建新的局部变量。因此,每个调用都有自己的参数和局部变量副本。
- 当
n
为2
时,执行到达最后一条语句,fact
试图返回2 * fact(1)
。但是,fact(1)
必须在返回值已知之前计算出来。把这想象成一个带有参数1
的函数fact
的调用。 - 该调用到达最后一条语句,
fact
试图返回1 * fact(0)
。但是,fact(0)
必须在返回值已知之前计算出来。把这看作是对带有参数0
的函数fact
的调用。 - 此时,运行时栈包含参数
3
、2
和1
,其中1
位于顶部。调用fact(0)
到达第二条语句并返回值1
。 - 现在可以完成计算
1 * fact(0)
,返回1
作为fact(1)
的值。 - 现在可以完成计算
2 * fact(1)
,返回2
作为fact(2)
的值。 - 现在可以完成计算
3 * fact(2)
,返回6
作为fact(3)
的值。
我们应该强调的是,fact
的递归版本仅仅是为了说明的目的。这不是计算阶乘的有效方法——想想所有的函数调用以及参数的堆叠和拆分,仅仅是为了将从1
到n
的数字相乘。更有效的函数如下:
public static int fact(int n) {
int f = 1;
while (n > 0) {
f = f * n;
--n;
}
return f;
}
另一个可以递归定义的函数的例子是两个正整数m
和n
的最高公因数(HCF)。
hcf(m, n) is
(1) m, if n is 0
(2) hcf(n, m % n), if n > 0
如果m
= 70
和n
= 42
,我们有这个:
hcf(70, 42) = hcf(42, 70 % 42) = hcf(42, 28) = hcf(28, 42 % 28)
= hcf(28, 14) = hcf(14, 28 % 14) = hcf(14, 0) = 14
我们可以把hcf
写成这样一个递归的 Java 函数:
public static int hcf(int m, int n) {
if (n == 0) return m;
return hcf(n, m % n);
}
有趣的是,我们也可以使用欧几里德算法将hcf
写成一个迭代(与递归相反)函数。这是:
public static int hcf(int m, int n) {
int r;
while (n > 0) {
r = m % n;
m = n;
n = r;
}
return m;
}
实际上,这个函数显式地做递归函数隐式地做的事情。
递归定义函数的另一个例子是斐波那契数。我们将前两个斐波那契数列定义为1
和1
。每个新数字都是通过将前两个数字相加得到的。因此,斐波纳契数列如下:
1, 1, 2, 3, 5, 8, 13, 21, and so on.
递归地,我们定义第 n 个斐波那契数F(n)
,如下:
F(0) = F(1) = 1
F(n) = F(n - 1) + F(n - 2), n > 1
这是一个返回第 n 个斐波那契数的 Java 函数:
public static int fib(int n) {
if (n == 0 || n == 1) return 1;
return fib(n - 1) + fib(n - 2);
}
我们再次强调,尽管这个函数整洁、简洁、易于理解,但它并不高效。例如,考虑F(5)
的计算:
F(5) = F(4) + F(3) = F(3) + F(2) + F(3) = F(2) + F(1) + F(2) + F(3)
= F(1) + F(0) + F(1) + F(2) + F(3) = 1 + 1 + 1 + F(1) + F(0) + F(3)
= 1 + 1 + 1 + 1 + 1 + F(2) + F(1) = 1 + 1 + 1 + 1 + 1 + F(1) + F(0) + F(1)
= 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1
= 8
请注意必须进行的函数调用和加法的数量,而我们可以只使用四次加法直接计算出F(5)
。我们敦促您编写一个高效的迭代函数来返回第 n 个斐波那契数。
5.3 使用递归将十进制数转换为二进制数
在 4.3.1 节中,我们使用栈将整数从十进制转换为二进制。我们现在将展示如何编写一个递归函数来执行相同的任务。
要了解需要做什么,假设n
是13
,在二进制中是1101
。回想一下,n % 2
给出了n
的二进制等价物的最后位。如果我们有办法打印除最后一位以外的所有位,那么我们可以打印除最后一位以外的所有位,后面跟着n % 2
。但是“打印除最后一位以外的所有内容”与打印n/2
的二进制等价物是一样的。
比如1101
就是110
后面跟着1
;110
是6
的二进制等价物,是13/2
,1
是13 % 2
。因此,我们可以打印出n
的二进制等价物,如下所示:
print binary of n / 2
print n % 2
我们用同样的方法打印出6
的二进制等价物。这是6/2
= 3
的二进制等值,是11
,后面是6 % 2
,是0
;这就给出了110
。
我们用同样的方法打印出3
的二进制等价物。这是3/2
= 1
的二进制等值,是1
,后面是3 % 2
,是1
;这就给出了11
。
我们用同样的方法打印出1
的二进制等价物。这是1/2
= 0
后跟1 % 2
的二进制等值,也就是1
;如果我们对0
什么都不做,这将给我们1
。
当我们到达需要找到0
的二进制等价物的阶段时,我们停下来。这就引出了下面的函数:
public static void decToBin(int n) {
if (n > 0) {
decToBin(n / 2);
System.out.printf("%d", n % 2);
}
}
调用decToBin(13)
将打印1101
。
注意这比程序 P4.4 要简洁得多。但是,它的效率并没有提高。在程序 P4.4 中显式完成的堆叠/拆分是由函数调用自身时语言提供的递归机制完成的。为了说明这一点,让我们追踪这个调用decToBin(13)
。
- 在第一次调用时,n 假定值为 13。
- 当调用 decToBin(13)正在执行时,调用 dec Tobin(6);13 被推送到运行时栈上,n 假定值为 6。
- 当调用 decToBin(6)正在执行时,调用 dec Tobin(3);6 被推送到栈上,n 取值为 3。
- 当调用 decToBin(3)正在执行时,调用 dec Tobin(1);3 被推送到栈上,n 假定值为 1。
- 当调用 decToBin(1)正在执行时,调用 dec Tobin(0);1 被推送到栈上,n 假定值为 0。
- 在这个阶段,栈包含 13,6,3,1。
- 因为 n 是 0,所以这个函数的调用立即返回;到目前为止,什么都没印出来。
- 当调用 decToBin(0)返回时,栈顶部的参数 1 恢复为 n 的值。
- 控制转到 printf 语句,该语句打印 1 % 2,即 1。
- 调用 decToBin(1)现在可以返回,栈顶部的参数 3 恢复为 n 的值。
- 控制转到 printf 语句,该语句打印 3 % 2,即 1。
- 调用 decToBin(3)现在可以返回,栈顶部的参数 6 恢复为 n 的值。
- 控制转到 printf 语句,该语句打印 6 % 2,即 0。
- 调用 decToBin(6)现在可以返回,栈顶部的参数 13 恢复为 n 的值。
- 控制转到 printf 语句,该语句打印 13 % 2,即 1。
- 呼叫 decToBin(13)现在可以返回,1101 已被打印。
我们可以将上面的描述总结如下:
decToBin(13) → decToBin(6)
print(13 % 2)
→ decToBin(3)
print(6 % 2)
print(13 % 2)
→ decToBin(1)
print(3 % 2)
print(6 % 2)
print(13 % 2)
→ decToBin(0) = do nothing
print(1 % 2) = 1
print(3 % 2) = 1
print(6 % 2) = 0
print(13 % 2) = 1
递归函数最重要的属性之一是,当函数调用自身时,当前参数(和局部变量,如果有的话)被推送到栈上。使用新的参数和新的局部变量执行函数。当执行完成时,从栈中弹出参数(和局部变量,如果有的话),并使用递归调用后的语句继续执行(使用这些弹出的值)。
考虑下面的函数片段和调用test(4, 9)
:
public static void test(int m, int n) {
char ch;
.
test(m + 1, n - 1);
System.out.printf("%d %d", m, n);
.
}
该函数用m
= 4
、n
= 9
和局部变量ch
执行。当进行递归调用时,会发生以下情况:
- 将
m
、n
和ch
的值压入栈。 test
再次开始执行m = 5
、n = 8
和ch
的新副本。- 每当对
test
的调用结束时(甚至可能在调用自己一次或多次并产生自己的输出后),栈被弹出,程序用printf
(递归调用后的语句)和弹出的值m
、n
和ch
继续执行。在本例中,4 9
将被打印。
5.4 逆序打印链表
考虑逆序打印链表的问题。
一种方法是遍历列表,当我们遇到条目时,将它们推到一个整数栈上。当我们到达列表的末尾时,最后一个数字会在栈的顶部,第一个数字会在底部。然后,我们从栈中弹出项目,并在弹出时打印每个项目。
正如我们现在所期望的,我们可以使用递归来执行堆叠/拆分。我们使用下面的想法:
to print a list in reverse order
print the list, except the first item, in reverse order
print the first item
使用上面的列表,以相反的顺序打印(15 52 23
),然后是36
。
- 要以相反的顺序打印(
15 52 23
),我们必须以相反的顺序打印(52 23
),然后是15
。 - 要以相反的顺序打印(
52 23
),我们必须以相反的顺序打印(23
),然后是52
。 - 要以逆序打印(
23
),我们必须以逆序后跟23
不打印任何东西(当 23 被删除时列表的剩余部分)。
最后,我们会打印出这个:23 52 15 36
。
对此的另一种看法如下:
reverse(36 15 52 23) → reverse(15 52 23) 36
→ reverse(52 23) 15 36
→ reverse(23) 52 15 36
→ reverse() 23 52 15 36
→ 23 52 15 36
下面是这个函数,假设指向列表头部的指针是类型Node
,节点字段是num
和next
:
public static void reverse(Node top) {
if (top != null) {
reverse(top.next);
System.out.printf("%d ", top.num);
}
}
解决一个问题的递归解决方案的关键是能够用解决方案本身来表达,但是是在一个“更小”的问题上。如果问题越来越小,最终它会小到我们可以直接解决它。
我们在“十进制到二进制”和“以逆序打印链表”的问题中都看到了这个原则。第一个问题中,n
的换算用n/2
来表示;这将依次用n/4
等术语来表示,直到没有东西可转换为止。在第二个问题中,反向打印列表表示为反向打印一个较短的列表(原始列表减去第一个元素)。名单越来越短,直到没有什么可以逆转。
5.5 河内之塔
汉诺塔难题是一个可以用递归解决的经典问题。传说当世界被创造的时候,梵天神庙里的一些高级祭司被给予了三枚金别针。在其中一个别针上放了 64 个金色的圆盘。这些磁盘大小不一,最大的在底部,最小的在顶部,没有磁盘放在较小的磁盘上面。
他们需要根据以下规则将 64 个磁盘从给定的 pin 移动到另一个 pin:
- 一次移动一个磁盘;只能移动引脚顶部的磁盘,并且必须将其移动到另一个引脚的顶部。
- 不得将磁盘放在较小的磁盘之上。
当所有 64 个磁盘都被转移后,世界将走向末日。
这是一个问题的例子,这个问题可以通过递归很容易地解决,但是非递归的解决方案是非常困难的。让我们用 A 、 B 和 C 来表示插针,其中磁盘最初放置在 A 上,目的插针为 B 。销 C 用于临时放置磁盘。
假设有一个磁盘。可以直接从 A 移动到 B 。接下来,假设 A 上有五个磁盘,如图图 5-1 所示。
图 5-1 。有五个圆盘的河内塔
假设我们知道如何使用 B 将前四名从 A 转移到 C 。完成后,我们就有了图 5-2 。
图 5-2 。将四个磁盘从 A 移动到 C 后
我们现在可以将第五个圆盘从 A 移动到 B ,如图图 5-3 所示。
图 5-3 。第五张盘放在 B 上
剩下的只是使用 A 将四个磁盘从 C 转移到 B ,我们假设我们知道如何做。如图 5-4 所示,工作完成。
图 5-4 。将四个磁盘从 C 移动到 B 后
因此,我们将转移五个磁盘的问题简化为将四个磁盘从一个引脚转移到另一个引脚的问题。反过来,这可以归结为一个问题,移动三个磁盘从一个引脚到另一个,这可以减少到两个,然后到一个,我们知道如何做。 n 个磁盘的递归解如下:
- 使用 b 将 n - 1 个磁盘从 A 转移到 C。
- 将第个磁盘从 A 移动到 b。
- 使用 a 将 n - 1 个磁盘从 C 转移到 B。
当然,我们可以使用相同的解决方案来传输 n - 1 个磁盘。
以下函数使用workPin
将n
磁盘从startPin
转移到endPin
:
public static void hanoi(int n, char startPin, char endPin, char workPin) {
if (n > 0) {
hanoi(n - 1, startPin, workPin, endPin);
System.out.printf("Move disk from %c to %c\n", startPin, endPin);
hanoi(n - 1, workPin, endPin, startPin);
}
}
当用语句调用时
hanoi(3, 'A', 'B', 'C'); //transfer 3 disks from A to B using C
该函数打印如下内容:
Move disk from A to B
Move disk from A to C
Move disk from B to C
Move disk from A to B
Move disk from C to A
Move disk from C to B
Move disk from A to B
转移 n 个磁盘需要多少步?
- 如果 n 为 1,则需要走一步:(1 = 2 1 - 1)。
- 如果 n 为 2,则需要三招:(3 = 2 2 - 1)。
- 如果 n 为 3,则需要 7 步棋(见前面所示):(7 = 2 3 - 1)。
看来,对于 n 盘,移动次数是 2 n - 1。可以证明确实如此。
当 n 为 64 时,移动次数为
2`64`- 1 = 18,446,744,073,709,551,615
假设牧师们可以每秒钟移动一张光盘,从不出错,从不休息,他们将需要差不多 6000 亿年才能完成任务。请放心,世界不会很快结束!
5.6 编写幂函数
给定一个数, x ,和一个整数, n ≥ 0,我们如何计算 x 的 n 次幂即 x n ?我们可以用 x n 是 x 乘以自身n-1
倍的定义。这样,3 4 就是 3 × 3 × 3 × 3。下面是一个使用此方法的函数:
public static double power(double x, int n) {
double pow = 1.0;
for (int h = 1; h <= n; h++) pow = pow * x;
return pow;
}
注意,如果 n 是0
,power
返回正确答案1
。
如前所述,该函数执行 n 次乘法。然而,如果我们采用不同的方法,我们可以编写一个更快的函数。假设我们要计算 x 16 。我们可以这样做:
- 如果我们知道
x8
=x
8
,我们可以将x8
乘以x8
得到x
16
,只需要再做一次乘法。 - 如果我们知道
x4
=x
4
,我们可以将x4
乘以x4
得到x8
,只需要再做一次乘法。 - 如果我们知道
x2
=x
2
,我们可以将x2
乘以x2
得到x4
,只需要再做一次乘法。
我们知道x
;因此,我们可以用一次乘法求出x2
。知道了x2
,再用一次乘法就能找到x4
。知道了x4
,再用一次乘法就能找到x8
。知道了x8
,再用一次乘法就能求出 x 16 。总之,我们只用四次乘法就能找到x16。
如果n
是15
会怎么样?首先,我们会算出 x 15/2 ,即 x 7 (称此为x7
)。然后我们将x7
乘以x7
得到x14。认识到n
是奇数,然后我们将这个值乘以x
以给出所需的答案。总结一下:
x`n` = x`n/2`.x`n/2`, if*n*is even and
x.x`n/2`.x`n/2`, if*n*is odd
我们将此作为递归幂函数的基础,该函数计算x[n] 比之前的函数更有效。
public static double power(double x, int n) {
double y;
if (n == 0) return 1.0;
y = power(x, n/2);
y = y * y;
if (n % 2 == 0) return y;
return x * y;
}
作为练习,用n
= 5
和n
= 6
跟踪函数的执行。
5.7 合并排序
再次考虑按升序对一系列 n 项进行排序的问题。我们将用一个整数列表来说明我们的想法。在 1.9 节中,我们看到了如何通过遍历每个列表一次来合并两个排序列表。我们现在展示如何使用递归和合并来对列表进行排序。考虑以下算法:
sort list
sort first half of list
sort second half of list
merge sorted halves into one sorted list
end sort
如果我们可以对这两半排序,然后将它们合并,我们就已经对列表排序了。但是我们如何对这一半进行排序呢?我们用同样的方法!例如,为了“排序列表的前半部分”,我们执行以下操作:
sort (first half of list)
sort first half of (first half of list) //one quarter of the original list
sort second half of (first half of list) //one quarter of the original list
merge sorted halves into one sorted list
end sort
等等。对于我们需要排序的每一块,我们把它分成两半,排序两半,然后合并它们。我们什么时候停止在一件作品上使用这种工艺?当该片仅由一个元素组成时;对一个元素进行排序没有任何作用。我们可以修改我们的算法如下:
sort a list
if the list contains more than one element then
sort first half of list
sort second half of list
merge sorted halves into one sorted list
end if
end sort
我们假设该列表存储在从A[lo]
到A[hi]
的数组A
中。我们可以将算法编码为 Java 方法,如下所示:
public static void mergeSort(int[] A, int lo, int hi) {
if (lo < hi) { //list contains at least 2 elements
int mid = (lo + hi) / 2; //get the mid-point subscript
mergeSort(A, lo, mid); //sort first half
mergeSort(A, mid + 1, hi); //sort second half
merge(A, lo, mid, hi); //merge sorted halves
}
} //end mergeSort
这假设merge
可用,并且语句
merge(A, lo, mid, hi);
将合并A[lo..mid]
和A[mid+1..hi]
中已排序的块,以便对A[lo..hi]
进行排序。我们将很快展示如何编写merge
。
但是首先,我们展示了mergeSort
如何对存储在数组中的下面的列表进行排序,num
:
将通过以下方式调用该方法:
mergeSort(num, 0, 6);
在该方法中,num
将称为A
,lo
将称为0
,hi
将称为6
。从这些中,mid
将被计算为3
,产生以下两个调用:
mergeSort(A, 0, 3);
mergeSort(A, 4, 6);
假设第一个将对A[0..3]
进行排序,第二个将对A[4..6]
进行排序,我们将得到以下结果:
merge
将各部分合并产生以下内容:
这些调用中的每一个都将引起另外两个调用。第一个会产生这个:
mergeSort(A, 0, 1);
mergeSort(A, 2, 3);
第二个会产生这个:
mergeSort(A, 4, 5);
mergeSort(A, 6, 6);
只要lo
小于hi
,就会产生两个进一步的调用。如果lo
等于hi
,列表只包含一个元素,函数简单返回。下面显示了初始呼叫mergeSort(num, 0, 6)
产生的所有呼叫,按照产生的顺序排列:
mergeSort(A, 0, 6)
mergeSort(A, 0, 3)
mergeSort(A, 0, 1);
mergeSort(A, 0, 0);
mergeSort(A, 1, 1);
mergeSort(A, 2, 3);
mergeSort(A, 2, 2);
mergeSort(A, 3, 3);
mergeSort(A, 4, 6);
mergeSort(A, 4, 5);
mergeSort(A, 4, 4);
mergeSort(A, 5, 5);
mergeSort(A, 6, 6);
为了完成这项工作,我们需要编写merge
。我们可以对merge
描述如下:
public static void merge(int[] A, int lo, int mid, int hi) {
//A[lo..mid] and A[mid+1..hi] are sorted;
//merge the pieces so that A[lo..hi] are sorted
注意必须做的事情:我们必须将A
的两个相邻部分合并回相同的位置。这样做的问题是,当合并正在进行时,我们不能合并到相同的位置,因为我们可能会在数字被使用之前覆盖它们。我们将不得不合并到另一个(临时)数组中,然后将合并后的元素复制回A
中的原始位置。
我们将使用一个名为T
的临时数组;我们只需要确保它足够大,能够容纳合并后的元素。合并中的元素数量是hi–lo+1
。我们将T
声明如下:
int[] T = new int[hi - lo + 1];
这里是merge
:
public static void merge(int[] A, int lo, int mid, int hi) {
//A[lo..mid] and A[mid+1..hi] are sorted;
//merge the pieces so that A[lo..hi] are sorted
int[] T = new int[hi - lo + 1];
int i = lo, j = mid + 1;
int k = 0;
while (i <= mid || j <= hi) {
if (i > mid) T[k++] = A[j++];
else if (j > hi) T[k++] = A[i++];
else if (A[i] < A[j]) T[k++] = A[i++];
else T[k++] = A[j++];
}
for (j = 0; j < hi-lo+1; j++) A[lo + j] = T[j];
} //end merge
我们用i
下标A
的第一部分,j
下标第二部分,k
下标T
。该方法将A[lo..mid]
和A[mid+1..hi]
合并为T[0..hi-lo]
。
while
循环表达了以下逻辑:只要我们还没有处理完和部分中的所有元素,我们就进入循环。如果我们完成了第一部分(i > mid
,从第二部分复制一个元素到T
。如果我们完成了第二部分(j > hi
,从第一部分复制一个元素到T
。否则,我们将A[i]
和A[j]
中较小的一个复制到T
。
最后,我们将元素从T
复制到位置A[lo]
到A[hi]
。
我们用程序 P5.1 测试mergeSort
。
程序 P5.1
public class MergeSortTest {
public static void main(String[] args) {
int[] num = {4,8,6,16,1,9,14,2,3,5,18,13,17,7,12,11,15,10};
int n = 18;
mergeSort(num, 0, n-1);
for (int h = 0; h < n; h++) System.out.printf("%d ", num[h]);
System.out.printf("\n");
} // end main
public static void mergeSort(int[] A, int lo, int hi) {
if (lo < hi) { //list contains at least 2 elements
int mid = (lo + hi) / 2; //get the mid-point subscript
mergeSort(A, lo, mid); //sort first half
mergeSort(A, mid + 1, hi); //sort second half
merge(A, lo, mid, hi); //merge sorted halves
}
} //end mergeSort
public static void merge(int[] A, int lo, int mid, int hi) {
//A[lo..mid] and A[mid+1..hi] are sorted;
//merge the pieces so that A[lo..hi] are sorted
int[] T = new int[hi - lo + 1];
int i = lo, j = mid + 1;
int k = 0;
while (i <= mid || j <= hi) {
if (i > mid) T[k++] = A[j++];
else if (j > hi) T[k++] = A[i++];
else if (A[i] < A[j]) T[k++] = A[i++];
else T[k++] = A[j++];
}
for (j = 0; j < hi-lo+1; j++) A[lo + j] = T[j];
} //end merge
} //end class MergeSortTest
运行时,该程序产生以下输出:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
顺便说一下,我们注意到合并排序是一种比选择排序或插入排序更快的排序方法。
5.8 生物计数
考虑以下安排:
0 1 0 1 1 1 0
0 0 1 1 0 0 0
1 1 0 1 0 0 1
1 0 1 0 0 1 1
1 1 0 0 0 1 0
假设每个1
代表一个生物体的一个细胞;0
表示没有细胞。如果两个单元格在同一行或同一列中相邻,则它们是连续的。有机体的定义如下:
- 一个生物体至少包含一个
1
。 - 两个相邻的属于同一个有机体。
在图示的排列中有五种生物。数数他们!
给定细胞在网格中的排列,我们想写一个程序来计算存在的有机体的数量。
扫一眼网格就会发现,给定一个细胞(1
),生物体可以向四个方向中的任何一个方向延伸。对于这些中的每一个,它可以向四个方向中的任何一个方向延伸,给出了 16 种可能性。其中的每一个都产生了另外四种可能性,以此类推。我们如何跟踪所有这些可能性,知道哪些已经被探索,哪些仍在等待探索?
最简单的方法是让递归机制跟踪我们。
为了计算生物体的数量,我们需要一种方法来确定哪些细胞属于一个生物体。首先,我们必须找到一个1
。接下来,我们必须找到与这个1
相邻的所有1
,然后是与之相邻的1
,依此类推。
为了找到连续的1
s,我们必须朝四个方向看——北、东、南、西(任何顺序)。当我们观察时,有四种可能性:
- 我们在电网之外,没有什么事可做。
- 我们看到一个 0,也没有办法。
- 我们看到一个之前已经出现过的 1;没什么可做的。
- 我们第一次看到 1。我们移动到那个位置,从那里向四个方向看。
第 3 步意味着,当我们第一次遇到一个1
时,我们需要以某种方式标记它,以便如果我们以后遇到这个位置,我们将知道它以前遇到过,并且我们不会试图再次处理它。
我们能做的最简单的事情就是把值从1
改成0
;这确保了如果再次遇到这种情况,什么也不做。如果我们想做的只是计算生物体,这没问题。但是,如果我们还想确定哪些细胞构成了一个有机体,我们就必须对它进行不同的标记。
据推测,我们将需要一个变量来记录生物体的数量。姑且称之为orgCount
。当第一次遇到一个1
时,我们会把它改成orgCount + 1
。因此,生物体 1 的细胞将被标记为2
,生物体 2 的细胞将被标记为3
,以此类推。
这是必要的,因为如果我们从 1 开始标记,我们将无法区分代表尚未满足的细胞的1
和指示属于有机体 1 的细胞的1
。
只有在我们处理网格时,这个“给标签加 1”才是必要的。当我们打印它时,我们将从标签中减去1
,这样在输出时,有机体 1 将被标记为1
,有机体 2 将被标记为2
,以此类推。
在编写程序时,我们假设网格数据存储在数组G
中,由m
行和n
列组成。我们将使用MaxRow
和MaxCol
分别表示m
和n
的最大值。程序数据由m
和n
的值组成,后跟按行顺序排列的单元格数据。例如,前一个网格的数据将按如下方式提供:
5 7
0 1 0 1 1 1 0
0 0 1 1 0 0 0
1 1 0 1 0 0 1
1 0 1 0 0 1 1
1 1 0 0 0 1 0
我们假设将从文件orgs.in
中读取数据,并将输出发送到文件orgs.out
。
程序逻辑的要点如下:
scan the grid from left to right, top to bottom
when we meet a 1, we have a new organism
add 1 to orgCount
call a function findOrg to mark all the cells of the organism
函数findOrg
将实现前面概述的四种可能性。比方说,当它在网格位置(i, j
)看到一个1
时,它将对(i, j
)的北、东、南、西的每个网格位置递归调用自己。所有细节见程序 P5.2 。
程序 P5.2
import java.io.*;
import java.util.*;
public class Organisms {
static int orgCount = 0;
public static void main(String[] args) throws IOException {
Scanner in = new Scanner(new FileReader("orgs.in"));
PrintWriter out = new PrintWriter(new FileWriter("orgs.out"));
int m = in.nextInt(), n = in.nextInt();
int[][] G = new int[m][n];
for (int i = 0; i < m; i++)
for (int j = 0; j < n; j++)
G[i][j] = in.nextInt();
for (int i = 0; i < m; i++)
for (int j = 0; j < n; j++)
if (G[i][j] == 1) {
orgCount++;
findOrg(G, i, j, m, n);
}
printOrg(out, G, m, n);
in.close(); out.close();
} // end main
public static void findOrg(int[][] G, int i, int j, int m, int n) {
if (i < 0 || i >= m || j < 0 || j >= n) return; //outside of grid
if (G[i][j] == 0 || G[i][j] > 1) return; //no cell or cell already seen
// else G[i][j] = 1;
G[i][j]= orgCount + 1; //so that this 1 is not considered again
findOrg(G, i - 1, j, m, n); //North
findOrg(G, i, j + 1, m, n); //East
findOrg(G, i + 1, j, m, n); //South
findOrg(G, i, j - 1, m, n); //West
} //end findOrg
public static void printOrg(PrintWriter out, int[][] G, int m, int n) {
out.printf("\nNumber of organisms = %d\n", orgCount);
out.printf("\nPosition of organisms are shown below\n\n");
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++)
if (G[i][j] > 1) out.printf("%2d ", G[i][j] - 1);
//organism labels are one more than they should be
else out.printf("%2d ", G[i][j]);
out.printf("\n");
}
} //end printOrg
} //end class Organisms
如果文件orgs.in
包含以下内容:
5 7
0 1 0 1 1 1 0
0 0 1 1 0 0 0
1 1 0 1 0 0 1
1 0 1 0 0 1 1
1 1 0 0 0 1 0
然后程序 P5.2 在文件orgs.out
中产生以下输出:
Number of organisms = 5
Position of organisms are shown below
0 1 0 2 2 2 0
0 0 2 2 0 0 0
3 3 0 2 0 0 4
3 0 5 0 0 4 4
3 3 0 0 0 4 0
考虑findOrg
如何识别生物体1
。在main
中,当i
= 0
和j
= 1
时,G[0][1]
为1
,那么findOrg(G, 0, 1, ...)
将与G
进行如下调用:
0 1 0 1 1 1 0
0 0 1 1 0 0 0
1 1 0 1 0 0 1
1 0 1 0 0 1 1
1 1 0 0 0 1 0
在findOrg
中,由于G[0][1]
是1
,它将被设置为2
,对findOrg
的四次调用将如下进行:
findOrg(G, -1, 1, ...); //immediate return since i < 0
findOrg(G, 0, 2, ...); //immediate return since G[0][2] is 0
findOrg(G, 1, 1, ...); //immediate return since G[1][1] is 0
findOrg(G, 0, -1, ...); //immediate return since j < 0
所有这些调用都会立即返回,因此只有G[0][1]
标有2
。
接下来,考虑findOrg
如何识别有机体 3。在main
中,当i
= 2
和j
= 0
,G[2][0]
为1
时,那么findOrg(G, 2, 0, ...)
将与G
进行如下调用(生物体2
将已经被标记为3
):
0 2 0 3 3 3 0
0 0 3 3 0 0 0
1 1 0 3 0 0 1
1 0 1 0 0 1 1
1 1 0 0 0 1 0
(记住,在这个阶段,生物体的标签比生物体的数量多1
。)对于这个例子,我们将使用符号 N、E、S 和 W(而不是下标)来分别表示北、东、南、西的网格位置。在这个阶段,orgCount
是3
,因此单元格将被标记为4
。
以下是从最初的findOrg(2, 0, ...)
到findOrg
的调用(为了清楚起见,我们省略了第一个参数G
):
findOrg(2, 0, ...) //G[2][0] is labeled with 4
findOrg(N...) //returns immediately since G[N] is 0
findOrg(E...) //G[E] is 1, relabeled with 4, gives rise to 4 calls
findOrg(N...) //returns immediately since G[N] is 0
findOrg(E...) //returns immediately since G[E] is 0
findOrg(S...) //returns immediately since G[S] is 0
findOrg(W...) //returns immediately since G[W] is 4
findOrg(S...) //G[S] is 1, relabeled with 4, gives rise to 4 calls
findOrg(N...) //returns immediately since G[N] is 4
findOrg(E...) //returns immediately since G[E] is 0
findOrg(S...) //G[S] is 1, relabeled with 4, gives rise to 4 calls
findOrg(N...) //returns immediately since G[N] is 4
findOrg(E...) //G[E] is 1, relabeled with 4, gives rise to 4 calls
findOrg(N...) //returns immediately since G[N] is 0
findOrg(E...) //returns immediately since G[E] is 0
findOrg(S...) //returns immediately since G[S] is outside grid
findOrg(W...) //returns immediately since G[W] is 4
findOrg(S...) //returns immediately since G[S] is outside grid
findOrg(W...) //returns immediately since G[W] is outside grid
findOrg(W...) //returns immediately since G[W] is outside grid
findOrg(W...) //returns immediately since G[W] is outside grid
当调用findOrg(2, 0, ...)
最终返回时,G
将改为:
0 2 0 3 3 3 0
0 0 3 3 0 0 0
4 4 0 3 0 0 1
4 0 1 0 0 1 1
4 4 0 0 0 1 0
第三种生物(标记为4
)已经被确认。请注意,生物体内的每个细胞都会对findOrg
发出四次呼叫。
5.9 在迷宫中寻找路径
考虑下面代表迷宫的图表:
##########
# # # #
# # # ## #
# # #
# ###### #
# # #S##
# ## #
##########
问题:从S
开始,沿着空旷的地方前进,试着找到一条走出迷宫的路。下面显示了如何用x
s 标记路径:
##########
# #xxx# #
# #x#x## #
#xxx#xxxx#
#x######x#
#x# #x##xx
#xxxxx## #
##########
我们想写一个程序,给定一个迷宫,确定路径是否存在。如果存在,用x
s 标记路径。
给定迷宫中的任何位置,都有四个可能的移动方向:北(N)、东(E)、南(S)和西(W)。如果你遇到一堵墙,你将不能向某个特定的方向移动。但是,如果有空地,你可以搬进去。
在编写程序时,我们将按照 N、E、S 和 w 的顺序尝试方向。我们将使用以下策略:
try N
if there is a wall, try E
else if there is a space, move to it and mark it with x
每当我们去一个开放的空间,我们重复这个策略。因此,举例来说,当我们向东走,如果有一个空间,我们标记它,并尝试从这个新位置的四个方向。
最终,我们会走出迷宫,或者我们会到达一个死胡同。例如,假设我们到达标记为 C 的位置:
##########
#C# # #
#B# # ## #
#A # #
#x###### #
#x# #x##
#xxxxx## #
##########
除了我们来的南边,其他方向都有墙。在这种情况下,我们回到先前的位置,并从那里尝试下一种可能性。在这个例子中,我们回到 C 以南的位置(称之为 B)。
当我们在 B 点的时候,我们应该通过尝试北向到达 C 点。既然这次失败了,那么当我们回到 B 时,我们将尝试“下一个”可能性,也就是东。由于有一堵墙,这就失败了。所以,我们试着往南走。这个失败了,因为我们已经去过了。最后,我们尝试了西方,失败了,因为有一堵墙。
所以,从 B,我们回到(我们说回溯)我们移动到 B 的位置(称之为 A)。
当我们回溯到 A 时,“下一个”可能性是东方。有一个空间,所以我们搬进去,用x
标记,从那里试第一个方向(北)。
当我们从一个失败的位置返回时,我们必须“取消标记”那个位置;也就是说,我们必须擦除x
。这是必要的,因为失败的位置将不是解决方案路径的一部分。
我们如何回溯?递归机制将为我们解决这个问题,就像“计算有机体”问题一样。以下伪代码显示了如何操作:
boolean findPath(P) {
//find a path from position P
if P is outside the maze, at a wall or considered already, return false
//if we get here, P is a space we can move into
mark P with x
if P is on the border of the maze, we are out of the maze; return true
//try to extend the path to the North; if successful, return true
if (findPath(N)) return true;
//if North fails, try East, then South, then West
if (findPath(E)) return true;
if (findPath(S)) return true;
if (findPath(W)) return true;
//if all directions fail, we must unmark P and backtrack
mark P with space
return false; //we have failed to find a path from P
} //end findPath
编写程序
首先,我们必须确定迷宫数据将如何提供。在刚才讨论的例子中,迷宫由八行十列组成。如果我们用 1 代表每面墙,用 0 代表每一个空间,迷宫就表示为:
1 1 1 1 1 1 1 1 1 1
1 0 1 0 0 0 1 0 0 1
1 0 1 0 1 0 1 1 0 1
1 0 0 0 1 0 0 0 0 1
1 0 1 1 1 1 1 1 0 1
1 0 1 0 1 0 1 1 0 0
1 0 0 0 0 0 1 1 0 1
1 1 1 1 1 1 1 1 1 1
起始位置S
位于第6
行第6
列。第一行数据将指定迷宫的行数和列数以及S
的坐标。因此,第一行数据将是这样的:
8 10 6 6
接下来是上面的迷宫数据。
当我们需要用x
标记一个位置时,我们将使用值2
。
我们的程序将从文件maze.in
中读取数据,并将输出发送到maze.out
。完整的程序如程序 P5.3 所示。
程序 p 5.3
import java.io.*;
import java.util.*;
public class Maze {
static int[][]G; //known to all methods
static int m, n, sr, sc; //known to all methods
public static void main(String[] args) throws IOException {
Scanner in = new Scanner(new FileReader("maze.in"));
PrintWriter out = new PrintWriter(new FileWriter("maze.out"));
getData(in);
if (findPath(sr, sc)) printMaze(out);
else out.printf("\nNo solution\n");
in.close(); out.close();
} // end main
public static void getData(Scanner in) {
m = in.nextInt(); n = in.nextInt();
G = new int[m+1][n+1];
sr = in.nextInt(); sc = in.nextInt();
for (int r = 1; r <= m; r++)
for (int c = 1; c <= n; c++)
G[r][c] = in.nextInt();
} //end getData
public static boolean findPath(int r, int c) {
if (r < 1 || r > m || c < 1 || c > n) return false;
if (G[r][c] == 1) return false; //into a wall
if (G[r][c] == 2) return false; //already considered
// else G[r][c] = 0;
G[r][c] = 2; //mark the path
if (r == 1 || r == m || c == 1 || c == n) return true;
//path found - space located on the border of the maze
if (findPath(r-1, c)) return true;
if (findPath(r, c+1)) return true;
if (findPath(r+1, c)) return true;
if (findPath(r, c-1)) return true;
G[r][c] = 0; //no path found; unmark
return false;
} //end findPath
public static void printMaze(PrintWriter out) {
int r, c;
for (r = 1; r <= m; r++) {
for (c = 1; c <= n; c++)
if (r == sr && c == sc) out.printf("S");
else if (G[r][c] == 0) out.printf(" ");
else if (G[r][c] == 1) out.printf("#");
else out.printf("x");
out.printf("\n");
}
} //end printMaze
} //end class Maze
假设文件maze.in
包含以下内容:
8 10 6 6
1 1 1 1 1 1 1 1 1 1
1 0 1 0 0 0 1 0 0 1
1 0 1 0 1 0 1 1 0 1
1 0 0 0 1 0 0 0 0 1
1 0 1 1 1 1 1 1 0 1
1 0 1 0 1 0 1 1 0 0
1 0 0 0 0 0 1 1 0 1
1 1 1 1 1 1 1 1 1 1
程序 P5.3 将把以下输出写到文件maze.out
:
##########
# #xxx# #
# #x#x## #
#xxx#xxxx#
#x######x#
#x# #S##xx
#xxxxx## #
##########
练习 5
-
写一个迭代函数返回第 n 个斐波那契数。
-
打印整数,用逗号分隔千位。例如,给定 12058,打印 12058。
-
A
是包含 n 个整数的数组。写一个递归函数,找出给定整数x
在A
中出现的次数。 -
写一个递归函数实现选择排序。
-
写一个递归函数来返回一个整数数组中最大的元素。
-
写一个递归函数在一个
int
数组中搜索一个给定的数字。 -
编写一个递归函数,在一个排序的
int
数组中搜索一个给定的数字。 -
调用下面的函数
W(0)
会产生什么输出?public static void W(int n) { System.out.printf("%3d", n); if (n < 10) W(n + 3); System.out.printf("%3d", n); }
-
调用下面的函数
S('C')
会产生什么输出?public static void S(char ch) { if (ch < 'H') { S(++ch); System.out.printf("%c ", ch); } }
-
在 9 中,如果互换
if
语句中的语句,会产生什么输出? -
在 9 中,如果
++ch
改成ch++
会怎么样? -
写一个递归函数
length
,给定一个指向链表的指针,返回链表中节点的数目。 -
写一个递归函数
sum
,给定一个指向整数链表的指针,返回链表节点值的和。 -
编写一个递归函数,给定一个指向整数链表头部的指针,如果链表是升序的,则返回
true
,否则返回false
。 -
编写一个递归方法,该方法采用一个整数参数,并在每个数字后打印一个空格。例如,给定
7583
,它打印7 5 8 3
。 -
下面这个递归函数的调用
fun(18, 3)
打印出来的是什么?
```java
public static void fun(int m, int n) {
if (n > 0) {
fun(m-1, n-1);
System.out.printf("%d ", m);
fun(m+1, n-1);
}
}
```
- 下面递归函数的调用
test(7, 2)
返回什么?
```java
public static int test(int n, int r) {
if (r == 0) return 1;
if (r == 1) return n;
if (r == n) return 1;
return test(n-1, r-1) + test(n-1, r);
}
```
- 考虑通常笛卡尔坐标系中的点( m , n ),其中 m 和 n 为正整数。在从 A 点到 B 点的东北路径中,只能向上和向右移动(不允许向下或向左移动)。写一个函数,给定任意两点 A 和 B 的坐标,返回从 A 到 B 的东北路径的号****
- The 8-queens problem can be stated as follows: place 8 queens on a chess board so that no two queens attack each other. Two queens attack each other if they are in the same row, same column or same diagonal. Clearly, any solution must have the queens in different rows and different columns.
我们可以如下解决这个问题。将第一个女王放在第一行的第一列。接下来,放置第二个女王,这样它就不会攻击第一个了。如果这是不可能的,请返回并将第一个皇后放在下一列中,然后重试。
前两个女王被放置后,放置第三个女王,这样它就不会攻击前两个。如果这是不可能的,请返回并将第二个皇后放在下一列中,然后重试。等等。
在每一步,试着放置下一个皇后,这样就不会与已经放置的皇后冲突。如果你成功了,尝试放置下一个皇后。如果你失败了,你必须*回溯*到先前放置的皇后,并尝试下一个可能的列。如果已经尝试了所有列,您必须回溯到这个女王的*之前的女王,并尝试下一列的*那个*女王。*
这个想法类似于在迷宫中寻找路径。写一个程序来解决 8 皇后问题。使用递归实现回溯。
- 写一个程序读取 n ( < = 10)并打印出 n 项的每一种可能的组合。例如,如果
n
=3
,则必须打印以下内容:
```java
1
1 2
1 2 3
1 3
2
2 3
3
```
六、随机数、游戏和模拟
在本章中,我们将解释以下内容:
- 随机数
- 随机数和伪随机数的区别
- 如何在计算机上生成随机数
- 如何写一个玩猜谜游戏的程序
- 如何写一个程序来训练用户算术
- 如何写一个程序来扮演尼姆
- 如何模拟收集瓶盖拼出一个单词
- 如何在现实生活中模拟队列
- 如何使用随机数估计数值
6.1 随机数
如果你掷出一个六面骰子 100 次,每次写下显示的数字,你将会写下 100 个随机的整数**均匀分布在 1 到 6 的范围内。
*如果你掷一枚硬币 144 次,每次都写下0
(正面)或1
(反面),你将写出 144 个均匀分布在 0 到 1 之间的随机整数。
如果你站在路边,当车辆经过时,你注意到了车牌号的最后两位数(对于那些至少有两位数的车辆),你会注意到均匀分布在 0 到 99 范围内的随机整数。
旋转轮盘(36 个数字)500 次。出现的 500 个数字是均匀分布在 1 到 36 范围内的随机整数。
random 这个词意味着任何结果都是完全独立于任何其他结果的。例如,如果一次掷骰子显示 5,那么这与下一次掷骰子显示什么没有关系。同样,轮盘赌上的 29 对下一个数字没有任何影响。
术语均匀分布是指所有值出现的可能性相等。在掷骰子的情况下,你有同样的机会掷出 1 或 6 或任何其他数字。而且,在大量的投掷中,每个数字出现的频率大致相同。
以一枚硬币为例,如果我们扔 144 次,我们会期望正面出现 72 次,反面出现 72 次。实际上,通常不会获得这些精确的值,但是如果硬币是公平的,那么这些值将足够接近预期值以通过某些统计测试。例如,75 个正面和 69 个反面的结果与期望值 72 非常接近,足以通过所需的测试。
随机数广泛用于模拟机会游戏(如涉及骰子、硬币或纸牌的游戏),玩教育游戏(如在算术中产生问题),以及在计算机上模拟现实生活中的情况。
例如,如果我们想玩一个蛇与梯子的游戏,掷骰子是由计算机生成一个从 1 到 6 的随机数来模拟的。假设我们想用从 1 到 9 的数字给一个孩子做附加题。对于每个问题,计算机可以生成 1 到 9 范围内的两个数字(例如,7 和 4 ),并将这些数字交给孩子进行加法运算。
但是假设我们想要模拟由交通灯控制的十字路口的交通模式。我们希望以这样一种方式安排灯的时间,使两个方向的等待时间尽可能短。为了在计算机上进行模拟,我们需要一些数据,比如车辆到达和离开十字路口的速度。为了使模拟尽可能有用,这必须通过观察来完成。
假设确定在方向 1 上行驶的随机数量的车辆(在 5 和 15 之间)每 30 秒到达交叉口。此外,每 30 秒钟就有 8 到 20 辆车朝方向 2 驶来。计算机可以如下模拟这种情况:
- 生成一个 5 到 15 之间的随机数
r1
。 - 生成一个 8 到 20 之间的随机数
r2
。
r1
和r2
为前 30 秒内每个方向到达路口的车辆数。该过程连续重复 30 秒。
6.2 随机数和伪随机数
掷出骰子时出现的数值对下一次掷出的数值没有影响。我们说抛出的结果是独立的,抛出的值是 1 到 6 范围内的随机整数。但是当一台计算机被用来在给定的时间间隔内生成一个随机数序列时,它使用了一种算法。
通常,序列中的下一个数字是以规定和预定的方式从前一个数字产生的。这意味着序列中的数字并不是相互独立的,就像我们掷骰子时那样。然而,生成的数字将通过通常的统计测试随机性,所以,实际上,它们是随机数。但是,因为它们是以一种非常可预测的方式生成的,所以它们通常被称为伪随机数。
在对许多类型的情况建模时,我们使用随机数还是伪随机数通常并不重要。事实上,在大多数应用中,伪随机数工作得相当令人满意。然而,考虑一个组织运行每周彩票,其中中奖号码是一个六位数。是否应该使用伪随机数发生器来提供一周到下一周的中奖号码?
由于生成器以完全预定的方式产生这些号码,因此有可能预测未来几周的中奖号码。显然,这是不可取的(除非你负责随机数发生器!).在这种情况下,需要一种真正随机的方法来产生中奖号码。
6.3 计算机生成随机数
在下文中,我们不区分随机数和伪随机数,因为在大多数实际应用中,没有必要进行区分。几乎所有的编程语言都提供了某种随机数生成器,但是它们的操作方式略有不同。
在 Java 中,我们可以使用Math
类中预定义的static
函数random
来处理随机数;random
产生随机分数(≥ 0.0 且< 1.0)。我们通过写Math.random()
来使用它。
实际上,我们很少在提供的形式中使用random
。这是因为,大多数时候,我们需要特定范围内的随机数(比如说从1
到6
)而不是随机分数。但是,我们可以轻松地编写一个函数,使用random
来提供从m
到n
的随机整数,其中m
< n
。这是:
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;
}
例如,调用random(1, 6)
将返回一个从 1 到 6 的随机整数,包括 1 和 6。如果m
= 1,n
= 6,那么n-m+1
就是 6。当 6 乘以一个从 0.0 到 0.999 的分数时...,我们得到一个从 0.0 到 5.999 的数....当用(int)
强制转换时,我们得到一个从 0 到 5 的随机整数。加 1 得到一个从 1 到 6 的随机整数。
再比如,假设m
= 5
和n
= 20
。在5
到20
范围内有20
–5
+1
=16
个数字。当 16 乘以一个从 0.0 到 0.999 的分数时...,我们得到一个从 0.0 到 15.999 的数....当用(int)
强制转换时,我们得到一个从 0 到 15 的随机整数。加 5 得到一个从 5 到 20 的随机整数。
程序 P6.1 将生成并打印从 1 到 6 的 20 个随机数。每次调用random
都会产生序列中的下一个数字。请注意,在另一台计算机上,或者在使用不同编译器的同一台计算机上,或者在不同时间运行,该顺序可能不同。
程序 P6.1
import java.io.*;
public class RandomTest {
public static void main(String[] args) throws IOException {
for (int j = 1; j <= 20; j++) System.out.printf("%2d", random(1, 6));
System.out.printf("\n");
} //end main
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;
} //end random
} //end class RandomTest
运行时,程序 P6.1 打印出以下数字序列:
4 1 5 1 3 3 1 3 1 3 6 2 3 6 5 1 3 1 1 1
当第二次运行时,它打印出以下序列:
6 3 5 6 6 5 6 3 5 1 5 2 4 1 4 1 1 5 5 5
每次运行时,都会生成不同的序列。
6.4 猜谜游戏
为了说明随机数的简单用法,让我们编写一个程序来玩一个猜谜游戏。程序会“思考”一个从 1 到 100 的数字。要求你尽可能少地猜测数字。以下是该程序的运行示例。带下划线的项目由用户键入:
I have thought of a number from 1 to 100.
Try to guess what it is.
Your guess? 50
Too low
Your guess? 75
Too high
Your guess? 62
Too high
Your guess? 56
Too low
Your guess? 59
Too high
Your guess? 57
Congratulations, you've got it!
正如你所看到的,每次你猜的时候,程序会告诉你你的猜测是太高还是太低,并允许你再猜一次。
程序会通过调用random(1, 100)
“想到”一个从 1 到 100 的数字。你会一直猜,直到你猜对了,或者直到你放弃。你放弃输入0
作为你的猜测。程序 P6.2 包含所有细节。
程序 P6.2
import java.util.*;
public class GuessTheNumber {
public static void main(String[] args) {
Scanner in = new Scanner(System.in);
System.out.printf("\nI have thought of a number from 1 to 100.\n");
System.out.printf("Try to guess what it is.\n\n");
int answer = random(1, 100);
System.out.printf("Your guess? ");
int guess = in.nextInt();
while (guess != answer && guess != 0) {
if (guess < answer) System.out.printf("Too low\n");
else System.out.printf("Too high\n");
System.out.printf("Your guess? ");
guess = in.nextInt();
}
if (guess == 0) System.out.printf("Sorry, answer is %d\n", answer);
else System.out.printf("Congratulations, you've got it!\n");
} //end main
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;
} //end random
} //end class GuessTheNumber
编程注意事项:提醒用户可以选择放弃以及如何放弃是个好主意。为此,提示可以如下:
Your guess (0 to give up)?
6.5 附加演练
我们想写一个程序来训练用户简单的算术问题(程序 P6.3 )。更具体地说,我们想写一个程序来为用户解决附加问题。这些问题将涉及两个数的相加。但是这些数字从何而来呢?我们将让计算机“思考”这两个数字。到现在,你应该知道,为了做到这一点,计算机会生成两个随机数。
我们还需要决定在问题中使用多大的数字。在某种程度上,这将决定问题的难度。我们将使用两位数,即从 10 到 99 的数字。该程序可以很容易地修改,以处理不同范围内的数字。
该程序将首先询问用户他希望给出多少个问题。用户将键入所需的号码。然后他会被问及每个问题他想尝试多少次。他会输入这个号码。然后程序继续给他所要求的问题数量。
以下是该程序的运行示例。带下划线的项目由用户键入;其他的都是电脑打出来的。
Welcome to Problems in Addition
How many problems would you like?3
Maximum tries per problem?2
Problem 1, Try 1 of 2
80 + 75 =155
Correct, well done!
Problem 2, Try 1 of 2
17 + 29 =36
Incorrect, try again
Problem 2, Try 2 of 2
17 + 29 =46
Correct, well done!
Problem 3, Try 1 of 2
83 + 87 =160
Incorrect, try again
Problem 3, Try 2 of 2
83 + 87 =180
Sorry, answer is 170
Thank you for playing. Bye...
所有细节显示在程序 P6.3 中。为了简洁起见,我们没有验证用户提供的输入。然而,强烈建议验证所有的用户输入,以确保你的程序尽可能的健壮。
程序 P6.3
import java.util.*;
public class Arithmetic {
public static void main(String[] args) {
Scanner in = new Scanner(System.in);
System.out.printf("\nWelcome to Problems in Addition\n\n");
System.out.printf("How many problems would you like? ");
int numProblems = in.nextInt();
System.out.printf("Maximum tries per problem? ");
int maxTries = in.nextInt();
giveProblems(in, numProblems, maxTries);
System.out.printf("\nThank you for playing. Bye...\n");
} //end main
public static void giveProblems(Scanner in, int amount, int maxTries) {
int num1, num2, answer, response, tri; //'tri' since 'try' is a reserved word
for (int h = 1; h <= amount; h++) {
num1 = random(10, 99);
num2 = random(10, 99);
answer = num1 + num2;
for (tri = 1; tri <= maxTries; tri ++) {
System.out.printf("\nProblem %d, Try %d of %d\n", h, tri, maxTries);
System.out.printf("%5d + %2d = ", num1, num2);
response = in.nextInt();
if (response == answer) {
System.out.printf("Correct, well done!\n");
break;
}
if (tri < maxTries) System.out.printf("Incorrect, try again\n");
else System.out.printf("Sorry, answer is %d\n", answer);
} //end for tri
} //end for h
} //end giveProblems
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;
} //end random
} //end class Arithmetic
6.6 之前
比如说,一个叫做 Nim 的游戏版本是在两个人 A 和 B 之间进行的。最初,桌子上有已知数量的火柴(startAmount
)。每个玩家依次被允许选择任意数量的比赛,从 1 场到某个约定的最大值(比如说maxPick
)。捡起最后一根火柴的玩家输掉游戏。
例如,如果startAmount
是20
,maxPick
是3
,游戏可能如下进行:
a 拿起 2,桌上剩下 18。
b 拿起 1,桌上剩下 17。
a 拿起 3,桌上剩下 14。
b 拿起 1,桌上剩下 13。
a 拿起 2,桌上剩下 11。
b 拿起 2,桌上剩下 9。
a 拿起 1,桌上剩下 8。
b 拿起 3,桌上剩下 5。
a 拿起 1,桌上剩下 4。
b 拿起 3,桌上剩下 1。
a 被迫捡起最后一根火柴,因此输掉了比赛。
玩这个游戏最好的方法是什么?显然,目标应该是让你的对手还剩一场比赛。姑且称此为失位。下一个要回答的问题是,你必须留下多少根火柴,这样无论他捡了多少根(在游戏规则范围内),你都可以留给他一根?
在这个例子中,答案是 5。不管他拿了 1、2 还是 3,你都可以给他 1。如果他捡 1,你捡 3;如果他拿起 2,你拿起 2;如果他拿起 3,你拿起 1。因此,5 是下一个失败的位置。
下一个问题是,你必须留下多少场比赛,这样,无论他捡了多少场(在游戏规则范围内),你都可以留给他 5 场?答案是 9。试试看!
等等。这样推理,我们发现 1,5,9,13,17,等等,都在亏损。换句话说,如果你能给你的对手留下这些数量的比赛,你就能迫使对手获胜。
在这个例子中,当 B 离开有 17 场比赛的 A 时,B 处于一个不会输的位置,除非他变得粗心大意。
一般来说,损失头寸是通过在maxPick+1
的倍数上加 1 得到的。如果maxPick
是3
,4 的倍数就是 4、8、12、16 等等。加 1 得到失败的位置 5、9、13、17 等等。
我们将编写一个程序,让计算机尽可能玩最好的 Nim 游戏。如果它能迫使用户处于亏损状态,它就会这么做。如果用户已经迫使它进入一个失败的位置,它将随机挑选一些匹配,并希望用户出错。
如果remain
是桌面上剩余的匹配数,计算机如何确定最好的走法?
如果remain
小于或等于maxPick
,计算机会选择remain-1
匹配,给用户留下 1。否则,我们执行以下计算:
r = remain % (maxPick + 1)
如果r
是0
,remain
是maxPick+1
的倍数;电脑选择maxPick
匹配,让用户处于失败的境地。在这个例子中,如果remain
是 16(4 的倍数),计算机拿起 3,留给用户 13——一个失败的位置。
如果r
是1
,则计算机处于输的位置,拾取随机数量的匹配。
否则,计算机会选择r-1
匹配,让用户处于失败的境地。在这个例子中,如果remain
是 18,r
就是 2。电脑得到 1,留给用户 17,这是一个失败的位置。
这个策略是在函数bestPick
中实现的,它是程序 P6.4 的一部分,在我们的 Nim 版本中,它让计算机与用户进行竞争。
程序 p 6.4
import java.util.*;
public class Nim {
public static void main(String[] args) {
Scanner in = new Scanner(System.in);
System.out.printf("\nNumber of matches on the table? ");
int remain = in.nextInt();
System.out.printf("Maximum pickup per turn? ");
int maxPick = in.nextInt();
playGame(in, remain, maxPick);
} //end main
public static void playGame(Scanner in, int remain, int maxPick) {
int userPick;
System.out.printf("\nMatches remaining: %d\n", remain);
while (true) { //do forever...well, until the game ends
do {
System.out.printf("Your turn: ");
userPick = in.nextInt();
if (userPick > remain)
System.out.printf("Cannot pick up more than %d\n", Math.min(remain, maxPick));
else if (userPick < 1 || userPick > maxPick)
System.out.printf("Invalid: must be between 1 and %d\n", maxPick);
} while (userPick > remain || userPick < 1 || userPick > maxPick);
remain = remain - userPick;
System.out.printf("Matches remaining: %d\n", remain);
if (remain == 0) {
System.out.printf("You lose!!\n"); return;
}
if (remain == 1) {
System.out.printf("You win!!\n"); return;
}
int compPick = bestPick(remain, maxPick);
System.out.printf("I pick up %d\n", compPick);
remain = remain - compPick;
System.out.printf("Matches remaining: %d\n", remain);
if (remain == 0) {
System.out.printf("You win!!\n");
return;
}
if (remain == 1) {
System.out.printf("I win!!\n");
return;
}
} //end while (true)
} //end playGame
public static int bestPick(int remain, int maxPick) {
if (remain <= maxPick) return remain - 1; //put user in losing position
int r = remain % (maxPick + 1);
if (r == 0) return maxPick; //put user in losing position
if (r == 1) return random(1, maxPick); //computer in losing position
return r - 1; //put user in losing position
} //end bestPick
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;
} //end random
} //end class Nim
注意使用了do...while
语句来获取和验证用户的游戏。一般形式如下:
do <statement> while (<expression>);
像往常一样,<statement>
可以是简单的(一行)也可以是复合的(用大括号括起来)。单词do
和while
以及括号和分号是必需的。程序员提供<statement>
和<expression>
。A do...while
执行如下:
<statement>
被执行。- 然后对
<expression>
进行评估;如果是true
,从步骤 1 开始重复。如果是false
,则继续执行分号后的语句(如果有)。
只要<expression>
是true
,就会执行<statement>
。值得注意的是,由于构造的性质,<statement>
总是至少执行一次。这在我们希望<statement>
至少被执行一次的情况下特别有用。在这个例子中,我们需要至少提示用户一次他的游戏,这就是do...while
的原因。
*以下是程序 P6.4 的运行示例:
Number of matches on the table?30
Maximum pickup per turn?5
Matches remaining: 30
Your turn: 2
Matches remaining: 28
I pick up 3
Matches remaining: 25
Your turn: 3
Matches remaining: 22
I pick up 3
Matches remaining: 19
Your turn: 6
Invalid: must be between 1 and 5
Your turn: 1
Matches remaining: 18
I pick up 5
Matches remaining: 13
Your turn: 4
Matches remaining: 9
I pick up 2
Matches remaining: 7
Your turn: 9
Cannot pick up more than 5
Your turn: 2
Matches remaining: 5
I pick up 4
Matches remaining: 1
I win!!
我们注意到,顺便说一下,当游戏运行时,为它提供指令是有用的。
6.7 不均匀分布
到目前为止,我们生成的随机数在给定的范围内是均匀分布的。例如,当我们生成从 10 到 99 的数字时,该范围内的每个数字都有相同的机会被生成。类似地,调用random(1, 6)
将以相等的概率生成数字 1 到 6。
现在假设我们想让计算机“扔”一个六面骰子。由于计算机不能物理投掷骰子,所以它必须模拟投掷的过程。扔骰子的目的是什么?简单来说就是想出一个从 1 到 6 的随机数。正如我们所看到的,计算机知道如何做到这一点。
如果骰子是公平的,那么每个面都有相同的机会出现。要模拟这样一个骰子的投掷,我们要做的就是生成均匀分布在 1 到 6 范围内的随机数。我们可以用random(1, 6)
做到这一点。
同样,当我们掷一枚公平的硬币时,正面和反面都有相同的机会出现。要在计算机上模拟这样一枚硬币的投掷,我们所要做的就是生成均匀分布在1
到2
范围内的随机数。我们可以让1
代表头2
代表尾。
一般来说,如果一个事件所有可能发生的情况(比如掷出一个公平的骰子)发生的概率相等,我们可以用均匀分布的随机数来模拟这个事件。然而,如果所有事件发生的可能性不同,我们如何模拟这样的事件呢?
举个例子,考虑一个偏向的硬币,它出现正面的几率是反面的两倍。我们说正面的概率是 2/3,反面的概率是 1/3。为了模拟这样的硬币,我们生成均匀分布在范围1
到3
内的随机数。如果1
或2
发生,我们说人头被抛出;如果3
发生,我们说尾巴被抛出。
因此,为了模拟具有非均匀分布的事件,我们将其转换为可以使用均匀分布随机数的事件。
再举一个例子,假设对于给定月份(比如说六月)的任何一天,我们知道以下情况,并且只有这些情况是可能的:
probability of sun = 4/9
probability of rain = 3/9
probability of overcast = 2/9
我们可以模拟六月的天气如下:
for each day in June
r = random(1, 9)
if (r <= 4) "the day is sunny"
else if (r <= 7) "the day is rainy"
else "the day is overcast"
endfor
我们顺便注意到,我们可以将任意四个数字指定为晴天,任意三个数字指定为雨天,剩下的两个数字指定为阴天。
收集瓶盖
一种流行饮料的制造商正在举办一场比赛,你必须收集瓶盖才能拼出单词 MANGO 。已知每 100 个瓶盖中,有 40 个 A s,25 个 O s,15 个 N s,15 个 M s,5 个 G s,我们想写一个程序,对收集的瓶盖进行 20 次模拟,直到有足够的瓶盖拼出芒果为止。对于每个模拟,我们想知道收集了多少个 cap。我们还想知道每次模拟收集的瓶盖的平均数量。
瓶盖的收集是一个分布不均匀的事件。收集一个一个比收集一个 G 容易。为了模拟该事件,我们可以生成均匀分布在 1 到 100 范围内的随机数。要确定收集了哪封信,我们可以使用:
c = random(1, 100)
if (c <= 40) we have an A
else if (c <= 65) we have an O
else if (c <= 80) we have an N
else if (c <=95) we have an M
else we have a G
在本例中,如果需要,我们可以将所有内容缩放 5 倍,并使用以下内容:
c = random(1, 20)
if (c <= 8) we have an A
else if (c <= 13) we have an O
else if (c <= 16) we have an N
else if (c <=19) we have an M
else we have a G
两个版本都可以很好地解决这个问题。
解决这个问题的算法要点如下:
totalCaps = 0
for sim = 1 to 20
capsThisSim = perform one simulation
print capsThisSim
add capsThisSim to totalCaps
endfor
print totalCaps / 20
执行一次模拟的逻辑如下:
numCaps = 0
while (word not spelt) {
collect a cap and determine the letter
mark the letter collected
add 1 to numCaps
}
return numCaps
我们将使用一个数组cap[5]
来保存每个字母的状态:cap[0]
代表 A ,cap[1]
代表 O ,cap[2]
代表 N ,cap[3]
代表 M ,cap[4]
代表 G 。值0
表示相应的字母没有被收集。当我们收集一个 N 时,比方说,我们将cap[2]
设置为1
;我们对其他字母也是如此。当cap
的所有元素都是1
时,我们已经收集了每个字母至少一次。
所有这些细节都包含在程序 P6.5 中。
程序 P6.5
public class BottleCaps {
static int MaxSim = 20;
static int MaxLetters = 5;
public static void main(String[] args) {
int sim, capsThisSim, totalCaps = 0;
System.out.printf("\nSimulation Caps collected\n\n");
for (sim = 1; sim <= MaxSim; sim++) {
capsThisSim = doOneSimulation();
System.out.printf("%6d %13d\n", sim, capsThisSim);
totalCaps += capsThisSim;
}
System.out.printf("\nAverage caps per simulation: %d\n", totalCaps/MaxSim);
} //end main
public static int doOneSimulation() {
boolean[] cap = new boolean[MaxLetters];
for (int j = 0; j < MaxLetters; j++) cap[j] = false;
int numCaps = 0;
while (!mango(cap)) {
int c = random(1, 20);
if (c <= 8) cap[0] = true;
else if (c <= 13) cap[1] = true;
else if (c <= 16) cap[2] = true;
else if (c <= 19) cap[3] = true;
else cap[4] = true;
numCaps++;
} //end while
return numCaps;
} //end doOneSimulation
public static boolean mango(boolean[] cap) {
for (int j = 0; j < MaxLetters; j++)
if (cap[j] == false) return false;
return true;
} //end mango
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;
} //end random
} //end class BottleCaps
运行时,该程序产生以下输出:
Simulation Caps collected
1 10
2 10
3 22
4 12
5 36
6 9
7 15
8 7
9 11
10 70
11 17
12 12
13 27
14 10
15 6
16 25
17 8
18 7
19 39
20 71
Average caps per simulation: 21
结果从少至 6 个上限到多达 71 个上限不等。有时候你会走运,有时候不会。
程序每次运行,都会产生不同的结果。
6.8 现实问题的模拟
通过使用模拟,计算机可以用来回答关于许多现实生活情况的某些问题。模拟的过程允许我们考虑一个问题的不同解决方案。这使我们能够满怀信心地选择特定情况下的最佳替代方案。
然而,在计算机模拟完成之前,我们需要收集数据以使模拟尽可能真实。例如,如果我们想模拟在银行服务客户,我们需要知道(或至少估计)以下内容:
- 队列中顾客到达的时间间隔 t1
- 服务客户的时间 t2
当然, t1 可以变化很大。这将取决于,例如,一天的时间;在某些时候,顾客会比其他时候来得更频繁。此外,不同的客户有不同的需求,所以 t2 会因客户而异。然而,通过观察系统运行一段时间,我们通常可以做出如下假设:
- t1 在一至五分钟之间随机变化。
- t2 在三到十分钟之间随机变化。
使用这些假设,我们可以进行模拟,找出当有 2 个、3 个、4 个,...等等,服务柜台。我们假设有一个队列;排在队伍最前面的人去最先有空位的柜台。在实践中,银行通常在高峰期比淡季分配更多的柜台。在这种情况下,我们可以使用适用于每个时期的假设,分两部分进行模拟。
以下是类似模拟方法适用的其他情况:
-
超市或商店的收银台:我们通常对收银台的数量和平均排队长度之间的折衷感兴趣。我们的柜台越少,队伍就越长。然而,拥有更多的柜台意味着更多的机器和更多的员工。我们希望在运营成本和客户服务之间找到最佳平衡点。
-
加油站:多少台泵最能满足顾客的需求?
-
红绿灯:什么是最佳的红绿灯时间,使各个方向的平均排队长度保持最小?在这种情况下,我们需要收集如下数据:
-
How often do cars arrive from direction 1 and from direction 2? The answer to this might be something like this:
1 号方向每分钟有 5 到 15 辆车到达。
每分钟有 10 到 30 辆车从 2 号方向开来。
-
How fast can cars leave in direction 1 and in direction 2? The answer might be as follows:
20 辆车可以在 30 秒内穿过方向 1 的十字路口。
30 辆车可以在 30 秒内穿过方向 2 的十字路口。
我们假设,在这个简单的情况下,转弯是不允许的。
6.9 模拟队列
考虑一下银行或超市收银台的情况,顾客到达后必须排队等候服务。假设有一个队列,但有几个计数器。如果一个柜台是空的,排在队伍前面的人就去那里。如果所有柜台都忙,顾客必须等待;排在队伍最前面的人去第一个有空位的柜台。
举例来说,假设有两个计数器;我们用 C1 和 C2 来表示他们。为了进行模拟,我们需要知道顾客到达的频率和服务一个顾客需要的时间。根据观察和经验,我们可以说:
- 顾客到达的时间间隔从一分钟到五分钟不等。
- 为顾客服务的时间从三分钟到十分钟不等。
为了使模拟有意义,这些数据必须接近实际发生的情况。一般来说,模拟的好坏取决于它所基于的数据。
假设我们从上午 9 点开始。我们可以通过生成十个从 1 到 5 的随机数来模拟前十个客户的到来,如下所示:
3 1 2 4 2 5 1 3 2 4
这意味着第一个客户在 9:03 到达,第二个在 9:04,第三个在 9:06,第四个在 9:10,依此类推。我们可以通过生成十个从 3 到 10 的随机数来模拟这些客户的服务时间,如下所示:
5 8 7 6 9 4 7 4 9 6
这意味着第一个顾客在出纳员那里花了五分钟,第二个花了八分钟,第三个花了七分钟,等等。
表 6-1 显示了这十个客户的情况。
表 6-1 。跟踪十个客户
- 第一个顾客在 9:03 到达,然后直接去了 C1。他的发球时间是五分钟,所以他将在 9:08 离开 C1。
- 第二个顾客在 9:04 到达,然后直接去了 C2。他的服务时间是 8 分钟,所以他将在 9:12 离开 C2。
- 第三个顾客在 9:06 到达。此时,C1 和 C2 都很忙,所以他必须等待。C1 将在 9:08 第一个获得自由。这位顾客将在 9:08 开始服务。他的服务时间是 7 分钟,所以他将在 9:15 离开 C1。这位顾客不得不排队等了两分钟。
- 第四个顾客在 9:10 到达。此时,C1 和 C2 都很忙,所以他必须等待。C2 将在 9:12 第一个获得自由。这位顾客将在 9:12 开始服务。他的发球时间是 6 分钟,所以他将在 9:18 离开 C2。这位顾客不得不排队等了两分钟。
等等。完成表格的其余部分,确保您理解这些值是如何获得的。
还要注意,一旦柜员开始服务,他们就没有空闲时间了。一个顾客刚走,另一个顾客就在等着接受服务。
6.9.1 编程模拟
我们现在展示如何编写一个程序来产生表 6-1 。首先,我们注意到为几个计数器编写程序并不比为两个计数器编写程序更困难。因此,我们将假设有n
( n
< 10
)计数器。对于这个特殊的例子,我们将把n
设置为 2。
我们将使用一个数组depart[10]
,这样depart[c]
将保存计数器c
下一次空闲的时间。我们不会用depart[0]
。如果我们需要处理九个以上的计数器,我们只需要增加depart
的大小。
假设排在队伍最前面的顾客在arriveTime
到达。他会去第一个免费柜台。如果最后一个顾客离开c
柜台后到达,即arriveTime
大于等于depart[c]
,则c
柜台空闲。如果没有空闲的柜台,他必须等待。他会先去那个会变成空闲的计数器,也就是数组depart
中数值最低的那个;假设这是depart[m]
。他将在arriveTime
和depart[m]
中较晚的一个时间开始服役。
该程序首先询问要模拟的柜台数量和顾客数量。模拟从时间0
开始,所有时间都与此相关。详情见程序 P6.6 。
程序 P6.6
import java.util.*;
public class SimulateQueue {
public static void main(String[] args) {
Scanner in = new Scanner(System.in);
System.out.printf("\nHow many counters? ");
int numCounters = in.nextInt();
System.out.printf("\nHow many customers? ");
int numCustomers = in.nextInt();
doSimulation(numCounters, numCustomers);
} //end main
public static void doSimulation(int counters, int customers) {
int m, arriveTime, startServe, serveTime, waitTime;
int[] depart = new int[counters + 1];
for (int h = 1; h <= counters; h++) depart[h] = 0;
System.out.printf("\n Start Service Wait\n");
System.out.printf("Customer Arrives Service Counter Time Departs Time\n\n");
arriveTime = 0;
for (int h = 1; h <= customers; h++) {
arriveTime += random(1, 5);
m = smallest(depart, 1, counters);
startServe = Math.max(arriveTime, depart[m]);
serveTime = random(3, 10);
depart[m] = startServe + serveTime;
waitTime = startServe - arriveTime;
System.out.printf("%5d %8d %7d %6d %7d %8d %5d\n",
h, arriveTime, startServe, m, serveTime, depart[m], waitTime);
} //end for h
} //end doSimulation
public static int smallest(int list[], int lo, int hi) {
//returns the subscript of the smallest value from list[lo..hi]
int h, k = lo;
for (h = lo + 1; h <= hi; h++)
if (list[h] < list[k]) k = h;
return k;
}
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;
} //end random
} //end class SimulateQueue
这里显示了一个运行程序 P6.6 的示例:
How many counters? 2
How many customers? 10
Start Service Wait
Customer Arrives Service Counter Time Departs Time
1 3 3 1 8 11 0
2 7 7 2 9 16 0
3 10 11 1 9 20 1
4 11 16 2 4 20 5
5 14 20 1 5 25 6
6 19 20 2 9 29 1
7 23 25 1 7 32 2
8 26 29 2 8 37 3
9 29 32 1 7 39 3
10 33 37 2 6 43 4
如您所见,等待时间相当短。但是,如果您对 25 个客户运行模拟,您将会看到等待时间明显增加。如果我们再增加一个计数器呢?通过模拟,很容易测试这种效果,而不必实际购买另一台机器或雇用另一名员工。
在这种情况下,我们所要做的就是分别输入3
和25
作为柜台和顾客的数量。当我们这样做的时候,我们会发现等待的时间很少。我们建议你用不同的数据——柜台、顾客、到达时间和服务时间——进行实验,看看会发生什么。
6.10 使用随机数估计数值
我们已经看到了如何使用随机数来玩游戏和模拟现实生活中的情况。一个不太明显的用途是估计难以计算或计算起来很麻烦的数值。我们将展示如何使用随机数来估计一个数的平方根和π (pi)。
6.10.1 估算
我们使用随机数根据以下公式估算 5 的平方根:
- 它在两点和三点之间。
- x 小于
如果x2 小于 5。
- 生成 2 到 3 之间的带分数的随机数。对那些小于
的数字进行计数。
- 设
maxCount
为 2 到 3 之间产生的随机数总数。用户将提供maxCount
。 - 设
amountLess
是小于的那些数的计数。 给出了
的近似值
为了理解该方法背后的思想,考虑 2 和 3 之间的线段,让点r
表示 5 的平方根。
如果我们想象 2 和 3 之间的线完全被点覆盖,我们会期望 2 和r
之间的点的数量与该线段的长度成比例。一般来说,落在任何线段上的点数都与该线段的长度成正比,线段越长,落在其上的点数就越多。
现在,2 到 3 之间的每个随机数代表那条线上的一个点。我们期望使用的数字越多,2 和r
之间的线的长度与落在上面的数字的数量成正比的说法就越准确,因此,我们的估计就越准确。
程序 P6.7 基于此方法计算出的估算值。记住
Math.random
生成一个随机分数。
当运行 1000 个数字时,这个程序给出 2.234 作为 5 的平方根。的值是 2.236 到小数点后三位。
程序 P6.7
import java.util.*;
public class Root5 {
public static void main(String[] args) {
Scanner in = new Scanner(System.in);
System.out.printf("\nHow many numbers to use? ");
int maxCount = in.nextInt();
int amountLess = 0;
for (int j = 1; j <= maxCount; j++) {
double r = 2 + Math.random();
if (r * r < 5) ++amountLess;
}
System.out.printf("\nThe square root of 5 is about %5.3f\n",
2 + (double) amountLess / maxCount);
} //end main
} //end class Root5
估算π
考虑图 6-1 ,它显示了一个正方形内的一个圆。
图 6-1 。在正方形内画圈
如果你闭上眼睛,继续用铅笔反复戳图,你可能会得到类似于图 6-2 的东西(只考虑落在图中的点)。
图 6-2 。用铅笔戳后在正方形内画圈
请注意,有些圆点落在圆圈内,有些落在圆圈外。如果这些点是“随机”形成的,那么似乎有理由预计圆内的点数与圆的面积成正比——圆越大,落入其中的点就越多。
基于此,我们有以下近似值:
请注意,正方形内的点数也包括圆形内的点数。如果我们想象整个正方形充满了点,那么前面的近似将是相当准确的。我们现在展示如何使用这个想法来估计π。
考虑图 6-3 。
图 6-3 。四分之一圆和一个正方形
- c 是半径为 1 的四分之一圆;s 是边长为 1 的正方形。
- 面积 C =
面积 S = 1。
- C 内的一点(x,y)满足 x 2 + y 2 ≤ 1,x ≥ 0,y ≥ 0。
- S 内的点(x,y)满足 0 ≤ x ≤ 1,0 ≤ y ≤ 1。
假设我们生成两个随机分数,即 0 和 1 之间的两个值;称这些值为 x 和 y 。
由于 0 ≤ x ≤ 1,且 0 ≤ y ≤ 1,因此该点( x , y )位于 s 内
如果 x 2 + y 2 ≤ 1,该点也将位于 C 内。
如果我们生成 n 对随机分数,我们实际上在 s 内生成了 n 个点。对于这些点中的每一个,我们可以确定该点是否位于 c 内。假设这些 n 个点中的 m 落在 c 内。从我们的讨论中,我们可以假设以下近似成立:
C 的面积为,S 的面积为 1。因此,以下成立:
因此:
基于此,我们编写程序 P6.8 估算π。
程序 P6.8
import java.util.*;
public class Pi {
public static void main(String[] args) {
Scanner in = new Scanner(System.in);
int inC = 0;
System.out.printf("\nHow many numbers to use? ");
int inS = in.nextInt();
for (int j = 1; j <= inS; j++) {
double x = Math.random();
double y = Math.random();
if (x * x + y * y <= 1) inC++;
}
System.out.printf("\nAn approximation to pi is %5.3f\n", 4.0 * inC/inS);
} //end main
} //end class Pi
π到小数点后 3 位的值是 3.142。当运行 1000 个数字时,这个程序给出 3.132 作为π的近似值。当运行 2000 个数字时,它给出的近似值为 3.140。
练习 6
- 写一个程序请求两个数字, m 和 n ,打印从 m 到 n 的 25 个随机数。
- 解释随机数和伪随机数的区别。
- 修改程序 P6.3 给用户减法题。
- 修改程序 P6.3 给用户出乘法题。
- 修改程序 P6.3 以纳入评分系统。例如,对一个问题进行两次尝试,第一次尝试的正确答案可以给 2 分,第二次尝试的正确答案可以给 1 分。
- 重写程序 P6.3 ,让它为用户提供一个菜单,允许用户选择他得到的问题类型(加法、减法或乘法)。
- 编写一个程序来模拟 1000 次掷骰子,并确定所显示的 1、2、3、4、5 和 6 的个数。写程序时(a)不使用数组,( b)使用数组。
- 用 6.7 节中的概率写一个程序来模拟 60 天的天气。
- 在电灯泡的制造中,灯泡有缺陷的概率是 0.01。模拟制造 5000 个灯泡,说明有多少是次品。
- 骰子的权重是 1 和 5 出现的频率是其他数字的两倍。模拟 1000 次投掷,指出每个数字出现的频率。
- 修改程序 P6.6 计算顾客平均等待时间和每个柜台的总空闲时间。
- One-Zero is a game that can be played among several players using a six-sided die. On his turn, a player can throw the die as many times as he wants. His score for that turn is the sum of the numbers he throws provided he does not throw a 1. If he throws a 1, his score is 0. Suppose a player decides to adopt the strategy of ending his turn after seven throws. (Of course, if he throws a 1 before the 7th throw, he must end his turn.) Write a program to play 10 turns using this strategy. For each turn, print the score obtained. Also, print the average score for the 10 turns.
将程序一般化,以请求`numTurns`和`maxThrowsPerTurn`的值,并按照描述打印结果。
- Write a program to simulate the game of Snakes and Ladders. The board consists of 100 squares. Snakes and ladders are input as ordered pairs of numbers, m and n. For example, the pair
17 64
means that there is a ladder from 17 to 64, and the pair99 28
means that there is a snake from 99 to 28.
模拟玩 20 个游戏,每个游戏持续最多 100 步。打印在 100 步中完成的游戏数以及已完成游戏的平均每局移动数。
- 写一个程序玩一个修改过的 Nim 游戏(第 6.6 节),游戏中有两堆火柴,一个玩家可以从其中选择一个。然而,在这种情况下,如果玩家选择了最后一场比赛,他就赢了。
- 使用第 6.8 节中的交通灯数据,编写一个程序来模拟 30 分钟内交通灯的情况。每次信号灯改变时,打印每个队列中的汽车数量。
- 写一个程序估计 59 的平方根。
- 写程序读取正整数 n 并估计 n 的平方根。
- 写程序读取正整数 n 并估计 n 的立方根。
- 写个程序模拟收集瓶盖拼出苹果。在每 100 个 cap 中,A 和 E 各出现 40 次,P 出现 10 次,L 出现 10 次。进行 50 次模拟,并打印每次模拟的平均瓶盖数。
- 彩票要求人们从数字 1 到 40 中选择 7 个数字。编写一个程序,随机生成并打印五组数字,每组七个(每行一组)。在任何集合中没有数字是重复的;也就是说,必须使用 40 个数字中的 35 个。如果产生了一个已经被使用的数( p ),则使用在 p 之后的第一个未使用的数。(假设 1 跟 40。)例如,如果生成了 15 但已经被使用,则尝试 16,但如果已经被使用,则尝试 17,依此类推,直到找到未使用的号码。
- 为 0 ≤ x ≤ 1 定义一个函数 f(x) ,使得对于所有 0 ≤ x < 1,0 ≤ f(x) < 1。写个程序估计 f(x) 从 0 到 1 的积分。提示:通过生成点( x , y ),0 ≤ x < 1,0 ≤ y < 1,估计曲线下的面积。
- 一个赌徒支付 5 美元玩下面的游戏。他掷出两个六面骰子。如果掷出的两个数之和是偶数,他就输了。如果总数是奇数,他从标准的 52 张扑克牌中抽出一张。如果他抽到了一张 ace,3、5、7 或 9,他将得到该卡的价值加上 5 美元(ace 计为 1)。如果他抽任何一张牌,他就输了。编写一个程序来模拟玩 20 个游戏,并打印出游戏者每局的平均赢款。**
七、使用文件
在本章中,我们将解释以下内容:
- 文本文件和二进制文件的区别
- 内部文件名和外部文件名的区别
- 如何写一个程序来比较两个文本文件
try . . . catch
构造- 如何对二进制文件进行输入/输出
- 如何使用记录的二进制文件
- 什么是随机存取文件
- 如何从随机存取文件中创建和检索记录
- 什么是索引文件
- 如何使用索引更新随机存取文件
7.1 Java 中的输入/输出
Java 有广泛的用于执行输入/输出的类。我们已经使用System.in
和System.out
分别从标准输入读取和写入标准输出。
例如,我们使用了以下代码从文件中读取数据:
Scanner in = new Scanner(new FileReader("input.txt"));
此外,我们还使用了以下代码将输出发送到文件中,output.txt:
PrintWriter out = new PrintWriter(new FileWriter("output.txt"));
我们处理过的文件都是文本文件(字符文件)。在本章中,我们将看到如何处理二进制文件。
7.2 文本和二进制文件 和
一个文本文件是组织成行的字符序列。从概念上讲,我们认为每行都以换行符结束。但是,根据主机环境的不同,可能会发生某些字符转换。例如,如果我们将换行符\n
写到一个文件中,它可以被翻译成两个字符——一个回车符和一个换行符。
因此,书写的字符和存储在外部设备上的字符之间不一定一一对应。同样,文件中存储的字符数和读取的字符数之间也可能没有一一对应的关系。
一个二进制文件仅仅是一个字节序列,在输入或输出中没有字符转换。因此,这里的就是读取或写入的内容与文件中存储的内容一一对应。
除了可能的字符翻译之外,文本文件和二进制文件之间还有其他区别。为了说明,使用 2 个字节(16 位)存储一个short
整数;数字3371
被存储为00001101 00101011
。
如果我们把这个数写到一个文本文件中,它将被写成字符 3,接着是字符 3,接着是字符 7,接着是字符 1,总共占用 4 个字节。另一方面,我们可以简单地将这两个字节按原样写入二进制文件。
尽管我们仍然可以把它们看作是两个“字符”的序列,但是它们包含的值可能不代表任何有效的字符。其实在这种情况下,两个字节的十进制值是13
和43
,解释为两个 ASCII 字符,分别是回车符(CR)和+
。
另一种理解方式是,通常,文本文件中的每个字节都包含一个人类可读的字符,而二进制文件中的每个字节都包含一个任意的位模式。二进制文件对于将数据从其内部表示形式直接写入外部设备(通常是磁盘文件)非常重要。
标准的输入和输出被认为是文本文件。磁盘文件可以创建为文本文件或二进制文件。我们将很快看到如何做到这一点。
7.3 内部与外部文件名
使用计算机的通常方式是通过它的操作系统。我们通常使用文字处理器或文本文件编辑器来创建和编辑文件。当我们创建一个文件时,我们给它一个名字,当我们需要对这个文件做任何事情时,我们就使用这个名字。这是操作系统识别文件的名称。
我们将这种名称称为外部文件名。(术语外部在这里表示“Java 程序的外部”)当我们写程序时,我们可能想要指定,比如说,从文件中读取数据。该程序将需要使用一个文件名,但是,出于几个原因,这个名称不应该是一个外部文件名。以下是主要原因:
- 要读取的文件可能尚未创建。
- 如果外部名称与程序相关联,程序将只能读取具有该名称的文件。如果数据在一个不同名称的文件中,要么改变程序,要么重命名文件。
- 由于不同的操作系统有不同的文件命名规则,所以程序的可移植性较差。一个系统上的有效外部文件名在另一个系统上可能无效。
- 由于这些原因,Java 程序使用内部文件名——我们通常使用 in 作为输入,out 作为输出。例如,当我们编写以下代码时,我们将内部名称
in
与外部文件input.txt
相关联:
Scanner in = new Scanner(new FileReader("input.txt"));
这是唯一提到外部文件名的语句。剩下的程序是用in
来写的。当然,我们可以更灵活地这样写:
Scanner in = new Scanner(new FileReader(fileName));
当程序运行时,我们在fileName
中提供文件名,如下所示:
System.out.printf("Enter file name: ");
String fileName = kb.nextLine(); //Scanner kb = new Scanner(System.in);
这个例子也说明了如何在同一个程序中从键盘和读取数据和文件。例如,kb.nextInt()
将读取在键盘上键入的整数,in.nextInt()
将从文件input.txt
中读取一个整数。
7.4 示例:比较两个文件
考虑比较两个文件的问题。逐行进行比较,直到发现不匹配或其中一个文件结束。程序 P7.1 展示了我们如何解决这个问题。
程序 P7.1
import java.io.*;
import java.util.*;
public class CompareFiles {
public static void main(String[] args) throws IOException {
Scanner kb = new Scanner(System.in);
System.out.printf("First file? ");
String file1 = kb.nextLine();
System.out.printf("Second file? ");
String file2 = kb.nextLine();
Scanner f1 = new Scanner(new FileReader(file1));
Scanner f2 = new Scanner(new FileReader(file2));
String line1 = "", line2 = "";
int numMatch = 0;
while (f1.hasNextLine() && f2.hasNextLine()) {
line1 = f1.nextLine();
line2 = f2.nextLine();
if (!line1.equals(line2)) break;
++numMatch;
}
if (!f1.hasNextLine() && !f2.hasNextLine())
System.out.printf("\nThe files are identical\n");
else if (!f1.hasNextLine()) //first file ends, but not the second
System.out.printf("\n%s, with %d lines, is a subset of %s\n",
file1, numMatch, file2);
else if (!f2.hasNextLine()) //second file ends, but not the first
System.out.printf("\n%s, with %d lines, is a subset of %s\n",
file2, numMatch, file1);
else { //mismatch found
System.out.printf("\nThe files differ at line %d\n", ++numMatch);
System.out.printf("The lines are \n%s\n and \n%s\n", line1, line2);
}
f1.close();
f2.close();
} //end main
} //end class CompareFiles
该程序执行以下操作:
- 它提示输入要比较的文件的名称;如果任何文件不存在,将抛出一个
FileNotFoundException
。 - 它创建两个
Scanner
、f1
和f2
,每个文件一个。 - 它使用
hasNextLine
来检查一个文件是否有更多的行需要读取;如果是true
,至少还有一行要读,如果是false
,则已经到了文件的末尾。 - 变量
numMatch
计算匹配行的数量。读取每个文件中的一行。如果匹配,1
加到numMatch
上,读取另一对行。如果一个(或两个)文件结束,循环自然退出;如果出现不匹配,我们就break
退出循环。
如果第一个文件包含以下内容:
one and one are two
two and two are four
three and three are six
four and four are eight
five and five are ten
six and six are twelve
第二个文件包含以下内容:
one and one are two
two and two are four
three and three are six
four and four are eight
this is the fifth line
six and six are twelve
该程序将打印以下内容:
The files differ at line 5
The lines are
five and five are ten
and
this is the fifth line
7.5try . . . catch
构造
当程序试图读取数据时,可能会出现错误。设备可能有问题,我们可能试图读取超出文件结尾的内容,或者我们要求读取的文件可能根本不存在。同样,当我们试图将数据写入文件时,设备可能被锁定或不可用,或者我们可能没有写权限。在这种情况下,Java 会抛出 IO(输入/输出)异常”。
每当一个方法有可能触发一个 I/O 错误时,无论是通过执行某个 I/O 操作本身还是通过调用一个触发错误的方法,Java 都要求该方法声明这一点。一种方法是在方法头中使用throws IOException
,如下所示:
public static void main(String[] args) throws IOException{
处理输入/输出错误的另一种方法是使用try . . . catch
构造。假设一个程序包含以下语句:
Scanner in = new Scanner(new FileReader("input.txt"));
运行时,如果程序找不到名为input.txt
的文件,它将停止运行,并显示“文件未找到异常”错误消息。我们可以通过以下方式避免这种情况:
try {
Scanner in = new Scanner(new FileReader("input.txt"));
}
catch (IOException e) {
System.out.printf("%s\n", e);
System.out.printf("Correct the problem and try again\n");
System.exit(1);
}
try
块由单词try
后跟一个块组成(用大括号括起来的零个或多个语句)。Java 试图执行块中的语句。
catch
部分由单词catch
组成,后面是括号中的“异常类型”,再后面是一个块。在这个例子中,我们预计可能会抛出一个 I/O 异常,所以我们在catch
之后使用IOException e
。如果确实抛出了异常,则执行catch
块中的语句。
在这个例子中,假设文件input.txt
存在。Scanner in…
语句将成功,程序将在的catch
块后继续执行语句。但是如果这个文件不存在或者不可用,那么这个异常将被抛出并被部分 ?? 捕获。
当这种情况发生时,执行catch
块中的语句(如果有的话)。Java 允许我们将任何语句放在一个catch
块中。在这种情况下,我们打印异常对象e
的内容和一条消息,程序退出。在没有文件input.txt
的情况下运行时,该代码打印如下内容:
java.io.FileNotFoundException: input.txt
Correct the problem and try again
程序没有有退出。如果省略了exit
语句,程序将简单地继续执行catch
块之后的语句(如果有的话)。如果我们愿意,我们也可以调用另一个方法来继续执行。
继续这个例子,考虑以下情况:
try {
Scanner in = new Scanner(new FileReader("input.txt"));
n = in.nextInt();
}
我们试图从文件中读取下一个整数。现在,很多事情都可能出错:文件可能不存在,文件中的下一项可能不是有效的整数,或者文件中可能没有“下一”项。这些将分别抛出“找不到文件”、“输入不匹配”和“没有这样的元素”异常。因为这些都是类Exception
的子类,我们可以用下面的代码来捕捉它们:
catch (Exception e) {
System.out.printf("%s\n", e);
System.out.printf("Correct the problem and try again\n");
System.exit(1);
}
当文件为空时,此代码打印如下内容:
java.util.NoSuchElementException
Correct the problem and try again
当文件包含数字5.7
(非整数)时,它打印如下:
java.util.InputMismatchException
Correct the problem and try again
如果有必要,Java 允许我们单独捕捉每个异常。我们可以根据需要拥有任意多的catch
构造。在本例中,我们可以编写以下内容:
try {
Scanner in = new Scanner(new FileReader("input.txt"));
n = in.nextInt();
}
catch (FileNotFoundException e) {
//code for file not found
}
catch (InputMismatchException e) {
//code for “invalid integer” found
}
catch (NoSuchElementException e) {
//code for “end of file” being reached
}
有时候,catch
子句的顺序很重要。假设我们希望将“文件未找到”与所有其他异常分开捕获。我们可能会忍不住这样写:
try {
Scanner in = new Scanner(new FileReader("input.txt"));
n = in.nextInt();
}
catch (Exception e) {
//code for all exceptions (except “file not found”, presumably)
}
catch (FileNotFoundException e) {
//code for file not found
}
这段代码甚至无法编译!Java 到最后一个catch
的时候会抱怨FileNotFoundException
已经被抓了。这是因为FileNotFoundException
是Exception
的子类。要解决这个问题,我们必须将catch (FileNotFoundException e)
放在 catch (Exception e)
之前。
通常,子类异常必须出现在包含类之前。
7.6 二进制文件的输入/输出
如前所述,二进制文件包含的数据形式与数据的内部表示形式完全一致。例如,如果一个float
变量占用了 4 个字节的内存,那么将它写入一个二进制文件只需要制作这 4 个字节的精确副本。另一方面,将其写入文本文件导致其被转换为字符形式,并且所获得的字符被存储在文件中。
通常,二进制文件只能从程序内部创建,并且其内容只能由程序读取。例如,列出一个二进制文件只会产生“垃圾”,有时还会产生错误。比较一个文本文件,它可以通过输入来创建,其内容可以被人类列出和阅读。但是,二进制文件具有以下优点:
- 由于不需要进行数据转换,数据在二进制文件之间的传输速度比文本文件快得多;数据按原样读写。
- 数组和结构等数据类型的值可以写入二进制文件。对于文本文件,必须写入单独的元素。
- 存储在二进制文件中的数据通常比存储在文本文件中的数据占用更少的空间。例如,整数–25367(六个字符)在文本文件中占 6 个字节,但在二进制文件中只占 2 个字节。
7.6.1 DataOutputStream
和DataInputStream
考虑从文本文件num.txt
中读取整数,并将它们以内部形式写入(二进制)文件num.bin
的问题。我们假设num.txt
中的数字以0
结束,并且0
不会被写入二进制文件。这可以通过程序 P7.2 来完成。
程序 P7.2
import java.io.*;
import java.util.*;
public class CreateBinaryFile {
public static void main(String[] args) throws IOException {
Scanner in = new Scanner(new FileReader("num.txt"));
DataOutputStream out = new DataOutputStream(new FileOutputStream("num.bin"));
int n = in.nextInt();
while (n != 0) {
out.writeInt(n);
n = in.nextInt();
}
out.close();
in.close();
} //end main
} //end class CreateBinaryFile
假设num.txt
包含以下内容:
25 18 47 96 73 89 82 13 39 0
运行程序 P7.2 时,数字(除0
外)将以其内部形式存储在文件num.bin
中。
程序 P7.2 中的新语句是:
DataOutputStream out = new DataOutputStream(new FileOutputStream("num.bin"));
数据输出流允许程序将原始 Java 数据类型写入输出流。文件输出流是用于将数据写入文件的输出流。下面的构造函数创建一个连接到文件num.bin
的输出流:
new FileOutputStream("num.bin")
下面是DataOutputStream
类中的一些方法。所有方法都写入基础输出流,所有值都从高字节开始写入(即从最高有效字节到最低有效字节)。
void writeInt(int v) //write an int
void writeDouble(double v) //write a double value
void writeChar(int v) //write a char as a 2-byte value
void writeChars(String s) //write a string as a sequence of chars
void writeFloat(float v) //write a float value
void writeLong(long v) //write a long value
void write(int b) //write the low 8-bits of b
在程序 P7.2 中,out.writeInt(n)
将整数n
写入文件num.bin
。如果你试图查看num.bin
的内容,你看到的都是废话。只有程序可以读取并理解文件中的内容。
考虑一下程序 P7.3 ,它从文件num.bin
中读取数字并打印出来。
程序 P7.3
import java.io.*;
public class ReadBinaryFile {
public static void main(String[] args) throws IOException {
DataInputStream in = new DataInputStream(new FileInputStream("num.bin"));
int amt = 0;
try {
while (true) {
int n = in.readInt();
System.out.printf("%d ", n);
++amt;
}
}
catch (IOException e) { }
System.out.printf("\n\n%d numbers were read\n", amt);
} //end main
} //end class ReadBinaryFile
如果num.bin
包含来自程序 P7.2 的输出,那么程序 P7.3 产生如下输出:
25 18 47 96 73 89 82 13 39
9 numbers were read
程序 P7.3 中的新语句是:
DataInputStream in = new DataInputStream(new FileInputStream("num.bin"));
数据输入流允许程序从作为数据输出流创建的输入流中读取原始 Java 数据类型。文件输入流是用于从文件中读取数据的输入流。下面的构造函数创建一个连接到文件num.bin
的输入流:
new FileInputStream("num.bin")
以下是DataInputStream
类中的一些方法:
int readInt() //read 4 input bytes and return an int value
double readDouble() //read 8 input bytes and return a double value
char readChar() //read a char as a 2-byte value
void readFully(byte[] b) //read bytes and store in b until b is full
float readFloat() //read 4 input bytes and return a float value
long readLong() //read 8 input bytes and return a long value
int skipBytes(int n) //attempts to skip n bytes of data;
//returns the number actually skipped
一般来说,这些方法用于从DataOutputStream
读取使用相应“写入”方法写入的数据。
注意使用try . . . catch
来读取数字,直到到达文件结尾。回想一下,文件中没有“数据结束”值,因此我们无法对此进行测试。while
语句将连续从文件中读取。当到达文件结尾时,抛出一个EOFException
。这是IOException
的子类,因此被捕获。
catch
块是空的,所以那里什么也没有发生。控制转到下面的语句,该语句打印读取的数字量。
7.6.2 记录的二进制文件
在上一节中,我们创建并读取了一个整数的二进制文件。我们现在讨论如何处理记录的二进制文件,其中一个记录可以包含两个或更多的字段。
假设我们想存储汽车零件的信息。现在,我们假设每个零件记录有两个字段——一个int
零件号和一个double
价格。假设我们有一个文本文件parts.txt
,包含以下格式的零件数据:
4250 12.95
3000 17.50
6699 49.99
2270 19.25
0
我们读取这些数据,并用程序 P7.4 创建一个二进制文件parts.bin
。
程序 P7.4
import java.io.*;
import java.util.*;
public class CreateBinaryFile1 {
public static void main(String[] args) throws IOException {
Scanner in = new Scanner(new FileReader("parts.txt"));
DataOutputStream out = new DataOutputStream(new FileOutputStream("parts.bin"));
int n = in.nextInt();
while (n != 0) {
out.writeInt(n);
out.writeDouble(in.nextDouble());
n = in.nextInt();
}
in.close(); out.close();
} //end main
} //end class CreateBinaryFile1
parts.bin
中的每条记录正好是 12 个字节(4 代表int
+ 8 代表double
)。在本例中,有 4 条记录,因此文件的长度正好是 48 字节。我们知道第一条记录从字节 0 开始,第二条从字节 12 开始,第三条从字节 24 开始,第四条从字节 36 开始。下一条记录将从字节 48 开始。
在这个场景中,我们可以很容易地计算出记录 n 将从哪里开始;它将从字节数(n–1)* 12 开始。
为了给后面的内容做准备,我们将使用下面的Part
类重写程序 P7.4 :
class Part {
int partNum;
double price;
public Part(int pn, double pr) {
partNum = pn;
price = pr;
}
public void printPart() {
System.out.printf("\nPart number: %s\n", partNum);
System.out.printf("Price: $%3.2f\n", price);
}
} //end class Part
程序 P7.5 从文本文件parts.txt
中读取数据并创建二进制文件parts.bin
。
程序 P7.5
import java.io.*;
import java.util.*;
public class CreateBinaryFile2 {
static final int EndOfData = 0;
public static void main(String[] args) throws IOException {
Scanner in = new Scanner(new FileReader("parts.txt"));
DataOutputStream fp = new DataOutputStream(new FileOutputStream("parts.bin"));
Part part = getPartData(in);
while (part != null) {
writePartToFile(part, fp);
part = getPartData(in);
}
in.close();
fp.close();
} //end main
public static Part getPartData(Scanner in) {
int pnum = in.nextInt();
if (pnum == EndOfData) return null;
return new Part(pnum, in.nextDouble());
}
public static void writePartToFile(Part part, DataOutputStream f) throws IOException {
f.writeInt(part.partNum);
f.writeDouble(part.price);
part.printPart(); //print data on standard input
} //end writePartToFile
} //end class CreateBinaryFile2
//class Part goes here
运行时,程序 P7.5 产生如下输出:
Part number: 4250
Price: $12.95
Part number: 3000
Price: $17.50
Part number: 6699
Price: $49.99
Part number: 2270
Price: $19.25
创建文件后,我们可以用下面的代码读取下一个Part
记录:
public static Part readPartFromFile(DataInputStream in) throws IOException {
return new Part(in.readInt(), in.readDouble());
} //end readPartFromFile
这假设以下声明:
DataInputStream in = new DataInputStream(new FileInputStream("parts.bin"));
7.7 随机访问文件
在正常操作模式下,数据项是按照存储顺序从文件中读取的。当一个文件被打开时,人们会想到一个位于文件开头的假想指针。当从文件中读取项目时,该指针会按照读取的字节数移动。在任何时候,该指针指示下一个读(或写)操作将在哪里发生。
通常,这个指针是通过读或写操作隐式移动的。然而,Java 提供了将指针显式移动到文件中任何位置的工具。如果我们希望能够以随机顺序读取数据,而不是顺序读取数据,这是很有用的。
例如,考虑前面创建的部件二进制文件。每条记录的长度为 12 字节。如果第一条记录从字节 0 开始,那么第n?? 第条记录将从字节 12 开始(n–1)。假设我们想读取第 10 条记录,而不必读取前 9 条。我们算出第 10 个记录从字节 108 开始。如果我们可以将文件指针定位在字节 108,那么我们就可以读取第 10 个记录。
在 Java 中,RandomAccessFile
类提供了处理随机访问文件的方法。以下语句声明parts.bin
将被视为随机访问文件;rw
是文件模式,意思是“读/写”——我们将被允许从文件中读取,向其中写入。
RandomAccessFile fp = new RandomAccessFile("parts.bin", "rw");
如果我们想以只读模式打开文件,我们使用r
而不是rw
。
最初,文件指针是 0,意味着它位于字节 0。
当我们从文件中读取数据或向文件中写入数据时,指针值会发生变化。在部件示例中,在我们读取(或写入)第一条记录后,指针值将是 12。在我们读取(或写入)第 5 个个记录后,该值将是 60。不过,请注意,第 5 个记录从字节 48 开始。
在任何时候,fp.getFilePointer()
返回指针的当前值。我们可以用seek
将指针定位在文件中的任意字节。以下语句将指针定位在字节n
处:
fp.seek(n); //n is an integer; can be as big as a long integer
例如,我们可以将第 10 条条记录读入一个Part
变量,如下所示:
fp.seek(108); //the 10`th`record starts at byte 108
Part part = new Part(fp.readInt(), fp.readDouble());
一般来说,我们可以这样来阅读第 n th 记录:
fp.seek((n – 1) * 12); //the n`th`record starts at byte (n – 1) * 12
Part part = new Part(fp.readInt(), fp.readDouble());
我们注意到,12
通常应该由一个符号常数代替,比如PartRecordSize
。
我们现在通过使用更真实的零件记录来扩展零件示例。假设每个零件现在有四个字段:六个字符的零件号、名称、库存量和价格。以下是一些示例数据:
PKL070 Park-Lens 8 6.50
BLJ375 Ball-Joint 12 11.95
FLT015 Oil-Filter 23 7.95
DKP080 Disc-Pads 16 9.99
GSF555 Gas-Filter 9 4.50
END
零件名被写成一个单词,因此可以用Scanner
方法next
读取。请注意,零件名称的长度并不相同。记住,为了使用随机存取文件,所有的记录必须具有相同的长度——这样我们可以计算出记录在文件中的位置。那么,如果零件名长度不同,我们如何创建零件记录的随机存取文件呢?
诀窍是使用相同的固定存储量来存储每个名称。例如,我们可以用 20 个字符存储每个名字。如果一个名字短于 20,我们用空格填充它来组成 20。如果它更长,我们将其截断为 20。但是,最好使用能够容纳最长名称的长度。
如果我们用 20 个字符存储一个名称,零件记录的大小是多少?在 Java 中,每个字符存储在 2 个字节中。因此,零件号(6 个字符)将占用 12 个字节,名称将占用 40 个字节,库存数量(整数)将占用 4 个字节,价格(双精度)将需要 8 个字节。这为每个记录提供了总共 64 个字节。
我们如下编写Part
类:
class Part {
String partNum, name;
int amtInStock;
double price;
public Part(String pn, String n, int a, double p) {
partNum = pn;
name = n;
amtInStock = a;
price = p;
}
public void printPart() {
System.out.printf("Part number: %s\n", partNum);
System.out.printf("Part name: %s\n", name);
System.out.printf("Amount in stock: %d\n", amtInStock);
System.out.printf("Price: $%3.2f\n", price);
}
} //end class Part
如果EndOfData
的值为END
,我们可以从零件文件中读取数据,假设它是以前的样本数据格式,如下所示:
public static Part getPartData(Scanner in) {
String pnum = in.next();
if (pnum.equals(EndOfData)) return null;
return new Part(pnum, in.next(), in.nextInt(), in.nextDouble());
}
如果没有更多的数据,该方法返回null
。否则,它返回一个包含下一部分数据的Part
对象。
如果StringFixedLength
表示存储零件名称的字符数,我们可以将名称写入文件f
,如下所示:
int n = Math.min(part.name.length(), StringFixedLength);
for (int h = 0; h < n; h++) f.writeChar(part.name.charAt(h));
for (int h = n; h < StringFixedLength; h++) f.writeChar(' ');
如果n
表示名称的实际长度和StringFixedLength
中较小的一个,我们首先将n
字符写入文件。第二个for
语句将空白写入文件,以补足所需的数量。注意,如果StringFixedLength
比名字短,那么最后一个for
不会写多余的空格。
要从文件中读取名称,我们将使用以下代码:
char[] name = new char[StringFixedLength];
for (int h = 0; h < StringFixedLength; h++) name[h] = f.readChar();
String hold = new String(name, 0, StringFixedLength);
这正好将文件中的StringFixedLength
个字符读入一个数组。这然后被转换成一个String
并存储在hold
中;hold.trim()
将删除尾随空格,如果有的话。我们将使用hold.trim()
来创建Part
对象 read。
程序 P7.6 从文本文件parts.txt
中读取数据,并创建随机存取文件parts.bin
。
程序 P7.6
import java.io.*;
import java.util.*;
public class CreateRandomAccess {
static final int StringFixedLength = 20;
static final int PartNumSize = 6;
static final int PartRecordSize = 64;
static final String EndOfData = "END";
public static void main(String[] args) throws IOException {
Scanner in = new Scanner(new FileReader("parts.txt"));
RandomAccessFile fp = new RandomAccessFile("parts.bin", "rw");
Part part = getPartData(in);
while (part != null) {
writePartToFile(part, fp);
part = getPartData(in);
}
} //end main
public static Part getPartData(Scanner in) {
String pnum = in.next();
if (pnum.equals(EndOfData)) return null;
return new Part(pnum, in.next(), in.nextInt(), in.nextDouble());
} //end getPartData
public static void writePartToFile(Part part, RandomAccessFile f) throws IOException {
System.out.printf("%s %-11s %2d %5.2f %3d\n", part.partNum, part.name,
part.amtInStock, part.price, f.getFilePointer());
for (int h = 0; h < PartNumSize; h++) f.writeChar(part.partNum.charAt(h));
int n = Math.min(part.name.length(), StringFixedLength);
for (int h = 0; h < n; h++) f.writeChar(part.name.charAt(h));
for (int h = n; h < StringFixedLength; h++) f.writeChar(' ');
f.writeInt(part.amtInStock);
f.writeDouble(part.price);
} //end writePartToFile
} //end class CreateRandomAccess
//class Part goes here
当使用包含先前样本数据的parts.txt
运行时,程序 P7.6 打印如下:
PKL070 Park-Lens 8 6.50 0
BLJ375 Ball-Joint 12 11.95 64
FLT015 Oil-Filter 23 7.95 128
DKP080 Disc-Pads 16 9.99 192
GSF555 Gas-Filter 9 4.50 256
每行的最后一个值是文件指针;这是存储记录的字节位置。使用格式规范%-11s
在字段宽度11
中左对齐打印零件名称(-
表示左对齐)。
我们现在编写程序 P7.7 来测试文件是否被正确存储。它提示用户输入记录号,并打印相应的零件记录。
程序 P7.7
import java.io.*;
import java.util.*;
public class ReadRandomAccess {
static final int StringFixedLength = 20;
static final int PartNumSize = 6;
static final int PartRecordSize = 64;
public static void main(String[] args) throws IOException {
RandomAccessFile fp = new RandomAccessFile("parts.bin", "rw");
Scanner kb = new Scanner(System.in);
System.out.printf("\nEnter a record number: ");
int n = kb.nextInt();
while (n != 0) {
fp.seek(PartRecordSize * (n - 1));
readPartFromFile(fp).printPart();
System.out.printf("\nEnter a record number: ");
n = kb.nextInt();
}
} //end main
public static Part readPartFromFile(RandomAccessFile f) throws IOException {
String pname = "";
for (int h = 0; h < PartNumSize; h++) pname += f.readChar();
char[] name = new char[StringFixedLength];
for (int h = 0; h < StringFixedLength; h++) name[h] = f.readChar();
String hold = new String(name, 0, StringFixedLength);
return new Part(pname, hold.trim(), f.readInt(), f.readDouble());
} //end readPartFromFile
} //end class ReadRandomAccess
// class Part goes here
以下是程序 P7.7 的运行示例:
Enter a record number: 3
Part number: FLT015
Part name: Oil-Filter
Amount in stock: 23
Price: $7.95
Enter a record number: 1
Part number: PKL070
Part name: Park-Lens
Amount in stock: 8
Price: $6.50
Enter a record number: 4
Part number: DKP080
Part name: Disc-Pads
Amount in stock: 16
Price: $9.99
Enter a record number: 0
7.8 索引文件
上一节展示了如何在给定记录号的情况下检索零件记录。但是这不是检索记录的最自然的方式。更有可能的是,我们希望基于某个键来检索记录,在本例中,是部件号。更自然的问题是,“我们有多少 BLJ375?”而不是“我们有多少记录 2?”接下来的问题是如何在给定零件号的情况下检索记录。
一种方法是使用索引。就像书籍索引让我们快速找到书中的信息一样,文件索引让我们快速找到文件中的记录。加载文件时会创建索引。以后,当记录被添加到文件中或从文件中删除时,它必须被更新。在我们的例子中,一个索引条目将由一个零件号和一个记录号组成。
我们将使用下面的类来创建索引:
class Index {
String partNum;
int recNum;
public Index(String p, int r) {
partNum = p;
recNum = r;
}
} //end class Index
我们将使用MaxRecords
来表示我们将满足的最大记录数。我们声明一个数组index
,如下所示:
Index[] index = new Index[MaxRecords + 1];
我们将使用index[0].recNum
来保存numRecords
,即文件中存储的记录数。索引条目将存储在index[1]
到index[numRecords]
中。
索引将按零件号有序保存。我们希望为以下记录创建索引:
PKL070 Park-Lens 8 6.50
BLJ375 Ball-Joint 12 11.95
FLT015 Oil-Filter 23 7.95
DKP080 Disc-Pads 16 9.99
GSF555 Gas-Filter 9 4.50
我们假设记录按照给定的顺序存储在文件中。当读取并存储第一条记录时,索引将包含以下内容:
PKL070 1
这意味着PKL070
的记录是零件文件中的记录号 1。在第二个记录(BLJ375
)被读取和存储后,索引将是这样的:
BLJ375 2
PKL070 1
请记住,我们是按照零件号来保存索引的。在第三个记录(FLT015
)被读取和存储后,索引将是这样的:
BLJ375 2
FLT015 3
PKL070 1
在第四个记录(DKP080
)被读取和存储后,索引将是这样的:
BLJ375 2
DKP080 4
FLT015 3
PKL070 1
最后,在第五个记录(GSF555
)被读取和存储之后,索引将是这样的:
BLJ375 2
DKP080 4
FLT015 3
GSF555 5
PKL070 1
程序 P7.8 说明了如何按照描述创建索引。
程序 P7.8
import java.io.*;
import java.util.*;
public class CreateIndex {
static final int StringFixedLength = 20;
static final int PartNumSize = 6;
static final int PartRecordSize = 64;
static final int MaxRecords = 100;
static final String EndOfData = "END";
public static void main(String[] args) throws IOException {
RandomAccessFile fp = new RandomAccessFile("parts.bin", "rw");
Index[] index = new Index[MaxRecords + 1];
createMasterIndex(index, fp);
saveIndex(index);
printIndex(index);
fp.close();
} //end main
public static void createMasterIndex(Index[] index,
RandomAccessFile f) throws IOException {
Scanner in = new Scanner(new FileReader("parts.txt"));
int numRecords = 0;
Part part = getPartData(in);
while (part != null) {
int searchResult = search(part.partNum, index, numRecords);
if (searchResult > 0)
System.out.printf("Duplicate part: %s ignored\n", part.partNum);
else { //this is a new part number; insert in location -searchResult
if (numRecords == MaxRecords) {
System.out.printf("Too many records: only %d allowed\n", MaxRecords);
System.exit(1);
}
//the index has room; shift entries to accommodate new part
for (int h = numRecords; h >= -searchResult; h--)
index[h + 1] = index[h];
index[-searchResult] = new Index(part.partNum, ++numRecords);
writePartToFile(part, f);
}
part = getPartData(in);
} //end while
index[0] = new Index("NOPART", numRecords);
in.close();
} //end createMasterIndex
public static Part getPartData(Scanner in) {
String pnum = in.next();
if (pnum.equals(EndOfData)) return null;
return new Part(pnum, in.next(), in.nextInt(), in.nextDouble());
} //end getPartData
public static void writePartToFile(Part part, RandomAccessFile f) throws IOException {
for (int h = 0; h < PartNumSize; h++) f.writeChar(part.partNum.charAt(h));
int n = Math.min(part.name.length(), StringFixedLength);
for (int h = 0; h < n; h++) f.writeChar(part.name.charAt(h));
for (int h = n; h < StringFixedLength; h++) f.writeChar(' ');
f.writeInt(part.amtInStock);
f.writeDouble(part.price);
} //end writePartToFile
public static void saveIndex(Index[] index) throws IOException {
RandomAccessFile f = new RandomAccessFile("index.bin", "rw");
int numRecords = index[0].recNum;
//fill the unused index positions with dummy entries
for (int h = numRecords+1; h <= MaxRecords; h++)
index[h] = new Index("NOPART", 0);
f.writeInt(MaxRecords);
for (int h = 0; h <= MaxRecords; h++) {
for (int i = 0; i < PartNumSize; i++)
f.writeChar(index[h].partNum.charAt(i));
f.writeInt(index[h].recNum);
}
f.close();
} //end saveIndex
public static int search(String key, Index[] list, int n) {
//searches list[1..n] for key. If found, it returns the location; otherwise
//it returns the negative of the location in which key should be inserted.
int lo = 1, hi = n;
while (lo <= hi) { // as long as more elements remain to consider
int mid = (lo + hi) / 2;
int cmp = key.compareToIgnoreCase(list[mid].partNum);
if (cmp == 0) return mid; // search succeeds
if (cmp < 0) hi = mid - 1; // key is 'less than' list[mid].partNum
else lo = mid + 1; // key is 'greater than' list[mid].partNum
}
return -lo; // key not found; insert in location lo
} // end search
public static void printIndex(Index[] index) {
System.out.printf("\nThe index is as follows: \n\n");
int numRecords = index[0].recNum;
for (int h = 1; h <= numRecords; h++)
System.out.printf("%s %2d\n", index[h].partNum, index[h].recNum);
} //end printIndex
} //end class CreateIndex
class Part {
String partNum, name;
int amtInStock;
double price;
public Part(String pn, String n, int a, double p) {
partNum = pn;
name = n;
amtInStock = a;
price = p;
}
public void printPart() {
System.out.printf("Part number: %s\n", partNum);
System.out.printf("Part name: %s\n", name);
System.out.printf("Amount in stock: %d\n", amtInStock);
System.out.printf("Price: $%3.2f\n", price);
}
} //end class Part
class Index {
String partNum;
int recNum;
public Index(String p, int r) {
partNum = p;
recNum = r;
}
} //end class Index
当读取零件号时,我们在索引中查找它。因为索引是按零件号排序的,所以我们使用二分搜索法来搜索它。如果零件号存在,则意味着该零件已经被存储,因此该记录被忽略。如果它不存在,这是一个新零件,所以它的记录存储在零件文件parts.bin
中,假设我们还没有存储MaxRecords
记录。
(在numRecords
中)记录读取的记录数。然后将零件号和记录号插入到index
数组的适当位置。
当所有记录都被存储后,索引被保存在另一个文件index.bin
中。在保存之前,index
(index[numRecords]
之后的条目)的未使用部分被填充了虚拟记录。MaxRecords
的值是发送到文件的第一个值。接下来是index[0]
到index[MaxRecords]
。记住index[0].recNum
包含了numRecords
的值。
假设parts.txt
包含以下内容:
PKL070 Park-Lens 8 6.50
BLJ375 Ball-Joint 12 11.95
PKL070 Park-Lens 8 6.50
FLT015 Oil-Filter 23 7.95
DKP080 Disc-Pads 16 9.99
GSF555 Gas-Filter 9 4.50
FLT015 Oil-Filter 23 7.95
END
当程序 P7.8 运行时,打印如下:
Duplicate part: PKL070 ignored
Duplicate part: FLT015 ignored
The index is as follows:
BLJ375 2
DKP080 4
FLT015 3
GSF555 5
PKL070 1
接下来,我们编写一个程序,通过首先从文件中读取索引来测试我们的索引。然后要求用户输入零件号,一次一个。对于每个零件,它会在索引中搜索零件号。如果找到,索引条目将指示零件文件中的记录号。使用记录号,检索零件记录。如果在索引中找不到零件号,则没有该零件的记录。程序显示为程序 P7.9 。
程序 P7.9
import java.io.*;
import java.util.*;
public class UseIndex {
static final int StringFixedLength = 20;
static final int PartNumSize = 6;
static final int PartRecordSize = 64;
static int MaxRecords;
public static void main(String[] args) throws IOException {
RandomAccessFile fp = new RandomAccessFile("parts.bin", "rw");
Index[] index = retrieveIndex();
int numRecords = index[0].recNum;
Scanner kb = new Scanner(System.in);
System.out.printf("\nEnter a part number (E to end): ");
String pnum = kb.next();
while (!pnum.equalsIgnoreCase("E")) {
int n = search(pnum, index, numRecords);
if (n > 0) {
fp.seek(PartRecordSize * (index[n].recNum - 1));
readPartFromFile(fp).printPart();
}
else System.out.printf("Part not found\n");
System.out.printf("\nEnter a part number (E to end): ");
pnum = kb.next();
} //end while
fp.close();
} //end main
public static Index[] retrieveIndex() throws IOException {
RandomAccessFile f = new RandomAccessFile("index.bin", "rw");
int MaxRecords = f.readInt();
Index[] index = new Index[MaxRecords + 1];
for (int j = 0; j <= MaxRecords; j++) {
String pnum = "";
for (int i = 0; i < PartNumSize; i++) pnum += f.readChar();
index[j] = new Index(pnum, f.readInt());
}
f.close();
return index;
} //end retrieveIndex
public static Part readPartFromFile(RandomAccessFile f) throws IOException {
String pname = "";
for (int h = 0; h < PartNumSize; h++) pname += f.readChar();
char[] name = new char[StringFixedLength];
for (int h = 0; h < StringFixedLength; h++) name[h] = f.readChar();
String hold = new String(name, 0, StringFixedLength);
return new Part(pname, hold.trim(), f.readInt(), f.readDouble());
} //end readPartFromFile
public static int search(String key, Index[] list, int n) {
//searches list[1..n] for key. If found, it returns the location; otherwise
//it returns the negative of the location in which key should be inserted.
int lo = 1, hi = n;
while (lo <= hi) { // as long as more elements remain to consider
int mid = (lo + hi) / 2;
int cmp = key.compareToIgnoreCase(list[mid].partNum);
if (cmp == 0) return mid; // search succeeds
if (cmp < 0) hi = mid - 1; // key is 'less than' list[mid].partNum
else lo = mid + 1; // key is 'greater than' list[mid].partNum
}
return -lo; // key not found; insert in location lo
} // end search
} //end class UseIndex
// Part and Index classes go here
以下是程序 P7.9 的运行示例:
Enter a part number (E to end): dkp080
Part number: DKP080
Part name: Disc-Pads
Amount in stock: 16
Price: $9.99
Enter a part number (E to end): GsF555
Part number: GSF555
Part name: Gas-Filter
Amount in stock: 9
Price: $4.50
Enter a part number (E to end): PKL060
Part not found
Enter a part number (E to end): pkl070
Part number: PKL070
Part name: Park-Lens
Amount in stock: 8
Price: $6.50
Enter a part number (E to end): e
请注意,可以使用任何大小写字母组合来输入零件号。
如果需要,我们可以使用索引按零件号顺序打印记录。我们只是按照记录在索引中出现的顺序打印它们。例如,使用我们的样本数据,我们有如下的索引:
BLJ375 2
DKP080 4
FLT015 3
GSF555 5
PKL070 1
如果我们打印记录 2,然后是记录 4,然后是记录 3,然后是记录 5,然后是记录 1,我们将按零件号升序打印它们。这可以通过以下函数来完成:
public static void printFileInOrder(Index[] index, RandomAccessFile f) throws IOException {
System.out.printf("\nFile sorted by part number: \n\n");
int numRecords = index[0].recNum;
for (int h = 1; h <= numRecords; h++) {
f.seek(PartRecordSize * (index[h].recNum - 1));
readPartFromFile(f).printPart();
System.out.printf("\n");
} //end for
} //end printFileInOrder
假设该函数被添加到程序 P7.9 中,并在检索到索引后用以下语句调用:
printFileInOrder(index, fp);
将打印以下内容:
File sorted by part number:
Part number: BLJ375
Part name: Ball-Joint
Amount in stock: 12
Price: $11.95
Part number: DKP080
Part name: Disc-Pads
Amount in stock: 16
Price: $9.99
Part number: FLT015
Part name: Oil-Filter
Amount in stock: 23
Price: $7.95
Part number: GSF555
Part name: Gas-Filter
Amount in stock: 9
Price: $4.50
Part number: PKL070
Part name: Park-Lens
Amount in stock: 8
Price: $6.50
7.9 更新随机存取文件
文件中的信息通常不是静态的。它必须不时更新。对于我们的零件文件,我们可能希望更新它,以反映项目售出时的新库存数量,或者反映价格的变化。我们可能决定储存新零件,因此我们必须在文件中添加记录,并且我们可能停止销售某些产品,因此必须从文件中删除它们的记录。
添加新记录的方式与首先加载文件的方式类似。我们可以从逻辑上删除一条记录,方法是在索引中将它标记为已删除,或者简单地将它从索引中删除。稍后,当文件被重组时,记录可能被物理删除(即,不存在于新文件中)。但是我们怎样才能改变现有记录中的信息呢?为此,我们必须做到以下几点:
- 在文件中找到记录。
- 把它读入内存。
- 更改所需的字段。
- 将更新后的记录写入其来源文件的相同位置。
这要求我们的文件以读写方式打开。假设文件已经存在,必须用模式rw
打开。我们解释如何更新零件号存储在key
中的记录。
首先,我们在索引中搜索key
。如果找不到,则不存在该零件的记录。假设在位置k
找到它。然后index[k].recNum
给出它在零件文件中的记录号(n
)。然后我们进行如下操作(为了清楚起见,省略了错误检查):
fp.seek(PartRecordSize * (n - 1));
Part part = readPartFromFile(fp);
该记录现在存储在变量part
中。假设我们需要从库存数量中减去amtSold
。这可以通过以下方式实现:
if (amtSold > part.amtInStock)
System.out.printf("Cannot sell more than you have: ignored\n");
else part.amtInStock -= amtSold;
其他字段(除了零件号,因为它用于识别记录)可以类似地更新。当所有的更改都完成后,更新的记录部分保存在内存中。现在必须将它写回到文件中它原来的位置。这可以通过以下方式实现:
fp.seek(PartRecordSize * (n - 1));
writePartToFile(part, fp);
注意,我们必须再次调用seek
,因为在第一次读取记录后,文件被定位在下一个记录的的开始处。在写入更新的记录之前,我们必须将它重新定位在刚刚读取的记录的开头。最终结果是更新的记录覆盖了旧的记录。
程序 P7.10 更新零件文件中记录的amtInStock
字段。要求用户输入零件号和销售量。该程序使用二分搜索法在索引中搜索零件号。如果找到,则从文件中检索记录,在内存中更新,并写回文件。重复此过程,直到用户输入E
。
程序 P7.10
import java.io.*;
import java.util.*;
public class UpdateFile {
static final int StringFixedLength = 20;
static final int PartNumSize = 6;
static final int PartRecordSize = 64;
static int MaxRecords;
public static void main(String[] args) throws IOException {
Scanner in = new Scanner(System.in);
Index[] index = retrieveIndex();
int numRecords = index[0].recNum;
System.out.printf("\nEnter part number (E to end): ");
String pnum = in.next();
while (!pnum.equalsIgnoreCase("E")) {
updateRecord(pnum, index, numRecords);
System.out.printf("\nEnter part number (E to end): ");
pnum = in.next();
} //end while
} //end main
public static void updateRecord(String pnum, Index[] index, int max)throws IOException {
Scanner in = new Scanner(System.in);
RandomAccessFile fp = new RandomAccessFile("parts.bin", "rw");
int n = search(pnum, index, max);
if (n < 0) System.out.printf("Part not found\n");
else {
fp.seek(PartRecordSize * (index[n].recNum - 1));
Part part = readPartFromFile(fp);
System.out.printf("Enter amount sold: ");
int amtSold = in.nextInt();
if (amtSold > part.amtInStock)
System.out.printf("You have %d: cannot sell more, ignored\n",
part.amtInStock);
else {
part.amtInStock -= amtSold;
System.out.printf("Amount remaining: %d\n", part.amtInStock);
fp.seek(PartRecordSize * (index[n].recNum - 1));
writePartToFile(part, fp);
System.out.printf("%s %-11s %2d %5.2f\n", part.partNum, part.name,
part.amtInStock, part.price);
} //end if
} //end if
fp.close();
} //end updateRecord
public static Index[] retrieveIndex() throws IOException {
RandomAccessFile f = new RandomAccessFile("index.bin", "rw");
int MaxRecords = f.readInt();
Index[] index = new Index[MaxRecords + 1];
for (int j = 0; j <= MaxRecords; j++) {
String pnum = "";
for (int i = 0; i < PartNumSize; i++) pnum += f.readChar();
index[j] = new Index(pnum, f.readInt());
}
f.close();
return index;
} //end retrieveIndex
public static Part readPartFromFile(RandomAccessFile f) throws IOException {
String pname = "";
for (int h = 0; h < PartNumSize; h++) pname += f.readChar();
char[] name = new char[StringFixedLength];
for (int h = 0; h < StringFixedLength; h++) name[h] = f.readChar();
String hold = new String(name, 0, StringFixedLength);
return new Part(pname, hold.trim(), f.readInt(), f.readDouble());
} //end readPartFromFile
public static void writePartToFile(Part part, RandomAccessFile f) throws IOException {
for (int h = 0; h < PartNumSize; h++) f.writeChar(part.partNum.charAt(h));
int n = Math.min(part.name.length(), StringFixedLength);
for (int h = 0; h < n; h++) f.writeChar(part.name.charAt(h));
for (int h = n; h < StringFixedLength; h++) f.writeChar(' ');
f.writeInt(part.amtInStock);
f.writeDouble(part.price);
} //end writePartToFile
public static int search(String key, Index[] list, int n) {
//searches list[1..n] for key. If found, it returns the location; otherwise
//it returns the negative of the location in which key should be inserted.
int lo = 1, hi = n;
while (lo <= hi) { // as long as more elements remain to consider
int mid = (lo + hi) / 2;
int cmp = key.compareToIgnoreCase(list[mid].partNum);
if (cmp == 0) return mid; // search succeeds
if (cmp < 0) hi = mid - 1; // key is 'less than' list[mid].partNum
else lo = mid + 1; // key is 'greater than' list[mid].partNum
}
return -lo; // key not found; insert in location lo
} // end search
} //end class UpdateFile
// Part and Index classes go here
以下是程序 P7.10 的运行示例:
Enter part number (E to end): blj375
Enter amount sold: 2
Amount remaining: 10
BLJ375 Ball-Joint 10 11.95
Enter part number (E to end): blj375
Enter amount sold: 11
You have 10: cannot sell more, ignored
Enter part number (E to end): dkp080
Enter amount sold: 4
Amount remaining: 12
DKP080 Disc-Pads 12 9.99
Enter part number (E to end): gsf55
Part not found
Enter part number (E to end): gsf555
Enter amount sold: 1
Amount remaining: 8
GSF555 Gas-Filter 8 4.50
Enter part number (E to end): e
练习 7
-
用
"r"
打开的文件和用"rw"
打开的文件有什么区别? -
写一个程序来判断两个二进制文件是否相同。如果它们不同,打印它们不同的第一个字节数。
-
写一个程序来读取整数的(二进制)文件,对整数进行排序,并把它们写回同一个文件。假设所有的数字都可以存储在一个数组中。
-
重复(3 ),但假设在任何时候只有 20 个数字可以存储在内存中(在一个数组中)。提示:您将需要使用至少 2 个额外的临时输出文件。
-
写一个程序来读取两个整数排序文件,并将值合并到第三个排序文件中。
-
写一个程序来读取一个文本文件并产生另一个文本文件,其中所有的行都小于给定的长度。一定要在可感的地方断行;例如,避免断词或在行首放置孤立的标点符号。
-
What is the purpose of creating an index for a file?
以下是员工档案中的一些记录。这些字段是员工编号(键)、姓名、职称、电话号码、月薪和要扣除的税。
STF425, Julie Johnson, Secretary, 623-3321, 2500, 600
COM319, Ian McLean, Programmer, 676-1319, 3200, 800
SYS777, Jean Kendall, Systems Analyst, 671-2025, 4200, 1100
JNR591, Lincoln Kadoo, Operator, 657-0266, 2800, 700
MSN815, Camille Kelly, Clerical Assistant, 652-5345, 2100, 500
STF273, Anella Bayne, Data Entry Manager, 632-5324, 3500, 850
SYS925, Riaz Ali, Senior Programmer, 636-8679, 4800, 1300
假设记录按照给定的顺序存储在二进制文件中。
-
给定记录号,如何检索记录?
-
给定记录的键,如何检索记录?
-
加载文件时,创建一个索引,其中的键按给定的顺序排列。如何在这样的索引中搜索给定的键?
-
As the file is loaded, create an index in which the keys are sorted. Given a key, how is the corresponding record retrieved?
讨论在文件中添加和删除记录时,必须对索引进行哪些更改。
-
-
对于本章中讨论的“零件文件”应用,编写(I)添加新记录和(ii)删除记录的方法。
八、二叉树简介
在本章中,我们将解释以下内容:
- 树和二叉树的区别
- 如何执行二叉树的前序、有序和后序遍历
- 如何在计算机程序中表示二叉树
- 如何从给定的数据建立二叉树
- 什么是二叉查找树以及如何构建
- 如何编写一个程序来计算文章中单词的词频
- 如何使用数组作为二叉树表示
- 如何编写一些递归函数来获取关于二叉树的信息
- 如何从二叉查找树中删除节点
8.1 树木
树是一组有限的节点,以下两个条件都成立:
- 有一个特别指定的节点叫做树的根。
- 剩下的节点被分割成 m ≥ 0 个不相交的集合 T 1 ,T 2 ,…,T m ,这些集合中的每一个都是一棵树。
树 T 1 ,T 2 ,…,T m ,被称为根的子树。我们使用递归定义,因为递归是树结构的固有特性。图 8-1 说明了一棵树。按照惯例,根是画在顶端的,树是向下生长的。
图 8-1 。一棵树
根是A
。有三个子树分别以B
、C
和D
为根。根在B
的树有两个子树,根在C
的树没有子树,根在D
的树有一个子树。树的每个节点都是子树的根。
节点的度是该节点的子树数。把它想象成离开节点的行数。例如,度(A
) = 3,度(C
) = 0,度(D
) = 1,度(G
) = 3。
我们使用术语父、子和兄弟来指代树的节点。比如父A
有三个孩子,分别是B
、C
和D
;父B
有两个孩子,分别是E
和F
;并且父节点D
有一个子节点G
,它有三个子节点:H
、I
和J
。请注意,一个节点可能是一个节点的子节点,但也可能是另一个节点的父节点。
兄弟节点是同一父节点的子节点。比如B
、C
、D
是兄弟姐妹;E
和F
是亲兄妹;并且H
、I
和J
是兄弟姐妹。
在树中,一个节点可以有几个子节点,但是除了根节点,只有一个父节点。根没有父级。换句话说,一个非根节点只有一行将引入。
终端节点(也称为叶)是度为 0 的节点。分支节点是非终端节点。在图 8-1 的、C
、E
、F
、H
、I
、J
为叶子,而A
、B
、D
、G
为分支节点。
树的矩是树中节点的个数。图 8-1 中的树有力矩 10。
一棵树的重量就是树上的叶子数。图 8-1 中的树权重为 6。
节点的级别(或深度)是从根到该节点的路径上必须经过的分支数。根的级别为 0。
在图 8-1 的树中、B
、C
、D
为一级;E
、F
、G
为二级;而H
、I
、J
处于 3 级。节点的级别是树中节点深度的度量。
一棵树的高度是树中的层数。图 8-1 中的树高 4。注意树的高度比它的最高高度多一。
如果子树 T 1 ,T 2 ,…,T m 的相对顺序重要,那么该树就是一棵有序 树。如果顺序不重要,树是面向的。
一个森林是零个或多个不相交的树 的集合,如图图 8-2 所示。
图 8-2 。三棵不相交的树组成的森林
虽然人们对一般的树有些兴趣,但迄今为止最重要的树是二叉树。
8.2 二叉树
二叉树是非线性数据结构的经典例子——将其与线性列表进行比较,在该列表中,我们确定第一项、下一项和最后一项。二叉树是更一般的树数据结构的特例,但它是最有用和最广泛使用的一种树。使用以下递归定义可以最好地定义二叉树:
一棵二叉树
-
is empty
或
-
由一个根树和两个子树(一左一右)组成,每个子树都是一棵二叉树
这个定义的结果是一个节点总是有两个子树,其中任何一个都可能是空的。另一个结果是,如果一个节点有个非空子树,区分它是在左边还是右边是很重要的。这里有一个例子:
第一个有一个空的右边子树,而第二个有一个空的左边子树。但是,作为树,它们是一样的。
下面是二叉树的例子。
这是一个只有一个节点的二叉树,根:
这里有两个节点的二叉树:
这里有三个节点的二叉树:
以下是所有左子树和右子树都为空的二叉树:
这是一棵二叉树,除了叶子,每个节点都有两个子树;这叫做完全二叉树 ??:
这里是一个通用的二叉树:
8.3 遍历二叉树
在许多应用中,我们希望以某种系统的方式访问二叉树的节点。现在,我们将认为“访问”只是在节点上打印信息。对于一棵有 n 个节点的树,有 n !访问它们的方法,假设每个节点被访问一次。
例如,对于一个有三个节点 A、B 和 C 的树,我们可以按以下任何顺序访问它们:ABC、ACB、BCA、BAC、CAB 和 CBA。并非所有这些命令都有用。我们将定义三种有用的方式——预排序、按序排序和后排序。
这是预购 t raversal :
- 访根。
- 按照预先的顺序遍历左边的子树。
- 按前序遍历右边的子树。
注意,遍历是递归定义的。在步骤 2 和 3 中,我们必须重新应用前序遍历的定义,即“访问根,等等”
这个树的前序遍历
是A B C
。
这个树的前序遍历
是C E F H B G A N J K
。
这是按序遍历 :
- 按顺序遍历左边的子树。
- 访根。
- 按顺序遍历右边的子树。
这里我们首先遍历左子树,然后是根,然后是右子树。
该树的有序遍历
是B A C
。
该树的有序遍历
是F H E B C A G J N K
这是后序遍历 :
- 按后顺序遍历左边的子树。
- 按后顺序遍历右边的子树。
- 访根。
这里,在访问根之前,我们遍历左右子树。
这个树的后序遍历
是B C A
。
这个树的后序遍历
是H F B E A J K N G C
。
请注意,遍历是从我们访问相对于左右子树的遍历的根的位置派生出它们的名称的。作为另一个例子,考虑可以表示以下算术表达式的二叉树:
(54 + 37) / (72 – 5 * 13)
这是树:
树的叶子包含操作数,分支节点包含操作符。给定一个包含运算符的节点,左子树表示第一个操作数,右子树表示第二个操作数。
前序遍历是:/ + 54 37 – 72 * 5 13
有序遍历是:54 + 37 / 72 – 5 * 13
后序遍历是:54 37 + 72 5 13 * - /
后序遍历可以与栈结合使用来计算表达式。算法如下:
initialize a stack, S, to empty
while we have not reached the end of the traversal
get the next item, x
if x is an operand, push it onto S
if x is an operator
pop its operands from S,
apply the operator
push the result onto S
endif
endwhile
pop S; // this is the value of the expression
考虑后序遍历:54 37 + 72 5 13 * - /
。其评估如下:
- 下一项是
54
;将54
推到S
上;S
包含54
。 - 下一项是
37
;将37
推到S
上;S
包含54 37
(右上)。 - 下一项是
+
;从S
弹出37
和54
;将+
应用到54
和37
,给出91
;将91
推到S
上;S
包含91
。 - 下一项是
72
;将72
推到S
上;S
包含91 72
。 - 接下来的项目是
5
和13
;这些都推给了S
;S
包含91 72 5 13
。 - 下一项是
*
;从S
弹出13
和5
;将*
应用到5
和13
,给出65
;将65
推到S
上;S
包含91 72 65
。 - 下一项是
–
;从S
弹出65
和72
;将–
应用到72
和65
,给出7
;将7
推到S
上;S
包含91 7
。 - 下一项是
/
;从S
弹出7
和91
;将/
应用到91
和7
,给出13
;将13
推到S
上;S
包含13
。 - 我们已经到达遍历的终点;我们弹出
S
,得到13
—表达式的结果。
注意,当操作数从栈中弹出时,第一个弹出的是第二个操作数,第二个弹出的是第一个操作数。这对加法和乘法无关紧要,但对减法和除法很重要。
8.4 表示二叉树
二叉树的每个节点至少由三个字段组成:包含节点数据的字段、指向左子树的指针和指向右子树的指针。例如,假设存储在每个节点的数据是一个单词。我们可以从编写一个包含三个实例变量的类(TreeNode
)和一个创建TreeNode
对象的构造函数开始。
class TreeNode {
NodeData data;
TreeNode left, right;
TreeNode(NodeData d) {
data = d;
left = right = null;
}
}
为了保持选项的开放性,我们用一种我们称之为NodeData
的通用数据类型来定义TreeNode
。任何想要使用TreeNode
的程序都必须提供自己对NodeData
的定义。
例如,如果一个节点上的数据是一个整数,NodeData
可以定义如下:
class NodeData {
int num;
public NodeData(int n) {
num = n;
}
} //end class NodeData
如果数据是字符,可以使用类似的定义。但是我们并不局限于单字段数据。可以使用任意数量的字段。稍后,我们将编写一个程序,对一篇文章中的单词进行频率统计。每个节点将包含一个单词及其频率计数。对于该计划,NodeData
将包含以下内容:
class NodeData {
String word;
int freq;
public NodeData(String w) {
word = w;
freq = 0;
}
} //end class NodeData
除了树的节点,我们还需要知道树的根。请记住,一旦我们知道了根,我们就可以通过左右指针访问树中的所有节点。因此,二叉树仅由其根来定义。我们将开发一个BinaryTree
类来处理二叉树。唯一的实例变量将是root
。课程将按如下方式开始:
class BinaryTree {
TreeNode root; // the only field in this class
BinaryTree() {
root = null;
}
//methods in the class
} //end class BinaryTree
这个构造函数并不是真正必需的,因为当一个BinaryTree
对象被创建时,Java 会将root
设置为null
。然而,我们包含它是为了强调,在空二叉树中,root
是null
。
如果你愿意,你可以把TreeNode
类放在它自己的文件TreeNode.java
中,并声明它为public
。然而,在我们的程序中,我们将把TreeNode
类和BinaryTree
放在同一个文件中,因为它只被BinaryTree
使用。为此,我们必须省略单词public
并写成class TreeNode
。
8.5 构建二叉树
让我们写一个构建二叉树的函数。假设我们想要构建一个由单个节点组成的树,如下所示:
数据将作为A @ @
提供。每个@表示一个空指针的位置。
为了构建以下内容,我们将提供数据作为A B @ @ C @ @
:
每个节点紧跟着它的左子树,然后是它的右子树。
相比之下,为了构建以下内容,我们将提供数据作为A B @ C @ @ @
。
C
后面的两个@s
表示其左右子树(空),最后一个@
表示A
的右子树(空)。
为了构建下面的内容,我们提供数据作为C E F @ H @ @ B @ @ G A @ @ N J @ @ K @ @
。
给定这种格式的数据,下面的函数将构建树并返回指向其根的指针:
static TreeNode buildTree(Scanner in) {
String str = in.next();
if (str.equals("@")) return null;
TreeNode p = new TreeNode(new NodeData(str));
p.left = buildTree(in);
p.right = buildTree(in);
return p;
} //end buildTree
该函数将从与Scanner
、in
相关的输入流中读取数据。它使用了NodeData
的如下定义:
class NodeData {
String word;
public NodeData(String w) {
word = w;
}
} //end class NodeData
我们将从下面的构造函数中调用buildTree
:
public BinaryTree(Scanner in) {
root = buildTree(in);
}
假设一个用户类将其树数据存储在文件btree.in
中。它可以用下面的代码创建一个二叉树bt
:
Scanner in = new Scanner(new FileReader("btree.in"));
BinaryTree bt = new BinaryTree(in);
构建了树之后,我们应该想要检查它是否被正确地构建了。一种方法是执行遍历。假设我们想按预先的顺序打印bt
的节点。如果能使用这样的语句就好了:
bt.preOrder();
为此,我们需要在BinaryTree
类中编写一个实例方法preOrder
。该方法如下面的类列表所示。它还包括方法inOrder
和postOrder
。我们还保留了无参数构造函数,因此如果需要,用户可以从一个空的二叉树开始。
二叉树类
import java.util.*;
public class BinaryTree {
TreeNode root;
public BinaryTree() {
root = null;
}
public BinaryTree(Scanner in) {
root = buildTree(in);
}
public static TreeNode buildTree(Scanner in) {
String str = in.next();
if (str.equals("@")) return null;
TreeNode p = new TreeNode(new NodeData(str));
p.left = buildTree(in);
p.right = buildTree(in);
return p;
} //end buildTree
public void preOrder() {
preOrderTraversal(root);
}
public void preOrderTraversal(TreeNode node) {
if (node!= null) {
node.data.visit();
preOrderTraversal(node.left);
preOrderTraversal(node.right);
}
} //end preOrderTraversal
public void inOrder() {
inOrderTraversal(root);
}
public void inOrderTraversal(TreeNode node) {
if (node!= null) {
inOrderTraversal(node.left);
node.data.visit();
inOrderTraversal(node.right);
}
} //end inOrderTraversal
public void postOrder() {
postOrderTraversal(root);
}
public void postOrderTraversal(TreeNode node) {
if (node!= null) {
postOrderTraversal(node.left);
postOrderTraversal(node.right);
node.data.visit();
}
} //end postOrderTraversal
} //end class BinaryTree
遍历都使用语句node.data.visit();
。因为node.data
是一个NodeData
对象,所以NodeData
类应该包含方法visit
。在这个例子中,我们只打印节点上的值,所以我们写visit
如下:
public void visit() {
System.out.printf("%s ", word);
}
我们现在编写程序 P8.1 ,它构建了一个二叉树,并按照前序、中序和后序打印节点。像往常一样,我们可以将类BinaryTree
声明为public
,并将其存储在自己的文件BinaryTree.java
中。我们也可以将类TreeNode
声明为public
,并将其存储在自己的文件TeeeNode.java
中。但是,如果您更喜欢将整个程序放在一个文件BinaryTreeTest.java
中,您可以省略单词public
并在程序 P8.1 中指示的位置包含类TreeNode
和BinaryTree
。
程序 P8.1
import java.io.*;
import java.util.*;
public class BinaryTreeTest {
public static void main(String[] args) throws IOException {
Scanner in = new Scanner(new FileReader("btree.in"));
BinaryTree bt = new BinaryTree(in);
System.out.printf("\nThe pre-order traversal is: ");
bt.preOrder();
System.out.printf("\n\nThe in-order traversal is: ");
bt.inOrder();
System.out.printf("\n\nThe post-order traversal is: ");
bt.postOrder();
System.out.printf("\n\n");
in.close();
} // end main
} //end class BinaryTreeTest
class NodeData {
String word;
public NodeData(String w) {
word = w;
}
public void visit() {
System.out.printf("%s ", word);
}
} //end class NodeData
// class TreeNode goes here
// class BinaryTree goes here
如果btree.in
包含C E F @ H @ @ B @ @ G A @ @ N J @ @ K @ @
,那么程序 P8.1 构建如下树:
然后,它打印如下遍历:
The pre-order traversalis: C E F H B G A N J K
The in-order traversalis: F H E B C A G J N K
The post-order traversalis: H F B E A J K N G C
buildTree
方法不限于单字符数据;可以使用任何字符串(不包含空格,因为我们使用%s
来读取数据)。
例如,如果btree.in
包含以下内容:
hat din bun @ @ fan @ @ rum kit @ @ win @ @
然后程序 P8.1 构建如下树:
然后,它打印如下遍历:
The pre-order traversal is: hat din bun fan rum kit win
The in-order traversal is: bun din fan hat kit rum win
The post-order traversal is: bun fan din kit win rum hat
顺便说一下,注意二叉树的有序和前序遍历唯一地定义了该树。顺序内和顺序后都一样。然而,前序和后序并不唯一地定义树。换句话说,可能有两个不同的树 A 和 B,其中 A 的前序和后序遍历分别与 B 的前序和后序遍历相同。作为练习,举两个这样的树的例子。
8.6 二分搜索法树
考虑一个可能的二叉树,由图 8-3 所示的三个字母的单词组成。
图 8-3 。二叉查找树和一些三个字母的单词
这是一种特殊的二叉树。它有这样的性质,给定任何一个节点,左子树中的单词比该节点上的单词“小”,右子树中的单词比该节点上的单词“大”。(此处,较小和较大指字母顺序。)
这样的树叫做二叉查找树 (BST) 。它使用类似于数组二分搜索法的搜索方法来帮助搜索给定的键。
考虑对ria
的搜索。从根开始,ria
与ode
相比较。由于ria
比ode
大(按字母顺序排列),我们可以得出结论,如果它在树中,那么它一定在右边的子树中。一定是这样,因为左子树中的所有节点都小于ode
。
跟随ode
的右子树,我们接下来比较ria
和tee
。由于ria
比tee
小,我们沿着tee
的左子树。然后我们比较ria
和ria
,搜索成功结束。
但是如果我们在搜索fun
呢?
fun
比ode
小,所以我们走左边。fun
比lea
小,所以我们再往左走。fun
大于era
,所以必须向右走。
但是由于era
的右子树是空的,我们可以断定fun
不在树中。如果有必要将fun
添加到树中,注意我们也已经找到了必须添加的地方。必须添加为era
的右子树,如图图 8-4 所示。
图 8-4 。添加乐趣后的 BST
因此,二叉查找树不仅方便了搜索,而且如果没有找到某个项目,也可以很容易地将其插入。它结合了二分搜索法的速度优势和链表的简单插入。
图 8-3 中绘制的树是七个给定单词的最佳二叉查找树。这意味着它是这些单词的最佳可能的树,从这个意义上说,没有更浅的二叉树可以从这些单词中建立。在包含这些单词的线性数组中,它会给出与二分搜索法相同的比较次数来查找一个键。
但这并不是这些词唯一可能的搜索树。假设单词一次出现一个,当每个单词出现时,它被添加到树中,使得树保持二叉查找树。最终构建的树将取决于单词出现的顺序。例如,假设单词按以下顺序出现:
mac tee ode era ria lea vim
最初,树是空的。当mac
进来的时候,就变成了树根。
- 接下来是
tee
,并与mac
进行比较。由于tee
比较大,所以作为mac
的右子树插入。 ode
接下来并且大于mac
,所以我们向右走;ode
比 tee 小,所以作为tee
的左子树插入。era
是 next,比mac
小,所以作为mac
的左子树插入。
到目前为止构建的树如图 8-5 所示。
图 8-5 。添加 mac、tee、ode、era 后的 BST
ria
是 next 并且大于mac
,所以我们向右走;它比tee
小,所以我们走左边;它大于ode
,所以作为ode
的右子树插入。
按照这个过程,lea
作为era
的右子树插入,vim
作为tee
的右子树插入,得到如图图 8-6 所示的最终树。
图 8-6 。添加所有七个单词后的 BST
请注意,获得的树与最佳搜索树有很大不同。查找给定单词所需的比较次数也发生了变化。例如,ria
现在需要四次比较;以前需要三个,lea
现在需要三个,而以前需要两个。但也不全是坏消息;era
现在需要两个,而以前需要三个。
可以证明,如果单词以随机顺序出现,那么给定单词的平均搜索时间大约是最佳搜索树的平均时间的 1.4 倍,即对于具有 n 个节点的树,1.4 log2n。
但是最坏的情况呢?如果单词按字母顺序排列,那么构建的树将如图 8-7 所示。
图 8-7 。退化的树
搜索这样的树被简化为链表的顺序搜索。这种树叫做退化树 ??。某些单词的顺序会给一些非常不平衡的树。作为练习,按照下列单词顺序画出获得的树:
- 我是来喝茶的
- 我是来喝茶的麦克瑞德
- 我来的时候是莱娅·泰里尔·麦克·奥德 vim era lea tee ria mac ode
- lea mac 我来喝茶是笑得 ode
8.7 建造一座二叉查找树
我们现在编写一个函数,在二叉查找树中查找或插入一个项目。假设前面定义了TreeNode
和BinaryTree
,我们编写函数findOrInsert
,它是BinaryTree
类中的一个实例方法。该函数在树中搜索一个NodeData
项目d
。如果找到了,它将返回一个指向该节点的指针。如果没有找到,该项将被插入到树中适当的位置,函数将返回一个指向新节点的指针。
public TreeNode findOrInsert(NodeData d) {
if (root == null) return root = new TreeNode(d);
TreeNode curr = root;
int cmp;
while ((cmp = d.compareTo(curr.data)) != 0) {
if (cmp < 0) { //try left
if (curr.left == null) return curr.left = new TreeNode(d);
curr = curr.left;
}
else { //try right
if (curr.right == null) return curr.right = new TreeNode(d);
curr = curr.right;
}
}
//d is in the tree; return pointer to the node
return curr;
} //end findOrInsert
在while
条件中,我们使用表达式d.compareTo(curr.data)
。这表明我们需要在NodeData
类中编写一个compareTo
方法来比较两个NodeData
对象。该方法如下所示:
public int compareTo(NodeData d) {
return this.word.compareTo(d.word);
}
它只是从String
类中调用compareTo
方法,因为NodeData
只包含一个String
对象。即使类中有其他字段,如果我们愿意,我们仍然可以决定基于word
字段或任何其他字段来比较两个NodeData
对象。
8.7.1 示例:词频统计
我们将通过编写一个程序来计算一篇文章中单词的出现频率,以此来说明目前为止所发展的思想。我们将把单词存储在二叉查找树中。在树中搜索每个输入的单词。如果没有找到这个单词,它将被添加到树中,并且它的频率计数被设置为 1。如果找到了这个词,那么它的频率计数就增加 1。在输入结束时,对树的有序遍历按字母顺序给出单词。
首先,我们必须定义NodeData
类。这将包括两个字段(一个单词和它的频率),一个构造函数,一个给频率加 1 的函数,compareTo
和visit
。这个类是这样的:
class NodeData {
String word;
int freq;
public NodeData(String w) {
word = w;
freq = 0;
}
public void incrFreq() {
++freq;
}
public int compareTo(NodeData d) {
return this.word.compareTo(d.word);
}
public void visit() {
WordFrequencyBST.out.printf("%-15s %2d\n", word, freq);
}
} //end class NodeData
请注意将频率增加 1 的方法。在visit
中,我们使用对象WordFrequencyBST.out
在一个节点打印数据。我们将很快编写类WordFrequencyBST
,但是现在,注意我们将让它决定输出应该去哪里,并且out
指定输出流。如果您愿意,您可以使用System.out.printf
将结果发送到标准输出流。
构建搜索树的算法要点如下:
create empty tree; set root to NULL
while (there is another word) {
get the word
search for word in tree; insert if necessary and set frequency to 0
add 1 to frequency //for an old word or a newly inserted one
}
print words and frequencies
对于我们的程序,我们将把一个单词定义为任何连续的大写或小写字母序列。换句话说,任何非字母都可以构成一个单词。特别是,空格和标点符号将分隔一个单词。如果in
是一个Scanner
对象,我们可以用这个语句指定这个信息:
in.useDelimiter("[^a-zA-Z]+"); // ^ means "not"
方括号内的部分表示“任何不是小写或大写字母的字符”,而+表示这些字符中的一个或多个。
通常,Scanner
使用空白来分隔使用next()
读取的标记。但是,我们可以改变这一点,并指定我们希望用作分隔符的任何字符。例如,要使用冒号作为分隔符,我们可以这样写:
in.useDelimiter(":");
当我们在程序中使用in.next()
时,它将返回一个字符串,该字符串包含直到下一个冒号的字符,但不包括下一个冒号。要使用冒号或逗号作为分隔符,我们可以这样写:
in.useDelimiter("[:,]"); //make a set using [ and ]
方括号表示一组。要使用冒号、逗号、句号或问号,我们这样写:
in.useDelimiter("[:,\\.\\?]");
句号和问号是所谓的 meta 字符(用于特殊目的),所以我们必须使用转义序列来指定每一个。还有\?。回想一下,在一个字符串中,\是由\指定的。
如果我们想指定一个定界符是任何一个小写字母而不是的字符,我们这样写:
in.useDelimiter("[^a-z]"); // ^ denotes negation, "not"
表达式a-z
表示一个范围——从a
到z
。
如果我们在右方括号后添加+
,它表示“一个或多个”非小写字符的序列。因此,因为我们希望分隔符是“一个或多个”非字母(既不是大写也不是小写)的序列,所以我们这样写:
in.useDelimiter("[^a-zA-Z]+");
我们现在编写程序 P8.2 来对文件wordFreq.in
中的单词进行频数统计。它只是反映了我们之前概述的算法。
程序 P8.2
import java.io.*;
import java.util.*;
public class WordFrequencyBST {
static Scanner in;
static PrintWriter out;
public static void main(String[] args) throws IOException {
in = new Scanner(new FileReader("wordFreq.in"));
out = new PrintWriter(new FileWriter("wordFreq.out"));
BinaryTree bst = new BinaryTree();
in.useDelimiter("[^a-zA-Z]+");
while (in.hasNext()) {
String word = in.next().toLowerCase();
TreeNode node = bst.findOrInsert(new NodeData(word));
node.data.incrFreq();
}
out.printf("\nWords Frequency\n\n");
bst.inOrder();
in.close(); out.close();
} // end main
} //end class WordFrequencyBST
class NodeData {
String word;
int freq;
public NodeData(String w) {
word = w;
freq = 0;
}
public void incrFreq() {
++freq;
}
public int compareTo(NodeData d) {
return this.word.compareTo(d.word);
}
public void visit() {
WordFrequencyBST.out.printf("%-15s %2d\n", word, freq);
}
} //end class NodeData
// class TreeNode goes here
// class BinaryTree (with findOrInsert added) goes here
注意,in
和out
被声明为static
类变量。这对于in
是不必要的,它本可以在main
申报,因为它只在那里使用。然而,NodeData
类的visit
方法需要知道将输出发送到哪里,所以它需要访问out
。我们通过将out
声明为类变量来授予它访问权限。
由于findOrInsert
需要一个NodeData
对象作为它的参数,我们必须从word
创建一个NodeData
对象,然后在这个语句中调用它:
TreeNode node = bst.findOrInsert(new NodeData(word));
搜索树的有序遍历按字母顺序产生单词。
假设文件wordFreq.in
包含以下数据:
If you can trust yourself when all men doubt you;
If you can dream - and not make dreams your master;
If you can talk with crowds and keep your virtue;
If all men count with you, but none too much;
If neither foes nor loving friends can hurt you;
当程序 P8.2 运行时,它将其输出发送到文件wordFreq.out
。以下是输出:
Words Frequency
all 2
and 2
but 1
can 4
count 1
crowds 1
doubt 1
dream 1
dreams 1
foes 1
friends 1
hurt 1
if 5
keep 1
loving 1
make 1
master 1
men 2
much 1
neither 1
none 1
nor 1
not 1
talk 1
too 1
trust 1
virtue 1
when 1
with 2
you 6
your 2
yourself 1
8.8 用父指针构建二叉树
我们已经看到了如何使用递归(使用栈实现)或显式栈来执行前序、按序和后序遍历。我们现在来看第三种可能性。首先,让我们构建树,使它包含“父”指针。
每个节点现在都包含一个附加字段—指向其父节点的指针。根的parent
字段将是null
。例如在图 8-8 所示的树中,H
的父字段指向F
, A
的父字段指向G
,G
的父字段指向C
。
图 8-8 。带有一些父指针的二叉树
为了表示这样的树,我们现在声明TreeNode
如下:
class TreeNode {
NodeData data;
TreeNode left, right, parent;
public TreeNode(NodeData d) {
data = d;
left = right = parent = null;
}
} //end class TreeNode
我们现在可以将buildTree
改写如下:
public static TreeNode buildTree(Scanner in) {
String str = in.next();
if (str.equals("@")) return null;
TreeNode p = new TreeNode(new NodeData(str));
p.left = buildTree(in);
if (p.left != null) p.left.parent = p;
p.right = buildTree(in);
if (p.right != null) p.right.parent = p;
return p;
} //end buildTree
在我们构建了节点p
的左子树之后,我们检查它是否是null
。如果是,那就没什么可进一步做的了。如果不是,并且q
是它的根,我们将q.parent
设置为p
。类似的评论也适用于右边的子树。
有了父字段,我们可以在没有递归的情况下进行遍历,也没有递归所隐含的参数和局部变量的堆叠/拆分。例如,我们可以执行如下的有序遍历:
get the first node in in-order; call it “node”
while (node is not null) {
visit node
get next node in in-order
}
给定树的非空根,我们可以按顺序找到第一个节点,如下所示:
TreeNode node = root;
while (node.left != null) node = node.left;
我们尽可能向左走。当我们不能再往前走的时候,我们已经按顺序到达了第一个节点。代码执行后,node
将按顺序指向第一个节点。
要解决的主要问题如下:给定一个指向任何节点的指针,返回一个指向它的有序后继节点的指针,也就是说,如果有的话,它的有序后继节点在之后。按顺序排列的最后一个节点将没有后继节点。
有两种情况需要考虑:
-
If the node has a nonempty right subtree, then its in-order successor is the first node in the in-order traversal of that right subtree. We can find it with the following code, which returns a pointer to the in-order successor:
if (node.right != null) { node = node.right; while (node.left != null) node = node.left; return node; }
例如,考虑下面的树:
通过向右一次(到
N
)然后尽可能向左(到J
)找到G
的有序后继。J
是G
的继承人。 -
If the node has an empty right subtree, then its in-order successor is one of its ancestors. Which one? It’s the lowest ancestor for which the given node is in its left subtree. For example, what is the in-order successor of
B
?我们来看看
B
的父母E
。由于B
在E
的右子树中,所以不是E
。然后我们看一下
E
的父节点C
。由于E
(因此,B
)在C
的左子树中,我们推断C
是B
的有序后继。然而,请注意,
K
是顺序中的最后一个节点,没有后继节点。如果我们跟踪来自K
的父指针,我们永远找不到左子树中有K
的指针。在这种情况下,我们的函数将返回null
。
利用这些想法,我们将inOrderTraversal
写成BinaryTree
类中的一个实例方法,将inOrderSuccessor
写成它调用的一个静态方法。
public void inOrderTraversal() {
if (root == null) return;
//find first node in in-order
TreeNode node = root;
while (node.left != null) node = node.left;
while (node != null) {
node.data.visit(); //from the NodeData class
node = inOrderSuccessor(node);
}
} //end inOrderTraversal
private static TreeNode inOrderSuccessor(TreeNode node) {
if (node.right != null) {
node = node.right;
while (node.left != null) node = node.left;
return node;
}
//node has no right subtree; search for the lowest ancestor of the
//node for which the node is in the ancestor's left subtree
//return null if there is no successor (node is the last in in-order)
TreeNode parent = node.parent;
while (parent != null && parent.right == node) {
node = parent;
parent = node.parent;
}
return parent;
} //end inOrderSuccessor
作为练习,编写类似的函数来执行前序和后序遍历。我们将在下一节编写一个测试inOrderTraversal
的程序。
8.8.1 使用父指针构建二叉查找树
我们可以从BinaryTree
类中修改findOrInsert
函数来构建一个带有父指针的搜索树。这可以通过以下方式实现:
public TreeNode findOrInsert(NodeData d) {
//Searches the tree for d; if found, returns a pointer to the node.
//If not found, d is added and a pointer to the new node returned.
//The parent field of d is set to point to its parent.
TreeNode curr, node;
int cmp;
if (root == null) {
node = new TreeNode(d);
node.parent = null;
return root = node;
}
curr = root;
while ((cmp = d.compareTo(curr.data)) != 0) {
if (cmp < 0) { //try left
if (curr.left == null) {
curr.left = new TreeNode(d);
curr.left.parent = curr;
return curr.left;
}
curr = curr.left;
}
else { //try right
if (curr.right == null) {
curr.right = new TreeNode(d);
curr.right.parent = curr;
return curr.right;
}
curr = curr.right;
} //end else
} //end while
return curr; //d is in the tree; return pointer to the node
} //end findOrInsert
当我们需要向树中添加一个节点(N
)时,如果curr
指向新节点将要悬挂的节点,我们只需将N
的父字段设置为curr
。
我们可以用程序 P8.3 测试findOrInsert
和inOrderTraversal
。
程序 P8.3
import java.io.*;
import java.util.*;
public class P8_3BinarySearchTreeTest {
public static void main(String[] args) throws IOException {
Scanner in = new Scanner(new FileReader("words.in"));
BinaryTree bst = new BinaryTree();
in.useDelimiter("[^a-zA-Z]+");
while (in.hasNext()) {
String word = in.next().toLowerCase();
TreeNode node = bst.findOrInsert(new NodeData(word));
}
System.out.printf("\n\nThe in-order traversal is: ");
bst.inOrderTraversal();
System.out.printf("\n");
in.close();
} // end main
} //end class P8_3BinarySearchTreeTest
class NodeData {
String word;
public NodeData(String w) {
word = w;
}
public int compareTo(NodeData d) {
return this.word.compareTo(d.word);
}
public void visit() {
System.out.printf("%s ", word);
}
} //end class NodeData
class TreeNode {
NodeData data;
TreeNode left, right, parent;
public TreeNode(NodeData d) {
data = d;
left = right = parent = null;
}
} //end class TreeNode
//The BinaryTree class - only the methods relevant to this problem are shown
class BinaryTree {
TreeNode root;
public BinaryTree() {
root = null;
}
public void inOrderTraversal() {
if (root == null) return;
//find first node in in-order
TreeNode node = root;
while (node.left != null) node = node.left;
while (node != null) {
node.data.visit(); //from the NodeData class
node = inOrderSuccessor(node);
}
} //end inOrderTraversal
private static TreeNode inOrderSuccessor(TreeNode node) {
if (node.right != null) {
node = node.right;
while (node.left != null) node = node.left;
return node;
}
//node has no right subtree; search for the lowest ancestor of the
//node for which the node is in the ancestor's left subtree
//return null if there is no successor (node is the last in in-order)
TreeNode parent = node.parent;
while (parent != null && parent.right == node) {
node = parent;
parent = node.parent;
}
return parent;
} //end inOrderSuccessor
//The method findOrInsert from this Section goes here
} //end class BinaryTree
程序 P8.3 从文件words.in
中读取单词,构建搜索树,并执行有序遍历以按字母顺序打印单词。例如,假设words.in
包含以下内容:
mac tee ode era ria lea vim
程序 P8.3 使用父指针构建以下二叉查找树:
然后,它会打印以下内容:
The in-order traversal is: era lea mac ode ria tee vim
8.9 层级顺序遍历
除了前序、按序和后序,另一个有用的遍历是级序。这里,我们从根开始,一层一层地遍历树。在每一层,我们从左到右遍历节点。例如,假设我们有下面的树:
它的层次顺序遍历是C E G B A N F J
。
为了执行层次顺序遍历,我们需要使用一个队列。以下算法说明了如何操作:
add the root to the queue, Q
while (Q is not empty) {
remove item at the head of Q and store in p
visit p
if (left(p) is not null) add left(p) to Q
if (right(p) is not null) add right(p) to Q
}
对于前面的树,会出现以下情况:
- 将
C
放在Q
上。 Q
不为空,所以移除并访问C
;将E
和G
添加到Q
中,后者现在有E G
。Q
不为空,所以移除并访问E
;把B
加到Q
,现在已经有了G B
。Q
非空;移除并访问G
;将A
和N
添加到Q
中,后者现在有B A N
。Q
非空;移除并访问B
;把F
加到Q
,现在已经有了A N F
。Q
非空;移除并访问A
;Q
什么都不加,现在有了N F
。Q
非空;移除并访问N
;把J
加到Q
,现在已经有了F J
。Q
非空;移除并访问F
;Q
什么都不加,现在有了J
。Q
非空;移除并访问J
;Q
什么都不加,现在是空的。Q
空;遍历结束时已经按顺序访问了节点C E G B A N F J
。
我们将需要以下内容来执行队列操作。首先,我们将类QueueData
定义如下:
public class QueueData {
TreeNode node;
public QueueData(TreeNode n) {
node = n;
}
} //end class QueueData
接下来,我们定义类QNode
:
public class QNode {
QueueData data;
QNode next;
public QNode(QueueData d) {
data = d;
next = null;
}
} //end class QNode
最后,这里是类Queue
:
public class Queue {
QNode head = null, tail = null;
public boolean empty() {
return head == null;
}
public void enqueue(QueueData nd) {
QNode p = new QNode(nd);
if (this.empty()) {
head = p;
tail = p;
}
else {
tail.next = p;
tail = p;
}
} //end enqueue
public QueueData dequeue() {
if (this.empty()) {
System.out.printf("\nAttempt to remove from an empty queue\n");
System.exit(1);
}
QueueData hold = head.data;
head = head.next;
if (head == null) tail = null;
return hold;
} //end dequeue
} //end class Queue
注意,如果你把QueueData
和Queue
或者使用Queue
的程序放在同一个文件里,你必须省略public
这个词。类似的评论也适用于QNode
这个阶层。
使用Queue
和QueueData
,我们可以将BinaryTree
中的实例方法levelOrderTraversal
编写如下:
public void levelOrderTraversal() {
Queue Q = new Queue();
Q.enqueue(new QueueData(root));
while (!Q.empty()) {
QueueData temp = Q.dequeue();
temp.node.data.visit();
if (temp.node.left != null) Q.enqueue(new QueueData(temp.node.left));
if (temp.node.right != null) Q.enqueue(new QueueData(temp.node.right));
}
} //end levelOrderTraversal
将所有这些放在一起,我们编写了程序 P8.4 ,它使用来自文件btree.in
的数据构建了一棵树,并执行了一次层次顺序遍历。注意,为了将整个程序放在一个文件中,只有包含main
的类被声明为public
。在其他类中,只显示了与这个问题相关的方法。
程序 P8.4
import java.io.*;
import java.util.*;
public class LevelOrderTest {
public static void main(String[] args) throws IOException {
Scanner in = new Scanner(new FileReader("btree.in"));
BinaryTree bt = new BinaryTree(in);
System.out.printf("\n\nThe level-order traversal is: ");
bt.levelOrderTraversal();
System.out.printf("\n");
in.close();
} // end main
} //end class LevelOrderTest
class NodeData {
String word;
public NodeData(String w) {
word = w;
}
public void visit() {
System.out.printf("%s ", word);
}
} //end class NodeData
class TreeNode {
NodeData data;
TreeNode left, right, parent;
public TreeNode(NodeData d) {
data = d;
left = right = parent = null;
}
} //end class TreeNode
//The BinaryTree class - only the methods relevant to this problem are shown
class BinaryTree {
TreeNode root;
public BinaryTree() {
root = null;
}
public BinaryTree(Scanner in) {
root = buildTree(in);
}
public static TreeNode buildTree(Scanner in) {
String str = in.next();
if (str.equals("@")) return null;
TreeNode p = new TreeNode(new NodeData(str));
p.left = buildTree(in);
p.right = buildTree(in);
return p;
} //end buildTree
public void levelOrderTraversal() {
Queue Q = new Queue();
Q.enqueue(new QueueData(root));
while (!Q.empty()) {
QueueData temp = Q.dequeue();
temp.node.data.visit();
if (temp.node.left != null) Q.enqueue(new QueueData(temp.node.left));
if (temp.node.right != null) Q.enqueue(new QueueData(temp.node.right));
}
} //end levelOrderTraversal
} //end class BinaryTree
class QueueData {
TreeNode node;
public QueueData(TreeNode n) {
node = n;
}
} //end class QueueData
class QNode {
QueueData data;
QNode next;
public QNode(QueueData d) {
data = d;
next = null;
}
} //end class QNode
class Queue {
QNode head = null, tail = null;
public boolean empty() {
return head == null;
}
public void enqueue(QueueData nd) {
QNode p = new QNode(nd);
if (this.empty()) {
head = p;
tail = p;
}
else {
tail.next = p;
tail = p;
}
} //end enqueue
public QueueData dequeue() {
if (this.empty()) {
System.out.printf("\nAttempt to remove from an empty queue\n");
System.exit(1);
}
QueueData hold = head.data;
head = head.next;
if (head == null) tail = null;
return hold;
} //end dequeue
} //end class Queue
假设文件btree.in
包含以下内容:
C E @ B F @ @ @ G A @ @ N J @ @ @
程序 P8.4 将构建本节开头所示的树,并打印以下内容:
The level-order traversal is: C E G B A N F J
8.10 一些有用的二叉树函数 s
我们现在向你展示如何编写一些函数(在类BinaryTree
中)来返回关于二叉树的信息。第一种方法计算树中节点的数量:
public int numNodes() {
return countNodes(root);
}
private int countNodes(TreeNode root) {
if (root == null) return 0;
return 1 + countNodes(root.left) + countNodes(root.right);
}
如果bt
是二叉树,bt.numNodes()
将返回树中节点的个数。对节点的计数被委托给private
功能countNodes
。
下一个函数返回树中叶子的数量:
public int numLeaves() {
return countLeaves(root);
}
private int countLeaves(TreeNode root) {
if (root == null) return 0;
if (root.left == null && root.right == null) return 1;
return countLeaves(root.left) + countLeaves(root.right);
}
下一个返回树的高度:
public int height() {
return numLevels(root);
}
private int numLevels(TreeNode root) {
if (root == null) return 0;
return 1 + Math.max(numLevels(root.left), numLevels(root.right));
}
Math.max
返回两个参数中较大的一个。
建议您在一些样本树上运行这些函数,以验证它们确实返回了正确的值。
8.11 二叉查找树删除
考虑从二叉查找树(BST)中删除一个节点以便它仍然是 BST 的问题。有三种情况需要考虑:
- 该节点是一个叶子。
- (a)该节点没有左子树。
(b)节点没有右边的子树。 - 该节点具有非空的左右子树。
我们使用图 8-9 所示的 BST 来说明这些情况。
图 8-9 。一个二叉查找树
案例 1 很简单。例如,要删除P
,我们只需将N
的右边子树设置为 null。案例 2 也很简单。为了删除A
(没有左子树),我们用它的右子树C
代替它。为了删除F
(没有右边的子树),我们用它的左边的子树A
代替它。
情况 3 有点困难,因为我们必须考虑如何处理悬挂在节点上的两个子树。比如我们怎么删除L
?一种方法是用它的有序后继者N
替换L
,其中必须有一个空的左子树。为什么呢?因为根据定义,一个节点的有序后继节点是其右子树中的第一个节点(按顺序)。并且这个第一个节点(在任何树中)是通过尽可能向左走找到的。
由于N
没有左子树,我们将把它的左链接设置为L
的左子树。我们将N
(本例中为R
)的父节点的左侧链接设置为指向N
的右侧子树P
。最后我们将N
的右链接设置为指向L
的右子树,给出图 8-10 所示的树。
图 8-10 。在图 8-9 中删除 L 后的 BST
另一种方式是想象节点N
的内容被复制到节点L
中。并将N
(也就是R)
)的父节点的左边链接设置为指向N
(也就是P
)的右边子树。
在我们的算法中,我们将把要删除的节点视为一个子树的根。我们将删除根,并返回一个指向重建树的根的指针。
假设我们用指向节点L
( 图 8-9 )的指针调用deleteNode
作为自变量。该函数将删除L
,并返回一个指向以下树的指针:
由于L
是H
的右子树,我们现在可以将H
的右子树设置为指向这棵树的根N
。
8.12 以二叉树表示的数组
一棵完全二叉树是这样的树,其中每个非叶子节点有两个非空子树,并且所有的叶子都在同一层。图 8-11 显示了一些完整的二叉树。
图 8-11 。完全二叉树
第一个是高度为 1 的完全二叉树,第二个是高度为 2 的完全二叉树,第三个是高度为 3 的完全二叉树。对于一棵高度为 n 的完全二叉树,树中的节点数为 2n-1。
考虑第三棵树。让我们按照图 8-12 所示对节点进行编号。
图 8-12 。逐层对节点进行编号
从根节点的 1 开始,我们按照从上到下和从左到右的顺序对每一层的节点进行编号。
注意,如果一个节点具有标签 n ,则其左子树具有标签 2 n ,其右子树具有标签 2 n + 1。
如果节点存储在数组T[1..7]
中,如下所示:
然后
- T[1]是根。
- T[i]的左子树是 T[2i]如果 2i <= 7,否则为
null
。 - T[i]的右子树是 T[2i+1]如果 2i+1 <= 7,否则为
null
。 - T[i]的父代是 Ti/2。
基于此,数组是一个完整二叉树的表示。换句话说,给定数组,我们可以很容易地构造它所代表的二叉树。
如果数组中的元素个数为 2n–1,对于某些 n ,则该数组表示一棵完整的二叉树。如果元素的数量是某个其他值,数组表示一个几乎完整的二叉树。
一棵几乎完全二叉树是这样一棵树,其中:
- 除了最低层之外,所有层都被完全填满。
- 最底层的节点(所有叶子)尽可能靠左。
如果节点如前所示进行编号,那么所有的叶子将被标上从n/2+1
到n
的连续编号。最后一个非叶节点将具有标签n/2
。例如,考虑如图 8-13 中所示的有十个节点的树。
图 8-13 。一个由十个节点组成的树,逐层标记
注意叶子的编号是从 6 到 10。例如,如果H
是B
的右子树而不是左子树,那么该树将不是“几乎完整的”,因为最底层的叶子不会“尽可能地靠左”
以下大小为 10 的数组可以代表这个几乎完整的二叉树:
一般而言,如果树由数组T[1..n]
表示,则以下成立:
- T[1]是根。
- T[i]的左子树是 T[2i]如果 2i <= n,否则为 null。
- T[i]的右子树是 T[2i+1]如果 2i+1 <= n,否则为 null。
- T[i]的父代是 Ti/2。
从另一个角度来看,确实有一个几乎完整的二叉树,有 n 个节点,大小为 n 的数组表示这棵树。
几乎完整的二叉树中没有“洞”;没有空间在现有节点之间添加节点。添加节点的唯一位置是在最后一个节点之后。
例如,图 8-14 不是“几乎完整的”,因为在B
的右边子树上有一个“洞”。
图 8-14 。空的 B 的右子树使得这不是“几乎完全的”
有了洞,A
(在位置 6)的左子树是不是现在在位置 62 = 12,右子树不在位置 62+1 =13。这种关系只有在树几乎完成时才成立。
给定一个表示具有 n 个节点的几乎完整的二叉树的数组T[1..n]
,我们可以通过调用下面的函数inOrder(1, n)
来执行树的有序遍历:
public static void inOrder(int h, int n) {
if (h <= n) {
inOrder(h * 2, n);
visit(h); //or visit(T[h]), if you wish
inOrder(h * 2 + 1, n);
}
} //end inOrder
我们可以为前序和后序遍历编写类似的函数。
与完全二叉树相比,完全二叉树是这样一种树,其中除了一片叶子之外,每个节点都有恰好两个非空子树。图 8-15 是一个完全二叉树的例子。
图 8-15 。完整的二叉树
注意,一棵完整的二叉树总是满的,但是如图图 8-15 所示,一棵完整的二叉树不一定是完整的。几乎完整的二叉树可能是满的,也可能不是满的。
图 8-16 中的树几乎完整但不完整(G
有一个非空子树)。
图 8-16 。几乎完全但不完全的二叉树
然而,如果节点A
被移除,树将几乎完成并且满。
在下一章,我们将解释如何通过解释一个几乎完整的二叉树来排序一个数组。
练习 8
-
二叉树由一个整数键字段和指向左子树、右子树和父树的指针组成。编写构建树所需的声明,并编写创建空树的代码。
-
Each node of a binary tree has fields
left
,right
,key
, andparent
.编写一个函数来返回任意给定节点
x
的有序后继节点。提示:如果节点x
的右子树为空,并且x
有一个后继y
,那么y
是x
的最低祖先,它的左子树中包含x
。编写一个函数来返回任何给定节点
x
的前序后继节点。编写一个函数来返回任意给定节点
x
的后序后继节点。使用这些函数,编写函数来执行给定二叉树的有序、前序和后序遍历。
-
假设树存储在一个数组中,做练习 2。
-
写一个函数,给定一个二叉查找树的根,删除最小的节点,并返回一个指向重建树的根的指针。
-
写一个函数,给定一个二叉查找树的根,删除最大的节点,并返回一个指向重建树的根的指针。
-
写一个函数,给定二叉查找树的根,删除根并返回一个指向重建树的根的指针。写出用(I)它的有序后继者和(ii)它的有序前趋者替换根的函数。
-
画一个五个节点的非退化二叉树,使得前序和层次序遍历产生相同的结果。
-
写一个函数,给定二叉树的根,返回树的宽,也就是任意级别的最大节点数。
-
二叉查找树包含整数。对于以下每个序列,说明它是否可能是在搜索数字
36
时检查的值序列。如果不能,说明原因。7 25 42 40 33 34 39 36 92 22 91 24 89 20 35 36 95 20 90 24 92 27 30 36 7 46 41 21 26 39 37 24 36
-
Draw the binary search tree (BST) obtained for the following keys assuming they are inserted in the following order:
56 30 61 39 47 35 75 13 21 64 26 73 18
.
对于前面的键,有一个几乎完整的 BST。画出来。
按照产生几乎完整 BST 的顺序列出这些键。
假设几乎完整的树存储在一维数组`num[1..13]`中,写一个递归函数,用于按后序打印整数。
- An imaginary “external” node is attached to each null pointer of a binary tree of n nodes. How many external nodes are there?
如果 **I** 是原树节点的层数之和, **E** 是外部节点的层数之和,证明**E**–**I**= 2*n*。( **I** 称为*内部路径长度*。)
写一个递归函数,给定一个二叉树的根,返回 **I** 。
写一个非递归函数,给定二叉树的根,返回 **I** 。
- Draw the binary tree whose in-order and post-order traversals of the nodes are as follows:
按顺序:`G D P K E N F A T L`
后期订单:`G P D K F N T A L E`
- Draw the binary tree whose pre-order and in-order traversals of the nodes are as follows:
预购:`N D G K P E T F A L`
按顺序:`G D P K E N F A T L`
- 画两个不同的二叉树,使得一棵树的前序和后序遍历与另一棵树的前序和后序遍历相同。
- 编写一个递归函数,在给定二叉树的根和一个键的情况下,使用(I)前序、(ii)按序和(iii)后序遍历来搜索该键。如果找到,返回包含该键的节点;否则,返回
null
。 - Store the following integers in an array
bst[1..15]
such thatbst
represents a complete binary search tree:
`34 23 45 46 37 78 90 2 40 20 87 53 12 15 91`
- 二叉查找树的每个节点包含三个字段——
left
、right
和data
——具有它们通常的含义;data
是正整数字段。写一个有效的函数,给定树的根和key
,返回树中大于key
的最小的数。如果没有这个数字,则返回-1。** - Write a program that takes a Java program as input and outputs the program, numbering the lines, followed by an alphabetical cross-reference listing of all user identifiers; that is, a user identifier is followed by the numbers of all lines in which the identifier appears. If an identifier appears more than once in a given line, the line number must be repeated the number of times it appears.
交叉引用列表必须*而不是*包含 Java 保留字、字符串中的单词或注释中的单词。
九、高级排序
在本章中,我们将解释以下内容:
- 什么是堆以及如何使用
siftDown
执行堆排序 - 如何使用
siftUp
构建一个堆 - 如何分析 heapsort 的性能
- 如何使用堆来实现优先级队列
- 如何使用快速排序对项目列表进行排序
- 如何找到列表中第 k 个最小的项目
- 如何使用外壳(递减增量)排序对项目列表进行排序
在第一章中,我们讨论了两种简单的方法(选择和插入排序)来排序条目列表。在这一章中,我们将详细介绍一些更快的方法——堆排序、快速排序和 Shell(递减增量)排序。
9.1 重头戏
Heapsort 是一种排序方法,将数组中的元素解释为一棵几乎完整的二叉树。考虑下面的数组,它将按升序排序:
我们可以把这个数组想象成一棵几乎完整的 12 节点二叉树,如图图 9-1 所示。
图 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 中的树。
图 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 所示。
图 9-3 。最后一棵树,现在是一堆
9.1.2 分拣过程
在转换为堆之后,注意最大的值 84 位于树的根处。现在,数组中的值形成了一个堆,我们可以按如下方式按升序对它们进行排序:
- 将最后一个项目 32 存储在临时位置。接下来,将 84 移动到最后一个位置(节点 12),释放节点 1。然后,假设 32 在节点 1 中,并移动它,使项目 1 到 11 成为一个堆。这将按如下方式完成:
- 32 与其较大的子节点 79 交换,后者现在移入节点 1。然后,32 进一步与其(新的)更大的子节点 69 交换,该子节点移动到节点 2。
最后 32 和 56 交换,给我们图 9-4 。
图 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 变为空(可用),节点2
到k-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
。
图 9-5 。一个堆,除了节点 1 和 2
考虑图 9-5 。
除了节点 1 和 2,所有其他节点都满足堆属性,因为它们大于或等于其子节点。假设我们想让节点 2 成为一个堆的根。实际上,值 25 小于它的子值(79 和 69)。我们希望编写siftDown
以便下面的调用可以完成这项工作:
siftDown(25, num, 2, 12)
这里,25
是key
,num
是数组,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 个元素从下标1
到n
存储。如果它们从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)来验证这些观察结果。
图 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]
中,流程结束。
图 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
后的堆。
图 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
在37
、25
、43
被读取后,我们将有图 9-9 。
图 9-9 。处理后堆 37,25,43
在65
、48
、84
和73
被读取之后,我们将会有图 9-10 。
图 9-10 。处理后堆 65、48、84、73
在读取了18
、79
、56
、69
、32
之后,我们将得到如图 9-11 中所示的最终堆。
图 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=n*log2n 的比较。然而,更仔细的分析会表明,我们最多只需要进行 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 。堆排序和选择排序的比较
第二列和第三列显示了每种方法进行的比较次数。最后两列显示了每种方法的运行时间(秒),假设计算机每秒可以处理一百万次比较。例如,对 100 万个项目进行排序,选择排序将花费 500,000 秒(差不多 6 天!),而 heapsort 会在不到 40 秒的时间内完成。
9.4 堆和优先级队列
一个优先级队列是这样的队列,其中每个项目都被分配了一些“优先级”,并且它在队列中的位置是基于这个优先级的。优先级最高的项目被放在队列的最前面。以下是可以在优先级队列上执行的一些典型操作:
- 移除(服务)具有最高优先级的项目
- 添加具有给定优先级的项目
- 从队列中删除项目
- 更改项目的优先级,根据新的优先级调整其位置
我们可以把优先级想象成一个整数——整数越大,优先级越高。
很快,我们可以推测,如果我们将队列实现为 max-heap,那么优先级最高的项将位于根,因此可以很容易地将其移除。重新组织堆只需要从根中“筛选”出最后一项。
添加一个项目将涉及到将该项目放置在当前最后一个项目之后的位置,并对其进行筛选,直到找到正确的位置。
要从队列中删除任意一项,我们需要知道它的位置。删除它将涉及到用当前最后一个项目替换它,并向上或向下筛选它以找到它的正确位置。堆将减少一项。
如果我们改变一个项目的优先级,我们可能需要向上或向下筛选来找到它的正确位置。当然,它也可能保持在原来的位置,这取决于变化。
在许多情况下(例如,一台多任务计算机上的一个作业队列),一个作业的优先级可能会随着时间的推移而增加,以便它最终得到服务。在这些情况下,随着每次改变,作业向堆的顶部移动得更近;因此,只需要向上筛选。
在典型的情况下,关于优先级队列中的项目的信息保存在另一个可以快速搜索的结构中,例如二叉查找树。节点中的一个字段将包含用于实现优先级队列的数组中项的索引。
使用作业队列示例,假设我们想要向队列中添加一个项目。比方说,我们可以通过作业编号搜索树,并将项目添加到树中。它的优先级数用于确定它在队列中的位置。该位置存储在树节点中。
如果后来优先级改变了,则该项在队列中的位置被调整,并且这个新位置被存储在树节点中。请注意,调整此项可能还涉及到更改其他项的位置(当它们在堆中上移或下移时),并且还必须为这些项更新树。
9.5 使用快速排序对项目列表进行排序
快速排序的核心是相对于一个叫做枢纽 的值来划分列表的概念。例如,假设给我们以下要排序的列表:
我们可以用第一个值 53 来划分 T2。这意味着将 53 放在这样一个位置,它左边的所有值都小于它,右边的所有值都大于或等于它。简而言之,我们将描述一种算法,该算法将如下划分num
:
数值 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
可能是如何写的。考虑以下阵列:
我们将通过一次遍历数组,相对于num[1]
,53(支点)对其进行划分。我们将依次查看每个数字。如果它比支点大,我们什么也不做。如果它比较小,我们把它移到数组的左边。最初,我们将变量lastSmall
设置为1
;随着方法的进行,lastSmall
将是已知小于枢纽的最后一个项目的索引。我们对num
分区如下:
-
比较
12
和53
;它更小,所以将1
加到lastSmall
(使其成为2
)并将num[2]
与其自身互换。 -
比较
98
和53
;它更大,所以继续前进。 -
比较
63
和53
;它更大,所以继续前进。 -
Compare
18
with53
; it is smaller, so add1
tolastSmall
(making it3
) and swapnum[3]
,98
, with18
.在这个阶段,我们有这个:
-
比较
32
和53
;它比较小,所以把1
加到lastSmall
(使之成为4
),把num[4]
、63
和32
互换。 -
比较
80
和53
;它更大,所以继续前进。 -
Compare
46
with53
; it is smaller, so add1
tolastSmall
(making it5
) and swapnum[5]
,98
, with46
.在此阶段,我们有以下内容:
-
比较
72
和53
;它更大,所以继续前进。 -
比较
21
和53
;它比较小,所以把1
加到lastSmall
(使之成为6
),把num[6]
、63
和21
互换。 -
我们已经到了数组的末尾;交换
num[1]
和num[lastSmall]
;这将枢轴移动到其最终位置(在本例中为6
)。
我们以此结束:
分割点用lastSmall
( 6
)表示。
我们可以将刚刚描述的方法表示为函数partition1
。该功能显示为程序 P9.3 的一部分,我们编写该程序来测试quicksort
和partition1
。
程序 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
。
我们选择 53 作为支点。总的想法是从右边扫描,寻找小于或等于枢轴的键。然后,我们从左侧扫描大于或等于主元的键。我们交换这两个值;这个过程有效地将较小的值放在左边,将较大的值放在右边。
我们用两个变量,lo
和hi
,来标记我们在左边和右边的位置。最初,我们将lo
设置为0
,将hi
设置为11
( n+1
)。然后我们循环如下:
- 从
hi
中减去1
(使其成为10
)。 - 比较
num[hi]
、21
,与53
;它变小了,所以用hi
=10
停止从右边扫描。 - 将
1
添加到lo
(使其成为1
)。 - 比较
num[lo]
、53
,与53
;它没有变小,所以用lo
=1
停止从左边扫描。 lo
(1
)小于hi
(10
),所以互换num[lo]
和num[hi]
。- 从
hi
中减去1
(使其成为9
)。 - 比较
num[hi]
、72
,与53
;它更大,所以减少hi
(使其成为8
)。比较num[hi]
、46
,与53
;它变小了,所以用hi
=8
停止从右边扫描。 - 将
1
添加到lo
(使其成为2
)。 - 比较
num[lo]
、12
,与53
;它更小,所以在lo
上加上1
(使其成为3
)。比较num[lo]
、98
,与53
;它比较大,所以用lo
=3
停止从左边扫描。 lo
(3
) is less thanhi
(8
), so swapnum[lo]
andnum[hi]
.
在这个阶段,我们有`lo` = `3`、`hi` = `8`和`num`如下:
![9781430266198_unFig09-09.jpg](https://gitee.com/OpenDocCN/vkdoc-java-zh/raw/master/docs/adv-topic-java/img/9781430266198_unFig09-09.jpg)
- 从
hi
中减去1
(使其成为7
)。 - 比较
num[hi]
、80
,与53
;它更大,所以减少hi
(使其成为6
)。比较num[hi]
、32
,与53
;它变小了,所以用hi
=6
停止从右边扫描。 - 将
1
添加到lo
(使其成为4
)。 - 比较
num[lo]
、63
,与53
;它比较大,所以用lo
=4
停止从左边扫描。 lo
(4
) is less thanhi
(6
), so swapnum[lo]
andnum[hi]
, giving this:
![9781430266198_unFig09-10.jpg](https://gitee.com/OpenDocCN/vkdoc-java-zh/raw/master/docs/adv-topic-java/img/9781430266198_unFig09-10.jpg)
- 从
hi
中减去1
(使其成为5
)。 - 比较
num[hi]
、18
,与53
;它变小了,所以用hi
=5
停止从右边扫描。 - 将
1
添加到lo
(使其成为5
)。 - 比较
num[lo]
、18
,与53
;它更小,所以在lo
上加上1
(使其成为6
)。比较num[lo]
、63
,与53
;它比较大,所以用lo
=6
停止从左边扫描。 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。
当然,我们必须自己操作栈。每个栈元素将由两个整数组成(比如说left
和right
,这意味着从left
到right
的列表部分仍有待排序。我们可以将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:
增量大小递减(因此术语递减增量排序 ),最后一个是 1。
使用增量 8,我们对数组进行八排序。这意味着我们对相距 8 的元素进行排序。我们对元素 1 和 9,2 和 10,3 和 11,4 和 12,5 和 13,6 和 14,7 和 15,8 和 16 进行排序。这将把num
转换成这样:
接下来,我们对数组进行三排序;也就是说,我们对相距三的元素进行排序。我们对元素进行排序(1,4,7,10,13,16),(2,5,8,11,14),和(3,6,9,12,15)。这为我们提供了以下信息:
注意,在每一步,数组都离排序更近了一点。最后,我们执行单排序,对整个列表进行排序,给出最终的排序顺序:
你可能会问,为什么我们不从一开始就做一次排序,然后对整个列表进行排序呢?这里的想法是,当我们到达进行单排序的阶段时,数组或多或少是有序的,如果我们使用一种更好地处理部分有序数据的方法(比如插入排序),那么排序可以快速进行。回想一下,插入排序可以进行少至 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
-
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 个元素进行排序。
-
函数
makeHeap
被传递一个整数数组A
。如果A[0]
包含n
,那么A[1]
到A[n]
包含任意顺序的数字。编写makeHeap
使得A[1]
到A[n]
包含一个 max-heap(根处的最大值)。您的函数必须按照A[2]
、A[3]
、...,A[n]
。 -
堆存储在一维整数数组
num[1..n]
中,最大的值位于位置 1。给出一个有效的算法,删除根元素,重新排列其他元素,使堆现在占据num[1]
到num[n-1]
。 -
堆存储在一维整数数组
A[0..max]
中,其中最大值位于位置 1。A[0]
指定任意时刻堆中元素的数量。写一个函数向堆中添加一个新值v
。如果堆最初是空的,你的函数应该工作,如果没有空间存储v
,应该打印一条消息。 -
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 thenH[1..n]
must contain a heap. Assume thatH
is large enough to hold all the integers.给定数据:
51 26 32 45 38 89 29 58 34 23 0
在每个数字被读取和处理后,显示 H 的内容。
-
一个函数被赋予一个整数数组
A
和两个下标m
和n
。该函数必须重新排列元素A[m]
到A[n]
并返回下标d
,使得d
左边的所有元素都小于或等于A[d]
,而d
右边的所有元素都大于A[d]
。 -
编写一个函数,给定一个整数数组
num
和一个整数n
,使用 Shell sort 对元素num[1]
到num[n]
进行排序。该函数必须返回在执行排序时进行的键比较的数量。您可以使用任何合理的方法来确定增量。 -
单个整数数组
A[1..n]
包含以下内容:A[1..k]
包含最小堆,A[k+1..n]
包含任意值。编写有效的代码来合并这两个部分,以便A[1..n]
包含一个最小堆。难道没有使用任何其他数组。 -
An integer max-heap is stored in an array (
A
, say) such that the size of the heap (n
, say) is stored inA[0]
andA[1]
toA[n]
contain the elements of the heap with the largest value inA[1]
.(I)编写一个函数
deleteMax
,在给定一个类似于A
的数组的情况下,删除最大的元素并重组该数组,使其仍然是一个堆。(ii)给定如上所述包含堆的两个数组
A
和B
,编写编程代码以将A
和B
的元素合并到另一个数组C
中,使得C
按升序排列。您的方法必须通过比较A
中的一个元素和B
中的一个元素来进行。你可以假设deleteMax
是可用的。 -
编写一个递归函数,在一个由 n 个数字组成的数组中寻找第 k 个个最小的数字,不需要对数组进行排序。
-
使用二分搜索法编写插入排序,以确定
A[j]
在排序后的子列表A[1..j-1]
中的插入位置。 -
如果相等的键在排序后保持它们原来的相对顺序,那么一个排序算法被称为是稳定的。讨论的排序方法中哪些是稳定的?
-
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).
写一个高效的算法,求全部五个值。
- 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.
如果列表中可能包含重复的数字,请修改列表的排序方法。
- 修改合并排序(第五章)和快速排序,以便如果要排序的子列表小于某个预定义的大小,则使用插入排序进行排序。
- 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 tox
. You must then delete this number from the list and replace it by a new numbery
, 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.
这些中哪一个是最有效的?
- 给你一份(很长的)英语单词表。写一个程序来确定这些单词中哪些是彼此的变位词。输出由每组变位词(两个或更多单词)组成,后跟一个空行。两个单词如果由相同的字母组成,如(老师,骗子),(妹子,反抗),就是变位词。
- 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://gitee.com/OpenDocCN/vkdoc-java-zh/raw/master/docs/adv-topic-java/img/9781430266198_unFig09-15.jpg)
可以用四个交换排序,依次为:(1,3) (4,7) (2,9) (5,9)。另一种解法是(1,3) (2,9) (4,7) (5,9)。少于四次交换无法对数组进行排序。*
十、哈希
在本章中,我们将解释以下内容:
- 哈希所基于的基本思想
- 如何使用哈希解决搜索和插入问题
- 如何从哈希表中删除项目
- 如何使用线性探测解决冲突
- 如何使用二次探测解决冲突
- 如何使用链接解决冲突
- 如何使用双哈希线性探测解决冲突
- 如何使用数组按顺序链接项目
10.1 哈希基础知识
在(大)表中搜索一个项目是许多应用中的常见操作。在本章中,我们将讨论哈希,这是一种执行这种搜索的快速方法。哈希背后的主要思想是使用项目的密钥(例如,车辆记录的车辆注册号码)来确定项目存储在表中的(哈希表 )的。这个键首先被转换成一个数字(如果它还不是一个数字的话),然后这个数字被映射(我们称之为哈希)到一个表位置。用于将键转换为表位置的方法称为哈希函数* 。*
当然,两个或更多的键哈希到同一个位置是完全可能的。当这种情况发生时,我们说我们有一个碰撞 ,我们必须找到一个解决碰撞的方法。哈希的效率(或其他方面)在很大程度上取决于用于解决冲突的方法。这一章的大部分时间都在讨论这些方法。
10.1.1 搜索和插入问题
搜索和插入问题的经典陈述如下:
给定一个项目列表(该列表最初可能为空),在列表中搜索给定的项目。如果找不到该项目,请将其插入列表。
项目通常可以是数字(学生、帐户、员工、车辆等等)、名称、单词或字符串。例如,假设我们有一组整数,不一定是不同的,我们想知道有多少个不同的整数。
我们从一个空列表开始。对于每个整数,我们在列表中查找。如果没有找到,它将被添加到列表中并进行计数。如果被发现了,那也没办法。
在解决这个问题时,一个主要的设计决策是如何搜索列表,这反过来又取决于如何存储列表以及如何添加新的整数。以下是一些可能性:
- 列表存储在一个数组中,一个新的整数放在数组中的下一个可用位置。这意味着必须使用顺序搜索来查找传入的整数。这种方法具有简单和易于添加的优点,但是随着列表中的数字越来越多,搜索时间会越来越长。
- 列表存储在一个数组中,并以列表总是有序的方式添加一个新的整数。这可能需要移动已经存储的号码,以便新号码可以插入正确的位置。
- 但是,由于列表是有序的,所以可以使用二分搜索法来搜索传入的整数。对于这种方法,搜索速度更快,但是插入速度比前一种方法慢。因为一般来说,搜索比插入更频繁,所以这种方法可能优于前一种方法。
- 这里的另一个优点是,在最后,整数将按顺序排列,如果这很重要的话。如果使用方法 1,则必须对数字进行排序。
- 该列表存储为未排序的链表,因此必须按顺序搜索。因为如果一个输入号码不存在,必须遍历整个列表,所以可以在开头或结尾添加新号码;两者同样容易。
- 该列表存储为排序的链表。必须在“适当的位置”插入一个新号码,以保持顺序。一旦找到位置,插入就很容易了。如果来电号码不存在,则不必遍历整个列表,但是我们仍然受限于顺序搜索。
- 该列表存储在二叉查找树中。如果树不变得太不平衡,搜索会相当快。添加号码很容易——只需设置几个链接。如果需要的话,对树的有序遍历将给出排序后的数字。
还有一种可能是称为哈希的方法。正如我们将看到的,这具有搜索速度极快和易于插入的优点。
10.2 通过哈希解决搜索和插入问题
我们将通过解决整数列表的“搜索和插入”问题来说明哈希是如何工作的。该列表将存储在数组num[1]
到num[n]
中。在我们的例子中,我们将假设n
是12
。
最初,列表中没有数字。假设第一个来电号码是52
。哈希背后的想法是将52
(通常称为键)转换成有效的表位置(比如说k
)。这里,有效的工作台位置是1
到12
。
如果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
产生一个介于0
和11
之间的值;添加1
得到1
和12
之间的值。一般来说,key % n + 1
产生的值在1
和n
之间,包括这两个值。我们将在我们的例子中使用这个函数。
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
哈希到10
;num[10]
是空的,所以33
放在num[10]
。84
哈希到1
;num[1]
是空的,所以84
放在num[1]
。43
哈希到8
;num[8]
是空的,所以43
放在num[8]
。
在这个阶段,num
可以这样描绘:
16
哈希到5
;num[5]
被占领,而不是被16
占领——我们发生了碰撞。为了解决这个冲突,我们必须找到另一个放置16
的位置。一个显而易见的选择是尝试下一个位置6
;num[6]
为空,所以16
放在num[6]
里。59
哈希到12
;num[12]
是空的,所以59
放在num[12]
。31
哈希到8
;num[8]
被占领,而不是被31
占领——我们发生了碰撞。我们试试下一个地点,9
;num[9]
为空,所以31
放在num[9]
里。
在这个阶段,num
看起来是这样的:
23
哈希到12
;num[12]
被占领,而不是被23
占领——我们发生了碰撞。我们必须尝试下一个位置,但是这里的下一个位置是什么?我们假设桌子是“圆形”的,因此位置1
跟随位置12
。然而,num[1]
被占用而不是被23
占用。所以,我们试试num[2]
;num[2]
为空,所以23
放在num[2]
中。- 最后,
61
哈希到2
;num[2]
被占领,而不是被61
占领——我们发生了碰撞。我们试试下一个地点,3
;num[3]
为空,所以61
放在num[3]
里。
下面显示了插入所有数字后的数组:
请注意,如果数组中已经有一个数字,该方法会找到它。例如,假设我们正在搜索 23。
23
哈希到12
。num[12]
被占用,未被23
占用。- 我们试试下一个位置,
1
;num[1]
被23
占领而不是被【】占领。 - 我们接下来试试
num[2]
;num[2]
被23
占领——我们找到了。
假设我们正在搜索33
;33
哈希到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
为 n
,loc % 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 从哈希表中删除项目
再次考虑插入所有样本号后的数组:
回想一下,43
和31
最初都被哈希到位置8
。假设我们要删除43
。第一个想法可能是将其位置设置为空。假设我们这样做了(将num[8]
设置为空),现在正在寻找31
。这将哈希到8
;但是由于num[8]
为空,我们会错误地得出结论,认为31
不在表中。因此,我们不能简单地通过将一个项目的位置设置为空来删除它,因为其他项目可能变得无法访问。
最简单的解决方案是将其位置设置为一个删除了的值——这个值不能与空或一个键混淆。在这个例子中,如果键是正整数,我们可以用0
代表Empty
,用-1
代表Deleted
。
现在,当搜索时,我们仍然检查关键字或空位置;删除的位置将被忽略。一个常见的错误是在删除的位置停止搜索;这样做会导致错误的结论。
如果我们的搜索发现一个传入的键不在表中,那么这个键可以被插入到一个空的位置或者一个被删除的位置,如果在这个过程中遇到一个这样的位置的话。例如,假设我们通过将num[8]
设置为-1
删除了43
。如果我们现在搜索55
,我们将检查位置8
、9
、10
和11
。由于num[11]
是空的,我们推断55
不在表中。
如果我们愿意,我们可以将num[11]
设置为55
。但是我们可以写我们的算法来记住在8
被删除的位置。如果我们这样做了,那么我们可以在num[8]
中插入55
。这是更好的,因为我们会发现55
比num[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
请注意,我们仍然搜索,直到我们找到一个空的位置或关键。如果我们遇到一个被删除的位置并且deletedLoc
是0
,这意味着它是第一个。当然,如果我们从来没有遇到一个被删除的位置,并且这个键不在表中,它将被插入一个空的位置。
10.3 解决冲突
在程序 P10.1 中,我们通过查看表中的下一个位置来解决冲突。这也许是解决冲突最简单的方法。我们说我们使用线性探测来解决冲突,我们将在下一节更详细地讨论这个问题。在这之后,我们将看看解决冲突的更复杂的方法。其中有二次探测、链接和双重哈希。
10.3.1 线性探测
线性探测的特点是陈述 loc = loc + 1
。再次考虑九个数字相加后num
的状态:
如您所见,随着表的填满,将新键哈希到空位置的机会减少了。
假设一个键哈希到位置12
。试完12
、1
、2
、3
后,放在num[4]
。事实上,任何哈希为12
、1
、2
、3
或4
的新键都将以num[4]
结束。当这种情况发生时,我们将有一个从位置12
到位置6
的长的、不间断的密钥链。任何新的哈希到这个链的密钥都将在num[7]
中结束,创建一个更长的链。
这种群集 的现象是线性探测的主要缺点之一。长链倾向于变得更长,因为哈希到长链的概率通常大于哈希到短链的概率。两条短链也很容易连接起来,形成一条更长的链,而这条链又会变得更长。例如,任何以num[7]
结尾的键都会创建一个从位置5
到10
的长链。
我们定义了两种类型的聚类。
- 当哈希到不同位置的关键字在寻找空位置时跟踪相同的序列时,发生初级聚类 。线性探测展示了初级聚类,因为哈希到
5
的关键字将跟踪5
、6
、7
、8
、9
等等,哈希到6
的关键字将跟踪6
、7
、8
、9
等等。 - 当哈希到相同位置的关键字在寻找空位置时跟踪相同序列时,发生二次聚类 。线性探测展示了二次聚类,因为哈希到
5
的关键字将跟踪相同的序列5
、6
、7
、8
、9
等等。
解决冲突的方法希望改进线性探测,目标是消除初级和/或次级聚类。
您可能想知道使用loc = loc + k
,其中k
是一个大于 1 的常数(例如,3
)是否会给出比loc = loc + 1
更好的结果。事实证明,这不会改变群集现象,因为仍然会形成k
分开的键组。
此外,它甚至可能比当k
为 1 时更糟,因为可能不会生成所有位置。
假设表的大小是12
,k
是3
,一个键哈希到5
。跟踪的位置顺序将是5
、8
、11
、2
( 11 + 3 - 12
)、5
,并且该顺序重复自身。换句话说,在寻找空位置时,只有相对较少的位置将被探测。相比之下,当k
为1
时,生成所有位置。
然而,这并不是一个真正的问题。如果表的大小是m
并且k
与m
是“互质的”(它们唯一的公因数是 1),那么所有的位置都被生成。如果两个数中一个是素数,另一个不是它的倍数,比如 5 和 12,那么这两个数就是相对素数。但是素数不是必要条件。数字 21 和 50(两者都不是质数)是相对质数,因为它们除了 1 之外没有公因数。
如果k
是5
,m
是12
,一个哈希到5
的键会按照5
,10
,3
,8
,1
,6
,11
,4
,9
,2
,7
,12
的顺序追踪,所有的位置都会生成。哈希到任何其他位置的密钥也将生成所有位置。
在任何情况下,能够生成所有位置都是理论上的,因为如果我们不得不跟踪许多位置来找到一个空的位置,搜索将会太慢,并且我们可能需要使用另一种方法。
尽管我们刚刚说过,但结果是loc = loc + k
,其中k
随密钥变化,为我们提供了实现哈希的最佳方式之一。我们将在 10.3.4 节中看到如何实现。
那么,线性方法有多快呢?我们感兴趣的是平均搜索长度,也就是为了找到或插入一个给定的键而必须检查的位置的数量。在上例中,33
的搜索长度为1
,61
的搜索长度为2
,23
的搜索长度为3
。
搜索长度是表中负载系数、 f 的函数,其中
对于成功的搜索,平均比较次数为,对于不成功的搜索,平均比较次数为
。注意,搜索长度只取决于填充的表格的分数,不取决于表格的大小。
表 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 其中 a , b 是常数,并且 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....当然,我们可以通过为 a 和 b 选择不同的值来得到不同的序列。
我们可以用下面的算法总结刚才描述的过程:
//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
注意我们使用
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
个哈希位置,那么一个链表的平均长度是,由于我们必须顺序搜索链表,所以平均成功搜索长度是
。可以通过增加哈希位置的数量来缩短搜索长度。
用链接实现哈希的另一种方式是使用单个数组并使用数组下标作为链接。我们可以使用以下声明:
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 所示。
图 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 项位于列表的末尾。
图 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 中的所示。
图 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
现在考虑如何处理传入的键。假设free
是9
并且数字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
。如果桌子位置从1
到n
,我们可以使用如下:
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
也是素数,我们会得到更好的结果(在这种情况下,n
和n-2
被称为孪生素数 ,例如 103/101,1021/1019)。
除了k
不固定之外,方法与线性探测相同。我们用两个哈希函数来描述它,H1
和H2\. H1
产生初始哈希位置,一个在1
和n
之间的值,包括这两个值。H2
产生增量,一个在1
和n - 1
之间的值,与n
互质;这是所希望的,这样,如果需要,许多位置将被探测。如前所述,如果n
是质数,那么1
和n - 1
之间的任何值对它来说都是相对质数。在前面的例子中,第二个哈希函数产生一个在1
和n-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
)。两个键同时被H1
和H2
哈希为相同的值,这确实是一种罕见的巧合。
在实践中,任何哈希应用的性能都可以通过保存每个密钥的访问频率信息来提高。如果我们事先有了这些信息,我们可以简单地将最流行的条目放在最前面,最不流行的条目放在最后。这将降低所有键的平均访问时间。
如果我们事先没有这些信息,我们可以为每个键保留一个计数器,并在每次访问该键时递增。经过一段预定的时间后(比如一个月),我们首先重新载入最受欢迎的条目,最后载入最不受欢迎的条目。然后,我们重置计数器并收集下个月的统计数据。这样,我们可以确保应用保持微调,因为不同的项目可能会在下个月变得流行。
10.4 示例:词频统计
再一次考虑写一个程序来计算一篇文章中单词的频率的问题。输出由单词及其频率的字母列表组成。现在,我们将使用带有双重哈希的线性探测将单词存储在哈希表中。
表格中的每个元素由三个字段组成— word
、freq
和next
。我们将使用下面的类来创建要存储在表中的对象:
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
。
图 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
-
使用主哈希函数
h1(k) = 1 + k mod 11
将整数插入哈希表H[1..11]
。使用(a)线性探测,(b)使用探测函数i + i2
的二次探测,以及(c)使用h2(k) = 1 + k mod 9
的双重哈希,显示插入关键字10
、22
、31
、4
、15
、28
、17
、88
和58
后数组的状态。 -
Integers are inserted in an integer hash table
list[1]
tolist[n]
using linear probe with double hashing. Assume that the functionh1
produces the initial hash location and the functionh2
produces the increment. An available location has the valueEmpty
, and a deleted location has the valueDeleted
.编写一个函数来搜索给定值
key
。如果找到,该函数返回包含key
的位置。如果没有找到,该函数将key
插入到搜索key
时遇到的第一个删除位置(如果有)或Empty
位置,并返回key
插入的位置。你可以假设list
包含了一个新整数的空间。 -
在哈希应用中,密钥由一串字母组成。编写一个哈希函数,给定一个键和一个整数
max
,返回 1 和max
之间的哈希位置,包括 1 和 T1。您的函数必须使用键的所有,并且不应该故意为由相同字母组成的键返回相同的值。 -
一个大小为
n
的哈希表包含两个字段——一个整数数据字段和一个整数链接字段——称为数据和下一个的。下一个字段用于以升序链接哈希表中的数据项。值-1 表示列表结束。变量top
(初始设置为-1)表示最小数据项的位置。使用哈希函数h1
和线性探测将整数插入哈希表。可用位置的data
字段具有值Empty
,并且不会从表中删除任何项目。编写程序代码以搜索给定值key
。如果发现,什么也不做。如果找不到,将key
插入表格中,并将链接到其指定位置。您可能会认为该表中有容纳新整数的空间。 -
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 integercount
, 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 callH(key)
returns a location from1
to n, inclusive.编写编程代码来初始化哈希表。
编写一个函数,给定键
nkey
,如果找不到就搜索它,在适当的位置添加nkey
,并将count
设置为0
。如果找到,将1
加到count
;如果计数达到10
,则从当前位置删除该节点,将其放在列表的开头,并将count
设置为0
。 -
Write a program to read and store a thesaurus as follows:
程序的数据由输入行组成。每行包含(可变)数量的不同单词,所有这些单词都是同义词。你可以假设单词只由字母组成,并由一个或多个空格分隔。可以使用大小写字母的任意组合来拼写单词。所有的字都将存储在一个哈希表中,使用开放寻址和双重哈希。一个单词可以出现在多行上,但是每个单词在表格中只能插入一次。如果一个单词出现在多行上,那么这些行上的所有单词都是同义词。这部分数据由包含单词
EndOfSynonyms
的行终止。必须组织数据结构,以便给定任何单词,都可以快速找到该单词的所有同义词。
数据的下一部分由几个命令组成,每行一个。有效命令由
P
、A
、D
或E
指定。P 单词按字母顺序打印单词的所有同义词。
A word1 word2 将 word1 添加到 word2 的同义词列表中。
D 单词从同义词库中删除单词。
E ,单独一行,表示数据结束。
-
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 该技术有时被称为双哈希开放式寻址。