滑動視窗(Sliding Window)技巧總結

大雜草發表於2020-09-01

什麼是滑動視窗(Sliding Window)

The Sliding Problem contains a sliding window which is a sub – list that runs over a Large Array which is an underlying collection of elements.

滑動視窗演算法可以用以解決陣列/字串的子元素問題,它可以將巢狀的迴圈問題,轉換為單迴圈問題,降低時間複雜度。

比如找最長的全為1的子陣列長度。滑動視窗一般從第一個元素開始,一直往右邊一個一個元素挪動。當然了,根據題目要求,我們可能有固定視窗大小的情況,也有視窗的大小變化的情況。

該圖中,我們視窗一格一格往右移動

如何判斷使用滑動視窗演算法

如果題目中求的結果有以下情況時可使用滑動視窗演算法:

  • 最小值 Minimum value
  • 最大值 Maximum value
  • 最長值 Longest value
  • 最短值 Shortest value
  • K值 K-sized value

演算法模板與思路

/* 滑動視窗演算法框架 */
void slidingWindow(string s, string t) {
    unordered_map<char, int> need, window;
    for (char c : t) need[c]++;
    
    int left = 0, right = 0;
    int valid = 0; 
    while (right < s.size()) {
        // c 是將移入視窗的字元
        char c = s[right];
        // 右移視窗
        right++;
        // 進行視窗內資料的一系列更新
        ...

        /*** debug 輸出的位置 ***/
        printf("window: [%d, %d)\n", left, right);
        /********************/
        
        // 判斷左側視窗是否要收縮
        while (window needs shrink) {
            // d 是將移出視窗的字元
            char d = s[left];
            // 左移視窗
            left++;
            // 進行視窗內資料的一系列更新
            ...
        }
    }
}

滑動視窗演算法的思路:

  1. 在字串 S 中使用雙指標中的左右指標技巧,初始化 left = right = 0 ,把索引左閉右開區間 [left, right) 稱為一個「視窗」。
  2. 不斷地增加 right 指標擴大視窗 [left, right) ,直到視窗中的字串符合要求(包含了 T 中的所有字元)。
  3. 此時停止增加 right ,轉而不斷增加 left 指標縮小視窗 [left, right) ,直到視窗中的字串不再符合要求(不包含 T 中的所有字元了)。同時,每次增加 left ,都要更新一輪結果。
  4. 重複第2和第3步,直到 right 到達字串 S 的盡頭。

needswindow 相當於計數器,分別記錄 T 中字元出現次數和「視窗」中的相應字元的出現次數。

開始套模板之前,要思考以下四個問題:

  1. 當移動 right 擴大視窗,即加入字元時,應該更新哪些資料?
  2. 什麼條件下,視窗應該暫停擴大,開始移動_left_ 縮小視窗?
  3. 當移動 left 縮小視窗,即移出字元時,應該更新哪些資料?
  4. 我們要的結果應該在擴大視窗時還是縮小視窗時進行更新?

滑動視窗問題例項

最小覆蓋子串

LeetCode題目:76.最小覆蓋子串

76.最小覆蓋子串

1、閱讀且分析題目

題目中包含關鍵字:時間複雜度O(n)字串最小子串。可使用滑動視窗演算法解決。

2. 思考滑動視窗演算法四個問題

1、當移動 right 擴大視窗,即加入字元時,應該更新哪些資料?

更新 window 中加入字元的個數,判斷 needwindow 中的字元個數是否相等,相等則 valid++

2、什麼條件下,視窗應該暫停擴大,開始移動_left_ 縮小視窗?

window 包含 need 中的字元及個數時,即 valid == len(need)

3、當移動 left 縮小視窗,即移出字元時,應該更新哪些資料?

更新 window 中移出字元的個數,且判斷 needwindow 中的移出字元個數是否相等,相等則 valid--

4、我們要的結果應該在擴大視窗時還是縮小視窗時進行更新?

在縮小視窗時,因為求的是最小子串。

3. 程式碼實現

