数据结构与算法-- 暴力法查找子字符串

数据结构与算法--子字符串查找

字符串的一种基本操作就是子字符串查找了,给定一段长度为N的文本字符串(主串)和长度为M的模式字符串(子串),在文本中找到和模式相符的子字符串,返回子字符串开头在主串中的索引。如果不存在和模式匹配的子字符串则按照惯例返回-1。

ABCDABCE中查找ABCD,那么应该返回4。ABCDABCE中查找ABCDE找不到,返回-1。

有一种思路很容易想到,比较“暴力”。

暴力法查找子字符串

该方法首先将子串p和主串t在索引0处对齐——即比较它们的第一个字符p[0]和t[0]相不相同。如果相同,同时将p和t的索引增大1,比较下一位字符p[1]和t[1]是否相等......当然期间会遇到主串和子串某个字符不相同的情况,这种情况称为“失配”,该处称为“失配位置”。一旦失配,不再比较之后的字符,将子串整体向后移动一位,此时p[0]与t[1]对齐,再次比较p[0]是否与t[1]相同,比如在第5个字符处失配了,比较p[0]和t[2]是否相同...重复以上操作。可以看到,每次失配,子串索引始终回退到0。而主串的索引回到上轮比较中主串开始比较处的下一位

上面是用通俗的语言来描述,如果将其转化为代码实现,像“将子字符串往后移一位”,“将p[0]与t[1]对齐”这样的操作,其实是为了帮助理解,实际上我们从未移动子字符串,只是错位比较而已——当我们比较p[0]和t[1]时,我们的眼睛不太习惯错位比较,我们可以人为的将字符串向后移动,让p[0]与t[1]对齐了,这更方便观察。所以,上面的说法只是为了方便人的理解,机器则不需要这些“人为加上去的操作”。我们先给出代码实现,下面结合着图来说明。

package Chap5;

public class BFSearch {
    public static int search(String p, String t) {
        int N = t.length();
        int M = p.length();
        int i = 0; // 主串的索引
        int j = 0; // 字串的索引
      
        // 若没到任一字符串的末尾,循环之
        while (i < N && j < M) {
            // 字符相同时,索引都加1
            if (p.charAt(j) == t.charAt(i)) {
                i++;
                j++;
            } else {
                i = i - j + 1; // 这句是关键
                j = 0;
            }
        }
        // 跳出循环的时候不是i == N(没找到)就是j == M(找到)
        if (j == M) {
            return i - j;
        }
        else {
            return -1;
        }
    }

    public static void main(String[] args) {
        int index = BFSearch.search("good", "gootgoodgoopt");
        System.out.println(index); // 4
    }
}

上面有句i = i - j + 1,这是失配后i应该回退的位置,为什么是这个值呢?说可能说不清楚,动笔画一画就一目了然。ij表示的是失配时,主串的位置和子串的位置。不管失配处之前有几个字符匹配成功了,或者第一个字符就失配,i - j的含义始终是本轮匹配中主串的开始比较处(与p[0]比较)的索引,那么i - j + 1就表示下轮匹配中主串的开始比较处的索引。既然理解了i - j的含义,当模式匹配成功时,也应该返回的是i - j —— 子字符串开头在主串中的索引。

ABCABCABX
ABX

比如上面失配位置i = j = 2则下轮匹配应该让p[0]和t[1]比较,如下图。i - j确实是本轮比较中,主串开始比较处的索引0,下轮比较从i - j + 1 = 1处开始。如下

ABCABCABX
 ABX

看上面,第一个字符就失配,i = 1, j = 0,下轮比较i = i - j + 1 = 2开始。接着看

ABCABCABX
   ABX

i = 5, j = 2,下轮比较从i = i - j + 1 = 4处,即p[0]与t[4]比较。最后成功匹配时

ABCABCABX
      ABX

此时j == M跳出循环,i = 8, j = 2i - j = 6,确实是在索引为6的位置,找到了和模式字符串匹配的子字符串。

暴力法另一种实现

上面的代码如果找不与模式字符串匹配的子字符串,i会一直增大直到等于N才跳出循环,可是我们真的需要将主串的每个字符都与p[0]比较吗?不需要!

假设一直失配,顶多到子串的末尾和主串的末尾对齐时,进行最后一次匹配。这次再失配,子串向后移动一位,主串的最后一位会和子串的倒数第二位进行比较,子串的最后一位在主串中没有与之比较的字符了!说白了就是,字符串长度都不同,不用比较了。比如ABCDE查找CDF

这将是最后一次匹配
ABCDE
  CDF

这次的匹配没有意义,de和cdf不需比较
ABCDE
   CDF

也就是说p[0]最多和t[N - M]比较。基于以上事实,我们可以将代码换种实现。

