《算法》筆記 8 - 二叉查找樹

  • 二叉查找樹
    • 查找
    • 插入
    • 性能
  • 有序性相關的操作
    • 最大鍵、最小鍵
    • 向上取整、向下取整
    • 選擇、排名
    • 範圍查找
  • 刪除操作
    • 刪除最大鍵、最小鍵
    • 通用刪除操作

二叉查找樹

前面瞭解的無序鏈表和有序數組在性能方面至少在線性級別,無法用於數據量大的場合。接下來要學習的二叉查找樹可以將鏈表插入的靈活性和有序數組查找的高效性結合起來,是計算機科學中最重要的算法之一。
一個二叉查找樹(Binary Search Tree)是一顆二叉樹,其中每個結點都含有一個Comparable的鍵,以及相關聯的值,且每個結點的鍵都大於其左子樹中任意結點的鍵,小於右子樹中任意結點的鍵。

查找

在二叉查找樹中查找時,如果樹是空的,則查找未命中;如果被查找的鍵和根結點的鍵相等,查找命中,否則就遞歸地在子樹中繼續查找,如果被查找的鍵小於根結點,就選擇左子樹,否則選擇右子樹。
查找算法的代碼實現爲:

public class BST<Key extends Comparable<Key>, Value> {
    private Node root;

    private class Node {
        private Key key;
        private Value val;
        private Node left, right;
        public int size;

        public Node(Key key, Value val, int size) {
            this.key = key;
            this.val = val;
            this.size = size;
        }
    }

    public Value get(Key key) {
        return get(root, key);
    }

    private Value get(Node x, Key key) {
        if (key == null)
            throw new IllegalArgumentException("calls get() with a null key");
        if (x == null)
            return null;
        int cmp = key.compareTo(x.key);
        if (cmp > 0) {
            return get(x.right, key);
        } else if (cmp < 0) {
            return get(x.left, key);
        } else {
            return x.val;
        }
    }
}

其中,Node類用來表示二叉查找樹的結點,每個結點都含有鍵、值、左右鏈接和一個後面實現最大最小值等有序操作時會用到的結點計數器。

插入

插入鍵值對時,首先進行查找,如果鍵已經存在於符號表中,則更新對應的值;如果查找未命中,就返回一個含有待插入鍵值對的新結點。

public void put(Key key, Value val) {
    root = put(root, key, val);
}

private Node put(Node x, Key key, Value val) {
    if (key == null)
        throw new IllegalArgumentException("calls put() with a null key");
    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.size = size(x.left) + size(x.right) + 1;
    return x;
}

插入操作的代碼與查找類似,但插入操作會更新結點值或添加新結點,並且會更新結點計數器。
x.left = put(x.left, key, val); 類似這樣的代碼利用遞歸的特性簡潔地實現了結點的添加。在遞歸調用時,相當於根據二分查找的邏輯,沿着樹的某個分支一直向下查找,如果找到,就終止遞歸,更新結點的值,如果到了樹的最底層也沒找到,此時key==null成立,遞歸也會終止,同時新初始化的結點也已經被掛在x.left或者x.right了。
在遞歸推出的過程中,相當於沿着樹向上爬,每爬一層,*x.size = size(x.left) + size(x.right) + 1;*都會被執行,這樣在添加結點後,相關路徑上的所有結點的size都得到了更新。

性能

插入新結點和未命中的查找都需要從整顆樹的根結點搜索到樹的最底層,所以二叉查找樹的性能與樹的形狀有關,因爲樹的形狀決定了樹的深度。在最好的情況下,一個含有N個結點的樹是完全平衡的,所有的空鏈接都在最底層,距離根結點的距離爲LgN;而在最壞的情況下,樹的形狀變成了一條鏈表,樹的深度爲N,將元素按順序逐個插入到二叉查找樹時,就可以造成這種情況。在一般的情況下,得到的樹的形狀與最好情況更加接近,二叉查找樹的性能在對數級別。
英文原版《雙城記》中大於7個字符的單詞一共14350個,這些單詞中不同的單詞有5737個,將這些單詞作爲鍵來測試不同符號表實現的性能,結果如下:
在這裏插入圖片描述
圖中橫座標表示插入單詞的數量,縱座標表示插入時的比較次數,灰點表示某次插入的實際比較次數,紅點表示平均比較次數(比較總數/插入單詞數量),前面學習過基於無序鏈表和有序數組的實現,平均次數分別爲2246和484次,可以看到二叉查找樹無論在單詞比較次數還是平均次數方面,都有了跨越數量級的進步。

有序性相關的操作

二叉查找樹除了擁有較好的性能,還因其能夠保持鍵的有序性而支持有序性相關的操作。

最大鍵、最小鍵

一個結點的左子樹的值都小於右子樹,所以最小值可能在左子樹中,如果左子樹爲空,則當前結點就是最小值。基於這種算法得出求最大值、最小值的代碼實現爲:

public Key min() {
    if (isEmpty())
        throw new NoSuchElementException("calls min() with empty symbol table");
    return min(root).key;
}