func minWindow(s string, t string) string {
	need, window := make(map[byte]int), make(map[byte]int)
	for i := 0; i < len(t); i++ { // 初始化 need 
		if _, ok := need[t[i]]; ok {
			need[t[i]]++
		} else {
			need[t[i]] = 1
		}
	}

	left, right, valid := 0, 0, 0
	start, slen := 0, len(s)+1 // 設定長度為 len(s) + 1 表示此時沒有符合條件的子串
	for right < len(s) { // 滑動視窗向右擴大
		c := s[right]
		right++

		if _, ok := need[c]; ok { // 向右擴大時,更新資料
			if _, ok := window[c]; ok {
				window[c]++
			} else {
				window[c] = 1
			}

			if window[c] == need[c] {
				valid++
			}
		}

		for valid == len(need) { // 當視窗包括 need 中所有字元及個數時,縮小視窗

			if right-left < slen {  // 縮小前,判斷是否最小子串
				start = left
				slen = right - left
			}

			d := s[left]
			left++

			if v, ok := need[d]; ok { // 向左縮小時,更新資料
				if window[d] == v {
					valid--
				}
				window[d]--
			}
		}
	}

	if slen == len(s)+1 { // 長度 len(s) + 1 表示此時沒有符合條件的子串
		return ""
	} else {
		return s[start : start+slen]
	}
}

4. 複雜度分析

  • 時間複雜度:O(n)n 表示字串 s 的長度。遍歷一次字串。
  • 空間複雜度:O(m)m 表示字串 t 的長度。使用了兩個雜湊表,儲存字串 t 中的字元個數。

字串排列

LeetCode題目:567.字串的排列

567.字串的排列

1、閱讀且分析題目

題目中包含關鍵字:字串子串,且求 s2 中是否包含 s1 的排列,即求是否包含長度 k 的子串。可使用滑動視窗演算法解決。

2. 思考滑動視窗演算法四個問題

1、當移動 right 擴大視窗,即加入字元時,應該更新哪些資料?

更新 window 中加入字元的個數,判斷 needwindow 中的字元個數是否相等,相等則 valid++

2、什麼條件下,視窗應該暫停擴大,開始移動_left_ 縮小視窗?

window 包含 need 中的字元及個數時,即 valid == len(need)

3、當移動 left 縮小視窗,即移出字元時,應該更新哪些資料?

更新 window 中移出字元的個數,且判斷 needwindow 中的移出字元個數是否相等,相等則 valid--

4、我們要的結果應該在擴大視窗時還是縮小視窗時進行更新?

無論在擴大時或縮小視窗時都可以,因為求的是固定長度的子串。選擇在縮小視窗時更新。

3. 程式碼實現

func checkInclusion(s1 string, s2 string) bool {
	if s1 == s2 {
		return true
	}

	need, window := make(map[byte]int), make(map[byte]int)

	for i := 0; i < len(s1); i++ {
		if _, ok := need[s1[i]]; ok {
			need[s1[i]]++
		} else {
			need[s1[i]] = 1
		}
	}

	left, right := 0, 0
	valid := 0

	for right < len(s2) {
		c := s2[right]
		right++

		if _, ok := need[c]; ok {
			if _, ok := window[c]; ok {
				window[c]++
			} else {
				window[c] = 1
			}
			if window[c] == need[c] {
				valid++
			}
		}

		for valid == len(need) {

			if right-left == len(s1) {
				return true
			}

			d := s2[left]
			left++

			if _, ok := need[d]; ok {
				if _, ok := window[d]; ok {
					if window[d] == need[d] {
						valid--
					}
					window[d]--
				}
			}
		}
	}

	return false
}

4. 複雜度分析

  • 時間複雜度:O(n)n 表示字串 s2 的長度。遍歷一次字串。
  • 空間複雜度:O(m)m 表示字串 s1 的長度。使用了兩個雜湊表,儲存字串 s1 中的字元個數。

找所有字母異位詞

LeetCode題目:438.找到字串中所有字母異位詞

438.找到字串中所有字母異位詞

1、閱讀且分析題目

題目中包含關鍵字:字串,且求 s 中的所有 p 的字母異位詞的子串。可使用滑動視窗演算法解決。

2. 思考滑動視窗演算法四個問題

1、當移動 right 擴大視窗,即加入字元時,應該更新哪些資料?

更新 window 中加入字元的個數,判斷 needwindow 中的字元個數是否相等,相等則 valid++

2、什麼條件下,視窗應該暫停擴大,開始移動_left_ 縮小視窗?

window 包含 need 中的字元及個數時,即 valid == len(need)

3、當移動 left 縮小視窗,即移出字元時,應該更新哪些資料?

