資料結構與演算法——單詞查詢樹

weixin_34007886發表於2017-11-30

資料結構與演算法——單詞查詢樹

單詞查詢樹由字元鍵中的所有字元構造而成,和各種查詢樹一樣,單詞查詢樹也是由結點連結所組成的資料結構。這些連結可能為空,也可能指向其他結點(或者說以此為根結點的其他子樹)。每個結點都有R條連結,R是字母表的大小,單詞查詢表通常含有大量的空連結,在繪製一棵單詞查詢樹時一般會忽略所有的空連結。單詞查詢樹的結點有兩個域,一個是字串鍵對應的值,字串的最後一個字元所在的結點才有值,其他結點的值都為空;另一個域指向下一個結點,這裡實際上是大小為R的結點陣列,每個陣列中儲存著若干字元,其餘位置都是空的。總的來說:值為空的結點在符號表中沒有對應的鍵,它們的存在是為了簡化單詞查詢樹中的查詢操作。

一棵簡單的單詞查詢樹如下所示:

2726327-d443722d6d873387.PNG
image

可以看到根結點沒有存放任何字元,每個結點都包含下一個可能出現的所有字元的連結。從根結點開始首先經過了鍵的首字母所對應的連結,如此這般沿著結點不斷前進,直到到達鍵的最後一個字元或是遇到一條空連結。這時可能出現以下三種情況:

  • 鍵的尾字元所對應的結點中的值非空,這是一次命中的查詢——鍵所對應的值就是鍵的尾字元對應結點中儲存的值。如下圖中對shells和she的查詢
  • 鍵的尾字元對應的結點中的值為空,這是次未命中的查詢。如下圖對shell的查詢。
  • 查詢結束於一條空連結,這是次未命中的查詢。如下圖對shore的查詢。
2726327-f12a9b95803255f8.jpg
image

所有查詢(get)都是從根結點開始檢查某條路徑上的所有結點。

單詞查詢樹的插入和二叉查詢樹一樣,在插入之前要先進行一次查詢。有兩種情況:

  • 在到達鍵的尾字元之前就遇到一條空連結。此時需要為鍵中還未被檢查的每個字元建立一個對應的結點,並將值存放到最後一個字元的結點中;比如下圖中飯shells和shore的插入就屬於這種情況。
  • 在遇到空連結之前就已經到達鍵的尾字元。此時需要將該結點的值設定為鍵所對應的值。下圖中第二次插入sea就屬於這種情況。
2726327-cf8e9d8dbfa4db7a.jpg
image

如果把單詞查詢樹忽略的空連結都畫出來,就是下面這個樣子。

2726327-4ce8871df282c3c9.jpg
image

可以看到除了根結點root,其他每個所謂的結點其實都是一個結點陣列。我們知道字母表大小為26,對於單詞sea,s對應著陣列的第19個位置,e對應著字元s所指向的結點陣列中的第5個位置,a對應著e所指向的結點陣列中的第1個位置,且該結點儲存著鍵sea對應的值。實際上結點沒有存放任何字元,從資料結構可以看出它只儲存了連結陣列(Node[] next)和值(Object val),字元的查詢是通過charAt方法得到一個擴充套件ASCII碼,這個碼值和結點陣列的索引是一一對應的,每個不同的字元對應著陣列中的唯一索引。next[c]指代的就是擴充套件ASCII碼為c的字元(比如小寫的a,ASCII碼為97,next[97]這個結點就指代了字元a)。

有了這些基礎,我們來試著實現單詞查詢樹。

package Chap5;

import java.util.LinkedList;
import java.util.Queue;

/**
 * R向單詞查詢樹
 *
 * @param <Value> 字串鍵對應的值
 */
public class TrieST<Value> {
    private static int R = 256;

    private Node root;
    private int N; // 記錄查詢樹的鍵的總數

    private static class Node {
        private Object val;
        private Node[] next = new Node[256];
    }

