字符串搜索算法:BF算法 RK算法 BM算法 KMP算法 Trie树 AC自动机

BF算法(字符串搜素算法)
暴力匹配算法
主串长度n,模式串长度m。
时间复杂度是O(n*m)
在这里插入图片描述

RK算法(字符串搜素算法)
通过哈希算法对主串中的n-m+1个子串分别求哈希值,然后逐个与模式串的哈希值比较大小。如果某个子串的哈希值与模式串相等,那就说明对应的子串和模式串匹配了(这里先不考虑哈希冲突的问题,后面我们会讲到)。因为哈希值是一个数字,数字之间比较是否相等是非常快速的,所以模式串和子串比较的效率就提高了。
时间复杂度:O(n),如果哈希大量冲突,会退化成O(n*m)
哈希算法:
用一个 K 进制数来表示一个子串,这个 K 进制数转化成十进制数,作为子串的哈希值。
这样在计算字串的时候,和前一个字串有交集的地方。

在这里插入图片描述
在这里插入图片描述
这种哈希算法不会造成哈希冲突,但是数据会比较大,我们可以换一些简单的方法,如:映射后单纯的数字相加(哈希冲突概率高),或者每个字符对应一个素数。
在哈希冲突的情况下,先对比字符串的哈希值,不一样则不匹配,哈希一样,再比对字符穿。

BM算法(字符串搜素算法,基于上面两种的优化)
当模式串和主串某个字符不匹配的时候,能够跳过一些肯定不会匹配的情况,将模式串往后多滑动几位。
时间复杂度O(n/m)
1.坏字符规则
按照模式串下标从大到小的顺序,倒着匹配的。
当我们发现某个字符没法匹配的时候。我们把这个没有匹配的字符叫作坏字符(主串中的字符)。
极客时间版权所有: https://time.geekbang.org/column/article/71525#previewimg
当发生不匹配的时候,我们把坏字符对应的模式串中的字符下标记作si。如果坏字符在模式串中存在,我们把这个坏字符在模式串中的下标记作 xi(选择最后那个)。如果不存在,我们把 xi 记作 -1。那模式串往后移动的位数就等于 si-xi。(注意,我这里说的下标,都是字符在模式串的下标)。
在这里插入图片描述
2.好后缀规则
(1)在模式串中,查找跟好后缀匹配的另一个子串;
(2)在好后缀的后缀子串中,查找最长的、能跟模式串前缀子串匹配的后缀子串;
在这里插入图片描述
我们把已经匹配的 bc 叫作好后缀,记作{u}。我们拿它在模式串中查找,如果找到了另一个跟{u}相匹配的子串{u*},那我们就将模式串滑动到子串{u*}与主串中{u}对齐的位置。
在这里插入图片描述
如果在模式串中找不到另一个等于{u}的子串,我们就直接将模式串,滑动到主串中{u}的后面,因为之前的任何一次往后滑动,都没有匹配主串中{u}的情况。
但是这里需要注意的是,{u}没有与模式串的匹配,但是{u}中的某个字符可能和模式串的前面的某些字符匹配,所以不能过度滑动。
在这里插入图片描述
所以在坏字符和好后缀之间,滑动的位数应该取决于两种规则中后移位数最大那个。

