算法(第四版)第一章笔记

来源:互联网 发布:oracle 大数据 编辑:程序博客网 时间:2024/05/18 01:18

第一章 基础

1.1 基础编程模型  4

1.1.1 Java程序的基本结构  4

1.1.2 原始数据类型与表达式  6

1.1.3  语句  8

1.1.4  简便记法  9

1.1.5  数组  10

1.1.6  静态方法  12

1.1.6.4 递归

编写递归代码最重要有以下三点:
- 递归总有一个最简单的情况——方法的第一条语句总是一个包含return 的条件语句。
- 递归调用总是尝试去解决一个规模更小的子问题,这样递归才能收敛到最简单的情况
- 递归调用的父问题 和 尝试解决的 子问题之间 不应该有交集。

1.1.7  API  16

1.1.7.4 你自己编写的库

API的目的是将调用和实现分离:除了API中给出的信息,调用者不需要知道实现的其他细节,而实现也不应考虑特殊的应用场景。
- 程序员可以将API看做调用和实现之间的一份契约,它详细说明了每个方法的作用。实现的目标就是能够遵守这份契约。

1.1.8  字符串  20

1.1.9 输入输出

在我们的模型中,Java程序可以从命令行参数或者一个名为标准输入流的抽象字符流中获得输入,并将输出写入另一个为标准输出流的字符流中。

1.1.9.4 标准输入

标准输入流最重要的特点是这些值会在你的程序读取之后消失。只要程序读取一个值,它就不能回退并再次读取它。

1.1.9.5 重定向与管道

1.1.10  二分查找  28

1.1.11  展望  3

1.1答疑:

  1. 1 / 0 与 1.0 / 0.0 的结果是什么?
    • 1.0 / 0.0 = INFINITY
    • 1 / 0编译出错 除零异常 ,
java.lang.ArithmeticException: / by zero
  1. 一个for循环 和 while形式有什么区别?
    • 答:while 循环中的 “递增变量” 在循环结束后还可以继续使用

习题1.1.25 使用数学归纳法证明欧几里得算法

  • 欧几里得算法的关键在于证明 gcd(a, b) = gcd(b,a mod b) 的正确性
  • 定理:a,b 是整数,则gcd(a, b) = gcd(b,a mod b)

  • 设k,r为整数。设r = a mod b ,则a可表示为 a = r + k*b

  • 假设d 是{a,b}的公约数,则d整除a,b。而r = a - k*b; 所以d整除r, d也是b和r的公约数。
  • 假设d 是{b,r}的公约数,则d整除b,r。而a = r + k*b 所以d也是a,b的公约数。
  • 所以{a,b},{b, r}的公因子集合是一样的。特别地{a,b}的最大公因子也是{b,r}的最大公因子。即 gcd(a, b) = gcd(b,a mod b)

1.2 数据抽象

数据类型指的是一组值和一组对这些值的操作的集合。
- Java编程的基础主要是使用class关键字构造被称为 引用类型 的数据类型。
- 抽象数据类型(ADT)是一种能够对使用者隐藏数据表示的数据类型。

1.2.1  使用抽象数据类型

1.2.1.4 对象

  • 对象是能够承载数据类型的值的实体。
  • 所有对象都有三大特性
    1. 状态:即类型中的值(实例变量)
    2. 标识:能够将一个对象区别于另一个对象。可以认为对象的标识就是它在内存中的位置(每个类都至少有一个构造函数以创建一个对象的标识)
    3. 行为:数据类型的操作()
  • 引用是访问对象的一种方式。

1.2.2  抽象数据类型举例

1.2.3  抽象数据类型的实现

1.2.3.5 API 用例与实现

  • 我们思考的不是应该采取什么行动来来达成某个计算性的目的,而是用例的需求。按照下面三步走的方式用抽象数据类型来满足它们。
    • 用例一般需要什么操作?
    • 数据类型的值应该是什么才能最好地支持这些操作?
      1. 定义一份API:API的作用是将使用和实现分离,以实现模块化编程。
      2. 用一个Java类实现API的定义:首先我们选择适当的实例变量,然后再编写构造函数和实例方法。
      3. 实现多个测试用例来验证前两步做出的设计决定。

1.2.4  更多抽象数据类型的实现