    public Value get(String key) {
        Node x = get(root, key, 0);
        if (x == null) {
            return null;
        }
        return (Value) x.val;
    }

    // 返回以字串key為首的子樹(如果key在符號表中,否則返回null)
    private Node get(Node node, String key, int d) {
        if (node == null) {
            return null;
        }
        // d記錄了單詞查詢樹的層數, 假設定義在根結點root時為0(樹的層數一般定義是根結點處是第一層)。
        // root沒有儲存字元,所以d = 1表示字串的第0個字元d = key.length表示字串最後一個字元key.length - 1
        if (d == key.length()) {
            return node;
        }
        char c = key.charAt(d);
        return get(node.next[c], key, d + 1);
    }

    public boolean contains(String key) {
        return get(key) != null;
    }

    public void put(String key, Value value) {
        root = put(root, key, value, 0);
    }

    private Node put(Node node, String key, Value value, int d) {
        // 為每個未檢查的字元新建一個結點
        if (node == null) {
            node = new Node();
        }
        // 並將值儲存在最後一個字元中
        if (d == key.length()) {
            // 為空說明插入新鍵,不為空說明是更新值
            if (node.val == null) {
                N++;
            }
            // 不管為不為空,都會設定值
            node.val = value;
            return node;
        }

        char c = key.charAt(d);
        node.next[c] = put(node.next[c], key, value, d + 1);
        return node;
    }

    public int size() {
        return N;
    }

    public boolean isEmpty() {
        return N == 0;
    }

    public Iterable<String> keys() {
        // 字首為空,說明任何字元都可以入列,該方法收集所有字串

        // !另外一種實現,一行就可以了
        // return keyWithPrefix("");
        Queue<String> queue = new LinkedList<>();
        collect(root, "", queue);
        return queue;
    }

    public Iterable<String> keyWithPrefix(String pre) {
        Queue<String> queue = new LinkedList<>();
        // 先找出給定字首的子樹,該子樹包含了所有以給定字首開頭的字串,從中收集並儲存在佇列中
        collect(get(root, pre, 0), pre, queue);
        return queue;
    }

    private void collect(Node node, String pre, Queue<String> queue) {
        if (node == null) {
            return;
        }
        // 如果值不為空,說明到達某字串的尾字元,應該儲存該字串
        if (node.val != null) {
            queue.offer(pre);
        }

        for (char c = 0; c < R; c++) {
            // 這裡pre + c是字元的拼接,c是一個字元不要當成了數字
            collect(node.next[c], pre + c, queue);
        }
    }

    public Iterable<String> keysThatMatch(String pat) {
        Queue<String> queue = new LinkedList<>();
        collect(root, "", pat, queue);
        return queue;
    }

    private void collect(Node node, String pre, String pat, Queue<String> queue) {
        int d = pre.length();
        if (node == null) {
            return;
        }
        // 和通配模式字串的長度要一致,且值不為空才會被加入佇列
        if (d == pat.length() && node.val != null) {
            queue.offer(pre);
        }
        // 檢查到通配模式字串的長度就行了
        if (d == pat.length()) {
            return;
        }

        char next = pat.charAt(d);
        for (char c = 0; c < R; c++) {
            // 是*就將結點陣列next中所有字元都遞迴收集,或者指定了字元,就按照指定的字元來遞迴收集
            if (next == '*' || next == c) {
                collect(node.next[c], pre + c, pat, queue);
            }
        }
    }

    // 返回給定字串在符號表中存在且擁有最長字首的字串
    public String longestPrefixOf(String s) {
        int length = search(root, s, 0, 0);
        return s.substring(0, length);
    }

    private int search(Node node, String s, int d, int length) {
        // 遇到空連結了,返回路徑上最近的一個鍵
        if (node == null) {
            return length;
        }
        // 不為空說明符號表中存在這個字串,是當前給定字串的最長字首,更新length
        if (node.val != null) {
            length = d;
        }
        // 到達給定字串末尾,返回最長字首的長度
        if (d == s.length()) {
            return length;
        }

        char c = s.charAt(d);
        return search(node.next[c], s, d + 1, length);
    }