坏字符的代码实现:

 private static final int SIZE = 256;
    /**
     *
     * @param b  模式串
     * @param m  模式串长度
     * @param bc 字符存储的位置
     */
    private void generateBC(char[] b,int m,int[] bc){
        for(int i = 0;i<SIZE;i++){
            bc[i] = -1; //初始化
        }
        for(int i = 0;i<m;i++){
            int ascii = (int)b[i];//计算b[i] 的ASCII值
            bc[ascii] = i;//存储出现的位置
        }
    }
    /**
     *
     * @param a   主串
     * @param n   主串长度
     * @param b   模式串
     * @param m   模式串长度
     * @return
     */
    public int bm(char[] a,int n,char[] b,int m){
        int[] bc = new int[SIZE];//记录模式串中每个字符最后出现的位置
        generateBC(b,m,bc);//构建坏字符哈希表
        int[] suffix = new int[m];
        boolean[] prefix = new boolean[m];
        generateGS(b,m,suffix,prefix);
        int i =0;  //i表示主串与模式串对齐的第一个字符
        while(i<=n-m){
            int j;
            for( j = m-1;j>= 0 ;j--){   //模式串从后往前匹配
                if(a[i+j] != b[j]){
                    break; //坏字符对应模式串中的下标是j
                }
            }
            if(j<0){
                return i; //匹配成功,返回主串与模式串第一个匹配的字符的位置
            }
            int x = j-bc[(int) a[i+j]];
            int y = 0;
            if(j<m-1){  //如果有好后缀的话
                y = moveByGS(j,m,suffix,prefix);
            }
            //这里等同于将模式串往后滑动j-bc[(int)a[i+j]]位
            i = i+Math.max(x,y);
        }
        return -1;
    }
     /**
     *
     * @param j 表示坏字符对应的模式串中的字符下标
     * @param m 表示模式串长度
     * @param suffix
     * @param prefix
     * @return
     */
    private int moveByGS(int j, int m, int[] suffix, boolean[] prefix) {
        int k = m-1-j;  //好后缀长度
        if(suffix[k]!=-1){
            return j-suffix[k]+1;
        }
        for(int r = j+2;r<=m-1;r++){
            if(prefix[m-r] == true){
                return r;
            }
        }
        return  m;

    }

在这里插入图片描述
好后缀代码实现:
suffix 数组:suffix 数组的下标 k,表示后缀子串的长度,下标对应的数组值存储的是,在模式串中跟好后缀{u}相匹配的子串{u*}的起始下标值。模式串中有多个(大于 1 个)子串跟后缀子串{u}匹配,要存储模式串中最靠后的那个子串的起始位置。
在这里插入图片描述
我们还需要另外一个 boolean 类型的 prefix 数组,来记录模式串的后缀子串是否能匹配模式串的前缀子串。
如果存储的位置是0,表明公共后缀子串也是模式串的前缀子串,我们就记录 prefix[k]=true。
在这里插入图片描述

/**
     *
     * @param b 模式串
     * @param m 模式串长度
     * @param suffix 后缀子串的长度,下标对应的数组值存储的是,在模式串中跟好后缀{u}相匹配的子串{u*}的起始下标值。
     * @param prefix 记录模式串的后缀子串是否能匹配模式串的前缀子串。
     */
    private void generateGS(char[] b,int m,int[] suffix,boolean[] prefix){
        for(int i = 0;i<m;i++){//初始化
            suffix[i] = -1;
            prefix[i] = false;
        }
        for(int i = 0;i<m-1;i++){ //b[0,i]
            int j = i;
            int k = 0;  //公共后缀子串长度
            while(j>=0&&b[j] == b[m-1-k]){ //与b[0,m-1]求公共后缀字串
                --j;
                ++k;
                suffix[k] = j+1; //j+1 表示公共后缀字串b[0,i]中的起始下标
            }
            if(j == -1 ){
                prefix[k] = true;  //如果公共后缀子串也是模式串的前缀子串
            }
        }
    }

如果 suffix[k] 不等于 -1(-1 表示不存在匹配的子串),那我们就将模式串往后移动 j-suffix[k]+1 位(j 表示坏字符对应的模式串中的字符下标)。如果 suffix[k] 等于 -1,表示模式串中不存在另一个跟好后缀匹配的子串片段。
在这里插入图片描述
好后缀的后缀子串 b[r, m-1]其中,r 取值从 j+2 到 m-1)的长度 k=m-r,如果 prefix[k] 等于 true,表示长度为 k 的后缀子串,有可匹配的前缀子串,这样我们可以把模式串后移 r 位。
在这里插入图片描述
如果两条规则都没有找到可以匹配好后缀及其后缀子串的子串,我们就将整个模式串后移 m 位。
在这里插入图片描述

KMP算法(字符串搜素算法)
时间复杂度O(n+m)
KMP 算法就是在试图寻找一种规律:在模式串和主串匹配的过程中,当遇到坏字符后,对于已经比对过的好前缀,能否找到一种规律,将模式串一次性滑动很多位。

