数据结构与算法:6.双指针之滑动窗口

转发:
滑动串口总结1
滑动串口总结2
读完本文,你不仅学会了算法套路,还可以顺便去 LeetCode 上拿下如下题目:

76.最小覆盖子串

567.字符串的排列

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

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


本文是之前的一篇文章 滑动窗口算法详解 的修订版,添加了滑动窗口算法更详细的解释。

本文详解「滑动窗口」这种高级双指针技巧的算法框架,带你秒杀几道高难度的子字符串匹配问题。

LeetCode 上至少有 9 道题目可以用此方法高效解决。但是有几道是 VIP 题目,有几道题目虽不难但太复杂,所以本文只选择点赞最高,较为经典的,最能够讲明白的三道题来讲解。第一题为了让读者掌握算法模板,篇幅相对长,后两题就基本秒杀了。

本文代码为 C++ 实现,不会用到什么编程方面的奇技淫巧,但是还是简单介绍一下一些用到的数据结构,以免有的读者因为语言的细节问题阻碍对算法思想的理解:

unordered_map 就是哈希表(字典),它的一个方法 count(key) 相当于 containsKey(key) 可以判断键 key 是否存在。

可以使用方括号访问键对应的值 map[key]。需要注意的是,如果该 key 不存在,C++ 会自动创建这个 key,并把 map[key] 赋值为 0。

所以代码中多次出现的 map[key]++ 相当于 Java 的 map.put(key, map.getOrDefault(key, 0) + 1)。

本文大部分代码都是图片形式,可以点开放大,更重要的是可以左右滑动方便对比代码。下面进入正题。

一、最小覆盖子串
题目链接

题目不难理解,就是说要在 S(source) 中找到包含 T(target) 中全部字母的一个子串,顺序无所谓,但这个子串一定是所有可能子串中最短的。

如果我们使用暴力解法,代码大概是这样的:

for (int i = 0; i < s.size(); i++)
for (int j = i + 1; j < s.size(); j++)
if s[i:j] 包含 t 的所有字母:
更新答案
思路很直接吧,但是显然,这个算法的复杂度肯定大于 O(N^2) 了,不好。

滑动窗口算法的思路是这样:

1、我们在字符串 S 中使用双指针中的左右指针技巧,初始化 left = right = 0,把索引闭区间 [left, right] 称为一个「窗口」。

2、我们先不断地增加 right 指针扩大窗口 [left, right],直到窗口中的字符串符合要求(包含了 T 中的所有字符)。

3、此时,我们停止增加 right,转而不断增加 left 指针缩小窗口 [left, right],直到窗口中的字符串不再符合要求(不包含 T 中的所有字符了)。同时,每次增加 left,我们都要更新一轮结果。

4、重复第 2 和第 3 步,直到 right 到达字符串 S 的尽头。

这个思路其实也不难,**第 2 步相当于在寻找一个「可行解」,然后第 3 步在优化这个「可行解」,最终找到最优解。**左右指针轮流前进,窗口大小增增减减,窗口不断向右滑动。

下面画图理解一下,needs 和 window 相当于计数器,分别记录 T 中字符出现次数和窗口中的相应字符的出现次数。

初始状态:

0

增加 right,直到窗口 [left, right] 包含了 T 中所有字符:

0

现在开始增加 left,缩小窗口 [left, right]。

0

直到窗口中的字符串不再符合要求,left 不再继续移动。

0

之后重复上述过程,先移动 right,再移动 left…… 直到 right 指针到达字符串 S 的末端,算法结束。

如果你能够理解上述过程,恭喜,你已经完全掌握了滑动窗口算法思想。至于如何具体到问题,如何得出此题的答案,都是编程问题,等会提供一套模板,理解一下就会了。

上述过程可以简单地写出如下伪码框架:

string s, t;
// 在 s 中寻找 t 的「最小覆盖子串」
int left = 0, right = 0;
string res = s;

while(right < s.size()) {
window.add(s[right]);
right++;
// 如果符合要求,移动 left 缩小窗口
while (window 符合要求) {
// 如果这个窗口的子串更短,则更新 res
res = minLen(res, window);
window.remove(s[left]);
left++;
}
}
return res;
如果上述代码你也能够理解,那么你离解题更近了一步。现在就剩下一个比较棘手的问题:如何判断 window 即子串 s[left…right] 是否符合要求,是否包含 t 的所有字符呢?

