字符串匹配问题 ----- KMP算法

题意:
  • 任意给定一段字符串str(“123abc123abc00abc”)
  • 再输入一个关键字key(“abc”)
  • 要求返回str中包含key的所有子串的头下标
解法1:暴力法(双指针,不使用String类的substring)

思路:

  • 建立一个滑动窗口
  • 建立两个指针p1,p2:p1指针扫描滑动窗口中的每个字符,p2指针扫描key串中的每个字符
    public static ArrayList<Integer> match(String str, String key) {
        ArrayList<Integer> list = new ArrayList<>();

        for (int startIndex = 0; startIndex < str.length(); startIndex++) {
            int endIndex;
            if ((endIndex = startIndex + key.length() - 1) > str.length() - 1) break;
            int p1 = startIndex;
            int p2 = 0;
            while (p2 < key.length() && str.charAt(p1) == key.charAt(p2)) {
                p1++;
                p2++;
            }
            if (p2 == key.length()) list.add(startIndex);
        }
        return list;
    }
解法2:KMP算法
一、写在前面
  1. 关于字符串的前缀和后缀

    如果字符串A和B,存在A=BS,其中S是任意的非空字符串,那就称B为A的前缀。例如,”Harry”的前缀包括{”H”, ”Ha”, ”Har”, ”Harr”},我们把所有前缀组成的集合,称为字符串的前缀集合。同样可以定义后缀A=SB, 其中S是任意的非空字符串,那就称B为A的后缀,例如,”Potter”的后缀包括{”otter”, ”tter”, ”ter”, ”er”, ”r”},然后把所有后缀组成的集合,称为字符串的后缀集合。要注意的是,字符串本身并不是自己的后缀。

  2. 关于next数组

    1. 数组长度等于key串长度
    2. 数组中元素的确定:next[i] = 从key串的下标0开始,长度为i的子串的前缀集和后缀集的交集中最长元素的长度,例如子串’‘abcdabc’'就是 next[7] = 3,表示长度为7的子串的前缀集和后缀集的交集中最长元素的长度为3。这里规定next[0] = -1, next[1] = 0

推荐的参考文章和视频(像我这种笨比都看得懂的):
b站视频:KMP算法原理
文章:如何更好地理解和掌握 KMP 算法?


