LeetCode 题解随笔:字符串篇

目录

一、双指针法相关

344.反转字符串

541. 反转字符串II

151.翻转字符串里的单词

剑指Offer58-II.左旋转字符串

43. 字符串相乘

二、字符串填充替换

剑指Offer 05.替换空格

三、KMP算法

28. 实现 strStr()[*]

459.重复的子字符串

四、滑动窗口延伸

187. 重复的DNA序列

1234. 替换子串得到平衡字符串[*]

五、二分查找判断子序列

792. 匹配子序列的单词数


一、双指针法相关

344.反转字符串

void reverseString(vector& s) {
        int front = 0;
        int back = s.size() - 1;
        while (front < back) {
            char temp = s[back];
            s[back] = s[front];
            s[front] = temp;
            front++;
            back--;
        }
    }

这里提供一个方便查看vector结果的函数:

template
void MyPrint(vector v) {
    for (auto it = v.begin(); it != v.end(); it++) {
        cout << *it << " ";
    }
    cout << endl;
}

541. 反转字符串II

void reverseString(string& s, int front, int back) {
        while (front < back) {
            char temp = s[back];
            s[back] = s[front];
            s[front] = temp;
            front++;
            back--;
        }
    }

string reverseStr(string s, int k) {
        int start = 0;
        int count = s.size();
        while (start < s.size())
        {
            int left = start;
            if (count < k) { 
                int right = start + count - 1;
                reverseString(s, left, right);
                return s;
            }
            else if (count >= k && count < 2 * k) {
                int right = start + k - 1;
                reverseString(s, left, right);
                return s;
            }
            else {
                int right = start + k - 1;
                reverseString(s, left, right);
                start += 2 * k;
                count -= 2 * k;
            }
        }
        return s;
    }

与上一题类似,直接进行行为模拟即可。当考察内容不是直接调用库函数就能完成时,可以采用库函数。例如本题中的反转可以利用reverse实现。

另外,当需要固定规律一段一段去处理字符串的时候,要想想在for循环的表达式上做做文章。本题可以利用下式进行循环。

for (int i = 0; i < s.size(); i += (2 * k))

151.翻转字符串里的单词

string reverseWords(string s) {
        int fast = s.size() - 1;
        int slow = s.size() - 1;
        string res;
        while (fast >= 0) {
            if (s[fast] == ' ') {
                slow--;
                fast--;
            }
            else {
                while (fast >= 0 && s[fast] != ' ') {
                    fast--;
                }
                //除第一次外,每次添加单词前,加一个空格
                if (res.size() > 0) {
                    res.append(1, ' ');
                }
                res.append(s.substr(fast + 1, slow - fast));
                slow = fast;
            }
        }
        return res;
    }

 本算法的时间复杂度为O(n),但采用了额外的空间。本题可以先利用双指针法删除字符串中额外的空格,再翻转整个字符串,最后再翻转每个单词,这样就可以不借助额外空间。此处不再写了。

剑指Offer58-II.左旋转字符串

string reverseStr(string& s, int left, int right) {
        while (left < right) {
            char temp = s[left];
            s[left] = s[right];
            s[right] = temp;
            left++;
            right--;
        }
        return s;
    }

string reverseLeftWords(string s, int n) {
        reverseStr(s, 0, n - 1);
        reverseStr(s, n, s.size() - 1);
        reverseStr(s, 0, s.size() - 1); 
        return s;
    }

本题和上一题类似,高端解法既要在O(n)时间内完成,又不能使用额外空间。仍然可以采用先局部翻转,再整体翻转的思路。 即先翻转前半段,再翻转后半段,最后翻转整个字符串。

至此,可以总结出翻转字符串相关的问题,都可以采用双指针法完成。

43. 字符串相乘

    string multiply(string num1, string num2) {
        int m = num1.size(), n = num2.size();
        // 结果最多为 m + n 位数
        vector res(m + n, 0);
        // 从个位数开始逐位相乘
        // num1[i] 和 num2[j] 的乘积对应的是 res[i+j] 和 res[i+j+1] 这两个位置
        for (int i = m - 1; i >= 0; i--) {
            for (int j = n - 1; j >= 0; j--) {
                int mul = (num1[i] - '0') * (num2[j] - '0');
                int sum = mul + res[i + j];
                res[i + j] = sum / 10;
                res[i + j + 1] = sum % 10;
            }
        }
        // 结果前缀可能存的 0(未使用的位)
        int i = 0;
        while (i < res.size() && res[i] == 0)
            i++;
        string str;
        for (; i < res.size(); i++)
            str.push_back('0' + res[i]);
        return str.size() == 0 ? "0" : str;
    }

 如图所示(来源:labuladong),num1[i] 和 num2[j] 的乘积对应的是 res[i+j] 和 res[i+j+1] 这两个位置。

