[算法系列之二十二]包含T全部元素的最小子窗口

寻找最小子窗口
本文探讨了在字符串S中寻找包含T所有字符的最小窗口问题,提供了两种算法:一种时间复杂度为O(NlgM),利用队列和sortedmap;另一种更优的O(N)算法,采用双指针技巧。

题目描述

给定一个包含一系列字符的集合T和字符串S,请在字符串S中找到一个最小的窗口,这个窗口中必须包含T中的所有字符。 
例如, 
S = “ADOBECODEBANC” 
T = “ABC”

最小窗口是“BANC”

分析

这是一个有趣的问题,这个有趣的问题有多种方法来解决,最好的方法是非常简单,美丽的。 
在这篇文章中,我首先说明了一个方法,是我第一次遇见这个问题时想到的。我的第一个方法有点复杂,同时也不是最好的解决方案(时间复杂度为O(NlgM))。在这篇文章的后面中,我介绍一个比较好的方法,时间复杂度为O(N)。 
Hint: 
使用上面的示例中S =“ADOBECODEBANC”,S =“ABC”,我们可以很容易地找到第一个窗口“ADOBEC”,包含了T中所有元素。另一个可能的候选者是“ADOBECODEB A”。事实上,我们应该跳过这个,因为在这个窗口中存在一个子窗口“CODEBA”,既短又满足约束条件。最后考虑的一个窗口是“BANC”,这也是最小的窗口。

为了有效地解决这个问题,下面我们需要考虑的两个关键点:

  1. 我们如何确定一个特定的窗口包含T ?(最理想的情况是O(1)时间)。
  2. 我们如何有效的选择所有窗口?(最理想的情况是不包括含有子窗口的那些窗口)。

我们绝对需要哈希表(Hash Table)的帮助。哈希表能在O(1)时间内告诉我们一个字符是否在T 中。

O(N lg M) 方法:

当我第一次遇到这一问题,我想到了另一个表,记录字符上次出现的位置。也就是说,当我第一次看到字符’A‘,我记录它的位置是0。我每次再见到’ A ‘,我就用新位置代替它原先的位置。这种方法虽然很简单,但是缺陷也很明显。请注意,T不包含重复的字符吗?如果T包含了重复的字符,如“AABC”,这种方法就不能使用了。

在这种情况下,补救措施是维持一个队列(而不是表),T中每个不同字符对应一个队列(例如:字符A对应一个队列,字符B对应一个队列。。。)。例如,假设T =“AABC”,当你第一次遇到“A”,把它的所在位置放入“A”队列中(最初是空的)。当你再次遇到“A ”时,把它的位置放入“A”队列末尾。第三次遇到“A”时,弹出第一个元素,并把这次遇到的A所在位置放入“A”队列末尾。通过弹出元素,我们不包括那些包含子窗口的窗口。这种方法很有效,但困难是双重的:

  1. 我们没有办法从队列本身直接确定窗口的开始和结束位置。一个最自然的方法是扫描整个队列得到最小值和最大值。我们如何确定这个窗口是否满足约束条件呢?我们不得不扫描整个队列来检查所有队列大小总和是否等于T的长度。

 我解决上述问题的方法是维护一个sorted map,它映射到每一个字符。这样我们能在O(1)时间内获取最小值和最大值的位置。但这样做会花费额外的时间。每次你从队列中弹出一个元素,你不得不通过删除相应的元素和插入一个新元素来更新map。检查窗口是否满足约束条件,我们必须查看map的大小,如果map的大小等于T的长度就代表找到一个有效的窗口。

这个方法的时间复杂度是O(N lg M),其中N是S的长度,和M是T的长度。额外的lgM是由于在map中删除和插入一个元素的额外花费,每个最坏情况花费O(lgM)时间。(注意,M是map的最大大小。)

/*---------------------------------------------
*   日期:2015-02-24
*   作者:SJF0115
*   题目: 22.包含T全部元素的最小子窗口
*   来源:算法系列
*   博客:
-----------------------------------------------*/
#include <iostream>
#include <map>
#include <queue>
#include <climits>
#include <algorithm>
using namespace std;

