我是技術搬運工,好東西當然要和大家分享啦.原文地址
第一章 基礎
棧
數組實現
public class ResizeArrayStack<Item> implements Iterable<Item> {
private Item[] a = (Item[]) new Object[1];
private int N = 0;
public void push(Item item) {
if (N >= a.length) {
resize(2 * a.length);
}
a[N++] = item;
}
public Item pop() {
Item item = a[--N];
if (N <= a.length / 4) {
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;
}
public boolean isEmpty() {
return N == 0;
}
public int size() {
return N;
}
@Override
public Iterator<Item> iterator() {
// 需要返回逆序遍歷的迭代器
return new ReverseArrayIterator();
}
private class ReverseArrayIterator implements Iterator<Item> {
private int i = N;
@Override
public boolean hasNext() {
return i > 0;
}
@Override
public Item next() {
return a[--i];
}
}
}
上面實現使用了泛型,Java 不能直接創建泛型數組,只能使用轉型來創建。
Item[] arr = (Item[]) new Object[N];
鏈表實現
需要使用鏈表的頭插法來實現,因爲頭插法中最後壓入棧的元素在鏈表的開頭,它的 next 指針指向前一個壓入棧的元素,在彈出元素使就可以讓前一個壓入棧的元素稱爲棧頂元素。
public class Stack<Item> {
private Node top = null;
private int N = 0;
private class Node {
Item item;
Node next;
}
public boolean isEmpty() {
return N == 0;
}
public int size() {
return N;
}
public void push(Item item) {
Node newTop = new Node();
newTop.item = item;
newTop.next = top;
top = newTop;
N++;
}
public Item pop() {
Item item = top.item;
top = top.next;
N--;
return item;
}
}
隊列
下面是隊列的鏈表實現,需要維護 first 和 last 節點指針,分別指向隊首和隊尾。
這裏需要考慮讓哪個指針指針鏈表頭部節點,哪個指針指向鏈表尾部節點。因爲出隊列操作需要讓隊首元素的下一個元素成爲隊首,就需要容易獲取下一個元素,而鏈表的頭部節點的 next 指針指向下一個元素,因此讓隊首指針 first 指針鏈表的開頭。
public class Queue<Item> {
private Node first;
private Node last;
int N = 0;
private class Node{
Item item;
Node next;
}
public boolean isEmpty(){
return N == 0;
}
public int size(){
return N;
}
// 入隊列
public void enqueue(Item item){
Node newNode = new Node();
newNode.item = item;
newNode.next = null;
if(isEmpty()){
last = newNode;
first = newNode;
} else{
last.next = newNode;
last = newNode;
}
N++;
}
// 出隊列
public Item dequeue(){
Node node = first;
first = first.next;
N--;
return node.item;
}
}
算法分析
1. 函數轉換
指數函數可以轉換爲線性函數,從而在函數圖像上顯示的更直觀。
T(N)=aN3 轉換爲 lg(T(N))=3lgN+lga
2. 數學模型
近似
使用 ~f(N) 來表示所有隨着 N 的增大除以 f(N) 的結果趨近於 1 的函數 , 例如 N3/6-N2/2+N/3 ~ N3/6。
增長數量級
增長數量級將算法與它的實現隔離開來,一個算法的增長數量級爲 N3 與它是否用 Java 實現,是否運行與特定計算機上無關。
內循環
執行最頻繁的指令決定了程序執行的總時間,把這些指令稱爲程序的內循環。
成本模型
使用成本模型來評估算法,例如數組的訪問次數就是一種成本模型。
3. ThreeSum
ThreeSum 程序用於統計一個數組中三元組的和爲 0 的數量。
public class ThreeSum {
public static int count(int[] a) {
int N = a.length;
int cnt = 0;
for (int i = 0; i < N; i++) {
for (int j = i + 1; j < N; j++) {
for (int k = j + 1; k < N; k++) {
if (a[i] + a[j] + a[k] == 0) {
cnt++;
}
}
}
}
return cnt;
}
}
該程序的內循環爲 if (a[i] + a[j] + a[k] == 0) 語句,總共執行的次數爲 N3/6-N2/2+N/3,因此它的近似執行次數爲 ~N3/6,增長數量級爲 N3。
改進
通過將數組先排序,對兩個元素求和,並用二分查找方法查找是否存在該和的相反數,如果存在,就說明存在三元組的和爲 0。
該方法可以將 ThreeSum 算法增長數量級降低爲 N2logN。
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++) {
for (int k = j + 1; k < N; k++) {
// rank() 方法返回元素在數組中的下標,如果元素不存在,這裏會返回 -1。應該注意這裏的下標必須大於 j,這樣就不會重複統計了。
if (BinarySearch.rank(-a[i] - a[j], a) > j) {
cnt++;
}
}
}
}
return cnt;
}
}
4. 倍率實驗
如果 T(N) ~ aNblgN,那麼 T(2N)/T(N) ~ 2b,例如對於暴力方法的 ThreeSum 算法,近似時間爲 ~N3/6,對它進行倍率實驗得到如下結果:
可見 T(2N)/T(N)~23,也就是 b 爲 3。
5. 注意事項
大常數
在求近似時,如果低級項的常數係數很大,那麼近似的結果就是錯誤的。
緩存
計算機系統會使用緩存技術來組織內存,訪問數組相鄰的元素會比訪問不相鄰的元素快很多。
對最壞情況下的性能的保證
在覈反應堆、心臟起搏器或者剎車控制器中的軟件,最壞情況下的性能是十分重要的。
隨機化算法
通過打亂輸入,去除算法對輸入的依賴。
均攤分析
將所有操作的總成本所以操作總數來將成本均攤。例如對一個空棧進行 N 次連續的 push() 調用需要訪問數組的元素爲 N+4+8+16+...+2N=5N-4(N 是向數組寫入元素,其餘的都是調整數組大小時進行復制需要的訪問數組操作),均攤後每次操作訪問數組的平均次數爲常數。
union-find
概覽
用於解決動態連通性問題,能動態連接兩個點,並且判斷兩個點是否連接。
API
基本數據結構
public class UF {
// 使用 id 數組來保存點的連通信息
private int[] id;
public UF(int N) {
id = new int[N];
for (int i = 0; i < N; i++) {
id[i] = i;
}
}
public boolean connected(int p, int q) {
return find(p) == find(q);
}
}
1. quick-find 算法
保證在同一連通分量的所有觸點的 id 值相等。
這種方法可以快速取得一個觸點的 id 值,並且判斷兩個觸點是否連通,但是 union 的操作代價卻很高,需要將其中一個連通分量中的所有節點 id 值都修改爲另一個節點的 id 值。
public int find(int p) {
return id[p];
}
public void union(int p, int q) {
int pID = find(p);
int qID = find(q);
if (pID == qID) return;
for (int i = 0; i < id.length; i++) {
if (id[i] == pID) id[i] = qID;
}
}
2. quick-union 算法
在 union 時只將觸點的 id 值指向另一個觸點 id 值,不直接用 id 來存儲所屬的連通分量。這樣就構成一個倒置的樹形結構,根節點需要指向自己。在進行查找一個節點所屬的連通分量時,要一直向上查找直到根節點,並使用根節點的 id 值作爲本連通分量的 id值。
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;
id[pRoot] = qRoot;
}
這種方法可以快速進行 union 操作,但是 find 操作和樹高成正比,最壞的情況下樹的高度爲觸點的數目。
3. 加權 quick-union 算法
爲了解決 quick-union 的樹通常會很高的問題,加權 quick-union 在 union 操作時會讓較小的樹連接較大的樹上面。
理論研究證明,加權 quick-union 算法構造的樹深度最多不超過 lgN。
public class WeightedQuickUnionUF {
private int[] id;
// 保存節點的數量信息
private int[] sz;
public WeightedQuickUnionUF(int N) {
id = new int[N];
sz = new int[N];
for (int i = 0; i < N; i++) {
id[i] = i;
sz[i] = 1;
}
}
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];
}
}
}
4. 路徑壓縮的加權 quick-union 算法
在檢查節點的同時將它們直接鏈接到根節點,只需要在 find 中添加一個循環即可。
5. 各種 union-find 算法的比較
第二章 排序
初級排序算法
1. 約定
待排序的元素需要實現 Java 的 Comparable 接口,該接口有 compareTo() 方法。
研究排序算法的成本模型時,計算的是比較和交換的次數。
使用輔助函數 less() 和 exch() 來進行比較和交換的操作,使得代碼的可讀性和可移植性更好。
private static boolean less(Comparable v, Comparable w){
return v.compareTo(w) < 0;
}
private void exch(Comparable[] a, int i, int j){
Comparable t = a[i];
a[i] = a[j];
a[j] = t;
}
2. 選擇排序
找到數組中的最小元素,然後將它與數組的第一個元素交換位置。然後再從剩下的元素中找到最小的元素,將它與數組的第二個元素交換位置。不斷進行這樣的操作,直到將整個數組排序。
public class Selection {
public static void sort(Comparable[] a) {
int N = a.length;
for (int i = 0; i < N; i++) {
int min = i;
for (int j = i + 1; j < N; j++) {
if (less(a[j], a[min])) min = j;
}
exch(a, i, min);
}
}
}
選擇排序需要 ~N2/2 次比較和 ~N 次交換,它的運行時間與輸入無關,這個特點使得它對一個已經排序的數組也需要這麼多的比較和交換操作。
3. 插入排序
將一個元素插入到已排序的數組中,使得插入之後的數組也是有序的。插入排序從左到右插入每個元素,每次插入之後左部的子數組是有序的。
public class Insertion {
public static void sort(Comparable[] a) {
int N = a.length;
for (int i = 1; i < N; i++) {
for (int j = i; j > 0 && less(a[j], a[j - 1]); j--) {
exch(a, j, j - 1);
}
}
}
}
插入排序的複雜度取決於數組的初始順序,如果數組已經部分有序了,那麼插入排序會很快。平均情況下插入排序需要 ~N2/4 比較以及 ~N2/4 次交換,最壞的情況下需要 ~N2/2 比較以及 ~N2/2 次交換,最壞的情況是數組是逆序的;而最好的情況下需要 N-1 次比較和 0 次交換,最好的情況就是數組已經有序了。
插入排序對於部分有序數組和小規模數組特別高效。
4. 選擇排序和插入排序的比較
對於隨機排序的無重複主鍵的數組,插入排序和選擇排序的運行時間是平方級別的,兩者之比是一個較小的常數。
5. 希爾排序
對於大規模的數組,插入排序很慢,因爲它只能交換相鄰的元素,如果要把元素從一端移到另一端,就需要很多次操作。
希爾排序的出現就是爲了改進插入排序的這種侷限性,它通過交換不相鄰的元素,使得元素更快的移到正確的位置上。
希爾排序使用插入排序對間隔 h 的序列進行排序,如果 h 很大,那麼元素就能很快的移到很遠的地方。通過不斷減小 h,最後令 h=1,就可以使得整個數組是有序的。
public class Shell {
public static void sort(Comparable[] a) {
int N = a.length;
int h = 1;
while (h < N / 3) {
h = 3 * h + 1;// 1, 4, 13, 40, ...
}
while (h >= 1) {
for (int i = h; i < N; i++) {
for (int j = i; j >= h && less(a[j], a[j - h]); j -= h) {
exch(a, j, j - h);
}
}
h = h / 3;
}
}
}
希爾排序的運行時間達不到平方級別,使用遞增序列 1, 4, 13, 40, ... 的希爾排序所需要的比較次數不會超過 N 的若干倍乘於遞增序列的長度。後面介紹的高級排序算法只會比希爾排序快兩倍左右。
歸併排序
歸併排序的思想是將數組分成兩部分,分別進行排序,然後歸併起來。
1. 歸併方法
public class MergeSort {
private static Comparable[] aux;
private static void merge(Comparable[] a, int lo, int mid, int hi) {
int i = lo, j = mid + 1;
for (int k = lo; k <= hi; k++) {
aux[k] = a[k]; // 將數據複製到輔助數組
}
for (int k = lo; k <= hi; k++) {
if (i > mid) a[k] = aux[j++];
else if (j > hi) a[k] = aux[i++];
else if (aux[i].compareTo(a[j]) < 0) a[k] = aux[i++]; // 先進行這一步,保證穩定性
else a[k] = aux[j++];
}
}
}
2. 自頂向下歸併排序
public static void sort(Comparable[] a) {
aux = new Comparable[a.length];
sort(a, 0, a.length - 1);
}
private static void sort(Comparable[] a, int lo, int hi) {
if (hi <= lo) return;
int mid = lo + (hi - lo) / 2;
sort(a, lo, mid);
sort(a, mid + 1, hi);
merge(a, lo, mid, hi);
}
很容易看出該排序算法的時間複雜度爲 O(NlgN)。
因爲小數組的遞歸操作會過於頻繁,因此使用插入排序來處理小數組將會獲得更高的性能。
3. 自底向上歸併排序
先歸併那些微型數組,然後成對歸併得到的子數組。
public static void busort(Comparable[] a) {
int N = a.length;
aux = new Comparable[N];
for (int sz = 1; sz < N; sz += sz) {
for (int lo = 0; lo < N - sz; lo += sz + sz) {
merge(a, lo, lo + sz - 1, Math.min(lo + sz + sz - 1, N - 1));
}
}
}
快速排序
1. 基本算法
歸併排序將數組分爲兩個子數組分別排序,並將有序的子數組歸併使得整個數組排序;快速排序通過一個切分元素將數組分爲兩個子數組,左子數組小於等於切分元素,右子數組大於等於切分元素,將這兩個子數組排序也就將整個數組排序了。
public class QuickSort {
public static void sort(Comparable[] a) {
shuffle(a);
sort(a, 0, a.length - 1);
}
private static void sort(Comparable[] a, int lo, int hi) {
if (hi <= lo) return;
int j = partition(a, lo, hi);
sort(a, lo, j - 1);
sort(a, j + 1, hi);
}
}
2. 切分
取 a[lo] 作爲切分元素,然後從數組的左端向右掃描直到找到第一個大於等於它的元素,再從數組的右端向左掃描找到第一個小於等於它的元素,交換這兩個元素,並不斷繼續這個過程,就可以保證左指針的左側元素都不大於切分元素,右指針 j 的右側元素都不小於切分元素。當兩個指針相遇時,將切分元素 a[lo] 和左子數組最右側的元素 a[j] 交換然後返回 j 即可。
private static int partition(Comparable[] a, int lo, int hi) {
int i = lo, j = hi + 1;
Comparable v = a[lo];
while (true) {
while (less(a[++i], v)) if (i == hi) break;
while (less(v, a[--j])) if (j == lo) break;
if (i >= j) break;
exch(a, i, j);
}
exch(a, lo, j);
return j;
}
3. 性能分析
快速排序是原地排序,不需要輔助數組,但是遞歸調用需要輔助棧。
快速排序最好的情況下是每次都正好能將數組對半分,這樣遞歸調用次數纔是最少的。這種情況下比較次數爲 CN=2CN/2+N,也就是複雜度爲 O(NlgN)。
最壞的情況下,第一次從最小的元素切分,第二次從第二小的元素切分,如此這般。因此最壞的情況下需要比較 N2/2。爲了防止數組最開始就是有序的,在進行快速排序時需要隨機打亂數組。
4. 算法改進
4.1 切換到插入排序
因爲快速排序在小數組中也會調用自己,對於小數組,插入排序比快速排序的性能更好,因此在小數組中可以切換到插入排序。
4.2 三取樣
最好的情況下是每次都能取數組的中位數作爲切分元素,但是計算中位數的代價很高。人們發現取 3 個元素並將大小居中的元素作爲切分元素的效果最好。
4.3 三向切分
對於有大量重複元素的數組,可以將數組切分爲三部分,分別對應小於、等於和大於切分元素。
三向切分快速排序對於只有若干不同主鍵的隨機數組可以在線性時間內完成排序。
public class Quick3Way {
public static void sort(Comparable[] a, int lo, int hi) {
if (hi <= lo) return;
int lt = lo, i = lo + 1, gt = hi;
Comparable v = a[lo];
while (i <= gt) {
int cmp = a[i].compareTo(v);
if (cmp < 0) exch(a, lt++, i++);
else if (cmp > 0) exch(a, i, gt--);
else i++;
}
sort(a, lo, lt - 1);
sort(a, gt + 1, hi);
}
}
優先隊列
優先隊列主要用於處理最大元素。
1. 堆
定義:一顆二叉樹的每個節點都大於等於它的兩個子節點。
堆可以用數組來表示,因爲堆是一種完全二叉樹,而完全二叉樹很容易就存儲在數組中。位置 k 的節點的父節點位置爲 k/2,而它的兩個子節點的位置分別爲 2k 和 2k+1。這裏我們不使用數組索引爲 0 的位置,是爲了更清晰地理解節點的關係。
public class MaxPQ<Key extends Comparable<Key> {
private Key[] pq;
private int N = 0;
public MaxPQ(int maxN) {
pq = (Key[]) new Comparable[maxN + 1];
}
public boolean isEmpty() {
return N == 0;
}
public int size() {
return N;
}
private boolean less(int i, int j) {
return pq[i].compareTo(pq[j]) < 0;
}
private void exch(int i, int j) {
Key t = pq[i];
pq[i] = pq[j];
pq[j] = t;
}
}
2. 上浮和下沉
在堆中,當一個節點比父節點大,那麼需要交換這個兩個節點。交換後還可能比它新的父節點大,因此需要不斷地進行比較和交換操作。把這種操作稱爲上浮。
private void swim(int k) {
while (k > 1 && less(k / 2, k)) {
exch(k / 2, k);
k = k / 2;
}
}
類似地,當一個節點比子節點來得小,也需要不斷的向下比較和交換操作,把這種操作稱爲下沉。一個節點有兩個子節點,應當與兩個子節點中最大那麼節點進行交換。
private void sink(int k) {
while (2 * k <= N) {
int j = 2 * k;
if (j < N && less(j, j + 1)) j++;
if (!less(k, j)) break;
exch(k, j);
k = j;
}
}
3. 插入元素
將新元素放到數組末尾,然後上浮到合適的位置。
public void insert(Key v) {
pq[++N] = v;
swim(N);
}
4. 刪除最大元素
從數組頂端刪除最大的元素,並將數組的最後一個元素放到頂端,並讓這個元素下沉到合適的位置。
public Key delMax() {
Key max = pq[1];
exch(1, N--);
pq[N + 1] = null;
sink(1);
return max;
}
5. 堆排序
由於堆可以很容易得到最大的元素並刪除它,不斷地進行這種操作可以得到一個遞減序列。如果把最大元素和當前堆中數組的最後一個元素交換位置,並且不刪除它,那麼就可以得到一個從尾到頭的遞減序列,從正向來看就是一個遞增序列。因此很容易使用堆來進行排序,並且堆排序是原地排序,不佔用額外空間。
堆排序要分兩個階段,第一個階段是把無序數組建立一個堆;第二個階段是交換最大元素和當前堆的數組最後一個元素,並且進行下沉操作維持堆的有序狀態。
無序數組建立堆最直接的方法是從左到右遍歷數組,然後進行上浮操作。一個更高效的方法是從右至左進行下沉操作,如果一個節點的兩個節點都已經是堆有序,那麼進行下沉操作可以使得這個節點爲根節點的堆有序。葉子節點不需要進行下沉操作,因此可以忽略葉子節點的元素,因此只需要遍歷一半的元素即可。
public static void sort(Comparable[] a){
int N = a.length;
for(int k = N/2; k >= 1; k--){
sink(a, k, N);
}
while(N > 1){
exch(a, 1, N--);
sink(a, 1, N);
}
}
6. 分析
一個堆的高度爲 lgN,因此在堆中插入元素和刪除最大元素的複雜度都爲 lgN。
對於堆排序,由於要對 N 個節點進行下沉操作,因此複雜度爲 NlgN。
堆排序時一種原地排序,沒有利用額外的空間。
現代操作系統很少使用堆排序,因爲它無法利用緩存,也就是數組元素很少和相鄰的元素進行比較。
應用
1. 排序算法的比較
快速排序時最快的通用排序算法,它的內循環的指令很少,而且它還能利用緩存,因爲它總是順序地訪問數據。它的運行時間增長數量級爲 ~cNlgN,這裏的 c 比其他線性對數級別的排序算法都要小。使用三向切分之後,實際應用中可能出現的某些分佈的輸入能夠達到線性級別,而其它排序算法仍然需要線性對數時間。
2. Java 的排序算法實現
Java 系統庫中的主要排序方法爲 java.util.Arrays.sort(),對於原始數據類型使用三向切分的快速排序,對於引用類型使用歸併排序。
3. 基於切分的快速選擇算法
快速排序的 partition() 方法,會將數組的 a[lo] 至 a[hi] 重新排序並返回一個整數 j 使得 a[lo..j-1] 小於等於 a[j],且 a[j+1..hi] 大於等於 a[j]。那麼如果 j=k,a[j] 就是第 k 個數。
該算法是線性級別的,因爲每次正好將數組二分,那麼比較的總次數爲 (N+N/2+N/4+..),直到找到第 k 個元素,這個和顯然小於 2N。
public static Comparable select(Comparable[] a, int k) {
int lo = 0, hi = a.length - 1;
while (hi > lo) {
int j = partion(a, lo, hi);
if (j == k) return a[k];
else if (j > k) hi = j - 1;
else lo = j + 1;
}
return a[k];
}
第三章 查找
本章使用三種經典的數據類型來實現高效的符號表:二叉查找樹、紅黑樹和散列表。
符號表
1. 無序符號表
2. 有序符號表
有序符號表的鍵需要實現 Comparable 接口。
查找的成本模型:鍵的比較次數,在不進行比較時使用數組的訪問次數。
3. 二分查找實現有序符號表
使用一對平行數組,一個存儲鍵一個存儲值。
需要創建一個 Key 類型的 Comparable 對象數組和一個 Value 類型的 Object 對象數組。
rank() 方法至關重要,當鍵在表中時,它能夠知道該鍵的位置;當鍵不在表中時,它也能知道在何處插入新鍵。
複雜度:二分查找最多需要 lgN+1 次比較,使用二分查找實現的符號表的查找操作所需要的時間最多是對數級別的。但是插入操作需要移動數組元素,是線性級別的。
public class BinarySearchST<Key extends Comparable<Key>, Value> {
private Key[] keys;
private Value[] values;
private int N;
public BinarySearchST(int capacity) {
keys = (Key[]) new Comparable[capacity];
values = (Value[]) new Object[capacity];
}
public int size() {
return N;
}
public Value get(Key key) {
int i = rank(key);
if (i < N && keys[i].compareTo(key) == 0) {
return values[i];
}
return null;
}
public int rank(Key key) {
int lo = 0, hi = N - 1;
while (lo <= hi) {
int mid = lo + (hi - lo) / 2;
int cmp = key.compareTo(keys[mid]);
if (cmp == 0) return mid;
else if (cmp < 0) hi = mid - 1;
else lo = mid + 1;
}
return lo;
}
public void put(Key key, Value value) {
int i = rank(key);
if (i < N && keys[i].compareTo(key) == 0) {
values[i] = value;
return;
}
for (int j = N; j > i; j--) {
keys[j] = keys[j - 1];
values[j] = values[j - 1];
}
keys[i] = key;
values[i] = value;
N++;
}
public Key ceiling(Key key){
int i = rank(key);
return keys[i];
}
}
二叉查找樹
二叉樹 定義爲一個空鏈接,或者是一個有左右兩個鏈接的節點,每個鏈接都指向一顆子二叉樹。
二叉查找樹(BST)是一顆二叉樹,並且每個節點的鍵都大於其左子樹中的任意節點的鍵而小於右子樹的任意節點的鍵。
二叉查找樹的查找操作每次迭代都會讓區間減少一半,和二分查找類似。
public class BST<Key extends Comparable<Key>, Value> {
private Node root;
private class Node {
private Key key;
private Value val;
private Node left, right;
// 以該節點爲根的子樹中節點總數
private int N;
public Node(Key key, Value val, int N) {
this.key = key;
this.val = val;
this.N = N;
}
}
public int size() {
return size(root);
}
private int size(Node x) {
if (x == null) return 0;
return x.N;
}
}
1. get()
如果樹是空的,則查找未命中;如果被查找的鍵和根節點的鍵相等,查找命中,否則遞歸地在子樹中查找:如果被查找的鍵較小就在左子樹中查找,較大就在右子樹中查找。
public Value get(Key key) {
return get(root, key);
}
private Value get(Node x, Key key) {
if (x == null) return null;
int cmp = key.compareTo(x.key);
if (cmp == 0) return x.val;
else if (cmp < 0) return get(x.left, key);
else return get(x.right, key);
}
2. put()
當插入的鍵不存在於樹中,需要創建一個新節點,並且更新上層節點的鏈接使得該節點正確鏈接到樹中。
public void put(Key key, Value val) {
root = put(root, key, val);
}
private Node put(Node x, Key key, Value val) {
if (x == null) return new Node(key, val, 1);
int cmp = key.compareTo(x.key);
if (cmp == 0) x.val = val;
else if (cmp < 0) x.left = put(x.left, key, val);
else x.right = put(x.right, key, val);
x.N = size(x.left) + size(x.right) + 1;
return x;
}
3. 分析
二叉查找樹的算法運行時間取決於樹的形狀,而樹的形狀又取決於鍵被插入的先後順序。最好的情況下樹是完全平衡的,每條空鏈接和根節點的距離都爲 lgN。在最壞的情況下,樹的高度爲 N。
複雜度:查找和插入操作都爲對數級別。
4. floor()
如果 key 小於根節點的 key,那麼小於等於 key 的最大鍵節點一定在左子樹中;如果 key 大於根節點的 key,只有當根節點右子樹中存在小於等於 key 的節點,小於等於 key 的最大鍵節點纔在右子樹中,否則根節點就是小於等於 key 的最大鍵節點。
public Key floor(Key key) {
Node x = floor(root, key);
if (x == null) return null;
return x.key;
}
private Node floor(Node x, Key key) {
if (x == null) return null;
int cmp = key.compareTo(x.key);
if (cmp == 0) return x;
if (cmp < 0) return floor(x.left, key);
Node t = floor(x.right, key);
if (t != null) {
return t;
} else {
return x;
}
}
5. rank()
public int rank(Key key) {
return rank(key, root);
}
private int rank(Key key, Node x) {
if (x == null) return 0;
int cmp = key.compareTo(x.key);
if (cmp == 0) return size(x.left);
else if (cmp < 0) return rank(key, x.left);
else return 1 + size(x.left) + rank(key, x.right);
}
6. min()
private Node min(Node x) {
if (x.left == null) return x;
return min(x.left);
}
7. deleteMin()
令指向最小節點的鏈接指向最小節點的右子樹。
public void deleteMin() {
root = deleteMin(root);
}
public Node deleteMin(Node x) {
if (x.left == null) return x.right;
x.left = deleteMin(x.left);
x.N = size(x.left) + size(x.right) + 1;
return x;
}
8. delete()
如果待刪除的節點只有一個子樹,那麼只需要讓指向待刪除節點的鏈接指向唯一的子樹即可;否則,讓右子樹的最小節點替換該節點。
public void delete(Key key) {
root = delete(root, key);
}
private Node delete(Node x, Key key) {
if (x == null) return null;
int cmp = key.compareTo(x.key);
if (cmp < 0) x.left = delete(x.left, key);
else if (cmp > 0) x.right = delete(x.right, key);
else {
if (x.right == null) return x.left;
if (x.left == null) return x.right;
Node t = x;
x = min(t.right);
x.right = deleteMin(t.right);
x.left = t.left;
}
x.N = size(x.left) + size(x.right) + 1;
return x;
}
9. keys()
利用二叉查找樹中序遍歷的結果爲有序序列的特點。
public Iterable<Key> keys(Key lo, Key hi) {
Queue<Key> queue = new LinkedList<>();
keys(root, queue, lo, hi);
return queue;
}
private void keys(Node x, Queue<Key> queue, Key lo, Key hi) {
if (x == null) return;
int cmpLo = lo.compareTo(x.key);
int cmpHi = hi.compareTo(x.key);
if (cmpLo < 0) keys(x.left, queue, lo, hi);
if (cmpLo <= 0 && cmpHi >= 0) queue.add(x.key);
if (cmpHi > 0) keys(x.right, queue, lo, hi);
}
10. 性能分析
複雜度:二叉查找樹所有操作在最壞的情況下所需要的時間都和樹的高度成正比。
平衡查找樹
2-3 查找樹
一顆完美平衡的 2-3 查找樹的所有空鏈接到根節點的距離應該是相同的。
1. 插入操作
當插入之後產生一個臨時 4- 節點時,需要將 4- 節點分裂成 3 個 2- 節點,並將中間的 2- 節點移到上層節點中。如果上移操作繼續產生臨時 4- 節點則一直進行分裂上移,直到不存在臨時 4- 節點。
2. 性質
2-3 查找樹插入操作的變換都是局部的,除了相關的節點和鏈接之外不必修改或者檢查樹的其它部分,而這些局部變換不會影響樹的全局有序性和平衡性。
2-3 查找樹的查找和插入操作複雜度和插入順序 無關,在最壞的情況下查找和插入操作訪問的節點必然不超過 logN 個,含有 10 億個節點的 2-3 查找樹最多只需要訪問 30 個節點就能進行任意的查找和插入操作。
紅黑二叉查找樹
2-3 查找樹需要用到 2- 節點和 3- 節點,紅黑樹使用紅鏈接來實現 3- 節點。指向一個節點的鏈接顏色如果爲紅色,那麼這個節點和上層節點表示的是一個 3- 節點,而黑色則是普通鏈接。
紅黑樹具有以下性質:
- 紅鏈接都爲左鏈接;
- 完美黑色平衡,即任意空鏈接到根節點的路徑上的黑鏈接數量相同。
畫紅黑樹時可以將紅鏈接畫平。
public class RedBlackBST<Key extends Comparable<Key>, Value> {
private Node root;
private static final boolean RED = true;
private static final boolean BLACK = false;
private class Node {
Key key;
Value val;
Node left, right;
int N;
boolean color;
Node(Key key, Value val, int n, boolean color) {
this.key = key;
this.val = val;
N = n;
this.color = color;
}
}
private boolean isRed(Node x) {
if (x == null) return false;
return x.color == RED;
}
}
1. 左旋轉
因爲合法的紅鏈接都爲左鏈接,如果出現右鏈接爲紅鏈接,那麼就需要進行左旋轉操作。
public Node rotateLeft(Node h) {
Node x = h.right;
h.right = x.left;
x.left = h;
x.color = h.color;
h.color = RED;
x.N = h.N;
h.N = 1 + size(h.left) + size(h.right);
return x;
}
2. 右旋轉
進行右旋轉是爲了轉換兩個連續的左紅鏈接,這會在之後的插入過程中探討。
public Node rotateRight(Node h) {
Node x = h.left;
h.left = x.right;
x.color = h.color;
h.color = RED;
x.N = h.N;
h.N = 1 + size(h.left) + size(h.right);
return x;
}
3. 顏色轉換
一個 4- 節點在紅黑樹中表現爲一個節點的左右子節點都是紅色的。分裂 4- 節點除了需要將子節點的顏色由紅變黑之外,同時需要將父節點的顏色由黑變紅,從 2-3 樹的角度看就是將中間節點移到上層節點。
void flipColors(Node h){
h.color = RED;
h.left.color = BLACK;
h.right.color = BLACK;
}
4. 插入
先將一個節點按二叉查找樹的方法插入到正確位置,然後再進行如下顏色操作:
- 如果右子節點是紅色的而左子節點是黑色的,進行左旋轉;
- 如果左子節點是紅色的且它的左子節點也是紅色的,進行右旋轉;
- 如果左右子節點均爲紅色的,進行顏色轉換。
public void put(Key key, Value val) {
root = put(root, key, val);
root.color = BLACK;
}
private Node put(Node x, Key key, Value val) {
if (x == null) return new Node(key, val, 1, RED);
int cmp = key.compareTo(x.key);
if (cmp == 0) x.val = val;
else if (cmp < 0) x.left = put(x.left, key, val);
else x.right = put(x.right, key, val);
if (isRed(x.right) && !isRed(x.left)) x = rotateLeft(x);
if (isRed(x.left) && isRed(x.left.left)) x = rotateRight(x);
if (isRed(x.left) && isRed(x.right)) flipColors(x);
x.N = size(x.left) + size(x.right) + 1;
return x;
}
可以看到該插入操作和 BST 的插入操作類似,只是在最後加入了旋轉和顏色變換操作即可。
根節點一定爲黑色,因爲根節點沒有上層節點,也就沒有上層節點的左鏈接指向根節點。flipColors() 有可能會使得根節點的顏色變爲紅色,每當根節點由紅色變成黑色時樹的黑鏈接高度加 1.
5. 刪除最小鍵
如果最小鍵在一個 2- 節點中,那麼刪除該鍵會留下一個空鏈接,就破壞了平衡性,因此要確保最小鍵不在 2- 節點中。將 2- 節點轉換成 3- 節點或者 4- 節點有兩種方法,一種是向上層節點拿一個 key,一種是向兄弟節點拿一個 key。如果上層節點是 2- 節點,那麼就沒辦法從上層節點拿 key 了,因此要保證刪除路徑上的所有節點都不是 2- 節點。在向下刪除的過程中,保證以下情況之一發生:
- 如果當前節點的左子節點不是 2- 節點,完成;
- 如果當前節點的左子節點是 2- 節點而它的兄弟節點不是 2- 節點,向兄弟節點拿一個 key 過來;
- 如果當前節點的左子節點和它的兄弟節點都是 2- 節點,將左子節點、父節點中的最小鍵和最近的兄弟節點合併爲一個 4- 節點。
最後得到一個含有最小鍵的 3- 節點或者 4- 節點,直接從中刪除。然後再從頭分解所有臨時的 4- 節點。
6. 分析
一顆大小爲 N 的紅黑樹的高度不會超過 2lgN。最壞的情況下是它所對應的 2-3 樹中構成最左邊的路徑節點全部都是 3- 節點而其餘都是 2- 節點。
紅黑樹大多數的操作所需要的時間都是對數級別的。
散列表
散列表類似於數組,可以把散列表的散列值看成數組的索引值。訪問散列表和訪問數組元素一樣快速,它可以在常數時間內實現查找和插入的符號表。
由於無法通過散列值知道鍵的大小關係,因此散列表無法實現有序性操作。
1. 散列函數
對於一個大小爲 M 的散列表,散列函數能夠把任意鍵轉換爲 [0, M-1] 內的正整數,該正整數即爲 hash 值。
散列表有衝突的存在,也就是兩個不同的鍵可能有相同的 hash 值。
散列函數應該滿足以下三個條件:
- 一致性:相等的鍵應當有相等的 hash 值。
- 高效性:計算應當簡便,有必要的話可以把 hash 值緩存起來,在調用 hash 函數時直接返回。
- 均勻性:所有鍵的 hash 值應當均勻地分佈到 [0, M-1] 之間,這個條件至關重要,直接影響到散列表的性能。
除留餘數法可以將整數散列到 [0, M-1] 之間,例如一個正整數 k,計算 k%M 既可得到一個 [0, M-1] 之間的 hash 值。注意 M 必須是一個素數,否則無法利用鍵包含的所有信息。例如 M 爲 10k,那麼只能利用鍵的後 k 位。
對於其它數,可以將其轉換成整數的形式,然後利用除留餘數法。例如對於浮點數,可以將其表示成二進制形式,然後使用二進制形式的整數值進行除留餘數法。
對於有多部分組合的鍵,每部分都需要計算 hash 值,並且最後合併時需要讓每部分 hash 值都具有同等重要的地位。可以將該鍵看成 R 進制的整數,鍵中每部分都具有不同的權值。
例如,字符串的散列函數實現如下
int hash = 0;
for(int i = 0; i < s.length(); i++)
hash = (R * hash + s.charAt(i)) % M;
再比如,擁有多個成員的自定義類的哈希函數如下
int hash = (((day * R + month) % M) * R + year) % M;
R 的值不是很重要,通常取 31。
Java 中的 hashCode() 實現了 hash 函數,但是默認使用對象的內存地址值。在使用 hashCode() 函數時,應當結合除留餘數法來使用。因爲內存地址是 32 位整數,我們只需要 31 位的非負整數,因此應當屏蔽符號位之後再使用除留餘數法。
int hash = (x.hashCode() & 0x7fffffff) % M;
使用 Java 自帶的 HashMap 等自帶的哈希表實現時,只需要去實現 Key 類型的 hashCode() 函數即可。Java 規定 hashCode() 能夠將鍵均勻分佈於所有的 32 位整數,Java 中的 String、Integer 等對象的 hashCode() 都能實現這一點。以下展示了自定義類型如何實現 hashCode()。
public class Transaction{
private final String who;
private final Date when;
private final double amount;
public int hashCode(){
int hash = 17;
hash = 31 * hash + who.hashCode();
hash = 31 * hash + when.hashCode();
hash = 31 * hash + ((Double) amount).hashCode();
return hash;
}
}
2. 基於拉鍊法的散列表
拉鍊法使用鏈表來存儲 hash 值相同的鍵,從而解決衝突。此時查找需要分兩步,首先查找 Key 所在的鏈表,然後在鏈表中順序查找。
對於 N 個鍵,M 條鏈表 (N>M),如果哈希函數能夠滿足均勻性的條件,每條鏈表的大小趨向於 N/M,因此未命中的查找和插入操作所需要的比較次數爲 ~N/M。
3. 基於線性探測法的散列表
線性探測法使用空位來解決衝突,當衝突發生時,向前探測一個空位來存儲衝突的鍵。使用線程探測法,數組的大小 M 應當大於鍵的個數 N(M>N)。
public class LinearProbingHashST<Key, Value> {
private int N;
private int M = 16;
private Key[] keys;
private Value[] vals;
public LinearProbingHashST() {
init();
}
public LinearProbingHashST(int M) {
this.M = M;
init();
}
private void init() {
keys = (Key[]) new Object[M];
vals = (Value[]) new Object[M];
}
private int hash(Key key) {
return (key.hashCode() & 0x7fffffff) % M;
}
}
3.1 查找
public Value get(Key key) {
for (int i = hash(key); keys[i] != null; i = (i + 1) % M) {
if (keys[i].equals(key)) {
return vals[i];
}
}
return null;
}
3.2 插入
public void put(Key key, Value val) {
int i;
for (i = hash(key); keys[i] != null; i = (i + 1) % M) {
if (keys[i].equals(key)) {
vals[i] = val;
return;
}
}
keys[i] = key;
vals[i] = val;
N++;
resize();
}
3.3 刪除
刪除操作應當將右側所有相鄰的鍵值重新插入散列表中。
public void delete(Key key) {
if (!contains(key)) return;
int i = hash(key);
while (!key.equals(keys[i])) {
i = (i + 1) % M;
}
keys[i] = null;
vals[i] = null;
i = (i + 1) % M;
while (keys[i] != null) {
Key keyToRedo = keys[i];
Value valToRedo = vals[i];
keys[i] = null;
vals[i] = null;
N--;
put(keyToRedo, valToRedo);
i = (i + 1) % M;
}
N--;
resize();
}
3.4 調整數組大小
線性探測法的成本取決於連續條目的長度,連續條目也叫聚簇。當聚簇很長時,在查找和插入時也需要進行很多次探測。
α = N/M,把 α 稱爲利用率。理論證明,當 α 小於 1/2 時探測的預計次數只在 1.5 到 2.5 之間。
爲了保證散列表的性能,應當調整數組的大小,使得 α 在 [1/4, 1/2] 之間。
private void resize() {
if (N >= M / 2) resize(2 * M);
else if (N <= M / 8) resize(M / 2);
}
private void resize(int cap) {
LinearProbingHashST<Key, Value> t = new LinearProbingHashST<>(cap);
for (int i = 0; i < M; i++) {
if (keys[i] != null) {
t.put(keys[i], vals[i]);
}
}
keys = t.keys;
vals = t.vals;
M = t.M;
}
雖然每次重新調整數組都需要重新把每個鍵值對插入到散列表,但是從攤還分析的角度來看,所需要的代價卻是很小的。從下圖可以看出,每次數組長度加倍後,累計平均值都會增加 1,因爲表中每個鍵都需要重新計算散列值,但是隨後平均值會下降。
應用
1. 各種符號表實現的比較
應當優先考慮散列表,當需要有序性操作時使用紅黑樹。
2. Java 的符號表實現
Java 的 java.util.TreeMap 和 java.util.HashMap 分別是基於紅黑樹和拉鍊法的散列表的符號表實現。
3. 集合類型
除了符號表,集合類型也經常使用,它只有鍵沒有值,可以用集合類型來存儲一系列的鍵然後判斷一個鍵是否在集合中。
4. 稀疏向量乘法
當向量爲稀疏向量時,可以使用符號表來存儲向量中的非 0 索引和值,使得乘法運算只需要對那些非 0 元素進行即可。
import java.util.HashMap;
public class SparseVector {
private HashMap<Integer, Double> hashMap;
public SparseVector(double[] vector) {
hashMap = new HashMap<>();
for (int i = 0; i < vector.length; i++) {
if (vector[i] != 0) {
hashMap.put(i, vector[i]);
}
}
}
public double get(int i) {
return hashMap.getOrDefault(i, 0.0);
}
public double dot(SparseVector other) {
double sum = 0;
for (int i : hashMap.keySet()) {
sum += this.get(i) * other.get(i);
}
return sum;
}
}