1.1 基础编程模型
1.1.1 java程序基本结构
- 原始数据类型:整型(int),浮点型(double),布尔型(boolean),字符型(char)
- 语句:声明,赋值,条件,循环,调用,返回。
- 数组
- 静态方法:可以封装并重用代码,使我们可以用独立的模块开发程序。
- 字符串
- 标准输入/输出
- 数据抽象:数据抽象封装和重用代码,使我们可以定义非原始数据类型,进而支持面向对象编程。
1.1.5 数组
数组名表示的是整个数组——如果我们将一个数组变量赋予另一个变量,那么两个变量将会指向同个数组。
int[] a =new int[N];
...
a[i] = 1234;
...
int[] b = a;
...
b[i] = 5678//a[i]的值也会编程5678
1.1.6 静态方法
方法的部分性质:
- 方法的参数按值传递:在方法中参数变量的使用方法和局部变量相同,唯一不同是参数变量的初始值由调用方提供。方法处理的是参数的值,而非参数本身。在静态方法中改变一个参数变量的值对调用者无影响。
- 方法名可以被重载:例如,Java的Math包使用这种方法给所有的原始数值类型实现了
Math.abs()
、Math.min()
、Math.max()
。另一种用法是为函数定义两个版本,一个需要一个参数另一个则为该参数提供一个默认值。 - 方法只能返回一个值,但可以包含多个返回语句:尽管可能存在多条返回语句,任何静态方法每次都只会返回一个值,即被执行的第一个条返回语句的参数。
- 方法可以产生副作用:void类型的静态方法会产生副作用(接受输入、产生输出、修改数组或改变系统状态)
递归
编写递归代码时最重要的有以下三点:
- 递归总有一个最简单的情况——方法的第一条语句总是一个包含
return
的条件语句。 - 递归调用总是去尝试解决一个规模更小的子问题。
- 递归调用的父问题和尝试解决的子问题之间不应该有交集。
public static int rank(int key, int[] a)
{ return rank(key, a, 0, a.length - 1); }
public static int rank(int key, int[] a, int lo, int hi)
{//如果key存在于a[]中,它的索引不会小于lo且不会大于hi
if(lo > hi) return -1;
int mid = lo + (hi - lo) / 2;
if(key < a[mid]) return rank(key, a, lo, mid -1 );
else if(key > a[mid]) return rank(key, a, mid + 1, hi);
else return mid;
}
1.1.8 字符串
自动转换:Java在连接字符串的时候会自动将任意数据类型的值转换为字符串:如果加号(+)的一个参数是字符串,那个Java会自动将其他参数都转换为字符串。这样,通过一个空字符串”“可将任意数据类型的值转换为字符串值。
练习
1.2 数据抽象
1.2.1 使用抽象数据类型
- 抽象数据类型(ADT)的定义和静态方法库共同之处:
- 两者的实现均为Java类
- 实例方法可能接受0个或多个指定类型的参数,由括号表示并且逗号分隔;
- 它们可能返回一个指定类型的值,也能不会(用void表示)。
- 不同:
- API中可能会出现若干个名称和类型相同且没有返回值的函数。称为构造函数
- 实例方法不需要static关键字。它们不是静态方法——它们的目的就是操作该数据类型中的值
- 某些实例方法的存在是为了尊重Java的习惯,此类方法称为继承的方法并在API中将它们显示为灰色。
1.2.3 抽象数据类型的实现
- 实例变量 和静态方法或局部变量最关键的区别:每个时刻局部变量只会有一个值,而每个实例变量可对应着无数值(数据类型的每个实例对象都会有一个)。在访问实例变量时都需要通过一个对象——我们访问的是这个对象的值。每个实例变量的声明都需要一个可见性修饰符(private:对本类可见)
- 每个java类至少含有一个构造函数以创建一个对象的标识。 用于初始化实例变量,它能偶直接访问实例变量且没有返回值。如果没有定义构造函数,类将会隐式定义一个默认情况下不接受任何参数的构造函数并将所有实例变量初始化为默认值。
- 每个实例方法都有一个返回值类型、一个签名(它指定了方法名、返回值类型和所有参数变量的名称)和一个主体(它有一系列语句组成,包含一个返回语句来讲一个返回类型的值传递给调用者)。与静态方法关键不同:它们可以访问并操作实例变量。
- 可以通过触发一个实例方法来操作该对象的值。
- 作用域:
- 参数变量:整个方法
- 局部变量:当前代码段中它的定义之后的所有语句
- 实例变量:整个类
1.2.5 数据类型的设计
- 接口继承:子类型,允许通过指定一个含有一组公共方法的接口为两个本来没有关系的类建立一种联系,这两个类都不许实现这些方法。
public interface Datable
{
int month();
int day();
int year();
}
public class Date implements Datable
{
//实现代码
}
- 实现继承:子类
等价性:java约定equals()必须是一种等价性关系。它必须具有:
- 自反省,x.equals(x)为true
- 对称性,当且仅当y.equals(x)为true时,x.equals(y)返回true
- 传递性,如果x.equals(y)和y.equals(z)均为true,x.equals(z)也将为true
另外,它必须接受一个Object为参数并满足以下性质:
- 一致性,当两个对象均未被修改时,反复调用x.equals(y)总是会返回相同的值
- 非空性,x.equals(null)总是返回false
- 不可变性:final只能用来保证原始数据类型的实例变量的不可变性,而无法用于引用类型的变量。如果一个应用类型的实例变量含有修饰符final,该实例变量的值(某个对象的引用)永远无法改变——它将永远指向同一个对象,但对象的值本身仍然是可变的。
public class Vector
{
private final double[] coords;
public Vector(double[] a)
{
coords = a;
}
...
}
用例程序可以通过给定的数组创建一个Vector对象,并在构造对象执行之后改变Vector中的元素的值:
double[] a = {3.0, 4.0};
Vector vector = new Vector(a);
a[0] = 0.0;//绕过 了公有API
- 异常(Exception),一般用于处理不受我们控制的不可预见的错误
- 断言(Assertion),验证我们在代码中作出的一些假设
练习
1.3 背包(Bag)、队列(Queue)和栈(Stack)
1.3.1 集合型抽象数据类型
- 集合类的抽象数据类型的一个关键特性:可以用它们存储任意类型的数据,称为泛型或参数化类型。
API中,类名后的<Item>
记号将Item
定义为一个类型参数。它是一个象征性的占位符,表示的是用例将会使用的某种具体数据类型。
例如,编写用栈来处理String
对象:
java
Stack<String> stack = new Stack<String>();
stack.push("Test");
...
String next = stack.pop();
使用队列处理Date对象:
java
Queue<Date> queue = new Queue<Date>();
queue.enqueue(new Date(12, 31, 1999));
...
Date next = queue.dequeue();
- 类型参数必须被实例化为引用参数。java的封装类型都是原始数据类型对应的引用类型:Boolean、Byte、Character、Double、Float、Integer、Long和Short分别对应着boolean、byte、character、double、float、integer、long和short。在处理赋值语句、方法的参数和算术或逻辑表达式时,java会自动在引用类型和对应的原始数据类型之间进行转换。
java
Stack<Integer> stack = new Stack<Integer>();
stack.push(17);//自动装箱(int -> Integer)
int i = stack.pop();//自动拆箱(INteger -> int)
- 迭代访问集合中的所有元素
例如,假设用例在Queue中维护一个交易集合
java
Queue<Transaction> collection = new Queue<Transaction>();
如果集合是可迭代的,用例用一行语句即可打印出交易的列表:
for (Transaction t : collection){ StdOut.print(t);}
这种语法叫foreach
语句 - 背包是一种不支持从中删除元素的集合数据类型——它的目的是帮助用例收集元素并迭代遍历所有收集到的元素(用例也可以检查背包是否为空或者获取背包中元素的数量)。迭代的顺序不确定且与用例无关。
图1.3.1 简单的计算输入中所有double
值的平均值和样本标准差。注意:不需要保存所有的数也可以计算标准差。
public ckass Stats
{
public static void main(String[] args)
{
Bag<Double> numbers = new Bag<Double>();
while(!StdIn.isEmpty())
numbers.add(StdIn.readDouble());
int N = numbers.size();
double sum = 0.0;
for (double x : numbers)
sum += x;
double mean = sum/N;
sum = 0.0;
for(double x : numbers)
sum +=(x - mean)*(x - mean);
double std = Math.sqrt(sum/(N-1));
StdOut.printf("Mean: %.2f\n", mean);
StdOut.printf("Std dev: %.2f\n", std);
}
}
队列一种基于先进先出(FIFO)策略的集合类型。用集合保存元素的同时保存它们的相对顺序:是它们入列顺序和出列顺序相同。
In类的静态方法readInts()的一种实现,该方法解决的问题:用例无需预先知道文件的大小即可将文件中的所有整数读入一个数组中。 public static int[] readInts(String name) { In in = new In(name); Queue<Integer> q = new Queue<Integer>(); while (!in.isEmpty()) q.enqueue(in.readInt()); int N = q.size(); int [] a = new int[N]; for (int i = 0; i < N; i++) a[i] = q.dequeue(); return a; }
栈一种基于后进先出(LIFO)策略的集合类型。
把标准输入中的所有整数逆序排列,无需预先知道整数的多少。 public class Reverse { public static void main(String[] args) { Stack<Integer> stack; stack = new Stack<Integer>(); while(!StdIn.isEmpty()) stack.push(StdIn.readInt()); for (int i : stack) StdOut.println(i); } }
- Dijikstra的双栈算术表达式求值算法
- 将操作数要入操作数栈
- 将运算符压入运算符栈
- 忽略左括号
- 在遇到右括号时,弹出一个运算符,弹出所需数量的操作数,并将运算符和操作数的运算结果压入操作数栈。
java
public class Evaluate
{
public static void main(String[] args)
Stack<String> ops = new Stack<Double>();
while(!StdIn.isEmpty())
{
String s = StdIn.readString();
if (s.equals("("));
else if (s.equals("+")) ops.push(s);
else if (s.equals("-")) ops.push(s);
else if (s.equals("*")) ops.push(s);
else if (s.equals("/")) ops.push(s);
else if (s.equals("sqrt")) ops.push(s);
else if (s.equals(")"))
{
String op = ops.pop();
double v = vals.pop();
if (op.equals("+")) v = vals.pop() + v;
else if (op.equals("+")) v = vals.pop() - v;
else if (op.equals("+")) v = vals.pop() * v;
else if (op.equals("+")) v = vals.pop() / v;
else if (op.equals("+")) v = Math.sqrt(v);
vals.push(v)
}
else vals.push(Double.parseDouble(s));//字符是数字
}
StdOut.println(vals.pop());
}
1.3.2 集合类数据类型的实现
栈(能够动态调整数组大小的实现):
- 每项操作的用时与集合大小无关;
- 空间需求总是不超过集合大小乘以一个常数。
- 存在缺点:某些
push()
、pop()
操作会调整数组的大小,这项操作的耗时跟栈大小成正比
import java.util.Iterator; public class ResizingArrayStack<Item> implements Iterable<Item> { private Item[] a = (Item[]) new Object[1];//栈元素。java不允许创建泛型数组,因此需要使用类型转换 private int N = 0;//元素数量 public boolean isEmpty() {return N == 0;} public int size() {return N;} private void resize(int max) {//由于java数组创建后无法改变大小,采用创建大小为max的新数组来替代旧数组的方式动态改变数组实际大小 Item[] temp = (Item[]) new Object[max]; for (int i = 0;i < N; i++) temp[i] = a[i]; a = temp; } public void push(Item item) {//将元素添加到栈顶 if (N == a.length) resize(2*a.length); a[N++] = item; } public Item pop() {//从栈顶删除元素 Item item = a[--N]; a[N] = null;//避免对象游离 if (N > 0 && N == a.length/4) resize(a.length/2); return item; } public Iterator<Item> iterator() { return new ReverseArrayIterator(); } private class ReverseArrayIterator implements Iterator<Item> {//支持后进先出的迭代 private int i = N; public boolean hasNext() { return i > 0;} public Item next() { return a[--i];} public void remove() { } } }
1.3.3 链表
- 链表是一种递归的数据结构,它或者为空(null),或者是指向一个结点(node)的引用,该结点含有一个泛型的元素和一个指向另一条链表的引用。
用一个嵌套类来定义节点的抽象数据类型
private class Node//在需要使用Node类的类中定义它并将它标记为private,因为它不是为用例准备的。 { Item item; Node next; }
通过
new Node()
触发(无参数的)构造函数来创建一个Node类型的对象。调用的结果是一个指向Node对象的引用,它的实例变量均被初始化为null。Item是一个占位符,表示我们希望用链表处理的任意数据类型。- 构造链表:
- 首先为每个元素创造一个结点:
java
Node first = new Node();
Node second = new Node();
Node thrid = new Node();
- 将每个结点的item域设为所需的值(我们这里假设在这些例子中Item为String):
java
first.item = "to";
second.item = "be";
thrid.item = "or";
- 设置next域来构造链表:
java
first.next = second;
second.next = third;
- third.next仍然是null,即对象创建时它被初始化的值。
- third是一条链表(它是一个结点的引用,该结点指向null,即是一个空链表);
second也是一条链表(它是一个结点的引用,且该结点含有一个指向third的引用,而third是一条链表)
first也是一条链表(它是一个结点的引用,且该结点含有一个指向second的引用,而second是一条链表)
- 链表表示的是一列元素。
- 首先为每个元素创造一个结点:
- 插入删除元素
- 在表头插入结点
- 从表头删除结点(该操作只含有一条赋值语句,因此它的运行时间和链表长度无关)
- 在表尾插入结点
- 其他位置的插入和删除操作:使用双向链表,其中每个结点都好有两个链接,分别指向不同的方向。
- 在表头插入结点
栈的实现(使用链表):
- 它可以处理任意类型的数据
- 所需的空间总是和集合的大小成正比
- 操作所需的时间总是和集合的大小无关
public class Stack<Item> implements Iterable<Item> { private Node first;//栈顶(最近添加的元素) private int N; private class Node {//定义了结点的嵌套类 Item item; Node next; } public boolean isEmpty() {return N == 0;}//或:return first == null; public int size() {return N;} public void push(Item item) {//向栈顶添加元素 Node oldfirst = first; first = new Node(); first.item = item; first.next = oldfirst; N++; } public Item pop() { Item item = first.item; first = first.next; N--; return item; } //iterator()的实现见背包实现算法 public static void main(String[] args) {//输入to be or not to - be - - that - - - is Stack<String> s = new Stack<String>(); while(!StdIn.isEmpty()) { String item = StdIn.readString(); if(!item.equals("-")) s.push(item); else if(!s.isEmpty()) StdOut.print(s.pop() + " "); } StdOut.println("(" + s.size() + " left on stack)"); } }
队列的实现
public class Queue<Item> implements Iterable<Item> { private Node first; private Node last; private int N; private class Node { Item item; Node next; } public boolean isEmpty() {return N == 0;}//或:return first == null; public int size() {return N;} public void enqueue(Item item) {//向表尾添加元素 Node oldfirst = last; last = new Node(); last.item = item; last.next = null; if (isEmpty()) first = last; else oldfirst.next = last; N++; } public Item dequeue() {//从表头删除元素 Item item = first.item; first = first.next; if (isEmpty()) last = null; N--; return item; } // public static void main(String[] args) {//输入to be or not to - be - - that - - - is Queue<String> s = new Queue<String>(); while(!StdIn.isEmpty()) { String item = StdIn.readString(); if(!item.equals("-")) q.enqueue(item); else if(!q.isEmpty()) StdOut.print(q.dequeue() + " "); } StdOut.println("(" + q.size() + " left on queue)"); } }
背包的实现
import java.util.Iterator; public class Bag<Item> implements Iterable<Item> { private Node first; private class Node { Item item; Node next; } public void add(Item item) { Node oldfirst = first; first = new Node(); first.item = item; first.next = oldfirst; } //通过遍历链表使Stack、Queue、Bag变为可迭代的。对于Stack,链表的访问顺序是后进先出;Queue,链表的访问顺序是先进先出;Bag,后进先出顺序,但顺序不重要。 public Iterator<Item> iterator() { return new ListIterator();} private class ListIterator implements Iterator<Item> { private Node current = first; public boolean hasNext() { return current != null;} public void remove() { } public Item next() { Item item = current.item; current = current.next; return item; } } }
练习
1.4 算法分析
1.4.3 数学模型
- 对于大多数程序,得到其运行时间的数据模型所需的步骤:
- 确定输入模型,定义问题的规模;
- 识别内循环(执行最频繁的语句);
- 根据内循环中的操作确定成本模型;
- 对于给定的输入,判断这些操作的执行频率。
- 例:二分查找,它的输入模型是大小为N的数组a[],内循环是一个while循环中的所有语句,成本模型是比较操作(比较两个数组元素的值)
1.4.4 增长数量级的分类
- 对增长数量级的常见假设的总结
- 2-sum NlogN解法(假设所有整数各不相同)
- 如果二分查找不成功则会返回-1,不会增加计数器的值
- 如果二分查找返回的 j > i,我们就有a[i]+a[j]=0,增加计数器的值
- 如果二分查找返回的j在0和i之间,不能增加计数器,避免重复计数。
java
import java.util.Arrays;
public class TwoSumFast
{
public static int cout(int[] a)
{
Arrays.sort(a);
int N = a.length;
int cnt = 0;
for (int i = 0; i< N; i++)
if (BinarySearch.rank(-a[i], a) > i)
cnt++;
return cnt;
}
}
- 3-sum N2logN 解法(假设所有整数各不相同)
import java.util.Arrays;
public class ThreeSumFast
{
public static int cout(int[] a)
{
Arrays.sort(a);
int N = a.length;
int cnt = 0;
for (int i = 0; i< N; i++)
for(int j = i + 1;j < N; j++)
if (BinarySearch.rank(-a[i]-a[j], a) > j)
cnt++;
return cnt;
}
}
1.4.7 注意事项
- 大常数:例如,当我们取函数 2N2+cN 的近似为 2N2 时,我们的假设是c很小,如果c很大,该近似就是错误的。
- 非决定性的内循环:
- 指令时间:每条指令执行所需的时间总是相同的假设并不总是正确的。
- 系统因素:计算机总是同时运行着许多程序
- 不分伯仲:在我们比较执行相同任务的两个程序时,常常出现的情况是其中一个在某些场景中更快而在另一些场景中更慢。
- 对输入的强烈依赖
- 多个问题参数
1.4.8 处理对于输入的依赖
练习
1.5 案例研究:union-find算法
- 优秀的算法因为能够解决实际问题而变得更为重要;
- 高效算法的代码也可以很简单;
- 理解某个实现的性能特点是一项有趣而令人满足的挑战;
- 在解决同一个问题的多种算法之间进行选择时,科学方法是一种重要的工具;
- 迭代式改进能够让算法的效率越来越高。
1.5.1 动态连接性问题
- 问题的输入是一列整数对,其中每个整数都表示一个某种类型的对象,一对整数pq可以被理解为“p和q是相连的”,我们假设相连是一种对等的关系。对等关系能够将对象分为多个等价类,在这里,当且仅当两个对象相连时它们才属于同一个等价类。我们的目标是编写一个程序来过滤掉序列中所有无意义的整数对(两个整数均来自于同一个等价类中)。换句话说,当程序从输入中读取了证书对p q时,如果已知的所有整数对都不能说明p和q相连的,那么则将这一对整数写入到输出中。如果已知的数据可以说明p 和q是相连的,那么程序应该忽略p q继续处理输入中的下一对整数。
- 该问题可应用于:
- 网络
- 变量名等价性
- 数据集合
- 设计一份API封装所需的基本操作:初始化、连接两个触点、判断包含某个触点的分量、判断两个触点是否存在于同一个分量之中以及返回所有分量的数量。
java
public class UF
{
private int[] id;//分量id(以触点作为索引)
private int count; //分量数量
public UF(int N)
{//初始化分量id数组
count = N;
id = new int[N];
for(int i=0;i < N;i++)
id[i] = i;
}
public int count()
{ return count;}
public boolean connected(int p, int q)
{ renturn find(p) == find(q); }
public int find(int p)//见quick-find
public void union(int p, int q)//见quick-union,加权quick-union
public static void main(String[] args)
{//解决由StdIn得到的动态连通性问题
int N = StdIn.readInt() //读取触点数量
UF N = new UF(N); //初始化N个分量
while (!StdIn.isEmpty())
{
int p = StdIn.readInt();
int q = StdIn.readInt();//读取整数对
if (uf.connected(p, q)) continue;//如果已经连通则忽略
uf.union(p, q);//归并分量
StdOut.println(p + " " + q);//打印连接
}
StdOut.println(uf.count() + "components");
}
}
1.5.2 实现(均根据以触点为索引的id[]数组来确定两个触点是否存在于相同的连通分量中)
quick-find算法:保证当且仅当id[p]等于id[q]时p和q是连通的。换句话说,在同一个连通分量重的所有触点在id[]中的值必须全部相同。
public int find(int p) { return id[p]; } public void union(int p, int q) {//将p和q归并到相同的分量中 int pID = find(p); int qID = find(q); //如果p和q已经在相同的分量之中则不需要采取任何行动 if (pID == qID) return; //将p的分量重命名为q的名称 for (int i = 0;i < id.length; i++) if (id[i] == pID) id[i] = qID; count--; }
find()操作的速度显然是很快的,因为它只需要访问id[]数组一次。但quick-find算法一般无法处理大型问题,因为对于每一对输入union()都需要扫描整个id[]数组。
quick-union算法:
- 每个触点所对应的id[]元素都是同一个分量中的另一个触点的名称(也可能是它自己)——我们将这种联系称为链接
- 在实现find()方法时,我们从给定的触点开始,由它的链接得到另一个触点,再由这个触点的链接到达第三个触点,如此继续指导到达一个根触点,即链接指向自己的触点。
- 当且仅当分别由两个触点开始的这个过程到达同一个根触点时它们存在于同一个连通分量中。
private int find(int p) {//找出分量的名称 while(p != id[p]) p = id[p]; return p; } public void union(int p, int q) {//将p和q的根节点统一 int pRoot = find(p); int qRoot = find(q); if (pRoot == qRoot) return; id[pRoot] = qRoot; count--; }
加权 quick-union算法:记录每一棵树的大小并总是将较小的树连接到较大的树上。
public class UF
{
private int[] id;//父链接数组(由触点索引)
private int[] sz;//(有触点索引的)各个根节点所对应的分量的大小
private int count; //连通分量的数量
public WeightedQuickUnionUF(int N)
{
count = N;
id = new int[N];
for(int i=0;i < N;i++)
id[i] = i;
sz = new int[N];
for(int i = 0; i < N; i++) sz[i] = 1;
}
public int count()
{ return count;}
public boolean connected(int p, int q)
{ renturn find(p) == find(q); }
public int find(int p)
{//跟随链接找到根节点
while(p != id[p]) p = id[p];
return p;
}
public void union(int p, int q)
{
int i = find(p);
int j = find(q);
if(i == j) return;
//将小树的根节点连接到大树的根节点
if (sz[i] < sz[j]) { id[i] = j; sz[j] += sz[i];}
else{id[j] = i;sz[i] += sz[j];}
count--;
}
}
- 最优算法