二、如何求出next数组
  1. 求next数组之前我们先求出pmt数组,next数组就是通过pmt数组所有元素右移一位后,令pmt[0] = -1得到的。

  2. 那么如何求出pmt数组呢?

    index   =   0  1  2  3  4  5  6  7  8
    pattern = "[A][B][A][B][C][A][B][A][A]"
    PMT     =  [0][0][1][2][0][1][2][3][1]
    next    = [-1][0][0][1][2][0][1][2][3]
    

    计算方法:从0~i截取pattern字符串(i = 0,1,2…pattern.length-1),然后求出每次截取子串的最大公共前后缀(不包含本身)长度
    例如:

    1. 当i=0时,截取子串为"A",最大公共前后缀长度为0,PMT[0]=0
    2. 当i=1时,截取子串为"AB",最大公共前后缀长度为0,PMT[1]=0
    3. 当i=2时,截取子串为"ABA",最大公共前后缀长度为1,PMT[2]=1
    4. 当i=3时,截取子串为"ABAB",最大公共前后缀长度为2,PMT[3]=2
    5. 当i=4时,截取子串为"ABABC",最大公共前后缀长度为0,PMT[4]=0
    6. 当i=5时,截取子串为"ABABCA",最大公共前后缀长度为1,PMT[5]=1
    7. 当i=6时,截取子串为"ABABCAB",最大公共前后缀长度为2,PMT[6]=2
    8. 当i=7时,截取子串为"ABABCABA",最大公共前后缀长度为3,PMT[7]=3
    9. 当i=8时,截取子串为"ABABCABAA",最大公共前后缀长度为1,PMT[8]=1
  3. 代码实现
    第一种:自己想出来的笨比方法

    // 思路:
    // PMT[0] = 0
    // i指针从下标1开始截取pattern
    // 建立两个单边扩展窗口
    // window1: 左顶点为定为0,右顶点扩展,len为窗口长度,从1扩展到i,startIndex1=0,endIndex1=startIndex1+len-1
    // window2: 右顶点定为i,左顶点进行扩展,len为窗口长度,从1扩展到i,endIndex2=i,startIndex2=endIndex2-len+1
    // 这两个窗口的作用: window1扫描截取子串的前缀,window2扫描截取子串的后缀,找到前缀与后缀的最大匹配长度
    // maxLen: 记录最大公共前后缀长度
        public static int[] getNext(String pattern) {
            int[] PMT = new int[pattern.length()];
            PMT[0] = 0;
    
            for (int i = 1; i < pattern.length(); i++) {
                int maxLen = 0;
                // 两个扫描窗口的长度默认为1
                int len = 1;
                // window1
                int startIndex1 = 0;
                int endIndex1 = startIndex1 + len - 1;
    
                // window2
                int endIndex2 = i;
                int startIndex2 = endIndex2 - len + 1;
    
                while (len <= i) {
                	// 如果两个窗口扫描到的前后缀匹配则更新maxLen
                    if (pattern.substring(startIndex1, endIndex1 + 1).equals(pattern.substring(startIndex2, endIndex2 + 1))) {
                        maxLen = len;
                    }
    				
    				// 两个窗口进行扩展
                    len++;
                    endIndex1 = startIndex1 + len - 1;
                    startIndex2 = endIndex2 - len + 1;
                }
                PMT[i] = maxLen;
            }
            return move(PMT);
        }
    
        // 把pmt数组右移一位
        public static int[] move(int[] pmt) {
            System.arraycopy(pmt, 0, pmt, 1, pmt.length - 1);
            pmt[0] = -1;
            return pmt;
        }
    

    第二种:KMP算法中使用的方法,复杂度比上面低,但是有点难懂

        public static int[] getNext(String pattern) {
            // next数组保存的是i指针前面的子串的最长公共前后缀的长度(包括i当前字符)
            // i同时也对应其下标
            int[] PMT = new int[pattern.length()];
            PMT[0] = 0;
            PMT[1] = 0;
    
            // 扫描模式串
            int i = 1;
    
            // i指针前面的子串的最长公共前后缀的长度(不包括i当前字符)
            // 同时也表示最长公共前后缀中的前缀的后一位字符下标
            int len = 0;
            while (i < pattern.length()) {
    
                // 如果i指针下的字符与最长公共前后缀中的前缀的后一个字符相同
                // 那么next数组此位置记录的最长公共前后缀的长度=之前的+1
                if (pattern.charAt(i) == pattern.charAt(len)) {
                    PMT[i++] = ++len;
                } else {
    
                    // 谁能告诉我这里为什么要这么写QAQ,
                    if (len > 0) {
                        len = PMT[len - 1];
                    } else {
                        PMT[i++] = 0;
                    }
                }
            }
            return move(PMT);
        }
        
        // 把pmt数组右移一位
        public static int[] move(int[] pmt) {
            System.arraycopy(pmt, 0, pmt, 1, pmt.length - 1);
            pmt[0] = -1;
            return pmt;
        }
    

