【演算法】滑動視窗三步走

Nemo&發表於2021-03-27

滑動視窗介紹

對於大部分滑動視窗型別的題目,一般是考察字串的匹配。比較標準的題目,會給出一個模式串B,以及一個目標串A。然後提出問題,找到A中符合對B一些限定規則的子串或者對A一些限定規則的結果,最終再將搜尋出的子串完成題意中要求的組合或者其他

比如:給定一個字串 s 和一個非空字串 p,找到 s 中所有是 p 的字母異位詞的子串,返回這些子串的起始索引。

又或者:給你一個字串 S、一個字串 T,請在字串 S 裡面找出:包含 T 所有字母的最小子串。

再如:給定一個字串 s 和一些長度相同的單詞 words。找出 s 中恰好可以由 words 中所有單詞串聯形成的子串的起始位置。

都是屬於這一類的標準題型。而對於這一類題目,我們常用的解題思路,是去維護一個可變長度的滑動視窗。無論是使用雙指標,還是使用雙端佇列,又或者用遊標等其他奇技淫巧,目的都是一樣的。滑動視窗的原理都是一致的,只不過改變了實現方法而已。

簡介

學過計算機網路的同學,都知道滑動視窗協議(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, 2, 3, \dots, n]\)。我們設滑動視窗的左邊界為 i,右邊界為 j,則滑動視窗框起來的是一個左閉右開區間 \([i, j)\)。注意,為了程式設計的方便,滑動視窗一般表示成一個左閉右開區間。在一開始,\(i=1, j=1\),滑動視窗位於序列的最左側,視窗大小為零。
【演算法】滑動視窗三步走

滑動視窗的重要性質是:視窗的左邊界和右邊界永遠只能向右移動,而不能向左移動。這是為了保證滑動視窗的時間複雜度是 \(O(n)\)。如果左右邊界向左移動的話,這叫做“回溯”,演算法的時間複雜度就可能不止 \(O(n)\)

在這道題中,我們關注的是滑動視窗中所有數的和。當滑動視窗的右邊界向右移動時,也就是 j = j + 1,視窗中多了一個數字 j,視窗的和也就要加上 j。當滑動視窗的左邊界向右移動時,也就是 i = i + 1,視窗中少了一個數字 i,視窗的和也就要減去 i。滑動視窗只有 右邊界向右移動(擴大視窗)左邊界向右移動(縮小視窗) 兩個操作,所以實際上非常簡單。

如何用滑動視窗解這道題

要用滑動視窗解這道題,我們要回答兩個問題:

  • 第一個問題,視窗何時擴大,何時縮小?
  • 第二個問題,滑動視窗能找到全部的解嗎?

對於第一個問題,回答非常簡單:

  • 當視窗的和小於 target 的時候,視窗的和需要增加,所以要擴大視窗,視窗的右邊界向右移動
  • 當視窗的和大於 target 的時候,視窗的和需要減少,所以要縮小視窗,視窗的左邊界向右移動
  • 當視窗的和恰好等於 target 的時候,我們需要記錄此時的結果。設此時的視窗為 \([i, j)\),那麼我們已經找到了一個 i 開頭的序列,也是唯一一個 i 開頭的序列,接下來需要找 i+1 開頭的序列,所以視窗的左邊界要向右移動

對於第二個問題,我們可以稍微簡單地證明一下:
【演算法】滑動視窗三步走

我們一開始要找的是 1 開頭的序列,只要視窗的和小於 target,視窗的右邊界會一直向右移動。假設 \(1+2+\dots+8\) 小於 target,再加上一個 9 之後, 發現 \(1+2+\dots+8+9\) 又大於 target 了。這說明 1 開頭的序列找不到解。此時滑動視窗的最右元素是 9。

接下來,我們需要找 2 開頭的序列,我們發現,\(2 + \dots + 8 < 1 + 2 + \dots + 8 < \mathrm{target}\)。這說明 2 開頭的序列至少要加到 9。那麼,我們只需要把原先 1~9 的滑動視窗的左邊界向右移動,變成 2~9 的滑動視窗,然後繼續尋找。而右邊界完全不需要向左移動。

