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

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

单词查找树由字符键中的所有字符构造而成,和各种查找树一样,单词查找树也是由结点链接所组成的数据结构。这些链接可能为空,也可能指向其他结点(或者说以此为根结点的其他子树)。每个结点都有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。私有方法searchget方法很像,参数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 + 1pre + node.c),它的特殊性是因为中链接的字符是以其父结点开头的。其实将所有左右链接画平(类似于红黑树将红色左链接画平),能更清晰地理解这个数据结构,这样选择左右链接就不能随着树深入,只有沿着中链接才能深入到树底。

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

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


by @sunhaiyu

2017.11.30

转载于:https://www.cnblogs.com/sun-haiyu/p/7929980.html

你可能感兴趣的:(数据结构与算法,java,shell)