1. 程式人生 > >【Algorithms公開課學習筆記6】 符號表part1——二叉搜尋樹

【Algorithms公開課學習筆記6】 符號表part1——二叉搜尋樹

BST 二叉搜尋樹

0. 前言

本文的主要內容是分析符號表這種資料結構,並著重介紹使用二叉搜尋樹來實現符號表的方法。

1. Symbol Table 符號表

基本概念

符號表是一種鍵值對(key-value)的資料結構,其基本操作包括:插入一個鍵值對,根據鍵查詢其對應的值。 符號表在現實中最常見的應用有:DNS域名解析系統(如下圖),routing table路由表,file system檔案系統等。

symbol table的API如下圖所示


public class ST<Key, Value>{
    ST() //建構函式
    void put(Key key, Value val)
//插入一鍵值對,如果val為空,相當於刪除鍵為key的項 Value get(Key key) //根據鍵key獲取其值value,如果沒有該項則為null void delete(Key key) //根據鍵key刪除該項 boolean contains(Key key) //根據鍵判斷是否包含該項 boolean isEmpty() //判斷是否為空 int size() //符號表的大小 Iterable<Key> keys() //迭代符號表的所有鍵 }

符號表中value的型別可以是任意的(泛型),但key的型別必須要滿足以下條件:

  • key是Comparable的,使用compareTo()方法來比較大小;
  • key可以是泛型,但必須使用equals()方法來判斷是否相等;
  • key可以是泛型,但必須使用equals()方法來判斷是否相等,使用hashCode()來置亂key。

實踐證明,key最好使用不可更改型別(immutable),如Integer, Double, String等常用型別。

equal()方法詳解

所有的java類都繼承了equals()方法,但使用equals()方法必須滿足等價關係,具體描述成:

  • 自反性:x.equals(x)是正確的;
  • 對稱性:如果x.equals(y),則必有y.equals(x);
  • 傳遞性:如果x.equals(y), y.equals(z), 則必有x.equals(z);
  • 非空性:x.equals(null)是錯誤的。

因此,如果要判斷x是否為空,不能用equals(),只能x==null。另外,一切不滿足等價關係的變數均不能使用equals()。

使用者自定義的類也可以重寫equals()方法,一般重寫equals()方法遵守以下套路

//必須傳入Object物件
public boolean equals(Object y){
    //如果是本類物件的引用,絕對是相等的(自反性)
    if (y == this) return true;
    //如果物件為null,絕對不相等(非空性)
    if (y == null) return false;
    //如果不同類,絕對不相等
    if (y.getClass() != this.getClass())
        return false;

    //強制型別轉換
    Date that = (Date) y;
    //判斷,如果是基本型別使用==,如果是物件使用equals(),如果是陣列使用Arrays.equals(a,b)
    if (this.day != that.day ) return false;
    if (this.month != that.month) return false;
    if (this.year != that.year ) return false;
    return true;
}

簡單應用

想象一個場景:程式順序讀取檔案的內容(字串)最為key,同時關聯讀取的順序i作為value 程式碼如下:

public static void main(String[] args){

    ST<String, Integer> st = new ST<String, Integer>();
    //插入
    for (int i = 0; !StdIn.isEmpty(); i++){
        String key = StdIn.readString();
        st.put(key, i);
    }
    //查詢
    for (String s : st.keys())
        StdOut.println(s + " " + st.get(s));
}

結果如下:

2. 符號表的基本實現

下面介紹符號表的兩種實現方法:順序查詢法和二叉樹法

順序查詢

順序查詢法通過無序連結串列來儲存鍵值對。查詢時,按指標順序查詢,直到找到匹配的key;插入時,先查詢,若找到匹配的可以則要替換value,若找不到則在連結串列前面插入節點。如下圖所示

二叉樹法

二叉樹法是通過兩個有序陣列來儲存鍵值對的,一個儲存鍵,另一個在相應位置儲存值。查詢時,使用二分查詢法,能夠非常高效地找到匹配的可以(或確認不存在);插入時,將項插入到有序陣列最後,在跟前面大於該項的其他項交換位置,直至有序,同時儲存鍵的陣列也要做相應交換。

二叉樹法中,需要運用到一個很方便的輔助函式rank(key):返回key的排位。程式碼實現如下:

//返回key在有序陣列中的排位,二分查詢法
private 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) hi = mid - 1;
        else if (cmp > 0) lo = mid + 1;
        else if (cmp == 0) return mid;
    }
    return lo;
}

有了rank(key)方法之後,get(key)的實現將相當簡單。

public Value get(Key key){
    if (isEmpty()) return null;
    //查到key的排位,同時也是value的排位
    int i = rank(key);
    if (i < N && keys[i].compareTo(key) == 0) return vals[i];
    else return null;
}

順序連結串列和二叉樹的效能對比如下:

從表中可以看出,二叉樹在查詢上顯示了極優的效能,但在插入上仍然不能滿足效能要求,這一點將會在下一小節分析的二叉搜尋樹得以解決。

3. 符號表的操作

對於符號表,除了查詢和插入操作之外,還有其他極其豐富的操作介面,詳細的API如下所示:

public class ST<Key extends Comparable<Key>, Value>{
    ST() //建構函式
    void put(Key key, Value val) //插入鍵值對
    Value get(Key key) //根據鍵key獲取其值value,如果沒有返回null
    void delete(Key key) //根據鍵key刪除鍵值對
    boolean contains(Key key) //查詢是否包含該鍵
    boolean isEmpty() //判空
    int size() //大小
    Key min() //查詢最小的key
    Key max() //查詢最大的key
    Key floor(Key key) //查詢小於或等於key中最大的那個
    Key ceiling(Key key) //查詢大於或等於key中最小的那個
    int rank(Key key) //根據可以返回其排位
    Key select(int k) //根據排序查詢key
    void deleteMin() //刪除最小的key
    void deleteMax() //刪除最大的key
    int size(Key lo, Key hi) //key lo到key hi之間key的數量
    Iterable<Key> keys(Key lo, Key hi) //迭代key lo到key hi之間key
    Iterable<Key> keys() //迭代所有的key
}

通過順序查詢法二叉樹法實現的符號表中,其操作的時間效能如下圖所示:

3. BST二叉搜尋樹

基本概念

二叉搜尋樹是擁有對稱順序二叉樹。 所謂的二叉樹其左右子樹均是二叉樹或空(遞迴定義)。 所謂對稱順序是指二叉樹的任意一個節點的值大於其左子樹的節點值,小於其右子樹的節點值。 如圖所示

二叉搜尋樹BST的API如下:

public class BST<Key extends Comparable<Key>, Value>{
    //樹根
    private Node root;
    //樹的節點類
    private class Node{
        private Key key;//鍵
        private Value val;//值
        private Node left;//左連結
        private Node right;//右連結
        private int count;//左右子樹節點總數
    }
    public void put(Key key, Value val);//插入鍵值對
    public Value get(Key key);//根據key查詢
    public void delete(Key key);//根據key刪除
    public Iterable<Key> iterator();//迭代所有key
}

基本操作

查詢:二分查詢,若小於root往左邊;若大於root往右邊;若相等返回root。 插入:先用二分查詢法找到key的合適位置,建立節點後插入。插入節點後需要修改上層root節點的count值。假設在查詢過程中遇到匹配key的節點,則直接替換其value。 一般情況下,查詢和插入操作使用遞迴法會比較簡潔。

//查詢
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 if (cmp == 0) return x.val;
    }
    return null;
}
//插入
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.left = put(x.left, key, val);
    else if (cmp > 0)
        x.right = put(x.right, key, val);
    else
        x.val = val;
    //修改上層root節點的count
    x.count = 1 + size(x.left) + size(x.right);
    return x;
}

由上面程式碼可以發現,查詢和插入的時間效能都是取決於樹的高度。一旦樹的高度變得非常陡峭的話,時間效能就會變壞,如圖所示。而樹高取決於節點的插入順序,因此儘量隨機插入數字,避免樹過於陡峭。

如果沒有相同的key,且隨機插入的話,BST的時間效能與快速排序的partitioning一樣的。

其他操作

最小key:查詢左子樹直到null 最大key:查詢右子樹直到null Floor向下取整:如Floor(G)是查詢小於G中的最大值 Ceiling向上取整:如Ceiling(G)是查詢大於G中的最小值

這裡重點分析Floor(Ceilling同理),程式碼和圖示如下

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

Size大小:返回root節點的count(子樹節點數量) Rank節點的排位:關鍵就暗示藉助count值。若==root,排位k則為root的count值;若 < root,排位k則為左子樹root的count;若 > root,則為左子樹root的count + 1 +右子樹root的count。

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);
    // < root
    if (cmp < 0) return rank(key, x.left);
    // > root
    else if (cmp > 0) return 1 + size(x.left) + rank(key, x.right);
    // == root
    else  return size(x.left);
}

Inorder Traversal中序遍歷:中序遍歷按照如下順序遞迴遍歷:遍歷左子樹——>root——>遍歷右子樹。

public Iterable<Key> keys(){
    Queue<Key> q = new Queue<Key>();
    inorder(root, q);
    return q;
}
private void inorder(Node x, Queue<Key> q){
    if (x == null) return;
    //遍歷左子樹
    inorder(x.left, q);
    //root
    q.enqueue(x.key);
    //遍歷右子樹
    inorder(x.right, q);
}

時間效能對比

4. 刪除操作

在BST中,由於刪除操作會打破BST的規則,需要調整BST,故在此處著重分析。

方法一

直接將匹配key的節點的value設定成null。 這種方法屬於偷懶型的方法,實際上節點仍然存在,且BST沒有變化。只是根據key查詢時將返回null而已。 這種方法的時間主要消耗在查詢上,故與查詢一致,為~2lgN。

方法二

真正地刪除節點,並調整BST

case1:如果待刪除的節點沒有子節點,則直接刪除(設定其父節點的連結為null)。

case2:如果待刪除的節點只有一個子節點,則刪除該節點後(設定其父節點的連結為替換節點,設定其連結為null),其位置用子節點替換。

case3:如果待刪除的節點有兩個子節點,則刪除該節點後(設定其父節點的連結為替換節點,設定其連結為null),其位置用右子樹的最小節點替換。

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);
        //刪除:t的位置由X替代,且左子樹不變,右子樹替換成刪除min之後的子樹
        x.right = deleteMin(t.right);
        x.left = t.left;
    }
    //調整count值
    x.count = size(x.left) + size(x.right) + 1;
    return x;
}

時間效能:大量實驗證明,BST的刪除操作的時間效能與(根號N)成正比,~√N。