(一)基础(Fundamentals):数据结构

1 基础编程模型

Java 四类八种类型

  • 整型:byte, short, int, long
  • 浮点型:float, double
  • 布尔型:boolean
  • 字符型:char
public static int binarySearch(int[] a, int key){
    int left = 0;
    int right = a.length - 1;
    while(left <= right){
        int mid = left + ((right - left) >>1);
        if(a[mid] > key){
            right = mid -1;
        }
        else if(a[mid] < key){
            left = mid + 1;
        }
        else{
            return mid;
        }
    }
    return -1;
}

数组

  1. 颠倒数组中元素顺序
//注意 n 值、for 循环边界、以及循环体内 n - 1 - i
int n = a.length;
for(int i = 0; i < n/2; i++){
    int temp = a[i];
    a[i] = a[n - 1 - i];
    a[i - 1 - i] = temp;
}
  1. 矩阵相乘
//假设a[n][n], b[n][n], c[n][n]
int n = a.length;
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];
        }
    }
}

静态方法

  • 判断一个数是否为素数

一个数若可以进行因数分解,那么分解时得到的两个数一定是一个小于等于sqrt(n)一个大于等于sqrt(n),据此,代码中不需要遍历到n-1,遍历到sqrt(n)即可,因为若sqrt(n)左侧找不到约数,那么右侧也一定找不到约数。

public static boolean isPrime(int n){
    //小于2直接返回false
    if(n < 2){
        return false;
    }
    for(int i = 2; i * i <= n; i++){
        if (n % i == 0){
            return false;
        }else{
            return true;
        }
    }
}
public static double sqrt(double c){
    if(c < 0){
        return Double.NaN;
    }
    double err = 1e-15;
    double t = c;
    // 1 - c/t^2 > err 两边同乘 t
    while(Math.abs(t - c / t) > err * t){
        t = (t + c / t) / 2;
    }
    return t;
}

方法的性质——参数按值传递:方法中改变一个参数变量的值对调用者没有影响,也意味着数组参数将是原数组别名——方法中使用的参数变量能引用调用者的数组并改变其内容。

递归

递归三个重要性质:

  1. 递归总有一个最简单情况——方法第一条语句总是一个包含return的条件语句。
  2. 递归调用总是尝试解决一个规模更小的子问题,这样递归才能收敛到最简单的情况。
  3. 递归调用的父问题和尝试解决的子问题之间不应该有交集。

输入输出

重定向和管道

//模型:RandomSeq -> 标准输出 -> data.txt
java RandomSeq 1000 2000.0 > data.txt
//模型:data.txt -> 标准输入 -> Average
java Average < data.txt
//模型:RandomSeq -> 标准输出 -> 标准输入 -> Average
java RandomSeq 1000 2000.0 | java Average

Q&A

  1. 如何将double变量初始化为无穷大?
    Double.POSITIVE_INFINITY(1.0/0.0) 和 Double.NEGTIVE_INFINITY(-1.0/0.0)
  2. Java表达式 1/0 和 1.0/0.0 值分别是?
    第一个表达式产生一个运行时除零异常(会终止程序,因为该值未定义);第二个表达式值是 Infinity(无穷大,JDK内部定义 1.0/0.0为正无穷大,-1.0/0.0为负无穷大)。
  3. 负数除法和余数的结果是什么?
    表达式 a/b 的商会向 0 取整;a % b 的余数定义为 (a/b)*b + a%b 恒等于 a。例如 -14/3 和 14/-3 的 商都是-4,但-14 % 3 是 -2,而14 % -3 是 2。

2 数据抽象

使用抽象数据类型

继承的方法
Java 中的所有数据类型都会继承 toString() 方法来返回 String 表示的该类型的值。Java 会在用 + 运算符将任意数据类型的值和 String 值连接时调用该方法。该方法的默认实现并不实用(它会返回用字符串表示的该数据类型值的内存地址),因此常常会提供实现来重载默认实现,并在此时在 API 中加上 toString() 方法。此类方法还有 equals()、compareTo() 和 hashCode()。

