滑動視窗法——Leetcode例題

IamQisir發表於2022-03-31

滑動視窗法——Leetcode例題(連更未完結)

1. 方法簡介

滑動視窗法可以理解為一種特殊的雙指標法,通常用來解決陣列和字串連續幾個元素滿足特殊性質問題(對於字串來說就是子串)。滑動視窗法的顯著特徵是:兩個指標同方向運動,且往往要對視窗內每個元素都加以處理。

滑動視窗法(以鄙人目前的程度)來看,大概可以分為兩類:

  1. 視窗的長度已知,此時雙指標可以用一個指標和視窗長度常數來表示。
  2. 視窗長度未知,往往對視窗內的元素都加以處理。

滑動視窗法實際上可以理解為是對暴力破解的優化,很多問題複雜度很高的暴力破解可以被簡化為O(n)級別。根據目前我的經驗來看,滑動視窗法的主要思路為如何減少視窗兩端指標的重複變化,也就是兩指標同向移動、不回溯,為了實現這個目標很多時候需要使用一些例如雜湊表、雜湊集合、佇列等資料結構。


2. 解決字串問題

2.1 LC——3 無重複最長子串

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

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

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

示例 3:
輸入: s = "pwwkew"
輸出: 3
解釋: 因為無重複字元的最長子串是 "wke",所以其長度為 3。
     請注意,你的答案必須是 子串 的長度,"pwke" 是一個子序列,不是子串。
提示:
0 <= s.length <= 5 * 104
s 由英文字母、數字、符號和空格組成

原題連結

2.1.1 問題分析

題目要求我們求出一個給定字串的無重複最長子串。對於新手來說,最容易想到的應該是暴力演算法,暴力演算法是對每個字元開頭的子串都加以遍歷,直到出現重複元素為止,使用變數maxLength記錄到目前為止最長的無重複子串長,遍歷結束後輸出maxLength即可。如果採用暴力演算法時間複雜度(\(O(n^2)\))會很高,恐怕無法通過用例測試。

可以試著從暴力演算法的缺點入手來優化它,暴力演算法很麻煩的地方在於重複比較次數過多,以上面的示例一舉例說明:

當指標start指向開頭元素a時,end指標從0開始依次遍歷,發現end = 3時,發生元素重複。那麼就把start增加1,end指向start,重新開始,在這裡b和c又比較了一次。可以預見,當每次start自增後,有大量的元素進行了重複比較。除此之外,每次檢查是否有重複元素時也十分耗時(尤其是當被檢查子串很長時)。

如何才能減少重複的比較呢?

首先我們來理解一下當出現重複元素時,如何移動指標才能減少無效比較次數。如下圖所示,當f元素出現重複時,若採用暴力解法,start指標需要自增到b,然後end指標回溯,繼續比較直到出現重複。可是我們可以發現,其實f重複了,以第一個f之前的元素作為start指標的位置都是不必要的,因為f總會再引起它們重複。所以,只需把start放到f的下一個位置就好,而且這樣end指標無需回溯,極大地提高了效率。

qRspyn.png

那麼如何才能能實現這個操作呢?我們將雜湊表儲存的鍵值對更改為:<字元,字元發生重複時start指標應到達的位置>,事實上,我們通過上面的例子已經清楚了,start指標只需變化為當前子串內第一個重複字元的右側即可。所以雜湊表儲存的鍵值對為<字元,位序>。

2.2.2 程式碼示例

class Solution {
    public int lengthOfLongestSubstring(String s) {
        Map<Character, Integer> map = new HashMap<>();
        int start = 0, end = 0; 
        int ans = 0;
        while (end < s.length()) {
            if (map.containsKey(s.charAt(end)))
                start = Math.max(start, map.get(s.charAt(end))); // 要十分注意需要用max函式保證start指標不後退
            map.put(s.charAt(end), end + 1);
            ans = Math.max(ans, end - start + 1);
            end++;
        }
        return ans;
    }
}

程式碼分析

指標start和指標end同向移動且不回溯,時間複雜度為O(n),相比於暴力解法是很大的提升。

2.2.3 總結

本題使用兩個指標是比較顯然的,但是如何利用HashMap解決暴力解法的指標頻繁回溯問題是關鍵之處。這也是雜湊表重要的應用體現。另外強烈推薦畫手大鵬的動態示例,很有助於理解哦。


2.2 LC——219 存在重複元素Ⅱ

給你一個整數陣列 nums 和一個整數 k ,判斷陣列中是否存在兩個 不同的索引 i 和 j ,滿足 nums[i] == nums[j] 且 abs(i - j) <= k 。如果存在,返回 true ;否則,返回 false 。

示例 1:
輸入:nums = [1,2,3,1], k = 3
輸出:true

示例 2:
輸入:nums = [1,0,1,1], k = 1
輸出:true

