数据结构与算法之字符串: PM

字符串

  • 字符串是一个很有意思的结构:由一连串字符的东西排成了一个线性序列
  • 它的特点可以归纳为两个方面
    • 相比于向量可以存储复杂的结构,字符串里面的元素只能是字符
    • 它是一个整体,一般很长,通过整体或局部整体性进行计算
      • 经常从里面挑出一段,我们称之为pattern,访问方式是 call-by-pattern
      • 这个模式,里面蕴含诸多技巧

PM: Preliminaries 预备知识

  • 既然是线性序列,我们就可以把它排个队,通过下标来访问(或通过一个秩的东西来访问,一个意思)
    • 下标从0~n-1, 假设在n的位置有一个虚拟的哨兵,我们叫界桩,同样在处理的时候在-1的位置也有一个哨兵
    • 通过这种方式来帮助我们理解一些算法实现
  • 字符串还有一个概念叫做substr, 也就是它的局部, 一般它有由两个东西来的定义,i,k
    • S.substr(i,k) = S[i, i+k], 0 <= i < n, 0 <= k
    • i是起始字符的rank, k是长度,左闭右开
    • [i, i+k) 是一个切片
  • 字符串还有一个概念叫做prefix前缀
    • 当上面的i为0的时候, 长度为k的子串叫做原字符串的前缀: [0, k)
    • S.prefix(k) = S.substr(0, k) = S[0,k), 0 <= k <= n
  • 和prefix相反的概念,还有一个叫做suffix
    • 后缀:[n-k, n)
    • S.suffix(k) = S.substr(n-k, n) = S[n-k, n), 0 <= k <= n
  • 这样,字符串的substr,可以通过前缀和后缀的操作来得到
    • S.substr(i, k) = S.prefix(i+k).suffix(k) = S.suffix(n-i).prefix(k)
    • 这个很容易理解

PM: Pattern Matching 模式匹配

  • 字符串中研究很经典的问题是模式匹配问题
    • Text: now is the time for all good people to come
    • Pattren: people
    • 我们的people这个Pattern能否在原文本Text中出现
  • 搜索引擎的本质其实和字符串搜索没区别
  • 通常我们把原始字符串的长度记为:n = |T|
  • 我们把模式记为:m = |P|
  • 其中:2 << m << n
    • 这里m也是一个足够大的变量
    • 其中n的数量级是非常大的
  • 将来如果有了一个算法,你如何来评价它的性能好坏
    • 我们可以随机生成一个Text和Pattern,跑一次算法
    • 再随机生成,跑一跑算法
    • 写一个脚本,生成足够数量的随机Text和Pattern,进行算法测试
    • 其实,这是一个非常不妥的算法,因为随机中,成功的可能非常小
    • 因为随机生成的Text和Pattern都是完全混沌的,如果是26个英文字母
    • Text的可能有26n种,Pattern的可能有26m种
    • 成功的概率是: n / (26^m) 很低很低了
    • 分母会出现指数爆炸并很快超过分子,整体很快趋于0
    • 所以你测试再多,成功的可能依然很低
    • 所以这种测试是非常有问题,而且是非常糟糕的
    • 正确的做法是随机生成一个Text,然后在Text中选择出一个Pattern

PM: Brute-Force 暴力算法

在这里插入图片描述

  • 如果给定一个Text和Pattern, 任何一个Pattern串要和Text某个地方匹配上
  • 命中位置一定在0 ~ n-m的位置,这里是一个非常简单的算法
    • 通过在Text中移位Pattern进行对比,如上图所示,但是效率堪忧
    • 上图绿色的是经过一轮比对后,成功的位置
    • 红色是当前对齐中失败的位置,灰色是省下时间的位置
    • 我们知道红色和绿色成本是一样的,都是经过一次对比
  • 由上图可知
    • Best: O(n)
      • 一般认识是上来就命中,但是这一般不作为我们的Best case. 我们不考虑运气问题
      • 我们考虑常规性问题,一般在失败意义上而言,从头到尾跑一遍
      • Pattern的第一个就不匹配,赶紧移动,匹配或不匹配
    • Worst: O(n·m)
      • 每次匹配到Pattern的最后一个才发现不匹配,并且在Text中从头到尾跑了一遍
      • 无论最终是匹配或不匹配

算法实现:版本1

int match(char * P, char * T) {
    size_t n = strlen(T), i = 0;
    size_t m = strlen(P), j = 0;
    // 自左向右逐次比对
    while(j < m && i < n) {
        if(T[i] == P[j]) {
            // 若匹配则转到下一对字符
            i ++;
            j ++;
        } else {
            // 否则,T回退,P复位
            i -= j-1; // 注意这里每个时刻i和j的差都是i-j, 移动一格就是i-j+1,写法不同,意义相同
            j = 0;
        }
    }
    return i-j; // 如何通过返回值,判断匹配结果:最后出格,通过判断i-j在允许的范围内
}

算法实现:版本2