更新 window 中移出字元的個數,且判斷 needwindow 中的移出字元個數是否相等,相等則 valid--

4、我們要的結果應該在擴大視窗時還是縮小視窗時進行更新?

無論在擴大時或縮小視窗時都可以,因為求的是固定長度的子串。選擇在縮小視窗時更新。

3. 程式碼實現

func findAnagrams(s string, p string) []int {
	need, window := make(map[byte]int), make(map[byte]int)
	for i := 0; i < len(p); i++ { // 初始化
		if _, ok := need[p[i]]; ok {
			need[p[i]]++
		} else {
			need[p[i]] = 1
		}
	}

	left, right := 0, 0
	valid := 0

	ans := make([]int, 0)

	for right < len(s) {
		c := s[right]
		right++

		if _, ok := need[c]; ok {
			if _, ok := window[c]; ok {
				window[c]++
			} else {
				window[c] = 1
			}
			if need[c] == window[c] {
				valid++
			}
		}

		for valid == len(need) {
			if right-left == len(p) {
				ans = append(ans, left)
			}

			d := s[left]
			left++

			if _, ok := need[d]; ok {
				if _, ok := window[d]; ok {
					if need[d] == window[d] {
						valid--
					}
					window[d]--
				}
			}
		}
	}

	return ans
}

4. 複雜度分析

  • 時間複雜度:O(n)n 表示字串 s 的長度。遍歷一次字串。
  • 空間複雜度:O(m)m 表示字串 p 的長度。使用了兩個雜湊表,儲存字串 p 中的字元個數。

最長無重複子串

LeetCode題目:3. 無重複字元的最長子串

3. 無重複字元的最長子串

1、閱讀且分析題目

題目中包含關鍵字:時間複雜度O(n)字串最小子串。可使用滑動視窗演算法解決。

2. 思考滑動視窗演算法四個問題

1、當移動 right 擴大視窗,即加入字元時,應該更新哪些資料?

更新 window 中加入字元的個數,及當 window 中的某個字元個數 == 2時,更新 valid == false

2、什麼條件下,視窗應該暫停擴大,開始移動_left_ 縮小視窗?

window 中的字元及個數 == 2時,即 valid == false

3、當移動 left 縮小視窗,即移出字元時,應該更新哪些資料?

更新 window 中移出字元的個數,且判斷 window 中移出字元個數是否 == 2 ,相等則 valid == true

4、我們要的結果應該在擴大視窗時還是縮小視窗時進行更新?

在擴大視窗時,因為求的是最大子串。

3. 程式碼實現

func lengthOfLongestSubstring(s string) int {
	if s == "" { // 當字串為空時,返回0
		return 0
	}

	window := make(map[byte]int)

	left, right, max := 0, 0, 0
	valid := true

	for right < len(s) {
		c := s[right]
		right++

		if _, ok := window[c]; !ok { // 初始化
			window[c] = 0
		}
		window[c]++         // 累加
		if window[c] == 2 { // 當出現重複字元時
			valid = false
		} else { // 否則累加不重複子串長度,並且判斷是否當前最長
			if max < right-left {
				max = right - left
			}
		}

		for valid == false {
			d := s[left]
			left++

			if window[d] == 2 {
				valid = true
			}
			window[d]--
		}
	}
	return max
}

4. 複雜度分析

  • 時間複雜度:O(n)n 表示字串 s 的長度。遍歷一次字串。
  • 空間複雜度:O(n)n 表示字串 s 的長度。使用了雜湊表,儲存不重複的字元個數。

總結

  • 滑動視窗演算法可以用以解決陣列/字串的子元素問題,它可以將巢狀的迴圈問題,轉換為單迴圈問題,降低時間複雜度。
  • 問題中包含字串子元素、最大值、最小值、最長、最短、K值等關鍵字時,可使用滑動視窗演算法。
  • 模板中的向左和向右時的處理是對稱的。
  • 套模板前思考四個問題:
    1. 當移動 right 擴大視窗,即加入字元時,應該更新哪些資料?
    2. 什麼條件下,視窗應該暫停擴大,開始移動_left_ 縮小視窗?
    3. 當移動 left 縮小視窗,即移出字元時,應該更新哪些資料?
    4. 我們要的結果應該在擴大視窗時還是縮小視窗時進行更新?

參考資料

相關文章