LeetCode 题解随笔:字符串篇_第1张图片


二、字符串填充替换

剑指Offer 05.替换空格

string replaceSpace(string s) {
        //首先统计空格字符个数,对字符串进行预扩容
        int count = 0;
        for (auto i : s) {
            if (i == ' ') {
                count++;
            }
        }
        if (count == 0) return s;
        s.resize(s.size() + 2 * count);
        int right = s.size() - 1;
        int left = right - 2 * count;
        //从后向前修改字符串
        while (left >= 0) {
            if (s[left] == ' ') {
                s.replace(right - 2, 3, "%20");
                left--;
                right -= 3;
            }
            else {
                s[right--] = s[left--];
            }
        }
        return s;
    }

数组填充类的问题,可以预先给数组扩容到填充后的大小,再从后向前进行操作

三、KMP算法

28. 实现 strStr()[*]

本题难度较高,需要深入学习KMP算法

本题的关键思想在于最长相等前后缀表的构造,利用最长相等前后缀的性质,查找haystack字符串时,若发生失配,只回退needle字符串。算法时间复杂度为O(m+n)。该过程如下图所示(转自公众号:labuladong):

LeetCode 题解随笔:字符串篇_第2张图片

  • 什么是最长相等前后缀表

首先理解什么是前后缀:例如子串aabaaf,其前缀串有:a,aa,aab,aaba,aabaa,其后缀串有:f,af,aaf,baaf,abaaf。

其次理解什么是最长相等前后缀:上面的子串aabaaf,前缀串和后缀串没有重复的,其最长相等前后缀长度为0。换一个字符串aabaa,其前缀串有:a,aa,aab,aaba,其后缀串有:a,aa,baa,abaa,可以看出前两个相同,从而其最长相等前后缀长度为2。

最后理解什么是前缀表:对于待查找字符串aabaaf,第一个位置a前缀和后缀都不存在,自然地最长相等前后缀为0;第二个位置,从头开始的子串为aa,最长相等前后缀长度为1(前缀后缀串分别为a和a);第三个位置,从头开始的子串为aab,最长相等前后缀长度为0。依此类推,可以构造出对应于待查找字符串aabaaf的前缀表为[0,1,0,1,2,0]。

  • 为什么可以利用前缀表实现查找:

前缀表记录了下标i之前(包括i)的字符串中,有多大长度的相同前缀后缀,可以指示发生失配时,待匹配的needle字符串回退到哪个位置。

LeetCode 题解随笔:字符串篇_第3张图片

例如上图(来源:代码随想录),匹配失败的位置是后缀子串的后面(5),那么我们回退到与其相同的前缀的后面(2)处开始匹配就可以了,而不需要全部重新匹配。在位置(4)处,记录了aabaa的最长相等前后缀长度2(即应当回退到needled目标串的下标位置),因此在某一位置失配时,要寻找该位置上一个位置记录的最长相等前后缀长度(即应当回退到的位置)。

  • 如何在代码层面构建前缀表【需要进一步理解】:

构造next数组的过程包括:

  1. 初始化
  2. 处理前后缀不相同的情况
  3. 处理前后缀相同的情况

初始时front指向前缀末尾位置(初始为0),rear指向后缀末尾位置(初始为1)。在一个循环中只遍历一次目标字符串,即移动后缀末尾位置rear。

前后缀不相同时,next[j]记录着j(包括j)之前的子串的相同前后缀的长度。利用front = next[front - 1]进行状态回退。

前后缀相同时,同时移动rear和front,同时记录front的位置更新前缀表。

    //使用KMP算法,首先构造前缀表;
    //实现方法为整体右移,查找时跳转至冲突元素指向的的位置即可
    void getNext(int* next, const string& s) {
        //初始化第一个元素为0
        int front = 0;
        next[front] = 0;
        //rear指向后缀最后一个元素,front起始时指向上个rear元素已匹配好的最长前缀的最后一个元素
        //next数组每个元素存放上一个元素对应最长相等前后缀的长度
        for (int rear = 1; rear < s.size(); rear++) {
            //前后缀没有匹配,则进行回退
            while (front > 0 && s[front] != s[rear]) {
                front = next[front - 1];
            }
            //前后缀匹配的情况
            if (s[front] == s[rear]) {
                front++;
            }
            next[rear] = front;
        }
    }

 利用前缀表可以方便实现目标子串的查找:

int strStr(string haystack, string needle) {
        if (needle.size() == 0) {
            return 0;
        }
        int *next = new int[needle.size()];
        getNext(next, needle);
        int j = 0;
        for (int i = 0; i < haystack.size(); i++) {
            //先进行单字符失配回退
            while (j > 0 && haystack[i] != needle[j]) {
                j = next[j - 1];
            }
            //回退后,再进行比较
            //若单字符匹配成功状态转移
            if (haystack[i] == needle[j]) {
                j++;
            }
            //查找成功
            if (j == needle.size()) {
                return (i - j + 1);
            }
        }
        return -1;
    }

和前缀表的构建很类似,若失配,则利用next数组一直回退到具有最大相同前后缀的位置 。回退后继续进行比较,若比较成功,在目标子串中更新指针状态,进入下一次循环,直到查找完毕。

采用动态规划思想理解KMP算法:算法过程参考连接

int strStr(string haystack, string needle) {
        // dp[j][c] = next:当前是状态 j,遇到了字符 c,应该转移到状态 next
        // next作为下标的含义是:已匹配了next个字符
        int length = needle.size();
        vector> dp(length, vector(26, 0));
        // 初始化dp数组:匹配到第一个字符时,状态才能推进
        dp[0][needle[0] - 'a'] = 1;
        // 递推公式:
        // 1.字符 c 和 pat[j] 匹配的话,状态就应该向前推进一个,也就是说 next = j + 1【即状态推进】
        // 2.1 如果字符 c 和 pat[j] 不匹配的话,状态就要回退(或原地不动)【即状态重启】
        // 2.2 确定回退的位置:当前状态具有相同的前后缀【即影子状态X】
        // 定义影子位置
        int X = 0;
        for (int j = 1; j < length; ++j) {
            for (int c = 0; c < 26; ++c) {
                if (needle[j] == (char)('a' + c)) {
                    dp[j][c] = j + 1;
                }
                else {
                    dp[j][c] = dp[X][c];
                }
            }
            // 更新影子位置:状态 X 总是落后状态 j 一个状态,与 j 具有最长的相同前缀
            X = dp[X][needle[j] - 'a'];
        }
        // 利用dp数组进行查找
        int j = 0;
        for (int i = 0; i < haystack.size(); ++i) {
            j = dp[j][haystack[i] - 'a'];
            // 抵达终止态
            if (j == length) {
                return  i - j + 1;
            }
        }
        // 没到达终止态,匹配失败
        return -1;
    }

 dp[j][c] = next:当前是状态 j,遇到了字符 c,应该转移到状态 next。在深入理解该dp数组含义的基础上,可以较清楚地构造状态转移表和搜索算法。

其中较难理解的点在于“影子状态”的构造,基于动态规划的思想,从迭代起始位置和迭代过程中的操作方式也可以看出,状态 X 总是落后状态 j 一个状态,与 j 具有最长的相同前后缀,因此更新X的方式为:X = dp[X][needle[j] - 'a']。(j已经遇到过该模式,就会更新;j没遇到过该模式,回退或不动)。

459.重复的子字符串

void getNext(int* next, const string& s) {       
        int front = 0;
        next[front] = 0; 
        for (int rear = 1; rear < s.size(); rear++) {
            while (front > 0 && s[front] != s[rear]) {
                front = next[front - 1];
            }
            if (s[front] == s[rear]) {
                front++;
            }
            next[rear] = front;
        }
    }
    
    bool repeatedSubstringPattern(string s) {
        int* next = new int[s.size()];
        getNext(next, s);
        if (s.size() == 0) {
            return false;
        }
        int len = s.size();
        if (next[len - 1] != 0 && len % (len - next[len - 1]) == 0) {
            return true;
        }
        return false;
    }

 本题仍采用KMP算法构建了前缀表,利用了前缀表的性质。若字符串可以被重复子串构成,那么最后一个周期的字符串与第一个周期的字符串相同,可以想象出来:前缀表的最后一个元素,记录了第一个周期最后一个元素的位置(即最大重复子串长度,亦即最长相同前后缀长度)。

因此利用if (next[len - 1] != 0 && len % (len - next[len - 1]) == 0),可以判断字符串是否可以被重复子串构建。

四、滑动窗口延伸

187. 重复的DNA序列