以此類推,滑動視窗的左右邊界都不需要向左移動,所以這道題用滑動視窗一定可以得到所有的解。時間複雜度是 \(O(n)\)

注:這道題當前可以用等差數列的求和公式來計算滑動視窗的和。不過我這裡沒有使用求和公式,是為了展示更通用的解題思路。實際上,把題目中的正整數序列換成任意的遞增整數序列,這個方法都可以解。

適用場景

適用於需要以某一視窗範圍的元素遍歷陣列,而不是單個遍歷每個元素。

關鍵詞:視窗、範圍

滑動視窗三步走

設滑動視窗的左邊界為 i,右邊界為 j,則滑動視窗框起來的是一個左閉右開區間 \([i, j)\)
滑動視窗前閉後開,也沒想象的那麼複雜,其實就是一個佇列啦!我們寫滑動視窗,其實就是手動實現一個佇列啦!!!
詳情請看:【資料結構】棧與佇列

注意,為了程式設計的方便,滑動視窗一般表示成一個左閉右開區間。在一開始,\(i=1, j=1\),滑動視窗位於序列的最左側,視窗大小為零

滑動視窗只有 右邊界向右移動(擴大視窗)左邊界向右移動(縮小視窗) 兩個操作,所以實際上非常簡單。

特徵:

  • 前閉後開,left 元素包含在滑動視窗內,right 元素不包含在滑動視窗內
  • 使用 right 來進行遍歷元素,滿足條件就 right++,加入滑動視窗
  • 滑動視窗 left、right 只能前進,不能後退,所以一般只用比較最大值即可

操作:

  • 右邊界向右移動(擴大視窗):(雙指標)right++; 或者 (雙端佇列)linkedList.addFirst(right);
  • 左邊界向右移動(縮小視窗):(雙指標)left++; 或者 (雙端佇列)linkedList.removeLast();

滑動視窗三步走:

1. 明確迴圈條件

1.明確迴圈條件:right 如何遍歷陣列?什麼時候保持迴圈,什麼時候結束迴圈?

注意:由於滑動視窗是利用佇列的原理實現的,前閉後開,所以你得思考一下迴圈的條件是right < s.length()還是right <= s.length()
因為有時候使用 right 來一個一個遍歷元素時,我們right < s.length()即可遍歷完所有元素;
如果我們有時候不使用 right 來一個一個遍歷元素的話,即 right 只充當邊界使用,那我們就需要right <= s.length()才能遍歷完所有元素,這裡需要特別注意了!!!切勿生搬硬套!

2. 尋找擴縮條件

2.尋找擴縮條件:視窗何時擴大,何時縮小?

注意:這個擴大與縮小的時機得根據題意來具體情況具體分析,並沒有一個詳細的標準。

3. 完成目標功能

3.完成目標功能:當滑動視窗滿足條件時,完成目標功能。

滑動視窗法的大體框架

其實,滑動視窗就是通過不斷調整子序列的 start 和 end 位置,從而獲取滿足要求的解。

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

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

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

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

  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 的末端,演算法結束。

如果你能夠理解上述過程,恭喜,你已經完全掌握了滑動視窗演算法思想。至於如何具體到問題,如何得出此題的答案,都是程式設計問題,等會提供一套模板,理解一下就會了。

上述過程對於非固定大小的滑動視窗,可以簡單地寫出如下偽碼框架:

int slidingWindow() {
    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;
}

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

int slidingWindow() {
   // 固定視窗大小為 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;

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

例項1

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;
    }
}

例項2

劍指Offer57.和為s的連續正數序列(簡單)

題目:輸入一個正整數 target ,輸出所有和為 target 的連續正整數序列(至少含有兩個數)。序列內的數字由小到大排列,不同序列按照首個數字從小到大排列。

示例 1:

輸入:target = 9

輸出:[[2,3,4],[4,5]]

示例 2:

輸入:target = 15

輸出:[[1,2,3,4,5],[4,5,6],[7,8]]

答案

這個題目比較簡單,典型的一道滑動視窗的題目。

假若我們輸入的 target 為 9,大腦中應該有下面這麼個玩意:

【演算法】滑動視窗三步走

然後我們通過左右指標來維護一個滑動視窗,同時計算視窗內的值是否是目標值:

【演算法】滑動視窗三步走

如果視窗的值過小,我們就移動右邊界。

【演算法】滑動視窗三步走

如果視窗的值過大,我們就移動左邊界。

【演算法】滑動視窗三步走

剩下的就是反覆上面的操作就可以了。到這裡分析過程看似結束了。但是我們可以觀察出一丟丟規律,用來優化我們的演算法。對於任意一個正整數,總是小於它的中值與中值+1的和。為了讓大家直觀,用下圖舉例:

【演算法】滑動視窗三步走

比如這裡的100,就一定小於50+51,換成其他數也一樣。換句話說,一旦視窗左邊界超過中值,視窗內的和一定會大於 target。

根據分析,得到題解:

同時也給一個java版本的:

//java
class Solution {
    public int[][] findContinuousSequence(int target) {
        List<int[]> res = new ArrayList<>();
        int i = 1; 
        int j = 1; 
        int win = 0; 
        while (i <= target / 2) {
            if (win < target) {
                win += j;
                j++;
            } else if (win > target) {
                win -= i;
                i++;
            } else {
                int[] arr = new int[j-i];
                for (int k = i; k < j; k++) {
                    arr[k-i] = k;
                }
                res.add(arr);
                win -= i;
                i++;
            }
        }
        return res.toArray(new int[res.size()][]);
    }
}

答案2

什麼是滑動視窗

滑動視窗可以看成陣列中框起來的一個部分。在一些陣列類題目中,我們可以用滑動視窗來觀察可能的候選結果。當滑動視窗從陣列的左邊滑到了右邊,我們就可以從所有的候選結果中找到最優的結果。

對於這道題來說,陣列就是正整數序列 \([1, 2, 3, \dots, n]\)。我們設滑動視窗的左邊界為 i,右邊界為 j,則滑動視窗框起來的是一個左閉右開區間 \([i, j)\)。注意,為了程式設計的方便,滑動視窗一般表示成一個左閉右開區間。在一開始,\(i=1, j=1\),滑動視窗位於序列的最左側,視窗大小為零。
【演算法】滑動視窗三步走

滑動視窗的重要性質是:視窗的左邊界和右邊界永遠只能向右移動,而不能向左移動。這是為了保證滑動視窗的時間複雜度是 \(O(n)\)。如果左右邊界向左移動的話,這叫做“回溯”,演算法的時間複雜度就可能不止 \(O(n)\)

在這道題中,我們關注的是滑動視窗中所有數的和。當滑動視窗的右邊界向右移動時,也就是 j = j + 1,視窗中多了一個數字 j,視窗的和也就要加上 j。當滑動視窗的左邊界向右移動時,也就是 i = i + 1,視窗中少了一個數字 i,視窗的和也就要減去 i。滑動視窗只有 右邊界向右移動(擴大視窗)左邊界向右移動(縮小視窗) 兩個操作,所以實際上非常簡單。

如何用滑動視窗解這道題

要用滑動視窗解這道題,我們要回答兩個問題:

  • 第一個問題,視窗何時擴大,何時縮小?
  • 第二個問題,滑動視窗能找到全部的解嗎?

對於第一個問題,回答非常簡單:

  • 當視窗的和小於 target 的時候,視窗的和需要增加,所以要擴大視窗,視窗的右邊界向右移動
  • 當視窗的和大於 target 的時候,視窗的和需要減少,所以要縮小視窗,視窗的左邊界向右移動
  • 當視窗的和恰好等於 target 的時候,我們需要記錄此時的結果。設此時的視窗為 \([i, j)\),那麼我們已經找到了一個 i 開頭的序列,也是唯一一個 i 開頭的序列,接下來需要找 i+1 開頭的序列,所以視窗的左邊界要向右移動

對於第二個問題,我們可以稍微簡單地證明一下:
【演算法】滑動視窗三步走

我們一開始要找的是 1 開頭的序列,只要視窗的和小於 target,視窗的右邊界會一直向右移動。假設 \(1+2+\dots+8\) 小於 target,再加上一個 9 之後, 發現 \(1+2+\dots+8+9\) 又大於 target 了。這說明 1 開頭的序列找不到解。此時滑動視窗的最右元素是 9。

