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

Week 11

正则表达式 Regular Expressions

  • 回顾·模式匹配:
    • 子串搜索:从一个文本中寻找一个特定的字符串
    • 模式匹配:从一个文本中寻找一个特定的字符串集合
  • 模式匹配应用:
    • 模式合法性判定
    • 文本解析
  • 正则表达式:对特定字符串集合的表示(这一集合可能是无限的)
  • 正则表达式操作
操作优先级实例匹配结果不匹配结果
连接3AABAABAABAAB任何其他字符串
4AA|BAABAA
BAAB
任何其他字符串
闭包2AB*AAA
ABBBBBA(任意个B)
AB
ABABA
括号1A(A|B)AABAAAAB
ABAAB(括号内容优先展开)
任何其他字符串
  • 一些用于方便表示的操作
操作实例匹配结果不匹配结果
通配符.U.U.U.CUMULUSSUCCUBUS
字符类别[A-Za-z][a-z]*word
Capitalized
camelCase
4illegal
一次以上出现A(BC)+DEABCDE
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的NFA
      • Pattern.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)=inp(xi)lgp(xi),衡量信息的量

  • 实际经验:尽可能地使用外部知识提高压缩效率

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值