【算法】代码随想录刷题记录 | 4. 字符串篇(含KMP算法详细步骤及代码)

344. 反转字符串

题目链接

思路:

C++中使用reverse()库函数就可以实现反转,当然本题中不能这样作答。

如果库函数仅仅是解题过程中的一小部分,并且你已经很清楚这个库函数的内部实现原理的话,可以考虑使用库函数。

使用双指针法,以字符串hello为例,过程如下:

344.反转字符串

题解:

class Solution {
public:
    void reverseString(vector<char>& s) {
        int left = 0;
        int right = s.size() - 1;
        while (left < right) {
            s[left] ^= s[right] ^= s[left] ^= s[right];
            left++;
            right--;
        }
    }
};

 541. 反转字符串Ⅱ

题目链接

 思路:

一些同学可能为了处理每隔2k个字符的前k的字符的逻辑,写了一堆逻辑代码或者再搞一个计数器,来统计2k,再统计前k个字符。

其实在遍历字符串的过程中,只要让 i += (2 * k),i 每次移动 2 * k 就可以了,然后判断是否需要有反转的区间。

题解:

class Solution {
public:
    void reverseString(string &s, int head, int tail) {
        while (head < tail) {
            s[head] ^= s[tail] ^= s[head] ^= s[tail];
            head++;
            tail--;
        }
    }
    string reverseStr(string s, int k) {
        for (int i = 0; i < s.size(); i += 2 * k) {
            if (i + k - 1 <= s.size() - 1) {
                reverseString(s, i, i + k - 1);
            } else{
                reverseString(s, i, s.size() - 1);
            }
        }
        return s;
    }
};

卡码网54. 替换数字

题目链接

如果题目加载不出来,右上角登录即可。卡码网可以练习ACM模式的算法题。

思路:

如果想把这道题目做到极致,就不要只用额外的辅助空间了。(不过使用Java则一定要使用辅助空间,因为Java里的string不能修改)

首先扩充数组到每个数字字符替换成 "number" 之后的大小。

例如 字符串 "a5b" 的长度为3,那么 将 数字字符变成字符串 "number" 之后的字符串为 "anumberb" 长度为 8。

如图:

然后从后向前替换数字字符,也就是双指针法,过程如下:i指向新长度的末尾,j指向旧长度的末尾。

为什么要从后向前填充,从前向后填充不行么?

从前向后填充就是O(n^2)的算法了,因为每次添加元素都要将添加元素之后的所有元素整体向后移动。

很多数组填充类的问题,其做法都是先预先给数组扩容带填充后的大小,然后在从后向前进行操作。

这么做有两个好处:

  1. 不用申请新数组。
  2. 从后向前填充元素,避免了从前向后填充元素时,每次添加元素都要将添加元素之后的所有元素向后移动的问题。

题解: 

可以注意ACM模式中输入、输出的写法。

#include <iostream>
using namespace std;

int main() {
    string s;
    while (cin >> s) {
        int numCount = 0;
        int oldSize = s.size();
        for (int i = 0; i < oldSize; i++) {
            if (s[i] >= '0' && s[i] <= '9') {
                numCount++;
            }
        }
        int newSize = oldSize + 5 * numCount;
        s.resize(newSize);
        int left = oldSize - 1;
        int right = newSize - 1;
        while (left >= 0) {
            if (s[left] >= 'a' && s[left] <= 'z') {
                s[right] = s[left];
            } else {
                s[right] = 'r';
                s[right - 1] = 'e';
                s[right - 2] = 'b';
                s[right - 3] = 'm';
                s[right - 4] = 'u';
                s[right - 5] = 'n';
                right = right - 5;
            }
            left--;
            right--;
        }
        cout << s;
    }
}

151. 反转字符串中的单词

题目链接

思路:

解题思路如下:

  • 移除多余空格
  • 将整个字符串反转
  • 将每个单词反转

举个例子,源字符串为:"the sky is blue "

  • 移除多余空格 : "the sky is blue"
  • 字符串反转:"eulb si yks eht"
  • 单词反转:"blue is sky the"

对于移除多余空格操作,可以用如下思路完成,类似于题27. 移除元素(相当于移除字符串中所有的空格,在每个单词前手动添加一个空格):

