leetcode-hot-100 (子串)

1. 和为 K 的子数组

题目链接:和为 K 的子数组
题目描述:给你一个整数数组 nums 和一个整数 k ,请你统计并返回 该数组中和为 k 的子数组的个数 。

子数组是数组中元素的连续非空序列。

解答

方法一:暴力枚举法(Brute force)

看到这道题目,首先肯定想到的是暴力破解,也就是遍历所有的子数组,计算每个子数组的和,如果等于 k k k,就计数加一。
具体的思想如下:

  • 枚举所有起点 i i i(从 0 0 0 n − 1 n-1 n1)。
  • 对于每个起点 i i i,枚举终点 j j j(从 i i i n − 1 n-1 n1)。
  • 计算子数组 n u m s [ i . . j ] nums[i..j] nums[i..j] 的和。
  • 如果和为 k k k,则计数器加一。

于是代码可以非常容易的写出来:

class Solution {
public:
    int subarraySum(vector<int>& nums, int k) {
        int len = nums.size();
        int sum = 0;
        int ans = 0;
        for (int i = 0; i < len; i++) {
            for (int j = i; j < len; j++) {
                sum = 0;
                for (int l = i; l < j + 1; l++) {
                    sum += nums[l];
                }
                if (sum == k)
                    ans++;
            }
        }
        return ans;
    }
};

显然,上述代码的时间复杂度是很高的。肯定是过不了这道题目的,因此我们必须在原有代码的基础上进行优化。
在这里插入图片描述
显然,我们需要考虑的是能不能将 for 的次数减少,从而能够降低时间复杂度,于是我们回顾上述代码,发现最外层循环是不能够删除的,那么我们就看最里面的循环能不能消掉。实际上,我们在上述代码除了限定左边界(也就是开始位置)之外,还设定了右边界,这就导致需要在上一层循环,要是我不设定右边界,而是每来一个 j ,我都和给定的 k 进行比较,看是否匹配,要是匹配的话,说明成功,这样开始下一轮循环,这样就不需要最里面的这层循环了。

for (int l = i; l < j + 1; l++) {
                    sum += nums[l];
                }

于是上述代码改进如下:

class Solution {
public:
    int subarraySum(vector<int>& nums, int k) {
        int n = nums.size();
        int ans = 0;

        // 枚举所有可能的子数组起点 i 和终点 j(闭区间)
        for (int i = 0; i < n; ++i) {
            int sum = 0;
            for (int j = i; j < n; ++j) {
                sum += nums[j]; // 逐步累加,而不是每次都从头加
                if (sum == k) {
                    ++ans;
                    // break; 
                    // 这里break不对,因为数组可以含有负数,要是都是正数的话,可以加上这个break,最好是不加,因为容易出现问题。
                }
            }
        }

        return ans;
    }
};

好的,提交,结果通过。
在这里插入图片描述
额,但是这个时间复杂度还是很高,不过欣慰的是空间复杂度比较的低。

方法二:前缀和 + 暴力查找

要是对算法有了解的,肯定是听说过前缀和的,也就是对于数组 n u m s [ 0... n ] nums[0...n] nums[0...n] 中的元素 n u m s [ j ] nums[j] nums[j] 中存储的不是原来 n u m s [ j ] nums[j] nums[j] 的值了,而是 从 n u m s [ 0 ] nums[0] nums[0] n u m s [ j − 1 ] nums[j-1] nums[j1] 的和.当然这只是大致的思想,具体编码可以根据题意稍稍变动。
于是我们的处理方法可以变成如下的步骤:
先预处理出数组的前缀和数组 prefix_sum,其中 prefix_sum[i] 表示从 nums[0] 到 nums[i-1] 的和。然后枚举任意两个位置 i < j,判断 prefix_sum[j] - prefix_sum[i] == k 是否成立。

根据上述思想,编码如下:

class Solution {
public:
    int subarraySum(vector<int>& nums, int k) {
        int n = nums.size();
        vector<int> prefix_sum(n + 1, 0);

        // 构造前缀和数组
        for (int i = 0; i < n; ++i) {
            prefix_sum[i + 1] = prefix_sum[i] + nums[i];
        }

        int count = 0;
        // 枚举所有 i < j 的组合
        for (int j = 1; j <= n; ++j) {
            for (int i = 0; i < j; ++i) {
                if (prefix_sum[j] - prefix_sum[i] == k) {
                    ++count;
                }
            }
        }

        return count;
    }
};

结果好点了,但是还是不尽如人意:
在这里插入图片描述

方法三:前缀和 + 哈希表优化

