尋找符合條件的最短子字符串——SLIDING WINDOW

簡介

用一個可伸縮的窗口遍歷字符串,時間複雜度大體爲O(n)。適用於「尋找符合某條件的最小子字符串」題型。java

題目

連接算法

求某字符串T中含有某字符串S的全部字符的最小子字符串。若是不存在則返回"".數組

算法

用左右兩個指針維護一個窗口。框架

  1. 將右指針右移,直至窗口知足條件,包含S中全部字符。
  2. 將左指針左移,直至窗口再也不知足條件。此過程當中每移動一次,都更新最小子字符串。
  3. 重複一、2兩步。

WHY IT WORKS

設想一個最naive的算法如何遍歷T中的全部子字符串。以T中的每個字符爲子字符串的起始字符,從1開始,增長子字符串的長度直至觸及T的尾字符,這樣就是遍歷了T中的全部子字符串。ide

好比字符串「ABCD」,以'A'開頭的子字符串有"A", "AB", "ABC", "ABCD";以'B'開頭的有"B", "BC", "BCD";以'C'開頭的有"C", "CD";以"D"開頭的有"D"。這樣遍歷的時間複雜度是O(n^2)。性能

咱們把目光集中於起始字符,看看滑動窗口的效用。測試

滑動窗口算法中的第一步立足於某字符x,至關於以x爲起始字符,尋找知足條件的子字符串。因爲題中要求最短的子字符串,因此一旦知足條件就可停下,沒必要再往下尋找,至關於節省了一部分算力。優化

 

假設第一步中找到的子字符串以某字符y結束,且x至y這個子字符串的長度爲m。則遍歷到如今爲止,找到的子字符串答案的長度<=m。(假設x以前還有其餘元素,則一、2步已重複過數輪)ui

在第二步中,經過移動左指針對窗口進行收縮。假設左指針到達元素z時,窗口再也不知足條件。則在左指針移動的過程當中,以(x,z)開區間內的元素做爲起始字符,y爲結束字符進行了遍歷。spa

將結束字符固定在y處是對naive解法的重要優化,蘊含了滑動窗口算法能夠正確找出答案的主要數學原理:

對x、z之間的某一元素t,t爲起始字符且知足條件的最小子字符串必在y處結束

證實:窗口收縮在z左側,保證了t至y的字符串知足條件;設t至y不是最小的子字符串,則存在由t開始至字符r的的字符串知足條件,且r在y左側,那麼x至r的字符串也必知足條件,與第一步中獲得的結論矛盾,故得證。

由於這個原理,x和z之間的元素只靠窗口左邊界收縮就獲得了遍歷。時間複雜度由平方變成了線性。

在第二步中,[x, z)區間內的元素爲起始字符的全部子字符串獲得遍歷。下一輪次的第一步會z爲起始字符進行尋找。如此往復,隨着窗口交替伸展和收縮,全部的可能性(即以全部元素做爲起始字符的子字符串)都會獲得遍歷。

IMPLEMENTATION

以上分析肯定了滑動窗口算法的大體框架。至於如何記錄窗口的狀態、判斷窗口是否知足條件,題目中挖了一個小坑。

乍一看,彷佛能夠用HashSet保存T中的字符(且稱爲重要字符),用來查看T中是否存在某字符。用另外一個HashSet記錄窗口中出現的重要字符,並用一個counter記錄窗口中重要字符的個數,若與T的長度相等則認爲符合條件。看起來完美無缺,但若是T中存在重複字符,如"AABCC",則該方法再也不有效。

可對該方法作一個小改進使之能夠符合題意:用HashMap來保存重要字符及出現的次數。若是T爲"AABCC",則保存爲[A--2, B--1, C--2]。另用一個HashMap記錄窗口中的重要字符及數量,用counter記錄窗口中達到次數的不重複的重要字符數。如A出現2次則counter可加1,B出現1次counter便可加1,同理,C必須出現2次counter纔可加1。經過將counter的值與第一個HashMap的size對比來判斷窗口是否知足條件。

寫代碼時,若以句爲單元進行思考則寫起來費時且易出錯,特別是邊界條件上的錯誤。一個比較靠譜的方法是先寫一個大體框架,而後將細節填入。只要框架合理,代碼通常錯不了。

先用註釋勾勒出大體框架。(能夠看成流程圖看,重要的是那兩個while內部的安排)

public String minWindow(String s, String t) {
    //建立HashMap1,將t中字符及出現次數存入
    //初始化窗口、窗口的HashMap二、counter
    //建立minLength記錄最小字符串的長度;建立result保存當前找到的最小字符串
    
    while(/*窗口右端未超出s*/) {
        //記錄右邊界所指的元素到HashMap2
        //若該元素次數知足條件,++counter
        
        //若窗口知足條件則讓左邊界慢慢收縮,不然跳過這個while,繼續伸展右邊界
        while(/*counter == HashMap2.size()*/) {
            //若窗口長度小於minLength, 更新minLength、result
            
            //因爲要收縮左邊界,將HashMap2中記錄的左邊界元素減1
            //如左邊界元素次數再也不知足條件,--counter            
            l++; //收縮左邊界
        }
        r++; //伸展右邊界
    }
    return result;
}