抽象数据类型举例

  1. 判断字符串是否是回文
public static boolean isPalindrome(String s){
    int n = s.length;
    for(int i = 0; i < n/2; i++){
        if(s.charAt(i) != s.charAt(n - 1 - i))
            return false;
    }
    return true;
}
  1. 从命令行参数中提取文件名和扩展名
String s = arg[0];
int dot = s.indexOf(".");
String base = s.subString(0, dot);
String extension = s.subString(dot + 1, s.length());
  1. 检查一个字符串数组中元素是否已按照字母表顺序排列
public boolean isSotred(String[] a){
    for(int i = 1; i < a.length; i++){
        if(a[i - 1].compareTo(a[i]) > 0){
            return false;
        }
    }
    return true;
}

a.compareTo(b):比较a和b的ascii码值,

  • a < b:返回 -1 (即前面小于后面)
  • a = b:返回 0 (即前后相等)
  • a > b:返回 1 (即前面大于后面)

数据类型的设计

等价性

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

不可变性

Java 数组(可变),String 类型(不可变)
不可变性的缺点:

  • 需要为每个值创建一个新对象,这种开销一般可以接受,因为 Java 的垃圾回收器通常都为此进行了优化。
  • final 只能保证原始数据类型的实例变量的不可变,无法用于引用类型的变量。如果一个引用类型的实例变量含有修饰符 final,该实例变量的值(某个对象的引用)就永远无法改变了——将永远指向同一个对象,但对象的值本身仍然可变。

3 背包、队列和栈

许多基础数据类型都和对象集合有关。接下来将学习三种数据类型:背包(Bag)、队列(Queue)和栈(Stack)。它们不同之处在于删除或访问对象的顺序不同。

API

每份API都含有一个无参的构造函数、一个向集合添加单个元素的方法、一个测试集合是否为空的方法和一个返回集合大小的方法。

背包
public class Bag<Item> implements Iterable<Item>
Bag() 创建一个空背包
void add(Item item) 添加一个元素
boolean isEmpty() 背包是否为空
int size() 背包中的元素数量

先进先出(FIFO)队列
public class Queue<Item> implements Iterable<Item>
Queue() 创建一个空队列
void enqueue(Item item) 添加一个元素
boolean isEmpty() 队列是否为空
int size() 队列中的元素数量
Item dequeue() 删除最近添加的元素

下压(后进先出,LIFO)栈
public class Stack<Item> implements Iterable<Item>
Stack() 创建一个空栈
void push(Item item) 添加一个元素
boolean isEmpty() 栈是否为空
int size() 栈中的元素数量
Item pop() 删除最近添加的元素

下面介绍一个用栈和泛型的经典例子。

举例:算术表达式求值

(1 + ((2 + 3) * (4 * 5)))

表达式由括号、运算符和操作数(数字)组成,用两个栈,根据以下4种情况从左到右逐个将这些实数送入栈处理。

  1. 操作数压入操作数栈
  2. 运算符压入运算符栈
  3. 忽略左括号
  4. 遇到右括号时,弹出一个运算符,弹出所需数量的操作数,并将运算符和操作数的运算结果压入操作数栈
  5. 处理完最后一个右括号时,操作数栈上只会有一个值,它就是表达式的值

简要证明:每当算法遇到一个被括号包围并由一个运算符和两个操作数组成的子表达式时,它都将运算符和操作数的计算结果压入操作数栈,这样结果就好像在输入中用这个值代替了该子表达式,因此用这个值代替子表达式得到的结果和原表达式相同,可以反复应用这个规律并得到一个最终值。

// Dijkstra 的双栈算术表达式求值算法
public class Evaluate{
    public static void main(String[] args){
        Stack<String> ops = new Stack<String>();
        Stack<Double> vals = 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("sqrt")) v = Math.sqrt(v);
                vals.push(v);
            }else{
                vals.push(Double.parseDouble(s));
            }
        }
        StdOut.println(vals.pop());
    }
}

