重学数据结构(五、串)

时间:2022-07-25
本文章向大家介绍重学数据结构(五、串),主要内容包括其使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。

计算机上的非数值处理的对象大部分是字符串数据, 字符串一般简称为串。串是一种特殊的 线性表, 其特殊性体现在数据元素是一个字符, 也就是说, 串是一种内容受限的线性表。

1、串的定义

串(string)(或字符串)是由零个或多个字符组成的有限序列,其中每个字符都来自某个字符表( Alphabet) Σ,比如 ASCII 字符集或 Unicode 字符集。 一般记为:

str= "a1,a2 … an" (n>=0)

其中,str是串的名, 用双引号括起来的字符序列是串的值;ai(1<=n<=n)可以是字母、 数字或其他字符;串中字符的数目n称为串的长度。零个字符的串称为空串(null string), 其长度为零。

串中任意个连续的字符组成的子序列称为该电的子串。包含子串的串相应地称为主串。 通常 称字符在序列中的序号为该字符在串中的位置。

两个串相等,只有当两个串的长度相等,并且各个对应位置的字符都相等时才相等。

一个或多个空格组成的串" "称为空格串 (blank string), 请注意:此处不是空串), 其长度为串 中空格字符的个数。

2、串的基本操作

串的逻辑结构和线性表极为相似,区别仅在于串的数据对象约束为字符集。

然而,串的基本操作和线性表有很大差别。在线性表的基本操作中,大多以 “单个元素” 作为操作对象。

例如,在线性表中查找某个元素,求取某个元素,在某个位置上插入一个元素或删除一个元素等;而在串的基本操作中,通常以 “ 串的整体 ” 作为操作对象,例如,在串中查找某个子串,求取一个子串,在串的某个位置上插入一个子串,以及删除一个子串等。

串的抽象数据类型定义:

/**
 * @Author 三分恶
 * @Date 2020/9/8
 * @Description 串的抽象定义
 */
public interface IString {
    int length();                 //查询串的长度
    char charAt(int i);           //返回第i个字符
    String subStr(int i,int k);  //返回从第 i 个字符起、长度为 k 的子串
    String prefix(int k);        //返回长度为k的前缀
    String suffix(int k);       //返回长度为k的后缀
    boolean equals(String t);    //判断t是否与当前字符串相等
    void concat(String t);       //将t拼接在当前字符串之后
    int indexOf(String p);       //若 p是当前字符串的一个子串,则返回该子串的起始位置;否则返回-1
}

3、串的存储结构

与线性表类似, 串也有两种基本存储结构:顺序存储和链式存储。但考虑到存储效率和算法的方便性, 串多采用顺序存储结构。

3.1、串的顺序存储

类似于线性表的顺序存储结构, 用一组地址连续的存储单元存储串值的字符序列。 按照预定义的大小, 为每个定义的串变量分配一个固定长度的存储区。

可以用一个定长的char数组来表示:

    private int defaultSize=100; //字符数组默认容量
    private int length;        // 字符串长度
    private char[] items;      //字符数组
    
    //构造方法,初始化字符数组
    public SequeueString() { 
        items=new char[defaultSize];
    }

当然,实际应用中字符串实际需要的空间差别不定,所以可以参照前面的线性表,进行动态扩容。

3.1、串的链式存储

对于串的链式存储结构, 与线性表是相似的, 但由于串结构的特殊性, 结构中的每个元素数据是一个字符, 如果也简单的应用链表存储串值, 一个结点对应一个字符, 就会存在很大的空间浪费。 因此, 一个结点可以存放一个字符, 也可以考虑存放多个字符, 最后一个结点若是未被占满时, 可以用 “#” 或其他非串值字符补全。

但串的链式存储结构除了在连接串与串操作时有一定方便之外, 总的来说不如顺序存储灵活, 性能也不如顺序存储结构好。

4、串的模式匹配算法

子串的定位运算通常称为串的模式匹配串匹配。此运算的应用非常广泛,比如在搜索引擎、拼写检查、 语言翻译、数据压缩等应用中, 都需要进行串匹配。

著名的模式匹配算法有BF (蛮力)算法和、KMP 算法和BM算法, 下面详细介绍这些算法。

