概述
在解決字串
問題時,滑動窗口技巧可能經常會使用,其本身思想並不難理解,難在靈活。因而本文從一個最小覆蓋字串
問題入手總結一個通用的算法框架以解決常見的滑動窗口問題。
算法與框架
下邊我們先看一個最小覆蓋子串問題:
題目本身不難理解,主要就是從S(source)中找到包含T(target)中全部字幕的一個子串,順序無所謂,個數相同且子串中一定是所有可能子串中最短的。
最簡單的思路是通過暴力法,通過兩層搜索來解決,但時間複雜度很高,甚至大於O(n^2)。
此類問題實際上我們可以通過滑動窗口
的思路來解決。具體思路如下:
- 在字符串S中使用雙指針中左右指針的技巧,初始化
left = right = 0
,把索引區間[left,right]
稱之爲一個[窗口]。 - 不斷的增加right指針擴大窗口
[left,right ]
,直到窗口中的字符串符合要求(窗口包含T中所有字符)。 - 停止增加right,轉而增加left指針,進而縮小窗口直到窗口不再符合要求。同時每增加一個left都要更新一輪結果。
- 重複2和3,直到right達到字符串S的盡頭。
整個過程思路並不難,其中第2步相當於在找一個可行解,第3步在優化這個可行解,每輪都進行結果更新,最後找到最優解。
下邊我們結合整下邊的圖來理解算法的整個過程。needs和windows相當於計數器,分別記錄T中字符串出現的次數和窗口中的對應字符出現的次數。
第1步:初始狀態,left和righ都爲0
第2步:向右移動right尋找可行解
第3步:向右移動left,優化可行解
第4步:重複2和3直到,right到達右邊界
上述過程可以簡單寫出如下的代碼框架:
public String slidingWindow(String s, String t) {
//定義兩個窗口
Map<Character, Integer> need = new HashMap<>(), window = new HashMap<>();
// 初始化need窗口
for (char c : t.toCharArray()) {
need.put(c, need.getOrDefault(c, 0) + 1);
}
int left = 0, right = 0;
// 已經和need匹配的字符串個數
int valid = 0;
while (right < s.length()) {
char c = s.charAt(right);
// move to right
right++;
// 進行窗口內一系列數據的更新
...
// 判斷左側窗口是否要收縮
while (window needs shrink) {
// d 是將移出窗口的字符
char d = s.charAt(left);
// 左移窗口
left++;
// 進行窗口內數據的一系列更新
...
}
}
其中兩處...
表示更新窗口數據的地方,根據不同的問題,進行填充即可。
針對最小覆蓋子串問題,開始套模板,只需要考慮如下四個問題:
- 移動right擴大窗口,即加入字符時需要考慮哪些數據?
- 什麼條件下,窗口應該暫停擴大,開始移動left縮小窗口?
- 當移動left縮小窗口,即移除字符時,應該更新哪些數據?
- 我們要的結果應該在擴大窗口時還是縮小窗口時進行更新?
如果一個字符進入窗口,應該增加 window 計數器;如果一個字符將移出窗口的時候,應該減少 window 計數器;當 valid 滿足 need 時應該收縮窗口;應該在收縮窗口的時候更新最終結果。
針對該問題我們將代碼進行填充後得到如何解法:
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, len = INT_MAX;
while (right < s.size()) {
// c 是將移入窗口的字符
char c = s[right];
// 右移窗口
right++;
// 進行窗口內數據的一系列更新
if (need.count(c)) {
window[c]++;
if (window[c] == need[c])
valid++;
}
// 判斷左側窗口是否要收縮
//必須使用equlals來判斷,不能使用 ==
while (valid.equals(need.size())) {
// 在這裏更新最小覆蓋子串
if (right - left < len) {
start = left;
len = right - left;
}
// d 是將移出窗口的字符
char d = s[left];
// 左移窗口
left++;
// 進行窗口內數據的一系列更新
if (need.count(d)) {
if (window[d].euqals(need[d])
valid--;
window[d]--;
}
}
}
// 返回最小覆蓋子串
return len == INT_MAX ?
"" : s.substr(start, len);
}
應用
接下來我們再看一下另一箇中等難度的題目字符串的排列
題意很好理解,就是判斷s2是否包含s1的某種排列。我們比較容易想到用暴力法。但會發現時間複雜度過高無法通過。然後考慮到是子串問題,嘗試使用滑動窗口方法。
結合模板,考慮兩個問題:
- 右側窗口滑動時,做哪些操作
- 左側窗口滑動的條件,以及所做操作
針對第一個問題,我們考慮到當右側窗口滑動獲取一個字符時要判斷當前字符是否在need中,如果存在進行windows計數
針對第二個問題,如果窗口的長度大於字符串t
的長度,則需要進行窗口左移操作,進行窗口“瘦身”
該問題具體代碼實現如下:
public boolean checkInclusion(String t, String s) {
if (t.length() > s.length()) {
return false;
}
Map<Character, Integer> need = new HashMap<>();
Map<Character, Integer> window = new HashMap<>();
// init need
for (char c : t.toCharArray()) {
need.put(c, need.getOrDefault(c, 0) + 1);
}
// define variable
int left = 0, right = 0;
int valid = 0;
while (right < s.length()) {
char c = s.charAt(right);
right++;
// update right window
if (need.containsKey(c)) {
window.put(c, window.getOrDefault(c, 0) + 1);
if (need.get(c).equals(window.get(c))) {
valid++;
}
}
// shrink left window
// 每一次窗口的尺寸比need的尺寸大的時候都會進行瘦身操作,一直移動到比need的尺寸小1結束
while (right - left >= t.length()) {
if (valid == need.size()) {
return true;
}
char d = s.charAt(left);
left++;
if (need.containsKey(d)) {
if (window.get(d).equals(need.get(d))) {
valid--;
}
window.put(d, window.get(d) - 1);
}
}
}
return false;
}
總結
簡單來說滑動窗口問題其實只要記下這個框架,大部分類似問題都可迎刃而解。