    public void delete(String key) {
        root = delete(root, key, 0);
    }

    private Node delete(Node node, String key, int d) {
        if (node == null) {
            return null;
        }

        // 到達給定字串末尾,停止遞迴
        if (d == key.length()) {
            // 要刪除的鍵確實存在於符號表中才減小個數
            if (node.val != null) {
                node.val = null;
                N--;
            }
        } else {
            char c = key.charAt(d);
            // 沒有到字串末尾就遞迴刪除
            node.next[c] = delete(node.next[c], key, d + 1);
        }

        // 接下來檢查子樹,如果結點值不為空,不能刪除
        if (node.val != null) {
            return node;
        }
        // 如果結點值為空,但是該結點有連結不為空,不能刪除
        for (char c = 0; c < R; c++) {
            if (node.next[c] != null) {
                return node;
            }
        }

        // 不是以上兩種情況,說明結點的值為空,而且它的所有連結都為空,可以刪除
        return null;
    }

    public static void main(String[] args) {
        TrieST<Integer> trieST = new TrieST<>();
        trieST.put("she", 0);
        trieST.put("sells", 1);
        trieST.put("sea", 2);
        trieST.put("shells", 3);
        trieST.put("by", 4);
        trieST.put("the", 5);
        trieST.put("sea", 6);
        trieST.put("shore", 7);
        System.out.println(trieST.keys());
        System.out.println(trieST.get("sea"));
        System.out.println(trieST.get("she"));
        System.out.println(trieST.get("shells"));

        System.out.println(trieST.keyWithPrefix("she"));
        System.out.println(trieST.keysThatMatch("s**"));
        System.out.println(trieST.longestPrefixOf("shell"));
        System.out.println(trieST.longestPrefixOf("shells"));
        System.out.println(trieST.longestPrefixOf("shellsort"));

        trieST.delete("she");
        System.out.println(trieST.get("shells"));
        trieST.delete("shells");
        System.out.println(trieST.keys());

        System.out.println(trieST.size());
    }
}

先看get方法,私有的get方法返回以字串key開頭的子樹(如果key不在符號表中就返回null),運用遞迴的思想沿著路徑查詢每一個字元,d記錄了單詞查詢樹的層數,這裡假設在根結點root時為0(樹的層數一般定義是根結點處是第一層)。root沒有儲存字元,所以d = 1表示字串的第0個字元d = key.length表示字串最後一個字元key.length - 1,每次遞迴d都加1,說明檢查下一個字元,直到d等於key的長度時,所有字元都被檢查過了,返回尾字元的結點;然後公有的get方法先判斷返回的結點是否為空,不為空就返回其值(值可能是null也可能不是)。

put方法和get方法很像,不過在遇到空連結時需要新建一個結點,如果d == key.length說明到達字串的最後一個字元,要麼是插入新鍵,要麼是更新已有鍵的值,接著返回這個結點。和二叉查詢樹一樣,遞迴呼叫的返回值賦值給查詢路徑的上一個字元,用於修正插入新鍵後該結點的狀態。

查詢所有鍵

在看keys方法之前,先理解keysWithPrefix方法,該方法返回以給定字串為字首的所有字串。比如在[she, sells, sea, shells, by, the, shore]這些字串中,keysWithPrefix("sh")將返回she、shells、shore。首先用get方法找到以給定字首為首的子樹,然後針對這棵子樹,呼叫collect方法,遞迴檢查當前結點的所有連結,同時字首更新為原來的字首pre和當前字元c的拼接,作為遞迴呼叫中新的字首,這保證了每個遞迴呼叫的方法中字首pre都是從子樹根結點到該結點路徑上的所有字元,當前結點的值不為空時,說明到達字串的尾字元,將當前的字首(其實就是存在於符號表中且滿足給定字首的字串)加入佇列。