4.1、BF算法

4.1.1、算法描述

Brute-Force算法,简称BF算法,中文可以译为蛮力算法。

BF算法是最直接、直观的方法。想象:

  • 将主串和模式串分别写在两条印有等间距方格的纸带上,主串对应的纸带固定,模式串的首字符与主串的首字符对齐,沿水平方向放好。主串的前m个字符将与模式串的m个字符两两对齐。
  • 接下来,自左向右检查对齐的每一对字符:如果匹配,则转向下一对字符;
  • 如果失配,则说明在这个位置主串与模式串无法匹配,是将模式串对应的纸带右移一个字符,然后从首字符开始重新对比。
  • 若经过检查,当前的m个字符对都是匹配的,则匹配成功,并返回匹配子串的位置。

模式串中,黑色方格为经检查与主串匹配的字符,灰色方格为失配的字符,白色方格为无需检查的字符。

4.1.2、算法实现

    /**
     *
     * @param s 目标串
     * @param t 模式串
     * @return 返回匹配子串的起始位置
     */
    public static int bruceForce(String s,String t){
        int i=0,j=0;
        while (i<s.length()&&j<t.length()){      //遍历两个字符串
            if (s.charAt(i)==t.charAt(j)){       //继续往后比较字符
                i++;
                j++;
            }else{                              // 字符不相等
                i=i-j+1;                        //位置回退,重新比较
                j=0;
            }
        }
        if (j>=t.length()){             //匹配成功
            return i-t.length();
        }
        //匹配失败
        return -1;
    }

4.1.3、算法分析

分析BF算法,必须考虑BF算法的最好情况和最坏情况。

  • 最好情况 最好情况下,每趟不成功的匹配都发生在模式串的第一个字符与主串中相应字符的比较。

例如:

s="aaaaaba"; t="ba";

主串的长度为n, 子串的长度为m, 假设从主串的第i个位置开始与模式串匹配成功,则在前 i-1 趟匹配中字符总共比较了 i-I 次;若第 i 趟成功的字符比较次数为 m, 则总比较次数为i- 1+m。 对于成功匹配的主串, 其起始位置由 1 到 n-m+I, 假定这 n-m+I 个起始位置上的匹配成功概率相等, 则最好的情况下匹配成功的平均比较次数为:

即最好情况下的平均时间复杂度是 O(n + m)。

  • 最坏情况 每趟不成功的匹配都发生在模式串的最后一个字符与主串中相应字符的比较。

例如: s= "aaaaaab" t= "aab"

假设从主串的第 l 个位置开始与模式串匹配成功, 则在前 i- 1 趟匹配中字符总共比较了 (i-1) ×m 次;若第 l 趟成功的字符比较次数为 m,_则总比较次数 i X m。 因此最坏情况下匹配成功 的平均比较次数为:

即最坏情况下的平均时间复杂度是 O(n×m)。

4.2、KMP算法

4.2.1、算法原理

针对BF算法,有一种改进的算法,算法是由 Knuth 、 Morris 和 Pratt 同时设计实现的, 因此简称 KMP 算法。

学习一下KMP算法的整体思路:

KMP算法和BF算法的“开局”是一样的,同样是把主串和模式串的首位对齐,从左到右对逐个字符进行比较。

第一轮:模式串和主串的第一个等长子串比较,发现前5个字符都是匹配的,第6个字符不匹配,是一个“坏字符”:

和BF算法不同的是,KMP算法利用到了我们已经匹配的字符,那么究竟是如何利用已匹配的前缀 “GTGTG” 呢?

在前缀“GTGTG”当中,后三个字符“GTG”和前三位字符“GTG”是相同的:

在下一轮的比较时,只有把这两个相同的片段对齐,才有可能出现匹配。这两个字符串片段,分别叫做最长可匹配后缀子串最长可匹配前缀子串

第二轮:所以在这一轮里,我们是把匹配串后移一位,而是移两位,这样就刚好让两个“GTG”对齐了。接着从上面的坏字符“A”开始比较。

“A” 仍然是个坏字符,这时候,匹配前缀缩短了,变成了“GTC”。

按照第一轮的思路,重新确定最长可匹配前缀子串和最长可匹配后缀子串。

