Algorithms (4th-Edition) Reading Notes: Fundamentals


本章開始閱讀時間: 2016年8月25日
本章結束閱讀時間: 2016年8月31日

注:本人閱讀的是英文原版書籍。

本章摘要:第一章用了200+頁,但并沒有急于進入算法部分。由于全書是通過Java語言來描述算法,所以這一章主要是以Java為例來講解基本的編程知識,不熟悉Java的同學完全可以將本章作為Java的入門學習材料。讀完第一章我的感受是:這本書完全是面向編程和算法初學者,但又不失一定的深度。作者充分考慮了初學者的感受,用了大量的圖表幫助讀者理解算法的行為,文字描述也非常用心。這本書的配套網站上也有對每章主要內容的概述,讀完每一節再利用網站的內容進行復習,效果非常好。

1 Fundamentals

1.1 Basic Programming Models

p.23上列舉了幾個靜態方法的實現,其中素性測試、牛頓法求平方根挺有用,應該理解并記下來。

方法(函數)的一些性質:

  • Java中,參數默認是按值傳遞的(pass by value)
  • 函數名可以被重載(overload)
  • 一個方法只能有一個返回值(Matlab中,可以有多個返回值)
  • 副作用(修改數組的值,輸出等)

遞歸的三要素:

  • 要單獨處理base-case,即問題不可劃分的最小情形,在處理完后用return返回結果;
  • 子問題:要合理地劃分出子問題;
  • 子問題不應該重疊。

外部庫

  • java.lang.*是標準系統庫,我們常用的Java類就在這個庫中,比如System, Math, Integer, Double, String等等
  • 需要手動導入的標準庫:比如java.util.Arrays
  • 第三方庫

p.32上列舉的StdRandom庫里的幾個靜態方法很有學習價值,比如洗牌算法shuffle()。我想到的是它可以用在遺傳算法種群初始化上~

  • StdDrew的API介紹:p.43p.45上有一些很有意思的小程序
Binary search

不多說了,經典的不能再經典的算法:

public class BinarySearch {
    public static int rank(int key, int[] a) {
        int start = 0;
        int end = a.length - 1;
        while (start <= end) {
            int mid = start + (end - start) / 2;
            if (key < a[mid]) {
                end = mid - 1;
            } else if (key > a[mid]) {
                start = mid + 1;
            } else {
                return mid;
            }
        }
        return -1;
    }
}

在測試p47BinarySearch時,由于我使用的是IntelliJ IDEA,無法像在命令行里那樣通過<重定向輸入流,試了在Run/Debug Configurations里設置Program arguments,比如這個語句tinyW.txt < tinyT.txttinyW可以讀入,tinyT.txt就無法讀入。在SegmentFault找到了答案。可行的做法是要手動在獲得輸入前重定向一下標準輸入:

try {
    FileInputStream input = new FileInputStream("algs4-data/tinyT.txt");
    System.setIn(input);
} catch (FileNotFoundException e) {
    e.printStackTrace();
}

需要引入的庫:

import java.io.FileInputStream;
import java.io.FileNotFoundException;

常見問題

  • 數組的重疊(Aliasing):如果要對一個數組進行復制,不能簡單地把一個已經存在的數組賦值給它,而應該重新聲明一個新的數組,并為他分配內存,然后通過初始化來拷貝。比如,下面的方法就是錯誤的:

    int[] a = new int[N];
    ...
    a[i] = 1234;
    ...
    int[] b = a;    // 這并不是拷貝,b僅僅是a的一個別名
    b[i] = 5678;    // 同時也會修改a[i]的內容
    

    所以,這是一個很容易犯錯的地方,必須弄清楚概念。

  • 整數溢出的一個典型例子:Math.abs(-2147483648)的結果是-2147483648
  • 整型的最大值和最小值:Integer.MAX_VALUEInteger.MIN_VALUE;如何將double型初始化為無窮大?正無窮是Double.POSITIVE_INFINITY,負無窮是Double.NEGATIVE_INFINITY
  • 1/0的結果是runtime異常,1/0.0的結果是INFINITY
  • 在Java中,以下兩種定義方式:int a[]int[] a是等價的,Java推薦后者。
  • Java不支持函數式編程(無法將一個靜態方法作為某個靜態方法的參數傳入)。