我们只需要拿好前缀本身,在它的后缀子串中,查找最长的那个可以跟好前缀的前缀子串匹配的。假设最长的可匹配的那部分前缀子串是{v},长度是 k。我们把模式串一次性往后滑动 j-k 位,相当于,每次遇到坏字符的时候,我们就把 j 更新为 k,i不变,然后继续比较。
在这里插入图片描述
先构建一个数组,用来存储模式串中每个前缀(这些前缀都有可能是好前缀)的最长可匹配前缀子串的结尾字符下标。我们把这个数组定义为next 数组,叫失效函数(failure function)。
数组的下标是每个前缀结尾字符下标,数组的值是这个前缀的最长可以匹配前缀子串的结尾字符下标。
在这里插入图片描述
在这里插入图片描述

/**
 * KMP算法
 */
public class KMP_StringSort {
    /**
     *
     * @param a 主串
     * @param n 主串长度
     * @param b 模式串
     * @param m 模式串长度
     * @return
     */
    public static int kmp(char[] a,int n,char[] b,int m){
        int[] next = getNexts(b,m);
        int j = 0;
        for(int i = 0;i<n;i++){
            while(j>0&& a[i]!=b[j]){  //一直找到a[i]和b[j]
                j = next[j-1] +1;
            }
            if(a[i]==b[j]){
                ++j;
            }
            if(j==m){ //找到匹配模式字符串的了
                return i-m+1;
            }
        }
        return -1;
    }

    /**
     *失效函数
     * @param b 模式串
     * @param m 模式串长度
     * @return
     */
    private static int[] getNexts(char[] b, int m) {
        int[] next = new int[m];
        next[0] = -1;
        int k = -1;
        for(int i = 1;i<m;i++){
            while(k != -1 && b[k+1] !=b[i]){
                k = next[k];
            }
            if(b[k+1] == b[i]){
                ++k;
            }
            next[i] = k;
        }
        return next;
    }

Trie树
“字典树” 专门处理字符串前缀匹配的数据结构。
Trie 树的本质,就是利用字符串之间的公共前缀,将重复的前缀合并在一起。
在这里插入图片描述

假设字符串只有a到z的26个小写字母,可以用长度为26的数组存储它们,0位置指向a,不存在就存储null。
但是消耗的内存会很大。

class TrieNode {
    public char data;
    public TrieNode[] children = new TrieNode[26];
    public boolean isEndingChar = false;
    public TrieNode(char data){
        this.data = data;
    }
}

构建Trie树的时间复杂度是O(n),n表述所有字符串的长度和。
查找:可以通过字符的ASCII码减去‘a’的ASCII码,得到的就是数组的下标。时间复杂度是O(k),k表示字符串的长度。

public class Trie {
    private TrieNode root = new TrieNode('/');//存储无意义字符
    
    //往Trie树中插入一个字符串
    public void insert(char[] text){
        TrieNode p = root;
        for(int i = 0;i<text.length;i++){
            int index = text[i] -'a';
            if(p.children[index] == null){
                TrieNode newNode = new TrieNode(text[i]);
                p.children[index] = newNode;
            }
            p = p.children[index];
        }
        p.isEndingChar = true;
    }
    