示例 3:
輸入:nums = [1,2,3,1,2,3], k = 2
輸出:false
提示:
1 <= nums.length <= 105
-109 <= nums[i] <= 109
0 <= k <= 105

原題連結

2.2.1 問題分析

這個問題顯然可以使用滑動視窗法,而且視窗的長度即為k + 1。我們只需要設定一個集合(使用HashSet),用來記錄遍歷過的元素。在新增新元素之前,先判斷集合內是否有相同的元素,如果有,則返回真。否則,就將其存入集合中。除此之外,還要判斷新增新元素後集合的大小是否超出k (為什麼不是k + 1呢?這是因為我們先判斷是否有重複,若已有k個元素,且第k + 1個元素無重複,那麼顯然就不行,直接刪除目前集合內最舊的元素),如果超出k則從集合中刪除。

2.2.2 程式碼示例

暴力解法

class Solution {
    public boolean containsNearbyDuplicate(int[] nums, int k) {
        Map<Integer, Integer> map = new HashMap<>();
        if (k >= nums.length)
            k = nums.length - 1;
        for (int i = k; i < nums.length; i++) {
            for (int j = i - k; j <= i; j++) {
                if (map.containsKey(nums[j]))
                    return true;
                map.put(nums[j], j);
            }
            map = new HashMap<>(); 
        }
        return false;
    }
}
// 這種做法無法通過示例的時間複雜度測試

暴力解法甚至無法通過Leetcode的全部測試用例。

滑動視窗法

class Solution {
     public boolean containsNearbyDuplicate(int[] nums, int k) {
         Set<Integer> set = new HashSet<>();
         for (int i = 0; i < nums.length; i++) {
             if (set.contains(nums[i]))
                 return true;
            set.add(nums[i]);
            if (set.size() > k)
                set.remove(nums[i - k]);
         }
         return false;
    }
}

2.3 LC——643 子陣列最大平均數Ⅰ

給你一個由 n 個元素組成的整數陣列 nums 和一個整數 k 。

請你找出平均數最大且 長度為 k 的連續子陣列,並輸出該最大平均數。

任何誤差小於 \(10^{-5}\)的答案都將被視為正確答案。

示例 1:
輸入:nums = [1,12,-5,-6,50,3], k = 4
輸出:12.75
解釋:最大平均數 (12-5-6+50)/4 = 51/4 = 12.75

示例 2:
輸入:nums = [5], k = 1
輸出:5.00000
提示:
n == nums.length
1 <= k <= n <= 105
-104 <= nums[i] <= 104

原題連結

2.3.1 問題分析

這道題跟219題看起來十分相似,其也可以滑動視窗法。連續子陣列長度固定,要求平均數最大的連續子陣列也就是求元素和最大的連續子陣列。最簡單的思路是暴力解法,選定一個指標i,其取值範圍為[k - 1,n - 1],在選定i的情況下選定指標j,其取值範圍為[i - k, i],利用j對子陣列元素求和,求出所有子陣列的元素和後即可得到最大者。

暴力解法的時間複雜度為\(O(n^2)\),而且像上一道例題一樣,做了大量的、重複的計算。當i為k - 1時(第一次計算),可以求出前k個元素組成連續子陣列的元素和,當i為k時(第二次計算),我們得到第2個元素到第k個元素組成的連續子陣列元素和,但是中間k - 2個元素的和我們計算了兩次。該如何解決呢?

可以採用滑動視窗的思想解決,也就是我們先計算前k個元素和,然後利用其值計算第二個陣列元素和,見下面推導:

\[\begin{align} sum_1 &= a_0 + a_1+...+a_{k-1} \\ sum_2 &= a_1 + a_2 + ... + a_{k-1} + a_{k} \\ &= sum_1 - a_0 + a_{k}\\ sum_i &= sum_{i-1} - a_{i-k} + a_i \end{align} \]

利用該公式,計算新的元素和只需在上一個元素和上微調一下即可。

2.3.2 程式碼示例

暴力解法

class Solution {
    public double findMaxAverage(int[] nums, int k) {
        int maxSum = 0;
        int nowSum = 0;
        for (int i = 0; i < nums.length - k + 1; i++) {
            for (int j = i; j < k + i; j++)
                nowSum += nums[j];
            if (i == 0)
                maxSum = nowSum;
            maxSum = Math.max(maxSum, nowSum);
            nowSum = 0;
        }
        return (double)maxSum / k;
    }
}

滑動視窗法

class Solution {
    public double findMaxAverage(int[] nums, int k) {
        int maxSum;
        int subSum = 0;
        for (int i = 0; i < k; i++) {
            subSum += nums[i];
        }
        maxSum = subSum;
        for (int i = k; i < nums.length; i++) {
            subSum = subSum + nums[i] - nums[i - k];
            maxSum = Math.max(subSum, maxSum);
        }
        return (double)maxSum / k;
    }
}

相關文章