1.2 Data Abstraction

靜態方法和非靜態方法的不同

  • 主要目的:靜態方法(static method)的首要目的是實現函數(單一功能),非靜態方法或實例方法(non-static method, instance method)的首要目的是實現對數據類型的操作;
  • 調用方式:靜態方法的調用以類名開始(首字母大寫),如Math.sqrt(2.0);而實例方法的調用以對象名開始(首字母小寫),如dog.bark()
  • 參數:靜態方法是直接傳入參數(值傳遞),實例方法是對象和參數的引用。

使用對象

  • 賦值。注意對引用的賦值不同于拷貝,和數組的Aliasing一樣,僅僅是復制了引用,實體并沒有復制。
  • 參數傳遞。好好理解這句話:The convention is to pass the reference by value (make a copy of it) but to pass the object by reference. 對象作為參數傳遞時,都是通過引用。
  • 返回值。對象可以作為返回值。這就相當于提供了返回多個變量的方法。
  • 注意:數組是對象!Java中,除了基本數據類型(primitive type),所有的類型都是對象。這也是為什么將數組作為參數傳遞時可以修改數組內部元素,因為數組是對象,而對象都是通過引用傳遞的。
  • 小結:數據類型(data type)是一個值的集合以及定義在它們上面的操作集合。對象(object)是數據類型的實體。對象的三要素:狀態(state),身份(identity),行為(behavior)。

抽象數據類型的例子

  • Java的String類。一個問題:為什么不使用字符數組而要使用String呢?答案與使用任何ADT的理由一樣:為了讓客戶代碼(client code)簡潔明了。
  • p.81列舉了一些典型的Java字符串處理代碼,比如回文串檢測、文件名解析、按特殊字符分割、字典序檢測等,很有用。

實現抽象數據類型

  • 實例變量(instance variables)。通常聲明為private(否則就不是抽象類型了);常量(初始化后就不再改變的量)用final修飾。
  • 構造函數(constructors)。一個Java類至少有一個構造函數。構造函數的目的就是初始化實例變量。構造函數與類名保持一致。
  • 實例方法(instance methods)。實例方法與靜態方法的不同:實例方法可以訪問和操作實例變量。
  • 作用域(scope)。在實現實例方法時,會使用三種變量:參數變量、局部變量、實例變量。前兩個與靜態方法一樣:參數變量的作用域是整個函數體;局部變量的作用域是從它被定義之后的塊體。而實例變量的作用域是整個類。

數據類型的設計

暫時先跳過。

常見問題

  • 創建對象數組:如果要創建一個有N個對象的數組,需要使用N+1次new——一次用來初始化數組,其他N次用來創建對象。正確的方法:
Counter[] a = new Counter[2];
a[0] = new Counter("test");
  • 什么是指針?p.111給出了詳細的解釋。

1.3 Bags, Queues, and Stacks

一些基本概念

泛型(generics) 是一種參數化的數據類型,而不具體指明是哪種。這樣可以簡化類的實現(否則對每個數據類型都要實現一遍,就很麻煩)。

與泛型聯系的概念是基本數據類型的自動封裝(autoboxing)和解封裝。

Iterable collections 不知道怎么翻譯...迭代集?意思就是用迭代器遍歷存放對象的數據結構,這是Java語言一種很方便的設計,因為我們在遍歷時無需知道對象的具體結構了。很多高級編程語言也支持這種特性。

Bags

  • 我理解的,bag是類似于set的東西,不同的是元素可以重復。
  • bag不支持移除元素。bag的目的是為用戶提供收集元素、訪問已收集元素的服務。遍歷元素的順序是不確定的,對用戶而言也是無關緊要的。

FIFO queues

