子串 - LeetCode hot 100
@jarringslee
文章目录
- [子串 - LeetCode hot 100](#子串 - LeetCode hot 100)
-
- 子串问题的常见算法与技巧
- 算法题
-
- [10 560. 和为 K 的子数组(https://leetcode.cn/problems/subarray-sum-equals-k/)](#10 560. 和为 K 的子数组)
- [11 239. 滑动窗口最大值(https://leetcode.cn/problems/sliding-window-maximum/)](#11 239. 滑动窗口最大值)
- [12 76. 最小覆盖子串(https://leetcode.cn/problems/minimum-window-substring/)](#12 76. 最小覆盖子串)
子串问题的常见算法与技巧
子串问题通常涉及在给定字符串中查找、匹配、统计或操作连续字符序列。这类问题在算法面试和实际应用中非常常见,核心特点是要求处理字符串的连续性和局部性。
前缀和
前缀和通过预处理字符串或数组,将子串求和问题转化为前缀数组的差值计算,优化时间复杂度。
适用场景:
- 统计子串的和或乘积
- 快速计算区间和
代码示例:
javascript
function prefixSum(s) {
const prefix = [0];
for (let i = 0; i < s.length; i++) {
prefix[i + 1] = prefix[i] + s.charCodeAt(i);
}
return prefix;
}
滑动窗口
滑动窗口通过动态调整窗口的左右边界来避免重复计算,适用于寻找满足条件的连续子串。
适用场景:
- 最长/最短满足条件的子串
- 固定长度的子串统计
代码示例:
javascript
function longestSubstringWithoutRepeating(s) {
const map = new Map();
let left = 0, maxLen = 0;
for (let right = 0; right < s.length; right++) {
if (map.has(s[right])) {
left = Math.max(left, map.get(s[right]) + 1);
}
map.set(s[right], right);
maxLen = Math.max(maxLen, right - left + 1);
}
return maxLen;
}
双指针
双指针通过协同移动两个指针来缩小搜索范围,常用于有序或部分有序的数据。
适用场景:
- 回文子串判断
- 合并或比较子串
代码示例:
javascript
function isPalindrome(s) {
let left = 0, right = s.length - 1;
while (left < right) {
if (s[left] !== s[right]) return false;
left++;
right--;
}
return true;
}
KMP算法
KMP算法通过部分匹配表优化子串匹配过程,减少不必要的比较。
适用场景:
- 高效子串匹配
- 模式串重复性高的场景
代码示例:
javascript
function buildKMPTable(pattern) {
const table = [0];
let len = 0, i = 1;
while (i < pattern.length) {
if (pattern[i] === pattern[len]) {
len++;
table[i] = len;
i++;
} else {
if (len !== 0) len = table[len - 1];
else table[i++] = 0;
}
}
return table;
}
算法题
10 560. 和为 K 的子数组
给你一个整数数组
nums和一个整数k,请你统计并返回 该数组中和为k的子数组的个数 。子数组是数组中元素的连续非空序列。
输入:nums = [1,1,1], k = 2 输出:2 输入:nums = [1,2,3], k = 3 输出:2
两次遍历:前缀和+哈希表
本题需要通过对数组各成员的前缀和的作差来得出答案。最直观的方法就是暴力循环,使用left和right指针作为滑动窗口,通过不断比较k值和两指针对应的前缀和之差来维护左右窗口。这是一种O(n2)的解法。我们也可以建立哈希表通过储值来计算两边界之差。
本题中,我们需要的前缀和相当于数组的边界:
数组值: 1 3 5 7
前缀和: 0 | 1 | 4 | 9 | 16
这样的边界更方便我们去遍历寻值,前缀和相当于是:前n个数的和,这样也避免了前缀和是否包括当前成员以及首尾成员的处理问题。
寻值的基本逻辑是等式的转化:我们将 arri - arrj = k 的问题转化成 arri - k = arrj,这是两数之和的寻值思想,通过将右边界与目标值相减是否能得出左边界来确定答案。
首先建立前缀和数组,然后建立哈希表,遍历前缀和数组,对于每一个成员都判断将其减去k的值能否在哈希表中查到,查到就累加答案。然后将该成员存入哈希表,如果已存入就累加存入值。
时间复杂度:O(n)
空间复杂度:O(n)
js
/**
* @param {number[]} nums
* @param {number} k
* @return {number}
*/
var subarraySum = function(nums, k) {
let n = nums.length;
let sum = new Array(n + 1).fill(0);
for (let i = 0; i < n; i++) {
sum[i + 1] = sum[i] + nums[i];
}
let ans = 0;
let map = new Map();
for (let i of sum) {
ans += map.get(i - k) ?? 0;
map.set(i, (map.get(i) ?? 0) + 1);
}
return ans;
};
一次遍历:边算前缀和边寻值存值
我们可把前缀和数组的一轮遍历优化掉,从而使用更小的空间来解题。只需在每次map循环时累加当前数字为前缀和即可。
时间复杂度:O(n) 其中 n 为 nums 的长度。
空间复杂度:O(m) 其中 m 为不同前缀和的个数。如果设置了哈希表的容量,则空间复杂度为 O(n)
js
var subarraySum = function(nums, k) {
const map = new Map();
map.set(0, 1); // 还没遍历任何数字时,前缀和为 0,出现 1 次
let ans = 0;
let s = 0; // 当前前缀和
for (const x of nums) {
s += x; // 算出当前位置右边界的前缀和
// 如果以前出现过前缀和 s-k,
// 那么从那些位置到当前位置的子数组和都等于 k
ans += cnt.get(s - k) ?? 0;
// 当前前缀和留给后面的位置使用
cnt.set(s, (cnt.get(s) ?? 0) + 1);
}
return ans;
};
11 239. 滑动窗口最大值
给你一个整数数组
nums,有一个大小为k的滑动窗口从数组的最左侧移动到数组的最右侧。你只可以看到在滑动窗口内的k个数字。滑动窗口每次只向右移动一位。返回 滑动窗口中的最大值 。
输入:nums = [1,3,-1,-3,5,3,6,7], k = 3 输出:[3,3,5,5,6,7] 解释: 滑动窗口的位置 最大值 --------------- ----- [1 3 -1] -3 5 3 6 7 3 1 [3 -1 -3] 5 3 6 7 3 1 3 [-1 -3 5] 3 6 7 5 1 3 -1 [-3 5 3] 6 7 5 1 3 -1 -3 [5 3 6] 7 6 1 3 -1 -3 5 [3 6 7] 7 输入:nums = [1], k = 1 输出:[1]
使用单调双端队列维护最大值
使用队列来记录当前窗口中的最大值。
使用一个空数组作为队列,队列中存放的是数组的下标,然后将原数组进行遍历。
首先执行的是最重要的比较机制:在队列仍有成员的情况下,将当前遍历的成员和队列最尾端成员比较,如果队尾成员小于或等于当前遍历成员,那就删除队尾成员,直到队列清空或找到大于它的成员后,将当前遍历成员push进队尾。
然后是窗口大小的维护:如果窗口大小大于k(遍历窗口长度为i - q[0]。i 是当前窗口右端下标,q[0] 是队首元素的原下标。两者差达到 k 时,说明队首已经不在长度为 k 的窗口内)那就去掉一个队首成员,如果遍历长度一旦大于等于k(即下标i大于等于k)就记录答案。
时间复杂度:O(n)
空间复杂度O(k)
js
/**
* @param {number[]} nums
* @param {number} k
* @return {number[]}
*/
var maxSlidingWindow = function(nums, k) {
let ans = [];
let q = [];
for (let i = 0; i < nums.length; i++) {
// 如果队列没有被清空那就一直让队尾成员和当前成员进行比较,比当前成员小就删除,直到找到比当前遍历成员大的队列成员或者队列被清空
while (q.length && nums[i] >= nums[q[q.length - 1]]) q.pop();
// 当前遍历成员进队
q.push(i);
// 队列长度一旦大于指定长度就删除队首成员
if (i - q[0] >= k) q.shift()
// 队列长度一旦等于指定长度就开始记录答案
if (i >= k - 1) ans.push(nums[q[0]]);
}
return ans;
12 76. 最小覆盖子串
给定两个字符串
s和t,长度分别是m和n,返回 s 中的 最短窗口 子串 ,使得该子串包含t中的每一个字符(包括重复字符 )。如果没有这样的子串,返回空字符串""。测试用例保证答案唯一。
输入:s = "ADOBECODEBANC", t = "ABC" 输出:"BANC" 解释:最小覆盖子串 "BANC" 包含来自字符串 t 的 'A'、'B' 和 'C'。 输入:s = "a", t = "a" 输出:"a" 解释:整个字符串 s 是最小覆盖子串。
滑动窗口:先伸再缩
这道题目的解法和 209. 长度最小的子数组 解法相似。为了能在给定子串s中找到覆盖目标子串t所有字母的一段子串,我们使用双指针+滑动窗口 的思想来寻找。首先初始化左右指针为0,先移动右指针,直到窗口能包含t的所有字母,接下来移动左指针,在子串一直能被完全覆盖的情况下开始缩减窗口范围,直到刚好有一个目标字母被卡出去,停止收缩左指针,开始移动右指针,直到又能覆盖给定目标子串后,继续收缩左指针。
我们需要两个哈希表:need存储目标子串t以及每个字母出现的次数,因为出现多次的字母在给定子串里也需要出现相应的次数;window记录当前窗口中出现的目标串字母以及出现的次数,左右指针每移动一次都要对这个表中的字符进行相应的添加或删除操作。
时间复杂度:O(n) 一次遍历
空间复杂度:O(n)
js
/**
* @param {string} s
* @param {string} t
* @return {string}
*/
var minWindow = function(s, t) {
// 初始化哈希表
const need = new Map();
const window = new Map();
// 遍历存值
for (const c of t) {
need.set(c, (need.get(c) ?? 0) + 1);
}
let left = 0; // 左指针
let valid = 0; // 记录当前橱窗口中出现的目标字符的总数量 用于核对当前窗口是否为正确答案之一
let start = 0; // 存储正确答案的起始值
let len = Infinity; // 存储正确答案的长度 初始一个哨兵值为无限(等价于 s.length +1)
// 外层循环:右指针右移
for (let right = 0; right < s.length; right++) {
// 先存储当前遍历成员
const c = s[right];
// 如果是目标字符就记录进window
if (need.has(c)) {
window.set(c, (window.get(c) ?? 0) + 1);
// 如果数量达标就记录valid
if (window.get(c) === need.get(c)) valid++;
}
// 所有目标字符出现数量达标 => 该窗口已包含目标子串 可以收缩
while(valid === need.size) {
// 如果当前窗口比已储存答案小就更新答案
if (right - left + 1 < len) {
start = left;
len = right - left + 1;
}
// 存储左指针对应成员
const d = s[left];
// 先判断该成员是否为目标字符
if (need.has(d)) {
// 如果是 而且 该字符数量与目标字符数量相等 说明移动会失去一个目标子串
if (window.get(d) === need.get(d)) valid--;
// 删除窗口记录的目标子串
window.set(d, window.get(d) - 1);
}
// 正式左移指针
left++;
}
}
// 返回答案子串
return len === Infinity ? "" :s.substring(start, start + len);
};