LeetCode #003# Longest Substring Without Repeating Characters(js描述)
索引
問題描述:https://leetcode.com/problems/longest-substring-without-repeating-characters/
思路1:分治策略
感覺兩下半寫完了。。沒啥收穫,就別出心載先寫了個分治版本,結果很悲催。
按照演算法導論上的分治演算法框架,將原問題一分為二得到兩個形式完全相同的子問題,然後遞迴地解決子問題得到兩個子問題的最優解,最後合併兩個子問題的最優解得到原問題的最優解。“分解”步驟的時間複雜度是O(1),“解決”以及“合併”步驟的時間複雜度(似乎)是O(n^3),整體為O(n^3lgn),當然僅限我寫的版本。。。哈哈。。下面是用js寫的程式碼:
class CommonUtil { static lightCopy(obj) { let result = {} Object.assign(result, obj);return result; } } // O(???), Runtime: 7792 ms, faster than 1.00% ... var lengthOfLongestSubstring = function(s) { if (s == "") return 0; let buildMaps = (symbol, p, q, r) => { let map = [], length = 0; let current = () => q - length; if (symbol == "right") { current= () => q - 1 + length; } let init = () => { // 建立一個空物件,免去繁瑣的初始化過程 map[length++] = {}; }; let isSafe = () => { // 越界or字元重複返回false let out = symbol == "right" ? current() >= r : current() < p; let repeat = map[length - 1][s[current()]]; return !out && !repeat; }; let createMap = () => { // 複製map>>新增元素>>為下次呼叫做準備 map[length] = CommonUtil.lightCopy(map[length - 1]); map[length][s[current()]] = true; length++; }; init(); while (isSafe()) { createMap(); } return map; }; let combineMaps = (leftMaps, rightMaps, max) => { let compatible = (map1, map2) => { if (map1.length < map2.length) { let temp = map1; map1 = map2; map2 = temp; } let keys = Object.keys(map2); return !keys.some(x => map1[x]); }; let getPairs = (totalReduce) => { let reducePairs = []; for (let k = 0; k <= totalReduce; ++k) { reducePairs.push({ i: k, j: totalReduce - k, }); } return reducePairs; }; const leftLen = leftMaps.length - 1, rightLen = rightMaps.length - 1, baseLen = leftLen + rightLen; let sum = 0; let totalReduce = 0; while ((sum = baseLen - totalReduce) > max) { // 只檢測比子問題解的值更大的情形,由大往小找,一旦找到立刻停止搜尋 let reducePairs = getPairs(totalReduce); for (let k = 0; k != reducePairs.length; ++k) { let i = leftLen - reducePairs[k].i; let j = rightLen - reducePairs[k].j; if (i > 0 && j > 0 && compatible(leftMaps[i], rightMaps[j])) { return sum; } } totalReduce++; } return max; }; let findMaxCrossingSubstring = (p, q, r, subMaxLength) => { if (s[q - 1] == s[q]) { // 不可連線 return subMaxLength; } else { let leftMaps = buildMaps("left", p, q, r); let rightMaps = buildMaps("right", p, q, r); return combineMaps(leftMaps, rightMaps, subMaxLength); } }; let findMax = (start, end) => { if (start + 1 < end) { let mid = Math.floor((start + end) / 2); // Divide let maxLeft = findMax(start, mid); // Conquer let maxRight = findMax(mid, end); // Conquer const subMaxLength = Math.max(maxLeft, maxRight); // Combine return findMaxCrossingSubstring(start, mid, end, subMaxLength); // Combine } else { return 1; // basecase } }; return findMax(0, s.length); };
思路2:Brute Force - O(n^3)
js程式碼如下:
let satisfyCondition = (i, j, s) => { let map = {}; for (let k = i; k <= j; ++k) { if (map[s[k]]) return false; map[s[k]] = true; } return true; }; // O(n^3), Runtime: 880 ms, faster than 6.67% ... var lengthOfLongestSubstring2 = function(s) { if (s == "") return 0; let max = 1, length, i, j; for (i = 0; i != s.length; ++i) { for (j = i + 1; j != s.length; ++j) { length = j - i + 1; if (length > max) { if (satisfyCondition(i, j, s)) { max = length; } else { break; } } } } return max; };
思路3:動態規劃
因為最開始思路有點離譜,在這裡詳細記錄下寫了n個版本的心路歷程(從420ms~388ms~100ms)以待以後反思。。。。
O(n^2)版,錯解之一:420 ms
縮寫一個概念,便於之後描述↓
相容:當前字元與“由上一個字元開始的逆向最長子串”相容意味著上一個逆向最長子串裡不包含該字元,言下之意,當前字元可以直接插入上一個子串構成一個新解。
但凡最優化問題都很容易想到dp:問題顯然滿足最優子結構,規模為n的問題依賴於形式相同、但規模為n-1的子問題。遞迴地定義最優解的值:Mi = max(Mi-1, 新解new的值),然後考慮怎麼計算得到new的值,最簡單的想法是從當前字元倒回去算一遍,然而這樣太耗費時間,仔細想想,計算new的值其實和new-1是相關聯的,如果,當前字元與new-1相容,那麼直接在new-1的值的基礎上+1就好了,不相容才需要另行計算。
原始思路:完全依靠一個動態的hashMap的map[key]操作判斷當前字元與上一個最長子串的相容性。(每一個這樣的“上一個子串”都對應一個全新、特定的hashMap)
初代js程式碼如下:
var lengthOfLongestSubstring3 = function(s) { let m = [], // 儲存最優解的值,以長度作為下標 b = [], // 儲存長度為i時,new的值 p, r, map = {}; let init = () => { b[0] = 0; m[0] = 0; }; let work = () => { for (let len = 1; len <= s.length; ++len) { r = len - 1; // 引入r:將子問題規模len轉化為當前字元的下標 if ((p = map[s[r]]) == undefined) { // 相容直接+1 map[s[r]] = r; b[len] = b[len - 1] + 1; } else { // 不相容重新計算 map = {}; for (let i = p + 1; i <= r; ++i) { map[s[i]] = i; } b[len] = r - p; } m[len] = Math.max(m[len - 1], b[len]); } }; init(); work(); return m[s.length]; };
因為規模為n的問題實際僅依賴於規模為n-1的子問題,所以只需儲存上一輪迭代的相關值就行了。這是另外一個無差別版本(可讀性極差):
var lengthOfLongestSubstring4 = function(s) { let m = 0, b = 0; let p, r, map = {}; for (let len = 1; len <= s.length; ++len) { r = len - 1; if ((p = map[s[r]]) == undefined) { // 可能為0 map[s[r]] = r; b = b + 1; } else { map = {}; for (let i = p + 1; i <= r; ++i) { map[s[i]] = i; } b = r - p; } m = Math.max(m, b); } return m; };View Code
O(n^2)版,錯解之二:388 ms
對建立新map做了點“然並卵”的優化(可讀性極差,其實這個時候我入了一個“縮短變數名可以提高几ms”的新坑,這是錯的!!!):
var lengthOfLongestSubstring5 = function(s) { let m = 0, b = 0, map = {}, p = 0, q, r; for (let len = 1; len <= s.length; ++len) { r = len - 1; if (s[r] in map) { q = map[s[r]]; if (q - p + 1 >= r - q - 1) { for (let i = p; i <= q; ++i) { delete map[s[i]]; } } else { map = {}; for (let i = q + 1; i < r; ++i) { map[s[i]] = i; } } map[s[r]] = r; b = r - q; p = q + 1; } else { map[s[r]] = r; b = b + 1; } m = Math.max(m, b); } return m; };
O(n)版,思路轉變: 100 ms
before:
- 判定相容性:僅用map[key]判斷
- 維護hashMap:相容新增鍵值對,不相容重新建立hashMap並新增相應的鍵值對
- 在如何高效地動態修改hashMap上鑽牛角尖
after:
- 判定相容性:map[key]判斷+利用map[key]裡儲存的下標,同時維護“上一個最長子串的起始下標”構造一個簡單的判定條件
- 維護hashMap:相容新增鍵值對,不相容更新相應鍵的值就可以了(其實兩個都一樣)
- 通過新增條件而不是修改hashMap,簡單地篩掉hashMap的無效命中!
程式碼如下:
var lengthOfLongestSubstring6 = function(s) { let m = 0, lastIndex = {}, lastBegin = 0, currentIndex; for (let len = 1; len <= s.length; ++len) { currentIndex = len - 1; let ch = s[currentIndex]; if (lastIndex[ch] !== undefined && lastIndex[ch] >= lastBegin) { // 不相容 lastBegin = lastIndex[ch] + 1; } lastIndex[ch] = currentIndex; m = Math.max(m, currentIndex - lastBegin + 1); } return m; };
現在看來這個思路很簡單、自然,為啥當時就想不到呢??歸根結底是經驗不足,但還是想總結下如何儘量避免“在錯誤的路上鑽牛角尖”以及“明明答案就在眼前,然而就是看不見”:
- 在專注某個問題的時候,偶爾要跳出來(從整體上)整理一下思路
- 轉換角度
- 看別人的思路
- 多喝熱水。。
細節上的優化(Javascript限定)
借鑑了TOP1的神仙程式碼,終於拿到了“Runtime: 76 ms, faster than 99.37%”的成就:
var lengthOfLongestSubstring7 = function(s) { if (s.length < 2) return s.length; let max = 0, lastBegin = 0, code, characterIndex = new Array(255), fresh; for (let i = 0; i !== s.length; ++i) { code = s.charCodeAt(i); if (characterIndex[code] >= lastBegin) { lastBegin = characterIndex[code] + 1; } fresh = i - lastBegin + 1; if (fresh > max) { max = fresh; } characterIndex[code] = i; } return max; };
js程式碼效能優化的小結:
- 歧途之一:縮短變數名絕對是最得不償失的做法!大大降低可讀性的同時,對效能的提升幾乎為0(在網路上傳輸有專門的精簡程式碼工具,刷演算法題完全沒必要)
- 歧途之二:將函式手動內聯同樣不能提升效能(約等於0)
- 優化一:用if語句取代m = Math.max(m, xxxx);當m>xxxx成立時,採用if語句可以省去一次不必要的賦值。
- 優化二:當陣列長度能確定在某個範圍時用new Array(size)取代[]
- 優化三:當打算用HashMap的時候,以數字作為鍵的陣列>obj>new Map()
一些坑點&語法:
- 儘量不要用類似if(變數)作為條件判斷,因為有時0可能是變數的可行值之一,但是會被轉化成false
- 儘量用===取代==,避免轉型出錯,似乎對效能還有微乎其微的幫助。。。。
- 顯式地對null、undefined、NaN等值進行判斷,可以減少一些莫名其妙的bug
- undefined與任何數比較都返回false