vector findRepeatedDnaSequences(string s) {
        // 滑动哈希技巧:在滑动窗口中快速计算元素的哈希值
        unordered_set dna_vals;
        set res_set;
        // 暂存当前窗口内字符串对应数值[A:1 G:2 C:3 T:4]
        int dna_val = 0;
        int left = 0, right = 0;
        int add_val = 0, delete_val = 0;
        while (right < s.size()) {
            // 右边界移动逻辑
            add_val = CharToInt(s[right]);
            right++;
            // 转化为四进制数
            dna_val = dna_val * 4 + add_val;
            // 左边界移动逻辑
            if (right - left == 10) {
                if (dna_vals.find(dna_val) != dna_vals.end()) {
                    res_set.insert(s.substr(left, 10));
                }
                else   dna_vals.insert(dna_val);
                delete_val = CharToInt(s[left]);
                left++;
                dna_val -= delete_val * pow(4, 9);
            }
        }
        vector res(res_set.begin(), res_set.end());
        return res;
    }
    int CharToInt(char c) {
        if (c == 'A')    return 1;
        else if (c == 'G')   return 2;
        else if (c == 'C')   return 3;
        else return 4;
    }

本题优化的关键在于,不需要取出滑动窗口内的字符串,而是用一些其他形式的唯一标识来表示滑动窗口中的子字符串,并且还能在窗口滑动的过程中快速更新。这种在滑动窗口中快速计算窗口中元素的哈希值,叫做滑动哈希技巧。

由于只有AGCT四个数,因此可以采用四进制的数储存10位长度的子串对应的值。本方法适用于哈希表规模较小的情况。若哈希表规模较大,可采用取模的方式,出现哈希冲突时再采用暴力搜索进行验证。

1234. 替换子串得到平衡字符串[*]

int balancedString(string s) {
        unordered_map record;
        int target = s.size() / 4;
        for (auto c : s) {
            record[c]++;
        }
        // 检查函数
        auto check = [&]() {
            if (record['Q'] > target || record['W'] > target 
                || record['E'] > target || record['R'] > target) {
                return false;
            }
            return true;
        };
        if (check()) return 0;
        int left = 0, right = 0, res = s.size();
        // 移动左窗口
        for (; left < s.size(); left++) {
            while (right < s.size() && !check()) {
                // 其中一种字符的出现次数大于 target 时, 移动右窗口
                record[s[right]]--;
                right++;
            }
            // 以当前left开头无解
            // [注意]:此处没有使用continue,因为一旦无解,继续移动左指针也不会有解
            if (!check()) {
                break;
            }
            // 区间定义:[left, right)
            res = min(res, right - left);
            record[s[left]]++;
        }
        return res;
    }

 LeetCode 题解随笔:字符串篇_第4张图片

 

五、二分查找判断子序列

792. 匹配子序列的单词数

class Solution {
public:
    int numMatchingSubseq(string s, vector& words) {
        // 记录目标串中各字符的下标
        unordered_map> records;
        for (int i = 0; i < s.size(); ++i) {
            records[s[i]].push_back(i);
        }
        int res = 0;
        for (string word : words) {
            // 启动算法,子序列第一个元素从s[0]开始查找
            int s_idx = 0;
            bool is_match = true;
            for (int i = 0; i < word.size(); ++i) {
                // 若s中不存在子序列当前元素,直接跳出
                if (!records.count(word[i])) {
                    is_match = false;
                    break;
                }
                // 若上次找到了一个目标元素,相当于移动i指针,word[i]对应s的下标应从s_idx + 1开始
                int record_idx = left_bound(records[word[i]], s_idx);
                // 有一个字符匹配不上,就结束本单词的匹配
                if (record_idx == -1) {
                    is_match = false;
                    break;
                }
                // 继续匹配下一个字符,从s的下一个位置开始
                s_idx = records[word[i]][record_idx] + 1;
            }
            if (is_match)   res++;
        }
        return res;
    }
    // 二分查找左边界:当 val 不存在时,得到的索引恰好是比 val 大的最小元素索引
    // 例如子序列"abc",查找到a在s中的下标为1,查找b时,在b对应s的下标索引中,找比1大的第一个元素即可
    int left_bound(vector& record, int target) {
        int left = 0;
        int right = record.size() - 1;
        while (left <= right) {
            int mid = left + (right - left) / 2;
            if (record[mid] < target) {
                left = mid + 1;
            }
            else {
                right = mid - 1;
            }
            // 若不存在比子序列上一个元素索引更大的当前子序列元素,标记返回结果为-1
            if (left == record.size())   return -1;
        }
        return left;
    }
};

算法执行过程如下图所示(来源:labuladong):

本题需要注意的是利用到了左边界二分查找的性质:当 val 不存在时,得到的索引恰好是比 val 大的最小元素索引

你可能感兴趣的:(LeetCode基础题,c++,数据结构,算法)