void removeExtraSpaces(string& s) {//去除所有空格并在相邻单词之间添加空格, 快慢指针。
    int slow = 0;   //整体思想参考 27.移除元素
    for (int i = 0; i < s.size(); ++i) { //
        if (s[i] != ' ') { //遇到非空格就处理,即删除所有空格。
            if (slow != 0) s[slow++] = ' '; //手动控制空格,给单词之间添加空格。slow != 0说明不是第一个单词,需要在单词前添加空格。
            while (i < s.size() && s[i] != ' ') { //补上该单词,遇到空格说明单词结束。
                s[slow++] = s[i++];
            }
        }
    }
    s.resize(slow); //slow的大小即为去除多余空格后的大小。
}

题解:

class Solution {
public:
    void removeExtraSpaces(string &s) {
        int left = 0;
        for (int right = 0; right < s.size(); right++) {
            if (s[right] != ' ') {
                if (left != 0) {
                    s[left++] = ' ';
                }
                while (right < s.size() && s[right] != ' ') {
                    s[left++] = s[right++];
                }
            }
        }
        s.resize(left);
    }

    void reverseString(string &s, int head, int tail) {
        while (head < tail) {
            swap(s[head++], s[tail--]);
        }
    }

    string reverseWords(string s) {
        removeExtraSpaces(s);
        reverseString(s, 0, s.size() - 1);
        for (int i = 0; i < s.size(); i++) {
            int temp = i;
            while (s[i] != ' ' && i < s.size()) {
                i++;
            }
            reverseString(s, temp, i - 1);
        }
        return s;
    }
};

卡码网55. 右旋字符串

题目链接

思路:

有点类似 题151. 反转字符串中的单词 的思想,先整体反转,再局部反转即可。

如:

题解:

#include <iostream>
using namespace std;
#include <string>
#include <algorithm>

void reverseString(string &s, int head, int tail) {
    while (head < tail) {
        swap(s[head++], s[tail--]);
    }
}

int main(){
    int k;
    string s;
    cin >> k;
    cin >> s;
    reverseString(s, 0, s.size() - 1);
    reverseString(s, 0, k - 1);
    reverseString(s, k, s.size() - 1);
    cout << s << endl;
    
    return 0;
}

KMP算法步骤及代码

KMP主要应用在字符串匹配上。

KMP的主要思想是当出现字符串不匹配时,可以知道一部分之前已经匹配的文本内容,可以利用这些信息避免从头再去做匹配了。

所以如何记录已经匹配的文本内容,是KMP的重点,也是next数组肩负的重任。

next数组就是一个前缀表(prefix table)。

前缀表有什么作用呢?

前缀表是用来回退的,它记录了模式串与主串(文本串)不匹配的时候,模式串应该从哪里开始重新匹配。

例如,要在文本串:aabaabaafa 中查找是否出现过一个模式串:aabaaf。

如动画所示:

KMP详解1

文本串中第六个字符b 和 模式串的第六个字符f,不匹配了。如果暴力匹配,发现不匹配,此时就要从头匹配了。

但如果使用前缀表,就不会从头匹配,而是从上次已经匹配的内容开始匹配,找到了模式串中第三个字符b继续开始匹配。

字符串的前缀是指不包含最后一个字符的所有以第一个字符开头的连续子串

后缀是指不包含第一个字符的所有以最后一个字符结尾的连续子串

前缀表要求的就是相同前后缀的长度。

刚刚匹配的过程在下标5的地方遇到不匹配,模式串是指向f,如图: 

KMP精讲1

然后就找到了下标2,指向b,继续匹配:如图: 

KMP精讲2

以下这句话,对于理解为什么使用前缀表可以告诉我们匹配失败之后跳到哪里重新匹配 非常重要!

下标5之前这部分的字符串(也就是字符串aabaa)的最长相等的前缀 和 后缀字符串是 子字符串aa ,因为找到了最长相等的前缀和后缀,匹配失败的位置是后缀子串的后面,那么我们找到与其相同的前缀的后面重新匹配就可以了。

所以前缀表具有告诉我们当前位置匹配失败,跳到之前已经匹配过的地方的能力。

再来看一下如何利用 前缀表找到 当字符不匹配的时候应该指针应该移动的位置。如动画所示:

KMP精讲2

找到的不匹配的位置, 那么此时我们要看它的前一个字符的前缀表的数值是多少。

为什么要前一个字符的前缀表的数值呢,因为要找前面字符串的最长相同的前缀和后缀。