可以用两个哈希表当作计数器解决。用一个哈希表 needs 记录字符串 t 中包含的字符及出现次数,用另一个哈希表 window 记录当前「窗口」中包含的字符及出现的次数,如果 window 包含所有 needs 中的键,且这些键对应的值都大于等于 needs 中的值,那么就可以知道当前「窗口」符合要求了,可以开始移动 left 指针了。

现在将上面的框架继续细化:

string s, t;
// 在 s 中寻找 t 的「最小覆盖子串」
int left = 0, right = 0;
string res = s;

// 相当于两个计数器
unordered_map<char, int> window;
unordered_map<char, int> needs;
for (char c : t) needs[c]++;

// 记录 window 中已经有多少字符符合要求了
int match = 0;

while (right < s.size()) {
char c1 = s[right];
if (needs.count(c1)) {
window[c1]++; // 加入 window
if (window[c1] == needs[c1])
// 字符 c1 的出现次数符合要求了
match++;
}
right++;

// window 中的字符串已符合 needs 的要求了
while (match == needs.size()) {
    // 更新结果 res
    res = minLen(res, window);
    char c2 = s[left];
    if (needs.count(c2)) {
        window[c2]--; // 移出 window
        if (window[c2] < needs[c2])
            // 字符 c2 出现次数不再符合要求
            match--;
    }
    left++;
}

}
return res;
上述代码已经具备完整的逻辑了,只有一处伪码,即更新 res 的地方,不过这个问题太好解决了,直接看解法吧!

string minWindow(string s, string t) {
// 记录最短子串的开始位置和长度
int start = 0, minLen = INT_MAX;
int left = 0, right = 0;

unordered_map<char, int> window;
unordered_map<char, int> needs;
for (char c : t) needs[c]++;

int match = 0;

while (right < s.size()) {
    char c1 = s[right];
    if (needs.count(c1)) {
        window[c1]++;
        if (window[c1] == needs[c1]) 
            match++;
    }
    right++;
    
    while (match == needs.size()) {
        if (right - left < minLen) {
            // 更新最小子串的位置和长度
            start = left;
            minLen = right - left;
        }
        char c2 = s[left];
        if (needs.count(c2)) {
            window[c2]--;
            if (window[c2] < needs[c2])
                match--;
        }
        left++;
    }
}
return minLen == INT_MAX ?
            "" : s.substr(start, minLen);

}
如果直接甩给你这么一大段代码,我想你的心态是爆炸的,但是通过之前的步步跟进,你是否能够理解这个算法的内在逻辑呢?你是否能清晰看出该算法的结构呢?

这个算法的时间复杂度是 O(M + N),M 和 N 分别是字符串 S 和 T 的长度。因为我们先用 for 循环遍历了字符串 T 来初始化 needs,时间 O(N),之后的两个 while 循环最多执行 2M 次,时间 O(M)。

读者也许认为嵌套的 while 循环复杂度应该是平方级,但是你这样想,while 执行的次数就是双指针 left 和 right 走的总路程,最多是 2M 嘛。

二、找到字符串中所有字母异位词
题目链接

这道题的难度是 Easy,但是评论区点赞最多的一条是这样:

How can this problem be marked as easy???

实际上,这个 Easy 是属于了解双指针技巧的人的,只要把上一道题的代码改中更新 res 部分的代码稍加修改就成了这道题的解:

vector findAnagrams(string s, string t) {
// 用数组记录答案
vector res;
int left = 0, right = 0;
unordered_map<char, int> needs;
unordered_map<char, int> window;
for (char c : t) needs[c]++;
int match = 0;

while (right < s.size()) {
    char c1 = s[right];
    if (needs.count(c1)) {
        window[c1]++;
        if (window[c1] == needs[c1])
            match++;
    }
    right++;

    while (match == needs.size()) {
        // 如果 window 的大小合适
        // 就把起始索引 left 加入结果
        if (right - left == t.size()) {
            res.push_back(left);
        }
        char c2 = s[left];
        if (needs.count(c2)) {
            window[c2]--;
            if (window[c2] < needs[c2])
                match--;
        }
        left++;
    }
}
return res;

}
因为这道题和上一道的场景类似,也需要 window 中包含串 t 的所有字符,但上一道题要找长度最短的子串,这道题要找长度相同的子串,也就是「字母异位词」嘛。

三、无重复字符的最长子串
题目链接

遇到子串问题,首先想到的就是滑动窗口技巧。

类似之前的思路,使用 window 作为计数器记录窗口中的字符出现次数,然后先向右移动 right,当 window 中出现重复字符时,开始移动 left 缩小窗口,如此往复:

int lengthOfLongestSubstring(string s) {
int left = 0, right = 0;
unordered_map<char, int> window;
int res = 0; // 记录最长长度

while (right < s.size()) {
    char c1 = s[right];
    window[c1]++;
    right++;
    // 如果 window 中出现重复字符
    // 开始移动 left 缩小窗口
    while (window[c1] > 1) {
        char c2 = s[left];
        window[c2]--;
        left++;
    }
    res = max(res, right - left);
}
return res;

}
需要注意的是,因为我们要求的是最长子串,所以需要在每次移动 right 增大窗口时更新 res,而不是像之前的题目在移动 left 缩小窗口时更新 res。

最后总结
通过上面三道题,我们可以总结出滑动窗口算法的抽象思想:

int left = 0, right = 0;

while (right < s.size()) {
window.add(s[right]);
right++;

while (valid) {
    window.remove(s[left]);
    left++;
}

}
其中 window 的数据类型可以视具体情况而定,比如上述题目都使用哈希表充当计数器,当然你也可以用一个数组实现同样效果,因为我们只处理英文字母。

稍微麻烦的地方就是这个 valid 条件,为了实现这个条件的实时更新,我们可能会写很多代码。比如前两道题,看起来解法篇幅那么长,实际上思想还是很简单,只是大多数代码都在处理这个问题而已。

_____________

刷算法,学套路,认准 labuladong,公众号和 在线电子书 持续更新最新文章。

本小抄即将出版,微信扫码关注公众号,后台回复「小抄」限时免费获取,回复「进群」可进刷题群一起刷题,带你搞定 LeetCode。

其他语言代码

Jiajun 提供最小覆盖子串 Python3 代码:

class Solution:
def minWindow(self, s: str, t: str) -> str:
# 最短子串开始位置和长度
start, min_len = 0, float(‘Inf’)
left, right = 0, 0
res = s

    # 两个计数器
    needs = Counter(t)
    window = collections.defaultdict(int) 
    # defaultdict在访问的key不存在的时候返回默认值0, 可以减少一次逻辑判断
    
    match = 0
    
    while right < len(s):
        c1 = s[right]
        if needs[c1] > 0:
            window[c1] += 1
            if window[c1] == needs[c1]:
                match += 1
        right += 1
        
        while match == len(needs):
            if right - left < min_len:
                # 更新最小子串长度
                min_len = right - left
                start = left
            c2 = s[left]
            if needs[c2] > 0:
                window[c2] -= 1
                if window[c2] < needs[c2]:
                    match -= 1
            left += 1
    
    return s[start:start+min_len] if min_len != float("Inf") else ""

_____________

刷算法,学套路,认准 labuladong,公众号和 在线电子书 持续更新最新文章。

本小抄即将出版,微信扫码关注公众号,后台回复「小抄」限时免费获取,回复「进群」可进刷题群一起刷题,带你搞定 LeetCode。

其他语言代码

python
第3题 Python3 代码(提供: FaDrYL ):

def lengthOfLongestSubstring(self, s: str) -> int:
# 子字符串
sub = “”
largest = 0

# 循环字符串,将当前字符加入子字符串,并检查长度
for i in range(len(s)):
    if s[i] not in sub:
        # 当前字符不存在于子字符串中,加入当前字符
        sub += s[i]
    else:
        # 如果当前子字符串的长度超过了之前的记录
        if len(sub) > largest:
            largest = len(sub)
        # 将子字符串从当前字符处+1切片至最后,并加入当前字符
        sub = sub[sub.find(s[i])+1:] + s[i]
        
# 如果最后的子字符串长度超过了之前的记录
if len(sub) > largest:
    return len(sub)
return largest

javascript
76.最小覆盖子串