核心思想:
利用前缀和的性质 + 哈希表记录出现次数,实现一次遍历解决问题。
关键公式:

prefix_sum[j] - prefix_sum[i] = k
=> prefix_sum[j] - k = prefix_sum[i]
也就是说,在遍历到 j 时,只要我们知道前面有多少个 prefix_sum[i] 等于 prefix_sum[j] - k,就能知道有多少个子数组以 j 结尾且和为 k

实现步骤:

  1. 初始化一个哈希表 count_map,用来记录某个前缀和出现的次数。
  2. 设置初始条件:prefix_sum = 0 出现了 1 次(表示空子数组)。
  3. 遍历数组,逐步累加前缀和 current_sum
  4. 每次计算 current_sum - k,查看哈希表中是否有这个值:
    • 要是有,则加上它的出现次数到结果中。
  5. 将当前 current_sum 的值加入哈希表中,继续下一轮。

在这里插入图片描述

class Solution {
public:
    int subarraySum(vector<int>& nums, int k) {
        unordered_map<int, int> count_map;
        count_map[0] = 1; // 表示前缀和为 0 出现了一次(空子数组)

        int current_sum = 0;
        int count = 0;

        for (int num : nums) {
            current_sum += num;

            // 查找是否有 current_sum - k 出现过
            if (count_map.find(current_sum - k) != count_map.end()) {
                count += count_map[current_sum - k];
            }

            // 将当前前缀和加入 map 中
            count_map[current_sum]++;
        }

        return count;
    }
};

结果还不错:
在这里插入图片描述
官方答案和上述差不多。

2. 滑动窗口最大值

题目链接:滑动窗口最大值
题目描述:
给你一个整数数组 n u m s nums nums,有一个大小为 k k k 的滑动窗口从数组的最左侧移动到数组的最右侧。你只可以看到在滑动窗口内的 k k k 个数字。滑动窗口每次只向右移动一位。

返回 滑动窗口中的最大值 。

解答

看到这道题目,觉得有点类似于卷积中的最大池化,最大池化是在二维数组中找寻一个最大值,这个是在一维中,感觉思路应该差不多(只是我的看法)。

方法一:暴力破解(超时)

还是一样,肯定最简单也是最好想到的方法就是暴力破解法。
也就是对于每个滑动窗口的位置,直接遍历窗口中的 k k k 个元素,找出最大值。

class Solution {
public:
    vector<int> maxSlidingWindow(vector<int>& nums, int k) {
        vector<int> result;
        int n = nums.size();
        // 防止无效输入
        if (n == 0 || k == 0) return result;
        // 遍历所有滑动窗口的位置
        for (int i = 0; i <= n - k; ++i) {
            int max_val = nums[i];
            // 找当前窗口的最大值
            for (int j = i + 1; j < i + k; ++j) {
                max_val = max(max_val, nums[j]);
            }
            result.push_back(max_val);
        }
        return result;
    }
};

不用说,包超时的,要不然这道题目就不会标记为难题了。
在这里插入图片描述
仔细读题,可以发现,相邻的滑动窗口,实际上共用 k − 1 k-1 k1 个元素,只有一个元素使变化的,因此上述暴力破解的方法有很多都是没有必要的,下面就需要根据这个点来进行代码优化。

方法二: 优先队列

这个方法借鉴的是官方的题解(点此进行详细查看官方题解)。
我想不到,只能对官方题解做出一些说明:

  • 使用一个 大根堆(优先队列) 来维护当前窗口中元素的 (值, 索引) 对。
  • 初始时,将前 k 个元素全部加入堆中。
  • 每次窗口右移一位时:
    • 将当前新元素 (nums[i], i) 加入堆。
    • 弹出堆顶元素(最大值),如果该元素的索引 不在当前窗口范围内,继续弹出直到堆顶元素在窗口内。
  • 堆顶元素即为当前窗口的最大值。
  • 时间复杂度:O(n log k),因为每个元素最多进堆、出堆一次。
class Solution {
public:
    vector<int> maxSlidingWindow(vector<int>& nums, int k) {
        int n = nums.size();
        vector<int> result;
        // 大根堆,存储 (元素值, 索引)
        priority_queue<pair<int, int>> max_heap;
        // 初始化:先把前 k 个元素放入堆中
        for (int i = 0; i < k; ++i) {
            max_heap.push({nums[i], i});
        }
        // 第一个窗口的最大值
        result.push_back(max_heap.top().first);
        // 滑动窗口移动
        for (int i = k; i < n; ++i) {
            // 当前新元素入堆
            max_heap.push({nums[i], i});
            // 移除堆顶不在窗口内的元素
            while (!max_heap.empty() && max_heap.top().second <= i - k) {
                max_heap.pop();
            }
            // 此时堆顶是当前窗口最大值
            result.push_back(max_heap.top().first);
        }
        return result;
    }
};