接下來,我們需要找 2 開頭的序列,我們發現,\(2 + \dots + 8 < 1 + 2 + \dots + 8 < \mathrm{target}\)。這說明 2 開頭的序列至少要加到 9。那麼,我們只需要把原先 1~9 的滑動視窗的左邊界向右移動,變成 2~9 的滑動視窗,然後繼續尋找。而右邊界完全不需要向左移動。

以此類推,滑動視窗的左右邊界都不需要向左移動,所以這道題用滑動視窗一定可以得到所有的解。時間複雜度是 \(O(n)\)

注:這道題當前可以用等差數列的求和公式來計算滑動視窗的和。不過我這裡沒有使用求和公式,是為了展示更通用的解題思路。實際上,把題目中的正整數序列換成任意的遞增整數序列,這個方法都可以解。

作者:nettee
連結:https://leetcode-cn.com/problems/he-wei-sde-lian-xu-zheng-shu-xu-lie-lcof/solution/shi-yao-shi-hua-dong-chuang-kou-yi-ji-ru-he-yong-h/
來源:力扣(LeetCode)
著作權歸作者所有。商業轉載請聯絡作者獲得授權,非商業轉載請註明出處。

本題題解:

class Solution {
    public int[][] findContinuousSequence(int target) {

        // 這得從1開始看起
        // 如果 滑動視窗的數字和sum == target 那麼就可以加入列表,算一個
        // 如果大於,那就前進 left,
        // 如果小於,前進 right

        int left = 1;
        int right = 1;
        int sum = 0;

        List<int[]> list = new ArrayList<>();

        // while (right <= target) {
        while (left <= target / 2) {

            if (sum == target) {

                int[] arr = new int[right - left];
                for (int i = left; i < right; i++) {
                    arr[i - left] = i;
                }

                list.add(arr);

                // 通過了就左邊界前進,繼續遍歷
                sum -= left;
                left++;
            } else if (sum < target) {
                sum += right;
                right++;
            } else if (sum > target) {
                sum -= left;
                left++;
            }
        }

        // return list.toArray();   // 這樣是不行的,不然返回的是Object[]陣列,得指明陣列型別
        return list.toArray(new int[list.size()][]);
    }
}

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

在上一題中,我們使用雙端佇列完成了滑動視窗的一道頗為困難的題目,以此展示了什麼是滑動視窗。在本節中我們將繼續深入分析,探索滑動視窗題型一些具有模式性的解法。

第3題:給定一個字串,請你找出其中不含有重複字元的 最長子串 的長度。

示例 1:

輸入: "abcabcbb"
輸出: 3
解釋: 因為無重複字元的最長子串是 "abc",所以其長度為 3。

示例 2:

輸入: "bbbbb"
輸出: 1
解釋: 因為無重複字元的最長子串是 "b",所以其長度為 1。

示例 3:

輸入: "pwwkew"
輸出: 3
解釋: 因為無重複字元的最長子串是 "wke",所以其長度為 3。

請注意,你的答案必須是 子串 的長度,"pwke" 是一個子序列,不是子串。

答案

直接分析題目,假設我們的輸入為“abcabcbb”,我們只需要維護一個視窗在輸入字串中進行移動。如下圖:

【演算法】滑動視窗三步走

當下一個元素在視窗沒有出現過時,我們擴大視窗。

【演算法】滑動視窗三步走

當下一個元素在視窗中出現過時,我們縮小視窗,將出現過的元素以及其左邊的元素統統移出:

【演算法】滑動視窗三步走

在整個過程中,我們記錄下視窗出現過的最大值即可。而我們唯一要做的,只需要儘可能擴大視窗。

那我們程式碼中通過什麼來維護這樣的一個視窗呢?anyway~ 不管是佇列,雙指標,甚至通過map來做,都可以。

我們演示一個雙指標的做法:

//java
public class Solution {
    public static int lengthOfLongestSubstring(String s) {
        int n = s.length();
        Set<Character> set = new HashSet<>();
        int result = 0, left = 0, right = 0;
        while (left < n && right < n) {
            //charAt:返回指定位置處的字元
            if (!set.contains(s.charAt(right))) {
                set.add(s.charAt(right));
                right++;
                result = Math.max(result, right - left);
            } else {
                set.remove(s.charAt(left));
                left++;
            }
        }
        return result;
    }
}

