Algorithms4 《算法》第四版 内容整理第一章干货
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.abs())、最大最小数比较(如Math.min()和Math.max())等操作;另外一种实现方式是为同一个功能定义两个版本函数——其中一个是必须传入特定参数而另一个则允许指定默认取值。 - 静态函数的设计特性:尽管这类函数可能包含多个return语句供选择使用;但按照程序执行流程的规定性要求,在每次执行时系统都会优先执行第一条可执行return语句并立即终止该函数的操作流程。
- 方法行为特性分析:具有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 字符串
自动转换 :当使用加号运算符进行字符串拼接时,默认情况下会隐式地将所有操作数强制转换为String类型:若其中一个操作数为String,则隐式地会将其他操作数也强制转化为String类型。例如,在不提供任何起始值的情况下,默认使用空String作为起始点。
练习
1.2 数据抽象
1.2.1 使用抽象数据类型
- 抽象数据类型(ADT)的定义和静态方法库共同之处 :
两者在实现上均采用Java类。
实例方法可能接受零个或多个指定类型参数的列表(以逗号分隔);
它们既可以返回一种预定义的数据类型值,也可以不返回值(后者则用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,都有x\text{.equals}\left(x\right)返回布尔值true
-
对称性,只有当y\text{.equals}\left(x\right)返回布尔值true时,才会有x\text{.equals}\left(y\right)返回布尔值true
-
传递性,若对象x与y相等且对象y与z相等,则对象x与z也必然相等
- 此外,该方法应接受一个Object类型作为参数,并满足以下条件:
-
一致性原则:当两个对象均未被修改时,连续调用x.equals(y)将始终返回一致的结果
-
null处理规则: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设计中,在类名后紧跟的<...>符号用于定义一个 参数化类型的标识符 ,它起到一种占位标记符的作用。
这种标识符通常由具体的实现细节决定。
例如,在Java编程语言中:
创建一个集合实例并指定其元素类型:
java
Stackstack = new Stack ();
压入一个字符串:
stack.push("Test");
...
从集合中取出一个元素:
String next = stack.pop();
同样的方法适用于队列:
初始化一个队列并将其元素设置为日期对象:
java
Queuequeue = new Queue ();
执行入队操作:
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)
- 类型参数必须被实例化为引用参数。java的封装类型都是原始数据类型对应的引用类型:Boolean、Byte、Character、Double、Float、Integer、Long和Short分别对应着boolean、byte、character、double、float、integer、long和short。在处理赋值语句、方法的参数和算术或逻辑表达式时,java会 自动 在引用类型和对应的原始数据类型之间进行转换。
-
遍历集合中的所有元素
例如,在Queue类中维护了一个交易集合【
背包 是一种无法删除其中任何元素的集合体数据类型——它主要用于将物品放入可用空间中进行管理(同时支持查询操作以确定可用空间的状态)。例如,在使用场景中不仅能够收集各种物品,并且还能查询背包的状态信息(如查看是否为空或统计存储数量)。然而,在遍历过程中,顺序并不受控制,并不影响结果。

图1.3.1 对于输入中的每个双精度数值快速地进行平均值和样本标准差的计算。无需将全部数值存储起来即可完成标准差的计算。
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 链表
这种 链式数据结构 可以被定义为一种递归结构。
为了定义节点的抽象数据类型,请采用嵌套类的形式。
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 third = new Node();
将每个节点的 item字段设置为其所需值(此处假设这些例子中的 Item 型态为 String):
* 设置next域来构造链表:
java first.next = second; second.next = third;
- third.next仍然为null, 即其初始赋值情况。
- third为一条链表(它是某个节点引用, 该节点自身指向null, 即构成一个空链表);
second同样也构成一条链表(它是某个节点引用, 并包含指向third的一个指针字段)
first也构成一条链表(它是某个节点引用, 并包含指向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[],
其中目标值x位于数组中间位置附近,
其成本模型主要由中间比较操作主导。
1.4.4 增长数量级的分类
- 对增长数量级的常见假设的总结

-
2-sum NlogN解法(假设所有整数各不相同)
-
若二分查找未能找到满足条件的元素,则返回值为-1。
-
因此不会影响计数器的数值。
-
若找到的位置j满足j>i,则有a[i]与a[j]之和等于零。
-
此时a[i]与a[j]之和等于零,并且计数值会相应增加。
- 3-sum N^2logN解法(假设所有整数各不相同)
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 注意事项
- 主导项 :例如,在将函数2N^2+cN近似为~2N^2时,默认假设c相对于N^2来说很小;但如果c很大,则这种近似将不再成立。
- 非支配型内循环 :
- 固定指令时间 :每条指令执行所需的时间通常被假定为固定的。
- 系统运行机制 :现代计算机通常采用多任务处理机制。
- 难以两全其美 :在比较两个程序执行相同任务的速度时,我们经常遇到的情况是其中一程序在某些测试用例中表现优异而在另一些测试用例中则表现不佳。
- 输入高度敏感
- 复杂参数组合
1.4.8 处理对于输入的依赖


练习
1.5 案例研究:union-find算法
- 卓越的算法因能解决实际问题而显得更加关键;
- 高效率算法同样可以非常简洁;
- 探索过程是一个极具趣味性和吸引力的过程;
- 在解决同一个问题的各种算法之间进行选择时科学方法是不可或缺的关键手段;
- 迭代优化有助于提高运行效率显而易见的趋势
1.5.1 动态连接性问题
- 输入数据是一系列的有序数字组合(每组包含两个整数值),每个数字代表一种特定类型的实体。
- 每一对数字p与q之间的关系定义为"p与q相关联"。
- 我们假设这种关联具有相互性(即如果p与q相关联,则q也与p相关联)。
- 因此,在这种情况下实体被划分为若干个互不相交且互不重叠的相关集合——只有当两个实体之间存在直接关联时才归属于同一集合。
- 我们的任务是设计一个算法来筛选出所有不在同一集合内的配对信息——即那些既不在任何已知关联链路中连接到一起又无法通过现有数据证明其关联性的配对外需加以保留。
- 另一方面,在确定存在足够证据支持两实体之间存在关联关系后,则应予以剔除而不参与后续处理。

- 该问题可应用于:
构建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数组来判定两个触点是否处于同一连通分量中)
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--;
}

该操作的效率显然很高。
由于它只需访问id[]数组一次就能完成工作。
然而,在快速合并算法中(Quick-Find),每当执行合并操作时都会必须遍历整个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--;
}
}
- 最优算法

