1. 程式人生 > >滑動視窗演算法基本原理與實踐

滑動視窗演算法基本原理與實踐

學過計算機網路的同學,都知道滑動視窗協議(Sliding Window Protocol),該協議是 TCP協議 的一種應用,用於網路資料傳輸時的流量控制,以避免擁塞的發生。該協議允許傳送方在停止並等待確認前傳送多個數據分組。由於傳送方不必每發一個分組就停下來等待確認。因此該協議可以加速資料的傳輸,提高網路吞吐量。

滑動視窗演算法其實和這個是一樣的,只是用的地方場景不一樣,可以根據需要調整視窗的大小,有時也可以是固定視窗大小。

滑動視窗演算法(Sliding Window Algorithm)

Sliding window algorithm is used to perform required operation on specific window size of given large buffer or array.

滑動視窗演算法是在給定特定視窗大小的陣列或字串上執行要求的操作。

This technique shows how a nested for loop in few problems can be converted to single for loop and hence reducing the time complexity.

該技術可以將一部分問題中的巢狀迴圈轉變為一個單迴圈,因此它可以減少時間複雜度。

簡而言之,滑動視窗演算法在一個特定大小的字串或陣列上進行操作,而不在整個字串和陣列上操作,這樣就降低了問題的複雜度,從而也達到降低了迴圈的巢狀深度。其實這裡就可以看出來滑動視窗主要應用在陣列和字串上。

基本示例

如下圖所示,設定滑動視窗(window)大小為 3,當滑動視窗每次劃過陣列時,計算當前滑動視窗中元素的和,得到結果 res。


可以用來解決一些查詢滿足一定條件的連續區間的性質(長度等)的問題。由於區間連續,因此當區間發生變化時,可以通過舊有的計算結果對搜尋空間進行剪枝,這樣便減少了重複計算,降低了時間複雜度。往往類似於“ 請找到滿足 xx 的最 x 的區間(子串、子陣列)的 xx ”這類問題都可以使用該方法進行解決。

需要注意的是,滑動視窗演算法更多的是一種思想,而非某種資料結構的使用。 

滑動視窗法的大體框架

在介紹滑動視窗的框架時候,大家先從字面理解下:

  • 滑動:說明這個視窗是移動的,也就是移動是按照一定方向來的。

  • 視窗:視窗大小並不是固定的,可以不斷擴容直到滿足一定的條件;也可以不斷縮小,直到找到一個滿足條件的最小視窗;當然也可以是固定大小。

為了便於理解,這裡採用的是字串來講解。但是對於陣列其實也是一樣的。滑動視窗演算法的思路是這樣:

  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 中字元出現次數和視窗中的相應字元的出現次數。

初始狀態:

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

現在開始增加 left,縮小視窗 [left, right]。

直到視窗中的字串不再符合要求,left 不再繼續移動。

之後重複上述過程,先移動 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;

但是,對於固定視窗大小,可以總結如下:

   // 固定視窗大小為 k
    string s;
    // 在 s 中尋找視窗大小為 k 時的所包含最大母音字母個數
    int  right = 0;while(right < s.size()) {
        window.add(s[right]);
        right++;
        // 如果符合要求,說明視窗構造完成,
        if (right>=k) {
            // 這是已經是一個視窗了,根據條件做一些事情
           // ... 可以計算視窗最大值等 
            // 最後不要忘記把 right -k 位置元素從窗口裡面移除
        }
    }
    return res;    

 

可以發現此時不需要依賴 left 指標了。因為視窗固定所以其實就沒必要使用left,right 雙指標來控制視窗的大小。

其次是對於視窗是固定的,可以輕易獲取到 left 的位置,此處 left = right-k;

實際上,對於視窗的構造是很重要的。具體可以看下面的例項。

演算法例項

1208. 儘可能使字串相等

給你兩個長度相同的字串,s 和 t。

將 s 中的第 i 個字元變到 t 中的第 i 個字元需要 |s[i] - t[i]| 的開銷(開銷可能為 0),也就是兩個字元的 ASCII 碼值的差的絕對值。

用於變更字串的最大預算是 maxCost。在轉化字串時,總開銷應當小於等於該預算,這也意味著字串的轉化可能是不完全的。

如果你可以將 s 的子字串轉化為它在 t 中對應的子字串,則返回可以轉化的最大長度。

如果 s 中沒有子字串可以轉化成 t 中對應的子字串,則返回 0。

示例 1:

輸入:s = "abcd", t = "bcdf", cost = 3
輸出:3
解釋:s 中的 "abc" 可以變為 "bcd"。開銷為 3,所以最大長度為 3。

示例 2:

輸入:s = "abcd", t = "cdef", cost = 3
輸出:1
解釋:s 中的任一字元要想變成 t 中對應的字元,其開銷都是 2。因此,最大長度為 1。

示例 3:

輸入:s = "abcd", t = "acde", cost = 0
輸出:1
解釋:你無法作出任何改動,所以最大長度為 1。 

程式碼

class Solution {
    public int equalSubstring(String s, String t, int maxCost) {
        int left = 0, right =0;
        int sum = 0;
        int res = 0;
     // 構造視窗 while (right < s.length()) { sum += Math.abs(s.charAt(right) - t.charAt(right)); right++;
       // 視窗構造完成,這時候要根據條件當前的視窗調整視窗大小 while (sum > maxCost) { sum -= Math.abs(s.charAt(left) - t.charAt(left)); left++; }
       // 記錄此時視窗的大小 res = Math.max(res, right -left); } return res; } }

這裡跟前面總結的框架不一樣的一個點就是,前面的框架是求最小視窗大小,這裡是求最大視窗大小,大家要學會靈活變通。

239. 滑動視窗最大值

給定一個數組 nums,有一個大小為 k 的滑動視窗從陣列的最左側移動到陣列的最右側。你只可以看到在滑動視窗內的 k 個數字。滑動視窗每次只向右移動一位。

返回滑動視窗中的最大值。

進階:

你能線上性時間複雜度內解決此題嗎?

示例:

輸入: nums = [1,3,-1,-3,5,3,6,7], 和 k = 3
輸出: [3,3,5,5,6,7] 
解釋: 

  滑動視窗的位置                最大值
---------------               -----
[1  3  -1] -3  5  3  6  7       3
 1 [3  -1  -3] 5  3  6  7       3
 1  3 [-1  -3  5] 3  6  7       5
 1  3  -1 [-3  5  3] 6  7       5
 1  3  -1  -3 [5  3  6] 7       6
 1  3  -1  -3  5 [3  6  7]      7

提示:

1 <= nums.length <= 10^5
-10^4 <= nums[i] <= 10^4
1 <= k <= nums.length

解答:

class Solution {
    public static int[] maxSlidingWindow(int[] nums, int k) {
        int right =0;
        int[] res = new int[nums.length -k +1];
        int index=0;
        LinkedList<Integer> list = new LinkedList<>();
     // 開始構造視窗 while (right < nums.length) {
       // 這裡的list的首位必須是視窗中最大的那位 while (!list.isEmpty() && nums[right] > list.peekLast()) { list.removeLast(); }
       // 不斷新增 list.addLast(nums[right]); right++;
       // 構造視窗完成,這時候需要根據條件做一些操作 if (right >= k){ res[index++]=list.peekFirst();
          // 如果發現第一個已經在視窗外面了,就移除 if(list.peekFirst() == nums[right-k]) { list.removeFirst(); } } } return res; } } 

這道題難度是困難。當然我們也會發現,這道題目和前面的非固定大小滑動視窗還是不一樣的。

看了一道困難的題目後,接下來看一道中等難度的就會發現是小菜一碟。

1456. 定長子串中母音的最大數目

給你字串 s 和整數 k 。

請返回字串 s 中長度為 k 的單個子字串中可能包含的最大母音字母數。

英文中的 母音字母 為(a, e, i, o, u)。

示例 1:

輸入:s = "abciiidef", k = 3
輸出:3
解釋:子字串 "iii" 包含 3 個母音字母。

示例 2:

輸入:s = "aeiou", k = 2
輸出:2
解釋:任意長度為 2 的子字串都包含 2 個母音字母。

示例 3:

輸入:s = "leetcode", k = 3
輸出:2
解釋:"lee"、"eet" 和 "ode" 都包含 2 個母音字母。

示例 4:

輸入:s = "rhythms", k = 4
輸出:0
解釋:字串 s 中不含任何母音字母。

示例 5:

輸入:s = "tryhard", k = 4
輸出:1

提示:

1 <= s.length <= 10^5
s 由小寫英文字母組成
1 <= k <= s.length

解答

class Solution {
    public int maxVowels(String s, int k) {
        int right =0;
        int sum = 0;
        int max = 0;
        while (right < s.length()) {
            sum += isYuan(s.charAt(right)) ;
            right++;
            if (right >=k) {
                max = Math.max(max, sum);
                sum -= isYuan(s.charAt(right-k));
            }
        }
        return max;
    }

    public int isYuan(char s) {
        return s=='a' || s=='e' ||s=='i' ||s=='o' ||s=='u' ? 1:0;
    }
}

 

參考文章

演算法與資料結構(一):滑動視窗法總結

&nbs