【演算法】滑動視窗三步走

當然,不使用集合,使用雜湊表做也是可以的:這裡的雜湊表鍵值對是(字元,出現頻數)

class Solution {
    public int lengthOfLongestSubstring(String s) {

        int left = 0;
        int right = 0;  // 使用右邊界來遍歷陣列
        int result = 0; // 用來存放最長子串的長度

        char c = 0; // 用來遍歷字串中每個字元

        Map<Character, Integer> map = new HashMap<>();

        // 前閉後開,滑動視窗
        // 每次遍歷都新增頻度
        // 如果不重複就加入雜湊表,右邊界前進,比較最大長度
        // 如果重複,刪除雜湊表,左邊界前進

        while (right < s.length()) {
            // 使用右邊界來遍歷陣列,後面再判斷是否加入雜湊表中
            c = s.charAt(right);
            int count = map.getOrDefault(c, 0) + 1;

            if (count > 1) {    // 重複,左邊界前進,雜湊表刪除
                map.put(s.charAt(left), map.get(s.charAt(left)) - 1);
                left++;
            } else {    // 右邊界前進,雜湊表增加
                map.put(c, count);
                right++;
                result = result > (right - left)? result : (right - left);
            }

        }
        return result;
    }
}

通過觀察,我們能看出來。如果是最壞情況的話,我們每一個字元都可能會訪問兩次,left一次,right一次,時間複雜度達到了O(2N),這是不可饒恕的。不理解的話看下圖:

假設我們的字串為“abcdc”,對於abc我們都訪問了2次。

【演算法】滑動視窗三步走

那如何來進一步優化呢?

其實我們可以定義字元到索引的對映,而不是簡單通過一個集合來判斷字元是否存在。這樣的話,當我們找到重複的字元時,我們可以立即跳過該視窗,而不需要對之前的元素進行再次訪問。

【演算法】滑動視窗三步走

而這裡的雜湊表的鍵值對是(字元,字元出現的索引+1)

//java
public class Solution {
    public static int lengthOfLongestSubstring(String s) {
        int n = s.length(), result = 0;
        Map<Character, Integer> map = new HashMap<>(); 
        for (int right = 0, left = 0; right < n; right++) {
            if (map.containsKey(s.charAt(right))) {
                left = Math.max(map.get(s.charAt(right)), left);
            }
            result = Math.max(result, right - left + 1);
            map.put(s.charAt(right), right + 1);
        }
        return result;
    }
}

【演算法】滑動視窗三步走

我的:

//java
// 上面的雜湊表記錄的是(字元,頻數)
// 這裡的雜湊表記錄的是(字元,出現索引+1)
public class Solution {
    public static int lengthOfLongestSubstring(String s) {
        int n = s.length(), result = 0;
        Map<Character, Integer> map = new HashMap<>(); 

        int left = 0;
        int right = 0;  // 使用右邊界來遍歷陣列

        char c = 0;

        while (right < s.length()) {

            c = s.charAt(right);
            int count = map.getOrDefault(c, -1);    // -1代表沒有出現過

            if (count == -1) {  // 沒有出現過
                map.put(c, right + 1);
                right++;
                result = Math.max(result, right - left);
            } else {    // 出現過
                left = Math.max(count, left);   // 這裡需要著重注意,因為滑動視窗left只能前進,不能倒退回去,只能取最大的
                map.put(c, right + 1);
                right++;
                result = Math.max(result, right - left);
            }
        }
        
        // for (int right = 0, left = 0; right < n; right++) {
        //     if (map.containsKey(s.charAt(right))) {
        //         left = Math.max(map.get(s.charAt(right)), left);
        //     }
        //     result = Math.max(result, right - left + 1);
        //     map.put(s.charAt(right), right + 1);
        // }
        return result;
    }
}

