Coursera - Algorithm (Princeton) - 课程笔记 - Week 10

Week 10

前缀树 Tries

R路前缀树 R-way Tries

  • 一种用于字符串键进行查找的数据结构
  • 对于已经了解的标记表数据结构,保证有序的最快数据结构是红黑数(对数级),不保证有序的最快数据结构是哈系表(常数级)
  • 进一步的思路:只检查键的一部分内容
  • 专门用于字符串键的标记表API:
public class StringST<Value>
{
    StringST(); // create an empty symbol table
    void put(String key, Value val); // put key-value pair into the symbol table
    Value get(String key); //  return value paired with given key
    void delete(String key); // delete key and corresponding value
}
  • 基本目标:比哈希表更快,比BST更加灵活

  • 前缀树:

    • 节点中保存字符而非键
    • 每一个节点有R个字节点,及对每一个字符表中允许的字符都有一个子节点(表示的时候不画出空链接)
    • 将值存在对应键的最后一个字符的节点上
    • 根节点是空的(表示任意情况,从空开始延伸字符串)
  • 在前缀树上搜索:

    • 按照键中的字符顺序遍历链接
    • 找到:结束字符对应节点有非空值
    • 未找到:到达一个空链接(没有符合要求的后缀)或者值为空
  • 在前缀树上插入:

    • 按照键中的字符顺序遍历链接
    • 遇到空链接:插入一个新的节点
    • 遇到结束字符:对该节点赋值
  • java表示:

    • 节点:包含一个值,和一个对R个节点的引用数组(用数组项的空与否表示对应字符的节点链接是否存在),不显式存储字符和键
    private static class Node
    {
        private Object value; // 这里使用Object是因为java没有泛型数组
        private Node[] next = new Node[R];
    }
    
    • 整体java实现:
    public class TrieST<Value>
    {
        private static final int R = 256;
        private Node root = new Node();
        
        private static class Node
        {
        	private Object value;
        	private Node[] next = new Node[R];
        }
        
        public void put(String key, Value val)
        { root = put(root, key, val, 0); }
        
        private 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; // 返回当前考察的父节点(最上层就是根节点了)
        }
        
        public boolean contains(String key)
        { return get(key) != null; }
        
        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); // 继续沿着键的字符路径查找
        }
    }
    
  • 性能分析

    • 查找找到:需要检查键的全部L个字符
    • 查找未找到:只检查前面几个字符(次线性时间)
    • 空间占用:每一个字节点都有R个空链接,如果大量键存在公共前缀,那么整个树的占用将是次线性的
    • 最坏性能:快速的找到以及更快速的未找到,但是浪费空间 ( R + 1 ) N (R+1)N (R+1)N
  • 前缀树上的删除

    • 找到对应的节点(保存值的那个),将值置空
    • 如果这个节点同时没有任何有效链接,将其直接删除(这个过程向上递归)
  • 对于较小的R,R路前缀树是一个很好的选择

  • 但是对一个较大的R,可能会占用巨大的空间

三分搜索前缀树 Ternary Search Tries

  • 用于应对R路前缀树的空间占用问题

  • 组成

    • 每一个保存字符和值而非键
    • 每一个节点有3个子节点:左子节点(小),中子节点(中,等),右子节点(大)
  • 对三分前缀树,中间节点对应的子树的键由中间节点为首字母,而两侧则以两侧节点为首字母

  • 在TST上寻找:

    • 沿着字符顺序遍历链接:
      • 如果小,则转向左链接;如果大,则转向右链接
      • 如果相等,中间节点为考,沿中间链接遍历下一个字符
    • 如果搜索结束在一个非空值节点,则找到
    • 如果结束在一个空连接,或者空值节点,则未找到
  • 插入操作同理,要么插入值(找到既有节点),要么补充新节点(遇到新链接)

  • java实现:

    • 值,字符,左中右三个子节点的链接
    private class Node
    {
        private Value val;
        private char c;
        private Node left, mid, right;
    }
    
    • 整体实现
    public class TST<Value>
    {
        private Node root;
        
        private class Node
        {
            private Value val;
            private char c;
            private Node left, mid, right;
        }
        
        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 (x == null) { x = new Node(); x.c = c; }
            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+1);
            else x.val = val;
            return x;
        }
        
        public boolean contains(String key)
        { return get(key) != null; }
        
        public Value get(String key)
        {
            Node x = get(root, key, 0);
            if (x == null) return null;
            return x.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+1);
            else return x;
        }
    }
    
  • 性能分析(一般情况)

    • 找到 L + ln ⁡ N L + \ln N L+lnN
    • 未找到 ln ⁡ N \ln N lnN
    • 插入 L + ln ⁡ N L + \ln N L+lnN
    • 空间占用 4 N 4N 4N(比上一种前缀树小得多)
    • 可以通过平衡旋转,将复杂度降到 L + log ⁡ N L + \log N L+logN
  • 对于字符串键,TST做到和哈希表一样快但是更加灵活

  • 于R路树的混合结构

    • 根节点引出 R 2 R^2 R2路子节点(前两个字符的所有混合情况)
    • 每一个子节点指向一个TST
    • 这种结果虽然占用比TST更多了一些,但更快速
    • 需要额外考虑一个字符和两个字符的单词
  • 相比于哈希表的一个优势:支持有序操作

