《大话数据结构》之串

1. 朴素的字符串匹配方法

1.1 比较过程

以主串T为基础,根据索引i对每个字符进行遍历,依次与目标串P的每个索引j的字符进行匹配。若字符相同,则两索引均后移,继续匹配;若不相同,则j恢复为0,从头开始,i回溯到开始成功匹配时索引的下一位。

1.2 比较结果

遍历索引i结束之后,若此时j已经达到目标串P的最后,则证明目标串的所有字符均匹配成功,目标串在主串中的位置即length(T) - j;否则匹配失败,P不是T的子串。

1.3 主要实现

NSInteger findIndex(const char *sourceStr, const char *targetStr) {
    // 获取二者长度
    NSInteger sourceLength = strlen(sourceStr);
    NSInteger targetLength = strlen(targetStr);
    
    NSInteger i = 0; // 主串索引
    NSInteger j = 0; // 目标串索引
    
    while ((i < sourceLength) && (j < targetLength)) {
        if (sourceStr[i] == targetStr[j]) {
            // 字符相同,继续向后
            i += 1;
            j += 1;
        } else {
            // 字符不同
            // 主串索引恢复到开始成功匹配时的下一位
            i = i - j + 1;
            // 目标串索引恢复到头
            j = 0;
        }
    }
    
    // 注:这里的大于只是防止最后一位匹配成功后的+1操作
    if (j >= targetLength) {
        // 主串的最终遍历索引 前移 目标串长度,即为子串的起始位置
        return i - targetLength;
    }
    
    return NSNotFound;
}

2. KMP的字符串匹配方法

KMP方法的作用主要是去掉了一些可避免的比较,提高了匹配效率。

2.1 匹配过程

KMP的主要匹配过程与朴素一致,都是字符依次比较。优化点是在字符匹配失败时,尽量让主串T的索引i不回溯,目标串的索引j回溯到需要的位置(而不是直接到0)。从而达到减少比较次数的目的。

如上所述,KMP的核心就是在任意位置匹配失败时,目标串的索引j回溯到的位置

我们将所有回溯位置的信息保存到数组next中,故最终目的就是计算出next数组的完整信息。

2.1.1 匹配表(Partial Match Table)

要说next数组,就先要了解匹配表PMT的含义。以字符串“abababca”为例,其匹配表如下:

image

其中,value行即为PMT的值。其意义是什么呢?

value的值是子串中前缀与后缀集合中,最长的公共子串的长度。例如,下表分别为当index为2和5时,value的得出过程:

索引 子串 前缀集合 后缀集合 交集 最长子串长度
2 “aba” {“a”,“ab”} {“ba”,“a”} {“a”} 1
5 “ababab” {“a”,“ab”,“aba”,“abab”,“ababa”} {“b”,“ab”,“bab”,“abab”,“babab”} {“ab”,“abab”} 4

故目标字符串有几位,就会有几个PMT中对应的值。

2.1.2 next表(PMT表的修改形式)

先抛图(主串“ababababca”和子串“abababca”进行匹配的过程之一)。

image

如图所示,当在主串的i处匹配失败,其之前长度为6的字符串“ababab”均已匹配成功。此时,由PMT表我们可知,子串“ababab”的value为4,即其四位前缀与四位后缀是一致的。又因为在主串中,i之前的四位与j之前的四位字符均一致。故,目标串的四位前缀与主串中i之前的四位字符一致。我们可以省略对该子串的比较,直接在目标串的j=4的位置与主串的当前位置i继续比较。

在上面是叙述中,我们发现,匹配失败,并不会导致主串索引i改变,只会影响目标串索引j出现变化。并且,有趣的是,以图中的过程为例,当j=6时,匹配失败,即最后一个成功匹配的j是5。之后j回溯的位置4正好是PMT表中index为5时的value值。根据这个原理,我们就可以跳过不需要的比较,只通过修改目标串索引的位置即可进行快速匹配。

image

那么,我们引入next表的目的是什么呢?由于在某一位置匹配失败时,都要查看其前一个位置对应的PMT值,故我们将PMT数据均偏移一位,直接对应于当前位置,以便在快速查找下一步时,可以直接得到目标串索引j的位置。因此,我们把PMT值偏移后的数据叫做next数据,为了补位,next的首位赋值为-1。

因此,有了next的方式,我们就可以对朴素匹配法进行优化了。

2.2 匹配过程

NSInteger KMPFindIndex(const char *sourceStr, const char *targetStr) {
    // 获取目标串的next信息,以备匹配时进行回溯(KMP算法核心)
    NSArray *nextInfo = getNextInfo(targetStr);
    NSLog(@"next - %@", nextInfo);
    
    // 获取两串长度
    NSInteger sourceLength = strlen(sourceStr);
    NSInteger targetLength = strlen(targetStr);
    
    // 主串索引
    NSInteger i = 0;
    // 目标串索引
    NSInteger j = 0;
    
    // 均在范围内查找
    while ((i < sourceLength)
           && (j < targetLength)) {
        if ((j == -1) 
            || (sourceStr[i] == targetStr[j])) {
                // 字符匹配时,索引均后移,准备下一次匹配(j为-1即为了防止出现next[0] = -1的情况出现)
                i += 1;
                j += 1;
            } else {
                // 字符不匹配,i不变,j回溯为next中对应的位置,作为下次匹配的起点
                j = [nextInfo[j] integerValue];
            }
    }
    
    if (j == tragetLength) {
        return i - j;
    }
    
    return NSNotFound;
}