基本的數據結構——先入先出隊列。p.126舉了一個連續讀入整數存入整數數組的例子,就是利用了隊列:先從輸入流把數據存入隊列,于是就可以根據隊列大小申請數組大小;再讓所有整數出隊,并存入數組中。

不過個人感覺這樣讀入是不是效率低了些...因為有入隊和出隊的開銷。但是好處似乎也是有的,就是不用事先知道有多少整數,而是通過建立隊列知道數組要開多大;而如果直接用數組就無法準確知道預分配的大小。

Pushdown stacks

就是我們通常所說的棧。這里指的是“從上往下”壓棧的方式,實際中還有“從下往上”壓棧的方式。對應的就是大端存儲和小端存儲?

算術表達式的模擬

p.128講了如何用棧實現對算術表達式的解析。其實就是用棧模擬遞歸的一種形式。一個非常簡單的算法是Dijkstra前輩于1960年提出來的。基本思想是維護兩個棧,一個用來存放運算對象(operand stack),一個用來存放運算符(operator stack)。從左到右遍歷算術表達式,并執行如下操作:

  • 把運算對象放入operand stack
  • 把運算符放入operator stack
  • 忽略左括號
  • 一旦遇到一個右括號,pop出一個運算符,pop出對應的運算對象(單目運算符pop出一個,雙目運算符pop出兩個),然后把運算結果放入operand stack

最終operator stack空,operand stack中只有一個運算對象,它就是整個算術表達式的結果。

需要加載的庫:

import edu.princeton.cs.algs4.*;
import java.io.FileInputStream;
import java.io.FileNotFoundException;

代碼:

public static void expressionEvaluate(String[] args) {
    Stack<String> ops = new Stack<String>();
    Stack<Double> vals = new Stack<Double>();
    // 重定向輸入流:從文件輸入
    try {
        FileInputStream input = new FileInputStream("tempString.txt");
        System.setIn(input);
    } catch (FileNotFoundException e) {
        e.printStackTrace();
    }

    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());
}

上面的處理假定表達式有完整的括號(最外層也有)。其中tempString.txt中即存放所要解析的算術表達式,如( 1 + ( ( 2 + 3 ) * ( 4 * 5 ) ) ),對應輸出為101.0;再如( ( 1 + sqrt ( 5.0 ) ) / 2.0 ),對應輸出為1.618033988749895

這個算法的復雜度是O(n)。

Implementing collections

本節講的是在實現Bag, Stack和Queue之前,先實現一些基本的框架。這個"collections"大概就是對數據結構的一種統稱吧,即存儲同類對象的邏輯結構。

Fixed-capacity stack

一個非常簡單的棧模型,只能存儲String,要求用戶定義容量,不支持迭代器。

文中說道:

The primary choice in developing an API implementation is to choose a representation for the data.

一個顯然的選擇是用String數組。用一個數組a[]存放實例變量,用一個整數N統計棧中元素的個數。須滿足的特性:

  • 數組中的元素按插入的順序排列
  • N0的時候,棧為空
  • 棧不空時,棧頂是a[N-1]

實現:

public class FixedCapacityStackOfStrings {
    private String[] a;
    private int N;

    public FixedCapacityStackOfStrings(int cap) {
        a = new String[cap];
    }

    public boolean isEmpty() {
        return N == 0;
    }

    public int size() {
        return N;
    }

    public void push(String item) {
        a[N++] = item;
    }

    public String pop() {
        return a[--N];
    }
}

上面的實現就是最簡單的棧模型,但是這個實現只是玩具程序,有很多缺點,后面會在這個基礎上設計更實用的數據結構。

泛型

FixedCapacityStackOfStrings的實現中,第一個要改進的地方就是適應所有的對象,而不僅僅是String,即使用泛型。方法是將代碼中所有的String改為Item,另外在類名后面加一個后綴<Item>,于是將實現改為如下:

public class FixedCapacityStack<Item> {
    private Item[] a;
    private int N;

    public FixedCapacityStack(int cap) {
        a = (Item[]) new Object[cap];
    }