如下圖所示,get方法返回的是以為sh為首的以h為根結點的子樹,然後遞迴地在這棵子樹中收集存在於符號表中的字串。

2726327-7f7053c5a642fbb7.jpg
image

有了這個方法,查詢所有鍵的方法就手到擒來了。只需將字首指定為"",即空字串,就能收集到以root為根結點的樹(實際上就是整棵樹)中所有字串。

2726327-b4e22bf7dbc268b9.jpg
image

萬用字元匹配

*通配任意一個字元,比如在[she, sells, sea, shells, by, the, shore]這些字串中,s**將匹配she和sea。**e將匹配she和the。keysThatMatch方法的實現基本和keysWithPrefix一樣,只不過多新增了一個引數來指定匹配模式,不像keysWithPrefix一樣只要求字首一樣,長度沒有限制;keysThatMatch萬用字元只能匹配任意一個字元,所以匹配到的字串長度和模式字串的長度一致,在程式碼中可以看到當d == pat.length時,就停止遞迴了。

最長字首

longestPrefixOf返回給定字串在符號表中存在且擁有最長字首的字串。比如在[she, sells, sea, shells, by, the, shore]這些字串中,longestPrefixOf("shellsort")將返回shells。私有方法searchget方法很像,引數length在遞迴呼叫中記錄了當前最長字首的長度,每次遇到一個值不為空的結點就更新它的值,當遇到空連結或當到達字串的尾字元,返回路徑上最近的一個鍵就是最長字首。

下圖給出了幾種查詢最長字首的情況。

2726327-72cb5e9a533ef207.jpg
image

刪除操作

刪除比較複雜一些。如果要刪除的鍵存在於符號表中,刪除的第一步會將該字串的尾字元儲存的值置空。如果該結點所有連結中有不為空的,就不需要其他操作了;否則就是該結點的所有連結均為空,那麼需要從樹中刪除這個結點,如果刪除該結點後導致父結點的所有連結也全空了,繼續刪除。直到某個結點的值不為空,或者某個結點有不空的連結為止。

如下圖,刪除shells先將尾字元s儲存的值置空,然後發現結點s的所有連結都為空,應該刪除結點s,刪掉後父結點l的連結也全空,刪除掉...以此類推直到遇到結點e,因為它儲存的值不為空,所有不應該刪除,往上由於h有一條連結(即e)不為空,也不應該刪除,再往上結點s同理不應該刪除。

2726327-057c29a0a6f39605.jpg
image

假設我們正在單詞查詢樹中查詢一個鍵,還是[she, sells, sea, shells, by, the, shore]這些字串,給定字串是zoo那麼在檢查第一個字元時,就能判斷出該鍵不在符號表中。這種情況很常見:單詞查詢樹的未命中查詢只需要檢查很少的幾個結點。查詢未命中的成本與鍵的長度無關。查詢命中的話,要檢查所有字元,所以所需的時間和被查詢的鍵的長度成正比。

三向單詞查詢樹

上面的單詞查詢樹,每個結點都對應著一個擁有R個結點的結點陣列,空間消耗很大。為了避免過度的空間消耗,現在來看另一種資料結構——三向單詞查詢樹(TST)。在三向單詞查詢樹中,每個結點都含有一個字元、三條連結和一個值。這三條連結分別對應當前字元小於父結點、以父結點開頭、大於父結點字元的所有鍵。

上面學習的單詞查詢樹,字元是隱式地儲存在陣列中(上面有提到,利用ASCII碼對應唯一的陣列索引);而TST中的字元是顯式地儲存在結點中的,父結點的三個連結中只有一條連結和當前字元匹配,選擇匹配的連結並不斷沿著路徑向下(在程式碼中將看到只有選擇了中間連結才能檢查下一個字元,沿著路徑向下前進)...

下圖是一棵單詞查詢樹及其對應的三向單詞查詢樹。

2726327-1494082f27a9d050.PNG
image