所以要看前一位的 前缀表的数值。

前一个字符的前缀表的数值是2, 所以把下标移动到下标2的位置继续匹配。

n为文本串长度,m为模式串长度,因为在匹配的过程中,根据前缀表不断调整匹配的位置,可以看出匹配的过程是O(n),之前还要单独生成next数组,时间复杂度是O(m)。所以整个KMP算法的时间复杂度是O(n+m)的。

暴力的解法显而易见是O(n × m),所以KMP在字符串匹配中极大地提高了搜索的效率。

求next数组分4步(前缀表不减一不右移的情况):

  1. 初始化
  2. 处理前后缀不同的情况
  3. 处理前后缀相同的情况
  4. 更新next数组

1. 初始化(next数组、i、j)

i —— 指向后缀末尾位置; j —— 指向前缀末尾位置,且代表i之前(包括i)字串的最长相等前后缀的长度

j = 0, next[0] = 0;

for (int i = 1; i < s.size(); i++)

2. 处理前后缀不同的情况

s[i]与s[j]不匹配时,j要回退到下标为j在next数组前一位的值的位置(初始位置,即j = 0除外)。注意是while而不是if,因为j需要连续回退。

while (s[i] != s[j] && j > 0)  j = next[j - 1];

3. 处理前后缀相同的情况

因为j也代表i之前(包括i)字串的最长相等前后缀的长度,因此s[i]与s[j]匹配时j应该加1。

if (s[i] == s[j]) j++;

4. 更新next数组

j代表i之前(包括i)字串的最长相等前后缀的长度。

next[i] = j;

整体代码如下:

void getNext(int* next, const string& s) {
    int j = 0;
    next[0] = 0;
    for (int i = 1; i < s.size(); i++) {    //s为模式串
        while (s[i] != s[j] && j > 0) {
            j = next[j - 1];
        }
        if (s[i] == s[j]) {
            j++;
        }
        next[i] = j;
    }
}

得到next数组(前缀表)后该怎么做,我们从下面的题目中来实现。


28. 找出字符串中第一个匹配的下标

题目链接

思路:

这是KMP算法的经典题目。注意在求next数组前,根据题给条件可能需要判断一下模式串是否为空(if(s.size() == 0))。

求得next数组后,对文本串进行遍历,如果haystack[i] != needle[j],则j回退;反之,则j++;如果j == needle.size(),则匹配成功。

题解:

class Solution {
public:
    void getNext(string& s, int* next) {
        int j = 0;
        next[0] = 0;
        for (int i = 1; i < s.size(); i++) {
            while (s[i] != s[j] && j > 0) {
                j = next[j - 1];
            }
            if (s[i] == s[j]) {
                j++;
            }
            next[i] = j;
        }
    }
    int strStr(string haystack, string needle) {
        int next[needle.size()];
        getNext(needle, next);
        int j = 0;
        for (int i = 0; i < haystack.size(); i++) {
            while (haystack[i] != needle[j] && j > 0) {
                j = next[j - 1];
            }
            if (haystack[i] == needle[j]) {
                j++;
            }
            if (j == needle.size()) {
                return (i - needle.size() + 1);
            }
        }
        return -1;
    }
};

459. 重复的子字符串

题目链接

思路:
本题的第2、3种方法不易临场想到,需要记住。

1. 暴力解法

一个for循环获取子串的终止位置(起始位置一定是下标为0的位置),又嵌套一个for循环判断子串是否能重复构成字符,所以是O(n^2)的时间复杂度。

而且遍历的时候都不用遍历结束,只需要遍历到中间位置,因为子串结束位置大于中间位置的话,一定不能重复组成字符串。

2. 移动匹配

当一个字符串s:abcabc,内部由重复的子串组成,那么这个字符串的结构一定是这样的:

图一

也就是由前后相同的子串组成。

那么既然前面有相同的子串,后面有相同的子串,用 s + s,这样组成的字符串中,后面的子串做前串,前面的子串做后串,就一定还能组成一个s,如图:

图二

所以判断字符串s是否由重复子串组成,只要两个s拼接在一起,里面还出现一个s的话,就说明是由重复子串组成

当然,我们在判断 s + s 拼接的字符串里是否出现一个s的的时候,要刨除 s + s 的首字符和尾字符,这样避免在s+s中搜索出原来的s,我们要搜索的是中间拼接出来的s。

3. KMP