修改之後,我們發現雖然時間複雜度有了一定提高,但是還是比較慢!如何更進一步的優化呢?我們可以使用一個256位的陣列來替代hashmap,以進行優化。(因為ASCII碼錶裡的字元總共有128個。ASCII碼的長度是一個位元組,8位,理論上可以表示256個字元,但是許多時候只談128個。具體原因可以下去自行學習~)

//java
class Solution {
    public int lengthOfLongestSubstring(String s) {
        int len = s.length();
        int result = 0;
        int[] charIndex = new int[256];
        for (int left = 0, right = 0; right < len; right++) {
            char c = s.charAt(right);
            left = Math.max(charIndex[c], left);
            result = Math.max(result, right - left + 1);
            charIndex[c] = right + 1;
        }
        return result;
    }
}

【演算法】滑動視窗三步走

我們發現優化後時間複雜度有了極大的改善!這裡簡單說一下原因,對於陣列和hashmap訪問時,兩個誰快誰慢不是一定的,需要思考hashmap的底層實現,以及資料量大小。但是在這裡,因為已知了待訪問資料的下標,可以直接定址,所以極大的縮短了查詢時間。

囉囉嗦嗦
本題基本就到這裡。最後要說的,一般建議如果要分析一道題,我們要壓縮壓縮再壓縮,抽繭剝絲一樣走到最後,儘可能的完成對題目的優化。不一定非要自己想到最優解,但絕對不要侷限於單純的完成題目,那樣將毫無意義!

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

之前的兩節講解了滑動視窗類問題的模式解法,相信大家對該類題型已不陌生。今天將繼續完成一道題目,來進行鞏固學習。

第438題:給定一個字串 s 和一個非空字串 p,找到 s 中所有是 p 的字母異位詞的子串,返回這些子串的起始索引。
字串只包含小寫英文字母,並且字串 s 和 p 的長度都不超過 20100。

說明:

字母異位詞指字母相同,但排列不同的字串。

不考慮答案輸出的順序。

示例 1:

輸入:

s: "cbaebabacd" p: "abc"

輸出:

[0, 6]

解釋:

起始索引等於 0 的子串是 "cba", 它是 "abc" 的字母異位詞。

起始索引等於 6 的子串是 "bac", 它是 "abc" 的字母異位詞。

示例 2:

輸入:

s: "abab" p: "ab"

輸出:

[0, 1, 2]

解釋:

起始索引等於 0 的子串是 "ab", 它是 "ab" 的字母異位詞。

起始索引等於 1 的子串是 "ba", 它是 "ab" 的字母異位詞。

起始索引等於 2 的子串是 "ab", 它是 "ab" 的字母異位詞。

答案

直接套用之前的模式,使用雙指標來模擬一個滑動視窗進行解題。分析過程如下:

假設我們有字串為“cbaebabacd”,目標串為“abc”

我們通過雙指標維護一個視窗,由於我們只需要判斷字母異位詞,我們可以將視窗初始化大小和目標串保持一致。(當然,你也可以初始化視窗為1,逐步擴大)

【演算法】滑動視窗三步走

而判斷字母異位詞,我們需要保證視窗中的字母出現次數與目標串中的字母出現次數一致。這裡因為字母只有26個,直接使用陣列來替代map進行儲存(和上一講中的ASCII使用256陣列儲存思想一致)。

pArr為目標串陣列,sArr為視窗陣列。我們發現初始化陣列,本身就滿足,記錄下來。(這裡圖示用map模擬陣列,便於理解)

【演算法】滑動視窗三步走

然後我們通過移動視窗,來更新視窗陣列,進而和目標陣列匹配,匹配成功進行記錄。每一次視窗移動,左指標前移,原來左指標位置處的數值減1,表示字母移出;同時右指標前移,右指標位置處的數值加1,表示字母移入。詳細過程如下:

【演算法】滑動視窗三步走

【演算法】滑動視窗三步走

最終,當右指標到達邊界,意味著匹配完成。

程式碼展示

根據分析,完成程式碼:(下面pSize相關的忽略,除錯忘刪了)

class Solution {

