leetcode-hot-100 (滑动窗口)

发布于:2025-05-14 ⋅ 阅读:(55) ⋅ 点赞:(0)

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

题目链接:无重复字符的最长子串
题目描述:给定一个字符串 s ,请你找出其中不含有重复字符的 最长 子串 的长度。

解答

如果题目中的字符串 s 仅由字母组成,那么我们可以创建一个大小为 char[26] 的数组,用来记录每个字母的出现次数。接着,使用双指针的方法:左指针(慢指针)和右指针(快指针)。每次右指针向右移动一位时,就将对应位置的字符加入到计数数组中。若此时该字符的计数值大于 1,说明出现了重复字符,于是我们需要不断向右移动左指针,直到所有字符的计数都恢复为 1 为止。这样可以动态维护一个不包含重复字符的子串。

通过上述方法,左指针和右指针所覆盖的区域实际上形成了一个“窗口”。每次右指针的移动相当于将窗口向前扩展一个位置,而左指针的移动则使窗口的范围缩小。这正是滑动窗口技术的核心思想。

然而,当前的问题在于字符串 s 不仅包含英文字母,还可能包括数字、符号和空格。为了处理这种情况,我们需要一种更通用的方法来记录每个字符的出现次数。不同于只使用大小为26的数组来存储字母的计数,我们可以考虑使用哈希表(或字典)来动态地记录所有可能出现的字符及其对应的计数值。

这个时候通过查找,可以发现 C++ 中的 unordered_set<char> chars; 是 C++ 中用于创建一个存储字符的无序集合(哈希集合)的方法。这种数据结构非常适合用于快速查找、插入和删除操作,因为它的平均时间复杂度为 O(1)。

具体有如下操作:

  • 插入元素
	chars.insert('a');
  • 查找元素
    使用 find() 方法来检查某个元素是否存在于集合中。如果存在,find() 返回一个指向该元素的迭代器;如果不存在,则返回 chars.end()
  • 删除元素
chars.erase('b');
  • 遍历集合
for(char c : chars) {
    std::cout << c << " ";
}
  • 检查集合大小
std::cout << "Set size: " << chars.size() << std::endl;
  • 清空集合
chars.clear();

有了上面的相关基础,我们下面就可以来编写这道题目的代码了。
完整的代码如下(带注释):

class Solution {
public:
    int lengthOfLongestSubstring(string s) {
        // 使用 unordered_set 来记录当前窗口内已经包含的字符
        unordered_set<char> chars;

        // 双指针:左指针 left 和右指针 right,用于定义滑动窗口
        int left = 0, right = 0;

        // 用于记录最长无重复字符子串的长度
        int max_len = 0;

        // 获取字符串的长度,避免在循环中反复调用 size() 提高性能
        int len = s.size();

        // 开始滑动窗口遍历字符串
        while (right < len) {
            // 如果当前右指针指向的字符不在集合中,说明没有重复
            if (chars.find(s[right]) == chars.end()) {
                // 将该字符加入集合中
                chars.insert(s[right]);

                // 更新最大长度:当前窗口大小为 right - left + 1
                max_len = max(max_len, right - left + 1);

                // 右指针向右移动一位
                right++;
            } else {
                // 如果当前字符已经存在,则说明有重复字符
                // 此时需要将左指针右移,直到窗口中不再包含重复字符

                // 从集合中删除左指针对应的字符
                chars.erase(s[left]);

                // 左指针向右移动一位
                left++;
            }
        }

        // 返回最长无重复字符子串的长度
        return max_len;
    }
};

当然,C++ 中还可以使用 unordered_map<char, int> 来解决寻找最长不含重复字符的子字符串问题,相比于 unordered_set,unordered_map 不仅能记录某个字符是否出现在当前窗口中,还能记录该字符出现的次数。
具体的实现代码如下:

class Solution {
public:
    int lengthOfLongestSubstring(string s) {
        unordered_map<char, int> charCount;
        int left = 0, right = 0, max_len = 0, len = s.size();
        while (right < len) {
            if (charCount[s[right]] > 0) {
                charCount[s[left]]--;
                left++;
            } else {
                charCount[s[right]]++;
                max_len = max(max_len, right - left + 1);
                right++;
            }
        }
        return max_len;
    }
};

在这里插入图片描述

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

题目链接:找到字符串中所有字母异位词
题目描述:给定两个字符串 sp,找到 s 中所有 p 的 异位词 的子串,返回这些子串的起始索引。不考虑答案输出的顺序。

解答

我其实一开始的想法就是使用 unordered_set<char> chars 进行求解的,但是我在编码过程中,发现不对,因为可能这个 p 有重复的字符,这样的话,上述这个只能记录是否出现这个字符,但是不能统计该字符的频率。因此上述方法就不行了。编写到下面我就编不下去了。