第三轮:再次让让两个“G”对齐,把模式串后移两位,继续从刚才主串的坏字符A开始进行比较:

KMP算法的整体思路:在已匹配的前缀当中寻找到最长可匹配后缀子串最长可匹配前缀子串,在下一轮直接把两者对齐,从而实现模式串的快速移动。

现在新的问题又来了,怎么找到最长可匹配后缀子串最长可匹配前缀子串呢?

答案是可以事先把两个子串缓存到一个数组里,这个数组称为next数组,接下来看看next数组的生成:

next数组

next数组是一个一维数组,数组的下标代表了“已匹配前缀的下一个位置”,元素的值则是“最长可匹配前缀子串的下一个位置”。

  • 当模式串的第一个字符就和主串不匹配时,并不存在已匹配前缀子串,更不存在最长可匹配前缀子串。这种情况对应的next数组下标是0,next[0]的元素值也是0。
  • 如果已匹配前缀是G、GT、GTGTGC,并不存在最长可匹配前缀子串,所以对应的next数组元素值(next[1],next[2],next[6])同样是0。
  • GTG的最长可匹配前缀是G,对应数组中的next[3],元素值是1。

以此类推,

  • GTGT 对应 next[4],元素值是2。
  • GTGTG 对应 next[5],元素值是3。

可以通过next数组,快速寻找到最长可匹配前缀的下一个位置,然后把这两个位置对齐。

比如下面的场景,我们通过坏字符下标5,可以找到next[5]=3,即最长可匹配前缀的下一个位置:

那么,next数组如何事先生成呢?

最简单的方法是从最长的前缀子串开始,把每一种可能情况都做一次比较。假设模式串的长度是m,生成next数组所需的最大总比较次数是1+2+3+4+......+m-2 次。

这种方法效率太低,如何进行优化呢?

我们可以采用类似“动态规划”的方法。首先next[0]和next[1]的值肯定是0,因为这时候不存在前缀子串;从next[2]开始,next数组的每一个元素都可以由上一个元素推导而来。

已知next[i]的值,如何推导出next[i+1]呢?看一下上述next数组的填充过程:

  • 们设置两个变量i和j,其中i表示“已匹配前缀的下一个位置”,也就是待填充的数组下标,j表示“最长可匹配前缀子串的下一个位置”,也就是待填充的数组元素值。 当已匹配前缀不存在的时候,最长可匹配前缀子串当然也不存在,所以i=0,j=0,此时next[0] = 0
  • 接下来,我们让已匹配前缀子串的长度加1:此时的已匹配前缀是G,由于只有一个字符,同样不存在最长可匹配前缀子串,所以i=1,j=0,next[1] = 0
  • 接下来,我们让已匹配前缀子串的长度继续加1:此时的已匹配前缀是GT,我们需要开始做判断了:由于模式串当中 pattern[j] != pattern[i-1],即G!=T,最长可匹配前缀子串仍然不存在。 所以当i=2时,j仍然是0,next[2] = 0。
  • 接下来,我们让已匹配前缀子串的长度继续加1:此时的已匹配前缀是GTG,由于模式串当中 pattern[j] = pattern[i-1],即G=G,最长可匹配前缀子串出现了,是G。 所以当i=3时,j=1,next[3] = next[2]+1 = 1。
  • 接下来,我们让已匹配前缀子串的长度继续加1:此时的已匹配前缀是GTGT,由于模式串当中 pattern[j] = pattern[i-1],即T=T,最长可匹配前缀子串又增加了一位,是GT。 所以当i=4时,j=2,next[4] = next[3]+1 = 2。
  • 接下来,我们让已匹配前缀子串的长度继续加1:

此时的已匹配前缀是GTGTG,由于模式串当中 pattern[j] = pattern[i-1],即G=G,最长可匹配前缀子串又增加了一位,是GTG。

所以当i=5时,j=3,next[5] = next[4]+1 = 3。

  • 接下来,我们让已匹配前缀子串的长度继续加1:

此时的已匹配前缀是GTGTGC,这时候需要注意了,模式串当中 pattern[j] != pattern[i-1],即T != C,这时候该怎么办呢?