    public boolean isEmpty() {
        return N == 0;
    }

    public int size() {
        return N;
    }

    public void push(Item item) {
        a[N++] = item;
    }

    public Item pop() {
        return a[--N];
    }
}

使用了Java中的關鍵詞Item來表示泛型,是用來代表類型的參數。注意創建泛型數組的方式,有一個疑問:為什么不寫成a = new Item[cap]來創建數組?書中說這是由于歷史和技術的原因導致Java不允許這種方式。所以就有了這種寫法:a = (Item[]) new Object[cap]。確實即使是這么寫編譯器也會警告,只不過我們可以安全地忽略它。我可以把它理解成強制類型轉換嘛...而且是強制類型轉換成泛型數組...

Array resizing

第二個要改進的地方就是盡量避免用戶自己指定內存空間的大小(如果用戶申請了很小的內存空間,則可能導致數組越界;而如果申請了很大的內存空間,則可能造成浪費),而是自適應地調整。

實現的思路很簡單:當push元素時,將原數組的內容拷貝到一個空間更大的數組;當pop元素時,則將原數組的內容拷貝到一個空間更小的數組。這讓我想到了C++里vector的實現,它的動態數組機制也是這樣的方式。

首先,把數組的長度擴大為max。其實是將原數組的內容拷貝到另一個長度為max的數組:

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

注意最后一句的引用賦值,a指向了新的內存空間,原來的內存空間并沒有處理,Java的垃圾回收機制會完成這一工作。

然后,當push元素時,檢查數組是否已滿,即判斷已經放入的元素數量有沒有達到數組的長度,如果是,就把所有元素拷貝到一個長度為原來兩倍的數組中,即執行resize(2*a.length),之后再放入新的元素:

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

類似地,當pop元素時,先去掉棧頂元素(--N)。再檢查數組是否盈余過多,是否「盈余過多」的標準不唯一,通常的做法是看已有元素數量是否小于數組長度的1/4,如果是,則表示盈余過多,就將數組長度減半,即resize(a.length/2)

public Item pop() {
    Item item = a[--N];
    a[N] = null;  // Avoid loitering
    if (N > 0 && N == a.length/4) {
        resize(a.length/2);
    }
    return item;
}
Loitering

這個單詞是「閑逛,游手好閑」的意思,這是一種隱式的內存泄漏問題,就是指在上面的pop()函數中,我們用a[--N]模擬「彈出棧頂元素」這一動作,但是實際上數據還是保存在內存里的,而它也不會再被訪問,Java的垃圾回收機制也無法處理這種現象,因此這個數據就成為了「孤兒」,即Loitering現象。要避免這種情況,只要把對應的元素置為null即可。其實,申請泛型數組的內存時,也是初始化為null的。

Iteration (迭代)

回顧使用String類的迭代器進行遍歷的操作:

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

或者用foreach操作:

for (String s : collection)
    ...

類似地,為了讓我們自己定義的類可迭代,須要完成以下兩個工作:

  • 實現方法iterator(),它返回對象的迭代器(迭代器也是一個類),對于泛型,為Iterator<Item>
  • 實現迭代器Iterator,它有兩個方法:hasNext()(返回值是boolean類型),以及next()(返回對象)

為了讓一個類是iterable的,首先須要在聲明這個類時加上語句implements Iterable<Item>。然后,實現iterator()方法,我們將馬上要實現的迭代器命名為ReverseArrayIterator(棧是反向遍歷):

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

Java提供了實現迭代器的接口,定義如下(java.util.Iterator):

public interface Iterator<Item> {
    boolean hasNext();
    Item next();
    void remove();
}

通常,remove()方法可以不用實現。于是,按照上面的接口,我們的迭代器ReverseArrayIterator實現如下(直接寫在大類中,聲明為private,這是類的嵌套寫法):

// 需要: import java.util.iterator;
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() {}
}