根據上面的描述,實現如下

package Chap5;

import java.util.LinkedList;
import java.util.Queue;

/**
 * 3向單詞查詢樹
 *
 * @param <Value> 字串鍵對應的值
 */
public class TST<Value> {
    private Node root;
    private int N;

    private class Node {
        char c; // 顯式儲存的字元
        Node mid, left, right; // 左中右子樹
        Value val; // 和字串關聯的值
    }

    // 和R向單詞查詢樹的實現一樣
    public Value get(String key) {
        Node x = get(root, key, 0);
        if (x == null) {
            return null;
        }
        return x.val;
    }

    private Node get(Node node, String key, int d) {
        if (node == null) {
            return null;
        }

        char c = key.charAt(d);
        // 要查詢的字元比當前字元小,在左子樹中查詢
        if (c < node.c) {
            return get(node.left, key, d);
            // 要查詢的字元比當前字元大,在右子樹中查詢
        } else if (c > node.c) {
            return get(node.right, key, d);
            // c = node.c的前提下(要查詢的字元和當前字元相等)但還沒到尾字元,在中子樹中查詢下一個字元
        } else if (d < key.length() - 1) {
            return get(node.mid, key, d + 1);
        } else {
            return node;
        }
    }

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

    private Node put(Node node, String key, Value val, int d) {
        char c = key.charAt(d);
        if (node == null) {
            node = new Node();
            node.c = c;
        }
        // 要查詢的字元比當前字元小,在左子樹中查詢
        if (c < node.c) {
            node.left = put(node.left, key, val, d);
            // 要查詢的字元比當前字元大,在右子樹中查詢
        } else if (c > node.c) {
            node.right = put(node.right, key, val, d);
            // c = node.c的前提下(要查詢的字元和當前字元相等)但還沒到尾字元,在中子樹中查詢下一個字元
        } else if (d < key.length() - 1) {
            node.mid = put(node.mid, key, val, d + 1);
        } else {
            // 為空說明插入新鍵,不為空說明是更新值
            if (node.val == null) {
                N++;
            }
            // 不管為不為空,都會設定值
            node.val = val;
        }

        return node;
    }

    public boolean contains(String key) {
        return get(key) != null;
    }

    public int size() {
        return N;
    }

    public boolean isEmpty() {
        return N == 0;
    }

    public Iterable<String> keys() {
        Queue<String> queue = new LinkedList<>();
        collect(root, "", queue);
        return queue;
    }

    public Iterable<String> keyWithPrefix(String pre) {
        Queue<String> queue = new LinkedList<>();
        Node x = get(root, pre, 0);
        if (x == null) {
            return queue;
        }
        // 如果get返回的這個結點值不為空,加入佇列
        if (x.val != null) {
            queue.offer(pre);
        }
        // 對其中子樹遞迴
        collect(x.mid, pre, queue);
        return queue;
    }

    private void collect(Node node, String pre, Queue<String> queue) {
        if (node == null) {
            return;
        }

        collect(node.left, pre, queue);
        // 如果值不為空,說明到達某字串的尾字元,應該儲存該字串
        if (node.val != null) {
            // pre不包含當前字元node.c,所以要加上
            queue.offer(pre + node.c);
        }
        // 凡是對中子樹的處理,表示檢查下一個字元,所有加上當前字元node.c
        collect(node.mid, pre + node.c, queue);
        collect(node.right, pre, queue);
    }
    // 和R向單詞查詢樹的實現一樣
    public Iterable<String> keysThatMatch(String pat) {
        Queue<String> queue = new LinkedList<>();
        collect(root, "", pat, 0, queue);
        return queue;
    }

