前言
滑動窗口類問題是面試當中的高頻題,問題本身其實並不複雜,但是實現起來細節思考非常的多,想着想着可能因爲變量變化,指針移動等等問題,導致程序反覆刪來改去,有思路,但是程序寫不出是這類問題最大的障礙。
本文會將 LeetCode 裏面的大部分滑動窗口問題分析、總結、分類,並提供一個可以參考的模版,相信可以有效減少面試當中的算法實現部分的不確定性。
問題形式
滑動窗口這類問題一般需要用到雙指針來進行求解,另外一類比較特殊則是需要用到特定的數據結構,如 Map,隊列等。
題目問法大致有這幾種
- 給兩個字符串,一長一短,問其中短的是否在長的中滿足一定的條件存在
- 求長的的最短子串,該子串必須涵蓋短的的所有字符
- 短串的某種排列在長的中出現的所有位置
- 給一個字符串或者數組,問這個字符串的子串或者子數組是否滿足一定的條件
- 含有少於 個不同字符的最長子串
- 所有字符都只出現一次的最長子串
除此之外,還有一些其他的問法,但是不變的是,這類題目脫離不開主串(主數組)和子串(子數組)的關係,要求的時間複雜度往往是 ,空間複雜度往往是 的。
解題思路與模板
根據前面的描述,滑動窗口就是這類題目的重點,換句話說,窗口的移動就是重點!我們要控制前後指針的移動來控制窗口,這樣的移動是有條件的,也就是要想清楚在什麼情況下移動,在什麼情況下保持不變。
思路是保證右指針每次往前移動一格,每次移動都會有新的一個元素進入窗口,這時條件可能就會發生改變,然後根據當前條件來決定左指針是否移動,以及移動多少格。
/** 參考模板 */
slidingWindow(char[] s) {
// 申請一個散列,用於記錄窗口中具體元素的個數情況
// 這裏用數組的形式呈現,也可以考慮其他數據結構
int[] hash = new int[...];
// 預處理(可省略), 一般情況是初始化 hash 內容
...
// left 爲窗口左指針,right 爲窗口右指針
// count 記錄題目要求記錄某些中間結果(最多最少等值)
// result 記錄結果
int left = 0, count = 0, result = 0;
for (int right = 0; right < s.length; right++) {
// 更新新元素在散列中的數量
hash[s[right]]++;
// 根據窗口的變更結果來改變條件值
if (hash[s[right]] == ...) {
count++;
}
// 如果當前窗口條件不滿足,移動左指針直至滿足窗口爲止
while (...) {
hash[s[left]]--;
// 視情況改變記錄的中間結果
if (...) {
count--;
}
left++;
}
// 更新結果
result = ...
}
}
具體問題
好了,下面我們來看具體的問題吧,然後套用上面的模板進行解題。
無重複字符的最長子串
給定一個字符串,請你找出其中不含有重複字符的最長子串的長度。
示例
輸入:"abcabcbb"
輸出:3
解釋:因爲無重複字符的最長子串是 "abc",所以其長度爲 3。
解題思路
輸入只有一個字符串,要求子串裏面不能夠有重複的元素,這裏 count 都不需要定義,直接判斷哈希散列裏面的元素是不是在窗口內即可,是的話得移動左指針去重。
建立一個 128 位大小的整型數組,用來建立字符和其出現位置之間的映射。維護一個滑動窗口,窗口內的都是沒有重複的字符,去儘可能的擴大窗口的大小,窗口不停的向右滑動。
public int lengthOfLongestSubstring(String s) {
if (s == null || s.isEmpty()) return 0;
char[] sArr = s.toCharArray();
int[] hash = new int[128];
int left = 0, result = Integer.MIN_VALUE;
for (int right = 0; right < sArr.length; right++) {
// 如果當前遍歷到的字符從未出現過,那麼直接擴大右邊界
hash[sArr[right]]++;
// 如果當前遍歷到的字符出現過,則縮小窗口(左指針向右移動)
while (hash[sArr[right]] != 1) {
hash[sArr[left]]--;
left++;
}
// 更新結果
result = Math.max(result, right - left + 1);
}
return result;
}
替換後的最長重複字符
給你一個僅由大寫英文字母組成的字符串,你可以將任意位置上的字符替換成另外的字符,總共可最多替換 k 次。在執行上述操作後,找到包含重複字母的最長子串的長度。
示例
輸入:s = "ABAB", k = 2
輸出:4
解釋:用兩個'A'替換爲兩個'B',反之亦然。
解題思路
最簡單的方法就是把哈希散列遍歷一邊找到最大的字符數量,但是仔細想想如果我們每次新進元素都更新這個最大數量,且只更新一次,我們保存的是當前遍歷過的全局的最大值,它肯定是比實際的最大值大的,我們左指針移動的條件是 right - left + 1 - count > k,保存的結果是 result = Math.max(result, right - left + 1); 這裏 count 比實際偏大的話,雖然導致左指針不能移動,但是不會記錄當前的結果,所以最後的答案並不會受影響。
public int characterReplacement(String s, int k) {
if (s == null || s.isEmpty()) return 0;
char[] sArr = s.toCharArray();
// 僅含大寫英文字母,設爲26即可
int[] hash = new int[26];
// count記錄最大出現次數
int left = 0, count = 0, result = 0;
for (int right = 0; right < sArr.length; right++) {
// 調整字母在hash中的索引位置
hash[sArr[right] - 'A']++;
// 比較最大數字符數和當前字符的數量
count = Math.max(count, hash[sArr[right] - 'A']);
// 子字符串的長度減去出現次數最多的字符個數大於k則移動左指針
while (right - left + 1 - count > k) {
hash[sArr[left] - 'A']--;
left++;
}
result = Math.max(result, right - left + 1);
}
return result;
}
長度爲 K 的無重複字符子串
給你一個字符串 S,找出所有長度爲 K 且不含重複字符的子串,請你返回全部滿足要求的子串的數目。
示例
輸入:S = "havefunonleetcode", K = 5
輸出:6
解釋:這裏有 6 個滿足題意的子串,分別是
'havef','avefu','vefun','efuno','etcod','tcode'
解題思路
根據題意我們發現相當於窗口大小固定爲K,同時在窗口內必須沒有重複的字符。我們用左右指針可以計算出當前窗口的大小right - left + 1,同時再利用一個count對字符種類進行計數(也可以直接用一個boolean值即可),那麼很容易可以得出當right - left + 1 > K 或者 count > 0時需要移動左指針了。剩下的部分就是愉快地套用模板啦。
public int numKLenSubstrNoRepeats(String S, int K) {
if (S == null || S.length() < K) return 0;
char[] sArr = S.toCharArray();
int[] hash = new int[128];
int left = 0, count = 0, result = 0;
for (int right = 0; right < sArr.length; right++) {
hash[sArr[right]]++;
// 若更新hash後大於1,說明出現了重複種類的字符,count加1
if (hash[sArr[right]] > 1) {
count++;
}
// 當子串超過窗口大小,或者窗口內包含重複字符時,移動左指針
while (left < right && (right - left + 1 > K || count > 0)) {
// 更新左指針右移後字符數量
hash[sArr[left]]--;
// 若更新後該字符數量仍大於0,說明找到了重複的字符
// 此時將count減1
if (hash[sArr[left]] > 0) {
count--;
}
left++;
}
// 更新結果,此時子串剛好滿足窗口條件,結果加1,同時將count重置
if (right - left + 1 == K) {
result++;
count = 0;
}
}
return result;
}
至多包含兩個不同字符的最長子串
給定一個字符串 s ,找出至多包含兩個不同字符的最長子串 t 。
示例
輸入:"ccaabbb"
輸出:5
解釋: t 是 "aabbb",長度爲5
解題思路
類似於上一題,不過我們用count來記錄當前窗口內字符的種類數量,當出現新字符以及滑動左指針時,做相應的判斷來改變count,窗口大小始終保持在滿足條件至多兩個不同字符的情況下。
public int lengthOfLongestSubstringTwoDistinct(String s) {
if (s == null || s.isEmpty()) return 0;
char[] sArr = s.toCharArray();
int[] hash = new int[128];
// count記錄出現過的不同種類字符的數量
int left = 0, count = 0, result = Integer.MIN_VALUE;
for (int right = 0; right < sArr.length; right++) {
hash[sArr[right]]++;
// 若當前字符在hash中計數爲1,說明是前面未出現過的,count加1
if (hash[sArr[right]] == 1) {
count++;
}
// 若種類超過2了,需要將左指針右移,以滿足窗口的條件
while (count > 2) {
// 更新左指針右移後字符數量
hash[sArr[left]]--;
// 若更新後該字符數量爲0,說明當前窗口只含有一個該類字符
// 則種類數量count相應要減1
if (hash[sArr[left]] == 0) {
count--;
}
left++;
}
// 更新結果
result = Math.max(result, right - left + 1);
}
return result;
}
至多包含 K 個不同字符的最長子串
給定一個字符串 s ,找出 至多 包含 k 個不同字符的最長子串 T。
示例
輸入: s = "eceba", k = 2
輸出:3
解釋: 則 T 爲 "ece",所以長度爲 3。
解題思路
和上一題完全一樣的思路,只需要把判斷窗口條件的地方改成 count > k ,又一題困難被我們直接秒殺。
public int lengthOfLongestSubstringKDistinct(String s, int k) {
if (s == null || s.isEmpty()) return 0;
char[] sArr = s.toCharArray();
int[] hash = new int[128];
int left = 0, count = 0, result = Integer.MIN_VALUE;
for (int right = 0; right < sArr.length; right++) {
hash[sArr[right]]++;
if (hash[sArr[right]] == 1) {
count++;
}
// 此處改爲超過k種不同字符則移動左指針
while (count > k) {
hash[sArr[left]]--;
if (hash[sArr[left]] == 0) {
count--;
}
left++;
}
result = Math.max(result, right - left + 1);
}
return result;
}
下面來看看兩個字符串的情況
最小覆蓋子串
給你一個字符串 S、一個字符串 T,請在字符串 S 裏面找出:包含 T 所有字母的最小子串。
示例
輸入:S = "ADOBECODEBANC", T = "ABC"
輸出:"BANC"
解釋:S 內包含 T 中所有字母的最小子串爲"BANC"
解題思路
同樣是兩個字符串之間的關係問題,因爲題目求的最小子串,也就是窗口的最小長度,說明這裏的窗口大小是可變的,這裏移動左指針的條件變成,只要左指針指向不需要的字符,就進行移動。
public String minWindow(String s, String t) {
if (s == null || t == null || s.length() < t.length()) return "";
char[] sArr = s.toCharArray(), tArr = t.toCharArray();
// 記錄字符出現次數
int[] hash = new int[128];
// 以T中的字符初始化hash內字符數量
for (char c : tArr) {
hash[c]++;
}
// count 記錄匹配字符數,minLen 記錄最小子串長度
String result = "";
int left = 0, count = 0, minLen = Integer.MAX_VALUE;
for (int right = 0; right < sArr.length; right++) {
// 更新S中字符在散列中的數量
hash[sArr[right]]--;
// 若經過上一步減1後仍大於等於0
// 說明S中的該字符在T中出現過(因爲我們用T中字符數量初始化了hash),匹配數加1
if (hash[sArr[right]] >= 0) {
count++;
}
// 若某字符在hash中爲負,說明在S中出現過,在T中未出現,略過,不需要匹配
while (left < right && hash[sArr[left]] < 0) {
hash[sArr[left]]++;
left++;
}
// 更新結果
// count == tArr.length說明找到了T中所有字符
if (count == tArr.length && minLen > right - left + 1) {
minLen = right - left + 1;
result = s.substring(left, right + 1);
}
}
return result;
}
字符串的排列
給定兩個字符串 s1 和 s2,寫一個函數來判斷 s2 是否包含 s1 的排列。
示例
輸入:s1 = "ab" s2 = "eidbaooo"
輸出:True
解釋:s2 包含 s1 的排列之一 ("ba").
解題思路
首先窗口是固定的,窗口長度就是s1的長度,也就是說,右指針移動到某個位置後,左指針必須跟着一同移動,且每次移動都是一格,count 用來記錄窗口內滿足條件的元素,直到 count 和窗口長度相等即可。
public boolean checkInclusion(String s1, String s2) {
if (s1.length() > s2.length()) return false;
char[] arr1 = s1.toCharArray(), arr2 = s2.toCharArray();
int[] hash = new int[128];
// 以s1中的字符初始化hash內字符數量
for (char c : arr1) {
hash[c]++;
}
// count 記錄匹配字符數,size 表示窗口的大小
int left = 0, count = 0, size = arr1.length;
for (int right = 0; right < arr2.length; right++) {
// 更新s2字符在散列中的數量
hash[arr2[right]]--;
// 說明s2中的該字符在s1中出現過
if (hash[arr2[right]] >= 0) {
count++;
}
// 右指針移動到超過窗口大小時,左指針跟着移動
if (right > size - 1) {
hash[arr2[left]]++;
// 若當前是已經匹配上的字符,左移後會丟失匹配,故匹配數減1
if (hash[arr2[left]] > 0) {
count--;
}
left++;
}
// 更新結果,count == size 表示匹配字符滿足窗口大小
if (count == size) {
return true;
}
}
return false;
}
找到字符串中所有字母異位詞
給定一個字符串 s 和一個非空字符串 p,找到 s 中所有是 p 的字母異位詞的子串,返回這些子串的起始索引。字符串只包含小寫英文字母,並且字符串 s 和 p 的長度都不超過 20100。
示例
輸入:s: "cbaebabacd" p: "abc"
輸出:[0, 6]
解釋:
起始索引等於 0 的子串是 "cba", 它是 "abc" 的字母異位詞。
起始索引等於 6 的子串是 "bac", 它是 "abc" 的字母異位詞。
解題思路
和上一題完全一致的思路,窗口固定爲p串的長度。
public List<Integer> findAnagrams(String s, String t) {
if (s == null || t == null || s.length() < t.length()) return new ArrayList<Integer>();
char[] sArr = s.toCharArray(), tArr = t.toCharArray();
// 僅含小寫英文字母,設爲26即可
int[] hash = new int[26];
for (char c : tArr) {
hash[c - 'a']++;
}
// count 記錄匹配字符數,size 表示窗口的大小
int left = 0, count = 0, size = tArr.length;
List<Integer> result = new ArrayList<>();
for (int right = 0; right < sArr.length; right++) {
// 更新s中的字符在散列中的數量
hash[sArr[right] - 'a']--;
// 說明s中的該字符在t中出現過
if (hash[sArr[right] - 'a'] >= 0) {
count++;
}
if (right > size - 1) {
hash[sArr[left] - 'a']++;
if (hash[sArr[left] - 'a'] > 0) {
count--;
}
left++;
}
// 更新結果,記錄子串起始位置
if (count == size) {
result.add(left);
}
}
return result;
}
最後來看看數組類型的題吧
最大連續1的個數 III
給定一個由若干 0 和 1 組成的數組 A,我們最多可以將 K 個值從 0 變成 1 。返回僅包含 1 的最長(連續)子數組的長度。
示例
輸入:A = [1,1,1,0,0,0,1,1,1,1,0], K = 2
輸出:6
解釋:[1,1,1,0,0,1,1,1,1,1,1]
解題思路
這題有點像上面的 替換後的最長重複字符,只不過把字符串換成了數組,由於只有兩種數字 0 和 1,並且只求連續 1 的長度,我們可以連 hash 映射都不需要了,直接計算遍歷到的 0 的個數即可。
public int longestOnes(int[] A, int K) {
if (A == null) return 0;
int left = 0, count = 0, result = 0;
for (int right = 0; right < A.length; right++) {
// 找到了0需要替換,count加1
if (A[right] == 0) {
count++;
}
while (count > K) {
// 之前的0滑出了窗口,count減1
if (A[left] == 0) {
count--;
}
left++;
}
result = Math.max(result, right - left + 1);
}
return result;
}
K 個不同整數的子數組
給定一個正整數數組 A,如果 A 的某個子數組中不同整數的個數恰好爲 K,則稱 A 的這個連續、不一定獨立的子數組爲好子數組。
示例
輸入:A = [1,2,1,2,3], K = 2
輸出:7
解釋:恰好由 2 個不同整數組成的子數組
[1,2], [2,1], [1,2], [2,3], [1,2,1], [2,1,2], [1,2,1,2]
解題思路
這題比較 tricky 的一個地方在於,這裏不是求最小值最大值,而是要你計數。
但是如果每次僅僅加 1 的話又不太對,例如 A = [1,2,1,2,3], K = 2 這個例子,假如右指針移到 index 爲 3 的位置,如果按之前的思路左指針根據 count 來移動,當前窗口是 [1,2,1,2],但是怎麼把 [2,1] 給考慮進去呢?
可以從數組和子數組的關係來思考!
假如 [1,2,1,2] 是符合條件的數組,如果要計數的話,[1,2,1,2] 要求的結果是否和 [1,2,1] 的結果存在聯繫?這兩個數組的區別在於多了一個新進來的元素,之前子數組計數沒考慮到這個元素,假如把這個元素放到之前符合條件的子數組中組成的新數組也是符合條件的,我們看看這個例子中所有滿足條件的窗口以及對應的滿足條件的子數組情況:
[1,2,1,2,3] // 窗口滿足條件
l r // 滿足條件的子數組 [1,2]
[1,2,1,2,3] // 窗口滿足條件
l r // 滿足條件的子數組 [1,2],[2,1],[1,2,1]
[1,2,1,2,3] // 窗口滿足條件
l r // 滿足條件的子數組 [1,2],[2,1],[1,2,1],[1,2],[2,1,2],[1,2,1,2]
[1,2,1,2,3] // 窗口不滿足條件,移動左指針至滿足條件
l r
[1,2,1,2,3] // 窗口滿足條件
l r // 滿足條件的子數組 [1,2],[2,1],[1,2,1],[1,2],[2,1,2],[1,2,1,2],[2,3]
你可以看到對於一段連續的數組,新的元素進來,窗口增加 1,每次的增量都會在前一次增量的基礎上加 1。當新的元素進來打破當前條件會使這個增量從新回到 1,這樣我們左指針移動條件就是隻要是移動不會改變條件,就移動,不然就停止。
public int subarraysWithKDistinct(int[] A, int K) {
if (A == null || A.length < K) return 0;
// hash表示A中元素出現次數,因爲條件中1 <= A[i] <= A.length
int[] hash = new int[A.length + 1];
// kind表示出現過的元素種類
// count表示子數組的計數
int left = 0, kind = 0, count = 1, result = 0;
for (int right = 0; right < A.length; right++) {
// 如果當前遍歷到的元素從未出現過,那麼直接擴大右邊界
hash[A[right]]++;
// 當前元素是第一次出現,種類加1
if (hash[A[right]] == 1) {
kind++;
}
// 左指針右移直至窗口滿足條件
while (hash[A[left]] > 1 || kind > K) {
hash[A[left]]--;
if (kind > K) {
count = 1;
kind--;
} else {
count++;
}
left++;
}
if (kind == K) {
result += count;
}
}
return result;
}
總結
至此,本文用同一個框架解決了多道滑動窗口的題目,這類問題思維複雜度並不高,但是出錯點往往在細節。記憶常用的解題模版還是很有必要的,特別是對於這種變量名多,容易混淆的題型。有了這個框架,思考的點就轉化爲 “什麼條件下移動左指針”,無關信息少了,思考加實現自然不是問題。