通常為了保證迭代器的健壯性,還需要針對兩個情形拋出異常(throw exception):一是當用戶調用remove()方法時,拋出UnsupportedOperationException,二是當i0時,用戶仍然試圖調用next()方法,這時拋出NoSuchElementException。這里就省略了~

到這里,迭代器的實現就完成了。小結一下,改進的地方有:

  • 泛型化
  • 內存大小自適應調整
  • 避免隱式內存泄漏
  • 加上迭代器功能

完整的ResizingArrayStack實現:

public class ResizingArrayStack<Item> implements Iterable<Item> {
    private Item[] a = (Item[]) new Object[1];  // 初始化數組大小為1
    private int N = 0;

    public boolean isEmpty() {
        return N == 0;
    }

    public int size() {
        return N;
    }

    private void resize(int max) {
        // N <= 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;  // Avoid loitering
        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() {}
    }
}

這個基本模型的實現可謂「麻雀雖小,五臟俱全」。

Linked lists

Linked list這個最基本且重要的數據結構就不多說了,再復習一下定義:

A linked list is a recursive data structure that is either empty (null) or a reference to a node having a generic item and a reference to a linked list.

其抽象類為:

private class Node {
    Item item;
    Node next;
}
構建一個鏈表

以創建字符串節點為例,首先創建節點實體:

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

建立連接關系:

first.next = second;
second.next = third;
鏈表的基本操作
  • 在頭部插入節點
  • 刪除頭結點(first = first.next)
  • 在尾部插入節點
  • 在任意位置插入/刪除節點
鏈表的遍歷
for (Node x = first; x != null; x = x.next) {
    ...
}
用鏈表實現棧(鏈式棧)

棧頂位于鏈表的頭部,棧的push通過在頭部插入節點完成,棧的pop通過刪除頭節點完成。

之間用數組實現棧存在效率問題,主要是resize()函數導致的,因為涉及到對整個棧內存的拷貝。而如果用鏈表實現棧,就不存在這個問題了,對棧的操作與棧的大小是無關的。最優設計原則即:

  • 適用于任何數據類型(支持泛型)
  • 空間需求與放入的元素數量成正比(無空間浪費)
  • 操作的時間效率與數據結構的規模無關(O(1)時間的基本操作)

The algorithms and data structure go hand in hand.

完整的Stack類實現:

public class Stack<Item> implements Iterable<Item> {
    private Node first;
    private int N;

    private class Node {
        Item item;
        Node next;
    }

    public boolean isEmpty() {
        return first == null;  // 或者: N == 0
    }

    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;
    }

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

    private class ListIterator implements Iterator<Item> {
        private Node current = first;

        public boolean hasNext() {
            return current != null;
        }

        public Item next() {
            Item item = current.item;
            current = current.next;
            return item;
        }
    }
}

友情提醒:別忘了import java.util.Iterator;

用鏈表實現隊列(鏈式隊列)

在鏈式棧的實現上稍加修改,就可以實現隊列。鏈表的頭、尾分別代表隊列的頭和尾,維護兩個指針指向鏈表的頭和尾,入隊操作通過在鏈表尾部插入節點實現,出隊操作通過刪除鏈表頭節點來實現。這也符合最優設計。

完整的Queue類實現:

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 first == null;  // 或者: N == 0
    }

    public int size() {
        return N;
    }

    public void enqueue(Item item)  {
        Node oldLast = last;
        last = new Node();
        last.item = item;
        last.next = null;
        if (isEmpty()) first = last;
        else oldLast.next = last;
        N++;
    }

    public Item dequeue() {
        Node oldFirst = first;
        first = first.next;
        if (isEmpty()) last = null;
        N--;
        return oldFirst.item;
    }

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

    private class ListIterator implements Iterator<Item> {
        private Node current = first;

        public boolean hasNext() {
            return current != null;
        }

        public Item next() {
            Item item = current.item;
            current = current.next;
            return item;
        }
    }
}
用鏈表實現包(bag)

Bag的實現其實比StackQueue都簡單,只要把Stackpush()改為add(),另外去掉pop()方法即可。