bool MinWindow(string s,string t,int &startWin,int &endWin){
    int slen = s.size();
    int tlen = t.size();
    if(slen <= 0 || tlen <= 0){
        return false;
    }//if
    // 存储T中不同字符的总数
    int needFind[256] = {0};
    for(int i = 0;i < tlen;++i){
        ++needFind[t[i]];
    }//for
    // 不在T中的元素设置为-1
    for(int i = 0; i < 256;++i){
        if(needFind[i] == 0){
            needFind[i] = -1;
        }//if
    }//for
    int minWinLen = INT_MAX;
    // 队列数组,每个不同的字符都对应一个队列
    queue<int> q[256];
    // 第一个元素和最后一个元素表明了窗口的开始和结束位置
    map<int,char> m;
    int val;
    for(int i = 0;i < slen;++i){
        val = s[i];
        // 跳过不在T中的元素
        if(needFind[val] == -1) {
            continue;
        }//id
        // 字符放入队列
        if(q[val].size() < needFind[val]) {
            q[val].push(i);
            m[i] = val;
        }//if
        // 取代队列中的字符,更新map中对应元素
        else{
            int idxToErase = q[val].front();
            map<int,char>::iterator it = m.find(idxToErase);
            m.erase(it);
            m[i] = val;
            q[val].pop();
            q[val].push(i);
        }//else
        if(m.size() == tlen){
            int end = m.rbegin()->first;
            int start = m.begin()->first;
            int winLen = end - start + 1;
            if (winLen < minWinLen) {
                minWinLen = winLen;
                startWin = start;
                endWin = end;
            }//if
        }//if
    }//for
    return (m.size() == tlen);
}

int main() {
    string s("acbbaca");
    string t("aba");
    int start,end;
    bool result = MinWindow(s,t,start,end);
    if(result){
        cout<<s.substr(start,end-start+1)<<endl;
    }//if
    else{
        cout<<"未找到"<<endl;
    }//else
    return 0;
}

O(N)方法:

注意到上面的思路是非常复杂的。它使用了一个哈希表,一个队列还有一个sorted map。在面试过程中,给出的问题往往是比较短的,解决方案通常在50行代码左右。所以你有必要大声说出你在想什么,时刻保持与面试官进行沟通。检查你的方法是否是没有必要的复杂,他/她可以给你指导。最不好的就是就是你被困在一点,什么也不说。

为了阐述这个思路,我使用一个不同上面的例子:S = “acbbaca”,T = “aba”。这个思路主要是遍历S时使用了两个指针begin和end(窗口开始和结束位置)和两个数组(needToFind 和 hasFound)。needToFind存储T中不同字符的总数,hasFound存储到目前为止遇到过的不同字符的总数。我们也使用一个count变量来存储到目前为止遇到过的T中字符总数(当hasFound[x]超过needToFind[x]时不用计数)。当count等于T的长度时,我们就找到了一个有效的窗口。

每次我们向前移动end指针(指向一个元素x),我们会使hasFound[x]加一。如果hasFound[x]是小于或等于needToFind[x]时count加一。为什么?当满足约束条件(即count等于T的大小),在满足约束的条件下,我们开尽可能的向右移动begin指针。

我们如何检查是否满足约束条件呢?假设begin指向一个元素x,我们检查hasFound[x]是否大于needToFind[x]。如果是,我们可以使hasFound[x]减一,在不破坏约束条件的前提下向前移动begin指针。相反,如果不是,我们立即停止向前移动begin指针,以防破坏约束条件。

最后,我们检查最小窗口长度是否小于当前的最小窗口长度。如果不是则更新最小窗口长度。

本质上,该算法找到满足约束的第一个窗口后,仍然继续保持约束条件。

(1) S = “acbbaca” T = “aba“

 

(2)第一个找到最小的窗口。我们无法向前移动begin指针当hasFound[‘a’]== needToFind[‘a’]= = 2。向前移动意味着打破约束。

(3)第二个窗口。begin指针仍然指向第一个元素“a”。hasFound[’ a ‘](3)大于needToFind[‘a’](2)。我们使hasFound[’ a ‘],向右移动begin指针。

 (4)我们跳过元素c,因为它不在T中。现在begin指针指向元素b。hasFound[b](2)大于needToFind[b](1)。我们使hasFound[b]减一,同时向右移动begin指针。

(5)begin指针现在指向下一个元素b。hasFound[b](1)等于needToFind[b](1)。我们立即停止,这是我们新发现的最小的窗口。

begin指针和end指针最坏情况下向前移动至多N步(N 是字符串S的长度),加起来是2N时间,因此时间复杂度是O(N)。