这段 Stack 的用例使用了两个栈来计算表达式的值。它展示了一种重要的计算模型:将一个字符串解释为一段程序并执行该程序得到结果。简单起见,这段代码假设表达式没有省略任何括号,数字和字符均以空白字符相隔。

集合数据类型的实现

1 定容栈

说明:定容栈只支持 String 值,要求用例指定一个容量且不支持迭代。

实现一份API第一步就是选择数据的表示方式

public class FixedCapacityStackOfStrings{
    private String[] a;
    private int N;
    public FixedCapacityStackOfStrings(int cap){
        a = new String[cap];
    }
    public void push(String item){
        a[N++] = item;
    }
    public String pop(){
        return a[--N];
    }
    public boolean isEmpty(){
        return N == 0;
    }
    public int size(){
        return N;
    }
}

2 进一步:泛型栈

把所有 String 都替换为 Item。Item 是一个类型参数,在实现时无须知道Item实际类型,只要用例在创建栈时提供具体数据类型就可以。实际类型必须是引用类型,用例可以靠自动装箱将原始类型转换为相应的封装类型。另外一个重要细节是我们希望实现一个泛型数组a = new Item[cap],但 Java中不允许创建泛型数组!因此,需要使用类型转换:a = (Item[]) new Object[cap];(Java系统库中类似抽象数据类型的实现也使用了相同的方式)。

public class FixedCapacityStackOfStrings<Item>{
    private Item[] a;
    private int N;
    public FixedCapacityStackOfStrings(int cap){
        a = (Item[]) new Object[cap];
    }
    public void push(Item item){
        a[N++] = item;
    }
    public Item pop(){
        return a[--N];
    }
    public boolean isEmpty(){
        return N == 0;
    }
    public int size(){
        return N;
    }
}

3 动态调整栈大小

选择数组表示栈内容意味着必须预先估计栈的最大容量。容量设大了浪费内存,设小了(没有检测机制)可能溢出。于是需要修改数组实现,动态调整数组 a[] 的大小,使它既能保存所有元素,又不至于浪费过多空间。原理很简单,实现一个方法将栈移动到另一个大小不同的数组中(C++ 中的 Vector 的实现类似)。

添加reSize()方法

private void reSize(int max){
    Item[] temp = (Item[]) new Object[max];
    for(int i = 0; i < N; i++){
        temp[i] = a[i];
    }
    a = temp;
}

修改push()方法

在 push() 中检查数组大小:通过检查栈大小 N 和数组大小 a.length 是否相等来检查数组是否能添加新元素。若没有多余空间,则将数组空间加倍。

public void push(Item item){
    if(N == a.length) reSize(2 * a.length);
    a[N++] = item;
}

修改pop()方法

在 pop() 中首先删除栈顶元素,然后检测如果数组太大就将长度减半。检测条件是栈大小是否小于数组的四分之一

public String pop(){
    String item = a[--N];
    a[N] = null; //避免对象游离
    if(N > 0 && N == a.length / 4) reSize(a.length / 2);
    return item;
}

在这个实现中,栈永远不会溢出,使用率也永远不会低于四分之一(除非栈为空)

对象游离:Java 垃圾收集策略是回收所有无法被访问的对象的内存。在上面的 pop() 中,被弹出的元素的引用仍然存在于数组中,这个元素已经是孤儿了,但垃圾收集器无法这点,除非引用被覆盖。即使用例已不再需要这个元素,数组中引用仍然可以让它继续存在。这种情况(保存一个不需要的对象的引用)成为游离。避免对象的游离很容易,只需将被弹出的元素的值设为 null 即可,这将覆盖无用的引用并使系统可以在用例用完被弹出的元素后回收它的内存。

4 迭代

集合类数据类型基本操作之一就是,能够使用 Java 的 foreach 语句通过迭代遍历并处理集合中的每个元素。

Iterator<String> i =collection.iterator();
while(i.hasNext()){
    String s = i.next();
    //...
}