    //往Trie树中查找一个字符串
    public boolean find(char[] pattern){
        TrieNode p = root;
        for(int i =0;i<pattern.length;i++){
            int index = pattern[i]-'a';
            if(p.children[index] == null){
                return false;
            }
            p = p.children[index];
        }
        if(p.isEndingChar==false){
            return false;//不能完全匹配,只是前缀啊
        }else{
            return true;
        }
    }
}

改良内存消耗的问题:
1 可以将存储的数据换成有序数组,跳表,散列,红黑树等,动态插入,只是查找的时候时间复杂度会边高一点,而不是原来的O(k)。
2 可以进行缩点优化,即对只有一个子节点的节点,而且次节点不是一个串的结束节点,可以将此节点与子节点合并。

Trie树的缺点:
1 字符串中包含的字符集不能太大,会浪费很多存储空间。
2 要求字符串的前缀重合比较多,不然也会浪费很多存储空间。
3 如果用Trie树解决问题,需要自己从0开始实现一个Trie树。
4 存储的引用是指针,数据块不连续,对缓存不友好。

AC自动机:
为了快速在主串中查找多个模式串。
将多个模式串构建成 Trie 树;
在 Trie 树上构建失败指针(相当于 KMP 中的失效函数next 数组)。

构建失败指针(相当于KMP算法中的next失效函数)
时间复杂度O(k*len) k是树中总的节点个数,len是树的高度
每一个节点都有一个失败指针
Trie 树走到 p 节点,那 p 的失败指针就是从 root 走到p节点形成的字符串 abc,跟所有模式串前缀匹配的最长可匹配后缀子串,就是箭头指的 bc 模式串。
我们通过已经求得的、深度更小的那些节点的失败指针来推导。也就是说,我们可以逐层依次来求解每个节点的失败指针所以,失败指针的构建过程,是一个按层遍历树的过程。
寻找子节点的失败指针:我们看节点 p 的子节点 pc 对应的字符,是否也可以在节点 q 的子节点中找到。如果找到了节点 q 的一个子节点 qc,对应的字符跟节点 pc 对应的字符相同,则将节点 pc的失败指针指向节点 qc。如果节点 q 中没有子节点的字符等于节点 pc 包含的字符,,则令 q=q->fail,继续上面的查找,直到 q 是 root 为止,如果还没有找到相同字符的子节点,就让节点 pc 的失败指针指向 root。

在这里插入图片描述
在这里插入图片描述

匹配查询:
时间复杂度O(n)
在匹配过程中,主串从 i=0 开始,AC 自动机从指针 p=root 开始,假设模式串是 b,主串是 a。
1 如果 p 指向的节点有一个等于 b[i] 的子节点 x,我们就更新 p 指向 x,这个时候我们需要通过失败指针,检测一系列失败指针为结尾的路径是否是模式串。处理完之后,我们将 i 加一,继续这两个过程;
2 如果 p 指向的节点没有等于 b[i] 的子节点,那失败指针就派上用场了,我们让 p=p->fail,然后继续这 2 个过程。

public class AcNode {
    public char data;
    public AcNode[] childer = new AcNode[26];
    public boolean isEndingChar = false;//结尾字符
    public int length = -1;//当isEndingChar=true时,记录模式串长度
    public AcNode fail;//失败指针
    public AcNode(char data){
        this.data = data;
    }
}

class AcTree{
  /**
     * 建立失败指针
     */
    public void buildFailurePointer() {
        Queue<AcNode> queue = new LinkedList<>();
        root.fail = null;
        queue.add(root);
        while(!queue.isEmpty()){
            AcNode p = queue.remove();
            for(int i =0;i<26;i++){
                AcNode pc = p.children[i];
                if(pc == null){
                    continue;
                }
                if(p == root){
                    pc.fail = root;
                }else{
                    AcNode q = p.fail;
                    while(q!=null){
                        AcNode qc = q.children[pc.data-'a'];
                        if(qc!=null){
                            pc.fail = qc;
                            break;
                        }
                        q = q.fail;
                    }
                    if(q== null){
                        pc.fail = root;
                    }
                }
                queue.add(pc);
            }
        }
    }

    /**
     * 匹配模式串
     * @param text
     */
    public void match(char[] text){  //text是主串
        int n = text.length;
        AcNode p = root;
        for(int i =0;i<n;i++){
            int idx = text[i]-'a';
            while(p.children[idx] == null && p != root){
                p = p.fail;  //失败指针发挥作用的地方。
            }
            p = p.children[idx];
            if(p==null){
                p = root;//如果没有匹配,从root开始重新匹配
                AcNode tmp = p;
                while(tmp != root){  //打印出可以匹配的模式串
                    if(tmp.isEndingChar == true){
                        int pos = i-tmp.length+1;
                        System.out.println("匹配其实下标"+pos+"; 长度"+tmp.length);
                    }
                    tmp = tmp.fail;
                }
            }
        }
    }
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值