Find the length of the longest substring T of a given string (consists of lowercase letters only) such that every character in T appears no less than k times.
Example 1:
Input: s = "aaabb", k = 3 Output: 3 The longest substring is "aaa", as 'a' is repeated 3 times.
Example 2:
Input: s = "ababbc", k = 2 Output: 5 The longest substring is "ababb", as 'a' is repeated 2 times and 'b' is repeated 3 times.
這道題給了我們一個字符串s和一個正整數k,讓求一個最大子字符串並且每個字符必須至少出現k次。作為 LeetCode 第三次編程比賽的壓軸題目,博主再一次沒有做出來,雖然難度標識只是 Medium。后來在網上膜拜學習了大神們的解法,發現當時的沒做出來的原因主要是卡在了如何快速的判斷某一個字符串是否所有的字符都已經滿足了至少出現k次這個條件,雖然博主也用 HashMap 建立了字符和其出現次數之間的映射,但是如果每一次都要遍歷 HashMap 中的所有字符看其出現次數是否大於等於k,未免有些不高效。而用 mask 就很好的解決了這個問題,由於字母只有 26 個,而整型 mask 有 32 位,足夠用了,每一位代表一個字母,如果為1,表示該字母不夠k次,如果為0就表示已經出現了k次,這種思路真是太聰明了,隱約記得這種用法在之前的題目中也用過,但是博主並不能舉一反三( 沮喪臉:( ),還得繼續努力啊。遍歷字符串,對於每一個字符,都將其視為起點,然后遍歷到末尾,增加 HashMap 中字母的出現次數,如果其小於k,將 mask 的對應位改為1,如果大於等於k,將 mask 對應位改為0。然后看 mask 是否為0,是的話就更新 res 結果,然后把當前滿足要求的子字符串的起始位置j保存到 max_idx 中,等內層循環結束后,將外層循環變量i賦值為 max_idx+1,繼續循環直至結束,參見代碼如下:
解法一:
class Solution { public: int longestSubstring(string s, int k) { int res = 0, i = 0, n = s.size(); while (i + k <= n) { int m[26] = {0}, mask = 0, max_idx = i; for (int j = i; j < n; ++j) { int t = s[j] - 'a'; ++m[t]; if (m[t] < k) mask |= (1 << t); else mask &= (~(1 << t)); if (mask == 0) { res = max(res, j - i + 1); max_idx = j; } } i = max_idx + 1; } return res; } };
雖然上面的方法很機智的使用了 mask 了標記某個子串的字母是否都超過了k,但仍然不是很高效,因為遍歷了所有的子串,使得時間復雜度到達了平方級。來想想如何進行優化,因為題目中限定了字符串中只有字母,這意味着最多不同的字母數只有 26 個,最后滿足題意的子串中的不同字母數一定是在 [1, 26] 的范圍,這樣就可以遍歷這個范圍,每次只找不同字母個數為 cnt,且每個字母至少重復k次的子串,來更新最終結果 res。這里讓 cnt 從1遍歷到 26,對於每個 cnt,都新建一個大小為 26 的數組 charCnt 來記錄每個字母的出現次數,使用的思想其實還是滑動窗口 Sliding Window,使用兩個變量 start 和 i 來分別標記窗口的左右邊界,當右邊界小於n時,進行 while 循環,需要一個變量 valid 來表示當前子串是否滿足題意,初始化為 true,還需要一個變量 uniqueCnt 來記錄子串中不同字母的個數。此時若 s[i] 這個字母在 charCnt 中的出現次數為0,說明遇到新字母了,uniqueCnt 自增1,同時把該字母的映射值加1。此時由於 uniqueCnt 變大了,有可能會超過之前限定了 cnt,所以這里用一個 while 循環,條件是當 uniqueCnt 大於 cnt ,此時應該收縮滑動窗口的左邊界,那么對應的左邊界上的字母的映射值要自減1,若減完后為0了,則 uniqueCnt 自減1,注意這里一會后加,一會先減的操作,不要搞暈了。當 uniqueCnt 沒超過 cnt 的時候,此時還要看當前窗口中的每個字母的出現次數是否都大於等於k,遇到小於k的字母,則直接 valid 標記為 false 即可。最終若 valid 還是 true,則表示滑動窗口內的字符串是符合題意的,用其長度來更新結果 res 即可,參見代碼如下:
解法二:
class Solution { public: int longestSubstring(string s, int k) { int res = 0, n = s.size(); for (int cnt = 1; cnt <= 26; ++cnt) { int start = 0, i = 0, uniqueCnt = 0; vector<int> charCnt(26); while (i < n) { bool valid = true; if (charCnt[s[i++] - 'a']++ == 0) ++uniqueCnt; while (uniqueCnt > cnt) { if (--charCnt[s[start++] - 'a'] == 0) --uniqueCnt; } for (int j = 0; j < 26; ++j) { if (charCnt[j] > 0 && charCnt[j] < k) valid = false; } if (valid) res = max(res, i - start); } } return res; } };
下面這種解法用的分治法 Divide and Conquer 的思想,看起來簡潔了不少,但是個人感覺比較難想,這里使用了一個變量 max_idx,是用來分割子串的,實現開始統計好了字符串s的每個字母出現的次數,然后再次遍歷每個字母,若當前字母的出現次數小於k了,則從開頭到前一個字母的范圍內的子串可能是滿足題意的,還需要對前面的子串進一步調用遞歸,用返回值來更新當前結果 res,此時變量 ok 標記為 false,表示當前整個字符串s是不符合題意的,因為有字母出現次數小於k,此時 max_idx 更新為 i+1,表示再從新的位置開始找下一個出現次數小於k的字母的位置,可以對新的范圍的子串繼續調用遞歸。當 for 循環結束后,若 ok 是 true,說明整個s串都是符合題意的,直接返回n,否則要對 [max_idx, n-1] 范圍內的子串再次調用遞歸,因為這個區間的子串也可能是符合題意的,還是用返回值跟結果 res 比較,誰大就返回誰,參見代碼如下:
解法三:
class Solution { public: int longestSubstring(string s, int k) { int n = s.size(), max_idx = 0, res = 0; int m[128] = {0}; bool ok = true; for (char c : s) ++m[c]; for (int i = 0; i < n; ++i) { if (m[s[i]] < k) { res = max(res, longestSubstring(s.substr(max_idx, i - max_idx), k)); ok = false; max_idx = i + 1; } } return ok ? n : max(res, longestSubstring(s.substr(max_idx, n - max_idx), k)); } };
Github 同步地址:
https://github.com/grandyang/leetcode/issues/395
類似題目:
Longest Substring with At Most K Distinct Characters
Longest Substring with At Most Two Distinct Characters
Longest Substring Without Repeating Characters
參考資料:
https://leetcode.com/problems/longest-substring-with-at-least-k-repeating-characters/