三、实现KMP算法
  1. 当我们求出模式串的next数组后,KMP算法差不多就实现了一大半了,next数组中存储了前后缀哪里相同的信息,用来减少比较次数,i就不用回退了,下面来说说如何利用next数组实现比暴力法更快的字符串匹配。

    index	      0  1  2  3  4  5  6  7  8  9  10 11
    源串T:		 [a][b][a][a][c][a][b][a][b][c][a][c]
    模式串P:		 [a][b][a][b][c] (第一次匹配:T在3处失配,P在3处失配,next对应1,则把P的1处位置移到T的失配处3)
    index	      0  1  2  3  4
    next	     -1  0  0  1  2
    
    
     0  1  2  3  4  5  6  7  8  9  10 11
    [a][b][a][a][c][a][b][a][b][c][a][c]
    	  [a][b][a][b][c] (第二次匹配:T在3处失配,P在1处失配,next对应0,则把P的0处位置移到T的失配处3)
    	   0  1  2  3  4
    	  -1  0  0  1  2
    
     0  1  2  3  4  5  6  7  8  9  10 11
    [a][b][a][a][c][a][b][a][b][c][a][c]
    		 [a][b][a][b][c] (第三次匹配:T在4处失配,P在1处失配,next对应0,则把P的0处位置移到T的失配处4)
    		  0  1  2  3  4
    		 -1  0  0  1  2
    
    
     0  1  2  3  4  5  6  7  8  9  10 11
    [a][b][a][a][c][a][b][a][b][c][a][c]
    		    [a][b][a][b][c] (第四次匹配:T在4处失配,P在0处失配,next对应-1,则把P的-1处位置移到T的失配处4 --- 不存在-1下标这里就是把P右移一位)
    		     0  1  2  3  4
    		    -1  0  0  1  2
    
    
     0  1  2  3  4  5  6  7  8  9  10 11
    [a][b][a][a][c][a][b][a][b][c][a][c]
    		       [a][b][a][b][c] (第五次匹配:匹配成功!记录T下标5,T在9处匹配结束,P在4处匹配结束,next对应2,则把P的2处位置移到T的9处)
    		        0  1  2  3  4
    		       -1  0  0  1  2
    
     0  1  2  3  4  5  6  7  8  9  10 11
    [a][b][a][a][c][a][b][a][b][c][a][c]
    		             [a][b][a][b][c] (第六次匹配:T在9处失配,P在2处失配,next对应0,则把P的0处位置移到T的失配处9)
    		              0  1  2  3  4
    		             -1  0  0  1  2
    
     0  1  2  3  4  5  6  7  8  9  10 11
    [a][b][a][a][c][a][b][a][b][c][a][c]
    		                   [a][b][a][b][c] (P越界,不用再进行后续匹配)
    		                    0  1  2  3  4
    		                   -1  0  0  1  2
    		  
    
  2. 代码实现

        public static ArrayList<Integer> kmp_search(String str, String pattern) {
            int[] next = getNext(pattern);
            ArrayList<Integer> list = new ArrayList<>();
    
            // 窗口左端点
            int startIndex = 0;
            // 窗口右端点
            int endIndex = startIndex + pattern.length() - 1;
            // p1扫描str, p2扫描pattern
            int p1 = 0;
            int p2 = 0;
    
            while (endIndex < str.length()) {
                while (p1 <= str.length() - 1 && p2 <= pattern.length() - 1 && str.charAt(p1) == pattern.charAt(p2)) {
                    p1++;
                    p2++;
                }
    
                // 如果str窗口内的子串与pattern匹配则记录窗口左端点
                if (p2 > pattern.length() - 1) {
                    list.add(startIndex);
                    System.out.println(startIndex);
                    p1--;
                    p2--;
    
                    // 更新窗口位置:
                    // p2向左移动p2 - next[p2]步,那么窗口要向右移动p2 - next[p2]步,保证p1与p2对齐
                    startIndex += p2 - next[p2];
                    endIndex = startIndex + pattern.length() - 1;
    
                    // 更新p2位置
                    p2 = next[p2];
                } else {
                    // 当前窗口内的子串与pattern不匹配:
    
                    // 1. 失配处在pattern第一个字符,则窗口和p1同时右移一位
                    if (next[p2] == -1) {
                        startIndex++;
                        endIndex = startIndex + pattern.length() - 1;
                        p1++;
    
                    } else {
                        // 2. 失配处不在pattern第一个字符,则p2移动到next[p2]位置,窗口右移p2 - next[p2]步,保证p1与p2对齐
                        // |当前下标 - 移动后的下标| = 移动步数
                        startIndex += p2 - next[p2];
                        endIndex = startIndex + pattern.length() - 1;
                        p2 = next[p2];
                    }
                }
            }
            return list;
        }
    
        public static int[] getNext(String pattern) {
            // next数组保存的是i指针前面的子串的最长公共前后缀的长度(包括i当前字符)
            // i同时也对应其下标
            int[] PMT = new int[pattern.length()];
            PMT[0] = 0;
            PMT[1] = 0;
    
            // 扫描模式串
            int i = 1;
    
            // i指针前面的子串的最长公共前后缀的长度(不包括i当前字符)
            // 同时也表示最长公共前后缀中的前缀的后一位字符下标
            int len = 0;
            while (i < pattern.length()) {
    
                // 如果i指针下的字符与最长公共前后缀中的前缀的后一个字符相同
                // 那么next数组此位置记录的最长公共前后缀的长度=之前的+1
                if (pattern.charAt(i) == pattern.charAt(len)) {
                    PMT[i++] = ++len;
                } else {
    
                    //
                    if (len > 0) {
                        len = PMT[len - 1];
                    } else {
                        PMT[i++] = 0;
                    }
                }
            }
            return move(PMT);
        }
    
        // 把pmt数组右移一位
        public static int[] move(int[] pmt) {
            System.arraycopy(pmt, 0, pmt, 1, pmt.length - 1);
            pmt[0] = -1;
            return pmt;
        }
    

    大致的核心思路:

    1. 源串T上建立一个滑动窗口
    2. 建立两个扫描指针:p1扫描源串T,p2扫描模式串P
    3. 模式串放在滑动窗口内(逻辑上帮助理解)
    4. 当指针p2移动到next[p2]位置处时,相当于左移动了p2 - next[p2]步
    5. 让窗口右移p2 - next[p2]步,始终保持p1与p2齐平
    6. 只要源串T被滑动窗口截取的子串与模式串P匹配,则记录滑动窗口左端点即可


收获
  1. 在数组中,指针移动可以看作其在一个数轴的正方向移动,移动步数 = |移动后的下标 - 起始下标|
  2. 数组的位移可以使用:System.arraycopy(原数组, 移动的起始下标, 原数组, 移动后的起始下标, 数组长度 - 移动步数)
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值