这时候,我们已经无法从next[5]的值来推导出next[6],而字符C的前面又有两段重复的子串“GTG”。那么,我们能不能把问题转化一下?

或许听起来有些绕:我们可以把计算“GTGTGC”最长可匹配前缀子串的问题,转化成计算“GTGC”最长可匹配前缀子串的问题。

这样的问题转化,也就相当于把变量j回溯到了next[j],也就是j=1的局面(i值不变):

回溯后,情况仍然是 pattern[j] != pattern[i-1],即T!=C。那么我们可以把问题继续进行转化:

问题再次的转化,相当于再一次把变量j回溯到了next[j],也就是j=0的局面:

回溯后,情况仍然是 pattern[j] != pattern[i-1],即G!=C。j已经不能再次回溯了,所以我们得出结论:i=6时,j=0,next[6] = 0。

以上就是next数组元素的推导过程。

4.2.2、算法实现

最后来整理一下KMP算法的步骤:

  1. 对模式串预处理,生成next数组
  2. 进入主循环,遍历主串 2.1. 比较主串和模式串的字符 2.2. 如果发现坏字符,查询next数组,得到匹配前缀所对应的最长可匹配前缀子串,移动模式串到对应位置 2.3.如果当前字符匹配,继续循环
/**
 * @Author 三分恶
 * @Date 2020/9/10
 * @Description KMP字符串匹配算法
 */
public class KMP {

    public static int kmp(String str,String pattern){
        //预处理
        int [] next=getNext(pattern);
        int j=0;
        for (int i=0;i<str.length();i++){
            while (j>0&&str.charAt(i)!=pattern.charAt(j)){
                //遇到坏字符时,查询next数组并改变模式串的起点
                j=next[j];
            }
            if (str.charAt(i)==pattern.charAt(j)){
                j++;
            }
            //找到匹配串
            if (j==pattern.length()){
                return i-1;
            }
        }
        return -1;
    }

    /**
     * 获取next数组
     * @param pattern
     * @return
     */
    private static int [] getNext(String pattern){
       int next[]=new int[pattern.length()];
       int j=0;
       for (int i=2;i<pattern.length();i++){
           while (j!=0&&pattern.charAt(j)!=pattern.charAt(i-1)){
               //从next[i+1]的求解回溯到 next[j]
               j=next[j];
           }
           if (pattern.charAt(j)==pattern.charAt(i-1)){
               j++;
           }
           next[i] = j;
       }
       return next;
    }

    public static void main(String[] args) {
/*        String str = "ATGTGAGCTGGTGTGTGCFAA";
        String pattern = "GTGTGCF";*/
        String str = "ABABBABBAAB";
        String pattern = "ABB";
        int index = kmp(str, pattern);
        System.out.println("首次出现位置:" + index);
    }
}

4.2.2、算法时间复杂度

KMP算法包含两步,第一步生成next数组,时间复杂度估算为O(m);第二步是遍历主串,时间复杂度为O(n)。

因此,KMP算法的时间复杂度是O(m+n),其中m是模式串的长度,n是主串的长度。

4.3、BM算法

KMP已经很巧妙了,但是还有更巧妙的BM算法。

Boyer-Moore算法不仅效率高,而且构思巧妙,容易理解。1977年,德克萨斯大学的Robert S. Boyer教授和J Strother Moore教授发明了这种算法。

BM算法算法的构思是:不断自右向左地比较模式串P与主串T,一旦发现失配,则利用此前的扫描所提供的信息,将P右移一定距离,然后重新自右向左扫描比较。该算法有两种启发式策略⎯⎯借助坏字符( Bad Character)好后缀( Good Suffix)确定移动的距离⎯⎯也可将二者结合起来,同时采用。

4.3.1、坏字符规则

从上面的KMP中我们也知道了,“坏字符”就是失配的字符。

以下面的字符串为例:

  • 当模式串和主串的第一个等长子串比较时,子串的最后一个字符T就是坏字符:

当检测到第一个坏字符之后,我们并不需要让模式串一位一位向后挪动和比较。因为只有模式串与坏字符T对齐的位置也是字符T的情况下,两者才有匹配的可能。

  • 可以发现,模式串的第1位字符也是T,这样一来我们就可以对模式串做一次“乾坤大挪移”,直接把模式串当中的字符T和主串的坏字符对齐,进行下一轮的比较:

坏字符的位置越靠右,下一轮模式串的挪动跨度就可能越长,节省的比较次数也就越多。这就是BM算法从右向左检测的好处。

后移位数 = 坏字符的位置 - 模式串中的上一次出现位置

  • 接下来,我们继续逐个字符比较,发现右侧的G、C、G都是一致的,但主串当中的字符A,又是一个坏字符:
  • 按照刚才的方式,找到模式串的第2位字符也是A,于是我们把模式串的字符A和主串中的坏字符对齐,进行下一轮比较:
  • 接下来,我们继续逐个字符比较,这次发现全部字符都是匹配的,比较完成:

如果模式串中不存在和坏字符相同的字符怎么办?直接将模式串移动到坏字符的下一位即可:

代码实现:

public class BM {

    /**
     * 基于坏字符规则的BM算法
     * @param str
     * @param pattern
     * @return
     */
    public static int boyerMooreBadChar(String str,String pattern){
        int strLength = str.length();
        int patternLength = pattern.length();
        //模式串的起始位置
        int start = 0;
        //遍历主串
        while (start <= strLength - patternLength) {
           int i;
           //模式串从右往左比较
           for(i=patternLength-1;i>=0;i--){
               if (str.charAt(start+i) != pattern.charAt(i))
                   //发现坏字符,跳出比较,i记录了坏字符的位置
                   break;
           }
           //匹配成功
            if (i<0){
                return start;
            }
            //获取坏字符在模式串中对应的字符
            int charIndex=findCharacter(pattern,str.charAt(start+i),i);
            //计算移动位数
            int bcOffset = charIndex>=0 ? i-charIndex : i+1;
            //移动
            start+=bcOffset;
        }
        return -1;
    }

    /**
     * 在模式串中查找和坏字符相同的字符的位置
     * @param pattern
     * @param badCharacter
     * @param index
     */
    private static  int findCharacter(String pattern, char badCharacter, int index){
        //从右往左查找
        for(int i= index-1; i>=0; i--){
            if(pattern.charAt(i) == badCharacter){
                return i;
            }
        }
        //不存在返回-1
        return -1;
    }

    public static void main(String[] args) {
        String str = "GTTATAGCTGGTAGCGGCGAA";
        String pattern = "GCGAA";
        int index = boyerMooreBadChar(str, pattern);
        System.out.println("首次出现位置:" + index);
    }
}

4.3.2、好后缀规则

BM 算法的思想,是尽可能地利用此前已进行过的比较所提供的信息,以加速模式串的移动。

上述坏字符策略,就很好地体现了这一构思:既然已经发现 P[j]与 T[i+j]不匹配,就应该从 P 中找出一个与 T[i+j]匹配的字符,将二者对齐之后,重新自右向左开始比较。

然而,仔细分析后我们可以发现,坏字符规则只利用了此前(最后一次)失败的比较所提供的信息。实际上,在失败之前往往还会有一系列成功的比较,它们也能提供大量的信息,对此我们能否加以利用呢?

来看一组例子。

我们继续使用“坏字符规则”。

  • 从后向前比对字符,我们发现后面三个字符都是匹配的,到了第四个字符的时候,发现坏字符G:
  • 接下来我们在模式串找到了对应的字符G,但是按照坏字符规则,模式串仅仅能够向后挪动一位:

这时候坏字符规则显然并没有起到作用,为了能真正减少比较次数,轮到我们的好后缀规则出场了。

  • 我们回到第一轮的比较过程,发现主串和模式串都有共同的后缀“GCG”,这就是所谓的“好后缀”。 如果模式串其他位置也包含与“GCG”相同的片段,那么我们就可以挪动模式串,让这个片段和好后缀对齐,进行下一轮的比较:

 后移位数 = 好后缀的位置 - 搜索词中的上一次出现位置

再举一个例子,字符串"ABCDAB"的后一个"AB"是"好后缀"。那么它的位置是5(从0开始计算,取最后的"B"的值),在"搜索词中的上一次出现位置"是1(第一个"B"的位置),所以后移 5 - 1 = 4位,前一个"AB"移到后一个"AB"的位置。

