算法是一种有限、确定、有效的并适合用计算机程序来实现的解决问题的方法。我们把描述和实现算法所用到的语言特性、软件库和操作系统特性总称为「基础编程模型」。
一段 Java 程序(类)是一个「静态方法库」(函数)或者一个「数据类型定义」。为了创建静态方法库和定义数据类型,会用到以下组成部分:
我们将在本节学习前六种语法,数据抽象在下一篇介绍。
数据类型就是一组数据和对其所能进行的操作的集合。Java 语言最基本的原始数据类型包括:
int
)double
)boolean
)char
)Java 操作的是用「标识符」命名的「变量」。标识符是由字母、数字、下划线和 $ 组成的字符串,首字符不能是数字。每个变量都有自己的类型并存储了一个合法的值。我们用类似数学表达式的「表达式」来实现对各种类型的操作。
只要能够制定值域和在此值域上的操作,就能够定义一个数据类型,如下表所示:
需要注意的是算术运算符是经过重载的——根据上下文,同样的运算符对不同类型会执行不同的操作。这些初级运算的关键性质是「运算所产生数据的数据类型和参与运算的数据的数据类型是相同的」,这意味着我们经常需要处理近似值(例如 5/3 的值是 1)。
Java 使用的是「中缀」表达式:一个字面量(或表达式)紧接着一个运算符,再接着是另一个字面量(表达式)。字面量即值在源代码中的表示(表达式的结果)。
当一个表达式包含多个运算符时,运算符的作用顺序非常重要,Java 规定的运算符优先级如下:
*
和 /
(以及 %
)的优先级高于 +
和 -
!
优先级最高,之后是 &&
,接下来是 ||
与算术表达式一样,使用括号能够改变这些规则。为了避免错误,建议多使用括号。
如果不会损失信息,数字会被自动提升为「高级」的数据类型,如表达式 1+2.5
中,1 会被转换为浮点数 1.0,表达式的值为 double
值 3.5。
「转换」指的是在表达式中把类型名放在括号里将其后的值转换为括号中的类型,如 (int)3.7
的值是 3。注意浮点型转换为整型将会截断小数部分而非四舍五入。
下列运算符能够比较相同数据类型的两个值并产生一个布尔值:
==
)!=
)<
)<=
)>
)>=
)这些运算符被称为「混合类型」运算符,因为它们的结果是布尔型,而不是参与比较的数据类型。结果是布尔型的表达式被称为「布尔表达式」
Java 的整型是 32 位,浮点型是 64 位。为了提供更大的灵活性,Java 还提供了其他四种原始数据类型:
long
)short
)byte
)float
)Java 程序是由「语句」组成的。语句能够通过创建和操作变量,对变量赋值并控制这些操作的执行流程来描述运算。
Java 语句一般包含以下几种:
声明语句用于创建某种类型的变量并用标识符为其命名。由于 Java 是一种强类型的语言(Java 编译器会检查类型的一致性),所以我们需要用声明语句来指定变量的名称和类型。变量的作用域就是定义它的地方,一般由相同代码段中声明之后的所有语句组成。
赋值语句将某个数据类型的值(由一个表达式定义)和一个变量关联起来。为了简洁,一般可以将声明语句和赋值语句结合起来,在声明一个变量的同时将它初始化,例如 int i = 1;
。
「隐性赋值」:当希望一个变量的值相对于其当前的值变化时,可以使用一些简便的写法。
++i
等价于 i=i+1
,且表达式为 i+1
,i++
意思相同只是表达式为 i
的值i/=2
等价于 i=i/2
条件语句能够简单地改变执行流程,根据指定的条件执行两个代码段之一。其基本格式如下:
if (<boolean expression>) { <block statements> }
else { <block statements> }
如果只有一条语句,代码的花括号可以省略(下同)。
循环语句可以更彻底地改变执行流程,只要条件为真就不断地反复执行代码段中的语句。其基本格式如下:
while (<boolean expression>) { <block statements> }
我们将循环语句中的代码段称为「循环体」。
有时候,很多循环的模式都是:初始化一个索引变量,然后使用 while 循环并将包含索引变量的表达式作为循环条件,while 循环的最后一条语句会将索引变量加 1(或其他操作)。对于这种情况,使用 for 语句可以更紧凑地表达这种「初始化—递增」循环:
for (<initialize>; <boolean expression>; <increment>)
{
<block statements>
}
有些情况下我们需要比基本的条件和循环语句更加复杂的流出控制。Java 支持在循环中使用另外两条语句:
break
语句:立即从循环中跳出continue
语句:立即开始下一轮循环调用和返回语句与静态方法有关,是改变执行流程和代码组织的另一种方式。请参见第五节。
下表对不同种类的 Java 语句进行了总结:
数组能够顺序存储相同类型的多个数据。访问数组中的某个元素的方法是将其编号然后索引。如果我们有 N 个值,对于 0 到
之间的任意的 i
,我们就能够使用 a[i]
唯一的表示第 i+1
个元素的值(针对一维数组)。
在 Java 中创建一个数组需要三步:
为了精简代码,我们常常会利用 Java 对数组默认的初始化来将三个步骤合为一条语句。数值类型的默认初始值是 0,布尔型的默认初始值是 false
。
如果想要不同的初始值,可以使用 for 循环或通过花括号将一列由逗号分隔的值在编译时将数组初始化。下图给出了完整模式和简化模式下的数组声明、创建和初始化。
在使用数组时要注意:数组一经创建,其大小就是固定的。程序能够通过 a.length
获取数组 a[]
的长度。Java 会自动进行边界检查,访问超出边界的位置时会抛出异常。
数组名表示的是整个数组。如果我们将一个数组变量赋予另一个变量,那么两个变量将会指向同一个数组:
int[] a = new int[N];
...
a[i] = 1234;
...
int[] b = a;
...
b[i] = 5678; // a[i] is now 5678.
这种情况叫做「别名」,有时可能会导致难以察觉的问题(可变性的锅)。如果想复制数组,应该声明、创建并初始化一个数组,然后将原数组中的元素挨个复制到新数组。
在 Java 中二维数组就是一维数组的数组。二维数组可以是参差不齐的(即元素数组的长度可以不一致),但大多数情况下我们都会使用
,即 M 行长度为 N 的数组的二维数组。创建二维数组的简化模式如下:
double[][] a = new double[M][N];
在 Java 中访问二维数组 a[][]
的第 i
行第 j
列的元素可以写作 a[i][j]
。
静态方法是一组在调用时会被顺序执行的语句。在许多语言中,静态方法被称为「函数」,因为它们和数学函数的性质类似。
方法封装了一系列语句所描述的运算。方法需要「参数」(某种类型的值)并根据参数计算出某种数据类型的「返回值」或者产生某种「副作用」。
每个静态方法都是由「签名」和「函数体」组成的:
调用静态方法的方法是写出方法名并在后面的括号中列出参数值,用逗号分隔。调用方法时,它的参数变量将被初始化为调用时所给出的相应表达式的值。「返回语句」将结束静态方法并将控制权交还给调用者。如果静态方法的目的是计算某个值,返回语句应该指定这个值。
Java 方法拥有以下几个特点:
void
,这表示该方法没有返回值。void
类型的方法会产生副作用(接受输入、产生输出、修改数组或者改变系统状态)递归方法是一种直接或间接调用自己的方法,编写递归代码时最重要的有以下三点:
违背其中任意一条都可能得到错误的结果或是低效的代码。
「静态方法库」是定义在一个 Java 类中的一组静态方法。Java 开发的一个基本模型是通过创建一个静态方法库(包含一个 main()
方法)编写一个程序来完成一个特定的计算任务。
静态方法库实现了模块化编程。一个库中的静态方法也能够调用另一个库中定义的静态方法。模块化编程能够带来很多好处:
Java 编程的最佳实践之一就是每个静态方法库中都包含一个 main 函数来测试库中的所有方法。每个模块的 main()
方法至少应该调用模块中的其他代码并在某种程度上保证它的正确性。
我们会使用来自 4 个不同类型的库中的静态方法,重用每种库代码的方式稍有不同。
java.lang
:包括 java.lang.Math
,java.lang.Integer
和 java.lang.Double
java.utils.Arrays
。需要在程序的开头使用 import
语句导入要调用另一个库中的方法,需要在方法前指定库的名称,如 Math.sqrt()
。
我们会统一使用「应用程序编程接口」(API)的方式来列出本书中使用的每个方法库名称、签名和简短的描述。
API 的目的是将调用和实现分离。我们用「客户端」(client)来指代调用另一个库中方法的程序,用「实现」(implementation)来描述实现了某个 API 方法的 Java 代码。
下面给出一个 API 的样例:
字符串是由一串字符组成的,一个 String 类型的字面量包括一对「双引号」和其中的字符。String 类型是 Java 的一个数据类型,但并「不是」原始数据类型。
Java 内置了一个串联 String 类型字符串的运算符(+
),拼接两个 String 类型的字符串将得到一个新的 String 值,其中第一个字符串在前,第二个字符串在后。
字符串的两个主要用途分别是:
举例来说,Integer 和 Double 库包含了分别和 String 类型相互转化的方法:
Java 在连接字符串的时候会自动将任意数据类型的值转换为字符串,我们能够通过一个空字符串将任意数据类型的值转换为字符串值。
在 Java 中字符串的一个重要的用途就是使程序能够接收到从命令行传递来的信息。当你输入命令 java
和一个库名以及一系列字符串后,Java 系统会调用库的 main 方法并将那「一系列字符串变成一个数据」作为参数传递给它:
在我们的模型中,Java 程序可以从「命令行参数」或者一个名为「标准输入流」的抽象字符流中获得输入,并将输出写入另一个名为「标准输出流」的字符流中:
终端窗口包含一个提示符,通过它我们能够向操作系统输入命令和参数。本书中会使用到如下几个命令:
系统默认会将标准输出打印到终端窗口。本书将使用自己编写的 StdOut 库:
在最简单的情况下 printf
方法接收两个参数:
最简单的格式字符串的第一个字符是 % 并紧跟一个字符表示的转换代码。在 % 和转换代码之间可以插入一个整数来表示转换之后的值的宽度,在宽度之后还可以插入一个小数点以及数值来指定保留的小数位数或是字符串截取长度:
我们的 StdIn 库从标准输入流中获取数据。这些数据可能为空,也可能是一系列由空白字符分隔的值(空格、制表符、换行符等)。输入流由 <ctrl-d>
或 <ctrl-z>
结束,取决于你的终端。标准输入流最重要的特点是这些值会在你的程序读取它们之后消失。
只需要向启动程序的命令中加入一个简单的提示符,就可以将它的标准输出或输入「重定向」至一个文件。将一个程序的输出重定向为另一个程序的输入叫做「管道」。
我们的 In 和 Out 库提供了一些静态方法,来实现向文件中写入或从文件中读取一个原始数据类型(或 String 类型)的数组的抽象借此我们可以在同一个程序中分别使用文件和标准输入输出达到两种不同的目的。
我们使用 StrDraw 来绘制图像,其基本方法如下:
标准绘图库中还包含一些控制方法,用以改变画布大小、字体、颜色等:
下面的程序实现了一个被称为「二分查找」的经典算法,并通过「白名单过滤」进行了测试:
算法是由静态方法 rank()
实现的。它接收一个整数键和一个已经「有序」的 int 数组作为参数,如果该键存在于数组中则返回它的索引,否则返回 -1。
算法使用两个变量 lo
和 hi
,并保证如果键在数组中则它一定在 a[lo..hi]
中,然后方法进入一个循环:不断地将数组的中间键(索引为 mid
)和被查找的键比较,如果被查找的键等于 a[mid]
,返回 mid
;否则算法就将查找范围缩小一半,如果被查找的键小于 a[mid]
就继续在左半边查找,如果被查找的键大于 a[mid]
就继续在右半边查找。算法找到被查找的键或是查找范围为空时则该过程结束。
下图可视化了有序数组中的二分查找:
白名单过滤的过程如下:
Sattolo 算法是一种随机打乱算法,对于一个序列 0 1 2 … n-1
,我们将序列的 index 看做节点,将其值看做其指向的节点,则该算法打乱的序列所构成的图必定只有一个环。
算法的基本思想是每次随机生成一个 index(不包括末尾),和序列末尾互换位置:
public class Sattolo{
public static void cycle(Object[] a) {
int n = a.length;
for (int i = n; i > 1; i--) {
// choose index uniformly in [0, i-1)
int r = (int) (Math.random() * (i-1));
Object swap = a[r];
a[r] = a[i-1];
a[i-1] = swap;
}
}
}
与该算法相似的算法是 「Fisher-Yates」 算法(Durstenfeld 版本),与 Sattolo 不同的是其允许元素与其自身互换(无偏差)。该版本可生成的组合为 n! 种,而 Sattolo 算法为 (n-1)! 种。该方法同样是一个原地算法(in-place),也可以改写为 inside-out 版本(不改变原数组)。