// 这是错误代码
class Solution {
public:
    vector<int> findAnagrams(string s, string p) {
        unordered_set<char> chars;
        for (int i = 0; i < p.size(); i++) {
            chars.insert(p[i]);
        }
        int left=0,right=0,len=s.size();
        vector<int> result;
        while(right<len){
            if()
        }
    }
};

但是 C++ 中还可以使用 unordered_map<char, int> 来解决寻找最长不含重复字符的子字符串问题,相比于 unordered_set,unordered_map 不仅能记录某个字符是否出现在当前窗口中,还能记录该字符出现的次数。而这是解题的关键。
于是我基于上述思想编写下面的代码:

class Solution {
public:
    vector<int> findAnagrams(string s, string p) {
        unordered_map<char, int> charCount_p;   // 存储p中字符及其频率
        unordered_map<char, int> charCount_win; // 存储当前窗口中字符及其频率
        int len_s = s.size();
        int len_p = p.size();
        // 如果p比s长,直接返回空结果
        if (len_p > len_s) return {};
        // 初始化p的字符频率表
        for (int i = 0; i < len_p; ++i) {
            charCount_p[p[i]]++;
        }
        int left = 0, right = 0;
        vector<int> result;
        while (right < len_s) {
            char currentChar = s[right];
            // 如果currentChar不在p中,整个窗口无效,重置窗口
            if (charCount_p.find(currentChar) == charCount_p.end()) {
                charCount_win.clear(); // 清空窗口统计
                left = right + 1;      // 左指针跳过该无效字符
            } else {
                charCount_win[currentChar]++;
                // 如果当前字符频次超过了p中的频次,移动左指针缩小窗口
                while (charCount_win[currentChar] > charCount_p[currentChar]) {
                    char leftChar = s[left];
                    charCount_win[leftChar]--;
                    if (charCount_win[leftChar] == 0) {
                        charCount_win.erase(leftChar); // 移除计数为0的键值对
                    }
                    left++;
                }
                // 如果窗口大小正好等于p的长度,说明 OK
                if (right - left + 1 == len_p) {
                    result.push_back(left);
                }
            }
            right++;
        }
        return result;
    }
};

上述代码的思路大致如下:

  • 使用两个 unordered_map 分别记录:
    • charCount_p: 字符串 p 中每个字符的出现次数。
    • charCount_win: 当前窗口中每个字符的出现次数。
  • 遇到非法字符(不在 p 中):清空窗口并让左指针跳过它。
  • 遇到频次超标:不断移动左指针缩小窗口直到合法。
  • 窗口大小等于 p.length():说明是异位词,将其起始索引加入结果集。

那上述代码有可以优化的地方吗,当然:
上述代码细致地处理了不同情况,例如遇到不在 p 中的非法字符和字符频次超标的情况,这使得逻辑相对复杂。然而,实际上我们可以简化这些处理过程。不论遇到何种情况,只要没有满足 charCount_win == charCount_p 的条件,则匹配肯定不会成功。因此,我们只需要区分两种情况编写代码:匹配成功不成功

具体来说,无需特别处理非法字符或频次超标的情况。每次调整窗口后,只需检查当前窗口的字符频率是否与 p 的字符频率完全一致。如果一致,则记录匹配成功的起始索引;如果不一致,则继续移动窗口,直到找到下一个可能的匹配或遍历结束。

于是代码可以修改如下:

class Solution {
public:
    vector<int> findAnagrams(string s, string p) {
        vector<int> result;
        if (p.size() > s.size()) return result; // 如果p比s长,直接返回空结果

        unordered_map<char, int> charCount_p;   // 统计p中字符及其频率
        unordered_map<char, int> charCount_win; // 统计当前窗口中的字符频率

        // 初始化p的字符频率表
        for (char c : p) 
            charCount_p[c]++;
        int left = 0, right = 0;
        int len_s = s.size();
        int len_p = p.size();

        while (right < len_s) {
            // 将右指针对应字符加入窗口频率表
            char rightChar = s[right];
            charCount_win[rightChar]++;

            // 当窗口大小超过p的长度时,左指针右移,缩小窗口
            if (right - left + 1 > len_p) {
                char leftChar = s[left];
                charCount_win[leftChar]--;
                if (charCount_win[leftChar] == 0) {
                    charCount_win.erase(leftChar); // 如果减为0,从map中删除
                }
                left++;
            }

            // 检查当前窗口是否和p的字符频率一致
            if (charCount_win == charCount_p) {
                result.push_back(left);
            }

            right++;
        }

        return result;
    }
};

这样就大大简化了判断。