基于字符的操作 Character-Based Operations

  • 以下为字符串标记表支持的一些操作

  • 前缀匹配:前缀相同的匹配键

  • 通配符匹配:指定内容相同,其他部分任意的匹配键

  • 最长前缀:拥有与指定字符串有公共前缀的键

  • 此外还能够支持其他有序标记表的方法

  • 有序遍历于R路前缀树:

    • 中序遍历整个前缀树,将遇到的键(有值节点为末尾)加入队列
    • 维护一个字符序列,从根到当前考察节点
    public Iterable<String> keys()
    {
        Queue<String> queue = new Queue<String>();
        collect(root, "", queue);
        return queue;
    }
    
    private void collect(Node x, String prefix, Queue<String> q)
    {
        if (x == null) return;
        if (x.val != null) q.enqueue(prefix);
        for (char c = 0; c < R; c++)
    		collect(x.next[c], prefix + c, q);
    }
    
  • 前缀匹配于R路前缀树:

    • 按前缀在树上遍历,在其子树上搜集所有的键
    public Iterable<String> keysWithPrefix(String prefix)
    {
        Queue<String> queue = new Queue<String>();
        Node x = get(root, prefix, 0); // 找到子树
        collect(x, prefix, queue);
        return queue;
    }
    
  • 最长前缀:

    • 在前缀树上寻找查询的字符串
    • 寻找过程中,保留遇到的最长键
    public String longestPrefixOf(String query)
    {
        int length = search(root, query, 0, 0);
        return query.substring(0, length);
    }
    
    private int search(Node x, String query, int d, int length)
    {
        if (x == null) return length;
        if (x.val != null) length = d;
        if (d == query.length()) return length;
        char c = query.charAt(d);
        return search(x.next[c], query, d+1, length);
    }
    
  • 一种复杂但快速(解决单分支)的树:Patrcia前缀树,取出单分支情况,一个节点代表一个字符序列

  • 后缀树:

    • 关于一个字符串后缀的Patricia前缀树
    • 构造时间可以在线性时间级别

子串搜索 Substring Search

子串搜索 Introduction

  • 目标:在一个N长度的字符串中找到一个M长度的模式(一般情况下 N ≫ M N \gg M NM,解决问题是,会认为N是无穷大的)
  • 字串搜索有很多的应用场景
    • 关键字搜索
    • 用户签名取证
    • 垃圾邮件识别
    • 屏幕截取
    • ……
  • Java库中的indexOf()方法就是一种屏幕截取的实现,给出模式第一次出现的索引,可以设置搜索起始偏移量

暴力字串搜索 Brute-Force Substring Search

  • 最简单直观的一种字符串搜索方法
  • 方法:从每一个文本位置开始,检查模式的匹配情况(当前模式出现不匹配,即开始下一个文本位置的匹配)
  • Java实现:
public static int search(String pat, String txt)
{
    int M = pat.length();
    int N = txt.length();
    
    for (int i = 0; i <= N - M; i++)
    {
        int j;
        
        for (j = 0; j < M; j++)
        	if (txt.charAt(i+j) != pat.charAt(j))
        		break;
        
        if (j == M) return i; // all matched!
    }
    return N; // not found
}
  • 如果文本和模型都是重复型(大量重复内容),暴力搜索方法会非常慢(最坏 ∼ M N \sim MN MN次字符比较)
  • 另外一种实现,i和j不再是当前位,而分别代表原文中已匹配的字符串结尾位置,和模式的已匹配结尾,显式地表现考察字符的回退(backup)过程
