《算法》笔记 14 - 单词查找树
- R向单词查找树
- 数据结构
- 查找
- 插入
- 查找所有键
- 通配符匹配
- 最长前缀
- 删除
- R向单词查找树的性质
- 三向单词查找树
- 三向单词查找树的性质
同字符串的排序一样,利用字符串的性质开发的查找算法也比通用的算法更有效,这些算法可以用于在以字符串作为被查找键的场合。这类算法在面对巨量的数据时,仍然可以取得这样的性能:查找命中所需的时间与被查找的键的长度成正比;而查找未命中时只需检查若干个字符。这样的性能是相当惊人的,也是算法研究的最高成就之一,这些算法成了建成现在能够便捷、快速地访问海量信息所依赖的基础设施的重要因素。
R向单词查找树
数据结构
单词查找树(Trie)是用于字符串键查找的数据结构。与之前的查找树类似,它也是由链接的结点所组成的数据结构,这些链接可能为空,也可能指向其他结点。
结点的数据结构为:
private static class Node {
private Object val;
private Node[] next = new Node[R];
}
每个节点都只有一个或0个指向它的结点(父结点),只有根结点不会有父结点。每个节点都含有R条链接,R为字母表的大小,如果字符都由26个小写英文字母构成,则R为26;如果字符属于ASCII字符集,则R=128;DNA研究中用4个字母表示4个碱基,R=4。
R条链接对应可能出现的字符,这其中会有大量的空链接,键是由从根节点到含有非空值的结点的路径所隐式表示的。每个结点也含有一个相应的值,可以是空也可以是符号表中的某个键所关联的值。值为空的结点在符号表中没有对应的键,它们的存在是为了简化单词查找树中的查找操作,每个键所关联的值保存在给定键的最后一个字母所对应的结点中。
也将基于含有R个字符的字母表的单词查找树称为R向单词查找树。
查找
在单词查找树中查找给定字符串键所对应的值时,是以被查找的键中的字符为导向的。单词查找树中的每个结点都包含了下一个可能出现的所有字符的链接。从根结点开始,首先经过的是键的首字母所对应的链接,在下一个结点沿着第二个字符所对应的链接继续前进,以此类推,直到找到键的最后一个字母所指向的结点,或者遇到了一条空链接。
- 如果键的尾字符所对应的结点中的值非空,则查找命中,键所对应的值就是键的尾字符中所存储的值;
- 如果键的尾字符所对应的结点中的值为空,或者查找的过程中遇到了空链接,则查找未命中。
public Value get(String key) {
Node x = get(root, key, 0);
if (x == null)
return null;
return (Value) x.val;
}
private Node get(Node x, String key, int d) {
if (x == null)
return null;
if (d == key.length())
return x;
char c = key.charAt(d);
return get(x.next[c], key, d + 1);
}
插入
插入的时候,也需要先进行一次查找
- 如果在到达键的尾字符之前就遇到了一个空链接,这种情况下单词查找树中不存在与键的尾字符对应的结点,因此需要为键中还未被检查的每个字符创建一个对应的结点,并将键的值保存到最后一个字符的结点中;
- 如果在遇到空链接之前就到达了键的尾字符,则将该结点的值设为键所对应的值。
public void put(String key, Value val) {
root = put(root, key, val, 0);
}
public Node put(Node x, String key, Value val, int d) {
if (x == null)
x = new Node();
if (d == key.length()) {
x.val = val;
return x;
}
char c = key.charAt(d);
x.next[c] = put(x.next[c], key, val, d + 1);
return x;
}
查找所有键
单词查找树中的字符是被隐式地表示的,查找的时候需要显式地将它们表示出来,并加入到队列中。查找基于一个叫做collect的方法,它的参数中包含了一个字符串,用来保存从根结点出发的路径上的一系列字符。每当在collect()调用中访问一个结点时,方法的第一个参数就是这个结点,第二个参数是从根节点到这个结点的路径上的所有字符。如果结点的值非空,就将和它相关联的字符串加入队列中,然后递归地访问它的链接数组所指向的所有可能的字符结点。在每次调用collect之前,都将链接对应的字符附加到当前键的末尾作为参数。要实现keys()方法,可以用空字符作为参数调用keysWithPrefix()方法。要实现keysWithPrefix(),则可以先调用get()方法找出给定前缀所对应的单词查找子树,再使用collect()。
public Iterable<String> keys() {
return keysWithPrefix("");
}
public Iterable<String> keysWithPrefix(String pre) {
Queue<String> q = new Queue<String>();
collect(get(root, pre, 0), pre, q);
return q;
}
private void collect(Node x, String pre, Queue<String> q) {
if (x == null)
return;
if (x.val != null)
q.enqueue(pre);
for (char c = 0; c < R; c++) {
collect(x.next[c], pre + c, q);
}
}
通配符匹配
通配符匹配的过程类似keysWithPrefix,但需要为collect添加一个用于指定匹配模式的参数。模式中用'.'来表示通配符,如果模式中含有通配符,就需要用递归调用处理所有的链接,否则就只需要处理模式中指定字符的链接即可。
public Iterable<String> keysThatMatch(String pat) {
Queue<String> q = new Queue<String>();
collect(root, "", pat, q);
return q;
}
private void collect(Node x, String pre, String pat, Queue<String> q) {
int d = pre.length();
if (x == null)
return;
if (d == pat.length() && x.val != null)
q.enqueue(pre);
if (d == pat.length())
return;
char next = pat.charAt(d);
for (char c = 0; c < R; c++) {
if (next == '.' || next == c)
collect(x.next[c], pre + c, pat, q);
}
}
最长前缀
longestPrefixOf方法会找出与给定字符串匹配的最长前缀。比如对于键by,she, shells,longestPrefixOf("shell")的结果为she。要找到最长前缀,需要一个类似于get的递归方法来记录查找路径上所找到的最长键的长度,并在遇到值非空的结点时更新它,然后在被查找的字符串结束或者遇到空链接时终止查找。
public String longestPrefixOf(String s) {
int length = search(root, s, 0, 0);
return s.substring(0, length);
}
public int search(Node x, String s, int d, int length) {
if (x == null)
return length;
if (x.val != null)
length = d;
if (d == s.length())
return length;
char c = s.charAt(d);
return search(x.next[c], s, d + 1, length);
}
删除
要从单词查找树中删除一个键值对,首先需要找到键所对应的结点并将它的值设为空。然后分两种情况:
- 如果这个结点还含有一个指向某个子结点的非空链接,就不需要在进行别的操作;
- 如果这个结点的所有链接都为空,那马就需要从树中删除这个结点;如果删除这个结点后,使得它的父结点的所有链接也都成了空,就要继续删除它的父结点,依次类推。
public void delete(String key) {
root = delete(root, key, 0);
}
private Node delete(Node x, String key, int d) {
if (x == null)
return null;
if (d == key.length())
x.val = null;
else {
char c = key.charAt(d);
x.next[c] = delete(x.next[c], key, d + 1);
}
if (x.val != null)
return x;
for (char c = 0; c < R; c++)
if (x.next[c] != null)
return x;
return null;
}
R向单词查找树的性质
单词查找树的链接结构和键的插入或删除顺序无关,对于任意给定的一组键,它们的单词查找树都是唯一的,这与之前所有的其它查找树都不相同。
在单词查找树中查找或插入一个键时,访问数组的次数最多为键的长度加1,因为get()和put()都使用了一个指示字符位置的参数d,它的初始值为0,每次递归都会加1,当长度等于键的长度时递归停止,此时访问了数组d+1,如果查找未命中,访问次数会更少。这说明在单词查找树中查找一个键所需的时间与树的大小无关,只与键的长度有关。
关于单词查找树占用的空间,与树中的链接总数有关。设w为键的平均长度,R为字符集的大小,N为键的总数,则一颗单词查找树中的链接总数在RN到RNw之间。
- 如果每个键的首字母都不相同,那么每个键中的每个字母都有一个结点,一个键含有Rw个链接,共N个键,含有RNw个链接;
- 如果键的首字母都相同,且都处于一个分支,那么树将只含有这一条分支,共RN个链接。
有一些经验性的规律:当所有键都较短时,链接的总数接近于RN;而当所有键都较长时,链接的总数接近于RNw,所以缩小R或w能够节省大量的空间。而且在实际应用中,使用单词查找树之前,首先了解将要插入的所有键的性质是非常重要的。
三向单词查找树
R向单词查找树虽然检索速度很快,但空间占用也非常大,尤其是对于比较大的字符集和比较长的键,这将消耗非常大的空间。三向单词查找树可避免这个问题。
在三向单词查找树中,每个节点都含有一个字符,三条链接和一个值。这三条链接分别对应着当前字母小于、等于、大于节点字母的所有键。只有沿着中间链接前进时才会找到待查找的键。
在三向单词查找树中查找键时,首先将键的首字母和根结点进行比较,如果首字母较小,就选择左链接,如果首字母较大,就选择右链接,首字母与根节点字符相等,就选择中链接,然后递归查找,直到遇到一个空链接或者当键结束时结点的值为空,则查找未命中;如果在键结束时结点的值非空,则查找命中。
public class TST<Value> {
private Node root;
private class Node {
char c;
Node left, mid, right;
Value val;
}
public Value get(String key) {
Node node = get(root, key, 0);
if (node == null)
return null;
return node.val;
}
private Node get(Node x, String key, int d) {
if (x == null)
return null;
char c = key.charAt(d);
if (c < x.c)
return get(x.left, key, d);
else if (c > x.c)
return get(x.right, key, d);
else if (d < key.length() - 1)
return get(x.mid, key, d);
else
return x;
}
public void put(String key, Value val) {
root = put(root, key, val, 0);
}
private Node put(Node x, String key, Value val, int d) {
char c = key.charAt(d);
if (c < x.c)
x.left = put(x.left, key, val, d);
else if (c > x.c)
x.right = put(x.right, key, val, d);
else if (d < key.length() - 1)
x.mid = put(x.mid, key, val, d);
else
return x.val = val;
return x;
}
}
三向单词查找树的性质
三向单词查找树可以看作是R向单词查找树的紧凑表示,但三向单词查找树的形状是与键的插入顺序有关的,而且空间占用要比R向单词查找树小很多。
三向单词查找树的每个结点只含有3个链接,树的链接总数在3N到3Nw之间。