特性/步骤 第二段代码实现 第一段代码实现
数据结构 使用两个 unordered_map<char, int> 分别记录 p 中字符及其频率 (charCount_p) 和当前窗口中的字符频率 (charCount_win)。 同样使用两个 unordered_map<char, int> 来分别记录 p 和当前窗口的字符频率。
处理非法字符 直接将右指针指向的字符加入到窗口中,没有特别处理不在 p 中的字符。 遇到不在 p 中的字符时,清空当前窗口并让左指针跳过该字符,重新开始计数。
频次超标处理 当窗口大小超过 p 的长度时,简单地从窗口中移除左边界的字符,并调整窗口大小。 当某个字符的频次超过 p 中的频次时,通过移动左指针缩小窗口直到窗口内该字符的频次与 p 中相等。
匹配检查时机 每次调整完窗口后立即检查 charCount_win 是否等于 charCount_p 只有当窗口大小恰好等于 p 的长度时,才进行匹配检查。
窗口调整逻辑 主要依赖于窗口大小是否超过了 p 的长度来进行调整。 主要依赖于字符频次是否超标来进行调整,同时考虑了非法字符的情况。
效率与准确性 这种方式虽然可以工作,但在每次循环中都进行完整的哈希表比较,可能会影响性能。 更加精细地控制窗口调整,减少了不必要的哈希表比较次数,理论上提高了效率。
代码复杂度 代码相对简洁,但缺少对特定情况(如非法字符)的处理。 代码稍微复杂一些,但是覆盖了所有边界情况,包括非法字符和频次超标的情况。

实际上,上述的编写还是比较复杂的,我们再看题目,注意到下面的话:
在这里插入图片描述
既然题目限定了字符都是小写字母,也可以用两个 int count_p[26] 和 count_win[26] 替代哈希表,效率更高。
官方代码也是这样实现的。
在这里插入图片描述

class Solution {
public:
    vector<int> findAnagrams(string s, string p) {
        int sLen = s.size();  // 主字符串长度
        int pLen = p.size();  // 模式字符串长度

        // 如果主字符串比模式字符串短,直接返回空结果
        if (sLen < pLen) {
            return vector<int>();
        }

        vector<int> ans;  // 存储所有匹配的起始索引
        // 使用两个大小为26的数组来记录字符频率(只考虑小写字母)
        vector<int> sCount(26);
        vector<int> pCount(26);

        // 初始化:统计第一个窗口(前 pLen 个字符)中的字符频率
        for (int i = 0; i < pLen; ++i) {
            ++sCount[s[i] - 'a'];  // 将 s 的前 pLen 个字符频率统计进 sCount
            ++pCount[p[i] - 'a'];  // 统计 p 的字符频率
        }

        // 判断初始窗口是否匹配
        if (sCount == pCount) {
            ans.emplace_back(0);  // 匹配则将起始索引 0 加入结果集
        }

        // 开始滑动窗口:从第 1 个位置开始,直到不能再形成一个完整窗口为止
        for (int i = 0; i < sLen - pLen; ++i) {
            // 窗口左边界右移一位:移除窗口最左边的字符
            --sCount[s[i] - 'a'];

            // 窗口右边界右移一位:加入新的右边界的字符
            ++sCount[s[i + pLen] - 'a'];

            // 检查当前窗口是否与 p 的字符频率一致
            if (sCount == pCount) {
                // 若一致,则当前窗口的起始位置是 i + 1
                ans.emplace_back(i + 1);
            }
        }

        return ans;
    }
};

其实和我前面的差不多,判断初始位置,然后每次移进一个右字符,弹出一个左字符,然后再进行判断。

在这里插入图片描述
官方还给出了一种优化的方式:
在这里插入图片描述

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

        // 如果主串长度小于模式串,直接返回空结果
        if (sLen < pLen) {
            return vector<int>();
        }

        vector<int> ans;              // 存储所有匹配的起始索引
        vector<int> count(26);        // 用于记录每个字符在窗口和 p 中的差值
        for (int i = 0; i < pLen; ++i) {
            ++count[s[i] - 'a'];      // 当前窗口中的字符增加
            --count[p[i] - 'a'];      // 模式串中的字符减少
        }

        // 统计当前窗口中有多少字符与 p 中的数量不同
        int differ = 0;
        for (int j = 0; j < 26; ++j) {
            if (count[j] != 0) {
                ++differ;
            }
        }

        // 如果初始窗口中没有差异,说明是异位词
        if (differ == 0) {
            ans.emplace_back(0);
        }

        // 开始滑动窗口:从第1个位置开始,直到不能再形成完整窗口为止
        for (int i = 0; i < sLen - pLen; ++i) {
            char leftChar = s[i];           // 即将移出窗口的字符(左边界)
            char rightChar = s[i + pLen];   // 即将加入窗口的字符(右边界)

            // 处理即将移出窗口的字符
            if (count[leftChar - 'a'] == 1) {
                // 之前这个字符比 p 多一个,现在要减掉,会导致差异减少
                --differ;
            } else if (count[leftChar - 'a'] == 0) {
                // 之前相同,现在会变不同,所以差异增加
                ++differ;
            }
            --count[leftChar - 'a'];  // 实际减去该字符

            // 处理即将加入窗口的字符
            if (count[rightChar - 'a'] == -1) {
                // 之前这个字符比 p 少一个,现在补上,差异减少
                --differ;
            } else if (count[rightChar - 'a'] == 0) {
                // 之前相同,现在变不同,差异增加
                ++differ;
            }
            ++count[rightChar - 'a'];  // 实际加上该字符

            // 如果当前窗口中所有字符都匹配,即差异为0,则找到一个异位词
            if (differ == 0) {
                ans.emplace_back(i + 1);  // 加入当前窗口的起始索引
            }
        }

        return ans;
    }
};