public static int search(String pat, String txt)
{
    int i, N = txt.length();
    int j, M = pat.length();
    for (i = 0, j = 0; i < N && j < M; i++)
    {
        if (txt.charAt(i) == pat.charAt(j)) j++;
        else { i -= j; j = 0; }
    }
    if (j == M) return i - M;
    else return N;
}
  • 一些应用场景需要避免备份回退,因此暴力算法并不能胜任

KMP算法 Knuth-Morris-Pratt

  • 暴力算法的一个劣势:在出现错配时,我们在已知已经匹配的内容时,实际上可以避免完全退回到第二个字符

  • 实现表示:DFA

    • 状态即模式的各个字符
    • 编号即已经配的模式的字符个数(从0开始计算,开始状态为0),同时表示(正在匹配的)模式的前缀长度和读到text[i]的后缀长度
    • 状态的转换是文本中的字符
    • 转换到的状态即下一步需要比较的模式的开始部分
  • Java实现:

    • 需要提前计算dfa[c][j],行表示字母表中各字符,列表示模式的状态转换(从左至右为模式的字符顺序)
    • 测试文本指针i永远都不会减小
    • 由于不需要回退,可以直接使用输入流进行匹配
    public int search(String txt)
    {
        int i, j, N = txt.length();
        for (i = 0, j = 0; i < N && j < M; i++)
        	j = dfa[txt.charAt(i)][j];
        // for (i = 0, j = 0; !in.isEmpty() && j < M; i++)
       		// j = dfa[in.readChar()][j];
        if (j == M) return i - M;
        else return N;
    }
    
  • 实现的DFA可以保证最多N次比较

  • 问题:如何实现DFA?

  • DFA构建:

    • 匹配状态转换:如果处于状态j且下一个文本(待匹配)字符c恰为模式的第j个(下一个待匹配)字符c == pat.charAt(j),转向状态j+1

    • 错配状态转换:如果下一个文本字符c不是模式的第j个字符,回退(这里有点复杂)

      • 可以明确的是,当前正在匹配的文本内容是前j-1个模式字符加上这个c
      • 计算dfa[c][j]:在DFA(尚未完工)上模拟pat[1...j-1],取在终点状态上完成对c的转换结果
      • 这种方式貌似很费时间,实际上并没有,如果我们能维护上面的状态(在构造时跟踪),实际可以做到常数时间复杂度
    • 线性时间构造

      • 匹配转换:对状态jdfa[pat.charAt(j)][j] = j + 1(转向模式的下一个字符匹配)
      • 错配转换:
        • 对状态0c != pat.charAt(j),置dfa[c][0] = 0(初始状态未满足,回到初始状态开始匹配),此时X为状态0
        • 对状态jc != pat.charAt(j),置dfa[c][j] =dfa[c][X],同时更新X = dfa[pat.charAt(j)][X](当前状态更新完毕,维护到下一个状态前,也就是当前状态的DFA模拟结果)
    • Java实现

      • 错配情形,将dfa[][X]拷贝到dfa[][j]
      • 匹配情形,将dfa[pat.charAt(j)][j]置为j + 1
      • 更新X为dfa[pat.charAt(j)][X]
      public KMP(String pat)
      {
          this.pat = pat;
          M = pat.length();
          dfa = new int[R][M];
          dfa[pat.charAt(0)][0] = 1;
          for (int X = 0, j = 1; j < M; j++)
          {
              // mismatch
              for (int c = 0; c < R; c++)
              	dfa[c][j] = dfa[c][X];
              // match
              dfa[pat.charAt(j)][j] = j+1;
              // update
              X = dfa[pat.charAt(j)][X];
          }
      }
      
      • 构建运行时间:M次字符访问,但是时间或空间是正比于RM的
  • 性质:

    • 对N长度字符串匹配M长模式,KMP最多访问 M + N M+N M+N次字符用于搜索
    • KMP状态机的构建时间正比于 R M RM RM
    • 对于大的字符表,需要使用一种新方法,构建NFA,其时间正比于 M M M

BM算法 Boyer-Moore

  • 直观思想:模式从右到左匹配,这样,可以最多节省M个字符的匹配(跳过)
  • 确定一次跳过多少个字符:提前计算字符c出现于模式的最右索引位(-1为未出现)
