Java 高级主题(三)
原文:Advanced Topics in Java: Core Concepts in Data Structures
五、递归
在本章中,我们将解释以下内容:
- 什么是递归定义
- 如何用 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=x8,我们可以将x8乘以x8得到x16,只需要再做一次乘法。 - 如果我们知道
x4=x4,我们可以将x4乘以x4得到x8,只需要再做一次乘法。 - 如果我们知道
x2=x2,我们可以将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);
}
}
```
17. 下面递归函数的调用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);
}
```
18. 考虑通常笛卡尔坐标系中的点( m , n ),其中 m 和 n 为正整数*。在从 A 点到 B 点的东北路径中,只能向上和向右移动*(不允许向下或向左移动*)。写一个函数,给定任意两点 A 和 B 的坐标,返回从 A 到 B 的东北路径的号*** 19. 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 皇后问题。使用递归实现回溯。
20. 写一个程序读取 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 64means that there is a ladder from 17 to 64, and the pair99 28means 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 个游戏,并打印出游戏者每局的平均赢款。**