前言
计算机中的非数值处理的对象基本上是字符串数据,随着语言加工程序的发展,字符串作为一种变量类型出现在越来越多的程序设计语言中,同时也产生了一系列的字符串操作。在日常的程序设计中,如名称、地点等信息都是以字符串形式表示的,又如文本框、输入框等都是以字符串数据作为处理对象。
什么是字符串
字符串(string),或者直接叫作串,是由零个或多个字符组成的有限序列。
串中字符的个数称为串的长度,当字符个数为0时,此时串被称为空串。
由一个或多个空格组成的串称为空格串,需要注意的是空串不等于空格串,虽然它们看起来都没有内容。
String str1 = "Jack";
String str2 = "123@qq.com";
String str3 = ""; // 空串
String str4 = " "; // 空格串(一个空格)
串中任意个(包含0个)连续的字符组成的子序列称为该串的子串,包含子串的串称为主串,子串第一个字符在主串中的位置表示该子串在主串中的位置。
String str1 = "helloworld!";
String str2 = "ll"; // str2 是 str1 的子串,它在 str1 中的位置为 2
当两个串的值相等时,称两个字符串相等。
String str1 = "WuHan";
String str2 = "WuHan"; // str1 与 str2 相等
字符串的表示和实现
字符串有时不仅仅是一个表示特定信息的常量,它有时候也以变量的形式出现。字符串通常有三种机内表示方法。
定长顺序存储表示
类似于线性表的顺序存储结构,用一组地址连续的存储单元存储串值额字符序列。
堆分配存储表示
以一组地址连续的存储单元存放串值字符序列,但存储空间是在程序执行过程中动态分配的。
块链存储表示
和线性表的链式存储结构相类似,采用链表方式存储串值。
串的模式匹配
模式匹配是数据结构中字符串的一种基本运算,是各种串处理系统中的最重要的操作之一。
设 S 是给定的主串,T 是给定的子串,在主串 S 中查找等于子串 T 的串的过程就是模式匹配的过程,T 称为模式串。如果在 S 中找到 T 子串,则称匹配成功,函数返回 T 在 S 中首次出现的存储位置,否则匹配失败,返回-1。
字符串匹配算法有很多,这里只介绍两种最常见的算法。
BF(Brute-Force)算法
原理
最朴素的想法就是枚举主串的每一个字符,以每一个字符为起始点,与模式串的第一个字符开始尝试匹配,如果相同继续匹配主串和模式串的下一个字符,如果不同,从主串下一个字符再次开始匹配。
如果在一次匹配过程中匹配到模式串的最后一个字符,则匹配成功,否则失败。
代码实现
public int myBFIndexOf(String S, String T) {
int n = S.length(), m = T.length();
// pos 为本轮匹配主串的起始字符位置,不能大于主串长度减模式串长度的差
// 因为当 pos > n - m 时,一定会匹配失败
for(int pos = 0; pos <= n - m; pos++) {
int j = 0;
for(; j < m; j++) {
if(S.charAt(pos + j) != T.charAt(j)) break;
}
// 匹配到模式串最后一个字符意味着匹配成功
if(j == m) return pos;
}
return -1;
}
动图展示
复杂度分析
-
时间复杂度: O ( ( n − m ) ∗ m ) O((n - m)*m) O((n−m)∗m) ,其中枚举主串的起始字符时间复杂度为 O ( n − m ) O(n - m) O(n−m) ,匹配模式串的时间复杂度为 O ( m ) O(m) O(m) 。
-
空间复杂度: O ( 1 ) O(1) O(1) ,仅使用常数空间保存一些变量。
KMP(Knuth-Morris-Pratt) 算法
原理
暴力解法的问题
在暴力求解时,每当一轮匹配过程出现字符不等时,都会以主串的下一个字符为起始字符开启新的一轮匹配。考虑一个极端的情况,主串为 aaaaaaaaaaaaaaab
,模式串为 aaaaab
,每一轮匹配都会比较到模式串的最后一个字符 b
,然后发现不相等,继续下一轮匹配。
不难发现,这里存在明显的问题,那应该如何改进呢?
优化的地方
我们先来看看匹配的具体过程,下面是第一轮匹配失配时的状态:
按照暴力匹配的方式,会从主串的第二个字符重新开始匹配:
事实上,我们很轻易地就会观察到(其实是 D.E.Knuth,J.H.Morris 和 V.R.Pratt 三位天才观察到的),已经匹配过的主串中的字符和模式串中的字符是有重叠的,没有必要再次匹配:
因此指向主串的指针不用回溯,指向模式串的指针不必从头开始,这样就大大提高了效率。
前缀表和前缀函数
现在我们知道了 BF 算法可以优化的地方,就是找到每次失配时已经匹配过的主串中的字符和模式串中的字符中重叠的部分,这部分不需要再次比较。
这里所说的重叠部分就是字符串的公共前缀和后缀,模式串的每一个子串(从下标0开始)的最长公共前后缀的长度用一个一维数组表示就构成了模式串的前缀表,或者编程时习惯性命名为 next 数组
。
在上面的例子中,第一次失配时,字符串匹配到了模式串的 b
字符,此时已经匹配过的字符串为 aaaaa
,它的最长公共前后缀为 aaaa
。
看了上面的动图应该能明白为什么说公共前后缀部分为什么不需要再次比较,但是我们还不清楚怎么求公共前后缀,下面我们看看一个更常规的例子,模式串 abaabcac
的前缀表是怎么得来的的。
上面得到前缀表的方式是肉眼观察的,原理也很简单,就是从字符串两边开始分别向另一边逐个比较,相等就继续,否则就停止,这样就可以得到每一个子串的最长公共前后缀的长度,不过这个时间复杂度很高,有没有可能简化这个过程呢?
答案是,当然有。D.E.Knuth,J.H.Morris 和 V.R.Pratt 三位天才给出了线性时间复杂度下求解 next 数组的方法,它是 KMP 算法的核心,称为前缀函数。
下面我们看看,线性时间复杂度下 next 数组的求解过程。
起始 next[0] = 0,指针 j 指向位置0,指针 i 指向位置1;
如果 T[j] == T[i],指针 i、j 均向后移动,重复该过程,直到 T[j] != T[i] 或者 i 指向模式串末尾;
如果 T[j] != T[i],指针 j 指向上一位置的 next 数组对应的值,即 j = next[j - 1],重复该过程,直到 T[j] == T[i] 或者 j == 0;
如果 j == 0,此时 T[j] != T[i] ,指针 j 不动,指针 i 继续向后移动。
代码实现
public int myKMPIndexOf(String S, String T) {
int n = S.length(), m = T.length();
if (m == 0) return 0;
// 求 next 数组
int[] next = new int[m];
for (int i = 1, j = 0; i < m; i++) {
while (j > 0 && T.charAt(i) != T.charAt(j)) {
j = next[j - 1];
}
if (T.charAt(i) == T.charAt(j)) {
j++;
}
// 前 i + 1 个字符组成的字符串,最长公共前后缀长度为 j
next[i] = j;
}
// 匹配过程
// 指向主串的指针 i 一直向后,不回溯
for (int i = 0, j = 0; i < n; i++) {
// 如果失配,根据 next 数组更新模式串指针 j
while (j > 0 && S.charAt(i) != T.charAt(j)) {
j = next[j - 1];
}
// 如果匹配,模式串指针 j 后移
if (S.charAt(i) == T.charAt(j)) {
j++;
}
// 匹配到模式串最后一个字符意味着匹配成功
if (j == m) return i - m + 1;
}
return -1;
}
动图展示
复杂度分析
-
时间复杂度: O ( m + n ) O(m + n) O(m+n) ,其中 n 为主串的长度,m 为模式串的长度。
-
空间复杂度: O ( m ) O(m) O(m) ,使用一维数组存储模式串的前缀表。