private Node min(Node x) {
    if (x.left == null)
        return x;
    else
        return min(x.left);
}

public Key max() {
    if (isEmpty())
        throw new NoSuchElementException("calls max() with empty symbol table");
    return max(root).key;
}

private Node max(Node x) {
    if (x.right == null)
        return x;
    else
        return max(x.right);
}

向上取整、向下取整

關於向下取整,如果給定鍵小於根結點的鍵,那麼小於等於給的鍵的最大值在根結點的左子樹中,如果給定的鍵大於根結點,那麼只有當根結點右子樹中存在小於等於給定鍵的結點時,向下取整的值會出現在右子樹中,否則根結點就是要找的值,向上取整的方法與此類似:

public Key floor(Key key) {
   Node n = floor(root, key);
   if (n == null) {
       return null;
   } else {
       return n.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 n = floor(x.right, key);
   if (n == null) {
       return x;
   } else {
       return n;
   }
}

public Key ceiling(Key key) {
   if (n == null) {
       return null;
   } else {
       return n.key;
   }
}

private Node ceiling(Node x, Key key) {
   if (x == null) {
       return null;
   }

   int cmp = key.compareTo(x.key);
   if (cmp == 0)
       return x;
   if (cmp > 0)
       return ceiling(x.right, key);

   Node n = ceiling(x.left, key);
   if (n == null) {
       return x;
   } else {
       return n;
   }
}

選擇、排名

排名從0開始,選擇方法select(k)會返回排名爲的鍵,樹中有k個小於它的鍵。如果左子樹中的結點數t大於k,就繼續在左子樹中查找,如果t等於k,那麼根結點就是要找的鍵,如果t小於k,就在右子樹中查找排名爲k-t-1的鍵,由此得到的代碼爲:

public Key select(int k) {
    return select(root, k).key;
}

private Node select(Node x, int k) {
    if (x == null) {
        return null;
    }

    int t = size(x.left);
    if (t > k) {
        return select(x.left, k);
    } else if (t < k) {
        return select(x.right, k - t - 1);
    } else {
        return x;
    }
}

排名rank()方法是選擇方法的逆方法,它返回給定鍵的排序。如果給定鍵與根結點相等,那麼鍵的排名就是根結點左子樹中的結點總數t;如果給定鍵小於根結點,在左子樹中繼續遞歸計算;如果給定鍵大於根結點,就返回t+1再加上它在右子樹中的排名。

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

範圍查找

範圍查找要求返回給定範圍內的所有鍵,這裏會用到遍歷二叉樹的基本方法-中序遍歷。先遍歷左子樹中的所有鍵,然後遍歷根結點,最後是右子樹中的所有鍵,這一過程遞歸地進行,就可以按從小到大的順序遍歷完所有結點。

public 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.enqueue(x.key);
    if (cmphi > 0)
        keys(x.right, queue, lo, hi);
}

刪除操作

刪除最大鍵、最小鍵

刪除最小鍵時,需要不斷地深入根結點的左子樹,直到遇見一個空鏈接,然後將指向該結點的鏈接指向該結點的右子樹,要被刪除的結點因爲沒有被任何對象引用,隨後就會被垃圾回收器清理掉。刪除最大鍵的過程類似。

public void deleteMin() {
    root = deleteMin(root);
}

private Node deleteMin(Node x) {
    if (x.left == null)
        return x.right;
    x.left = deleteMin(x.left);
    x.size = size(x.left) + size(x.right) + 1;
    return x;
}                           

public void deleteMax() {
    root = deleteMax(root);
}

private Node deleteMax(Node x) {
    if (x.right == null)
        return x.left;
    x.right = deleteMax(x.right);
    x.size = size(x.left) + size(x.right) + 1;
    return x;
}

在不斷深入左子樹的時候,除非遇見空鏈接,deleteMin(Node x)方法都返回結點x,只有最後一次遞歸纔將上個結點指向x.right,遞歸退出時,會更新路徑上的結點計數器。

通用刪除操作

二叉查找樹中最難實現的方法就是delete()方法了,刪除最大、最小鍵時,被刪除的結點的兩個子結點中,只有一個不爲空,但一般的結點都會有兩個子結點,刪除這個結點後,需要合理處理它的兩個子結點。T.Hibbard在1962年提出瞭解決這個難題的第一個方法,在刪除結點x後用它的後繼結點填補它的位置。因爲x有一個右子結點,由此它的後繼結點就是其右子樹中的最小結點。這樣的替換仍然能保證樹的有序性,因爲x.key和它的後繼結點之間不存在其他的鍵。完成這個操作需要4步:

  • 將指向即將被刪除的結點的鏈接保存爲t;
  • 將x指向它的後繼結點min(t.right);
  • 將x的右鏈接(原本指向一顆所有結點都大於x.key的二叉查找樹)指向deleteMin(t.right),也就是在刪除後所有結點仍然後大於x.key的子二叉查找樹。
  • 將x的左鏈接(原本爲空)設爲t.left(其下所有的鍵都小於被刪除的結點和它的後繼結點)。
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.size = size(x.left) + size(x.right) + 1;
    return x;
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章