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 N≫M,解决问题是,会认为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的转换结果 - 这种方式貌似很费时间,实际上并没有,如果我们能维护上面的状态(在构造时跟踪),实际可以做到常数时间复杂度
-
线性时间构造
- 匹配转换:对状态
j
,dfa[pat.charAt(j)][j] = j + 1
(转向模式的下一个字符匹配) - 错配转换:
- 对状态
0
和c != pat.charAt(j)
,置dfa[c][0] = 0
(初始状态未满足,回到初始状态开始匹配),此时X为状态0 - 对状态
j
和c != 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=tiRM−1+ti+1RM−2+…+ti+M−1R0(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=(xi−tiRM−1)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)
-
优势:更容易进行扩展
- 二维模式匹配
- 多维模式匹配
-
劣势:内循环可能会很麻烦
- 对于子串搜索,字符串比较比这个更快
- 拉斯维加斯方法涉及到子串匹配,因此可能会涉及到回退操作
- 最坏情况太糟糕