如果没有“好后缀”呢?字符串"ABCDEF"的"EF"是好后缀,则"EF"的位置是5 ,上一次出现的位置是 -1(即未出现),所以后移 5 - (-1) = 6位,即整个字符串移到"F"的后一位。

好后缀有3个需要注意的点:

  • (1)"好后缀"的位置以最后一个字符为准。假定"ABCDEF"的"EF"是好后缀,则它的位置以"F"为准,即5(从0开始计算)。
  • (2)如果"好后缀"在搜索词中只出现一次,则它的上一次出现位置为 -1。比如,"EF"在"ABCDEF"之中只出现一次,则它的上一次出现位置为-1(即未出现)。
  • (3)如果"好后缀"有多个,则除了最长的那个"好后缀",其他"好后缀"的上一次出现位置必须在头部。比如,假定"BABCDAB"的"好后缀"是"DAB"、"AB"、"B",请问这时"好后缀"的上一次出现位置是什么?回答是,此时采用的好后缀是"B",它的上一次出现位置是头部,即第0位。这个规则也可以这样表达:如果最长的那个"好后缀"只出现一次,则可以把搜索词改写成如下形式进行位置计算"(DA)BABCDAB",即虚拟加入最前面的"DA"。

那么在什么时候用“坏字符”,什么时候用“好后缀”呢?

Boyer-Moore算法的基本思想是,每次后移这两个规则之中的较大值。

算法实现:

/**
 * @Author 三分恶
 * @Date 2020/9/15
 * @Description BM算法
 */
public class BM {

    /**
     * 基于坏字符规则的BM算法
     *
     * @param str
     * @param pattern
     * @return
     */
    public static int boyerMoore(String str, String pattern) {
        int strLength = str.length();
        int patternLength = pattern.length();
        //模式串的起始位置
        int start = 0;
        //遍历主串
        while (start <= strLength - patternLength) {
            int i;
            //模式串从右往左比较
            for (i = patternLength - 1; i >= 0; i--) {
                if (str.charAt(start + i) != pattern.charAt(i))
                    //发现坏字符,跳出比较,i记录了坏字符的位置,坏字符后都是好后缀
                    break;
            }
            //匹配成功
            if (i < 0) {
                return start;
            }
            //获取坏字符在模式串中对应的字符
            int charIndex = findBadChar(pattern, str.charAt(start + i), i);
            //计算坏字符规则移动位数
            int badOffset = charIndex >= 0 ? i - charIndex : i + 1;
            //计算好后缀移动规则
            int goodOffset=findGoodSuffix(pattern,i);
            int offset=badOffset>=goodOffset?badOffset:goodOffset;
            //移动
            start += offset;
        }
        return -1;
    }

    /**
     * 在模式串中查找和坏字符相同的字符的位置
     * @param pattern
     * @param badCharacter
     * @param index
     */
    private static int findBadChar(String pattern, char badCharacter, int index) {
        //从右往左查找
        for (int i = index - 1; i >= 0; i--) {
            if (pattern.charAt(i) == badCharacter) {
                return i;
            }
        }
        //不存在返回-1
        return -1;
    }

    /**
     * 应用好后缀规则计算移动位数
     * @param pattern
     * @param goodCharSuffix
     * @return
     */
    private static int findGoodSuffix(String pattern,int goodCharSuffix) {
        int result = -1;
        // 模式串长度
        int moduleLength = pattern.length();
        // 好字符数
        int goodCharNum = moduleLength -1 - goodCharSuffix;

        for(;goodCharNum > 0; goodCharNum--){
            String endSection = pattern.substring(moduleLength - goodCharNum, moduleLength);
            String startSection = pattern.substring(0, goodCharNum);
            if(startSection.equals(endSection)){
                result = moduleLength - goodCharNum;
            }
        }

        return result;
    }

    public static void main(String[] args) {
        String str = "GTTATAGCTGGTAGCGGCGAA";
        String pattern = "GCGAA";
        int index = boyerMoore(str, pattern);
        System.out.println("首次出现位置:" + index);
    }
}