美文网首页
数据结构与算法——单词查找树

数据结构与算法——单词查找树

作者: sunhaiyu | 来源:发表于2017-11-30 17:04 被阅读147次

    数据结构与算法——单词查找树

    单词查找树由字符键中的所有字符构造而成,和各种查找树一样,单词查找树也是由结点链接所组成的数据结构。这些链接可能为空,也可能指向其他结点(或者说以此为根结点的其他子树)。每个结点都有R条链接,R是字母表的大小,单词查找表通常含有大量的空链接,在绘制一棵单词查找树时一般会忽略所有的空链接。单词查找树的结点有两个域,一个是字符串键对应的值,字符串的最后一个字符所在的结点才有值,其他结点的值都为空;另一个域指向下一个结点,这里实际上是大小为R的结点数组,每个数组中保存着若干字符,其余位置都是空的。总的来说:值为空的结点在符号表中没有对应的键,它们的存在是为了简化单词查找树中的查找操作。

    一棵简单的单词查找树如下所示:

    image

    可以看到根结点没有存放任何字符,每个结点都包含下一个可能出现的所有字符的链接。从根结点开始首先经过了键的首字母所对应的链接,如此这般沿着结点不断前进,直到到达键的最后一个字符或是遇到一条空链接。这时可能出现以下三种情况:

    • 键的尾字符所对应的结点中的值非空,这是一次命中的查找——键所对应的值就是键的尾字符对应结点中保存的值。如下图中对shells和she的查找
    • 键的尾字符对应的结点中的值为空,这是次未命中的查找。如下图对shell的查找。
    • 查找结束于一条空链接,这是次未命中的查找。如下图对shore的查找。
    image

    所有查找(get)都是从根结点开始检查某条路径上的所有结点。

    单词查找树的插入和二叉查找树一样,在插入之前要先进行一次查找。有两种情况:

    • 在到达键的尾字符之前就遇到一条空链接。此时需要为键中还未被检查的每个字符创建一个对应的结点,并将值存放到最后一个字符的结点中;比如下图中饭shells和shore的插入就属于这种情况。
    • 在遇到空链接之前就已经到达键的尾字符。此时需要将该结点的值设置为键所对应的值。下图中第二次插入sea就属于这种情况。
    image

    如果把单词查找树忽略的空链接都画出来,就是下面这个样子。

    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为根结点的子树,然后递归地在这棵子树中收集存在于符号表中的字符串。

    image

    有了这个方法,查找所有键的方法就手到擒来了。只需将前缀指定为"",即空字符串,就能收集到以root为根结点的树(实际上就是整棵树)中所有字符串。

    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在递归调用中记录了当前最长前缀的长度,每次遇到一个值不为空的结点就更新它的值,当遇到空链接或当到达字符串的尾字符,返回路径上最近的一个键就是最长前缀。

    下图给出了几种查找最长前缀的情况。

    image

    删除操作

    删除比较复杂一些。如果要删除的键存在于符号表中,删除的第一步会将该字符串的尾字符保存的值置空。如果该结点所有链接中有不为空的,就不需要其他操作了;否则就是该结点的所有链接均为空,那么需要从树中删除这个结点,如果删除该结点后导致父结点的所有链接也全空了,继续删除。直到某个结点的值不为空,或者某个结点有不空的链接为止。

    如下图,删除shells先将尾字符s保存的值置空,然后发现结点s的所有链接都为空,应该删除结点s,删掉后父结点l的链接也全空,删除掉...以此类推直到遇到结点e,因为它保存的值不为空,所有不应该删除,往上由于h有一条链接(即e)不为空,也不应该删除,再往上结点s同理不应该删除。

    image

    假设我们正在单词查找树中查找一个键,还是[she, sells, sea, shells, by, the, shore]这些字符串,给定字符串是zoo那么在检查第一个字符时,就能判断出该键不在符号表中。这种情况很常见:单词查找树的未命中查找只需要检查很少的几个结点。查找未命中的成本与键的长度无关。查找命中的话,要检查所有字符,所以所需的时间和被查找的键的长度成正比。

    三向单词查找树

    上面的单词查找树,每个结点都对应着一个拥有R个结点的结点数组,空间消耗很大。为了避免过度的空间消耗,现在来看另一种数据结构——三向单词查找树(TST)。在三向单词查找树中,每个结点都含有一个字符、三条链接和一个值。这三条链接分别对应当前字符小于父结点、以父结点开头、大于父结点字符的所有键。

    上面学习的单词查找树,字符是隐式地保存在数组中(上面有提到,利用ASCII码对应唯一的数组索引);而TST中的字符是显式地保存在结点中的,父结点的三个链接中只有一条链接和当前字符匹配,选择匹配的链接并不断沿着路径向下(在代码中将看到只有选择了中间链接才能检查下一个字符,沿着路径向下前进)...

    下图是一棵单词查找树及其对应的三向单词查找树。

    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。

    image

    特别注意上面加粗的两句话,选择中链接表示要继续处理下一个字符了;选择左右链接当前处理字符不改变。我们知道左链接是小于父结点的,右链接是大于父结点的,而中链接的字符是以父结点开头的(不是等于!)。因为父结点只能从左中右三条链接中选一条匹配的,若中链接不匹配,需要从左右链接重新选一条,此时还没找到匹配的所以当前处理字符不变。一旦字符匹配成功,该处理下一个字符了,就要沿着它的中链接向下。所以在各个方法都能看到,处理中链接的方式和处理左右链接不同(比如处理中链接时是d + 1pre + node.c),它的特殊性是因为中链接的字符是以其父结点开头的。其实将所有左右链接画平(类似于红黑树将红色左链接画平),能更清晰地理解这个数据结构,这样选择左右链接就不能随着树深入,只有沿着中链接才能深入到树底。

    理解了这些,相信看上面的代码会稍微清晰一点。

    下表总结了各种字符串查找算法的性能。

    image

    by @sunhaiyu

    2017.11.30

    相关文章

      网友评论

          本文标题:数据结构与算法——单词查找树

          本文链接:https://www.haomeiwen.com/subject/zhzvbxtx.html