/**

  • @param {string} s
  • @param {string} t
  • @return {string}
    */
    const minWindow = (s, t) => {
    let minLen = s.length + 1;
    let start = s.length; // 结果子串的起始位置
    let map = {}; // 存储目标字符和对应的缺失个数
    let missingType = 0; // 当前缺失的字符种类数
    for (const c of t) { // t为baac的话,map为{a:2,b:1,c:1}
    if (!map[c]) {
    missingType++; // 需要找齐的种类数 +1
    map[c] = 1;
    } else {
    map[c]++;
    }
    }
    let l = 0, r = 0; // 左右指针
    for (; r < s.length; r++) { // 主旋律扩张窗口,超出s串就结束
    let rightChar = s[r]; // 获取right指向的新字符
    if (map[rightChar] !== undefined) map[rightChar]–; // 是目标字符,它的缺失个数-1
    if (map[rightChar] == 0) missingType–; // 它的缺失个数新变为0,缺失的种类数就-1
    while (missingType == 0) { // 当前窗口包含所有字符的前提下,尽量收缩窗口
    if (r - l + 1 < minLen) { // 窗口宽度如果比minLen小,就更新minLen
    minLen = r - l + 1;
    start = l; // 更新最小窗口的起点
    }
    let leftChar = s[l]; // 左指针要右移,左指针指向的字符要被丢弃
    if (map[leftChar] !== undefined) map[leftChar]++; // 被舍弃的是目标字符,缺失个数+1
    if (map[leftChar] > 0) missingType++; // 如果缺失个数新变为>0,缺失的种类+1
    l++; // 左指针要右移 收缩窗口
    }
    }
    if (start == s.length) return “”;
    return s.substring(start, start + minLen); // 根据起点和minLen截取子串
    };
    567.字符串的排列

var checkInclusion = function(s1, s2) {
const n = s1.length, m = s2.length;
if (n > m) {
return false;
}
const cnt1 = new Array(26).fill(0);
const cnt2 = new Array(26).fill(0);
for (let i = 0; i < n; ++i) {
++cnt1[s1[i].charCodeAt() - ‘a’.charCodeAt()];
++cnt2[s2[i].charCodeAt() - ‘a’.charCodeAt()];
}
if (cnt1.toString() === cnt2.toString()) {
return true;
}
for (let i = n; i < m; ++i) {
++cnt2[s2[i].charCodeAt() - ‘a’.charCodeAt()];
–cnt2[s2[i - n].charCodeAt() - ‘a’.charCodeAt()];
if (cnt1.toString() === cnt2.toString()) {
return true;
}
}
return false;
};
438.找到字符串中所有字母异位词

/**

  • @param {string} s
  • @param {string} p
  • @return {number[]}
    */
    var findAnagrams = function(s, p) {
    // 用于保存结果
    const res = []
    // 用于统计p串所需字符
    const need = new Map()
    for(let i = 0; i < p.length; i++) {
    need.set(p[i], need.has(p[i])?need.get(p[i])+1: 1)
    }
    // 定义滑动窗口
    let left = 0, right = 0, valid = 0
    // 用于统计窗口中的字符
    const window = new Map()
    // 遍历s串
    while(right < s.length) {
    // 进入窗口的字符
    const c = s[right]
    // 扩大窗口
    right++
    // 进入窗口的字符是所需字符
    if (need.has©) {
    // 更新滑动窗口中的字符记录
    window.set(c, window.has©?window.get©+1:1)
    // 当窗口中的字符数和滑动窗口中的字符数一致
    if (window.get© === need.get©) {
    // 有效字符自增
    valid++
    }
    }
    // 当滑动窗口的大小超出p串长度时 收缩窗口
    while (right - left >= p.length) {
    // 有效字符和所需字符数一致 找到一条符合条件的子串
    if (valid === need.size) {
    // 保存子串的起始索引位置
    res.push(left)
    }
    // 离开窗口的字符
    const d = s[left]
    // 收缩窗口
    left++
    // 如果离开窗口字符是所需字符
    if (need.has(d)) {
    // 如果离开字符数和所需字符数一致
    if (window.get(d) === need.get(d)) {
    // 有效字符减少一个
    valid–
    }
    // 更新滑动窗口中的字符数
    window.set(d, window.get(d)-1)
    }
    }
    }
    // 返回结果
    return res
    };
    3.无重复字符的最长子串

/**

  • @param {string} s
  • @return {number}
    */
    var lengthOfLongestSubstring = function(s) {
    // 哈希集合,记录每个字符是否出现过
    const occ = new Set();
    const n = s.length;
    // 右指针,初始值为 -1,相当于我们在字符串的左边界的左侧,还没有开始移动
    let rk = -1, ans = 0;
    for (let i = 0; i < n; ++i) {
    if (i != 0) {
    // 左指针向右移动一格,移除一个字符
    occ.delete(s.charAt(i - 1));
    }
    while (rk + 1 < n && !occ.has(s.charAt(rk + 1))) {
    // 不断地移动右指针
    occ.add(s.charAt(rk + 1));
    ++rk;
    }
    // 第 i 到 rk 个字符是一个极长的无重复字符子串
    ans = Math.max(ans, rk - i + 1);
    }
    return ans;
    };
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值