上述代码说明任意可迭代的集合数据类型中必须实现的东西:

  • 实现一个 iterator() 方法并返回一个 Iterator 对象
  • Iterator 类必须包含两个方法:hasNext()(返回一个布尔值)和next() (返回集合中的一个泛型元素)

在 Java 中,使用接口机制来指定一个类所必须实现的方法。要使一个类可迭代,需如下步骤:

  1. 声明中加入 implements Iterabe<Item>,对应的接口(java.lang.Iterable)为:
public interface Iterable<Item>{
    Iterator<Item> iterator();
}

  1. 类中添加 iterator() 方法并返回一个迭代器 Iterator<Item>。迭代器都是泛型的。
public Iterator<Item> iteraotr(){
    return new ReverseArrayIterator();
}
  1. 迭代器是实现了hasNext()和next()方法的类的对象,由下面接口定义(java.util.Iterator):
public interface Iterator<Item>{
    boolean hasNext();
    Item next();
    void remove();//不实现
}

自定义的迭代器(逆序遍历):

private class ReverseArrayIterator implements Iterator<Item>{
    private int i = N;
    public boolean hasNext(){
        return i > 0 ;
    }
    public Item next(){
        return a[--i];
    }
}

5 下压(LIFO)栈(能够动态调整数组大小的完整实现):

public class ResizingArrayStack<Item> implements Iterable<Item>{
    private Item[] a = (Item[]) new Object[1];
    private int N = 0;
    public boolean isEmpty(){
        return N == 0;
    }
    public int size(){
        return N;
    }
    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 void reSize(int max){
        Item[] temp = (Item[]) new Obecjt[max];
        for(int i = 0; i < N; i++){
            temp[i] = a[i];
        }
        a = temp;
    }

    public Iterator<Item> iterator(){
        return new ReverseArrayIterator();
    }

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

该泛型可迭代Stack API的实现是所有集合类抽象数据类型实现的模板。它将所有元素保存在数组中,并动态调整数组大小以保持数组大小和栈大小之比小于一个常数。

链表

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

1 结点记录

用一个嵌套类定义节点的抽象数据类型:

private class Node{
    Item item;
    Node next;
}

2 构造链表

Node first = new Node();
first.item = "to";
Node second = new Node();
second.item = "be";
first.next = second;
Node third = new Node();
third.item = "or";
second.next = third;

3 在表头插入结点

Node oldFirst = first;
first = new Node();
first.item = "not";
first.next = oldFirst;

4 在表头删除结点

first = first.next;

5 在表尾插入结点

Node oldLast = last;
last = new Node();
last.item = "to";
oldLast.next = last;

6 在其它位置的插入和删除结点

例如:怎样删除链表的尾结点呢?目前唯一方法是遍历整条链表并找出指向 last 的结点,所需时间和链表长度成正比。

实现任意插入和删除操作的标准解决方案是使用双向链表

7 链表的遍历

for(Node x = first; x ! = null; x = x.next){
    //处理 x.item
}

8 链式栈的实现

public class Stack<Item>{
    private class Node{
        Item item;
        Node next;
    }

    private int N;
    private Node first;
    public boolean isEmpty(){
        return N == 0;
    }
    public boolean size(){
        return N;
    }
    public void push(Item item){
        Node oldFirst = first;
        first.item = item;
        first.next = oldFirst;
        N++;
    }
    public Item pop(){
        Item item = first.item;
        first = first.next;
        N--;
        return item;
    }
}

9 链式队列的实现

public class Queue<Item>{
    private class Node{
        Item item;
        Node next;
    }

    Node first;
    Node last;
    private int N;
    public boolean isEmpty(){
        return N == 0;
    }
    public int size(){
        return N;
    }
    public void enQueue(Item item){
        Node oldLast = last;
        last = new Node();
        last.item = item;
        if(isEmpty()){
            first = last;
        }else{
            oldLast.next = last;
        }
        N++;
    }
    public Item deQueue(){
        Item item = first.item;
        first = first.next;
        if(isEmpty()){
            last = null;
        }
        N--;
        return item;
    }
}

注意:enQueue() 中当原链表为空时需将 first 和 last 都指向新结点;deQueue() 中当链表为空时也要更新 last 的值

背包(Bag)

用链表实现 Bag 只需将 push() 改为 add() 并去掉 pop() 即可,访问顺序是后进先出但不重要。

// isEmpty() 和 size() 同 Stack
public class Bag<Item> implements Iterable<Item>{
    private class Node<Item>{
        Item item;
        Node next;
    }
    Node first;

