普林斯顿编程和算法讲义-全-
普林斯顿编程和算法讲义(全)
原文:普林斯顿大学算法课程
译者:飞龙
Java
1. 编程元素
原文:
introcs.cs.princeton.edu/java/10elements译者:飞龙
概述。
本章中我们的目标是说服您,编写计算机程序比编写段落或文章等文本要容易。在本章中,我们将带您了解这些基本知识,帮助您开始使用 Java 进行编程,并学习各种有趣的程序。
1.1 编程元素 指导您如何在系统上创建、编译和执行 Java 程序。
1.2 数据的内置类型 描述了 Java 用于操作字符串、整数、实数和布尔值的内置数据类型。
1.3 条件和循环 介绍了 Java 的控制流结构,包括
if-else语句、while循环和for循环。1.4 数组 考虑了一种称为数组的数据结构,用于组织大量数据。
1.5 输入和输出 扩展了输入和输出抽象(命令行参数和标准输出)的集合,包括标准输入、标准绘图和标准音频。
1.6 随机网络冲浪者 提供了一个案例研究,模拟了使用马尔可夫链的网络冲浪者的行为。
本章中的 Java 程序。
下面是本章中的 Java 程序列表。单击程序名称以访问 Java 代码;单击参考编号以获取简要描述;阅读教材以获取详细讨论。
参考 程序 描述 1.1.1 HelloWorld.java Hello, World 1.1.2 UseArgument.java 使用命令行参数 1.2.1 Ruler.java 字符串连接示例 1.2.2 IntOps.java 整数乘法和除法 1.2.3 Quadratic.java 二次方程公式 1.2.4 LeapYear.java 闰年 1.2.5 RandomInt.java 强制转换以获得随机整数 1.3.1 Flip.java 抛硬币 1.3.2 TenHellos.java 你的第一个 while 循环 1.3.3 PowersOfTwo.java 计算 2 的幂 1.3.4 DivisorPattern.java 你的第一个嵌套循环 1.3.5 HarmonicNumber.java 调和数 1.3.6 Sqrt.java 牛顿法 1.3.7 Binary.java 转换为二进制 1.3.8 Gambler.java 赌徒破产模拟 1.3.9 Factors.java 整数因子分解 1.4.1 Sample.java 无重复抽样 1.4.2 CouponCollector.java 收集优惠券模拟 1.4.3 PrimeSieve.java 埃拉托斯特尼筛法 1.4.4 SelfAvoidingWalk.java 避免自我随机漫步 1.5.1 RandomSeq.java 生成随机序列 1.5.2 TwentyQuestions.java 交互式用户输入 1.5.3 Average.java 对一系列数字进行平均 1.5.4 RangeFilter.java 一个简单的过滤器 1.5.5 PlotFilter.java 标准输入到绘图过滤器 1.5.6 BouncingBall.java 弹跳球 1.5.7 PlayThatTune.java 数字信号处理 1.6.1 Transition.java 计算转移矩阵 1.6.2 RandomSurfer.java 模拟随机冲浪者 1.6.3 Markov.java 混合马尔可夫链
1.1 您的第一个 Java 程序: Hello World
原文:
introcs.cs.princeton.edu/java/11hello译者:飞龙
在本节中,我们的计划是通过带领您完成三个基本步骤来进入 Java 编程的世界,以便让一个简单的程序运行起来。与任何应用程序一样,您需要确保 Java 已正确安装在您的计算机上。您还需要一个编辑器和一个终端应用程序。以下是三种流行家用操作系统的系统特定说明。[Mac OS X · Windows · Linux]
在 Java 中编程。
我们将 Java 编程过程分为三个步骤:
通过在文本编辑器中键入程序并将其保存为名为
MyProgram.java的文件来创建程序。通过在终端窗口中键入"
javac MyProgram.java"来编译它。执行(或运行)它,通过在终端窗口中键入"
java MyProgram"。
第一步创建程序;第二步将其翻译为更适合机器执行的语言(并将结果放在名为MyProgram.class的文件中);第三步实际运行程序。
创建 Java 程序。 程序只是一系列字符,就像句子、段落或诗歌一样。要创建一个程序,我们只需要像为电子邮件一样使用文本编辑器定义该字符序列。HelloWorld.java 是一个示例程序。将这些字符键入您的文本编辑器并将其保存到名为
HelloWorld.java的文件中。public class HelloWorld { public static void main(String[] args) { // Prints "Hello, World" in the terminal window. System.out.println("Hello, World"); } }编译 Java 程序。 编译器是一个应用程序,它将程序从 Java 语言翻译为更适合在计算机上执行的语言。它以扩展名为
.java的文本文件(您的程序)作为输入,并生成一个扩展名为.class的文件(计算机语言版本)。要编译HelloWorld.java,请在终端中键入下面的粗体文本。(我们使用%符号来表示命令提示符,但根据您的系统可能会有所不同。)% javac HelloWorld.java如果您正确输入了程序,您不应该看到任何错误消息。否则,请返回并确保��按照上面显示的方式输入程序。
执行(或运行)Java 程序。 一旦编译程序,您就可以执行它。这是令人兴奋的部分,计算机遵循您的指示。要运行
HelloWorld程序,请在终端窗口中键入以下内容:% java HelloWorld如果一切顺利,您应该看到以下响应
Hello, World理解 Java 程序。 具有
System.out.println()的关键行在终端窗口中打印文本“Hello, World”。当我们开始编写更复杂的程序时,我们将讨论public、class、main、String[]、args、System.out等的含义。创建自己的 Java 程序。 目前,我们所有的程序都将像
HelloWorld.java一样,只是main()中的语句顺序不同。编写这样的程序最简单的方法是:将
HelloWorld.java复制到一个新文件中,文件名是程序名称后跟.java。在所有地方用程序名称替换
HelloWorld。用一系列语句替换打印语句。
错误。
大多数错误都可以通过仔细检查我们创建的程序来轻松修复,就像我们在输入电子邮件消息时修复拼写和语法错误一样。
编译时错误。 这些错误在编译程序时被系统捕获,因为它们阻止编译器进行翻译(因此会发出尝试解释原因的错误消息)。
运行时错误。 这些错误在执行程序时被系统捕获,因为程序尝试执行无效操作(例如,除以零)。
逻辑错误。 这些错误(希望)在执行程序时由程序员捕获,因为程序产生了错误答案。错误是程序员的噩梦。它们可能很微妙,很难找到。
你将学到的最基本的技能之一是识别错误;接下来的一个技能将是在编码时足够小心,以避免许多错误。
输入和输出。
通常,我们希望为我们的程序提供输入:它们可以处理以产生结果的数据。提供输入数据的最简单方法在 UseArgument.java 中有所说明。每当执行此程序时,它都会读取您在程序名称后键入的命令行参数,并将其作为消息的一部分打印回终端。
% javac UseArgument.java
% java UseArgument Alice
Hi, Alice. How are you?
% java UseArgument Bob
Hi, Bob. How are you?
练习
编写一个程序 TenHelloWorlds.java,打印"
Hello, World"十次。修改 UseArgument.java 以创建一个程序 UseThree.java,接受三个名字并打印出一个以给定顺序相反的名字构成的正确句子,例如,"
java UseThree Alice Bob Carol"给出"Hi Carol, Bob, and Alice."。
网页练习
编写一个程序 HelloWorldMultilingual.java,在尽可能多的人类语言中打印“Hello World!”。
编写一个程序 Initials.java,使用九行星号打印您的缩写,就像下面的例子一样。
** *** ********** ** * ** ** *** ** ** ** *** ** ** *** ** ** ** ** ** ** ** *** ** ** ** ** ** ** ***** ** ** ** ** ** ** ** *** ** ** ** ** ** ** ** *** ** ** ** ** ** ** ** *** ** ** *** *** ** *** ********** * *描述如果在 HelloWorld.java 中省略
mainStringHelloWorldSystem.outprintln
描述如果在 HelloWorld.java 中省略
;第一个
"第二个
"第一个
{第二个
{第一个
}第二个
}
描述如果在 HelloWorld.java 中拼写错误(比如,省略第二个字母)
mainStringHelloWorldSystem.outprintln
我输入了以下程序。它编译正常,但当我执行它时,我得到错误
java.lang.NoSuchMethodError: main。我做错了什么?public class Hello { public static void main() { System.out.println("Doesn't execute"); } }答案:你忘记了
String[] args。这是必需的。
1.2 数据的内置类型
原文:
introcs.cs.princeton.edu/java/12types译者:飞龙
数据类型是一组值和在其上定义的一组操作。例如,我们熟悉数字及其上定义的操作,如加法和乘法。在 Java 中有八种不同的内置数据类型,主要是不同类型的数字。我们经常使用系统类型来表示字符字符串,因此我们在这里也考虑它。
术语。 我们使用以下代码片段来介绍一些术语:
int a, b, c;
a = 1234;
b = 99;
c = a + b;
第一行是一个声明语句,声明了三个变量的名称为a、b和c,它们的类型为int。接下来的三行是赋值语句,改变了变量的值,使用了字面量1234和99,以及表达式a + b,最终c的值为1333。
字符和字符串。 char是字母数字字符或符号,就像您键入的那些。我们通常不对字符执行任何操作,只是将值赋给变量。String是一系列字符。我们对字符串执行的最常见操作称为连接:给定两个字符串,将它们连接在一起以生成新的字符串。例如,考虑以下 Java 程序片段:
String a, b, c;
a = "Hello,";
b = " Bob";
c = a + b;
第一条语句声明了三个变量的类型为String。接下来的三条语句为它们赋值,最终c的值为"Hello, Bob"。使用字符串连接,Ruler.java 打印了标尺上各个刻度的相对长度。
整数。 int 是介于−2³¹和 2³¹ − 1(−2,147,483,648 到 2,147,483,647)之间的整数。我们经常使用int,不仅因为它们在现实世界中经常出现,而且在表达算法时自然产生。Java 内置了用于整数加法、乘法和除法的标准算术运算符,如 IntOps.java 和下表所示:
浮点数。 double 类型用于表示浮点数,例如,在科学应用中使用。内部表示类似于科学计数法,因此我们可以在一个巨大的范围内计算实数。我们可以使用带有小数点的数字字符串来指定浮点数,例如,3.14159用于数学常数π的六位近似值,或者使用科学计数法表示,例如,6.022E23用于阿伏伽德罗常数 6.022 × 10²³。Java 内置了用于双精度加法、乘法和除法的标准算术运算符,如 DoubleOps.java 和下表所示:
Quadratic.java 展示了在计算二次方程的两个根时使用双精度的情况,使用了二次公式。
布尔值。 boolean 类型只有两个值:true或false。这种明显的简单性是具有欺骗性的——布尔值是计算机科学的基础。为boolean定义的最重要的运算符是and、or和not。
and:
a && b如果a和b都为真,则为真,否则为假。or:
a || b如果a或b为真(或两者都为真),则为真,否则为假。not:
!a如果a为假,则为真,否则为假。
尽管这些定义直观且易于理解,但完全指定每个操作的每种可能性在真值表中是值得的。
比较。 比较运算符是混合类型操作,它们接受一个类型(例如,int或double)的操作数,并产生类型为boolean的结果。这些操作在开发更复杂程序的过程中起着至关重要的作用。
闰年.java 测试一个整数是否对应于公历中的闰年。
库方法和 API。
许多编程任务涉及使用 Java 库方法以及内置运算符。应用程序编程接口是总结库中方法的表格。
将字符串打印到终端窗口。
![打印到标准输出]()
将字符串转换为原始类型。
![解析命令行参数]()
数学函数。
![数学库]()
您可以通过键入方法名称,后跟参数,用括号括起并用逗号分隔来调用方法。以下是一些示例:
我们经常发现自己使用以下方法之一将数据从一种类型转换为另一种类型。
类型转换。
我们经常发现自己使用以下方法之一将数据从一种类型转换为另一种类型。
显式类型转换。 调用诸如
Math.round()、Integer.parseInt()和Double.parseDouble()之类的方法。自动类型转换。 对于原始数值类型,当我们使用具有比预期更大值范围的类型的值时,系统会自动执行类型转换。
显式转换。 Java 还具有一些用于原始类型的内置类型转换方法,当您意识到可能会丢失信息时,可以使用这些方法,但必须使用称为转换的东西表明您的意图。随机整数.java 读取一个整数命令行参数n,并打印介于 0 和n−1 之间的“随机”整数。
字符串的自动转换。 内置类型
String遵守特殊规则。其中一个特殊规则是,您可以通过使用+运算符轻松地将任何类型的数据转换为String。
练习
假设
a和b是int值。以下语句序列做什么?int t = a; b = t; a = b;解决方案: 将
a、b和t设置为a的原始值。假设
a和b是int值。简化以下表达式:(!(a < b) && !(a > b))解决方案:
(a == b)异或运算符
^用于boolean操作数,如果它们不同则定义为true,如果它们相同则定义为false。给出此函数的真值表。为什么
10/3会得到3而不是3.33333333?解决方案: 由于 10 和 3 都是整数字面量,Java 认为不需要类型转换并使用整数除法。如果您希望数字为
double字面量,则应编写10.0/3.0。如果您写10/3.0或10.0/3,Java 会进行隐式转换以获得相同的结果。以下每个打印出什么?
System.out.println(2 + "bc");输出: 2bcSystem.out.println(2 + 3 + "bc");输出: 5bcSystem.out.println((2+3) + "bc");输出: 5bcSystem.out.println("bc" + (2+3));输出: bc5System.out.println("bc" + 2 + 3);输出: bc23
解释每个结果。
解释如何使用二次方程.java 找到一个数字的平方根。
解决方案: 要找到 c 的平方根,找到 x² + 0x - c 的根。
一个物理学生在使用代码时得到了意外的结果
double force = G * mass1 * mass2 / r * r;根据公式F = G**m[1]m[2] / r²计算值。解释问题并更正代码。
解决方案: 它除以
r,然后乘以r(而不是除以r *r)。使用括号:double force = G * mass1 * mass2 / (r * r);编写一个程序 Distance.java,接受两个整数命令行参数 x 和 y,并打印从点(x, y)到原点(0, 0)的欧几里德距离。
编写一个程序 SumOfTwoDice.java,打印两个介于 1 和 6 之间的随机整数的和(例如,掷骰子时可能得到的值)。
编写一个程序 SumOfSines.java,接受一个双精度命令行参数 t(以度为单位),并打印 sin(2t) + sin(3t) 的值。
编写一个程序 SpringSeason.java,从命令行接受两个
int值m和d,如果月份m的第d天在 3 月 20 日(m = 3, d = 20)和 6 月 20 日(m = 6, d = 20)之间,则打印true,否则打印false。
创意练习
风寒温度。 给定温度
t(华氏度)和风速v(英里/小时),国家气象局定义风寒温度为:w = 35.74 + 0.6215 t + (0.4275 t - 35.75) v^(0.16)
编写一个程序 WindChill.java,接受两个
double命令行参数t和v,并打印风寒温度。使用Math.pow(a, b)计算 a^b。注意:如果 t 的绝对值大于 50 或者 v 大于 120 或小于 3,则该公式无效(您可以假设您得到的值在该范围内)。极坐标。 编写一个程序 CartesianToPolar.java,将笛卡尔坐标转换为极坐标。您的程序应该在命令行上接受两个实数 x 和 y,并打印极坐标 r 和 θ。使用 Java 方法
Math.atan2(y, x),计算 y/x 的反正切值,范围为 -π 到 π。星期几。 编写一个程序 DayOfWeek.java,接受一个日期作为输入,并打印该日期所在的星期几。您的程序应该接受三个命令行参数:
m(月份)、d(日期)和y(年份)。对于m,使用 1 表示一月,2 表示二月,依此类推。对于输出,星期日打印为 0,星期一打印为 1,星期二打印为 2,依此类推。使用以下公式,适用于公历(其中/表示整数除法):y[0] = y − (14 − m) / 12
x = y[0] + y[0] / 4 − y[0] / 100 + y[0] / 400
m[0] = m + 12 × ((14 − m) / 12) − 2
d[0] = (d + x + 31m[0] / 12) mod 7
例如,2000 年 2 月 14 日是星期几?
y0 = 2000 - 0 = 1999 x = 1999 + 1999/4 - 1999/100 + 1999/400 = 2483 m0 = 2 + 12*1 - 2 = 12 d0 = (13 + 2483 + (31*12) / 12) mod 7 = 2528 mod 7 = 1 (Monday)均匀随机数。 编写一个程序 Stats5.java,打印 5 个介于 0 和 1 之间的均匀随机值,它们的平均值,以及它们的最小值和最大值��使用
Math.random(),Math.min()和Math.max()。三数排序。 编写一个程序 ThreeSort.java,从命令行接受三个整数值,并按升序打印它们。使用
Math.min()和Math.max()。龙曲线。
编写一个程序 Dragon.java 来打印绘制龙曲线的指令,从 0 到 5 阶。指令是由字符F、L和R组成的字符串,其中F表示“向前移动 1 个单位时画线”,L表示“向左转”,R表示“向右转”。当您将一条纸折叠 n 次,然后展开成直角时,就形成了 n 阶龙曲线。解决这个问题的关键是注意到 n 阶曲线是 n-1 阶曲线后跟一个L,后跟以相反顺序遍历的 n-1 阶曲线,然后找出反向曲线的类似描述。
网页练习
编写一个程序 Swap.java,接受两个整数型命令行参数 a 和 b,并使用第 17 页描述的交换惯用法交换它们的值。在每个赋值语句之后,使用
System.out.println()打印变量的跟踪。当
grade是一个int类型的变量时,以下语句会做什么?boolean isA = (90 <= grade <= 100);解决方案:语法错误,因为
<=是一个二元运算符。你可以将表达式重写为(90 <= grade && grade <= 100)。RGB 到 YIQ 颜色转换器。 编写一个程序
RGBtoYIQ.java,接受一个 RGB 颜色(三个介于 0 和 255 之间的整数)并将其转换为 YIQ 颜色(三个不同的实数 Y、I 和 Q,其中 0 ≤ Y ≤ 1,–0.5957 ≤ I ≤ 0.5957,–0.5226 ≤ Q ≤ 0.5226)。编写一个程序YIQtoRGB.java,应用逆转换。CMYK 到 RGB 颜色匹配。 编写一个程序
CMYKtoRGB,读取四个介于 0 和 1 之间的命令行输入 C、M、Y 和 K,并打印相应的 RGB 参数。通过"反转" RGB 到 CMYK 转换公式来设计适当的公式。以下代码片段会打印什么?
double x = (double) (3/5); System.out.println(x);解决方案:它会打印
0.0,因为整数除法在转换之前进行。为什么以下程序不会打印 4294967296 = 2³²?
int x = 65536; long y = x * x; System.out.println(y);解决方案:两个
int值的乘积被计算为一个int,然后自动转换为一个long。但是 65536 * 65536 = 2³² 在转换之前会导致 32 位int溢出。(Math.sqrt(2) * Math.sqrt(2) == 2)的值是多少?编写一个程序 DivideByZero.java 来查看当你将一个
int或double���以零时会发生什么。解决方案:
(17 / 0)和(17 % 0)会导致除以零异常;(17.0 / 0.0)的结果是一个值Infinity;(17.0 % 0.0)的结果是一个值NaN,代表着"不是一个数字"。
猜最大数。 考虑以下游戏。爱丽丝在两张卡上写下 0 到 100 之间的两个整数。鲍勃可以选择其中一张卡并查看其值。查看值后,鲍勃会选择其中一张卡。如果他选择的卡上有最大的值,他就赢了;否则他就输了。为鲍勃设计一种策略(以及相应的计算机程序),以确保他能赢得超过一半的时间。
斐波那契词。 编写一个程序
FibonacciWord.java,打印出 0 到 10 阶的斐波那契词。f(0) = "a",f(1) = "b",f(2) = "ba",f(3) = "bab",f(4) = "babba",一般情况下 f(n) = f(n-1) 后跟 f(n-2)。使用字符串连接。标准差。 修改练习 1.2.30,使其在打印平均值的同时打印样本标准差。
编写一个程序,读取三个参数并打印出如果它们都相等则为
true,否则为false。如果你编译 LeapYear.java 并执行它,会发生什么?
java LeapYear
java LeapYear 1975.5
java LeapYear -1975
java LeapYear 1975 1976 1977
如果你尝试写下以下表达式,编译器会做什么:
int a = 27 * "three";如果你尝试写下以下表达式,编译器会做什么:
double x; System.out.println(x);解决方案:编译器会抱怨变量 x 可能尚未初始化。
main中的变量不会自动初始化。以下代码片段会打印什么。
int threeInt = 3; int fourInt = 4; double threeDouble = 3.0; double fourDouble = 4.0; System.out.println(threeInt / fourInt); System.out.println(threeInt / fourDouble); System.out.println(threeDouble / fourInt); System.out.println(threeDouble / fourDouble);编写一个程序,接受四个实数型命令行参数 x1、y1、x2 和 y2,并打印出点 (x1, y1) 和点 (x2, y2) 之间的欧几里得距离。使用
Math.sqrt()。编写一个程序
Ordered.java,读取三个整数型命令行参数x、y和z。创建一个布尔变量b,如果这三个值是升序或降序排列,则为true,否则为false。打印变量b。编写一个程序 Divisibility.java,读取两个命令行输入,并在两者都能被 7 整除时打印
true,否则打印false。三角形的面积。 编写一个名为
TriangleArea.java的程序,接受三个命令行输入 a、b 和 c,表示三角形的边长,并使用海伦公式打印三角形的面积:area = sqrt(s(s-a)(s-b)(s-c)),其中 s = (a + b + c) / 2。赤道坐标转换为水平坐标。 赤道坐标系统被天文学家广泛用于指示星球在天球上的位置。位置由其赤纬δ、时角 H 和纬度φ指定。水平坐标系统(也称为 Alt/Az 坐标系统)对于确定天体的落下/升起时间很有用。位置由其高度(与地平线的垂直角)和方位角(水平角)指定。给定一个星星的位置使用赤道坐标,使用下面的公式找到其在水平坐标中的位置。
Altitude = asin (sin φ sin δ + cos φ cos δ cos H) Azimuth = acos ((cos φ sin δ - sin φ cos δ cos H) / cos (Altitude) )身体质量指数。 身体质量指数(BMI)是一个人体重(以千克为单位)与身高(以米为单位)的平方比率。编写一个名为
BMI.java的程序,接受两个命令行参数,weight和height,并打印 BMI。温度转换。 下面的代码片段将从华氏度(F)转换为摄氏度(C)有什么问题?
double C = (F - 32) * (5 / 9);指数。 下面的代码片段计算
a²有什么问题,其中a是double类型?double b = a²;解决方案:在 Java 中,
^不表示指数(它是逻辑中的异或函数)。使用a*a代替。要计算 a^x,使用Math.pow(a, x)。请注意,Math.pow()返回一个double,因此如果上面的示例中的a和b是整数,则需要显式转换。以下哪个陈述是合法的?
boolean b = 1; boolean b = true; boolean b = "true"; boolean b = True;解决方案:只有第二个。
除溢出外,给出一个计算两个整数
a和b的最大值的代码片段,不使用Math.max()或if。int max = (a + b + Math.abs(a - b)) / 2;三次多项式的判别式。 给定三次多项式 x³ + bx² + cx + d 的系数 b、c 和 d,编写一个表达式来计算判别式 b²c² - 4c³ - 4b³d - 27d² + 18bcd。
重心。 在一个双体系统中,重心是两个天体围绕其相互轨道的重心。给定两个天体的质量m[1]和m[2],以及两个天体之间的最短距离a,编写一个程序来计算从第一个(质量更大)天体的中心到重心的距离:r[1] = a m[2] / (m[1] + m[2])。
以下是一些示例。质量以地球质量单位表示,距离以千米表示。
地球-月球:m[1] = 1,m[2] = .0123,a = 384,000,r[1] = 4,670,R[1] = 6,380。
冥王星-卡戎:m[1] = .0021,m[2] = .000254,a = 19,600,r[1] = 2,110,R[1] = 1,150。
太阳-地球:m[1] = 333,000,m[2] = 1,a = 150,000,000,r[1] = 449,R[1] = 696,000。
请注意,如果r[1]小于第一个天体的半径R[1],则重心位于第一个天体内部。
毒括号。 找到一个合法的 Java 表达式,当您在子表达式周围添加括号以记录在没有括号的情况下将发生的评估顺序时,会导致编译时错误。
解决方案:字面值 2147483648(2³¹)只允许作为一元减号运算符的操作数,即,-2147483648。将其括在括号中,即,-(2147483648),会导致编译时错误。类似的想法适用于字面值 9223372036854775808L(2⁶³)。
汽车贷款付款。 编写一个程序 CarLoan.java,读取三个命令行参数 P、Y 和 R,并计算在 Y 年内支付 P 美元贷款的每月付款额,利率为 R,按月复利。公式是
P r payment = ---------------, where n = 12 * Y, r = (R / 100) / 12 1 - (1 + r)^(-n)注意:在第九章中,我们将考虑更准确计算这个数量的方法,所以在你开始运行在线银行之前,请务必了解舍入误差。
编写一个程序 Trig.java 来展示
Math库中的各种三角函数,比如Math.sin(),Math.cos()和Math.toRadians()。
1.3 条件和循环
原文:
introcs.cs.princeton.edu/java/13flow译者:飞龙
在我们到目前为止检查的程序中,每个语句都按给定的顺序执行一次。大多数程序更复杂,因为语句的顺序和每个语句执行的次数可能会有所不同。我们使用术语控制流来指代程序中的语句顺序。
if 语句。
大多数计算需要针对不同的输入采取不同的行动。
以下代码片段使用
if语句将两个int值中较小的值放入x,将两个值中较大的值放入y,如果需要则交换两个变量中的值。![if 语句的解剖]()
Flip.java 使用
Math.random()和if-else语句来打印硬币翻转的结果。下表总结了一些典型情况,您可能需要使用
if或if-else语句。![条件示例]()
While 循环。
许多计算本质上是重复的。while循环使我们能够多次执行一组语句。这使我们能够在不编写大量代码的情况下表达冗长的计算。
以下代码片段计算小于或等于给定正整数n的最大 2 的幂。
![while 循环的解剖]()
TenHellos.java 打印"Hello World" 10 次。
PowersOfTwo.java 接受一个整数命令行参数n,并打印小于或等于n的所有 2 的幂。
for 循环。
for 循环 是另一种 Java 构造,使我们在编写循环时更加灵活。
符号说明。 许多循环遵循相同的基本方案:将索引变量初始化为某个值,然后使用
while循环测试涉及索引变量的退出条件,使用while循环中的最后一条语句修改索引变量。Java 的for循环是表达这种循环的直接方式。![for 循环的解剖]()
复合赋值习语。 习语
i++是i = i + 1的简写表示。作用域。 变量的作用域是程序中可以通过名称引用该变量的部分。通常,变量的作用域包括声明后面的语句,与声明在同一块中的语句。为此,
for循环头中的代码被视为与for循环主体在同一块中。
嵌套。
if、while和for语句与 Java 中的赋值语句或其他语句具有相同的地位;也就是说,我们可以在需要语句的任何地方使用它们。特别是,我们可以在另一个语句的主体中使用一个或多个语句来创建复合语句。为了强调嵌套,我们在程序代码中使用缩进。
DivisorPattern.java 有一个
for循环,其主体包含一个for循环(其主体是一个if-else语句)和一个打印语句。它打印出一个星号模式,其中第i行的每个位置都有一个星号,对应于i的约数(列也是如此)。MarginalTaxRate.java 计算给定收入的边际税率。它使用几个嵌套的
if-else语句来从一系列互斥的可能性中进行选择。
循环示例。
应用。
使用循环和条件语句编程立即为我们打开了计算的世界。
- *尺规划分。*RulerN.java 接受一个整数命令行参数 n 并打印出尺规划分长度的字符串。这个程序展示了循环的一个基本特征——程序几乎不能再简单了,但它可以产生大量的输出。

*有限和。*PowersOfTwo.java 中使用的计算范式是您经常会使用的范式。它使用两个变量——一个作为控制循环的索引,另一个用于累积计算结果。程序 HarmonicNumber.java 使用相同的范式来评估和
\(H_n = \frac{1}{1} + \frac{1}{2} + \frac{1}{3} + \frac{1}{4} + \; \ldots \; + \frac{1}{n}\)
这些数字,被称为调和数,在算法分析中经常出现。
牛顿法。
Sqrt.java 使用一种经典的迭代技术,称为牛顿法,来计算正数x的平方根:从一个估计值t开始。如果t等于x/t(直到机器精度),那么t等于x的平方根,计算完成。如果不是,则通过用t和x/t的平均值替换t来改进估计值。每次执行此更新,我们都会更接近所需的答案。数字转换。二进制.java 打印出以命令行参数输入的十进制数的二进制(基数 2)表示。它基于将数字分解为 2 的幂的和。例如,106 的二进制表示是 1101010[2],这等同于说 106 = 64 + 32 + 8 + 2。要计算n的二进制表示,我们按递减顺序考虑小于或等于n的 2 的幂,以确定哪些属于二进制分解(因此对应于二进制表示中的 1 位)。
赌徒的毁灭。
假设一个赌徒进行一系列公平的 1 美元赌注,从 50 美元开始,并继续玩下去,直到她破产或赢得 250 美元。她赢得 250 美元的机会有多大,以及在赢或输之前她可能会做多少赌注?Gambler.java 是一个可以帮助回答这些问题的模拟。它需要三个命令行参数,初始赌注(50 美元),目标金额(250 美元)以及我们想要模拟游戏的次数。*质因数分解。*Factors.java 接受一个整数命令行参数
n并打印出它的质因数分解。与我们看到的许多其他程序不同(我们可以在几分钟内用计算器或纸和笔完成),这个计算没有计算机是不可行的。
其他条件和循环结构。
为了完整起见,我们考虑与条件和循环相关的另外四个 Java 构造。它们使用频率远低于我们一直在使用的if、while和for语句,但了解它们是值得的。
中断语句。 在某些情况下,我们希望立即退出一个循环,而不让它运行到完成。Java 提供
break语句来实现这一目的。Prime.java 接受一个整数命令行参数n,如果n是质数则打印true,否则打印false。有两种不同的方法可以退出这个循环:要么执行break语句(因为n不是质数),要么循环继续条件不满足(因为n��质数)。请注意,
break语句不适用于if或if-else语句。在一次著名的编程错误中,美国电话网络崩溃,因为一个程序员打算使用break语句退出一个复杂的if语句。Continue 语句. Java 还提供了一种跳过循环的下一次迭代的方法:
continue语句。当在for循环的主体内执行continue时,控制流直接转移到循环的下一次迭代的增量语句。Switch 语句.
if和if-else语句允许一个或两个替代方案。有时,一个计算自然地提出了两个以上的互斥的替代方案。Java 提供了switch语句来实现这一目的。NameOfDay.java 接受一个介于 0 和 6 之间的整数作为命令行参数,并使用switch语句打印相应的星期几名称(星期日到星期六)。Do-while 循环.
do-while循环与while循环几乎相同,只是第一次通过循环时省略了循环继续条件。RandomPointInCircle.java 设置x和y,使得(x,y)在以(0, 0)为中心、半径为 1 的圆内随机分布。![do-while 循环]()
使用
Math.random(),我们得到的点在以(0, 0)为中心的 2x2 正方形中随机分布。我们只在这个区域内生成点,直到找到一个位于单位圆内的点为止。我们总是希望生成至少一个点,因此使用do-while循环是最合适的。由于我们希望在循环终止后访问它们的值,因此必须在循环外声明x和y。
我们在本教材中不使用以下两个流程控制语句,但为了完整性起见在此包含它们。
条件运算符. 条件运算符
?:是一个三元运算符(三个操作数),它使您能够在表达式中嵌入一个条件。这三个操作数由?和:符号分隔。如果第一个操作数(一个布尔表达式)为true,则结果具有第二个表达式的值;否则具有第三个表达式的值。int min = (x < y) ? x : y;带标签的 break 和 continue 语句.
break和continue语句适用于最内层的for或while循环。有时我们希望跳出几层嵌套循环。Java 提供了带标签的 break 和 continue 语句来实现这一点。这里有一个示例。
练习
编写一个程序 AllEqual.java,它接受三个整数命令行参数,并在所有三个参数相等时打印
equal,否则打印not equal。编写一个程序 RollLoadedDie.java,打印掷一个加载的骰子的结果,使得得���1、2、3、4 或 5 的概率为 1/8,得到 6 的概率为 3/8。
重写 TenHellos.java 以创建一个程序 Hellos.java,该程序将要打印的行数作为命令行参数。您可以假设参数小于 1000。提示:考虑使用
i % 10和i % 100来确定是否使用"st"、"nd"、"rd"或"th"来打印第i个 Hello。编写一个程序 FivePerLine.java,使用一个
for循环和一个if语句,每行打印从 1000 到 2000 的整数,每行五个整数。提示:使用%运算符。编写一个程序 FunctionGrowth.java,打印出ln n,n,n ln n,n²,n³和2^n的值表,其中n = 16, 32, 64, ..., 2048。使用制表符(
'\t'字符)来对齐列。在执行以下代码后,
m和n的值是多少?int n = 123456789; int m = 0; while (n != 0) { m = (10 * m) + (n % 10); n = n / 10; }以下代码打印出什么?
int f = 0, g = 1; for (int i = 0; i <= 15; i++) { System.out.println(f); f = f + g; g = f - g; }与调和��不同,和 1/1 + 1/4 + 1/9 + 1/16 + ... + 1/n² 确实会收敛到一个常数,随着n趋向无穷。 (实际上,常数是π² / 6,因此可以使用这个公式来估计π的值。)以下哪个 for 循环计算这个和? 假设
n是一个初始化为 1000000 的int,sum是一个初始化为 0 的double。(a) for (int i = 1; i <= n; i++) sum = sum + 1 / (i * i); (b) for (int i = 1; i <= n; i++) sum = sum + 1.0 / i * i; (c) for (int i = 1; i <= n; i++) sum = sum + 1.0 / (i * i); (d) for (int i = 1; i <= n; i++) sum = sum + 1 / (1.0 * i * i);修改 Binary.java 以获得一个程序修改
Kary.java,它接受第二个命令行参数K,并将第一个参数转换为基数K。 假设基数在 2 到 16 之间。 对于大于 10 的基数,使用字母A到F表示数字 10 到 15。编写一个程序代码片段,将正整数
n的二进制表示放入一个String变量s中。
创意练习
拉马努金的出租车。 S.拉马努金是一位以其对数字的直觉而闻名的印度数学家。 有一天,英国数学家 G.H.哈代来医院看望他时,哈代说他的出租车号码是 1729,一个相当乏味的数字。 拉马努金回答说:“不,哈代!不,哈代!这是一个非常有趣的数字。它是唯一的一个可以用两种不同方式的两个立方体的和来表示的最小数字。” 通过编写一个程序 Ramanujan.java,接受一个整数命令行参数 n,并打印小于或等于 n 的所有整数,这些整数可以用两种不同方式的两个立方体的和来表示 - 找到不同的正整数a、b、c和d,使得a³ + b³ = c³ + d³。 使用四个嵌套的 for 循环。
现在,车牌号 87539319 似乎是一个相当乏味的数字。 确定它为什么不是。
校验和。 国际标准书号(ISBN)是一个包含 10 位数字的代码,可以唯一指定一本书。 最右边的数字是一个校验和数字,可以根据其他 9 位数字确定,条件是d[1] + 2d[2] + 3d[3] + ... + 10d[10]必须是 11 的倍数(这里d[i]表示从右边数第 i 位数字)。 校验和数字d[1]可以是 0 到 10 之间的任何值:ISBN 约定使用值 X 表示 10。 示例:对应于 020131452 的校验和数字是 5,因为它是 0 到 10 之间d[1]的唯一值,使得d[1] + 22 + 35 + 44 + 51 + 63 + 71 + 80 + 92 + 100*是 11 的倍数。 编写一个程序 ISBN.java,它以一个 9 位整数作为命令行参数,计算校验和,并打印 10 位 ISBN 号码。 如果不打印任何前导 0,也没关系。
指数函数。 假设
x是一个正的double类型变量。 编写一个程序 Exp.java,使用泰勒级数展开计算 e^x\(e^ x = 1 + x + \frac{x²}{2!} + \frac{x³}{3!} + \frac{x⁴}{4!} + \ldots\)
三角函数。 编写两个程序 Sin.java 和
Cos.java,使用泰勒级数展开计算sin x和cos x\(\sin x = x - \frac{x³}{3!} + \frac{x⁵}{5!} - \frac{x⁷}{7!} + \ldots\)
\(\cos x = 1 - \frac{x²}{2!} + \frac{x⁴}{4!} - \frac{x⁶}{6!} + \ldots\)
游戏模拟。 在游戏节目让我们做个交易中,一个参赛者面前有三扇门。其中一扇门后面是一个有价值的奖品,另外两扇门后面是恶作剧礼物。在参赛者选择一扇门后,主持人会打开另外两扇门中的一扇(当然不会揭示奖品)。然后参赛者有机会换到另一扇未打开的门。参赛者应该这样做吗?直觉上,参赛者最初选择的门和另一扇未打开的门同样有可能包含奖品,因此没有动机去换。编写一个程序 MonteHall.java 通过模拟来测试这种直觉。你的程序应该接受一个整数命令行参数n,使用两种策略(换门或不换门)玩n次游戏,并打印每种策略的成功率。或者你可以在这里玩游戏。
欧拉幂和猜想。 1769 年,莱昂哈德·欧拉提出了费马大定理的一个广义版本,猜想至少需要n个n次幂才能得到一个本身是n次幂的和,其中n > 2。编写一个程序 Euler.java 来证明欧拉的猜想(直到 1967 年仍然有效),使用五重嵌套循环找到四个正整数,它们的 5 次幂之和等于另一个正整数的 5 次幂。也就是说,找到a、b、c、d和e,使得a⁵ + b⁵ + c⁵ + d⁵ = e⁵。使用
long数据类型。
网络练习
编写一个程序 RollDie.java,生成掷一个公平的六面骰子的结果(1 到 6 之间的整数)。
编写一个程序,接受三个整数命令行参数 a、b 和 c,并打印 a、b 和 c 中不同值的数量(1、2 或 3)。
编写一个程序,接受五个整数命令行参数,并打印中位数(第三大的数)。
(难)现在,尝试计算 5 个元素的中位数,使得当执行时,总比较次数不超过 6 次。
如何使用 for 循环创建一个无限循环?
解决方案:
for(;;)与while(true)相同。以下循环有什么问题?
boolean done = false; while (done = false) { ... }while 循环条件使用
=而不是==,因此是一个赋值语句(这使得done始终为false,循环体永远不会被执行)。最好避免使用==。boolean done = false; while (!done) { ... }以下循环有什么问题,它旨在计算 1 到 100 之间整数的总和?
for (int i = 1; i <= N; i++) { int sum = 0; sum = sum + i; } System.out.println(sum);变量
sum应该在循环外定义。通过在循环内定义它,每次循环时都会初始化一个新的sum变量为 0;而且在循环外部无法访问它。编写一个程序 Hurricane.java,将风速(以英里/小时为单位)作为整数命令行参数,并打印出它是否符合飓风标准,如果是的话,它是 1、2、3、4 还是 5 级飓风。下面是根据Saffir-Simpson 飓风等级的风速表。
类别 风速(英里/小时) 1 74 - 95 2 96 - 110 3 111 - 130 4 131 - 155 5 155 及以上 以下代码片段有什么问题?
double x = -32.2; boolean isPositive = (x > 0); if (isPositive = true) System.out.println(x + " is positive"); else System.out.println(x + " is not positive");解决方案:它使用赋值运算符
=而不是相等运算符==。更好的解决方案是写成if (isPositive)。改变/添加一个字符,使得以下程序打印出 20 个 x。有两种不同的解决方案。
int i = 0, n = 20; for (i = 0; i < n; i--) System.out.print("x");解决方案:将 i < n 的条件替换为-i < n。将 i--替换为 n--。(在 C 中,还有第三种解决方案:将<替换为+。)
以下代码片段做什么?
if (x > 0); System.out.println("positive");解决方案:无论
x的值如何,由于在if语句后面多了一个分号,总是打印positive。RGB 转 HSB 转换器。 编写一个名为
RGBtoHSV.java的程序,该程序接受 RGB 颜色(0 到 255 之间的三个整数)并将其转换为HSB 颜色(0 到 255 之间的三个不同整数)。编写一个名为HSVtoRGB.java的程序,应用逆转换。男孩和女孩。 一对开始组建家庭的夫妇决定继续生育,直到他们至少有一个性别。通过模拟估计他们将拥有的平均子女数量。还估计最常见的结果(记录 2、3 和 4 个孩子的频率计数,以及 5 个及以上的孩子)。假设男孩或女孩的概率 p 为 1/2。
以下程序做什么?
public static void main(String[] args) { int N = Integer.parseInt(args[0]); int x = 1; while (N >= 1) { System.out.println(x); x = 2 * x; N = N / 2; } }解决方案:打印小于或等于 n 的所有 2 的幂。
男孩和女孩。 重复上一个问题,但假设夫妇继续生育,直到他们有另一个与第一个孩子性别相同的孩子。如果 p 与 1/2 不同,您的答案会如何改变?
令人惊讶的是,如果 p = 0 或 1,则平均子女数为 2,对于所有其他 p 值,则为 3。但对于所有 p 值,最可能的值是 2。
给定两个正整数
a和b,以下代码片段在c中留下什么结果c = 0; while (b > 0) { if (b % 2 == 1) c = c + a; b = b / 2; a = a + a; }解决方案:a * b。
使用循环和四个条件语句编写一个程序来打印
12 midnight 1am 2am ... 12 noon 1pm ... 11pm以下程序打印什么?
public class Test { public static void main(String[] args) { if (10 > 5); else; { System.out.println("Here"); }; } }爱丽丝抛一枚公平硬币,直到她看到两个连续的正面。鲍勃抛另一枚公平硬币,直到他看到一个正面后面跟着一个反面。编写一个程序来估计爱丽丝比鲍勃少抛硬币的概率?解决方案:39/121。
重新编写 DayOfWeek.java,使其打印星期几,例如星期日、星期一等,而不是 0 到 6 之间的整数。使用
switch语句。数字转英文。 编写一个程序,读取介于-999,999,999 和 999,999,999 之间的命令行整数,并打印英文等价物。以下是您的程序应使用的单词的详尽列表:negative, zero, one, two, three, four, five, six, seven, eight, nine, ten, eleven, twelve, thirteen, fourteen, fifteen, sixteen, seventeen, eighteen, nineteen, twenty, thirty, forty, fifty, sixty, seventy, eighty, ninety, hundred, thousand, million。当您可以使用千时,请不要使用百,例如,使用一千五百而不是一千五百。参考。
体操评分。 体操运动员的得分由 6 名评委组成的小组决定,每位评委决定 0.0 到 10.0 之间的得分。最终得分是通过丢弃最高和最低分数,然后对剩余的 4 个分数取平均值来确定的。编写一个名为
GymnasticsScorer.java的程序,该程序接受代表 6 个分数的 6 个实数命令行输入,并打印它们的平均值,在丢弃最高和最低分数后。四分卫评分。 为了比较 NFL 四分卫,NFL 设计了基于四分卫完成传球次数(A)、传球尝试次数(B)、传球码数(C)、触球传球(D)和拦截(E)的四分卫评分公式如下:
完成比率:W = 250/3 * ((A / B) - 0.3)。
每次传球码数:X = 25/6 * ((C / B) - 3)。
触球比率:Y = 1000/3 * (D / B)
拦截比率:Z = 1250/3 * (0.095 - (E / B))四分卫评分通过总结上述四个量来计算,但将每个值四舍五入,使其至少为 0,最多为 475/12。编写一个名为
QuarterbackRating.java的程序,该程序接受五个命令行输入 A、B、C、D 和 E,并打印四分卫评分。使用您的程序计算史蒂夫·杨(Steve Young)在 1994 年创下的纪录赛季(112.8),他完成了 461 次传球中的 324 次,总共 3969 码,投出 35 次触球和 10 次拦截。截至 2014 年,单赛季最高纪录是 2011 年阿伦·罗杰斯(Aaron Rodgers)的 122.5。
**有理数的十进制扩展。**给定两个整数 p 和 q,p/q 的十进制扩展具有无限重复循环。例如,1/33 = 0.03030303....我们使用符号 0.(03)表示 03 无限重复。另一个例子,8639/70000 = 0.1234(142857)。编写一个名为
DecimalExpansion.java的程序,该程序读取两个命令行整数 p 和 q,并使用上述符号打印 p/q 的十进制扩展。提示:使用 Floyd 的规则。**黑色星期五。**在连续的天数中,没有星期五 13 号出现的最大天数是多少?提示:格里高利历每 400 年(146097 天)重复一次,因此您只需要担心 400 年的间隔。
解决方案:426(例如,从 1999 年 8 月 13 日到 2000 年 10 月 13 日)。
**1 月 1 日。**1 月 1 日更有可能是星期六还是星期日?编写一个程序来确定在 400 年间隔中每个日期发生的次数。
解决方案:周日(58 次)比周六(56 次)更有可能。
以下两个代码片段分别做什么?
for (int i = 0; i < N; i++) for (int j = 0; j < N; j++) if (i != j) System.out.println(i + ", " + j); for (int i = 0; i < N; i++) for (int j = 0; (i != j) && (j < N); j++) System.out.println(i + ", " + j);不使用计算机确定打印出的值是多少。从 0、100、101、517 或 1000 中选择正确答案。
int cnt = 0; for (int i = 0; i < 10; i++) for (int j = 0; j < 10; j++) for (int k = 0; k < 10; k++) if (2*i + j >= 3*k) cnt++; System.out.println(cnt);重写 Creative Exercise XYZ 中的 CarLoan.java,以便正确处理 0%的利率并避免除以 0。
编写您可以的最短的 Java 程序,该程序接受一个整数命令行参数 n,并在(1 + 2 + ... + n)²等于(1³ + 2³ + ... + n³)时打印
true。解决方案:始终打印
true。修改 Sqrt.java 以便在用户输入负数时报告错误,并在用户输入零时正常工作。
如果在程序 Sqrt.java 中将
t初始化为-x而不是x会发生什么?**均匀分布的样本标准差。**修改练习 8,以便除了平均值外还打印样本标准差。
**正态分布的样本标准差。**接受一个整数 N 作为命令行参数,并使用第 1.2 节中的 Web 练习 1 打印 N 个标准正态随机变量,以及它们的平均值和样本标准差。
偏斜骰子。[斯蒂芬·鲁迪奇]假设您有三个三面骰子。A:{2, 6, 7},B:{1, 5, 9},C:{3, 4, 8}。两名玩家掷骰子,值最高者获胜。您会选择哪个骰子?解决方案:A 以 5/9 的概率击败 B,B 以 5/9 的概率击败 C,C 以 5/9 的概率击败 A。一定要选择第二个!
**Thue–Morse 序列。**编写一个程序 ThueMorse.java,读取一个命令行整数 n,并打印 n 阶的Thue–Morse 序列。前几个字符串是 0、01、0110、01101001。每个后续字符串都是通过翻转前一个字符串的所有位并将结果连接到前一个字符串的末尾而获得的。该序列具有许多惊人的特性。例如,它是一个无立方体的二进制序列:它不包含 000、111、010101 或
sss,其中s是任何字符串。它是自相似的:如果删除每隔一个位,您将得到另一个 Thue–Morse 序列。它在数学领域以及国际象棋、图形设计、编织图案和音乐作曲中都有出现。程序 Binary.java 通过去除 2 的幂来打印十进制数 n 的二进制表示。编写一个基于以下方法的替代版本程序 Binary2.java:如果 n 是奇数,则写 1,如果 n 是偶数,则写 0。将 n 除以 2,舍弃余数。重复直到 n = 0,并将答案倒过来读。使用
%确定 n 是否为偶数,并使用字符串连接以逆序形成答案。以下代码片段做什么?
int digits = 0; do { digits++; n = n / 10; } while (n > 0);解决方案:自然数 n 的二进制表示中的位数。我们使用
do-while循环,因此当 n = 0 时,代码输出 1。编写一个程序
NPerLine.java,接受一个整数命令行参数n,并打印从 10 到 99 的整数,每行打印 n 个整数。修改
NPerLine.java,使其每行打印从 1 到 1000 的整数,每行打印 n 个整数。通过在整数前打印正确数量的空格使整数对齐(例如,1-9 为三个空格,10-99 为两个空格,100-999 为一个空格)。假设 a、b 和 c 是在 0 和 1 之间均匀分布的随机数。a、b 和 c 形成某个三角形的边长的概率是多少?提示:只有当任意两个值的和大于第三个值时,它们才会形成一个三角形。
重复上一个问题,但计算得到的三角形是钝角三角形的概率,假设三个数字是一个三角形。提示:三条边长度将形成一个钝角三角形,当且仅当(i)任意两个值的和大于第三个值,且(ii)任意两边长度的平方和大于或等于第三边长度的平方。
答案。
在执行以下代码后,s 的值是多少?
int M = 987654321; String s = ""; while (M != 0) { int digit = M % 10; s = s + digit; M = M / 10; }在执行以下混乱的代码后,i 的值是多少?
int i = 10; i = i++; i = ++i; i = i++ + ++i;寓意:不要编写这样的代码。
格式化的 ISBN 号码。 编写一个程序 ISBN2.java,从命令行参数中读取一个 9 位整数,计算校验位,并打印完全格式化的 ISBN 号码,例如,0-201-31452-5。
UPC 码。 通用产品代码(UPC)是一个 12 位代码,唯一指定一个产品。最低有效位 d[1](最右边的一位)是一个校验位,通过使以下表达式成为 10 的倍数来唯一确定:
(d[1] + d[3] + d[5] + d[7] + d[9] + d[11]) + 3 (d[2] + d[4] + d[6] + d[8] + d[10] + d[12])
例如,对应于 0-48500-00102(Tropicana Pure Premium Orange Juice)的校验位是 8,因为
(8 + 0 + 0 + 0 + 5 + 4) + 3 (2 + 1 + 0 + 0 + 8 + 0) = 50
并且 50 是 10 的倍数。编写一个程序,从命令行参数读取一个 11 位整数,计算校验位,并打印完整的 UPC。提示:使用
long类型的变量存储 11 位数。编写一个程序,将风速(以节为单位)作为命令行参数读入,并根据伯福特风级表打印其风力。使用
switch语句。找零钱。 编写一个程序,读取一个命令行整数 N(便士数),并打印使用美国硬币(仅限 25 美分、10 美分、5 美分和 1 美分)找零的最佳方式(硬币数量最少)。例如,如果 N = 73,则打印
2 quarters 2 dimes 3 pennies提示:使用贪婪算法。即,尽可能多地发放 25 美分硬币,然后是 10 美分硬币,然后是 5 美分硬币,最后是 1 美分硬币。
编写一个程序 Triangle.java,接受一个命令行参数 N 并打印一个 N-by-N 的三角形图案。
* * * * * * . * * * * * . . * * * * . . . * * * . . . . * * . . . . . *编写一个程序 Ex.java,接受一个命令行参数 N 并打印一个(2N + 1)-by-(2N + 1)的 X 图案。使用两个
for循环和一个if-else语句。* . . . . . * . * . . . * . . . * . * . . . . . * . . . . . * . * . . . * . . . * . * . . . . . *编写一个程序 BowTie.java,接受一个命令行参数 N 并打印一个(2N + 1)-by-(2N + 1)的蝴蝶结图案。使用两个
for循环和一个if-else语句。* . . . . . * * * . . . * * * * * . * * * * * * * * * * * * * . * * * * * . . . * * * . . . . . *编写一个程序 Diamond.java,接受一个命令行参数 N 并打印一个(2N + 1)-by-(2N + 1)的菱形图案。
% java Diamond 4 . . . . * . . . . . . . * * * . . . . . * * * * * . . . * * * * * * * . * * * * * * * * * . * * * * * * * . . . * * * * * . . . . . * * * . . . . . . . * . . . .编写一个程序 Heart.java,接受一个命令行参数 N 并打印一个心形。
当 N = 5 时,程序 Circle.java 打印出什么?
for (int i = -N; i <= N; i++) { for (int j = -N; j <= N; j++) { if (i*i + j*j <= N*N) System.out.print("* "); else System.out.print(". "); } System.out.println(); }季节。 编写一个名为
Season.java的程序,接受两个命令行整数 M 和 D,并打印对应于北半球月份 M(1 = 1 月,12 = 12 月)和日期 D 的季节。使用以下表格季节 从 到 春季 3 月 21 日 6 月 20 日 夏季 6 月 21 日 9 月 22 日 秋季 9 月 23 日 12 月 21 日 冬季 12 月 21 日 3 月 20 日 星座。 编写一个名为
Zodiac.java的程序,接受两个命令行整数 M 和 D,并打印对应于月份 M(1 = 1 月,12 = 12 月)和日期 D 的星座。使用以下表格星座 从 到 摩羯座 12 月 22 日 1 月 19 日 水瓶座 1 月 20 日 2 月 17 日 双鱼座 2 月 18 日 3 月 19 日 白羊座 3 月 20 日 4 月 19 日 金牛座 4 月 20 日 5 月 20 日 双子座 5 月 21 日 6 月 20 日 巨蟹座 6 月 21 日 7 月 22 日 狮子座 7 月 23 日 8 月 22 日 处女座 8 月 23 日 9 月 22 日 天秤座 9 月 23 日 10 月 22 日 天蝎座 10 月 23 日 11 月 21 日 射手座 11 月 22 日 12 月 21 日 泰拳搏击。 编写一个程序,通过命令行参数读取泰拳搏击手的体重(以磅为单位),并打印他们的体重级别。使用
switch语句。级别 从 到 雏量级 0 112 超级雏量级 112 115 幼量级 115 118 超级雏量级 118 122 羽量级 122 126 超级羽量级 126 130 轻量级 130 135 超级轻量级 135 140 欢迎量级 140 147 超级次中量级 147 154 中量级 154 160 超级中量级 160 167 轻重量级 167 175 超级轻重量级 175 183 重量级 183 190 重量级 190 220 超级重量级 220 - 欧拉幂和猜想。 1769 年,欧拉推广了费马大定��,并猜想找不到三个 4 次幂的和是 4 次幂,或者四个 5 次幂的和是 5 次幂,等等。这个猜想在 1966 年被详尽的计算机搜索所证伪。通过找到正整数 a、b、c、d 和 e,使得 a⁵ + b⁵ + c⁵ + d⁵= e⁵来证伪这个猜想。编写一个程序 Euler.java,读取一个命令行参数 N,并详尽搜索所有这样的解,其中 a、b、c、d 和 e 小于或等于 N。对于大于 5 的幂,目前没有反例,但您可以加入EulerNet,这是一个分布式计算项目,旨在找到第六次幂的反例。
二十一点。 编写一个名为
Blackjack.java的程序,接受三个命令行整数 x、y 和 z,表示您的两张二十一点牌 x 和 y,以及庄家的明牌 z,并打印大西洋城 6 副牌的“标准策略”。假设 x、y 和 z 是 1 到 10 之间的整数,表示从 A 到面牌。根据这些策略表报告玩家应该要牌、停牌还是分牌。 (当您学习数组时,您将遇到一种不涉及太多 if-else 语句的替代策略)。带加倍的二十一点。 修改前面的练习以允许加倍。
抛射运动。 以下方程式给出了弹道导弹的轨迹作为初始角度 theta 和风速的函数:xxxx。编写一个 Java 程序,在每个时间步长 t 打印导弹的(x,y)位置。使用试错法确定如果希望焚烧位于当前位置正东 100 英里处且海拔相同的目标,应该瞄准导弹的角度。假设风速为东风 20 英里/小时。
世界大赛。 棒球世界大赛是一场 7 局 4 胜的比赛,第一支赢得四场比赛的球队将赢得世界大赛。假设更强的球队在每场比赛中获胜的概率为 p > 1/2。编写一个程序来估计较弱球队赢得世界大赛的机会,并估计平均需要多少场比赛。
考虑方程(9/4)x = x(9/4)。一个解是 9/4。你能使用牛顿法找到另一个解吗?
排序网络。 编写一个程序 Sort3.java,其中有三个
if语句(没有循环),从命令行读取三个整数a、b和c,并按升序打印它们。if (a > b) swap a and b if (a > c) swap a and c if (b > c) swap b and c遗忘排序网络。 说服自己以下代码片段重新排列存储在变量 A、B、C 和 D 中的整数,使得 A ⇐ B ⇐ C ⇐ D。
if (A > B) { t = A; A = B; B = t; } if (B > C) { t = B; B = C; C = t; } if (A > B) { t = A; A = B; B = t; } if (C > D) { t = C; C = D; D = t; } if (B > C) { t = B; B = C; C = t; } if (A > B) { t = A; A = B; B = t; } if (D > E) { t = D; D = E; E = t; } if (C > D) { t = C; C = D; D = t; } if (B > C) { t = B; B = C; C = t; } if (A > B) { t = A; A = B; B = t; }设计一系列语句来对 5 个整数进行排序。你的程序使用了多少个
if语句?最佳遗忘排序网络。 创建一个程序,使用仅 5 个
if语句对四个整数进行排序,并使用仅 9 个上述类型的if语句对五个整数进行排序。遗忘排序网络对于在硬件中实现排序算法非常有用。你如何检查你的程序对所有输入都有效?解决方案:Sort4.java 使用 5 次比较交换对 4 个元素进行排序。Sort5.java 使用 9 次比较交换对 5 个元素进行排序。
0-1 原则断言您可以通过检查一个由 0 和 1 序列组成的输入是否正确排序来验证(确定性)排序算法的正确性。因此,要检查
Sort5.java是否有效,您只需要在 32 个可能的 0 和 1 序列输入上进行测试。最佳遗忘排序(具有挑战性)。 找到一个最佳的排序网络,用于 6、7 和 8 个输入,分别使用 12、16 和 19 个前一个问题中形式的
if语句。解决方案:Sort6.java 是对 6 个元素进行排序的解决方案。
最佳非遗忘排序。 编写一个程序,使用仅 7 次比较对 5 个输入进行排序。提示:首先比较前两个数字,然后比较后两个数字,再比较两组中较大的数字,并标记它们,使得 a < b < d 且 c < d。其次,通过首先与 b 比较,然后根据结果与 a 或 d 比较,将剩余元素 e 插入到链条 a < b < d 的正确位置。第三,通过与 b 比较,然后根据结果与 a 或 d 比较,将 c 插入到涉及 a、b、d 和 e 的链条中的正确位置(知道 c < d)。这使用了 3(第一步)+ 2(第二步)+ 2(第三步)= 7 次比较。这种方法最初由 H.B. Demuth 在 1956 年发现。
气象气球。 (Etter 和 Ingber,第 123 页)假设 h(t) = 0.12t⁴ + 12t³ - 380t² + 4100t + 220 表示气象气球在时间 t(以小时为单位)后 48 小时内的高度。创建一个表,列出 t = 0 到 48 时的高度。它的最大高度是多少?解决方案:t = 5。
以下代码片段会编译吗?如果会,它会做什么?
int a = 10, b = 18; if (a = b) System.out.println("equal"); else System.out.println("not equal");解决方案:它在条件中使用赋值运算符
=而不是相等运算符==。在 Java 中,此语句的结果是一个整数,但编译器期望一个布尔值。因此,程序将无法编译。在某些语言(特别是 C 和 C++)中,此代���片段将将变量 a 设置为 18 并打印equal而不会出现错误。陷阱 1。 以下代码片段做什么?
boolean a = false; if (a = true) System.out.println("yes"); else System.out.println("no");解决方案:它打印
yes。请注意,条件使用=而不是==。这意味着a被赋予值true。因此,条件表达式评估为true。Java 并不免疫于前一个练习中描述的=与==错误。因此,在测试布尔值时,最好使用if (a)或if (!a)。陷阱 2。 以下代码片段做什么?
int a = 17, x = 5, y = 12; if (x > y); { a = 13; x = 23; } System.out.println(a);解决方案:由于
if语句后面有一个多余的分号,所以始终打印 13。因此,即使(x <= y),赋值语句a = 13;也会被执行。拥有一个不属于条件语句、循环或方法的块是合法的(但不常见)。陷阱 3。 以下代码片段做什么?
for (int x = 0; x < 100; x += 0.5) { System.out.println(x); }解决方案:它进入一个无限循环,打印
0。复合赋值语句x += 0.5等同于x = (int) (x + 0.5)。以下代码片段做什么?
int income = Integer.parseInt(args[0]); if (income >= 311950) rate = .35; if (income >= 174700) rate = .33; if (income >= 114650) rate = .28; if (income >= 47450) rate = .25; if (income >= 0) rate = .22; System.out.println(rate);由于编译器无法保证
rate已初始化,因此无法编译。使用if-else代替。牛顿法的应用。 编写一个程序
BohrRadius.java,找到氢原子 4s 激发态中电子出现概率为零的半径。概率由*(1 - 3r/4 + r²/8 - r³/192)² e^(-r/2)给出,其中r是以玻尔半径(0.529173E-8 厘米)为单位的半径。使用牛顿法。���过在不同的r*值开始牛顿法,您可以发现所有三个根。提示:使用 r= 0、5 和 13 的初始值。挑战:解释如果使用初始值 r = 4 或 12 会发生什么。Pepys 问题。 1693 年,塞缪尔·皮皮斯问艾萨克·牛顿,在掷一个公平骰子 6 次时,至少得到一个 1 的概率更大,还是在掷一个公平骰子 12 次时至少得到两个 1 的概率更大。编写一个使用模拟来确定正确答案的程序 Pepys.java。
在 N = 1、2、3、4 和 5 时运行以下循环后,变量 s 的值是多少。
String s = ""; for (int i = 1; i <= N; i++) { if (i % 2 == 0) s = s + i + s; else s = i + s + i; }解决方案:Palindrome.java。
身体质量指数。 身体质量指数(BMI)是一个人的体重(以千克为单位)与身高(以米为单位)的平方的比值。编写一个名为
BMI.java的程序,接受两个命令行参数,weight和height,计算 BMI,并打印相应的 BMI 类别:饥饿:小于 15
厌食症:小于 17.5
体重不足:小于 18.5
理想:大于或等于 18.5 但小于 25
超重:大于或等于 25 但小于 30
肥胖:大于或等于 30 但小于 40
极度肥胖:大于或等于 40
雷诺数。 雷诺数是惯性力与粘性力的比值,在流体动力学中是一个重要的量。编写一个程序,接受 4 个命令行参数,直径 d、速度 v、密度 rho 和粘度 mu,并打印雷诺数 d * v * rho / mu(假设所有参数都以国际单位制表示)。如果雷诺数小于 2000,则打印
层流,如果在 2000 到 4000 之间,则打印过渡流,如果大于 4000,则打印湍流。风寒再探。 练习 1.2.14 中的风寒公式仅在风速高于 3MPH 且低于 110MPH,温度低于 50 华氏度且高于-50 华氏度时才有效。修改您的解决方案,如果用户输入超出允许范围的值,则打印错误消息。
球面上的点。 编写一个程序来打印球面上一个随机点的(x,y,z)坐标。使用Marsaglia 的方法:在单位圆中选择一个随机点(a,b),如
do-while示例中所示。然后,设置 x = 2a sqrt(1 - a² - b²),y = 2b sqrt(1 - a² - b²),z = 1 - 2(a² + b²)。k 的幂。 编写一个程序
PowersOfK.java,将一个整数K作为命令行参数,并打印 Javalong数据类型中所有正幂的K。注意:常量Long.MAX_VALUE是long中最大整数的值。平方根,再探。 为什么在 Sqrt.java 中不使用循环继续条件
(Math.abs(t*t - c) > EPSILON),而是使用Math.abs(t - c/t) > t*EPSILON)?解决方案:令人惊讶的是,它可能导致不准确的结果甚至更糟。例如,如果你向 SqrtBug.java 提供命令行参数
1e-50,你会得到1e-50作为答案(而不是1e-25);如果你提供16664444,你会得到一个无限循环!当你尝试编译以下代码片段时会发生什么?
double x; if (a >= 0) x = 3.14; if (a < 0) x = 2.71; System.out.println(x);解决方案:它抱怨变量 x 可能尚未初始化(尽管我们清楚地看到 x 将通过两个 if 语句之一进行初始化)。在这里,你可以通过使用 if-else 来避免这个问题。
1.4 数组
原文:
introcs.cs.princeton.edu/java/14array译者:飞龙
在本节中,我们考虑一种称为数组的基本构造。数组存储一系列相同类型的值。我们不仅想��储值,还想能够快速访问每个单独的值。我们用来引用数组中单个值的方法是对它们进行编号,然后索引它们——如果我们有n个值,我们将它们视为从 0 到n−1 编号。
Java 中的数组。
在 Java 程序中创建数组涉及三个不同的步骤:
声明数组名称。
创建数组。
初始化数组数值。
我们通过在数组名称后面的方括号中放置其索引来引用数组元素:代码a[i]表示数组a[]的第i个元素。例如,以下代码创建了一个类型为 double 的 n 个数字的数组,所有元素都初始化为 0:
double[] a; // declare the array
a = new double[n]; // create the array
for (int i = 0; i < n; i++) // elements are indexed from 0 to n-1
a[i] = 0.0; // initialize all elements to 0.0
典型的数组处理代码。
ArrayExamples.java 包含了 Java 中使用数组的典型示例。
使用数组进行编程。
在考虑更多示例之前,我们先考虑一些与数组编程相关的重要特性。
基于零的索引。 我们总是将数组
a[]的第一个元素称为a[0],第二个称为a[1],依此类推。你可能认为将第一个元素称为a[1],第二个值称为a[2]等更自然,但从 0 开始索引有一些优势,并已成为大多数现代编程语言使用的约定。数组长度。 一旦我们创建了一个数组,它的长度就是固定的。您可以在程序中使用代码
a.length引用a[]的长度。默认数组初始化。 为了节省代码,我们经常利用 Java 的默认数组初始化约定。例如,以下语句等同于本页顶部的四行代码:
double[] a = new double[n];对于所有数值原始类型,默认初始值为 0,对于布尔类型
boolean为false。内存表示。 当您使用
new创建数组时,Java 会在内存中为其保留空间(并初始化值)。这个过程称为内存分配。边界检查。 在使用数组编程时,您必须小心。在访问数组元素时使用合法的索引是您的责任。
在编译时设置数组值。 当我们有一小组我们想要保留在数组中的文字值时,我们可以通过在大括号中列出用逗号分隔的值来初始化它。例如,我们可以在处理扑克牌的程序中使用以下代码。
String[] SUITS = { "Clubs", "Diamonds", "Hearts", "Spades" }; String[] RANKS = { "2", "3", "4", "5", "6", "7", "8", "9", "10", "Jack", "Queen", "King", "Ace" };创建这两个数组后,我们可以使用它们来打印一个随机的卡片名称,比如
梅花皇后,如下所示。int i = (int) (Math.random() * RANKS.length); int j = (int) (Math.random() * SUITS.length); System.out.println(RANKS[i] + " of " + SUITS[j]);在运行时设置数组值。 更典型的情况是,当我们希望计算要存储在数组中的值时。例如,我们可以使用以下代码初始化一个长度为 52 的数组,表示一副扑克牌,使用刚刚定义的数组
RANKS[]和SUITS[]。String[] deck = new String[RANKS.length * SUITS.length]; for (int i = 0; i < RANKS.length; i++) for (int j = 0; j < SUITS.length; j++) deck[SUITS.length*i + j] = RANKS[i] + " of " + SUITS[j]; System.out.println(RANKS[i] + " of " + SUITS[j]);
洗牌和抽样。
现在我们描述一些有用的算法来重新排列数组中的元素。
交换。 经常,我们希望在数组中交换两个值。继续我们的扑克牌示例,以下代码交换了位置
i处的卡片和位置j处的卡片:String temp = deck[i]; deck[i] = deck[j]; deck[j] = temp;洗牌。 以下代码洗牌我们的牌组:
int n = deck.length; for (int i = 0; i < n; i++) { int r = i + (int) (Math.random() * (n-i)); String temp = deck[r]; deck[r] = deck[i]; deck[i] = temp; }从左到右,我们从
deck[i]到deck[n-1]中选择一张随机卡片(每张卡片同等可能)并将其与deck[i]交换。这段代码比看起来更复杂:详情请参阅教科书。Deck.java 包含了创建和洗牌一副扑克牌的完整代码。无替换抽样。在许多情况下,我们希望从一个集合中抽取一个随机样本,使得集合中的每个成员在样本中最多出现一次。Sample.java 接受两个命令行参数
m和n,并创建一个长度为n的排列,其中前m个条目组成一个随机样本。详情请参阅教科书。
预先计算的值。
数组的一个简单应用是保存你计算过的值,以供以后使用。例如,假设你正在编写一个使用调和数小值进行计算的程序。实现这样一个任务的一种简单方法是将值保存在一个数组中,代码如���
double[] harmonic = new double[n];
for (int i = 1; i < n; i++)
harmonic[i] = harmonic[i-1] + 1.0/i;
然后简单地使用代码 harmonic[i] 来引用任何一个值。以这种方式预先计算值是时空权衡的一个例子:通过投资空间(保存值)来节省时间(因为我们不需要重新计算它们)。如果我们需要大量的 n 值,这种方法并不有效,但如果我们需要大量小 n 值的值,这种方法非常有效。
简化重复的代码。
作为数组的另一个简单应用的示例,考虑以下代码片段,根据月份的数字(1 代表一月,2 代表二月,依此类推)打印月份的名称。
if (m == 1) System.out.println("Jan");
else if (m == 2) System.out.println("Feb");
else if (m == 3) System.out.println("Mar");
else if (m == 4) System.out.println("Apr");
else if (m == 5) System.out.println("May");
else if (m == 6) System.out.println("Jun");
else if (m == 7) System.out.println("Jul");
else if (m == 8) System.out.println("Aug");
else if (m == 9) System.out.println("Sep");
else if (m == 10) System.out.println("Oct");
else if (m == 11) System.out.println("Nov");
else if (m == 12) System.out.println("Dec");
我们也可以使用 switch 语句,但一个更紧凑的替代方案是使用一个包含每个月份名称的字符串数组:
String[] MONTHS = {
"", "Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"
};
...
System.out.println(MONTHS[m]);
如果你需要在程序的多个不同位置通过其数字访问月份名称,这种技术将特别有用。请注意,我们故意浪费数组中的一个槽(元素 0)以使 MONTHS[1] 对应于一月,如所需。
优惠券收集器。
假设你有一副洗过的扑克牌,并且你一个接一个地把它们翻面。在你看到每种花色之前,你需要翻多少张牌?这是著名的收集优惠券问题的一个例子。一般来说,假设一个交易卡公司发行具有 n 种不同可能卡片的交易卡:在你收集到所有 n 种可能性之前,你需要收集多少张卡片,假设每张卡片的每种可能性对于你收集的每张卡片都是等可能的?CouponCollector.java 接受一个整数命令行参数 n 并模拟这个过程。详情请参阅教科书。
埃拉托斯特尼筛法。
素数计数函数 π(n) 是小于或等于 n 的素数数量。例如,π(17) = 7,因为前七个素数是 2, 3, 5, 7, 11, 13 和 17。PrimeSieve.java 接受一个整数命令行参数 n 并使用埃拉托斯特尼筛法计算 π(n)。详情请参阅教科书。
二维数组。
在许多应用中,组织信息的一种自然方式是使用一个按照矩形组织的数字表,并在表中引用行和列。对应于这种表的数学抽象是矩阵;相应的 Java 构造是二维数组。
Java 中的二维数组。要引用二维数组
a[][]中第i行和第j列的元素,我们使用记法a[i][j];要声明一个二维数组,我们在类型名称后添加另一对方括号;要创建数组,我们在类型名称后指定行数,然后是列数(都在方括号内),如下所示:double[][] a = new double[m][n];我们将这样的数组称为m 行 n 列数组。按照惯例,第一个维度是行数,第二个维度是列数。
默认初始化。 与一维数组一样,Java 将数字数组中的所有条目初始化为 0,布尔数组中的所有条目初始化为
false。二维数组的默认初始化很有用,因为它比一维数组需要更多的代码。要访问二维数组中的每个元素,我们需要嵌套循环:double[][] a; a = new double[m][n]; for (int i = 0; i < m; i++) for (int j = 0; j < n; j++) a[i][j] = 0;内存表示。 Java 将二维数组表示为一个数组的数组。一个具有
m行和n列的矩阵实际上是一个长度为m的数组,其中每个条目都是长度为n的数组。在 Java 的二维数组中,我们可以使用代码a[i]来引用第 i 行(这是一个一维数组)。支持不规则数组。在编译时设置值。 以下代码初始化了 11 行 4 列的数组
a[][]:double[][] a = { { 99.0, 85.0, 98.0, 0.0 }, { 98.0, 57.0, 79.0, 0.0 }, { 92.0, 77.0, 74.0, 0.0 }, { 94.0, 62.0, 81.0, 0.0 }, { 99.0, 94.0, 92.0, 0.0 }, { 80.0, 76.5, 67.0, 0.0 }, { 76.0, 58.5, 90.5, 0.0 }, { 92.0, 66.0, 91.0, 0.0 }, { 97.0, 70.5, 66.5, 0.0 }, { 89.0, 89.5, 81.0, 0.0 }, { 0.0, 0.0, 0.0, 0.0 } };不规则数组。 二维数组中的所有行不需要具有相同的长度要求,一个具有非统一长度行的数组称为不规则数组。不规则数组的可能性需要更多的注意来编写数组处理代码。例如,这段代码打印了一个不规则数组的内容:
for (int i = 0; i < a.length; i++) { for (int j = 0; j < a[i].length; j++) { System.out.print(a[i][j] + " "); } System.out.println(); }多维数组。 相同的���示法适用于具有任意维数的数组。例如,我们可以使用以下代码声明和初始化一个三维数组
double[][][] a = new double[n][n][n];然后通过类似
a[i][j][k]的代码引用一个条目。

矩阵操作。
在科学和工程中的典型应用中,涉及使用矩阵操作数执行各种数学操作。例如,我们可以如下相加两个n乘n的矩阵:
double[][] c = new double[n][n];
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
c[i][j] = a[i][j] + b[i][j];
}
}
类似地,我们可以相乘两个矩阵。矩阵a[]和b[]的乘积中的每个条目c[i][j]是通过计算a[]的第 i 行与b[]的第 j 列的点积来计算的。
double[][] c = new double[n][n];
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
for (int k = 0; k < n; k++) {
c[i][j] += a[i][k]*b[k][j];
}
}
}
避免自我交叉的行走。
SelfAvoidingWalk.java 是将二维数组应用于化学的一个应用。详情请参阅教科书。
练习
描述并解释当您尝试编译一个程序 HugeArray.java 时会发生什么,其中包含以下语句:
int n = 1000; int[] a = new int[n*n*n*n];编写一个代码片段,将一维字符串数组中的值顺序颠倒。不要创建另一个数组来保存结果。提示:使用文本中用于交换两个元素的代码。
解决方案。
int n = a.length; for (int i = 0; i < n/2; i++) { String temp = a[n-i-1]; a[n-i-1] = a[i]; a[i] = temp; }以下代码片段有什么问题?
int[] a; for (int i = 0; i < 10; i++) a[i] = i * i;解决方案: 它没有使用
new为a[]分配内存。该代码导致variable might not have been initialized编译时错误。以下代码片段打印什么?
int[] a = { 1, 2, 3 }; int[] b = { 1, 2, 3 }; System.out.println(a == b);解决方案: 它打印 false。
==运算符比较(内存地址的)两个数组是否相同,而不是它们对应的值是否相等。编写一个程序 Deal.java,它接受一个整数命令行参数
n,并从洗牌后的牌组中打印n个扑克牌手(每手五张牌),用空行分隔。编写一个程序 HowMany.java,它接受可变数量的命令行参数并打印它们的数量。
编写一个程序 DiscreteDistribution.java,它接受可变数量的整数命令行参数,并按照第
i个命令行参数的比例打印整数i。编写一个代码片段 Transpose.java,在不创建第二个数组的情况下对一个方形的二维数组进行转置。
创意练习
糟糕的洗牌。 假设在我们的洗牌代码中,你选择一个在 0 到 n-1 之间的随机整数,而不是在 i 到 n-1 之间选择一个。证明结果的顺序不可能是 n!种可能性之一。对这个版本运行上一个练习的测试。
部分解决方案: 当 n = 3 时,所有 3! = 6 种结果都是可能的,但有些更有可能:
ABC ACB BAC BCA CAB CBA 4/27 5/27 6/27 4/27 5/27 3/27 当PlanetPoker使用一个只能生成可能的 52!中约 200,000 个洗牌的破损洗牌算法时发生了什么。
逆排列。 编写一个程序 InversePermutation.java,从
n个命令行参数中读取 0 到n-1的整数的一个排列,并打印逆排列。(如果排列在一个数组a[]中,其逆排列是数组b[],使得a[b[i]] = b[a[i]] = i。)确保检查输入是否是有效的排列。哈达玛矩阵。 n 阶哈达玛矩阵 H(n)是一个布尔矩阵,具有这样一个显著的特性,即任意两行在恰好 n/2 位上不同。(这个特性使其在设计纠错码时非常有用。)H(1)是一个 1 阶矩阵,其中唯一的元素为 true,对于 n > 1,H(2n)通过将四个 H(n)的副本对齐在一个大正方形中获得,然后反转右下角的 n 阶副本中的所有元素,如下例所示(T 代表 true,F 代表 false,如常)。
H(1) H(2) H(4) ------------------- T T T T T T T T 0 T 0 T 0 T T 0 0 T 0 0 T编写一个程序 Hadamard.java,接受一个命令行参数 n 并打印 H(n)。假设 n 是 2 的幂。
随机漫步者。 假设有 n 个随机漫步者,从一个 n 乘 n 的网格的中心开始,每次移动一步,选择向左、向右、向上或向下的概率相等。编写一个程序 RandomWalkers.java 来帮助制定和测试有关在所有单元格被触及之前所需步数的假设。
生日问题。 假设人们进入一个空房间,直到有一对人共享生日。平均来说,有多少人会进入才会有匹配?编写一个程序 Birthday.java 来模拟一个实验。编写一个程序 Birthdays.java 来重复实验多次并估计平均值。假设生日是在 0 到 364 之间均匀随机的整数。
二项式系数。 编写一个程序 BinomialDistribution.java,构建并打印一个二维不规则数组 a,使得
a[n][k]包含抛掷 n 次硬币时获得恰好 k 个正面的概率。接受一个命令行参数以指定 n 的最大值。这些数字被称为二项分布:如果将第 i 行中的每个条目乘以 2^n,就得到二项式系数—(x+1)n 中 xk 的系数—排列在帕斯卡三角形中。要计算它们,从a[n][0] = 0.0开始对所有 n,a[1][1] = 1.0,然后按行从左到右计算值,a[n][k] = (a[n-1][k] + a[n-1][k-1]) / 2。Pascal's triangle Binomial distribution -------------------------------------------- 1 1 1 1 1/2 1/2 1 2 1 1/4 1/2 1/4 1 3 3 1 1/8 3/8 3/8 1/8 1 4 6 4 1 1/16 1/4 3/8 1/4 1/16
网页练习
生日问题。 修改 Birthday.java 以计算两个人生日相差不超过一天的概率。
高于平均水平。 90%的大学新生认为自己高于平均水平。编写一个程序
AboveAverage.java,它接受一个命令行参数 n,从标准输入读取 n 个整数,并打印严格高于平均值的值的比例。随机排列。 编写一个程序 Permutation.java,使其接受一个命令行参数 N,并打印整数 0 到 N-1 的一个随机排列。同时打印排列的棋盘可视化。例如,排列{ 4, 1, 3, 0, 2 }对应于:
4 1 3 0 2 * * * Q * * Q * * * * * * * Q * * Q * * Q * * * *8 皇后检查器。 整数 0 到 n-1 的排列对应于在 n×n 的棋盘上放置皇后,以便没有两个皇后在同一行或列中。编写一个程序
QueensChecker.java,确定排列是否对应于放置皇后的位置,以便没有两个皇后在同一行、列或对角线上。例如,排列{ 4, 1, 3, 0, 2 }是一个合法的放置:* * * Q * * Q * * * * * * * Q * * Q * * Q * * * *尝试在除了长度为 n 的输入排列
q之外不使用任何额外数组。提示:确定设置 q[i]是否与 q[j]冲突,其中 i < j。如果 q[i]等于 q[j]:两个皇后放在同一行上
如果 q[i] - q[j]等于 j - i:两个皇后在同一主对角线上
如果 q[j] - q[i]等于 j - i:两个皇后在同一副对角线上
找到你的啤酒。 大量的大学生正在参加一个派对。每位客人都在喝一罐啤酒(如果他们未满 21 岁,则为苏打)。一次紧急情况导致灯灭和火警响起。客人们平静地放下他们的啤酒并离开建筑物。当警报响起时,他们重新进入并尝试找回他们的啤酒。然而,灯还是关着,所以每个学生随机拿起一瓶啤酒。至少有一个学生拿到自己原来的啤酒的机会有多大?编写一个程序
MyBeer.java,接受一个命令行参数 n,并运行 1000 次模拟这个事件,假设有 n 位客人。打印至少有一位客人拿到自己原来啤酒的次数比例。当 n 变大时,这个比例是接近 0 还是 1 还是介于两者之间?线性反馈移位寄存器。 通过使用数组重写线性反馈移位寄存器第一章,使其更加简洁和可扩展,例如,如果移位寄存器中的单元数增加。程序 LFSR.java 使用一个
boolean提示:使用^运算符对两个布尔值进行异或操作。储物柜。 你在一个有 100 个开放储物柜的更衣室里,编号从 1 到 100。切换所有偶数的储物柜。通过切换,我们的意思是如果是开着的就关闭,如果是关闭的就打开。现在切换所有 3 的倍数的储物柜。重复 4、5、直到 100 的倍数。有多少个储物柜是开着的?答案:储物柜 1、4、9、16、25、...、100 将是开着的。一旦看到模式,你可能不需要数组。
带截止日期的调度。 假设您有 N 个任务要安排。每个任务需要 1 个单位的时间,并且有一个截止日期,到达截止日期时应该完成任务。如果任务未在截止日期前完成,您将支付 1000 美元的罚款。找到一个最小化罚款的调度方案。提示:按照截止日期的顺序安排任务,但不要为无法在截止日期前完成的任务费心。
日历。 重复练习 1.33,为给定的月份和年份制作一个日历。使用数组存储一周中的天的名称,月份的名称以及一个月中的天数。
四子连珠。 给定一个 N×N 的网格,每个单元格都被'X'、'O'或空占据,编写一个程序来找到水平、垂直或对角线上连续'X'的最长序列。为了测试您的程序,您可以创建一个随机网格,其中每个单元格包含'X'或'O'的概率为 1/3。
泰拳。 编写一个程序 KickBoxer.java,接受一个整数体重 w 作为命令行输入,并根据下表打印相应的泰拳体重级别。
weight class from to ------------------------------------ Fly Weight 0 112 Super Fly Weight 112 115 Bantam Weight", 115 118 Super Bantam Weight 118 122 Feather Weight 122 126 Super Feather Weight 126 130 Light Weight 130 135 Super Light Weight 135 140 Welter Weight 140 147 Super Welter Weight 147 154 Middle Weight 154 160 Super Middle Weight 160 167 Light Heavy Weight 167 174 Super Light Heavy Weight 174 183 Cruiser Weight 183 189 Super Cruiser Weight 189 198 Heavy Weight 198 209 Super Heavy Weight 209使用一个整数数组来存储重量限制,一个字符串数组来存储重量类别(从 Flyweight 到 Super Heavyweight)。
N 进制计数器。 编写一个程序,从 0 计数到 N²⁰ - 1 的 N 进制数。使用一个包含 20 个元素的数组。
地形分析。 给定一个 N×N 的海拔值网格(以米为单位),峰值是一个所有四个相邻单元格都严格较低的网格点。编写一个代码片段,计算给定 N×N 网格中的峰值数量。
幻方。 编写��个名为 MagicSquare.java 的程序,从命令行读取一个奇数整数 N,并打印出一个 N×N 的幻方。该方格包含 1 到 N² 之间的每个整数,使得所有行总和、列总和和对角线总和相等。
4 9 2 11 18 25 2 9 3 5 7 10 12 19 21 3 8 1 6 4 6 13 20 22 23 5 7 14 16 17 24 1 8 15一个简单的算法是按升序分配整数 1 到 N²,从底部中间单元格开始。重复将下一个整数分配给右下角对角线相邻的单元格。如果此单元格已分配了另一个整数,则改为使用上方相邻的单元格。使用环绕处理边界情况。
横幅。 编写一个名为
Banner.java的程序,接受一个字符串作为命令行参数,并按照以下方式打印出大字母的字符串。% java Banner "Kevin" # # ###### # # # # # # # # # # # ## # #### ##### # # # # # # # # # # # # # # # # # # # # # # ## # # ###### ## # # #模仿 Unix 实用程序
banner。选举和社会选择理论。 多数制(美国总统选举)、决胜选举、顺序决胜选举(澳大利亚、爱尔兰、普林斯顿大学教师委员会)、康多塞。肯尼米排名聚合。阿罗不可能定理。体育、谷歌、元搜索、机器学习等领域的相同思想。
波达计数。 1781 年,波达提出了一种用于确定具有 K 选民和 N 候选人的政治选举结果的位置方法。每个选民按照偏好的递增顺序(从 1 到 N)对候选人进行排名。波达的方法为每个候选人分配一个分数,等于他们的排名之和。得分最高的候选人获胜。这在美国职业棒球大联盟中用于确定最有价值球员。
肯德尔的 tau 距离。 给定两个排列,肯德尔的 tau 距离是位置不同的对数。"冒泡排序度量"。在前 k 个列表中很有用。在选举理论中,最优的肯尼米排名聚合可以最小化肯德尔的 tau 距离。也可用于使用多个表达谱来排名基因、排名搜索引擎结果等。
斯皮尔曼的脚距离。 给定两个排列,斯皮尔曼的脚距离是排列作为向量之间的 L1 距离。在前 k 个列表中很有用。
int footrule = 0; for (int i = 0; i < N; i++) footrule = footrule + Math.abs(p[i] - q[i]);美国邮政条形码。 POSTNET条形码被美国邮政系统用于邮件路由。邮政编码中的每个十进制数字都使用一系列 5 个短线和长线进行编码,以供扫描仪使用,如下所示:
VALUE ENCODING 0 ||╷╷╷1 ╷╷╷||2 ╷╷|╷|3 ╷╷||╷4 ╷|╷╷|5 ╷|╷|╷6 ╷||╷╷7 |╷╷╷|8 |╷╷|╷9 |╷|╷╷添加第六个校验和数字:通过对原始五位数字进行模 10 求和来计算。此外,将一条长线添加到开头并附加到末尾。编写一个名为 ZipBarCoder.java 的程序,从命令行参数读取一个五位数邮政编码,并打印相应的邮政条形码。垂直打印代码,而不是水平打印,例如,以下编码 08540(校验位为 7)。
***** ***** ***** ** ** ** ***** ** ** ***** ** ** ***** ** ***** ** ** ***** ** ** ***** ***** ***** ** ** ** ***** ** ** ** ***** *****美国邮政条形码。 重复上一个练习,但使用海龟图形绘制输出。
没有质数的间隙。 找到最长的连续整数序列,其中没有质数。编写一个名为 PrimeGap.java 的程序,接受一个命令行参数 N,并打印出 2 到 N 之间没有质数的最大整数块。
哥德巴赫猜想。 1742 年,克里斯蒂安·哥德巴赫猜想,每个大于 2 的偶数都可以写成两个质数的和。例如,16 = 3 + 13。编写一个程序 Goldbach.java,接受一个命令行参数 N,并将 N 表示为两个质数的和。哥德巴赫猜想仍未解决,但已知对于所有 N < 10¹⁴都成立。
排列中的最小值。 编写一个程序,从命令行接受一个整数 n,生成一个随机排列,打印排列,然后打印排列中从左到右的最小值的次数(元素是迄今为止看到的最小值的次数)。然后编写一个程序,从命令行接受整数 m 和 n,生成长度为 n 的 m 个随机排列,并打印生成的排列中从左到右的最小值的平均次数。额外学分:提出一个关于长度为 n 的排列中从左到右的最小值次数的假设函数。
原地逆置排列。 重新做练习 1.4.25,但在原地计算排列,即不为逆置排列分配第二个数组。注意:这很困难。
最有可能的点数。 爱丽丝和鲍勃就是否重复掷骰子直到总和超过 12 而争论不休,13 是否是最有可能的总和?编写一个程序 MostLikelyRoll.java 来模拟这个过程一百万次,并生成一个表格,显示总和为 13、14、15、16、17 和 18 的次数比例。
螺旋 2D 数组。 给定一个 2D 数组,编写一个程序 Spiral.java 以螺旋顺序打印出来。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 1 2 3 4 8 12 16 15 14 13 9 5 6 7 11 10数独验证器。 给定一个 9x9 的整数数组,检查它是否是数独谜题的有效解:每行、每列和每个块应该包含 9 个整数,且仅出现一次。
5 3 4 | 6 7 8 | 9 1 2 6 7 2 | 1 9 5 | 3 4 8 1 9 8 | 3 4 2 | 5 6 7 -------+-------+------ 8 5 9 | 7 6 1 | 4 2 3 4 2 6 | 8 5 3 | 7 9 1 7 1 3 | 9 2 4 | 8 5 6 -------+-------+------ 9 6 1 | 5 3 7 | 2 8 4 2 8 7 | 4 1 9 | 6 3 5 3 4 5 | 2 8 6 | 1 7 9幂和猜想。 重新做练习 1.3.x,但预先计算所有相关整数的 5 次幂。评估这样做节省了多少时间。程序 Euler.java 搜索整数解 a⁵ + b⁵ + c⁵ + d⁵= e⁵。
哈尔小波变换。 给定长度为 2n 的数组
a[],其1D 哈尔变换如下获得:计算 a[2i]和 a[2i+1]的平均值和差值,计算相同长度的数组,包含平均值,然后是差值。然后对平均值(前 2n-1 个条目)应用相同的技术,依此类推。下面展示了一个有 2³ 个条目的示例。448 768 704 640 1280 1408 1600 1600 (original) 608 672 1344 1600 -160 32 -64 0 (step 1) 640 1472 -32 -128 -160 32 -64 0 (step 2) 1056 -416 -32 -128 -160 32 -64 0 (step 3)2n 乘以 2n 矩阵的2D 哈尔小波变换,通过对每一行应用哈尔小波变换,然后对每一列应用哈尔小波变换获得。哈尔小波变换在信号处理、医学成像和数据压缩中很有用。
当您尝试编译具有以下语句的程序时会发生什么?
int[] a = new int[-17];它可以干净地编译,但在执行时抛出
java.lang.NegativeArraySizeException。二十一点。 编写一个程序 Blackjack.java,接受三个命令行整数 x、y 和 z,表示您的两张二十一点牌 x 和 y,以及庄家的明牌 z,并根据大西洋城 6 副牌的“标准策略”打印出来。假设 x、y 和 z 是 1 到 10 之间的整数,表示从 A 到面牌。根据这些策略表报告玩家应该要牌、停牌还是分牌。使用三个 2D 布尔数组编码策略表。
修改
Blackjack.java以允许加倍。**玻尔兹曼分布。**这里有一个简单的模型来近似统计物理中的玻尔兹曼分布:生成 1 到 10 之间的 100 个随机整数。如果总和恰好为 200,则保留此试验。重复此过程,直到满足条件的试验达到 1,000 次。现在绘制每个 10 个整数出现次数的直方图。
**双随机。**编写一个程序来读取一个 N×N 的实数矩阵,并在矩阵是双随机时打印
true,否则打印false。如果所有行和列的和都为 1,则矩阵是随机的。由于涉及浮点数,允许和在 1-ε和 1+ε之间,其中ε= 0.000000001。假设
b[]是一个包含 100 个元素的数组,所有条目都初始化为 0,并且a[]是一个包含 N 个元素的数组,每个元素都是介于 0 和 99 之间的整数。以下循环的效果是什么?|
for (j = 0; j < N; j++) b[a[j]]++;|
修改 RandomStudent.java 以便它存储一个名为
isFemale的布尔类型的并行数组,其中元素 i 如果学生 i 是女性则为true,否则为false。现在,随机打印一个男学生和一个女学生。提示:使用do-while循环生成随机整数,直到得到一个索引男学生的整数。以下哪些需要使用数组。对于每个输入来自标准输入,包含 N 个介于 0.0 和 1.0 之间的实数。
打印最大的元素。
打印最大和最小的元素。
打印中位数元素。
打印出现频率最高的元素。
打印元素的平方和。
打印 N 个元素的平均值。
打印最接近 0 的元素。
打印所有大于平均值的数字。
以递增顺序打印 N 个元素。
以随机顺序打印 N 个元素。
打印直方图(例如,大小为 0.1 的 10 个箱)。
编写一个程序 Yahtzee.java 来模拟掷五个骰子并打印“Yahtzee”,如果所有五个骰子都相同;否则应打印“再试一次”。
修改 DayOfWeek.java 以便它读取一个日期并打印该日期是星期几。您的程序应该接受三个命令行参数,M(月份),D(日期)和 Y(年份)。不要使用任何
if-else语句;而是使用一个包含一周 7 天名称的字符串数组。编写一个程序 Pascal.java 来使用一个不规则数组计算帕斯卡三角形。
**将矩阵行和列清零。**给定一个m×n整数矩阵
a[][],如果a[i][j]为 0,则将第 i 行和第 j 列设置为 0。不要使用任何额外的数组。解决方案。首先,检查第 0 行是否有 0,以及第 0 列是否有 0;将此信息记录在两个布尔变量中。接下来,对于每个为 0 的元素
a[i][j],将元素a[i][0]和a[0][j]设置为 0。最后,如果a[i][0]或a[0][j]中有一个为 0,则将a[i][j]设置为 0。
1.5 输入和输出
原文:
introcs.cs.princeton.edu/java/15inout译者:飞龙
在本节中,我们将扩展我们一直在使用的简单抽象集合(命令行输入和标准输出)以包括标准输入、标准绘图和标准音频,以便我们编写处理任意数量输入的程序并与我们的程序交互;标准绘图使我们能够处理图形;标准音频添加了声音。 
俯视图。
一个 Java 程序从命令行接受输入值,并将一串字符作为输出打印出来。默认情况下,命令行参数和标准输出都与一个接受命令的应用程序相关联,我们称之为终端窗口。
命令行参数。 我们所有的类都有一个以
String数组args[]为参数的main()方法。该数组是我们键入的命令行参数序列。如果我们打算将参数作为数字处理,我们必须使用Integer.parseInt()等方法将其从String转换为适当的类型。标准输出。 为了在我们的程序中打印输出值,我们一直在使用
System.out.println()。Java 将结果发送到一个称为标准输出的字符抽象流。默认情况下,操作系统将标准输出连接到终端窗口。到目前为止,我们程序中的所有输出都出现在终端窗口中。
RandomSeq.java 使用这个模型:它接受一个命令行参数n,并将n个介于 0 和 1 之间的随机数序列打印到标准输出。
为了完成我们的编程模型,我们添加以下库:
标准输入。 从用户那里读取数字和字符串。
标准绘图。 绘制图形。
标准音频。 创建声音。
标准输出。
Java 的System.out.print()和System.out.println()方法实现了我们需要的基本标准输出抽象。尽管如此,为了以统一的方式处理标准输入和标准输出(并提供一些技术改进),我们使用在我们的 StdOut 库中定义的类似方法:
Java 的print()和println()方法是您一直在使用的方法。printf()方法使我们能够更好地控制输出的外观。
格式化打印基础知识。 在其最简单的形式中,
printf()接受两个参数。第一个参数称为格式字符串。它包含一个转换规范,描述了如何将第二个参数转换为输出的字符串。![printf()调用的解剖]()
格式字符串以
%开头,并以一个字母的转换代码结尾。以下表总结了最常用的代码:![printf()的格式化示例]()
格式字符串。 格式字符串可以包含除转换规范之外的字符。转换规范将被参数值替换(按指定的方式转换为字符串),所有剩余字符都将传递到输出。
多个参数。
printf()函数可以接受两个以上的参数。在这种情况下,格式字符串将为每个额外的参数有一个额外的转换规范。
这里有更多关于printf 格式字符串语法的文档。
标准输入。
我们的 StdIn 库从包含一系列由空格分隔的值的标准输入流中获取数据。每个值都是一个字符串或来自 Java 的原始类型之一。标准输入流的一个关键特点是,当程序读取值时,它会消耗这些值。一旦程序读取了一个值,就不能回退并再次读取它。该库由以下 API 定义:
现在我们详细考虑几个示例。
输入数据。 当您使用
java命令从命令行调用 Java 程序时,实际上正在执行三件事:(1) 发出命令以开始执行程序,(2) 指定命令行参数的值,以及(3) 开始定义标准输入流。您在命令行后的终端窗口中键入的字符序列是标准输入流。例如,AddInts.java 接受一个命令行参数n,然后从标准输入读取n个数字并将它们相加,然后将结果打印到标准输出:![命令的解剖]()
输入格式。 如果您在
StdIn.readInt()期望一个int时键入abc或12.2或true,那么它将响应InputMismatchException。StdIn将连续空格字符的字符串视为一个空格,并允许您用这样的字符串来分隔您的数字。交互式用户输入。 TwentyQuestions.java 是一个与用户交互的简单程序示例。该程序生成一个随机整数,然后给出线索,让用户尝试猜测数字。这个程序与我们编写的其他程序之间的根本区别在于,用户有能力在程序执行时改变控制流。
处理任意大小的输入流。 通常,输入流是有限的:您的程序遍历输入流,消耗值,直到流为空。但是输入流的大小没有限制。Average.java 从标准输入读取一系列实数并���印它们的平均值。
重定向和管道。
对于许多应用程序来说,从终端窗口键入输入数据作为标准输入流是不可行的,因为这样做会限制我们程序的处理能力,限制了我们可以键入的数据量。同样,我们经常希望保存在标准输出流上打印的信息以供以后使用。我们可以使用操作系统机制来解决这两个问题。
将标准输出重定向到文件。 通过向调用程序的命令添加一个简单的指令,我们可以重定向其标准输出到文件,无论是用于永久存储还是在以后的某个时间输入到其他程序中。例如,命令
![重定向标准输出]()
指定标准输出流不要打印在终端窗口中,而是写入一个名为
data.txt的文本文件。每次调用StdOut.print()或StdOut.println()都会在该文件的末尾追加文本。在这个例子中,最终结果是一个包含 1,000 个随机值的文件。从文件重定向标准输入。 同样,我们可以重定向标准输入,使
StdIn从文件而不是终端窗口读取数据。例如,命令![重定向标准输入]()
从文件
data.txt中读取一系列数字并计算它们的平均值。具体来说,<符号是一个指令,通过从文件data.txt中读取值来实现标准输入流,而不是等待用户在终端窗口中输入。当程序调用StdIn.readDouble()时,操作系统从文件中读取值。将标准输入从文件重定向到文件的能力使我们能够处理来自任何来源的大量数据,仅受我们可以存储的文件大小的限制。连接两个程序. 实现标准输入和标准输出抽象的最灵活方式是指定它们由我们自己的程序实现!这种机制称为管道。例如,以下命令
![管道]()
指定
RandomSeq的标准输出和Average的标准输入流是相同的流。过滤器. 对于许多常见任务,方便将每个程序视为将标准输入流以某种方式转换为标准输出流的过滤器,RangeFilter.java 接受两个命令行参数,并在标准输出上打印出标准输入中落在指定范围内的数字。
您的操作系统还提供了许多过滤器。例如,
sort过滤器将标准输入中的行按排序顺序放置:% java RandomSeq 5 | sort 0.035813305516568916 0.14306638757584322 0.348292877655532103 0.5761644592016527 0.9795908813988247另一个有用的过滤器是
more,它从标准输入读取数据,并在终端窗口中一次显示一个屏幕。例如,如果您键入% java RandomSeq 1000 | more您将看到尽可能多的数字适合您的终端���口,但更多的数字将等待您按空格键,然后显示每个后续屏幕。
标准绘图。
现在我们引入一个简单的抽象来生成绘图作为输出。我们想象一个能够在二维画布上绘制线条和点的抽象绘图设备。该设备能够响应我们的程序以静态方法调用的形式发出的命令,这些命令在 StdDraw 中。主要接口由两种方法组成:绘图命令,导致设备执行动作(如绘制线条或绘制点),以及控制命令,设置参数,如笔的大小或坐标比例。
基本绘图命令. 我们首先考虑绘图命令:
![标准绘图 API:绘图命令]()
这些方法几乎是自解释的:
StdDraw.line(x0, y0, x1, y1)绘制一条连接点(x[0], y[0])和点(x[1], y[1])的直线段。StdDraw.point(x, y)在点(x, y)上绘制一个点。默认坐标比例是单位正方形(所有x和y坐标在 0 和 1 之间)。标准实现在计算机屏幕上的窗口中显示画布,黑色线条和点在白色背景上。您的第一个绘图. 使用
StdDraw进行图形编程的HelloWorld是在三角形内绘制一个点。Triangle.java 通过三次调用StdDraw.line()和一次调用StdDraw.point()来实现这一点。控制命令. 默认画布大小为 512x512 像素,默认坐标系为单位正方形,但我们经常希望以不同比例绘制图表。此外,我们经常希望绘制不同粗细的线段或不同大小的点。为了满足这些需求,
StdDraw具有以下方法:![标准绘图 API:控制命令]()
例如,两次调用序列
StdDraw.setXscale(x0, x1); StdDraw.setYscale(y0, y1);将绘图坐标设置为一个边界框,其左下角在(x[0], y[0])处,右上角在(x[1], y[1])处。
将数据绘制到标准绘图。 PlotFilter.java 从标准输入读取由(x, y)坐标定义的点序列,并在每个点处绘制一个点。它采用的约定是标准输入的前四个数字指定了边界框,以便它可以缩放绘图。
% **java PlotFilter <**USA.txt![美国的 13509 个城市]()
绘制函数图。 FunctionGraph.java 在区间(0, π)中绘制函数y = sin(4x) + sin(20x)。在该区间内有无限多个点,因此我们必须通过在区间内的有限数量的点上评估函数来进行。我们通过选择一组x值对函数进行采样,然后通过在每个x值处评估函数来计算y值。通过连接相邻点绘制函数产生了所谓的分段线性逼近。
![绘制函数图]()
轮廓和填充形状。
StdDraw还包括绘制圆、矩形和任意多边形的方法。每个形状定义了一个轮��。当方法名只是形状名时,轮廓由绘图笔描绘。当方法名以filled开头时,命名形状将被实心填充,而不是描边。![标准绘图 API:形状]()
circle()的参数定义了半径为 r 的圆;square()的参数定义了以给定点为中心的边长为 2r 的正方形;polygon()的参数定义了我们通过线连接的一系列点,包括从最后一个点到第一个点的线。![标准绘图形状]()
文本和颜色。 为了注释或突出显示绘图中的各种元素,
StdDraw包括用于绘制文本、设置字体和设置笔墨颜色的方法。![标准绘图文本和颜色命令]()
在这段代码中,java.awt.Font 和 java.awt.Color 是使用非原始类型实现的抽象,你将在第 3.1 节中了解到。在那之前,我们将细节留给
StdDraw。默认的墨水颜色是黑色;默认字体是 16 点普通衬线字体。双缓冲。
StdDraw支持一个强大的计算机图形功能,称为双缓冲。通过调用enableDoubleBuffering()启用双缓冲时,所有绘图都在离屏画布上进行。离屏画布不会显示;它只存在于计算机内存中。只有当你调用show()时,你的绘图才会从离屏画布复制到屏幕画布,然后在标准绘图窗口中显示。你可以将双缓冲看作是收集所有你要绘制的线条、点、形状和文本,然后在请求时同时绘制它们的过程。使用双缓冲的一个原因是在执行大量绘图命令时提高效率。计算机动画。 我们对双缓冲最重要的用途是制作计算机动画,通过快速显示静态绘图来产生运动的幻觉。我们可以通过重复以下四个步骤来制作动画:
清除离屏画布。
在离屏上绘制对象
将离屏画布复制到屏幕画布。
等待片刻。
为了支持这些步骤,
StdDraw有几种方法:![标准绘图动画命令]()
动画的“Hello, World”程序是产生一个黑色小球,看起来在画布上移动,并根据弹性碰撞定律反弹。假设小球在位置(x, y),我们想要给人一种它移动到新位置,比如(x + 0.01, y + 0.02)的印象。我们通过四个步骤实现:
将屏幕外画布清空为白色。
在屏幕外画布上的新位置画一个黑色小球。
将屏幕外画布复制到屏幕上画布。
等待片刻。
为了营造运动的错觉,BouncingBall.java 对小球的整个位置序列执行这些步骤。
![弹跳球]()
图像。 我们的标准绘图库支持绘制图片以及几何形状。命令
StdDraw.picture(x, y, filename)在画布上以(x, y)为中心绘制给定文件名(JPEG、GIF 或 PNG 格式)的图像。BouncingBallDeluxe.java 演示了一个例子,其中弹跳球被一个网球的图像替换。用户交互。 我们的标准绘图库还包括方法,以便用户可以使用鼠标与窗口进行交互。
double mouseX() return x-coordinate of mouse double mouseY() return y-coordinate of mouse boolean mousePressed() is the mouse currently being pressed?第一个例子。 MouseFollower.java 是鼠标交互的
HelloWorld。它绘制一个蓝色小球,位于鼠标位置的中心。当用户按住鼠标按钮时,小球从蓝色变为青色。一个简单的吸引子。 OneSimpleAttractor.java 模拟了一个向鼠标吸引的蓝色小球的运动。它还考虑了阻力。
许多简单的吸引子。 SimpleAttractors.java 模拟了 20 个蓝色小球向鼠标吸引的运动。它还考虑了阻力。当用户点击时,小球会随机分散。
弹簧。 Springs.java 实现了一个弹簧系统。
标准音频。
StdAudio 是一个可以用来播放和操作声音文件的库。它允许您播放、操作和合成声音。
我们介绍了计算机科学和科学计算中最古老和最重要领域之一的基本概念之一:数字信号处理。
音乐会 A。 音乐会 A 是一个正弦波,被缩放为每秒振荡 440 次。函数 sin(t)在x轴上每 2π单位重复一次,因此如果我们以秒为单位测量t并绘制函数 sin(2πt × 440),我们得到一个每秒振荡 440 次的曲线。振幅(y值)对应于音量。我们假设它被缩放到-1 和+1 之间。
其他注意事项。 一个简单的数学公式描述了色谱音阶上的其他音符。它们在以对数(以 2 为底)刻度均匀分布:色谱音阶上有 12 个音符,我们通过将其频率乘以 2 的(i/12)次幂来得到给定音符上方的第i个音符。
![音符、数字和波形]()
当您将频率加倍或减半时,您在音阶上上升或下降一个八度。例如,880 赫兹比音乐会 A 高一个八度,110 赫兹比音乐会 A 低两个八度。
采样。 对于数字音频,我们通过以固定间隔对其进行采样来表示曲线,这与我们绘制函数图形时的方式���全相同。我们采样得足够频繁,以便准确表示曲线 - 一个广泛使用的采样率是每秒 44,100 个样本。就是这么简单:我们将声音表示为一组数字(实数,介于-1 和+1 之间)。
![以不同速率采样正弦波]()
![以 44,100 赫兹采样正弦波]()
例如,以下代码片段演奏 10 秒钟的 A 音调。
int SAMPLING_RATE = 44100; double hz = 440.0; double duration = 10.0; int n = (int) (SAMPLING_RATE * duration); double[] a = new double[n+1]; for (int i = 0; i <= n; i++) { a[i] = Math.sin(2 * Math.PI * i * hz / SAMPLING_RATE); } StdAudio.play(a);*演奏那首曲子。*PlayThatTune.java 是一个示例,展示了我们如何使用
StdAudio轻松创建音乐。它从标准输入接受音符,按照升降调音阶进行索引,并在标准音频上播放它们。
练习
编写一个名为 MaxMin.java 的程序,从标准输入读取整数(用户输入的数量),并打印出最大值和最小值。
编写一个名为 Stats.java 的程序,接受一个整数命令行参数n,从标准输入读取n个浮点数,并打印它们的均值(平均值)和样本标准差(与平均值的差的平方和的平方根,除以n−1)。
编写一个名为 LongestRun.java 的程序,读取一系列整数,并打印出出现在最长连续序列中的整数以及序列的长度。例如,如果输入是
1 2 2 1 5 1 1 7 7 7 7 1 1,则您的程序应该打印最长连续序列:4 个连续的 7。编写一个名为 WordCount.java 的程序,从标准输入读取文本,并打印出文本中的单词数。在这个练习中,单词是由空白字符包围的非空白字符序列。
编写一个名为 Closest.java 的程序,接受三个浮点型命令行参数(x, y, z),从标准输入读取一系列点坐标((x_i, y_i, z_i)),并打印最接近((x, y, z))的点的坐标。请注意,((x, y, z))和((x_i, y_i, z_i))之间的距离的平方为((x - x_i)² + (y - y_i)² + (z - z_i)²)。为了效率,不要使用
Math.sqrt()或Math.pow()。给定一系列对象的位置和质量,编写一个程序来计算它们的质心或质心。质心是n个对象的平均位置,按质量加权。如果位置和质量由(x[i], y[i], m[i])给出,则质心(x, y, m)由以下公式给出:
m = *m[1]* + *m[2]* + ... + *m[n]* x = (*m[1]x[1]* + ... + *m[n]x[n]*) / m y = (*m[1]y[1]* + ... + *m[n]y[n]*) / m编写一个名为
Centroid.java的程序,从标准输入读取一系列位置和质量(x[i], y[i], m[i]),并打印出它们的质心(x, y, m)。提示:参照 Average.java 模型您的程序。编写一个名为 Checkerboard.java 的程序,接受一个命令行参数 n,并绘制一个 n×n 的红黑棋盘。将左下角的方块涂成红色。
![5x5 棋盘]()
![8x8 棋盘]()
![25x25 棋盘]()
编写一个名为 Rose.java 的程序,接受一个命令行参数 n,并绘制具有 n 个花瓣(如果 n 为奇数)或 2n 个花瓣(如果 n 为偶数)的玫瑰,通过绘制极坐标(r,θ)的函数r = sin(n × θ),其中θ的范围从 0 到 2π弧度。下面是 n = 4、7 和 8 时的期望输出。
![玫瑰]()
编写一个名为 Banner.java 的程序,从命令行接受一个字符串 s,并以横幅样式显示在屏幕上,从左向右移动,并在到达字符串末尾时回到字符串开头。添加第二个命令行参数以控制速度。
编写一个名为
Circles.java的程序,在单位正方形的随机位置绘制随机大小的填充圆,生成类似下面的图像。您的程序应该接受四个命令行参数:圆的数量,每个圆为黑色的概率,最小半径和最大半径。![随机圆]()
创意练习
斯派罗图。 编写一个程序 Spirograph.java,接受三个命令行参数 R,r 和 a,并绘制生成的斯派罗图。斯派罗图(技术上,是一个外摆线)是通过围绕半径为 r 的固定大圆滚动一个圆形而形成的曲线。如果笔从滚动圆的中心偏移(r+a),则在时间 t 时得到的曲线方程如下
x(t) = (R+r)*cos(t) - (r+a)*cos(((R+r)/r)*t) y(t) = (R+r)*sin(t) - (r+a)*sin(((R+r)/r)*t)这样的曲线被畅销玩具所推广,该玩具包含具有齿轮齿的圆盘和小孔,您可以在其中放入笔来追踪斯派罗图。
为了产生戏剧性的 3D 效果,绘制一个圆形图像,例如 earth.gif 而不是一个点,并显示它随时间旋转。这是当 R = 180,r = 40,a = 15 时产生的斯派罗图的图片。
时钟。 编写一个程序 Clock.java,显示模拟时钟的秒、分和时针的动画。使用方法
StdDraw.show(1000)大约每秒更新一次显示。提示:这可能是您想要使用
double与%运算符的罕见时刻 - 它的工作方式符合您的期望。示波器。 编写一个程序 Oscilloscope.java 来模拟示波器的输出并产生利萨如图案。这些图案以法国物理学家朱尔斯·A·利萨如的名字命名,他研究了两个相互垂直的周期性干扰同时发生时产生的图案。假设输入是正弦的,因此以下参数方程描述了曲线:
x = Ax sin (wxt + θx) y = Ay sin (wyt + θy) Ax, Ay = amplitudes wx, wy = angular velocity θx, θy = phase factors从命令行获取六个参数 A,w,θ,θ[y],w[y]和θ[y]。
例如,下面的第一幅图具有 Ax = Ay = 1,w = 2,w[y] = 3,θ = 20 度,θ[y] = 45 度。另一幅图具有参数(1, 1, 5, 3, 30, 45)
![示波器 2]()
![示波器 3]()
网页练习
单词和行数统计。 修改 WordCount.java,从标准输入读取文本并打印出文本中的字符数、单词数和行数。
降雨问题。 编写一个程序
Rainfall.java,逐个读取非负整数(表示降雨量),直到输入 999999,然后打印出值的平均值(不包括 999999)。删除重复项。 编写一个程序
Duplicates.java,读取一系列整数并将整数打印回去,除非它们连续出现重复值。例如,如果输入是 1 2 2 1 5 1 1 7 7 7 7 1 1,则您的程序应该打印出 1 2 1 5 1 7 1。游程编码。 编写一个程序 RunLengthEncoder.java,使用游程编码对二进制输入进行编码。编写一个程序
RunLengthDecoder.java,解码游程编码的消息。头部和尾部。 编写程序
Head.java和Tail.java,接受一个整数命令行输入 N,并打印给定文件的前 N 行或后 N 行。(如果文本文件包含<= N 行文本,则打印整个文件。)打印随机单词。 从标准输入读取 N 个单词的列表,其中 N 事先未知,并均匀随机打印出 N 个单词中的一个。不要存储单词列表。而是使用 Knuth 的方法:在读取第 i 个单词时,以 1/i 的概率选择它作为选定的单词,替换前一个冠军。在读取所有数据后打印出幸存的单词。
凯撒密码。 尤利乌斯·凯撒使用一种方案向西塞罗发送秘密消息,现在被称为凯撒密码。每个字母都被替换为字母表中比它前进 k 个位置的字母(如果需要,可以循环)。下表给出了当 k = 3 时的凯撒密码。
Original: A B C D E F G H I J K L M N O P Q R S T U V W X Y Z Caesar: D E F G H I J K L M N O P Q R S T U V W X Y Z A B C例如,消息"VENI, VIDI, VICI"被转换为"YHQL, YLGL, YLFL"。编写一个名为
Caesar.java的程序,它接受一个命令行参数 k,并将 Caesar 密码应用于从标准输入读取的字母序列,其中移位= k。如果一个字母不是大写字母,只需将其打印出来。凯撒密码解密。 如何解密使用凯撒密码加密的消息?提示:你不需要再写任何代码。
奇偶校验。 当每行和每列的和都是偶数时,布尔矩阵具有奇偶校验属性。这是一种简单的纠错码,因为如果在传输中一个位被损坏(位从 0 翻转到 1 或从 1 翻转到 0),它可以被检测和修复。这是一个具有奇偶校验属性的 4 x 4 输入文件:
1 0 1 0 0 0 0 0 1 1 1 1 0 1 0 1编写一个名为 ParityCheck.java 的程序,它将一个整数 N 作为命令行输入,并从标准输入读取一个 N x N 的布尔矩阵,并输出(i)矩阵是否具有奇偶校验属性,或者(ii)指示哪个单个损坏的位(i,j)可以翻转以恢复奇偶校验属性,或者(iii)指示矩阵已损坏(需要更改两个以上的位才能恢复奇偶校验属性)。尽可能少地使用内部存储。提示:你甚至不需要存储矩阵!
高木函数。 绘制高木函数:处处连续,无处可导。
搭便车问题。 你正在面试 N 位候选人,竞争唯一的美国偶像职位。每分钟你可以看到一个新的候选人,并且你有一分钟的时间来决定是否宣布那个人为美国偶像。一旦你结束面试候选人,就不能改变主意。假设你可以立即用 0 到 1 之间的一个实数对每个候选人进行评分,但当然,你不知道尚未见过的候选人的评分。设计一种策略,并编写一个名为
AmericanIdol的程序,该程序至少有 25%的机会选择最佳候选人(假设候选人以随机顺序到达),从标准输入读取 500 个数据值。解决方案: 面试 N/2 分钟,并记录到目前为止看到的最佳候选人的评分。在接下来的 N/2 分钟内,选择第一个评分高于记录评分的候选人。这至少有 25%的机会,因为如果第二好的候选人在前 N/2 分钟到达,最佳候选人在最后 N/2 分钟到达,你将得到最佳候选人。通过在时间 N/e 时切换,可以稍微改进到 1/e = 0.36788。
嵌套菱形。 编写一个名为
Diamonds.java的程序,它接受一个命令行输入 N,并绘制 N 个嵌套的正方形和菱形。下面是 N = 3、4 和 5 时的期望输出。![菱形 3]()
![菱形 4]()
![菱形 5]()
正多边形。 创建一个函数来绘制一个以(x, y)为中心,大小为 s 的 N 边形。使用该函数绘制如下图片中的嵌套多边形。
![嵌套多边形]()
凸出的正方形。 编写一个名为
BulgingSquares.java的程序,从Akiyoshi Kitaoka绘制以下光学错觉。尽管所有正方形大小相同,中心似乎向外凸出。![凸出的正方形]()
螺旋式老鼠。 假设有 N 只老鼠,它们从一个具有 N 边的正多边形的顶点出发,然后它们各自向最近的其他老鼠(逆时针方向)前进,直到它们全部相遇。编写一个程序来绘制它们追踪的对数螺旋路径,通过绘制旋转和缩小的嵌套 N 边形,就像这个动画中所示。
螺旋。 编写一个程序来绘制如下所示的螺旋。
![螺旋]()
球体。 编写一个程序 Globe.java,接��一个实数命令行参数α,并绘制一个参数为α的球形图案。绘制函数*f(θ) = cos(α × θ)*的极坐标(r,θ),θ范围从 0 到 7200 度。下面是α为 0.8、0.9 和 0.95 时的期望输出。
![α = 0.8 的球形图案]()
![α = 0.9 的球形图案]()
![α = 0.95 的球形图案]()
绘制字符串。 编写一个程序 RandomText.java,接受一个字符串 s 和一个整数 N 作为命令行输入,随机选择一个位置和颜色,将字符串写入 N 次。
![hello]()
![world]()
![java]()
2D 随机漫步。 编写一个程序 RandomWalk.java,模拟一个 2D 随机漫步并动画显示结果。从一个 2N×2N 网格的中心开始。当前位置以蓝色显示;轨迹以白色显示。
![5 步后的 2D 随机漫步]()
![25 步后的 2D 随机漫步]()
![106 步后的 2D 随机漫步]()
旋转桌子。 你坐在一个旋转的正方形桌子上(就像一个懒人苏珊),桌子的四个角放着四个硬币。你的目标是翻转这些硬币,使它们要么都是正面要么都是反面,此时会响起一声铃声通知你完成了。你可以选择其中任意两个,确定它们的朝向,并(可选)翻转其中一个或两个。为了增加难度,你被蒙上了眼睛,每次选择两个硬币后桌子都会旋转。编写一个名为
RotatingTable.java的程序,将硬币初始化为随机朝向。然后,提示用户选择两个位置(1-4),并确定每个硬币的朝向。接下来,用户可以指定要翻转哪一个或哪两个硬币。这个过程重复进行,直到用户解决了谜题。旋转桌子求解器。 写另一个名为
RotatingTableSolver.java的程序来解决旋转桌子谜题。一种有效的策略是随机选择两个硬币并将它们翻转为正面。然而,如果你非常不走运,这可能需要任意数量的步骤。目标:设计一种策略,最多在 5 步内解决谜题。六角形。 六角 是由约翰·纳什在普林斯顿大学攻读研究生时推广的一种双人棋盘游戏,后来由帕克兄弟公司商业化。它在一个呈11×11 菱形的六边形网格上进行。编写一个名为
Hex.java的程序来绘制棋盘。带阻力的抛体运动。 编写一个程序 BallisticMotion.java,绘制以速度 v 和角度θ射出的球的轨迹。考虑重力和阻力力。假设阻力力与速度的平方成正比。使用牛顿的运动方程和欧拉-克罗默方法,根据以下方程更新位置、速度和加速度:
v = sqrt(vx*vx + vy*vy) ax = - C * v * vx ay = -G - C * v * vy vx = vx + ax * dt vy = vy + ay * dt x = x + vx * dt y = y + vy * dt使用 G = 9.8,C = 0.002,将初始速度设为 180,角度设为 60 度。
心形。 编写一个程序 Heart.java 来绘制一个粉色的心形:先画一个菱形,然后在左上角和右上角各画两个圆。
![心形]()
变色正方形。 编写一个程序,绘制一个正方形并每秒更改其颜色。
简谐运动。 重复上一个练习,但像这个小程序中的 Lissajous 图案一样进行动画。例如:A = B = w = w[y] = 1,但在每个时间 t 绘制 100(左右)个点,其中φ范围从 0 到 720 度,φ范围从 0 到 1080 度。
Bresenham 直线绘制算法。 要在显示器上绘制从(x1,y1)到(x2,y2)的线段,比如 1024x1024,您需要对连续线进行离散近似,并确定要打开哪些像素。Bresenham 直线绘制算法是一个聪明的解决方案,适用于斜率在 0 和 1 之间且 x1 < x2 时。
int dx = x2 - x1; int dy = y2 - y1; int y = y1; int eps = 0; for (int x = x1; x <= x2; x++) { StdDraw.point(x, y); eps += dy; if (2*eps >= dx) { y++; eps -= dx; } }修改 Bresenham 的算法以处理任意线段。
米勒的疯狂。 编写一个程序 Madness.java 来绘制参数方程:
x = sin(0.99 t) - 0.7 cos( 3.01 t) y = cos(1.01 t) + 0.1 sin(15.03 t)其中参数
t以弧度为单位。你应该得到以下复杂图片。通过改变参数进行实验,并生成原始图片。Fay 的蝴蝶。 编写一个程序 Butterfly.java 来绘制极坐标方程:
r = e^(cos t) - 2 cos(4t) + (sin(t/12)⁵)其中参数
t以弧度为单位。你应该得到一个类似以下蝴蝶状图案的图像。通过改变参数进行实验,并生成原始图片。![蝴蝶]()
学生数据库。 文件 students.txt 包含普林斯顿大学一门入门计算机科学课程中注册的学生名单。第一行包含一个整数 N,指定数据库中的学生人数。接下来的每个 N 行由四个信息组成,以空格分隔:名字,姓氏,电子邮件地址和分组号。程序 Students.java 读取整数 N,然后从标准输入读取 N 行数据,将数据存储在四个并行数组中(一个用于分组号的整数数组,其他字段用于字符串数组)。然后,程序打印出第 4 和第 5 组的学生列表。
洗牌。 在 2003 年 10 月 7 日加利福尼亚州州长竞选中,有 135 名官方候选人。为了避免对字母表末尾出现的候选人(Jon W. Zellhoefer)的自然偏见,加利福尼亚选举官员试图以随机顺序排列候选人。编写一个程序 Shuffle.java,它接受一个命令行参数 N,从标准输入读取 N 个字符串,然后以随机顺序将它们打印出来。(加利福尼亚决定随机化字母表而不是打乱候选人。使用这种策略,不是所有 N!可能的结果都是同等可能的,甚至可能!例如,两个姓氏非常相似的候选人将总是相邻。)
反转。 编写一个程序 Reverse.java,从标准输入读取任意数量的实数值,并以相反顺序打印它们。
时间序列分析。 这个问题研究了时间序列分析中的两种预测方法。移动平均或指数平滑。
极坐标图。 创建任何这些极坐标图之一。
Java 游戏。 使用
StdDraw.java实现Java 无限网上的游戏之一。考虑以下程序。
|
public class Mystery { public static void main(String[] args) { int N = Integer.parseInt(args[0]); int[] a = new int[M]; while(!StdIn.isEmpty()) { int num = StdIn.readInt(); a[num]++; } for (int i = 0; i < M; i++) for (int j = 0; j < a[i]; j++) System.out.print(i + " "); System.out.println(); } }|
假设文件
input.txt包含以下整数:|
8 8 3 5 1 7 0 9 2 6 9 7 4 0 5 3 9 3 7 6|
运行以下命令后,数组
a的内容是什么?|
java Mystery 10 < input.txt|
高低。 洗一副牌,并发一张给玩家。提示玩家猜测下一张牌是比当前牌更高还是更低。重复直到玩家猜错。游戏节目:????使用了这个。
弹性碰撞。 编写一个名为
CollidingBalls.java的程序,接受一个命令行参数 n,并绘制 n 个弹跳球的轨迹,这些球根据弹性碰撞的法则与墙壁和彼此弹跳。假设所有球的质量相同。弹性碰撞与障碍物。 每个球应该有自己的质量。在中心放一个大球,初始速度为零。布朗运动。
统计异常值。 修改 Average.java 以打印出大于平均值 1.5 个标准差的所有值。您需要一个数组来存储这些值。
计算机动画。 1995 年,詹姆斯·高斯林向 Sun 高管展示了 Java 的潜力,展示了其提供动态和交互式 Web 内容的潜力。当时,网页是固定的,不可交互的。为了展示 Web 的潜力,高斯林展示了旋转 3D 分子、可视化排序例程和公爵在屏幕上翻筋斗的小程序。Java 于 1995 年 5 月正式推出,并在技术领域被广泛采用。互联网将永远不同。
![公爵翻筋斗]()
程序 Duke.java 读取了 17 个图像 T1.gif 到 T17.gif,并生成了动画。要在您的计算机上执行,请下载这 17 个 GIF 文件并放在与
Duke.java相同的目录中。(或者,下载并解压文件 duke.zip 或 duke.jar 以提取所有 17 个 GIF。)翻筋斗的公爵。 修改 Duke.java,使其在屏幕上从右向左翻 5 次筋斗,当碰到窗口边界时环绕。重复这个翻筋斗的循环 100 次。提示:在显示一系列 17 帧后,向左移动 57 像素并重复。将你的程序命名为 MoreDuke.java。
Tac(猫的反向拼写)。 编写一个名为
Tac.java的程序,从标准输入读取文本行,并以相反顺序打印这些行。游戏。 使用
StdDraw实现游戏dodge:在单位正方形内移动一个蓝色圆盘,触碰随机放置的绿色圆盘,同时避开移动的红色圆盘。每次触碰后,添加一个新的移动红色圆盘。简谐运动。 创建一个类似于Wikipedia中的简谐运动动画。
![简谐运动]()
阴阳。 使用
StdDraw.arc()绘制一个阴阳。二十个问题。 编写一个名为 QuestionsTwenty.java 的程序,从相反的角度进行 20 个问题的游戏:用户想一个介于 1 和一百万之间的数字,计算机进行猜测。使用二分查找确保计算机最多需要 20 次猜测。
编写一个名为
DeleteX.java的程序,从标准输入读取文本并删除所有字母 X 的出现。要过滤文件并删除所有 X,请使用以下命令运行您的程序:% java DeleteX < input.txt > output.txt编写一个名为
ThreeLargest.java的程序,从标准输入读取整数,并打印出最大的三个输入。编写一个名为
Pnorm.java的程序,接受一个命令行参数 p,从标准输入读取实数,并打印出它们的p-范数。向量(x[1],...,x[N])的 p-范数定义为(|x[1]|^p + |x[2]|^p + ... + |x[N]|^p)的 p 次根。考虑以下 Java 程序。
public class Mystery { public static void main(String[] args) { int i = StdIn.readInt(); int j = StdIn.readInt(); System.out.println((i-1)); System.out.println((j*i)); } }假设文件
input.txt包含5 1以下命令做什么?
java Mystery < input.txt重复上一个练习,但使用以下命令代替
java Mystery < input.txt | java Mystery | java Mystery | java Mystery考虑以下 Java 程序。
public class Mystery { public static void main(String[] args) { int i = StdIn.readInt(); int j = StdIn.readInt(); int k = i + j; System.out.println(j); System.out.println(k); } }假设文件
input.txt包含整数 1 和 1。以下命令会做什么?java Mystery < input.txt | java Mystery | java Mystery | java Mystery考虑 Java 程序 Ruler.java。
public class Ruler { public static void main(String[] args) { int n = StdIn.readInt(); String s = StdIn.readString(); System.out.println((n+1) + " " + s + (n+1) + s); } }假设文件
input.txt包含整数 1 和 1。以下命令会做什么?java Ruler < input.txt | java Ruler | java Ruler | java Ruler修改 Add.java,以便在用户输入非正整数时,重新要求用户输入两个正整数。
修改 TwentyQuestions.java,以便在用户输入除
true或false之外的内容时,重新要求用户输入响应。提示:在主循环中添加一个do-while循环。Nonagram. 编写一个程序来绘制一个nonagram。
星形多边形。 编写一个程序
StarPolygon.java,接受两个命令行输入 p 和 q,并绘制{p/q}-star polygon。完全图。 编写一个程序,接受一个整数 N,绘制一个 N-gon,其中每个顶点位于半径为 256 的圆上。然后绘制连接每对顶点的灰色线。
内克立方体。 编写一个程序
NeckerCube.java来绘制一个内克立方体。如果将
StdDraw.clear(Color.BLACK)命令移到 BouncingBall.java 中while循环开始之前会发生什么? 答案:尝试一下,观察给定起始速度和位置的漂亮编织的 3D 图案。如果将在 BouncingBall.java 中将
StdDraw.show()的参数更改为 0 或 1000 会发生什么?编写一个程序,使用两次调用
StdDraw.filledCircle()来绘制一个宽度为 10 的圆环。编写一个程序,使用嵌套的
for循环和多次调用StdDraw.point()来绘制一个宽度为 10 的圆环,如下所示。编写一个程序来绘制奥运五环。
![奥运五环 http://www.janecky.com/olympics/rings.html]()
编写一个程序 BouncingBallDeluxe.java,通过使用
StdAudio和声音文件 pipebang.wav 在与墙碰撞时播放声音效果来装饰 BouncingBall.java。
1.6 案例研究: 随机网络冲浪者
原文:
introcs.cs.princeton.edu/java/16pagerank译者:飞龙
通过网络进行交流已经成为日常生活的一个重要组成部分。这种交流部分得益于对网络结构的科学研究。我们考虑一个简单的模型,称为随机冲浪者模型。我们认为网络是一组固定的页面��每个页面包含一组固定的超链接,每个链接引用到其他页面。我们研究一个人(随机冲浪者)从页面到页面随机移动时会发生什么,无论是通过在地址栏中输入页面名称还是通过点击当前页面上的链接。
模型。
问题的关键在于指定从页面到页面随机移动的含义。以下直观的90–10 规则捕捉了移动到新页面的两种方法:假设 90%的时间随机冲浪者点击当前页面上的随机链接(每个链接被等概率选择),10%的时间随机冲浪者直接转到随机页面(网络上的所有页面被等概率选择)。
你可以立即看出这个模型有缺陷,因为你从自己的经验中知道,真实网络冲浪者的行为并不是那么简单:
没有人会以相等的概率选择链接或页面。
没有真正的潜力直接冲浪到网络上的每个页面。
90–10(或任何固定)的分解只是一个猜测。
它没有考虑返回按钮或书签。
尽管存在这些缺陷,但这个模型足够丰富,计算机科学家通过研究它已经学到了很多关于网络性质的知识。
输入格式。
我们假设有n个网页,编号从 0 到n−1,并且我们用这些数字的有序对表示链接,第一个指定包含链接的页面,第二个指定链接指向的页面。我们采用的输入格式是一个整数(n的值),后跟一系列整数对(所有链接的表示)。
数据文件 tiny.txt 和 medium.txt 是两个简单的示例。
转移矩阵。
我们使用一个二维矩阵,我们称之为转移矩阵,完全指定了随机冲浪者的行为。对于n个网页,我们定义一个n×n矩阵,使得第i行和第j列的条目是随机冲浪者在页面i时移动到页面j的概率。
Transition.java 是一个从标准输入读取链接并在标准输出上生成相应转移矩阵的过滤器。
模拟。
RandomSurfer.java 模拟了随机冲浪者的行为。它读取一个转移矩阵,并根据规则冲浪,从页面 0 开始,并将移动次数作为命令行参数。它计算冲浪者访问每个页面的次数。将该计数除以移动次数得出随机冲浪者最终停留在页面上的概率的估计。这个概率被称为页面的排名。
一个随机移动。 计算的关键是随机移动,由转移矩阵指定:每一行代表一个离散概率分布—条目完全指定了随机冲浪者下一步的行为,给出了冲浪到每个页面的概率。
![从离散分布生成随机整数]()
RandomSurfer.java 是一个改进版本,使用了我们将在第 2.2 节介绍的两个库方法。
马尔可夫链. 描述冲浪者行为的随机过程称为 马尔可夫链。马尔可夫链具有广泛的适用性,被广泛研究,并具有许多显著和有用的特性。例如,马尔可夫链的一个基本极限定理表明,我们的冲浪者可以从任何地方开始,因为随机冲浪者最终落在任何特定页面上的概率对于所有起始页面都是相同的!
页面排名. 随机冲浪者模拟很简单:它循环执行指定数量的移动,通过图表随机冲浪。增加迭代次数会给出越来越准确的估计,即冲浪者落在每个页面上的概率—页面排名。
可视化直方图. RandomSurferHistogram.java 绘制一个频率直方图,最终稳定到页面排名。
![页面排名和直方图]()
混合马尔可夫链。
直接模拟随机冲浪者的行为以了解网页结构是吸引人的,但可能太耗时。幸运的是,我们可以通过使用线性代数更有效地计算相同的数量。
马尔可夫链的平方. 随机冲浪者在两步内从页面 i 移动到页面 j 的概率是多少?第一步移动到一个中间页面 k,因此我们计算从 i 移动到 k,然后从 k 移动到 j 的概率,对所有可能的 k 进行计算并累加结果。这个计算我们之前见过—矩阵-矩阵乘法。
![马尔可夫链的平方]()
幂法. 然后我们可以通过再次乘以
p[][]计算三次移动的概率,通过再次乘以p[][]计算四次移动的概率,依此类推。然而,矩阵-矩阵乘法是昂贵的,而我们实际上对一个 向量–矩阵计算感兴趣。![幂法]()
Markov.java 是一个实现,你可以用它来检查我们示例的收敛性。例如,它得到与 RandomSurfer.java 相同的结果(页面排名精确到小数点后两位),但只需进行 20 次向量-矩阵乘法。
问答
Q. 如果某个页面没有外链,转移矩阵的行应该是什么?
A. 为了使矩阵随机(所有行之和为 1),使得该页面等可能地转移到每个其他页面。
Q. Markov.java 收敛需要多长时间?
A. Brin 和 Page 报告说,在迭代收敛之前只需要 50 到 100 次迭代。收敛取决于 P λ[2] 的第二大特征值。Web 的链接结构使得 λ[2] 大约等于 α = 0.9。由于 0.9⁵⁰ = 0.005153775207,我们预计在 50 次迭代后会有 2-3 位有效数字。
Q. 有关 PageRank 的推荐阅读吗?
A. 这里有一篇关于 PageRank 的精彩文章。
Q. 为什么要添加随机页面 / 传送组件?
A. 如果不这样做,随机冲浪者可能会陷入图表的某个部分。更多技术原因:使马尔可夫链遍历。
练习
创意练习
网页练习
- 滑梯与梯子. 将经典的 Hasbro 棋盘游戏 滑梯与梯子 建模为马尔可夫链。确定如果有两名玩家,第一名玩家获胜的概率。
2. 函数
原文:
introcs.cs.princeton.edu/java/20functions译者:飞龙
概述。
在本章中,我们考虑了一个对控制流具有深远影响的概念,就像条件语句和循环一样重要:函数,它允许我们在不同代码段之间来回传递控制。函数很重要,因为它们允许我们在程序中清晰地分离任务,并且提供了一个通用机制,使我们能够重用代码。
2.1 Static Methods 介绍了 Java 机制(静态方法)来实现函数。
2.2 Libraries and Clients 描述了如何将相关的静态方法分组到库中,以实现模块化编程。
2.3 Recursion 考虑了一个函数调用自身的概念。这种可能性被称为递归。
2.4 Percolation 提供了一个案例研究,使用蒙特卡洛模拟来研究一种名为渗透的自然模型。
本章中的 Java 程序。
下面是本章中的 Java 程序列表。点击程序名称以访问 Java 代码;点击参考号以获取简要描述;阅读教材以获取详细讨论。
REF PROGRAM DESCRIPTION 2.1.1 Harmonic.java 调和数(重新讨论) 2.1.2 Gaussian.java 高斯函数 2.1.3 Coupon.java 收集优惠券(重新讨论) 2.1.4 PlayThatTuneDeluxe.java 演奏那首曲子(重新讨论) 2.2.1 StdRandom.java 随机数库 2.2.2 StdArrayIO.java 数组 I/O 库 2.2.3 IFS.java 迭代函数系统 2.2.4 StdStats.java 数据分析库 2.2.5 StdStats.java 数据分析库 2.2.6 Bernoulli.java 伯努利试验 2.3.1 Euclid.java 欧几里德算法 2.3.2 TowersOfHanoi.java 汉诺塔 2.3.3 Beckett.java 格雷码 2.3.4 Htree.java 递归图形 2.3.5 Brownian.java 布朗桥 2.3.6 LongestCommonSubsequence.java 最长公共子序列 2.4.1 Percolation.java 渗透脚手架 2.4.2 VerticalPercolation.java 垂直渗透 2.4.3 PercolationVisualizer.java 渗透可视化客户端 2.4.4 PercolationProbability.java 渗透概率估计 2.4.5 Percolation.java 渗透检测 2.4.6 PercolationPlot.java 自适应绘图客户端
2.1 静态方法
原文:
introcs.cs.princeton.edu/java/21function译者:飞龙
实现函数的 Java 构造称为静态方法。
使用和定义静态方法。
使用静态方法很容易理解。例如,当你在程序中写入 Math.abs(a-b) 时,效果就好像你将该代码替换为 Java 的 Math.abs() 方法在传递表达式 a-b 作为参数时产生的返回值。
控制流. Harmonic.java 包括两个静态方法:
harmonic()用于计算谐波数,main()用于与用户交互。
![函数调用跟踪]()
函数调用跟踪. 跟踪函数调用的控制流的一种简单方法是想象每个函数在被调用时打印其名称和参数值,在返回之前打印其返回值,并在调用时添加缩进并在返回时减少缩进。
静态方法定义. 静态方法定义的第一行,称为签名,为方法和每个参数变量指定名称。它还指定了每个参数变量的类型和方法的返回类型。在签名之后是方法的主体,用大括号括起来。主体由我们在第一章中讨论过的各种语句组成。它还可以包含一个return 语句,它将控制传回静态方法被调用的点,并返回计算结果或返回值。主体可能声明局部变量,这些变量仅在声明它们的方法中可用。
![静态方法解剖]()
函数调用. 静态方法调用只是方法名称后跟其参数,用逗号分隔并用括号括起来。方法调用是一个表达式,因此你可以用它来构建更复杂的表达式。同样,参数是一个表达式—Java 评估表达式并将结果值传递给方法。因此,你可以编写像
Math.exp(-x*x/2) / Math.sqrt(2*Math.PI)这样的代码,Java 知道你的意思。![静态方法调用解剖]()
静态方法的属性。
多个参数. 像数学函数一样,Java 静态方法可以接受多个参数,因此可以有多个参数变量。
多个方法. 你可以在一个
.java文件中定义任意多个静态方法。这���方法是独立的,可以以任何顺序出现在文件中。一个静态方法可以调用同一文件中的任何其他静态方法或 Java 库中的任何静态方法,如Math。重载. 签名不同的静态方法是不同的静态方法。对两个签名不同的静态方法使用相同的名称称为重载。
多个返回语句. 你可以在方法中放置
return语句,无论何时需要它们:一旦到达第一个return语句,控制就会返回给调用程序。单个返回值. Java 方法仅向调用者提供一个返回值,类型与方法签名中声明的类型相同。
作用域. 变量的作用域是程序中可以通过名称引用该变量的部分。在 Java 中的一般规则是,声明在语句块中的变量的作用域仅限于该块中的语句。特别地,在静态方法中声明的变量的作用域仅限于该方法的主体。因此,你不能在一个静态方法中引用在另一个方法中声明的变量。
![作用域]()
副作用。 一个纯函数是一个函数,给定相同的参数,总是返回相同的值,而不产生任何可观察的副作用,比如消耗输入、产生输出或者改变系统的状态。函数
harmonic()是一个纯函数的例子。然而,在计算机编程中,我们经常定义函数的唯一目的是产生副作用。在 Java 中,一个静态方法可以使用关键字void作为其返回类型,以指示它没有返回值。
FunctionExamples.java 给出了一些例子。
实现数学函数。
现在我们考虑两个在科学、工程和金融中起重要作用的函数。高斯(正态)分布函数 以熟悉的钟形曲线为特征,并由以下公式定义:
\(\phi(x) \;=\; \frac{1}{\sqrt{2 \pi}} e^{-x²/2}\)
累积高斯分布函数 (\Phi(z)) 被定义为曲线 (\phi(x)) 在 x 轴上方且在垂直线 x = z 左侧的面积。
闭式形式。 在最简单的情况下,我们有一个以
Math库中实现的函数为基础的闭式数学方程来定义我们的函数。这适用于 (\phi(x))。无闭式形式。 否则,我们可能需要一个更复杂的算法来计算函数值。这种情况适用于 (\Phi(z)),对于它没有闭式表达式。对于小(或大)的 z,值非常接近于 0(或 1);因此代码直接返回 0(或 1);否则以下泰勒级数逼近是评估函数的有效基础:
\(\begin{eqnarray*} \Phi(z) &= & \int_{-\infty}^{z} \phi(x) dx \\ & = & \frac{1}{2} \;+\; \phi(z) \; \left(z \;+\; \frac{z³}{3} \;+\; \frac{z⁵}{3 \cdot 5} \;+\; \frac{z⁷}{3 \cdot 5 \cdot 7} \;+\; \ldots \;\; \right) \end{eqnarray*}\)
Gaussian.java 实现了这两个静态方法。
使用静态方法来组织代码。
通过定义函数的能力,我们可以在适当时候在程序中定义函数来更好地组织我们的程序。例如,Coupon.java 是 CouponCollector.java 的一个版本,更好地分离了计算的各个组件。
给定 n,计算一个随机优惠券值。
给定 n,进行优惠券收集实验。
从命令行获取 n,然后计算并打印结果。
每当你可以清晰地在程序中分离任务时,你应该这样做。
传递参数和返回值。
接下来,我们将详细研究 Java 传递参数和从函数返回值的机制。
值传递。 你可以在函数体中的任何代码中使用参数变量,就像使用局部变量一样。参数变量和局部变量之间唯一的区别是,Java 评估调用代码提供的参数并用结果值初始化参数变量。这种方法称为值传递。
数组作为参数。 当一个静态方法以数组作为参数时,它实现了一个操作相同类型任意数量值的函数。
数组的副作用。 经常情况下,接受数组作为参数的静态方法的目的是产生副作用(改变数组元素的值)。
数组作为返回值。 静态方法也可以提供数组作为返回值。
ArrayFunctionExamples.java 给出了一些关于数组作为参数和返回值的函数的例子。
声波的叠加。
像 A 调这样的音符具有纯净的声音,不太具有音乐性,因为您习惯听到的声音还有许多其他成分。大多数乐器产生谐波(不同八度的相同音符,但不那么响亮),或者您可能演奏和弦(同时演奏多个音符)。要组合多个声音,我们使用叠加:简单地将波形相加并重新缩放,以确保所有值保持在−1 和 1 之间。
PlayThatTuneDeluxe.java 是 PlayThatTune 的一个版本,封装了声波计算并添加了谐波。
这里有一些示例数据文件(由各个学生创建):
音阶.txt
伊莉莎白.txt
通往天堂的阶梯.txt
初稿.txt
疯狂的.txt
国歌.txt
阿拉伯舞曲.txt
艺人.txt
自由鸟.txt
tomsdiner.txt
练习
编写一个静态方法
max3(),它接受三个int参数,并返回最大值。添加一个重载函数,使用三个double值执行相同的操作。解决方案:
public static int max3(int a, int b, int c) { int max = a; if (b > max) max = b; if (c > max) max = c; return max; } public static double max3(double a, double b, double c) { double max = a; if (b > max) max = b; if (c > max) max = c; return max; }编写一个静态方法
majority(),它接受三个boolean参数,并在至少两个参数为true时返回true,否则返回false。不要使用if语句。解决方案:
public static boolean majority(boolean a, boolean b, boolean c) { return (a && b) || (a && c) || (b && c); }编写一个静态方法
eq(),它接受两个int数组作为参数,并在数组长度相同且所有对应元素相等时返回true,否则返回false。解决方案。ArraysEquals.java。
编写一个静态方法
sqrt(),它接受一个double参数,并返回该数字的平方根。使用牛顿法(参见 Sqrt.java)来计算结果。解决方案:Newton.java。
考虑下面的静态方法
cube()。public static void cube(int i) { i = i * i * i; }以下
for循环迭代了多少次?for (int i = 0; i < 1000; i++) cube(i);答案:只有 1,000 次。
创意练习
Black–Scholes 期权估值。 Black–Scholes公式提供了不支付股息的股票上的欧式看涨期权的理论价值,给定当前股价
s、行权价x、连续复利风险无息率r、波动率σ和到期时间(年)t。该值由以下公式给出\(\Phi(a) - s x e^{-rt} \, \Phi(b)\)
其中
\(a = \frac{\ln(s/x) + (r + \sigma² / 2) t}{\sigma \sqrt{t}}, \;\; b = a - \sigma \sqrt{t}\)
编写一个程序 BlackScholes.java,从命令行获取
s、x、r、sigma和t,并打印出 Black-Scholes 值。日历。 编写一个程序 Calendar.java,它接受两个整数命令行参数
m和y,并打印出月份m和年份y的月度日历,如下例所示:February 2009 S M Tu W Th F S 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28提示:参见 LeapYear.java 和练习 1.2.29。
霍纳法。编写一个类 Horner.java,其中包含一个名为
evaluate()的方法,该方法以浮点数数组xp[]作为参数,并返回在x处评估多项式的结果,其中p[]中的元素是系数:\(p_0 + p_1x¹ + p_2x² + \; \ldots \; + p_{n-2}x^{n-2} + p_{n-1}x^{n-1}\)
使用霍纳法,这是一种执行计算的高效方法,建议使用以下括号化:
\(p_0 + x (p_1 + x (p_2 + \; \ldots \; + x(p_{n-2} + x p_{n-1}) \ldots))\)
编写一个测试客户端,其中包含一个使用
evaluate()计算近似值 ex 的静态方法exp(),使用泰勒级数展开式的前n项(ex = 1 + x + x²/2! + x³/3! + \ldots)。您的客户端应该接受一个命令行参数x,并将您的结果与Math.exp()计算的结果进行比较。本福德定律。 美国天文学家西蒙·纽康布观察到一本编制对数表的书中的一个怪现象:开始的页面比结束的页面脏得多。他怀疑科学家们更多地使用以 1 开头的数字进行计算,而不是 8 或 9,提出了第一位数定律,该定律指出在一般情况下,领先数字更有可能是 1(大约 30%)而不是数字 9(不到 4%)。这种现象被称为本福德定律,现在经常被用作统计测试。例如,美国国税局的法务会计师依靠它来发现税务欺诈。编写一个程序 Benford.java,从标准输入读取一系列整数,并制表显示 1-9 中每个数字作为领先数字的次数,将计算分解为一组适当的静态方法。使用您的程序在计算机或网络上的一些信息表上测试该定律。然后,编写一个程序通过生成从$1.00 到$1,000.00 的随机金额来破坏国税局,使其具有您观察到的相同分布。
网页练习
菱形瓷砖。 编写一个程序 DiamondTile.java,接受一个命令行参数 N,并创建一个 N×N 的菱形瓷砖。包括静态方法
diamond()和filledDiamond()。六边形瓷砖。 编写一个程序 HexTile.java,接受一个命令行参数 N,并创建一个 N×N 的六边形瓷砖。包括静态方法
hexagon()和filledHexagon()。逆高斯累积分布。 假设 SAT 数学成绩服从均值 500 和标准差的正态分布。估计学生必须获得多高的分数才能成为前 10%。为了做到这一点,您需要找到值z,使得Φ(z, 500, 100) = 0.9。提示: 使用二分查找。
SAT 成绩。 一所著名的东北大学收到 2 万份学生申请。假设这些个体的 SAT 成绩服从均值 1200 和标准差 100 的正态分布。假设大学决定录取 SAT 成绩最好的 5000 名学生。估计仍然会被录取的最低分数。
投票机。 假设在一个拥有 1 亿选民的人口中,51%投票给候选人 A,49%投票给候选人 B。然而,投票机容易出错,有 5%的概率产生错误答案。假设错误是独立且随机发生的,5%的错误率足以使紧密选举的结果无效吗?可以容忍什么错误率?
赌徒的直方图。 编写一个程序
RandomWalk.java,接受一个命令行参数 M,并模拟一个从 M 开始的赌徒,下注正好 M 美元。运行这个实验 N 次,生成赌徒最终赢得的金额的直方图。
赌徒最终赢得的金额遵循二项分布,均值为 M,方差为 N/4。该分布可以用具有相同均值和方差的正态分布来近似。生成一个直方图,显示您预期赌徒最终在每个直方图箱中获得的金额的比例。将您的程序组织成几个函数。
统计抽样。 编写一个程序 Sampling.java,随机抽取 N 个人并向他们提出一个是/否问题。计算 95%的置信区间。
二十一点。 编写一个程序
Blackjack.java来玩基本策略,或者编写一个程序BlackjackCounter.java来实现高低计数系统。小波。 应用于计算机视觉、人类视觉、语音处理、压缩 FBI 指纹数据库、过滤嘈杂数据、检测时间序列中的自相似性、声音合成、计算机图形学、医学成像、分析星系的聚集以及分析湍流。Haar 函数由Φ(x) = 1(如果 0 ≤ x < 1/2)、Φ(x) = -1(如果 1/2 ≤ x < 1)和Φ(x) = 0(否则)定义。对于整数 m 和 n,Haar 基函数Φm,n = 2^(-m/2) Φ(2^(-m)x - n)。编写一个程序
Haar.java,它接受���个整数输入 M 和 N,一个实数输入 x,并打印Φm,n。或者可能绘制它?Baccarat. 百家乐是一种简单的纸牌游戏,在詹姆斯·邦德电影中被浪漫化。当玩家得到九时,庄家宣布“neuf a la banque”。编写一个程序来确定你赢得胜利的机会......
共线点。 编写一个函数
public boolean areCollinear(int x1, int y1, int x2, int y2, int x3, int y3)如果三个点(x1,y1),(x2,y2)和(x3,y3)在同一条直线上,则返回
true,否则返回false。高斯误差函数。 误差函数是在概率、统计和微分方程解决方案中出现的函数。例如,Φ(z) = 1/2 + (1 + erf(z / sqrt(2))),其中Φ(z)是上面定义的高斯累积分布函数。
![误差函数]()
在初等函数的术语中,积分没有封闭形式的解,因此我们求助于近似。当
z为非负时,下面的切比雪夫拟合估计精确到 7 个有效数字:![切比雪夫逼近误差函数]()
如果z为负数,则使用erf(z) = -erf(-z)的恒等式。实现它的一种特别有效的方法是通过一种称为霍纳方法的括号的巧妙使用。编写一个函数
erf()在 ErrorFunction.java 中,它接受一个实数输入z并使用上述公式计算误差函数。Haversine。 编写一个函数
haversine(),它接受一个double参数θ并返回 haversine(θ) = (sin(θ/2))²。土壤温度。 (克利夫·莫勒)假设土壤温度在表面和下面均匀为 Ti(20 摄氏度)。一股冷锋来袭,表面温度 Ts(-15 摄氏度)在可预见的未来保持不变。在暴露于这些条件下 30 天后,10 米深处的土壤温度是多少?在表面以下 5 米处水结冰需要多长时间?挖掘水管的深度是多少,以便在这些条件下暴露 60 天而不结冰?
Craps. 计算在 craps 中赢得pass bet的概率。以下是 pass bet 的规则。掷两个 6 面骰子,让x为它们的和。
如果x为 7 或 11,则立即获胜
如果x为 2、3 或 12,则立即失败
否则,重复掷两个骰子,直到它们的和为x或 7
如果它们的和为x,则获胜
如果它们的和为 7,则失败
程序 Craps.java 接受一个命令行参数 N,并模拟 N 次通过投注。该程序的组织受益于两个辅助函数:
sumOfTwoDice和winsPassBet。这两个函数都有一个有趣的特点-它们不接受任何输入参数。第一个函数模拟掷两个骰子。这返回一个介于 2 和 12 之间的整数,但并非所有值都是等概率的。为了正确模拟概率,我们调用StdRandom.random(6)两次,一次生成 1 到 6 之间的数字。然后,我们将这两个值相加。第二个函数返回一个boolean值:如果我们赢得通过投注,则返回true,否则返回false。该函数有几个return语句。一旦执行第一个语句,函数将终止并返回给定的返回值。**音阶。**使用函数
note(),编写一个程序 Scale.java 来演奏一个大调音阶。**素数测试。**创建一个名为
isPrime()的函数,它接受一个整数参数 N,并根据 N 是否为素数返回 true 或 false。public static boolean isPrime(long N) { if (N < 2) return false; for (long i = 2; i*i <= N; i++) { if (N % i == 0) return false; } return true; }**电子资金转账路由号检查。**给定一个 9 位 EFT 路由号 a[1]a[2]a[3]a[4]a[5]a[6]a[7]a[8]a[9],检查方程式为
3 a[1] + 7a[2] + a[3] + 3a[4] + 7a[5] + a[6] +3a[7] +7a[8] +a[9] mod 10 = 0
编写一个静态方法
nint(),它接受一个实数作为参数,并返回最接近的整数。不要使用任何 Math 库函数,而是使用强制转换。编写一个静态方法
int mod(int a, int n),其中a是整数,n是正整数,并返回模 n。当a为正数时,这对应于a % n,但如果a为负数,则a % n返回一个非正整数。现值。
编写一个名为
fv的方法,计算如果您以复利利率 r 每期投资 C 美元,T 期后您将拥有的金额。未来价值的公式为 C*(1 + r)^T。编写一个名为
pv的方法,计算现在必须投资多少金额,以每期复利利率 r 获得 T 期内的现金流。现值的公式为 C/(1 + r)^T。
ACT 是另一种标准化测试。假设测试分数遵循均值为 18,标准差为 6 的高斯分布。还假设参加 SAT 和 ACT 考试的考生是无法区分的。620 分的 SAT 分数和 26 分的 ACT 哪个更好?
编写一个程序 Factorial.java,接受一个整数命令行输入 n,并打印出 n! = 1 * 2 * ... * n。编写一个具有以下签名的函数:
public static long factorial(int n)您的函数能处理的最大 n 值是多少,而不会发生溢出?
以下函数有什么问题?
static int sum(int n) { if (n < 0) return; double sum = 0.0; for (int i = 0; i < n; i++) sum = sum + i; return sum; }答案:该函数声明要返回类型为
int的值。第一个返回语句是错误的,因为它不返回任何内容。第二个返回语句是错误的,因为它返回类型为double的值。以下代码是做什么的?
public static void negate(int a) { a = -a; } public static int main(String[] args) { int a = 17; System.out.println(a); negate(a); System.out.println(a); }答案:它两次打印
17。一个函数无法改变另一个函数中的基本类型变量的值。编写一个函数,接受三个实数参数 x、y 和 s,并绘制以(x, y)为中心,边长为 s 的等边三角形。在
main中多次调用该函数以产生一个有趣的图案。以下函数中哪个返回其四个输入中的最小值?哪个最容易理解和验证其正确性?
public static int min(int a, int b, int c, int d) { // if a is the smallest return it if (a <= b && a <= c && a <= d) return a; // otherwise, if b is the smallest of b, c, and d, return it if (b <= c && b <= d) return b; // otherwise, return the smaller of c and d if (c <= d) return c; return d; } public static int min(int a, int b, int c, int d) { int min = a; if (b < min) min = b; if (c < min) min = c; if (d < min) min = d; return min; } public static int min(int a, int b, int c, int d) { if (a < b && a < c && a < d) return a; if (b < c && b < d) return b; if (c < d) return c; return d; } public static int min(int a, int b, int c, int d) { if (a <= b) { if (a <= c) { if (a <= d) return a; else return d; } if (c <= d) return c; else return d; } if (b <= c) { if (b <= d) return b; else return d; } else if (c <= d) return c; return d; } public static int min(int a, int b) { if (a <= b) return a; else return b; } public static int min(int a, int b, int c, int d) { return min(min(a, b), min(c, d)); }您将如何测试前一个练习中的函数是否按照其所声称的那样工作?
答案:你无法希望在每种可能的输入上进行测试,因为有 2¹²⁸种不同的可能输入。相反,根据 a < b,a < c,...,c < d 的 24 种情况中的每一种,测试所有 4!= 24 种情况。或者测试 0、1、2 和 3 的所有 4⁴ 种排列。
以下方法调用有什么问题?
double y = 2.0; double x = sqrt(double y);在调用
sqrt()时重新声明变量y。锯齿波。 编写一个程序 SawTooth.java 来绘制 2/pi [sin(1t)/1 + sint(2t)/2 + sin(3t)/3 + ... ]。随着绘制更多项,波形会收敛到一个 锯齿波。然后使用标准音频播放它。
方波。 绘制 4/pi [sin(12pit)/1 + sint(32pit)/3 + sin(52pi*t)/5 + ... ]。随着绘制更多项,波形会收敛到一个 方波。然后使用标准音频播放它。
编写一个程序来打印 Old McDonald 的歌词。
public static String sing(String animals, String sound) { String s = ""; s += "Old MacDonald had a farm\n"; s += "E-I-E-I-O\n"; s += "And on his farm he had some " + animals + "\n"; s += "With a " + sound + "-" + sound + " here\n"; s += "And a " + sound + "-" + sound + " there\n"; s += "Here a " + sound + ", there a " + sound + "\n"; s += "Everywhere a + sound + "-" + sound + "\n"; s += "Old MacDonald had a farm\n"; s += "E-I-E-I-O\n"; return s; } ... System.out.println(sing("chicks", "cluck")); System.out.println(sing("cows", "moo")); System.out.println(sing("pigs", "oink")); System.out.println(sing("cats", "meow")); System.out.println(sing("sheep", "baa")); System.out.println(sing("ducks", "quack")); System.out.println(sing("horses", "neigh"));编写一个静态方法
maxwellBoltzmann(),它返回一个从 Maxwell-Boltzmann 分布 中伪随机值,参数为 σ。为了生成这样的值,取三个均值为 0,标准差为 σ 的高斯随机变量的平方和,然后返回平方根。理想气体中分子的速度具有 Maxwell-Boltzmann 分布,其中参数 σ 与 XYZ 有关。编写一个静态方法
reverse1(),它以字符串数组作为参数,并创建一个按相反顺序排列的新数组(不改变参数数组中的顺序)。编写一个静态方法reverse2(),它以字符串数组作为参数,并颠倒其条目。将你的代码放在一个名为 Reverse.java 的程序中。当我有两个重载函数时,
f(1, 2)调用哪个函数?public static void f(int x, double y) { System.out.println("f(int, double)"); } public static void f(double x, int y) { System.out.println("f(double, int)"); }解决方案。通常,Java 的类型提升规则会将任一
int参数提升为double。然而,在这种情况下,这将导致两个匹配的重载签名。由于 Java 无法解决这种歧义,Overloaded.java 导致编译时错误。高斯随机值。 尝试使用以下方法生成服从高斯分布的随机变量,该方法基于在单位圆内生成一个随机点,并使用一种 Box-Muller 变换 的形式。(参见练习 1.2.27 和第 1.3 节末尾关于 do-while 的讨论)。
public static double gaussian() { double r, x, y; do { x = uniform(-1.0, 1.0); y = uniform(-1.0, 1.0); r = x*x + y*y; } while (r >= 1 || r == 0); return x * Math.sqrt(-2.0 * Math.log(r) / r); }接受一个命令行参数 N,并生成 N 个随机数,使用数组 a[20] 计算生成的数字落在 i*.05 和 (i+1)*.05 之间的次数,其中 i 从 0 到 19。然后使用
StdDraw绘制这些值,并将结果与正态钟形曲线进行比较。备注:这种方法在效率和准确性上优于练习 XYZ 中描述的方法。虽然涉及循环,但 do-while 循环平均只执行 4 / π = 1.273 次。这减少了对超越函数的整体预期调用次数。
2.2 库和客户端
原文:
introcs.cs.princeton.edu/java/22library译者:飞龙
您编写的每个程序都包含在一个.java文件中的 Java 代码。对于大型程序,将所有代码放在一个文件中是受限制和不必要的。幸运的是,在 Java 中很容易引用另一个文件中定义的方法。这种能力对我们的编程风格有两个重要的影响:
它允许我们通过开发静态方法库来扩展 Java 语言,供任何其他程序使用,每个库都保存在自己的文件中。
它实现了模块化编程,我们将程序分成静态方法,以某种逻辑方式进行分组。
在其他程序中使用静态方法。
要引用另一个类中定义的静态方法,您必须使 Java 可以访问这两个类(例如,通过将它们都放在计算机上的同一目录中)。然后,要调用方法,请在其类名和句点分隔符之前添加。例如,SAT.java 调用 Gaussian.java 中的cdf()方法,后者调用pdf()方法,后者调用Math中的exp()和sqrt()方法。
我们描述了有关该过程的几个细节。
public 关键字。
public修饰符将方法标识为可供任何其他程序使用。您也可以将方法标识为private,但在此时您没有理由这样做。每个模块都是一个类。 我们使用术语模块来指代我们在单个文件中保留的所有代码。按照惯例,每个模块都是一个 Java 类,保存在与类同名但具有
.java扩展名的文件中。在本章中,每个类仅仅是一组静态方法。.class 文件。 当您编译程序时,Java 编译器会生成一个以类名开头,后跟
.class扩展名的文件,其中包含您的程序代码,以适合计算机的语言编写。必要时编译。 当您编译程序时,Java 通常会编译运行程序所需的所有内容。例如,当您键入
javac SAT.java时,编译器还会检查自上次编译以来是否修改了Gaussian.java。如果是这样,它还会编译Gaussian。多个
main()方法。 SAT.java 和 Gaussian.java 都有自己的main()方法。当您键入java后跟类名时,Java 将控制转移到该类中定义的main()方法对应的机器代码。

库。
我们将那些方法主要用于许多其他程序的模块称为库。
客户端。 我们使用术语客户端来指代调用给定库方法的程序。
API。 程序员通常以客户端和实现之间的合同来思考,这是方法应该执行的明确规范。
实现。 我们使用术语实现来描述实现 API 中方法的 Java 代码。
例如,Gaussian.java 是以下 API 的实现:
随机数。
StdRandom.java 是一个用于从各种分布生成随机数的库。
数组的输入和输出。
StdArrayIO.java 是一个用于从标准输入读取原始类型数组并将其打印到标准输出的库。
迭代函数系统。
迭代函数系统(IFS)是生成分形图像如谢尔宾斯基三角形或巴恩斯利蕨的一般方法。作为第一个例子,考虑以下简单过程:从等边三角形的一个顶点开始绘制一个点。然后随机选择三个顶点中的一个,并在刚刚绘制的点与该顶点之间的中点处绘制一个新点。继续执行相同的操作。
Sierpinski.java 模拟这一过程。以下是 1,000、10,000 和 100,000 步后的快照。
IFS.java 是一个模拟这一过程的数据驱动版本程序的通用化。您可以在输入 sierpinski.txt、barnsley.txt、tree.txt 和 coral.txt 上运行它。
标准统计。
StdStats.java 是一个用于统计计算和基本可视化的库。
伯努利试验。
Bernoulli.java 计算在抛掷公平硬币n次时找到正面的次数,并将结果与预测的高斯分布函数进行比较。根据中心极限定理,得到的直方图极好地近似于均值为n/2,方差为n/4 的高斯分布。
练习
在 Gaussian.java 中添加一个实现了三个参数的静态方法
pdf(x, mu, sigma),该方法根据给定的均值μ和标准差σ计算高斯概率密度函数,公式为(\phi(x, \mu, \sigma)) = (\phi((x - \mu) / \sigma) / \sigma)。还添加一个实现了相关累积分布函数cdf(z, mu, sigma)的方法,公式为(\Phi(z, \mu, \sigma)) = (\Phi((z - \mu) / \sigma))。编写一个静态方法库 Hyperbolic.java,根据定义(\sinh(x) = (ex - e{-x}) / 2)和(\cosh(x) = (ex + e{-x}) / 2),实现双曲函数,其中(\tanh(x))、(\coth(x))、(\text(x))和(\text(x))的定义方式类似于标准三角函数。
在 StdRandom.java 中添加一个方法
shuffle(),该方法以double值数组作为参数,并以随机顺序重新排列它们。实现一个测试客户端,检查数组的每个排列大致相同次数地产生。添加重载方法,接受整数和字符串数组。开发一个完整的 StdArrayIO.java 实现(实现 API 中指示的所有 12 个方法)。
编写一个实现以下 API 的库 Matrix.java:
![矩阵 API]()
编写一个 Matrix.java 客户端 MarkovSquaring.java,实现第 1.6 节中描述的 Markov.java 版本,但是基于矩阵的平方,而不是迭代向量-矩阵乘法。
创意练习
Sicherman 骰子。 假设你有两个六面骰子,一个面标有 1、3、4、5、6 和 8,另一个面标有 1、2、2、3、3 和 4。编写一个程序 Sicherman.java 来比较每个骰子和标准骰子的和值出现概率。使用
StdRandom和StdStats。解决方案:具有这些属性的骰子称为Sicherman 骰子:它们产生与常规骰子相同频率的和(2 的概率为 1/36,3 的概率为 2/36,依此类推)。
网络练习
样本标准差。 一系列 n 个观测值的样本标准差类似于标准差,只是我们除以n−1 而不是n。添加一个计算这个量的方法
sampleStddev()。Barnsley 蕨类植物。 编写一个程序 Barnsley.java,接受一个命令行参数 N,并根据以下规则绘制 N 个点的序列。设(x, y) = (0.5, 0)。然后根据给定的概率将(x, y)更新为以下四个量之一。
概率 新 X 新 Y 2% 0.5 0.27y 15% -0.139x + 0.263y + 0.57 0.246x + 0.224y - 0.036 13% 0.170x - 0.215y + 0.408 0.222x + 0.176y + 0.0893 70% 0.781x + 0.034y + 0.1075 -0.032x + 0.739y + 0.27 下面的图片显示了 500、1000 和 10,000 次迭代后的结果。
![Barnsley fern]()
![Barnsley fern]()
![Barnsley fern]()
Black-Scholes。 Black-Scholes模型预测时间 t 时的资产价格将为 S' = S exp { (rt - 0.5sigma²t + sigma ε sqrt(t) },其中 epsilon 是标准高斯随机变量。可以使用蒙特卡洛模拟来估计。要估算时间 T 时期权的价值,计算 max(S' - X, 0)并在 epsilon 的许多试验中取平均值。今天期权的价值为 e^-rT * 平均值。欧式看跌期权= max(X - S', 0)。重用函数。将程序命名为 BlackScholes.java。参见练习 2.1.30 中针对此情况的精确公式。
模拟。 应用:某种使用
StdRandom和StdStats翻转硬币并分析均值/方差的模拟。[例如:基于Black-Scholes 对冲模拟的物理、金融模拟。模拟需要定价依赖于价格路径而不仅仅是到期时间 T 的期权。例如:亚洲平均价格看涨期权= max(0, S_bar - X),其中 S_bar 是从时间 0 到 T 资产的平均价格。回顾期权 = max(0, S(T) - min_t S_t)。思路:将时间离散化为 N 个期间。] 另一个参考资料 将模拟分解为各种封装为函数的部分。火焰分形。 实现 IFS 的一般化,以生成类似 Water Lilies 的分形火焰,由Roger Johnston。火焰分形与经典 IFS 不同,它使用非线性更新函数(正弦、球形、漩涡、马蹄铁),使用对数密度显示根据它们导致过程的次数对像素进行着色,并根据应用哪个规则到达该点来加入颜色。
球面上的随机点。 使用
StdRandom.gaussian()生成球面或超球面表面上的随机点,方法如下:从高斯分布生成 N 个随机值 x[0],...,x[N-1]。然后(x[0]/scale, ..., x[N-1]/scale)是 N 维球面上的随机点,其中 scale = sqrt(x[0]² + ... + x[N-1]²)。优惠券收集者。 编写一个模块化程序 CouponExperiment.java,运行实验来估计优惠券收集者问题中感兴趣的数量的值。将您的程序的实验结果与数学分析进行比较,数学分析表明,在找到所有 N 个值之前收集的优惠券的期望数量应该大约是 N 倍第 N 个调和数(1 + 1/2 + 1/3 + ... + 1/N),标准偏差应该大约是 N π / sqrt(6)。
指数分布。 在 StdRandom.java 中添加一个名为
exp()的方法,该方法接受一个参数λ,并返回一个以速率λ为参数的指数分布中的随机数。提示:如果x是在 0 和 1 之间均匀分布的随机数,则-ln x / λ是从速率λ的指数分布中的随机数。
2.3 递归
原文:
introcs.cs.princeton.edu/java/23recursion译者:飞龙
从另一个函数调用一个函数的想法立即暗示了函数调用自身的可能性。Java 中的函数调用机制支持这种可能性,这被称为递归。
您的第一个递归程序。
递归的“Hello, World”是阶乘函数,它由正整数n的方程式定义
\(n! = n \times (n-1) \times (n-2) \times \; \ldots \; \times 2 \times 1\)
数量n!可以通过for循环轻松计算,但在 Factorial.java 中更简单的方法是使用以下递归函数:
public static long factorial(int n) {
if (n == 1) return 1;
return n * factorial(n-1);
}
我们可以以与跟踪任何函数调用序列相同的方式跟踪此计算。
factorial(5)
factorial(4)
factorial(3)
factorial(2)
factorial(1)
return 1
return 2*1 = 2
return 3*2 = 6
return 4*6 = 24
return 5*24 = 120
我们的factorial()实现展示了每个递归函数所需的两个主要组成部分。
基本情况 在不进行任何后续递归调用的情况下返回一个值。它为一个或多个特殊输入值提供了函数可以在没有递归的情况下进行评估的值。对于
factorial(),基本情况是n = 1。减少步骤 是递归函数的核心部分。它将一个(或多个)输入值处函数的值与另一个(或多个)输入值处函数的值联系起来。此外,输入值序列必须收敛到基本情况。对于
factorial(),n的值每次调用都减少 1,因此输入值序列收敛到基本情况。
数学归纳法。
递归编程与数学归纳法直接相关,数学归纳法是一种证明关于自然数的事实的技术。通过数学归纳法证明涉及整数n的语句对于无限多个n值为真涉及以下两个步骤:
基本情况:证明某些特定值或值的n(通常为 0 或 1)为真。
归纳步骤:假设对于所有小于n的正整数该语句为真,然后使用该事实证明对于n为真。
这样的证明足以表明该语句对于无限多个n值为真:我们可以从基本情况开始,并使用我们的证明逐个为每个更大的n值证明该语句为真。
欧几里得算法。
两个正整数的最大公约数(gcd)是能够均匀整除它们的最大整数。例如,gcd(102, 68) = 34。
我们可以使用以下性质高效地计算最大公约数 gcd,该性质对正整数p和q成立:
如果p > q,则p和q的 gcd 与q和p % q的 gcd 相同。
Euclid.java 中的静态方法gcd()是一个紧凑的递归函数,其减少步骤基于此性质。
gcd(1440, 408)
gcd(408, 216)
gcd(216, 192)
gcd(192, 24)
gcd(24, 0)
return 24
return 24
return 24
return 24
return 24
汉诺塔。
在汉诺塔问题中,我们有三根杆和n个可以放在杆上的圆盘。这些圆盘大小不同,最初堆叠在一根杆上,从最大的圆盘n(底部)到最小的圆盘 1(顶部)。任务是将所有n个圆盘移动到另一根杆上,同时遵守以下规则:
一次只移动一个圆盘。
永远不要将一个较大的圆盘放在一个较小的圆盘上。
递归提供了我们需要的计划:首先将顶部的n−1 个圆盘移动到一个空柱子上,然后将最大的圆盘移动到另一个空柱子上,然后通过将n−1 个圆盘移动到最大的圆盘上来完成工作。TowersOfHanoi.java 是这种策略的直接实现。
指数时间。
设 T(n)是 TowersOfHanoi.java 发出的移动指令数量,用于将n个圆盘从一个柱子移动到另一个柱子。那么,T(n)必须满足以下方程:
\(T(n) = 2T(n-1) + 1 \text{ for } n > 1, \text{ with } T(1) = 1\)
这样的方程在离散数学中被称为递归关系。我们经常可以使用它们来推导出所关注的数量的闭合形式表达式。例如,T(1) = 1, T(2) = 3, T(3) = 7, T(4) = 15。一般来说,T(n) = 2^(n) − 1。假设僧侣们每秒移动一个圆盘,他们需要超过 58 亿个世纪才能解决 64 个圆盘问题。
格雷码。
n位格雷码是 2^(n)个不同的n位二进制数的列表,使得列表中的每个条目与其前一个条目在恰好一个位上不同。n位二进制反射格雷码的定义如下:
n−1 位编码,每个单词前面加 0,然后是
n−1 位编码按相反顺序排列,每个单词前面加 1。
0 位编码被定义为空,因此 1 位编码是 0 后跟 1\。
![]() |
![]() |
|---|
Beckett.java 使用n位格雷码打印舞台指令,用于n个字符的戏剧,使得角色一个接一个地进入和退出,以便舞台上的每个角色子集恰好出现一次。
递归图形。
简单的递归绘图方案可能导致非常复杂的图片。例如,n阶H 树的定义如下:当n = 0 时,基本情况为空。减少步骤是在形状为字母 H 的单位正方形内绘制三条线,四个n − 1 阶 H 树,每个 H 树与 H 的每个尖端连接,附加条件是n − 1 阶 H 树位于正方形的四个象限中心,尺寸减半。
![]() |
![]() |
![]() |
![]() |
![]() |
|---|
Htree.java 接受一个命令行参数n,并绘制一个n阶 H 树到标准绘图。H 树是分形的一个简单示例:一个几何形状,可以被分成部分,每个部分(大致)是原始形状的缩小副本。
布朗桥。
Brownian.java 生成一个函数图,近似于称为布朗桥的分数布朗运动的简单示例。您可以将这个图形看作是连接两个点(x[0], y[0])和(x[1], y[1])的随机行走,由几个参数控制。该实现基于中点位移法,这是一个递归绘制绘图的计划,位于x区间[x[0], x[1]]内。基本情况(当间隔的大小小于给定的容差时)是绘制连接两个端点的直线。减少情况是将间隔分成两半,然后继续如下操作:
计算间隔的中点(x[m],y[m])。
将中点的y坐标*y[m]*增加一个从均值为 0 且给定方差的高斯分布中抽取的随机值δ。
在子间隔上进行递归,通过给定的缩放因子s来减少方差。
曲线的形状由两个参数控制:波动性(方差的初始值)控制图形偏离连接点的直线的距离,赫斯特指数控制曲线的平滑度。
递归的陷阱。
通过递归,你可以编写简洁而优雅的程序,在运行时却失败得令人瞠目结舌。
缺少基本情况。 NoBaseCase.java 中的递归函数应该计算调和数,但缺少一个基本情况:
public static double harmonic(int n) { return harmonic(n-1) + 1.0/n; }如果调用这个函数,它将不断调用自身而永远不会返回。
��有收敛的保证。 另一个常见问题是在递归函数中包含一个递归调用来解决一个不比原问题更小的子问题。例如,NoConvergence.java 中的递归函数对于其参数的任何值(除了 1)都会进入无限递归循环。
public static double harmonic(int n) { if (n == 1) return 1.0; return harmonic(n) + 1.0/n; }过度内存需求。 如果一个函数在返回之前递归调用自身过多次数,Java 需要的内存来跟踪递归调用可能是不可接受的。ExcessiveMemory.java 中的递归函数正确计算第 n 个调和数。然而,用一个巨大的
n值调用它将导致StackOverflowError。public static double harmonic(int n) { if (n == 0) return 0.0; return harmonic(n-1) + 1.0/n; }过度重复计算。
写一个简单的递归程序解决问题的诱惑必须始终受到这样的理解的限制,即简单程序可能需要指数时间(不必要地),因为存在过度重复计算。例如,斐波那契数列0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, ...
由公式定义
\(F_n = F_{n-1} + F_{n-2} \text{ for } n \ge 2, \text{ with } F_0 = 0 \text{ and } F_1 = 1\)
一个初学者程序员可能会实现这个递归函数来计算斐波那契数列中的数字,就像 Fibonacci.java 中所示的那样:
// Warning: spectacularly inefficient. public static long fibonacci(int n) { if (n == 0) return 0; if (n == 1) return 1; return fibonacci(n-1) + fibonacci(n-2); }然而,这个程序效率极低!要看出为什么这样做是徒劳的,考虑一下这个函数如何计算
fibonacci(8) = 21。它首先计算fibonacci(7) = 13和fibonacci(6) = 8。为了计算fibonacci(7),它递归计算fibonacci(6) = 8再次和fibonacci(5) = 5。事情迅速变得更糟。这个程序在计算fibonacci(n)时计算fibonacci(1)的次数恰好是F[n]。
动态规划。
实现递归程序的一般方法,动态规划的基本思想是将一个复杂问题递归地分解为若干较简单的子问题;存储每个子问题的答案;最终使用存储的答案来解决原始问题。通过仅解决每个子问题一次(而不是一遍又一遍),这种技术避免了运行时间的潜在指数级增长。
自顶向下动态规划。 在自顶向下动态规划中,我们存储或缓存我们解决的每个子问题的结果,这样下次我们需要解决相同的子问题时,我们可以使用缓存的值而不是从头开始解决子问题。TopDownFibonacci.java 演示了用于计算斐波那契数的自顶向下动态规划。
![自顶向下动态规划]()
自底向上动态规划。 在自底向上动态规划中,我们计算所有子问题的解,从“最简单”的子问题开始,逐渐构建更复杂子问题的解。BottomUpFibonacci.java 演示了用于计算斐波那契数的自底向上动态规划。
public static long fibonacci(int n) { long[] f = new long[n+1]; f[0] = 0; f[1] = 1; for (int i = 2; i <= n; i++) f[i] = f[i-1] + f[i-2]; return f[n]; }最长公共子序列问题。 给定两个字符串x和y,我们希望计算它们的<em.longest common="" subsequence="">(LCS)。如果我们从x中删除一些字符,从y中删除一些字符,得到的两个字符串相等,我们称结果字符串为公共子序列。LCS 问题是找到两个字符串的一个尽可能长的公共子序列。例如,
GGCACCACG和ACGGCGGATACG的 LCS 是GGCAACG,一个长度为 7 的字符串。- - G G C - - A - C C A C G A C G G C G G A T - - A C G ```</em.longest>最长公共子序列递归。 现在我们描述一个递归公式,使我们能够找到给定字符串
s和t的 LCS。设m和n分别为s和t的长度。我们使用符号s[i..m)表示s从索引i开始的后缀,使用t[j..n)表示从索引j开始的t的后缀。如果
s和t以相同字符开头,则s和t的 LCS 包含该第一个字符。因此,我们的问题简化为找到后缀s[1..m)和t[1..n)的 LCS。如果
s和t以不同字符开头,则两个字符都不能成为公共子序列的一部分,因此可以安全地丢弃其中一个。在任何一种情况下,问题都简化为找到两个字符串的 LCS——要么s[0..m)和t[1..n),要么s[1..m)和t[0..n)。
一般来说,如果我们让
opt[i][j]表示后缀s[i..m)和t[j..n)的 LCS 的长度,则以下递归成立:opt[i][j] = 0 if i = m or j = n = opt[i+1][j+1] + 1 if s[i] = t[j] = max(opt[i][j+1], opt[i+1][j]) otherwise*动态规划解决方案。*LongestCommonSubsequence.java 从底向上的动态规划方法开始解决这个递归。
![最长公共子序列]()
最终挑战是恢复最长公共子序列本身,而不仅仅是其长度。关键思想是向后重新跟踪动态规划算法的步骤,从
opt[0][0]到opt[m][n]重新发现选择路径(在图中用灰色突出显示)。为了确定导致opt[i][j]的选择,我们考虑三种可能性:字符 s[i]匹配t[j]。在这种情况下,我们必须有opt[i][j]=opt[i+1][j+1]+ 1,并且 LCS 中的下一个字符是s[i]。我们继续从opt[i+1][j+1]回溯。LCS 不包含
s[i]。在这种情况下,opt[i][j]=opt[i+1][j],我们继续从opt[i+1][j]回溯。LCS 不包含
t[j]。在这种情况下,opt[i][j]=opt[i][j+1],我们继续从opt[i][j+1]回溯。
练习
给定四个正整数
a、b、c和d,解释gcd(gcd(a, b), gcd(c, d))计算的值是什么。解决方案:
a、b、c和d的最大公约数。用整数和除数的术语解释以下类似于欧几里得函数的效果。
public static boolean gcdlike(int p, int q) { if (q == 0) return (p == 1); return gcdlike(q, p % q); }解决方案:返回
p和q是否互质。考虑以下递归函数。
public static int mystery(int a, int b) { if (b == 0) return 0; if (b % 2 == 0) return mystery(a+a, b/2); return mystery(a+a, b/2) + a; }mystery(2, 25)和mystery(3, 11)的值是多少?给定正整数a和b,描述mystery(a, b)计算的值。用*替换+,用return 1替换return 0后,回答相同的问题。解决方案:50 和 33。它计算 a*b。如果你用
*替换+,它计算 a^b。编写一个程序 AnimatedHtree.java,用于动画绘制 H 树。
![动画 H 树]()
接下来,重新排列递归调用的顺序(和基本情况),查看生成的动画,并解释每个结果。
创意练习
二进制表示。 编写一个程序 IntegerToBinary.java,以十进制正整数n作为命令行参数,并打印其二进制表示。回想一下,在 Binary.java 中,我们使用了减去 2 的幂的方法。现在,使用以下更简单的方法:重复地将 2 除以n,并倒序读取余数。首先,编写一个
while循环来执行这个计算并以错误顺序打印位。然后,使用递归以正确顺序打印位。排列。 编写一个程序 Permutations.java,接受一个整数命令行参数 n,并打印出以
a开头的 n 个字母的 n! 排列。n 元素的排列是元素的 n! 种可能排序之一。例如,当 n = 3 时,您应该得到以下输出(但不必担心枚举它们的顺序):bca cba cab acb bac abc ```</m>大小为 k 的排列。 编写一个程序 PermutationsK.java,接受两个命令行参数 n 和 k,并打印出包含恰好 k 个 n 元素的排列的数量 (P(n, k) = \frac{n!}{(n-k)!})。当 k = 2 且 n = 4 时,以下是期望的输出(再次,不必担心顺序):
ab ac ad ba bc bd ca cb cd da db dc组合。 编写一个程序 Combinations.java,接受一个整数命令行参数 n,并打印出任意大小的 2^(n) 组合。组合 是 n 元素的子集,与顺序无关。例如,当 n = 3 时,您应该得到以下输出:
a ab abc ac b bc c请注意,您的程序需要打印空字符串(大小为 0 的子集)。
大小为 k 的组合。 编写一个程序 CombinationsK.java,接受两个命令行参数 n 和 k,并打印出大小为 k 的组合的数量 (C(n, k) = \frac{n!}{k! (n-k)!})。例如,当 n = 5 且 k = 3 时,您应该得到以下输出:
abc abd abe acd ace ade bcd bce bde cde使用数组而不是字符串的替代解决方案:Comb2.java。
递归方块。 编写一个程序来生成以下递归图案。方块大小的比例为 2.2:1。要绘制阴影方块,请先绘制填充的灰色方块,然后是未填充的黑色方块。
![递归方块]()
RecursiveSquares.java 提供了第一个模式的解决方案。
格雷码。 修改 Beckett.java 以打印格雷码(而不仅仅是变化的位位置序列)。
解决方案:GrayCode.java 使用了 Java 的字符串数据类型;GrayCodeArray.java 使用了布尔数组。
汉诺塔动画。 编写一个程序 AnimatedHanoi.java,使用
StdDraw来动画显示解决汉诺塔问题的过程,每秒移动一个盘子。Collatz 函数。 考虑以下递归函数 Collatz.java,它与数论中一个著名的未解决问题相关,即 Collatz 问题 或 3n + 1 问题。
public static void collatz(int n) { StdOut.print(n + " "); if (n == 1) return; if (n % 2 == 0) collatz(n / 2); else collatz(3*n + 1); }例如,调用
collatz(7)打印出序列7 22 11 34 17 52 26 13 40 20 10 5 16 8 4 2 1由于 17 次递归调用的结果。编写一个程序,接受一个命令行参数
n,并返回使得collatz(i)的递归调用次数最大化的i < n的值。提示:使用记忆化。未解决的问题是没有人知道该函数对所有整数是否终止(数学归纳法无助,因为其中一个递归调用是针对参数的较大值)。布朗岛。 B. 曼德布罗特提出了著名问题 英国海岸有多长? 修改 Brownian.java 以获得一个程序 BrownianIsland.java,绘制出类似于大不列颠岛的 布朗岛。修改很简单:首先,将
curve()更改为在 x 坐标和 y 坐标上添加随机高斯数;其次,将main()更改为从画布中心的点绘制一条曲线回到自身。尝试使用各种参数值,使您的程序产生外观逼真的岛屿。![布朗岛]()
等离子云。 编写一个递归程序 PlasmaCloud.java 来绘制等离子云,使用文本中建议的方法。
![等离子云]()
一个奇怪的函数。 考虑麦卡锡的 91 函数:
public static int mcCarthy(int n) { if (n > 100) return n - 10; else return mcCarthy(mcCarthy(n+11)); }确定
mcCarthy(50)的值,不使用计算机。给出mcCarthy()用于计算此结果的递归调用次数。证明对于所有正整数n都会达到基本情况,或找到一个值n,使得此函数进入递归循环。递归树。 编写一个程序 Tree.java,它接受一个命令行参数
n,并为n等于 1、2、3、4 和 8 生成以下递归模式。![递归树]()
网络练习
如果输入可以是负数,Euclid.java 仍然有效吗?如果不是,请修复它。提示:回想一下,如果第一个输入是负数,% 可以返回一个负整数。在调用函数时,取两个输入的绝对值。
编写一个递归程序 GoldenRatio.java,它接受一个整数输入 N,并使用以下递归公式计算黄金比例的近似值:
f(N) = 1 if N = 0 = 1 + 1 / f(N-1) if N > 0重新做,但不使用递归。
发现黄金比例与斐波那契数之间的联系。提示:考虑连续斐波那契数的比率:2/1, 3/2, 8/5, 13/8, 21/13, 34/21, 55/34, 89/55, 144/89, ...
考虑以下递归函数。
mystery(1, 7)是什么?public static int mystery(int a, int b) { if (0 == b) return 0; else return a + mystery(a, b-1); }在上一个练习中,对于 0 到 100 之间的每对整数 a 和 b,函数是否会终止?给出对于 0 到 100 之间的整数 a 和 b,
mystery(a, b)返回的高级描述。答案:mystery(1, 7) = 1 + mystery(1, 6) = 1 + (1 + mystery(1, 5)) = ... 7 + mystery(1, 0) = 7。
答案:是的,基本情况是 b = 0。连续的递归调用将 b 减少 1,将其推向基本情况。函数
mystery(a, b)返回a * b。数学倾向的学生可以使用恒等式 ab = a + a(b-1) 通过归纳法证明这一事实。考虑以下函数。
mystery(0, 8)做什么?public static void mystery(int a, int b) { if (a != b) { int m = (a + b) / 2; mystery(a, m); StdOut.println(m); mystery(m, b); } }答案:无限循环。
考虑以下函数。
mystery(0, 8)做什么?public static void mystery(int a, int b) { if (a != b) { int m = (a + b) / 2; mystery(a, m - 1); StdOut.println(m); mystery(m + 1, b); } }答案:堆栈溢出。
重复上一个练习,但将
if (a != b)替换为if (a <= b)。mystery(0, 8)做什么?public static int mystery(int a, int b) { if (a == b) StdOut.println(a); else { int m1 = (a + b ) / 2; int m2 = (a + b + 1) / 2; mystery(a, m1); mystery(m2, b); } }以下函数计算什么?
public static int f(int n) { if (n == 0) return 0; if (n == 1) return 1; if (n == 2) return 1; return 2*f(n-2) + f(n-3);编写一个程序 Fibonacci2.java,它接受一个命令行参数 N,并使用以下替代定义打印出前 N 个斐波那契数:
F(n) = 1 if n = 1 or n = 2 = F((n+1)/2)2 + F((n-1)/2)2 if n is odd = F(n/2 + 1)2 - F(n/2 - 1)2 if n is even使用这个定义,在一分钟内你能计算出的最大斐波那契数是多少?将其与 Fibonacci.java 进行比较。
编写一个程序,它接受一个命令行参数 N,并使用Dijkstra 提出的方法打印出前 N 个斐波那契数。
F(0) = 0 F(1) = 1 F(2n-1) = F(n-1)² + F(n)² F(2n) = (2F(n-1) + F(n)) * F(n)通过数学归纳法证明前两个练习中给出的斐波那契函数的替代定义与原始定义等价。
编写一个程序
Pell.java,它接受一个命令行参数 N,并打印出前 N 个 Pell 数:p[0] = 0,p[1] = 1,对于 n >= 2,p[n] = 2 p[n-1] + p[n-2]。打印连续项的比率,并与 1 + sqrt(2) 进行比较。考虑来自程序 Recursion.java 的以下函数:
public static void mystery(int n) { if (n == 0 || n == 1) return; mystery(n-2); StdOut.println(n); mystery(n-1); }mystery(6)打印出什么?提示:首先弄清楚mystery(2),mystery(3)等打印出什么。如果基本情况被替换为以下语句,上一个练习会发生什么?
if (n == 0) return;考虑以下递归函数。
public static int square(int n) { if (n == 0) return 0; return square(n-1) + 2*n - 1; } public static int cube(int n) { if (n == 0) return 0; return cube(n-1) + 3*(square(n)) - 3*n + 1; }square(5)的值是多少?cube(5)?cube(123)?考虑下面一对相互递归的函数。
g(g(2))的求值结果是什么?public static int f(int n) { public static int g(int n) { if (n == 0) return 0; if (n == 0) return 0; return f(n-1) + g(n-1); return g(n-1) + f(n); } }编写程序验���(对于较小的 n 值),前 n 个斐波那契数的立方和 F(0)³ + F(1)³ + ... + F(n)³ 等于 (F(3n+4) + (-1)^n * 6 * f(n-1)) / 10,其中 F(0) = 1,F(1) = 1,F(2) = 2,依此类推。
通过递增和展开进行转换。 给定两个整数 a ≤ b,编写一个程序 Sequence.java,通过最小的递增(加 1)和展开(乘以 2)操作将 a 转换为 b。例如,
% java Sequence 5 23 23 = ((5 * 2 + 1) * 2 + 1) % java Sequence 11 113 113 = ((((11 + 1) + 1) + 1) * 2 * 2 * 2 + 1)哈达玛矩阵。 编写一个递归程序 Hadamard.java,接受一个命令行参数 n,并绘制一个 N×N 的哈达玛图案,其中 N = 2^n。不要使用数组。一个 1×1 的哈达玛图案是一个黑色的正方形。一般来说,一个 2N×2N 的哈达玛图案是通过将 N×N 图案的 4 个副本对齐成一个 2×2 网格的形式获得的,然后反转右下角 N×N 图案中所有方块的颜色。N×N 哈达玛 H(N) 矩阵是一个布尔矩阵,具有任意两行恰好相差 N/2 位的显著特性。这个特性使得它对设计纠错码非常有用。以下是前几个哈达玛矩阵。
![2x2 哈达玛矩阵图]()
![4x4 哈达玛矩阵图]()
![8x8 哈达玛矩阵图]()
![16x16 哈达玛矩阵图]()
8 皇后问题。 在这个练习中,您将解决经典的 8 皇后问题:在一个 8x8 的棋盘上放置 8 个皇后,使得没有两个皇后在同一行、列或对角线上。没有两个皇后放在同一行或列的方式有 8! = 40,320 种。整数 0 到 7 的任意排列 p[] 都可以给出这样的放置:将皇后 i 放在第 i 行,第 p[i] 列。您的程序 Queens.java 应该接受一个整数命令行参数 n,并通过绘制皇后的位置来列举解决 n 皇后问题的所有解决方案,就像下面的两个解决方案一样。
* * * Q * * * * * * * * Q * * * * Q * * * * * * * Q * * * * * * * * * * * * Q * * * * Q * * * * * * Q * * * * * * * * * * * Q * * * * * * Q * * * * Q * * * * * * * * * * * * Q * * * * * * * Q * * * * Q * * * * * * * * Q * * Q * * * * * * * Q * * * * * * *提示:确定设置 q[n] = i 是否与 q[0] 到 q[n-1] 冲突
如果 q[i] 等于 q[n]:两个皇后放在同一列
如果 q[i] - q[n] 等于 n - i:两个皇后在同一主对角线上
如果 q[n] - q[i] 等于 n - i:两个皇后在同一副对角线上
另一个 8 皇后问题求解器。 程序 Queens2.java 通过隐式枚举所有 n! 排列(而不是 n^n 放置)来解决 8 皇后问题。它基于程序 Permutations.java。
欧几里得算法和 π。 从一个大随机数集中选择两个数,它们没有公共因子(除了 1)的概率是 6 / π²。利用这个想法来估计 π。罗伯特·马修斯使用相同的想法,通过将星空中的星星位置作为一个函数来估计 π。
汉诺塔变种 II。(Knuth-Graham 和 Pathashnik)解决原始汉诺塔问题,但额外限制您不能直接将一个盘子从 A 移动到 C。解决具有 n 个盘子的问题需要多少步?提示:递归地将 A 到 C 的 n-1 个最小盘子移动(没有任何直接的 A 到 C 移动),将盘子 n 从 A 移动到 B,递归地将 C 到 A 的 n-1 个最小盘子移动(没有任何直接的 A 到 C 移动),将盘子 n 从 B 移动到 C,并递归地将 A 到 C 的 n-1 个最小盘子移动(没有任何直接的 A 到 C 移动)。
汉诺塔变种 III。 重复上一个问题,但不允许 A 到 C 和 C 到 A 的移动。也就是说,每次移动必须涉及柱子 B。
四根柱子的汉诺塔。 假设你有第四根柱子。从最左边的柱子转移一个由 8 个圆盘组成的堆到最右边的柱子需要的最少移动次数是多少?答案。在一百多年来,找到一般情况下最短的解决方案仍然是一个悬而未决的问题,被称为雷夫谜题。
另一个棘手的递归函数。 考虑以下递归函数。
f(0)是多少?public static int f(int x) { if (x > 1000) return x - 4; else return f(f(x+5)); }检查 n 是否为斐波那契数。 编写一个函数来检查 n 是否为斐波那契数。提示:当且仅当 (5nn + 4) 或 (5nn - 4) 是一个完全平方数时,正整数才是斐波那契数。
随机中缀表达式生成器。 使用不同的命令行参数 p 在 0 和 1 之间运行 RandomExpression.java。你观察到了什么?
public static String expr(double p) { double r = Math.random(); if (r <= 1*p) return "(" + expr(p) + " + " + expr(p) + ")"; if (r <= 2*p) return "(" + expr(p) + " * " + expr(p) + ")"; return "" + (int) (100 * Math.random()); }一个棘手的递归。 定义 F(n) 使得 F(0) = 0 并且 F(n) = n - F(F(n-1))。F(100000000)是多少?
冯·诺伊曼序数。 冯·诺伊曼整数 i 的定义如下:对于 i = 0,它是空集;对于 i > 0,它是包含冯·诺伊曼整数 0 到 i-1 的集合。
0 = {} = {} 1 = {0} = {{}} 2 = {0, 1} = {{}, {{}}} 3 = {0, 1, 2} = {{}, {{}}, {{}, {{}}}}编写一个程序 Ordinal.java,其中有一个递归函数
vonNeumann(),接受一个非负整数N并返回冯·诺伊曼整数 N 的字符串表示。这是集合论中定义序数的一种方法。字符串的子序列。 编写一个程序 Subsequence.java,接受一个字符串命令行参数
s和一个整数命令行参数k���并打印出长度为k的s的所有子序列。% java Subsequence abcd 3 abc abd acd bcd交错两个字符串。 给定两个不同字符的字符串
s和t,打印出所有 (M+N)! / (M! N!) 交错,其中 M 和 N 是两个字符串中字符的数量。例如,如果s = "ab" t = "CD" abCD CabD aCbD CaDb aCDb CDab二进制最大公约数。 编写一个程序 BinaryGCD.java,使用二进制最大公约数算法找到两个正整数的最大公约数:gcd(p, q) =
如果 q = 0,则为 p
如果 p = 0,则为 q
如果 p 和 q 都是偶数,则为 2 * gcd(p/2, q/2)
如果 p 是偶数且 q 是奇数,则为 gcd(p/2, q)
如果 p 是奇数且 q 是偶数,则为 gcd(p, q/2)
如果 p 和 q 都是奇数且 p >= q,则为 gcd((p-q)/2, q)
如果 p 和 q 都是奇数且 p < q,则为 gcd(p, (q-p)/2)
整数分区。 编写一个程序 Partition.java,将一个正整数 N 作为命令行参数,并打印出 N 的所有分区。分区是将 N 写成正整数之和的一种方式。如果两个和仅在其组成部分的顺序上有所不同,则认为它们是相同的。分区在数学和物理中的对称多项式和群表示理论中出现。
% java Partition 4 % java Partition 6 4 6 3 1 5 1 2 2 4 2 2 1 1 4 1 1 1 1 1 1 3 3 3 2 1 3 1 1 1 2 2 2 2 2 1 1 2 1 1 1 1 1 1 1 1 1 1约翰逊-特罗特排列。 编写一个程序 JohnsonTrotter.java,接受一个整数命令行参数 n,并以一种方式打印出整数 0 到 n-1 的所有 n! 排列,使得连续的排列仅在一个相邻的转位中有所不同(类似于格雷码在组合中迭代的方式,使得连续的组合仅在一个位上有所不同)。
% java JohnsonTrotter 3 012 (2 1) 021 (1 0) 201 (2 1) 210 (0 1) 120 (1 2) 102 (0 1)按字典顺序排列的排列。 编写一个程序 PermutationsLex.java,接受一个命令行参数 N,并按字典顺序打印出整数 0 到 N-1 的所有 N! 排列。
% java PermutationsLex 3 012 021 102 120 201 210错位排列。 错位排列是一个整数从 0 到 N-1 的排列
p[],使得对于任何 i,p[i]不等于 i。例如,当 N = 4 时有 9 个错位排列:1032, 1230, 1302, 2031, 2301, 2310, 3012, 3201, 3210。编写一个程序来计算大小为 N 的错位排列的数量,使用以下递推关系:d[N] = (N-1) (d[N-1] + d[N-2]),其中 d[1] = 0,d[2] = 1。前几项是 0, 1, 2, 9, 44, 265, 1854, 14833, 133496 和 1334961。Tribonacci 数。 Tribonacci 数类似于斐波那契数列,不同之处在于序列中的每一项是前三项的和。前几项是 0, 0, 1, 1, 2, 4, 7, 13, 24, 44, 81。编写一个计算 Tribonacci 数的程序。连续项的比率是多少?答案。x³ - x² - x - 1 的根,约为 1.83929。
前 n 个斐波那契数的和。 通过归纳证明,前 n 个斐波那契数 F(1) + F(2) + ... + F(N)的和是 F(N+2) - 1。
组合格雷码。 按照只有一个元素不同的方式打印出 k 个 n 项的所有组合,例如,如果 k = 3 且 n = 5,123, 134, 234, 124, 145, 245, 345, 135, 235, 125。提示:使用格雷码,但只打印出那些在其二进制表示中恰好有 k 个 1 的整数。
迷宫生成。 使用分而治之的方法创建一个迷宫:从一个没有墙壁的矩形区域开始。选择矩形中的一个随机网格点,并构建两条垂直墙壁,将正方形分成 4 个子区域。随机选择四个区域中的三个,并在每个区域中的一个随机点开一个单元的洞。递归直到每个子区域的宽度或高度为 1。
等离子云。 程序 PlasmaCloud.java 接受一个命令行参数 N,并使用中点位移法生成一个随机的 N×N 等离子分形。
![等离子云 1]()
![等离子云 2]()
![等离子云 3]()
这里有一个 800×800 的示例。这里有一个参考链接,包括一个简单的一维版本。注意:在 x 和 y 轴平行方向上会有一些视觉伪影。不具备 2D 分数布朗运动的所有统计特性。
蕨类分形。 编写一个递归程序来绘制蕨类或树,就像这个蕨类分形演示中所示。
��数集合划分。 使用记忆化开发一个解决正整数值集合划分问题的程序。可以使用一个大小为输入值之和的数组。
投票权力。 约翰·F·班扎夫三世提出了一个在分块投票系统中为每个联盟排名的系统。假设第 i 党控制 w[i]票。接受或拒绝提案需要严格多数的选票。第 i 党的投票权力是它可以加入的少数派联盟数量,使其成为获胜多数联盟。编写一个程序 VotingPower.java,接受一个联盟权重列表作为命令行参数,并打印出每个联盟的投票权力。提示:使用 Schedule.java 作为起点。
两台并行机器上的调度。 程序 Schedule.java 接受一个命令行参数 N,从标准输入读取 N 个实数,并将它们分成两组,使它们的差最小化。
霍夫斯塔德-康威 $10,000 序列。 考虑以下递归函数。f(n) = f(f(n-1)) + f(n-f(n-1)),对于 n > 2 且 f(1) = f(2) = 1。计算 f(3)。编写一个 Java 程序来计算Hofstadter–Conway $10,000 序列中 f(n) 的前 50 个值。使用动态规划。这个序列具有许多迷人的特性,并与帕斯卡三角形、高斯分布、斐波那契数和卡特兰数相关。
运行时间递归。 使用动态规划计算值表 T(N),其中 T(N) 是以下分治递归的解。T(1) = 0,如果 N > 1,则 T(N) = N + T(N/2) + T(N - N/2)。
加油站优化。 你正在驾驶一辆每加仑行驶 25 英里,油箱容量为 15 加仑的汽车从普林斯顿到旧金山。沿途有 N 个加油站可以停下加油。加油站 i 在旅程中的 d[i] 英里处,每加仑卖 p[i] 美元的汽油。如果你在加油站 i 停下加油,你必须完全加满油箱。假设你从满箱开始,d[i] 是整数。使用动态规划找到最少费���的停车顺序。
Unix diff。 Unix
diff程序逐行比较两个文件,并打印出它们不同的地方。编写一个程序 Diff.java,逐行读取命令行指定的两个文件,计算每个文件的组成行序列上的 LCS,并打印出与 LCS 中的非匹配对应的任何行。3 个字符串的最长公共子序列。 给定 3 个字符串,使用动态规划找到最长的公共子序列。你的算法的运行时间和内存使用情况是多少?
找零。 给定 A 张一百美元的钞票,B 张五十美元的钞票,C 张二十美元的钞票,D 张十美元的钞票,E 张五美元的钞票,F 张一美元的钞票,G 个半美元,H 个四分之一美元,I 个一角,J 个五分,和 K 个便士,确定是否可能找零 N 分。提示:背包问题。(贪心算法也可行。)
找零。 假设你是一个收银员,在一个货币面额为:1, 3, 8, 16, 22, 57, 103 和 526 分的奇怪国家。描述一个动态规划算法,使用最少数量的硬币找零 c 分。提示:贪心算法不起作用,因为找零 114 分的最佳方式是 57 + 57 而不是 103 + 8 + 3。
最长递增序列。 给定 N 个 64 位整数的数组,找到严格递增的最长子序列。
提示。 计算原始数组和去除整数重复副本的数组的排序版本之间的最长公共子序列。
最长公共递增序列。 计算生物学。给定两个 N 个 64 位整数的序列,找到两个序列中都存在的最长递增子序列。
带利润的活动选择。 工作 i 具有开始时间 s_i,结束时间 f_i 和利润 p_i。找到最佳的工作子集进行安排。
Diff。 编写一个程序,读取两个文件并打印出它们的 diff。将每行视为一个符号,并计算一个 LCS。打印出那些不在 LCS 中的每个文件的行。
背包问题。 Knapsack.java。
文本对齐。 编写一个程序,接受一个命令行参数 N,从标准输入读取文本,并以每行最多 N 个字符的方式打印出文本。使用动态规划。
维特比算法。 给定一个有向图,其中每条边都标有来自有限字母表的符号。是否有一条从一个特定顶点 x 开始的路径,与字符串 s 中的字符匹配?动态规划。A(i, v) = 0 或 1,如果有一条从 x 到 v 的路径消耗了 s 的前 i 个字符。A(i, v) = max (A(i-1, u) : (u, v) 在用 s[i] 标记的 E 中)。
Viterbi 算法。 语音识别,手写分析,计算生物学,隐马尔可夫模型。假设离开 v 的每条边都有概率 p(v, w) 被遍历。路径的概率是该路径上概率的乘积。什么是最有可能的路径?动态规划。
Smith–Waterman 算法。 局部序列比对。
二项式系数(暴力法)。 二项式系数 C(n, k) 是从 n 个元素的集合中选择 k 个元素的方式数。它在概率和统计中出现。计算二项式系数的一个公式是 C(n, k) = n! / (k! (n-k)!). 这个公式不太适合直接计算,因为中间结果可能会溢出,即使最终答案没有溢出。例如 C(100, 15) = 253338471349988640 可以适应 64 位
long,但 100! 的二进制表示有 525 位长。Pascal's identity 用较小的二项式系数表示 C(n, k):
![Pascal's identity]()
SlowBinomial.java 在中等规模的 n 或 k 下表现惨不忍睹,不是因为溢出,而是因为同样的子问题被重复解决。
// DO NOT RUN THIS CODE FOR LARGE INPUTS public static long binomial(int n, int k) { if (k == 0) return 1; if (n == 0) return 0; return binomial(n-1, k) + binomial(n-1, k-1); }二项式系数(动态规划)。 编写一个程序 Binomial.java,接受两个命令行参数 n 和 k,并使用自底向上的动态规划计算 C(n, k)。
2.4 案例研究:渗流
原文:
introcs.cs.princeton.edu/java/24percolation译者:飞龙
我们通过考虑开发一个解决有趣科学问题的程序的案例研究来结束我们对函数和模块的研究。作为研究一种称为渗流的自然模型的蒙特卡罗模拟。
渗流。
我们将系统建模为一个n×n的网格,每个站点要么是阻塞的,要么是开放的;开放的站点最初是空的。一个满站点是一个可以通过一系列相邻的(左、右、上、下)开放站点连接到顶行开放站点的开放站点。如果底行有一个满站点,那么我们说系统渗流了。
如果站点独立设置为以空缺概率 p开放,那么系统渗流的概率是多少?目前尚未推导出这个问题的数学解。我们的任务是编写计算机程序来帮助研究这个问题。
数据表示。
我们的第一步是选择数据的表示。我们使用一个布尔矩阵 isOpen[][] 表示哪些站点是开放的,另一个布尔矩阵 isFull[][] 表示哪些站点是满的。
垂直渗流。
给定表示开放站点的布尔矩阵,我们如何确定它是否代表一个渗流的系统?目前,我们将考虑一个我们称之为垂直渗流的问题的简化版本。简化是将注意力限制在垂直连接路径上。
VerticalPercolation.java 确定通过一些与顶部垂直连接的路径填充的站点,使用简单的计算。
数据可视化。
PercolationVisualizer.java 是一个测试客户端,生成随机布尔矩阵并使用标准绘图绘制它们。
估计概率。
PercolationProbability.java 估计了具有站点空缺概率p的随机n×n系统渗流的概率。我们将这个数量称为渗流概率。为了估计它的值,我们只需运行一些实验。
渗流的递归解决方案。
当任何从顶部开始并以底部结束的路径(不仅仅是垂直路径)都能完成任务时,我们如何测试系统是否渗流?令人惊讶的是,我们可以用一个基于经典递归方案的紧凑程序来解决这个问题,这个方案被称为深度优先搜索。Percolation.java 采用这种方法。有关详细信息,请参阅教科书。
自适应绘图。
PercolationPlot.java 将渗流概率作为站点空缺概率p的函数绘制为一个n×n系统。它使用一种递归方法,以相对较低的成本产生一个外观良好的曲线。
曲线支持这样一个假设:存在一个阈值值(约为 0.593):如果p大于阈值,则系统几乎肯定渗透;如果p小于阈值,则系统几乎肯定不会渗透。随着n的增加,曲线逼近一个在阈值处从 0 变为 1 的阶跃函数。这种现象,被称为相变,在许多物理系统中都能找到。
练习
- 创建一个程序 PercolationDirected.java,用于测试有向渗透(通过在递归
flow()方法中省略最后一个递归调用,如文本中所述),然后使用 PercolationPlot.java 绘制有向渗透概率作为站点空缺概率p的函数的图表。
创意练习
非递归有向渗透。 编写一个非递归程序 PercolationDirectedNonrecursive.java,通过像我们的垂直渗透代码中从顶部到底部移动来测试有向渗透。基于以下计算构建您的解决方案:如果当前行中一组连续的开放站点中的任何站点与上一行的某个满站点相连,则该子行中的所有站点都变为满。
![有向渗透]()
网络练习
2x2 渗透。 验证 2x2 系统渗透的概率是 p²(2 - p²),其中 p 是一个站点开放的概率。
立方曲线。 使用递归细分算法绘制立方曲线。
随机漫步。 术语由匈牙利数学家乔治·波利亚在 1921 年的一篇论文中创造。从 0 开始,以 1/2 的概率向左走,以 1/2 的概率向右走。在 0 处有反射屏障 - 如果粒子碰到 0,它必须在下一步改变方向并返回 1。在n处有吸收屏障 - 当粒子碰到状态n时停止。估计作为 n 的函数的步数,直到粒子被吸收。
解析解:n²。
三维随机漫步。 在三维晶格上进行随机漫步,从(0, 0, 0)开始。编写一个程序 Polya.java 来估计在最多一定数量的步骤(比如 1,000 步)之后返回原点的机会。 (在一维和二维中,你肯定会返回;在三维中,概率小于 50%。)在四维晶格上重复练习。 解决方案:实际概率(没有对步数的人为限制)被称为波利亚的随机漫步常数。对于三维,它略高于 1/3,对于四维,它略低于 1/5。
避免自交随机漫步。 模拟在晶格上的随机漫步,直到相交。在所有长度为n的避免自交步行(SAW)中,到原点的平均距离是多少?为了节省模拟时间,排除那些曾经向后迈出一步的 SAW。直到至少有一个长度为n = 40,n = 80 的 SAW 需要多长时间?SAW 的半衰期是多少?为了在模拟中节省更多时间,只允许 SAW 向一个未占用的单元格迈出一步(并重复直到被困住)。关于这些问题几乎没有严格的已知结果,因此模拟是最好的方法。这里有一篇来自《新科学家》的文章。
著名的 1949 年诺贝尔化学奖得主弗洛里的问题。猜想:在二维中,均方位移的指数为 3/4,在三维中为 3/5。
自避随机漫步。 编写一个程序 SelfAvoidingWalk.java 来模拟和展示二维自避随机漫步。自避随机漫步在建模聚合物分子折叠等物理过程中出现。这样的漫步难以用经典数学建模,因此最好通过直接数值模拟来研究。看看多少比例的这种随机漫步最终距离起点超过 R²(比如 30)。
或者保持长度为n的路径。
3. 面向对象编程
原文:
introcs.cs.princeton.edu/java/30oop译者:飞龙
概述。
在面向对象编程中,我们编写 Java 代码来创建新的数据类型,指定值和操作以操作这些值。这个想法源自于对(软件中)现实世界实体的建模,如电子、人、建筑物或太阳系,并很容易扩展到对抽象实体的建模,如位、数字、程序或操作系统。
3.1 使用数据类型 描述了如何使用现有的引用数据类型,用于文本处理和图像处理。
3.2 创建数据类型 描述了如何使用 Java 的类机制创建用户定义的数据类型。
3.3 设计数据类型 考虑了设计数据类型的重要技术,强调 API、封装、不可变性和契约式设计。
3.4 案例研究:N-Body 模拟 展示了一个模拟n粒子运动的案例研究,受牛顿引力定律的影响。
本章中的 Java 程序。
以下是本章节中的 Java 程序列表。点击程序名称以访问 Java 代码;点击参考编号以获取简要描述;阅读教材以获取详细讨论。
参考 程序 描述 3.1.1 PotentialGene.java 识别潜在基因 3.1.2 AlbersSquares.java 阿���伯斯方块 3.1.3 Luminance.java 亮度库 3.1.4 Grayscale.java 将颜色转换为灰度 3.1.5 Scale.java 图像缩放 3.1.6 Fade.java 渐变效果 3.1.7 Cat.java 文件连接 3.1.8 StockQuote.java 股票报价抓取 3.1.9 Split.java 文件分割 3.2.1 Charge.java 带电粒子数据类型 3.2.2 Stopwatch.java 计时器数据类型 3.2.3 Histogram.java 直方图数据类型 3.2.4 Turtle.java 海龟图形数据类型 3.2.5 Spiral.java 黄金螺线 3.2.6 Complex.java 复数数据类型 3.2.7 Mandelbrot.java 曼德勃罗集 3.2.8 StockAccount.java 股票账户数据类型 3.3.1 Complex.java") 复数数据类型(重新审视) 3.3.2 Counter.java 计数器数据类型 3.3.3 Vector.java 空间向量数据类型 3.3.4 Sketch.java 文档草图数据类型 3.3.5 CompareDocuments.java 相似度检测 3.4.1 Body.java 万有引力体数据类型 3.4.2 Universe.java n 体模拟
3.1 使用数据类型
原文:
introcs.cs.princeton.edu/java/31datatype译者:飞龙
数据类型是一组值和一组在这些值上定义的操作。���Java 中,您一直在使用原始数据类型,这些数据类型由广泛的适用于各种应用程序的引用类型库补充。在本节中,我们考虑用于字符串处理和图像处理的引用类型。
字符串。
您已经在使用一个不是原始的数据类型——String数据类型,其值是字符序列。我们在应用程序编程接口(API)中指定数据类型的行为。这是 Java 的String数据类型的部分 API:
第一个条目,与类名相同且没有返回类型,定义了一种特殊的方法,称为构造函数。其他条目定义了可以接受参数并返回值的实例方法。
声明变量。 您声明引用类型的变量的方式与声明原始类型的变量的方式完全相同。声明语句不会创建任何内容;它只是说我们将使用变量名
s来引用一个String对象。创建对象。 每个数据类型值都存储在一个对象中。当客户端调用构造函数时,Java 系统会创建(或实例化)一个单独的对象(或实例)。要调用构造函数,请使用关键字
new;后跟类名;后跟构造函数的参数,用括号括起来,用逗号分隔。调用实例方法。 引用类型变量和原始类型变量之间最重要的区别是,您可以使用引用类型变量来调用实现数据类型操作的实例方法(与我们在原始类型中使用的涉及运算符如
+的内置语法相反)。
现在,我们考虑各种字符串处理示例。
数据类型操作。 以下示例说明了
String数据类型的各种操作。![字符串操作]()
代码片段。 以下代码片段演示了各种字符串处理方法的使用。
![字符串代码片段]()
基因组学。 生物学家使用一个简单的模型来表示生命的基本组成部分,其中字母 A、C、G 和 T 代表生物体 DNA 中的四个碱基。基因是一个表示在理解生命过程中至关重要的功能单元的子字符串。PotentialGene.java 以 DNA 字符串作为参数,并根据以下标准确定它是否对应于潜在基因:
它以起始密码子 ATG 开头。
其长度是 3 的倍数。
它以终止密码子 TAG、TAA 或 TGA 之一结束。
它没有干扰的终止密码子。
颜色。
Java 的Color数据类型使用RGB 颜色模型表示颜色值,其中颜色由三个整数(每个介于 0 和 255 之间)定义,表示颜色的红色、绿色和蓝色分量的强度。通过混合红色、蓝色和绿色分量获得其他颜色值。
Color数据类型有一个接受三个整数参数的构造函数。例如,您可以编写
Color red = new Color(255, 0, 0);
Color bookBlue = new Color( 9, 90, 166);
用于创建代表纯红色和用于打印本书的蓝色的对象。以下表总结了我们在本书中使用的Color API 中的方法:
这里有一些使用 Color 数据类型的示例客户端。
Albers 方块. AlbersSquares.java 在命令行上以 1960 年代约瑟夫·阿尔伯斯开发的格式显示以 RGB 表示的两种颜色,这种格式彻底改变了人们对颜色的看法。
![Albers 方块]()
亮度. 现代显示器(如液晶显示器、等离子电视和手机屏幕)上的图像质量取决于一种称为 单色亮度 或有效亮度的颜色属性的理解。 它是三种强度的线性组合:如果颜色的红色、绿色和蓝色值分别为 r、g 和 b,则其亮度由以下方程定义
\(Y = 0.299r + 0.587g + 0.114b\)
灰度.
RGB 颜色模型具有这样的特性,即当三种颜色强度相同时,结果颜色位于从黑色(全 0)到白色(全 255)的灰度范围内。 将颜色转换为灰度的简单方法是用其亮度等于其红色、绿色和蓝色值的新颜色替换该颜色。颜色兼容性. 亮度值在确定两种颜色是否兼容方面也至关重要,即在另一种颜色的背景上打印文本是否可读。 一个广泛使用的经验法则是前景色和背景色之间的亮度差应至少为 128。
![颜色兼容性]()
Luminance.java 是一个静态方法库,我们可以用它来将颜色转换为灰度并测试两种颜色是否兼容。## 图像处理。
数字图像 是一个由 像素(图像元素)组成的矩形网格,其中每个像素的颜色是单独定义的。 数字图像有时被称为 光栅 或 位图 图像。 相比之下,我们使用 StdDraw 生成的图像(涉及几何对象)被称为 矢量 图像。
Picture 数据类型允许您操作数字图像。 值集是一个二维矩阵,其中包含 Color 值,并且操作是您可能期望的:创建图像(空白或从文件),将像素的值设置为给定颜色,并提取给定像素的颜色。 以下 API 总结了可用的操作:
大多数图像处理程序都是过滤器,通过扫描源图像中的所有像素,然后执行一些计算来确定目标图像中每个像素的颜色。
灰度. Grayscale.java 将图像从彩色转换为灰度。
![狒狒]()
![灰度狒狒]()
缩放. Scale.java 接受一个图像文件的名称和两个整数(宽度 w 和高度 h)作为命令行参数,将图片缩放到 w-by-h,并显示两个图像。
600-by-300
200-by-400淡入效果. Fade.java 接受一个整数 n 和源图像和目标图像的名称作为命令行参数,并在 n 步内从源图像淡入到目标图像。 它使用线性插值策略,其中图像 i 中的每个像素是源图像和目标图像中相应像素的加权平均值。
输入和输出重访。
在第 1.5 节中,您学习了如何使用标准输入、输出和绘图读取和写入数字和文本。这些限制我们只能处理一个输入文件、一个输出文件和一个绘图文件。通过面向对象编程,我们考虑允许我们在一个程序中处理多个输入流、输出流和绘图的数据类型。
输入流数据类型。In是支持从文件和网站以及标准输入流中读取数字和文本数据的StdIn的面向对象版本。
![In API]()
输出流数据类型。Out是支持将文本打印到各种输出流的StdOut的面向对象版本。
![Out API]()
*文件连接。*Cat.java 读取指定为命令行参数的多个文件,将它们连接起来,并将结果打印到一个文件中。
屏幕抓取。StockQuote.java 以纽约证券交易所股票符号作为命令行参数,并打印其当前交易价格。它使用一种称为屏幕抓取的技术,其目标是使用程序从网页中提取一些信息。为了报告谷歌的当前股价(纽约证券交易所符号=GOOG),它读取 Web 页面
http://finance.yahoo.com/quote/GOOG。然后,它使用indexOf()和substring()来识别相关信息。*提取数据。*Split.java 使用多个输出流将 CSV 文件拆分为单独的文件,每个文件包含一个逗号分隔的字段。
引用类型的属性。
我们总结了一些引用类型的基本属性。
别名。具有引用类型的赋值语句会创建引用的第二个副本。赋值语句不会创建新对象,只是对现有对象的另一个引用。这种情况被称为别名:两个变量引用同一个对象。例如,考虑以下代码片段:
Picture a = new Picture("mandrill.jpg"); Picture b = a; a.set(col, row, color1); // a updated b.set(col, row, color2); // a updated again在第二个赋值语句之后,变量
a和b引用同一个Picture对象。按值传递。当你调用一个带有参数的方法时,在 Java 中的效果就好像每个参数都出现在赋值语句的右侧,相应的参数名出现在左侧一样。也就是说,Java 将调用者的参数值的副本传递给方法。如果参数值是原始类型,Java 将传递该值的副本;如果参数值是对象引用,Java 将传递对象引用的副本。这种安排被称为按值传递。
*数组是对象。*在 Java 中,数组是对象。与字符串一样,对数组的某些操作提供了特殊的语言支持:声明、初始化和索引。与任何其他对象一样,当我们将一个数组传递给一个方法或在赋值语句的右侧使用一个数组变量时,我们实际上是在传递数组引用的副本,而不是数组的副本。
*对象数组。*当我们创建一个对象数组时,我们需要分两步进行:
使用
new和方括号语法创建数组。通过使用
new调用构造函数为数组中的每个对象创建对象。
例如,以下代码创建了一个包含两个
Color对象的数组:Color[] a = new Color[2]; a[0] = new Color(255, 255, 0); a[1] = new Color(160, 82, 45);安全指针。 在 Java 中,只有一种方式可以创建引用(使用
new),也只有一种方式可以操作该引用(使用赋值语句)。Java 引用被称为安全指针,因为 Java 可以保证每个引用指向指定类型的对象(而不是任意内存地址)。孤立对象。 将不同对象分配给引用变量的能力会导致程序可能创建了一个无法再引用的对象。这样的对象被称为孤立对象。例如,考虑以下代码片段:
Color a, b; a = new Color(160, 82, 45); // sienna b = new Color(255, 255, 0); // yellow b = a;在最后一个赋值语句之后,不仅
a和b引用相同的Color对象(sienna),而且不再有引用指向用于初始化b的Color对象(yellow)。垃圾收集。 Java 最重要的特性之一是其自动管理内存的能力。这个想法是通过跟踪孤立对象并将它们使��的内存返回到空闲内存池中,使程序员摆脱管理内存的责任。以这种方式回收内存被称为垃圾收集,而 Java 的安全指针策略使其能够高效地执行此操作。
练习
编写一个函数
reverse(),它接受一个字符串作为参数,并返回一个包含与参数字符串相同字符序列但顺序相反的字符串。解决方案:ReverseString.java。
编写一个程序 FlipX.java,它接受图像文件的名称作为命令行参数,并水平翻转图像。
![peppers]()
![flip peppers]()
编写一个程序 ColorSeparation.java,它接受图像文件的名称作为命令行参数,并创建并显示三个
Picture对象,一个包含红色分量,一个包含绿色分量,一个包含蓝色分量。![baboon red]()
![baboon green]()
![baboon blue]()
编写一个静态方法
isValidDNA(),它接受一个字符串作为参数,并仅在其完全由字符A、T、C和G组成时返回true。解决方案:
public static boolean isValidDNA(String s) { for (int i = 0; i < s.length(); i++) { char c = s.charAt(i); if (c != 'A' && c != 'T' && c != 'C' && c != 'G') return false; }编写一个函数
complementWatsonCrick(),它接受一个 DNA 字符串作为参数,并返回其Watson-Crick 互补:用T替换A,用G替换C,反之亦然。解决方案:
public static String complementWatsonCrick(String s) { s = s.replaceAll("A", "t"); s = s.replaceAll("T", "a"); s = s.replaceAll("C", "g"); s = s.replaceAll("G", "c"); return s.toUpperCase(); }以下代码片段打印什么?
String string1 = "hello"; String string2 = string1; string1 = "world"; System.out.println(string2);解决方案:
hello。以下代码片段打印什么?
String s = "Hello World"; s.toUpperCase(); s.substring(6, 11); StdOut.println(s);解决方案:
Hello, World。字符串对象是不可变的。如果一个字符串
s是字符串t的循环移位,则当一个字符串的字符被某个位置的循环移动时,它们匹配。例如,ACTGACG是TGACGAC的循环移位,反之亦然。检测这种条件在基因组序列研究中很重要。编写一个函数isCircularShift(),检查给定的两个字符串s和t是否彼此的循环移位。解决方案:
public boolean isCircularShift(String s, String t) { String s2 = s + s; return s2.contains(t); }以下递归函数返回什么?
public static String mystery(String s) { int n = s.length(); if (n <= 1) return s; String a = s.substring(0, n/2); String b = s.substring(n/2, N); return mystery(b) + mystery(a); }解决方案:其参数字符串的反转。
假设
a[]和b[]都是由数百万个整数组成的整数数组。以下代码做了什么,需要多长时间?int[] temp = a; a = b; b = temp;解决方案:它交换了数组,但是通过复制对象引用来实现,因此不需要复制数百万个值。
描述以下函数的效果。
public void swap(Color a, Color b) { Color temp = a; a = b; b = temp; }解决方案:它没有效果,因为 Java 通过值传递对象引用。
创意练习
印度密宗密码. 编写一个过滤器 KamasutraCipher.java,它将两个字符串作为命令行参数(密钥字符串),然后从标准输入读取字符串(以空格分隔),按照密钥字符串指定的方式替换每个字母,并将结果打印到标准输出。这个操作是已知的最早的密码系统之一的基础。密钥字符串的条件是��们必须具有相同的长度,并且标准输入中的任何字母必须只出现在其中一个中。例如,如果两个密钥是
THEQUICKBROWN和FXJMPSVLAZYDG,那么我们制作表格T H E Q U I C K B R O W N F X J M P S V L A Z Y D G这告诉我们,在将标准输入过滤到标准输出时,应该将 F 替换为 T,T 替换为 F,H 替换为 X,X 替换为 H,依此类推。消息通过用其对应的字母替换每个字母来进行编码。例如,消息
MEET AT ELEVEN被编码为QJJF BF JKJCJG。接收消息的人可以使用相同的密钥将消息还原。色彩研究. 编写一个程序 ColorStudy.java,显示右侧显示的色彩研究,其中给出了对应于本书中使用的 256 个蓝色级别(按行主序的蓝色到白色)和灰色级别(按列主序的黑色到白色)的 Albers 方块。
![色彩研究]()
标题. 编写一个程序 Tile.java,该程序接受一个图像文件的名称和两个整数m和n作为命令行参数,并创建一个m乘以n的图像平铺。
![2 乘 3 的狒狒]()
旋转滤镜. 编写一个程序 Rotation.java,该程序接受两个命令行参数(图像文件的名称和一个实数(\theta)),并将图像逆时针旋转(\theta)度。要进行旋转,将源图像中每个像素((s_i, s_j))的颜色复制到由以下公式给出的目标像素((t_i, t_j))中:
\(\begin{align} t_i \;&=\; (s_i - c_j) \cos \theta - (s_j - c_j) \sin \theta + c_j \\[1ex] t_j \;&=\; (s_i - c_j) \sin \theta + (s_j - c_j) \cos \theta + c_j \end{align}\)
其中((c_i, c_j))是图像的中心。
![狒狒]()
![旋转狒狒]()
涡旋滤镜. 创建涡旋效果类似于旋转,只是角度随着到图像中心的距离的变化而变化。使用与前一个练习中相同的公式,但将(\theta)计算为((s_i, s_j))的函数,具体来说是(\pi/256)乘以到中心的距离。
![狒狒]()
![涡旋狒狒]()
波浪滤镜. 编写一个过滤器 Wave.java,类似于前两个练习中的过滤器,创建波浪效果,通过将源图像中每个像素((s_i, s_j))的颜色复制到目标像素((t_i, t_j))中,其中
\(\begin{align} t_i \;&=\; s_i \\[1ex] t_j \;&=\; s_j + 20 \sin(2 \pi s_j / 64) \end{align}\)
添加代码以将振幅(附图中的 20)和频率(附图中的 64)作为命令行参数。
![波浪狒狒]()
![波浪狒狒]()
玻璃滤镜. 编写一个程序 Glass.java,该程序将图像文件的名称作为命令行参数,并应用玻璃滤镜:将每个像素p设置为随机相邻像素的颜色(其像素坐标与p的坐标最多相差 5)。
![狒狒]()
![透过玻璃的狒狒]()
![数字缩放]()
**数字缩放。**编写一个程序 Zoom.java,接受图像文件的名称和三个数字s、x和y作为命令行参数,并显示一个放大输入图像部分的输出图像。这些数字都在 0 和 1 之间,s被解释为比例因子,(x, y)为输出图像中心点的相对坐标。使用此程序在计算机上的某个数字照片上放大一个相对或宠物。
网络练习(字符串处理)
编写一个函数,接受一个字符串作为输入,并返回字母
e出现的次数。给出一个一行 Java 代码片段,将字符串中所有句号替换为逗号。答案:
s = s.replace('.', ',')。不要使用
s = s.replaceAll(".", ",")。replaceAll()方法使用正则表达式,其中"."具有特殊含义。将所有制表符替换为四个空格。答案:
s = s.replace("\t", " ")。编写一个程序,接受一个命令行输入字符串 s,从标准输入读取字符串,并打印出 s 出现的次数。提示:使用
equals而不是==来比较引用。编写一个程序,将一个月份的名称(3 个字母缩写)作为命令行参数读入,并打印出非闰年中该月份的天数。
public static void main(String[] args) { String[] months = { "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" }; int[] days = { 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 }; String name = args[0]; for (int i = 0; i < months.length; i++) if (name.equalsIgnoreCase(months[i])) System.out.println(name + " has " + days[i] + " days"); }编写一个程序 Squeeze.java,接受一个字符串作为输入,并移除相邻的空格,最多保留一个空格。
以下哪个或哪些方法将数组
a中的所有字符串转换为大写?for (int i = 0; i < a.length; i++) { String s = a[i]; s = s.toUpperCase(); } for (int i = 0; i < a.length; i++) { a[i].toUpperCase(); } for (int i = 0; i < a.length; i++) { a[i] = a[i].toUpperCase(); }答案:只有最后一个。
描述以下函数返回的字符串,给定一个正整数
n?public static String mystery(int n) { String s = ""; while (n > 0) { if (n % 2 == 1) s = s + s + "x"; else s = s + s; n = n / 2; } return s; }解决方案:长度为
n且只包含字符x的字符串。编写一个函数,接受一个字符串
s和一个整数n,并返回一个长度恰好为n的新字符串t,其中包含s(如果其长度大于n则截断)后跟一个序列的'-'字符(如果s的长度小于n)。给定两个长度相同的字符串
s和t,以下递归函数返回什么?public static String mystery(String s, String t) { int n = s.length(); if (n <= 1) return s + t; String a = mystery(s.substring(0, n/2), t.substring(0, n/2)); String b = mystery(s.substring(n/2, n), t.substring(n/2, n)); return a + b; }解决方案:s 和 t 字符的完美洗牌。
编写一个程序,读取一个字符串,并打印出字符串中第��次出现的仅出现一次的字符。例如:ABCDBADDAB → C。
给定一个字符串,创建一个新的字符串,其中移除所有连续重复的字符。例如:ABBCCCCCBBAB → ABCBAB。
编写一个函数,接受两个字符串参数
s和t,并返回s中第一个出现在t中的字符的索引(如果 s 中没有字符出现在 t 中,则返回-1)。给定一个字符串
s,确定它是否表示一个网页的名称。假设任何以http://开头的字符串都是网页。解决方案:
if (s.startsWith("http://"))。给定一个表示网页名称的字符串
s,将其分割成多个部分,每个部分由句点分隔,例如,http://www.cs.princeton.edu应该分割为www、cs、princeton和edu,并移除http://部分。使用split()或indexOf()方法。给定一个表示文件名的字符串
s,编写一个代码片段来确定其文件扩展名。文件扩展名是最后一个句点之后的子字符串。例如,monalisa.jpg的文件类型是jpg,mona.lisa.png的文件类型是png。库解决方案:此解决方案用于在
Picture.java中将图像保存到适当类型的文件中。String extension = s.substring(s.lastIndexOf('.') + 1);给定一个表示文件名的字符串
s,编写一个代码片段来确定其目录部分。这是以最后一个/字符(目录分隔符)结尾的前缀;如果没有这样的/,则为空字符串。例如,/Users/wayne/monalisa.jpg的目录部分是/Users/wayne/。给定一个表示文件名的字符串
s,编写一个代码片段来确定其基本名称(文件名减去任何目录)。对于/Users/wayne/monalisa.jpg,基本名称是monalisa.jpg。编写一个程序,从标准输入读取文本并将其打印回去,将所有单引号替换为双引号。
编写一个程序
Paste.java,接受任意数量的命令行输入,并连接每个文件的相应行,并将结果写入标准输出。(通常给定文件中的每行长度相同。)Cat.java程序的对应程序。当 N = 5 时,程序 LatinSquare.java 会打印什么?
String alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; for (int i = 0; i < N; i++) { for (int j = 0; j < N; j++) { char c = alphabet.charAt((i + j) % N); System.out.print(c + " "); } System.out.println(); }阶数为 N 的拉丁方是一个由 N 个不同符号组成的 N×N 数组,使得每个符号在每行和每���中恰好出现一次。拉丁方在统计设计和密码学中很有用。
以下代码片段会打印什么?
String s = "Hello World"; s.toUpperCase(); s.substring(6, 11); System.out.println(s);答案:
Hello World。toUpperCase和substring方法返回结果字符串,但程序忽略了这些,所以s从未被改变。要打印出WORLD,使用s = s.toUpperCase()和s = s.substring(6, 11)。当执行以下代码片段时会发生什么?
String s = null; int length = s.length();答案:由于
s为null且您试图对其进行解引用,因此会得到NullPointerException。在下面的两个赋值语句之后,x 和 y 的值是多少?
int x = '-'-'-'; int y = '/'/'/';当
c是char类型时,以下语句会做什么?System.out.println((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'));答案:如果
c是大写或小写字母,则打印true,否则打印false。编写一个表达式,测试一个字符是否代表数字
'0'到'9'之间的数字,而不使用任何库函数。boolean isDigit = ('0' <= c && c <= '9');编写一个程序,WidthChecker.java,接受一个命令行参数 N,从标准输入读取文本,并将长度超过 N 个字符(包括空格)的所有行打印到标准输出。
编写一个程序,Hex2Decimal.java,将十六进制字符串(使用 A-F 表示数字 11-15)转换为十进制。
wget。编写一个程序 Get.java,将 URL 的名称作为命令行参数,并使用相同的文件名保存引用的文件。
大写首字母。编写一个程序,Capitalize.java,从标准输入读取文本,并将每个单词大写(首字母大写,其余字母小写)。
香农的熵实验。通过列出句子中的一些字母并提示用户输入下一个符号,重新创建香农对英语语言熵的实验。香农得出结论,字母表中每个字母大约有 1.1 位信息。
混淆的文本。一些认知心理学家认为人们根据单词的形状来识别单词。
根据一项在英语大学进行的研究,一个单词中字母的顺序并不重要,重要的是第一个和最后一个字母在正确的位置。其余字母可以是一个混乱,但你仍然可以无障碍地阅读。这是因为我们不是逐个字母阅读,而是整个单词。
编写一个程序,从标准输入读取文本并将文本打印回去,但是对每个单词中的内部字母进行混淆。编写并使用一个名为
scramble()的函数,该函数接受一个字符串作为输入,并返回另一个内部字母顺序随机排列的字符串。使用 Shuffle.java 中的洗牌算法进行混洗部分。日期格式转换。编写一个程序,读取形式为 2003-05-25 的日期,并将其转换为 5/25/03。
英文文本的频率分析。 编写一个名为
LetterFrequency.java的程序,从标准输入中读取文本(例如,白鲸),并计算每个 26 个小写字母出现的频率。在分析中忽略大写字母、标点符号、空白等。使用第 2.4 节中的CharStdIn.java来读取处理文本文件。打印最长单词。 重复上一个练习,但如果存在并列情况,则打印出所有最长的单词,最多打印 10 个单词。使用一个字符串数组来存储当前最长的单词。
测试两个文件是否相等。 编写一个程序,接受两个文本文件的名称作为命令行输入,并检查它��的内容是否相同。
解析命令行选项。 Unix 命令行程序通常支持标志,这些标志配置程序的行为以产生不同的输出,例如,"wc -c"。编写一个程序,从命令行接受任意数量的标志,并运行用户指定的选项。要检查选项,使用类似
if (s.equals("-v"))的语句。大写。 编写一个名为
Capitalizer.java的程序,从标准输入中读取文本字符串,并修改每个字符串,使每个单词的第一个字母大写,其他字母小写。栅栏置换密码。 编写一个名为
RailFenceEncoder.java的程序,从标准输入中读取文本,并打印出奇数位置的字符,然后是偶数位置的字符。例如,如果原始消息是"Attack at Dawn",那么你应该打印出"Atc tDwtaka an"。这是一种粗糙的加密形式。栅栏置换密码。 编写一个名为
RailFenceDecoder.java的程序,该程序读取使用栅栏置换密码编码的消息,并通过反转加密过程来打印原始消息。斯塞塔利密码。 斯塞塔利密码是用于军事目的的第一个密码设备之一。(参见《密码书》,第 8 页有一张漂亮的图片。)它是由公元前 5 世纪的斯巴达人使用的。为了混淆文本,你需要从开头开始打印每第 k 个字符,然后从第二个字符开始打印每第 k 个字符,依此类推。编写一对程序
ScytaleEncoder.java和ScytaleDecoder.java来实现这种加密方案。打印最长单词。 从标准输入读取一个单词列表,并打印出最长的单词。使用
length方法。子序列。 给定两个字符串
s和t,编写一个程序 Subsequence.java,确定s是否是t的子序列。也就是说,s的字母应该按照相同的顺序出现在t中,但不一定是连续的。例如,accag是taagcccaaccgg的一个子序列。圣经密码。 一些宗教狂热者相信《圣经》包含通过每第 k 个字母阅读出现的隐藏短语,并且这种模式可以用于寻找约柜、治愈癌症和预测未来。这些结果不基于科学方法,数学家已经揭穿了这些结果,并将其归因于非法数据操纵。使用相同的方法,可以在《战争与和平》的希伯来文翻译中找到统计上相似的模式。
单词链检查器。 编写一个程序,从命令行读取一个单词列表,并在它们形成单词链时打印
true,否则打印false。在单词链中,相邻单词必须在恰好一个字母上有所不同,例如,HEAL, HEAD, DEAD, DEED, DEER, BEER。俳句检测器。 编写一个程序,从标准输入中读取文本,并检查它是否构成一个俳句。俳句由三行组成,分别包含正确数量的音节(5、7 和 5 个)。根据此问题的定义,一个音节是任何连续的元音序列(a、e、i、o、u 或 y)。根据这个规则,haiku有两个音节,purpose有三个音节。当然,第二个例子是错误的,因为 purpose 中的 e 是不发音的。
ISBN 号码。 编写一个程序来检查 ISBN 号码是否有效。回忆校验位。ISBN 号码也可以在任意位置插入连字符。
最长公共前缀。 编写一个函数,接受两个输入字符串 s 和 t,并返回这两个字符串的最长公共前缀。例如,如果 s = ACCTGAACTCCCCCC,t = ACCTAGGACCCCCC,那么最长公共前缀是 ACCT。注意,如果 s 和 t 以不同的字母开头,或者一个是另一个的前缀,要小心处理。
最长互补回文。 在 DNA 序列分析中,互补回文是一个等于其反向互补的字符串。腺嘌呤(A)和胸腺嘧啶(T)是互补的,胞嘧啶(C)和鸟嘌呤(G)也是互补的。例如,ACGGT 是一个互补回文。这样的序列作为转录结合位点,并与基因扩增和遗传不稳定性相关联。给定一个长度为 N 的文本输入,找到文本的最长互补回文子串。例如,如果文本是
GACACGGTTTTA,那么最长互补回文是ACGGT。提示:将每个字母视为可能长度为奇数的回文的中心,然后将每对字母视为可能长度为偶数的回文的中心。最高密度 C+G 区域。 给定一个由 A、C、T、G 组成的 DNA 字符串
s和一个参数L,找到一个包含最高比例的 C + G 字符的子串s,该子串至少包含L个字符。循环位移的子字符串。 编写一个函数,接受两个字符串
s和t,如果s是循环字符串t的子字符串,则返回true,否则返回false。例如,gactt是循环字符串tgacgact的子字符串。DNA 转蛋白质。 蛋白质是由一系列氨基酸(单体)组成的大分子(聚合物)。一些蛋白质的例子包括:血红蛋白、激素、抗体和铁蛋白。自然界中存在 20 种不同的氨基酸。每种氨基酸由三个 DNA 碱基对(A、C、G 或 T)指定。编写一个程序,读取一个蛋白质(由其碱基对指定),并将其转换为一系列氨基酸。使用以下表格。例如,异亮氨酸(I)由 ATA、ATC 或 ATT 编码。
生命的罗塞塔石。
TTT Phe TCT Ser TAT Tyr TGT Cys TTC Phe TCC Ser TAC Tyr TGC Cys TTA Leu TCA Ser TAA ter TGA ter TTG Leu TCG Ser TAG ter TGG Trp CTT Leu CCT Pro CAT His CGT Arg CTC Leu CCC Pro CAC His CGC Arg CTA Leu CCA Pro CAA Gln CGA Arg CTG Leu CCG Pro CAG Gln CGG Arg ATT Ile ACT Thr AAT Asn AGT Ser ATC Ile ACC Thr AAC Asn AGC Ser ATA Ile ACA Thr AAA Lys AGA Arg ATG Met ACG Thr AAG Lys AGG Arg GTT Val GCT Ala GAT Asp GGT Gly GTC Val GCC Ala GAC Asp GGC Gly GTA Val GCA Ala GAA Glu GGA Gly GTG Val GCG Ala GAG Glu GGG Gly氨基酸 缩写 缩写 氨基酸 缩写 缩写 丙氨酸 ala A 亮氨酸 leu L 精氨酸 arg R 赖氨酸 lys K 天冬酰胺 asn N 甲硫氨酸 met M 天冬氨酸 asp D 苯丙氨酸 phe F 半胱氨酸 cys C 脯氨酸 pro P 谷氨酸 glu E 丝氨酸 ser S 谷氨酰胺 gln Q 苏氨酸 thr T 甘氨酸 gly G 色氨酸 trp W 组氨酸 his H 酪氨酸 tyr Y 异亮氨酸 ile I 缬氨酸 val V 计数器。 编写一个程序,从命令行读取一个十进制字符串(例如,56789),并从该数字开始计数(例如,56790,56791,56792)。不要假设输入是 32 位或 64 位整数,而是一个任意精度整数。使用
String来实现整数(而不是数组)。任意精度整数运算。 编写一个程序,接受两个十进制字符串作为输入,并打印它们的和。使用一个字符串表示整数。
Boggle。 Boggle 游戏在一个 4x4 的字符网格上进行。有 16 个骰子,每个骰子上有 6 个字母。创建一个 4x4 的网格,其中每个骰子以随机方式出现在一个单元格中,每个骰子随机显示其中的一个字母。
FORIXB MOQABJ GURILW SETUPL CMPDAE ACITAO SLCRAE ROMASH NODESW HEFIYE ONUDTK TEVIGN ANEDVZ PINESH ABILYT GKYLEU生成密码。 密码 是通过将英文文本混淆而获得的,方法是用另一个字母替换每个字母。编写一个程序来生成 26 个字母的随机排列,并使用它来映射字母。给出示例:不要混淆标点符号或空格。
Scrabble。 编写一个程序来确定可以玩的最长合法 Scrabble 单词是什么?要合法,单词必须在官方锦标赛和俱乐部单词列表(TWL98)中,其中包含 TWL98 中介于 2 到 15 个字母之间的所有 168,083 个单词。每个字母代表的瓷砖数量在下表中给出。此外,还有两个 空白 可以用来表示任何字母。
a b c d e f g h i j k l m n o p q r s t u v w x y z - 9 2 2 4 12 2 3 2 9 1 1 4 2 6 8 2 1 6 4 6 4 2 2 1 2 1 2Soundex。 Soundex 算法 是一种根据其发音而不是其拼写方式对姓氏进行编码的方法。发音相同的姓名(例如,SMITH 和 SMYTH)将具有相同的 Soundex 编码。Soundex 算法最初是为了简化人口普查而发明的。它也被家谱学家用来处理具有替代拼写的姓名,并被航空公司接待员用来避免在稍后尝试发音客户姓名时尴尬。
编写一个程序 Soundex.java,它将两个小写字符串作为参数读入,计算它们的 Soundex,并确定它们是否等价。算法如下:
保留字符串的第一个字母,但删除所有元音字母以及字母 'h','w' 和 'y'。
使用以下规则为剩余字母分配数字:
1: B, F, P, V 2: C, G, J, K, Q, S, X, Z 3: D, T 4: L 5: M, N 6: R如果两个或更多连续的数字相同,则删除所有重复的数字。
将字符串转换为四个字符:第一个字符是原始字符串的第一个字母,剩下的三个字符是字符串中的前三个数字。如果数字不够,用尾随的 0 填充;如果数字太多,则截断。
最长单词。 给定一个单词字典和一个起始单词 s,找到可以从 s 开始形成的最长单词,每次插入一个字母,使得每个中间单词也在字典中。例如,如果起始单词是
cal,那么以下是一系列有效单词coal,coral,choral,chorale。参考链接。电话单词。 编写一个程序
PhoneWords.java,它将一个 7 位数字字符串作为命令行输入,从标准输入中读取一个单词列表(例如,字典),并打印出所有可以使用标准电话规则形成的 7 个字母单词(或后跟 4 个字母单词的 3 个���母单词),例如,266-7883 对应于compute。0: No corresponding letters 1: No corresponding letters 2: A B C 3: D E F 4: G H I 5: J K L 6: M N O 7: P Q R S 8: T U V 9: W X Y ZRot13。Rot13 是一种非常简单的加密方案,用于隐藏一些可能具有冒犯性的帖子的互联网新闻组。它通过将每个小写或大写字母循环移位 13 个位置来工作。因此,字母 'a' 被 'n' 替换,字母 'n' 被 'a' 替换。例如,字符串 "Encryption" 被编码为 "Rapelcgvba." 编写一个程序 ROT13.java,它读取一个字符串作为命令行参数,并使用 Rot13 对其进行编码。
最长 Rot13 单词。 编写一个程序,将单词字典读入数组,并确定每个单词对中的最长单词,使得每个单词都是另一个单词的 Rot13,例如,bumpily 和 unfiber。
Thue-Morse 编织。 回顾第 2.3 节练习中的Thue-Morse 序列。编写一个程序 ThueMorse.java,读取一个命令行输入 N,并在乌龟图形中绘制 N×N 的 Thue-Morse 编织。如果 Thue-Morse 字符串中第 i 位和第 j 位的位不同,则将单元格(i, j)绘制为黑色。下面是 N = 4、8 和 16 的 Thue-Morse 模式。
![4x4 Thue-Morse 模式]()
![8x8 Thue-Morse 模式]()
![16x16 Thue-Morse 模式]()
由于迷人的不规则性,对于较大的 N,你的眼睛可能很难保持集中。
重复单词。 编写一个程序 Repetition.java 来读取一个单词字典列表,并打印出每个字母恰好出现两次的单词,例如,intestines,antiperspirantes,appeases,arraigning,hotshots,arraigning,teammate 等。
文字转换。 编写一个程序 TextTwist.java,从命令行读取一个单词和标准输入中的单词字典,并打印出由输入单词中的字母子集重新排列而成的至少四个字母的所有单词。这构成了游戏Text Twist的核心。*提示:*通过计算 26 个字母出现的次数来创建输入单词的概要。然后,对于每个字典单词,创建一个类似的概要,并检查每个字母在输入单词中出现的次数是否至少与字典单词中的次数相同。
单词频率。 编写一个程序(或多个程序并使用管道),读取一个文本文件,并按频率降序打印出单词列表。考虑将其分成 5 部分并使用管道:读取文本并以小写形式逐行打印单词,排序以将相同的单词放在一起,去除重复项并打印计数,按计数排序。
VIN 号码。 VIN 号码是一个用于唯一标识机动车辆的 17 个字符的字符串。它还编码了车辆的制造商和属性。为了防止意外输入错误的 VIN 号码,VIN 号码包含一个检验位(第 9 个字符)。每个字母和数字被分配一个 0 到 9 之间的值。检验位被选择为值的加权和对 11 取模,如果余数为 10,则使用符号
X。A B C D E F G H I J K L M N O P Q R S T U V W X Y Z 1 2 3 4 5 6 7 8 - 1 2 3 4 5 - 7 - 9 2 3 4 5 6 7 8 9 1st 2nd 3rd 4th 5th 6th 7th 8th 9th 10 11 12 13 14 15 16 17 8 7 6 5 4 3 2 10 0 9 8 7 6 5 4 3 2例如,部分 VIN 号码 1FA-CP45E-?-LF192944 的��验位为 X,因为加权和为 373,373 mod 11 为 10。
1 F A C P 4 5 E X L F 1 9 2 9 4 4 1 6 1 3 7 4 5 5 - 3 6 1 9 2 9 4 4 8 7 6 5 4 3 2 10 - 9 8 7 6 5 4 3 2 ------------------------------------------------------------------ 8 42 6 15 28 12 10 50 - 27 48 7 54 10 36 12 8编写一个程序 VIN.java,接受一个命令行字符串,并确定它是否是一个有效的 VIN 号码。允许输入大小写字母,并允许插入破折号。进行彻底的错误检查,例如,字符串的长度是否正确,是否使用了非法字符(I,O,Q)等。
音乐 CD。 屏幕抓取MusicBrainz以识别有关音乐 CD 的信息。
猪拉丁文。 猪拉丁文是一种供年幼儿童使用的有趣秘密语言。要将一个单词转换为猪拉丁文:
如果以元音字母开头,则在末尾附加“hay”。在单词开头,将 y 视为元音字母,除非后面跟着一个元音字母。
如果以一串辅音字母开头,将辅音字母移到末尾,然后附加“ay”。将跟在 q 后面的 u 视为辅音字母。
例如,“input”变为“input-hay”,“standard”变为“andard-stay”,“quit”变为“it-quay”。编写一个程序
PigLatinCoder.java,从标准输入读取一系列单词,并以猪拉丁文形式将它们打印到标准输出。编写一个程序PigLatinDecoder.java,从标准输入读取以猪拉丁文编码的一系列单词,并将原始单词打印出来。旋转鼓问题。 应用于伪随机数生成器、计算生物学、编码理论。考虑一个旋转鼓(画一个被分成 16 个段的圆的图片,每个段都是两种类型之一 - 0 和 1)。我们希望任何 4 个连续段的序列都能唯一标识鼓的象限。也就是说,每 4 个连续段应该代表从 0000 到 1111 的 16 个二进制数中的一个。德布鲁因序列是一个最短的(循环)字符串,使得每个 n 位比特序列至少出现一次。例如,0000111101100101 是一个 4 阶德布鲁因序列,所有 2⁴ 个可能的 4 位序列(0000、0001、...、1111)都恰好出现一次。编写一个程序 DeBruijn.java,读取一个命令行参数 n,并打印一个 n 阶德布鲁因序列。算法:从 n 个 0 开始。如果形成的 n 元组在序列中尚未出现,则附加一个 1;否则附加一个 0。*提示:*使用
String.indexOf和String.substring方法。埃伦费库特-米切尔斯基序列。 埃伦费库特-米切尔斯基序列是以"010"开头的二进制序列。给定前 n 位 b[0]、b[1]、...、b[n-1],b[n]通过找到先前出现的最长后缀 b[j]、b[j+1]、...、b[n-1]来确定(如果出现多次,则取最后一次出现)。然后,b[n]是跟在匹配项后面的位的相反值。0100110101110001000011110110010100100111010001100000101101111100。*提示:*使用
substring()和lastIndexOf()方法。
网页练习(图像处理)
画家和打印机的色彩三角形。 创建以下两幅图像。画家三角形的主色调是红色、绿色和蓝色;打印机三角形的主色调是品红、青色和黄色。
![Painter's triangle]()
![Printer's triangle]()
色谱。 编写一个程序 Spectrum.java,绘制所有 2²⁴ 种可能的颜色,通过为每个红色值绘制一个 256x256 的颜色芯片数组(每个绿色和蓝色值一个)。
垂直翻转。 编写一��程序
FlipY.java,读取一幅图像并进行垂直翻转。图片尺寸。 编写一个程序
Dimension.java,接受一个图像文件名作为命令行输入,并打印其尺寸(宽×高)。抗锯齿。 抗锯齿是一种消除用有限数量的像素表示平滑曲线的伪影的方法。一个非常粗糙的方法(也会使图像模糊)是将一个 N×N 像素网格转换为一个(N-1)×(N-1)像素,方法是将原始图像中的每个像素设为四个单元格的平均值。编写一个程序
AntiAlias,读取一个整数 N,然后读取一个 N×N 的整数数组,并打印抗锯齿版本。参考。阈值处理。 编写一个程序 Threshold.java,读取一幅黑白图片的灰度版本,创建并绘制 256 个灰度强度的直方图,并确定像素为黑色和白色的阈值。
镜像图像。 读取一个 WxH 图片,并生成一个 2WxH 图片,其中包含原始 WxH 图片和 WxH 图片的镜像。围绕 y 轴重复镜像。或者创建一个 WxH 图片,但围绕中心镜像,删除图片的一半。
线性滤波器。 盒子滤波器或均值滤波器通过其 9 个相邻像素(包括自身)的平均值来替换像素(x, y)的颜色。矩阵[1 1 1; 1 1 1; 1 1 1] / 9 被称为卷积核。卷积核是要一起平均的像素集。程序 MeanFilter.java 使用
Picture数据类型实现了一个均值滤波器。模糊滤波器。 使用低通 3x3 均匀滤波器[1/13 1/13 1/13; 1/13 5/13 1/13; 1/13, 1/13, 1/13]。
浮雕滤波器。 使用 prewitt 掩模[-1 0 1; -1 1 1; -1 0 1](东)或[1 0 -1; 2 0 -2; 1 0 -1],[-1 -1 0; -1 1 1; 0 1 1](东南),
锐化滤波器。 心理物理实验表明,具有更清晰边缘的照片比精确的照片复制更具美感。使用高通 3x3 滤波器。使接近暗像素的亮像素变得更亮;使接近亮像素的暗像素变得更暗。拉普拉斯核。试图捕捉二阶导数为零的区域。[-1 -1 -1; -1 8 -1; -1 -1 -1]
油画滤波器。 将像素(i, j)设置为原始图像中曼哈顿距离为 W 的像素中最频繁值的颜色。
亮度和色度。 使用 YIQ 颜色空间分解图片:Y(亮度)= 0.299 r + 0.587 g + 0.114 b,I(相位)= 0.596 r - 0.274 g - 0.322 b,Q(象限)= 0.211 r - 0.523 g + 0.312 b。绘制所有 3 幅图像。YIQ 颜色空间被 NTSC 彩色电视系统使用。
变亮。 编写一个程序 Brighter.java,该程序接受一个命令行参数,即 JPG 或 PNG 文件的名称,将其显示在窗口中,并显示一个更亮的副本。使用
Color方法brighter(),它返回调用颜色的更亮版本。![狒狒]()
![更亮的狒狒]()
边缘检测。 目标:形成图像某些特征的数学模型。为了实现这一目标,我们希望检测边缘或线条。边缘是图片中一个像素到下一个像素之间强烈对比的区域。边缘检测是图像处理和计算机视觉中��一个基本问题。Sobel 方法是一种流行的边缘检测技术。我们假设图像是灰度的。(如果不是,我们可以通过取红色、绿色和蓝色强度的平均值来转换。)对于每个像素(i, j),我们通过计算两个 3x3卷积掩模来计算边缘强度。这涉及计算以(i, j)为中心的 3x3 邻域中九个像素的灰度值,将它们乘以 3x3 掩模中的相应权重,并将乘积相加。
-1 0 +1 +1 +2 +1 -2 0 +2 0 0 0 -1 0 +1 -1 -2 -1这产生两个值 Gx 和 Gy。在输出图片中,我们根据灰度值 255 - Sqrt(GxGx + GyGy)对像素(i, j)进行着色。处理边界的方法有很多种。为简单起见,我们忽略这种特殊情况,并将边界像素着色为黑色。程序 EdgeDetector.java 接受图像名称作为命令行输入,并对该图像应用 Sobel 边缘检测算法。
![狒狒]()
![Sobel 狒狒]()
3.2 创建数据类型
原文:
introcs.cs.princeton.edu/java/32class译者:飞龙
在本节中,我们介绍了使我们能够创建用户定义数据类型的 Java 机制。我们考虑了一系列示例,从带电粒子和复数到乌龟图形和股票账户。
数据类型的基本元素。
为了说明这个过程,我们在 Charge.java 中定义了一个用于带电粒子的数据类型。库仑定律告诉我们,由于给定带电粒子,点((x, y))处的电势是(V = kq /r),其中(q)是电荷值,(r)是点((x, y))到电荷的距离,(k = 8.99 \times 10⁹)是静电常数。
API。 应用程序编程接口是与所有客户端的合同,因此是任何实现的起点。
![Charge API]()
类。 在 Java 中,您在
class中实现数据类型。通常情况下,我们将数据类型的代码放在与类相同名称的文件中,后面跟着.java扩展名。访问修饰符。 我们将类中的每个实例变量和方法指定为
public(此实体可被客户端访问)或private(此实体不可被客户端访问)。final修饰符表示一旦初始化,变量的值将不会改变——其访问是只读的。实例变量。 我们声明实例变量来表示数据类型的值,方式与声明局部变量相同,只是这些声明出现在类的第一条语句中,而不是在
main()或任何其他方法中。对于Charge,我们使用三个double变量——两个用于描述电荷的位置,一个用于描述电荷量。![实例变量]()
构造函数。 构造函数是创建对象并提供对该对象的引用的特殊方法。当客户端程序使用关键字
new时,Java 会自动调用构造函数。每当客户端调用构造函数时,Java 会自动为对象分配内存
调用构造函数代码来初始化实例变量。
返回对新创建对象的引用
Charge中的构造函数很典型:它使用客户端提供的值作为参数来初始化实例变量。![构造函数]()
实例方法。 要实现实例方法,我们编写的代码与实现静态方法的代码完全相同。唯一的关键区别是实例方法可以对实例变量执行操作。
![实例方法]()
方法内的变量。 我们编写的 Java 代码来实现实例方法使用三种类型的变量。参数变量和局部变量是熟悉的。实例变量完全不同:它们保存类中对象的数据类型值。
![实例、参数和局部变量]()
类中的每个对象都有一个值:实例方法中的代码引用了调用该方法的对象的值。例如,当我们写
c1.potentialAt(x, y)时,potentialAt()中的代码是在引用c1的实例变量。测试客户端。 每个类都可以定义自己的
main()方法,我们保留用于单元测试。至少,测试客户端应该调用类中的每个构造函数和实例方法。
总之,在 Java 类中定义数据类型,需要实例变量、构造函数、实例方法和一个测试客户端。
秒表。
秒表.java 实现了以下 API:
这是一个简化版本的老式秒表。当你创建一个时,它就开始计时,你可以通过调用方法elapsedTime()来询问它已经运行了多长时间。
直方图。
Histogram.java 是一种使用熟悉的绘图方式直方图来可视化数据的数据类型。为简单起见,我们假设数据由 0 到 n−1 之间的整数值序列组成。直方图计算每个值出现的次数,并为每个值绘制一个柱(高度与其频率成比例)。以下 API 描述了操作:
海龟图形。
Turtle.java 是一个用于海龟图形的可变类型,我们可以命令海龟沿直线移动指定距离,或者旋转(逆时针)指定角度。
这里有一些示例客户端:
正多边形。 Ngon.java 接受一个命令行参数 n 并使用海龟图形绘制一个正 n 边形。通过将 n 取得足够大的值,我们可以得到一个对圆的良好近似。
递归图形。 Koch.java 接受一个命令行参数 n 并绘制一个 n 阶的Koch 曲线。0 阶的 Koch 曲线是一条线段。要形成一个 n 阶的 Koch 曲线:
绘制一个 n−1 阶的 Koch 曲线
逆时针旋转 60°
绘制一个 n−1 阶的 Koch 曲线
顺时针旋转 120°
绘制一个 n−1 阶的 Koch 曲线
逆时针旋转 60°
绘制一个 n−1 阶的 Koch 曲线
下面是 0、1、2 和 3 阶的 Koch 曲线。
![0 阶科赫曲线 科赫曲线 0]()
![1 阶科赫曲线 科赫曲线 1]()
![2 阶科赫曲线 科赫曲线 2]()
![3 阶科赫曲线 科赫曲线 3]()
奇妙的螺线。
螺线.java 接受一个整数 n 和一个衰减因子作为命令行参数,并指示海龟交替前进和转向,直到它绕自身旋转了 10 次。这产生了一个被称为对数螺线的几何形状,它经常出现在自然界中。下面描绘了三个例子:鹦鹉螺壳的腔室、螺旋星系的臂和热带风暴中的云团。![鹦鹉螺壳]()
![螺旋星系的臂]()
![冰岛上空的低气压系统]()
这张维基百科图片来自用户 Chris 73,并可通过CC by-SA 3.0 许可证获得。 照片:NASA 和 ESA 照片:NASA 布朗运动。 醉海龟.java 绘制了一个迷失方向的海龟所经过的路径,它交替前进和向随机方向转向。这个过程被称为布朗运动。醉海龟们.java 绘制了许多这样的海龟,它们都四处游荡。
复数。
复数是形式为 x + iy 的数,其中 x 和 y 是实数,i 是-1 的平方根。复数的基本运算是相加和相乘,如下所示:
加法: ((x_0+iy_0) + (x_1+iy_1) = (x_0+x_1) + i,(y_0+y_1))
乘法: ((x_0 + iy_0) \cdot (x_1 + iy_1) = (x_0x_1 - y_0y_1) + i,(y_0x_1 + x_0y_1))
幅度: (\left | x + iy \right | = \sqrt{x² + y²})
实部: (\operatorname(x + iy) = x)
虚部: ( \operatorname(x + iy) = y)
Complex.java 是一个不可变数据类型,实现了以下 API:
这种数据类型引入了一些新功能。
访问同一类型其他对象的值。 实例方法
plus()和times()需要访问两个对象中的值:作为参数传递的对象和用于调用方法的对象。如果我们用a.plus(b)调用方法,我们可以像往常一样使用名称re和im访问a的实例变量。但是,要访问b的实例变量,我们使用代码b.re和b.im。创建和返回新对象。 注意
plus()和times()如何向客户端提供返回值:它们需要返回一个Complex值,因此它们分别计算所需的实部和虚部,用它们创建一个新对象,然后返回对该对象的引用。链接方法调用。 注意
main()如何将两个方法调用链接成一个紧凑的表达式z.times(z).plus(z0),对应于数学表达式 z² + z[0]。最终实例变量。
Complex中的两个实例变量是final的,这意味着它们的值在创建Complex对象时设置,并且在该对象的生命周期内不会更改。
曼德勃罗集。
曼德勃罗集 是一组具有许多迷人特性的复数。确定一个复数 (z_0) 是否在曼德勃罗集中的算法很简单:考虑复数序列 (z_0, z_1, z_2, \ldots, z_t, \ldots,) 其中 (z_{i+1} = z_i² + z_0)。例如,以下表格显示了与 (z_0 = 1 + i) 对应的序列的前几个条目:
现在,如果序列 ( | z_i |) 发散到无穷大,那么 (z_0) 不 在曼德勃罗集中;如果序列有界,那么 (z_0) 在曼德勃罗集中。对于许多点,测试很简单;对于许多其他点,测试需要更多计算,如下表中的示例所示:
为了可视化曼德勃罗集,我们在指定的正方形内定义一个均匀间隔的 n×n 像素网格,并在相应点属于曼德勃罗集时绘制黑色像素,不属于时绘制白色像素。
但是我们如何确定一个复数是否属于曼德勃罗集?对于每个复数,Mandelbrot.java 计算其序列中的最多 255 个项。如果幅度超过 2,那么我们可以得出结论该复数不在集合中(因为已知该序列肯定会发散)。否则,我们得出结论该复数在集合中(知道我们的结论偶尔可能是错误的)。
商业数据处理。
StockAccount.java 实现了一个数据类型,可能被金融机构用来跟踪客户信息。
练习
开发一个实现 Rectangle.java 的实现,该实现表示具有其左下角和右上角的 x 和 y 坐标的矩形。不要更改 API。
实现一个支持加法、减法、乘法和除法的数据类型 Rational.java。
![有理数 API]()
编写一个实现以下 API 的数据类型 Interval.java:
![区间 API]()
区间被定义为线上大于等于
min且小于等于max的所有点的集合。特别地,max小于min的区间为空。编写一个客户端,它是一个过滤器,接受一个浮点命令行参数x,并打印所有包含x的标准输入中的区间(每个由一对双精度值定义)。编写一个实现以下 API 的数据类型 Point.java:
![点 API]()
修改 Complex.java 中的
toString()方法,使复数以传统格式显示。例如,它应该将值 (3-i) 打印为3 - i而不是3.0 + -1.0i,将值 3 打印为3而不是3.0 + 0.0i,将值 (3i) 打印为3i而不是0.0 + 3.0i。编写一个
Complex客户端 RootsOfUnity.java,从命令行接受两个double值 a 和 b 以及一个整数 n,并打印 (a + bi) 的第 n 个根。对 Complex.java 实现以下添加:
![复数 API]()
编写一个测试客户端来测试所有你的方法。
假设你想要为 Complex.java 添加一个以
double值作为参数的构造函数,并创建一个具有该值作为实部(没有虚部)的Complex数字。你写下以下代码:public void Complex(double real) { re = real; im = 0.0; }但是语句
Complex c = new Complex(1.0);却无法编译通过。为什么?解决方案:构造函数没有返回类型,甚至不是
void。这段代码定义了一个名为Complex()的方法,而不是构造函数。移除关键字void。
创意练习
电势可视化。 编写一个程序 Potential.java,从标准输入中给定的值创建一个带电粒子数组(每个带电粒子由其 x 坐���、y 坐标和电荷值指定),并在单位正方形中生成电势的可视化。为此,在单位正方形中采样点。对于每个采样点,计算该点的电势(通过对每个带电粒子的电势求和),并绘制与电势成比例的灰度点。
![电势]()
![电势]()
四元数。 在 1843 年,威廉·哈密尔顿爵士发现了一种称为四元数的复数扩展。四元数将三维空间中的旋转概念扩展到四维空间。它们被用于计算机图形学、控制理论、信号处理和轨道力学等领域,例如用于航天器姿态控制系统的指令。与物理学中的 Pauli 自旋矩阵相关。创建一个数据类型 Quaternion.java 来表示四元数。包括四元数的加法、乘法、求逆、共轭和范数运算。
一个四元数可以用实数四元组 ((a_0, a_1, a_2, a_3)) 来表示,表示为 (a_0 + a_1 i + a_2 j + a_3 k)。其基本恒等式为 (i² = j² = k² = ijk = -1)。
大小:( \left | a \right | = \sqrt{a_0² + a_1² + a_2² + a_3²} )
共轭:( a^* = (a_0, -a_1, -a_2, -a_3))
求逆:( a^{-1} = (a_0,/, \left | a \right |², -a_1,/, \left | a \right |², -a_2,/, \left | a \right |², -a_3,/, \left | a \right |²))
求和:( a + b = (a_0+b_0, a_1+b_1, a_2+b_2, a_3+b_3))
哈密顿积:\(\begin{align} a \times b \; = \; (& a_0b_0 - a_1b_1 - a_2b_2 - a_3b_3, \\ & a_0b_1 + a_1b_0 + a_2b_3 - a_3b_2, \\ & a_0b_2 - a_1b_3 + a_2b_0 + a_3b_1, \\ & a_0b_3 + a_1b_2 - a_2b_1 + a_3b_0) \end{align}\)
商:( a,/,b = a^{-1} \times b )
龙曲线。 编写一个程序 Dragon.java,读取一个命令行参数 N,并使用海龟图形绘制 N 阶龙曲线。龙曲线最初由三位 NASA 物理学家(约翰·E·海威、布鲁斯·A·班克斯和威廉·G·哈特)发现,后来由马丁·加德纳在《科学美国人》(1967 年 3 月和 4 月)和迈克尔·克莱顿在《侏罗纪公园》中推广。
![]()
![]()
![]()
![]()
![]()
![]()
![]()
![]()
![]()
![]()
这是一个使用两个相互递归函数的复杂程序。
程序 SequentialDragon.java 是龙曲线的迭代版本。这是一个黑客的天堂。
希尔伯特曲线。 填充空间曲线 是一个连续的曲线,在单位正方形中通过每一点。编写一个递归的
Turtle客户端 Hilbert.java(或 SingleHilbert.java),生成这些递归模式,这些模式逼近数学家大卫·希尔伯特在 19 世纪末定义的填充空间曲线。![一阶希尔伯特曲线]()
![二阶希尔伯特曲线]()
![三阶希尔伯特曲线]()
![四阶希尔伯特曲线]()
![五阶希尔伯特曲线]()
高斯波岛。 编写一个递归的
Turtle客户端 GosperIsland.java,生成这些递归模式。![]()
![]()
![]()
![]()
![]()
使用牛顿法混沌。 多项式 (f(z) = z⁴ - 1) 在 1, −1, i, 和 −i 处有 4 个根。我们可以在复平面上使用牛顿法找到这些根:(z_{k+1} = z_k - f(z_k) ,/ , f'(z_k))。这里 (f(z) = z⁴ - 1),(f'(z) = 4z³)。该方法收敛到 4 个根中的一个,取决于起始点 (z_0)。编写一个程序 NewtonChaos.java,接受一个命令行参数 n,并创建一个以原点为中心、边长为 2 的正方形的 n×n 图像。根据相应复数收敛到的四个根中的哪一个,将每个像素着色为白色、红色、绿色或蓝色(如果经过 100 次迭代后仍未收敛,则为黑色)。
![牛顿法混沌]()
彩色曼德勃罗特图。 创建一个包含 256 个整数三元组的文件,表示有趣的
Color值,然后使用这些颜色而不是灰度值来绘制 ColorMandelbrot.java 中的每个像素。读取这些值以创建一个包含 256 个Color值的数组,然后使用mand()的返回值索引到该数组中。通过在集合的各个位置尝试不同的颜色选择,您可以产生令人惊叹的图像。参见 mandel.txt 以获取示例。![曼德勃罗特集]()
![曼德勃罗特集]()
-1.5 -1.0 2.0 2.00.10259 -0.641 0.0086 0.0086朱利亚集。 对于给定复数 c,朱利亚集是与曼德勃罗特函数相关的一组点。我们不是固定 z 并变化 c,而是固定 c 并变化 z。那些修改后的曼德勃罗特函数保持有界的点 z 属于朱利亚集;那些序列发散到无穷大的点 z 不属于该集合。所有感兴趣的点 z 都位于以原点为中心的 4x4 方框内。对于 c 的朱利亚集是连通的,当且仅当 c 在曼德勃罗特集中时!编写一个程序 ColorJulia.java,它接受两个命令行参数 a 和 b,并使用前面练习中描述的颜色表方法绘制 c = a + bi 的朱利亚集的彩色版本。
![Julia set]()
![Julia set]()
-1.25 0.00-0.75 0.10
网页练习
IP 地址。 创建一个用于 IPv4(互联网协议第 4 版)地址的数据类型。IP 地址是一个 32 位整数。
日期。 创建一个表示日期的数据类型
Date。您应该能够通过指定月、日和年来创建一个新的Date。它应该支持计算两个日期之间的天数、返回一天落在星期几的方法等。定时炸弹。 UNIX 用一个有符号整数表示自 1970 年 1 月 1 日以来的秒数来表示日期。编写一个客户端程序来计算这个日期将发生的时间。在您的日期数据类型中添加一个静态方法
add(Date d, int days),返回日期 d 之后指定天数的新日期。请注意,一天有 86,400 秒。量子位。 在量子计算中,量子位扮演位的角色。它是一个复数 a + bi,使得|a + bi| = 1。一旦我们测量一个量子位,它就会以概率 a² 成为 1,以概率 b² 成为 0。任何后续的观察都将始终产生相同的值。实现一个数据类型
Qubit,它具有一个构造函数Qubit(a, b)和一个布尔方法observe,根据规定的概率返回true或false。生物节律。 生物节��是您身体的三个自然周期的伪科学概况:身体(23 天)、情绪(28 天)和智力(31 天)。编写一个程序,接受六个命令行输入 M、D、Y、m、d 和 y,其中 (M、D、Y) 是您的生日的月份(1-12)、日期(1-31)和年份(1900-2100),而 (m、d、y) 是今天的月份、日期和年份。然后根据公式:sin (2 π 年龄 / 周期长度) 在 -1.0 到 1.0 的范围内打印出您的生物节律。使用前面练习中创建的日期数据类型。
粒子。 创建一个用于基本或复合粒子(电子、质子、夸克、光子、原子、分子)的数据类型。每个粒子应该有一个实例变量来存储其名称、质量、电荷和自旋(1/2 的倍数)。
夸克。 夸克是已知的物质最小的基本构建块。创建一个用于夸克的数据类型。包括一个字段用于其类型(上夸克、下夸克、魅夸克、奇夸克、顶夸克或底夸克)和其颜色(红色、绿色或蓝色)。其电荷分别为 +2/3、-1/3、+2/3、-1/3、+2/3、-1/3。所有夸克的自旋都是 1/2。
生物节律. 在一个为期 6 周的时间间隔内,使用海龟图形绘制你的生物节律。识别临界日,即你的节律从正向负转变的日子 - 根据生物节律理论,这是你最容易发生事故、不稳定和出错的时候。
Vector3. 包括三维向量的法线向量运算,包括叉乘。两个向量的叉乘是另一个向量。a 叉乘 b = ||a|| ||b|| sin(theta) n,其中 theta 是 a 和 b 之间的角度,n 是垂直于 a 和 b 的单位法线向量。 (a[1], a[2], a[3]) 叉乘 (b[1], b[2], b[3]) = (a[2] b[3] - a[3] b[2], a[3] b[1] - a[1] b[3], a[1] b[2] - a[2] b[1])。注意 |a 叉乘 b| = 以 a 和 b 为边的平行四边形的面积。叉乘在力矩、角动量和向量算子旋度的定义中出现。
四维矢量. 创建一个数据类型用于四维矢量。四维矢量是一个四维向量 (t, x, y, z),受洛伦兹变换的约束。在狭义相对论中很有用。
欧几里德点. 创建一个名为
EuclideanPoint.java的数据类型,表示一个 d 维点。包括一个方法,使得p.distanceTo(q)返回点 p 和 q 之间的欧几里德距离。矢量场. 矢量场 将一个向量与欧几里德空间中的每个点相关联。在物理学中广泛用于模拟运动物体的速度和方向,或者牛顿力的强度和方向。
饮料机. 创建一个名为
SodaMachine的数据类型,其中包括insertCoin()、getChange()、buy()等方法。月份. 编写一个数据类型
Month,表示一年中的十二个月之一。它应该包括月份名称、月份天数和诞生石的字段。月份 天数 诞生石 一月 31 石榴石 二月 28 紫水晶 三月 31 海蓝宝石 四月 30 钻石 五月 31 翡翠 六月 30 紫翠石 七月 31 红宝石 八月 31 橄榄石 九月 30 蓝宝石 十月 31 蛋白石 十一月 30 黄玉 十二月 31 蓝锆石 高斯乘法. 使用仅 3 次浮点乘法(而不是 4 次)实现复数乘法。你可以使用多达 5 次浮点加法。 答案: 高斯给出了以下方法来计算 (a + bi)(c + di)。设 x1 = (a + b)(c + d),x2 = ac,x3 = bd。那么乘积为 x + yi,其中 x = x2 - x3,y = x1 - x2 - x3。
张量. 创建一个数据类型用于张量。
联合国国家. 为联合国国家创建一个名为
Country的数据类型。包括 3 位联合国代码、3 个字母 ISO 缩写、国家名称和首都的字段。编写一个程序 Country.java,读取国家列表并将它们存储在类型为Country的数组中。使用方法String.split来帮助解析输入文件。区号. 为北美电话区号创建一个数据类型。包括区号、城市和州名以及两个字母的州缩写。或者为国际电话区号创建一个数据类型。包括区域、代码和国家的字段。
国会选区. 为地点、县和国会选区创建一个数据类型。包括地点名称、县名、县代码、邮政编码、国会选区等字段。使用来自1998 FIPS55-DC3 索引的数据集:宾夕法尼亚 (2MB) 或所有 50 个州加上哥伦比亚特区和 9 个外围地区 (30MB)。
纬度和经度。 对于美国的纬度和经度,使用TIGER 数据库或www.bcca.org或地名数据库。对于世界其他地区,请使用earth-info。
天文学。有关小行星、流星和彗星的数据。
财富 1000 强公司。 创建一个数据类型,用于财富 1000 强。包括公司名称和以百万美元计的销售收入字段。数据取自 2002 年 4 月 15 日《财富》杂志。注意:目前需要解析数据。
分子量。 编写一个程序,用户输入一个分子 H2 O,程序计算其分子量。
一些可能有用的数据文件:芳香疗法、营养信息、气象术语词汇表、精神疾病、15 种语言翻译的单词、表情符号词典、常见名字的含义、世界年鉴关于国家的事实。
学生记录。 创建一个数据类型 Student.java 来表示初级计算机科学课程中的学生。每个学生记录对象应表示名字、姓氏、电子邮件地址和分组号。包括一个
toString()方法,返回学生的字符串表示形式,以及一个less()方法,通过分组号比较两个学生。阻抗。 阻抗是从直流电路到交流电路的电阻的概括。在交流电路中,组件的阻抗测量其在给定频率ω下对电子流动的阻力。阻抗有两个组成部分:电阻和电抗。电路组件的电阻 R 测量其在给定电压下对电子运动的阻力(对电子运动的摩擦)。电路组件的电抗 X 测量其在电流和电压波动时存储和释放能量的能力(对电子运动的惯性)。
在仅有电阻的电路中,电流与电压成正比。然而,对于电容器和电感器,电流和电压之间存在+- 90 度的“相位移”。这意味着当电压波达到最大值时,电流为 0,当电流达到最大值时,电压为 0。为了统一对电阻(R)、电感(L)和电容(C)的处理,方便起见,将阻抗视为复数 Z = R + iX。电感器的阻抗是 iwL,电容器的阻抗是 1/iwC。要确定串联电路元件的阻抗,只需将它们各自的阻抗相加即可。电气工程中的两个重要量是阻抗的大小和相位角度。大小是 RMS 电压与 RMS 电流的比值 - 它等于复阻抗的大小。相位角度是电压领先或滞后于电流的量 - 它是复阻抗的相位。程序 CircuitRLC.java 进行了涉及复数和串联电阻、电感和电容电路的阻抗的计算。
练习:并联 RLC 电路。1/Z = 1/Z1 + 1/Z2 + ... 1/Zn。
练习(针对对象):重复使用阻抗而不是电阻的 RLC 电路的串并联网络
粒子在流体中的扩散。 模拟流体中粒子的扩散。参见第 9.8 节中的 BrownianParticle.java。
电场线。 迈克尔·法拉第引入了一种称为电场线的抽象概念来可视化电场。根据库仑定律,由点电荷 q[i]诱导的点处的电场为 E[i] = k q[i] / r²,方向指向 q[i]如果 q[i]为负,远离 q[i]如果为正。如果有一组 n 个点电荷,点处的电场是 n 个单个点电荷诱导的电场的矢量和。我们可以通过在 x 和 y 方向上求和分量来计算它。下图说明了两个相等正点电荷(左)和两个异号点电荷(右)的场线。第二种配置称为电偶极:电荷互相抵消,当您远离电荷时,电场迅速减弱。电偶极的例���可以在电荷分布不均匀的分子中找到。振荡的电偶极可用于产生电磁波以传输无线电和电视信号。
![电势]()
![电势]()
程序 FieldLines.java 绘制出每个电荷发出的 10 条电场线。(我们稍微取了些自由,因为传统上单位面积上的电场线条数应该与电场强度的大小成比例。)每条线从电荷周围的一个 1 像素圆圈开始,以十二等分角度开始。从点电荷 q[i]到点(x, y)处的电场由 E[i] = k q[i] / r²给出,其中 q[i]是电荷 i 的大小,r 是到它的径向距离。由几个电荷产生的电场是每个电场的矢量和,可以通过添加 x 和 y 分量来找到。计算电场强度后,我们沿着矢量场的方向移动并绘制一个点。我们重复这个过程,直到到达区域边界或另一个点电荷。下面的图示说明了几个相等大小的随机电荷的电势和电场线。
![电势和电场线]()
![电势和电场线]()
![电势和电场线]()
彩虹色的科赫雪花。
第 n 阶科赫雪花由三个复制的阶数为 n 的科赫曲线组成。我们依次绘制这三条科赫曲线,但在它们之间顺时针旋转 120°。下面是阶数为 0、1、2 和 3 的科赫雪花。编写一个程序 KochRainbow.java,以连续的彩虹色谱从红色到紫色绘制科赫雪花。
![科赫雪花]()
![科赫雪花]()
![科赫雪花]()
![科赫雪花]()
![科赫雪花]()
反向科赫雪花。 反向科赫雪花的生成方式与科赫雪花完全相同,只是顺时针和逆时针互换。编写一个名为
AntiKoch.java的程序,接受一个命令行参数 N,并使用 Turtle 图形绘制阶数为 N 的反向科赫雪花。![]()
![]()
![]()
![]()
![]()
随机科赫雪花。 生成一个随机科赫雪花,与科赫雪花完全相同,只是我们在每一步翻转硬币以生成顺时针和逆时针方向。
海龟图形。
明科夫香肠。 (Sausage.java)
![]()
![]()
![]()
![]()
![]()
Cesaro 破碎的正方形。
![]()
![]()
![]()
![]()
![]()
更多海龟图形。 编写一个程序来生成以下每个递归图案。
莱维织物。 (Levy.java)
![]()
![]()
![]()
![]()
![]()
![]()
![]()
![]()
![]()
![]()
Fudgeflake。
![]()
![]()
![]()
![]()
![]()
![]()
![]()
![]()
![]()
![]()
海龟图形(困难)。 编写一个程序来生成以下每个递归图案,而不需要抬起笔或重复跟踪同一线段。
谢尔宾斯基箭头。
![]()
![]()
![]()
![]()
![]()
谢尔宾斯基曲线。
![]()
![]()
![]()
![]()
![]()
曼德勃罗特轨迹。 编写一个交互式程序 Trajectory.java,在复平面中绘制曼德勃罗特迭代中的点序列。如果用户点击(x, y),则为 z = x + iy 绘制迭代序列。
更快的曼德勃罗特。 通过直接执行计算而不是使用
Complex来加速曼德勃罗特。进行周期性检查或边界追踪以进一步改进。使用分而治之:选择矩形的 4 个角和几个随机点内部;如果它们都是相同颜色,就用该颜色着色整个矩形;否则将其分成 4 个矩形并递归。随机漫步者。 编写一个数据类型
RandomWalker,模拟平面上从原点开始的随机漫步者的运动,每一步都是随机的(左、右、上或下)。包括一个移动随机漫步者一步的方法step()和一个返回随机漫步者距离原点的距离的方法distance()。使用这个数据类型来提出一个关于随机漫步者在 N 步之后离原点多远(作为 N 的函数)的假设。(另见练习 1.x。)大有理数。 创建一个数据类型 BigRational.java 用于正有理数,其中分子和分母可以是任意大的。提示:使用java.math.BigInteger
豪华海龟图形。 以各种方式扩展 Turtle。创建一个添加颜色等功能的
DeluxeTurtle。添加一个支持错误检查的版本。例如,如果海龟超出指定边界,则抛出TurtleOutOfBounds异常。编写一个程序 FourChargeClient.java,接受一个
double命令行参数r,创建四个Charge对象,每个对象距离屏幕中心(0.5, 0.5)的距离为r,并打印由这四个电荷组合在位置(0.25, 0.5)处产生的电势。所有四个电荷应具有相同的单位电荷。当执行程序 Bug1.java 时为什么会创建一个
java.lang.NullPointerException?public class Bug1 { private String s; public void Bug1() { s = "hello"; } public String toString() { return s.toUpperCase(); } public static void main(String[] args) { Bug1 x = new Bug1(); StdOut.println(x); } }答案: 程序员可能打算让无参数构造函数将字符串设置为
hello。然而,它有一个返回类型(void),所以它是一个普通的实例方法,而不是构造函数。它只是碰巧与类名相同。当执行程序 Bug2.java 时为什么会创建一个
java.lang.NullPointerException?实现一个数据类型
Die用于掷一个公平的骰子,比如有 6 个面。包括一个修改器方法roll()和一个访问器方法value。实现一个可变数据类型
LFSR用于线性反馈移位寄存器。实现一个可变数据类型
Odometer。复数三角函数。 在 Complex.java 中添加支持��数的三角函数和指数函数。
(\exp(a + ib) = ea \cos(b) + i , ea \sin(b))
(\sin(a + ib) = \sin(a) \cosh(b) + i \cos(a) \sinh(b))
(\cos(a + ib) = \cos(a) \cosh(b) - i \sin(a) \sinh(b))
(\tan(a + ib) = \sin(a + ib) ;/; \cos(a + ib))
实现一个数据类型
VotingMachine用于计票。包括修改器方法voteRepublican(),voteDemocrat()和voteIndependent()。包括一个访问器方法getCount()来检索总票数。当您尝试编译和执行以下代码片段时会发生什么?
Student x; StdOut.println(x);答案: 它抱怨
x可能未初始化,并且无法编译。当您尝试编译和执行以下代码片段时会发生什么?
Student[] students = new Student[10]; StdOut.println(students[5]);答案: 它编译并打印出
null。以下代码片段有什么问题?
int n = 17; Dog[] dogs = new Dog[n]; for (int i = 0; i < n; i++) { dogs[i].bark(); dogs[i].eat(); }答案: 它产生一个
NullPointerException,因为我们忘记使用new来创建每个单独的Dog对象。要纠正,添加以下循环在数组初始化语句之后。for (int i = 0; i < n; i++) dogs[i] = new Dog();以下代码片段打印什么?
Complex c = new Complex(2.0, 0.0); StdOut.println(c); StdOut.println(c.mul(c).mul(c).mul(c)); StdOut.println(c);以下交换 Student 对象 x 和 y 的代码片段有什么问题?
Student swap = new Student(); swap = x; x = y; y = swap;答案: 首先,数据类型
Student没有无参数构造函数。如果有的话,那么它在技术上是正确的,但new Student()这行是多余且浪费的。它为一个新的学生对象分配内存空间,将swap设置为该内存地址,然后立即将swap设置为x的内存地址。分配的内存不再可访问。以下版本是正确的。Student swap = x; x = y; y = swap;找到使 Mandelbrot 更新公式收敛(z0 = 1/2 + 0i)、周期为 1 循环(z0 = -2 + 0i)、周期为 2 循环(z0 = -1 + 0i)或保持有界而不收敛(z0 = -3/2 + 0i)的输入。
Point3D。 创建一个用于三维空间中点的数据类型。包括一个接受三个实数坐标 x、y 和 z 的构造函数。包括计算欧几里得距离、欧几里得距离平方和 L1 距离的方法
distance、distanceSquared和distanceL1。创建一个数据类型 PhoneNumber.java,表示美国电话号码。构造函数应该接受三个字符串参数,区号(3 个十进制数字)、交换机号(3 个十进制数字)和分机号(4 个十进制数字)。包括一个
toString方法,打印出形式为 (800) 867-5309 的电话号码。包括一个方法,使得p.equals(q)返回true,如果电话号码 p 和 q 相同,则返回false。重新实现 PhoneNumber.java,但使用三个整数字段来实现。构造函数接受三个整数参数。评论这种方法相对于字符串表示的优缺点。
答案:在时间和内存上更有效率。在构造函数和
toString方法中处理前导 0 的正确性更加麻烦。编写一个程序来绘制均匀场的场线。在垂直列中排列 N 个等间距带电粒子,带电量为 e,并在另一侧排列 N 个带电量为 -e 的粒子,使得一侧的每个电荷与另一侧对应的电荷对齐。这模拟了平行板电容器内的电场。关于结果电场,你能说些什么?A. 几乎均匀。
等势面。 一个等势面是所有具有相同电势 V 的点的集合。给定一组 N 个点电荷,通过绘制等势面(又称等势线图),可以直观地可视化电势。程序 Equipotential.java 通过计算每个网格点的电势并检查电势是否在 5V 的倍数的像素范围内来绘制每 5V 画一条线。由于电场 E 衡量了电势的变化量,E * eps 是电势在 1 像素距离内变化的范围。它依赖于辅助程序 DeluxeCharge.java。
![电势等势线]()
![电势等势线]()
同时绘制场线和等势线也很有趣。场线始终垂直于等势线。
调色板。 使用不同的调色板创建曼德勃罗特集和 Julia 集。例如,这种方案是由 Hubert Grassmann 提出的。
Color[] colors = new Color[ITERS]; for (int t = 0; t < ITERS; t++) { // or some other primes int r = 13*(256-t) % 256; int g = 7*(256-t) % 256; int b = 11*(256-t) % 256; colors[t] = new Color(r, g, b); }并生成一个引人注目的 Julia 集图像
![Julia 集]()
3.3 设计数据类型
原文:
introcs.cs.princeton.edu/java/33design译者:飞龙
在本节中,我们讨论封装、不可变性和继承,特别关注这些机制在数据类型设计中的应用,以实现模块化编程,促进调试,并编写清晰和正确的代码。
封装。
通过隐藏信息将客户端与实现分离的过程被称为封装。我们使用封装来实现模块化编程��促进调试,并澄清程序代码。
复数再探.
Complex.java 与 Complex.java 具有相同的 API,只是它使用极坐标 (r (\cos \theta + i \sin \theta)) 而不是笛卡尔坐标 (x + iy) 表示复数。封装的思想是我们可以将其中一个程序替换为另一个而不改变客户端代码。私有. 当你将一个实例变量(或方法)声明为
private时,你使得任何客户端(另一个类中的代码)无法直接访问该实例变量(或方法)。这有助于强制封装。限制错误的潜力. 封装还帮助程序员确保他们的代码按预期运行。要理解这个问题,考虑 Counter.java,它封装了一个单个整数,并确保唯一可以对该整数执行的操作是加 1。
![计数器 API]()
没有
private修饰符,客户端可以编写如下代码:Counter counter = new Counter("Volusia"); counter.count = -16022;使用
private修饰符,像这样的代码将无法编译。
不可变性。
如果一个数据类型的对象一旦创建就不能改变其数据类型值,则该对象是不可变的。一个不可变数据类型是其中所有对象都是不可变的数据类型。
不可变性的优势. 我们可以在赋值语句中使用不可变对象(或作为方法的参数和返回值)而不必担心它们的值会改变。这使得不可变类型更容易推理和调试。
不可变性的代价. 不可变性的主要缺点是必须为每个值创建一个新对象。
最终. 当你将一个实例变量声明为
final时,你承诺只分配一次值。这有助于强制不可变性。引用类型.
final访问修饰符不能保证可变类型的实例变量是不可变的。在这种情况下,你必须进行防御性拷贝。
空间向量。
一个空间向量是一个具有大小和方向的抽象实体。 一系列n个实数足以指定n维空间中的一个向量。我们使用粗体字母如( \boldsymbol )表示向量( ( x_0, x_1, ; \ldots, ; x_) )。
API. 向量的基本操作是将两个向量相加,将一个向量乘以一个标量,计算两个向量的点积,以及计算大小和方向,如下所示:
加法: ( \boldsymbol + \boldsymbol = ( x_0 + y_0, x_1 + y_1, ; \ldots, ; x_ + y_) )
向量缩放: ( \alpha \boldsymbol = (\alpha x_0, \alpha x_1, ; \ldots, ; \alpha x_) )
点积 ( \boldsymbol \cdot \boldsymbol = x_0y_0 + x_1y_1 + , \ldots , + x_y_ )
大小: ( \left | \boldsymbol \right | = \sqrt{x_0² + x_1² + , \ldots , + x_²} )
方向: ( \boldsymbol ,/, \left | \boldsymbol \right | = (x_0 ,/, \left | \boldsymbol \right |, x_1 ,/, \left | \boldsymbol \right |, ; \ldots, ; x_ ,/, \left | \boldsymbol \right |) )
这些基本的数学定义立即导致了一个 API:
![向量 API]()
实现。Vector.java 是一个不可变的数据类型,实现了这个 API。在内部,它使用长度为n的数组来存储笛卡尔坐标。
*this 引用。*在实例方法(或构造函数)中,
this关键字为我们提供了一种引用调用的对象的方式。例如,Vector 中的magnitude()方法以两种方式使用this关键字:调用dot()方法和作为dot()方法的参数。// return the magnitude of this Vector public double magnitude() { return Math.sqrt(this.dot(this)); }
接口继承(子类型化)。
Java 提供了interface构造用于声明否则无关的类之间的关系,通过指定每个实现类必须包含的一组公共方法。接口使我们能够编写客户端程序,可以通过调用接口的公共方法来操作不同类型的对象。
*定义接口。*Function.java 为单变量实值函数定义了一个接口。
public interface Function { public abstract double evaluate (double x); }接口的主体包含一个抽象方法列表。抽象方法是声明但不包含任何实现代码的方法;它只包含方法签名。您必须将 Java 接口保存在与接口名称匹配的文件中,扩展名为
.java。*实现接口。*要编写一个实现接口的类,您必须做两件事。
在类声明中包含一个
implements子句,其中包含接口的名称。实现接口中的每个抽象方法。
例如,Square.java 和 GaussianPDF.java 实现了
Function接口。*使用接口。*接口是一个引用类型。因此,您可以声明变量的类型为接口的名称。当您这样做时,分配给该变量的任何对象必须是实现接口的类的实例。例如,类型为
Function的变量可以存储类型为Square或GaussianPDF的对象。Function f1 = new Square(); Function f2 = new GaussianPDF(); Function f3 = new Complex(1.0, 2.0); // compile-time error当接口类型的变量调用接口中声明的方法时,Java 知道调用哪个方法,因为它知道调用对象的类型。这种强大的编程机制称为多态或动态分派。
绘制函数。FunctionGraph.java 通过在n+1 个均匀间隔的点上对函数进行采样,在区间[a, b]中绘制实值函数f的图形。它适用于任何实现
Function接口的足够平滑的函数f。![绘制函数图]()
数值积分。RectangleRule.java 使用矩形法则估计在区间(a, b)中的正实值函数f的积分。它适用于任何实现
Function接口的足够平滑的函数f。![矩形法则]()
Lambda 表达式。为了简化语法,Java 提供了一个强大的函数式编程特性,称为lambda 表达式。您应该将 lambda 表达式视为可以传递并稍后执行的代码块。在其最简单的形式中,lambda 表达式由三个元素组成:
由逗号分隔的参数变量列表,括在括号中
lambda 运算符
->一个单一表达式,这是 lambda 表达式返回的值
例如,以下 lambda 表达式实现了斜边函数:
![lambda 表达式的解剖]()
我们主要使用 lambda 表达式作为实现函数接口(具有单个抽象方法的接口)的简洁方式。具体来说,您可以在需要函数接口对象的任何地方使用 lambda 表达式。例如,以下所有表达式都实现了 Function.java 接口:
![函数接口]()
因此,您可以通过调用
integrate(x -> x*x, 0, 10, 1000)来集成平方函数,而无需定义一个单独的Square类。内置接口. Java 包含三个内置接口,我们将在本书后面考虑。
接口java.util.Comparable定义了一种比较同一类型对象的顺序,例如字符串的字母顺序或整数的升序。
接口java.util.Iterator和java.lang.Iterable使客户端能够在不依赖底层表示的情况下遍历集合中的项。
实现继承(子类化)。
Java 还支持另一种继承机制,称为子类化。其思想是定义一个新类(子类或派生类),从另一个类(超类或基类)继承实例变量(状态)和实例方法(行为),实现代码重用。通常,子类会重新定义或覆盖超类中的一些方法。例如,Java 为 GUI 组件提供了一个复杂的继承层次结构:
在这本书中,我们避免使用子类化,因为它违反了封装性和不可变性(例如,脆弱基类问题和圆-椭圆问题)。
Java 的 Object 超类. Java 中内置了一些子类化的遗留问题,因此不可避免。具体来说,每个类都是java.lang.Object的子类。在 Java 编程中,您经常会重写其中一个或多个继承的方法:
![对象 API]()
字符串转换. 每个 Java 类都继承了
toString()方法,因此任何客户端都可以为任何对象调用toString()。这个约定是 Java 自动将字符串连接运算符+的一���操作数转换为字符串的基础,只要另一个操作数是字符串。引用相等性. 如果我们用
(x == y)测试相等性,其中x和y是对象引用,我们正在测试它们是否具有相同的标识:即对象引用是否相等。对象相等性.
equals()方法的目的是测试两个对象是否相等(对应于相同的数据类型值)。它必须实现一个等价关系:自反性:
x.equals(x)为true。对称性:当且仅当
x.equals(y)为true时,y.equals(x)也为true。传递性:如果
x.equals(y)为true且y.equals(z)为true,则x.equals(z)为true。
此外,以下两个属性必须成立:
多次调用
x.equals(y)返回相同的真值,前提是在调用之间没有修改任何对象。x.equals(null)返回false。
重写
equals()方法是意外复杂的,因为它的参数可以是任何类型的对象引用(或null)。哈希。
hashCode()方法的目的是支持哈希,这是一种将对象映射到整数(称为哈希码)的基本操作。它必须满足以下两个属性:如果
x.equals(y)为true,则x.hashCode()等于y.hashCode()。多次调用
x.hashCode()返回相同的整数,前提是对象在调用之间没有被修改。
通常,我们使用哈希码将对象
x映射到一个小范围内的整数,比如在0和m-1之间,使用这个哈希函数:private int hash(Object x) { return Math.abs(x.hashCode() % m); }值不相等的对象可以具有相同的哈希函数值,但我们期望哈希函数将
n个典型对象分成大致相等大小的m组。包装类型。
toString(),hashCode()和equals()方法仅适用于引用类型,而不适用于基本类型。例如,如果x是Integer类型的变量,则表达式x.hashCode()有效,但如果是int类型则无效。在我们希望将基本类型的值表示为对象的情况下,Java 提供了内置的引用类型,称为包装类型,每个基本类型对应一个。自动装箱和拆箱。 Java 自动在包装类型和相应的基本类型之间进行转换,因此您可以编写如下代码:
Integer x = 17; // Autoboxing (int -> Integer) int a = x; // Unboxing (Integer -> int)
应用程序:数据挖掘。
我们考虑一个数据挖掘应用程序,其目标是为每个文档关联一个称为草图的向量,以便不同的文档具有不同的草图,而相似的文档具有相似的草图。我们的 API 将这个概念抽象成方法similarTo(),它是一个介于 0(不相似)和 1(相似)之间的实数。参数k和d控制草图的质量。
计算草图。
Sketch.java 使用简单的频率计数方法来计算文本文档的草图。在其最简单的形式中,它计算文本中每个k-gram(长度为k的子字符串)出现的次数。我们使用的草图是由这些频率定义的向量的方向。哈希。 对于 ASCII 文本字符串,每个字符有 128 个不同的可能值,因此有 128^(k)个可能的k-gram。为了提高效率,Sketch.java 使用哈希。也就是说,我们不是计算每个k-gram 出现的次数,而是将每个k-gram 哈希到 0 到d-1 之间的整数,并计算每个哈希值出现的次数。
比较草图。 Sketch.java 使用余弦相似度度量比较两个草图:
( x \cdot y = x_0y_0 + x_1y_1 + ; \ldots ; + x_y_ )
它是一个介于 0 和 1 之间的实数。
比较所有对。 CompareDocuments.java 打印输入列表中所有文档对的余弦相似度度量。
![文本文档]()
契约式设计。
我们简要讨论了两种 Java 语言机制,使您能够在程序运行时验证对程序的假设 - 异常和断言。
异常。 异常是程序运行时发生的中断事件,通常用于表示错误。所采取的行动称为抛出异常。Java 包括一个预定义异常的复杂继承层次结构,我们之前遇到过其中的几个。
![Java 异常]()
当它们对用户有帮助时,使用异常是一个好的实践。例如,在 Vector.java 中,如果要相加的两个向量具有不同的维度,我们应该在
plus()中抛出一个异常:if (this.length() != that.length()) throw new IllegalArgumentException("Dimensions disagree.");断言. 一个断言是一个布尔表达式,你在程序执行的某个时刻确认为真。如果表达式为假,程序将抛出一个
AssertionError,通常会终止程序并报告一个错误消息。例如,在 Counter.java 中,我们可以通过在increment()的最后一条语句中添加以下断言来检查计数器永远不会为负:assert count >= 0 : "Negative count detected in increment()";默认情况下,断言是禁用的,但您可以通过在命令行中使用
-enableassertions标志(简写为-ea)来启用它们。断言仅用于调试;您的程序不应依赖断言进行正常操作,因为它们可能被禁用。在设计契约编程模型中,设计者使用断言表达关于程序��为的条件。
前置条件. 客户在调用方法时承诺满足的条件。
后置条件. 实现在从方法返回时承诺实现的条件。
不变量. 在方法执行时,实现承诺满足的条件。
练习
仅使用其他
Vector方法(如direction()和magnitude())为 Vector.java 提供minus()的实现。解决方案:
public Vector minus(Vector that) { return this.plus(that.scale(-1.0)); }为 Vector.java 添加一个
toString()方法,返回以逗号分隔的向量分量,并用匹配的括号括起来。
创意练习
统计. 开发一个数据类型,用于维护一组实数的统计信息。提供一个添加数据点的方法和返回点数、均值、标准差和方差的方法。
\(\begin{eqnarray*} \bar x &=& \frac{1}{n} \sum_i x_i \\ s² &=& \frac{\sum_i (x_i - \mu)²}{n-1} \;\; = \;\; \frac{n \sum_i x_i² - (\sum_i x_i)²}{n(n-1)} \end{eqnarray*}\)
开发两种实现:OnePass.java,其实例值为点数和值的总和,以及值的平方和,TwoPass.java,它保留一个包含所有点数的数组。为简单起见,您可以在构造函数中取最大点数。您的第一个实现可能会更快,占用的空间也会大大减少,但也可能容易受到舍入误差的影响。
解决方案: StableOnePass.java 是一个精心设计的替代方案,它在数值上是稳定的,不需要数组来存储元素。
\(\begin{eqnarray*} m_0 &=& 0 \\ s_0 &=& 0 \\ m_n &=& m_{n-1} + \frac{1}{n} \; (x_n - m_{n-1}) \\ s_n &=& s_{n-1} + \frac{n-1}{n} \; (x_n - m_{n-1})² \\ \bar x &=& m_n \\ s² &=& \frac{1}{n-1} s_n \end{eqnarray*}\)
基因组. 开发一个数据类型来存储生物体的基因组。生物学家经常将基因组抽象为核苷酸序列(A、C、G 或 T)。数据类型应支持方法
addNucleotide(),nucleotideAt(),以及isPotentialGene()。开发三种实现。使用一个
String类型的实例变量,使用字符串连接实现addCodon()。每次方法调用的时间与当前基因组的长度成正比。使用字符数组,每次填满时将数组长度加倍。
使用布尔数组,使用两位来编码每个密码子,并在每次填满时将数组长度加倍。
解决方案: StringGenome.java, Genome.java, 和 CompactGenome.java.
封装. 以下类是否是不可变的?
import java.util.Date public class Appointment { private Date date; private String contact; public Appointment(Date date) { this.date = date; this.contact = contact; } public Date getDate() { return date; }解决方案:不行,因为 Java 的 java.util.Date 是可变的。要纠正,需要在构造函数中对日期进行防御性拷贝,并在返回给客户端之前对日期进行防御性拷贝。
日期。 设计一个不可变的 Java java.util.Date API 的实现,从而纠正上一个练习的缺陷。
部分解决方案:Date.java。
网络练习
在 Genome.java 中添加方法来测试相等性并返回反向互补的基因组。
在 Date.java 中添加方法来检查给定日期属于哪个季节(春季、夏季、秋季、冬季)或星座(双鱼座、天秤座,...)。要注意跨越十二月至一月的事件。
在 Date.java 中添加一个
daysUntil()方法,该方法以Date作为参数,并返回两个日期之间的天数。创建一个实现 Date2.java,它将日期表示为自 1970 年 1 月 1 日以来的天数。与 Date.java 进行比较。
创建一个代表矩形的
RectangleADT。通过两个点来表示一个矩形。包括一个构造函数,一个toString方法,一个计算面积的方法,以及一个使用我们的图形库绘制的方法。重复上一个练习,但这次将
Rectangle表示为左下端点和宽度和高度。重复上一个练习,但这次将
Rectangle表示为中心点、宽度和高度。稀疏向量。 创建一个稀疏向量的数据类型。通过非零索引数组和相应的非零值并行数组来表示稀疏向量。假设索引按升序排列。实现点积运算。
拷贝构造函数。 只有在数据类型是可变的情况下才需要。否则,赋值语句可以按预期工作。
public Counter(Counter x) { count = x.count; } Counter counter1 = new Counter(); counter1.hit(); counter1.hit(); counter1.hit(); Counter counter2 = new Counter(counter1); counter2.hit(); counter2.hit(); StdOut.println(counter1.get() + " " + counter2.get()); // 3 5为两次可微函数定义一个接口 DifferentiableFunction.java。编写一个实现函数 f(x) = c - x² 的类 Sqrt.java。
编写一个程序 Newton.java,实现牛顿法来找到一个充分光滑函数的实根,假设你从一个足够接近根的地方开始。当方法收敛时,它会呈二次收敛。假设它以
DifferentiableFunction作为参数。生成随机数。 从标准高斯分布中生成随机数的不同方法。在这里,封装使我们能够用更准确或更有效的方法替换一个版本。三角方法简单,但由于调用多个超越函数可能会很慢。更重要的是,当 x1 接近 0 时,它会遇到数值稳定性问题。更好的方法是 Box-Muller 方法的另一种形式。参考链接。这两种方法都需要两个来自均匀分布的值,并产生两个均值为 0,标准差为 1 的高斯分布值。可以通过记住第二个值以节省工作量。 (这就是在
java.util.Random中实现的方式。)它们的实现是 Box-Muller 的极坐标方法,保存第二个随机数以供后续调用。(参见 Knuth,ACP,第 3.4.1 节算法 C。)洛杉矶机场关闭。 2004 年 9 月 14 日,洛杉矶机场由于空中交通管制员与飞行员通信的无线电系统软件故障而被关闭。该程序使用了一个 Windows API 函数调用
GetTickCount(),它返回自系统上次启动以来的毫秒数。该值以 32 位整数返回,因此大约在 49.7 天后会"环绕"。软件开发人员意识到了这个错误,并实施了一个政策,即每个月技术人员会重新启动机器,以确保其运行时间不超过 31 天。糟糕。洛杉矶时报指责了技术人员,但更应该责备开发人员的糟糕设计。点的极坐标表示。 使用极坐标重新实现 Point.java 数据类型。
解决方案:PointPolar.java。
球坐标系。 使用笛卡尔坐标((x, y, z))或球坐标((r, \theta, \phi))表示三维空间中的点。要从一个坐标系转换到另一个坐标系,使用以下公式
\(\begin{array}{lllllll} r &=& \sqrt{x² + y² + z²} &\hspace{.3in} & x &=& r \cos \theta \sin \phi \\ \theta &=& \tan^{-1}(y/x) & & y &=& r \sin \theta \sin \phi \\ \phi &=& \cos^{-1}(z/r) & & z &=& r \cos \phi \\ \end{array}\)
颜色。 可以用 RGB、CMYK 或 HSV 格式表示。自然而然地会有相同接口的不同实现。
邮政编码。 实现一个表示美国邮政服务 ZIP 码的 ADT。支持原始的 5 位数字格式和更新的(但可选的)ZIP+4 格式。
3.4 案例研究: N 体模拟
原文:
introcs.cs.princeton.edu/java/34nbody译者:飞龙
在本节中,我们编写一个面向对象的程序,动态模拟 n 个身体在相互引力影响下的运动。
弹跳球。
数据类型 Ball.java 表示具有给定位置 ((r_x, r_y)) 的球,它以固定速度 ((v_x, v_y)) 在坐标为 −1 到 +1 的盒子中移动。当它与边界碰撞时,根据弹性碰撞定律反弹。
客户端 BouncingBalls.java 接受一个命令行参数 n 并创建 n 个随机弹跳球。
N 体模拟。
弹跳球模拟基于 牛顿第一定律:运动中的物体保持相同速度的运动,除非受到外力的作用。将该示例装饰以包含重力导致我们进入一个迷住科学家多年的基本问题。给定一个由相互影响的 n 个身体组成的系统,受到引力作用,问题是描述它们的运动。
Body 数据类型. 数据类型 Body.java 表示具有给定位置 ((r_x, r_y)), 速度 ((v_x, v_y)), 和质量 (m) 的身体。它应用 牛顿第三定律(解释了两个身体之间的引力)来确定作用在身体上的净力:
\(\boldsymbol{F} = G \frac{m_1 m_2}{r²}\)
和 牛顿第二定律(解释外力如何直接影响加速度和速度)。
\(\boldsymbol{F} = m \boldsymbol{a}\)
它使用 Vector.java 数据类型来表示位移、速度和力作为矢量量。
![Body 数据类型 API]()
Universe 数据类型. Universe.java 接受一个命令行参数
dt,从标准输入读取一个宇宙,并使用时间量子dt模拟宇宙。以下是数据文件格式的示例:![n 体模拟的数据文件格式]()
以下静态图像 2body.txt, 3body.txt, 和 4body.txt 是通过修改 Universe.java 和 Body.java 来绘制白色身体,然后在灰色背景上绘制黑色身体而制作的。
![2-, 3-, 和 4-体系的模拟]()
练习
从第 1.5 节开发一个面向对象的版本的 BouncingBall.java。包括一个构造函数,该构造函数以随机方向和随机速度(在合理范围内)开始每个球的运动,并且一个测试客户端,从命令行接受一个整数 n 并模拟 n 个弹跳球的运动。
解决方案: Ball.java 和 BouncingBalls.java.
在一个不适用牛顿第二定律的宇宙中会发生什么?这种情况对应于
Body中的forceTo()总是返回零矢量。解决方案: 身体将沿着直线运动,根据它们的初始速度。
创意练习
- N 体模拟跟踪. 编写一个客户端 UniverseTrace.java 产生类似书中静态图像的 n 体模拟系统的跟踪。
网页练习
彩色弹跳球. 修改 Ball.java 和 BouncingBalls.java 以将每个球与颜色关联起来。将你的程序命名为 ColoredBall.java 和 BouncingColoredBalls.java.
摩擦和阻力. 修改 Ball.java 以包含摩擦和阻力。将你的数据类型命名为 DeluxeBall.java.
基于重力模拟器的生成音乐。 根据 n 体模拟生成音乐,其中当物体碰撞时发出音符。Simran Gleason 的网站描述了这一过程,并包括示例视频。
4. 算法和数据结构
原文:
introcs.cs.princeton.edu/java/40algorithms译者:飞龙
概述。
在本章中,我们描述并实现了当今计算机上使用的一些最重要的算法和数据结构。(对于更深入的讨论,我们推荐配套教材《算法,第 4 版》。)我们首先考虑了一个衡量和分析程序效率的强大框架。这使我们能够比较算法并准确预测性能。接下来,我们考虑了几种用于经典排序问题的新颖算法。然后,我们构建了最重要的高级数据结构,包括栈、队列和符号表。
4.1 性能概述了一种科学方法和强大理论,用于理解我们编写的程序的性能和资源消耗。
4.2 排序和搜索描述了两种经典算法——归并排序和二分查找——以及它们的效率在多个应用中起关键作用的情况。
4.3 栈和队列介绍了两种密切相关的数据结构,用于操作任意大量的数据集合。
4.4 符号表考虑了一种存储信息的基本数据结构,称为符号表,以及两种高效的实现方式——哈希表和二叉搜索树。
4.5 小世界现象提供了一个案例研究,探讨了小世界现象——我们都通过短链条的熟人联系相互联系。
本章中的 Java 程序。
以下是本章中的 Java 程序列表。点击程序名称以访问 Java 代码;点击参考号以获取简要描述;阅读教材以获取详细讨论。
参考 程序 描述 4.1.1 ThreeSum.java 3 数之和问题 4.1.2 DoublingTest.java 验证加倍假设 4.2.1 Questions.java 二分查找(20 个问题) 4.2.2 Gaussian.java 二分查找 4.2.3 BinarySearch.java 二分查找(在排序数组中) 4.2.4 Insertion.java 插入排序 4.2.5 InsertionTest.java 插入排序的加倍测试 4.2.6 Merge.java 归并排序 4.2.7 FrequencyCount.java 频率统计 4.3.1 ArrayStackOfStrings.java 字符串栈(数组) 4.3.2 LinkedStackOfStrings.java 字符串栈(链表) 4.3.3 ResizingArrayStackOfStrings.java 字符串栈(调整大小数组) 4.3.4 Stack.java 通用栈 4.3.5 Evaluate.java 表达式求值 4.3.6 Queue.java 通用队列 4.3.7 MM1Queue.java M/M/1 队列模拟 4.3.8 LoadBalance.java 负载均衡模拟 4.4.1 Lookup.java 字典查找 4.4.2 Index.java 索引 4.4.3 HashST.java 哈希表 4.4.4 BST.java 二叉搜索树 4.4.5 DeDup.java 去重过滤器 4.5.1 Graph.java 图数据类型 4.5.2 IndexGraph.java 使用图来反转索引 4.5.3 PathFinder.java 最短路径客户端 4.5.4 PathFinder.java 最短路径实现 4.5.5 SmallWorld.java 小世界测试 4.5.6 Performer.java 表演者-表演者图
4.1 算法分析
原文:
introcs.cs.princeton.edu/java/41analysis译者:飞龙
在本节中,您将学会在编程时尊重一个原则:注意成本。 为了研究运行它们的成本,我们通过科学方法研究我们的程序本身。我们还应用数学分析来推导成本的简洁模型。
科学方法。
以下五步方法总结了科学方法:以下 5 步方法。
观察自然界的某些特征。
假设一个与观察结果一致的模型。
使用假设预测事件。
通过进一步观察验证预测。
通过重复直到假设和观察结果一致来验证。
我们设计的实验必须是可重现的,我们制定的假设必须是可证伪的。
观察结果。
测量程序的确切运行时间很困难,但有许多工具可用于帮助。在本书中,我们简单地在各种输入上运行程序,并使用 Stopwatch.java 数据类型测量处理每个输入所需的时间量。关于大多数程序的第一个定性观察是,存在一个问题规模来表征计算任务的难度。通常,问题规模要么是输入的大小,要么是命令行参数的值。直观地,运行时间应该随问题规模增加而增加,但每次我们开发和运行程序时都会自然地产生一个增加多少的问题。
一个具体的例子。
为了说明这种方法,我们从 ThreeSum.java 开始,它计算一个包含(n)个整数的数组中总和为 0 的三元组的数量。问题规模(n)与ThreeSum的运行时间之间的关系是什么?
加倍假设。 对于许多程序,我们可以快速为以下问题制定一个假设:将输入大小加倍对运行时间有什么影响?
经验分析。 制定加倍假设的一种简单方法是将输入大小加倍并观察对运行时间的影响。DoublingTest.java 为
ThreeSum生成一系列随机输入数组,每一步将数组长度加倍,并打印ThreeSum.count()对于每个输入的运行时间与前一个(大小为前一个的一半)的比率。如果您运行此程序,您会发现每行打印的经过时间增加了大约 8 倍。这立即导致假设:当输入大小加倍时,运行时间增加了 8 倍。对数-对数图。 我们也可以在标准图(左侧)或对数-对数图(右侧)上绘制运行时间。对数-对数图是一条斜率为 3 的直线,清楚地表明运行时间满足(cn³)形式的幂律的假设。
![对数-对数图]()
数学分析。 总运行时间由两个主要因素决定:
每个语句的执行成本。
每个语句的执行频率。
前者是系统的属性,后者是算法的属性。如果我们知道程序中所有指令的这两个属性,我们可以将它们相乘并对程序中所有指令求和,以获得运行时间。
主要挑战在于确定语句的执行频率。有些语句很容易分析:例如,在
ThreeSum.count()中将count设置为0的语句只执行一次。其他语句需要更高级别的推理:例如,在ThreeSum.count()中的if语句被执行了精确地(n(n-1)(n-2) / 6)次。
波浪线符号。
我们使用波浪符号表示法来开发更简单的近似表达式。首先,我们通过使用称为波浪符号的数学工具处理数学表达式的主导项。我们写( \sim g(n))来表示任何数量,当除以(f(n))时,随着(n)的增长趋近于 1。我们还写(g(n) \sim f(n))来表示当(n)增长时,(g(n) ,/, f(n))趋近于 1。使用这种符号,我们可以忽略表示小值的表达式的复杂部分。例如,在ThreeSum.count()中的if语句执行(\sim n³ / 6 )次,因为(n(n-1)(n-2) / 6 = n³/6 - n²/2 + n/3),当除以(n³/6)时,随着(n)的增长趋近于 1。
我们关注执行频率最高的指令,有时被称为程序的内循环。在这个程序中,合理假设是,内循环之外的指令所花费的时间相对不重要。
增长顺序。
分析程序运行时间的关键点在于:对于许多程序,运行时间满足关系(T(n) \sim c f(n)),其中(c)是一个常数,(f(n))是称为运行时间增长顺序的函数。对于典型程序,(f(n))是诸如(\log_2 n, n, n \log_2 n, n²,)或(n³)的函数。
ThreeSum.count()的运行时间增长顺序为(n³)。常数(c)的值取决于执行指令的成本和频率分析的细节,但通常我们不需要计算出具体数值。了解增长顺序通常会立即导致一个倍增假设。对于ThreeSum.count(),知道增长顺序为(n³)告诉我们,当问题规模加倍时,预计运行时间会增加 8 倍,因为
\(\lim_{n\to\infty} \frac{T(2n)}{T(n)} \;=\; \frac{c (2n)³}{c (n)³} \;=\; 8\)
增长顺序分类。
我们只使用几个结构原语(语句、条件、循环和方法调用)来构建 Java 程序,因此我们的程序的增长顺序往往是问题规模的几个函数之一,总结在下表中。
估算内存使用量。
要注意成本,您需要了解内存使用情况。Java 在您的计算机上的内存使用情况是明确定义的(每次运行程序时,每个值将需要完全相同的内存量),但 Java 在各种计算设备上实现,内存消耗取决于实现。
基本类型. 例如,由于 Java
int数据类型是介于-2,147,483,648 和 2,147,483,647 之间的整数值集合,共 2³²个不同的值,因此可以合理地期望实现使用 32 位来表示int值。![Java 中基本类型的典型内存使用情况]()
对象. 要确定对象的内存消耗,我们将每个实例变量使用的内存量与每个对象通常使用的 8 字节开销相加。例如,一个 Complex.java 对象使用 32 字节(16 字节的开销,加上其两个
double实例变量的每个 8 字节)。![复杂对象的典型内存使用情况]()
引用对象通常使用 8 字节的内存。当数据类型包含对对象的引用时,我们必须单独考虑引用的 8 字节和每个对象的 16 字节开销,再加上对象实例变量所需的内存。
数组和字符串。 Java 中的数组是作为对象实现的,通常具有两个实例变量(指向第一个数组元素的内存位置的指针和长度)。对于原始类型,包含(n)个元素的数组使用 24 字节的头信息,加上(n)乘以存储一个元素所需的字节数。在 Java 中,二维数组是数组的数组。例如,一个(n)乘以(n)的整数数组使用大约(4n²)字节的内存。长度为(n)的字符串使用(56 + 2n)字节的内存。
![Java 中变长数据类型的典型内存使用情况]()
详细信息请参阅教科书。
练习
为 ThreeSum.java 实现方法
printAll(),打印所有总和为零的三元组。编写一个程序 FourSum.java,从标准输入读取一个整数
long整数,并计算总和为零的 4 元组的数量。使用四重嵌套循环。你的程序的运行时间增长阶数是多少?估计你的程���在一个小时内可以处理的最大输入大小。然后,运行你的程序验证你的假设。在运行以下代码片段后,变量
count的值作为(n)的函数是多少?int count = 0; for (int i = 0; i < n; i++) for (int j = i+1; j < n; j++) for (int k = j+1; k < n; k++) count++;解决方案:( \displaystyle { n \choose 3} = n (n-1) (n-2) / 6).
确定
ThreeSum中此语句的运行时间作为标准输入上整数数量n的函数的增长阶数:int[] a = StdIn.readAllInts();解决方案:线性。瓶颈是数组初始化和输入循环。然而,根据你的系统,像这样的输入循环的成本可能会在线性对数时间甚至二次时间程序中占主导地位,除非输入大小足够大。
你更喜欢二次、线性对数还是线性算法?
解决方案:虽然根据增长阶数做出快速决定很诱人,但这样做很容易被误导。你需要对问题规模和运行时间的主导系数的相对值有一些概念。例如,假设运行时间分别为(n²)秒,(100 n \log_2 n)秒和(10000 n)秒。对于(n)小于约(1000)的情况,二次算法将是最快的,而线性算法永远不会比线性对数算法更快((n)必须大于(2^{100}),远远大于考虑的范围)。
运用科学方法,针对以下两个代码片段的运行时间作为(n)的函数,发展和验证一个关于增长阶数的假设。
String s = ""; for (int i = 0; i < n; i++) { if (StdRandom.bernoulli(0.5)) s += "0"; else s += "1"; } StringBuilder sb = new StringBuilder(); for (int i = 0; i < n; i++) { if (StdRandom.bernoulli(0.5)) sb.append("0"); else sb.append("1"); } String s = sb.toString();解决方案:第一个是二次的;第二个是线性的。
给出一个线性时间的反转字符串算法。
解决方案:
public static String reverse(String s) { int n = s.length(); char[] a = new char[n]; for (int i = 0; i < n; i++) a[i] = s.charAt(n-i-1); String reverse = new String(a); return reverse; }
创意练习
子集求和。 编写一个程序 SubsetSum.java,从标准输入读取
long整数,并计算这些整数的子集中总和恰好为零的数量。给出你的算法的增长阶数。亚指数函数。 寻找一个增长阶数大于任何多项式函数,但小于任何指数函数的函数。 额外加分:找到一个运行时间具有该增长阶数的程序。
解决方案:(n^{\ln n}).
网页练习
假设算法在输入大小为 1,000、2,000、3,000 和 4,000 时的运行时间分别为 5 秒、20 秒、45 秒和 80 秒。估计解决大小为 5,000 的问题需要多长时间。运行时间的增长阶数是线性、线性对数、二次、三次还是指数?
解决方案:125 秒,二次。
编写一个程序 OneSum.java,从标准输入读取一系列整数并计算其中值为 0 的数量。数据处理循环中执行了多少条指令?
编写一个程序 TwoSum.java,从标准输入中读取一系列整数,并计算总共有多少对数的和恰好为 0。在数据处理循环中执行了多少条指令?
分析以下代码片段的数学特性,并确定其运行时间的增长顺序是线性、二次还是立方的关于n的函数。
for (int i = 0; i < n; i++) for (int j = 0; j < n; j++) for (int k = 0; k < n; k++) c[i][j] += a[i][k] * b[k][j];以下函数返回一个长度为n的随机字符串。作为n的函数,其运行时间的增长顺序是多少?
public static String random(int n) { if (n == 0) return ""; int r = StdRandom.uniform(26); // between 0 and 25 char c = 'a' + r; // between 'a' and 'z' return random(n/2) + c + random(n - n/2 - 1); }埃拉托斯特尼筛法。 作为n的函数,估计埃拉托斯特尼筛法寻找小于或等于n的所有质数的运行时间的增长顺序。
解决方案:理论上,增长顺序是n对数对数n。这源自数论中的 Mertens 定理。实际上,很难识别出对数对数n的因子。
4.2 排序和搜索
原文:
introcs.cs.princeton.edu/java/42sort译者:飞龙
排序问题是将一个数组中的项按升序重新排列。在本节中,我们将详细讨论两种经典的排序和搜索算法——二分搜索和归并排序——以及它们的效率在多个关键应用中发挥的作用。

二分搜索。
在“二十个问题”的游戏中,你的任务是猜测一个秘密数字,该数字是 0 到n−1 之间的一个整数。为简单起见,我们假设n是 2 的幂,并且问题的形式是“数字是否大于或等于x?”
一个有效的策略是维护一个包含秘密数字的区间,猜测区间中间的数字,然后使用答案将区间大小减半。Questions.java 实现了这种策略。这是一个被称为二分搜索的一般问题解决方法的示例。
运行时间分析。由于每次迭代间隔的大小减少了 2 倍(当n = 1 时达到基本情况),二分搜索的运行时间是 lg n。
线性对数鸿沟。使用二分搜索的替代方法是猜测 0,然后 1,然后 2,然后 3,依此类推,直到找到秘密数字。我们将这样的算法称为蛮力算法:它似乎可以完成任务,但并不太关心成本(这可能会阻止它实际完成大问题的任务)。在最坏情况下,运行时间可能高达n。
二进制表示。如果回顾一下 Binary.java,您会发现二分搜索几乎与将数字转换为二进制的计算相同!每次猜测确定答案的一个位。例如,如果数字是 77,则立即得到答案序列 no yes yes no no yes no,这是 77 的二进制表示。

反转递增函数 f(x)。给定一个值y,我们的任务是找到一个值x,使得f(x) = y。我们从一个已知包含x的区间(lo, hi)开始,并使用以下递归策略:
计算mid = lo + (hi − lo) / 2
基本情况:如果(hi − lo)小于δ,则将mid作为x的估计返回
递归步骤:否则,测试f(mid) > y。如果是,就在(lo, mid)中寻找x;如果不是,则在(mid, hi)中寻找x。
Gaussian.java 中的
inverseCDF()方法实现了这种策略,用于高斯累积密度函数Φ。在这种情况下,二分搜索通常被称为二分搜索。

- 有序数组中的二分搜索。二分搜索最重要的用途之一是在有序数组中查找一个项。要做到这一点,查看中间的数组元素。如果包含您正在寻找的项,则完成;否则,从考虑中消除中间元素之前或之后的子数组,并重复。BinarySearch.java 是这种算法的一个实现。
插入排序。
插入排序是一种基于人们经常用来整理扑克牌的简单方法的蛮力排序算法:逐个考虑卡片并将每张卡片插入到已考虑的卡片中的适当位置(保持它们排序)。以下代码在一个 Java 方法中模拟了这个过程,用于对数组中的字符串进行排序:
public static void sort(String[] a) {
int n = a.length;
for (int i = 1; i < n; i++) {
for (int j = i; j > 0; j--) {
if (a[j-1].compareTo(a[j]) > 0)
exch(a, j, j-1);
else break;
}
}
}
在外部for循环的每次迭代开始时,数组中的前i个元素是按顺序排列的;内部循环通过将a[i]与其左侧的每个较大值交换,从右向左移动,直到达到其正确位置,将其移动到数组中的正确位置。这是i为6时的一个例子:
这个过程首先执行i等于1,然后2,然后3,依此类推,如下面的跟踪所示。
运行时间分析。插入排序代码的内部循环位于双重嵌套的
for循环中,这表明运行时间是二次的,但由于break的存在,我们不能立即得出这个结论。最佳情况。当输入数组已经按排序顺序排列时,总比较次数约为n,运行时间为线性。
最坏情况。当输入是逆序排序时,比较次数约为~ 1/2 n²,运行时间为二次。
平均情况。当输入是随机排序时,预期的比较次数约为~ 1/4 n²,运行时间为二次。
对其他类型数据进行排序。我们希望能够对所有类型的数据进行排序,而不仅仅是字符串。对于对数组中的对象���行排序,我们只需要假设我们可以比较两个元素,以查看第一个元素是大于、小于还是等于第二个元素。Java 为此提供了Comparable接口。Insertion.java 实现了插入排序,以便对
Comparable对象的数组进行排序。经验分析。InsertionTest.java 测试了我们关于插入排序对于随机排序数组是二次的假设。
归并排序。
为了开发更快的排序方法,我们使用分而治之的算法设计方法,每个程序员都需要理解。为了归并排序一个数组,我们将其分成两半,独立地对这两半进行排序,然后合并结果以对整个数组进行排序。为了对alo, hi)进行排序,我们使用以下递归策略:
基本情况:如果子数组长度为 0 或 1,则已经排序。
归约步骤:否则,计算
mid = lo + (hi - lo) / 2,递归地对两个子数组a[lo, mid)和a[mid, hi)进行排序,并将它们合并以产生排序结果。
![归并排序 Merge.java 是这种策略的实现。这里是在合并过程中数组内容的跟踪。>
运行时间分析。在最坏情况下,归并排序进行了约~ 1/2 n lg n到~ n lg n次比较,运行时间是线性对数的。有关详细信息,请参阅本书。
二次-线性对数裂缝。n²和n lg n之间的差异在实际应用中产生了巨大的影响。
分而治之算法。对于许多重要问题,相同的基本方法都是有效的,如果您学习算法设计课程,您将了解到这一点。
归约到排序。问题A 归约到问题B,如果我们可以使用问题B的解决方案来解决问题A。例如,考虑确定数组中的元素是否都不同的问题。这个问题归约到排序,因为我们可以对数组进行排序,然后通过排序后的数组进行线性遍历,检查任何条目是否等于下一个条目(如果不是,则元素都不同)。
频率计数。
FrequencyCount.java 从标准输入读取一系列字符串,然后按照频率降序打印找到的不同值的表以及每个值被找到的次数。我们通过两次排序来实现这一点。
计算频率。 我们的第一步是对标准输入中的字符串进行排序。在这种情况下,我们不太关心字符串被排序,而是关心排序将相同的字符串放在一起。如果输入是
to be or not to be to然后排序的结果是
be be not or to to to像数组中的三个
to出现次数相同的字符串一样放在一起。现在,将所有相同的字符串放在数组中,我们可以通过数组进行一次遍历来计算所有的频率。我们在第 3.3 节中考虑过的 Counter.java 数据类型是这项工作的完美工具。排序频率。 接下来,我们对
Counter对象进行排序。我们可以在客户端代码中这样做,无需任何特殊安排,因为Counter实现了Comparable接口。齐夫定律。 FrequencyCount.java 中突出显示的应用是基本的语言分析:文本中哪些单词出现频率最高?一种称为齐夫定律的现象表明,文本中第i个最常见单词的频率与m个不同单词的文本成反比。
练习
- 编写一个过滤器 Dedup.java,从标准输入读取字符串,并打印删除所有重复项的字符串(按排序顺序)到标准输出。
创意练习
这些练习旨在让您体验开发快速解决方案常见问题的经验。考虑使用二分查找、归并排序��设计自己的分治算法。实现并测试您的算法。
整数排序。 编写一个线性时间过滤器 IntegerSort.java,从标准输入读取介于 0 和 99 之间的整数序列,并按排序顺序将相同的整数打印到标准输出。例如,给定输入序列
98 2 3 1 0 0 0 3 98 98 2 2 2 0 0 0 2您的程序应该打印输出序列
0 0 0 0 0 0 1 2 2 2 2 2 3 3 98 98 98三数之和。 给定一个包含n个整数的数组,设计一个算法来确定其中任意三个数是否和为 0。程序的运行时间增长率应为n² log n。额外加分:开发一个能在二次时间内解决问题的程序。
解决方案:ThreeSumDeluxe.java。
快速排序。 编写一个递归程序 Quick.java,通过使用前面练习中描述的分区算法作为子程序,对包含
Comparable对象的数组进行排序:首先,选择一个随机元素 v 作为分区元素。接下来,将数组分成一个包含所有小于 v 的元素的左子数组,一个包含所有等于 v 的元素的中间子数组,一个包含所有大于 v 的元素的右子数组。最后,递归地对左右子数组进行排序。反向域。 编写一个程序从标准输入中读取域名列表,并按排序顺序打印反向域名。例如,
cs.princeton.edu的反向域是edu.princeton.cs。这种计算对于网络日志分析很有用。为此,创建一个实现Comparable接口的数据类型 Domain.java,使用反向域名顺序。数组中的局部最小值。 给定一个包含
n个实数的数组a[],设计一个对数时间算法来找到一个局部最小值(一个索引 i,使得a[i-1] < a[i]且a[i] < a[i+1])。解决方案:查询中间值 a[n/2],以及两个邻居 a[n/2 - 1]和 a[n/2 + 1]。如果 a[n/2]是局部最小值,则停止;否则���较小邻居的一半中搜索。
网页练习
区间的并集。 给定实数线上的 N 个区间,在 O(N log N)的时间内确定它们的并集长度。例如,四个区间[1, 3],[2, 4.5],[6, 9]和[7, 8]的并集为 6.5。
咖啡罐问题。 (大卫·格里斯)。假设你有一个咖啡罐,里面装有未知数量的黑豆和未知数量的白豆。重复以下过程,直到只剩下一颗豆:随机从罐中选取两颗豆。如果它们颜色相同,将它们都丢掉,但插入另一颗黑豆。如果它们颜色不同,扔掉黑色的那颗,但返回白色的那颗。证明这个过程以恰好一颗豆结束。根据初始黑白豆数量,你能推断出最后一颗豆的颜色吗?提示:找到一个过程中保持不变的有用不变量。
垃圾邮件活动。 为了发起非法的垃圾邮件活动,你有一个来自各种域的电子邮件地址列表(电子邮件地址中@符号后面的部分)。为了更好地伪造返回地址,你想从同一域的另一个用户发送电子邮件。例如,你可能想伪造一个从 nobody@princeton.edu 发送到 somebody@princeton.edu 的电子邮件。你如何处理电子邮件列表以使这成为一个高效的任务?
顺序统计。 给定一个包含 N 个元素的数组,不一定按升序排列,设计一个算法来找到第 k 个最大的元素。它应该在随机输入上以 O(N)时间运行。
肯德尔 tau 距离。 给定两个排列,肯德尔 tau 距离是位置不正确的对数。"冒泡排序度量"。给出一个 O(N log N)算法,计算大小为 N 的两个排列之间的肯德尔 tau 距离。在前 k 个列表、社会选择和投票理论、使用表达谱比较基因以及排名搜索引擎结果方面很有用。
对脚点。 给定圆上 N 个点,以原点为中心,设计一个算法来确定是否存在两个对脚点,即连接这两个点的直线经过原点。你的算法应该在时间上与 N log N 成正比。
对脚点。 重复上一个问题,但假设点是按顺时针顺序给出的。你的算法应该在时间上与 N 成正比。
身份。 给定一个按升序排列的包含N个不同整数(正数或负数)的数组
a[]。设计一个算法,找到一个索引i,使得a[i] = i如果这样的索引存在。提示:二分查找。L1 范数。 平面上有 N 个电路元件。你需要沿电路运行一根特殊的线(与 x 轴平行)。每个电路元件必须连接到特殊的线。你应该把特殊的线放在哪里?提示:中位数最小化 L1 范数。
查找共同元素。 给定两个包含 N 个 64 位整数的数组,设计一个算法来打印出两个列表中出现的所有元素。输出应按排序顺序排列。你的算法应该在 N log N 时间内运行。提示:归并排序,归并排序,合并。备注:在比较基础模型中,不可能做得比 N log N 更好。
查找共同元素。 重复上述练习,但假设第一个数组有 M 个整数,第二个数组有 N 个整数,其��M 远小于 N。给出一个在 N log M 时间内运行的算法。提示:排序和二分查找。
字谜。 设计一个 O(N log N)算法来读取一个单词列表并打印出所有字谜。例如,字符串"comedian"和"demoniac"是彼此的字谜。假设有 N 个单词,每个单词最多包含 20 个字母。设计一个 O(N²)算法应该不太困难,但将其降至 O(N log N)需要一些巧妙的方法。
模式识别。 给定平面上的 N 个点列表,找到所有包含 3 个或更多共线点的子集。
模式识别。 给定平面上 N 个点的列表,这些点一般位置(没有三个共线),找到一个新点 p,它与 N 个原始点的任意一对都不共线。
在排序、旋转列表中搜索。 给定一个已经旋转了未知次数的 N 个整数的排序列表,例如,15 36 1 7 12 13 14,设计一个 O(log N) 的算法来确定给定整数是否在列表中。
计算逆序数。 每个用户按偏好顺序对 N 首歌曲进行排名。给定一个偏好列表,找到偏好最接近的用户。根据逆序数计算“最接近”。为该问题设计一个 N log N 算法。
从 N 层楼扔猫。 假设您有一个 N 层楼房和一群猫。假设一只猫被扔到 F 楼或更高处时会死亡,否则会存活。制定一种策略来确定楼层 F,同时杀死 O(log N) 只猫。
从建筑物中扔猫。 重复上一个练习,但制定一种杀死 O(log F) 只猫的策略。提示:重复加倍和二分查找。
从 N 层楼房中扔两只猫。 重复上一个问题,但现在假设您只有两只猫。现在您的目标是最小化扔猫的次数。制定一种策略,在扔猫 O(√N) 次后确定 F(在杀死它们之前)。如果搜索命中(猫在摔下后存活)比未命中(猫死亡)便宜得多,则可能会发生这种情况。
从建筑物中扔两只猫。 重复上一个问题,但只扔 O(√F) 只猫。参考:???。
几乎排序。 给定一个包含 N 个元素的数组,每个元素最多离其目标位置 k 个位置,设计一个在 O(N log k) 时间内排序的算法。
解决方案 1: 将文件分成大小为 k 的 N/k 个片段,并在 O(k log k) 时间内对每个片段进行排序,例如使用归并排序。请注意,这保留了没有元素超出位置 k 个元素的属性。现在,将每个 k 元素块与其左侧的块合并。
解决方案 2: 将前 k 个元素插入二叉堆中。将数组中的下一个元素插入堆中,并删除堆中的最小元素。重复此过程。
合并 k 个排序列表。 假设您有 k 个排序列表,共有 N 个元素。给出一个 O(N log k) 的算法,以生成所有 N 个元素的排序列表。
最长公共反向互补子串。 给定两个 DNA 字符串,找到出现在一个字符串中的最长子串,其反向沃森-克里克互补出现在另一个字符串中。如果两个字符串 s 和 t 是反向互补的,那么 t 是 s 的反向,除了以下替换 AT,CG。例如 ATTTCGG 和 CCGAAAT 是彼此的反向互补。提示:后缀排序。
循环字符串线性化。 质粒包含 DNA 在一个圆形分子中而不是线性分子中。为了在 DNA 字符串数据库中进行搜索,我们需要一个断开它以形成线性字符串的地方。一个自然的选择是留下字典序最小的地方。设计一个算法来计算圆形字符串的规范表示提示:后缀排序。
查找所有匹配项。 给定一个文本字符串,找到查询字符串的所有匹配项。提示:结合后缀排序和二分查找。
具有更少内存的最长重复子串。 不使用后缀数组,其中 suffixes[i] 指的是第 i 个排序后缀,而是维护一个整数数组,使得 index[i] 指的是第 i 个排序后缀的偏移量。要比较由 a = index[i] 和 b = index[j] 表示的子串,比较字符
s.charAt(a)与s.charAt(b),s.charAt(a+1)与s.charAt(b+1),依此类推。您节省了多少内存?您的程序更快吗?空闲时间。 假设一个并行机器处理 n 个作业。作业 j 从 s[j] 处理到 t[j]。给定开始和结束时间列表,找到机器空闲的最长时间间隔。找到机器非空闲的最长时间间隔。
矩阵的局部最小值。 给定一个 N×N 的 N²个不同整数的数组
a,设计一个 O(N)算法来找到一个局部最小值:一个索引对 i 和 j,使得 a[i][j] < a[i+1][j],a[i][j] < a[i][j+1],a[i][j] < a[i-1][j],以及 a[i][j] < a[i][j-1]。单调二维数组。 给定一个 n×n 的元素数组,使得每行按升序排列,每列也按升序排列,设计一个 O(n)算法来确定数组中给定元素 x。你可以假设 n×n 数组中的所有元素都是不同的。
二维极大值。 给定平面上的一组 n 个点,点(xi, yi)支配点(xj, yj)如果 xi > xj 且 yi > yj。极大值是一个不被集合中任何其他点支配的点。设计一个 O(n log n)算法来找到所有极大值。应用:x 轴是空间效率,y 轴是时间效率。极大值是有用的算法。提示:按照 x 坐标升序排序;从右到左扫描,记录迄今为止看到的最高 y 值,并将其标记为极大值。
复合词。 从标准输入读取一个单词列表,并打印出所有的双词复合词。如果列表中包含
after、thought和afterthought,那么afterthought就是一个复合词。注意:复合词中的组成部分不一定长度相同。史密斯规则。 在供应链管理中出现了以下问题。你有一堆工作要在一台机器上安排。(给出示例。)工作 j 需要 p[j]单位的处理时间。工作 j 有一个表示其相对重要性的正权重 w[j] - 将其视为存储工作 j 的原材料成本的单位时间。如果工作 j 在时间 t 完成处理,那么它的成本为 t * w[j]美元。目标是安排工作的顺序,以使每个工作的加权完成时间之和最小化。编写一个名为
SmithsRule.java的程序,它读取一个命令行参数 N 和一个由它们的处理时间 p[j]和权重 w[j]指定的 N 个工作列表,并输出一个最佳的处理工作顺序。提示: 使用史密斯规则:按照处理时间与权重的比率顺序安排工作。这种贪婪规则被证明是最优的。四个质数之和。 哥德巴赫猜想说,所有大于 2 的正偶数都可以表示为两个质数的和。给定一个输入参数 N(奇数或偶数),将 N 表示为四个质数之和(不一定不同),或报告不可能这样做。为了使你的算法对于大的 N 快速,执行以下步骤:
使用厄拉托色尼筛法计算小于 N 的所有质数。
制表一个两个质数之和的列表。
对列表进行排序。
检查列表中是否有两个数字相加等于 N。如果是,打印出相应的四个质数。
打字猴和幂律。 (迈克尔·米岑马赫)假设一个打字猴通过将每个 26 个可能的字母以概率 p 附加到当前单词来创建随机单词,并以概率 1 - 26p 完成单词。编写一个程序来估计生成的单词的频谱。
打字猴和幂律。 重复上一个练习,但假设字母 a-z 出现的概率与以下概率成比例,这是英文文本的典型情况。
CHAR FREQ CHAR FREQ CHAR FREQ CHAR FREQ CHAR FREQ A 8.04 G 1.96 L 4.14 Q 0.11 V 0.99 B 1.54 H 5.49 M 2.53 R 6.12 W 1.92 C 3.06 I 7.26 N 7.09 S 6.54 X 0.19 D 3.99 J 0.16 O 7.60 T 9.25 Y 1.73 E 12.51 K 0.67 P 2.00 U 2.71 Z 0.09 F 2.30 二分查找。 证明以下修改版本的
binarySearch()为什么有效。证明如果关键字在数组中,则它正确返回最小索引i,使得 a[i] = key;如果关键字不在数组中,则返回-i,其中 i 是使得 a[i] > key 的最小索引。// precondition array a in ascending order public static int binarySearch(long[] a, long key) { int bot = -1; int top = a.length; while (top - bot > 1) { int mid = bot + (top - bot) / 2; if (key > a[mid]) bot = mid; else top = mid; } if (a[top] == key) return top; else return -top - 1; }答案。 while 循环不变式表示 top >= bot + 2。这意味着 bot < mid < top。因此,每次迭代中间隔的长度严格减小。while 循环还保持不变式:
a[bot] < key <= a[top],其中a[-1]为负无穷,a[N]为正无穷。范围搜索。 给定 2006 年新泽西道路系统中收集的所有通行费的数据库,设计一种方案来回答以下查询形式:提取给定时间间隔内收集的所有通行费的总和。使用一个实现
Comparable接口的Toll数据类型,其中关键是收取通行费的时间。提示:按时间排序,计算前 i 个通行费的累积和,然后���用二分查找找到所需的间隔。
最长重复子串。 修改 LRS.java 以找到所有最长重复子串。
非递归二分查找。 编写二分查找的非递归版本。
public static int binarySearch(long[] a, long key) { int bot = 0; int top = a.length - 1; while (bot <= top) { int mid = bot + (top - bot) / 2; if (key < a[mid]) top = mid - 1; else if (key > a[mid]) bot = mid + 1; else return mid; } return -1; }两数之和为 x。 给定一个排序的 N 个整数列表和一个目标整数 x,在 O(N)时间内确定是否有两个数的和恰好为 x。
提示:维护索引 lo = 0 和 hi = N-1,并计算 a[lo] + a[hi]。如果总和等于 x,则完成;如果总和小于 x,则减少 hi;如果总和大于 x,则增加 lo。如果一个(或多个)整数为 0,请小心。
单调函数的零点。 让 f 是一个单调递增函数,其中 f(0) < 0 且 f(N) > 0。找到最小的整数 i,使得 f(i) > 0。设计一个算法,使其对 f()进行 O(log N)次调用。
提示:假设我们知道 N,维护一个区间[lo, hi],使得 f[lo] < 0 且 f[hi] > 0,并应用二分查找。如果我们不知道 N,重复计算 f(1),f(2),f(4),f(8),f(16)等,直到找到一个值 N,使得 f(N) > 0。
双峰最大值。 让
a[]是一个开始递增,达到最大值,然后减少的数组。设计一个 O(log N)算法来找到最大值的索引。双峰查找。 如果一个数组由一个递增的整数序列紧接着一个递减的整数序列组成,则它是双峰的。给定一个由 N 个不同整数组成的双峰数组
a,描述如何在 O(log N)步内确定给定整数是否在数组中。提示:找到最大值,然后在每个部分中进行二分查找。两个排序数组的中位数。 给定大小为 N[1]和 N[2]的两个排序数组,以 O(log N)时间找到所有元素的中位数,其中 N = N[1] + N[2]。提示:设计一个更通用的算法,找到任何 k 的最大元素。计算两个列表中较大的那个列表的中位数元素;丢弃至少 1/4 的元素并递归。
元素唯一性。 给定一个包含 N 个长整数的数组,设计一个 O(N log N)算法来确定是否有任何两个相等。提示:排序将相等的值放在一起。
重复计数。 给定一个包含 N 个元素的排序数组,可能包含重复项,在 O(log N)时间内找到 k 的第一次和最后一次出现的索引。给定一个包含 N 个元素的排序数组,可能包含重复项,在 O(log N)时间内找到元素 k 的出现次数。提示:修改二分查找。
公共元素。 编写一个静态方法,该方法以三个字符串数组作为参数,确定是否有任何字符串同时存在于这三个数组中,并如果有,则返回其中一个字符串。您的方法的运行时间应该是总字符串数量的对数线性。
提示:对三个列表进行排序,然后描述如何进行“三路”合并。
最长重复子串。 编写一个程序 LRS.java 来找到字符串中最长的重复子串。找出你最喜欢的书中最长的重复子串。
在 LRS.java 中添加代码,使其打印出最长重复子字符串出现的原始字符串中的索引。
最长公共子串。 编写一个静态方法,找到给定字符串
s和t的最长公共子串。提示:对每个字符串进行后缀排序。然后将两个排序后的后缀合并在一起。
最长重复、不重叠的字符串。 修改 LRS.java 以找到最长的重复子字符串,不重叠。
押韵的单词。 编写一个程序 Rhymer.java,制表一个列表,您可以使用该列表找到押韵的单词。使用以下方法:
将一个单词字典读入字符串数组中。
反转每个单词的字母(例如,
confound变为dnuofnoc)。对结果数组进行排序。
将每个单词中的字母反转回其原始顺序。
例如,
confound在结果列表中与astound和surround等单词相邻。
排序的科学示例。 谷歌显示搜索结果按“重要性”降序排列,电子表格显示按特定字段排序的列,Matlab 按对称矩阵的实特征值降序排序。排序也出现在许多看似与排序无关的应用程序中作为关键子程序,包括:数据压缩(参见 Burrows-Wheeler 编程作业)、计算机图形学(凸包、最近对)、计算生物学(下文讨论的最长公共子串)、供应链管理(安排工作以最小化加权完成时间之和)、组合优化(Kruskal 算法)、社会选择和投票(Kendall's tau 距离)。在历史上,排序对商业应用程序最为重要,但排序也在科学计算基础设施中扮演重要角色。NASA和流体力学社区使用排序来研究稀疏流动中的问题;这些碰撞检测问题尤其具有挑战性,因为它们涉及数十亿粒子,并且只能在超级计算机上并行解决。一些快速 N 体模拟代码中也使用类似的排序技术。排序的另一个重要科学应用是平衡并行超级计算机的处理器。科学家依赖巧妙的排序算法在这些系统上执行负载平衡。
4.3 栈和队列
原文:
introcs.cs.princeton.edu/java/43stack译者:飞龙
在本节中,我们介绍了两种密切相关的数据类型,用于操作任意大的对象集合:栈和队列。栈和队列是集合概念的特殊情况。每个都由四个操作特征化:创建集合,插入项目,移除项目,以及测试集合是否为空。
栈。
栈是基于后进先出(LIFO)策略的集合。按照传统,我们将栈的插入方法命名为push(),将栈的移除操作命名为pop()。我们还包括一个方法来测试栈是否为空,如下所示的 API:
栈的数组实现。
使用数组表示栈是一个自然的想法。特别是,我们维护一个实例变量n,用于存储栈中的项目数量,以及一个数组items[],用于存储n个项目,其中最近插入的项目在items[n-1]中,最早插入的项目在items[0]中。这种策略允许我们在末尾添加和移除项目而无需移动栈中的其他项目。
*固定长度数组实现的字符串栈。*ArrayStackOfStrings.java 实现了这种方法,用于指定构造函数参数的字符串栈的最大容量。要移除一个项目,我们减少
n然后返回a[n];要插入一个新项目,我们将a[n]设置为新项目,然后增加n。![ArrayStackOfStrings 测试客户端的跟踪]()
调整大小的字符串栈数组实现。ResizingArrayStackOfStrings.java 是 ArrayStackOfStrings.java 的一个版本,它动态调整数组
items[]的长度,以便足够大以容纳所有项目,但不会浪费过多空间。首先,在push()中,我们检查是否有空间放置新项目;如果没有,我们创建一个新数组,其长度是旧数组的两倍,并将项目从旧数组复制到新数组。类似地,在pop()中,我们检查数组是否过大,如果是,则减半其长度。![使用数组加倍实现栈的跟踪]()
这种加倍和减半的策略确保栈永远不会溢出,也永远不会少于四分之一满。
*调整大小的通用栈数组实现。*ResizingArrayStack.java 使用调整大小的数组实现了一个通用栈。出于技术原因,在为泛型分配数组时需要进行强制转换。
链表。
单链表由一系列节点组成,每个节点包含对其后继节点的引用(或链接)。按照惯例,最后一个节点中的链接为null,表示终止列表。通过面向对象编程,实现链表并不困难。我们定义了一个递归性质的节点抽象类:
class Node {
String item;
Node next;
}
一个Node对象有两个实例变量:一个String和一个Node。在这个例子中,String是任何我们想要用链表结构化的数据的占位符(我们可以使用任何一组实例变量);类型为Node的实例变量表征了数据结构的链式特性。
*链接在一起的链表。*例如,要构建一个包含项目
"to"、"be"和"or"的链表,我们为每个项目创建一个Node:![链接在一起的链表]()
*插入。*假设您想要将新节点插入到链表中。最容易这样做的地方是在列表的开头。例如,要在给定链表的开头插入字符串
not,其第一个节点为first,我们将first保存在临时变量oldFirst中,为first分配一个新的Node,将其item字段分配给not,将其next字段分配给oldFirst。![将项目插入到链表中]()
*移除。*���设您想要从列表中移除第一个节点。这个操作甚至更容易:只需将
first赋值为first.next。![从链表中移除项目]()
遍历。为了检查链表中的每个项目,我们初始化一个循环索引变量
x,它引用链表的第一个Node。然后,我们通过访问x.item找到与x关联的项目的值,然后更新x以引用链表中的下一个Node,将其赋值为x.next的值,并重复此过程,直到x为null(表示我们已经到达链表的末尾)。这个过程被称为遍历列表,并在这段代码片段中简洁地表达:for (Node x = first; x != null; x = x.next) StdOut.println(x.item);![遍历单链表]()
使用链表实现栈。
使用链表表示栈是一个自然的想法。特别是,我们维护一个实例变量first,它存储对最近插入的项目的引用。这个策略允许我们在链表的开头添加和移除项目,而无需访问链表中任何其他项目的链接。
字符串栈的链表实现。LinkedStackOfStrings.java 使用链表来实现字符串栈。该实现基于我们一直在使用的嵌套类
Node。Java 允许我们以这种自然的方式在类实现中定义和使用其他类。我们将嵌套类指定为private,因为客户端不需要知道链表的任何细节。![使用字符串链表实现堆栈的跟踪]()
*泛型栈的链表实现。*Stack.java 使用单链表实现了一个泛型栈。
队列。
队列支持使用先进先出(FIFO)原则的插入和移除操作。按照惯例,我们将队列的插入操作命名为enqueue,移除操作命名为dequeue,如下所示的 API:
*使用链表实现队列。*Queue.java 使用链表实现了一个字符串的 FIFO 队列。与
Stack类似,我们维护对队列中最近添加的Node的引用first。为了效率,我们还维护对队列中最近添加的Node的引用last。![插入到队列中]()
*调整大小的数组实现队列。*ResizingArrayQueue.java 使用调整大小的数组实现了一个队列。它类似于 ResizingArrayStack.java,但更棘手,因为我们需要从数组的两端添加和移除项目。
![使用数组实现队列的跟踪]()
泛型。
我们已经开发了允许我们构建特定类型的堆栈的堆栈实现,比如String。Java 中的一种特定机制,称为泛型类型,使我们能够构建由客户端代码指定类型的对象集合。
实现一个通用集合。 要实现一个通用集合,我们在尖括号中指定一个类型参数,比如
Item,并在我们的实现中使用该类型参数而不是特定类型。例如,Stack.java 是 LinkedStackOfStrings.java 的通用版本。使用通用集合。 要使用通用集合,客户端必须在创建栈时指定类型参数:
Stack<Integer> stack = new Stack<Integer>();
自动装箱。
我们设计我们的栈是通用的,因此它们可以存���任何类型的对象。Java 语言提供的自动装箱和拆箱功能使我们能够重用通用代码与原始类型。Java 提供了称为包装类型的内置对象类型,每个原始类型对应一个:Boolean、Integer、Double、Character等。Java 自动在这些引用类型和相应的原始类型之间进行转换,以便我们可以编写如下代码:
Stack<Integer> stack = new Stack<Integer>();
stack.push(17); // autoboxing (int -> Integer)
int a = stack.pop(); // unboxing (Integer -> int)
迭代。
有时客户端需要逐个访问集合中的所有项目,而不删除它们。为了保持封装性,我们不希望向客户端透露队列(数组或链表)的内部表示。为了适应这种设计模式,Java 提供了foreach语句。您应该将以下代码片段中的for语句解释为对于集合中的每个字符串 s,打印 s。
Stack collection = new Stack<string>();
...
for (String s : stack)
StdOut.println(s);</string>
以这种方式支持迭代的集合的实现需要实现 Java 的java.util.Iterator和java.util.Iterable接口。有关详细信息,请参阅教科书。
栈和队列应用。
栈和队列有许多有用的应用。
算术表达式求值。 栈的一个重要应用是在解析中。例如,编译器必须解析使用中缀表示法编写的算术表达式。例如,以下中缀表达式求值为 212。
( 2 + ( ( 3 + 4 ) * ( 5 * 6 ) ) )Evaluate.java 评估一个完全括号化的算术表达式。
函数调用抽象。 大多数程序隐式使用栈,因为它们支持实现函数调用的一种自然方式,如下所示:在函数执行的任何时候,将其状态定义为所有变量的值和指向下一个要执行的指令的指针。实现函数调用抽象的自然方式是使用栈。要调用函数,将状态推送到栈上。要从函数调用返回,从栈中弹出状态以将所有变量恢复为函数调用前的值,并在下一个要执行的指令处恢复执行。
M/M/1 队列。 最重要的排队模型之一被称为M/M/1 队列,已被证明可以准确地模拟许多现实情况。它具有三个特性:
有一个服务器—一个 FIFO 队列。
到达队列的间隔时间服从每分钟率为λ的指数分布。
非空队列的服务时间服从每分钟率为μ的指数分布。
MM1Queue.java 模拟一个M/M/1 队列,并绘制等待时间的直方图。
负载平衡。LoadBalance.java 模拟将n个项目分配给一组m个服务器的过程。对于每个项目,它选择s个服务器的样本,并将项目分配给当前项目最少的服务器。
练习
在 ArrayStackOfStrings.java 中添加一个
isFull()方法。编写一个过滤器 Reverse.java,从标准输入逐个读取字符串,并以相反顺序打印到标准输出。
编写一个栈客户端 Parentheses.java,从标准输入中读取一串括号、方括号和大括号,使用栈确定它们是否平衡。例如,你的程序应该对
[()]{}{[()()]()}打印true,对[(])打印false。当
n为 50 时,以下代码片段会打印什么?给出当给定正整数n时,代码片段的高级描述。Stack stack = new Stack<integer>(); while (n > 0) { stack.push(n % 2); n /= 2; } while (!stack.isEmpty()) StdOut.print(stack.pop()); StdOut.println();</integer>解决方案:打印
n的二进制表示(当 n 为 50 时为110010)。以下代码片段对队列
queue做了什么?Stack stack = new Stack<string>(); while (!queue.isEmpty()) stack.push(queue.dequeue()); while (!stack.isEmpty()) queue.enqueue(stack.pop());</string>解决方案:颠倒队列中字符串的顺序。
为 Stack.java 添加一个名为
peek()的方法,该方法返回栈中最近插入的元素(不移除)。为 Queue.java 和 Stack.java 都添加一个名为
size()的方法,该方法返回集合中的项目数。编写一个过滤器 InfixToPostfix.java,将中缀算术表达式转换为后缀表达式。
编写一个程序 EvaluatePostfix.java,从标准输入中获取后缀表达式,对其进行评估,并打印值。(将上一个练习的程序输出导入到此程序中,可实现与 Evaluate.java 相同的行为。)
开发一个数据类型 ResizingArrayQueueOfStrings.java,以固定长度数组实现一个队列,使得所有操作都需要常数时间。
修改 MM1Queue.java 以创建一个程序 MD1Queue.java,模拟一个服务时间固定(确定性)为速率μ的队列。验证该模型的 Little 定律。
开发一个类 StackOfInts.java,使用链表表示(但没有泛型)来实现一个整数栈。编写一个客户端,比较你的实现与
Stack<Integer>的性能,以确定在你的系统上由于自动装箱和拆箱而产生的性能损失���
链表练习
假设
x是一个链表节点。以下代码片段的效果是什么?x.next = x.next.next;解决方案:从列表中删除紧随
x之后的节点。编写一个名为
delete()的方法,接受链表中的第一个节点和一个int参数k,如果存在,则删除链表中的第k个节点。解决方案:
// we assume that first is a reference to the first Node in the list public void delete(int k) { if (k <= 0) throw new RuntimeException("Invalid value of k"); // degenerate case - empty linked list if (first == null) return; // special case - removing the first node if (k == 1) { first = first.next; return; } // general case, make temp point to the (k-1)st node Node temp = first; for (int i = 2; i < k; i++) { temp = temp.next; if (temp == null) return; // list has < k nodes } if (temp.next == null) return; // list has < k nodes // change temp.next to skip kth node temp.next = temp.next.next; }假设
x是一个链表节点。以下代码片段的效果是什么?t.next = x.next; x.next = t;解决方案:在节点
x之后立即插入节点t。为什么以下代码片段的效果与前一个问题不同?
x.next = t; t.next = x.next;解决方案:当需要更新
t.next时,x.next不再是原始紧随x的节点,而是t本身!
创意练习
约瑟夫问题。 在古代的约瑟夫问题中,n个人处于困境,并同意采取以下策略来减少人口。他们排成一个圆圈(位置编号从 0 到n−1),沿着圆圈前进,每隔 m 个人淘汰一个,直到只剩下一个人。传说中,约瑟夫找到了一个位置可以避免被淘汰。编写一个
Queue客户端 Josephus.java,接受两个整数命令行参数m和n,并打印人们被淘汰的顺序(从而向约瑟夫展示在圆圈中应该坐在哪里)。拓扑排序。 您需要对服务器上编号为 0 到 n-1 的 n 个作业的顺序进行排序。有些作业必须在其他作业开始之前完成。编写一个程序 TopologicalSorter.java,它接受一个命令行参数 n 和一个有序对作业(i,j)的标准输入序列,然后打印一个整数序列,以便对于输入中的每对(i,j),作业 i 出现在作业 j 之前。首先,从输入中为每个作业构建(1)必须跟随它的作业队列和(2)其入度(必须在其之前的作业数)。然后,构建一个所有入度为 0 的节点的队列,并重复删除任何入度为 0 的作业,保持所有数据。这个过程有许多应用。例如,您可以用它来模拟专业课程的先修课程,以便找到一系列要修的课程,以便毕业。
堆栈的复制构造函数。 为
Stack.java的链表实现创建一个新的构造函数,使得Stack<String> t = new Stack<String>(s)使t引用Stack s的一个新的独立副本。您应该能够从s或t中推送和弹出,而不会影响另一个。递归解决方案: 为
Node创建一个复制构造函数,并使用它来创建新的堆栈。public Node(Node x) { item = x.item; if (x.next != null) next = new Node(x.next); } public Stack(Stack s) { first = new Node(s.first); }非递归解决方案(未经测试):
public Node(Node x, Node next) { this.x = x; this.next = next; } public Stack(Stack s) { if (s.first != null) { first = new Node(s.first.value, s.first.next) { for (Node x = first; x.next != null; x = x.next) x.next = new Node(x.next.value, x.next.next); } }引用。 开发一个实现以下 API 的数据类型 Quote.java:
为此,定义一个嵌套类Card,它保存引语的一个单词,并链接到引语中的下一个单词:private class Card { private String word; private Card next; public Card(String word) { this.word = word; this.next = null; } }循环引用。 重复上一个练习,但使用循环链表。在循环链表中,每个节点指向其后继节点,而列表中的最后一个节点指向第一个节点(而不是 null,如标准的以 null 结尾的链表)。
解决方案: CircularQuote.java
逆转链表(迭代)。 编写一个非递归函数,以链表中的第一个
Node作为参数,并逆转链表,返回结果中的第一个Node。解决方案: 为了实现这一点,我们在链表中保持对三个连续节点的引用,
reverse、first和second。在每次迭代中,我们从原始链表中提取节点first,并将其插入到逆转后的列表的开头。我们保持first是原始列表剩余部分的第一个节点,second是原始列表剩余部分的第二个节点,reverse是结果逆转列表的第一个节点。![逆转链表]()
public static Node reverse(Node list) { if (first == null || first.next == null) return first; Node first = list; Node reverse = null; while (first != null) { Node second = first.next; first.next = reverse; reverse = first; first = second; } return reverse; }逆转链表(递归)。 编写一个递归函数,以链表中的第一个
Node作为参数,并逆转链表,返回结果中的第一个Node。解决方案: 假设链表有 n 个元素,我们递归地颠倒最后 n-1 个元素,然后将第一个元素附加到末尾。
public Node reverse(Node first) { if (first == null || first.next == null) return first; Node second = first.next; Node rest = reverse(second); second.next = first; first.next = null; return rest; }列出文件。 一个文件夹是文件和文件夹的列表。编写一个程序 Directory.java,它以文件夹的名称作为命令行参数,并打印该文件夹中包含的所有文件,每个文件夹的内容递归列出(缩进)在该文件夹的名称下面。
网络练习
编写一个递归函数,以队列作为输入,并重新排列队列,使其顺序颠倒。提示:
dequeue()第一个元素,递归地颠倒队列,然后将第一个元素入队。为
Stack添加一个方法Item[] multiPop(int k),从堆栈中弹出 k 个元素,并将它们作为对象数组返回。为
Queue添加一个方法Item[] toArray(),将队列中的所有 N 个元素作为长度为 N 的数组返回。以下代码片段做什么?
IntQueue q = new IntQueue(); q.enqueue(0); q.enqueue(1); for (int i = 0; i < 10; i++) { int a = q.dequeue(); int b = q.dequeue(); q.enqueue(b); q.enqueue(a + b); System.out.println(a); }斐波那契
在文字处理器中,您会选择哪种数据类型来实现“撤销”功能?
假设您有一个大小为 N 的单个数组,并希望实现两个堆栈,以便在两个堆栈上的元素总数为 N+1 之前不会溢出。您将如何操作?
假设您在
StackList的链表实现中使用以下代码实现了push。错误在哪里?public void push(Object value) { Node second = first; Node first = new Node(); first.value = value; first.next = second; }解决方案: 通过重新声明
first,您创建了一个名为first的新局部变量,它与名为first的实例变量不同。使用一个队列实现堆栈。 展示如何使用一个队列实现堆栈。提示: 要删除一个项目,逐个获取队列中的所有元素,并将它们放在末尾,除了最后一个应删除并返回。
使用堆栈列出文件。 编写一个程序,将一个目录的名称作为命令行参数,并打印出该目录及其子目录中包含的所有文件。同时打印出每个文件的文件大小(以字节为单位)。使用堆栈而不是队列。使用递归重复,并将程序命名为 DirectoryR.java。修改 DirectoryR.java,以便打印出每个子目录及其总大小。目录的大小等于其包含的所有文件或其子目录包含的所有文件的总和。
堆栈 + 最大值。 创建一个数据结构,有效支持堆栈操作(弹出和推入),并返回最大元素。假设元素是整数或实数,以便您可以比较它们。提示: 使用两个堆栈,一个用于存储所有元素,另一个用于存储最大值。
标签系统。 编写一个程序,从命令行读取一个二进制字符串,并应用以下(00, 1101)标签系统:如果第一个位为 0,则删除前三位并追加 00;如果第一个位为 1,则删除前三位并追加 1101。只要字符串至少有 3 位,就重复此过程。尝试确定以下输入是否会停止或进入无限循环:10010、100100100100100100。使用队列。
整数集合。 创建一个表示 0 到 n-1 之间的整数集合(无重复项)的数据类型。支持 add(i)、exists(i)、remove(i)、size()、intersect、difference、symmetricDifference、union、isSubset、isSuperSet 和 isDisjointFrom。
为书编制索引。 编写一个程序,从标准输入读取文本文件,并编制一个按字母顺序排列的索引,显示哪些单词出现在哪些行,如以下输入所示。忽略大小写和标点符号。类似于 FrequencyCount,但对于每个单词,维护一个出现位置的列表。
调整大小数组实现堆栈的复制构造函数。 在 ArrayStackOfStrings.java 中添加一个复制构造函数
重新排序链表。 给定包含 2n 个节点的单链表 x1 → x2 → ... → x_2n,重新排列节点为 x1 → x2n → x2 → x_2n-1 → x3 → .... 提示: 将链表分成两半;颠倒第二个链表中节点的顺序;将两个列表合并在一起。
4.4 符号表
原文:
introcs.cs.princeton.edu/java/44st译者:飞龙
符号表是一种数据类型,我们用它来将值与键关联起来。客户端可以通过指定键-值对将条目存储(put)到符号表中,然后可以检索(get)与特定键对应的值。
API。
符号表是一组键-值对。我们为键使用通用类型Key,为值使用通用类型Value。
此 API 反映了几个设计决策:
不可变键。 我们假设键在符号表中不会改变其值。
替换旧值策略。 如果将一个已经将另一个值与给定键关联的键-值对插入符号表中,我们采用新值替换旧值的约定。
未找到。 如果指定的键没有关联的值,则方法
get()返回null。空键和空值。 客户端不允许使用
null作为键或值。这个约定使我们能够实现contains()如下:public boolean contains(Key key) { return get(key) != null; }移除。 我们还在 API 中包含了一个方法,用于从符号表中移除一个键(及其关联的值),因为许多应用程序需要这样的方法。
遍历键-值对。
keys()方法为客户端提供了一种遍历数据结构中键-值对的方法。ST st = new ST<string double="">(); ... for (String key : st.keys()) StdOut.println(key + " " + st.get(key));</string>可哈希键。 Java 包括对符号表实现的直接语言和系统支持。每个类都继承了一个
equals()方法(我们可以用它来测试两个键是否相同)和一个hashCode()方法(稍后我们将对其进行检查)。可比较键。 在许多应用程序中,键具有自然顺序并实现了
Comparable接口。在这种情况下,我们可以支持一系列新操作。![有序符号表操作]()
最常用的键类型是String和Integer,它们是不可变的、可哈希的和可比较的。
符号表客户端。
我们考虑了两个典型示例。两者都依赖于我们的参考符号表实现 ST.java。
字典查找。 最基本的符号表客户端通过连续的put操作构建一个符号表,以支持get请求。
![字典应用]()
Lookup.java 从逗号分隔值文件构建一个键-值对集合,然后打印与从标准输入读取的键对应的值。命令行参数是文件名和两个整数,一个指定用作键的字段,另一个指定用作值的字段。
索引。 Index.java 是一个符号表客户端的典型示例,它使用
get()和put()的交错调用序列:它从标准输入读取一系列字符串,并打印一个排序的整数列表,指定每个字符串在输入中出现的位置。![索引应用]()
基本实现。
我们简要考虑了两种基本实现,基于我们遇到的两种基本数据结构:调整大小的数组和链表。
顺��搜索。 也许最简单的实现是将键-值对存储在无序链表(或数组)中,并使用顺序搜索。在搜索键时,我们按顺序检查每个节点(或元素),直到找到指定的键或耗尽列表(或数组)。SequentialSearchST.java 使用这种策略实现了一个符号表。
![无序链表符号表]()
这样的实现对于典型客户端的使用是不可行的,例如,当搜索键不在符号表中时,获取需要线性时间。

二分查找。 或者,我们可以使用一个排序(可调整大小)数组来存储键,以及一个并行数组来存储值。由于键是按排序顺序排列的,我们可以使用二分查找来搜索键(及其相关值)。BinarySearchST.java 使用这种策略实现了符号表。
获取操作很快(对数级),但插入操作通常需要线性时间,因为每次插入新键时,较大的键必须向数组中的更高位置移动一个位置。
要实现一个符号表,以便与Lookup和Index等客户端一起使用,我们需要一种比链表或调整大小数组更灵活的数据结构。接下来,我们考虑两个这样的数据结构的示例:哈希表和二叉搜索树。
哈希表。
哈希表是一种数据结构,我们使用哈希函数将键分成m组,我们期望每组的大小相等。对于每组,我们将键保存在一个无序链表中,并使用顺序搜索。
表示。 我们维护一个包含m个链表的数组,其中第i个元素包含所有哈希值为i的键的链表(以及它们的相关值)。
![]()
哈希函数。 正如我们在第 3.3 节中看到的,每个 Java 类都有一个
hashCode()方法,将对象映射到整数。我们使用哈希函数private int hash(Key key) { return Math.abs(key.hashCode() % m); }将哈希码转换为 0 到m−1 之间的哈希值。以下是当m=5 时,n=12 个字符串的哈希码和哈希值:
![]()
搜索。 要搜索键:
计算其哈希值以识别其链表。
遍历该链表中的节点,检查搜索键。
如果搜索键在链表中,则返回相关值;否则返回 null。
插入。 要插入键-值对:
计算键的哈希值以识别其链表。
遍历该链表中的节点,检查键。
如果键在链表中,则用新值替换当前与键关联的值;否则,创建一个具有指定键和值的新节点,并将其插入到链表的开头。
实现。 HashST.java 是一个完整的实现。它使用调整大小数组来确保每个链表中的平均键数在 1 和 8 之间。
运行时间分析。 假设哈希函数合理地分布键,HashST.java 实现了插入和获取的常数(摊销)时间性能。
二叉搜索树。
二叉树是递归定义的:它要么为空(null),要么是一个包含指向两个不同二叉树的链接的节点。我们将顶部的节点称为树的根,由其左链接引用的节点称为左子树,由其右链接引用的节点称为右子树。链接都为 null 的节点称为叶节点。树的高度是从根节点到叶节点的任意路径上的链接数的最大值。

二叉搜索树是一种二叉树,每个节点包含一个键-值对,并且键处于对称顺序:节点中的键大于其左子树中每个节点的键,小于其右子树中每个节点的键。
表示. 要实现 BST,我们从节点抽象的类开始,该类具有对键、值和左右 BST 的引用。键类型必须是可比较的(以指定键的排序),但值类型是任意的。
private class Node { private Key key; private Value val; private Node left, right; }![BST 表示]()
搜索. 假设您想在 BST 中搜索具有给定键的节点。递归算法立即显而易见:
如果树为空,则以失败结束搜索。
如果搜索键等于节点中的键,则以成功结束搜索(通过返回与键关联的值)。
如果搜索键小于节点中的键,则在左子树中搜索(递归)。
如果搜索键大于节点中的键,则在右子树中搜索(递归)。
![在 BST 中搜索]()
插入. 假设您想将新节点插入 BST 中。逻辑与搜索键类似,但实现更加棘手。理解它的关键是意识到只需更改一个链接以指向新节点,并且该链接恰好是在对该键进行不成功搜索时发现为 null 的链接。
![插入到 BST 中]()
实现. BST.java 是基于这两个递归算法的完整符号表实现。
BST 的性能特征。
算法在 BST 上的运行时间最终取决于树的形状,而树的形状取决于键的插入顺序。
最佳情况. 在最佳情况下,树是完美平衡的(每个节点恰好有两个非空子节点),根节点和每个叶节点之间大约有 lg n个链接。在这样的树中,每个put操作和get请求的成本与 lg n或更少成正比。
![最佳情况二叉搜索树]()
平均情况. 经典的数学推导表明,在从n个随机排序的键构建的树中,对于随机put或get,预期的关键比较次数约为~ 2 ln n。
![平均情况二叉搜索树]()
最坏情况. 在最坏情况下,每个节点(除一个外)都有一个空链接,因此 BST 本质上是一个带有额外浪费链接的链表,其中put操作和get请求需要线性时间。不幸的是,这种最坏情况在实践中并不罕见——例如,当我们按顺序插入键时就会出现。
![最坏情况二叉搜索树]()
红黑树. 令人惊讶的是,有 BST 变体可以消除这种最坏情况,并保证每次操作的对数性能。平衡。一种流行的变体称为红黑树。
遍历 BST。
或许最基本的树处理函数被称为树遍历:给定一个(对)树,我们希望系统地处理树中的每个节点。对于链表,我们通过跟随单个链接来从一个节点移动到下一个节点来完成此任务。然而,对于树,我们需要做出决策,因为有两个链接要跟随。递归立即提供帮助。要处理 BST 中的每个节点:
处理左子树中的每个节点。
处理根节点。
处理右子树中的每个节点。
这种方法被称为中序树遍历,因为它按键排序顺序处理 BST 中的节点。以下方法按升序打印其参数根节点的 BST 中的键:
private static void traverse(Node x) {
if (x == null) return;
traverse(x.left);
eStdOut.println(x.key);
traverse(x.right);
}
这段代码作为 BST.java 中keys()方法的基础,该方法返回符号表中的所有键,作为一个可迭代对象。
有序符号表操作。
二叉搜索树的灵活性和比较键的能力使得可以实现许多有用的额外操作。
最小值和最大值. 要找到二叉搜索树中最小的键,从根节点开始沿着左链接直到达到
null。最后遇到的键是二叉搜索树中最小的。同样的过程,尽管是沿着右链接,会导致二叉搜索树中最大的键。大小和子树大小. 要跟踪二叉搜索树中节点的数量,BST.java 中保持一个额外的实例变量
n,用于计算树中节点的数量。或者,保持每个Node中一个额外的实例变量size,用于计算每个节点根节点的子树中节点的数量。范围搜索和范围计数. 使用像
traverse()这样的递归方法,我们可以返回两个给定值之间的键的可迭代对象。如果我们在每个节点中维护一个实例变量,表示每个节点根节点的子树的大小,我们可以在时间上与二叉搜索树的高度成正比地计算两个给定值之间的键的数量。顺序统计和排名. 如果我们在每个节点中维护一个实例变量,表示每个节点根节点的子树的大小,我们可以实现一个递归方法,在时间上与二叉搜索树的高度成正比,返回第k小的键。同样地,我们可以计算一个键的排名,即二叉搜索树中严格小于该键的键的数量。
参考实现 ST.java 实现了我们的有序符号表 API用于可比较的键。它将操作委托给java.util.TreeMap,这是一个基于红黑树的符号表实现。
集合数据类型。
集合是一个包含不同键的集合,就像一个没有值的符号表:
参考实现 SET.java 实现了我们的有序 SET API用于可比较的键。DeDup.java 是一个客户端,从标准输入读取一系列字符串并打印每个字符串的第一次出现(从而删除重复项)。
练习
开发一个实现 BinarySearchST.java 的符号表 API,该 API 维护键和值的并行数组,保持它们按键排序的顺序。使用二分查找进行get操作,并将较大的键-值对移动到右边一个位置进行put操作(使用调整大小的数��以保持数组长度与表中键-值对数量成正比)。使用 Index.java 测试你的实现,并验证使用这样的实现对
Index进行操作所需的时间与输入中字符串数量和不同字符串数量的乘积成正比的假设。开发一个实现 SequentialSearchST.java 的符号表 API,该 API 维护一个包含键和值的节点链表,保持它们的任意顺序。使用 Index.java 测试你的实现,并验证使用这样的实现对
Index进行操作所需的时间与输入中字符串数量和不同字符串数量的乘积成正比的假设。为 HashST.java 实现
contains()方法。为 HashST.java 实现
size()方法。为 HashST.java 实现
keys()方法。修改 HashST.java,添加一个名为
remove()的方法,接受Key参数,并从符号表中删除该键(以及相应的值),如果存在的话。修改 HashST.java,使用调整大小的数组,使得与每个哈希值关联的列表的平均长度在 1 和 8 之间。
真或假。给定一个 BST,让x为叶节点,p为其父节点。那么要么(1)p的键是大于x的键且在 BST 中最小的键,要么(2)p的键是小于x的键且在 BST 中最大的键。
解决方案:真。
修改 BST.java,添加方法
min()和max(),返回表中最小(或最大)的键(如果不存在这样的键,则返回null)。修改 BST.java,添加方法
floor()和ceiling(),以键作为参数,返回符号表中不大于(不小于)指定键的最大(最小)键(如果不存在这样的键,则返回null)。修改 BST.java,添加一个返回符号表中键值对数量的方法
size()。使用在每个Node中存储根节点的子树中节点数量的方法。修改 BST.java,添加一个名为
rangeSearch()的方法,以两个键作为参数,并返回介于两个给定键之间的所有键的可迭代对象。运行时间应与树的高度和范围内键的数量成比例。修改 BST.java,添加一个名为
rangeCount()的方法,以两个键作为参数,并返回 BST 中介于两个指定键之间的键的数量。您的方法应该花费与树的高度成比例的时间。提示:先完成前一个练习。编写一个 ST.java 客户端 GPA.java,创建一个符号表,将字母等级映射到数字分数,如下表所示,然后从标准输入读取字母等级列表并计算它们的平均值(GPA)。
A+ A A- B+ B B- C+ C C- D F 4.33 4.00 3.67 3.33 3.00 2.67 2.33 2.00 1.67 1.00 0.00
二叉树练习
这个练习列表旨在让您体验与不一定是 BST 的二叉树一起工作。它们都假设有一个Node类,其中包含三个实例变量:一个正的double值和两个Node引用。
如果两个二叉树只有键值不同(它们具有相同的形状),则它们是同构的。实现一个线性时间的静态方法
isomorphic(),以两个树引用作为参数,并在它们引用同构树时返回true,否则返回false。然后实现一个线性时间的静态方法eq(),以两个树引用作为参数,并在它们引用相同的树(具有相同键值的同构树)时返回true,否则返回false。public static boolean isomorphic(Node x, Node y) { if (x == null && y == null) return true; // both null if (x == null || y == null) return false; // exactly one null return isomorphic(x.left, y.left) && isomorphic(x.right, y.right); }在 BST.java 中添加一个线性时间方法
isBST(),如果树是 BST,则返回true,否则返回false。在 BST.java 中添加一个名为
levelOrder()的方法,按层次顺序打印键:首先打印根节点;然后按从左到右的顺序打印根节点下一级的节点;然后按从左到右的顺序打印根节点下两级的节点;依此类推。提示:使用Queue<Node>。计算
mystery()在一些示例二叉树上返回的值,然后提出关于其行为的假设并加以证明。public int mystery(Node x) { if (x == null) return 0; else return mystery(x.left) + mystery(x.right); }解决方案:对于任何二叉树,返回 0。
创意练习
**拼写检查。**编写一个
SET客户端 SpellChecker.java,以命令行参数形式接受包含单词字典的文件名,然后从标准输入读取字符串,并打印出不在字典中的任何字符串。拼写校正。 编写一个
ST客户端 SpellCorrector.java,作为一个过滤器,用建议的替换词替换标准输入中常见的拼写错误,并将结果打印到标准输出。以一个包含常见拼写错误和更正的文件的文件名作为命令行参数。使用文件 misspellings.txt,其中包含许多常见的拼写错误。集合操作。 在 SET.java 中添加方法
union()和intersection(),接受两个集合作为参数,并分别返回这两个集合的并集和交集。频率符号表。 开发一个支持以下操作的数据类型 FrequencyTable.java:
increment()和frequencyOf(),两者都接受字符串参数。数据类型跟踪使用给定字符串作为参数调用increment()操作的次数。increment()操作将计数增加 1,count()操作返回计数,可能为 0。此数据类型的客户端可能包括网页流量分析器、计算每首歌曲播放次数的音乐播放器、计数电话的电话软件等。顺序统计。 在 BST.java 中添加一个名为
select()的方法,该方法接受一个整数参数k并返回 BST 中第k小的键。在每个节点中维护子树大小。运行时间应与树的高度成比例。排名查询。 在 BST.java 中添加一个名为
rank()的方法,该方法以一个键作为参数并返回 BST 中严格小于key的键数。在每个节点中维护子树大小。运行时间应与树的高度成比例。稀疏向量。 如果一个d维向量的非零值个数很少,则称其为稀疏向量。你的目标是用与其非零值个数成比例的空间表示向量,并且能够在时间与总非零值个数成比例的情况下添加两个稀疏向量。实现一个支持以下 API 的类 ADTs SparseVector.java:
![稀疏向量 API]()
稀疏矩阵。 如果一个n×n矩阵的非零元素个数与n成比例(或更少),则称其为稀疏矩阵。你的目标是用与n成比例的空间表示矩阵,并且能够在时间与总非零元素个数成比例的情况下添加和相乘两个稀疏矩阵(可能还带有额外的对数n因子)。实现一个支持以下 API 的类 SparseMatrix.java:
![稀疏矩阵 API]()
网页练习
树上的函数。 编写一个函数
count(),接受一个名为Node的参数x并返回以x为根的子树中节点的数量(包括x)。空二叉树中元素的数量为 0(基本情况),非空二叉树中元素的数量等于左子树中元素的数量加上右子树中元素的数量再加 1。public static int count(TwoNode x) { if (x == null) return 0; return 1 + count(x.left) + count(x.right); }随机元素。 在 BST 中添加一个符号表函数
random(),返回一个随机元素。假设 BST 的节点具有整数大小字段,其中包含根节点到返回节点的路径长度中的元素数。运行时间应与从根到返回节点的路径长度成比例。马尔可夫语言模型。 创建一个支持以下两个操作的数据类型:
add和random。add方法应在数据结构中插入新项(如果尚不存在);如果已存在,则应将其频率计数增加一。random方法应随机返回一个元素,其中各元素的概率由每个元素的频率加权。贝叶斯垃圾邮件过滤器。 参考A Plan for Spam中的想法。这里是获取测试数据的地方。
具有随机访问的符号表。 创建一个支持插入键值对、搜索键并返回相关值、删除并返回随机值的数据类型。提示:结合符号表和随机队列。
随机电话号码。 编写一个程序,接受命令行输入 N,并打印 N 个形式为(xxx) xxx-xxxx 的随机电话号码。使用符号表以避免选择相同号码超过一次。使用这个区号列表以避免打印虚假区号。
长度为 L 的唯一子字符串。 编写一个程序,从标准输入中读取文本并计算其包含的长度为 L 的唯一���字符串的数量。例如,如果输入是
cgcgggcgcg,那么长度为 3 的唯一子字符串有 5 个:cgc、cgg、gcg、ggc和ggg。应用于数据压缩。提示:使用字符串方法substring(i, i + L)提取第 i 个子字符串并插入符号表。在第一个π的百万位数或π的一千万位数上进行测试。伟大的树-列表递归问题。二叉搜索树和循环双向链表在概念上是由相同类型的节点构建的 - 一个数据字段和对其他节点的两个引用。给定一个二叉搜索树,重新排列引用,使其成为一个循环双向链表(按排序顺序)。Nick Parlante 将其描述为有史以来设计的最整洁的递归指针问题之一。提示:从左子树创建一个循环链接列表 A,从右子树创建一个循环链接列表 B,并使根节点成为一个节点的循环链接列表。然后合并这三个列表。
密码检查器。 编写一个程序,从命令行读取一个字符串和从标准输入读取一个单词字典,并检查它是否是一个“好”密码。在这里,假设“好”意味着(i)至少有 8 个字符长,(ii)不是字典中的单词,(iii)不是字典中的单词后跟一个数字 0-9(例如,hello5),(iv)不是由一个数字分隔的两个单词(例如,hello2world)。
反向密码检查器。 修改前一个问题,使得(ii)-(v)也适用于字典中单词的反向(例如,olleh 和 olleh2world)。简单解决方案:将每个单词及其反向插入符号表。
密码学。 编写一个程序来读取密码并解密。密码是一种古老的加密形式,称为替换密码,其中原始消息中的每个字母都被另一个字母替换。假设我们只使用小写字母,有 26!种可能性,你的目标是找到一个结果,其中每个单词都是字典中的有效单词。使用 Permutations.java 和回溯。
频率计数器。 编写一个程序 FrequencyCounter.java,从标准输入中读取一系列字符串,并计算每个字符串出现的次数。
无序数组符号表。 编写一个数据类型 SequentialSearchArrayST,使用(无序)调整大小的数组实现符号表。
非递归二叉搜索树。 编写一个数据类型 IterativeBST.java,使用二叉搜索树实现符号表,但使用非递归版本的
get()和put()。异常过滤器。 客户端程序 ExceptionFilter.java 从指定为命令行参数的允许列表文件中读取一系列字符串,然后打印标准输入中不在允许列表中的所有单词。
树重建。 给定二叉树的以下遍历(只有元素,没有空节点),你能重建这棵树吗?
先序遍历和中序遍历。
后序遍历和中序遍历。
层序遍历和中序遍历。
先序遍历和层序遍历。
先序遍历和后序遍历。
解决方案:
可以。从左到右扫描先序遍历,并使用中序遍历来识别左右子树。
可以。从右到左扫描后序遍历。
可以。从左到右扫描层序遍历。
不行。考虑两棵以 A 为根节点,B 为左或右子节点的树。它们的先序遍历都是 AB,层序遍历也都是 AB。
不行。与上面相同的反例。
给定某个二叉搜索树的先序遍历(不包括空节点),你能重建这棵树吗?
解决方案:可以。这等同于知道先序遍历和中序遍历。
突出显示浏览器超链接。 浏览器通常用蓝色表示超链接,除非已经访问过,此时用紫色表示。编写一个程序
HyperLinkColorer.java,从标准输入中读取网址列表,如果是第一次读取该字符串,则输出blue,否则输出purple。垃圾邮件黑名单。 将已知的垃圾邮件地址插入到 SET.java 数据类型中,并使用它来阻止垃圾邮件。
书籍的倒排索引。 编写一个程序,从标准输入中读取文本文件,并编制一个按字母顺序排列的索引,显示哪些单词出现在哪些行中,如下所示的输入。忽略大小写和标点符号。
It was the best of times, it was the worst of times, it was the age of wisdom, it was the age of foolishness, age 3-4 best 1 foolishness 4 it 1-4 of 1-4 the 1-4 times 1-2 was 1-4 wisdom 4 worst 2提示:创建一个符号表,其键是表示单词的
String,值是表示单词出现的页码列表的Sequence<Integer>。随机生成的身份信息。 文件 names20k.csv 和文件 names20k-2.csv 每个包含 20,000 个从 fakenamegenerator.com 随机生成的身份信息(编号、性别、名字、中间名、姓氏、街道地址、城市、州、邮编、国家、电子邮件地址、电话号码、母亲的婚前姓、生日、信用卡类型、信用卡号、信用卡到期日)。
邮编之间的距离。 编写一个表示地球表面上命名位置(名称、纬度和经度)的数据类型 Location.java。然后,编写一个客户端程序,接受 ZIP 文件名(例如 zips.txt)作为命令行参数,从文件中读取数据,并将其存储在符号表中。然后,重复从标准输入中读取 ZIP 码对,并输出它们之间的大圆距离(以英里为单位)。这个距离用于邮局计算运费。
证明从随机排序的键构建的 BST 中进行随机 put 或 get 的预期关键比较次数为 ~ 2 ln N。
运行实验来验证文本中关于使用
BST时 put 操作和 get 请求的索引和查找在表大小对数级别的声明。数据库连接。 给定两个表,内连接 找到两个表之间的“交集”。
Name Dept ID Dept Dept ID ----------------- ---------------- Smith 34 Sales 31 Jones 33 Engineering 33 Robinson 34 Clerical 34 Jasper 36 Marketing 35 Steinberg 33 Rafferty 31部门 ID 的内连接如下。
Name Dept ID Dept ------------------------------- Smith 34 Clerical Jones 33 Engineering Robinson 34 Clerical Steinberg 33 Engineering Rafferty 31 Sales演员和女演员的别名。 给定包含 演员列表(带有规范名称)及其别名 的 10MB 文件,编写一个程序,从标准输入中读取演员的名称,并打印出他的规范名称。
分子量计算器。 编写一个程序 MolecularWeight.java,从 elements.csv 中读取元素及其分子量的列表,然后提示用户输入化学化合物的分子描述(例如,CO.2 或 Na.Cl 或 N.H4.N.O3 = 硝酸铵),并输出其分子量。
4.5 案例研究:小世界现象
原文:
introcs.cs.princeton.edu/java/45graph译者:飞龙
我们用于研究实体之间成对连接性质的数学模型称为图。一些图表现出一种特定属性,称为小世界现象。这是一个基本概念,即使我们每个人的熟人相对较少,但我们之间存在着相对较短的熟人链���
图。
图由一组顶点和一组边组成。每条边代表两个顶点之间的连接。如果两个顶点由一条边连接,则它们是邻居,而顶点的度是其邻居的数量。下面的列表建议了各种系统,其中图是理解结构的合适起点。
图 顶点 边 循环 器官 血管 骨骼 关节 骨头 神经 神经元 突触 社交 人 关系 流行病学 人 感染 化学 分子 键 n 体 粒子 力 遗传 基因 突变 生物化学 蛋白质 相互作用 交通 交叉口 道路 互联网 计算机 电缆 网页 网页 链接 分布 发电站 输电线 机械 关节 梁 软件 模块 调用 金融 账户 交易
图数据类型。
图处理算法通常首先通过添加边来构建图的内部表示,然后通过迭代顶点和迭代给定顶点的邻居来处理它。以下 API 支持这样的处理:
图实现。
Graph.java 实现了这个 API。其内部表示是一组集合的符号表:键是顶点,值是邻居的集合(与键相邻的顶点)此表示使用两种数据类型 ST.java 和 SET.java。
我们的实现具有三个重要属性:
客户可以高效地遍历图的顶点。
客户可以高效地遍历顶点的邻居。
空间使用量与边的数量成正比。
表演者-电影图。
表演者-电影图包括每个表演者和电影的顶点以及如果表演者出现在电影中则两者之间存在边。
我们提供了一些示例数据文件(使用 UTF-8 编码以与In兼容)。文件中的每一行都包括一部电影,后跟出现在电影中的表演者列表,由/分隔。
文件 描述 电影 演员 边 cast.06.txt 2006 年发行的电影 8780 84236 103815 cast.00-06.txt 自 2000 年以来的电影发行 52195 348497 606531 cast.G.txt MPAA 评级为 G 的电影 1288 21177 28485 cast.PG.txt MPAA 评级为 PG 的电影 3687 74724 116715 cast.PG13.txt MPAA 评级为 PG-13 的电影 2538 70325 103265 cast.mpaa.txt MPAA 评级的电影 21861 280624 627061 cast.action.txt 动作电影 14938 139861 266485 cast.rated.txt 热门电影 4527 122406 217603 cast.all.txt 超过 25 万部电影 285462 933864 3317266 这些输入文件截至 2006 年 11 月 2 日。数据来自互联网电影数据库。
倒排索引。 程序 IndexGraph.java 接受一个查询,查询可以是电影的名称(在这种情况下打印出出现在该电影中的表演者列表)或表演者的名称(在这种情况下打印出该表演者出现在哪些电影中)。
图中的最短路径。
在图中给定两个顶点,路径是连接它们的一系列边。最短路径是所有这样的路径中长度最小的路径。
PathFinder.java 使用一种被称为广度优先搜索的经典算法计算图中的最短路径。
分离度。
最短路径算法的经典应用之一是在社交网络中找到个体之间的分离度。为了明确概念,我们使用电影-表演者。凯文·贝肯是一位出演过许多电影的多产演员。我们为每位出演过电影的表演者分配一个贝肯数:贝肯本人是 0,任何与贝肯同台演出的表演者的凯文·贝肯数为 1,任何其他表演者(除了贝肯)与凯文·贝肯数为 1 的表演者同台演出的表演者的凯文·贝肯数为 2,依此类推。
给定表演者的名称,凯文·贝肯游戏是找到一��交替的电影和表演者序列,最终回到凯文·贝肯。令人惊讶的是,PathFinder.java 提供了一个解决方案。
% java PathFinder movies.txt "/" "Bacon, Kevin"
Kidman, Nicole
Bacon, Kevin
Animal House (1978)
Sutherland, Donald (I)
Cold Mountain (2003)
Kidman, Nicole
distance 2
Hanks, Tom
Bacon, Kevin
Apollo 13 (1995)
Hanks, Tom
distance 1
六度分隔规则表明,大多数演员的凯文·贝肯数不会超过 6。事实上,大多数演员的凯文·贝肯数不会超过 3!
小世界图。
科学家们已经确定了一类特别有趣的图,被称为小世界图,在自然和社会科学的许多应用中出现。小世界图具有以下三个特性:
它们是稀疏的:边的数量远小于具有指定顶点数的图的总潜在边数。
它们具有较短的平均路径长度:如果你随机选择两个顶点,它们之间的最短路径长度很短。
它们表现出局部聚类:如果两个顶点是第三个顶点的邻居,则这两个顶点很可能是彼此的邻居。
SmallWorld.java 计算图的平均度、平均路径长度和聚类系数。
表演者-表演者图。
我们的电影-表演者图不是一个小世界图,因为它是二部图,因此聚类系数为 0。然而,通过将两个表演者连接起来(如果他们出现在同一部电影中)定义的更简单的表演者-表演者图是小世界图的一个经典例子(在丢弃未与凯文·贝肯相连的表演者后)。下面的图示说明了与一个小电影演员文件相关联的电影-表演者和表演者-表演者图。
Performer.java 从我们的电影-演员输入格式的文件中创建一个表演者-表演者图。您可以使用它来验证这样的图是否是小世界图。
练习
在 Graph.java 中添加
V()和E()的实现,分别返回图中的顶点数和边数。在 Graph.java 中添加一个名为
degree()的方法,该方法接受一个字符串参数,并返回指定顶点的度。使用此方法找到文件movies.txt中出现在最多电影中的表演者。在 Graph.java 中添加一个名为
hasVertex()的方法,该方法接受一个字符串参数,并在图中命名一个顶点时返回true,否则返回false。在 Graph.java 中添加一个
hasEdge()方法,它接受两个字符串参数,并在图中指定一条边时返回true,否则返回false。真或假:在广度优先搜索过程中的某个时刻,队列中可能包含两个顶点,一个距离源点为 7,另一个距离为 9。
解决方案:错误。队列最多可以包含两个不同距离d和d + 1 的顶点。广度优先搜索按照从源点到顶点的距离递增的顺序检查顶点。在检查距离为d的顶点时,只有距离为d−1 的顶点可以入队。
假设在
PathFinder.java中使用栈而不是队列进行广度优先搜索。它仍然能够计算从源点到每个顶点的路径吗?它仍然能够计算最短路径吗?解决方案:是的,不是。
当在
pathTo()中使用队列而不是栈来形成最短路径时会产生什么影响?解决方案:它会返回顶点的逆序。
创意练习
直方图。 编写一个程序 BaconHistogram.java,打印出凯文·贝肯数的直方图,指示有多少表演者来自 movies.txt 的贝肯数为 0、1、2、3、...。包括那些与凯文·贝肯完全没有联系的人的类别。
# Freq ------------ 0 1 1 2083 2 187072 3 515582 4 113741 5 8269 6 772 7 93 8 7 Inf 28942单词阶梯。 编写一个程序 WordLadder.java,以两个 5 个字母的字符串作为命令行参数,从标准输入读取 5 个字母单词列表,并打印出一个最短的单词阶梯,使用标准输入上的单词连接这两个字符串(如果存在)。在单词阶梯链中,两个单词如果只相差一个字母,则相邻。例如,以下单词阶梯连接了
green和brown:green greet great groat groan grown brown这个游戏最初被称为doublet,是由刘易斯·卡罗尔发明的。你也可以尝试在这个 6 个字母单词列表上运行你的程序。
所有路径。 编写一个 Graph.java 客户端 AllPaths.java,其构造函数以
Graph作为参数,并支持计算或打印图中两个给定顶点s和t之间的所有简单路径的操作。简单路径是不重复任何顶点的路径。在二维网格中,这样的路径被称为避免自身的行走。警告:可能存在指数级的路径,因此不要在大型图上运行此程序。有向图。 实现一个表示有向图的 Digraph.java 数据类型,其中边的方向很重要:
addEdge(v, w)表示从v到w添加一条边,但不是从w到v。用两种方法替换adjacentTo():一种是给出具有从参数顶点到它们的边的顶点集合,另一种是给出具有从它们到参数顶点的边的顶点集合。解释如何修改 PathFinder.java 以在有向图中找到最短路径。
网络练习
所有最短路径。 编写一个程序 AllShortestPaths.java,从文件中构建图形,从标准输入读取源-目标请求,并打印出图中从源到目标的最短路径。
更快的单词阶梯。 为了加快速度(如果单词列表非常大),不要编写嵌套循环来尝试所有成对的单词,看它们是否相邻。为了处理 5 个字母的单词,首先对单词列表进行排序。只有最后一个字母不同的单词将连续出现在排序列表中。再排序 4 次,但将字母向右循环移动一个位置,以便在一个排序列表中连续出现在第 i 个字母不同的单词。
使用更大的单词列表尝试这种方法,其中包含不同长度的单词。两个长度不同的单词如果较小的单词与较大的单词相同,减去最后一个字���,则它们是相邻的,例如,brow 和 brown。
跳棋。 将跳棋规则扩展到一个 N×N 的跳棋棋盘。展示如何确定一个棋子是否可以在当前移动中变成国王。(使用 BFS 或 DFS。)展示如何确定黑方是否有获胜的着法。(找到一个欧拉路径。)
组合电路。 给定其输入,确定组合电路的真值是一个图可达性问题(在有向无环图上)。
Hex。 Hex 游戏在一个六边形网格上进行...
描述如何使用 BFS 或 DFS 检测白色或黑色谁赢得了比赛。
证明游戏不可能以平局结束。提示:考虑从棋盘左侧可达的单元格集合。
证明 Hex 游戏中,如果玩家采取最佳策略,第一位玩家总是能赢。提示:如果第二位玩家有一个获胜策略,您可以最初选择一个随机单元格,然后只需复制第二位玩家的获胜策略。在博弈论中,这种技术被称为策略窃取。
编写一个程序 MovieStats.java,读取电影数据集并打印出不同演员和电影的数量。
修改 Bacon.java,使用户输入两个演员(每行一个),程序打印两个演员之间的最短链。
修改
Graph.java,包括一个返回图顶点的Iterator的方法iterator()。此外,使Graph实现Iterable<String>接口,以便您可以使用 foreach 遍历图的顶点。public Iterator<String> iterator() { return st.iterator(); }垃圾回收。 引用计数 vs. 标记-清除。像 Java 这样的语言中的自动内存管理是一个具有挑战性的问题。分配内存很容易,但发现程序何时完成内存(并回收它)更困难。引用计数:不适用于循环链接结构。标记-清除算法。根 = 局部变量和静态变量。从根运行 DFS,标记所有从根引用的变量,依此类推。然后,进行第二遍:释放所有未标记的对象并取消标记所有标记的对象。或者复制 GC 将所有标记的对象移动到单个内存区域。每个对象使用一个额外的位。JVM 在进行垃圾回收时必须暂停。碎片化内存。
网络链接的幂律(Micahel Mitzenmacher)全球网络的入度和出度遵循幂律。可以通过优先附加过程建模。假设每个网页只有一个外链。每个页面逐一创建,从指向自身的单个页面开始。以概率 p < 1,它链接到现有页面之一,随机选择。以概率 1-p,它链接到具有与该页面的入链数成比例的概率的现有页面。此规则反映了新网页指向热门页面的普遍倾向。编写一个程序来模拟这个过程,并绘制入链数的直方图。答案:您应该观察到具有入度 k 的页面比例与 k^(-1 / (1 - p)) 成比例。
5. 计算理论
原文:
introcs.cs.princeton.edu/java/50theory译者:飞龙
本章正在大力构建中。
概述。
在本章中,我们描述了对机器的能力和限制进行严格研究如何揭示了所有已知类型计算机之间的惊人共性,并使我们能够考虑一些基本问题:
有些计算机本质上比其他计算机更强大吗?
我们可以用计算机解决哪些问题?
计算机的能力是否存在限制?
计算机在资源有限的情况下能做到什么程度?
这确实是深奥的问题,数学家们在过去的大部分世纪里一直在努力解决这些问题。
5.1 形式语言
5.2 图灵机
5.3 通用性解释了为什么所有计算设备具有等效的计算能力。
5.4 可计算性确定了在任何计算设备上都无法解决的特定问题。
5.5 难解性讨论了在现实世界中不可避免的资源限制下我们能解决的计算问题。
5.1 形式语言
原文:
introcs.cs.princeton.edu/java/51language译者:飞龙
在本节中,我们介绍形式语言、正规表达式、确定性有限状态自动机和非确定性有限状态自动机。
基本定义。
我们从一些重要的定义开始。
符号 是我们的基本构建块,通常是字符或数字。
字母表 是一组有限的符号。
字符串 是字母表符号的有限序列。
形式语言 是一组字符串(可能是无限的),都属于相同的字母表。
现在,我们考虑一些示例。
二进制字符串。 我们从二进制字母表上的形式语言的示例开始。指定形式语言的最简单方法是列举其字符串。一个复杂之处在于语言可以是大型或无限集合,因此我们经常使用非正式描述。
![二进制字母表上的形式语言]()
其他字母表。 在处理形式语言时,我们使用适合任务的任何字母表:标准罗马字母表用于处理文本,十进制数字用于处理数字,字母表 { A, T, C, G } 用于处理遗传数据,依此类推。
![常用字母表]()
这里是一些关于不同字母表的示例语言:
![形式语言]()
规范化问题。 我们如何完全和准确地定义形式语言?这个任务被称为形式语言的规范化问题。我们的非正式英语描述在某些情况下可以胜任,但在其他情况下相当不足。
识别问题。 给定一个语言 L 和一个字符串 x,识别问题是回答以下问题:x 是否在 L 中?
正规语言。
现在我们考虑一类重要的形式语言,称为正规语言,对于这类语言,我们可以解决规范化和识别问题。
基本操作。 我们使用并集、连接和闭包操作在集合上,以及括号,来指定一个正规语言。
两个形式语言 R 和 S 的并集 R | S 是在 R 或 S 中的字符串的集合(或两者都有)。例如,
{ a, ba } | { ab, ba, b } = { a, ab, ba, b }我们在并集中不包括重复项。
两个形式语言 R 和 S 的连接 RS 是通过将来自 R 的字符串附加到来自 S 的字符串而创建的所有字符串的集合。例如,
{ a, ab } { a, ba, bab } = { aa, aba, abab, abba, abbab }再次,我们在结果中不包括重复项。
形式语言 R 的闭包 R* 是从 R 中的零个或多个字符串的连接。
R* = ε | R | RR | RRR | RRRR | RRRRR | RRRRRR ...请注意,每次我们从 R 中取一个字符串时,我们可以自由使用集合中的任何字符串。这里 ε 指的是空字符串——由 0 个字符组成的字符串。
我们使用括号或依赖于定义的运算符优先级顺序来指定运算符应该应用的顺序。对于正规表达式,闭包在连接之前执行,连接在并集之前执行。
正规表达式。
正规表达式(RE)是指定形式语言的符号串。每个正规表达式都是一个字母表符号,指定包含该符号的单例集,或者由以下操作组成(其中 R 和 S 是 REs):并集 R | S,指定集合 R 和 S 的并集,
连接:RS,指定集合 R 和 S 的连接,
闭包:R*,指定集合 R 的闭包,
括号:(R),指定与 R 相同的集合。
正规语言。 如果且仅当可以通过正规表达式指定时,形式语言才是正规的。以下是一些示例:
![正规语言]()
广义 REs。
我们对 RE 的定义是一个包含表征正则语言的四个基本操作(连接、并集、闭包和括号)的最小定义。在实践中,对这个集合进行各种添加是有用的。
扩展字母表. 我们需要一个转义机制,允许我们使用元符号
|、*、(和)来指定 RE 并作为语言字母表中的符号。具体来说,为了将元符号用作字母表中的符号,我们在其前面加上反斜杠字符(/)。简写符号. 广义 RE 支持许多简写符号,例如以下内容:
通配符符号
.匹配任何字母表符号。元符号
^匹配行的开头,$匹配行的结尾。用方括号
[]括起的符号列表或范围匹配列表或范围中的任何符号。如果方括号内的第一个字符是
^字符,则该规范指的是不在列表或范围内的 Unicode 字符。由反斜杠后跟字母表符号组成的几个转义序列匹配一组定义的符号。例如,
\s匹配任何空白符号。
对闭包操作的扩展. 因此,Java RE 具有以下选项,用于指定对闭包操作的重复次数的限制:
一个或多个:
+零个或一个:
?恰好n次:
{n}介于m和n之间:
{m, n}
以下表格说明了其中几个简写符号:
Java 包含许多更多的简写和扩展,我们将不探讨。
Java 中的正则表达式。
Java 的String库中的matches()方法解决了广义正则表达式的识别问题。如果s是任何 Java String,re是任何正则表达式,那么如果s在re指定的语言中,则s.matches(re)为真,否则为假。
有效性检查. Validate.java 将一个 RE 作为命令行参数,并对标准输入中的每个字符串打印
Yes,如果它在 RE 指定的语言中,则打印No。搜索. Grep.java 将一个 RE 作为命令行参数,并打印标准输入中所有具有 RE 描述的语言中的子字符串的行。
确定有限状态自动机。
DFA 是一个由以下组成的抽象机器
有限数量的状态,每个状态被指定为接受状态或拒绝状态。
一组转换指定了机器如何改变状态。每个状态对于字母表中的每个符号都有一个转换。
一个磁带阅读器最初位于输入字符串的第一个符号处,只能读取一个符号并移动到下一个符号。
我们将每个 DFA 表示为一个有向图,其中接受状态是标记为Yes的顶点,拒绝状态是标记为No的顶点,每个转换是一个由字母表中的符号标记的有向边。
操作. 所有 DFA 都从状态 0 开始,输入字符串在磁带上,磁带头位于输入字符串的最左边的符号上。机器通过读取一个符号,将磁带头向右移动一个位置,然后根据刚刚读取的输入符号标记的转换来改变状态来操作。当输入用尽时,DFA 停止。
描述语言。 每个 DFA识别一个形式语言—它接受的所有字符串的集合。例如,上述 DFA 识别所有
b的数量是 3 的倍数的二进制字符串。Java 实现。 DFA.java 接受一个 DFA 规范(来自命令行上命名的文件)和一系列来自标准输入的字符串,并打印在给定输入字符串上运行 DFA 的结果。
非确定有限状态自动机。
DFA 的行为是确定性的:对于每个输入符号和每个状态,都有一个可能的状态转换。非确定性有限自动机(NFA)与 DFA 相同,但是去除了离开每个状态的转换的限制,因此
允许有多个标记相同符号的转换。
允许未标记的状态转换(空转换)。跟随空转换不会消耗输入符号。
不需要将所有符号包含在离开每个状态的转换中。
如果 NFA 接受一个字符串,则存在任何一系列转换可以将机器从起始状态转移到接受状态。
在左侧的 NFA 中,当处于状态 0 并读取
a时,它可以选择留在状态 0 或转移到状态 1。它识别的是倒数第二个符号为a的二进制字符串。在右侧的 NFA 中,当它处于状态 0 时,它可以跟随空转换到状态 1,而不消耗输入符号。它识别的是不包含子字符串
bba的二进制字符串。
克林定理。
正则表达式、DFA 和 NFA 之间存在着引人注目的联系,这对实践和理论都有戏剧性的影响。克林定理断言,RE、DFA 和 NFA 是等价的模型,因为它们都表征了正则语言。
RE 识别。 克林定理为 RE 的识别问题提供了解决方案的基础。
构建与给定 RE 对应的 NFA。
模拟 NFA 对给定输入字符串的操作。
这是 Java 实现其
matches()方法所采取的方法。DFA 能力的限制。 克林定理还有助于我们阐明一个基本的理论问题:哪些形式语言可以用 RE 描述,哪些不行?例如,包含所有具有相等数量的
a和b符号的二进制字符串的语言不是正则的。功能更强大的机器。 定义一个可以识别更多语言的机器的简单方法是向 DFA 添加一个下推栈,得到一个称为下推自动机(PDA)的机器。
![下推自动机(PDA)]()
开发一个 PDA 来识别具有相等数量的
a和b符号的二进制字符串并不困难。在下一节中,我们将考虑图灵机,这是计算机科学核心的抽象机器。
练习
给出一个 RE,指定以下二进制字母表中的每种语言。
所有字符串除了空字符串
包含至少三个连续的
b以
a开头且长度为奇数,或以b开头且长度为偶数没有连续的
b除了
bb或bbb之外的任何字符串以相同符号开头和结尾
包含至少两个
a且最多一个b
解决方案:
(a|b)(a|b)*,(a|b)*bbb(a|b)*,a((a|b)(a|b))* | b(a|b)((a|b)(a|b))*,...
创意练习
收割机。 编写一个Pattern和Matcher客户端 Harvester.java,它接受文件名(或 URL)和 RE 作为命令行输入,并打印文件中与 RE 匹配的所有子字符串。
网络爬虫。 开发一个程序 WebCrawler.java,打印出可以从作为命令行参数给出的网页访问的所有网页。
搜索和替换。 编写一个过滤器 SearchAndReplace.java,它接受一个 RE 和一个字符串
str作为命令行参数,从标准输入读取一个字符串,用str替换所有与 RE 匹配的标准输入上的子字符串,并将结果发送到标准输出。首先使用 Java 的String库中的replaceAll()方法解决问题;然后解决方法是不使用该方法。
网页练习(正则表达式)
给出一个 RE,指定以下每个语言{0, 1}。
a或bb或bab只有
a所有二进制字符串
以
a开头,以a结尾以
aa结尾
答案:a | bb | bab, a*, (a|b)*, a(a|b)*a | a, (a|b)*aa
编写一个正则表达式来描述字母表{a, b, c}上的输入,这些输入按排序顺序排列。答案:abc*。
为以下每组二进制字符串编写一个正则表达式。仅使用基本操作。
包含至少三个连续的 1
包含子字符串 110
包含子字符串 1101100
不包含子字符串 110
答案:(0|1)111(0|1), (0|1)110(0|1), (0|1)1101100(0|1), (0|10)1。最后一个是最棘手的。
为至少有两个 0 但不连续的 0 的二进制字符串编写一个正则表达式。
为以下每组二进制字符串编写一个正则表达式。仅使用基本操作。
至少有 3 个字符,第三个字符是 0
0 的数量是 3 的倍数
奇数长度
长度至少为 1 且最多为 3
答案:(0|1)(0|1)0(0|1), 1 | (1010101), (0|1)((0|1)(0|1)), (0|1) | (0|1)(0|1) | (0|1)(0|1)(0|1)。
对于以下每个问题,指出正则表达式
0(0 | 1)*1,0*101*,(1 | 01)*精确匹配长度为 1000 的位字符串的数量。编写一个正则表达式,匹配包含以下内容的字母表{a, b, c}的所有字符串:
以
a开头并以a结尾最多一个 a
至少有两个 a
偶数个 a
a 的数量加上 b 的数量是偶数
找出字母按字母顺序排列的长单词,例如
almost和beefily。答案:使用正则表达式'^abcdefghijklmnopqrstuvwxyz$'。编写一个 Java 正则表达式,匹配电话号码,带有或不带有区号。区号应为(609) 555-1234 或 555-1234 的形式。
找出所有以
nym结尾的英文单词。找出所有包含三连字母
bze的英文单词。答案:subzero。找出所有以 g 开头,包含三连字母
pev且以 e 结尾的英文单词。答案:grapevine。找出所有包含三连字母
spb且至少有两个 r 的英文单词。找出可以用标准键盘顶行打出的最长英文单词。答案:proprietorier。
找出包含字母 a、s、d 和 f 的所有单词,不一定按顺序。解决方案:
cat words.txt | grep a | grep s | grep d | grep f。给定一个由 A、C、T 和 G 以及 X 组成的字符串,找到一个字符串,其中 X 匹配任何单个字符,例如,CATGG 包含在 ACTGGGXXAXGGTTT 中。
编写一个 Java 正则表达式,用于 Validate.java,验证形式为 123-45-6789 的社会安全号码。提示:使用
\d表示任何数字。答案:[0-9]{3}-[0-9]{2}-[0-9]{4}。修改上一个练习,使
-成为可选项,这样 123456789 被视为合法输入。编写一个 Java 正则表达式,匹配所有包含正好五个元音字母且元音字母按字母顺序排列的字符串。答案:
[^aeiou]*a[^aeiou]*e[^aeiou]*i[^aeiou]*o[^aeiou]*u[^aeiou]*编写一个 Java 正则表达式来匹配有效的 Windows XP 文件名。这样的文件名由除了任意字符序列组成
/ \ : * ? " < > |另外,不能以空格或句号开头。
编写一个 Java 正则表达式,匹配有效的 OS X 文件名。这样的文件名由除冒号外的任意字符序列组成。此外,它不能以句点开头。
给定一个表示 IP 地址名称的字符串
s,采用dotted quad表示法,将其分解为其组成部分,例如,255.125.33.222。确保四个字段都是数字。编写一个 Java 正则表达式,描述形式为 a.b.c.d 的有效 IP 地址,其中每个字母可以表示 1、2 或 3 位数字,并且必须有句点。是的:196.26.155.241。
编写一个 Java 正则表达式,匹配以 4 位数字开头并以两个大写字母结尾的车牌。
编写一个正则表达式,从 DNA 字符串中提取编码序列。它以 ATG 密码子开头,并以终止密码子(TAA、TAG 或 TGA)结尾。参考
编写一个正则表达式,检查序列是否以 rGATCy 开头:即,它是否以 A 或 G 开头,然后是 GATC,最后是 T 或 C。
编写一个正则表达式,检查序列是否包含两个或更多次重复的 GATA 四核苷酸。
修改 Validate.java 以使搜索不区分大小写。提示:使用嵌入式标志
(?i)。编写一个 Java 正则表达式,匹配利比亚独裁���穆阿迈尔·卡扎菲姓氏的各种拼写,使用以下模板:(i)以 K、G、Q 开头,(ii)可选地跟随 H,(iii)后跟 AD,(iv)可选地跟随 D,(v)可选地跟随 H,(vi)可选地跟随 AF,(vii)可选地跟随 F,(vii)以 I 结尾。
编写一个 Java 程序,读取类似
(K|G|Q)[H]AD[D][H]AF[F]I的表达式,并打印出所有匹配的字符串。这里的符号[x]表示字母x的 0 或 1 个副本。为什么
s.replaceAll("A", "B");不会替换字符串s中所有出现的字母 A 为 B?答案:使用
s = s.replaceAll("A", "B");。方法replaceAll返回结果字符串,但不更改s本身。字符串是不可变的。编写一个程序 Clean.java,从标准输入读取文本并将其打印出来,删除每行末尾的空格,并用 4 个空格替换所有制表符。
提示:使用
replaceAll()和正则表达式\s匹配空格。编写一个正则表达式,匹配文本
a href ="和下一个"之间的所有文本。答案:href=\"(.*?)\"。?使.*变得不贪婪而是勉强。在 Java 中,使用Pattern.compile("href=\\\"(.*?)\\\"", Pattern.CASE_INSENSITIVE)来转义反斜杠字符。编写一个程序 Title.java,提取标签
<title>和<\title>之间的所有文本。(?i)使匹配不区分大小写。$2指的是第二个捕获的子序列,即title标签之间的内容。String pattern = "(?i)(<title.*?>)(.+?)(</title>)"; String updated = s.replaceAll(pattern, "$2");编写一个正则表达式,匹配在<TD ...>和标签之间的所有文本。答案:
<TD[^>]*>([^<]*)</TD>**排列的正则表达式。**找到一组所有 n 个元素的排列的最短正则表达式(仅使用基本操作),其中 n = 5 或 10。例如,如果 n = 3,则语言是 abc、acb、bac、bca、cab、cba。答案:困难。解决方案的长度与 n 的指数成正比。
Web 练习(DFAs 和 NFAs)
绘制一个接受以 11 结尾的所有比特串的 4 状态 DFA。(每个状态表示到目前为止读入的输入字符串是否以 00、01、10 或 11 结尾。)绘制一个完成相同任务的 3 状态 DFA。
为至少包含一个 0 和至少包含一个 1 的比特串编写一个 DFA。
绘制一个 NFA,匹配所有包含三个 0 的倍数或五个 1 的倍数的字符串。提示:使用 3 + 5 + 1 = 9 个状态和一个ε转换。
绘制一个 NFA,识别所有以 aaab 结尾的字符串的语言。
绘制一个 NFA,识别所有倒数第四个字符为 a 的字符串的语言。
绘制一个识别语言的 NFA,该语言的所有字符串的第五个到最后一个字符是 a。
给出一个具有 5 个接受状态的 NFA。写出一个具有仅一个接受状态的等价 NFA。
给出一个具有 ε-转换的 NFA。写出一个没有 ε-转换的等价 NFA。
一元可除性。 给定一个接受具有三个 0 的倍数和五个 1 的倍数的比特串的 DFA。
排列的 DFA。 找出对于 n = 5 或 10 的所有 n 元素排列集合的最短 DFA。
Mealy 和 Moore 机。 Mealy:每个转换边上都有输出的 DFA。Moore:每个状态上都有输出的 DFA。
网页练习
文本到语音合成。 grep 的原始动机。"例如,你如何处理发音多种不同的二合字 ui:fruit, guile, guilty, anguish, intuit, beguine?"
波士顿口音。 编写一个程序,将所有的 r 替换为 h,将句子翻译成波士顿版本,如 "Park the car in Harvard yard" 翻译成波士顿版本 "Pahk the cah in Hahvahd yahd"。
文件扩展名。 编写一个程序,接受文件名作为命令行参数并打印出其文件类型扩展名。扩展名 是最后一个
.后面的字符序列。例如,文件sun.gif的扩展名是gif。提示:使用split("\\.");回想一下.是一个正则表达式元字符,所以你需要转义它。反转子域。 对于网络日志分析,根据子域(如
wayne.faculty.cs.princeton.edu)方便地组织网络流量。编写一个程序,读取一个域名并以反向顺序打印出来,如edu.princeton.cs.faculty.wayne。银行抢劫。 你刚刚目击了一起银行抢劫,并得到了逃跑车辆的部分车牌。它以
ZD开头,中间某处有一个3,以V结尾。帮助警官为这个车牌写一个正则表达式。解析带引号的字符串。 读取一个文本文件并打印出所有带引号的字符串。使用类似
"[^"]*"的正则表达式,但需要担心转义引号。解析 HTML。 一个 >,可选跟随空格,跟随
a,跟随空格,跟随href,可选跟随空格,跟随=,可选跟随空格,跟随"http://,跟随直到"的字符,可选跟随空格,然后是一个<。< \s* a \s+ href \s* = \s* \\"http://[^\\"]* \\" \s* >子序列。 给定一个字符串 s,确定它是否是另一个字符串 t 的子序列。例如,abc 是 achfdbaabgabcaabg 的子序列。使用正则表达式。现在重复这个过程,不使用正则表达式。答案:(a) a.*b.c.,(b) 使用贪婪算法。
亨廷顿病诊断。 导致亨廷顿病的基因位于染色体 4 上,并具有 CAG 三核苷酸重复的可变次数。编写一个程序来确定重复次数,并打印出
will not develop HD如果重复次数小于 26,offspring at risk如果次数为 37-35,at risk如果次数在 36 和 39 之间,如果次数大于或等于 40,则打印will develop HD。这就是亨廷顿病在遗传测试中的识别方式。重复查找器。 编写一个程��
Repeat.java,它接受两个命令行参数,并在指定的文件中找到第一个命令行参数的最大重复次数。字符过滤器。 给定一组 坏字符 的字符串
t,例如t = "!@#$%^&*()-_=+",编写一个函数来读取另一个字符串s并返回删除所有坏字符的结果。String pattern = "[" + t + "]"; String result = s.replaceAll(pattern, "");通配符模式匹配器。 在不使用 Java 内置正则表达式的情况下,编写一个程序 Wildcard.java 来查找字典中与给定模式匹配的所有单词。特殊符号匹配任意零个或多个字符。因此,例如模式"ward"匹配单词"ward"和"wildcard"。特殊符号.匹配任何一个字符。您的程序应该将模式作为命令行参数读取,并从标准输入读取单词列表(以空格分隔)。
通配符模式匹配器。 重复上一个练习,但这次使用 Java 内置的正则表达式。*警告:*在通配符的上下文中,*与正则表达式的含义不同。
密码验证器。 假设出于安全原因,您要求所有密码至少包含以下字符之一
~ ! @ # $ % ^ & * |为
String.matches编写一个正则表达式,如果密码包含所需字符之一,则返回true。答案:"[~!@#\(%^&*|]+\)"字母数字过滤器。 编写一个程序 Filter.java 从标准输入读取文本并消除所有不是空格或字母数字的字符。答案这是关键行。
String output = input.replaceAll("[^\\s0-9a-zA-Z]", "");将制表符转换为空格。 编写一个程序,将 Java 源文件中的所有制表符转换为 4 个空格。
解析分隔文本文件。 存储数据库的一种流行方式是在文本文件中每行一个记录,并且每个字段由称为分隔符的特殊字符分隔。
19072/Narberth/PA/Pennsylvania 08540/Princeton/NJ/New Jersey编写一个程序 Tokenizer.java,该程序读取一个分隔符字符和一个文件名,并在文件中创建一个令牌数组。
解析分隔文本文件。 重复上一个练习,但使用
String库方法split()。PROSITE 到 Java 正则表达式。 编写一个程序来读取 PROSITE 模式并打印出相应的 Java 正则表达式。
拼写错误。 编写一个 Java 程序来验证这个常见拼写错误列表是否只包含形式为的行
misdemenors (misdemeanors) mispelling (misspelling) tennisplayer (tennis player)其中第一个单词是拼写错误,括号中的字符串是可能的替换。
注释剥离器。 编写一个程序 CommentStripper.java,从标准输入读取 Java(或 C++)程序,删除所有注释,并将结果打印到标准输出。这在 Java 编译器的一部分中很有用。它使用一个 5 状态有限状态自动机来删除
/* */和//样式的注释。它旨在说明 DFA 的强大之处,但要正确剥离 Java 注释,您需要更多状态来处理额外情况,例如,像s = "/***//*"这样的引号字符串文字。下面的图片由David Eppstein提供。![用于剥离注释的 DFA]()
改进的注释剥离器。 修改 CommentStripper.java 以正确处理引号字符串内的字符。
睡一觉吧。 德国神经学家在《自然》杂志 427 卷(2004 年)第 352 页记录的一项实验假设,睡眠充足的学生比睡眠不足的学生更能解决棘手的问题。他们使用的问题涉及一个由三个数字 1、4 和 9 组成的字符串。"比较"两个相同的数字会产生原始数字;比较两个不同的数字会产生缺失的数字。例如 f(1, 1) = 1, f(4, 4) = 4, f(1, 4) = 9, f(9, 1) = 4。比较输入字符串的前两个数字,然后重复比较当前结果与字符串中的下一个数字。给定一个特定的字符串,你最终得到什么数字?例如,如果输入是字符串 11449494,你最终得到 9。
1 1 4 4 9 4 9 4 1 9 1 4 4 1 9
5.2 图灵机
原文:
introcs.cs.princeton.edu/java/52turing译者:飞龙
本节正在大力施工中。
图灵机。
图灵机是 20 世纪最美丽和最引人入胜的智力发现之一。图灵机是计算(和数字计算机)的简单且有用的抽象模型,足够一般以包含任何计算机程序。它构成了理论计算机科学的基础。由于其简单的描述和行为,它适合进行数学分析。这种分析导致对数字计算机和计算的更深入理解,包括揭示了有些计算问题根本无法在计算机上解决,无论处理器有多快,内存有多大。
图灵机模拟器。 这是一个由 Tom Ventimiglia 在 Bob Sedgewick 和 Kevin Wayne 的监督下用 Java 编写的图灵机模拟器。
可执行 jar 文件(turing.jar)。要执行,请在命令行中键入
java -jar turing.jar。OS X 应用程序(Turing.zip)。要执行,请双击 Turing.zip 文件解压缩。双击 Turing.app 启动。
欢迎查看和修改源代码以供您自己使用。
组件。 阿兰·图灵试图描述一种与人类“计算机”具有相同基本功能的机械设备的最原始模型。在他具有划时代意义的1936 年论文中,阿兰·图灵介绍了一个抽象机器,后来被称为图灵机。该机器由以下组件组成:
纸带 存储输入、中间结果和输出。纸带是一条任意长的带子,分成单元格。每个单元格存储有限符号字母表中的一个符号。在下面的示例中,我们使用由 0、1、A、X 和 # 组成的 4 个字符字母表。
![图灵机纸带]()
图灵机的纸带头逐个单元格扫描纸带。我们将正在扫描的单元格称为活动单元格,其中包含的符号称为输入符号。在每个时间步骤中,纸带头读取输入符号,并将其保持不变或用新符号覆盖。在每个时间步骤结束时,纸带头向左或向右移动一个位置。我们用黄色突出显示活动单元格。在下面的示例中,
A被替换为X,纸带头向左移动一个单元格。![图灵机纸带头]()
控制单元 相当于现代微处理器中的 CPU。它由一个状态转换图组成,这是一张有限的指令表,准确指定了机器在每一步中采取的动作。每个状态代表机器的可能配置之一。根据当前状态和输入符号,图灵机用新符号覆盖输入符号并移动到新状态。每个转换将一个状态,比如 s,连接到另一个状态,比如 t,并用两个符号,比如 A 和 X 标记:这意味着如果图灵机处于状态 s 并且输入符号是 A,则它将用 X 覆盖 A 并转换到状态 t。每个状态用五种标记之一标记:L(左)、R(右)、Y(是)、N(否)或 H(停止)。进入状态时,图灵机根据状态的标记移动其纸带头或停止。下面是一个具有四个状态的机器的状态转换图示例。
![图灵机状态图]()
执行。 最初,图灵机从称为起始状态的一个特殊状态开始,并且磁带头指向称为起始单元的一个特殊单元。对于每个状态和输入符号的组合,最多可能有一个对应的转换;因此��机器的操作是事先完全确定的。(如果从某个输入符号的状态没有可能的转换,则图灵机保持在相同状态并且不覆盖输入符号。)图灵机的每一步如下进行:
从活动单元格读取输入符号。
查找与当前状态和输入符号相关联的转换规则。
用新符号覆盖输入符号。
根据转换规则改变当前状态。
根据新状态的指定,将磁带头向左或向右移动一个单元。
这些步骤重复进行,直到当前状态标记为 H 以停止,Y(在这种情况下,机器回答是)或 N(在这种情况下,机器回答否)。机器可能永远运行而永远不会达到这些终止状态。
计算必须允许重复动作 - 重复执行动作 A 直到满足某个条件。这相当于保持在一个状态中(并将磁带头向左或向右移动),直到满足某个条件。计算还必须允许自适应动作 - 如果满足某个条件,则执行动作 A;否则执行动作 B。这由根据特定位置磁带头内容的状态转换捕获。
一个例子:一元转换为二进制。 我们考虑下面的 4 状态图灵机。当前状态和输入符号用黄色突出显示。我们跟踪其执行。
由于输入符号是 A,图灵机遵循离开当前状态的适当转换箭头 - 标记为 A:X 的箭头。图灵机用 X 覆盖输入符号,将状态更改为右下角的状态,并将磁带头向左移动一个位置(因为新状态标有 L)。下面的插图显示了第一步结束时的图灵机。
由于输入符号现在是#,图灵机遵循离开当前状态的适当转换箭头 - 标记为#:1 的箭头。这将用 1 覆盖当前单元格,将状态更改回左下角的状态,并将磁带头向右移动一个位置(因为新状态标有 R)。
下面是接下来几步后磁带的内容。
(勘误:在第四行中,突出显示的单元格应包含#而不是 1。)
一旦所有的 A 都被 X 覆盖,图灵机会抹去所有的 X(用#覆盖它们)。
它是如何工作的以及为什么有效。 上述描述的图灵机将一元转换为二进制。也就是说,如果输入由 n 个连续的 A 组成,那么图灵机会在 A 序列的左侧打印数字 n 的二进制表示(并用 X 覆盖 A)。在上面的示例中,输入由 6 个 A 组成,图灵机将二进制数 110 写入磁带。
要描述如何实现这一点,我们首先回顾一种将二进制整数加 1 的算法:从右向左扫描位,将 1 更改为 0,直到看到 0。然后将 0 更改为 1。
图灵机反复地每次敲掉一个 A 并增加二进制数。我们的图灵机模仿这种策略。初始状态寻找下一个 A,用 X 覆盖它,然后转移到增量状态。增量状态将二进制整数增加一(保持 X 不变,将 1 更改为 0,直到看到 0 或#,将其更改为 1),然后转移到初始状态。当所有的 A 都被 X 覆盖时,清理状态用#替换所有的 X,然后转移到停止状态。
Java 中的图灵机实现。
我们使用良好的面向对象编程原则封装了主要的图灵机组件(磁带、转换、控制)。
磁带。 程序
Tape.java是表示无界图灵机磁带的 ADT。它支持以下操作:将磁带头向左移动,将磁带头向右移动,读取当前单元格中的符号,向当前单元格写入符号。为了实现它,我们使用两个栈(一个用于存储磁带头左侧的所有符号,一个用于右侧)。为了打印出磁带的内容,我们打印出第一个栈的反向,当前元素,然后第二个栈。状态。 每个状态都有一个名称和类型(停止、左、右、接受或拒绝)。
转换。 每个转换都有初始状态的名称、最终状态的名称和要写入磁带的符号。
图灵机。 我们将图灵机实现为一条磁带,一个状态符号表和一个转换符号表。
非终止图灵机。 从理论上讲,我们主要关注执行有限计算然后停止的机器。然而,许多实际应用涉及设计永不终止的程序(操作系统、空中交通管制系统、核反应堆控制系统)或产生无限量输出的程序(网络浏览器、计算π = 3.1415...的程序)。图灵机计算模型扩展到处理这种非终止情况。
图灵机连接物理和数学(图灵最初的动机,计算的热力学)。
练习
当给定磁带启动以下图灵机时,它会做什么...
二进制加法器。
二进制计数器。
二进制回文。
一元乘法。
相同数量的 a 和 b。
3 的倍数或 7 的倍数。
平衡的括号。
2 的幂。
字符串比较。
一元转二进制。将 N 转换为二进制的 3 状态一元转二进制图灵机需要多少步?答案:与 N² 成正比。
一元转二进制。设计一个 6 状态的一元转二进制图灵机,将一元数 N 转换为二进制数,时间与 N log N 成正比。提示:划掉每隔一个 A。如果 A 的数��是奇数,写入 1;否则写入 0。重复剩下的未划掉的 A。
![图灵机状态图]()
交换图灵机上的两个单元。使用状态来编码临时符号。
十六进制转二进制。设计一个将十六进制转换为二进制的图灵机。
比较器。 创建一个接受两个由#字符分隔的二进制整数作为输入的图灵机,并在第一个字符串严格小于第二个字符串时接受输入。前一个问题中的图灵机比较两个 N 位整数需要多少步?(每步是一次磁带头的移动。)
高效比较器。 创建一个在 N 的多项式时间内运行的比较器。
按位或。 创建一个计算其两个长度为 N 的二进制输入的按位或的图灵机。
创意练习
加倍。 编写一个将由 k 个连续 1 组成的输入转换为由 2k 个连续 1 组成的输入的图灵机(乘以 2 的一元运算)。提示:在左侧写两个 1,并在右侧删除一个 1。
复制。 编写一个图灵机,将由 0 和 1 组成的输入转换为原始输入的两个副本,用符号#分隔。
朗顿蚂蚁。 编写一个程序
LangtonsAnt.java,模拟一个被称为朗顿蚂蚁的二维图灵机,并使用 Turtle 图形来展示结果。Turmites。 创建一些其他二维图灵机或Turmites,产生有趣的图案。
图灵带。 编写一个程序 Tape.java,实现一维图灵带。该带由一系列单元格组成,每个单元格存储一个整数(初始化为 0)。在任何时刻,都有一个带头指向其中一个单元格。支持以下接口方法:
moveLeft()将带头向左移动一个单元格,moveRight()将带头向右移动一个单元格,look()返回活动单元格的内容,write(int a)将活动单元格的内容更改为a。提示:使用一个int表示活动单元格,使用两个栈表示带的左侧和右侧部分。图灵机模拟器。 编写一个程序 TuringMachine.java,模拟一个图灵机。设计程序如下:Tape.java,State.java,Transition.java。
Collatz 图灵机。 设计一个图灵机,其输入为二进制整数的二进制表示形式,并重复地将其除以 2(如果是偶数)或乘以 3 并加 1(如果是奇数),直到等于 1。著名的开放猜想是,这台机器对于任何输入都会终止。
5.3 通用性
原文:
introcs.cs.princeton.edu/java/53universality译者:飞龙
本节正在大力施工中。
20 世纪最重要的科学成就之一是形式化计算的概念。在本节中,我们探讨了这个宇宙中什么是可计算的这个基本问题。20 世纪的惊人发现是,通用计算机能够执行任何其他计算机可以执行的计算。也许这是计算机科学中最重要的思想。
点燃计算机革命的颠覆性技术。通用性有助于解释世界各地网页的快速采用。在 Web 存在之前,人们已经使用计算机进行数字计算和文字处理。通用计算机很容易适应处理 Web 协议、MP3 文件和数字照片。一旦技术可用,人们就可以立即利用其力量。(工业革命等过程采用速度较慢。)“很难想象电视的这个过程的类比 - 就好像数百万美国人被诱使购买他们客厅里的大型惰性盒子,十年后有人梦想出开始向他们广播图片的技术。但这与 Web 发生的情况差不多。”(Kleinberg-Papadimitriou)我们只能想象未来几十年会出现什么新技术,但我们可以肯定我们的通用计算机将能够充分利用它。
不需要单独的机器来处理图像和文本。一个通用机器适用于所有任务。这与大多数其他领域形成鲜明对比。例如,没有通用的烹饪设备。相反,我们有用于切片、混合、搅拌、烘烤、煮沸、烤、烤面包、烘烤、酿造和辐射的单独设备。在自然科学中可能有一个例外:基因组(可以改变基因组中的几个符号并创建新的生物体,就像编写新的计算机程序一样)。
许多不同类型的计算设备:Cray 超级计算机、戴尔个人电脑、iMac、Palm Pilot、XBox、Tivo、图灵机、TOY 机、Java 编程语言、Microsoft Excel、Java 手机、量子图灵机、Perl 编程语言。这些东西能做的事情与 Gaggia 浓缩咖啡机能做的事情有根本区别吗?
图灵机在功能上等同于 TOY 和 Java。可以用 Java 程序模拟任何图灵机,可以用图灵机模拟 TOY,可以用 TOY 机模拟 Java。同样的想法适用于 C、C++、C#、Python、Excel、Outlook。还有 Mac、PC、Cray、ENIAC、康拉德·祖斯的 Z3(但直到 1998 年才被证明)、Palm pilot。还有 TiVo、Xbox、Java 手机/但不包括 DFA、Gaggia 浓缩咖啡机,或者 MIT 学生们为了玩井字游戏而制作的Tinker Toy 计算机。
TOY 机和 Java 编程语言有一个无限的存储量(可扩展存储)的隐含假设。否则,TM 就会严格更强大。这个假���合理吗?如果无限让你感到恐惧,你可以考虑签订服务合同。如果需要更多内存,你只需上网订购一些。我们默认认为栈/队列是无限的,尽管内存最终会用完。图灵机是计算的模型,而不是计算机的模型。因此,我们不限制机器带的磁带的大小。
Java 程序编写为处理任意长度的输入。您可能无法在内存有限的计算机上执行大输入的程序,但您可以在内存更大的计算机上执行该程序。图灵机模拟程序,而不是机器:对于给定问题存在一个快速的 Java 程序等价于存在一个相同问题的快速图灵机。
非正式地,算法是解决问题的逐步过程。形式上,我们将算法定义为图灵机。每个图灵机能够执行单个算法,例如,测试一个整数是否为质数。在这个意义上,图灵机类似于计算机程序(软件)而不是计算机(硬件)。Knuth:“算法是与宇宙物理定律无关的抽象概念。”这意味着我们可能需要为我们想要执行的每个算法构建一个单独的图灵机。这是非常不切实际的!
通用图灵机。 通用图灵机是一种特定的图灵机,可以模拟任何图灵机的行为(包括自身!)。这使得图灵机能够回答关于其他图灵机(或自身)的问题。艾伦·图灵描述了这样一台机器:它的存在意味着有一台单独的图灵机,即 UTM,能够运行任何算法。关键思想是将图灵机的描述本身视为数据。例如,我们可以对下面所示的图灵机进行编码

其中包含以下表格。我们将每个状态标记为 0 到 5。我们为每个转换箭头包括一个行,用于表示每个状态的索引以及其标签(左、右、是、否或停止)。我们为每个转换箭头包括一行四个符号,分别表示当前状态、输入符号、下一个状态和写入符号。
0 L
1 R
2 R
3 Y
4 R
5 N
0 1 # #
2 0 1 x
4 0 0 x
1 2 0 x
1 3 # #
1 4 1 x
2 5 # #
4 5 # #
现在,我们可以将表连接成一个关于字母表{0, 1, 2, 3, 4, 5, x, #}的单个字符串。
0 L 1 R 2 R 3 Y 4 R 5 N 0 1 # # 2 0 1 x ... 4 5 # #
并用这个输入初始化磁带。由于原始图灵机本身也有一个输入,例如,我们想要检查其质数性质的整数,我们将这个输入附加到图灵机描述的末尾,用一个新符号分隔。
0 L 1 R 2 R 3 Y 4 R 5 N 0 1 # # 2 0 1 x ... 4 5 # # # # 0 0 1 1 1 0 # #
我们还可以附加起始状态和磁带头的初始位置。
通用图灵机接受图灵机的描述作为输入(以及图灵机的初始磁带内容),并在该图灵机上模拟输入。因此,通用图灵机可以模拟任何单个 TM 的行为。用现代术语来说,通用图灵机是一个解释器 - 它对任何图灵机进行逐步模拟。程序和数据是一回事 - 程序(图灵机)只不过是一个看起来像其他数据的符号序列。当输入到通用图灵机时,程序“变得活跃”并开始计算。(从 Web 下载程序和电子邮件病毒类似 - 只是数据,直到你双击它们并将它们提供给操作系统执行)。
建立这样一个通用图灵机似乎是一项艰巨的任务。艾伦·图灵在他的 1937 年论文中描述了第一台这样的图灵机。1962 年,明斯基发现了一台使用四个符号字母表执行的令人惊奇的 7 状态 UTM,但描述起来相当复杂。
通用计算机。 首次由艾达·洛芙莱斯(Ada Lovelace)阐述。她将巴贝奇的分析引擎描述为适合“开发和制表任何函数...引擎[是]任何不确定性功能的材料表达,无论其一般性和复杂性程度如何。”她描述了它在科学计算中的用途,包括三角函数和伯努利数。她还提倡它可以用于制作音乐和图形。通用计算机类似于通用图灵机:它们能够运行不同的算法,而无需进行任何硬件修改。这是可能的,因为现代微处理器基于冯·诺伊曼结构。在这种模型中,计算机程序和数据存储在同一主存储器中。这意味着内存内容可以视为机器指令或数据,取决于上下文。这与 UTM 的磁带内容完全类似,其中包含程序(原始 TM)和数据(原始 TM 的磁带内容)。艾伦·图灵关于 UTM 的工作预示了通用计算机的发展,并可以看作是软件的发明!
丘奇-图灵论题。
1936 年,阿隆佐·丘奇(Alonzo Church)和艾伦·图灵(Alan Turing)独立提出了计算模型,他们认为这些模型体现了机械过程的计算概念。丘奇发明了λ演算来研究可计算性概念,而图灵则使用他的图灵机。尽管这两种模型看起来非常不同,但图灵后来证明它们是等价的,因为它们都选择了相同的数学函数集。他们得出了相同的结论,我们用图灵机的术语表达:
图灵机可以执行任何可以用纯机械过程描述的事情。
换句话说,任何理想数学家可以执行的过程都可以在图灵机上模拟。随后,这个论题被扩展为断言宇宙中所有(物理上可利用的)过程都可以由图灵机模拟,这是我们将考虑的丘奇-图灵论题的版本。这将计算研究简化为图灵机的研究,而不是无限数量的潜在计算设备。这意味着我们不必寻找更强大的机器;我们唯一的选择是利用现有机器的能力或创建新的能够更快地完成任务的机器。这个论题的逆命题也具有深远的影响。它表明,如果某件事在图灵机上无法完成,那么我们无法使用任何纯机械过程来完成它。当我们发现有些问题图灵机无法解决时,我们将在第 7.6 节探讨其后果。请注意,丘奇-图灵论题不是一个数学命题,也不受严格证明的约束。它是关于现实可实现宇宙的陈述。然而,像任何良好的科学理论一样,丘奇-图��论题也可能被推翻。如果有人展示了一个严格更强大的计算模型,而且这个模型是物理上可利用的,我们将否定丘奇-图灵论题。
这个论题等同于说任何通用编程语言都足以表达任何算法。
普适性。 丘奇-图灵论题暗示了不同计算模型之间的普适性。有大量支持表明这种普适性。向图灵机添加新功能并不会使其在接受的语言或计算的函数方面更强大。
| 修改的图灵机 | 描述 |
|---|---|
| 多个磁头 | 两个或更多独立的磁头 |
| 多个磁带 | 两个或更多磁带 |
| 多维磁带 | 二维磁带 |
| 非确定性 | NFA 控制磁带而不是 DFA |
| 概率性 | 可以抛硬币。如果大多数硬币翻转导致接受状态,则接受输入 |
| 可编辑 | 可以在磁带上插入和删除符号 |
图灵机的定义非常健壮。以下限制不会影响图灵机的能力(当分别应用于标准图灵机时)。
| 修改图灵机 | 描述 |
|---|---|
| 单向无限 | 磁带只在一个方向上是无限的 |
| 二进制 | 磁带字母表只包含两个符号 |
| 两状态 | 控制磁带的 DFA 具有两个状态 |
| 非擦除 | 一旦写入磁带,就永远不能重新写入符号的图灵机 |
| 可逆时间 | 前一个状态总是可以从当前状��和磁带内容唯一确定。 |
数学家、计算机科学家、生物学家和物理学家已经考虑了许多其他计算模型。以下是一些已被证明与图灵机在能力上等效的模型的部分列表,从而进一步支持了丘奇-图灵论题。
| 通用计算模型 | 描述 |
|---|---|
| 波斯特形式系统 埃米尔·波斯特,1920 年代 | 旨在从一组公理中证明数学命题的字符串替换规则。 |
| 无类型λ演算 阿隆佐·邱奇,1936 | 一种定义和操作函数的方法。是函数式编程语言的基础,包括 Lisp 和 ML |
| 通用图灵机 阿兰·图灵,1936 | 可以模拟任何其他图灵机行为的图灵机。 |
| 一般递归函数 哈布兰德,1932
库尔特·哥德尔,1934 | 处理自然数上的计算的函数。 |
| 部分递归函数 阿隆佐·邱奇,1932
史蒂芬·克林,1935 | 处理自然数上的计算的函数。 |
| 马尔可夫算法 安德烈·马尔可夫,1960 | 按照预定顺序依次应用字符串替换规则。 |
|---|---|
| 无限制文法 诺姆·乔姆斯基,1950 年代 | 按任意顺序依次应用字符串替换规则,直到满足某种停止条件。被语言学家用来描述自然语言。 |
| 标记系统 埃米尔·波斯特,1921 年,1935 年,1965 年 | 只要字符串至少包含 k 个字母:读取第一个字母,删除前 k 个字母,并根据第一个字母附加一个字符串。 |
| 扩展 L-系统 阿里斯蒂德·林登迈尔,1976 | 并行应用字符串替换规则。生物学家用于模拟植物生长。 |
| 半图灵系统 阿克塞尔·图厄,1910 | 按任意顺序应用字符串替换规则。 |
| 霍恩子句逻辑 阿尔弗雷德·霍恩,1951 | 基于逻辑的定理证明系统。构成 Prolog 语言的基础。 |
| 1D 元胞自动机 马修·库克,1983
史蒂芬·沃尔夫拉姆,2002 | 一个一维布尔数组,其单元格的值根据相邻单元格的状态而改变。对应于有限冲激响应数字滤波器。 |
| 2D 元胞自动机 约翰·冯·诺伊曼,1952
约翰·康威,1960 年代 | 一个二维布尔单元格数组,其值根据相邻单元格的状态而改变。最著名的例子是康威的生命游戏。 |
| 波斯特机器 埃米尔·波斯特,1936 | 一个 DFA 加上一个队列。 |
|---|---|
| 两栈机 | 一个 DFA 加上两个栈。 |
| 两寄存器机器 谢泼德森-斯特吉斯,1963
马文·明斯基,1961 | 一个 DFA 加上两个整数计数器,它可以递增、递减,并与零进行比较。 |
| 编程语言 | Java, C, C++, Perl, Python, PHP, Lisp, PostScript, Excel, ... |
|---|---|
| 随机访问机 | 有限数量的寄存器加上可以用整数地址访问的内存。包括 TOY 和几乎所有现代微处理器。 |
| 指针机 | 有限数量的寄存器加上可以作为链表访问的内存。 |
| 量子图灵机 理查德·费曼,1965
大卫·迪奥特,1985 | 使用量子态叠加进行计算。 |
| 台球计算机 Fredkin-Toffoli, 1982 | 不可区分的台球在平面上移动,彼此之间和内部障碍物弹性碰撞。 |
|---|---|
| 粒子机器 | 信息通过粒子在空间中传递,计算发生在粒子碰撞时。 |
| 过滤器自动机 Park-Steiglitz-Thurston, 1985 | 使用新计算值一旦可用即刻使用的元胞自动机。对应于无限冲激响应数字滤波器。 |
| 广义移位映射 Christopher Moore, 1990 | 一个在由抛物面镜组成的三维势阱中移动的单个经典粒子。 |
| DNA 计算机 Len Adleman, 1994 | 使用 DNA 链上的生物操作进行计算。 |
| 类移位动力系统 Christopher Moore, 1981 | 使用混沌理论的基于动力学的计算。 |
| 动力系统 Sinha-Ditto, 1998 | 使用混沌理论的基于动力学的计算。 |
| 动力系统 | 混合系统、分段仿射系统、饱和线性系统。 |
| 孤立子碰撞系统 Ken Steiglitz, 2000 | 在均匀介质中的时间门控 Manakov 空间孤立子。一种没有空间固定门的无门计算机。 |
| 高级 Petri 网 Carl Petri, 1962 | 用于同时发生事件的自动机的泛化。应用于管理、制造、通信协议、容错系统。 |
交互式计算。 或许最自然的计算形式是作为计算器 - 将输入转换为输出。当图灵机计算时,它不接受外部输入;这阻止了它直接模拟许多自然过程。我们的计算机执行许多不太适合放入这个框架的操作。例如,你的操作系统、用户界面、文字处理器、视频游戏、踢足球的机器人、网络、传感器。所有这些都涉及与程序的外部代理(人或其他程序)的交互或通信。交互机器 是图灵机的自然推广,它接受同步或异步输入流。
为什么这不违反了丘奇-图灵论题?有人可能会争辩说外部代理本身可以被图灵机模拟!
计算的物理学。 在他们具有里程碑意义的论文保守逻辑中,Fredkin 和 Toffoli 论证计算是一个物理过程,最终受物理原则支配。他们提出了一些所有计算过程都受到的公理。图灵的目标是发明一台捕捉计算本质的机器,同时仍受物理定律约束。Fredkin 和 Toffoli 讨论了图灵机如何隐含地体现这前三个公理。
信息传播速度受限。 在物理学中,远距作用 受到光速的限制。对于图灵机,磁带头每次只能向左或向右移动一个单元。因此,因果效应只能通过局部相互作用传播。
有限系统状态中可以编码的信息量受限。 在物理学中,热力学和量子物理的考虑表明存在这样的限制。例如,全息原理是一个关于量子热力学的猜想,它对时空表面积上的信息量设定了一个限制(每平方米10⁶⁹ 位)。对于图灵机,每个单元只包含三种可能的符号之一。
可以构建宏观、耗散性物理设备,以可识别和可靠地执行逻辑函数 AND、OR 和 FAN-OUT。 这意味着可以用物理部件制造图灵机,并可靠地运行它们。
迪奥特提出了丘琴原理,它断言宇宙能够包含一个能够模拟宇宙本身的通用机器。这将计算性概念与物理学联系起来。
祖斯-弗雷德金论。大约在 1960 年,爱德华·弗雷德金首次提出了一个激进的观念,即宇宙是一个(通用的)元胞自动机,一种高度并行的计算设备。在 1960 年代末,康拉德·祖斯独立提出了宇宙是在计算机上计算的观点,可能是一个元胞自动机。他将其称为Rechnender Raum或Computing Cosmos。这一颇具争议的论点被称为祖斯-弗雷德金论。从物理学的角度来看,这是一个涵盖一切的普适理论。许多复杂系统可以用简单的离散过程来解释是事实。关于自然界中这种复杂性是由元胞自动机还是其他过程负责的问题,仍然是一个迷人的开放科学问题。尽管图灵-丘琴论被广泛接受,但更具深远意义的祖斯-弗雷德金论仍然相对默默无闻,尚未被广泛接受。其中一个主要挑战是将其与量子物理相协调。此外,这一理论很难验证,尚未证明在利用传统方法无法做出的关于宇宙的准确预测方面有用。康拉德·祖斯论是两个新兴学科——数字力学和数字物理学的基石。它具有深刻的哲学意义。例如,如果宇宙是一个元胞自动机,那么我们人类以及我们的逻辑思维就是在元胞自动机的某个时空区域中执行的算法。在祖斯、弗雷德金和许多其他人的研究基础上,史蒂夫·沃尔夫勃罗姆在他的书一种新的科学中提倡整个宇宙是一个巨大的计算过程,并且这样的物理过程最好通过研究元胞自动机等简单的计算模型来理解。
数字物理学:宇宙是一个确定性计算机的输出。
计算的普遍限制。霍金-贝肯斯坦界限假设一个半径为 R、能量为 E 的球体能够容纳的信息量(量子态数或可编码的比特数)存在一个限制:I &le 2 π E R / (ℏ c ln 2),其中ℏ为普朗克常数,c 为光速。另一种表达方式是使用 E = mc²,&le k M R,其中 M 为区域的质量,k 约为 2.57686 × 10⁴³比特/(m 千克)。最大处理速度受限于状态转换的最短时间,这个时间受限于光穿过半径为 R 的区域所需的时间:π E / (ℏ ln 2)比特/秒。贝肯斯坦界限基于黑洞物理学。这意味着,理论上,图灵机,以其任意大小的磁带,只存在于我们的想象中(如果它们具有有限大小和有界能量)。尽管在实践中,它们是现实的极好模型。
问:假设人类大脑存储了 10¹⁶比特,质量为 1 千克,大致呈球形,半径为 10 厘米。将其效率与贝肯斯坦界限进行比较。
这个更严格的界限基于 Krauss 和 Starkmann 的物理论文,该论文表明,如果宇宙正在加速,那么即使在未来无限遥远的情况下,可以处理的信息总量也存在固有限制。估计的限制约为 10¹²⁰比特,因此目前无需担心!Seth Lloyd 的这篇 Nature 论文对 1 升容积的 1 千克计算机的计算速度和可访问的内存量进行了定量限制。
超越教堂-图灵论题。 是否有任何类型的计算无法由图灵机完成,但可以使用其他类型的物理或抽象机器完成?是的。我们考虑了一些所谓的超级计算的代表性例子。
这些计算模型能够解决一些问题(例如,停机问题),这些问题无法使用图灵机解决。我们不知道如何构建这样的机器,因此不违反教堂-图灵论题。
Oracle 图灵机。 一种带有用于回答问题的预言的图灵机,例如,用于停机问题。这种计算模型比图灵机更强大,但我们对如何构建这样的机器毫无头绪。因此,它们(目前)不违反教堂-图灵论题。
带有初始铭文的图灵机。 以无限数量的符号开始的图灵机。同样,这导致了比图灵机更强大的计算模型,但我们不知道如何构建这样的机器,因为它使用了无限的存储空间。
实值图灵机。 这是一种抽象的计算模型,其中每个磁带单元存储实值而不是离散符号。Blum-Shub-Smale 模型允许每个磁带单元存储任意实数,可能是超越数。旨在捕捉科学计算、计算几何、计算代数、连续优化中的连续问题。这些机器使用无限精度操作实数。基本的单位成本计算步骤包括:算术运算(+,-,*,/),任意常数,比较运算或(,=如果在实数域上)。输入和输出是 Rn 中的向量。如果 x 在 Rn 中,则其大小为 n。(可以用另一个域替换实数,例如,复数 C,但不允许有序比较。如果域是 F_2,则恢复经典图灵模型。)在这个模型下存在不可判定的问题:给定一个复数 z,它是否在 Mandelbrot 集中?给定一个起始点 x,牛顿法是否收敛?在这个模型下存在“NP 完全”问题:背包问题:给定 n 个实数,是否存在一个子集的和恰好为 1?给定 n 个变量的实系数的 4 次多项式,它是否有一个实零点?给定 n 个变量的复多项式的有限集,它们是否有一个公共零点?然而,目前尚不清楚连续值是否真实存在于自然界中,如果存在,任何自然过程是否能利用它们的力量。
这些计算模型在可计算性方面等同于图灵机,例如,都无法解决停机问题。然而,这些模型可以解决计算函数和确定语言范围之外的问题,例如生成真正的随机数。
概率图灵机。 一种概率图灵机类似于非确定性图灵机,只是它从合法选择中均匀随机选择下一个转换。在可决定的语言或可计算的函数方面,概率图灵机与图灵机的能力是等价的。然而,概率图灵机可以生成真正的随机位,这是传统图灵机无法实现的。然而,似乎存在利用量子力学或其他自然现象产生随机性的物理过程。支持祖斯-弗雷德金论题的人会认为自然只产生伪随机数,这些可以在确定性图灵机上模拟。
量子图灵机。计算是一个物理过程,但图灵机忽略了量子力学效应。德沃斯提出了一种行为类似于图灵机但利用量子力学来进行计算的量子图灵机。量子图灵机在决定语言或计算函数方面与图灵机的能力相当。然而,量子图灵机开辟了新的计算模式,因为它们可以执行除计算函数和决定语言之外的任务。一个引人注目的例子出现在密码学中。在第零章中,我们考虑了一种称为一次性密码本的加密方案。为了使它们实用,我们需要一种有效的方法在两个方之间共享密钥(一串随机位),而不被第三方拦截,或者如果被拦截,两个通信方应该能够检测到。使用经典计算机和物理学是不可能实现无条件安全密钥分发的。然而,通过利用海森堡不确定性原理,本内特和布拉萨德(1984 年)设计了一个解决方案来解决这个问题。不确定性原理的主要论点是(i)从物理系统中提取信息的任何测量都必然会改变该系统,(ii)提取关于某一数量的信息的任何测量都必然会阻止提取关于共轭数量的信息。本内特和布拉萨德的方案使用单个光子发送每个量子位(比特的量子等价物)。光子在第一次被读取时就会被改变,因此不能被拦截而不被检测到。虽然我们还不知道如何构建量子图灵机,但科学家们已经开发了专门的密码学电路来实现本内特和布拉萨德的方案,并已经用它们安全地在 15 公里的光纤电缆上分发密钥。
莱文的Tale of One-way Functions是对超级计算的一个对立观点。
练习
康威的生命游戏。普适性。这是马丁·加德纳在 1970 年发表在《科学美国人》上的文章。
元胞自动机。元胞自动机是试图模拟自然规律的计算机模拟。它们用于模拟生物、化学和物理过程,包括:根据伊辛模型的铁磁性、森林火灾传播、非线性化学反应扩散系统、湍流流动、生物色素图案、材料断裂、晶体生长以及植物和动物的生长。它们还用于图像处理、计算机图形学、大规模并行硬件设计、密码学和艺术。
一维元胞自动机是一个随时间演化的细胞数组,根据相邻细胞的状态和一组规则进行演化。在给定时间 t,每个细胞要么是活的,要么是死的。规则 90 表示细胞 i 在时间 t 处于活动状态,如果其相邻细胞(细胞 i-1 或细胞 i+1)在时间 t-1 处于活动状态。程序 Cellular.java 使用两个布尔数组模拟了这种元胞自动机的行为。数组元素 cells[i]在当前时间 t 中为 true,如果细胞 i 在当前时间 t 中是活的,则为 true;否则为 false;数组元素 old[i]在上一个时间步骤 t-1 中为 true,如果细胞 i 在上一个时间步骤 t-1 中是活的,则为 true;否则为 false。
开火小队同步问题。 开火小队问题最初由迈尔希尔在 1957 年提出,并由摩尔在 1962 年在有限状态机的背景下解决。您有一个具有 2^n 个相同单元格的一维元胞自动机,每个单元格可以采用有限数量的颜色之一。它们都以相同的速度运行,每个单元格只能与其相邻的单元格通信。所有单元格最初都是白色的。设计机器,使得在向第一个机器(将军)发出“开始”信号后,所有机器(士兵)在时间 t 第一次变为特殊的“开火”颜色。您事先不知道数组的大小,因此您的解决方案不得依赖于 n。提示:尝试找到中间单元格。
斑马条纹。 通过使用乌龟图形模拟 2D 元胞自动机来生成合成斑马条纹的图片。
首先创建一个 N×N 的单元格网格,随机初始化每个单元格为黑色或白色。然后,运行 10 个阶段,每个阶段依次遍历 N×N 单元格,更新单元格(i, j)的颜色(黑色或白色)如下:
对于每个黑色相邻单元格(i', j'),使得|i - i'| = 0 或 1 且|j - j'| ⇐ 3,将 2.0 添加到一个累加总和中。
对于每个黑色相邻单元格(i', j'),使得|i - i'| = 2 或 3 且|j - j'| ⇐ 3,从累加总和中减去 1.2。
如果累加总和超过 1.4,则将(i, j)着色为黑色;否则将其着色为白色。编写一个程序 Zebra.java 来说明元胞自动机的结果。
尝试使用不同的加权函数(例如 2.0 和-0.4),并使用曼哈顿距离函数|i - i'| + |j - j'|代替上面的函数。
帕特森蠕虫。 帕特森蠕虫是由约翰·康威发现并由马文·加德纳广泛推广的一种重现谜题,类似于康威的生命游戏。编写一个程序
PatersonWorm.java,在乌龟图形中展示帕特森蠕虫的动画。林登迈尔系统。 将替换规则
F -> F+F--F+F并行应用于初始字符串F+F--F+F。例如,一次应用后,字符串变为F+F--F+F--F+F--F+F--F+F--F+F。如果将F解释为向前走 1 步,+解释为顺时针旋转 60 度,-解释为逆时针旋转 60 度,这个命令序列绘制了科赫雪花。编写一个程序 Lindenmayer.java,它接受一个命令行参数 N,并打印生成 N 阶科赫曲线的命令。提示:使用字符串库方法replaceAll。用林登迈尔系统生成树。 编写一个程序 LSystem.java,使用林登迈尔系统创建类似树的图形:XYZ。
林登迈尔系统解释器。 编写一个程序,读取林登迈尔系统的描述并绘制生成的图案。
阿克曼函数。 阿克曼函数 A(m, n)是一个看似简单但在算法分析和复杂性理论中起着关键作用的函数。它的递归定义如下:
A(0, n) = n + 1
A(m, 0) = A(m - 1, 1)
A(m, n) = A(m - 1, A(m, n - 1))
编写一个程序 Ackermann.java,接受两个命令行参数 M 和 N,并计算 A(M, N)。
计算 A(3, 9)时会发生什么?答案:堆栈溢出错误。
A(5, 5)是多少?答案:数字太多,无法打印出来。这里是A(4, 2)。要看到这个函数增长的速度有多快,请考虑以下等价定义:
A(0, n) = n + 1
A(1, n) = 2 + (n + 3) - 3
A(2, n) = 2 × (n + 3) - 3
A(3, n) = 2 ^(n + 3) - 3
A(4, n) = 2^(2^(...²)) - 3 (塔中有n + 3 个项)
...
巴克函数。 巴克函数的递归定义如下:
f(m, n) = f(m-1, f(m, n-1)) f(0, n) = n + 1 f(1, 0) = 2 f(2, 0) = 0 f(m, 0) = 1 for m = 3, 4, ...手动计算 f(1, n),f(2, n),f(3, n)和 f(4, n)作为 n 的函数。
答案: 2 + n, 2n, 2n, 2(2^(2^(...²)))(塔中有 n 个项)。
元胞自动机沙堆。 使用元胞自动机模型模拟沙子堆积。
谢林分离模型。 研究谢林分离模型(SSM)。"首个能够自组织的动态系统的建设性模型。" 模拟了一个整合环境,其中每个代理人都略微偏好邻居是相同类型的可能导致分离。对于每个有颜色的单元,如果超过 1/3(或其他阈值)的邻居是不同颜色的,则移动到随机选择的单元(或让每个单元进行随机行走)。 (或者也许检查所有在某个邻域内的单元,权重与 1/d 成比例)。谢林获得了 2005 年的诺贝尔经济学奖。 绿色乌龟和红色乌龟。 偏好通过池塘传播。 即使乌龟最初只希望相似度达到 30%,最终也会达到约 70%。 这里有一个演示。
5.4 可计算性
原文:
introcs.cs.princeton.edu/java/54computability译者:飞龙
本节正在进行重大改造。
在大卫·希尔伯特于 1900 年对国际数学大会的著名演讲中,他断言:
拿任何明确未解决的问题来说,比如欧拉-马斯克罗尼常数γ的无理性问题,或者形式为 2^n+1 的无限个素数是否存在的问题。无论这些问题对我们来说看起来多么难以接近,我们在它们面前多么无助,我们仍然坚信,它们的解决必须通过有限数量的纯逻辑过程来实现。
现在我们对算法(图灵机)有了清晰的概念,我们将看到一些计算问题无论可用资源量如何都无法解决。如果存在解决特定问题的算法,则该问题被称为可解;否则该问题被称为不可解。我们给出几个自然的不可解问题的例子。不可解问题出现在许多领域,包括:元胞自动机、混沌理论、组合数学、运筹学、统计学、物理学、编译器理论、结论论、逻辑、集合论和拓扑学。请注意,不可解性是关于问题的一个非常强烈的陈述 - 它不仅表示科学家们尚未发现该问题的算法,而且这样的发现是不可能的。
停机问题。 停机问题是所有不可解问题中最著名的,也是第一个被归类为不可解的问题。停机问题的输入是一个图灵机及其输入。目标是确定该图灵机是否会达到停机状态。这比看起来要困难,因为非常简单的图灵机,通常被称为忙碌的海狸,可能执行非常复杂的动作。N 状态忙碌的海狸是一个在二进制字母(0 和 1)上定义的 N 状态图灵机,当以全零磁带启动时,在停机前尽可能多地在磁带上留下 1。为了找到即使对于较小的 N 值也能找到忙碌的海狸是一个令人惊讶的艰巨任务。以下这台 8 状态图灵机在磁带上留下 4,098 个 1,并在 47,176,870 步之后停机。
然而,它并不是一个忙碌的海狸。事实上,Marxen and Buntrock有一台 8 状态的图灵机(转换为我们的 Minsky 风格符号后),在超过 10⁹⁵步之后,在磁带上留下超过 10⁴⁷个 1,还有一台令人惊讶的 9 状态图灵机,在超过 10¹⁷³⁰步之后,在磁带上留下超过 10⁸⁶⁵个 1,并停止运行。
Java 中的停机问题。 我们可以用 Java 编程语言重新表述停机问题。在这里,目标是编写一个程序,比如Halt.java,来确定某个静态方法,比如mystery,在某个特定输入x上是否进入无限循环。这将是一个强大的调试工具。我们都写过进入无限循环的程序。代码中可能存在无限循环的可能通常表示一个 bug。商业软件中的无限循环可能导致愤怒的客户,甚至更糟。如果 Java 编译器能够警告我们的函数可能进入无限循环,那将是很好的。要了解为什么这是一项艰巨的任务,请考虑程序 Perfect.java。它搜索一个奇数完美数:一个等于其真因子之和的数(例如,28 是完美的,因为 28 = 1 + 2 + 4 + 7 + 14)。这个程序是否停机(假设我们不会遇到溢出问题)?如果是,我们需要等多久才能得出它进入无限循环的结论?我们可以输入代码并查看结果。如果程序终止,我们可以安全地回答是。主要障碍是确定何时回答否。假设我们在某个时刻停止程序(Ctrl-c)并回答否。也许如果我们让程序运行更长一点,它会自行停机。尽管进行了大量研究,但没有人知道Perfect.java是否会停机。数学家已经证明,直到 n 至少为 10³⁰⁰时它才会停机。这是一个极端的例子,但它突显了通过查看给定程序是否终止没有简单方法。相比之下,程序 Cube.java 搜索一个正整数解 313(a³ + b³) = c³。事实证明,Cube.java会停机(假设我们不会遇到溢出问题),但直到 c 大于 10¹⁰⁰⁰。事实上,我们可以以相同的方式提出类似费马大定理的数学问题(参见练习 XYZ)。如果停机问题是可解的,那么数学将变得容易。
停机问题是不可解的。 我们在下面概述了一个令人震惊的证明,证明不存在或���远不存在解决停机问题的算法。这个证明的思想受到以下悖论的启发。以下陈述是真还是假?*这个句子是假的。*这个悖论的本质是由自指引起的。TM“形式化了一个关于为什么你永远无法拥有完美内省的旧论点:因为如果你可以,那么你可以确定十秒后你将要做什么,然后做其他事情。”(斯科特·亚伦森)我们停机证明的关键思想是将一个程序本身作为输入。
理发师悖论。 假设巴里是一个理发师,他声称他给镇上所有(而且只有那些)不给自己剃须的人剃须,并且理发师住在镇上,自己是刮得干净的。巴里会给自己剃须吗?我们可以通过反证法证明这样一个理发师是不存在的...现在我们将同样的逻辑推理应用到 Java 程序中...
非正式证明。 由于 Java 编程语言等价于图灵机,我们只需证明我们无法编写一个 Java 程序来解决停机问题。我们考虑接受一些任意输入(比如来自stdin)的程序。我们用反证法或归谬法这种数学技巧。假设,为了矛盾,存在这样一个停机程序Halt(P, x)。(我们将展示这导致一个明显的矛盾,因此我们必须得出结论,这样的程序不存在。)它接受两个输入:一个程序P和它的输入x。程序Halt(P, x)在P(x)停机时输出yes,否则输出no。注意,根据我们的假设,对于任何一对输入,Halt(P, x)本身总是停机的。
现在,乐趣开始了。创建一个新程序Strange(P),它以单个程序P作为输入。这个新程序调用带有P作为两个输入的停机程序,即,Halt(P, P)。[回顾一下���建其输入副本的图灵机。]将程序作为输入可能看起来有点奇怪,但这是相当常见的。编译器就是这样做的;它们读取 Java 程序作为输入,并输出一个机器语言程序。正如 Marvin Minsky 所观察到的,你不需要太担心为什么我们想要执行这样一种内向的计算。但是,通过考虑一个计算生物学家想要创建自己基因组的完整描述,你可以获得一些直觉!这一点一点都不荒谬。
现在,我们设计程序Strange(P),以便如果Halt(P, P)输出no,则它立即停机。否则,我们让Strange(P)进入无限循环。在 Java 中,Strange(P)的代码可能如下所示:
if (Halt(P, P) == true)
while (true) // infinite loop
;
总结
如果
P(P)不停机,那么Strange(P)就会停机。如果
P(P)停机,那么Strange(P)就不会停机。
现在,我们执行关键步骤:将程序Strange(P)本身作为输入,即,设置P = Strange。让我们看看会发生什么疯狂的事情。语句(a)和(b)现在简化为:
如果
Strange(Strange)不停机,那么Strange(Strange)就会停机。如果
Strange(Strange)停机,那么Strange(Strange)就不会停机。
这两种情况都导致矛盾。我们唯一能得出的结论是我们假设程序Halt(P, x)存在是不可能的。也就是说,停机问题是不可解的!
一首诗意的证明。
这里有一首诗,以诗歌的形式证明了停机问题的不可判定性!在诗意的证明中,程序Strange()被替换为Q()。
对角线化论证。 这个证明是对对角线化论证的一个例子:我们想象一个二维网格,行由程序 P 索引,列由输入 x 索引,Halt(P, x)是在 P(x)上运行停机程序的结果。对角线条目对应于 Halt(P, P)。证明的本质是确定哪一行对应于程序 Strange。矛盾之处在于我们构造 Strange 使其与网格中的每一行不同(特别是在每个对角线条目上)。
后果。 作为任何确定性算法正确性证明的一部分,我们必须证明它在有限步骤后终止。有一些重要的程序类别,可以轻松判断,但停机问题的不可判定性排除了一般规则或公式。每个正确性证明可能需要一个全新的想法(例如,奇完美数程序),而且没有办法(完全)自动化这个过程。
为什么调试很难?以下是不可判定的。
自停机问题。 给定一个接受一个输入的程序,当给定自身作为输入时是否终止?
全体性问题。 给定一个接受一个输入的程序,它是否在所有输入上停机。3x+1 问题。
程序等价性问题。 给定两个分别接受一个输入的程序,它们在每个输入上是否产生相同的结果。
死代码消除。 特定代码是否会被执行?
变量初始化。 变量在首次引用之前是否被初始化?
内存管理。 变量是否会再次被引用?
其他不可解问题。 以下是一些更多的不可解问题的例子:
赖斯定理。 说任何图灵机的输入/输出的非平凡性质都是不可判定的元定理。非平凡意味着一些程序具有该属性,而一些程序没有。图灵机在所有输入上是否停机?在无限多个输入上是否停机?在没有输入上是否停机?在至少两个不同长度的输入字符串上是否停机?被接受的字符串集是否是正则语言?两个图灵机是否对完全相同的输入停机?
柴田数。 图灵机 M 在随机输入上停机的概率是多少?
希尔伯特的第十个问题。 为了获得一些历史视角,我们重述希尔伯特第十个问题的著名故事。1900 年,大卫·希尔伯特在巴黎国际数学家大会上提出了 23 个问题,作为即将到来的世纪的挑战。希尔伯特的第十个问题是设计一个过程,根据这个过程可以通过有限次操作确定给定的多元多项式是否有整数根。例如,多项式 f(x,y,z) = 6x³yz² + 3xy² - x³ - 10 有一个整数根,因为 f(5,3,0) = 0,而多项式 f(x,y) = x² + y² - 3 没有整数根。这个问题可以追溯到两千年前的丢番图方程,它涉及到物理学、计算生物学、运筹学和统计学等多个领域。当时,算法没有严格的定义;因此,甚至没有考虑到不可解问题的存在。在 20 世纪 70 年代,希尔伯特的第十个问题以一种非常令人惊讶的方式得到解决:尤里·马季亚塞维奇证明了它是不可解的。也就是说,不可能创建一个程序(比如,在 Java 中),它可以读取任意多元多项式作为输入,并返回适当的是-否答案!如果只有一个变量,这个问题是可判定的,但即使有 13 个变量,它也是不可判定的。
一元函数的根。 如果是单变量函数,希尔伯特的第十个问题是可判定的。然而,如果允许三角函数,那么问题再次变得不可判定。给定一个由多项式(加法、乘法、组合)和正弦项组成的单变量 x 的有理函数 f(x),是否存在一个 x使得 f(x) = 0?Richardson's theorem 稍微弱一些但更著名和更早 - 它假设 f(x)是由有理数、π、ln 2、变量 x、加法、乘法、组合、正弦、指数和绝对值函数构建的函数。参考:Paul Wang。
定积分。 根查找问题可以用来证明定积分也是不可判定的。给定一个仅涉及多项式和三角函数的函数 f(x)的定积分,f(x)从-∞到+∞的积分是否收敛(极限是否存在)?因此,当我们输入一个定积分时,我们不能期望符号代数软件(如 Maple 或 Mathematica)总是告诉我们答案。即使 f(x) = [(x² + 1)g²(x)]^(-1),其中 g(x)是涉及多项式和正弦项的有理函数,也是不可判定的。
波斯特对应问题。 这个涉及字符串的不可解谜题最早由 Emil Post 在 1940 年代分析。问题的输入是
N种不同的卡片类型。每种卡片类型包含两个字符串,一个在顶部,一个在底部。谜题是将卡片排列成顶部和底部字符串相同(一个yes实例),或报告不可能(一个no实例)。你可以使用任意数量的每种卡片类型。这是一个有 4 种卡片类型的yes实例:![波斯特对应问题]()
以及使用卡片 1、3、0、2 和第二份卡片 1 的对应解决方案。
![波斯特对应问题]()
顶部字符串和底部字符串分别为
abababababa。这是一个有 4 张卡片的no实例:![波斯特对应问题]()
为什么这是一个
no实例?在一个yes实例中,解决方案中最左边的卡片的最上方和最下方的字符必须匹配。在这个例子中,没有一张卡片的最左边的顶部和底部字符匹配。因此,没有可能将卡片适当地排列。一般来说,这样一个简单的解释可能不存在。这里有一个由 Andrew Appel 创建的具有挑战性的 11 张卡片实例。令人难以置信的是,不可能编写一个 Java 程序,读取一系列
N种卡片类型,并始终能够正确报告这样的排列是否可能。当然,在某些输入(如上面的两个输入)上,程序可能能够返回正确答案。但是,在某些输入上,程序注定会失败。请注意,如果您只能使用每种类型的一张卡片,那么问题可以通过尝试所有可能性来解决,因为可能性的数量是有限的(尽管非常大)。可解性指的是计算是否可以完成,而不是需要多长时间。程序等价问题。 程序等价问题 是确定两个程序在给定相同输入时是否产生相同输出的问题。显然,初级计算机科学课程的评分员会欣赏这样的程序。更重要的是,通过将其与经过充分测试且无错误的版本(例如,暴力搜索)进行比较,可以在测试新程序(例如,一个新的但复杂的排序算法)时发现其是否存在问题。同样,这个问题是不可解的。
未初始化的变量。 Java 编译器有时会抱怨您可能在变量初始化之前访问局部变量。程序员有时可以保证变量已初始化,但编译器并不“聪明”到能意识到这一点。在下面的示例中,a 将被初始化为 17,因为数组的长度始终是非负的。
public static void main(String[] args) { int a; if (args.length >= 0) a = 17; int b = a * a; }为什么编译器不能自行解决这个问题?在这种情况下,它可以。但一般来说,确定变量是否已初始化是不可判定的。编译器会保守地行事,使用“足够”但不是“必要”的测试。
优化编译器(参考:Appel 论文)。 优化编译器是一种分析您的程序,删除任何无用代码和变量的编译器。死代码消除:程序是否会到达程序中的这一点?寄存器分配:从这一点开始,寄存器 x 中的值是否会影响计算结果?加载/存储调度:这两个引用是否别名(它们是否可能包含指向相同内存位置的指针)?
数据压缩。 给定一个字符串s,找到输出s的最短(以字符数衡量)程序。这个问题在信息论和数据压缩中具有基本重要性。这是奥卡姆剃刀的一个正式陈述——找到适合事实的最简单解释。曼德勃罗集是一个美丽的例子,它可以用一个简单的程序生成一个复杂的图片。有一个正式的方法可以保证找到这样一个简洁的描述将是很好的。
病毒识别。 Fred Cohen 非正式地将计算机病毒定义为能够通过修改其他程序以包含可能进化的自身副本来感染其他程序的程序。他还提供了涉及图灵机的严格定义,并表明确定给定程序是否为病毒是不可解的。
语法中的歧义。 给定一个上下文无关文法,它是否具有歧义?
矩阵死亡问题。 给定一个具有整数元素的 N×N 矩阵的有限集合,它们是否可以按某种顺序相乘(可能重复相同矩阵多次)以产生零矩阵?(对于 15 个或更多 3×3 矩阵的集合,或两个 45×45 矩阵的集合,这个问题已经是不可判定的。)
多边形铺砖。 给定一个多边形,不一定规则或凸,是否可能用该形状的副本铺满整个平面?对于矩形,直角三角形或六边形是可以的。
王瓷砖。 给定一组Wang tiles,你能否安排瓷砖的副本覆盖无限平面?
无限带自组装。 自组装 = “物体自主聚集形成复杂结构的过程。”参考。 应用于电路制造,DNA 计算,纳米机器人。给定一组瓷砖,你能否安排瓷砖的副本形成无限带?
群论。 测试有限呈现群是否可交换是一个不可判定的问题。测试它是否有限,自由或简单也是不可判定的问题。
拓扑学。 测试两个多面体(通过它们的三角剖分表示)是否同胚是不可判定的。测试两个 n 维流形(使用庞加莱和维布伦的定义)是否同胚对于 n > 3 是不可判定的。
排队论。 (Gamarnik)在维度 d 中,均匀随机行走是否稳定?到达的作业必须沿着图中的某条路径进行处理。如果处理器忙碌,作业将在缓冲区等待。到达时间和处理时间是已知的。如果系统中零件数量始终存在固定的上限,则调度策略是稳定的。广义优先级调度策略是否稳定?
控制论。 控制论中某些离散时间动力系统(混合系统,分段仿射系统)的全局渐近稳定性(Blondel,Henzinger)。
波动方程。 参考:Pour-El 和 Richards。三维波动方程的解 u(x, y, z, t)由初始条件和时间 t = 0 时的 du/dt 唯一确定。即使初始数据是可计算值,解在可计算时空点可能达到一个不可计算值。有些微分方程无法数值求解。
动力系统。 参考:克里斯托弗·摩尔。给定一个广义移位Φ,Φ是否混沌?
影响。 无解问题的存在在计算和哲学上有深远的影响。首先,它表明有些语言任何计算机都无法识别。也就是说,所有计算机都有固有的限制。这些问题可能具有巨大的实际意义,包括希尔伯特的第十个问题。我们必须在计算机的限制内工作,通过识别和避免无解问题。其次,逻辑可以解决任何问题的整体断言是本质上错误的。如果人脑的运作等同于机器,那么根据丘奇-图灵论题,人脑将不会比图灵机更强大。因此,人类无法解决像停机问题这样的问题。人类可能具有基本限制,就像计算机一样。其他人可能会有不同看法。罗森布卢姆得出结论人永远无法消除使用自己的聪明才智的必要性,无论他多么聪明地尝试。
直观地说,可计算性告诉我们要确定程序实际执行的操作,必须运行程序。
可计算数。 图灵的主要兴趣之一是定义可计算数 - 那些数字的位数可以通过机械过程描述。例如,可以编写一个图灵机,在磁带的某个指定(只写一次)部分留下π的数字(3, 1, 4, 1, 5 等)。即使π是无理数,在有限步骤后,前 N 位数字会打印在磁带的特殊部分上。其他可计算数的例子包括所有整数,所有有理数,sqrt(2),所有代数数,sin(10),erf(.4),贝塞尔函数的零点等。这基本上包括所有在科学计算中出现的数字。
可数和不可数的数字。 决策问题是某个字母表上的字符串的子集。有可数多个图灵机(如整数),但有不可数多个决策问题(如实数)。因此,几乎所有决策问题都是不可判定的。这个论证确立了不可判定问题的存在,但没有构造任何特定的问题,就像我们证明了停机问题的不可判定性一样。同样,只有可数无限多个可计算数,但有不可数多个无理数。因此,大多数无理数是不可计算的。幸运的是,在科学和工程中我们经常遇到的问题和数字是可判定的。当然,这可能是因为这些问题正是我们知道如何应对的问题!
哥德尔的不完备性定理。 图灵不可判定性结果是数学基础受到的最震惊的打击之一。1931 年,库尔特·哥德尔证明了自然数的最广泛接受的数学形式化(数学原理)要么是不完备的(不是每个真实陈述都能被证明为真),要么是不一致的(一些假陈述可以被证明为真)。哥德尔的不完备性定理适用于任何足够丰富以包括皮亚诺公理的算术的形式数学系统。
负面结果。 计算机科学与其他科学的一个区别特征是“下界”和“负面结果”的概念。我们在不可判定性中看到这一点。我们还将在 NP 完全性及其衍生物以及排序的下界中看到这一点。社会和物理科学中的一些伟大成就涉及到不可能性结果。在量子力学中有海森堡的不确定性原理,在经济学中有阿罗不可能定理,在热力学中有卡诺定理。在数学中,有关于寻找 5 次多项式根的不可能性定理,用直尺和圆规三等分角度的不可能性,证明欧几里得平行公设和非欧几里得几何的存在,根号 2 的无理性,林登曼证明π是超越数的。除了数学逻辑(例如,哥德尔的定理)之外,很少有学科有形式化方法来证明这样的负面结果。
练习
创意练习
(霍普克罗夫特,莫特瓦尼和乌尔曼。)对于两个 PCP 实例中的每一个,要么找到一个解决方案,要么说明为什么不可能有这样的解决方案。
0 1 2 3 0 1 2 3 a aba b bb abb ba bab bbabaa ba ab ba b bb bab abb babaab答案:第一个实例的解决方案是 bababaababb(2-0-2-1-1-3)。第二个实例没有解决方案。请注意,所有卡片底部至少有与顶部相同数量的 b。我们必须从卡片 1 开始,底部比顶部多一个 b。
PCP 的可解实例。 即使只允许的符号是 0 和 1,PCP 问题也是不可判定的。设计一个算法,假设只允许的符号是 1,确定 PCP 问题是否是一个是实例。提示:需要一些数论。
3x + 1 函数。 即使函数只有几行,有时也不容易判断特定函数是否在所有输入上终止。例如,考虑以下Collatz 函数对输入
x是否终止。void mystery(int x) { while (x > 1) { if (x % 2 == 1) x = 3*x + 1; else x = x / 2; } }在许多输入中,包括 8 和 7,程序终���:
mystery(8): 8 4 2 1 mystery(7): 7 22 11 34 17 52 26 13 40 20 10 5 16 8 4 2 1乍一看,这似乎是一个简单的问题。要确定程序是否在任何特定输入上终止,诱人的做法是运行具有给定输入的程序并查看发生了什么。如果程序终止,我们可以安全地回答
yes。主要障碍是决定何时停止并说no。假设我们在某个时刻停止程序并回答no。也许,如果我们让程序运行更长一点,它就会终止。没有办法确定。196 问题。 给定一个正整数,将数字颠倒并加到原数上。重复此过程直到得到一个回文数。例如,如果我们从 5280 开始,颠倒是 825,所以下一个数是 5280 + 825 = 6105。下一个数是 6105 + 5016 = 11121。最后一个数是 11121 + 12111 = 23232,因为它是一个回文数。这种颠倒并加算法对大多数整数快速终止。没有人知道它是否对 196 终止,尽管已知在前 900 万次迭代中没有终止。
费马风格问题。 是否存在一个正整数解来满足 313(a³ + b³) = c³?编写一个名为 Cube.java 的程序,枚举所有整数 a、b 和 c,并检查方程的解。该程序是否会停止(假设没有溢出)? 答案:是的,但不要等待,因为最小的反例有超过 1,000 位数!
费马大定理。 在 1XYZ 年,费马猜想不存在正整数 a、b、c 和 n,使得 an + bn = cn,其中 n > 2。编写一个名为 Fermat.java 的程序来搜索反例,并在找到一个时终止。按照 cn 的增序枚举所有元组 a、b、c 和 n。在 19XY 年,安德鲁·怀尔斯证明了费马大定理,这意味着假设没有溢出,Fermat.java 永远不会终止。
自指程序。 自指程序是一个执行时打印自身作为输出的程序。编写一个名为 Quine.java 的 Java 程序,它是一个自指程序。参考。这里是许多语言中的自指程序列表。
全字母句。 编写一个计算机程序,创建形式为“这个计算机生成的全字母句包含 _ 个 a,_ 个 b,...”的真实句子,其中空白处用英文数字代替。
这个计算机生成的全字母句包含六个 a,一个 b,三个 c,三个 d,三十七个 e,六个 f,三个 g,九个 h,十二个 i,一个 j,一个 k,两个 l,三个 m,二十二个 n,十三个 o,三个 p,一个 q,十四个 r,二十九个 s,二十四个 t,五个 u,六个 v,七个 w,四个 x,五个 y 和一个 z。
参考:这里
自停机问题。 自停机问题:给定一个接受一个输入的程序,当给定自身作为输入时,它是否终止?通过类似于停机问题的论证证明这个问题是不可判定的。
全体性问题。 全体性问题是决定任意图灵机是否在所有输入上停机。这样的程序将使我们能够自动检测 Java 程序中的无限循环的可能性。通过展示如果有一个返回
true或false的程序 TOTALITY(P),取决于图灵机 P 是否在所有输入上停机,证明全体性问题是不可判定的。解决方案:假设我们想解决停机问题,例如,知道图灵机 Q 在输入 x 上是否停机。创建一个新机器 P,它接受任意输入,忽略它,并在 x 上运行 Q。现在,只有当 Q 在输入 x 上停机时,P 才会在所有输入上停机。因此,我们可以使用 P 来解决停机问题。
程序等价问题。 证明程序等价问题是不可判定的。程序等价问题:给定两个程序 P 和 Q,它们对所有可能的输入值产生相同的结果吗?这样的程序意味着优化编译器无法保证找到最优效率的程序,因为可能有更好的版本,但编译器无法确定它们是否等价。
解决方案:我们将注意力限制在没有输出的图灵机上 - 它们要么停机,要么不停机。我们可以轻松构造一个总是停机且不输出任何内容的图灵机 Q。然后 PEQ(P, Q) 仅在 P 对所有可能的输入值停机时输出 true。因此,如果我们有一个 PEQ(P, Q) 子程序,我们就可以解决全面性问题,这是不可判定的。因此 PEQ(P, Q) 也是如此。
繁忙的海狸和可计算性。 繁忙的海狸函数 B(n) 被定义为 n 状态图灵机在二进制字母表上可以在初始空白磁带上留下的最大数量的 1,同时仍然停机。函数 B(n) 是不可计算的:不可能编写一个计算机程序,输入一个整数 n 并返回 B(n)。 Jeffrey Shallit 的讲义提供了对这一陈述的严格证明。
一个结果。 我们对停机问题不可判定性的证明使用了一个输入为自身表示的图灵机。事实上,即使图灵机的输入为空(全为 0),停机问题也是不可判定的。这样的机器被称为空白磁带图灵机。此外,即使我们只允许两个符号的字母表(0 和 1),问题也是不可判定的。我们利用 B(n) 的不可计算性来证明这一事实。
为了推导矛盾,让我们假设我们可以判断任何图灵机 M 在空白磁带上启动时是否停机。鉴于这样的决策者,我们将展示如何计算 B(n)。
INPUT: integer n FOREACH n-state Turing machine M - Check whether or not M halts when started with a blank tape - If it halts, run M until it halts and count how many 1's it leaves on the tape OUTPUT: the maximum number of 1's left on the tape by any machine该过程将终止,因为只有有限数量的 n 状态图灵机(我们可以按顺序列出它们),而我们只模拟那些停机的图灵机的操作。该过程计算 B(n),因为所有停机的 n 状态图灵机都被考虑。这个构造意味着我们有一种计算 B(n) 的方法。由于计算 B(n) 是不可判定的,我们最初的假设,即我们可以判断图灵机是否停机,必须是无效的。因此,即使图灵机以空白磁带开始并且在二进制字母表上工作,也不可能编写一个计算机程序来判断图灵机是否会停机。
变形虫生长。 假设你在一个罐子里开始有一只变形虫。每分钟,每只变形虫以 25% 的概率变成 0(死亡)、1(不做任何事)、2(分裂成两只)或 3(分裂成三只)只变形虫。变形虫种群最终灭绝的概率是多少?编写一个 Java 程序来模拟这个实验。你如何知道何时停止并得出结论说种群不会灭绝?答案:使用概率论,可以计算灭绝概率 = sqrt(2) - 1。使用 Java,没有简单答案。
60 分钟停机问题。 是否可能编写一个程序,接受另一个程序及其输入作为输入,并确定它是否会在不到 60 分钟内停机(比如在每秒执行一条机器指令的机器上)?
佩尔方程。 佩尔方程是找到方程 x² - Dy² = 1 的整数解 (x, y),其中 D 是正整数。找到方程 x² - 991y² - 1 = 0 的最小正整数解。答案:x = 1, y = 0 是一个平凡解,但最小正整数解是 x = 379516400906811930638014896080,y = 12055735790331359447442538767。
阿基米德的牛问题。 在阿基米德的牛问题的解决中出现了以下问题,这是一个数值问题,在计算机时代之前困扰数学家超过 200 年。找到方程 x² - 4729494y² = 1 的最小正整数解。答案:x = 109931986732829734979866232821433543901088049,y = 50549485234315033074477819735540408986340。
5.5 难解性
原文:
introcs.cs.princeton.edu/java/55intractability译者:飞龙
本节正在进行重大改建。
复杂性理论的目标是理解有效计算的本质。在第 4.1 节中,我们学习了算法分析,这使我们能够根据算法消耗的资源量对算法进行分类。在本节中,我们将学习一个丰富的问题类别,对于这些问题,没有人能够设计出有效的算法。
一个关于 P = NP 的不错的YouTube 视频。
���算复杂性。
随着数字计算机在 1940 年代和 1950 年代的发展,图灵机成为计算的理论模型。在 1960 年代,Hartmanis 和 Stearns 提出了根据输入大小的函数来衡量计算机所需的时间和内存。他们以图灵机的术语定义了复杂性类,并证明了一些问题具有“无法通过巧妙编程规避的固有复杂性”。他们还证明了一个直观观念的正式版本(时间层次定理),即如果给予更多时间或空间,图灵机可以计算更多的事情。换句话说,无论问题有多难(时间和空间要求),总会有更难的问题。
计算复杂性是确定不同问题的资源需求的艺术和科学。计算复杂性涉及对任何可能的问题算法的断言。做出这样的陈述比理解解决问题的一个特定算法的运行时间要困难得多,因为我们必须推理出所有可能的算法(甚至是尚未发现的算法)。这使得计算复杂性成为一个令人兴奋但令人望而生畏的研究领域。我们将概述一些其最重要的思想和实际产物。
多项式时间。
在第 4.1 节中,我们学习了如何分析算法的运行时间作为其输入大小的函数。在解决给定问题时,我们更喜欢一个需要 8 N log N 步骤的算法,而不是需要 3 N²步骤的算法,因为当 N 很大时,第一个算法比第二个算法快得多。第二个算法最终将解决相同的问题(但可能需要几小时而不是几秒)。相比之下,指数时间算法具有不同的定性行为。例如,对于 TSP 的暴力算法可能需要 N!步骤。即使宇宙中的每个电子(10⁷⁹)都具有今天最快超级计算机(每秒 10¹²条指令)的能力,并且每个电子在解决问题上工作了宇宙寿命(10¹⁷秒),也几乎无法解决 N = 1,000 的问题,因为 1000! >> 10¹⁰⁰⁰ >> 10⁷⁹ * 10¹² * 10¹⁷。指数增长使技术变革相形见绌。我们将任何运行时间受输入大小多项式限制的算法(例如 N log N 或 N²)称为多项式时间算法。如果对于问题没有多项式时间算法,则称该问题为难解性。
在 Harel p. 74 中创建 N、N³、N⁵、N¹⁰、1.1N、2N、N!的对数对数比例图。
随着程序员对计算的经验增加,很明显多项式时间算法是有用的,而指数时间算法则不是。在一篇非常有影响力的论文中,Jack Edmonds 将多项式时间算法称为“好算法”,并认为多项式时间是有效计算的一个很好的替代。Kurt Godel 在 1956 年给冯·诺伊曼写了一封信(第 9 页),其中包含了多项式性是一个可取的特征的(隐含)概念。早在 1953 年,冯·诺伊曼就认识到了多项式和指数算法之间的定性差异。根据多项式和指数时间对问题进行分类的想法深刻地改变了人们对计算问题的看法。
NP.
非正式地,我们将搜索问题定义为一个计算问题,我们在(可能非常庞大的)可能性中寻找一个解,但是当我们找到一个解时,我们可以轻松地检查它是否解决了我们的问题。给定搜索问题的一个实例 I(指定问题的一些输入数据),我们的目标是找到���个解 S(满足一些预先指定标准的实体)或报告不存在这样的解。为了成为搜索问题,我们要求检查 S 是否确实是一个解是容易的。这里的“容易”是指在输入 I 的大小的多项式时间内。复杂度类NP是所有搜索问题的集合。以下是一些示例。
线性方程组. 给定一个线性方程组 Ax = b,找到一个满足方程的解 x(如果存在的话)。这个问题属于 NP 类,因为如果我们得到一个假设的解 x,我们可以通过将 x 代入并验证每个方程来检查 Ax = b。
线性规划. 给定一个线性不等式系统 Ax ≤ b,找到一个满足不等式的解 x(如果存在的话)。这个问题属于 NP 类,因为如果我们得到一个假设的解 x,我们可以通过将 x 代入并验证每个不等式来检查 Ax ≤ b。
整数线性规划. 给定一个线性不等式系统 Ax ≤ b,找到一个满足不等式的二进制(0/1)解 x(如果存在的话)。这个问题属于 NP 类,因为如果我们得到一个假设的解 x,我们可以通过将 x 代入并验证每个不等式来检查 Ax ≤ b。
虽然检查对所有三个问题的提议解是容易的,但是从头开始找到一个解有多困难呢?
备注:我们对 NP 的定义略有不同。从历史上看,复杂度类是根据决策问题(是-否问题)来定义的。例如,给定一个矩阵 A 和一个向量 b,是否存在一个解 x 使得 Ax = b?
P.
复杂度类 P 是所有可以在多项式时间内解决的搜索问题的集合(在确定性图灵机上)。与以前一样,我们根据搜索问题(而不是决策问题)定义 P。它涵盖了我们在实际机器上可以解决的大多数问题。以下是一些示例:
| 问题 | 描述 | 算法 | 实例 | 解决方案 |
|---|---|---|---|---|
| 最大公约数 | 找到两个整数 x 和 y 的最大公约数。 | 欧几里得算法(欧几里得,公元前 300 年) | 34, 51 | 17 |
| STCONN | 给定图 G 和两个顶点 s 和 t,找到从 s 到 t 的路径。 | BFS 或 DFS(忒修斯) | ||
| 排序 | 找到将元素按升序排列的排列。 | 归并排序(冯·诺伊曼,1945) | 2.3 8.5 1.2 9.1 2.2 0.3 | 5 2 4 0 1 3 |
| PLANARITY | 给定一个图 G,在平面上绘制它,使得没有两条边相交。 | (Hopcroft-Tarjan,1974) | ||
| LSOLVE | 给定矩阵 A 和向量 b,找到一个向量 x 使得 Ax = b。 | 高斯消元法(Edmonds,1967) | x+y=1 2x+4y=3 | x = 1/2 y = 1/2 |
| LP | 给定一个矩阵 A 和一个向量 b,找到一个向量 x 使得 Ax ≤ b? | 椭球算法(Khachiyan,1979) | x+y≤1 2x+4y≤3 | x = 0 y = 0 |
| 丢番图方程 | 给定一个具有整数系数的(稀疏)一元多项式,找到一个整数根? | (Smale 等,1999) | x⁵ - 32 | x = 2 |
扩展的图灵-丘奇论题。
在 1960 年代中期,Cobham 和 Edmonds 独立观察到,在多种计算模型中,从确定性图灵机到 RAM 机,可以在多项式步骤内解决的问题集保持不变。扩展的图灵-丘奇论题断言图灵机与任何物理计算设备一样有效。也就是说,P 是在这个宇宙中可以在多项式时间内解决的搜索问题集。如果某个硬件解决了大小为 N 的问题需要时间 T(N),扩展的图灵-丘奇论题断言确定性图灵机可以在时间 T(N)^k 内解决,其中 k 是某个固定常数,k 取决于特定问题。Andy Yao 表达了这个论题的广泛影响:
它们暗示至少原则上,要使未来的计算机更有效,只需要专注于改进现代计算机设计的实现技术。
换句话说,任何合理的计算模型都可以在(概率性)图灵机上进行有效模拟。已知的所有物理通用计算机都符合扩展的图灵-丘奇论题。对于随机访问机器(例如您的 PC 或 Mac),常数 k = 2。因此,例如,如果随机访问机器可以在时间 N^(3/2)内执行计算,则图灵机可以在时间 N³内执行相同的计算。
P = NP 吗?
我们这个时代最深刻的科学问题之一是P = NP。也就是说,所有搜索问题是否都可以在多项式时间内解决?Clay 基金会为解决这个问题提供了100 万美元的千禧奖。以下是一些关于何时解决这个问题的猜测。压倒性的共识是 P != NP,但没有人能够证明它。
视频中荷马·辛普森对 P = NP 进行演讲,伴随着《失乐园》的音乐。
Godel 写给 von Neumann 的信预见了 P = NP 问题。他认识到如果 P = NP(可满足性在 P 中),那么“将会产生最重要的后果”,因为“数学家关于是或否问题的思维工作可以完全被机器取代”。他询问哪些组合问题有更有效的替代方案来避免穷举搜索。
简化。
简化或模拟是计算机科学中的一个强大概念……将问题 X 转化为更简单的问题 Y 的问题解决框架,以便根据对 Y 问题的解决方案推断出原始问题 X 的解决方案。
Eric Alander - “简化提供了一种抽象。如果 A 有效地简化为 B,而 B 有效地简化为 A,那么 A 和 B 在某种意义上是等价的:它们是解决同一问题的两种不同方式。我们不再有无限多的计算问题,而是留下了更少数量的等价问题类。没有什么能够让计算社区为这个惊人的发现做好准备,即人们真正想要解决的基本不同的计算问题只有几个。”基于资源限制将自然计算问题划分为有意义的组。
NP 完全性。
非正式地说,NP 完全问题是 NP 中“最难”的问题;它们最有可能不在 P 中。定义:如果一个问题(i)在 NP 中且(ii)每个 NP 问题都可以多项式归约到它,则问题是NP 完全的。定义 NP 完全性的概念并不意味着这样的问题存在。事实上,NP 完全问题的存在是一件令人惊奇的事情。我们无法通过从每个 NP 问题呈现归约来证明一个问题是 NP 完全的,因为它们有无限多个。在 1960 年代,Cook 和 Levin 证明了 SAT 是 NP 完全的。
这是普遍性的一个例子:如果我们可以解决任何 NP 完全问题,那么我们就可以解���NP 中的任何问题。独特的科学发现为各种问题提供了共同的解释。更令人惊奇的是存在“自然”的 NP 完全问题。
NP 完全性对自然科学的影响是不可否认的。一旦第一个 NP 完全问题被发现,难以解决性质就像“冲击波一样在问题空间中蔓延”,首先在计算机科学中,然后传播到其他科学学科。Papadimitriou 列出了 20 个不同的科学学科正在应对内部问题。最终,科学家们在意识到他们的核心问题是 NP 完全后发现了它们固有的复杂性。每年有 6000 篇科学论文提到 NP 完全性作为关键词。“涵盖了计算、科学、数学努力的广泛领域,并似乎粗略地界定了数学家和科学家一直渴望可行计算的范围。” [Papadimitriou] 很少有科学理论有如此广泛和深远的影响。
一些 NP 完全问题。自从发现 SAT 是 NP 完全以来,已经确定了成千上万个问题是 NP 完全的。1972 年,Karp 表明离散数学中最臭名昭著的 21 个问题是NP 完全的,包括Tsp、Knapsack、3Color和Clique。科学家们未能为这 21 个问题找到有效算法,尽管他们不知道这些问题是 NP 完全的,这是最早表明 P != NP 的证据之一。以下是一些 NP 完全问题的示例。这里还有一些NP 完全问题。这只是为了说明它们的多样性和普遍性。
装箱问题。你有 n 个物品和 m 个箱子。第 i 个物品重 w[i]磅。每个箱子最多可以容纳 W 磅。你能否将所有 n 个物品装入 m 个箱子而不违反给定的重量限制?
这个问题有许多工业应用。例如,UPS 可能需要从一个配送中心将大量包裹(物品)运送到另一个中心。它希望将它们放入卡车(箱子)中,并尽可能少地使用卡车。其他 NP 完全的变体允许体积要求:每个三维包裹占用空间,你还必须担心如何在卡车内摆放包裹。
背包问题。你有一组 n 个物品。第 i 个物品重 w[i]磅,具有收益 b[i]。你能否选择物品的一个子集,使得总重量小于或等于 W 且总利益大于或等于 B?例如,当你去露营时,必须根据它们的重量和效用选择要带的物品。或者,假设你正在入室行窃,只能在你的背包中携带 W 磅的赃物。每个物品 i 重 w[i]磅,有 b[i]美元的街头价值。你应该偷哪些物品?
子集和。给定 n 个整数,是否存在它们的一个子集,其和恰好为 B?例如,假设整数是{4, 5, 8, 13, 15, 24, 33}。如果 B = 36,则答案是肯定的(4, 8, 24 是一个证书)。如果 B = 14,则答案是否定的。
划分。给定 n 个整数,你能将它们划分为两个子集,使得每个子集的和相等吗?例如,假设整数是 {4, 5, 8, 13, 15, 24, 33}。那么答案是
yes,{5, 13, 33}是一个证书。双处理器的负载平衡。整数线性规划。给定一个整数矩阵 A 和一个整数向量 b,是否存在一个整数向量 x 使得 Ax ≤ b?这是运筹学中的一个核心问题,因为许多优化问题可以用这种方式表达。请注意与上面提出的线性规划问题形成对比,我们在这里寻找的是一个有理向量而不是一个整数向量。可解问题和不可解问题之间的界限可能非常微妙。
SAT. 给定 n 个布尔变量 x[1], x[2], ..., x[N] 和一个逻辑公式,是否存在一种真值赋值使得该公式是可满足的,即为真?例如,假设公式是
(x[1]' + x[2] + x[3]) (x[1] + x[2]' + x[3]) (x[2] + x[3]) (x[1]' + x[2]' + x[3]')
然后,答案是 yes,(x[1], x[2], x[3]) = (true, true, false) 是一个证书。在电子设计自动化(EDA)中有许多应用,包括测试和验证、逻辑综合、FPGA ��由和路径延迟分析。在人工智能中的应用,包括知识库推理和自动定理证明。
练习:给定两个电路 C1 和 C2,设计一个新电路 C,使得某些输入值的设置使得 C 输出为真当且仅当 C1 和 C2 是等价的。
3-SAT. 给定 n 个布尔变量 x[1], x[2], ..., x[N] 和一个逻辑公式(合取范式),每个子句恰好有 3 个不同的文字,是否存在一种真值赋值使得该公式是可满足的?
团. 给定 n 个人和一组两两友谊关系。是否存在一个由 k 个人组成的群体或团,使得群体内每对人之间都是朋友?方便起见,我们可以绘制友谊图,其中我们为每个人包括一个节点,并连接每对朋友之间的边。在下面的例子中,当 n = 11 且 k = 4 时,答案是
yes,{2, 4, 8, 9}是一个证书。最长路径。给定一组节点和节点之间的两两距离,是否存在一条长度至少为 L 的简单路径连接某对节点?
机器调度。你的目标是在 m 台机器上处理 n 个作业。为简单起见,假设每台机器可以在 1 个时间单位内处理任何一个作业。此外,可能存在优先约束:也许作业 j 必须在作业 k 开始之前完成。你能安排所有作业在 L 个时间单位内完成吗?
调度问题有大量的应用。工作和机器可以是相当抽象的:为了毕业普林斯顿,你需要修完 n 门不同的课程,但不愿意在任何一个学期修超过 m 门课程。此外,许多课程有先修课程(你不能在修 126 之前修 COS 226 或 217,但可以同时修 226 和 217)。你能在 L 个学期内毕业吗?
最短公共超字符串。给定基因字母表 { a, t, g, c } 和 N 个 DNA 片段(例如,ttt, atggtg, gatgg, tgat, atttg),是否存在一个长度不超过 K 的 DNA 序列,其中包含每个 DNA 片段?假设在上面的例子中 K = 11;那么答案是
yes,而atttgatggtg是一个证书。应用于计算生物学。蛋白质折叠。 生物体内的蛋白质以非常特定的方式在三维空间中 折叠 到它们的 天然状态。这种几何模式决定了蛋白质的行为和功能。最广泛使用的折叠模型之一是二维亲水-疏水(H-P)模型。在这个模型中,蛋白质是一个由 0 和 1 组成的序列,问题是将其嵌入到一个 2-d 格点中,使得格点中相邻的 1 对的数量,但不在序列中(它的能量),被最小化。例如,序列 011001001110010 被嵌入到下面的图中,以便有 5 对新的相邻的 1(用星号表示)。
0 --- 1 --- 1 --- 0 * * | 0 --- 1 --- 1 0 | * | | 0 --- 1 * 1 * 1 | | | 0 0 --- 0最小化蛋白质的 H-P 能量是 NP 难题。(Papadimitriou 等人)生物学家普遍认为蛋白质会折叠以最小化它们的能量。Levinthal 悖论的一个版本问的是蛋白质如何能够有效地解决表面上看起来棘手的问题。
积分。 给定整数 a[1]、a[2]、...、a[N],以下积分是否等于 0?
![积分]()
如果你在下一门物理课程中看到这个积分,你不应该期望能够解决它。这应该不会让人感到惊讶,因为在第 7.4 节中,我们考虑了一个不可判定的积分版本。
填字游戏。 给定一个整数 N 和一个有效单词列表,是否可能为一个 N×N 网格的单元格分配字母,以便所有水平和垂直单词都是有效的?如果一些方格是黑色的,就像填字游戏中一样,那么问题并不会更容易。
定理。 给定��个所谓的定理(比如黎曼猜想),你能否在某个形式系统(如 Zermelo-Fraenkel 集合论)中最多使用 n 个符号证明它是真实的?
俄罗斯方块。
扫雷。
正则表达式。 给定两个在一元字母表 { 1 } 上的正则表达式,它们表示不同的语言吗?给定两个 NFA,它们表示不同的语言吗?由于我们没有一个明显的界限来确定一个语言中最小字符串的大小,而另一个语言中没有,因此甚至可能无法确定这两个问题是否可判定。[请注意,对于 DFA 的对应不等价问题是多项式可解的。] 我们将问题表述为不等价而不是等价的原因是,通过展示一个字符串 s,很容易检查这两个实体是否不等价。实际上,如果这两个语言不同,那么最小字符串在输入大小的多项式中。因此,我们可以使用第 7.xyz 节中的高效算法来检查 s 是否被 RE 识别或被 NFA 接受。然而,要证明两个 RE 是等价的,我们需要一个保证一个语言中的所有字符串都在另一个语言中,反之亦然的论证。[可以设计一个(指数级的)算法来测试两个 RE 或 NFA 是否等价,尽管这并不明显。]
旅鼠。 在游戏旅鼠的一个关卡中,是否可能引导一群绿发旅鼠生物安全到达目的地?
单位超立方体上的多项式最小化。 给定 N 个变量的多项式,最小值是否 ⇐ C,假设所有变量都在 0 和 1 之间。经典微积分问题:在 [0, 1] 上,min f(x) = ax² + bx + c。在 x = ?? 处的导数为 0,但最小值出现在边界处。
二次丢番图方程。 给定正整数 a、b 和 c,是否存在正整数 x 和 y,使得 ax² + by = c?
结实论。 在三维流形上,哪些结实边界的结实的表面的亏格 ≤ g?
有界的邮政对应问题。 给定一个具有 N 张卡片的邮政对应问题和一个整数 K,是否存在一个使用最多 K 张卡片的解?如果 K 没有限制,那么问题是不可判定的。
纳什均衡。 合作博弈论。给定一个 2 人游戏,找到一个最大化玩家 1 收益的纳什均衡。是否存在多个 NE?是否存在 Pareto 最优 NE?最大化社会福利的 NE。
二次同余。 给定正整数 a、b 和 c,是否存在一个小于 c 的正整数 x,使得 x² = a (mod b)?
三维伊辛模型。 相变的简单数学模型,例如,当水结冰或冷却铁变成磁性时。计算最低能量状态是 NP 难题。如果图是平面的,则可以在多项式时间内解决,但 3D 晶格是非平面的。在被证明为 NP 难题之前,统计力学的圣杯已经存在了 75 年。建立 NP 完全性意味着物理学家不会再花 75 年时间试图解决不可能解决的问题。
带宽最小化。 给定一个 N×N 矩阵 A 和一个整数 B,是否可以重新排列 A 的行和列,使得当|i - j| > B 时,A[ij] = 0。对数值线性代数很有用。
投票和社会选择。 对于个人来说,操纵称为单次转移投票的投票方案是 NP 难的。在刘易斯·卡罗尔(查尔斯·道奇森)于 1876 年提出的方案中,确定谁赢得了选举是 NP 难的。在卡罗尔的方案中,获胜者是在选民偏好排名中进行最少配对相邻变化的候选人,成为康德塞特赢家(在两两选举中击败所有其他候选人的候选人)。夏普利-舒比克投票权。计算Kemeny 最优聚合。
应对难以解决的问题。
NP 完全性理论表明,除非 P = NP,否则有一些重要问题无法创建同时实现以下三个属性的算法:
保证在多项式时间内解决问题。
保证解决问题到最优性。
保证解决问题的任意实例。
当我们遇到一个 NP 完全问题时,我们必须放宽三个要求中的一个。我们将考虑解决 TSP 问题的解决方案,这些解决方案放宽了三个目标中的一个。
复杂性理论处理最坏情况的行为。这留下了设计在某些实例上快速运行但在其他实例上需要大量时间的算法的可能性。例如,Chaff是一个可以解决许多具有 10000 个变量的实际 SAT 实例的程序。值得注意的是,它是由普林斯顿大学的两名本科生开发的。该算法不能保证在多项式时间内运行,但我们感兴趣的实例可能是“简单的”。
有时我们可能愿意牺牲找到最优解的保证。许多启发式技术(模拟退火、遗传算法、Metropolis 算法)已被设计用于找到“几乎最优”的 TSP 问题解决方案。有时甚至可以证明最终解决方案的优劣。例如,Sanjeev Arora 设计了一种用于欧几里德 TSP 问题的近似算法,保证找到的解决方案的成本最多比最优解高出 1%。设计近似算法是一个活跃的研究领域。不幸的是,也存在一些无法近似的结果形式:如果你能找到一个问题 X 的近似算法,保证能够接近最优解的两倍,那么 P = NP。因此,为一些 NP 完全问题设计近似算法是不可能的。
如果我们试图解决一类特殊的 TSP 问题,例如,点位于圆的边界上或 M×N 晶格的顶点上,那么我们可以设计高效(且微不足道)的算法来解决问题。
利用难以解决性。 有时遇到难以解决的问题是一件好事。在第 XYZ 节中,我们将利用难以解决的问题设计密码系统。
P 与 NP-complete 之间。 现在已知大多数 NP 中的自然问题属于 P 或 NP-complete。如果 P != NP,则可以证明存在一些既不属于 P 也不属于 NP-complete 的 NP 问题。就像“我们尚未开发出观察手段的暗物质”。在地下世界中有一些显著的未分类问题:因子分解和子图同构。
因子分解。 给定一个整数,找到其素因子分解。已知的最佳算法是 2^O(n¹/3 polylog(n)) - 数域筛法。专家认为不太可能属于 P。
优先约束 3 处理器调度。 给定一组单位长度任务和一个优先顺序,找到 3 台并行机器上的最短调度。
转角石问题。 给定 N(N-1)/2 个正数(不一定不同),是否存在一组 N 个点在直线上,使得这些数字是这些 N 个点的两两距离。直觉:点是 I-95 上的出口。问题最早在 1930 年代出现在 X 射线晶体学的背景中。在分子生物学中也被称为部分消化问题。
布尔公式对偶化。 给定一个单调 CNF 公式和一个单调 DNF 公式,它们是否等价?(a + b)(c + d) = ac + ad + bc + bd。直接应用德摩根定律会导致指数级算法,因为存在冗余。最佳算法为 O(n^(log n / log log n))。
随机游戏。 白色、黑色和自然轮流在有向图的边上移动一个令牌,从起始状态 s 开始。白色的目标是将令牌移动到目标状态 t。黑色的目标是阻止令牌到达 t。自然以随机方式移动令牌。给定一个有向图、一个起始状态 s 和一个目标状态 t,白色是否有一种策略使得令牌到达 t 的概率 ≥ 1/2?问题在 NP 交 co-NP 中,但尚不知道是否在 P 中。人们相信它在 P 中,只是我���尚未找到多项式时间算法。
其他复杂度类。
复杂度类 P、NP 和 NP-complete 是三个最著名的复杂度类。Scott Aaronson 的网站The Complexity Zoo包含了其他复杂度类的全面列表,这些类对根据计算资源(时间、空间、可并行性、随机性使用、量子计算)对问题进行分类非常有用。我们以下简要描述一些最重要的类。
PSPACE。 复杂度类 PSPACE = 可由图灵机使用多项式空间解决的问题。PSPACE-complete = 在 PSPACE 中,且 PSPACE 中的每个其他问题都可以在多项式时间内归约到它。
这是停机问题的一个复杂性版本。给定一个限制在 n 个磁带单元上的图灵机,在最多 k 步内是否会停机?问题是 PSPACE-complete,其中 n 以一进制编码。这意味着除非 P = PSPACE,否则我们不太可能能够判断给定程序在具有 n 个内存单元的计算机上在 k 步之前是否终止,这比运行它 k 步并查看结果的简单方法要快得多。
Bodlaender:给定一个带有顶点 1, ..., N 的图,两名玩家轮流为顶点标记红色、绿色或蓝色。首个将顶点标记为与其邻居相同颜色的玩家失败。确定是否有第一个玩家的获胜策略是 PSPACE-complete。
许多传统游戏的变体被证明是棘手的;这在一定程度上解释了它们的吸引力。此外,黑白棋、六角棋、地理游戏、上海、交通堵塞、五子棋、瞬间疯狂和推箱子的自然推广都是 PSPACE-complete。
一个给定的字符串是否是上下文敏感文法的成员?
两个正则表达式描述不同的语言吗?即使在二进制字母表上,如果其中一个正则表达式是
.*,也是 PSPACE-complete。另一个可以严格化的例子是移动一个复杂对象(例如家具),其附件可以通过不规则形状的走廊移动和旋转。
另一个例子出现在并行计算中,当挑战是确定在一个通信处理器系统中是否可能存在死锁状态时。
注意 PSPACE = NPSPACE(Savitch 定理)。
EXPTIME. 复杂度类 EXPTIME = 在确定性图灵机上指数时间内可解决的所有决策问题。注意 P ⊆ NP ⊆ PSPACE ⊆ EXPTIME,并且根据时间层次定理,至少有一个包含是严格的,但未知哪一个(或更多)。有猜想认为所有包含都是严格的。
Harel 第 85 页的路障。
国际象棋,跳棋,围棋(带有日本式的劫规则),将棋的自然推广是 EXPTIME-complete。给定一个棋盘位置,第一位玩家能否强迫获胜?这里 N 是棋盘的大小,运行时间是 N 的指数。这些问题比奥赛洛(和其他 PSPACE-complete 游戏)在理论上更难的一个原因是它们可能需要指数数量的步骤。跳棋(在 N×N 棋盘上的英式跳棋):玩家在一个回合中可以有指数数量的步骤,因为可以进行跳跃序列。[pdf] 注意:根据终局规则的不同,跳棋可以是 PSPACE-complete 或 EXPTIME-complete。对于 EXPTIME-complete,我们假设“强制捕获规则”,即如果有可用的跳跃(或跳跃序列),玩家必须进行跳跃。
这里是停机问题的一个复杂性版本。给定一个图灵机,在最多 k 步内是否停机?或者,给定一个固定的 Java 程序和一个固定的输入,在最多 k 步内是否终止?这个问题是 EXPTIME-complete。这里的运行时间是 k 的二进制表示的指数。事实上,没有图灵机能保证在 O(k / log k)步内解决它。因此,暴力模拟基本上是最佳的方法:可以证明,这个问题不能比运行图灵机的前 k 步并观察发生了什么更快地解决。
一个 EXPTIME-complete 问题不能在确定性图灵机上多项式时间内解决-这不依赖于 P ≠ NP 猜想。
EXPSPACE. EXPSPACE-complete:给定两个“扩展”正则表达式,它们是否表示不同的语言?通过扩展,我们允许一个平方操作(表达式的两个副本)。Stockmeyer 和 Meyer(1973)。或者更简单的集合交集(Hunt,1973)。Abelian 群的字问题(Cardoza,Lipton,Meyer,1976),向量加法子系统。
向量加法子系统是 EXPSAPCE-hard:给定一个非负向量 s 和一组任意向量 v1,v2,...,vn,如果向量 x 是从 s 可达的,则它要么是(i)向量 s,要么是可达的向量 y + vi,其中 y 是可达的。VAS 问题是确定给定向量 x 是否可达。
双指数时间. 双指数时间类是所有在双指数时间内可解决的决策问题的集合。一个显著的例子是确定一阶 Presburger 算术中的一个公式是否为真。Presburger 算术包括涉及只有+作为操作的整数的语句(没有乘法或除法)。它可以模拟以下语句:如果 x 和 y 是整数,使得 x ≤ y + 2,则 y + 3 > x。1929 年,Presburger 证明了他的系统是一致的(不能证明矛盾,如 1 > 2)和完备的(每个语句都可以被证明为真或假)。1974 年,Fischer 和 Rabin 证明了任何决定 Presburger 公式真假的算法都��要至少 2^((2^(cN)))时间,其中 c 是常数,N 是公式的长度。
非初等. 对于任何有限的塔,超过 2²²^...²^N。给定允许平方和补集的两个正则表达式,它们描述不同的语言吗?
其他类型的计算问题。
我们关注搜索问题,因为这是科学家和工程师面临的一个非常丰富和重要的问题类。
搜索问题。这是我们详细考虑的版本。从技术上讲,FP = 多项式时间函数问题,FNP = 非确定性图灵机上的多项式时间函数问题。FP 问题可以有任何可以在多项式时间内计算的输出(例如,两个数字相乘或找到 Ax = b 的解)。
决策问题。传统上,复杂性理论是以是/否问题来定义的,例如,Ax &le b 是否存在解?规约的定义更清晰(无需处理输出)。P 和 NP 类通常以决策问题来定义。通常搜索问题归约为决策问题(对于所有 NP 完全问题来说这是成立的)。这样的搜索问题被称为自可归约。P = NP 问题等价于 FP = FNP 问题。
全函数。有时,一个决策问题很容易,而相应的搜索问题(被认为)很难。例如,可能有一个定理断言保证存在解,但该定理不提供如何高效找到解的任何提示。
子集和示例。给定 N 个数字,找到这些 N 个数字的两个(不相交)子集,使它们的和恰好相等。如果 N = 77,并且所有数字最多为二十一位十进制数,则根据鸽巢原理,至少有两个子集的和必定相等。这是因为有 2⁷⁷ 个子集,但最多有 1 + 77 * 10²¹ < 2⁷⁷ 种可能的和。或者决策 = 复合,搜索 = 因子。
约翰·纳什证明了在具有指定效用的两个或更多玩家的正常形式博弈中总是存在Nash 均衡。证明是非构造性的,因此不清楚如何找到这样的均衡。被证明是PPAD 完全 - 已知具有解的问题的 NP 完全的类比。
一般均衡理论是微观经济学的基础。给定一个有 k 种商品的经济体,每个 N 个代理人都有商品的初始禀赋。每个代理人还对每种商品有一个效用函数。阿罗-德布鲁定理断言,在适当的技术条件下(例如,效用函数连续、单调且严格凹),存在一组(唯一的)市场价格,使得每个代理人都卖掉所有商品,并用这笔钱购买最佳组合(即,每种商品的供给等于需求)。但市场如何计算?证明依赖于拓扑学的一个深刻定理(Kakutani 的不动点定理),目前尚不知道任何有效的算法。经济学家假设市场找到均衡价格;亚当·斯密用看不见的手的隐喻来描述这种社会机制。
15 滑块拼图的泛化。测试解是否存在在 P 中,但找到最短解是棘手的。[Ratner-Warmuth, 1990]
优化问题。有时我们有优化问题,例如,TSP。给定一个 NP 问题和解的成本函数,对于给定的实例,目标是找到其最佳解(例如找到最短的 TSP 路径、最小能量配置等)。有时很难表述为搜索问题(找到最短的 TSP 路径),因为不清楚如何有效地检查是否有最佳路径。相反,我们重新表述为:给定长度 L,找到长度最多为 L 的路径。然后二分搜索最佳 L。
计数问题。给定一个 NP 问题,找到其解的数量。例如,给定一个 CNF 公式,它有多少满足的赋值?包括统计物理学和组合数学中的许多问题。形式上,这类问题被称为#P。
战略问题。 给定一个游戏,为玩家找到最佳策略(或最佳移动)。包括经济学和棋盘游戏(例如国际象棋,围棋)中的许多问题。
输出多项式时间。
一些问题涉及的输出比单个位的信息更多。例如,输出汉诺塔问题的解决方案至少需要 2N 步。这种要求并不是因为解决方案本质上难以计算,而是因为有 2N 个输出符号,并且每个输出符号写入需要一个单位的时间。也许更自然的衡量效率的方法是输入大小和输出大小的函数。一个具有 DFAs 的经典电气工程问题是从 RE 构建使用最少状态的 DFA。我们希望的算法在输入 RE 的大小(符号数)和输出 DFA 的大小(状态数)上都是多项式的。除非 P = NP,设计这样的算法是不可能的。事实上,甚至不可能设计一个在常数(甚至多项式)数量的状态内得出答案的多项式算法!没有 NP 完全性理论,研究人员将浪费时间追随没有前途的研究方向。
其他下界。
信息论。 在第 X.Y 节中,我们看到插入最多使用 N² 次比较来对 N 个项目进行排序,而归并排序最多使用 N log N 次比较。一个自然的问题是我们是否可以做得更好,也许最多使用 5N 次比较,甚至 1/2 N log N 次比较。为了使问题更加明确,我们必须明确陈述我们的计算模型(决策树)。在这里,我们假设我们只通过
less()函数访问数据。由 X 提出的一个引人注目的定理表明,没有(基于比较的)排序算法可以保证在少于~ N log N 次比较中对 N 个不同元素的每个输入进行排序。要理解原因,观察到每次比较(调用less)提供一位信息。为了识别正确的排列,您需要 log N!位信息,而 log N! ~ N log N。这告诉我们,归并排序是(渐近地)最佳可能的排序算法。不存在任何排序算法(甚至是尚未想象的算法)将使用大大少于这些比较。3-Sum 难题。 给定一组 N 个整数,其中任意三个数之和为 0 吗?存在二次算法(参见练习 xyz),但没有已知的次二次算法。 3-SUM 线性归约为计算几何中的许多问题。(找出平面上的点集是否有 3 个共线,决定平面上的线段集是否可以被一条线分成两个子集,确定一组三角形是否覆盖单位正方形,您是否可以将多边形 P 平移到完全位于另一个多边形 Q 内部,机器人运动规划)。
暴力 TSP 需要 N!步。使用动态规划,可以将其降至 2^N。最佳下界 = N。计算复杂性的本质 = 尝试找到匹配的上界和下界。
电路复杂度。
还有其他定义和衡量计算复杂性的方法。具有 n 个输入的布���电路可以计算 n 个变量的任何布尔函数。我们可以将电路输出 1 的大小的二进制字符串集合与语言中的字符串集合相关联。我们需要每个输入大小 n 的电路。 Shannon(1949)提出了电路大小作为复杂性的度量。已知,如果语言具有统一多项式电路,则该语言属于 P。
物理和模拟计算。
P = NP 问题是关于图灵机和经典数字计算机能力的数学问题。我们也可以思考模拟计算机是否也适用于模拟计算机。通过模拟,我们指的是任何“使用固定数量的物理变量来表示每个问题变量的确定性物理设备”。内部状态由连续变量而不是离散变量表示。例如,肥皂泡、蛋白质折叠、量子计算、齿轮、时间旅行、黑洞等。
维尔吉斯、斯泰格利茨和迪金森提出了强丘奇-图灵论断的模拟形式:
任何有限的模拟计算机都可以被数字计算机高效模拟,即数字计算机模拟模拟计算机所需的时间受到模拟计算机使用资源的多项式函数的限制。
模拟计算机的资源可以是时间、体积、质量、能量、扭矩或角动量。参考:模拟计算的物理
任何合理的计算模型(例如,不涉及指数并行性)都可以通过图灵机(辅以硬件随机数生成器)在多项式时间内模拟。
参考:斯科特·亚伦森。可以为物理学带来新的见解。有一天,“NP 完全问题的假定不可解性可能被视为寻找新物理理论的有用约束”,就像热力学第二定律一样。仍然可以通过实验来验证,但不要浪费时间...
肥皂泡。 传说中可以解决斯坦纳树问题。实际上,只能找到一个局部最小值,并且可能需要一段时间才能找到。
量子计算。 一种推测的计算模型 - 量子计算机 - 可能能够在多项式时间内解决一些确定性图灵机无法解决的问题。彼得·肖尔发现了一个 N³ 算法来因式分解 N 位整数,但在经典计算机上已知的最佳算法需要指数级时间。同样的想法可能导致在模拟量子力学系统方面取得可比较的加速。这解释了最近对量子计算的兴奋,因为它可能导致计算的范式转变。然而,量子计算机尚未违反扩展的丘奇-图灵论断,因为我们尚不知道如何构建它们。(难以利用,因为大部分量子信息似乎很容易被其与外界的相互作用所破坏,即退相干。)此外,仍然有可能有人在经典计算机上发现一个多项式时间算法来因式分解,尽管大多数专家认为这是不可能的。格罗弗算法:在 sqrt(N)时间内搜索而不是 N。
理查德·费曼在 1982 年表明,经典计算机无法在不出现指数级减速的情况下模拟量子力学系统(论点的关键在于图灵机具有引用的局部性,而量子力学包括“利用远距离的诡异作用”)。量子计算机可能能够解决这个问题。费曼关于建造模拟物理的计算机的引用...
“我想要的模拟规则是,用于模拟大型物理系统所需的计算机元素数量仅与物理系统的时空体积成正比。我不希望出现爆炸。”
用“与...成正比”替换现代复杂性理论的表述,用多项式函数“受限于”。
Deutsch-Jozsa 提出的算法在量子计算机上被证明比确定性图灵机快指数倍。(尽管如果图灵机可以访问硬件随机数生成器并且可以以可忽略的概率出错,指数差距就不存在。量子计算机可以生成真正的随机性。)
PRIMES 和 COMPOSITE。
通过提供一个因子很容易说服某人一个数是合数。然后,这个人只需通过长除法检查你是否对他们撒谎。马林·梅森猜想形式为 2^p - 1 的数对于 p = 2, 3, 5, 7, 13, 17, 19, 31, 67, 127 和 257 时是质数。他对 p = 67 的猜想在两百五十多年后的 1903 年被 F·N·科尔证明是错误的。根据 E·T·贝尔的书籍数学:科学的女王和仆人
在 AMS 的十月会议上,Cole 宣布了一个名为“关于大数的因式分解”的讲座。他默默无言地走到黑板前,手工计算了 2⁶⁷的值,仔细地减去 1。然后他将两个数相乘(分别是 193707721 和 761838257287)。黑板上写下的两个结果是相等的。Cole 默默地走回座位,据说这是 AMS 会议上唯一一次观众鼓掌的讲座。没有问题。根据他所说,Cole 花了大约 3 年的时间,每个星期日,找到这个因式分解。
作为记录,2⁶⁷ - 1 = 193707721 × 761838257287 = 147573952589676412927。
Q + A
Q. 多项式算法总是有用的吗?
A. 不,那些需要 N¹⁰⁰或 10¹⁰⁰N²步骤的算法在实践中和指数级别的算法一样无用。在实践中出现的常数通常足够小,使得多项式时间算法可以扩展到巨大的问题,因此多项式性通常作为实践中有用的替代品。
Q. 为什么所有搜索问题的类别被命名为 NP?
A. NP 的最初定义是关于非确定性图灵机的:NP 是所有可以在非确定性图灵机上多项式时间内解决的决策问题的集合。粗略地说,确定性和非确定性图灵机之间的区别在于前者像传统计算机一样运行,按顺序执行每个指令,形成一个计算路径;非确定性图灵机可以“分支”,其中每个分支可以并行执行不同的语句,形成一个计算树(如果树中的任何路径导致 YES,则我们接受;如果所有路径导致 NO,则我们拒绝。)这就是 NP 中的 N 的含义。事实证明,这两个定义是等价的,但现在更广泛使用证书的定义。(此外,卡普尔的 1972 年论文使用了多项式时间可验证性的定义。)
Q. 复杂度类 NP-hard 是什么?
A. 有几个竞争性的定义。我们定义一个问题(决策、搜索或优化)问题为 NP 难问题,如果在多项式时间内解决它将意味着 P = NP。定义隐含地使用图灵归约(扩展到搜索问题)。
Q. 在多项式时间内对一个整数 N 进行因式分解有什么困难之处 - 我不能只是将小于 N(或√N)的所有潜在因子除以 x,看看是否有余数为零吗?
A. 算法是正确的,但请记住只需要 lg N 位来表示整数 N。因此,对于一个算法在输入大小上是多项式的,它��须在 lg N 上是多项式的,而不是 N。
Q. 如何可能检查一个整数是否为合数可以在多项式时间内解决,但找到它的因子却不为人所知(或被认为)?
A. 有方法可以证明一个数是合数而不需要得到它的任何因子。数论中的一个著名定理(费马小定理)暗示着,如果你有两个整数 a 和 p,使得(i)a 不是 p 的倍数且(ii)a^(p-1) != 1(mod p),那么 p 不是质数。
Q. 有没有一个决策问题在量子计算机上可以多项式解决,但可以被证明不在 P 中?
A. 这是一个开放的研究问题。FACTOR 是一个候选者,但没有证据表明 FACTOR 不在 P 中,尽管普遍认为它不在 P 中。
Q. NP = EXPTIME 吗?
A. 专家们认为不可能,但他们无法证明。
Q. 假设有人证明 P = NP。这会有什么实际后果?
A. 这取决于问题的解决方式。显然,这将是一个显著的理论突破。在实践中,如果 P = NP 的证明建立了一个快速的整数因子分解算法或其他搜索问题的算法,那么可能会具有重大意义。如果证明导致 TSP 的 2¹⁰⁰ N¹¹⁷ 算法(且常数和指数无法降低),那么它将几乎没有实际影响。也可能是有人通过间接手段证明了 P = NP,从而根本没有产生算法!
Q. 假设有人证明 P != NP。这会有什么实际后果?
A. 这将是一个显著的理论突破,并巩固了计算复杂性的许多基础。
Q. 假设 P = NP。这是否意味着确定性图灵机与非确定性图灵机相同?
A. 不完全相同。例如,即使 P = NP,非确定性图灵机可能能够在与最佳确定性图灵机相比为 N³ 的时间内解决一个问题。如果 P = NP,这只是意味着这两种类型的机器在多项式时间内解决相同的决策问题,但它并不涉及多项式的程度。
Q. 我在哪里可以了解更多关于 NP 完全性的知识?
A. ��威参考仍然是 Garey 和 Johnson 的《计算机与难解性:NP 完全性理论指南》。许多最重要的后续发现都记录在 David Johnson 的NP 完全性专栏中。
练习
从旅行推销员问题是 NP 完全问题这一事实中我们可以推断出什么,假设 P 不等于 NP?
不存在一个解决 TSP 问题任意实例的算法。
不存在一个有效地解决 TSP 问题任意实例的算法。
存在一个有效地解决 TSP 问题任意实例的算法,但没有人能找到它。
TSP 不在 P 中。
所有保证解决 TSP 问题的算法在某些输入点族的多项式时间内运行。
所有保证解决 TSP 问题的算法在所有输入点族的指数时间内运行。
答案:只有(b)和(d)。
从 FACTORING 在 NP 中但不被认为是 NP 完全问题这一事实中我们可以推断出什么,假设 P 不等于 NP?
存在一个解决任意 FACTORING 实例的算法。
存在一个有效地解决任意 FACTORING 实例的算法。
如果我们找到了一个高效的 FACTORING 算法,我们可以立即将其用作黑匣子来解决 TSP 问题。
答案:我们只能推断出(a),因为 NP 中的所有问题都是可判定的。如果 P != NP,那么 NP 中存在一些既不在 P 中也不是 NP 完全问题的问题。研究人员猜想 FACTORING 就是其中之一(尽管最近已经被证明不是)。无法推断出(c)部分,因为我们不知道 FACTORING 是否是 NP 完全问题。
以下哪些是 NP 完全问题?
用于 TSP 问题的蛮力算法。
用于排序的快速排序算法。
停机问题。
希尔伯特第十问题。
答案:没有。NP 完全性涉及问题而不是问题的具体算法。停机问题和希尔伯特第十问题是不可判定的,因此它们不在 NP 中(所有 NP 完全问题都在 NP 中)。
设 X 和 Y 是两个决策问题。假设我们知道 X 归约于 Y。我们可以推断出以下哪些?
如果 Y 是 NP 完全问题,则 X 也是。
如果 X 是 NP 完全问题,那么 Y 也是。
如果 Y 是 NP 完全问题且 X 在 NP 中,则 X 也是 NP 完全问题。
如果 X 是 NP 完全问题且 Y 在 NP 中,则 Y 也是 NP 完全问题。
X 和 Y 不能同时是 NP 完全问题。
如果 X 在 P 中,那么 Y 也在 P 中。
如果 Y 在 P 中,那么 X 也在 P 中。
答案:只有(d)和(g)。X 归约于 Y 意味着如果你有一个有效地解决 Y 的黑匣子,你可以用它来有效地解决 X。X 不比 Y 难。
假设 X 是 NP 完全的,X 归约到 Y,Y 归约到 X。Y 一定是 NP 完全的吗?
答案:不,因为 Y 可能不在 NP 中。例如,如果 X = 电路可满足问题(CIRCUIT-SAT)且 Y = 互补电路可满足问题(CO-CIRCUIT-SAT),那么 X 和 Y 满足条件,但尚不清楚 Y 是否在 NP 中。
证明电路可满足问题归约到电路差异问题。提示:创建一个具有 N 个输入的电路,总是输出 0。
证明电路差异问题归约到电路可满足问题。
证明行列式问题在 NP 中:给定一个 N×N 的整数矩阵 A,det(A) = 0 吗?
解决方案:证书是一个非零向量 x,使得 Ax = 0。
证明全秩问题在 NP 中:给定一个 N×N 的整数矩阵 A,det(A) ≠ 0 吗?
解决方案:证书是一个 N×N 的逆矩阵 B,使得 AB = I。
搜索问题与决策问题。 我们可以使用相应的决策问题来制定一个搜索问题。例如,找到整数 N 的素因数分解问题可以使用决策问题来制定:给定两个整数 N 和 L,N 是否有一个小于 L 的非平凡因子。如果相应的决策问题可解,则搜索问题也可在多项式时间内解决。为了理解原因,我们可以通过使用不同的 L 值和二分查找来高效地找到 N 的最小因子 p。一旦我们有了因子 p,我们可以在 N/p 上重复这个过程。
通常我们可以证明搜索问题和决策问题在运行时间的多项式因子上是等价的。Papadimitriou(示例 10.8)给出了一个有趣的反例。给定 N 个正整数,使得它们的和小于 2^N - 1,找到两个和相等的子集。例如,下面的 10 个数字的和为 1014 < 1023。
23 47 59 88 91 100 111 133 157 205
由于 N 个整数的子集(2^N)比 1 到 1014 之间的数字更多,必然存在两个不同的子集具有相同的和。但是没有人知道一个多项式时间算法来找到这样的子集。另一方面,自然的决策问题在常数时间内是易解的:是否存在两个子集的和相等?
普拉特素性证书。 证明 PRIMES 在 NP 中。使用 Lehmer 定理(费马小定理的逆定理),该定理断言大于 1 的整数 p 是素数当且仅当存在一个整数 x,使得 x^(N-1) = 1(mod p)且 x^((p-1)/d) ≠ 1(mod p),其中 d 是 p-1 的素数因子。例如,如果 N = 7919,则 p-1 = 7918 = 2 × 37 × 107 的素因数分解。现在 x = 7 满足 7⁷⁹¹⁸ = 1(mod 7919),但 7^(7918/2) ≠ 1(mod 7919),7^(7918/37) ≠ 1(mod 7919),7^(7918/107) ≠ 1(mod 7919)。这证明了 7919 是素数(假设您递归地证明 2、37 和 107 是素数)。
佩尔方程。 找到佩尔方程 x² - 92y² = 1 的所有正整数解。解决方案:(1151, 120),(2649601, 276240),等等。有无穷多个解,但每个连续的解大约是前一个解的 2300 倍。
佩尔方程。 1657 年,皮埃尔·费马向他的同事提出了以下问题:给定一个正整数 c,找到一个正整数 y,使得 cy²是一个完全平方数。费马使用了 c = 109。事实证明,最小的解是(x,y)=(158,070,671,986,249,15,140,424,455,100)。编写一个程序 Pell.java,读入一个整数 c,并找到佩尔方程 x² - cy² = 1 的最小解。尝试 c = 61。最小的解是(1,766,319,049,226,153,980)。对于 c = 313,最小的解是(3,218,812,082,913,484,91,819,380,158,564,160)。由于输出可能需要指数级的位数,该问题在多项式步骤中是不可解的(作为输入 c 位数的函数)!
3-着色问题归约到 4-着色问题。 证明 3-着色问题多项式归约到 4-着色问题。提示:给定一个 3-着色问题的实例 G,通过在 G 中添加一个特殊顶点 x 并将其连接到 G 中的所有顶点来创建一个 4-着色问题的实例 G'。
3-SAT 问题。 证明 3-SAT 问题是自可归约的。
3-COLOR。 证明 3-COLOR 是自可归约的。也就是说,给定一个回答任何图 G 是否可 3 着色的 oracle,设计一个算法可以为图着色(假设它是可 3 着色的)。你的算法应在多项式时间内运行,再加上多项式次数的 oracle 调用。
连通性。 将 USTCONN(无向 s-t 连通性)简化为 STCONN(s-t 连通性)。
LP 标准形式。 标准形式线性规划是 Ax = b,x ≥ 0。展示如何将一般线性规划(带有≤、≥和=约束)简化为标准形式。
Ax = b, x 整数。 给定一个整数值的 N×N 矩阵 A 和一个整数值的向量 b,是否存在一个整数值的向量 x 使得 Ax = b。
如果 A 的秩为 N,则解 Ax = b 并检查 x 是否为整数值。否则,问题仍然可以在多项式时间内解决,但更加棘手,涉及到史密斯标准形式。参见这篇博客文章。
9.9 密码学
原文:
introcs.cs.princeton.edu/java/99crypto译者:飞龙
本节正在大力施工中。
密码学。 密码学 是秘密通信的科学。它有两个主要子领域:密码学 是创建秘密代码的科学;密码分析 是破译代码的科学。密码学有五大支柱:
保密性:保持通信私密。
完整性:检测通信的未经授权的更改。
认证:确认发件人身份。
授权:为受信任的方建立访问级别。
不可否认性:证明通信已被接收。
我们将主要关注保密性,这些努力中最浪漫的部分。强烈推荐阅读娱乐:《密码本》。有用的 Flash 演示:e-Security history 来自 rsa.com。
密码学的一些应用。 菲尔·齐默曼声称“密码学曾经是一门鲜为人知的科学,与日常生活无关。从历史上看,它一直在军事和外交通信中扮演着特殊的角色。但在信息时代,密码学涉及政治权力,特别是政府与人民之间的权力关系。它关乎隐私权,言论自由,政治结社自由,新闻自由,免受不合理搜查的自由,独处的自由。”(《密码本》,第 296 页)。密码学既有利于普通公民,也有利于恐怖分子。促进电子商务。以下是我们希望能够数字化和安全实现的活动表格。我们列出了每个任务的一些日常模拟实现。
| 任务 | 模拟实现 |
|---|---|
| 保护信息 | 密码本,锁和钥匙 |
| 识别 | 驾驶执照,社会安全号码,密码,生物信息学,秘密握手 |
| 合同 | 手写签名,公证 |
| 转账 | 硬币,纸币,支票,信用卡 |
| 公开拍卖 | 密封信封 |
| 公开选举 | 匿名投票 |
| 扑克 | 有隐蔽背面的牌 |
| 公开抽奖 | 骰子,硬币,石头剪刀布 |
| 匿名通信 | 匿名,勒索信 |
恶意对手有时可以破坏这些模拟实现:伪造,撬锁,伪造者,作弊者,投票作弊,偷骰子。
我们的目标。 我们的目标是数字化和安全地实现所有这些任务。我们还希望实现一些物理上无法完成的额外任务!例如:玩一种扑克变体,庄家赢得比赛如果没有人有一张 A 牌,进行一个匿名选举,每个人都知道赢家,但其他什么都不知道。这些是否有可能?如果是,如何实现?在本节的其余部分,我们将介绍现代(数字)密码学的风味,实现其中一些任务,并勾勒一些技术细节。
历史。 解密玛丽·斯图尔特的加密信件揭示了她暗杀伊丽莎白一世的意图。在 19 世纪,埃德加·爱伦·坡自夸可���通过频率分析破解任何人的密码。艾伦·图灵领导了布莱切利园的一个团队,破解了德国恩尼格玛密码机。许多历史学家认为这是第二次世界大战的转折点。这里有一个恩尼格玛小程序。
安全性通过混淆实现。 内容加密系统(CSS)被好莱坞用于加密 DVD。每张光盘有三个 40 位密钥。每个 DVD 解码器有唯一的 40 位密钥。原则上,在没有光盘的情况下在计算机上播放是“不可能的”。1999 年,两名挪威人(Canman 和 SoupaFrog,1999)编写了一个破解 CSS 系统的解密算法。CSS 是一种专有算法,好莱坞赌注于没有人会发现这个算法。此外,密钥的大小太小,因此可以进行穷举攻击。由于临时方法导致的其他高调失败:GSM 手机,Windows XP 产品激活,RIAA 数字音乐水印,VCR+代码,Adobe 电子书,Diebold AccuVote-TS 电子投票机,埃克森美孚 SpeedPass RFIDs。
1883 年,荷兰语言学家Auguste Kerckhoffs von Nieuwenhof在他的论文Cryptographie militaire中体现了指导现代密码学的基本原则。
Il faut qu'il n'exige pas le secret, et qu'il puisse sans inconvenient tomber entre les mains de l'ennemi.
系统不应该要求保密,可以被敌人窃取而不会造成麻烦。
这现在被称为Kerckhoffs' Principle。一个加密系统的安全性不应该依赖于保密算法,而只应该依赖于保密数字密钥。算法和数字密钥之间有两个主要区别。(Ed Felten) 首先,由于我们随机生成数字密钥,我们可以准确地建模和量化对手猜测所需的时间(在一般技术条件下);相比之下,预测或量化对手猜测我们的算法需要更多的努力。其次,对于不同的目的或人员,使用不同的数字密钥很容易,或者停止使用已被泄露的密钥;设计新算法更加困难。
它说基于“安全性通过混淆”的系统存在致命缺陷。这等同于香农的格言是“敌人知道系统。”设计安全系统应该留给专家。尽管如此,我们仍然可以探索专家使用的密码学基本思想。
参与者。 与密码学家丰富的传统一致,Alice 和 Bob 是两个试图在不安全的通信渠道上进行安全通信的人。我们假设消息已经以二进制编码,因此我们可以将其视为一个(可能很大的)整数m。我们让N表示消息m中的位数。Alice 将加密函数E应用于消息,产生另一个N位整数E(m)。Bob 收到E(m)并对其应用解密函数D。这个方案有意义的一个明显条件是D(E(m)) = m。换句话说,Bob 恢复了原始消息。Eve 是希望拦截消息的第三方。Eve 可以观察E(m),因此为了使方案安全,Eve 应该从E(m)中单独恢复m应该是极其困难的。
私钥加密。 私钥 = 两个参与方在通信之前共享一个秘密密钥。一次性密码本(第一章)如果密钥中的位是从真正随机的来源生成的,则可以被证明是安全的。它也非常容易实现。然而,一次性密码本有几个减轻因素,使其在大多数情况下变得不切实际。首先,生成真正随机、没有偏见和相关性的位是一个挑战。必须走出数字计算机的世界,从某种物理来源提取它们(例如,由于放射性衰变而发射的粒子之间的时间、麦克风的声音、按键之间的经过时间)。这些来源通常是有偏见的,我们需要非常小心地防止伊夫观察或篡改过程。该方案被称为一次性,因为我们需要为每条消息或消息的一部分生成新密钥。如果我们重复使用一次性密码本,那么系统将不再安全。签名?不可否认?也许最具限制性的因素是密钥分发。爱丽丝和鲍勃必须相互认识并交换密钥发送秘密消息。克里姆林宫和白宫曾经使用这种方法相互通信。一个受信任的信使将被派遣穿越大西洋,手铐着一次性密码本的公文包。如果爱丽丝想要通过互联网从鲍勃购买产品,这种方法是荒谬的不切实际的。
其他私钥加密方案。数据加密标准(DES)。高级加密标准(AES,Rijndael 算法)。Blowfish。这些方法不像一次性密码本那样可以被证明是安全的,但经受住了数学审查的时间考验。高效。然而,这些方案遭受了困扰一次性密码本的相同密钥分发问题。解决密钥分发问题的一种新兴方法是使用量子力学。这被称为量子密钥分发。这是两个参与方分享一次性密码的一种无条件安全的方式。此外,还有入侵检测组件,因此如果伊夫观察到甚至一个位,双方都将了解到试图窃听。
现代密码学。 现代密码学理论利用了困难问题的理论。目标是表明破解安全系统等同于解决一些世界上最伟大的未解决问题!著名的电子安全专家布鲁斯·施奈尔在《应用密码学》中写道:“仅仅依靠法律保护自己是不够的,我们需要用数学来保护自己。”现代密码学的基础依赖于三个关键公理和一个重要事实。
公理 1. 参与方可以抛硬币。 没有随机性,密码学是不可能的,因此这个公理是必不可少的。在实践中,我们可以通过使用量子现象或粒子的放射性衰变来生成真正随机的位。
公理 2. 参与方在计算上受限。 我们通过限制参与者(通信各方和恶意对手)只使用多项式时间算法来正式表达这个概念。
公理 3. 因子分解在计算上很困难。 我们假设在 N 位整数中不可能在 N 的多项式时间内分解出因子。给定一个整数(例如,1541),找到其素数分解似乎很困难。然而,给定因子(例如,23 * 67),很容易将它们相乘并获得原始数字。这被称为“一种单向陷阱函数”,因为从因子到乘积的方向很容易(从乘积到因子的方向似乎很困难)。
事实。素性测试在计算上很容易。 米勒-拉宾素性测试算法。2002 年证明了 PRIMES in P。
如果上述三个公理有效,则数字密码学存在。也就是说,可以通过数字方式完成以前的所有任务。
公钥密码学. 公钥密码学是一种令人惊奇的方案,使两方能够安全地通信,即使他们从未见过面。这是数字化的带有组合锁的盒子的类比。假设 Alice 想要向 Bob 发送一条消息。首先,Bob 将带有开放位置的挂锁的盒子发送给 Alice,而不向任何人透露组合。Alice 将她的消息放在盒子里,关闭组合锁,然后将其发送回 Bob。Eve 可能在传输过程中拦截盒子,但由于她不知道组合,她无法打开它。她可以尝试猜测组合,但可能性太多了。当盒子到达时,Bob 可以打开它,知道没有其他人看过里面(除非他们知道组合)。
Bob 在数字上有两个密钥(或组合):他的私钥 d 不向任何人透露,他的公钥 e 在互联网电话簿中公布。我们将密钥视为整数,但实际上它们只是一系列位,比如 1024 位。如果 Alice 想要向 Bob 发送一条消息,她在互联网上查找 Bob 的公钥 e。她使用 e 加密她的消息并将其发送给 Bob。Bob 使用他的私钥 d 解密消息。
公钥密码学的概念最早是由 Whitfield Diffie 和 Martin Hellman 在他们开创性的论文密码学的新方向中于 1976 年首次发表的。这篇论文描述了用于密钥分发问题的公钥加密系统。这个想法显然是由英国政府通信总部(GCHQ)的 Ellis、Cocks 和 Williamson 在 20 世纪 70 年代早期独立发现的,但他们的工作保密了两十年。
RSA 加密系统。
我们将描述 1978 年 Adleman、Rivest 和 Shamir 开发的RSA 加密系统的基本机制。这里是 RSA 论文。RSA 今天被广泛用于安全的互联网通信(浏览器,S/MIME,SSL,S/WAN,PGP,Microsoft Outlook),操作系统(Sun,Microsoft,Apple,Novell)和硬件(手机,ATM 机,无线以太网卡,Mondex 智能卡,Palm Pilots)。然后,我们将解释为什么它有效以及如何高效实现它。RSA 加密系统涉及模运算。回想一下......
密钥生成. 要参与 RSA 加密系统,Bob 必须首先生成公钥和私钥。即使他打算多次使用该系统,他也只需要这样做一次。
随机选择两个大素数 p 和 q。
计算 n = p × q。
选择两个整数 e 和 d,使得对所有整数 m 都有(m^e)^d ≡ m (mod n)。
举例来说,我们可能选择以下参数,尽管在实践中我们需要使用更大的整数来保证安全性。
p = 11, q = 29
n = 11 * 29 = 319
e = 3
d = 187
加密. Alice 想要向 Bob 发送一个 N 位的秘密消息 m。她从互联网上获取 Bob 的公钥(e,n)。然后她使用加密函数 E(m) = m^e (mod n)加密消息 m,并将 E(m)发送给 Bob。
m = 100
E(m) = 1003 (mod 319)
= 254
解密. Bob 从 Alice 那里收到加密消息 c。Bob 回忆起他的私钥(d,n)。然后他通过应用解密函数 D(c) = c^d (mod n)来解密密文。由于 Bob 知道 d,他可以计算这个函数。
c = 254
D(c) = 254187 (mod 319) = 100
RSA 模拟器. RSA 模拟器.
正确性. 为了确保 Bob 接收到原始消息,我们必须检查 D(E(m)) = m。在上面的例子中,当 m = 100 时,E(100) = 254,D(254) = 100,但我们需要确保它适用于所有可能的消息,以及所有有效的 e、d 和 n 的选择。这可以直接从定义和我们选择 e 和 d 的方式得出。

我们省略了一个重要的细节 - 如何选择 e 和 d,以使魔术属性成立。
实现 RSA 加密系统。 实现 RSA 加密系统是一个艰巨的工程挑战。成功的实现需要许多巧妙的算法和对数论中几个定理的了解。我们将描述一个基本的实现,但商业实现更加复杂。
大整数。不能使用内置的
int或long类型,因为数字太大。需要重新实现算术法则,例如加法、减法、乘法和除法。小学的算法对所有这些操作都相当有效,尽管总是有机会使用聪明的算法来改进。模指数。如何执行模指数运算:ab (mod c)。朴素的方法是重复地将 a 乘以自身,b 次,然后除以 c 并返回余数。当 a、b 和 c 是 N 位整数时,这种方法会因为两个原因而失败。首先,中间数 ab 可能非常大。数字的位数可能是 N 的指数。当 N = 50 时,这将消耗 128TB 内存。第二个问题是乘法的次数也需要指数时间。因此,这将永远持续下去。
更好的选择是使用重复平方。这个想法可以追溯到至少公元前 200 年,据 Knuth 称。程序 ModExp.java 使用以下递归计算 a^b mod n:
如果 b 是零:1
如果 b 是偶数:(a^(b/2) * a^(b/2)) mod n
如果 b 是奇数:(a * a^(b/2) * a^(b/2)) mod n 这类似于乘法的以下递归。
如果 b 是零:1
如果 b 是偶数:(a * b/2) + (a * b/2)
如果 b 是奇数:(a * b/2) + (a * b/2) + a
计算一个随机质数。为了生成密钥,我们必须有一个生成随机 N 位质数的方法,比���N = 1024。一个想法是随机选择一个 N 位整数并检查它是否是质数。如果是,那么停止;否则重复,直到找到一个质数。
REPEAT x = random N-bit integer UNTIL (x is prime)这个简单的想法有效,但要实现它需要两个关键的想法。首先,如果没有足够的质数,循环可能需要很长时间。幸运的是,素数定理(Hadamard,Vallee Poussin,1896)断言 2 到 x 之间的质数大约是 x / ln x。有超过 10¹⁵¹个 512 位或更少的质数。换句话说,大约每 ln x 个 x 位数中有一个是质数,因此我们期望在偶然发现一个质数之前只需 ln x 步。但是我们如何检查一个数是否是质数呢?试图对其进行因式分解将是代价高昂的。相反,我们可以使用 Miller-Rabin(或更近期的 Agarwal-Kayal-Saxena)的巧妙算法,在多项式步骤内检查一个整数是否为质数。
生成随机数。(移到一次性密码?)物理随机源。Java 库
SecureRandom是一个生成密码安全随机数的伪随机数生成器。这意味着计算未来位是困难的。与 LFSR 不同,你无法逆向工程它。计算私钥指数。最后一个挑战是选择公钥和私钥。在实践中,通常使用 e = 65,537 作为公钥。但这意味着我们需要找到一个使魔术属性成立的私钥。这事实上是一个在数论中被充分理解的问题,只要 gcd(e, (p-1)(q-1)) = 1,就会存在一个唯一的 d。我们可以使用 Euclid 算法的扩展(参见练习 xyz)来实现这个目的。
使用 Java 的
BigInteger库来操作大整数非常容易。private final static SecureRandom random = new SecureRandom(); BigInteger ONE = new BigInteger("1"); BigInteger p = BigInteger.probablePrime(N/2, random); BigInteger q = BigInteger.probablePrime(N/2, random); BigInteger n = p.multiply(q); BigInteger phi = (p.subtract(ONE)).multiply(q.subtract(ONE)); BigInteger e = new BigInteger("65537"); BigInteger d = e.modInverse(phi); BigInteger rsa(BigInteger a, BigInteger b, BigInteger c) { return a.modPow(b, n); }
RSA 攻击。 密码分析是破解秘密代码的科学。我们描述了一些常见的 RSA 加密系统攻击,以让您了解现代密码分析的风格。
分解。 破解 RSA 加密系统最明显的方法是分解模数 n。如果 Eve 能够分解 n = pq,那么她拥有与 Bob 完全相同的信息,因此她可以有效地计算出他的私有指数,给定公共指数(使用 Bob 用来计算私有指数的算法)。使用一个非常复杂的分解算法,即一般数域筛,研究人员最近成功地分解了 RSA-576,这是 RSA 安全性提供的一个挑战问题,一个 576 位(174 位十进制数字)的复合整数。这个工作需要 100 台工作站和 3 个月的计算。该算法的运行时间是超多项式但是次指数的 - O(exp(c (log n)(1/3) (log log n)(2/3)))。
不当使用。 如果 RSA 系统被不当使用,也会被破解。例如,如果 Bob 决定使用一个小的私有指数来减轻解密的计算负担,那么他就在牺牲安全性。如果 d < 1/3 n^(1/4),那么可以在多项式时间内恢复 d(Wiener 攻击)。请注意,使用一个小的公共指数 e 是可以的,在实践中,65,537 是常见的。另一个错误是允许两个参与者共享相同的模数 n(即使双方都不知道如何分解 n)。例如,假设 Bob 和 Ben 分别拥有(d1,e1)和(d2,e2)作为他们的私有和公共指数,但他们都使用 n 作为模数。那么任何一方都有可能发现另一方的私有指数(Simmon's 攻击)。
侧信道攻击。 利用从机器泄露的物理信息,包括电磁辐射、功耗、CRT 显示器的漫射可见光和声学辐射。例如,在一个时序攻击中,Eve 通过测量 Bob 进行指数运算所需的时间来获取关于 Bob 的私有密钥的信息。如果 Bob 使用高度优化的指数运算例程,那么 Eve 可以发现足够的信息来揭示 Bob 的私有密钥。��近,Dan Boneh 展示了如何利用这种技术来在局域网上破解 SSL。
一个长期存在的开放研究问题是是否有一种方法可以在不分解或物理访问的情况下破解 RSA 系统。即使分解很困难,也不能保证 RSA 是安全的。此外,目前也没有关于分解很困难的数学保证!FACTOR 及其补充问题 NON-FACTOR 都在 NP 中。这使得 FACTOR 是 NP 完全的可能性很小,因为这将意味着 NP = coNP...
语义安全。 其他更强的安全概念。如果一个公钥加密系统是语义安全的,那么 Eve 可以在多项式时间内计算出密文中的任何内容,而无需密文。因此,观察密文不提供任何有用的信息。例如,我们不应该能够确定明文的最后一位是 0 还是 1,或者明文中 1 的位数是否比 0 多。RSA 系统不是语义安全的,实际上没有确定性方案可以实现。这不仅仅是一个理论上的缺陷。为了理解为什么,假设 Eve 知道 Alice 将向 Bob 发送 ATTACK 或 RETREAT 消息。Eve 可以使用 Bob 的公钥加密两条消息,然后与 Alice 发送给 Bob 的加密消息进行比较。因此,Alice 可以准确地了解发送了哪条消息。像在加密前附加一串随机的 0 和 1 到明文这样的天真想法通常不能保证额外的安全性。
可证明安全的加密系统。 使用一个不像某些困难问题(例如因子分解)那样难以证明的加密系统有点令人不满。理论高地=Blum-Goldwasser(1985)。与因子分解同样难度,语义安全。基于 Goldwasser 和 Micali 的概率加密方案。在速度上与 RSA 可比。
电子投票。 需要一种加密方案,使得可以确认你的选票被正确计算,而不透露你的选票给谁。需要第二个条件来防止某人“购买”你的选票,因为如果他们无法验证你投给谁,他们就没有贿赂你的动机。
零知识证明。 Alice 想向 Bob 证明图 G 是 3 可着色的,但不想透露任何额外信息。这个例子可以推广到许多其他问题,因为 3Color 是 NP 完全的。
数字版权管理。 在传统设置中,试图进行通信的 Alice、Bob 和 Eve 是人类,他们使用计算机辅助计算。一个有趣的变体是当 Alice 和 Bob 是计算机,而 Eve 是人类。这正是音乐行业设想的数字版权管理的情景。在这种情况下,Alice 是你的计算机,Bob 是你的扬声器,而你是 Eve。音乐行业希望只有你的计算机能够在你的计算机上播放合法购买的音乐,但不希望你能够拦截原始音频数据。我们很快可以想象到一个世界,在那里对复制 DVD、运行软件、打印文件和转发电子邮件都有限制。所有这些限制将通过加密算法和协议执行。
目标:将一个程序转换为计算相同函数的混淆版本,但不向多项式有界的对手透露任何额外信息(例如源代码)。通常情况下无法混淆。
安全。 密码学只是整体计算机安全的一部分。这项调查显示,70%的人会为了一块巧克力棒而透露他们的计算机密码。一位安全专家评论说,“在互联网上使用加密相当于安排一辆装甲车将信用卡信息从一个住在纸箱里的人送到一个住在公园长椅上的人。”
CAPTCHAs。 完全自动化的公共图灵测试,用于区分计算机和人类。反向图灵测试,其中计算机是判官,试图区分人类和计算机。纽约时报文章。
问答
练习
编写一个程序来经验性地确定方法
BigInteger.add、BigInteger.multiply、BigInteger.mod和BigInteger.modExp的运行时间。尝试将每个操作的运行时间建模为 c N^k 秒,其中 c 和 k 是一些常数。使用BigInteger.rand生成随机输入参数。对于加法、乘法和模指数运算,对所有参数使用 N 位整数;对于除法,使用 N 位分子和 N/2 位分母。编写一个程序 RandomPrime.java,该程序接受一个命令行参数 N,并打印出一个(可能)是素数的 N 位整数。使用
BigInteger.probablePrime进行素性测试,并使用SecureRandom生成密码安全的伪随机数。估计 RandomPrime.java 的运行时间作为位数 N 的函数。
假设不使用
RandomPrime.java来选择具有 N 位的素数,而是使用以下策略:生成所有最多具有 N 位的素数,并选择一个随机素数。如果 N 很大,比如 512,会发生什么?假设不使用重复平方来计算 a^b mod c,而是将 a 重复乘以自身 b 次,同时取模 c。估计当 a、b 和 c 都是 N 位整数时需要多长时间。
以下问题的复杂度是多少:给定一个偶数 x,确定 x 是否有大于 1 的奇数因子。答案:多项式 - 检查 x 是否是 2 的幂。
以下问题的复杂度是多少:给定一个偶数 x 和另一个整数 y,确定 x 是否在 3 和 y 之间有任何奇数因子。答案:等同于因式分解问题。
创意练习
扩展欧几里得算法。 扩展欧几里得算法用于计算 p 和 q 的最大公约数,还可以计算系数 a 和 b(可能为零或负),使得 ap + bq = gcd(p, q)。编写一个程序 ExtendedEuclid.java,接受两个命令行参数 p 和 q,并输出 gcd(p, q)以及如上所述的一对整数 a 和 b。
EXTENDED-EUCLID(p, q) if q = 0 then return (p, 1, 0) (d', a', b') return (d, x, y)提示:由于你的方法需要返回三个整数,考虑使用一个三元素数组。
最佳矩形。 给定矩形的面积 A,找到一个宽度和高度为整数的矩形,其面积为 A,并且高度和宽度之间的差距尽可能接近。例如,如果 A = 48,则最佳矩形是 6 乘以 8 而不是 3 乘以 16 或 4 乘以 12。证明如果你能解决这个问题,你就能破解 RSA 加密系统。
水桶问题。 给定容量为 p 和 q 的两个桶,一个无限容量的接收器,一个水管和一个排水口,设计一种方法,使用以下规则将恰好 k 升水倒入接收器中:
你可以用水管装满任一桶。
你可以将任一桶倒空到排水口。
你可以在两个桶之间或一个桶和接收器之间转移水,直到一个满了或另一个空了。证明当且仅当 k 是 gcd(p, q)的倍数时,你才能解决这个问题。提示:使用前一个练习中存在整数 a 和 b 使得 ap + bq = gcd(p, q)的事实。
乘法逆元。 给定正整数 n,整数 k 的模 b 的乘法逆元是一个整数 x,使得(k * x) % n = 1。这样的逆元存在当且仅当 gcd(k, n) = 1。���写一个程序 Inverse.java,读取两个命令行参数 k 和 n,并计算模逆元(如果存在)。*提示:*使用前一个练习的答案。另请参阅
BigInteger.modInverse。破解 RSA 加密系统。 破解 RSA 加密系统的一个潜在方法是在给定 n 的情况下计算φ(n)。回想一下,如果 n = pq,那么φ(n) = (p-1)(q-1)。证明计算φ(n)等同于因式分解。
解决方案:显然,如果你能分解 n = pq,那么计算φ(n) = (p-1)(q-1)就很容易。要看到另一个方向,观察者可以发现 n + 1 - φ(n) = pq + 1 - (p-1)(q-1) = p + q = n/q + q。因此 q² - (n + 1 - φ(n))q + n = 0。假设我们知道φ(n),我们可以解二次方程得到 q,并恢复 n 的一个因子。通过计算 n/q,我们可以恢复另一个因子 p。
生成公钥和私钥 RSA 密钥。 编写一个程序 RSA.java 来生成用于 RSA 加密系统的密钥对,确定两个 N/2 位素数 p 和 q。设置 e = 65537,计算 n = (p-1)(q-1),找到一个数字 d,使得(e * d) % n == 0。假设 gcd(e, n) = 1,逆 d 将存在。*提示:*使用 RandomPrime.java 来计算 p 和 q,使用 Inverse.java 来计算 d。
Sophie Germaine 素数。 如果你使用特殊类型的素数 p 和 q,RSA 加密系统的安全性似乎会得到改善。具体来说,Sophie Germaine 素数是一个素数 p,其中(p-1)/2 也是素数。生成一个公钥和私钥 RSA 密钥,其中 p 和 q 是 Sophie Germaine 素数。调查找到这样一个素数所需的时间与位数 N 的函数关系。
费马素性测试。 费马素性测试是一种算法,接受一个奇数整数 n,并报告它绝对是合数或“可能”是素数。通过“可能”,该算法有时会出错,但不会太频繁。费马定理表明,如果 p 是素数且 gcd(a, p) = 1,则 a^(p-1) ≡ 1 (mod p)。PGP 加密系统中使用的一个版本的逆定理作为粗略的素性测试:如果 2^(p-1) ≡ 3^(p-1) ≡ 5^(p-1) ≡ 7^(p-1) ≡ 1 (mod p),则使用 p 作为素数。不幸的是,有一些数字满足这个费马测试,但不是素数(例如 29341、46657、75361)。
米勒-拉宾素性测试。 米勒-拉宾算法是一种用于确定奇数整数 n 是否为素数的随机算法。它接受一个安全参数 t,并输出
prime或composite。如果输出composite,则 n 绝对是合数;如果输出prime,则 n 可能是素数,但算法可能以 2^(-t)的概率错误。boolean isProbablyPrime(BigInteger n, int t) { Compute r and s such that n-1 = 2sr and r is odd Repeat from 1 to t { Choose a random integer a such that 1 < a < n - 1 Compute y = ar mod n by repeated squaring If y ≠ 1 and y ≠ n-1 { j = 1 while (j < s and y ≠ n-1) y = y2 mod n if (y == 1) return false j = j + 1 if y ≠ n-1 return false } return true }因式分解。 从RSA Security赢得 20 万美元,用于分解一个 2048 位数(616 位)。使用程序 xyz 在一分钟内分解一个 64 位数(32 位 RSA)。分解一个 128 位数需要多长时间?
波拉德的ρ方法。 波拉德的ρ方法是一种随机因式分解算法,可以在合理的时间内分解 128 位数,特别是如果这些数有一些小因子的话。它基于以下事实:如果 d 是 N 的最小非平凡因子,而 x - y 是 d 的非平凡倍数,则 gcd(x-y, N) = d。一种朴素的方法是生成一堆随机值 x[1]、x[2]、...、x[m],并计算所有 i 和 j 对的 gcd(x[i]-x[j], N)。波拉德的ρ方法是一种巧妙的方法,可以找到 x 和 y,而不必进行所有成对的计算。它的工作原理如下:随机选择 a 和 b 在 1 和 N-1 之间,并初始化 x = y = a。重复更新 x = f(x),y = f(f(y)),其中 f(x) = x² + b,只要 gcd(x-y, N) = 1。gcd 是 N 的一个因子,但如果你运气不好,它可能等于 N。通过每次随机选择 a 和 b,我们确保我们永远不会太不幸。编写一个程序 PollardRho.java,它接受一个命令行参数 N,并使用波拉德ρ方法计算 N 的素因子分解。估计运行时间作为 N 的函数。
费特-汤普森猜想。 反驳费特-汤普森猜想:不存在两个素数 p 和 q,使得(pq - 1) / (p - 1)和(qp - 1) / (q - 1)有除 1 以外的公因数。反例:(17, 3313),公因数为 112643。
卡拉兹巴乘法。 编写一个程序 Karatsuba.java,使用卡拉兹巴算法来计算两个整数的乘积。这种巧妙的算法仅使用三次 N 位乘法(以及线性量的额外工作)来计算两个 2N 位整数的乘积。要将 x 和 y 相乘,将 x 和 y 分解为 N 位块,并使用以下等式:
xy = (a + 2Nb) (c + 2N d) = ac + [(a+b)(c+d) - ac - bd] 2N + bd 22N你的递归算法应该计算位数 N 的数量,并在 N 较小时(比如 10000)将截止值设为默认的
BigInteger.multiply方法,并在 N 较大时应用 Karatsuba 分治策略。调查最佳截止点,并比较其在 N = 1000 万时与BigInteger.multiply的有效性。因式分解归结为找到一个因子。 给定一个函数
factor(N),如果 N 是素数则返回 1,否则返回 N 的任何非平凡因子,编写一个函数factorize(N),返回 N 的素因子分解。完全幂。 如果存在两个整数 p ≥ 2 和 q ≥ 2,使得 N = pq,则整数 N 是完全幂。设计一个高效的算法(与 N 中位数的位数成多项式关系)来确定 N 是否是完全幂,如果是的话,找到其质因数分解。提示:对于所有 q ≤ lg N,二进制搜索满足 N = pq 的 p。
欧拉猜想。 在 1769 年,欧拉猜想不存在正整数解使得 a⁴ + b⁴ + c⁴ = d⁴。218 年后,Noam Elkies 发现了第一个反例 2682440⁴ + 15365639⁴ + 18796760⁴ = 20615673⁴。编写一个程序 Euler.java 来证明欧拉的猜想是错误的。在练习 XYZ 中概述的蛮力解决方案不会奏效,原因有两点:(i)使用四重嵌套循环找到解决方案需要太长时间,(ii)计算 a⁴ 会导致
long溢出,因为最小的这种反例是 95800⁴ + 217519⁴ + 414560⁴ = 422481⁴。使用以下思路。遍历 1 到 N 之间的所有整数 a 和 b,并将 a⁴ + b⁴ 插入哈希表中。然后,遍历 1 到 N 之间的所有整数 c 和 d,并搜索以查看 d⁴ - c⁴ 是否在哈希表中。使用扩展精度整数以避免溢出。
使用扩展精度整数可能会比使用原始类型产生显著的开销。不要将 a⁴ + b⁴ 插入哈希表中,而是插入 a⁴ + b⁴ 模 p,其中 p 是某个大素数,比如 XYZ。然后,遍历所有 c 和 d,并搜索 d⁴ - c⁴ 模 p。如果有匹配项,使用扩展精度算术来检查它不仅仅是一个巧合的碰撞。提示:在计算 a⁴ + b⁴ 模 p 时,每次乘法后都要取出 p 的倍数以避免溢出。
指纹识别。 Alice 和 Bob 在不同地点维护着一个大基因组数据库的两份副本。为了保持一致性,他们希望能够比较这两个数据库是否相同。我们将数据库解释为 N 位整数,称为 A 和 B。由于 N 非常大,他们无法承担传输整个数据库的成本。相反,考虑以下方案发送数据的 指纹,使得 Alice 和 Bob 能够检查数据是否不一致。Alice 生成一个介于 2 和 N² 之间的随机素数 p,并发送 p 和 (A % p)。这只需要 O(log N) 位。Bob 声明如果 ((A % p) == (B % p)),则 A 和 B 相同。该方案产生错误否定(应该是是的否定)的概率为零。证明随着 n 趋近无穷大,错误肯定(应该是否定的是)的概率趋近于 0。提示:利用小于 n² 的素数数量至少为 c n² / log n,其中 c > 0 是一个常数。发送了多少位?
通过电话翻转硬币。 Alice 和 Bob 正处于一场激烈的离婚中。他们决定翻一枚硬币来决定谁将获得他们唯一儿子 Carl 的监护权。然而,他们拒绝亲自见面,也不希望任何其他人知道他们如何解决监护权纠纷。换句话说,我们希望设计一种方法,在电话线或互联网上公平地翻转一枚硬币,以便任何一方都无法作弊。以下是一个优雅的协议:
Alice 将两个或三个大素数相乘,将乘积 N 发送给 Bob。
Bob 收到整数 N,并回答数字 2 或 3。
Alice 等待 Bob 的有效回应,然后向 Bob 发送 N 的质因数分解。
如果 Bob 猜对了因子的数量,那么他获得监护权。否则,假设 Alice 遵循协议,她将赢得监护权。
通过回答以下每个问题来解释系统为什么有效。您可以假设没有有效的方法来确定给定整数 N 是否至少有 3 个非平凡因子(尽管这是一个未解决的猜想)。
Alice 如何高效地计算 N?
为什么 Bob 不能有效地独自确定真实答案?
Bob 如何有效地检查 Alice 是否发送了正确的 N 的因数分解?换句话说,防止 Alice 在 Bob 说 3 时透露两个因数(其中一个不是质数),即使她将三个(或更多)质数相乘在一起。
电话上的扑克牌。 使用上面描述的比特承诺方案开发一个在电话上玩扑克牌的协议,比如在两个当事方之间。
离散对数。 设p为一个素数。a对于基数b的离散对数是唯一的整数x,满足 0 到p-1 之间的条件,使得a = b^x (mod p)。例如,如果p = 97,b = 5,a = 35,那么 log[5] 35 = 32,因为 5³² = 35 (mod 97)。编写一个名为
DiscreteLog.java的程序,通过蛮力搜索,接受三个命令行输入 a、b 和 p,并计算模 p 下的log[b] a。Diffie Hellman。 设p为一个素数,a和b为两个整数。给定p,一个x,xa (mod p)和xb (mod p),Diffie-Hellman问题是计算x^(ab) (mod p)。
Rabin 的加密系统。 选择 p、q 为素数,使得 p = 3 mod 4 且 q = 3 mod 4。公钥为 n = pq,私钥为(p, q)。加密时,计算 E(m) = m² mod n。解密时计算 D(c) = sqrt(c) mod n。如何计算平方根:c = x² mod n?使用扩展欧几里得算法找到 a、b,使得 ap + bq = 1。计算 r = c^((p+1)/4) mod p 和 s = c^((q+1)/4) mod q。计算 m = (aps + bqr) mod n 和 t = (aps - bqr) mod n。c 的四个平方根为 m,-m mod n,t 和-t mod n。
模拟私钥交换。 你被困在一个岛上,有一个盒子,一个带钥匙的挂锁,还有一本《计算机科学导论》的副本。你有一个在另一个岛上的朋友,他也有一个盒子,一个带钥匙的挂锁,但想借你的教科书。你可以通过一个不怀好意的快递服务运送物品,如果盒子没有锁上,他们会洗劫盒子里的任何东西。你如何把书送给你的朋友?
密码安全的哈希函数。 SHA-1 和 MD5。可以通过将字符串转换为字节来计算,或者在逐个读取字节时计算。
import java.security.MessageDigest; ... MessageDigest sha1 = MessageDigest.getInstance("SHA-1"); sha1.update(s.getBytes()); Byte[] hash = sha1.digest();Java 中��RSA。 用于 RSA 或 DSA 的内置功能。未经测试的代码如下。
// key generation KeyPairGenerator keygen = KeyPairGenerator.getInstance("DSA"); SecureRandom random = new SecureRandom(); keygen.initialize(512, random); KeyPair keys = keygen.generateKeyPair(); PublicKey pubkey = keys.getPublic(); PrivateKey prikey = keys.getPrivate(); // digital signing Signature signer = Signature.getInstance("DSA"); signer.initSign(prikey); signer.update(s.getBytes()); Byte[] signature = signer.sign(); // verifying Signature verifier = Signature.getInstance("DSA"); verifier.initVerify(pubkey); verifier.update(t.getBytes()); Boolean check = verifier.verify(signature);Blum-Blum-Shub 伪随机比特生成器。 选择两个不同的 N 位素数 p 和 q,使得 p mod 4 = q mod 4 = 3。设 n = pq,并通过选择一个随机种子 1 < s < n,使得 gcd(s, n) = 1 来选择一个起始值 x[0]。形成整数序列 x[0] = s² mod n 和 x[i+1] = x[i] x[i] mod n。使用 x[i] % 2 作为伪随机比特序列。不需要保密 n。发现任何模式(在多项式时间内)与分解 n 一样困难。注意:我们仍然需要随机生成 p、q 和 s,但这些只有 O(N)位,我们将能够生成 2^N 个伪随机比特。可以用作一次性密码本。
也可以直接用于公钥加密
VCR Plus 解码。 用于在报纸上打印的特殊代码来录制 VCR 上节目的遥控方案。密码学不好,很容易破解。论文链接
帕斯卡三角形。 计算帕斯卡三角形的第 k 行(对于 k > 2)的一种方法是计算(2k + 1)(k+1),并以 k 位为一组取其二进制表示。
10 = 1 (10 = 1 = 1) 111 = 1 1 (31 = 3 = 1 1) 10110 = 01 10 01 (52 = 25 = 1 2 1) 10111 = 01 11 11 01 (53 = 125 = 1 3 3 1) 1001100 = 001 100 110 100 001 1 4 6 4 1 (94 = 6561 = 1 4 6 4 1) 10001101 = 0001 0101 1010 1010 0101 0001 (175 = 1419857)Bailey-Borwein-Plouffe 算法。 使用BBP 算法计算π的第 i 位二进制数字,而不需要计算前面的数字,这需要模指数运算。
秘密分享。 想要将一条消息分发给 N 个人,以便其中任意 3 个人可以恢复原始消息,但任何 1 或 2 个人都不能。参考链接。Scientific American 谜题
梅森素数。 梅森素数 是形式为 M_p = 2^p - 1 的素数,其中 p 是一个奇素数。要测试 M_p 是否为素数,形成以下序列:s_0 = 4,s_i+1 = (s_i)² - 2 mod M_p。当且仅当 s_(p-2) = 0 mod M_p 时,M_p 是素数。这种方法被称为卢卡斯-勒默素数检验。
6. 一台计算机
原文:
introcs.cs.princeton.edu/java/60machine译者:飞龙
本章节正在大力施工中。
概述。
架构 既指设计计算机的艺术,也指建造计算机的过程。我们对这个主题的覆盖范围围绕着一个类似于真实计算机的虚构机器展开。我们详细规定了这台机器,考虑了用于熟悉任务的机器语言程序,并提供了这台机器的 Java 模拟器。
6.1 信息表示 描述了数据在数字计算机上的存储方式,包括二进制、十六进制、二进制补码表示和浮点数。
6.2 TOY 机器 描述了一个简单的冯·诺伊曼机器 TOY 的基本组件。有 16 种不同的指令类型。
6.3 机器语言编程 提供了几个示例 TOY 程序,包括变量、赋值语句、条件和循环以及数组。
6.4 TOY 虚拟机 开发了一个 Java 程序来模拟 TOY 机器的行为。
资源。
这里是 TOY 参考卡。
这里是Visual X-TOY 模拟器的链接。
6.1 表示信息
原文:
introcs.cs.princeton.edu/java/61data译者:飞龙
一切适合数字计算机处理的内容都表示为一系列 0 和 1,无论是数字数据、文本、可执行文件、图像、音频还是视频。计算机中给定一系列比特的含义取决于上下文。在本节中,我们描述了如何以二进制、十进制和十六进制表示整数,以及如何在不同表示之间进行转换。我们还描述了如何表示负整数和浮点数。
二进制和十六进制。
自巴比伦时代以来,人们一直使用具有固定基数的位置表示法来表示整数。其中最熟悉的系统之一是十进制,其中基数为 10,每个正整数表示为介于 0 和 9 之间的数字字符串。具体来说,( d_n d_ \ldots d_2 d_1 d_0 )表示整数
\(\quad\quad\quad\quad\quad\;\; d_n10^n + d_{n-1}10^{n-1} + \ldots + d_210² + d_110¹ + d_010⁰\)
例如,10345表示整数 10,345 = 1·10⁴ + 0·10³ + 3·10² + 4·10¹ + 5·10⁰。
二进制。当基数为 2 时,我们将整数表示为一系列 0 和 1。在这种情况下,我们将每个二进制(基数 2)数字——0 或 1——称为比特。具体来说,(b_n b_ \ldots b_2 b_1 b_0)表示整数
\(\quad\quad\quad\quad b_n 2^n + b_{n-1} 2^{n-1} + \ldots + b_2 2² + b_1 2¹ + b_0 2⁰\)
例如,
1100011表示整数 99 = 1·2⁶ + 1·2⁵ + 0·2⁴ + 0·2³ + 0·2² + 1·2¹ + 1·2⁰。十六进制。
在十六进制(或hex)中,十六进制数字序列具体表示为(h_n h_ \ldots h_2 h_1 h_0)表示整数\(\quad\quad\quad\quad\;\; h_n 16^n + h_{n-1} 16^{n-1} + \ldots + h_2 16² + h_1 16¹ + h_0 16⁰\)
我们需要一个字符来表示每个数字,因此我们使用
A表示 10,B表示 11,C表示 12,依此类推。例如,FACE表示整数 64,206 = 15·16³ + 10·16² + 12·16¹ + 14·16⁰。
数字转换。
您需要知道如何将一个系统中表示的数字转换为另一个系统中的数字。
十六进制和二进制之间的转换。给定一个数字的十六进制表示,找到二进制表示很容易,反之亦然,因为 16 是 2 的幂。要从十六进制转换为二进制,将每个十六进制数字替换为对应值的四个二进制位。反之,要从二进制转换为十六进制,添加前导 0 使位数成为 4 的倍数,然后每 4 位分组并将每组转换为单个十六进制数字。
![二进制到十六进制的转换]()
从十进制到基数 b 的转换。将一个以十进制表示的整数转换为基数b的整数稍微困难一些,因为我们习惯于在十进制中进行算术运算。手动从十进制转换为基数b的最简单方法是反复除以基数b,并从上往下读取余数。例如,下面的计算将十进制整数 366 转换为二进制(101101110)和十六进制(16E)。
![从十进制到二进制的转换]()
解析和字符串表示. 将一串字符转换为内部表示称为解析。首先,我们考虑一个方法
parseInt()来解析以任何进制编写的整数。接下来,我们考虑一个toString()方法来计算给定进制中整数的字符串表示。BinaryConverter.java 提供了将一串位转换为 Javaint和反之的方法。Converter.java 是一个更通用的版本,处理 2 到 36 之间任何进制的数字字符串。这些是 Java 的两参数Integer.toString()和Integer.parseInt()方法的简化版本。
以下表格包含了从 0 到 255 的整数的十进制、8 位二进制和 2 位十六进制表示。
整数算术。
我们首先考虑的整数操作是基本算术运算,如加法和乘法。
加法. 在小学时,你学会了如何加两个十进制整数:将两个最低有效位(最右边的位)相加;如果和大于 10,则进位 1 并写下和模 10 的余数。重复下一个数字,但这次在加法中包括进位位。相同的过程可以通过用所需进制替换 10 来推广到任何进制。
![整数加法(二进制)]()
无符号整数. 在n位字中,我们只能表示 2^(n)个整数。如果我们只想要非负(或无符号的)整数,自然的选择是使用二进制表示 0 到 2n − 1 的整数,带有前导 0。例如,使用 16 位字,我们可以表示 0 到 65,535 的整数。
溢出. 我们需要注意确保算术运算的结果值不超过最大可能值。这个条件称为溢出。对于无符号整数的加法,溢出很容易检测:如果最后(最左边)的加法导致进位,那么结果太大无法表示。
![整数溢出]()
乘法. 乘法的小学算法可以在任何进制下完美运行。
![整数乘法(二进制)]()
负整数。
修改整数数据类型以包含负数并不困难,使用一种称为二进制补码的表示方法。在n位二进制补码中,我们像以前一样表示正数,但我们用(正的,无符号的)二进制数 2^(n) – x来表示每个负数–x。例如,4 位二进制补码整数 0101 仍代表+5,但 1011 代表–5,因为 2⁴ – 5 = 11[10] = 1011[2]。
加法. 添加两个n位二进制补码整数很容易:将它们像无符号整数一样相加。检测溢出比对无符号整数更复杂一些。
![二进制补码整数加法]()
减法. 要计算 x – y,我们计算 x + (– y)。也就是说,如果我们���道如何计算 –y,我们仍然可以使用标准二进制加法。要对二进制补码整数取反,翻转位然后加 1。
![二进制补码整数减法]()
Java. Java 的
short、int和long数据类型分别是 16 位、32 位和 64 位的二进制补码整数。这解释了这些类型值的边界,并解释了我们在第 1.2 节中首次观察到的 Java 中溢出行为。
实数。
IEEE 754 标准 定义了大多数计算机系统中浮点数的行为。为简单起见,我们以 16 位版本(称为 半精度二进制浮点数 或简称为 binary16)进行说明。相同的基本思想适用于 Java 中使用的 32 位和 64 位版本,我们称之为 binary32 和 binary64。
浮点数. 计算机系统中常用的实数表示称为 浮点数。它就像科学计数法一样,只是一切都用二进制表示。与科学计数法一样,浮点数由 符号、系数 和 指数 组成。
![IEEE 754 半精度格式]()
符号. 浮点数的第一位是其 符号。如果符号位为 0,则数字为正(或零),如果为 1,则为负。
指数. 浮点数的接下来的 t = 5 位用于其 指数。浮点数的指数以 偏移二进制 表示,其中我们取 R = 2^(t−1) – 1(
binary16为 15)并用 x 在 −R 和 R(binary16为 –15 和 16)之间的任何十进制数表示为 x + R 的二进制表示。例如,10101 表示指数 6,因为 6 + 15 = 21,而 10101[2] 是 21 的二进制表示。分数. 剩余的 10 位用于 系数。归一化条件意味着系数小数点前的数字始终为 1,因此我们不需要在表示中包含该数字。这些位被解释为二进制分数,因此 1.101 对应于 1 + 2^(−1) + 2^(−3) = 1.625。
编码和解码浮点数. 遵循这些规则,解码以 IEEE 754 格式编码的数字的过程很简单。编码数字的过程更复杂,因为需要归一化并扩展二进制转换以包括分数。
![浮点数到十进制的转换]()
Java. Java 使用
binary32表示float类型(32 位,其中 8 位用于指数,23 位用于分数),使用binary64表示double类型(64 位,其中 11 位用于指数,52 位用于分数)。这解释了这些类型值的边界,并解释了我们在第 1.2 节中首次观察到的舍入误差的各种异常行为。
用于操作位的 Java 代码。
Java 将 int 数据类型定义为 32 位二进制补码整数,并支持各种操作来操作位。
程序 BitWhacking.java 从命令行读取两个整数 a 和 b,应用位操作,并打印结果。
二进制和十六进制字面量. 您可以在二进制(通过在前面加上
0b)和十六进制(通过在前面加上0x)中指定整数字面值。您可以在任何可以使用十进制字面值的地方使用这些字面值。![二进制、十进制和十六进制字面量]()
移位和位操作。 Java 支持各种操作来操作整数的位:
补码:将 0 变为 1,将 1 变为 0。
位逻辑运算符:将对应的两个位应用与、或和异或函数。
![AND、OR 和 XOR 的真值表]()
左移和右移:将位左移或右移给定数量的位置。对于右移,有两个版本:逻辑右移在左侧填充空出的位置为 0;算术右移在左侧用符号位填充空出的位置。
以下是一些示例:
![位操作示例]()
移位和掩码。 这种操作的主要用途之一是移位和掩码,其中我们从同一字中隔离一组连续的位。
使用右移指令将位放在最右边的位置。
如果我们需要k位,创建一个字面掩码,其位全为 0,除了其k最右边的位为 1。
使用位与来隔离位。掩码中的 0 导致结果中的零;掩码中的 1 指定感兴趣的位。
在下面的示例中,我们从 32 位
int中提取第 9 到第 12 位。![移位和掩码]()
ExtractFloat.java 演示了使用移位和掩码从浮点数中提取符号、指数和尾数的方法。
字符。
要处理文本,我们需要为字符提供二进制编码。基本方法非常简单:一个表定义了字符和n位无符号二进制整数之间的对应关系。ASCII 和 Unicode 是两种最流行的编码方案。
ASCII。 美国信息交换标准代码(ASCII)是一个 7 位代码,尽管在现代计算中,它通常以 8 位字节的形式使用,忽略了前导位。以下表格是 ASCII 的定义,提供了您需要从 8 位二进制(等效地,2 位十六进制)转换为字符和反向的对应关系。例如,4A 编码了字母 J。
![十六进制转 ASCII]()
![ASCII 转二进制]()
Unicode。 Unicode是一个支持成千上万字符的 21 位代码。Unicode 的主要实现被称为UTF-8。UTF-8 是一种可变宽度字符编码,用于 ASCII 字符的 8 位,大多数字符的 16 位,其他字符的最多 32 位。编码规则很复杂,但现在在大多数现代系统(如 Java)中已实现,因此程序员通常不需要过多担心细节。
大端序、小端序。
计算机在存储多字节信息的方式上有所不同,例如,16 位短整数 0111000011110010 = 70F2。这由两个字节 70 和 F2 组成,其中每个字节编码了 8 位。这两种是两种主要格式,它们只在存储字节的顺序或"字节序"上有所不同。
大端序系统首先存储最重要的字节,例如,它们以自然顺序 70F2 存储上面的整数。Java 使用这种格式,苹果 Mac、IBM PowerPC G5、Cray 和 Sun Sparc 也是如此。
小端序系统首先存储最不重要的字节,例如,它们以反向字节顺序 F270 存储上面的整数。这种格式在手动执行算术运算时更自然,例如,对于加法,您从最不重要的字节到最重要的字节进行操作。Intel 8086、Intel Pentium、Intel Xeon 使用这种格式。
练习
将十进制数 92 转换为二进制。
解决方案:1011100。
将十六进制数
BB23A转换为八进制。解决方案: 首先转换为二进制
1011 1011 0010 0011 1010,然后每次考虑三位10 111 011 001 000 111 010,并转换为八进制2731072。将两个十六进制数
23AC和4B80相加,并以十六进制给出结果。解决方案: 6F2C。假设 m 和 n 是正整数。在 2^(m + n) 的二进制表示中有多少�� 1 位?解决方案: 1。
唯一的十进制整数是其十六进制表示的数字被颠倒的整数。
解决方案: 53 在十六进制中是 35。
为 Converter.java 开发一个
toInt()方法的实现,将范围在0-9或A-Z的字符转换为介于 0 到 35 之间的int值。为 Converter.java 开发一个
toChar()方法的实现,将介于 0 到 35 之间的int值转换为范围在0-9或A-Z的字符。
创意练习
- IP 地址。 编写一个程序 IP.java,将一个 32 位字符串作为命令行参数,并使用 点分十进制 表示法打印相应的 IP 地址。也就是说,每次取 8 位,将每组转换为十进制,并用点分隔每组。例如,二进制 IP 地址 01010000000100000000000000000001 应转换为
80.16.0.1。
网络练习
Excel 列编号。 编写一个函数,将非负整数转换为相应的 Excel 列名(0 = A,1 = B,...,25 = Z,26 = AA,...,702 = AAA)。
Elias Gamma 编码。 编写一个函数
elias,接受一个整数 N 作为输入,并将 Elias Gamma 编码作为字符串返回。Elias Gamma 编码 是一种编码正整数的方案。要为整数 N 生成编码,将整数 N 用二进制表示,从二进制编码的位数中减去 1,并在前面添加相应数量的零。例如,前 10 个正整数的编码如下所示。1 1 6 00110 2 010 7 00111 3 011 8 0001000 4 00100 9 0001001 5 00101 10 0001010位反转。 编写一个函数,接受一个整数输入,反转其位,并返回该整数。例如,如果 n = 8,输入为 13(00001101),那么它的反转是 176(10110000)。
public static int bitReverse(int input) { int ans = 0; for (int i = 0; i < n; i++) { ans = (ans << 1) + (input & 1); input = input >> 1; } return ans; }位反转排序。 使用先前的算法将由 N = 2^n 个元素组成的数组按其位反转顺序“排序”。如果 i 和 j 是彼此的位反转,则交换元素 i 和 j。这种排列在快速傅立叶变换中出现。
0 1 2 3 4 5 6 12 13 14 15 0000 0001 0010 0011 0100 0101 0110 ... 1100 1101 1110 1111 0000 1000 0100 1100 0010 1010 0110 ... 0011 1011 0111 1111 0 8 4 9 2 10 6 3 11 7 15无需临时存储交换。 给定整数 a 和 b,以下两个代码片段分别做什么?
a = a + b; b = a - b; a = a - b; a = a ^ b; b = a ^ b; a = a ^ b;答案: 每个 3 行片段交换 a 和 b。只要 a 和 b 不是相同的变量(在这种情况下,两个变量都将被清零)。
找到唯一的整数。 假设你有一个由 2N + 1 个整数组成的数组,并且你知道每个 N 个整数都恰好出现两次。描述一个优雅且高效的算法来识别只出现一次的整数。提示: 异或运算。
位操作版本的格雷码 使用位操作和迭代而不是递归来生成格雷码。将你的程序命名为 BitWhackingGrayCode.java。
释放囚犯 I。 当 17 名新囚犯到达时,看守与他们见面。看守告诉他们,他们今天可以见面并制定一个策略,但会议结束后,每个囚犯将被单独关押,无法互相交流。监狱有一个有 17 个开关的开关室,可以打开或关闭,尽管初始配置未透露。有一种特殊的 17 个开关设置,如果它被实现,囚犯们就可以自由了。每小时,看守会带一名囚犯到开关室,囚犯最多可以翻转一个开关(从开到关或从���到开)。看守可以任意选择囚犯的顺序,因此一个囚犯可能连续四次被选择,或者根本不被选择。设计一个策略,使 17 名囚犯保证在有限时间内被释放。
释放囚犯 II。 与上述相同的前提,只是开关室有 2 个开关(最初都关闭),囚犯进入开关室时必须翻转其中一个开关。任何时候,一个囚犯可以宣布“我们 17 个人都去过控制室了”。如果是真的,所有囚犯都将被释放;否则他们都将被处决。看守可以任意选择囚犯的顺序,因此一个囚犯可能连续四次被选择,但每个囚犯将被无限次选择(假设他们从未被释放)。设计一个策略,使 17 名囚犯保证在有限时间内被释放。额外加分:不要假设初始配置是已知的。
计算 1 位的数量。 编写一个函数,接受一个整数输入,并返回其二进制表示中的 1 的数量。
答案:这里有一个迭代和一个递归解决方案。
public static int bitCount(int input) { int count = 0; for (int i = 0; i < 32; i++) count = count + (input >>> i & 1); return count; } public static int bitCount(int x) { if (x == 0) return 0; return (x & 1) + bitCount(x >>> 1); }这是 Java 如何实现
Integer.bitCount()的。看看你能否弄清楚它是如何工作的。public static int bitCount(int i) { i = i - ((i >>> 1) & 0x55555555); i = (i & 0x33333333) + ((i >>> 2) & 0x33333333); i = (i + (i >>> 4)) & 0x0f0f0f0f; i = i + (i >>> 8); i = i + (i >>> 16); return i & 0x3f; }稀疏位计数。 解释为什么以下函数(在程序员的面试中经常出现)正确计算其输入的二进制表示中 1 的位数。如果输入有 k 个 1,while 循环会迭代多少次?
public static int bitCount(int input) { int count = 0; while (input != 0) { count++; input = input & (input - 1); } return count; }查表位计数。 重复前面的练习,但预先计算一个表以加快计算速度。
答案:这个假设你有一个大小为 256 的预先计算的表,其中
bits[i]存储 i 的二进制表示中 1 的位数。你可以使用前面练习中的位计数函数来初始化它。public static int bitCount(int input) { return bits[(input >> 0) & 0xff] + bits[(input >> 8) & 0xff] + bits[(input >> 16) & 0xff] + bits[(input >> 24) & 0xff]; }将表大小增加到 2¹⁶ = 65,536 会使事情变得更快,假设你有足够的内存。大小为 2³²的表可能是禁止的。
Java 库函数用于位操作。 重新实现Integer类中定义的以下静态方法。
函数 返回值 Integer.bitCount(x)x 中 1 的位数 Integer.highestOneBit(x)将 x 除最左边的 1 位外都置零。 Integer.lowestOneBit(x)将 x 除最右边的 1 位外都置零。 Integer.numberOfLeadingZeros(x)最高位 1 前面的零位数。 Integer.numberOfTrailingZeros(x)最低位 1 后面的零位数。 Integer.rotateLeft(x, i)将 x 循环左移 i 位。 Integer.rotateRight(x, i)将 x 循环右移 i 位。 Integer.reverse(x)x 的位的反转。 **字典攻��。**一种肮脏的垃圾邮件发送者使用的方法是通过枚举给定域名(例如 hotmail.com)的所有可能的电子邮件地址来自动生成电子邮件地址。这种恼人的策略称为字典或 Rumpelstiltskin 攻击,解释了为什么有时候你会收到发送到你尚未告知任何人的新电子邮件地址的垃圾邮件。使用 Converter.java 设计这样一个程序。你的程序 Rumpelstiltskin.java 应该接受一个命令行参数 N,并打印出所有 36^N 个可能的密码,其中包括数字和大写字母。
**断金链。**你有一条有 14 个环节的金链,你要用它来支付一个工人 15 天,每天 1 个金环节的费用。通过切割 14 次,可以将链子分成 15 段。你的目标是在只断开链子 3 次的情况下支付工人。工人必须在每天工作结束后准确收到总支付的一定比例。*提示:*将链子断开,使得有 1 段、2 段、4 段和 8 段的部分。
**海明编码器。**编写一个 Java 程序 HammingEncoder.java,读取 0 和 1 序列,每次 4 位,使用海明码进行编码。
**海明译码器。**编写一个 Java 程序 HammingDecoder.java,读取使用海明码编码的 0 和 1 序列,每次 7 位,解码并纠正错误。
**海明码。**修改你之前两个练习的解决方案,使输入位每 8 位打包一次。
**绝对值。**常量
Integer.MIN_VALUE是最负的 32 位补码整数。Math.abs(Integer.MIN_VALUE)是多少?证明一个 k 位的十进制数可以用不超过 4k 位的二进制表示。
**2 的幂之和。**计算 2 的幂之和。在补码机器上,你最终得到什么值?
**CD 数据库。**CDDB 和freedb是允许你在网络上查找 CD 信息并显示艺术家、标题和歌曲名称的数据库。每张 CD 都有一个(几乎)唯一的唱片 ID 号码,用于查询数据库。
编写一个静态方法
sumDigits(),接受一个整数参数,并返回整数中十进制数字的和。例如,sumDigits(6324)返回 15,因为 6 + 3 + 2 + 4 = 15。编写一个程序 CDDB.java,从曲目长度列表计算出唱片 ID。32 位(8 位十六进制数字)ID 号码是根据 CD 上曲目的长度和曲目数计算的:
XXYYYYZZ XX = checksum of track offsets in seconds, taken mod 255 YYYY = length of the CD in seconds ZZ = number of tracks on the CD
真或假。如果 a xor b = c,那么 c xor a = b,c xor b = a 是否成立?
解释为什么以下代码片段不会将
ABCD留在变量a中。你会如何修复它?byte b0 = 0xAB; byte b1 = 0xCD; int c = (b0 << 8) | b1;答案。在 Java 中,
byte是一个有符号的 8 位整数。右移会将b0提升为一个(负的)整数。要解决问题,使用c = ((b0 & 0xff) << 8) | (b1 & 0xff);。有毒的酒。“你是一个帝国的统治者,明天你将举办庆典。这次庆典是你举办过的最重要的派对。你有 1000 瓶酒打算在庆典上开启,但你发现其中一瓶被下毒了。实际的毒药在大约第 23 个小时左右才会出现症状,然后导致突然死亡。你手头有成千上万的囚犯。你必须让多少囚犯从瓶子中喝酒才能找到被下毒的瓶子?”
*提示:*你可以用 10 位表示数字 1,000。
**无符号 32 位整数。**描述如何在 Java 中模拟 32 位无符号整数。
解决方案:首先,您确定您真的需要无符号类型吗。有符号和无符号整数在位运算符(除了>>)、加法、减法和乘法上的行为是相同的。在许多应用程序中,这些已经足够了,假设您用>>>替换>>。比较运算符易于通过检查符号位来模拟。除法和余数是最棘手的:最简单的解决方案是转换为
long类型。long MASK = (1L << 32) - 1; // 0x00000000FFFFFFFF; int quotient = (int) ((a & MASK) / (b & MASK)); int remainder = (int) ((a & MASK) % (b & MASK));程序 UnsignedDivision.java 使用这个技巧,并且还直接使用 32 位操作。
**无符号 8 位整数。**描述如何在 Java 中模拟 8 位无符号整数。
解决方案:与前一个问题相同的建议。使用无符号整数很好的一个地方是用于查找表,由字节索引。使用有符号整数,索引可能为负。此外,如果
b是byte,那么b << 4会自动将b转换为int。这可能是不希望的,因为b是有符号的。在许多应用程序中,您需要通过(b << 4) & 0xff来去除符号扩展位。**添加两个 short 整数。**解释为什么以下代码片段失败。
short a = 4; short b = 5; short c = a + b;解决方案:Java 自动将大多数整数操作的结果提升为
int类型。要将结果分配给short,需要显式将其转换回c = (short) (a + b)。是的,这有点古怪。这个规则的一个例外是如果使用+=,那么转换会自动执行。**使用字节掩码。**解释当
b是byte类型时,(b << i)会产生奇怪的结果的原因。解决方案:在 Java 中,
byte是一个 8 位有符号整数。在右移之前,b被转换为整数。您可能想要使用((b & 0xff) << i)。2²²²¹⁷ 的二进制表示中有多少位?
程序 Overflow.java 中的以下代码片段打印什么?
int a = 2147483647; // 2³¹ - 1 int b = a + 1; System.out.println("a = " + a); System.out.println("b = " + b);以下代码片段打印什么?
int a = -5 >> 3; int b = -5 >>> 3; System.out.println(a); System.out.println(b);列出所有
int类型的a值,使得(a == (a >> 1))。提示:不止一个。假设
a是int类型的变量。找到两个值的a,使得(a == -a)为true。答案:0 和-2147483648。a = -1 * -2147483648的结果是什么?答案:0。以下代码片段打印出什么?
int a = 11 & 17; int b = 11 ^ 17; int c = 11 | 17; int d = ~11; System.out.println(a); System.out.println(b); System.out.println(c); System.out.println(d);给定两个正整数
a和b,以下 Java 代码片段将在c中留下什么结果?c = 0; while (b > 0) { if (b & 1 == 1) c = c + a; b = b >> 1; a = a << 1; }答案:a * b。
以下代码对存储在两个不同变量
a和b中的整数做了什么?a = a ^ b; b = a ^ b; a = a ^ b;重复上一个问题,但假设
a和b是相同的变量。以下代码对存储在两个不同变量
a和b中的整数做了什么?有溢出问题吗?a = a + b; b = a - b; a = a - b;以下每个语句做什么?
x = - ~x; x = ~ -x;增加 x,减少 x
以下代码做什么?
public static boolean parity(int a) { a ^= a >>> 32; a ^= a >>> 16; a ^= a >>> 8; a ^= a >>> 4; a ^= a >>> 2; a ^= a >>> 1; return a & 1; }答案:使用分治法计算二进制表示中设置的 1 位数的奇偶性。
在以下循环后,
cnt的值是多少?int cnt = 0; for (int i = 1; i != 0; i = 2 * i) { cnt++; }提示:这不是一个无限循环。
解释为什么以下 Java 代码片段正确确定整数
n是否为 2 的幂。boolean isPowerOfTwo = (n & -n) == n;**以-2 为基数计数。**使用位置表示法的定义来定义基数为-2 的数字系统。有两个数字 0 和 1。在这个系统中从-7 到 7 计数。
0 = 0 -1 = 11 (-2 + 1) 1 = 1 -2 = 10 2 = 110 (4 + -2) -3 = 1101 (-8 + 4 + 1) 3 = 111 -4 = 100 4 = 100 -5 = 1111 5 = 101 -6 = 1110 6 = 11010 (16 + -8 + -2) -7 = 1001 7 = 11011RGBA 颜色格式。 Java 的一些类(BufferedImage、PixelGrabber)使用一种称为 RGBA 的特殊编码来存储每个像素的颜色。该格式由四个整数组成,表示从 0(不存在)到 255(完全使用)的红、绿和蓝强度,以及从 0(透明)到 255(不透明)的 alpha 透明度值。这四个 8 位整数被压缩成一个 32 位整数。编写一个代码片段,从 RGBA 整数中提取四个分量,并反向操作。
// extract int alpha = (rgba >> 24) & 0xff; int red = (rgba >> 16) & 0xff; int green = (rgba >> 8) & 0xff; int blue = (rgba >> 0) & 0xff; // write back rgba = (alpha << 24) | (red << 16) | (green << 8) | (blue << 0); System.out.println(rgba);最小值和最大值。 以下哪个计算
min(a, b),另一个计算max(a, b)而不使用分支。哪个是哪个?解释它是如何工作的。f = b + ((a - b) & -(a < b)); // min(a, b) g = a - ((a - b) & -(a < b)); // max(a, b)找到缺失的值。 假设你有一个由
int类型的 2³² - 1 个整数组成的数组,其中没有任何整数出现超过一次。由于有 2³²个可能的值,恰好有一个整数缺失。编写一个代码片段,尽可能少地使用额外存储来找到缺失的整数。提示:这是一个常见的面试问题。可以只使用一个额外的
int来完成。可以利用整数溢出的性质或使用 XOR 函数来实现。循环冗余校验。 编写程序 CRC16.java 和 CRC32.java 从标准输入读取数据并计算其 16 位或 32 位 CRC。编写一个程序 CRC16CCITT.java"用于 CCITT 格式的 16 位 CRC。
6.2 TOY 机器
原文:
introcs.cs.princeton.edu/java/62toy译者:飞龙
本节正在建设中。
TOY 是一个虚构的机器(在普林斯顿创建),与古代计算机非常相似。我们今天研究它是因为它具有现代微处理器的基本特征。此外,它展示了简单的计算模型可以执行有用且非平凡的计算。在本文档中,我们描述如何使用和编程 TOY 机器。在 第七章 中,我们描述如何在硬件中构建这样一台机器。
TOY 机器内部。 TOY 机器由算术逻辑单元、存储器、寄存器、程序计数器、开关、灯和几个按钮 Load、Look、Step、Run、Enter、Stop 和 Reset 组成。我们现在描述每个组件的功能。然后,我们将描述如何使用这些组件编写 TOY 机器语言程序。

为什么学习 TOY。
帮助我们理解计算机的真正工作原理。
将 Java 与机器相关联。
帮助我们理解 Java 引用和 C 指针。这使我们成为更好的程序员。
今天仍然有一些情况需要实际进���机器语言编程(或汇编语言),尤其是视频处理、音频处理和科学计算。程序员希望利用专为多媒体应用设计的先进计算机硬件的快速能力,包括英特尔的 MMX 和 SSE、苹果的 AltiVec 以及图形卡。游戏程序员编写手工调优的汇编程序进行像素着色和纹理映射以优化性能。计算科学家意识到相同的矢量操作同样适用于科学应用,将 FFT 应用于科学数据。
字长。 TOY 机器有两种存储类型:主存储器和寄存器。每个实体存储一个信息 字。在 TOY 机器上,一个字是一个 16 位的序列。通常,我们将这 16 位解释为十六进制整数范围为 0000 到 FFFF。使用 二进制补码表示,我们也可以将其解释为十进制整数范围为 -32,768 到 +32,767。请参阅第 5.1 节以了解数字表示和二进制补码整数的刷新知识。
主存储器。 TOY 机器有 256 个 主存储器 字。每个存储位置都带有唯一的 存储器地址。按照惯例,我们使用十六进制整数范围为 00 到 FF。将存储位置视为邮箱,存储器地址视为邮政地址。主存储器用于存储指令和数据。
寄存器。 TOY 机器有 16 个 寄存器,从 0 到 F 索引。寄存器与主存储器非常相似:每个寄存器存储一个 16 位字。但是,寄存器提供了比主存储器更快的存储形式。寄存器在计算过程中用作临时空间,并在 TOY 语言中扮演变量的角色。寄存器 0 是一个特殊寄存器,其输出值始终为 0。
程序计数器。 程序计数器 或 pc 是一个额外的寄存器,用于跟踪下一条要执行的指令。它存储 8 位,对应于十六进制整数范围为 00 到 FF。这个整数存储了要执行的下一条指令的内存地址。
核心转储。 核心转储 是在特定时间点机器内容状态的完整列表。核心转储提供了机器的操作记录,并完全确定了机器将要做什么。数据和程序都存储在主存储器中,程序员需要确保数据被视为数据,指令被视为指令。

输入。开关和加载按钮用于将指令和数据输入到机器中。开关的行为就像普通的灯开关:它们要么打开,要么关闭。有 8 个内存地址开关可选择 256 个可能的内存地址中的一个。还有 16 个数据开关可选择要加载到相应内存位置的 16 位整数。要将数据输入到内存中,您需要设置适当的内存和数据开关,然后按下加载按钮。这种繁琐的过程需要为每个内存位置重复。所有寄存器和内存位置最初都是0000;程序计数器最初为10。
输出。内存地址开关、灯和查看按钮用于在程序执行时显示主存储器中一个字的地址和内容。要选择要查看的主存储器中的字,您需要设置 8 个内存地址开关并按下查看按钮。现在,8 个地址灯显示所选的内存地址(此刻与地址开关相同)。16 个数据灯显示该内存位置的内容。老程序员通常可以通过盯着内存灯的模式来知道程序的哪一部分正在执行。
运行机器。要执行 TOY 程序,您首先需要将程序和数据逐个字地输入到主存储器中,如下所述。然后,您设置程序计数器的初始值:为此,将内存地址开关设置为所需值(通常为10)并按下查看。现在,您可以按下运行或步进按钮来启动计算。从这一点开始,TOY 机器以特定、明确定义的方式执行指令。首先,它检查程序计数器的值并获取该内存位置的内容。接下来,它将程序计数器增加 1。(例如,如果程序计数器为10,则增加到11。)最后,它将此数据解释为指令并根据以下规则执行。每个指令都可以修改各种寄存器、主存储器甚至程序计数器本身的内容。它还可以将整数输出到 LED 标准输出。如果程序需要从标准输入读取值,则INWAIT灯会激活,机器会等待用户使用数据开关输入一个 16 位整数,并按下输入按钮。执行指令后,整个获取-执行循环会重复,使用程序计数器的新值获取下一条指令。这将永远持续下去,或直到机器执行停机指令。与 Java 一样,可以编写进入无限循环的程序。通过拔掉插头或按下停止按钮,始终可以停止 TOY 机器。
冯·诺伊曼机。TOY 机器的一个基本特征是将计算机程序存储为数字,数据和程序都存储在同一主存储器中。1945 年,普林斯顿学者约翰·冯·诺伊曼首次推广了这种存储程序模型或冯·诺伊曼机。它使计算机能够执行任何类型的计算,而无需用户物理更改或重新��置硬件。相比之下,要编程 ENIAC 计算机,操作员必须手动插入电缆并设置开关。这是相当繁琐和耗时的。这种存储程序机器的简单但基本思想已被纳入所有现代数字计算机中。
由于程序和数据共享同一空间,机器可以在执行时修改其数据或程序本身。也就是说,代码和数据是相同的,或者至少可以是相同的。当程序计数器引用内存时,内存被解释为指令,当指令引用内存时,内存被解释为数据。将程序视为数据的能力至关重要。考虑一下当您想要从远程位置下载程序时会发生什么,例如,download.com。这与接收电子邮件或任何其他数据没有区别。编译器和调试器也是读取其他程序作为输入数据的程序。将程序视为数据并非没有危险。计算机病毒是(恶意的)通过编写新程序或修改现有程序来传播的���序。
事后看来冯·诺伊曼的想法可能显而易见。然而,围绕存储程序模型构建计算机是否能像可以重新布线和重新配置的计算机一样强大并不明显。事实上,只要基本指令集足够丰富(正如 TOY 机器的情况),物理重新配置计算机的能力并不能使您解决更多问题。这是艾伦·图灵关于Turing 机的先前工作的结果,我们在第五章中学习过。
指令集架构
指令集架构(ISA)是 TOY 编程语言和执行程序的物理硬件之间的接口。ISA 指定了主存储器的大小、寄存器数量和每条指令的位数。它还准确指定了机器能够执行的每个指令以及如何解释每个指令位。
TOY ISA。 TOY 机器有 256 个字的主存储器,16 个寄存器和 16 位指令。有 16 种不同的指令类型;每种指令由0到F中的一个操作码指定。每个指令以完全指定的方式操作内存、寄存器或程序计数器的内容。这 16 个 TOY 指令分为三类:算术逻辑、内存和寄存器之间的传输以及流程控制。下表给出了简要摘要。(这里是 TOY 备忘单的文本版本。)我们稍后会更详细地描述它们。
| OPCODE | 描述 | 格式 | 伪代码 |
|---|---|---|---|
| 0 | 停止 | - | exit |
| 1 | 加 | 1 | R[d] |
| 2 | 减 | 1 | R[d] |
| 3 | 与 | 1 | R[d] |
| 4 | 异或 | 1 | R[d] |
| 5 | 左移 | 1 | R[d] |
| 6 | 右移 | 1 | R[d] > R[t] |
| 7 | 加载地址 | 2 | R[d] |
| 8 | 加载 | 2 | R[d] |
| 9 | 存储 | 2 | mem[addr] |
| A | 间接加载 | 1 | R[d] |
| B | 间接存储 | 1 | mem[R[t]] |
| C | 分支零 | 2 | if (R[d] == 0) pc |
| D | 分支正数 | 2 | if (R[d] > 0) pc |
| E | 跳转寄存器 | - | pc |
| F | 跳转并链接 | 2 | R[d] |
每个 TOY 指令由 4 个十六进制数字(16 位)组成。最前面(最左边)的十六进制数字编码了 16 个操作码中的一个。第二个(从左边数)十六进制数字指的是 16 个寄存器中的一个,我们称之为目标寄存器,用d表示。最右边的两个十六进制数字的��释取决于操作码。对于格式 1操作码,第三和第四个十六进制数字分别被解释为寄存器的索引,我们称之为两个源寄存器,用s和t表示。例如,指令1462将寄存器s = 6 和t = 2 的内容相加,并将结果放入寄存器d = 4。对于格式 2操作码,第三和第四个十六进制数字(最右边的 8 位)被解释为内存地址,我们用addr表示。例如,指令9462将寄存器d = 4 的内容存储到内存位置addr = 62。请注意,格式 1 和格式 2 指令之间没有歧义,因为每个操作码都有唯一的格式。

现代微处理器和 ISA。 今天,在现代微处理器上使用了各种 ISA:IA-32(英特尔,AMD),PowerPC(IBM,摩托罗拉),PA-RISC(惠普),以及 SPARC(SUN Microsystems)。这些 ISA 通常访问数百万字的主存储器,每个字长为 32 或 64 位。例如,IA-32 有 8 个 32 位通用寄存器;PowerPC 有 32 个 64 位通用寄存器。指令类型的数量从几十个到几百个不等。ISA 的选择是为了便于构建底层硬件和编译器,同时努力最大化性能并最小化成本。不幸的是,有时为了与过时硬件保持向后兼容性,这两个目标都会被牺牲。总是需要权衡。
TOY 机器具有现代微处理器的所有基本特征。然而,它更容易理解,因为它只有 16 条指令和两种指令格式。相比之下,IA-32(用于英特尔 PC 的那种)有 100 多种指令类型和十几种不同的指令格式。作为一种编程语言,TOY 也比 Java 编程语言简单得多。这使得完全理解更容易,但不一定容易编写代码或调试。Java 编译器(例如,javac)是一个自动将 Java 代码转换为要在其上执行的计算机的 ISA 的程序。您很快就会体会到在高级语言如 Java 中工作的便利性。
Q + A
古代程序员真的要翻转开关来输入程序吗?
是的。后来他们转向使用打孔卡作为更持久的存储形式。
寄存器和内存单元之间有什么区别?
两者都存储 16 位整数。然而,在 TOY 机器中,所有计算(加法,减法等)只能在寄存器内容上执行,而不是在主存储器上执行。要在内存单元上执行算术运算,必须先将其转移到寄存器,然后再转回。希望尽可能使寄存器尽快,因此它们通常由比主存储器更昂贵的材料构建,尽管原则上两者只是存储 16 位。这类似于计算机 RAM(稀缺且昂贵)和硬盘驱动器(丰富且便宜)之间的区别。
今天还在制造特殊用途的计算机或微处理器吗?
是的,因为在硬件中可以比在软件中更快地执行简单操作。恩尼格玛机是二战期间德国人使用的一种专用加密机。艾伦·图灵在布莱切利园领导一个团队建造了图灵破解机,其唯一功能是破解恩尼格玛密码。这里有一个恩尼格玛套件,您可以购买来自己组装。一些现代例子包括:信号处理、Deep Blue 用于评估棋盘位置的棋盘芯片、N 体模拟芯片、可以操作 2048 位整数的密码学芯片。天体物理学家使用一种非常专门的芯片来计算粒子之间的牛顿引力。其��最成功的芯片之一是GRAPE-6,每块板可以达到一万亿次浮点运算每秒,64 块处理器板可以持续达到 64 万亿次每秒的速度。这可能是世界上最快的计算机,尽管它只擅长这一项专门任务。
加载地址、加载和间接加载之间有什么区别?
所有三条指令都将某个值加载到寄存器中。Load address 是最简单的:
7C30将值0030加载到寄存器 C 中。Load 和 load indirect 都将某个内存单元的内容复制到寄存器中。假设内存位置30包含AAAA,内存位置31包含BBBB,寄存器 A 存储值0031。那么8C30将值AAAA加载到寄存器 C 中,而AC0A将值BBBB加载到寄存器 C 中。E100和E1CC有什么区别?没有区别。使用操作码
E时,最后两个十六进制数字被忽略。类似地,使用操作码A和B时,第三个十六进制数字被忽略。使用操作码0时,最后三个十六进制数字被忽略。如果寄存器 C 包含一个大于 15 的值,
5ABC(左移)会发生什么?由于只有左移 15 个位置才有意义,TOY 只考虑寄存器 C 的最后一个十六进制数字来确定要移位的位数。因此,如果寄存器 C 是
FFFD(-3),那么左移不会变成右移 3 位。如果程序计数器是
FF会发生什么?机器将尝试读取存储在内存位置
FF的任何值。由于FF用于标准输入,机器将从标准输入获取下一条指令。然后程序计数器将增加到00。对于 TOY 程序员来说,准备编写这样一个意图的程���是极为罕见的。
练习
TOY 机器的总存储位数是多少?包括寄存器、主存储器和程序计数器。
TOY 使用 8 位内存地址,这意味着可以访问 256 个字的内存。一台 32 位机器可以访问多少个字的内存?一台 64 位机器可以访问多少个字的内存?
一台具有 1GB 内存的 Pentium IV 有多少个字的主存储器?Pentium IV 是一台 32 位机器。
一台具有 1GB 内存的 Mac G5 有多少个字的主存储器?G5 处理器是一台 64 位机器。
给出一个单独的指令,将程序计数器更改为内存地址
15,而不考虑任何寄存器或内存单元的内容。答案:
C015或F015。这两个指令都依赖于寄存器 0 始终为0000。列出七个指令(具有不同的操作码),将
0000放入寄存器 A。答案:
1A00,2Axx,3A0x,4Axx,5A0x,6A0x,7A00,其中x是任意十六进制数字。列出三种方式(不同的操作码)将程序计数器设置为
00,而不改变任何寄存器或内存单元的内容。答案:
C000,E0xy,F000。列出五个指令(具有不同的操作码),它们是空操作。排除目标寄存器为 0 的情况。
答案:
1xx0,1x0x,2xx0,3xxx,5xx0,6xx0,其中x是除 0 以外的任意十六进制数字。列出一个 Format 2 指令,它是一个空操作。
答案:
D0xy,其中xy是任意两位十六进制数字。列出六种将寄存器 B 的内容赋值给寄存器 A 的方法。
答案:
1AB0,1A0B,2AB0,3ABB,4A0B,4AB0,5AB0,和6AB0。其中x是任意十六进制数字。TOY 中没有按位或运算符。(在 Java 中是
|。)解释如何计算 RC 答案:3DAB 4EAB 1CDE。TOY 中没有按位或运算符(或 Java)。解释如何计算 RC TOY 中没有按位 nand运算符(或 Java)。解释如何计算 RC TOY 中没有按位非运算符。(在 Java 中是
~。)解释如何计算 RB 答案:7101 2B01 4BAB或7101 2B01 2BBA。TOY 中没有绝对值函数。解释如何计算 RB TOY 中没有非负分支运算符。解释如何在寄存器 A 大于或等于 0 时跳转到内存地址
15。答案:连续使用正分支和零分支:
CA15 DA15。证明减法运算符是多余的。也就是说,解释如何通过一系列不涉及操作码 2 的 TOY 指令来计算
RC。16 个 TOY 指令中有哪些不使用全部 16 位? 答案:halt(仅使用前 4 位),load indirect(不使用第三组 4 位),store indirect(不使用第三组 4 位),jump register(不使用最后 8 位)。
我们根据二进制补码表示法将一些 TOY 整数解释为负数。这只对哪些指令有影响?
答案:正数分支将
0001到7FFF之间的整数视为正数。右移是有符号移位,因此如果最左边的位是 1,则 1 会被添加到开头。所有其他指令(甚至减法!)不依赖于 TOY 是否具有负整数。
创意练习
6.3 机器语言编程
原文:
introcs.cs.princeton.edu/java/63programming译者:飞龙
本节正在建设中。
尽管 TOY 机器语言只包含 16 种不同的指令类型,但可以执行各种有趣的计算。事实上,任何可以在您的 PC 上用 Java 编程语言完成的计算也可以在 TOY 中完成(前提是给予 TOY 足够的主存储器和时间)。这可能是一个令人惊讶的事实;我们将在第八章中稍后证明它。下面,我们描述 TOY 语言中的每个指令。
内存-寄存器传输(操作码 8 和 9)。
为了在寄存器和主存储器之间传输数据,我们使用load(操作码 8)和store(操作码 9)指令。这些操作很方便,因为不可能直接对主存储器的内容进行算术运算。相反,数据必须首先转移到寄存器中。还有一些情况下,不可能同时在寄存器中维护程序的所有变量,例如,如果我们需要存储超过 16 个值。我们通过将变量存储在主存储器中,并使用load和store指令将它们来回传输到寄存器中,克服了 16 个寄存器的限制。
算术运算(操作码 1 和 2)。
add(操作码 1)和subtract(操作码 2)执行传统的算术运算。在 TOY 中,所有算术运算都涉及 16 位的二进制补码整数,如第 5.1 节所述。
加法。 程序 add.toy 将内存地址
00和01视为存储变量RA和RB的输入值。然后计算这两个值的和,并将结果暂时放在寄存器 C 中。最后,将寄存器 C 的内容传输回内存,并将其存储在内存地址02处。重要的是要注意,内存位置00到02在此程序中永远不会被执行;它们被视为数据。|
00: 0005 5 01: 0008 8 10: 8A00 R[A]|
在程序终止时,寄存器 C 包含值
000D,这是十进制整数 13 的十六进制等价值。(如果你计算出结果0013,开始适应使用十六进制整数。)如果算术运算的结果太大而无法适应 16 位寄存器怎么办?这种溢出通过忽略除最右边的 4 位十六进制数字外的所有内容来处理。例如,将
EFFF和1005相加的结果是0004,因为EFFF + 1005 = 10004在十六进制中,我们丢弃了前导数字。这就是 Java 中加法的工作方式,只是int中有 32 位而不是 TOY 字中的 16 位。减法。 类似地,程序 subtract.toy 计算
0005 - 0008 = FFFD。答案FFFD是使用二进制补码整数表示的十进制整数-3 的十六进制等价值。(回顾第 5.1 节中对二进制补码表示法的描述。)|
00: 0005 5 01: 0008 8 10: 8A00 R[A]|
标准输入和标准输出。
load和store指令也用于访问标准输入和标准输出。TOY 硬件中断拦截了特殊的内存地址FF:不是在内存位置FF加载或存储信息,而是从键盘接收数据或发送到屏幕。
求两个整数的和。 程序 stdin.toy 从标准输入读取两个整数,并将它们的和写入标准输出。
|
10: 8AFF read R[A] from stdin 11: 8BFF read R[B] from stdin 12: 1CAB R[C]|
当程序执行时,它会暂停,直到用户输入两个整数。通常情况下,整数被指定为 4 位十六进制数字。然后计算它们的和,并将结果打印到屏幕上。
斐波那契数列。 程序 fibonacci.toy 将斐波那契数列 0、1、1、2、3、5、8、D 等打印到标准输出。
整数序列的和。 程序 sum.toy 从标准输入读取一个整数序列,并打印出它们的和。在读取整数
0000时停止。它演示了一个可以处理超出 TOY 内存容量的信息的程序。
标准输入和输出的影响。 TOY 的标准输入和标准输出功能对 TOY 机器的功能有深远影响。一个明显的特点是将信息输入和输出到机器中。这些信息可以是数据,也可以是指令!引导计算机是将一系列存储的指令(例如操作系统)复制到计算机中。TOY 机器只有有限的内存(256 个字加上几个寄存器)。尽管如此,仍然可以处理比这更多的信息。标准输入的另一个优点是它提供了一种粗糙的用户交互形式。
流程控制(操作码 C 和 D)。
到目前为止,我们已经看到了如何将 TOY 用作计算器。分支语句使我们能够发挥 TOY 的真正力量,就像while循环和if-else条件语句使我们能够发挥 Java 的力量一样。程序计数器的值控制着 TOY 机器将执行下一个语句。通常,程序计数器在每个时间步长增加一次。这导致指令按顺序执行,依次执行。分支如果为零(操作码 C)和分支如果为正(操作码 D)使我们能够直接更改程序计数器,从而改变程序的控制流。
二的幂。 程序 powers2.toy 使用分支如果为正语句打印出正的二的幂。
|
00: 0001 1 10: 8A00 RA 0) goto 11 } 14: 0000 halt|
无限循环。 改变程序流程控制的能力引入了无限循环的可能性,就像 infinite_loop.toy 中的情况一样。
|
10: 1000 no-op 11: 1000 no-op 12: C010 goto 10|
乘法。 TOY 指令集中明显缺少乘法指令。为了在软件中实现相同的效果,我们描述并实现了一个算法来将两个整数相乘。计算
c=a * b的蛮力方法是将c = 0,然后将a加到c,b次。这表明有一个重复b次的循环。我们通过创建一个计数器变量i,将其初始化为b,然后递减到达 0 来实现这一点。我们使用分支如果为正指令来检测此事件。程序 multiply.toy 从内存位置0A和0B中加载两个整数到寄存器 A 和 B 中,将它们相乘并将结果放入寄存器 C,然后将结果写回内存位置0C。|
0A: 0003 3 0B: 0009 9 0C: 0000 0 0D: 0000 0 0E: 0001 1 10: 8A0A RA|
机智的读者可能会注意到我们的算法存在严重的性能缺陷。如果值很大,蛮力算法效率低下。循环迭代
b次,由于b是一个 16 位整数,它可以达到 32767。在 64 位机器上,这个问题会更加突出,循环可能需要令人难以置信的 9,223,372,036,854,775,807 次迭代!幸运的是,我们可以引入更好的算法思想(正如我们下面所做的那样)来拯救这个看似无望的任务。
TOY 惯用法。 TOY 中有几种常见的惯用法或伪指令,可用于常见的编程任务。这些技巧中的许多依赖于寄存器 0 始终存储值0000。
寄存器之间的传输。 假设您想要使寄存器 2 具有与寄存器 1 相同的值。没有内置指令可以做到这一点。依靠寄存器 0 始终包含
0000这一事实,我们可以使用加法指令将R0和R1相加,并将结果放入R2中。|
14: 1201 R[2]|
交换。 作为一个更复杂的例子,假设我们想要交换两个寄存器 RA 和 RB 的内容....
无操作。在像 Java 这样的结构化编程语言中(具有
for和while循环),插入额外代码很容易。在像 TOY 这样的非结构化语言中(其中有行号和 goto 语句),必须小心插入代码。分支语句将内存地址硬编码为要跳转的地址;如果插入代码,程序的行号可能会更改。为了避免一些尴尬,机器语言程序员通常发现填充程序中的“无用”语句以充当占位符很方便。这些语句称为无操作,因为它们不执行任何操作。指令1000非常适合这个目的,因为寄存器 0 始终为 0。 (指令10xy对于任何 x 和 y 的值也是无操作,因为寄存器 0 始终包含 0,无论您如何尝试更改它。跳转。没有直接将程序计数器更改为
addr的指令。但是,可以使用零值分支指令与寄存器 0 来实现相同的效果。例如,指令C0F0将程序计数器更改为F0,因为寄存器 0 始终为 0。
位操作符(操作码 3, 4, 5, 和 6)。
位操作 - 按位与(操作码 3)、按位异或(操作码 4)、左移(操作码 5)和右移(操作码 6) - 与 Java 中的类似操作一样,只是使用 16 位的二进制补码整数。
按位与和按位异或
按位与
按位异或
00B5
00E3
3312
00A1
按位与
|
R[1] = 0000 0000 1011 0101 (binary) = 00B5 (hex)
R[2] = 0000 0000 1110 0011 (binary) = 00E3 (hex)
R[3] = 0000 0000 1010 0001 (binary) = 00A1 (hex)
|
左移(操作码 5)将位向左移动一定数量的位置,右侧填充 0。例如,如果寄存器 2 的值为
00B5,寄存器 3 的值为0002,那么指令5423将值02D4赋给寄存器 4。要了解原因,请查看二进制表示。|
R[2] = 0000 0000 1011 0101 (binary) = 00B5 (hex) = 181 (dec) R[2] << 2 = 0000 0010 1101 0100 (binary) = 02D4 (hex) = 724 (dec)|
请注意,左移一位等同于乘以 2;左移i位等同于乘以 2^i。
右移(操作码 6)类似,但是位向右移动。根据符号位(最左边的位),左侧填充 0 或 1。例如,如果寄存器 2 的值为
00B5,寄存器 3 的值为0002,那么指令6423将值002D赋给寄存器 4。要了解原因,请查看二进制表示。|
R[2] = 0000 0000 1011 0101 (binary) = 00B5 (hex) = 181 (dec) R[2] >> 2 = 0000 0000 0010 1101 (binary) = 002D (hex) = 45 (dec)|
寄存器 2 中的值为非负数,因此左侧填充 0。如果寄存器 2 的值为
FF4B,那么右移的结果是FFD3。在这种情况下,寄存器 2 中的值为负数,因此左侧填充 1。|
R[2] = 1111 1111 0100 1011 (binary) = FF4B (hex) = -181 (dec) R[2] >> 2 = 1111 1111 1101 0010 (binary) = FFD2 (hex) = - 46 (dec)|
注意,将整数右移 1 位等同于将整数除以 2 并丢弃余数。这对于原始整数的符号无关紧要。一般来说,将整数右移i位等同于将其除以 2^i 并向下取整。���请注意,当被除数为负数时,这与 Java 中对应的 2 的幂的整数除法并不完全一致,例如,-181/4 = -45。)这种移位称为算术移位或有符号移位:它保留二进制补码整数的符号。
高效的乘法。 使用位操作符,我们提供了一个高效的实现 multiply.toy。要将两个 16 位整数a和b相乘,我们让b[i]表示b的第i位。也就是说,
| b = (b[15] × 2¹⁵) + (b[14] × 2¹⁴) + ... + (b[1] × 2¹) + (b[0] × 2⁰) |
|---|
通过分配率,我们得到:
| a × b = (a × b[15] × 2¹⁵) + ... + (a × b[1] × 2¹) + (a × b[0] × 2⁰) |
|---|
因此,要计算a × b,只需添加上述 16 个项即可。从表面上看,这似乎将执行一次乘法的问题减少到 32 次乘法,每个 16 个项需要两次。幸运的是,这 32 次乘法都是非常特殊的类型。回想一下a × 2^i等同于将a左移i位。其次,注意b[i]要么是 0 要么是 1;因此第i项要么是a << i要么是 0。程序 multiply-fast.toy 循环 16 次。在第i次迭代中,它计算第i项并将其添加到寄存器 C 中存储的运行总数中。为了获得一些视角,回想一下用于乘法两个十进制整数的标准小学算法。我们刚刚描述的按位过程实际上只是将小学算法应用于二进制整数。
装载地址(操作码 7)。
装载地址指令(操作码 7)是 TOY 语言中最原始的赋值语句类型。下面的代码片段将寄存器 A 初始化为30。
|
11: 7A30 R[A]
|
在使用装载地址指令时,我们经常将目标寄存器视为存储某些数据的内存地址。在处理数组时,这尤其有用。在 C 编程语言中,这种类型的变量被称为指针。然而,我们也可以使用装载地址指令将一个小常数存储到寄存器中,而不是使用装载指令。请注意,装载地址只允许您将 8 位整数(00到FF)分配给寄存器,即使寄存器能够存储 16 位整数。
数组(操作码 A 和 B)。
数组并不直接内置在 TOY 语言中,但可以使用装载地址(操作码 7)、装载间接(操作码 A)和存储间接(操作码 B)指令来实现相同的功能。我们用两个示例说明这种技术。首先,我们将考虑一个程序,该程序读取一系列整数并以相反顺序打印它们。然后,我们将考虑一个更复杂的数组应用程序,该程序对一系列整数执行经典的插入排序算法。
*反转。*程序 reverse.toy 从标准输入读取一系列正整数,并在遇到整数
0000时停止。它从地址30开始将整数存储在主内存中。我们使用装载地址指令来存储地址30。然后,它以相反的顺序遍历元素,并将它们打印到标准输入。我们使用寄存器 B 来跟踪读入的元素数量。我们安排寄存器 6 包含当前正在读取或写入的数组元素的内存位置。为了写入和读取数组元素,我们分别使用操作码 A 和 B。10: 7101 R[1]缓冲区溢出。程序 reverse.toy 存在一个关键缺陷。由于 TOY 机器只有 256 个内存位置,因此不可能存储或反转包含太多元素的列表。在上面的示例中,当程序填满内存位置
30到FF时,它将绕回并开始写入内存位置00到0F。很快,它将开始覆盖原始程序的行10到20。一个狡猾的用户可以利用这种缓冲区溢出,以这样的方式输入整数,使得标准输入的整数被解释为指令而不是数据。病毒经常通过这种缓冲区溢出攻击传播。程序 crazy8.toy 是
reverse.toy的一个版本,它从内存地址 00 开始存储数组。因此,在读取并存储 16 个整数后,程序开始覆盖自身。下面的输入特别恶意。在标准输入中输入 20 个整数可以让用户控制机器,并使其无限循环打印出 8888。1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 8888 8810 98FF C011
函数(操作码 E 和 F)。
在 Java 中,将程序分解为较小的函数非常有用。我们可以在 TOY 中做同样的事情。下面是一个调用带有两个参数的乘法函数并计算它们乘积的 TOY 程序。由于所有变量(寄存器)都是全局的,我们需要就调用函数达成一致的协议。我们假设我们要将存储在寄存器A和B中的整数相乘,并将它们的乘积存储在��存器C中。程序 multiply-function.toy 两次调用乘法函数来计算x × y × z,一次计算x × y,然后再次计算(x × y)× z。它使用了专门设计用于此目的的跳转和链接(操作码 F)和跳转寄存器(操作码 E)指令。指令11和14在跳转到位于F0的函数之前将程序计数器的值存储在寄存器 3 中。这使得可以返回到主程序,而不需要将地址硬编码到程序中。
每当程序计数器重置为F0时,旧的程序计数器都会保存在寄存器 F 中以备将来使用。指令F5通过将程序计数器重置为寄存器 F 中存储的值来从函数中返回。还要注意,程序计数器在执行指令之前会先递增。因此,在第一次函数调用时,寄存器 F 为16而不是15。
在编写机器语言函数时一定要非常小心使用哪些变量。没有所谓的“局部变量”。如果我们继续在乘法函数中使用寄存器 2 作为循环计数器,这将覆盖主程序中正在用于存储量b的寄存器 2。
霍纳法。我们可以使用乘法函数来评估多项式:给定整数系数a[n],...,a[2],a[1],a[0]和整数x,在整数x处评估多项式p(x) = a[n] x^n + ... + a[2] x² + a[1] x¹ + a[0] x⁰。多项式评估是早期机器的一个存在理由。它有许多应用,包括研究弹道运动和将整数从十进制表示转换为十六进制表示。
多项式评估的蛮力算法是将n+1项相加,其中第i项是a[i]和x^i的乘积。要计算x^i,我们可以编写一个将x乘以自身i-1次的幂函数。霍纳法是一个更高效且更易于编码的巧妙替代方法。基本思想是明智地安排项的乘法顺序。我们可以通过分配性重写一个三次多项式:
| p(x) = a[3] x³ + a[2] x² + a[1] x + a[0] = (((a[3]) x + a[2]) x + a[1]) x + a[0] |
|---|
同样,我们可以重写一个五次多项式
| p(x) = a[5] x⁵ + a[4] x⁴ + a[3] x³ + a[2] x² + a[1] x + a[0] = (((((a[5]) x + a[4]) x + a[3]) x + a[2]) x + a[1]) x + a[0] |
|---|
使用霍纳法,只需要n次乘法即可评估一个n次多项式。此外,我们可以直接将该方法转换为 Java 或 TOY 代码。程序 horner.toy 是 TOY 版本。在 TOY 版本中,每次我们想要将两个整数相乘时,我们都调用我们的乘法函数。
霍纳法是由英国数学家 W. G. Horner 于 19 世纪发表的,但该算法在一个多世纪前就被艾萨克·牛顿使用过。
我们可以使用 horner.toy 将十进制整数转换为其十六进制表示。要将 765[10]转换为十六进制,我们设置输入x = A,n = 3,a[2] = 7,a[1] = 6和a[0] = 5。由于所有算术都是在十六进制中进行的,程序计算出 7 × 10² + 6 × 10 + 5 的十六进制等价值为02FD。
插入排序。程序insertion-sort.toy从标准输入中读取一系列正整数并对它们进行插入排序。在读取非正整数时程序终止。
链表。
|
// data
01: 0001 the constant 1
02: 00D0 memory address of first node of linked list
// initialize
10: 8101 R1 0) goto 12 } while (x != null)
17: 0000 halt
// data
D0: 0001 D4: 0004 D8: 0000 DC: 0000
D1: 00D6 D5: 0000 D9: 0000 DD: 0000
D2: 0000 D6: 0002 DA: 0003 DE: 0000
D3: 0000 D7: 00DA DB: 00D4 DF: 0000
|
这段代码将遍历从内存位置 D0 开始的链表,打印出每个“节点”中存储的整数。它将打印出 0001 0002 0003 0004。在整个计算过程中,R1 始终为 1。指令 A304 和 A203 中使用了索引寻址。寄存器 R2 是一个指针 - 它是下一个节点的内存地址。寄存器 R3 是指向 R2 立即后面的内存地址的指针。在每次循环迭代中,我们打印由 R2 引用的内存中的值,并使用由 R3 引用的内存中的值来确定下一次迭代中 R2 将存储的内存地址。对于上面给出的数据,寄存器 R2 将按顺序具有值 D0、D6、DA、D4 和 00。这个过程重复,直到 R2 为 0000,即链表的末尾。在 Java 中,关键字 null 扮演 0000 的角色,并用于终止链表。
递归。 你也可以在 TOY 中进行递归,但这相当棘手。
练习
编写一个程序 powers2.toy,将所有正的 2 的幂打印到标准输出。
给定整数 x,其Collatz 序列定义为如果 x 是偶数,则用 x/2 替换它,如果 x 是奇数,则用 3x + 1 替换它,并重复直到 x 为 1。编写一个程序
collatz.toy,从标准输入读取一个整数 x,并将其 Collatz 序列打印到标准输出。提示:使用右移运算符执行整数除法。编写一个程序 sum_1-n.toy,从标准输入读取一个整数 N,并打印出 1 + 2 + 3 + ... + N 的和。
编写一个程序
min3.toy,从标准输入读取三个整数,并打印出最小的一个。编写一个程序
max3.toy,从标准输入读取三个整数,并打印出最大的一个。编写一个程序
sort3.toy,从标准输入读取三个整数,并按升序打印到标准输出。编写一个程序 chop.toy,从标准输入读取一个整数 N,并将其打印为 2 的幂的和。例如,如果 N =
012A,那么程序应该打印出|
0002 0008 0020 0100|
因为
012A=0002+0008+0020+0100。这个问题测试加载地址、加载和间接加载之间的区别。对于以下每个 TOY 程序,在终止时给出寄存器 1、2 和 3 的内容。
|
(a) 10: 7211 (b) 10: 8211 (c) 10: 7211 11: 7110 11: 8110 11: A102 12: 2321 12: 2312 12: 2312 13: 0000 13: 0000 13: 0000|
考虑以下 TOY 程序。在终止时,寄存器 3 的值是多少?
|
10: 7101 11: 7207 12: 7301 13: 1333 14: 2221 15: D213 16: 0000|
编写一个程序,从标准输入读取一个整数 a,并输出 a³。
编写一个程序,从标准输入读取一个整数 a,并在标准输出中打印
AAAA,如果a = 3
a > 3
a < 3
a != 3
a >= 3
a ⇐ 3
假设你将以下内容加载到 TOY 的 10-17 位置,将 PC 设置为 10,然后按运行。
|
10: 7100 R[1]|
如果标准输入中出现以下数据,标准输出会打印什么?
|
1112 1112|
答案:TOY 程序从标准输入读取两个值。第一个值放入内存位置 15,最终被执行为代码。这插入了第二个加法指令。
重复上一个问题,但现在标准输入中有以下数据。
|
C011 C011 1112 1112|
编写一个程序,从标准输入读取三个整数 a、b 和 c,并计算判别式 d = b² - 4ac。使用上面描述的乘法函数。
假设你将以下内容加载到 TOY 的 10-17 位置,将 PC 设置为 10,然后按运行。该程序从标准输入读取一个整数,并将一个整数打印到标准输出。列出所有输入值在
0123和3210之间,使得程序打印0000。|
10: 8AFF read R[A] 11: 7101 R[1]|
答案:
0200 0400 0800 1000 2000。对于所有二进制表示中最多有一个 1 的输入,即十六进制整数 0000、0001、0002、0004、0008、0010、...、8000,它返回 1。假设你在按运行之前将以下数据加载到内存位置 30 到 37。
|
30-37: 0001 0002 0003 0004 0004 0003 0002 0001|
将 PC 设置为 10,然后按运行。运行程序后,内存位置 30 到 37 的内容将是什么?
10: 7101 R[1]将上述 TOY 程序翻译成 Java 代码,填写????。
for (int i = N; i > ???? ; i = i ????) for (int j = 0; j < ???? ; j = j ????) a[ ???? ] = ???? ;假设你将以下内容加载到 TOY 的 10-1F 位置,将以下数据加载到 30-37 位置,假设你在按下运行之前将以下数据加载到内存位置 30 到 37。
|
0001 0002 0003 0004 0004 0003 0002 0001|
将 PC 设置为 10,然后按运行。运行程序后,内存位置 30 到 37 的内容将是什么?
假设你将以下内容加载到 TOY 的 10-1B 位置,将 PC 设置为 10,然后按运行。还假设以下数据从标准输入输入。打印出什么值?
|
1CAB EF00 0000 4321 1234|
|
10: 7101 R[1]|
重复上一个练习,但使用以下数据作为标准输入。
|
2CAB EF00 0000 4321 1234|
下表显示了 TOY 寄存器的内容和 TOY 内存的一部分。假设将程序计数器设置为 30 并运行。在标准输出中打印出什么,如果有的话,在终止时列出寄存器 2 和 3 的最终内容。
|
R0: 0000 0000 0000 0000 0000 0000 0000 0000 R8: 0000 0000 0000 0000 0000 0011 0000 0000 20: 0000 0000 0000 0000 0000 0000 0000 0000 28: 0000 005A 0000 0000 0000 0000 0000 0000 30: 7101 7200 8329 1221 1331 A303 D333 92FF 38: 0000 0000 0000 0000 0000 0000 0000 0000 40: 7101 7200 8329 A403 1224 1331 A303 D343 48: 92FF 0000 0000 0000 0000 0000 0000 0000 50: 0003 0000 0005 0000 0004 0052 0000 0000 58: 0001 0060 0000 0058 0000 0000 0000 0000 60: 0002 0050 0000 0000 0000 0000 0000 0000|
假设内存位置 D0 到 E0 的数据如下。运行
linked.toy的结果是什么? 答案:1 2 3 4 5 6 7。更改上一个练习中的一个内存字,以便打印出 1 2 6 7 而不是 1 2 3 4 5 6 7。 (链表删除)
更改三个内存字(覆盖一个,使用另外两个)以便打印出 1 2 3 4 8 5 6 7。 (链表插入)
创意练习
最大值。 编写一个名为
max.toy的程序,从标准输入读取一系列非负整数,并打印出最大值。一旦遇到负整数,停止读取整数。魔术交换。 编写一个 TOY 代码片段,交换寄存器 A 和 B 的内容,而不写入主存储器或任何其他寄存器。提示:使用 XOR 指令。
32 位整数。 用 TOY 表示 32 位二进制补码整数,使用内存或寄存器的两个连续字(大端或小端)。解释如何相加两个 32 位整数。
格雷码。 编写一个 TOY 程序 graycode.toy,从标准输入读取一个整数 n(介于 1 和 15 之间),然后打印出
(i >> 1) ^ i到标准输出,其中 i = 2^n - 1 到 0。生成的序列称为 n 阶格雷码。参见第 5.1 节中的练习 XYZ。可视化 X-TOY 模拟器使用 LCD 显示器显示标准输出。观看备���标准输出(打孔卡)并查看每行的单个位是很有启发性的。
最大公约数。 编写一个程序 gcd.toy,从标准输入读取两个整数,并将它们的最大公约数打印到标准输出。编写一个假设寄存器 A 和 B 包含两个输入整数的函数,将值输出到寄存器 C,并返回到寄存器 F 中存储的地址。你可以使用以下(低效的)算法:
|
while (b > 0) { if (b > a) swap a and b a = a - b } return a|
一次性密码本。 在 TOY 中实现一次性密码本,用于加密和解密 256 位消息。假设密钥存储在内存位置 30-3F,并且输入由十六个 16 位整数组成。
点积。 计算两个数组的点积,这两个数组从 RA 和 RB 位置开始,长度为 RC。
Axpy。 给定标量 a,向量 x 和向量 b,计算向量 ax + b。
查找单身数字。 假设一个包含 2N+1 个 16 位整数的序列出现在标准输入中,其中 N 个整数出现两次,一个整数只出现一次。编写一个 TOY 程序来找到单身整数。
提示:对所有整数执行异或操作。
6.4 TOY 虚拟机
原文:
introcs.cs.princeton.edu/java/64simulator译者:飞龙
本节正在大力施工中。
模拟器
. 假设我们有兴趣设计一台新的机器或微处理器。我们可以通过建立一个原型机器并在其上运行各种程序来测试它。另一种方法是在现有机器上编写一个程序,模拟新机器的行为。这有两个主要优点。首先,很容易扩展模拟器以增加额外的灵活性,例如添加调试工具或尝试不同的 ISA。其次,这比为新机器建立原型要便宜得多。
设计模拟器还有其他好处。假设我们的 TOY 机器已经被更强大的 NOTATOY 机器所淘汰。但是,我们已经用 TOY 语言编写了成千上万的程序,并且不愿意为 NOTATOY 重新编写它们。相反,我们可以在 NOTATOY 上为 TOY 机器构建一个模拟器。现在,我们可以通过在 TOY 模拟器上运行它们来在 NOTATOY 上运行我们所有现有的程序。正如您可能怀疑的那样,这种额外的模拟层会使我们的代码运行速度有所减慢。许多古老的程序目前仍在今天的计算机上运行,经过几层模拟。例如,仍然可以在 Microsoft Windows XP 下运行 Apple IIe 游戏 Lode Runner。
Java 中的 TOY 模拟器。 程序 TOY.java 是用 Java 编写的 TOY 模拟器。它读取一个 TOY 程序并模拟TOY 机器的行为。Java 程序使用数组来存储寄存器和主存储器。它还存储程序计数器。Java 程序读取 TOY 程序并修改寄存器、存储器和程序计数器的适当内容,就像 TOY 机器会修改其寄存器、存储器和程序计数器的方式一样。为 TOY 机器编写的任何程序最终都可以在 TOY 模拟器上使用,为 TOY 模拟器编写的任何程序也可以在 TOY 机器上使用,如果它被构建的话。
模拟器需要两个输入流 - 一个用于 TOY 程序,一个用于 TOY 标准输入。我们将操作系统的标准输入与 TOY 的标准输入关联起来,并直接从文件中读取 TOY 程序。为此,我们的程序接受一个命令行参数来指定文件的名称。然后,我们使用库 In.java 从文件中逐行读取数据。我们使用正则表达式来解析输入文件。我们将在第七章详细讨论正则表达式。该程序还使用两个辅助函数toHex和fromHex,用于将十六进制字符串转换为整数,反之亦然。
Java 虚拟机。 Java 编译器javac将您的 Java 程序编译成一种机器语言程序,用于一台名为Java 虚拟机规范(JVM)的虚拟机。这台抽象机器有 229 个操作码,包括加法、除法、移位、异或、加载和存储等。当您运行java时,您正在模拟计算机上 JVM 的行为。
翻译器。 可以将任何特定的 TOY 程序翻译成 Java 程序。也就是说,给定一个 TOY 程序(例如,插入排序),编写一个执行相同操作的 Java 程序。这类似于将一本书从法语翻译成西班牙语。请注意,模拟不同于翻译。模拟器精确地逐行模仿原始机器的行为,而翻译器需要生成产生相同输出的代码,给定相同的输入。原则上,也可以将 Java 程序翻译成 TOY,但有点繁琐。
引导。我们现在考虑一个令人费解但具有重要实际意义的想法。由于将 Java 程序转换为 TOY 总是可能的,让我们将我们的 TOY 模拟器(用 Java 编写)转换为用 TOY 语言编写的程序!也就是说,我们创建一个模拟 TOY 机器本身的 TOY 程序。现在,我们可以修改 TOY 模拟器,例如,添加调试工具,或模拟 TOY 机器的变体。结果的 TOY 模拟器程序比原始的 TOY 机器“更强大”。这个想法被称为引导,一旦我们建立了一台机器,我们就可以用它来模拟“更强大”的机器。这个基本想法现在被用于所有新计算机的设计。根据苹果的网站“西摩·克雷,Cray Research 的创始人和几代超级计算机的鼻祖,听说苹果购买了一台 Cray 来模拟计算机设计。克雷感到好笑,评论说,有趣,我正在使用一台苹果来模拟 Cray-3。”
对偶。
代码和数据之间的对偶。
Code = 打印这个。 Data = 你好,世界。 Result = 打印"你好,世界"。
Code = 打印这个。 Data = 打印这个。 Result = 打印"打印这个"。
Code = 打印这个两次。 Data = 打印这个两次。 Result = 打印这个两次。 打印这个两次。
自我复制程序!在生物学中编码的对偶和自我复制:基因 = 指示符,蛋白质 = 被指示物。
练习
创意练习
7. 构建计算设备
原文:
introcs.cs.princeton.edu/java/70circuits译者:飞龙
本章正在进行重大改造。
概述。
架构既指设计计算机的艺术,也指构建计算机的过程。我们对这个主题的覆盖范围围绕着一个类似于真实计算机的虚构机器展开。在第六章中,我们详细说明了这台机器。我们继续讨论电路和逻辑设计,最终描述了如何从零开始构建这样一台机器。我们的目的是揭开这个看似艰巨的挑战的神秘面纱,同时为理解 Java 编程和其他熟悉的高级任务如何与实际机器相关提供背景。
7.1 布尔逻辑
7.2 基本电路模型
7.3 组合电路
7.4 时序电路
7.5 数字设备
7.1 布尔逻辑
原文:
introcs.cs.princeton.edu/java/71boolean译者:飞龙
布尔函数是将参数映射到值的数学函数,其中范围(函数参数)���定义域(函数值)的允许值只有两个值— *true*和*false*(或*0*和*1*)。布尔函数的研究被称为布尔逻辑。
布尔函数。
要定义任何布尔函数,我们只需指定其在输入的每个可能值上的值。非函数是一个具有一个变量的布尔函数。
\(\quad\quad\quad\quad\quad\quad \begin{align} NOT(x) &\;=\; \begin{cases} 1 & \text {如果$x$是$0$} \\[1ex] 0 & \text {如果$x$是$1$} \end{cases} \end{align}\)
与、或和异或函数是熟悉的两个变量的布尔函数。
\(\quad\quad\quad\quad\quad\quad \begin{align} AND(x, y) &\;=\; \begin{cases} 1 & \text {如果$x$和$y$都是$1$} \\[1ex] 0 & \text {否则} \end{cases} \\ \\ OR(x, y) &\;=\; \begin{cases} 1 & \text {如果$x$或$y$(或两者)是$1$} \\[1ex] 0 & \text {否则} \end{cases} \\ \\ XOR(x, y) &\;=\; \begin{cases} 1 & \text {如果$x$和$y$不同} \\[1ex] 0 & \text {否则} \end{cases} \end{align}\)
符号. 对于基本布尔函数,存在许多竞争的符号。在本章中,我们主要使用电路设计符号。
![布尔函数的符号]()
真值表. 定义布尔函数的一种方法是指定其在参数的每个可能值上的值。我们使用真值表以有组织的方式这样做。真值表有一个列用于每个变量,一行用于每个变量值的可能组合,以及一列指定该组合的函数值。
![基本函数的真值表]()
一个由n个变量组成的函数的真值表有 2^(n)行。
布尔代数. 布尔代数指的是由布尔变量和布尔运算符组成的表达式的符号操作。来自代数的熟悉的恒等式、交换律、分配律和结合律以及两个互补公理定义了布尔代数的公理。
![布尔代数的公理]()
此外,您可以从这些公理中推导出许多其他定律。例如,表中的最后一个条目给出了两个特殊的恒等式,称为德摩根定律。
![布尔代数的恒等式和定理]()
Java 中的布尔代数. 您可以以两种不同的方式将布尔代数纳入您的 Java 程序中。
Java 的布尔数据类型:在第 1.2 节中,我们介绍了具有值
*true*和*false*以及使用运算符&&、||和!的*AND*、*OR*和*NOT*操作的布尔操作。整数值的位操作:在第 6.1 节中,我们讨论了 Java 的位操作,它使用
*AND*、*OR*、*NOT*和*XOR*运算符对整数值的二进制表示中的每个位进行操作,分别使用运算符&、|、~和^。
三个或更多变量的布尔函数。
随着变量数量的增加,可能函数的数量急剧增加。有 2⁸个不同的三变量布尔函数,有 2¹⁶个四变量函数,有 2³²个五变量函数,依此类推。几个这样的函数在计算和电路设计中起着关键作用,因此我们现在将对它们进行考虑。
AND 和 OR 函数. 对于多个参数的
*AND*和*OR*函数的定义从我们的两个参数的定义自然地推广:\(\quad\quad\quad\quad\quad\quad \begin{align} AND(x_1, x_2, \ldots, x_n) &\;=\; \begin{cases} 1 & \text {如果所有参数都为$1$} \\[1ex] 0 & \text {否则} \end{cases} \\ \\ OR(x_1, x_2, \ldots, x_n) &\;=\; \begin{cases} 1 & \text {如果任何参数为$1$} \\[1ex] 0 & \text {否则} \end{cases} \\ \end{align}\)
多数和奇偶函数。 我们考虑数字电路设计中出现的另外两个函数:多数和奇偶函数:
\(\quad\quad\quad\quad\quad\quad \begin{align} MAJ(x_1, x_2, \ldots, x_n) &\;=\; \begin{cases} 1 & \text {如果$1$的参数比$0$的参数多} \\[1ex] 0 & \text {否则} \end{cases} \\ \\ ODD(x_1, x_2, \ldots, x_n) &\;=\; \begin{cases} 1 & \text {如果参数中有奇数个$1$} \\[1ex] 0 & \text {否则} \end{cases} \\ \end{align}\)
布尔表达式。 与两个变量的布尔函数一样,我们可以使用真值表来明确指定布尔函数。
![三个变量的布尔函数真值表]()
这种表示法对于具有更多变量的函数很繁琐,并且在变量较多时很快失效,因为对于n个变量,所需的行数为 2^(n)。相反,我们通常更喜欢使用布尔表达式来定义布尔函数。例如,很容易验证这两个恒等式:
\(\quad\quad\quad\quad\quad\quad \begin{align} AND(x_1, x_2, \ldots, x_n) &\;=\; x_1 x_2 \ldots x_n \\ \\ OR(x_1, x_2, \ldots, x_n) &\;=\; x_1 + x_2 + \ldots + x_n \end{align}\)
积和表示法。 布尔代数的一个基本结果是,每个布尔函数都可以用一个使用
*AND*、*OR*和*NOT*运算符而没有其他运算符的表达式来表示。例如,考虑以下真值表:![多数函数 MAJ(x, y, z)的积和表示法的真值表证明]()
由于这两列对于变量的每个值都相等,所以蓝色突出显示的两列代表以下等式的证明:
\(\quad\quad\quad\quad\quad\quad MAJ(x, y, z) = x'yz + xy'z + xyz' + xyz\)
我们可以从布尔函数的真值表中推导出这样的表达式:对于真值表中函数值为 1 的每一行,我们创建一个项,如果输入变量在该行上具有相应的值,则该项为 1,否则为 0。每个项是每个输入变量的乘积(如果该行上的对应条目为 1)或其否定(如果条目为 0)。所有这些项的和给出了函数。
我们构建的布尔表达式称为函数的积和表示法或合取范式。作为另一个例子,这是奇偶函数的表格:
![奇偶函数 ODD(x, y, z)的积和表示法的真值表证明]()
7.2 基本电路模型
原文:
introcs.cs.princeton.edu/java/72circuit译者:飞龙
本章节正在大规模施工中。
电路是一个相互连接的网络,由导线、电源连接和受控开关组成,将输入导线上的值转换为输出导线上的值。在我们的二维几何表示中,导线对应于平面上绘制的线段;受控开关对应于以特定方式交叉的导线;电路对应于在矩形内绘制的导线。我们通过绘制定义电路边界的矩形来封装电路。输入是终止在边界处的导线;输出则延伸到边界之外。
导线。
导线连接到电源,携带值,并连接到电路元素。一些导线被指定为输入;另一些被指定为输出。每根导线始终处于两种状态之一(0 或 1)。连接的导线必须具有相同的值。当我们跟踪电路中导线的值时,我们用粗线表示值为 1 的导线,用细线表示值为 0 的导线。
电源源。
我们假设输入中的一个始终为 1,并使用电源点表示连接到电路中任何位置的输入。连接到电源点的导线的值为 1,除非该连接断开。
受控开关。
受控开关是电路中一个地方,其中开关控制线穿过另一根导线然后结束。开关控制线的值变化可以断开与其交叉的导线中的电源连接,从而改变该导线的值。
开/关开关. 一个开/关开关是连接到电源(1)的导线,被开关控制线穿过。如果开关控制线值为 0,则输出导线值为 1;如果控制线值为 1,则输出导线值为 0。
![开/关开关]()
输入/关闭开关. 更一般地,我们可以将受控开关视为具有任意输入值(不一定为 1)。从逻辑上讲,开关的操作很简单:如果开关控制线为 0,则输入和输出导线连接,因此具有相同的值(都为 0 或都为 1);如果开关控制线为 1,则输入和输出导线不连接,因此输出导线的值为 0(无论输入导线的值如何)。
![输入/关闭开关]()
布局约定. 在绘制受控开关时,我们不会明确区分输入和输出。在我们所有的电路中,这种区分是清晰的,因为输入始终是连接到电源或另一个开关的导线。
一个物理例子. 在继电器中,控制线连接到一个电磁铁,它吸引一个小金属片,可以连接输入导线和输出导线,但也连接到一个弹簧。如果电磁铁关闭,弹簧保持输入连接到输出;如果电磁铁打开,它施加的力比弹簧更大,以拉动连接件以断开输入和输出之间的连接。
![继电器(受控开关)解剖图]()
电路。
开关电路分析.
![开关电路分析]()
组合电路.
时序电路.
逻辑设计与现实世界。
练习
以下电路的输出将在什么条件下为 0?
![练习 7.2.1]()
解决方案:只有当所有输入都为 0 时,输出才为 0。这是一个多路或非门。
说明前一个练习中电路输出为 1 的条件。
解决方案:只有当任一输入为 1 时,输出才为 1。这是一个多路或门。
以下电路的输出将在什么条件下为 1?
![练习 7.2.3]()
解决方案:只有当所有输入都为 1 时,输出才为 1。这是一个多路与门。
说明前一个练习中电路输出为 0 的条件。
解决方案:只有当任一输入为 0 时,输出才为 0。这是一个多路与非门。
7.3 组合电路
原文:
introcs.cs.princeton.edu/java/73combinational译者:飞龙
本章节正在大规模施工中。
多米诺加法器。 这个 视频 展示了一个使用 10,000 个多米诺骨牌实现的 4 位串行进位加法器。
练习。
设计一个电路,通过三个开关控制一个灯泡。该电路有三个输入(开关设置 x、y 和 z)和一个输出(灯控制)。任何时候,你都应该能够通过改变三个开关中的任意一个的位置将灯关闭(如果打开)或打开(如果关闭)。当三个开关都为 0(下方位置)时,灯控制为 0(灯关闭)。
以下 TOY 机器组件中哪些不是 组合电路?
控制
计数器
寄存器
主存储器
加法器
创意练习。
积和形式。 析取范式。代数性质:和与积是可交换和可结合的,分配律等。
积和形式。 合取范式。为真值表中的每个 0 包含一个项。
共识定理。 验证 (a+b)(a'+c)(b+c) = (a+b)(a'+c) 和 (ab) + (a'c) + (bc) = (ab) + (a'c)。
对偶性。 任何布尔恒等式的对偶通过交换 OR 与 AND 以及 0 与 1 而得到。
德摩根定律。
使用 NAND 实现 XOR。 只使用 NAND 门实现 XOR 门。假设输入可以是 0 或 1。
使用 NAND 实现 XOR。 只使用 4 个 NAND 门实现 XOR 门。假设输入可以是 0 或 1。 答案:XOR(a, b) = NAND(d, e),其中 c = NAND(a, b),d = NAND(a, c),e = NAND(b, c)。
使用 NOR 实现 OR 和 NAND。 使用 NOR 门实现 OR 门。使用 NOR 门实现 NAND 门。不要假设可以使用输入为 0 或 1。
普适性。 证明以下门的组合是通用的:
{ 与, 非 }
{ 或, 非 }
{ XOR, 与 }
非普适性。 证明以下门不是通用的:
{ 与 }
{ 或 }
从 XOR 到 AND。 证明你不能用 XOR 门制作 AND 门。
解答。如上所述 { XOR, 与 } 是通用的。因此,如果你可以用 XOR 门制作 AND 门, 就是通用的。但在前一个问题中,你证明它不是通用的。
两个 NOT 问题。 给定三个输入 a、b 和 c,设计一个电路,输出 a'、b' 和 c'。你可以使用任意多个 AND 和 OR 门,但最多两个 NOT 门。 警告:难度较高。
平面计算机。 展示原则上你可以在平面上绘制电路。假设两个输入 AND、OR 和 NOT 可以在平面上绘制。只需绘制一个交叉电路即可。平面交叉电路。
比较器电路。
至少 k。 一个 n 路多数电路接受 n 个输入,仅当其输入中的 1 严格多于 0 时返回 1。给定一个 n 路多数电路,描述如何构建一个至少 k 电路,仅当其输入中至少 k 个��� 1 时返回 1。 答案:考虑一个 2n 路多数电路。使 n-k+1 个输入为 1,k-1 个输入为 0,以及原始的 n 个输入。
恰好 k 电路。 描述如何构建一个电路,仅当其输入中恰好有 k 个为 1 时输出 1。 答案:如果 k = n,则使用 n 路与门。否则按照前一个练习中的方法构建一个至少 k 电路和一个至少 k+1 电路。它恰好有 k 个输入为 1,如果第一个电路输出 1,则第二个电路输出 0。
从多数门到奇校验。 描述如何只使用多数门构建一个 n 路奇校验电路。你可以假设输入为 0 和 1。
奇校验。 描述如何只使用 AND、OR、NOT 构建一个 N 路奇校验电路。提示:构建一个 3 路奇校验门的树,总共使用 ceil(2.5(N-1)) 个门。
你能为 N = 2 到 6 构建的最小电路是什么?对于 N = 2 到 5,3 路 XOR 树结构是最优的:分别为 3 个门、5 个门、8 个门和 10 个门。对于 N = 6 的答案尚不清楚(但已知为 12 或 13)。(Ingo Wegener,1991)
AND 门实现 XOR。 只使用 AND 和 NOT 设计一个 XOR 电路。使用尽可能少的 AND 门。最小值 = 3。
AND 门实现 MAJ。 只使用 AND 和 NOT 设计一个 3 路 MAJ 电路。使用尽可能少的 AND 门。最小值 = 4。
多数门实现加法器。 描述如何只使用多数门构建一个 n 位串行进位加法器电路。你可以假设你有 0 和 1 作为输入。提示:用 3 位多数门构建一个 3 路奇偶校验电路。注意 MAJ(x, y) = AND(x, y) 和 MAJ(x, y, 1) = OR(x, y)。
布尔逻辑谜题。 你必须用一条船将一只狼、一只山羊和一颗卷心菜运到河的另一边。由于自然的捕食关系,你不能让山羊和狼同时留在同一边。也不能让山羊和卷心菜在一起。
![逻辑谜题]()
二进制补码加法器的溢出。 如何检测二进制补码加法器的溢出?减法器呢?
答案:如果最左边比特的进位不同于进位。 布尔函数和电路。 设 f 是一个关于 n 个输入比特的布尔函数。证明对于大多数函数 f,实现 f 所需的 AND、OR 和 NOT 门的数量至少为 2^(n/3)。提示:证明 m 个门的电路数量至多为 2^m,并将此数量与 n 个输入的布尔函数数量进行比较。
7.4 顺序电路
原文:
introcs.cs.princeton.edu/java/74sequential译者:飞龙
这一章节正在进行重大改建。
7.5 数字设备
原文:
introcs.cs.princeton.edu/java/75architecture译者:飞龙
这一章节正在进行重大改造。
8. 系统
原文:
introcs.cs.princeton.edu/java/80systems译者:飞龙
这一章节正在进行重大改造。
到目前为止,我们知道计算机是什么以及它是如何构建的。我们还知道如何编写 Java 程序来解决有趣的问题。在本节中,我们将通过"揭秘 Java 抽象"来连接这两个概念。关于数据表示的细节,包括链表和对象;编译器、解释器和虚拟机;内存和进程管理;操作系统、网络和常见应用程序,如文字处理器和浏览器。
系统使我们能够以高度抽象的方式与计算机进行交互。我们描述了支持编程的计算机系统的基本组件;用于与我们的程序和数据进行交互的操作系统;允许计算机之间进行交互的网络;以及为特定任务提供专门支持的应用系统。
8.1 系统库
原文:
introcs.cs.princeton.edu/java/81library译者:飞龙
本节正在大规模施工中。
Java 包含许多由专家设计和实现的库。还可以从网络上下载更多通用库。我们已经考虑过的许多数据结构和算法(二叉堆、二叉搜索树、哈希表、快速排序)在这些库中起着核心作用。避免重复造轮子。知道如何找到正确的库并与之交互是一项有用的技能。
以下是一些值得了解的类。
NumberFormatter。 DecimalFormat 用于格式化实数以便通过 System.out.println 打印。
DecimalFormat df = new DecimalFormat("###.##"); System.out.println(df.format("123456.789")); System.out.println(df.format("16.1111111"));
BigInteger。 这个类实现了任意精度整数的算术运算,包括加法、乘法、模算术、素性测试、素数生成和位操作。这个类对于实现各种加密方案包括 RSA 非常有用。
StringBuffer。 有两个用于操作字符串的内置类:String 和 StringBuffer。第一个用于存储不可变的字符序列,即其值永远不会更改的字符串。第二个用于存储其字符可能被修改的字符串。在幕后,编译器使用 StringBuffer 执行任何连接操作,例如,s = "Hello " + "World"。
一个常见的性能错误是逐个连接一系列字符(或单词)以形成一个长字符串。以下代码说明了问题。
String s = ""; while(!In.isEmpty()) { char c = In.getChar(); s = s + c; }
每次将新字符 c 附加到 s 时,会出现问题,因为字符串是不可变的,所以每次都会创建 s 的新副本。构建长度为 N 的字符串可能需要与 N² 成正比的时间。相反,应该使用 StringBuffer,这样时间与 N 成正比。
StringBuffer sb = new StringBuffer(); while(!In.isEmpty()) { char c = In.getChar(); sb.append(c); } String s = sb.toString();
包。 有许多情况下没有现成的库符合您的需求。在这种情况下,您必须创建自己的库。在 Java 中,有一种内置机制称为包,它组织相关类,以便可以像内置系统库一样访问。要构建名为Jama的包,必须在客户端程序的开头包含语句import Jama;。Jama 包中的类必须存储在名为Jama的子目录中。父目录必须在您的 CLASSPATH 中,例如,当前目录。
Javadoc。 在 Jama 包上运行 Javadoc,自动生成 Java 风格的文档。命令
% javadoc -d Jama/javadoc Jama
生成一个文件Jama/javadoc/index.html,记录 Jama 包中的所有类和方法。
HTML 解析。 Java 之外还有许多重要的库。例如,如果要解析 HTML 中的 Web 文档,可以使用HTMLParser v 1.3。示例应用程序:网络爬虫,提取文本内容。解释如何下载外部库作为.jar 文件并使用它。
练习
创意练习
课程
使用内置库节省编码时间。
注意系统库中的性能保证(或缺乏性能保证)。
使用包来构建自己的库。
使用 javadoc 自动生成文档。库。
8.2 编译器、解释器和仿真器
原文:
introcs.cs.princeton.edu/java/82compiler译者:飞龙
本节正在大规模施工中。
编译器和解释器。
编译器是一个程序,它以某种高级编程语言的程序作为输入,并输出某种机器架构的机器语言代码。机器语言代码随后可以使用不同的输入数据多次执行。例如,Unix 程序g++将一个 C++源文件转换为一个可以在 Sparc 微处理器上本地运行的机器可执行文件a.out。第二个例子,Java 编译器javac将一个.java源文件转换为一个用Java 字节码编写的.class文件,这是一个名为Java 虚拟机的虚拟机器的机器语言。
解释器是一个程序,它以源程序和程序数据作为输入,并逐条翻译源程序。例如,Java 解释器java将一个.class文件翻译为可以在底层机器上本地执行的代码。第二个例子,程序 VirtualPC 将为 Intel Pentium 架构(IBM-PC 克隆)编写的程序解释为 PowerPC 架构(Macintosh)的程序。这使得 Macintosh 用户可以在他们的计算机上运行 Windows 程序。
为什么 Java 通常解释而不是编译?编译的主要优势是最终得到了可以在您的计算机上高效执行的原始机器语言代码。然而,它只能在一种类型的机器架构上执行(Intel Pentium,PowerPC)。将编译为像 Java 字节码这样的中间语言然后解释的主要优势是可以实现平台独立性:您可以在不同类型的机器架构上解释相同的.class文件。然而,解释字节码通常比执行预编译的机器语言代码慢。使用 Java 字节码的第二个优势是它充当您的计算机和程序之间的缓冲区。这使您可以从互联网下载一个不受信任的程序并在您的计算机上执行它,同时提供一些保证。由于您正在运行 Java 解释器(而不是原始机器语言代码),您受到一层防护,可防范恶意程序。正是 Java 和 Java 字节码的结合产生了一个平台独立和安全的环境,同时还包含了一整套现代编程抽象。
Java 字节码和 java 解释器并不固有地特定于 Java 编程语言。例如,您可以使用 Jython 将 Python 编程语言编译为 Java 字节码,然后使用java来解释它。有类似的 ML、Lisp 和 Fortran 编译器将编译为 Java 字节码。您还可以使用 Unix 程序gcj直接从一个.java源文件编译为一个可以在任何 Sparc 微处理器上本地运行的机器可执行��件a.out。此外,您可以设计硬件,其机器语言是 Java 字节码。Sun Microsystems 正是这样做的,使 Java 虚拟机不再那么虚拟。
为什么不使用真正的机器语言而是 Java 字节码?Java 字节码比典型的高级编程语言简单得多。为新类型的计算机编写一个 Java 字节码解释器比编写一个完整的 Java 编译器要容易得多。
创意练习
- 布尔表达式求值。 编写一个程序来评估由位、一元运算符(~)、二元运算符(&, ^, |)和括号组成的布尔表达式,使用与 Java 相同的优先级规则。这里是一个使用两个栈的解决方案,一个用于存储位,另一个用于存储运算符。
8.3 操作系统
原文:
introcs.cs.princeton.edu/java/83os译者:飞龙
本节正在进行重大改造。
内存管理。 在许多语言中,包括 C 和 C ++,程序员负责管理内存消耗,并在不再使用时显式释放它。如果正确执行,"微管理"内存可能更有效,但这很繁琐且容易出错。自动内存收集在常见应用程序中几乎可以与之一样有效,并且需要更少的开发时间。
垃圾收集。 由约翰·麦卡锡于 1958 年作为 LISP 的一部分发明。思路 = 通过其他可达对象的引用确定哪些对象是可达的;释放不可达对象(垃圾)。这里有一些常见的垃圾收集技术。
引用计数. 计算对每个对象的引用次数。当引用次数为零时,可以释放对象。缺点 - 不适用于循环链接结构,需要频繁更新计数字段。
标记-清除算法. 将所有对象标记为垃圾。扫描所有可达对象并标记为可达。扫描后,仍标记为垃圾的任何内容都可以被清除。缺点 - 活动内存变得分散,必须扫描内存中的每个对象。
复制. 进行标记-清除,但将所有非垃圾对象复制到连续的内存块中。消除碎片。缺点 - 持久对象来回复制,需要两倍的内存。
标记整理. 将对象复制到内存的同一部分。持久对象很少被复制。
分代. 根据对象活跃时间将对象划分为几个"代"。最初,对象被放置在"最年轻"的代中,随着持久性,它们移动到"更老"的代中。
默认情况下,Java 使用三代垃圾收集器。
创意练习
8.4 网络
原文:
introcs.cs.princeton.edu/java/84network译者:飞龙
本节正在进行重大改建。
网络. 客户端-服务器模型,点对点网络。
TCP/IP. 由鲍勃·卡恩(Bob Kahn)和文特·瑟夫(Vint Cerf)创建。
万维网. 范尼瓦·布什(Vannevar Bush)是一位有远见的人,他在一篇著名的 1945 年论文我们可能认为的中描述了后来成为互联网的东西。他的论文描述了一种存储信息和使用链接从一条数据到另一条数据的理论模型。泰德·纳尔逊(Ted Nelson)和道格·恩格尔巴特(Doug Englebart)将这个想法发展成了我们现在所知道的超文本。1980 年,蒂姆·伯纳斯-李实现了布什的梦想。他使用 HTML(超文本标记语言)格式化超文本,并编写了一个他称之为WorldWideWeb的浏览器和第一个 Web 服务器 info.cern.ch。在 1990 年代中期,使用 WWW 变得流行,现在已经成为日常学生生活中不可或缺的部分。
协议.
telnet www.nytimes.com 80
Trying 199.239.136.200...
Connected to www.nytimes.com.
Escape character is '^]'.
GET /2003/12/23/technology/23linux.html HTTP/1.0
Host: www.nytimes.com
Referer: http://news.google.com
Web 服务器. Java 使与 Web 服务器通信变得容易。数据类型URL代表的是统一资源定位符。资源可以是文件或网站。要读取网站的内容,请使用我们的In类。
In in = new In("http://www.cnn.com");
while (in.hasNextLine()) {
String line = in.readLine();
System.out.println(line);
}
Traceroute. 我的数据包如何从 A 到 B,需要多长时间才能到达?
|
% traceroute cornell.edu traceroute to cornell.edu (132.236.56.6), 30 hops max, 40 byte packets 1 ignition (128.112.139.1) 0.860 ms 0.599 ms 0.653 ms 2 fw-mgmt (128.112.138.2) 1.209 ms 0.493 ms 0.532 ms 3 csgate-subnet193-192 (128.112.139.193) 1.017 ms 0.957 ms 0.838 ms 4 gigagate1.Princeton.EDU (128.112.128.114) 1.070 ms 0.956 ms 0.905 ms 5 vgate1.Princeton.EDU (128.112.12.22) 2.498 ms 0.998 ms 1.000 ms 6 local.princeton.magpi.net (198.32.42.65) 2.657 ms 2.823 ms 3.699 ms 7 remote1.abilene.magpi.net (198.32.42.210) 9.740 ms 5.872 ms 8.518 ms 8 nycmng-washng.abilene.ucaid.edu (198.32.8.84) 10.191 ms 9.677 ms 10.253 ms 9 nyc-gsr-abilene-nycm.nysernet.net (199.109.4.129) 9.892 ms 9.575 ms 9.620 ms 10 nyc-m20-nyc-gsr.nysernet.net (199.109.4.2) 9.997 ms 11.565 ms 11.049 ms 11 cornell-nyc-m20.nysernet.net (199.109.5.29) 16.958 ms 16.989 ms 17.246 ms 12 core1-msfc-dmz1.cit.cornell.edu (128.253.222.5) 17.504 ms 17.054 ms 17.038 ms 13 bb3-msfc-0000-07-vl7.cit.cornell.edu (128.253.222.167) 17.463 ms 18.548 ms 17.245 ms 14 cornell.edu (132.236.56.6) 17.142 ms * 17.352 ms邮件. 程序 Mail.java 使用套接字在端口 25 上创建一个 SMTP(简单邮件传输协议)客户端。这是一个用于发送电子邮件的简陋程序。如果您更改电子邮件中的发件人和回复地址会发生什么?您可能会惊讶地发现,SMTP 没有身份验证机制,因此您可以使电子邮件看起来像来自任何地方。这被称为电子邮件欺骗。欺骗有一些合法用途(例如,希望保持匿名的告密者),但大多数情况下被垃圾邮件发送者用来掩盖他们的身份。如果仔细检查这种伪造电子邮件的邮件头,您可以看到连接到端口 25 的机器的 IP 号码。然而,普通的互联网用户会被欺骗。当然,您不应该在未经收件人事先同意的情况下使用这种欺骗技术。在某些司法管辖区是违法的。开放中继是一个 SMTP 电子邮件服务器,处理既不��发件人也不是收件人的邮件。如果
smtp.princeton.edu是一个开放中继,那么您可以从任何计算机上运行Mail.java,即使它在princeton.edu域之外。垃圾邮件发送者经常利用这样的开放中继来“洗钱”他们的电子邮件。开放中继使垃圾邮件发送者能够匿名发送大量电子邮件,使用他人的资源。如果您运行 Web 服务器,请确保不要运行开放中继。回声客户端和服务器. 程序 EchoClient.java 与服务器建立连接(在端口 4444 上),从标准输入读取行,将它们发送到服务器,并将服务器的响应打印回来。它使用 In.java 和 Out.java。程序 EchoServer.java 是配套的服务器程序。它在端口 4444 上监听来自客户端的连接请求。 (您可以使用从 1024 到 65536 的任何端口;端口 0-1023 保留用于“众所周知”的任务,例如,80 用于 http,21 用于 ftp)。收到请求后,它建立连接,从客户端读取行,并将它们回显给客户端。该语句ServerSocket serverSocket = new ServerSocket(4444);创建一个在端口 4444 上监听连接请求的
ServerSocket。关键行Socket clientSocket = serverSocket.accept();使服务器等待直到连接请求到达,然后与客户端创建
Socket连接。这是一个阻塞语句:程序在accept方法返回之前停止。服务器代码的另一个关键部分是:String s; while ((s = in.readLine()) != null) { out.println(s); }在这种情况下,
in是来自客户端的输入流,out是发送到客户端的输出流。这个循环不断地从客户端读取字符串并将其回显给客户端。调用readLine()是阻塞的,因此程序会停止,直到它返回一个String。当客户端最终断开连接时,readLine()返回null,服务器可以继续。要执行服务器和客户端,请先启动服务器,然后执行客户端程序:OS X, Linux ----------- % java EchoServer & % java EchoClient username localhost Windows ----------- > start java EchoServer > java EchoClient username localhost程序 ChatClient.java 是
EchoClient.java的简单 GUI 版本。用户在JTextField中输入消息,然后在希望将消息发送到服务器时按下回车键。结果会显示在JTextArea中。线程、死锁和同步。 两个线程同时运行。当两个(或更多)线程访问共享数据时,会发生竞争条件,并且结果行为会因"线程如何调度"而异。通过锁定对象来避免竞争条件,以便在对象解锁之前无法被另一个线程调用。一个线程可能需要等待另一个线程完成对对象的操作。可能会导致死锁。需要仔细协调。如果不小心,访问相同值的未同步代码块可能会破坏状态(给出示例)。另一方面,如果不小心,访问相同值的同步代码块可能会导致死锁(给出示例)。即使方法set()和get()是同步的,代码片段a.set(a.get() + 1)可能会导致不可预测的行为,如果另一个线程在调用a.get和a.set之间访问a。尽量避免使用线程编程;调试并发错误非常困难。聊天服务器。 回声客户端和服务器演示了两个程序通过套接字进行通信。但是,一次只有一个回声客户端可以与服务器通信。程序 ChatServer.java 使用线程允许任意数量的客户端同时连接。此外,它将接收到的每条消息广播给所有连接的客户端。这是一个基本的聊天室。它使用辅助类 Connection.java 和 ConnectionListener.java。对于客户端程序,我们可以完全重用 ChatClient.java,因为它已经完全符合我们的要求:它将消息发送到服务器,并回显服务器传输的所有内容。| |
|
| |
| |
|
线程。 Java 使处理线程变得尽可能简单,但仍然是一个困难的任务,因为执行流程不再那么清晰。同步。
Connection.java是生产者/消费者关系的一个例子。每个Connection从客户端读取消息。ConnectionListener从Connection中提取消息并将其广播给所有客户端。我们必须小心地同步这个活动,以确保每条消息只广播一次。当一条消息从客户端到达时,会调用setMessage()并设置变量message。当准备将其广播给所有客户端时,会调用getMessage()来检索字符串。完成后,将message设置为null表示已完成消息。为了确保在中间没有getMessage()之前不会连续调用两次setMessage(),我们使用wait()和notifyAll()锁定对象。如果在getMessage()广播上一条消息之前调用setMessage(),那么message不是null,因此setMessage()执行wait()语句。这会阻止setMessage()进一步执行,直到另一个方法调用notifyAll()。当getMessage()处理完一条消息后,将message设置为null并调用notifyAll()解除setMessage()的阻塞。synchronized关键字确保在特定时间内只有getMessage()和setMessage()中的一个方法执行。public synchronized String getMessage() { if (message == null) return null; String temp = message; message = null; notifyAll(); return temp; } public synchronized void setMessage(String s) { if (message != null) { try { wait(); } catch (Exception ex) { ex.printStackTrace(); } } message = s; }问答
Q. 一个线程能否在已经持有锁的对象上调用同步方法?A. 能。Java 锁是可重入的。
创意练习
- 股票报价。 编写一个程序,它接受一个命令行参数,即股票的三个字母符号,并查询网站,比如 cbs.marketwatch.com,并打印出股票的当前价格。
- Curl. Curl 是一个 Linux 程序,它以一个网页的名称作为命令行参数,并打印出其内容。
- 死链接检查器。 编写一个程序,它以一个网页的 URL 作为命令行参数,并检查页面中的所有超链接是否有效。使用正则表达式来识别超链接。首先,只检查完全指定的 URL,例如以
http://开头的。然后,允许相对超链接。|
8.5 应用系统
原文:
introcs.cs.princeton.edu/java/85application译者:飞龙
这一部分正在进行重大改造。
JavaServer Pages(JSP)。 一种类似于 PHP 和 ASP 的 Web 脚本技术。可以编写 Java 代码来显示网页。
JAR 文件。 Java 存档,简称 JAR,使您能够将各种文件打包到单个存档中。它类似于 ZIP 或 tar 文件。要创建一个存档,请在命令行中键入以下内容:
jar cf *jar-file* *input-files*
要从 JAR 中提取所有文件,请键入:
jar xf *jar-file*
或者,Java 程序可以直接访问 JAR 文件中的资源,前提是 JAR 文件在类路径中。例如,cards.jar 包含 52 张扑克牌的 GIF 图像,而 Card.java 访问它以显示图像。请注意,您必须将 JAR 文件放入类路径中,并将资源作为 URL 访问。
可执行的 JAR 文件。 JAR 文件的另一个用途是将程序的所有资源捆绑在一起,以便用户可以下载 JAR 文件并执行它(假设他们已安装了 Java 运行时环境)。这里有创建可执行 JAR 文件的说明。Windows 用户的另一个选择是JSmooth,它可以从 Java JAR 文件创建标准的 Windows EXE 文件。
Java Web Start。 Java Web Start 是通过 Web 分发 Java 应用程序的框架。与 JAR 可执行文件不同,客户端从托管在 Web 上的资源启动应用程序,而不是从本地文件系统启动。如果您希望用户始终运行应用程序的最新版本,则此方法很有用。这里有创建 Java Web Start 应用程序的说明。
小程序。 Java 小程序会自动下载并在 Web 浏览器中启动并在安全沙箱中执行。小程序的一个困难之处在于某些浏览器不支持当前版本的 Java。因此,如果尚未安装,您需要指示浏览器下载适当的插件。appletviewer。
创意练习
9. 科学计算
原文:
introcs.cs.princeton.edu/java/90scientific译者:飞龙
本章节正在大力施工中。
科学和工程中许多重要且具有挑战性的问题需要大量的计算资源来模拟和仿真自然现象。一些示例问题包括:人类基因组计划、计算流体动力学、海洋环流、等离子体动力学、车辆碰撞模拟、建筑漫游、全球气候模拟、金融建模、地震勘测、分子动力学模拟、蛋白质折叠、电子设计、核武器模拟、制药设计和自然语言处理。这些重大挑战性问题具有广泛的科学、社会、经济和政治影响。
高效的计算机算法也在现代技术中发挥着核心作用,并丰富了我们的日常生活。例如网页搜索引擎、DVD 播放器、手机、JPEG 图像、MP3 音频文件和 DivX 视频文件。
科学计算涵盖了一个庞大的知识领域,在过去半个世纪里建立在高斯、牛顿、欧拉等人的工作之上。在本章中,我们将概述在我们的计算基础设施中发挥关键作用的一些最重要的算法。设计稳健和高效的科学算法是一项非常不平凡的任务。我们的目标是让您了解所涉及的内容,并确保您在编写科学代码时意识到您所做决策的后果。本书不旨在成为全面参考(请参阅《Java 数值计算法》、《Golub 和 Van Loan 的矩阵计算》)。我们也不包括数学证明来证明我们的算法。
计算机模拟方法简介 由 Gould, Tobochnik 和 Christian 撰写。
Colt 提供了一组用于在 Java 中进行高性能科学和技术计算的开源库。由 CERN 提供。速度高达优化后的 Fortran 的 90%。它包含了用于离线和在线数据分析、线性代数、多维数组、统计学、直方图、蒙特卡洛模拟、并行和并发编程的高效数据结构和算法。
9.1 浮点数
原文:
introcs.cs.princeton.edu/java/91float译者:飞龙
本节正在进行重大施工。
传统计算机科学与科学计算的一个显著特征是其使用离散数学(0 和 1)而不是连续数学和微积分。从整数过渡到实数不仅仅是一种表面变化。数字计算机无法精确表示所有实数,因此在为实数设计计算机算法时,我们面临新的挑战。现在,除了分析运行时间和内存占用量之外,我们还必须关注结果解决方案的“正确性”。这个具有挑战性的问题进一步恶化,因为许多重要的科学算法为适应离散计算机而进行额外的近似。正如我们发现一些离散算法本质上太慢(多项式 vs. 指数),我们将看到一些浮点算法太不准确(稳定 vs. 不稳定)。有时,通过设计更聪明的算法可以解决这个问题。对于离散问题,困难有时是固有的(NP 完全性)。对于浮点问题,困难也可能是固有的(病态条件),例如准确的长期天气预测。要成为一名有效的计算科学家,我们必须能够相应地对我们的算法和问题进行分类。
浮点数。
20 世纪最伟大的成就之一如果没有数字计算机的浮点能力是不可能的。然而,大多数程序员对这个主题并不了解,经常引起混淆。在 1998 年 2 月的一次主题演讲中,名为Java 扩展用于数值计算的 James Gosling 断言“95%的人完全不了解浮点数”。然而,浮点数背后的主要思想并不难理解,我们将揭开大多数新手困惑的神秘面纱。
IEEE 754 二进制浮点表示。
首先,我们将描述浮点数是如何表示的。Java 使用IEEE 754 二进制浮点标准的一个子集来表示浮点数并定义算术运算的结果。几乎所有现代计算机都符合这一标准。一个float使用 32 位表示,每种可能的位组合表示一个实数。这意味着最多可以精确表示 2³²个实数,尽管实数有无限多个(甚至在 0 和 1 之间)。IEEE 标准使用类似于科学记数法的内部表示,但是使用二进制而不是十进制。这涵盖了从±1.40129846432481707e-45 到±3.40282346638528860e+38 的范围。具有 6 或 7 个有效十进制数字,包括正无穷大、负无穷大和 NaN(不是一个数字)。该数字包含一个符号位s(解释为正或负),8 位指数e,和 23 位尾数M。十进制数根据以下公式表示。
(-1)s × m × 2(e - 127)
符号位 s(第 31 位)。最高有效位表示数字的符号(1 为负,0 为正)。
指数字段 e(第 30 - 23 位)。接下来的 8 位表示指数。按照惯例,指数通过 127 进行偏置。这意味着要表示二进制指数 5,我们在二进制中编码为 127 + 5 = 132(10000100)。要表示二进制指数-5,我们在二进制中编码为 127 - 5 = 122(01111010)。这种约定是用于表示负整数的补码表示法的替代方法。
尾数 m(位 22-0)。剩余的 23 位表示尾数,标准化为 0.5 到 1 之间。通过相应地调整二进制指数,始终可以进行这种标准化。二进制小数与十进制小数类似:0.1101 表示 1/2 + 1/4 + 1/16 = 13/16 = 0.8125。并非每个十进制数都可以表示为二进制小数。例如,1/10 = 1/16 + 1/32 + 1/256 + 1/512 + 1/4096 + 1/8192 + ... 在这种情况下,数字 0.1 由最接近的 23 位二进制小数 0.000110011001100110011...来近似���还有一个进一步的优化。由于尾数始终以 1 开头,因此不需要显式存储这个隐藏位。
例如,十进制数 0.085 存储为 00111101101011100001010001111011。
0.085:
bits: 31 30-23 22-0
binary: 0 01111011 01011100001010001111011
decimal: 0 123 3019899
这恰好表示数字 2^(e-127) (1 + m / 2²³) = 2^(-4)(1 + 3019899/8388608) = 11408507/134217728 = 0.085000000894069671630859375。
double类似于float,只是其内部表示使用 64 位,11 位指数,偏置为 1023,以及 52 位尾数。这覆盖了从±4.94065645841246544e-324 到±1.79769313486231570e+308 的范围,精度为 14 或 15 个有效数字。
精度与准确性。
精度=规范的严密程度。准确性=正确性。不要混淆精度和准确性。3.133333333 是数学常数π的估计值,其规范为 10 位小数,但只有两位小数的准确性。正如约翰·冯·诺伊曼曾经说过的:“当你连自己在说什么都不知道时,精确是没有意义的。”Java 通常以 16 或 17 位小数的精度打印浮点数,但不要盲目相信这意味着有那么多位的准确性!计算器通常显示 10 位数字,但以 13 位精度进行计算。卡汉:哈勃太空望远镜的镜子被磨得非常精确,但却是按照错误的规范。因此,最初它是一个巨大的失败,因为它无法产生预期的高分辨率图像。然而,它的精度使一名宇航员能够安装一个校正镜片来抵消误差。货币计算通常以给定的精度来定义,例如,欧元汇率必须报价到 6 位数。
舍入误差。
使用浮点数进行编程对于未经培训的人来说可能是一个令人困惑和危险的过程。整数运算是精确的,除非答案超出了可以表示的整数范围(溢出)。相比之下,浮点运算并不是精确的,因为一些实数需要无限位数的数字来表示,例如,数学常数 e 和π以及 1/3。然而,大多数初学者 Java 程序员会惊讶地发现,1/10 在标准二进制浮点数中也无法精确表示。这些舍入误差可能以非直观的方式传播到计算中。
小数的四舍五入。例如,下面来自 FloatingPoint.java 的第一个代码片段打印出
false,而第二个则打印出true。double x1 = 0.3; double x2 = 0.1 + 0.1 + 0.1; StdOut.println(x1 == x2); double z1 = 0.5; double z2 = 0.1 + 0.1 + 0.1 + 0.1 + 0.1; StdOut.println(z1 == z2);牛顿法。一个更现实的例子是以下代码片段,其目的是通过迭代牛顿法计算 c 的平方根。数学上,迭代���列收敛到√c,使得 t² - c > 0。然而,浮点数只有有限位数的准确性,所以最终,我们可能期望 t²精确地等于 c,直到机器精度。
double EPSILON = 0.0; double t = c; while (t*t - c > EPSILON) t = (c/t + t) / 2.0;实际上,对于某些 c 的值,这种方法是有效的。
% java Sqrt 2 % java Sqrt 4 % java Sqrt 10 1.414213562373095 2.0 3.162277660168379当我们尝试计算 20 的平方根时,可能会让我们对我们的代码片段正确性产生一些信心。但是当我们尝试计算 20 的平方根时,一个令人惊讶的事情发生了!我们的程序陷入了无限循环!这种错误称为舍入误差。机器精度是最小的数字ε,使得(1.0 + ε != 1.0)。在 Java 中,使用
double是 XYZ,使用float是 XYZ。将误差容限ε更改为一个小的正值有所帮助,但并不能解决问题(参见练习 XYZ)。我们必须满足于近似平方根。进行计算的可靠方法是选择一些误差容限ε,比如
1E-15,并尝试找到一个值t,使得|t - c/t| < ε t。我们使用相对误差而不是绝对误差;否则程序可能会陷入无限循环(参见练习 XYZ)。double epsilon = 1e-15; double t = c; while (Math.abs(t*t - c) > c*epsilon) t = (c/t + t) / 2.0;*调和级数。*可能是受到尝试估计欧拉常数γ = 当 n 趋向无穷大时γ[n] = H[n] - ln n 的启发。极限存在且约为 0.5772156649。令人惊讶的是,甚至不知道γ是否是无理数。
程序 HarmonicSum.java 使用单精度和双精度计算 1/1 + 1/2 + ... + 1/N。使用单精度时,当 N = 10,000 时,总和精确到 5 位小数,当 N = 1,000,000 时,只精确到 3 位小数,当 N = 10,000,000 时,只精确到 2 位小数。实际上,一旦 N 达到 1000 万,总和就再也不增加了。尽管调和级数发散到无穷大,在浮点数中它收敛到一个有限的数!这打破了一个常见的误解,即如果你解决的问题只需要 4 或 5 位小数的精度,那么使用存储 7 位的类型是安全的。许多数值计算(例如,积分或微分方程的解)涉及对许多小项求和。误差可能会累积。这种舍入误差的累积可能导致严重问题。
更好的公式:γ ≈ γ[n] - 1/(2n) + 1/(12n²)。对于 n = 100 万,精确到小数点后 12 位。
每次进行算术运算时,至少会引入一个额外的误差ε。Kernighan 和 Plauger 说:“浮点数就像沙堆;每次移动一个,你就会丢失一点沙子并拾起一点灰尘。”如果错误是随机发生的,我们可能会预期一个累积误差为 sqrt(N) ε[m]。然而,如果我们在设计数值算法时不够警惕,这些错误可能会以非常不利和非直观的方式传播,导致累积误差达到 N ε[m]或更糟。
金融计算。
我们举例说明一些可能破坏财务计算的舍入误差。涉及美元和美分的财务计算涉及基数 10 算术。以下示例演示了使用类似 IEEE 754 的二进制浮点系统可能遇到的一些危险。
*销售税。*程序 Financial.java 说明了危险。计算一个 50 美分电话的 9%销售税。(或者 0.70 美元电话的 5%销售税)。使用 IEEE 754,1.09 * 50 = xxx。即使精确答案是 0.xx,结果也会被四舍五入为 0.xx,而电话公司(根据法律)必须将其四舍五入为 0.xx(使用银行家舍入)。相比之下,一个 75 美分电话的 14%税可能会被四舍五入为 x.xx,即使��根据法律)电话公司必须将数字四舍五入为 x.xx(使用银行家舍入)。这几分钱的差异可能看起来不重要,你可能希望这些影响在长期内互相抵消。然而,经过数亿次交易,这种薄利多销可能导致数百万美元的损失。参考:《超人 III》(1983 年),《骇客》(1995 年)和《办公室空间》(1999 年)。在《办公室空间》中,三个朋友通过计算机病毒感染会计系统,将分数部分四舍五入并转移到他们的账户中。
出于这个原因,一些程序员认为在存储财务值时应始终使用整数类型,而不是浮点类型。下一个示例将展示使用
int类型存储财务值的危险。复利. 这个例子向您介绍了舍入误差的危险。假设您以每日复利的方式投资 $1000.00,利率为 5%。1 年后您将有多少钱?如果银行正确计算价值,您应该最终得到 $1051.27,因为精确公式是
a * (1 + r/n)n这导致 1051.2674964674473.... 假设银行将您的余额存储为整数(以便士计算)。每天结束时,银行计算您的余额并乘以 1.05,然后将结果四舍五入到最接近的一分钱。那么,您最终只会得到 $1051.10,被骗了 17 分钱。假设银行每天结束时向下舍入到最接近的一分钱。现在,您最终只会得到 $1049.40,您将被骗走 $1.87。不存储分数部分的误差会累积,并最终可能变得显著,甚至是欺诈性的。程序 CompoundInterest.java。
您应该使用 Java 的
BigDecimal库,而不是使用整数或浮点类型。这个库有两个主要优点。首先,它可以精确表示十进制数。这可以避免使用二进制浮点数时的销售税问题。其次,它可以存储具有任意精度的数字。这使程序员能够控制舍入误差对计算的影响程度。
其他误差来源。
除了使用浮点运算时固有的舍入误差外,科学应用中常见的还有一些其他类型的近似误差。
测量误差。 计算中使用的数据值不准确。这源于实际(不准确或���精确的测量仪器)和理论(海森堡不确定性原理)考虑。我们主要关注的是对初始数据不太敏感的模型和解决方法。
离散化误差。 另一个不准确性来源是对连续系统进行离散化,例如,通过截断其泰勒展开来近似超越函数,使用矩形的有限和来近似积分,使用有限差分方法找到微分方程的近似解,或者在格点上估计连续函数。即使使用精确算术,离散化误差仍然存在。这通常是不可避免的,但我们可以通过使用更精细的离散化来减少截断误差。当然,这要以使用更多资源为代价,无论是内存还是时间。在实际应用中,离散化误差通常比舍入误差更重要。
统计误差。 没有足够的随机样本。
灾难性的取消。
当从大数通过加法或减法计算小数时,会导致精度严重丢失。例如,如果 x 和 y 除了最后几位外完全相同,那么如果我们计算 z = x - y,那么 z 可能只有几位精度。如果随后在计算中使用 z,那么结果可能只有几位精度。
绘制函数。 尝试绘制 f(x) = (1 - cos x) / (x²),其中 x = -4 * 10-8 到 4 * 10-8。在这个区域内,数学函数 f(x) 大致恒定,值为 0.5。然而,在 IEEE 浮点数中,情况绝对不是这样!程序 Catastrophic.java 进行了这个操作,结果非常令人惊讶。
![灾难性取消]()
指数函数。 现在我们考虑灾难性取消的一个更微妙和有害的后果。程序 Exponential.java 使用泰勒级数计算 e^x
ex = 1 + x + x2/2! + x3/3! + x4/4! + ...该级数在所有 x 值上均一致绝对收敛。然而,对于许多负的 x 值(例如,-25),程序无法获得任何正确的数字,无论级数中有多少项相加。例如,当 x = -25 时,级数在浮点数中收敛为-7.129780403672078E-7(一个负数!),但真实答案是 1.3887943864964021E-11。要了解原因,请注意级数中的第 24 项是 25²⁴/24!,第 25 项是-25²⁵/25!。原则上,它们应该完全互相抵消。实际上,它们相互抵消得灾难性。这些项的大小(5.7 * 10⁹)比真实答案大 20 个数量级,任何抵消中的错误都会在计算答案中放大。幸运的是,在这种情况下,问题很容易纠正(见练习 XYZ)。
数值分析。
Lloyd Trefethen(1992 年)"数值分析是研究连续数学问题算法的学科。"
稳定性. 如果一个数学问题的解在输入参数发生微小变化时只有微小变化,则该问题是良好条件的。如果算法的输出在输入数据发生微小变化时只有微小变化,则该算法是数值稳定的。数值稳定性捕捉了算法如何传播错误。数值分析是寻找解决良好条件问题的数值稳定算法的艺术��科学。准确性取决于问题的条件和算法的稳定性。应用稳定算法解决病态问题或应用不稳定算法解决良好条件问题可能导致不准确性。
指数函数. 举个简单的例子,计算 f(x) = exp(x)是一个良好条件的问题,因为 f(x + ε) = .... 通过其泰勒级数计算 exp(x)的一种算法。假设我们使用其泰勒近似的前四项来估计 f(x) = exp(x):g(x) = 1 + x + x²/2 + x³/3!。那么 f(1) = 2.718282,g(1) = 2.666667。然而,它是不稳定的,因为如果 x < 0 ..... 一个稳定的算法是,如果 x 是非负的,则使用泰勒级数,但如果 x 是负的,则使用泰勒级数计算 e^(-x)并取倒数。
(1 - cos x) / (x²)函数. 给出一个稳定的方法来评估这个函数。
条件. 我们看到了一个计算 exp(x)的不稳定算法的例子。我们也看到了一个稳定的计算算法。有时,我们并不总是那么幸运。如果没有稳定的算法来解决问题,我们称问题为病态问题。
正数的加法、乘法、指数运算和除法都是良好条件的问题。计算二次方程的根也是如此。(见练习 XYZ。)减法是病态问题。找到一般多项式的根也是如此。解 Ax = b 问题的条件取决于矩阵 A。
人口动态. Verhulst 方程是人口动态的简化模型。
xn = (R + 1)xn-1 - R (xn-1)2程序 Verhulst.java 读取命令行参数 R,并迭代 Verhulst 方程 100 次,从 x[0] = 0.5 开始。它以四种不同但在数学上等效的方式进行计算。当 R = 3 时,所有计算结果都明显不同,但都不正确(可以使用 Maple 中的精确算术进行验证)。(参考链接)
(R+1)x-R(xx) (R+1)x-(Rx)x ((R+1)-(Rx))x x + R(x-xx) correct 0: 0.5000000000 0.5000000000 0.5000000000 0.5000000000 0.5000000000 10: 0.3846309658 0.3846309658 0.3846309658 0.3846309658 0.3846309658 20: 0.4188950250 0.4188950250 0.4188950250 0.4188950250 0.4188950250 30: 0.0463994725 0.0463994756 0.0463994787 0.0463994775 0.0463994768 40: 0.3201767255 0.3201840912 0.3201915468 0.3201885159 0.3201870617 50: 0.0675670955 0.0637469566 0.0599878799 0.0615028942 0.0622361944 60: 0.0011449576 0.2711150754 1.0005313342 1.2945078734 0.0049027225 70: 1.2967745569 1.3284619999 1.3296922488 0.1410079704 0.5530823827 80: 0.5530384607 0.8171627721 0.0219521770 0.0184394043 0.1196398067 90: 0.0948523432 0.1541841695 0.0483069438 1.2513933889 0.3109290854 100: 0.0000671271 0.1194574394 1.2564956763 1.0428230334 0.7428865400当 R > 2.57 时,该系统表现出混沌行为。系统本身是病态的,因此无法重构计算以使用浮点算术迭代系统。尽管我们不能指望我们的浮点算法正确处理病态问题,但我们可以要求它们报告与解决方案相关的错误范围,以至少警示我们可能存在的问题。例如,在解线性方程组时(见第 9.5 节),我们可以计算一种称为条件数的东西:这个量可以用来限制结果解的误差。
病态条件不仅仅是一个理论可能性。天体物理学家已经确定我们的太阳系是混沌的。冥王星轨道的轨迹是混沌的,就像木星行星、哈雷彗星和小行星带轨迹的运动一样。在与金星的近距离接触中,水星可能在不到 35 亿年内被从太阳系中抛出!
计算特殊函数。 在学习微积分时,我们推导出用于计算指数和三角函数的收敛泰勒级数公式(例如,exp(x),log(x),sin(x),cos(x),arctan(x)等)。然而,在数字计算机上应用这些公式时必须非常小心。并非所有特殊函数都内置在 Java 的 Math 库中,因此有时必须自己创建。举例,误差函数。
数值分析家们为计算科学应用中出现的经典函数(例如,双曲三角函数、伽玛函数、贝塔函数、误差函数、贝塞尔函数、雅可比椭圆函数、球谐函数)推导出了准确、精确和高效的算法。我们强烈建议使用这些经过验证的方法,而不是设计自己的临时程序。
真实世界的数值灾难。
我们上面讨论的例子相当简单。然而,这些问题在实际应用中会出现。如果处理不当,灾难可能很快发生。
阿丽亚娜 5 号火箭。 阿丽亚娜 5 号火箭在欧洲航天局发射后 40 秒爆炸。这是经过十年和 70 亿美元的研发后的首次飞行。传感器报告的加速度如此之大,以至于导致重新校准惯性导航的程序部分溢出。64 位浮点数被转换为 16 位有符号整数,但数字大于 32767,转换失败。意外的溢出被一个通用系统诊断捕获,并将调试数据转储到用于引导火箭发动机的内存区域。控制被切换到备用计算机,但这台计算机也有相同的数据。这导致了一次试图纠正不存在问题的激烈尝试,导致了火箭发动机与支架分离,导致了阿丽亚娜 5 号的终结。
爱国者导弹事故。 1991 年 2 月 25 日,一枚美国爱国者导弹未能追踪和摧毁一枚伊拉克飞毛腿导弹。相反,它击中了一个陆军兵营,造成 26 人死亡。后来确定原因是由于以十分之一秒为单位测量时间导致的不准确计算。由于使用了 24 位浮点数���无法准确表示 1/10。用于解决问题的软件于 2 月 26 日抵达达赫兰。这里有更多信息。
英特尔 FDIV 错误 五代芯片硬件浮点除法电路中的错误。英特尔于 1994 年 7 月发现,数学教授于 1994 年 9 月重新发现并公开。1994 年 12 月英特尔召回成本为 3 亿美元。1997 年发现另一个浮点错误。
斯莱普纳石油钻井平台沉没。 价值 7 亿美元的斯莱普纳 A 平台用于生产石油和天然气,在 1991 年 8 月在北海泄漏并沉没。有误差的有限元近似低估了剪切应力 47% 参考。
温哥华证券交易所。 温哥华证券交易所指数在累积了 22 个月的四舍五入误差后被低估了超过 50%。明显的算法是在每次交易后将所有股票价格相加。然而,“聪明”的分析师决定更高效地重新计算指数,方法是在每次交易后添加股票的净变化。这个计算使用四位小数并将结果截断(而非四舍五入)到三位。参考链接
教训。
为了精确性,请使用
double而不是float。只有在真正需要节省内存并且意识到与精度相关的风险时才使用
float。通常情况下,这不会加快速度,有时甚至会减慢速度。谨慎计算两个非常相似值的差异,并在随后的计算中使用结果。
谨慎添加两个数量的数量级差异很大。
谨慎多次重复进行略有不准确的计算。例如,随时间计算行星位置的变化。
设计稳定的浮点算法非常不容易。在有库可用时请使用库。
Q + A
Q. 有关浮点数的任何好参考资料吗?
A. 这里有两篇关于浮点精度的文章:计算机科学家应该了解的浮点运算知识 由大卫·戈德堡(David Goldberg)撰写,以及由图灵奖获得者威廉·卡恩合著的Java 的浮点数如何伤害每个人。这是维基百科关于数值分析的条目。
Q. 如何将 IEEE 位表示转换为双精度浮点数?
A. 这里有一个十进制到 IEEE 转换器。在 Java 中,要获取变量 x 的 IEEE 754 位表示,使用 Double.doubleToLongBits(x)。根据Code Project的说法,要获取大于 x 的最小双精度数(假设 x 是正的且有限的),使用 Double.longBitsToDouble(Double.doubleToLongBits(x) + 1)。
有没有直接的方法来检查整数类型的溢出?
不。整数类型在任何情况下都不指示溢出。当分母为零时,整数除法和整数余数会抛出异常。
如果我输入一个太大的数字,比如
1E400,会发生什么?Java 返回错误消息“浮点数太大”。
浮点类型有什么不同?
溢出的操作会评估为正无穷大或负无穷大。下溢的操���会导致正零或负零。数学上没有明确定义的操作会评估为 NaN(不是一个数字),例如
0.0/0.0,Math.sqrt(-3.0),Math.acos(3.0),Math.log(-3.0)和Math.pow(8, 1.0/3.0)。如何测试我的变量是否具有 NaN 值?
使用方法
Double.isNaN()。请注意,NaN 是无序的,因此涉及一个或两个 NaN 的比较操作,如 >、< 和 = 总是评估为 false。任何涉及 NaN 的 != 比较都评估为 true,即使 (x != x),当 x 是 NaN 时也是如此。-0.0 和 0.0 有什么区别?
两者都是表示零的方式。如果 x = 0.0 并且 y = -0.0,那么
(x == y)。然而,1/x 产生 Infinity,而 1/y 产生 -Infinity。程序 NegativeZero.java 说明了这一点。它给出了一个令人惊讶的反例,即如果(x == y),那么(f(x) == f(y))。log 0 = -infinity,log (-1) = NaN。log(ε) vs. log(-ε)。我听说程序员永远不应该比较两个实数是否完全相等,而应该始终使用类似于 if |a-b| < ε 或 |a-b| < ε min(|a|, |b|) 的测试。这是正确的吗?
这取决于情况。如果您知道浮点数是精确可表示的(例如,1/4 的倍数),那么可以进行精确相等的比较。如果浮点数不能精确表示,则通常首选相对误差方法(但如果数字非常接近零,则可能失败)。绝对值方法可能会产生意想不到的后果。不清楚要使用什么值的 ε。如果 a = +ε/4,b = -ε/4,我们真的应该认为它们相等吗。传递性不成立:如果 a 和 b 是“相等的”,b 和 c 是“相等的”,那么 a 和 c 可能不是“相等的”。
Java 使用什么规则来打印双精度数?
通过将所有指数位设置为 1。这里是 java.lang.Double API。它总是至少打印小数点后一位数字。之后,它使用尽可能多的数字(但不超过)来区分数字与最近可表示的双精度数。
零、无穷大和 NaN 如何使用 IEEE 754 表示?
通过将所有指数位设置为 1。正无穷 = 0x7ff0000000000000(所有指数位为 1,符号位为 0,所有尾数位为 0),负无穷 = 0xfff0000000000000(所有指数位为 1,符号位为 1,所有尾数位为 0),NaN = 0x7ff8000000000000(所有指数位为 1,至少一个尾数位设置为 1)。正零 = 所有位为 0。负零 = 所有位为 0,除了符号位为 1。
使用双精度 IEEE 浮点数存储 0.1 时表示的确切值是多少。
它是 0.1000000000000000055511151231257827021181583404541015625。您可以使用
System.out.println(new BigDecimal(0.1));来自己查看。什么是
StrictMath?Java 的
Math库保证其结果与真实答案的 1 或 2 ulps 之内。Java 的StrictMath保证所有结果准确到真实答案的 1/2 ulp。速度与准确性的经典权衡。什么是
strictfp修饰符?在声明类或方法时也可以使用
strictfp修饰符。这确保浮点结果在不同的 JVM 中是逐位准确的。IEEE 标准允许处理器在结果溢出时使用更高精度进行中间计算。strictfp要求每个中间结果被截断为 64 位双精度格式。这可能会对性能造成重大影响,因为英特尔奔腾处理器寄存器使用 IEEE 754 的 80 位双扩展格式。没有多少人会使用这种模式。编译器标志
javac -ffast-math是做什么的?它放宽了 IEEE 的一些舍入要求。它使一些浮点计算更快。
整数是否总是使用 IEEE 浮点数精确表示?
是的,除了 52 位尾数的溢出。使用类型
double时,不精确表示的最小正整数是 2⁵³ + 1 = 9,007,199,254,740,993。当 a 和 b 是浮点数时,(a + b) 是否总是等于 (b + a)?
是的。
x / y 是否总是等于相同的值,不受我的平台影响?
是的。IEEE 要求操作(+ * - /)必须精确执行,然后四舍五入到最接近的浮点数(如果出现平局,则使用银行家舍入:四舍五入到最接近的偶数)。这提高了可移植性,但以效率为代价。
(x - y)是否总是等于(x + (-y))?是的。由 IEEE 754 保证。
-x 是否总是等于 0 - x?
几乎是,除非 x = 0。那么 -x = -0,但 0 - x = 0。
(x + x == 2 * x) 是否成立?(1 * x == x)?(0.5 * x == x / 2)?
是的,由 IEEE 754 保证。
x / 1000.0 是否总是等于 0.001 * x?
不是。例如,如果 x = 1138,则它们是不同的。
如果
(x != y),那么z = 1 / (x - y)是否总是保证不会产生除以零的情况。是的,由 IEEE 754 保证(使用非规格化数)。
(x >= y)是否总是等同于!(x < y)?如果
x或y中有一个是NaN,或者两者都是NaN,则不会。
问:为什么不使用十进制浮点而不是二进制浮点?
- 十进制浮点相对于二进制浮点有几个优势,特别是对于金融计算。但是,通常需要大约 20% 的额外存储空间(假设使用二进制硬件存储),而且生成的代码速度略慢。
问:为什么不使用固定点表示而不是浮点?浮点?
- 固定点数在小数点后具有固定数量的数字。可以使用整数算术表示。浮点数具有动态精度的滑动窗口,提供了很大的动态范围和高精度。固定点数用于一些没有 FPU 的嵌入式硬件设备(为了节省成本),例如音频解码或 3D 图形。当数据受到一定范围的限制时,适用。
有关 Java 中数值计算的好资源吗?
- Java numerics 提供了关于 Java 数值计算的信息的焦点。JSci:Java 中用于数值计算的免费科学 API。
问:为什么 Java 的 Math.sin 和 Math.cos 函数比它们的 C 对应函数慢?
- 在 -pi/4 到 pi/4 范围内,Java 使用硬件 sin 和 cos 函数,因此它们的执行时间与 C 中的大致相同。超出此范围的参数必须通过取模 pi 转换为此范围。正如 James Gosling 在 x87 平台上的博客中所述,硬件 sin 和 cos 使用一种近似值,使计算速度快,但不符合 IEEE 所需的精度。C 实现通常对所有参数使用硬件 sin 和 cos,以速度换取 IEEE 符合性。
练习
以下代码片段的结果是什么?
double a = 12345.0; double b = 1e-16; System.out.println(a + b == a);列出具有 1 个符号位、3 个指数位和 2 个尾数位的 6 位浮点数的所有可表示数字。以下代码片段的结果是什么?
以下代码片段打印出多少个值?
for (double d = 0.1; d <= 0.5; d += 0.1) System.out.println(d); for (double d = 1.1; d <= 1.5; d += 0.1) System.out.println(d);*答案:*九(第一个循环中五个,第二个循环中四个)。输出为:
0.1 0.2 0.30000000000000004 0.4 0.5 1.1 1.2000000000000002 1.3000000000000003 1.4000000000000004执行以下代码片段时打印出什么数字,其中 N = 25?
for (int i = 0; i < N; i++) { int counter = 0; for (double x = 0.0; x < i; x += 0.1) counter++; if (counter != 10*i) System.out.print(i + " "); }*答案:*1 5 6 7 8 9 10 11 12 13 14 15 16 17 18。你不太可能在不输入的情况下预测这个。避免使用浮点数来检查循环继续条件。
以下代码片段的结果是什么?
for (double t = 0.0; t < 100.0; t += 0.01) System.out.println(t);*答案:*几乎符合你的预期,但最不显著的两三位有很多噪音。
以下代码片段打印什么?
System.out.println(0.9200000000000002); System.out.println(0.9200000000000001);两次打印出 0.9200000000000002!
找到一个值 x,使得 (0.1 * x) 与 (x / 10) 不同。
找到一个实数 a,使得 (a * (3.0 / a)) 不等于 3.0。*答案:*如果 a = 0,则第一个表达式计算结果为 NaN。
找到一个实数 a,使得 (Math.sqrt(a) * Math.sqrt(a)) 不等于 a。*答案:*a = 2.0。
找到浮点值,使得 (x/x != 1),(x - x != 0),(0 != 0 * x)。*答案:*考虑 x 的值为 0、+- 无穷大或 NaN。
找到浮点值
x和y,使得x >= y为true,但!(x < y)为false。*提示:*考虑
x或y(或两者)的值为NaN的情况。FloatingPoint.java 中以下代码片段的结果是什么?
float f = (float) (3.0 / 7.0); if (f == 3.0 / 7.0) System.out.println("yes"); else System.out.println("no");来自 S. M. Rump 的《计算机结果有多可靠》的示例。查看程序 Rump.java。
计算 aa - 2bb,其中 a = 665857,b = 470832,类型为
float。然后计算 aa - 2.0bb。使用double类型重复。Java 的答案为 float:0.0 和 11776.0。Java 的答案为 double:1.0。精确答案为:1.0。计算 9x⁴ - y⁴ + 2y²,其中 x = 10864,y = 18817,类型为
double。Java 答案为 2.0。精确答案为 1.0。计算 p(x) = 8118x⁴ - 11482x³ + x² + 5741x - 2030,其中 x = 0.707107 是
float和double类型。float 的 Java 答案:1.2207031E-4。double 的 Java 答案:-1.9554136088117957E-11。精确答案:-1.91527325270... * 10^-7。
您认为使用
double类型来存储总是以 1/64 递增的股价是个好主意吗?解释为什么或为什么不��答案:是的,这是完全可以的,因为这些值在二进制浮点数中可以精确表示。如果股价以十进制表示,例如 45.10,那可能会导致舍入误差。修复 Exponential.java,使其能正确处理负输入,使用文本中描述的方法。
从左到右求和以下实数。使用浮点运算,并假设您的计算机只存储三位小数的精度。
0.007 0.103 0.205 0.008 3.12 0.006 0.876 0.005 0.015使用优先队列算法重复。使用 Kahan 的算法重复。
编写一个程序,读取一系列银行账户值,并打印平均值,精确到最接近的一分钱,使用银行家舍入法。输入值将以空格分隔,并使用两位小数。提示:避免使用
Double.parseDouble。100.00 489.12 1765.12 3636.10 3222.05 3299.20 5111.27 3542.25 86369.18 532.99以下 代码片段 打印什么?
long x = 16777217; // 2²⁴ + 1 System.out.println(x); float y = 16777217; DecimalFormat df = new DecimalFormat("0.00000000000"); System.out.println(df.format(y));答案:16777217 和 16777216.00000000000。2²⁴ + 1 是最小的正整数,使用
float类型无法精确表示。能找到值 a、b 和 c,使得 Math.sqrt(bb - ac) 无效(NaN),但 (bb < ac) 为假吗?Kahan。
以下 两个循环 各有什么问题?
int count1 = 0; for (float x = 0.0f; x < 20000000.0f; x = x + 1.0f) count1++; System.out.println(count1); int count2 = 0; for (double x = 0.0; x < 20000000.0; x = x + 1.0) count2++; System.out.println(count2);答案:当 x = 16777216.0f 时,第一个代码片段会陷入无限循环,因为使用
float类型无法精确表示 16777217.0f,而 16777216.0f + 1.0f = 16777216.0f。第二个循环保证能正确运行,但使用int类型的变量作为循环变量可能更有效率。定义 e 的一种方式是当 n 趋近无穷大时的极限 (1 + 1/n)^n。编写一个计算程序,使用这个公式估计 e。对于哪个值的 n 你得到了最好的 e 近似值?提示:如果 n 太大,那么在浮点运算中 1 + 1/n 等于 1。
以下
Math.max()实现有什么问题。public static double max(double x, double y) { if (x >= y) return x; return y; }答案:double 不能正确处理 NaN。根据这个定义,max(0, NaN) 是 NaN,但 max(NaN, 0) 是 0。应该是 NaN。
并非所有减法都会导致灾难性的消除。演示当 x 接近 y 时,表达式 x² - y² 会出现灾难性的消除。然而,改进后的表达式 (x + y)(x - y) 仍然减去几乎相等的量,但这是一种良性的消除。
(Goldberg) (1 + i/n)n 在涉及利息的金融计算中出现。重写为 e(n ln (1 + i/n))。现在的技巧是计算 ln(1+x)。当 x << 1 时,Math.log(1+x) 不准确。
if (1 + x == 1) return x else return (x * Math.log(1 + x)) / (1 + x) - 1.在 Java 1.5 中的替代方法:
Math.log1p(x)要计算 e^x - 1,使用
Math.expm1(x)。对于 x 附近的值,Math.expm1(x) + 1 比 Math.exp(x) 更准确。灾难性的消除:f(x) = e^x - sin x - cos x 在 x = 0 附近。使用 x² + x³/3 作为 x = 0 附近的近似值。
灾难性的消除:f(x) = ln(x) - 1。对于 x 附近的 e 使用 ln(x/e)。
灾难性的消除:f(x) = ln(x) - log(1/x)。对于 x 附近的 1 使用 2 ln(x)。
灾难性的消除:x^(-2)(sinx - e^x + 1)。
找到一个 x 的值,使得
Math.abs(x)不等于Math.sqrt(x*x)。数值稳定性。 黄金分割数 φ = sqrt(5)/2 - 1/2 = 0.61803398874989484820... 它满足方程φN = φ(N-2) - φ^(N-1)。我们可以使用这个递归来计算φ的幂,从 phi0 = 1,phi1 = φ开始,并通过迭代递归来计算φ的连续幂。然而,浮点舍入误差会在 N = 40 左右时影响计算。编写一个程序 Unstable.java,读取一个命令行参数 N,并打印出使用上述递归和
Math.pow()计算的φ^N。运行你的程序,N 分别为 40 和 100,看看舍入误差的后果。
创意练习
舍入误差。 求和。相对误差 = |x - x'| / |x| = 与单位无关。一个想法是创建一个数字的优先级队列,并重复添加两个最小值并插入回优先级队列。 (参见练习 XYZ。) 更简单更快的替代方案:Kahan 求和公式 (Goldberg,定理 8)。利用(x + y) + z 不等于 x + (y + z)这一事实的有用算法的例子。优化编译器最好不要重新排列项。
double c = 0.0, sum = 0.0, y; for (int i = 0; i < N; i++) y = term[i] - c; c = ((sum + y) - sum) - y; sum = t;推荐方法:排序和添加。
柯西-施瓦茨不等式。 柯西-施瓦茨不等式保证对于任意实数 x[i]和 y[i],有
(x1x1 + ... xnxn) × (y1y1 + ... ynyn) ≥ (x1y1 + ... xnyn)2特别地,当 n = 2,y[1] = y[2] = 1 时,我们有
2(x1x1 + x2x2) ≥ (x1 + x2)2编写一个程序 CauchySchwartz.java,读取两个整数 x[1]和 x[2],并验证浮点数中的恒等式不一定成立。尝试 x[1] = 0.5000000000000002 和 x[2] = 0.5000000000000001
切比雪夫距离计算逼近。 使用以下逼近计算 p 和 q 之间的距离,而不进行昂贵的平方根运算。sqrt(dx² + dy²) = max(|dx|, |dy|) + 0.35 * min(|dx|, |dy|)。有多精确。
毕达哥拉斯。 编写一个程序 Pythagoras.java,读取两个实数命令行参数 a 和 b,并打印出 sqrt(aa + bb)。尝试避免溢出。例如,如果|a| >= |b|,则计算|a| sqrt(1 + (b/a)*(b/a))。如果|a| < |b|,则进行类似操作。例如:x = y = DBL_MAX / 10.0;
Java 1.5 以来的可靠实现:
Math.hypot(a, b)。溢出。 以下代码片段的结果是什么?
double a = 4.0, b = 0.5, c = 8e+307; System.out.println((a * b) * c); System.out.println(a * (b * c));优化编译器。 一个优化编译器... 必须谨慎地应用代数定律到计算机程序中,因为这可能导致灾难性的结果。证明浮点数中,加法不是可结合的。即找到值 a、b 和 c,使得((a + b) + c) != (a + (b + c))。还证明分配律不一定适用,找到值 a、b 和 c,使得(a * (b + c)) != (a * b + a * c)。问题在于乘法更加明显 ((a * b) * c) != (a * (b * c))。给出一个简单的例子。
整数运算。 在 Java 中,整数加法和乘法在数学上是可结合的,例如,(a+b)+c 总是等于 a+(b+c)。
调和和。 重新进行调和和计算,但从右到左而不是从左到右求和。这说明加法不是可结合的 (a + b) + c vs. a + (b + c),因为我们根据从左到右或从右到左求和得到不同的答案。
圆周率。 比较以下两种逼近数学常数 pi 的方法。当 p_k > p_k-1 时重复。序列 p_k 收敛到 pi。
s_6 = 1 s_6 = 1 s_2k = sqrt(2 - sqrt((2-s_k)(2+s_k)) s_2k = s_k / sqrt(2 + sqrt((2-s_k)(2+s_k)) p_k = k * s_k / 2 p_k = k * s_k / 2第二个公式的行为要好得多,因为它避免了灾难性的消除 2 - sqrt((2-s_k)(2+s_k))。
Jean-Michael Muller 的例子。 考虑序列 x[0] = 4,x[1] = 4.25,x[n] = 108 - (815 - 1500/x[n-2]) / x[n-1]。程序 Muller.java 试图估计 n 变大时 x[n]收敛到的值。它计算值为 100.0,但正确值为 5。
另一个 Kahan 的例子。 迷思:如果你不断增加精度并且答案收敛,那么它就是正确的。反例:E(0) = 1,E(z) = (exp(z) - 1) / z。Q(x) = |x - sqrt(x² + 1)| - 1/(x + sqrt(x² + 1)),H(x) = E(Q(x)²)。计算 x = 15.0, 16.0, 17.0, 9999.0 时的 H(x)。使用更多精度,比如使用
BigDecimal。分割有理数 (a + ib) /(c + id)。参见 Smith 的公式(11)。
对于整数类型,我们必须注意溢出问题。溢出的相同原则也适用于浮点数。例如,要计算 sqrt(xx + yy),如果 x < y,则使用 fabs(y) * sqrt(1+(x/y)*(x/y)),如果 x > y,则使用类似的方法。x = y = DBL_MAX / 10.0;
Gamma 函数。 编写一个程序 Gamma.java,接受一个命令行参数 x,并打印出 Gamma(x) 和 log Gamma(x) 至 9 个有效数字,其中 Gamma(x) 是 gamma 函数:
|
Gamma(x) = integral( tx-1 e-t, t = 0..infinity )|
Gamma 函数是阶乘函数的连续版本:如果 n 是正整数,则 n! = Gamma(n+1)。使用 Lanczos' formula:
|
Gamma(x) ≈ (x + 4.5)x - 1/2 * e-(x + 4.5) * sqrt(2 π) * [ 1.0 + 76.18009173 / (x + 0) - 86.50532033 / (x + 1) + 24.01409822 / (x + 2) - 1.231739516 / (x + 3) + 0.00120858003 / (x + 4) - 0.00000536382 / (x + 5) ]|
对于 x > 1,这个结果精确到 9 个有效数字。
Siegfried M. Rump 的例子。 编写一个程序 Remarkable.java 来计算
y = 333.75 b6 + a2(11 a2 b2 - b6 - 121 b4 - 2) + 5.5 b8 + a/(2b)对于 a = 77617 和 b = 33096。尝试使用不同的浮点精度。可以重写
x = 5.5 b8 z = 333.75 b6 + a2(11 a2 b2 - b6 - 121 b4 - 2) y = z + x + a/(2b)结果表明 x 和 z 有 35 位数字是相同的。
z = -7919111340668961361101134701524942850 x = +7919111340668961361101134701524942848在 Java(在 Sun Sparc 上)使用单精度得到的答案是 6.338253 ×10²⁹,使用双精度是 a/(2b) = 1.1726039400531787。真正的答案是 -2 + a/(2b) = -54767/66192 = -0.82739606....,但 z + x = -2 项会在没有至少使用 122 位精度的情况下被灾难性地取消!我们使用 Java 的 Math 库中的
BigDecimalADT 来匹配这个答案。论文 A Remarkable Example of Catastrophic Cancellation Unraveled 描述了这个著名的公式,展示了为什么在不同精度下获得相同结果并不意味着数学上的准确性。此外,即使在符合 IEEE 754 标准的平台上(Intel、Sun Sparc),根据中间舍入模式是 24、53 还是 64 位,你可能会得到不同的答案。这个引人注目的例子是由 IBM 的 Siegfried M. Rump 为 S/370 架构构建的。
三角形的面积。 编写一个程序
TriangleArea.java,接受三个命令行输入 a、b 和 c,表示三角形的边长,并使用海伦公式打印出三角形的面积:area = sqrt(s(s-a)(s-b)(s-c)),其中 s = (a + b + c) / 2。当 a 接近 b 时不准确。使用 1960 年代的公式进行改进:排序 a >= b >= c。如果 (c + b < a) 则不是合法三角形。1/4 * sqrt((a+(b+c)) * (c-(a-b)) * (c+(a-b)) *(a+(b-c)))。例如:a = b = 12345679.0,c = 1.01233995。二次方程公式。 二次方程 是一个用于找到 ax² + bx + c 的实根的小学公式。
discriminant = Math.sqrt(b*b - 4*a*c); root1 = -b + discriminant / (2*a); root2 = -b - discriminant / (2*a);一个初学者程序员需要计算二次方程的根,可能会天真地应用这个公式。当然,我们应该小心处理 a = 0 的情况,以避免除以零。此外,我们应该检查判别式 b² - 4ac。如果是负数,则没有实根。然而,这个公式的主要问题是,如果 a 或 c 非常小,那么其中一个根将通过从几乎相等的数量中减去 b 来计算。计算根的正确方法是
if (b > 0) q = -0.5 * (b + discriminant); else q = -0.5 * (b - discriminant); root1 = q / a; root2 = c / q;例如,x² - 3000000x + 2 的根是 r1 和 r2。当使用双精度算术时,小根(6.665941327810287E-7)的小学公式仅提供 3 位精度,而好的公式提供 12 位精度(6.666666666668148E-7)。当 b = -30000000 时,情况恶化。小学方法现在只有两位精度(6.705522537231445E-8)与 12 位精度(6.666666666666681E-8)。当 b = -300000000 时,小学方法的精度为零(0.0),而不是 16(6.666666666666667E-9)。
考虑用表格总结这一点。
有益的消除。 减法中的大量消除并不总是导致灾难性的消除。消除误差并不总是导致不准确。如果 x 接近 1,则 f(x) = (x-1) / (e^(x-1) - 1)的直接实现不准确。令人惊讶的是,下面的解决方案无论 x 的大小如何都能实现完全的工作精度。通过有益的消除在极端巧妙中获得的最终计算答案比中间结果更准确!误差分析可能非常微妙和不明显。
double y = x - 1.0; double z = Math.exp(y); if (z == 1.0) return z; return Math.log(z) / (z - 1.0);程序 BeneficialCancellation.java 实现了直接和数值精确的方法。
1 - cos(x)。比较天真地计算 1 - cos(x)和使用公式 cos(x) = 2 sin(x/2)²。正确计算的方法。
平方根消除。 设计一个准确的表达式,表示(b² + 100)的平方根与 b 之间的差异。解决方案:如果 b 为负数或相对较小,只需做明显的事情。如果 b 远大于 100,可能会发生灾难性的消除。差异等于 b(sqrt(1 + 100/b²) - 1)。如果 x 非常小,那么可以安全地用 1 + x/2 来近似 sqrt(1 + x)。现在,差异大约为 b (100 / b²) / 2 = 50 / b。
9.2 符号方法
原文:
introcs.cs.princeton.edu/java/92symbolic译者:飞龙
本节正在进行重大改造。
符号积分.
在初级微积分中,我们学习了各种不同函数的求导和积分规则。求导是一个机械过程,有大约半打通用规则。
和差法则. (f(x) ± g(x))′ = f′(x) ± g′(x).
乘积法则. (f(x) g(x))′ = f(x) g′(x) + g(x)f′(x).
商法则. (f(x)/g(x))′ = (f′(x) g(x) - g′(x) f(x)) / g²(x).
幂法则. (xk)′ = kx(k-1).
链式法则. (f(g(x))′ = f(g(x)) g′(x).
还有一些特殊函数的规则通常是推导一次然后记忆的,例如,sin(x)的导数是 cos(x);exp(x)的导数是 exp(x);ln(|x|)的导数是 1/x;sec(x)��导数是 sec(x) tan(x);arcsin(x)的导数是(1 - x²)^(-1/2)。
另一方面,我们了解到不定积分是一个更难的问题。微积分学生通常学习一组特定的模式匹配规则,以找到一个变量函数的反导数。(以下,我们假设反导数的常数项为零。)
常数法则. cf(x)的反导数是 c 乘以 f(x)的反导数。
求和法则. f(x) + g(x)的反导数是 f(x)和 g(x)的反导数之和。
多项式. f(x) = xb(其中 b ≠ 0 且 b ≠ 1)的反导数是 x(b-1) / (b-1)。通过将此规则与前两个规则结合,我们可以确定任何多项式的反导数。
查表法. 记住各种简单函数的反导数。例如:sin(x),tan(x),arctan(x)。例如:ax 的反导数是 ax / ln a。
替换法. 当 f(x) = g(h(x))时通常很有用。例如:f(x) = sin(√x)或 f(x) 1/(1 + x²),然后替换 x = tan t。并不总是明显应该替换什么。通常当你发现类似 sqrt(x² ± a²)的项时,三角替换有所帮助。
分部积分. f(x)g′(x)的反导数等于 f(x)g(x)减去 g(x)f′(x)的反导数。需要运气使函数分解为所需的项。还需要识别正确的模式。例如:x ex 或 x² sin x 的反导数。有趣的例子强调了特定性:f(x) = ex cos x 和 f(x) = ln(x)。
对数法则. 如果 f(x) = g′(x) / g(x),那么 f(x)的反导数是 ln(|g(x)|)。例如:f(x) = tan(x) = sin(x) / cos(x) = -g'(x) / g(x),其中 g(x) = cos(x)。因此,f(x)的反导数是 ln|sec(x)|。
部分分式分解. 例如:f(x) = (x⁴ - x³) / (x² + 2)(x - 3)。参考链接。需要将多项式除以多项式,使得分母的次数不小于分子的次数。需要因式分解并将多项式简化为最简形式。(欧几里得算法的泛化。)需要解一个线性方程组。多重根使事情变得更加复杂。该方法使我们能够积分两个多项式的所有分数。
更多特定规则. 这些规则是不够的。例如:f(x) = 1 / (x³ + x + 1).
编写一个计算机程序来执行符号积分似乎是一项艰巨的任务。在 20 世纪 60 年代初,只有人类才能找到函数的不定积分,除了最琐碎的情况。一种方法是模仿初级微积分课程中教授的方法 - 建立一个巨大的已知积分表并尝试模式匹配。在 19 世纪,Liouville 寻求了一个用于积分初等函数的算法。在 19 世纪,Hermite 发现了一个用于积分有理函数的算法 - 使用部分分数作为基本原始函数。初等函数是指可以通过有理值函数的有限嵌套对数、指数和代数数或函数序列获得的函数。由于√-1 是初等的,所有“常见”的三角和反三角函数(sin、cos、arctan)都属于这一类,因为它们可以使用虚数的指数和对数重新表达。并非所有初等函数都有初等不定积分,例如,f(x) = exp(-x²),f(x) = sin(x²),f(x) = x^x,f(x) = sqrt(1 + x³)。
对于积分初等函数(如果存在)找到一个有限的方法是符号积分的中心问题,持续了许多十年。Hardy(1916)指出“有理由认为不可能给出这样的方法”,也许预示了图灵关于不可判定性的后续结果。1970 年,Robert Risch 解决了这个问题,提供了一个可证明正确且有限的方法,用于积分任何不定积分是初等的初等函数。(实际上,他的方法并不是普遍适用的。要应用它,您需要解一个困难的微分方程。已经付出了大量努力来解决这个微分方程,以适用于各种初等函数。)这种方法的改进在现代符号代数系统(如 Maple 和 Mathematica)中很常见。工作还扩展到处理一些“特殊函数”。依赖于代数和数论的深刻思想。这些技术使数学家能够找到以前不知道或未列入表格的新积分,还能纠正已知积分集合中的错误!对于特别好奇的读者,这里有一个符号积分教程。
多项式。
多项式是一种非常特殊的初等函数类型。我们的目标是能够编写能够操作多项式并执行计算的程序,例如:
我们还希望能够评估给定值 x 的多项式。对于 x = 0.5,这个方程的两边的值都是 1.1328125。乘法、加法和多项式求值是许多数学计算的核心。许多应用程序用于简单操作(加、乘),更复杂操作(除法、gcd)有一些令人惊讶的应用,例如,Sturm 算法用于找到给定区间内多项式的实根数,解多项式方程组,Groebner 基础。在系统和控制理论中广泛使用,因为常见信号的拉普拉斯变换导致两个多项式的比值。
多项式 API。 第一步是为多项式 ADT 定义 API。我们从系数和指数为整数的多项式开始。对于像多项式这样被充分理解的数学抽象,规范是如此清晰以至于无需言明:我们希望 ADT 的实例行为与充分理解的数学抽象完全相同。不可变。
public Polynomial(int coef, int exp)
public Polynomial plus(Polynomial b)
public Polynomial minus(Polynomial b)
public Polynomial times(Polynomial b)
public Polynomial div(Polynomial b)
public Polynomial compose(Polynomial b)
public Polynomial differentiate(Polynomial b)
public int evaluate(int x)
public int degree()
public int compareTo(Polynomial b)
public String toString()
一个示例客户端。 程序 Binomial.java 从命令行读取一个整数 N,并打印出(1+x)^N 的展开式。
Polynomial one = new Polynomial(1, 0); // 1 = 1 * x⁰
Polynomial x = new Polynomial(1, 1); // x = 1 * x¹
Polynomial binomial = one;
for (int i = 0; i < N; i++)
binomial = binomial.times(x.plus(one));
System.out.println(binomial);
实现。 程序 Polynomial.java 使用整数数组coef[]表示一个具有整数次数deg的一元多项式,其中coef[i]记录x^i的系数。
public class Polynomial {
private int[] coef; // coefficients
private int deg; // degree of polynomial
我们提供一个构造函数,它接受两个参数a和b,并创建单项式 ax^b。辅助方法degree()计算多项式的实际次数(如果a为零,则为零)。
public Polynomial(int a, int b) {
coef = new int[b+1];
coef[b] = a;
deg = degree();
}
要添加两个多项式a和b,我们循环遍历这两个数组并相加它们的系数。结果多项式的最大次数是a.deg + b.deg。我们将c初始化为次数为N且所有系数为零的多项式。我们要小心地保持不变式,即c.deg是多项式的实际次数(如果两个被加数的首项系数互相抵消,则可能与a.deg + b.deg不同)。
public Polynomial plus(Polynomial b) {
Polynomial a = this;
Polynomial c = new Polynomial(0, Math.max(a.deg, b.deg));
for (int i = 0; i <= a.deg; i++) c.coef[i] += a.coef[i];
for (int i = 0; i <= b.deg; i++) c.coef[i] += b.coef[i];
c.deg = c.degree();
return c;
}
要将两个多项式相乘,我们使用基于分配律的基本算法。我们将一个多项式乘以另一个多项式中的每个项,使得 x 的幂匹配,然后将这些项相加以获得最终结果。
要在特定点(比如x = 3)评估多项式,我们可以将每个系数乘以适当的x幂,然后将它们全部相加。evaluate的实现使用了一种称为霍纳法则的直接最优算法,该算法基于括号化。
以下代码片段使用霍纳法则执行多项式评估。
public int evaluate(int x) {
int p = 0;
for (int i = deg; i >= 0; i--)
p = coef[i] + (x * p);
return p;
}
有理数算术。
程序 Rational.java 是一个非负有理数的抽象数据类型。它实现了以下接口��为了简化分数,我们使用欧几里得的最大公约数算法作为子程序来找到两个整数的最小公倍数(lcm)。
Rational(int num, int dem) // initialize
public double num() // return numerator
public double den() // return denominator
public String toString() // print method
public Rational plus(Rational b) // return this Rational + b
public Rational times(Rational b) // return this Rational * b
任意精度算术。
java.math库提供了两个 ADT BigInteger 和 BigDecimal,它们支持任意精度算术。
Maple。
Maple 是一个流行的符号数学计算系统。它由滑铁卢大学的一个研究小组开发,并在许多大学提供。它可用于微积分、线性代数、抽象代数、微分方程、绘制函数和数值计算。它还是一种带有条件、循环、数组和函数的通用编程语言。
以下会展示基本算术和内置函数。请注意,给出的答案是精确的,除非我们明确转换为浮点数,否则不会进行浮点数近似。所有语句以分号结尾(在这种情况下结果会打印到屏幕上)或冒号结尾(在这种情况下结果会被抑制)。
% maple
|\^/| Maple V Release 5 (WMI Campus Wide License)
._|\| |/|_. Copyright (c) 1981-1997 by Waterloo Maple Inc. All rights
\ MAPLE / reserved. Maple and Maple V are registered trademarks of
<____ ____=""> Waterloo Maple Inc.
| Type ? for help.
> # arithmetic
> 1 + 1;
2
> # arbitrary precision arithmetic
> 2¹⁰⁰;
1267650600228229401496703205376
> # rational arithmetic
> # trigometric function
> sin(Pi/6);
1/2
> # exponential function
> exp(1);
exp(1)
# convert to floating point
> exp(1.0);
2.718281828
> # 50 digits of precision
> Digits := 50:
> exp(1.0);
2.7182818284590452353602874713526624977572470937000
> Digits := 10:
> # an error
> 1 / 0;
Error, division by zero
> # complex numbers
> (6 + 5*I)⁴;
-3479 + 1320 I
# built-in functions
> BesselK(1.0, -3.0);
-.04015643113 - 12.41987883 I
> # base 10 logarithm
> log10;
ln(100)
-------
ln(10)
# simplifying
> simplify(log10);
2
# end the session
quit;
Maple 最强大的功能之一是支持符号变量。Maple 使用:=表示赋值语句,因为=保留用于数学相等。
# assignment statements
> m := 10:
> a := 9.8:
> f := m * a;
f := 98.0
> i := 10:
> i := i + 1;
i := 11
> # polynomials
> expand((x+1)⁶);
6 5 4 3 2
x + 6 x + 15 x + 20 x + 15 x + 6 x + 1
> # differentiation
> diff(sin(x*x), x);
2
2 cos(x ) x
> # partial differentiation
> diff((x² - y) / (y³ - 1), x);
x
2 ------
3
y - 1
> # indefinite integration
> int(x³ * sin(x), x);
3 2
-x cos(x) + 3 x sin(x) - 6 sin(x) + 6 x cos(x)
> # definite integration
> int(exp(-x²), x = 0..infinity);
1/2
1/2 Pi
> # series summation
> sum(i, i = 1..100);
5050
factor(sum(i, i = 1..N));
1/2 N (N + 1)
方程求解、数组、条件、循环、函数、库、矩阵,
> # solve equation
> solve(x⁴ - 5*x² + 6*x = 2);
1/2 1/2
-1 + 3 , -1 - 3 , 1, 1
> # solving system of equations
> solve({x² * y² = 0, x - y = 1});
{y = -1, x = 0}, {y = -1, x = 0}, {y = 0, x = 1}, {y = 0, x = 1}
> # find floating point solutions
> fsolve(x⁴ * sin(x) + x³*exp(x) - 1);
.7306279509
> # maximize
> # user-defined functions
> f := x -> x²:
> f(9);
81
> g := x -> sin(x) * exp(x):
> f(g(Pi/6));
2
1/4 exp(1/6 Pi)
> diff(f(g(x)), x);
2 2 2
2 sin(x) exp(x) cos(x) + 2 sin(x) exp(x)
> # absolute value function
> f := proc(x) if x > 0 then x else -x fi; end:
> f(-7);
7
> f(7);
> # recursion
> mygcd := proc(p, q)
if q = 0 then p
else mygcd(q, p mod q)
fi;
end:
> mygcd(1440, 408);
24
> # loops and conditionals
> # print primes of the form 2^i - 1
> for i from 1 to 600 do
if isprime(2^i - 1) then print(i);
fi;
od;
2 3 5 7 13 17 19 31 61 89 107 127 521
> # arrays - Chebyshev polynomials (expand to keep it unfactored)
> p[0] := 1;
> p[1] := x;
> for i from 2 to 10 do
p[i] := expand(2*x*p[i-1] - p[i-2])
od;
> p[10];
10 8 6 4 2
512 x - 1280 x + 1120 x - 400 x + 50 x - 1
> subs(x = 1/6, p[10]);
12223
------
118098
垃圾回收,变量是全局的,因此必须小心不要重复使用 - 可以使用x := 'x'重置。
Maple 中的积分。 当您在 Maple 中对一个函数进行积分时,它会尝试多种不同的积分方法。
多项式。
查表。
启发式:替换、分部积分、部分分式、涉及三角函数和多项式的特殊形式
Risch 算法:Horowitz 简化,Lazard/Rioboo/Trager 方法
见证 Maple 的实际操作,
> infolevel[int] := 2;
> int(1 / (x³ + x + 1), x);
int/indef1: first-stage indefinite integration
int/ratpoly: rational function integration
int/rischnorm: enter Risch-Norman integrator
bytes used=1000360, alloc=786288, time=0.14
int/rischnorm: exit Risch-Norman integrator
int/risch: enter Risch integration
int/risch: the field extensions are
[x]
unknown: integrand is
1
----------
3
x + x + 1
int/ratpoly/horowitz: integrating
1
----------
3
x + x + 1
int/ratpoly/horowitz: Horowitz' method yields
/
| 1
| ---------- dx
| 3
/ x + x + 1
int/risch/ratpoly: Rothstein's method - factored resultant is
3
[[z - 3/31 z - 1/31, 1]]
int/risch/ratpoly: result is
-----
\ 2
) _R ln(x - 62/9 _R + 31/9 _R + 4/9)
/
-----
_R = %1
3
%1 := RootOf(31 _Z - 3 _Z - 1)
int/risch: exit Risch integration
-----
\ 2
) _R ln(x - 62/9 _R + 31/9 _R + 4/9)
/
-----
_R = %1
3
%1 := RootOf(31 _Z - 3 _Z - 1)
使用 Maple。
set Digits := 50; // 50 digits of floating point precision
evalf(exp(1), 30);
fsolve()...
solve()...
mod
argmin
问与答
问:当我在 Maple 中遇到错误时,我无法返回到 Maple 提示符。
答:尝试输入一个分号然后回车。
练习
修改
Rational的toString()方法,以便在分母为 1 时抑制它,例如,5而不是5/1。为多项式 ADT 添加
isOdd()和isEven()方法,以指示多项式是奇数(所有非零系数具有奇数指数)还是偶数(所有非零系数具有偶数指数)。为
Rational添加equals()和compareTo()方法。修改
Polynomial的toString()方法,以便抑制 x¹ 项中的指数和常数项中的 x⁰。一些边界情况要检查:f(x) = 0, 1 和 x。为
Polynomial添加一个equals()方法。使用以下方法避免
Rational中的溢出:.... 检查Rational中的溢出,并在结果将溢出时抛出异常。为
Rational添加一个minus()方法,并支持负有理数。编写一个程序 Taylor.java,创建一个包含 ex、sin x 和 ex sin x 的泰勒展开的前 10 项的多项式(有理系数)。
展开(1-x)(1-x²)(1-x³)(1-x⁴)...(1-x^n)。当 n = 3 时,这是 1 -x - x² + x⁴ + x⁵ - x⁶。在极限情况下,所有系数都是 0、+1 或-1。
创意练习
切比雪夫多项式。 切比雪夫多项式由以下方程的解定义
Tn(x) = cos(n arccos x)尽管解看起来是三角函数的,但其解是 x 的多项式。以下是前几个这样的多项式。一般来说,T(n) = 2x * T(n-1) - T(n-2)。
T0(x) = 1 T1(x) = x T2(x) = 2x2 - 1 T3(x) = 4x3 - 3x切比雪夫多项式在插值理论、逼近理论、数值积分、遍历理论、数论、信号处理和计算机音乐中具有许多特殊的算术性质,它们也源自微分方程:
(1 - x2) y'' - x y' + n2y = 0编写一个程序 Chebyshev.java,接受一个命令行参数 N,并打印出前 N 个切比雪夫多项式。
厄米特多项式。 编写一个程序 Hermite.java,接受一个整数输入 N,并打印出前 N 个厄米特多项式。以下是前几个厄米特多项式。一般来说,H(n) = 2x * H(n-1) - 2(n-1) * H(n-2)。
H(0) = 1 H(1) = 2x H(2) = 4x2 - 2 H(3) = 8x3 - 12x斐波那契多项式。 编写一个程序
Fibonacci.java,接受一个整��输入 N,并打印出前 N 个斐波那契多项式。以下是前几个斐波那契多项式。一般来说,F(n) = xF(n-1) + F(n-2)。F(1) = 1 F(2) = x F(3) = x2 + 1 F(4) = x3 + 2x这个序列与斐波那契序列有什么关系?提示:在 x = 1 处评估多项式。
合成。 添加用于合成两个多项式的方法,例如,
f.compose(g)应返回多项式 f(g(x))。拉盖尔方法。 编写一个程序,使用拉盖尔方法找到多项式的实根或复根。给定一个 N 次多项式 p(z)和一个复数起始估计 z[0],应用以下更新规则直到收敛。
![]()
选择分母中项的符号以最小化|z[k+1] - z[k]|。拉盖尔方法具有比牛顿法更优越的全局收敛性质,并且如果多项式只有实根,则保证收敛到根。
费雷序列。 阶数为 N 的Farey 序列是所有介于 0 和 1 之间的有理数(以最简分数形式表示),其分子和分母是介于 0 和 N 之间的整数的递增序列。
1: 0/1 1/1 2: 0/1 1/2 1/1 3: 0/1 1/3 1/2 2/3 1/1 4: 0/1 1/4 1/3 1/2 2/3 3/4 1/1 5: 0/1 1/5 1/4 1/3 2/5 1/2 3/5 2/3 3/4 4/5 1/1编写一个程序 Farey.java,接受一个命令行参数 N,并打印出阶数为 N 的费雷序列。使用上面创建的有理数数据类型。
要计算费雷序列,您可以使用以下惊人的关系:如果 m/n 和 m'/n'是费雷序列中顺序的两个元素,其阶数为 N,则下一个元素是 m''/n'',可以按照以下方式计算(其中除法是整数除法):
m'' = ((n + N) / n') * m' - m n'' = ((n + N) / n') * n' - n最佳有理逼近。 新闻播音员经常试图将难以处理的比率(如 0.4286328721345)简化为一个近似的有理数,如 4/7,其分子和分母较小。如何做到这一点?答案取决于您愿意容忍的分母大小,因此我们的目标是列出最佳逼近值,让记者选择所需的一个。以下是数学常数 e 的最佳几个有理逼近值:
0/1 1/1 2/1 3/1 5/2 8/3 11/4 19/7 49/18 68/25 87/32 106/39 193/71 685/252 878/323 1071/394尽管 27/10 是对 e 的一个不错的近似值,但它被排除在列表之外,因为 19/7 提供了一个更好的近似值,且分母更小。Stern-Brocot 树方法 提供了一个优雅的数学解决方案。以下是生成实数 x 的最佳上下有理近似值的算法:
将左端点设置为 0/1,将右端点设置为 1/0。
计算左右端点的中值。两个有理数 a/b 和 c/d 的中值是 (a+c)/(c+d)。
如果中值等于 x(机器精度内),则停止。否则,如果中值小于 x,则将右端点设置为中值。否则,将左端点设置为中值。
当你迭代上述过程时,如果它提供了更好的近似值,请打印出每个新项。编写一个程序 RationalApprox.java 来打印出这些最佳有理近似值。
![Stern Brocot tree]()
连分数。 连分数是形式为 a0 + 1 / (a1 + 1 / ( a2 + 1 /a3) 的表达式,其中 a[i] 是整数。
给定一个连分数展开 a0, a1, ..., an,编写一个程序来确定它对应的有理数。
给定一个有理数,找到它的连分数展开。例如,159/46 = 3 + 21/46 = 3 + 1 / (46/21) = 3 + 1 / (2 + 4/21) = 3 + 1 / (2 + 1 / (21/4)) = 3 + 1 / (2 + 1 / (5 + 1/4))。
任意精度有理数算术。 编写一个 ADT BigRational.java,实现任意精度有理数。提示:重新实现 Rational.java,但使用
BigInteger代替int来表示分子和分母。复有理数。 实现一个数据类型 RationalComplex.java ComplexRational,支持实部和虚部为有理数的复数。在深度缩放 Mandelbrot 集的绘图中使用它,以避免浮点精度问题。(还要使用加倍技巧检查 Mandelbrot 序列中的循环。)
多项式次数。 添加一个计算多项式次数的方法
degree()。警告:这可能不等于数组的大小,如果我们从两个原本次数为 10 的多项式中减去,我们可能得到一个次数为 4 的多项式。有理多项式。 创建一个 ADT RationalPolynomial.java,使用任意精度有理系数表示有理多项式。包括一个方法
integrate(int a, int b),用于从 a 到 b 对调用多项式进行积分,并返回结果有理数。多项式除法。 为具有有理系数的两个多项式编写
div()和rem()方法。使用以下小学方法计算将 u(x) 除以 v(x) 的商和余数,假设 v(x) 不为零。商 q(x) 和余数 r(x) 是满�� u(x) = q(x) v(x) + r(x) 且 degree(r(x)) < degree(v(x)) 的多项式。如果 degree(u(x)) < degree(v(x)),则返回零商。
将 v(x) 乘以 axb,使得 u'(x) = u(x) - v(x)axb 的次数小于 degree(u(x)),并且最高次项抵消。
返回一个商 ax^b + u'(x) / v(x),其中除法是递归计算的。
要计算余数,首先计算商 q(x),然后返回余数 r(x) = u(x) - q(x) v(x)。
斯图姆算法。 斯图姆算法 是一种优雅的方法,用于确定给定区间上有理多项式的实根数量。给定一个多项式 p(x),我们定义斯图姆链如下:
f0 = p(x)
f1 = p'(x)
fn = fn-1 % fn-2 其中 % 是多项式余数。链条一直延续,直到 fn 成为一个常数。斯图姆定理断言在区间 (a, b) 中的实根数等于两个斯图姆链在 x = a 和 x = b 处的符号变化数的差异。使用有理算术,我们可以得到精确答案;使用浮点数时,需要非常小心以避免舍入误差。
多项式最大公约数。 在有理多项式上实现欧几里德算法,以找到两个多项式的最大公约数。使用前一个练习中的除法算法。与整数的欧几里德算法一样,我们可以使用以下递归 gcd((u(x), v(x)) = gcd(v(x), r(x)),其中 r(x) 是 u(x) % v(x),如前一个练习中定义。基本情况是 gcd(u(x), 0) = u(x)。
多项式实现。 假设您需要快速访问各个系数,但多项式是稀疏的。使用符号表存储系数。可能是二叉搜索树,这样您可以按顺序打印系数或获取最大次数。
任意精度整数算术。 开发自己的库
MyBigInteger,实现任意精度整数算术,就像 Java 库 BigInteger 中一样。斐波那契数。 编写一个程序 Fibonacci.java 以使用大整数计算斐波那契数。使用迪杰斯特拉的递归。
传递函数。 传递函数 模拟系统的输出如何根据输入变化。传递函数在工程的所有领域中都有应用,可以模拟手机扬声器、相机镜头或核反应堆。与地震仪相关的传递函数(以及许多其他机械和模拟电子系统)是具有实系数 p(s) / q(s) 的两个(频域)多项式的比率。这种传递函数在系统和控制理论中经常出现,因为常见信号的拉普拉斯变换结果是两个多项式的比率。零点是分子为 0 的值,极点是分母为 0 的值。极点和零点对于理解基础系统的行为至关重要。极点决定稳定性。如果系统稳定且输入发生变化,则输出将收敛到一个恒定值;如果不稳定(例如切尔诺贝利的核反应堆),输出将无限增长或下降。如果系统稳定,则所有极点的实部都是正的。零点影响反馈控制器的设计。例如:(3z - 1) / (z - 1/3)(z² - 1), 30(z-6)/ z(z²+4z+13), (s+1)(s² + s + 25)/ s²(s+3)(s²+s+36)。
任意精度平方根。 编写一个程序 ArbitraryPrecisionSqrt.java,接受两个整数命令行参数 x 和 n,并打印 x 的平方根,精确到 n 位。使用牛顿法和 java.math.BigDecimal。牛顿法收敛二次:每个牛顿步骤将精度位数加倍。
9.3. 数值积分
原文:
introcs.cs.princeton.edu/java/93integration译者:飞龙
本节正在大力施工中。
中点法则。 目标:给定一个变量的连续函数 f(x),计算从 a 到 b 的区间上的∫ f(x) dx。通过 M = (b-a) * f(c)来估计积分,其中 c = (a + b)/2。
梯形法则。 目标:给定一个变量的连续函数 f(x),计算从 a 到 b 的区间上的∫ f(x) dx。TrapezoidalRule.java 使用梯形法则数值积分一个变量的函数。我们可以使用公式 T = (b-a)/2 (f(a) + f(b))来估计从 a 到 b 的 f(x)的积分。将区间从 a 到 b 分成 N 个等距间隔的子区间(并合并公共项),我们得到公式:

其中区间[a, b]被分成 N 个均匀大小的子区间,h = (b - a) / N。在某些技术条件下,如果 N 很大,则上述公式是积分的一个很好的估计。
辛普森法则。 梯形法则在实践中很少用于积分。对于光滑的 f,中点法则的精度大约是梯形法则的两倍,并且误差具有不同的符号。通过结合这两个表达式,我们得到 f 的更准确的估计:S = 2/3M + 1/3T。这种组合被称为辛普森 1/3 法则。S = (b-a)/6 (f(a) + 4f(c) + f(b)),其中 c = (a + b)/2。将区间从 a 到 b 分成 N 个等距间隔的子区间(并合并公共项),我们得到公式:

其中区间[a, b]被分成 N 个均匀大小的子区间,h = (b - a) / (N - 1),而 2/3 和 4/3 的系数在内部交替。这里有一些关于数值积分的不错的动画。在某些技术条件下,如果 N 很大,则上述公式是积分的一个很好的估计。程序 SimpsonsRule.java 从 a = 0 到 b = 2 数值积分 x⁴ log (x + sqrt(x² + 1))。
程序组织。 为了编写可重复使用的积分例程,我们希望能够创建一个类TrapezoidalRule并传递一个要被积分的任意连续函数。实现这一目标的一种方法是声明一个连续函数的接口,该接口具有一个名为eval的方法,该方法接受一个实数参数 x 并返回 f(x)。
public interface ContinuousFunction {
public double eval(double x);
}
然后,我们可以实现误差函数为
public class NormalPDF implements ContinuousFunction {
private double mu;
private double sigma;
public NormalPDF(double mu, double sigma) {
this.mu = mu;
this.sigma = sigma;
}
public double eval(double x) {
return Math.exp(- x * x / 2) / Math.sqrt(2 * Math.PI); // mu, sigma
}
}
自适应积分。 Matlab 函数quad使用外推辛普森法则的自适应积分。[参考第六章,Cleve Moler。]
Q1 = h/6 (f(a) + 4f(c) + f(b)), where c = (a + b)/2 [Simpson]
Q2 = h/12 (f(a) + 4f(d) + 2f(c) + 4f(e) + f(b)), [Simpson over two subintervals]
where d = (a + c)/2, e = (c + b)/2
Q = Q2 + (Q2 - Q1) / 15 [ 6th order Newton-Cotes / Weddle's Rule]
梯形法则通过将区间分成固定数量的等距子区间,并通过梯形逼近每个子区间中的面积来近似曲线下的面积。通过使用可变数量的子区间并根据曲线的形状选择它们,我们通常可以获得更精细的近似。在自适应积分中,我们估计从 a 到 b 曲线下的面积两次,一次使用 Q1,一次使用 Q2。如果这两个估计足够接近,我们使用 Q = Q2 + (Q2 - Q1)/ 15 来估计面积。否则,我们将区间分成从 a 到 c 和从 c 到 b 的两个相等子区间,其中 c 是中点(a + b) / 2。我们计算每个部分的面积(递归使用自适应积分)并将结果相加。程序 AdaptiveQuadrature.java 实现了这种策略。
如果我们小心一些,可以在一次迭代到下一次迭代中保存函数评估,并且每次递归调用只需两次函数评估。(见练习。)
当积分 1 / (3x - 1) 从 x = 0 到 1 时,我们的方法失败得很惨。积分具有非奇异性,我们的函数将陷入无限循环。一个工业强度的实现应该诊断这种情况并报告适当的错误信息。
蒙特卡罗积分。 基于大数定律的理论。通过拒绝方法估计圆的面积。飞镖板。在几杯啤酒后在爱尔兰酒吧扔飞镖... 程序 Dartboard.java 读取一个命令行整数 N,将 N 个均匀分布在单位盒中的飞镖扔出,并绘制结果。下面的图片展示了 N = 1,000、10,000 和 100,000 的样本结果。
![]() |
![]() |
![]() |
|---|
另一种观点:2D 蒙特卡洛积分 f(x, y) = 1,如果 x² + y² ≤ 1。展示直方图 - 标准差正态分布的图。
为了估计 f 在多维体积 V 上的积分,我们在体积中随机选择 N 个点 x[1]、x[2]、...、x[N]。我们对积分的估计是随机点落在 f 以下的比例乘以体积 = V
<f> = 1/N * (f(x1) + ... + f(x2))
<f2> = 1/N * (f2(x1) + ... + f2(x2))
蒙特卡罗积分的基本定理断言,f 在 V 上的积分等于 V
圆环的质心。 举个例子(摘自《数值食谱》第 221 页),假设我们想要估计一个圆环和两个平面的交点的重量和质心,由满足点 (x, y, z) 的定义
z2 + (sqrt(x2 + y2) - 3)2 ≤ 1
x ≥ 1
y ≥ -3
我们必须计算积分 f(x, y, z) = ρ,fx = xρ,fy = yρ 和 fz = zρ。质心然后是 (f[x]/f, f[y]/f, f[z]/f)。为了从圆环中均匀采样点 (x, y, z),我们使用拒绝方法。在这种情况下,圆环被包含在矩形框内,其中 1 ≤ x ≤ 4,-3 ≤ y ≤ 4,-1 ≤ z ≤ 1。程序 Torus.java 接受一个命令行参数 N,并使用 N 个样本点计算这些量。
高斯 cdf。 通过从高斯分布生成随机数并记录小于 z 的值的比例来估计高斯 cdf Phi(z)。这是重要性抽样的一个例子。注意:无法在负无穷到正无穷之间均匀采样。
练习
对 f(x) = x^x 从 x = 0 积��到 x = 1。
答案:1 - 2^(-2) + 3^(-3) - 4^(-4) + ... = 0.78343...
数值积分 f(x) = e^(-x) sin(8x^(2/3)) 从 0 积分到 2。
对 f(x) = sin(x²) 从 x = 0 积分到 x = pi。
对 f(x) = cos(20 x²) 从 x = 0 积分到 x = 1。
使用梯形法则数值积分 f(x) = 4 sqrt(1 - x²) 从 x = -1 积分到 1。注意收敛到真实答案 xyz 的速度较慢。
编写一个计算正弦积分 Si(x) 的程序,它被定义为 (sin t) / t 从 0 积分到 x。
编写一个计算 Fresnel 正弦积分 FresnelSi(x) 的程序,它被定义为 sin (π/2 t²) 从 0 积分到 x。
使用蒙特卡洛积分来近似 f(x, y) = x² + 6xy + y² 在单位圆内 (x² + y² ≤ 1) 的二维积分。
创意练习
9.4 微分方程的数值解
原文:
introcs.cs.princeton.edu/java/94diffeq译者:飞龙
本节正在大力施工中。
解微分方程是科学和工程中的一个基本问题。微分方程是...例如:y' = -2y,y(0) = 1 有解析解 y(x) = exp(-2x)。拉普拉斯方程 d²φ/dx² + d²φ/dy² = 0 加上一些边界条件。有时我们可以使用微积分找到封闭形式的解。然而,一般情况下我们必须求助于数值逼近。ODE = 所有因变量都是单个自变量的函数的微分方程,如第一个例子中。PDE = 所有因变量都是几个自变量的函数的微分方程,如第二个例子中。
欧拉方法。 在 18 世纪,莱昂哈德·欧拉发明了一种简单的方案来数值逼近 ODE 的解。给定形式为 dy/dx = f(x, y)的一阶 ODE,满足初始边界条件 y(x[0]) = y[0],我们在 x[n] = x[0] + hn 的一系列值 x[n]上估计函数 y(x)。参数 h 被称为步长。如果 y[n]是在 x[n]处对 y(x)的近似,则我们可以通过 f(x[n], y[n])来近似 x[n]处的梯度。我们通过假设梯度在 x[n]和 x[n+1]之间的区间内保持不变来估计 y[n+1]。这导致以下方法:

我们将 h 设得越小,逼近就越准确。但这样做会增加计算量。相关的截断误差可以通过将上述估计与泰勒级数逼近进行比较来形式化。

我们看到截断误差大约为 O(h²)。如果误差是累积的(保守假设),那么从 x[0] = 0 到 x[n] = 1 的总误差为 O(h)。称为一阶方法。为了保持相对误差低于 1E-6,我们必须进行 100 万步。请注意,如果步长太大,那么欧拉方法会变得不稳定。
Lorenz 吸引子。 Lorenz 方程是以下微分方程组

程序 Butterfly.java 使用欧拉方法数值解 Lorenz 方程并绘制轨迹(x, z)。
![]() |
![]() |
|---|
程序 Lorenz.java 绘制了 Lorenz 方程具有略有不同初始条件的两条轨迹。这种扰动最终导致显着不同的行为。这就是所谓的蝴蝶效应的起源。这里有一个很好的演示。
龙格-库塔方法。 欧拉方法在实践中不使用,因为每步的截断误差相对较大,与其他方法相比。此外,如果步长太大,欧拉方法会变得不稳定。欧拉方法仅在步骤开始时使用第一导数信息。龙格-库塔方法在区间中的几个点上采样导数。在计算函数 f(x, y)和提高准确性之间存在权衡。四阶龙格-库塔方法是一个受欢迎的平衡点。

这是一个四阶方法。
N 体模拟。 艾萨克·牛顿爵士在他著名的《自然哲学的数学原理》中于 1687 年制定了控制两个粒子在彼此之间的引力影响下运动的原则。然而,牛顿无法解决三个或更多粒子的问题。事实上,三个或更多粒子的系统只能通过数值方法来解决。我们将描述一种经典的数值解法,广泛应用于研究宇宙学、等离子物理、半导体、流体动力学和天体物理等复杂物理系统。科学家们还将相同的技术应用于其他成对相互作用,包括库仑力、比奥-萨瓦尔定律和范德瓦尔斯力。
我们首先回顾牛顿描述行星运动的数学模型。牛顿的普遍引力定律断言,两个粒子之间的引力强度由 F = Gm[1]m[2] / R²给出,其中 G 是普遍引力常数,m[1]和 m[2]是两个粒子的质量,R 是它们之间的距离。力是矢量量,一个粒子对另一个粒子的拉力作用在它们之间的直线上。牛顿的第二运动定律 F = ma 将力与加速度联系起来。让 m[i]表示粒子 i 的质量,让 r[i]表示其位置矢量(作为时间 t 的函数)。结合牛顿的两个定律,我们得到了描述粒子运动的微分方程组。

由于粒子的加速度仅取决于其他粒子的位置而不是它们的速度(这可能是粒子在电磁场中的情况),我们可以使用一种称为跃点法的专门数值积分方法。这是大多数天体物理模拟重力系统的基础。在这种方案中,我们离散化时间,并以时间量子 dt 的增量增加时间变量t。我们保持每个粒子的位置和速度,但它们相位差半个时间步长(这解释了跃点法的名称)。下面的步骤说明了如何演化粒子的位置和速度。这里

詹姆斯·M·斯通,普林斯顿大学天体物理学,“我们发现的宇宙中一些最基本的事实(例如太阳中心的温度)只有通过计算方法才能知道。”
椭圆型偏微分方程。 椭圆型偏微分方程涉及空间的二阶导数,但不涉及时间。科学计算中的一个重大挑战是用于解决椭圆型偏微分方程的快速多极方法。其中最简单且最重要的例子之一是Laplace's equation:d²φ/dx² + d²φ/dy² = 0。它在许多科学应用中用于模拟电场、引力场和流体势。它描述了热传输的稳态、非受限含水层中的稳态水位高度。我们只知道边界上的值,因此没有初始条件,欧拉方法不适用。为了数值求解,我们可以在一个方形网格上使用有限差分方法。

点的电压是其最近邻的平均值。称为松弛算法或高斯-赛德尔。程序 Laplace.java 在平面上数值解拉普拉斯方程,其四个边界墙的电势固定为:左(30),右(70),上(100)和下(0)。在每一步中,我们使每个单元格成为其四个邻居的平均值。我们始终使用最新值(不一定与上一步相同)。我们使用整数范围从 0(蓝色)到 100(红色)绘制所有点。我们绘制电势等于 10 的倍数的点为白色,以突出等势线。请注意,由于平均性质,最大值和最小值在边界点处实现。下图显示了 0、500、1,000 和 10,000 次迭代后的电势。收敛速度较慢。
收敛缓慢。瓶颈是绘制到屏幕上。为了加快速度,我们在使用海龟图形绘制到屏幕之前执行 100 次更新。该方法也可以通过选择更好的初始电势估计(而不是选择任意值)来加快速度。
其他技术:解线性方程组。通常涉及大型但稀疏和带状矩阵。在一维中,拉普拉斯方程的有限差分方案产生一个三对角线性方程组。

易于并行化的变体:雅可比迭代,逐次超松弛,红黑排序。在二维中使用多重网格方法(在几个尺度上离散化网格,使信息传播更快),共轭梯度,或者如果有周期性边界则使用 FFT。
Q + A
为什么使用跳跃法而不是欧拉法或龙格-库塔法?
跳跃法对于积分哈密顿系统更稳定。它是辛波形的,这意味着它保留了特定于哈密顿系统的性质(线性动量和角动量的守恒,时间可逆性,以及离散哈密顿能量的守恒)。相比之下,普通数值方法会变得耗散,并表现出不正确的长期行为。
有关 Java 中数字计算的好资源吗?
练习
(由 Tamara Broderick 建议的示例)编写一个程序 LaplaceSquare.java 来解决拉普拉斯方程,其中网格边界上的电势固定为 0,中心有一个面积为 1/9 的内部正方形,电势固定为 100。
解决带有 L 形内部边界的拉普拉斯方程。
创意练习
跳伞者。 假设跳伞者的下降用以下微分方程建模:d²x/dt² = 9.8 - 0.01 (dx/dt)²,其中 x 是距离投放区的距离(米)。最初 x(0) = 0,x'(0) = 0。估计经过 3 秒后的行程。估计终端速度。使用 dt = 1。
泊松方程。 泊松方程是:dφ/dx² + dφ/dy² = f(x, y)。
SIR 流行病学模型。 SIR 模型测量了宿主人群中易感、感染和康复个体的数量。给定一个固定的人口,让 S(t)表示在时间 t 时患有传染性但不致命疾病的易感人群的比例;让 I(t)表示在时间 t 时感染的比例;让 R(t)表示已康复的比例。让β表示感染者感染易感者的速率。让γ表示感染者从疾病中康复的速率。描述人群中易感、感染和康复人群比例的微分方程为
![SIR 模型]()
第一个方程模拟了感染率;第二个说人口是封闭的;第三个模拟了康复率。流行病学家使用这个简单模型来跟踪麻疹和埃博拉的感染率。香港流感:最初有 790 万人,10 人感染,0 人康复。因此 S(0) = 1,I(0) = 1.27E-6,R(0) = 0。估计平均感染期为 3 天,所以γ = 1/3,感染率为每天一个新人,所以β = 1/2。为 t = 0 到 200 以不同颜色绘制 S(t)、I(t)和 R(t)。
天花:1/γ = 14 天,1/μ = 27375 天,1/β = 27389/20。玉米病:1/γ = 20 天,1/μ = 60 天,1/β = 80/5。
Logistic 映射。 编写一个程序 LogisticMap.java,绘制 Logistic 映射的bifurcation diagram。Logistic 方程为:y[n+1] = 4 r y[n] (1 - y[n])。对于 r 在 0.7 到 1.0 之间的每个值,初始化 y[0] = 0.5,执行 1,000 次迭代并丢弃它们,然后在 y 轴上绘制接下来的 100 次迭代。
![Logistic map]()
化学工程中的 ODE 初值问题。 控制发酵罐的方程。
捕食-被捕食者动态。 在 Lotka-Volterra 模型中,有一种动物群体(捕食者)以另一种动物群体(被捕食者)为食。这里是一些近似代表了哈德逊湾公司从 1852 年开始观察到的猞猁和雪兔种群的数据。x_t 表示雪兔(被捕食者)的数量,y_t 表示在时间 t 时生活的猞猁(捕食者)的数量。雪兔是素食动物,我们假设出生的雪兔数量与时间 t 时存活的雪兔数量成比例(出生率α)。猞猁与雪兔之间的决定性相遇的概率与雪兔和猞猁数量的乘积成比例(杀伤率β)。我们假设猞猁没有天敌,死亡数量与出生率γ成比例。我们还假设出生的猞猁数量与雪兔数量成比例。
|
dx/dt = α x - β x y dy/dt = γ x y - δ y|
平衡点:(0, 0)或(δ / γ, α / β)。不稳定。α = 1,β = 0.01,γ = 0.02,δ = 1。
x_0 = 20,y_0 = 20。
使用欧拉方法。
使用 4 阶龙格-库塔方法。
9.5. 数值线性代数
原文:
introcs.cs.princeton.edu/java/95linear译者:飞龙
本节正在大力施工中。
Java 数值计算提供了关于 Java 中数值计算的信息的焦点。
线性代数。 计算机科学应用:小波、计算机图形学中的变换、计算机视觉、谷歌的 PageRank 算法、线性规划、线性回归、马尔可夫链。其他应用:线性和非线性优化、控制理论、组合优化、常微分方程的数值解、电气网络分析、投资组合优化、量子力学。解决这些问题的愿望推动了计算技术的发展。BLAS。
矩阵。 在数值线性代数中,矩阵是一个实数或复数的矩形表。给定矩阵 A,我们使用符号 A[ij]表示第 i 行和第 j 列的条目。我们可以通过使用二维数组在 Java 中实现矩阵。我们使用A[i][j]访问 A[ij]。我们从 0 开始索引以符合 Java 索引约定。
矩阵乘法。 两个 N×N 矩阵 A 和 B 的乘积是一个 N×N 矩阵 C,定义为
以下代码片段计算 C = AB。
for (int i = 0; i < N; i++)
for (int j = 0; j < N; j++)
for (int k = 0; k < N; k++)
C[i][j] += A[i][k] * B[k][j];
行主序 vs. 列主序。 可以对缓存和性能产生巨大影响。在 Java 中最好迭代一行而不是一列。我们可以以 3!= 6 种方式重新排列矩阵乘法三重循环以获得相同的答案。每种可能性具有不同的内存访问模式,可能在机器架构(缓存、分页等)上表现非常不同(2-3 倍)。程序 MatrixMultiplication.java 在 6 个顺序中执行矩阵乘法,并输出所需的时间。一些架构具有内置的 gaxpy 方法等。高性能矩阵库非常精心地调整到将要运行的机器架构,以充分利用这些效果。
微优化。 在许多应用中,矩阵乘法是瓶颈计算,因此在这里考虑是合适的。我们可以明确地缓存某些行和列以加快计算速度。我们缓存 A 的第 i 行和 B 的第 j 列。由于 Java 数组是按行排序的,我们将 B 的第 j 列的条目复制到一维数组中以便未来访问。为什么更快?更好的内存访问模式。避免边界检查。现在赋值语句的数量与 N² 成正比,而不是 N³。
double[] ai; // row i of A
double[] bj = new double[N]; // column j of B
for (int j = 0; j < N; j++) {
for (int k = 0; k < N; k++) bj[k] = B[k][j];
for (int i = 0; i < N; i++) {
ai = A[i];
double s = 0;
for (int k = 0; k < N; k++)
s += ai[k] * bj[k];
C[i][j] = s;
}
}
也可以通过使用大小为 N²的一维数组而不是 Java 数组的数组来提高性能(可能高达 2 倍)。这里有一个关于在 Java 中使用矩阵的概述。
线性方程组。 线性代数中最基本和重要的���题之一是找到方程 Ax = b 的解 x。差分方程、插值、数字信号处理、最小二乘、预测、经济均衡的 Leontief 模型、胡克弹性定律、交通分析、板材中的热平衡、线性和非线性优化。
基尔霍夫电压定律。 电路的环流分析。
行操作。 考虑以下三个未知数的线性方程组。
0x0 + 1x1 + 1x2 = 4
2x0 + 4x1 - 2x2 = 2
0x0 + 3x1 + 15x2 = 36
我们可以应用一些恒等式将方程组转化为一个可能更容易解决的等价方程组。我们将使用以下两种形式的转换。
行互换。 交换任意两行。例如,我们可以交换上面的第一行和第二行以得到等价系统。
2x0 + 4x1 - 2x2 = 2 0x0 + 1x1 + 1x2 = 4 0x0 + 3x1 + 15x2 = 36在 Java 中,交换二维数组中的第 i 行和第 j 行是一种特别高效的操作。我们只需要交换第 i 行和第 j 行的引用。
double[] temp = A[i]; // ith row of A A[i] = A[j]; A[j] = temp;线性组合。 将一个行的多倍加或减去另一个行。例如,我们可以从第三个方程中减去第二个方程的三倍,以获得另一个等价系统。
2x0 + 4x1 - 2x2 = 2 0x0 + 1x1 + 1x2 = 4 0x0 + 0x1 + 12x2 = 24这种转换涉及乘以一个实数,因此即使我们从整数系数开始,最终可能得到实数系数。
回代法。 上述最后一个方程组特别适合求解。从最后一个方程(12 x[2] = 24)中,我们可以立即推导出 x[2] = 2。将 x[2] = 2 代入第二个方程中得到 x[1] + 2 = 4。现在我们可以推导出 x[1] = 2。最后,我们可以将 x[1] 和 x[2] 代入第一个方程中。这导致 2x[0] + 4(2) - 2(2) = 2,这意味着 x[0] = -1。这种回代过程在 Java 中表达起来很简单。
for (int j = N - 1; j >= 0; j--) {
double t = 0.0;
for (int k = j + 1; k < N; k++)
t += A[j][k] * x[k];
x[j] = (b[j] - t) / A[j][j];
}
高斯消元法。 高斯消元法是解线性方程组的最古老和最广泛使用的算法之一。该算法由刘徽在 263 年明确描述,当时他在解释著名的中国文本《九章算术》时提出了解决方案,但实际上可能早在此之前就已被发现。高斯消元法这个名称是在高斯使用它来预测天体位置时产生的,他使用了自己新发现的最小二乘法。对原始方程组应用行操作,将其转换为上三角形式。然后使用回代法。
以下虚构代码是高斯消元法的一个简单实现。
for (int i = 0; i < N; i++) {
// pivot within b
for (int j = i + 1; j < N; j++)
b[j] -= b[i] * A[j][i] / A[i][i];
// pivot within A
for (int j = i + 1; j < N; j++)
// optimization precompute m = A[j][i] / A[i][i] and loop upwards
for (int k = N - 1; k >= i; k--)
A[j][k] -= A[i][k] * A[j][i] / A[i][i];
A[j][i] = 0.0; // can stop previous loop at i+1
不幸的是,如果其中一个主元素A[i][i]为零,代码会除以零并失败。有一些重要的应用场景,我们保证永远不会遇到零主元素并陷入困境(例如,如果矩阵严格对角占优或对称正定),但一般情况下,我们必须通过将包含零主元素的行与其下方的另一行交换来确保零主元素永远不会出现。如果没有这样的行存在,则系统要么没有解,要么有无穷多个解。(参见练习 XYZ 和 XYZ。)
部分主元素选取。 一种常见的主元素选取策略是选择具有最大(绝对值)主元素的行,并在每次主元素选取之前进行交换,无论我们是否遇到潜在的零主元素。程序 GaussianElimination.java 实现了带有部分主元素选取的高斯消元法。这种选择规则被称为部分主元素选取。它被广泛使用,因为除了解决零主元素问题外,它还显著提高了算法的数值稳定性。要看到其效果,请考虑以下方程组,其中 a = 10^(-17)。
ax0 + x1 = 1
x0 + 2x1 = 3
如果我们不在最大系数上选主元,那么高斯消元会产生解(x[0],x[1] = (0.0, 1.0),而带部分主元的高斯消元会产生 (1.0, 1.0)。精确答案是 (99999999999999997/99999999999999998, 50000000000000000/49999999999999999)。带部分主元的解提供了 16 位小数的精度,而不带部分主元的解对于 x[0] 的精度为 0 位。尽管这个例子是为了演示和放大效果而构造的,但在实践中确实会出现这种情况。这个例子是一个问题实��很好的情况,但算法(不带部分主元)是不稳定的。在这个例子中,通过使用部分主元解决了潜在的问题。 (参见练习 XYZ,一个例子展示了即使实例不是病态的情况下部分主元也会失败。)数值分析师对带部分主元的高斯消元有很高的信心,尽管它不能被证明是稳定的。当问题实例本身是病态的时候,没有浮点算法能够拯救它。为了检测这种情况,我们计算条件数,它衡量矩阵的病态程度。解中的位数约等于数据中的位数 - lg kappa(A)。
完全主元. 选择主元素为仍需行约化的条目中绝对值最大的条目。交换行和列(而不仅仅是行)。更多的簿记和搜索主元的时间,但更好的稳定性。然而,科学家们在实践中很少使用完全主元,因为部分主元几乎总是成功的。
高斯消元法也可用于计算秩,因为行操作不会改变秩。m×n 矩阵的秩:如果在列 j 中选主元时卡住了,就继续到列 j+1。秩 = 终止时非零行数。
有趣的矩阵 用于测试。
迭代方法. 高斯消元中会积累舍入误差。迭代方法(高斯-赛德尔,雅各比迭代,逐次超松弛)也可用于改进通过高斯消元获得的线性方程组的解。也可以从头开始解决 - 如果 A 是稀疏的话,这是一个很大的优势。高斯-赛德尔:x[0] = b, x[k+1] = (I - A)x[k] + b。如果 A 的对角线上的元素都是 1(可以通过重新缩放假设),且(I - A)的所有特征值的绝对值都小于 1,那么高斯-赛德尔迭代会收敛到真解。
矩阵 ADT. 现在,我们描述一个矩阵的 ADT。
public class Matrix {
private double[][] data;
private int M;
private int N;
}
程序 Matrix.java 实现了以下操作:加法,乘法,转置,单位矩阵,迹,随机矩阵。更多操作:逆,秩,行列式,特征值,特征向量,范数,解,条件数,奇异值。
用于数值线性代数的 Java 库。构建高效和稳健的线性代数问题算法是一项具有挑战性的任务。幸运的是,这些算法在过去几十年中得到了改进,成熟的库易于访问。JAMA:Java 矩阵包 是一个包括解 Ax = b,计算特征值,计算奇异值分解等矩阵操作的库。这些算法与 EISPACK、LINPACK 和 MATLAB 中的算法相同。这个软件已经被 MathWorks 和国家标准技术研究所(NIST)释放到公共领域。这里是Javadoc 文档。程序 JamaTest.java 演示了如何与这个库进行交互。它使用 JAMA 包解决了一个包含 3 个未知数的线性方程组。
特征值和特征向量。 给定一个方阵 A,特征值问题是找到满足 Ax = λx 的解。满足这个方程的标量λ被称为特征值,对应的向量 x 被称为特征向量。特征值问题的解在许多科学和工程学科的计算基础设施中扮演着重要角色。1940 年,塔科马纳罗斯大桥在建成四个月后倒塌,因为风的频率太接近桥梁的固有频率,导致了压倒性的振荡。桥梁的固有频率是模拟桥梁的线性系统中最小的特征值。特征值也用于分析弦的振动模式,微分方程的解,莱斯利矩阵模型的人口动态,检测固体中的裂缝或变形,勘探土地寻找石油,减少汽车乘客舱内的噪音,设计音质最佳的音乐厅,计算刚体的惯性轴。
谱分解。 如果 A 是对称的,那么特征值分解是 A = VΛV^T,其中Λ是特征值的对角矩阵,V 是特征向量的正交矩阵。程序 Eigenvalues.java 生成一个随机的对称正定矩阵,并使用 Jama 库EigenvalueDecomposition计算其谱分解。
幂法。 在许多科学和工程应用中,主特征值(绝对值最大的)和相关的特征向量揭示了主导行为模式。例如,谷歌使用它来排名最重要的网页,结构工程师使用它来测量桥梁的最大荷载,声学工程师使用它来测量音乐厅中最低的共振频率。幂法是一种简单的方案来分离最大特征值和相关的特征向量。
x = Ax
x = x / |x|
λ = xTAx / xTx
这里的|x|表示 L1 范数(用于概率分布)或 L2 范数。在一般的技术条件下,λ收敛到主特征值,x 收敛到主特征向量。
马尔可夫链稳态分布。 马尔可夫链是...像一个随机的 NFA。计算马尔可夫链在每个状态中花费的时间比例。稳态分布满足π A = π。向量π是对应于特征值 1 的(归一化的)特征向量。在某些技术条件(遍历性)下,稳态分布是唯一的,它是 A^T 的主特征向量。这个特征向量的所有分量都保证是非负的。
Google 的 PageRank 算法。 使用特征值来排名网页的重要性或者根据赛程强度排名足球队伍。好的讨论。
Jack Dongarra 有一个在线指南代数特征值问题的解模板。
奇异值。 奇异值分解是科学和工程中的一个基本概念,也是数值线性代数中最核心的问题之一。给定一个 M×N 矩阵 A,奇异值分解是 A = UΣV^T,其中 U 是具有正交列的 M×N 矩阵,Σ是一个 N×N 对角矩阵,V 是一个 N×N 正交矩阵。在统计学中也被称为主成分分析(PCA),在模式识别中被称为 Karhunen-Loeve 或 Hotelling 展开。奇异值分解对于任何 M×N 矩阵都是明确定义的(即使矩阵没有完整的行或列秩),并且基本上是唯一的(假设奇异值按降序排列)。它的计算效率为 O( min { MN², M²N } )。它具有许多惊人和美丽的性质,我们只会开始探索其中的一部分。奇异值分解有许多应用:多元线性回归、因子分析、计算机图形学、人脸识别、降噪、信息检索、机器人技术、基因表达分析、计算断层摄影、地球物理反演(地震学)、图像压缩、图像去模糊、人脸识别、利用光学线性灵敏度矩��分析航天器动态、化学数据库的可视化以及潜在语义索引(LSI)。在生物学中也被广泛应用于解卷积涉及三种 pH 指示剂混合的滴定,蛋白质动力学分析肌红蛋白的运动,微阵列数据分析,逆向工程基因网络。
程序 SVD.java 计算一个随机 8×5 矩阵的奇异值。它还输出条件数、数值秩和 2-范数。
图像处理。 有损压缩。压缩图像和其他数据的一种流行技术是通过 SVD 或 Karhunen-Loeve 分解。我们可以将一个 M×N 像素图像视为三个 M×N 数组,分别表示红色、绿色和蓝色强度,每个强度在 0 到 255 之间。使用 SVD,我们可以计算每个三个矩阵的“最佳”秩 r 近似。这可以仅使用 r(M + N + 1)个值来存储,而不是 MN。随着 r 的增大,图像的质量会提高,但会增加存储成本。
SVD 的最重要特性之一是截断 SVD A[r] = U[r]S[r]V[r] 是矩阵 A 的最佳秩 r 近似,其中 U[r]表示 U 的前 r 列,V[r]表示 V 的前 r 列,S[r]表示 S 的前 r 行和列。这里的“最佳”是指 L_2 范数 - A[r]使得 A 和 A[r]之间的差的平方和最小化。
程序 KarhunenLoeve.java 读入一张图片和一个整数 r,计算其红色、绿色和蓝色矩阵的最佳秩 r 近似,并显示结果压缩后的图片。关键子程序计算矩阵 A 的最佳秩 r 近似。方法getMatrix(i1, i2, j1, j2)返回由指定行和列索引限定的 A 的子矩阵。
public static Matrix KL(Matrix A, int r) {
int M = A.getRowDimension();
int N = A.getColumnDimension();
SingularValueDecomposition svd = A.svd();
Matrix Ur = svd.getU().getMatrix(0, M-1, 0, r-1);
Matrix Vr = svd.getV().getMatrix(0, N-1, 0, r-1);
Matrix Sr = svd.getS().getMatrix(0, r-1, 0, r-1);
return Ur.times(Sr).times(Vr.transpose());
}
下面的图像展示了对著名的 Mandrill 测试图像进行 KL 变换的结果,秩分别为 2、5、10、25、50 和 298。最后一个是原始图像。
![]() |
![]() |
![]() |
|---|---|---|
![]() |
![]() |
![]() |
潜在语义索引。 谷歌用于分类网页,语言学家用于分类文档等的 LSI。创建矩阵,其中行索引文档中的术语,列索引文档。矩阵条目 (i, j) 是术语 i 在文档 j 中出现的次数的某种函数。矩阵 AA^T 衡量文档之间的相似性。特征向量对应于语言概念,例如,体育可能包含足球、曲棍球和棒球等术语。LSI 技术可以识别文档之间的隐藏相关性,即使这些文档没有共同的术语。例如,术语汽车和汽车被拉在一起,因为两者都经常与轮胎、散热器和汽缸等术语一起出现。
中心节点和权威节点。 Kleinberg 的方法用于查找相关的网页。如果从 i 到 j 有链接,则 A[ij] = 1,否则为 0。矩阵 ATA 统计 i 和 j 共同拥有多少链接;矩阵 AAT 统计多少共同页面链接到 i 和 j。中心节点是指指向多个权威页面的页面;权威节点是指被多个中心节点指向的页面。矩阵 ATA 的主要成分(或者等价地,SVD A = USVT 中 U 的第一列)给出了“主要中心节点”;矩阵 AA^T 的主要成分(或者等价地,V 的第一列)给出了“主要权威节点”。
基因表达数据分析。 将基因置于一系列实验中,并尝试将具有相似响应的基因聚类在一起。通过转录响应对基因进行分组,通过表达谱对实验进行分组。
稀疏矩阵。 如果非零元素的数量与 N 成比例,则 N×N 矩阵是稀疏的。在优化和解决偏微分方程的情况下,维度为 100,000 的稀疏矩阵会出现。搜索引擎谷歌使用大小为 N = 40 亿的庞大稀疏矩阵进行计算。在这些情况下,2D 数组表示变得无用。例如,要计算矩阵-向量乘积将需要二次空间和时间。计算主特征向量的幂法需要快速的矩阵-向量乘法。我们将描述如何在线性时间内执行相同的计算。主要思想是仅显式存储矩阵 A 的 s 个非零元素,同时保留足够的辅助信息以便与 A 进行计算。我们将描述一种被称为压缩行存储的流行稀疏矩阵存储方案。我们将所有 s 个非零条目连续存储在一个一维数组 val[] 中,以便 val[j] 存储第 j 个非零元素(按从左到右、从上到下的顺序)。我们还维护两个额外的辅助数组,以提供对各个矩阵条目的访问。具体来说,我们维护一个大小为 s 的整数数组 col[],使得 col[j] 是第 j 个非零元素出现的列。最后,维护一个大小为 N+1 的整数数组 row,使得 row[i] 是数组 val 中第 i 行的第一个非零元素的索引。按照惯例,row[N] = s。
0.1 0.0 0.0 0.2 val[] = 0.1 0.2 0.3 0.4 0.5
0.3 0.0 0.0 0.0 col[] = 0 3 0 1 2
0.0 0.0 0.0 0.0
0.0 0.4 0.5 0.0 row[] = 0 2 3 3 5
由于每个double占用 8 字节,每个int占用 4 字节,CRS 的整体存储大约为 12s + 4N。与 2D 数组表示需要的 8N² 字节相比,这是有利的。现在,如果 A 使用 CRS 表示,那么矩阵-向量乘积 y = Ax 可以使用以下紧凑的代码片段高效计算。现在,浮点运算的数量与 (s + N) 成正比,而不是 N²。
double[] y = new double[N];
for (int i = 0; i < N; i++)
for (j = row[i]; j < row[i+1]; j++)
y[i] += val[j] * x[col[j]];
计算 y = A^Tx 稍微棘手,因为朴素方法涉及遍历 A 的列,这在 CRS 格式下不方便。改变求和顺序得到:
double[] y = new double[N];
for (int j = 0; j < N; j++)
for (int i = row[j]; j < row[j+1]; i++)
y[col[i]] += val[i] * x[j];
共轭梯度法。 当 A 对称正定时的 Krylov 子空间方法。[可能省略或留作练习。]
问答
问:有没有理由明确计算矩阵的逆?
- 是的,如果在考试中要求。在实践中,几乎从不需要。要解 Ax = b,应该使用高斯消元而不是形成 A^(-1)b。如果需要为许多不同的 b 值解 Ax = b,则使用称为 LU 分解的东西。它是两倍快,具有更好的数值精度和稳定性属性。
练习
在
Matrix中添加一个名为frobenius()的方法,返回矩阵的 Frobenius 范数。Frobenius 范数是所有条目的平方和的平方根。添加一个名为
normInfinity的方法,返回矩阵的无穷范数。无穷范数(又称行和范数)是通过将每行中元素的绝对值相加得到的最大和。添加一个名为
trace的方法,返回矩阵的迹。迹是对角线条目的总和。添加一个名为
isSymmetric的方法,如果矩阵是对称的,则返回true,否则返回false。矩阵 A 是对称的,如果它是方阵且对于所有 i 和 j,A[ij] = A[ji]。添加一个名为
isTridiagonal的方法,如果矩阵是三对角的,则返回true,否则返回false。给定一个 N×N 数组
a[][],编写一个代码片段来原地转置a。也就是说,最多使用少量额外的存储变量。添加一个名为
plusEquals的方法,以矩阵 B 作为输入,并用自身和 B 的和覆盖调用矩阵。假设你进行高斯消元而不交换行。展示它将在哪里失败
0x0 + x1 = 7 x0 + 0x1 = 5替代的主元策略:选择列 j 中的行,使得|A_ij| / max_k | A_ik|尽可能大。
通过部分主元消去法手工解以下系统 Ax = b。创建的数字最大为 2⁵。对于 N×N 系统,泛化为 2^N。
1 0 0 0 1 1 1/2 -1 1 0 0 1 0 0 A = -1 -1 1 0 1 b = 0 x = 0 -1 -1 -1 1 1 0 0 -1 -1 -1 -1 1 0 1/2中间表达式膨胀。
考虑一个 N×N 矩阵 A,形式如下,对于 N = 100,
1.1000 0 0 0 0 1.0000 -0.9000 1.1000 0 0 0 1.0000 -0.9000 -0.9000 1.1000 0 0 1.0000 -0.9000 -0.9000 -0.9000 1.1000 0 1.0000 -0.9000 -0.9000 -0.9000 -0.9000 1.1000 1.0000 -0.9000 -0.9000 -0.9000 -0.9000 -0.9000 1.0000和一个向量 b = [1, 0, 0, ..., 0, 0]。矩阵 A 是非奇异的,Ax = b 有唯一解 x = [1/2, 0, 0, ..., 0, 9/20]。使用带部分主元消去的高斯消元解 Ax = b(使用我们的代码或 Jama 库)。检查残差误差,并观察解向量在许多坐标上没有有效数字。矩阵 A 是良好条件的,因此这不是问题不良条件的结果,如 XYZ 中所述。相反,这是因为部分主元消去是不稳定的,这个输入突显了这个缺陷。将你的程序命名为 PartialPivotStability.java。
通过手工使用回代法解以下上三角线性方程组。
2x1 + 4x2 - 2x3 = 2 0x1 + 1x2 + 1x3 = 4 0x1 + 0x2 + 4x3 = 8答案: -1 2 2。
通过手工使用高斯消元和回代解以下线性方程组。
2x1 + 4x2 - 2x3 = 2 4x1 + 9x2 - 3x3 = 8 - 2x1 - 3x2 + 7x3 = 10答案: -1 2 2。你应该在解上一个练习中获得上三角系统。
解以下线性方程组。
-9x1 - x2 + x3 + x4 + x5 + 3x6 = 2 2x1 - 7x2 - x3 + x4 + x5 + x6 = -12 x1 + 2x2 - 9x3 - x4 + x5 + 3x6 = -33 x1 + x2 + 2x3 - 7x4 - x5 + x6 = -29 x1 + x2 + x3 + 2x4 - 9x5 - 3x6 = 21 x1 + x2 + x3 + x4 + 2x5 - 7x6 = -13答案: 2 3 5 7 -1 4。
三角形的面积和 ccw。 三角形面积的公式已知已有 2000 年。小学公式(1/2 底*高)和海伦公式需要分析三角函数或取平方根。在 17 世纪,笛卡尔和费马使用线性代数来深入了解几何问题。例如,以下行列式给出了以 a、b 和 c 为顶点的三角形的带符号面积的两倍。
| ax ay 1 | | bx by 1 | | cx cy 1 |行列式的符号指定了 c 是在 a 到 b 的线上的左侧、右侧还是在线上。这个 ccw 测试对于凸包和其他计算几何算法非常有用。自然地推广到更高维度的四面体和其他单纯形。
内切圆测试。 确定点 d 是否在平面上由三个点 a、b 和 c 定义的圆内或圆外。应用:Delaunay 三角剖分算法中的原始操作。假设 a、b、c 按逆时针顺序标记在圆周上,如果 d 在圆内则下面的行列式为正,如果 d 在圆外则为负,如果所有四个点共圆则为零。这自然地推广到更高维度,例如,点在由 4 个点定义的球内。
| ax ay ax² + ay² 1 | | bx by bx² + by² 1 | | cx cy cx² + cy² 1 | | dx dy dx² + dy² 1 |给定包含三个点(10, 5, 2)、(3, 8, 9)和(3, 6, -1)的平面方程 ax + by + cz = 1。
找到以下矩阵的特征值和特征向量。
3 -1 -1 3答案: λ[1] = 2, λ[2] = 4。
假设你面临计算 cTA(-1)d 的问题。解释如何在不显式计算 A^(-1) 的情况下做到这一点。解决方案。 使用高斯消元解 Ax = d 得到 x。现在所需的答案是 c^Tx。每当在一个公式中看到一个逆时,总是将其视为解方程而不是计算逆。
创造性练习
复杂矩阵。 创建一个抽象数据类型来表示复杂矩阵。
熄灯游戏。 实现游戏 Lights Out 的求解器。解一个线性方程组(在 Z_2 上)以确定应该打开哪些灯(如果存在这样的解)。
可行性检测。 无法找到非零主元,且当前右侧不为零。
不可行性证书。 如果 Ax = b 没有解,那么高斯消元将失败。如果是这样,那么存在一个向量 c,使得 cTA = 0 且 cTb ≠ 0。修改高斯消元,使其在 Ax = b 没有解时产生这样一个向量。
三对角矩阵。 实现一个数据类型
TridiagonalMatrix,使用三个 1-D 数组实现三对角矩阵。设计一个算法,当 A 是一个方三对角矩阵时解 Ax = b。你的算法应该在线性时间内运行。Strassen 算法。 用于矩阵乘法的 N^(2.81) 分治算法。与高斯消元比较���
特殊矩阵。 使用继承创建类
DiagonalMatrix、TridiagonalMatrix,并重写解线性方程组和矩阵乘法的方法...马尔可夫链。 马尔可夫链是一种简单的数学工具,用于建模行为模式。广泛应用于包括排队理论、统计学、建模人口过程和基因预测在内的许多科学领域。 Glass 和 Hall(1949)在他们的社会流动性研究中区分了 7 个状态:
专业的,高级管理
管理的
检查、监督、非手工高级
非手工低级
熟练的手工
半熟练的手工
未熟练的手工
下表呈现了他们研究的数据。条目 (i, j) 是从状态 i 过渡到 j 的概率。
{ 0.386, 0.147, 0.202, 0.062, 0.140, 0.047, 0.016 } { 0.107, 0.267, 0.227, 0.120, 0.207, 0.052, 0.020 } { 0.035, 0.101, 0.188, 0.191, 0.357, 0.067, 0.061 } { 0.021, 0.039, 0.112, 0.212, 0.431, 0.124, 0.061 } { 0.009, 0.024, 0.075, 0.123, 0.473, 0.171, 0.125 } { 0.000, 0.103, 0.041, 0.088, 0.391, 0.312, 0.155 } { 0.000, 0.008, 0.036, 0.083, 0.364, 0.235, 0.274 }编写一个程序 MarkovChain.java 来计算一些有趣的数量。
希尔伯特矩阵。 编写一个程序 Hilbert.java,读取一个命令行参数 N,创建一个 N×N 的 希尔伯特矩阵 H,数值计算其逆 H^(-1)。希尔伯特矩阵的 i-j 元素是 1/(i+j-1)。所有希尔伯特矩阵都是可逆的。下面是 4×4 希尔伯特矩阵及其逆矩阵
1 1/2 1/3 1/4 16 -120 240 -140 H = 1/2 1/3 1/4 1/5 H^-1 = -120 1200 -2700 1680 1/3 1/4 1/5 1/6 240 -2700 6480 -4200 1/4 1/5 1/6 1/7 -140 1680 -4200 2800当你尝试求一个 100×100 的希尔伯特矩阵的逆时会发生什么?
答案: Jama 矩阵求逆器报告该矩阵不可逆。希尔伯特矩阵病态,大多数线性代数包在求解较大 N 的情况下难以求逆该矩阵。请注意,尽管它可以求逆较小的矩阵而不报告错误,但结果中存在实质性的误差。
马尔可夫链稳态分布。 如果马尔可夫链 M 是不可约(经过有限次转换可以从任意状态到达任何其他状态)且非周期的,那么它被称为遍历的。稳态分布是 M 的(唯一的)对应于特征值=1 的特征向量。
详细平衡。 马尔可夫链的一个特殊情况是满足详细平衡:存在π[i]使得π[i] p[ij] = π[j] p[ji]对于所有 i 和 j != i 成立。证明如果满足详细平衡,则π P = π。提示:对 j 求和。
环上的随机行走。 假设你有一个 N 个节点的圆圈,并且你从节点 1 开始。在每一步中,你抛一枚公平的硬币,然后顺时针或逆时针移动。计算每个顶点(除了 1 之外)是最后一个被访问的概率。解答:所有的顶点被访问的概率相等!
莱昂蒂夫投入产出模型。 经济学的一个分支,利用线性代数来模拟产业之间的相互依赖关系。(瓦西里·莱昂蒂夫获得 1973 年诺贝尔经济学奖)描述。莱昂蒂夫将美国经济分为 81 个部门(石油、纺织品、交通运输、化学品、钢铁、农业等)例子 技术矩阵表示生产一单位另一资源所需的每种资源的数量。例如,生产 1 单位石油需要 0.2 单位交通运输,0.4 单位化学品,和 0.1 单位石油本身。单位以百万美元计量。
Petroleum 0.10 0.40 0.60 0.20 Textiles 0.00 0.10 0.00 0.10 Transportation 0.20 0.15 0.10 0.30 Chemicals 0.40 0.30 0.25 0.20如果经济产出石油净额为 900 百万美元,纺织品,交通运输和化学品分别为 300,850 和 800,那么经济内部消费的各项数量是多少。将 Ax 相乘得到 b [880, 110, 550, 822.50]。
莱昂蒂夫封闭模型。 平衡经济:Ax = x。每个部门的总产量等于总消费。
莱昂蒂夫开放模型。 外部需求向量 d:Ax + d = x。如果存在具有非负分量的解,则矩阵是生产性的。$1 吨煤需要 0.3 度电,0.1 汽车,和 0.1 吨煤。
Coal 0.10 0.25 0.20 Electricity 0.30 0.40 0.50 Auto manufacturing 0.10 0.15 0.10需求 d = [50 75 125]。 (I - A)x = d。 x = [229.9, 437.8, 237.4]。
莱斯利矩阵模型。 在种群生态学中,莱斯利矩阵模拟了一个自然分段为年龄类别的种群的年龄分布。设 F[i]为第 i 类��性的繁殖率,S[i]为从第 i 类到 i+1 类的存活率。莱斯利矩阵 L 的构造方式为:
![莱斯利矩阵]()
下表列出了新西兰母羊的出生和存活概率。原始来源为:[G. Caughley, "季节性繁殖种群的参数," 生态学 48(1967)834-839]。
年龄(年) 出生率 存活率 0-1 0.000 0.845 1-2 0.045 0.975 2-3 0.391 0.965 3-4 0.472 0.950 4-5 0.484 0.926 5-6 0.546 0.895 6-7 0.543 0.850 7-8 0.502 0.786 8-9 0.468 0.691 9-10 0.459 0.561 10-11 0.433 0.370 11-12 0.421 0.000 莱斯利矩阵特性:唯一的正特征值和对应的特征向量的所有条目都是实数且符号相同。在这个例子中为 1.175。使用特征值进行分析。
莱斯利矩阵模型。 哥伦比亚河流域的大西洋鲑鱼(Kareiva 等人,2002)
eggs per yearling 4 eggs per 2 year old 20 eggs per 3 year old 60 survival rate of eggs = 0.005 yearling survival 0.3 2 year old survival 0.6 0 4 20 60 0.05 0 0 0 0 0.3 0 0 0 0 0.6 0主特征值 = 人口增长率 = 0.93(每年减少 7%)。稳态分布。
最大基数匹配。 给定一个双分图 G,每一边有 N 个顶点,找到最大基数匹配。形成 N×N 邻接矩阵。如果 i 和 j 之间有一条边,将 i-j 的条目设置为 0 到 2N 之间的随机数。如果行列式非零,则 G 有一个完美匹配。如果行列式为零,则至少有 1/2 的概率 G 没有完美匹配。重复以获得更好的误差容限。相同的思路来获得最大基数,但使用 rank(G)。注意:有更快的算法用于使用图找到完美匹配,但这个算法可以有效地并行化。
最大基数匹配。 重新做前面的练习,但为了避免溢出,将所有计算模 p,其中 p 是介于 N 和 2N 之间的素数。
最大基数匹配。 设计一个算法来找到完美匹配(如果存在)。
从 s 到 t 的路径数。 给定一个无向图 G,计算从 s 到 t 的路径数。查看邻接矩阵 G 的 k 次幂。注意路径不一定是简单的。
查找图的所有单纯顶点。 给定一个无向图 G,单纯顶点 是一个顶点 v,如果你取任意两个它的邻居 x 和 y,那么 x 和 y 之间就有一条边。在时间复杂度小于 N³ 的情况下找到图中所有的单纯顶点。提示:计算 N×N 顶点邻接矩阵 A,其中 A[vw] = 1 表示 v 和 w 之间有一条边或者 v = w,否则为 0。事实:一个顶点是单纯的当且仅当对于所有与 v 相邻的顶点 w,(A²)[vv] = (A²)[vw]。使用快速矩阵乘法计算 A²。
计算有向图中三角形的数量。 给定一个有向图 G,三角形 是三个顶点 x、y 和 z,使得存在一个有向循环 x->y->z->x 或 x->z->y->x。给定一个具有 N 个顶点的图,编写一个程序在时间复杂度小于 N³ 的情况下打印出所有的三角形。提示:计算 N×N 顶点邻接矩阵 A,其中 A[vw] = 1 表示从 v 到 w 有一条边,否则为 0(包括 v = w 的情况)。然后 (A²)[vw] 表示从 v 到 w 通过恰好一个其他中间顶点的路径数。只有当 (A²)[xz] > 0 且 A[zx] = 1 时,存在三角形 x->y->z。
数值秩。 数值秩 是非零奇异值的数量。使用奇异值分解(SVD)进行计算。
条件数。 条件数 κ 是最大奇异值与最小奇异值的比值。最初由 Alan Turing 提出!对于方阵,它衡量矩阵接近奇异的程度。对于矩形矩阵,它衡量矩阵接近秩不足的程度。它还有助于限制如果我们对 Ax = b 进行微小扰动,解可以改变多少。如果条件数非常大,那么当我们解 Ax = b 时就不会得到非常准确的结果。粗略地说,解的精度位数等于输入精度位数减去 log[2]κ。
这是一个特别病态的 4×4 矩阵,其条件数约为 10⁶⁵。这意味着在不到 65 位小数精度的情况下,我们不能期望在答案中有任何有效数字。[参考:S.M. Rump. A Class of Arbitrarily Ill-conditioned Floating-Point Matrices. SIAM Journal on Matrix Analysis and Applications (SIMAX), 12(4):645-653, 1991.]
-5046135670319638 -3871391041510136 -5206336348183639 -6745986988231149 -640032173419322 8694411469684959 -564323984386760 -2807912511823001 -16935782447203334 -18752427538303772 -8188807358110413 -14820968618548534 -1069537498856711 -14079150289610606 7074216604373039 7257960283978710对称正定。 一个 N×N 矩阵 A 是对称正定的,如果它是对称的且对于所有 x ≠ 0 都有 x^TAx > 0(等价地,所有特征值都是正的)。对称正定矩阵在统计学中作为协方差矩阵,在有限元方法中作为刚度矩阵,在线性回归的正规方程中出现。编写一个程序来测试一个矩阵是否是对称正定的。提示:在高斯消元中所有主元都是正的(且没有行交换)。
Cholesky 分解。 编写一个程序 Cholesky.java 来计算对称正定矩阵的Cholesky 分解:A = LL^T。使用 Cholesky-Banachiewicz 算法。
for (int i = 0; i < N; i++) { for (int j = 0; j <= i; j++) { double sum = 0.0; for (int k = 0; k < j; k++) { sum += L[i][k] * L[j][k]; } if (i == j) L[i][i] = Math.sqrt(A[i][i] - sum); else L[i][j] = 1.0 / L[j][j] * (A[i][j] - sum) } }多维缩放。 仅根据点间距离重建相对点位置。解决基本问题的技术用于在地球表面上绘制美国的三维地图等。在二维平面上给定 N 个点,定义一个 N×N 矩阵 A,使得 A[ij]是点 i 和点 j 之间的欧几里德距离。找到一个等距嵌入,即满足这些距离的一组点。提示:让 I 为 N×N 单位矩阵,让 B 为 N×N 的平方成对距离矩阵,让 u 为全 1 的 N×1 向量,B 为 n 个点之间的平方距离矩阵,并且让 C = (-1/2)(I - uuT/N) * B * (I - uuT/N)。如果点位于 m 维空间中,则 C 是正半定的并且秩为 m。让 C = LL^T 为 C 的 Cholesky 分解。然后 L 包含点的坐标。
压缩列存储。 压缩列存储(又称 Harwell-Boeing 存储)与压缩行存储完全类似,只是列按顺序存储。在使用 CCS 时实现矩阵-向量乘法。提示:使用 CCS 存储 A 与使用 CRS 存储 A^T 是相同的。
稀疏带矩阵。 使用压缩对角线存储来表示带状矩阵。高效实现矩阵-向量乘法。
多项式的根。 给定多项式 a[n]x^n + ... + a[1]x + a[0],我们可以通过找到伴随矩阵的特征值来计算其根。
![伴随矩阵]()
不稳定数值,因此不能保证可靠工作。
多项式的实根。 编写一个程序
RouthHurwitz.java,其输入为多项式 a[0] + a[1]x¹ + ... + a[n]x^N 的正实系数,并确定多项式的所有零点是否具有负实部。这是控制理论中的经典问题,可用于确定线性系统是否稳定(如果每个根的实部为负,则它是稳定的;否则不稳定)。根据著名的Routh-Hurwitz 稳定性判据,如果以下 N×N 矩阵的所有主子行列式严格为正,则为真。![Routh-Hurwitz]()
按照惯例,如果 m < 0 或 m > n,则 a[m] = 0。
解决方案:计算所有子行列式,运行不进行主元交换的高斯消元 - 如果遇到任何零或负主元,答案为否。我们可以像前面的练习一样计算特征值,但可以在不显式计算根的情况下完成。
图的连通性。 给定无向图 A(G)的邻接矩阵,最大特征值(lambda = 1)的重数等于 G 中连通分量的数量。如果-1 是 A(G)的特征值,则 G 是二部图。
9.6 优化
原文:
introcs.cs.princeton.edu/java/96optimization译者:飞龙
本节正在大规模施工中。
寻找根。
目标:给定函数 f(x),找到 x使得 f(x) = 0。非线性方程可以有任意数量的解。
x2 + y2 = -1 no real solutions
e-x = 17 one real solution
x2 -4x + 3 = 0 has two solutions (1, 3)
sin(x) = 0 has infinitely many solutions
无约束优化。 目标:给定函数 f(x),找到 x使得 f(x)被最大化或最小化。如果 f(x)可微,那么我们正在寻找一个 x,使得 f'(x*) = 0。然而,这可能导致局部最小值、最大值或鞍点。
二分法。 目标:给定函数 f(x),找到 x使得 f(x) = 0。假设你知道区间[a, b],使得 f(a) < 0 且 f(b) > 0。
牛顿法。 二次逼近。如果距离答案足够接近,收敛速度快。下面的更新公式用于找到 f(x)和 f'(x)的根。
root finding: xk+1 = xk - f'(xk)-1 f(xk)
optimization: xk+1 = xk - f''(xk)-1 f'(xk)
只有在距离解“足够接近”时,牛顿法才可靠。坏例子(Smale):f(x) = x³ - 2*x + 2。如果你从区间[-0.1, 0.1]开始,牛顿法会收敛到一个稳定的 2 周期。如果从负实根的左侧开始,它会收敛。
为了处理一维一般可微或两次可微函数,我们可以声明一个接口
public interface Function {
public double eval(double x);
public double deriv(double x);
}
程序 Newton.java 在可微函数上运行牛顿法,计算满足 f(x*) = 0 和 f'(x*) = 0 的点 x*。
在氢原子的 4s 激发态中找到电子的概率在半径 r 处给出:f(x) = (1 - 3x/4 + x²/8 - x³/192)² e^(-x/2),其中x是以玻尔半径(0.529173E-8 厘米)为单位的半径。程序 BohrRadius.java 包含 f(x),f'(x)和 f''(x)的公式。通过从 0、4、5、13 和 22 开始牛顿法,我们获得所有三个根和所有五个局部极小值和极大值。
高维牛顿法。 [可能省略或留作练习] 用于解决非线性方程组。一般来说,解决非线性方程组没有好的方法
xk+1 = xk - J(xk)-1 f(xk)
其中 J 是偏导数的雅可比矩阵。在实践中,我们不会显式计算逆矩阵。我们不是计算 y = J^(-1)f,而是解线性方程组 Jy = f。
为了说明这种方法,假设我们想要找到两个非线性方程组的解(x, y)。
x3 - 3xy2 - 1 = 0
3x2y - y3 = 0
在这个例子中,雅可比矩阵如下
J = [ 3x2 - 3y2 -6xy ]
[ 6x 3x2 - 3y2 ]
如果我们从点(-0.6, 0.6)开始牛顿法,我们很快就能获得一个根(-1/2, sqrt(3)/2),达到机器精度。另外两个根是(-1/2, -sqrt(3)/2)和(1, 0)。程序 TestEquations.java 使用接口 Equations.java 和 EquationSolver.java 来解决方程组。我们使用 Jama 矩阵库进行矩阵计算。
优化。 使用相同的方法来优化多个变量的函数。如果多变量函数足够平滑,那么存在很好的方法。
xk+1 = xk - H(xk)-1 g(xk)
需要梯度 g(x) = ∇f(x)和黑塞矩阵 H(x) = ∇²f(x)。该方法找到一个 x*,使得 g(x*) = 0,但这可能是一个极大值、极小值或鞍点。如果黑塞矩阵是正定的(所有特征值都是正的),那么它是一个极小值;如果所有特征值都是负的,那么它是一个极大值;否则它是一个鞍点。
此外,二���导数变化缓慢,因此可能不需要在每一步重新计算黑塞矩阵(或其 LU 分解)。在实践中,精确计算黑塞矩阵是昂贵的,因此其他所谓的拟牛顿方法更受青睐,包括 Broyden-Fletcher-Goldfarb-Shanno(BFGS)更新规则。
线性规划。 创建矩阵接口。推广两人零和博弈,许多组合优化问题,... 从网上运行 AMPL。
编程 = 计划。给出一些历史。决策问题长期以来不被认为在 P 中。1979 年,Khachian 肯定地解决了这个问题,并以一种称为椭球算法的几何分治算法登上了《纽约时报》的头条新闻。它需要 O(N⁴L)位操作,其中 N 是变量的数量,L 是输入中的位数。尽管这是优化中的一个里程碑,但它并没有立即导致一个实用的算法。1984 年,Karmarkar 提出了一个需要 O(N^(3.5)L)时间的投影缩放算法。它为高效实现打开了大门,因为通常比其最坏情况保证表现要好得多。1990 年代提出了各种内点方法,最佳已知复杂度界限为 O(N³ L)。更重要的是,这些算法是实用的,并且与单纯形法相竞争。它们还可以扩展以处理更一般的问题。
单纯形法。
线性规划求解器。 1947 年,乔治·丹齐格提出了线性规划的单纯形算法。是有史以来最伟大和最成功的算法之一。线性规划,但不具备工业强度。程序 LPDemo.java 演示了如何使用它。类MPSReader和MPSWriter可以解析标准 MPS 格式的输入文件并写入输出文件。在MPS 格式中测试 LP 数据文件。
更多应用。 OR-Objects 还包括图着色、旅行推销员问题、车辆路径规划、最短路径等。
练习
使用牛顿法找到一个 x(以弧度表示),使得 x = cos(x)。
使用牛顿法找到一个 x(以弧度表示),使得 x² = 4 sin(x)。
使用牛顿法找到一个 x(以弧度表示),使得 f(x) = sin(x) + x - exp(x)最小化。
使用牛顿法找到解决方程的(x, y)
x + y - xy = -2 x exp(-y) = 1从点(0.1, -0.2)开始,该点接近真实根(0.09777, -2.325)。
使用牛顿法找到解决方程的(x, y)
x + 2y = 2 x2 + 4y2 = 4从点(1, 2)开始,该点接近真实根(0, 1)。
使用牛顿法找到解决方程的(x, y)
x + y = 3 x2 + y2 = 9从点(2, 7)开始。
使用牛顿法最小化 f(x) = 1/2 - x e^(-x²)。提示:f'(x) = (2x²-1)e^(-x²),f''(x) = 2x(3-2x²)e^(-x²)。
使用牛顿法找到以下两个变量函数的所有最小值、最大值和鞍点。
f(x,y) = 3(1-x)2 exp(-x2-(y+1)2) - 10((y/6)-y3) exp(-x2-y2)
创意练习
伯努利数。 伯努利数出现在正切函数的泰勒展开式、欧拉-麦克劳林求和公式和黎曼ζ函数中。它们可以通过 B[0] = 1 的递归定义,并利用从 j = 0 到 N 的二项式(N+1, j) B[j]之和为 0 的事实来定义。例如 B[1] = -1/2,B[2] = 1/6,B[3] = 0,B[12] = -691/2730。伯努利通过手工计算了前 10 个伯努利数;欧拉计算了前 30 个。1842 年,阿达·洛夫莱斯建议查尔斯·巴贝奇利用他的分析引擎设计一个计算伯努利数的算法。编写一个程序 Bernoulli.java,它接受一个命令行参数 N 并打印出前 N 个伯努利数。使用第 9.2 节中的 BigRational.java 数据类型。
偏心率异常。(克利夫·莫勒)偏心率异常出现在开普勒的行星运动模型中,并满足 M = E - e sin E,其中 M 是平均偏近角(24.851090),e 是轨道离心率(0.1)。解出 E。
带复数的牛顿法。 实现用于找到方程的复数根的牛顿法。使用 Complex.java 并完全实现牛顿法,就像找到实根一样,但使用复数。
9.7 数据分析
原文:
introcs.cs.princeton.edu/java/97data译者:飞龙
本节正在大规模施工中。
正态分布。 一堆随机硬币翻转的总和。
随机抽样。 对某些未知常数进行物理测量,例如,重力常数。每次测量都会有一定的误差,因此我们每次都会得到略有不同的结果。我们的目标是尽可能准确和精确地估计未知数量。样本均值和样本方差定义如下:

样本均值估计未知常数,样本方差衡量估计的精确性。在相当一般的条件下,当 n 变大时,样本均值服从均值为未知常数,方差为样本方差的正态分布。95% 的近似置信区间为

置信区间衡量了我们对未知常数估计的不确定性。这意味着如果我们多次执行相同的实验,我们预计估计的均值会在给定的区间内出现的概率为 95%。数字 1.96 出现是因为正态随机变量在 -1.96 和 1.96 之间的概率恰好为 95%。如果我们想要 90% 或 99% 的置信区间,分别替换为 1.645 或 2.575。上述置信区间并非精确。这是因为我们正在估计标准差。如果 n 较小(比如小于 50),我们应该使用具有 n-1 自由度的学生 t 分布的精确 95% 置信区间。例如,如果有 n = 25 个样本,则我们应该使用 2.06 而不是 1.96。这些数字可以使用 or124.jar 计算,其中包含 OR-Objects library。程序 ProbDemo.java 演示了如何使用它。
NormalDistribution normal = new NormalDistribution(0, 1);
System.out.println(normal.cdf(1.9605064392089844));
System.out.println(normal.inverseCdf(0.975));
StudentsTDistribution students = new StudentsTDistribution(25 - 1);
System.out.println(students.cdf(2.0638981368392706));
System.out.println(students.inverseCdf(0.975));
实现. 程序 Average.java 是上述公式的��接实现。这个公式涉及对数据的两次遍历:一次计算样本均值,一次计算样本方差。因此,我们将数据存储在一个数组中。这看起来有些浪费,因为我们可以使用备用的样本方差的教科书公式一次计算两者。

我们避免使用这种单次算法,因为它在数值上是不稳定的。(参见练习 XYZ 和 XYZ。)当数据方差较小但有效数字位数较多时,这种不稳定性最为明显。事实上,它可能导致程序对负数进行平方根运算!(参见练习 XYZ。)这种微妙之处会让许多未经培训的程序员感到惊讶。事实上,甚至有些经验丰富的程序员也会感到惊讶。微软 Excel 版本 1.0 到 2002 实现了不稳定的单次算法在超过十几个统计库函数中。因此,您可能会在没有警告的情况下遇到不准确的结果。这些错误已经在 Excel 2003 发布时得到修复。
置信区间. 一月与七月的温度。
调查抽样。 人口普查调查、温度读数、选举出口民意调查、制造过程的质量控制、审计财务记录、流行病学等。通常,报纸会将某项调查的结果报告为类似于 47% ± 3%。这到底意味着什么?通常隐含地假定为 95%的置信区间。我们假设人口由 N 个元素组成,我们抽取大小为 n 的样本,样本 i 有一个关联的实际值 x[i],它可以代表重量或年龄。它也可以表示 0 或 1 来表示某种特征是否存在或不存在(例如,计划投票给 Kerry)。随机抽样的技术适用,只是我们需要对有限人口规模进行修正。

当 N 相对于 n 很大(只抽取了人口的一小部分)时,可以忽略有限人口效应。
直方图。 程序Histogram.java在数据累积时动态显示直方图。
简单线性回归。 1800 年,朱塞佩·皮亚齐发现了一个看似新星,并在 41 天内追踪了它的运动,然后由于恶劣天气而失去了踪迹。他感到惊讶,因为它的运动方向与其他星星相反。卡尔·弗里德里希·高斯利用他新发明的最小二乘法预测了星星的位置。高斯因根据他的预测找到了这颗星星而变得著名。事实证明,这颗天体是一颗小行星,是有史以来发现的第一颗小行星。现在,最小二乘法在许多学科中应用,从心理学到流行病学再到物理学。高斯的著名计算涉及使用 6 个变量预测物体的位置。我们首先考虑简单线性回归,它只涉及一个预测变量 x,并且我们对响应建模 y = &beta[0] + β[1]x。给定一系列 n 对实数(x[i], y[i]),我们定义 x[i]处的残差为 r[i] = (y[i] - β[0] - β[1]x[i])。目标是估计未观察到的参数β[0]和β[1]的值,使得残差尽可能小。最小二乘法是选择参数使残差的平方和最小化。使用基本微积分,我们可以得到经典的最小二乘估计:

程序 LinearRegression.java 从标准输入读取 n 个测量值,绘制它们,并根据最小二乘度量计算最适合数据的直线。
评估拟合解。 为了衡量拟合的好坏,我们可以计算决定系数 R²,它衡量数据中可以由变量 x 解释的变异性的比例。

我们还可以估计标准误差,β[0]和β[1]的回归估计的标准误差,以及两个未知系数的 95%近似置信区间。

算法的运行时间。 对两边取对数。斜率是指数,截距是常数。
绘制纬度与一月温度的关系。在 2 个标准偏差内的点用黑色表示,在 2 到 3 之间用蓝色表示,在 3 以上用绿色表示。19 个异常值中有 18 个在加利福尼亚州或俄勒冈州。另一个在科罗拉多州冈尼森县,那里的海拔非常高。也许需要将经度和海拔高度纳入模型中...
正态性检验。
多元线性回归。 多元线性回归通过允许多个预测变量而不仅仅是一个来推广简单线性回归。我们对响应 y = β[0] + β[1]x[1] + ... + β[p]x[p] 进行建模。现在,我们有一系列 n 个响应值 y[i],以及一系列 n 个预测 向量(x[i1],x[i2],...,x[ip])。目标是估计参数向量(β[0],...,β[p]),以使平方误差的总和最小化。在矩阵表示中,我们有一个过度确定的方程组 y = Xβ。

我们的目标是找到一个最小化 ||X^(β - y|| 的向量 β。假设 X 具有完整的列秩,我们可以通过解决 正规方程 XTXβ = XTy 来计算 β 的估计值。解决正规方程的最简单方法是显式计算 A = XTX 和 b = XTy,并使用高斯消元法解方程组 Ax = b。计算 β 的一个数值稳定的算法是计算 QR 分解 X = QR,然后通过回代解三角形系统 Rβ = Q^Ty 来解决。这正是 Jama 的 solve 方法在面对一个过度确定系统时所做的(假设矩阵具有完整的列秩)。程序 MultipleLinearRegression.java 是这种方法的一个直接实现。参见练习 XYZ,其中介绍了一种基于 SVD 的方法,即使系统没有完整的列秩也可以工作。)
一个例子。 天气数据集和来自这个参考资料的例子。2001 年 3 月,美国 1070 个气象站的平均最高日温度。预测因子 = 纬度(X1)、经度(X2)和海拔(X3)。模型 Y = 101 - 2 X1 + 0.3 X2 - 0.003 X3。随着经度增加(向西),温度增加,但随着纬度增加(向北)和海拔增加,温度降低。纬度对温度的影响在西部和东部哪个更大?绘制 3 月份温度与纬度的散点图(以 93 度为中位数的经度分割)。绘制残差与拟合值的图。不应显示任何模式。
评估模型。 误差方差 s² 是平方误差之和除以自由度(n - p - 1)。标准方差矩阵的对角线条目是 σ²(XTX)(-1) 估计参数估计的方差。

多项式回归。 预测变量不只是一个。我们对响应 y = β[0] + β[1]x¹ + ... + β[p]x^p 进行建模。PolynomialRegression.java 是执行多项式回归的数据类型。
离散傅立叶变换。
发现高效算法可以产生深远的社会和文化影响。离散傅立叶变换是一种将 N 个样本(例如声音)的波形分解为周期分量的方法。暴力解决方案的时间复杂度与 N²成正比。27 岁时,高斯提出了一种仅需 N log N 步的方法,并用它来分析小行星谷神星的周期运动。这种方法后来被库利和图基在 1965 年重新发现并推广,他们描述了如何在数字计算机上高效实现它。他们的动机是监视苏联的核试验和跟踪苏联潜艇。快速傅立叶变换已成为信号处理的基石,并是 DVD 播放器、手机和磁盘驱动器等设备的关键组件。它还构成许多流行数据格式的基础,包括 JPEG、MP3 和 DivX。还有语音分析、音乐合成、图像处理。医生们经常在医学成像中使用快速傅立叶变换,包括磁共振成像(MRI)、磁共振光谱(MRS)、计算机辅助断层扫描(CAT 扫描)。另一个重要应用是快速解决具有周期边界条件的偏微分方程,尤其是泊松方程和非线性薛定谔方程。还用于模拟分数布朗运动。如果没有快速计算 DFT 的方��,这一切都将不可能。查尔斯·范·洛恩写道:“快速傅立叶变换是本世纪真正伟大的计算发展之一。它已经改变了科学和工程的面貌,可以毫不夸张地说,没有快速傅立叶变换,我们所知道的生活将会截然不同。”
傅立叶分析是一种通过不同频率的正弦波(复指数)的和来近似函数(信号)的方法。在使用计算机时,我们还假设连续函数由在规则间隔上采样的有限数量点来近似。正弦波在物理学中起着至关重要的作用,用于描述振荡系统,包括简谐运动。人耳是声音的傅立叶分析器。粗略地说,人类听觉通过将声波分解为正弦分量来工作。每个频率在耳蜗膜中的不同位置共振,并将这些信号沿听神经传递到大脑。DFT 的主要应用之一是识别数据中的周期性及其相对强度,例如在声学数据中滤除高频噪声,分离天气中的昼夜和年度循环,分析天文数据,执行大气成像,并识别经济数据中的季节性趋势。
长度为 N 的复向量 x 的离散傅立叶变换(DFT)定义为

其中 i 是-1 的平方根,ω = e^(-2iπ/N)是主 N 次单位根。我们还可以将 DFT 解释为矩阵-向量乘积 y = F[N] x,其中 F[N]是 N×N 矩阵,其第 j 行第 k 列为ω^(jk)。例如,当 N = 4 时,

我们注意到一些作者将傅立叶矩阵定义为我们的傅立叶矩阵的共轭,并通过因子 1 / sqrt(N)对其进行归一化,使其成为幺正的。直觉:设 x[i]为时间间隔从 0 到 T 的信号样本,f[i]为 DFT。那么 f[0] / n 是信号在该间隔内的平均值的近似值。复数 f[j]的模(绝对值)和幅角(角度)表示频率为 j / T 的信号分量的振幅和相位(一半)。
快速傅里叶变换。 直接从定义或通过密集矩阵-向量乘法计算 N 长度向量的 DFT 是直截了当的。这两种方法都需要二次时间。快速傅里叶变换(FFT)是一种巧妙的方法,它以与 N log N 成比例的时间计算 DFT。它通过利用傅里叶矩阵 F 的对称性来工作。关键思想是利用 n 次单位根的性质将大小为 n 的向量的傅里叶变换与大小为 n/2 的两个向量上的两个傅里叶变换相关联。

其中 x[even]表示大小为 n/2 的向量,由 x[0]、x[2]、...、x[n-2]组成,x[odd]表示由 x[1]、x[3]、...、x[n-1]组成的向量,矩阵 I[n/2]是 n/2 乘 n/2 的单位矩阵,矩阵 D[n/2]是对角线条目为ω^k 的对角矩阵。基数 2 Cooley-Tukey FFT 使用这个递归公式以分治风格的框架计算 DFT。请注意,我们隐含地假设 N 是 2 的幂。程序 FFT.java 是这个方案的基本实现。它依赖于在第 xyz 节中开发的 Complex.java ADT。程序 InplaceFFT.java 是原位变体:它只使用 O(1)额外内存。
逆 FFT。 逆 DFT 定义为:F[N]的逆是其复共轭,缩小了 N 倍。因此,要计算 x 的逆 DFT:计算 x 的共轭的 DFT,取结果的共轭,并将每个值乘以 N。
按键电话。 触摸音 ® 电话使用称为双音多频(DTMF)的系统将按键编码为音频信号。根据下表,每个按键按键与两个音频频率相关联
Freqs 1209 Hz 1336 Hz 1477 Hz 1633Hz
--------------------------------------------
697 Hz 1 2 3 A
770 Hz 4 5 6 B
852 Hz 7 8 9 C
941 Hz * 0 # D
例如,当按下键 7 时,电话会在频率为 770 Hz 和 1209 Hz 的信号之间发出信号,并将它们相加。频率应该在规定值的 1.5%范围内,否则电话公司会忽略它。高频率必须至少与低频率一样响亮,但不能比低频率响亮超过 3 分贝。
商业实现。 由于 FFT 的重要性,有许多高效的 FFT 算法的丰富文献,还有许多高度优化的库实现可用(例如,Matlab 和Fastest Fourier Transform in the West)。我们的实现是一个基本版本,捕捉了最显著的思想,但可以通过多种方式改进。例如,商业实现适用于任何 N,而不仅仅是 2 的幂。如果输入是实数(而不是复数),它们会利用额外的对称性并运行得更快。它们还可以处理多维 FFT。我们的 FFT 实现的内存占用比所需的要高得多。经过精心设计,甚至可以在原地执行 FFT,即除了 x 之外不需要额外的数组。商业 FFT 实现还使用迭代算���而不是递归。这可以使代码更高效,但更难理解。高性能计算机具有专门的矢量处理器,可以比等效的标量操作序列更快地执行矢量操作。尽管计算科学家通常根据浮点操作的数量来衡量性能,但对于 FFT 来说,内存访问的数量也至关重要。商业 FFT 算法特别关注在内存中移动数据所带来的成本。并行 FFT。在硬件中实现。
卷积。 两个向量的卷积是表示两个向量之间重叠的第三个向量。它在许多应用中出现:统计学中的加权移动平均、光学中的阴影和声学中的回声。给定长度为 N 的两个周期信号 a 和 b,a 和 b 的循环卷积定义为

我们使用符号 c = a ⊗ b。向量 b 称为脉冲响应、滤波器、模板或点扩散函数。为了看到卷积的重要性,考虑两个 N 次多项式 p(x)和 q(x)。观察到 r(x) = p(x) q(x)的系数为

通过将 p 和 q 的系数进行卷积得到,其中 p 和 q 在长度为 2N 的情况下填充了前导 0。为了便于计算,我们还可以填充额外的前导 0,使其长度成为 2 的幂。这模拟了线性卷积,因为我们不希望周期性边界条件。
傅里叶分析的一个基石结果是卷积定理。它表明两个向量卷积的 DFT 是两个向量的 DFT 的逐点乘积。
DFTN(a ⊗ b) = DFTN(a) · DFTN(b)
卷积定理很有用,因为逆 DFT很容易计算。这意味着我们可以通过进行三次单独的 FFT 来在 N log N 步骤中计算循环卷积(从而进行多项式乘法)。
a ⊗ b = (DFTN)-1 (DFTN(a) · DFTN(b))
这在两个层面上都是令人惊奇的。首先,这意味着我们可以比蛮力更快地相乘两个实(或复)多项式。其次,这种方法依赖于复数,即使相乘两个实多项式似乎与虚数无关。
Matlab 提供了一个名为conv的函数,用于执行两个向量的线性卷积。然而,他们的实现需要二次时间。在许多应用中,向量很大,比如有 100 万个条目,使用这个库函数作为黑匣子是不可接受的。通过利用我们对算法和复杂性的理解,我们可以用 FFT 替换库解决方案,从而得到优化的解决方案!
X = fft( [x zeros(1, length(y) - 1)] )
Y = fft( [y zeros(1, length(x) - 1)] )
c = ifft(X .* Y)
正如我们刚刚见证的,通过首先将数据从时域转换到频域,我们可以在计算卷积时取得显著的性能改进。这个原则也适用于相关问题,包括交叉相关、自相关、多项式乘法、离散正弦和余弦变换。这也意味着我们对循环矩阵和 Toeplitz 矩阵有快速矩阵-向量乘法算法,这些矩阵出现在偏微分方程的数值解中。f = 信号,y = 频谱。f = 脉冲响应,y = 频率响应。
2D DFT。(练习)通过对每一列进行 DFT,然后对结果值的每一行进行 DFT,计算一个 N×N 矩阵的 2D DFT。N² log N。
Q + A
为什么称之为学生 T 分布?
由吉尼斯啤酒公司的一名名叫威廉·高斯特的员工于 1908 年发现,但吉尼斯不允许他以自己的名义发表,所以他使用了“学生”。
为什么要最小化平方误差的总和而不是绝对误差的总和或其他度量?
简短回答:这是科学家在实践中所做的事情。还有一些数学上的理由。高斯-马尔可夫定理说,如果你有一个线性模型,其中误差均值为零,方差相等,并且不相关,那么 a 和 b 的最小二乘估计(最小化平方误差和的那些)在所有无偏估计中具有最小的方差。如果我们进一步假设误差是独立的并且服从正态分布,那么我们可以推导出95%或 99%的置信区间...
我在哪里可以获得一个图表库?
查看JFreeChart。这里有一些关于如何使用它的说明。或者查看科学图形工具包,用于创建科学数据的交互式、出版质量的图形。
练习
棒球统计。 对棒球统计进行一些分析。
直方图。 修改
Histogram.java,使其无需提前输入范围。直方图。 修改
Histogram.java,使其具有 10 个桶。饼图。
茎叶图。
简单线性回归。 修改程序 LinearRegression.java 以绘制和缩放数据。再次,我们要谨慎选择一个稳定的算法,而不是稍微简单的一次遍历替代方案。
创意练习
一次遍历算法。 编写一个程序 OnePass.java,使用备用教科书公式一次计算样本均值和方差(而不是两次)。
sum = x1 + ... + xN sum2 = x1x1 + ... + xNxN σ = sqrt ( (N sum2 - sum * sum) / (N*(N-1)) )通过插入 n = 3, x[1] = 1000000000, x[2] = 1000000001 和 x[3] = 1000000002 来验证它在数值上不稳定。一次遍历算法给出方差为 0,但真实答案是 1。另外,通过插入 n = 2, x[1] = 0.5000000000000002 和 x[2] = 0.5000000000000001 的输入来验证它可能导致对负数求平方根。与 Average.java 进行比较。
样本方差。 实现以下稳定的一次遍历算法来计算样本方差。验证公式是否正确。
m1 = x1, mk = mk-1 + (xk - mk-1)/k s1 = 0, sk = sk-1 + ((k-1)/k)(xk - mk-1)2 μ = mN σ = sqrt(sN/(N-1))正态分位数图。 为了测试给定数据集是否遵循正态分布,创建一个正态分位数图并检查数据点是否位于(或接近)一条直线上。要创建正态分位数图,对 N 个数据点进行排序。然后将第 i 个数据点绘制在Φ^(-1)(i / N)上。
钻石 3D 图。 编写一个程序来读取一组三维数据并绘制类似下面的钻石图的数据。钻石图比 3D 条形图有几个优点
![]()
多项式曲线拟合。 假设我们有一组 N 个观测值(x[i], y[i]),我们想要使用低次多项式对数据进行建模。
![多项式曲线拟合]()
实证收集 n 个样本:(x[i], y[i])。用矩阵表示,我们的最小二乘问题是:
![范德蒙德矩阵]()
矩阵 X 称为范德蒙德矩阵,如果 n ≥ p 且 x[i]不同,则具有完整的列秩。我们的问题是一般线性回归的特例。解向量β是最佳拟合度 p 多项式的系数。
秩亏线性回归。 更好的方法:使用 SVD。具有更好的数值稳定性特性。即使 A 没有满秩,也可以工作。计算瘦 SVD:A = U[r]Σ[r]V[r]T。这里 r 是 A 的秩,U[r],Σ[r]和 V[r]分别是 U,Σ和 V 的前 r 列。伪逆 A† = Ur^(-1)V[r]T 和最小二乘估计 x* = A†b。伪逆很好地推广了矩阵的逆:如果 A 是方阵且可逆,则 A† = A(-1)。如果 A 是瘦的且具有满秩,则 A† = (ATA)(-1)AT。要计算 A†b,不要显式形成伪逆。相反,计算 v = VTb,w = Σ^(-1)u,x* = Uw。注意Σ^(-1)易于计算,因为Σ是对角线的。
在 Matlab 中,
pinv(A)给出伪逆,svd(A, 0)给出瘦 SVD 用于瘦矩阵(但不适用于胖矩阵!)欠定系统。 在数据拟合应用中,方程组通常是过度确定的且 A 是瘦的。在控制系统中,我们有一个欠定的方程组,目标是找到一个解 Ax* = b,使得 x*的范数最小化。同样,SVD 拯救了我们。如果 A 具有完整的列秩,则 A^†b 就是这样一个解。
多项式乘法。 给定两个分别为 m 和 n 次的多项式,描述如何在 O(m log n)的时间内计算它们的乘积。
聚类。 生物学中的进化树,商业中的市场研究,文理学中的画家和音乐家分类,社会学中的调查响应分类,[参考:盖伊·布洛克]
9.8 蒙特卡罗模拟
原文:
introcs.cs.princeton.edu/java/98simulation译者:飞龙
本节正在大规模施工中。
1953 年,恩里科·费米、约翰·帕斯塔和斯坦斯劳·乌拉姆创建了第一个“计算机实验”来研究振动的原子晶格。非线性系统无法通过经典数学进行分析。
模拟=模拟物理系统的分析方法。蒙特卡洛模拟=使用随机生成的值来处理不确定变量。以摩纳哥著名赌场命名。在计算的每个步骤中,重复多次以生成可能情景范围,并对结果进行平均。广泛适用的蛮力解决方案。计算密集型,因此在其他技术失败时使用。通常,准确性与重复次数的平方根成正比。这些技术广泛应用于各个领域,包括:设计核反应堆,预测恒星的演化,预测股市走势等。
生成随机数。 数学库函数Math.random生成大于或等于 0.0 且小于 1.0 的伪随机数。如果要生成随机整数或布尔值,最好的方法是使用库Random。程序 RandomDemo.java 演示了如何使用它。
Random random = new Random();
boolean a = random.nextBoolean(); // true or false
int b = random.nextInt(); // between -2³¹ and 2³¹ - 1
int c = random.nextInt(100); // between 0 and 99
double d = random.nextDouble(); // between 0.0 and 1.0
double e = random.nextGaussian(); // Gaussian with mean 0 and stddev = 1
请注意,您应该在程序中只创建一个新的Random对象。通过创建多个对象,您不会获得更多“随机”的结果。对于调试,您可能希望每次程序执行时生成相同的伪随机数序列。要做到这一点,请使用带有long参数的构造函数。
Random random = new Random(1234567L);
伪随机数生成器将使用 1234567 作为种子。对于需要密码安全的伪随机数,例如密码学或老丨虎丨机,使用SecureRandom。
线性同余随机数生成器。 对于整数类型,我们必须注意溢出。考虑 a * b (mod m)作为一个例子(无论是在 a^b (mod m)的上下文中还是线性同余随机数生成器中:给定常数 a、c、m 和种子 x[0],迭代:x = (a * x + c) mod m。Park 和 Miller 建议 a = 16807,m = 2147483647,c = 0 适用于 32 位有符号整数。为避免溢出,使用 Schrage 的方法。
Precompute: q = m / a, r = m % a
Iterate: x = a * (x - x/ q) * q) - r * (x / q)
练习:计算循环长度。
概率函数库。 OR-Objects 包含许多经典的概率分布和随机数生成器,包括正态分布、F 分布、卡方分布、伽玛分布、二项分布、泊松分布。您可以在这里下载jar 文件。程序 ProbDemo.java 演示了如何使用它。它从伽玛分布生成一个随机值和从二项分布生成 5 个随机值。请注意,该方法称为getRandomScaler而不是getRandomScalar。
GammaDistribution x = new GammaDistribution(2, 3);
System.out.println(x.getRandomScaler());
BinomialDistribution y = new BinomialDistribution(0.1, 100);
System.out.println(y.getRandomVector(5));
排队模型。 M/M/1 等。一个制造设施有 M 台相同的机器。每台机器在指数分布的平均时间 1 / μ后发生故障。一个维修人员负责维护所有机器,修理一台机器的时间服从平均时间 1 / λ的指数分布。模拟没有机器运行的时间比例。
扩散限制聚集。
扩散=经历随机漫步。物理过程扩散限制聚集(DLA)模拟了表面上聚集物质的形成,包括地衣生长、溶液中聚合物的生成、柴油发动机气缸壁上的碳沉积、电放电路径和城市定居。
当粒子一个接一个地释放到空间体积中时,受到随机热运动的影响,模拟的聚集形成。有限的概率使得粒子之间的短程吸引会影响运动。两个接触的粒子将粘在一起形成一个更大的单位。随着聚集中形成占用位置的簇,粘附的概率增加,刺激进一步生长。使用蒙特卡洛方法在 2D 中模拟这个过程:创建一个 2D 网格,并通过一个发射区域逐个向晶格引入粒子。粒子被释放后,它会进行随机漫步,直到它要么粘附到聚集上,要么漫步到晶格之外的杀伤区。如果漫步的粒子进入一个空位旁边的占用位置,那么粒子的当前位置自动成为聚集的一部分。否则,随机漫步继续。重复这个过程,直到聚集包含一定数量的粒子。参考资料: Wong, Samuel, Computational Methods in Physics and Engineering, 1992.
程序 DLA.java 模拟了 DLA 的生长过程,具有以下特性。它使用了辅助数据类型 Picture.java。将初始聚集设置为 N×N 格子的底行。从顶行的随机单元中释放粒子。假设粒子向上的概率为 0.15,向下的概率为 0.35,向左或向右的概率各为 1/4。持续进行,直到粒子粘附到相邻单元(上方、下方、左侧、右侧或四个对角线之一)或离开 N×N 格子。向下的首选方向类似于温度梯度对布朗运动的影响,或者类似于晶体形成时,聚集的底部比顶部冷却更多;或者类似于重力的影响。为了效果,我们按照从红色到紫色的彩虹顺序对粒子进行着色。下面是三个 N = 176 的模拟;这里是一个 N = 600 的图像 dla-big.png。
![]() |
![]() |
![]() |
|---|
布朗运动。 布朗运动是一种随机过程,用于模拟各种物理现象,包括墨水在水中扩散以及量子物理学预测的原子粒子的行为。(更多应用)。宇宙中的基本随机过程。它是离散随机漫步的极限,也是高斯分布的随机模拟。现在广泛应用于计算金融、经济学、排队理论、工程学、机器人技术、医学成像、生物学和柔性制造系统。1828 年由苏格兰植物学家罗伯特·布朗首次研究,并在 1905 年由阿尔伯特·爱因斯坦进行数学分析。让-巴蒂斯特·佩兰进行实验证实了爱因斯坦的预测,并因此获得了诺贝尔奖。一个小程序来说明可能控制布朗运动原因的物理过程。
模拟布朗运动。 由于布朗运动是一个连续和随机的过程,我们只能希望在有限区间上绘制一条路径,在有限数量的点上采样。我们可以在这些点之间进行线性插值(即连接这些点)。为简单起见,我们假设区间从 0 到 1,采样点 t[0],t[1],...,t[N]在这个区间内等间距。要模拟标准布朗运动,重复生成均值为 0,标准差为 sqrt(1/N)的独立高斯随机变量。在时间 i 处的布朗运动值是前 i 个增量的总和。
![]() |
![]() |
![]() |
|---|
几何布朗运动。 布朗运动的一个变体被广泛用于模拟股票价格,而诺贝尔奖获得者布莱克-斯科尔斯模型是基于这个随机过程的。具有漂移μ和波动率σ的几何布朗运动是一个可以模拟股票价格的随机过程。参数μ表示百分比漂移。如果μ = 0.10,则我们预期股票每年增长 10%。参数σ表示百分比波动率。如果σ = 0.20,则一年内股价的标准差大约是当前股价的 20%。要从时间 t = 0 模拟几何布朗运动到时间 t = T,我们遵循标准布朗运动的相同过程,但是将增量相乘,而不是相加,并且结合漂移和波动率参数。具体来说,我们将当前价格乘以(1 + μΔt + σsqrt(Δt)Z),其中 Z 是标准高斯,Δt = T/N,从 X(0) = 100 开始,σ = 0.04。
布莱克-斯科尔斯公式。 移至此处?
Ising 模型。 电子围绕原子核的运动产生与原子相关的磁场。这些原子磁铁表现得很像传统磁铁。通常,磁铁指向随机方向,所有力相互抵消,在宏观物质块中没有整体磁场。然而,在一些材料中(例如铁),磁铁可以排列产生可测量的磁场。19 世纪物理学的一个重大成就是描述和理解控制原子磁铁的方程。状态 S 发生的概率由玻尔兹曼概率密度函数给出,P(S) = e^(-E(S)/kT) / Z,其中 Z 是归一化常数(分区函数)对所有状态 A 求和 e^(-E(A)/kT),k 是玻尔兹曼常数,T 是绝对温度(以开尔文度表示),E(S)是系统在状态 S 的能量。
Ising 模型被提出来描述晶体材料中的磁性。还模拟其他自然现象,包括:液体的冻结和蒸发,蛋白质折叠,以及玻璃物质的行为。
Ising 模型。 玻尔兹曼概率函数是磁性的一个优雅模型。然而,将其应用于计算真实铁磁体的磁性属性并不实际,因为任何宏观铁块包含大量原子,并且它们以复杂的方式相互作用。Ising 模型是一个简化的磁铁模型,捕捉了许多重要特性,包括在临界温度下的相变。(在此温度以上,没有宏观磁性,在此以下,系统表���出磁性。例如,铁在大约 770 摄氏度左右失去磁化。值得注意的是,转变是突然的。)参考链接
20 世纪 20 年代由 Lenz 和 Ising 首次引入。在 Ising 模型中,铁磁体被分成一个 N×N 的单元格网格。(顶点=晶体中的原子,边=相邻原子之间的键。)每个单元格包含一个称为自旋的抽象实体。单元格 i 的自旋 s[i]处于两种状态之一:指向上方(+1)或指向下方(-1)。单元格之间的相互作用仅限于最近邻居。系统的总磁性 M = s[i]的总和。系统的总能量 E = - J s[i] s[j]的总和,其中总和取自所有最近邻居 i 和 j。常数 J 衡量自旋-自旋相互作用的强度(以能量单位,如 ergs)。[该模型可以扩展以允许与外部磁场的相互作用,此时我们在所有站点 k 上添加项-B s[k]的总和。]如果 J > 0,当自旋对齐时(都是+1 或都是-1)能量最小化-这模拟了铁磁性。如果 J < 0,当自旋相反对齐时能量最小化-这模拟了反铁磁性。
鉴于这个模型,在统计力学中的一个经典问题是计算期望的磁性。状态是指定 N² 晶格单元格中每个自旋的规范。系统的期望磁性 E[M] = 在所有状态 S 上的 M(S) P(S)的总和,其中 M(S)是状态 S 的磁性,P(S)是根据玻尔兹曼概率函数发生状态 S 的概率。不幸的是,这个方程不适合直接计算,因为对于 N×N 晶格,状态 S 的数量为 2^(N*N)。直接的蒙特卡洛积分不起作用,因为随机点不会对总和有太大贡献。需要选择性采样,理想情况下按比例采样点 e^(-E/kT)。 (1925 年,Ising 解决了一维问题-没有相变。1944 年,Onsager 通过一次成功的解决了 2D Ising 问题。他的解决方案表明它具有相变。不太可能在 3D 中解决-请参见难以解决部分。)
*Metropolis 算法。*蒙特卡罗方法的广泛使用始于 Metropolis 算法,用于计算刚性球体系统。在 Metropolis、Rosenbluth、Rosenbluth、Teller 和 Teller 之间的晚餐交谈后于 1953 年发表。广���用于研究原子系统的平衡性质。使用马尔可夫链采样,使用 Metropolis 规则:如果ΔE ⇐ 0,则从 A 转换到 B 的概率为 1,如果ΔE > 0,则为 e^(-ΔE/kT)的概率。当应用于 Ising 模型时,这个马尔可夫链是遍历的(类似于 Google PageRank 的要求),因此 Metropolis 算法的理论适用。收敛到稳态分布。
程序 Cell.java、State.java 和 Metropolis.java 实现了 2D 晶格的 Metropolis 算法。Ising.java 是一种过程式编程版本。"通过掷骰子来做物理学。"通过一系列简单的随机步骤模拟复杂的物理系统。
测量物理量。当系统热化(系统已经达到与其周围环境的共同温度 T 的热平衡)时,测量磁性、能量、比热。随时间计算平均能量
相变。 当温度 T[c]为 2 / ln(1 + sqrt(2)) = 2.26918 时,相变发生。T[c]被称为居里温度。绘制磁化 M(所有自旋的平均值)与温度(kT = 1 至 4)的关系。斜率的不连续是二阶相变的标志。斜率趋近于无穷大。绘制能量(所有自旋-自旋相互作用的平均值)与温度(kT = 1 至 4)的关系。通过相变的平滑曲线。与精确解进行比较。算法明显减慢的临界温度。以下是 J/kT = 0.4(热/无序)和 0.47(冷/有序)的第 5000 个样本轨迹。随着温度的降低,系统变得磁性;此外,随着温度的降低,相邻位置具有相同自旋的概率增加(更多聚集)。
实验。
从远高于临界温度开始。状态收敛到几乎均匀,不受初始状态(全部上、全部下、随机)的影响,并且波动迅速。零磁化。
从远低于临界温度开始。所有自旋都具有相同的值(全部上或全部下)。形成一些小的相反自旋团簇。
从远低于临界温度开始。从随机自旋开始。每种自旋形成大团簇;最终模拟会做出决定。大团簇在上自旋或下自旋中同等可能。
从接近临界温度开始。大团簇形成,但波动非常缓慢。
![]() |
![]() |
|---|
伊辛模型在 1D 和 2D 中的精确解已知;在 3D 和非平面图中是 NP 难的。
模拟二元合金和自旋玻璃中的相变。还模拟神经网络、成群的鸟类和跳动的心脏细胞。已有超过 10,000 篇论文使用伊辛模型。
问答
练习
打印一个随机单词。 从标准输入中读取一个未知长度的单词列表,并以均匀随机的方式打印其中的一个单词。不要存储单词列表。而是使用 Knuth 的方法:在读取第 i 个单词时,以 1/i 的概率选择它作为新的冠军。在读取所有数据后打印出幸存的单词。
链表的随机子集。 给定一个包含 N 个元素的数组和一个整数 k ≤ N,构造一个包含 k 个元素的随机子集的新数组。提示:遍历数组,以 a/b 的概率接受每个元素,其中 a 是剩余要选择的元素数,b 是剩余的元素数。
创意练习
随机数生成。 计算介于 0 和 N-1 之间的伪随机整数时,以下方法会失败吗?
Math.random保证返回大于或等于 0.0 且严格小于 1.0 的浮点数。double x = Math.random(); int r = (int) (x * N);也就是说,你能找到一个实数
x和一个整数N,使得 r 等于 N 吗?解决方案: 不,这在 IEEE 浮点算术中是不可能发生的。舍入误差不会导致结果为 N,即使
Math.random返回 0.9999999999.... 然而,这种方法并不会均匀地产生整数,因为浮点数不是均匀分布的。此外,它涉及到强制转换和乘法,这是过度的。随机数测试。 编写一个程序来绘制布尔伪随机数生成器的结果。为简单起见,使用
(Math.random() < 0.5)并在一个 128x128 的网格中绘制,就像以下的伪随机 applet。也许使用 LFSR 或Random.nextLong() % 2。从离散概率分布中抽样。 假设有 N 个事件,事件i以概率 p[i]发生,其中 p[0] + p[1] + ... + p[N-1] = 1。 编写一个名为
Sample.java的程序,根据概率分布打印出 1,000 个样本事件。 提示:选择一个介于 0 和 1 之间的随机数 r,并从 i = 0 迭代到 N-1,直到 p[0] + p[1] + ... + p[i] > r。 (要注意浮点精度。)从离散概率分布中抽样。 改进前一个问题的算法,使其生成新样本的时间与 log N 成正比。 提示:在累积和上进行二分查找。 注意:参见这篇论文,其中提供了一个非常巧妙的替代方案,可以在恒定的时间内生成随机样本。 Discrete.java 是 Warren D. Smith 的 WDSsampler.c 程序的 Java 版本。
从离散概率分布中抽样。 重复上一个问题,但使其动态化。 也就是说,在每个样本之后,一些事件的概率可能会改变,或者可能会有新事件发生。 用于n 倍算法,这是动力学蒙特卡洛方法的首选方法,其中希望模拟动力学演化过程。 典型应用:模拟气体与基板表面反应,化学反应发生在不同速率的情况。 提示:使用二叉搜索树。
Zipf 分布。 利用前面练习的结果从具有参数 s 和 N 的Zipf 分布中抽样。 该分布可以取 1 到 N 之间的整数值,并以概率 1/ks / sum_(i = 1 to N) 1/is 取值 k。 例如:莎士比亚戏剧《哈姆雷特》中的单词,其中 s 约等于 1。
模拟马尔可夫链。 编写一个程序 MarkovChain.java 来模拟马尔可夫链。 提示:您需要从离散分布中抽样。
非单位粘附概率的 DLA 修改 DLA.java,使初始聚集由沿着晶格底部随机间隔的几个单元格组成。 这模拟了类似细菌生长的串状生长。
非单位粘附概率的 DLA 修改 DLA.java 以允许小于 1 的粘附概率。 也就是说,如果一个粒子有邻居,则以概率 p < 1.0 粘附;否则,它会随机移动到一个未被占据的相邻单元格。 这会导致更多聚集结构,模拟原子之间更高的键合亲和力。
对称 DLA。 将聚集初始化为晶格中心的单个粒子。 从初始粒子周围的圆形均匀发射粒子。 随着聚集大小的增加,增加发射圆的大小。 将您的程序命名为 SymmetricDLA.java。 这模拟了粒子从无限远处随机漫步进入聚集的生长过程��� 这里有一些加速过程的技巧速度。
![对称扩散有限聚集 1]()
![对称扩散有限聚集 2]()
![对称扩散有限聚集 3]()
可变粘附概率。 一个漫步的粒子进入一个空位,旁边是一个占据的位置,被分配一个随机数,表示粒子可以移动的潜在方向(上、下、左或右)。 如果新位置上存在占据的位置,则粒子通过占据其当前晶格位置而粘附到聚集体上。 如果没有,则移动到该位置,随机行走继续。 这模拟了雪花的生长。
拉普拉斯方程的随机漫步解。 数值求解拉普拉斯方程以确定给定边界上电荷位置时的电势。拉普拉斯方程表明电势的梯度是相对于 x 和 y 的二阶偏导数之和。参见 Gould 和 Tobochnik,10.2。您的目标是找到满足指定边界条件的函数 V(x, y)。假设无电荷区域是一个正方形,且沿垂直边界的电势为 10,沿水平边界为 5。要解决拉普拉斯方程,将正方形分成一个 N×N 的点网格。单元格(x, y)的电势 V(x, y)是四个相邻单元格的电势的平均值。要估计 V(x, y),模拟 100 万个从单元格(x, y)开始并持续到达边界的随机漫步者。V(x, y)的估计值是达到的 100 万个边界单元格的平均电势。编写一个程序 Laplace.java,它接受三个命令行参数 N、x 和 y,并估计 N×N 单元格网格上的 V(x, y),其中第 0 列和 N 的电势为 10,第 0 行和 N 的电势为 5。
备注:尽管上述边界值问题可以通过解析方法解决,但像上面的数值模拟在区域具有更复杂形状或需要为不同的边界条件重复时非常有用。
模拟退火
模拟几何随机变量。 如果某事件以概率 p 发生,则具有参数 p 的几何随机变量模拟事件发生之间所需的独立试验次数 N。要生成具有几何分布的变量,请使用以下公式
N = ceil(ln U / ln (1 - p))
其中 U 是具有均匀分布的变量。使用 Math 库方法
Math.ceil、Math.log和Math.random。模拟指数随机变量。 指数分布广泛用于模拟城市公交车之间的到达时间、灯泡故障之间的时间等。具有参数λ的指数随机变量小于x的概率是x >= 0时的F(x) = 1 - e^(λ x)。要从分布中生成随机偏差,使用反函数方法:输出-ln(U) / λ,其中 U 是 0 到 1 之间的均匀随机数。
泊松分布。 泊松分布在描述任何特定小时间间隔内衰变的核数的波动方面非常有用。
public static int poisson(double c) { double t = 0.0; for (int x = 0; true; x++) { t = t - Math.log(Math.random()) / c; // sum exponential deviates if (t > 1.0) return x; } }模拟帕累托随机变量。 帕累托分布通常用于模拟保险索赔损失、金融期权持有时间和互联网流量活动。带参数a的帕累托随机变量小于x的概率是x >= 0时的F(x) = 1 - (1 + x)^(-a)。要从分布中生成随机偏差,使用反函数方法:输出(1-U)^(-1/a) - 1,其中 U 是 0 到 1 之间的均匀随机数。
模拟柯西随机变量。 柯西随机变量的密度函数是 f(x) = 1/(Π(1 + x²))。柯西随机变量小于x的概率是x >= 0时的F(x) = 1/Π (Π/2 + arctan(x))。要从分布中生成随机偏差,使用反函数方法:输出 tan(Π(U - 1/2)),其中 U 是 0 到 1 之间的均匀随机数。
生成单位圆内随机点。 在 0 和 1 之间均匀选择r,在 0.0 和 2π之间均匀选择θ,并使用(x, y) = (r cosθ, r sinθ)是不正确的。如果这样做,会更多的点靠近圆盘的中心。相反,设置(x, y) = (√r cosθ, √r sinθ)。或者,在-1 和 1 之间均匀生成 x 和 y,并在 x² + y² ≤ 1 时接受。使用这两种方法绘制一系列随机点并查看偏差。
翻转比特。 作为遗传算法的一部分,假设您需要独立翻转 N 个比特,每个比特的概率为 p,其中 p 是一个非常小的常数。
方法 1:循环遍历 N 位,为每一位生成一个 Bernouilli(p) 随机变量并相应地翻转。时间复杂度与 N 成正比。
方法 2:生成一个几何分布(p) 随机变量 X_0 并翻转位 X_0;生成另一个几何分布(p) 随机变量并翻转位 X_0 + X_1,依此类推。时间复杂度与 Np 成正比。
方法 3:在二项分布(N, p) 中翻转的位数。通过用高斯分布(Np, sigma) 随机变量近似来确定要翻转的位数。然后翻转 Z 位,注意避免重复。时间复杂度与 Np 成正比,但调用超越函数更少。
N 维球体内的随机点。 编写一个程序
InsideSphere.java,接受一个命令行参数 N,并计算一个半径为 1 的 N 维球体内的随机点。生成 N 个均匀随机变量 x[1], ..., x[N],并使用这个点如果|
(x1)2 + ... + (xN)2 ≤ 1|
否则重复。
N 维球面上的随机点。 编写一个程序 Sphere.java,接受一个命令行参数 N,并使用布朗方法计算 N 维球面上半径为 1 的随机点。布朗方法是计算 N 个独立的标准正态分布变量 x[1], x[N],然后
|
( x1/r, x2/r, ..., xN/r ), where r = sqrt((x1)2 + ... + (xN)2)|
具有所需分布。使用第 3 节中的练习 xyz 来计算标准正态分布变量。
波茨模型。 波茨模型 是伊辛模型的一个变种,其中每个位置有 q 个可能的方向。(q = 2 对应于伊辛模型)系统的总能量 E = 所有邻居之间的 - J sigma(s[i], s[j]) 的总和。Kronecker delta 函数 δ(x, y) = 如果 x = y 则为 1,否则为 0。
2D 布朗运动。 模拟粒子在流体中的扩散。编写一个数据类型 BrownianParticle.java,表示一个在二维空间中经历布朗运动的粒子。为此,模拟两个独立的布朗运动 X(t) 和 Y(t),并绘制 (X(t), Y(t))。创建一个客户端程序,接受一个命令行整数 N,将 N 个粒子放置在原点,并模拟 N 个粒子的布朗运动。
布朗桥。 布朗桥 是一种受限制的布朗运动,要求在时间 0 从原点开始,并在时间 T 结束于原点。如果 X(t) 是一个布朗运动,那么 Z(t) = X(t) - (t/T)X(T) 就是这样一个过程。要绘制,存储中间值 X(t),并在计算完 X(T) 后绘制。
彩虹。 1637 年,勒内·笛卡尔发现了对彩虹形成的第一个科学解释。他的方法涉及跟踪光线穿过球形雨滴时的内部反射。根据大量平行光线击中球形雨滴的模型,模拟彩虹的生成。当光线击中雨滴时,光线会反射和折射。我们使用 HSB 颜色格式,并随机选择色调 h 在 0(红色)和 1(紫色)之间。我们使用 1.33 + 0.06 * h 作为色调 h 的折射率。对于每条光线,我们根据折射和反射的物理定律绘制一个光点。然后,观察者将看到每个光点以随机颜色绘制,无论是在主彩虹还是次彩虹中。为了进行模拟,我们均匀随机选择 7 种颜色中的一种。然后,我们选择单位圆中心为 (0, 0) 的点 (x, y),并设置冲击参数 r = sqrt(x² + y²)。入射角 θ[i] = arcsin(r),根据斯涅尔定律,折射角 θ[r] = arcsin (r / n),其中 n 是折射率。如果光线完全反射一次,它以 θ[p] = 4θ[r] - 2θ[i] 的角度出射,贡献于主彩虹。如果光线完全反射第二次,它以 θ[p] = 6θ[r] - 2θ[i] - π 的角度出射,贡献于次彩虹。根据两个介质边界上的电磁波的传输和反射公式,计算主光线和次光线的强度 I[p] 和 I[s]。
Ip = 1/2 (s(1-s)2 + p(1-p)2) Is = 1/2 (s2(1-s)2 + p2(1-p)2) p = (sin(θi-θr)/sin(θi+θr))2 r = (tan(θi-θr)/tan(θi+θr))2颜色强度 I[p] 和 I[s] 用于确定 HSB 颜色格式中的饱和度。程序 Rainbow.java 模拟了这个过程。
![彩虹]()
彩虹网站。
Python
1. 编程元素
原文:
introcs.cs.princeton.edu/python/10elements译者:飞龙
本章的目标是说服您,编写计算机程序比写段文字(如段落或文章)更容易。写散文很困难:我们在学校花了很多年学习如何做到这一点。相比之下,只需几个基本构件就足以让我们进入一个世界,在这个世界中,我们可以利用计算机帮助我们解决各种各样的迷人问题,否则这些问题将无法解决。在本章中,我们将带领您了解这些基本构件,让您开始使用 Python 进行编程,并研究各种有趣的程序。
1.1 你的第一个程序 指导您如何在系统上编写和执行 Python 程序。
1.2 内置数据类型 描述了 Python 的内置数据类型,用于操作字符串、整数、实数和布尔值。
1.3 条件和循环 介绍了 Python 中用于控制流的结构,包括
if、while和for语句。1.4 数组 讨论了一种称为数组的数据结构,用于组织大量数据。
1.5 输入和输出 将输入和输出抽象的范围(从命令行输入和标准输出)扩展到包括标准输入、标准绘图和标准音频。
1.6 案例研究:随机网页浏览者 展示了一个案例研究,模拟了使用马尔可夫链的网页浏览���的行为。
本章中的 Python 程序
以下是本章中使用的 Python 程序和数据文件列表。
参考 程序 描述 数据 1.1.1 helloworld.py 你好,世界 – 1.1.2 useargument.py 使用命令行参数 – 1.2.1 ruler.py 字符串连接示例 – 1.2.2 intops.py 整数运算符 – 1.2.3 floatops.py 浮点数运算符 – 1.2.4 quadratic.py 二次方程 – 1.2.5 leapyear.py 闰年 – 1.3.1 flip.py 抛硬币 – 1.3.2 tenhellos.py 你的第一个循环 – 1.3.3 powersoftwo.py 计算二的幂 – 1.3.4 divisorpattern.py 你的第一个嵌套循环 – 1.3.5 harmonic.py 调和数 – 1.3.6 sqrt.py 牛顿法 – 1.3.7 binary.py 转换为二进制 – 1.3.8 gambler.py 赌徒破产模拟 – 1.3.9 factors.py 整数因子分解 – 1.4.1 sample.py 无重复抽样 – 1.4.2 couponcollector.py 优惠券收集器模拟 – 1.4.3 primesieve.py 埃拉托斯特尼筛法 – 1.4.4 selfavoid.py 避免自我随机漫步 – 1.5.1 randomseq.py 生成随机序列 – 1.5.2 twentyquestions.py 交互式用户输入 – 1.5.3 average.py 对一系列数字求平均值 – 1.5.4 rangefilter.py 一个简单的过滤器 – 1.5.5 plotfilter.py 标准输入绘制滤波器 usa.txt 1.5.6 bouncingball.py 弹跳球 – 1.5.7 playthattune.py 数字信号处理 elise.txt ascale.txt stairwaytoheaven.txt entertainer.txt firstcut.txt freebird.txt looney.txt 1.6.1 transition.py 计算转移矩阵 small.txt medium.txt 1.6.2 randomsurfer.py 模拟随机冲浪者 – 1.6.3 markov.py 混合马尔可夫链 –
1.1 您的第一个程序
原文:
introcs.cs.princeton.edu/python/11hello译者:飞龙
在本节中,我们的计划是通过带领您完成运行简单程序所需的基本步骤,将您引入 Python 编程的世界。Python 系统(以下简称Python)是一组应用程序,与您习惯使用的其他应用程序(如文字处理器、电子邮件程序和网络浏览器)类似。与任何应用程序一样,您需要确保 Python 已正确安装在您的计算机上。您还需要一个文本编辑器和一个终端应用程序。您的第一个任务是按照安装这样一个 Python 编程环境的说明。
您必须安装 Python 3 或 Python 2 编程环境中的一个。
这里是安装 Python 3 编程环境的说明[Windows · Mac OS X · Linux]。我们建议您安装并使用 Python 3 编程环境。
这里是安装 Python 2 编程环境的说明[Windows · Mac OS X · Linux]。我们建议您仅在有充分理由(不属于本书和书站要求的外部原因)时使用 Python 2 编程环境。
Python 编程
要在 Python 中编程,您需要:
通过将其键入到一个名为,比如
myprogram.py的文件中来撰写一个程序。通过在终端窗口中键入
python myprogram.py来运行(或执行)它。
撰写 Python 程序
一个 Python 程序只是存储在具有.py扩展名的文件中的一系列字符。要创建一个,您只需使用文本编辑器定义该字符序列。
该程序 helloworld.py 是一个完整 Python 程序的示例。我们在本页重复该程序以便后续描述。行号显示出来是为了方便引用特定行,但它们不是程序的一部分,也不应该出现在您的 helloworld.py 文件中。
1 import stdio
2
3 # Write 'Hello, World' to standard output.
4 stdio.writeln('Hello, World')
该程序的唯一操作是向终端窗口写入一条消息。一个 Python 程序由语句组成。通常,您将每个语句放在不同的行上。
第 1 行包含一个
import语句。该语句告诉 Python 您打算使用在名为stdio.py的文件中定义的stdio模块中定义的功能。stdio.py文件是我们专门为本书设计的一个文件。它定义了与读取输入和写入输出相关的函数。导入stdio模块后,您可以稍后调用该模块中定义的函数。第 2 行是空行。Python 会忽略空行;程序员使用它们来分隔代码的逻辑块。
第 3 行包含一个注释,用于记录程序。在 Python 中,注释以'#'字符开始,并延伸到行尾。Python 会忽略注释;它们仅供程序的人类读者阅读。
第 4 行是程序的核心。这是一个调用
stdio.writeln()函数写入一行给定文本的语句。请注意,我们通过写入模块名、后跟一个句点、后跟函数名的方式来调用另一个模块中的函数。
|
Python 2
本书站的通用语言是 Python 3,因为它是 Python 编程的未来。然而,我们非常小心地确保书站中的代码适用于 Python 2 或 Python 3。例如,在 Python 2 中,helloworld.py 程序可能只是一行print 'Hello, World',但这在 Python 3 中不是有效的程序。为了开发能在两个 Python 版本中都有效的输出代码,我们使用我们的stdio模块。每当两种语言之间有显著差异时,我们会在一个类似这样的提示框中提醒 Python 2 用户。|
执行 Python 程序
一旦您编写好程序,您可以运行(或执行)它。为了运行您的程序,Python 编译器将您的 Python 程序转换为更适合在计算机上执行的语言。然后 Python 解释器指导您的计算机按照该语言中表达的指令执行。要运行您的程序,在终端窗口中键入python命令,后跟包含 Python 程序的文件名。
$ python helloworld.py
如果一切顺利,您将看到以下响应:
Hello, World
目前,您的所有程序都将与 helloworld.py 相同,只是语句序列不同。组成这样的程序的最简单方法是:
将 helloworld.py 复制到一个新文件中,文件名为程序名称后跟
.py。用不同的语句或语句序列替换
stdio.writeln()的调用。
错误
在学习编程时,很容易混淆编辑、编译和解释程序之间的区别。在学习编程时,您应该将它们分开,以更好地理解不可避免出现的错误的影响。您可以在本节末尾的问答中找到几个错误的示例。
通过仔细检查程序,您可以修复或避免大多数错误。一些错误,称为编译时错误,在 Python 编译程序时引发,因为它们阻止编译器进行翻译。Python 报告编译时错误为SyntaxError。其他错误,称为运行时错误,直到 Python 解释程序时才会引发。例如,如果您在 helloworld.py 中忘记了import stdio语句,那么 Python 将在运行时引发NameError。
输入和输出
通常,我们希望为我们的程序提供输入,即它们可以处理以产生结果的数据。提供输入数据的最简单方法在 useargument.py 中有所说明。每当您运行该程序时,它都会接受您在程序名称后键入的命令行参数,并将其作为消息的一部分写回终端。
$ python useargument.py Alice
Hi, Alice. How are you?
$ python useargument.py Bob
Hi, Bob. How are you?
在 useargument.py 中,语句import sys告诉 Python 您希望使用sys模块中定义的功能。其中一个功能名为argv,是一个命令行参数列表(出现在命令行上python useargument.py之后,由空格分隔)。第 1.4 节详细描述了列表;目前了解sys.argv[1]是您在程序名称后键入的第一个命令行参数,sys.argv[2]是您在程序名称后键入的第二个命令行参数,依此类推。
除了stdio.writeln(),useargument.py 还调用stdio.write()函数。该函数与stdio.writeln()类似,但只写入字符串(不是换行符)。
获取更多信息
我们鼓励您在使用本书站时经常访问官方 Python 网站www.python.org。更具体地说:
docs.python.org/reference/index.htm提供有关 Python 语言的信息。docs.python.org/library/index.html提供有关 Python 标准库的信息。www.python.org/dev/peps/pep-0008/提供有关 Python 编程风格的信息。
编程风格
列表中的最后一项值得一些详细说明。关于编程风格...
在编写代码时的首要目标是使其易于理解。易于理解的程序更有��能是正确的,并且在随着时间的推移而进行维护时更有可能保持正确。
程序员使用风格指南使程序更易于理解。正如上面所述,官方 Python 风格指南在页面 www.python.org/dev/peps/pep-0008/ 中给出。我们建议你现在快速阅读一下风格指南,并在以后随着编写 Python 程序的经验增加时偶尔返回查看。
附录 B 补充了官方 Python 风格指南,提供了适合初学计算机编程的建议。我们建议你现在快速阅读一下附录 B,并偶尔返回查看。
问与答
Q. 为什么选择 Python?
A. 我们正在研究的程序与其他几种语言中的对应程序非常相似,因此我们选择的语言并不重要。我们使用 Python 是因为它广泛可用,包含一整套现代抽象,并且具有各种��动检查程序错误的功能,因此适合学习编程。Python 正在不断发展,并有许多版本。
Q. 我应该使用哪个版本的 Python?
A. 我们推荐使用 Python 3,但我们非常小心地确保本书中的代码适用于 Python 2 或 Python 3。所有代码都经过了 Python 2.7 和 3.4 版本的测试(出版时的最新主要版本)。我们使用通用术语 Python 2 指 Python 2.7,Python 3 指 Python 3.4。
Q. 我真的必须在书中输入程序以尝试运行它们吗?我相信你已经运行过它们并且它们产生了指定的输出。
A. 你应该输入并运行 helloworld.py。如果你还运行 useargument.py,尝试不同的输入并修改它以测试自己的想法,你的理解将大大增强。
Q. 当我运行 helloworld.py 时,Python 生成消息
ImportError: No module named stdio.
那是什么意思?
A. 这意味着书站模块 stdio 对 Python 不可用。
Q. 如何使书站模块 stdio 可用于 Python?
A. 如果你按照本书网站上的逐步说明安装 Python 编程环境,stdio 模块应该已经可用于 Python。
Q. Python 对制表符、空格和换行符等空白字符有什么规定?
A. 一般来说,Python 认为程序文本中的大多数空白字符是等效的,但有两个重要的例外:字符串字面值 和 缩进。字符串字面值是单引号内的字符序列,比如 'Hello, World'。如果在引号内放入任意数量的空格,你将在字符串字面值中得到完全相同数量的空格。缩进是行首的空白。行首的空格数量在构建 Python 程序结构中起着重要作用,我们将在第 1.3 节中看到。目前你不应该缩进任何代码。
Q. 为什么要使用注释?
A. 评论是必不可少的,因为它们帮助其他程序员理解你的代码,甚至可以帮助你回顾时理解自己的代码。尽管书中的程序要求我们在书中展示的程序中节制使用注释,但本书网站上的程序被注释得更加现实。
Q. 我可以在一行上放置多个语句吗?
A. 是的,通过用分号分隔语句。例如,这行代码产生与 helloworld.py 相同的输出:
import stdio; stdio.writeln('Hello, World')
但许多程序员建议不要这样做,作为一种风格问题。
Q. 如果省略括号或拼错单词,比如 stdio、write 或 writeln,会发生什么?
A. 这取决于你具体做什么。这些所谓的语法错误通常会被编译器捕获。例如,如果你编写一个程序bad.py,与 helloworld.py 完全相同,只是省略了第一个左括号,你���得到以下相当有用的消息:
% python bad.py
File "bad.py", line 4
stdio.write'Hello, World')
^
SyntaxError: invalid syntax
从这条消息中,你可能会正确地推测你需要插入一个左括号。但编译器可能无法告诉你确切地犯了哪个错误,所以错误消息可能难以理解。例如,如果你省略了第一个右括号而不是第一个左括号,你会得到以下不太有用的消息,它引用了错误行后面的行:
% python bad.py
File "bad.py", line 5
^
SyntaxError: unexpected EOF while parsing
习惯于这种消息的一种方法是故意在一个简单的程序中引入错误,然后看看会发生什么。无论错误消息说什么,你都应该把编译器当作朋友,因为它只是在试图告诉你你的程序有问题。
Q. 当我运行useargument.py时,我收到一个奇怪的错误消息。请解释一下。
A. 最有可能的是,你忘记包含一个命令行参数:
% python useargument.py
Hi, Traceback (most recent call last):
File "useargument.py", line 5, in stdio.write(sys.argv[1])
IndexError: list index out of range
Python 解释器抱怨你运行了程序,但没有像承诺的那样输入命令行参数。在第 1.4 节中,你将更详细地了解列表索引。记住这个错误消息:你可能会再次看到它。即使有经验的程序员偶尔也会忘记输入命令行参数。
Q. 我可以使用哪些 Python 模块和函数?
A. 许多标准模块都随着任何 Python 安装捆绑在一起。许多其他模块作为扩展模块可供下载和安装。我们专门为本书和书站编写了其他模块(如stdio模块);我们将它们称为书站模块。简而言之,有数百个 Python 模块可供你使用,每个模块(通常)定义多个函数。本书只介绍最基本的模块和函数,并且有意以逐步增量的方式(从下一节开始)介绍,以避免用信息压倒你。
练习
- 编写一个程序,将
Hello, World消息写出 10 次。
解决方案:参见 tenhellos1.py。
描述如果你在 helloworld.py 中省略以下内容会发生什么:
importstdioimport stdio描述如果你在 helloworld.py 中拼错(比如说,省略第二个字母)以下内容会发生什么:
importstdiowritewriteln描述如果你在 helloworld.py 中省略以下内容会发生什么:
第一个
'第二个
'stdio.writeln()语句描述如果你尝试用以下每个命令行执行 useargument.py 会发生什么:
python useargument.py pythonpython useargument.py @!&^%python useargument.py 1234python useargument Bobuseargument.py Bobpython useargument.py Alice Bob修改 useargument.py 编写一个程序,接受三个名字,并按给定顺序的相反顺序写出一个包含这些名字的正确句子,例如,
python usethree.py Alice Bob Carol写出字符串'Hi Carol, Bob, and Alice'。
解决方案:参见 usethree.py。
编写一个程序,使用九行星号写出你的姓名首字母,就像下面这样。
** *** ********** ** * ** ** *** ** ** ** *** ** ** *** ** ** ** ** ** ** ** *** ** ** ** ** ** ** ***** ** ** ** ** ** ** ** *** ** ** ** ** ** ** ** *** ** ** ** ** ** ** ** *** ** ** *** *** ** *** ********** * *解决方案:参见 initials.py。
1.2 数据的内置类型
原文:
introcs.cs.princeton.edu/python/12types译者:飞龙
数据类型是一组值和在这些值上定义的一组操作。许多数据类型内置于 Python 语言中。在本节中,我们考虑 Python 内置的数据类型int(用于整数)、float(用于浮点数)、str(用于字符序列)和bool(用于真假值)。
定义
要讨论数据类型,我们需要介绍一些术语。为此,我们从以下代码片段开始:
a = 1234
b = 99
c = a + b
这段代码创建了三个类型为int的对象,使用字面值1234和99以及表达式a + b,并使用赋值语句将变量a、b和c绑定到这些对象。最终结果是变量c绑定到一个类型为int、值为1333的对象。
对象。
Python 程序中的所有数据值都由对象和对象之间的关系表示。对象是特定数据类型的值在计算机内存中的表示。每个对象由其标识、类型和值特征化。
标识唯一标识一个对象。你应该将其视为计算机内存中对象存储的位置(或内存地址)。
对象的类型完全指定了其行为——它可能表示的值集合和可以对其执行的操作集合。
对象的值是它所代表的数据类型值。
每个对象存储一个值;例如,类型为int���对象可以存储值1234、值99或值1333。不同对象可能存储相同的值。例如,一个类型为str的对象可能存储值'hello',另一个类型为str的对象也可能存储相同的值'hello'。我们可以对对象应用其类型定义的任何操作(仅限于这些操作)。例如,我们可以将两个int对象相乘,但不能将两个str对象相乘。
对象引用。
对象引用只不过是对象标识(对象存储的内存地址)的具体表示。Python 程序使用对象引用来访问对象的值或操作对象引用本身。
字面值。
字面值是数据类型值的 Python 代码表示。它创建一个具有指定值的对象。
操作符。
操作符是 Python 代码中数据类型操作的表示。例如,Python 使用+和*表示整数和浮点数的加法和乘法;Python 使用and、or和not表示布尔操作;等等。
标识符。
标识符是一个名称的 Python 代码表示。每个标识符是一个字母、数字和下划线的序列,第一个不是数字。以下关键字是保留的,你不能将它们用作标识符:
False class finally is return
None continue for lambda try
True def from nonlocal while
and del global not with
as elif if or yield
assert else import pass
break except in raise

变量。
变量是对象引用的名称。我们使用变量来跟踪随着计算的展开而变化的值。我们使用像右侧的图表来显示变量与对象的绑定。
表达式。
表达式是由文字、变量和操作符组合而成的,Python 评估后会产生一个对象。每个操作数可以是任何表达式,可能在括号内。例如,我们可以组合表达式如4 * (x - 3)或5 * x - 6,Python 会理解我们的意思。
操作符优先级。
表达式是一系列操作的简写。Python 的优先级规则指定应用操作的顺序。对于算术运算,乘法和除法在加法和减法之前执行,因此a - b * c和a - (b * c)表示相同的操作序列。当算术运算符具有相同的优先级时,它们是左结合的,这意味着a - b - c和(a - b) - c表示相同的操作序列。您可以使用括号覆盖规则,因此如果您想要,可以编写a - (b - c)。有关完整详情,请参阅附录 A:Python 中的运算符优先级。
赋值语句。
赋值语句是对 Python 的指令,将=运算符左侧的变量绑定到右侧表达式评估产生的对象。例如,当我们写c = a + b时,我们表达了这个动作:“将变量c与变量a和b关联的值的和关联起来。”
非正式追踪。
跟踪与变量关联的值的有效方法是使用右侧的表格,其中一行给出每个语句执行后的值。这样的表称为追踪。
对象级别的追踪。
为了更全面地理解,我们有时会在追踪中跟踪对象和引用。右侧的对象级别追踪说明了我们的三个赋值语句的全部效果:
语句
a = 1234创建一个值为1234的int对象;然后将变量a绑定到这个新的int对象。语句
b = 99创建一个值为99的int对象;然后将变量b绑定到这个新的int对象。语句
c = a + b创建值为1333的int对象,作为绑定到a的int对象的值和绑定到b的int对象的值的和;然后将变量c绑定到新的int对象。
字符串
str数据类型表示字符串,用于文本处理。

str对象的值是一系列字符。您可以通过将一系列字符括在匹配的单引号中来指定str文字。您可以使用运算符+连接两个字符串。例如,ruler.py 计算了描述标尺上标记相对长度的标尺函数值的表。
将数字转换为字符串以进行输出。
Python 提供了内置函数str()来将数字转换为字符串。我们最常用的字符串连接运算符是将计算结果与stdio.write()和stdio.writeln()一起链在一起进行输出,通常与str()函数一起使用,就像这个例子中一样:
stdio.writeln(str(a) + ' + ' + str(b) + ' = ' + str(a+b))
如果a和b是值分别为1234和99的int对象,则该语句写出输出行1234 + 99 = 1333。
将字符串转换为数字以进行输入。
Python 还提供了内置函数来将字符串(例如我们作为命令行参数键入的字符串)转换为数字对象。我们使用 Python 内置函数int()和float()来实现这一目的。如果用户将1234键入为第一个命令行参数,则代码int(sys.argv[1])评估为值为1234的int对象。
整数
int数据类型表示整数或自然数。
Python 包括用于整数的常见算术运算符,包括加法+,减法-,乘法*,向下取整除法//,取余%和指数运算**。所有这些运算符都与小学定义的一样(请记住,向下取整除法运算符的结果是整数)。程序 intops.py 演示了操作int对象的基本操作。
|
Python 2 中的除法。
在 Python 3 中,当两个操作数都是整数时,/运算符的行为与浮点除法运算符相同。在 Python 2 中,当两个操作数都是整数时,/运算符的行为与向下取整除法运算符//相同。例如,在 Python 3 中,17 / 2的结果为8.5,在 Python 2 中为8。为了在不同版本的 Python 中保持兼容性,在本书站点中不使用带有两个int操作数的/运算符。
浮点数
float数据类型用于表示浮点数,用于科学和商业应用。
Python 包括用于浮点数的常见算术运算符,包括加法+,减法-,乘法*,除法/和指数运算**。程序 floatops.py 演示了操作float对象的基本操作。程序 quadratic.py 展示了使用二次方程公式计算二次方程的两个根时使用float对象。
我们使用浮点数来表示实数,但它们与实数并不相同!实数有无限多个,但我们只能在任何数字计算机中表示有限数量的浮点数。例如,5.0/2.0的结果为2.5,但5.0/3.0的结果为1.6666666666666667。通常,浮点数具有 15-17 位的精度。
注意在 quadratic.py���序中使用math.sqrt()函数。标准的math模块定义三角函数,对数/指数函数和其他常见的数学函数。要使用math模块,请在程序开头附近放置语句import math,然后使用语法调用函数,例如math.sqrt(x)。
布尔值
bool数据类型只有两个值:True和False。
表面上看起来很简单,但布尔值是计算机科学的基础。对于布尔值定义的最重要运算符是逻辑运算符:and,or和not:
如果
a和b都是True,则a和b为True,如果其中一个为False,则a和b为False。如果
a或b都是False,则a或b为False,如果其中一个为True,则a或b为True。如果
a为False,则not a为True,如果a为True,则not a为False。
我们可以使用真值表正式指定每个操作的定义:
比较
比较运算符==,!=,<,<=,>和>=对整数和浮点数都定义,并计算为布尔结果。
程序 leapyear.py 展示了使用布尔表达式和比较运算来计算给定年份是否为闰年。本书站点的第 1.3 节描述了比较运算符的更常见用法。
函数和 APIs

正如我们所见,许多编程任务涉及使用函数。我们区分三种函数:内置函数(如 int()、float() 和 str())可以直接在任何 Python 程序中使用,标准函数(如 math.sqrt())在 Python 标准模块中定义,并在导入模块的任何程序中可用,以及书站函数(如 stdio.write() 和 stdio.writeln())在本书站模块中定义,并在你将它们提供给 Python 并导入它们后可供你使用。我们在本节中描述了一些更有用的函数。在后面的章节中,你将学习不仅如何使用其他函数,还将学习如何定义和使用自己的函数。
为方便起见,我们总结了你需要了解如何使用的函数,就像右侧显示的表格所示。这样的表格称为应用程序���程接口(API)。下面的表格显示了一些典型的函数调用。
类型转换
我们经常需要使用以下方法之一将数据从一种类型转换为另一种类型。
显式类型转换。
调用函数,如 int()、float()、str() 和 round()。
隐式类型转换(从整数到浮点数)。
当需要浮点数时,你可以使用整数,因为 Python 会在适当的时候自动将整数转换为浮点数。例如,10/4.0 的结果是 2.5,因为 4.0 是浮点数,两个操作数需要是相同的类型;因此,10 被转换为浮点数,然后两个浮点数相除的结果是浮点数。这种转换称为自动提升或强制转换。
交互式 Python
如果在终端窗口中输入命令 python(即,单独输入单词 python,后面没有文件名),Python 会识别自身并显示 >>> 提示符。此时你可以输入一个 Python 语句,Python 将执行它。或者,你可以输入一个 Python 表达式,Python 将评估它并输出结果值。或者,你可以输入 help() 来访问 Python 的广泛交互式文档。这是一个方便的测试新构造和访问文档的方式。

问与答:字符串
Q. Python 如何在内部存储字符串?
A. 字符串是使用 Unicode 编码的字符序列,Unicode 是一种现代文本编码标准。Unicode 支持超过 100,000 个不同的字符,包括 100 多种不同的语言以及数学和音乐符号。
Q. Python 提供哪种数据类型用于字符?
A. Python 没有专门的字符数据类型。字符只是由一个元素组成的字符串,比如 'A'。
Q. 我可以使用比较运算符(如 == 和 <)或内置函数(如 max() 和 min())来比较字符串吗?
A. 是的。非正式地,Python 使用词典顺序来比较两个字符串,就像书索引或字典中的单词一样。例如,'hello' 和 'hello' 相等,'hello' 和 'goodbye' 不相等,'goodbye' 小于 'hello'。
Q. 我可以使用匹配的双引号来表示字符串文字,而不是单引号吗?
A. 是的。例如,'hello' 和 "hello" 是相同的文字。双引号可用于指定包含单引号的字符串,这样你就不需要转义它们。例如,'Python\'s' 和 "Python's" 是相同的字符串文字。你也可以使用匹配的三引号来表示多行字符串。例如,以下代码创建一个两行字符串并将其赋值给变量 s:
s = """Python's "triple" quotes are useful to
specify string literals that span multiple lines
"""
在本书站点中,我们不使用双引号或三引号来界定字符串文字。
| Python 2 中的字符串。 Python 2 使用 ASCII 而不是 Unicode 来编码字符。ASCII 是一个支持 128 个字符的传统标准,包括英文字母、数字和标点符号。Python 2 为由 Unicode 字符组成的字符串提供了一个单独的数据类型 unicode,但许多 Python 2 库不支持它。 |
|---|
Q & A:整数
Q. Python 是如何在内部存储整数的?
A. 最简单的表示是对于小的正整数,使用二进制数系统将每个整数表示为固定数量的计算机内存。
Q. 什么是二进制数系统?
A. 在二进制数系统中,我们将整数表示为一系列位。一个位是一个单一的二进制(基数 2)数字 —— 要么是 0,要么是 1 —— 是计算机中表示信息的基础。在这种情况下,位是 2 的幂的系数。具体来说,位序列*b[n]b[n-1]...b[2]b[1]b[0]*表示整数
b[n]2n + b[n-1]2(n-1) + ... + b[2]2² + b[1]2¹ + b[0]2⁰
例如,1100011 表示整数
99 = 1 · 64 + 1 · 32 + 0 · 16 + 0 · 8 + 0 · 4 + 1 · 2 + 1 · 1。
更熟悉的十进制数系统与此相同,只是数字在 0 到 9 之间,我们使用 10 的幂。将一个数字转换为二进制是一个有趣的计算问题,我们将在下一节中考虑。对于小整数,Python 使用固定数量的位,通常由计算机的基本设计参数决定 —— 通常是 32 或 64。例如,整数 99 可能用 32 位表示为00000000000000000000000001100011。
Q. 负数呢?
A. 小的负数使用一种称为二进制补码的约定处理,我们不需要详细考虑。"小"的定义取决于底层计算机系统。在旧的 32 位机器上,“小”通常涵盖范围为-2147483648(-2³¹)到 2147483647(2³¹ - 1)。在新的 64 位机器上,“小”通常涵盖范围为-2⁶³到 2⁶³ - 1,这种情况下,“小”并不那么小!如果一个整数��是“小”,那么 Python 会自动使用一个更复杂的表示,其范围仅受计算机系统上可用内存量的限制。请注意,这些内部表示的细节对于您的程序是隐藏的,因此您可以在具有不同表示的系统中使用它们,而无需更改它们。
Q. 在 Python 中,表达式1/0的结果是什么?
A. 在运行时引发ZeroDivisionError。注意:回答这类问题最简单的方法是使用 Python 的交互模式。试试看!
Q. 负操作数上的地板除法运算符//和余数运算符%是如何工作的?
A. 试一试!-47 // 5的结果是-10,-47 % 5的结果是3。一般来说,地板除法运算符//产生地板商;也就是说,商向负无穷大取整。余数运算符%的行为更复杂。在 Python 中,如果a和b是整数,则表达式a % b的结果是一个与b具有相同符号的整数。这意味着对于任何整数a和b,都有b * (a // b) + a % b == a。在一些其他语言(如 Java)中,表达式a % b的结果是一个与a具有相同符号的整数。
Q. 指数运算符**如何处理负操作数?
A. 亲自尝试一下。请注意,**运算符比左侧的一元加/减运算符具有更高的优先级,但比右侧的一元加/减运算符具有更低的优先级。例如,-3**4的结果是-81(而不是81)。此外,它可能导致不同类型的对象。例如,10**-2的结果是浮点数0.01,而(-10)**(10**-2)在 Python 3 中的结果是一个复数(但在 Python 2 中会引发运行时错误)。
Q. 为什么10⁶的结果是12而不是1000000?
A. ^运算符不是指数运算符,你可能一直在想。相反,它是一个我们在本书中不使用的运算符。你想要的是字面值1000000。你可以使用表达式10**6,但在只需字面值时使用表达式(需要在运行时评估)是浪费的。
Python 2 中的整数。Python 2 支持两种不同类型的整数 — int(用于小整数)和long(用于较大整数)。Python 2 会在必要时自动从int类型提升为long类型。 |
|---|
Q & A:浮点数
Q. 为什么实数的类型被命名为float?
A. 小数点可以在构成实数的数字之间“浮动”。相比之下,对于整数,(隐式)小数点在最低有效数字之后是固定的。
Q. Python 如何在内部存储浮点数?
A. 一般来说,Python 使用对底层计算机系统自然的表示。大多数现代计算机系统按照 IEEE 754 标准存储浮点数。该标准规定浮点数使用三个字段存储:符号、尾数和指数。如���你感兴趣,可以查看维基百科的IEEE 浮点数页面了解更多细节。IEEE 754 标准还规定了如何处理特殊浮点值 — 正零、负零、正无穷大、负无穷大和NaN(不是一个数字)。例如,它规定-0.0/3.0应该评估为-0.0,1.0/0.0应该评估为正无穷大,0.0/0.0应该评估为NaN。你可以在一些简单计算中使用(相当不寻常的)表达式float('inf')和float('-inf')表示正无穷大和负无穷大,但 Python 不符合 IEEE 754 标准的这一部分。例如,在 Python 中,-0.0/3.0正确评估为-0.0,但1.0/0.0和0.0/0.0都会在运行时引发ZeroDivisionError。
Q. 浮点数的十五位数字对我来说肯定足够了。我真的需要太在意精度吗?
A. 可以,因为你习惯于基于具有无限精度的实数的数学,而计算机总是处理近似值。例如,在 IEEE 754 浮点数中,表达式(0.1 + 0.1 == 0.2)评估为True,但(0.1 + 0.1 + 0.1 == 0.3)评估为False!在科学计算中遇到这样的陷阱并不罕见。初学者程序员应避免将两个浮点数进行相等比较。
Q. 写入浮点数时看到所有那些数字很烦人。是否可以让stdio.write()和stdio.writeln()只写入小数点后的两三个数字?
A. booksite 函数stdio.writef()是一种方法 — 它类似于 C 编程语言和许多其他现代语言中的基本格式化写入函数,如 1.5 节所讨论的那样。在那之前,我们将接受额外的数字(这并不全是坏事,因为这样做有助于我们适应不同类型的数字)。
Q. 我可以将地板除法运算符//应用于两个浮点操作数吗?
A. 是的,它产生其操作数的地板除法。也就是说,结果是商,小数点后的数字被移除。我们在本书中不对浮点数使用地板除法运算符。
Q. 如果其参数的小数部分为0.5,round()会返回什么?
A. 在 Python 3 中,它返回最接近的偶数,因此round(2.5)是2,round(3.5)是4,round(-2.5)是-2。但在 Python 2 中,round()函数远离零四舍五入(并返回一个浮点数),因此round(2.5)是3.0,round(3.5)是4.0,round(-2.5)是-3.0。
Q. 我可以比较float和int吗?
A. 没有进行类型转换的话是不行的,但要记住 Python 会自动进行必要的类型转换。例如,如果x是整数3,那么表达式(x < 3.1)的结果是True,因为 Python 将整数3提升为生成浮点数3.0,然后将3.0与3.1进行比较。
Q. Python 的math模块中是否有其他三角函数,如反正弦、双曲正弦和正割函数?
A. 是的,Python 的math模块包括反三角函数和双曲函数。然而,没有正割、余割和余切函数,因为你可以使用math.sin()、math.cos()和math.tan()来轻松计算它们。选择在 API 中包含哪些函数是方便性和烦恼之间的权衡:方便性在于拥有你需要的每个函数,而烦恼在于必须在一个长列表中找到你需要的少数几个函数。没有选择会满足所有用户,Python 设计者有很多用户要满足。请注意,即使在我们列出的 API 中也有很多冗余。例如,你可以使用math.sin(x) / math.cos(x)代替math.tan(x)。
Q & A
Q. 如果我访问一个未绑定到对象的变量会发生什么?
A. Python 会在运行时引发NameError。
Q. 我如何确定一个变量的类型?
A. 这是一个诡计问题。与许多编程语言(如 Java)中的变量不同,Python 变量没有类型。相反,变量绑定的对象才有类型。你可以将同一个变量绑定到不同类型的对象,就像这个代码片段中:
x = 'Hello, World'
x = 17
x = True
然而,为了清晰起见,通常这样做是一个坏主意。
Q. 我如何确定一个对象的类型、标识和值?
A. Python 提供了用于此目的的内置函数。函数type()返回对象的类型;函数id()返回对象的标识;函数repr()返回对象的一个明确的字符串表示。
>>> import math
>>> a = math.pi
>>> id(a)
140424102622928
>>> type(a)
>>> repr(a)
'3.141592653589793'
在日常编程中你很少会使用这些函数,但在调试时可能会发现它们很有用。
Q. =和==之间有什么区别?
A. 是的,它们是完全不同的!第一个指定对变量的赋值,第二个是一个比较运算符,产生一个布尔结果。你能理解这个答案是对你是否理解本节内容的一个确定测试。想想你如何向朋友解释这个区别。
Q. a < b < c会测试这三个数字a、b和c是否按顺序排列吗?
A. 是的,Python 支持任意链式比较,比如a < b < c,它们遵循标准的数学约定。然而,在许多编程语言(如 Java)中,表达式a < b < c是非法的,因为子表达式a < b评估为布尔值,然后将该布尔值与一个数字进行比较,这是没有意义的。我们在��书中不使用链式比较;相反,我们更喜欢像(a < b) and (b < c)这样的表达式。
Q. a = b = c = 17会将这三个变量设置为 17 吗?
A. 可以,尽管 Python 赋值语句不是表达式,但 Python 支持任意链式赋值语句。我们在书中不使用链式赋值,因为许多 Python 程序员认为这是不好的风格。
Q. 我可以在不是布尔值的操作数上使用逻辑运算符and、or和not吗?
A. 是的,但为了清晰起见,通常这样做是一个坏主意。在这种情况下,Python 认为0、0.0和空字符串''表示False,而任何其他整数、浮点数或字符串表示True。
Q. 我可以在布尔操作数上使用算术运算符吗?
A. 是的,但再次这样做通常是不好的。当你将布尔操作数与算术运算符一起使用时,它们会被提升为整数:False为0,True为1。例如,(False - True - True) * True的结果是int值-2。
Q. 我可以将变量命名为max吗?
A. 是的,但如果这样做,你就无法使用内置函数max()。对于min()、sum()、float()、eval()、open()、id()、type()、file()和其他内置函数也是如此。
练习
假设
a和b是整数。以下语句序列做什么?绘制该计算的对象级跟踪。t = a b = t a = b解决方案: 该序列将
a、b和t设置为a的原始值。编写一个程序,使用
math.sin()和math.cos()来检查对于任何作为命令行参数输入的θ,cos² θ + sin² θ的值是否大约为1.0。只需写出值。为什么这些值不总是完全等于1.0?
解决方案(来自 Hassan Alam 和 Lee Jong Gil):
import sys import math theta = float(sys.argv[1]) theta_in_rad = math.radians(theta) val_of_sin = math.sin(theta_in_rad) val_of_cos = math.cos(theta_in_rad) result = (val_of_sin**2) + (val_of_cos**2) sys.stdout.write(str(result))
假设
a和b是布尔值。展示这个表达式的计算结果为True:(not (a and b) and (a or b)) or ((a and b) or not (a or b))假设
a和b是整数。简化以下表达式:(not (a < b) and not (a > b))解决方案:
a == b每个语句会写入什么?解释每个结果。
stdio.writeln(2 + 3) stdio.writeln(2.2 + 3.3) stdio.writeln('2' + '3') stdio.writeln('2.2' + '3.3') stdio.writeln(str(2) + str(3)) stdio.writeln(str(2.2) + str(3.3)) stdio.writeln(int('2') + int('3')) stdio.writeln(int('2' + '3')) stdio.writeln(float('2') + float('3')) stdio.writeln(float('2' + '3')) stdio.writeln(int(2.6 + 2.6)) stdio.writeln(int(2.6) + int(2.6))解释如何使用 quadratic.py 来找到一个数的平方根。
解决方案: 要找到
c的平方根,找到x² + 0x - c的根。stdio.writeln((1.0 + 2 + 3 + 4) / 4)会写入什么?假设
a是3.14159。每个语句会写入什么?解释每个结果。stdio.writeln(a) stdio.writeln(a + 1.0) stdio.writeln(8 // int(a)) stdio.writeln(8.0 / a) stdio.writeln(int(8.0 / a))描述在 quadratic.py 中写入
sqrt而不是math.sqrt的效果。(math.sqrt(2) * math.sqrt(2) == 2)的计算结果是 True 还是 False?编写一个程序,从命令行获取两个正整数,如果其中一个可以整除另一个,则写入
True。编写一个程序,从命令行获取三个正整数,并在任何一个大于或等于另外两个之和时写入
False,否则写入True。(注意:这个计算测试这三个数字是否可以是某个三角形的边长。)在执行以下序列后,
a的值是多少:a = 1 a = True a = 2 a = a + a a = not a a = a * a a = a + a a = not a a = a * a a = a + a a = not a a = a * a一个物理学生在使用代码时得到了意外的结果
force = G * mass1 * mass2 / radius * radius根据公式F = Gm[1]m[2] / r²计算值。解释问题并纠正代码。
解决方案: 代码除以
r,然后乘以r。实际上应该除以r的平方。使用括号:F = G * mass1 * mass2 / (r * r)或者使用乘方运算符:
F = G * mass1 * mass2 / r ** 2或者,为了更清晰起见,同时使用括号和乘方运算符:
F = (G * mass1 * mass2) / (r ** 2)假设
x和y是表示平面上点*(x, y)*的两个浮点数。给出一个表达式,计算出点到原点的距离。解决方案:
math.sqrt(x*x + y*y)编写一个程序,从命令行获取两个整数
a和b,然后写入一个介于a和b之间的随机整数。编写一个程序,写入两个介于 1 和 6 之间的随机整数的和(例如掷骰子时可能得到的值)。
解决方案: 参见 sumoftwodice.py。
编写一个程序,从命令行获取一个浮点数
t,然后写入*sin(2t) + sin(3t)*的值。编写一个程序,从命令行获取三个浮点数x[0]、v[0]和t,计算x[0] + v[0]t - Gt² / 2的值,并写入结果。(注意:G是常数 9.80665。这个值是物体从初始位置x[0]以速度v[0]米每秒向上抛出后t秒后的位移。)
编写一个程序,从命令行获取两个整数
m和d,如果月份m的第d天在 3 月 20 日和 6 月 20 日之间,则写入True,否则写入False。(将m解释为 1 代表一月,2 代表二月,依此类推。)解决方案: 参见 spring.py。
创意练习
连续复利。 编写一个程序,计算并写出以给定利率连续复利投资后的金额,将年数t、本金P和年利率r作为命令行参数。所需值由公式*pe^(rt)*给出。
风寒。 给定温度t(华氏度)和风速v(英里/小时),国家气象局定义有效温度��风寒)为:
w = 35.74 + 0.6215 t + (0.4275 t - 35.75) v^(0.16) 编写一个程序,从命令行接受两个浮点数
t和v,并写出风寒。注意:如果t的绝对值大于 50 或者v大于 120 或小于 3,则该公式无效(您可以假设您得到的值在这个范围内)。解决方案: 请查看 windchill.py。

极坐标。 编写一个程序,将笛卡尔坐标转换为极坐标。您的程序应该从命令行接受两个浮点数
x和y,并写出极坐标r和θ。使用 Python 函数math.atan2(y, x),计算y/x的反正切值,范围为-π到π。解决方案: 请查看 polar.py。
高斯随机数。 生成服从高斯分布的随机数的一种方法是使用Box-Muller公式:
Z = sin(2 π v) (-2 ln u)^(1/2) 其中u和v是由
random.random()函数生成的 0 到 1 之间的实数。编写一个程序,写出一个标准高斯随机变量。顺序检查。 编写一个程序,将三个浮点数
x、y和z作为命令行参数,并在值严格递增或递减(x < y < z或x > y > z)时写出True,否则写出False。星期几。 编写一个程序,接受日期作为输入,并写出该日期所在的星期几。您的程序应该接受三个命令行参数:
m(月份),d(日期)和y(年份)。对于m,使用 1 表示一月,2 表示二月,依此类推。对于输出,写出 0 表示星期日,1 表示星期一,2 表示星期二,依此类推。使用以下公历日历公式:| y[0] = y - (14 - m) / 12 x = y[0] + y[0]/4 - y[0]/100 + y[0]/400
m[0] = m + 12 * ((14 - m) / 12) - 2
d[0] = (d + x + (31**m[0]*)/ 12) mod 7 |
例如,1953 年 8 月 2 日是星期几?
| y = 1953 - 0 = 1953 x = 1953 + 1953/4 - 1953/100 + 1953/400 = 2426
m = 8 + 12*0 - 2 = 6
d = (2 + 2426 + (31*6) / 12) mod 7 = 2443 mod 7 = 0 (星期日)
|
解决方案: 请查看 day.py。
均匀随机数。 编写一个程序,写出五个介于 0 和 1 之间的均匀随机浮点数,它们的平均值以及最小值和最大值。使用内置的
max()和min()函数。解决方案: 请查看 stats1.py。
墨卡托投影。 墨卡托投影是一种保角(保持角度)投影,将纬度φ和经度λ映射到矩形坐标*(x, y)*。它被广泛使用,例如在航海图和从网络打印的地图中。该投影由以下方程定义:
x = λ - λ[0] y = 1/2 * ln((1 + sin(φ)) / (1 - sin(φ)))
其中λ[0]是地图中心点的经度。编写一个程序,接受λ[0]以及从命令行接受的点的纬度和经度,并写出其投影。
颜色转换。 有几种不同的格式用于表示颜色。例如,LCD 显示器、数码相机和网页的主要格式,称为 RGB 格式,指定了红色(R)、绿色(G)和蓝色(B)的级别,范围从 0 到 255 的整数。出版书籍和杂志的主要格式,称为 CMYK 格式,指定了青色(C)、品红色(M)、黄色(Y)和黑色(K)的级别,范围从 0.0 到 1.0 的实数。编写一个程序,将 RGB 转换为 CMYK。从命令行接受三个整数 —— r、g 和 b —— 并写入相应的 CMYK 值。如果 RGB 值都为 0,则 CMY 值都为 0,K 值为 1;否则,使用以下公式:
w = max(r/255, g/255, b/255) c = (w - r/255) / w m = (w - g/255) / w y = (w - b/255) / w k = 1 - w 这是一个示例运行:
$ python rgbtocmyk.py 75 0 130 # indigo cyan = 0.4230769230769229 magenta = 1.0 yellow = 0.0 black = 0.4901960784313726

大圆。 编写一个程序,接受四个浮点命令行参数
x1、y1、x2和y2(地球上两点的纬度和经度,以度为单位),并计算它们之间的大圆距离。大圆距离d(以海里为单位)由根据余弦定理推导的公式给出:d = 60 * arccos(sin(x[1]) * sin(x[2]) + cos(x[1]) * cos(x[2]) * cos(y[1] - y[2])) 请注意,此方程式使用的是度数,而 Python 的三角函数使用的是弧度。使用
math.radians()和math.degrees()在两者之间进行转换。使用您的程序计算巴黎(48.87° N,-2.33° W)和旧金山(37.8° N,122.4° W)之间的大圆距离。注意:地球的形状更像是一个扁平的椭球体,而不是一个球体,因此上面的公式只是一个近似值(误差约为 0.5%)。此外,这个公式对于小距离是不可靠的,因为反余弦函数是病态的。
这里是Haversine 公式:
a = sin²((L2-L1)/2) + cos(L1) * cos(L2) * sin²((G2-G1)/2) c = 2 * arcsin(min(1, sqrt(a))) # 以弧度表示的距离 distance = 60 * c # 海里 Haversine 公式对大多数距离是准确的,但当点(几乎)是对极点时,会出现舍入误差。以下公式对所有距离都是准确的。
delta = G1 - G2 p1 = cos(L2) * sin(delta) p2 = cos(L1) * sin(L2) - sin(L1) * cos(L2) * cos(delta) p3 = sin(L1) * sin(L2) + cos(L1) * cos(L2) * cos(delta) distance = 60 * atan2(sqrt(p1p1 + p2p2), p3) 这里的Kahan 参考提供了更多细节。
解决方案:参见大圆.py。
三排序。 ��写一个程序,从命令行接受三个整数,并按升序写出它们。使用内置的
min()和max()函数。解决方案:参见三排序.py。

龙曲线。 编写一个程序,以绘制龙曲线的指令,从 0 到 5 阶。指令是由字符
F、L和R组成的字符串,其中F表示“向前移动 1 个单位时画线”,L表示“向左转”,R表示向右转。当您将一条纸折叠 n 次,然后展开成直角时,就形成了 n 阶龙曲线。解决这个问题的关键是注意到 n 阶曲线是 n-1 阶曲线后跟一个 L,然后是以相反顺序遍历的 n-1 阶曲线,然后找出相反曲线的类似描述。解决方案:参见龙 1.py。
1.3 条件语句和循环
原文:
introcs.cs.princeton.edu/python/13flow译者:飞龙
我们使用术语控制流来指代程序中执行的语句序列。到目前为止,我们检查过的所有程序都具有简单的控制流:语句按给定顺序依次执行。大多数程序具有更复杂的结构,其中语句可能会根据某些条件(条件语句)而执行或不执行,或者一组语句会多次执行(循环)。
if语句
大多数计算需要针对不同的输入采取不同的操作。程序 flip.py 使用if-else语句来编写硬币翻转的结果。下表总结了一些典型情况,您可能需要使用if或if-else语句。
请注意,在 Python 中缩进是有意义的。例如,考虑这两个代码片段:
if x >= 0: if x >= 0:
stdio.write('not ') stdio.write('not ')
stdio.writeln('negative') stdio.writeln('negative')
如果x大于或等于 0,则两个片段都写出'not negative'。如果x小于 0,则左侧的代码写出'negative',但右侧的代码根本不写出任何内容。
while语句
许多计算本质上是重复的。while语句使我们能够多次执行一组语句。这使我们能够表达冗长的计算而不需要编写大量代码。程序 tenhellos.py 会写出"Hello, World"十次。程序 powersoftwo.py 接受一个命令行参数n,并写出所有小于或等于n的 2 的幂。
顺便提一下,在 Python 中,我们可以用简写符号i += 1来缩写形式为i = i + 1的赋值语句。相同的符号也适用于其他二元运算符,包括-、*和/。例如,大多数程序员会使用power *= 2而不是power = 2 * power在 powersoftwo.py 中。
for语句
许多循环遵循相同的基本方案:将索引变量初始化为某个值,然后使用while循环测试涉及索引变量的退出条件,使用while循环中的最后一个语句修改索引变量。Python 的for语句是表达这种循环的直接方式。例如,以下两行代码等同于 tenhellos.py 中相应的代码行:
for i in range(4, 11):
stdio.writeln(str(i) + 'th Hello')
如果range()只有一个参数,则范围值的起始值默认为 0。例如,以下for循环比 powersoftwo.py 中的while循环更好:
power = 1
for i in range(n+1):
stdio.writeln(str(i) + ' ' + str(power))
power *= 2
下表总结了一些典型情况,您可能需要使用while或for语句。
嵌套
我们可以在其他if、while或for语句中嵌套if、while或for语句。例如,divisorpattern.py 有一���for循环,其嵌套语句是一个for循环(其嵌套语句是一个if语句)和一个stdio.writeln()语句。
作为嵌套的第二个示例,考虑一个包含以下代码的税务准备程序:
if income < 0.0:
rate = 0.00
else:
if income < 8925:
rate = 0.10
else:
if income < 36250:
rate = 0.15
else:
if income < 87850:
rate = 0.25
...
Python 允许if语句包含elif("else if")子句。使用elif子句会产生更紧凑的代码:
if income < 0: rate = 0.00
elif income < 8925: rate = 0.10
elif income < 36250: rate = 0.15
elif income < 87850: rate = 0.23
elif income < 183250: rate = 0.28
elif income < 398350: rate = 0.33
elif income < 400000: rate = 0.35
else: rate = 0.396
应用
能够使用条件语句和循环进行编程立即为我们打开了计算世界的大门。

有限和。
powersoftwo.py 使用的计算范式是您经常会使用的范式之一。它使用两个变量 — 一个作为控制循环的索引,另一个用于累积计算结果。程序 harmonic.py 使用相同的范式来计算有限和H[n] = 1 + 1/2 + 1/3 + ... + 1/n。这些数字被称为调和数。
计算平方根。
math.sqrt()函数是如何实现的?程序 sqrt.py 演示了一种技术。它使用了艾萨克·牛顿和约瑟夫·拉夫逊开发的一般计算技术的一个特例,被广泛称为牛顿法。要计算正数t的平方根,从估计t = c开始。如果t等于c / t,那么t等于c的平方根,计算完成。如果不是,则通过用t和c / t的平均值替换t来改进估计。每次执行此更新时,我们都会更接近所需的答案。
数字转换。
程序 binary.py 将作为命令行参数键入的十进制数的二进制(基数 2)表示写入。它基于将数字分解为二的幂的和。例如,106 的二进制表示是 1101010,这意味着 106 = 64 + 32 + 8 + 2,或者用二进制表示,1101010 = 1000000 + 100000 + 1000 + 10。要计算n的二进制表示,我们按递减顺序考虑小于或等于n的 2 的幂,以确定哪些属于二进制分解(因此对应于二进制表示中的 1 位)。
蒙特卡洛模拟。
我们的下一个示例代表了一类广泛使用的程序,我们在其中使用计算机模拟真实世界可能发生的情况,以便在各种复杂情况下做出明智的决策。假设一个赌徒进行一系列公平的$1 赌注,从$50 开始,并继续玩直到她破产或拥有$250。她带着$250 回家的机会有多大,以及在赢或输之前她可能会做多少赌注?程序 gambler.py 是一个可以帮助回答这些问题的模拟。它接受三个命令行参数,初始赌注($50),目标金额($250),以及我们想要模拟游戏的次数。
因式分解。
素数是大于 1 的整数,其唯一的正因子是 1 和它本身。一个整数的素数分解是其乘积为该整数的素数的多重集。例如,3757208 = 22271313397。factors.py 程序计算任何给定正整数的素数分解。当factor*factor大于n时,我们可以停止寻找因子,因为如果整数n有一个因子,那么它小于或等于n的平方根。
半循环
假设我们想要一个循环,重复执行以下操作:执行一些语句序列,如果满足某个循环终止条件,则退出循环,并执行一些其他语句序列。也就是说,我们希望将循环控制条件放在循环中间,而不是在开头。这被称为半循环,因为你必须在达到循环终止测试之前走过循环的一部分。Python 提供break语句来实现这一目的。当 Python 执行break语句时,它立即退出(最内层)循环。

例如,考虑生成一个在单位圆盘中随机分布的点的问题。由于我们总是希望生成至少一个点,我们构建一个while循环,其循环继续条件总是满足,生成 2x2 方形中的随机点(x, y),并使用break语句来终止循环,如果(x, y)在单位圆盘中。
while True:
x = -1.0 + 2.0*random.random()
y = -1.0 + 2.0*random.random()
if x*x + y*y <= 1.0:
break
问与答
Q. =和==之间有什么区别?
A. 我们在这里重复这个问题,提醒您在条件表达式中不要使用=而应该使用==。语句x = y将y赋给x,而表达式x == y测试当前两个变量是否相等。在某些编程语言中,这种差异可能会在程序中造成混乱并难以检测。在 Python 中,赋值语句不是表达式。例如,如果我们在 gambler.py 中犯了错误,将cash = goal而不是cash == goal,编译器会为我们找到错误:
% python gambler.py 10 20 1000
File "gambler.py", line 21
if cash = goal:
^
SyntaxError: invalid syntax
Q. 如果我在if、while或for语句中省略冒号会发生什么?
A. Python 在编译时会引发SyntaxError。
Q. 缩进语句块的规则是什么?
A. 块中的每个语句必须具有相同的缩进;如果没有,Python 会在编译时引发IndentationError。Python 程序员通常使用四个空格的缩进方案,我们在本书中遵循这种方式。
Q. 我应该使用制表符来缩进我的代码吗?
A. 不可以,在.py文件中避免使用制表符。然而,许多编辑器在您按下
Q. 我可以将一条长语句分布在多行吗?
A. 是的,但是由于 Python 处理缩进的方式,需要一些小心。如果跨越多行的表达式被括在括号(或方括号或大括号)中,则不需要做任何特殊处理。例如,这是一条跨越三行的单个语句:
stdio.write(a0 + a1 + a2 + a3 +
a4 + a5 + a6 + a7 +
a8 + a9)
然而,如果没有暗示的行继续,你必须在每行末尾使用反斜杠字符以继续。
total = a0 + a1 + a2 + a3 + \
a4 + a5 + a6 + a7 + \
a8 + a9
Q. 假设我想在某些情况下跳过循环中的一些代码,或者假设我希望条件语句的主体为空,以便不执行任何语句。Python 是否支持这样的语言特性?
A. 是的,Python 分别为这些情况提供了continue和pass语句。然而,真正需要它们的情况很少,我们在本书中不使用它们。此外,Python 中没有switch语句(用于互斥的替代方案),尽管在其他语言中通常可以找到一个,也没有goto语句(用于非结构化控制流)。
Q. 我可以在if或while语句中使用非布尔表达式吗?
A. 可以,但这可能不是一个好主意。评估为零或空字符串的表达式被视为False;所有其他数字和字符串表达式被视为True。
Q. 是否有必须使用for语句而不使用while语句的情况,反之亦然?
A. 您可以使用while语句来实现任何类型的循环,但是,如此定义,您只能使用for语句来迭代有限整数序列的循环。稍后(第 1.4、3.3 和 4.4 节),我们将考虑使用for语句的其他方法。
Q. 我可以使用内置的range()函数来创建步长不为 1 的整数序列吗?
A. 是的,range()支持一个可选的第三个参数步长,默认为 1。也就是说,range(start, stop, step)生成整数序列start、start + step、start + 2 * step等。如果step是正整数,则序列会继续,直到start + i * step小于stop;如果step是负整数,则序列会继续,直到start + i * step大于stop。例如,range(0, -100, -1)返回整数序列 0, -1, -2, ..., -99。
Q. 我可以将浮点数用作range()的参数吗?
A. 不行,所有参数必须是整数。
Q. 我可以在for循环中更改循环索引变量吗?
A. 是的,但它不会影响range()生成的整数序列。例如,以下循环写入从 0 到 99 的 100 个整数:
for i in range(100):
stdio.writeln(i)
i += 10
Q. 在for循环中,循环控制变量在循环终止后的值是多少?
A. 它是循环结束时的循环控制变量的最后一个值。在上面的 for 循环终止后,i指的是整数 109。在 for 循环终止后使用循环控制变量通常被认为是不良风格,因此我们在任何程序中都不这样做。
Q. 我的程序陷入了无限循环。我该如何停止它?
A. 输入Ctrl-c。也就是,按住标有Ctrl或control的键,然后按c键。对于 Windows 命令提示符,请输入Ctrl-z。
Q. 是否有一个示例表明以下for和while循环不等价?
for *variable* in range(*start*, *stop*): *statement1* *statement2* ... *variable* = *start* while *variable* < *stop*: *statement1* *statement2* ... *variable* += 1
A. 是的。提示:使用continue语句。
练习
编写一个程序,接受三个整数命令行参数,如果三个数都相等则写入
'equal',否则写入'not equal'。编写一个更通用和健壮的版本的 quadratic.py(来自第 1.2 节),该程序写入多项式ax²bx + c的根,如果判别式为负则写入适当的错误消息,并且如果a为零则适当地行为(避免除以零)。
编写一个代码片段,接受两个浮点数命令行参数,如果两者都严格介于 0 和 1 之间则写入
True,否则写入False。通过添加代码来检查命令行参数的值是否在公式有效范围内,并添加代码来写入错误消息(如果不在范围内),改进第 1.2 节中“风寒”练习的解决方案。
在执行以下代码片段后,
j的值是多少?a. j = 0 for i in range(0, 10): j += i b. j = 1 for i in range(0, 10): j += j c. for j in range(0, 10): j += j重新设计 tenhellos.py 以编写一个接受要写入的行数作为命令行参数的程序。您可以假设参数小于 1000。提示:考虑使用
i % 10和i % 100来确定写入第i个 Hello 时是否使用st、nd、rd或th。编写一个程序,使用一个
for循环和一个if语句,每行写入从 1000(包括)到 2000(不包括)的整数,每行写入五个整数。提示:使用%运算符。解决方案:参见 fiveperline.py。
将第 1.2 节中的“均匀随机数”练习进行泛化,编写一个程序,接受一个整数
n作为命令行参数,使用random.random()写入n个介于 0 和 1 之间的均匀随机数,然后写入它们的平均值、最小值和最大值。描述当使用一个太大的参数调用 rulern.py 时会发生什么。例如,尝试执行命令
python rulern 100。编写一个程序,为n = 2, 4, 8, 16, 32, 64, 128 时,写入log n, n, n log n, n²和n³的值表。使用制表符(
'\t'字符)对齐列。
解决方案:参见 functiongrowth.py。
当执行以下代码后,
m和n是什么?n = 123456789 m = 0 while n != 0: m = (10 * m) + (n % 10) n //= 10解决方案:运行程序 digitreverser.py。
这段代码会写入什么?
f = 0 g = 1 for i in range(0, 16): f = f + g g = f - g stdio.writeln(f)解决方案:运行程序 fibonacci.py。
编写一个程序,接受一个命令行参数
n,并写出小于或等于n的所有正数的 2 的幂。确保您的程序对所有n的值都能正常工作。(如果n为负数或零,则您的程序不应写出任何内容。)扩展您对第 1.2 节中“连续复利”练习的解决方案,编写一个表格,列出每次月付款后支付的总金额和剩余本金。
组合一个使用
while循环而不是for循环的 divisorpattern.py 版本。与调和数不同,和 1/1² + 1/2² + ... + 1/n²随着n增长到无穷大时会收敛到一个常数。 (实际上,该常数是π²/6,因此可以使用此公式来估计π的值。)以下哪个
for循环计算这个和?假设n是整数 1000000,total是初始化为 0.0 的浮点数。a. for i in range(1, n+1): total += 1 / (i*i) b. for i in range(1, n+1): total += 1.0 / i*i c. for i in range(1, n+1): total += 1.0 / (i*i) d. for i in range(1, n+1): total += 1 / (1.0*i*i)证明 sqrt.py 实现了用于找到c的平方根的牛顿法。提示:使用切线的斜率等于函数f(x)在x = t处的导数f'(t)来找到切线方程,然后使用该方程找到切线与x轴相交的点,以显示您可以使用牛顿法来找到任何函数的根,方法如下:在每次迭代时,将估计值t替换为t - f(t) / f'(t)。
使用牛顿法,开发一个程序,该程序将整数 n 和 k 作为命令行参数,并写出 n 的第 k 个根(提示:参考前一个练习)。
修改 binary.py 以创建一个程序,该程序将
i和k作为命令行参数,并将i转换为基数k。假设k是介于 2 和 16 之间的整数。对于大于 10 的基数,使用字母A到F分别表示第 11 到第 16 位数字。编写一个名为的程序,接受一个正整数命令行参数
n,将n的二进制表示放入一个字符串中,然后写出该字符串。解决方案:参见 binary2.py。
组合一个版本的 gambler.py,该版本使用两个嵌套的
while循环或两个嵌套的for循环,而不是在for循环内部使用while循环。编写一个程序,通过在每次下注后写一行来跟踪赌徒破产模拟,每个星号对应赌徒所持有的每一美元。
修改 gambler.py 以接受额外的命令行参数,指定赌徒每次下注赢得的(固定)概率。使用您的程序尝试了解这个概率如何影响获胜的机会和预期下注次数。尝试一个接近 0.5 的p值(例如,0.48)。
修改 gambler.py 以接受额外的命令行参数,指定赌徒愿意进行的下注次数,以便游戏以三种可能的方式结束:赌徒赢了、输了或时间用尽。添加输出以给出赌徒在游戏结束时预期拥有的金额。额外奖励:使用您的程序计划下次前往蒙特卡洛的旅行。
修改 factors.py 以仅写出每个质因数的一个副本。
运行快速实验,以确定在 factors.py 中使用终止条件
factor <= n而不是factor*factor <= n的影响。对于每种方法,找到最大的n,以便当您输入一个n位数时,程序肯定会在 10 秒内完成。编写一个程序,接受一个整数命令行参数
n,并写出一个二维n×n的棋盘图案,交替使用空格和星号,就像以下 4×4 的图案一样。* * * * * * * * * * * * * * * *编写一个程序,从命令行接受两个整数
x和y,并使用欧几里德算法找到并写出x和y的最大公约数(gcd),这是一种基于以下观察的迭代计算:如果x > y,那么如果y整除x,则x和y的最大公约数是y;否则x和y的最大公约数与x % y和y的最大公约数相同。编写一个程序,接受一个命令行参数
n,并写出一个n×n表格,如果i和j的最大公约数是 1(i和j是互质的),则在第i行和第j列中有一个*,否则在该位置有一个空格。编写一个程序,生成一个在单位圆盘中随机分布的点,但不使用
break语句。将你的解决方案与本节末尾给出的解决方案进行比较。编写一个程序,写出单位球面上一个随机点(a, b, c)的坐标。要生成这样一个点,使用Marsaglia 方法:首先,在单位圆盘中选择一个随机点(x, y),方法如本节末尾所述。然后,将a设置为 2 x sqrt(1 - x² - y²),将b设置为 2 y sqrt(1 - x² - y²),将c设置为 1 - 2 (x² + y²)。
创意练习
拉马努金的出租车。 S.拉马努金是一位印度数学家,以他对数字的直觉而闻名。有一天,英国数学家 G.H.哈代来医院看望他时,哈代说他的出租车号码是 1729,一个相当乏味的数字。拉马努金回答说:“不,哈代!不,哈代!这是一个非常有趣的数字。它是可以用两种不同方式的两个立方数之和来表示的最小数字。”通过编写一个程序验证这一说法,该程序接受一个命令行参数
n,并写出所有小于或等于n的整数,这些整数可以用两种不同方式的两个立方数之和来表示。换句话说,找到不同的正整数a, b, c和d,使得a³ + b³ = c³ + d³。使用四个嵌套的for循环。解决方案: 请参见 ramanujanwhile.py 和 ramanujanfor.py。
现在,车牌号 87539319 似乎是一个相当乏味的数字。确定它为什么不是。
校验和。 国际标准书号(ISBN)是一个 10 位代码,可以唯一指定一本书。最右边的数字是一个校验和数字,可以根据其他 9 位数字唯一确定,条件是 10d[10] + 9d[9] + ... + 2d[2] + d[1]必须是 11 的倍数(这里d[i]表示从右边数第i位数字)。校验和数字d[1]可以是 0 到 10 之间的任何值:ISBN 约定使用值'X'表示 10。示例: 对应于 020131452 的校验和数字是 5,因为它是 0 到 10 之间的唯一值,使得 100 + 92 + 80 + 71 + 63 + 51 + 44 + 35 + 2*2 + d[1]是 11 的倍数。编写一个程序,接受一个 9 位整数作为命令行参数,计算校验和,并写出 10 位 ISBN 号码。如果程序不写任何前导 0,那也没关系。
解决方案: 请参见 isbn.py。
计算素数。 编写一个程序,接受一个命令行参数
n,并写出小于n的素数数量。使用它来计算小于 1000 万的素数数量。注意: 如果你不小心让你的程序变得高效,它可能无法在合理的时间内完成。在第 1.4 节中,你将学习一种更有效的计算方法,称为埃拉托斯特尼筛法。二维随机漫步。 二维随机漫步模拟了一个粒子在点阵中移动的行为。在每一步中,随机漫步者以 1/4 的概率向北、南、东或西移动,与之前的移动无关。编写一个程序,接受一个命令行参数
n,并估计一个随机漫步者击中以起始点为中心的一个 2n+1×2n+1 正方形边界需要多长时间。五数中位数。 编写一个程序,从命令行获取五个不同的整数,并写出中位数(即其他两个整数较小,另外两个整数较大的值)。额外加分:使用比给定输入少于七次比较值的程序解决该问题。
指数函数。 假设
x是一个浮点数。编写一个代码片段,使用泰勒级数展开将 e^x = 1 + x + x²/2! + x³/3! + ... 赋值给total。
解决方案:这个练习的目的是让你思考一个类似 math.exp() 这样的库函数如何通过基本运算符实现。尝试解决它,然后将你的解决方案与这里开发的解决方案进行比较。我们首先考虑计算一个项的问题。假设 x 是一个浮点数,n 是一个整数。以下代码使用直接方法为 term 赋值 x^(n) / n!,其中分子和分母分别有一个循环,然后将结果相除:
num = 1.0
den = 1.0
for i in range(1, n+1):
num *= x
for i in range(1, n+1):
den *= i
term = num / den
更好的方法是只使用一个 for 循环:
term = 1.0
for i in range(1, n+1):
term *= x / i
除了更紧凑和优雅外,后一种解决方案更可取,因为它避免了由于使用巨大数字进行计算而引起的不准确性。例如,对于像 x = 10 和 n = 100 这样的值,两个循环的方法会出现问题,因为 100! 太大,无法准确表示为浮点数。
要计算 e^x,我们将这个 for 循环嵌套在一个 while 循环中:
term = 1.0
total = 0.0
n = 1
while total != total + term:
total += term
term = 1.0
for i in range(1, n+1):
term *= x / i
n += 1
while
total
(term > 0)
for
while
while
term = 1.0
total = 0.0
n = 1
while total != total + term:
total += term
term *= x / n
n += 1
实验分析。 运行实验以确定计算 e^x 问题时
Math.exp()和来自练习 2.3.36 的以下三种方法的相对成本:具有嵌套循环的直接方法,具有单个循环的改进方法,以及具有循环继续条件(term > 0)的后者。对于每种方法,使用命令行参数进行试错,以确定您的计算机在 10 秒内可以执行多少次计算。三角函数。 编写程序,使用泰勒级数展开计算 sin x 和 cos x:
sin x = x - x³/3! + x⁵/5! - x⁷/7! + ...
cos x = 1 - x²/2! + x⁴/4! - x⁶/6! + ...
部分解决方案:参见 sine.py。
皮皮斯问题。 在 1693 年,塞缪尔·皮皮斯问艾萨克·牛顿,掷一个公平骰子六次至少得到一个 1 的概率和掷它十二次至少得到两个 1 的概率哪个更大。编写一个程序,可以为牛顿提供一个快速答案。
游戏模拟。 在 1970 年代的游戏节目 让我们来做个交易 中,一个参赛者面前有三扇门。其中一扇门后面有一个有价值的奖品。在参赛者选择一扇门后,主持人会打开其他两扇门中的一扇(当然不会透露奖品)。然后参赛者有机会换到另一扇未打开的门。参赛者应该这样做吗?直观上,参赛者最初选择的门和另一扇未打开的门被认为是同等可能包含奖品的,因此没有换门的动机。编写一个程序通过模拟来测试这种直觉。您的程序应该接受一个命令行参数
n,使用两种策略(换门或不换门)玩n次游戏,并写出两种策略的成功概率。或者您可以在这里玩游戏。解决方案:参见 montehall.py。
混沌。 编写一个程序来研究以下简单的人口增长模型,该模型可以应用于研究池塘中的鱼、试管中的细菌或类似情况。我们假设人口从 0(灭绝)到 1(可维持的最大人口)范围内。如果时间为t时的人口为x,那么我们假设时间为t + 1 时的人口为rx*(1-x),其中参数r,称为生育参数,控制增长速率。从一个小的人口开始 — 比如,x = 0.01 — 并研究模型的迭代结果,对于不同的r值。在哪些r值下,人口会稳定在x = 1 - 1/r?当r为 3.5 时,你能说些什么?3.8?5?
生物学家使用Logistic 方程来模拟池塘中鱼类的人口增长。研究一些混沌行为。
欧拉的幂求和猜想。1769 年,莱昂哈德·欧拉提出了费马大定理的一个广义版本,猜想至少需要n个正整数的n次幂来得到一个本身也是n次幂的和,对于n > 2。编写一个程序来反驳欧拉的猜想(直到 1967 年仍然有效),使用五重嵌套循环来找到四个正整数,它们的 5 次幂之和等于另一个正整数的 5 次幂。也就是说,找到五个整数a、b、c、d和e,使得a⁵ + b⁵ + c⁵ + d⁵ = e⁵。
龙曲线。 这个练习是从第 1.2 节的“龙曲线”练习推广而来。编写一个程序,接受一个整数命令行参数
n,并编写绘制一个阶为n的龙曲线的指令。解决方案:参见 dragon2.py。
1.4 数组
原文:
introcs.cs.princeton.edu/python/14array译者:飞龙
数据结构是一种组织我们希望用计算机程序处理的数据的方式。一维数组(或数组)是一种存储对象序列(引用)的数据结构。我们将数组中的对象称为其元素。我们用来引用数组中的元素的方法是编号,然后索引它们。如果我们有* n 个元素在序列中,我们认为它们从 0 到 n -1 编号。然后,我们可以通过引用该范围内的任何整数i的第i*个元素来明确指定其中的一个。
二维数组是一个(引用)一维数组的数组。一维数组的元素由单个整数索引,而二维数组的元素由一对整数索引:第一个指定行,第二个指定列。
Python 中的数组
在 Python 中创建数组的最简单方法是在匹配的方括号之间放置逗号分隔的文字。例如,代码
SUITS = ['Clubs', 'Diamonds', 'Hearts', 'Spades']
x = [0.30, 0.60, 0.10]
y = [0.50, 0.10, 0.40]
创建一个包含四个字符串的数组SUITS[],并创建包含三个浮点数的数组x[]和y[]。
给定相同长度的两个向量,它们的点积是它们对应分量的乘积之和。如果我们将两个向量表示为长度为 n 的一维数组x[]和y[],它们的点积很容易计算:
total = 0.0
for i in range(n):
total += x[i]*y[i]
例如,以下跟踪显示了计算长度为 3 的两个向量的点积。

将数组中元素的引用连续存储在计算机内存中,如上图所示,对于上面定义的SUITS[]数组。
基于零的索引。
我们总是将数组 a[]的第一个元素称为 a[0],第二个称为 a[1],依此类推。可能更自然的是将第一个元素称为 a[1],第二个元素称为 a[2],依此类推,但从 0 开始索引有一些优势,并且已经成为大多数现代编程语言使用的约定。
数组长度。
您可以使用 Python 内置的len()函数访问数组的长度:len(a)是a[]中元素的数量。在 Python 中,我们可以使用+=运算符将元素附加到数组。例如,如果a[]是数组[1, 2, 3],那么语句a += [4]将其扩展为[1, 2, 3, 4]。更一般地,我们可以使用以下代码创建一个包含n个浮点数的数组,其中每个元素初始化为0.0,
a = []
for i in range(n):
a += [0.0]
边界检查。
在使用数组编程时必须小心。在访问数组元素时使用合法索引是您的责任。
可变性。

如果一个对象是可变的,那么它的值可以改变。数组是可变对象,因为我们可以改变它们的元素。例如,如果我们用代码x = [.30, .60, .10]创建一个数组,那么赋值语句x[1] = .99将把它改变为数组[.30, .99, .10]。这个操作的对象级跟踪显示在右侧。
以下代码颠倒了数组 a[]中元素的顺序:
n = len(a)
for i in range(n // 2):
temp = a[i]
a[i] = a[n-1-i]
a[n-1-i] = temp
对于一个包含七个元素的数组[3, 1, 4, 1, 5, 9, 2],这段代码的非正式跟踪显示在右侧。

迭代。
以下代码遍历数组的所有元素以计算其中包含的浮点数的平均值:
total = 0.0
for i in range(len(a)):
total += a[i]
average = total / len(a)
Python 还支持在不显式引用索引的情况下迭代数组中的元素。为此,在for语句中的in关键字后放置数组名称,如下所示:
total = 0.0
for v in a:
total += v
average = total / len(a)
内置函数。
Python 有几个可以接受数组作为参数的内置函数。我们已经讨论了len()函数。举个例子,如果a[]的元素是数字,那么sum(a)会计算它们的和,因此我们可以使用float(sum(a)) / len(a)来计算它们的平均值,而不是使用刚刚描述的任何一个循环。其他有用的可以接受数组作为参数的内置函数有用于计算最小值的min()和用于计算最大值的max()。
写入数组。
你可以通过将数组作为参数传递给stdio.write()或stdio.writeln()来写入数组。数组中的每个对象都会被转换为一个字符串。
数组别名和复制
在查看使用数组的程序之前,值得更详细地研究两个基本的数组处理操作。
别名。
如果x[]和y[]是数组,则语句x = y会导致x和y引用同一个数组。这个结果可能一开始会让人感到意外,因为自然而然地会认为x和y是指向两个独立数组的引用。例如,在赋值语句之后
x = [.30, .60, .10]
y = x
x[1] = .99
y[1]也是.99,即使代码没有直接引用y[1]。这种情况——当两个变量引用同一个对象时——被称为别名,并在右侧的对象级别跟踪中进行了说明。
复制和切片。
那么我们如何复制给定数组x[]的副本y[]?对于这个问题的一个答案是通过遍历x[]来构建y[],就像以下代码中所示:
y = []
for v in x:
y += [v]
这种情况在右侧的对象级别跟踪中进行了说明。
复制数组是一个非常有用的操作,Python 为更一般的操作提供了语言支持,称为切片,表达式a[i:j]评估为一个新数组,其元素为a[i], ..., a[j-1]。此外,i的默认值为 0,j的默认值为len(a),因此y = x[:]等同于前面给出的代码。
数组的系统支持
用于处理数组的 Python 代码可以采用多种形式。我们简要描述每种形式以便理解上下文。
Python 内置的list数据类型。
在其最基本的形式中,数组支持四个核心操作:创建、索引访问、索引赋值和迭代。在这个书站中,我们使用 Python 的内置list数据类型来表示数组,因为它支持这些基本操作。我们将在第四章中考虑 Python 的list数据类型支持的更复杂的操作。
Python 的numpy模块。
Python 内置的list数据类型可能会存在严重的性能问题。因此,科学家和工程师经常使用一个名为numpy的 Python 扩展模块来处理大量的数字数组,因为该模块使用了一个避免了标准 Python 表示中许多低效的表示的底层表示。请参阅附录:numpy以获取numpy模块的概述。
我们的stdarray模块。
之前我们介绍了书站stdio模块。现在,我们介绍另一个书站模块:stdarray模块。它的主要目的是定义用于处理数组的函数。
几乎每个数组处理程序中都会找到的一个基本操作是创建一个包含n个元素的数组,每个元素都初始化为给定值。正如我们所见,你可以使用类似以下代码在 Python 中实现这一点:
a = []
for i in range(n):
a += [0.0]
这样的代码是如此常见,以至于 Python 甚至为其提供了一个特殊的简写表示法:代码a = [0.0]*n等同于刚刚给出的代码。为了避免在整本书中重复这样的代码,我们将使用类似这样的代码:
a = stdarray.create1D(n, 0.0)
为了保持一致,stdarray还包括一个create2D()函数,我们将在本节稍后讨论。
数组的示例应用
接下来,我们考虑一些应用程序,这些应用程序说明了数组的实用性,并且本身也很有趣。
表示扑克牌。
假设我们想要编写处理扑克牌的程序。我们可能从以下代码开始:
SUITS = ['Clubs', 'Diamonds', 'Hearts', 'Spades']
RANKS = ['2', '3', '4', '5', '6', '7', '8', '9', '10',
'Jack', 'Queen', 'King', 'Ace']
例如,我们可以使用这两个数组来编写一个随机的卡片名称,比如梅花皇后,如下所示:
rank = random.randrange(0, len(RANKS))
suit = random.randrange(0, len(SUITS))
stdio.writeln(RANKS[rank] + ' of ' + SUITS[suit])
更典型的情况是当我们计算要存储在数组中的值时。例如,我们可以使用以下代码初始化一个长度为 52 的数组,表示一副扑克牌,使用刚刚定义的两个数组:
deck = []
for rank in RANKS:
for suit in SUITS:
card = rank + ' of ' + suit
deck += [card]
交换。
经常情况下,我们希望在数组中交换两个元素。继续我们的扑克牌示例,以下代码交换索引i和j处的卡片:
temp = deck[i]
deck[i] = deck[j]
deck[j] = temp
洗牌
。以下代码洗牌我们的牌组:
n = len(deck)
for i in range(n):
r = random.randrange(i, n)
temp = deck[r]
deck[r] = deck[i]
deck[i] = temp
从左到右进行,我们从 deck[i]到 deck[n-1]中随机选择一张卡片(每张卡片等概率),并将其与 deck[i]交换。
无放回抽样。
在许多情况下,我们希望从一个集合中随机抽取一个样本,以便集合中的每个元素在样本中最多出现一次。程序 sample.py 接受命令行参数m和n,并创建一个大小为n的排列,其中前m个元素构成一个随机样本。
预先计算的值。
数组的另一个应用是保存您已计算的值以供以后使用。例如,假设您正在编写一个使用调和数小值进行计算的程序。一种高效的方法是将这些值保存在数组中,如下所示:
harmonic = stdarray.create1D(n+1, 0.0)
for i in range(1, n+1):
harmonic[i] = harmonic[i-1] + 1.0/i
请注意,我们在数组中浪费了一个槽位(元素 0),以使 harmonic[1]对应于第一个调和数 1.0,而 harmonic[i]对应于第 i 个调和数。如果我们需要大量 n 的值,这种方法并不有效,但如果我们需要多次获取小 n 的值,这种方法非常有效。
简化重复代码。
作为数组的另一个简单应用的例子,考虑以下代码片段,根据月份的数字(1 代表一月,2 代表二月,依此类推)写出月份的名称:
if m == 1: stdio.writeln('Jan')
elif m == 2: stdio.writeln('Feb')
elif m == 3: stdio.writeln('Mar')
elif m == 4: stdio.writeln('Apr')
...
elif m == 11: stdio.writeln('Nov')
elif m == 12: stdio.writeln('Dec')
更紧凑的替代方案是使用一个包含月份名称的字符串数组:
MONTHS = ['', 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
...
stdio.writeln(MONTHS[m])
如果您需要在程序中的多个不同位置通过其数字访问月份的名称,这种技术将特别有用。请注意,我们故意浪费了数组中的一个槽位(元素 0),以使MONTHS[1]对应于一月,如所需。

收集优惠券。
假设你有一副牌,并且你逐个随机选择卡片(有放回地)。在你看到每种花色之前,你需要翻开多少张卡片?这是著名的收集优惠券问题的一个例子。一般来说,假设一个交易卡公司发行了具有 n 种不同可能卡片的交易卡:在你收集到所有 n 种可能性之前,你需要收集多少张卡片,假设每张卡片收集时每种可能性都是等概率的?程序 couponcollector.py 是一个模拟这一过程的示例程序。详细信息请参阅教科书。
埃拉托斯特尼筛法。
素数计数函数π(n)是小于或等于n的素数的数量。例如π(17) = 7,因为前七个素数是 2、3、5、7、11、13 和 17。程序 primesieve.py 接受一个命令行整数 n,并使用埃拉托斯特尼筛法计算π(n)。详细信息请参阅教科书。
二维数组

在许多应用中,存储信息的一种便捷方式是使用一个以矩形表格组织的数字表,并引用表中的行和列。对应于这种表格的数学抽象是矩阵;相应的数据结构是二维数组。
初始化。
创建二维数组的最简单方法是在匹配的方括号之间放置逗号分隔的一维数组。例如,以下具有两行三列的整数矩阵
18 19 20
21 22 23
可以使用以下数组表示在 Python 中:
a = [[18, 19, 20], [21, 22, 23]]
我们称这样的数组为2 乘 3数组。更一般地,Python 将m乘n数组表示为包含m个对象的数组,每个对象都是包含n个对象的数组。例如,以下 Python 代码创建了一个浮点数的m乘n数组a[][],其中所有元素都初始化为 0.0:
a = []
for i in range(m):
row = [0.0] * n
a += [row]
对于一维数组,我们在整个本站使用了自描述的替代方案stdarray.create2D(m, n, 0.0),该方案来自我们的本站模块stdarray。
索引。
当a[][]是一个二维数组时,语法a[i]表示对其第i行的引用。语法a[i][j]指的是第i行和第j列的对象。为了访问二维数组中的每个元素,我们使用两个嵌套的for循环。例如,以下代码将m乘n数组a[][]的每个对象写入,每行一个。
for i in range(m):
for j in range(n):
stdio.write(a[i][j])
stdio.write(' ')
stdio.writeln()
该代码实现了相同的效果,而不使用索引:
for row in a:
for v in row:
stdio.write(v)
stdio.write(' ')
stdio.writeln()

矩阵操作。
在科学和工程中的典型应用涉及将矩阵表示为二维数组,然后使用矩阵操作数实现各种数学运算。例如,我们可以如下相加两个n乘n的矩阵a[][]和b[][]:
c = stdarray.create2D(n, n, 0.0)
for i in range(n):
for j in range(n):
c[i][j] = a[i][j] + b[i][j]
同样,我们可以相乘两个矩阵。矩阵a[][]和b[][]的乘积中每个元素c[i][j]是通过计算a[][]的第i行与b[][]的第j列的点积得到的。
c = stdarray.create2D(n, n, 0.0)
for i in range(n):
for j in range(n):
# Compute the dot product of row i and column j
for k in range(n):
c[i][j] += a[i][k] * b[k][j]
不规则数组。
事实上,并不要求二维数组中的所有行具有相同的长度。行长不一的数组称为不规则数组。不规则数组的可能性需要在编写数组处理代码时更加小心。例如,以下代码写入了一个不规则数组的内容:
for i in range(len(a)):
for j in range(len(a[i])):
stdio.write(a[i][j])
stdio.write(' ')
stdio.writeln()
请注意,不使用索引的等效代码对于矩形数组和不规则数组同样有效:
for row in a:
for v in row:
stdio.write(v)
stdio.write(' ')
stdio.writeln()
多维数组。
相同的表示法扩展到允许我们��用任意维数的数组来组合代码。使用数组的数组的数组...,我们可以创建三维数组、四维数组等,然后使用诸如a[i][j][k]的代码引用单个元素。
示例:避免自我随机漫步。
程序 selfavoid.py 是将二维数组应用于化学的一个示例。有关详细信息,请参阅教科书。
问与答
问: 为什么 Python 字符串和列表的索引从 0 开始而不是从 1 开始?
答: 这种约定起源于机器语言编程,其中计算数组元素的地址是通过将索引加到数组开头的地址来完成的。从 1 开始的索引要么会导致在数组开头浪费空间,要么会浪费时间来减去 1。这里是Edsger Dijkstra 的解释。
问: 如果我使用负整数索引数组会发生什么?
答: 答案可能会让你惊讶。给定一个数组a[],您可以使用索引-i作为len(a)-i的简写。例如,您可以用a[-1]或a[len(a)-1]引用数组中的最后一个元素,用a[-len(a)]或a[0]引用第一个元素。如果您使用范围在-len(a)到len(a)-1之外的索引,Python 会在运行时引发IndexError。
问: 为什么切片a[i:j]包括a[i]但不包括a[j]?
答: 这种表示法与使用range()定义的范围一致,其中包括左端点但不包括右端点。它导致一些吸引人的特性:j-i是子数组的长度(假设没有截断);a[0:len(a)]是整个数组;a[i:i]是空数组;a[i:j] + a[j:k]是子数组a[i:k]。
Q. 当我用(a == b)比较两个数组a[]和b[]时会发生什么?
A. 这取决于情况。对于数字数组(或多维数组),它的工作方式如你所期望的:如果每个数组具有相同的长度且对应元素相等,则数组相等。
Q. 当随机漫步不避免自身时会发生什么?
A. 这种情况很容易理解。这是一个二维版本的赌徒破产问题,如 1.3 节所述。
Q. 使用数组时应该注意哪些陷阱?
A. 记住,创建数组所需的时间与数组的长度成正比。在循环内创建数组时要特别小心。
练习
编写一个程序,创建一个包含恰好 1000 个整数的一维数组
a,然后尝试访问a[1000]。运行程序时会发生什么?给定用一维数组表示的长度为
n的两个向量,编写一个代码片段,计算它们之间的欧几里德距离(对应元素之间差的平方和的平方根)。编写一个代码片段,反转一个浮点数一维数组的顺序。不要创建另一个数组来保存结果。提示:使用本网页早期提供的代码交换两个元素。
解决方案:
n = len(a) for i in range(n//2): temp = a[n-i-1] a[n-i-1] = a[i] a[i] = temp以下代码片段有什么问题?
a = [] for i in range(10): a[i] = i * i解决方案:最初
a是空数组。随后没有元素附加到数组中。因此,a[0],a[1]等等不存在。在赋值语句中尝试使用它们将在运行时引发IndexError。编写一个代码片段,使用
*表��True,空格表示False,写出一个布尔值的二维数组的内容。包括行号和列号。以下代码片段写出了什么?
a = stdarray.create1D(10, 0) for i in range(10): a[i] = 9 - i for i in range(10): a[i] = a[a[i]] for v in a: stdio.writeln(v)执行以下代码片段后
a[]是什么?n = 10 a = [0, 1] for i in range(2, n): a += [a[i-1] + a[i-2]]编写一个程序,从命令行参数
n中接受一个整数,并从洗牌后的牌组中写出n个扑克手(每个五张牌),用空行分隔。解决方案:参见 deal.py。
编写代码片段创建一个二维数组
b[][],它是现有二维数组a[][]的副本,在以下每种假设下:a是方阵的。a是矩形的。a可能是不规则的。
你对(b)的解决方案应该适用于(a),你对(c)的解决方案应该适用于(b)和(a)。
编写一个代码片段,写出一个二维数组的转置(行和列交换)。例如,给定这个整数二维数组:
99 85 98 98 57 78 92 77 76 94 32 11 99 34 22 90 46 54 76 59 88 92 66 89 97 71 24 89 29 38你的代码应该写出这个:
99 98 92 94 99 90 76 92 97 89 85 57 77 32 34 46 59 66 71 29 98 78 76 11 22 54 88 89 24 38编写一个代码片段,就地转置一个方阵二维数组
b[][],而不创建第二个数组。解决方案。参见 transpose.py。
编写一个代码片段,创建一个二维数组
b[][],它是现有m乘n数组a[][]的转置。编写一个程序,计算两个布尔值方阵的乘积,使用
or操作代替+,使用and操作代替*。编写一个程序,从命令行接受一个整数n,创建一个n乘n的布尔数组
a,使得a[r][c]为True,如果r和c是互质的(除了 1 没有其他公因数),否则为False。然后使用*表示True,空格表示False写出数组(参见书站本节中的先前练习)。包括行号和列号。提示:使用筛法。编写一个代码片段,用于乘法计算两个不一定是方阵的浮点数矩阵。注意:为了使点积有明确定义,第一个矩阵的列数必须等于第二个矩阵的行数。如果维度不满足此条件,请写出错误消息。
修改 selfavoid.py 以计算并写入路径的平均长度以及死胡同的概率。保持逃逸路径和死胡同路径的平均长度分开。
修改 selfavoid.py 以计算并写入包围路径的最小轴向矩形的平均面积。为逃逸路径和死胡同路径保持统计数据分开。
创意练习
骰子模拟。 ���下代码计算两个骰子点数之和的精确概率分布:
probabilities = stdarray.create1D(13, 0.0) for i in range(1, 7): for j in range(1, 7): probabilities[i+j] += 1.0 for k in range(2, 13): probabilities[k] /= 36.0在此代码完成后,
probabilities[k]是骰子点数为k的概率。运行实验验证这个计算,模拟 n 次掷骰子,跟踪每个值出现的频率,当你计算两个介于 1 和 6 之间的随机整数的和时。在你的经验结果与精确结果匹配到小数点后三位之前,n 必须有多大?最长高原。 给定一个整数数组,编写一个程序,找到最长连续相等值序列的长度和位置,其中该序列前后的元素值较小。
经验洗牌检查。 运行计算实验以检查我们的洗牌代码是否按照广告宣传的那样工作。编写一个程序,接受整数命令行参数 m 和 n,对一个大小为 m 的数组进行 n 次洗牌,每次初始化为
a[i] = i,并写入一个 m×m 的表,其中第i行给出了i最终在所有j位置上出现的次数。数组中的所有条目应接近于 n/m。糟糕的洗牌。 假设在我们的洗牌代码中选择一个介于 0 和 n-1 之间的随机整数,而不是介于
i和n-1之间的随机整数。证明所得到的顺序不可能是 n! 种可能性之一。对这个版本运行上一个练习的测试。部分解决方案:当 n = 3 时,有 3! = 6 种可能性,但有些更有可能:
ABC ACB BAC BCA CAB CBA 4/27 5/27 6/27 4/27 5/27 3/27 音乐洗牌。 你将音乐播放器设置为随机模式。它在重复任何歌曲之前播放 n 首歌曲。编写一个程序来估计你不会听到任何连续一对歌曲的可能性(即,歌曲 3 不会跟在歌曲 2 后面,歌曲 10 不会跟在歌曲 9 后面,依此类推)。
排列中的极小值。 编写一个程序,从命令行接受一个整数 n,生成一个随机排列,写入排列,并写入排列中从左到右的极小值的数量(元素是迄今为止看到的最小值的次数)。然后编写一个程序,从命令行接受整数 m 和 n,生成大小为 n 的 m 个随机排列,并写入所生成排列中从左到右的极小值的平均数量。额外奖励:提出一个关于大小为 n 的排列中从左到右的极小值数量的假设,作为 n 的函数。
逆排列。 编写一个程序,从 n 个命令行参数中接受整数范围为 0 到 n-1 的排列,并写入其逆排列。(如果排列是一个数组
a[],其逆排列是数组b[],使得a[b[i]]=b[a[i]]=i。)确保检查输入是否是有效的排列。解决方案:参见 inversepermutation.py。
哈达玛矩阵。 n×n 哈达玛矩阵 H[n] 是一个布尔矩阵,具有显著的特性,即任意两行在恰好 n/2 个元素上不同。(这个特性使其在设计纠错码时非常有用。)H[1] 是一个 1×1 的矩阵,其中单个元素为
True,对于 n > 1,H[2n] 是通过将四个 H[n] 的副本对齐在一个大正方形中获得的,然后反转右下角的所有元素,如下例所示(其中T代表True,F代表False,如常)。H(1) H(2) H(4) ------------------- T T T T T T T T F T F T F T T F F T F F T编写一个程序,接受一个命令行参数n,并写出H[n]。假设n是 2 的幂。
解决方案: 请查看 hadamard.py。
谣言。 爱丽丝正在与其他 n 位客人举办派对,包括鲍勃。鲍勃向其中一位其他客人传播关于爱丽丝的谣言。第一次听到这个谣言的人会立即将其告诉另一位客人,从所有在派对上的人中(除了爱丽丝和他们听到谣言的人)随机选择一个。如果一个人(包括鲍勃)第二次听到这个谣言,他或她将不会再传播它。编写一个程序来估计在谣言停止传播之前每个在派对上的人(除了爱丽丝)都会听到谣言的概率。还计算一下预计会听到谣言的人数。
查找重复项。 给定一个包含n个元素的数组,每个元素介于 1 和n之间,编写一个代码片段来确定是否存在任何重复项。你不需要保留给定数组的内容,但不要使用额外的数组。
扫雷。 编写一个程序,接受三个命令行参数m、n和p,并生成一个m乘n的布尔数组,其中每个元素以概率p被占据。在扫雷游戏中,被占据的单元格代表炸弹,空单元格代表安全单元格。用星号表示炸弹,用句点表示安全单元格。然后,用相邻炸弹的数量(上、下、左、右或对角线)替换每个安全方块,并写出结果,如下例所示。
* * . . . * * 1 0 0 . . . . . 3 3 2 0 0 . * . . . 1 * 1 0 0尽量表达你的代码,使得尽可能少处理特殊情况,通过使用一个(m+2)-by-(n+2)的布尔数组。
解决方案: 请查看 minesweeper.py。
避免自我行走长度。 假设网格大小没有限制。运行实验估计平均步行长度。
三维避障步行。 运行实验以验证三维避障步行的死胡同概率为 0,并计算各个n值的平均步行长度。
随机行走者。 假设有
n个随机行走者,从一个n乘n的网格中心开始,每次移动一步,选择向左、向右、向上或向下的概率相等。编写一个程序来帮助制定并测试有关在所有单元格都被触及之前所需步数的假设。解决方案: 请查看 randomwalkers.py。
桥牌手。 在桥牌游戏中,四名玩家每人发 13 张牌。一个重要的统计数据是每手中每种花色的牌数分布。5-3-3-2,4-4-3-2 或 4-3-3-3 哪种可能性最大?编写一个程序来帮助你回答这个问题。
��日问题。 假设人们继续进入一个空房间,直到有一对人共享生日。平均需要多少人进入才会有匹配?运行实验估计这个数量的值。假设生日是在 0 到 364 之间均匀随机的整数。
解决方案: 请查看 birthday.py 和 birthdays.py。
收集优惠券。 运行实验验证经典数学结果,即收集n个值所需的期望优惠券数量约为nH[n]。例如,如果你在二十一点桌上仔细观察牌(并且庄家有足够多的随机洗在一起的牌组),平均需要发出约 235 张牌,才能看到每张牌的价值。
洗牌。 编写一个程序,使用 Gilbert-Shannon-Reeds 模型的洗牌方法重新排列一副n张牌。首先,根据二项分布生成一个随机整数r:抛掷一个公平硬币n次,让r为正面的次数。现在,将牌分成两堆:前r张牌和剩下的n - r张牌。为了完成洗牌,反复从两堆中的一堆顶部取一张牌并放在一个新堆的底部。如果第一堆剩余n[1]张牌,第二堆剩余n[2]张牌,从第一堆选择下一张牌的概率为n[1] / (n[1] + n[2]),从第二堆选择的概率为n[2] / (n[1] + n[2])。研究需要对一副 52 张牌的牌组应用多少次洗牌才能产生一个(几乎)均匀洗牌的牌组。
二项式系数。 编写一个程序,构建并写入一个二维不规则数组
a,使得a[n][k]包含在你抛掷一个公平硬币n次时恰好获得k个正面的概率。接受一个命令行参数来指定n的最大值。这些数字被称为二项分布:如果你将第k行中的每个元素乘以 2*n*,你将得到二项式系数((x+1)^n中*xk的系数)在帕斯卡三角形*中排列。要计算它们,从所有n开始,a[n][0] = 0.0,a[1][1] = 1.0,然后按照从左到右的顺序计算连续行中的值,a[n][k] = (a[n-1][k] + a[n-1][k-1])/2.0。Pascal's triangle Binomial distribution 1 1 1 1 1/2 1/2 1 2 1 1/4 1/2 1/4 1 3 3 1 1/8 3/8 3/8 1/8 1 4 6 4 1 1/16 1/4 3/8 1/4 1/16
1.5 输入和输出
原文:
introcs.cs.princeton.edu/python/15inout译者:飞龙
在本节中,我们扩展了我们一直在使用的简单抽象集合(命令行输入和标准输出),作为我们的 Python 程序与外部世界之间接口的一部分,包括标准输入、标准绘图和标准音频。标准输入使我们能够方便地组合处理任意数量的输入的程序,并与我们的程序进行交互;标准绘图使我们能够处理图形;标准音频添加了声音。
鸟瞰

到目前为止,我们看到的 Python 程序从命令行获取输入值,并将一串字符作为输出。默认情况下,命令行输入和程序写入的输出都与在您的计算机上运行的接受命令的应用程序相关联(也就是说,您一直在其中输入 python 命令的应用程序)。我们使用通用术语终端窗口来指代该应用程序。
到目前为止,我们看到的 Python 程序已经使用了:
命令行输入。 任何 Python 程序都可以访问由
sys.argv引用的字符串数组。该数组包含我们键入的命令行参数序列,由操���系统提供给 Python。按照惯例,Python 和操作系统都将参数作为字符串处理,因此如果我们打算将命令行参数作为数字处理,我们使用转换函数如int()或float()将其从字符串转换为适当的类型。标准输出。 为了写入输出值,我们一直在使用函数
stdio.write()和stdio.writeln()。当程序调用这些函数时,Python 将结果以一种称为标准输出的抽象字符流的形式放置。默认情况下,操作系统将标准输出连接到终端窗口。到目前为止,我们程序中的所有输出都出现在终端窗口中。
程序 randomseq.py 使用了这个模型。它接受一个整数命令行参数 n,并向标准输出写入一个介于 0 和 1 之间的 n 个随机数序列,可能包括 0。
为了完成我们的编程模型,我们添加以下内容:
标准输入。 书站
stdio.py模块定义了除了write()和writeln()之外的几个函数。这些额外的函数实现了一个标准输入抽象,以补充标准输出抽象。也就是说,stdio模块包含了允许你的程序从标准输入中读取的函数。就像一个程序可以随时写入标准输出一样,一个程序也可以随时从标准输入中读取。标准绘图。 书站
stddraw模块允许你的程序创建和绘制图形。它实现了一个简单的图形模型,允许你的程序在计算机窗口中创建和绘制点、线条和几何形状。stddraw还实现了动画功能。标准音频。 书站
stdaudio模块允许你的程序创建和播放声音。它使用标准格式将浮点数组转换为声音。
标准输出
这是与标准输出相关的 stdio.py 模块部分的 API:
stdio.writeln() 和 stdio.write() 函数是你一直在使用的。stdio.writef() 函数让你对输出的外观有更多控制,并值得一些解释。
格式化写入基础知识。
stdio.writef() 的最简单的调用只传递一个参数;该参数应为字符串。在这种情况下,stdio.writef() 简单地将字符串写入标准输出,因此等同于 stdio.write()。stdio.writef() 的更常见的调用传递两个参数。在这种情况下,第一个参数称为格式字符串。它包含一个转换说明,描述了第二个参数如何转换为输出的字符串。转换说明的形式为 %w.pc,其中:

w是字段宽度。字段宽度是应该写入的字符数。负的字段宽度表示输出应在右侧填充空格。p是精度。对于浮点数,精度是小数点后应写入的数字位数。对于字符串,精度是应写入的字符串的字符数。c是转换代码。当写入整数时,转换代码应为d,当写入浮点数时,应为f或e,当写入字符串时,应为s。
stdio.writef() 函数可以接受多于两个参数。在这种情况下,格式字符串将为每个参数都有一个格式说明符,可能由其他字符分隔以传递到输出。
格式字符串中任何不是转换说明的部分都会简单地传递到输出。例如,语句
stdio.writef('pi is approximately %.2f\n', math.pi)
写入行
pi is approximately 3.14
多个参数。
stdio.writef() 函数可以接受多于两个参数。在这种情况下,格式字符串将为每个参数都有一个转换说明,可能由其他字符分隔以传递到输出。
格式化写入的实现。
stdio.writef() 函数内部使用了 % 运算符。具体来说,形如 stdio.writef(*formatString*, *value*) 的函数调用在内部使用了形如 *formatString* % *value* 的表达式。
正如你所知,表达式 *integer* % *integer* 中的 % 运算符表示“计算余数”。表达式 *formatString* % *value* 中的 % 运算符表示根据 *formatString* 的指示将 *value* 转换为字符串。Python 字符串格式化操作的文档详细描述了 % 运算符。因此,间接地,它也详细描述了 stdio.writef() 函数。
标准输入
我们的 stdio.py 模块支持标准输入,这是一个可能为空或包含一系列由空格分隔的值的抽象数据流。每个值都是一个字符串或 Python 的原始类型之一。标准输入流的一个关键特点是,当程序读取值时,程序会消耗这些值。一旦程序读取了一个值,它就不能回退并再次读取。这是与标准输入相关的 stdio 模块的一部分:
这些函数分为三类:逐个读取单个标记并将每个标记转换为整数、浮点数、布尔值或字符串的函数;逐行从标准输入读取行的函数;以及读取相同类型值序列的函数(将值返回为数组)。通常最好不要在同一个程序中混合来自不同类别的函数。
现在我们考虑一些示例程序,演示如何使用标准输入。

输入输入。
程序 addints.py 接受一个命令行参数 n。然后它从标准输入读取 n 个数字,计算它们的总和,并将总和写入标准输出。
当您使用python命令从命令行运行 Python 程序时,实际上您正在做三件事:(i)发出命令开始执行您的程序,(ii)指定命令行参数的值,以及(iii)开始定义标准输入流。在命令行之后在终端窗口中键入的字符序列是标准输入流。当您输入字符时,您正在与您的程序交互。程序等待您创建标准输入流。
当您输入python addints.py时,在接受命令行参数后,程序调用stdio.readInt()并等待您输入一个整数。假设您希望144是第一个输入���当您输入1,然后4,然后4时,什么也不会发生,因为stdio不知道您何时完成输入整数,但当您输入<return>表示您的整数结束时,stdio.readInt()立即返回值 144。在这种方式输入四个数字后,程序不再期望更多输入,并将总和写入标准输出,如所需。
如果在stdio.readInt()期望整数时输入abc、12.2或True,那么它将返回ValueError。每种类型的格式与您在 Python 程序中使用的文字值的格式相同。stdio将连续的空白字符字符串视为一个空格,并允许您用这些字符串来分隔数字。无论您在数字之间放置多少空格,无论您是在一行上输入数字还是用制表符分隔它们或者将它们分散在几行上(除了您的终端应用程序一次处理标准输入一行,因此它将等到您输入<return>后才将该行上的所有数字发送到标准输入)。您可以在输入流中混合不同类型的值,但每次程序期望特定类型的值时,输入流中都需要有该类型的值。
交互式用户输入。
程序 twentyquestions.py 玩一个简单的猜谜游戏。您输入数字,每个数字都是一个隐式问题(这是数字吗?),程序会告诉您您的猜测是太高还是太低。
该程序说明了交互式用户输入。也就是说,它说明了程序可以交替写入标准输出和从标准输入读取,从而在程序执行期间与用户交互。
处理任意大小的输入流
程序 average.py 从标准输入读取一系列浮点数,并将它们的平均值写入标准输出。
通常,输入流是有限的:您的程序遍历输入流,消耗值直到流为空。但是输入流的大小没有限制。该程序说明了使用输入流的一个关键特性:程序不知道流的长度。我们输入所有的数字,然后程序对它们求平均值。在读取每个数字之前,程序调用stdio.isEmpty()来检查标准输入中是否还有更多数字。
当我们没有更多数据可输入时,如何发出信号?按照惯例,我们输入一系列特殊字符,称为文件结尾序列。在 OS X 和 Linux 上是<ctrl-d>,在 Windows 上是<ctrl-z>。在某些系统上,文件结尾序列必须单独出现在一行上。输入文件结尾序列表示标准输入为空。
重定向和管道
对于许多应用程序来说,从终端窗口将输入数据作为标准输入流输入是不可行的,因为这样做会限制我们程序的处理能力,限制了我们可以输入的数据量。同样,我们经常希望保存标准输出流上打印的信息以供以后使用。我们可以使用操作系统机制来解决这两个问题。
将标准输出重定向到文件。
通过向调用程序的命令添加一个简单的指令,我们可以重定向其标准输出到文件,以便永久存储或在以后的某个时间输入到其他程序中。例如,命令:
指定了标准输出流不写入终端窗口,而是写入名为data.txt的文本文件。每次调用stdio.write()或stdio.writeln()都会将文本追加到该文件的末尾。在这个例子中,最终结果是一个包含 1,000 个随机值的文件。终端窗口中不会显示任何输出:它直接进入以>符号命名的文件中。因此,我们可以保存信息以供以后检索。
从文件重定向标准输入。
同样地,我们可以重定向标准输入,使程序从文件而不是终端应用程序读取数据。例如,命令:
从文件data.txt读取一系列数字,计算它们的平均值,并将平均值写入标准输出。具体来说,<符号是一个指令,通过从文件data.txt而不是等待用户在终端窗口中键入来实现标准输入流。当程序调用stdio.readFloat()时,操作系统从文件中读取值。这种从文件重定向标准输入的功能使我们能够处理来自任何来源的大量数据,仅受我们可以存储的文件大小限制。
连接两个程序。
实现标准输入和标准输出抽象的最灵活方式是指定它们由我们自己的程序实现!这种机制称为piping。例如,以下命令:
指定了随机序列.py 的标准输出流和平均值.py 的标准输入流是相同的流。也就是说,结果与以下命令序列具有相同效果:
% python randomseq.py 1000 > data.txt % python average.py < data.txt
但文件data.txt是不需要的。
过滤器。
对于许多常见任务,将每个程序视为以某种方式将标准输入流转换为标准输出流的过滤器,并使用管道作为连接程序的命令机制是方便的。例如,范围过滤器.py 接受两个命令行参数,并将标准输入中落在指定范围内的数字写入标准输出。
一些为 Unix 设计的标准过滤器仍然存在(有时使用不同的名称)作为现代操作系统中的命令。例如,sort过滤器从标准输入读取行并按排序顺序写入标准输出:
% python randomseq.py 9 | sort
0.0472650078535
0.0681950168757
0.0967410236589
0.0974385525393
0.118855769243
0.46604926859
0.522853708616
0.599692836211
0.685576779833
另一个有用的过滤器是more,它从标准输入读取数据,并在您的终端窗口中一次显示一个屏幕。例如,如果您键入
% python randomseq.py 1000 | more
您将在终端窗口中看到尽可能多的数字,但更多的数字将等待您按空格键,然后显示每个后续屏幕。
标准绘图
现在我们介绍一个用于生成绘图输出的抽象。我们想象一个抽象的绘图设备,能够在二维“画布”上绘制线条和点,然后在标准绘图窗口中显示该画布在您的屏幕上。该设备能够响应我们程序发出的命令,形式为对stddraw模块中函数的调用。该模块的 API 由两种函数组成:绘图函数会导致设备执行动作(如绘制线条或绘制点),控制函数控制绘图的显示方式并设置参数,如笔的大小或坐标比例。
创建绘图。
绘图的基本函数在此 API 中描述:
绘图函数几乎是自解释的:stddraw.line() 以给定参数作为坐标绘制连接两点的直线段,stddraw.point() 在给定坐标处绘制一个以该坐标为中心的点。默认坐标比例是单位正方形(所有坐标在 0 到 1 之间)。点 (0.0, 0.0) 在左下角,点 (1.0, 1.0) 在右上角 — 因此对应于熟悉的笛卡尔坐标系的第一象限。默认设置在白色背景上绘制黑色线条和黑色点。
控制函数 stddraw.show() 需要更多解释。当您的程序调用任何绘图函数,如 stddraw.line() 或 stddraw.point() 时,stddraw 使用一种称为背景画布的抽象。背景画布不会显示;它只存在于计算机内存中。所有点、线条等都是在背景画布上绘制的,而不是直接在标准绘图窗口中。只有当您调用 stddraw.show() 时,您的绘图才会从背景画布复制到标准绘图窗口中,在那里显示,直到用户关闭标准绘图窗口 — 通常通过单击窗口标题栏中的 关闭 按钮。
为什么 stddraw 需要使用背景画布?主要原因是使用两个画布而不是一个使 stddraw 模块更有效率。在许多计算机系统上,逐步显示正在创建的复杂图形可能效率低下。在计算机图形中,这种技术称为双缓冲。
要总结您需要了解的信息,使用 stddraw 模块的典型程序具有以下结构:
导入
stddraw模块。调用诸如
stddraw.line()和stddraw.point()等绘图函数在背景画布上创建绘图。调用
stddraw.show()来显示标准绘图窗口中的背景画布,并等待窗口关闭。
你的第一个绘图。
使用 stddraw 进行图形编程的“Hello, World”等价物是绘制一个带有内部点的三角形。为了形成三角形,我们绘制三条线。程序 triangle.py 是完整的程序。
保存绘图。
您可以将标准绘图窗口画布保存到文件中。要这样做,请在窗口画布的任何位置右键单击。这样做后,stddraw 将显示一个文件对话框,允许您指定文件名。然后,在对话框中输入文件名并单击 保存 按钮后,stddraw 将窗口画布保存到指定名称的文件中。文件名必须以 .jpg 结尾(以 JPEG 格式保存窗口画布)或 .png 结尾(以“便携式网络图形”格式保存窗口画布)。本章中显示的图形程序生成的图形是使用此机制保存到文件中的。
控制命令。
标准绘图的默认坐标系是单位正方形,但我们经常希望以不同比例绘制图形。此外,我们经常希望绘制不同粗细的线条和不同大小的点。为了满足这些需求,stddraw 提供了以下函数:
例如,当您调用函数 stddraw.setXscale(0, n) 时,您告诉绘图设备您将使用 0 到 n 之间的 x 坐标。请注意,两次调用序列
stddraw.setXscale(x0, x1)
stddraw.setYscale(y0, y1)
将绘图坐标设置为一个边界框,其左下角在 (x0, y0) 处,右上角在 (x1, y1) 处。
将数据过滤到标准绘图。
程序 plotfilter.py 读取由(x, y)坐标定义的一系列点,并在每个点处绘制一个点。它采用的约定是从标准输入读取的前四个数字指定了边界框,以便它可以缩放绘图。尝试将其标准输入重定向到 usa.txt 运行。
绘制函数图。
程序 functiongraph.py 在区间(0, π)中绘制函数y = sin(4x) + sin(20x)。在区间中有无限多个点,所以我们必须通过在区间内的有限数量的点评估函数来处理。我们通过选择一组x值来对函数进行采样,然后通过在每个x值处评估函数来计算y值。通过连接连续点以线条绘制函数产生了所谓的分段线性逼近。
轮廓和填充形状。
stddraw模块还包括用于绘制圆、矩形和任意多边形的函数。每个形状定义一个轮廓。当函数名只是形状名时,轮廓由绘图笔描绘。当名称以filled开头时,命名的形状实际上是填充的实心形状,而不是描绘的。通常情况下,我们在 API 中总结可用的函数:
stddraw.circle()的参数定义了以(x, y)为中心的半径为r的圆;stddraw.square()的参数定义了以(x, y)为中心的边长为 2r的正方形;stddraw.polygon()的参数定义了我们通过线连接的一系列点,包括从最后一个点到第一个点的线。
文本和颜色。
为了注释或突出显示绘图中的各种元素,stddraw包括用于绘制文本、设置字体和设置笔墨水的方法。
在这段代码中,颜色和字体使用的类型将在第 3.1 节中学习。在那之前,我们将细节留给stddraw。可用的笔颜色是BLACK、BLUE、CYAN、DARK_GRAY、GRAY、GREEN、LIGHT_GRAY、MAGENTA、ORANGE、PINK、RED、WHITE和YELLOW,这些都是在stddraw中定义的常量。例如,调用stddraw.setPenColor(stddraw.GRAY)会更改为灰色墨水。默认墨水颜色是黑色;默认字体是 12 点普通 Helvetica 字体。
这些代码片段展示了一些用于绘制形状和文本的 stddraw 函数:
动画
如果我们为stddraw.show()提供参数,则该调用不需要是程序的最后一个动作:它将将背景画布复制到标准绘图窗口,然后等待指定的毫秒数。很快你会看到,我们可以利用这种能力(结合擦除或清除背景画布的能力)在stddraw窗口中产生运动效果。
动画的“Hello, World”程序是在画布上产生一个黑色球,看起来在画布上移动。假设球在位置(r[x],r[y]),我们想要给人一种将其移动到附近新位置的印象,例如,例如,(r[x] + 0.01, r[y] + 0.02)。我们分三步进行:
清除背景画布。
在新位置绘制一个黑色球。
显示绘图并等待片刻。
为了营造运动的错觉,我们对一整个位置序列(在这种情况下将形成一条直线)进行迭代这些步骤。stddraw.show()的参数量化了“短暂的时间”,控制了视觉速度。
程序 bouncingball.py 实现了这些步骤,以创建一个球在以原点为中心的 2x2 盒子中移动的幻觉。球的当前位置是(r[x] , r[y]),我们通过在每一步中将v[x]加到r[x]和v[y]加到r[y]来计算新位置。由于(v[x] , v[y])是球在每个时间单位移动的固定距离,它代表速度。为了保持球在绘图中,我们模拟球按照弹性碰撞定律弹跳到墙壁的效果。这个效果很容易实现:当球撞到垂直墙壁时,我们只需将x方向上的速度从v[x]改变为-v[x],当球撞到水平墙壁时,我们将y方向上的速度从v[y]改变为-v[y]。下面的图像显示了球的轨迹,这些图像是由这段代码的修改版本生成的(请参见本节末尾的一个练习)。
标准音频
stdaudio 模块可以播放、操作和合成声音。它允许你播放.wav文件,编写程序来创建和操作浮点数组,并将它们读取和写入为.wav 文件:
我们首先介绍计算机科学和科学计算中最古老和最重要领域之一的一些基本概念,这个领域被称为数字信号处理。
协奏 A。
声音是分子振动的感知,特别是我们耳膜的振动。因此,振荡是理解声音的关键。也许最简单的起点是考虑中央 C 上方的音符 A,也称为协奏 A。这个音符只不过是一个正弦波,按照每秒振荡 440 次的频率进行缩放。函数 sin(t)每 2π单位重复一次,因此如果我们以秒为单位测量t并绘制函数 sin(2πt × 440),我们得到一个每秒振荡 440 次的曲线。我们用hertz(每秒循环次数)来衡量频率。当你将频率加倍或减半时,你在音阶上向上或向下移动一个八度。例如,880 赫兹是协奏 A 的一个八度,110 赫兹是协奏 A 的两个八度下方。作为参考,人类听觉的频率范围约为 20 至 20,000 赫兹。声音的振幅(y-值)对应于音量。我们假设它被缩放在-1 到+1 之间。
其他注意事项。
一个简单的数学公式描述了半音音阶上的其他音符。半音音阶上有 12 个音符,均匀分布在对数(以 2 为底)刻度上。我们通过将给定音符的频率乘以 2 的(i/12)次方来得到半音音阶上的第i个音符。换句话说,半音音阶上每个音符的频率恰好是音阶上前一个音符的频率乘以 2 的十二次方根(约为 1.06)。这些信息足以创作音乐!例如,要演奏曲调Frere Jacques,我们只需要通过产生适当频率的正弦波来演奏 A B C# A 的每个音符约半秒钟,然后重复这个模式。
采样。
对于数字音频,我们通过在规则间隔上对其进行采样来表示曲线,这与绘制函数图形时的方式完全相同。我们采样得足够频繁,以便准确表示曲线 — 数字音频的常用采样率为每秒 44,100 个样本。对于升降调 A,该速率对应于在大约 100 个点上对正弦波的每个周期进行采样。由于我们定期采样,我们只需要计算采样点的 y 坐标。就是这么简单:我们将声音表示为一组数字(浮点值,介于 -1 和 +1 之间)。我们的 booksite 声音模块函数stdaudio.playSamples()以浮点数组作为其参数,并在您的计算机上播放由该数组表示的声音。
例如,假设你想要播放升降调 A 音符 10 秒钟。以每秒 44,100 个样本的速度,你需要一个包含 441,001 个浮点值的数组。为了填充数组,使用一个for循环,在其中对函数 sin(2πt × 440)在 t = 0/44100, 1/44100, 2/44100, 3/44100, ..., 441000 / 44100 进行采样。一旦我们用这些值填充了数组,我们就可以准备好使用stdaudio.playSamples(),就像下面的代码一样:
import math
import stdaudio
import stdarray
SPS = 44100 # samples per second
hz = 440.0 # concert A
duration = 10.0 # ten seconds
n = int(SPS * duration)
a = stdarray.create1D(n+1)
for i in range(n+1):
a[i] = math.sin(2.0 * math.pi * i * hz / SPS)
stdaudio.playSamples(a)
stdaudio.wait()
这段代码是数字音频的“Hello, World”。一旦你用它让你的计算机播放这个音符,你就可以编写代码来播放其他音符并制作音乐!
保存到文件。
音乐可能会占用计算机上的大量空间。以每秒 44,100 个样本的速度,一首四分钟的歌曲对应于 4 × 60 × 44100 = 10,584,000 个数字。因此,通常会使用比我们用于标准输入和输出的数字串表示法占用更少空间的二进制格式来表示与歌曲对应的数字。近年来已经开发了许多这样的格式 — stdaudio使用.wav格式。
播放音乐。
程序 playthattune.py 是一个示例,展示了我们如何使用stdaudio轻松创建音乐。它从标准输入中获取音符,以升降调音阶为索引,并在标准音频上播放它们。尝试将其标准输入重定向到以下这些数据文件中的每一个(由不同学生创建)来重复运行它:elise.txt, ascale.txt, stairwaytoheaven.txt, entertainer.txt, firstcut.txt, freebird.txt, 和 looney.txt。
Q & A
Q. 如何使 Python 中的 booksite 模块 stdio、stddraw 和 stdaudio 可用?
A. 如果你按照这个 booksite 上的逐步说明安装 Python,这些模块应该已经可以在 Python 中使用了。
Q. 是否有用于处理标准输出的标准 Python 模块?
A. 实际上,这些功能已经内置在 Python 中。在 Python 2 中,你可以使用print语句将数据写入标准输出。在 Python 3 中,没有print语句;取而代之的是类似的print()函数。
Q. 那么,为什么我们在写入标准输出时使用 booksite 的 stdio 模块,而不是使用 Python 已经提供的功能呢?
A. 我们的意图是编写尽可能与所有 Python 版本兼容的代码。例如,在所有我们的程序中使用print语句将意味着它们将与 Python 2 兼容,但与 Python 3 不兼容。由于我们使用stdio函数,我们只需要确保我们有正确的库。
Q. 关于标准输入呢?
A. 在 Python 2 和 Python 3 中有与stdio.readLine()对应的功能,但没有与stdio.readInt()和类似函数对应的功能。再次强调,通过使用stdio,我们可以编写不仅利用这些额外功能的���序,而且在 Python 的所有版本中都能正常工作。
Q. 关于绘图和声音呢?
A. Python 没有附带audio库。Python 附带一个名为Tkinter的图形库用于生成绘图,但对于本书中的一些图形应用来说速度太慢了。我们的stddraw和stdaudio模块提供了易于使用的 API,基于Pygame库。
Q. 所以,让我弄清楚一点;如果我使用格式%2.4f与stdio.writef()一起写一个浮点数,我得到小数点前两位和小数点后四位数字,对吗?
A. 不,这只指定小数点后的四位数字。小数点前面的数字是整个字段的宽度。你需要使用格式%7.2f来指定总共七个字符 — 小数点前四位,小数点本身,以及小数点后两位数字。
Q. stdio.writef()还有哪些其他转换代码?
A. 对于整数值,有o表示八进制,x表示十六进制;对于浮点数,你可以使用e或g来获得科学计数法。还有许多日期和时间的格式。Python 字符串格式化操作的文档提供了丰富的信息。
Q. 我的程序可以从标准输入重新读取数据吗?
A. 不。你只有一次机会,就像你不能撤消stdio.writeln()的调用一样。
Q. 如果我的程序在耗尽后尝试从标准输入读取数据会发生什么?
A. Python 会在运行时引发EOFError。函数stdio.isEmpty()和stdio.hasNextLine()允许你通过检查是否还有更多输入来避免这样的错误。
Q. 为什么stddraw.square(x, y, r)画出的正方形宽度是 2r 而不是r?
A. 这使得它与函数stddraw.circle(x, y, r)保持一致,其中第三个参数是圆的半径,而不是直径。在这个上下文中,r是可以容纳在正方形内的最大圆的半径。
Q. 如果我的程序调用stddraw.show(0)会发生什么?
A. 这个函数调用告诉 stddraw 将背景画布复制到标准绘图窗口,然后等待 0 毫秒(也就是根本不等待)才继续。如果,例如,你想以计算机支持的最快速度运行动画,这个函数调用是合适的。
Q. 我可以用stddraw画除了圆以外的曲线吗?
A. 我们不得不在某个地方划定界限(双关语),所以我们只支持文本中讨论的基本形状。你可以一次一个点地绘制其他形状,就像文本中的几个练习中探讨的那样,但不直接支持填充它们。
Q. 那么在为 playthattune.py 制作输入文件时,我使用负整数来低于 A 音调吗?
A. 是的。实际上,我们选择将 A 音调放在 0 的位置是任意的。一种流行的标准,称为MIDI 调音标准,从 A 音调下方五个八度的 C 开始编号。按照这个约定,A 音调是 69,你不需要使用负数。
Q. 当我尝试用频率为 30,000 赫兹(或更高)的正弦波进行声音化时,为什么我从标准音频听到奇怪的结果?
A. 奈奎斯特频率,定义为采样频率的一半,代表可以重现的最高频率。对于标准音频,采样频率为 44,100,因此奈奎斯特频率为 22,050。
Q. 如果我正在从文件重定向标准输入,我如何输入文件结束序列?
A. 当标准输入绑定到你的终端应用程序时,最终你必须输入文件结束序列来通知程序标准输入没有更多数据可读取了。然而,当标准输入绑定到文件时,你不需要输入文件结束序列。相反,操作系统会在没有更多数据可从文件中读取时自动通知你的程序。
Q. stdio.writef()还有哪些其他转换代码?
Q. 如何在stdio.writef()中打印%字符?
A. 使用%%。
Q. 什么是表示一行结束的符号?
A. 不同的操作系统使用不同的符号。在 Unix 系统和 Mac OS X 上,换行符是'\n'。在 Windows 上,每行由两个字符的字符串'\r\n'终止。在 OS X 之前的 Mac 上,每行由字符串'\n\r'终止。在编写程序时,应避免使用特定于操作系统的功能,否则可能在其他系统上无法正常工作。使用stdio.writeln()来写入换行符。
Q. 如何为stddraw模块创建颜色?
A. stddraw 模块使用了一个我们专门为本书站点定义的名为Color的类。本站点的第三章描述了该类。
Q. PNG、JPEG 和 PostScript 图形格式的主要区别是什么?
A. 大多数网页上的图形都是以 PNG、GIF 或 JPEG 格式呈现的。这三种格式都是基于栅格的 —— 它们存储了表示图片所需的像素集和颜色渐变。PNG 和 GIF 适合显示带有直线和几何图形的图形,而 JPEG 最适合于照片。PostScript 是一种基于矢量的格式。例如,它将圆表示为几何对象,而不是成千上万个像素的集合。如果你放大或缩小它,质量不会降低。因此,大多数打印机使用 PostScript 来打印文档和图形。
Q. 错误消息NameError: name 'stdio' is not defined是什么意思?
A. 你可能忘记安装本站点模块了。当然,stdarray.py、stddraw.py和stdaudio.py也是一样。
Q. 如何创建一个动画 GIF?
练习
编写一个程序,从标准输入读取整数(用户输入的数量不定),并将最大值和最小值写入标准输出。
解决方案: 请参阅 maxmin.py。
修改上一个练习中的程序,要求整数必须是正数(提示用户输入正整数,如果输入的值不是正数)。
编写一个程序,从命令行接受一个整数n,从标准输入读取n个���点数,并写出它们的平均值和标准差(平均值的平方根,除以n的差值平方和)。
解决方案: 请参阅 stats2.py。
扩展上一个练习中的程序,创建一个过滤器,写出所有偏离平均值 1.5 个标准差以上的值。
编写一个程序,读取一系列整数,并写出出现在最长连续序列中的整数以及序列的长度。例如,如果输入是
1 2 2 1 5 1 1 7 7 7 7 1 1,则你的程序应该写出最长序列:4 个连续的 7。解决方案: 请参阅 longestrun.py。
编写一个过滤器,读取一系列整数,并写出这些整数,去除连续出现的重复值。例如,如果输入是
1 2 2 1 5 1 1 7 7 7 7 1 1 1 1 1 1 1 1 1,你的程序应该写出1 2 1 5 1 7 1。编写一个程序,接受一个命令行参数
n,从标准输入读取介于 1 和n之间的n-1个不同整数,并确定缺失的值。编写一个程序,从标准输入读取正实数,并写出它们的几何平均数和调和平均数。n个正数x[1]、x[2]、...、x[n]的几何平均数是(x[1] × x[2] × ... × x[n])^(1/n)。调和平均数是n / (1/x[1] + 1/x[2] + ... + 1/x[n])。提示:对于几何平均数,考虑取对数以避免溢出。
假设文件
input.txt包含两个字符串 F 和 F。以下命令做什么?更多关于龙曲线的信息,请参见第 1.2 节的练习。这是 Python 程序 dragon3.py。python dragon3.py < input.txt | python dragon3.py | python dragon3.py编写一个名为
tenperline.py的过滤器,该过滤器读取介于 0 和 99 之间的整数序列,并每行写入 10 个整数,列对齐。然后编写一个名为randomintseq.py的程序,该程序接受两个命令行参数m和n,并写入 0 到m-1 之间的n个随机整数。使用命令python randomintseq 100 200 | python tenperline.py测试您的程序。编写一个名为
wordcount.py的程序,从标准输入读取文本,并将文本中的单词数写入标准输出。对于本练习,单词是由空格包围的一系列非空白字符。例如,命令python wordcount < tale.txt应该写入 139043。解决方案:参见 wordcount.py。
编写一个程序,从标准输入读取每行包含一个名称和两个整数的行,然后调用
stdio.writef()将表格写入标准输出,其中包含名称的列、整数和将第一个整数除以第二个整数的结果,精确到三位小数。您可以使用这样的程序制表棒球运动员的击球率或学生的成绩。以下哪些需要保存所有来自标准输入的值(例如保存在数组中),哪些可以仅使用固定数量的变量实现为过滤器?对于每个输入,来自标准输入的是 0 到 1 之间的n个浮点数。
写出最大和最小的数字。
写出第k小的值。
写出数字的平方和。
写出数字的平均值。
写出大于平均值的数字的百分比。
按升序写出数字。
按随机顺序写出数字。
编写一个程序,为贷款编写一个每月付款、剩余本金和支付利息的表格,接受三个数字作为命令行参数:贷款年限、本金和利率。(请参见第 1.2 节中的相关练习。)
编写一个程序,接受三个命令行参数x、y和z,从标准输入读取一系列点坐标(x[i], y[i], z[i]),并写出距离(x, y, z)最近的点的坐标。请记住,(x , y , z)和(x[i] , y[i] , z[i] )之间的距离的平方是(x - x[i])² + (y - y[i])² + (z - z[i])2。为了效率,不要使用
math.sqrt()或**运算符。解决方案:参见 closest.py。
编写一个程序,给定一系列对象的位置和质量,计算它们的质心,或质心。质心是按质量加权的n个对象的平均位置。如果位置和质量由(x[i], y[i], m[i])给出,则质心(x, y, m)由以下公式给出
m = m[1] + m[2] + ... + m[n] x = (m[1]x[1] + ... + m[n]x[n]) / m y = (m[1]y[1] + ... + m[n]y[n]) / m
编写一个程序,读取-1 到 1 之间的一系列浮点数,并写出它们的平均幅度、平均功率和零交叉数。平均幅度是数据值绝对值的平均值。平均功率是数据值的平方的平均值。零交叉数是数据值从严格负数转变为严格正数,或反之的次数。这三个统计量被广泛用于分析数字信号。
编写一个程序,接受一个整数命令行参数
n,并绘制一个由红色和黑色方块组成的n乘n棋盘。将左下角的方块涂成红色。![5x5 棋盘]()
![8x8 棋盘]()
![25x25 棋盘]()
解决方案:参见 checkerboard.py
编写一个程序,从命令行参数中接受一个整数
n和一个介于 0 和 1 之间的浮点数p,在圆周上绘制n个等间距点,然后,对于每对点,以概率p,绘制连接它们的灰色线。![erdos]()
编写代码来绘制红心、黑桃、梅花和方块。要绘制一个红心,先绘制一个方块,然后将两个半圆连接到左上角和右上角。
编写一个程序,接受一个整数命令行参数
n,并绘制一个具有n个花瓣(如果n为奇数)或2n个花瓣(如果n为偶数)的“花朵”,通过绘制极坐标(r, θ)函数r = sin(n × θ),其中θ范围从 0 到2π弧度。下面是n为 4、7 和 8 时的期望输出。![玫瑰]()
解决方案:参见 rose.py。
编写一个程序,从命令行接受一个字符串
s,并以横幅样式在屏幕上显示它,从左向右移动,并在达到末尾时回到字符串的开头。添加第二个命令行参数以控制速度。解决方案:参见 banner.py。
修改 playthattune.py 以接受额外的命令行参数,控制音量(将每个采样值乘以音量)和节奏(将每个音符的持续时间乘以节奏)。
编写一个程序,接受一个
.wav文件的名称和一个播放速率r作为命令行参数,并以给定速率播放文件。首先,使用stdaudio.read()将文件读入数组a[]。如果r = 1,只需播放a[];否则创建一个大约大小为r倍len(a)的新数组b[]。如果r < 1,通过从原始数组中进行采样来填充b[];如果r > 1,通过插值从原始数组填充b[]。然后播放b[]。编写使用
stddraw创建这些设计的程序。![几何设计]()
编写一个程序,在单位正方形中的随机位置绘制随机大小的填充圆,生成类似下面的图像。您的程序应该接受四个命令行参数:圆的数量、每个圆为黑色的概率、最小半径和最大半径。
![随机圆]()
创意练习
可视化音频。 修改 playthattune.py 以将播放的值发送到标准绘图,这样您就可以观看播放时的声波。您将不得不尝试在绘图画布中绘制多条曲线,以同步声音和图片。
统计调查。 在为某些政治民意调查收集统计数据时,非常重要的是获得一个无偏的注册选民样本。假设您有一个文件,其中包含n个注册选民,每行一个。编写一个过滤器,写入大小为m的随机样本。(参见第 1.4 节的 sample.py 程序。)
地形分析。 假设地形由二维高程值网格表示(以米为单位)。山峰是一个网格点,其四个相邻单元格(左、右、上、下)的高程值严格较低。编写一个程序,从标准输入读取地形,然后计算并写入地形中山峰的数量。
直方图。 假设标准输入流是一系列浮点数。编写一个程序,从命令行接受一个整数
n和两个浮点数lo和hi,并使用stddraw绘制一个直方图,显示标准输入流中落入(lo,hi)中的每个n个间隔中的数字的计数。螺线图。 编写一个程序,接受三个命令行参数R、r和a,并绘制结果的螺线图。一个螺线图(技术上,是一个外摆线)是通过围绕半径为r的圆在一个固定半径为R的大圆周围滚动形成的曲线。如果笔偏离滚动圆心(r+a),那么在时间t时得到的曲线方程为
*x*(*t*) = (*R*+*r*)cos(*t*) - (*r*+*a*)cos((*R*+*r*)*t*/*r*) *y*(*t*) = (*R*+*r*)sin(*t*) - (*r*+*a*)sin((*R*+*r*)*t*/*r*)这些曲线是由一种畅销玩具推广的,该玩具包含具有齿轮齿的圆盘和小孔,您可以在其中放入笔来追踪螺线图。
解决方案:参见 spirograph.py。
时钟。 编写一个程序,显示模拟时钟的秒、分和时针的动画。使用调用
stddraw.show(1000)来大约每秒更新一次显示。提示:这可能是您想要使用浮点数的
%运算符的罕见时刻之一;它的工作方式与您期望的一样。解决方案:参见 clock.py。
示波器。 编写一个程序来模拟示波器的输出并产生利萨如图案。这些图案以法国物理学家朱尔斯·A·利萨如的名字命名,他研究了当两个相互垂直的周期性干扰同时发生时产生的图案。假设输入是正弦的,因此以下参数方程描述曲线:
*x*(*t*) = *A[x]* sin (*w[x]t* + θ*[x]*) *y*(*t*) = *A[y]* sin (*w[y]t* + θ*[y]*)从命令行获取六个参数A和A[y](振幅);w和w[y](角速度);以及θx和θy(相位因子)。
例如,下面的第一幅图像具有A = A[y] = 1,w = 2,w[y] = 3,θ** = 20 度,θ[y] = 45 度。另一个具有参数(1, 1, 5, 3, 30, 45)
![示波器 2]()
![示波器 3]()
解决方案:参见 oscilloscope.py。
带轨道的弹跳球。修改 bouncingball.py 以生成像本页早期显示的那样的图像,显示球在灰色背景上的轨迹。
带重力的弹跳球。 修改 bouncingball.py 以在垂直方向上加入重力。添加调用
stdaudio.playFile()来在球撞到墙壁时添加一个声音效果,撞到地板时添加另一个声音效果。随机旋律。 编写一个程序,使用
stdaudio播放随机旋律。尝试保持在调内,给整音高概率,重复,以及其他规则来产生合理的旋律。瓷砖图案。 在之前的练习中,您编写了创建类似瓷砖的设计的程序。使用您解决该练习的解决方案,编写一个名为
tilepattern.py的程序,它接受一个命令行参数n,并绘制一个n乘n的图案,使用您选择的瓷砖。添加第二个命令行参数以添加棋盘选项。添加第三个命令行参数以选择颜色。使用下面的图案作为起点,设计一个瓷砖地板。发挥创意!![瓷砖]()
注意:这些都是古代的设计,您可以在许多古代(和现代)建筑中找到,例如罗马的圣若望大殿(圣约翰大殿) [1 2 3 4 5 6 ]或里斯本的瓷砖博物馆 [1 2 3 4 5 6 7 8 9 10 ]
2. 函数和模块
原文:
introcs.cs.princeton.edu/python/20functions译者:飞龙
在本章中,我们考虑了一个对控制流程具有深远影响的概念,就像条件语句和循环一样重要:函数,它允许我们在不同代码段之间传递控制。函数很重要,因为它们允许我们在程序中清晰地分离任务,并且因为它们提供了一个通用机制,使我们能够重用代码。
2.1 定义函数 描述了如何在 Python 中创建自己的函数。
2.2 模块和客户端 描述了如何将相关函数分组到模块中以实现模块化编程。
2.3 递归 考虑了一个函数调用自身的概念。这种可能性被称为递归。
2.4 案例研究:渗透 提供了一个案例研究,使用蒙特卡洛模拟来研究一种名为渗透的自然模型。
本章中的 Python 程序
以下是本章中使用的 Python 程序和数据文件列表。
参考 程序 描述 数据 2.1.1 harmonicf.py 调和数(重新讨论) – 2.1.2 gauss.py 高斯函数 – 2.1.3 coupon.py 优惠券收集器(重新讨论) – 2.1.4 playthattunedeluxe.py 演奏那首曲子(重新讨论) elise.txt ascale.txt stairwaytoheaven.txt entertainer.txt firstcut.txt freebird.txt looney.txt 2.2.1 gaussian.py 高斯函数模块 – 2.2.2 gaussiantable.py 示例高斯客户端 – 2.2.3 sierpinski.py 谢尔宾斯基三角形 – 2.2.4 ifs.py 迭代函数系统 sierpinski.txt barnsley.txt coral.txt culcita.txt cyclosorus.txt dragon.txt fishbone.txt floor.txt koch.txt spiral.txt swirl.txt tree.txt zigzag.txt 2.2.5 bernoulli.py 伯努利试验 – 2.3.1 euclid.py 欧几里德算法 – 2.3.2 towersofhanoi.py 汉诺塔 – 2.3.3 beckett.py 格雷码 – 2.3.4 htree.py 递归图形 – 2.3.5 brownian.py 布朗桥 – 2.4.1 percolationv.py 垂直渗流检测 test5.txt test8.txt 2.4.2 percolationio.py 渗流支持函数 – 2.4.3 visualizev.py 垂直渗流可视化客户端 – 2.4.4 estimatev.py 垂直渗流概率估计 – 2.4.5 percolation.py 渗流检测 test5.txt test8.txt 2.4.6 visualize.py 渗流可视化客户端 – 2.4.7 estimate.py 渗流概率估计 –
2.1 定义函数
原文:
introcs.cs.princeton.edu/python/21function译者:飞龙
从本书站点的开始,您一直在编写调用 Python 函数的代码。在本节中,您将学习如何定义和调用自己的函数。函数支持一个关键概念,从现在开始将贯穿您的编程方法:每当您可以清晰地将计算中的任务分开时,您都应该这样做。
使用和定义函数
调用 Python 函数的效果很容易理解。例如,当您在程序中放置math.sqrt(a-b)时,效果就好像您用 Python 的math.sqrt()函数传递表达式a-b作为参数时替换了该代码的返回值。如果您考虑系统为实现此效果所必须做的工作,您将看到它涉及更改程序的控制流。
您可以使用def语句定义函数,该语句指定函数签名,后跟构成函数的一系列语句。例如,harmonicf.py 定义了一个名为harmonic()的函数,该函数接受一个参数n并计算第n个调和数(如第 1.3 节中所述)。它还说明了 Python 程序的典型结构,包括三个组件:
一系列
import语句一系列函数定义
任意全局代码,或程序的主体
当我们在命令行上键入python harmonicf.py调用程序时,Python 执行全局代码;该全局代码调用了先前定义的harmonic()函数。(为了说明,我们使程序的用户交互部分比第 1.3 节中更复杂。���

控制流程。
右侧显示的图表说明了命令python harmonicf.py 1 2 3的控制流程。首先,Python 处理import语句,从而使程序中定义的sys和stdio模块中的所有功能可用。接下来,Python 处理了第 4 到 8 行中harmonic()函数的定义,但不执行该函数 — Python 只有在调用函数时才执行函数。然后,Python 执行函数定义后全局代码中的第一条语句,即for语句,直到 Python 开始执行语句value = harmonic(arg),从arg为 1 开始正常进行。为此,它将控制传递给harmonic()函数 — 控制流程传递到函数定义中的代码。Python 将“参数”变量n初始化为 1,将“局部”变量total初始化为 0.0,然后执行harmonic()中的for循环,该循环在一次迭代后终止,总和等于 1.0。然后,Python 执行harmonic()定义末尾的return语句,导致控制流跳回到调用语句value = harmonic(arg),从离开的地方继续,但现在表达式harmonic(arg)被替换为 1.0。因此,Python 将 1.0 赋给value并将其写入标准输出。然后,Python 再次迭代循环,并第二次调用harmonic()函数,其中n初始化为 2,结果为 1.5。然后,该过程再次重复,arg(然后n)等于 4,结果为 2.083333333333333。最后,for循环终止,整个过程完成。正如图表所示,简单的代码掩盖了相当复杂的控制流程。
非正式函数调用/返回跟踪。
通过函数调用跟踪控制流的一个简单方法是想象每个函数在被调用时写下其名称和参数,以及在返回之前写下其返回值,调用时增加缩进,返回时减少缩进。这个结果通过写入变量的值来增强程序的追踪过程,我们从第 1.2 节开始一直在使用。我们的示例的非正式追踪如右侧所示。
基本术语。
数学函数的概念中包含了几个概念,对应于每个概念在 Python 中都有相应的构造,如下表所总结的:
当我们在定义数学函数的公式中使用符号名称(例如f(x) = 1 + x + x²)时,符号x是某个输入值的占位符,将被替换到公式中以确定输出值。在 Python 中,我们使用参数变量作为符号占位符,并将要评估函数的特定输入值称为参数。

函数定义。
函数定义的第一行,也称为签名,为函数和每个参数变量赋予名称。签名由关键字def、函数名称、由逗号分隔并括在括号中的零个或多个参数变量名称序列以及冒号组成。缩进的语句跟在签名后面定义函数体。函数体可以包含我们在第一章讨论过的各种语句。它还可以包含一个返回语句,将控制转移到调用函数的地方,并返回计算结果或返回值。函数体还可以定义局部变量,这些变量仅在定义它们的函数内部可用。
函数调用。
正如我们一直看到的,Python 函数调用只是函数名称后跟其参数,用逗号分隔并括在括号中。每个参数可以是一个表达式,它被评估并将结果值作为输入传递给函数。当函数完成时,返回值取代函数调用的位置,就像它是一个变量的值(可能在表达式中)。
多个参数。
像数学函数一样,Python 函数可以有多个参数变量,因此可以用多个参数调用它。
多个函数。
您可以在一个.py 文件中定义任意数量的函数。这些函数是独立的,除非它们通过调用相互引用。但是,函数的定义必须出现在调用它的任何全局代码之前。这就是典型的 Python 程序包含(1)import语句,(2)函数定义和(3)任意全局代码的原因,按照这个顺序。
多个返回语句。
您可以在函数中放置return语句,无论何时需要它们:一旦到达第一个return语句,控制就会返回到调用程序。
单个返回值。
Python 函数仅向调用者提供一个返回值(或更准确地说,它返回一个对象的引用)。这一政策并不像看起来那么严格,因为 Python 数据类型可以包含比单个数字、布尔值或字符串更多的信息。例如,您将在本节后面看到,您可以将数组用作返回值。
作用域。
变量的作用域是可以直接引用该变量的语句集合。函数的局部和参数变量的作用域仅限于该函数;在全局代码中定义的变量的作用域(称为全局变量)仅限于包含该变量的.py文件。因此,全局代码不能引用函数的局部或参数变量。一个函数也不能引用另一个函数中定义的局部或参数变量。当一个函数使用与全局变量相同名称的局部(或参数)变量定义变量时(例如harmonicf.py中的i),函数中的变量名称指的是局部(或参数)变量,而不是全局变量。虽然函数中的代码可以引用全局变量,但不应该这样做:调用者到函数的所有通信应该通过函数的参数变量进行,函数到调用者的所有通信应该通过函数的返回值进行。
默认参数。
Python 函数可以通过为该参数指定默认值来指定一个参数为可选。如果在函数调用中省略了可选参数,则 Python 会用默认值替换该参数。我们已经遇到了这个特性的几个例子。您可以通过在函数签名中的参数变量后面放置一个等号,然后是默认值,来在用户定义的函数中指定一个带有默认值的可选参数。您可以在函数签名中指定多个可选参数,但所有可选参数必须在所有必选参数之后。下表中的harmonic()函数使用了一个默认参数。
副作用。
纯函数是一个函数,给定相同的参数,总是返回相同的值,而不产生任何可观察的副作用,如消耗输入、产生输出或以其他方式改��系统的状态。在计算机编程中,定义产生副作用的函数也是有用的。事实上,我们经常定义的函数的唯一目的是产生副作用。在这样的函数中,显式的返回语句是可选的:在 Python 执行函数的最后一个语句后,控制返回给调用者。没有指定返回值的函数实际上返回特殊值None,通常被忽略。下表中的drawTriangle()函数具有副作用,并且不返回值。
类型检查。
在数学中,函数的定义同时指定了定义域和值域。例如,对于调和数,定义域是正整数,值域是正实数。在 Python 中,我们不需要指定参数变量的类型或返回值的类型。只要 Python 可以在函数内执行所有操作,Python 就会执行该函数并返回一个值。如果 Python 无法对给定对象执行操作,因为它的类型不正确,Python 会引发运行时错误以指示无效类型。这种灵活性是 Python 的一个受欢迎特性(称为多态性),因为它允许我们为不同类型的对象定义一个单一函数来使用。
下表总结了我们的讨论,提供了函数定义的示例。
实现数学函数

现在我们考虑两个在科学、工程和金融中起重要作用的重要函数。高斯(正态)分布函数以熟悉的钟形曲线为特征,并由以下公式定义:
累积高斯分布函数Φ(z)被定义为在由上述 x 轴和左侧垂直线x = z定义的曲线下的面积。计算φ和Φ的函数在 Python 的math模块中不可用,因此我们开发自己的实现。
闭式形式。
在最简单的情况下,我们有一个闭式数学公式,用 Python 的math模块中实现的函数来定义我们的函数。这是φ的情况,因此很容易实现与数学定义对应的函数pdf()。为了方便起见,gauss.py 使用默认参数μ = 0 和σ = 1,并实际计算φ(x, μ, σ) = φ((x - μ) / σ) / σ
没有闭式形式。
如果没有已知的公式,我们可能需要一个更复杂的算法来计算函数值。这种情况适用于Φ —— 该函数没有闭式表达式。Φ的泰勒级数逼近比率结果是评估函数的有效基础:
这个公式很容易转换为 Python 代码中的函数cdf(),在 gauss.py 中。对于小(分别大)的z,该值非常接近 0(分别 1),因此代码直接返回 0(分别 1);否则,它使用泰勒级数添加项,直到总和收敛。同样,为了方便起见,gauss.py 实际计算Φ(z, μ, σ) = Φ((z - μ) / σ),使用默认值μ = 0 和σ = 1。
使用函数来组织代码
通过定义函数的能力,我们可以在适当时在程序中定义函数来更好地组织程序。例如,coupon.py 是 couponcollector.py(来自第 1.4 节)的一个版本,更好地分离了计算的各个组件:
给定n,计算一个随机优惠券价值。
给定n,进行优惠券收集实验。
从命令行获取n,然后计算并写入结果。
无论何时你可以清晰地在计算中分离任务,你都应该这样做。
传递参数和返回值
接下来,我们将研究 Python 传递参数和从函数返回值的具体机制。
对象引用调用。
你可以在函数体中的任何地方使用参数变量,就像使用局部变量一样。参数变量和局部变量之间唯一的区别是,Python 会用调用代码提供的相应参数初始化参数变量。我们称这种方法为对象引用调用。这种方法的一个后果是,如果参数变量引用可变对象,并且在函数内部更改了该对象的值,那么这也会在调用代码中更改对象的值(因为它是同一个对象)。接下来,我们将探讨这种方法的后果。
不可变性和别名。
如第 1.4 节所讨论的,数组是可变数据类型,因为我们可以更改数组元素。相比之下,如果无法更改该类型对象的值,则数据类型是不可变的。我们一直在使用的其他数据类型(int、float、str和bool)都是不可变的。在不可变数据类型中,可能看起来会更改值的操作实际上会导致创建一个新对象,如右侧简单示例所示。首先,语句i = 99创建一个整数 99,并将引用分配给i。然后j = i将i(一个对象引用)分配给j,因此i和j都引用相同的对象——整数 99。引用同一对象的两个变量称为别名。接下来,j += 1导致j引用一个值为 100 的对象,但它并没有通过更改现有整数从 99 更改为 100!实际上,由于int对象是不可变的,没有语句可以更改现有整数的值。相反,该语句创建一个新整数 1,将其添加到整数 99 以创建另一个新整数 100,并将引用分配给j。但i仍然引用原始的 99。请注意,新整数 1 最终没有引用它——这是系统的问题,不是我们的问题。
整数、浮点数、布尔值和字符串作为参数。
每当您将参数传递给函数时,参数和函数的参数变量成为别名。在实践中,这是 Python 中别名的主要用法。为了说明,假设我们需要一个增加整数的函数(我们的讨论也适用于任何更复杂的函数)。一个刚接触 Python 的程序员可能尝试这样定义:
def inc(j):
j += 1
然后期望使用调用inc(i)来增加整数i。这样的代码在某些编程语言中可以工作,但在 Python 中没有效果,如右侧的图所示。
要增加变量i,我们可以使用以下定义
def inc(j):
j += 1
return j
并使用赋值语句i = inc(i)调用该函数。
对于任何不可变类型也是如此。函数无法更改整数、浮点数、布尔值或字符串的值。
数组作为参数。
当函数将数组作为参数时,它实现了一个操作任意数量对象的函数。例如,以下函数计算浮点数或整数数组的平均值:
def mean(a):
total = 0.0
for v in a:
total += v
return total / len(a)

使用数组时的副作用。
由于数组是可变的,通常情况下,接受数组作为参数的函数的目的是产生副作用(例如更改数组元素的顺序)。这样一个函数的典型例子是一个在给定数组中交换两个给定索引处的元素的函数。我们可以调整我们在第 1.4 节开头检查的代码:
def exchange(a, i, j):
temp = a[i]
a[i] = a[j]
a[j] = temp
此函数调用的正式跟踪显示在右侧。
第二个典型的例子是一个接受数组参数并产生副作用的函数,该函数随机打乱数组中的元素,使用我们在第 1.4 节中检查的算法版本(以及刚刚定义的exchange()函数):
def shuffle(a):
n = len(a)
for i in range(n):
r = random.randrange(i, n)
exchange(a, i, r)
作为返回值的数组。
函数可以返回一个数组。例如,考虑以下函数,它返回一个随机浮点数数组:
def randomarray(n):
a = stdarray.create1D(n, 0.0)
for i in range(n):
a[i] = random.random()
return a
下表总结了我们对数组作为函数参数的讨论,突出了一些典型的数组处理函数。
声波的叠加

像协奏 A 音这样的音符具有纯净的声音,不太具有音乐性,因为你习惯听到的声音有许多其他成分。大多数乐器会产生谐波(不同八度的相同音符,但不那么响亮),或者你可能演奏和弦(同时演奏多个音符)。为了合并多个声音,我们使用叠加:简单地将它们的波形相加并重新调整比例,以确保所有值保持在-1 和+1 之间。
程序 playthattunedeluxe.py 是 playthattune.py(来自第 1.5 节)的一个版本,封装了声波计算并添加了谐波。尝试反复运行 playthattunedeluxe.py,并将其标准输入重定向到这些数据文件中的每一个(由不同学生创建):elise.txt、ascale.txt、stairwaytoheaven.txt、entertainer.txt、firstcut.txt、freebird.txt 和 looney.txt。
Q & A
Q. 我能在函数中使用没有指定值的 return 语句吗?
A. 是的。从技术上讲,它返回None对象,这是NoneType类型的唯一值。
Q. 如果一个函数有一个控制流导致return语句返回一个值,但另一个控制流达到函数体的末尾会发生什么?
A. 定义这样一个函数是不良风格的,因为这会给函数的调用者带来严重负担:调用者需要知道在哪些情况下函数返回一个值,在哪些情况下返回None。
Q. 如果我在函数体中的代码出现在return语句之后会发生什么?
A. 一旦到达return语句,控制权将返回给调用者。因此,在 return 语句之后出现的函数体中的任何代码都是无效的;它永远不会被执行。在 Python 中,这是不良风格,但并非非法定义这样的函数。
Q. 如果我在同一个.py文件中定义了两个同名函数(但可能参数个数不同)会发生什么?
A. 这被称为函数重载,许多编程语言都支持。然而,Python 不是其中之一:第二个函数定义将覆盖第一个函数。通常可以通过使用默认参数来实现相同的效果。
Q. 如果我在不同文件中定义了同名的两个函数会发生什么?
A. 没问题。例如,在 gauss.py 中有一个名为pdf()的函数,用于计算高斯概率密度函数,另一个名为pdf()的函数在cauchy.py中用于计算柯西概率密度函数。在第 2.2 节中,你将学习如何调用在不同.py文件中定义的函数。
Q. 一个函数能改变参数变量绑定的对象吗?
A. 是的,你可以在赋值语句的左侧使用参数变量。然而,许多 Python 程序员认为这样做是不良风格的。请注意,这样的赋值语句对客户端没有影响。
Q. 副作用和可变对象的问题很复杂。这真的那么重要吗?
A. 是的。正确控制副作用是程序员在大型系统中最重要的任务之一。花时间确保你理解传递数组(可变的)和传递整数、浮点数、布尔值和字符串(不可变的)之间的区别肯定是值得的。同样的机制也适用于所有其他类型的数据,你将在第三章中学到。
Q. 我如何安排将数组传递给函数,以使函数无法更改数组中的元素?
答: 没有直接的方法。在第 3.3 节中,您将看到如何通过构建包装数据类型并传递该类型的对象来实现相同的效果。您还将看到如何使用 Python 的内置tuple数据类型,它表示一组不可变的对象。
问: 我可以使用可变对象作为可选参数的默认值吗?
答: 可以,但可能会导致意外行为。Python 只在函数定义时评估默认值一次(而不是每次调用函数时)。因此,如果函数体修改了默认值,后续函数调用将使用修改后的值。如果通过调用不纯函数来初始化默认值,也会出现类似的困难。例如,在 Python 执行代码片段后
def append(a=[], x=random.random()):
a += [x]
return a
b = append()
c = append()
b[]和c[]是长度为 2 的相同数组的别名(而不是 1),其中包含一个浮点数重复两次(而不是两个不同的浮点数)。
练习
组合一个函数
max3(),它接受三个int或float参数,并返回最大的一个。解决方案:
def max3(a, b, c) max = a if b > max: max = b if c > max: max = c return max组合一个函数
odd(),它接受三个bool参数,并在奇数个参数为True时返回True,否则返回False。组合一个函数
majority(),它接受三个bool参数,并在至少两个参数为True时返回True,否则返回False。不要使用if语句。解决方案:这里有两个解决方案。第一个简洁。第二个愚蠢,但遵守规则。
def majority(a, b, c): return (a and b) or (a and c) or (b and c) def majority(a, b, c): while a and b: return True while a and c: return True while b and c: return True return False组合一个函数
areTriangular(),它以三个数字作为参数,并在它们可能是三角形的边(没有一个大于或等于另外两个的和)时返回True,否则返回False。组合一个函数
sigmoid(),它接受一个float参数x,并返回从公式得到的float:1 / (1 - e^(-x))。组合函数
lg(),它以整数n作为参数,并返回n的以 2 为底的对数。您可以使用 Python 的math模块。组合一个函数
lg(),它以整数n作为参数,并返回不大于n的以 2 为底的对数的最大整数。不要使用标准的 Pythonmath模块。组合一个函数
signum(),它接受一个float参数n,如果n小于 0,则返回-1,如果n等于 0,则返回 0,如果n大于 0,则返回+1。考虑这个函数
duplicate():def duplicate(s): t = s + s以下代码片段写了什么?
s = 'Hello' s = duplicate(s) t = 'Bye' t = duplicate(duplicate(duplicate(t))) stdio.writeln(s + t)考虑这个函数
cube():def cube(i): i = i * i * i以下
while循环迭代了多少次?i = 0 while i < 1000: cube(i) i += 1解决方案:只有 1000 次。对
cube()的调用对客户端代码没有影响。它改变了其本地参数变量i的值,但这种改变对while循环中的i没有影响,因为那是一个不同的变量。如果您用语句i = i * i * i替换对cube(i)的调用(也许这是您想的),那么循环将迭代五次,i在五次迭代开始时分别取值 0、1、2、9 和 730。以下代码片段写了什么?
for i in range(5): stdio.write(i) for j in range(5): stdio.write(i)解决方案:0123444444。请注意,第二次调用
stdio.write()使用的是i,而不是j。与许多其他编程语言中的类似循环不同,当第一个 for 循环终止时,变量i为 4,并且仍然在作用域内。下面的校验和公式被银行和信用卡公司广泛用于验证合法的账号号码:
d[0] + f(d[1]) + d[2] + f(d[3]) + d[4] + f(d[5]) + d[6] + ... = 0 (mod 10) d[i]是账号数字的小数位,f(d)是 2d的小数位之和(例如,f(7) = 5,因为 2 × 7 = 14,1 + 4 = 5)。例如,17327 是有效的,因为 1 + 5 + 3 + 4 + 7 = 20,这是 10 的倍数。实现函数f并组合一个程序,以一个 10 位整数作为命令行参数,并打印一个有效的 11 位数字,其中给定整数作为前 10 位数字,校验和作为最后一位数字。
给定两颗星的赤纬和赤经角度(d1, a1)和(d2, a2),它们所夹角度由Haversine formula给出:
2 arcsin((sin²(d/2) + cos(d[1]) cos(d[2]) sin²(a/2))^(1/2)) 其中a[1]和a[2]是在-180 到 180 度之间的角度,d[1]和d[2]是在-90 到 90 度之间的角度,a = a[2] - a[1],d = d[2] - d[1]。编写一个程序,接受两颗星的赤纬和赤经作为命令行参数,并写��它们所夹角度。 提示:在将度数转换为弧度时要小心。
参见第 1.2 节中类似的练习。纬度对应于赤纬,经度对应于赤经。
编写一个
readBoolean2D()函数,将一个由 0 和 1 值组成的二维矩阵(带有维度)读入一个布尔数组中。解决方案:该函数的主体与本页早期表中给出的浮点数二维数组的相应函数几乎相同:
def readBool2D(): m = stdio.readInt() n = stdio.readInt() a = stdarray.create2D(m, n, False) for i in range(m): for j in range(n): a[i][j] = stdio.readBool() return a编写一个函数,以一个严格正浮点数数组
a[]作为参数,并重新调整数组,使每个元素介于 0 和 1 之间(通过从每个元素中减去最小值,然后将每个元素除以最小值和最大值之间的差异)。使用内置的max()和min()函数。编写一个
histogram()函数,以一个整数数组a[]和一个整数m作为参数,并返回一个长度为m的数组,其第i个条目是参数数组中整数i出现的次数。假设a[]中的值都在 0 和m-1 之间,因此返回数组中值的总和应等于len(a)。在本节和第 1.4 节中组装代码片段,开发一个程序,从命令行接受一个整数
n,并写入n个五张卡牌的手,用空行分隔,从一个随机洗牌的卡牌牌组中抽取,每行一张卡牌,使用Ace of Clubs等卡牌名称。编写一个函数
multiply(),它以两个相同维度的方阵作为参数,并返回它们的乘积(另一个相同维度的方阵)。 额外加分:使您的程序在第一个矩阵的列数等于第二个矩阵的行数时也能工作。编写一个函数
any(),它以一个布尔数组作为参数,并在数组中的任何条目为True时返回True,否则返回False。编写一个函数all(),它以一个布尔数组作为参数,并在数组中的所有条目都为True时返回True,否则返回False。请注意,all()和any()是 Python 内置函数;这个练习的目的是通过创建自己的版本来更好地理解它们。开发一个更好地模拟当其中一个优惠券稀有时的
getCoupon()版本:随机选择一个值,以 1/(1000n)的概率返回该值,并以相等概率返回所有其他值。 额外加分:这种变化如何影响优惠券收集函数的平均值?修改 playthattune.py(来自第 1.5 节),添加每个音符两个八度的谐波,权重为一个八度谐波的一半。
创意练习
生日问题。 编写一个适当的程序来研究生日问题(参见第 1.4 节中相关的练习)。
欧拉函数。 欧拉函数是数论中的一个重要函数:φ(n)被定义为小于或等于n且与n互质(除了 1 以外没有其他公因数)的正整数的数量。编写一个函数,接受一个整数参数n并返回φ(n)。包括从命令行接受一个整数、调用该函数并写入结果的全局代码。
调和数。 编写一个名为
harmonic.py的程序,定义三个函数harmonic()、harmonicSmall()和harmonicLarge()来计算调和数。harmonicSmall()函数应该只计算总和(如 harmonic.py 中所示),harmonicLarge()函数应该使用近似公式 H[n] = loge + γ + 1/(2n) - 1/(12n²) + 1/(120n⁴)(其中γ = .577215664901532...被称为欧拉常数),而harmonic()函数应该在n < 100 时调用harmonicSmall(),否则调用harmonicLarge()。高斯随机值。 尝试使用以下函数生成高斯分布的随机变量,该函数基于在单位圆内生成随机点并使用一种形式的Box-Muller 变换。(请参阅第 1.2 节末尾的“高斯随机数”练习。)
def gaussian(): r = 0.0 while (r >= 1.0) or (r == 0.0): x = random.uniform(-1.0, 1.0) y = random.uniform(-1.0, 1.0) r = x*x + y*y return x * math.sqrt(-2.0 * math.log(r) / r)接受一个命令行参数
n,并生成n个随机数,使用一个包含 20 个整数的数组a[]来计算生成的落在i*.05和(i+1)*.05之间的数字,其中i从 0 到 19。然后使用stddraw来绘制数值,并将结果与正态钟形曲线进行比较。备注:这种方法比第 1.2 节中描述的“高斯随机数”练习中的方法更快更准确。尽管涉及循环,但平均只执行循环 4/π(约 1.273)次。这减少了对超越函数的整体预期调用次数。二分查找。 我们在第 4.2 节中详细研究的一种通用方法是用于计算类似
cdf()的累积概率密度函数的逆函数。这些函数是连续的,从(0, 0)到(1, 1)是非递减的。要找到f(x[0]) = y[0]的值x[0],检查f(.5)的值。如果它大于y[0],那么x[0]必须在 0 和.5 之间;否则,它必须在.5 和 1 之间。无论哪种方式,我们都将已知包含x[0]的区间的长度减半。通过迭代,我们可以在给定的容差内计算x[0]。在 gauss.py 中添加一个使用二分查找来计算逆函数的函数cfdInverse()。解决方案:请参阅 gaussinv.py。
Black-Scholes 期权定价。 Black Scholes公式提供了不支付股息的股票上的欧式看涨期权的理论价值,给定当前股价s,行权价x,连续复利无风险利率r,股票回报的标准差σ(波动率)和到期时间(年)t。该价值由公式sΦ(a) - x**e(-rt)φ(b)给出,其中Φ(z)是高斯累积分布函数,a = (ln(s/x) + (r + σ²/2)t)/(σt(1/2)),b = a - σt^(1/2)。编写一个程序,从命令行获取
s、x、r、sigma和t,并输出 Black-Scholes 值。目前,将phi()和Phi()函数的定义从 gauss.py 复制到您的程序中。在网站的下一部分,您将学习如何在一个.py文件中定义一个函数,以便可以被另一个.py文件中的代码调用。Myron Scholes 因Black-Scholes 论文而获得了 1997 年的诺贝尔经济学奖。
解决方案:请参阅 blackscholes.py。
隐含波动率。 通常波动率是 Black-Scholes 公式中的未知值。编写一个程序,从命令行读取
s、x、r、t和期权的当前价格,并使用二分查找(请参阅本节中的先前练习)来计算σ。霍纳方法。 编写一个带有函数
evaluate(x, a)的程序,该函数评估多项式a(x),其系数是数组a[]中的元素:a[0] + a[1]x¹ + a[2]x² + ... + a[n-2]x(n-2) + a[n-1]x(n-1) 使用 霍纳法,这是一种执行计算的高效方法,建议按照以下括号化:
a[0] + x (a[1] + x( a[2] + ... + x(a[n-2] + x**a[n-1]))...) 然后编写一个函数
exp(),调用evaluate()来计算 e^x ���近似值,使用泰勒级数展开的前 n 项 e^x = 1 + x + x²/2! + x³/3! + ... 从命令行获取参数x,并将您的结果与由math.exp(x)计算的结果进行比较。包括代码来检查您的答案与由
math.exp()计算的答案是否一致。解决方案:参见 horner.py。
本福德定律。 美国天文学家西蒙·纽康布观察到一本编制对数表的书中的一个怪现象:开始的页面比结束的页面脏得多。他怀疑科学家们使用以 1 开头的数字进行的计算比使用以 8 或 9 开头的数字进行的计算要多,并假设了第一位数字定律,该定律表明在一般情况下,领先数字更有可能是 1(大约 30%)而不是数字 9(不到 4%)。这种现象被称为本福德定律,现在经常被用作统计测试。例如,美国国税局的法务会计师依靠它来发现税务欺诈。编写一个程序,从标准输入读取一系列整数,并制表显示数字 1-9 每个数字作为领先数字的次数,将计算分解为一组适当的函数。使用您的程序在计算机或网络上的一些信息表上测试该定律。然后,编写一个程序通过生成从 $1.00 到 $1,000.00 的随机金额,使 IRS 无法发现。
文件普林斯顿文件.txt 是普林斯顿公共 Unix 机器上文件大小的列表。尝试在该文件上运行您的程序。
解决方案:参见 benford.py。
二项分布。 编写一个接受整数
n、整数k和浮点数p的函数binomial(),并计算在进行n次有偏向正面概率为p的硬币翻转时恰好获得k次正面的概率,使用公式f(k, n, p) = p(k)(1 - p)(n-k)n! / (k!(n-k)!) 提示:为了避免使用巨大的整数进行计算,计算 x = ln f(k, n, p),然后返回 e^x。在全局代码中,从命令行获取
n和p,并检查在 0 到n之间的所有k值的总和是否(大约)为 1。还要将计算的每个值与正态近似进行比较。f(k, n, p) ≈ Φ(k + 1/2, np, (np(1-p)(1/2)) - Φ(k - 1/2, np, (np(1-p)(1/2)) 从二项分布中收集优惠券。 编写一个使用前一个练习中的
binomial()返回符合 p = 1/2 的二项分布的优惠券值的getCoupon()版本。提示:生成一个介于 0 和 1 之间的均匀分布的随机数 x,然后返回所有 j < k 的 f(n, j, p) 的总和超过 x 的最小值 k。额外学分:为描述在此假设下优惠券收集函数的行为开发一个假设。弦。 编写一个版本的 playthattunedeluxe.py,可以处理包含和弦(包括谐波)的歌曲。开发一种输入格式,允许您为每个和弦指定不同的持续时间和每个音符内的不同振幅权重。创建测试文件,用各种和弦和谐波测试您的程序,并创建一个使用它们的 Elise 之歌 版本。
![条形码示例]()
邮政条形码。 美国邮政系统用于路由邮件的POSTNET 条形码定义如下:邮政编码中的每个十进制数字都使用三个半高和两个全高的条形码进行编码。条形码以全高的条作为起始和结束(保护栏),并包括一个校验和数字(在五位邮政编码或 ZIP+4 之后),通过对原始数字取模 10 来计算。实现以下函数:
在
stddraw上绘制半高或全高的条形码。给定一个数字,绘制其条形码序列。
计算校验和数字。
以及一个测试客户端,读取一个五位(或九位)数字的 ZIP 码作为命令行参数,并绘制相应的邮政条形码。
值 0 1 2 3 4 5 6 7 8 9 编码 ||╷╷╷╷╷╷||╷╷|╷|╷╷||╷╷|╷╷|╷|╷|╷╷||╷╷|╷╷╷||╷╷|╷|╷|╷╷日历。 编写一个程序,接受两个命令行参数
m和y,并为年份y的第m个月写出月历,如下例所示:February 2009 S M Tu W Th F S 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28提示:参见 leapyear.py 和第 1.2 节中的“星期几”练习。
解决方案:参见 calendar.py。
傅里叶尖峰。 编写一个程序,接受一个命令行参数n,并绘制函数:
(cos(t) + cos(2t) + cos(3t) + cos(4t) + ... + cos(nt)) / n 对于从-10 到 10(以弧度为单位)均匀间隔的 500 个样本的t。运行你的程序,n = 5 和n = 500。注意:你会发现总和收敛到一个尖峰(除了一个单一值外,其他地方都是 0)。这个性质是证明任何光滑函数都可以表示为正弦波的和的基础。
2.2 模块和客户端
原文:
introcs.cs.princeton.edu/python/22module译者:飞龙
到目前为止,你编写的每个程序都包含在一个单独的 .py 文件中的 Python 代码。对于大型程序来说,将所有代码放在一个文件中是受限制且不必要的。幸运的是,在 Python 中很容易调用另一个文件中定义的函数。这种能力有两个重要的后果:
它实现了代码重用。一个程序可以使用已经编写和调试过的代码,而不是复制代码,只需调用它。通过这样做,你实际上可以扩展 Python —— 你可以定义并使用自己的一组数据操作。
它实现了模块化编程。你不仅可以将程序分成函数,就像在第 2.1 节中描述的那样,还可以将它们保存在不同的文件中,根据应用程序的需求进行分组。模块化编程很重要,因为它允许我们独立地逐步组合和调试大型程序的部分,将每个完成的部分留在自己的文件中以供以后使用,而无需再担心其细节。
在其他程序中使用函数
要引用另一个 Python 程序中定义的函数,我们使用与调用std*模块和 Python 的math和random模块中的函数相同的机制。在本节中,我们描述这种基本的 Python 语言机制。为此,我们区分两种类型的 Python 程序:
一个模块包含其他程序可用的函数。
一个客户端是使用模块中函数的程序。
创建和使用模块需要执行五个(简单)步骤。我们用模块 gaussian.py 和客户端 gaussiantable.py 来说明完整的过程,其中模块是计算高斯分布函数的模块化版本,而客户端使用该模块来计算并写入值表。这是五个步骤:
在客户端中:导入模块。 客户端 gaussiantable.py 包含语句
import gaussian;现在它可以调用在模块 gaussian.py 中定义的任何函数。在客户端中:限定对模块的函数调用。 客户端 gaussiantable.py 使用函数调用
gaussian.cdf(score, mu, sigma)来调用在模块 gaussian.py ��定义的cdf()函数。在模块中:编写一个测试客户端。 模块 gaussian.py 包含一个
main()函数,接受三个命令行参数,调用模块中的函数,并将结果写入标准输出。在模块中:消除任意全局代码。 我们不能在 gaussian.py 中留下任意全局代码,因为 Python 每次导入模块时都会执行它。相反,我们将测试代码放在一个
main()函数中,就像刚才描述的那样。现在,我们可以安排 Python 在我们从命令行执行gaussian.py时调用main()(仅在那时),使用以下咒语:
if __name__ == '__main__': main()
- 使模块对客户端可访问。 Python 需要能够在处理
import gaussian语句时找到文件 gaussian.py。你继续进行的最简单方法是将 gaussian.py 和 gaussiantable.py 放在一起。本节末尾的问答描述了另一种方法。
总之,模块 gaussian.py 中的函数可通过import gaussian语句供任何其他程序使用。相反,客户端 gaussiantable.py 包含任意全局代码,不打算供其他程序使用。我们使用术语脚本来指代这样的代码。
这张图总结了 gaussiantable.py 客户端、gaussian.py 模块和标准math模块之间的控制流。
模块化编程。
通过定义多个文件,每个文件都是一个独立的模块,具有多个函数,编程的潜在影响是我们编程风格的另一个深刻变化。通常,我们将这种方法称为模块化编程。模块化编程的关键好处在于鼓励我们将计算分解为可以单独调试和测试的较小部分。通常情况下,你应该通过确定一个合理的方式将计算分解为可管理大小的独立部分,并实现每个部分,就好像有人以后会想要使用它一样。
模块化编程抽象
接下来,我们描述作为模块化编程基础的抽象。

实现。
我们使用通用术语实现来描述实现一组旨在重复使用的函数的代码。Python 模块就是一个实现:我们用一个名为 module 的名称来集体引用一组函数,并将它们保存在一个名为 module.py 的文件中。模块设计的指导原则是向客户提供他们需要的函数,而不是其他函数。
客户端。
我们使用通用术语客户端来指代使用实现的程序。我们说调用一个在名为*module*.py的文件中定义的函数的 Python 程序(脚本或模块)是*module*的客户端。
应用程序编程接口(API)。
程序员通常以客户端和实现之间的合同来思考,这是对实现要做什么的明确规范。你已经能够组成作为math和random等标准 Python 模块的客户端的程序,因为有一个非正式的合同(对它们应该做什么的���语描述)以及可供使用的函数的签名的精确规范。总体而言,这些信息被称为应用程序编程接口(API)。对于用户定义的模块,相同的机制是有效的。API 允许任何客户端使用模块,而无需检查定义模块的代码。当我们组成一个新模块时,我们总是提供一个 API。例如,这是我们的 gaussian.py 模块的 API:
API 应包含多少信息?在这个书站中,我们坚持一个与我们的设计原则相一致的原则:向客户程序员提供他们需要的信息,而不是更多。
私有函数。
有时,我们希望在一个模块中定义一个不打算由客户端直接调用的辅助函数。我们将这样的函数称为私有函数。按照惯例,Python 程序员在私有函数名称的第一个字符中使用下划线。例如,以下是从 gaussian.py 中调用私有函数 _phi()的 pdf()函数的另一种实现:
def _phi(x):
return math.exp(-x*x/2.0) / math.sqrt(2*math.pi)
def pdf(x, mu=0.0, sigma=1.0):
return _phi(float((x - mu) / sigma)) / sigma
我们不在 API 中包含私有函数,因为它们不是客户端和实现之间的合同的一部分。实际上,函数名称中的下划线信号告诉客户不要显式调用该函数。(遗憾的是,Python 没有强制执行这种约定的机制。)
库。
库是一组相关的模块。例如,Python 有一个标准库(其中包括模块 random 和 math)和许多扩展库(如用于科学计算的 NumPy 和用于图形和声音的 Pygame)。此外,对于这本书,我们提供了一个书站库(其中包括模块 stdio 和 stddraw)。
文档。
所有标准、扩展和书站模块的 API 都可以通过交互式 Python 中内置的 help() 函数获得。如下所示,你只需要输入 python(进入交互式 Python),然后输入语句 import module(加载模块),然后输入 help(module) 来查看模块的 API。标准和扩展 Python 模块的 API 也可以通过在线 Python 文档以另一种形式获得。
接下来,我们介绍我们的 stdrandom 模块(用于生成随机数)、stdarray 模块(用于一维和二维数组)和 stdstats 模块(用于统计计算)的 API。我们还描述了这些模块的一些有趣的客户端。
随机数
stdrandom.py 模块用于从各种分布生成随机数。
API 设计。
我们对传递给 stdrandom 中每个函数的对象做出了一些假设���例如,我们假设客户端将一个介于 0.0 和 1.0 之间的浮点数传递给 stdrandom.bernoulli(),并且将一个非负数数组(其中不是所有元素都为零)传递给 stdrandom.discrete()。这些假设是客户端和实现之间的合同的一部分。
单元测试。
我们实现 stdrandom 时没有参考任何特定的客户端,但将一个基本的测试客户端 main() 包含其中是良好的编程实践,至少要
测试所有代码。
提供了一些保证代码正常工作的保证。
从命令行接受参数以允许灵活测试。
尽管它不是为客户端设计的,但在调试、测试和改进模块中的函数时,我们使用 main()。这种实践被称为单元测试。适当的单元测试本身可能是一个重要的编程挑战。在这种特殊情况下,适合在一个单独的客户端中进行更广泛的测试,以检查这些数字是否具有与从引用分布中真正随机抽取的数字相同的属性。
压力测试。
一个被广泛使用的模块,比如 stdrandom,也应该经受压力测试,确保即使客户端不遵循合同或做出一些未明确涵盖的假设时,它也不会出现意外失败。如果一些数组元素为负数,stdrandom.discrete() 应该怎么办?如果参数是长度为 0 的数组呢?如果第二个参数小于(或等于)第一个参数,stdrandom.uniform() 应该怎么办?这些情况有时被称为边界情况。
数组处理 API
在第 1.4 节中,我们看到了创建指定长度的一维数组和指定行列数的二维数组的函数的实用性。因此,我们引入了书站库中的 stdarray 模块,特别是它的用于创建和初始化数组的函数 stdarray.create1D() 和 stdarray.create2D()。
此外,我们已经看到并将继续看到许多例子,我们希望从标准输入中读取值到数组中,并将值从数组写入标准输出。因此,我们在 stdarray 模块中包含了从标准输入读取整数、浮点数和布尔数组并将它们写入标准输出的函数,从而补充了 stdio 模块。这是 stdarray 的完整 API:
我们采用的约定是标准输入中出现的数组包括维度,并按照指示的顺序出现,如下图所示。read*()函数期望这种格式,而write*()函数以这种格式生成输出。
对于布尔数组,我们的文件格式使用0和1值,而不是False和True。这种约定对于大数组来说更加经济。更重要的是,使用这种文件格式更容易发现数据中的模式。
迭代函数系统
迭代函数系统(IFS)是产生分形图像如谢尔宾斯基三角形和巴恩斯利蕨的一般方法。作为第一个例子,考虑以下简单过程:从等边三角形的一个顶点开始绘制一个点。然后随机选择三个顶点中的一个,并在刚刚绘制的点和该顶点之间的中点绘制一个新点。继续执行相同的操作。
程序 sierpinski.py 模拟了这个过程。以下是 1000、10000 和 100000 步后的快照。您可能会认出这个图形是谢尔宾斯基三角形。
程序 ifs.py 是一个数据驱动版本的程序,模拟了这个过程的一般化。详情请参阅教科书。您可以在以下任何输入文件上运行它:barnsley.txt, binary.txt, coral.txt, culcita.txt, cyclososus.txt, dragon.txt, fishbone.txt, floor.txt, koch.txt, sierpinski.txt, spiral.txt, swirl.txt, tree.txt, 或 zigzag.txt。
能够如此轻松地产生如此逼真的图表,引发了有趣的科学问题:计算告诉我们关于自然的什么?自然告诉我们关于计算的什么?
标准统计
stdstats.py模块用于统计计算和基本可视化,如下所示的 API。详情请参阅教科书。
伯努利试验。
程序 bernoulli.py 是一个stdstats.py客户端示例。它计算在抛掷公平硬币n次时找到的正面数,并将结果与预测的高斯分布函数进行比较。根据中心极限定理,结果直方图极好���近似于均值为n/2 和方差为n/4 的高斯分布。这是命令python bernoulli.py 20 100000的输出:
模块化编程的好处
我们开发的模块实现展示了模块化编程。我们不是将一个新程序组合成一个自包含的文件来解决新问题,而是将每个任务分解为更小、更易管理的子任务,然后实现和独立调试解决每个子任务的代码。ifs.py 和 bernoulli.py 程序展示了模块化编程,因为它们是相对复杂的计算,使用了几个相对较小的模块。
我们在整个书站强调模块化编程,因为它有许多重要的好处,包括以下内容:
合理规模的程序。 没有一个大任务是如此复杂,以至于不能分解为更小的子任务。
调试。 通过模块化编程和我们的指导原则,尽可能将变量的作用域局限在本地,我们严格限制了在调试时需要考虑的可能性数量。同样重要的是客户端和实现之间的契约概念。一旦我们确信实现满足了约定的要求,我们就可以在这个假设下调试所有的客户端。
代码重用。 一旦我们实现了诸如 StdStats 和 StdRandom 之类的库,我们就不必担心再次编写代码来计算平均值或标准差或生成随机数 —— 我们可以简单地重用我们编写的代码。
维护。 像一篇好的文章一样,一个好的程序总是可以改进的。假设在开发新客户端时,您发现某个模块中有一个错误。通过模块化编程,修复该错误相当于修复所有模块的客户端中的错误。
问答
Q. 我如何使一个书站模块(如stdio或stdrandom)可供我的 Python 程序使用?
A. 如果您按照书站上的逐步说明安装 Python,那么我们所有的标准模块(包括stdio、stddraw、stdarray、stdrandom和stdstats)应该已经可以在您的 Python 程序中使用。
Q. 我如何使非标准和非书站模块(如gaussian)可供我的 Python 程序使用?
A. 让我们具体一点。假设您编写了一个名为myprogram.py的程序,并且myprogram.py导入了gaussian。问题是如何使gaussian模块对myprogram.py可用。换句话说,问题是在执行myprogram.py时如何告诉 Python 在哪里找到gaussian.py文件。
最简单的方法是将gaussian.py放在与myprogram.py相同的目录中。然而,使用这种方法,您可能会在每个包含gaussian.py客户端的目录中得到多个副本的gaussian.py —— 每个目录中都有一个副本。这种方法会使gaussian.py代码难以维护。
另一种方法是将gaussian.py的一个副本放在某个目录中,然后设置PYTHONPATH环境变量以包含该目录。随后,每当 Python 遇到import语句时,它会在该目录中查找文件。
设置PYTHONPATH环境变量的机制取决于您使用的操作系统。假设您的计算机正在运行 Mac OS X 或 Linux。进一步假设您将gaussian.py放在目录/Users/yourusername/common中。然后这些命令是适当的:
export PYTHONPATH=/Users/yourusername/common
python myprogram.py
export命令将PYTHONPATH变量的值设置为/Users/yourusername/common。因此,在执行后续的python myprogram.py命令时,Python 在处理import语句时会查找/Users/yourusername/common目录,从而找到gaussian.py文件。
一般来说,在 Mac OS X 和 Linux 系统上,PYTHONPATH变量的值是由冒号分隔的一系列目录。因此,在您发出这个命令之后:
export PYTHONPATH=*directory1*:*directory2*:*directory3*
当处理import语句时,Python 会先查找*directory1*,然后查找*directory2*,最后查找*directory3*中的.py文件。
另一方面,假设您的计算机正在运行 Microsoft Windows。进一步假设您将gaussian.py放在目录c:\Users\yourusername\common中。然后这些命令是适当的:
set PYTHONPATH=c:\Users\yourusername\common
python myprogram.py
set命令将PYTHONPATH变量的值设置为c:\Users\yourusername\common。因此,在执行后续的python myprogram.py命令时,Python 在处理import语句时会查找c:\Users\yourusername\common目录,从而找到gaussian.py文件。
一般来说,在 Microsoft Windows 系统上,PYTHONPATH 变量的值是由分号分隔的一系列目录。因此,在发出这个命令后:
set PYTHONPATH=*directory1*;*directory2*;*directory3*
处理 import 语句时,Python 会先查找 *directory1*,然后是 *directory2*,最后是 *directory3* 中的 .py 文件。
Q. 我尝试导入 gaussian 模块,但收到以下错误消息。出了什么问题?
ImportError: No module named gaussian
A. 你没有像上面描述的那��让 gaussian 对 Python 可用。
Q. 我尝试调用 gaussian.pdf(),但收到以下错误。出了什么问题?
NameError: name 'gaussian' is not defined
A. 你忘记了 import gaussian 语句。
Q. 有一个关键字可以识别 .py 文件是一个模块(而不是脚本)吗?
A. 不。从技术上讲,关键是避免使用前面描述的模式中的任意全局代码。如果你避免在一个 .py 文件中使用任意全局代码,以便该 .py 文件可以被导入到其他 .py 文件中,那么我们称之为一个模块。然而,从实用的角度来看,这种观点有一定的概念性飞跃,因为创建一个 .py 文件来运行(也许以后会用不同的数据再次运行),和创建一个 .py 文件以后在未来依赖它,以及为将来的其他人创建一个 .py 文件是完全不同的事情。
Q. 我如何开发一个我已经使用了一段时间的模块的新版本?
A. 谨慎处理。对 API 的任何更改都可能破坏任何客户端程序,所以最好在一个单独的目录中工作。当然,使用这种方法时,你是在使用代码的副本。如果你要对一个有很多客户端的模块进行更改,你就能体会到公司发布软件新版本时面临的问题。如果你只想向一个模块添加一些函数,那就继续:通常不太危险。
Q. 我怎么知道一个实现是否行为正常?为什么不自动检查它是否符合 API?
A. 我们使用非正式规范,因为撰写详细规范与撰写程序没有太大区别。此外,理论计算机科学的一个基本原则是,这样做甚至不能解决基本问题,因为通常没有办法检查两个不同的程序是否执行相同的计算。
Q. 我注意到当我运行本节中的程序时,出现了文件名后缀为 .pyc 的文件。例如,当我发出命令 python gaussiantable.py 时,我注意到 Python 自动创建了一个名为 gaussian.pyc 的文件。这些 .pyc 文件是什么?
A. 如第 1.1 节所述,每当 Python 执行一个程序时,它会将程序转换为一种更适合执行的内部(不可读)形式,称为字节码。当你第一次导入一个模块时,Python 会编译代码并将生成的字节码存储在一个 .pyc 文件中。这使得模块加载更快,因为 Python 不需要每次重新编译它(但这并不会使程序运行更快)。
当一个程序只包含一个文件时,Python 在执行完成后会丢弃文件的字节码。然而,当一个程序由多个文件组成,也就是说,当一个程序由一个客户端文件和模块组成时,Python 不会丢弃为模块生成的字节码。相反,它会将该字节码存储在 .pyc 文件中。
例如,回想一下 gaussiantable.py 依赖于 gaussian.py。所以当你发出命令 python gaussiantable.py 1019 209 时,Python 将 gaussiantable.py 转换为字节码。它也将 gaussian.py 转换为字节码。最终它会丢弃前者;但它会保存后者在名为 gaussian.pyc 的文件中。
Python 的理念是:由于 gaussian.py 在这个程序中被用作模块,很可能将来会在其他程序中作为模块使用。因此,Python 将 gaussian.py 的字节码版本保存在gaussian.pyc中,以避免在模块确实被重用时的翻译开销。
随时删除.pyc文件都是可以的;Python 会在适当时重新生成它们。如果你编辑了一个.py文件,Python 生成了相应的.pyc文件,那么不删除.pyc文件也是可以的,因为 Python 会自动重新生成.pyc文件。
练习
编写一个基于定义 sinh(x) = (e(x) - e(-x))/2 和 cosh(x) = (e(x) + e(-x))/2 的双曲三角函数的模块,其中 tanh(x), coth(x), sech(x), 和 csch(x)的定义方式类似于标准三角函数。
为
stdstats和stdrandom编写一个测试客户端,检查这两个模块中的所有方法是否按预期运行。使用命令行参数n,使用每个stdrandom函数生成n个随机数,并写出它们的统计数据。额外加分:通过与数学分析预期的结果进行比较来证明你得到的结果。开发一个为
stdrandom进行压力测试的客户端。特别关注discrete()。例如,概率是否为非负?是否全部为零?编写一个函数,接受浮点数
ymin和ymax(其中ymin严格小于ymax)以及一个float数组a[]作为参数,并线性缩放a[]中的元素,使它们都在ymin和ymax之间。编写一个 gaussian.py 和
stdstats.py客户端,探索改变均值和标准差对高斯分布曲线的影响。创建一个曲线固定均值和各种标准差的图,另一个曲线固定标准差和各种均值的图。在
stdrandom.py中添加一个函数maxwellBoltzmann(),返回从参数σ的Maxwell-Boltzmann 分布中抽取的随机值。为了产生这样的值,返回三个均值为 0,标准差为σ的高斯随机变量的平方和的平方根。(理想气体中分子的速度具有 Maxwell-Boltzmann 分布。)修改 bernoulli.py,为条形图添加动画效果,在每次实验后重新绘制,以便观察其收敛到正态分布。
修改 bernoulli.py,添加一个命令行参数p,指定有偏硬币出现正面的概率。运行实验,以了解与有偏硬币对应的分布。一定要尝试接近 0 和接��1 的p值。
编写一个模块
matrix.py,为向量和矩阵实现以下 API(参见第 1.4 节):![矩阵 API]()
解决方案:参见 matrix.py。
编写一个
matrix.py的客户端(来自上一个练习),使用以下代码:moves = int(sys.argv[1]) p = stdarray.readFloat2D() ranks = stdarray.create1D(len(p), 0.0) ranks[0] = 1.0 for i in range(moves): ranks = matrix.multiplyVM(ranks, p) stdarray.write1D(ranks)该代码执行与 markov.py(来自第 1.6 节)相同的计算。
在实践中,数学家和科学家使用成熟的库,如
NumPy(或专用矩阵处理语言如Matlab)来执行这些任务,因为它们可能比你自己编写的任何东西更有效、准确和稳健。NumPy 附录描述了如何使用NumPy。编写一个
matrix.py的客户端(来自前两个练习),命名为markovsquaring.py,实现了基于矩阵平方而不是迭代向量-矩阵乘法的版本的 markov.py(来自第 1.6 节)。重新设计随机冲浪者.py(来自第 1.6 节)使用
stdarray和stdrandom模块。部分解决方案:
... p = stdarray.readFloat2D() page = 0 # Start at page 0. hits = stdarray.create1D(n, 0) for i in range(moves): page = stdrandom.discrete(p[page]) hits[page] += 1 ...向
stdrandom.py添加一个函数exp(),它接受一个参数λ,并返回一个从速率为λ的指数分布中随机数。提示:如果x是均匀分布在 0 和 1 之间的随机数,则-ln x / λ是从速率为λ的指数分布中的随机数。
创意练习
Sicherman 骰子。 假设你有两个六面骰子,一个面标有 1、3、4、5、6 和 8,另一个面标有 1、2、2、3、3 和 4。比较这两个骰子的和的每个值发生的概率与标准骰子的概率。使用
stdrandom和stdstats。解决方案:具有这些属性的骰子称为Sicherman 骰子:它们产生与常规骰子相同频率的和(2 的概率为 1/36,3 的概率为 2/36,依此类推)。
Craps. 这里是craps游戏中pass bet的规则:掷两个 6 面骰子,让x为它们的和。
如果x为 7 或 11,则获胜。
如果x为 2、3 或 12,则失败。
否则,重复掷两个骰子,直到它们的和为x或 7。
如果它们的和为x,则获胜。
如果它们的和为 7,则失败。
编写一个模块化程序来估计获胜pass bet的概率。修改你的程序以处理有偏骰子,其中骰子落在 1 上的概率来自命令行,落在 6 上的概率为 1/6 减去该概率,2-5 被假定为等概率。提示:使用
stdrandom.discrete()。动态直方图。 假设标准输入流是一系列浮点数。编写一个程序,从命令行获取一个整数
n和两个浮点数l和r,并使用stdstats绘制一个直方图,显示标准输入流中落入将(l, r)分成n个等大小区间的每个区间中数字的计数。使用你的程序为你之前在本节中的练习 2 的解决方案添加代码,以绘制每个函数产生的数字分布的直方图,从命令行获取n。Tukey 图。 Tukey 图是一种概括直方图的数据可视化,适用于当给定范围内的每个整数与一组
y值相关联时。对于范围内的每个整数i,我们计算所有相关y值的均值、标准差、第 10 百分位数和第 90 百分位数;画一条从第 10 百分位数y值到第 90 百分位数y值的垂直线;然后画一个细长矩形,以均值下方一个标准差到均值上方一个标准差为中心。假设标准输入流是一��列数对,其中每对中的第一个数是一个整数,第二个数是一个双精度值。编写一个stdstats和stddraw客户端,从命令行获取一个整数n,假设标准输入流中的所有整数都在 0 到n-1之间,使用stddraw绘制数据的 Tukey 图。IFS. 尝试使用各种输入来实验 ifs.py 以创建自己设计的图案,如谢尔宾斯基三角形、巴恩斯利蕨或其他示例。你可以从对给定输入进行微小修改开始实验。
IFS 矩阵实现。 编写一个使用
matrix.multiply()(如本节中的一个先前练习中开发的)而不是计算x和y的新值的方程的 ifs.py 版本。压力测试。 编写一个客户端对
stdstats.py进行压力测试。与同学合作,一人编写代码,另一人测试。赌徒困境。 编写一个
stdrandom.py客户端来研究赌徒困境问题(参见第 1.3 节中的 gambler.py 和该节末尾的练习)。注意:为实验定义一个函数比 bernoulli.py 更困难,因为函数不能返回两个值。但请记住,函数可以返回一个包含两个值的单个数组。整数属性模块。 基于本书中考虑的用于计算整数属性的函数,编写一个模块。包括确定给定整数是否为质数的函数;两个整数是否互质;计算给定整数的所有因子;两个整数的最大公约数和最小公倍数;欧拉函数(参见第 2.1 节中的欧拉函数练习);以及你认为可能有用的其他函数。创建一个 API,一个执行压力测试的客户端,以及解决本书前面几个练习的客户端。
投票机。 编写一个
stdrandom.py客户端(具有适当的函数),研究以下问题:假设在一个拥有 1 亿选民的人口中,51%的选民投票给候选人 A,49%的选民投票给候选人 B。然而,投票机容易出错,有 5%的概率给出错误答案。假设错误是独立且随机发生的,5%的错误率足以使紧密选举的结果无效吗?可以容忍多少错误率?扑克分析。 编写一个
stdrandom.py和stdstats.py客户端(具有适当的函数),通过模拟估计在五张扑克牌手中获得一对、两对、三条、满堂和同花的概率。将程序分解为适当的函数,并捍卫你的设计决策。额外加分:将顺子和同花顺添加到可能性列表中。音乐模块。 开发一个基于 playthattunedeluxe.py 中的函数的模块(来自第 2.1 节),你可以用来编写客户端程序来创建和操作歌曲。
动画绘图。 编写一个程序,接受一个命令行参数
m,并在标准输入上生成最近m个浮点数的条形图。使用我们在第 1.5 节中用于bouncingball.py的相同动画技术:擦除、重绘、显示,并稍作等待。每次程序读取一个新数字时,应重新绘制整个图形。由于大部分图像在稍微向左重新绘制时不会改变,因此你的程序将产生一个固定大小窗口动态滑过输入值的效果。使用你的程序绘制一个庞大的时变数据文件,如股票价格。数组绘图模块。 开发自己的绘图函数,改进
stdstats.py中的函数。要有创意!尝试创建一个你认为将来会用到的绘图模块。
2.3 递归
原文:
introcs.cs.princeton.edu/python/23recursion译者:飞龙
从一个函数调用另一个函数的想法立即引出了函数调用自身的可能性。Python 中的函数调用机制支持这种可能性,这被称为递归。递归是一种强大的通用编程技术,是许多至关重要的计算应用的关键,从组合搜索和排序方法(提供信息处理的基本支持(第四章))到用于信号处理的快速傅里叶变换。
你的第一个递归程序
递归的"HelloWorld"程序是实现阶乘函数,对于正整数n,它由以下方程定义:
n! = n × (n-1) × (n-2) × ... × 2 × 1
用for循环计算n!很容易,但更简单的方法是使用以下递归函数,factorial.py 中使用了这种方法:
def factorial(n):
if n == 1:
return 1
return n * factorial(n-1)
你可以说服自己它会产生期望的结果,注意到factorial()在n为 1 时返回 1 = 1!,并且如果它正确计算值
(n-1)! = (n-1) × (n-2) × ... × 2 × 1
然后它正确计算值
n! = n × (n-1)! = n × (n-1) × (n-2) × ... × 2 × 1
我们可以像追踪任何函数调用序列一样追踪这个计算过程。
factorial(5)
factorial(4)
factorial(3)
factorial(2)
factorial(1)
return 1
return 2*1 = 2
return 3*2 = 6
return 4*6 = 24
return 5*24 = 120
我们的factorial()实现展示了每个递归函数所需的两个主要组成部分。
基本情况在不进行任何后续递归调用的情况下返回一个值。这是为了一种或多种特殊输入值,函数可以在没有递归的情况下进行评估。对于
factorial(),基本情况是n = 1。减少步骤是递归函数的核心���分。它将一个(或多个)输入处的函数与另一个(或多个)输入处的函数相关联。此外,参数值序列必须收敛到基本情况。对于
factorial(),减少步骤是n * factorial(n-1),每次调用n减少一次,因此参数值序列收敛到n = 1的基本情况。
数学归纳
递归编程与数学归纳直接相关,这是一种用于证明关于离散函数的事实的技术。通过数学归纳证明涉及整数n的陈述对无限多个n值成立涉及两个步骤。
基本情况是为了证明某些特定值或值的陈述对n(通常为 0 或 1)成立。
归纳步骤是证明的核心部分。例如,我们通常假设一个陈述对小于n的所有正整数都成立,然后利用这个事实来证明它对n也成立。
这样的证明足以表明该陈述对所有n值都成立:我们可以从基本情况开始,并使用我们的证明逐个证明该陈述对每个更大的n值都成立。
欧几里得算法
两个正整数的*最大公约数(gcd)*是能够整除它们的最大整数。例如,102 和 68 的最大公约数是 34,因为 102 和 68 都是 34 的倍数,但没有比 34 更大的整数能够整除 102 和 68。
我们可以使用以下性质高效地计算最大公约数,该性质适用于正整数p和q:
如果 p > q,则 p 和 q 的最大公约数与 q 和 p % q 的最大公约数相同。
euclid.py 中的gcd()函数是一个紧凑的递归函数,其减少步骤基于这个性质。
gcd(1440, 408)
gcd(408, 216)
gcd(216, 24)
gcd(192, 24)
gcd(24, 0)
return 24
return 24
return 24
return 24
return 24
汉诺塔
没有讨论递归就不完整的话题是古老的汉诺塔问题。我们有三根柱子和n个适合放在柱子上的盘子。盘子的大小不同,最初排列在其中一根柱子上,从最大的盘子n到最小的盘子 1。任务是将盘子堆移到另一根柱子上,同时遵守以下规则:
每次只移动一个盘子。
永远不要将一个盘子放在一个较小的盘子上。
为了解决问题,我们的目标是发出一系列指令来移动盘子。我们假设柱子是排成一排的,并且每个移动盘子的指令都指定了它的编号以及是向左还是向右移动。如果一个盘子在左柱上,那么向左移动的指令意味着移到右柱;如果一个盘子在右柱上,那么向右移动的指令意味着移到左柱。
递归提供了我们需要的计划,基于以下想法:首先我们将顶部的n-1 个盘子移动到一个空柱子上,然后我们将最大的盘子移动到另一个空柱子上(这样它就不会干扰较小的盘子),然后我们通过将n-1 个盘子移动到最大的盘子上来完成工作。towersofhanoi.py 程序是该计划的直接实现。
指数时间

有一个传说说,当一群特定的僧侣在一个寺庙里用三根金针上的 64 个金盘解决汉诺塔问题时,世界将会终结。我们可以估计到世界末日的时间(假设传说是真实的)。如果我们定义函数T(n)为towersofhanoi.py发出的移动n个盘子从一个柱子到另一个柱子的指令数量,那么递归代码意味着T(n)必须满足以下方程:
T(n) = 2 T(n - 1) + 1 for n > 1, with T(1) = 1
这样的方程在离散数学中被称为递归关系。我们经常可以使用它们推导出所关心的数量的封闭形式表达式。例如,T(1) = 1,T(2) = 3,T(3) = 7,T(4) = 15。一般来说,T(n) = 2^(n) - 1。
知道T(n)的值,我们可以估计执行所有移动所需的时间。如果僧侣们每秒移动一个盘子,那么完成一个 20 盘子问题将需要超过一周的时间,完成一个 30 盘子问题将需要超过 31 年的时间,完成一个 40 盘��问题将需要超过 348 个世纪的时间(假设他们不犯错误)。64 盘子问题将需要超过 58 亿个世纪的时间。
格雷码
剧作家塞缪尔·贝克特写了一部名为Quad的戏剧,具有以下特点:从一个空舞台开始,角色一个接一个地进入和退出,但舞台上的每个角色子集都只出现一次。这部戏剧有四个角色,有 2⁴ = 16 种不同的四个元素子集;因此得名。贝克特是如何为这部戏剧生成舞台指示的?我们如何为 5 位演员或更多演员做到这一点?
一个n位的格雷码是一个包含 2^(n)个不同的n位二进制数的列表,使得列表中的每个条目与其前一个条目恰好在一位上不同。格雷码直接适用于贝克特的问题,因为我们可以将每一位解释为其位位置对应的整数是否在子集中。将一位的值从 0 改为 1 对应于一个整数进入子集;将一位的值从 1 改为 0 对应于一个整数退出子集。
我们如何生成格雷码?一个递归计划,与我们用于汉诺塔问题的计划非常相似,是有效的。n位二进制反射格雷码的定义如下递归地进行:
n-1 位代码,每个单词前面加 0,然后是
将n-1 位代码按相反顺序排列,每个单词前面加上 1。
0 位代码被定义为空,因此 1 位代码是 0 后跟 1。
经过一些仔细思考,递归定义导致了在 beckett.py 中实现贝克特舞台指示的实现。
递归图形
简单的递归绘图方案可能导致非常复杂的图片。例如,n阶 H 树的定义如下:当n=0 时,基本情况为空。减少步骤是在单位正方形内绘制三条 H 形状的线,四个n-1 阶 H 树,每个 H 形状的顶端连接到 H 的一个顶端,附加条件是n-1 阶 H 树位于正方形的四个象限的中心,尺寸减半。程序 htree.py 接受一个命令行参数n,并使用标准绘图绘制一个n阶 H 树。
![]() |
![]() |
![]() |
![]() |
![]() |
|---|
布朗桥
H 树是分形的一个简单示例:一个几何形状,可以被分成部分,每个部分(大致上)是原始形状的缩小副本。对分形的研究在艺术表达、经济分析和科学发现中起着重要而持久的作用。艺术家和科学家使用它们来构建复杂形状的紧凑模型,这些形状在自然界中出现,并且难以用传统几何描述,如云、植物、山脉、河床、人类皮肤等。经济学家也使用分形来建模经济指标的函数图。
程序 brownian.py 生成一个函数图,近似一个称为布朗桥的简单示例和密切相关的函数。您可以将这个图形看作是连接两点的随机漫步,从(x[0],y[0])到(x[1],y[1]),由几个参数控制。该实现基于中点位移法,这是一个用于在区间[x[0],x[1]]内绘制图形的递归计划。基本情况(当区间大小小于给定容差时)是��制连接两个端点的直线。减少情况是将区间分成两半,然后继续如下:
计算区间的中点(x[m],y[m])。
在中点的
y坐标上加上一个从均值为 0 且给定方差的高斯分布中选择的随机值δ。在子区间上进行递归,通过给定的缩放因子s来减少方差。
曲线的形状由两个参数控制:波动性(方差的初始值)控制图形偏离连接点的直线的距离,赫斯特指数控制曲线的平滑度。我们用H表示赫斯特指数,并在每个递归级别将方差除以 2^(2H)。当H为 1/2(每个级别除以 2)时,标准差在整个曲线上保持恒定:在这种情况下,曲线是一个布朗桥。这些图像显示了由命令python brownian.py 1、python brownian.py .5和python brownian.py .05生成的输出。
递归的陷阱
通过递归,您可以编写简洁而优雅的程序,在运行时引起惊人的失败。
缺少基本情况。这个递归函数应该计算调和数,但缺少一个基本情况:
def H(n):
return H(n-1) + 1.0/n;
如果你调用这个函数,它将不断调用自身而永远不会返回。
不保证收敛。 另一个常见问题是在递归函数中包含一个递归调用来解决一个不比原问题更小的子问题。例如,如果使用任何值而不是 1 调用参数 n 来调用此递归函数,它将进入无限递归循环:
def H(n):
if n == 1:
return 1.0
return H(n) + 1.0/n
过度空间需求。 Python 需要跟踪每个递归调用以按预期实现函数抽象。如果一个函数在返回之前递归调用自身过多次,Python 为此任务所需的空间可能是禁止的。例如,这个递归函数正确计算第 n 个调和数。然而,我们不能用它来计算大的n,因为递归深度与n成正比,这会导致StackOverflowError。
def H(n):
if n == 0:
return 0.0
return H(n-1) + 1.0/n

过度重复计算。 编写一个简单的递归程序来解决问题的诱惑必须始终受到这样的理解的限制,即简单程序可能需要指数时间(不必要地),因为存在过度重复计算。例如,斐波那契数列
0 1 1 2 3 5 8 13 21 34 55 89 144 233 377 ...
由公式F[n] = F[n-1] + F[n-2]定义,其中n ≥ 2,F[0] = 0,F[1] = 1。
一个初学者程序员可能会实现这个递归函数来计算斐波那契数列中的数字:
def fib(n):
if n == 0:
return 0
if n == 1:
return 1
return fib(n-1) + fib(n-2)
然而,这个程序效率极低!例如,考虑计算fib(7) = 13 的函数。它首先计算fib(6) = 8 和fib(5) = 5。为了计算fib(6),它递归计算fib(5) = 5 和fib(4) = 3。事情迅速变得更糟,因为两次计算fib(5)时,它忽略了已经计算过fib(4),依此类推。当计算fib(n)时,这个��序计算fib(1)的次数恰好是F[n]。重复计算的错误成倍增加。任何想象得到的计算机都无法执行那么多次计算。
顺便说一句,一种称为记忆化的系统技术允许我们避免这种陷阱,同时仍然利用计算的紧凑递归描述。在记忆化中,我们维护一个数组,以跟踪我们已经计算的值,这样我们可以返回这些值,并仅对新值进行递归调用。这种技术是动态规划的一种形式,这是一种组织计算的良好技术,如果你学习算法或运筹学课程,你将学到的。
问与答
问: 有没有情况下迭代是解决问题的唯一选择?
答: 不,任何循环都可以用递归函数替代,尽管递归版本可能需要过多的内存。
问: 有没有情况下递归是解决问题的唯一选择?
答: 不,任何递归函数都可以用迭代方式替代。在第 4.3 节中,我们将看到编译器如何通过使用称为栈的数据结构为函数调用生成代码。
问: 我应该更喜欢递归还是迭代?
答: 任何导致更简单、更易理解或更高效代码的方式。
问: 我理解递归代码中过度空间和过度重复计算的担忧。还有其他需要关注的问题吗?
答: 在递归代码中极度谨慎地创建数组。使用的空间量可能会非常迅速地增加,内存管理所需的时间也会增加。
练习
如果你用负值的
n运行factorial()会发生什么?用一个大值,比如 35 呢?编写一个递归程序,计算ln(n!)的值。
给出调用
ex233(6)时写出的整数序列:def ex233(n): if n <= 0: return stdio.writeln(n) ex233(n-2) ex233(n-3) stdio.writeln(n)给出
ex234(6)的值:def ex234(n): if n <= 0: return '' return ex234(n-3) + str(n) + ex234(n-2) + str(n)批评以下递归函数:
def ex235(n): s = ex233(n-3) + str(n) + ex235(n-2) + str(n) if n <= 0: return '' return s解决方案:基本情况永远不会被触发。调用
ex235(3)将导致调用ex235(0)、ex235(-3)、ex235(-6)等,直到发生"超出最大深度"的运行时错误。给定四个正整数
a、b、c和d,解释gcd(gcd(a, b), gcd(c, d))计算的值是什么。用整数和除数的术语解释以下类似欧几里得函数的效果。
def gcdlike(p, q): if q == 0: return p == 1 return gcdlike(q, p % q)解决方案。返回
p和q是否互质。考虑以下递归函数:
def mystery(a, b): if b == 0: return 0 if b % 2 == 0: return mystery(a+a, b//2) return mystery(a+a, b//2) + amystery(2, 25)和mystery(3, 11)的值是多少?给定正整数a和b,描述mystery(a, b)计算的值。用*替换+,用return 1替换return 0回答相同的问题。解决方案:50 和 33。它计算
a*b。如果你用*替换+,它计算a^b。编写一个递归程序
ruler.py,使用stddraw绘制标尺的划分,就像第 1.2 节中的 ruler.py 程序一样。解决以下递归关系,均满足T(1) = 1。假设n是 2 的幂。
T(n) = T(n/2) + 1
T(n) = 2T(n/2) + 1
T(n) = 2T(n/2) + n
T(n) = 4T(n/2) + 3
通过归纳证明,解汉诺塔谜题所需的最小移动次数满足我们递归解决方案使用的移动次数相同的递归关系。
通过归纳证明,上面给出的递归程序在计算
fib(n)时对fib(1)进行了恰好F[n]次递归调用。证明
gcd()的第二个参数在每第二次递归调用时至少减少一半,然后证明gcd(p, q)最多使用 log[2]n次递归调用,其中n是p和q中较大的一个。修改
htree.py���动画显示 H 树的绘制。![动画 H 树]()
接下来,重新排列递归调用的顺序(和基本情况),查看结果动画,并解释每个结果。
创意练习
二进制表示。 编写一个程序,从命令行接受一个正整数n(十进制),并写出它的二进制表示。回想一下,在第 1.3 节中,我们使用了减去 2 的幂的方法。相反,使用以下更简单的方法:反复将 2 除以n,并倒序读取余数。首先,编写一个
while循环执行这个计算,并以错误的顺序写出位。然后,使用递归以正确的顺序写出位。解决方案:见 binaryconverter.py。
A4 纸。 ISO 格式纸张的宽高比是 2 的平方根比 1。A0 格式的面积为 1 平方米。A1 格式是将 A0 垂直切成两等份,A2 是将 A1 水平切成两等份,依此类推。编写一个程序,接受一个命令行参数n,并使用
stddraw显示如何将一张 A0 纸切成 2^(n)份。这里有一个漂亮的A 尺寸格式示意图。排列。 编写一个程序,接受一个命令行参数n,并写出从 a 开始的n!个排列的n个字母(假设
n不大于 26)。n个元素的排列是元素的n!种可能的排序之一。例如,当n=3 时,你应该得到以下输出。不用担心你枚举它们的顺序。bca cba cab acb bac abc解决方案:见 permutations.py。
大小为k的排列。 修改你之前练习的解决方案,使其接受两个命令行参数n和k,并写出包含n个元素中恰好k个的n! / (n-k)!排列。当k=2 且n=4 时,以下是期望的输出。你不需要按任何特定顺序写出它们。
ab ac ad ba bc bd ca cb cd da db dc
解决方案:见 perm.py。
组合。 编写一个程序,接受一个整数命令行参数 n,并写出任意大小的所有 2^(n) 组合。组合是 n 个元素的子集,与顺序无关。例如,当 n = 3 时,你应该得到以下输出。
a ab abc ac b bc c注意,第一个写出的元素是空字符串(大小为 0 的子集)。
解决方案: 参见 combinations.py。
大小为
k的组合。 修改你之前练习的解决方案,使其接受两个命令行参数 n 和 k,并写出所有大小为 k 的 C*(n*, k) = n! / (k! * (n-k)!) 组合。例如,当 n = 5 且 k = 3 时,你应该得到以下输出。abc abd abe acd ace ade bcd bce bde cde解决方案: 参见 comb.py。
汉明距离。 两个长度为 n 的比特串之间的汉明距离等于这两个串中不同的比特数。编写一个程序,从命令行接受一个整数 k 和一个比特串 s,并写出与 s 的汉明距离最多为 k 的所有比特串。例如,如果 k 为 2,s 为 0000,则你的程序应该写出:
0011 0101 0110 1001 1010 1100提示: 选择 s 中的 n 位中的 k 位进行翻转。
递归方块。 编写一个程序来生成以下递归图案。方块大小的比例为 2.2:1。要绘制一个阴影方块,先绘制一个填充的灰色方块,然后是一个未填充的黑色方块。
- |
|
|
|
| - | --- | --- | --- | --- |
- |
|
|
|
| - |
|
|
|
| - |
|
|
|
|
解决方案: 参见 recursivesquares.py 以获取第 a 部分的解决方案。
- |
煎饼翻转。 你有一堆在煎锅上大小不同的 n 块煎饼。你的目标是重新排列这些煎饼,使最大的煎饼在底部,最小的在顶部。你只能翻转顶部的 k 块煎饼,从而颠倒它们的顺序。设计一个方案,通过最多 2n - 3 次翻转将煎饼排列成正确的顺序。
提示: 你可以在这里尝试策略。
格雷码。 修改
beckett.py以写出格雷码,而不仅仅是变化的位位置序列。解决方案: 参见 graycode.py。
汉诺塔变种。 考虑汉诺塔问题的以下变种。有 2n 个递增大小的圆盘存放在三根柱子上。最初,所有奇数大小的圆盘(1, 3, ..., 2n-1)按大小递增的顺序从顶部到底部堆叠在左柱上;所有偶数大小的圆盘(2, 4, ..., 2n)堆叠在右柱上。编写一个程序,为将奇数圆盘移动到右柱和偶数圆盘移动到左柱提供指令,遵守与汉诺塔相同的规则。
汉诺塔动画。 编写一个使用
stddraw的程序,以每秒大约移动一个盘子的速度动画显示解决汉诺塔问题的过程。解决方案: 参见 animatedhanoi.py。
谢尔宾斯基三角形。 编写一个递归程序来绘制 谢尔宾斯基地毯。与
htree.py一样,使用一个命令行参数来控制递归的深度。![谢尔宾斯基三角形]()
![谢尔宾斯基三角形]()
![谢尔宾斯基三角形]()
![谢尔宾斯基三角形]()
![谢尔宾斯基三角形]()
![谢尔宾斯基三角形]()
![谢尔宾斯基三角形]()
![谢尔宾斯基三角形]()
**二项分布。**估计代码将使用的递归调用次数
def binomial(n, k): if (n == 0) or (k < 0): return 1.0 return (binomial(n-1, k) + binomial(n-1, k-1)) / 2.0计算
binomial(100, 50)。开发一个基于记忆化的更好的实现。提示:参见第 1.4 节中的 二项式系数 练习。**一个奇怪的函数。**考虑麦卡锡的 91 函数:
def mcCarthy(n): if n > 100: return n - 10 return mcCarthy(mcCarthy(n+11))确定在不使用计算机的情况下
mcCarthy(50)的值。给出mcCarthy()用于计算此结果所使用的递归调用次数。证明对于所有正整数 n 都会达到基本情况,或者给出一个使该函数进入无限递归循环的 n 值。**Collatz 函数。**考虑以下递归函数在 collatz.py 中,它与一个著名的未解决的数论问题有关,即 Collatz 问题 或 3n + 1 问题。
def collatz(n): stdio.write(str(n) + ' ') if n == 1: return elif n % 2 == 0: collatz(n // 2) else: collatz(3*n + 1)例如,调用
collatz(7)会写出 17 个整数的序列7 22 11 34 17 52 26 13 40 20 10 5 16 8 4 2 1在 17 次函数调用之后。编写一个程序,接受一个命令行参数 m,并返回对于
collatz(n)的递归调用次数最多的 n < m 的值。未解决的问题是没有人知道该函数是否对所有正值的 n 终止(数学归纳法无助,因为其中一个递归调用是针对参数值更大的情况)。开发一个基于记忆化的更好的实现。**递归树。**编写一个程序,接受一个命令行参数 n,并为 n 等于 1、2、3、4 和 8 时生成类似树状递归模式的程序:
![递归树]()
**布朗尼亚岛。**Benoit Mandelbrot 提出了著名问题 英国海岸有多长? 修改
brownian.py以编写一个绘制 布朗尼亚岛 的程序,其海岸线类似于英国的海岸线。修改很简单:首先,将curve()更改为在 x 坐标和 y 坐标上添加高斯;其次,将main()更改为从画布中心点绘制一条曲线回到自身。尝试使用各种参数值,使您的程序产生外观逼真的岛屿。![布朗尼亚岛]()
解决方案:参见 brownianisland.py。
2.4 案例研究:渗流
原文:
introcs.cs.princeton.edu/python/24percolation译者:飞龙
我们通过考虑一个有趣的科学问题的案例研究来结束我们对函数和模块的研究:一个用于研究一种名为渗流的自然模型的蒙特卡罗模拟。
渗流
我们将系统建模为一个n×n站点网格。每个站点都是阻塞或开放的;开放站点最初是空的。一个满站点是一个可以通过一系列相邻(左、右、上、下)开放站点连接到顶行开放站点的开放站点。如果底行有一个满站点,那么我们说系统渗流。如果站点独立设置为以空缺概率**p开放,那么系统渗流的概率是多少?对于这个问题还没有数学解决方案。我们的任务是编写计算机程序来帮助研究这个问题。
脚手架
我们的第一步是选择数据的表示。我们使用一个布尔矩阵表示哪些站点是开放的,另一个布尔矩阵表示哪些站点是满的。我们将设计一个flow()函数,该函数以二维布尔数组isOpen作为参数,该数组指定哪些站点是开放的,并返回另一个二维布尔数组isFull,指定哪些站点是满的。我们还将包括一个percolates()函数,检查flow()返回的数组是否在底部有任何满的站点。
垂直渗流
给定表示开放站点的布尔矩阵,我们如何确定它是否代表一个渗流系统?目前,我们将考虑问题的一个简化版本,我们称之为垂直渗流。简化是将注意力限制在垂直连接路径上。
确定通过某些与顶部垂直连接的路径填充的站点是一个简单的计算。
程序 percolationv.py 是解决仅垂直渗流问题的解决方案。尝试将其标准输入重定向到 test5.txt 或 text8.txt
蒙特卡罗模拟
我们希望我们的代码对任何布尔矩阵都能正常工作。此外,感兴趣的科学问题涉及随机布尔矩阵。为此,我们编写一个函数random(),它接受两个参数n和p,并生成一个随机的n×n布尔数组,其中每个元素为True的概率为p。该函数在 percolationio.py 中定义。
数据可视化
如果我们使用stddraw进行输出,我们可以处理更大的问题实例。因此,我们开发一个draw()函数来可视化布尔矩阵的内容。该函数,连同前述的random()函数和一个测试main()函数,在 percolationio.py 中定义。
% python percolationio.py 10 0.8
程序 visualizev.py 是 percolationv.py 的测试客户端,通过调用percolationio.random()生成随机布尔矩阵,并通过调用percolationio.draw()将其绘制到标准绘图中。它生成如下输出:
% python visualizev.py 20 0.95 1% python visualizev.py 20 0.95 1
估计概率
我们程序开发过程中的下一步是编写代码来估计随机系统(大小为n,站点空缺概率为p)渗透的概率。我们将这个数量称为渗透概率。为了估计其值,我们只需运行一些实验。程序 estimatev.py 将这个计算封装在一个evaluate()函数中。
渗透的递归解决方案
在一般情况下,我们如何测试系统是否渗透,即从顶部开始并以底部结束的任何路径(不仅仅是垂直路径)都可以完成任务?令人惊讶的是,我们可以通过一个基于经典递归方案的紧凑程序来解决这个问题,该方案被称为深度优先搜索。程序 percolation.py 包含一个计算流数组的flow()实现。尝试将其标准输入重定向到 test5.txt 然后再到 text8.txt 运行它
程序 visualize.py 和 estimate.py 与 visualizev.py 和 estimatev.py 完全相同,只是它们是 percolation.py 的客户端,而不是 percolationv.py。visualize.py 程序生成如下输出:
自适应绘图
为了更深入地了解渗透,程序开发的下一步是编写一个程序,根据给定的n值,绘制渗透概率作为站点空缺概率p的函数。立即,我们面临着许多决策。我们应该为多少个p值计算渗透概率的估计?我们应该选择哪些p值?程序 percplot.py 实现了一种递归方法,以相对较低的成本产生一个外观良好的曲线。
% python percplot.py 20% python percplot.py 100
曲线支持这样的假设,即存在一个阈值值(约为 0.593):如果p大于阈值,则系统几乎肯定会渗透;如果p小于阈值,则系统几乎肯定不会渗透。随着n的增加,曲线逐渐接近一个在阈值处从 0 变为 1 的阶跃函数。这种现象被称为相变,在许多物理系统中都存在。
教训
期望出现错误。 您编写的每个有趣的代码片段都将至少有一个或两个错误,如果不是更多。通过在您理解的小测试案例上运行小代码片段,您可以更容易地隔离任何错误,然后在找到错误时更容易地修复它们。
保持模块小。 您一次最多只能关注几十行代码,因此最好在编写代码时将其分解为小模块。

限制交互。 在设计良好的模块化程序中,大多数模块应该只依赖于少数其他模块。特别是,调用大量其他模块的模块需要分成更小的部分。被大量其他模块调用的模块(您应该只有少数)需要特别注意,因为如果您需要更改模块的 API,您必须在所有客户端中反映这些更改。
逐步开发代码。 在实现每个小模块时,您应该运行和调试它。这样,您永远不会一次处理超过几十行不可靠代码。
解决一个更简单的问题。 通常开始时,你会尽可能简单地组合解决给定问题的代码,就像我们在仅垂直渗透的情况下所做的那样。
考虑一个递归解决方案。 递归是现代编程中不可或缺的工具,你应该学会信任它。如果你还没有被 percolation.py 中
flow()方法的简单和优雅所说服,你可能希望尝试开发一个非递归版本。在适当的时候构建工具。 我们的可视化函数
draw()和随机布尔矩阵生成函数random()对许多其他应用程序都很有用,就像 percplot.py 中的自适应绘图函数一样。将这些函数合并到适当的模块中将会很简单。在可能的情况下重用软件。 我们的
stdio、stdrandom和stddraw模块都简化了本节中代码的开发过程。
问答
Q. 编辑 visualize.py 和 estimate.py 以将每个percolation重命名为percolationv(或者我们想要研究的任何模块)似乎很麻烦。有没有办法避免这样做?
A. 是的。最直接的方法是在不同的子目录中保留多个名为percolation.py的文件。然后从一个子目录中复制所需的percolation.py文件到你的工作目录,从而选择一个特定的实现。另一种方法是使用 Python 的import as语句来定义一个标识符来引用模块:
import percolationv as percolation
现在,任何对percolation.percolates()的调用都使用 percolationv.py 中定义的函数,而不是 percolation.py 中定义的函数。在这种情况下,更改实现只涉及编辑源代码的一行。
Q. 那个递归的flow()函数让我感到紧张。我如何更好地理解它在做什么?
A. 运行自己制作的小例子,附带写一个函数调用跟踪的指令。几次运行后,你会对它总是填充与起始点连接的站点感到自信。
Q. 有没有一个简单的非递归方法?
A. 有几种已知的方法执行相同的基本计算。我们将在本书末尾的第 4.5 节重新讨论这个问题。同时,如果你感兴趣,致力于开发flow()的非递归实现肯定是一个有益的练习。
Q. percplot.py 程序似乎需要大量计算才能得到一个简单的函数图。有没有更好的方法?
A. 最好的策略是对阈值进行数学证明,但这个推导一直困扰着科学家。
Q. 不同晶格的渗透阈值是多少?
A. 渗透阈值概率 p*,如果 p < p*,则不存在跨越的集群,如果 p >= p*,则存在一个跨越的集群。
Q. 有相关的论文吗?
A. 参见 Newman 和 Ziff 的A Fast Monte Carlo Algorithm for Site or Bond Percolation。
练习
编写一个程序,接受一个命令行参数n,并创建一个n乘以n的矩阵,其中第i行和第j列的条目设置为
True,如果i和j是互质的,则在标准绘图上显示 n 乘以 n 的布尔数组(参见第 1.4 节中的“互质”练习)。然后,编写一个类似的程序来绘制阶为n的 Hadamard 矩阵(参见第 1.4 节中的“Hadamard 矩阵”创意练习),以及另一个程序来绘制矩阵,其中第i行和第j列的元素设置为True,如果(1+x)(i)(二项式系数)中*xj*的系数是奇数(参见第 1.4 节中的“随机漫步者”创意练习)。您可能会对后者形成的模式感到惊讶。为 percolationio.py 编写一个
write()函数,将阻塞站点写为 1,开放站点写为 0,满站点写为*。给出以下输入时 percolation.py 的递归调用:
3 3 1 0 1 0 0 0 1 1 0编写一个类似于 visualize.py 的 percolation.py 的客户端程序,对命令行参数n进行一系列实验,其中站点空缺概率p从 0 逐渐增加到 1,增量也从命令行中获取。
编写一个程序
percolationd.py,测试有向渗透(通过在递归的_flow()函数中省略最后一个递归调用)。然后使用 percplot.py(适当修改)绘制有向渗透概率作为站点空缺概率的函数的图。编写一个客户端程序,使用 percolation.py 和
percolationd.py,从命令行获取站点空缺概率p,并写入一个系统渗透但不向下渗透的概率估计。进行足够的实验以获得精确到三位小数的估计。描述在没有阻塞站点的系统上使用 percolation.py 时标记站点的顺序。最后一个标记的站点是哪个?递归的深度是多少?
修改 percolation.py 以动画显示流计算,逐个显示填充站点。检查您对上一个练习的答案。
尝试使用 percplot.py 绘制各种数学函数的图形(只需将
estimate.evaluate()的调用替换为评估函数的表达式)。尝试函数sin(x) + cos(10*x),看看图形如何适应振荡曲线,并为您自己选择的三四个函数想出有趣的图形。修改 percolation.py 以动画显示��计算,逐个显示填充站点。检查您对上一个练习的答案。
修改 percolation.py 以计算流计算中使用的递归的最大深度。绘制该数量的期望值作为站点空缺概率p的函数。如果递归调用的顺序被颠倒,您的答案会如何改变?
修改 estimate.py 以产生类似于 bernoulli.py(来自第 2.2 节)产生的输出。额外加分:使用您的程序验证数据是否符合高斯(正态)分布的假设。
修改 percolationio.py、estimate.py、percolation.py 和 visualize.py 以处理m乘以n的网格和m乘以n的布尔矩阵。使用可选参数,如果只指定两个维度中的一个,则m默认为n。
创意练习
**垂直渗透。**证明具有站点空缺概率p的n乘以n渗透系统在垂直方向上渗透的概率为 1 - (1 - p(n))(n),并使用 estimate.py 验证您对各种n值的分析。
矩形渗透系统。 修改本节中的代码,以便你可以研究矩形系统中的渗透。比较宽高比为 2:1 和 1:2 的系统的渗透概率图。
自适应绘图。 修改 percplot.py,从命令行获取其控制参数(间隙容差、误差容差和试验次数)。尝试不同参数值,了解它们对曲线质量和计算成本的影响。简要描述你的发现。
渗透阈值。 编写一个 percolation.py 客户端,使用二分查找来估计阈值(参见第 2.1 节中的“二分查找”创意练习)。
![定向渗透]()
非递归定向渗透。 编写一个非递归程序,通过从顶部到底部移动来测试定向渗透,就像我们的垂直渗透代码一样。基于以下计算来构建你的解决方案:如果当前行中一组连续的开放站点中的任何站点与前一行中的某个满站点相连,则该子行中的所有站点都变为满的。
快速渗透测试。 修改 percolation.py 中递归的
_flow()函数,使其在找到底部一行的站点后立即返回(不再填充更多站点)。提示:使用一个参数done,如果到达底部则为True,否则为False。当运行 percplot.py 时,给出这种改变的性能改进因子的粗略估计。使用n的值,使得程序运行至少几秒钟但不超过几分钟。请注意,除非_flow()中的第一个递归调用是当前站点下方的站点,否则改进是无效的。![键合渗透]()
键合渗透。 编写一个用于研究在网格的边提供连接性的情况下的渗透的模块化程序。也就是说,一条边可以是空的或者是满的,如果存在一条由满边组成的路径从顶部到底部,则系统渗透。注意:这个问题已经被解析,因此你的模拟应该验证这样一个假设,即随着n的增大,渗透阈值趋近于 1/2。
![三角形格点上的键合渗透]()
三角形格点上的键合渗透。 编写一个用于研究三角形格点上键合渗透的模块化程序,其中系统由 2n²个等边三角形组成,这些三角形在一个n×n的菱形网格中紧密排列,如前一个练习中所述,网格的边提供了连接性。每个内部点有六个键合;每个边上的点有四个;每个角点有两个。
三维渗透。 实现模块
percolation3d.py和percolation3dio.py(用于 I/O 和随机生成),以研究三维立方体中的渗透,将本章中研究的二维情况进行泛化。一个渗透系统是一个n×n×n的立方体,其中每个单元立方体以概率p开放,以概率 1-p阻塞。路径可以连接一个开放的立方体与任何共享公共面的开放立方体(六个邻居之一,除了边界)。如果存在一条路径连接底部平面上的任何开放站点和顶部平面上的任何开放站点,则系统渗透。使用类似于 percolation.py 中的递归版本的_flow(),但是有六个递归调用而不是四个。绘制渗透概率与站点空缺概率的图,尽可能使用大的n值。确保逐步开发你的解决方案,正如本节中所强调的那样。生命游戏。 实现一个模块
life.py来模拟康威的生命游戏。考虑一个对应于细胞系统的布尔矩阵,我们称之为活着或死亡。游戏包括检查并可能更新每个细胞的值,取决于其邻居的值(每个方向的相邻细胞,包括对角线)。活细胞保持活着,死细胞保持死亡,但有以下例外:一个有三个活着的邻居的死细胞会变成活细胞。
一个有一个活着的邻居的活细胞会变成死细胞。
一个有超过三个活着的邻居的活细胞会变成死细胞。
用一个滑翔机测试你的程序,这是一个著名的图案,每四代向下和向右移动一次,如下图所示。然后尝试两个相撞的滑翔机。然后尝试一个随机的布尔矩阵。这个游戏已经被广泛研究,与计算机科学的基础有关。
![滑翔机]()
3. 面向对象编程
原文:
introcs.cs.princeton.edu/python/30oop译者:飞龙
在面向对象编程中,我们将一个大型且可能复杂的程序分解为一组相互作用的元素或对象。这个想法源自于对(软件中)建模现实世界实体的概念,如电子、人、建筑物或太阳系,并很容易扩展到对抽象实体的建模,如位、数字、颜色、图像或程序。
如第 1.2 节所讨论的,数据类型是一组值和一组在这些值上定义的操作。在 Python 中,许多数据类型(如int和float)的值和操作是预定义的。在面向对象编程中,我们组合代码来定义新的数据类型。
这种定义新数据类型并操作持有数据类型值的对象的能力也被称为数据抽象,并引导我们进入一种自然扩展了第二章基础的函数抽象风格的模块化编程风格。数据类型允许我们隔离数据以及函数。
3.1 数据类型 描述了如何使用现有数据类型,用于文本处理和图像处理。
3.2 创建数据类型描述了如何使用 Python 的类机制创建用户定义的数据类型。
3.3 设计数据类型考虑了设计数据类型的重要技术,强调 API、封装、不可变性和按合同设计。
3.4 案例研究:N 体��拟提供了一个模拟 n 个粒子运动的案例研究,受牛顿引力定律的影响。
本章中的 Python 程序
下面是本章中使用的 Python 程序和数据文件列表。
参考 程序 描述 数据 3.1.1 potentialgene.py 潜在基因识别 – 3.1.2 chargeclient.py 带电粒子客户端 – 3.1.3 alberssquares.py 阿尔伯斯方块 – 3.1.4 luminance.py 亮度库 – 3.1.5 grayscale.py 将颜色转换为灰度 mandrill.jpg mandrill.png darwin.jpg darwin.png 3.1.6 scale.py 图像缩放 mandrill.jpg mandrill.png darwin.jpg darwin.png 3.1.7 fade.py 渐变效果 mandrill.jpg mandrill.png darwin.jpg darwin.png 3.1.8 potential.py 可视化电势 charges.txt 3.1.9 cat.py 连接文件 in1.txt in2.txt 3.1.10 stockquote.py 股票报价的屏幕抓取 – 3.1.11 split.py 文件分割 djia.csv 3.2.1 charge.py 带电粒子数据类型 – 3.2.2 stopwatch.py 秒表数据类型 – 3.2.3 histogram.py 直方图数据类型 – 3.2.4 turtle.py 海龟图形数据类型 – 3.2.5 koch.py 科赫曲线 – 3.2.6 spiral.py 黄金螺旋 – 3.2.7 drunk.py 醉汉乌龟 – 3.2.8 drunks.py 醉汉乌龟们 – 3.2.9 complex.py 复数数据类型 – 3.2.10 mandelbrot.py 曼德勃罗集 – 3.2.11 stockaccount.py 股票账户数据类型 turing.txt 3.3.1 complexpolar.py 复数(再探讨) – 3.3.2 counter.py 计数器数据类型 – 3.3.3 vector.py 空间向量数据类型 – 3.3.4 sketch.py 素描数据类型 genome20.txt 3.3.5 comparedocuments.py 相似性检测 documents.txt constitution.txt tomsawyer.txt huckfinn.txt prejudice.txt djia.csv amazon.html actg.txt 3.4.1 body.py 引力体数据类型 – 3.4.2 universe.py n 体模拟 2body.txt 3body.txt 4body.txt 2bodytiny.txt
3.1 使用数据类型
原文:
introcs.cs.princeton.edu/python/31datatype译者:飞龙
在本书站点的前两章中,我们的程序仅��于对数字、布尔值和字符串的操作。当然,原因是到目前为止我们遇到的 Python 数据类型 —— int、float、bool和str —— 使用熟悉的操作操作数字、布尔值和字符串。在本章中,我们开始考虑其他数据类型。
在本节中,我们专注于使用现有数据类型的客户端程序,为您提供一些具体的参考点,以便理解这些新概念并展示它们的广泛应用。我们介绍构造函数以从数据类型创建对象,并介绍操作其值的方法。我们考虑操作电荷、颜色、图像、文件和网页的程序 —— 这与我们在前几章中使用的内置数据类型相比是一个很大的飞跃。
方法
方法是与指定对象(以及该对象的类型)相关联的函数。也就是说,方法对应于数据类型操作。

我们可以通过使用变量名,后跟点运算符(.),后跟方法名,后跟由逗号分隔并括在括号中的参数列表来调用(或调用)方法。举个简单的例子,Python 的内置int类型有一个名为bit_length()的方法,因此您可以如下确定int值的二进制表示中的位数:
x = 3 ** 100
bits = x.bit_length()
stdio.writeln(bits)
这段代码将 159 写入标准输出,告诉您 3¹⁰⁰(一个巨大的整数)在二进制表示中有 159 位。
方法调用的语法和行为几乎与函数调用的语法和行为相同。例如,方法可以接受任意数量的参数,这些参数通过对象引用传递,并且方法将一个值返回给其调用者。类似于函数调用,方法调用也是一个表达式。主要区别在于语法:您使用指定对象和点运算符调用方法。在面向对象编程中,我们通常更喜欢方法调用语法而不是函数调用语法,因为它强调了对象的作用。
函数和方法之间的关键区别在于方法与指定对象相关联。您可以将这个指定对象视为传递给函数的一个额外参数,除了普通的方法参数。
字符串处理
通过使用str的经验表明,您不需要知道数据类型是如何实现的才能使用它。您知道str值是字符序列,并且您可以执行连接两个str值的操作以产生一个str结果。
Python 的str数据类型包括许多其他操作,如下所示的 API 中所述。
str API 中的操作可以分为三类:
内置运算符
+,+=,[],[:],in,not in,以及特殊符号和语法特征的比较运算符一个带有标准函数调用语法的内置函数
len()方法
upper()、startswith()、find()等,在 API 中用变量名后跟点运算符区分开来
从现在开始,我们可能考虑的任何 API 都将具有这些操作。接下来,我们依次考虑每一个。
内置运算符。
可以应用于多种数据类型的运算符(或函数)称为多态。您已经在使用+运算符,用于数字,进行字符串连接。API 告诉您,您可以使用[]运算符,用于数组,从字符串中提取单个字符,并使用[:]运算符从字符串中提取子字符串。
内置函数。
Python 还内置了许多多态函数,例如len()函数。多态函数类似于多态运算符,但没有特殊的语法。
方法。
我们包含内置运算符和内置函数以方便使用(并符合 Python 规范),但我们在创建数据类型时的大部分工作都是开发操作对象值的方法,例如upper(),startswith(),find()和str API 中的其他方法。
下表给出了几个简单的字符串处理应用程序示例,展示了 Python 的str数据类型中各种操作的实用性。
程序 potentialgene.py 是一个更为实质性的字符串处理示例。该程序接受 DNA 序列作为命令行参数,并确定它是否对应于一个潜在的基因。教科书提供了详细信息。
用户定义数据类型
作为用户定义数据类型的一个运行示例,我们将考虑一个用于带电粒子的数据类型Charge。特别地,我们感兴趣的是一个使用Coulomb 定律的二维模型,该定律告诉我们,由给定带电粒子引起的某点的电势由V = kq/r表示,其中q是电荷值,r是点到电荷的距离,k = 8.99 × 10⁹ N m²/C²是一个称为静电常数或Coulomb 常数的常数。为了保持一致性,我们使用国际单位制(SI):在这个公式中,N 代表牛顿(力),m 代表米(距离),C 代表库仑(电荷)。当存在多个带电粒子时,任意点的电势是由每个电荷引起的电势之和。
应用程序编程接口。
我们通过在 API 中列出其操作来指定Charge数据类型的行为,将实现的讨论推迟到第 3.2 节。
API 中的第一个条目,与数据类型同名,被称为构造函数。每次调用Charge构造函数都会创建一个新的Charge对象。另外两个条目定义了数据类型的操作。第一个是一个名为potentialAt()的方法,用于计算并返回给定点(x,y)处电荷引起的电势。第二个是内置函数str(),用于返回带电粒子的字符串表示。
文件约定。
定义用户定义数据类型的代码位于.py文件中。按照惯例,我们在不同的.py文件中定义每个数据类型,文件名与数据类型相同(但不大写)。因此,Charge数据类型在名为charge.py的文件中找到。为了编写一个使用Charge数据类型的客户端程序,我们将以下import语句放在客户端.py文件的顶部:
from charge import Charge
请注意,我们与用户定义数据类型一起使用的import语句的格式与我们与函数一起使用的格式不同。
创建对象。
要创建一个来自用户定义数据类型的对象,您调用其构造函数,该构造函数指示 Python 创建一个新的独立对象。您调用构造函数就像调用函数一样,使用数据类型的名称,后跟构造函数的参数,括号括起来,用逗号分隔。例如,Charge(x0, y0, q0)创建一个具有位置(x0, y0)和电荷值q0的新Charge对象,并返回对新对象的引用。
您可以创建任意数量的相同数据类型的对象。回想一下第 1.2 节,每个对象都有自己的标识、类型和值。因此,虽然任何两个对象都驻留在计算机内存中的不同位置,但它们可能是相同类型并存储相同值。
调用方法。
如本节开头所讨论的,我们通常使用变量名来标识要与我们打算调用的方法关联的对象。对于我们的示例,方法调用c1.potentialAt(.20, .50)返回一个浮点数,表示由c1引用的Charge对象在查询点(0.20, 0.50)处的电势。查询点与电荷位置之间的距离为 0.34,因此该电势为 8.99 × 10⁹ × 21.3 / 0.34 = 5.63 × 10¹¹。
字符串表示。
在任何数据类型的实现中,通常值得包括一个将对象值转换为字符串的操作。Python 有一个内置函数str()用于此目的,您从一开始就一直在使用它将整数和浮点数转换为字符串进行输出。由于我们的Charge API 有一个str()实现,任何客户端都可以调用str()来获取Charge对象的字符串表示。对于我们的示例,调用str(c1)返回字符串'21.3 at (0.51, 0.63)'。
这些机制在客户端 chargeclient.py 中总结,该客户端创建两个Charge对象,并计算从命令行中取出的查询点处两个电荷的总电势。
接下来我们考虑几个用户定义数据类型的更多示例。
颜色
颜色是眼睛中由电磁辐射引起的感觉。由于我们经常希望在计算机上查看和操作彩色图像,颜色在计算机图形学中被广泛使用。
为了表示颜色值,我们定义了一个Color数据类型。
Color使用 RGB 颜色模型,其中颜色由三个整数定义,每个整数介于 0 和 255 之间,分别表示颜色的红色、绿色和蓝色(分别)分量的强度。通过混合红色、绿色和蓝色分量获得其他颜色值。Color有一个构造函数,它接受三个整数参数,因此您可以组成代码
red = Color(255, 0, 0)
blue = Color( 0, 0, 255)
创建代表纯红色和纯蓝色的对象。自第 1.5 节以来,我们一直在stddraw中使用颜色,但一直受限于一组预定义颜色,如stddraw.BLACK、stddraw.RED和stddraw.PINK。现在您可以使用数百万种颜色。
程序 alberssquares.py 是一个Color和stddraw客户端,允许您对颜色进行实验。该程序从命令行接受两种颜色,并以 20 世纪 60 年代由色彩理论家约瑟夫·阿尔伯斯开发的格式显示颜色,彻底改变了人们对颜色的看法。
% python alberssquares.py 9 90 166 100 100 100% python alberssquares.py 0 174 239 147 149 252
亮度。
现代显示器(如 LCD��示器、LED 电视和手机屏幕)上图像的质量取决于一种称为单色亮度或有效亮度的颜色属性的理解。亮度的标准公式源自眼睛对红色、绿色和蓝色的敏感性。它是三种强度的线性组合:如果颜色的红色、绿色和蓝色值分别为r、g和b,则其亮度由以下公式定义:
Y = 0.299r + 0.587g + 0.114b
灰度。
RGB 颜色模型具有这样的特性,当三种颜色强度相同时,所得颜色位于从黑色(全 0)到白色(全 255)的灰度范围内。要在黑白报纸(或书籍)上打印彩色照片,我们需要一个将彩色转换为灰度的函数。将彩色转换为灰度的简单方法是用其单色亮度等于其红色、绿色和蓝色值的新颜色替换该颜色。
颜色兼容性。
亮度值在确定两种颜色是否兼容方面也至关重要,即在另一种颜色的背景上打印文本是否可读。一个广泛使用的经验法则是前景和背景颜色之间的亮度差应至少为 128。例如,黑色文本在白色背景上的亮度差为 255,但黑色文本在(书籍)蓝色背景上的亮度差仅为 74。
程序 luminance.py 是一个模块,我们可以用它将颜色转换为灰度,并测试两种颜色是否兼容,例如,当我们在stddraw应用程序中使用颜色时。luminance.py 中的函数说明了使用数据类型组织信息的实用性。使用Color数据类型并将对象作为参数传递使得这些实现比传递三个强度值更简单。如果没有Color数据类型,从函数返回多个值也会变得笨拙且更容易出错。
数字图像处理
我们一直在使用stddraw在计算机屏幕上的窗口中绘制几何对象(点、线、圆、正方形)。计算机显示的基本抽象与数字照片相同,非常简单:数字图像是像素(图片元素)的矩形网格,每个像素的颜色都是单独定义的。数字图像有时被称为光栅或位图图像。
我们在picture.py模块中定义的Picture数据类型实现了数字图像抽象。值集合只不过是Color值的二维数组,操作由此 API 描述:

按照惯例,(0, 0)是最左上角的像素,因此图像的布局与数组的常规顺序相同(相比之下,stddraw的约定是将点(0,0)放在左下角,使得图形的方向与笛卡尔坐标的常规方式相同)。
使用Picture构造函数,您可以通过从.png或.jpg文件中读取图像来创建Picture对象。使用save()方法,您可以将创建的图像保存为.png或.jpg格式;随后可以以查看照片或其他图像的方式查看您创建的图像。此外,stddraw模块支持一个picture()函数,允许您在标准绘图窗口中绘制给定的Picture对象以及线条、矩形、圆等。
灰度。
程序 grayscale.py 是一个过滤器,它从命令行接受一个文件名,并生成该图像的灰度版本。它创建一个新的Picture对象,初始化为彩色图像,然后将每个像素的颜色设置为通过在 luminance.py 中应用toGray()函数计算的灰度值的新Color。尝试在文件 mandrill.jpg、mandrill.png、darwin.jpg 和 darwin.png 上运行它。
% python grayscale.py mandrill.jpg% python grayscale.py darwin.jpg
缩放。
最常见的图像处理任务之一是使图像变小或变大。
程序 scale.py 接受文件名和目标图像的宽度和高度作��命令行参数,并将图像重新缩放到指定大小。尝试在 mandrill.jpg、mandrill.png、darwin.jpg 和 darwin.png 上运行它。
% python scale.py mandrill.jpg 200 200% python scale.py mandrill.jpg 200 100% python scale.py mandrill.jpg 100 200
渐变效果。
程序 fade.py 是一个Picture、Color和stddraw客户端,使用线性插值策略实现淡入淡出效果。它计算n - 1 个中间图像,其中第t个图像中的每个像素都是源图像和目标图像中对应像素的加权平均值。函数blend()实现了插值:源颜色按 1-t/n的因子加权,目标颜色按t/n的因子加权(当t为 0 时,我们有源颜色;当t为n时,我们有目标颜色)。请注意,fade.py 假定图像具有相同的宽度和高度;如果您有宽度和高度不同的图像,可以使用 scale.py 为 fade.py 创建一个或两个图像的缩放版本。尝试在 mandrill.jpg、mandrill.png、darwin.jpg 和 darwin.png 的各种排列上运行它。
% python fade.py mandrill.png darwin.png 5
潜在价值可视化。
图像处理在科学可视化中也很有帮助。程序 potential.py 可视化了一组电荷产生的电位值。它依赖于数据文件 charges.txt。方法的核心计算非常简单:对于每个像素,我们在单位正方形中计算相应的(x, y)值,然后为每个电荷调用potentialAt()来找到由于所有电荷而在该点产生的电位,将返回的值相加。
% python potential.py < charges.txt
输入和输出再次访问

在第 1.5 节中,您学会了如何使用我们的stdio模块读取和写入数字和文本。在使用stdio时,我们依赖于操作系统的管道和重定向机制来访问文件,任何一个程序只能使用一个输入文件和一个输出文件。
在这一部分中,我们定义了数据类型InStream和OutStream,分别用于输入流和输出流。与仅限于一个输入流和一个输出流不同,我们可以轻松地创建多个每种数据类型的对象,将流连接到各种来源和目的地。我们还可以灵活地设置变量来引用这些对象,将它们作为参数传递给函数或方法,或从函数或方法返回值,并创建它们的数组,就像我们操作任何数据类型的对象一样。
输入流数据类型。
我们的数据类型InStream,定义在模块instream.py中,是stdio读取方面的更通用版本,支持从文件和网站以及标准输入流读取数字和文本。这是它的 API:
当您使用带有字符串参数的InStream构造函数时,构造函数首先尝试在本地计算机上找到具有该名称的文件。如果无法找到文件,则假定参数是网站名称,并尝试连接到该网站。(如果不存在这样的网站,它会在运行时引发IOError。)在任一情况下,指定的文件或网站将成为创建的InStream对象的输入源,并且read*()方法将从该流读取输入。
输出流数据类型。
同样,我们的数据类型OutStream,定义在模块outstream.py中,是stdio写入方面的更通用版本,支持将字符串写入各种输出流,包括标准输出和文件。同样,API 指定了与其stdio对应物相同的方法。
通过使用文件名作为参数的单参数构造函数来指定要用于输出的文件。OutStream将此字符串解释为本地计算机上新文件的名称,并将其输出发送到那里。
文件连接和过滤。
程序 cat.py 是InStream和OutStream的一个示例客户端,使用多个输入流将多个输入文件连接成单个输出文件。例如,此命令将文件 in1.txt 和 in2.txt 连接起来创建文件out.txt:
python cat.py in1.txt in2.txt out.txt
屏幕抓取。
程序 stockquote.py 是str和InStream数据类型的客户端。它查询一个网页,提取一些信息,并报告结果 —— 这个过程称为屏幕抓取。具体来说,程序接受一个纽约证券交易所股票符号作为命令行参数,并将其当前交易价格写入标准输出。例如,如果命令行参数是goog(谷歌的纽约证券交易所符号),程序使用InStream构造函数读取网页http://finance.yahoo.com/q?s=goog,使用str方法识别谷歌的股价,并将股价写入标准输出。该程序依赖于 Yahoo 网页的格式;如果 Yahoo 更改其网页格式,我们将需要更改我们的程序。尽管如此,这可能比自己维护数据更方便。
提取数据。
程序 split.py 使用一个InStream对象和多个OutStream对象将 CSV(逗号分隔值)文件拆分为单独的文件,每个文件对应一个逗号分隔的字段。例如,命令
python split.py djia 3
将文件 djia.csv 拆分为文件djia1.txt,djia2.txt和djia3.txt。
内存管理
在 Python 中,我们通过调用构造函数来创建对象。每次创建对象时,Python 都会为该对象保留计算机内存。但是何时以及如何销毁对象,以便其中的内存可以被释放以供重用?我们将简要讨论这个问题。
孤立对象。
将变量绑定到不同对象的能力会导致程序可能创建一个无法再引用的对象。例如,考虑右侧图中的三个赋值语句。在第三个赋值语句之后,c1和c2不仅指向相同的Charge对象(位于(.51, .63),电荷值为 21.3),而且不再有对初始化c2时创建和使用的Charge对象的引用。这样的对象被称为孤立对象。当变量超出范围时,对象也可能成为孤立对象。
对象的内存管理。
程序往往会创建大量的对象,但在任何给定时间只需要其中很���一部分。因此,编程语言和系统需要机制来创建对象(和分配内存),以及在对象成为孤立对象时销毁对象(和释放内存)。大多数编程系统在变量产生时负责为变量分配内存,并在变量超出范围时释放该内存。对象的内存管理更为复杂:Python 在创建对象时知道要为对象分配内存,但无法准确知道何时释放与对象关联的内存,因为程序执行的动态决定了何时对象成为孤立对象,因此应该被销毁。系统无法预测程序将做什么,因此必须监视程序正在做什么并相应采取行动。
在许多语言(如 C 和 C++)中,程序员负责分配和释放内存。这样做既繁琐又容易出错。Python 最重要的特性之一是其自动管理内存的能力。其思想是通过跟踪孤立对象并将它们使用的内存返回到空闲内存池中,使程序员免除管理内存的责任。以这种方式回收内存被称为垃圾回收,Python 的类型系统使其能够高效自动执行此操作。
问与答
Q. 如果我调用一个给定对象未定义的方法会发生什么?
A. Python 在运行时引发AttributeError。
Q. 为什么我们可以使用stdio.writeln(x)而不是stdio.writeln(str(x))来写入一个不是字符串的对象x?
A. 为了方便起见,stdio.writeln()函数在需要字符串对象时会自动调用内置的str()函数。
Q. 我注意到 potential.py 调用stdarray.create1D()来创建一个Charge对象数组,但只提供了一个参数(所需元素的数量)。难道stdarray.create1D()不需要我提供两个参数吗:所需元素的数量和元素的初始值?
A. 如果未指定初始值,stddarray.create1D()和stdarray.create2D()都使用特殊值None,它不指向任何对象。在调用stdarray.create1D()后,potential.py 会将每个数组元素更改为指向一个新的Charge对象。
Q. 我可以用文字或其他表达式调用一个方法吗?
A. 是的,从客户端的角度来看,您可以使用任何表达式来调用方法。当 Python 执行方法调用时,它会评估表达式并在结果对象上调用方法。例如,'python'.upper()返回'PYTHON',(3 ** 100).bit_length()返回 159。然而,您需要小心处理整数字面量 - 例如,1023.bit_length()会引发SyntaxError,因为 Python 将1023.解释为浮点数字面量;相反,您可以使用(1023).bit_length()。
Q. 我可以在一个表达式中链接多个字符串方法调用吗?
A. 是的。例如,表达式s.strip().lower()按预期工作。也就是说,它计算为一个新字符串,该字符串是s的副本,去除了前导和尾随空格,并将所有剩余字符转换为小写。它之所以有效是因为(1)每个方法将其结果作为字符串返回,(2)点运算符是左结合的,因此 Python 从左到右调用方法。
Q. 为什么是红色、绿色和蓝色,而不是红色、黄色和蓝色?
A. 理论上,任何包含每种原色一定量的三种颜色都可以使用,但已经发展出了两种不同的颜色模型:RGB 在电视屏幕、计算机显示器和数码相机上产生良好的颜色,而 CMYK 通常用于印刷页面(请参见第 1.2 节中的“颜色转换练习”)。CMYK 包括黄色(青色、品红色、黄色和黑色)。两种不同的方案是合适的,因为印刷油墨吸收颜色;因此,当有两种不同的油墨时,吸收的颜色更多,反射的颜色更少。相反,视频显示器发射颜色;因此,当有两种不同的彩色像素时,发射的颜色更多。
Q. 创建成千上万个Color对象是否会有问题,就像 grayscale.py 中那样?看起来很浪费。
A. 所有编程语言结构都有一定的成本。在这种情况下,成本是合理的,因为创建Color对象所需的时间与实际绘制图片所需的时间相比微不足道。
Q. 一个数据类型是否可以有两个方法(或构造函数)具有相同的名称,但参数数量不同?
A. 不可以,就像函数一样,不能有两个方法(或构造函数)具有相同的名称。与函数一样,方法(和构造函数)可以使用具有默认值的可选参数。这就是Picture数据类型如何创建具有两个构造函数的幻觉。
Q. 在使用线性滤波器时,每个像素变为其 8 个邻居的加权平均值。当像素由于靠近边界而具有少于 8 个邻居时,我该怎么办?
A. 您可以假设图像是环形的(周期性边界条件),使左边界环绕到右边界,顶部边界环绕到底部边界。
Q. 我在哪里可以下载一些用于图像处理的测试文件?
A. USC SIPI包含标准测试图像(包括 Mandrill)。
练习
编写一个程序,接受一个浮点命令行参数
w,创建四个Charge对象(每个对象的电荷值为 1.0),每个对象距离(0.5, 0.5)在四个基本方向上各为w,并在(0.25, 0.5)处写入电势。解决方案:参见 fourchargeclient.py。
编写一个程序,从命令行获取三个介于 0 和 255 之间的整数,表示颜色的红色、绿色和蓝色值,然后创建并显示一个 256x256 的
Picture颜色。修改 alberssquares.py,以获取指定三种颜色的九个命令行参数,然后绘制六个方块,显示所有具有大方块的 Albers 方块,每个颜色一个,小方块每个颜色不同。
编写一个程序,该程序将以灰度图片文件的名称作为命令行参数,并使用
stddraw绘制每个 256 个灰度强度的频率直方图。编写一个程序,以图片文件的名称作为命令行参数,并水平翻转图像。
编写一个程序,以图片文件的名称作为命令行输入,并创建三个图像——一个只有红色分量,一个只有绿色分量,一个只有蓝色分量。
编写一个程序,该程序以图片文件的名称作为命令行参数,并写入包含所有非白色像素的最小边界框(与x和y轴平行的矩形)的左下角和右上角的像素坐标。
编写一个程序,以图片文件的名称和图像内矩形的像素坐标作为命令行参数;从标准输入中读取
Color值的列表(表示为整数三元组);并作为过滤器,打印出那些矩形中所有像素都是背景/前景兼容的Color值。(这样的过滤器可用于选择用于标记图像文本的颜色。)编写一个名为
isValidDNA()的函数,以字符串作为输入,并仅当它完全由字符 A、C、T 和 G 组成时返回True。编写一个名为
complementWC()的函数,以 DNA 字符串作为参数,并返回其沃森-克里克互补:将 A 替换为 T,C 替换为 G,反之亦然。编写一个名为
palindromeWC()的函数,以 DNA 字符串作为参数,并在该字符串是沃森-克里克互补回文时返回True,否则返回False。沃森-克里克互补回文是一个等于其沃森-克里克互补的字符串的反转的 DNA 字符串。编写一个程序来检查 ISBN 号码是否有效(参见第 1.3 节中的“校验和”练习),考虑到 ISBN 号码可以在任意位置插入连字符。
以下代码片段写入什么内容?
s = 'Hello World' s.upper() s[6:11] stdio.writeln(s)解决方案:'Hello World'。字符串对象是不可变的 —— 字符串方法返回一个具有适当值的新
str对象,但不会更改用于调用它的对象的值。此代码忽略了返回的对象,只是写入原始字符串。要更新s,请写入s = s.upper()和s = s[6:11]。如果字符串
s是字符串t的循环移位,则当字符按任意位置进行循环移位时它们匹配。例如,ACTGACG 是 TGACGAC 的循环移位,反之亦然。检测这种条件在基因组序列研究中很重要。编写一个函数,检查两个给定的字符串s和t是否彼此的循环移位。提示:解决方案是使用in运算符和字符串连接的一行代码。给定表示网站 URL 的字符串 site,编写一个代码片段来确定其域类型。例如,字符串
http://introcs.cs.princeton.edu/python的域类型是edu。编写一个函数,以域名作为参数,并返回反向域(反转句点之间的字符串顺序)。例如,
introcs.cs.princeton.edu的反向域是edu.princeton.cs.introcs。这种计算对于网络日志分析很有用(参见第 4.2 节中的“反向域”创意练习)。以下递归函数返回什么内容?
def mystery(s): n = len(s) if n <= 1: return s a = s[0, n//2] b = s[n//2, n] return mystery(b) + mystery(a)编写 potentialgene.py 的一个版本,找出长 DNA 字符串中包含的所有潜在基因。添加一个命令行参数,允许用户指定潜在基因的最小长度。
编写一个程序,以起始字符串和停止字符串作为命令行参数,并写入给定字符串的所有子字符串,这些子字符串以第一个字符串开头,以第二个字符串结尾,否则不包含这两个字符串。注意:特别注意重叠!
编写一个过滤器,从输入流中读取文本并将其打印到输出流中,删除任何仅由空白组成的行。
修改 potential.py,从命令行接受一个整数n,并在单位正方形中生成n个随机的
Charge对象,其电位值从均值为 50、标准差为 10 的高斯分布中随机抽取。修改 stockquote.py 以在命令行上接受多个符号。
用于 split.py 的示例文件
djia.csv列出了自有记录以来每天道琼斯股市平均价格的日期、最高价格、成交量和最低价格。从书站下载这个文件,并编写一个程序,根据命令行中的速率绘制价格和成交量。编写一个名为
merge.py的程序,它接受一个分隔符字符串,后跟任意数量的文件名作为命令行参数,将每个文件的相应行连接起来,用分隔符分隔,然后将结果写入标准输出,从而执行与 split.py 相反的操作。找一个发布你所在地当前温度的网站,并编写一个名为
weather.py的屏幕抓取程序,这样输入python weather.py后跟上你的邮政编码,就能给你一个天气预报。
创意练习
图片过滤。编写一个名为
rawpicture.py的模块,其中包含用于标准输入和标准输出的read()和write()函数。write()函数以Picture作为参数,并将图片写入标准输出,使用以下格式:如果图片是w-by-h,则写入w,然后h,然后wh个整数三元组,表示像素颜色值,按行主要顺序。read()函数不带参数,并返回一个Picture,它通过从标准输入读取图片来创建,格式如上所述。注意:图片过滤将使用比图片更多的磁盘空间 — 标准格式压缩*这些信息,以便不会占用太多空间。卡玛苏特拉密码。编写一个过滤器,它以两个字符串作为命令行参数(密钥字符串),然后读取标准输入,按照密钥字符串指定的方式替换每个字母,并将结果写入标准输出。这个操作是已知的最早的密码系统之一的基础。密钥字符串的条件是它们必须具有相同的长度,并且标准输入中的任何字母必须在其中一个字符串中。例如,如果输入都是大写字母,密钥是 THEQUICKBROWN 和 FXJMPSVRLZYDG,那么我们制作表格。
T H E Q U I C K B R O W N F X J M P S V L A Z Y D G告诉我们,当将输入复制到输出时,我们应该用 F 替换 T,T 替换 F,H 替换 X,X 替换 H,依此类推。消息通过用其对应的字母替换每个字母来编码。例如,消息 MEET AT ELEVEN 被编码为 QJJF BF JKJCJG。接收消息的人可以使用相同的密钥将消息还原。
解决方案:参见 kamasutra.py。
安全密码验证。编写一个函数,以字符串作为参数,并在满足以下条件时返回
True,否则返回False:至少八个字符长
至少包含一个数字(0-9)
至少包含一个大写字母
至少包含一个小写字母
至少包含一个既不是字母也不是数字的字符
这些检查通常用于网站上的密码。
色彩研究。编写一个程序,显示下面显示的色彩研究,其中给出了与书中使用的 256 个蓝色级别(按行主要形式的蓝色到白色)和灰色级别(按列主要形式的黑色到白色)相对应的 Albers 方块。
![色彩研究]()
解决方案:参见 colorstudy.py。
熵。香农熵度量输入字符串的信息内容,在信息理论和数据压缩中起着基石作用。给定一个包含n个字符的字符串,让f[c]表示字符c出现的频率。量p[c] = f[c] / n是字符c在字符串中出现的概率的估计,熵被定义为所有出现在字符串中的字符的数量-p[c] log[2] p[c]的总和。熵被认为是字符串的信息内容的度量:如果每个字符出现相同次数,熵将达到最小值。编写一个程序,计算并将字符串的熵写入标准输出。在您经常阅读的网页和您最近写的论文上运行您的程序。
最小化电势。编写一个函数,该函数接受具有正电势的
Charge对象数组作为参数,并找到一个点,使得该点的电势与单位正方形中任何位置的最小电势相差不超过 1%。使用Charge对象返回此信息。编写一个测试客户端,调用您的函数以写入文本中给定数据和本节中描述的随机电荷的点坐标和电荷值。幻灯片放映。编写一个程序,该程序以几个图像文件的名称作为命令行参数,并以幻灯片放映的方式显示它们(每两秒显示一个),在图片之间使用淡入淡出效果。
平铺。编写一个程序,该程序以图像文件的名称和两个整数
m和n作为命令行参数,并创建一个m×n的图片平铺。旋转滤镜。编写一个程序,该程序接受两个命令行参数(图像文件的名称和实数θ),并将图像逆时针旋转θ度。要进行旋转,将源图像中的每个像素(s[i], s[j])的颜色复制到由以下公式给出的目标像素(t[i], t[j):
t[i] = (s[i] - c[i])cos θ + (s[j] - c[j])sin θ + c[i] t[j] = (s[i] - c[i])sin θ + (s[j] - c[j])cos θ + c[j] 其中(c[i], c[j])是图像的中心。例如,这些图像展示了 30 度的旋转:
![Mandrill]()
![Mandrill 旋转]()
解决方案: 请查看 rotation.py。
漩涡滤镜。创建漩涡效果类似于旋转,只是角度随着到中心的距离而变化。使用与前一个练习中相同的公式,但将θ计算为(s[i], s[j])的函数,具体来说是π/256 乘以到中心的距离。例如:
![Mandrill]()
![Mandrill 漩涡效果]()
解决方案: 请查看 swirl.py。
波浪滤镜。编写一个类似于前两个练习中的滤镜,创建波浪效果,通过将源图像中的每个像素(s[i], s[j])的颜色复制到目标像素(t[i], t[j),其中t[i] = s[i],t[j] = s[j] + 20 sin(2 π s[j]/64)。添加代码以将振幅(附图中的 20)和频率(附图中的 64)作为命令行参数。尝试不同的参数值。
![Mandrill]()
![Mandrill 波浪效果]()
解决方案: 请查看 wave.py。
玻璃滤镜。编写一个程序,该程序以图像文件的名称作为命令行参数,并应用玻璃滤镜:将每个像素p设置为随机相邻像素的颜色(其像素坐标在p的坐标的 5 个像素内)。例如:
![猴面包树]()
![透过玻璃的猴面包树]()
解决方案:参见 glass.py。
变形。本节中显示的示例图像对于 fade.py 并不完全在垂直方向上对齐(猴面包树的嘴比达尔文的低得多)。修改 fade.py,在垂直维度上添加一个变换,使过渡更加平滑。
聚类。编写一个程序,从命令行接受图片文件的名称,并生成并显示一个用填充圆覆盖兼容区域的图片。首先,扫描图像以确定背景颜色(在超过一半像素中找到的主导颜色)。使用深度优先搜索,如第 2.4 节所述,找到与背景兼容的前景像素的连续集。科学家可以使用这个程序来研究自然场景,如飞行中的鸟类或运动中的粒子。拍摄台球桌上的球的照片,并尝试让您的程序识别球和位置。
数字缩放。编写一个名为
zoom.py的程序,接受一个图像文件的名称和三个数字s、x和y作为命令行参数,并显示一个放大输入图片一部分的图片。这些数字都在 0 和 1 之间,s被解释为一个比例因子,(x, y)被解释为输出图像中心点的相对坐标。使用这个程序来放大您计算机上某个数字图像中的狗或朋友。
3.2 创建数据类型
原文:
introcs.cs.princeton.edu/python/32class译者:飞龙
在上一节中,我们解释了如何在 Python 中使用我们自己的数据类型。在本节中,我们将解释如何实现它们。
在 Python 中,我们使用一个类来实现数据类型。将数据类型实现为 Python 类与将函数模块实现为一组函数并没有太大的不同。主要区别在于我们将值(以实例变量的形式)与方法关联起来,并且每个方法调用都与用于调用它的对象相关联。
数据类型的基本元素
为了说明将数据类型实现为 Python 类的过程,我们现在考虑第 3.1 节��Charge数据类型的实现。
API。
我们在下面重复了 Charge API。我们已经看过 API 作为如何在客户端代码中使用数据类型的规范;现在看它们作为如何实现数据类型的规范。
类。
在 Python 中,我们将数据类型实现为一个类。我们在名为 charge.py 的文件中定义了Charge类。要定义一个类,我们使用关键字class,后跟类名,然后是一个冒号,然后是一系列方法定义。我们的类定义了一个构造函数、实例变量和方法,我们将在接下来详细讨论。
构造函数。
构造函数创建指定类型的对象并返回对该对象的引用。对于我们的示例,客户端代码
c = Charge(x0, y0, q0)
返回一个新的Charge对象,适当初始化。Python 提供了一个灵活和通用的对象创建机制,但我们采用了一个简单的子集,很好地服务于我们的编程风格。具体来说,在这本书站点中,每种数据类型都定义了一个特殊方法__init__(),其目的是定义和初始化实例变量,如下所述。名称前后的双下划线是你的线索,表明它是“特殊的” — 我们很快就会遇到其他特殊方法。
当客户端调用构造函数时,Python 的默认构造过程会创建指定类型的新对象,调用__init__()方法来定义和初始化实例变量,并返回对新对象的引用。在这本书站点中,我们将__init__()称为数据类型的构造函数,即使从技术上讲,它只是对象创建过程的相关部分。

右侧的代码是Charge的__init__()实现。它是一个方法,因此它的第一行是一个签名,由关键字def、它的名称(__init__)、一个参数变量列表和一个冒号组成。按照惯例,第一个参数变量被命名为self。*作为 Python 默认对象创建过程的一部分,当调用__init()__时,self 参数变量的值是对新创建对象的引用。*来自客户端的普通参数变量跟随特殊参数变量self。其余行组成构造函数的主体。本书中的惯例是,__init()__由初始化新创建对象的代码组成,通过定义和初始化实例变量。
实例变量。
数据类型是一组值和在这些值上定义的一组操作。在 Python 中,实例变量实现这些值。实例变量属于类的特定实例——即特定对象。在本书站点中,我们的约定是在构造函数中定义和初始化新创建对象的每个实例变量,并且仅在构造函数中。Python 程序的标准约定是实例变量名称以下划线开头。在我们的实现中,您可以检查构造函数以查看整套实例变量。例如,前一页上的__init__()实现告诉我们Charge有三个实例变量_rx、_ry和_q。当创建对象时,__init__()方法的self参数变量的值是对该对象的引用。就像我们可以使用syntax c.potentialAt()为电荷c调用方法一样,我们也可以使用syntax self._rx为电荷self引用实例变量。因此,Charge的__init__()构造函数中的三行定义和初始化了新对象的_rx、_ry和_q。
对象创建的细节。
右侧的内存图详细描述了当客户端使用代码创建新的Charge对象时发生的精确事件顺序
c1 = Charge(0.51, 0.63, 21.3)
Python 创建对象并调用
__init__()构造函数,将构造函数的self参数变量初始化为引用新创建对象的对象,将其x0参数变量初始化为引用 0.51,将其y0参数变量初始化为引用 0.63,将其q0参数变量初始化为引用 21.3。构造函数在由 self 引用的新创建对象中定义和初始化
_rx、_ry和_q实例变量。构造函数完成后,Python 会自动将对新创建对象的 self 引用返回给客户端。
客户端将该引用分配给
c1。
参数变量x0、y0和q0在__init__()完成时超出作用域,但它们引用的对象仍可通过新对象的实例变量访问。
方法。
要定义方法,我们编写的代码与我们在第二章学习的用于定义函数的代码非常相似,但(重要的是)方法还可以访问实例变量。例如,我们的Charge数据类型的potentialAt()方法的代码如下所示:
第一行是方法的签名:关键字def、方法名称、括号中的参数变量名称和冒号。每个方法的第一个参数变量都命名为self。当客户端调用方法时,Python 会自动将self参数变量设置为引用要操作的对象——调用方法的对象。例如,当客户端使用c.potentialAt(x, y)调用我们的方法时,potentialAt()方法的self参数变量的值被设置为c。客户端的普通参数变量(在本例中为x和y)跟随特殊参数变量self。其余行构成potentialAt()方法的主体。
方法中的变量。
要理解方法的实现,非常重要的是要知道方法通常使用三种变量:
self对象的实例变量方法的参数变量
局部变量
三种变量之间的差异是面向对象编程的关键,并在这个表格中总结如下:
在我们的示例中,potentialAt()使用由self引用的对象的_rx、_ry和_q实例变量,参数变量x和y,以及局部变量COULOMB、dx、dy和r来计算并返回一个值。
方法就是函数。
方法是一种在类中定义并与对象关联的特殊类型的函数。函数和方法之间的关键区别在于方法与指定的对象相关联,并直接访问其实例变量。
内置函数。
Charge API 中的第三个操作是内置函数 str(c)。Python 的约定是自动将此函数调用转换为标准方法调用 c.__str()__。因此,为了支持这个操作,我们实现了特殊方法 __str__(),它使用与调用对象关联的实例变量将所需结果串在一起。
隐私。
客户端应该只通过 API 中的方法访问数据类型。有时,在实现中定义一些辅助方法是方便的,这些方法不打算由客户端直接调用。特殊方法 __str__() 就是一个典型的例子。正如我们在第 2.2 节中看到的私有函数一样,标准的 Python 约定是以下划线开头命名这些方法。下划线开头是一个强烈的信号,告诉客户端不要直接调用该私有方法。类似地,以下划线开头命名实例变量也告诉客户端不要直接访问这些私有实例变量。尽管 Python 没有语言支持来强制执行这些约定,但大多数 Python 程序员将其视为神圣不可侵犯。
charge.py 中 Charge 数据类型的实现展示了我们描述的所有特性,并定义了一个测试客户端。这个图表将 charge.py 中的代码与其特性相关联:
在本节的其余部分,我们将这些基本步骤应用于创建许多有趣的数据类型和客户端。
秒表
程序 stopwatch.py 定义了一个 Stopwatch 类,实现了这个 API:
Stopwatch 对象是一个简化版本的老式秒表。创建时开始计时,可以通过调用 elapsedTime() 方法询问它已经运行了多长时间。
直方图
程序 histogram.py 定义了一个 Histogram 类,用于以不同高度的条形图形式图形地表示数据的分布,这种图表称为直方图。这是它的 API:
Histogram 对象维护一个给定区间内整数值出现频率的数组。它的 draw() 方法将绘图缩放,使最高的条形图紧密地适应画布,然后调用 stdstats.plotBars() 来显示值的直方图。
% python histogram.py 50 .5 100000
海龟图形
想象一个生活在单位正方形中并在移动时绘制线条的海龟。它可以沿直线移动指定的距离,或者可以向左旋转(逆时针)指定的角度。根据这个 API:
当我们创建一个海龟时,我们将它放在指定的点,面向指定的方向。然后,通过给海龟一系列的 goForward() 和 turnLeft() 命令来创建绘图。
例如,要绘制一个三角形,我们在 (0, 0.5) 处创建一个海龟,面向从 x 轴逆时针旋转 60 度的角度,然后指示它向���迈一步,然后逆时针旋转 120 度,再向前迈一步,然后再逆时针旋转 120 度,最后再向前迈一步以完成三角形。
在 turtle.py 中定义的Turtle类是使用stddraw实现的 API,它维护三个实例变量:乌龟位置的坐标和当前面向的方向,以逆时针从x轴(极角)测量的角度。实现这两个方法需要更新这些变量的值,因此Turtle对象是可变的。必要的更新很简单:turnLeft(delta)将 delta 添加到当前角度,goForward(step)将步长乘以其参数的余弦值添加到当前x坐标,将步长乘以其参数的正弦值添加到当前y坐标。Turtle中的测试客户端将整数n作为命令行参数,并绘制具有n个边的正多边形。
% python turtle.py 3% python turtle.py 7% python turtle.py 1000
科赫曲线。
阶数为 0 的科赫曲线是一条直线。要形成阶数为n的科赫曲线,画一条阶数为n - 1 的科���曲线,左转 60 度,画第二条阶数为n - 1 的科赫曲线,右转 120 度(左转-120 度),画第三条阶数为n - 1 的科赫曲线,左转 60 度,画第四条阶数为n - 1 的科赫曲线。这些递归指令立即导致了在 koch.py 中显示的乌龟客户端代码。通过适当的修改,像这样的递归方案已被证明在模拟自然中发现的自相似模式,如雪花,中是有用的。
% python koch.py 0% python koch.py 1% python koch.py 2% python koch.py 3% python koch.py 4
奇迹螺线。
想象一下,乌龟的步长每次前进时都会以微小的恒定因子(接近 1)衰减。我们的图形会发生什么变化?值得注意的是,修改 turtle.py 中的多边形绘制测试客户端以回答这个问题会导致一种被称为对数螺线的图像,这是许多自然环境中都存在的曲线。程序 spiral.py 是这种曲线的实现。该脚本接受三个命令行参数,控制螺旋的形状和性质。
% python spiral.py 3 1 1.0% python spiral.py 3 10 1.2% python spiral.py 1440 10 1.00004% python spiral.py 1440 10 1.0004
对数螺线最早由勒内·笛卡尔于 1638 年描述。雅各布·伯努利对其数学特性感到惊讶,因此将其命名为奇迹螺线(miraculous spiral)。许多人也认为这个精确的曲线在各种自然现象中清晰可见是"奇迹":
鹦鹉螺壳 螺旋星系 暴风云
布朗运动。
想象一只迷失方向的乌龟(再次按照其标准的交替转向和步进规则)在每一步之前随机转向。程序 drunk.py 绘制了这样一只乌龟所走过的路径。1827 年,植物学家罗伯特·布朗通过显微镜观察到浸泡在水中的花粉颗粒似乎以这种随机方式移动,后来被称为 布朗运动,并引发了阿尔伯特·爱因斯坦对物质原子性质的洞察。
% python drunk.py 10000 .01
程序 drunks.py 绘制了许多这样的乌龟,它们都在四处漫步。
% python drunks.py 20 500 .005% python drunks.py 20 1000 .005% python drunks.py 20 5000 .005
乌龟图形最初是由麻省理工学院的西摩·帕帕特在 20 世纪 60 年代作为教育性编程语言 Logo 的一部分开发的。但是乌龟图形并不是玩具,正如我们刚刚在许多科学示例中看到的那样。乌龟图形还有许多商业应用。例如,它是 PostScript 的基础,这是一种用于创建大多数报纸、杂志和书籍的印刷页面的编程语言。
复数
复数是形式为 x + yi 的数,其中 x 和 y 是实数,i 是 -1 的平方根。数 x 被称为复数的 实部,数 y 被称为复数的 虚部。这个术语源于平方根 -1 必须是一个虚数的想法,因为没有实数可以具有这个值。复数是一个典型的数学抽象:无论一个人是否认为从物理上讲得通取 -1 的平方根,复数都帮助我们理解自然界。
Python 语言提供了一个 complex(小写 c)数据类型。在实际应用中,除非你发现自己需要更多的操作,否则你肯定应该利用 complex。然而,由于为复数开发数据类型是面向对象编程的一个典型练习,我们现在考虑自己的 Complex(大写 C)数据类型。这样做将使我们能够考虑围绕数学抽象的数据类型的一些有趣问题,在一个非平凡的环境中。
复数的基本计算所需的操作是通过应用代数的交换律、结合律和分配律(以及恒等式 i² = -1)来对它们进行加法和乘法运算;计算幅度;并根据以下方程式提取实部和虚部:
加法:(x+yi) + (v+wi) = (x+v) + (y+w)i
乘法:(x + yi) * (v + wi) = (xv - yw) + (yv + xw)i
幅度:|x + yi| = (x² + y²)^(1/2)
实部:Re(x + yi) = x
虚部:Im(x + yi) = y
通常,我们从规定数据类型操作的 API 开始。
在 complex.py 中定义的 Complex 类实现了该 API。
特殊方法。
当 Python 在客户端代码中看到表达式a + b时,它将其替换为方法调用a.__add__(b)。类似地,Python 将a * b替换为方法调用a.__mul__(b)。因此,我们只需要为加法和乘法实现特殊方法__add__()和__mul__(),以使加法和乘法按预期运行。这个机制与我们用来支持 Python 内置的str()函数对Charge实现__str__()特殊方法的机制相同,只是算术运算符的特殊方法需要两个参数。上面的 API 包括一个额外的列,将客户端操作映射到特殊方法。我们通常在我们的 API 中省略这一列,因为这些名称是标准的,与客户端无关。Python 特殊方法的列表很广泛——我们将在第 3.3 节进一步讨论它们。
访问此类型对象中的实例变量。
__add__()和__mul__()的实现需要访问两个对象中的值:作为参数传递的对象和用于调用方法的对象(即,被self引用的对象)。当客户端调用a.__add__(b)时,参数变量self被设置为引用与参数a相同的对象,参数变量other被设置为引用与参数b相同的对象。我们可以像往常一样使用self._re和self._im访问 a 的实例变量。要访问b的实例变量,我们使用代码other._re和other._im。由于我们的约定是保持实例变量私有,我们不直接访问另一个类中的实例变量。在同一类中访问另一个对象中的实例变量不违反此隐私政策。
不可变性。
Complex中的两个实例变量在创建Complex对象时设置,并在对象的生命周期内不会更改。也就是说,Complex对象是不可变的。
Mandelbrot 集合。
Mandelbrot 集合是由 Benoit Mandelbrot 发现的一组特定的复数,��有许多迷人的性质。它是一个与 Barnsley 蕨类、Sierpinski 三角形、Brownian 桥、Koch 曲线、醉龟等我们在本书中看到的递归(自相似)模式和程序相关的分形图案。Mandelbrot 集合中的点集不能用单个数学方程描述。相反,它由一个算法定义,因此是一个复杂客户端的完美候选者:我们通过编写一个程序来绘制它来研究这个集合。
<考虑复数序列>z[0],z[1],z[2],...,z[t],...,其中z[t]+1 = (z[t])² + z[0]。例如,这个表格显示了对应于z[0] = 1 + i的序列的前几个条目:
现在,如果序列|z[t]|发散到无穷大,那么z[0]不在 Mandelbrot 集合中;如果序列有界,那么z[0]在 Mandelbrot 集合中。

要可视化 Mandelbrot 集合,我们对复数点进行采样,就像我们对实数点进行采样以绘制实值函数一样。每个复数x + yi对应于平面上的一个点(x,y),因此我们可以按照以下方式绘制结果:对于指定分辨率n,我们在指定的正方形内定义一个n乘n像素网格,并在相应点在 Mandelbrot 集合中时绘制黑色像素,在不在时绘制白色像素。
没有简单的测试可以让我们断定一个点肯定在集合中。但有一个简单的数学测试可以告诉我们肯定一个点不在集合中:如果序列中的任何数字的大小超过 2(例如 1 + 3i),那么该序列肯定会发散。程序 mandelbrot.py 使用这个测试来绘制 Mandelbrot 集的视觉表示。由于我们对该集合的了解并不十分黑白分明,因此在我们的视觉表示中使用灰度。计算的基础是函数mandel(),它接受一个复数参数z0和一个整数参数limit,并计算从z0开始的 Mandelbrot 迭代序列,返回在给定限制下保持大小小于(或等于)2 的迭代次数。即使在我们放大平面的一个小部分时,这个简单程序产生的图像的复杂性也是显著的。
% python mandelbrot.py 512 -.5 0 2% python mandelbrot.py 512 .1015 -.633 .01
使用 mandelbrot.py 生成图像需要对复数值进行数亿次操作。因此,我们使用 Python 的complex数据类型,这肯定比我们刚考虑的Complex数据类型更有效率。
商业数据处理
假设股票经纪人需要维护包含各种股票股份的客户账户。也就是说,经纪人需要处理的值集合包括客户姓名、持有的不同股票数量、每支股票的股票数量和股票代码,以及手头现金。为了处理一个账户,经纪人至少需要在此 API 中定义的操作:
客户信息具有很长的生命周期,并且需要保存在文件或数据库中。为了处理一个账户,客户端程序需要从相应的文件中读取信息;适当处理信息;如果信息发生变化,则将其写回文件,保存以备后用。为了实现这种处理,我们需要一个文件格式和一个内部表示,或者账户信息的数据结构。
文件格式。
现代系统通常使用文本文件,即使是用于数据,以减少对任何一个程序定义的格式的依赖。为简单起见,我们使用直接表示,其中列出了账户持有人的姓名(字符串)、现金余额(浮点数)和持有的股票数量(整数),然后是每支股票的一行,列出了股票数量和股票代码。例如,文件 turing.txt 包含了这些数据:
% more turing.txt
Turing, Alan
10.24
4
100 ADBE
25 GOOG
97 IBM
250 MSFT
数据结构。
要实现一个StockAccount,我们使用以下实例变量:
一个字符串用于账户名
一个浮点数用于手头现金
一个整数用于股票数量
一个字符串数组用于股票代码
一个整数数组用于股票数量
在 stockaccount.py 中定义的StockAccount类实现了这些设计决策。数组_stocks[]和_shares[]被称为并行数组。给定索引i,_stocks[i]给出股票代码,_shares[i]给出账户中该股票的股票数量。valueOf()方法使用 stockquote.py(来自第 3.1 节)从网络获取每支股票的价格。buy()和sell()的实现需要在第 4.4 节介绍的基本机制,因此我们将它们推迟到该部分的练习中。测试客户端接受一个文件名(例如前述的 turing.txt)作为命令行参数,并编写适当的报告。
问答
问: 我可以在与类名无关的文件中定义一个类吗?我可以在一个.py 文件中定义多个类吗?
A. 是的,但出于风格考虑,我们在本章中没有这样做。在第四章中,我们将遇到一些适合使用这些特性的情况。
Q. 如果__init__()在技术上不是构造函数,那是什么?
A. 另一个特殊函数,__new__()。为了创建一个对象,Python 首先调用__new__(),然后调用__init__()。对于本书中的程序,__new__()的默认实现符合我们的目的,因此我们不讨论它。
Q. 每个类都必须有一个构造函数吗?
A. 是的,但如果你不定义构造函数,Python 会自动提供一个默认(无参数)构造函数。按照我们的约定,这样的数据类型将是无用的,因为它没有实例变量。
Q. 为什么在引用实例变量时需要显式使用self?
A. 在语法上,Python 需要一种方式来知道你是在为局部变量还是实例变量赋值。在许多其他编程语言(如 C++和 Java)中,你明确声明数据类型的实例变量,因此没有歧义。self变量还使程序员很容易知道代码是在引用局部变量还是实例变量。
Q. 假设我在我的数据类型中不包括__str__()方法。如果我使用该类型的对象调用str()或stdio.writeln()会发生什么?
A. Python 提供了一个默认实现,返回一个包含对象类型和其标识(内存地址)的字符串。这通常不太有用,所以你通常会想要定义自己的实现。
Q. 除了参数、局部和实例变量之外,类中还有其他种类的变量吗?
A. 是的。回想一下第一章,你可以在全局代码中定义全局变量,在任何函数、类或方法的定义之外。全局变量的作用域是整个.py文件。在现代编程中,我们专注于限制作用域,因此很少使用全局变量(除了不打算重用的小型脚本)。Python 还支持类变量,这些变量在类内部定义,但在任何方法之外。每个类变量在类中的所有对象之间共享;这与实例变量形成对比,实例变量每个对象一个。类变量有一些专门的用途,但我们在本书中不使用它们。
Q. 只有我觉得 Python 的命名约定很复杂吗?
A. 是的,但这也适用于许多其他编程语言。以下是我们在本书中遇到的命名约定的快速总结:
变量名以小写字母开头。
常量变量名由大写字母组成。
实例变量名以下划线和小写字母开头。
一个方法名以小写字母开头。
一个特殊方法名以双下划线和小写字母开头,以双下划线结尾。
一个用户定义的类名以大写字母开头。
一个内置类名以小写字母开头。
脚本或模块存储在以小写字母结尾的文件中,文件名以
.py结尾。
大多数这些约定并不是语言的一部分,尽管许多 Python 程序员将其视为语言的一部分。你可能会想:如果它们如此重要,为什么不将其纳入语言中呢?好问题。然而,一些程序员对这些约定非常热衷,你很可能会在某一天遇到一个坚持让你遵循某种风格的老师、主管或同事,所以最好跟着大流。事实上,许多 Python 程序员使用下划线而不是大写字母来分隔多个单词的变量名,更喜欢is_prime和hurst_exponent而不是isPrime和hurstExponent。
Q. 我如何为complex数据类型指定文字?
答: 将字符j附加到数值文字产生一个虚数(其实部为零)。你可以将这个字符添加到数值文字中以产生一个复数,如3 + 7j。在某些工程学科中,选择j而不是i是常见的。请注意,j不是一个复数文字 —— 相反,你必须使用1j。
问: mandelbrot.py 程序创建了大量的complex对象。所有这些对象创建的开销会减慢速度吗?
答: 是的,但不至于使我们无法生成我们的图表。我们的目标是使我们的程序易于阅读、易于组合和易于维护。通过复数抽象限制范围有助于我们实现这一目标。如果出于某种原因需要显著加快 mandelbrot.py 的速度,你可以考虑绕过复数抽象,使用一个不是对象的低级语言。一般来说,Python 并不针对性能进行优化。我们将在第四章重新讨论这个问题。
问: 为什么在 complex.py 中的__add__(self, other)方法可以引用参数变量other的实例变量?这些实例变量不应该是私有的吗?
答: Python 程序员认为隐私是相对于特定类而不是特定对象的。因此,一个方法可以引用同一类中任何对象的实例变量。Python 没有“超级私有”命名约定,只能引用调用对象的实例变量。然而,访问其他对象的实例变量可能有点风险,因为一个粗心的客户端可能传递一个不是Complex类型的参数,这样我们就会(不知情地)访问另一个类中的实例变量!对于可变类型,我们甚至可能(不知情地)修改或创建另一个类中的实例变量!
问: 如果方法真的是函数,我可以使用函数调用语法调用一个方法吗?
答: 是的,你可以将类中定义的函数称为方法或普通函数调用。例如,如果c是Charge类型的对象,则函数调用Charge.potentialAt(c, x, y)等同于方法调用c.potentialAt(x, y)。在面向对象编程中,我们更喜欢方法调用语法,以突出所述对象的角色,并避免将类名硬编码到函数调用中。
练习

考虑下面显示的(轴对齐)矩形的数据类型实现,它用矩形的中心点坐标、宽度和高度表示每个矩形。组合一个这种数据类型的 API,并填写
perimeter()、intersects()、contains()和draw()的代码。注意:将重合的线视为相交,因此,例如a.intersects(a)为 True,a.contains(a)为 True。class Rectangle: # Construct self with center (x, y), width w, and height h. def __init__(self, x, y, w, h): self._x = x self._y = y self._width = w; self._height = h; # Return the area of self. def area(self): return self._width * self._height # Return the perimeter of self. def perimeter(self): ... # Return True if self intersects other, and False otherwise. def intersects(self, other): ... # Return True if other is completely inside of self, and False # otherwise. def contains(self, other): ... # Draw self on stddraw. def draw(self): ...为
Rectangle编写一个测试客户端,接受三个命令行参数n、lo和hi;在单位正方形中生成宽度和高度均匀分布在lo和hi之间的n个随机矩形;将这些矩形绘制到标准绘图中;并将它们的平均面积和平均周长写入标准输出。将代码添加到你之前练习中的测试客户端代码中,计算相交并相互包含的矩形对的平均数量。
开发一个实现你之前练习中的
RectangleAPI 的实现,表示矩形的坐标为它们的左下角和右上角的坐标。不要更改 API。
以下代码有什么问题?
class Charge:
def __init__(self, x0, y0, q0):
_rx = x0 # Position
_ry = y0 # Position
_q = q0 # Charge
...
解决方案。构造函数中的赋值语句创建了本地变量_rx、_ry和_q,它们从参数变量中分配值,但从未被使用。它们在构造函数执行完毕时消失。相反,构造函数应该通过在每个变量前加上self后跟点运算符来创建实例变量,如下所示:
class Charge:
def __init__(self, x0, y0, q0):
self._rx = x0 # Position
self._ry = y0 # Position
self._q = q0 # Charge
...
在 Python 中,下划线并不是严格要求的,但我们在本书中遵循这个标准 Python 约定,以表明我们的意图是将实例变量设为私有的。
创建一个表示地球上位置的数据类型
Location,使用纬度和经度。包括一个distanceTo()方法,使用大圆距离计算距离(参见第 1.2 节中的大圆练习)。Python 提供了一个数据类型
Fraction,定义在标准模块fractions.py中,实现有理数。实现自己版本的该数据类型。具体地,为有理数数据类型开发以下 API 的实现:![Rational 的 API]()
使用 euclid.py 中定义的
euclid.gcd()(来自第 2.3 节)确保分子和分母永远没有任何公共因子。包括一个测试客户端,测试所有您的方法。区间被定义为一条线上大于等于
left且小于等于right的所有点的集合。特别地,right小于left的区间为空。组合一个实现以下 API 的数据类型Interval。包括一个测试客户端,是一个过滤器,从命令行获取一个浮点数x,并将包含x的所有输入的区间(每个由一对浮点数定义)写入标准输出,并将相互交叉的所有区间对写入标准输出。![Interval 的 API]()
开发一个实现您的
RectangleAPI(在本节的先前练习中)的实现,利用Interval(在本节的先前练习中)来简化和澄清代码。组合一个数据类型
Point,实现以下 API。包括一个自己设计的客户端。![Point 的 API]()
添加到
Stopwatch的方法,如 stopwatch.py 中定义的,允许客户端停止和重新启动秒表。使用
Stopwatch比较使用for循环计算谐波数(如第 1.3 节所示)的成本,与使用第 2.3 节中给出的递归方法相比。修改 turtle.py 中的测试客户端,以便为奇数
n生成星星。组合一个版本的 mandelbrot.py,使用
Complex代替 Python 的complex。然后使用Stopwatch(如 stopwatch.py 中定义的)来计算两个程序的运行时间比率。修改 class
Complex中的__str__()方法 ,使其以传统格式写出复数。例如,它应该将值 3 - i写为3 - i,而不是3.0 + -1.0i,将值 3 写为3,而不是3.0 + 0.0i,将值 3i写为3i,而不是0.0 + 3.0i。组合一个
Complex客户端,从命令行获取三个浮点数a、b和c,并写出ax² + bx + c的复数根。编写一个
Complex客户端Roots,从命令行获取两个浮点数a和b以及一个整数n,并写出a + bi的第n个根。注意:如果您不熟悉复数取根的操作,请跳过此练习。实现对
ComplexAPI 的以下添加:![Complex 的 API(续)]()
包括一个测试客户端,测试所有您的方法。
找到一个
complex数,使得mandel()(来自 mandelbrot.py)返回大于 100 的数,然后放大该数。
创意练习
可变电荷。修改 charge.py 中定义的
Charge类,使得电荷值q0可以改变,通过添加一个接受浮点参数并将给定值加到q0的方法increaseCharge()。然后,编写一个客户端,初始化一个数组:a = stdarray.create1D(3) a[0] = Charge(.4, .6, 50) a[1] = Charge(.5, .5, -5) a[2] = Charge(.6, .6, 50)然后显示通过将计算图片的代码包装在类似以下循环中,逐渐减少
a[1]的电荷值的结果:for t in range(100): # Compute the picture p. stddraw.clear() stddraw.picture(p) stddraw.show(0) a[1].increaseCharge(-2.0)复杂计时。编写一个
Stopwatch客户端(参见 stopwatch.py),比较使用complex和直接操作两个浮点值的代码的成本,用于执行 mandelbrot.py 中的计算任务。具体来说,创建一个仅执行计算的 mandelbrot.py 版本(删除引用Picture的代码),然后创建一个不使用complex的程序版本,然后计算运行时间的比率。四元数。1843 年,威廉·哈密尔顿爵士发现了一种称为四元数的复数扩展。四元数是一个向量a = (a[0], a[1], a[2], a[3]),具有以下操作:
幅度:|a| = (a[0]² + a[1]² + a[2]² + a[3]²)^(1/2)。
共轭:a的共轭是(a[0], -a[1], -a[2], -a[3])。
逆:a^(-1) = (a[0] /|a|, -a[1] /|a|, -a[2] /|a|, -a[3] /|a|)。
和:a + b = (a[0] + b[0], a[1] + b[1], a[2] + b[2], a[3] + b[3])。
积:a * b = (a[0] b[0] - a[1] b[1] - a[2] b[2] - a[3] b[3], a[0] b[1] - a[1] b[0] + a[2] b[3] - a[3] b[2], a[0] b[2] - a[1] b[3] + a[2] b[0] + a[3] b[1], a[0] b[3] + a[1] b[2] - a[2] b[1] + a[3] b[0])。
商:a / b = ab^(-1)。
创建一个四元数数据类型和一个测试客户端,测试所有你的代码。四元数将三维空间中的旋转概念扩展到四维空间。它们被用于计算机图形学、控制理论、信号处理和轨道力学。
龙曲线。编写一个递归客户端
dragon.py,使用Turtle(在 turtle.py 中定义)绘制龙曲线(参见第 1.2 和 1.5 节的练习)。这些曲线最初由三位 NASA 物理学家发现,后来在 20 世纪 60 年代由马丁·加德纳广泛传播,并在迈克尔·克莱顿的书籍和电影《侏罗纪公园》中使用。这个练习可以用非常紧凑的代码解决,基于一对直接从第 1.2 节练习中的定义派生的相互作用递归函数。其中一个函数dragon()应该按预期绘制曲线;另一个函数nogard()应该以相反顺序绘制曲线。![]()
![]()
![]()
![]()
![]()
![]()
![]()
![]()
![]()
![]()
希尔伯特曲线。填充空间曲线是一个连续曲线,在单位正方形中通过每个点。组成
Turtle的递归客户端(如 turtle.py 中定义的)来生成这些递归模式,这些模式接近 19 世纪末数学家大卫·希尔伯特定义的填充空间曲线。参见前一个练习。您需要一对方法:hilbert(),遍历希尔伯特曲线,和treblih(),以相反顺序遍历希尔伯特曲线。![一阶希尔伯特曲线]()
![二阶希尔伯特曲线]()
![三阶希尔伯特曲线]()
![四阶希尔伯特曲线]()
![五阶希尔伯特曲线]()
Gosper 岛。组成一个
Turtle的递归客户端(如 turtle.py 中定义的),生成这些递归模式。![]()
![]()
![]()
![]()
![]()
数据分析。为运行实验而组成一种数据类型,其中控制变量是范围为 0, n)的整数,而因变量是浮点数。例如,研究接受整数参数的程序的运行时间将涉及这样的实验。Tukey 图是一种可视化此类数据统计的方法(请参阅第 2.2 节中的Tukey 图练习)。实现以下 API:
![数据 API
您可以使用
stdstats中的函数进行统计计算和绘制图表。使用stddraw,以便客户端可以为plot()和tukeyPlot()使用不同的颜色(例如,所有点使用浅灰色,Tukey 图使用黑色)。编写一个测试客户端,绘制运行实验的结果(渗透概率),其中 percolation.py(来自第 2.4 节)的网格大小逐渐增加。元素。为元素周期表中的��目组成一个数据类型
Element。包括元素、原子序数、符号和原子量的数据类型值,以及每个值的访问器方法。然后,创建一个数据类型PeriodicTable,从文件中读取值以创建Element对象数组,并响应标准输入上的查询,以便用户可以输入分子方程式如H2O,程序通过打印分子量来回应。为每种数据类型开发 API 和实现。文件 elements.csv 包含程序应该读取的数据。包括元素、原子序数、符号和原子量的字段。(忽略沸点、熔点、密度(kg/m3)、汽化热(
kJ/mol)、熔化热(kJ/mol)、热导率(W/m/K)和比热容(J/kg/K)的字段,因为并非所有元素都知道这些值)。该文件采用 CSV 格式(字段由逗号分隔)。股票价格。文件 djia.csv 包含道琼斯工业平均指数历史上所有收盘股价,采用逗号分隔值格式。组成一个数据类型
Entry,可以保存表中的一个条目,其中包括日期、开盘价、当日最高价、当日最低价、收盘价等值。然后,组成一个数据类型Table,读取文件以构建Entry对象数组,并支持计算不同时间段内的平均值的方法。最后,创建有趣的Table客户端来生成数据的图表。发挥创造力:这条路已经被走过很多次。牛顿法的混沌。多项式f(z) = z⁴ - 1 有四个根:1,-1,i和-i。我们可以在复平面上使用牛顿法找到这些根:z[k+1] = z[k] - f(z[k])/f'(z[k])。这里,f(z) = z⁴ - 1,f'(z) = 4z³。该方法收敛到四个根中的一个,取决于起始点z[0]。编写一个名为
Newton的Complex客户端(如 complex.py 中定义的),它接受一个命令行参数n,通过将像素映射到以原点为中心、大小为 2 的正方形中的复平面上的复点,并根据相应点收敛到哪个四个根(如果 100 次迭代后没有收敛则为黑色)将n乘以n的Picture像素涂成白色、红色、绿色或蓝色。![牛顿法]()
等势面。等势面是所有具有相同电势 V 的点的集合。给定一组点电荷,通过绘制等势面(也称为等势线图)来可视化电势是很有用的。编写一个名为
equipotential.py的程序,通过计算每个像素点的电势并检查相应点的电势是否在 5V 的倍数的 1 像素范围内来绘制每 5V 一条线。注意:通过将分配给每个像素的颜色值混合,而不是让它们与灰度值成比例,可以很容易地近似解决这个练习,具体方法请参考这里(来自第 3.1 节)。例如,通过在创建Color之前插入上面的代码,可以创建附带的图像。解释为什么它有效,并尝试使用自己的版本进行实验。![电势等势面]()
![电势等势面]()
彩色曼德博图。创建一个包含 256 个整数三元组的文件,表示有趣的
Color值,然后使用这些颜色而不是灰度值来绘制 mandelbrot.py 中的每个像素。读取值以创建一个包含 256 个Color值的数组,然后使用mandel()的返回值索引到该数组。通过在集合的各个位置尝试不同的颜色选择,可以产生令人惊叹的图像。mandel.txt 是一个示例。% python mandelbrot.py -1.5 -1.0 2.0 2.0% python mandelbrot.py 0.10259 -0.641 0.0086 0.0086![曼德博集]()
![曼德博集]()
茱莉亚集。对于给定的复数c,茱莉亚集是与曼德博函数相关的一组点。我们不��固定z而变化c,而是固定c而变化z。那些使修改后的曼德博函数保持有界的点属于茱莉亚集;那些使序列发散到无穷大的点不属于该集合。所有感兴趣的点z都位于以原点为中心的 4 乘 4 的方框内。对于c的茱莉亚集是连通的,当且仅当c在曼德博集中时!编写一个名为
colorjulia.py的程序,它接受两个命令行参数a和b,并使用前面练习中描述的颜色表方法为c = a + bi绘制一个彩色版本的茱莉亚集。python colorjulia.py -1.25 0.00python colorjulia.py-0.75 0.10![茱莉亚集]()
![茱莉亚集]()
最大赢家和最大输家。编写一个
StockAccount的客户端(如在 stockaccount.py 中定义),该客户端构建一个StockAccount对象数组,计算每个账户的总价值,并为价值最大和最小的账户编写报告。假设账户信息保存在一个文件中,该文件依次包含每个账户的信息,格式如本页前面描述的那样。
3.3 设计数据类型
原文:
introcs.cs.princeton.edu/python/33design译者:飞龙
在本节中,我们将重点放在开发 API 作为任何程序开发的关键步骤上。我们需要考虑各种替代方案,了解它们对客户端程序和实现的影响,并完善设计以在客户端需求和可能的实现策略之间取得适当平衡。
设计 API
在第 3.1 节中,我们编写了使用 API 的客户端程序;在第 3.2 节中,我们实现了 API。现在我们考虑设计 API 的挑战。
标准。
通过考虑其他领域,很容易理解遵循 API 的重要性。从铁路轨道,到螺纹螺母,到 MP3 和 DVD,到无线电频率,到互联网标准,我们知道使用共同的标准接口能够实现技术的最广泛使用。通过使用 API 将客户端与实现分离,我们为我们组合的每个程序获得标准接口的好处。
规范问题。
我们针对数据类型的 API 是一组方法,以及简短的英语描述这些方法应该做什么。理想情况下,一个 API 应该清晰地表达所有可能参数的行为,包括副作用,然后我们会有软件来检查实现是否符合规范。不幸的是,来自理论计算机科学的一个基本结果,即规范问题,表明这个目标实际上是不可能实现的。因此,我们转而采用带有示例的非正式描述,比如围绕我们的 API 的文本。
宽接口。
宽接口是具有过多方法的接口。在设计 API 时要遵循的一个重要原则是避免宽接口。
从客户端代码开始。
开发数据类型的主要目的之一是简化客户端代码。因此,在设计 API 时,从一开始就关注客户端代码是有意义的。甚至更好的是组合两个客户端。从客户端代码开始是确保开发实现值得的一种方式。
避免对表示的依赖。
通常在开发 API 时,我们心中有一个表示。毕竟,数据类型是一组值和在这些值上定义的一组操作,并且在不了解值的情况下谈论操作并没有太多意义。但这与了解值的表示是不同的。数据类型的一个目的是通过允许客户端避免对特定表示的细节和依赖来简化客户端代码。
API 设计中的陷阱。
一个 API 可能太难实现,意味着难以开发或不可能开发的实现,或者太难使用,导致客户端代码比没有 API 更复杂。一个 API 可能太窄,省略了客户端需要的方法,或者太宽,包含大量任何客户端都不需要的方法。一个 API 可能太一般化,提供没有用的抽象,或者太具体,提供过于详细或过于分散的抽象。这些考虑有时总结为座右铭:为客户端提供他们需要的方法,而不提供其他方法。
封装
通过隐藏信息将客户端与实现分离的过程被称为封装。实现的细节对客户端保持隐藏,实现没有办法知道客户端代码的细节,甚至可能是未来创建的。我们使用封装来实现模块化编程,促进调试,并澄清程序代码。这些原因是相互联系的(设计良好的模块化代码比完全基于内置类型的代码更容易调试和理解)。
模块化编程。
模块化编程成功的关键在于保持模块之间的独立性。我们通过坚持 API 是客户端和实现之间唯一的依赖点来实现这一点——数据类型实现代码可以假定客户端除了 API 之外一无所知。
更改 API。
当我们使用标准模块时,我们经常会获得封装的好处。例如,Python 的新版本通常可能包括各种数据类型或定义函数的模块的新实现。改进数据类型实现的动机非常强烈且持续,因为所有客户端都有可能从改进的实现中受益。然而,Python 的 API 很少改变。当发生更改时,整个 Python 社区都会付出代价——每个人都必须更新他们的客户端。因此,一旦有大量客户端使用一个模块,就尽量不要更改其 API。
更改实现。 ![极坐标表示]()
考虑在 complexpolar.py 中定义的Complex类。它与 complex.py(来自第 3.2 节)中定义的Complex类具有相同的名称和 API,但使用不同的复数表示。complex.py 中定义的Complex类使用笛卡尔表示,其中实例变量_re和_im表示复数为x + yi。complexpolar.py 中定义的Complex类使用极坐标表示,其中实例变量_r和_theta表示复数为r(cos θ + i sin θ)。在这种表示中,我们将r称为幅值,θ称为极角。极坐标表示是有趣的,因为在极坐标表示中,对复数的某些操作更容易执行。加法和减法在笛卡尔表示中更容易;乘法和除法在极坐标表示中更容易。封装的思想是我们可以在不改变客户端代码的情况下将其中一个程序替换为另一个程序(无论出于何种原因),只需将import语句更改为使用complexpolar而不是complex。
私有。
许多编程语言提供支持以强制实现封装。例如,Java 提供了private可见性修饰���。当您将实例变量(或方法)声明为私有时,您使得任何客户端(另一个模块中的代码)无法直接访问修饰符所涉及的实例变量(或方法)。Python 没有提供private可见性修饰符,这意味着客户端可以直接访问所有实例变量、方法和函数。然而,Python 编程社区提倡一个相关的约定:如果一个实例变量、方法或函数的名称以下划线开头,那么客户端应该将该实例变量、方法或函数视为私有。通过这种命名约定,客户端被告知不应直接访问以这种方式命名的实例变量、方法或函数。
在这个书站中,我们总是在我们的类中将所有实例变量设为私有的。我们强烈建议您也这样做——没有理由从客户端直接访问实例变量。
限制错误的可能性。
封装还帮助程序员确保他们的代码按预期运行。例如,在 2000 年总统选举中,阿尔·戈尔在佛罗里达州沃卢西亚县的一台电子投票机上收到了负 16,022 票。计数器变量在投票机软件中没有被正确封装!要理解问题,请考虑 counter.py,它根据这个 API 定义了一个简单的Counter类:
这种抽象在许多情况下都很有用,比如电子投票机。它封装了一个单一的整数,并确保唯一可以对该整数执行的操作是加一。因此,它永远不会变为负数。适当的封装远非是解决投票安全问题的完整解决方案,但却是一个很好的开始。
代码清晰度。
精确指定数据类型可以改善设计,因为它导致客户端代码可以更清晰地表达其计算。在第 3.1 节和第 3.2 节中,你已经看到了许多这样的客户端代码示例,从带电粒子到图片再到复数。良好设计的关键之一是观察到使用适当的抽象组合的代码几乎可以自我说明。
不可变性
如果一个数据类型的对象一旦创建就无法更改其数据类型值,则该数据类型的对象是不可变的。不可变数据类型,比如 Python 字符串,是所有该类型对象都是不可变的。相比之下,可变数据类型,比如 Python 列表/数组,是其对象的值被设计为可以改变的。在本章考虑的数据类型中,Charge、Color和Complex都是不可变的,而Picture、Histogram、Turtle、StockAccount和Counter都是可变的。是否使数据类型不可变是一个基本的设计决策,取决于手头的应用。
不可变数据类型。
许多数据类型的目的是封装不会改变的值。例如,一个程序员实现一个Complex客户端可能合理地期望组合代码z = z0,从而设置两个变量引用相同的Complex对象,就像对浮点数或整数一样。但是,如果Complex是可变的,并且在赋值z = z0之后被引用的对象发生变化,那么被z0引用的对象也会发生变化(它们是别名,或者都是对同一对象的引用)。从概念上讲,改变z的值将改变z0的值!这种意外的结果,称为别名错误,对许多初学者来说是一个惊喜,这是面向对象编程的一个重要概念。
可变数据类型。
对于许多其他数据类型,抽象的目的就是封装值随着变化而变化。在 turtle.py(来自第 3.2 节)中定义的Turtle类就是一个典型例子。同样,Picture、Histogram、StockAccount、Counter和 Python 列表/数组都是我们期望值会改变的类型。
数组和字符串。
作为客户端程序员,当使用 Python 列表/数组(可变)和 Python 字符串(不可变)时,你已经遇到了这种区别。当你将一个字符串传递给一个方法/函数时,你不��要担心该方法/函数改变字符串中的字符序列。相反,当你将一个数组传递给一个方法/函数时,该方法/函数可以自由地改变数组的元素。Python 字符串是不可变的,因为我们通常不希望str值发生变化;Python 数组是可变的,因为我们经常希望数组元素发生变化。
不可变性的优势。
一般来说,不可变数据类型更容易使用,更难被误用,因为能够改变对象值的代码范围远比可变类型小得多。
不可变性的代价。
不可变性的缺点是你必须为每个值创建一个新对象。例如,当你使用Complex数据类型时,表达式z = z*z + z0涉及创建第三个对象(用于保存值z*z),然后使用该对象与+运算符(不保存显式引用)并创建第四个对象来保存值z*z + z0,并将该对象分配给z(从而使原始引用z变为孤立)。一个程序,比如 mandelbrot.py(来自第 3.2 节),会创建大量这样的中间对象。然而,这种开销通常是可以接受的,因为 Python 的内存管理通常针对这种情况进行了优化。
防御性复制。
假设我们希望开发一个名为Vector的不可变数据类型,其构造函数接受一个浮点数数组作为参数来初始化一个实例变量。考虑以下尝试:
class Vector:
def __init__(self, a):
self._coords = a # array of coordinates
...
这段代码使Vector成为一个可变数据类型。客户程序可以通过指定数组中的元素来创建一个Vector对象,然后(绕过 API)在创建后更改 Vector 的元素:
a = [3.0, 4.0]
v = Vector(a)
a[0] = 17.0 # bypasses the public API
为了确保包含可变类型实例变量的数据类型的不可变性,实现需要进行本地复制,称为防御性复制。回想一下第 1.4 节中,表达式a[:]创建了数组a[]的一个副本。因此,这段代码创建了一个防御性复制:
class Vector:
def __init__(self, a):
self._coords = a[:] # array of coordinates
...
接下来,我们考虑这种数据类型的完整实现。
示例:空间向���

空间向量 是一个具有大小和方向的抽象实体。空间向量提供了一种自然的方式来描述物理世界的属性,如力、速度、动量或加速度。一种标准的指定向量的方式是作为从原点到笛卡尔坐标系中一点的箭头:方向是从原点到点的射线,大小是箭头的长度(从原点到点的距离)。为了指定向量,只需指定点即可。
这个概念可以扩展到任意维度:一个包含n个实数的有序列表(一个n维点的坐标)足以指定n维空间中的一个向量。按照惯例,我们使用粗体字母来表示一个向量,用逗号分隔的数字或带下标的变量名(斜体字母相同)在括号内表示其值。例如,我们可以用x表示向量(x[0], x[1], ..., x[n-1]),用y表示向量(y[0], y[1], ..., y[n-1])。
API。
向量的基本操作是将两个向量相加,将一个向量乘以一个标量(一个实数),计算两个向量的点积,以及计算大小和方向,如下所示:
加法:x + y = (x[0] + y[0], x[1] + y[1], ..., x[n-1] + y[n-1])
标量乘积:αx = (αx[0], αx[1], ..., αx[n-1])
点积:x · y = x[0]y[0] + x[1]y[1] + ... + x[n-1]y[n-1]
大小:|x| = (x[0]² + x[1]² + ... + x[n-1]²)^(1/2)
方向:x / |x| = (x[0] / |x|, x[1] / |x|, ..., x[n-1] / |x|)
这些定义导致了以下 API:
与Complex一样,这个 API 并没有明确指定数据类型是不可变的,但我们知道客户程序员(可能会以数学抽象的方式思考)肯定会期望这种约定,也许我们宁愿不向他们解释我们试图保护他们免受别名错误的影响!
表示。
在开发实现时,我们的第一选择是为数据选择一个表示。使用数组保存构造函数中提供的笛卡尔坐标是一个明显的选择,但不是唯一合理的选择。如果有必要,实现可以更改坐标系而不影响客户端代码。
实现。
给定表示,实现所有这些操作的代码是直接的,你可以在 vector.py 中定义的Vector类中看到。构造函数对客户端数组进行了防御性拷贝,而且没有任何方法给拷贝赋值,因此Vector对象是不可变的。
当客户端可以自由组合代码如x[i] = 2.0时,我们如何确保不可变性?这个问题的答案在于一个特殊的方法,我们在不可变数据类型中不实现:在这种情况下,Python 调用特殊方法__setitem__()而不是__getitem__()。由于Vector没有实现该方法,这样的客户端代码会在运行时引发AttributeError。
元组
Python 的内置tuple数据类型表示一个不可变对象序列。它类似于内置的list数据类型(我们用于数组),不同之处在于一旦创建了元组,就不能更改其项。你可以使用熟悉的数组表示法来操作元组,如本 API 中所述:
你可以使用内置函数tuple()创建元组,也可以通过列出一系列用逗号分隔的表达式,并(可选)用匹配的括号括起来。
使用元组可以改善程序的设计。例如,如果我们用以下语句替换 vector.py 构造函数中的第一条语句
self._coords = tuple(a)
那么任何尝试在 Vector 类内部更改向量坐标的操作都会在运行时引发TypeError,有助于强制执行Vector对象的不可变性。
Python 还提供了一个强大的元组赋值功能,称为元组打包和元组解包,它允许你将右侧赋值运算符上的表达式元组分配给左侧的变量元组(前提是左侧的变量数量与右侧的表达式数量相匹配)。你可以使用这个功能同时赋值多个变量。例如,以下语句交换了变量x和y中的对象引用:
x, y = y, x
你还可以使用元组打包和解包从函数中返回多个值(请参见本节末尾的一个练习)。
多态
通常,当我们组合方法(或函数)时,我们打算它们只能与特定类型的对象一起使用。有时,我们希望它们能够与不同类型的对象一起使用。一个可以接受不同类型参数的方法(或函数)被称为多态的。
最好的多态是意想不到的:当你将一个现有的方法/函数应用于一个新的数据类型(你从未计划过的)时,发现该方法/函数恰好具有你想要的行为。最糟糕的多态也是意想不到的:当你将一个现有的方法/函数应用于一个新的数据类型时,它返回错误的答案!发现这种错误可能是一个非凡的挑战。
鸭子类型。
鸭子类型是一种编程风格,语言不正式指定函数参数的要求;相反,它只是尝试调用定义了兼容函数的函数(否则会引发运行时错误)。这个名字来自一句被归因于诗人 J.W.莱利的古老引语:*当我看到一只鸟
走起来像鸭子,游泳像鸭子,嘎嘎叫像鸭子
我称那只鸟为鸭子*
在 Python 中,如果一个对象像鸭子一样走路,游泳,嘎嘎叫,你可以将该对象视为鸭子;你不需要明确声明它是鸭子。在许多语言(如 Java 或 C++)中,您需要明确声明变量的类型,但在 Python 中不需要 — Python 对所有操作(函数调用,方法调用和运算符)使用鸭子类型。如果由于不适当的类型而无法将操作应用于对象,则它会在运行时引发TypeError。这种方法导致客户端代码更简单、更灵活,并将重点放在实际使用的操作上,而不是类型上。
鸭子类型的缺点。
鸭子类型的主要缺点是很难准确知道客户端和实现之间的契约是什么,特别是当需要的方法只间接需要时。API 简单地不携带这种信息。这种信息的缺乏可能导致运行时错误。更糟糕的是,最终结果可能在语义上不正确,而根本没有引发错误。接下来,我们考虑这种情况的一个简单示例。
一个案例。
我们设计我们的Vector数据类型时,隐含地假设向量分量��是浮点数,并且客户端将通过将float对象数组传递给构造函数来创建一个新向量。如果客户端以这种方式创建两个向量x和y,那么x[i]和x.dot(y)都返回浮点数,x + y和x - y都返回具有浮点分量的向量,如预期的那样。
假设,相反地,一个客户通过将int对象数组传递给构造函数来创建具有整数分量的Vector。如果客户以这种方式创建两个向量x和y,那么x[i]和x.dot(y)都返回整数,x + y和x - y都返回具有整数分量的向量,如所需。当然,abs(x)返回一个float,而x.direction()返回一个具有float分量的向量。这是最好的多态性,其中鸭子类型恰好起作用。
现在,假设一个客户通过将complex对象数组传递给构造函数来创建具有复数分量的Vector。向量加法或标量乘法没有问题,但点积操作的实现(以及依赖于点积的幅度和方向的实现)却失败得惊人。这里是一个例子:
a = [1 + 2j, 2 + 0j, 4 + 0j]
x = Vector(a)
b = abs(x)
这段代码在运行时导致TypeError,因为math.sqrt()试图对一个复数取平方根。问题在于两个复值向量x和y的点积需要取第二个向量中元素的复共轭。教科书详细描述了问题及其解决方案。
在这种情况下,鸭子类型是最糟糕的多态性。当向量分量是复数时,客户端期望Vector的实现能够正常工作是完全合理的。一个实现如何能够预期并准备好处理数据类型的所有潜在用途呢?这种情况提出了一个不可能满足的设计挑战。我们所能做的就是警告您,尽可能检查您使用的任何数据类型是否能够处理您打算与之一起使用的数据类型。
重载
定义提供其自己的运算符定义的数据类型的能力是一种称为运算符重载的多态性形式。在 Python 中,您几乎可以重载每个运算符,包括算术、比较、索引和切片运算符。您还可以重载内置函数,包括绝对值、长度、哈希和类型转换。重载运算符和内置函数使用户定义的类型更像内置类型。
为执行操作,Python 内部将表达式转换为对应特殊方法的调用;要调用内置函数,Python 内部调用相应的特殊方法。要重载运算符或内置函数,您需要在自己的代码中包含相应特殊方法的实现。
算术运算符。
Python 为其每个算术运算符关联一个特殊方法,因此您可以通过实现相应的特殊方法来重载任何算术操作,详细��息请参见此表。
相等性。
用于测试相等性的==和!=运算符需要特别注意。例如,考虑右侧图中的代码,它创建了两个由三个变量c1、c2和c3引用的Charge对象。正如图中所示,c1和c3都引用相同的对象,这与c2引用的对象不同。显然,c1 == c3为True,但c1 == c2呢?对于这个问题的答案不明确,因为在 Python 中有两种思考相等性的方式:
引用相等性(标识相等性)。当两个引用相等时,它们指向同一个对象。内置函数id()给出对象的标识(其内存地址);is和is not运算符测试两个变量是否引用同一个对象。也就是说,c1 is c2的实现测试id(c1)和id(c2)是否相同。在我们的例子中,c1 is c3如预期的那样是True,但c1 is c2是False,因为c1和c2位于不同的内存地址。
对象相等性(值相等性)。当两个对象相等时,它们具有相同的数据类型值。您应该使用==和!=运算符,这些运算符是使用特殊方法__eq__()和__ne__()定义的,用于测试对象的相等性。如果您没有定义__eq__()方法,那么 Python 会使用is运算符。也就是说,默认情况下,==实现引用相等性。因此,在我们之前的例子中,即使c1和c2具有相同的位置和电荷值,c1 == c2也是False。如果我们希望将具有相同位置和电荷值的两个电荷视为相等,则可以通过在 charge.py(来自第 3.2 节)中包含以下代码来确保这一结果:
def __eq__(self, other):
if self._rx != other._rx: return False
if self._ry != other._ry: return False
if self._q != other._q: return False
return True
def __ne__(self, other):
return not __eq__(self, other)
有了这段代码,我们的例子中现在c1 == c2为 True。
哈希。
现在我们考虑与相等性测试相关的基本操作,称为哈希,它将对象映射到一个整数,称为哈希码。这个操作非常重要,Python 通过支持内置的hash()函数的特殊方法__hash__()来处理它。如果对象满足以下三个属性,则我们将对象称为可哈希:
通过
==运算符,可以将对象与其他对象进行相等性比较。每当两个对象比较相等时,它们具有相同的哈希码。
对象的哈希码在其生命周期中不会更改。
在典型应用中,我们使用哈希码将对象x映射到一个小范围内的整数,例如在 0 和m-1 之间,使用哈希函数
hash(x) % m
然后,我们可以使用哈希函数值作为整数索引到长度为m的数组中(请参阅本节后面描述的 sketch.py 程序和第 4.4 节中描述的 hashst.py 程序)。Python 的所有不可变数据类型(包括int、float、str和tuple)都是可哈希的,并且被设计为以合理的方式分布对象。
通过实现两个特殊方法__hash__()和__eq__(),你可以使用户定义的数据类型可哈希。设计一个良好的哈希函数需要科学和工程的巧妙结合,这超出了本书的范围。��反,我们在 Python 中描述了一个简单的方法,该方法在各种情况下都很有效:
确保数据类型是不可变的。
通过比较所有重要的实例变量来实现
__eq__()。通过将相同的实例变量放入元组并在元组上调用内置的
hash()函数来实现__hash__()。
例如,以下是Charge数据类型(在 charge.py 中定义,来自第 3.2 节)的__hash__()实现,以配合我们刚刚考虑的__eq__()实现:
def __hash__(self):
a = (self._rx, self._ry, self._q)
return hash(a)
比较运算符。
同样,在 Python 中,像x < y和x >= y这样的比较不仅适用于整数、浮点数和字符串。再次,Python 为每个比较运算符关联了一个特殊方法,因此你可以通过实现相应的特殊方法来重载任何比较运算符,详细信息请参考下表:
作为一种风格,如果你定义了任何一个比较方法,那么你应该以一致的方式定义所有这些方法。你可以通过实现六个特殊方法使用户定义的类型可比较,就像我们在 counter.py 中为Counter类所做的那样:
def __lt__(self, other): return self._count < other._count
def __le__(self, other): return self._count <= other._count
def __eq__(self, other): return self._count == other._count
def __ne__(self, other): return self._count != other._count
def __gt__(self, other): return self._count > other._count
def __ge__(self, other): return self._count >= other._count
其他运算符。
Python 中几乎每个运算符都可以被重载。如果你想重载一个运算符,你可以在官方 Python 文档中找到相应的特殊方法。
内置函数。
我们一直在每个我们开发的类中重载内置函数str(),还有其他几个内置函数可以以相同的方式重载。我们在本书中使用的这些函数已总结在下表中。我们已经使用了所有这些函数,除了iter(),我们将在第 4.4 节中推迟使用。
函数是对象
在 Python 中,一切都是对象,包括函数。这意味着你可以将函数用作函数的参数并将它们作为结果返回。定义所谓的高阶函数,用于操作其他函数,在数学和科学计算中都很常见。

举个例子,考虑估计正实值函数f的黎曼积分(即曲线下的面积)的问题。也许最简单的方法是称为矩形法,在这种方法中,我们通过计算曲线下n个等宽矩形的总面积来近似积分的值。下面定义的integrate()函数评估了在区间(a, b)中实值函数f()的积分,使用n个矩形的矩形法:
def square(x):
return x*x
def integrate(f, a, b, n=1000):
total = 0.0
dt = 1.0 * (b - a) / n
for i in range(n):
total += dt * f(a + (i + 0.5) * dt)
return total
继承
Python 提供了定义类之间关系的语言支持,称为继承。软件开发人员广泛使用继承,因此如果你学习软件工程课程,你将详细学习它。有效使用继承超出了本书的范围,但我们在这里简要描述它,因为有一些情况下你可能会遇到它。
当正确使用时,继承使得一种称为子类化的代码重用成为可能。其思想是定义一个继承自另一个类(超类或基类)的新���(子类或派生类),该子类继承自实例变量和方法。子类包含的方法比超类更多。系统程序员使用子类化来构建所谓的可扩展*模块。其思想是一个程序员(甚至是您)可以向另一个程序员(或者可能是一组系统程序员)构建的类添加方法,有效地重用潜在庞大模块中的代码。这种方法被广泛使用,特别是在用户界面的开发中,以便可以重用提供用户所期望的所有功能所需的大量代码(下拉菜单、剪切和粘贴、访问文件等)。
尽管它有优点,但子类化的使用在系统程序员中是有争议的。我们在这个书站上没有使用它,因为它通常违反了封装。子类化使得模块化编程变得更加困难,原因有两个。首先,超类的任何更改都会影响所有子类。子类无法独立于超类开发;事实上,它完全依赖于超类。这个问题被称为脆弱基类问题。其次,子类代码可以破坏超类代码的意图,因为它可以访问实例变量。例如,类似Vector的类的设计者可能非常小心地使Vector不可变,但是子类可以访问实例变量,可以随意更改它们,给任何假设该类是不可变的客户端带来混乱。
应用:数据挖掘
为了在应用程序的背景下说明本节讨论的一些概念,我们接下来考虑一个在解决数据挖掘中令人生畏的挑战方面变得重要的软件技术,即搜索大量信息的过程。
为简单起见,我们将限制注意力在文本文档上(尽管我们将考虑的方法也适用于图片、音乐和各种其他文件)。即使有了这个限制,文档类型的多样性仍然显著。您可以在书站上找到这些文档的参考:
我们的兴趣在于找到使用文件内容搜索的高效方法来表征文档。解决这个问题的一个富有成果的方法是为每个文档关联一个称为草图的向量,它是文档内容的超紧凑表示。基本思想是草图应该捕捉文档的显著统计特征,以便不同的文档具有“不同”的草图,相似的文档具有“相似”的草图。这些考虑导致了这个 API:
构造函数的参数是一个字符串和两个控制草图质量的整数。客户端可以使用similarTo()来确定两个草图之间相似程度的范围,从 0(不相似)到 1(相似)。
计算草图。
计算文档的草图是第一个挑战。我们的第一个选择是使用Vector来表示文档的草图。我们的实现 sketch.py 使用了简单的频率计数方法。除了字符串外,构造函数还有两个参数,一个整数k和一个向量维度d。它扫描文档并检查文档中的所有k-gram — 即,从每个位置开始的长度为k的子字符串。在其最简单的形式中,草图是一个向量,给出了字符串中k-gram 出现的相对频率:为每个可能的k-gram 给出具有该值的文档中k-gram 的数量。
哈希。
在许多系统上,每个字符有 128 个不同的可能值,因此每个字符有 128k个可能的k-gram,简单方案中维度d将不得不是 128k。即使对于中等大小的k,这个数字也是不可接受的大。为了缓解这个问题,我们使用哈希,这是我们在本节前面考虑过的将对象映射到整数的基本操作。对于任何字符串s,hash(s) % d是一个介于 0 和d-1 之间的整数,我们可以将其用作数组的索引来计算频率。我们使用的草图是文档中所有k-gram 的��些值的频率定义的向量的方向(具有相同方向的单位向量)。sketch.py 中的测试客户端接受k和d作为命令行参数,并计算并写入从标准输入读取的文档的草图。尝试将标准输入重定向到文件 genome20.txt。
比较草图。
第二个挑战是计算两个草图之间的相似度。一个广泛使用的相似度度量称为余弦相似度度量。由于我们的草图是具有非负坐标的单位向量,它们的点积是介于 0 和 1 之间的数字。文件越相似,我们期望这个度量值越接近 1。sketch.py 中的similarTo()方法使用了这种方法。
比较所有配对。
程序 comparedocuments.py 是一个简单而有用的Sketch客户端,提供解决以下问题所需的信息:给定一组文档,找到最相似的两个文档。由于这个规范有点主观,程序会为所有文档对写入余弦相似度度量。程序接受命令行参数k和d,从标准输入读取文件名列表,并写入显示文件之间相似度度量的表格。尝试将标准输入重定向到 documents.txt,其中包含文件 constitution.txt、tomsawyer.txt、huckfinn.txt、prejudice.txt、djia.csv、amazon.html 和 actg.txt 的名称。
契约式设计
最后,我们简要讨论了 Python 语言机制,使您能够在程序运行时验证对程序的假设。
异常。
异常是程序运行时发生的中断性事件,通常用于表示错误。所采取的行动称为引发异常(或错误)。在学习编程过程中,我们已经遇到了 Python 标准模块引发的异常:IndexError和ZeroDivisionError是典型例子。您也可以引发自己的异常。最简单的一种是中断程序执行并写入错误消息的Exception:
raise Exception('Error message here.')
当异常对客户端有帮助时,使用异常是一个好的实践。例如,在 vector.py 中,如果要相加的两个Vector具有不同的维度,我们应该在__add__()中引发异常。为此,我们在__add__()的开头插入以下语句:
if len(self) != len(other):
raise Exception('vectors have different dimensions')
断言。
断言是一个布尔表达式,你在程序中断言在那一点是True。如果表达式是False,程序将在运行时引发AssertionError。程序员使用断言来检测错误并增加对程序正确性的信心。断言还用于记录程序员的意图。例如,在 counter.py 中,我们可以通过在increment()的最后一条语句中添加以下断言来检查计数器永远不会为负:
assert self._count >= 0
这个声明会引起负计数的注意。您还可以添加一个可选的详细消息,例如
assert self._count >= 0, 'Negative count detected!'
以帮助识别错误。
默认情况下,断言是启用的,但你可以通过在命令行中使用带有 python 命令的-O(减号后跟一个大写字母“oh”)标志来禁用它们。(O代表“优化”)。断言仅用于调试;你的程序不应依赖断言进行正常操作,因为它们可能被禁用。
当使用设计契约模型时,数据类型的设计者表达了前置条件(客户在调用方法时承诺满足的条件)、后置条件(实现在从方法返回时承诺实现的条件)、不变量(实现在执行方法时承诺满足的任何条件)和副作用(方法可能引起的状态变化)。在开发过程中,这些条件可以通过断言进行测试。许多程序员在调试时会大量使用断言。
Q & A
Q. 为什么下划线约定不是 Python 的一部��(并受到强制执行)?
A. 很好的问题。
Q. 为什么要使用前导下划线?
A. 这只是许多例子之一,其中编程语言设计者遵循个人偏好,我们只能接受结果。幸运的是,你编写的大多数 Python 程序将是客户程序,不直接调用特殊方法或引用私有实例变量,因此它们不需要许多前导下划线。相对较少的 Python 程序员实现自己的数据类型(现在就是你),需要遵循下划线约定,但即使这些程序员可能会编写更多的客户端代码而不是类实现,所以从长远来看,下划线可能并不那么繁琐。
Q. complexpolar.py 中的__mul__()方法很笨拙,因为它创建了一个表示 0 + 0i的Complex对象,然后立即将其实例变量更改为所需的极坐标。如果我可以添加一个以极坐标为参数的第二个构造函数,设计会不会更好?
A. 是的,但我们已经有一个以矩形坐标为参数的构造函数。一个更好的设计可能是在 API 中有两个普通函数(而不是方法)createRect(x, y)和createPolar(r, theta),它们创建并返回新对象。这种设计可能更好,因为它将为客户端提供切换到极坐标的能力。这个例子表明,在开发数据类型时考虑多种实现是一个好主意。当然,进行这样的更改需要增强所有现有实现和 API 的客户端,因此这种思考应尽早在设计过程中发生。
Q. 如何指定由零个项目或一个项目组成的元组?
A. 你可以分别使用()和(1,)。在第二个表达式中没有逗号,Python 会将其视为括号括起来的算术表达式。
Q. 如果我想使我的数据类型可比较,是否真的需要重载所有六个比较方法?
A. 是的。这是一个例子,其中约定为在实现中提供最大的灵活性,以换取额外的代码。通常,你可以利用对称性来减少实际的实现代码量。此外,Python 3 提供了一些快捷方式。例如,如果为数据类型定义了__eq__()方法,但没有定义__ne__()方法,那么 Python 会自动提供一个调用__eq__()并否定结果的实现。然而,Python 2 不提供这些快捷方式,因此最好不要在代码中依赖它们。
Q. 内置的hash()函数返回的整数值范围是多少?
A. 通常,Python 使用 64 位整数,因此范围在 -2⁶³ 和 2⁶³-1 之间。对于加密应用,应使用 Python 的 hashlib 模块,该模块支持支持更大范围的“安全”哈希函数。
Q. 哪些 Python 运算符不能被重载?
A. 在 Python 中,你不能重载
布尔运算符
and、or和not。is和is not运算符,用于测试对象标识。字符串格式化运算符
%,仅适用于字符串。赋值运算符
=.
练习
创建一个用于处理地球上位置的数据类型
Location,使用球面坐标(纬度/经度)。包括生成地球表面上的随机位置、解析位置“25.344 N, 63.5532 W”和计算两个位置之间的大圆距离的方法。创建一个三维粒子的数据类型,具有位置(r[x]、r[y]、r[z])、质量 m 和速度
(*v*[*x*]、*v*[*y*]、*v*[*z*])。包括一个返回其动能的方法,等于 1/2 m (v[x]² + v[y]² + v[z]²)。使用在 vector.py 中定义的Vector数据类型。如果你了解物理学,请基于使用 动量 (p[x]、p[y]、p[z]) 作为实例变量的上一个练习中的数据类型开发一个替代实现。
开发一个
Histogram类的实现,如第 3.2 节中定义的 histogram.py,使用 counter.py 中定义的Counter。为
Vector(如 vector.py 中定义)实现一个__sub__()方法,用于计算两个向量的差。解决方案:
def __sub__(self, other): return self + (other * -1.0)注意,
__sub__()调用__add__()和__mul__()。这种实现的优点是限制了需要检查的详细代码量;缺点是可能效率低下。在这种情况下,__add__()和__mul__()都会创建新的Vector对象,因此复制__add__()的代码并将减号替换为加号可能是更好的实现。为二维向量实现一个数据类型
Vector2D,其 API 与Vector相同(如 vector.py 中定义),只是构造函数接受两个浮点数作为参数。使用两个浮点数(而不是数组)作为实例变量。使用��个
Complex对象作为唯一实例变量,实现上一个练习中的Vector2D数据类型。证明两个二维单位向量的点积是它们之间角度的余弦。
为三维向量实现一个数据类型
Vector3D,其 API 与Vector相同,只是构造函数接受三个浮点数作为参数。此外,添加一个 叉积 方法:两个向量的叉积是另一个向量,由以下方程定义a × b = c |a| |b| sin θ 其中 c 是垂直于 a 和 b 的单位法向量,θ 是 a 和 b 之间的角度。在笛卡尔坐标中,以下方程定义了叉积:
(a[0]、a[1]、a[2]) × (b[0]、b[1]、b[2]) = (a[1] b[2] - a[2] b[1]、a[2] b[0] - a[0] b[2]、a[0] b[1] - a[1] b[0]) 叉积出现在力矩、角动量和矢量算符旋度的定义中。此外,|a × b| 是以 a 和 b 为边的平行四边形的面积。
你需要对
Vector(参见 vector.py)进行哪些修改(如果有的话)才能使其与Complex组件(参见 complex.py 第 3.2 节)或Rational组件(参见第 3.2 节中的“有理数”练习)一起工作?在 charge.py(第 3.2 节)中添加代码,使得
Charge对象可通过电荷值确定顺序。编写一个函数
fibonacci(),接受一个整数参数n并计算第n个斐波那契数。使用元组打包和解包。修改 euclid.py(来自第 2.3 节)中的
gcd()函数,使其接受两个非负整数参数p和q,并返回一个整数元组(d、a、b),其中d是p和q的最大公约数,系数a和b满足贝祖等式:d = a*p + b*q。使用元组打包和解包。解决方案:这个算法被称为扩展欧几里得算法:
def gcd(p, q): if q == 0: return (p, 1, 0) (d, a, b) = gcd(q, p % q) return (d, b, a - (p // q) * b)讨论 Python 设计中将内置类型
bool设计为内置类型int的子类的优缺点。在
Counter(如 counter.py 中定义)中添加代码,如果客户端尝试使用负值为maxCount创建Counter对象,则在运行时引发ValueError。使用异常开发
Rational的实现(请参阅第 3.2 节中的“有理数”练习),如果分母为零,则在运行时引发ValueException。
数据类型设计练习
这组练习旨在让您有机会开发数据类型。对于每个问题,设计一个或多个 API,并通过实现典型客户端代码来测试您的设计决策。一些练习要求对特定领域的知识或在网络上搜索信息。
统计。开发一个用于维护一组浮点数统计信息的数据类型。提供一个添加数据点的方法和返回点数、均值、标准差和方差的方法。开发两种实现:一种实例值为点数、值的总和和值的平方和,另一种保留包含所有点的数组。为简单起见,您可以在构造函数中使用最大点数。您的第一个实现可能更快,占用的空间也更少,但也可能容易受到舍入误差的影响。
基因组。开发一个用于存储生物体基因组的数据类型。生物学家通常将基因组抽象为核苷酸序列(A、C、G 或 T)。数据类型应支持
addCodon(c)和baseAt(i)方法,以及isPotentialGene()(请参阅第 3.1 节中的 potentialgene.py)。开发三种实现。使用字符串作为唯一实例变量;使用字符串连接实现
addCodon()。使用单个字符字符串数组作为唯一实例变量;使用
+=运算符实现addCodon()。使用布尔数组,用两位编码每个碱基。
时间。开发一个表示一天时间的数据类型。提供返回当前小时、分钟和秒的客户端方法,以及一个
__str__()方法。开发两种实现:一种将时间保留为单个int值(从午夜开始的秒数),另一种保留三个int值,分别为秒、分钟和小时。向量场。开发一个表示二维力向量的数据类型。提供一个构造函数,一个用于添加两个向量的方法,以及一个有趣的测试客户端。
日期。为日期(年、月、日)开发一个 API。包括比较两个日期的时间顺序、计算两个日期之间的天数、确定给定日期的星期几,以及客户端可能需要的任何其他操作。设计完 API 后,查看 Python 的
datetime.date数据类型。多项式。开发一个具有整数系数的一元多项式数据类型,例如x³ + 5x² + 3x + 7。包括多项式的标准操作方法,如加法、减法、乘法、次数、求值、组合、微分、定积分和测试相等性。
有理多项式。重复上一个练习,确保当提供
int、float、complex和Fraction类型的系数时,多项式数据类型的行为是正确的(请参见第 3.2 节中的“有理数”练习)。
创意练习
日历。开发
Appointment和CalendarAPI,可用于在日历年中跟踪约会(按天)。您的目标是使客户能够安排不冲突的约会,并向客户报告当前约会。使用 Python 的datetime模块。矢量场。矢量场将一个向量与欧几里得空间中的每一点相关联。编写一个版本的 potential.py(来自第 3.1 节),它以网格大小n作为输入,计算在n乘n的等间距点网格中每个点处由点电荷引起的势的
Vector值,并在每个点处绘制指向累积场的单位矢量。素描。从书站选择一组有趣的文档(或使用您自己的集合),并使用各种命令行参数运行 comparedocuments.py,以了解它们对计算的影响。
多媒体搜索。开发声音和图片的素描策略,并使用它们在计算机的音乐库中的歌曲和照片相册中发现有趣的相似之处。
数据挖掘。编写一个递归程序,从给定为第一个命令行参数的页面开始浏览网页,并查找与给定为第二个命令行参数的页面相似的页面,方法如下。要处理一个名称,打开一个输入流,执行
readAll(),进行素描,并在距离目标页面的距离大于作为第三个命令行参数给定的阈值时写入名称。然后扫描页面以查找所有包含子字符串http://的字符串,并(递归地)处理具有这些名称的页面。注意:此程序可能会读取大量页面!
3.4 案例研究:N 体模拟
原文:
introcs.cs.princeton.edu/python/34nbody译者:飞龙
在本节中,我们考虑一个展示面向对象编程的新程序。我们的任务是编写一个动态模拟n个物体在相互引力影响下的运动的程序。这个n体模拟问题是由艾萨克·牛顿在 350 多年前首次提出的,科学家们今天仍在密切研究。
这个问题是面向对象编程的一个引人入胜的例子的一个原因是,它在现实世界中的物理对象和我们在编程中使用的抽象对象之间呈现出直接而自然的对应关系。
N-Body Simulation
第 1.5 节的弹球模拟基于牛顿第一运动定律:运动中的物体保持相同速度的运动,除非受到外力的作用。将该例子装饰以包括牛顿第二运动定律(解释外力如何影响速度)将我们引向一个迷住科学家多年的基本问题。给定一个由引力相互影响的n个物体系统,问题是描述它们的运动。
物体数据类型。
在 bouncingball.py(来自第 1.5 节),我们将与原点的位移保持在浮点数rx和ry中,将速度保持在浮点数vx和vy中,并用以下语句将球体在一个时间单位内移动的距离位移:
rx = rx + vx
ry = ry + vy

使用Vector,如在 vector.py(来自第 3.3 节)中定义的,我们可以将位置保持在Vector r中,将速度保持在Vector v中,然后用一条语句将物体在dt时间单位内移动的距离位移:
r = r + v.scale(dt)
程序 body.py 实现了Body,一个用于移动物体的 Python 类。Body是一个Vector客户端 — 数据类型的值是Vector对象,携带了物体的位置和速度,以及一个携带了质量的浮点数。数据类型操作允许客户端移动和绘制物体(以及计算由另一个物体引起的引力吸引力的力向量),如此 API 所定义:
从技术上讲,物体的位置(与原点的位移)不是一个向量(它是空间中的一个点,不是一个方向和大小),但将其表示为Vector是方便的,因为Vector操作导致我们需要移动物体的转换的紧凑代码。当我们移动一个Body时,我们不仅需要改变它的位置,还需要改变它的速度。
力和运动。
牛顿第二运动定律表明,物体上的力(一个向量)等于其质量和加速度(也是一个向量)的数量积:F = m a。换句话说,要计算物体的加速度,我们计算力,然后除以其质量。在Body中,力是传递给move()的Vector参数f,因此我们可以首先通过除以质量(作为浮点数保留在实例变量中)来计算加速度向量,然后通过添加这个向量在时间间隔内变化的量来计算速度的变化(就像我们使用速度来改变位置一样)。这个定律立即转化为以下代码,用于根据给定的力向量f和时间量dt更新物体的位置和速度:
a = f.scale(1.0 / mass)
v = v + a.scale(dt)
r = r + v.scale(dt)
这段代码出现在Body的move()方法中,以调整其值以反映施加该力量的后果的时间量:物体移动,其速度改变。这个计算假定加速度在时间间隔内是恒定的。
物体之间的力。
一个天体对另一个天体施加的力的计算被封装在Body中的forceFrom()方法中,该方法以一个Body对象作为参数并返回一个Vector。牛顿的普遍引力定律是计算的基础:两个天体之间的引力大小等于它们的质量乘积除以它们之间距离的平方(乘以引力常数G,即 6.67 × 10^(-11) N m² / kg²),力的方向是两个粒子之间的连线。这个定律转化为以下代码来计算a.forceFrom(b):
G = 6.67e-11
delta = b._r - a._r
dist = abs(delta)
magnitude = G * a.mass * b.mass / (dist * dist)
f = delta.direction().scale(magnitude)
力矢量的大小是浮点数大小,力矢量的方向与两个天体位置之间的差矢量的方向相同。力矢量f是大小和方向的乘积。
Universe 数据类型。
Universe,如在 universe.py 中定义的,是一个实现以下 API 的数据类型:
其数据类型值定义了一个宇宙(其大小、天体数量和一个天体数组)和两个数据类型操作:increaseTime(),它调整所有天体的位置(和速度),以及draw(),它绘制所有天体。n-body 模拟的关键在于Universe中increaseTime()的实现。计算的第一部分是一个双重嵌套循环,计算每个天体对其他每个天体施加的引力矢量。它应用了叠加原理,即我们可以将影响一个天体的所有力矢量相加,得到代表总力的单一矢量。在计算了所有力之后,它调用每个天体的move()方法来应用计算出的力进行固定时间量的运动。
文件格式。
构造函数从一个文件中读取宇宙参数和天体描述,该文件包含以下信息:
天体数量
宇宙的半径
每个天体的位置、速度和质量
文件 2bodytiny.txt、2body.txt、3body.txt 和 4body.txt 包含了这种形式的数据。和往常一样,为了保持一致性,所有的测量单位都是标准国际单位制(请注意,引力常数G也出现在我们的代码中)。有了这种定义的文件格式,我们的Universe构造函数的代码就很简单了。每个Body由五个浮点数来描述:其位置的x和y坐标,初始速度的x和y分量,以及其质量。
下面显示的静态图像是通过修改Universe和Body来绘制白色的天体,然后在灰色背景上绘制黑色的天体制作的。相比之下,当你运行 universe.py 时,得到的动态图像给人一种天体相互轨道运行的真实感觉,这在固定图片中很难辨认。
Q & A
Q. Universe的 API 确实很小。为什么不只是在Body的main()测试客户端中实现那些代码?
A. 我们的设计表达了许多人对宇宙的信念:它是被创造出来的,然后时间流逝。它澄清了代码,并允许在模拟宇宙中发生的事情时具有最大的灵活性。
Q. 为什么forceFrom()是一个方法?它不是作为一个接受两个Body对象作��参数的函数会更好吗?
A. 将forceFrom()实现为一个方法是几种可能的选择之一,而将一个函数作为参数接受两个Body对象显然是一个合理的选择。一些程序员更喜欢完全避免在数据类型实现中使用函数;另一个选择是将作用在每个Body上的力作为实例变量维护。我们的选择是这两个选项之间的折衷。
Q. body.py 中的move()方法应该使用旧速度而不是更新后的速度来更新位置吗?
A. 结果表明,使用更新后的速度(称为跳跃法)比使用旧速度(称为欧拉法)产生更准确的结果。如果你学习数值分析课程,你会明白为什么。
练习
开发一个面向对象的版本 bouncingball.py(来自第 1.5 节)。包括一个构造函数,以随机方向和随机速度(在合理范围内)启动每个球的运动,并编写一个测试客户端,从命令行获取一个整数
n,并模拟n个弹跳球的运动。在 body.py 中添加一个
main()函数,用于对 Body 数据类型进行单元测试。修改 body.py,使其绘制的圆的半径与其质量成比例。
在没有引力作用的宇宙中会发生什么?这种情况对应于
Body中的forceFrom()始终返回零向量。创建一个数据类型
Universe3D来模拟三维宇宙。开发一个数据文件,模拟我们太阳系中行星围绕太阳的运动。编写一个测试客户端,模��两个不同宇宙的运动(由两个不同文件定义,并出现在标准绘图窗口的两个不同部分)。你还需要修改
Body中的draw()方法。编写一个类
RandomBody,它使用(精心选择的)随机值初始化其实例变量,而不是将它们作为参数传递。然后编写一个客户端,从命令行获取一个参数n,并在一个具有n个物体的随机宇宙中模拟运动。修改
Vector(如第 3.3 节中定义的 vector.py)以包含一个方法__iadd__(self, other)来支持原地加法运算符+=,使客户端能够编写像r += v.scale(dt)这样的代码。使用这种方法,修改 body.py 和 universe.py。修改
Vector构造函数(如第 3.3 节中定义的 vector.py)以便如果传递一个正整数d作为参数,则创建并返回维度为d的全零向量。使用这个修改后的构造函数,修改 universe.py 以便它可以处理三维(或更高维)宇宙。为简单起见,不要担心更改 body.py 中的draw()方法 - 它将位置投影到由第一个x和y坐标定义的平面上。
创意练习
新宇宙。设计一个具有有趣属性的新宇宙,并使用 universe.py 中定义的
Universe来模拟其运动。这个练习真的是一个发挥创造力的机会!渗透。编写一个面向对象的版本 percolation.py(来自第 2.4 节)。在开始之前,请仔细考虑设计,并准备好为你的设计决策辩护。
4. 算法和数据结构
原文:
introcs.cs.princeton.edu/python/40algorithms译者:飞龙
本章介绍了作为各种应用的基本构建块的基本数据类型。我们提供完整的实现,尽管其中一些内置在 Python 中,这样你就可以清楚地了解它们的工作原理以及它们为什么重要。
本章介绍的算法和数据结构构成了过去几十年发展的一系列知识,为计算机在各种应用中的高效使用奠定了基础。随着计算应用范围的不断扩大,这些基本方法的影响也在不断增长。
4.1 性能概述了一种科学方法和强大的理论,用于理解我们编写的程序的性能和资源消耗。
4.2 排序和搜索描述了两种经典算法——归并排序和二分查找——以及它们的效率在几个关键应用中起着重要作用。
4.3 栈和队列介绍了两种密切相关的数据结构,用于操作任意大量的数据集合。
4.4 符号表考虑了一种称为符号表的基本数据结构,用于存储信息,以及一种高效的实现称为二叉搜索树。
4.5 小世界现象提供了一个案例研究,以调查小世界现象——我们都通过短链条的熟人联系相互联系。
本章的 Python 程序
下面是本章中的 Python 程序列表。
REF PROGRAM DESCRIPTION DATA 4.1.1 threesum.py 3-sum 问题 8ints.txt 1kints.txt 2kints.txt 4kints.txt 8kints.txt 16kints.txt 32kints.txt 64kints.txt 128kints.txt 4.1.2 doublingtest.py 验证倍增假设 – 4.1.3 timeops.py 计时操作和函数 – 4.1.4 bigarray.py 发现内存容量 – 4.2.1 questions.py 二分查找(20 个问题) – 4.2.2 bisection.py 二分查找(反转函数) – 4.2.3 binarysearch.py 二分查找(有序数组) emails.txt white.txt 4.2.4 insertion.py 插入排序 tiny.txt tomsawyer.txt 4.2.5 timesort.py 排序函数的倍增测试 – 4.2.6 merge.py 归并排序 tiny.txt tomsawyer.txt 4.2.7 frequencycount.py 频率统计 leipzig100k.txt leipzig200k.txt leipzig1m.txt 4.3.1 arraystack.py 栈(可变大小数组实现) tobe.txt 4.3.2 linkedstack.py 栈(链表实现) tobe.txt 4.3.3 evaluate.py 表达式求值 expression1.txt expression2.txt 4.3.4 linkedqueue.py 队列(链表实现) tobe.txt 4.3.5 mm1queue.py M/M/1 队列模拟 – 4.3.6 loadbalance.py 负载均衡模拟 – 4.4.1 lookup.py 字典查找 amino.csv djia.csv elements.csv ip.csv ip-by-country.csv morse.csv phone-na.csv 4.4.2 index.py 索引 mobydick.txt tale.txt 4.4.3 hashst.py 哈希符号表数据类型 – 4.4.4 bst.py 二叉搜索树符号表数据类型 – 4.5.1 graph.py 图数据类型 tinygraph.txt 4.5.2 invert.py 使用图来反转索引 tinygraph.txt movies.txt 4.5.3 separation.py 最短路径客户端 routes.txt movies.txt 4.5.4 pathfinder.py 最短路径客户端 – 4.5.5 smallworld.py 小世界测试 tinygraph.txt 4.5.6 performer.py 表演者-表演者图 tinymovies.txt moviesg.txt
4.1 算法分析
原文:
introcs.cs.princeton.edu/python/41analysis译者:飞龙
我们编写的程序的成本是值得关注的。为了研究我们程序的运行成本,我们通过科学方法进行研究,这是科学家们普遍使用的一套技术,用于开发关于自然界的知识。以下五步方法简要总结了科学方法:
观察自然界的某些特征。
提出与观察一致的模型的假设。
使用假设预测事件。
通过进一步观察验证预测。
通过重复直到假设和观察一致来验证。
我们还应用数学分析来推导成本的简洁模型。在大多数情况下,我们对一个基本特征感兴趣:时间。
观察
我们的第一个挑战是对程序的运行时间进行定量测量。有许多工具可用于帮助我们获得近似值。也许最简单的是物理秒表或在 stopwatch.py(来自第 3.2 节)中定义的Stopwatch数据类型。我们可以简单地在各种输入上运行程序,测量处理每个输入所需的时间。
对于大多数程序,存在一个表征计算任务难度的问题大小。通常,问题大小要么是输入的大小,要么是命令行参数的值。直观地,运行时间应该随问题大小增加而增加,但每次我们开发和运行程序时,问题大小与 threesum.py 的运行时间之间的关系自然地引起了我们的注意。
作为一个具体的例子,我们从 threesum.py 开始,它计算一个包含n个数字的数组中总和为 0 的三元组的数量。尝试在文件 8ints.txt、1kints.txt、2kints.txt、4kints.txt、8kints.txt、16kints.txt、32kints.txt、64kints.txt 和 128kints.txt 上运行它,以了解程序的运行时间。问题大小n与 threesum.py 的运行时间之间的关系是什么?
假设
每个程序员都需要知道如何进行快速性能估算。幸运的是,我们通常可以通过使用经验观察和一小组数学工具来获得这样的知识。
加倍假设。
对于许多程序,我们可以很快为以下问题制定一个假设:将输入大小加倍对运行时间的影响是什么?为了清晰起见,我们将这个假设称为加倍假设。
经验分析。
很明显,我们可以通过将输入大小加倍并观察运行时间的影响来提前发展一个加倍假设。例如,doublingtest.py 为 threesum.py 生成一系列随机输入数组,每一步都将数组长度加倍,并写下threesum.countTriples()对于每个输入的运行时间与前一个输入(大小为前一个输入的一半)的比值。程序写入的Stopwatch测量立即导致一个假设:当输入大小加倍时,运行时间增加了 8 倍。
数学分析。
程序的总运行时间由两个主要因素决定:
每个语句的执行成本
每个语句的执行频率
前者是系统的属性,后者是算法的属性。如果我们对程序中的所有指令都了解这两者,我们可以将它们相乘并对程序中的所有指令求和,以获得运行时间。
主要挑战在于确定语句的执行频率。有些语句很容易分析;例如,在threesum.countTriples()中将count初始化为 0 的语句只执行一次。其他需要更高级推理的语句;例如,在threesum.countTriples()中的if语句精确地执行n(n-1)(n-2)/6 次(这正是从输入数组中选择三个不同数字的方法的数量)。
为了在数学分析中大大简化问题,我们以两种方式开发了更简单的近似表达式。首先,我们通过使用一种称为波浪符号的数学工具来处理数学表达式的主导项。我们写成 f(n) 来表示任何数量,当除以f(n)时,随着n的增长趋于 1。我们还写成g(n) ~ f(n)来表示g(n)/f(n)随着n的增长趋于 1。有了这个符号,我们可以忽略代表小值的表达式的复杂部分。例如,在 threesum.py 中的countTriples()中的if语句执行了n³/6 次,因为n(n-1)(n-2)/6 = n³/6 - n²/2 + n/3,当除以n³/6 时,随着n的增长趋于 1。当主导项后的项相对不重要时,这种符号是有用的(例如,当n = 1,000 时,这个假设相当于说-n²/2 + n/3 ≈ -499,667 相对于n³/6 ≈ 166,666,667 是相对不重要的)。其次,我们关注执行频率最高的指令,有时被称为程序的内循环。在这个程序中,合理地假设内循环之外的指令所花费的时间相对不重要。
分析程序的运行时间的关键点在于:对于许多程序,运行时间满足关系
T(n) ~ cf(n)
其中c是一个常数,f(n)是一个称为运行时间增长顺序的函数。对于典型程序,f(n)是一个如 log n、n、n log n、n²或n³的函数(通常,我们表达增长顺序函数时不带任何常数系数)。当f(n)是n的幂时,这种假设通常等同于说运行时间满足幂律。在 threesum.py 的情况下,这是我们的经验观察已经验证的假设:threesum.py 的运行时间的增长顺序是n³。常数c的值取决于执行指令的成本和频率分析的细节,但我们通常不需要计算出这个值。
增长顺序是一个简单但强大的运行时间模型。例如,知道增长顺序通常会立即导致加倍假设。在 threesum.py 的情况下,知道增长顺序是n³告诉我们,当我们将问题规模加倍时,预计运行时间将增加 8 倍,因为
T(2n) / T(n) ~ c(2n)³ / (cn³) = 8
这与经验分析得出的值相匹配,从而验证了模型和实验。
增长顺序分类

我们只使用了一些结构原语(语句、条件、循环和函数调用)来构建 Python 程序,因此我们程序的增长顺序往往是问题规模的几个函数之一,总结在右侧的表中。
常数。
运行时间增长阶数为常数的程序执行固定数量的语句来完成任务;因此,其运行时间不取决于问题规模。我们在第一章中的前几个程序——比如 hello.py(来自第 1.1 节)和 leapyear.py(来自第 1.2 节)——属于这一类:它们每次只执行几个语句。
Python 对标准数值类型的所有操作都需要恒定时间。也就是说,将操作应用于大数值与将其应用于小数值消耗的时间相同。 (一个例外是涉及具有大量位数的整数的操作可能消耗超过常数时间;有关详细信息,请参阅本节末尾的问答。)Python 的math模块中的函数也需要恒定时间。
对数。
运行时间增长阶数为对数的程序几乎比常数时间程序慢。在问题规模上运行时间为对数的程序的经典示例是在排序数组中查找元素(见二分查找.py 第 4.2 节)。对数的底数与增长阶数无关(因为所有具有常数底数的对数都由一个常数因子相关),因此我们通常在提到增长阶数时使用 log n。
线性。
我们使用术语线性来描述程序的增长阶数,该程序在处理每个输入数据片段时花费恒定的时间,或者基于单个for循环。这样的程序的运行时间与问题规模成正比。计算标准输入数字平均值的程序 average.py(来自第 1.5 节)是典型的例子。
线性对数。
我们使用术语线性对数来描述对于规模为n的问题,其运行时间增长阶数为n log n的程序。同样,对数的底数不相关。例如,couponcollector.py(来自第 1.4 节)是线性对数的。典型示例是归并排序,如在 merge.py(来自第 4.2 节)中实现的。
二次方。
运行时间增长阶数为n²的典型程序有两个嵌套的for循环,用于涉及所有n个元素对的某些计算,被称为二次方增长。在 universe.py(来自第 3.4 节)中的力更新双循环是这一类程序的原型,以及在 insertion.py(见第 4.2 节)中定义的基本排序算法插入排序。
立方。
我们本节的示例 threesum.py 是立方的——其运行时间增长阶数为n³——因为它有三个嵌套的for循环,用于处理所有n个元素的三元组。
指数。
如第 2.3 节所讨论的,hanoi 塔.py 和 beckett.py 的运行时间与 2^(n)成正比,因为它们处理所有n个元素的所有子集。通常,我们使用术语指数来指代增长阶数为b^n的算法,其中b > 1 为任意常数,尽管不同的b值会导致截然不同的运行时间。指数算法非常慢——你不应该为大问题运行其中之一。
Python 列表和数组
Python 内置的list数据类型表示可变对象序列。我们在整本书中一直在使用 Python 列表 —— 回想一下,我们将 Python 列表用作数组,因为它们支持四个核心数组操作:创建、索引访问、索引赋值和迭代。但是,Python 列表比数组更通用,因为您还可以向 Python 列表中插入项目和删除项目。尽管 Python 程序员通常不区分列表和数组,但许多其他程序员确实会区分。例如,在许多编程语言中,数组长度固定,不支持插入或删除。事实上,到目前为止,我们在本书中考虑的所有数组处理代码都可以使用固定长度数组完成。
下表列出了 Python 列表中最常用的操作。
我们将此 API 推迟到本节,因为不注意成本而使用 Python 列表的程序员将会遇到麻烦。例如,考虑以下两个代码片段:
# quadratic time # linear time
a = [] a = []
for i in range(n): for i in range(n):
a.insert(0, 'slow') a.insert(i, 'fast')
左侧的操作需要二次时间;右侧的操作需要线性时间。要理解为什么 Python 列表操作具有这样的性能特征,您需要了解更多关于 Python 列表的调整大小数组表示,我们将在下面讨论。
调整大小的数组。
调整大小的数组是一种数据结构,用于存储一个序列的项目(长度不一定固定),可以通过索引访问。为了在机器级别实现调整大小的数组,Python 使用一个固定长度的数组(作为一块连续的内存块分配)来存储项目引用。数组被分为两个逻辑部分:数组的第一部分包含序列中的项目;数组的第二部分未使用,保留用于后续插入。因此,我们可以在常数时间内从末尾附加或删除项目,使用保留的空间。我们使用术语size来指代数据结构中项目的数量,术语capacity指代底层数组的长度。
主要挑战在于确保数据结构具有足够的容量来容纳所有项目,但又不会过大以浪费过多内存。实现这两个目标事实上非常容易。
首先,如果��们想要将项目附加到调整大小的数组的末尾,我们会检查其容量。如果有空间,我们只需将新项目插入到末尾。如果没有,我们通过创建两倍长度的新数组并将项目从旧数组复制到新数组来加倍其容量。
类似地,如果我们想要从调整大小的数组的末尾删除项目,我们会检查其容量。如果它过大,我们通过创建一半长度的新数组并将项目从旧数组复制到新数组来减半其容量。一个适当的测试是检查调整大小的数组的大小是否小于其容量的四分之一。这样,在容量减半后,调整大小的数组大约半满,并且可以容纳大量插入,然后我们必须再次更改其容量。
加倍和减半策略保证调整大小的数组始终保持在 25%到 100%之间,因此空间与项目数量成线性关系。具体的策略并非神圣不可侵犯。例如,典型的 Python 实现在调整大小的数组已满时将容量扩大 9/8 倍(而不是 2 倍)。这样浪费的空间更少(但会触发更多的扩展和收缩操作)。
摊销分析。
我们可以证明加倍和减半的成本总是被其他 Python 列表操作的成本吸收(在一个常数因子内)。
从空 Python 列表开始,标记为列表 API 表中“常数时间”的n个操作序列需要与n���线性时间。换句话说,任何这种 Python 列表操作序列的总成本除以操作数都受到常数的限制。这种分析称为摊销分析。这种保证不像说每个操作都是常数时间那样强,但在许多应用中具有相同的含义(例如,当我们的主要兴趣是总运行时间时)。
对于我们在空调整大小数组中执行n个插入的特殊情况,这个想法很简单:每次插入都需要恒定时间添加项目;每次触发调整大小的插入(当当前大小是 2 的幂时)需要额外的时间与n成比例,将长度为n的旧数组的元素复制到长度为 2n的新数组中。因此,假设n是 2 的幂以简化,总成本与n成比例
(1 + 1 + 1 + ... + 1) + (1 + 2 + 4 + 8 + ... + n) ~ 3n
第一项(总和为n)代表n个插入操作;第二项(总和为 2n - 1)代表 lg n调整大小操作。
在 Python 编程中理解调整大小的数组是很重要的。例如,它解释了为什么通过重复将项目附加到末尾来创建一个包含n个项目的 Python 列表需要与n成比例的时间(以及为什么通过重复将项目前置到前面来创建一个包含n个项目的列表需要与n²成比例的时间)。
字符串
Python 的字符串数据类型与 Python 列表有一些相似之处,但有一个非常重要的例外:字符串是不可变的。例如,你可能认为可以通过s[0] = 'H'来将值为'hello'的字符串s大写,但这将导致运行时错误:
TypeError: 'str' object does not support item assignment
如果你想要'Hello',你需要创建一个全新的字符串。这种差异强调了不可变性的概念,并且对性能有重大影响,现在我们来详细研究。
内部表示。
首先,Python 对字符串使用比对列表/数组更简单的内部表示,如右侧图表中详细说明的那样。具体来说,一个字符串对象包含两个信息:
字符串中字符存储在内存中的连续位置的引用
字符串的长度

相比之下,考虑左侧的图表,这是一个由单个字符字符串组成的数组。我们将在本节稍后进行更详细的分析,但您可以看到字符串表示肯定更简单。它每个字符使用的空间要少得多,并且提供更快的访问速度。在许多应用中,这些特性非常重要,因为字符串可能非常长。因此,重要的是内存使用量不要比字符本身所需的多太多,并且可以通过索引快速访问每个字符,就像在数组中一样。
性能。
对于数组,索引访问和计算字符串长度都是常数时间操作。从第 3.1 节开头的 API 可以清楚地看出,大多数其他操作都随着输入字符串的长度而线性增长,因为它们引用了字符串的副本。特别是,将字符连接到字符串需要线性时间,将两个字符串连接需要与结果长度成比例的时间。右侧显示了一个示例。就性能而言,这是字符串和列表/数组之间最重要的区别:Python 没有可调整大小的字符串,因为字符串是不可变的。
示例。
不理解字符串连接的性能常常会导致性能错误。最常见的性能错误是逐个字符构建一个长字符串。例如,考虑以下代码片段,用于创建一个新字符串,其字符顺序与字符串s中的字符相反:
n = len(s)
reverse = ''
for i in range(n):
reverse = s[i] + reverse
在for循环的第i次迭代中,字符串连接运算符产生长度为i+1 的字符串。因此,整体运行时间与 1 + 2 + ... + n成比例 ~ n² / 2。也就是说,代码片段随着字符串长度n的增加而花费二次时间。
内存
与运行时间一样,程序的内存使用直接与物理世界相连:你计算机的大量电路使得程序能够存储值并稍后检索它们。你需要存储的值越多,你就需要更多的电路。要注意成本,你需要了解内存使用情况。
Python 没有定义我们一直在使用的内置数据类型(int、float、bool、str和list)的大小;这些类型的对象的大小因系统而异。因此,你创建的数据类型的大小也会因系统而异,因为它们基���这些内置数据类型。函数调用sys.getsizeof(x)返回在你的系统上内置对象x消耗的字节数。本节中给出的数字是通过在一个典型系统上使用这个函数进行观察得到的。
整数。
要表示一个值在(-2⁶³到 2⁶³-1)范围内的int对象,Python 使用 16 字节的开销和 8 字节(即 64 位)的数值。对于超出此范围的整数,Python 会切换到不同的内部表示,这会消耗与整数中数字位数成比例的内存,就像字符串的情况一样(见下文)。
浮点数。
为了表示一个float对象,Python 使用 16 字节的开销和 8 字节的数值(即尾数、指数和符号),无论对象的值是多少。因此,一个 float 对象总是占用 24 字节。
布尔值。
原则上,Python 可以使用一个计算机内存位来表示一个布尔值。实际上,Python 将布尔值表示为整数。具体来说,Python 使用 24 字节来表示bool对象True,使用 24 字节来表示bool对象False。这比最小所需量高出 192 倍!然而,这种浪费在一定程度上得到缓解,因为 Python“缓存”这两个布尔对象。
缓存。
为了节省内存,Python 仅创建具有特定值的对象的一个副本。例如,Python 只创建一个值为 true 的bool对象,只创建一个值为 false 的bool对象。也就是说,每个布尔变量都持有对这两个对象中的一个的引用。这种缓存技术是可能的,因为bool数据类型是不可变的。在典型系统上,Python 还会缓存小的int值(-5 到 256 之间),因为程序员经常使用它们。Python 通常不会缓存float对象。
字符串。
为了表示一个str对象,Python 使用 40 字节的开销(包括字符串长度),加上每个字符的一个字节。因此,例如,Python 使用 40 + 3 = 43 字节表示字符串'abc',使用 40 + 18 = 58 字节表示字符串'abcdefghijklmnopqr'。Python 通常只缓存字符串字面值和单字符字符串。![内存使用情况为[0.3, 0.6, 0.1]](../Images/9e88210747cb6b511bfad4975e154568.png)
数组(Python 列表)。
Python 用于表示数组时,每个对象引用使用 72 字节的开销(包括数组长度)加上每个对象引用 8 字节(数组中的每个元素一个)。因此,例如,数组[0.3, 0.6, 0.1]的 Python 表示使用 72 + 83 = 96 字节。这不包括数组引用的对象的内存,因此数组[0.3, 0.6, 0.1]的总内存消耗为 96 + 324 = 168 字节。一般来说,包含n个整数或浮点数的数组的内存消耗为 72 + 32n字节。这个总数可能是一个低估,因为 Python 用于实现数组的调整大小数组数据结构可能会消耗额外的n字节的保留空间。
二维数组和对象数组。
二维数组是数组的数组,因此我们可以根据上一段中的信息计算具有m行和n列的二维数组的内存消耗。每行是一个消耗 72 + 32n字节的数组,因此总共是 72(开销)加上 8m(对行的引用)加上m(72 + 32n)(对m行的内存)字节,总共是 72 + 80m + 32mn字节。相同的逻辑适用于任何类型对象的数组:如果一个对象使用x字节,那么m个这样的对象的数组总共消耗 72 + m(x+8)字节。同样,这可能是一个轻微的低估,因为 Python 用于表示数组的调整大小数组数据结构。注意:Python 的sys.getsizeof(x)在这些计算中并没有太大帮助,因为它不计算对象本身的内存——对于长度为m的任何数组(或具有m行的任何二维数组),它返回 72 + 8m。
对象。
Python 编程的一个关键问题是:表示用户定义对象需要多少内存?这个问题的答��可能会让您感到惊讶,但了解这一点很重要:至少几百字节。具体来说,Python 使用 72 字节的开销加上280 字节用于将实例变量绑定到对象的字典(我们将在第 4.4 节讨论字典)加上24 字节用于每个实例变量的引用,再加上实例变量本身的内存。例如,为了表示一个Charge对象,Python 至少使用 72 + 280 = 352 字节的开销,8 * 3 = 24 字节来存储三个实例变量的对象引用,24 字节来存储由_rx实例变量引用的float对象,24 字节来存储由_ry实例变量引用的float对象,以及 24 字节来存储由_q实例变量引用的float对象,总共(至少)448 字节。在您的系统上,总数可能会更高,因为一些实现会消耗更多的开销。
对于每个 Python 程序员来说,理解用户定义类型的每个对象可能会消耗大量内存是很重要的。因此,一个定义大量用户定义类型对象的 Python 程序可能会使用比您预期的更多的空间(和时间)。自几十年前引入该概念以来,许多面向对象的语言已经出现并消失,其中许多最终采用了轻量级对象来表示用户定义类型。Python 为此提供了两个高级功能——命名元组和槽——但我们在本书中不会利用这些内存优化。
问答
Q. 文本指出,对非常大的整数进行操作可能会消耗超过常数时间。您能更精确地说明吗?
A. 并非如此。"非常大"的定义取决于系统。对于大多数实际目的,您可以认为应用于 32 位或 64 位整数的操作在常数时间内运行。现代密码学应用涉及具有数百或数千位数的巨大数字。
Q. 我如何找出在我的计算机上将两个浮点数相加或相乘需要多长时间?
A. 进行一些实验!程序 timeops.py 使用Stopwatch,如在第 3.2 节中定义的 stopwatch.py,来测试整数和浮点数的各种算术运算的执行时间。这种技术测量的是实际经过的时间,就像在挂钟上观察到的一样。如果您的系统没有运行许多其他应用程序,它可以产生准确的结果。Python 还包括用于测量小代码片段运行时间的timeit模块。
Q. 有没有办法测量处理器时间而不是挂钟时间?
A. 在某些系统上,函数调用time.clock()返回当前处理器时间作为浮点数,以秒为单位表示。如果可用,您应该用time.clock()替换time.time()来对 Python 程序进行基准测试。
Q. 函数如math.sqrt()、math.log()和math.sin()��要多少时间?
A. 进行一些实验!如在 stopwatch.py 中定义的 Stopwatch,使得很容易组合程序,比如 timeops.py 来自己回答这类问题。如果养成这样做的习惯,您将能够更有效地使用计算机。
Q. 为什么分配大小为n的数组(Python 列表)需要与n成正比的时间?
A. Python 将所有数组元素初始化为程序员指定的值。也就是说,在 Python 中,没有办法为数组分配内存而不为数组的每个元素分配对象引用。为大小为n的数组的每个元素分配对象引用需要与n成正比的时间。
Q. 我如何找出我的 Python 程序可用的内存量?
A. 由于 Python 在内存耗尽时会引发MemoryError,因此运行一些实验并不困难。例如,使用 bigarray.py。像这样运行它:
% python bigarray.py 100000000
finished
以显示您有 100 百万个整数的空间。但如果您键入
% python bigarray.py 1000000000
Python 将挂起、崩溃或引发运行时错误;您可以得出结论,您没有足够的空间来存储 10 亿个整数的数组。
Q. 当有人说算法的最坏情况运行时间是O(n²)时,这意味着什么?
A. 那是一种称为大 O表示法的记法示例。我们写f(n)是O(g(n)),如果存在常数c和n[0],使得对于所有n > n[0],f(n) ≤ c g(n)。换句话说,函数f(n)在常数因子和足够大的n值下被g(n)上界约束。例如,函数 30n² + 10n + 7 是O(n²)。我们说算法的最坏情况运行时间是O(g(n)),如果作为输入大小n的函数运行时间对于所有可能的输入都是O(g(n))。这种记法被理论计算机科学家广泛用于证明关于算法的定理,因此如果您学习算法和数据结构课程,您肯定会看到它。它提供了最坏情况性能保证。
Q. 那么我可以利用算法的最坏情况运行时间为O(n³)或O(n²)来预测性能吗?
A. 不,因为实际运行时间可能要少得多。例如,函数 30n² + 10n + 7 是O(n²),但它也是O(n³)和O(n¹⁰),因为大 O 符号只提供最坏情况下运行时间的上限。此外,即使有一些输入族,其运行时间与给定函数成正比,也许这些输入在实践中并不常见。因此,你不能使用大 O 符号来预测性能。我们使用的波浪符号和增长顺序分类比大 O 符号更精确,因为它们提供了函数增长的匹配上限和下限。许多程序员错误地使用大 O 符号来表示匹配的上限和下限。
Q. Python 通常使用多少内存来存储包含 n 个项目的元组?
A. 56 + 8n 字节,再加上对象本身所需的内存。这比数组少一点,因为 Python 可以使用数组而不是调整大小的数组来实现元组(在机器级别)。
Q. 为什么 Python 要使用这么多内存(280 字节)来存储一个将对象的实例变量映射到其值的字典?
A. 原则上,同一数据类型的不同对象可以有不同的实例变量。在这种情况下,Python 需要一种方式来管理每个对象的可能实例变量的任意数量。但大多数 Python 代码不需要这样做(而且,作为一种风格,我们在这本书中从未需要过)。
练习
修改 threesum.py 以接受一个名为
x的命令行参数,并在标准输入中找到三个数字的组合,使它们的和最接近x。编写一个程序
foursum.py,从标准输入中获取一个整数n,然后从标准输入中读取n个整数,并计算总和为零的 4 元组的数量。使用四重循环。你的程序的运行时间增长的顺序是多少?估计你的程序在一个小时内能处理的最大n是多少。然后,运行你的程序验证你的假设。证明 1 + 2 + ... + n = n(n+1)/2。
解答:我们在第 2.3 节开始时通过归纳证明了这一点。以下是另一种证明的基础:
1 + 2 + ... + n-1 + n + n + n-1 + ... + 2 + 1 ---------------------------- n+1 + n+1 + ... + n+1 + n+1通过归纳证明,在 0 到 n-1 之间的不同三元组的数量是 n(n-1)(n-2)/6。
解答:对于 n = 2,该公式是正确的。对于 n > 2,计算所有不包含 n-1 的三元组,根据归纳假设为(n-1)(n-2)(n-3)/6,以及包含 n-1 的所有三元组,为(n-1)(n-2)/2,得到总数
(n-1)(n-2)(n-3)/6 + (n-1)(n-2)/2 = n(n-1)(n-2)/6 通过用积分近似显示,在 0 到 n-1 之间的不同三元组的数量约为 n³/6。
![积分]()
运行以下代码片段后,
x的值(作为 n 的函数)是多少?x = 0 for i in range(n): for j in range(i+1, n): for k in range(j+1, n): x += 1解答:n(n-1)(n-2)/6。
使用波浪符号简化以下每个公式,并给出每个的增长顺序:
n(n - 1)(n - 2)(n - 3) / 24
(n - 2) (lg n - 2) (lg n + 2)
n(n + 1) - n²
n(n + 1)/2 + n lg n
ln((n - 1)(n - 2) (n - 3))²
以下代码片段是线性的、二次的还是立方的(作为 n 的函数)?
for i in range(n): for j in range(n): if i == j: c[i][j] = 1.0 else: c[i][j] = 0.0假设算法在大小为 1000、2000、3000 和 4000 的输入上的运行时间分别为 5 秒、20 秒、45 秒和 80 秒。估计解决大小为 5000 的问题需要多长时间。该算法是线性的、线性对数的、二次的、立方的还是指数的?
你更喜欢哪种算法:二次的、线性对数的还是线性的?
解决方案:尽管根据增长顺序做出快速决定很诱人,但这样做很容易被误导。你需要对问题规模和运行时间的主导系数的相对值有一些了解。例如,假设运行时间为n²秒,100 n log[2] n秒,和 10,000 n秒。对于n最多约为 1000 时,二次算法将是最快的,而线性算法永远不会比线性对数算法更快(n必须大于 2¹⁰⁰,远远太大了,不值得考虑)。
运用科学方法来开发和验证关于以下代码片段的运行时间增长顺序的假设,作为输入参数
n的函数。def f(n): if (n == 0): return 1 return f(n-1) + f(n-1)运用科学方法来开发和验证关于以下两个代码片段的运行时间增长顺序的假��,作为
n的函数。s = '' for i in range(n): if stdrandom.bernoulli(0.5): s += '0' else: s += '1's = '' for i in range(n): oldS = s if stdrandom.bernoulli(0.5): s += '0' else: s += '1'解决方案:在许多系统上,第一个是线性的;第二个是二次的。你无法知道原因:在第一种情况下,Python 检测到s是唯一引用字符串的变量,因此它会将每个字符附加到字符串上,就像对列表一样(在摊销常数时间内),即使字符串是不可变的!一个更安全的替代方法是创建一个包含字符的列表,并通过调用
join()方法将它们连接在一起。a = [] for i in range(n): if stdrandom.bernoulli(0.5): a += ['0'] else: a += ['1'] s = ''.join(a)下面的四个 Python 函数中的每个函数返回一个长度为
n且所有字符都是x的字符串。确定每个函数的运行时间的增长顺序。回想一下,在 Python 中连接两个字符串的时间与它们长度的和成正比。def f1(n): if (n == 0): return '' temp = f1(n // 2) if (n % 2 == 0): return temp + temp else: return temp + temp + 'x'def f2(n): s = '' for i in range(n): s += 'x' return sdef f3(n): if (n == 0): return '' if (n == 1): return 'x' return f3(n//2) + f3(n - n//2)def f4(n): temp = stdarray.create1D(n, 'x') return ''.join(temp)def f5(n): return 'x' * n以下代码片段(改编自一本 Java 编程书籍)创建了从 0 到n-1 的整数的随机排列。确定其运行时间的增长顺序作为n的函数。将其增长顺序与第 1.4 节中的洗牌代码进行比较。
a = stdarray.create1D(n, 0) taken = stdarray.create1D(n, False) count = 0 while (count < n): r = stdrandom.uniformInt(n) if not taken[r]: a[r] = count taken[r] = True count += 1以下代码片段中的第一个
if语句在三重嵌套循环中执行多少次?for i in range(n): for j in range(n): for k in range(n): if (i < j) and (j < k): if a[i] + a[j] + a[k] == 0: count += 1使用波浪符号表示简化你的答案。
运用科学方法来开发和验证关于 coupon.py(来自第 2.1 节)中
collect()方法运行时间增长顺序的假设,作为参数n的函数。注意:加倍对于区分线性和线性对数假设并不有效 — 你可以尝试将输入大小的平方。运用科学方法来开发和验证关于 markov.py(来自第 1.6 节)运行时间增长顺序的假设,作为
moves和n参数的函数。编写一个名为
mooreslaw.py的程序,该程序接受一个命令行参数n,并在处理器速度每n个月翻倍时,写入处理器速度在十年内的增长量。如果速度每n = 15 个月翻倍,处理器速度在接下来的十年内将增加多少?24 个月呢?使用文本中的内存模型,给出第三章中以下数据类型的每个对象的内存需求:
秒表乌龟向量主体宇宙
估计 visualizev.py(来自第 2.4 节)在垂直渗透检测下使用的空间量,作为网格大小n的函数。额外学分:回答当使用 percolation.py 中的递归渗透检测方法时相同的问题。
估计你的计算机可以容纳的最大n乘以n的整数数组的大小,然后尝试分配这样的数组。
估计 comparedocuments.py(来自第 3.3 节)使用的空间量,作为文档数量n和维度d的函数。
编写一个版本的 primesieve.py(来自第 1.4 节),它使用整数数组而不是布尔数组,并在每个整数中使用 32 位,以提高其能处理的最大n值的因子为 32。
以下表格给出了各个程序在不同n值下的运行时间。根据给定信息填写空白处的估计值。
程序 1,000 10,000 100,000 1,000,000 A 0.001 秒 0.012 秒 0.16 秒 ?秒 B 1 分钟 10 分钟 1.7 小时 ?小时 C 1 秒 1.7 分钟 2.8 小时 ?天 给出每个程序运行时间增长顺序的假设。
创意练习
三数之和分析。计算n个随机 32 位整数中没有三元组总和为 0 的概率,并给出n等于 1000、2000 和 4000 时的近似估计。额外加分:给出预期这样的三元组数量的近似公式(作为n的函数),并运行实验验证你的估计。
最接近对。设计一个二次算法,找到彼此最接近的整数对。(在下一节中,你将被要求找到一个线性对数算法。)
幂律。证明函数cn^b的对数-对数图的斜率为b,x截距为 log c。对于 4 n³(log n)²,斜率和x截距是多少?
距离零最远的和。设计一个找到和距离零最远的整数对的算法。你能发现一个线性算法吗?
"贝克"漏洞。���个流行的 Web 服务器支持一个名为
no2slash()的函数,其目的是折叠多个/字符。例如,字符串/d1///d2////d3/test.html变成/d1/d2/d3/test.html。原始算法是重复搜索/并复制字符串的其余部分:def no2slash(name): nameList = list(name) x = 1 while x < len(nameList): if (nameList[x-1] == '/') and (nameList[x] == '/'): for y in range(x+1, len(nameList)): nameList[y-1] = nameList[y] nameList = nameList[:-1] else: x += 1 return ''.join(nameList)不幸的是,这段代码的运行时间与输入中的
/字符数量成二次关系。通过发送带有大量/字符的多个同时请求,黑客可以淹没服务器并使其他进程饥饿,从而创建拒绝服务攻击。开发一个在线性时间内运行且不允许这种攻击的no2slash()版本。年轻图。假设你在内存中有一个n乘n的整数网格
a[][],使得对于所有的i和j,a[i][j] < a[i+1][j]且a[i][j] < a[i][j+1],就像下面的表格一样。5 23 54 67 89 6 69 73 74 90 10 71 83 84 91 60 73 84 86 92 90 91 92 93 94设计一个在n中具有线性增长顺序的算法,用于确定给定整数x是否在给定的年轻图中。
解决方案:从右上角开始。如果值为x,则返回
True。否则,如果值大于x则向左移动,如果值小于x则向下移动。如果到达左下角,则x不在表中。该算法是线性的,因为你最多可以向左移动n次,向下移动n次。子集和。编写一个名为
anysum.py的程序,从标准输入中获取一个整数n,然后从标准输入中读取n个整数,并计算总和为 0 的子集数量。给出程序运行时间的增长顺序。数组旋转。给定一个包含n个元素的数组,给出一个线性时间算法来将数组旋转k个位置。也就是说,如果数组包含a[0]、a[1]、...、a[n-1],则旋转后的数组是a[k]、a[k+1]、...、a[n-1]、a[0]、...、a[k-1]。使用最多恒定量的额外空间(数组索引和数组值)。提示:反转三个子数组。
查找重复整数。 (a) 给定一个从 1 到n的n个整数数组,其中一个值重复两次,一个缺失,给出一个在线性时间和常数额外空间内找到缺失整数的算法。 (b) 给定一个只读的n个整数数组,其中每个值从 1 到n-1 出现一次,一个出现两次,给出一个在线性时间和常数额外空间内找到重复值的算法。 (c) 给定一个只读的n个整数数组,值介于 1 和n-1 之间,给出一个在线性时间和常数额外空间内找到重复值的算法。
阶乘。设计一个快速算法来计算大值n!。使用你的程序计算 1000000!中连续 9 的最长运行时间。为你的算法的运行时间增长顺序开发并验证一个假设。
最大和。设计一个线性算法,在n个整数序列中找到最多m个元素的连续子序列,其和最大。实现你的算法,并确认其运行时间的增长顺序为线性。
模式匹配。给定一个由黑色(1)和白色(0)像素组成的n×n数组,设计一个线性算法,找到完全由黑色像素组成的最大正方形子数组。例如,以下 8×8 数组包含一个完全由黑色像素组成的 3×3 子数组。
1 0 1 1 1 0 0 0 0 0 0 1 0 1 0 0 0 0 1 1 1 0 0 0 0 0 1 1 1 0 1 0 0 0 1 1 1 1 1 1 0 1 0 1 1 1 1 0 0 1 0 1 1 0 1 0 0 0 0 1 1 1 1 0实现你的算法,并确认其运行时间的增长顺序与像素数量成线性关系。额外加分:设计一个算法来找到最大的矩形黑色子数组。
最大平均值。编写一个程序,在n个整数数组中找到最多m个元素的连续子数组,其平均值最高,通过尝试所有子数组。使用科学方法确认你的程序的运行时间增长顺序为mn²。接下来,编写一个程序,通过首先计算
prefix[i] = a[0] + ... + a[i],然后使用表达式(prefix[j] - prefix[i]) / (j - i + 1)计算从a[i]到a[j]的区间的平均值。使用科学方法确认这种方法将运行时间的增长顺序减少了一个n的因子。次指数函数。找到一个比任何多项式函数慢但比任何指数函数快的函数。额外加分:编写一个具有该增长顺序的运行时间的程序。
调整数组。对于以下每种策略,要么证明每个调整数组操作的摊销时间为常数,要么找到一系列n个操作(从空数据结构开始),其时间为二次方。
当调整数组满时,将容量加倍;当数组半满时,将容量减半。
当调整数组满时,将容量加倍;当数组三分之一满时,将容量减半。
当调整数组满时,将容量增加 9/8 倍;当数组 80%满时,将容量减少 9/8 倍。
4.2 排序和搜索
原文:
introcs.cs.princeton.edu/python/42sort译者:飞龙
排序问题是将一组项目按升序重新排列。它如此有用的一个原因是在排序列表中搜索比在未排序列表中搜索要容易得多。在本节中,我们将详细讨论两种经典的排序和搜索算法,以及它们的效率在几个关键应用中发挥作用的情况。
二分查找

在“二十个问题”游戏中,你的任务是猜测一个隐藏数字的值,该数字是 0 到n-1 之间的n个整数之一。(为简单起见,我们假设n是 2 的幂。)每次猜测时,你会被告知你的猜测是太高还是太低。一种有效的策略是维护一个包含隐藏数字的区间lo, hi),猜测区间中间的数字,然后利用答案将区间大小减半。程序[questions.py 实现了这种策略,这是已知的一般问题解决方法二分查找的一个示例。
正确性证明。
首先,我们必须确信这种方法是正确的:它总是引导我们找到隐藏数字。我们通过建立以下事实来做到这一点:
区间始终包含隐藏数字。
区间大小是 2 的幂,从n减小。
这些事实是归纳证明方法运行如预期的基础。最终,区间大小变为 1,因此我们保证找到数字。
运行时间分析。
由于每次迭代间隔大小减少 2 倍(当n=1 时达到基本情况),二分查找的运行时间为 lgn。
线性对数鸿沟。
使用二分查找的替代方法是猜测 0,然后 1,然后 2,然后 3,依此类推,直到找到隐藏数字。我们将这样的算法称为蛮力算法:它似乎可以完成任务,但并不太关心成本(这可能会阻止它实际完成大问题的任务)。在这种情况下,蛮力算法的运行时间对输入值敏感,但如果输入值是随机选择的,则可能达到n,并且期望值为n/2。与此同时,二分查找保证不会超过 lgn步。
二进制表示。
如果你回顾一下 binary.py,你会发现二分查找几乎与将数字转换为二进制的计算相同!每次猜测确定答案的一个位。在我们的例子中,数字在 0 到 127 之间的信息表明其二进制表示中的位数为 7,第一个问题的答案(数字是否大于或等于 64?)告诉我们领先位的值,第二个问题的答案告诉我们下一个位的值,依此类推。例如,如果数字是 77,答案序列 true false, false, true, true, false, true 立即得到 77 的二进制表示 1001101。
反转函数。
作为科学计算中二分查找实用性的一个例子,我们重新讨论了在第 2.1 节练习中首次遇到的问题:反转一个递增函数。给定一个递增函数f和一个值y,以及一个开区间lo, hi),我们的任务是找到区间内的一个值x,使得f(x) = y。在这种情况下,我们使用实数作为区间的端点,而不是整数,但我们使用了与猜测“二十个问题”问题中相同的基本方法:在每一步中将区间长度减半,保持x在区间内,直到区间足够小,我们可以在所需精度内确定x的值,这个精度作为函数的参数。这个图示了第一步。
![二分查找反转递增函数
程序 bisection.py 实现了这个策略。我们从一个已知包含x的区间(lo, hi)开始,并使用以下递归过程:
计算mid = (hi + lo) / 2。
基本情况:如果hi - lo小于δ,则将mid作为x的估计返回。
递归步骤:否则,测试f(mid) > y。如果是,查找(lo, mid)中的x;如果不是,查找(mid, hi)中的x。
这种方法的关键在于函数是递增的——对于任意值a和b,知道f(a) < f(b)告诉我们a < b,反之亦然。在这种情况下,二分查找通常被称为二分查找,因为我们在每个阶段将区间二等分。
在有序数组中进行二分查找。
在上个世纪的大部分时间里,人们会使用一种称为字典的出版物来查找单词的定义。条目按照一个标识它的键(单词)排序,以顺序出现。想想你会如何在字典中查找一个单词。一种蛮力的解决方案是从头开始,逐个检查每个条目,直到找到单词。没有人会使用这种方法:相反,你会打开字典的某个内部页面,然后在该页面上查找单词。如果找到了,你就完成了;否则,你要么排除当前页面之前的部分,要么排除当前页面之后的部分,并重复这个过程。
异常过滤器。
我们现在使用二分查找来解决存在问题:给定键是否在已排序键的数据库中?例如,在检查单词拼写时,你只需要知道你的单词是否在字典中,而不关心定义。在计算机搜索中,我们将信息保存在一个数组中,按照键的顺序排序。binarysearch.py 中的二分查找代码在两个细节上与我们的其他应用不同。首先,数组长度n不必是 2 的幂。其次,它必须允许所寻找的项不在数组中的可能性。客户程序实现了一个异常过滤器:它从文件中读取一个按照键排序的字符串列表,我们称之为白名单(例如,white.txt),以及从标准输入读取一系列任意字符串(例如,emails.txt),并将那些不在白名单中的字符串按顺序写入。
插入排序
二分查找要求数组已排序,并且排序有许多其他直接应用,因此我们现在转向排序算法。我们首先考虑一个蛮力算法,然后考虑一个可以用于大型数组的复杂算法。
我们考虑的蛮力算法称为插入排序。它基于人们经常用来整理扑克牌的简单方法——也就是,逐个考虑卡片并将每张卡片插入到已考虑的卡片中的适当位置(保持它们排序)。
程序 insertion.py 包含一个sort()函数的实现,模拟这个过程来对长度为n的数组a[]中的元素进行排序。测试客户端从标准输入读取所有字符串,将它们放入数组中,调用sort()函数对它们进行排序,然后将排序后的结果写入标准输出。尝试运行它来对小型 tiny.txt 文件进行排序。还尝试运行它来对更大的 tomsawyer.txt 文件进行排序,但要准备等待很长时间!
外部循环对数组中的前i个条目进行排序;内部循环可以通过将a[i]放入数组中的正确位置来完成排序。
数学分析。
sort()函数包含一个嵌套在for循环中的while循环,这表明运行时间是二次的。然而,我们不能立即得出这个结论,因为while循环在a[j]大于或等于a[j-1]时终止。
最佳情况. 当输入数组已经按顺序排序时,内部循环仅仅是一个比较(学习
a[j-1]小于或等于a[j]),因此总运行时间是线性的。最坏情况. 当输入是逆序排序时,内部循环直到j等于 0 才终止。因此,内部循环中指令的执行频率为 1 + 2 + ... + n-1 ~ n²/2。
平均情况. 当输入是随机排序时,我们期望每个要插入的新元素等可能地落入任何位置,因此该元素平均向左移动一半。因此,我们期望运行时间为 1/2 + 2/2 + ... + (n-1)/2 ~ n²/2。
实证分析。
程序 timesort.py 实现了对排序函数的倍增测试。我们可以使用它来确认我们的假设,即插入排序对随机排序的文件是二次的:
% python
>>> import insertion
>>> import timesort
>>> timesort.doublingTest(insertion.sort, 128, 100)
128 3.67
256 3.73
512 4.21
1024 4.19
2048 4.11
对输入的敏感性。
请注意,timesort.py 中的doublingTest()函数接受参数m并为每个数组大小运行m个实验,而不仅仅是一个。这样做的一个原因是插入排序的运行时间对其输入值很敏感。直接预测插入排序的运行时间将是二次的是不正确的,因为您的应用可能涉及其运行时间为线性的输入。
可比较的键。
我们希望能够对具有自然顺序的任何类型的数据进行排序。令人高兴的是,我们的插入排序和二分查找函数不仅适用于字符串,还适用于任何可比较的数据类型。您可以通过实现对应于==、!=、<、<=、>和>=运算符的六个特殊方法来使用户定义的类型可比较。实际上,我们的插入排序和二分查找函数仅依赖于<运算符,但最好实现所有六个特殊方法。
归并排序

为了开发一个更快的排序算法,我们使用了分而治之的算法设计方法,每个程序员都需要理解这个术语。这个术语指的是解决问题的一种方法是将问题分解为独立的部分,独立解决它们,然后使用这些部分的解决方案来开发完整问题的解决方案。为了使用这种策略对数组进行排序,我们将其分成两半,独立对两半进行排序,然后合并结果以对整个数组进行排序。这种方法被称为归并排序。对于排序 alo, hi),我们使用以下递归策略:
基本情况: 如果子数组大小为 0 或 1,则已经排序。
递归步骤: 否则,计算mid = (hi + lo)/2,对两个子数组a[lo, mid)和a[mid, hi)进行排序(递归),然后合并它们。
程序[merge.py 是一个实现。与 insert.py 一样,测试客户端从标准输入中读取所有字符串,将它们放入数组中,调用sort()函数对它们进行排序,然后将排序后的结果写入标准输出。尝试运行它来对小的 tiny.txt 文件和更大的 tomsawyer.txt 文件进行排序。
和往常一样,理解合并过程的最简单方法是研究合并过程中数组内容的跟踪。
数学分析。
归并排序的内部循环集中在辅助数组上。for循环涉及n次迭代,因此内部循环中指令的执行频率与递归函数的所有调用中子数组长度的总和成比例。当我们根据大小将调用排列在不同级别时,这个数量的值就出现了。为简单起见,假设n是 2 的幂,即n = 2^(k)。在第一级别上,我们有一个大小为n的调用;在第二级别上,我们有两个大小为n/2 的调用;在第三级别上,我们有四个大小为n/4 的调用;依此类推,直到最后一个大小为 2 的n/2 调用的级别。总共有k = lg n个级别,使得归并排序内部循环中指令的执行频率总共为n lg n。这个方程证明了归并排序的运行时间是线性对数的假设。
当n不是 2 的幂时,每个级别上的子数组不一定都是相同大小,但级别的数量仍然是对数的,因此对于所有n,线性对数的假设都是合理的。
实证分析。
我们可以运行程序 timesort.py 进行一个加倍测试,以确认我们的假设,即归并排序对于随机排序的文件具有n lg n的运行时间:
% python
>>> import merge
>>> import timesort
>>> timesort.doublingTest(merge.sort, 128, 100)
128 1.84
256 2.15
512 2.22
1024 2.17
2048 2.13
4096 2.12
8192 2.14
二次线性对数的鸿沟。
n²和n lg n之间的差异在实际应用中有很大差别。理解这种巨大差异是理解算法设计和分析的重要性的另一个关键步骤。对于许多重要的计算问题,从二次到线性对数的加速使得能否解决涉及大量数据的问题成为可能,而不是根本无法有效解决。
Python 系统排序
Python 包括两种排序操作。内置list数据类型中的sort()方法将底层列表中的项目重新排列为升序,类似于merge.sort()。相比之下,内置函数sorted()不会改变底层列表;相反,它返回一个包含���升序排列的项目的新列表。右侧的这个交互式 Python 脚本演示了这两种技术。
% python
>>> a = [3, 1, 4, 1, 5]
>>> b = sorted(a)
>>> a
[3, 1, 4, 1, 5]
>>> b
[1, 1, 3, 4, 5]
>>> a.sort()
>>> a
[1, 1, 3, 4, 5]
>>>
Python 系统排序使用归并排序的一个版本。它可能比 merge.py 快得多(10-20×),因为它使用了一个低级别的实现,而不是由 Python 组成的,从而避免了 Python 对自身施加的重大开销。与我们的排序实现一样,您可以使用系统排序与任何可比较的数据类型,例如 Python 的内置str、int和float数据类型。
应用:频率计数
程序 frequencycount.py 从标准输入读取一系列字符串,然后按照频率降序写入找到的不同值和每个值找到的次数的表格。我们通过两次排序来实现这一点。
计算频率。
我们的第一步是对标准输入中的字符串进行排序。在这种情况下,我们更感兴趣的不是字符串被排序,而是排序将相同的字符串放在一起。如果输入是
to be or not to be to
那么排序的结果是
be be not or to to to
将相同的字符串(如数组中的三个to出现)放在一起。现在,将所有相同的字符串放在数组中,我们可以通过数组进行单次遍历来计算所有频率。在第 3.3 节中定义的Counter类,是这项工作的完美工具。
对频率进行排序。
接下来,我们按频率对Counter对象进行排序。我们可以通过在Counter数据类型中增加六个比较方法来在客户端代码中这样做,以比较Counter对象的计数。因此,我们只需对Counter对象数组进行排序,以按频率升序重新排列它们!接着,我们反转数组,使元素按频率降序排列。最后,我们将每个Counter对象写入标准输出。
齐普夫定律。
frequencycount.py 中突出显示的应用是基本的语言分析:文本中哪些单词出现频率最高?一种被称为齐普夫定律的现象表明,文本中第i个最常见单词的频率与 1/i成比例。尝试在大型文件 leipzig100k.txt、leipzig200k.txt 和 leipzig1m.txt 上运行 frequencycount.py,观察这种现象。
问与答
Q. 为什么我们需要如此费力地证明程序的正确性?
A. 为了避免自己遭受相当大的痛苦。二分查找是一个显著的例子。例如,现在你理解了二分查找;一个经典的编程练习是编写一个使用while循环而不是递归的版本。尝试在不回头查看书中代码的情况下解决本节的前三个练习。在一次著名的实验中,乔恩·本特利曾要求几位专业程序员这样做,他们大多数的解决方案都是不正确的。
Q. Python 提供了一个在list数据类型中定义的高效sort()方法,为什么要介绍归并排序算法?
A. 和我们学习过的许多主题一样,如果你了解它们背后的背景,就能更有效地使用这些工具。
Q. 在已经排序的数组上运行以下版本的插入排序的运行时间是多少?
def sort(a):
n = len(a)
for i in range(1, n):
for j in range(i, 0, -1):
if a[j] < a[j-1]: exchange(a, j, j-1)
else: break
A. 在 Python 2 中为二次时间;在 Python 3 中为线性时间。原因是,在 Python 2 中,range()是一个返回整数数组的函数,其长度等于范围的长度(如果循环因break或return语句而提前终止,可能会浪费)。在 Python 3 中,range()返回一个迭代器,只生成所需的整数。
Q. 如果我尝试对不全是相同类型的元素数组进行排序会发生什么?
A. 如果元素是兼容类型(如int和float),一切都能正常工作。例如,混合数值类型根据其数值进行比较,因此 0 和 0.0 被视为相等。如果元素是不兼容类型(如str和int),那么 Python 3 在运行时会引发TypeError。Python 2 支持一些混合类型比较,使用类名确定哪个对象较小。例如,Python 2 将所有整数视为小于所有字符串,因为'int'在字典顺序上小于'str'。
Q. 在使用诸如==和<等运算符比较字符串时使用哪种顺序?
A. 非正式地,Python 使用 字典顺序 来比较两个字符串,就像书目录或字典中的单词一样。例如 'hello' 和 'hello' 相等,'hello' 和 'goodbye' 不相等,'goodbye' 小于 'hello'。更正式地,Python 首先比较每个字符串的第一个字符。如果这些字符不同,则整个字符串比较为这两个字符的比较。否则,Python 比较每个字符串的第二个字符。如果这些字符不同,则整个字符串比较为这两个字符的比较。以此类推,如果 Python 同时到达两个字符串的末尾,则认为它们相等。否则,它认为较短的字符串较小。Python 使用 Unicode 进行逐字符比较。我们列出了一些最重要的属性:
'0'小于'1',依此类推。'A'小于'B',依此类推。'a'小于'b',依此类推。十进制数字(
'0'到'9')小于大写字母('A'到'Z')。大写字母(
'A'到'Z')小于小写字母('a'到'z')。
练习
开发一个实现 questions.py 的程序,将最大数
n作为命令行参数(不必是 2 的幂)。证明你的实现是正确的。编写 binarysearch.py 的非递归版本。
修改 binarysearch.py,使得如果搜索键在数组中,则返回最小的索引
i,使得a[i]等于key,否则返回最大的索引i,使得a[i]小于key(如果不存在这样的索引则返回-1)。描述将二分查找应用于无序数组会发生什么。为什么在每次调用二分查找之前不应检查数组是否已排序?你能检查二分查找检查的元素是否按升序排列吗?
描述为什么在二分查找中使用不可变键是可取的。
设 f() 是一个单调递增函数,满足 f(a) < 0 且 f(b) > 0。编写一个计算值 x 的程序,使得 f(x) = 0(在给定误差容限内)。
在 insertion.py 中添加代码以生成上述轨迹。
在 merge.py 中添加代码以生成上述轨迹。
以上述示例的方式给出插入排序和归并排序的轨迹,输入为
it was the best of times it was。编写一个名为
dedup.py的程序,从标准输入读取字符串,并将它们写入标准输出,删除所有重复项(并按排序顺序排列)。编写一个归并排序的版本,如 merge.py 中定义的那样,在每次递归调用
_merge()中创建一个辅助数组,而不是仅在sort()中创建一个辅助数组并将其作为参数传递。这种改变对性能有什么影响?编写 merge.py 中定义的归并排序的非递归版本。
找出你最喜欢的书中单词的频率分布。它是否遵循 Zipf 定律?
创意练习
以下练习旨在让您体验开发解决典型问题的快速解决方案。考虑使用二分查找、归并排序或设计自己的分治算法。实现并测试您的算法。
中位数。研究
stdstats.py中的函数median()。它以线性对数时间计算给定数字数组的中位数。请注意,它通过将问题简化为排序来工作。众数。在
stdstats.py中添加一个函数mode(),以线性对数时间计算一系列 n 个整数的众数(出现频率最高的值)。提示:简化��排序。整数排序。编写一个线性时间过滤器,从标准输入读取介于 0 和 99 之间的整数序列,并按排序顺序将整数写入标准输出。例如,给定输入序列
98 2 3 1 0 0 0 3 98 98 2 2 2 0 0 0 2你的程序应该输出序列
0 0 0 0 0 0 1 2 2 2 2 2 3 3 98 98 98下界和上界。给定一个已排序的 n 个可比较键的数组,编写
floor()和ceiling()函数,以对数时间返回不大于(或不小于)参数键的最大(或最小)键的索引。双峰最大值。如果一个数组由一个递增序列的键紧接着一个递减序列的键组成,则该数组是双峰的。给定一个双峰数组,设计一个对数时间的算法来找到最大键的索引。
在双峰数组中搜索。给定一个包含n个不同整数的双峰数组,设计一个对数时间的算法来确定给定整数是否在数组中。
最接近的一对。给定一个n个浮点数的数组,编写一个函数,在线性对数时间内找到数值最接近的一对浮点数。
最远的一对。给定一个n个浮点数的数组,编写一个函数,在线性时间内找到数值上最远的一对整数。
两数之和。编写一个函数,该函数以n个整数数组作为参数,并在线性对数时间内确定是否有两个数相加等于 0。
三数之和。编写一个函数,以n个整数数组作为参数,并确定是否有三个数相加等于 0。你的程序应该在时间上与n² log n成比例。额外加分:开发一个能在二次时间内解决问题的程序。
多数派。给定一个包含n个元素的数组,如果一个元素出现超过n/2 次,则该元素是多数派。编写一个函数,将一个包含n个字符串的数组作为参数,并在线性时间内识别多数派(如果存在)。
公共元素。编写一个函数,该函数以三个字符串数组作为参数,确定是否有任何一个字符串在这三个数组中都存在,并如果有,则返回其中一个字符串。你的函数的运行时间应该与字符串的总数成线性对数关系。
最大空闲区间。给定n个请求文件的时间戳,找到在没有请求文件的最长时间间隔。编写一个程序,在线性对数时间内解决这个问题。
前缀自由编码。在数据压缩中,如果一组字符串是前缀自由的,那么没有一个字符串是另一个字符串的前缀。例如,字符串集合
01、10、0010和1111是前缀自由的,但字符串集合01、10、0010、1010不是前缀自由的,因为10是1010的前缀。编写一个程序,从标准输入中读取一组字符串,并确定该集合是否是前缀自由的。分区。编写一个函数,对已知最多有两个不同值的数组进行排序。提示:保持两个指针,一个从左端开始向右移动,另一个从右端开始向左移动。保持不变的是,左指针左侧的所有元素都等于两个值中较小的值,右指针右侧的所有元素都等于两个值中较大的值。
荷兰国旗。编写一个函数,对已知最多有三个不同值的数组进行排序。(Edsgar Dijkstra 将这称为荷兰国旗问题,因为结果是三个值的“条纹”,就像国旗中的三条条纹一样。)提示:通过首先将数组分成两部分,第一部分中的所有元素具有最小值,第二部分中的所有其他元素,然后对第二部分进行分区,将问题简化为前一个问题。
快速排序。编写一个递归程序,对一个随机排序的不同元素数组进行排序。提示:使用类似前一个练习中描述的方法。首先,将数组分成一个左部分,其中所有元素都小于v,然后是v,然后是一个右部分,其中所有元素都大于v。然后,递归地对这两部分进行排序。额外加分:修改你的方法(如果需要),使其在元素不一定不同的情况下也能正常工作。
反向域名。编写一个程序,从标准输入中读取域名列表,并按排序顺序写出反向域名。例如,
cs.princeton.edu的反向域名是edu.princeton.cs。这种计算对于网络日志分析很有用。为此,创建一个实现特殊比较方法的数据类型Domain,使用反向域名顺序。数组中的局部最小值。给定一个包含n个浮点数的数组,编写一个函数以对数时间找到一个局部最小值(一个索引
i,满足a[i] < a[i-1]且a[i] < a[i+1])。离散分布。设计一个快速算法,重复地从离散分布中生成数字。给定一个非负浮点数数组
p[],其总和为 1,目标是以概率p[i]返回索引i。形成一个累积和数组s[],使得s[i]是p[]的前i个元素的和。现在,生成一个介于 0 和 1 之间的随机浮点数r,并使用二分查找返回索引i,满足s[i] ≤ r < s[i+1]。押韵单词。制表一个列表,您可以使用它来查找押韵的单词。使用以下方法:
将单词字典读入字符串数组中。
反转每个单词中的字母(例如,
confound变为dnuofnoc)。对结果数组进行排序。
将每个单词中的字母恢复到原始顺序。
例如,
confound在结果列表中与astound和surround等单词相邻。
4.3 栈和队列
原文:
introcs.cs.princeton.edu/python/43stack译者:飞龙
在本节中,我们介绍了两种密切相关的数据类型,用于操作任意大的对象集合:栈和队列。每种类型都由两个基本操作定义:插入一个新项和移除一个项。当我们插入一个项时,我们的意图是明确的。但当我们移除一个项时,我们应该选择哪一个?队列使用的规则是始终移除在集合中存在时间最长的项。这个策略被称为先进先出或FIFO。栈使用的规则是始终移除在集合中存在时间最短的项。这个策略被称为后进先出或LIFO。
推入栈

一个推入栈(或简称栈)是基于后进先出(LIFO)策略的集合。当你点击一个超链接时,你的浏览器会显示新页面(并将其插入到栈中)。你可以继续点击超链接访问新页面。你可以通过点击返回按钮(从栈中移除)来重新访问上一页。推入栈提供了你期望的行���。
按照传统,我们将栈的插入操作称为push,将栈的移除操作称为pop。我们还包括一个方法来测试栈是否为空。以下 API 总结了这些操作:
Python 列表(调整大小数组)实现的栈
用 Python 列表表示栈是一个自然的想法,但在继续阅读之前,值得你花一点时间考虑如何实现。

自然地,你需要一个实例变量a[]来在 Python 列表中保存栈项。为了效率,我们按照插入顺序存储项,因为在 Python 列表的末尾插入和删除每次操作都需要恒定的摊销时间(而在开头插入和删除每次操作都需要线性时间)。
我们几乎无法希望有比 arraystack.py 更简单的栈 API 实现了 — 所有的方法都是一行代码!实例变量是一个 Python 列表_a[],按照插入顺序保存栈中的项。要推入一个项,我们使用+=运算符将其附加到列表的末尾;要弹出一个项,我们调用pop()方法,它会从列表的末尾移除并返回该项;要确定栈的大小,我们调用内置的len()函数。这些操作保持以下属性:
栈包含
len(_a)项。当
len(_a)为 0 时,栈为空。列表
_a[]包含栈项,按照插入顺序排列。栈中最近插入的项(如果非空)是
_a[len(_a) - 1]。
arraystack.py 中的测试客户端允许使用任意序列的操作进行测试:对于标准输入中的每个字符串,它执行一个push()操作,除了由减号组成的字符串,对于该字符串,它执行一个pop()操作。右侧的图表是测试文件 tobe.txt 的跟踪。
这种实现的主要特点是它使用的空间与栈中的项数成线性关系,并且推入和弹出操作需要恒定的摊销时间。
使用链表实现的栈
接下来,我们考虑一种完全不同的实现栈的方式,使用一种称为链表的基本数据结构。在这里重复使用“列表”这个词有点令人困惑,但我们别无选择 — 链表比 Python 存在的时间更长。
链表是一种递归数据结构,定义如下:它要么为空(null),要么是一个指向具有指向链表的节点的引用。在这个定义中,节点是一个抽象实体,除了表征其在构建链表中的角色的节点引用外,还可能包含任何类型的数据。
利用面向对象编程,实现链表并不困难。我们从节点抽象的类开始:
class Node:
def __init__(self, item, next):
self.item = item
self.next = next
类型为Node的对象有两个实例变量:item(指向一个项目的引用)和next(指向另一个Node对象的引用)。next实例变量表征了数据结构的链式特性。为了强调我们只是使用Node类来组织数据,我们除了构造函数外不定义任何方法。我们还从实例变量的名称中省略了前导下划线,这表明允许外部代码(但仍在我们的 Stack 实现内部)访问这些实例变量。
现在,根据递归定义,我们可以用一个指向Node对象的引用来表示一个链表,该对象包含一个指向项目的引用和另一个Node对象的引用,该对象包含一个指向项目的引用和另一个Node对象的引用,依此类推。链表中的最后一个Node对象必须指示它确实是最后一个 Node 对象。在 Python 中,我们通过将最后一个Node对象的next实例变量赋值为None来实现这一点。请记住,None是 Python 的一个关键字 — 赋值为None的变量不引用任何对象。

例如,要构建一个包含项目'to'、'be'和'or'的链表,我们执行以下代码:
third = Node('or', None)
second = Node('be', third)
first = Node('to', second)
为了简洁起见,我们使用术语link来指代Node引用。为了简单起见,当项目是一个字符串时(如我们的示例中),我们将其放在节点矩形内(而不是使用更准确的表达方式,即节点持有对外部字符串对象的引用)。这种视觉表示让我们可以专注于链接。

假设你想要从���表中删除第一个节点。这个操作很简单:只需将first赋值为first.next。通常,在执行此赋值之前,你会先检索项目(通过将其赋值给某个变量),因为一旦更改变量first,你可能会失去对先前引用的节点的任何访问权限。通常,Node对象变成孤立的,Python 的内存管理系统最终会回收它。

现在,假设你想要在链表中插入一个新节点。最容易的地方是在链表的开头插入。例如,要在第一个节点为first的给定链表中的开头插入字符串'not',我们将first保存在变量oldFirst中;创建一个新的Node,其item实例变量为'not',next实例变量为oldFirst;然后将first指向该新的Node。
这两个操作的时间复杂度是常数时间;它们的效率与链表的长度无关。
使用链表实现栈。
程序 linkedstack.py 使用链表来实现栈。该实现基于一个私有的_Node类,该类与我们一直在使用的Node类相同。我们将该类设为私有,因为Stack数据类型的客户端不需要知道链表的任何细节。通常,我们给类名加上前导下划线以强调Stack客户端不应直接访问_Node类。
链表遍历。
许多链表应用需要遍历链表中的项目。为此,我们首先初始化一个循环索引变量cur,引用链表的第一个Node。接下来,我们通过访问cur.item获取与cur关联的项目,然后更新cur以引用链表中的下一个Node,将cur.next的值赋给它,并重复此过程,直到cur为None(表示已到达链表的末尾)。这个过程称为遍历列表。在 linkedstack.py 中定义的__str__()方法执行列表遍历。
对于栈来说,链表很重要,因为它们允许我们在最坏情况下以常数时间实现push()和pop()方法,同时仅使用很小的额外空间常数因子(用于链接)。然而,Python 程序员通常更喜欢 Python 列表(调整大小的数组),主要是因为用户定义类型(如我们的链表Node)的 Python 开销很大。
栈应用
推入栈在计算中起着至关重要的作用。一些示例进行说明。
算术表达式。
在第一章中考虑的一些最初的程序涉及计算类似于这样的算术表达式的值:
( 1 + ( ( 2 + 3 ) * ( 4 * 5 ) ) )
Python 如何进行这种计算?我们可以通过编写一个 Python 程序来解决基本思想,该程序可以接受字符串作为输入(表达式)并将表达式表示的数字作为输出。为简单起见,我们从以下明确的递归定义开始:算术表达式是一个数字或一个左括号,后跟一个算术表达式,后跟一个运算符,后跟另一个算术表达式,后跟一个右括号。为简单起见,此定义适用于完全括号化的算术表达式,其中明确指定了哪些运算符适用于哪些操作数。为了具体性,我们支持熟悉的二元运算符*、+和-,以及一个仅接受一个参数的平方根运算符sqrt。
算术表达式求值。
我们如何将算术表达式(一串字符)转换为它表示的值?Edsgar Dijkstra 在 1960 年代开发的一个非常简单的算法使用两个推入栈(一个用于操作数,一个用于运算符)来执行此任务。表达式由括号、运算符和操作数(数字)组成。从左到右进行,并逐个处理这些实体,我们根据四种可能情况操作栈,如下所示:
将操作数推送到操作数栈上。
将运算符推送到运算符栈上。
忽略左括号。
遇到右括号时,弹出一个运算符,弹出所需数量的操作数,并将将该运算符应用于这些操作数的结果推送到操作数栈上。
处理完最后一个右括号后,栈上有一个值,即表达式的值。程序 evaluate.py 是此算法的实现。尝试使用 expression1.txt 和 expression2.txt 运行它。
基于栈的编程语言。
令人惊讶的是,Dijkstra 的双栈算法也计算出与我们示例中此表达式相同的值:
( 1 ( ( 2 3 + ) ( 4 5 * ) * ) + )
换句话说,我们可以将每个运算符放在其两个操作数之后,而不是在它们之间。在这种表达式中,每个右括号紧跟在一个运算符后面,因此我们可以忽略这两种括号,将表达式写成如下形式:
1 2 3 + 4 5 * * +
这种表示法称为逆波兰表示法,或后缀。要评估后缀表达式,我们使用一个栈。从左到右进行,逐个处理这些实体,我们根据只有两种可能情况操作栈:
将操作数推送到操作数栈上。
遇到运算符时,弹出所需数量的操作数,并将应用运算符到这些操作数的结果推送到操作数栈上。
再次,这个过程在栈上留下一个值,这个值是表达式的值。这种表示方法是如此简单,以至于一些编程语言,比如 Forth(一种科学编程语言)和 PostScript(一种用于大多数打印机的页面描述语言)使用显式栈。
函数调用抽象。
当控制流进入函数时,Python 会在可能已经存在的其他变量之上创建函数的参数变量。随着函数的执行,Python 会创建函数的局部变量 — 再次在可能已经存在的其他变量之上。当控制流从函数返回时,Python 会销毁该函数的局部和参数变量。从这个意义上说,Python 以类似栈的方式创建和销毁参数和局部变量。事实上,大多数程序隐式使用栈,因为它们支持实现函数调用的自然方式
FIFO 队列
一个 FIFO 队列(或者只是一个队列)是基于先进先出(FIFO)策略的集合。队列是如此多日常现象的自然模型,以至于在计算机出现之前就已经详细研究了它们的属性。
和往常一样,我们首先明确 API。再次按照传统,我们将队列插入操作命名为enqueue,将移除操作命名为dequeue,如下所示的 API。
应用我们从栈中学到的知识,我们可以使用 Python 列表(调整大小的数组)或链表来开发实现,其中操作需要常数时间,与队列中的元素数量一起增长和缩小的内存。
链表实现。
要使用链表实现队列,我们按照它们到达的顺序保留项目(与我们在 linkedstack.py 中使用的顺序相反)。dequeue()的实现与 linkedstack.py 中的pop()实现相同(保存第一个节点中的项目,从队列中移除第一个节点,并返回保存的项目)。然而,实现enqueue()会更具挑战性:我们如何将一个节点添加到链表的末尾?为此,我们需要一个链接到链表中最后一个节点的链接,因为该节点的链接必须更改为引用包含要插入的项目的新节点。因此,我们维护第二个实例变量,它始终引用链表中的最后一个节点。
程序 linkedqueue.py 是一个Queue的链表实现,具有与Stack相同的性能特性:所有方法都是常数时间操作,并且空间使用量与队列中的项目数量成线性关系。
调整大小的数组实现。
也可以开发一个基于显式��整大小数组表示的 FIFO 队列实现,其性能特征与我们在 arraystack.py 中为栈开发的性能特征相同。这种实现是一个值得的经典编程练习,鼓励您在本节末尾的练习中进一步探索。可能会诱人地使用 Python list方法的一行调用,就像在 arraystack.py 中一样。然而,在 Python 列表的前端插入或删除项目的方法不符合要求,因为它们需要线性时间。
随机队列。
尽管它们具有广泛的适用性,但 FIFO 和 LIFO 规则并非神圣不可侵犯。考虑其他规则来移除项目是完全有道理的。其中最重要的之一是考虑一个数据类型,其中dequeue()移除一个随机项目(无替换抽样),而sample()返回一个随机项目而不从队列中移除它(有替换抽样)。这些操作在许多应用中被精确调用,其中一些我们已经考虑过,从第 1.4 节开始,例如 sample.py。使用 Python 列表(调整大小的数组)表示,实现sample()是直接的,我们可以使用与 sample.py 相同的思路来实现dequeue()(在删除之前将一个随机项目与最后一个项目交��)。我们使用名称RandomQueue来引用这种数据类型(请参阅本节末尾的“随机队列”创意练习)。
队列应用
在过去的一个世纪里,先进先出队列被证明是准确和有用的模型,在各种应用中广泛使用。一个被称为排队理论的数学领域已被广泛成功地用于帮助理解和控制各种复杂系统。理解和控制这样一个复杂系统涉及对队列抽象的坚实实现,应用排队理论的数学结果以及涉及两者的模拟研究。接下来我们考虑一个经典示例,以了解这个过程的味道。
M/M/1 队列。
最重要的排队模型之一被称为M/M/1 队列,已被证明可以准确地模拟许多现实情况,例如一条汽车排队进入收费站或患者进入急诊室。 M代表马尔可夫或无记忆,表示到达和服务都是泊松过程:到达时间和服务时间都服从指数分布(参见练习 2.2.12),而 1 表示只有一个服务器。 M/M/1 队列由到达率λ(例如,每分钟到达收费站的汽车数量)和服务率μ(例如,每分钟可以通过收费站的汽车数量)参数化,并具有三个特性:
有一个服务器 — 先进先出队列。
进入队列的到达时间服从每分钟率为λ的指数分布。
非空队列的服务时间服从每分钟率为μ的指数分布。
到达之间的平均时间为 1/λ分钟,服务之间的平均时间(当队列非空时)为 1/μ分钟。因此,除非μ > λ,否则队列将无限增长;否则,顾客将以有趣的动态过程进入和离开队列。
在实际应用中,人们对参数λ和μ对队列各种属性的影响感兴趣。对于 M/M/1 队列,已知系统中平均顾客数L为λ / (μ - λ),顾客在系统中平均等待时间W为 1 / (μ - λ)。这些公式证实,当λ接近μ时,等待时间(和队列长度)会无限增长。它们还遵守一个被称为利特尔定律的一般规则:系统中平均顾客数是λ乘以顾客在系统中平均等待时间(L - λW)对于许多类型的队列。
程序 mm1queue.py 是一个Queue客户端,您可以使用它来验证这些数学结果。这是一个基于事件的模拟的简单示例:我们生成在特定时间发生的事件,并相应地调整我们的数据结构以进行事件,模拟它们发生时发生的情况。有关详细信息,请参阅教科书。从实际角度来看,您可以通过运行 mm1queue.py 来发现参数λ和μ的各种值时,过程的最重要特征之一是,当服务速率接近到达速率时,顾客在系统中的平均停留时间(以及系统中的平均顾客数量)可能会急剧增加。
% python mm1queue.py .167 .25% python mm1queue.py .167 .20
资源分配。
一个资源共享系统涉及大量松散合作的服务器,它们希望共享资源。每个服务器同意维护一个用于共享的项目队列,一个中央机构将项目分发给服务器(并告知用户它们可以在哪里找到)。我们将考虑中央机构可能用来分发项目的程序类型,忽略从系统中删除项目的动态,添加和删除服务器等等。
中央机构通常使用随机策略,其中分配基于随机选择。一个更好的策略是选择一组服务器的随机样本,并将新项目分配给具有最少项目数量的服务器。但我们应该取多大的样本呢?
程序 loadbalance.py 是一个采样策略的模拟,我们可以用来研究这个问题。这个程序很好地利用了RandomQueue数据类型(请参阅本节末尾的“随机队列”创意练习),提供了一个易于理解的程序,我们可以用来进行实验。该模拟维护一个随机队列的队列,并围绕一个内部循环构建计算,在该循环中,每个新的服务请求都放在一个队列样本中最小的队列上,使用RandomQueue的sample()方法随机抽样队列。令人惊讶的最终结果是,大小为 2 的样本导致几乎完美的平衡,因此没有必要进行更大的样本。
% python loadbalance.py 50 500 1% python loadbalance.py 50 500 2
问答
Q. 何时应该调用_Node构造函数?
A. 就像任何其他类一样,当你想要创建一个新的_Node对象(链表中的新节点)时,应该调用_Node构造函数。你不应该用它来创建对现有_Node对象的新引用。例如,下面的代码
oldfirst = _Node(item, next)
oldfirst = first
创建一个新的_Node对象,然后立即失去对它唯一引用的跟踪。这段代码不会导致错误,但是没有理由创建孤立的对象有点凌乱。
Q. 为什么不将Node定义为一个独立的类,在名为node.py的单独文件中?
A. 通过在 linkedstack.py 或 linkedqueue.py 中定义_Node,并以下划线开头的名称,我们鼓励Stack或Queue类的客户端不直接使用_Node类。我们的意图是,_Node对象仅���linkedstack.py 或 linkedqueue.py 实现中使用,而不在其他客户端中使用。
Q. 客户是否允许将项目None插入堆栈或队列?
A. 在 Python 中实现集合时,这个问题经常出现。我们的实现允许插入任何对象,包括None。
Q. 是否有用于栈和队列的标准 Python 模块?
A. 实际上并没有。正如本节前面提到的,Python 内置的list数据类型具有使得使用列表轻松实现栈的操作。但list数据类型还需要许多额外的方法,这些方法通常与栈不相关,比如索引访问和删除任意项目。将自己限制在我们需要的操作集合(仅限于这些操作)的优势在于,它使���更容易开发一个可以为这些操作提供最佳性能保证的实现。Python 还包括一个名为collections.deque的数据类型,它实现了一个可变序列,具有向前或向后高效插入和删除的功能。
Q. 为什么不使用一个单一的数据类型来实现插入项目、删除最近插入的项目、删除最近插入的项目、删除随机项目、遍历项目、返回集合中项目数量以及我们可能需要的其他操作的方法?然后我们可以将它们全部实现在一个类中,可以被许多客户端使用。
ts。
A. 这是一个宽接口的示例,正如我们在第 3.3 节中指出的那样,应该避免使用。正如刚才提到的,避免使用宽接口的一个原因是很难构建对所有操作都有效率的实现。更重要的原因是窄接口对程序施加了一定的纪律,使客户端代码更容易理解。如果一个客户端使用Stack,另一个使用Queue,我们可以很好地了解到 LIFO 纪律对第一个客户端很重要,而 FIFO 纪律对第二个客户端很重要。另一种方法是使用继承来尝试封装所有集合共有的操作。然而,这样的实现最好留给专家,而任何程序员都可以学会构建Stack和Queue等实现。
Q. 是否有办法编写一个客户端,同时在同一个程序中使用 arraystack.py 和 linkedstack.py?
A. 是的,最简单的方法是在import语句中添加一个as子句,如下所示。实际上,这种类型的import语句为类的名称创建了一个别名,然后你的代码可以使用该别名而不是类的名称。
from arraystack import Stack as ArrayStack
from linkedstack import Stack as LinkedStack
...
stack1 = ArrayStack()
stack2 = LinkedStack()
练习
给出 arraystack.py 对以下输入的输出:
it was - the best - of times - - - it was - the - -给出 arraystack.py 在以下输入的每个操作后的数组内容和长度:
it was - the best - of times - - - it was - the - -假设一个客户端在
Stack上执行一系列交错的push和pop操作。push 操作按顺序将整数 0 到 9 放入栈中;pop 操作写入返回值。以下哪个序列可能不会发生?4 3 2 1 0 9 8 7 6 54 6 8 7 5 3 2 9 0 12 5 6 7 4 8 9 3 1 04 3 2 1 0 5 6 7 8 91 2 3 4 5 6 9 8 7 00 4 6 5 3 8 1 7 2 91 4 7 9 8 6 5 3 0 22 1 4 3 6 5 8 7 9 0
编写一个名为
reverse.py的栈客户端,从标准输入读取字符串,并以相反顺序写入标准输出。编写一个名为parentheses.py的栈客户端,从标准输入读取文本流,并使用栈来确定其括号是否正确平衡。例如,你的程序应该对[()]{}{[()()]()}写入True,对[(])写入False。在 linkedstack.py 的
Stack类中添加方法__len__()。在 arraystack.py 的
Stack类中添加一个名为peek()的方法,该方法返回栈上最近插入的项目(不弹出)。当
n为 50 时,以下代码片段会写入什么?为给定的正整数n提供代码片段的高级描述。stack = Stack() while n > 0: stack.push(n % 2) n /= 2 while not stack.isEmpty(): stdio.write(stack.pop()) stdio.writeln()解决方案:它写出
n的二进制表示(当n为 50 时为110010)。以下代码片段对队列
queue做了什么?stack = Stack() while not queue.isEmpty(): stack.push(queue.dequeue()) while not stack.isEmpty(): queue.enqueue(stack.pop())为介绍本节中链表的三节点示例绘制一个对象级别的跟踪图。
编写一个程序,从标准输入接收一个没有左括号的表达式,并将插入了等效中缀表达式的括号的结果写入。例如,给定输入
1 + 2 ) * 3 - 4 ) * 5 - 6 ) ) )您的程序应该写
( ( 1 + 2 ) * ( ( 3 - 4 ) * ( 5 - 6 ) )编写一个过滤器
infixtopostfix.py,将完全括号化的中缀算术表达式从中缀转换为后缀。编写一个程序
evaluatepostfix.py,从标准输入读取后缀表达式,对其进行评估,并将值写入标准输出。(将上一个练习的程序输出通过管道传递给此程序,可以实现与 evaluate.py 相同的行为。)假设客户端对
Queue执行一系列交错的enqueue和dequeue操作。enqueue操作按顺序将整数 0 到 9 放入队列;dequeue操作写入返回值。以下序列中哪个序列不能发生?0 1 2 3 4 5 6 7 8 94 6 8 7 5 3 2 9 0 12 5 6 7 4 8 9 3 1 04 3 2 1 0 5 6 7 8 9
编写一个
Queue客户端,它接受一个命令行参数k,并将从标准输入中找到的倒数第k个字符串写入。给出以下
Queue类中每个操作的运行时间,其中最近插入的项位于_a[0]。class Queue: def __init__(self): self._a = [] def isEmpty(self): return len(self._a) == 0 def __len__(self): return len(self._a) def enqueue(self, item): self._a += [item] def dequeue(self): return self._a.pop(0)给出以下
Queue类中每个操作的运行时间,其中最近插入的项位于_a[0]。class Queue: def __init__(self): self._a = [] def isEmpty(self): return len(self._a) == 0 def __len__(self): return len(self._a) def enqueue(self, item): self._a.insert(0, item) def dequeue(self): return self._a.pop()修改 mm1queue.py 以创建一个程序
md1queue.py,该程序模拟服务时间固定(确定性)为速率μ的队列。通过这个模型经验性地验证 Little's 定律。
链表练习
以下练习旨在让您熟练处理链表。解决它们的最简单方法是使用文本中描述的可视化表示法进行绘图。
假设
x是一个链表节点。以下代码片段的效果是什么��x.next = x.next.next解决方案:删除列表中紧随
x之后的节点。编写一个函数
find(),它以链表中的第一个节点和对象key作为参数,并在列表中的某个节点的项字段为key时返回True,否则返回False。编写一个函数
delete(),它以链表中的第一个节点和整数k作为参数,并删除链表中的第k个元素(如果存在)。假设
x是一个链表节点。以下代码片段的效果是什么?t.next = x.next x.next = t解决方案:在节点
x之后立即插入节点t。为什么以下代码片段的效果与上一个问题中的代码片段不同?
x.next = t t.next = x.next解决方案:当更新
t.next时,x.next不再是跟在x后面的原始节点,而是t本身!编写一个函数
removeAfter(),它以链表节点作为参数,并删除给定节点后面的节点(如果参数或参数节点中的下一个字段为None,则不执行任何操作)。编写一个函数
copy(),它以一个链表节点作为参数,并创建一个具有相同项目序列的新链表,而不破坏原始链表。编写一个函数
remove(),它以链表节点和对象项作为参数,并删除列表中每个项目为项的节点。编写一个函数
listmax(),它以链表中的第一个节点作为参数,并返回列表中最大项目的值。假设项目是可比较的,并且如果列表为空,则返回None。开发一个递归解决方案来回答上一个问题。
编写一个函数,它以链表中的第一个节点作为参数,并反转列表,返回结果中的第一个节点。
迭代解决方案:为了完成这个任务,我们在链表中保持对三个连续节点的引用:
reverse、first和second。在每次迭代中,我们从原始链表中提取节点first,并将其插入到反转列表的开头。我们保持不变的是first是原始列表剩余部分的第一个节点,second是原始列表剩余部分的第二个节点,reverse是结果反转列表的第一个节点。def reverse(first): reverse = None while first is not None: second = first.next first.next = reverse reverse = first first = second return reverse在编写涉及链表的代码时,我们必须始终小心处理异常情况(当链表为空时,当列表只有一个或两个节点时)和边界情况(处理第一个或最后一个项目)。这通常比处理正常情��要困难得多。
编写一个递归函数,以相反的顺序写出链表的元素。不要修改任何链接。简单:使用二次时间,常数额外空间。同样简单:使用线性时间,线性额外空间。不那么简单:开发一个分治算法,其时间复杂度为线性对数级,使用对数额外空间。
二次时间,常数空间解决方案:我们递归地反转从第二个节点开始的列表部分,然后小心地将第一个元素附加到末尾。
def reverse(first): if first is None: return None if first.next is None: return first second = first.next rest = reverse(second) second.next = first first.next = None return rest编写一个递归函数,通过修改链接来随机洗牌链表的元素。简单:使用二次时间,常数额外空间。不那么简单:开发一个分治算法,其时间复杂度为线性对数级,使用对数额外内存。对于“合并”步骤,请参见第 1.4 节末尾的“Riffle shuffle”创意练习。
创意练习
Deque. 双端队列或deque(发音为“deck”)是栈和队列的结合体。编写一个使用链表实现此 API 的类
Deque:![Deque API]()
Josephus 问题。 在古代的 Josephus 问题中,n个人处于困境,并同意采取以下策略来减少人口。他们排成一个圆圈(位置编号从 0 到n-1),并沿着圆圈进行,每隔m个人就淘汰一个人,直到只剩下一个人为止。传说中 Josephus 找到了一个位置,可以避免被淘汰。编写一个
Queue客户端josephus.py,从命令行获取n和m,并写出人们被淘汰的顺序(从而向 Josephus 展示在圆圈中应该坐在哪里)。% python josephus.py 7 2 1 3 5 0 4 2 6合并两个排序队列。 给定两个按升序排列的队列,将所有字符串移动到第三个队列中,使得第三个队列中的字符串按升序排列。
非递归归并排序。 给定n个字符串,创建n个队列,每个队列包含一个字符串。创建一个包含n个队列的队列。然后,重复应用排序合并操作到前两个队列,并将合并后的队列重新插入到末尾。重复此过程,直到队列中只剩下一个队列。
删除第 i 个元素。 实现一个支持以下 API 的类:
![GeneralizedQueue API]()
首先,开发一个使用 Python 列表(调整大小的数组)实现的方法,然后开发一个使用链表实现的方法。(查看第 4.4 节末尾的“广义队列”创意练习,以获得使用二叉搜索树的更有效实现。)
使用两个栈实现队列。 展示如何使用两个栈(仅使用恒定额外内存)实现队列,以便每个队列操作使用恒定的摊销栈操作次数。
环形缓冲区。 环形缓冲区,或循环队列,是一个固定容量为n的 FIFO 数据结构。它对于在异步进程之间传输数据或存储日志文件非常有用。当缓冲区为空时,消费者等待直到数据被存入;当缓冲区满时,生产者等待存入数据。为环形缓冲区开发一个 API,并使用数组表示(带有循环环绕)的实现。
移至前端。 从标准输入读取一系列字符,并在不重复的情况下将字符维护在一个链表中。当读取到以前未见过的字符时,在列表的前面插入它。当读取到重复的字符时,从列表中删除它并重新插入到开头。将你的程序命名为
movetofront.py:它实现了众所周知的移至前端策略,这对于缓存、数据压缩以及许多其他应用程序非常有用,其中最近访问的项目更有可能被重新访问。随机队列。 随机队列按照以下 API 存储一组项目:
![RandomQueue API]()
编写一个实现此 API 的类
RandomQueue。提示:使用 Python 列表(调整大小的数组)表示,就像 arraystack.py 中一样。要删除一个项目,将一个随机位置(从 0 到n-1 索引)的项目与最后一个位置(索引n-1)的项目交换。然后删除并返回最后一个对象。编写一个客户端,使用RandomQueue以随机顺序写入一副卡牌。解决方案:参见 randomqueue.py。
拓扑排序。 你需要对服务器上编号从 0 到
n-1 的n个作业的顺序进行排序。有些作业必须在其他作业开始之前完成。编写一个程序topologicalsorter.py,它接受n作为命令行参数,并在标准输入上接受作业i j的有序对序列,然后写入一系列整数,使得对于输入中的每对i j,作业i出现在作业j之前。使用以下算法:首先,从输入中为每个作业构建(1)必须在其后执行的作业的队列和(2)其入度(必须在其之前执行的作业数)。然后,构建一个所有入度为 0 的节点的队列,并重复删除一些入度为零的作业,同时维护所有数据结构。这个过程有许多应用;例如,你可以用它来模拟专业课程的先修课程,以便找到一系列要修的课程,以便毕业。文本编辑器缓冲区。 为文本编辑器中的缓冲区开发一个数据类型,实现以下 API:
![缓冲区 API]()
提示:使用两个栈。
复制栈。 为栈的链表实现创建一个
copy()方法,以便stack2 = stack1.copy()使
stack2成为栈stack1的一个新的独立副本的引用。你应该能够从stack1或stack2中推入和弹出,而不会影响另一个。复制队列。 为队列的链表实现创建一个
copy()方法,以便queue2 = queue1.copy()使
queue2成为队列queue1的一个新的独立副本的引用。提示:删��queue1中的所有项目,并将这些项目添加到queue1和queue2中。使用显式调整大小数组的栈。 使用显式调整大小数组实现一个栈:通过使用长度为 1 的数组作为实例变量来初始化一个空栈;当数组变满时将数组长度加倍,当数组变为四分之一满时将数组长度减半。
解决方案:
class Stack: def __init__(self): self._a = [None] self._n = 0 def isEmpty(self): return self._n == 0 def __len__(self): return self._n def _resize(self, capacity): temp = stdarray.create1D(capacity) for i in range(self._n): temp[i] = self._a[i] self._a = temp def push(self, item): if self._n == len(self._a): self._resize(2 * self._n) self._a[self._n] = item self._n += 1 def pop(self): self._n -= 1 item = self._a[self._n] self._a[self._n] = None if (self._n > 0) and (self._n == len(self._a) // 4): self._resize(self._n // 2) return item使用显式调整大小数组的队列。 使用显式调整大小数组实现一个队列,以便所有操作都需要恒定的摊销时间。提示:挑战在于随着项目被添加到队列和从队列中移除,项目将在数组中“爬行”。使用模运算来维护队列前后项目的数组索引。
stdin stdout n lo hi a[0] a[1] a[2] a[3] a[4] a[5] a[6] a[7] 0 0 0 无 是 1 0 1 是 无 是 2 0 2 是 是 或 3 0 3 是 或 或 无 不 4 0 4 是 或 不 不 是 5 0 5 是 或 不 是 是 无 无 无 - 是 4 1 4 无 或 不 是 是 无 无 无 是 5 1 6 无 是 或 不 是 是 无 无 - 是 4 2 6 无 无 或 不 是 无 无 - 或 3 3 6 无 无 无 不 是 无 无 那 4 3 7 无 无 无 不 是 是 那 无 解决方案:参见 arrayqueue.py。
队列模拟。 研究当您修改 mm1queue.py 以使用堆栈而不是队列时会发生什么。Little 定律成立吗?对于随机队列,回答相同的问题。绘制直方图并比较等待时间的标准差。
负载平衡模拟。 修改 loadbalance.py 以写入平均队列长度和最大队列长度,而不是绘制直方图,并使用它在 100000 个队列上运行 100 万个项目的模拟。对于每个样本大小为 1、2、3 和 4 的 100 次试验,写下最大队列长度的平均值。您的实验是否验证了文本中关于使用样本大小为 2 的结论?
列出文件。 文件夹是文件和子文件夹的列表。编写一个程序,将文件夹的名称作为命令行参数,并将该文件夹中包含的所有文件名写入,每个文件夹的内容递归列在该文件夹的名称下(缩进)。提示:使用队列,并查看 Python 的
os模块中定义的listdir()函数。
4.4 符号表
原文:
introcs.cs.princeton.edu/python/44st译者:飞龙
符号表是一种数据类型,我们用它来将值与键关联起来。客户端可以通过指定键值对将条目存储(put)到符号表中,然后可以从符号表中检索(get)与特定键对应的值。
在本章中,我们考虑符号表数据类型的基本 API。我们的 API 增加了put和get操作的能力,以测试是否已将任何值与给定键关联(contains),以及在键上迭代的能力。我们还考虑了一个扩展 API,用于可比较键的情况,这允许许多有用的操作。
我们还考虑了两种经典的实现。第一种使用称为哈希的操作,将键转换为我们可以用来访问值的数组索引。第二种基于一种称为二叉搜索树(BST)的数据结构。
API
符号表是一组键值对的集合 — 每个符号表条目将一个值与一个键关联,如下所示:
该 API 与 Python 内置的dict数据类型的 API 一致,我们稍后在本节中讨论。API 已经反映了几个设计决策,我们现在列举如下。
关联数组。
我们为两个基本操作put和get重载了[]运算符。在客户端代码中,这意味着我们可以将符号表视为一个关联数组,其中我们可以使用标准数组语法,方括号内可以是任何类型的数据,而不是介于 0 和长度之间的整数,就像数组一样。因此,我们可以将密码子与氨基酸名称关联起来,客户端代码如下:
amino['TTA'] = 'Leucine'
我们稍后可以通过客户端代码访问与给定密码子关联的名称
stdio.writeln(amino['TTA'])
也就是说,关联数组引用是一个获取操作,除非它在赋值语句的左侧,那时它是一个放置操作。我们可以通过实现特殊方法__getitem__()和__setitem__()来支持这些操作。
替换旧值策略。
如果要将值与已经关联值的键关联起来,我们采用新值替换旧值的约定(就像数组赋值语句一样)。同样,这是从关联数组抽象中所期望的。由特殊方法__contains__()支持的key in st操作,给予客户端灵活性,如果需要的话可以避免这样做。
未找到。
调用st[key]会在表中未关联键的情况下引发KeyError。另一种设计是在这种情况下返回None。
无键和值。
客户端可能使用None作为键或值,尽管他们通常不这样做。另一种设计是不允许None键和/或值。
可迭代的。
为了支持for key in st的构造,Python 的约定是我们需要实现一个特殊方法__iter__(),它返回一个迭代器,这是一种特殊的数据类型,包括在for循环的开始和每次迭代时调用的方法。我们将在本节末尾考虑 Python 的迭代机制。
移除。
我们基本的 API 不包括从符号表中删除键的方法。一些应用程序确实需要这样的方法,Python 提供了特殊语法del st[key],可以通过实现特殊方法__delitem__()来支持。我们将实现留作练习,或者用于更高级的算法课程。
不可变的键。
我们假设键在符号表中不会更改其值。最简单且最常用的键类型(整数、浮点数和字符串)是不可变的。如果你仔细想一想,你会发现这是一个非常合理的假设!如果客户端更改了一个键,符号表的实现如何跟踪这个事实呢?
变体。
计算机科学家已经确定了符号表上许多其他有用的操作,并且基于它们的各种子集的 API 已经得到广泛研究。我们将在本节中以及特别是最后的练习中考虑其中的几个操作。
可比较的键。
在许多应用程序中,键可以是整数、浮点数、字符串或其他具有自然顺序的数据类型。在 Python 中,如第 3.3 节所讨论的,我们期望这些键是可比较的。具有可比较键的符号表有两个重要原因。首先,我们可以利用键的顺序来开发put和get的实现,以保证 API 中的性能规范。其次,有许多新的操作(并且可以支持)与可比较的键相关。客户端可能想要最小的键,或最大的键,或中位数,或按排序顺序迭代键。这个主题的全面覆盖更适合于算法和数据结构的书籍,但我们稍后在本节中会检查一个典型的客户端和这种数据类型的实现。这是一个部分 API:
符号表客户端
我们从两个原型示例开始,每个示例在许多重要且熟悉的实际应用程序中都会出现。
字典查找。
最基本的符号表客户端通过连续的put操作构建符号表,以支持get请求。程序 lookup.py 从命令行指定的逗号分隔值文件中构建一组键值对,然后写入与从标准输入读取的键对应的值。命令行参数是文件名和两个整数,一个指定用作键的字段,另一个指定用作值的字段。
本书站点提供了许多逗号分隔值(.csv)文件,您可以将其用作 lookup.py 的输入,包括 amino.csv(密码子到氨基酸编码)、djia.csv(股市平均开盘价、成交量和收盘价,历史上的每一天)、elements.csv(元素周期表)、ip.csv(DNS 数据库中的条目选择)、ip-by-country.csv(IP 地址按���家)、morse.csv(摩尔斯电码)和 phone-na.csv(电话区号)。在选择要用作键的字段时,请记住每个键必须唯一确定一个值。如果有多个put操作将值与键关联,表将仅记住最近的一个(考虑关联数组)。接下来我们将考虑希望将多个值与一个键关联的情况。
索引。
程序 index.py 是一个用于可比较键的符号表客户端的原型示例。它从标准输入中读取一组字符串,并写入所有不同字符串的排序表,以及为每个字符串指定出现在输入中的位置的整数列表。在这种情况下,我们似乎将多个值与每个键关联起来,但实际上我们只关联了一个值:一个 Python 列表。
为了减少输出量,index.py 接受三个命令行参数:一个文件名和两个整数。第一个整数是要包含在符号表中的最小字符串长度,第二个是要包含在打印索引中的出现次数最少的单词数。尝试在文件 tale.txt 和 mobydick.txt 上运行 index.py。
哈希表
符号表实现已经得到广泛研究,为此已经发明了许多不同的算法和数据结构,并且现代编程环境(包括 Python)提供直接支持。通常情况下,了解基本实现的工作原理将帮助您欣赏、选择并更有效地使用高级实现,或者帮助您为可能遇到的某些专门情况实现自己的版本。
实现符号表的一种方法是作为哈希表。哈希表是一种数据结构,我们将键分成可以快速搜索的小组。基本思想很简单。我们选择一个参数m,将键分成m组,我们希望这些组的大小大致相等。对于每个组,我们将键保留在一个列表中,并使用顺序搜索。
为了将键分成小组,我们使用一个称为哈希函数的函数,将每个可能的键映射到一个哈希值——一个介于 0 和m-1 之间的整数。这使我们能够将符号表建模为一个固定长度的列表数组,并使用哈希值作为数组索引来访问所需的列表。在 Python 中,我们可以使用内置的list数据类型来实现固定长度数组和列表。
哈希是非常有用的,因此许多编程语言都包含对其的直接支持。正如我们在第 3.3 节中看到的,Python 提供了内置的hash()函数,用于此目的,它接受一个可哈希对象作为参数并返回一个整数哈希码。为了将其转换为 0 到m-1 之间的哈希值,我们使用表达式
hash(x) % m
请记住,如果对象满足以下三个属性,则对象是可哈希的:
对象可以与其他对象进行相等比较。
每当两个对象比较相等时,它们具有相同的哈希码。
对象的哈希码在其生命周期内不会更改。
不相等的对象可能具有相同的哈希码。但是,为了获得良好的性能,我们希望哈希函数将我们的键分成大约相等长度的m组。
使用哈希实现高效的符号表是直截了当的。对于键,我们维护一个包含m个列表的数组,其中元素i包含一个 Python 列表,其中包含哈希值为i的键。对于值,我们维护一个平行数组,也包含m个列表,这样当我们定位到一个键时,我们可以使用相同的索引访问相应的值。程序 hashst.py 是一个完整的实现,使用固定数量的m个列表(默认为 1024)。
hashst.py 的效率取决于m的值和哈希函数的质量。假设哈希函数合理地分布键,性能大约比顺序搜索快m倍,代价是m额外的引用和列表。这是一个经典的时空权衡:m值越高,我们使用的空间就越多,但花费的时间就越少。
哈希表的主要缺点是它们不利用键的顺序,因此无法按排序顺序提供键或支持像查找最小值或最大值这样的操作的高效实现。例如,在 index.py 中,键将以任意顺序出现,而不是所要求的排序顺序。接下来,我们考虑一种符号表实现,当键可比较时,可以支持这些操作,而不会牺牲太多性能。
二叉搜索树
二叉树是在信息高效组织中起着核心作用的数学抽象。对于符号表实现,我们使用一种特殊类型的二叉树来组织数据,并为符号表的put操作和get请求提供高效实现的基础。二叉搜索树(BST)将可比较的键与值关联在一起,以递归定义的结构。BST 是以下之一:

空(
None)一个具有键-值对和两个指向 BSTs 的引用的节点,一个具有较小键的左 BST 和一个具有较大键的右 BST
键必须可通过<运算符进行比较。
要实现二叉搜索树(BSTs),我们首先从一个节点抽象的类开始,该类具有对键、值以及左右 BSTs 的引用:
class Node:
def __init__(self, key, val):
self.key = key
self.val = val
self.left = None
self.right = None
这个定义类似于我们对链表节点的定义,只是它有两个链接,而不只是一个。从 BSTs 的递归定义中,我们可以通过确保其值为None或引用到左右实例变量为 BSTs 的Node的引用,并确保满足排序条件(左 BST 中的键小于键,右 BST 中的键大于键)来表示类型为Node的变量的 BST。
在讨论 BSTs 时,我们经常使用基于树的术语。我们将顶部的节点称为树的根,由其左链接引用的 BST 称为左子树,由其右链接引用的 BST 称为右子树。传统上,计算机科学家将树倒置绘制,根在顶部。两个链接都为 null 的节点称为叶节点。树的高度是从根节点到叶节点的任意路径上的最大链接数。
假设你想要在 BST 中搜索具有给定键的节点(或在符号表中获取具有给定键的值)。有两种可能的结果:搜索可能成功(我们在 BST 中找到键;在符号表实现中,我们返回相关联的值)或者可能不成功(在 BST 中没有具有给定键的键;在符号表实现中,我们引发运行时错误)。
递归搜索算法很简单:给定一个 BST(一个指向Node的引用),首先检查树是否为空(引用为None)。如果是,则将搜索终止为不成功(在符号表实现中,引发运行时错误)。如果树不为空,则检查节点中的键是否等于搜索键。如果是,则将搜索终止为成功(在符号表实现中,返回与键关联的值)。如果不是,则将搜索键与节点中的键进行比较。如果较小,则在左子树中搜索(递归);如果较大,则在右子树中搜索(递归)。
假设你想要在 BST 中插入一个新节点(在符号表实现中,将一个新的键-值对放入数据结构中)。逻辑与搜索键类似,但实现更加棘手。理解它的关键是意识到只有一个链接必须更改为指向新节点,而且该链接恰好是在对该节点的键进行不成功搜索时发现为None的链接。
如果 BST 为空,我们创建并返回一个包含键-值对的新Node;如果搜索键小于根节点的键,我们将左链接设置为将键-值对插入左子树的结果;如果搜索键大于根节点的键,我们将右链接设置为将键-值对插入右子树的结果;否则,如果搜索键相等,我们用新值覆盖现有值。在递归调用后以这种方式重置左或右链接通常是不必要的,因为链接只有在子树为空时才会更改,但设置链接与测试以避免设置它一样容易。
程序 bst.py 是基于这两个递归算法的符号表实现。与 linkedstack.py 和 linkedqueue.py(来自第 4.3 节)一样,我们使用一个私有的_Node类来强调OrderedSymbolTable的客户端不需要知道二叉搜索树表示的任何细节。
BST 的性能特征
BST 算法的运行时间最终取决于树的形状,而树的形状取决于插入关键字的顺序。
最佳情况。
在最佳情况下,树是完全平衡的(每个Node恰好有两个不是None的子���点,除了底部的节点,它们恰好有两个是None的子节点),根节点和每个叶节点之间有 lg n个节点。在这样的树中,很容易看出无法成功搜索的成本是对数级的,因为该成本满足与二分查找成本相同的递归关系(参见第 4.2 节),因此每个put操作和get请求的成本与 lg n成正比或更少。在实践中,通过逐个插入关键字来获得这样的完全平衡树是相当幸运的,但了解最佳性能特征是值得的。

平均情况。
如果我们插入随机关键字,我们可能期望搜索时间也是对数级的,因为第一个关键字成为树的根节点,并且应该将关键字大致分为两半。将相同的论点应用于子树,我们期望得到与最佳情况大致相同的结果。
最坏情况。
在最坏的情况下,每个节点都有一个None链接,因此 BST 就像是一个带有额外浪费链接的链表,其中put操作和get请求需要线性时间。不幸的是,在实践中这种最坏情况并不罕见 — 例如,当我们按顺序插入关键字时就会出现这种情况。
因此,基本 BST 实现的良好性能取决于关键字与随机关键字足够相似,以使树不太可能包含许多长路径。如果您不确定这种假设是否合理,请不要使用简单的 BST。值得注意的是,有一些 BST 变体可以消除这种最坏情况,并保证每次操作的对数性能,方法是使所有树几乎完全平衡。其中一种流行的变体被称为红黑树。
遍历 BST
或许最基本的树处理函数被称为树遍历:给定一个(对)树的引用,我们希望系统地处理树中的每个键-值对。为了处理 BST 中的每个关键字,我们使用这种递归方法:

处理左子树中的每个关键字。
在根节点处理关键字。
处理右子树中的每个关键字。
这种方法被称为中序树遍历,以区别于前序(先处理根)和后序(最后处理根),这些在其他应用中出现。例如,以下方法按键排序顺序写入其参数根节点的 BST 中的键:
def inorder(x):
if x is None: return
inorder(x.left)
stdio.writeln(x.key)
inorder(x.right)
可迭代对象
正如您在第 1.3 节和第 1.4 节中学到的,您可以使用for循环来迭代范围中的整数或数组a[]中的元素。
for i in range(n): for v in a:
stdio.writeln(i) stdio.writeln(v)
for循环不仅适用于整数范围和数组 — 您可以将其与任何可迭代对象一起使用。可迭代对象是一种能够逐个返回其项的对象。Python 的所有序列类型 — 包括list、tuple、dict、set和str — 都是可迭代的,内置range()函数返回的对象也是可迭代的。
现在,我们的目标是使SymbolTable可迭代,这样我们就可以使用for循环来遍历其键(并使用索引来获取相应的值):
st = SymbolTable()
...
for key in st:
stdio.writeln(str(key) + ' ' + str(st[key]))
要使用户定义的数据类型可迭代,必须实现特殊方法__iter__(),以支持内置函数iter()。iter()函数创建并返回一个迭代器,它包括一个特殊方法__next__(),Python 在每次for循环迭代开始时调用该方法。
尽管这看起来复杂,但我们可以使用一个基于 Python 列表可迭代的快捷方式:如果a是一个 Python 列表,那么iter(a)会返回一个迭代器,遍历其项。因此,我们可以通过将键收集到 Python 列表中并返回该列表的迭代器,使我们的哈希表和二叉搜索树实现可迭代。
要使 hashst.py 可迭代,我们将所有键累积到一个 Python 列表中,然后返回该列表上的迭代器;hashst.py 中的__iter__()方法正是这样做的。要使 bst.py 可迭代,我们修改上面显示的递归inorder()方法,以收集 Python 列表中的键而不是写入它们。然后我们可以为该列表返回一个迭代器。bst.py 中的_inorder()和__iter__()方法使用了这种方法。
二叉搜索树的灵活性和比较键的能力使得我们能够实现许多有用的操作,超出了哈希表能够高效支持的操作范围。例如,使用二叉搜索树,我们可以高效地找到最小或最大键,找到指定范围内的所有键,并找到第k小的键。我们将这些操作的实现留给练习,并将它们的性能特征和应用的进一步研究留给算法和数据结构课程。
字典数据类型
现在您了解了符号表的工作原理,可以开始使用 Python 的工业强度版本。内置的dict数据类型遵循与SymbolTable相同的基本 API,但具有更丰富的操作,包括删除;一个返回默认值的版本,如果键不在字典中;以及遍历键-值对。这是一个部分 API:
底层实现是哈希表,因此不支持有序操作。通常情况下,由于 Python 使用低级语言并且不对所有用户施加其施加的开销,如果有序操作不重要,那么该实现将更有效,并且是首选的。
作为一个简单的例子,以下dict客户端从标准输入读取一系列字符串,计算每个字符串出现的次数,并写入字符串及其频率。这些字符串不按排序顺序输出。
import stdio
st = dict()
while not stdio.isEmpty():
word = stdio.readString()
st[word] = 1 + st.get(word, 0)
for word, frequency in st.iteritems():
stdio.writef('%s %4d\n', word, frequency)
在本节末尾的练习中出现了几个dict客户端的示例。
集合数据类型
最后一个例子,我们考虑一个比符号表更简单、仍然广泛有用且易于使用哈希或二叉搜索树实现的数据类型。集合是一个包含不同键的集合,类似于没有值的符号表。例如,我们可以通过删除 hashst.py 或 bst.py 中的值引用来实现一个集合。同样,Python 提供了一个用低级语言实现的set数据类型。这是一个部分 API:
例如,考虑从标准输入读取一系列字符串并写入每个字符串的第一次出现(从而删除重复项)的任务。我们可以使用一个set,就像以下客户端代码中所示:
import stdio
distinct = set()
while not stdio.isEmpty():
key = stdio.readString()
if key not in distinct:
distinct.add(key)
stdio.writeln(key)
在本节末尾的练习中,您可以找到几个其他集合客户端的示例。
你应该使用 Python 的内置dict和set数据类型吗?当然,如果它们支持你需要的操作,因为它们是用低级语言编写的,不受 Python 对用户代码施加的开销影响,因此可能比你自己实现的任何东西都要快。但是,如果你的应用程序需要基于顺序的操作,如查找最小值或最大值,你可能需要考虑二叉搜索树。
Q & A
Q. 我可以将数��(或 Python 列表)用作dict或set中的键吗?
A. 不,内置的list数据类型是可变的,因此你不应该将数组用作符号表或集合中的键。事实上,Python 列表不可哈希,因此你不能将它们用作dict或set中的键。内置的tuple数据类型是不可变的(且可哈希的),所以你可以使用它。
Q. 为什么我的用户定义数据类型不能与dict或set一起使用?
A. 默认情况下,用户定义的类型是可哈希的,hash(x)返回id(x),==测试引用相等性。虽然这些默认实现满足了可哈希的要求,但它们很少提供你想要的行为。
Q. 为什么我不能直接在特殊方法__iter__()中返回 Python 列表?为什么我必须调用内置的iter()函数,并将 Python 列表作为参数?
A. Python 列表是可迭代对象(因为它有一个返回迭代器的__iter__()方法),但它不是迭代器。
Q. Python 使用哪种数据结构来实现dict和set?
A. Python 使用开放寻址哈希表,这是我们在本节中考虑的分离链接哈希表的近亲。Python 的实现经过高度优化,并用低级编程语言编写。
Q. Python 是否提供了用于指定set和dict对象的语言支持?
A. 是的,你可以通过用花括号括起逗号分隔的项目列表来指定一个set。你可以通过用花括号括起逗号分隔的键值对列表,并在每个键和其关联值之间使用冒号来指定一个dict。
stopwords = {'and', 'at', 'of', 'or', on', 'the', 'to'}
grades = {'A+':4.33, 'A':4.0, 'A-':3.67, 'B+':3.33, 'B':3.0}
Q. Python 是否提供了一个内置的有序符号表(或有序集合)的数据类型,支持有序迭代、顺序统计和范围搜索?
A. 不是的。如果你只需要有序迭代(具有可比较的键),你可以使用 Python 的dict数据类型并对键进行排序(并为排序付出性能损失)。例如,如果你在 index.py 中使用dict而不是二叉搜索树,你可以通过类似以下代码来按排序顺序编写键
for word in sorted(st):
如果你需要其他有序符号表操作(如范围搜索或顺序统计),你可以使用我们的二叉搜索树实现(并为使用 Python 实现的数据类型付出性能损失)。
练习
修改 lookup.py 以创建一个名为
lookupandput.py的程序,允许在标准输入上指定put操作。使用约定,加号表示接下来输入的两个字符串是要插入的键值对。修改 lookup.py 以创建一个名为
lookupmultiple.py的程序,通过将具有相同键的多个值放入数组(如 index.py 中),然后在get请求时将它们全部写出,如下所示:% python lookupmultiple.py amino.csv 3 0 Leucine TTA TTG CTT CTC CTA CTG修改 index.py 以创建一个名为
indexbykeyword.py的程序,从命令行获取文件名,并仅使用该文件中的关键字从标准输入创建索引。注意:使用相同的文件进行索引和关键字应该会产生与 index.py 相同的结果。修改 index.py 以创建一个程序
indexlines.py,仅将连续的字母序列视为键(没有标点符号或数字),并使用行号而不是单词位置作为���。这个功能对于程序很有用:当以 Python 程序作为输入时,indexlines.py应该写出一个显示程序中每个关键字或标识符以及其出现行号的索引。开发一个实现符号表 API 的
OrderedSymbolTable实现,它维护键和值的并行数组,并按键排序顺序保持它们。对于get使用二分查找,对于put将较大的元素向右移动一个位置(使用调整数组大小以使数组长度与表中键值对的数量成线性关系)。使用 index.py 测试您的实现,并验证使用这样的实现对 index.py 进行操作所需的时间与输入中字符串数量和不同字符串数量的乘积成正比的假设。开发符号表 API 的
LinkedSymbolTable实现,维护包含键和值的节点的链表,保持它们以任意顺序。使用 index.py 测试您的实现,并验证使用这样的实现对 index.py 进行操作所需的时间与输入中字符串数量和不同字符串数量的乘积成正比的假设。计算单字符键的
hash(x) % 5E A S Y Q U E S T I O N绘制当这个序列中的第
i个键与值i相关联时创建的哈希表,i从 0 到 11。以下
__hash__()实现有什么问题?def __hash__(self): return -17解决方案:虽然从技术上讲它满足数据类型可哈希的条件(如果两个对象相等,则它们具有相同的哈希值),但这会导致性能不佳,因为我们期望
hash(x) % m将键均匀地分成大约相等大小的 m 组。扩展
Complex(在第 3.2 节中定义的 complex.py)和Vector(在第 3.3 节中定义的 vector.py)使它们通过实现特殊方法__hash__()和__eq__()成为可哈希的。修改 hashst.py 以使用调整大小的数组,以便与每个哈希值关联的列表的平均长度在 1 和 8 之间。
绘制可以表示键序列的所有不同 BST。
best of it the time was插入具有键的项目后绘制的 BST
E A S Y Q U E S T I O N将这些键按照顺序插入一个初始为空的树中。结果 BST 的高度是多少?
假设我们在 BST 中有 1 到 1000 之间的整数键,并搜索 363。以下哪个序列不可能是检查的键序列?
2 252 401 398 330 363 399 387 219 266 382 381 278 363 3 923 220 911 244 898 258 362 363 4 924 278 347 621 299 392 358 363 5 925 202 910 245 363假设以下 31 个键(以某种顺序)出现在高度为 5 的 BST 中:
10 15 18 21 23 24 30 31 38 41 42 45 50 55 59 60 61 63 71 77 78 83 84 85 86 88 91 92 93 94 98绘制树的前三个节点(根节点及其两个子节点)。
描述如果您在 lookup.py 中用 bst.py 替换 hashst.py 对性能的影响。为了防止最坏情况,调用
stdrandom.shuffle(database)在填充符号表之前。真或假:给定一个 BST,让x是一个叶子节点,p是它的父节点。那么要么(i)p的键是大于x的 BST 中最小的键,要么(ii)p的键是小于x的 BST 中最大的键。
修改 hashst.py 中的
SymbolTable类,使其成为一个实现了 Python 内置set数据类型部分 API 中的常量时间操作的Set类。修改 bst.py 中的
OrderedSymbolTable类,使其成为一个实现了 Python 内置set数据类型部分 API 中的常量时间操作的OrderedSet类,假设键是可比较的。修改 hashst.py 以支持客户端代码
del st[key],通过添加一个接受键参数并从符号表中删除该键(以及相应的值)的方法__delitem__()。使用调整大小的数组来确保与每个哈希值关联的列表的平均长度在 1 到 8 之间。为 bst.py 实现
__str__(),使用递归辅助方法。通常,由于字符串连接的成本,可以接受二次性能。额外加分:为 bst.py 组成一个使用数组和 Python 内置str数据类型的join()方法的线性时间__str__()方法。词汇表是文本中单词的按字母顺序排列的列表,显示每个单词出现的所有位置。因此,
python index.py 0 0生成一个词汇表。在一个著名的事件中,一组研究人员试图在向其他人保密死海古卷的细节的同时建立可信度,通过公开一个词汇表。编写一个程序invertconcordance.py,它接受一个命令行参数n,从标准输入读取一个词汇表,并在标准输出上写入相应文本的前n个单词。运行实验来验证文本中关于使用具有调整大小数组的 hashst.py 时put操作和get请求是常数时间操作的说法,如前面的练习所述。开发测试客户端,生成随机键,并对各种数据集进行测试,可以是来自本书站点或您自己选择的数据集。
运行实验来验证文本中关于使用 bst.py 时put操作和get请求与符号表大小的对数关系的说法。开发测试客户端,生成随机键,并对各种数据集进行测试,可以是来自本书站点或您自己选择的数据集。
修改 bst.py 以添加返回表中最小(或最大)键的方法
min()和max()(如果表为空,则返回None)。修改 bst.py 以添加
floor()和ceiling()方法,接受一个键作为参数,并返回集合中不大于(不小于)给定键的最大(最小)键。修改 bst.py 以支持特殊的
len()函数,通过实现一个返回符号表中键值对数量的特殊方法__len__()来实现。使用在每个_Node中存储根节点下子树中节点数量的方法。修改 bst.py 以添加一个
rangeSearch()方法,接受两个键lo和hi作为参数,并返回所有位于lo和hi之间的键的迭代器。运行时间应与高度加上范围内键的数量成比例。修改 bst.py 以添加一个
rangeCount()方法,接受键作为参数,并返回 BST 中两个给定键之间的键的数量。您的方法应该花费与树的高度成比例的时间。提示:首先完成前一个练习。修改 bst.py 以支持客户端代码
del st[key],通过添加一个接受键参数并从符号表中删除该键(以及相应的值)的方法__delitem__()来实现。提示:这个操作比看起来更困难。用 BST 中下一个最大键及其相关值替换键及其相关值;然后从 BST 中删除包含下一个最大键的节点。修改符号表 API,通过使
get()返回具有给定键的值的迭代器来处理具有重复键的值。根据此 API 重新实现 hashst.py 和 bst.py。讨论这种方法与文本中给出的方法的优缺点。假设
a[]是一个可散列对象的数组。以下语句的效果是什么?a = list(set(a))重新编写 lookup.py 和 index.py,使用
dict代替分别使用 hashst.py 和 bst.py。比较性能。编写一个
dict客户端,创建一个将字母等级映射到数字分数的符号表,然后从标准输入读取一个字母等级列表并计算它们的平均值(GPA)。A+ A A- B+ B B- C+ C C- D F 4.33 4.00 3.67 3.33 3.00 2.67 2.33 2.00 1.67 1.00 0.00在 stockaccount.py(来自第 3.2 节)中实现
buy()和sell()方法。使用一个dict来存储每只股票的股数。
二叉树练习
以下练习旨在让您熟练处理不一定是 BST 的二叉树。它们都假设一个具有三个实例变量的 Node 类:一个正的双精度值和两个 Node 引用。与链表一样,使用文本中显示的可视表示进行绘图会很有帮助。
实现以下函数,每个函数以一个作为参数的
Node作为二叉树的根。size(node):节点为根的树中的节点数leaves(node):节点为根的树中链接都为None的节点数total(node):节点为根的树中所有节点键值的总和
您的方法应该都在线性时间内运行。
实现一个线性时间函数
height(),返回从根到叶节点的任意路径上的节点数的最大值(空树的高度为 0;一个节点的树的高度为 1)。如果根节点的键大于其所有后代节点的键,则二叉树是堆有序的。实现一个线性时间函数
heapOrdered(),如果树是堆有序的则返回True,否则返回False。给定一个二叉树,单值子树是包含相同数值的最大子树。设计一个线性时间算法,计算二叉树中单值子树的数量。
如果一个二叉树的两个子树都是平衡的,并且两个子树的高度最多相差 1,则该二叉树是平衡的。实现一个线性时间方法
balanced(),如果树是平衡的则返回True,否则返回False。如果只有它们的键值不同(即它们具有相同的形状),则两个二叉树是同构的。实现一个线性时间函数
isomorphic(),它以两个树引用作为参数,并在它们引用同构树时返回True,否则返回False。然后,实现一个线性时间函数eq(),它以两个树引用作为参数,并在它们引用相同的树(具有相同键值的同构树)时返回True,否则返回False。编写一个函数
levelOrder(),按层次顺序写入 BST 键:首先写入根;然后从左到右写入根下一级的节点;然后从左到右写入根下两级的节点;依此类推。提示:使用一个Queue。实现一个线性时间函数
isBST(),如果二叉树是 BST 则返回True,否则返回False。解决方案:这个任务比看起来要困难一些。使用一个递归辅助函数
_inRange(),它接受两个额外参数lo和hi,如果二叉树是 BST 且所有值都在lo和hi之间,则返回True,使用None表示最小可能键和最大可能键。def _inRange(node, lo, hi): if node is None: return True if (lo is not None) and (node.item <= lo): return False if (hi is not None) and (hi <= node.item): return False if not _inRange(node.left, lo, node.item): return False if not _inRange(node.right, node.item, hi): return False return True def _isBST(node): return _inRange(node, None, None)我们注意到这个实现同时使用了<和<=运算符,而我们的二叉搜索树代码只使用<运算符。
计算
mystery()在一些示例二叉树上返回的值,然后提出一个关于该值的假设并加以证明。def mystery(node): if node is None: return 1 return mystery(node.left) + mystery(node.right)
创意练习
拼写检查。 编写一个
set客户端spellchecker.py,以一个包含单词字典的文件名作为命令行参数,然后从标准输入读取字符串,并写入任何不在字典中的字符串。使用文件 words.utf-8.txt。额外加分:增强程序以处理常见后缀,如-ing 或-ed。拼写校正。 编写一个
dict客户端spellcorrector.py,作为一个过滤器,用建议的替换词替换标准输入中常见拼写错误的单词,并将结果写入标准输出。将一个包含常见拼写错误和更正的文件作为命令行参数。使用文件 misspellings.txt,其中包含许多常见拼写错误。网页过滤器。 编写一个
set客户端webblocker.py,以一个包含不良网站列表的文件名作为命令行参数,然后从标准输入读取字符串,并仅写入不在列表中的网站。集合操作。 将
union()和intersection()方法添加到OrderedSet(请参见本节中的先前练习),每个方法接受两个集合作为参数,并返回这两个集合的并集和交集。频率符号表。 开发一个支持以下操作的数据类型
FrequencyTable:click()和count(),两者都接受字符串参数。数据类型值是一个整数,用于跟踪使用给定字符串调用click()操作的次数。click()操作将计数增加 1,count()操作返回该值,可能为 0。此数据类型的客户端可能包括 Web 流量分析器,计算每首歌曲播放次数的音乐播放器,用于计算通话次数的电话软件等。1D 范围搜索。 开发一个支持以下操作的数据类型:插入日期,搜索日期,并计算数据结构中位于特定区间内的日期数量。使用 Python 的
datetime.Date数据类型。非重叠区间搜索。 给定一个整数的非重叠区间列表,编写一个函数,接受一个整数参数,并确定该值位于哪个(如果有)区间中。例如,如果区间是 1643-2033,5532-7643,8999-10332 和 5666653-5669321,则查询点 9122 位于第三个区间,8122 不在任何区间中。
按国家查找 IP。 编写一个
dict客户端,使用数据文件 ip-by-country.csv 来确定给定 IP 地址来自哪个国家。数据文件有五个字段:IP 地址范围的开始,IP 地址范围的结束,两个字符的国家代码,三个字符的国家代码和国家名称。IP 地址不重叠。这样的数据库工具可用于信用卡欺诈检测,垃圾邮件过滤,网站上语言的自动选择以及 Web 服务器日志分析。具有单词查询的网页倒排索引。 给定一个网页列表,创建包含网页中包含的单词的符号表。将每个单词与出现该单词的网页列表关联起来。编写一个程序,读取网页列表,创建符号表,并支持通过返回包含查询单词的网页列表来支持单词查询。
具有多词查询的网页倒排索引。 扩展上一个练习,以支持多词查询。在这种情况下,输出包含每个查询单词至少出现一次的网页列表。
多词搜索(无序)。 编写一个程序,从命令行获取
k个关键字,从标准输入读取一系列单词,并识别包含所有k个关键字的最小文本间隔(不一定按照相同顺序)。不需要考虑部分单词。多词搜索(有序)。 重复上一个练习,但现在假设关键字必须按指定的顺序出现。
在国际象棋中的重复抽取。 在国际象棋中,如果一个棋盘位置连续三次出现相同的一方移动,则该方可以宣布平局。描述如何使用计算机程序测试此条件。
注册调度。 东北一所知名大学的注册处最近安排一名教师在完全相同的时间上教授两门不同的课程。通过描述一种检查此类冲突的方法来帮助注册处避免未来的错误。为简单起见,假设所有课程都持续 50 分钟,并且从 9 点、10 点、11 点、1 点、2 点或 3 点开始。
熵。 我们定义一个包含n个单词的文本语料库的相对熵,其中有k个是不同的,如下所示
E = 1 / (n lg n) (p[0] lg(k/p[0]) + p[1] lg(k/p[1]) + ... + p[k-1] lg(k/p[k-1]))
其中p[i]是单词i出现的次数的分数。编写一个程序,读取文本语料库并写入相对熵。将所有字母转换���小写,并将标点符号视为空格。
顺序统计。 在 bst.py 中添加一个名为
select()的方法,该方法接受一个整数参数k并返回 BST 中第k个最小的键。在每个节点中维护子树大小。运行时间应与树的高度成比例。排名查询。 在 bst.py 中添加一个名为
rank()的方法,该方法以一个键作为参数并返回 BST 中严格小于该键的键的数量。在每个节点中维护子树大小。运行时间应与树的高度成比例。随机元素。 在 bst.py 中添加一个名为
random()的方法,该方法返回一个随机键。在每个节点中维护子树大小。运行时间应与树的高度成比例。无重复项的队列。 创建一个数据类型,它是一个队列,但是在任何给定时间一个元素最多只能出现在队列中一次。如果已经在队列中,则忽略插入项的请求。
给定长度的唯一子字符串。 编写一个程序,从标准输入中读取文本并计算其包含的给定长度
k的唯一子字符串的数量。例如,如果输入是CGCGGGCGCG,则长度为 3 的唯一子字符串有五个:CGC、CGG、GCG、GGC和GGG。这种计算在数据压缩中很有用。提示:使用字符串切片s[i:i+k]提取第i个子字符串并插入符号表中。在包含π的前 1000 万位数字的文件 pi-10million.txt 上测试您的程序。广义队列。 实现一个支持以下 API 的类:
![广义队列 API]()
使用 BST 将插入的第k个元素与键k关联,并在每个节点中维护以该节点为根的子树中的总节点数。要找到最近添加的第i个项,搜索 BST 中第i个最小的元素。
动态离散分布。 创建一个支持以下两个操作的数据类型:
add()和random()。add()方法应在数据结构中插入一个新项(如果之前没有看到过);否则,应将其频率计数增加 1。random()方法应按每个元素的频率加权的概率返回一个元素。使用与项数成比例的空间。密码检查器。 编写一个程序,该程序将一个字符串作为命令行参数,并从标准输入中获取一个单词字典,然后检查该字符串是否是一个“好”密码。在这里,假设“好”意味着它(1)至少有八个字符长,(2)不是字典中的一个词,(3)不是字典中的一个词后跟一个数字 0-9(例如,hello5),(4)不是字典中的两个单词连接在一起(例如,helloworld),以及(5)字典中的单词的反转不满足(2)到(4)中的任何一个。文件 words.utf-8.txt 包含一个单词字典。
随机电话号码。 编写一个程序,该程序接受一个命令行参数n,并写入n个形式为(xxx) xxx-xxxx 的随机电话号码。使用
set来避免多次选择相同的号码。仅使用合法的区号,如文件 phone-na.csv 中所示。稀疏向量。 如果一个n维向量的非零值数量很少,则称其为稀疏。您的目标是用与其非零值数量成比例的空间表示一个向量,并且能够在时间上与总非零值数量成比例地添加两个稀疏向量。实现支持以下 API 的类:
![稀疏向量 API]()
稀疏矩阵。 如果一个n×n矩阵的非零值数量与n成比例(或更少),则称其为稀疏。您的目标是用与n成比例的空间表示一个矩阵,并且能够在时间上与总非零值数量成比例地添加和乘以两个稀疏矩阵(可能有额外的对数n因子)。实现支持以下 API 的类:
![稀疏矩阵 API]()
可变字符串。 创建一个名为
MutableString的数据类型,它与 Python 的str数据类型相同,但是可变的。它应该支持以下操作:ms[i]: 返回MutableString对象ms的第i个字符ms[i] = c: 将MutableString对象ms的第i个字符更改为c。ms.insert(i, c): 在索引i之前将字符c插入MutableString对象ms中。del ms[i]: 删除MutableString对象ms的第i个字符
使用 BST 以对数时间实现这些操作。然后编写其他方法 —— 构造函数、
__str__()方法、比较方法、__contains__()方法、__iter__()方法等 —— 使数据类型相对完整。赋值语句。 编写一个程序来解析和评估由完全括号化的算术表达式组成的赋值和写入语句的程序(参见第 4.3 节的 evaluate.py)。例如,给定输入
A = 5 B = 10 C = A + B D = C * C write(D)你的程序应该写入值为 225。假设所有变量和值都是浮点数。使用符号表来跟踪变量名。
密码子使用表。 编写一个程序,使用符号表为从标准输入中获取的基因组中的每个密码子编写摘要统计信息(每千个的频率),如下所示:
UUU 13.2 UCU 19.6 UAU 16.5 UGU 12.4 UUC 23.5 UCC 10.6 UAC 14.7 UGC 8.0 UUA 5.8 UCA 16.1 UAA 0.7 UGA 0.3 UUG 17.6 UCG 11.8 UAG 0.2 UGG 9.5 CUU 21.2 CCU 10.4 CAU 13.3 CGU 10.5 CUC 13.5 CCC 4.9 CAC 8.2 CGC 4.2 CUA 6.5 CCA 41.0 CAA 24.9 CGA 10.7 CUG 10.7 CCG 10.1 CAG 11.4 CGG 3.7 AUU 27.1 ACU 25.6 AAU 27.2 AGU 11.9 AUC 23.3 ACC 13.3 AAC 21.0 AGC 6.8 AUA 5.9 ACA 17.1 AAA 32.7 AGA 14.2 AUG 22.3 ACG 9.2 AAG 23.9 AGG 2.8 GUU 25.7 GCU 24.2 GAU 49.4 GGU 11.8 GUC 15.3 GCC 12.6 GAC 22.1 GGC 7.0 GUA 8.7 GCA 16.8 GAA 39.8 GGA 47.2
4.5 一个案例研究:小世界现象
原文:
introcs.cs.princeton.edu/python/45graph译者:飞龙
我们用于研究实体之间成对连接性质的数学模型称为图。图对于研究自然界很重要,也有助于我们更好地理解和完善我们创建的网络。
一些图表展示了一种特定属性,被称为小世界现象。你可能熟悉这个属性,有时也被称为六度分隔。这基本上是一个想法,即尽管我们每个人的熟人相对较少,但我们之间存在着一个相对较短的熟人链(六度分隔)将我们彼此分开。科学家对小世界图表感兴趣,因为它们模拟了自然现象,工程师对构建利用小世界图表的自然属性的网络感兴趣。
在本节中,我们探讨了围绕研究小世界图表的基本计算问题。
图数据类型

我们从一些基本定义开始。图由一组顶点和一组边组成。每条边表示��个顶点之间的连接。如果两个顶点通过一条边连接,则它们是邻居,顶点的度是其邻居的数量。
图处理算法通常首先通过添加边来构建图的内部表示,然后通过遍历顶点和与顶点相邻的边来处理它。这个 API 支持这样的处理。
程序 graph.py 实现了这个 API。它的内部表示是一个符号表的集合:键是顶点,值是邻居的集合——与键相邻的顶点。右侧展示了一个小例子。为了实现这种表示,我们使用了我们在第 4.4 节介绍的两种内置数据类型dict和set。

一种自然的写法是将Graph的顶点一个接一个地放在一行上,每个顶点后面跟着其直接邻居的列表。因此,我们通过实现__str__()来支持内置函数str(),如 graph.py 所示。生成的字符串包括每条边的两种表示,一种是我们发现w是v的邻居的情况,另一种是我们发现v是w的邻居的情况。许多图算法都基于这种基本的处理每条边的范式(两次)。这种实现仅适用于小图,因为在某些系统上,运行时间与字符串长度的平方成正比。
str()的输出格式还定义了一个合理的输入文件格式。__init__()方法支持从这种格式的文件创建图(每行是一个顶点名称,后面是该顶点的邻居的名称,用空格分隔)。为了灵活性,我们允许使用除空格之外的分隔符(例如,顶点名称可能包含空格),如 graph.py 所示。
图表客户端示例
作为第一个图处理客户端,我们考虑社会关系的一个例子——这对你来说肯定很熟悉,并且有大量数据可用。
在这个书站上,你可以找到文件 movies.txt,其中包含一系列电影及出演其中的演员。每行给出一部电影的名称,后面是演员名单(出现在该电影中的演员的名称列表)。由于名字中有空格和逗号,我们使用“/”字符作为分隔符。
使用Graph,我们可以构建一个简单方便的客户端,从 movies.txt 中提取信息。我们首先构建一个Graph来更好地组织信息。顶点和边应该如何建模?我们选择为电影和表演者都有顶点,每部电影与其中的每个表演者连接一条边。正如你将看到的,处理这个图的程序可以回答我们许多有趣的问题。
程序 invert.py 是一个第一个例子。它是一个Graph客户端,接受查询,比如电影的名称,并写出出现在该电影中的表演者列表。invert.py 的一个更有趣的特点是,你可以输入表演者的名称,然后得到该表演者出现在哪些电影中的列表。为什么会这样?尽管数据库似乎将电影连接到表演者而不是反过来,但图中的边是连接,也将表演者连接到电影。
所有连接一种顶点到另一种顶点的图被称为二部图。正如这个例子所示,二部图有许多自然属性,我们经常可以以有趣的方式利用这些属性。
值得反思的是,构建一个二部图提供了一种简单的方式来自动反转任何索引!这种反转索引的功能是图数据结构的直接好处。接下来,我们将研究一些从处理数据结构的算法中获得的附加好处。
图中的最短路径

在图中给定两个顶点,路径是由边连接的顶点序列。最短路径是所有这样的路径中边的最小数量(可能存在多个最短路径)。在图中找到连接两个顶点的最短路径是计算机科学中的一个基本问题。
根据应用程序的不同,客户端对于最短路径有各种需求。我们的选择是从以下 API 开始:
在庞大的图中或对于大量查询,我们必须特别关注 API 设计,因为计算路径的成本可能是禁止性的。通过这种设计,客户端可以为给定的图和给定的顶点创建一个PathFinder对象,然后使用该对象来查找最短路径的长度或迭代最短路径上的顶点到图中的任何其他顶点。这些方法的实现被称为单源最短路径算法。一个被称为广度优先搜索的经典算法提供了一个直接而优雅的解决方案,其中构造函数需要线性时间,distanceTo()需要常数时间,而pathTo()需要与路径长度成比例的时间。在检查我们的实现之前,我们将考虑一些客户端。
单源客户端。
假设你有你的廉价航空公司航线图的顶点和连接的图。然后,使用你的家乡作为源,你可以编写一个客户端,在任何时候你想去旅行时写出你的路线。程序 separation.py 是一个PathFinder客户端,为任何图提供这种功能。你被鼓励通过在我们的示例输入 routes.txt 上运行PathFinder或任何你选择的输入模型来探索最短路径的属性。
分离度。
最短路径算法的一个经典应用是在社交网络中找到个人之间的分离度。为了明确概念,我们讨论这个应用,以最近流行的一种名为凯文·贝肯游戏的消遣为例,该游戏使用我们刚刚考虑的电影-表演者图。凯文·贝肯是一位多产的演员,出演了许多电影。我们为每个出演过电影的表演者分配一个贝肯数:贝肯本人是 0,任何与贝肯同台演出过的表演者的贝肯数为 1,任何其他表演者(除了贝肯)与贝肯数为 1 的表演者同台演出过的表演者的贝肯数为 2,依此类推。例如��梅丽尔·斯特里普的贝肯数为 1,因为她与凯文·贝肯在《狂野河流》中演出。妮可·基德曼的数为 2:尽管她没有与凯文·贝肯一起出演过任何电影,但她与唐纳德·萨瑟兰在《冷山》中演出,而萨瑟兰与凯文·贝肯在《动物屋》中演出。给定一个表演者的名字,游戏的最简单版本是找到一些交替的电影和表演者序列,将其连接回凯文·贝肯。值得注意的是,pathfinder.py 正是你需要找到建立任何表演者在 movies.txt 中的贝肯数的最短路径的程序:任何表演者的贝肯数恰好是凯文·贝肯与该表演者之间的距离的一半。
最短路径距离。
我们定义两个顶点之间的距离为它们之间最短路径的长度。理解广度优先搜索的第一步是考虑计算源和每个顶点之间距离的问题(在 PathFinder 中实现 distanceTo())。我们的方法是在构造函数中计算并存储所有距离,然后当客户调用 distanceTo()时,只需返回请求的值。为了将整数距离与每个顶点名称关联起来,我们使用一个符号表:
_distTo = dict()
这个符号表的目的是将每个顶点与该顶点和s之间的最短路径长度(距离)关联起来。为了做到这一点,我们按照它们到s的距离的顺序考虑顶点,忽略那些到s的距离已知的顶点。为了组织计算,我们使用一个 FIFO 队列。从队列中的s开始,我们执行以下操作,直到队列为空:
出队一个顶点 v。
将 v 的所有未知邻居的距离设为比 v 的距离大 1。
将所有未知邻居入队。
这个方法按照它们到源s的距离的非递减顺序出队顶点。
最短路径树。
我们不仅需要源到目的地的距离,还需要路径。为了实现 pathTo(),我们使用一个称为最短路径树的子图,定义如下:
将源顶点 s 放在树的根上。
如果顶点 v 的邻居被添加到队列中,将它们放入树中,并用一条边将每个邻居连接到 v。
由于我们每次只将每个顶点入队一次,这个结构是一棵正确的树:它由一个根(源)连接到源的每个邻居的一个子树。研究这样一棵树,你可以立即看到树中每个顶点到根的距离与图中到源的最短路径距离相同。更重要的是,树中的每条路径都是图中的最短路径。这一观察很重要,因为它为我们提供了一种向客户提供最短路径本身的简单方法(在 PathFinder 中实现 pathTo())。
首先,我们维护一个符号表,将每个顶点与在最短路径上离源一步的顶点关联起来:
_edgeTo = dict()
对于每个顶点w,我们希望将最短路径从源点到w的前一个停靠点关联起来。将最短距离方法扩展到计算这些信息也很容易:当我们因为首次发现w是v的邻居而将其入队时,正是因为v是从源点到w的最短路径上的前一个停靠点,所以我们可以赋值_edgeTo[w] = v。_edgeTo数据结构实际上只是最短路径树的表示:它为树中的每个节点提供了到其父节点的链接。然后,为了响应客户端对从源点到v的路径的请求(在PathFinder中调用pathTo(v)),我们沿着树从v向上遍历这些链接,以相反的顺序遍历路径。当我们遇到顶点时,我们将它们收集到一个数组中,然后反转数组,这样客户端在使用从pathTo()返回的迭代器时就能得到从s到v的路径。
广度优先搜索。
这个过程被称为广度优先搜索,因为它在图中广泛搜索。相比之下,另一种重要的图搜索方法称为深度优先搜索,它基于递归方法,就像我们在第 2.4 节的 percolation.py 中使用的方法一样,并且深入搜索图。深度优先搜索倾向于找到长路径;广度优先搜索保证找到最短路径。
小世界图
科学家们已经确定了一类特别有趣的图,在自然科学和社会科学的许多应用中出现。小世界图具有以下三个特性:
它们是稀疏的:顶点数远小于边数。
它们具有短平均路径长度:如果你随机选择两个顶点,它们之间的最短路径长度很短。
它们表现出局部聚类:如果两个顶点是第三个顶点的邻居,则这两个顶点很可能也是彼此的邻居。
我们将具有这三个特性的图统称为表现出小世界现象。术语小世界指的是绝大多数顶点既具有局部聚类又与其他顶点之间存在短路径的想法。修饰语现象指的是这样一个意外的事实,即实践中出现的许多图都是稀疏的,具有局部聚类,并且具有短路径。
在这样的研究中一个关键问题是:给定一个图,我们如何判断它是否是一个小世界图?为了回答这个问题,我们首先要设定图不是小的(比如,有 1000 个或更多个顶点),并且它是连通的(存在一条连接每对顶点的路径)。然后,我们需要为每个小世界属性设定具体的阈值:
通过稀疏,我们指的是平均顶点度数小于 20 lg V。
通过短平均路径长度,我们指的是两个顶点之间最短路径的平均长度小于 10 lg V。
通过局部聚类,我们指的是一个称为聚类系数的特定数量应该大于 10%。
局部聚类的定义比稀疏性和平均路径长度的定义要复杂一些。直观地说,一个顶点的聚类系数表示如果你随机选择它的两个邻居,它们之间也会通过一条边相连的概率。更准确地说,如果一个顶点有t个邻居,那么连接这些邻居的可能边数为t(t-1)/2;它的局部聚类系数是图中存在的这些边的比例(如果顶点的度为 0 或 1,则为 0)。图的聚类系数是其顶点的局部聚类系数的平均值。如果这个平均值大于 10%,我们说这个图是局部聚类的。下面的图示计算了一个小图的这三个量。
为了更好地让你熟悉这些定义,接下来我们定义一些简单的图模型,并考虑它们是否描述了小世界图,通过检查它们是否具有三个必要属性。

完全图。
一个具有V个顶点的完全图有V(V-1)/ 2 条边,每对顶点之间都有一条边相连。完全图不是小世界图。它们具有短平均路径长度(每条最短路径的长度为 1),并且表现出局部聚类(聚类系数为 1),但它们不是稀疏的(平均顶点度为V-1,远远大于对于大V的 20 lg V)。
环图。
环图是一组V个顶点均匀分布在圆周上,每个顶点与其两侧的邻居相连。在一个k-环图中,每个顶点与其两侧的k个最近邻相连。右侧的图示例展示了一个具有 16 个顶点的 2-环图。环图也不是小世界图。例如,2-环图是稀疏的(每个顶点的度为 4)并且表现出局部聚类(聚类系数为 1/2),但它们的平均路径长度不短。
随机图。
Erdos-Renyi 模型是一个用于生成随机图的研究充分的模型。在这个模型中,我们通过概率p包含每条可能的边来构建一个具有V个顶点的随机图。具有足够数量的边的随机图很可能是连通的并且具有短平均路径长度,但它们不是小世界图,因为它们没有局部聚类。
这些例子说明,开发一个同时满足这三个属性的图模型是一个令人困惑的挑战。花点时间尝试设计一个你认为可能做到这一点的图模型。在思考了这个问题之后,你会意识到你可能需要一个程序来帮助计算。此外,你可能会同意它们在实践中经常被发现是相当令人惊讶的。实际上,你可能会想知道是否有任何图是小世界图!
选择将聚类阈值设为 10%而不是其他固定百分比有些是任意的,就像选择 20 lg V作为稀疏阈值和 10 lg V作为短路径阈值一样,��我们通常不会接近这些边界值。例如,考虑网页图,其中每个网页都有一个顶点,如果两个网页通过链接相连,则它们之间有一条边。科学家估计从一个网页到另一个网页的点击次数很少会超过 30 次。由于有数十亿个网页,这个估计意味着两个顶点之间路径的平均长度非常短,远低于我们的 10 lg V阈值(对于 10 亿个顶点,这将约为 300)。
在确定了定义之后,测试一个图是否是小世界图仍然可能是一个重要的计算负担。正如你可能已经怀疑的那样,我们一直在考虑的图处理数据类型正好提供了我们需要的工具。程序 smallworld.py 是一个Graph和PathFinder客户端,实现了这些测试。如果没有我们一直在考虑的高效数据结构和算法,这种计算的成本将是不可承受的。即便如此,对于大型图(例如 movies.txt),我们必须借助统计抽样来估计平均路径长度和聚类系数,以在合理的时间内完成,因为函数averagePathLength()和clusteringCoefficient()需要二次时间。
一个经典的小世界图。
我们的电影-演员图不是一个小世界图,因为它是二部图,因此具有聚类系数为 0。此外,一些演员对之间没有连接路径。然而,通过连接出现在同一部电影中的两个演员定义的更简单的演员-演员图是小世界图的经典示例(在丢弃未与凯文·贝肯相连的演员后)。下面的图示展示了与一个小电影演员文件相关联的电影演员和演员-演员图。
程序 performer.py 是一个脚本,从我们的电影-演员输入格式的文件中创建一个演员-演员图。回想一下,电影-演员文件中的每一行都包含一部电影,后面跟着出现在该电影中的所有演员,用斜杠分隔。该脚本通过添加连接每对演员的边来连接该电影中的所有演员。对输入中的每部电影执行此操作会产生一个连接演员的图。
由于演员-演员图通常比相应的电影-演员图具有更多的边,我们暂时使用从文件 moviesg.txt 中导出的较小的演员-演员图,其中包含 1261 部 G 级电影和 19044 名演员(所有演员都与凯文·贝肯相连)。现在,performer.py 告诉我们,与 moviesg.txt 相关联的演员-演员图有 19044 个顶点和 1415808 条边,因此平均顶点度为 148.7(约为 20 lg V = 284.3 的一半),这意味着它是稀疏的;其平均路径长度为 3.494(远小于 10 lg V = 142.2),因此具有短路径;其聚类系数为 0.911,因此具有局部聚类。我们找到了一个小世界图!这些计算验证了这种类型的社交关系图表现出小世界现象的假设。
这个案例研究是书站的一个适当结束点,因为我们考虑的程序只是一个起点,而不是一个完整的研究。这个书站也是你在科学、数学或工程领域进一步学习的起点。你在这里学到的编程方法和工具应该能够很好地为你解决任何计算问题做好准备。
问答
问: 有多少个具有给定顶点数 V 的不同图形?
答: 没有自环或平行边,有 V(V-1)/2 条可能的边,每条边可以存在也可以不存在,因此总数为 2^(V(V-1)/2)。这个数字增长得非常快,如下表所示:
V 1 2 3 4 5 6 7 8 9 2^(V(V-1)/2) 1 2 8 64 1024 32768 2097152 268435456 68719476736
这些巨大的数字为我们提供了一些关于社交关系复杂性的见解。例如,如果你只考虑在街上看到的下一个九个人,那么存在超过 68 万亿种相互熟识的可能性!
问: 一个图可以有一个顶点,它与任何其他顶点都没有边连接吗?
答: 很好的问题。这样的顶点被称为孤立顶点。我们的实现不允许它们。另一个实现可能选择通过包含一个显式的addVertex()方法来允许孤立顶点进行添加顶点操作。
问: 为什么countV()和countE()查询方法需要具有常数时间的实现?大多数客户端只会调用这样的方法一次吗?
答: 看起来是这样,但是像这样的代码
while i < g.countE():
...
i += 1
如果你使用一个懒惰的实现来计算边的数量而不是维护一个包含边数的实例变量,那么将需要二次时间。
问: 为什么Graph和PathFinder在单独的类中?将PathFinder方法包含在GraphAPI 中会更有意义吗?
A. 寻找最短路径只是众多图处理算法中的一个。在单个接口中包含所有这些算法将是糟糕的软件设计。请重新阅读第 3.3 节中有关宽接口的讨论。
练习
找出在 movies.txt 中出现次数最多的表演者。
修改
Graph中的__str__()方法,使其按顶点的排序顺序返回顶点(假设顶点是可比较的)。提示:使用内置的sorted()函数。修改
Graph中的__str__()方法,使其在最坏情况下以顶点数和边数为线性时间运行。提示:使用 str 数据类型中的join()方法。在
Graph中添加一个名为copy()的方法,创建并返回图的一个新的、独立的副本。对原始图的任何未来更改不应影响新创建的图(反之亦然!)。编写一个支持显式顶点创建并允许自环、平行边和孤立顶点(度为 0 的顶点)的
Graph版本。在
Graph中添加一个名为removeEdge()的方法,该方法接受两个字符串参数,并从图中删除指定的边(如果存在)。在
Graph中添加一个名为subgraph()的方法,该方法接受一个字符串集合作为参数,并返回诱导子图(仅由原始图中连接任意两个顶点的那些顶点和边组成的图)。描述使用数组或链表代表顶点的邻居而不是使用集合的优缺点。
编写一个
Graph客户端,从文件中读取一个Graph,然后将图中的边逐行写出。修改
Graph,使其支持任何可散列类型的顶点。实现一个
PathFinder客户端allshortestpaths.py,���接受图文件的名称和分隔符作为命令行参数,为每个顶点构建一个PathFinder,然后重复从标准输入中获取两个顶点的名称(在一行上,由分隔符分隔),并写出连接它们的最短路径。注意:对于 movies.txt,顶点名称可以都是表演者、都是电影,或者是表演者和电影。真或假:在广度优先搜索的某个时刻,队列可以包含两个顶点,一个距离源点的距离为 7,另一个距离源点的距离为 9。
解答:错误。队列最多可以包含两个不同距离d和d+1 的顶点。广度优先搜索按照从源点距离递增的顺序检查顶点。在检查距离为d的顶点时,只有距离为d+1 的顶点可以入队。
通过对访问的顶点集合进行归纳,证明
PathFinder能够找到从源点到每个顶点的最短路径距离。假设在
PathFinder中使用栈而不是队列进行广度优先搜索。它仍然能找到一条路径吗?它仍然能正确计算最短路径吗?在每种情况下,证明它能够或者给出反例。编写一个程序,绘制平均路径长度与随机添加到具有 1000 个顶点的 2 环图中的随机快捷方式数量之间的关系。
在模块 smallworld.py 中的
clusterCoefficient()中添加一个可选参数k,以便根据存在的总边和在每个顶点距离k内的顶点集合之间可能的总边来计算图的局部簇系数。使用默认值k=1,使得您的函数产生与 smallworld.py 中同名函数相同的结果。证明k环图中的簇系数为(2k-2) / (2k-1)。推导出k环图在V个顶点上的平均路径长度的公式,作为V和k的函数。
证明在具有V个顶点的 2 环图中直径为~V/4。证明如果添加一条连接两个对脚顶点的边,则直径减小到~V/8。
进行计算实验,验证 V 个顶点的环图中的平均路径长度约为~ 1/4 V。重复此过程,但向环图添加一条随机边,并验证平均路径长度减少到约~ 3/16 V。
在 smallworld.py 中添加一个名为
isSmallWorld()的函数,该函数接受一个图作为参数,并在图符合特定阈值(文本中给出的定义)时返回True,否则返回False。编写一个 smallworld.py 和
Graph客户端,生成k环图,并测试它们是否表现出小世界现象(首先完成前一个练习)。在一个网格图中,顶点按n乘n的网格排列,边连接每个顶点与网格中上、下、左、右的邻居。编写一个 smallworld.py 和
Graph客户端,生成网格图,并测试它们是否表现出小世界现象(首先在 smallworld.py 中添加一个isSmallWorld()方法,如前一个练习中所述)。将你对前两个练习的解决方案扩展到还接受一个命令行参数m,并向图中添加m条随机边。通过实验,找出具有相对较少边的约 1000 个顶点的小世界图。
编写一个
Graph和PathFinder客户端,接受电影演员表文件的名称和分隔符作为参数,并写出一个新的电影演员表文件,但删除所有与凯文·贝肯没有联系的电影。
创意练习
大的贝肯数。 找出 movies.txt 中具有最大但有限的凯文·贝肯数的表演者。
直方图。 编写一个名为
baconhistorgram.py的程序,写出凯文·贝肯数的直方图,指示来自 movies.txt 的表演者中有多少人的贝肯数为 0、1、2、3,... 包括那些贝肯数为无穷大的人(与凯文·贝肯完全没有联系)。表演者-表演者图。 计算凯文·贝肯数的另一种方法是构建一个图,其中每个表演者都有一个顶点(但不是每部电影),如果两个表演者在一部电影中一起出现,则它们通过一条边相连。通过在表演者-表演者图上运行广度优先搜索来计算凯文·贝肯数。将此方法的运行时间与 movies.txt 上的运行时间进行比较。解释为什么这种方法要慢得多。还解释一下如果要包含路径上的电影,就像我们的实现自动完成的那样,你需要做什么。
连通分量。 在无向图中,一个连通分量是一个互相可达的顶点的最大集合。编写一个数据类型
ConnectedComponents,计算图的连通分量。包括一个以Graph为参数的构造函数,使用广度优先搜索计算所有连通分量。还包括一个方法areConnected(v, w),如果v和w在同一个连通分量中则返回True,否则返回False。还添加一个方法components(),返回连通分量的数量。泛洪填充。 一个
Picture是一个代表像素的Color值的二维数组(参见第 3.1 节)。一个blob是相邻像素的集合,颜色相同。编写一个Graph客户端,其构造函数从给定图像构建一个网格图(参见本节中的一个先前练习),并支持泛洪填充操作。给定像素坐标col和row以及颜色c,将该像素及同一 blob 中的所有像素的颜色更改为c。单词阶梯。 编写一个程序
wordladder.py,从命令行接受两个 5 个字母的字符串,从标准输入读取一个 5 个字母单词列表,并使用标准输入上的单词连接这两个字符串的最短word ladder。如果存在的话,两个单词可以在单词阶梯链中连接,如果它们只有一个字母不同。例如,以下单词阶梯连接了 green 和 brown:green greet great groat groan grown brown编写一个简单的过滤器,从系统字典中获取 5 个字母的单词作为标准输入,或下载 words5.txt。你也可以尝试在 words6.txt 上运行你的程序,这是一个 6 个字母单词的列表。(这个游戏最初被称为doublet,是刘易斯·卡罗尔发明的。)
所有路径。 编写一个
Graph客户端类AllPaths,其构造函数接受一个Graph作为参数,并支持在图中两个给定顶点s和t之间计算或写入所有简单路径的操作。简单路径不会多次访问任何顶点。在二维网格中,这样的路径被称为避免自身的行走(参见第 1.4 节)。在统计物理学和理论化学中,这是一个基本问题,例如,用于模拟溶液中线性聚合物分子的空间排列。警告:可能存在指数多条路径。渗透阈值。 为渗透开发一个图模型,并编写一个执行与 percolation.py(来自第 2.4 节)相同计算的
Graph客户端。估计三角形、正方形和六边形网格的渗透阈值。地铁图。 在东京地铁系统中,路线用字母标记,车站用数字标记,例如 G-8 或 A-3。允许换乘的车站是一组车站。在网上找到东京地铁地图,开发一个简单的数据库格式,并编写一个
Graph客户端,用于读取文件并可以回答东京地铁系统的最短路径查询。如果你愿意,也可以选择巴黎地铁系统,那里的路线是名称序列,当两个车站有相同名称时可以换乘。好莱坞宇宙的中心。 我们可以通过计算每个表演者的好莱坞数或平均路径长度来衡量凯文·贝肯的中心性。凯文·贝肯的好莱坞数是所有表演者的平均贝肯数(在其连通分量中)。另一个表演者的好莱坞数计算方式相同,只是将该表演者作为源而不是凯文·贝肯。计算凯文·贝肯的好莱坞数,并找到一个比凯文·贝肯好的好莱坞数的表演者。找到(与凯文·贝肯在同一连通分量中的)好莱坞数最好和最差的表演者。
直径。 一个顶点的离心率是它与任何其他顶点之间的最大距离。图的直径是任意两个顶点之间的最大距离(任何顶点的最大离心率)。编写一个
Graph客户端diameter.py,可以计算顶点的离心率和图的直径。使用它来找到由 movies.txt 表示的图的直径。有向图。 实现一个表示有向图的
Digraph数据类型,其中边的方向很重要:addEdge(v, w)表示从v到w添加一条边,但不是从w到v。用两种方法替换adjacentTo():adjacentFrom(),给出具有从参数顶点指向它们的边的顶点集,以及adjacentTo(),给出具有从它们指向参数顶点的边的顶点集。解释如何修改PathFinder以在有向图中找到最短路径。随机冲浪者。 修改你之前练习中的
Digraph类,制作一个允许平行边的MultiDigraph类。作为一个测试客户端,运行一个与 randomsurfer.py(来自第 1.6 节)相匹配的随机冲浪者模拟。传递闭包。编写一个
Digraph客户端类TransitiveClosure,其构造函数接受一个Digraph作为参数,其方法isReachable(v, w)如果在有向图中从v到w有一条路径,则返回True,否则返回False。提示: 从每个顶点运行广度优先搜索,就像在allshortestpaths.py中一样(来自之前的练习)。统计抽样。 ��用统计抽样来估计图的平均路径长度和聚类系数。例如,要估计聚类系数,选择 t 个随机顶点,并计算这些顶点的聚类系数的平均值。你的函数的运行时间应该比 smallworld.py 中相应函数的运行时间快几个数量级。
覆盖时间。 在一个连通的无向图中,随机游走 从一个顶点移动到其邻居之一,每个邻居被等概率选择。(这个过程是无向图的随机冲浪者类比。)编写程序运行实验,支持对访问图中每个顶点所需步数的假设的发展。完全图的覆盖时间是多少?你能找到一个图族,其中覆盖时间与 V³ 或 2^(V) 成比例增长吗?
Erdos-Renyi 随机图模型。 在经典随机图模型中,我们通过以概率 p 独立地包含每条可能的边来构建具有 V 个顶点的随机图。编写一个
Graph客户端来验证以下属性:连接阈值: 如果 p < 1/V 且 V 很大,则大多数连接组件很小,最大的组件大小对数级别。如果 p > 1/V,则几乎肯定存在一个包含几乎所有顶点的巨型组件。如果 p < ln V / V,则图几乎肯定是不连通的;如果 p > ln V / V,则图几乎肯定是连通的。
度的分布: 度的分布遵循二项分布,以平均值为中心,因此大多数顶点具有相似的度。顶点连接到 k 其他顶点的概率按 k 指数下降。
无中心枢纽: 当 p 是一个常数时,最大顶点度数至多对数级别。
无局部聚类: 如果图稀疏且连通,则聚类系数接近于 0。随机图不是小世界图。
短路径长度: 如果 p > ln V / V,那么图的直径(请参见本节前面的 直径 创意练习)是对数的。
网络链接的幂律。 网页的入度和出度遵循幂律,可以通过 优选附加 过程来建模。假设每个网页只有一个外链。每个页面逐个创建,从指向自身的单个页面开始。以概率 p < 1,它链接到现有页面之一,随机选择。以概率 1-p,它链接到现有页面的概率与该页面的入链数成正比。这一规则反映了新网页指向热门页面的普遍倾向。编写一个程序来模拟这个过程,并绘制入链数量的直方图。
部分解: 入度为 k 的页面比例与 k^(-1/(1-p)) 成正比。
全局聚类系数。 在 smallworld.py 中添加一个计算图的全局聚类系数的函数。全局聚类系数 是两个共同顶点的随机顶点是彼此的邻居的条件概率。找到局部和全局聚类系数不同的图。
瓦茨-斯特罗加茨图模型。(见练习 4.5.24 和 4.5.25。)瓦茨和斯特罗加茨提出了一个混合模型,其中包含了顶点之间相邻的典型链接(人们知道他们的地理邻居),以及一些随机的远程连接链接。在一个n×n的网格图上添加随机边的效果(如本节中先前练习中描述的),对于n = 100,对平均路径长度和聚类系数进行绘图。对于V个顶点的k环图,对于V = 10000 和不同值的k,最多到 10 log V。
博洛巴斯-钟图模型。 博洛巴斯和钟提出了一个混合模型,结合了一个V个顶点的 2 环(V为偶数),加上一个随机匹配。一个匹配是一个每个顶点度为 1 的图。要生成一个随机匹配,打乱V个顶点并在打乱顺序中在顶点i和顶点i+1 之间添加一条边。确定该模型中每个顶点的度。使用 smallworld.py,估计根据这个模型生成的随机图的平均路径长度和聚类系数,对于V = 1000。
克莱因伯格图模型。 在瓦茨-斯特罗加茨模型中,参与者无法在分散网络中找到短路径。但米尔格拉姆的实验也有一个引人注目的算法组成部分 — 个体可以找到短路径!乔恩·克莱因伯格建议使快捷方式的分布服从幂律,概率与距离(在d维度中)的d次幂成比例。每个顶点有一个远程邻居。编写一个程序根据这个模型生成图形,使用 smallworld.py 的测试客户端来测试它们是否表现出小世界现象。绘制直方图以显示图形在所有距离尺度上均匀(在距离 1-10 处具有相同数量的链接,如在距离 10-100 或 100-1000 处)。编写一个程序来计算通过取将路径尽可能接近目标的边缘(以格点距离为准)而获得的路径的平均长度,并测试这个平均值是否与(log V)²成比例的假设。
算法
1. 基础知识
原文:
algs4.cs.princeton.edu/10fundamentals译者:飞龙
概述。
本书的目标是研究各种重要和有用的算法——解决问题的方法适合计算机实现。算法与数据结构——组织数据的方案密切相关。本章介绍了我们研究算法和数据结构所需的基本工具。
1.1 编程模型 介绍了我们的基本编程模型。我们所有的程序都是使用 Java 编程语言的一个小子集以及一些用于输入和输出的自定义库来实现的。
1.2 数据抽象 强调数据抽象,我们定义抽象数据类型(ADTs)。我们指定一个应用程序编程接口(API),然后使用 Java 类机制开发一个实现,供客户端代码使用。
1.3 背包、队列和栈 考虑了三种基本的 ADT:背包、队列和栈。我们使用调整大小数组和链表描述 API 和实现。
1.4 算法分析 描述了我们分析算法性能的方法。我们方法的基础是科学方法:我们提出关于性能的假设,创建数学模型,并进行实验来测试它们。
1.5 案例研究:并查集 是一个案例研究,我们考虑解决一个连接性问题的解决方案,该问题使用实现经典并查集ADT 的算法和数据结构。
本章中的 Java 程序。
下面是本章中的 Java 程序列表。点击程序名称以访问 Java 代码;点击参考号以获取简要描述;阅读教材以获取详细讨论。
REF PROGRAM 描述 / JAVADOC - BinarySearch.java 二分查找 - RandomSeq.java 给定范围内的随机数 - Average.java 一系列数字的平均值 - Cat.java 连接文件 - Knuth.java Knuth 洗牌 - Counter.java 计数器 - StaticSETofInts.java 整数集合 - Allowlist.java 白名单客户端 - Vector.java 欧几里得向量 - Date.java 日期 - Transaction.java 交易 - Point2D.java 点 - RectHV.java 轴对齐矩形 - Interval1D.java 1d 区间 - Interval2D.java 2d 区间 - Accumulator.java 运行平均值和标准差 1.1 ResizingArrayStack.java LIFO 栈(调整大小数组) 1.2 LinkedStack.java 后进先出栈(链表) - Stack.java 后进先出栈 - ResizingArrayQueue.java 先进先出队列(调整大小数组) 1.3 LinkedQueue.java 先进先出队列(链表) - Queue.java 先进先出队列 - ResizingArrayBag.java 多重集(调整大小数组) 1.4 LinkedBag.java 多重集(链表) - Bag.java 多重集 - Stopwatch.java 计时器(墙上时间) - StopwatchCPU.java 计时器(CPU 时间) - LinearRegression.java 简单线性回归 - ThreeSum.java 暴力三数求和 - ThreeSumFast.java 更快的三数求和 - DoublingTest.java 倍增测试 - DoublingRatio.java 倍增比率 - QuickFindUF.java 快速查找 - QuickUnionUF.java 快速联合 1.5 WeightedQuickUnionUF.java 加权快速联合 - UF.java 按秩合并并路径减半
1.1 编程模型
原文:
algs4.cs.princeton.edu/11model译者:飞龙
本节正在大规模施工中。
我们对算法的研究基于将它们作为用 Java 编程语言编写的程序来实现。我们这样做有几个原因:
我们的程序是算法的简洁、优雅和完整描述。
您可以运行程序来研究算法的属性。
您可以立即将算法应用于应用程序中。
原始数据类型和表达式。
数据类型是一组值和对这些值的一组操作。以下四种原始数据类型是 Java 语言的基础:
整数,带有算术运算(
int)实数,再次带有算术运算(
double)布尔值,值集合为{ true, false },带有逻辑运算(
boolean)字符,您键入的字母数字字符和符号(
char)
一个 Java 程序操作用标识符命名的变量。每个变量与数据类型关联,并存储其中一个可允许的数据类型值。我们使用表达式来应用与每种类型相关的操作。
以下表总结了 Java 的int、double、boolean和char数据类型的值集合和最常见操作。
表达式。 典型表达式为中缀。当一个表达式包含多个运算符时,优先级顺序指定它们应用的顺序:运算符
*和/(以及%)的优先级高于(在+和-运算符之前应用);在逻辑运算符中,!具有最高优先级,其次是&&,然后是||。通常,相同优先级的运算符是左结合(从左到右应用)。您可以使用括号来覆盖这些规则。类型转换。 如果不会丢失信息,数字会自动提升为更包容的类型。例如,在表达式
1 + 2.5中,1被提升为double值1.0,表达式求值为double值3.5。强制转换是将一个类型的值转换为另一个类型的指令。例如 (int) 3.7是3。将double转换为int会朝向零截断。比较。 以下混合类型运算符比较相同类型的两个值并产生一个
boolean值:等于(
==)不等于(
!=)小于(
<)小于或等于(
<=)大于(
>)大于或等于(
>=)
其他原始类型。 Java 的
int有 32 位表示;Java 的double类型有 64 位表示。Java 还有五种额外的原始数据类型:64 位整数,带有算术运算(
long)16 位整数,带有算术运算(
short)16 位字符,带有算术运算(
char)8 位整数,带有算术运算(
byte)32 位单精度实数,带有算术运算(
float)
语句。
一个 Java 程序由语句组成,通过创建和操作变量、为它们分配数据类型值以及控制这些操作的执行流程来定义计算。
声明创建指定类型的变量并用标识符命名它们。Java 是一种强类型语言,因为 Java 编译器会检查一致性。变量的作用域是程序中定义它的部分。
赋值将数据类型值(由表达式定义)与变量关联。
初始化声明将声明与赋值结合在一起,以初始化变量的同时声明变量。
隐式赋值。 当我们的目的是相对于当前值修改变量的值时,可以使用以下快捷方式:
递增/递减运算符:代码
i++是i = i + 1��简写。代码++i相同,只是在递增/递减之后取表达式值,而不是之前。其他复合运算符:代码
i /= 2是i = i/2的简写。
条件语句提供了对执行流程的简单改变——根据指定条件在两个块中的一个中执行语句。
循环提供了对执行流程的更深刻改变——只要给定条件为真,就执行块中的语句。我们将循环中的语句称为循环的主体。
中断和继续。 Java 支持在 while 循环中使用的两个额外语句:
break语句,立即退出循环continue语句,立即开始循环的下一次迭代
关于符号。 许多循环遵循这种方案:将索引变量初始化为某个值,然后使用
while循环来测试涉及索引变量的循环继续条件,其中while循环中的最后一条语句递增索引变量。您可以使用 Java 的for符号简洁地表示这样的循环。单语句块。 如果条件或循环中的语句块只有一个语句,则大括号可以省略。
以下表格说明了不同类型的 Java 语句。
数组。
数组存储相同类型的值序列。如果有N个值,我们可以使用符号a[i]来引用i的值,其中i的值从0到N-1。
创建和初始化数组。 在 Java 程序中创建数组涉及三个不同的步骤:
声明数组名称和类型。
创建数组。
初始化数组值。
默认数组初始化。 为了节省代码,我们经常利用 Java 的默认数组初始化约定,并将所有三个步骤合并为一个语句。数值类型的默认初始值为零,
boolean类型的默认值为false。初始化声明。 我们可以在编译时指定初始化值,通过在大括号之间列出逗号分隔的文字值。
![声明、创建和初始化数组]()
使用数组。 一旦创建数组,其大小就固定了。程序可以使用代码
a.length引用数组a[]的长度。Java 进行自动边界检查——如果您使用非法索引访问数组,程序将以ArrayIndexOutOfBoundsException终止。别名。 数组名称指代整个数组——如果我们将一个数组名称分配给另一个数组名称,则两者都指代同一个数组,如下面的代码片段所示。
int[] a = new int[N]; ... a[i] = 1234; ... int[] b = a; ... b[i] = 5678; // a[i] is now 5678.这种情况被称为别名,可能导致微妙的错误。
二维数组。 在 Java 中,二维数组是一维数组的数组。二维数组可能是不规则的(其数组的长度可能各不相同),但我们通常使用(对于适当的参数 M 和 N)M×N 的二维数组。要引用二维数组
a[][]中第i行第j列的条目,我们使用表示法a[i][j]。
静态方法。
许多编程语言中称静态方法为函数,因为它们可以像数学函数一样运行。每个静态方法是一系列语句,当调用静态方法时,这些语句将依次执行。
定义静态方法。 方法 封装了一系列语句定义的计算。方法接受参数(给定数据类型的值)并计算某种数据类型的返回值或引起副作用。每个静态方法由签名和主体组成。
![静态方法的解剖]()
调用静态方法。 对静态方法的调用是其名称,后跟用逗号分隔的括号中指定参数值的表达式。当调用方法时,其参数变量将用调用中相应表达式的值初始化。
return语句终止静态方法,将控制返回给调用者。如果静态方法要计算一个值,那么该值必须在return语句中指定。方法的属性。 Java 方法具有以下特点:
参数按值传递。 调用函数时,参数值会被完全评估,并且生成的值会复制到参数变量中。这被称为按值传递。数组(和其他对象)引用也是按值传递的:方法无法更改引用,但可以更改数组中的条目(或对象的值)。
方法名可以重载。 类中的方法可以具有相同的名称,只要它们具有不同的签名。这个特性被称为重载。
方法具有单个返回值,但可能有多个返回语句。 Java 方法只能提供一个返回值。一旦到达第一个
return语句,控制就会返回到调用程序。方法可能具有副作用。 方法可以使用关键字
void作为其返回类型,以指示它没有返回值并产生副作用(消耗输入,产生输出,更改数组中的条目,或以其他方式更改系统的状态)。
递归。 递归方法是一种直接或间接调用自身的方法。在开发递归程序时有三个重要的经验法则:
递归有一个基本情况。
递归调用必须处理在某种意义上更小的子问题,以便递归调用收敛到基本情况。
递归调用不应处理重叠的子问题。
基本编程模型。 一组静态方法的库是在 Java 类中定义的一组静态方法。Java 编程的基本模型是通过创建一组静态方法的库来解决特定的计算任务,其中一个方法被命名为
main()。模块化编程。 静态方法库使模块化编程成为可能,其中一个库中的静态方法可以调用其他库中定义的静态方法。这种方法有许多重要的优点。
使用合理大小的模块进行工作
共享和重用代码,而无需重新实现它
替换改进的实现
为解决编程问题开发适当的抽象模型
本地化调试
单元测试。 Java 编程中的最佳实践是在每个静态方法库中包含一个
main(),用于测试库中的方法。外部库。 我们使用来自三种不同类型的库的静态方法,每种库都需要(略有)不同的代码重用程序。
java.lang中的标准系统库,包括java.lang.Math,java.lang.Integer和java.lang.Double。这些库在 Java 中始终可用。导入的系统库,如
java.util.Arrays。程序开头需要一个import语句来使用这些库。本书中的库。按照这些说明添加 algs4.jar 到您的 Java 类路径。
要从另一个库调用方法,我们在每次调用时在方法名前加上库名:
Math.sqrt(),Arrays.sort(),BinarySearch.rank()和StdIn.readInt()。
API。
Java 库。
我们的标准库。
您自己的库。
字符串。
连接。
转换。
自动转换。
命令行参数。
输入和输出。
命令和参数。
![命令的解剖]()
标准输出。
格式化输出。
![printf 格式约定]()
标准输入。
重定向和管道。
![从命令行重定向和管道]()
从文件中输入和输出。
标准绘图。
二分查找。
下面是一个完整的 Java 程序 BinarySearch.java,演示了我们编程模型的许多基本特性。它实现了一种称为二分查找的经典算法,并对其进行了白名单过滤应用的测试。
静态方法rank()接受一个整数键和一个排序的int值数组作为参数,并在数组中返回键的索引,否则返回-1。它通过维护变量lo和hi来完成这个任务,使得如果键在a[lo..hi]中,则进入一个循环,测试间隔中的中间条目(在��引mid处)。如果键等于a[mid],则返回值为mid;否则,该方法将间隔大小减半,如果键小于a[mid],则查看左半部分,如果键大于a[mid],则查看右半部分。当找到键或间隔为空时,该过程终止。
开发客户端。
白名单。 在测试中,我们使用样本文件 tinyAllowlist.txt,tinyText.txt,largeAllowlist.txt 和 largeText.txt。
性能。
输入和输出库。
这是我们在整本教材以及更多领域中使用的输入和输出库列表。
我们简要描述输入和输出库,并包含一个示例客户端。
标准输入和标准输出。
StdIn.java 和 StdOut.java 是用于从标准输入读取数字和文本并将数字和文本打印到标准输出的库。我们的版本比相应的 Java 版本具有更简单的接口(并提供一些技术改进)。RandomSeq.java 生成给定范围内的随机数。Average.java 从标准输入读取一系列实数,并在标准输出上打印它们的平均值。
% java Average
10.0 5.0 6.0 3.0 7.0 32.0
3.14 6.67 17.71
<Ctrl-d>
Average is 10.05777777777778
In.java 和 Out.java 是支持多个输入和输出流的面向对象版本,包括从文件或 URL 读取和写入文件。
标准绘图。
StdDraw.java 是一个易于使用的库,用于绘制几何形状,如点、线和圆。RightTriangle.java 绘制一个直角三角形和一个外接圆。
Draw.java 是支持在多个窗口中绘图的面向对象版本。
标准音频。
StdAudio.java 是一个易于使用的合成声音库。Tone.java 从命令行读取频率和持续时间,并为给定持续时间的给定频率声音化正弦波。
% java Tone 440.0 3.0
图像处理。
Picture.java 是一个易于使用的图像处理库。Scale.java 接受图片文件的名称和两个整数(宽度 w 和高度 h)作为命令行参数,并将图像缩放到 w-by-h。
|
| % java Scale mandrill.jpg 298 298 |
|
| % java Scale mandrill.jpg 200 200 |
|
| % java Scale mandrill.jpg 200 400 |
|
问答
Q. 使用一个好的洗牌算法有多重要?
A. 这里有一个有趣的轶事,讲述了当你没有正确执行时会发生什么(尤其是在你的业务是在线扑克时!)。如果你经营一个在线赌场,这里是洗牌一副牌的推荐方法:(i)使用一个密码学安全的伪随机数生成器,(ii)为每张卡分配一个随机的 64 位数字,(iii)根据它们的数字对卡进行排序。
创意问题
- 二项分布。 估计方法调用
binomial1(100, 50, .25)在 Binomial.java 中将使用的递归调用次数。开发一个基于在数组中保存计算值的更好的实现。
网络练习
Sattolo's algorithm. 编写一个程序 Sattolo.java,使用Sattolo's algorithm生成一个长度为 N 的均匀分布循环。
Wget. 编写一个程序 Wget.java,从命令行指定的 URL 读取数据并将其保存在同名文件中。
% java Wget http://introcs.cs.princeton.edu/data/codes.csv % more codes.csv United States,USA,00 Alabama,AL,01 Alaska,AK,02 ...直角三角形。 编写一个客户端 RightTriangle.java,绘制一个直角三角形和一个外接圆。
% java RightTriangle![直角三角形和外接圆]()
弹跳球。 编写一个程序 BouncingBall.java,演示弹跳球的运动。
<BouncingBall.mov>
您的浏览器不支持视频标记。
1.2 数据抽象
原文:
algs4.cs.princeton.edu/12oop译者:飞龙
面向对象编程。
Java 编程主要基于构建数据类型。这种编程风格被称为面向对象编程,因为它围绕着对象的概念展开,一个持有数据类型值的实体。使用 Java 的原始类型,我们主要限于操作数字的程序,但使用引用类型,我们可以编写操作字符串、图片、声音或 Java 标准库或我们的书站上提供的数百种其他抽象的程序。比预定义数据类型库更重要的是,Java 编程中可用的数据类型范围是开放的,因为您可以定义自己的数据类型。
数据类型。 数据类型 是一组值和对这些值的一组操作。
抽象数据类型。 抽象数据类型 是一个其内部表示对客户端隐藏的数据类型。
对象。 对象 是一个可以取一个数据类型值的实体。对象具有三个基本属性:对象的状态是来自其数据类型的值;对象的标识区分一个对象与另一个对象;对象的行为是数据类型操作的效果。在 Java 中,引用是访问对象的机制。
应用程序编程接口(API)。 为了指定抽象数据类型的行为,我们使用一个应用程序编程接口(API),它是一个构造函数和实例方法(操作)的列表,每个操作的效果都有一个非正式描述,就像这个
Counter的 API 一样:![Counter 的 API]()
客户端。 客户端是使用数据类型的程序。
实现。 实现是实现 API 中指定的数据类型的代码。
使用抽象数据类型。
客户端不需要知道数据类型是如何实现的才能使用它。
创建对象。 每个数据类型值都存储在一个对象中。要创建(或实例化)一个单独的对象,我们通过使用关键字
new来调用一个构造函数。每当客户端使用new时,系统会为对象分配内存空间,初始化其值,并返回对对象的引用。![构造函数]()
调用实例方法。 实例方法的目的是操作数据类型的值。实例方法具有静态方法的所有属性:参数按值传递,方法名称可以重载,它们可能有返回值,并且可能会引起副作用。它们具有表征它们的附加属性:每次调用都与一个对象关联。
![实例方法]()
使用对象。 声明为我们提供了在代码中可以使用的对象的变量名。要使用给定的数据类型,我们:
声明类型的变量,用于引用对象
使用关键字
new来调用创建该类型对象的构造函数使用对象名称来调用实例方法,可以作为语句或在表达式中
例如,Flips.java 是一个 Counter.java 客户端,它接受一个命令行参数
T并模拟T次硬币翻转。赋值语句。 具有引用类型的赋值语句会创建引用的副本(而不会创建新对象)��这种情况被称为别名:两个变量都引用同一个对象。别名是 Java 程序中常见的错误来源,如下例所示:
Counter c1 = new Counter("ones"); c1.increment(); Counter c2 = c1; c2.increment(); StdOut.println(c1);该代码打印字符串
"2 ones"。对象作为参数。 您可以将对象作为参数传递给方法。Java 将调用程序中的参数值的副本传递给方法。这种安排称为按值传递。如果您将一个指向
Counter类型对象的引用传递给方法,Java 将传递该引用的副本。因此,该方法无法更改原始引用(使其指向不同的Counter),但可以更改对象的值,例如通过使用引用调用increment()。对象作为返回值。 您还可以将对象作为方法的返回值。该方法可能会返回作为参数传递给它的对象,就像 FlipsMax.java 中的情况,或者它可能创建一个对象并返回对其的引用。这种能力很重要,因为 Java 方法只允许一个返回值——使用对象使我们能够编写代码,实际上返回多个值。
数组是对象。 在 Java 中,任何非原始类型的值都是对象。特别是,数组是对象。与字符串一样,对数组有特殊的语言支持:声明、初始化和索引。与任何其他对象一样,当我们将数组传递给方法或在赋值语句的右侧使用数组变量时,我们只是复制数组引用,而不是数组本身的副本。
对象数组。 数组条目可以是任何类型。当我们创建一个对象数组时,需要分两步进行:使用数组构造函数的括号语法创建数组;为数组中的每个对象创建一个标准构造函数。Rolls.java 模拟掷骰子,使用
Counter对象数组来跟踪每个可能值出现的次数。
抽象数据类型的示例。
几何对象。 面向对象编程的一个自然示例是为几何对象设计数据类型。
Point2D.java 是用于平面上的点的数据类型。
Interval1D.java 是用于一维区间的数据类型。
Interval2D.java 是用于二维区间的数据类型。
信息处理。 抽象数据类型提供了一个自然的机制来组织和处理信息。信息
Date.java 是一个表示日期、月份和年份的���据类型。
Transaction.java 是一个表示客户、日期和金额的数据类型。
累加器。 Accumulator.java 定义了一个 ADT,为客户提供了维护数据值的运行平均值的能力。例如,我们在本书中经常使用这种数据类型来处理实验结果。VisualAccumulator.java 是一个增强版本,还会绘制数据(灰色)和运行平均值(红色)。
字符串。 Java 的
String数据类型是一个重要且有用的 ADT。String是char值的索引序列。String有几十种实例方法,包括以下内容:![string api]()
String有特殊的语言支持用于初始化和连接:我们可以使用字符串字面量来创建和初始化字符串,而不是使用构造函数;我们可以使用+运算符来连接字符串,而不是调用concat()方法。输入和输出再探讨。 第 1.1 节的
StdIn、StdOut和StdDraw库的一个缺点是它们限制了我们在任何给定程序中只能使用一个输入文件、一个输出文件和一个绘图。通过面向对象编程,我们可以定义类似的机制,允许我们在一个程序中使用多个输入流、输出流和绘图。具体来说,我们的标准库包括支持多个输入和输出流的数据类型 In.java、Out.java 和 Draw.java。
实现抽象数据类型。
我们使用 Java 类来实现 ADTs,将代码放在与类同名的文件中,后面跟着.java 扩展名。文件中的第一条语句声明实例变量,定义数据类型的值。在实例变量之后是构造函数和实例方法,实现对数据类型值的操作。
实例变量. 为了定义数据类型的值(每个对象的状态),我们声明实例变量的方式与声明局部变量的方式非常相似。每个实例变量对应着许多值(对应数据类型的每个实例对象)。每个声明都由可见性修饰符修饰。在 ADT 实现中,我们使用
private,使用 Java 语言机制来强制执行 ADT 的表示应该对客户端隐藏,还可以使用final,如果该值在初始化后不会更改。构造函数. 构造函数建立对象的标识并初始化实例变量。构造函数总是与类同名。我们可以重载名称并具有具有不同签名的多个构造函数,就像方法一样。如果没有定义其他构造函数,则隐式存在一个默认无参数构造函数,没有参数,并将实例值初始化为默认值。原始数值类型的默认值为 0,
boolean为false,null。实例方法. 实例方法指定数据类型的操作。每个实例方法都有一个返回类型,一个签名(指定其名称和参数变量的类型和名称),以及一个主体(包括一系列语句,包括一个返回语句,将返回类型的值提供给客户端)。当客户端调用方法时,参数值(如果有)将用客户端值初始化,语句将执行直到计算出返回值,并将该值返回给客户端。实例方法可以是public(在 API 中指定)或private(用于组织计算且不可用于客户端)。
![类的解剖图]()
作用域. 实例方法使用三种类型的变量:参数变量,局部变量和实例变量。前两者与静态方法相同:参数变量在方法签名中指定,并在调用方法时用客户端值初始化,局部变量在方法主体内声明和初始化。参数变量的作用域是整个方法;局部变量的作用域是定义它们的块中的后续语句。实例变量保存类中对象的数据类型值,其作用域是整个类(在存在歧义时,可以使用
this前缀来标识实例变量)。![作用域]()
设计抽象数据类型。
我们将与设计数据类型相关的重要信息放在一个地方供参考,并为本书中的实现奠定基础。
封装. 面向对象编程的一个特点是它使我们能够将数据类型封装在其实现中,以促进客户端和数据类型实现的分开开发。封装实现了模块化编程。
设计 APIs. 在构建现代软件中最重要且最具挑战性的步骤之一是设计 APIs。理想情况下,API 将清晰地阐明所有可能输入的行为,包括副作用,然后我们将有软件来检查实现是否符合规范。不幸的是,理论计算机科学中的一个基本结果,即规范问题,意味着这个目标实际上是不可能实现的。在设计 API 时存在许多潜在的陷阱:
过于难以实现,使得开发变得困难或不可能。
过于难以使用,导致复杂的客户端代码。
过于狭窄,省略了客户端需要的方法。
过于广泛,包含许多任何客户端都不需要的方法。
过于一般,提供没有用的抽象。
过于具体,提供的抽象太过模糊,无用。
过于依赖特定表示,因此不能使客户端代码摆脱表示的细节。
总之,为客户端提供他们需要的方法,而不是其他方法。
算法和 ADT. 数据抽象自然适合于算法的研究,因为它帮助我们提供一个框架,可以精确指定算法需要完成的任务以及客户端如何使用算法。例如,我们在本章开头的白名单示例自然地被视为 ADT 客户端,基于以下操作:
从给定值数组构造一个 SET。
确定给定值是否在集合中。
这些操作封装在 StaticSETofInts.java 和 Allowlist.java 中。
接口继承. Java 提供了语言支持来定义对象之间的关系,称为继承。我们考虑的第一种继承机制称为子类型化,它允许我们通过在接口中指定一组每个实现类必须包含的共同方法来指定否则无关的类之间的关系。我们使用接口继承进行比较和迭代。
![本书中使用的 Java 接口]()
实现继承. Java 还支持另一种继承机制,称为子类化,这是一种强大的技术,使程序员能够在不从头开始重写整个类的情况下更改行为和添加功能。这个想法是定义一个��承实例方法和实例变量的新类(子类),从另一个类(超类)继承。我们在本书中避免使用子类化,因为它通常违反封装。这种方法的某些残留物内置在 Java 中,因此不可避免:具体来说,每个类都是Object的子类。
![本书中使用的 Object 继承方法]()
字符串转换. 每种 Java 类型都从Object继承了
toString()。这种约定是 Java 自动将连接运算符+的一个操作数转换为String的基础,只要另一个操作数是String。我们通常包含重写默认toString()的实现,如 Date.java 和 Transaction.java。包装类型. Java 提供了内置的引用类型,称为包装类型,每种原始类型对应一个:
基本类型 包装类型 booleanBoolean byteByte charCharacter doubleDouble floatFloat intInteger longLong shortShort Java 在必要时会自动将基本类型转换为包装类型(autoboxing)并在需要时转换回来(auto-unboxing)。
相等性. 两个对象相等意味着什么?如果我们用
(a == b)测试相等性,其中a和b是相同类型的引用变量,我们正在测试它们是否具有相同的标识:是否引用相等。典型的客户端更希望能够测试数据类型值(对象状态)是否相同。每个 Java 类型都从 Object 继承了equals()方法。Java 为标准类型(如Integer、Double和String)以及更复杂类型(如 java.io.File 和 java.net.URL)提供了自然的实现。当我们定义自己的数据类型时,我们需要重写equals()。Java 的约定是equals()必须是一个等价关系:自反性:
x.equals(x)成立。对称性:
x.equals(y)成立当且仅当y.equals(x)成立。传递性: 如果
x.equals(y)和y.equals(z)成立,则x.equals(z)也成立。
此外,它必须以
Object作为参数,并满足以下属性。一致性: 多次调用
x.equals(y)一致地返回相同的值,前提是没有修改任何对象。非空:
x.equals(null)返回 false。
遵循这些 Java 约定可能会有些棘手,就像 Date.java 和 Transaction.java 中所示的那样。
内存管理. Java 最重要的特性之一是其能够自动管理内存。当一个对象不再被引用时,它被称为孤立的。Java 跟踪孤立的对象,并将它们使用的内存返回给一个空闲内存池。以这种方式回收内存被称为垃圾回收。
不可变性. 一个不可变的数据类型具有一个特性,即对象的值在构造后永远不会改变。相比之下,可变的数据类型操作旨在改变的对象值。Java 为帮助强制实现不可变性提供了
final修饰符。当你声明一个变量为final时,你承诺只能在初始化程序或构造函数中为其分配一个值。试图修改final变量的值的代码会导致编译时错误。Vector.java 是一个用于向量的不可变数据类型。为了保证不可变性,它防御性地复制了可变的构造函数参数。
异常和错误是处理我们无法控制的意外错误的破坏性事件。我们已经遇到了以下异常和错误:
ArithmeticException。当发生异常的算术条件(例如整数除以零)时抛出。
ArrayIndexOutOfBoundsException。当使用非法索引访问数组时抛出。
NullPointerException。当需要对象而使用
null时抛出。OutOfMemoryError。当 Java 虚拟机无法分配对象因为内存不足时抛出。
StackOverflowError。当递归方法递归太深时抛出。
你也可以创建自己的异常。最简单的一种是终止程序执行并打印错误消息的 RuntimeException。
throw new RuntimeException("Error message here.");断言 是在我们开发的代码中验证我们所做假设的布尔表达式。如果表达式为 false,程序将终止并报告错误消息。例如,假设您有一个计算值,可能用于���引到数组中。如果这个值为负,它将在稍后引起
ArrayIndexOutOfBoundsException。但如果您编写代码assert index >= 0;您可以确定发生错误的地方。默认情况下,断言是禁用的。您可以通过使用
-enableassertions标志(简写为-ea)从命令行启用它们。断言用于调试:您的程序不应依赖断言进行正常操作,因为它们可能被禁用。
Q + A.
- Java 中是否有真正的不可变类?
如果使用反射,可以访问任何类的private字段并更改它们。程序 MutableString.java 演示了如何改变一个String。程序 MutableInteger.java 证明了即使实例变量是 final,这也是正确的。
练习
编写一个 Point2D.java 客户端,从命令行获取一个整数值 N,在单位正方形内生成 N 个随机点,并计算最近一对点之间的距离。
以下代码片段打印什么?
String string1 = "hello"; String string2 = string1; string1 = "world"; StdOut.println(string1); StdOut.println(string2);解决方案:
world hello如果一个字符串 s 是字符串 t 的circular rotation,那么当字符被任意数量的位置循环移位时,它们匹配;例如,ACTGACG 是 TGACGAC 的循环移位,反之亦然。检测这种条件在基因组序列研究中很重要。编写一个程序,检查两个给定的字符串 s 和 t 是否彼此的循环移位。
解决方案:
(s.length() == t.length()) && (s.concat(s).indexOf(t) >= 0)以下递归函数返回什么?
public static String mystery(String s) { int N = s.length(); if (N <= 1) return s; String a = s.substring(0, N/2); String b = s.substring(N/2, N); return mystery(b) + mystery(a); }解决方案: 字符串的反转。
使用我们的 Date.java 的实现作为模型,开发一个 Transaction.java 的实现。
使用我们在 Date.java 中的
equals()的实现作为模型,为 Transaction.java 开发一个equals()的实现。
创意问题
有理数。 为有理数实现一个不可变的数据类型 Rational.java,支持加法、减法、乘法和除法。
您不必担心溢出测试,但使用两个表示分子和分母的long值作为实例变量,以限制溢出的可能性。使用欧几里得算法确保分子和分母永远没有任何公因数。包括一个测试客户端,测试所有方法。累加器的样本方差。 验证以下代码,为 Accumulator.java 添加
var()和stddev()方法,计算作为addDataValue()参数呈现的数字的均值、样本方差和样本标准差。参考: 这里有一个很好的解释这种一次性方法,最早由 Welford 在 1962 年发现。这种方法可以应用于计算偏度、峰度、回归系数和皮尔逊相关系数。
解析。 为您的 Date.java 和 Transaction.java 实现开发解析构造函数,使用下表中给出的格式的单个
String参数指定初始化值。![Date 和 Transaction 的解析]()
1.3 袋子、队列和栈
原文:
algs4.cs.princeton.edu/13stacks译者:飞龙
几种基本数据类型涉及对象的集合。具体来说,值的集合是对象的集合,操作围绕向集合中添加、删除或检查对象展开。在本节中,我们考虑了三种这样的数据类型,称为袋子、队列和栈。它们在规定下一个要移除或检查的对象方面有所不同。
API。
我们为袋子、队列和栈定义了 API。除了基础知识外,这些 API 还反映了两个 Java 特性:泛型和可迭代集合。
泛型. 集合 ADT 的一个重要特征是我们应该能够将它们用于任何类型的数据。一种名为 泛型 的特定 Java 机制实现了这一功能。在我们的每个 API 中类名后面的
<Item>表示将Item命名为 类型参数,一个用于客户端的具体类型的符号占位符。你可以将Stack<Item>理解为“项目的堆栈”。例如,你可以编写如下代码Stack<String> stack = new Stack<String>(); stack.push("Test"); ... String next = stack.pop();用于
String对象的堆栈。自动装箱. 类型参数必须实例化为引用类型,因此 Java 在赋值、方法参数和算术/逻辑表达式中自动在原始类型和其对应的包装类型之间转换。这种转换使我们能够在原始类型中使用泛型,就像以下代码中所示:
Stack<Integer> stack = new Stack<Integer>(); stack.push(17); // autoboxing (int -> Integer) int i = stack.pop(); // unboxing (Integer -> int)将基本类型自动转换为包装类型称为 自动装箱,将包装类型自动转换为基本类型称为 拆箱。
可迭代集合. 对于许多应用程序,客户端的要求只是以某种方式处理每个项目,或者在集合中 迭代。Java 的 foreach 语句支持这种范例。例如,假设
collection是一个Queue<Transaction>。那么,如果集合是可迭代的,客户端可以通过一条语句打印交易列表:for (Transaction t : collection) StdOut.println(t);袋子. 一个 袋子 是一个不支持移除项目的集合——它的目的是为客户提供收集项目并遍历收集项目的能力。Stats.java 是一个袋子客户端,从标准输入读取一系列实数,并打印出它们的平均值和标准差。
FIFO 队列. 一个 FIFO 队列 是基于 先进先出(FIFO)策略的集合。按照任务到达的顺序执行任务的策略在我们日常生活中经常遇到:从在剧院排队等候的人们,到在收费站排队等候的汽车,再到等待计算机应用程序服务的任务。
推入栈. 一个 推入栈 是基于 后进先出(LIFO)策略的集合。当你点击超链接时,浏览器会显示新页面(并将其推入栈)。你可以继续点击超链接访问新页面,但总是可以通过点击返回按钮重新访问上一页(从栈中弹出)。Reverse.java 是一个堆栈客户端,从标准输入读取一系列整数,并以相反顺序打印它们。
算术表达式求值. Evaluate.java 是一个堆栈客户端,用于评估完全括号化的算术表达式。它使用 Dijkstra 的 2 栈算法:
将操作数推送到操作数栈上。
将运算符推送到运算符栈上。
忽略左括号。
遇到右括号时,弹出一个运算符,弹出所需数量的操作数,并将将该运算符应用于这些操作数的结果推送到操作数栈上。
这段代码是一个 解释器 的简单示例。
数组和调整大小数组实现集合。
*固定容量的字符串栈。*FixedCapacityStackOfString.java 使用数组实现了一个固定容量的字符串栈。
*固定容量的通用栈。*FixedCapacityStack.java 实现了一个通用的固定容量栈。
数组调整大小栈。ResizingArrayStack.java 使用调整大小数组实现了一个通用栈。使用调整大小数组,我们动态调整数组的大小,使其足够大以容纳所有项目,同时又不会浪费过多空间。如果数组已满,在
push()中我们将数组大小加倍;如果数组少于四分之一满,在pop()中我们将数组大小减半。*数组调整大小队列。*调整大小数组队列.java 使用调整大小数组实现队列 API。
链表。
链表 是一种递归数据结构,要么为空(null),要么是指向具有通用项和指向链表的节点的引用。要实现链表,我们从定义节点抽象的嵌套类开始。
private class Node {
Item item;
Node next;
}
构建链表。 要构建一个包含项目
to、be和or的链表,我们为每个项目创建一个Node,将每个节点中的项目字段设置为所需值,并设置next字段以构建链表。![构建链表]()
在开头插入。 在链表中插入新节点的最简单位置是在开头。
![在链表开头插入新节点]()
从开头删除。 删除链表中的第一个节点也很容易。
![删除链表中的第一个节点]()
在末尾插入。 要在链表的末尾插入一个节点,我们需要维护一个指向链表中最后一个节点的链接。
![在链表末尾插入节点]()
遍历。 以下是遍历链表中节点的习惯用法。
for (Node x = first; x != null; x = x.next) { // process x.item }
集合的链表实现。
*栈的链表实现。*Stack.java 使用链表实现了一个通用栈。它将栈作为一个链表维护,栈的顶部在开头,由实例变量
first引用。要push()一个项目,我们将其添加到列表的开头;要pop()一个项目,我们将其从列表的开头移除。队列的链表实现。 程序 Queue.java 使用链表实现了一个通用 FIFO 队列。它将队列作为一个链表维护,从最近添加的项目到最近添加的项目的顺序,队列的开始由实例变量
first引用,队列的结束由实例变量last引用。要enqueue()一个项目,我们将其添加到列表的末尾;要dequeue()一个项目,我们将其从列表的开头移除。背包的链表实现。 程序 Bag.java 使用链表实现了一个通用背包。该实现与 Stack.java 相同,只是将
push()的名称更改为add()并删除pop()。
迭代。
要考虑实现迭代的任务,我们从一个客户端代码片段开始,该代码打印字符串集合中的所有项目,每行一个:
Stack<String> collection = new Stack<String>();
...
for (String s : collection)
StdOut.println(s);
...
这个foreach语句是以下while语句的简写:
Iterator<String> i = collection.iterator();
while (i.hasNext()) {
String s = i.next();
StdOut.println(s);
}
要在集合中实现迭代:
包含以下
import语句,以便我们的代码可以引用 Java 的java.util.Iterator接口:import java.util.Iterator;将以下内容添加到类声明中,承诺提供一个
iterator()方法,如Java.lang.Iterable接口中指定的:implements Iterable<Item>实现一个返回实现
Iterator接口的类的对象的方法iterator():public Iterator<Item> iterator() { return new LinkedIterator(); }实现一个嵌套类,通过包含
hasNext()、next()和remove()方法来实现Iterator接口。我们总是对可选的remove()方法使用空方法,因为最好避免在迭代中插入修改数据结构的操作。当底层数据结构是链表时,Bag.java 中的嵌套类
LinkedIterator说明了如何实现一个实现Iterator接口的类。当底层数据结构是数组时,ResizingArrayBag.java 中的嵌套类
ArrayIterator也是如此。
自动装箱问题 + 回答
Q. 自动装箱如何处理以下代码片段?
Integer a = null;
int b = a;
A. 这导致运行时错误。原始类型可以存储其对应包装类型的每个值,除了null。
Q. 为什么第一组语句打印true,但第二组打印false?
Integer a1 = 100;
Integer a2 = 100;
System.out.println(a1 == a2); // true
Integer b1 = new Integer(100);
Integer b2 = new Integer(100);
System.out.println(b1 == b2); // false
Integer c1 = 150;
Integer c2 = 150;
System.out.println(c1 == c2); // false
A. 第二个打印false,因为b1和b2是指向不同 Integer 对象的引用。第一个和第三个代码片段依赖于自动装箱。令人惊讶的是,第一个打印 true,因为在-128 和 127 之间的值似乎指向相同的不可变 Integer 对象(Java 的valueOf()实现在整数在此范围内时检索缓存值),而 Java 为此范围外的每个整数构造��对象。
这里是另一个 Autoboxing.java 的异常。
泛型问题 + 回答
Q. 泛型仅用于自动转换吗?
A. 不是,但我们只会用于“具体参数化类型”,其中每种数据类型都由单个类型参数化。主要好处是在编译时而不是运行时发现类型不匹配错误。泛型还有其他更一般(更复杂)的用途,包括通配符。这种一般性对于处理子类型和继承很有用。有关更多信息,请参阅这个泛型常见问题解答和这个Java 泛型教程。
Q. 具体参数化类型可以像普通类型一样使用吗?
A. 是的,有几个例外情况(数组创建、异常处理、使用instanceof和在类文字中)。
Q. 我可以将 Node 类设为静态吗?
A. 对于 LinkedStackOfString.java,你可以这样做而不需要其他更改,并节省每个节点的 8 字节(内部类开销)。然而,在 LinkedStack.java 中的嵌套类Node使用外部类的Item类型信息,因此你需要做一些额外的工作使其静态化。Stack.java 通过使嵌套类(和嵌套迭代器)泛型化来实现这一点:有三个单独的泛型类型参数,每个都命名为Item。
Q. 当我尝试创建泛型数组时为什么会出现“无法创建泛型数组”的错误?
public class ResizingArrayStack<Item> {
Item[] a = new Item[1];
A. 不幸的是,在 Java 1.5 中无法创建泛型数组。根本原因是 Java 中的数组是协变的,但泛型不是。换句话说,String[]是Object[]的子类型,但Stack<String>不是Stack<Object>的子类型。为了解决这个缺陷,你需要执行一个未经检查的转换,就像在 ResizingArrayStack.java 中一样。ResizingArrayStackWithReflection.java 是一个(笨拙的)替代方案,通过使用反射来避免未经检查的转换。
Q. 那么,为什么数组是协变的?
A. 许多程序员(和编程语言理论家)认为 Java 类型系统中的协变数组是一个严重的缺陷:它们会产生不必要的运行时性能开销(例如,参见ArrayStoreException),并且可能导致微妙的错误。Java 引入协变数组是为了解决 Java 最初设计中不包含泛型的问题,例如,实现Arrays.sort(Comparable[])并使其能够接受String[]类型的输入数组。
Q. 我可以创建并返回一个参数化类型的新数组吗,例如为泛型队列实现一个toArray()方法?
A. 不容易。你可以使用反射来实现,前提是客户端向toArray()传递所需具体类型的对象。这是 Java 集合框架采取的(笨拙的)方法。GenericArrayFactory.java 提供了一个客户端传递Class类型变量的替代解决方案。另请参阅 Neal Gafter 的博客,了解使用type tokens的解决方案。
迭代器问答
Q. 为什么这个结构被称为foreach,而它使用关键字for?
A. 其他语言使用关键字foreach,但 Java 开发人员不想引入新关键字并破坏向后兼容性。
Q. String可迭代吗?
A. 不。
Q. 数组是Iterable吗?
A. 不。你可以使用它们的 foreach 语法。但是,你不能将数组传递给期望Iterable的方法,也不能从返回Iterable的方法返回数组。这样会很方便,但实际上不起作用。
Q. 以下代码片段有什么问题?
String s;
for (s : listOfStrings)
System.out.println(s);
A. 增强的 for 循环要求在循环内部声明迭代变量。
练习
在 FixedCapacityStackOfStrings.java 中添加一个
isFull()方法。给出
java Stack对输入打印的输出it was - the best - of times - - - it was - the - -解决方案。
was best times of the was the it (1 left on stack)假设执行了一系列交错的(栈)push和pop操作。push 操作按顺序将整数 0 到 9 推入栈;pop 操作打印返回值。以下哪种序列不可能发生?
(a) 4 3 2 1 0 9 8 7 6 5 (b) 4 6 8 7 5 3 2 9 0 1 (c) 2 5 6 7 4 8 9 3 1 0 (d) 4 3 2 1 0 5 6 7 8 9 (e) 1 2 3 4 5 6 9 8 7 0 (f) 0 4 6 5 3 8 1 7 2 9 (g) 1 4 7 9 8 6 5 3 0 2 (h) 2 1 4 3 6 5 8 7 9 0答案:(b)、(f)和(g)。
编写一个栈客户端 Parentheses.java,从标准输入中读取一系列左右括号、大括号和方括号,并使用栈来确定序列是否平衡。例如,你的程序应该对
[()]{}{[()()]()}打印true,对[(])打印false。当
n为 50 时,以下代码片段打印什么?给出当给定正整数n时它的高级描述。Stack<Integer> s = new Stack<Integer>(); while (n > 0) { s.push(n % 2); n = n / 2; } while (!s.isEmpty()) System.out.print(s.pop()); System.out.println();答案:打印
N的二进制表示(当n为50时为110010)。以下代码片段对队列
q做了什么?Stack<String> s = new Stack<String>(); while(!q.isEmpty()) s.push(q.dequeue()); while(!s.isEmpty()) q.enqueue(s.pop());答案:颠倒队列中的项目。
在 Stack.java 中添加一个
peek方法,返回栈中最近插入的项(不弹出)。编写一个过滤器程序 InfixToPostfix.java,将中缀算术表达式转换为后缀表达式。
编写一个程序 EvaluatePostfix.java,从标准输入中获取后缀表达式,对其进行评估,并打印值。(将上一个练习的程序输出通过管道传递给这个程序,可以实现与 Evaluate.java 相同的行为。)
假设客户端执行了一系列交错的(队列)enqueue和dequeue操作。enqueue 操作按顺序将整数 0 到 9 放入队列;dequeue 操作打印返回值。以下哪种序列不可能发生?
(a) 0 1 2 3 4 5 6 7 8 9 (b) 4 6 8 7 5 3 2 9 0 1 (c) 2 5 6 7 4 8 9 3 1 0 (d) 4 3 2 1 0 5 6 7 8 9答案:(b)、(c)和(d)。
开发一��类
ResizingArrayQueueOfStrings,使用固定大小数组实现队列抽象,然后扩展您的实现以使用数组调整大小以消除大小限制。解决方案: ResizingArrayQueue.java
链表练习
创意问题
约瑟夫问题。 在古代的约瑟夫问题中,N 个人陷入困境,并同意采取以下策略来减少人口。 他们围成一个圆圈(位置从 0 到 N-1 编号),沿着圆圈进行,每隔 M 个人就淘汰一个,直到只剩下一个人。 传说中约瑟夫找到了一个位置可以避免被淘汰。编写一个
Queue客户端 Josephus.java,从命令行获取 M 和 N,并打印出人们被淘汰的顺序(从而向约瑟夫展示在圆圈中应该坐在哪里)。% java Josephus 2 7 1 3 5 0 4 2 6复制一个栈。 为链表实现的 Stack.java 创建一个新的构造函数,使得
Stack t = new Stack(s)使t引用栈s的一个新且独立的副本。递归解决方案: 为从给定
Node开始的链表创建一个复制构造函数,并使用它来创建新的栈。Node(Node x) { item = x.item; if (x.next != null) next = new Node(x.next); } public Stack(Stack<Item> s) { first = new Node(s.first); }非递归解决方案: 为单个
Node对象创建一个复制构造函数。Node(Node x) { this.item = x.item; this.next = x.next; } public Stack(Stack<Item> s) { if (s.first != null) { first = new Node(s.first); for (Node x = first; x.next != null; x = x.next) x.next = new Node(x.next); } }栈的可生成性。 假设我们有一个混合 push 和 pop 操作的序列,就像我们的测试栈客户端一样,其中按顺序 0、1、...、N-1(push 指令)与 N 个减号(pop 指令)交错。设计一个算法,确定混合序列是否会导致栈下溢。 (您只能使用与 N 无关的空间量 - 不能将整数存储在数据结构中。)设计一个线性时间算法,确定给定排列是否可以由我们的测试客户端生成输出(取决于 pop 操作发生的位置)。
解决方案。 只有存在整数 k,使得前 k 个 pop 操作发生在前 k 个 push 操作之前,栈才会下溢。
如果可以生成给定的排列,那么它将唯一生成如下:如果排列中的下一个整数在栈的顶部,则弹出它;否则,将输入序列中的下一个整数推送到栈上(或者如果已经推送了 N-1,则停止)。 只有在终止时栈为空,排列才能生成。
栈可生成的禁止三元组。 (R. Tarjan) 证明排列可以由栈生成(如前一个问题中所述),当且仅当它没有 禁止的三元组 (a, b, c),其中 a < b < c,c 第一,a 第二,b 第三(可能在 c 和 a 之间以及 a 和 b 之间有其他插入的整数)。
部分解决方案。 假设存在一个禁止的三元组(a,b,c)。 在 a 和 b 之前弹出项 c,但在 c 之前推入 a 和 b。 因此,当推入 c 时,a 和 b 都在栈上。 因此,在弹出 b 之前,a 不能被弹出。
可连接的队列、栈或 steque。 添加一个额外的 连接 操作,(破坏性地)连接两个队列、栈或 steques。 提示: 使用循环链表,保持指向最后一项的指针。
快速失败的迭代器。 修改 Stack.java 中的迭代器代码,如果客户端在迭代期间修改集合(通过
push()或pop())则立即抛出 java.util.ConcurrentModificationException。解决方案: 维护一个计数器,计算
push()和pop()操作的次数。 创建一个迭代器时,将此值存储为迭代器实例变量。 在每次调用hasNext()和next()之前,检查该值是否自构造迭代器以来已更改;如果已更改,则抛出异常。带优先级的表达式求值。 编写一个程序 EvaluateDeluxe.java,扩展 Evaluate.java 以处理未完全括号化的表达式,使用标准的运算符 +、-、* 和 / 的优先级顺序。
网络练习
尾部。 编写一个程序
Tail,使得Tail k < file.txt打印文件file.txt的最后k行。使用StdIn.readLine()。应该使用哪种数据结构?有界栈。 一个有界栈是一个最多容纳 N 个元素的栈。(应用:带有有限缓冲区的撤销或历史记录。)
删除第 i 个元素。 创建一个支持以下操作的数据类型:
isEmpty、insert和remove(int i),其中删除操作删除并返回队列中最近添加的第 i 个对象。首先使用数组实现,然后使用链表实现。每个操作的运行时间是多少?动态缩小。 使用栈和队列的数组实现时,当数组不足以存储下一个元素时,我们会将数组大小加倍。如果我们执行了多次加倍操作,然后删除了很多元素,可能会得到一个比必要的大得多的数组。实现以下策略:每当数组的填充率低于 1/4 时,将其缩小到一半大小。解释为什么当填充率低于 1/2 时我们不将其缩小到一半大小。
栈 + 最大值。 创建一个数据结构,有效支持栈操作(弹出和推入),并返回最大元素。假设元素是整数或实数,以便可以比较它们。
提示:使用两个堆栈,一个用于存储所有元素,另一个用于存储最大值。
PostScript。 PostScript 是大多数打印机使用的基于堆栈的语言。使用一个堆栈实现 PostScript 的一个小子集。
面试问题。 给定一个未知数量的字符串的堆栈,打印出倒数第 5 个字符串。在此过程中破坏堆栈是可以的。提示:使用一个包含 5 个元素的队列。
标签系统。 编写一个程序,从命令行读取一个二进制字符串,并应用以下(00, 1101���标签系统:如果第一个位是 0,则删除前三位并追加 00;如果第一个位是 1,则删除前三位并追加 1101。只要字符串至少有 3 位,就重复此过程。尝试确定以下输入是否会停止或进入无限循环:10010, 100100100100100100。使用一个队列。
图灵带。 实现一个一维图灵带。带由一系列单元格组成,每个单元格存储一个整数(初始化为 0)。在任何时刻,都有一个带头指向其中一个单元格。支持以下接口方法:
moveLeft将带头向左移动一个单元格,moveRight将带头向右移动一个单元格,look返回活动单元格的内容,write(int a)将活动单元格的内容更改为a。提示:使用一个int表示活动单元格,使用两个堆栈表示带的左侧和右侧部分。类似于文本编辑器缓冲区。回文检查器。 编写一个程序,读取一系列字符串并检查它们是否构成回文。忽略标点、空格和大小写。(A MAN, A PLAN, A CANAL - PANAMA)。使用一个栈和一个队列。
流算法。 给定一长序列的项目,设计一个数据结构来存储最近看到的 k 个项目。
2 M/M/1 队列。 下一个顾客被分配到两个队列中较小的一个。使用 2 个先进先出队列。当接近收费站时,总是选择较长的队列(或错误的车道)的感觉。假设两辆车同时进入收费站并选择相同长度的不同队列。计算一辆车领先另一辆车的平均时间长度。
M/M/k 队列。 比较 k 个独立的 M/M/1 队列和 M/M/k 队列。
M/G/1 队列。 分析具有不同服务分布(G = 一般)的排队模型。
中缀表达式转后缀表达式并考虑优先级顺序。 编写一个程序将中缀表达式转换为后缀表达式。从左到右扫描中缀表达式。
操作数:输出它。
左括号:推入栈中。
右括号:重复弹出栈中的元素并输出,直到遇到左括号。丢弃两个括号。
优先级高于栈顶的运算符:推入栈中。
优先级低于或等于栈顶的运算符:重复弹出栈中的元素并输出,直到栈顶的运算符具有更高的优先级。将扫描到的运算符推入栈中。之后,弹出栈中的剩余元素并输出。
检查重复。 编写一个代码片段,确定一个袋子是否包含任何重复项目。使用两个嵌套迭代器。
检查三重复。 编写一个代码片段,确定一个袋子是否包含至少三次重复的项目。使用三重嵌套迭代器。
相等。 如果两个队列按相同顺序包含相同项目,则它们相等。如果两个袋子包含相同项目但顺序不同,则它们相等。
整数集合。 创建一个表示 0 到 N-1 之间(无重复)整数集合的数据类型。支持
add(i),exists(i),remove(i),size(),intersect,difference,symmetricDifference,union,isSubset,isSuperSet和isDisjointFrom。包括一个迭代器。冒号。 有经验的程序员知道,像下面这样写一个循环通常是一个坏主意
for (double x = 0.0; x <= N; x += 0.1) { .. }由于浮点精度的结果,如果 N = xxx,则循环将执行 10N 次,如果 N = yyy,则执行 10N + 1 次。创建一个数据类型
Mesh,使得x从left到right以delta的大小增量。假设right >= left,则循环应该恰好执行1 + floor((right - left) / delta)次。for (double x : new Mesh(left, right, delta)) { .. }这是 MATLAB 中冒号运算符的工作原理。您还应该对程序进行调试,以确保即使
left > right且delta为负数也能正常工作。列表迭代器。 我们可能还想包括用于在列表中向后移动的方法
hasPrevious()和previous()。要实现previous(),我们可以使用双向链表。程序 DoublyLinkedList.java 实现了这种策略。它使用 Java 的java.util.ListIterator接口支持向前和向后移动。我们实现了所有可选方法,包括remove(),set()和add()。remove()方法删除next()或previous()返回的最后一个元素。set()方法覆盖next()或previous()返回的最后一个元素的值。add()方法在next()将返回的下一个元素之前插入一个元素。只有在调用next()或previous()之后,且没有调用remove()或add()之后,才能调用set()和remove()是合法的。我们使用一个虚拟的头节点和尾节点来避免额外的情况。我们还存储一个额外的变量
lastAccessed,它存储在最近一次调用next()或previous()时访问的节点。删除元素后,我们将lastAccessed重置为null;这表示调用remove()是非法的(直到随后调用next()或previous()为止)。双向迭代器。 定义一个支持四种方法的接口
TwoWayIterator:hasNext(),hasPrevious(),next()和previous()。实现一个支持TwoWayIterator的列表。提示:使用数组或双向链表实现列表。将一个袋子添加到另一个末尾。 编写一个方法,将一个袋子 b 的项目添加到调用方的末尾。假设两个袋子存储相同类型的项目。
提示:使用迭代器遍历 b 的项目,并将每个项目添加到调用方的末尾。
替换所有。 编写一个方法,在队列或栈中用项目
from替换所有出现的项目to。将列表添加到自身。 以下代码片段的结果是什么?
List list1 = new ArrayList(); List list2 = new ArrayList(); list1.add(list2); list2.add(list1); System.out.println(list1.equals(list2)); List list = new ArrayList(); list.add(list); System.out.println(list.hashCode());答案: 栈溢出。Java 文档中说:“虽然列表可以包含自身作为元素,但极度谨慎是明智的:在这样的列表上,equals 和 hashCode 方法不再被很好地定义。”
歌曲播放列表。 创建一个支持以下操作的数据类型:
enqueue(将新歌曲添加到列表末尾)、play(打印下一首歌曲的名称)、skip(跳过列表中的下一首歌曲,不打印其名称)和back(返回上一首歌曲)。使用支持前向和后向迭代器的列表。Josephus。 程序 Josephus.java 计算 Josephus 数。
以下代码会按升序打印出整数 0 到 9 吗?
int[] vals = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 }; for (int val : vals) { System.out.print(val + " "); StdRandom.shuffle(vals); // mutate the array while iterating } System.out.println();不会。它会打印出 10 个值,但会有一些重复项,并且不会按升序排列。迭代器不会保存原始数组的副本 - 相反,它使用已变异的副本。
使用一个访问指针实现队列。 重新实现一个队列,所有操作都需要恒定时间,但只有一个实例变量(而不是两个)。提示: 使用循环链表,保持指向最后一个项目的指针。
Steque。 栈结束队列或steque是一种支持 push、pop 和 enqueue 的数据类型。Knuth 将其称为输出受限双端队列。使用单链表实现它。
使用两个栈实现队列。 实现一个使用两个栈的队列,使得每个队列操作都需要恒定的摊销栈操作次数。提示: 如果你将元素推入栈然后全部弹出,它们会以相反顺序出现。如果你重复这个过程,它们现在又会按顺序排列。
*解决方案:*QueueWithTwoStacks.java。
使用恒定数量的栈实现队列。 实现一个使用恒定数量的栈的队列,使得每个队列操作都需要恒定(最坏情况)的栈操作次数。警告: 难度非常高。
使用队列实现栈。 实现一个使用单个队列的栈,使得每个栈操作都需要线性数量的队列操作。提示: 要删除一个项目,逐个获取队列中的所有元素,并将它们放在末尾,除了最后一个应该删除并返回。(诚然非常低效。)
使用 Deque 实现两个栈。 使用单个 Deque 实现两个栈,使得每个操作都需要恒定数量的 Deque 操作。
使用两个栈实现 Steque。(R. Tarjan) 实现一个使用两个栈的 Steque,使得每个 Steque 操作都需要恒定的摊销栈操作次数。
使用栈和 Steque 实现 Deque。(R. Tarjan) 实现一个使用栈和 Steque 的 Deque,使得每个 Deque 操作都需要恒定的摊销栈和 Steque 操作次数。
使用三个栈实现 Deque。(R. Tarjan) 实现一个使用三个栈的 Deque,使得每个 Deque 操作都需要恒定的摊销栈操作次数。
多词搜索。 程序 MultiwordSearch.java 从命令行读取查询词 q[1],...,q[k]的序列,从标准输入读取文档单词 d[1],...,d[N]的序列,并找到这些 k 个单词按相同顺序出现的最短间隔。(这里最短意味着间隔中的单词数。)即找到索引 i 和 j,使得 d[i1] = q[1],d[i2] = q[2],...,d[ik] = q[k],且 i1 < i2 < ... < ik。
答案:对于每个查询词,创建一个在文档中出现的索引的排序列表。按照 2 到 k 的顺序扫描列表,删除每个列表前面的索引,直到生成的 k 个列表的第一个元素按升序排列。
q[1]: 50 123 555 1002 1066 q[2]: 33 44 93 333 606 613 q[3]: 60 200 q[4]: 12 18 44 55 203 495 q[1]: 50 123 555 1002 1066 q[2]: 93 333 606 613 q[3]: 200 q[4]: 203 495列表 1 上的第一个元素序列形成包含列表 1 上第一个元素的最短间隔。
现在删除列表 1 上的第一个元素。重复删除列表 2 中的元素,直到它与列表 1 一致。对列表 3 重复此操作,直到整个数组按升序排列。检查这个序列的第一个元素等等。
M/M/1 队列. 马尔可夫/马尔可夫/单服务器模型 是运筹学和概率论中的基本排队模型。任务以特定速率 λ 按泊松过程到达。这意味着每小时到达 λ 个顾客。���具体地说,到达遵循均值为 1 / λ 的指数分布:在时间 0 和 t 之间到达 k 个的概率是 (λ t)k e(-λ t) / k!。任务按照率为 μ 的泊松过程按 FIFO 顺序服务。两个 M 代表马尔可夫:这意味着系统是无记忆的:到达之间的时间是独立的,离开之间的时间也是独立的。
M/M/1 模型分析。我们感兴趣的是理解排队系统。如果 λ > μ,则队列大小会无限增加。对于像 M/M/1 这样的简单模型,我们可以使用概率论来分析这些数量。假设 μ > λ,系统中恰好有 n 个顾客的概率是 (λ / μ)^n (1 - λ / μ)。
L = 系统中平均顾客数量 = λ / (μ - λ).
L[Q] = 队列中平均顾客数量 = λ² / (μ (μ - λ)).
W = 顾客在系统中的平均时间 = 1 / (μ - λ).
W[Q] = 顾客在队列中的平均时间 = W - 1 / μ.
程序 MM1Queue.java 对于更复杂的模型,我们需要使用这样的模拟。变体:多个队列,多个服务器,顺序多级服务器,使用有限队列并测量被拒绝的顾客数量。应用:麦当劳的顾客,互联网路由器中的数据包,
列出文件. Unix 目录是文件和目录的列表。程序 Directory.java 接受目录名称作为命令行参数,并按级别顺序打印出该目录中包含的所有文件(以及任何子目录)。它使用一个队列。
中断处理. 当编写可以被中断的实时系统(例如,通过鼠标点击或无线连接)时,有必要立即处理中断,然后再继续当前活动。如果中断应按照到达顺序处理,则 FIFO 队列是适当的数据结构。
库实现. Java 有一个名为
Stack的内置库,但您应该避免使用它。它具有不通常与堆栈相关联的附加操作,例如获取第 i 个元素和将元素添加到堆栈底部(而不是顶部)。尽管具有这些额外操作可能看起来是一个奖励,但实际上是一个诅咒。我们使用 ADT 不是因为它们提供了每个可用的操作,而是因为它们限制了我们可以执行的操作类型!这可以防止我们执行我们实际上不想要的操作。如果我们需要的不仅仅是 LIFO 访问,我们应该使用不同的数据类型。我们仍然可以从 Java 库构建一个堆栈数据类型,但我们要小心限制操作类型。没有 Java 队列实现。负载平衡. N 个用户必须在网络中的 N 个相同服务器中进行选择。目标:平衡用户在资源之间的分布。检查每个资源以找到一个空闲的(或最不忙的)资源太昂贵了。相反,选择一个随机服务器。在任何步骤中,您应该能够看到每台机器上的作业。程序 Server.java 绘制负载分布。理论:平均负载 = 1,最大负载 = log N / log log N。
负载平衡再加载. (Azar, Broder, Karlin, and Upfal) 选择两个随机资源。插入到两者中最不忙的资源上。理论:平均负载 = 1,最大负载 = log log N。
*网格化。*给定单位盒中的 N 个欧几里得点和参数 d,找到所有距离 d 以内的点对。将盒子分成一个 G×G 的网格,其中 G = ceil(1/d)。将所有点放入给定网格单元格中的列表。任何距离 d 以内的邻居必须在该单元格或其 8 个邻居之一中。程序 Grid.java 使用辅助数据类型 Point2D.java 实现了这种策略。
*Java 库。*Java 包含库类
LinkedList和ArrayList,实现了一个列表。比我们的Sequence数据类型具有更广泛的接口:通过索引访问元素,删除元素,搜索元素。没有 urns。为
Stack添加一个名为dup()的方法,用于创建顶部元素的副本并将其推入栈中。为
Stack添加一个名为exch()的方法,用于交换栈顶部的两个元素。为
Stack添加一个名为size()的方法,返回栈中的元素数量。为
Stack添加一个名为Item[] multiPop(int k)的方法,从栈中弹出 k 个元素并将它们作为对象数组返回。为
Queue添加一个名为Item[] toArray()的方法,将队列中的所有 N 个元素作为长度为 N 的数组返回。编写一个递归函数,该函数以队列作为输入,并重新排列队列,使其顺序相反。提示:出队第一个元素,递归反转队列,然后入队第一个元素。
给定一个队列,创建两个新队列 q1 和 q2,使得 q1 包含 q 的偶数元素,q2 包含奇数元素,例如,就像处理一副牌一样。
以下代码片段做什么?
Queue<Integer> q = new Queue<Integer>(); q.enqueue(0); q.enqueue(1); for (int i = 0; i < 10; i++) { int a = q.dequeue(); int b = q.dequeue(); q.enqueue(b); q.enqueue(a + b); System.out.println(a); }在文字处理器中实现“撤销”功能,您会选择哪种数据类型来实现?
假设您有一个大小为 N 的单个数组,并且希望实现两个栈,以便在两个栈上的元素总数为 N+1 之前不会溢出。您将如何实现这一点?
假设您在 Stack.java 的链表实现中使用以下代码实现
push。错误在哪里?public void push(Item item) { Node second = first; Node first = new Node(); first.item = item; first.next = second; }答案:通过重新声明
first,您创建了一个名为first的新局部变量,它与名为first的实例变量不同。**最小栈。**设计一个数据类型,实现以下操作,所有操作都在常数时间内完成:推送,弹出,最小值。假设项目是
Comparable的。解决方案:维护两个栈,一个包含所有项目,另一个包含最小值。要推送项目,请将其推送到第一个栈;如果它小于第二个栈的顶部项目,请将其也推送到第二个栈。要弹出项目,请从第一个栈弹出;如果它是第二个栈的顶部项目,请也从第二个栈弹出。要找到最小值,请返回第二个栈的顶部项目。
**翻倍和减半。**将 ResizingArrayStack.java 中的减半测试从
if (N > 0 && N == a.length/4) resize(a.length/2);替换为if (N == a.length/4) resize(2*N);的效果是什么?**Shunting-yard 算法。**实现 Dijkstra 的shunting-yard 算法将中缀表达式转换为后缀表达式。支持运算符优先级,包括左结合和右结合运算符。
**FIFO 队列与随机删除。**实现一个数据类型,支持插入一个项目,删除最近添加的项目和删除一个随机项目。每个操作应该在每次操作中花费常数期望摊销时间,并且应该使用空间(最多)与数据结构中的项目数量成比例。
**股票价格。**给定每日股票价格数组
prices[],创建一个数组days[],使得days[i]告诉您从第i天开始,直到股票价格超过prices[i]需要等待多少天。提示:你的算法应该以线性时间运行,并使用一个数组索引的栈。
1.4 算法分析
原文:
algs4.cs.princeton.edu/14analysis译者:飞龙
随着人们在使用计算机方面的经验增加,他们用计算机来解决困难问题或处理大量数据,不可避免地会引发这样的问题:
我的程序需要多长时间?
为什么我的程序会耗尽内存?
科学方法。
科学家用来理解自然界的方法同样适用于研究程序的运行时间:
观察自然界的某些特征,通常是通过精确的测量。
假设 一个与观察一致的模型。
使用假设预测事件。
通过进一步观察验证预测。
通过重复直到假设和观察一致来验证。
我们设计的实验必须是可重复的,我们制定的假设必须是可证伪的。
观察。
我们的第一个挑战是确定如何对程序的运行时间进行定量测量。Stopwatch.java 是一种测量程序运行时间的数据类型。
ThreeSum.java 计算一个包含 N 个整数的文件中总和为 0 的三元组的数量(忽略整数溢出)。DoublingTest.java 生成一系列随机输入数组,每一步将数组大小加倍,并打印 ThreeSum.count() 的运行时间。DoublingRatio.java 类似,但还输出从一个大小到下一个大小的运行时间比率。
数学模型。
一个程序的总运行时间由两个主要因素决定:执��每个语句的成本和每个语句的执行频率。
波浪线近似。 我们使用波浪线近似,其中我们丢弃复杂化公式的低阶项。我们写 ~ f(N) 来表示任何函数,当除以 f(N) 时,随着 N 的增长趋近于 1。我们写 g(N) ~ f(N) 来表示当 N 增长时,g(N) / f(N) 趋近于 1。
![波浪线近似]()
增长顺序分类。 我们通常使用形式为 g(N) ~ a f(N) 的波浪线近似,其中 f(N) = Nb logc N,并将 f(N) 称为 g(N) 的增长顺序。我们只使用几个结构原语(语句、条件、循环、嵌套和方法调用)来实现算法,因此成本的增长顺序往往是问题大小 N 的几个函数之一。
![增长顺序分类]()
成本模型。 我们通过阐明定义基本操作的成本模型来关注算法的属性。例如,对于 3-sum 问题,一个适当的成本模型是我们访问数组条目的次数,无论是读取还是写入。
性质。 ThreeSum.java 的运行时间增长顺序为 N³。
命题。 暴力 3-sum 算法使用*~ N³ / 2* 数组访问来计算在 N 个数字中总和为 0 的三元组的数量。
设计更快的算法。
研究程序增长顺序的一个主要原因是帮助设计更快的算法来解决相同的问题。使用归并排序和二分查找,我们为 2-sum 和 3-sum 问题开发了更快的算法。
2-sum. 暴力解决方案 TwoSum.java 需要的时间与 N² 成正比。TwoSumFast.java 在时间上与 N log N 成正比地解决了 2-sum 问题。
3-sum. ThreeSumFast.java 在时间上与 N² log N 成正比地解决了 3-sum 问题。
处理对输入的依赖。
对于许多问题,运行时间可能会根据输入而有很大的变化。
输入模型. 我们可以仔细地对要处理的输入类型进行建模。这种方法具有挑战性,因为模型可能是不现实的。
最坏情况性能保证. 程序的运行时间小于某个界限(作为输入大小的函数),无论输入是什么。这种保守的方法可能适用于运行核反应堆、心脏起搏器或汽车刹车的软件。
随机算法. 提供性能保证的一种方法是引入随机性,例如快速排序和哈希。每次运行算法时,它都会花费不同的时间。这些保证并不是绝对的,但它们无效的几率小于你的计算机被闪电击中的几率。因此,这些保证在实践中与最坏情况的保证一样有用。
摊销分析. 对于许多应用程序,算法的输入可能不仅仅是数据,还包括客户端执行的操作序列。摊销分析提供了对操作序列的最坏情况性能保证。
命题. 在Bag、Stack和Queue的链表实现中,所有操作在最坏情况下都需要常数时间。
命题. 在Bag、Stack和Queue的调整大小数组实现中,从空数据结构开始,任何长度为N的操作序列在最坏情况下需要与N成比例的时间(摊销每个操作的常数时间)。
内存使用。
要估算我们的程序使用了多少内存,我们可以计算变量的数量,并根据它们的类型按字节加权。对于典型的 64 位机器,
原始类型. 下表给出了原始类型的内存需求。
![原始类型的内存需求]()
对象. 要确定对象的内存使用量,我们将每个实例变量使用的内存量加到与每个对象相关联的开销上,通常为 16 字节。此外,内存使用量通常会填充为 8 字节的倍数(在 64 位机器上)。
![Date 的内存需求]()
参考文献. 对象的引用通常是一个内存地址,因此在 64 位机器上使用 8 字节的内存。
链表. 嵌套的非静态(内部)类,比如我们的
Node类,需要额外的 8 字节开销(用于引用封闭实例)。![Node 的内存需求]()
数组. Java 中的数组被实现为对象,通常需要额外的开销来存储长度。原始类型值的数组通常需要 24 字节的头信息(16 字节的对象开销,4 字节的长度,和 4 字节的填充),再加上存储值所需的内存。
![数组的内存需求]()
字符串. Java 7 中长度为N的字符串通常使用 32 字节(用于
String对象),再加上 24 + 2N字节(用于包含字符的数组),总共为 56 + 2N字节。![String 的内存需求]()
根据上下文,我们可能会或不会递归地计算对象的内存引用。例如,我们会计算String对象中的char[]数组的内存,因为这段内存是在创建字符串时分配的。但是,我们通常不会计算StackOfStrings对象中String对象的内存,因为这些String对象是由客户端创建的。
问与答
问. 如何增加 Java 分配的内存和堆栈空间?
A. 你可以通过使用 java -Xmx200m Hello 来增加分配给 Java 的内存量,其中 200m 表示 200 兆字节。默认设置通常为 64MB。你可以通过使用 java -Xss200k Hello 来增加分配给 Java 的堆栈空间量,其中 200k 表示 200 千字节。默认设置通常为 128KB。你可以通过使用 java -Xmx200m -Xss200k Hello 来同时增加内存和堆栈空间的量。
Q. 填充的目的是什么?
A. 填充使所有对象占用的空间是 8 字节的倍数。这可能会浪费一些内存,但可以加快内存访问和垃圾回收速度。
Q. 我在我的计算实验中得到了不一致的时间信息。有什么建议吗?
A. 确保你的计算消耗足够的 CPU 周期,以便你可以准确地测量它。通常,1 秒到 1 分钟是合理的。如果你使用了大量内存,那可能是瓶颈。考虑关闭 HotSpot 编译器,使用 java -Xint,以确保更统一的测试环境。缺点是你不再准确地测量你想要测量的内容,即实际运行时间。
Q. 如果考虑垃圾回收和其他运行时进程,链表实现的栈或队列是否真的保证每次操作的常数时间?
A. 我们的分析没有考虑许多系统效应(如缓存、垃圾回收和即时编译)-在实践中,这些效应很重要。特别是,默认的 Java 垃圾收集器仅保证每次操作的摊销常数时间。然而,有实时垃圾收集器保证最坏情况下每次操作的常数时间。实时 Java提供了 Java 的扩展,为各种运行时进程(如垃圾回收、类加载、即时编译和线程调度)提供最坏情况下的性能保证。
练习
给出以下代码片段的运行时间的增长顺序(作为 N 的函数):
-
int sum = 0; for (int n = N; n > 0; n /= 2) for (int i = 0; i < n; i++) sum++; -
int sum = 0; for (int i = 1; i < N; i *= 2) for(int j = 0; j < i; j++) sum++; -
int sum = 0; for (int i = 1; i < N; i *= 2) for (int j = 0; j < N; j++) sum++;答案:线性(N + N/2 + N/4 + ...);线性(1 + 2 + 4 + 8 + ...);线性对数级(外部循环循环 lg N 次)。
-
创意问题
4-求和。 对 FourSum.java 问题开发一个蛮力解决方案。
数组中的局部最小值。 编写一个程序,给定一个由 n 个不同整数组成的数组
a[],找到一个局部最小值:一个索引i,使得a[i] < a[i-1]和a[i] < a[i+1](假设相邻条目在范围内)。在最坏情况下,你的程序应该使用 ~ 2 lg n 次比较。答案:检查中间值
a[n/2]及其两个邻居a[n/2 - 1]和a[n/2 + 1]。如果a[n/2]是局部最小值,则停止;否则在较小邻居的一半中搜索。矩阵中的局部最小值。 给定一个由 n² 个不同整数组成的 n×n 数组
a[],设计一个算法,其运行时间与 n log n 成正比,以找到一个局部最小值:一对索引i和j,使得a[i][j] < a[i+1][j],a[i][j] < a[i][j+1],a[i][j] < a[i-1][j],以及a[i][j] < a[i][j-1](假设相邻条目在范围内)。提示:找到第
n/2行中的最小条目,称为a[n/2][j]。如果它是局部最小值,则返回它。否则,检查它的两个垂直邻居a[n/2-1][j]和a[n/2+1][j]。在较小邻居的一半中进行递归。额外奖励:设计一个算法,其运行时间与 n 成正比。
双峰搜索。 如果一个数组由一个递增的整数序列紧接着一个递减的整数序列组成,则该数组是双峰的。编写一个程序,给定一个由 n 个不同
int值组成的双峰数组,确定给定的整数是否在数组中。在最坏情况下,你的程序应该使用 ~ 3 log n 次比较。答案: 使用二分查找的一个版本,如 BitonicMax.java 中所示,找到最大值(在~ 1 lg n次比较中);然后使用二分查找在每个片段中搜索(每个片段在~ 1 lg n次比较中)。
只使用加法和减法的二分查找。 [Mihai Patrascu] 编写一个程序,给定一个按升序排列的包含n个不同整数的数组,确定给定的整数是否在数组中。你只能使用加法和减法以及恒定数量的额外内存。你的程序在最坏情况下的运行时间应与 log n成比例。
答案: 不要基于二的幂(二分查找)进行搜索,而是使用斐波那契数(也呈指数增长)。保持当前搜索范围为[i, i + F(k)],并将 F(k)、F(k-1)保存在两个变量中。在每一步中,通过减法计算 F(k-2),检查元素 i + F(k-2),并将范围更新为[i, i + F(k-2)]或[i + F(k-2), i + F(k-2) + F(k-1)]。
带有重复项的二分查找。 修改二分查找,使其始终返回与搜索键匹配的项的键的最小(最大)索引。
从建筑物上扔鸡蛋。 假设你有一座N层的建筑物和大量的鸡蛋。假设如果鸡蛋从第F层或更高处扔下,就会摔碎,否则不会。首先,设计一种策略来确定F的值,使得在使用*~ lg N次扔鸡蛋时破碎的鸡蛋数量为~ lg N*,然后找到一种方法将成本降低到*~ 2 lg F*,当N远大于F时。
提示: 二分查找;重复加倍和二分查找。
从建筑物上扔两个鸡蛋。 考虑前面的问题,但现在假设你只有两个鸡蛋,你的成本模型是扔鸡蛋的次数。设计一种策略,确定F,使得扔鸡蛋的次数最多为 2 sqrt(√ N),然后找到一种方法将成本降低到*~c √ F*,其中 c 是一个常数。
第一部分的解决方案: 为了达到 2 * sqrt(N),在 sqrt(N)、2 * sqrt(N)、3 * sqrt(N)、...、sqrt(N) * sqrt(N)层放置鸡蛋。(为简单起见,我们假设 sqrt(N)是一个整数。)假设鸡蛋在第 k * sqrt(N)层摔碎。用第二个鸡蛋,你应该在区间(k-1) * sqrt(N)到 k * sqrt(N)中进行线性搜索。总共,你最多需要进行 2 * sqrt(N)次试验就能找到楼层 F。
第二部分的提示: 1 + 2 + 3 + ... k ~ 1/2 k²。
热还是冷。 你的目标是猜测一个介于 1 和N之间的秘密整数。你反复猜测介于 1 和N之间的整数。每次猜测后,你会得知它是否等于秘密整数(游戏停止);否则(从第二次猜测开始),你会得知猜测是更热(更接近)还是更冷(距离更远)比你之前的猜测。设计一个算法,在*~ 2 lg N次猜测中找到秘密数字。然后,设计一个算法,在~ 1 lg N*次猜测中找到秘密数字。
提示: 第一部分使用二分查找。对于第二部分,首先设计一个算法,在*~1 lg N次猜测中解决问题,假设你被允许在范围-N到 2N*中猜测整数。
网页练习
设 f 是一个单调递增的函数,满足 f(0) < 0 且 f(N) > 0。找到最小的整数 i,使得 f(i) > 0。设计一个算法,使得对 f()的调用次数为 O(log N)。
上下取整。 给定一组可比较的元素,x 的上取整是集合中大于或等于 x 的最小元素,下取整是小于或等于 x 的最大元素。假设你有一个按升序排列的包含 N 个项的数组。给出一个 O(log N)的算法,找到 x 的上取整和下取整。
使用 lg N 两路比较进行排名。 实现
rank(),使其使用~ 1 lg N 两路比较(而不是~ 1 lg N 三路比较)。身份。 给定一个按升序排列的包含 N 个不同整数(正数或负数)的数组
a。设计一个算法来找到一个索引i,使得a[i] = i,如果这样的索引存在的话。提示:二分查找。多数派。 给定一个包含 N 个字符串的数组。如果一个元素出现次数超过 N/2 次,则称其为多数派。设计一个算法来识别多数派是否存在。你的算法应在线性对数时间内运行。
多数派。 重复上一个练习,但这次你的算法应在线性时间内运���,并且只使用恒定数量的额外空间。此外,你只能比较元素是否相等,而不能比较字典顺序。
答案:如果 a 和 b 是两个元素且 a != b,则移除它们两个;多数派仍然存在。使用 N-1 次比较找到多数派的候选者;使用 N-1 次比较检查候选者是否真的是多数派。
第二小元素。 给出一个算法,使用最少的比较次数从 N 个项目的列表中找到最小和第二小的元素。答案:通过构建一个锦标赛树,在 ceil(N + lg(N) - 2) 次比较中完成。每个父节点都是其两个子节点中的最小值。最小值最终在根节点处;第二小值在根节点到最小值的路径上。
查找重复项。 给定一个包含 N 个元素的数组,其中每个元素是介于 1 和 N 之间的整数,请编写一个算法来确定是否存在任何重复项。你的算法应在线性时间内运行,并使用 O(1) 额外空间。提示:你可以破坏数组。
查找重复项。 给定一个包含 N+1 个元素的数组,其中每个元素是介于 1 和 N 之间的整数,请编写一个算法来查找重复项。你的算法应在线性时间内运行,使用 O(1) 额外空间,并且不得修改原始数组。提示:指针加倍。
查找共同元素。 给定两个包含 N 个 64 位整数的数组,设计一个算法来打印出两个列表中都出现的所有元素。输出应按排序顺序排列。你的算法应在 N log N 时间内运行。提示:归并排序,归并排序,合并。备注:在基于比较的模型中,不可能比 N log N 更好。
查找共同元素。 重复上述练习,但假设第一个数组有 M 个整数,第二个数组有 N 个整数,其中 M 远小于 N。给出一个在 N log M 时间内运行的算法。提示:排序和二分查找。
变位词。 设计一个 O(N log N) 算法来读取一个单词列表,并打印出所有的变位词。例如,字符串 "comedian" 和 "demoniac" 是彼此的变位词。假设有 N 个单词,每个单词最多包含 20 个字母。设计一个 O(N²) 的算法应该不难,但将其降至 O(N log N) 需要一些巧妙的方法。
在排序、旋转数组中搜索。 给定一个包含 n 个不同整数的排序数组,该数组已经旋转了未知数量的位置,例如,15 36 1 7 12 13 14,请编写一个程序 RotatedSortedArray.java 来确定给定的整数是否在列表中。你的算法的运行时间增长应为对数级别。
找到数组中的跳跃。 给定一个由 n 个整数组成的数组,形式为 1, 2, 3, ..., k-1, k+j, k+j+1, ..., n+j,其中 1 ⇐ k ⇐ n 且 j > 0,请设计一个对数时间算法来找到整数 k。也就是说,数组包含整数 1 到 n,只是在某个点上,所有剩余值都增加了 j。
找到缺失的整数。 一个数组
a[]包含从 0 到 N 的所有整数,除了 1。但是,你不能通过单个操作访问一个元素。相反,你可以调用get(i, k),它返回a[i]的第 k 位,或者你可以调用swap(i, j),它交换a[]的第 i 和第 j 个元素。设计一个 O(N) 算法来找到缺失的整数。为简单起见,假设 N 是 2 的幂。最长的 0 行。 给定一个由 0 和 1 组成的 N×N 矩阵,使得在每行中 0 不会出现在 1 之前,找到具有最多 0 的行,并在 O(N) 时间内完成。
单调二维数组。 给定一个 n×n 的元素数组,使得每行按升序排列,每列也按升序排列,设计一个 O(n)的算法来确定数组中是否存在给定元素 x。你可以假设 n×n 数组中的所有元素都是不同的。
你站在一条路中间,但有一场尘暴遮挡了你的视线和方向。只有一个方向有庇护所,但直到你站在它面前才能看到任何东西。设计一个能够找到庇护所的算法,保证能找到。你的目标是尽量减少步行的距离。提示:某种来回走动的策略。
通过尽可能大的常数因子改进以下代码片段,以适应大规模 n。通过性能分析确定瓶颈在哪里。假设
b[]是一个长度为n的整数数组。double[] a = new double[n]; for (int i = 0; i < n; i++) for (int j = 0; j < n; j++) a[j] += Math.exp(-0.5 * (Math.pow(b[i] - b[j], 2));原地置换。 编写一个程序
Permutation.java,其中包含接受数组和置换(或逆置换)的函数,并根据置换(或逆置换)重新排列数组中的元素。原地操作:只使用恒定量的额外内存。三数之和。 给定三个集合 A、B 和 C,每个集合最多包含 N 个整数,确定是否存在三元组 a 在 A 中,b 在 B 中,c 在 C 中,使得 a + b + c = 0。
答案:按升序对 B 进行排序;按降序对 C 进行排序;对于 A 中的每��a,扫描 B 和 C,找到一个对,使得它们的和为-a(当和太小时,在 B 中前进,当和太大时,在 C 中前进)。
两数之和。 给定两个集合 A 和 B,每个集合最多包含 N 个整数,确定 A 中任意两个不同整数的和是否等于 B 中的一个整数。
连续和。 给定一组实数和目标值 V,找到一个连续块(任意长度),其和尽可能接近 V。
暴力法:通过暴力法计算每个连续块的总和。这需要 O(N³)的时间。
部分和:计算所有部分和 s[i] = a[0] + a[1] + ... + a[i],以便连续块的和形式为 s[j] - s[i]。这需要 O(N²)的时间。
排序和二分查找:按上述方式形成部分和,然后按升序对它们进行排序。对于每个 i,二分查找尽可能接近 s[i]的 s[j]。这需要 O(N log N)的时间。
三变量的线性方程。 对于三个变量的固定线性方程(例如整数系数),给定 N 个数字,其中任意三个是否满足方程?为该问题设计一个二次算法。提示:参见三数之和的二次算法。
卷积三数之和。 给定 N 个实数,确定是否存在索引 i 和 j,使得 a[i] + a[j] = a[i+j]。为该问题设计一个二次算法。提示:参见三数之和的二次算法。
找到主要项。 给定一个从标准输入中任意长的项序列,其中一个项出现的次数严格占多数,识别主要项。只使用恒定量的内存。
解决方案。 维护一个整数计数器和一个变量来存储当前的冠军项。读取下一个项,如果该项等于冠军项,则将计数器加一。(ii) 否则将计数器减一,如果计数器达到 0,则用当前项替换冠军值。终止时,冠军值将是主要项。
数组的记忆。 MemoryOfArrays.java。依赖于 LinearRegression.java。
字符串和子字符串的记忆。 MemoryOfStrings.java。依赖于 LinearRegression.java 和 PolynomialRegression.java。取决于你使用的是 Java 6 还是 Java 7。
栈和队列的记忆。 作为 N 个项的栈的内存使用量是 N 的函数吗?
解决方案。 32 + 40N(不包括引用对象的内存)。MemoryOfStacks.java。
欧几里得算法的分析。 证明欧几里得算法的时间复杂度最多与N成正比,其中N是较大输入中的位数。
答案:首先我们假设 p > q。如果不是,则第一个递归调用实际上会交换 p 和 q。现在,我们要证明在至多 2 次递归调用后,p 会减少一半。为了证明这一点,有两种情况需要考虑。如果 q ≤ p / 2,则下一个递归调用将有 p' = q ≤ p / 2,因此在仅一次递归调用后,p 至少减少了一半。否则,如果 p / 2 < q < p,则 q' = p % q = p - q < p / 2,因此 p'' = q' < p / 2,经过两次迭代后,p 将减少一半或更多。因此,如果 p 有 N 位,则在至多 2N 次递归调用后,欧几里得算法将达到基本情况。因此,总步数与 N 成正比。
查找重复项。 给定一个包含 0 到 N 之间的 N+2 个整数的排序数组,其中恰好有一个重复项,设计一个对数时间复杂度的算法来找到重复项。
提示 二分查找。
给定一个包含 n 个实数的数组
a[],设计一个线性时间算法来找到a[j] - a[i]的最大值,其中j≥i。解决方案:
double best = 0.0; double min = a[0]; for (int i = 0; i < n; i++) { min = Math.min(a[i], min); best = Math.max(a[i] - min, best); }给定一个包含 n 个实数的数组
a[],设计一个线性时间算法来找到|a[j] - a[i]| + |j - i|的最大值。提示:创建两个长度为 n 的数组 b[]和 c[],其中 b[i] = a[i] - i,c[i] = a[i] + i。
1.5 案例研究:并查集
原文:
algs4.cs.princeton.edu/15uf译者:飞龙

动态连通性。
输入是一系列整数对,其中每个整数表示某种类型的对象,我们将解释对p q为p连接到q。我们假设“连接到”是一个等价关系:
对称性:如果
p连接到q,那么q连接到p。传递性:如果
p连接到q且q连接到r,那么p连接到r。自反性:
p连接到p。
等价关系将对象划分为等价类或连通分量。
我们的目标是编写一个程序来过滤序列中的多余对:当程序从输入中读取一对p q时,只有当它到目前为止看到的对不意味着p连接到q时,它才将这对写入输出。如果之前的对确实意味着p连接到q,那么程序应忽略这对p q并继续读取下一对。
并查集 API。
以下 API 封装了我们需要的基本操作。
为了测试 API 的实用性,UF.java 中的main()解决了动态连通性问题。我们还准备了测试数据:文件 tinyUF.txt 包含我们小例子中使用的 11 个连接,文件 mediumUF.txt 包含 900 个连接,文件 largeUF.txt 是一个包含数百万连接的示例。
实现。
我们现在考虑几种不同的实现方式,都基于使用一个站点索引数组id[]来确定两个站点是否在同一个组件中。
快速查找. QuickFindUF.java 维护了这样一个不变量:当且仅当
id[p]等于id[q]时,p和q连接。换句话说,组件中的所有站点在id[]中必须具有相同的值。![快速查找概述]()
快速联合. QuickUnionUF.java 基于相同的数据结构——站点索引
id[]数组,但它使用了不同的值解释,导致更复杂的结构。具体来说,每个站点的id[]条目将是同一组件中另一个站点的名称(可能是它自己)。为了实现find(),我们从给定站点开始,沿着它的链接到另一个站点,再沿着那个站点的链接到另一个站点,依此类推,一直沿着链接直到到达一个根节点,一个有链接指向自身的站点。只有当这个过程将它们导向相同的根节点时,两个站点才在同一个组件中。为了验证这个过程,我们需要union()来维护这个不变量,这很容易安排:我们沿着链接找到与每个给定站点相关联的根节点,然后通过将其中一个根节点链接到另一个根节点来重命名一个组件。![快速联合概述]()
加权快速联合. 在快速联合算法中,为了
union()将第二棵树任意连接到第一棵树,我们跟踪每棵树的大小,并始终将较小的树连接到较大的树。程序 WeightedQuickUnionUF.java 实现了这种方法。![加权快速联合概述]()
带路径压缩的加权快速联合. 有许多简单的方法可以进一步改进加权快速联合算法。理想情况下,我们希望每个节点直接链接到其树的根节点,但我们不想付出改变大量链接的代价。我们可以通过使我们直接检查的所有节点直接链接到根节点来接近理想状态。
并查集成本模型。
在研究并查集算法时,我们计算数组访问次数(访问数组条目的次数,用于读取或写入)。
定义。
树的大小是其节点数。树中节点的深度是从节点到根的路径上的链接数。树的高度是其节点中的最大深度。
命题。
快速查找算法对每次find()调用使用一个数组访问,并且对于每次将两个组件合并的union()调用,数组访问次数在n + 3 和 2n + 1 之间。
命题。
在快速联合中,find()所使用的数组访问次数为 1 加上节点深度的两倍,该节点对应给定站点。union()和connected()所使用的数组访问次数为两个find()操作的成本(如果给定站点在不同树中,则union()还需加 1)。
命题。
由加权快速联合构建的森林中任何节点的深度最多为 lg n。
推论。
对于具有n个站点的加权快速联合,find()、connected()和union()的最坏情况成本增长顺序为 log n。
问与答
Q. 是否有一种有效的数据结构,支持边的插入和删除?
A. 是的。然而,用于图连接性的已知最佳完全动态数据结构比我们考虑的增量版本复杂得多。此外,它的效率也不如增量版本。参见 Mikkel Thorup 的Near-optimal fully-dynamic graph connectivity。
练习
开发类 QuickUnionUF.java 和 QuickFindUF.java,分别实现快速联合和快速查找。
给出一个反例,说明快速查找的
union()的这种直观实现是不正确的:public void union(int p, int q) { if (connected(p, q)) return; for (int i = 0; i < id.length; i++) if (id[i] == id[p]) id[i] = id[q]; count--; }答案. 在 for 循环中,
id[p]的值会改变为id[q]。因此,任何r>p且id[r]等于id[p]的对象都不会被更新为等于id[q]。在加权快速联合实现中,假设我们将
id[root(p)]设置为q而不是id[root(q)]。得到的算法是否正确?答案. 是的。然而,这会增加树的高度,因此性能保证将无效。
创意问题
带路径压缩的快速联合。 修改 QuickUnionUF.java 以包括路径压缩,通过在
find()中添加一个循环,将从 p 到根的路径上的每个站点连接起来。给出一系列输入对,使得该方法产生长度为 4 的路径。注意:该算法的摊销成本每次操作已知为对数级别。解决方案. QuickUnionPathCompressionUF.java。
带路径压缩的加权快速联合。 修改 WeightedQuickUnionUF.java 以实现路径压缩,如练习 1.5.12 所述。给出一系列输入对,使得该方法产生高度为 4 的树。
注意:该算法的摊销成本每次操作已知受到称为反阿克曼函数的函数的限制,对于实践中出现的任何可想象的n值,该函数均小于 5。
解决方案. WeightedQuickUnionPathCompressionUF.java。
按高度加权快速联合。 开发一个实现 WeightedQuickUnionByHeightUF.java 的算法,该算法使用与加权快速联合相同的基本策略,但跟踪树高度并始终将较短的树链接到较高的树。证明对于n个站点,您的算法对树的高度有对数上界。
解决方案. 不同树中元素之间的联合操作要么保持高度不变(如果两棵树的高度不同),要么增加一次高度(如果两棵树的高度相同)。你可以通过归纳证明树的大小至少为 2^高度。因此,高度最多可以增加 lg n次。
随机连接。开发一个
UF客户端 ErdosRenyi.java,接受一个整数命令行参数n���在 0 到n之间生成随机整数对,调用connected()确定它们是否连接,如果没有连接则调用union()(与我们的开发客户端相同),循环直到所有站点连接,并打印生成的连接数。将程序打包为一个以n为参数的静态方法count(),返回连接数和一个从命令行获取n的main(),调用count(),并打印返回的值。
网页练习
真或假。在快速联合实现中,假设我们将
parent[p]设置为parent[root(q)]而不是将parent[root(p)]设置为parent[root(q)],得到的算法是否正确?答案。不。
在执行带路径压缩的加权快速联合时,以下哪个数组不可能出现:
0 1 2 3 4 5 6 7 8 9
7 3 8 3 4 5 6 8 8 1
6 3 8 0 4 5 6 9 8 1
0 0 0 0 0 0 0 0 0 0
9 6 2 6 1 4 5 8 8 9
9 8 7 6 5 4 3 2 1 0
解决方案。B、C、E 和 F。
递归路径压缩。使用递归实现路径压缩。
解决方案:
public int find(int p) { if (p != parent[p]) parent[p] = find(parent[p]); return parent[p];路径减半。编写一个数据类型 QuickUnionPathHalvingUF.java,实现一种更简单的策略,称为路径减半,使得查找路径上的每个其他节点都链接到其祖父节点。备注:该算法每次操作的摊销成本被限制在一个称为反阿克曼函数的函数中。
路径分裂。编写一个数据类型 WeightedQuickUnionPathSplittingUF.java,实现一种称为路径分裂的替代策略,使得查找路径上的每个节点都链接到其祖父节点。备注:该算法每次操作的摊销成本被限制在一个称为反阿克曼函数的函数中。
随机快速联合。实现以下版本的快速联合:将整数 0 到 n-1 均匀随机分配给 n 个元素。在链接两个根时,始终将具有较小标签的根链接到具有较大标签的根。添加路径压缩。备注:没有路径压缩版本的每次操作的期望成本是对数级的;具有路径压缩版本的每次操作的期望摊销成本被限制在一个称为反阿克曼函数的函数中。
3D 位置渗透。对 3D 晶格重复。阈值约为 0.3117。
键合渗透。与位置渗透相同,但是随机选择边而不是位置。真实阈值恰好为 0.5。
给定一组 N 个元素,创建一个 N 个联合操作的序列,使得带权重的快速联合的高度为 Theta(log N)。对带路径压缩的带权重快速联合重复。
六角形。六角形游戏在一个梯形六边形网格上进行...描述如何检测白色或黑色何时赢得游戏。使用并查集数据结构。
六角形。证明游戏不可能以平局结束。提示:考虑从棋盘左侧可达的单元格集合。
六角形。证明第一个玩家可以通过完美的游戏获胜。提示:如果第二个玩家有一个获胜策略,你可以最初选择一个随机单元格,然后只需复制第二个玩家的获胜策略。这被称为策略窃取。
在网格上标记聚类。 物理学家将其称为Hoshen–Kopelman 算法,尽管它只是在栅格图上进行的并查集算法,按照栅格扫描顺序进行。 应用包括模拟渗透和电导。 绘制站点占用概率与聚类数量的关系(比如 100x100,p 在 0 到 1 之间,聚类数量在 0 到 1500 之间)或聚类分布。(似乎 DFS 在这里就足够了)Matlab 在图像处理工具箱中有一个名为
bwlabel的函数,用于执行聚类标记。
2. 排序
原文:
algs4.cs.princeton.edu/20sorting译者:飞龙
概述。
排序是重新排列一系列对象的过程,使它们按照某种逻辑顺序排列。排序在商业数据处理和现代科学计算中起着重要作用。在交易处理、组合优化、天体物理学、分子动力学、语言学、基因组学、天气预测等领域都有广泛的应用。
在本章中,我们考虑了几种经典的排序方法以及一个称为优先队列的基本数据类型的高效实现。我们讨论了比较排序算法的理论基础,并以对排序和优先队列算法的应用进行调查来结束本章。
2.1 Elementary Sorts介绍了选择排序、插入排序和希尔排序。
2.2 Mergesort描述了归并排序,这是一种能够保证在线性对数时间内运行的排序算法。
2.3 Quicksort描述了快速排序,它比其他任何排序算法都被广泛使用。
2.4 Priority Queues介绍了优先队列数据类型以及使用二叉堆的高效实现。它还介绍了堆排序。
2.5 Applications描述了排序的应用,包括使用替代排序、选择、系统排序和稳定性。
本章的 Java 程序。
以下是本章中的 Java 程序列表。点击程序名称以访问 Java 代码;点击参考号以获取简要描述;阅读教材以获取全面讨论。
REF PROGRAM DESCRIPTION / JAVADOC 2.1 Insertion.java 插入排序 - InsertionX.java 插入排序(优化版) - BinaryInsertion.java 二分插入排序 2.2 Selection.java 选择排序 2.3 Shell.java 希尔排序 2.4 Merge.java 自顶向下的归并排序 - MergeBU.java 自底向上的归并排序 - MergeX.java 优化的归并排序 - Inversions.java 逆序对数量 2.5 Quick.java 快速排序 - Quick3way.java 三向切分的快速排序 - QuickX.java 优化的双向快速排序 - QuickBentleyMcIlroy.java 优化的三向快速排序 - TopM.java 优先队列客户端 2.6 MaxPQ.java 最大堆优先队列 - MinPQ.java 最小堆优先队列 - IndexMinPQ.java 索引最小堆优先队列 - IndexMaxPQ.java 索引最大堆优先队列 - Multiway.java 多路归并 2.7 Heap.java 堆排序
排序演示。
以下是一些有趣的排序演示。
排序算法动画,作者 David Martin。
排序算法的声音和可视化,作者 Timo Bingmann。
Carlo Zapponi 的排序可视化,使用逆序计数作为进度的衡量标准。
2.1 基本排序
原文:
algs4.cs.princeton.edu/21elementary译者:飞龙
在本节中,我们将学习两种基本的排序方法(选择排序和插入排序)以及其中一种的变体(希尔排序)。
游戏规则。
我们的主要关注点是重新排列包含关键字的项目数组的算法,目标是重新排列项目,使它们的关键字按升序排列。在 Java 中,关键字的抽象概念在内置机制中���现为Comparable接口。除了少数例外,我们的排序代码只通过两个操作引用数据:比较对象的方法less()和交换它们的方法exch()。
private static boolean less(Comparable v, Comparable w) {
return (v.compareTo(w) < 0);
}
private static void exch(Comparable[] a, int i, int j) {
Comparable swap = a[i];
a[i] = a[j];
a[j] = swap;
}
排序成本模型。在研究排序算法时,我们计算比较和交换。对于不使用交换的算法,我们计算数组访问。
额外内存。我们考虑的排序算法分为两种基本类型:一种是原地排序(除了可能需要一小段函数调用堆栈或常数数量的实例变量外,不需要额外内存),另一种是需要足够额外内存来保存另一个要排序的数组的副本。
数据类型。我们的排序代码适用于实现 Java 的Comparable 接口的任何数据类型。这意味着存在一个
compareTo()方法,其中v.compareTo(w)在 v < w 时返回负整数,在 v = w 时返回零,在 v > w 时返回正整数。该方法必须实现全序:*自反性:*对于所有的 v,v = v。
*反对称性:*对于所有的 v 和 w,如果(v < w),那么(w > v);如果(v = w),那么(w = v)。
*传递性:*对于所有的 v、w 和 x,如果(v ≤ w)且(w ≤ x),那么 v ≤ x。
此外,如果
v和w是不兼容类型或其中任何一个为null,v.compareTo(w)必须抛出异常。Date.java 演示了如何为用户定义的类型实现
Comparable接口。
选择排序。
最简单的排序算法之一的工作方式如下:首先,在数组中找到最小的项,并将其与第一个条目交换。然后,找到下一个最小的项并将其与第二个条目交换。继续这样做,直到整个数组排序完成。这种方法被称为选择排序,因为它通过重复选择剩余的最小项来工作。Selection.java 是这种方法的实现。
命题。
选择排序使用~n²/2 次比较和 n 次交换来对长度为 n 的数组进行排序。
插入排序。
人们经常用来排序桥牌的算法是逐个考虑卡片,将每张卡片插入到已考虑的卡片中的适当位置(保持它们排序)。在计算机实现中,我们需要为当前项目腾出空间,通过将较大的项目向右移动一个位置,然后将当前项目插入到空出的位置。Insertion.java 是这种方法的实现,称为插入排序。
命题。
对于具有不同键的长度为 N 的随机排序数组,插入排序平均使用N²/4 次比较和N²/4 次交换。最坏情况下,使用N²/2 次比较和N²/2 次交换,最佳情况下是 N-1 次比较和 0 次交换。
插入排序对于某些在实践中经常出现的非随机数组非常有效,即使它们很大。逆序对是数组中顺序不正确的一对关键字。例如,E X A M P L E 有 11 个逆序对:E-A、X-A、X-M、X-P、X-L、X-E、M-L、M-E、P-L、P-E 和 L-E。如果数组中的逆序对数量小于数组大小的常数倍,则称该数组是部分排序的。
命题。
插入排序使用的交换次数等于数组中的逆序数,比较次数至少等于逆序数,最多等于逆序数加上数组大小。
属性。
对于具有不同值的随机排序数组,插入排序和选择排序的运行时间是二次的,并且彼此之间相差一个小的常数因子。
SortCompare.java 使用命令行参数中命名的类中的sort()方法执行给定数量的实验(对给定大小的数组进行排序),并打印算法观察运行时间的比率。
可视化排序算法。
我们使用简单的可视化表示来描述排序算法的属性。我们使用垂直条形图,按其高度排序。SelectionBars.java 和 InsertionBars.java 生成这些可视化效果。
希尔排序。
希尔排序是插入排序的简单扩展,通过允许远离的条目进行交换,以产生部分排序的数组,最终可以通过插入排序高效地排序。其思想是重新排列数组,使其具有这样的属性:取每个第 h 个条目(从任何位置开始)会产生一个排序序列。这样的数组称为h-排序。
通过对一些大的 h 值进行 h-排序,我们可以将数组中的条目移动到较远的距离,从而使得对较小的 h 值进行 h-排序更容易。对于以 1 结尾的任何增量序列的值使用这种过程将产生一个排序的数组:这就是希尔排序。Shell.java 是这种方法的实现。
ShellBars.java 生成希尔排序的可视化效果。

属性。
使用增量为 1、4、13、40、121、364 的希尔排序所使用的比较次数受到 N 的倍数限制,与使用的增量数量成正比。
命题。
使用增量为 1、4、13、40、121、364 的希尔排序所使用的比较次数为 O(N^(3/2))。
问与答
Q. 当我编译 Insertion.java 时,编译器会发出警告。有没有办法避免这种情况?
Insertion.java:73: warning: [unchecked] unchecked call to compareTo(T)
as a member of the raw type java.lang.Comparable
return (v.compareTo(w) < 0);
A. 是的,如果使用静态泛型,就像 InsertionPedantic.java 一样。这会导致笨拙(但无警告)的代码。
练习
以选择排序示例跟踪的方式展示选择排序如何对数组进行排序。
E A S Y Q U E S T I O N解决方案。
![选择排序]()
在选择排序中涉及任何特定项目的最大交换次数是多少?涉及特定项目 x 的平均交换次数是多少?
解决方案。 平均交换次数恰好为 2,因为总共有 n 次交换和 n 个项目(每次交换涉及两个项目)。最大交换次数为 n,如下例所示。
![选择排序]()
以插入排序示例跟踪的方式展示插入排序如何对数组进行排序。
E A S Y Q U E S T I O N解决方案。
![选择排序]()
对于所有键相同的数组,选择排序和插入排序哪个运行速度更快?
解决方案。 当所有键相等时,插入排序运行时间为线性时间。
假设我们在一个随机排序的数组上使用插入排序,其中项目只有三个键值之一。运行时间是线性的、二次的还是介于两者之间的?
解决方案。 二次的。
以希尔排序示例跟踪的方式展示希尔排序如何对数组进行排序。
E A S Y S H E L L S O R T Q U E S T I O N解决方案。
![希尔排序跟踪]()
为什么在希尔排序的h排序中不使用选择排序?
解决方案。 插入排序在部分排序的输入上更快。
创意问题
昂贵的交换。 一家运输公司的职员负责按照要运出的时间顺序重新排列一些大箱子。因此,相对于交换的成本(移动箱子),比较的成本非常低(只需查看标签)。仓库几乎满了:有足够的额外空间来容纳任何一个箱子,但不能容纳两个。职员应该使用哪种排序方法?
解决方案。 使用选择排序,因为它最小化了交换的次数。
可视化跟踪。 修改你对上一个练习的解决方案,使 Insertion.java 和 Selection.java 产生类似本节中所示的可视化跟踪。
解决方案。 TraceInsertion.java、TraceSelection.java 和 TraceShell.java。
可比较的交易。 扩展你的 Transaction.java 实现,使其实现
Comparable,使得交易按金额顺序排列。交易排序测试客户端。 编写一个类 SortTransactions.java,其中包含一个静态方法
main(),从标准输入读取一系列交易,对其进行排序,并在标准输出上打印结果。
实验
带哨兵的插入排序。 开发一个插入排序的实现 InsertionX.java,通过首先将最小的项目放入位置来消除内部循环中的 j > 0 测试。使用 SortCompare.java 来评估这样做的有效性。注意:通常可以通过这种方式避免索引越界测试——使测试能够被消除的项目称为哨兵。
无交换的插入排序。 开发一个插入排序的实现 InsertionX.java,将较大的项目向右移动一个位置,而不是进行完整的交换。使用 SortCompare.java 来评估这样做的有效性。
网络练习
排序网络。 编写一个程序 Sort3.java,其中有三个
if语句(没有循环),从命令行读取三个整数a、b和c,并按升序打印它们。if (a > b) swap a and b if (a > c) swap a and c if (b > c) swap b and c无视排序网络。 说服自己,以下代码片段重新排列存储在变量 A、B、C 和 D 中的整数,使得 A ⇐ B ⇐ C ⇐ D。
if (A > B) { t = A; A = B; B = t; } if (B > C) { t = B; B = C; C = t; } if (A > B) { t = A; A = B; B = t; } if (C > D) { t = C; C = D; D = t; } if (B > C) { t = B; B = C; C = t; } if (A > B) { t = A; A = B; B = t; } if (D > E) { t = D; D = E; E = t; } if (C > D) { t = C; C = D; D = t; } if (B > C) { t = B; B = C; C = t; } if (A > B) { t = A; A = B; B = t; }设计一系列语句,可以对 5 个整数���行排序。你的程序使用了多少个
if语句?最佳的无视排序网络。 创建一个程序,使用仅 5 个
if语句对四个整数进行排序,以及使用仅 9 个上述类型的if语句对五个整数进行排序?无视排序网络对于在硬件中实现排序算法很有用。如何检查你的程序对所有输入都有效?答案: Sort4.java 使用 5 个比较交换对 4 个项目进行排序。Sort5.java 使用 9 个比较交换对 5 个项目进行排序。
0-1 原则说,你可以通过检查一个(确定性的)排序网络是否正确地对由 0 和 1 组成的输入进行排序来验证其正确性。因此,要检查
Sort5.java是否有效,你只需要在 32 个可能的由 0 和 1 组成的输入上测试它。最佳的无视排序(具有挑战性)。 找到一个针对 6、7 和 8 个输入的最佳排序网络,分别使用 12、16 和 19 个上一个问题中形式的
if语句。答案:Sort6.java 是对 6 个项目进行排序的解决方案。
最佳非盲目排序。 编写一个程序,仅使用 7 次比较对 5 个输入进行排序。提示:首先比较前两个数字,然后比较后两个数字,以及两组中较大的数字,并标记它们,使得 a < b < d 和 c < d。其次,将剩余的项目 e 插入到链 a < b < d 中的适当位置,首先与 b 进行比较,然后根据结果与 a 或 d 进行比较。第三,以与插入 e 相同的方式将 c 插入到涉及 a、b、d 和 e 的链中的适当位置(知道 c < d)。这使用了 3(第一步)+ 2(第二步)+ 2(第三步)= 7 次比较。这种方法最初是由 H.B. Demuth 在 1956 年发现的。
Stupidsort。 分析以下排序算法的运行时间(最坏情况和最佳情况)、正确性和稳定性。从左到右扫描数组,直到找到两个连续的位置不正确的项。交换它们,并从头开始。重复直到扫描到数组的末尾。
for (int i = 1; i < N; i++) { if (less(a[i], a[i-1])) { exch(i, i-1); i = 0; } }考虑以下递归变体并分析最坏情况下的内存使用情况。
public static void sort(Comparable[] a) { for (int i = 1; i < a.length; i++) { if (less(a[i], a[i-1])) { exch(i, i-1); sort(a); } } }Stoogesort。 分析以下递归排序算法的运行时间和正确性:如果最左边的项大于最右边的项,则交换它们。如果当前子数组中有 2 个或更多项,(i) 递归地对数组的前两个三分之一进行排序,(ii) 对数组的最后两个三分之一进行排序,(iii) 再次对数组的前两个三分之一进行排序。
猜测排序。 随机选择两个索引 i 和 j;如果 a[i] > a[j],则交换它们。重复直到输入排序。分析此算法的预期运行时间。提示:每次交换后,逆序的数量会严格减少。如果有 m 个坏对,那么找到一个坏对的预期时间为 Theta(n²/m)。从 m = 1 到 n² 求和得到 O(N² log N)的总体时间,类似于收集优惠券。这个界限是紧的:考虑输入 1 0 3 2 5 4 7 6 ...
Bogosort。 Bogosort 是一种随机算法,通过将 N 张卡片抛起来,收集它们,并检查它们是否以递增顺序排列。如果没有,重复直到它们排好序。使用第 1.4 节中的洗牌算法实现 bogosort。估计运行时间作为 N 的函数。
慢速排序。 考虑以下排序算法:随机选择两个整数 i 和 j。如果 i < j,但 a[i] > a[j],则交换它们。重复直到数组按升序排列。论证该算法最终会完成(概率为 1)。作为 N 的函数,它需要多长时间?提示:在最坏情况下,它会进行多少次交换?
对数组进行排序的最小移动次数。 给定一个包含 N 个键的列表,移动操作包括从列表中移除任意一个键并将其附加到列表的末尾。不允许其他操作。设计一个算法,使用最少的移动次数对给定列表进行排序。
猜测排序。 考虑以下基于交换的排序算法:随机选择两个索引;如果 a[i]和 a[j]是一个逆序,交换它们;重复。证明对大小为 N 的数组进行排序的预期时间最多为 N² log N。参见此论文进行分析,以及称为 Fun-Sort 的相关排序算法。
交换一个逆序。 给定一个包含 N 个键的数组,设 a[i]和 a[j]是一个逆序(i < j 但 a[i] > a[j])。证明或证伪:交换 a[i]和 a[j]会严格减少逆序的数量。
二进制插入排序。 开发一个实现 BinaryInsertion.java 的插入排序,该排序使用二分查找来找到插入点 j 以便将条目 a[i]插入,然后将所有条目 a[j]到 a[i-1]向右移动一个位置。在最坏情况下,对长度为 n 的数组进行排序的比较次数应该约为~ n lg n。请注意,在最坏情况下,数组访问次数仍然是二次的。使用 SortCompare.java 来评估这样做的有效性。
2.2 归并排序
原文:
algs4.cs.princeton.edu/22mergesort译者:飞龙
我们在本节中考虑的算法基于一种简单的操作,称为合并:将两个有序数组组合成一个更大的有序数组。这个操作立即适用于一种简单的递归排序方法,称为归并排序:将数组分成两半,对这两半进行排序(递归),然后合并结果。
归并排序保证以与 N log N 成正比的时间对 N 个项目的数组进行排序���无论输入是什么。它的主要缺点是它使用与 N 成正比的额外空间。
抽象原地归并。
Merge.java 中的方法merge(a, lo, mid, hi)将子数组a[lo..mid]与a[mid+1..hi]的归并结果放入一个有序数组中,将结果留在a[lo..hi]中。虽然希望实现这种方法而不使用大量额外空间,但这样的解决方案非常复杂。相反,merge()将所有内容复制到辅助数组,然后再次归并到原始数组。
自顶向下的归并排序。
Merge.java 是基于这种抽象原地归并的递归归并排序实现。这是利用分治范式进行高效算法设计的最著名的例子之一。
命题。
自顶向下的归并排序使用 1/2 N lg N 和 N lg N 比较,并且最多需要 6 N lg N 次数组访问来对长度为 N 的任何数组进行排序。
改进。
通过对实现进行一些经过深思熟虑的修改,我们可以大大减少归并排序的运行时间。
对小子数组使用插入排序。 通过对待处理的小情况进行不同处理,我们可以改进大多数递归算法。对小子数组使用插入排序将使典型归并排序实现的运行时间提高 10 到 15%。
测试数组是否已经有序。 通过添加一个测试来跳过对
merge()的调用,如果a[mid]小于或等于a[mid+1],我们可以将已经有序的数组的运行时间减少为线性。通过这种改变,我们仍然执行所有递归调用,但对于任何已排序的子数组,运行时间是线性的。消除对辅助数组的复制。 可以消除用于归并的辅助数组的复制时间(但不是空间)。为此,我们使用两次调用排序方法,一次从给定数组中获取输入并将排序后的输出放入辅助数组;另一次从辅助数组中获取输入并将排序后的输出放入给定数组。通过这种方法,在一些令人费解的递归技巧中,我们可以安排递归调用,使计算在每个级别切换输入数组和辅助数组的角色。
MergeX.java 实现了这些改进。
可视化。
MergeBars.java 提供了带有小子数组截止的归并排序可视化。
自底向上的归并排序。
即使我们考虑将两个大子数组合并在一起,事实上大多数合并都是将微小的子数组合并在一起。 另一种实现归并排序的方法是组织合并,使我们在一次遍历中执行所有微小数组的合并,然后进行第二次遍历以成对合并这些数组,依此类推,直到进行涵盖整个数组的合并。 这种方法比标准递归实现需要更少的代码。 我们首先进行 1 对 1 的合并(将单个项目视为大小为 1 的子数组),然后进行 2 对 2 的合并(合并大小为 2 的子数组以生成大小为 4 的子数组),然后进行 4 对 4 的合并,依此类推。 MergeBU.java 是底部向上归并排序的实现。
命题。
底部向上的归并排序使用了介于 1/2 N lg N 和 N lg N 次比较,以及最多 6 N lg N 次数组访问来对长度为 N 的任意数组进行排序。
命题。
没有基于比较的排序算法可以保证使用少于 lg(N!) ~ N lg N 次比较对 N 个项目进行排序。
命题。
归并排序是一种渐进最优的基于比较的排序算法。 也就是说,归并排序在最坏情况下使用的比较次数以及任何基于比较的排序算法可以保证的最小比较次数都是~N lg N。
练习
给出追踪,展示如何使用自顶向下的归并排序和自底向上的归并排序对键
E A S Y Q U E S T I O N进行排序的方式。解决方案。
![归并排序]()
回答底部向上归并排序的练习 2.2.2。
解决方案。
![归并排序]()
如果抽象的原地合并仅在两个输入子数组按排序顺序排列时才产生正确的输出,那么是否正确? 证明你的答案,或提供一个反例。
解决方案。 是的。如果子数组按排序顺序排列,那么原地合并会产生正确的输出。 如果一个子数组未按排序顺序排列,则其条目将按照它们在输入中出现的顺序出现在输出中(与另一个子数组的条目交错)。
给出自顶向下和自底向上归并排序算法在 n = 39 时每次合并后的子数组大小序列。
解决方案。
自顶向下的归并排序:2, 3, 2, 5, 2, 3, 2, 5, 10, 2, 3, 2, 5, 2, 3, 2, 5, 10, 20, 2, 3, 2, 5, 2, 3, 2, 5, 10, 2, 3, 2, 5, 2, 2, 4, 9, 19, 39。
底部向上的归并排序:2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 4, 4, 4, 4, 4, 4, 4, 4, 4, 3, 8, 8, 8, 8, 7, 16, 16, 32, 39。查看代码 MergeSizes.java。
假设自顶向下的归并排序修改为在
a[mid] <= a[mid+1]时跳过对merge()的调用。 证明对于已排序顺序的数组,使用的比较次数是线性的。解决方案。 由于数组已经排序,不会调用
merge()。 当 N 是 2 的幂时,比较次数将满足递归 T(N) = 2 T(N/2) + 1,其中 T(1) = 0。在库软件中使用类似 aux[]的静态数组是不明智的,因为多个客户端可能同时使用该类。给出一个不使用静态数组的 Merge.java 实现。
创造性问题
更快的合并。 实现一个
merge()的版本,将a[]的后半部分以递减顺序复制到aux[],然后将其合并回a[]。 这个改变允许你从内部循环中删除测试每个半部分是否已耗尽的代码。 注意:结果排序不是稳定的。private static void merge(Comparable[] a, int lo, int mid, int hi) { for (int i = lo; i <= mid; i++) aux[i] = a[i]; for (int j = mid+1; j <= hi; j++) aux[j] = a[hi-j+mid+1]; int i = lo, j = hi; for (int k = lo; k <= hi; k++) if (less(aux[j], aux[i])) a[k] = aux[j--]; else a[k] = aux[i++]; }改进。 编写一个程序 MergeX.java,实现文本中描述的三个归并排序改进:添加对小子数组的截止,测试数组是否已经有序,通过在递归代码中切换参数来避免复制。
逆序数。 开发并实现一个线性对数算法 Inversions.java,用于计算给定数组中的逆序数(插入排序为该数组执行的交换次数—参见第 2.1 节)。这个数量与 Kendall tau 距离 有关;参见第 2.5 节。
索引排序。 开发一个版本的 Merge.java,该版本不重新排列数组,而是返回一个
int[] perm,使得perm[i]是数组中第 i 小的条目的索引。
实验
网络练习
每个项最多进行 log N 次比较的归并。 设计一个合并算法,使得每个项最多比较对数次数。 (在标准合并算法中,当合并大小为 N/2 的两个子数组时,一个项可以比较 N/2 次。)
对于排序 Young 表格的下界。 一个 Young 表格 是一个 N×N 矩阵,使得条目在列和行上都是有序的。证明对于排序 N² 个条目(只能通过成对比较访问数据)需要 Theta(N² log N) 次比较。
解决方案概述。如果条目 (i, j) 在 i + j 的 1/2 范围内,则所有 2N-1 个网格对角线彼此独立。对对角线进行排序需要 N² log N 次比较。
给定一个大小为 2N 的数组
a,其中前 N 个项按升序排列在位置 0 到 N-1,以及一个大小为 N 的数组b,其中 N 个项按升序排列,将数组b合并到数组a中,使得a包含所有项按升序排列。使用 O(1) 额外内存。提示:从右向左合并。
k-近排序。 假设你有一个包含 N 个不同项的数组
a[],几乎是有序的:每个项最多离其在排序顺序中的位置不超过 k 个位置。设计一个算法,在时间复杂度为 N log k 的情况下对数组进行排序。提示:首先,对从 0 到 2k 的子数组进行排序;最小的 k 个项将处于正确的位置。接下来,对从 k 到 3k 的子数组进行排序;最小的 2k 个项现在将处于正确的位置。
找到一组输入,对于这组输入,归并排序比对包含 N 个不同键的数组进行排序时的比较次数严格少于 1/2 N lg N。
解决方案:一个 N = 2^k + 1 个键的逆序排序数组使用大约 1/2 N lg N - (k/2 - 1) 次比较。
最坏情况的输入数组。 编写一个程序 MergeWorstCase.java,该程序接受一个命令行参数 n,并创建一个长度为 n 的输入数组,使得归并排序进行最大数量的比较。
编写一个程序 SecureShuffle.java,从标准输入中读取一系列字符串并进行安全洗牌。使用以下算法:将每张卡片与一个介于 0 和 1 之间的随机实数关联起来。根据其关联的实数对值进行排序。使用
java.security.SecureRandom生成随机实数。使用Merge.indexSort()获取随机排列。合并两个不同���度的数组。 给定大小为 M 和 N 的两个有序数组
a[]和b[],其中 M ≥ N,设计一个算法将它们合并成一个新的有序数组c[],使用 ~ N lg M 次比较。提示:使用二分查找。
注意:存在一个 下界 为 Omega(N log (1 + M/N)) 次比较。这是因为有 M+N 个 N 个可能的合并结果。决策树论证表明,这至少需要 lg (M+N 个 N) 次比较。我们注意到 n 个 r 个选择 >= (n/r)^r。
合并三个数组。 给定大小为 N 的三个有序数组
a[]、b[]和c[],设计一个算法将它们合并成一个新的有序数组d[],在最坏情况下最多使用 ~ 6 N 次比较(或者,更好地说,~ 5 N 次比较)。合并三个数组。 给定三个大小为 N 的排序数组
a[]、b[]和c[],证明没有基于比较的算法可以在最坏情况下使用少于 ~ 4.754887503 N 次比较将它们合并成一个新的排序数组d[]。具有 N^(3/2)逆序对的数组。 证明任何基于比较的算法,可以对具有 N^(3/2)或更少逆序对的数组进行排序,在最坏情况下必须进行 ~ 1/2 N lg N 次比较。
证明概要:将数组分成 sqrt(N) 个连续的子数组,每个子数组有 sqrt(N) 个项目,使得不同子数组之间没有逆序对,但每个子数组内的项目顺序是任意的。这样的数组最多有 N^(3/2) 个逆序对——每个 sqrt(N) 子数组中最多有 ~N/2 个逆序对。根据排序的下界,对每个子数组进行排序需要 ~ sqrt(N) lg sqrt(N) 次比较,总共需要 ~ 1/2 N lg N 次比较。
最优非遗忘排序。 设计算法,使用最少的比较次数(在最坏情况下)对长度为 3、4、5、6、7 和 8 的数组进行排序。
解决方案。 已知最优解使用 3、5、7、10、13 和 16 次比较,分别。已知 Ford-Johnson 合并插入算法对于 n ⇐ 13 是最优的。在最坏情况下,它需要进行 sum(ceil(log2), k=1..n) 次比较。
2.3 快速排序
原文:
algs4.cs.princeton.edu/23quicksort译者:飞龙
快速排序很受欢迎,因为它不难实现,适用于各种不同类型的输入数据,并且在典型应用中比任何其他排序方法都要快得多。它是原地排序(仅使用一个小型辅助栈),平均需要时间与 N log N 成正比来对 N 个项进行排序,并且具有极短的内部循环。
基本算法。
快速排序是一种分而治之的排序方法。它通过分区数组为两部分,然后独立对这两部分进行排序。
方法的关键在于分区过程,该过程重新排列数组以满足以下三个条件:
条目
a[j]在数组中处于最终位置,对于某个j。a[lo]到a[j-1]中没有任何条目大于a[j]。a[j+1]到a[hi]中没有任何条目小于a[j]。
我们通过分区实现完整排序,然后递归地将该方法应用于子数组。这是一种随机化算法,因为它在对数组进行排序之前对数组进行随机洗牌。
分区。
要完成实现,我们需要实现分区方法。我们采用以下一般策略:首先,我们任意选择a[lo]作为分区项—即将进入最终位置的项。接下来,我们从数组的左端开始扫描,直到找到一个大于(或等于)分区项的条目,然后我们从数组的右端开始扫描,直到找到一个小于(或等于)分区项的条目。
停止扫描的两个条目在最终分区数组中是不正确的,因此我们交换它们。当扫描索引交叉时,为了完成分区过程,我们只需将分区项a[lo]与左子数组的最右边的条目(a[j])交换并返回其索引j。
快速排序。
Quick.java 是一个使用上述分区方法的快速排序实现。
实现细节。
在实现快速排序方面存在一些微妙的问题,这些问题反映在这段代码中,并值得一提。
原地分区。 如果我们使用额外的数组,分区就很容易实现,但并不比将分区版本复���回原始数组的额外成本值得。
保持边界。 如果数组中最小项或最大项是分区项,我们必须注意指针不要跑到数组的左端或右端。
保持随机性。 随机洗牌使数组处于随机顺序。由于它均匀对待子数组中的所有项,Quick.java 具有其两个子数组也处于随机顺序的特性。这一事实对算法的可预测性至关重要。保持随机性的另一种方法是在
partition()中选择一个随机项进行分区。终止循环。 正确测试指针是否交叉比起初看起来要棘手一些。一个常见的错误是忽略了数组可能包含其他与分区项相同值的键。
处理具有与划分项目键相等的键的项目。 最好停止扫描具有大于或等于划分项目键的键的项目的左扫描,以及停止扫描具有小于或等于划分项目键的键的项目的右扫描。尽管这种策略似乎会导致涉及具有与划分项目键相等的键的项目的不必要的交换,但这对于避免在某些典型应用程序中出现二次运行时间至关重要。
终止递归。 在实现快速排序时的一个常见错误是没有确保始终将一个项目放在正确位置,然后当划分项目恰好是数组中最大或最小的项目时,陷入无限递归循环。
命题。
快速排序平均使用~2 N ln N 次比较(和其中六分之一的交换)来对具有不同键的长度为 N 的数组进行排序。
命题。
快速排序在最坏情况下使用~N²/2 次比较,但随机洗牌可以防止这种情况发生。
运行时间的标准偏差约为 0.65 N,因此随着 N 的增长,运行时间趋于平均值,并且不太可能远离平均值。在您的计算机上对大数组进行排序时,快速排序使用二次比较的概率远小于您的计算机被闪电击中的概率!
改进。
快速排序是由 C. A. R. Hoare 于 1960 年发明的,并自那时以来一直被许多人研究和完善。
切换到插入排序。 与归并排序一样,对于微小数组,切换到插入排序是值得的。截断的最佳值取决于系统,但在大多数情况下,任何值在 5 到 15 之间可能都能很好地工作。
三取样划分。 改进快速排序性能的另一种简单方法是使用从数组中取出的一小部分项目的中位数作为划分项目。这样做将给出一个稍微更好的划分,但需要计算中位数的成本。事实证明,大部分可用的改进来自选择大小为 3 的样本(然后在中间项目上进行划分)。
可视化。
QuickBars.java 使用三取样划分和对小子数组进行截断的快速排序进行可视化。
熵最优排序。
在应用程序中经常出现具有大量重复排序键的数组。在这种应用程序中,有可能将排序时间从线性对数减少到线性。
一个直接的想法是将数组划分为三部分,分别用于具有小于、等于和大于划分项目键的项目。完成这种划分是一个经典的编程练习,由 E. W. Dijkstra 推广为荷兰国旗问题,因为它类似于对具有三种可能键值的数组进行排序,这可能对应于国旗上的三种颜色。
Dijkstra 的解决方案基于数组的单向左到右遍历,维护指针lt,使得a[lo..lt-1]小于v,指针gt,使得a[gt+1..hi]大于v,指针i,使得a[lt..i-1]等于 v,a[i..gt]尚未检查。

从i等于lo开始,我们使用Comparable接口给出的 3 路比较来处理a[i],以处理三种可能的情况:
a[i]小于v:交换a[lt]和a[i],并同时增加lt和ia[i]大于v:交换a[i]和a[gt],并减少gta[i]等于v:增加i

Quick3way.java 是这种方法的一个实现。
命题。
三路划分的快速排序是熵最优的。
可视化。
Quick3wayBars.java 可视化了使用三向切分的快速排序。
练习
展示
partition()如何以E A S Y Q U E S T I O N数组进行划分的跟踪风格。![切分跟踪]()
展示快速排序如何对数组
E A S Y Q U E S T I O N进行排序的快速排序跟踪风格。(在这个练习中,忽略初始洗牌。)![快速排序跟踪]()
编写一个程序 Sort2distinct.java,对已知只包含两个不同关键值的数组进行排序。
当对一个包含 N 个相同项的数组进行排序时,
Quick.sort()会进行多少次比较?解决方案。 〜 N lg N 次比较。每个划分将数组分成两半,加上或减去一个。
展示熵最优排序如何首先对数组
B A B A B A B A C A D A B R A进行划分的跟踪风格。![三向切分跟踪]()
创造性问题
螺母和螺栓。(G. J. E. Rawlins)。你有一堆混合的 N 个螺母和 N 个螺栓,需要快速找到相应的螺母和螺栓配对。每个螺母恰好匹配一个螺栓,每个螺栓恰好匹配一个螺母。通过将螺母和螺栓配对,你可以看出哪个更大。但不能直接比较两个螺母或两个螺栓。给出一个解决问题的高效方法。
提示:根据问题定制快速排序。顺便说一句:对于这个问题,已知只有一个非常复杂的确定性 O(N log N)算法。
最佳情况。 编写一个程序 QuickBest.java,为
Quick.sort()生成一个最佳情况数组(无重复项):一个包含 N 个不同键的数组,具有每个划分产生的子数组大小最多相差 1 的特性(与 N 个相等键的数组产生相同子数组大小的情况相同)。在这个练习中,忽略初始洗牌。![快速排序的最佳情况输入]()
快速三向切分。(J. Bentley 和 D. McIlroy)。实现一个基于保持相等键在子数组的左右两端的熵最优排序 QuickBentleyMcIlroy.java。维护索引 p 和 q,使得 a[lo..p-1]和 a[q+1..hi]都等于 a[lo],一个索引 i,使得 a[p..i-1]都小于 a[lo],一个索引 j,使得 a[j+1..q]都大于 a[lo]。在内部划分循环代码中添加代码,如果 a[i]等于 v,则交换 a[i]和 a[p](并增加 p),如果 a[j]等于 v,则交换 a[j]和 a[q](并减少 q),然后再进行通常的 a[i]和 a[j]与 v 的比较。
在划分循环结束后,添加代码将相等的键交换到正确位置。
网络练习
QuickKR.java 是最简单的快速排序实现之一,并出现在 K+R 中。说服自己它是正确的。它将如何执行?所有相等的键呢?
随机化快速排序。 修改
partition(),使其总是从数组中均匀随机选择划分项(而不是最初对数组进行洗牌)。与 Quick.java 比较性能。Antiquicksort. Java 6 中用于对原始类型进行排序的算法是由 Bentley 和 McIlroy 开发的 3 路快速排序的变体。对于实践中出现的大多数输入,包括已经排序的输入,它非常高效。然而,使用 M. D. McIlroy 在 A Killer Adversary for Quicksort 中描述的巧妙技术,可以构造使系统排序在二次时间内运行的病态输入。更糟糕的是,它会溢出函数调用堆栈。要看到 Java 6 中的排序库崩溃,请尝试一些不同大小的致命输入:10,000, 20,000, 50,000, 100,000, 250,000, 500,000, 和 1,000,000。您可以使用程序 IntegerSort.java 进行测试,该程序接受一个命令行输入 N,从标准输入读取 N 个整数,并使用系统排序对它们进行排序。
糟糕的分区。 当所有键相等时,不停止在相等键上会使快速排序变为二次的原因是什么?
解决方案。 这是在我们在相等键上停止时对 AAAAAAAAAAAAAAA 进行分区的结果。它将数组不均匀地分成了一个大小为 0 的子问题和一个大小为 14 的子问题。
![在我们不停止在相等键上时对 AAAAAAAAAAAAAAA 进行分区]()
这是在我们在相等键上停止时对 AAAAAAAAAAAAAAA 进行分区的结果。它将数组均匀地分成了两个大小为 7 的子问题。
![在我们在相等键上停止时对 AAAAAAAAAAAAAAA 进行分区]()
将项目与自身进行比较。 展示我们的快速排序实现可以将项目与自身进行比较,即对某个索引
i调用less(i, i)。修改我们的实现,使其永远不会将项目与自身进行比较。霍尔原始快速排序。 实现霍尔原始快速排序算法的一个版本。它类似于我们的两路分区算法,只是枢轴不会交换到其最终位置。相反,枢轴留在两个子数组中的一个,没有元素固定在其最终位置,指针交叉的两个子数组会递归排序。
解决方案。 HoareQuick.java。我们注意到,虽然这个版本非常优雅,但它不会保留子数组中的随机性。根据 Sedgewick 的博士论文,“这种偏差不仅使方法的分析几乎不可能,而且还会显著减慢排序过程。”
双轴快速排序。 实现 Yaroslavskiy 的双轴快速排序的版本。
解决方案。 QuickDualPivot.java 是一个非常类似于 Quick3way.java 的实现。
三轴快速排序。 实现类似 Kushagra-Ortiz-Qiao-Munro 的三轴快速排序的版本。
比较次数。 给出一个长度为 n 的数组族,使得标准快速排序分区算法进行 (i) n + 1 次比较,(ii) n 次比较,(iii) n - 1 次比较,或者证明不存在这样的数组族。
解决方案:升序;降序;无。
2.4 优先队列
原文:
algs4.cs.princeton.edu/24pq译者:飞龙
许多应用程序要求我们按顺序处理具有键的项目,但不一定是完全排序的顺序,也不一定一次处理所有项目。通常,我们收集一组项目,然后处理具有最大键的项目,然后可能收集更多项目,然后处理具有当前最大键的项目,依此类推。在这种环境中,一个适当的数据类型支持两个操作:删除最大和插入。这样的数据类型称为优先队列。
API。
优先队列的特点是删除最大和插入操作。按照惯例,我们将仅使用less()方法比较键,就像我们对排序所做的那样。因此,如果记录可以具有重复的键,最大意味着具有最大键值的任何记录。为了完善 API,我们还需要添加构造函数和测试是否为空操作。为了灵活性,我们使用一个实现了Comparable的通用类型Key的通用实现。
程序 TopM.java 是一个优先队列客户端,它接受一个命令行参数M,从标准输入读取交易,并打印出M个最大的交易。
基本实现。
我们在第 1.3 节中讨论的基本数据结构为我们提供了四个立即的实现优先队列的起点。
数组表示(无序)。 也许最简单的优先队列实现是基于我们的推入栈代码。优先队列中插入的代码与栈中的推入相同。要实现删除最大,我们可以添加类似于选择排序的内部循环的代码,将最大项与末尾的项交换,然后删除那个,就像我们对栈的
pop()所做的那样。程序 UnorderedArrayMaxPQ.java 使用这种方法实现了一个优先队列。数组表示(有序)。 另一种方法是添加插入的代码,将较大的条目向右移动一个位置,从而保持数组中的条目有序(就像插入排序一样)。因此,最大的项始终在末尾,优先队列中删除最大的代码与栈中的弹出相同。程序 OrderedArrayMaxPQ.java 使用这种方法实现了一个优先队列。
链表表示(无序和反向有序)。 类似地,我们可以从我们的推入栈的链表代码开始,修改
pop()的代码以找到并返回最大值,或者修改push()的代码以保持项目以相反顺序,并修改pop()的代码以取消链接并返回列表中的第一个(最大)项目。

所有刚讨论的基本实现都具有插入或删除最大操作在最坏情况下需要线性时间的特性。找到一个保证两个操作都快速的实现是一个更有趣的任务,也是本节的主要内容。
堆定义。
二叉堆是一种数据结构,可以高效支持基本的优先队列操作。在二叉堆中,项目存储在一个数组中,使得每个键都保证大于(或等于)另外两个特定位置的键。反过来,这两个键中的每一个必须大于另外两个键,依此类推。如果我们将键视为在具有从每个键到已知较小键的两个键的边的二叉树结构中,这种排序是很容易看到的。
定义。 如果每个节点中的键大于(或等于)该节点的两个子节点(如果有的话)中的键,则二叉树是堆有序的。
命题。 堆有序二叉树中最大的键位于根节点。
我们可以对任何二叉树施加堆排序限制。然而,使用像下面这样的完全二叉树特别方便。
我们通过层级顺序在数组中顺序表示完全二叉树,根位于位置 1,其子节点位于位置 2 和 3,它们的子节点位于位置 4、5、6 和 7,依此类推。
定义。 二叉堆是一组按照完全堆排序的二叉树中的键排列的节点集合,在数组中按层级顺序表示(不使用第一个条目)。

在堆中,位置为 k 的节点的父节点在位置 k/2;反之,位置为 k 的节点的两个子节点在位置 2k 和 2k + 1。我们可以通过对数组索引进行简单算术来上下移动:从 a[k] 向上移动树,我们将 k 设置为 k/2;向下移动树,我们将 k 设置为 2k 或 2k+1。
堆上的算法。
我们在长度为 n + 1 的私有数组 pq[] 中表示大小为 n 的堆,其中 pq[0] 未使用,堆在 pq[1] 到 pq[n] 中。我们仅通过私有辅助函数 less() 和 exch() 访问键。我们考虑的堆操作通过首先进行可能违反堆条件的简单修改,然后通���遍历堆,根据需要修改堆以确保堆条件在任何地方都得到满足来工作。我们将这个过程称为重新堆化,或恢复堆顺序。
自底向上重新堆化(上浮)。如果堆顺序被违反,因为一个节点的键变大于该节点的父节点的键,那么我们可以通过将节点与其父节点交换来向修复违规迈进。交换后,节点比其两个子节点都大(一个是旧父节点,另一个比旧父节点小,因为它是该节点的子节点),但节点可能仍然比其父节点大。我们可以以相同的方式修复该违规,依此类推,向上移动堆,直到到达具有较大键的节点,或根节点。
![自底向上堆化(上浮)]()
private void swim(int k) { while (k > 1 && less(k/2, k)) { exch(k, k/2); k = k/2; } }自顶向下堆化(下沉)。如果堆顺序被违反,因为一个节点的键变小于一个或两个子节点的键,那么我们可以通过将节点与其两个子节点中较大的一个交换来向修复违规迈进。这种交换可能导致子节点违规;我们以相同的方式修复该违规,依此类推,向下移动堆,直到到达两个子节点都较小或底部的节点。
![自顶向下堆化(下沉)]()
private void sink(int k) { while (2*k <= N) { int j = 2*k; if (j < N && less(j, j+1)) j++; if (!less(k, j)) break; exch(k, j); k = j; } }

基于堆的优先队列。
这些 sink() 和 swim() 操作为优先队列 API 的高效实现提供了基础,如下图所示,并在 MaxPQ.java 和 MinPQ.java 中实现。
插入。我们在数组末尾添加新项,增加堆的大小,然后通过该项向上游走以恢复堆的条件。
移除最大值。我们将顶部的最大项取出,将堆的末尾项放在顶部,减少堆的大小,然后通过该项向下沉入堆中以恢复堆的条件。

命题。 在一个包含 n 项的优先队列中,堆算法对插入最多需要 1 + lg n 次比较,对移除最大值最多需要 2 lg n 次比较。
实际考虑。
我们以几个实际考虑结束对堆优先队列 API 的研究。
多路堆。修改我们的代码以构建基于完整堆排序三元或d元树的数组表示并不困难。在降低树高度的较低成本和在每个节点找到三个或d个子节点中最大成本��间存在权衡。
数组调整。我们可以添加一个无参数构造函数,在
insert()中添加数组加倍的代码,在delMax()中添加数组减半的代码,就像我们在第 1.3 节中为堆栈所做的那样。当优先队列的大小是任意的且数组被调整大小时,对数时间界是摊销的。键的不可变性。优先队列包含由客户端创建的对象,但假设客户端代码不会更改键(这可能会使堆的不变性无效)。
索引优先队列。在许多应用中,允许客户端引用已经在优先队列中的项目是有意义的。一种简单的方法是为每个项目关联一个唯一的整数索引。
![索引优先队列 API]()
IndexMinPQ.java 是这个 API 的基于堆的实现;IndexMaxPQ.java 类似,但用于面向最大的优先队列。Multiway.java 是一个客户端,将几个排序的输入流合并成一个排序的输出流。
堆排序。
我们可以使用任何优先队列来开发排序方法。我们将所有要排序的键插入到面向最小的优先队列中,然后重复使用删除最小值按顺序删除它们。当使用堆作为优先队列时,我们获得堆排序。
着眼于排序任务,我们放弃了隐藏优先队列的堆表示的概念,并直接使用swim()和sink()。这样做允许我们在不需要任何额外空间的情况下对数组进行排序,通过在要排序的数组内维护堆。堆排序分为两个阶段:堆构造,在这个阶段我们将原始数组重新组织成堆,和sortdown,在这个阶段我们按递减顺序从堆中取出项目以构建排序结果。
堆构造。我们可以在时间上按比例完成这项任务n lg n,通过从数组的左侧到右侧进行,使用
swim()来确保扫描指针左侧的条目组成一个堆排序完整树,就像连续的优先队列插入一样。一个更有效的巧妙方法是从右到左进行,使用sink()来随着我们的前进制作子堆。数组中的每个位置都是一个小子堆的根;sink()也适用于这样的子堆。如果一个节点的两个子节点是堆,那么在该节点上调用sink()会使根在那里的子树成为堆。Sortdown。在堆排序期间,大部分工作是在第二阶段完成的,在这个阶段我们从堆中移除剩余的最大项目,并将其放入数组位置中,随着堆的缩小而腾出。

Heap.java 是堆排序的完整实现。下面是每次下沉后数组内容的跟踪。

命题。 基于 sink 的堆构造是线性时间的。
命题。 堆排序使用少于 2 n lg n 次比较和交换来对 n 个项目进行排序。
在 sortdown 期间重新插入堆中的大多数项目都会一直到底部。因此,我们可以通过避免检查项目是否已到达其位置来节省时间,简单地提升两个子节点中较大的一个,直到到达底部,然后沿着堆向上移动到正确的位置。这个想法通过增加额外的簿记来减少了比较次数。
练习
假设序列
P R I O * R * * I * T * Y * * * Q U E * * * U * E(其中字母表示插入,星号表示删除最大值)应用于最初为空的优先队列。给出删除最大值操作返回的值序列。
解决方案。R R P O T Y I I U Q E U(PQ 上剩下 E)
批评以下想法:为了在常数时间内实现查找最大值,为什么不跟踪迄今为止插入的最大值,然后在查找最大值时返回该值?
解决方案。在删除最大值操作后,需要从头开始更新最大值。
提供支持插入和删除最大值的优先队列实现,每种实现对应一个基础数据结构:无序数组、有序数组、无序链表和有序链表。给出您在上一个练习中四种实现的每个操作的最坏情况下界的表格。
部分解决方案。OrderedArrayMaxPQ.java 和 UnorderedArrayMaxPQ.java
排序为降序的数组是否是面向最大值的堆。
答案。是的。
假设您的应用程序将有大量插入操作,但只有少量删除最大值操作。您认为哪种优先队列实现最有效:堆、无序数组、有序数组?
答案。无序数组。插入是常数时间。
假设您的应用程序将有大量查找最大值操作,但相对较少的插入和删除最大值操作。您认为哪种优先队列实现最有效:堆、无序数组、有序数组?
答案。有序数组。在常数时间内找到最大值。
在一个没有重复键的大小为n的堆中,删除最大值操作期间必须交换的最小项数是多少?给出一个大小为 15 的堆,使得最小值得以实现。对连续两次和三次删除最大值操作,回答相同的问题。
部分答案:(a) 2。
设计一个线性时间的认证算法来检查数组
pq[]是否是一个面向最小值的堆。解决方案。参见 MinPQ.java 中的
isMinHeap()方法。证明基于下沉的堆构建最多使用 2n次比较和最多n次交换。
解决方案。只需证明基于下沉的堆构建使用的交换次数少于n次,因为比较次数最多是交换次数的两倍。为简单起见,假设二叉堆是完美的(即每一层都完全填满的二叉树)且高度为h。
![堆调整分析]()
我们定义树中节点的高度为以该节点为根的子树的高度。当一个高度为k的键被下沉时,它最多可以与其下面的k个键交换。由于在高度k处有 2^(h−k)个节点,总交换次数最多为:\(\begin{eqnarray*} h + 2(h-1) + 4(h-2) + 8(h-3) + \ldots + 2^h (0) & = & 2^{h+1} - h - 2 \\ & = & n - (h+1) \\ & \le & n \end{eqnarray*}\)
第一个等式是针对非标准求和的,但通过数学归纳法很容易验证该公式成立。第二个等式成立是因为高度为h的完美二叉树有 2^(h+1) − 1 个节点。
证明当二叉树不完美时结果成立需要更加小心。您可以使用以下事实来证明:在具有n个节点的二叉堆中,高度为k的节点的数量最多为 ceil(n / 2^(k+1))。
替代解决方案。我们定义树中节点的高度为以该节点为根的子树的高度。
首先,观察到一个具有n个节点的二叉堆有n − 1 个链接(因为每个链接是一个节点的父节点,每个节点都有一个父链接,除了根节点)。
下沉一个高度为k的节点最多需要k次交换。
我们将对每个高度为k的节点收取k个链接,但不一定是在下沉节点时所采取的路径上的链接。相反,我们对从节点沿着左-右-右-右-...路径的k个链接收费。例如,在下图中,根节点收取 4 个红色链接;蓝色节点收取 3 个蓝色链接;依此类推。
![备用堆化分析]()
注意,没有链接会被收费超过一个节点。(仅通过从根节点向右链接获得的链接不会被收费给任何节点。)
因此,总交换次数最多为n。由于每次交换最多有 2 次比较,因此比较次数最多为 2n。
创意问题
计算数论。 编写一个程序 CubeSum.java,打印出所有形式为(a³ + b³)的整数,其中(a)和(b)是介于 0 和(n)之间的整数,按排序顺序打印,而不使用过多的空间。也就是说,不要计算一个包含(n²)个和并对它们进行排序的数组,而是构建一个最小导向的优先队列,最初包含((0³, 0, 0), (1³ + 1³, 1, 1), (2³ + 2³, 2, 2), \ldots, (n³ + n³, n, n))。然后,在优先队列非空时,移除最小项(i³ + j³,; i, ; j)),打印它,然后,如果(j < n),插入项((i³ + (j+1)³,; i,; j+1))。使用这个程序找到所有介于 0 和(10⁶)之间的不同整数(a, b, c)和(d),使得(a³ + b³ = c³ + d³),例如(1729 = 9³ + 10³ = 1³ + 12³)。
查找最小值。 在 MaxPQ.java 中添加一个
min()方法。你的实现应该使用恒定的时间和额外的空间。解决方案:添加一个额外的实例变量,指向最小项。在每次调用
insert()后更新它。如果优先队列变为空,则将其重置为null。动态中位数查找。 设计一个数据类型,支持对数时间的插入,常数时间的查找中位数,以及对数时间的删除中位数。
解决方案。 将中位数键保留在 v 中;对于小于 v 键的键,使用一个最大导向的堆;对于大于 v 键的键,使用一个最小导向的堆。要插入,将新键添加到适当的堆中,用从该堆中提取的键替换 v。
下界。 证明不可能开发一个 MinPQ API 的实现,使得插入和删除最小值都保证使用~n log log n比较。
解决方案。 这将产生一个n log log n比较排序算法(插入n个项目,然后重复删除最小值),违反了第 2.3 节的命题。
索引优先队列实现。 通过修改 MaxPQ.java 来实现 IndexMaxPQ.java:将
pq[]更改为保存索引,添加一个数组keys[]来保存键值,并添加一个数组qp[],它是pq[]的逆——qp[i]给出i在pq[]中的位置(索引j,使得pq[j]是i)。然后修改代码以维护这些数据结构。使用约定,如果i不在队列中,则qp[i]为-1,并包括一个测试此条件的方法contains()。您需要修改辅助方法exch()和less(),但不需��修改sink()或swim()。
网络练习
堆排序的最佳、平均和最差情况。 对于对长度为n的数组进行堆排序,最佳情况、平均情况和最差情况的比较次数分别是多少?
解决方案。 如果允许重复项,最佳情况是线性时间(n个相等的键);如果不允许重复项,最佳情况是~n lg n比较(但最佳情况输入是非平凡的)。平均情况和最差情况的比较次数是~2 n lg n比较。详细信息请参阅堆排序的分析。
堆化的最佳和最差情况。 对于n个项目的数组进行堆化所需的最少和最多比较/交换次数是多少?
解决方案。 对包含n个项目的数组进行降序堆化需要 0 次交换和n − 1 次比较。对包含n个项目的数组进行升序堆化需要~ n次交换和~ 2n次比较。
出租车数。 找到可以用两种不同方式的整数立方和表示的最小整数(1,729),三种不同方式(87,539,319),四种不同方式(6,963,472,309,248),五种不同方式(48,988,659,276,962,496),以及六种不同方式(24,153,319,581,254,312,065,344)。这样的整数被命名为出租车数以纪念著名的拉马努金故事。目前尚不清楚可以用七种不同方式表示为整数立方和的最小整数。编写一个程序 Taxicab.java,该程序读取一个命令行参数 N,并打印出所有非平凡解 a³ + b³ = c³ + d³,其中 a、b、c 和 d 小于或等于 N。
计算数论。 找到方程 a + 2b² = 3c³ + 4d⁴的所有解,其中 a、b、c 和 d 小于 100,000。提示:使用一个最小堆和一个最大堆。
中断处理。 在编写可以被中断的实时系统时(例如,通过鼠标点击或无线连接),需要立即处理中断,然后再继续当前活动。如果中断应按照到达的顺序处理,则 FIFO 队列是适当的数据结构。然而,如果不同的中断具有不同的优先级(例如,),则需要优先级队列。
排队网络的模拟。 M/M/1 队列用于双并行队列等。数学上难以分析复杂的排队网络。因此使用模拟来绘制等待时间分布等。需要优先级队列来确定下一个要处理的事件。
Zipf 分布。 使用前面练习的结果从具有参数 s 和n的Zipf 分布中进行抽样。该分布可以取 1 到n之间的整数值,并以概率 1/ks / sum_(i = 1 to n) 1/is 取值 k。例如:莎士比亚的戏剧《哈姆雷特》中的单词,s 约等于 1。
随机过程。 从n个箱子开始,每个箱子包含一个球。随机选择其中一个n个球,并将球随机移动到一个箱���中,使得球被放置在具有m个球的箱子中的概率为m/n。经过多次迭代后,结果是什么样的球分布?使用上述描述的随机抽样方法使模拟更有效率。
最近邻。 给定长度为m的n个向量 x[1]、x[2]、...、x[N]和另一个相同长度的向量x,找到距离x最近的 20 个向量。
在一张图纸上画圆。 编写一个程序来找到以原点为中心,与整数 x 和 y 坐标的 32 个点相切的圆的半径。提示:寻找一个可以用几种不同方式表示为两个平方和的数字。答案:有两个勾股三元组的斜边为 25:15² + 20² = 25²,7² + 24² = 25²,得到 20 个这样的格点;有 22 个不同的斜边为 5,525 的勾股三元组;这导致 180 个格点。27,625 是比 64 更多的最小半径。154,136,450 有 35 个勾股三元组。
完美幂。 编写一个程序 PerfectPower.java 来打印所有可以表示为 64 位
long整数的完美幂:4, 8, 9, 16, 25, 27, .... 完美幂是可以写成 a^b 的数字,其中 a 和 b ≥ 2 为整数。浮点加法。 添加n个浮点数,避免舍入误差。删除最小的两个:将它们相加,然后重新插入。
首次适应装箱。 17/10 OPT + 2, 11/9 OPT + 4(递减)。使用最大锦标赛树,其中选手是 N 个箱子,值=可用容量。
具有最小/最大值的栈。 设计一个数据类型,支持推入、弹出、大小、最小值和最大值(其中最小值和最大值是栈上的最小和最大项目)。所有操作在最坏情况下应该花费常数时间。
*提示:*将每个栈条目与当前栈上的最小和最大项目关联起来。
具有最小/最大值的队列。 设计一个数据类型,支持入队、出队、大小、最小值和最大值(其中最小值和最大值是队列上的最小和最大项目)。所有操作应该在常摊时间内完成。
*提示:*完成前面的练习,并模拟使用两个栈的队列。
2i + 5j。 按升序打印形式为 2i * 5j 的数字。
最小-最大堆。 设计一个数据结构,通过将项目放入大小为n的单个数组中,支持常数时间内的最小值和最大值,以及对数时间内的插入、删除最小值和删除最大值,具有以下属性:
数组表示一个完全二叉树。
偶数级别节点中的键小于(或等于)其子树中的键;奇数级别节点中的键大于(或等于)其子树中的键。请注意,最大值存储在根节点,最小值存储在根节点的一个子节点中。
解决方案。 最小-最大堆和广义优先队列
范围最小查询。 给定一个包含n个项目的序列,从索引 i 到 j 的范围最小查询是 i 和 j 之间最小项目的索引。设计一个数据结构,在线性时间内预处理n个项目的序列,以支持对数时间内的范围最小查询。
证明具有n个节点的完全二叉树恰好有*ceiling(n/2)*个叶节点(没有子节点的节点)。
具有最小值的最大导向优先队列。 在最大导向的二叉堆中查找最小键的运行时间增长顺序是什么。
*解决方案:线性—最小键可能在任何一个ceiling(n/2)*个叶节点中。
具有最小值的最大导向优先队列。 设计一个数据类型,支持对数时间内的插入和删除最大值,以及常数时间内的最大值和最小值。
解决方案。 创建一个最大导向的二叉堆,并存储迄今为止插入的最小键(除非此堆变为空,否则永远不会增加)。
大于 x 的第 k 个最大项目。 给定一个最大导向的二叉堆,设计一个算法来确定第 k 个最大项目是否大于或等于 x。你的算法应该在与 k 成比例的时间内运行。
*解决方案:*如果节点中的键大于或等于 x,则递归搜索左子树和右子树。当探索的节点数等于 k 时停止(答案是是),或者没有更多节点可探索时(否)。
最小导向二叉堆中的第 k 个最小项目。 设计一个 k log k 算法,找到包含n个项目的最小导向二叉堆 H 中的第 k 个最小项目。
解决方案。 构建一个新的最小导向堆 H'。我们不会修改 H。将 H 的根插入 H'中,同时插入其堆索引 1。现在,重复删除 H'中的最小项目 x,并将 x 的两个子项从 H 插入 H'。从 H'中删除的第 k 个项目是 H 中第 k 小的项目。
随机队列。 实现一个
RandomQueue,使得每个操作都保证最多花费对数时间。*提示:*不能承受数组加倍。使用链表无法以 O(1)时间定位随机元素。相反,使用具有显式链接的完全二叉树。具有随机删除的 FIFO 队列。 实现一个支持以下操作的数据类型:插入一个项目,删除最近添加的项目,和删除一个随机项目。每个操作在最坏情况下应该花费(最多)对数时间。
解决方案:使用具有显式链接的完全二叉树;为添加到数据结构中的第 i 个项目分配长整型优先级i。
两个排序数组的前 k 个和。 给定两个长度为n的排序数组 a[]和 b[],找到形式为 a[i] + b[j]的最大 k 个和。
提示:使用优先队列(类似于出租车问题),您可以实现一个 O(k log n)算法。令人惊讶的是,可以在 O(k)时间内完成,但是算法比较复杂。
堆构建的实证分析。 通过实证比较线性时间的自底向上堆构建和朴素的线性对数时间的自顶向下堆构建。一定要在一系列n值上进行比较。LaMarca 和 Ladner报告称,由于缓存局部性,对于大n值(当堆不再适合缓存时),朴素算法在实践中可能表现更好,即使后者执行的比较和交换要少得多。
多路堆的实证分析。 实证比较 2-、4-和 8 路堆的性能。LaMarca 和 Ladner提出了几种优化方法,考虑了缓存效果。
堆排序的实证分析。 实证比较 2-、4-和 8 路堆排序的性能。LaMarca 和 Ladner提出了几种优化方法,考虑了缓存效果。他们的数据表明,经过优化(并调整内存)的 8 路堆排序可以比经典堆排序快两倍。
通过插入堆化。 假设您通过反复将下一个键插入二叉堆来在n个键上构建二叉堆。证明总比较次数最多为~ n lg n。
答案:比较次数最多为 lg 1 + lg 2 + ... + lg n = lg (n!) ~ n lg n。
**堆化下界。(Gonnet 和 Munro)**证明任何基于比较的二叉堆构建算法在最坏情况下至少需要~1.3644 N 次比较。
答案:使用信息论论证,类似于排序下界。对于 n 个不同键的 n!个可能堆(N 个整数的排列),但有许多堆对应于相同的排序。例如,有两个堆(c a b 和 c b a),对应于 3 个元素 a < b < c。对于完美堆(n = 2h - 1),有 A(h) = n! / prod((2k-1)(2(h-k)), k=1..h)个堆对应于n个元素 a[0] < a[1] < ... < a[n-1]。(参见Sloane 序列 A056971。)因此,任何算法必须能够输出 P(h) = prod((2k-1)(2^(h-k)), k=1..h)可能的答案之一。使用一些花哨的数学,��可以证明 lg P(h) ~ 1.3644 n。
注意:通过对手论证,下界可以改进为~ 3/2 n(Carlsson–Chen);该问题的最佳已知算法在最坏情况下需要~ 1.625 n次比较(Gonnet 和 Munro)。
股票交易撮合引擎。 连续限价订单簿:交易员不断发布买入或卖出股票的竞价。限价订单意味着买方(卖方)以指定价格或以下(或以上)的价格下达购买(出售)一定数量给定股票的订单。订单簿显示买单和卖单,并按价格然后按时间对其进行排名。匹配引擎匹配兼容的买家和卖家;如果存在多个可能的买家,则通过选择最早下单的买家来打破平局。为每支股票使用两个优先队列,一个用于买家,一个用于卖家。
随机二叉堆。 假设您用 1 到 n 的整数的随机排列填充长度为 n 的数组。对于 n = 5 和 6,生成的数组是最小定向二叉堆的概率是多少?
解决方案:分别为 1/15 和 1/36。这里有一个很好的讨论。
2.5 排序应用
原文:
algs4.cs.princeton.edu/25applications译者:飞龙
排序算法和优先队列在各种应用中被广泛使用。本节的目的是简要概述其中一些应用。
对各种类型的数据进行排序。
我们的实现对Comparable对象的数组进行排序。这种 Java 约定允许我们使用 Java 的回调机制对实现了Comparable接口的任何类型的对象数组进行排序。
事务示例。 程序 Transaction.java 基于事务发生时间实现了事务数据类型的
Comparable接口。指针排序。 我们正在使用的方法在经典文献中被称为指针排序,因为我们处理的是对键的引用,而不是移动数据本身。
键是不可变的。 如果允许客户在排序后更改键的值,那么数组可能不会保持排序。在 Java 中,通过使用不可变键来确保键值不变是明智的。
交换成本低廉。 使用引用的另一个优点是我们避免了移动完整项的成本。引用方法使得交换的成本在一般情况下大致等于比较的成本。
备用排序。 有许多应用程序,我们希望根据情况使用两种不同的顺序对我们正在排序的对象。Java 的
Comparator接口有一个名为compare()的公共方法,用于比较两个对象。如果我们有一个实现了此接口的数据类型,我们可以将Comparator传递给sort()(它传递给less())如 Insertion.java 中所示。具有多个键的项。 在典型应用中,项具有多个可能需要用作排序键的实例变量。在我们的事务示例中,一个客户可能需要按帐号号码对事务列表进行排序;另一个客户可能需要按地点对列表进行排序;其他客户可能需要使用其他字段作为排序键。我们可以定义多个比较器,如 Transaction.java 中所示。
具有比较器的优先队列。 使用比较器的灵活性对于优先队列也很有用。MaxPQ.java 和 MinPQ.java 包括一个以
Comparator作为参数的构造函数。稳定性。 如果排序方法在数组中保留相等键的相对顺序,则称其为稳定。例如,在我们的互联网商务应用中,我们按照事务到达的顺序将其输入到数组中,因此它们按照数组中的时间字段顺序排列。现在假设应用程序要求将事务按位置分开以进行进一步处理。一个简单的方法是按位置对数组进行排序。如果排序是不稳定的,那么每个城市的事务在排序后可能不一定按时间顺序排列。我们在本章中考虑的一些排序方法是稳定的(插入排序和归并排序);许多排序方法则不是(选择排序、希尔排序、快速排序和堆排序)。
![在第二个关键字上排序时的稳定性]()
我应该使用哪种排序算法?
确定哪种算法是最佳的取决于应用和实现的细节,但我们已经研究了一些通用方法,它们在各种应用中几乎与最佳方法一样有效。下表是一个概括我们在本章中研究的排序算法的重要特征的一般指南。
性质。 快速排序是最快的通用排序方法。
在大多数实际情况下,快速排序是首选方法。如果稳定性很重要且有空间可用,则归并排序可能是最佳选择。在一些性能关键的应用中,重点可能仅仅是对数字进行排序,因此可以避免使用引用的成本,而是对原始类型进行排序。
排序原始类型。 我们可以通过将
Comparable替换为原始类型名称,并将对less()的调用替换为类似a[i] < a[j]的代码,为原始类型开发更高效的排序代码。但是,对于浮点类型,需要注意处理-0.0 和 NaN。Java 系统排序。 Java 的主要系统排序方法
Arrays.sort()在java.util库中表示一组重载方法:每种原始类型的不同方法。
一种用于实现
Comparable的数据类型的方法。一种使用
Comparator的方法。
Java 的系统程序员选择使用快速排序(带有 3 路分区)来实现原始类型方法,并使用归并排序来实现引用类型方法。这些选择的主要实际影响是在速度和内存使用(对于原始类型)与稳定性和性能保证(对于引用类型)之间进行权衡。
缩减。
我们可以使用排序算法来解决其他问题的想法是算法设计中一种基本技术的例子,称为缩减。缩减是一种情况,其中为一个问题开发的算法用于解决另一个问题。我们从一些排序的基本示例开始。
重复项。 在一个包含
Comparable对象的数组中是否有重复的键?数组中有多少个不同的键?哪个值出现最频繁?通过排序,您可以在线性对数时间内回答这些问题:首先对数组进行排序,然后通过排序后的数组进行一次遍历,注意在有序数组中连续出现的重复值。排名。 一个排列(或排名)是一个包含 N 个整数的数组,其中 0 到 N-1 之间的每个整数恰好出现一次。两个排名之间的Kendall tau 距离是在两个排名中顺序不同的对数。例如,
0 3 1 6 2 5 4和1 0 3 6 4 2 5之间的 Kendall tau 距离是四,因为在两个排名中,对 0-1、3-1、2-4、5-4 的顺序不同,但所有其他对的顺序相同。优先队列缩减。 在第 2.4 节中,我们考虑了两个问题的示例,这些问题可以简化为对优先队列的一系列操作。TopM.java 在输入流中找到具有最高键的 M 个项目。Multiway.java 将 M 个排序的输入流合并在一起,以生成一个排序的输出流。这两个问题都可以通过大小为 M 的优先队列轻松解决。
中位数和顺序统计。
与排序相关的一个重要应用是找到一组键的中位数(具有一半键不大于它,一半键不小于它的值)。这个操作在统计学和其他各种数据处理应用中是一个常见的计算。找到中位数是选择的一个特殊情况:找到一组数字中第 k 小的数字。通过排序,可以很容易在线性对数时间内解决这个问题。方法select()我们描述了一种在线性时间内解决问题的方法:维护变量
lo和hi来限定包含要选择的项目的索引k的子数组,并使用快速排序分区来缩小子数组的大小,如下所示:如果
k等于j,那么我们完成了。否则,如果
k < j,那么我们需要继续在左子数组中工作(通过将hi的值更改为j-1)否则,如果
k > j,那么我们需要继续在右子数组中工作(通过将lo更改为j+1)。
区间收缩,直到只剩下
k。终止时,a[k]包含第(k+1)小的条目,a[0]到a[k-1]都小于(或等于)a[k],而a[k+1]到数组末尾都大于(或等于)a[k]。select()方法在 Quick.java 中实现了这种方法,但在客户端需要进行类型转换。QuickPedantic.java 中的select()方法是更加严谨的代码,避免了需要进行类型转换。
对排序应用的简要调查。
商业计算。 政府机构、金融机构和商业企业通过对信息进行排序来组织大部分信息。无论信息是按名称或编号排序的账户、按时间或地点排序的交易、按邮政编码或地址排序的邮件、按名称或日期排序的文件,还是其他任何信息,处理这些数据肯定会涉及到某种排序算法。
搜索信息。 将数据保持有序可以通过经典的二分搜索算法高效地搜索数据。
运筹学。 假设我们有 N 个工作要完成,其中第 j 个工作需要 t[j]秒的处理时间。我们需要完成所有工作,但希望通过最小化工作的平均完成时间来最大化客户满意度。最短处理时间优先规则,即按处理时间递增顺序安排工作,已知可以实现这一目标。另一个例子是负载平衡问题,其中我们有 M 个相同的处理器和 N 个工作要完成,我们的目标是在处理器上安排所有工作,以便最后一个工作完成的时间尽可能早。这个具体问题是 NP 难题(参见第六章),因此我们不指望找到一个实际的方法来计算最佳的安排。已知一种能够产生良好安排的方法是最长处理时间优先规则,即按处理时间递减顺序考虑工作,将每个工作分配给最先可用的处理器。
事件驱动模拟。 许多科学应用涉及模拟,计算的目的是模拟现实世界的某个方面,以便更好地理解它。进行这种模拟可能需要适当的算法和数据结构。我们在第 6.1 节中考虑了一个粒子碰撞模拟,说明了这一点。
数值计算。 科学计算通常关注准确性(我们距离真实答案有多接近?)。当我们进行数百万次计算时,准确性非常重要,特别是在使用计算机上常见的浮点数表示实数时。一些数值算法使用优先队列和排序来控制计算中的准确性。
组合搜索。 人工智能中的一个经典范例是定义一组配置,其中每个配置都有从一个配置到下一个配置的明确定义的移动和与每个移动相关联的优先级。还定义了一个起始配置和一个目标配置(对应于已解决问题)。A算法*是一个问题解决过程,其中我们将起始配置放在优先队列中,然后执行以下操作直到达到目标:移除优先级最高的配置,并将可以通过一次移动到达的所有配置添加到队列中(不包括刚刚移除的配置)���
普里姆算法和迪杰斯特拉算法是处理图的经典算法。优先队列在组织图搜索中起着基础性作用,实现高效的算法。
Kruskal 算法是另一��经典的图算法,其边具有权重,取决于按权重顺序处理边。其运行时间由排序的成本主导。
赫夫曼压缩是一种经典的数据压缩算法,它依赖于通过将具有整数权重的一组项目组合起来,以产生一个新的项目,其权重是其两个组成部分的和。使用优先队列立即实现此操作。
字符串处理算法通常基于排序。例如,我们将讨论基于首先对字符串后缀进行排序的算法,用于查找一组字符串中的最长公共前缀以及给定字符串中的最长重复子字符串。
练习
考虑
String的compareTo()方法的以下实现。第三行如何提高效率?public int compareTo(String t) { String s = this; if (s == t) return 0; // this line int n = Math.min(s.length(), t.length()); for (int i = 0; i < n; i++) { if (s.charAt(i) < t.charAt(i)) return -1; else if (s.charAt(i) > t.charAt(i)) return +1; } return s.length() - t.length(); }解决方案:如果
s和t是对同一字符串的引用,则避免直接比较单个字符。批评下面的类实现,该类旨在表示客户账户余额。为什么
compareTo()是Comparable接口的一个有缺陷的实现?public class Customer implements Comparable<Customer> { private String name; private double balance; public int compareTo(Customer that) { if (this.balance < that.balance - 0.005) return -1; if (this.balance > that.balance + 0.005) return +1; return 0; } }解决方案:它违反了
Comparable合同。可能a.compareTo(b)和b.compareTo(c)都为 0,但a.compareTo(c)为正(或负)。解释为什么选择排序不稳定。
解决方案。 它交换非相邻元素。在下面的示例中,第一个 B 被交换到第二个 B 的右侧。
![选择排序不稳定]()
编写一个程序 Frequency.java,从标准输入读取字符串,并按频率降序打印每个字符串出现的次数。
创造性问题
调度。 编写一个程序 SPT.java,从标准输入读取作业名称和处理时间,并打印一个最小化平均完成时间的调度,如文本中所述。
负载平衡。 编写一个程序 LPT.java,将整数 M 作为命令行参数,从标准输入读取 N 个作业名称和处理时间,并打印一个调度分配作业给 M 个处理器,以近似最小化最后一个作业完成的时间,如文本中所述。
备注。 结果解决方案保证在最佳解决方案的 33%之内(实际上为 4/3 - 1/(3N))。
按反向域排序。 编写一个数据类型 Domain.java,表示域名,包括一个适当的
compareTo()方法,其中自然顺序是反向域名顺序。例如,cs.princeton.edu的反向域是edu.princeton.cs。这对于 Web 日志分析很有用。编写一个客户端,从标准输入读取域名,并按排序顺序打印反向域。垃圾邮件活动。 要发起非法的垃圾邮件活动,您有一个来自各种域的电子邮件地址列表(即在@符号后面的电子邮件地址部分)。为了更好地伪造寄件人地址,您希望从同一域的另一个用户发送电子邮件。例如,您可能想要伪造从 wayne@princeton.edu 发送到 rs@princeton.edu 的电子邮件。您将如何处理电子邮件列表以使此成为一个高效的任务?
解决方案。 首先按照反向域排序。
公正选举。 为了防止对字母表末尾出现的候选人产生偏见,加利福尼亚州通过以下顺序对其 2003 年州长选票上出现的候选人进行排序:
R W Q O J M V A H B S G Z X N T C I E K U P D Y F L创建一个数据类型 California.java,其中这是自然顺序。编写一个客户端,根据此顺序对字符串进行排序。假设每个字符串仅由大写字母组成。
肯德尔距离。 编写一个程序 KendallTau.java,以线性对数时间计算两个排列之间的肯德尔距离。
**稳定的优先队列。**开发一个稳定的优先队列实现 StableMinPQ.java(返回以插入顺序返回重复键)。
**平面上的点。**为 Point2D.java 数据类型编写三个
static静态比较器,一个按照它们的 x 坐标比较点,一个按照它们的 y 坐标比较点,一个按照它们与原点的距离比较点。为 Point2D 数据类型编写两个非静态比较器,一个按照它们到指定点的距离比较,一个按照它们相对于指定点的极角比较。**一维区间数据类型。**为 Interval1D.java 编写三个
static比较器,一个按照它们的左端点比较区间,一个按照它们的右端点比较区间,一个按照它们的长度比较区间。**按名称对文件进行排序。**编写一个程序 FileSorter.java,该程序接受一个目录名称作为命令行输入,并按文件名打印出当前目录中的所有文件。提示:使用java.io.File数据类型。
**博纳定理。**真或假:如果对矩阵的每一列进行排序,然后对每一行进行排序,那么列仍然是有序的。解释你的答案。
答案。正确。
**不同值。**编写一个程序 Distinct.java,它接受整数 M、N 和 T 作为命令行参数,然后使用文本中给出的代码执行以下实验的 T 次试验:生成 0 到 M-1 之间的 N 个随机整数值,并计算生成的不同值的数量。将程序运行 T = 10 和 N = 10³、10⁴、10⁵ 和 10⁶,其中 M = 1/2 N、N 和 2N。概率论表明,不同值的数量应该约为 M(1 - e^(-alpha)),其中 alpha = N/M—打印一个表格来帮助您确认您的实验验证了这个公式。
Web 练习
**计数器数据类型。**修改 Counter.java,使其实现
Comparable接口,通过计数比较计数器。**成绩数据类型。**编写一个程序 Grade.java 来表示成绩的数据类型(A、B+等)。它应该使用 GPA 对成绩进行自然排序,实现
Comparable接口。**学生数据类型。**编写一个数据类型 Student.java,表示大学课程中的学生。每个学生应该有一个登录名(String)、一个部分号(整数)和一个成绩(Grade)。
**不区分大小写的顺序。**编写一个代码片段,读取一系列字符串并按升序排序,忽略大��写。
String[] a = new String[N]; for (int i = 0; i < N. i++) { a[i] = StdIn.readString(); } Arrays.sort(a, String.CASE_INSENSITIVE_ORDER);**不区分大小写的比较器。**实现自己版本的比较器
String.CASE_INSENSITIVE_ORDER。public class CaseInsensitive implements Comparator<String> { public int compare(String a, String b) { return a.compareToIgnoreCase(b); } }**降序字符串比较器。**实现一个比较器,按降序而不是升序对字符串进行排序。
public class Descending implements Comparator<String> { public int compare(String a, String b) { return b.compareToIgnoreCase(a); } }或者,您可以使用
Collections.reverseOrder()。它返回一个Comparator,它施加实现Comparable接口的对象的自然顺序的反向排序。**按非英语字母表排序字符串。**编写一个程序,根据非英语字母表对字符串进行排序,包括重音符号、分音符号和像西班牙语中的 ch 这样的预组合字符。
提示:使用 Java 的java.text.Collator API。例如,在 UNICODE 中,
Rico在Réal之前按字典顺序出现,但在法语中,Réal首先出现。import java.util.Arrays; import java.text.Collator; ... Arrays.sort(words, Collator.getInstance(Locale.FRENCH));史密斯规则。 在供应链管理中出现了以下问题。你有一堆工作要在一台机器上安排。(给出例子。)工作 j 需要 p[j]单位的处理时间。工作 j 有一个正权重 w[j],表示其相对重要性 - 将其视为存储原材料的库存成本为工作 j 存储 1 个时间单位。如果工作 j 在时间 t 完成处理,那么它的��本为 t * w[j]美元。目标是安排工作的顺序,以最小化每个工作的加权完成时间之和。编写一个程序
SmithsRule.java,它从命令行参数 N 和由它们的处理时间 p[j]和权重 w[j]指定的 N 个工作列表中读取,并输出一个最佳的处理工作顺序。提示: 使用史密斯规则:按照处理时间与权重比率的顺序安排工作。这种贪婪规则事实证明是最优的。押韵的词。 对于你的诗歌课程,你想要列出一张押韵词的列表。完成这个任务的一种简单方法如下:
将一个单词字典读入一个字符串数组中。
将每个单词的字母倒转,例如,
confound变为dnuofnoc。对结果数组中的单词进行排序。
将每个单词的字母倒转回原始状态。
现在单词
confound将会与astound和compound等单词相邻。编写一个程序 Rhymer.java,从标准输入中读取一系列单词,并按照上述指定的顺序打印它们。现在重复一遍,但使用一个自定义的
Comparator,按从右到左的字典顺序排序。众数。 给出一个 O(N log N)的算法,用于计算序列 N 个整数中出现最频繁的值。
最接近的 1 维对。 给定一个包含 N 个实数的序列,找到值最接近的整数对。给出一个 O(N log N)的算法。
最远的 1 维对。 给定一个包含 N 个实数的序列,找到值最远的整数对。给出一个 O(N)的算法。
具有许多重复项的排序。 假设你有一个包含 N 个元素的序列,其中最多有 log N 个不同的元素。描述如何在 O(N log log N)时间内对它们进行排序。
几乎有序。 给定一个包含 N 个元素的数组,每个元素最多离其目标位置 k 个位置,设计一个能在 O(N log k)时间内排序的算法。
对链表进行排序。 给定一个包含 N 个元素的单链表,如何在保证 O(N log N)时间内、稳定地、且只使用 O(1)额外空间的情况下对其进行排序?
Goofysort(Jim Huggins)。 论证 Goofy.java 按升序对数组进行排序。作为要排序的项目数量 N 的函数,最佳情况运行时间是多少?作为要排序的项目数量 N 的函数,最坏情况运行时间是多少?
令人愉悦的区间。 给定一个包含 N 个非负整数的数组(代表一个人每天的情感值),一个区间的幸福度是该区间中值的总和乘以该区间中最小的整数。设计一个 O(N log N)的分治算法来找到最幸福的区间。
解决方案。 这里是一个归并排序风格的解决方案。
将元素分为中间部分:a[l..m-1],a[m],a[m+1..r]
递归地计算左半部分中的最佳区间
递归地计算右半部分中的最佳区间
计算包含 a[m]的最佳区间
返回三个区间中最佳的一个为了效率的关键步骤是在线性时间内计算包含
a[m]的最佳区间。这里是一个贪婪的解决方案:如果包含a[m]的最佳区间只包含一个元素,那就是a[m]。如果包含多于一个元素,那么必须包含a[m-1]和a[m+1]中较大的一个,所以将其添加到区间中。重复这个过程,以此类推。返回通过这个过程构建的任何大小的最佳区间。
Equality detector. 假设你有 N 个元素,并且想确定至少有 N/2 个元素相等。假设你只能执行相等性测试操作。设计一个算法,在 O(N log N) 次相等性测试中找到一个代表元素(如果存在的话)。提示:分治法。注意:也可以在 O(N) 次测试中完成。
Maxima. 给定平面上的 n 个点集,点 (xi, yi) 支配点 (xj, yj) 如果 xi > xj 并且 yi > yj。极大值是一个不被集合中任何其他点支配的点。设计一个 O(n log n) 的算法来找到所有极大值。应用:在 x ��上是空间效率,在 y 轴上是时间效率。极大值是有用的算法。提示:根据 x 坐标升序排序;从右到左扫描,记录迄今为止看到的最高 y 值,并将其标记为极大值。
Min and max. 给定一个包含 N 个元素的数组,尽可能少地比较找到最小值和最大值。暴力法:找到最大值(N-1 次比较),然后找到剩余元素的最小值(N-2 次比较)。
Solution 1. 分治法:在每一半中找到最小值和最大值(2T(N/2) 次比较),返回 2 的最小值和 2 的最大值(2 次比较)。T(1) = 0,T(2) = 1,T(N) = 2T(N/2) + 2。递归解:T(N) = ceil(3N/2) - 2。
Solution 2. 将元素分成一对一对,并比较每对中的两个元素。将最小的元素放在 A 中,最大的元素放在 B 中。如果 n 是奇数,将元素 n 放在 A 和 B 中。这需要 floor(n/2) 次比较。现在直接计算 A 中的最小值(ceil(n/2) - 1 次比较)和 B 中的最大值(ceil(N/2) - 1 次比较)。[事实上,这是最佳的解决方案。]
Sorting by reversals. [ Mihai Patrascu] 给定一个数组 a[1..n],使用以下类型的操作进行排序:选择两个索引 i 和 j,并反转 a[i..j] 中的元素。这个操作的成本为 j-i+1。目标:O(n log² n)。
L1 norm. 平面上有 N 个电路元件。你需要沿电路运行一根特殊的导线(平行于 x 轴)。每个电路元件必须连接到特殊导线。你应该把特殊导线放在哪里?提示:中位数最小化 L1 范数。
Median given two sorted arrays. 给定大小为 N[1] 和 N[2] 的两个已排序数组,以 O(log N) 时间找到所有元素的中位数,其中 N = N[1] + N[2]。或者在 O(log k) 时间内找到第 k 大的元素。
Three nearby numbers in an array. 给定一个浮点数数组
a[],设计一个线性对数时间复杂度的算法,找到三个不同的整数 i, j, 和 k,使得 |a[i] - a[j]| + |a[j] - a[k]| + |a[k] - a[i]| 最小。Hint: 如果 a[i] ⇐ a[j] ⇐ a[k],那么 |a[i] - a[j]| + |a[j] - a[k]| + |a[k] - a[i]| = 2 (a[k] - a[i])。
Three nearby numbers in three arrays. 给定三个浮点数数组
a[],b[], 和c[],设计一个线性对数时间复杂度的算法,找到三个整数 i, j, 和 k,使得 |a[i] - b[j]| + |b[j] - c[k]| + |c[k] - a[i]| 最小。Minimum dot product. 给定相同长度的两个向量,找到两个向量的点积尽可能小的排列。
Two-sum. 给定一个包含 N 个整数的数组,设计一个线性对数时间复杂度的算法,找到一对整数,使它们的和最接近零。
Solution: 按绝对值排序,最佳对现在是相邻的。
3-sum in quadratic time. 3-sum 问题是在整数数组中找到和最接近零的三元组。描述一个使用线性空间和二次时间的解决方案。
Hint:解决以下子问题。给定 N 个整数的排序列表和目标整数 x,在线性时间内确定最接近 x 的两个整数。
Bandwidth. 给定带宽要求的区间,找到最大带宽需求(以及需要该最大带宽的区间)。
解决方案。 按开始时间对区间进行排序;按照这个顺序将区间插入 PQ,但使用结束时间作为键。在插入下一个区间之前,比较其开始时间与 PQ 上最小区间的结束时间:如果大于,删除 PQ 上的最小区间。始终跟踪 PQ 上的累积带宽。
时间戳。 给定 N 个时间戳,当文件从 Web 服务器请求时,找到没有文件到达的最长时间间隔。解决方案:按时间戳排序。扫描排序列表以识别最大间隙。 (与空闲时间相同。)
票务范围。 给定一个形式为 A1、A2、A11、A10、B7、B9、B8、B3 的票务座位列表,找到最大的非空相邻座位块,例如,A3-A9。 (与空闲时间相同。)
十进制主导。 给定一个具有 N 个可比较键的数组,设计一个算法来检查是否有一个值出现的次数超过 N/10 次。你的算法应该在期望的线性时间内运行。
解决方案。 使用快速选择找到第 N/10 大的值;检查它是否是主导值;如果不是,在具有 9N/10 个值的子数组中递归。
或者,使用 9 个计数器。
局部最小和最大。 给定 N 个不同的可比较项,重新排列它们,使得每个内部项要么大于其前后两项,要么小于其前后两项。
提示:对前半部分和后半部分进行排序和交错。
h 指数。 给定一个由 N 个正整数组成的数组,它的h 指数是最大的整数h,使得数组中至少有h个条目大于或等于h。设计一个算法来计算数组的h指数。
提示:中位数或类似快速排序的分区和分治。
软件版本号。 定义一个比较器,比较两个版本号(例如 1.2.32 和 1.2.5)的时间顺序。假设版本号是仅由十进制数字和.字符组成的字符串。.字符分隔字段;它不是小数点。
稳定的选择排序。 你需要做什么修改才能使选择排序稳定?
解决方案:首先,在找到最小剩余键时,始终选择最左边的条目;其次,不是用一次交换将最小键移动到最前面,而是将所有大于它的元素向右移动一个位置。
最大数。 给定 n 个正整数,将它们连接起来,使它们形成最大的数。例如,如果数字是 123、12、96 和 921,则结果应该是 9692112312。
解决方案。 定义一个比较器,通过将两个数字连接在一起(例如,对于 96 和 921,比较 96921 与 92196),看哪个字符串在字典顺序上最大。
最大数。 给定三个长度为 n 的数组 A、B 和 C,确定有多少个三元组 a 在 A 中,b 在 B 中,c 在 C 中,使得 a < b < c?
3. 搜索
原文:
algs4.cs.princeton.edu/30searching译者:飞龙
概述。
现代计算和互联网使得大量信息变得可访问。高效搜索这些信息的能力对计算至关重要。本章描述了几十年来在众多应用中证明有效的经典搜索算法。我们使用术语符号表来描述一个抽象机制,我们可以保存信息(一个值),��后通过指定一个键进行搜索和检索。
3.1 基础符号表包括无序和有序的实现,使用数组或链表。
3.2 二叉查找树描述了二叉查找树。
3.3 平衡查找树描述了红黑树,这是一种保证每个符号表操作具有对数性能的数据结构。
3.4 哈希表描述了两种经典的哈希算法:分离链接和线性探测。
3.5 应用介绍了集合数据类型,并包括了符号表和集合的众多应用。
本章的 Java 程序。
以下是本章的 Java 程序列表。点击程序名称以访问 Java 代码;点击参考号以获取简要描述;阅读教材以获取详细讨论。
REF 程序 描述 / JAVADOC - FrequencyCounter.java 频率计数器 3.1 SequentialSearchST.java 顺序查找 3.2 BinarySearchST.java 二分查找 3.3 BST.java 二叉查找树 3.4 RedBlackBST.java 红黑树 3.5 SeparateChainingHashST.java 分离链接哈希表 3.6 LinearProbingHashST.java 线性探测哈希表 - ST.java 有序符号表 - SET.java 有序集合 - DeDup.java 去重 - AllowFilter.java 允许列表过滤器 - BlockFilter.java 阻止列表过滤器 - LookupCSV.java 字典查找 - LookupIndex.java 索引和倒排索引 - FileIndex.java 文件索引 - SparseVector.java 稀疏向量
3.1 基本符号表
原文:
algs4.cs.princeton.edu/31elementary译者:飞龙
符号表。
符号表 的主要目的是将 值 与 键 关联起来。客户端可以将键值对插入符号表,并期望以后能够搜索与给定键关联的值。
API。
这是 API。
我们考虑了几种设计选择,以使我们的实现代码一致、紧凑和有用。
泛型. 我们考虑在不指定正在处理的键和值类型的情况下使用泛型的方法。
重复键. 每个键只关联一个值(表中没有重复键)。当客户端将一个包含该键(和关联值)的键值对放入已经包含该键的表中时,新值将替换旧值。这些约定定义了关联数组抽象,您可以将符号表视为类似于数组的结构,其中键是索引,值是数组条目。
空值. 没有键可以与值
null关联。这个约定直接与我们在 API 中规定的get()应该对不在表中的键返回null相关。这个约定有两个(预期的)后果:首先,我们可以通过测试get()是否返回null来测试符号表是否定义了与给定键关联的值。其次,我们可以使用调用put()时将null作为第二个(值)参数来实现删除。删除. 符号表中的删除通常涉及两种策略之一:惰性删除,其中我们将表中的键与
null关联,然后可能在以后的某个时间删除所有这些键,以及急切删除,其中我们立即从表中删除键。正如刚才讨论的,代码put(key, null)是delete(key)的一个简单(惰性)实现。当我们给出一个(急切)delete()的实现时,它旨在替换此默认值。迭代器.
keys()方法返回一个Iterable<Key>对象,供客户端用于遍历键。键相等性. Java 要求所有对象实现一个
equals()方法,并为标准类型(如Integer、Double和String)以及更复杂类型(如Date、File和URL)提供实现。对于涉及这些类型数据的应用程序,您可以直接使用内置实现。例如,如果x和y是String值,则x.equals(y)为true当且仅当x和y长度相同且在每个字符位置上都相同。在实践中,键可能更复杂,如 Person.java。对于这样的客户定义键,您需要重写equals()。Java 的约定是equals()必须实现一个等价关系:自反性:
x.equals(x)为true。对称性:
x.equals(y)当且仅当y.equals(x)为true时,true。传递性: 如果
x.equals(y)和y.equals(z)为true,那么x.equals(z)也是true。
此外,
equals()必须以Object作为参数,并满足以下属性:一致性: 多次调用
x.equals(y)一致地返回相同的值,前提是没有修改任何对象非空:
x.equals(null)返回false。
最佳实践是使
Key类型不可变,因为否则无法保证一致性。
有序符号表。
在典型应用中,键是Comparable对象,因此存在使用代码a.compareTo(b)来比较两个键a和b的选项。几个符号表实现利用Comparable暗示的键之间的顺序来提供put()和get()操作的高效实现。更重要的是,在这种实现中,我们可以将符号表视为按顺序保留键,并考虑一个定义了许多自然和有用的涉及相对键顺序的操作的显著扩展 API。对于键是Comparable的应用程序,我们实现以下 API:

最小值和最大值。对于一组有序键来说,可能最自然的查询是询问最小和最大的键。我们已经在第 3.4 节讨论优先队列时遇到了这些操作的需求。
下界和上界。给定一个键,通常有必要执行下界操作(找到小于或等于给定键的最大键)和上界操作(找到大于或等于给定键的最小键)。这个命名法来自于实数上定义的函数(实数 x 的下界是小于或等于 x 的最大整数,实数 x 的上界是大于或等于 x 的最小整数)。
排名和选择。确定新键在顺序中的位置的基本操作是排名操作(找到小于给定键的键数)和选择操作(找到具有给定排名的键)。我们已经在第 2.5 节讨论排序应用时遇到了这些操作的需求。
范围查询。有多少个键落在给定范围内?哪些键在给定范围内?回答这些问题的两个参数为
size()和keys()方法在许多应用中非常有用,特别是在大型数据库中。删除最小值和删除最大值。我们的有序符号表 API 添加了基本 API 方法来删除最大和最小键(及其关联的值)。
异常情况。当一个方法应该返回一个键,而表中没有符合描述的键时,我们的约定是抛出异常。
键相等性(重新审视)。在 Java 中的最佳实践是使
compareTo()与所有Comparable类型中的equals()一致。也就是说,对于任何给定Comparable类型中的对象对a和b,应该满足(a.compareTo(b) == 0)和a.equals(b)具有相同的值。
示例客户端。
我们考虑两种客户端:一个测试客户端,用于跟踪算法在小输入上的行为,以及一个性能客户端。
测试客户端。我们符号表实现中的
main()客户端从标准输入中读取一系列字符串,通过将值 i 与输入中的第 i 个键关联来构建符号表,然后打印表。频率计数器。程序 FrequencyCounter.java 是一个符号表客户端,它在标准输入中找到每个字符串(至少具有给定阈值长度的字符)的出现次数,然后遍历键以找到出现最频繁的键。
无序链表中的顺序搜索。
程序 SequentialSearchST.java 实现了一个包含键和值的节点链表的符号表。要实现get(),我们通过列表扫描,使用equals()将搜索键与列表中每个节点中的键进行比较。如果找到匹配项,则返回相关值;如果没有,则返回null。要实现put(),我们也通过列表扫描,使用equals()将客户键与列表中每个节点中的键进行比较。如果找到匹配项,则将与该键关联的值更新为第二个参数中给定的值;如果没有,则创建一个具有给定键和值的新节点,并将其插入列表开头。这种方法称为顺序搜索。
命题 A.
在(无序)链表符号表中,不成功的搜索和插入都使用 N 次比较,在最坏情况下成功的搜索使用 N 次比较。特别是,将 N 个键插入到最初为空的链表符号表中使用 ~N²/2 次比较。
在有序数组中进行二分查找
. 程序 BinarySearchST.java 实现了有序符号表 API。底层数据结构是两个并行数组,键按顺序保存。实现的核心是rank()方法,它返回小于给定键的键数。对于get(),rank 告诉我们如果键在表中,则键应该被找到的确切位置(如果不在表中,则不在表中)。对于put(),rank 告诉我们当键在表中时精确更新值的位置,当键不在表中时精确放置键的位置。我们将所有较大的键向后移动一个位置以腾出空间(从后向前工作),并将给定的键和值插入到各自数组中的适当位置。
二分查找. 我们将键保持在有序数组中的原因是为了可以使用数组索引来显著减少每次搜索所需的比��次数,使用一种著名的经典算法称为二分查找。基本思想很简单:我们维护索引到排序键数组的指示符,限定可能包含搜索键的子数组。要搜索,我们将搜索键与子数组中间的键进行比较。如果搜索键小于中间键,则在子数组的左半部分搜索;如果搜索键大于中间键,则在子数组的右半部分搜索;否则中间键等于搜索键。
![等级]()
其他操作. 由于键保持在有序数组中,大多数基于顺序的操作都是紧凑且简单的。
命题 B.
在具有 N 个键的有序数组中进行二分查找,在最坏情况下搜索(成功或失败)不会超过 lg N + 1 次比较。
命题 C.
将新键插入有序数组中在最坏情况下使用 ~ 2N 个数组访问,因此将 N 个键插入到最初为空的表中在最坏情况下使用 ~ N² 个数组访问。
练习
编写一个客户端程序 GPA.java,创建一个将字母等级映射到数字分数的符号表,如下表所示,然后从标准输入读取字母等级列表,并计算并打印 GPA(对应等级的数字分数的平均值)。
A+ A A- B+ B B- C+ C C- D F 4.33 4.00 3.67 3.33 3.00 2.67 2.33 2.00 1.67 1.00 0.00开发一个符号表实现 ArrayST.java,它使用(无序)数组作为底层数据结构来实现我们的基本符号表 API。
为 SequentialSearchST.java 实现
size()、delete()和keys()。为 BinarySearchST.java 实现
delete()方法。为 BinarySearchST.java 实现
floor()方法。
创意问题
**测试客户端。**编写一个测试客户端 TestBinarySearchST.java,用于测试
min()、max()、floor()、ceiling()、select()、rank()、deleteMin()、deleteMax()和keys()的实现。**认证。**在 BinarySearchST.java 中添加
assert语句,以检查每次插入和删除后的算法不变性和数据结构完整性。例如,每个索引i应始终等于rank(select(i)),并且数组应始终保持有序。
网页练习
**电话号码数据类型。**编写一个实现美国电话号码的数据类型 PhoneNumber.java,包括一个
equals()方法。**学生数据类型。**编写一个实现具有姓名和班级的学生的数据类型 Student.java,包括一个
equals()方法。
3.2 二叉搜索树
原文:
algs4.cs.princeton.edu/32bst译者:飞龙
我们研究了一种符号表实现,它将链表中的插入灵活性与有序数组中的搜索效率结合起来。具体来说,每个节点使用两个链接会导致基于二叉搜索树数据结构的高效符号表实现,这被认为是计算机科学中最基本的算法之一。
定义. 二叉搜索树(BST)是一种二叉树,其中每个节点都有一个Comparable键(和一个相关联的值),并满足一个限制条件,即任何节点中的键都大于该节点左子树中所有节点的键,且小于该节点右子树中所有节点的键。
基本实现。
程序 BST.java 使用二叉搜索树实现了有序符号表 API。我们定义一个内部私有类来定义 BST 中的节点。每个节点包含一个��、一个值、一个左��接、一个右链接和一个节点计数。左链接指向具有较小键的项目的 BST,右链接指向具有较大键的项目的 BST。实例变量N给出了根节点下子树中的节点计数。这个字段有助于实现各种有序符号表操作,你将看到。
搜索. 一个递归算法用于在 BST 中搜索键,直接遵循递归结构:如果树为空,则搜索未命中;如果搜索键等于根节点的键,则搜索命中。否则,我们在适当的子树中搜索(递归)。递归的
get()方法直接实现了这个算法。它以一个节点(子树的根)作为第一个参数,以一个键作为第二个参数,从树的根和搜索键开始。![在 BST 中搜索]()
插入. 插入比搜索实现稍微困难一些。实际上,对于树中不存在的键的搜索会在一个空链接处结束,我们需要做的就是用包含键的新节点替换该链接。递归的
put()方法使用了与递归搜索相似的逻辑来完成这个任务:如果树为空,我们返回一个包含键和值的新节点;如果搜索键小于根节点的键,我们将左链接设置为将键插入左子树的结果;否则,我们将右链接设置为将键插入右子树的结果。![在 BST 中插入]()
分析。
算法在二叉搜索树上的运行时间取决于树的形状,而树的形状又取决于键的插入顺序。
对于许多应用程序来说,使用以下简单模型是合理的:我们假设键是(均匀)随机的,或者等效地说,它们是以随机顺序插入的。
命题。
在由 N 个随机键构建的 BST 中,搜索命中平均需要约 2 ln N(约 1.39 lg N)次比较。
命题。
在由 N 个随机键构建的 BST 中,插入和搜索未命中平均需要约 2 ln N(约 1.39 lg N)次比较。
下面的可视化展示了以随机顺序向二叉搜索树中插入 255 个键的结果。它显示了键的数量(N)、从根到叶子节点的路径上节点的最大数量(max)、从根到叶子节点的路径上节点的平均数量(avg)、在完全平衡的二叉搜索树中从根到叶子节点的路径上节点的平均数量(opt)。
<media/bst-255random.mov>
您的浏览器不支持视频标签。
基于顺序的方法和删除。
二叉搜索树被广泛使用的一个重要原因是它们可以让我们保持键有序。因此,它们可以作为实现有序符号表 API 中众多方法的基础。
最小值和最大值。 如果根节点的左链接为空,则二叉搜索树中的最小键是根节点的键;如果左链接不为空,则二叉搜索树中的最小键是左链接引用的节点为根的子树中的最小键。查找最大键类似,向右移动而不是向左移动。
下取整和上取整。 如果给定的键 key 小于二叉搜索树根节点的键,则 key 的下取整(小于或等于 key 的二叉搜索树中的最大键)必须在左子树中。如果 key 大于根节点的键,则 key 的下取整可能在右子树中,但只有在右子树中存在小于或等于 key 的键时才可能;如果没有(或者 key 等于根节点的键),则根节点的键就是 key 的下取整。查找上取整类似,交换右子树和左子树。
选择。 假设我们寻找排名为 k 的键(即 BST 中恰好有 k 个其他键比它小)。如果左子树中的键数 t 大于 k,我们在左子树中查找排名为
k的键;如果 t 等于 k,我们返回根节点的键;如果 t 小于 k,我们在右子树中查找排名为 k - t - 1 的键。![二叉搜索树中的下取整]()
![二叉搜索树中的选择]()
排名。 如果给定的键等于根节点的键,则返回左子树中键数 t;如果给定的键小于根节点的键,则返回左子树中键的排名;如果给定的键大于根节点的键,则返回 t 加一(计算根节点的键)再加上右子树中键的排名。
删除最小值和最大值。 对于删除最小值,我们向左移动直到找到一个具有空左链接的节点,然后用其右链接替换指向该节点的链接。对于删除最大值,对称方法适用。
删除。 我们可以类似地删除任何只有一个子节点(或没有子节点)的节点,但是如何删除具有两个子节点的节点呢?我们剩下两个链接,但是父节点只有一个位置可以放置它们中的一个。1962 年 T. Hibbard 首次提出的解决这个困境的方法是通过用其后继替换节点 x 来删除节点 x。因为
x有一个右子节点,其后继是其右子树中具有最小键的节点。替换保持了树中的顺序,因为在x.key和后继的键之间没有其他键。我们通过四个(!)简单的步骤完成了用其后继替换 x 的任务:在
t中保存要删除的节点的链接将
x设置为其后继min(t.right)。将
x的右链接(应指向包含所有大于x.key的键的二叉搜索树)设置为deleteMin(t.right),即删除后包含所有大于x.key的键的二叉搜索树的链接。将
x的左链接(原本为空)设置为t.left(所有小于被删除键和其后继的键)。
![删除二叉查找树中的最小值]()
![二叉查找树中的 Hibbard 删除]()
尽管这种方法能够完成任务,但它有一个缺点,在某些实际情况下可能会导致性能问题。问题在于使用后继者是任意的,而不是对称的。为什么不使用前任者呢?
每个二叉查找树包含 150 个节点。然后我们通过 Hibbard 删除方法重复删除和随机插入键。二叉查找树向左倾斜。
<media/hibbard-150random.mov>
您的浏览器不支持视频标签。
范围搜索。 为了实现返回给定范围内键的
keys()方法,我们从一个基本的递归二叉查找树遍历方法开始,称为中序遍历。为了说明这种方法,我们考虑按顺序打印二叉查找树中所有键的任务。为此,首先打印左子树中的所有键(根据二叉查找树的定义,这些键小于根键),然后打印根键,然后打印右子树中的所有键(根据二叉查找树的定义,这些键大于根键)。-
private void print(Node x) { if (x == null) return; print(x.left); StdOut.println(x.key); print(x.right); }要实现带有两个参数的
keys()方法,我们修改这段代码,将在范围内的每个键添加到一个Queue中,并跳过不能包含范围内键的子树的递归调用。
建议。
搜索、插入、查找最小值、查找最大值、floor、ceiling、rank、select、删除最小值、删除最大值、删除和范围计数操作在最坏情况下都需要时间与树的高度成比例。
练习
给出五种键
A X C S E R H的排序方式,当插入到一个初始为空的二叉查找树时,产生最佳情况的树。解决方案。 任何首先插入 H;在 A 和 E 之前插入 C;在 R 和 X 之前插入 S 的序列。
在 BST.java 中添加一个计算树高度的方法
height()。开发两种实现:一个递归方法(需要与树高成比例的线性时间和空间),以及像size()那样为树中的每个节点添加一个字段的方法(需要线性空间和每次查询的常数时间)。为了测试文本中给出的
min()、max()、floor()、ceiling()、select()、rank()、deleteMin()、deleteMax()和keys()的实现,编写一个测试客户端 TestBST.java。给出二叉查找树的
get()、put()和keys()的非递归实现。*解决方案:*NonrecursiveBST.java
创意问题
完美平衡。 编写一个程序 PerfectBalance.java,将一组键插入到一个初始为空的二叉查找树中,使得生成的树等同于二叉搜索,即对于二叉查找树中任何键的搜索所做的比较序列与二叉搜索对相同键集的比较序列相同。
提示:将中位数放在根节点,并递归构建左子树和右子树。
认证。 在 BST.java 中编写一个名为
isBST()的方法,该方法以一个Node作为参数,并在参数节点是二叉查找树根节点时返回true,否则返回false。子树计数检查。 在 BST.java 中编写一个递归方法
isSizeConsistent(),该方法以一个Node作为参数,并在该节点根的数据结构中N字段一致时返回true,否则返回false。选择/排名检查。 在 BST.java 中编写一个名为
isRankConsistent()的方法,检查对于所有i从0到size() - 1,是否i等于rank(select(i)),以及对于二叉查找树中的所有键,是否key等于select(rank(key))。
Web 练习
伟大的树-列表递归问题。 二叉搜索树和循环双向链表在概念上都是由相同类型的节点构建的 - 一个数据字段和两个指向其他节点的引用。 给定一个二叉搜索树,重新排列引用,使其成为一个循环双向链表(按排序顺序)。 尼克·帕兰特将其描述为有史以来设计的最整洁的递归指针问题之一。 提示:从左子树创建一个循环链接列表 A,从右子树创建一个循环链接列表 B,并使根节点成为一个节点的循环链接列表。 然后合并这三个列表。
BST 重建。 给定 BST 的前序遍历(不包括空节点),重建树。
真或假。 给定 BST,设 x 是叶节点,y 是其父节点。 那么 y 的键要么是大于 x 的键中最小的键,要么是小于 x 的键中最大的键。 答案:真。
真或假。 设 x 是 BST 节点。 可以通过沿着树向根遍历直到遇到具有非空右子树的节点(可能是 x 本身);然后在右子树中找到最小键来找到 x 的下一个最大键(x 的后继)。
具有恒定额外内存的树遍历。 描述如何使用恒定额外内存(例如,没有函数调用堆栈)执行中序树遍历。
提示:在树下行的过程中,使子节点指向父节点(并在树上行的过程中反转它)。
反转 BST。 给定一个标准 BST(其中每个键都大于其左子树中的键,小于其右子树中的键),设计一个线性时间算法将其转换为反转 BST(其中每个键都小于其左子树中的键,大于其右子树中的键)。 结果树形状应对称于原始形状。
BST 的层序遍历重建。 给定一系列键,设计一个线性时间算法来确定它是否是某个 BST 的层序遍历(并构造 BST 本身)。
在 BST 中查找两个交换的键。 给定一个 BST,其中两个节点中的两个键已被交换,找到这两个键。
解决方案。 考虑 BST 的中序遍历 a[]。 有两种情况需要考虑。 假设只有一个索引 p,使得 a[p] > a[p+1]。 然后交换键 a[p]和 a[p+1]。 否则,存在两个索引 p 和 q,使得 a[p] > a[p+1]和 a[q] > a[q+1]。 假设 p < q。 然后,交换键 a[p]和 a[q+1]。
3.3 平衡搜索树
原文:
algs4.cs.princeton.edu/33balanced译者:飞龙
本节正在大力施工中。
我们在本节介绍了一种类型的二叉搜索树,其中成本保证为对数。我们的树几乎完美平衡,高度保证不会大于 2 lg N。
2-3 搜索树。
获得我们需要保证搜索树平衡的灵活性的主要步骤是允许我们树中的节点保存多个键。
定义。
一个2-3 搜索树是一棵树,要么为空,要么:
一个2 节点,带有一个键(和相关值)和两个链接,一个指向具有较小键的 2-3 搜索树的左链接,一个指向具有较大键的 2-3 搜索树的右链接
一个3 节点,带有两个键(和相关值)和三个链接,一个指向具有较小键的 2-3 搜索树的左链接,一个指向具有节点键之间的键的 2-3 搜索树的中间链接,一个指向具有较大键的 2-3 搜索树的右链接。
一个完美平衡的 2-3 搜索树(或简称 2-3 树)是指其空链接与根之间的距离都相同。
搜索。 要确定 2-3 树中是否存在一个键,我们将其与根处的键进行比较:如果它等于其中任何一个键,则有一个搜索命中;否则,我们跟随从根到对应于可能包含搜索键的键值区间的子树的链接,然后在该子树中递归搜索。
![在 2-3 树中搜索]()
插入到 2 节点中。 要在 2-3 树中插入新节点,我们可能会进行一次不成功的搜索,然后挂接到底部的节点,就像我们在二叉搜索树中所做的那样,但新树不会保持完美平衡。如果搜索终止的节点是一个 2 节点,要保持完美平衡很容易:我们只需用包含其键和要插入的新键的 3 节点替换该节点。
![在 2-3 树中插入到 2 节点中]()
插入到由单个 3 节点组成的树中。 假设我们想要插入到一个仅由单个 3 节点组成的微小 2-3 树中。这样的树有两个键,但在其一个节点中没有新键的空间。为了能够执行插入操作,我们暂时将新键放入一个4 节点中,这是我们节点类型的自然扩展,具有三个键和四个链接。创建 4 节点很方便,因为很容易将其转换为由三个 2 节点组成的 2-3 树,其中一个带有中间键(在根处),一个带有三个键中最小的键(由根的左链接指向),一个带有三个键中最大的键(由根的右链接指向)。
![插入到由单个 3 节点组成的 2-3 树中]()
插入到父节点为 2 节点的 3 节点中。 假设搜索在底部结束于其父节点为 2 节点的 3 节点。在这种情况下,我们仍然可以为新键腾出空间,同时保持树的完美平衡,方法是制作一个临时的 4 节点,然后按照刚才描述的方式拆分 4 节点,但是,而不是创建一个新节点来保存中间键,将中间键移动到节点的父节点。
![在父节点为 2 节点的 3 节点中插入到 2-3 树中]()
插入到父节点为 3 节点的 3 节点中。 现在假设搜索结束于父节点为 3 节点的节点。同样,我们制作一个临时的 4 节点,然后将其拆分并将其中间键插入父节点。父节点是 3 节点,所以我们用刚刚拆分的临时新 4 节点替换它,其中包含来自 4 节点拆分的中间键。然后,我们对该节点执行完全相同的转换。也就是说,我们拆分新的 4 节点并将其中间键插入其父节点。扩展到一般情况很明显:我们沿着树向上移动,拆分 4 节点并将它们的中间键插入它们的父节点,直到达到一个 2 节点,我们用一个不需要进一步拆分的 3 节点替换它,或者直到达到根节点处的 3 节点。
![在父节点为 3 节点的 2-3 树中插入 3 节点]()
拆分根节点。 如果从插入点到根节点沿着整个路径都是 3 节点,我们最终会在根节点处得到一个临时的 4 节点。在这种情况下,我们将临时的 4 节点拆分为三个 2 节点。
![在 2-3 树中拆分根节点]()
局部转换。 2-3 树插入算法的基础是所有这些转换都是纯粹局部的:除了指定的节点和链接之外,不需要检查或修改 2-3 树的任何部分。每次转换更改的链接数量受到小常数的限制。这些转换中的每一个都将一个键从 4 节点传递到树中的父节点,然后相应地重构链接,而不触及树的任何其他部分。
全局属性。 这些局部转换保持了树是有序和平衡的全局属性:从根到任何空链接的路径上的链接数量是相同的。
命题。
在具有 N 个键的 2-3 树中,搜索和插入操作保证最多访问 lg N 个节点。
然而,我们只完成了实现的一部分。虽然可以编写代码来对表示 2 和 3 节点的不同数据类型执行转换,但我们描述的大部分任务在这种直接表示中实现起来很不方便。
红黑 BST。
刚刚描述的 2-3 树插入算法并不难理解。我们考虑一种简单的表示法,称为红黑 BST,可以自然地实现。
编码 3 节点。 红黑 BST 背后的基本思想是通过从标准 BST(由 2 节点组成)开始,并添加额外信息来编码 3 节点,从而对 2-3 树进行编码。我们认为链接有两种不同类型:红色链接,将两个 2 节点绑在一起表示 3 节点,以及黑色链接,将 2-3 树绑在一起。具体来说,我们将 3 节点表示为由单个向左倾斜的红色链接连接的两个 2 节点。我们将以这种方式表示 2-3 树的 BST 称为红黑 BST。
使用这种表示的一个优点是,它允许我们在不修改的情况下使用我们的
get()代码进行标准 BST 搜索。![在红黑 BST 中编码 3 节点]()
1-1 对应关系。 给定任何 2-3 树,我们可以立即推导出相应的红黑 BST,只需按照指定的方式转换每个节点即可。反之,如果我们在红黑 BST 中水平绘制红色链接,所有空链接距离根节点的距离相同,然后将由红色链接连接的节点合并在一起,结果就是一个 2-3 树。
![左倾红黑 BST 之间的 1-1 对应关系]()
红黑 BST 和 2-3 树](../Images/2a82ce5ba078c8217adc45ad5e5d7a47.png)
颜色表示。 由于每个节点只被一个链接(从其父节点)指向,我们通过在节点中添加一个
boolean实例变量颜色来编码链接的颜色,如果来自父节点的链接是红色,则为true,如果是黑色,则为false。按照惯例,空链接为黑色。![红黑 BST 中的颜色表示]()
旋转。 我们将考虑的实现可能允许右倾斜的红链接或操作中连续两个红链接,但它总是在完成之前纠正这些条件,通过巧妙使用称为旋转的操作来切换红链接的方向。首先,假设我们有一个需要旋转以向左倾斜的右倾斜红链接。这个操作称为左旋转。实现将左倾斜的红链接转换为右倾斜的右旋转操作等同于相同的代码,左右互换。
翻转颜色。 我们将考虑的实现也可能允许黑色父节点有两个红色子节点。颜色翻转操作将两个红色子节点的颜色翻转为黑色,并将黑色父节点的颜色翻转为红色。
![红黑 BST 中的左旋转]()
![红黑 BST 中的右旋转]()
![红黑 BST 中的颜色翻转]()
插入到单个 2 节点中。
在底部插入到 2 节点。
在具有两个键的树中(在 3 节点中)插入。
保持根节点为黑色。
在底部插入到 3 节点。
将红链接向上传递树。
实现。
程序 RedBlackBST.java 实现了一个左倾斜的红黑 BST。程序 RedBlackLiteBST.java 是一个更简单的版本,只实现了 put、get 和 contains。
删除。
命题。
具有 N 个节点的红黑 BST 的高度不超过 2 lg N。
命题。
在红黑 BST 中,以下操作在最坏情况下需要对数时间:搜索、插入、查找最小值、查找最大值、floor、ceiling、rank、select、删除最小值、删除最大值、删除和范围计数。
属性。
具有 N 个节点的红黑 BST 中从根到节点的平均路径长度约为~1.00 lg N。
可视化。
以下可视化展示了 255 个键按随机顺序插入到红黑 BST 中。
练习
哪些是合法的平衡红黑 BST?
![合法的平衡红黑 BST]()
解决方案。 (iii) 和 (iv)。 (i) 不平衡,(ii) 不是对称顺序或平衡的。
真或假:如果您将键按递增顺序插入到红黑 BST 中,则树的高度是单调递增的。
解决方案。 真的,请看下一个问题。
描述当按升序插入键构建红黑 BST 时,插入字母
A到K时产生的红黑 BST。然后,描述当按升序插入键构建红黑 BST 时通常会发生什么。解决方案。 以下可视化展示了 255 个键按升序插入到红黑 BST 中。
回答前两个问题,当键按降序插入时的情况。
解决方案。 错误。以下可视化展示了 255 个键按降序插入到红黑 BST 中。
创建一个测试客���端 TestRedBlackBST.java。
创造性问题
认证. 在 RedBlackBST.java 中添加一个方法
is23(),以检查没有节点连接到两个红链接,并且没有右倾斜的红链接。 添加一个方法isBalanced(),以检查从根到空链接的所有路径是否具有相同数量的黑链接。 将这些方法与isBST()结合起来创建一个方法isRedBlackBST(),用于检查树是否是 BST,并且满足这两个条件。旋转的基本定理. 证明任何 BST 都可以通过一系列左旋和右旋转变换为具有相同键集的任何其他 BST。
解决方案概述: 将第一个 BST 中最小的键旋转到根节点沿着向左的脊柱;然后对结果的右子树进行递归,直到得到高度为 N 的树(每个左链接都为 null)。 对第二个 BST 执行相同的操作。 备注:目前尚不清楚是否存在一种多项式时间算法,可以确定将一个 BST 转换为另一个 BST 所需的最小旋转次数(即使对于至少有 11 个节点的 BST,旋转距离最多为 2N - 6)。
删除最小值. 通过保持与文本中给出的向树的左脊柱下移的转换的对应关系,同时保持当前节点不是 2 节点的不变性,为 RedBlackBST.java 实现
deleteMin()操作。删除最大值. 为 RedBlackBST.java 实现
deleteMax()操作。 请注意,涉及的转换与前一个练习中的转换略有不同,因为红链接是向左倾斜的。删除. 为 RedBlackBST.java 实现
delete()操作,将前两个练习的方法与 BST 的delete()操作结合起来。
网络练习
给定一个排序的键序列,描述如何在线性时间内构建包含这些键的红黑 BST。
假设在红黑 BST 中进行搜索,在从根节点开始跟踪 20 个链接后终止,以下划线填写下面关于任何不成功搜索的最佳(整数)界限,您可以从这个事实中推断出来
从根节点至少要遵循 ______ 条链接
从根节点最多需要遵循 _______ 条链接
使用每个节点 1 位,我们可以表示 2、3 和 4 节点。 我们需要多少位来表示 5、6、7 和 8 节点。
子串反转. 给定长度为 N 的字符串,支持以下操作:select(i) = 获取第 i 个字符,并且 reverse(i, j) = 反转从 i 到 j 的子串。
解决方案概述. 在平衡搜索树中维护字符串,其中每个节点记录子树计数和一个反转位(如果从根到节点的路径上存在奇数个反转位,则交换左右子节点的角色)。 要实现 select(i),从根节点开始进行二分搜索,使用子树计数和反转位。 要实现 reverse(i, j),在 select(i)和 select(j)处拆分 BST 以形成三个 BST,反转中间 BST 的位,并使用连接操作将它们重新组合在一起。 旋转时维护子树计数和反转位。
BST 的内存. BST、RedBlackBST 和 TreeMap 的内存使用情况是多少?
解决方案. MemoryOfBSTs.java.
随机化 BST. 程序 RandomizedBST.java 实现了一个随机化 BST,包括删除操作。 每次操作的预期 O(log N)性能。 期望仅取决于算法中的随机性; 它不依赖于输入分布。 必须在每个节点中存储子树计数字段; 每次插入生成 O(log N)个随机数。
命题. 树具有与按随机顺序插入键时相同的分布。
连接. 编写一个函数,该函数以两个随机化 BST 作为输入,并返回包含两个 BST 中元素并集的第三个随机化 BST。 假设没有重复项。
伸展 BST。 程序 SplayBST.java 实现了一个伸展树。
随机队列。 实现一个 RandomizedQueue.java,使得所有操作在最坏情况下都需要对数时间。
具有许多更新的红黑色 BST。 当在红黑色 BST 中执行具有已经存在的键的
put()时,我们的 RedBlackBST.java 会执行许多不必要的isRed()和size()调用。优化代码,以便在这种情况下跳过这些调用。
3.4 哈希表
原文:
algs4.cs.princeton.edu/34hash译者:飞龙
如果键是小整数,我们可以使用数组来实现符号表,通过将键解释为数组索引,以便我们可以将与键 i 关联的值存储在数组位置 i 中。在本节中,我们考虑哈希,这是一种处理更复杂类型键的简单方法的扩展。我们通过进行算术运算将键转换为数组索引来引用键值对。

使用哈希的搜索算法由两个独立部分组成。第一步是计算哈希函数,将搜索键转换为数组索引。理想情况下,不同的键将映射到不同的索引。这种理想通常超出我们的能力范围,因此我们必须面对两个或更多不同键可能哈希到相同数组索引的可能性。因此,哈希搜索的第二部分是处理这种情况的冲突解决过程。
哈希函数。
如果我们有一个可以容纳 M 个键值对的数组,则需要一个函数,可以将任何给定的键转换为该数组的索引:在范围[0, M-1]内的整数。我们寻求一个既易于计算又均匀分布键的哈希函数。
典型例子。 假设我们有一个应用程序,其中键是美国社会安全号码。例如,社会安全号码 123-45-6789 是一个分为三个字段的 9 位数。第一个字段标识发放号码的地理区域(例如,第一个字段为 035 的号码来自罗德岛,第一个字段为 214 的号码来自马里兰),其他两个字段标识个人。有十亿个不同的社会安全号码,但假设我们的应用程序只需要处理几百个键,因此我们可以使用大小为 M = 1000 的哈希表。实现哈希函数的一种可能方法是使用键中的三位数。使用右侧字段中的三位数可能比使用左侧字段中的三位数更可取(因为客户可能不均匀地分布在地理区域上),但更好的方法是使用所有九位数制成一个整数值,然后考虑下面描述的整数的哈希函数。
正整数。 用于哈希整数的最常用方法称为模块化哈希:我们选择数组大小 M 为素数,并且对于任何正整数键 k,计算 k 除以 M 的余数。这个函数非常容易计算(在 Java 中为 k % M),并且在 0 和 M-1 之间有效地分散键。
浮点数。 如果键是介于 0 和 1 之间的实数,我们可能只需乘以 M 并四舍五入到最接近的整数以获得 0 和 M-1 之间的索引。尽管这是直观的,但这种方法有缺陷,因为它给予键的最高有效位更多权重;最低有效位不起作用。解决这种情况的一种方法是使用键的二进制表示进行模块化哈希(这就是 Java 所做的)。
字符串。 模块化哈希也适用于长键,如字符串:我们只需将它们视为巨大的整数。例如,下面的代码计算了一个 String s 的模块化哈希函数,其中 R 是一个小素数(Java 使用 31)。
int hash = 0; for (int i = 0; i < s.length(); i++) hash = (R * hash + s.charAt(i)) % M;复合键。 如果键类型具有多个整数字段,我们通常可以像刚才描述的
String值一样将它们混合在一起。例如,假设搜索键的类型为 USPhoneNumber.java,其中包含三个整数字段:区域(3 位区号)、交换(3 位交换)和分机(4 位分机)。在这种情况下,我们可以计算数字int hash = (((area * R + exch) % M) * R + ext) % M;Java 约定。 Java 帮助我们解决了每种数据类型都需要一个哈希函数的基本问题,要求每种数据类型必须实现一个名为
hashCode()的方法(返回一个 32 位整数)。对象的hashCode()实现必须与equals一致。也就是说,如果a.equals(b)为真,则a.hashCode()必须与b.hashCode()具有相同的数值。如果hashCode()值相同,则对象可能相等也可能不相等,我们必须使用equals()来确定哪种情况成立。将
hashCode()转换为数组索引。 由于我们的目标是一个数组索引,而不是 32 位整数,因此我们在实现中将hashCode()与模块化哈希结合起来,以产生 0 到 M-1 之间的整数,如下所示:private int hash(Key key) { return (key.hashCode() & 0x7fffffff) % M; }该代码掩盖了符号位(将 32 位整数转换为 31 位非负整数),然后通过除以 M 来计算余数,就像模块化哈希一样。
用户定义的
hashCode()。 客户端代码期望hashCode()在可能的 32 位结果值中均匀分散键。也就是说,对于任何对象x,你可以编写x.hashCode(),并且原则上期望以相等的可能性获得 2³² 个可能的 32 位值中的任何一个。Java 为许多常见类型(包括String、Integer、Double、Date和URL)提供了渴望实现此功能的hashCode()实现,但对于您��己的类型,您必须自己尝试。程序 PhoneNumber.java 演示了一种方法:从实例变量中生成整数并使用模块化哈希。程序 Transaction.java 演示了一种更简单的方法:使用实例变量的hashCode()方法将每个转换为 32 位int值,然后进行算术运算。
在为给定数据类型实现良好的哈希函数时,我们有三个主要要求:
它应该是确定性的—相同的键必须产生相同的哈希值。
计算效率应该高。
它应该均匀分布键。
为了分析我们的哈希算法并对其性能提出假设,我们做出以下理想化假设。
假设 J(均匀哈希假设)。
我们使用的哈希函数在 0 和 M-1 之间的整数值之间均匀分布键。
使用分离链接进行哈希。
哈希函数将键转换为数组索引。哈希算法的第二个组成部分是冲突解决:处理两个或更多个要插入的键哈希到相同索引的情况的策略。冲突解决的一个直接方法是为 M 个数组索引中的每一个构建一个键-值对的链表,这些键的哈希值为该索引。基本思想是选择足够大的 M,使得列表足够短,以便通过两步过程进行有效搜索:哈希以找到可能包含键的列表,然后顺序搜索该列表以查找键。
程序 SeparateChainingHashST.java 实现了一个带有分离链接哈希表的符号表。它维护了一个 SequentialSearchST 对象的数组,并通过计算哈希函数来选择哪个SequentialSearchST可以包含键,并然后使用SequentialSearchST中的get()和put()来完成工作。程序 SeparateChainingLiteHashST.java 类似,但使用了一个显式的Node嵌套类。
命题 K。 在具有 M 个列表和 N 个键的分离链接哈希表中,假设 J 下,列表中键的数量在 N/M 的小常数因子范围内的概率极其接近 1。N/M 的小常数因子范围内的概率极其接近 1。 (假设一个理想的哈希函数。)
这个经典的数学结果很有说服力,但它完全依赖于假设 J。然而,在实践中,相同的行为发生。
性质 L. 在具有 M 个列表和 N 个键的分离链接哈希表中,搜索和插入的比较次数(相等测试)与 N/M 成正比。
使用线性探测进行哈希。
实现哈希的另一种方法是将 N 个键值对存储在大小为 M > N 的哈希表中,依赖表中的空条目来帮助解决冲突。这种方法称为开放寻址哈希方法。最简单的开放寻址方法称为线性探测:当发生冲突(当我们哈希到已经被不同于搜索键的键占据的表索引时),我们只需检查表中的下一个条目(通过增加索引)。有三种可能的结果:
键等于搜索键:搜索命中
空位置(索引位置处的空键):搜索未命中
键不等于搜索键:尝试下一个条目

程序 LinearProbingHashST.java 是使用这种方法实现符号表 ADT 的实现。
与分离链接一样,开放寻址方法的性能取决于比率 α = N/M,但我们对其进行了不同的解释。对于分离链接,α 是每个列表的平均项目数,通常大于 1。对于开放寻址,α 是占用的表位置的百分比;它必须小于 1。我们将 α 称为哈希表的负载因子。
命题 M. 在大小为 M 的线性探测哈希表中,N = α M 个键,平均探测次数(在假设 J 下)对于搜索命中约为 ~ 1/2 (1 + 1 / (1 - α)),对于搜索未命中或插入约为 ~ 1/2 (1 + 1 / (1 - α)²)。
问与答。
为什么 Java 在
String的hashCode()中使用 31?它是质数,因此当用户通过另一个数字取模时,它们没有公共因子(除非它是 31 的倍数)。31 也是梅森素数(如 127 或 8191),是一个比某个 2 的幂少 1 的素数。这意味着如果机器的乘法指令很慢,那么取模可以通过一次移位和一次减法完成。
如何从类型为
double的变量中提取位以用��哈希?Double.doubleToLongBits(x)返回一个 64 位的long整数,其位表示与double值x的浮点表示相同。使用
(s.hashCode() % M)或Math.abs(s.hashCode()) % M进行哈希到 0 到 M-1 之间的值有什么问题?如果第一个参数为负数,则
%运算符返回一个非正整数,这将导致数组索引越界错误。令人惊讶的是,绝对值函数甚至可以返回一个负整数。如果其参数为Integer.MIN_VALUE,那么由于生成的正整数无法用 32 位的二进制补码整数表示,这种情况就会发生。这种错误将非常难以追踪,因为它只会发生 40 亿分之一的时间![字符串"polygenelubricants"的哈希码为-2³¹。]
练习
下面的
hashCode()实现是否合法?public int hashCode() { return 17; }解决方案。 是的,但这将导致所有键都哈希到相同的位置,这将导致性能不佳。
分析使用分离链接、线性探测和二叉搜索树(BSTs)处理
double键的空间使用情况。将结果呈现在类似第 476 页上的表中。解决方案。
顺序搜索。 24 + 48N.
SequentialSearch符号表中的Node占用 48 字节的内存(16 字节开销,8 字节键,8 字节值,8 字节下一个,8 字节内部类开销)。SequentialSearch对象占用 24 字节(16 字节开销,8 字节第一个)加上节点的内存。请注意,booksite 版本每个
SequentialSearch对象额外使用 8 字节(4 用于 N,4 用于填充)。分离链接。 56 + 32M + 48N。
SeparateChaining符号表消耗 8M + 56 字节(16 字节开销,20 字节数组开销,8 字节指向数组,每个数组条目的引用 8 字节,4 字节 M,4 字节 N,4 字节填充),再加上 M 个SequentialSearch对象的内存。
创意练习
哈希攻击。 找到 2^N 个长度为 N 的字符串,它们具有相同的
hashCode()值,假设String的hashCode()实现(如Java 标准中指定的)如下:public int hashCode() { int hash = 0; for (int i = 0; i < length(); i++) hash = (hash * 31) + charAt(i); return hash; }解决方案。 很容易验证
"Aa"和"BB"哈希到相同的hashCode()值(2112)。现在,任何由这两个字符串以任何顺序连接在一起形成的长度为 2N 的字符串(例如,BBBBBB,AaAaAa,BBAaBB,AaBBBB)将哈希到相同的值。这里是一个具有相同哈希值的 10000 个字符串的列表。糟糕的哈希函数。 考虑以下用于早期 Java 版本的
String的hashCode()实现:public int hashCode() { int hash = 0; int skip = Math.max(1, length() / 8); for (int i = 0; i < length(); i += skip) hash = (hash * 37) + charAt(i); return hash; }解释为什么你认为设计者选择了这种实现,然后为什么你认为它被放弃,而选择了上一个练习中的实现。
解决方案。 这样做是希望更快地计算哈希函数。确实,哈希值计算得更快,但很可能许多字符串哈希到相同的值。这导致在许多真实输入(例如,长 URL)上性能显著下降,这些输入都哈希到相同的值,例如,
http://www.cs.princeton.edu/algs4/34hash/*****java.html。
网络练习
假设我们希望重复搜索一个长度为 N 的链表,每个元素都包含一个非常长的字符串键。在搜索具有给定键的元素时,我们如何利用哈希值? 解决方案:预先计算列表中每个字符串的哈希值。在搜索键 t 时,将其哈希值与字符串 s 的哈希值进行比较。只有在它们的哈希值相等时才比较字符串 s 和 t。
为以下数据类型实现
hashCode()和equals()。要小心,因为很可能许多点的 x、y 和 z 都是小整数。public class Point2D { private final int x, y; ... }答案:一个解决方案是使哈希码的前 16 位是 x 的前 16 位和 y 的后 16 位的异或,将哈希码的后 16 位是 x 的后 16 位和 y 的前 16 位的异或。因此,如果 x 和 y 只有 16 位或更少,那么不同点的 hashCode 值将不同。
以下点的
equals()实现有什么问题?public boolean equals(Point q) { return x == q.x && y == q.y; }equals()的错误签名。这是equals()的重载版本,但它没有覆盖从Object继承的版本。这将破坏任何使用Point与HashSet的代码。这是更常见的错误之一(与在覆盖equals()时忽略覆盖hashCode()一样)。以下代码片段将打印什么?
import java.util.HashMap; import java.util.GregorianCalendar; HashMap st = new HashMap<gregoriancalendar string="">(); GregorianCalendar x = new GregorianCalendar(1969, 21, 7); GregorianCalendar y = new GregorianCalendar(1969, 4, 12); GregorianCalendar z = new GregorianCalendar(1969, 21, 7); st.put(x, "human in space"); x.set(1961, 4, 12); System.out.println(st.get(x)); System.out.println(st.get(y)); System.out.println(st.get(z));</gregoriancalendar>它将打印 false,false,false。日期 7/21/1969 被插入到哈希表中,但在哈希表中的值被更改为 4/12/1961。因此,尽管日期 4/12/1961 在哈希表中,但在搜索 x 或 y 时,我们将在错误的桶中查找,找不到它。我们也找不到 z,因为日期 7/21/1969 不再是哈希表中的键。
这说明在哈希表的键中只使用不可变类型是一个好习惯。Java 设计者可能应该使
GregorianCalendar成为一个不可变对象,以避免出现这样的问题。密码检查器。 编写一个程序,从命令行读取一个字符串和从标准输入读取一个单词字典,并检查它是否是一个“好”密码。在这里,假设“好”意味着它(i)至少有 8 个字符长,(ii)不是字典中的一个单词,(iii)不是字典中的一个单词后跟一个数字 0-9(例如,hello5),(iv)不是由一个数字分隔的两个单词(例如,hello2world)。
反向密码检查器。 修改前一个问题,使得(ii)-(v)也适用于字典中单词的反向(例如,olleh 和 olleh2world)。巧妙的解决方案:将每个单词及其反向插入符号表。
镜像网站。 使用哈希来确定哪些文件需要更新以镜像网站。
生日悖论。 假设您的音乐点播播放器随机播放您的 4000 首歌曲(带替换)。您期望等待多久才能听到一首歌曲第二次播放?
布隆过滤器。 支持插入、存在。通过允许一些误报来使用更少的空间。应用:ISP 缓存网页(特别是大图像、视频);客户端请求 URL;服务器需要快速确定页面是否在缓存中。解决方案:维护一个大小为 N = 8M(M = 要插入的元素数)的位数组。从 0 到 N-1 选择 k 个独立的哈希函数。
CRC-32。 哈希的另一个应用是计算校验和以验证某个数据文件的完整性。要计算字符串
s的校验和,import java.util.zip.CRC32; ... CRC32 checksum = new CRC32(); checksum.update(s.getBytes()); System.out.println(checksum.getValue());完美哈希。 另请参见 GNU 实用程序 gperf。
密码学安全哈希函数。 SHA-1 和 MD5。可以通过将字符串转换为字节或每次读取一个字节时计算它。程序 OneWay.java 演示了如何使用
java.security.MessageDigest对象。指纹。 哈希函数(例如,MD5 和 SHA-1)也可用于验证文件的完整性。将文件哈希为一个短字符串,将字符串与文件一起传输,如果传输文件的哈希与哈希值不同,则数据已损坏。
布谷鸟哈希。 在均匀哈希的最大负载下为 log n / log log n。通过选择两者中负载最小的来改进为 log log n。(如果选择 d 中负载最小的,则仅改进为 log log n / log d。)布谷鸟哈希 实现了常数平均插入时间和常数最坏情况搜索:每个项目有两个可能的插槽。如果空,则放入两个可用插槽中的任一个;如果不是,则将另一个插槽中的另一个项目弹出并移动到其另一个插槽中(并递归)。"这个名字来源于一些布谷鸟物种的行为,母鸟将蛋推出另一只鸟的巢来产卵。"如果进入重新定位循环,则重新散列所有内容。
协变等于。 CovariantPhoneNumber.java 实现了一个协变的
equals()方法。后来者先服务线性探测。 修改 LinearProbingHashST.java,使得每个项目都插入到它到达的位置;如果单元格已经被占用,则该项目向右移动一个条目(规则重复)。
罗宾汉线性探测。 修改 LinearProbingHashST.java,使得当一个项目探测到已经被占用的单元格时,当前位移较大的项目(两者中的一个)获得单元格,另一个项目向右移动一个条目(规则重复)。
冷漠图。 给定实线上的 V 个点,其冷漠图是通过为每个点添加一个顶点并在两个顶点之间添加一条边形成的图,当且仅当两个对应点之间的距离严格小于一时。设计一个算法(在均匀哈希假设下),以时间比��于 V + E 计算一组 V 点的冷漠图。
解决方案. 将每个实数向下取整到最近的整数,并使用哈希表来识别所有向同一整数取整的点。现在,对于每个点 p,使用哈希表找到所有向 p 的取整值内的整数取整的点,并为距离小于一的每对点添加一条边(p, q)。参见此参考链接以了解为什么这需要线性时间。
3.5 搜索应用
原文:
algs4.cs.princeton.edu/35applications译者:飞龙
本节正在大规模施工中。
从计算机的早期时代,当符号表允许程序员从在机器语言中使用数值地址进展到在汇编语言中使用符号名称,到新千年的现代应用,当符号名称在全球计算机网络中具有意义时,快速搜索算法在计算中发挥了并将继续发挥重要作用。符号表的现代应用包括组织科学数据,从在基因组数据中搜索标记或模式到绘制宇宙;在网络上组织知识,从在线商务搜索到将图书馆放在线;以及实现互联网基础设施,从在网络上的机器之间路由数据包到共享文件系统和视频流。
集合 API。
一些符号表客户端不需要值,只需要将键插入表中并测试键是否在表中。由于我们不允许重复键,这些操作对应于以下 API,我们只对表中的键集感兴趣。
为了说明 SET.java 的用途,我们考虑过滤客户端,从标准输入读取一系列键,并将其中一些写入标准输出。
*去重。*程序 DeDup.java 从输入流中删除重复项。
白名单和黑名单过滤。另一个经典示例,使用单独的文件中的键来决定哪些来自输入流的键传递到输出流。程序 AllowFilter.java 实现了白名单,其中文件中的任何键都会传递到输出,而文件中没有的键将被忽略。程序 BlockFilter.java 实现了黑名单,其中文件中的任何键都将被忽略,而文件中没有的键将传递到输出。
字典客户端。
最基本的符号表客户端通过连续的put操作构建符号表,以支持get请求。下面列举的熟悉示例说明了这种方法的实用性。
作为一个具体的例子,我们考虑一个符号表客户端,您可以使用它查找使用逗号分隔值(.csv)文件格式保存的信息。LookupCSV.java 从命令行指定的逗号分隔值文件中构建一组键值对,然后打印出与从标准输入读取的键对应的值。命令行参数是文件名和两个整数,一个指定用作键的字段,另一个指定用作值的字段。
索引客户端。
这个应用是符号表客户端的另一个典型示例。我们有大量数据,想知道感兴趣的字符串出现在哪里。这似乎是将多个值与每个键关联起来,但实际上我们只关联一个SET。
FileIndex.java 将一系列文件名作为命令行参数,并构建一个符号表,将每个关键字与可以找到该关键字的文件名的SET关联起来。然后,它从标准输入接受关键字查询。
MovieIndex.java 读取一个包含表演者和电影的数据文件。
稀疏向量和矩阵。
程序 SparseVector.java 使用索引-值对的符号表实现了一个稀疏向量。内存与非零数目成比例。set和get操作在最坏情况下需要 log n 时间;计算两个向量的点积所需的时间与两个向量中的非零条目数成比例。
系统符号表。
Java 有几个用于集合和符号表的库函数。API 类似,但你可以将null值插入符号表。
TreeMap 库使用红黑树。保证每次插入/搜索/删除的性能为 log N。他们的实现为每个节点维护三个指针(两个子节点和父节点),而我们的实现只存储两个。
Sun 在 Java 1.5 中的
HashMap实现使用具有分离链接的哈希表。表大小为 2 的幂(而不是素数)。这用 AND 替换了相对昂贵的% M 操作。默认负载因子= 0.75。为防止一些编写不佳的哈希函数,他们对hashCode应用以下混淆例程。static int hash(Object x) { int h = x.hashCode(); h += ~(h << 9); h ^= (h >>> 14); h += (h << 4); h ^= (h >>> 10); return h; }
Q + A
Q. 运行性能基准测试时,插入、搜索和删除操作的合理比例是多少?
A. 这取决于应用程序。Java 集合框架针对大约 85%的搜索/遍历,14%的插入/更新和 1%的删除进行了优化。
练习
创意练习
词汇表。 编写一个 ST 客户端 Concordance.java,在标准输出中输出标准输入流中字符串的词汇表。
稀疏矩阵。 为稀疏 2D 矩阵开发一个 API 和一个实现。支持矩阵加法和矩阵乘法。包括行和列向量的构造函数。
解决方案:SparseVector.java 和 SparseMatrix.java。
网页练习
修改
FrequencyCount以读取一个文本文件(由 UNICODE 字符组成),并打印出字母表大小(不同字符的数量)和一个按频率降序排序的字符及其频率表。集合的交集和并集。 给定两组字符串,编写一个代码片段,计算一个包含这两组中出现的字符串的第三组(或任一组)。
双向符号表。 支持 put(key, value)和 getByKey(key)或 getByValue(value)。在幕后使用两个符号表。例如:DNS 和反向 DNS。
突出显示浏览器超链接。 每次访问网站时,保留上次访问网站的时间,这样你只会突出显示那些在过去一个月内访问过的网站。
频率符号表。 编写一个支持以下操作的抽象数据类型 FrequencyTable.java:
hit(Key)和count(Key)。hit操作将字符串出现的次数增加一。count操作返回给定字符串出现的次数,可能为 0。应用:网页计数器,网页日志分析器,音乐点播机统计每首歌曲播放次数等。非重叠区间搜索。 给定一个非重叠整数(或日期)区间列表,编写一个函数,接受一个整数参数,并确定该值位于哪个(如果有)区间中,例如,如果区间为 1643-2033、5532-7643、8999-10332、5666653-5669321,则查询点 9122 位于第三个区间,8122 不在任何区间中。
注册调度。 一所东北部知名大学的注册处最近安排一名教师在完全相同的时间上教授两门不同的课程。通过描述一种检查此类冲突的方法来帮助注册处避免未来的错误。为简单起见,假设所有课程从 9 点开始,每门课程持续 50 分钟,时间分别为 9、10、11、1、2 或 3。
列表。 实现以下列表操作:size()、addFront(item)、addBack(item)、delFront(item)、delBack(item)、contains(item)、delete(item)、add(i, item)、delete(i)、iterator()。所有操作应高效(对数时间)。提示:使用两个符号表,一个用于高效查找列表中的第 i 个元素,另一个用于按项目高效搜索。Java 的 List 接口包含这些方法,但没有提供支持所有操作高效的实现。
间接 PQ。 编写一个实现间接 PQ 的程序 IndirectPQ.java。
LRU 缓存。 创建一个支持以下操作的数据结构:
access和remove。访问操作将项目插入到数据结构中(如果尚未存在)。删除操作删除并返回最近访问的项目。提示:在双向链表中按访问顺序维护项目,并在符号表中使用键=项目,值=链表中的位置。当访问一个元素时,从链表中删除它并重新插入到开头。当删除一个元素时,从末尾删除它并从符号表中删除它。UniQueue. 创建一个数据类型,它是一个队列,但是一个元素只能被插入队列一次。使用存在性符号表来跟踪所有曾经被插入的元素,并忽略重新插入这些项目的请求。
带随机访问的符号表。 创建一个支持插入键值对、搜索键并返回关联值、删除并返回随机值的数据类型。提示:结合符号表和随机队列。
纠正拼写错误。 编写一个程序,从标准输入中读取文本,并用建议的替换替换任何常见拼写错误的单词,并将结果打印到标准输出。使用这个常见拼写错误列表(改编自Wikipedia)。
移至前端。 编码:需要排名查询、删除和插入。解码:需要查找第 i 个、删除和插入。
可变字符串。 创建一个支持字符串上述操作的数据类型:
get(int i)、insert(int i, char c)和delete(int i),其中get返回字符串的第 i 个字符,insert插入字符 c 并使其成为第 i 个字符,delete删除第 i 个字符。使用二叉搜索树。提示:使用 BST(键=0 到 1 之间的实数,值=字符)使得树的中序遍历产生适当顺序的字符。使用
select()找到第 i 个元素。在位置 i 插入字符时,选择实数为当前位置 i-1 和 i 的键的平均值。幂法和最大特征值。 要计算具有最大幅度的特征值(及相应的特征向量),请使用幂法。在技术条件下(最大两个特征值之间的差距),它会迅速收敛到正确答案。
进行初始猜测 x[1]
y[n] = x[n] / ||x[n]||
x[n+1] = A y[n]
λ = x[n+1]^T y[n]
n = n + 1 如果 A 是稀疏的,那么这个算法会利用稀疏性。例如:Google PageRank。
外积。 向
Vector添加一个方法outer,使得a.outer(b)返回两个长度为 N 的向量 a 和 b 的外积。结果是一个 N×N 矩阵。网络链接的幂律分布。(Michael Mitzenmacher)全球网络的入度和出度遵循幂律分布。可以通过优先附加过程来建模。假设每个网页只有一个外链。每个页面逐一创建,从指向自身的单个页面开始。以概率 p < 1,它将链接到现有页面之一,随机选择。以概率 1-p,它将链接到现有页面,概率与该页面的入链数成比例。这一规则反映了新网页倾向于指向热门页面的普遍趋势。编写一个程序来模拟这个过程,并绘制入链数的直方图。
VMAs. Unix 内核中用于管理一组虚拟内存区域(VMAs)的 BST。每个 VMA 代表 Unix 进程中的一部分内存。VMAs 的大小从 4KB 到 1GB 不等。还希望支持范围查询,以确定哪些 VMAs 与给定范围重叠。参考资料
**互联网对等缓存。**由互联网主机发送的每个 IP 数据包都带有一个必须对于该源-目的地对是唯一的 16 位 ID。Linux 内核使用以 IP 地址为索引的 AVL 树。哈希会更快,但希望避免攻击者发送具有最坏情况输入的 IP 数据包。参考资料
文件索引变体。
移除停用词,例如,a,the,on,of。使用另一个集合来实现。
支持多词查询。这需要进行集合交集操作。如果总是先与最小集合进行交集,那么这将花费与最小集合大小成正比的时间。
实现 OR 或其他布尔逻辑。
记录文档中单词的位置或单词出现的次数。
**算术表达式解释器。**编写一个程序 Interpreter.java 来解析和评估以下形式的表达式。
>> x := 34 x := 34.0 >> y := 23 * x y := 782.0 >> z := x ^ y z := Infinity >> z := y ^ 2 z := 611524.0 >> x x := 34.0 >> x := sqrt 2 x := 1.4142135623730951变体。
添加更复杂的表达式,例如,z = 7 * (x + y * y),使用传统的运算符优先级。
添加更多的错误检查和恢复。
4. 图
原文:
algs4.cs.princeton.edu/40graphs译者:飞龙
概述。
项目之间的成对连接在大量计算应用程序中起着至关重要的作用。这些连接所暗示的关系引发了一系列自然问题:是否有一种方法可以通过遵循这些连接将一个项目连接到另一个项目?有多少其他项目连接到给定项目?这个项目和另一个项目之间的连接的最短链是什么?下表展示了涉及图处理的各种应用程序的多样性。
我们逐步介绍了四种最重要的图模型:无向图(具有简单连接),有向图(其中每个连接的方向很重要),带权重的图(其中每个连接都有一个相关联的权重),以及带权重的有向图(其中每个连接都有方向和权重)。
4.1 无向图介绍了图数据类型,包括深度优先搜索和广度优先搜索。
4.2 有向图介绍了有向图数据类型,包括拓扑排序和强连通分量。
4.3 最小生成树描述了最小生成树问题以及解决它的两种经典算法:Prim 算法和 Kruskal 算法。
4.4 最短路径介绍了最短路径问题以及解决它的两种经典算法:Dijkstra 算法和 Bellman-Ford 算法。
本章中的 Java 程序。
下面是本章中的 Java 程序列表。单击程序名称以访问 Java 代码;单击参考号以获取简要描述;阅读教科书以获取详细讨论。
REF 程序 描述 / JAVADOC - Graph.java 无向图 - GraphGenerator.java 生成随机图 - DepthFirstSearch.java 图中的深度优先搜索 - NonrecursiveDFS.java 图中的 DFS(非递归) 4.1 DepthFirstPaths.java 图中的路径(DFS) 4.2 BreadthFirstPaths.java 图中的路径(BFS) 4.3 CC.java 图的连通分量 - Bipartite.java 二分图或奇环(DFS) - BipartiteX.java 二分图或奇环(BFS) - Cycle.java 图中的环 - EulerianCycle.java 图中的欧拉回路 - EulerianPath.java 图中的欧拉路径 - SymbolGraph.java 符号图 - DegreesOfSeparation.java 分离度 - Digraph.java 有向图 - DigraphGenerator.java 生成随机有向图 4.4 DirectedDFS.java 有向图中的深度优先搜索 - NonrecursiveDirectedDFS.java 有向图中的深度优先搜索(非递归) - DepthFirstDirectedPaths.java 有向图中的路径(深度优先搜索) - BreadthFirstDirectedPaths.java 有向图中的路径(广度优先搜索) - DirectedCycle.java 有向图中的环 - DirectedCycleX.java 有向图中的环(非递归) - DirectedEulerianCycle.java 有向图中的欧拉回路 - DirectedEulerianPath.java 有向图中的欧拉路径 - DepthFirstOrder.java 有向图中的深度优先顺序 4.5 Topological.java 有向无环图中的拓扑排序 - TopologicalX.java 拓扑排序(非递归) - TransitiveClosure.java 传递闭包 - SymbolDigraph.java 符号有向图 4.6 KosarajuSharirSCC.java 强连通分量(Kosaraju–Sharir 算法) - TarjanSCC.java 强连通分量(Tarjan 算法) - GabowSCC.java 强连通分量(Gabow 算法) - EdgeWeightedGraph.java 加权边图 - Edge.java 加权边 - LazyPrimMST.java 最小生成树(延时 Prim 算法) 4.7 PrimMST.java 最小生成树(Prim 算法) 4.8 KruskalMST.java 最小生成树(Kruskal 算法) - BoruvkaMST.java 最小生成树(Boruvka 算法) - EdgeWeightedDigraph.java 加权有向图 - DirectedEdge.java 加权有向边 4.9 DijkstraSP.java 最短路径(Dijkstra 算法) - DijkstraUndirectedSP.java 无向图的最短路径(Dijkstra 算法) - DijkstraAllPairsSP.java 全局最短路径 4.10 AcyclicSP.java 有向无环图中的最短路径 - AcyclicLP.java 有向无环图中的最长路径 - CPM.java 关键路径法 4.11 BellmanFordSP.java 最短路径(贝尔曼-福特算法) - EdgeWeightedDirectedCycle.java 加权有向图中的环 - Arbitrage.java 套汇检测 - FloydWarshall.java 全局最短路径(稠密图) - AdjMatrixEdgeWeightedDigraph.java 加权图(稠密图)
4.1 无向图
原文:
algs4.cs.princeton.edu/41graph译者:飞龙
图。
图是一组顶点和连接一对顶点的边的集合。我们在 V-1 个顶点的图中使用 0 到 V-1 的名称表示顶点。
术语表。
这里是我们使用的一些定义。
自环是连接顶点与自身的边。
如果它们连接相同的一对顶点,则两条边是平行的。
当一条边连接两个顶点时,我们说这两个顶点相邻,并且该边关联这两个顶点。
一个顶点的度是与其关联的边的数量。
子图是构成图的边(和相关顶点)的子集,构成一个图。
图中的路径是由边连接的顶点序列,没有重复的边。
一个简单路径是一个没有重复顶点的路径。
循环是一条路径(至少有一条边),其第一个和最后一个顶点相同。
简单循环是一个没有重复顶点(除了第一个和最后一个顶点必须重复)的循环。
一条路径或循环的长度是其边的数量。
如果存在包含它们两者的路径,则我们说一个顶点连接到另一个顶点。
如果从每个顶点到每个其他顶点都存在路径,则图是连通的。
一个非连通的图由一组连通分量组成,这些连通分量是最大连通子图。
无环图是一个没有循环的图。
树是一个无环连通图。
森林是一组不相交的树。
连通图的生成树是包含该图所有顶点且为单棵树的子图。图的生成森林是其连通分量的生成树的并集。
二分图是一个我们可以将其顶点分为两组的图,使得所有边连接一组中的顶点与另一组中的顶点。

无向图数据类型。
我们实现以下无向图 API。
关键方法adj()允许客户端代码迭代给定顶点相邻的顶点。值得注意的是,我们可以在adj()所体现的基本抽象上构建本节中考虑的所有算法。
我们准备了测试数据 tinyG.txt、mediumG.txt 和 largeG.txt,使用以下输入文件格式。

图客户端.java 包含典型的图处理代码。
图表示。
我们使用邻接表表示法,其中我们维护一个以顶点索引的数组,数组中的每个元素是与每个顶点通过边连接的顶点的列表。
图.java 使用邻接表表示法实现了图 API。邻接矩阵图.java 使用邻接矩阵表示法实现了相同的 API。
深度优先搜索。
深度优先搜索是一种经典的递归方法,用于系统地检查图中的每个顶点和边。要访问一个顶点
将其标记为已访问。
访问(递归地)所有与其相邻且尚未标记的���点。
深度优先搜索.java 实现了这种方法和以下 API:
寻找路径。
修改深度优先搜索以确定两个给定顶点之间是否存在路径以及找到这样的路径(如果存在)。我们试图实现以下 API:
为了实现这一点,我们通过将edgeTo[w]设置为v来记住将我们带到每个顶点w的边缘v-w,这是第一次。换句话说,v-w是从源到w的已知路径上的最后一条边。搜索的结果是以源为根的树;edgeTo[]是该树的父链接表示。深度优先路径.java 实现了这种方法。
广度优先搜索。
深度优先搜索找到从源顶点 s 到目标顶点 v 的一条路径。我们经常有兴趣找到最短这样的路径(具有最小数量的边)。广度优先搜索是基于这个目标的经典方法。要从s到v找到最短路径,我们从s开始,并在我们可以通过一条边到达的所有顶点中检查v,然后我们在我们可以通过两条边从s到达的所有顶点中检查v,依此类推。
要实现这种策略,我们维护一个队列,其中包含所有已标记但其邻接列表尚未被检查的顶点。我们将源顶点放入队列,然后执行以下步骤,直到队列为空:
从队列中移除下一个顶点
v。将所有未标记的与
v相邻的顶点放入队列并标记它们。
广度优先路径.java 是实现Paths API 的一个实现,用于找到最短路径。它依赖于 FIFO 队列.java。
连通分量。
我们下一个直接应用深度优先搜索的是找到图的连通分量。回想一下第 1.5 节,“连接到”是将顶点划分为等价类(连通分量)的等价关系。对于这个任务,我们定义以下 API:
CC.java 使用 DFS 实现此 API。
命题。 DFS 在时间上标记与给定源连接的所有顶点,其时间与其度数之和成正比,并为客户提供从给定源到任何标记顶点的路径,其时间与其长度成正比。
**命题。**对于从s可达的任何顶点v,BFS 计算从s到v的最短路径(从s到v没有更少的边的路径)。在最坏情况下,BFS 花费时间与 V + E 成正比。
命题。 DFS 使用预处理时间和空间与 V + E 成正比,以支持图中的常数时间连接查询。
更多深度优先搜索应用。
我们用 DFS 解决的问题是基础的。深度优先搜索还可以用于解决以下问题:
*循环检测:*给定图是否无环?循环.java 使用深度优先搜索来确定图是否有循环,如果有,则返回一个。在最坏情况下,它花费时间与 V + E 成正比。
*双色性:*给定图的顶点是否可以被分配为两种颜色,以便没有边连接相同颜色的顶点?二分图.java 使用深度优先搜索来确定图是否具有二��图;如果是,则返回一个;如果不是,则返回一个奇数长度的循环。在最坏情况下,它花费时间与 V + E 成正比。
桥: 桥(或割边)是一条删除后会增加连接组件数量的边。等价地,仅当边不包含在任何循环中时,边才是桥。桥.java 使用深度优先搜索在图中找到桥。在最坏情况下,它花费时间与 V + E 成正比。
双连通性:一个关节点(或割点)是一个移除后会增加连接组件数量的顶点。如果没有关节点,则图形是双连通的。Biconnected.java 使用深度优先搜索来查找桥梁和关节点。在最坏情况下,它的时间复杂度为 V + E。
平面性:如果可以在平面上绘制图形,使得没有边相互交叉,则图形是平面的。 Hopcroft-Tarjan 算法是深度优先搜索的高级应用,它可以在线性时间内确定图形是否是平面的。
符号图。
典型应用涉及使用字符串而不是整数索引来处理图形,以定义和引用顶点。为了适应这些应用程序,我们定义了具有以下属性的输入格式:
顶点名称是字符串。
指定的分隔符分隔顶点名称(以允许名称中包含空格的可能性)。
每行表示一组边,将该行上的第一个顶点名称连接到该行上命名的每个其他顶点。
输入文件 routes.txt 是一个小例子。
输入文件 movies.txt 是来自互联网电影数据库的一个更大的示例。该文件包含列出电影名称后跟电影中表演者列表的行。
API。 以下 API 允许我们为这种输入文件使用我们的图处理例程。
![符号图 API]()
*实现。*SymbolGraph.java 实现了 API。它构建了三种数据结构:
一个符号表
st,具有String键(顶点名称)和int值(索引)一个作为反向索引的数组
keys[],给出与每个整数索引关联的顶点名称使用索引构建的
GraphG,以引用顶点
![符号图数据结构]()
*分离度。*DegreesOfSeparation.java 使用广度优先搜索来查找社交网络中两个个体之间的分离度。对于演员-电影图,它玩的是凯文·贝肯游戏。
练习
为 Graph.java 创建一个复制构造函数,该构造函数以图
G作为输入,并创建并初始化图的新副本。客户端对G所做的任何更改都不应影响新创建的图。向 BreadthFirstPaths.java 添加一个
distTo()方法,该方法返回从源到给定顶点的最短路径上的边数。distTo()查询应在常数时间内运行。编写一个程序 BaconHistogram.java,打印凯文·贝肯号的直方图,指示 movies.txt 中有多少表演者的贝肯号为 0、1、2、3 等。包括那些具有无限号码的类别(与凯文·贝肯没有联系)。
编写一个
SymbolGraph客户端 DegreesOfSeparationDFS.java,该客户端使用深度优先而不是广度优先搜索来查找连接两个表演者的路径。使用第 1.4 节的内存成本模型确定
Graph表示具有V个顶点和E条边的图所使用的内存量。解决方案。 56 + 40V + 128E。MemoryOfGraph.java 根据经验计算,假设没有缓��
Integer值—Java 通常会缓存-128 到 127 之间的整数。
创意问题
**并行边检测。**设计一个线性时间算法来计算图中的平行边数。
提示:维护一个顶点的邻居的布尔数组,并通过仅在需要时重新初始化条目来重复使用此数组。
双边连通性。 在图中,桥是一条边,如果移除,则会将一个连通图分隔成两个不相交的子图。没有桥的图被称为双边连通。开发一个基于 DFS 的数据类型 Bridge.java,用于确定给定图是否是边连通的。
网页练习
找一些有趣的图。它们是有向的还是无向的?稀疏的还是密集的?
度。 顶点的度是与之关联的边的数量。向
Graph添加一个方法int degree(int v),返回顶点 v 的度数。假设在运行广度优先搜索时使用堆栈而不是队列。它仍然计算最短路径吗?
使用显式堆栈的 DFS。 给出 DFS 可能出现堆栈溢出的示例,例如,线图。修改 DepthFirstPaths.java,使其使用显式堆栈而不是函数调用堆栈。
完美迷宫。 生成一个完美迷宫像这样的
![14×14 完美迷宫]()
![22×22 完美迷宫]()
编写一个程序 Maze.java,它接受一个命令行参数 n,并生成一个随机的 n×n 完美迷宫。如果迷宫完美,则每对迷宫中的点之间都有一条路径,即没有无法访问的位置,没有循环,也没有开放空间。这里有一个生成这样的迷宫的好算法。考虑一个 n×n 的单元格网格,每个单元格最初与其四个相邻单元格之间都有一堵墙。对于每个单元格(x, y),维护一个变量
north[x][y],如果存在将(x, y)和(x, y + 1)分隔的墙,则为true。我们有类似的变量east[x][y],south[x][y]和west[x][y]用于相应的墙壁。请注意,如果(x, y)的北面有一堵墙,则north[x][y] = south[x][y+1] = true。通过以下方式拆除一些墙壁来构建迷宫:从较低级别单元格(1, 1)开始。
随机找到一个您尚未到达的邻居。
如果找到一个,就移动到那里,拆除墙壁。如果找不到,则返回上一个单元格。
重复步骤 ii.和 iii.,直到您访问了网格中的每个单元格。
提示:维护一个(n+2)×(n+2)的单元格网格,以避免繁琐的特殊情况。
这是由卡尔·埃克洛夫使用此算法创建的一个 Mincecraft 迷宫。
![Minecraft 迷宫]()
走出迷宫。 给定一个 n×n 的迷宫(就像在前一个练习中创建的那样),编写一个程序,如果存在路径,则从起始单元格(1, 1)到终点单元格(n, n)找到一条路径。要找到迷宫的解决方案,请运行以下算法,从(1, 1)开始,并在到达单元格(n, n)时停止。
explore(x, y) ------------- - Mark the current cell (x, y) as "visited." - If no wall to north and unvisited, then explore(x, y+1). - If no wall to east and unvisited, then explore(x+1, y). - If no wall to south and unvisited, then explore(x, y-1). - If no wall to west and unvisited, then explore(x-1, y).迷宫游戏。 开发一个迷宫游戏,就像来自gamesolo.com的这个,您在其中穿过迷宫,收集奖品。
演员图。 计算凯文·贝肯数的另一种(也许更自然)方法是构建一个图,其中每个节点都是一个演员。如果两个演员一起出现在一部电影中,则它们之间通过一条边连接。通过在演员图上运行 BFS 来计算凯文·贝肯数。比较与文本中描述的算法的运行时间。解释为什么文本中的方法更可取。答案:它避免了多个平行边。因此,它更快,使用的内存更少。此外,它更方便,因为您不必使用电影名称标记边缘-所有名称都存储在顶点中。
好莱坞宇宙的中心。 我们可以通过计算他们的好莱坞数来衡量凯文·贝肯是一个多好的中心。凯文·贝肯的好莱坞数是所有演员的平均贝肯数。另一位演员的好莱坞数计算方式相同,但我们让他们成为源,而不是凯文·贝肯。计算凯文·贝肯的好莱坞数,并找到一个演员和一个女演员,他们的好莱坞数更好。
好莱坞宇宙的边缘。 找到(与凯文·贝肯相连的)具有最高好莱坞数的演员。
单词梯子。 编写一个程序 WordLadder.java,从命令行中获取两个 5 个字母的字符串,并从标准输入中读取一个 5 个字母的单词列表,然后打印出连接这两个字符串的最短单词梯子(如果存在)。如果两个单词在一个字母上不同,那么它们可以在一个单词梯子链中连接起来。例如,以下单词梯子连接了 green 和 brown。
green greet great groat groan grown brown你也可以尝试在这个 6 个字母单词列表上运行你的程序。
更快的单词梯子。 为了加快速度(如果单词列表非常大),不要编写嵌套循环来尝试所有成对的单词是否相邻。对于 5 个字母的单词,首先对单词列表进行排序。只有最后一个字母不同的单词将在排序后的列表中连续出现。再排序 4 次,但将字母向右循环移动一个位置,以便在一个排序列表中连续出现在第 i 个字母上不同的单词。
尝试使用一个更大的单词列表来测试这种方法,其中包含不同长度的单词。如果两个长度不同的单词���有最后一个字母不同,则它们是相邻的。
假设你删除无向图中的所有桥梁。结果图的连通分量是否是双连通分量?答案:不是,两个双连通分量可以通过一个关节点连接。
桥梁和关节点。
桥梁(或割边)是一条移除后会断开图的边。关节点(或割点)是一个移除后(以及移除所有关联边后)会断开剩余图的顶点。桥梁和关节点很重要,因为它们代表网络中的单点故障。蛮力方法:删除边(或顶点)并检查连通性。分别需要 O(E(V + E))和 O(V(V + E))的时间。可以通过巧妙地扩展 DFS 将两者都改进为 O(E + V)。
双连通分量。 一个无向图是双连通的,如果对于每一对顶点 v 和 w,v 和 w 之间有两条顶点不重叠的路径。(或者等价地,通过任意两个顶点的简单循环。)我们在边上定义一个共圆等价关系:如果 e1 = e2 或者存在包含 e1 和 e2 的循环,则 e1 和 e2 在同一个双连通分量中。两个双连通分量最多共享一个公共顶点。一个顶点是关节点,当且仅当它是多于一个双连通分量的公共部分时。程序 Biconnected.java 标识出桥梁和关节点。
双连通分量。 修改
Biconnected以打印构成每个双连通分量的边。提示:每个桥梁都是自己的双连通分量;要计算其他双连通分量,将每个关节点标记为已访问,然后运行 DFS,跟踪从每个 DFS 起点发现的边。对随机无向图的连通分量数量进行数值实验。在 1/2 V ln V 附近发生相变。(参见 Algs Java 中的属性 18.13。)
流氓。(安德鲁·阿普尔。)在一个无向图中,一个怪物和一个玩家分别位于不同的顶点。在角色扮演游戏 Rogue 中,玩家和怪物轮流行动。每轮中,玩家可以移动到相邻的顶点或原地不动。确定玩家在怪物之前可以到达的所有顶点。假设玩家先行动。
流氓。(安德鲁·阿普尔。)在一个无向图中,一个怪物和一个玩家分别位于不同的顶点。怪物的目标是落在与玩家相同的顶点上。为怪物设计一个最佳策略。
关节点。 设 G 是一个连通的无向图。考虑 G 的 DFS 树。证明顶点 v 是 G 的关节点当且仅当(i)v 是 DFS 树的根并且有多于一个子节点,或者(ii)v 不是 DFS 树的根并且对于 v 的某个子节点 w,w 的任何后代(包括 w)和 v 的某个祖先之间没有反向边。换句话说,v 是关节点当且仅当(i)v 是根并且有多于一个子节点,或者(ii)v 有一个子节点 w,使得 low[w] >= pre[v]。
谢尔宾斯基垫。 一个优美的欧拉图的例子。
优先连接图。 如下创建一个具有 V 个顶点和 E 条边的随机图:以任意顺序开始具有 V 个顶点 v1,..,vn。均匀随机选择序列的一个元素并添加到序列的末尾。重复 2E 次(使用不断增长的顶点列表)。将最后的 2E 个顶点配对以形成图。
大致来说,等价于按照两个端点的度数的乘积成比例的概率逐个添加每条边。参考。
维纳指数。 一个顶点的维纳指数是该顶点与所有其他顶点之间的最短路径距离之和。图 G 的维纳指数是所有顶点对之间的最短路径距离之和。被数学化学家使用(顶点=原子,边=键)。
随机游走。 从迷宫中走出(或图中的 st 连通性)的简单算法:每一步,朝一个随机方向迈出一步。对于完全图,需要 V log V 时间(收集优惠券);对于线图或环,需要 V² 时间(赌徒的失败)。一般来说,覆盖时间最多为 2E(V-1),这是 Aleliunas、Karp、Lipton、Lovasz 和 Rackoff 的经典结果。
删除顺序。 给定一个连通图,确定一个顺序来删除顶点,使得每次删除后图仍然连通。你的算法在最坏情况下应该花费与 V + E 成比例的时间。
树的中心。 给定一个树(连通且无环)的图,找到一个顶点,使得它与任何其他顶点的最大距离最小化。
提示:找到树的直径(两个顶点之间的最长路径)并返回中间的一个顶点。
树的直径。 给定一个树(连通且无环)的图,找到最长的路径,即一对顶点 v 和 w,它们之间的距离最远。你的算法应该在线性时间内运行。
提示。 选择任意顶点 v。计算从 v 到每个其他顶点的最短路径。设 w 是最大最短路径距离的顶点。计算从 w 到每个其他顶点的最短路径。设 x 是最大最短路径距离的顶点。从 w 到 x 的路径给出直径。
使用并查集查找桥梁。 设 T 是一个连通图 G 的生成树。图 G 中的每条非树边 e 形成一个由边 e 和树中连接其端点的唯一路径组成的基本环。证明一条边是桥梁当且仅当它不在某个基本环上。因此,所有桥梁都是生成树的边。设计一个算法,使用 E + V 时间加上 E + V 并查集操作,找到所有桥梁(和桥梁组件)。
非递归深度优先搜索。 编写一个程序 NonrecursiveDFS.java,使用显式堆栈而不是递归来实现深度优先搜索。
这是 Bin Jiang 在 1990 年代初提出的另一种实现。唯一额外的内存是用于顶点堆栈,但该堆栈必须支持任意删除(或至少将任意项移动到堆栈顶部)。
private void dfs(Graph G, int s) { SuperStack<Integer> stack = new SuperStack<Integer>(); stack.push(s); while (!stack.isEmpty()) { int v = stack.peek(); if (!marked[v]) { marked[v] = true; for (int w : G.adj(v)) { if (!marked[w]) { if (stack.contains(w)) stack.delete(w); stack.push(w); } } } else { // v's adjacency list is exhausted stack.pop(); } } }这里是另一种实现。这可能是最简单的非递归实现,但在最坏情况下使用的空间与 E + V 成比例(因为一个顶点的多个副本可能在堆栈上),并且以标准递归 DFS 的相反顺序探索与 v 相邻的顶点。此外,
edgeTo[v]条目可能被更新多次,因此可能不适用于回溯应用。private void dfs(Graph G, int s) { Stack<Integer> stack = new Stack<Integer>(); stack.push(s); while (!stack.isEmpty()) { int v = stack.pop(); if (!marked[v]) { marked[v] = true; for (int w : G.adj(v)) { if (!marked[w]) { edgeTo[w] = v; stack.push(w); } } } } }非递归深度优先搜索。 解释为什么以下非递归方法(类似于 BFS,但使用堆栈而不是队列)不实现深度优先搜索。
private void dfs(Graph G, int s) { Stack<Integer> stack = new Stack<Integer>(); stack.push(s); marked[s] = true; while (!stack.isEmpty()) { int v = stack.pop(); for (int w : G.adj(v)) { if (!marked[w]) { stack.push(w); marked[w] = true; edgeTo[w] = v; } } } }*解决方案:*考虑由边 0-1、0-2、1-2 和 2-1 组成的图,其中顶点 0 为源。
Matlab 连通分量。 在 Matlab 中,bwlabel() 或 bwlabeln() 用于标记 2D 或 kD 二进制图像中的连通分量。bwconncomp() 是更新版本。
互补图中的最短路径。 给定一个图 G,设计一个算法来找到从 s 到互补图 G' 中每个其他顶点的最短路径(边的数量)。互补图包含与 G 相同的顶点,但只有当边 v-w 不在 G 中时才包含边 v-w。你能否比明确计算互补图 G' 并在 G' 中运行 BFS 做得更好?
删除一个顶点而不断开图。 给定一个连通图,设计一个线性时间算法来找到一个顶点,其移除(删除顶点和所有关联边)不会断开图。
提示 1(使用 DFS):从某个顶点 s 运行 DFS,并考虑 DFS 中完成的第一个顶点。
提示 2(使用 BFS):从某个顶点 s 运行 BFS,并考虑具有最大距离的任何顶点。
生成树。 设计一个算法,以时间复杂度为 V + E 计算一个连通图的生成树。提示:使用 BFS 或 DFS。
图中的所有路径。 编写一个程序 AllPaths.java,枚举图中两个指定顶点之间的所有简单路径。提示:使用 DFS 和回溯。警告:图中可能存在指数多个简单路径,因此对于大型图,没有算法可以高效运行。
4.2 有向图
原文:
algs4.cs.princeton.edu/42digraph译者:飞龙
有向图。
一个有向图(或有向图)是一组顶点和一组有向边,每条边连接一个有序对的顶点。我们说一条有向边从该对中的第一个顶点指向该对中的第二个顶点。对于 V 个顶点的图,我们使用名称 0 到 V-1 来表示顶点。
术语表。
这里是我们使用的一些定义。
自环 是连接顶点到自身的边。
如果两条边连接相同的顶点对,则它们是平行的。
一个顶点的outdegree是指指向它的边的数量。
一个顶点的indegree是指指向它的边的数量。
子图是构成有向图的一部分边(和相关顶点)的子集。
在有向图中,有向路径是一个顶点序列,其中每个顶点到其后继顶点有一条(有向)边,且没有重复的边。
一个有向路径是简单的,如果它没有重复的顶点。
一个有向循环是一条有向路径(至少有一条边),其第一个和最后一个顶点相同。
如果一个有向循环没有重复的顶点(除了第一个和最后一个顶点的必要重复),那么它是简单的。
一条路径或循环的长度是指它的边数。
我们说一个顶点
w是从顶点v可达的,如果存在一条从v到w的有向路径。如果两个顶点
v和w是强连通的,那么它们是相互可达的:从v到w有一条有向路径,从w到v也有一条有向路径。如果每个顶点到每个其他顶点都有一条有向路径,那么有向图是强连通的。
一个非强连通的有向图由一组强连通分量组成,这些分量是最大的强连通子图。
一个有向无环图(或 DAG)是一个没有有向循环的有向图。

有向图数据类型。
我们实现了以下有向图 API。
关键方法 adj() 允许客户端代码遍历从给定顶点邻接的顶点。
我们使用以下输入文件格式准备测试数据 tinyDG.txt。

图的表示。
我们使用邻接表表示法,其中我们维护一个以顶点为索引的列表数组,其中包含与每个顶点通过边连接的顶点。
Digraph.java 使用邻接表表示法实现了有向图 API。AdjMatrixDigraph.java 使用邻接矩阵表示法实现了相同的 API。
有向图中的可达性。
深度优先搜索和广度优先搜索是基本的有向图处理算法。
单源可达性: 给定一个有向图和源
s,是否存在一条从 s 到 v 的有向路径?如果是,找到这样的路径。DirectedDFS.java 使用深度优先搜索来解决这个问题。多源可达性: 给定一个有向图和一组源顶点,是否存在一条从集合中的任意顶点到 v 的有向路径?DirectedDFS.java 使用深度优先搜索来解决这个问题。
单源有向路径: 给定一个有向图和源
s,是否存在一条从 s 到 v 的有向路径?如果是,找到这样的路径。DepthFirstDirectedPaths.java 使用深度优先搜索来解决这个问题。单源最短有向路径:给定一个有向图和源点
s,是否存在从 s 到 v 的有向路径?如果有,找到一条最短的这样的路径。BreadthFirstDirectedPaths.java 使用广度优先搜索来解决这个问题。
循环和 DAG。
在涉及处理有向图的应用中,有向循环尤为重要。输入文件 tinyDAG.txt 对应于以下 DAG:
有向环检测:给定一个有向图,是否存在有向环?如果有,找到这样的环。DirectedCycle.java 使用深度优先搜索来解决这个问题。
深度优先顺序:深度优先搜索每个顶点恰好一次。在典型应用中,有三种顶点排序是感兴趣的:
前序:在递归调用之前将顶点放入队列。
后序:在递归调用后将顶点放入队列。
逆后序:在递归调用后将顶点放入栈。
DepthFirstOrder.java 计算这些顺序。
![前序、后序和逆后序]()
拓扑排序:给定一个有向图,按顶点顺序排列,使得所有的有向边都从顺序中较早的顶点指向顺序中较晚的顶点(或报告无法这样做)。Topological.java 使用深度优先搜索来解决这个问题。值得注意的是,在 DAG 中的逆后序提供了一个拓扑顺序。
![拓扑排序]()
命题。
有向图具有拓扑顺序当且仅当它是 DAG。
命题。
DAG 中的逆后序是拓扑排序。
命题。
使用深度优先搜索,我们可以在时间上将 DAG 进行拓扑排序,时间复杂度为 V + E。
强连通性。
强连通性是顶点集合上的等价关系:
自反性:每个顶点 v 与自身强连通。
对称性:如果 v 与 w 强连通,则 w 也与 v 强连通。
传递性:如果 v 与 w 强连通,且 w 与 x 强连通,则 v 也与 x 强连通。
强连通性将顶点划分为等价类,我们简称为强连通分量。我们试图实现以下 API:
令人惊讶的是,KosarajuSharirSCC.java 仅通过在 CC.java 中添加几行代码就实现了该 API,如下所示:
给定一个有向图 G,使用 DepthFirstOrder.java 来计算其反向图 G^R 的逆后序。
在 G 上运行标准 DFS,但考虑刚刚计算的顺序中的未标记顶点,而不是标准的数字顺序。
从构造函数中对递归
dfs()的调用到达的所有顶点都在一个强连通分量中(!),因此像在 CC 中一样识别它们。
命题。
Kosaraju-Sharir 算法使用预处理时间和空间与 V + E 成比例,以支持有向图中的常数时间强连通性查询。
传递闭包。
有向图 G 的传递闭包是另一个有向图,具有相同的顶点集,但如果且仅当在 G 中从 v 到 w 可达时,有一条从 v 到 w 的边。
TransitiveClosure.java 通过从每个顶点运行深度优先搜索并存储结果来计算有向图的传递闭包。这种解决方案非常适合小型或密集的有向图,但不适用于我们在实践中可能遇到的大型有向图,因为构造函数使用的空间与 V² 成比例,时间与 V (V + E) 成比例。
练习
为 Digraph 创建一个复制构造函数,该函数以有向图 G 作为输入,并创建和初始化有向图的新副本。客户端对 G 所做的任何更改都不应影响新创建的有向图。
有向图在第 591 页上有多少个强连通分量?
解决方案: 10. 输入文件是 mediumDG.txt。
有向无环图(DAG)的强连通分量是什么?
解决方案: 每个顶点都是自己的强连通分量。
真或假:有向图的反向的逆后序与有向图的逆后序相同。
解决方案: 假。
真或假:如果我们修改 Kosaraju-Sharir 算法,在有向图 G 中运行第一个深度优先搜索(而不是反向有向图 GR),并在 GR 中运行第二个深度优先搜索(而不是 G),那么它仍然会找到强连通分量。
解决方案. 是的,有向图的强连通分量与其反向的强连通分量相同。
真或假:如果我们修改 Kosaraju-Sharir 算法,用广度优先搜索替换第二次深度优先搜索,那么它仍然会找到强连通分量。
解决方案. 真。
计算具有 V 个顶点和 E 条边的
Digraph的内存使用情况,根据第 1.4 节的内存成本模型。解决方案. 56 + 40V + 64E。MemoryOfDigraph.java 根据经验计算,假设没有缓存
Integer值—Java 通常会缓存-128 到 127 之间的整数。
创造性问题
有向欧拉回路。 有向欧拉回路是一个包含每条边恰好一次的有向循环。编写一个有向图客户端 DirectedEulerianCycle.java 来查找有向欧拉回路或报告不存在这样的回路。
提示: 证明一个有向图 G 有一个有向欧拉回路当且仅当 G 中的每个顶点的入度等于出度,并且所有具有非零度的顶点属于同一个强连通分量。
强连通分量。 描述一个计算包含给定顶点 v 的强连通分量的线性时间算法。基于该算法,描述一个简单的二次时间算法来计算有向图的强连通分量。
部分解决方案: 计算包含 s 的强连通分量
找到从 s 可达的顶点集
找到可以到达 s 的顶点集
取两个集合的交集,使用这个作为子程序,你可以在时间比例为 t(E + V)的情况下找到所有强连通分量,其中 t 是强连通分量的数量。
DAG 中的哈密顿路径。 给定一个 DAG,设计一个线性时间算法来确定是否存在一个访问每个顶点恰好一次的有向路径。
解决方案: 计算一个拓扑排序,并检查拓扑顺序中每对连续顶点之间是否有边。
唯一拓扑排序。 设计一个算法来确定一个有向图是否有唯一的拓扑排序。
提示: 一个有向图有一个唯一的拓扑排序当且仅当拓扑排序中每对连续顶点之间存在一个有向边(即,有向图有一个哈密顿路径)。如果有向图有多个拓扑排序,那么可以通过交换一对连续顶点来获得第二个拓扑排序。
2-可满足性。 给定一个布尔公式,其合取范式中有 M 个子句和 N 个文字,每个子句恰好有两个文字,找到一个满足的赋值(如果存在)。
解决方案草图: 用 2N 个顶点(每个文字及其否定一个)形成蕴含有向图。对于每个子句 x + y,从 y'到 x 和从 x'到 y 包括边缘。声明:如果没有变量 x 与其否定 x'在同一个强连通分量中,则公式是可满足的。此外,核心 DAG 的拓扑排序(将每个强连通分量缩减为单个顶点)产生一个满足的赋值。
基于队列的拓扑排序算法。 开发一个非递归的拓扑排序实现 TopologicalX.java,该实现维护一个顶点索引数组,用于跟踪每个顶点的入度。在一次遍历中初始化数组和源队列,就像练习 4.2.7 中那样。然后,执行以下操作,直到源队列为空:
从队列中移除一个源并标记它。
减少入度数组中与已移除顶点的边的目标顶点对应的条目。
如果减少任何条目使其变为 0��则将相应的顶点插入源队列。
最短有向循环。 给定一个有向图,设计一个算法来找到具有最少边数的有向循环(或报告图是无环的)。你的算法在最坏情况下的运行时间应该与E V成正比。
应用: 给出一组需要肾移植的患者,每个患者都有一个愿意捐赠肾脏但类型不匹配的家庭成员。愿意捐赠给另一个人,前提是他们的家庭成员得到肾脏。然后医院进行“多米诺手术”,所有移植同时进行。
解决方案: 从每个顶点 s 运行 BFS。通过 s 的最短循环是一条边 v->s,再加上从 s 到 v 的最短路径。ShortestDirectedCycle.java。
奇数长度的有向循环。 设计一个线性时间算法,以确定一个有向图是否有一个奇数长度的有向循环。
解决方案。 我们声称,如果一个有向图 G 有一个奇数长度的有向循环,那么它的一个(或多个)强连通分量作为无向图时是非二分的。
如果有向图 G 有一个奇数长度的有向循环,则此循环将完全包含在一个强连通分量中。当强连通分量被视为无向图时,奇数长度的有向循环变为奇数长度的循环。回想一下,无向图是二分的当且仅当它没有奇数长度的循环。
假设 G 的一个强连通分量是非二分图(当作无向图处理时)。这意味着在强连通分量中存在一个奇数长度的循环 C,忽略方向。如果 C 是一个有向循环,那么我们完成了。否则,如果边 v->w 指向“错误”的方向,我们可以用指向相反方向的奇数长度路径替换它(这保留了循环中边数的奇偶性)。要了解如何做到这一点,请注意存在一条从 w 到 v 的有向路径 P,因为 v 和 w 在同一个强连通分量中。如果 P 的长度为奇数,则我们用 P 替换边 v->w;如果 P 的长度为偶数,则这条路径 P 与 v->w 组合在一起就是一个奇数长度的循环。
DAG 中可达的顶点。 设计一个线性时间算法,以确定一个 DAG 是否有一个顶点可以从每个其他顶点到达。
解决方案。 计算每个顶点的出度。如果 DAG 有一个出度为 0 的顶点 v,那么它可以从每个其他顶点到达。
有向图中可达的顶点。 设计一个线性时间算法,以确定有向图是否有一个顶点可以从每个其他顶点到达。
解决方案。 计算强连通分量和核 DAG。将练习 4.2.37 应用于核 DAG。
网络爬虫。 编写一个程序 WebCrawler.java,使用广度优先搜索来爬取网络有向图,从给定的网页开始。不要显式构建网络有向图。
网络练习
符号有向图。 修改 SymbolGraph.java 以创建一个实现符号有向图的程序 SymbolDigraph.java。
组合电路。 给定输入,确定组合电路的真值是一个图可达性问题(在有向无环图上)。
权限提升。 如果 A 可以获得 B 的权限,则在用户类 A 到用户类 B 之间包含一个数组。找出所有可以在 Windows 中获得管理员访问权限的用户。
Unix 程序 tsort。
跳棋。 将跳棋规则扩展到一个 N×N 的跳棋棋盘。展示如何确定一个跳棋在当前移动中是否可以变成国王。(使用 BFS 或 DFS。)展示如何确定黑方是否有获胜的着法。(找到一个有向欧拉路径。)
优先附着模型。 网络具有无标度特性,并遵循幂律。新页面倾向于优先附着到受欢迎的页面上。从指向自身的单个页面开始。每一步中,一个新页面出现,出度为 1。以概率 p,页面指向一个随机页面;以概率(1-p),页面指向一个现有页面,概率与页面的入度成比例。
子类型检查。 给定单继承关系(一棵树),检查 v 是否是 w 的祖先。提示:v 是 w 的祖先当且仅当 pre[v] ⇐ pre[w]且 post[v] >= post[w]。
子类型检查。 重复上一个问题,但使用有向无环图而不是树。
有根树的 LCA。 给定一个有根树和两个顶点 v 和 w,找到顶点 v 和 w 的最低共同祖先(lca)。顶点 v 和 w 的 lca 是离根最远的共同祖先。根树上最基本的问题之一。可以在 O(1)的查询时间内解决,预处理时间为线性时间(Harel-Tarjan,Bender-Coloton)。
找到一个有向无环图,其中最短的祖先路径通向一个不是 LCA 的共同祖先 x。
九个字母的单词。 找到一个九个字母的英文单词,使得在适当的顺序中依次删除每个字母后仍然是一个英文单词。使用单词和顶点构建一个有向图,如果一个单词可以通过添加一个字母形成另一个单词,则在两个单词之间添加一条边。
答案:一个解决方案是 startling → starting → staring → string → sting → sing → sin → in → i。
电子表格重新计算。 希望没有循环依赖。使用公式单元格图的拓扑排序来确定更新单元格的顺序。
嵌套箱子。 一个维度为 d 的箱子,其尺寸为(a1, a2, ..., ad),如果第二个箱子的坐标可以重新排列,使得 a1 < b1, a2 < b2, ..., ad < bd,则该箱子嵌套在第二个箱子内。
给出一个有效的算法,用于确定一个 d 维箱子嵌套在另一个箱子内的位置。提示:排序。
证明嵌套是传递的:如果箱子 i 嵌套在箱子 j 内部,箱子 j 又嵌套在箱子 k 内部,那么箱子 i 也嵌套在箱子 k 内部。
给定一组 n 个 d 维箱子,给出一个有效的算法,找到可以同时嵌套最多箱子的方法。
提示:创建一个有向图,如果箱子 i 嵌套在箱子 j 内部,则从箱子 i 到箱子 j 添加一条边。然后运行拓扑排序。
Warshall 的传递闭包算法。 WarshallTC.java 算法适用于稠密图。依赖于 AdjMatrixDigraph.java。
暴力强连通分量算法。 BruteSCC.java 通过首先计算传递闭包来计算强连通分量。时间复杂度为 O(EV),空间复杂度为 O(V²)。
Tarjan 的强连通分量算法。 TarjanSCC.java 实现了 Tarjan 算法来计算强连通分量。
Gabow 的强连通分量算法。 GabowSCC.java 实现了 Gabow 算法来计算强连通分量。
有向图生成器。 DigraphGenerator.java 生成各种有向图。
有限马尔可夫链. 回归状态:一旦在状态开始,马尔可夫链将以概率 1 返回。瞬时状态:有些概率它永远不会返回(某个节点 j,i 可以到达 j,但 j 无法到达 i)。不可约马尔可夫链=所有状态都是回归的。马尔可夫链是不可约的当且仅当它是强连通的。回归组件是核 DAG 中没有离开边的组件。马尔可夫链中的通信类是强连通分量。
定理. 如果 G 是强连通的,则存在唯一的稳态分布 pi。此外,对于所有 v,pi(v) > 0。
定理. 如果 G 的核 DAG 具有单个没有离开边的超节点,则存在唯一的稳态分布 pi。此外,对于所有回归的 v,pi(v) > 0 且对于所有瞬时的 v,pi(v) = 0。
后代引理. [R. E. Tarjan] 将 pre[v]和 post[v]分别表示为 v 的前序和后序编号,nd[v]表示 v 的后代数(包括 v)。证明以下四个条件是等价的。
顶点 v 是顶点 w 的祖先。
pre[v] ⇐ pre[w] < pre[v] + nd(v).
post[v] - nd [v] < post[w] ⇐ post[v]
pre[v] ⇐ pre[w]且 post[v] >= post[w](嵌套引理)
边引理. [R. E. Tarjan] 证明边(v, w)是以下四种之一:
w 是 v 的子节点:(v, w)是一条树边。
w 是 v 的后代但不是子节点:(v, w)是一条前向边。
w 是 v 的祖先:(v, w)是一条后向边
w 和 v 无关且 pre[v] > pre[w]:(v, w)是一条交叉边。
路径引理. [R. E. Tarjan] 证明从 v 到 w 的任何路径,其中 pre[v] < pre[w],都包含 v 和 w 的共同祖先。
证明如果(v, w)是一条边且 pre[v] < pre[w],则 v 是 DFS 树中 w 的祖先。
后序引理. [R. E. Tarjan] 证明如果 P 是一条路径,最后一个顶点 x 在后序中最高,则路径上的每个顶点都是 x 的后代(因此与 x 有一条路径)。
解. 证明通过对 P 的长度进行归纳(或通过反证法)。设(v, w)是一条边,其中 w 是 x 的后代且 post[v] < post。由于 w 是 x 的后代,我们有 pre[w] >= pre。
如果 pre[v] >= pre,那么 v 是 x 的后代(通过嵌套引理)。
如果 pre[v] < pre,那么 pre[v] < pre[w],这意味着(通过前一个练习)v 是 w 的祖先,因此与 x 有关。但是 post[v] < post意味着 v 是 x 的后代。
前拓扑排序. 设计一个线性时间算法来找到一个前拓扑排序:一种顶点的排序,使得如果从 v 到 w 有一条路径且 w 在排序中出现在 v 之前,则从 w 到 v 也必须有一条路径。
提示:反向后序是一种前拓扑排序。这是 Kosaraju-Sharir 算法正确性证明的关键。
Wordnet. 使用 WordNet 测量形容词的语义取向.
垃圾收集. 在像 Java 这样的语言中进行自动内存管理是一个具有挑战性的问题。分配内存很容易,但发现程序何时完成对内存的使用(并回收它)更加困难。引用计数:不适用于循环链接结构。标记-清除算法。根=局部变量和静态变量。从根运行 DFS,标记所有从根引用的变量,依此类推。然后,进行第二遍:释放所有未标记的对象并取消标记所有标记的对象。或者复制垃圾收集器将所有标记的对象移动到单个内存区域。每个对象使用一个额外的位。JVM 在进行垃圾收集时必须暂停。碎片化内存。
应用:C 泄漏检测器(泄漏=不可达的,未释放的内存)。
有向循环检测应用。 应用:检查非法继承循环,检查死锁。目录是文件和其他目录的列表。符号链接是对另一个目录的引用。在列出目录中的所有文件时,需要小心避免跟随符号链接的循环!
拓扑排序应用。 应用:课程先修条件、大型计算机程序组件的编译顺序、因果关系、类继承、死锁检测、时间依赖性、计算作业的管道、检查符号链接循环、电子表格中的公式求值。
强连通分量应用。 应用于 CAD、马尔可夫链(不可约)、蜘蛛陷阱和网络搜索、指针分析、垃圾回收。
单向街定理。 实现一个算法来定向无向图中的边,使其成为强连通图。罗宾斯定理断言,当且仅当无向图是双边连通的(没有桥)时,这是可能的。在这种情况下,一种解决方案是运行深度优先搜索(DFS),并将 DFS 树中的所有边定向远离根节点,将所有剩余的边定向朝向根节点。
定向混合图中的边以使其无环。 混合图是具有一些有向边和一些无向边的图。设计一个线性时间算法来确定是否可以定向无向边,使得结果有向图是无环的。
应用:老城区的狭窄道路希望使每条道路单向通行,但仍允许城市中的每个交叉口可从其他城市到达。
定向混合图中的边以形成有向循环。 混合图是具有一些有向边和一些无向边的图。设计一个线性时间算法来确定是否可以定向无向边,使得结果有向图具有有向循环。
应用:确定最大流是否唯一。
解决方案:一个算法。
后序引理变种。 设 S 和 T 是有向图 G 中的两个强连通分量。证明如果存在一条从 S 中的一个顶点到 T 中的一个顶点的边 e,则 S 中顶点的最高后序编号高于 T 中顶点的最高后序编号。
DAG 中路径的数量。 给定一个有向无环图(DAG)和两个特定顶点 s 和 t,设计一个线性时间算法来计算从 s 到 t 的有向路径数量。
提示:拓扑排序。
DAG 中长度为 L 的路径。 给定一个有向无环图(DAG)和两个特定顶点 s 和 t,设计一个算法来确定是否存在一条从 s 到 t 的路径,其中恰好包含 L 条边。
核心顶点。 给定一个有向图 G,如果从顶点 v 可以到达 G 中的每个顶点,则顶点 v 是一个核心顶点。设计一个线性时间算法来找到所有核心顶点。
提示:创建 G 的强连通分量并查看核心 DAG。
强连通分量和二分图匹配。 给定一个二分图 G,一个未匹配边是指不出现在任何完美匹配中的边。设计一个算法来找到所有未匹配边。
提示:证明以下算法可以胜任。在 G 中找到一个完美匹配;将匹配中的边从双分区的一侧定向到另一侧;将剩余的边定向到相反方向;在不在完美匹配中的边中,返回那些端点在不同强连通分量中的边。
有向图的传递闭包。 有向图的传递闭包是具有与原始有向图相同传递闭包的边数最少的有向图。设计一个 V(E + V)算法来计算有向图的传递闭包。请注意,有向图中的传递闭包不一定是唯一的,也不一定是原始有向图的子图。(有向无环图中的传递闭包是唯一的且是原始有向图的子图。)
奇长度路径。 给定一个有向图 G 和一个源顶点 s,设计一个线性时间算法,确定通过具有奇数边数的路径(不一定简单)从 s 可达的所有顶点。
解决方案:为 G 中的每个顶点 v 创建一个新的有向图 G',其中包含两个顶点 v 和 v'。对于 G 中的每条边 v->w,包括两条边:v->w'和 w->v'。现在,在 G'中从 s 到 v'的任何路径对应于 G 中从 s 到 v 的奇长度路径。运行 BFS 或 DFS 以确定从 s 可达的顶点。
找到一个有向无环图(DAG)的拓扑排序,无论深度优先搜索(DFS)以何种顺序选择起始顶点,都无法计算为 DFS 的逆后序。展示出 DAG 的每一个拓扑排序都可以被计算为 DFS 的逆后序,只要 DFS 可以任意选择构造函数中起始顶点的顺序。
非递归 DFS。 编写一个��序 NonrecursiveDirectedDFS.java,使用显式栈而不是递归来实现深度优先搜索。编写一个程序 NonrecursiveDirectedCycle.java,在不使用递归的情况下找到一个有向环。
非递归拓扑排序。 将基于队列的拓扑排序算法 TopologicalX.java 从练习 4.2.39 扩展到在有向图存在有向环时找到该有向环。将程序命名为 DirectedCycle.java。
Cartalk 难题。 在字典中找到一个具有以下特性的最长单词:您可以一次删除一个字母(从任一端或中间),结果字符串也是字典中的单词。例如,STRING 是一个具有此特性的 6 字母单词(STRING → STING → SING → SIN → IN → I)。
逆后序与前序。 真或假:有向图的逆后序与有向图的前序相同。
Kosaraju–Sharir 算法中的逆后序与前序。 假设您在 Kosaraju–Sharir 算法中使用有向图的前序而不是逆后序。它是否仍会产生强连通分量?
答案:不会,运行 KosarajuSharirPreorderSCC.java 在
tinyDG.txt上。
4.3 最小生成树
原文:
algs4.cs.princeton.edu/43mst译者:飞龙
最小生成树。
带权重的图 是一种我们为每条边关联权重或成本的图。带权重图的*最小生成树(MST)*是其边权重之和不大于任何其他生成树的生成树。
假设。
为了简化演示,我们采用以下约定:
图是连通的。 我们定义的生成树条件意味着图必须是连通的才能存在 MST。如果图不连通,我们可以调整算法以计算其每个连通分量的 MST,统称为最小生成森林。
边的权重不一定是距离。 几何直觉有时是有益的,但边的权重可以是任意的。
边的权重可能为零或负数。 如果边的权重都是正数,则定义最小生成树为连接所有顶点的总权重最小的子图即可。
边的权重都不同。 如果边可以具有相同的权重,则最小生成树可能不唯一。做出这种假设简化了我们一些证明,但我们的所有算法即使在存在相同权重的情况下也能正常工作。
基本原理。
我们回顾树的两个定义性质:
添加连接树中两个顶点的边会创建一个唯一的循环。
从树中移除一条边会将其分成两个独立的子树。

图的切割是将其顶点划分为两个不相交集合。跨越边是连接一个集合中的顶点与另一个集合中的顶点的边。我们假设为简单起见,所有边的权重都是不同的。在此假设下,MST 是唯一的。定义切割和循环。以下性质导致多种 MST 算法。
命题。(切割性质)
在带权重图中的任何切割中(所有边权重不同),最小权重的跨越边在图的 MST 中。
切割性质是��们考虑 MST 问题的算法的基础。具体来说,它们是贪心算法的特例。
命题。(贪心 MST 算法)
以下方法将所有连接的带权重图的 MST 中的所有边涂黑:从所有边都涂灰色开始,找到没有黑色边的切割,将其最小权重的边涂黑,继续直到涂黑 V-1 条边。
最小生成树问题](../Images/fce4a44e5b52cd8391fb6ea99f7fa182.png)
带权重图数据类型。
我们使用以下 API 表示带权重的边:
either() 和 other() 方法用于访问边的顶点;compareTo() 方法通过权重比较边。Edge.java 是一个直接的实现。
我们使用以下 API 表示带权重的图:

我们允许平行边和自环。EdgeWeightedGraph.java 使用邻接表表示法实现 API。

MST API.
我们使用以下 API 计算带权重图的最小生成树:
我们准备了一些测试数据:
tinyEWG.txt 包含 8 个顶点和 16 条边
mediumEWG.txt 包含 250 个顶点和 1,273 条边
1000EWG.txt 包含 1,000 个顶点和 8,433 条边
10000EWG.txt 包含 10,000 个顶点和 61,731 条边
largeEWG.txt 包含一百万个顶点和 7,586,063 条边
Prim 算法。
Prim 算法通过在每一步将新边附加到单个增长树上来工作:从任何顶点开始作为单个顶点树;然后向其添加 V-1 条边,始终取下一个(着色为黑色)连接树上顶点与尚未在树上的顶点的最小权重边(对于由树顶点定义的切割的跨越边)。
Prim 算法的一句描述留下了一个关键问题:我们如何(高效地)找到最小权重的跨越边?
懒惰实现. 我们使用优先队列来保存跨越边并找到最小权重的边。每次我们将一条边添加到树中时,我们也将一个顶点添加到树中。为了维护跨越边的集合,我们需要将从该顶点到任何非树顶点的所有边添加到优先队列中。但我们必须做更多的事情:连接刚刚添加的顶点到已经在优先队列中的树顶点的任何边现在变得不合格(它不再是跨越边,因为它连接了两个树顶点)。懒惰实现将这样的边留在优先队列中,推迟不合格测试到我们删除它们时。
LazyPrimMST.java 是这种懒惰方法的实现。它依赖于 MinPQ.java 优先队列。
![Prim 算法(懒惰实现)]()
急切实现. 为了改进 Prim 算法的懒惰实现,我们可以尝试从优先队列中删除不合格的边,以便优先队列只包含跨越边。但我们可以消除更多的边。关键在于注意到我们唯一感兴趣的是从每个非树顶点到树顶点的最小边。当我们将顶点 v 添加到树中时,与每个非树顶点 w 相关的唯一可能变化是,添加 v 使 w 比以前更接近树。简而言之,我们不需要在优先队列中保留所有从 w 到树顶点的边 - 我们只需要跟踪最小权重的边,并检查是否添加 v 到树中需要我们更新该最小值(因为边 v-w 的权重更低),我们可以在处理 s 邻接列表中的每条边时做到这一点。换句话说,我们只保留优先队列中的一条边用于每个非树顶点:连接它与树的最短边。
![Prim 算法(急切实现)]()
PrimMST.java 是这种急切方法的实现。它依赖于 IndexMinPQ.java 索引优先队列来执行减少键操作。
命题。
Prim 算法计算任何连通的边权重图的最小生成树。Prim 算法的懒惰版本使用空间与 E 成比例,时间与 E log E 成比例(在最坏情况下)来计算具有 E 条边和 V 个顶点的连通边权重图的最小生成树;急切版本使用空间与 V 成比例,时间与 E log V 成比例(在最坏情况下)。
Kruskal 算法。
Kruskal 算法按照它们的权重值(从小到大)的顺序处理边,每次添加不与先前添加的边形成循环的边作为 MST(着色为黑色),在添加 V-1 条边后停止。黑色边形成逐渐演变为单一树 MST 的树林。
要实现 Kruskal 算法,我们使用优先队列按权重顺序考虑边,使用并查集数据结构标识导致循环的边,使用队列收集最小生成树边。程序 KruskalMST.java 按照这些方式实现了 Kruskal 算法。它使用了辅助的 MinPQ.java、UF.java 和 Queue.java 数据类型。
命题。
Kruskal 算法使用额外空间与 E 成正比,时间与 E log E 成正比(在最坏情况下)来计算具有 E 条边和 V 个顶点的任何连通边权图的最小生成树。
练习
证明,通过给所有权重加上一个正常数或将它们全部乘以一个正常数,不会影响最小生成树。
解决方案. Kruskal 算法只通过
compareTo()方法访问边权重。给每个权重添加一个正常数(或乘以一个正常数)不会改变compareTo()方法的结果。证明,如果一个图的边都有不同的权重,那么最小生成树是唯一的。
解决方案. 为了推导矛盾,假设图 G 有两个不同的最小生成树,称为 T1 和 T2。设 e = v-w 是 G 中在 T1 或 T2 中的最小权重边,但不在两者中都存在。假设 e 在 T1 中。将 e 添加到 T2 中会创建一个循环 C。C 中至少有一条边,假设为 f,不在 T1 中(否则 T1 就是循环的)。根据我们选择的 e,w(e) ≤ w(f)。由于所有边的权重都不同,w(e) < w(f)。现在,在 T2 中用 e 替换 f 会得到一棵权重小于 T2 的新生成树(与 T2 的最小性相矛盾)。
如何找到边权图的最大生成树?
解决方案. 反转每条边的权重(或在
compareTo()方法中反转比较的意义)。为 EdgeWeightedGraph.java 实现从输入流读取边权图的构造函数。
确定 EdgeWeightedGraph.java 用于表示具有 V 个顶点和 E 条边的图所使用的内存量,使用第 1.4 节的内存成本模型。
解决方案. 56 + 40V + 112E。MemoryOfEdgeWeightedGraph.java 通过假设没有缓存
Integer值来进行经验计算—Java 通常会缓存 -128 到 127 的整数。给定边权图 G 的最小生成树,假设删除一个不会使 G 断开的边。描述如何在与 E 成正比的时间内找到新图的最小生成树。
解决方案. 如果边不在最小生成树中,则旧的最小生成树是更新后图的最小生成树。否则,从最小生成树中删除边会留下两个连通分量。添加一个顶点在每个连通分量中的最小权重边。
给定边权图 G 的最小生成树和一个新边 e,描述如何在与 V 成正比的时间内找到新图的最小生成树。
解决方案. 将边 e 添加到最小生成树会创建一个唯一的循环。删除此循环上的最大权重边。
为 EdgeWeightedGraph.java 实现
toString()。假设你实现了 Prim 算法的急切版本,但是不使用优先队列来找到下一个要添加到树中的顶点,而是扫描
distTo[]数组中的所有V个条目,找到具有最小值的非树顶点。在具有 V 个顶点和 E 条边的图上,Prim 算法的急切版本的最坏情况运行时间的增长顺序是多少?如果有的话,什么时候这种方法是合适的?为什么?请解释你的答案。解决方案. Prim 算法的运行时间将与 V² 成正比,这对于稠密图是最佳的。
为 PrimMST.java 实现
edges()。
创意问题
最小生成森林。 开发 Prim 和 Kruskal 算法的版本,计算不一定连通的边权图的最小生成森林。
解决方案。 PrimMST.java 和 KruskalMST.java 实现了这一点。
认证。 编写一个名为
check()的方法,使用以下割优化条件来验证提议的边集是否实际上是最小生成树(MST):如果一组边是一棵生成树,并且每条边都是通过从树中移除该边定义的割的最小权重边,则这组边就是 MST。你的方法的运行时间增长率是多少?解决方案。 KruskalMST.java。
实验
Boruvka 算法。 开发 Boruvka 算法的实现 BoruvkaMST.java:通过将边添加到不断增长的树森林中来构建 MST,类似于 Kruskal 算法,但是分阶段进行。在每个阶段,找到将每棵树连接到另一棵树的最小权重边,然后将所有这样的边添加到 MST 中。假设边的权重都不同,以避免循环。提示:维护一个顶点索引数组,以标识连接每个组件到其最近邻居的边,并使用并查集数据结构。
备注。 由于每个阶段树的数量至少减少一半,所以最多有 log V 个阶段。这种方法高效且可以并行运行。
网页练习
最小瓶颈生成树。 图 G 的最小瓶颈生成树是 G 的一棵生成树,使得生成树中任意边的最大权重最小化。设计一个算法来找到最小瓶颈生成树。
解决方案。 每个 MST 都是最小瓶颈生成树(但不一定反之)。这可以通过割性质来证明。
最小中位数生成树。 图 G 的最小中位数生成树是 G 的一棵生成树,使得其权重的中位数最小化。设计一个高效的算法来找到最小中位数生成树。
解决方案。 每个 MST 都是最小中位数生成树(但不一定反之)。
迷宫生成。 使用随机化的 Kruskal 或 Prim 算法创建迷宫。
唯一 MST。 设计一个算法来确定给定图 G 的 MST 是否唯一。
随机生成树。 给定图 G,均匀随机生成 G 的一棵生成树。使用 Aldous 和 Broder 的以下显著定理:从任意顶点 s 开始,并进行随机游走,直到每个顶点都被访问过(在所有相邻边中均匀随机选择一条出边)。如果一个顶点以前从未被访问过,则将边添加到该顶点以形成生成树 T。那么 T 是图 G 的均匀随机生成树。预期的运行时间受限于 G 的覆盖时间,最多与 EV 成比例。
最小权重反馈边集。 图的反馈边集是包含图中每个循环中至少一条边的子集。如果删除反馈边集的边,则结果图将是无环的。设计一个高效的算法,在具有正边权的加��图中找到最小权重的反馈边集。
两个 MST 中边权重的分布。 假设加权有向图有两个 MST T1 和 T2。证明如果 T1 有权重为 w 的 k 条边,则 T2 也有权重为 w 的 k 条边。
美国计算奥林匹克问题。 在一个城市中有 N 栋房子,每栋房子都需要供水。在第 i 栋房子建造井的成本为 w[i]美元,在第 i 和第 j 栋房子之间建造管道的成本为 c[i][j]。如果一栋房子建有井或者有一条管道路径通向有井的房子,那么这栋房子就可以接收水。设计一个算法来找到供应每栋房子所需的最小金额。
解决方案.: 创建一个带有 N+1 个顶点的边权图(每个房子一个顶点加上一个源顶点 x)。包括每对顶点 i 和 j 之间的成本 c[i][j] 的边(表示潜在的管道)。包括源 s 和每个房子 i 之间成本为 w[i] 的边(表示潜在的开放井)。在这个边权图中找到一个最小生成树。
恰好有 k 条橙色边的生成树。 给定一个边缘着色为橙色或黑色的图,设计一个线性对数算法来找到一个包含恰好 k 条橙色边的生成树(或报告不存在这样的生成树)。
最小方差生成树。 给定一个连通的边权重图,找到一个最小生成树,使其边权重的方差最小化。
4.4 最短路径
原文:
algs4.cs.princeton.edu/44sp译者:飞龙
最短路径。
加权有向图是一个有向图,其中我们为每条边关联权重或成本。从顶点 s 到顶点 t 的最短路径是从 s 到 t 的有向路径,具有没有更低权重的其他路径的属性。
属性。
我们总结了几个重要的属性和假设。
路径是有方向的。 最短路径必须遵守其边的方向。
权重不一定是距离。 几何直觉可能有所帮助,但边的权重可能代表时间或成本。
并非所有顶点都需要可达。 如果 t 从 s 不可达,则根本没有路径,因此从 s 到 t 的最短路径也不存在。
负权重引入了复杂性。 目前,我们假设边的权重是正数(或零)。
最短路径通常是简单的。 我们的算法忽略形成循环的零权重边,因此它们找到的最短路径没有循环。
最短路径不一定是唯一的。 从一个顶点到另一个顶点可能有多条最低权重的路径;我们满足于找到其中任何一条。
并行边和自环可能存在。 在文本中,我们假设不存在并行边,并使用符号 v->w 来表示从 v 到 w 的边,但我们的代码可以轻松处理它们。
加权有向图数据类型。
我们使用以下 API 表示加权边:
from()和to()方法对于访问边的顶点很有用。DirectedEdge.java 实现了这个 API。
我们使用以下 API 表示加权有向图:

EdgeWeightedDigraph.java 使用邻接表表示实现了该 API。

最短路径 API。
我们使用以下 API 计算加权有向图的最短路径:
我们准备了一些测试数据:
tinyEWD.txt 包含 8 个顶点和 15 条边
mediumEWD.txt 包含 250 个顶点和 2,546 条边
1000EWG.txt 包含 1,000 个顶点和 16,866 条边
10000EWG.txt 包含 10,000 个顶点和 123,462 条边
largeEWG.txt 包含一百万个顶点和 15,172,126 条边。
单源最短路径的数据结构。
给定一个加权有向图和一个指定的顶点 s,最短路径树(SPT)是一个子图,包含 s 和所有从 s 可达的顶点,形成以 s 为根的有向树,使得每条树路径都是图中的最短路径。
我们用两个顶点索引数组表示最短路径:
最短路径树上的边:
edgeTo[v]是从 s 到 v 的最短路径上的最后一条边。到源的距离:
distTo[v]是从 s 到 v 的最短路径的长度。

松弛。
我们的最短路径实现基于一种称为松弛的操作。我们将distTo[s]初始化为 0,对于所有其他顶点 v,将distTo[v]初始化为无穷大。
边松弛。 对边 v->w 进行松弛意味着测试从 s 到 w 的已知最佳路径是否是从 s 到 v,然后沿着从 v 到 w 的边,如果是,则更新我们的数据结构。
private void relax(DirectedEdge e) { int v = e.from(), w = e.to(); if (distTo[w] > distTo[v] + e.weight()) { distTo[w] = distTo[v] + e.weight(); edgeTo[w] = e; } }![边松弛]()
顶点松弛。 我们所有的实现实际上都会松弛从给定顶点指向的所有边。
private void relax(EdgeWeightedDigraph G, int v) { for (DirectedEdge e : G.adj(v)) { int w = e.to(); if (distTo[w] > distTo[v] + e.weight()) { distTo[w] = distTo[v] + e.weight(); edgeTo[w] = e; } } }
迪杰斯特拉算法。
戴克斯特拉算法将dist[s]初始化为 0,将所有其他distTo[]条目初始化为正无穷。然后,它重复地放松并将具有最低distTo[]值的非树顶点添加到树中,继续直到所有顶点都在树上或没有非树顶点具有有限的distTo[]值。
DijkstraSP.java 是戴克斯特拉算法的高效实现。它使用 IndexMinPQ.java 作为优先队列。
命题。
戴克斯特拉算法使用额外空间与 V 成正比,时间与 E log V 成正比(在最坏情况下)解决了带非负权重的带权有向图中的单源最短路径问题。
无环带权有向图。
我们使用术语带权有向无环图来指代无环带权有向图。
带权有向无环图中的单源最短路径问题。我们现在考虑一种用于查找最短路径的算法,对于带权有向无环图而言,它比戴克斯特拉算法更简单且更快。
它在线性时间内解决了单源问题。
它处理负边权重。
它解决了相关问题,如查找最长路径。
该算法将顶点放松与拓扑排序结合起来。我们将
distTo[s]初始化为 0,将所有其他distTo[]值初始化为无穷大,然后按照拓扑顺序放松顶点。AcyclicSP.java 是这种方法的实现。它依赖于这个版本的 Topological.java,扩展以支持带权有向图。带权有向无环图中的单源最长路径问题。我���可以通过将
distTo[]值初始化为负无穷大并在relax()中改变不等式的意义来解决带权有向无环图中的单源最长路径问题。AcyclicLP.java 实现了这种方法。关键路径法。我们考虑并行的有前置约束的作业调度问题:给定一组指定持续时间的作业,其中有前置约束规定某些作业必须在某些其他作业开始之前完成,我们如何在相同数量的处理器上安排这些作业,以便它们在最短的时间内完成,同时仍然遵守约束条件?
![作业调度问题]()
![作业调度解决方案]()
通过将问题制定为带权有向无环图中的最长路径问题,可以解决此问题:创建一个带权有向无环图,其中包含一个源 s,一个汇 t,以及每个作业的两个顶点(一个起始顶点和一个结束顶点)。对于每个作业,从其起始顶点到其结束顶点添加一条权重等于其持续时间的边。对于每个前置约束 v->w,从对应于 v 的结束顶点到对应于 w 的开始顶点添加一条零权重边。还从源到每个作业的起始顶点和从每个作业的结束顶点到汇添加零权重边。
![作业调度问题简化为最长路径]()
现在,根据从源到达的最长路径的长度安排每个作业的时间。
![作业调度问题关键路径]()
CPM.java 是关键路径法的实现。
命题。
通过按拓扑顺序放松顶点,我们可以在时间复杂度为 E + V 的情况下解决带权有向无环图的单源最短路径和最长路径问题。
一般带权有向图中的最短路径。
如果(i)所有权重为非负或(ii)没有循环,则可以解决最短路径问题。
负循环。负循环是一个总权重为负的有向循环。如果存在负循环,则最短路径的概念是没有意义的。
![带有负循环的加权有向图]()
因此,我们考虑没有负循环的加权有向图。
贝尔曼-福特算法。将
distTo[s]初始化为 0,将所有其他distTo[]值初始化为无穷大。然后,以任意顺序考虑有向图的边,并放松所有边。进行 V 次这样的遍历。for (int pass = 0; pass < G.V(); pass++) for (int v = 0; v < G.V(); v++) for (DirectedEdge e : G.adj(v)) relax(e);我们不详细考虑这个版本,因为它总是放松 V E 条边。
基于队列的贝尔曼-福特算法。可能导致
distTo[]变化的唯一边是那些离开上一轮中distTo[]值发生变化的顶点的边。为了跟踪这样的顶点,我们使用一个 FIFO 队列。BellmanFordSP.java 通过维护两个额外的数据结构来实现这种方法:一个要放松的顶点队列
一个顶点索引的布尔数组
onQ[],指示哪些顶点在队列上,以避免重复
负循环检测。在许多应用中,我们的目标是检查并提取负循环。因此,我们向 API 添加以下方法:
![负循环检测的 API]()
当且仅当在所有边的第 V 次遍历后队列非空时,从源可达负循环。此外,我们
edgeTo[]数组中的边子图必须包含一个负循环。因此,为了实现negativeCycle(),BellmanFordSP.java 从edgeTo[]中的边构建一个加权有向图,并在该图中查找循环。为了找到循环,它使用 EdgeWeightedDirectedCycle.java,这是第 4.3 节中 DirectedCycle.java 的一个版本,适用于加权有向图。我们通过仅在每次第 V 次边放松后执行此检查来分摊此检查的成本。套汇检测。考虑一个基于商品交易的金融交易市场。rates.txt 中的表显示了货币之间的转换率。文件的第一行是货币 V 的数量;然后文件每行给出货币的名称,然后是转换为其他货币的汇率。套汇机会是一个有向循环,使得交换率的乘积大于 1。例如,我们的表格显示,1000 美元可以购买 1000.00 × .741 = 741 欧元,然后我们可以用我们的欧元购买 741 × 1.366 = 1,012.206 加拿大元,最后,我们可以用我们的加拿大元购买 1,012.206 × .995 = 1,007.14497 美元,获得 7.14497 美元的利润!
![汇率]()
![套汇机会]()
为了将套汇问题制定为负循环检测问题,将每个权重替换为其对数的负值。通过这种改变,在原问题中通过乘以边权重来计算路径权重对应于在转换后的问题中将它们相加。Arbitrage.java 通过解决相应的负循环检测问题来识别货币兑换网络中的套汇机会。
命题。
在加权有向图中,从 s 到 v 存在最短路径当且仅当从 s 到 v 存在至少一条有向路径,并且从 s 到 v 的任何有向路径上的顶点都不在负循环上。
命题。
贝尔曼-福特算法解决了给定源 s 的单源最短路径问题(或找到从 s 可达的负循环)对于具有 V 个顶点和 E 条边的任意加权有向图,在最坏情况下,时间复杂度为 E V,额外空间复杂度为 V。
问与答
Q. Dijkstra 算法能处理负权重吗?
A. 是和否。有两种已知的最短路径算法称为Dijkstra 算法,取决于一个顶点是否可以多次入队到优先队列。当权重为非负时,这两个版本是相同的(因为没有顶点会多次入队)。DijkstraSP.java 中实现的版本(允许一个顶点多次入队)在存在负边权(但没有负环)时是正确的,但其最坏情况下的运行时间是指数级的。(我们注意到 DijkstraSP.java 如果边权重为负数,则会抛出异常,以便程序员不会对这种指数级行为感到惊讶。)如果我们修改 DijkstraSP.java 以使一个顶点不能多次入队(例如,使用marked[]数组标记那些已经被松弛的顶点),那么算法保证在E log V时间内运行,但当存在负权边时可能产生错误结果。
练习
真或假。将每个边权重增加一个常数不会改变单源最短路径问题的解决方案。
解决方案。 假。
为 EdgeWeightedDigraph.java 提供
toString()的实现。使用第 1.4 节的内存成本模型确定 EdgeWeightedDigraph.java 用于表示具有V个顶点和E条边的图所使用的内存量。
解决方案。 56 + 40V + 72E。MemoryOfEdgeWeightedDigraph.java 根据经验计算,假设没有缓存
Integer值 - Java 通常缓存-128 到 127 的整数。从第 4.2 节中的
DirectedCycle和Topological类中使用本节的EdgeweightedDigraph和DirectedEdgeAPI,从而实现 EdgeWeightedDirectedCycle.java 和 Topological.java。假设我们通过为
EdgeWeightedGraph中的每个Edge创建两个DirectedEdge对象(分别在每个方向上)来将EdgeWeightedGraph转换为EdgeWeightedDigraph,然后使用贝尔曼-福特算法。解释为什么这种方法会失败得惊人。解决方案: 即使带权图不包含负权重环,这可能会引入负成本循环。
如果在贝尔曼-福特算法的同一遍历中允许一个顶点被多次入队会发生什么?
答案: 算法的运行时间可能呈指数增长。例如,考虑所有边权重均为-1 的完全带权有向图会发生什么。
创意问题
有向无环图中的最长路径。 开发一个实现 AcyclicLP.java 的程序,可以解决带权有向无环图中的最长路径问题。
线上的所有对最短路径。 给定一个加权线图(无向连通图,所有顶点的度为 2,除了两个端点的度为 1),设计一个算法,在线性时间内预处理图,并能在常数时间内返回任意两个顶点之间最短路径的距离。
部分解决方案。 找到一个度为 1 的顶点 s,并运行广度优先(或深度优先)搜索以找到其余顶点出现的顺序。然后,计算从 s 到每个顶点 v 的最短路径长度,称为
dist[v]。顶点 v 和 w 之间的最短路径是|dist[v] - dist[w]|。单调最短路径。 给定一个带权有向图,找到从 s 到每个其他顶点的单调最短路径。如果路径上每条边的权重要么严格递增要么严格递减,则路径是单调的。
部分解决方案: 按升序松弛边并找到最佳路径;然后按降序松弛边并找到最佳路径。
Dijkstra 算法的懒惰实现。 开发一个实现 LazyDijkstraSP.java 的 Dijkstra 算法的懒惰版本,该版本在文本中有描述。
Bellman-Ford 队列永不为空。 证明如果在基于队列的 Bellman-Ford 算法中从源可达到一个负循环,那么队列永远不会为空。
解决方案:考虑一个负循环,并假设对于循环 W 上的所有边,
distTo[w] <= distTo[v] + length(v, w)。对循环上的所有边进行这个不等式求和意味着循环的长度是非负的。Bellman-Ford 负循环检测。 证明如果在通用 Bellman-Ford 算法的第 V 次遍历中有任何边被松弛,那么
edgeTo[]数组中就有一个有向循环,并且任何这样的循环都是负循环。解决方案:待定。
网络练习
最优子结构性质。 证明从 v 到 w 的最短路径上的每个子路径也是两个端点之间的最短路径。
唯一最短路径树。 假设从 s 到每个其他顶点都有唯一的最短路径。证明 SPT 是唯一的。
没有负循环。 证明如果通用算法终止,则从 s 可达的地方没有负循环。提示:在终止时,从 s 可达的所有边都满足
distTo[w] <= distTo[v] + e.weight()。将这个不等式对沿循环的所有边相加。前驱图。 真或假。在没有负循环的边权重有向图中执行 Bellman-Ford 时,遵循
edgeTo[]数组总是会回到 s 的路径。对 Dijkstra 算法重复这个问题。Yen 对 Bellman-Ford 的改进。 [参考] 将边分为两个 DAGs A 和 B:A 由从较低索引顶点到较高索引顶点的边组成;B 由从较高索引顶点到较低索引顶点的边组��。在 Bellman-Ford 的一个阶段中遍历所有边时,首先按顶点编号的升序(A 的拓扑顺序)遍历 A 中的边,然后按顶点编号的降序(B 的拓扑顺序)遍历 B 中的边。在遍历 A 中的边时,SPT 中从具有正确
distTo[]值的顶点开始并且仅使用 A 中的边的任何路径都会得到正确的distTo[]值;B 也是如此。所需的遍历次数是路径上 A-B 交替的最大次数,最多为(V+1)/2。因此,所需的遍历次数最多为(V+1)/2,而不是 V。替换路径。 给定具有非负权重和源 s 以及汇 t 的边权重有向图,设计一个算法,找到从 s 到 t 的最短路径,该路径不使用每条边 e。你的算法的增长顺序应为 E V log V。
道路网络数据集。
互联网路由。 OSPF(开放最短路径优先)是互联网路由中广泛使用的协议,使用了迪杰斯特拉算法。RIP(路由信息协议)是另一种基于贝尔曼-福特算法的路由协议。
具有跳过一条边的最短路径。 给定具有非负权重的边权重有向图,设计一个 E log V 算法,用于找到从 s 到 t 的最短路径,其中您可以将任意一条边的权重更改为 0。
解决方案。 计算从 s 到每个其他顶点的最短路径;计算从每个顶点到 t 的最短路径。对于每条边 e = (v, w),计算从 s 到 v 的最短路径长度和从 w 到 t 的最短路径长度的和。最小的这样的和提供了最短的这样的路径。
无向图中的最短路径。 编写一个程序 DijkstraUndirectedSP.java,使用迪杰斯特拉算法解决非负权重的无向图中的单源最短路径问题。
弗洛伊德-沃舍尔算法。 FloydWarshall.java 实现了弗洛伊德-沃舍尔算法,用于全对最短路径问题。其时间复杂度与 V³ 成正比,空间复杂度与 V² 成正比。它使用了 AdjMatrixEdgeWeightedDigraph.java。
随机贝尔曼-福特算法。 [参考资料] 假设我们在 Yen 算法中均匀随机选择顶点顺序(其中 A 包含所有从排列中较低顶点到较高顶点的边)。证明预期的通过次数最多为(V+1)/3。
苏尔巴勒算法。 给定具有非负边权重和两个特殊顶点 s 和 t 的有向图,找到从 s 到 t 的两条边不相交的路径,使得这两条路径的权重之和最小。
解决方案。 这可以通过巧妙地应用迪杰斯特拉算法来实现,即苏尔巴勒算法。
5. 字符串
原文:
algs4.cs.princeton.edu/50strings译者:飞龙
概述。
我们通过交换字符串来进行通信。我们考虑经典算法来解决围绕以下应用程序的基本计算挑战:
5.1 字符串排序 包括 LSD 基数排序、MSD 基数排序和用于对字符串数组进行排序的三向基数快速排序。
5.2 Trie 描述了用于实现具有字符串键的符号表的 R-way trie 和三向搜索 trie。
5.3 子字符串搜索 描述了在大段文本中搜索子字符串的算法,包括经典的 Knuth-Morris-Pratt、Boyer-Moore 和 Rabin-Karp 算法。
5.4 正则表达式 介绍了一种称为 grep 的基本搜索工具,我们用它来搜索不完全指定的子字符串。
5.5 数据压缩 介绍了数据压缩,我们试图将字符串的大小减少到最小。我们介绍了经典的 Huffman 和 LZW 算法。
游戏规则。
为了清晰和高效,我们的实现是基于 Java String 类表达的。我们简要回顾它们最重要的特性。
字符.
String是字符序列。字符的类型是char,可以有 2¹⁶ 种可能的值。几十年来,程序员们一直关注编码为 7 位 ASCII 或 8 位扩展 ASCII 的字符,但许多现代应用程序需要 16 位 Unicode。不可变性.
String对象是不可变的,因此我们可以在赋值语句中使用它们,并且作为方法的参数和返回值,而不必担心它们的值会改变。索引.
charAt()方法以常数时间从字符串中提取指定字符。长度.
length()方法以常数时间返回字符串的长度。子字符串.
substring()方法通常以常数时间提取指定的子字符串。警告:从 Oracle 和 OpenJDK Java 7,更新 6 开始,
substring()方法在提取的子字符串大小上需要线性时间和空间。由于我们没有预料到这种 drastical 变化,我们的一些字符串处理代码将受到影响。String API 对其任何方法,包括substring()和charAt(),都不提供性能保证。教训是自行承担风险。查看这篇文章获取更多细节。
![字符串操作]()
连接.
+运算符执行字符串连接。我们避免逐个字符附加形成字符串,因为在 Java 中这是一个 二次时间 的过程。(Java 有一个 StringBuilder 类用于这种用途。)字符数组. Java 的
String不是原始类型。标准实现提供了上述操作,以便于客户端编程。相比之下,我们考虑的许多算法可以使用低级表示,比如一个char值数组,许多客户端可能更喜欢这种表示,因为它占用更少的空间并且耗时更少。
字母表。
一些应用程序涉及从受限字母表中获取的字符串。在这种应用程序中,使用具有以下 API 的 Alphabet.java 类通常是有意义的:
构造函数以 R 个字符的字符串作为参数,该字符串指定了字母表;toChar()和toIndex()方法在常数时间内在字符串字符和介于 0 和 R-1 之间的int值之间进行转换。R()方法返回字母表或基数中的字符数。包括一些预定义的字母表:

Count.java 是一个客户端程序,它在命令行上指定一个字母表,读取该字母表上的一系列字符(忽略不在字母表中的字符),计算每个字符出现的频率,
本章中的 Java 程序。
以下是本章中的 Java 程序列表。单击程序名称以访问 Java 代码;单击参考号以获取简要描述;阅读教科书以获取全面讨论。
REF 程序 描述 / JAVADOC - Alphabet.java 字母表 - Count.java 字母表客户端 5.1 LSD.java LSD 基数排序 5.2 MSD.java MSD 基数排序 - InplaceMSD.java 原地 MSD 基数排序¹ 5.3 Quick3string.java 三向字符串快速排序 - AmericanFlag.java 美国国旗排序¹ - AmericanFlagX.java 非递归美国国旗排序¹ 5.4 TrieST.java 多向字典树符号表 - TrieSET.java 多向字典树集合 5.5 TST.java 三向单词查找树 5.6 KMP.java 子字符串查找(Knuth–Morris–Pratt) 5.7 BoyerMoore.java 子字符串查找(Boyer–Moore) 5.8 RabinKarp.java 子字符串查找(Rabin–Karp) 5.9 NFA.java 正则表达式的 NFA - GREP.java grep - BinaryDump.java 二进制转储 - HexDump.java 十六进制转储 - PictureDump.java 图片转储 - Genome.java 基因组编码 - RunLength.java 数据压缩(行程长度编码) 5.10 Huffman.java 数据压缩(赫夫曼) 5.11 LZW.java 数据压缩(Lempel–Ziv–Welch)
Q + A
Q. 什么是 Unicode。
A. Unicode(通用字符编码)= 复杂的 21 位代码,用于表示国际符号和其他字符。
Q. 什么是 UTF-16。
A. UTF-16(Unicode 转换格式)= 复杂的 16 位可变宽度代码,用于表示 Unicode 字符。大多数常见字符使用 16 位(一个char)表示,但代理对使用一对char值表示。如果第一个char值在D800和DFFF之间,则与下一个char(在相同范围内)组合形成代理对。没有 Unicode 字符对应于D800到DFFF。例如,007A表示小写字母 Z,6C34表示中文水的符号,D834 DD1E表示音乐的 G 大调。
Q. 什么是子字符串陷阱?
A. 字符串方法调用s.substring(i, j)返回 s 从索引 i 开始到 j-1 结束的子字符串(而不是在 j 结束,正如你可能会怀疑的那样)。
Q. 如何更改字符串的值?
A. 在 Java 中无法修改字符串,因为字符串是不可变的。如果你想要一个新的字符串,那么你必须使用字符串连接或返回新字符串的字符串方法之一,如toLowerCase()或substring()来创建一个新的字符串。
网页练习
**挤压空格。**编写一个程序 Squeeze.java,该程序接受一个字符串作为输入,并删除相邻的空格,最多保留一个空格。
**删除重复项。**给定一个字符串,创建一个新字符串,其中删除所有连续的重复项。例如,
ABBCCCCCBBAB变为ABCBAB。**N 个 x 的字符串。**描述以下函数返回的字符串,给定一个正整数
N?public static String mystery(int N) { String s = ""; while(N > 0) { if (N % 2 == 1) s = s + s + "x"; else s = s + s; N = N / 2; } return s; }**回文检查。**编写一个函数,该函数以字符串作为输入,并在字符串是回文时返回
true,否则返回false。回文是指字符串从前往后读和从后往前读是相同的。**Watson-Crick 互补回文检查。**编写一个函数,该函数以字符串作为输入,并在字符串是 Watson-Crick 互补回文时返回
true,否则返回false。Watson-Crick 互补回文是指 DNA 字符串等于其反向的互补(A-T,C-G)。**Watson-Crick 互补。**编写一个函数,该函数以 A、C、G 和 T 字符的 DNA 字符串作为输入,并返回以其互补替换所有字符的反向字符串。例如,如果输入是 ACGGAT,则返回 ATCCGT。
**完美洗牌。**给定长度相同的两个字符串
s和t,以下递归函数返回什么?public static String mystery(String s, String t) { int N = s.length(); if (N <= 1) return s + t; String a = mystery(s.substring(0, N/2), t.substring(0, N/2)); String b = mystery(s.substring(N/2, N), t.substring(N/2, N)); return a + b; }**二叉树表示。**编写一个名为
TreeString.java的数据类型,使用二叉树表示不可变字符串。它应该支持在常数时间内进行连接,并在与字符数成比例的时间内打印出字符串。**反转字符串。**编写一个递归函数来反转一个字符串。不要使用任何循环。提示:使用 String 方法
substring()。public static String reverse(String s) { int N = s.length(); if (N <= 1) return s; String a = s.substring(0, N/2); String b = s.substring(N/2, N); return reverse(b) + reverse(a); }你的方法效率如何?我们的方法具有线性对数运行时间。
**随机字符串。**编写一个递归函数,创建一个由字符'A'和'Z'之间的随机字符组成的字符串。
public static String random(int N) { if (N == 0) return ""; if (N == 1) return 'A' + StdRandom.uniform(26); return random(N/2) + random(N - N/2); }**子序列。**给定两个字符串
s和t,编写一个程序 Subsequence.java,确定s是否是t的子序列。也就是说,s的字母应该按照相同的顺序出现在t中,但不一定是连续的。例如accag是taagcccaaccgg的子序列。最长互补回文。 在 DNA 序列分析中,互补回文是一个等于其反向互补的字符串。腺嘌呤(A)和胸腺嘧啶(T)是互补的,胞嘧啶(C)和鸟嘌呤(G)也是互补的。例如,ACGGT 是一个互补回文。这样的序列作为转录结合位点,并与基因扩增和遗传不稳定性相关。给定一个长度为 N 的文本输入,找到文本的最长互补回文子串。例如,如果文本是
GACACGGTTTTA,那么最长的互补回文是ACGGT。提示:将每个字母视为奇数长度可能回文的中心,然后将每对字母视为偶数长度可能回文的中心。DNA 转 RNA。 编写一个函数,该函数接受一个 DNA 字符串(A、C、G、T)并返回相应的 RNA 字符串(A、C、G、U)。
DNA 互补。 编写一个函数,该函数以 DNA 字符串(A、C、G、T)作为输入,并返回互补的碱基对(T、G、C、A)。DNA 通常以双螺旋结构存在。两条互补的 DNA 链以螺旋结构连接在一起。
从十六进制转换为十进制。 Hex2Decimal.java 包含一个函数,该函数接受一个十六进制字符串(使用 A-F 表示数字 11-15)并返回相应的十进制整数。它使用了一些字符串库方法和霍纳方法。
public static int hex2decimal(String s) { String digits = "0123456789ABCDEF"; s = s.toUpperCase(); int val = 0; for (int i = 0; i < s.length(); i++) { char c = s.charAt(i); int d = digits.indexOf(c); val = 16*val + d; } return val; }替代方案:
Integer.parseInt(String s, int radix)。更加健壮,并且适用于负整数。
5.1 字符串排序
原文:
algs4.cs.princeton.edu/51radix译者:飞龙
本节正在大规模施工中。
LSD 基数排序。
程序 LSD.java 实现了用于固定长度字符串的 LSD 基数排序。它包括一种用于对待每个整数作为 4 字节字符串处理的 32 位整数进行排序的方法。当 N 很大时,这种算法比系统排序快 2-3 倍。
MSD 基数排序。
程序 MSD.java 实现了 MSD 基数排序。
三向字符串快速排序。
程序 Quick3string.java 实现了三向字符串快速排序。
问与答
练习
频率计数。 读入一个字符串列表并打印它们的频率计数。算法:将字符串读入数组,使用三向基数快速排序对它们进行排序,并计算它们的频率计数。加速奖励:在三向分区期间计算计数。缺点:使用空间存储所有字符串。备选方案:TST。
对均匀分布数据进行排序。 给定 N 个来自 [0, 1] 区间的随机实数,考虑以下算法对它们进行排序:将 [0, 1] 区间分成 N 个等间距子区间。重新排列(类似于累积计数)这 N 个元素,使每个元素都在其适当的桶中。对每个桶中的元素进行插入排序(或者等效地,只对整个文件进行插入排序)。也就是说,对一个级别进行 MSD 基数排序,然后切换到插入排序。[尝试原地进行?] 解决方案:平均总共需要 O(N) 的时间。设 n_i 是桶 i 中的元素数量。插入排序所有桶的预期时间是 O(n),因为 E[sum_i (n_i)²] ⇐ 2n。
给定一个包含 N 个不同长度的十进制整数的数组,描述如何在 O(N + K) 的时间内对它们进行排序,其中 K 是所有 N 个整数的总位数。
美国国旗排序。(原地键索引计数)给定一个包含 N 个介于 0 和 R-1 之间的不同值的数组,以线性时间和 O(R) 的额外空间对它们进行升序排列。导致(本质上)原地字符串排序。
提示:计算
count[]数组,告诉你键需要放置的位置。扫描输入数组。取第一个键,找到它应该属于的桶,并将其交换到相应的位置(更新相应的count[]条目)。重复第二个键,但要小心跳过已知属于其位置的键。
网络练习
2-sum. 给定一个包含 N 个 64 位整数的数组
a[]和一个目标值 T,确定是否存在两个不同的整数 i 和 j,使得a[i]+a[j]等于 T。你的算法应该在最坏情况下线性时间运行。解决方案。在线性时间内对数组进行基数排序。从左到右扫描指针 i 和从右到左扫描指针 j:考虑 a[i] + a[j]。如果它大于 T,则推进 j 指针;如果它小于 T,则推进 i 指针;如果它等于 T,则我们找到了所需的索引。
注意,整数数组可以使用 Franceschini、Muthukrishnan 和 Patrascu 的高级基数排序算法在线性时间和常数额外空间内进行基数排序。
在排序的字符串数组中进行二分查找。 实现一个用于排序字符串数组的二分查找版本,它跟踪查询字符串与 lo 和 hi 端点之间已知相同字符的数���。利用这些信息在二分查找过程中避免字符比较。比较此算法与调用
compareTo()的版本的性能。(compareTo()方法的优点是它不需要调用charAt(),因为它是作为String数据类型的实例方法实现的。)
5.2 查找树
原文:
algs4.cs.princeton.edu/52trie译者:飞龙
本节正在大规模建设中。
具有字符串键的符号表。
可以使用标准符号表实现。而是利用字符串键的附加结构。为字符串(以及其他以数字表示的键)定制搜索算法。目标:像哈希一样快速,比二叉搜索树更灵活。可以有效地支持额外的操作,包括前缀和通配符匹配,例如,IP 路由表希望转发到 128.112.136.12,而实际上转发到 128.112 是它已知的最长匹配前缀。附带好处:快速且占用��间少的字符串搜索。
R 向查找树。 程序 TrieST.java 使用多向查找树实现了一个字符串符号表。
三向查找树。 程序 TST.java 使用三向查找树实现了一个字符串符号表。
参考:快速排序和搜索的算法 作者 Bentley 和 Sedgewick。
属性 A.(Bentley-Sedgewick)给定一个输入集,无论字符串插入的顺序如何,其 TST 中的节点数都是相同的。
证明。在集合中,TST 中每个不同字符串前缀都有一个唯一的节点。节点在 TST 中的相对位置可能会根据插入顺序而改变,但节点数是不变的。
高级操作。
通配符搜索,前缀匹配。R 向查找树和 TST 实现包括用于通配符匹配和前缀匹配的代码。
惰性删除 = 更改单词边界位。急切删除 = 清理任何死亡父链接。
应用:T9 手机文本输入。用户使用手机键盘键入;系统显示所有对应的单词(并在唯一时自动完成)。如果用户键入 0,系统会显示所有可能的自动完成。
问答
练习
编写 R 向查找树字符串集和 TST 的非递归版本。
长度为 L 的唯一子字符串。 编写一个程序,从标准输入中读取文本并计算其包含的长度为 L 的唯一子字符串的数量。例如,如果输入是
cgcgggcgcg,那么长度为 3 的唯一子字符串有 5 个:cgc、cgg、gcg、ggc和ggg。应用于数据压缩。提示:使用字符串方法substring(i, i + L)提取第 i 个子字符串并插入符号表。另一种解决方案:使用第 i 个子字符串的哈希值计算第 i+1 个子字符串的哈希值。在第一千万位数的π或者第一千万位数的π上测试它。唯一子字符串。 编写一个程序,从标准输入中读取文本并计算任意长度的不同子字符串的数量。(可以使用后缀树非常高效地完成。)
文档相似性。 要确定两个文档的相似性,计算每个三字母组(3 个连续字母)的出现次数。如果两个文档的三字母组频率向量的欧几里德距离很小,则它们相似。
拼写检查。 编写一个程序 SpellChecker.java,它接受一个包含英语词汇的字典文件的名称,然后从标准输入读取字符串并打印出不在字典中的任何单词。使用一个字符串集。
垃圾邮件黑名单。 将已知的垃圾邮件地址插入到存在表中,并用于阻止垃圾邮件。
按国家查找 IP。 使用数据文件ip-to-country.csv来确定给定 IP 地址来自哪个国家。数据文件有五个字段(IP 地址范围的开始,IP 地址范围的结束,两个字符的国家代码,三个字符的国家代码和国家名称。请参阅IP-to-country 网站。IP 地址不重叠。这样的数据库工具可用于:信用卡欺诈检测,垃圾邮件过滤,网站上语言的自动选择以及 Web 服务器日志分析。
Web 的倒排索引。 给定一个网页列表,创建包含网页中包含的单词的符号表。将每个单词与出现该单词的网页列表关联起来。编写一个程序,读取一个网页列表,创建符号表,并通过返回包含该查询单词的网页列表来支持单词查询。
Web 的倒排索引。 扩展上一个练习,使其支持多词查询。在这种情况下,输出包含每个查询词至少出现一次的网页列表。
带有重复项的符号表。
密码检查器。 编写一个程序,从命令行读取一个字符串和从标准输入读取一个单词字典,并检查它是否是一个“好”密码。在这里,假设“好”意味着(i)至少有 8 个字符长,(ii)不是字典中的单词,(iii)不是字典中的单词后跟一个数字 0-9(例如,hello5),(iv)不是由一个数字分隔的两个单词(例如,hello2world)。
反向密��检查器。 修改上一个问题,使得(ii)-(v)也适用于字典中单词的反向形式(例如,olleh 和 olleh2world)。巧妙的解决方案:将每个单词及其反向形式插入符号表中。
随机电话号码。 编写一个程序,接受一个命令行输入 N,并打印 N 个形式为(xxx)xxx-xxxx 的随机电话号码。使用符号表避免多次选择相同的号码。使用这个区号列表来避免打印虚假的区号。使用 R 向 Trie。
包含前缀。 向
StringSET添加一个方法containsPrefix(),接受字符串 s 作为输入,并在集合中存在包含 s 作为前缀的字符串时返回 true。子字符串匹配。 给定一个(短)字符串列表,您的目标是支持查询,其中用户查找字符串 s,您的任务是报告列表中包含 s 的所有字符串。提示:如果您只想要前缀匹配(字符串必须以 s 开头),请使用文本中描述的 TST。要支持子字符串匹配,请将每个单词的后缀(例如,string,tring,ring,ing,ng,g)插入 TST 中。
Zipf 定律。 哈佛语言学家乔治·齐普夫观察到,包含 N 个单词的英文文本中第 i 个最常见单词的频率大致与 1/i 成比例,其中比例常数为 1 + 1/2 + 1/3 + ... + 1/N。通过从标准输入读取一系列单词,制表它们的频率,并与预测的频率进行比较来测试“齐普夫定律”。
打字猴和幂律。(Micahel Mitzenmacher)假设一个打字猴通过将每个 26 个可能的字母以概率 p 附加到当前单词来创建随机单词,并以概率 1 - 26p 完成单词。编写一个程序来估计生成的单词长度的频率分布。如果“abc”被生成多次,则只计算一次。
打字猴和幂律。 重复上一个练习,但假设字母 a-z 出现的概率与以下概率成比例,这是英文文本的典型概率。
CHAR FREQ CHAR FREQ CHAR FREQ CHAR FREQ CHAR FREQ A 8.04 G 1.96 L 4.14 Q 0.11 V 0.99 B 1.54 H 5.49 M 2.53 R 6.12 W 1.92 C 3.06 I 7.26 N 7.09 S 6.54 X 0.19 D 3.99 J 0.16 O 7.60 T 9.25 Y 1.73 E 12.51 K 0.67 P 2.00 U 2.71 Z 0.09 F 2.30 书的索引。 编写一个程序,从标准输入中读取一个文本文件,并编制一个按字母顺序排列的索引,显示哪些单词出现在哪些行,如下所示的输入。忽略大小写和标点符号。
It was the best of times, it was the worst of times, it was the age of wisdom, it was the age of foolishness, age 3-4 best 1 foolishness 4 it 1-4 of 1-4 the 1-4 times 1-2 was 1-4 wisdom 4 worst 2熵。 我们定义一个包含 N 个单词的文本语料库的相对熵为 E = 1 / (N log N) * sum (p[i] log(k) - log(p[i]), i = 1..k),其中 p_i 是单词 i 出现的次数的比例。编写一个程序,读取一个文本语料库并打印出相对熵。将所有字母转换为小写,并将标点符号视为空格。
最长前缀。 真或假。二进制字符串 x 在符号表中的最长前缀要么是 x 的下取整,要么是 x 的上取整(如果 x 在集合中则两者都是)。
错误。在 { 1, 10, 1011, 1111 } 中,1100 的最长前缀是 1,而不是 1011 或 1111。
创意练习
网页练习
5.3 �� 子字符串搜索
原文:
algs4.cs.princeton.edu/53substring译者:飞龙
本节正在大规模施工中。在长字符串中搜索 - 在线。
这个网站是一个关于精确字符串搜索算法的重要资源。
Java 中的高性能模式匹配用于一般字符串搜索,带通配符的搜索和带字符类的搜索。
程序 Brute.java 是暴力字符串搜索。基本上等同于 SystemSearch.java。
拉宾卡普。
程序 RabinKarp.java 实现了拉宾卡普随机指纹算法。
Knuth-Morris-Pratt。
程序 KMP.java 是 Knuth-Morris-Pratt 算法。KMPplus.java 是一个改进版本,时间和空间复杂度与 M + N 成正比(与字母表大小 R 无关)。
Boyer-Moore。
程序 BoyerMoore.java 实现了 Boyer-Moore 算法的坏字符规则部分。它不实现强好后缀规则。
入侵检测系统。
需要非常快速的字符串搜索,因为这些部署在网络的瓶颈处。应用
问答
练习
设计一个从右到左扫描模式的暴力子字符串搜索算法。
展示 Brute-Force 算法的跟踪,样式类似于图 XYZ,用于以下模式和文本字符串。
AAAAAAAB; AAAAAAAAAAAAAAAAAAAAAAAAB
ABABABAB; ABABABABAABABABABAAAAAAAA
确定以下模式字符串的 KMP DFA。
AAAAAAAB
AACAAAB
ABABABAB
ABAABAAABAAAB
ABAABCABAABCB
假设模式和文本是在大小为 R >= 2 的字母表上的随机字符串。证明字符比较的期望次数为(N - M + 1) (1 - R-M) / (1 - R-1) ⇐ 2 (N - M + 1)。
构造一个例子,其中 Boyer-Moore 算法(仅使用坏字符规则)性能较差。
如何修改拉宾卡普算法以搜索给定模式,并附加条件中间字符是一个“通配符”(任何文本字符都可以匹配它)。
如何修改拉宾卡普算法以确定文本中是否存在 k 个模式子集中的任何一个(比如,所有长度相同)?
解决方案。 计算 k 个模式的哈希值,并将哈希值存储在一个集合中。
如何修改拉宾卡普算法以在 N×N 文本中搜索 M×M 模式?或者在 N×N 文本中搜索其他不规则形状的模式?
蒙特卡洛与拉斯维加斯拉宾卡普。
在线回文检测。 逐个读入字符。报告每个瞬间当前字符串是否是回文。提示:使用 Karp-Rabin 哈希思想。
串联重复。 在字符串 s 中,基本字符串 b 的串联重复是由至少一个连续的基本字符串 b 的副本组成的子字符串。给定 b 和 s,设计一个算法,在 s 中找到 b 的最大长度的串联重复。运行时间应与 M + N 成正比,其中 M 是 b 的长度,N 是 s 的长度。
解决方案。 这个问题是子字符串搜索的一般化(s 中是否至少有一个连续的 b 的副本?),所以我们需要一个泛化的子字符串搜索算法。创建 k 个 b 的连接副本的 Knuth-Morris-Pratt DFA,其中 k = n/m。现在,在输入 s 上模拟 DFA 并记录它达到的最大状态。从中,我们可以识别最长的串联重复。
后缀前缀匹配。 设计一个线性时间算法,找到一个字符串a的最长后缀,恰好匹配另一个字符串b的前缀。
循环旋转。 设计一个线性时间算法来确定一个字符串是否是另一个字符串的循环旋转。如果字符串a是字符串b的循环旋转,那么a和b具有相同的长度,a由b的后缀和前缀组成。
循环字符串的子串。 设计一个线性时间算法来确定一个字符串 a 是否是循环字符串 b 的子串。
最长回文子串。 给定一个字符串 s,找到最长的回文子串(或 Watson-crick 回文串)。解决方案:可以使用后缀树或Manacher's algorithm在线性时��内解决。这里有一个通常在线性对数时间内运行的更简单的解决方案。首先,我们描述如何在线性时间内找到长度恰好为 L 的所有回文子串:使用 Karp-Rabin 迭代地形成每个长度为 L 的子串(及其反转)的哈希值,并进行比较。由于你不知道 L,重复将你对 L 的猜测加倍,直到你知道最佳长度在 L 和 2L 之间。然后使用二分查找找到确切的长度。
解决方案。 Manacher.java 是 Manacher 算法的实现。
重复子串。 [ Mihai Patrascu] 给定一个整数 K 和长度为 N 的字符串,找到至少出现 K 次的最长子串。
一个解决方案。 假设你知道重复字符串的长度 L。对长度为 L 的每个子串进行哈希处理,并检查任何哈希是否出现 K 次或更多。如果是,检查以确保你没有运气不佳。由于你不知道 L,重复将你对 L 的猜测加倍,直到你知道最佳长度在 L 和 2L 之间。然后使用二分查找找到正确的值。
最长公共子串。 给定两个(或三个)字符串,找到在所有三个字符串中都出现的最长子串。提示:假设你知道最长公共子串的长度 L。对长度为 L 的每个子串进行哈希处理,并检查任何哈希桶是否包含每个字符串的(至少)一个条目。
所有匹配。 修改 KMP 以在线性时间内找到所有匹配(而不是最左匹配)。
斐波那契字符串。 KMP 的有趣案例。F(1) = B, F(2) = A, F(3) = AB, F(4) = ABA, F(5) = ABAAB, F(N) = F(N-1) F(N-2)。
假设 x 和 y 是两个字符串。设计一个线性时间算法来确定是否存在整数 m 和 n 使得 xm = yn(其中 x^m 表示 x 的 m 个副本的连接)。
解决方案。 只需检查 xy = yx 的位置(这个事实并不平凡 - 它来自于 Lyndon-Schutzenberger 定理)。
字符串的周期。 让 s 为一个非空字符串。如果对于所有 i = 0, 1, ..., N-p-1 都有 s[i] = s[i+p],则整数 p 被称为 s 的周期。字符串 s 的周期是是 s 的周期中最小的整数 p(可以是 N)。例如,ABCABCABCABCAB 的周期是 3。设计一个线性时间算法来计算字符串的周期。
字符串的边界。 给定一个非空字符串 s,如果 s = yw = wz 对于一些字符串 y、z 和 w 且 |y| = |z| = p,则我们将字符串 w 定义为 s 的边界,即 w 是 s 的既是前缀又是后缀的一个合适子串。字符串的边界是 s 的最长合适边界(可以为空)。例如,ABCABCABCABCAB 的边界是 w = ABCABCAB(其中 y = ABC,z = CAB,p = 3)。设计一个线性时间算法来计算字符串的边界。
变位词子串搜索。 给定长度为 N 的文本字符串 txt[] 和长度为 M 的模式字符串 pat[],确定 pat[] 或其任何变位词(其 M! 种排列之一)是否出现在文本中。
提示:在文本中维护长度为 M 的给定子串的字母频率直方图。
5.4 正则表达式
原文:
algs4.cs.princeton.edu/54regexp译者:飞龙
本节正在大力整理中。
正则表达式。
NFA.java, DFS.java, Digraph.java, 和 GREP.java.
运行时间。
M = 表达式长度,N = 输入长度。正则表达式匹配算法可以在 O(M)时间内创建 NFA,并在 O(MN)时间内模拟输入。
库实现。
Validate.java。
大多数正则表达式库实现使用回溯算法,在某些输入上可能需要指数级的时间。这样的输入可能非常简单。例如,确定长度为 N 的字符串是否与正则表达式(a|aa)*b匹配,如果选择字符串得当,可能需要指数级的时间。下表展示了 Java 1.4.2 正则表达式的失败情况。
java Validate "(a|aa)*b" aaaaaaaaaaaaaaaaaaaaaaaaaaaaaac 1.6 seconds
java Validate "(a|aa)*b" aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaac 3.7 seconds
java Validate "(a|aa)*b" aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaac 9.7 seconds
java Validate "(a|aa)*b" aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaac 23.2 seconds
java Validate "(a|aa)*b" aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaac 62.2 seconds
java Validate "(a|aa)*b" aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaac 161.6 seconds
java Validate "(a*)*|b*" aaaaaaaaaaaaaaaaaaaaac 1.28
java Validate "(a*)*|b*" aaaaaaaaaaaaaaaaaaaaaac 2.45
java Validate "(a*)*|b*" aaaaaaaaaaaaaaaaaaaaaaac 4.54
java Validate "(a*)*|b*" aaaaaaaaaaaaaaaaaaaaaaaac 8.84
java Validate "(a*)*|b*" aaaaaaaaaaaaaaaaaaaaaaaaac 17.74
java Validate "(a*)*|b*" aaaaaaaaaaaaaaaaaaaaaaaaaac 33.77
java Validate "(a*)*|b*" aaaaaaaaaaaaaaaaaaaaaaaaaaac 67.72
java Validate "(a*)*|b*" aaaaaaaaaaaaaaaaaaaaaaaaaaaac 134.73
上述示例是人为的,但它们展示了大多数正则表达式库中的一个令人担忧��缺陷。在实践中确实会出现不良输入。根据Crosby 和 Wallach的说法,以下正则表达式出现在 SpamAssassin 的一个版本中,这是一个功能强大的垃圾邮件过滤程序。
[a-z]+@[a-z]+([a-z\.]+\.)+[a-z]+
它试图匹配某些电子邮件地址,但在许多正则表达式库中,包括 Sun 的 Java 1.4.2 中,匹配某些字符串需要指数级的时间。
java Validate "[a-z]+@[a-z]+([a-z\.]+\.)+[a-z]+" spammer@x......................
这尤其重要,因为垃圾邮件发送者可以使用一种病态的返回电子邮件地址来拒绝服务攻击一个运行 SpamAssassin 的邮件服务器。这个特定的模式现在已经修复,因为 Perl 5 正则表达式使用内部缓存来在回溯过程中在相同位置短路重复匹配。
这些缺陷不仅限于 Java 的实现。例如,GNU regex-0.12 对于匹配形式为aaaaaaaaaaaaaac的字符串与正则表达式(a*)*|b*需要指数级的时间。Sun 的 Java 1.4.2 同样容易受到这个问题的影响。此外,Java 和 Perl 正则表达式支持反向引用 - 对于这些扩展正则表达式的正则表达式模式匹配问题是NP 难的,因此在某些输入上这种指数级的增长似乎是固有的。
这是我实际写的一个,用来找到字符串NYSE之前的最后一个单词:regexp = "([\w\s]+).*NYSE";
参考:正则表达式匹配可以简单快速(但在 Java、Perl、PHP、Python、Ruby 等中很慢)。比较了 Thompson NFA 和回溯方法。包含了一些针对 Thompson NFA 的性能优化。还有一些历史注释和参考资料。
Q + A
Q. Java 正则表达式库的文档?
A. 这里是 Oracle 关于使用正则表达式的指南。它包括更多操作,我们不会探索。还请参阅String方法matches()、split()和replaceAll()。这些是使用Pattern和Matcher类的简写。这里有一些常见的正则表达式模式。
Q. 用于电子邮件地址、Java 标识符、整数、小数等的工业级别正则表达式?
A. 这里有一个有用的正则表达式库,提供了工业级别的模式,用于匹配电子邮件地址、URL、数字、日期和时间。试试这个正则表达式工具。
Q. 我困惑为什么(a | b)*匹配所有的 a 和 b 的字符串,而不仅仅是所有 a 的字符串或所有 b 的字符串?
A. *操作符复制正则表达式(而不是匹配正则表达式的固定字符串)。因此,上述等同于ε | (a|b) | (a|b)(a|b) | (a|b)(a|b)(a|b) | ....
Q. 历史?
A. 在 1940 年代,沃伦·麦卡洛克和沃尔特·皮茨将神经元建模为有限自动机来描述神经系统。1956 年,史蒂夫·克利纳发明了一种数学抽象称为正则集来描述这些模型。神经网络和有限自动机中事件的表示,《自动机研究》,3-42 页,普林斯顿大学出版社,新泽西州普林斯顿,1956 年。
Q. 有哪些可视化正则表达式的工具?
A. 尝试Debuggerx。
练习
为以下每组二进制字符串编写正则表达式。只使用基本操作。
0 或 11 或 101
只有 0
答案:0 | 11 | 101, 0*
为以下每组二进制字符串编写正则表达式。只使用基本操作。
所有二进制字符串
所有二进制字符串,除了空字符串
以 1 开头,以 1 结尾
以 00 结尾
包含至少三个 1
答案:(0|1), (0|1)(0|1), 1 | 1(0|1)*1, (0|1)*00, (0|1)1(0|1)1(0|1)1(0|1)或 010101(0|1)。
编写一个正则表达式描述字母表{a, b, c}上按排序顺序的输入。答案:abc*。
为以下每组二进制字符串编写正则表达式。只使用基本操作。
包含至少三个连续的 1
包含子串 110
包含子串 1101100
不包含子串 110
答案:(0|1)111(0|1), (0|1)110(0|1), (0|1)1101100(0|1), (0|10)1。最后一个是最棘手的。
为至少有两个 0 但不连续的 0 的二进制字符串编写正则表达式。
为以下每组二进制字符串编写正则表达式。只使用基本操作。
至少有 3 个字符,并且第三个字符为 0
0 的数量是 3 的倍数
以相同字符开头和结尾
奇数长度
以 0 开头且长度为奇数,或以 1 开头且长度为偶数
长度至少为 1 且最多为 3
答案:(0|1)(0|1)0(0|1), 1 | (1010101), 1(0|1)1 | 0(0|1)0 | 0 | 1, (0|1)((0|1)(0|1)), 0((0|1)(0|1)) | 1(0|1)((0|1)(0|1)), (0|1) | (0|1)(0|1) | (0|1)(0|1)(0|1)。
对于以下每个问题,指出有多少长度为 1000 的位字符串与正则表达式匹配:
0(0 | 1)*1,0*101*,(1 | 01)*。编写一个正则表达式,匹配字母表{a, b, c}中包含的所有字符串:
以 a 开头且以 a 结尾
最多一个 a
至少有两个 a
偶数个 a
a 的数量加上 b 的数量为偶数
找出字母按字母顺序排列的长单词,例如,
almost和beefily。答案:使用正则表达式'^abcdefghijklmnopqrstuvwxyz$'。编写一个 Java 正则表达式,匹配电话号码,带有或不带有区号。区号应为(609) 555-1234 或 555-1234 的形式。
找出所有以
nym结尾的英语单词。找出所有包含三连字母
bze的英语单词。答案:subzero。找出所有以 g 开头,包含三连字母
pev且以 e 结尾的英语单词。答案:grapevine。找出所有包含三个 r 且至少有两个 r 的英语单词。
找出可以用标准键盘顶行写出的最长英语单词。答案:proprietorier。
找出所有包含字母 a、s、d 和 f 的单词,不一定按照顺序。解决方案:
cat words.txt | grep a | grep s | grep d | grep f。给定一个由 A、C、T 和 G 以及 X 组成的字符串,找到一个字符串,其中 X 匹配任何单个字符,例如,CATGG 包含在 ACTGGGXXAXGGTTT 中。
编写一个 Java 正则表达式,用于 Validate.java,验证形式为 123-45-6789 的社会安全号码。提示:使用
\d表示任何数字。答案:[0-9]{3}-[0-9]{2}-[0-9]{4}。修改上一个练习,使
-成为可选项,这样 123456789 就被视为合法输入。编写一个 Java 正则表达式,匹配包含恰好五个元音字母且元音字母按字母顺序排列的所有字符串。 答案:
[^aeiou]*a[^aeiou]*e[^aeiou]*i[^aeiou]*o[^aeiou]*u[^aeiou]*编写一个 Java 正则表达式,匹配有效的 Windows XP 文件名。这样的文件名由除了冒号以外的任意字符序列组成。
/ \ : * ? " < > |此外,它不能以空格或句号开头。
编写一个 Java 正则表达式,描述有效的 OS X 文件名。这样的文件名由除冒号以外的任意字符序列组成。此外,它不能以句点开头。
给定一个代表 IP 地址的名称为
s的字符串,采用dotted quad表示法,将其分解为其组成部分,例如,255.125.33.222。确保四个字段都是数字。编写一个 Java 正则表达式,描述形式为Month DD, YYYY的所有日期,其中Month由任意大写或小写字母字符串组成,日期是 1 或 2 位数字,年份正好是 4 位数字。逗号和空格是必需的。
编写一个 Java 正则表达式,描述形式为 a.b.c.d 的有效 IP 地址,其中每个字母可以表示 1、2 或 3 位数字,句点是必需的。是:196.26.155.241。
编写一个 Java 正则表达式,匹配以 4 位数字开头并以两个大写字母结尾的车牌。
编写一个正则表达式,从 DNA 字符串中提取编码序列。它以 ATG 密码子开头,以停止密码子(TAA、TAG 或 TGA)结尾。参考
编写一个正则表达式来检查序列 rGATCy:即,它是否以 A 或 G 开头,然后是 GATC,最后是 T 或 C。
编写一个正则表达式来检查一个序列是否包含两个或更多次重复的 GATA 四核苷酸。
修改 Validate.java 使搜索不区分大小写。 提示: 使用
(?i)嵌入式标志。编写一个 Java 正则表达式,匹配利比亚独裁者穆阿迈尔·卡扎菲姓氏的各种拼写,使用以下模板:(i)以 K、G、Q 开头,(ii)可选地跟随 H,(iii)后跟 AD,(iv)可选地跟随 D,(v)可选地跟随 H,(vi)可选地跟随 AF,(vii)可选地跟随 F,(vii)以 I 结尾。
编写一个 Java 程序,读取类似
(K|G|Q)[H]AD[D][H]AF[F]I的表达式,并打印出所有匹配的字符串。这里的符号[x]表示字母x的 0 或 1 个副本。为什么
s.replaceAll("A", "B");不会替换字符串s中所有出现的字母 A 为 B?答案:使用
s = s.replaceAll("A", "B");代替。replaceAll方法返回结果字符串,但不会改变s本身。字符串是不可变的。编写一个程序 Clean.java,从标准输入中读取文本并将其打印出来,在一行上去除任何尾随空格,并用 4 个空格替换所有制表符。
提示: 使用
replaceAll()和正则表达式\s匹配空格。编写一个正则表达式,匹配在文本
a href ="和下一个"之间的所有文本。 答案:href=\"(.*?)\"。?使.*变得不贪婪而是懒惰。在 Java 中,使用Pattern.compile("href=\\\"(.*?)\\\"", Pattern.CASE_INSENSITIVE)来转义反斜杠字符。使用正则表达式提取在
<title>和<\title>标签之间的所有文本。(?i)是另一种使匹配不区分大小写的方法。$2指的是第二个捕获的子序列,即title标签之间的内容。String pattern = "(?i)(<title.*?>)(.+?)(</title>)"; String updated = s.replaceAll(pattern, "$2");编写一个正则表达式来匹配在<TD ...>和标签之间的所有文本。 答案:
<TD[^>]*>([^<]*)</TD>
创意练习
FMR-1 三联重复区域。 “人类 FMR-1 基因序列包含一个三联重复区域,在该区域中序列 CGG 或 AGG 重复多次。三联体的数量在个体之间高度变化,增加的拷贝数与脆性 X 综合征相关,这是一种导致 2000 名儿童中的一名智力残疾和其他症状的遗传疾病。”(参考:Durbin 等人的《生物序列分析》)。该模式由 GCG 和 CTG 括起来,因此我们得到正则表达式 GCG (CGG | AGG)* CTG。
广告拦截。 Adblock 使用正则表达式来阻止 Mozilla 和 Firebird 浏览器下的横幅广告。
解析文本文件。 一个更高级的例子,我们想要提取匹配输入的特定部分。这个程序代表了解析科学输入数据的过程。
PROSITE 到 Java 正则表达式。 编写一个程序,读取 PROSITE 模式并打印出相应的 Java 正则表达式。PROSITE 是蛋白质家族和结构域的“第一个和最著名”的数据库。其主要用途是确定从基因组序列翻译而来的未知功能蛋白质的功能。生物学家使用PROSITE 模式语法规则在生物数据中搜索模式。这是CBD FUNGAL(访问代码 PS00562)的原始数据。每行包含各种信息。也许最有趣的一行是以 PA 开头的行 - 它包含描述蛋白质基序的模式。这些模式很有用,因为它们通常对应于功能或结构特征。
PA C-G-G-x(4,7)-G-x(3)-C-x(5)-C-x(3,5)-[NHG]-x-[FYWM]-x(2)-Q-C.每个大写字母对应一个氨基酸残基。字母表由对应于 2x 氨基酸的大写字母组成。连字符
-表示连接。例如,上面的模式以 CGG(Cys-Gly-Gly)开头。符号x扮演通配符的角色 - 它匹配任何氨基酸。这对应于我们符号中的.。括号用于指定重复:x(2)表示恰好两个氨基酸,x(4,7)表示 4 到 7 个氨基酸。这对应于 Java 符号中的.{2}和.{4,7}。花括号用于指定禁止的残基:表示除 C 或 G 之外的任何残基。星号具有其通常的含义。文本转语音合成。 grep 的原始动机。“例如,如何处理发音多种不同的二连音 ui:fruit, guile, guilty, anguish, intuit, beguine?”
具有挑战性的正则表达式。 为以下每组二进制字符串编写一个正则表达式。只使用基本操作。
除了 11 或 111 之外的任何字符串
每个奇数符号是 1
包含至少两个 0 和最多一个 1
没有连续的 1s
二进制可被整除。 为以下每组二进制字符串编写一个正则表达式。只使用基本操作。
以二进制数解释的比特串可被 3 整除
以二进制数解释的比特串可被 123 整除
波士顿口音。 编写一个程序,将所有的 r 替换为 h,将句子翻译成波士顿版本,例如将“Park the car in Harvard yard”翻译为波士顿版本的“Pahk the cah in Hahvahd yahd”。
文件扩展名。 编写一个程序,以文件名作为命令行参数,并打印出其文件类型扩展名。扩展名是跟在最后一个
.后面的字符序列。例如,文件sun.gif的扩展名是gif。提示:使用split("\\.");请记住.是一个正则表达式元字符,因此您需要转义它。反向子域。 为了进行网络日志分析,方便地根据子域(如
wayne.faculty.cs.princeton.edu)组织网络流量。编写一个程序来读取域名并以反向顺序打印出来,如edu.princeton.cs.faculty.wayne。银行抢劫。 你刚刚目睹了一起银行抢劫案,并且得到了逃跑车辆的部分车牌号。它以
ZD开头,中间有一个3,以V结尾。帮助警官写出这个车牌的正则表达式。排列的正则表达式。 找到 N 个元素的所有排列集合的最短正则表达式(仅使用基本操作),其中 N = 5 或 10。例如,如果 N = 3,则语言是 abc,acb,bac,bca,cab,cba。*答案:*困难。解决方案的长度与 N 呈指数关系。
解析带引号的字符串。 读取一个文本文件并打印出所有带引号的字符串。使用类似
"[^"]*"的正则表达式,但需要担心转义引号。解析 HTML。 一个>,可选地跟随空格,后跟
a,后跟空格,后跟href,可选地跟随空格,后跟=,可选地跟随空格,后跟"http://,后跟字符直到",可选地跟随空格,然后是一个<。< \s* a \s+ href \s* = \s* \\"http://[^\\"]* \\" \s* >子序列。 给定一个字符串
s,确定它是否是另一个字符串t的子序列。例如,abc 是 achfdbaabgabcaabg 的一个子序列。使用正则表达式。现在不使用正则表达式重复这个过程。答案:(a) a.*b.c.,(b) 使用贪婪算法。亨廷顿病诊断。 导致亨廷顿病的基因位于染色体 4 上,并且具有可变数量的 CAG 三核苷酸重复。编写一个程序来确定重复次数并打印
不会患 HD,如果重复次数少于 26,则打印后代有风险,如果数字为 37-35,则打印有风险,如果数字在 36 和 39 之间,则打印将患 HD。这就是遗传测试中识别亨廷顿病的方式。基因查找器。 基因是基因组的一个子字符串,以起始密码子(ATG)开始,以终止密码子(TAG,TAA,TAG 或 TGA)结束,并由除起始或终止密码子之外的密码子序列(核苷酸三联体)组成。基因是起始和终止密码子之间的子字符串。
重复查找器。 编写一个程序
Repeat.java,它接受两个命令行参数,并查找指定由第二个命令行参数指定的文件中第一个命令行参数的最大重复次数。字符过滤器。 给定一个包含坏字符的字符串
t,例如t = "!@#$%^&*()-_=+",编写一个函数来读取另一个字符串s并返回删除所有坏字符后的结果。String pattern = "[" + t + "]"; String result = s.replaceAll(pattern, "");通配符模式匹配器。 不使用 Java 内置的正则表达式,编写一个程序 Wildcard.java 来查找与给定模式匹配的字典中的所有单词。特殊符号匹配任意零个或多个字符。因此,例如模式"ward"匹配单词"ward"和"wildcard"。特殊符号.匹配任何一个字符。您的程序应将模式作为命令行参数读取,并从标准输入读取单词列表(由空格分隔)。
通配符模式匹配器。 重复上一个练习,但这次使用 Java 内置的正则表达式。*警告:*在通配符的上下文中,*的含义与正则表达式不同。
搜索和替换。 文字处理器允许您搜索给定查询字符串的所有出现并用另一个替换字符串替换每个出现。编写一个程序 SearchAndReplace.java,它接受两个字符串作为命令行输入,从标准输入读取数据,并用第一个字符串替换所有出现的第一个字符串,并将结果发送到标准输出。*提示:*使用方法
String.replaceAll。密码验证器。 假设出于安全原因,您要求所有密码至少包含以下字符之一
~ ! @ # $ % ^ & * |为
String.matches编写一个正则表达式,如果密码包含所需字符之一,则返回true。答案:"[~!@#\(%^&*|]+\)"字母数字过滤器。 编写一个程序 Filter.java,从标准输入中读取文本,并消除所有不是空格或字母数字的字符。答案 这是关键行。
String output = input.replaceAll("[^\\s0-9a-zA-Z]", "");将制表符转换为空格。 编写一个程序,将 Java 源文件中的所有制表符转换为 4 个空格。
解析分隔文本文件。 存储数据库的一种流行方式是将其存储在一个文本文件中,每行一个记录,每个字段由称为分隔符的特殊字符分隔。
19072/Narberth/PA/Pennsylvania 08540/Princeton/NJ/New Jersey编写一个程序 Tokenizer.java,它读取两个命令行参数,一个是分隔符字符,另一个是文件名,并创建一个标记数组。
解析分隔文本文件。 重复上一个练习,但使用
String库方法split()。检查文件格式。
拼写错误。 编写一个 Java 程序,验证这个常见拼写错误列表中只包含形式为的行
misdemenors (misdemeanors) mispelling (misspelling) tennisplayer (tennis player)第一个单词是拼写错误,括号中的字符串是可能的替换。
DFA 的大小与 RE 的大小呈指数关系。 给出一个 RE,用于表示所有最后一个字符为 1 的比特串集合。RE 的大小应该与 k 成线性关系。现在,给出同一组比特串的 DFA。它使用了多少个状态?
提示:对于这组比特串,每个确定有限自动机(DFA)至少需要有 2^k 个状态。
5.5 数据压缩
原文:
algs4.cs.princeton.edu/55compression译者:飞龙
本节正在大规模施工中。
数据压缩:将文件大小缩小以节省空间存储和在传输时节省时间。摩尔定律:芯片上的晶体管数量每 18-24 个月翻一番。帕金森定律:数据会扩张以填满可用空间。文本、图像、声音、视频等。维基百科提供公共转储以供学术研究和再发布。使用 bzip 和 SevenZip 的 LZMA。对 300GB 数据进行压缩可能需要一周的时间。
古代思想。
摩尔斯电码,十进制数系统,自然语言,旋转电话(较低的号码拨号速度更快,所以纽约是 212,芝加哥是 312)。
二进制输入和输出流。
我们使用 BinaryStdIn.java、BinaryStdOut.java、BinaryDump.java、HexDump.java 和 PictureDump.java。
固定长度编码。
需要 ceil(lg R) 位来指定 R 个符号中的一个。Genome.java。使用 Alphabet.java。
运行长度编码。
RunLength.java。
变长编码。
希望有唯一可解码的编码。实现这一目标的一种方法是向每个码字附加一个特殊的停止符号。更好的方法是前缀无码:没有字符串是另一个字符串的前缀。例如,{ 01, 10, 0010, 1111 } 是前缀无码,但 { 01, 10, 0010, 1010 } 不是,因为 10 是 1010 的前缀。
给出传真机的例子。
Huffman 编码。
构建最佳前缀无码的特定方式。由 David Huffman 在 1950 年在 MIT 时发明。Huffman.java 实现了 Huffman 算法。
属性 A. 没有前缀无码使用更少的比特。
LZW 压缩。
使用 TST.java 中的前缀匹配代码,LZW.java 实现了 LZW 压缩。
现实世界:Pkzip = LZW + Shannon-Fano,GIF,TIFF,V.42bis 调制解调器,Unix 压缩。实际问题:
将所有内容编码为二进制。
限制符号表中元素的数量(GIF = 丢弃并重新开始,Unix 压缩 = 不起作用时丢弃)。
最初字典有 512 个元素(其中填充了 256 个 ASCII 字符),因此我们每个整数传输 9 位。当填满时,我们将其扩展到 1024 并开始每个整数传输 10 位。
只遍历树一次(可能会破坏我们的字符串表抽象)。
实际问题:限制符号表中元素的数量。
总结。
Huffman:固定长度符号的变长编码。LZW:变长字符串的固定长度编码。
通用压缩算法。
不可能压缩所有文件(通过简单计数论证)。直观论证:压缩莎士比亚的生平作品,然后压缩结果,再次压缩结果。如果每个文件都严格缩小,最终将只剩下一个比特。
参考文献。
卡内基梅隆大学的 Guy Blelloch 在 数据压缩 方面有一章非常出色。
错误校正/检测。
假设用于发送信息的信道存在噪声,每个比特以概率 p 翻转。发送每个比特 3 次;解码时取 3 个比特的大多数。解码比特的正确概率为 3p² - 2p³。这小于 p(如果 p < 1/2)。可以通过多次发送每个比特来减少解码比特错误的概率,但这在传输速率方面是浪费的。
Reed-Solomon 编码。
参考资料。用于大容量存储系统(CD 和 DVD)和卫星传输(旅行者号探测器,火星探路者)当错误是突发性的时候。将要发送的数据视为一个度为 d 的多项式。只需要 d+1 个点来唯一指定多项式。发送更多点以实现纠错/检测错误。如果我们要发送的编码是 a0,a1,...,am-1(每个元素在有限域 K 上),将其视为多项式 p(x) = a0 + a1x + ... + am-1 x^m-1。发送 p(0),p(b),p(b²),...,其中 b 是 K 上的乘法循环群的生成元。
香农编码定理。
大致来说,如果信道容量为 C,则我们可以以略低于 C 的速率发送比特,使用编码方案将解码错误的概率降低到任意所需水平。证明是非构造性的。
问答
练习
以下哪些编码是前缀自由的?唯一可解码的?对于那些唯一可解码的编码,给出编码为 1000000000000 的编码。
code 1 code 2 code 3 code 4 A 0 0 1 1 B 100 1 01 01 C 10 00 001 001 D 11 11 0001 000给出一个不是前缀自由的唯一可解码编码的例子。
解决方案。 任何无后缀编码都是唯一可解码的,例如,{ 0, 01 }。
给出一个不是前缀自由或无后缀的唯一可解码编码的例子。
解决方案。 { 0011, 011, 11, 1110 }或{ 01, 10, 011, 110 }。
{ 1, 100000, 00 },{ 01, 1001, 1011, 111, 1110 }和{ 1, 011, 01110, 1110, 10011 }是唯一可解码的吗?如果不是,找到一个具有两个编码的字符串。解决方案。 第一组编码是唯一可解码的。第二组编码不是唯一可解码的,因为 111-01-1110-01 和 1110-111-1001 是 11101111001 的两种解码方式。第三组编码不是唯一可解码的,因为 01110-1110-011 和 011-1-011-10011 是 011101110011 的两种解码方式。
唯一可解码性测试。 实现 Sardinas-Patterson 算法,用于测试一组编码词是否是唯一可解码的:将所有编码词添加到一个集合中。检查所有编码词对,看看是否有一个是另一个的前缀;如果是,提取悬挂后缀(即,长字符串中不是短字符串前缀的部分)。如果悬挂后缀是一个编码词,则编码不是唯一可解码的;否则,将悬挂后缀添加到列表中(前提是它尚未存在)。重复此过程直到没有剩余的新悬挂后缀为止。
该算法是有限的,因为添加到列表中的所有悬挂后缀都是有限一组编码词的后缀,并且悬挂后缀最多只能添加一次。
{ 0, 01, 11 }。编码词 0 是 01 的前缀,因此添加悬挂后缀 1。{ 0, 01, 11, 1 }。编码词 0 是 01 的前缀,但悬挂后缀 1 已经在列表中;编码词 1 是 11 的前缀,但悬挂后缀 1 已经在列表中。没有其他悬挂后缀,因此得出该集合是唯一可解码的结论。
{ 0, 01, 10 }。编码词 0 是 01 的前缀,因此将悬挂后缀 1 添加到列表中。{ 0, 01, 10, 1 }。编码词 1 是 10 的前缀,但悬挂后缀 0 是一个编码词。因此,得出该编码不是唯一可解码的结论。
Kraft-McMillan 不等式。 考虑一个具有长度为 n1, n2, ..., nN 的 N 个编码词的编码 C。证明如果编码是唯一可解码的,则 K(C) = sum_i = 1 to N 2^(-ni) ≤ 1。
Kraft-McMillan 构造。 假设我们有一组满足不等式 sum_i = 1 to N 2^(-ni) ≤ 1 的整数 n1, n2, ..., nN。证明总是可以找到一个编码长度为 n1, n2, ..., nN 的前缀自由编码。因此,通过将注意力限制在前缀自由编码上(而不是唯一可解码编码),我们不会失去太多。
Kraft-McMillan 最优前缀自由编码等式。 证明如果 C 是一个最优前缀自由编码,那么 Kraft-McMillan 不等式是一个等式:K(C) = sum_i = 1 to N 2^(-ni) = 1。
假设所有符号概率都是 2 的负幂次方。描述哈夫曼编码。
假设所有符号频率相等。描述哈夫曼编码。
找到一个哈夫曼编码,其中概率为 pi 的符号的长度大于 ceil(-lg pi)。
解决方案. .01 (000), .30 (001), .34 (01), .35 (1)。码字 001 的长度大于 ceil(-lg .30)。
真或假。任何最优前缀自由编码都可以通过哈夫曼算法获得。
解决方案. 错误。考虑以下符号和频率集合(A 26, B 24, C 14, D 13, E 12, F 11)。
C1 C2 C3 A 26 01 10 00 B 24 10 01 01 C 14 000 111 100 D 13 001 110 101 E 12 110 001 110 F 11 111 000 111在任何哈夫曼编码中,字符 A 和 B 的编码必须以不同的位开始,但是代码 C3 没有这个属性(尽管它是一个最优前缀自由编码)。
以下输入的 LZW 编码是什么?
T O B E O R N O T T O B E
Y A B B A D A B B A D A B B A D O O
A A A A A A A A A A A A A A A A A A A A A
描述 LZW 编码中的棘手情况。
解决方案. 每当遇到 cScSc,其中 c 是一个符号,S 是一个字符串,cS 在字典中但 cSc 不在字典中。
作为 N 的函数,编码 N 个符号 A 需要多少位?N 个序列 ABC 需要多少位?
让 F(i) 为第 i 个斐波那契数。考虑 N 个符号,其中第 i 个符号的频率为 F(i)。注意 F(1) + F(2) + ... + F(N) = F(N+2) - 1。描述哈夫曼编码。
解决方案. 最长的码字长度为 N-1。
显示对于给定的 N 个符号集合,至少有 2^(N-1) 种不同的哈夫曼编码。
解决方案. 有 N-1 个内部节点,每个节点都可以任意选择其左右子节点。
给出一个哈夫曼编码,其中输出中 0 的频率远远高于 1 的频率。
解决方案. 如果字符 'A' 出现一百万次,字符 'B' 出现一次,那么 'A' 的码字将是 0,'B' 的码字将是 1。
证明有关哈夫曼树的以下事实。
两个最长的码字长度相同。
如果符号 i 的频率严格大于符号 j 的频率,则符号 i 的码字长度小于或等于符号 j 的码字长度。
描述如何在一组符号 { 0, 1, ..., N-1 } 上传输哈夫曼编码(或最优前缀自由编码),使用 2N - 1 + N ceil(lg N) 位。
提示:使用 2N-1 位来指定相应 trie 的结构。
假设在一个扩展 ASCII 文件(8 位字符)中,最大字符频率最多是最小字符频率的两倍。证明固定长度的 8 位扩展 ASCII 码是最优的。
香农-范诺编码。 证明哈夫曼算法的以下自顶向下版本不是最优的。将码字集合 C 分成两个子集 C1 和 C2,其频率(几乎)相等。递归地为 C1 和 C2 构建树,从 0 开始为 C1 的所有码字,从 1 开始为 C2 的所有码字。为了实现第一步,香农和范诺建议按频率对码字进行排序,并尽可能地将集合分成两个子数组。
解决方案. S 32, H 25, A 20, N 18, O 5。
LZMW 编码(米勒-韦格曼 1985)。 LZ 变种:在字典中搜索最长的已经存在的字符串(当前匹配);将前一个匹配与当前匹配的连接添加到字典中。字典条目增长更快。当字典填满时,也可以删除低频率条目。难以实现。
LZAP 编码。 类似于 LZMW:不仅添加前一个匹配与当前匹配的连接,还添加前一个匹配与当前匹配的 所有前缀 的连接。比 LZMW 更容易实现,但字典条目更多。
确定一个不是前缀自由的最优编码。
提示:只需要 3 个具有相等频率的符号。
确定对于相同输入的两个最优前缀自由编码,其码字长度分布不同。
提示:只需要 4 个符号。
最小方差 Huffman 编码。 由于与打破平局相关的不确定性,Huffman 算法可能生成具有不同码字长度分布的编码。在生成压缩流时传输,希望以(近)恒定速率传输比特。找到最小化 sum_i (p_i (l_i - l_average(T)) ²) 的 Huffman 编码。
解决方案。 在组合 tries 时,通过选择具有最小概率的最早生成的 trie 来打破平局。
用于 Huffman 编码的双队列算法。 证明以下算法计算出 Huffman 编码(如果输入符号已按频率排序,则在线性时间内运行)。维护两个 FIFO 队列:第一个队列包含输入符号,按频率升序排列,第二个队列包含组合权重的内部节点。只要两个队列中有超过一个节点,就通过检查两个队列的前端出队两个权重最小的节点。创建一个新的内部节点(左右子节点 = 两个节点,权重 = 两个节点的权重之和)并将其加入第二个队列。
要获得最小方差的 Huffman 编码,通过从第一个队列中选择节点来打破平局。
提示:证明第二个队列按频率升序排列。
兄弟属性。 如果(i)每个节点(除了根节点)都有一个兄弟节点,且(ii)二叉树可以按概率的非递增顺序列出,使得在列表中所有兄弟节点都相邻,则二叉树具有 兄弟属性。证明二叉树表示 Huffman 树当且仅当它具有兄弟属性。
相对编码。 不是压缩图像中的每个像素,而是考虑像素与前一个像素之间的差异并对差异进行编码。直觉:通常像素变化不大。与颜色表字母上的 LZW 一起使用。
可变宽度 LZW 编码。 在第 2^p 个码字插入表后,将表的宽度从 p 增加到 p+1。与颜色表字母一起使用。
自适应 Huffman 编码。 一次通过算法,不需要发送前缀自由码。根据迄今为止读入的字符的频率构建 Huffman 树。在读入每个字符后更新树。编码器和解码器需要协调处理平局的约定。
香农熵。 具有可能值 x1, ..., xN 且以概率 p1, ..., pN 出现的离散随机变量 X 的熵 H 定义为 H(X) = -p1 lg p1 - p2 lg p2 - ... - pN lg pN,其中 0 lg 0 = 0 与极限一致。
一个公平硬币的熵是多少?
一个硬币的熵是什么,其中两面都是正面?
一个六面骰子的熵是多少?
解决方案。 -lg (1/6) 大约为 2.584962。
两个公平骰子的和的熵是多少?
给定一个取 N 个值的随机变量。什么分布使熵最大化?熵是信息论中的一个基本概念。香农的源编码定理断言,要压缩来自一系列独立同分布随机变量流的数据,至少需要每个符号 H(X) 位。例如,发送一系列公平骰子投掷结果至少需要每次骰子投掷 2.584962 位。
经验熵。 经验熵 是通过计算每个符号出现频率并将其用作离散随机变量的概率来获得的一段文本的熵。计算你最喜欢小说的经验熵。将其与 Huffman 编码实现的数据压缩率进行比较。
香农实验。 进行以下实验。给一个主体一段文本(或 Leipzig 语料库)中的 k 个字母序列,并要求他们预测下一个字母。估计主体在 k = 1, 2, 5, 100 时答对的比例。
真或假。固定长度编码是���一可解码的。
解决方案。 真,它们是前缀自由的。
给出两棵不同高度的 Huffman 树字符串 ABCCDD。
前缀自由编码。 设计一个高效的算法来确定一组二进制码字是否是前缀自由的。提示:使用二进制 trie 或排序。
唯一可解码编码。 设计一个唯一可解码的编码,它不是前缀自由编码。提示:后缀自由编码 = 前缀自由编码的反向。后缀自由编码的反向是前缀自由编码 → 可以通过以相反顺序读取压缩消息来解码。不太方便。
哈夫曼树。 修改 Huffman.java,使得编码器打印查找表而不是先序遍历,并修改解码器以通过读取查找表构建树。
真或假。在最佳前缀自由三进制编码中,出现频率最低的三个符号具有相同的长度。
解答。 False.
三进制哈夫曼编码。 将哈夫曼算法推广到三进制字母表(0, 1 和 2)上的码字,而不是二进制字母表。也就是说,给定一个字节流,找到一个使用尽可能少的三进制位(0、1 和 2)的前缀自由三进制编码。证明它产生最佳前缀自由三进制编码。
解答。 在每一步中合并最小的 3 个概率(而不是最小的 2 个)。当有 3 + 2k 个符号时,这种方法有效。为了将其减少到这种情况,添加概率为 0 的 1 或 2 个虚拟符号。(或者,如果符号数量不是 3 + 2k,则在第一步中合并少于 3 个符号。)例如:{ 0.1, 0.2, 0.2, 0.5 }。
非二进制哈夫曼编码。 将哈夫曼算法扩展到 m 进制字母表(0, 1, 2, ..., m-1)上的码字,而不是二进制字母表。
考虑以下由 3 个 a、7 个 c、6 个 t 和 5 个 g 组成的 21 个字符消息。
a a c c c c a c t t g g g t t t t c c g g以下的 43 位是否是上述消息的可能哈夫曼编码?
0000001111000101010010010010101010111001001尽可能简洁而严谨地证明你的答案。
解答。 对于一条消息的哈夫曼编码会产生使用最少位数的编码,其中 2 位二进制码 a = 00, c = 01, g = 10, t = 11 是一个使用 21 * 2 = 42 位的前缀自由编码。因此,哈夫曼编码将使用少于 43 位。
如果一个二叉树是满的,则除了叶子节点外的每个节点都有两个子节点。证明与最佳前缀自由编码对应的任何二叉树都是满的。
提示:如果内部节点只有一个子节点,请用其唯一子节点替换该内部节点。
Move-to-front 编码(Bentley, Sleator, Tarjan 和 Wei 1986)。 编写一个名为
MoveToFront的程序,实现 move-to-front 编码和解码。维护符号字母表的列表,其中频繁出现的符号位于前面。一个符号被编码为列表中在它之前的符号数。编码一个符号后,将其移动到列表的前面。参考Move-ahead-k 编码。 与 move-to-front 编码相同,但将符号向前移动 k 个位置。
等待-c-并移动。 与 move-to-front 编码相同,但只有在符号在上次移动到前面后遇到 c 次后才将其移动到前面。
双哈夫曼压缩。 找到一个输入,对该输入应用 Huffman.java 中的
compress()方法两次比仅应用compress()一次导致输出严格较小。合并 k 个排序数组。 你有 k 个已排序的列表,长度分别为 n1、n2、...、nk。假设你可以执行的唯一操作是 2 路合并:给定长度为 n1 的一个已排序数组和长度为 n2 的另一个已排序数组,用长度为 n = n1 + n2 的已排序数组替换它们。此外,2 路合并操作需要 n 个单位的时间。合并 k 个已排序数组的最佳方法是什么?
解决方案. 将列表长度排序,使得 n1 < n2 < ... < nk。重复地取最小的两个列表并应用 2 路合并操作。最优性的证明与哈夫曼编码的最优性证明相同:重复应用 2 路合并操作会产生一棵二叉树,其中每个叶节点对应于原始排序列表中的一个,每个内部节点对应于一个 2 路合并操作。任何原始列表对总体成本的贡献是列表长度乘以其树深度(因为这是其元素参与 2 路合并的次数)。
6. 上下文
原文:
algs4.cs.princeton.edu/60context译者:飞龙
本章节正在大规模施工中。
概述。
本章中的 Java 程序。
以下是本章节中的 Java 程序列表。点击程序名称以访问 Java 代码;点击参考编号以获取简要描述;阅读教材以获取详细讨论。
REF 程序 描述 / JAVADOC 6.1 CollisionSystem.java 碰撞系统 - Particle.java 粒子 6.2 BTree.java B 树 6.3 SuffixArray.java 后缀数组(后缀排序) - SuffixArrayX.java 后缀数组(优化) - LongestRepeatedSubstring.java 最长重复子串 - KWIK.java 上下文关键词 - LongestCommonSubstring.java 最长公共子串 6.4 FordFulkerson.java 最大流-最小割 - FlowNetwork.java 带容量网络 - FlowEdge.java 带流量的容量边 - GlobalMincut.java 全局最小割(Stoer-Wagner)⁵ - BipartiteMatching.java 二分图匹配(交替路径) - HopcroftKarp.java 二分图匹配(Hopcroft-Karp) - AssignmentProblem.java 加权二分图匹配 - LinearProgramming.java 线性规划(单纯形法) - TwoPersonZeroSumGame.java 双人零和博弈
6.1 事件驱动模拟
原文:
algs4.cs.princeton.edu/61event译者:飞龙
本章节正在建设中。
根据弹性碰撞的法则使用事件驱动模拟模拟 N 个碰撞粒子的运动。这种模拟在分子动力学(MD)中被广泛应用,以理解和预测粒子级别的物理系统的性质。这包括气体中分子的运动,化学反应的动力学,原子扩散,球体堆积,围绕土星的环的稳定性,铈和铯的相变,一维自引力系统以及前沿传播。相同的技术也适用于其他涉及粒子系统的物理建模领域,包括计算机图形学,计算机游戏和机器人技术。我们将在第七章再次讨��其中一些问题。
硬球模型. 硬球模型(台球模型)是容器中原子或分子运动的理想化模型。我们关注二维版本称为硬盘模型。这个模型的显著特性如下。
N 个运动中的粒子,限制在单位盒中。
粒子i具有已知位置(rx[i], ry[i])、速度(vx[i], vy[i])、质量m[i]和半径σ[i]。
粒子之间以及与反射边界之间通过弹性碰撞相互作用。
没有其他力的作用。因此,粒子在碰撞之间以恒定速度直线运动。
这个简单模型在统计力学中起着核心作用,这个领域将宏观可观测量(例如温度、压力、扩散常数)与微观动力学(单个原子和分子的运动)联系起来。麦克斯韦和玻尔兹曼使用这个模型推导出相互作用分子的速度分布与温度的关系;爱因斯坦用它来解释花粉颗粒在水中的布朗运动。
模拟. 有两种自然方法来模拟粒子系统。
时间驱动模拟. 将时间离散化为大小为dt的量子。在每个dt时间单位后更新每个粒子的位置并检查重叠。如果有重叠,将时钟回滚到碰撞时刻,更新碰撞粒子的速度,并继续模拟。这种方法简单,但存在两个重大缺点。首先,我们必须在每个时间量子中执行 N²次重叠检查。其次,如果dt太大,碰撞粒子在我们查看时未发生重叠,我们可能会错过碰撞。为了确保相对准确的模拟,我们必须选择dt非常小,这会减慢模拟速度。
事件驱动模拟. 通过事件驱动模拟,我们只关注发生有趣事件的时间点。在硬盘模型中,所有粒子在碰撞之间以恒定速度直线运动。因此,我们的主要挑战是确定粒子碰撞的有序序列。我们通过维护一个按时间排序的优先队列来解决这个挑战。在任何给定时间,优先队列包含所有未来可能发生的碰撞,假设每个粒子永远沿直线轨迹运动。随着粒子碰撞并改变方向,一些计划在优先队列上的事件变得“过时”或“无效”,不再对应物理碰撞。我们采取一种懒惰策略,将这些无效的碰撞留在优先队列上,等待识别并丢弃它们被删除时。主要的事件驱动模拟循环工作如下:
删除即将发生的事件,即具有最小优先级t的事件。
如果事件对应于无效的碰撞,将其丢弃。如果其中一个粒子自事件插入优先队列以来已经参与了碰撞,则该事件无效。
如果事件对应于粒子i和j之间的物理碰撞:
将所有粒子沿直线轨迹推进到时间t。
根据弹性碰撞的法则更新两个碰撞粒子i和j的速度。
确定所有未来可能涉及i或j的碰撞事件,假设所有粒子从时间t开始沿直线轨迹移动。将这些事件插入优先队列。
如果事件���应于粒子i和墙壁之间的物理碰撞,对粒子i执行类似的操作。
这种事件驱动的方法比时间驱动的方法产生了更健壮、更准确和更高效的模拟。
碰撞预测。 我们回顾了指定粒子是否以及何时与边界或其他粒子碰撞的公式,假设所有粒子都沿直线轨迹移动。
粒子与墙壁之间的碰撞. 给定时间t时粒子的位置(rx, ry)、速度(vx, vy)和半径σ,我们希望确定它是否以及何时会与垂直或水平墙壁碰撞。
![两个粒子之间的弹性碰撞]()
由于坐标在 0 和 1 之间,如果ry + Δt vy等于σ或(1 - σ),则粒子在时间t + Δt 时与水平墙面接触。解出Δt得到:
![与水平墙面碰撞]()
一个类似的方程预测了与垂直墙壁的碰撞时间。
两个粒子之间的碰撞. 给定时间t时两个粒子i和j的位置和速度,我们希望确定它们是否以及何时相互碰撞。
![两个粒子之间的弹性碰撞]()
让(rx[i]' , ry[i]' )和(rx[j]' , ry[j]' )表示粒子i和j在接触时刻,即t + Δt的位置。当粒子碰撞时,它们的中心相距σ = σ[i] + σ[j]的距离。换句话说:
σ² = (rx[i]' - rx[j]' )² + (ry[i]' - ry[j]' )²
在碰撞之前的时间内,粒子沿直线轨迹移动。因此,
rx[i]' = rx[i] + Δt vx[i], ry [i]' = ry[i] + Δt vy[i]
rx[j]' = rx[j] + Δt vx[j], ry [j]' = ry[j] + Δt vy[j]
将这四个方程代入前一个方程,解出得到的二次方程的Δt,选择物理相关的根,并简化,我们得到Δt的表达式,其中包括已知位置、速度和半径。
![两个粒子之间的碰撞]()
![相关量的定义]()
如果Δv ⋅ Δr ≥ 0 或 d < 0,则二次方程对Δt > 0 没有解;否则我们保证Δt ≥ 0。
碰撞解决方案。 在本节中,我们提供了指定粒子在与反射边界或其他粒子发生弹性碰撞后行为的物理公式。为简单起见,我们忽略多粒子碰撞。有三个方程控制着一对硬盘之间的弹性碰撞:(i) 线性动量守恒,(ii) 动能守恒,以及(iii) 碰撞时,法向力作用于碰撞点的表面垂直方向。对物理感兴趣的学生被鼓励从第一原理推导这些方程;其余的可以继续阅读。
粒子与墙壁之间。 如果具有速度(vx,vy)的粒子与垂直于x轴的墙壁碰撞,则新速度为(-vx,vy);如果与垂直于y轴的墙壁碰撞,则新速度为(vx,-vy)。
两个粒子之间。 当两个硬盘碰撞时,法向力沿着连接它们中心的线作用(假设没有摩擦或旋转)。在接触瞬间,完全弹性碰撞的法向冲量(Jx,Jy)在x和y方向上的作用是:
![弹性碰撞法向力的冲量]()
其中m[i]和m[j]分别是粒子i和j的质量,σ、Δx、Δy和Δ v ⋅ Δr如上所定义。一旦我们知道冲量,我们就可以应用牛顿第二定律(以动量形式)来计算碰撞后立即的速度。
vx[i]' = vx[i] + Jx / m[i], vx[j]' = vx[j] - Jx / m[j]
vy[i]' = vy[i] + Jy / m[i], vy[j]' = vy[j] - Jy / m[j]
Java 中的粒子碰撞模拟。 我们的实现涉及以下数据类型:MinPQ.java、Particle.java 和 CollisionSystem.java。
粒子数据类型。 代表在单位盒中移动的粒子。
事件数据类型。 表示碰撞事件的数据类型。有四种不同类型的事件:与垂直墙壁的碰撞、与水平墙壁的碰撞、两个粒子之间的碰撞和重绘事件。这将是一个很好的机会来尝试面向对象编程和多态性。我们提出以下更简单(但略显不够优雅)的方法。
为了实现
isValid,事件数据类型应该在事件创建时存储相关粒子的碰撞计数。如果粒子的当前碰撞计数与事件创建时的相同,则该事件对应于物理碰撞,即没有干预碰撞。
数据文件。 我们使用以下数据格式。第一行包含粒子数量 N。剩下的 N 行中,每行包含 6 个实数(位置、速度、质量和半径),后跟三个整数(颜色的红、绿、蓝值)。您可以假设所有位置坐标在 0 和 1 之间,颜色值在 0 和 255 之间。此外,您可以假设没有粒子相互交叉或与墙壁相交。
N
rx ry vx vy radius mass r g b
rx ry vx vy radius mass r g b
rx ry vx vy radius mass r g b
rx ry vx vy radius mass r g b
这里是一些示例数据文件:
| p10.txt | 10 个粒子 |
|---|---|
| p2000.txt | 2000 个粒子 |
| diagonal.txt | |
| diagonal1.txt | |
| diagonal2.txt | |
| wallbouncing.txt | 一排有 9 个粒子与一列有 9 个粒子碰撞 |
| wallbouncing2.txt | 一排有 10 个粒子不与一列有 9 个粒子碰撞 |
| wallbouncing3.txt | 一排有 19 个粒子与一列有 19 个粒子碰撞 |
| p100-.125K.txt | 0.125 开尔温下的 100 个粒子 |
| p100-.5K.txt | 0.5 开尔温下的 100 个粒子 |
| p100-2K.txt | 2.0 开尔温下的 100 个粒子 |
| p1000-.5K.txt | 0.5 开尔温下的 1000 个粒子 |
| p1000-2K.txt | 2.0 开尔温下的 1000 个粒子 |
| billiards2.txt | 母球撞击 3 个球的金字塔 |
| billiards4.txt | 母球撞击 10 个球的金字塔 |
| billiards5.txt | 母球撞击 15 个球的金字塔 |
| diffusion.txt | 从裂缝一侧扩散的粒子 |
| diffusion2.txt | 从一个四分之一处扩散的粒子 |
| diffusion3.txt | |
| brownian.txt | |
| brownian2.txt | |
| squeeze.txt | 一个微小粒子夹在两个大粒子之间 |
| squeeze2.txt | 一个微小粒子夹在两个大颗粒之间 |
| pendulum.txt | 钟摆效应 |
其他潜在的数据文件
一个颗粒在运动。
两个颗粒正面碰撞。
两个颗粒,一个静止,以角度碰撞。
一个红色颗粒在运动,N 个蓝色颗粒静止。
N 个粒子在一个具有随机初始方向的晶格上(但速度相同),以使总动能与某个固定温度 T 一致,总线性动量= 0。 对于不同的 T 值,有不同的数据集。
扩散 I:在容器中心附近分配 N 个非常小的相同大小的粒子,具有随机速度。
扩散 II:左侧有 N 个蓝色颗粒,右侧有 N 个绿色颗粒,分配速度使它们热化(例如,离开它们之间的隔板并在一段时间后保存位置和速度)。 观察它们混合。 计算平均扩散速率?
N 个大颗粒,所以没有太多空间可以移动而不碰到东西。
爱因斯坦对布朗运动的解释:中间有一个大红色颗粒,N 个较小的蓝色颗粒。
练习
粒子碰撞游戏。 实现游戏Particles:你用鼠标控制一个红色球,试图避免与根据弹性碰撞定律行事的蓝色球碰撞。 一段时间后,随机引入一个新的蓝色球。
布朗运动。 1827 年,植物学家罗伯特·布朗使用显微镜观察了浸泡在水中的野花花粉颗粒的运动。 他观察到花粉颗粒是随机运动的,遵循后来被称为布朗运动的运动。 这种现象被讨论过,但直到爱因斯坦在 1905 年提供了一个数学解释之前,没有提供令人信服的解释。 爱因斯坦的解释:花粉颗粒的运动是由数百万微小分子与较大颗粒碰撞引起的。 他给出了描述在给定温度下悬浮在液体中的微小颗粒行为的详细公式。 爱因斯坦的解释旨在帮助证明原子和分子的存在,并可用于估计分子的大小。 爱因斯坦的布朗运动理论使工程师能够通过观察单个粒子的运动来计算合金的扩散常数。 这里有一个来自这里的 Einstein's explanation of Brownian motion 演示。
自由程和自由时间。 自由程 = 粒子在碰撞之间行进的距离。 绘制直方图。 平均自由程 = 所有粒子的平均自由程长度。 随着温度的升高,平均自由程增加(保持压力恒定)。 计算自由时间长度 = 粒子与另一个粒子或墙壁碰撞之前经过的时间。
碰撞频率。 每秒碰撞次数。
均方根速度。 均方根速度 / 平均自由程 = 碰撞频率。均方根速度 = sqrt(3RT/M),其中摩尔气体常数 R = 8.314 J / mol K,T = 温度(例如,298.15 K),M = 分子质量(例如,氧气为 0.0319998 kg)。
麦克斯韦-玻尔兹曼分布。 在硬球模型中,粒子速度的分布服从麦克斯韦-玻尔兹曼分布(假设系统已经热化,粒子足够重,我们可以忽略量子效应)。 分布形状取决于温度。 每个分量的速度分布与 exp(-mv_x² / 2kT)成比例。 在 d 维中速度的大小分布与 v^(d-1) exp(-mv² / 2kT)成比例。 在统计力学中使用,因为模拟大约 10²³ 个粒子是不方便的。 原因:x,y 和 z 方向的速度是正态的(如果所有粒子质量和半径相同)。 在 2d 中,用 Rayleigh 代替麦克斯韦-玻尔兹曼。
压力。 感兴趣的主要热力学性质 = 平均压力。压力 = 分子碰撞对容器施加的单位面积的力。在二维中,压力 = 容器壁上单位长度的平均力。
温度。 绘制随时间变化的温度(应保持恒定)= 1/N sum(mv²) / (d k),其中 d = 维度 = 2,k = 玻尔兹曼常数。
扩散。 分子移动非常迅速(比喷气机更快),但扩散缓慢,因为它们与其他分子碰撞,从而改变它们的方向。两个通过管道连接的容器,其中包含两种不同类型的粒子。随时间变化测量每种类型粒子在每个容器中的比例。
时间可逆性。 改变所有速度并向后运行系统。忽略舍入误差,系统将返回到其原始状态!
麦克斯韦的恶魔。 麦克斯韦的恶魔是詹姆斯·克拉克·麦克斯韦于 1871 年构想出来的一个思想实验,旨在反驳热力学第二定律。中间有垂直墙壁,带有分子大小的陷阱门,左半部分有 N 个粒子,右半部分也有 N 个粒子,只有恶魔允许通过的粒子才能通过陷阱门。恶魔让左到右速度比平均速度快的粒子通过,让右到左速度比平均速度慢的粒子通过。可以利用能量重新分配来运行热机,允许热量从左到右流动。(不违反法则,因为恶魔必须与物理世界互动以观察分子。恶魔必须存储关于分子在陷阱门哪一侧的信息。恶魔最终会耗尽存储空间,必须开始擦除先前积累的信息以为新信息腾出空间。这种信息的擦除增加了熵,需要 kT ln 2 单位的工作。)
度量空间。 扩展以支持任意度量空间中的球体和超平面。
细胞方法。 有用的优化:将区域划分为矩形单元。确保粒子只能在任何时间量子中与九个相邻单元中的粒子发生碰撞。减少必须计算的二元碰撞事件数量。缺点:必须监视粒子在单元之间移动的过程。
多粒子碰撞。 处理多粒子碰撞。在模拟台球游戏中的碰撞时,这种碰撞非常重要。
动态堆或动态数据结构。(Guibas)
创意练习
6.2 B 树
原文:
algs4.cs.princeton.edu/62btree译者:飞龙
本章正在进行重大改建。
问答
练习
6.3 后缀数组
原文:
algs4.cs.princeton.edu/63suffix译者:飞龙
本章正在大规模施工中。
重要说明。
*从 Oracle 和 OpenJDK Java 7,Update 6 开始,substring()方法在提取的子串大小上花费线性时间和空间(而不是常数时间和空间)。String API对其所有方法,包括substring()和charAt(),都不提供性能保证。
课本和书站中的程序已经更新,不再依赖于常数时间的子串操作。但是,如果您使用的是课本的第三版印刷版(或更早版本),请自行考虑。*
后缀排序和后缀数组。
后缀排序:给定一个字符串,按升序对该字符串的后缀进行排序。排序后的列表称为后缀数组。程序 SuffixArray.java 构建了一个后缀数组数据结构。
最长重复子串。
将排序应用于计算生物学和抄袭检测。程序 LongestRepeatedSubstring.java 使用后缀数组解决了这个问题。
上下文关键词(KWIC)。
给定后缀数组,通过二分搜索很容易搜索字符串或句子。内存是线性的。搜索是 O(K log N),其中 K 是您要搜索的字符串的长度。(通过使用 lcp 数组,可以在 K + log N 内完成。)有时被称为 KWIK(关键词上下文)和共现。被语言学家使用。Concordancer = 制作索引的程序。马丁·阿贝格(Martin Abegg)曾经将死海古卷的索引反向工程并转换为可读文本。程序 KWIK.java。
Manber 算法。
然而,输入大小为 N,因此我们可能希望利用子串的重叠性质并取得更好的效果。程序 Manber.java 使用 Manber-Myers 重复加倍算法的版本对字符串后缀进行排序。Larsson在内部排序例程替换为 O(N log N)基于比较的排序算法时给出了 O(N log N)的分析。
Kasai 算法。
这里是描述Kasai 算法计算 lcp 数组的内容。
问答
线性时间后缀排序。
倾斜分治。最简单的线性时间后缀排序解决方案。递归地对索引为{0, 1, 2} mod 3 的后缀进行排序,然后合并。
练习
给出字符串"abacadaba"的后缀数组。此外,给出 i 从 1 到 n-1 的
lcp(i)值表。对字符串"mississippi"重复上一个问题。
创建一个长度为 n 的
SuffixArray对象需要多少字节?下面的代码片段计算后缀排序的所有后缀有什么问题?
suffix = ""; for (int i = s.length() - 1; i >= 0; i--) { suffix = s.charAt(i) + suffix; suffixes[i] = suffix; }答案:二次时间 和 二次空间。
下面的代码片段计算后缀排序的所有循环后缀有什么问题?
int n = s.length(); for (int i = 0; i < n; i++) suffixes[i] = s.substring(i, n) + s.substring(0, i);答案:二次时间 和 二次空间。
下面的代码片段计算后缀排序的所有循环后缀有什么问题?
int n = s.length; StringBuilder ss = new StringBuilder(); ss.append(s).append(s); for (int i = 0; i < N; i++) suffixes[i] = ss.substring(i, i + n);答案:二次时间 和 二次空间。
最长公共子串。 编写一个程序 LongestCommonSubstring.java,接受两个文件名作为命令行参数,读取这两个文本文件,并找到两者中都出现的最长子串。提示:为 s#t 创建一个后缀数组,其中 s 和 t 是两个文本字符串,#是两者都不出现的字符。
1970 年,D. Knuth 猜想在线性时间内解决这个问题是不可能的。事实上,可以使用后缀树或后缀数组在线性时间内(在最坏情况下)解决这个问题。
Burrows-Wheeler 变换。 Burrows-Wheeler 变换(BWT)是数据压缩算法中使用的一种转换,包括 bzip2 和基因组学中的高通量测序。给定长度为 N 的文本字符串(以特殊的文件结束符 $ 结尾,比任何其他字符都小),考虑 N×N 矩阵,其中每行包含原始文本字符串的不同循环旋转。按字典顺序对行进行排序。Burrows-Wheeler 变换是排序矩阵中的最右列。例如,BWT (mississippi$) = ipssm$pissii。
Burrows-Wheeler 逆变换。 Burrows-Wheeler 逆变换(BWI)用于反转 BWT。给定文本字符串的 BWT,设计一个线性时间算法来恢复原始文本字符串。例如,BWI(ipssm$pissii) = mississippi$。
循环字符串线性化。 给定一个字符串 s,找到字典序最小的旋转。在化学数据库中用于循环分子。每个分子表示为循环字符串。规范表示是字典序最小的旋转。设计一个算法来计算循环字符串的规范表示 提示:后缀排序。
其他解决方案:Duval 算法使用 Lyndon 分解和由周元提出的令人惊讶优雅的最小表达算法。
加速排名。 使用以下思想加速
rank()方法中的二分搜索。让lo和hi表示当前搜索区间的左右端点。让lcpLo表示查询字符串和suffixes[lo]的 lcp,让lcpHi表示查询字符串和suffixes[hi]的 lcp。然后,在将查询字符串与suffixes[mid]进行比较时,只需要比较从lcp = min(lcpLo, lcpHi)开始的字符,因为搜索区间中的所有后缀都具有相同的前lcp个字符。加速排名分析。 显示加速排名的最坏情况运行时间仍然与 L log N 成正比,其中 L 是查询的长度,N 是文本的长度。然而,Myers 和 Manber 报告说这在实践中加快了计算。理论上,可以使用非相邻的 lcp 值将其改进为 L + log N。
最长 3 重复子串。 给定一个文本字符串,找到重复 3 次或更多次的最长子串。
最长 k 重复子串。 给定一个文本字符串和一个整数 k,找到重复 k 次或更多次的最长子串。
长重复子串。 给定一个文本字符串和一个整数 L,找到所有长度大于等于 L 的重复子串。
三个字符串中的最长公共子串。 给定三个字符串 r、s 和 t,找到在所有三个字符���中出现的最长子串。
最长公共反向互补子串。 给定两个 DNA 字符串,找到出现在一个字符串中的最长子串,其反向 Watson-Crick 互补出现在另一个字符串中。如果 t 是 s 的反向,除了以下替换 AT、CG,则两个字符串 s 和 t 是反向互补的。例如 ATTTCGG 和 CCGAAAT 是彼此的反向互补。 提示:后缀排序。
具有更少内存的后缀数组。 不使用子字符串数组,其中
suffixes[i]指的是第 i 个排序后缀,而是维护一个整数数组,其中 index[i] 指的是第 i 个排序后缀的偏移量。要比较由 a = index[i] 和 b = index[j] 表示的子字符串,比较字符s.charAt(a)与s.charAt(b),s.charAt(a+1)与s.charAt(b+1),依此类推。你节省了多少内存?你的程序更快吗?k-gram 频率计数。 给定一个文本字符串,设计一个数据结构以高效地回答以下形式的查询:给定一个 k-gram 出现了多少次?在最坏情况下,时间复杂度应该与 k log N 成正比,其中 k 是 k-gram 的长度,N 是文本字符串的长度。
最频繁的 k-gram。 给定一个文本字符串和一个整数 k,找到出现频率最高的 k-gram。
最短唯一子串。 给定一个文本字符串,找到一个出现仅一次的最短子串。这个问题常见于生物信息学。
最短非子串。 给定一个比特串,找到一个最短的比特串,它不作为子串出现。
最短唯一子串。 给定一个文本字符串,预处理它以回答以下形式的最短唯一子串查询:给定一个索引 q 到文本字符串,找到一个包含索引 q 且在文本中其他地方不作为子串出现的最短子串。
6.4 最大流
原文:
algs4.cs.princeton.edu/64maxflow译者:飞龙
本节正在大规模建设中。
最大流和最小 s-t 割。
程序 FordFulkerson.java 在 E² V 时间内使用 Edmonds-Karp 最短增广路径启发式方法计算带权有向图中的最大流和最小 s-t 割(尽管在实践中,它通常运行得更快)。它使用 FlowNetwork.java 和 FlowEdge.java。
渗透。
2D(始终选择最左边的路径)和 3D 中的最大流。
应用于计算机图形学。
Yuri Boykov在计算机视觉中的分割应用中有关于最大流的论文。maxflow data。
二部图匹配。
练习
**包含两个顶点的循环。**给定一个无向图 G 和两个特殊顶点 s 和 t,找到包含 s 和 t 的循环(不一定是简单的),或报告不存在这样的循环。您的算法应该在线性时间内运行。
答案。当且仅当从 s 到 t 的最大流至少为 2 时,答案是肯定的。因此,在每条边都替换为两条反向边和单位容量的有向图中运行 Ford-Fulkerson 的两次迭代。
**k-连通。**给定一个无向图,确定两个顶点 s 和 t 是否 k-连通(或等价地,是否存在 k 条边不相交的路径)。
真或假。如果为真,请提供简短的证明,如果为假,请给出一个反例。
在任何最大流中,没有一个有正流量的有向循环。
存在一种最大流,其中没有一个有正流量的有向循环。
如果所有边的容量都不同,最大流是唯一的。
如果所有边的容量都不同,最小割是唯一的。
如果所有边的容量增加一个加法常数,最小割保持不变。
如果所有边的容量乘以一个正整数,最小割保持不变。
**包含两个顶点的简单循环。**给定一个无向图和两个顶点 s 和 t,找到包含 s 和 t 的简单循环(或报告不存在这样的循环)。
提示:当且仅当存在两个(内部)顶点不相交的路径时,包含 s 和 t 的有向循环才存在。
**有向图中的顶点不相交路径。**给定一个有向图 G 和两个顶点 s 和 t,找到从 s 到 t 的最大数量的顶点不相交路径。
提示:将每个顶点 v(除了 s 和 t 之外)替换为两个顶点 v1 和 v2;从 v1 到 v2 添加容量为 1 的边;将所有指向 v 的边重定向到 v1;将所有从 v 指向的边重定向到 v2。
**无向图中的顶点不相交路径。**给定一个无向图 G 和两个顶点 s 和 t,找到 s 和 t 之间的最大数量的顶点不相交路径。
提示:将每条无向边 v-w 替换为两条有向边 v->w 和 w->v。
**包含三个顶点的简单路径。**给定一个无向图和三个顶点 u、v 和 w,找到包含 u、v 和 w 的简单路径(或报告不存在这样的路径)。
提示:当且仅当存在两个(内部)顶点不相交的路径,一个从 v 到 u,一个从 v 到 w 时,u 和 w 之间存在一个通过 v 的简单路径。
**唯一最小割。**给定一个 s-t 流网络,确定最小割是否唯一。
提示:在 G 和 G^R 中解决 s-t 最小割问题。
**二部图中的不可匹配边。**给定一个二部图 G,如果边不出现在 G 的任何完美匹配中,则该边是不��匹配的。
提示:如果 G 没有完美匹配,那么所有边都是不可匹配的。否则,找到一个完美匹配;将完美匹配中的所有边定向为一个方向,将所有剩余的边定向为相反的方向;不在完美匹配中且端点在结果有向图的不同强连通分量中的边都是不可匹配的边。
具有最少边的最小割。 给定一个流网络,在所有最小割中,找到一个具有最少边数的割。
提示:创建一个新的流网络 G',它等于 G,除了 w'(e) = w(e) * n + 1。
6.5 归约
原文:
algs4.cs.princeton.edu/65reductions译者:飞龙
本章节正在建设中。
归约。
在计算机科学中,归约或模拟是一个强大的概念.... 将问题 X 转化为一个更简单的问题 Y 的问题解决框架,以便根据 Y 的解推导出原始问题 X 的解。
Eric Alander - “归约提供了一种抽象。如果 A 高效地归约到 B,且 B 高效地归约到 A,那么 A 和 B 在某种意义上是等价的:它们是观察同一问题的两种不同方式。我们不再有无限多的计算问题,而是留下了更少数量的等价问题类。没有什么能让计算社区为这一惊人的洞察做好准备,即人们真正想要解决的计算问题只有几个。根据资源限制将自然计算问题划分为有意义的组别。”
上界。
3-共线到排序。
凸包到排序(Graham 扫描)。
中位数到排序。
二分图匹配到最大流。BipartiteMatchingToMaxflow.java 通过将问题归约为最大流,在二分图中计算最大基数匹配。在最坏情况下,每次增广都会增加匹配的基数。Hopcroft-Karp 算法将运行时间改进为 E sqrt(V)。
LP 标准形式:标准形式线性规划是 { max cx : Ax ⇐ b, x ≥ 0 }。展示如何将一般线性规划(带有 ≤、≥ 和 = 约束)归约为标准形式。
最大流到线性规划。
分配问题到线性规划。
PERT 到 有向无环图的拓扑排序。
USTCONN(无向图中的 s-t 连通性)到 STCONN(有向图中的无向 s-t 连通性)。
无向图上的最短路径到有向图上的最短路径。
欧几里得最小生成树到 Delauney 三角剖分。
LP 可行性归约为 LP。(使用二分搜索。需要限制有界 LP 的值。)
二人零和博弈到 LP。TwoPersonZeroSumGame.java
LP 可归约为二人零和博弈。(Bradley, Hax, 和 Magnanti 的练习 26)
下界。
元素唯一性到排序。
2D 最近点对问题到 2D 欧几里得最小生成树。
凸包到 Voronoi / Delauney 三角剖分。
3-SUM 到 3-共线。
练习:3-SUM 到 4-SUM。
练习:3-SUM 到 3-SUMplus
练习:3-共线到 3-共点。
3-SAT 到 独立集。
独立集到整数线性规划。
元素唯一性。 给定 N 个来自全序宇宙的元素 x[1], ..., x[n],是否存在一对 i ≠ j,使得 x[i] = x[j]?在比较树模型和代数决策树模型中有 Theta(N log N) 的下界。通过排序可以轻松达到 O(N log N)。
解决方案 1(比较树模型):给定 N 个不同的值。让 ai 成为第 i 小的元素。在任何基于比较的算法中,我们必须比较 a[i] 和 a[i-1];否则算法无法区分 a[i-1] < a[i] 和 a[i-1] = a[i] 的情况。考虑 N 个元素的两种不同排列。存在一个元素 a[i],在第一个排列中 a[i-1] 在 a[i] 之前,但在第二个排列中 a[i-1] 在 a[i] 之后。由于每个基于比较的算法必须比较 a[i] 和 a[i-1],算法在这两个排列上运行不同。因此,至少有 N! 个叶子节点。参考链接
解法 2(比较树模型):假设元素都是不同的且 a_1 < a_2 < ... < a_N。任何正确的元素唯一性算法必须对每个 i < N 比较 a_i 和 a_i+1。如果不这样做,那么如果我们将 a_i+1 更改为 a_i,算法将产生相同的输出(但这将从无重复更改为有重复的答案)。算法使用的比较集合形成一个 DAG。找到总顺序(线性时间)并得到排序顺序。因此,该算法可用于对不同元素进行排序,且排序下界适用。
备注:这些论点适用于比较树模型的计算,但不适用于线性决策树或代数决策树模型的计算。这里有一个更一般的论点(由 Jeff Erickson 提供):考虑所有可能输入的空间 R^n。正输入的集合有 n!个连通分量,每个排列一个。另一方面,可以到达线性决策树中任何叶子的输入子集是凸的,因此是连通的。因此,任何确定唯一性的线性决策树至少有 n!个叶子。线性决策树的结果是由 Dobkin 和 Lipton 在On the complexity of computations under varying sets of primitives.中得出的。代数决策树的结果是由 Ben-Or 在Lower bounds for algebraic computation trees.中得出的。整数输入的特殊情况的结果更微妙:参见 Lubiw 和 Racs 的整数元素唯一性问题的下界。
3-SUM。
3-SAT。
线性规划。
将线性方程组推广到不等式。
单纯形算法。
由 George Dantzig 于 1948 年发明。根据 Dongarra 和 Sullivan 的说法,单纯形算法是 20 世纪对科学和工程影响最大的十大算法之一。广义的高斯消元以处理不等式。程序 LinearProgramming.java 是一个基本实现。它解决{ max cx : Ax ⇐ b, x >= 0 }假设 b >= 0。因此 x = 0 是一个基本可行解(我们不需要担心单纯形的第一阶段)。
线性规划的基本定理。形式为(P)的每个 LP 都具有以下三个属性:(i)如果它没有最优解,则它要么是不可行的,要么是无界的(ii)如果它有一个可行解,则它有一个基本可行解(iii)如果它有一个最优解,则它有一个基本最优解。
强对偶定理。形式为(P)的每个 LP 都有一个对偶(D){ min by : y A >= c : y >= 0 }。p1 + p2 + ... + pM = 1 → x1 + x2 + ... + xm = 1/V。
如果(P)有���且可行,则(D)也有界且可行。此外,它们具有相同的最优值。单纯形算法同时解决这两个问题。
显著特性。实践中,单纯形算法通常在最多 2(m+n)个主元中终止。n = 总变量(原始+松弛),m = 方程。
陷阱。退化,循环。
分配问题。
形式化为 LP。依赖于 Birchoff-von Neumann 定理:分配问题 LP 的所有极端点都是 {0, 1}。AssignmentProblemToLP.java 将分配问题(最大权重完美匹配)简化为线性规划。EasyAssignmentProblemToLP.java 将所有内容放在主函数中,不提取对偶解。Hungarian.java 实现了匈牙利算法;在最坏情况下,运行时间的增长数量级为 N⁴。 --> AssignmentProblem.java 实现了连续最短路径算法;在最坏情况下,运行时间的增长数量级为 N³ log N。AssignmentProblemDense.java 实现了连续最短路径算法;在最坏情况下,运行时间的增长数量级为 N³。
二人零和博弈。
简化为 LP。可以假设收益矩阵严格为正(对每个条目添加一个常数会使游戏的价值增加 M,但不会改变解)。(P)min { x1 + ... + xm, : x >= 0, M^t x >= 1} 和(D)max { y1 + ... + yn, : y >= 0, My ⇐ 1}。将解向量 x* 和 y* 标准化,以获得行玩家(最小化)和列玩家(最大化)的最佳策略;标准化常数是游戏的价值。
技巧。替换变量:xi = pi / V;最大化 V → 最小化 1/V;博弈论,第 6-7 页
程序 ZeroSumGame.java 实现了这种方法。
注意:通过适当的变量更改,任何 LP 都可以转化为这种形式。
极小极大定理。(冯·诺伊曼)。对于每个有限的二人零和博弈,存在一个值 V 和每个玩家的混合策略,使得(i)给定行玩家的策略,列玩家的最佳可能收益是 V,以及(ii)给定列玩家的策略,行玩家的最佳可能收益是 -V。
线性规划求解器。
LinearProgramming.java 是单纯形算法的简化版本。假设 b >= 0,因此 x = 0 是一个起始的基本可行解。TwoPhaseSimplex.java 是两阶段单纯形算法的简化版本,它消除了假设 b >= 0。
OR-Objects 包含一个 Java 线性规划求解器。LPDemo.java 演示了如何使用 or124.jar 解决线性规划问题。
QSopt 是由大卫·阿普尔盖特、威廉·库克、Sanjeeb Dash 和 Monika Mevenkamp 创造的 Java 线性规划求解器。可以免费用于研究或教育目的。QSoptSolver.java 解决了 LP 格式的线性规划问题,例如 beer.lp。
Matlab 包含优化工具箱中的线性规划求解器。
[wayne:tombstone] ~> matlab < M A T L A B (R) > Copyright 1984-2009 The MathWorks, Inc. Version 7.9.0.529 (R2009b) 64-bit (glnxa64) August 12, 2009 >> A = [5 15; 4 4; 35 20]; >> b = [480; 160; 1190]; >> c = [13; 23]; >> lb = [0; 0]; >> ub = [inf; inf]; >> x = linprog(-c, A, b, [], [], lb, ub) x = 12.0000 28.0000CPLEX 是用于线性规划、混合整数规划和二次规划的高性能数学规划求解器。支持与 C、C++、Java、Python、Matlab 和 Microsoft Excel 的接口。也可通过建模系统(包括 AIMMS、AMPL、GAMS 和 MPL)访问。
AMPL 是数学规划的建模语言。文件 beer.mod 和 beer.dat 指定了酿酒厂问题的模型和数据。
[wayne:tombstone] ~> ampl ILOG AMPL 9.100 AMPL Version 20021038 (SunOS 5.8) ampl: model beer.mod; ampl: data beer.dat; ampl: solve; ILOG CPLEX 9.100 CPLEX 9.1.0: optimal solution; objective 800 2 dual simplex iterations (1 in phase I) ampl: display x; x [*] := ale 12 beer 28 ; ampl: display constraints.dual; x [*] := corn 1 hops 2 malt 0 ;Microsoft Excel 在 Windows 上有一个基本的求解器插件,但在 Mac 上不再可用。
问答
Q. 无向最短路径与带负权重的有向最短路径?
A. 需要更聪明的简化来避免负循环(通过非二部匹配)。
练习
最大公约数和最小公倍数. 将最小公倍数简化为最大公约数。
表 k-和. 给定一个 k 行 N 列的整数表,是否有 k 个数相加为 0,每个数来自 k 行中的一个?
硬币不同。 给定 N 枚硬币和一个天平,确定这些硬币是否都有不同的重量。证明解决该问题的任何算法的复杂度至少为 N log N。提示:你可以使用这样一个事实,即给定 N 个实数,元素的不同性在线性决策树模型中至少需要 N log N 次比较(在这个模型中,你可以对 N 个元素的任意线性组合进行比较,例如,x1 + 2x2 < x3)。
集合相等。 给定两个集合 S 和 T,S 是否等于 T?给出一个 O(N log N)的算法和一个匹配的下界。
集合子集。 给定两个集合 S 和 T,S 是否是 T 的子集?给出一个 O(N log N)的算法和一个匹配的下界。
集合不相交。 给定两个集合 S 和 T,S 与 T 的交集是否为空集?给出一个 O(N log N)的算法和一个匹配的下界。(由于 S 和 T 是集合,因此两者中没有重复元素。)
解决方案。 要获得 O(N log N)的上界,对 S 和 T 中的元素的并集进行排序,并检查重复项。
集合不相交。 给定两个按升序排列的集合 S 和 T,S 与 T 的交集是否为空集?证明一个 Omega(N log N)的下界或给出一个 O(N)的算法。
合并两个列表的下界。 展示任何基于比较的合并两个大小为 N 的列表的算法在最坏情况下至少需要 2N-1 次比较。解决方案:即使所有元素都不同,下界也成立。如果第 i 个和第(i+1)个最小的元素在不同的列表中,则它们必须进行比较。
二分查找的下界。 需要 log(N+1)次比较。解决方案:即使所有元素都不同,下界也成立……
欧几里得 TSP 和 MST 下界。 对于欧几里得 TSP 或欧几里得 MST,给出一个 Omega(N log N)的下界(需要代数决策树来理解,因为你希望允许一个合理的算法来计算距离)。解决方案:在一条线上选择 N 个点。最优路径必须按升序访问这些点。
模式的下界。 模式的 Theta(N log N)下界。解决方案:可以通过找到模式并检查是否超过一个来解决元素不同性问题。
最接近对的下界。 最接近对的 Theta(N log N)下界。解决方案:如果有一个次线性对数算法,可以通过找到最接近对并检查它们是否相等来在次线性对数时间内解决元素不同性问题。
最小和次小元素。 描述如何使用最多 N + log N 次比较找到最小和次小元素。解决方案:将元素分成一对一对,并比较每对中的两个元素。使用每对中的 N/2 个获胜者进行递归。经过 N-1 次比较后,我们得到最小元素。请注意,每个元素最多与 log N 个其他元素进行比较。特别是,最小元素最多与 log N 个其他元素进行比较,其中一个必须是次小元素。如果你跟踪所有的比较,你可以记住涉及最小元素的 log N 次比较,并进行比较。
计数逆序对。 证明 Theta(N log N)的下界。这是一个尚未解决的研究问题。
螺母和螺栓。 证明快速排序部分的螺母和螺栓问题的 Theta(N log N)下界。[已知匹配的最坏情况上界为 O(N log N),但这是非常复杂的。]
存在符号表。 假设你有一个支持插入和存在操作的基于比较的算法,每次操作都只需要 O(1)次比较。解释为什么在这种计算模型中这是不可能的。解决方案:为了在 O(N)时间内解决元素不同性问题,对于每个 N 个元素,检查它是否已经在数据结构中(如果是,则这是一个重复项);如果不是,则插入它。
使用整数规划模型一个形如 x ≤ y 的约束(Ax = b,x >= 0,x 为整数)。
使用整数规划模型一个形如 x = {0 或 1}的约束。
使用整数规划模型一个形如 x = {1 或 2 或 4}的约束。
Tait 着色。 使用 IP 模拟 3 边着色立方图。
为患者分配器官捐赠者。
二部顶点覆盖。 减少到最大流。
传递闭包和传递闭包。 将传递闭包减少到传递闭包,反之亦然(当运行时间仅作为顶点数 V 的函数时)。也可减少到布尔矩阵乘法。
3SUM'。 给定三组整数 A、B 和 C,总大小为 n,是否存在 A 中的 a,B 中的 b 和 C 中的 c,使得 a + b = c?证明 3SUM 线性时间减少到 3SUM',反之亦然。
解决方案。 要显示 3SUM 减少到 3SUM',设置 A = S,B = S,C = -S。
要显示 3SUM'减少到 3SUM,假设所有整数都是正数(如果不是,则将 k 添加到 A 和 B 中的每个元素,将 2K 添加到 C 中的每个元素)。设置 m = 2 max(A, B, C)。对于 A 中的每个元素 a,在 S 中放置 a + m。对于 B 中的每个元素 b,在 S 中放置 b。对于 C 中的每个元素,在 S 中放置-c -m。
不等式满足等式。 给定线性不等式系统A x ≤ b,设计一个线性规划来确定在任何可行解x中哪些不等式必须满足等式。
等式约束。 使用两个<=约束模拟线性规划等式约束。
无限制变量。 使用两个非负变量 y 和 z 来模拟线性规划无限制变量 x。
无界 LP。 如果(P)是无界的,则存在一个矢量 d >= 0,使得 Ad ⇐ 0 且 cd > 0。首先解释为什么如果存在这样的矢量 d,则问题是无界的。接下来,修改单纯形算法,在 LP 无界时报告这样的矢量。答案:根据假设 b >= 0 且 x = 0 是可行的。因此,对于任何正常数α,αd 都是可行的,并且具有目标值αcd。通过检查最小比率规则是否失败,可以识别矢量 d。在这种情况下,列 q 中的条目为非正(Ad ⇐ 0),并且目标函数系数为正(cd > 0)。
减少成本。 修改单纯形算法以报告减少的成本(影子价格)。给出经济解释。
丹齐格的最陡边规则。 修改单纯形算法,使其始终选择最正的目标函数系数。
循环。 给出一个导致单纯形算法(使用最陡边规则)循环的病态线性规划输入。
瓶颈分配问题。 给定 N 个男人和 N 个女人。每个人有 M 个属性。每个人指定异性的一组理想属性。找到一个完美匹配,其中最不幸的人与指定属性最少的伴侣匹配。将此问题减少到分配问题。
解决方案。 创建一个边权重图,其中边的权重是两个人中满足的可取属性的最小数量。目标是最大化分配中最小权重边的权重。解决此问题的一种方法是使用二分查找(消除所有权重小于给定阈值的边)并解决结果的二部完美匹配问题。
中国邮递员问题。 给定一个强连通的有向图 G,找到一个最短长度的有向循环,该循环至少经过每条边一次。(事实证明,最佳循环将最多访问每条边两次。)如果每个顶点都是平衡的,则图是欧拉图:其度等于其出度。将不平衡的顶点分为两组:L = 出度小于入度的顶点,R = 出度大于入度的顶点。通过在 L 中的一个顶点到 R 中的一个顶点添加一个有向路径,我们改善了平衡性。在 (L, R) 上形成一个加权二部图,其中从 v 到 w 的边的权重是 G 中从 v 到 w 的最短路径的长度。找到一个最小权重匹配,并将这些边添加到 G 中使其成为欧拉图。然后,找到一个循环。这被称为 Edmonds-Johnson(1973)算法。(类似的算法适用于无向图,但需要在非二部图中找到一个最小权重完美匹配。)
6.6 难解性
原文:
algs4.cs.princeton.edu/66intractability译者:飞龙
本节正在建设中。复杂性理论的目标是理解高效计算的本质。我们已经学习了算法分析,这使我们能够根据它们消耗的资源量对算法进行分类。在本节中,我们将学习一个丰富的问题类别,至今还没有人能够设计出高效的算法。
计算复杂性。
随着数字计算机在 1940 年代和 1950 年代的发展,图灵机成为计算的理论模型。在 1960 年代,Hartmanis 和 Stearns 提出了将计算机所需的时间和内存作为输入大小的函数来衡量。他们以图灵机为基础定义了复杂性类,并证明了一些问题具有“无法通过巧妙编程规避的固有复杂性”。他们还证明了直观观念的一个正式版本(时间层次定理),即如果给予更多时间或空间,图灵机可以计算更多事物。换句话说,无论问题有多难(时间和空间要求),总会有更难的问题。
计算复杂性是确定不同问题的资源需求的艺术和科学。计算复杂性涉及对任何可能的问题算法的断言。做出这样的断言比理解问题的一个特定算法的运行时间要困难得多,因为我们必须推理所有可能的算法(甚至是尚未发现的算法)。这使得计算复杂性成为一个令人兴奋但又令人望而生畏的研究领域。我们将概述一些其最重要的思想和实际产物。
多项式时间。
我们已经分析了算法的运行时间作为其输入大小的函数。在解决给定问题时,我们更喜欢一个需要 8 N log N 步的算法,而不是需要 3 N² 步的算法,因为当 N 很大时,第一个算法比第二个算法快得多。第二个算法最终会解决相同的问题(但可能需要几小时而不是几秒)。相比之下,指数时间算法具有不同的定性行为。例如,对于 TSP 的暴力算法可能需要 N! 步。即使宇宙中的每个电子(10⁷⁹)都具有今天最快超级计算机(每秒 10¹² 条指令)的能力,并且每个电子在解决问题上工作了宇宙寿命(10¹⁷ 秒),也几乎无法解决 N = 1,000 的问题,因为 1000! >> 10¹⁰⁰⁰ >> 10⁷⁹ * 10¹² * 10¹⁷。指数增长使技术变革相形见绌。我们将任何运行时间受输入大小多项式限制的算法(例如 N log N 或 N²)称为多项式时间算法。如果问题没有多项式时间算法,则称该问题为难解问题。
创建 N、N³、N⁵、N¹⁰、1.1N、2N、N! 的对数对数比例图,如 Harel 第 74 页所示。
随着程序员对计算的经验增加,人们逐渐意识到多项式时间算法是有用的,而指数时间算法则不是。在一篇非常有影响力的论文中,Jack Edmonds 将多项式算法称为“好算法”,并认为多项式时间是高效计算的一个很好的替代。Kurt Godel 在 1956 年给 von Neumann 写了一封信(第 9 页),其中包含了多项式性是一个可取特征的(隐含)概念。早在 1953 年,von Neumann 就认���到了多项式和指数算法之间的定性差异。根据多项式和指数时间对问题进行分类的想法深刻地改变了人们对计算问题的看法。
NP。
非正式地,我们将搜索问题定义为一个计算问题,我们在(可能巨大的)可能性中寻找解决方案,但是当我们找到解决方案时,我们可以轻松检查它是否解决了我们的问题。给定搜索问题的一个实例 I(指定问题的一些输入数据),我们的目标是找到一个解决方案 S(符合某些预先指定标准的实体)或报告不存在这样的解决方案。为了成为搜索问题,我们要求很容易检查 S 是否确实是一个解决方案。在这里,我们指的是在输入 I 的大小上是多项式时间。复杂性类NP是所有搜索问题的集合。以下是一些例子。
*线性方程组。*给定线性方程系统 Ax = b,找到满足方程的解 x(如果存在)。这个问题属于 NP,因为如果我们得到一个所谓的解 x,我们可以通过将 x 代入并验证每个方程来检查 Ax = b。
*线性规划。*给定线性不等式系统 Ax ≤ b,找到满足不等式的解 x(如果存在)。这个问题属于 NP,因为如果我们得到一个所谓的解 x,我们可以通过将 x 代入并验证每个不等式来检查 Ax ≤ b。
*整数线性规划。*给定线性不等式系统 Ax ≤ b,找到满足不等式的二进制(0/1)解 x(如果存在)。这个问题属于 NP,因为如果我们得到一个所谓的解 x,我们可以通过将 x 代入并验证每个不等式来检查 Ax ≤ b。
虽然检查对这三个问题的提议解决方案很容易,但是从头开始找到解决方案有多困难?
备注:我们对 NP 的定义略有不同。在历史上,复杂性类别是根据决策问题(是-否问题)来定义的。例如,给定矩阵A和向量b,是否存在解x使得Ax = b?
P。
复杂性类 P 是所有可以在多项式时间内解决的搜索问题的集合(在确定性图灵机上)。与以前一样,我们根据搜索问题(而不是决策问题)来定义 P。它涵盖了我们可以在实际机器上解决的大多数问题。以下列出了一些例子:
| 问题 | 描述 | 算法 | 实例 | 解决方案 |
|---|---|---|---|---|
| GCD | 找到两个整数 x 和 y 的最大公约数。 | 欧几里得算法(欧几里得,公元前 300 年) | 34, 51 | 17 |
| STCONN | 给定图 G 和两个顶点 s 和 t,找到从 s 到 t 的路径。 | BFS 或 DFS(Theseus) | ||
| SORT | 找到将元素按升序排列的排列。 | 归并排序(冯·诺伊曼,1945) | 2.3 8.5 1.2 9.1 2.2 0.3 | 5 2 4 0 1 3 |
| PLANARITY | 给定一个图 G,在平面上画出它,使得没有两条边相交。 | (Hopcroft-Tarjan, 1974) | ||
| LSOLVE | 给定矩阵 A 和向量 b,找到一个向量 x 使得 Ax = b。 | 高斯消元(Edmonds, 1967) | x+y=1 2x+4y=3 | x = 1/2 y = 1/2 |
| LP | 给定矩阵 A 和向量 b,找到一个向量 x 使得 Ax ≤ b? | 椭球算法(Khachiyan, 1979) | x+y≤1 2x+4y≤3 | x = 0 y = 0 |
| DIOPHANTINE | 给定一个具有整数系数的(稀疏)一元多项式,找到一个整数根? | (Smale 等,1999) | x⁵ - 32 | x = 2 |
扩展的丘奇-图灵论断。
在 1960 年代中期,Cobham 和 Edmonds 独立观察到,在一个广泛的计算模型范围内,可以在多项式步骤内解决的问题集保持不变,从确定性图灵机到 RAM 机。扩展的丘奇-图灵论题断言图灵机与任何物理计算设备一样高效。也就是说,P 是在这个宇宙中可以在多项式时间内解决的搜索问题的集合。如果某个硬件解决了大小为 N 的问题,时间为 T(N),扩展的丘奇-图灵论题断言确定���图灵机可以在时间 T(N)^k 内解决它,其中 k 是某个固定常数,k 取决于特定问题。Andy Yao 表达了这个论题的广泛含义:
它们暗示,至少从原理上讲,要使未来的计算机更加高效,只需要专注于改进现代计算机设计的实现技术。
换句话说,任何合理的计算模型都可以在(概率性)图灵机上高效模拟。对于所有已知的物理通用计算机,扩展的丘奇-图灵论题都是成立的。对于随机访问机器(例如您的 PC 或 Mac),常数 k = 2。因此,例如,如果随机访问机器可以在时间 N^(3/2)内执行计算,则图灵机可以在时间 N³内执行相同的计算。
P = NP 吗?
我们这个时代最深刻的科学问题之一是P = NP。也就是说,所有搜索问题是否都能在多项式时间内解决?Clay Foundation 为解决这个问题提供了100 万美元的千禧奖。以下是一些关于何时解决这个问题的猜测。压倒性的共识是 P != NP,但没有人能够证明。
视频中荷马·辛普森对 P = NP 进行演讲,伴随着《失乐园》的音乐。
哥德尔写给冯·诺伊曼的信预见了 P = NP 问题。他意识到如果 P = NP(可满足性在 P 中),那么“将会有最重要的后果”,因为那时“数学家关于是或否问题的思维工作可以完全被机器取代”。他询问哪些组合问题存在更有效的替代方案以避免穷举搜索。
NP 完全性。
非正式地说,NP 完全问题是 NP 中“最难”的问题;它们最有可能不在 P 中。定义:如果(i)它在 NP 中且(ii)每个 NP 问题都可以多项式归约到它,则问题是NP 完全的。定义 NP 完全性的概念并不意味着这样的问题存在。事实上,NP 完全问题的存在是一件令人惊奇的事情。我们无法通过从每个 NP 问题进行归约来证明问题是 NP 完全的,因为它们有无限多个。在 1960 年代,Cook 和 Levin 证明了 SAT 是 NP 完全的。
这是普遍性的一个例子:如果我们可以解决任何 NP 完全问题,那么我们就可以解决 NP 中的任何问题。独特的科学发现为所有类型的问题提供了共同的解释。更令人惊讶的是,存在着“自然”的 NP 完全问题。
NP 完全性对自然科学的影响是不可否认的。一旦发现了第一个 NP 完全问题,难以解决性质就像“冲击波一样在问题空间中蔓延”,首先是在计算机科学中,然后传播到其他科学学科。帕帕迪米特里奥列出了 20 个不同的科学学科,它们正在应对内部问题。最终,科学家们在意识到他们的核心问题是 NP 完全问题后,发现了它们固有的复杂性。每年有 6000 篇科学论文中提到 NP 完全性作为一个关键词。它“涵盖了计算、科学、数学努力的广泛领域,并似乎粗略地界定了数学家和科学家一直渴望可行计算的范围。”[帕帕迪米特里奥]很少有科学理论有如此广泛和深远的影响。
一些 NP 完全问题。 自从发现 SAT 是 NP 完全以来,已经确定了成千上万个问题是 NP 完全的。1972 年,卡普尔表明离散数学中最臭名昭著的 21 个问题是NP 完全的,包括Tsp,Knapsack,3Color和Clique。科学家未能为这 21 个问题找到有效算法,尽管他们不知道这些问题是 NP 完全的,这是最早表明 P != NP 的证据之一。下面列出了一些 NP 完全问题的样本。这里还有一些NP 完全问题。这只是为了说明它们的多样性和普遍性。
Bin Packing. 你有 n 个物品和 m 个箱子。第 i 个物品重 w[i]磅。每个箱子最多可以容纳 W 磅。你能否将所有 n 个物品装入 m 个箱子中,而不违反给定的重量限制?
这个问题有许多工业应用。例如,UPS 可能需要从一个配送中心将大量包裹(物品)运送到另一个中心。它希望将它们放入卡车(箱子)中,并尽可能少地使用卡车。其他 NP 完全变体允许体积要求:每个三维包裹占用空间,你还必须担心如何在卡车内安排包裹。
Knapsack. 你有一组 n 个物品。第 i 个物品重 w[i]磅,具有利益 b[i]。你能否选择一些物品的子集,使得总重量小于或等于 W,总利益大于或等于 B?例如,当你去露营时,你必须根据它们的重量和效用选择要带的物品。或者,假设你正在入室行窃,只能在你的背包中携带 W 磅的赃物。每个物品 i 重 w[i]磅,有 b[i]美元的市场价值。你应该偷哪些物品?
Subset Sum. 给定 n 个整数,是否存在一个子集,其和恰好为 B?例如,假设这些整数是{4, 5, 8, 13, 15, 24, 33}。如果 B = 36,则答案是 yes(且 4, 8, 24 是一个证明)。如果 B = 14,则答案是 no。
Partition. 给定 n 个整数,你能将它们分成两个子集,使得每个子集的和相等吗?例如,假设这些整数是{4, 5, 8, 13, 15, 24, 33}。那么答案是
yes,{5, 13, 33}是一个证明。双处理器的负载平衡。整数线性规划. 给定一个整数矩阵 A 和一个整数向量 b,是否存在一个整数向量 x 使得 Ax ≤ b?这是运筹学中的一个核心问题,因为许多优化问题可以用这种方式表达。请注意,与上面提出的线性规划问题形成对比,我们在这里寻找的是一个有理数向量而不是一个整数向量。可解问题和难解问题之间的界限可能非常微妙。
SAT. 给定 n 个布尔变量 x[1],x[2],...,x[N]和一个逻辑公式,是否存在一个真值变量的赋值使得公式是可满足的,即为真?例如,假设公式是
(x[1]' + x[2] + x[3]) (x[1] + x[2]' + x[3]) (x[2] + x[3]) (x[1]' + x[2]' + x[3]')
然后,答案是 yes,(x[1],x[2],x[3]) = (true,true,false)是一个证书。许多应用于电子设计自动化(EDA),包括测试和验证,逻辑综合,FPGA 布线和路径延迟分析。应用于人工智能,包括知识库推理和自动定理证明。
练习:给定两个电路 C1 和 C2,设计一个新电路 C,使得一些输入值的设置使得 C 输出为真当且仅当 C1 和 C2 等价。
3-SAT。 给定 n 个布尔变量 x[1],x[2],...,x[N]和一个逻辑公式(合取范式)的逻辑公式,每个子句恰好有 3 个不同的文字,是否存在一个真值变量的赋值使得公式可满足?
团。 给定 n 个人和一组成对友谊关系。是否存在一个由 k 个人组成的团或团,使得团内每对可能的人都是朋友?在绘制友谊图时很方便,其中我们为每个人包括一个节点,并连接每对朋友的边。在以下示例中,n = 11,k = 4,答案是
yes,{2, 4, 8, 9}是一个证书。最长路径。 给定一组节点和节点之间的距离,是否存��一条长度至少为 L 的简单路径连接某对节点?
机器调度。 你的目标是在 m 台机器上处理 n 个作业。为简单起见,假设每台机器可以在 1 个时间单位内处理任何一个作业。此外,可能存在优先约束:也许作业 j 必须在作业 k 开始之前完成。你能安排所有作业在 L 个时间单位内完成吗?
调度问题有大量的应用。工作和机器可能相当抽象:为了毕业普林斯顿,你需要修读 n 门不同的课程,但不愿意在任何一个学期修读超过 m 门课程。此外,许多课程有先修课程(在修读 126 之前不能修读 COS 226 或 217,但可以同时修读 226 和 217)。你能在 L 个学期内毕业吗?
最短公共超字符串。 给定基因字母表{ a,t,g,c }和 N 个 DNA 片段(例如,ttt,atggtg,gatgg,tgat,atttg),是否存在一个包含 K 个或更少字符的 DNA 序列,其中包含每个 DNA 片段?假设在上面的示例中 K = 11;那么答案是
yes,atttgatggtg是一个证书。应用于计算生物学。蛋白质折叠。 生物体内的蛋白质以非常特定的方式在三维空间中折叠到它们的天然状态。这种几何图案决定了蛋白质的行为和功能。最广泛使用的折叠模型之一是二维亲水-疏水(H-P)模型。在这个模型中,蛋白质是一个由 0 和 1 组成的序列,问题是将其嵌入到一个二维格子中,使得格子中相邻的 1 对数,但不在序列中(它的能量),被最小化。例如,序列 011001001110010 被嵌入到下图中,以便有 5 对新的相邻的 1(用星号表示)。
0 --- 1 --- 1 --- 0 * * | 0 --- 1 --- 1 0 | * | | 0 --- 1 * 1 * 1 | | | 0 0 --- 0最小化蛋白质的 H-P 能量是 NP 难题。(Papadimitriou 等)生物学家普遍认为蛋白质折叠是为了最小化它们的能量。Levinthal 悖论的一个版本问如何可能蛋白质能够有效地解决表面上看起来棘手的问题。
积分。 给定整数 a[1],a[2],...,a[N],以下积分是否等于 0?
![积分]()
如果你在下一门物理课程中看到这个积分,你不应该期望能够解决它。这不应该让人感到惊讶,因为在第 7.4 节中,我们考虑了一个不可判定的积分版本。
填字游戏. 给定一个整数 N 和一个有效单词列表,是否可以将字母分配给一个 N×N 的网格的单元格,以便所有水平和垂直单词都是有效的?如果一些方格是黑色的,像填字游戏一样,是否更容易?
定理. 给定一个假设的定理(比如黎曼猜想),你能否在某种形式系统(如策梅洛-弗伦克尔集合论)中使用最多 n 个符号证明它是真的?
俄罗斯方块.
扫雷.
正则表达式. 给定两个在一元字母表{ 1 }上的正则表达式,它们表示不同的语言吗?给定两个 NFA,它们表示不同的语言吗?也许很难确定这两个问题是否可判定,因为我们没有对一个语言中最小字符串的大小有明显的界限,而不在另一个语言中。[请注意,对于 DFA 的对应不等价问题是多项式可解的。] 我们将问题表述为不等价而不是等价的原因是,通过展示一个字符串 s,很容易检查这两个实体是否不等价。实际上,如果两个语言不同,那么最小字符串在输入大小的多项式中。因此,我们可以使用第 7.xyz 节中的高效算法来检查 s 是否被 RE 识别或被 NFA 接受。然而,要证明两个 RE 等价,我们需要一个保证所有字符串都在另一个中的论证,反之亦然。[可以设计一个(指数级)算法来测试两个 RE 或 NFA 是否等价,尽管这并不明显。]
旅鼠. 在游戏旅鼠的一个关卡中,是否可能引导一群绿发旅鼠生物安全到达目的地?
单位超立方体上的多项式最小化. 给定 N 个变量的多项式,是否最小值<= C,假设所有变量都在 0 和 1 之间。经典微积分问题:在[0, 1]上,min f(x) = ax² + bx + c。在 x = ??处的导数为 0,但最小值出现在边界处。
二次丢番图方程. 给定正整数 a、b 和 c,是否存在正整数 x 和 y,使得 ax² + by = c?
结实论. 在三维��形上哪些结实限制了一个 genus ≤ g 的表面?
有界后对应问题. 给定一个具有 N 张卡片的后对应问题和一个整数 K &le N,是否存在一个使用最多 K 张卡片的解?请注意,如果 K 没有限制,那么这是不可判定的。
纳什均衡. 合作博弈论。给定一个 2 人游戏,找到最大化玩家 1 收益的纳什均衡。是否存在多个 NE?是否存在一个帕累托最优的 NE?最大化社会福利的 NE。
二次同余. 给定正整数 a、b 和 c,是否存在一个小于 c 的正整数 x,使得 x² = a (mod b)?
3D 中的伊辛模型. 相变的简单数学模型,例如,当水结冰或冷却铁变成磁性时。计算最低能量状态是 NP 难的。如果图是平面的,则可以在多项式时间内解决,但 3D 晶格是非平面的。在被证明 NP 难之前,统计力学的圣杯已经存在了 75 年。建立 NP 完全性意味着物理学家不会再花 75 年时间试图解决不可解的问题。
带宽最小化. 给定一个 N×N 矩阵 A 和一个整数 B,是否可以重新排列 A 的行和列,使得 A[ij] = 0,如果|i - j| > B。对数值线性代数很有用。
投票和社会选择。 对于个人来说,操纵称为单一可转移选票的投票方案是 NP 难的。在 1876 年,刘易斯·卡罗尔(查尔斯·道奇森)提出的一种方案中,确定谁赢得了选举是 NP 难的。在卡罗尔的方案中,获胜者是通过在选民偏好排名中进行最少的两两相邻变化的候选人成为康德塞特赢家(在两两选举中击败所有其他候选人的候选人)。夏普利-舒比克投票权。计算Kemeny 最优聚合。
应对难解性。
NP 完全性理论表明,除非 P = NP,否则有一些重要问题无法创建同时实现以下三个属性的算法:
保证在多项式时间内解决问题。
保证解决问题的最优性。
保证解决问题的任意实例。
当我们遇到 NP 完全问题时,我们必须放宽三个要求中的一个。我们将考虑解决放宽了三个目标之一的 TSP 问题的解决方案。
复杂性理论处理最坏情况的行为。这留下了设计算法的可能性,这些算法在某些实例上运行速度快,但在其他实例上需要大量时间。例如,Chaff是一个可以解决许多具有 10,000 个变量的实际 SAT 实例的程序。令人惊讶的是,它是由普林斯顿大学的两名本科生开发的。该算法不能保证在多项式时间内运行,但我们感兴趣的实例可能是“简单的”。
有时我们可能愿意牺牲找到最优解的保证。许多启发式技术(模拟退火、遗传算法、Metropolis 算法)已被设计用于找到“几乎最优”的解决方案。有时甚至可以证明最终解决方案的优良程度。例如,Sanjeev Arora 设计了一种近似算法用于欧几里德 TSP 问题,保证找到的解决方案的成本最多比最优解高 1%。设计近似算法是一个活跃的研究领域。不幸的是,也有一些不可近似性结果,即:如果您可以为问题 X 找到一个近似算法,保证可以达到最优解的 2 倍,则 P = NP。因此,为一些 NP 完全问题设计近似算法是不可能的。
如果我们试图解决 TSP 问题的特殊类,例如,点位于圆的边界上或 M×N 格点的顶点上,则我们可以设计高效(且平凡)的算法来解决问题。
利用难解性。 有时难解性问题是一件好事。在第 XYZ 节中,我们将利用难解性问题设计密码系统。
介于 P 和 NP 完全之间。 现在已知大多数 NP 中的自然问题属于 P 或 NP 完全。如果 P != NP,则可以证明有一些 NP 问题既不属于 P 也不属于 NP 完全。就像“我们还没有观察手段的暗物质”。在地下世界中有一些显著的未分类问题:因子分解和子图同构。
因子分解。 已知的最佳算法是 2^O(n¹/3 polylog(n)) - 数域筛法。专家认为不属于 P。
受限优先级的 3 处理器调度。 给定一组单位长度的任务和一个优先级顺序,在 3 台并行机器上找到最短的调度。
转角问题。 给定 N(N-1)/2 个正数(不一定不同),是否存在一组 N 个点在直线上,使得这些数字是 N 个点的两两距离。直觉:点是 I-95 上的出口。问题首次出现在 1930 年代的 X 射线晶体学背景中。在分子生物学中也被称为部分消化问题。
布尔公式对偶化。 给定一个单调 CNF 公式和一个单调 DNF 公式,它们是否等价?(a + b)(c + d) = ac + ad + bc + bd。简单地应用德摩根定律会导致指数级算法,因为存在冗余。最佳算法为 O(n^(log n / log log n))。
随机游戏。 白色、黑色和自然轮流在有向图的边上移动一个令牌,从起始状态 s 开始。白色的目标是将令牌移动到目标状态 t。黑色的目标是阻止令牌到达 t。自然以随机方式移动令牌。给定一个有向图、一个起始状态 s 和一个目标状态 t,白色是否有一种策略使得令牌到达 t 的概率 ≥ 1/2?该问题属于 NP 交 co-NP,但尚不清楚是否属于 P。人们相信它属于 P,只是我们还没有找到一个多项式时间算法。
其他复杂度类。
复杂度类 P、NP 和 NP-完全是三个最著名的复杂度类。Scott Aaronson 的网站The Complexity Zoo包含了其他复杂度类的全面列表,这些类对问题根据其计算资源(时间、空间、可并行性、随机性使用、量子计算)进行分类。我们在下面描述了一些最重要的类。
PSPACE. 复杂度类 PSPACE = 可以使用多项式空间的图灵机解决的问题。PSPACE-完全 = 在 PSPACE 中,且可以在多项式时间内将所有其他问题归约为它。
这里是停机问题的一个复杂版本。给定一个被限制在 n 个磁带单元上的图灵机,在最多 k 步内是否会停机?该问题是 PSPACE-完全的,其中 n 以一元编码。这意味着除非 P = PSPACE,否则我们不太可能能够判断一个给定程序在具有 n 个内存单元的计算机上运行是否会在 k 步之前终止,比简单地运行它 k 步并观察发生的情况要快得多。
Bodlaender:给定一个具有顶点 1, ..., N 的图,两名玩家轮流标记顶点为红色、绿色或蓝色。第一个标记一个与其邻居相同颜色的顶点的玩家失败。确定第一个玩家是否有获胜策略是 PSPACE-完全的。
许多传统游戏的变体被证明是棘手的;这在一定程度上解释了它们的吸引力。此外,黑白棋、六角、地理、上海、赛车、五子棋、瞬间疯狂和推箱子的自然推广都是 PSPACE-完全的。
一个给定的字符串是否是上下文敏感文法的成员?
两个正则表达式是否描述不同的语言?即使在二进制字母表上也是 PSPACE-完全的,如果其中一个正则表达式是
.*。另一个可以严格化的例子是移动一个复杂对象(例如家具),其附件可以通过不规则形状的走廊移动和旋转。
另一个例子出现在并行计算中,当挑战是确定在一个通信处理器系统中是否可能存在死锁状态时。
注意 PSPACE = NPSPACE(Savitch 定理)。
EXPTIME. 复杂度类 EXPTIME = 所有在确定性图灵机上以指数时间可解决的决策问题。注意 P ⊆ NP ⊆ PSPACE ⊆ EXPTIME,并且根据时间层次定理,至少有一个包含是严格的,但不知道是哪一个(或多个)。有人推测所有包含都是严格的。
Harel 的 Roadblock 第 85 页。
国际象棋、跳棋、围棋(采用日本式劫争终结规则)和将棋的自然泛化都是 EXPTIME 完全问题。给定一个棋盘局面,第一位玩家能否强迫获胜?这里 N 是棋盘大小,运行时间与 N 呈指数关系。这些问题比奥赛罗(和其他 PSPACE 完全游戏)在理论上更难的一个原因是它们可能需要指数次数的移动。跳棋(在 N×N 棋盘上的英式跳棋):玩家在某一回合可以有指数次数的移动,因为可以进行跳跃序列。[pdf] 注意:根据终结规则的不同,跳棋可能是 PSPACE 完全或 EXPTIME 完全。对于 EXPTIME 完全,我们假设“强制捕获规则”,即如果有可用的跳跃(或跳跃序列),玩家必须进行跳跃。
这是停机问题的一个复杂性版本。给定一个图灵机,在最多 k 步内是否会停机?或者,给定一个固定的 Java 程序和一个固定的输入,在最多 k 步内是否会终止?这个问题是 EXPTIME 完全的。这里的运行时间与 k 的二进制表示成指数关系。事实上,没有图灵机可以保证在 O(k / log k)步内解决它。因此,暴力模拟基本上是最佳的方法:可以证明,这个问题不能比运行图灵机的前 k 步并观察发生了什么更快地解决。
一个 EXPTIME 完全问题在确定性图灵机上不能在多项式时间内解决 - 这与 P ≠ NP 猜想无关。
EXPSPACE. EXPSPACE 完全问题:给定两个“扩展”正则表达式,它们是否表示不同的语言?通过扩展,我们允许一个平方操作(表达式的两个副本)。Stockmeyer 和 Meyer(1973)。或者更简单的集合交集(Hunt,1973)。阿贝尔群的字问题(Cardoza,Lipton,Meyer,1976),向量加法子系统。
向量加法子系统是 EXPSAPCE 难题:给定一个非负向量 s 和一组任意向量 v1、v2、...、vn,如果向量 x 是从 s 可达的,则它要么是(i)向量 s,要么是可达的向量 y + vi,其中 y 是可达的。VAS 问题是确定给定向量 x 是否可达。
DOUBLE-EXPTIME. DOUBLE-EXPTIME 类是在双指数时间内可解决的所有决策问题。一个显著的例子是确定first order Presburger arithmetic中的一个公式是否为真。Presburger 算术包括涉及只有+作为操作的整数的语句(没有乘法或除法)。它可以模拟以下语句:如果 x 和 y 是整数,使得 x ≤ y + 2,则 y + 3 > x。1929 年,Presburger 证明了他的系统是一致的(不能证明矛盾,比如 1 > 2)和完备的(每个语句都可以被证明为真或假)。1974 年,Fischer 和 Rabin 证明了任何决定 Presburger 公式真实性的算法都需要至少 2^((2^(cN)))时间,其中 c 是一个常数,N 是公式的长度。
非初等. 对于任何有限的塔,超过 2²²^...²^N。给定允许平方和补集的两个正则表达式,它们是否描述不同的语言?
其他类型的计算问题。
我们专注于搜索问题,因为这是科学家和工程师非常丰富和重要的问题类别。
搜索问题. 这是我们详细考虑的版本。技术上,FP = 多项式时间函数问题,FNP = 非确定性图灵机上的多项式时间函数问题。FP 问题可以有任何可以在多项式时间内计算的输出(例如,两个数字相乘或找到 Ax = b 的解)。
决策问题。 传统上,复杂性理论是以是/否问题来定义的,例如,Ax &le b 是否存在解?规约的定义更清晰(无需处理输出)。P 类和 NP 类传统上是以决策问题来定义的。通常搜索问题归约为决策问题(对于所有 NP 完全问题都已知为真)。这样的搜索问题被称为自可归约。P = NP 问题等价于 FP = FNP 问题。
全函数。 有时,一个决策问题很容易,而相应的搜索问题(被认为)很难。例如,可能有一个定理断言解肯定存在,但该定理并未提供如何高效找到解的任何提示。
子集和示例。给定 N 个数字,找出这些 N 个数字的两个(不相交)子集,使它们的和恰好相等。如果 N = 77,且所有数字最多为二十一位十进制数,则根据鸽巢原理,至少有两个子集的和相等。这是因为有 2⁷⁷ 个子集,但最多有 1 + 77 * 10²¹ < 2⁷⁷ 种可能的和。或者决策 = 复合,搜索 = 因子。
约翰·纳什证明了在具有指定效用的两个或更多玩家的正常形式游戏中,纳什均衡总是存在。证明是非构造性的,因此不清楚如何找到这样的均衡。被证明是PPAD-完全 - 已知具有解的问题的 NP-完全问题的类比。
广义均衡理论是微观经济学的基础。给定一个有 k 种商品的经济体,每个 N 个代理人都有这些商品的初始禀赋。每个代理人还为每种商品有一个效用函数。阿罗-德布鲁定理断言,在适当的技术条件下(例如,效用函数连续、单调且严格凹),存在一组(唯一的)市场价格,使得每个代理人都卖掉所有商品,并用这笔钱购买最佳组合(即,每种商品的供求平衡)。但市场是如何计算的呢?证明依赖于拓扑学中的一个深刻定理(卡库塔尼不动点定理),目前尚不知道任何有效的算法。经济学家假设市场找到了均衡价格;亚当·斯密用看不见的手的比喻来描述这种社会机制。
15-滑块拼图的推广。测试解是否存在在 P 中,但找到最短解是棘手的。[Ratner-Warmuth, 1990]
优化问题。 有时我们有优化问题,例如,TSP。给定一个 NP 问题和一个解的成本函数,对于给定的实例,目标是找到其最佳解(例如找到最短的 TSP 旅行路线,最小能量配置等)。有时很难表述为搜索问题(找到最短的 TSP 旅行路线),因为不清楚如何有效地检查是否有最优路线。相反,我们重新表述为:给定长度 L,找到长度最多为 L 的旅行路线。然后二分搜索最优 L。
计数问题。 给定一个 NP 问题,找出其解的数量。例如,给定一个 CNF 公式,它有多少满足的赋值?包括统计物理和组合数学中的许多问题。从形式上讲,这类问题被称为#P。
战略问题。 给定一个游戏,为玩家找到一个最佳策略(或最佳移动)。包括经济学和棋盘游戏中的许多问题(例如,国际象棋,围棋)。
输出多项式时间。
有些问题涉及的输出比单个位的信息更多。例如,输出汉诺塔问题的解至���需要 2N 步。这个要求不是因为解本质上难以计算,而是因为有 2N 个输出符号,并且每个输出符号需要一个单位的时间来写入。也许更自然的衡量效率的方法是输入大小和输出大小的函数。一个具有 DFAs 的经典电气工程问题是从 RE 构建一个使用最少状态的 DFA。我们希望的算法在输入 RE 的大小(符号数量)和输出 DFA 的大小(状态数量)上都是多项式的。除非 P = NP,否则设计这样的算法是不可能的。事实上,甚至不可能设计一个在常数(甚至多项式)数量的状态内得出答案的多项式算法!没有 NP 完全性理论,研究人员将浪费时间追随没有前途的研究方向。
其他下界。
信息论的。 在第 X.Y 节中,我们看到插入最多使用 N² 次比较来对 N 个项目进行排序,而归并排序最多使用 N log N 次比较。一个自然的问题是我们是否可以做得更好,也许最多使用 5N 次比较,甚至 1/2 N log N 次比较。为了使问题更加明确,我们必须明确陈述我们的计算模型(决策树)。在这里,我们假设我们只通过
less()函数访问数据。由 X 提出的一个引人注目的定理表明,没有(基于比较的)排序算法可以保证在少于 ~ N log N 次比较中对 N 个不同元素的每个输入进行排序。要理解原因,观察到每次比较(调用less)提供一位信息。为了识别正确的排列,您需要 log N! 位信息,而 log N! ~ N log N。这告诉我们,归并排序是(渐近地)最佳的排序算法。不存在任何排序算法(甚至是尚未想象的算法)会使用更少的比较。3-Sum 困难。 给定一组 N 个整数,其中任意三个数相加是否等于 0?存在二次算法(参见练习 xyz),但没有已知的次二次算法。3-SUM 线性归约到计算几何中的许多问题。(查找平面上的点集是否有 3 个共线,决定平面上的线段集是否可以被一条线分成两个子集,确定一组三角形是否覆盖单位正方形,您是否可以将多边形 P 移动到完全位于另一个多边形 Q 内部,机器人运动规划)。
蛮力 TSP 需要 N! 步。使用动态规划,可以将其减少到 2^N。最佳下界 = N。计算复杂性的本质 = 尝试找到匹配的上界和下界。
电路复杂性。
还有其他定义和衡量计算复杂性的方法。具有 n 个输入的布尔电路可以计算 n 个变量的任何布尔函数。我们可以将电路输出 1 的大小为 n 的二进制字符串集合与语言中的字符串集合相关联。我们需要一个用于每个输入大小 n 的电路。Shannon(1949)提出了电路大小作为复杂性的度量。已知,如果语言在 P 中,则语言具有均匀多项式电路。
物理和模拟计算。
P = NP 问题是关于图灵机和经典数字计算机能力的数学问题。我们也可以思考模拟计算机是否也是如此。模拟 意味着任何“确定性物理设备,使用固定数量的物理变量来表示每个问题变量。” 内部状态由连续变量而非离散变量表示。例如,肥皂泡沫,蛋白质折叠,量子计算,齿轮,时间旅行,黑洞等。
Vergis, Steiglitz, and Dickinson 提出了强克尔图灵论文的模拟形式:
任何有限的模拟计算机都可以被数字计算机高效模拟,即数字计算机模拟模拟计算机所需的时间受限于模拟计算机使用的资源的多项式函数。
模拟计算机的资源可以是时间、体积、质量、能量、扭矩或角动量。参考:模拟计算的物理学
任何合理的计算模型(例如,不涉及指数并行性)都可以通过图灵机(辅以硬件随机数生成器)在多项式时间内模拟。
参考:斯科特·阿伦森。可以为物理学提供新的见解。有一天,“NP 完全问题的被假定为难以解决可能被视为寻找新物理理论的有用约束”,就像热力学第二定律一样。仍然可以通过实验证伪,但不要浪费时间...
肥皂泡。 传说你可以解决斯坦纳树问题。实际上,只能找到一个局部最小值,并且可能需要一段时间才能找到。
量子计算。 一种推测性的计算模型 - 量子计算机 - 可能能够在确定性图灵机无法做到的多项式时间内解决一些问题。彼得·肖尔发现了一个用于分解 N 位整数的 N³ 算法,但在经典计算机上已知的最佳算法需要指数时间。同样的想法可能导致在模拟量子力学系统时获得可比较的加速。这解释了量子计算引起的最近激动,因为它可能导致计算的范式转变。然而,量子计算机尚未违反扩展的丘奇-图灵论题,因为我们尚不知道如何构建它们。(难以利用,因为许多量子信息似乎很容易被与外界的相互作用所破坏,即退相干。)此外,仍然有可能有人在经典计算机上发现一个多项式时间算法来分解,尽管大多数专家认为这是不可能的。格罗弗的算法:在 sqrt(N)时间内搜索而不是 N。
理查德·费曼在 1982 年表明,经典计算机无法模拟量子力学系统而不会指数级减速(争论的关键在于图灵机具有局部性,而量子力学包括“利用远距作用”)。量子计算机可能能够解决这个问题。费曼关于建造一个模拟物理的计算机的引用...
“我想要的模拟规则是,用于模拟大型物理系统所需的计算机元素数量仅与物理系统的时空体积成正比。我不想出现爆炸。”
用“受限于”替换“与...成正比”来重新表述现代复杂性理论。
德乔萨提出的算法在量子计算机上的运行速度被证明比确定性图灵机快得多。(尽管如果图灵机可以访问硬件随机数生成器并且可以以可忽略的概率出错,指数差距就不存在。量子计算机可以生成真正的随机性。)
素数和合数。
通过提供一个因子很容易说服某人一个数字是合数。然后,这个人只需通过长除法检查你是否对他们撒谎。马林·梅森猜想形如 2^p - 1 的数字对于 p = 2, 3, 5, 7, 13, 17, 19, 31, 67, 127 和 257 是素数。他对 p = 67 的猜想在两百五十多年后的 1903 年被 F·N·科尔推翻。根据 E·T·贝尔的书籍数学:科学的女王和仆人
在 AMS 的十月会议上,Cole 宣布了一个关于“大数的因式分解”的讲座。他默不作声地走到黑板前,手算出了 2⁶⁷ 的值,仔细地减去 1。然后他将两个数相乘(分别是 193707721 和 761838257287)。黑板上写下的两个结果是相等的。Cole 默默地走回座位,据说这是 AMS 会议中唯一一次观众鼓掌的讲座。没有问题。根据他所说,Cole 花了大约 3 年的每个星期日来找到这个因式分解。
记录上 2⁶⁷ - 1 = 193707721 × 761838257287 = 147573952589676412927。
Q + A
Q. 多项式算法总是有用吗?
A. 不,需要 N¹⁰⁰ 或 10¹⁰⁰ N² 步的算法在实践中与指数算法一样无用。实践中产生的常数通常足够小,使得多项式时间算法适用于巨大问题,因此多项式性通常作为实践中有用的替代品。
Q. 为什么所有搜索问题的类别被命名为 NP?
A. NP 的最初定义是基于非确定性图灵机的:NP 是所有可以在非确定性图灵机上多项式时间内解决的决策问题的集合。粗略地说,确定性和非确定性图灵机之间的区别在于前者像传统计算机一样运行,按顺序执行每个指令,形成一个计算路径;非确定性图灵机可以“分支”,其中每个分支可以并行执行不同的语句,形成一个计算树(如果树中的任何路径导致 YES,则我们接受;如果所有路径导致 NO,则我们拒绝。)这就是 NP 中的 N 来源。事实证明这两个定义是等价的,但现在更广泛使用证书的定义。(此外,Karp 的 1972 年论文使用了多项式时间可验证性的定义。)
Q. 复杂度类 NP-难是什么?
A. 几个竞争性定义。我们定义一个问题(决策、搜索或优化)问题为 NP-难,如果在多项式时间内解决它将意味着 P = NP。定义隐含地使用图灵归约(扩展到搜索问题)。
Q. 在多项式时间内对整数 N 因式分解有什么困难之处 - 我不能只将小于 N(或 √N)的所有潜在因子分成 x 并查看是否有余数为零吗?
A. 算法是正确的,但请记住只需 lg N 位来表示整数 N。因此,为了使算法在输入大小上是多项式的,它必须在 lg N 中是多项式的,而不是 N。
Q. 检查一个整数是否为合数可以在多项式时间内解决,但找到它的因子却未知(或被认为)不可解吗?
A. 有方法证明一个数是合数而不需要得到它的任何因子。数论中的一个著名定理(费马小定理)暗示,如果你有两个整数 a 和 p,使得(i)a 不是 p 的倍数且(ii)a^(p-1) != 1 (mod p),那么 p 不是质数。
Q. 是否存在一个在量子计算机上多项式可解的决策问题,但可以证明不在 P 中?
A. 这是一个未解决的研究问题。FACTOR 是一个候选项,但没有证据表明 FACTOR 不在 P 中,尽管普遍认为它不在 P 中。
Q. NP = EXPTIME 吗?
A. 专家们认为不是,但他们无法证明。
Q. 假设有人证明 P = NP。这将有什么实际后果?
A. 这取决于问题是如何解决的。显然,如果证明 P = NP,那将是一个显著的理论突破。在实践中,如果 P = NP 的证明为一个重要的 NP 完全问题建立了一个快速算法,那可能具有重大意义。如果证明导致旅行商问题的一个 2¹⁰⁰ N¹¹⁷ 的算法(且常数和指数无法减少),那将没有太大的实际影响。也可能有人通过间接手段证明 P = NP,从而根本没有算法!
Q. 假设有人证明 P != NP。这将会有什么实际后果?
A. 这将是一个显著的理论突破,并巩固了计算复杂性的许多基础。
Q. 假设 P = NP。这是否意味着确定性图灵机与非确定性图灵机相同?
A. 不完全是这样。例如,即使 P = NP,非确定性图灵机可能能够在与最佳确定性图灵机相比为 N³ 的时间内解决问题。如果 P = NP,这只是意味着这两种类型的机器在多项式时间内解决相同的决策问题,但它并不说明多项式的次数。
Q. 我在哪里可以了解更多关于 NP 完全性的知识?
A. 权威参考仍然是 Garey 和 Johnson 的《计算机与难解性:NP 完全性理论指南》。许多最重要的后续发现都记录在 David Johnson 的NP 完全性专栏中。
练习
假设 X 是 NP 完全的,X 多项式时间归约到 Y,Y 多项式时间归约到 X。那么 Y 是否一定是 NP 完全的?
答案:不是,因为 Y 可能不在 NP 中。例如,如果 X = CIRCUIT-SAT,Y = CO-CIRCUIT-SAT,那么 X 和 Y 满足条件,但未知 Y 是否在 NP 中。请注意,答案取决于我们对多项式时间归约的定义(应为图灵归约而不是 Karp 归约)。
解释为什么顶点覆盖问题的优化版本不一定是一个搜索问题。
答案:目前似乎没有有效的方法来证明一个所谓的解决方案是最佳的(即使我们可以在问题的搜索版本上使用二分搜索来找到最佳解决方案)。
网络练习
子集和。 给定 N 个正整数和一个目标值 V,确定是否存在一个子集,其和恰好为 V。将整数分成 4 个相等的组。通过蛮力法列举和存储每组中的所有子集和。让 A、B、C 和 D 分别表示四个组的子集和。目标是找到整数 a、b、c 和 d,使得 a + b + c + d = V,其中 a 在 A 中,b 在 B 中,c 在 C 中,d 在 D 中。现在,使用一个堆来列举 a 在 A 中,b 在 B 中的和。同时,使用另一个堆以递减顺序列举 c 在 C 中,d 在 D 中的和。
平方根之和。 两个整数平方根之和之间的最小非零差是多少?给定 n 和 k,找到
| √a1 + √a2 + ... + √ak - √b1 - √b2 - ... - √bk |其中 ai 和 bi 在 0 和 n 之间。例如 r(20, 2) = √10 + √11 - √5 - √18 和 r(20, 3) = √5 + √6 + √18 - √4 - √12 - √12。提示:列举前 n/2 个整数的平方根之和的 2^(n/2) 种可能,并将该集合命名为 A,列举后 n/2 个整数的平方根之和的 2^(n/2) 种可能,并将其命名为 B。现在按排序顺序列举 a 在 A 中,b 在 B 中的和,其中 a 在 A 中,b 在 B 中。寻找差异非常微小的和。
划分钻石。 给定 N(大约 36)类 D 钻石,将它们分成两组,使它们的总重量尽可能接近。假设重量是实数(以克拉为单位)。
DAG 中的哈密顿路径。 给定一个有向无环图 G,给出一个 O(n+m) 时间复杂度的算法来测试它是否是哈密顿图。提示:拓扑排序。
如果假设 P 不等于 NP,从旅行推销员问题是 NP 完全的这一事实中我们可以推断出以下哪些?
不存在一个能解决 TSP 问题的任意实例的算法。
不存在一个能高效解决 TSP 任意实例的算法。
存在一个高效解决任意 TSP 实例的算法,但没有人能找到它。
TSP 不在 P 中。
所有保证解决 TSP 的算法对于某些输入点族都在多项式时间内运行。
所有保证解决 TSP 的算法对于所有输入点族都运行在指数时间内。
答案:(b) 和 (d)。
如果假设 P 不等于 NP,从 PRIMALITY 在 NP 中但不知道是否 NP 完全这一事实中我们可以推断出以下哪些?
存在一个能解决任意 PRIMALITY 实例的算法。
存在一个能高效解决任意 PRIMALITY 实例的算法。
如果我们找到 PRIMALITY 的一个高效算法,我们可以立即将其用作黑盒来解决 TSP。
答案:我们只能推断 (a),因为所有 P 中的问题都是可判定的。如果 P != NP,那么有些 NP 中的问题既不在 P 中也不是 NP 完全的。PRIMALITY 可能是其中之一(尽管最近已被证明不是)。部分 (c) 不能被推断,因为我们不知道 PRIMALITY 是否是 NP 完全的。
以下哪些是 NP 完全的?
蛮力 TSP 算法。
用于排序的快速排序算法。
停机问题。
希尔伯特的第十个问题。
答案:无。NP 完全性涉及问题而不是问题的具体算法。停机问题和希尔伯特的第十个问题是不可判定的,因此它们不在 NP 中(所有 NP 完全问题都在 NP 中)。
假设 X 和 Y 是两个决策问题。假设我们知道 X 可归约到 Y。我们可以推断以下哪些?
如果 Y 是 NP 完全的,则 X 也是。
如果 X 是 NP 完全的,那么 Y 也是。
如果 Y 是 NP 完全的且 X 在 NP 中,则 X 是 NP 完全的。
如果 X 是 NP 完全的且 Y 在 NP 中,则 Y 是 NP 完全的。
X 和 Y 不能同时是 NP 完全的。
如果 X 在 P 中,那么 Y 也在 P 中。
如果 Y 在 P 中,那么 X 也在 P 中。
答案:(d) 和 (g)。X 可归约到 Y 意味着如果你有一个能高效解决 Y 的黑盒,你可以用它来高效解决 X。X 不比 Y 更难。
证明 CIRCUIT-SAT 可归约到 CIRCUIT-DIFF。提示:创建一个具有 N 个输入的电路,总是输出 0。
证明 CIRCUIT-DIFF 可归约到 CIRCUIT-SAT。
证明 DETERMINANT 在 NP 中:给定一个 N×N 的整数矩阵 A,det(A) = 0 吗?
解法:证书是一个非零向量 x,使得 Ax = 0。
证明 FULL-RANK 在 NP 中:给定一个 N×N 的整数矩阵 A,det(A) ≠ 0 吗?
解法:证书是一个 N×N 的逆矩阵 B,使得 AB = I。
搜索问题 vs. 决策问题。 我们可以使用相应的决策问题来制定一个搜索问题。例如,找到整数 N 的素因子分解问题可以使用决策问题来制定:给定两个整数 N 和 L,N 是否有一个严格小于 L 的非平凡因子。如果相应的决策问题可在多项式时间内解决,那么搜索问题也可以。为了理解原因,我们可以通过使用不同的 L 值和二分查找来高效地找到 N 的最小因子 p。一旦我们有了因子 p,我们可以对 N/p 重复这个过程。
通常我们可以证明搜索问题和决策问题在运行时间上等价于多项式因子。Papadimitriou(示例 10.8)给出了一个有趣的反例。给定 N 个正整数,它们的和小于 2^N - 1,找到两个和相等的子集。例如,下面的 10 个数字的和为 1014 < 1023。
23 47 59 88 91 100 111 133 157 205
由于 N 个整数的子集(2^N)比 1 到 1014 之间的数字更多,必然存在两个不同的子集具有相同的和。但没有人知道一个多项式时间算法来 找到 这样的子集。另一方面,自然的决策问题在常数时间内是易解的:是否存在两个和相同的数字子集?
普拉特素性证书。 证明 PRIMES 属于 NP。使用莱默定理(费马小定理的逆定理),它断言大于 1 的整数 p 是素数当且仅当存在一个整数 x,使得 x^(N-1) = 1 (mod p) 且 x^((p-1)/d) ≠ 1 (mod p) 对于 p-1 的所有素数因子 d 都成立。例如,如果 N = 7919,那么 p-1 的素因子分解为 7918 = 2 × 37 × 107。现在 x = 7 满足 7⁷⁹¹⁸ = 1 (mod 7919),但 7^(7918/2) ≠ 1 (mod 7919),7^(7918/37) ≠ 1 (mod 7919),7^(7918/107) ≠ 1 (mod 7919)。这证明了 7919 是素数(假设你递归地证明了 2、37 和 107 是素数)。
佩尔方程。 找到佩尔方程 x² - 92y² = 1 的所有正整数解。解答:(1151, 120), (2649601, 276240),等等。有无穷多解,但每个连续的���大约是前一个解的 2300 倍。
佩尔方程。 在 1657 年,皮埃尔·费马向他的同事们提出了以下问题:给定一个正整数 c,找到一个正整数 y,使得 cy² 是一个完全平方数。费马使用了 c = 109。结果表明最小解为 (x, y) = (158,070,671,986,249, 15,140,424,455,100)。编写一个程序 Pell.java,读入一个整数 c,并找到佩尔方程的最小解:x² - c y² = 1。尝试 c = 61。最小解为 (1,766,319,049, 226,153,980)。对于 c = 313,最小解为 ( 3,218,812,082,913,484,91,819,380,158,564,160)。该问题在多项式步数内是无法解决的(作为输入 c 位数的函数),因为输出可能需要指数级的位数!
3-COLOR 归约到 4-COLOR。 证明 3-COLOR 多项式归约到 4-COLOR。提示:给定一个 3-COLOR 实例 G,通过向 G 添加一个特殊顶点 x 并将其连接到 G 中的所有顶点,创建一个 4-COLOR 实例 G'。
3-SAT 是自可归约的。 证明 3-SAT 是自可归约的。也就是说,给定一个回答任意 3-SAT 公式是否可满足的预言机,设计一个算法可以找到一个满足条件的 3-SAT 公式(假设它是可满足的)。你的算法应在多项式时间内运行,再加上多项式次调用预言机。
3-COLOR 是自可归约的。 证明 3-COLOR 是自可归约的。也就是说,给定一个回答任意图 G 是否可 3-染色的预言机,设计一个算法可以对图进行 3-染色(假设它是可 3-染色的)。你的算法应在多项式时间内运行,再加上多项式次调用预言机。
算法分析
1. 算法分析
原文:
aofa.cs.princeton.edu/10analysis译者:飞龙
本章考虑了算法分析的一般动机以及研究算法性能特征的各种方法之间的关系。
1.1 为什么要分析算法?
分析算法的最直接原因是为了发现其特性,以便评估其适用性于各种应用程序或将其与同一应用程序的其他算法进行比较。此外,算法分析可以帮助我们更好地理解它,并提出明智的改进意见。在分析过程中,算法往往会变得更短、更简单和更优雅。
1.2 计算复杂性。
理论计算机科学的一个分支,其目标是根据效率对算法进行分类,根据固有难度对计算问题进行分类,被称为计算复杂性。矛盾的是,这种分类通常不适用于预测性能或比较实际应用中的算法,因为它们侧重于增长阶的最坏情况性能。在本书中,我们专注于可以用于预测性能和比较算法的分析。
1.3 算法分析。
对算法运行时间的完整分析涉及以下步骤:
完全实现算法。
确定每个基本操作所需的时间。
确定可以用来描述基本操作执行频率的未知量。
为程序的输入开发一个现实模型。
分析未知量,假设模拟的输入。
通过将每个操作的时间乘以频率计算总运行时间,然后将所有乘积相加。
早期计算机上的经典算法分析可以得出运行时间的精确预测。现代系统和算法要复杂得多,但现代分析受到这样一种思想的启发,即原则上可以执行这种精确分析。
1.4 平均情况分析。
初等概率论提供了多种计算数量平均值的方法。虽然它们之间密切相关,但对于我们来说,明确地确定两种不同的计算平均值的方法会更方便。
分布式。 让 \(\Pi_N\) 表示大小为 \(N\) 的可能输入数量,\(\Pi_{Nk}\) 表示导致算法成本为 \(k\) 的大小为 \(N\) 的输入数量,因此 \(\Pi_N=\sum_k\Pi_{Nk}\)。那么成本为 \(k\) 的概率是 \(\Pi_{Nk}/\Pi_N\),期望成本为 \({1\over \Pi_N}\sum_k k\Pi_{Nk}.\) 分析取决于"计数"。大小为 \(N\) 的输入有多少个,大小为 \(N\) 的输入导致算法成本为 \(k\) 的有多少个?这些是计算成本为 \(k\) 的概率的步骤,因此这种方法可能是从初等概率论中最直接的方法。
累积。 让 \(\Sigma_N\) 表示算法在所有大小为 \(N\) 的输入上的总(或累积)成本。(也就是说,\(\Sigma_N=\sum_kk\Pi_{Nk}\),但重点是不必以那种方式计算 \(\Sigma_N\)。)那么平均成本就是简单的 \(\Sigma_N/\Pi_N\)。分析取决于一个不太具体的计数问题:所有输入的算法总成本是多少?我们将使用使这种方法非常吸引人的通用工具。
分布式方法提供完整信息,可直接用于计算标准差和其他时刻。当使用另一种方法时,也可以使用间接(通常更简单)的方法来计算时刻,我们将看到。在本书中,我们考虑这两种方法,尽管我们的倾向将是累积方法,最终使我们能够从基本数据结构的组合性质角度考虑算法分析。
1.5 示例:快速排序的分析。
经典的快速排序算法是由 C.A.R. Hoare 于 1962 年发明的:
public class Quick
{
private static int partition(Comparable[] a, int lo, int hi)
{
int i = lo, j = hi+1;
while (true)
{
while (less(a[++i], a[lo])) if (i == hi) break;
while (less(a[lo], a[--j])) if (j == lo) break;
if (i >= j) break;
exch(a, i, j);
}
exch(a, lo, j);
return j;
}
private static void sort(Comparable[] a, int lo, int hi)
{
if (hi <= lo) return;
int j = partition(a, lo, hi);
sort(a, lo, j-1);
sort(a, j+1, hi);
}
}
要分析这个算法,我们首先定义一个成本模型(运行时间)和一个输入模型(随机排序的不同元素)。为了将分析与实现分开,我们定义$C_N$为对$N$个元素进行排序所需的比较次数,并分析$C_N$(假设任何实现的运行时间都将是$\sim aC_N$,其中$a$是依赖于实现的常数)。注意算法的以下特性:
用于分区的比较次数为$N+1$。
分区元素是第$k$小的概率是$1/N$,其中$k$介于$0$和$N-1$之间。
在这种情况下,要排序的两个子数组的大小分别为$k$和$N-k-1$。
分区后两个子数组是随机排序的。
这些暗示了一个数学表达式(一个递归关系),直接源自递归程序$$C_N = N+1 + \sum_{0\le k \le N-1}{1\over N}(C_k + C_)\(。这个方程可以通过一系列简单但神秘的代数步骤轻松解决。首先,应用对称性,乘以$N$,减去$N-1$的相同方程,并重新排列项以获得一个更简单的递归。\)\eqalign{ C_N &= N+1 + {2\over N}\sum_{0\le k \le N-1}C_k\ NC_N &= N(N+1) + 2\sum_{0\le k \le N-1}C_k\ NC_N - (N-1)C_ &= N(N+1) -(N-1)N + 2C_\ NC_N &= (N+1)C_ +2N\ }$$ 注意,这个更简单的递归给出了一个计算确切答案的高效算法。要解决它,将两边除以$N(N+1)\(并进行折叠。$\)\eqalign{ NC_N &= (N+1)C_ + 2N {\quad\rm for\quad} N > 1 {\quad\rm with\quad} C_1 = 2\ {C_N\over N+1} &= {C_\over N} + {2\over N+1}\ &= {C_\over N-1} + {2\over N} + {2\over N+1}\ &= 2H_{N+1} - 2\ C_N &= 2(N+1)H_{N+1} - 2(N+1) = 2(N+1)H_N - 2N. }$$ 结果是一个关于调和数的精确表达式。
1.6 渐近近似
调和数可以通过积分(见第三章)近似为$$H_N \sim \ln N$$,从而得到简单的渐近近似$$C_N \sim 2N\ln N$$。验证我们的数学模型总是一个好主意。这段代码
public class QuickCheck
{
public static void main(String[] args)
{
int maxN = Integer.parseInt(args[0]);
double[] c = new double[maxN+1];
c[0] = 0;
for (int N = 1; N <= maxN; N++)
c[N] = (N+1)*c[N-1]/N + 2;
for (int N = 10; N <= maxN; N *= 10)
{
double approx = 2*N*Math.log(N) - 2*N;
StdOut.printf("%10d %15.2f %15.2f\n", N, c[N], approx);
}
}
}
产生这个输出。
% java QuickCheck 1000000
10 44.44 26.05
100 847.85 721.03
1000 12985.91 11815.51
10000 175771.70 164206.81
100000 2218053.41 2102585.09
表中的差异是由于我们去掉了$2N$项(以及我们没有使用更准确的积分近似)所解释的。
1.7 分布。
可以使用类似的方法找到标准差和其他时刻。快速排序使用的比较次数的标准差为$\sqrt{7 - 2\pi²/3}N \approx .6482776 N$,这意味着比较的预期次数与大$N$时的平均值不太可能相差太远。比较次数是否服从正态分布?不。表征这种分布是一个困难的研究挑战。
1.8 概率算法。
我们的假设是输入数组是随机排序的一个有效的输入模型吗?是的,因为我们可以在排序之前随机排序数组。这样做将快速排序转变为一个随机算法,其良好性能由概率法则保证。
验证我们的模型和分析总是一个好主意。在过去几十年里,许多人在许多计算机上进行了详细的实验,验证了快速排序的性能。
在某些应用程序中,对于数组项不需要是不同的这一模型的一个缺陷是。对于这种情况,可以使用三向分区实现更快的实现。
选定的练习
1.14
按照上述步骤解决递归$$A_N=1+{2 \over N} \sum_{1\le j\le N} A_ {\quad\rm for\quad} N>0$$,其中$A_0=0$。这是快速排序在hi≥lo时被调用的次数。
1.15
显示在指针交叉之前第一个分区阶段使用的平均交换次数为$(N-2)/6$。 (因此,通过递归的线性性质,快速排序使用的平均交换次数为${1\over6}C_N-{1\over2}A_N$。)
1.16
当使用快速排序对大小为$N$的随机数组进行排序时,平均会遇到多少大小为$k>0$的子数组?
1.17
如果我们将上面快速排序实现中的第一行更改为当hi-lo <= M时调用插入排序,那么对于排序$N$个元素的总比较次数由递归描述为$$ C_N=\beginN+1+\displaystyle{1\over N} \sum_{1\le j\le N} (C_+C_)&N>M;\ {1\over4}N(N-1)&N\le M\ \end$$ 解决这个递归。
1.18
忽略上一个练习的答案中的小项(明显小于$N$的项),找到一个函数$f(M)$,使得比较次数大约为$$2N\ln N+f(M)N.$$ 绘制函数$f(M)$的图形,并找到最小化函数的$M$的值。
复习问题
Q1.1
给定递归$F_N=t_N+{2 \over N} \sum_{1\le j\le N} F_ {\ \rm for\ } N>0$,其中$F_0=0$,对于以下选择的“通行费函数”\(t_N\),给出$F_N$的增长顺序(常数、线性、线性对数、二次或三次):(a) 0 (b) 1 (c) \(N\) (d) \(2N+1\) (e) \(N²\)
Q1.2
在一个特定的(虚构的)云计算排序应用中,大小小于 100 万的文件的排序成本可以忽略不计。否则,比较成本是这样的,预算只能覆盖$10^{12}$次比较。在以下选项中,使用标准快速排序算法,对于大小小于 100 万的文件,可以在预算内排序的最大随机顺序键文件是哪一个:\(10⁹\), \(10^{10}\), \(10^{11}\), \(10^{12}\), \(10^{13}\), 或 \(10^{14}\)?
Q1.3
让$B_$表示使用快速排序对$N$个不同元素的随机排序文件进行排序时遇到的大小不超过$k$的平均子文件数,其中对于$0\le N\le k$,\(B_{Nk}=N\)。对于$N>k$,$B_$的值是多少?
2. 递归关系
原文:
aofa.cs.princeton.edu/20recurrence译者:飞龙
本章重点讨论各种类型的递归关系的基本数学性质,这些关系在通过将程序的递归表示直接映射到描述其属性的函数的递归表示时经常出现。
2.1 基本性质。
递归按照项的组合方式、涉及的系数性质以及使用的先前项的数量和性质进行分类。
| 递归类型 | 典型示例 |
|---|---|
| 一阶 | |
| 线性 | \(a_n=na_{n-1}-1\) |
| 非线性 | \(a_n=1/(1+a_{n-1})\) |
| 二阶 | |
| 线性 | \(a_n=a_{n-1}+2a_{n-2}\) |
| 非线性 | \(a_n=a_{n-1}a_{n-2}+\sqrt{a_{n-2}}\) |
| 变量系数 | \(a_n=na_{n-1}+(n-1)a_{n-2}+1\) |
| 第$t$阶 | \(a_n=f(a_{n-1},a_{n-2},\ldots,a_{n-t})\) |
| 完全历史 | \(a_n=n+a_{n-1}+a_{n-2}\ldots + a_1\) |
| 分治法 | \(a_n=a_{\lfloor{n/2}\rfloor}+a_{\lceil{n/2}\rceil}+n\) |
计算数值。 通常,递归提供了计算所需数量的有效方法。特别是,攻击任何递归的第一步是使用它来计算小值,以了解它们的增长方式。对于小值,可以手动完成,或者通常可以轻松实现一个程序来计算更大的值,就像我们在第一章中已经看到的那样。您可以在讲义幻灯片中找到所提到的 Java 代码这里。
2.2 一阶递归。
我们经常可以通过乘以一个积分因子然后积分的方式解决递归关系,类似于解决微分方程。相反,我们使用一个求和因子将递归关系变换为一个求和。适当选择求和因子使得能够解决实践中出现的许多递归关系。 \(\eqalign{ NC_N &= (N+1)C_{N-1} + 2N {\quad\rm for\quad} N > 1 {\quad\rm with\quad} C_1 = 2\\ {C_N\over N+1} &= {C_{N-1}\over N} + {2\over N+1} \quad({\rm 求和因子 \ }{1\over N(N+1)})\\ &= {C_{N-2}\over N-1} + {2\over N} + {2\over N+1}\\ &= 2H_{N+1} - 2\\ }\)
任何具有常数或非常数系数的一阶线性递归都可以通过这种方式转换为一个求和。解决递归的问题被简化为求解求和的问题。
2.3 非线性一阶递归。
2.4 高阶递归
2.5 解决递归的方法
2.6 二进制分治递归和二进制数。
对于各种问题,通过应用以下基本算法设计范式已经开发出了良好的算法:"将问题分解为两个大小相等的子问题,递归求解,然后利用这些解来解决原始问题。" 归并排序是这类算法的原型。 归并排序使用的比较次数由递归方程的解给出 \(C_N=C_{\lfloor N/2\rfloor} +C_{\lceil N/2\rceil}+N \quad{\rm for}\ N>1{\rm \ with\ } C_1=0.\) 这种递归方程以及类似的其他方程在分析具有与归并排序相同基本结构的各种算法时会出现。 通常可以确定满足这些递归方程的函数的渐近增长,但是在推导精确结果时需要特别小心,主要是因为问题的"大小"为 \(N\) 时,如果 \(N\) 是奇数,则无法将问题分解为大小相等的子问题:最好的方法是使问题大小相差一个。 对于大的 \(N\),这是可以忽略的,但对于小的 \(N\),这是明显的,并且通常情况下,递归结构确保会涉及许多小的子问题。 最终结果是确切解通常具有周期性,有时甚至有严重的不连续性,并且通常无法用平滑函数来描述。 对于归并排序,比较次数为 \(C_N = N\lfloor\lg N\rfloor+2N-2^{\lfloor\lg N\rfloor+1}\) 可以写成 $$ C_N = N\lg N + N\theta({1-{\lg N}}),$$ 其中 \(\theta(x)=1+x-2^x\) 是一个满足 \(\theta(0)=\theta(1)=0\) 和 $0
顶部曲线是 \(1-\{\lg N\}\),中间曲线是 \(1-2^{1-\{\lg N\}}\)。将它们相加得到 \(2-\{\lg N\}-2^{1-\{\lg N\}} = \theta({1-\{\lg N\}}),\) 一个连续但周期性的函数。 在算法分析中经常出现这种情况,这个结果表明准确描述某些算法性能的挑战,因为这种周期性行为是固有的。
2.7 一般的分治递归方程。
更一般地,高效的算法和复杂度研究中的上界往往通过扩展分治算法设计范式得出,其思路如下:"将问题分解为更小(可能重叠)的子问题,递归求解,然后利用这些解来解决原始问题。" 会出现各种"分治"递归方程,这些方程取决于子问题的数量和相对大小,它们之间的重叠程度,以及重新组合它们以解决问题的成本。通常可以确定满足这些递归方程的函数的渐近增长,但是,正如上文所述,所涉及的函数的周期性和分形性质使得有必要仔细说明细节。
选定练习
2.11
解决递归方程 \(na_n = (n-2)a_{n-1} + 2\quad{\rm for}\ n>1{\rm \ with\ } a_1=1.\) 解法. 使用求和因子 \(n-1\): \(\eqalign{ n(n-1)a_n &= (n-1)(n-2)a_{n-1} + 2(n-1)\\ &= (n-2)(n-3)a_{n-2} + 2(n-2) + 2(n-1)\\ &= 2(1 + 2 + \ldots + n-1)\\ &= n(n-1)\\ a_n &= 1\quad{\rm for}\ n\ge 1\\ }\)
更简单的解法. 计算 \(a_2=1\),思考片刻,然后通过归纳证明对所有 \(n\ge 1\) 都有 \(a_n=1\)。
2.12
解决递归方程 \(a_n = 2a_{n-1} + 1\quad{\rm for}\ n>1{\rm \ with\ } a_1=1.\) 提示. 将两边除以 \(2^n\)。
2.13
解决递归方程 \(a_n = {n\over n+1}a_{n-1} + 1\quad{\rm for}\ n>0{\rm \ with\ } a_0=1.\)
2.15
解决递归方程 \(na_n = (n+1)a_{n-1} + 2n\quad{\rm for}\ n>0{\rm \ with\ } a_0=0.\)
2.17
[姚] ("2-3 树的边缘分析") 解决递归关系式 \(A_N = A_{N-1}-{2A_{N-1}\over N} + 2\Bigl(1-{2A_{N-1}\over N}\Bigr) \quad{\rm for}\ N>1{\rm \ with\ } A_1=1.\) 这个递归描述了以下随机过程:一组 \(N\) 个元素聚集成"2-节点"和"3-节点"。在每一步中,每个 2-节点有可能以 \(2/N\) 的概率变成 3-节点,每个 3-节点有可能以 \(3/N\) 的概率变成两个 2-节点。经过 \(N\) 步后,平均有多少个 2-节点?
2.69
绘制递归解的周期部分 \(a_N = 3a_{\lfloor N/3\rfloor} + N \quad{\rm for}\ N>3{\rm \ with\ } a_1=a_2=a_3=1.\) 对于 \(1\le N\le 972\)。
2.71
给出递归关系的渐近解 \(a(x) = \alpha a_{x/\beta} + 2^x \quad{\rm for}\ x>1{\rm \ with\ } a(x)=0{\rm \ for\ } x\le 1.\)
2.73
给出递归关系式的渐近解 \(a_N = a_{N/2} + a_{N/4} + N \quad{\rm for}\ N>2{\rm \ with\ } a_1=a_2=a_3=1.\)
复习问题
Q2.1
解决递归关系式 \(na_n = (n-3)a_{n-1} + n\quad{\rm for}\ n\ge 3{\rm \ with\ } a_0=a_1=a_2=0.\)
Q2.2
以下哪项关于归并排序用于排序 \(N\) 个项目时所需的比较次数是正确的? (a) 增长阶为 \(N lg N\) (b) 当 \(N\) 是 2 的幂时恰好为 \(N lg N\) (c) 等于小于 \(N\) 的数字的二进制表示中的 1 的数量 (d) 具有周期性行为 (e) 对于所有 \(N\) 都小于 \(N lg N + N/4\)
Q2.3
考虑以下递归关系:
**A. **$(n+1)a_{n+1} = (n-2)a_n + n $
**B. **$a_{n+1} = 4a_ + (n+1)(n+2) $
**C. **$na_n = 4a_ + (n+1)(n+2) $
**D. **$na_{n+1} = (n+4)a_ + n+4 $
**E. **$(n+1)a_{n+1} = (n+2)a_n + n $
将每个递归关系与此列表中可用于使其变为望远镜的表达式进行匹配:$(n+1)(n+2)(n+3) $, $n(n+2)(n+4) $, $2^{n+1} $, $(n+1)! $, $(n-1)!/4^n $, $n(n-1) $, $(n+1)(n+2) $.
Q2.4
(R. Brott) 考虑递归族 \(a_n = ba_{n-1} - a_{n-2}\),其中 \(n>1\),\(a_0=0\),\(a_1=1\)。对于哪些整数值的 \(b\),这个序列会振荡?
3. 生成函数
译者:飞龙
本章介绍了算法分析和组合学中的一个核心概念:生成函数 —— 这是算法研究对象和发现其性质所必需的分析方法之间的必要而自然的联系。
3.1 普通生成函数
在算法分析中,我们经常的目标是推导出一系列量 \(a_0, a_1, a_2, \ldots\) 的特定表达式,这些量衡量了某些性能参数。在本章中,我们看到了使用代表整个序列的单个数学对象的好处。
定义。 给定一个序列 \(a_0, a_1, a_2, \ldots, a_k, \ldots\),函数 \(A(z)=\sum_{k\ge0}a_k z^k\) 被称为该序列的普通生成函数(OGF)。我们使用记号 \([z^k]A(z)\) 表示系数 \(a_k\)。
| 序列 | OGF |
|---|---|
| \(1,\,1,\,1,\,1,\,\ldots,\,1,\,\ldots\) | \({1\over 1-z}=\sum_{N\ge0}z^N\) |
| \(0,\,1,\,2,\,3,\,4,\,\ldots,\,N,\,\ldots\) | \({z\over(1-z)²}=\sum_{N\ge1}Nz^N\) |
| \(0,\,0,\,1,\,3,\,6,\,10,\,\ldots,\,{N\choose2},\,\ldots\) | \({z²\over(1-z)³}=\sum_{N\ge2}{N\choose2}z^N\) |
| \(0,\,\ldots,\,0,\,1,\,M+1,\,\ldots,\,{N\choose M},\,\ldots\) | \({z^M\over(1-z)^{M+1}}=\sum_{N\ge M}{N\choose M}z^N\) |
| \(1,\,M,\,{M\choose 2}\,\ldots,\,{M\choose N},\,\ldots,\,M,\,1\) | \({(1+z)^M}=\sum_{N\ge0}{M\choose N}z^N\) |
| \(1,\,M+1,\,{M+2\choose2},\,{M+3\choose3},\,\ldots\) | \({1\over(1-z)^{M+1}}=\sum_{N\ge 0}{N+M\choose N}z^N\) |
| \(1,\,0,\,1,\,0,\,\ldots,\,1,\,0,\,\ldots\) | \({1\over 1-z²}=\sum_{N\ge0}z^{2N}\) |
| \(1,\,c,\,c²,\,c³,\,\ldots,\,c^N,\,\ldots\) | \({1\over1-cz}=\sum_{N\ge0}c^Nz^N\) |
| \(1,\,1,\,{1\over2!},\,{1\over3!},\,{1\over4!},\,\ldots,\,{1\over N!},\,\ldots\) | \(e^z=\sum_{N\ge0}{\displaystyle z^N\over N!}\) |
| \(0,\,1,\,{1\over2},\,{1\over3},\,{1\over4},\,\ldots,\,{1\over N},\,\ldots\) | \(\ln{1\over1-z}=\sum_{N\ge1}{\displaystyle z^N\over N}\) |
| \(0,\,1,\,1+{1\over2},\,1+{1\over2}+{1\over3},\,\ldots,\,H_N,\,\ldots\) | \({1\over1-z}\ln{1\over1-z}=\sum_{N\ge1}H_Nz^N\) |
| \(0,\,0,\,1,\,3({1\over2}+{1\over3}),\,4({1\over2}+{1\over3}+{1\over4}),\,\ldots\) | \({z\over(1-z)²}\ln{1\over1-z}=\sum_{N\ge0}N(H_N-1)z^N\) |
给定代表序列 \(a_0,a_1,\ldots,a_k,\ldots\) 和 \(b_0,b_1,\ldots,b_k,\ldots\) 的生成函数 \(A(z)=\sum_{k\ge0}a_kz^k\) 和 \(B(z)=\sum_{k\ge0}b_kz^k\),我们可以进行一些简单的转换以获得其他序列的生成函数。
| 操作 | 结果 |
|---|---|
| 右移 | \(zA(z)=\sum_{n\ge1}a_{n-1}z^n\) |
| 左移 | \({A(z)-a_0\over z}=\sum_{n\ge0}a_{n+1}z^n\) |
| 指标乘法(微分) | \(A^\prime(z)=\sum_{n\ge0}(n+1)a_{n+1}z^n\) |
| 指标除法(积分) | \(\int_0^zA(t)dt=\sum_{n\ge1}{a_{n-1}\over n}z^n\) |
| 缩放 | \(A(\lambda z)=\sum_{n\ge0}\lambda^na_nz^n\) |
| 加法 | \(A(z)+B(z)=\sum_{n\ge0}(a_n+b_n)z^n\) |
| 差分 | \({(1-z)A(z)}=a_0+\sum_{n\ge1}(a_n-a_{n-1})z^n\) |
| 卷积 | \({A(z)B(z)}=\sum_{n\ge0}\Bigl(\,\sum_{0\le k\le n}a_kb_{n-k}\Bigr)z^n\) |
| 部分和 | \({A(z)\over1-z}=\sum_{n\ge0}\Bigl(\,\sum_{0\le k\le n}a_k\Bigr)z^n\) |
3.2 指数生成函数
有些序列更方便地通过涉及归一化因子的生成函数处理:
定义。 给定一个序列 \(a_0, a_1, a_2, \ldots, a_k, \ldots\),函数 \(A(z)=\sum_{k\ge0}a_k {z^k\over k!}\) 被称为该序列的指数生成函数(EGF)。我们使用记号 \(k![z^k]A(z)\) 表示系数 \(a_k\)。
| 序列 | EGF |
|---|---|
| \(1,\,1,\,1,\,1,\,\ldots,\,1,\,\ldots\) | \(e^z=\sum_{N\ge0}{z^N\over N!}\) |
| \(0,\,1,\,2,\,3,\,4,\,\ldots,\,N,\,\ldots\) | \(ze^z=\sum_{N\ge1}{z^N\over(N-1)!}\) |
| \(0,\,0,\,1,\,3,\,6,\,10,\,\ldots,\,{N\choose2},\,\ldots\) | \({1\over2}z²e^z={1\over2}\sum_{N\ge2}{z^N\over(N-2)!}\) |
| \(\qquad0,\,\ldots,\,0,\,1,\,M+1,\,\ldots,\,{N\choose M},\,\ldots\qquad\) | \({1\over M!}z^Me^z={1\over M!}\sum_{N\ge M}{z^N\over(N-M)!}\) |
| \(1,\,0,\,1,\,0,\,\ldots,\,1,\,0,\,\ldots\) | \(\qquad{1\over2}(e^z+e^{-z})=\sum_{N\ge0}{1+(-1)^N\over2}{z^{N}\over N!}\qquad\) |
| \(1,\,c,\,c²,\,c³,\,\ldots,\,c^N,\,\ldots\) | \(e^{cz}=\sum_{N\ge0}{c^Nz^N\over N!}\) |
| \(0,\,1,\,{1\over2},\,{1\over3},\,\ldots,\,{1\over N+1},\,\ldots\) | \({\displaystyle e^z-1\over z}=\sum_{N\ge0}{\displaystyle z^N\over (N+1)!}\) |
| \(1,\,2,\,6,\,24,\,\ldots,\,N!,\,\ldots\) | \({\displaystyle 1\over 1-z}=\sum_{N\ge0}{\displaystyle N!z^N\over N!}\) |
与 OGFs 一样,对这些基本函数进行简单操作可以得到实践中出现的大部分 EGFs。请注意,EGFs 的左/右移操作与 OGFs 的索引乘法/除法操作相同,反之亦然。
| 操作 | 结果 |
|---|---|
| 右移(积分) | \(\int_0^zA(t)dt=\sum_{n\ge1}a_{n-1}{z^n\over n!}\) |
| 左移(微分) | \(A^\prime(z)=\sum_{n\ge0}a_{n+1}{z^n\over n!}\) |
| 索引乘法 | \(zA(z)=\sum_{n\ge0}na_{n-1}{z^n\over n!}\) |
| 索引除法 | \((A(z)-A(0))/z=\sum_{n\ge1}{a_{n+1}\over n+1}{z^n\over n!}\) |
| 加法 | \(A(z)+B(z)=\sum_{n\ge0}(a_n+b_n){z^n\over n!}\) |
| 差分 | \(A^\prime(z)-A(z)=\sum_{n\ge0}(a_{n+1}-a_n){z^n\over n!}\) |
| 二项式卷积 | \(\qquad{A(z)B(z)}=\sum_{n\ge0}\biggl(\>\sum_{0\le k\le n}{n\choose k}a_kb_{n-k}\biggr){z^n\over n!}\qquad\) |
| 二项式求和 | \(e^zA(z)=\sum_{n\ge0}\biggl(\>\sum_{0\le k\le n}{n\choose k}a_k\biggr){z^n\over n!}\) |
3.3 递归的生成函数解法
生成函数为解决许多递归关系提供了一种机械方法。给定描述某个序列${a_n}_{n\ge0}$的递归,我们通常可以通过执行以下步骤来开发解决方案:
将递归的两边都乘以$z^n$并在$n$上求和。
评估求和以导出 OGF 满足的方程。
解方程以得出 OGF 的显式公式。
将 OGF 表示为幂级数以获得系数(原始序列的成员)的表达式。
相同的方法适用于 EGFs,其中我们在第一步中乘以$z^n/n!$并在$n$上求和。OGFs 或 EGFs 哪个更方便取决于递归。
例子. \(a_n=5a_{n-1}-6a_{n-2}\qquad\hbox{对于$n>1$,其中$a_0=0$且$a_1=1$}\) 使用生成函数$a(z)=\sum_{n\ge0}a_nzn$。将递归的两边都乘以$zn$并在$n$上求和,得到方程$$a(z)={z\over 1-5z+6z²}={z\over(1-3z)(1-2z)}={1\over 1-3z}-{1\over 1-2z}$$(通过部分分式)因此我们必须有$a_n=3n-2n$。
一般来说,我们可以通过将生成函数$a(z)$表示为有理函数$f(z)/g(z)$,然后展开来解决线性递归$$a_n=x_1a_+x_2a_+\ldots+x_ta_$$。
从递归中推导$g(z)$:\(g(z)=1-x_1z-x_2z²-\ldots-x_tz^t\)
从递归中计算$f(z)$得到$g(z)$和初始条件。
消除$f(z)/g(z)$中的公共因子。
使用部分分式将$f(z)/g(z)\(表示为\)(1-\beta z)^{-j}$形式的项的线性组合。
展开部分分式展开中的每一项,使用$$zn{-j}={n+j-1\choose j-1}\beta^n$$。
注意根可能是复数。
例子. \(a_n=2a_{n-1}-a_{n-2}+2a_{n-3}\qquad \hbox{对于$n>2$,其中$a_0=1$,$a_1=0$,$a_2=-1$}.\) 这给出 \(g(z)=1-2z+z²-2z³=(1+z²)(1-2z)\) 和 \(f(z)=(1-z⁴)(1-2z)\pmod{z⁴}=(1-2z),\) 所以 \(a(z)={f(z)\over g(z)}={1\over1+z²}={1\over2}\Bigl({1\over1-iz}-{1\over1+iz}\Bigr),\) 而 \(a_n={1\over2}(i^n+(-i)^n)\)。因此,\(a_n\) 在$n$为奇数时为$0$,在$n$为 4 的倍数时为$1$,在 n 为偶数但不是 4 的倍数时为$-1$(这也直接从形式$a(z)=1/(1+z²)$得出)。对于初始条件$a_0=1$,\(a_1=2\),和$a_2=3$,我们得到$f(z)=1$,因此解的增长类似于$2^n$,但由于复根引起的周期性变化项。
3.4 展开生成函数。
给定生成函数的显式函数形式,我们希望找到相关序列的一般机制。这个过程称为“展开”生成函数,因为我们将其从紧凑的函数形式转化为无限系列的项。许多函数可以通过泰勒定理的组合轻松处理 \(f(z)=f(0)+f^{\prime}(0)z+{f^{\prime\prime}(0)\over2!}z² +{f^{\prime\prime\prime}(0)\over 3!}z³ +{f^{\prime\prime\prime\prime}(0)\over 4!}z⁴+\ldots.\) 和表格中给出的基本恒等式和变换的代数运算来处理。对于更复杂的函数,解析组合学的理论允许我们开发系数的近似而不展开。
3.5 生成函数的变换
3.6 生成函数上的函数方程
3.7 用 OGFs 解决快速排序中的中位数三分位数
3.8 生成函数计数
生成函数提供了一种系统计算组合对象的方法。为了说明,我们考虑一个经典的组合问题,它也对应于本书第五章和其他几个地方将要考虑的基本数据结构。二叉树是一种递归定义的结构,要么是单个外部节点,要么是连接到两个二叉树(左子树和右子树)的内部节点。二叉树出现在组合学和算法分析中的许多问题中:例如,如果内部节点对应于二元算术运算符,外部节点对应于变量,则二叉树对应于算术表达式。问题是,有多少具有$N$个外部节点的��叉树?

计算二叉树(带有递归)。 让$T_N$表示具有$N+1$个外部节点的二叉树的数量:\(T_0=1\),\(T_1=1\),\(T_2=2\),\(T_3=5\),和$T_4=14$。如果具有$N+1$个外部节点的二叉树中的左子树具有$k$个外部节点(有$T_$个不同的这样的树),那么右子树必须具有$N-k+1$个外部节点(有$T_$个可能性),因此$T_N$必须满足 \(T_N = \sum_{1\le k\le N}T_{k-1}T_{N-k} \qquad\hbox{对于$N>0$,其中$T_0=1$}.\) 将其乘以$z^N$并在$N$上求和得到非线性函数方程 \(T(z)=zT(z)²+1.\) 这很容易用二次方程解决: \(zT(z)={1\over2}(1\pm\sqrt{1-4z}).\) 为了在$z=0$时得到相等,我们取带有负号的解。为了提取系数,使用具有指数$1\over2$的二项式定理(牛顿公式): \(zT(z)=-{1\over2}\sum_{N\ge1}{{1\over2}\choose N}(-4z)^N.\) 将系数设置为相等得到 \(\eqalign{T_N&=-{1\over2}{{1\over2}\choose N+1}(-4)^{N+1}\cr &=-{1\over2}{{1\over2}({1\over2}-1)({1\over2}-2)\ldots({1\over2}-N)(-4)^{N+1}\over (N+1)!}\cr &={1\cdot 3\cdot 5\cdots(2N-1)\cdot2^{N}\over (N+1)!}\cr &={1\over N+1}{1\cdot 3\cdot 5\cdots(2N-1)\over N!}\, {2\cdot 4\cdot 6\cdots2N\over1\cdot 2\cdot 3\,\cdots\,N\,}\cr &={1\over N+1}{2N\choose N}.\cr}\)
这些数字被称为卡特兰数。在下一章中,我们将看到近似值为$T_\approx 4^N/N\sqrt{\pi N}$。
计数二叉树(直接)。 定义 \({\cal T}\) 为所有二叉树的集合,并采用记号 \(|t|\) 表示 \(t\in{\cal T}\) 时 \(t\) 中内部节点的数量。 然后我们有以下推导: \(\eqalign{T(z)&=\sum_{t\in{\cal T}}z^{|t|}\cr &=1+\sum_{t_L\in{\cal T}}\sum_{t_R\in{\cal T}}z^{|t_L|+|t_R|+1}\cr &=1+zT(z)²\cr}\) 第一行是从其定义中另一种表达 \(T(z)\) 的方式。 每棵具有恰好 \(k\) 个外部节点的树都会对 \(z^k\) 的系数贡献 \(1\),因此和式中 \(z^k\) 的系数“计数”具有 \(k\) 个内部节点的树的数量。 第二行来自于二叉树的递归定义:一个二叉树要么没有内部节点(这解释了 \(1\)),要么可以分解为两个独立的二叉树,其内部节点包括原始树的内部节点,再加上一个根节点。 第三行是因为索引变量 \(t_L\) 和 \(t_R\) 是独立的。 仔细研究这个基本例子,因为它是我们接下来考虑的一般框架的“招牌儿童”。
符号方法
符号方法 是将组合对象的形式定义转化为生成函数上的函数方程的强大方法。
未标记的对象。 在使用生成函数进行计数时,我们考虑组合对象的类别,每个对象都定义了一个“大小”的概念。 对于类别 \(\cal A\),我们用 \(a_n\) 表示大小为 \(n\) 的类别的成员数量。 然后我们对 OGF 感兴趣 \(A(z)=\sum_{n\ge 0}a_nz^n=\sum_{a\in\cal A}z^{|a|}.\) 给定两个组合对象类别 \(\cal A\) 和 \(\cal B\),我们可以用 不相交并集 运算 \(\cal A +\cal B\) 将它们组合在一起,得到由 \(\cal A\) 和 \(\cal B\) 的成员的不相交副本组成的类别,用 笛卡尔积 运算 \(\cal A \times\cal B\) 得到由对象的有序对(一个来自 \(\cal A\),一个来自 \(\cal B\))组成的类别,以及用 序列 运算得到由 \(\cal A\) 的对象序列组成的类别。 这些构造性定义直接导致了生成函数 \(A(z)\) 和 \(B(z)\) 的以下函数关系。
\(A(z)+B(z)\) 是枚举 \(\cal A +\cal B\) 的 OGF
\(A(z)B(z)\) 是枚举 \(\cal A \times \cal B\) 的 OGF
\(\displaystyle{1\over 1-A(z)}\) 是枚举 \(\cal A\) 的对象序列的 OGF
基本构件。 组合构造最终源自这些起始点:
空类 \(\phi\),OGF 为 \(0\)。
空对象 \(\epsilon\),OGF 为 \(1\)。
原子对象如 \(0\),\(1\) 或 \(\bullet\),OGF 为 \(z\)。
符号方程将这些与使用不相交并集、笛卡尔积、序列和其他运算的符号类名结合起来的符号方程直接转化为生成函数上的函数方程。
例子。 让 \(\cal G\) 是没有连续 \(0\) 位的二进制字符串类别。 这样的字符串要么是 \(\epsilon\),一个单独的 \(0\),要么是 \(1\) 或 \(01\),后跟一个没有连续 \(0\) 位的字符串。 符号上,\({\cal G} = \epsilon + \{0\} + \{1, 01\}\times{\cal G}.\) 因此 \(G(z) = 1 + z + (z + z²)G(z).\) 我们有 \(G(z)=(1+z)/(1-z-z²)\),这直接导致长度为 \(N\) 的字符串中没有连续 \(0\) 位的数量是 \(F_{N}+F_{N+1}=F_{N+2}\),即斐波那契数。
标记对象. 另一种范式是假设个体项目{\it 是}可区分的---它们是{\it 标记的},因此,当组装成组合对象时,项目出现的顺序是重要的。标记对象通常由指数生成函数枚举。对于标记类$\cal A$,我们对 EGF 感兴趣$$A(z)=\sum_{n\ge 0}a_n{zn\over n!}=\sum_{a\in\cal A}{z{|a|}\over |a|!}.$$ 当我们组合两个标记对象类$\cal A$和$\cal B$时,我们需要以一致的方式重新标记,以便如果生成的对象大小为$N$,则只出现标签$1$到$N$。不相交并操作本质上相同,但乘积操作涉及重新标记,因此我们使用不同的符号(\(\cal A \star \cal B\))。再次,定义直接导致生成函数上的函数关系。
$A(z)+B(z)\(是枚举\)\cal A +\cal B$的 EGF
$A(z)B(z)\(是枚举\)\cal A \star \cal B$的 EGF
$\displaystyle{1\over 1-A(z)}\(是枚举来自\)\cal A$的对象序列的 EGF
$\displaystyle{e^{A(z)}}\(是枚举\)\cal A$的对象集合的 EGF
详细内容和示例请参见第六章。
3.10 拉格朗日反演。
符号方法清楚地表明,我们经常面临从通过函数方程隐式定义的生成函数中提取系数的问题。以下通用工具可用于此任务,并且在树枚举方面尤为重要。
拉格朗日反演定理。 如果生成函数$A(z)=\sum_{n\ge0}a_nzn$满足函数方程$z = f(A(z))$,其中$f(z)$满足$f(0)=0$且$f{\prime}(0)\ne0$,那么$$a_n \equiv [zn]A(z) = {1\over n}[u]\Bigl({u\over f(u)}\Bigr)n.$$ 同样,\(z^n)^m = {m\over n}[u^{n-m}]\Bigl({u\over f(u)}\Bigr)^n.\) 和$$[zn]g(A(z)) = {1\over n}[u^]g^\prime(u)\Bigl({u\over f(u)}\Bigr)^n.$$
函数$f$的函数逆是满足$f^{-1}(f(z))=f(f^{-1}(z))=z$的函数$f^{-1}$。将方程$z=f(A(z))$两边应用$f^{-1$,我们可以看出函数$A(z)$是$f(z)$的函数逆。在这个意义上,拉格朗日定理是一个反转幂级数的通用工具。它的惊人之处在于提供了函数逆的系数与该函数幂次之间的直接关系。
例子. 让$T^{[2]}(z)=zT(z)$是计算外部节点的二叉树的 OGF。将函数方程$T^{[2]}(z)=z+T^{[2]}(z)²$重写为$$z = T^{[2]}(z)-T^{[2]}(z)²$$,我们可以应用拉格朗日反演,其中$f(u)=u-u²$。这给出结果$$[zn]T{[2]}(z) = {1\over n}[u^]\Bigl({u\over u-u²}\Bigr)n = {1\over n}[u]\Bigl({1\over 1-u}\Bigr)^n.$$ 现在,\({u^{n-1}\over(1-u)^n}=\sum_{k\ge n-1}{k\choose n-1}u^k\) 所以,考虑$k=2n-2$的项,\([u^{n-1}]\Bigl({1\over 1-u}\Bigr)^n = {2n-2\choose n-1} \quad\hbox{因此}\quad [z^n]T^{[2]}(z) = {1\over n}{2n-2\choose n-1}\),如预期。
3.11 概率生成函数
3.12 双变量生成函数
3.13 特殊函数
生成函数长期以来在组合学、概率论和解析数论中被广泛使用;因此,已经开发出了丰富的数学工具,这些工具对算法分析非常重要。我们既将生成函数用作组合工具来帮助精确计算感兴趣的数量,又将其用作解析工具来提供解决方案。因此,它们在算法分析中的作用至关重要。
选定练习
3.1
找到以下序列的每个 OGF:\(\{2^{k+1}\}_{k\ge0}, \qquad\{k2^{k+1}\}_{k\ge0}, \qquad\{kH_k\}_{k\ge1}, \qquad\{k³\}_{k\ge2}.\)
3.2
找到以下 OGFs 的$[zN]\(:$\){1\over(1-3z){4}}, \qquad(1- z)²\ln{1\over1-z}, \qquad{1\over(1-2z²)²}.$$
3.4
证明$$\sum_{1\le k\le N}H_k = (N+1)(H_{N+1}-1).$$
3.6
找到$$\Bigl{\sum_{0< k< n}{1\over k(n-k)}\Bigr}_{n>1}$$的 OGF。
3.7
找到${{H_k/k}}_{k\ge1}$的 OGF。
3.8
找到以下 OGFs 的$[zN]\(:\) {1\over1-z}\Bigl(\ln{1\over1-z}\Bigr)²$和$\Bigl(\ln{1\over1-z}\Bigr)³$。在这些展开中使用记号$$H_N{(2)}\equiv 1 + {1\over2²} + {1\over3²} + \ldots + {1\over N²}$$代表出现的“广义调和数”。
3.9
找到以下序列的 EGF:\(\{2^{k+1}\}_{k\ge0}, \qquad\{k2^{k+1}\}_{k\ge0}, \qquad\{k³\}_{k\ge2}.\)
3.10
找到$1, 3, 5, 7, \ldots$和$0, 2, 4, 6, \ldots$的 EGF。
3.11
对于以下 EGF,找到$N![z^N]A(z)$:\(A(z)={1\over 1-z}\ln{1\over 1-z}, \qquad A(z)=\Bigl(\ln{1\over 1-z}\Bigr)², \qquad A(z)=e^{z+z²}.\)
3.16
使用生成函数解决以下递推关系:\(\displaylines{ a_n = -a_{n-1} +6a_{n-2}\qquad\hbox{对于$n>1$,其中$a_0=0$,$a_1=1$};\cr a_n = 11a_{n-2} -6a_{n-3}\qquad\hbox{对于$n>2$,其中$a_0=0$,$a_1=a_2=1$};\cr a_n = 3a_{n-1} -4a_{n-2}\qquad\hbox{对于$n>1$,其中$a_0=0$,$a_1=1$};\cr a_n = a_{n-1} -a_{n-2}\qquad\hbox{对于$n>1$,其中$a_0=0$,$a_1=1$}.\cr }\)
3.17
解决递推关系$$a_n=5a_-8a_+4a_\qquad\hbox{对于$n>2$,其中$a_0=1$,\(a_1=2\),\(a_2=4\)}.$
3.18
解决递推关系$$a_n=2a_-a_\qquad\hbox{对于$n>4$,其中$a_0=a_1=0$,\(a_2=a_3=1\)}.\ $$
3.19
解决递推关系$$a_n=6a_-12a_+18a_-27a_\quad\hbox{对于$n>4$}$,其中$a_0=0$,\(a_1=a_2=a_3=1\)。
3.20
解决递推关系$$a_n=3a_-3a_+a_\quad\hbox{对于$n>2$}$,其中$a_0=a_1=0$,\(a_2=1\)。将初始条件$a_1$更改为$a_1=1$后,解决相同的递推关系。
3.22
使用生成函数解决递推关系$$na_n = (n-2)a_ + 2\quad{\rm for}\ n>1{\rm \ with\ } a_1=1.$$
3.25
使用泰勒定理找到以下函数的展开式:\(\sin(z),\qquad 2^z,\qquad ze^z.\)
3.27
使用泰勒定理直接验证$$H(z)={1\over1-z}\ln{1\over1-z}$$是调和数的生成函数。
3.28
找到以下表达式的值:\([z^n]{1\over\sqrt{1-z}}\ln{1\over1-z}.\) 提示. 展开$(1-z)^{-\alpha}\(并对\)\alpha$求导。
3.55
讨论$[z^N]D(z)$的表达形式。
3.56
编写一个高效的计算机程序,可以计算给定$N$的$[z^N]D(z)$。
3.58
[欧拉] 证明$${1\over1-z}=(1+z)(1+z²)(1+z⁴)(1+z⁸)\cdots.$$ 给出前$t$个因子的乘积的闭合形式。这个恒等式有时被称为“计算机科学家的恒等式”。为什么?
3.60
将$(1-z)(1-z²)(1-z⁴)(1-z⁸)\cdots$表示为$N$的二进制表示形式。
复习问题
Q3.1
假设$a_n$满足$a_n = 9a_-20a_ + n\quad{\rm for}\ n>1{\rm \ with\ } a_0=0 {\rm \ and\ } a_1=1 .$ 那么$\lim_{n\to\infty}a_n/a_{n+1}$是多少?
Q3.2
计算$[z^n]\sum_{0\le k\le n}{2k\choose k}{2n-2k\choose n-k}$的值是多少?
Q3.3
不要使用计算代数解决这个问题。 给出以下序列的 EGF:
**A. **0, -1, 1, -1, 1, -1, . . .
**B. **0, 1, 3, 7, 15, 31, 63, . . .
**C. **0, 1, 2, 3, 4, 5, 6, . . .
**D. **0, 1, 4, 9, 16, 25, 36, . . .
**E. **0, 1, 2, 6, 24, 120, 720, . . .
对于每个序列,使用以下列表中的一个函数:\((e^z-e^{-z})/2\),\(ze^z\),\(e^{2z}-e^z\),\((z²+z)e^z\),\(e^{-z}-1\),\(z/(1-z)\),\(e^{z²}-1\)。
Q3.4
(D. 卡特) 对于$n>0$,找到$[z^n]\bigl(1-{1\over z}\bigr)\ln(1-2z)$。
4. 渐近逼近
原文:
aofa.cs.princeton.edu/40asymptotic译者:飞龙
本章探讨了导出问题的近似解或近似精确解的方法,这些方法使我们能够在分析算法时对感兴趣的数量进行简洁和精确的估计。
4.1 渐近逼近符号
以下符号,至少可以追溯到本世纪初,被广泛用于对函数的近似值进行精确陈述:
定义。 给定一个函数 \(f(N)\),我们写
\(g(N)=O(f(N))\) 当且仅当 \(|g(N)/f(N)|\) 作为 \(N\to\infty\) 时被上界限制
若 \(g(N)=o(f(N))\) 当且仅当 \(g(N)/f(N)\to 0\) 当 \(N\to\infty\) 时
\(g(N)\sim f(N)\) 当且仅当 \(g(N)/f(N)\to 1\) 当 \(N\to\infty\) 时。
\(O\)-和$o$-符号提供了表达上界(\(o\) 是更强的断言)的方法,而 \(\sim\)-符号提供了表达渐近等价的方法。
在算法分析中,我们避免直接使用诸如“这个数量的平均值是 \(O{f(N)}\)”这样的用法,因为这对于预测性能而言提供的信息很少。相反,我们努力使用 \(O\)-符号来限定“误差”项,这些项的值远小于主要或“主导”项。非正式地说,我们期望所涉及的项应该足够小,以至于在 \(N\) 很大时可以忽略不计。
\(O\)-逼近. 我们说 \(g(N)=f(N)+O(h(N))\) 表示我们可以通过计算 \(f(N)\) 来近似 \(g(N)\),并且误差将在 \(h(N)\) 的一个常数因子范围内。通常情况下,与 \(O\)-符号一样,涉及的常数是未指定的,但通常可以合理假设它不大。正如下文所讨论的,我们通常将此符号与 \(h(N)=o(f(N))\) 一起使用。
\(o\)-逼近. 更强的陈述是说 \(g(N)=f(N)+o(h(N))\) 表示我们可以通过计算 \(f(N)\) 来近似 \(g(N)\),并且随着 \(N\) 的增大,误差相对于 \(h(N)\) 会变得越来越小。涉及到一个未指定的函数来描述减小的速率,但通常可以合理假设它在数值上从不大(即使对于小的 \(N\))。
\(\sim\)-逼近. 符号 \(g(N)\sim f(N)\) 用于表示最弱的非平凡 \(o\)-逼近 \(g(N)=f(N)+o(f(N))\)。
这些符号很有用,因为它们可以在不损失数学严谨性或精确结果的情况下抑��不重要的细节。如果需要更准确的答案,可以获得,但大部分详细计算通常被抑制。我们最感兴趣的是能够保持这种“潜在精度”的方法,产生的答案如果需要可以计算到任意精度。
线性递归的渐近性. 线性递归提供了渐近性的实用示例。任何线性递归序列 \(\{a_n\}\) 都有一个有理 OGF,并且是形如 \(\beta^{n}n^{j}\) 的项的线性组合。从渐近的角度来看,只需要考虑少数几个项,因为具有较大 \(\beta\) 的项会指数级地支配具有较小 \(\beta\) 的项。例如,对于 \(a_n=5a_{n-1}-6a_{n-2}\qquad\hbox{对于 $n>1$,其中 $a_0=0$ 且 $a_1=1$}\) 的精确解是 \(3^n-2^n\),但是近似解 \(3^n\) 对于 \(n>25\) 的精度在千分之一的范围内。我们只需要跟踪与绝对值或模最大的项有关的项。
定理 4.1(线性递归的渐近性)。假设有一个有理生成函数 \(f(z)/g(z)\),其中 \(f(z)\) 和 \(g(z)\) 互质且 \(g(0)\ne0\),具有最小模的唯一极点 \(1/\beta\)(即,\(g(1/\alpha)=0\) 且 \(\alpha\ne\beta\) 意味着 \(|1/\alpha|>|1/\beta|\),或 $|\alpha|
4.2 渐近展开。
我们更喜欢方程$f(N)=c_0g_0(N)+O(g_1(N))$,其中$g_1(N)=o(g_0(N))$,而不是方程$f(N)=O(g_0(N))$,因为它提供了常数$c_0$,因此允许我们提供随着$N$变大而精度提高的具体估计。
由庞加莱发展的渐近展开概念推广了这个概念:
定义. 给定一系列函数${g_k(N)}{k\ge0}$,其中$g{k+1}(N)=o(g_k(N))$对于$k\ge0$,\(f(N)\sim c_0g_0(N)+c_1g_1(N)+c_2g_2(N)+\ldots\) 被称为$f$的渐近级数,或$f$的渐近展开。渐近级数代表了以下公式的集合 \(\eqalign{ f(N)&=O(g_0(N))\cr f(N)&=c_0g_0(N)+O(g_1(N))\cr f(N)&=c_0g_0(N)+c_1g_1(N)+O(g_2(N))\cr f(N)&=c_0g_0(N)+c_1g_1(N)+c_2g_2(N)+O(g_3(N))\cr &\hskip5pt\vdots\cr}\),$g_k(N)$被称为渐近尺度。
这种一般方法允许用任何递减的函数无限级数来表达渐近展开(在$o$-记法意义上)。然而,我们通常对一组非常有限的函数感兴趣:事实上,当近似函数随着$N$增加而近似时,我们往往能够用$N$的递减幂来表达近似。偶尔需要其他函数,但通常我们会满足于由$N$、\(\log N\)、迭代对数(如$\log\log N$)和指数的幂乘积递减级数项组成的渐近尺度。
从渐近级数中取出的每个额外项都会给出更准确的渐近估计。在算法分析中常见的许多函数都有完整的渐近级数,我们主要考虑的方法是可以扩展的,原则上可以提供描述感兴趣量的渐近展开。我们可以使用$\sim$-记法简单地省略错误项的信息,或者使用$O$-记法或$o$-记法提供更具体的信息。
这个表格给出了从截断泰勒级数导出的四个基本函数的经典渐近展开。其他类似的展开可以立即从第三章中给出的生成函数中得到。在接下来的章节中,我们描述了使用这些展开操作渐近级数的方法。
| 函数 | 渐近展开 |
|---|---|
| 指数 | \(\displaystyle e^x=1+x+{x²\over2}+{x³\over6}+O(x⁴)\) |
| 对数 | \(\displaystyle\ln(1+x)=x-{x²\over2}+{x³\over3}+O(x⁴)\) |
| 二项式 | \(\displaystyle(1+x)^k=1+kx+{k\choose2}x²+{k\choose3}x³+O(x⁴)\) |
| 几何 | \(\displaystyle{1\over1-x}=1+x+x²+x³+O(x⁴)\) |
例子. 对于$N\to\infty$,展开$\ln(N-2)$,提取主导项,写成 \(\ln(N-2) = \ln N + \ln(1-{2\over N}) = \ln N - {2\over N} +O({1\over N²}).\) 即,我们使用替换(\(x = -2/N\)),其中$x\to0$。
非收敛的渐近级数. 任何收敛级数都会导致完整的渐近近似,但非常重要的是要注意,反之不成立---渐近级数很可能是发散的。例如,我们可能有一个函数 \(f(N)\sim\sum_{k\ge0}{k!\over N^k}\) 意味着(例如) \(f(N)=1+{1\over N}+{2\over N²}+{6\over N³}+O({1\over N⁴})\) 即使无限和不收敛。为什么允许这样?如果我们从展开中取任意固定数量的项,那么从定义中暗示的相等在$N\to\infty$时是有意义的。也就是说,我们有无限数量的更好和更好的近似,但它们开始提供有用信息的点变得越来越大。
这个表格给出了在组合学和算法分析中经常遇到的特殊数列的渐近级数。我们稍后考虑许多这些展开作为操作和推导渐近级数的例子。我们在本书后面经常提到它们,因为这些数列在研究算法属性时自然产生。
| 函数 | 渐近展开 |
|---|---|
| 阶乘(斯特林公式) | \(\displaystyle N!=\sqrt{2\pi N}\Bigl({N\over e}\Bigr)^N\Bigl(1+{1\over12N}+{1\over288N²}+O({1\over N³})\Bigr)\) |
| 斯特林公式的对数版本 | \(\qquad\displaystyle \ln N!=\Bigl(N+{1\over2}\Bigr)\ln N -N + \ln\sqrt{2\pi}+{1\over 12N}+O({1\over N³})\qquad\) |
| 谐和数 | \(\displaystyle H_N=\ln N + \gamma + {1\over2N} -{1\over12N²}+O({1\over N⁴})\) |
| 二项式系数 | \(\displaystyle {N\choose k}={N^k\over k!}\Bigl(1+O({1\over N})\Bigr)\quad{\rm for\ }k=O(1)\) |
| 二项式系数(中心) | \(\displaystyle {N\choose k}={2^N\over\sqrt{\pi N/2}}\Bigl(1+O({1\over N})\Bigr)\quad\hbox{for\ }k={N\over2}+O(1)\) |
| 二项分布的正态近似 | \(\displaystyle {2N\choose N- k}{1\over 2^{2N}}={e^{-{k²/N}}\over\sqrt{\pi N}} + O({1\over N^{3/2}})\) |
| 二项分布的泊松近似 | \(\displaystyle{N\choose k}p^k(1-p)^{N-k}={\lambda^ke^{-\lambda}\over k!}+o(1)\quad{\rm for\ }p = \lambda/N\) |
| 第一类斯特林数 | \(\displaystyle {N\brack k}\sim{(N-1)!\over (k-1)!}(\ln N)^{k-1} \quad{\rm for\ } k = O(1)\) |
| 第二类斯特林数 | \(\displaystyle {N\brace k}\sim{k^N\over k!}\quad{\rm for\ } k = O(1)\) |
| 伯努利数 | \(\displaystyle B_{2N}=(-1)^{N}{(2N)!\over(2\pi)^{2N}}(-2+O(4^{-N}))\) |
| 卡特兰数 | \(\displaystyle T_N\equiv{1\over N+1}{2N\choose N}={4^N\over\sqrt{\pi N³}}\Bigl(1 + O({1\over N})\Bigr)\) |
| 斐波那契数 | \(\displaystyle F_N={\phi^N\over\sqrt5}+O({\phi^{-N}}) \quad{\rm where\ }\phi={1+\sqrt5\over2}\) |
4.3 操纵渐近展开
渐近展开的操作通常简化为应用几种基本操作之一,我们依次考虑这些操作。在示例中,我们通常会考虑具有一、两或三项(不计算$O$项)的级数。当然,这些方法也适用于更长的级数。
简化. 渐近级数的好坏取决于其$O$项,因此任何更小的(在渐近意义上)都可以舍弃。例如,表达式$\ln N + O(1)\(在数学上等价于表达式\)\ln N + \gamma + O(1)$,但更简单。
替换. 最简单和最常见的渐近级数来自于将适当选择的变量值代入泰勒级数展开或其他渐近级数中。例如,在几何级数中取$x=-1/N$,得到$${1\over 1-x}=1+x+x²+O({x³})\qquad\hbox{as \(x\to0\)}\(可得\){1\over N+1}={1\over N}-{1\over N²}+ O({1\over N³})\qquad\hbox{as \(N\to\infty\)}.$$ 类似地,\(e^{1/N}= 1+{1\over N}+{1\over 2N²}+{1\over 6N³}+\cdots+ {1\over k!N^k} + O({1\over N^{k+1}}).\)
因式分解. 当函数的“近似”值在检查时显而易见时,将函数重写为相对或绝对误差的显式形式是值得的。例如,函数$1/(N²+N)$对于大的$N$显然非常接近$1/N²$,我们可以通过写成$$\eqalign{{1\over N²+N}&={1\over N²}{1\over 1+1/N}\cr &={1\over N²}\Bigl(1+{1\over N}+O({1\over N²})\Bigr)\cr &={1\over N²}+{1\over N³}+O({1\over N⁴}).\cr}$$来明确表达。当近似值不明显时,可能需要进行简短的试错过程。
乘法. 将两个渐近级数相乘只是逐项相乘,然后收集项。例如,\(\eqalign{ (H_N)²&=\Bigl(\ln N+\gamma+ O({1\over N})\Bigr) \Bigl(\ln N+\gamma+O({1\over N})\Bigr)\cr &=\Bigl((\ln N)²+\gamma\ln N+O({\log N\over N})\Bigr)\cr &\qquad+\Bigl(\gamma\ln N+\gamma²+O({1\over N})\Bigr)\cr &\qquad+\Bigl(O({\log N\over N})+O({1\over N})+O({1\over N²})\Bigr)\cr &=(\ln N)²+2\gamma\ln N+\gamma²+O({\log N\over N}).\cr}\)
在这种情况下,乘积的绝对渐近精度比因子低—结果仅准确到$O({\log N/N})$。这是正常的,通常我们需要以比结果中所需的更多项开始渐近展开的推导。通常,我们使用两步过程:进行计算,如果答案精度不够,则更准确地表达原始组件并重复计算。
除法. 为了计算两个渐近级数的商,我们通常会将分母因式分解并重写为形式$1/(1-x)$,其中$x$是趋近于 0 的符号表达式,然后展开为几何级数,并相乘。例如,要计算$\tan x$的渐近展开,我们可以将$\sin x$的级数除以$\cos x$的级数,如下所示:\(\eqalign{ \tan x = {\sin x\over\cos x} &= {x - x³/6 +O({x⁵})\over 1 - x²/2 +O({x⁴})}\cr &= \Bigl(x - x³/6 +O({x⁵})\Bigr){1\over 1 - x²/2 +O({x⁴})}\cr &= \Bigl(x - x³/6 +O({x⁵})\Bigr)({1 + x²/2 +O({x⁴})})\cr &= x + x³/3 +O({x⁵}).\cr }\)
指数/对数. 将$f(x)\(写为\)\exp{\ln(f(x))}$通常是处理涉及幂次或乘积的渐近的方便起点。一个标准例子是以下对$e$的近似:\(\eqalign{ \Bigl(1+{1\over N}\Bigr)^N &= \exp\Bigl\{N\ln\Bigl(1 + {1\over N}\Bigr)\Bigr\}\cr &= \exp\Bigl\{N\Bigl({1\over N} + O({1\over N²}) \Bigr)\Bigr\} \cr &= \exp\Bigl\{1 + O({1\over N})\Bigr\}\cr &= e + O({1\over N}).\cr }\)
4.4 有限和的渐近逼近
经常情况下,我们能够将一个量表达为有限和,因此需要准确估计总和的值。有些总和可以精确计算。在更多情况下,精确值不可用,或者我们只能对被求和的量本身进行估计。
尾部估计. 当有限和中的项迅速减少时,可以通过用无限和近似总和并开发对无限尾部大小的界限来开发渐近估计。
例子(错位)。
\(N!\sum_{0\le k\le N}{(-1)^k\over k!} = N!e^{-1} - R_N \quad\hbox{where}\quad R_N= N!\sum_{k>N}{(-1)^k\over k!}.\) 通过限制各项来限制尾部$R_N$:$$|R_N|
例子(尝试)。
\(\sum_{1\le k\le N}{1\over 2^k-1} = \sum_{k\ge 1}{1\over 2^k-1} - R_N, \quad\hbox{where}\quad R_N=\sum_{k>N}{1\over 2^k-1}.\) 常数$1 + 1/3 + 1/7 + 1/15 + \ldots = 1.6066\cdots$ 是对有限和的极好近似(可以轻松计算到任意精度),因为$R_N < \sum_{k>N}{1/2^}=1/2^.$
利用尾部. 当有限和中的项迅速增加时,通常最后一项就足以给出整个总和的良好渐近估计。
例子。
\(\sum_{0\le k\le N} k! = N!\Bigl(1+{1\over N}+\sum_{0\le k\le N-2}{k!\over N!}\ \Bigr) = N!\Bigl(1+O({1\over N})\Bigr),\) 因为总和中有$N-1$个项,每个项都小于$1/(N(N-1))$。
用积分近似总和. 当我们使用$$\int_ab {f(x)}dx\quad\hbox{来估计}\quad\sum_{a\le k < b} {f(k)}\(时,所产生的误差有多大?这个问题的答案取决于函数$f(x)$的“平滑性”。在$a$和$b$之间的每个单位间隔中,我们使用$f(k)$来估计$f(x)$。让$\delta_k = \max_{k\le x < k+1}|f(x)-f(k)|$表示每个间隔中的最大误差,我们可以粗略估计总误差:\)\sum_{a\le k < b} {f(k)}=\int_ab {f(x)}dx + \Delta, \qquad\hbox{其中\ }|\Delta|\le\sum_{a\le k < b}\delta_k.$$ 如果函数在整个区间$[a,/b]\(上单调增或单调减,则误差项简化为\)\Delta \le |f(a)-f(b)|$。
例子(调和数)。
\(H_N=\sum_{1\le k\le N}{1\over k} = \int_1^N{1\over x}dx + \Delta = \ln N + \Delta\) 其中$|\Delta| \le 1-1/N$,一个简单的证明$H_N\sim\ln N$。
例子(斯特林逼近(对数形式))。
\(\ln N!=\sum_{1\le k\le N}{\ln k}= \int_1^N{\ln x}\,dx + \Delta = N\ln N - N +1+ \Delta\),其中$|\Delta|\le\ln N$,一个简单的证明$\ln N!\sim N\ln N - N$。
误差项的更精确估计取决于函数的导数。这是下一节的主题。
4.5 欧拉-麦克劳林求和。
渐近分析中最强大的工具之一可以追溯到 18 世纪,使我们能够以两种不同的方式用积分近似求和。
函数在固定区间上定义,我们评估对应于在区间上沿着越来越多的点采样函数的和,步长越来越小,求和与积分之间的差异收敛于零(就像经典的黎曼积分)。
步长固定,因此积分区间变得越来越大,求和与积分之间的差异收敛到一个常数。
我们分别考虑这两种情况(尽管它们都体现了相同的基本方法)。
定理 4.2(欧拉-麦克劳林求和公式,第一形式)。设$f(x)\(是定义在区间\)[,a,,b,]$上的函数,其中$a$和$b$是整数,并假设导数$f^{(i)}(x)$对$1\le i\le 2m$存在且连续,其中$m$是一个固定常数。那么$$\sum_{a\le k\le b} {f(k)} = \int_ab {f(x)}dx +{f(a)+f(b)\over2} +\sum_{1\le i\le m}{B_{2i} \over (2i)!} f{(2i-1)} (x)\bigm|a^b + R,$$ 其中$B_{2i}$是伯努利数,$R_\(是一个满足$\)|R_|\le{|B_{2m}|\over(2m)!}\int_ab|f{(2m)}(x)|dx定理 4.3(欧拉-麦克劳林求和公式,第二形式)。设$f(x)\(是定义在区间\),1,,\infty,)$上的函数,并假设导数$f^{(i)}(x)$对$1\le i\le 2m$存在且绝对可积,其中$m$是一个固定常数。那么$$\sum_{1\le k\le N} {f(k)} = \int_1N {f(x)}dx +{1\over2}f(N)+C_f + \sum_{1\le k\le m}{B_{2k} \over (2k)!} f{(2k-1)} (N) + R_,$$ 其中$C_f$是与函数相关的常数,$R_{2m}\(是一个满足$\)|R_{2m}|=O\Bigl(\int_N^\infty|f^{(2m)}(x)|dx\Bigr)。$$
例子(调和数)。
取$f(x)=1/x$。这种情况下的欧拉-麦克劳林常数被简单地称为欧拉常数:\(\gamma = {1\over2}-\int_1^\infty\Bigl(\{x\}-{1\over2}\Bigr){dx\over x²}。\) 因此$$H_N=\ln N + \gamma +o(1)。$$ 常数$\gamma$约为$.57721\cdots$,并不是其他基本常数的简单函数。完整的展开为$$H_N \sim \ln N + \gamma + {1\over 2N} - {1\over12N²} + {1\over120N⁴}-\ldots。
例子(斯特林逼近(对数形式))。
令$f(x)=\ln x$,得到$\ln N!\(的斯特林逼近。在这种情况下,欧拉-麦克劳林常数为$\)\int_1^\infty\Bigl({x}-{1\over2}\Bigr){dx\over x}.$$ 这个常数确实是其他基本常数的一个简单函数:它等于$\ln\sqrt{2\pi}-1$。数值$\sigma=\sqrt{2\pi}\(被称为*斯特林常数*。它在算法分析和许多其他应用中经常出现。因此$\)\ln N!=N\ln N - N +{1\over2}\ln N + \ln\sqrt{2\pi} +o(1)。$$ 完整的展开为$$\ln N! \sim (N+{1\over 2}) \ln N - N + \ln\sqrt{2\pi}+{1\over 12N}-{1\over360\ N³}+\ldots$$,通过指数化和基本操作得到$$N! \sim \sqrt{2\pi N}\Bigl({N\over e}\Bigr)N\Bigl(1+{1\over12N}+{1\over288N\ 2}-{139\over5140 N³}+\ldots\Bigr)。
4.6 双变量渐近。
此表左侧的双变量函数在组合数学和算法分析中至关重要。右侧是对所有$k$值都成立的均匀逼近(见证明文本)。
| 函数 | 定义 | 均匀逼近 |
|---|---|---|
| 拉马努金 Q | \(N!\over(N-k)!N^k\) | \(e^{ -{k²/(2N)}} + O({1\over\sqrt{N}})\) |
| 拉马努金 R | \(N!N^k\over (N+k)!\) | \(e^{ -{k²/(2N)}} + O({1\over\sqrt{N}})\) |
| 正态近似 | \({2N\choose N-k}={(2N)!\over (N+k)!(N-k)!}\) | \({e^{ -{k²/N}}\over\sqrt{\pi N}} + O({1\over N^{3/2}})\) |
| 泊松近似 | \({N\choose k}\Bigl({\lambda\over N}\Bigr)^k\Bigl(1-{\lambda\over N}\Bigr)^{N-k}\) | \({\lambda^ke^{-\lambda}\over k!}+o(1)\) |
对于每个函数,我们还需要中心展开来近似最大项(对小的$k$有效),如下表所总结。
| 函数 | 中心展开 |
|---|---|
| 拉马努金 Q | \(e^{ -{k²/(2N)}}\Bigl(1 + O({k\over N})+O({k³\over N²})\Bigr) \quad{\rm for\ }k=o(N^{2/3})\) |
| 拉马努金 R | \(e^{ -{k²/(2N)}}\Bigl(1 + O({k\over N})+O({k³\over N²})\Bigr) \quad{\rm for\ }k=o(N^{2/3})\) |
| 正态近似 | \({e^{ -{k²/N}}\over\sqrt{\pi N}}\Bigl(1 + O({{1\over N}})+O({{k⁴\over N³}})\Bigr) \quad{\rm for\ }k=o(N^{3/4})\) |
| 泊松近似 | \({\lambda^ke^{-\lambda}\over k!}\Bigl(1+O({1\over N})+O({k\over N})\Bigr) \quad{\rm for\ }k=o(N^{1/2})\) |
对于每个函数,以下上界对所有$N$和$k$都有效。
| 函数 | 统一上界 |
|---|---|
| 拉马努金 Q | $$ e^{-k(k-1)/(2N)}$$ |
| 拉马努金 R | $$ e^{-k(k+1)/(4N)}$$ |
| 正态近似 | $$ e^{-k²/(2N)}$$ |
这种结果通常用于以以下方式限制分布的尾部。给定$\epsilon>0$,对于$k>\sqrt{2N^{1+\epsilon}}\(,我们有,$\) {1\over 2^{2N}}{2N\choose N-k}\le e^{-{(2N)^{\epsilon}}}. $$ 也就是说,当$k$略微快于$\sqrt$增长时,分布的尾部是指数级小的。
4.7 拉普拉斯方法
在估计整个范围内的求和时,我们希望利用我们在范围的不同部分获得准确估计的求和项的能力。另一方面,如果我们能在整个感兴趣的范围内坚持使用单个函数,那当然更方便。在本节中,我们讨论一种通用方法,允许我们同时做到这两点,即用于估计求和和积分值的Laplace 方法。我们经常在算法分析中遇到可以用这种方法估计的求和。通常情况下,我们也利用我们在这些情况下用积分近似求和的能力。该方法围绕以下三个步骤进行求和评估:
将范围限制在包含最大求和项的区域内。
近似求和项并限制尾部。
扩展范围并限制新的尾部,以获得一个更简单的求和。
这幅图以一种概要的方式说明了这种方法。实际上,在近似求和时,涉及的函数都是阶跃函数;通常在最后会出现一个“平滑”函数,应用欧拉-麦克劳林公式。
![
例子(拉马努金 Q 函数)。为了近似计算$$Q(N)\equiv\sum_{1\le k\le N} {N!\over (N-k)!Nk}\(,将刚才给出的近似应用于求和的不同范围。更准确地说,定义$k_0$为一个满足$o(N^{2/3})$的整数,并将求和分为两部分:\)\sum_{1\le k\le N} {N!\over (N-k)!Nk} = \sum_{1\le k\le k_0} {N!\over (N-k)!Nk} + \sum_{k_0 < k\le N} {N!\over (N-k)!Nk}.$$ 我们分别近似这两部分,利用两部分中对$k$的不同限制来优势。对于第一(主要)项,我们使用均匀近似。对于第二项(尾部),限制$k>k_0$和项是递减的事实意味着它们都是指数级小的。\(Q(N)=\sum_{1\le k\le k_0} e^{-k²/(2N)}\Bigl(1 + O({k\over N})+ O({k³\over N²})\Bigr)+\Delta.\) 这里我们使用$\Delta$作为一个表示指数级小但未指定的项的符号。主要项$\exp(-k²/(2N)$对于$k>k_0$也是指数级小的,我们可以将$k>k_0$的项加回去,所以我们有$$Q(N),=,\sum_{k\ge1},,,e^{-k²/(2N)} + O(1).$$ 本质上,我们用近似的尾部替换了原始求和的尾部,这是合理的,因为两者都是指数级小的。剩下的求和是函数$e^{x²/2}$在间隔为$1/\sqrt\(的点上的值的和。因此,欧拉-麦克劳林定理提供了近似$\)\sum_{k\ge1}e^{-k²/(2N)}=\sqrt\int_0^\infty e^{-{x²/2}}dx + O(1).$$ 这个积分的值是众所周知的$\sqrt{\pi/2}$。将其代入上述$Q(N)$的表达式得到结果$$Q(N) = \sqrt{\pi N/2} +O(1).$$
4.8 算法分析中的“正常”示例
4.9 "Poisson" 算法分析示例
4.10 生成函数渐近
选择的练习
4.2
展示$$\displaystyle{N\over N+1}=1+O\Bigl({1\over N}\Bigr)\qquad\hbox{和}\qquad \displaystyle{N\over N+1}\sim 1-{1\over N}$$。
4.9
如果$\alpha 0$。对于$\beta=1.2$和$\alpha=1.1$,当$\alphaN+\betaN$被$\beta^N$近似时,找出$N=10$和$N=100$时的绝对误差和相对误差。
4.14
使用定理 4.1 找到递推式$$a_n=5a_-8a_+4a_\qquad\hbox{对于$n>2$,其中$a_0=1$,\(a_1=2\),\(a_2=4\)}$$的渐近解。将初始条件$a_0$和$a_1$更改为$a_0=1$和$a_1=2$后,解决相同的递推式。
4.15
使用定理 4.1 找到递推式$$a_n=2a_-a_\qquad\hbox{对于$n>4$,其中$a_0=a_1=0$,\(a_2=a_3=1\)}$$的渐近解。
4.16
使用定理 4.1 找到递推式$$a_n=3a_-3a_+a_\qquad\hbox{对于$n>2$,其中$a_0=a_1=0$,\(a_2=1\)}$$的渐近解。
4.21
当$x\to0$时,将$\ln(1-x+x²)$展开为$O\bigl(x⁴\bigr)$。
4.23
给出$\displaystyle{N\over N-1}\ln {N\over N-1}\(的渐近展开,精确到\)\displaystyle O\bigl({1\over N⁴}\bigr)$。
4.38
计算$(3N)!/(N!)³$的渐近展开,精确到相对精度$\displaystyle\Bigl(1+ O({1\over N})\Bigr)$。
4.39
$\displaystyle\Bigl(1-{\lambda\over N}\Bigr)^N$的近似值是多少?
4.50
给出$\sum_{0\le k\le N}{2k/(2k+1)}$的渐近估计。
4.58
使用欧拉-麦克劳林求和估计$$\sum_{1\le k\le N}{\sqrt},\qquad \sum_{1\le k\le N}{1\over\sqrt},\quad\hbox{和}\quad \sum_{1\le k\le N}{1\over\root3\of}$$,精确到$O\bigl(1/N²\bigr)$。
4.71
证明对于$k = o(N^{2/3})\(,有$\){(N-k)k(N-k)!\over N!} = e{ - {k²\over 2N}}\Bigl( 1 + O({k\over N}) + O({k³\over N²})\Bigr) \(. 通过与定理 4.5、4.8 的证明以及定理 4.4 的推论的相同论证,您可以将此推导扩展到证明\) P(N)=\sum_{0\le k < N} {(N-k)^k(N-k)!\over N!}=\sqrt{\pi N/2}+O(1)$$
复习问题
Q4.1
分别给出以下各项的$O({1\over N})$的渐近展开:(a)\(H_N\)(b)\(\exp(H_{2N} - H_N) - 1\)(c)\(\exp(H_N)\)(d)\(\exp({1\over N})\)(e)\((1 - {1\over N})^{-1}\)
Q4.2
给出以下各项的值,保留三位小数:(a) \(1.01^{10}\) (b) \(1.05^{10}\) (c) \(1.01^{20}\) (d) \(1.01^{50}\) (e) \(1.01^{100}\)
Q4.3
不要使用计算机或计算器解决这个问题。 对于下面的每个表达式,请从以下列表中给出最接近的值:1.716924, 1.000145, 1.000023, 1.010050, 1.00111, 1.000007, 1.007000. \(e^{.01}\) \(\ln(1000)/\ln(999)\) \(1.001^{1000}-1\) \((100+\ln(100!))/(100\ln 100))\) \(\ln(2.7183)\)
Q4.4
(A. Yan) 给出${3N\choose N}\(的渐近展开式,精确到\)\Bigl(1 + O({1\over N})\Bigr)$。
5. 解析组合学
译者:飞龙
本章介绍了解析组合学,这是一种现代方法,用于研究我们在算法分析中经常遇到的组合结构。这种方法的基础是组合结构通常由简单的形式规则定义,这些规则是学习其属性的关键。这一观察的最终结果之一是,相对较小的一组转移定理最终产生了我们寻求的数量的准确近似值。图 5.1 概述了这个过程。生成函数是解析组合学中研究的中心对象。首先,我们直接将组合对象的形式定义转换为枚举对象或描述其属性的生成函数的定义。其次,我们使用经典数学分析来提取生成函数系数的估计。
5.1 形式基础
5.2 无标记类的符号方法
5.3 标记类的符号方法
5.4 参数的符号方法
定理 5.3(无标记类 OBGF 的符��方法)设$\cal A$和$\cal B$是组合对象的无标记类。如果$A(z, u)$和$B(z, u)\(分别是与\)\cal A$和$\cal B$相关联的 OBGF,其中$z$标记大小,$u$标记参数,则
$A(z, u)+B(z, u)\(是与\)\cal A +\cal B$相关联的 OBGF,
$A(z, u)B(z, u)\(是与\)\cal A \times \cal B$相关联的 OBGF,以及
$\displaystyle{1\over 1-A(z, u)}$是与$SEQ({\cal A})$相关联的 OBGF。
证明。 省略。
二叉树中的叶子节点。 一个大小为$N$的二叉树中,具有两个外部子节点的内部节点的比例是多少?这些节点被称为叶子节点。你可以检查$N=0,1,2,3,$和$4$时这些节点的总数分别为$0,1,2,6,$和$20$。通过除以卡特兰数,相关比例分别为$0,1,1,6/5$和$10/7$。在 BGF 中,\(T(z, u) = \sum_{t\in\cal T}z^{|t|}u^{\hbox{leaves}(t)}\) 下列是$z⁰$,\(z¹\),\(z²\),\(z³\),$z⁴$的系数,直接反映在图 5.2 中的树中:\(u⁰\) \(u¹\) \(u¹+ u¹\) \(u¹+ u¹+ u²+ u¹+ u¹\) \(u¹+ u¹+u²+u¹+u¹+u²+u²+u¹+u¹+u²+u¹+u¹+u²+u².\) 将这些项相加,我们知道$$T(z, u) = 1 + z¹u + 2z²u + z³(4u + u²) + z⁴(8u + 6u²) + \ldots,.$$ 检查小值,我们发现$$T(z, 1) = 1 + z¹ + 2z² + 5z³ + 14z⁴ + \ldots$$ 和$$T_u(z, 1) = z¹ + 2z² + 6z³ + 20z⁴ + \ldots$$ 如预期。为了用符号方法推导 GF 方程,我们在标准递归构造的两侧加上${\cal Z}{\bullet}\(得到$\){\cal T} + {\cal Z}{\bullet}= {\cal E} + {\cal Z}{\bullet} + {\cal Z}{\bullet}\times{\cal T} \times{\cal T}.$$ 这给了我们一种标记叶子节点的方法(通过在右侧的${\cal Z}{\bullet}$项上使用 BGF \(zu\))并平衡大小为 1 的树的方程。应用定理 5.3(在左侧的${\cal Z}{\bullet}$项上使用 BGF \(z\),在最右侧的项上使用${\cal Z}_{\bullet}$因子,因为两者都不对应叶子节点)立即给出了功能方程$$T(z, u) + z=1+zu+zT(z, u)².$$ 设置$u=1$得到了卡特兰数的 OGF,对$u$求导并在$u=1$处求值得到$$\eqalign{ T_u(z, 1) &= z + 2zT(1,z)T_u(z, 1)\cr &= {z\over 1- 2zT(z, 1)}\cr &= {z\over\sqrt{1-4z}}.\cr }$$ 因此,在大小为$n$的二叉树中,具有两个外部节点的内部节点的平均数量是$${[z^n]\displaystyle{z\over\sqrt{1-4z}}\over\displaystyle{1\over n+1}{2n\choose n}}={\displaystyle{2n-2\choose n-1}\over\displaystyle{1\over n+1}{2n\choose n}}={(n+1)n\over2(2n-1)}$$ 在极限情况下趋向于$n/4$。二叉树中约有$1/4$的内部节点是叶子节点。
5.5 生成函数系数渐近性
定理 5.5(收敛半径转移定理)设 \(f(z)\) 的收敛半径严格大于 1,并假设 \(f(1)\ne0\)。对于任意实数 \(\alpha\not\in\{0,-1,-2,\ldots\}\),有 \([z^n] {f(z)\over (1-z)^\alpha}\sim f(1) {n+\alpha-1\choose n}\sim {f(1)\over \Gamma(\alpha)}n^{\alpha-1}。**证明。**设 $f(z)$ 的收敛半径 $>r$,其中 $r>1$。我们知道从收敛半径界限得到 $f_n\equiv[z^n]f(z)=O(r^{-n})$,特别地,和 $\sum_n f_n$ 以几何速度收敛到 $f(1)$。然后分析卷积很简单:\)\eqalign{ [zn] {f(z)\over(1-z){\alpha}} &=f_0 {n+\alpha-1\choose n}+f_1 {n+\alpha-2\choose n-1} +\cdots+f_n {\alpha-1\choose 0}\cr &={n+\alpha-1\choose n}\Bigl(f_0+f_1{n\over n+\alpha-1}\cr &\qquad\qquad+f_2{n(n-1)\over (n+\alpha-1)(n+\alpha-2)}\cr &\qquad\qquad+f_3{n(n-1)(n-2)\over (n+\alpha-1)(n+\alpha-2)(n+\alpha-3)}+\cdots\Bigr).\cr } $$ 此和中索引为 \(j\) 的项为 \(f_j {n(n-1)\cdots(n-j+1)\over (n+\alpha-1)(n+\alpha-2)\cdots(n+\alpha-j)},\) 当 \(n\to+\infty\) 时趋于 \(f_j\)。由此我们推断出 \([z^n] f(z)(1-z)^{-\alpha}\sim {n+\alpha-1\choose n} (f_0+f_1+\cdots f_n)\sim f(1){n+\alpha-1\choose n},\) 因为部分和 \(f_0+\cdots+f_n\) 以几何速度收敛到 \(f(1)\)。由欧拉-麦克劳林公式推导出对二项式系数的近似。
一般来说,系数的渐近性由生成函数在发散点附近的行为决定。当收敛半径不为 1 时,我们可以对一个适用于定理的函数进行重新缩放。这种重新缩放总是引入一个乘法指数因子。
推论。 设 \(f(z)\) 的收敛半径严格大于 \(\rho\),并假设 \(f(\rho)\ne0\)。对于任意实数 \(\alpha\not\in\{0,-1,-2,\ldots\}\),有 $$[zn] {f(z)\over (1-z/\rho)\alpha}\sim {f(\rho) \over \Gamma(\alpha)}\rho^{-n} n^{\alpha-1}。**证明。**在定理 5.5 中将变量从 \(z\) 更改为 \(z/\rho\)。
尽管定理 5.5(及其推论)存在局限性,但对于从本书中遇到的许多生成函数中提取系数而言,它是有效的。这是解析组合学第二阶段组成的解析转移定理的杰出示例。
广义错位。 我们使用符号方法表明给定排列没有长度小于或等于 \(M\) 的循环的概率是 \([z^N]P^*_{>M}(z)\),其中 \(P^*_{>M}(z) = {e^{-z - z²\!/2 \ldots - z^M\!/M}\over 1-z}\) 从这个表达式,定理 5.5 立即 给出了 \([z^N]P^*_{>M}(z)\sim{1\over e^{H_M}}。\) 解析组合学的一个主要原则是通常不需要详细计算,因为一般的转移定理可以为大量组合类提供准确的结果,就像在这种情况下一样。
卡特兰数和二叉树中的叶子。 类似地,定理 5.5 的推论立即提供了从卡特兰生成函数 \(T(z) = {1 - \sqrt{1 -4z}\over 2}\) 转移到其系数的渐近形式的转换:忽略常数项,取 \(\alpha = -1/2\) 和 \(f(z) = -1/2\) 得到渐近结果 \(T_N\sim{4^N\over N\sqrt{\pi N}}。\) 此外,我们对上述证明有一个更简单的结论,即二叉树中叶子的平均数量约为 \(\sim N/4\),因为定理 5.5 的推论立即给出了 \([z^N] {z\over\sqrt{1 -4z}} = {4^N\over 4\sqrt{\pi N}}\)
像定理 5.5 这样的结果,系数的渐近形式直接“转移”自类似$(1-z)^{-\alpha}$(称为“奇点元素”)的元素,这些元素在有理函数分析中起着非常类似于部分分数元素的作用。更深层次的数学真理正在发挥作用。定理 5.5 只是上个世纪达布尔、波利亚、塞格、本德等人发展的一整套类似结果中最简单的一个。这些方法在 Flajolet 和 Sedgewick 的《解析组合学》中有详细讨论。与我们在这里所能做的不同,它们的完整发展需要复变函数论。这种渐近分析方法称为“奇点分析”。
选定的练习
5.1
长度为$N$的比特串有多少个不包含 000?
5.3
让$\cal U$为具有节点总数(内部加外部)的二叉树集合,因此其计数序列的生成函数为$U(z) = z + z³ + 2 z⁵ + 5z⁷ + 14z⁹ + \ldots,,$。推导出$U(z)$的显式表达式。
5.7
推导一个生成函数,用于计算循环长度全为奇数的排列数。
5.15
找到大小为$N$且两个子节点都是内部节点的二叉树中内部节点的平均数量。
5.16
找到大小为$N$且一个子节点是内部节点一个子节点是外部节点的二叉树中内部节点的平均数量。
5.23
证明在长度为$N$的随机排列中,所有循环长度均为奇数的概率是$\sim 1/\sqrt{\pi N/2}$(参见练习 5.7)。
复习问题
Q5.1
给出(a) 不包含 01 的二进制字符串的组合结构 (b) 不包含 11 的二进制字符串的组合结构
Q5.2
给出(a) \([z^n]{1\over1-3z}\ln{1\over 1-2z}\) (b) $[z^n]{1\over\sqrt{1-3z}}\ln{1\over 1-2z}$的近似值(主导项)。
Q5.3
对于以下每个组合结构,请指出它描述排列、二进制字符串、循环、错位排列还是以上都不是。\(A = Z+A\times A\) \(A = SET(CYC_{>1}(Z)\) \(A = Z+E+Z\star A\) \(A = CYC_{>0}(Z)\) \(A = A\times(Z_0+Z_1)\) \(A = SEQ(Z)\)
Q5.4
(A. Yin)有多少种方式可以通过每次走 1 步或 3 步来爬$N$阶楼梯?给出形式为$c_1\cdot c_2^N$的答案,其中$c_1$和$c_2$是常数(并给出常数的近似值)。
6. 树
原文:
aofa.cs.princeton.edu/60trees译者:飞龙
本章研究了许多不同类型的树的性质,这些树是许多实际算法中隐式和显式出现的基本结构。我们的目标是提供对树的组合分析广泛文献中的结果的访问,同时为大量算法应用奠定基础。
6.1 二叉树
定义。 一棵二叉树要么是一个外部节点,要么是连接到称为该节点的左子树和右子树的有序二叉树对的内部节点。
定理。(二叉树的枚举)具有$N$个内部节点和$N+1$个外部节点的二叉树的数量由卡特兰数给出:\(T_{N}={1\over N+1}{2N\choose N} = {4^N\over\sqrt{\pi N³}}\Bigl(1+O({1\over N})\Bigr)。\) 证明。
6.2 森林和树
在二叉树中,没有节点有超过两个子节点。这个特征使得如何在计算机实现中表示和操作这样的树变得明显,并且与“分而治之”算法自然相关,将问题分解为两个子问题。然而,在许多应用中(以及在更传统的数学用法中),我们需要考虑一般树:
定义。 一棵树(也称为一般树)是一个节点(称为根)连接到一系列不相交树的序列。这样的序列称为森林。
我们使用与二叉树相同的命名法:节点的子树是其子节点,根节点没有父节点,等等。对于某些计算,树比二叉树更适合作为模型。
定理。(一般树的枚举)设$G_N$是具有$N$个节点的一般树的数量。那么$G_N$恰好等于具有$N-1$个内部节点的二叉树的数量,并由卡特兰数给出:\(G_N=T_{N-1}={1\over N}{2N-2\choose N-1}。\) 证明。(通过符号方法)森林要么为空,要么是一系列树:\(\cal F = \epsilon + \cal G + (\cal G\times\cal G) + (\cal G\times\cal G\times\cal G) + (\cal G\times\cal G\times\cal G\times\cal G) + \ldots\) 这直接转化为 \(F(z) = 1 + G(z) + G(z)² + G(z)³ + G(z)⁴ + \ldots = {1\over 1-G(z)}。\) 森林和树之间明显的一一对应关系(去掉根)意味着$zF(z) = G(z)$,因此 \(G(z)={z\over 1-G(z)}。\) 因此$G(z)-G(z)²=z$,因此$G(z)=zT(z)$,因为两个函数满足相同的函数方程。也就是说,具有$N$个节点的树的数量等于具有$N$个外部节点的二叉树的数量。
6.3 与树和二叉树的组合等价
以下组合对象都是一一对应的,因此由卡特兰数计数。
具有$N$个外部节点的二叉树。
有$N$个节点的树。
合法的$N$对括号的序列。
三角形$N$-边形。
赌徒的破产序列。
这里是这些对象在小$N$时的例子:

6.4 树的性质
定义。 在一棵树$t$中:
大小 \(|t|\) 是其节点数
节点的级别是其与根的距离(根在级别 0)
路径长度 \(\pi(t)\) 是$t$中每个节点的级别之和
高度 \(\eta(t)\) 是所有节点中的最大级别
叶子是没有子节点的节点
定义。 在二叉树$t$中:
大小 \(|t|\) 是其内部节点数
节点的级别是其与根的距离(根在级别 0)
内部路径长度 \(\pi(t)\) 是$t$中每个内部节点的级别之和
外部路径长度 \(\xi(t)\) 是$t$中每个外部节点的级别之和
高度 \(\eta(t)\) 是所有外部节点中的最大级别
叶子是(内部)节点,两个子节点都是外部节点
递归定义。 通常方便使用递归定义来处理树参数。在二叉树$t$中,如果$t$是外部节点,则我们定义的参数都为$0$;否则,如果$t$的根是内部节点,左右子树分别用$t_l$和$t_r$表示,则我们有以下递归公式:\(\eqalign{ |t|&=|t_l|+|t_r|+1\cr \pi(t)&=\pi(t_l)+\pi(t_r)+|t|-1\cr \xi(t)&=\xi(t_l)+\xi(t_r)+|t|+1\cr \eta(t)&=1+\max(\eta(t_l), \eta(t_r)).\cr}\) 很容易看出这些与上面的定义是等价的。这些形式为我们在分析树参数时推导相关生成函数的函数方程提供了基础。它们还有助于关于参数之间关系的归纳证明。
示例。 任何二叉树$t$中的路径长度满足$\xi()=\pi()+2|t|\(。从方程\)\pi(t)=\pi(t_l)+\pi(t_r)+|t|-1$减去方程$\xi(t)=\xi(t_l)+\xi(t_r)+|t|+1$立即提供了归纳证明的基础。
在算法分析中,我们特别关注这些参数的平均值,对于各种类型的“随机”树。本章的主要讨论话题之一是这些数量如何与基本算法相关,以及我们如何确定它们的期望值。随机二叉树的路径长度和高度都比随机森林大。本章的主要目标之一是准确量化这些以及类似的观察结果。
6.5 树算法示例
树对算法分析的研究不仅因为它们隐含地模拟了递归程序的行为,而且因为它们明确地涉及到许多广泛使用的基本算法。对于典型应用,我们关注树的路径长度和高度。当然,为了考虑这些参数的平均值,我们需要指定一个定义“随机”树的模型。对于树遍历和表达式求值(或者更一般地说,递归程序执行)等应用,值得考虑的是所有树等可能出现的模型。对于这些应用,我们研究所谓的卡特兰模型,其中每个大小为$N$的二叉树$T_N$或大小为$N+1$的一般树都以相等概率出现。这不是唯一的可能性---在许多情况下,树是由外部数据引起的,其他随机模型更合适。接下来,我们考��一个特别重要的例子。
6.6 二叉搜索树
二叉树最重要的应用之一是二叉树搜索算法,这是一种明确使用二叉树来提供计算机科学中基本问题高效解决方案的方法。有关详细信息,请参阅《算法,第 4 版》中的第 3.2 节。二叉树搜索的分析说明了在所有树等可能出现和基础分布不均匀的模型之间的区别。这两者的并置是本章的基本概念之一。
定义。 二叉搜索树是一棵与内部节点关联键的二叉树,满足每个节点中的键都大于或等于其左子树中的所有键,且小于或等于其右子树中的所有键的约束。
我们在随机排列模型下研究二叉搜索树,假设树中每个键的排列(都不同)都是等可能的输入。这个模型在许多实际情况下得到验证。许多不同的排列可能映射到同一棵树。如果键以随机顺序插入到最初为空的树中,则并非每棵树都是等可能出现的。
构建特定树的成本与其内部路径长度成正比,因为节点一旦插入就不会移动,而节点的级别恰好是插入所需节点的数量。因此,构建树的成本对于可能导致其构建的每个排列都是相同的。
我们可以通过为所有$N!$排列添加导致构建的树的内部路径长度(累积成本),然后除以$N!$来获得平均构建成本。累积成本也可以通过为所有树添加导致构建的排列数和内部路径长度的乘积来计算。这是我们在最初为空的树中进行$N$次随机插入后预期的平均内部路径长度,但它不与随机二叉树的平均内部路径长度相同,在卡特兰模型下所有树等可能的情况下。这需要单独的分析。我们将在下一节考虑卡特兰树的情况,以及在第 6.8 节考虑二叉搜索树的情况。事实上,更平衡的树结构,搜索和构建成本较低,比搜索和构建成本较高的树结构更有可能发生。在分析中,我们将量化这一点。
6.7 卡特兰树中的平均路径长度
如果每个$N$个内部节点的树被认为是等可能的,那么树的平均路径长度是多少?我们对这个重要问题的分析是我们在第三章介绍的分析组合结构参数的一般方法的典型代表:
定义一个双变量生成函数(BGF),其中一个变量标记树的大小,另一个变标记内部路径长度。
推导 BGF 满足的函数方程,或其相关的累积生成函数(CGF)。
提取系数以得出结果。
在具有$N$个节点的随机二叉卡特兰树中,左子树有$k$个节点(右子树有$N-k-1$个节点)的概率是$T_T_/T_N$(其中$T_N={2N\choose N}\bigm / (N+1)$是第$N$个卡特兰数)。分母是可能的$N$个节点树的数量,分子计算了通过在左侧使用具有$k$个节点的任何树和在右侧使用具有$N-k-1$个节点的任何树来制作$N$个节点树的方法数。我们将这种概率分布称为卡特兰分布,如图所示。

关于分布的一个引人注目的事实之一是,随着$N$的增长,其中一个子树为空的概率趋于一个常数:它是$2T_/T_N\sim 1/2$。事实上,随机二叉树中大约一半的节点可能有一个空子树,因此这样的树并不特别平衡。使用卡特兰分布,我们可以类似于我们对快速排序分析的方式分析路径长度。随机二叉卡��兰树中的平均内部路径长度由递归$$Q_N=N-1+\sum_{1\le k\le N}{T_T_\over T_N}(Q_+Q_) \qquad\hbox{for \(N>0\)} $$描述,其中$Q_0=0$。这个递归背后的论点是一般的,并且可以通过用其他分布替换卡特兰分布来分析其他随机二叉树结构,例如,如下所讨论的,二叉搜索树的分析导致均匀分布(每个子树大小以$1/N$的概率发生)并且递归与快速排序递归匹配。继续沿着快速排序分析的方向是可能的,但会导致复杂的计算,可以通过 CGF 轻松避免。
定理。(二叉树中的路径长度)具有$N$个内部节点的随机二叉树的平均内部路径长度为$${(N+1)4N\over{2N\choose N}}-3N-1 = N\sqrt{\pi N}-3N+O(\sqrt)。*证明。*定义 CGF$$C_T(z) \equiv P_u(1,z) = \sum_{t\in \cal T}\pi(t)z{|t|}。\(平均路径长度为$[z^n]C_T(z)/[z^n]T(z)。$二叉树的递归定义立即导致\)\eqalign{C_T(z) &= \sum_{t_l\in \cal T}\sum_{t_r\in\cal T} (\pi(t_l ) + \pi(t_r ) + |t_l | + | t_r|) z^{|t_l| + |t_r| + 1}\cr &= 2zC_T(z)T(z)+2z²T(z)T^\prime(z),\cr} $$ 这导致解$$C_T(z) = {2z²T(z)T^\prime(z)\over 1-2zT(z)。$$现在,\(T(z)=(1-\sqrt{1-4z})/(2z)\),所以$1-2zT(z)=\sqrt{1-4z}$,$zT^\prime(z)=-T(z)+1/\sqrt{1-4z}。$代入这些给出显式表达式$$zC_T(z) = {z \over 1-4z} - {1 -z\over \sqrt {1-4z}} + 1,$$展开得到所述结果。
这个结果由下面显示的大型随机二叉树所说明:渐近地,一个大树大致适合于一个$\sqrt\(乘以\)\sqrt$的正方形。

定理。(一般树中的路径长度)具有$N$个内部节点的随机一般树的平均路径长度为$${N\over 2}\biggl({4^\over{2N-2\choose N-1}}-1\biggr) = {N\over2}\bigl(\sqrt{\pi N}-1\bigr)+O({\sqrt})。*证明。*按照上述步骤进行:\(\eqalign{ C_G(z)&\equiv\sum_{t\in\cal G}{\pi(t)}z^{|t|}\cr &=\sum_{k\ge0}\sum_{t_1\in\cal G}\dots\sum_{t_k\in\cal G} (\pi(t_1)+\cdots+\pi(t_k)+|t_1|+\ldots+|t_k|) z^{|t_1|+\ldots+|t_k|+1}\cr }\) 这(最终)导致方程$$C_G(z) = {zC_G(z)+z²G^\prime(z)\over(1-G(z))²}。$$简化后得到$$C_G(z)={1\over2}{z\over1-4z}-{1\over2}{z\over\sqrt{1-4z}}。$$其中$G(z)=zT(z)=(1-\sqrt{1-4z})/2$是生成一般树的卡特兰生成函数。展开$[zN]C_G(z)/[zN]G(z)$得到所述结果。
6.8 二叉搜索树中的路径长度
在二叉搜索树中路径长度的分析实际上是对排列的性质的研究,而不是对树的研究,因为我们从一个随机排列开始。在第七章中,我们将详细讨论排列作为组合对象的性质。我们在这里提到 BST 的路径长度分析不仅是因为将其与刚刚给出的随机树的分析进行比较很有趣,而且它几乎与我们的快速排序分析完全相同。
定理。(BST 的构造成本)通过将$N$个不同键以随机顺序插入到初始空树中构造二叉搜索树的过程中涉及的平均比较次数(随机二叉搜索树的平均内部路径长度)为$$2(N+1)(H_{N+1}-1)-2N\approx 1.386 N\lg N - 2.846 N$$,方差渐近为$(7-2\pi²/3)N²$。
渐近地,一个大的 BST 大致适合于一个$\log$乘以$N/\log$的矩形。

6.9 随机树的可加参数。
我们对路径长度的分析以一种直接的方式推广,以涵盖任何可以与树递归对齐的可加参数,如下所示:树的参数值是子树的参数值之和再加上根的额外项。可加参数的示例包括大小、路径长度和叶子节点数。
6.10 高度
生成函数也可以用于研究树的高度,但与可加参数相比,分析要复杂得多。
6.11 关于树属性的平均情况结果总结
| 类型 | 路径长度 | 高度 | 叶子节点 |
|---|---|---|---|
| 树 | \({N\over 2}\sqrt{\pi N} -{N\over 2}+ O(\sqrt{N})\) | \(\sqrt{\pi N} +O(1)\) | \(N\over2\) |
| 二叉树 | \(N\sqrt{\pi N}-3N+O(\sqrt{N})\) | \(2\sqrt{\pi N} +O(N^{1/4 + \epsilon})\) | \(\sim{N\over4}\) |
| BST | \(2N\ln N+(2\gamma-4)N+O(\log N)\) | \(\sim 4.31107...\ln N\) | \(\qquad {N+1\over3}\qquad\) |
6.12 拉格朗日反演
6.13 有根无序树
在算法分析中出现的四种主要类型的树形成了一个层次结构:
自由树,一种无环连通图。
根树,具有一个明确根节点的自由树。
有序树,根节点的子树顺序很重要的根树。
二叉树是一种每个节点具有度为 0 或 2 的有序树。
到目前为止,我们一直在研究有序树和二叉树。
在我们使用的命名法中,形容词描述了将每种类型的树与层次结构中上面的树区分开的特征。通常也使用将每种类型与层次结构中下面的树分开的命名法。因此,我们有时将自由树称为未根树,根树称为无序树,有序树称为一般的卡特兰树。
这里是具有五个节点的树的层次结构示例。图中展示了 14 种不同的五节点有序树,并且它们进一步被组织成使用小阴影和较大开放矩形的等价类。有 9 种不同的五节点根树(阴影矩形中的树是相同的根树),以及 3 种不同的五节点自由树(大矩形中的树是相同的自由树)。

鉴于文献中发现的术语种类繁多,有必要再多说几句关于命名。有序树通常被称为平面树,无序树则被称为非平面树。术语平面之所以被使用,是因为这些结构可以通过平面上的连续操作相互转换。尽管这种术语广泛使用,但我们更喜欢有序,因为它对于计算机表示具有自然的含义。术语定向通常用来指出根节点是有区别的,因此边缘朝向根节点;如果从上下文中不明显涉及到根节点,我们更倾向于使用术语根。
随着定义变得更加严格,被视为不同的树的数量变得更多,因此,对于给定大小,根树比自由树更多,有序树比根树更多,二叉树比有序树更多。事实证明,根树数量与自由树数量之间的比例与$N$成正比;有序树与根树之间的相应比例随$N$呈指数增长;而二叉树与有序树之间的比例是一个常数。这些枚举结果是经典的,可以通过我们一直在考虑的分析方法得到(我们在本章前面考虑了有序树和二叉树的分析),并总结在下表中。表中的常数是近似值,\(\alpha\approx 2.9558\)。
| 类型 | 具有 N 个节点的树的数量 |
|---|---|
| 自由 | \(\sim .5350\cdot\alpha^N / N^{5/2}\) |
| 根 | \(\sim .4399\cdot\alpha^N / N^{3/2}\) |
| 有序 | \(\sim .1410\cdot 4^N / N^{3/2}\) |
| 二叉 | \(\sim .5640\cdot 4^N N^{3/2}\) |
6.14 标记树
6.15 其他类型的树
通常方便地对树施加各种局部和全局限制,例如,以适应特定应用的要求或尝试排除退化情况。从组合学的角度来看,任何限制都对应于一类新的树,需要解决一组新的问题来枚举这些树并了解它们的统计特性。
该表显示了八种不同类型的树的示例:3 元和 4 元,3 受限和 4 受限,2-3 和 2-3-4,以及红黑树和 AVL 树。

选定练习
6.2
具有$N$个内部节点的二叉树中,根节点的两个子树都非空的比例是多少?对于$N=4$,答案是$4/14$。
6.6
具有$N$个节点的森林中没有由单个节点组成的树的比例是多少?对于$N=1,2,3,$和$4$,答案分别为$0, 1/2, 2/5,$和$3/7$。
6.18
[克拉夫特等式] 设$k_j$是二叉树中第$j$级的外部节点数。序列${ k_0, k_1, \ldots, k_h }$(其中$h$是树的高度)描述了树的轮廓。证明只有当$\sum_j2^{-k_j} = 1$时,整数向量才描述了二叉树的轮廓。
6.19
给出具有$N$个节点的一般树的路径长度的紧密上下界。
6.20
给出具有$N$个内部节点的二叉树的内部和外部路径长度的紧密上下界。
6.27
对于$N=2n-1$,如果所有$N!$个键插入序列等可能,那么构建完全平衡树结构(所有$2n$个外部节点在第$n$级)的概率是多少?
6.42
二叉树中的内部节点分为三类:它们有两个、一个或零个外部子节点。在具有$N$个节点的随机二叉卡特兰树中,每种类型的节点占比是多少?
6.43
二叉树中的内部节点分为三类:它们有两个、一个或零个外部子节点。在具有$N$个节点的随机二叉搜索树中,每种类型的节点占比是多少?
复习问题
Q6.1
给出具有$n$个节点的一元二元树数量的渐近近似(主导项)。
Q6.2
按照它们在$n$较大时可能的高度的顺序排列这些树:(a)随机$n$_ 节点卡特兰树(b)随机$n$_ 节点二叉搜索树(c)随机$n$_ 节点 AVL 树
Q6.3
二进制 trie 结构是一种具有两种外部节点(空和非空)的二叉树,限制条件是空外部节点不出现在叶子中。当$n$为 2、3 和 4 时,具有$n$个外部节点的不同二进制 trie 结构的数量分别为 1、4 和 17。从构造$$T = Z_V+(Z_I\times T\times T)+2(Z_N\times Z_I\times (T - Z_V)) $$开始,推导出具有$n$个外部节点的二进制 trie 结构数量的渐近近似。
提示:\(\sqrt{12z²-8z+1} = {\sqrt{1-2z}\over(1-6z)^{-1/2}}\)。
Q6.4
给出所有节点度数均为偶数的树的 OGF 方程。
Q6.5
(N. 林登斯特劳斯)证明卡特兰树右分支的平均长度约为 3。
7. 排列
原文:
aofa.cs.princeton.edu/70permutations译者:飞龙
本章概述了排列(数字1到N的排序)的组合性质,并展示了它们与基本且广泛使用的排序算法之间的自然关系。
7.1 排列的基本性质
7.2 排列算法
7.3 排列的表示
7.4 枚举问题
7.5 使用 CGF 分析排列的属性
7.6 逆序和插入排序
7.7 从左到右的最小值和选择排序
7.8 循环和原地排列
7.9 极值参数
选定练习
7.3
$2n$个元素的排列中,有正好两个长度为$n$的循环的排列有多少个?有多少个长度为$2$的$n$个循环的排列?
7.4
$n$个元素的排列中,哪些排列具有不同循环表示的最大数量?
7.8
编写一个计算给定排列对应的规范循环表示的程序。
7.9
编写一个计算给定规范循环表示对应的排列的程序。
7.10
给出一个计算给定排列对应的逆序表的高效算法,以及计算给定逆序表对应的排列的另一个算法。
7.24
证明大小为$N$的对合数满足递推关系$$b_{N+1} = b_N + Nb_\quad{\rm for}\ N>0{\rm \ with\ } b_0=b_1=1.$$
7.26
找到仅由偶数长度循环组成的排列的 EGF。推广以找到仅由可被$t$整除的长度循环组成的排列的 EGF。
7.27
通过对关系$(1-z)D(z)=e^z$进行微分并设置系数相等,得到$N$个元素的错位排列数满足的递推关系。
7.29
$N$个元素的排列是从元素子集中形成的序列。证明排列的 EGF 是$e^z/(1-z)$。将系数表示为简单的和,并从组合角度解释该和。
7.45
找到长度为$N$的所有对合数中逆序的总数的 CGF。利用这一点找到对合数中逆序的平均数量。
7.61
使用生成函数的渐近性(参见第 5.5 节)或直接论证,证明随机排列具有长度为$k$的$j$个循环的概率渐近于泊松分布$e^{-\lambda}\lambda^j/j!\(,其中\)\lambda=1/k$。
复习问题
Q7.1
随机大小为$n$的排列恰���有 2 个循环的概率是多少?
Q7.2
识别大小为 1000 的随机排列的最可能和最不可能事件。(a) 没有长度为 2 的幂的循环 (b) 没有长度小于 5 的循环 (c) 没有长度为 1、2、3 或 7 的循环 (d) 没有长度大于 400 的循环
Q7.3
给出$N$个元素的排列中具有偶数个单元循环的比例的~近似值。
Q7.4
给出$N$个元素的排列中具有奇数个单元循环的比例的~近似值。
8. 字符串
原文:
aofa.cs.princeton.edu/80strings译者:飞龙
本章研究字符串的基本组合特性,即从固定字母表中提取的字符或字母序列,并介绍处理字符串的算法,从计算理论核心的基本方法到具有许多重要应用的实用文本处理方法。
8.1 字符串搜索
8.2 比特串的组合特性
8.3 正则表达式
8.4 有限状态自动机和 Knuth-Morris-Pratt 算法
8.5 上下文无关文法
8.6 前缀树
8.7 前缀树算法
8.8 前缀树的组合特性
8.9 更大的字母表
选定练习
8.1
给出满足$[z^N]B_P(z)$的两个递归。
8.2
随机比特串应该有多长才能确保至少有三个连续的 0 的概率为 99%?
8.3
随机比特串应该有多长才能确保至少有 32 个连续的 0 的概率为 50%?
8.6
通过考虑没有连续两个 0 的比特串,评估涉及斐波那契数的以下和:\(\sum_{j\ge0} F_j/2^j\)。
8.14
假设一只猴子在一个有 32 个键的键盘上随机输入。在猴子找到短语THE QUICK BROWN FOX JUMPED OVER THE LAZY DOG之前,预期输入的字符数是多少?
8.15
假设一只猴子在一个有 32 个键的键盘上随机输入。在猴子找到短语TO BE OR NOT TO BE之前,预期输入的字符数是多少?
8.22
假设一只猴子在一个有 2 个键的键盘上随机输入。在猴子找到一个包含$2k$个交替 0 和 1 的字符串之前,预期输入的位数是多少?
8.40
假设一只猴子在一个有 26 个字母A到Z、符号+、*、(和)、一个空格键和一个句号的 32 键键盘上随机输入。在猴子找到一个合法的正则表达式之前,预期输入的字符数是多少?假设空格可以出现在正则表达式的任何位置,并且合法的正则表达式必须被括号括起来,并且在末尾只有一个句号。
8.42
有${8\choose5}=56$种不同的五个三位比特串集合。哪个前缀树与这些集合中的大多数相关联?哪个与最少相关联?
8.44
有多少不同的前缀树有$N$个外部节点?
8.45
在“随机”前缀树中,外部节点中有多少比例是空的(假设每个不同的前缀树结构出现的可能性相等)?
8.52
证明$A_N/N$等于$1/\ln 2$加上一个波动项。
8.53
编写一个计算$A_N$在$N<10⁶$时精确到$10^{-9}$的程序,并探索$A_N/N$的振荡特性。
8.57
解决定理 8.9 证明中给出的$p_N$的递归,直到振荡项。\(p_N= {1\over 2^{N}} \sum_k{N\choose k}p_k\quad \hbox{for}\ N>1\quad \hbox{with}\ p_0=0\ \hbox{and}\ p_1 = 1\)
复习问题
Q8.1
按照在随机比特串中等待时间的期望值对这些模式进行排名(从低到高)。(a) 00000 (b) 00001 (c) 01000 (d) 01010 (e) 10101
Q8.2
给出不包含模式 01010 的比特串的 OGF。
Q8.3
给出不包含模式 1011 的比特串的 OGF。
Q8.4
(J. Chen)使用 BGFs 计算长度为$N$的随机二进制字符串中 00 出现的预期次数。(按照惯例,具有$t$个连续 0 的子串有$t-1$个 00 出现次数。)
9. 单词和映射
原文:
aofa.cs.princeton.edu/90maps译者:飞龙
本章涵盖了单词(从M-字母表中的N-字母字符串)的全局属性,这在经典组合学中得到了很好的研究(因为它们模拟了独立伯努利试验的序列)和经典应用算法学中得到了很好的研究(因为它们模拟了哈希算法的输入序列)。本章还涵盖了随机映射(从N-字母表中的N-字母单词)并讨论了与树和排列的关系。
9.1 使用分离链接进行哈希
9.2 单词的基本属性
9.3 生日悖论和优惠券收集者问题
9.4 占用限制和极值参数
9.5 占用分布
9.6 开地址哈希
9.7 映射
9.8 整数因子分解和映射
选定练习
9.5
对于$M=365$,需要多少人才能确保两个人生日相同的概率为 99%?
9.22
给出一个(对于$M=365$)三个人生日相同的概率的图表。
9.23
对于$M=365$,需要多少人才能确保三个人生日相同?四个人呢?
9.28
当 100 个球随机分布在 100 个瓮中时,一个瓮得到所有球的概率是多少?
9.29
当 100 个球随机分布在 100 个瓮中时,每个瓮都得到一个球的概率是多少?
9.37
找到$[zn]e{\alpha C(z)}$,其中$C(z)$是 Cayley 函数(请参见本章末尾的讨论和本章 9.7 节)。5
9.38
(``阿贝尔二项式定理。'')使用前一个练习的结果和恒等式$e^{(\alpha+\beta)C(z)}=e^{\alpha C(z)}e^{\beta C(z)}\(证明$\)(\alpha+\beta)(n+\alpha+\beta) =\alpha\beta\sum_k{n\choose k}(k+\alpha)(n-k+\beta)^.$$
9.53
没有重复整数的映射是一个排列。给出一个有效的算法来确定一个映射是否是一棵树。
9.58
描述部分映射的图结构,其中点的图像可能未定义。建立相应的 EGF 方程,并检查大小为$N$的部分映射的数量为$(N+1)^N$。
9.60
生成 100 个大小为 100、1000 和 10000 的随机映射,并经验性地验证大约 48%的节点在最大树中,大约 76%的节点在最大循环中,在大小为$N$的随机映射中,最长尾部、循环和 rho 路径中的节点数分别约为$1.74\sqrt\(、\).78\sqrt$和$2.41\sqrt$。
9.61
使用 Floyd 的方法测试您机器上的随机数生成器是否存在短循环。
9.99
证明大小为 N 的随机映射没有单例循环的概率是$\sim 1/e$,与排列的概率相同(!)。
复习问题
Q9.1
给出没有单例循环的随机映射的 EGF。将你的答案表示为 Cayley 函数的函数$C(z) = e^{C(z)}$。
Q9.2
这个问题涉及枚举每个字符出现正好两次或不出现的映射。这样的映射必须是偶数长度。长度为 2 的映射有 2 个(11 和 22),长度为 4 的映射有 36 个。通过直接的组合论论证,很容易得出显式形式$$C_{2n} = {2n\choose n}{(2n)!\over 2n}.$$给出 EGF 的显式公式$$C(z) = \sum_{n\ge0}{C_{2n}\over(2n)!}z{2n}$$和$C_{2n}$的渐近估计(~近似)。额外学分:通过解析组合学给出完整推导。
Q9.3
给出元素具有多个连接组件的映射比例的~近似。
组合数学
1. 组合结构和普通生成函数
译者:飞龙
I.1 符号枚举方法
I.2 可接受的构造和规范整数组合
I.3 整数组合和分区
I.4 单词和正则语言
I.5 树结构
I.6 附加构造
I.7 视角
选定练习
注释 I.23
爱丽丝、鲍勃和编码界限。 爱丽丝想要通过一个传输0,1位的通道(一根电线,一根光纤)向鲍勃传递$n$位信息,但是任何11的出现都会终止传输。因此,她只能在通道上发送她的消息的编码版本(其中编码长度为$\ell\ge n$),不包含模式11。
这是一个第一个编码方案:给定消息$m=m_1m_2\cdots m_n$,其中$m_j\in{{\sf 0,1}}\(,应用替换:\)\sf 0\mapsto \sf 00$ ��� \(\sf 1\mapsto\sf 10\);通过发送$\sf 11$来终止传输。这个方案的$\ell=2n+O(1)$,我们说它的速率为 2。能否设计出速率更好的编码?速率是否可以无限接近 1,渐近地?
让$\cal C$是允许的码字类。对于长度为$n$的单词,只有当存在从${0,1}^n$到$\bigcup_L \cal C_j$的一一映射时,长度为$L\equiv L(n)$的码字才是可实现的,即$2n\le \sum_^L C_j$。找到$\cal C$的 OGF,并使用它来展示[ L(n) \ge \lambda n +O(1), \qquad \lambda=\frac{1}{\log_2 \varphi}\doteq 1.440420, \quad \varphi=\frac{1+\sqrt{5}}{2}.] 因此,没有码字可以实现比 1.44 更好的速率;即,至少会有 44%的损失是不可避免的。
注释 I.43
Cayley-Polya 数的快速确定。 使用$H(z)$的对数微分为$H_n$提供一个可以作为计算$H_n$的基础的递归,其时间复杂度为$n$的多项式时间。(注:类似的技术也适用于分区数$P_n$。)
选定实验
程序 I.1
确定四枚硬币的选择,使得更换一美元的方式数量最大化。
程序 I.2
编写一个估计 Cayley 数增长率(\(H_n/H_{n-1}\))的程序。参见注释 I.43。
程序 I.3
编写一个估计分区数增长率(\(P_n/P_{n-1}\))的程序。参见注释 I.43。
网络练习
I.1
(R. Brott) 给出一个~的近似值,表示将$N$表示为 1、2 和 4 的和(无序)的方式的数量。例如,对于$N=4$,有四种这样的方式:1+1+1+1,1+1+2,2+2 和 4。
I.2
(D. Carter) 加权树是一棵根为根的有序树,其中每个节点被分配一个严格正整数权重。找到权重为$N$的加权树的总数的~近似值。
2. 标记结构和指数生成函数
译者:飞龙
II.1 有标签类
II.2 可接受的有标签构造
II.3 满射、集合划分和单词
II.4 对齐、排列和相关结构
II.5 有标签树、映射和图
II.6 附加构造
II.7 视角
选定练习
注 II.11
球在室之间切换:埃伦费斯特模型。 考虑一个包含两个室 \(A\) 和 \(B\)(也经典地称为“urns”)的系统。有 \(N\) 个可区分的球,最初,室 \(A\) 中包含所有球。在任意时刻 \(\frac{1}{2},\frac{3}{2},\ldots\),允许一颗球从一个室转移到另一个室。设 \(E_{n}^{[\ell]}\) 是导致在时刻 \(n\) 时室 \(A\) 包含 \(\ell\) 个球的可能演变数量,\(E^{[\ell]}(z)\) 是相应的 EGF。证明 \(E^{[\ell]}(z)=\binom{N}{\ell}(\cosh z)^\ell (\sinh z)^{N-\ell}, \qquad E^{[N]}(z)=(\cosh z)^N\equiv 2^{-N}(e^z+e^{-z})^N.\) [提示:EGF \(E^{[N]}\) 枚举每个原像具有偶数基数的映射。] 特别地,证明在时间 \(2n\) 时 urn \(A\) 再次充满的概率是 \(\frac{1}{2^NN^{2n}}\sum_{k=0}^{N} \binom{N}{k}(N-2k)^{2n}.\) 这个著名的模型由 Paul 和 Tatiana Ehrenfest 于 1907 年引入,作为热传递的简化模型。它有助于解决热力学中不可逆性(\(N\to\infty\) 的情况)和经历遍历变换的系统再现性之间的明显矛盾(\(N\) 的情况
注 II.31
三角函数的组合数学。 将 \(\tan{z\over 1-z}\)、\(\tan\tan z\) 和 \(\tan(e^z-1)\) 解释为组合类的 EGF。
选定实验
程序 II.1
编写一个程序来模拟埃伦费斯特模型(见注 II.11),并用它来绘制在 urn A 中的球数分布,当开始时 urn A 中有 \(10³\) 个球,urn B 中没有球,经过 \(10³\)、\(10⁴\) 和 \(10⁵\) 步后。
网页练习
II.1
(E. Levin)使用解析组合数学计算均匀随机选择的元素排列不包含 2-循环的概率的 ~-近似。
II.2
找到一个关于有标签的三元树(根据顺序,每个节点有 0 或 3 个子节点)数量的 ~-近似,其标签在每条路径上递增。
3. 组合参数和多变量生成函数
译者:飞龙
III.1 双变量生���函数(BGFs)简介
III.2 双变量生成函数和概率分布
III.3 继承参数和普通 MGFs
III.4 继承参数和指数 MGFs
III.5 递归参数
III.6 完全生成函数和离散模型
III.7 附加构造
III.8 极值参数
III.9 视角
选定练习
注 III.17
Cayley 树中的叶子和节点度分布。对于 Cayley 树,证明具有标记叶子数$u$的双变量 EGF 是方程的解$$T(z,u)=uz+z(e^{T(z,u)}-1).$$然后证明随机 Cayley 树中叶子的平均数量渐近于$ne^{-1}$,更一般地,随机大小为$n$的 Cayley 树中出度为$k$的节点的平均数量渐近于$$n\cdot e^{-1}, {1\over k!}.$$度数因此近似描述为速率为 1 的泊松分布。
注 III.21
Bhaskara Acharya 之后(约 1150 年)。考虑所有使用数字 1 一次,数字 2 两次,...,数字 9 九次形成的十进制数字。这些数字都有 45 位。计算它们的和$S$,并惊讶地发现$S$等于45875559600006153219084769286399999999999999954124440399993846780915230713600000. 这个数字有一长串的 9(还有更多隐藏的 9!)。有一个简单的解释吗?这个练习受到印度数学家 Bhaskara Acharya 在 1150 年左右发现的多项式系数的启发。
选定实验
Program III.1
编写一个程序,生成大小为$N$的 1000 个随机排列,其中$N$ = \(10³\),\(10⁴\),...(尽可能多),并绘制循环数的分布,验证平均值集中在$H_N$处。
网页练习
III.1
(Exercise 5.13 in Analysis of Algorithms) 一个长度为$N$的随机比特串中没有 00 的平均 1 比特数是多少?
III.2
(E. Neyman)使用符号方法和 OBGF 计算具有$N$位数字的随机三进制(基数 3)数字中数字之和的平均值。对于$N=1$,结果是(0 + 1 + 2)/3 = 1。对于$N=2$,有九种可能性 00, 01, 02, 10, 11, 12, 20, 21, 22,结果是(0 + 1 + 2 + 1 + 2 + 3 + 2 + 3 + 4)/9 = 2。注意:这不是解决这个问题的最简单方法——这个问题的目的是测试你对这种技术的理解。
III.3
(D. Mavrides)推导一个整数组合集合中 1 的 OBGF,并用它计算随机选择的$N$的组合中预期的 1 的数量。
III.4
(M. Bahrani)比特串中的一个run是一串连续相同比特的最大序列。例如,字符串 11010100001 有 7 个 runs,长度分别为 2、1、1、1、1、4、1。长度为$N$的随机二进制串中平均 runs 的数量是多少?列出相应的水平和垂直 OBGFs。二进制串中 runs 的数量分布是否集中?
III.5
(T. Ratigan)推导一个组合结构,导致涉及具有$n$节点的 Catalan 树中度为$d$的节点数量的 OBGF 的方程。解出$d=1$的方程,并给出随机$n$节点 Catalan 树中度为 1 的节点数量的渐近估计。
III.6
(M. Tyler)将单位$n$-超立方体定义为点集$[0,1]^n \subset \mathbb^n$。例如,单位 0-超立方体是一个点,而单位 3-超立方体是单位立方体。将单位$n$-超立方体的*\(k\)-面*定义为$n$-超立方体外部的$k$-超立方体的副本。更正式地说,单位$n$-超立方体的$k$-面是形式为$\prod_{1\le i\le n}S_i$的集合,其中对于每个$i$在$1$和$n$之间,$S_i$要么是${0}\(,要么是\){1}\(,要么是\)[0,1]$,并且恰好有$k$个指数$i$使得$S_i = [0,1]$。推导出一种组合构造,导致一个 OBGF 和单位$n$-超立方体中$k$-面的数量的显式公式。使用 OBGF 推导出单位$n$-超立方体的随机面的维度的期望值。
III.7
(F. Dong)考虑从{1,...,N}到{1,...,k}的随机映射。对于$k>2$,证明偶数大小的原像数量在期望上大于奇数大小的原像数量。
III.8
(A. Alag)给出一个~近似值,用于$N$的随机组合中偶数项的平均数量。
4. 复分析,有理和亚纯渐近
原文:
ac.cs.princeton.edu/40complex译者:飞龙
IV.1 生成函数作为解析对象
IV.2 分析函数和亚纯函数
IV.3 奇点和系数的指数增长
IV.4 闭包性质和可计算上界
IV.5 有理和亚纯函数
有理函数的解析传递定理(常见情况)
如果$h(z)\(是一个在 0 处解析且具有最小模数的极点\)\alpha=1/\beta$,重数为$M$ 大于所有其他最小模数极点的重数,那么$$[zn]h(z) \sim c\betann^\quad\hbox{其中}\quad c = {1\over(M-1)!\alpha^M}\lim_{z\to\alpha} (\alpha - z)^Mh(z).$$
请参见第 256 页上的定理 IV.9 和注 IV.26,注意在勘误页上列出的文本错误(抱歉!)。
例子. 用便士、镍币、一角硬币和二角硬币找零$n$美分的方式数量为$$[zn]{1\over(1-z)(1-z⁵)(1-z{10})(1-z^{25})} ~\sim {n³\over 3!}{1\over 1\cdot 5 \cdot 10 \cdot 25}={n³\over 7500}\(,因为$z=1$是一个重数为 4 的极点,且\)\lim_{z\to 1}{1-z\over 1-zt} = \lim_{z\to 1}{1\over 1 + z + z² + \ldots + z} = {1\over t}.$$
请参见第 258 页上的命题 IV.2 和讲座 5 中的幻灯片 46。
对于$h(z) = f(z)/g(z)$,计算常数的另一种方法是使用洛必达法则,结果为$$c = M{(-\beta)Mf(\alpha)\over g{(M)}(\alpha)}.$$ 参见讲座 4 中的幻灯片 30 或《算法分析》中的定理 4.1。
IV.6 奇点的局部化
IV.7 奇点和函数方程
IV.8 视角
选定练习
注 IV.28
超项链. 第 3 类的"超项链"是一个标记的循环链(见第 125 页)。枚举大小为$n$的第 3 类超项链,$n$分别为 1、2 和 3.(分别为 1、2 和 7。)然后通过展示$$[zn]\ln\Bigl({1\over 1 - \ln{1\over 1-z}}\Bigr)\sim{1\over n}(1-e{-1})^{-n}$$来发展大小为$n$的超项链数量的渐近估计。提示: 求导数。
选定实验
程序 IV.1
计算在加拿大找零$n$美分(没有便士)的排列方式的百分比,并与解析组合学的渐近估计进行比较,$N = 10$和$N = 20$。
程序 IV.2
绘制第 3 类超项链 GF 的导数(见注 IV.28)的图表,风格类似于Lecture 4.中的图表。点击这里访问讲座中的 Java 代码。
网络练习
IV.1
(D. Luo)在加拿大找零$n$美分(没有便士)大约有多少种方式?
IV.2
(M. Moore)第 1 类"超项链"是一个标记的序列循环(见第 125 页)。枚举大小为$n$的第 1 类超项链,$n$分别为 1、2 和 3.(分别为 1、3 和 14。)构造$S = CYC(SEQ_{>0}(Z))$立即导致 EGF \(S(z) = \ln{1\over 1 - {z\over 1-z}} = \ln{1-z\over 1-2z}.\) 使用这个结果展示大小为$n$的第 1 类超项链数量约为$(n-1)!2^n$。提示: 求导数。
IV.3
假设$h(z)\(是一个具有正系数的亚纯函数,\)\alpha$是最小模数的极点。下表列出了$\alpha$的模数、重数和主导性的各种可能性。对于每一行,指示$[z^n]h(z)$的增长顺序是恒定的、线性的、二次的、指数的、指数小的,还是这些选项中的任何一个。
| 模数 | 重数 | 主导? |
|---|---|---|
| 1 | 1 | 是 |
| 1 | 1 | 否 |
| 1 | 2 | 是 |
| 1 | 3 | 是 |
| 1/2 | 1 | 是 |
| 1/2 | 2 | 否 |
| 2 | 1 | 是 |
IV.4
(D. Carter)超项链是第 3 类超项链的循环。给出大小为$N$的超项链数量的~表达式。
IV.5
(A. Yan)给出$$[z^N]{1\over(1-z)(1-2z)(1-3z)\ldots(1-tz)}$$的~近似。
5. 应用有理和亚纯渐近
原文:
ac.cs.princeton.edu/50applications译者:飞龙
V.1 有理和亚纯渐近的路线图
V.2 超临界序列模式
V.3 正则规范和语言
V.4 嵌套序列,晶格路径和连分数
V.5 图和自动机中的路径
V.6 转移矩阵模型
V.7 视角
网络练习
V.1
给出不包含模式0000000001的字符串数量的渐近表达式。对于0101010101也做同样的事情。
V.2
考虑由 1、2 和 3 组成的组合类。给出对象大小为N的数量和随机对象的部分数量的渐近表达式。
V.3
考虑三重满射类。给出对象大小为N的数量和随机对象的部分数量的渐近表达式。
V.4
考虑没有单例循环的对齐类。给出对象大小为N的数量和随机对象的部分数量的渐近表达式。
V.5
一个r-满射是从[1..n]到[1..r]的映射。一个满射是对于某个$r$的$r$-满射。对于长度为$n$的满射,$r$的平均值是多少?
V.6
使用解析组合学计算没有长度为完全平方数的循环的排列百分比的~近似值。非常有用的提示:\(1 + 1/4 + 1/9 + 1/16 + \ldots = \pi²/6\)。
V.7
(A. Alag,R. Jasani)给出一个~近似值,用于不包含单例循环的随机对齐中循环数量的平均值。
选定实验
Program V.1
以第 6 讲中的图表风格绘制 GFs,用于没有模式0000000001出现的比特串集合。对于0101010101也做同样的事情。(参见 Web 练习 V.1 和程序 IV.2)。
6. 生成函数的奇点分析
原文:
ac.cs.princeton.edu/60singularity译者:飞龙
VI.1 基本奇点分析理论一瞥
VI.2 标准尺度下的系数渐近
VI.3 转移
VI.4 奇点分析过程
VI.5 多个奇点
VI.6 插曲:适合奇点分析的函数
VI.7 反函数
VI.8 多对数
VI.9 函数复合
VI.10 闭包性质
VI.11 陶伯理论和达布方法
VI.12 观点
网络练习
VI.1
使用标准函数尺度直接推导出以下 CFG 中字符串数量的渐近表达式:S = E + U×Z×S + D×Z×S, U = Z + U×U×Z, D = Z + D×D×Z。
VI.2
给出一个关于每个节点具有 0、2 或 3 个子节点的根有序树数量的渐近表达式。表示这样一棵树需要多少位?
VI.3
(D. 卡特)彩虹树是一种根有序树,其中每个节点都是四种颜色之一,规则是每个节点与其父节点的颜色不同,并且只能有彼此颜色不同的子节点。(请注意,没有节点可以有超过 3 个子节点。)给出一个关于具有$N$个节点的彩虹树数量的~表达式。
选定实验
程序 VI.1
以第 6 讲中的绘图风格,在以原点为中心的大小为 10 的单位正方形中绘制$1/\Gamma(z)$的$r$和$\theta$图。点击这里访问用于讲座的 Java 代码。
7. 奇点分析的应用
原文:
ac.cs.princeton.edu/70applications译者:飞龙
VII.1 奇点分析渐近的路线图
VII.2 集合和指数-对数模式
VII.3 简单树的种类和反函数
VII.4 树状结构和隐式函数
VII.5 无标记非平面树和 P´olya 算子
VII.6 不可约上下文无关结构
VII.7 代数函数的一般分析
VII.8 代数函数的组合应用
VII.9 普通微分方程和系统
VII.10 奇点分析和概率分布
VII.11 视角
Web 练习
VII.1
使用树状模式为具有 \(N\) 个叶子的括号的数量开发一个渐近表达式(参见第 69 页的示例 I.15 和第 474 页的注 VII.19)
VII.2
为大小为 \(N\) 的根有序树的数量开发一个渐近表达式,其中没有节点具有单个子节点。
VII.3
对于以下每个组合结构,给出用于开发渐近枚举结果的适当模式(简单树的种类,指数-对数,或隐式树状类)。当两者都适用时,选择更简单的一个。\(A = Z\times SEQ(A)\), \(A = Z + SEQ_{>1}(A)\), \(A = SET(CYC(Z))\), \(B = Z\times (1+B)⁴\), \(A = Z\star SET(A)\), \(A = Z + SET_{>1}(A)\), \(A = SET(CYC_{>1}(Z))\).
VII.4
(A. Ding)证明在 \(N\) 个顶点上的 2-正则简单图中,所有循环长度大于 \(r\) 的比例大约为 \(e^{3/4}/\sqrt{r}\),对于大的 \(r\)。
选定实验
程序 VII.1
对于括号的生成函数进行 r 和 θ 图(参见 Web 练习 VII.1)。
8. 鞍点渐近
原文:
ac.cs.princeton.edu/80saddle译者:飞龙
VIII.1 解析函数和鞍点的景观
VIII.2 鞍点界限
VIII.3 鞍点方法概述
VIII.4 三个组合例子
VIII.5 可接受性
VIII.6 整数划分
VIII.7 鞍点和线性微分方程
VIII.8 大幂
VIII.9 鞍点和概率分布
VIII.10 多个鞍点
VIII.11 视角
9. 多元渐近和极限定律
原文:
ac.cs.princeton.edu/90multivariate译者:飞龙
IX.1 极限定律和组合结构
IX.2 离散极限定律
IX.3 离散定律的组合实例
IX.4 连续极限定律
IX.5 准幂和高斯极限定律
IX.6 拟合亚渐近的亚渐近
IX.7 扰动奇异点分析的亚渐近
IX.8 扰动马鞍点亚渐近
IX.9 局部极限定律
IX.10 大偏差
IX.11 非高斯连续极限
IX.12 多元极限定律
IX.13 观点












编写一个程序 Dragon.java 来打印绘制




Sqrt.java 使用一种经典的迭代技术,称为牛顿法,来计算正数x的平方根:从一个估计值t开始。如果t等于x/t(直到机器精度),那么t等于x的平方根,计算完成。如果不是,则通过用t和x/t的平均值替换t来改进估计值。每次执行此更新,我们都会更接近所需的答案。
假设一个赌徒进行一系列公平的 1 美元赌注,从 50 美元开始,并继续玩下去,直到她破产或赢得 250 美元。她赢得 250 美元的机会有多大,以及在赢或输之前她可能会做多少赌注?Gambler.java 是一个可以帮助回答这些问题的模拟。它需要三个命令行参数,初始赌注(50 美元),目标金额(250 美元)以及我们想要模拟游戏的次数。

























































































写一个简单的递归程序解决问题的诱惑必须始终受到这样的理解的限制,即简单程序可能需要指数时间(不必要地),因为存在过度重复计算。例如,斐波那契数列
























Java 的

RGB 颜色模型具有这样的特性,即当三种颜色强度相同时,结果颜色位于从黑色(全 0)到白色(全 255)的灰度范围内。 将颜色转换为灰度的简单方法是用其亮度等于其红色、绿色和蓝色值的新颜色替换该颜色。



600-by-300
200-by-400


































螺线.java 接受一个整数 n 和一个衰减因子作为命令行参数,并指示海龟交替前进和转向,直到它绕自身旋转了 10 次。这产生了一个被称为
































































































Complex.java 与 Complex.java 具有相同的 API,只是它使用极坐标 (r (\cos \theta + i \sin \theta)) 而不是笛卡尔坐标 (x + iy) 表示复数。封装的思想是我们可以将其中一个程序替换为另一个而不改变客户端代码。








Sketch.java 使用简单的频率计数方法来计算文本文档的草图。在其最简单的形式中,它计算文本中每个k-gram(长度为k的子字符串)出现的次数。我们使用的草图是由这些频率定义的向量的方向。
























为此,定义一个嵌套类
























正规表达式(RE)是指定形式语言的符号串。每个正规表达式都是一个字母表符号,指定包含该符号的单例集,或者由以下操作组成(其中 R 和 S 是 REs):




















在十六进制(或hex)中,十六进制数字序列具体表示为(h_n h_ \ldots h_2 h_1 h_0)表示整数

































|
|













































































































|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|

















































































































































































您不必担心溢出测试,但使用两个表示分子和分母的
























在划分循环结束后,添加代码将相等的键交换到正确位置。







与排序相关的一个重要应用是找到一组键的中位数(具有一半键不大于它,一半键不小于它的值)。这个操作在统计学和其他各种数据处理应用中是一个常见的计算。找到中位数是选择的一个特殊情况:找到一组数字中第 k 小的数字。通过排序,可以很容易在线性对数时间内解决这个问题。方法





















































浙公网安备 33010602011771号