在这里插入图片描述
简要说一下代码的原理,就是这个队列中,每次存储的值我其实不在乎,只需要堆顶的元素是进入堆中所有的元素中最大的那一个即可。同时引入 pair 是为了判断这个堆顶元素,要是其索引小于 i - k + 1,则说明它已经不在当前窗口内了,应该从堆中移除。
简单模拟一下过程:
假设输入数组为 nums = [1, 3, -1, -3, 5, 3, 6, 7],窗口大小 k = 3

初始化阶段

  • 将前 k=3 个元素 [1, 3, -1] 加入优先队列(大根堆)中。
  • 初始时的优先队列为:[(3, 1), (1, 0), (-1, 2)]
    当前优先队列状态:
[(3, 1), (1, 0), (-1, 2)]

结果列表: [3]


第一次滑动 (i=3)

  • 新元素 -3 及其索引 (i=3) 加入优先队列。
  • 加入新元素后优先队列: [(3, 1), (1, 0), (-1, 2), (-3, 3)]
  • 移除不在窗口范围内的堆顶元素(如果有的话)。此时不需要移除任何元素,因为 (3, 1) 在窗口范围内。

当前优先队列状态:

[(3, 1), (1, 0), (-1, 2), (-3, 3)]

结果列表: [3, 3]


第二次滑动 (i=4)

  • 新元素 5 及其索引 (i=4) 加入优先队列。
  • 加入新元素后优先队列: [(5, 4), (3, 1), (-1, 2), (-3, 3)]
  • 移除不在窗口范围内的堆顶元素(如果有的话)。此时不需要移除任何元素,因为 (5, 4) 在窗口范围内。

当前优先队列状态:

[(5, 4), (3, 1), (-1, 2), (-3, 3)]

结果列表: [3, 3, 5]


第三次滑动 (i=5)

  • 新元素 3 及其索引 (i=5) 加入优先队列。
  • 加入新元素后优先队列: [(5, 4), (3, 5), (3, 1), (-1, 2), (-3, 3)]
  • 移除不在窗口范围内的堆顶元素(如果有的话)。此时不需要移除任何元素,因为 (5, 4) 在窗口范围内。

当前优先队列状态:

[(5, 4), (3, 5), (3, 1), (-1, 2), (-3, 3)]

结果列表: [3, 3, 5, 5]


第四次滑动 (i=6)

  • 新元素 6 及其索引 (i=6) 加入优先队列。
  • 加入新元素后优先队列: [(6, 6), (5, 4), (3, 5), (3, 1), (-1, 2), (-3, 3)]
  • 移除不在窗口范围内的堆顶元素(如果有的话)。此时不需要移除任何元素,因为 (6, 6) 在窗口范围内。

当前优先队列状态:

[(6, 6), (5, 4), (3, 5), (3, 1), (-1, 2), (-3, 3)]

结果列表: [3, 3, 5, 5, 6]


第五次滑动 (i=7)

  • 新元素 7 及其索引 (i=7) 加入优先队列。
  • 加入新元素后优先队列: [(7, 7), (6, 6), (5, 4), (3, 5), (3, 1), (-1, 2), (-3, 3)]
  • 移除不在窗口范围内的堆顶元素(如果有的话)。此时不需要移除任何元素,因为 (7, 7) 在窗口范围内。

当前优先队列状态:

[(7, 7), (6, 6), (5, 4), (3, 5), (3, 1), (-1, 2), (-3, 3)]

结果列表: [3, 3, 5, 5, 6, 7]

方法三: 单调队列

核心思想:

  • 初始化
    • 将前 k 个元素对应的下标加入双端队列,并确保队列中元素按值递减排列。
  • 处理后续元素
    每次向右移动滑动窗口时,将新元素的下标加入队列,并保持队列的单调性(即移除所有比新 - 元素小或相等的元素)。
    • 移除队列头部不在当前窗口范围内的元素。
  • 获取最大值
    • 队列头部元素始终是当前窗口的最大值。
  • 关键操作
    • 入队:每次添加新元素时,移除队尾所有小于等于新元素的元素。
    • 出队:移除队头不在窗口范围内的元素。

代码如下:

class Solution {
public:
    vector<int> maxSlidingWindow(vector<int>& nums, int k) {
        vector<int> result;
        deque<int> deq; // 存储下标
        for (int i = 0; i < nums.size(); ++i) {
            // 移除队列头部不在窗口范围内的元素
            if (!deq.empty() && deq.front() == i - k) {
                deq.pop_front();
            }
            // 移除所有比当前元素小的元素(保持队列单调递减)
            while (!deq.empty() && nums[deq.back()] <= nums[i]) {
                deq.pop_back();
            }
            // 添加当前元素下标到队列
            deq.push_back(i);
            // 当窗口形成后,记录最大值
            if (i >= k - 1) {
                result.push_back(nums[deq.front()]);
            }
        }
        return result;
    }
};

在这里插入图片描述

简单举一个例子:
给定数组 nums = [1, 3, -1, -3, 5, 3, 6, 7] 和窗口大小 k = 3,我们使用单调双端队列来求出每个滑动窗口中的最大值。


📌 步骤分解

✅ 初始状态:窗口 [1, 3, -1]

  • 加入索引 0(值为 1)→ 队列为 [0]
  • 加入索引 1(值为 3),弹出 0 → 队列为 [1]
  • 加入索引 2(值为 -1)→ 队列为 [1, 2]
  • 当前窗口最大值为 nums[1] = 3

✅ 结果列表:[3]


➡️ 第一次滑动:窗口 [3, -1, -3]

  • 新元素索引 3(值为 -3
  • 由于 -3 <= nums[2],直接加入 → 队列为 [1, 2, 3]
  • 队首 1 在窗口内

✅ 结果列表:[3, 3]


➡️ 第二次滑动:窗口 [-1, -3, 5]

  • 新元素索引 4(值为 5
  • 弹出 3-3 < 5),弹出 2-1 < 5),弹出 13 < 5)→ 队列为空
  • 加入 4 → 队列为 [4]
  • 队首 4 在窗口内

✅ 结果列表:[3, 3, 5]


➡️ 第三次滑动:窗口 [-3, 5, 3]

  • 新元素索引 5(值为 3
  • 3 <= nums[4] = 5,所以保留 4,加入 5 → 队列为 [4, 5]
  • 队首 4 在窗口内

✅ 结果列表:[3, 3, 5, 5]


➡️ 第四次滑动:窗口 [5, 3, 6]

  • 新元素索引 6(值为 6
  • 弹出 53 < 6),弹出 45 < 6)→ 队列为空
  • 加入 6 → 队列为 [6]
  • 队首 6 在窗口内

✅ 结果列表:[3, 3, 5, 5, 6]


➡️ 第五次滑动:窗口 [3, 6, 7]

  • 新元素索引 7(值为 7
  • 弹出 66 < 7)→ 队列为空
  • 加入 7 → 队列为 [7]
  • 队首 7 在窗口内

✅ 结果列表:[3, 3, 5, 5, 6, 7]


✅ 最终结果

输入: nums = [1, 3, -1, -3, 5, 3, 6, 7], k = 3
输出: [3, 3, 5, 5, 6, 7]

步骤窗口内容最大值
1[1, 3, -1]3
2[3, -1, -3]3
3[-1, -3, 5]5
4[-3, 5, 3]5
5[5, 3, 6]6
6[3, 6, 7]7

方法四: 分块 + 预处理

首先将数组按每 k 个元素一组进行划分,预处理出两个辅助数组:

prefix_max[i]:表示从当前块的开头到位置 i 的最大值(即前缀最大值)。
suffix_max[i]:表示从位置 i 到当前块结尾的最大值(即后缀最大值)。

这样在查询任意长度为 k 的滑动窗口时:
如果窗口刚好是一个完整的块(i 是 k 的倍数),直接取该块的 prefix_maxsuffix_max 即可;
否则,这个窗口会跨越两个块,我们只需要比较:
当前块的 suffix_max[i]
下一块的 prefix_max[i + k - 1]
最终答案就是这两个值中的最大值。

class Solution {
public:
    vector<int> maxSlidingWindow(vector<int>& nums, int k) {
        int n = nums.size();
        vector<int> prefix_max(n);
        vector<int> suffix_max(n);
        vector<int> result;
        // Step 1: 填充 prefix_max
        for (int i = 0; i < n; ++i) {
            if (i % k == 0) {
                prefix_max[i] = nums[i];
            } else {
                prefix_max[i] = max(prefix_max[i - 1], nums[i]);
            }
        }
        // Step 2: 填充 suffix_max
        suffix_max[n - 1] = nums[n - 1];
        for (int i = n - 2; i >= 0; --i) {
            if ((i + 1) % k == 0) {
                suffix_max[i] = nums[i];
            } else {
                suffix_max[i] = max(suffix_max[i + 1], nums[i]);
            }
        }
        // Step 3: 枚举每个窗口起点 i
        for (int i = 0; i <= n - k; ++i) {
            int j = i + k - 1;
            result.push_back(max(suffix_max[i], prefix_max[j]));
        }
        return result;
    }
};

