当前位置: 首页 > news >正文

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_maxfor (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_maxsuffix_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: 枚举每个窗口起点 ifor (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] += 1left = 0right = 0valid = 0     # 记录满足 need 条件的字符个数start = 0     # 最小窗口起始索引length = ∞    # 最小窗口长度while right < len(s):c = s[right]right += 1if c is in need:window[c] += 1if window[c] == need[c]:valid += 1# 当窗口满足条件时,尝试缩小左边界while valid == len(need):# 更新最小窗口结果if right - left < length:start = leftlength = right - leftd = s[left]left += 1if d is in need:if window[d] == need[d]:valid -= 1window[d] -= 1if 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);}
};

相关文章:

  • Apple Vision Pro空间视频创作革命:从180度叙事到沉浸式语法的重构——《Adventure》系列幕后技术深度解析
  • [c++项目]云备份项目测试
  • 抢购Python代码示例与技术解析
  • Java中的设计模式
  • C++:字符数组与字符串指针变量的大小
  • 35页AI应用PPT《DeepSeek如何赋能职场应用》DeepSeek本地化部署与应用案例合集
  • 【论文阅读】BEVFormer
  • P8803 [蓝桥杯 2022 国 B] 费用报销
  • TypeScript中文文档
  • 【Java项目脚手架系列】第七篇:Spring Boot + Redis项目脚手架
  • 配置别名路径 @
  • ArcGIS切片方案记录bundle文件
  • 机器学习笔记3
  • 【iOS】alloc的实际流程
  • 106. 从中序与后序遍历序列构造二叉树
  • 本地化部署HomeAssistant语音助手并接入DeepSeek
  • 波导模型(表面等离激元、石墨烯等)本征模式分析、各种类型波导传输效率求解
  • JAVA数组题(7)
  • STL - stack 和 queue 及容器适配器模式的介绍
  • C++11(2)
  • 外交部:国际社会广泛理解和支持中方不同意台参加世卫大会的决定
  • 由我国牵头制定,适老化数字经济国际标准发布
  • 腾讯一季度营收增长13%,马化腾:战略性的AI投入将带来长期回报
  • 4月企业新发放贷款利率处于历史低位
  • 日月谭天丨这轮中美关税会谈让台湾社会看清了什么?
  • 白天气温超30℃的北京,晚间下起了冰雹