1.基礎查詢
符號表是一種典型的ADT,它提供了操作鍵值對的方法: put(插入、insert)、search、delete操作,這一節將會給出兩種初級的符號表: 無序連結串列中的順序查詢、基於有序陣列二分查詢的有序符號表。
在某些實現中我們認為保持鍵的有序性並大大擴充套件它的API是很有用。例如鍵是時間,你可能會對最早的或是最晚的或者給定時間段內的所有鍵感興趣。在大多數情況下這些額外的操作只需要在資料結構上增加一些資訊及少量程式碼即可實現,如下定義的一些API便支援一般的動態集合上順序統計操作的資料結構(所謂第i個順序統計量是該集合中第i小或大的元素)。
1.1 有序符號表的API說明
在一個給定不變的集合中選擇第i個順序統計量的問題,這種被稱為選擇問題。它可以在O(lgn)時間內利用堆排序或者合併排序對資料進行排序後再取第i個元素,或者基於快速排序的確定性劃分演算法在O(n)時間內取第i個元素。由此引申出了一箇中位數的查詢問題(中位數: 集合元素按大小順序排序後其中間的位置的數,集合大小為奇數時,中位數唯一,位於中間;大小為偶數時,存在兩個中位數,取其平均值即可)。對於集合固定的中位數查詢只需要利用select操作找到中間的數即可,對於兩個有序陣列(長度一致或者不一致均可以)的中位數查詢可以用分治法(每次可去除一半的元素)。
如果集合元素是動態變化呢,可以設計一個含最大堆和最小堆的資料型別,它能夠在對數時間內插入元素或刪除元素,常數時間內找到中位數。那麼選擇問題呢? 這裡就用到了有序符號表的實現來支援這些動態順序統計問題,它們能保證元素的選擇、排名、範圍查詢都可以在對數時間內確定。
如圖為有序符號表的API定義:
有序操作說明:
- 最大和最小鍵值
和優先佇列一樣,我們可以查詢和刪除集合中的最大和最小鍵
- 向下取整(floor)和向上取整(ceiling)
對於給定的鍵,向下取整操作即找出小於或等於該鍵的最大鍵,向上取整操作即找出大於或等於該鍵的最小鍵
- 排名和選擇
選擇問題計是查詢出集合中的第i小關鍵字(即找出排名為i的鍵);排名問題是確定當前鍵的順序位置
- 範圍查詢
1.2 基於二分查詢的有序符號表實現
基於二分查詢的有序符號表實現的核心操作是rank方法,它返回表中小於給定鍵的數量或者是應該插入的位置(範圍:0-N)。它保留了以下性質:
- 如果表中存在該鍵,rank()返回該鍵的位置,即表中小於該鍵的數量
- 如果表中不存在該鍵,它返回的還是表中小於該鍵的數量
可以知道迴圈結束時low的值正好等於表中小於被查詢的鍵的數量,程式碼如下:
public int rank(Key key){ int low=0,high=N-1; while(low<=high){ int mid=low+ (high-low)/2; int cmp=key.compareTo(keys[mid]); if(cmp<0) high=mid-1; else if(cmp>0) low=mid+1; else return mid; } return low; }
其他的操作如get、put方法均利用rank方法找到該鍵的位置或者查詢未命中的位置,完整的具體實現如下:
package algo.search; import java.util.NoSuchElementException; import com.stdlib.In; import com.stdlib.StdIn; import com.stdlib.StdOut; import algo.basic.LinkedQueue; public class BinSearchST<Key extends Comparable<Key>,Value> { private static final int INIT_CAPACITY=2; private Key[] keys; private Value[] vals; private int N; //鍵值對個數 public BinSearchST(int capacity) { keys=(Key[]) new Comparable[capacity]; vals=(Value[]) new Object[capacity]; N=0; } public BinSearchST(){ this(INIT_CAPACITY); } public int size(){ return N; } public boolean isEmpty(){ return N==0; } public boolean contains(Key key){ return get(key)!=null; } /** * 如果符號表為空,返回null * 找到key的排名rank,如果位置合法並且給定的鍵值與對應位置的鍵值相等,返回對應的實值 * 否則返回null */ public Value get(Key key){ if(isEmpty()) return null; int pos=rank(key); if(pos<N && key.compareTo(keys[pos])==0) return vals[pos]; return null; } /** * 插入鍵值對,查詢鍵找到就更新值,後面的元素整體右移結點個數加1 */ public void put(Key key,Value val){ if(val==null){ delete(key); return; } /*小於key的鍵的數量,*/ int i=rank(key); /*如果鍵在表中,更新鍵值對*/ if(i<N&&key.compareTo(keys[i])==0){ vals[i]=val; return ; } /*元素個數已滿,等於容量大小*/ if(N==keys.length) resize(2*keys.length); for(int j=N;j>i;j--){ keys[j]=keys[j-1]; vals[j]=vals[j-1]; } keys[i]=key; vals[i]=val; N++; assert check(); } public void delete(Key key){ if(isEmpty()) return; int i=rank(key); if(i==N ||key.compareTo(keys[i])!=0){ return ; } for(int j=i;j<N-1;j++){ keys[j]=keys[j+1]; vals[j]=vals[j+1]; } N--; keys[N]=null; vals[N]=null; if(N>=0&& N==keys.length/4) resize(keys.length/2); assert(check()); } /********************************* * 有序性操作: * 最大鍵、最小鍵(刪除最大、最小鍵) * 對於給定的鍵,向下取整(floor)操作: 找出小於等於該鍵的最大鍵 * 對於給定的鍵,向上取整(ceiling)操作: 找出大於等於該鍵的最小鍵 * 排名操作(rank): 找出小於指定鍵的鍵的數量 * 選擇操作(select): 找出排名為k的鍵 * 範圍查詢: 在兩個給定的鍵之間有多少鍵,分別是哪些 *********************************/ /** * rank採用了類似於二分查詢的操作,rank返回的範圍是0..N * 如果表中存在該鍵,rank()應該返回該鍵的位置,也就是表中小於該鍵的數量 * 如果不存在該鍵,rank()還是應該返回表中小於它的鍵的數量 */ public int rank(Key key){ int low=0,high=N-1; while(low<=high){ int mid=low+ (high-low)/2; int cmp=key.compareTo(keys[mid]); if(cmp<0) high=mid-1; else if(cmp>0) low=mid+1; else return mid; } return low; } public Key select(int i){ if(i<0 || i>=N) return null; return keys[i]; } public Key min(){ if(isEmpty()) return null; return keys[0]; } public Key max(){ if(isEmpty()) return null; return keys[N-1]; } /** * floor、ceiling、rank()-最多是對數級別的操作 * rank()- 找到鍵的排名 * select(): 取排名為k的鍵(O(1)複雜度) * @param key * @return */ /*取大於或等於它的最小鍵,要麼是當前的鍵或者應該插入的位置()*/ public Key ceiling(Key key){ int i=rank(key); if(i==N) return null; return keys[i]; } /*取小於或等於該鍵的最大鍵*/ public Key floor(Key key){ int i=rank(key); if(i<N && key.compareTo(keys[i])==0) return keys[i]; if(i==0) return null; //取小於首元素的最大鍵,必然是null return keys[i-1]; //常規是小於當前鍵的最大鍵 } public void deleteMin(){ if(isEmpty()) throw new NoSuchElementException("符號表下溢錯誤!"); delete(min()); } public void deleteMax(){ if(isEmpty()) throw new NoSuchElementException("符號表下溢錯誤!"); delete(max()); } /*[low..high]的鍵已排好序*/ public int size(Key low,Key high){ if(low.compareTo(high)>0) return 0; if(contains(high)) return rank(high)-rank(low)+1; else return rank(high)-rank(low); } /*表中的所有鍵的集合,已排序*/ public Iterable<Key> keys(){ return keys(min(),max()); } /*[low..high]之間的所有鍵,已排序*/ public Iterable<Key> keys(Key lo,Key hi){ LinkedQueue<Key> queue=new LinkedQueue<Key>(); if(lo==null&&hi==null) return queue; if(lo==null|| hi==null) throw new NoSuchElementException("某鍵不存在"); if(lo.compareTo(hi)>0) return queue; for(int i=rank(lo);i<rank(hi);i++) queue.enqueue(keys[i]); if(contains(hi)) queue.enqueue(keys[rank(hi)]); return queue; } private void resize(int capacity){ assert capacity>=N; Key[] keysTemp=(Key[]) new Comparable[capacity]; Value[] valsTemp=(Value[]) new Object[capacity]; for(int i=0;i<N;i++){ keysTemp[i]=keys[i]; valsTemp[i]=vals[i]; } keys=keysTemp; vals=valsTemp; } /********************************* * 檢查內部不變式 *********************************/ private boolean check(){ return isSorted()&& rankCheck(); } private boolean isSorted(){ for(int i=1;i<N;i++) if(keys[i].compareTo(keys[i-1])<0) return false; return true; } //檢查選擇第i個位置的鍵值對應的位置是否為i-rank(select(i))=i private boolean rankCheck(){ for(int i=0;i<size();i++) if(i!=rank(select(i))) return false; for(int i=0;i<size();i++) if(keys[i].compareTo(select(rank(keys[i])))!=0) return false; return true; } public static void test(String filename){ BinSearchST<String,Integer> st=new BinSearchST<String,Integer>(); In input=new In(filename); int i=0; while(!input.isEmpty()){ String key=input.readString(); st.put(key,i); i++; } for(String s: st.keys()){ StdOut.println(s+ " "+st.get(s)); } String key=st.floor("F"); StdOut.println("E的向下取整鍵值對(key-val): "+key+" "+st.get(key)); StdOut.println(st.rank("E")+" "+st.select(1)); key=st.ceiling("F"); StdOut.println("E的向上取整鍵值對(key-val): "+key+" "+st.get(key)); StdOut.println(st.size("E","P")); for(String s: st.keys("E","P")){ StdOut.println(s+ " "+st.get(s)); } } public static void computeGPA(){ BinSearchST<String,Double> st=new BinSearchST<String,Double>(); st.put("A+", 4.33); st.put("A",4.00); st.put("A-", 3.67); st.put("B+",3.33); st.put("B",3.00); st.put("B-",2.67); st.put("C+",2.33); st.put("C",2.00); st.put("C-",1.67); st.put("D",1.00); st.put("F",0.00); int n=0; double total=0.0; for(n=0;!StdIn.isEmpty();n++){ String grade=StdIn.readString(); total +=st.get(grade); } double gpa=total/n; StdOut.println("GPA = "+ gpa); } public static void main(String[] args) { //test("tiny.txt"); computeGPA(); } }
2 二叉查詢樹
根據二叉查詢樹的定義,有左子樹結點值小於或等於根結點值,右子樹結點值大於等於根結點值的性質,因而對BST進行中序遍歷,能夠得到一個非遞減的有序序列。因而進行BST的相關操作時必須維護它的有序性質
2.1 BST 基本操作
為了實現有序的操作,可以BST的結點內部定義一個變數N,統計以該結點為根的子樹的結點總數。結點定義如下:
/*和二分查詢定義結點總數N的方式不同*/ 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 Value get(Key key){ Node x=root; while(x!=null){ int cmp=key.compareTo(x.key); if(cmp<0) x=x.left; else if(cmp>0) x=x.right; else return x.val; } return null; }
對於插入操作,即先檢查這個元素是否存在,存在更新值,不存在插入新結點。它的遞迴實現和非遞迴實現都比較簡單(非遞迴插入維護結點計數器略微有點麻煩),如下為遞迴插入的程式碼:
對於查詢操作,思路很簡單: 先與根結點進行比較,如果相同查詢結束;否則根據比較結果,被查詢的結點較小沿左子樹查詢,較大沿右子樹查詢。對於一個未命中的查詢返回null。實現如下:
/** * 遞迴插入思路: * 如果樹是空的,就返回一個含該鍵值對的新節點 * 如果被查詢的鍵小於根結點的的鍵,遞迴在左子樹中插入 * 如果被查詢的鍵小於根結點的的鍵,遞迴在右子樹中插入 * 注意: 遞迴地在路徑上將每個結點中的計數器值加1 * 非遞迴的思路: 實現也不復雜,但更新結點計數器的方法比較複雜 */ 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.left=put(x.left,key,val); else if(cmp>0) x.right=put(x.right,key,val); else x.val=val; x.N=size(x.left)+size(x.right)+1; //更新結點的計數(遞迴方式) return x; }
對於刪除操作,要想刪除樹中的某個結點,還必須保證BST樹的排序不會丟失。思路如下:
- 被刪除結點是葉結點,直接刪除,不會影響BST的性質
- 若結點z只有一顆左子樹或者右子樹,讓z的子樹成為z父結點的子樹,替代z位置
- 若結點有兩個子樹,令它的直接後繼(直接前驅)替代z, 再從BST樹中刪除該直接後繼(直接前驅),這樣轉換成了前兩個情況
對於刪除最大值或最小值操作,只需要一直沿左或右側遍歷直到遇見第一個空連結位置然後返回另外一側的連結。這三個刪除操作的Java實現如下:
public void deleteMin(){ if (isEmpty()) throw new NoSuchElementException("符號表為空"); root=deleteMin(root); } /** * 不斷深入根結點的左子樹中直到遇到一個空連結 * 然後把指向該節點的連結指向該節點的右子樹(返回它的右連結即可) * 並遞迴更新在新節點到根結點的路徑上的所有結點的計數器的值 */ private 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; } public void deleteMax(){ if (isEmpty()) throw new NoSuchElementException("符號表為空"); root=deleteMax(root); } /** * 不斷深入根結點的右子樹中直到遇到一個空連結 * 然後把指向該節點的連結指向該節點的左子樹(返回它的左連結即可) * 並遞迴更新在新節點到根結點的路徑上的所有結點的計數器的值 */ private Node deleteMax(Node x){ if(x.right==null) return x.left; x.right=deleteMax(x.right); x.N=size(x.left)+size(x.right)+1; //更新結點的計數(遞迴方式) return x; } public void delete(Key key){ root=delete(root,key); } /** * 刪除結點x用它的後繼結點填補它的位置-一般是右子樹的最小結點 */ 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; }
2.2 BST的有序操作
A. 選擇操作
select操作和快速排序中的陣列劃分操作很類似,我們在BST中的每個結點維護結點計數器變數N就是來支援此操作的。找到排名為k的鍵的思路如下:
- 如果左子樹的結點數t大於k,則遞迴地在左子樹中查詢排名為k的鍵
- 如果小於k,則遞迴地在右子樹中查詢排名為k-t-1的鍵
- 如果相等,就返回根結點的鍵
程式碼實現如下:
/*返回排名為k的鍵值*/ public Key select(int k){ if(k <0 || k >=size()) return null; //排名0-N-1 return select(k,root).key; } private Node select(int k,Node x){ if(x==null) return null; int t=size(x.left); if(t>k) return select(k,x.left); else if(t<k) return select(k-t-1,x.right); else return x; }
B. 排名操作
rank操作返回給定鍵的排名(即以x為根結點的子樹中小於key的鍵的數量),它的實現和select類似。思路如下:
- 如果給定的鍵與根結點鍵相等,返回左子樹中結點的個數
- 小於根結點,返回該鍵在左子樹中的排名
- 大於根結點,返回該鍵在右子樹的排名+左子樹結點個數+1
程式碼實現如下:
public int rank(Key key){ return rank(key,root); } /** * 排名操作 * 返回以x為根結點的子樹中小於key的鍵的數量 * 如果給定的鍵與根結點鍵相等,返回左子樹中結點的個數 * 小於根結點,返回該鍵在左子樹中的排名 * 大於根結點,返回該鍵在右子樹的排名+左子樹結點個數+1 */ private int rank(Key key,Node x){ if(x==null) return 0; int cmp=key.compareTo(x.key); if(cmp<0) return rank(key,x.left); else if(cmp>0) return 1+size(x.left)+rank(key,x.right); else return size(x.left); }
C. 取整操作
向下取整操作的思路:
- 如果給定鍵小於當前根結點的鍵,那麼小於或等於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; else if(cmp<0) return floor(x.left,key); else { Node t=floor(x.right,key); if(t==null) return x; else return t; } } public Key ceiling(Key key){ Node x=ceiling(root,key); if(x==null) return null; return x.key; } private Node ceiling(Node x,Key key){ if(x==null) return null; int cmp=key.compareTo(x.key); if(cmp==0) return x; else if(cmp>0) return ceiling(x.right,key); else { Node t=ceiling(x.left,key); if(t==null) return x; else return t; } }
D. 範圍查詢及統計
範圍查詢思路很簡單,通過比較和兩個給定鍵的大小,將所有落在特定範圍內的鍵加入佇列中並跳過那些不可能含有查詢鍵的子樹。程式碼實現如下:
public Iterable<Key> keys(Key lo,Key hi){ LinkedQueue<Key> queue=new LinkedQueue<Key>(); keys(root,queue,lo,hi); return queue; } private void keys(Node x,LinkedQueue<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.enqueue(x.key); if(cmphi>0) keys(x.right,queue,lo,hi); } public int size(Key lo,Key hi){ if(lo.compareTo(hi)>0) return 0; if(contains(hi)) return rank(hi)-rank(lo)+1; else return rank(hi)-rank(lo); }
可以看到所有操作在最壞情況下所需的時間都和樹的高度成正比。如果BST是一個只有左(右)孩子的單支樹,它的高度為結點的個數,在這種情形下BST的效能是非常糟糕的。因而為了避免樹的高度增長過高,我們可以構造隨機化BST(J.Robson證明隨機鍵構造的二叉查詢樹的平均高度為樹中結點數的對數級別)或者增加一些資訊維護樹的平衡,如AVL樹、紅黑樹、伸展樹等。
2.3 BST的C實現
二叉查詢樹中結點和結構的定義如下:
/*二叉查詢樹的結點定義*/ typedef struct bst_node { void *item; struct bst_node *left; struct bst_node *right; } bst_node_t; /*二叉查詢樹的結構定義*/ typedef struct bst { bst_node_t *root; int n; int (*comp)(const void *,const void *); } bst_t;
這裡實現的操作基本上都是非遞迴的版本,不過並沒有實現各種有序性操作(通過在二叉樹的結點中定義統計以該節點為根的子樹結點總數)。
#include <stdlib.h> #include "bst.h" /** * bst.c - Binary Search Tree Implementation * */ /*為二叉查詢樹分配記憶體,comp是一個比較兩元素大小的函式指標*/ bst_t *bst_alloc(int (*comp)(const void *,const void *)){ bst_t *t=malloc(sizeof(bst_t)); t->root=NULL; t->n =0; t->comp=comp; return t; } /*釋放二叉查詢樹的記憶體*/ void bst_free(bst_t *t) { bst_node_t *cur; bst_node_t **stack; //維護一個指向bst_node結點的的指標陣列棧 /** * 釋放每個結點的記憶體: 通過VRL的訪問方式,根結點首先入棧 * 如果棧不空,結點出棧,若結點的孩子不空,子結點入棧(先右後左),再釋放當前根結點空間 * 只要棧不空採用DFS遍歷的方式重複上面的操作 * 和VRL遍歷方式類似 */ if(t->root){ //根結點不空 //分配n個指標元素的記憶體 stack=malloc((t->n) * sizeof(bst_node_t *)); stack[0]= t->root; //根結點入棧 int top=1; while(top) { cur=stack[--top]; //出棧 if(cur->right){ //以先右後左方式入棧儲存子結點,出棧則以先左後右 stack[top++]=cur->right; } if(cur->left){ stack[top++]=cur->left; } free(cur); } free(stack); //注意不釋放棧記憶體則產生記憶體洩露 } free(t); } /** * bst_insert(): 要求插入的元素項的鍵不能和樹中元素鍵相同,鍵獨一無二 * 思路: 首先檢查該元素是否已經存在,若查詢成功,直接返回該位置 * 否則查詢不成功,把新元素插入到查詢停止的地方 */ void *bst_insert(bst_t *t,void *item) { int (*comp)(const void *,const void *); bst_node_t *prev=NULL; //儲存待插入結點的父結點 int cmp_res; if(t->root){ bst_node_t *cur=t->root; comp=t->comp; while(cur){ cmp_res=comp(item,cur->item); if(cmp_res < 0){ prev=cur; cur=cur->left; } else if(cmp_res > 0) { prev=cur; cur=cur->right; } else return cur->item; //找到了並返回關鍵字item,不再插入 } } /*構造新結點*/ bst_node_t *node=malloc(sizeof(bst_node_t)); node->left=node->right=NULL; node->item=item; if(!prev){ t->root=node; } else { cmp_res=comp(item,prev->item); if(cmp_res < 0){ prev->left=node; } else { prev->right=node; } } t->n++; return NULL;//插入成功返回NULL } /** * bst_find(): 查詢樹中的某個關鍵字 * 思路: 先與根結點比較,若相同則查詢結束 * 否則根據比較結果,沿著左子樹或右子樹向下繼續查詢 * 如果沒有找到,返回NULL * 注: 這裡的item是指關鍵字的值,而非結點 */ void *bst_find(bst_t *t ,void *item){ bst_node_t *cur; int (*comp)(const void *,const void *); cur=t->root; comp=t->comp; while(cur){ int cmp_res=comp(item,cur->item); if(cmp_res < 0){ cur=cur->left; } else if(cmp_res > 0) { cur=cur->right; } else return cur->item; //找到了並返回關鍵字item } return NULL; } /*返回二叉查詢樹中的含最小鍵值的結點*/ void *bst_find_min(bst_t *t) { bst_node_t *cur; if(t->root){ cur=t->root; while(cur->left){ cur=cur->left; } return cur->item; } else { return NULL; } } /** * bst_delete : 刪除樹中的某個結點,還必須保證BST樹的排序不會丟失 * (1) 被刪除結點是葉結點,直接刪除,不會影響BST的性質 * (2) 若結點z只有一顆左子樹或者右子樹,讓z的子樹成為z父結點的子樹,替代z位置 * (3) 若結點有兩個子樹,令它的直接後繼(直接前驅)替代z, * 再從BST樹中刪除該直接後繼(直接前驅),這樣轉換成了前兩個情況 */ void *bst_delete(bst_t *t,void *item) { bst_node_t *cur,*prev; int (*comp)(const void *,const void *); prev=NULL; if(t->root){ cur=t->root; comp=t->comp; while(cur){ int cmp_res=comp(item,cur->item); if(cmp_res < 0){ prev=cur; cur=cur->left; } else if(cmp_res > 0) { prev=cur; cur=cur->right; } else break; //找到了並返回關鍵字item } } else { return NULL; } /*前兩個條件可表示三種情形*/ if(!cur->left){ //左孩子為空,右孩子代替它 if(!prev){ t->root=cur->right; } else if(cur==prev->left) { prev->left=cur->right; } else { prev->right=cur->right; } } else if(!cur->right){ //右孩子為空,左孩子代替它 if(!prev){ t->root=cur->left; } else if(cur==prev->left) { prev->left=cur->left; } else { prev->right=cur->left; } } else { //找到當前結點右子樹的最左孩子 bst_node_t *prev_cur=cur; bst_node_t *cur_cur=cur->right; while(cur_cur->left){ prev_cur=cur_cur; cur_cur=cur_cur->left; } /*更新cur結點的父親結點prev的孩子連結資訊*/ if(!prev){ t->root=cur_cur; } else if(cur==prev->left){ prev->left=cur_cur; } else { prev->right=cur_cur; } /*更新cur_cur的左右孩子連結資訊*/ if(prev_cur!=cur){ //最左孩子的父結點不是要刪除的結點, cur_cur不是cur的右孩子 prev_cur->left=cur_cur->right; cur_cur->right=cur->right; } cur_cur->left=cur->left; } void *res_item=cur->item; free(cur); t->n--; return res_item; } /*刪除BST樹中最小的鍵值結點*/ void *bst_delete_min(bst_t *t){ bst_node_t *cur,*prev; prev=NULL; if(t->root){ cur=t->root; while(cur->left){ prev=cur; cur=cur->left; } } else { return NULL; } if(!prev){ t->root=cur->right; } else { prev->left=cur->right; } void *res_item=cur->item; free(cur); t->n--; return res_item; }
3 AVL樹
AVL樹定義任意結點的左右子樹高度差的絕對值不超過1,定義結點左子樹與右子樹的高度差為該結點的平衡因子(BF)。
AVL樹保證平衡的基本思路是: 每當在樹中插入或刪除一個結點時,首先需要檢查其路徑上的結點是否因為這次操作而導致了不平衡,由於只有從根結點到插入點的路徑上的每個結點可能改變平衡狀態。因而只要找到插入路徑上離插入結點最近的平衡銀子絕對值大於1的結點X,再對以結點X為根的子樹,在保持BST有序特性的前提下,調整各結點的位置關係,使之重新達到平衡。
我們可以根據平衡被破壞時X的左右兩棵子樹的高度差為2分為四種情況,假設結點X為cur結點(參考STL原始碼剖析的5.1小節)
- 插入點為位於X的左子節點的左子樹(LL右單旋轉)
方法很簡單: A子樹提上一層,C子樹下降一層,可以理解為把k1向上提起,使k2自動下滑,並使B子樹掛到k2的左側,保持了平衡,如圖: 程式碼實現如下:
/*省去其他細節,比如實際函式裡面可能含有一個avl的資料結構avl_t */ void single_rotate_right(avl_node_t *cur){ avl_node_t *next=cur->left; cur->left=next->right; next->right=cur; cur=next; }
- 插入點為位於X的右子節點的右子樹(RR左單旋轉)
同上,程式碼實現如下:
void single_rotate_left(avl_node_t *cur){ avl_node_t *next=cur->right; cur->right=next->left; next->left=cur; cur=next; }
- 插入點為位於X的左子節點的右子樹(LR先左後右雙旋轉)
思路: (以k2為新的根結點,使得k1必須為左子樹,k3必須為右子樹)先把k2提上去,然後k1自然下滑(左單旋轉),然後再把k2提上去,k3自然下滑,注意保持左右子樹有序的性質。如圖: 程式碼實現如下:
void double_rotate_right_left(avl_node_t *cur){ avl_node_t *next=cur->left; avl_node_t *last=next->right; next->right=last->left; last->left=next; next=last; //以上為左單旋轉 cur->left=next->right; next->right=cur; cur=next; //這3行為右單旋轉 }
- 插入點為位於X的右子節點的左子樹(LR先左後右雙旋轉)
同上面的思路,程式碼不再贅述。
4 2-3樹、B樹
一顆2-3查詢樹或為一棵空樹,或由以下節點組成:
- 2-結點: 含一個鍵和兩條連結(左小右大)
- 3-結點: 含兩個鍵和三條連結(左小中間右大)
我們把指向一顆空樹的連結稱為空連結,一般地完美平衡的2-3查詢樹中的所有空連結到根結點的距離都應該是相同的,如圖所示:
所有葉子結點在樹的同一層,因此樹總是高度平衡的。注意到在除了提供符號表基本的查詢、插入、刪除操作外,由於BST是有序的,它同樣支援有序操作,如選擇、排名、範圍查詢、向上或向下調整。
4.1 插入操作
2-3樹的查詢演算法和二叉查詢樹的演算法基本類似,這裡不再詳述。為了在樹中插入一個新結點,我們可能先進行一次未命中的查詢,然後把新結點掛在樹的底部。對於插入我們要討論以下情形:
- 在一個父結點是2-結點的3-結點中插入新鍵
這種情形類似於在2-結點中插入新鍵。若未命中的查詢結束於一個2-結點,只需要把2-結點替換成3-結點即可,如圖:
為了維持樹的完美平衡需要為新鍵騰出空間,假想一個4-結點然後把其中鍵移動值原來的父結點中,新父結點中的原中鍵左右兩邊的兩個連結分別指向了兩個新的2-結點。這種轉換並不影響2-3樹的主要性質- 插入後所有的葉子節點到根結點的距離是相同的。如圖:
- 一個父結點是3-結點的3-結點中插入新鍵
這種情形類似於 在一棵只含有一個3-結點的樹中插入新鍵。為了將新鍵插入,先臨時把新鍵存入結點中,變成一個4-結點,很容易把它轉換成一個由3個2-結點組成的2-3樹,其中根結點含有中鍵,另外兩個分別持有3個鍵中的最大者和最小者。可以看出插入前後樹的高度由0變成了1,例子很簡單,但它說明了2-3樹是如何生長的。 如圖:
假設未命中的查詢結束於一個3-結點,如情形3。先將中鍵插入它的父節點中,但父結點是3-結點,我們再利用中鍵構造一個新的臨時4-結點,對父結點進行相同的變換,即分解父結點並把它的中鍵插入到它的父結點中取。推廣到一般情況,我們就這樣一直向上不斷分解臨時的4-結點並將中鍵插入更高層的父結點中,直到遇到一個2-結點並將它替換成一個不需要繼續分解的3-結點或者到達3-結點的根。 如圖:
可以看到2-3樹的插入演算法只需要修改相關的結點和連結而不必修改或者檢查樹的其他部分。這種區域性變換中變更的連結數不會超過一個很小的常數,操作結束後依然能保持任意的葉子結點到根結點的路徑長度是相等的性質,即保持了樹的完美平衡。
如圖為在2-3樹中分解為一個4-結點的情況彙總:
4.2 2-3樹的效能
2-3樹的分析和BST的分析大不相同,因為我們主要感興趣的是最壞情況下的效能(我們無法控制用例以怎樣的順序輸入,因此對最壞情況的分析是唯一能夠提供效能保證的方法),而非一般情況(使用隨機鍵模型分析預期的效能)。 如下兩圖分別為按升序的插入到樹中、隨機鍵構造的樹:
2-3樹在最壞情況下任何查詢、插入的成本都不會超過對數級別。經過實驗瞭解到:
- 在最壞情況下的時間複雜度: O(lgN) ; 最好情況下的複雜度: O(lgN/ lg3)
- 含有百萬個結點的2-3樹高度在12-20左右;含有10億個結點的2-3樹高度在18-30。也就是說完美平衡的2-3樹相比二叉查詢樹會平展很多,如我們最多隻需要訪問30個結點就能夠在10億個鍵中進行任意地查詢和插入操作(比較30次),這效率是相當驚人的
4.3 B-樹
2-3-4樹也可按照結點的度數來定義:
- 2-節點: 有1個鍵和2個兒子
- 3-節點: 有2個鍵和3個兒子
- 4-節點: 有3個鍵和4個兒子
一般認為B-樹是2-3樹(階為3的B-樹)、2-3-4樹(階為4的B-樹)的推廣,它們均是通過操作結點的度數來維持平衡的。這裡給出B樹的具體定義,一棵m階的B-樹,或為空樹或為滿足下列特性的m叉樹:
- 樹中每個結點最多含有m-1個鍵和m個兒子
- 若根結點不是葉結點,則至少有兩個子樹
- 除根結點外的所有非葉結點至少有m/2向上取整的棵子樹(即[m/2]-1個關鍵字)
- 所有葉結點都在同一層次上,並且不帶資訊(可看成外部結點,實際上這些結點不存在,為空)
例如一個3階的B-樹,每個結點最多有3個連結、2個關鍵字,最少有2個連結、1個關鍵字;一個6階的B-樹,每個結點至少有3個至多有5個的連結(根結點除外,它可以只含有2個連結、1個關鍵字)。B樹通常用作字典、資料檢索系統的自平衡結構、檔案系統,其大部分操作所需的磁碟存取次數與B-樹的高度成正比。對於任意一顆含有n個關鍵字、高度為h的m階B-樹,它滿足以下條件
- B樹中每個結點至多有m棵子樹、m-1個關鍵字,則有
- 為了讓結點中的關鍵字個數最少,即可使B-樹的高度達到最大。由B-樹定義:第一層至少有1個結點,第二層至少有2個結點,第三層至少有2(m/2)個結點,由葉結點即查詢不成功的結點為n+1個,則有
即一個有8個關鍵的3階B-樹,高度範圍為[2,3.17]
在進行B樹的插入、刪除時為了滿足B-樹中結點關鍵字個數的要求需要進行調整-分裂結點或移動結點,這是B-樹操作正確實現的核心步驟,這其實和2-3樹或者2-3-4樹的插入類似,不再詳述,有空可以自己實現一個
5 紅黑樹
在Robert Sedegwick的演算法書中說到,紅黑二叉查詢樹的基本思想是用標準的二叉查詢樹和一些額外的資訊(顏色)來表示2-3樹。其中紅色連結將兩個2-結點連結起來表示一個3-結點,對於任意的2-3樹我們都可用等價的二叉查詢樹來表示,這種樹被稱為紅黑樹。它是一種頗具歷史並被廣泛運用的平衡二叉查詢樹,能夠保證在最壞情形下查詢、插入、刪除、求最值、選擇、排序、範圍查詢等操作所需的時間是對數級別的
注意在RS的演算法和CLRS的演算法導論中紅黑樹的定義是有所不同的,但它們的意義是等價的。可以對比一下:
演算法導論中的紅黑樹定義如下:
- 每個結點要麼是紅色,要麼是黑色
- 根結點是黑的,每個葉子結點(NULL)是黑的
- 如果結點為紅,其子結點必為黑色
- 任意一結點到其空連結底部(樹尾端)的任何路徑,所含的黑色結點數目必須相同
演算法中的紅黑樹定義如下:
- 紅色連結均為左連結,並且說結點的顏色我們指的是指向該結點的連結的顏色
- 沒有任何一個結點同時和兩條紅連結相連
- 該樹是完美黑色平衡的,即任意空連結到根結點的路徑上的黑色連結數目是相同的
可以看到:
- 二叉查詢樹結合了連結串列插入的靈活性和有序陣列查詢的高效性,而紅黑樹結合了二叉查詢樹中簡潔高效的查詢方法和2-3樹中高效的平衡插入演算法
- 新插入的結點必然是紅色,而新增結點的父結點必然是黑色。當新結點根據規則到達其插入點,卻未能符合上述條件,就必須調整顏色並旋轉樹形
- 紅黑樹既是二叉查詢樹又是2-3樹(也可以是2-3-4樹)的等價
如下為紅黑樹和2-3樹的一一對應關係:
這裡的實現參考Robert Sedgewick的演算法書,定義一個內部結點類,連結的顏色定義在Color變數中,有序性操作的實現需要定義子樹中結點的總數N,如下所示:
private class Node { private Key key; private Value val; private Node left,right; private int N; //該子樹的結點總數 private boolean color; //由其父結點指向它的連結的顏色 public Node(Key key, Value val, boolean color, int N) { this.key = key; this.val = val; this.color = color; this.N = N; } }
5.1 紅黑樹的插入
在插入新的鍵時我們使用旋轉操作來保持紅黑樹的各種定義性質:有序性、完美黑色平衡性、不存在兩條連續的紅色連結和不存在紅色的右連結。如下為旋轉和顏色轉換操作的處理情形:
- 右子結點是紅色的而左子結點是黑色的,進行左旋轉
- 左子結點是紅色的且它的左子結點也是紅色的,進行右旋轉
- 如果左右子結點均為紅色,進行顏色轉換(根結點始終為黑色)
如下為插入操作的討論
- 向樹底部的2-結點插入新鍵
這種方法只需要討論如果樹只有一個2-結點該如何插入新鍵,即
- 新鍵小於父結點的鍵,只需要增加紅色的結點即可,新的紅黑樹與單個3-結點完全等價
- 新鍵大於父結點的鍵,新增的紅色結點會產生一個紅色的右連結,進行左旋轉調整樹形
如圖所示
在樹底部的2-結點插入新鍵操作和上面操作類似
- 向樹底部的3-結點插入新鍵
這種方法同樣我們只需要討論如果樹只有一個3-結點該如何插入新鍵,即
- 新鍵大於原樹中的兩個鍵,直接將兩個連結的顏色由紅變黑,根結點還是為黑色
- 新鍵小於原樹中的兩個鍵,這樣產生了兩條連續的紅連結,只需把上層的紅連結右旋轉即可得到第一種情形
- 新鍵介於原樹中的兩個鍵之間,這樣產生了一條紅色左連結和一條紅色右連結,只需要把下層的紅連結左旋轉即可得到第二種情形
這樣我們通過0,1,2次旋轉以及顏色的變化的到了期望的結果,如圖所示:
因而在樹的底部的3-結點中插入一個新結點,它可能的位置如上面所討論的情形類似。值得注意的是,當顏色發生轉換時中結點的連結變紅,可使得紅色連結不斷在樹中向上傳遞,直至遇到一個2-結點或者根結點。
public class RedBlackBST<Key extends Comparable<Key>, Value> { private static final boolean RED=true; private static final boolean BLACK=false; private Node root; private class Node { private Key key; private Value val; private Node left,right; private int N; //該子樹的結點總數 private boolean color; //由其父結點指向它的連結的顏色 public Node(Key key, Value val, boolean color, int N) { this.key = key; this.val = val; this.color = color; this.N = N; } } /*結點輔助函式*/ private boolean isRed(Node x){ if(x==null) return false; return (x.color==RED); } /** * 左旋轉 * 若新增的紅色結點會產生一個紅色的右連結 * 只需要將插入結點的父結點旋轉為紅色左連結,修改顏色和結點總數 */ private 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; } /** * 右旋轉 * 若新增的紅色結點會產生一個紅色的左連結(兩個連續的紅色左連結) * 使左連結lean向右側 */ private Node rotateRight(Node h){ Node x=h.left; h.left=x.right; x.right=h; x.color=h.color; h.color=RED; x.N=h.N; h.N=1+size(h.left)+size(h.right); return x; } /** * 當左右連結均為紅連結時 * 子結點的顏色由紅變黑,父節點的顏色由黑變紅 */ private void flipColors(Node h){ h.color=!h.color; h.left.color=!h.left.color; h.right.color=!h.right.color; } private int size(Node x){ if(x==null) return 0; return x.N; } public int size(){ return size(root); } public boolean isEmpty(){ return (root==null); } public Value get(Key key){ Node x=root; while(x!=null){ int cmp=key.compareTo(x.key); if(cmp<0) x=x.left; else if(cmp>0) x=x.right; else return x.val; } return null; } public boolean contains(Key key){ return get(key)!=null; } /*自底向上更新結點顏色,並始終保持根結點顏色是黑色*/ public void put(Key key,Value val){ root=put(root,key,val); root.color=BLACK; } /** * 樹形調整操作: 旋轉及改變顏色,重新令樹保持平衡 */ private Node put(Node h,Key key,Value val){ if(h==null) return new Node(key,val,RED,1); /*查詢到未命中結點的位置*/ int cmp = key.compareTo(h.key); if (cmp < 0) h.left = put(h.left, key, val); else if (cmp > 0) h.right = put(h.right, key, val); else h.val = val; /*修正任何右傾的連結,遞迴地自底向上判斷h結點及其孩子結點的顏色*/ if(isRed(h.right)&& !isRed(h.left)) h=rotateLeft(h); if(isRed(h.left)&& isRed(h.left.left)) h=rotateRight(h); if(isRed(h.left)&& isRed(h.right)) flipColors(h); h.N=1+size(h.left)+size(h.right); return h; } }
紅黑樹中delete操作的實現比較複雜,工業界實現的程式碼量大概100行左右,可以參考下STL的紅黑樹實現
5.2 紅黑樹的性質與結構擴張
和典型的二叉查詢樹相比,一棵典型的紅黑樹的平衡性是很好的,大小為N的紅黑樹的高度不超過2lgN,使得它所有操作的執行時間都是對數級別的。
紅黑樹這種近乎完美的平衡性,在效率表現和實現複雜度上也保持相當的"平衡",所以運用很廣,使得STL中的map和set均以RB-tree為底層機制,元素的自動排序效果很不錯,map和set所開放的各種操作介面實際上是呼叫紅黑樹的各種操作行為而已,對比SGI的hash_map、hash_set是以雜湊表hash table作為底層機制,它不具有自動排序功能。
總結
總結下來,我們已經實現了各種各樣的符號表(字典),包括雜湊表。那麼對於一些典型的應用程式,我們該使用符號表的哪種實現呢?
-
和二叉查詢樹相比,Hash-table的優點在於程式碼更簡單,支援常數級別的插入和查詢操作;key實現是無序的,並且無需比較;雜湊函式的計算可能複雜而且昂貴
-
二叉查詢樹相對於雜湊表的優點在於抽象結構更簡單(無需設計雜湊函式),儘管一般的二叉查詢樹很實用,但它還是有一些缺點,如連結使用了大量的記憶體空間,若記憶體資源寶貴的話,建議使用開地址法的雜湊實現
-
如果通用的二叉查詢樹的平衡性變得很差,並引起效能急劇降低,基於一般的BST衍生出了很多保證對數效能或平衡性的結構,如隨機化BST、AVL樹、紅黑樹、跳躍表、伸展BST(splay生成樹)、Treap
-
隨機化BST對隨機數生成器有要求,並試圖避免花費太多時間生成隨機位;AVL樹是通過約束任意節點的左右子樹的高度差來維護樹的平衡,如果結點不平衡時可以藉助4種旋轉操作使樹重新獲得平衡;伸展BST是一種頻繁訪問小型關鍵字集合的應用,它的自調整形式會沿著結點到根的路徑通過一系列的選擇把該結點移到樹根上去(為了使整個查詢時間最小,被查頻率高的條目應當處於靠近樹根的位置)
-
紅黑樹是平衡性近乎完美的一種二叉查詢樹,通過旋轉和顏色的調整保證最壞情況下的對數效能且它能夠支援的操作更多(如排名、選擇、排序和範圍查詢)。根據經驗法則,大多數程式設計師的第一選擇是雜湊表,在其他因素更重要時才會選擇紅黑樹
-
跳躍表也是一種平衡的二叉樹,擴充了一些額外指標資訊的連結串列(見演算法和資料結構筆記一),藉助隨機化保能夠以少於其他方法的空間保證每一個字典操作都在O(lg n)的期望時間內執行(如insert、delete、search、join、select、rank等操作)
-
在鍵都是長字串時,我們可以構造出比紅黑樹更靈活而又比雜湊表更高效的資料結構- Trie樹。動機是利用字串的公共字首來節約記憶體,加快檢索速度。一般地只要是由關鍵字集合S中若干元素串在一起的結構叫做Trie,多用於文字串的詞頻統計,適合字首匹配和全字匹配
-
Trie有很多變式,其中一個重要的結構是字尾樹,還有字尾樹的變形字尾陣列。字尾樹的單詞集合是由指定字串的字尾子串構成的,適合字尾和子串匹配(常規構造方法或者基於Rabin-Karp演算法中的O(nlgn)的 Monto-Carlo演算法構造)。它可用於找出字串S中的最大回文子串S1(如xmadamyx的最長迴文子串是madam)、字串S的最長重複子串S1(如abcdabcefda裡abc和da都重複出現,而最長重複子串是abc)、找出字串S1和S2的最長公共子串(lcs的實現可以基於動態規劃或者字尾陣列)
如下圖為各種符號表實現的漸進效能的總結: