Java 高级主题(二)
原文:Advanced Topics in Java: Core Concepts in Data Structures
三、集合框架
在本章中,我们将解释以下内容:
- 链表的概念
- 如何编写使用链表的声明
- 如何计算链表中的节点数
- 如何在链表中搜索项目
- 如何找到链表中的最后一个节点
- 静态存储和动态存储分配的区别
- 如何通过在列表末尾添加一个新项目来构建一个链表
- 如何在链表中插入节点
- 如何通过在列表的头部添加一个新项目来构建一个链表
- 如何从链表中删除项目
- 如何通过添加一个新项来构建一个链表,使得链表总是排序的
- 如何组织你的 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,
b1andb2. 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
postis 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)给定一个作业号,从队列中删除该作业。
确保无论队列状态如何,您的方法都能正常工作。
14. 一个栈,S1,包含一些任意顺序的数字。使用另一个栈S2作为临时存储,展示如何对S1中的数字进行排序,使得最小的在S1的顶部,最大的在底部。*