資料結構與演算法——單詞查詢樹
資料結構與演算法——單詞查詢樹
單詞查詢樹由字元鍵中的所有字元構造而成,和各種查詢樹一樣,單詞查詢樹也是由結點連結所組成的資料結構。這些連結可能為空,也可能指向其他結點(或者說以此為根結點的其他子樹)。每個結點都有R條連結,R是字母表的大小,單詞查詢表通常含有大量的空連結,在繪製一棵單詞查詢樹時一般會忽略所有的空連結。單詞查詢樹的結點有兩個域,一個是字串鍵對應的值,字串的最後一個字元所在的結點才有值,其他結點的值都為空;另一個域指向下一個結點,這裡實際上是大小為R的結點陣列,每個陣列中儲存著若干字元,其餘位置都是空的。總的來說:值為空的結點在符號表中沒有對應的鍵,它們的存在是為了簡化單詞查詢樹中的查詢操作。
一棵簡單的單詞查詢樹如下所示:
可以看到根結點沒有存放任何字元,每個結點都包含下一個可能出現的所有字元的連結。從根結點開始首先經過了鍵的首字母所對應的連結,如此這般沿著結點不斷前進,直到到達鍵的最後一個字元或是遇到一條空連結。這時可能出現以下三種情況:
- 鍵的尾字元所對應的結點中的值非空,這是一次命中的查詢——鍵所對應的值就是鍵的尾字元對應結點中儲存的值。如下圖中對shells和she的查詢
- 鍵的尾字元對應的結點中的值為空,這是次未命中的查詢。如下圖對shell的查詢。
- 查詢結束於一條空連結,這是次未命中的查詢。如下圖對shore的查詢。
所有查詢(get)都是從根結點開始檢查某條路徑上的所有結點。
單詞查詢樹的插入和二叉查詢樹一樣,在插入之前要先進行一次查詢。有兩種情況:
- 在到達鍵的尾字元之前就遇到一條空連結。此時需要為鍵中還未被檢查的每個字元建立一個對應的結點,並將值存放到最後一個字元的結點中;比如下圖中飯shells和shore的插入就屬於這種情況。
- 在遇到空連結之前就已經到達鍵的尾字元。此時需要將該結點的值設定為鍵所對應的值。下圖中第二次插入sea就屬於這種情況。
如果把單詞查詢樹忽略的空連結都畫出來,就是下面這個樣子。
可以看到除了根結點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為根結點的子樹,然後遞迴地在這棵子樹中收集存在於符號表中的字串。
有了這個方法,查詢所有鍵的方法就手到擒來了。只需將字首指定為""
,即空字串,就能收集到以root為根結點的樹(實際上就是整棵樹)中所有字串。
萬用字元匹配
用*
通配任意一個字元,比如在[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。私有方法search
和get
方法很像,引數length在遞迴呼叫中記錄了當前最長字首的長度,每次遇到一個值不為空的結點就更新它的值,當遇到空連結或當到達字串的尾字元,返回路徑上最近的一個鍵就是最長字首。
下圖給出了幾種查詢最長字首的情況。
刪除操作
刪除比較複雜一些。如果要刪除的鍵存在於符號表中,刪除的第一步會將該字串的尾字元儲存的值置空。如果該結點所有連結中有不為空的,就不需要其他操作了;否則就是該結點的所有連結均為空,那麼需要從樹中刪除這個結點,如果刪除該結點後導致父結點的所有連結也全空了,繼續刪除。直到某個結點的值不為空,或者某個結點有不空的連結為止。
如下圖,刪除shells
先將尾字元s儲存的值置空,然後發現結點s的所有連結都為空,應該刪除結點s,刪掉後父結點l的連結也全空,刪除掉...以此類推直到遇到結點e,因為它儲存的值不為空,所有不應該刪除,往上由於h有一條連結(即e)不為空,也不應該刪除,再往上結點s同理不應該刪除。
假設我們正在單詞查詢樹中查詢一個鍵,還是[she, sells, sea, shells, by, the, shore]
這些字串,給定字串是zoo
那麼在檢查第一個字元時,就能判斷出該鍵不在符號表中。這種情況很常見:單詞查詢樹的未命中查詢只需要檢查很少的幾個結點。查詢未命中的成本與鍵的長度無關。查詢命中的話,要檢查所有字元,所以所需的時間和被查詢的鍵的長度成正比。
三向單詞查詢樹
上面的單詞查詢樹,每個結點都對應著一個擁有R個結點的結點陣列,空間消耗很大。為了避免過度的空間消耗,現在來看另一種資料結構——三向單詞查詢樹(TST)。在三向單詞查詢樹中,每個結點都含有一個字元、三條連結和一個值。這三條連結分別對應當前字元小於父結點、以父結點開頭、大於父結點字元的所有鍵。
上面學習的單詞查詢樹,字元是隱式地儲存在陣列中(上面有提到,利用ASCII碼對應唯一的陣列索引);而TST中的字元是顯式地儲存在結點中的,父結點的三個連結中只有一條連結和當前字元匹配,選擇匹配的連結並不斷沿著路徑向下(在程式碼中將看到只有選擇了中間連結才能檢查下一個字元,沿著路徑向下前進)...
下圖是一棵單詞查詢樹及其對應的三向單詞查詢樹。
根據上面的描述,實現如下
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。
特別注意上面加粗的兩句話,選擇中連結表示要繼續處理下一個字元了;選擇左右連結當前處理字元不改變。我們知道左連結是小於父結點的,右連結是大於父結點的,而中連結的字元是以父結點開頭的(不是等於!)。因為父結點只能從左中右三條連結中選一條匹配的,若中連結不匹配,需要從左右連結重新選一條,此時還沒找到匹配的所以當前處理字元不變。一旦字元匹配成功,該處理下一個字元了,就要沿著它的中連結向下。所以在各個方法都能看到,處理中連結的方式和處理左右連結不同(比如處理中連結時是d + 1
、pre + node.c
),它的特殊性是因為中連結的字元是以其父結點開頭的。其實將所有左右連結畫平(類似於紅黑樹將紅色左連結畫平),能更清晰地理解這個資料結構,這樣選擇左右連結就不能隨著樹深入,只有沿著中連結才能深入到樹底。
理解了這些,相信看上面的程式碼會稍微清晰一點。
下表總結了各種字串查詢演算法的效能。
by @sunhaiyu
2017.11.30
相關文章
- 資料結構與演算法-二叉查詢樹資料結構演算法
- 『資料結構與演算法』二叉查詢樹(BST)資料結構演算法
- 資料結構與演算法知識點總結(5)查詢樹資料結構演算法
- 資料結構與演算法-二叉查詢樹平衡(DSW)資料結構演算法
- 資料結構與演算法-二叉查詢樹平衡(AVL)資料結構演算法
- 【資料結構與演算法】手撕二叉查詢樹資料結構演算法
- 資料結構與演算法:查詢演算法資料結構演算法
- Java資料結構(十五)—— 多路查詢樹Java資料結構
- 淺談演算法和資料結構(10):平衡查詢樹之B樹演算法資料結構
- 資料結構-單連結串列查詢按序號查詢資料結構
- 資料結構與演算法-二分查詢資料結構演算法
- 【資料結構與演算法】—— 二分查詢資料結構演算法
- 樹形結構的選單表設計與查詢
- 淺談演算法和資料結構(9):平衡查詢樹之紅黑樹演算法資料結構
- 資料結構與演算法——二叉查詢樹類的C++實現資料結構演算法C++
- 資料結構與演算法——二分查詢演算法資料結構演算法
- 資料結構與演算法 第五章 查詢資料結構演算法
- 資料結構與演算法:AVL樹資料結構演算法
- 資料結構之查詢(順序、折半、分塊查詢,B樹、B+樹)資料結構
- 資料結構與演算法整理總結---二分查詢資料結構演算法
- 淺談演算法和資料結構(7):二叉查詢樹演算法資料結構
- 淺談演算法和資料結構(8):平衡查詢樹之2-3樹演算法資料結構
- 樹狀資料結構儲存方式——查詢篇資料結構
- JS中的演算法與資料結構——二叉查詢樹(Binary Sort Tree)JS演算法資料結構
- 演算法與資料結構——二分查詢插入點演算法資料結構
- 樹形結構的儲存與查詢
- 資料結構與演算法——查詢演算法-斐波那契(黃金分割法)查詢資料結構演算法
- 二叉樹 & 二叉查詢樹 ADT【資料結構與演算法分析 c 語言描述】二叉樹資料結構演算法
- 二叉樹 & 二叉查詢樹 ADT [資料結構與演算法分析 c 語言描述]二叉樹資料結構演算法
- 資料結構與演算法知識點總結(3)樹、圖與並查集資料結構演算法並查集
- 【資料結構與演算法】二叉樹資料結構演算法二叉樹
- 05 Javascript資料結構與演算法 之 樹JavaScript資料結構演算法
- 資料結構與演算法:哈夫曼樹資料結構演算法
- 資料結構與演算法——AVL樹簡介資料結構演算法
- 資料結構與演算法——RB樹簡介資料結構演算法
- 資料結構:二叉查詢樹的相關操作資料結構
- 【演算法資料結構Java實現】折半查詢演算法資料結構Java
- 演算法+資料結構=程式,今天就來說說遞迴+排序+查詢,再加上樹與圖演算法資料結構遞迴排序