在这里插入图片描述

步骤 功能描述
初始化窗口 先统计第一个窗口(前 p.length() 个字符)与 p 的字符频率差
计算差异数 统计当前窗口中有多少字符与 p 不一致
滑动窗口 每次只处理窗口首尾两个字符的变化,更新 countdiffer
判断匹配 differ == 0,则当前窗口是一个异位词

官方给的第二段优化滑动窗口的代码,有下面的问题需要解释一下:

问题

在这段代码中:

  • 处理 leftChar(即将移出窗口的字符)时,为什么只判断 count[leftChar - 'a'] == 1== 0
  • 为什么不处理 count[leftChar - 'a'] == -1 的情况?
  • 同理,处理 rightChar 时,为什么不处理 count[rightChar - 'a'] == 1

一、关于 leftChar 的判断为何不考虑 count[leftChar - 'a'] == -1

代码片段
if (count[leftChar - 'a'] == 1) {
    --differ;
} else if (count[leftChar - 'a'] == 0) {
    ++differ;
}
--count[leftChar - 'a'];
解析

当我们将一个字符 leftChar 移出窗口时:

  • 如果它在当前窗口中比 p 多了 1 个(即 count[leftChar - 'a'] == 1),那么减去这个字符后,就变成一样多了 → 差异数减少。
  • 如果它在当前窗口中与 p 相等(即 count[leftChar - 'a'] == 0),那么减去之后就不相等了 → 差异数增加。
为什么没有处理 count[leftChar - 'a'] == -1

因为 count[leftChar - 'a'] == -1 表示:当前窗口中该字符比 p 少了一个,也就是它已经在“差异”中了。
而我们现在只是从窗口中 再减去一个字符,只会让这个差距更大(变成 -2)。所以:

  • 它本来就在差异里(count != 0)
  • 减完以后还在差异里(count != 0)
    ➡️ 所以 differ 不会变化,不需要做任何调整。

二、关于 rightChar 的判断为何不考虑 count[rightChar - 'a'] == 1

代码片段
if (count[rightChar - 'a'] == -1) {
    --differ;
} else if (count[rightChar - 'a'] == 0) {
    ++differ;
}
++count[rightChar - 'a'];
解析

当我们将一个字符 rightChar 加入窗口时:

  • 如果它在当前窗口中比 p 少了 1 个(即 count[rightChar - 'a'] == -1),加上这个字符后,就变成一样多了 → 差异数减少。
  • 如果它在当前窗口中与 p 相等(即 count[rightChar - 'a'] == 0),那么加上之后就不相等了 → 差异数增加。
为什么没有处理 count[rightChar - 'a'] == 1

如果 count[rightChar - 'a'] == 1,表示当前窗口中该字符已经比 p 多了一个。
现在再加上一个,就会变成多两个,仍然处于“差异”状态(count != 0)。

➡️ 所以 differ 不变,不需要处理。

总结

我们只关心字符是否从“差异状态”进入“相同状态”,或者从“相同状态”进入“差异状态”。其他情况下,字符仍处于“差异状态”,不影响 differ 的值。

条件 是否影响 differ 原因
count[x] == 1(移出) 从“多一个”变成“一样”,差异数减少
count[x] == 0(移出) 从“一样”变成“少一个”,差异数增加
count[x] == -1(移出) 从“少一个”变成“少两个”,仍在差异中
count[x] == -1(加入) 从“少一个”变成“一样”,差异数减少
count[x] == 0(加入) 从“一样”变成“多一个”,差异数增加
count[x] == 1(加入) 从“多一个”变成“多两个”,仍在差异中

网站公告

今日签到

点亮在社区的每一天
去签到