    private void collect(Node node, String pre, String pat, int d, Queue<String> queue) {
        if (node == null) {
            return;
        }
        char next = pat.charAt(d);
        // 左子樹收集
        if (next == '*' || next < node.c) {
            collect(node.left, pre, pat, d, queue);
        }
        // 中子樹收集
        if (next == '*' || next == node.c) {
            // 和通配模式字串的長度一致,且值不為空才會被加入佇列
            if (d == pat.length() - 1 && node.val != null) {
                queue.offer(pre + node.c);
                // 該條件保證了d == pat.length() - 1不會繼續收集
            } else if (d < pat.length() - 1) {
                collect(node.mid, pre + node.c, pat, d + 1, queue);
            }
        }
        // 右子樹收集
        if (next == '*' || next > node.c) {
            collect(node.right, pre, pat, d, queue);
        }

    }

    // 返回給定字串在符號表中存在且擁有最長字首的字串
    public String longestPrefixOf(String s) {
        int length = search(root, s, 0, 0);
        return s.substring(0, length);
    }

    private int search(Node node, String s, int d, int length) {
        // 遇到空連結了,返回路徑上最近的一個鍵
        if (node == null) {
            return length;
        }
        // 不為空說明符號表中存在這個字串,是當前給定字串的最長字首,更新length
        if (node.val != null) {
            // 和TriesST不同,這裡root存放了字元。所以d就是字元索引,和字串總長度相差1;如索引3,表示長度為4。
            length = d + 1;
        }
        // 到達給定字串末尾,返回最長字首的長度
        if (d == s.length()-1) {
            return length;
        }

        char c = s.charAt(d);
        if (c < node.c) {
            return search(node.left, s, d, length);
        } else if (c > node.c) {
            return search(node.right, s, d, length);
        } else {
            return search(node.mid, s, d + 1, length);
        }
    }

    public static void main(String[] args) {
        TST<Integer> tST = new TST<>();
        tST.put("she", 0);
        tST.put("sells", 1);
        tST.put("sea", 2);
        tST.put("shells", 3);
        tST.put("by", 4);
        tST.put("the", 5);
        tST.put("sea", 6);
        tST.put("shore", 7);
        System.out.println(tST.keys());
        System.out.println(tST.get("sea"));
        System.out.println(tST.get("she"));
        System.out.println(tST.get("shells"));

        System.out.println(tST.keyWithPrefix("she"));
        System.out.println(tST.keysThatMatch("s**"));

        System.out.println(tST.longestPrefixOf("shell"));
        System.out.println(tST.longestPrefixOf("shells"));
        System.out.println(tST.longestPrefixOf("shellsort"));

        System.out.println(tST.size());
    }
}

下圖是在三向單詞查詢樹中的get操作,s 為根結點,在根結點匹配,選擇中連結繼續處理下一個字元,h不匹配,選擇左連結或者右連結,但是當前字元不變。接著e匹配,選擇中連結l不匹配,選擇l的左連結,到達字串尾字元,返回其關聯的值14。

2726327-b9f6179bcb58daa2.PNG
image

特別注意上面加粗的兩句話,選擇中連結表示要繼續處理下一個字元了;選擇左右連結當前處理字元不改變。我們知道左連結是小於父結點的,右連結是大於父結點的,而中連結的字元是以父結點開頭的(不是等於!)。因為父結點只能從左中右三條連結中選一條匹配的,若中連結不匹配,需要從左右連結重新選一條,此時還沒找到匹配的所以當前處理字元不變。一旦字元匹配成功,該處理下一個字元了,就要沿著它的中連結向下。所以在各個方法都能看到,處理中連結的方式和處理左右連結不同(比如處理中連結時是d + 1pre + node.c),它的特殊性是因為中連結的字元是以其父結點開頭的。其實將所有左右連結畫平(類似於紅黑樹將紅色左連結畫平),能更清晰地理解這個資料結構,這樣選擇左右連結就不能隨著樹深入,只有沿著中連結才能深入到樹底。

理解了這些,相信看上面的程式碼會稍微清晰一點。

下表總結了各種字串查詢演算法的效能。

2726327-fa97d9329a7655ab.PNG
image

by @sunhaiyu

2017.11.30

相關文章