若是理解了以上框架便不難填入細節,細節實如今下面,供參考。(注:這是一個正確的解法,但並非最優的解法,見優化一節)

 1 public String minWindow(String s, String t) {
 2     if(s == null || t== null || t.length() == 0 || s.length() == 0)
 3         return s;
 4     
 5     //建立HashMap1
 6     HashMap<Character, Integer> required = new HashMap<>();
 7     //初始化窗口、窗口的HashMap二、counter
 8     HashMap<Character, Integer> contained = new HashMap<>();
 9     int l = 0, r = 0, counter = 0;
10     //建立minLength記錄最小字符串的長度;建立result保存當前找到的最小字符串
11     int minLength = Integer.MAX_VALUE;
12     String result = "";
13 
14     //將t中字符及出現次數存入
15     for(int i = 0; i < t.length(); i++) {
16         int count = required.getOrDefault(t.charAt(i), 0);
17         required.put(t.charAt(i), count + 1);    
18     }
19     
20     while(r < s.length()/*窗口右端未超出s*/) {
21         char current = s.charAt(r);
22         if(required.containsKey(current)){
23             //記錄右邊界所指的元素到HashMap2
24             int count = contained.getOrDefault(current, 0);
25             contained.put(current, count + 1);
26             //若該元素次數知足條件,++counter
27             if(contained.get(current).intValue() == required.get(current).intValue())
28                 ++counter;
29         }
30         
31         //若窗口知足條件則讓左邊界慢慢收縮,不然跳過這個while,繼續伸展右邊界
32         while(counter == required.size()/*counter == HashMap2.size()*/) {
33             //若窗口長度小於minLength, 更新minLength、result
34             if(r - l + 1 < minLength) {
35                 result = s.substring(l, r + 1);
36                 minLength = r - l + 1;
37             }
38             char toDelete = s.charAt(l);
39             if(required.containsKey(toDelete)) {
40                 //因爲要收縮左邊界,將HashMap2中記錄的左邊界元素減1
41                 contained.put(toDelete, contained.get(toDelete) - 1);
42                 //如左邊界元素次數再也不知足條件,--counter
43                 if(contained.get(toDelete).intValue() == required.get(toDelete).intValue() - 1)
44                     --counter;
45             }
46             l++; //收縮左邊界
47         }
48         r++; //伸展右邊界
49     }
50     return result;
51 }
算法實現

注意在27及43行,比較Integer的值時,必須用.intValue()進行比較,不然比較的是Integer對象的地址。當Integer對象的值較小時,對象存在常量池中,用contained.get(current) == required.get(current)直接比較不會出錯。但Integer值比較大從而沒法放入常量池時會出錯,致使counter永遠不被更新,錯誤地返回空字符串。

複雜度

空間上用了兩個HashMap,複雜度爲O(n + m),n和m分別爲s和t的長度。

時間上,滑動窗口算法自己含有左右兩個指針,這兩個指針都只向右移動,最差的狀況是每一個元素都被兩個指針各遍歷一遍,因此滑動窗口的時間爲2n。因爲還要對t進行遍從來記錄其中的字符,因此總的時間複雜度爲O(n + m)

優化

在leetcode使用的代碼引擎中,上述實現的執行時間爲33ms,在全部的java實現中僅排名77%。

最優實現爲2ms,很是簡潔,抄錄以下

 1 class Solution {
 2     public String minWindow(String s, String t) {
 3         int[] map = new int[128];
 4         for (char c : t.toCharArray())
 5             map[c]++;
 6         int counter = t.length(), begin = 0, end = 0, distance = Integer.MAX_VALUE, head = 0;
 7         while (end < s.length()) {
 8             if (map[s.charAt(end++)]-- > 0)
 9                 counter--;
10             while (counter == 0) { // valid
11                 if (end - begin < distance)
12                     distance = end - (head = begin);
13                 if (map[s.charAt(begin++)]++ == 0)
14                     counter++; // make it invalid
15             }
16         }
17         return distance == Integer.MAX_VALUE ? "" : s.substring(head, head + distance);
18     }
19 }

大體框架跟上面的實現差很少,優化點以下:

  1. 用數組而非HashMap存取字符,由於不須要算哈希值及在桶中遍歷元素,性能有所提高
  2. 直接在原數組的基礎上作減法,這樣就不須要第二個HashMap,免去了containsKey()等方法的調用,簡潔又高效
  3. 每次更新子字符串時,不求出字符串的值,只記錄head和distance,從而substring()方法只在最後調用一次

另一個優化的思路是先遍歷一遍s,記錄其中全部重要元素的位置,而後l和r只在這些位置上進行移動。因爲仍然須要遍歷,時間複雜度仍然是O(n + m),只是滑動窗口自己的複雜度被減少了。這種方法在leetcode的test case進行測試對性能的提高結果不明顯,大概在秒級。比較適用於s中重要元素的個數遠小於s的長度的狀況,即t的長度相對比較短,且s中含有許多t中沒有的元素。

相關文章
相關標籤/搜索