- book site: http://algs4.cs.princeton.edu/home/
- 用Java語言描述
- 用到的第三方庫下載地址: algs4.jar
- 用到的所有數據下載地址: algs4-data.zip
- 導入庫時,用
import edu.princeton.cs.algs4.*
本章開始閱讀時間: 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.43
。p.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;
}
}
在測試p47
的BinarySearch
時,由于我使用的是IntelliJ IDEA,無法像在命令行里那樣通過<
重定向輸入流,試了在Run/Debug Configurations
里設置Program arguments
,比如這個語句tinyW.txt < tinyT.txt
,tinyW
可以讀入,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_VALUE
,Integer.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
統計棧中元素的個數。須滿足的特性:
- 數組中的元素按插入的順序排列
- 當
N
是0
的時候,棧為空 - 棧不空時,棧頂是
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
,二是當i
為0
時,用戶仍然試圖調用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
的實現其實比Stack
和Queue
都簡單,只要把Stack
的push()
改為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
, Stack
和Queue
類的迭代器實現部分都一樣。
常見問題
- 并不是所有的編程語言都有泛型(早期的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。
- 先考慮最直接的算法,仔細地設計一個簡潔的實現;然后認真地設計測試,盡可能模擬實際場景下的輸入。
- 要知道當前的實現無法解決什么樣規模的問題。
- 通過一步步的提煉來改進算法,并結合經驗和數學分析驗證其效果。
- 尋找數據結構或算法更高級的抽象,以更好地優化算法。
- 保證對典型的輸入能有好的性能,必要時也要努力保證最壞情況的性能。
- 知道何時應該將更深層的優化和研究細節交給研究者們,然后繼續解決下一個問題(及時放手)。