【算法笔记】3.LeetCode-Hot100-字符串专项

93 阅读5分钟

1. 无重复字符的最长子串(t3)

中等难度,题目示例如下:

给定一个字符串 s ,请你找出其中不含有重复字符的最长子串的长度。

示例 1:

输入: s = "abcabcbb"
输出: 3 
解释: 因为无重复字符的最长子串是 "abc",所以其长度为 3。
示例 2:

输入: s = "bbbbb"
输出: 1
解释: 因为无重复字符的最长子串是 "b",所以其长度为 1。
示例 3:

输入: s = "pwwkew"
输出: 3
解释: 因为无重复字符的最长子串是 "wke",所以其长度为 3。
     请注意,你的答案必须是 子串 的长度,"pwke" 是一个子序列,不是子串。

解题思路:第一个想法就用纯暴力的方式去做,挨个元素开始遍历,用unordered_set来去重,maxLen记录每轮循环的最大值。

class Solution {
public:
    int lengthOfLongestSubstring(string s) {
        int maxLen0;
        int n = s.size();

        for (int i0; i < n; i++){
            unordered_set<char> charSet;
            for (int j = i; j < n; j++){
                if (charSet.count(s[j])) break;
                charSet.insert(s[j]);
                if (maxLen < (j - i + 1)) maxLen = j - i + 1;
            }
        }
        
        return maxLen;
    }
};

这样需要两个循环,时间复杂度是O()

这道题官方更推荐采用滑动窗口的思路去做:滑动窗口有点类似于双指针的思路,通过两个指针表示字符串中的某个子串(窗口)的左右边界,左指针从起始位为开始遍历,右指针不断向右拓展,找到集合中无重复的字符就加入窗口范围。

class Solution {
public:
    int lengthOfLongestSubstring(string s) {
        unordered_set<char> charSet;
        int n = s.size();
       // 右指针,初始值为 -1,相当于我们在字符串的左边界的左侧,还没有开始移动
        int rk = -1, ans = 0;
        // 枚举左指针的位置,初始值隐性地表示为 -1
        for (int i = 0; i < n; i++){
            if (i != 0) {
                // 左指针向右移动一格,移除一个字符
                charSet.erase(s[i - 1]);
            }
            while (rk + 1 < n && !charSet.count(s[rk + 1])) {
                // 不断地移动右指针
                charSet.insert(s[rk + 1]);
                rk++;
            }
            // 第 i 到 rk 个字符是一个极长的无重复字符子串
            ans = max(ans, rk - i + 1);
        }
        
        return ans;
    }
};

下面的例子能够更好地理解滑动窗口的思路:

对于字符串abcabcbb

暴力解法:

  • i = 0 → 检查 abc → 合法
  • i = 1 → 检查 bca → 合法,但其实“bc”已经在之前窗口检查过了,又重新检查一次(重复计算)

滑动窗口:

  • i = 0 → 构建 abc
  • i = 1 → 只移除 'a',窗口右边已构建好,直接继续滑动,无需重建窗口

因此,滑动窗口最大优化点就是窗口内本身的元素不需要重复访问,这就让复杂度减少了一倍,时间复杂度降为 O(N)。

2. 找到字符串中所有字母异位词(t438)

中等难度,题目示例如下:

给定两个字符串 s 和 p,找到 s 中所有 p 的 异位词 的子串,返回这些子串的起始索引。不考虑答案输出的顺序。

字母异位词是通过重新排列不同单词或短语的字母而形成的单词或短语,并使用所有原字母一次。

示例 1:

输入: s = "cbaebabacd", p = "abc"
输出: [0,6]
解释:
起始索引等于 0 的子串是 "cba", 它是 "abc" 的异位词。
起始索引等于 6 的子串是 "bac", 它是 "abc" 的异位词。

示例 2:

输入: s = "abab", p = "ab"
输出: [0,1,2]
解释:
起始索引等于 0 的子串是 "ab", 它是 "ab" 的异位词。
起始索引等于 1 的子串是 "ba", 它是 "ab" 的异位词。
起始索引等于 2 的子串是 "ab", 它是 "ab" 的异位词。

这道题思路和上面一题如出一辙,主要区别是异形词的判断会比重复词更复杂一些。遇事不决先来个纯暴力的方式,异形词的判断直接通过排序,如果排序后一致,则表明是异形词。

class Solution {
public:
    vector<int> findAnagrams(string s, string p) {
        vector<int> result;
        int sLen = s.size(), pLen = p.size();
        if (sLen < pLen) return result;

        // 对 p 排序作为比较基准
        string sortedP = p;
        sort(sortedP.begin(), sortedP.end());

        // 枚举 s 中所有长度为 pLen 的子串
        for (int i = 0; i <= sLen - pLen; ++i) {
            string sub = s.substr(i, pLen);
            sort(sub.begin(), sub.end());  // 排序子串
            if (sub == sortedP) {
                result.push_back(i);  // 是异位词
            }
        }

        return result;
    }
};

时间复杂度是 O(n * m log m),即接近 n 次循环,每次循环的排序时间复杂度为 m log m。

提交上去,超时。

下面用滑动窗口的思路进行优化,核心思想是不用排序,而采用计数数组,因为异形词本身就需要交换顺序,因此用统计窗口内26个字母的相应数量和p一致就行了。这里也用到了独热编码**的思想,计数数组构建成一个长度为26的数组。

class Solution {
public:
    vector<int> findAnagrams(string s, string p) {
        vector<int> result;
        int sLen = s.size(), pLen = p.size();
        if (sLen < pLen) return result;

        // 统计 p 中每个字符的频率
        vector<int> countP(26), countS(26);
        for (char c : p) {
            countP[c - 'a']++;
        }

        // 初始化前 pLen 长度的窗口
        for (int i = 0; i < pLen; ++i) {
            countS[s[i] - 'a']++;
        }
        if (countS == countP) result.push_back(0);

        // 开始滑动窗口
        for (int i = pLen; i < sLen; ++i) {
            // 移出窗口左边的字符
            countS[s[i - pLen] - 'a']--;
            // 加入窗口右边的新字符
            countS[s[i] - 'a']++;

            if (countS == countP) {
                result.push_back(i - pLen + 1);
            }
        }

        return result;
    }
};

这样时间复杂度变成 O(n + 2p),n是循环遍历的时间,p是初始化频率数组和初始化窗口的时间。