数据结构与算法——单词查找树
单词查找树由字符键中的所有字符构造而成,和各种查找树一样,单词查找树也是由结点链接所组成的数据结构。这些链接可能为空,也可能指向其他结点(或者说以此为根结点的其他子树)。每个结点都有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 字符串键对应的值
*/
public class TrieST {
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 keys() {
// 前缀为空,说明任何字符都可以入列,该方法收集所有字符串
// !另外一种实现,一行就可以了
// return keyWithPrefix("");
Queue queue = new LinkedList<>();
collect(root, "", queue);
return queue;
}
public Iterable keyWithPrefix(String pre) {
Queue queue = new LinkedList<>();
// 先找出给定前缀的子树,该子树包含了所有以给定前缀开头的字符串,从中收集并保存在队列中
collect(get(root, pre, 0), pre, queue);
return queue;
}
private void collect(Node node, String pre, Queue 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 keysThatMatch(String pat) {
Queue queue = new LinkedList<>();
collect(root, "", pat, queue);
return queue;
}
private void collect(Node node, String pre, String pat, Queue 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 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 字符串键对应的值
*/
public class TST {
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 keys() {
Queue queue = new LinkedList<>();
collect(root, "", queue);
return queue;
}
public Iterable keyWithPrefix(String pre) {
Queue 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 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 keysThatMatch(String pat) {
Queue queue = new LinkedList<>();
collect(root, "", pat, 0, queue);
return queue;
}
private void collect(Node node, String pre, String pat, int d, Queue 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 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