2.3 生成Next数组的过程

我们知道,生成Next数据的过程,即在每一个位置上,获取前面子串里,前后缀集合中最长的公共子串的长度

如目标字符串“ABCDABDE”,我们以k作为前缀索引,j为后缀索引。如下图,当在字符索引在“D”时(即j为6时),其子串“ABCDAB”的最长前后缀公共子串长度为2(子串“AB”的长度),故next[6] = 2。我们还可知,在j为5时,next[5] = 1,即在j = 5时,公共子串最大长度为1(子串“A”的长度)。故我们可以得到:在当前字符存在有效next值时(Pk == Pj时),下一个字符的next即为之前next位置的下一个,即next[j + 1] = next[j] + 1 = k + 1

image

若Pk与Pj不相等,如图中k为2,j为6时。按照KMP的思想,后缀j不变,需要更新前缀k,以便找到另一个字符与Pj相同。故将前缀索引k修改为next[k]以继续进行比较(缩小前缀查找范围),直到找到相同的字符“D”或没有相同字符为止。

按照这个思想,生成next数据的实现为:

NSArray *getNextInfo(const char *targetStr) {
    // 创建返回的next数组
    NSMutableArray *nextInfo = [[NSMutableArray alloc] initWithCapacity:10];
    nextInfo[0] = @(-1); // 人为指定首位的next值为-1(首位无法回溯,故此值只是占位用)
    
    // 获取字符串长度
    NSInteger strLength = strLen(targetStr);
    
    NSInteger k = -1; // 前缀索引初始值
    NSInteger j = 0; // 后缀索引初始值(后缀必须大于前缀才有效)
    
    // 以后缀索引作为遍历范围(-1为了防止最后无效的自加1)
    while (j < strLength - 1) {
        if ((k == -1)
            || (targetStr[k] == targetStr[j])) {
                // 前缀为初值索引时,无条件后移并记录(即没有有效前后缀公共串)
                // 当前位置的字符相同,即找到了公共前后缀字符串,记录下一个位置的next值
                
                // 下一位置的next值即为当前next的索引值k再加1
                k += 1;
                j += 1;
                
                nextInfo[j] = @(k);
            } else {
                // 当前位置字符不同
                // 按照KMP思想,j不回溯,k回溯为下一次的查找索引
                k = [next[k] integerValue];
            }
    }
    
    return [nextInfo copy];
} 

Objective-C实现的代码地址:点击查看

2.4 Next数组的优化

我们设想一下这种情况:目标串为“abcabc”,主串为“abacababc”,其中一种比较情况如下图:

img

即此时,第四位比较失败(“c”与“b”不同)。我们知道,此时目标串下一次比较的索引j为next[j],即第二位。也就是目标串右移j-next[j]位(2位)。

img

如图所示,由于移动后,第二位的字符仍然为“b”,显然比较失败。在这种情况下,本次比较实际上是没有意义的。那么应该如何避免这种比较呢?

其实,在正常情况下,当匹配失败时,我们还会进一步缩小匹配范围(j = next[j]),故在这种情况下(next[j]指向的字符与当前字符相同时),我们只需再次递归next[j],跳过相同字符即可

因此,解决办法就是,在生成next数组时,在当前Pk和Pj相同的情况下,若下一位的j与k指向的字符相同,直接设置该位置的next为next[k]即可

NSArray *getNextInfo(const char *targetStr) {
    NSMutableArray *nextInfo = [[NSMutableArray alloc] initWithCapacity:10];
    nextInfo[0] = @(-1); 
    
    NSInteger strLength = strLen(targetStr);
    
    NSInteger k = -1; // 前缀索引初始值
    NSInteger j = 0; // 后缀索引初始值(后缀必须大于前缀才有效)
    
    // 以后缀索引作为遍历范围
    while (j < strLength - 1) {
        if ((k == -1)
            || (targetStr[k] == targetStr[j])) {
                // 前缀为初值索引时,无条件后移并记录(即没有有效前后缀公共串)
                // 当前位置的字符相同,即找到了公共前后缀字符串,记录下一个位置的next值
                
                k += 1;
                j += 1;
                
                // nextInfo[j] = @(k); // 修改
                
                if (targetStr[k] == targetStr[j]) {
                    // 下一位的前后缀字符相同
                    nextInfo[j] = @(nextInfo[k]); // next跳过当前的前缀位置,直接指向前缀位置的next位置
                } else {
                    // 下一位的前后缀字符不同(正常情况)
                    nextInfo[j] = @(k); // 原始匹配,next指向前缀后面的位置
                }
                
                
            } else {
                // 当前位置字符不同
                k = [next[k] integerValue];
            }
    }
    
    return [nextInfo copy];
} 

最终,优化后的next数组信息如下表所示(以目标串“abab”为例):

img

使用优化后的next数组,可以再次减少一些不必要的比较。

2.5 时间复杂度

KMP方式的运算次数为计算next数组的次数匹配主串的次数之和,即O(m + n)。

参考引用:

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

推荐阅读更多精彩内容