public static int search(String p, String t) {
    int N = t.length();
    int M = p.length();
    for (int i = 0; i <= N - M; i++) {
        int j;
        for (j = 0; j < M; j++) {
            if (p.charAt(j) != t.charAt(i + j)) {
                break;
            }
        }
        if (j == M) {
            return i;
        }
    }
    return -1;
}

上面已经分析过i不会超过N - M,这体现在了第一层for循环中,第二层for循环中,如果两个字符相同,那么j++,由于实现中p.charAt(j)t.charAt(i + j)比较,效果等同于第一种实现中的i++, j++。即拿p的下一个字符和t的下一个字符继续比较,不同的是i的索引值并没有改变,始终保持为主串开始比较处的索引。这里i的值就等于第一种实现的i - j,所以当匹配成功时,也是返回的i。这样当失配时,break跳出第二层循环,i只需加1,进行下一轮匹配就好,进入第二层循环时j = 0。效果等同于第一种实现种的i = i - j + 1;j = 0;

暴力法查找中,有一种最坏的情况。比如在pppppppppt种查找ppt,每一轮匹配中总是要比较到最后一位j = M -1时候才发现失配,即每一轮匹配都比较了M次。直到i的索引为N - M即第N - M + 1轮匹配(也就是最后一轮匹配了)才匹配成功。总共比较了(N - M + 1) * M次,时间复杂度为O((N - M + 1) * M)

第二种实现中,其实i从未减小过,但是从t.charAt(i + j)可以看出,i位置之后的若干个字符我们都比较过了,失配了还是回到i + 1的位置重新匹配,已经比较过的字符串信息没有利用起来,有些比较步骤必然是多余的 (下面会说到),这和第一种实现的原理是一模一样的——第一种实现,i确实是先变大,失配后再减小,称为显式回退。那么第二种实现可以认为是隐式回退,只是i没有减小罢了。

看个例子,如果给定文本串T“BBC ABCDAB ABCDABCDABDE”,和模式串P“ABCDABD”,现在要拿模式串P去跟文本串T匹配,过程如下所示:

以下图片取自这篇文章,别人作的图挺好的,我就直接引用了。

1、匹配开始,p[0]t[0]比较,不相同,i = 1, j = 0,下轮用p[0]和t[1]比较

2、p[0]与t[1]依然不相同,i = 2, j = 0,重复之,一直到有字符相同时

3、此时有相同的字符串, 比较下一个字符

4、继续比较,直到出现失配

5、前面的ABCDAB已经匹配,直到D与空串失配。

6、i = 5, j = 0,从p[0]与t[5]比较开始。

后面还有若干步骤...先省略了。

仔细思考最后一步,在步骤5中发现了失配处D之前的字符串ABCDAB已经匹配。且p中第一个字符A和之后的B不相同,那么按暴力法的方法(即步骤6做的那样),移动一位后肯定也是失配的。为什么呢?步骤5中已知t[5] = p[1] = B,且p[0]显然不等于p[1]。所以步骤6中移动一位,p[0]和t[5]比较即p[0]和p[1]比较,肯定不相同的,这信息事先我们就知道了。等于说,这步根本就是多余的。对于第一个字符A后面的C、D字符也是一个道理。我们应该可以猜到,直接让p[2]即字符C与失配处(ABCDAB后的空串)对齐,从这里开始比较,相当于p向右移动了4位。因为p中字符C之前的AB和t中失配位置之前的AB字符串是相同的,在这个地方开始比较时有可能可以匹配成功的,值得一试。所以我们最好不要直接跳到p[0]和失配处对齐的位置。那么如何确定子串p的哪个位置和失配处对齐,继而从该位置开始比较呢?

通过上面的分析,可以发现,当失配位置之前已经有若干字符匹配时,暴力法很多步骤是多余的。因此提出了改进算法——KMP算法,该算法可以利用失配位置之前已经匹配的字符串这些已经比较过的信息。KMP算法的主串指针i永远也不回退,它充分利用了已比较过的字符串所包含的信息;而且和暴力法的第二种实现比起来, 可以将子串一次移动多位,省去了不必要的比较。

KMP算法i的值不回退,j肯定是不回到0了,那j应该取多少呢?也就是说原本p[j]和t[i]这两个字符失配了,现在为了避免多余的比较需要找到一个新的j,让p[j]再和t[i]比较,在人的角度看来相当于直接移动子串p使p[j]和失配处t[i]对齐。关键是这个新的j怎么求了。这将在下一节KMP算法中揭晓。


by @ sunhaiyu

2017.8.3

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,053评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,527评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,779评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,685评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,699评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,609评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,989评论 3 396
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,654评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,890评论 1 298
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,634评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,716评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,394评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,976评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,950评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,191评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,849评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,458评论 2 342

推荐阅读更多精彩内容