Algorithms4 《演算法》第四版 內容整理第一章乾貨
阿新 • • 發佈:2019-01-06
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包使用這種方法給所有的原始數值型別實現了
Math.abs()
、Math.min()
、Math.max()
。另一種用法是為函式定義兩個版本,一個需要一個引數另一個則為該引數提供一個預設值。 - 方法只能返回一個值,但可以包含多個返回語句:儘管可能存在多條返回語句,任何靜態方法每次
- 方法可以產生副作用: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 字串
自動轉換:Java在連線字串的時候會自動將任意資料型別的值轉換為字串:如果加號(+)的一個引數是字串,那個Java會自動將其他引數都轉換為字串。這樣,通過一個空字串”“可將任意資料型別的值轉換為字串值。
練習
1.2 資料抽象
1.2.1 使用抽象資料型別
- 抽象資料型別(ADT)的定義和靜態方法庫共同之處:
- 兩者的實現均為Java類
- 例項方法可能接受0個或多個指定型別的引數,由括號表示並且逗號分隔;
- 它們可能返回一個指定型別的值,也能不會(用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.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
- 不可變性: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中,類名後的<Item>
記號將Item
定義為一個型別引數。它是一個象徵性的佔位符,表示的是用例將會使用的某種具體資料型別。
例如,編寫用棧來處理String
物件:
java
Stack<String> stack = new Stack<String>();
stack.push("Test");
...
String next = stack.pop();
使用佇列處理Date物件:
java
Queue<Date> queue = new Queue<Date>();
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)
- 迭代訪問集合中的所有元素
例如,假設用例在Queue中維護一個交易集合
java
Queue<Transaction> collection = new Queue<Transaction>();
如果集合是可迭代的,用例用一行語句即可打印出交易的列表:
for (Transaction t : collection){ StdOut.print(t);}
這種語法叫foreach
語句 - 揹包是一種不支援從中刪除元素的集合資料型別——它的目的是幫助用例收集元素並迭代遍歷所有收集到的元素(用例也可以檢查揹包是否為空或者獲取揹包中元素的數量)。迭代的順序不確定且與用例無關。
圖1.3.1 簡單的計算輸入中所有double
值的平均值和樣本標準差。注意:不需要儲存所有的數也可以計算標準差。
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 連結串列
- 連結串列是一種遞迴的資料結構,它或者為空(null),或者是指向一個結點(node)的引用,該結點含有一個泛型的元素和一個指向另一條連結串列的引用。
用一個巢狀類來定義節點的抽象資料型別
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 thrid = new Node();
- 將每個結點的item域設為所需的值(我們這裡假設在這些例子中Item為String):
java
first.item = "to";
second.item = "be";
thrid.item = "or";
- 設定next域來構造連結串列:
java
first.next = second;
second.next = third;
- third.next仍然是null,即物件建立時它被初始化的值。
- third是一條連結串列(它是一個結點的引用,該結點指向null,即是一個空連結串列);
second也是一條連結串列(它是一個結點的引用,且該結點含有一個指向third的引用,而third是一條連結串列)
first也是一條連結串列(它是一個結點的引用,且該結點含有一個指向second的引用,而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[],內迴圈是一個while迴圈中的所有語句,成本模型是比較操作(比較兩個陣列元素的值)
1.4.4 增長數量級的分類
- 對增長數量級的常見假設的總結
- 2-sum NlogN解法(假設所有整數各不相同)
- 如果二分查詢不成功則會返回-1,不會增加計數器的值
- 如果二分查詢返回的 j > i,我們就有a[i]+a[j]=0,增加計數器的值
- 如果二分查詢返回的j在0和i之間,不能增加計數器,避免重複計數。
java
import java.util.Arrays;
public class TwoSumFast
{
public static int cout(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;
}
}
- 3-sum
N2logN 解法(假設所有整數各不相同)
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 注意事項
- 大常數:例如,當我們取函式
2N2+cN 的近似為2N2 時,我們的假設是c很小,如果c很大,該近似就是錯誤的。 - 非決定性的內迴圈:
- 指令時間:每條指令執行所需的時間總是相同的假設並不總是正確的。
- 系統因素:計算機總是同時執行著許多程式
- 不分伯仲:在我們比較執行相同任務的兩個程式時,常常出現的情況是其中一個在某些場景中更快而在另一些場景中更慢。
- 對輸入的強烈依賴
- 多個問題引數
1.4.8 處理對於輸入的依賴
練習
1.5 案例研究:union-find演算法
- 優秀的演算法因為能夠解決實際問題而變得更為重要;
- 高效演算法的程式碼也可以很簡單;
- 理解某個實現的效能特點是一項有趣而令人滿足的挑戰;
- 在解決同一個問題的多種演算法之間進行選擇時,科學方法是一種重要的工具;
- 迭代式改進能夠讓演算法的效率越來越高。
1.5.1 動態連線性問題
- 問題的輸入是一列整數對,其中每個整數都表示一個某種型別的物件,一對整數pq可以被理解為“p和q是相連的”,我們假設相連是一種對等的關係。對等關係能夠將物件分為多個等價類,在這裡,當且僅當兩個物件相連時它們才屬於同一個等價類。我們的目標是編寫一個程式來過濾掉序列中所有無意義的整數對(兩個整數均來自於同一個等價類中)。換句話說,當程式從輸入中讀取了證書對p q時,如果已知的所有整數對都不能說明p和q相連的,那麼則將這一對整數寫入到輸出中。如果已知的資料可以說明p 和q是相連的,那麼程式應該忽略p q繼續處理輸入中的下一對整數。
- 該問題可應用於:
- 網路
- 變數名等價性
- 資料集合
- 設計一份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[]陣列來確定兩個觸點是否存在於相同的連通分量中)
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--; }
find()操作的速度顯然是很快的,因為它只需要訪問id[]陣列一次。但quick-find演算法一般無法處理大型問題,因為對於每一對輸入union()都需要掃描整個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--;
}
}
- 最優演算法