在一个串中查找是否出现过另一个串,这是KMP的看家本领。那么寻找重复子串怎么也涉及到KMP算法了呢?

KMP算法中next数组为什么遇到字符不匹配的时候可以找到上一个匹配过的位置继续匹配,靠的是有计算好的前缀表。 前缀表里,统计了各个位置为终点字符串的最长相同前后缀的长度。

  • 前缀是指不包含最后一个字符的所有以第一个字符开头的连续子串;
  • 后缀是指不包含第一个字符的所有以最后一个字符结尾的连续子串

在由重复子串组成的字符串中,最长相等前后缀不包含的子串就是最小重复子串,这里拿字符串s:abababab 来举例,ab就是最小重复单位,如图所示:

图三

为什么一定是开头的ab呢,其实最关键还是要理解 最长相等前后缀,如图:

图四

步骤一:因为 这是相等的前缀和后缀,t[0] 与 k[0]相同, t[1] 与 k[1]相同,所以 s[0] 一定和 s[2]相同,s[1] 一定和 s[3]相同,即:s[0]s[1]与s[2]s[3]相同 。

步骤二: 因为在同一个字符串位置,所以 t[2] 与 k[0]相同,t[3] 与 k[1]相同。

步骤三: 因为 这是相等的前缀和后缀,t[2] 与 k[2]相同 ,t[3]与k[3] 相同,所以,s[2]一定和s[4]相同,s[3]一定和s[5]相同,即:s[2]s[3] 与 s[4]s[5]相同。

步骤四:循环往复。

所以字符串s,s[0]s[1]与s[2]s[3]相同, s[2]s[3] 与 s[4]s[5]相同,s[4]s[5] 与 s[6]s[7] 相同。

正是因为 最长相等前后缀的规则,当一个字符串由重复子串组成的,最长相等前后缀不包含的子串就是最小重复子串。

数组长度减去最长相同前后缀的长度相当于是第一个周期的长度,也就是一个周期的长度,如果这个周期可以被整除,就说明整个数组就是这个周期的循环

题解:

//移动匹配
class Solution {
public:
    bool repeatedSubstringPattern(string s) {
        string ss = s + s;
        ss.erase(ss.begin());
        ss.erase(ss.end() - 1);
        if (ss.find(s) != -1) {
            return true;
        }
        return false;
    }
};

//KMP
class Solution {
public:
    void getNext(string& s, int* next) {
        int j = 0;
        next[0] = 0;
        for (int i = 1; i < s.size(); i++) {
            while (s[i] != s[j] && j > 0) {
                j = next[j - 1];
            }
            if (s[i] == s[j]) {
                j++;
            }
            next[i] = j;
        }
    }
    bool repeatedSubstringPattern(string s) {
        if (s.size() == 0) {
            return false;
        }
        int next[s.size()];
        getNext(s, next);
        int len = s.size();
        if (next[len - 1] != 0 && len % (len - next[len - 1]) == 0) {
            return true;
        }
        return false;
    }
};

总结感悟

1. 双指针法

双指针法在数组,链表,字符串以及N数之和中很常用。

其实很多数组填充类的问题,都可以先预先给数组扩容带填充后的大小,然后在从后向前进行操作。

2. 反转系列

在反转上还可以在加一些玩法,其实考察的是对代码的掌控能力。

比如题541. 反转字符串Ⅱ中,做题时可能为了处理逻辑:每隔2k个字符的前k的字符,写了一堆逻辑代码或者再搞一个计数器,来统计2k,再统计前k个字符。

其实当需要固定规律一段一段去处理字符串的时候,要想想在在for循环的表达式上做做文章

只要让 i += (2 * k),i 每次移动 2 * k 就可以了,然后判断是否需要有反转的区间。

再如题151. 反转字符串中的单词中,采用了先整体反转再局部反转的思路。

3. KMP

KMP的主要思想是当出现字符串不匹配时,可以知道一部分之前已经匹配的文本内容,可以利用这些信息避免从头再去做匹配了。

KMP的精髓所在就是前缀表:起始位置到下标i之前(包括i)的子串中,有多大长度的相同前缀后缀。求next数组的函数写法要牢记。

那么使用KMP可以解决两类经典问题:

  1. 匹配问题:如题28. 找出字符串中第一个匹配的下标
  2. 重复子串问题:如题459. 重复的子字符串
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值