完整的Bag實現:

public class Bag<Item> implements Iterable<Item> {
    private Node first;
    private int N;

    private class Node {
        Item item;
        Node next;
    }

    public boolean isEmpty() {
        return first == null;
    }

    public int size() {
        return N;
    }

    public void add(Item item) {
        Node oldFirst = first;
        first = new Node();
        first.item = item;
        first.next = oldFirst;
        N++;
    }

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

    private class ListIterator implements Iterator<Item> {
        private Node current = first;

        public boolean hasNext() {
            return current != null;
        }

        public Item next() {
            Item item = current.item;
            current = current.next;
            return item;
        }
    }
}

Bag, StackQueue類的迭代器實現部分都一樣。

常見問題

  • 并不是所有的編程語言都有泛型(早期的Java也沒有),怎么辦?一種辦法是對每一種類型都實現一次;還有一種是建立一個存放各種對象的棧。
  • 為什么Java不支持泛型數組?歷史問題。當前也正在研究中。
  • 如何創建一個數組,其中每個元素是一個存放字符串的棧?Stack<String>[] a = (Stack<String>[]) new Stack[N]
  • 對于數組,可以使用foreach遍歷嗎?可以。
  • 對于String,可以使用foreach遍歷嗎?不可以。String沒有實現迭代器。
  • 為什么不創建一個這樣的數據類型:它支持添加元素、刪除元素、迭代、返回某個元素...以及所有我們想要的功能,這樣不是就可以只實現一個類了嗎?答:需要提醒的是,這樣的實現被稱為寬接口(wide interface)。Java其實提供了這樣的實現,比如java.util.ArrayList, java.util.LinkedList等。為什么要盡量避免使用它們呢?一個原因是沒法保證所有的操作都是高效的,另一個原因是they enforce a certain discipline on client programs, which makes client code much easier to understand

1.4 Analysis of Algorithms

由于有基礎,算法分析這一節可以快速閱讀,里面的實驗就省略了,都能理解(感覺這節還真是啰嗦啊~)。

書上p.185有一些級數的階的近似,最好能夠熟悉。

2-sum快速算法

idea: 如果a[i]是一對2-sum中的一個,那么另一個一定是-a[i]

  • 首先對數組a按從小到大排序
  • 遍歷數組,對每個a[i],二分搜索-a[i],如果返回的下標大于i,就將計數器加1(要求大于i是為了避免重復統計)

算法復雜度是O(n log n)。

代碼(要import java.util.Arrays;):

public class TwoSumFast {
    public static int count(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;
    }

    public static void main(String[] args) {
        int[] a = In.readInts(args[0]);
        StdOut.println(count(a));
    }
}

3-sum快速算法

類似2-sum,idea: 如果a[i], a[j]是一個3-sum中的兩個數,那么第三個數一定是-(a[i]+a[j])

算法類似2-sum,這里不再描述。復雜度是O(n^2 log n)

代碼:

public class ThreeSumFast {
    public static int count(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;
    }

    public static void main(String[] args) {
        int[] a = In.readInts(args[0]);
        StdOut.println(count(a));
    }
}

后面關于Memory分析的部分實在是沒有閱讀欲望,比較枯燥,有些部分就直接跳過了,以后有需要再回來補上。

1.5 Case Study: Union-Find

吐槽:第一章直接拿并查集來作為case study,與本章講解的內容似乎并沒有什么邏輯關系...難道不應該放在圖論部分嗎?

又想到了曾經看過的一篇文章,我至今認為它是對并查集最易懂且最有趣的講解。文章的作者已無從考究,這是一篇轉載: http://www.cnblogs.com/ACShiryu/archive/2011/11/24/unionset.html

Quick-union

這是最基本的并查集算法。

public class UF {
    private int[] id;
    private int count;

    public UF(int N) {
        // 初始化連通分量
        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) {
        return find(p) == find(q);
    }