1.2.4.2 维护多个实现

同一份api的不同实现。
通常采用一种非正式的命名约定
- 通过前缀性修饰符区别同一份API的不同实现
- 维护一个没有前缀的参考实现,它应该适合于大多数用例的需求。

1.2.5  数据类型的设计  

抽象数据类型是一种向用例隐藏内部表示的数据类型。

我们提倡的编程风格:将大型程序分解为能够独立开发和调试的小型模块(也促进了代码复用)。

Java系统的新实现往往更新了多种数据类型的或静态方法库的实现,但它们的API并没有变化。

1.2.5.8 等价性

equals 模板

    @Override    public boolean equals(Object obj) {        //如果引用相同,直接返回true 不需要其他测试工作        if (this == obj) {            return true;        }        //对象为空直接返回false        if (obj == null) {            return false;        }        //两个对象的类不同        if (obj.getClass() != this.getClass()) {            return false;        }//      //书上没这么用,还是直接getClass比较好//      if (!(obj instanceof Date)) {//          return false;//      }        //强制类型        Date that = (Date)obj;        if (that.day != day) {            return false;        }        if (that.year != year) {            return false;        }        if (that.mon != mon) {            return false;        }        return true;    }

1.2.5.10 不可变性

  • 不可变数据类型,该类型的对象的值在创建之后就无法再被改变(final修饰)
    • eg:String
  • 可变数据类型,能够操作并改变对象中的值
    • eg:数组

1.2.5.13 断言

契约式设计的编程模型采用的就是断言的思想。
- 数据类型的设计者需要说明前提条件(调用某个方法需要满足的条件,如:二分查找需要满足有序)
- 后置条件(实现在方法返回时必须达到的要求)
- 副作用(方法可能对对象状态产生的任何变更)

答疑:

要保证含有一个可变的实例变量的数据类型的不可变性,需要得到一个本地副本,称为保护性复制


1.3背包、队列和栈

1.3.1  API  

1.3.1.4 背包

背包是一种不支持从中删除的集合数据类型——它的目的是帮助用例收集元素并迭代遍历所有收集到的元素。(当然可以检查背包是否为空或者获取其中的数量的功能还是有的)。


  1. 进出栈的顺序
    • 进栈的顺序的已经定死了
    • abc 依次进。—— a进 … b 进 … c 进 …
      • 那么区别就只在于进栈之间的出栈元素。
    • 如果后面的元素已经出栈(这里有一个隐含的条件就是前面的元素已入栈了),那么前面的未出栈元素一定是逆序出栈。
      • 后元素进了,前元素肯定已经进了(只是不知道出来了没有)
  2. 入队列和出队列的顺序

    • 使用Collection类的Iterator,可以方便的遍历Vector, ArrayList, LinkedList等集合元素,避免通过get()方法遍历时,针对每一种对象单独进行编码。

    • 迭代器模式

1.3.2  集合类数据类型的实现  

参考
- Java目前还不支持创建 泛型数组。因为java的泛型是擦除实现

List<String>[] l = new ArrayList<String>[10];//错误List<String>[] l = (ArrayList<String>[] )new Object[10];Item[] a = (Item[]) new Object[N];//正确 

1.3.2.3 调整数组大小

以栈为栗子:
- push()中,检查数组是否太小,如果没有多余的空间,就将数组的长度加倍。

if(N == a.length){    resize(a.length * 2);}
  • pop()中, 首先删除栈顶元素。然后数组太大就将它的长度减半。
    • 检测条件为栈的大小是否小于数组的四分之一。
Item item = a[--N];a[N] = null;//防止游离if(N > 0 && N == a.length / 4){ // 另一条件N > 0 勿忘    resize(a.length / 2);}return item;

调整数组的函数实现

private void resize(int size){    Item[] tmp = (Item[]) new Object[size];    for(int i = 0 ; i < N; i++){        tmp[i] = a[i];    }    a = tmp;}

1.3.2.4 对象游离

用数组实现的栈的栗子:
- 对pop的实现中,被弹出的元素的引用仍然在数组中,应该将其置为null。

1.3.2.5 迭代

任意可迭代的集合数据类型需要实现的东西
1. 通过实现Iterable接口
- 实现iterator方法,返回Iterator
2. 定义一个实现了Iterator接口的 嵌套内部类
- 实现 hasNext方法
- 实现 next方法
- remove方法可以放空,或者抛异常

数组实现的迭代 栗子:

@Overridepublic Iterator<Item> iterator() {    return new ReverseArrayIterator();}private class ReverseArrayIterator<Item> implements Iterator<Item>{    private int i = N;    @Override    public boolean hasNext() {        return i > 0;    }    @Override    public Item next() {        return (Item) a[--i];    }    public void remove(){    }}

链表实现的迭代栗子:

private class Itr implements Iterator<Item> {    Node current = first;    @Override    public boolean hasNext() {        return current != null;    }    @Override    public Item next() {        Item i = current.item;        current = current.next;        return i;    }    @Override    public void remove() {    }}

1.3.3  链表  

定义:链表是一种递归的数据结构,或者为空,或者是一个指向一个结点的引用,该结点含有一个泛型元素和一个指向另一条链表的引用。

1.3.4  综述

  • 使用链式一般都是从某个固定的点开始访问。例如树的根结点。
  • 使用数组,可以随机访问。

在研究一个新的应用领域的时,我们将会按照以下步骤识别目标并使用数据结构对象解决问题
1. 定义API。
2. 根据特定的应用场景开发用例代码,先确定客户怎么使用(跟以往的思路有些不同)
3. 描述一种数据结构。(一组值的表示), 并在API所对应的抽象数据类型的实现中根据它定义类的实例变量。
4. 描述算法(实现一组操作方式),并根据它实现类中的实例方法
5. 分析算法的性能优点。

1.4 算法分析

时间 空间

1.4.1  科学方法  

  • 所设计的实验必须是可重现的
  • 所有的假设必须是可证伪的

1.4.2  观察  

1.4.3  数学模型  

D.E.Knuth 的基本见地:
一个程序运行的时间主要和两点有关:
- 执行每条语句的耗时
- 取决于计算机、Java编译器和操作系统
- 执行每条语句的频率
- 取决程序本身 和 输入

定义: 我们用~f(N)表示所有随着N的增大除以f(N)的结果趋近于1的函数。我们用g(N)~f(N)表示g(N)/f(N)的随着N的·增大趋近于1。

执行最频繁的指令决定了程序执行的时间,称这些指令为内循环

本书中
- 性质表示需要用实验验证的猜想
- 命题表示 在某个成本模型下算法的数学性质。

附: 循环计算

ref

1. 复杂度是线性级别的循环体
  • 如果某个循环结构以线性方式运行n次,并且循环体的时间复杂度都是O(1),那么该循环的复杂度就是O(n).
  • 即使该循环跳过某些常数部分,只要跳过的部分是线性的,那么该循环体的时间复杂度仍就是O(n).
    • 下面第二个栗子 以线性方式运行 N/2 次数
for(int i = 0; i < N ; i++){    //一系列复杂度为O(1)的步骤....}for(int i = 0; i < N ; i+=2){    //一系列复杂度为O(1)的步骤....}
2. 复杂度是对数级别的循环体
for(int i = 0; i < N ; i*=2){    //一系列复杂度为O(1)的步骤....}
  • 关键概念
      - 循环的时间复杂度等于该循环体的复杂度乘以循环的次数
3. 嵌套循环复杂度分析

先计算内层循环的时间复杂度,然后用内层的复杂度乘以外层循环的次数

三层循环参考

3. 方法调用的复杂度分析

先计算方法体的的时间复杂度.

1.4.4  增长数量级的分类  

各种级别的对应的经典算法是什么

1.4.4.1 常数级别

普通语句 两个数相加

1.4.4.2 对数级别

对数的底数和增长的数量级无关(因为不同底数仅相当于一个常数因子)

1.4.4.3 线性级别

一维数组找出最大元素

1.4.4.4 线性对数级别

归并排序

1.4.4.5 平方级别

检查所有元素对

1.4.4.6 立方级别

检查所有三元组

1.4.4.7 指数级别

检查所有子集

1.4.5  设计更快的算法  

2sum 计算出数组中和为0的整数对的数量(假设所有元素都是不同的)
- 先进行排序
- 然后进行二分查找
- 二分查找不成功,返回-1,我们不改变计数器的值
- 二分查找返回的j > i, 计数器的值+1
- 二分查找的j 在 0 和 i之间,也有a[i] + a[j] = 0;但是不能改变计数器的值,以免重复计数
- 复杂度 NlogN + logN

public int twoSumFast(int[] a){    Arrays.sort(a);    int cnt = 0;    for(int i = 0 ;i<a.length; i++){        if(Binarysearch.rank(-a[i],a) > i) {            cnt++;        }    }    return cnt;}

3sum问题快速解法
- (假设所有元素各不相同)
- 复杂度 N^2logN

public int threeSumFast(int[] a){    Arrays.sort(a);    int cnt = 0;    for(int i = 0; i < a.length; i++){        for(int j = i+1; j < a.length; i++){            if(Binarysearch.rank(-(a[i] + a[j]), a) > j) {                cnt++;            }        }    }    return cnt;}

本书中会尝试按照以下的方式解决各种算法问题
1. 实现并分析问题的一种简单解法,通常称它们为暴力解法
2. 考察算法的各种改进
3. 用实验证明新的算法更快。

1.4.6  倍率实验  

开发一个输入生成器来产生实际情况下的各种可能的输入

  • 例如使用循环,每次将问题的输入规模增长一倍,再调用方法
public static double timeTrail(int N) {    int MAX = 100000;    int[] a = new int[N];    for (int i = 0; i < N; i++) {        a[i] = StdRandom.uniform(-MAX, MAX);    }    Stopwatch stopwatch = new Stopwatch();    ThreeSum.count(a);    return stopwatch.elapsedTime();}public static void main(String[] args) {    double prev = timeTrail(125);    for(int N = 250 ; true; N+=N){        double time = timeTrail(N);        StdOut.printf("%6d %7.1f ", N , time);        StdOut.printf("%5.1f\n", time / prev);        prev = time;    }}

命题C (倍率定理) 如果T(N) ~ aN^blgN,那么T(2N)/T(N) ~ 2^b。
- 注:一般而言,数学模型的对数项是不能忽略的,但在倍率假设中它在预测性能的公式中并不那么重要。

1.4.7  注意事项  

性能分析无法得到正确的结果
- 一般都是由于我们的猜想基于的一个或多个假设并不完全正确所造成的。

1.4.8  处理对于输入的依赖  

问题所要处理对输入建模。困难点:
- 建立输入模型是不切实际的
- 对输入的分析可能极端困难

1.4.8.2 对最坏情况下的性能保证

命题D。 在Bag、Stack、Queue的链表实现中所有的操作在最坏情况下都是常数级别的

1.4.8.3 随机化算法

随机打乱输入

1.4.8.4 操作序列

例如:栈。先入栈N个值再将它们弹出所得到的性能 跟 N次压入弹出的混合操作序列所得到的性能可能是不同的。

1.4.8.5 均摊分析

命题E。在基于可调整大小的数组实现的Stack数据结构中,对空数据结构所进行的任意操作序列对数组的平均访问次数 在最坏情况下 均为常数
- 证明:P125

1.4.9内存

类型 所占字节数 对象的引用 (一般是一个内存的地址) 8 对象开销 16 boolean 1 byte 1 char 2 int 4 float 4 double 8 long 8
  • 对象开销包括
    • 一个指向对象的类的引用(Mark word)
    • 垃圾回收信息
    • 同步信息
  • 填充字节用来填充字节数

    • HotSpot的对齐方式是以8字节对齐,所有没有对象最终大小没有到8个字节的倍数的,都会被填充
    • 一般内存的使用都会被填充为8字节(64位计算机中的机器字)
  • 当我们说明一个引用所占的内存时,会单独说明它所指向的对象所占用的内存

1.4.9.2链表

嵌套的非静态(内部)类,还需额外的8个字节(用于一个指向外部类的引用)

1.4.9.3数组

分析时,画出图像,一个一个对应写出来。

数组 字节 对象开销 16 int 数组长度 4 填充字节 ? … …

eg:

数组 字节 对象开销 16 int 数组长度 4 填充字节 4 double 8 double 8 。。。 double 8

- 一个原始数据类型的数组一般需要24字节的头信息
- 16字节的对象开销
- 4字节(int类型)保存数组长度
- 4个填充字节

  • 一个对象的数组(一个对象的引用的数组),一般需要24字节的头信息

小结

类型 字节数 近似 int[] 24+4N ~4N double[] 24+8N ~8N long[] 24+8N ~8N Date[] 24+8N + 32N ~40N double[][] 24+8M + (24+8N)*M ~8MN

1.4.9.4字符串对象

三个int值:
- 偏移量
- 计数器(字符串的长度)
- 散列值

//String对象public class String{    char[] value;    int offset;    int count;    int hash;    ....|
字符串对象 字节 对象开销 16 字符串的值(引用) 8 偏移量 (int) 4 字符串的长度(int) 4 散列值 (int) 4 填充字节 4

一个长度为N的String对象一般需要使用40字节(String对象本身),加上(24+2N)字节(字符数组),总共(64+2N)字节。

调用subString方法时,会创建一个新的String对象(40字节),但是它会重用相同的value数组(通过偏移量和它的字符串长度来指定 ),因此只占40字节内存。

1.4.10  展望  

  • 不要过早优化。
  • 应该注重写出正确清晰的代码。
  • 但是也不能完全忽略性能

1.5  案例研究:union-find算法  136

先画出图来,再写代码,易理解。
- 用节点(带标签的圆圈)表示触点
- 用一个节点到另一个结点的箭头表示 链接

由此得到的数据结构的图像表示使我们理解算法的操作变得相对容易。

1.5.1  动态连通性  136

等价关系能够将对象分为多个等价类
- 当且仅当两个对象相连时他们才属于同一个等价类。

动态连通性的应用场景

1.5.1.1 网络

此程序能够判定我们是否需要在p和q之间架设一条新的连接才能进行通信,或是我们可以通过已有的连接在两者之间建立通信线路。

1.5.1.2 变量名等同性

在程序中,可以声明多个引用来指向同一对象,这个时候就可以通过为程序中声明的引用和实际对象建立动态连通图来判断哪些引用实际上是指向同一对象。

并查集的应用

对问题的建模

并查集 笔记

对问题进行建模的时候,先尽量想清楚要解决的问题是什么。
就动态连接性这个场景而言,我们要解决的问题可能是:
- 给出两个结点,判断他们是否连通,==如果连通,需要给出具体的路径==
- union-find 属于第一种
- 给出两个结点,判断他们是否连通,==如果连通,不需要给出具体的路径==
- 使用基于DFS的算法

1.5.1.3 数学集合

更高的抽象层次上,可以将输入的所有整数 看做属于不同的数学集合。
- 在处理一个整数对p和q时,我们是在判断它们是否属于相同的集合
- 如果不是,就将p所属的集合和q所属的集合归并到同一个集合中。

  • 将 整数对 称为连接
  • 将 对象 称为触点
  • 将 等价类 称为连通分量(简称 分量)

union-find的成本模型。在研究实现union-find的API的各种算法时,我们统计的是数组的访问次数(无论读写)

对于动态连通图几种可能的操作

  • 查找节点所属于的组
    • 数组的位置对应值,即为组号
  • 判断是否属于同一个组
    • 两个组的组号是否相同
  • 连接两个节点使之属于同一个组
    • 分别得到两个节点的组号,组号同时 操作结束;不同时,将其中的一个节点的组号换成另一个节点的组号
  • 获取组的总数量
    • 初始化为节点的总数。每次成功连接两个节点之后,递减1.

API

注意其中使用整数来表示节点,如果需要使用其他的数据类型表示节点,比如使用字符串,那么可以用哈希表来进行映射,即将String映射成这里需要的Integer类型。

1.5.2  实现  140

public boolean connected(int p, int q) {    return find(q) == find(p);}public int find(int p) {    return id[p];}// 第一种方法, 当且仅当id[p] 与 id[q]的值相等时,p和q是连通的。// 即以id[]的值来区分不同的分量。值同就属于同一个分量,不同就属于不同的分量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] == qID) {            id[i] = pID;        }    }    count--; //前面“局部”操作完以后,需要对“全局”的统计量进行更改}

1.5.2.1 quick-find算法

在同一个连通分量中的所有触点的id[] 中的值必须全部相同。

1.5.2.2 quick-find算法分析

命题F。在quick-find算法中,每次find()调用只需访问数组一次,归并两个分量的union操作访问数组的次数在(N+3) 和 (2N+1)之间。

1.5.2.3 quick-union算法

考虑一下,为什么以上的quick-find 解法会造成“牵一发而动全身”?因为每个节点所属的组号都是单独记录,各自为政的,没有将它们以更好的方式组织起来,当涉及到修改的时候,除了逐一通知、修改,别无他法。

所以现在的问题就变成了,如何将节点以更好的方式组织起来,组织的方式有很多种,但是最直观的还是将组号相同的节点组织在一起,想想所学的数据结构,什么样子的数据结构能够将一些节点给组织起来?常见的就是链表,图,树,什么的了。但是哪种结构对于查找和修改的效率最高?毫无疑问是树,因此考虑如何将节点和组的关系以树的形式表现出来。

union与find算法是互补的。
赋予id[] 数组的值 不同的意义,每一个触点所对应的id[]元素 都是同一个分量中另一个的触点的名称(也可能是自己)

“根节点”作为连通分量的标识。

//建立链接,每一个触点所对应的id[]元素 都是同一个分量中另一个的触点的名称(也可能是自己)public int quick_find(int p) {    //找出分量的名称    while (id[p] != p) {        p = id[p];     }    return p;}//public void quick_union(int p ,int q) {    //p和q的根触点 (类似于树的根节点)    int pRoot = quick_find(p);    int qRoot = quick_find(q);    if (pRoot == qRoot) {        return;    }    id[pRoot] = qRoot;    count--;// 全局的统计量更改}

1.5.2.4 森林的表示

1.5.2.5 quick-union算法分析

定义。一棵树的大小是它节点的数量
- 树中的一个节点的深度是它到根节点的路径上的链接数(即 路径节点总数-1)

命题G。quick-union算法中的find()方法访问数组的次数为1 加上给触点所对应的节点的深度的两倍。union和connected 访问数组的次数为两次find操作(如果不在同一个分量中还要加1)

1.5.2.6 加权quick-union算法

  • 目的:控制树高。以减少find查询时间。

  • 添加一个数组和一些代码来记录树中的节点数,让比较小(节点数目比较少)的树的根指向比较大(节点数目多)的树的根,(减少find查询时间)改进算法的效率。

1.5.2.7 加权quick-union算法的分析

public class WeightedQuickUnion {    private int[] id;// 父链接数组,由触点索引    private int[] sz;// (由触点索引的)各个根节点所对应的分量的大小    private int count;// 连通分量的 数量    public WeightedQuickUnion(int N) {        count = N;// 一开始每个节点分属不同的连通分量        id = new int[N];        for (int i = 0; i < id.length; i++) {            id[i] = i;        }        sz = new int[N];        for (int i = 0; i < sz.length; i++) {            sz[i] = 1;// 每一个连通分量都只有一个元素,因此均为1        }    }    public int count() {        return count;    }    public int find(int p) {        // 跟随链接找到 根节点        while (p != id[p]) {            p = id[p];        }        return p;    }    public void union(int p, int q) {        int pRoot = find(p);        int qRoot = find(q);        if (pRoot == qRoot) {            return;        }        if (sz[pRoot] > sz[qRoot]) {            id[qRoot] = pRoot;            sz[pRoot] = sz[pRoot] + sz[qRoot];        }else if (sz[pRoot] < sz[qRoot]) {            id[qRoot] = pRoot;            sz[qRoot] = sz[pRoot] + sz[qRoot];        }        count--;//连通后,连通分量总数少1    }}

命题H。对N个触点,加权union算法构造的森林中的任意节点的深度最多为lgN。

推论。对于加权quick-union算法和N个触点,在最坏情况下find connected 和 union 的成本增长数量级为logN。

命题和它的推论的实际意义在于加权quick-union算法是三种算法中唯一能解决大型实际问题的算法。

1.5.2.8 最优算法

路径压缩算法,每个结点都直接连接到根节点。
- 实现:在检查节点的同时将它们直接链接到根节点。

public int find(int p){    int root = p;    while(root != id[root]){        root = id[root];    }    while(p != root){        int newP = p;        id[p] = root;        p = newP;    }    return root;}
0 0