下面的这个大佬可视化不错,可以看看:可视化分块解法

3. 最小覆盖子串

题目链接:最小覆盖子串
题目描述:

给你一个字符串 s 、一个字符串 t 。返回 s 中涵盖 t 所有字符的最小子串。如果 s 中不存在涵盖 t 所有字符的子串,则返回空字符串 “” 。
注意:
对于 t 中重复字符,我们寻找的子字符串中该字符数量必须不少于 t 中该字符数量。
如果 s 中存在这样的子串,我们保证它是唯一的答案。

解答

只能依照官方的思路了,官方的题解链接:官方题解

🧠 核心思路

1. 使用哈希表或数组来统计字符频率

  • 我们用两个哈希表(或者数组):
    • need[c]:记录子串 t 中每个字符所需的数量。
    • window[c]:记录当前滑动窗口中每个字符的数量。

2. 滑动窗口框架

使用双指针技巧,维护一个左右开闭区间 [left, right) 表示当前窗口。

  • 右指针 right 不断向右扩展窗口,直到窗口满足条件。
  • 一旦窗口满足条件,尝试收缩左指针 left,以寻找更小的有效窗口。
  • 在这个过程中不断更新最小有效窗口的起始位置和长度。

3. 引入计数变量优化判断

  • 使用一个变量 valid 来记录当前窗口中满足 need[c] 数量要求的字符种类数。
    • 当某个字符在窗口中的数量达到了 need[c],就增加 valid
    • 如果该字符数量又不满足了,则减少 valid
  • valid == need.size() 时,说明当前窗口涵盖了 t 中所有字符并满足数量要求。

📐 伪代码逻辑

function minWindow(s, t):
    # 哈希表记录需要的字符和当前窗口的字符数量
    need = defaultdict(int)
    window = defaultdict(int)

    # 初始化 need 字典
    for char in t:
        need[char] += 1

    left = 0
    right = 0
    valid = 0     # 记录满足 need 条件的字符个数
    start = 0     # 最小窗口起始索引
    length = ∞    # 最小窗口长度

    while right < len(s):
        c = s[right]
        right += 1

        if c is in need:
            window[c] += 1
            if window[c] == need[c]:
                valid += 1

        # 当窗口满足条件时,尝试缩小左边界
        while valid == len(need):
            # 更新最小窗口结果
            if right - left < length:
                start = left
                length = right - left

            d = s[left]
            left += 1

            if d is in need:
                if window[d] == need[d]:
                    valid -= 1
                window[d] -= 1

    if length == ∞:
        return ""
    else:
        return s[start : start + length]

✅ 时间复杂度分析

  • 整个算法中,每个字符最多被左右指针各访问一次,因此时间复杂度是 O(N),其中 N 是字符串 s 的长度。

C++实现代码

class Solution {
public:
    string minWindow(string s, string t) {
        // 哈希表记录目标字符需求和当前窗口中的字符数量
        unordered_map<char, int> need, window;

        // 初始化目标字符需求
        for (char c : t) {
            need[c]++;
        }

        int left = 0, right = 0;     // 滑动窗口双指针
        int valid = 0;               // 当前窗口满足条件的字符种类数
        int start = 0;               // 最小窗口起始位置
        int len = INT_MAX;           // 最小窗口长度(初始化为最大值)

        while (right < s.size()) {
            char c = s[right];      // 即将加入窗口的字符
            right++;

            if (need.count(c)) {
                window[c]++;
                if (window[c] == need[c]) {
                    valid++;  // 该字符满足要求,更新 valid
                }
            }

            // 判断是否满足覆盖 t 的条件
            while (valid == need.size()) {
                // 更新当前找到的最小窗口
                if (right - left < len) {
                    start = left;
                    len = right - left;
                }

                char d = s[left];   // 即将移出窗口的字符
                left++;

                if (need.count(d)) {
                    if (window[d] == need[d]) {
                        valid--;  // 移除后不满足条件了,减少 valid
                    }
                    window[d]--;
                }
            }
        }

        return (len == INT_MAX) ? "" : s.substr(start, len);
    }
};
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值