    public int find(int p) {
        while (id[p] != 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;

        id[pRoot] = qRoot;

        count--;
    }
}

quick-union算法中的connected(), find(), union()的平均時間復雜度是O(log n)。

Weighted quick-union

上面的算法有個缺陷:由于執行union()操作時是任意連接兩個連通分量的,連通分量的連接受輸入數據影響比較大,可能會造成很不均衡的狀態。一個改進的思路是,用一個數組記錄并跟蹤每個連通分量(樹)的節點數,每次連接兩個樹時,把節點數較少的樹連到節點數較多的樹上。

public class WeightedQuickUnionUF {
    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) {
        return 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--;
    }
}

這樣,如果有N個節點,最壞情況下,最后構造的森林中所有節點的深度也不會超過log n。從而的算法的connected(), find(), union()的最壞時間復雜度就被降到了O(log n)。

在實際中,真正實用的算法是weighted quick-union,其時間復雜度是O(m log n),其中m是連接數(邊數),n是節點數。

路徑壓縮

觀察上面的代碼,可以看出,weighted quick-union的效率瓶頸就在于find()函數,怎樣可以更快地查找呢?一個idea就是:修改find()函數,想辦法將搜索根節點過程中遇到的節點直接連到根上。這就是所謂的路徑壓縮(path compression)。路徑壓縮的實現非常容易,只要稍稍修改一下find()函數即可:用一個變量保存根節點,再走一遍剛才的搜索路徑,將路徑上所有節點的id[]值為根節點即可:

public int find(int p) {
    int r;  // 根節點
    int cur = p;
    while (cur != id[cur]) {
        cur = id[cur];
    }
    r = cur;
    // 路徑壓縮
    cur = p;
    int tmp;
    while (cur != r) {
        tmp = id[cur];
        id[cur] = r;
        cur = tmp;
    }

    return r;
}

雖然看起來是增加了一個循環,但是卻能將搜索的效率大大提升,能使搜索的效率接近常數級(平攤復雜度并不是嚴格的常數級),尤其在數據規模很大的時候。帶路徑壓縮的weighted quick-union的算法分析十分復雜,理論分析已經證明了它是union-find問題的最優算法。

方法論

在這一節的最后總結了解決問題的一些基本步驟,還是挺好的:

  • 徹底并且具體地明確問題,包括弄清楚問題內在的基本抽象操作,以及合理地設計API。
  • 先考慮最直接的算法,仔細地設計一個簡潔的實現;然后認真地設計測試,盡可能模擬實際場景下的輸入。
  • 要知道當前的實現無法解決什么樣規模的問題。
  • 通過一步步的提煉來改進算法,并結合經驗和數學分析驗證其效果。
  • 尋找數據結構或算法更高級的抽象,以更好地優化算法。
  • 保證對典型的輸入能有好的性能,必要時也要努力保證最壞情況的性能。
  • 知道何時應該將更深層的優化和研究細節交給研究者們,然后繼續解決下一個問題(及時放手)。
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容

  • Android 自定義View的各種姿勢1 Activity的顯示之ViewRootImpl詳解 Activity...
    passiontim閱讀 173,333評論 25 708
  • 從三月份找實習到現在,面了一些公司,掛了不少,但最終還是拿到小米、百度、阿里、京東、新浪、CVTE、樂視家的研發崗...
    時芥藍閱讀 42,366評論 11 349
  • 上周剛剛去了黃山,總想試著寫點什么,雖然不知道能寫出什么,就這樣稀里糊涂的開始啦! 我們是周五晚上出發,周六早上開...
    錢小丫丫閱讀 347評論 0 2
  • 相信是一種能力,它會讓你處在一種安全和幸福的自在狀態里。 你相信自己嗎? 相信自己其實就是一股內在強大的力量。它無...
    湘云xy閱讀 1,945評論 2 4
  • 遠望南山渺,近觀鷓鴣麻。 孤鳥獨行志,落寞萬人家。 梅骨朔雷電,白雪笑殘霞。 今朝不北上,何時看芳華?
    東武居士閱讀 354評論 0 2