/*---------------------------------------------
*   日期:2015-02-24
*   作者:SJF0115
*   题目: 22.包含T全部元素的最小子窗口
*   来源:算法系列
*   博客:
-----------------------------------------------*/
#include <iostream>
#include <climits>
#include <algorithm>
using namespace std;

// Returns false if no valid window is found. Else returns
// true and updates start and end with the
// starting and ending position of the minimum window.
bool MinWindow(string s,string t,int &startWin,int &endWin){
    int slen = s.size();
    int tlen = t.size();
    if(slen <= 0 || tlen <= 0){
        return false;
    }//if
    // 存储T中不同字符的总数
    int needFind[256] = {0};
    for(int i = 0;i < tlen;++i){
        ++needFind[t[i]];
    }//for
    // 存储到目前为止遇到过的不同字符的总数
    int hasFound[256] = {0};
    // 存储到目前为止遇到过的T中字符总数
    int count = 0;
    int minWin = INT_MAX;
    int endEle;
    for(int start = 0,end = 0;end < slen;++end){
        endEle = s[end];
        // 剪枝 无用字符(T中字符为有用字符)
        if(needFind[endEle] == 0){
            continue;
        }//if
        ++hasFound[endEle];
        if(hasFound[endEle] <= needFind[endEle]){
            ++count;
        }//if
        // 找到一个有效窗口
        if(count == tlen){
            int begEle = s[start];
            // 满足:字符为无用字符,begEle元素找多了 start指针才向右移动
            while(needFind[begEle] == 0 || hasFound[begEle] > needFind[begEle]){
                if(hasFound[begEle] > needFind[begEle]){
                   --hasFound[begEle];
                }//if
                ++start;
                begEle = s[start];
            }//while
            // 更新最小窗口
            int curWin = end - start + 1;
            if(curWin < minWin){
                minWin = curWin;
                startWin = start;
                endWin = end;
            }//if
        }//if
    }//while
    return (count == tlen);
}

int main() {
    string s("ADOBECODEBANC");
    string t("ABC");
    int start,end;
    bool result = MinWindow(s,t,start,end);
    if(result){
        cout<<s.substr(start,end-start+1)<<endl;
    }//if
    else{
        cout<<"未找到"<<endl;
    }//else
    return 0;
}

本文原文地址:https://blog.youkuaiyun.com/SunnyYoona/article/details/43924715