int match(char * P, char * T) {
    size_t n = strlen(T), i = 0;
    size_t m = strlen(P), j;
    for(i=0; i<n-m+1; i++) {
        // T[i]与P[0]对齐后,逐次比对
        for(j=0; j<m; j++) {
            if(T[i+j] != P[j]) {
                break; // 失配,转下一对齐位置
            }
        }
        if(m <= j) {
            break; // 完全匹配
        }
    }
    return i;
}
  • 以上是两种蛮力算法的实现

PM: KMP: Good Prefix + Look-up Table

在这里插入图片描述

  • 由上图可知,Pattern字符串中第一个字符是R, 当进行一次对比后,就可以记录下,当前对比中是否存在是R的字符
  • 如果存在,下次对比,直接记录并挪动到该位置,这样省去了中间,比如上图的EGR之间的对比,直接跨过来
  • 如上图的例子:
    • 当前在0号位置对齐,移位到4号位置出现不匹配的错误
    • 这时候,就没有必要在EG的位置移动位置了,因为不匹配
    • 直接到R进行移位,也就是3号位的R
    • 这里需要借助一个look-up table来处理
  • 通过这个Pattern字符串, 不用出现主串, 我们可以给它预处理,记录一些笔记
  • 如果在某个位置适配了,应该拿哪个字符和它对齐,我们看到失败是在O这个位置上
  • 那么我们给O记录一个笔记1,如上图所示,这个意思就是说,如果失败在O字符上,
  • 我们应该拿1号字符E和O进行对齐

在这里插入图片描述

  • 上面又是另一个例子,我们看到,上面对齐的过程,直到L和X适配,发现失败了
  • 蛮力算法是移动一位,现在我们有了一个查询表,我们看到在L的位置失败了,用L上记录的3
  • 也就是Pattern上的3号字符也就是N,来和刚才失败的位置对齐
  • 换句话说是用N来替代刚才失败的L来和X对齐,然后就可以继续对比下去…
    • 首先我们可以看到这个过程移动非常的快,在这个例子中移动了4个位置,这4步跳过去都是安全的
    • 并且剩下的3个字符CHI也是可以完美匹配成功的,所以这个查询表很重要,它可以让你大步移动
    • 并且剩下的字符可以不用对比,必然能匹配成功,所以,接下来,只需要在刚才失败的位置继续努力即可
  • 这时候,N和X对比,显然又失败了,这时候,找到N头上记录的0,也就是Pattern的0号位C来对齐
  • 这时候C是首字符了,仍然C和X适配失败,C头上记录的是-1,这时候的意思是整体向前移过去1位
  • 其实-1代表的是通配符,天生就是绿色的,以此来解开僵局,这个算法就变得更加鲁棒简明
  • 这就是著名的KMP算法

KMP算法具体实现

int match(char * P, char * T) {
    int * next = buildNext(P); // 构造一个查询表
    int n = (int) strlen(T), i=0;
    int m = (int) strlen(P), j=0;
    while(j < m && i < n) {
        if(0 > j || T[i] == P[j]) {
            i++;
            j++;
        } else {
            j = next[j];
        }
    }
    delete [] next;
    return i - j;
}

直观的对比

在这里插入图片描述

  • 只有绿色和红色部分花时间,最坏情况下也是线性的算法
  • 青色的部分被KMP跳过去了, 灰色的部分并没有出现
  • 总的时间复杂度不超过O(n)

PM: KMP: Understanding next[] 理解查询表

在这里插入图片描述

  • 在某个位置上对齐之后,我们经过一系列的成功,在Y的位置和主串的X适配
  • 之后,我们需要胆子大一些快速的滑动,能滑动多少我们不知道,但是我们知道
  • 前面多一截的P[0,t)不用考虑,接下来的计算是通过Z替代Y继续和主串的X适配
  • 为什么我们可以不用顾及P[0,t), 因为我们可以判定可以不用顾及
  • 因为P[0,t)与主串上的P[j-t, j)完全一致,也就是说如果当时失败在j的位置
  • 那么在比j小的t的前缀,和某个t长度的后缀它们是相等的,只有这种情况下
  • 移动相应的距离,残留下来长度为t的前缀才会和那个后缀相逢
  • 它们之间的比较就可以省下来

PM: KMP: Constructing next[] 构造表

在这里插入图片描述

  • 这里我们用到递增的策略,任何一个Pattern串给定之后,我们可以首先把0项字符
  • 对应的查询表记录为-1,如上面的M头上记录-1,递增的意义是逐个字符处理
  • 以next[0]为基础算next[1],然后算next[2],next[3]…
  • 这种基于一个基础,接下来算下一个,再算下一个…我们称为递增的策略
  • 假如这个next[]表已经算到第j项了,接下来计算第j+1项
  • 我们的核心问题是在第j项之前算到第j+1项,解决这么一个通用的问题
  • 所有问题反复借用即可

构造算法

int * buildNext(char * P) {
    size_t m = strlen(P), j = 0;
    int *N = new int[m];
    int t = N[0] = -1;
    while(j < m -1) {
        (0 > t || P[j] == P[t]) ? N[++j] = ++t : t = N[t]; 
    }
    return N;
}

TODO

  • 未完…
  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Wang's Blog

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值