    public List<Integer> findAnagrams(String s, String p) {

        if (s == null || p == null || s.length() < p.length()) return new ArrayList<>();

        List<Integer> list = new ArrayList<>();

        int[] pArr = new int[26];
        int pSize = p.length();
        int[] sArr = new int[26];

        for (int i = 0; i < p.length(); i++) {
            sArr[s.charAt(i) - 'a']++;  
            pArr[p.charAt(i) - 'a']++;
        }

        for (int i = 0; i < p.length(); i++) {
            int index = p.charAt(i) - 'a';
            if (pArr[index] == sArr[index]) 
                pSize--;
        }

        int i = 0;
        int j = p.length();

        // 視窗大小固定為p的長度
        while (j < s.length()) {
            if (isSame(pArr, sArr))
                list.add(i);            
            //sArr[s.charAt(i) - 'a']-- 左指標位置處字母減1
            sArr[s.charAt(i) - 'a']--;
            i++;
            //sArr[s.charAt(j) - 'a']++ 右指標位置處字母加1
            sArr[s.charAt(j) - 'a']++;
            j++;
        }

        if (isSame( pArr, sArr))
            list.add(i);

        return list;
    }

    public boolean isSame(int[] arr1, int[] arr2) {
        for (int i = 0; i < arr1.length; ++i)
            if (arr1[i] != arr2[i])
                return false;
        return true;
    }
}

【演算法】滑動視窗三步走

我的:
答案一:使用map,超時

class Solution {
    public List<Integer> findAnagrams(String s, String p) {

        // 固定視窗大小為p.length() - 1
        int left = 0;
        int right = p.length();

        List<Integer> list = new ArrayList<>();

        Map<Character, Integer> mapP = new HashMap<>();
        Map<Character, Integer> mapS = new HashMap<>();


        // 將p的所有字元放入雜湊表
        for (int i = 0; i < p.length(); i++) {

            char c = p.charAt(i);
            int count = mapP.getOrDefault(c, 0) + 1;

            mapP.put(c, count);
        }

        while (right <= s.length()) {

            // 從left到right,遍歷視窗
            for (int i = left; i < right; i++) {

                char c = s.charAt(i);
                int count = mapS.getOrDefault(c, 0) + 1;

                mapS.put(c, count);
            }

            if (mapP.equals(mapS)) {
                list.add(left);
            }

            // 無論如何都要前進
            left++;
            right++;
            
            // 清理一下mapS,便於下個視窗存入
            mapS.clear();
        }
        return list;
    }
}

答案二:上面的演算法面對超長超大的字串會超時,所以我們把雜湊表換成了自己寫的

// 上面的演算法面對超長超大的字串會超時,所以我們把雜湊表換成了自己寫的
class Solution {
    public List<Integer> findAnagrams(String s, String p) {

        int left = 0;
        int right = p.length();
        
        int[] mapP = new int[26];   // p的雜湊表
        int[] mapS = new int[26];   // s的雜湊表

        List<Integer> list = new ArrayList<>();


        // 將p的所有字元放入雜湊表
        for (int i = 0; i < p.length(); i++) {
            mapP[p.charAt(i) - 'a']++;
        }

        // 由於前閉後開,所以right得等於s.length()才算遍歷完了所有
        while (right <= s.length()) {

            mapS = new int[26];

            for (int i = left; i < right; i++) {
                mapS[s.charAt(i) - 'a']++;
            }

            if (isSame(mapP, mapS)) {
                list.add(left);
            }

            left++;
            right++;
        }
        return list;
    }

    public boolean isSame(int[] mapP, int[] mapS) {
        for (int i = 0; i < mapP.length; i++) {
            if (mapP[i] != mapS[i]) {
                return false;
            }
        }
        return true;
    }
}

239. 滑動視窗最大值(困難可不做)

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

給定一個陣列 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

題目分析

本題對於題目沒有太多需要額外說明的,應該都能理解,直接進行分析。我們很容易想到,可以通過遍歷所有的滑動視窗,找到每一個視窗的最大值,來進行暴力求解。那一共有多少個滑動視窗呢,小學題目,可以得到共有 L-k+1 個視窗。

假設 nums = [1,3,-1,-3,5,3,6,7],和 k = 3,視窗數為6
【演算法】滑動視窗三步走

根據分析,直接完成程式碼:

//java
class Solution {
    public int[] maxSlidingWindow(int[] nums, int k) {
        int len = nums.length;
        if (len * k == 0) return new int[0];
        int [] win = new int[len - k + 1];
        //遍歷所有的滑動視窗
        for (int i = 0; i < len - k + 1; i++) {
            int max = Integer.MIN_VALUE;
            //找到每一個滑動視窗的最大值
            for(int j = i; j < i + k; j++) {
                max = Math.max(max, nums[j]);
            }
            win[i] = max;
        }
        return win;
    }
}

【演算法】滑動視窗三步走
It's Bullshit!結果令我們很不滿意,時間複雜度達到了O(LK),如果面試問到這道題,基本上只寫出這樣的程式碼,一定就掛掉了。那我們怎麼樣優化時間複雜度呢?有沒有可以O(L)的實現呢?=_=

線性題解

這裡不賣關子,其實這道題比較經典,我們可以採用佇列,DP,堆等方式進行求解,所有思路的主要源頭應該都是在視窗滑動的過程中,如何更快的完成查詢最大值的過程。但是最典型的解法還是使用雙端佇列。具體怎麼來求解,一起看一下。

首先,我們瞭解一下,什麼是雙端佇列:是一種具有佇列和棧的性質的資料結構。雙端佇列中的元素可以從兩端彈出或者插入。
【演算法】滑動視窗三步走

我們可以利用雙端佇列來實現一個視窗,目的是讓該視窗可以做到張弛有度(漢語博大精深,也就是長度動態變化。其實用遊標或者其他解法的目的都是一樣的,就是去維護一個可變長的視窗)

然後我們再做一件事,只要遍歷該陣列,同時在雙端佇列的頭去維護當前視窗的最大值(在遍歷過程中,發現當前元素比佇列中的元素大,就將原來佇列中的元素祭天),在整個遍歷的過程中我們再記錄下每一個視窗的最大值到結果陣列中。最終結果陣列就是我們想要的,整體圖解如下。

假設 nums = [1,3,-1,-3,5,3,6,7],和 k = 3

【演算法】滑動視窗三步走

(個人認為我畫的這個圖是目前全網對於雙端佇列本題解法比較清晰的一個...所以我覺得如果不點個讚的話...晤..)

根據分析,得出程式碼:

//go
func maxSlidingWindow(nums []int, k int) []int {
    if len(nums) == 0 {
        return []int{}
    }
    //用切片模擬一個雙端佇列
    queue := []int{}
    result := []int{}
    for i := range nums {
        for i > 0 && (len(queue) > 0) && nums[i] > queue[len(queue)-1] {
            //將比當前元素小的元素祭天
            queue = queue[:len(queue)-1]
        }
        //將當前元素放入queue中
        queue = append(queue, nums[i])
        if i >= k && nums[i-k] == queue[0] {
            //維護佇列,保證其頭元素為當前視窗最大值
            queue = queue[1:]
        }
        if i >= k-1 {
            //放入結果陣列
            result = append(result, queue[0])
        }
    }
    return result
}

【演算法】滑動視窗三步走
Perfact~題目完成!看著一下子超越百分之99的使用者,是不是感覺很爽呢~

//Java
class Solution {
    public int[] maxSlidingWindow(int[] nums, int k) {
        if(nums == null || nums.length < 2) return nums;
        // 雙向佇列 儲存當前視窗最大值的陣列位置 保證佇列中陣列位置的數值按從大到小排序
        LinkedList<Integer> queue = new LinkedList();
        // 結果陣列
        int[] result = new int[nums.length-k+1];
        // 遍歷nums陣列
        for(int i = 0;i < nums.length;i++){
            // 保證從大到小 如果前面數小則需要依次彈出,直至滿足要求
            while(!queue.isEmpty() && nums[queue.peekLast()] <= nums[i]){
                queue.pollLast();
            }
            // 新增當前值對應的陣列下標
            queue.addLast(i);
            // 判斷當前佇列中隊首的值是否有效
            if(queue.peek() <= i-k){
                queue.poll();   
            } 
            // 當視窗長度為k時 儲存當前視窗中最大值
            if(i+1 >= k){
                result[i+1-k] = nums[queue.peek()];
            }
        }
        return result;
    }
}

相關文章