public int search(String txt)
{
    int N = txt.length();
    int M = pat.length();
    int skip;
    // i指的是模式匹配的头,所以是N-M,一次跳过指定个字
    for (int i = 0; i <= N-M; i += skip){
        skip = 0;
        // 从右到左
        for (int j = M-1; j >= 0; j--)
        {
            // mismatch
            if (pat.charAt(j) != txt.charAt(i+j))
            {
                // 要么跳一个,要么跳j-right[c]个,让他们(相同字符)对上
                skip = Math.max(1, j - right[txt.charAt(i+j)]);
                break;
            }
        }
        // match
        if (skip == 0) return i;
    }
    return N;
}
  • 启发式搜索:比较次数正比于 ∼ N / M \sim N/M N/M
  • 最坏情况下(除了第一个字符都能匹配,且文本字符串大量重复后续字符,如模式为ABBBB,文本为BBBBBBBBB……),时间可以糟糕到 ∼ M N \sim MN MN
  • 通过引入一条类似KMP的规则,可以在重复性文本中的复杂度降到 ∼ 3 N \sim 3N 3N

RK算法 Rabin-Karp

  • 核心思想:模块化哈希
    • 计算模式字符0到M-1的哈希值
    • 对每一个i,计算文本字符i到M+i-1的哈希值(相当于和模式等长的一段)
    • 如果模式哈希和文本子串哈希相等,找到一个匹配
  • 模块化哈希函数:记文本的第i个字符为 t i t_i ti
    • 计算哈希值: x i = t i R M − 1 + t i + 1 R M − 2 + … + t i + M − 1 R 0 ( m o d    Q ) x_i = t_i R^{M-1} + t_{i + 1} R^{M-2} + \ldots + t_{i + M - 1} R^0 (\mod Q) xi=tiRM1+ti+1RM2++ti+M1R0(modQ)
  • 霍纳方法:线性时间方法评估一个M阶多项式的值
private long hash(String key, int M)
{
    long h = 0;
    for (int j = 0; j < M; j++)
    	h = (R * h + key.charAt(j)) % Q; // 原余数升一阶,加入当前阶值,再整体取余
    return h;
}
  • 效率提升手段:在已知 x i x_{i} xi的情况下,高效计算 x i + 1 x_{i+1} xi+1

    • 常数时间内更新哈希函数: x i + 1 = ( x i − t i R M − 1 ) R + t i + M x_{i+1} = (x_i - t_i R^{M-1}) R + t_{i + M} xi+1=(xitiRM1)R+ti+M
    • 留意ppt第53页,不是很明白这种算法,主要在于取余的处理
    • Java实现:
    public class RabinKarp
    {
        private long patHash; // pattern hash value
        private int M; // pattern length
        private long Q; // modulus
        private int R; // radix
        private long RM; // R^(M-1) % Q
        
        public RabinKarp(String pat) {
            M = pat.length();
            R = 256;
            Q = longRandomPrime();
            RM = 1;
            
            for (int i = 1; i <= M-1; i++)
            	RM = (R * RM) % Q; // 提前算好 R^(M-1) mod Q
            patHash = hash(pat, M);
        }
        
        private long hash(String key, int M)
        { /* as before */ }
        
        public int search(String txt)
        { /* see next slide */ }
    }
    
    • 比较函数实现:

      • 蒙特卡洛版本:哈希值匹配即匹配,文本字符滚动哈希,基于素数足够大的情况下,碰撞即相容的假设
      public int search(String txt)
      {
          int N = txt.length();
          int txtHash = hash(txt, M);
          if (patHash == txtHash) return 0;
          for (int i = M; i < N; i++)
          {
              txtHash = (txtHash + Q - RM*txt.charAt(i-M) % Q) % Q;
              txtHash = (txtHash*R + txt.charAt(i)) % Q;
              if (patHash == txtHash) return i - M + 1;
          }
          return N;
      }
      
      • 拉斯维加斯版本:在哈希匹配后,进行子串匹配检查,确保绝对一致
    • 哈希碰撞分析:如果Q足够大且随机,那么错误的碰撞可能性在 1 / N 1/N 1/N

    • 实际应用中,会选择一个足够大但是不至于溢出的指数,在合理假设下,碰撞可能性是 1 / Q 1/Q 1/Q

    • 蒙特卡洛版本分析:

      • 总是线性时间运行
      • 基本都会返回正确答案(可能存在错误哈希碰撞)
    • 拉斯维加斯版本分析:

      • 总是返回正确答案
      • 基本都会运行在线性时间(最坏情况就是NM)
    • 优势:更容易进行扩展

      • 二维模式匹配
      • 多维模式匹配
    • 劣势:内循环可能会很麻烦

      • 对于子串搜索,字符串比较比这个更快
      • 拉斯维加斯方法涉及到子串匹配,因此可能会涉及到回退操作
      • 最坏情况太糟糕
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值