Week 11
正则表达式 Regular Expressions
- 回顾·模式匹配:
- 子串搜索:从一个文本中寻找一个特定的字符串
- 模式匹配:从一个文本中寻找一个特定的字符串集合
- 模式匹配应用:
- 模式合法性判定
- 文本解析
- 正则表达式:对特定字符串集合的表示(这一集合可能是无限的)
- 正则表达式操作
操作 | 优先级 | 实例 | 匹配结果 | 不匹配结果 |
---|---|---|---|---|
连接 | 3 | AABAAB | AABAAB | 任何其他字符串 |
或 | 4 | AA|BAAB | AA BAAB | 任何其他字符串 |
闭包 | 2 | AB*A | AA ABBBBBA(任意个B) | AB ABABA |
括号 | 1 | A(A|B)AAB | AAAAB ABAAB(括号内容优先展开) | 任何其他字符串 |
- 一些用于方便表示的操作
操作 | 实例 | 匹配结果 | 不匹配结果 |
---|---|---|---|
通配符 | .U.U.U. | CUMULUS | SUCCUBUS |
字符类别 | [A-Za-z][a-z]* | word Capitalized | camelCase 4illegal |
一次以上出现 | A(BC)+DE | ABCDE ABCBCDE | ADE |
准确地k次出现 | [0-9]{5}-[0-9{4}] | 05134-2164 13145-2151 | 11111111 |
- 正则表达式的表达能力非常强大
- 书写一个正则表达式是在书写一个程序
- 需要理解编程模型
- 写起来比读起来可能要容易一些
- 调试可能会很困难
- 正则表达式很高效强大,但也很容易变得很复杂,容易写错
正则表达式和非有限状态自动机 REs and NFAs
- RE:精确描述一组字符串的方式
- DFA:用于识别给定字符串是否在一个指定的字符串集合内的机制
- RE和DFA具有二元性(duality)
- 模式匹配实现的第一次尝试:
- 和KMP一样使用自动机
- 不需要回退
- 线性时间复杂度保证
- 方法设计:
- 根据RE构造DFA
- 在文本输入上模拟DFA,到达接收状态即匹配成功
- 不可行:随着RE的长度不断增加,DFA中的状态数量呈指数型增长
- 对上述模式的改进
- 和KMP比较相似
- 不需要回退
- 平方时间复杂度保证(一般情况下是线性时间)
- 使用NFA
- 方法设计
- 根据RE构造NFA
- 在问版本输入上模拟NFA,到达接受状态即匹配成功
- RE匹配用NFA
- 整个RE由括号包围
- 每一个RE字符作为一个状态,状态0为开始状态,状态M为接受状态(除了字符之外的符号可以认为是占位空槽,但是整体是一个状态,实际匹配时,文本输入并不包含这些字符,这些字符所在的节点亦不会发生普通链接下的状态转换)
- ε连接:在不需要扫描字符的情况下进行状态转换
- 普通链接:扫描字符,并进行状态转换
- 到达接受状态,匹配成功
- NFA的不确定性:
- 整个机制可以猜出合适的序列
- 序列是机制接受文本输入的保证???
- 如何使用一个自动机确定一个字符串是否匹配
- DFA,由于确定性,这一过程非常简单,因为一次只进行一个状态转换
- NFA,由于不确定性,每一次转换都有很多可能,需要选择正确的状态转换
- 对NFA的模拟:系统地考虑所有的可能情况
NFA模拟 NFA Simulation
- NFA表示:
- 状态名称:从0到M的整数,M为RE中的符号个数(包括括号等非语义符号)
- 匹配转换:将RE按顺序保存在一个数组当中
re[]
,语义符号将会有一个匹配转换到其下一位字符 - ε转换:存储在有向图G中
- 有效地模拟NFA
- 维护一个状态集合,其内部为到目前位置的所有可能的到达状态
- 首先做读入下一个字符的匹配转换
- 再计算读入下一个字符前的ε转换
- 两个状态集合即为且下一个字符入读的所有可到达状态
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-wCLMeoE1-1588302687275)(assets/1554865195037.png)]
- NFA模拟
- 目标:检查输入文本是否和模式相匹配
- 读入下一个字符
- 寻找所有匹配转换到达的状态
- 在寻找所有ε转换可以到达的状态
- 不再有字符时
- 如果任一个可到状态是接受状态,则接受
- 否则拒绝
- 有向图(ε转换的可达性):
- 寻找所有有向图上给定一个或一组顶点的可达顶点
- 使用DFS从每一个源找到顶点
- 时间复杂度为 E + V E+V E+V
- NFA模拟的Java实现:
public class NFA
{
private char[] re; // match transitions
private Digraph G; // epsilon transition digraph
private int M; // number of states
public NFA(String regexp)
{
M = regexp.length();
re = regexp.toCharArray();
G = buildEpsilonTransitionDigraph();
}
public boolean recognizes(String txt)
{
Bag<Integer> pc = new Bag<Integer>(); // 程序计数器,NFA可到达的状态
DirectedDFS dfs = new DirectedDFS(G, 0);
for (int v = 0; v < G.V(); v++)
if (dfs.marked(v)) pc.add(v); // 开始状态通过ε可达的状态
for (int i = 0; i < txt.length(); i++)
{
Bag<Integer> match = new Bag<Integer>(); // 再经过i个字符之后,可以到达的状态
for (int v : pc)
{
if (v == M) continue; // 在扫描结束前到达接受状态,无任何意义
if ((re[v] == txt.charAt(i)) || re[v] == '.')
match.add(v+1);
}
dfs = new DirectedDFS(G, match); // 从当前字符的状态扫描ε转换到达的状态
pc = new Bag<Integer>();
for (int v = 0; v < G.V(); v++)
if (dfs.marked(v)) pc.add(v);
}
for (int v : pc)
if (v == M) return true; // 最后是否到达接受状态
return false;
}
public Digraph buildEpsilonTransitionDigraph()
{ /* stay tuned */ }
}
- 性质:N字符进行M长模式匹配,最坏需要时间正比于 M N MN MN(考虑总边数小于 3 M 3M 3M)
构建NFA NFA Construction
-
状态:表示RE中的每一个标记,此外还有一个接受状态
-
连接:对于每一个包含字母表字符的状态,添加一条匹配转换边,从这些状态到其下一个状态
-
括号:从括号状态添加一条ε转换边到其下一个状态
-
闭包:对每一个*运算符,添加三条ε转换边,分别到其下一个状态,最左字符(单字符即字符,多字符即左括号)的来回
-
或:对每一个|运算符,添加两条ε转换边,分别为最左字符(左括号)到|的下一个状态,和|到最右字符(右括号)
-
实现:
- 目标:构建ε连接有向图
- 问题:括号,或,闭包的连接
- 解决方案:使用栈进行维护
- 左括号:
- 向下一个状态加入ε转换
- 入栈
- 字母表标记:
- 向下一个状态加入匹配转换
- 向后多看一个字符,如果是*,添加ε转换(来回两条)
- 闭包标记:
- 向下一个状态加入ε转换
- 或标记:
- 入栈
- 右括号:
- 向下一个状态添加ε转换
- 将对应的左括号出栈,包括内部的或标记,添加对应的ε转换
- 多看一个字符,应对*的情况
- 在最终的结尾加入接受状态
private Digraph buildEpsilonTransitionDigraph() { Digraph G = new Digraph(M+1); Stack<Integer> ops = new Stack<Integer>(); for (int i = 0; i < M; i++) { int lp = i; // 左括号和或标记 if (re[i] == '(' || re[i] == '|') ops.push(i); // 或的情况 else if (re[i] == ')') { int or = ops.pop(); if (re[or] == '|') { lp = ops.pop(); G.addEdge(lp, or+1); G.addEdge(or, i); } else lp = or; } // 闭包的情况(往后多看一个字符) if (i < M-1 && re[i+1] == '*') { G.addEdge(lp, i+1); G.addEdge(i+1, lp); } if (re[i] == '(' || re[i] == '*' || re[i] == ')') G.addEdge(i, i+1); } return G; }
-
性质:M长模式的NFA构建的时间复杂度正比于M
-
对+运算符,和*基本一致,但是需要移去从最左字符到运算符的ε转换(必须在字母表字符出现时进行转换,至少一个字符要求成立)
正则表达式应用 Regular Expression Applications
-
通用正则表达式式打印(Generalized Regular Expression Print,GREP)
- 将一个RE作为命令行参数,打印出标准输入中的一些行,其中包含能够匹配RE的子串
public class GREP { public static void main(String[] args) { String re = "(.*" + args[0] + ".*)"; NFA nfa = new NFA(re); while (StdIn.hasNextLine()) { String line = StdIn.readLine(); if (nfa.recognizes(line)) StdOut.println(line); } } }
- GREP最坏线性于 M N MN MN,与暴力子串搜索相同
- 工业级的grep实现,要求很多的额外要素(通配符,多路或等等)
-
正则表达式应用在很多场景中
-
Java库实现:
- 使用
input.match(regex)
实现基本的RE匹配 - 复杂实现需要Java中的Pattern类和Matcher类用于获取详细信息
Patttern.compile()
用于创建一个RE的NFAPattern.matcher
产生一个用于执行NFA模拟的Matcher- 结果可以从Matcher实例中提取(包括匹配状态和具体的匹配结果)
- 隐患:一般的实现并不考虑性能问题,因此不能保证性能(可以引发DOS攻击)
- 使用
-
回引用(back-references):\1表示将之前匹配的子表达式再匹配一次,比较难以处理(在一些情况下会导致时间指数级别于N)
数据压缩 Data Compression
- 压缩:减小一个文件的大小
- 节省存储时的空间占用
- 节省传输时的时间小号
- 大多数文件都存在一定的冗余信息
- 无损压缩和扩展
- 信息:我们希望压缩的二进制数据B
- 压缩:生成一个压缩的数据表示C(B)
- 扩展:重建原始数据B
- 压缩率:C(B) / B
- 使用规范:标准字节输入和输出接口(查询讲义)
- 通用数据压缩方法:
- 性质:没有算法能够压缩所有的二进制数据流
运行长度编码 Run-Length Coding
- 一种很简单的二进制流的冗余情况:连续重复位
- 改进表示:使用4位计数器描述连续的0或者1(15次以内的连续位后翻转)
- 解决方案:
- 使用8个位表示一个连续位串(最多255次)
- 如果超出最大长度(255):在整个链中散布0长度翻转位串
- Java实现:
public class RunLength
{
private final static int R = 256;
private final static int lgR = 8;
public static void compress()
{ /* see textbook */ }
public static void expand()
{
boolean bit = false;
while (!BinaryStdIn.isEmpty())
{
int run = BinaryStdIn.readInt(lgR);
for (int i = 0; i < run; i++)
BinaryStdOut.write(bit);
bit = !bit;
}
BinaryStdOut.close();
}
}
霍夫曼压缩 Huffman Compression
- 变长度编码:使用不同个数的位来编码不同的字符
- 摩尔斯码(问题:存在歧义,使用一个间断来分开代码)
- 避免存在歧义:确保没有某一个代码是另外一个代码的前缀
- 方案1:定长代码
- 方案2:每一个代码后接特定的停止字符
- 方案3:普遍无前缀代码
- 无前缀代码的前缀树表示:
- 树的叶子节点含有字符
- 代码即从根到指定字符的路径
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-6iFxAYer-1588302687282)(assets/1555489361812.png)]
- 无前缀代码的压缩和扩展
- 压缩:
- 方法1:从叶子开始,逐步向上寻找路径,按照寻找路径的反向(从根开始)输出位表示
- 方法2:创建标记表
- 扩展:
- 从根开始
- 0向左,1向右
- 到达叶子节点,输出对应字符,并回到根节点扫描下一个字符
- 压缩:
- 霍夫曼前缀树的数据类型实现:
private static class Node implements Comparable<Node>
{
private final char ch; // used only for leaf nodes
private final int freq; // used only for compress
private final Node left, right;
public Node(char ch, int freq, Node left, Node right)
{
this.ch = ch;
this.freq = freq;
this.left = left;
this.right = right;
}
public boolean isLeaf()
{ return left == null && right == null; }
public int compareTo(Node that)
{ return this.freq - that.freq; }
// expand,linear time N
public void expand()
{
Node root = readTrie(); // 读入整个树
int N = BinaryStdIn.readInt(); //需要读入几个字符
for (int i = 0; i < N; i++)
{
Node x = root;
while (!x.isLeaf())
{
if (!BinaryStdIn.readBoolean()) // 一次读一个位
x = x.left;
else
x = x.right;
}
BinaryStdOut.write(x.ch, 8);
}
BinaryStdOut.close();
}
// compression in text book
}
-
传输:
- 如何写出前缀树:写出前缀树的前序遍历,使用0代表中间节点1代表叶子节点,后接字符的位表示
- 如何读入前缀树:按照上面的定义从树的先序遍历重建
-
香农-范诺编码:
- 算法:
- 将标记集合 S S S粗略地分成频相等的两个子集 S 0 S_0 S0和 S 1 S_1 S1
- 对应 S 0 S_0 S0的标记,其代码以0开始;对应 S 1 S_1 S1的标记,其代码以1开始
- 对两个集合递归
- 问题:
- 如何划分集合?
- 算法:
-
霍夫曼算法:
- 计算输入中每一个标记出现的频率
- 每一个标记对应一个节点,且权重为其频率
- 不断重复下述步骤知道单个树
- 选择两个最小权重的前缀树
- 合并之,结果前缀树权重为两个树的权重之和
- Java实现:
private static Node buildTrie(int[] freq) { MinPQ<Node> pq = new MinPQ<Node>(); for (char i = 0; i < R; i++) if (freq[i] > 0) pq.insert(new Node(i, freq[i], null, null)); // 初始化单个节点 while (pq.size() > 1) { Node x = pq.delMin(); Node y = pq.delMin(); Node parent = new Node('\0', x.freq + y.freq, x, y); // 内部节点 pq.insert(parent); } return pq.delMin(); }
- 性质:最优无前缀代码
- 压缩实现:
- 计算字符频率并构建前缀树
- 对输入文件进行编码
- 运行时间(二元堆): N + R log R N+R \log R N+RlogR,N为输入长度,R为字母表大小(先遍历一遍计算频数,再构建二元堆)
LZW压缩 LZW Compression
-
统计方法:
- 统计模型:对于所有的相关文本,使用一致的模型进行编码压缩
- 非常快速
- 不是最优的(不同的文本内容肯定有着不同的文本特性)
- ASCII,莫尔斯码
- 动态模型:根据文本内容生成模型
- 需要预先浏览一遍文本以生成模型
- 必须要传输这个模型(没有文本无法复现)
- 霍夫曼代码
- 自适应模型:随着不断地阅读文本,而不断地学习和更新模型
- 更准确的建模,更好地压缩
- 解码同样的步骤,因此必须要从头开始
- LZW压缩算法
- 统计模型:对于所有的相关文本,使用一致的模型进行编码压缩
-
压缩算法设计
- 创建一个以字符串为键(因为不只是单个字符),W-位代码的标记表
- 用单字符键来初始化ST(课程中使用的是ASCII)
- 寻找尚未扫描的文本中在ST的最长键s
- 将s对应的代码写入
- 取下一个字符c,将s+c作为一个新键写入ST
- 对ST的高效实现:前缀树,寻找s即最长前缀匹配
- Java实现:
public static void compress() { String input = BinaryStdIn.readString(); TST<Integer> st = new TST<Integer>(); for (int i = 0; i < R; i++) st.put("" + (char) i, i); int code = R+1; // 对于新键的代码开始 while (input.length() > 0) { String s = st.longestPrefixOf(input); // 最长模式匹配 BinaryStdOut.write(st.get(s), W); int t = s.length(); if (t < input.length() && code < L) st.put(input.substring(0, t+1), code++); // 创建新键 input = input.substring(t); // 继续扫描 } BinaryStdOut.write(R, W); BinaryStdOut.close(); }
-
扩展算法设计
- 创建一个以W-位代码为键,字符串为值的ST
- 以单字符值进行初始化(ASCII)
- 读取一个代码的值
- 写出其字符结果
- 更新ST(前一个模式和当前模式的第一个字符作为新键的值)
-
一种特殊情况:如果扩展时一个键出现在构建其ST表项之前怎么办?
- ABABABA
- 压缩时,结果为41 42 81 83 80
- 解压缩时,在试图解开83时发现我们只知道41 42 81 82,83本体尚未进入ST
- 首先我们知道当前位置的键,只能来自于新键AB和BA(因为其前一项为二元键),因此分析来自AB,形式为AB?,和前一个键的结果为AB AB?
- 展开时,该键的确定由前一个键加当前键的第一个字符组成,确认为ABA
-
无损压缩总结:
- 使用变长代码代表定长标识符——Huffman
- 使用定长代码表示边长标识符——LZW
-
有损压缩:利用FFT
-
压缩的理论限制:香农熵—— H ( X ) = − ∑ i n p ( x i ) lg p ( x i ) H(X)= -\sum\limits_i^n p(x_i) \lg p(x_i) H(X)=−i∑np(xi)lgp(xi),衡量信息的量
-
实际经验:尽可能地使用外部知识提高压缩效率