<think> 我们被要求回答滑动窗口算法的应用场景。根据引用内容,滑动窗口算法广泛应用于多个领域。以下将结合引用内容,系统化地介绍其应用场景。 引用[1]提到滑动窗口算法在字符串匹配、子数组和子问题、时间序列数据处理以及网络流量控制等领域的应用。 引用[2]指出滑动窗口算法与其他算法(如动态规划、分治法、双指针)的比较,说明其在不同问题场景下的适用性。 引用[3]重点介绍了滑动窗口算法在高可用系统中作为限流算法的应用,特别是在处理动态流量和高并发场景中的作用。 引用[4]则强调了滑动窗口算法在字符串和数组子串或子数组问题(如字符串匹配、长子串、小覆盖子串)中的应用,并且可以优化时间复杂度。 因此,我们可以将应用场景分为以下几类: 1. **字符串处理**: - 字符串匹配(如Rabin-Karp算法) - 长无重复字符子串(LeetCode经典问题) - 小覆盖子串(LeetCode 76题) - 找到字符串中所有字母异位词(LeetCode 438题) 2. **数组/序列处理**: - 连续子数组的大/小值问题(如大小为k的子数组的大和) - 满足特定条件的连续子数组(如和大于等于目标值的短子数组) 3. **时间序列数据分析**: - 计算移动平均值(如股票分析中的N日移动平均线) - 时间窗口内的统计量(如过去一小时内的大值、小值、总和等) 4. **网络流量控制**: - 限流算法(如滑动窗口限流,用于控制单位时间内的请求量,避免系统过载) - 网络协议中的流量控制(如TCP协议的滑动窗口机制用于数据流控制) 5. **数据流处理**: - 实时计算(如计算近N个元素的和、平均值等) - 实时监控(如监控系统在滑动时间窗口内的错误率) 下面我们详细说明每个应用场景,并给出具体例子。 ### 一、字符串处理 #### 1.1 字符串匹配 滑动窗口算法可用于模式匹配,例如Rabin-Karp算法使用滑动窗口来计算文本中每个窗口的哈希值,并与模式串的哈希值进行比较,从而高效地找到匹配位置[^1][^4]。 #### 1.2 长无重复字符子串 给定一个字符串,找出其中不含有重复字符的长子串的长度。例如,在字符串`"abcabcbb"`中,长无重复子串为`"abc"`,长度为3。滑动窗口通过两个指针(左指针和右指针)和哈希集合来动态调整窗口,确保窗口内无重复字符,并记录窗口长度[^1][^4]。 #### 1.3 小覆盖子串 给定一个字符串`s`和一个字符串`t`,在`s`中找到包含`t`所有字符的小子串。例如,`s="ADOBECODEBANC"`, `t="ABC"`,小覆盖子串为`"BANC"`。滑动窗口通过移动右指针扩展窗口,当窗口包含`t`的所有字符后,移动左指针收缩窗口以找到窗口[^4]。 ### 二、数组/序列处理 #### 2.1 大小为k的子数组的大和 给定一个整数数组和一个整数`k`,找出数组中连续`k`个元素大和。例如,数组`[1, 4, 2, 10, 2, 3, 1, 0, 20]`,`k=4`时,大和为`10+2+3+1=16`(子数组`[10, 2, 3, 1]`)。滑动窗口通过固定窗口大小`k`,每次移动一位,利用前一个窗口的和减去离开的元素加上新加入的元素,避免重复计算[^1][^4]。 #### 2.2 满足特定条件的连续子数组 例如,求满足和`≥ target`的短连续子数组的长度。在数组`[2,3,1,2,4,3]`,`target=7`中,满足条件的短子数组是`[4,3]`,长度为2。滑动窗口通过动态调整窗口大小(右指针扩展,左指针收缩)来寻找满足条件的窗口[^4]。 ### 三、时间序列数据分析 #### 3.1 移动平均值计算 在金融分析中,经常需要计算时间序列数据的移动平均。例如,计算过去`N`天的平均股价。滑动窗口可以维护一个包含近`N`个数据点的窗口,每次新数据加入时,更新窗口并重新计算平均值,同时移除早的数据点。这样可以在$O(1)$时间内完成更新[^1]。 #### 3.2 时间窗口内的大值/小值 在监控系统中,可能需要统计过去一小时内的高温度。使用滑动窗口(结合单调队列)可以高效地维护窗口内的大值(如LeetCode 239题:滑动窗口大值)[^4]。 ### 四、网络流量控制 #### 4.1 限流算法 滑动窗口限流算法用于控制单位时间内的请求量。与固定窗口(如1秒内多10个请求)相比,滑动窗口限流通过将时间窗口划分为更小的区间(如将1秒划分为10个100ms的区间),并在每个区间内计数,从而避免固定窗口的临界突变问题(即在窗口切换时刻可能出现的两倍流量),提供更精确的流量控制[^3]。 #### 4.2 TCP流量控制 TCP协议使用滑动窗口机制来实现流量控制。接收方通过通告窗口大小(rwnd)告诉发送方自己还能接收多少数据,发送方根据这个窗口动态调整发送的数据量,避免接收方缓冲区溢出[^3]。 ### 五、数据流处理 #### 5.1 实时计算 在流数据处理中,如Apache Flink、Spark Streaming等框架,滑动窗口用于对连续到达的数据进行聚合计算(如过去5分钟内的用户访问量)[^1][^3]。 #### 5.2 实时监控 监控系统需要实时计算某个指标在滑动时间窗口内的值,例如过去5分钟内API调用的错误率。滑动窗口可以高效地更新这些统计值[^3]。 ### 总结 滑动窗口算法通过维护一个窗口(通常由两个指针表示),根据问题要求动态调整窗口大小(左指针和右指针的移动),从而高效地解决一系列需要处理连续子序列的问题。其核心优势在于避免重复计算,将时间复杂度从$O(n^2)$降低到$O(n)$或$O(nk)$,特别适合处理字符串、数组、数据流等连续数据的问题[^1][^4]。 §§相关问题§§ 1. 滑动窗口算法与双指针算法的区别是什么? 2. 如何用滑动窗口算法解决LeetCode中的“小覆盖子串”问题? 3. 滑动窗口限流与令牌桶限流的主要差异是什么? 4. TCP协议中滑动窗口机制的具体工作流程是怎样的? 5. 滑动窗口算法在实时数据处理框架(如Flink)中是如何实现的? 参考文献: [^1]: 滑动窗口算法及其应用场景有哪些? [^2]: 趣味算法:滑动窗口算法的理解与应用 [^3]: 在高可用系统中,限流算法扮演着至关重要的角色 [^4]: 必学算法——滑动窗口 </think> 以下是滑动窗口算法的详细应用场景分析,结合其在算法设计、系统开发和数据处理中的实际应用: ### 一、算法与数据结构领域 1. **子串/子数组问题** - **长无重复字符子串** 通过动态调整窗口边界,在$O(n)$时间内定位长无重复子串(如 LeetCode 第3题)[^4] ```python def lengthOfLongestSubstring(s: str) -> int: window = {} # 字符出现位置记录 left = max_len = 0 for right, char in enumerate(s): if char in window and window[char] >= left: left = window[char] + 1 # 收缩左边界 window[char] = right max_len = max(max_len, right - left + 1) return max_len ``` - **小覆盖子串** 在$O(n)$时间内找到包含目标字符集的短连续子串(LeetCode 76题)[^4] 2. **固定窗口统计问题** - **大小为k的子数组大平均值** 通过维护固定长度窗口,避免重复计算: ```python def find_max_average(nums, k): window_sum = sum(nums[:k]) max_avg = window_sum / k for i in range(k, len(nums)): window_sum += nums[i] - nums[i-k] # 滑动更新 max_avg = max(max_avg, window_sum / k) return max_avg ``` ### 二、网络与系统领域 1. **流量控制与限流** - **滑动窗口限流器** 解决固定窗口算法的临界突变问题[^3]: ```mermaid graph LR A[时间轴] --> B[将1秒划分为10个100ms子窗口] C[请求到达] --> D{当前子窗口计数+1 ≤ 阈值?} D -- 是 --> E[允许通过] D -- 否 --> F[拒绝请求] G[每100ms滑动窗口] --> H[移除旧子窗口计数] ``` 实现特点: - 时间窗口划分为$n$个子窗口(如$n=10$) - 实时统计$[t-1s, t]$区间内的总请求数 - 避免固定窗口在$t=0.9s$和$t=1.1s$突发双倍流量[^3] 2. **TCP协议流量控制** - 接收方通过**通告窗口大小**(rwnd)动态控制发送速率 - 发送方维护滑动窗口跟踪已发送未确认数据段 - 实现网络拥塞控制的核心机制 ### 三、数据流处理领域 1. **实时数据聚合** - **移动平均值计算** ```python class MovingAverage: def __init__(self, size): self.window = deque(maxlen=size) self.total = 0 def next(self, val): if len(self.window) == self.window.maxlen: self.total -= self.window[0] self.window.append(val) self.total += val return self.total / len(self.window) ``` - **时间窗口统计** 在监控系统中计算近5分钟的请求错误率: $$ \text{error\_rate} = \frac{\text{错误计数}_{[t-5min,t]}}{\text{总请求}_{[t-5min,t]}} $$ 2. **事件流模式检测** - 在复杂事件处理(CEP)引擎中检测连续事件序列 - 例如:金融交易中识别"10秒内价格连续上涨3次" ### 四、时间序列分析 1. **金融数据分析** - 计算股票$N$日移动平均线(MA): $$ MA_t = \frac{1}{N} \sum_{i=0}^{N-1} P_{t-i} $$ - 实现波动率分析:在滑动窗口内计算价格标准差 2. **物联网数据处理** - 传感器数据平滑:在时间窗口$T$内取采样值中位数 - 异常检测:当连续$k$个数据点超出阈值时触发告警 ### 性能优势对比 | **算法类型** | 暴力枚举复杂度 | 滑动窗口复杂度 | 优化原理 | |---------------|--------------|--------------|-------------------| | 长无重复子串 | $O(n^2)$ | $O(n)$ | 避免重复扫描 | | 子数组大和 | $O(nk)$ | $O(n)$ | 增量更新窗口和 | | 模式匹配 | $O(mn)$ | $O(n)$ | Rabin-Karp哈希跳转 | > 💡 **核心优势**: > 通过维护动态窗口边界,将时间复杂度从$O(n^2)$降至$O(n)$,空间复杂度通常为$O(1)$或$O(k)$($k$为窗口大小)[^1][^4]
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值