    public void add(Item item){
        Node oldFirst = first;
        first = new Node();
        first.item = item;
        first.next = oldFirst;
    }
    public Iterator iterator(){
        return new ListIterator();
    }
    private class ListIterator<Item> implements Iterator<Item>{
        //不直接使用first,单独保存一个引用,避免覆盖 first
        private Node current = first;
        public boolean hasNext(){
            return current != null;
        }
        public Item next(){
            Item item = current.item;
            current = current.next;
            return item;
        }
    }
}
数据结构 优 点 缺 点
数组 通过索引可以直接访问任意元素 在初始化时就要知道元素的数量
链表 使用的空间大小和元素数量成正比 需要通过引用访问任意元素

在研究新问题时,采用以下步骤识别目标并使用数据抽象解决问题:

  • 定义API
  • 根据特定应用场景开发用例代码
  • 描述一种数据结构(一组值的表示),并在 API 所对应的抽象数据类型的实现中根据它定义类的实例变量
  • 描述算法(实现一组操作的方式),并根据它实现类中实例方法
  • 分析算法性能

以后用到的一些数据结构:

数据结构 抽象数据类型 数据表示
父链接树 UnionFind 整形数组
二分查找树 BST 含有两个链接的结点
字符串 String 数组、偏移量和长度
二叉堆 PQ 对象数组
散列表(拉链表) SeparateChainingHashSH 链表数组
散列表(线性探测法) LinearProbingHashST 两个对象数组
图的邻接链表 Graph Bag 对象的数组
单词查找树 TrieST 含有链接数组的结点
三向单词查找树 TST 含有三个链接的结点

Q&A

  1. 不是所有语言都支持泛型,其它替代方案:
    • 为每种类型数据都实现一个不同集合数据类型
    • 构造一个 Object 对象的栈,并在 pop() 时进行类型转换。缺点是类型不匹配错误只能在运行时发现,而在泛型中可在编译期发现。
  2. Java不允许泛型数组?
    详细了解 共变数组(convariant array)类型擦除(type erasure)
  3. 创建一个字符串栈的数组:
    Stack<String>[] a = (Stack<String>[]) new Stack[N]; 使用泛型时,Java会在编译时检查类型安全性,但会在运行时抛弃所有这些信息。因此运行时右侧语句变为 Stack<Object>[] 或只剩下 Stack[N],故需要类型转换。
  4. 为什么将 Node 声明为嵌套类?为什么用 private?
    这样可以将 Node 的方法和实例变量的访问范围限制在包含它的类中。私有嵌套类另一个特点是只有包含它的类能直接访问它的实例变量,因此不用将它的实例变量声明为 public 或 private。非静态的嵌套类也被称为内部类
  5. 运行 javac Stack.java 生成 Stack.classStack$Node.class
    后者是内部类 Node 创建的,Java 中 $分隔外部类和内部类。
  6. 能否向栈或队列添加空(null)元素?
    Java中很常见(如 Stack 和 List 允许,但测试发现 Queue 似乎不允许)。
  7. 若在迭代中用 push() 或 pop(),Stack 的迭代器怎么办?
    java.util.ConcurrentModificationException
  8. foreach 循环可以访问数组,但不能访问 String?
    String 没有实现 Iterable 接口。
  9. 为什么不设计一个通用的 Collection 实现添加元素、删除最近插入元素、删除最早插入的元素、删除随机元素、迭代、返回集合元素数量等?
    使用窄接口而不是宽接口,原因是无法保证高效实现所有方法,并且分开设计更简单,代码更易懂。

推荐阅读更多精彩内容