每當我遇到一個難道,腦子裏下意識出現的第一個想法就是幹掉它。將複雜問題簡單化,簡單到不能再簡單的地步,也是我文章一直追求的一點。面試
K Sum 求和問題這種題型套娃題,備受面試官喜好,經過層層拷問,來考察你對事物的觀察能力和解決能力,這彷佛成爲了每一個面試官的習慣和套路。戰勝對手,首先要了解你的對手。算法
看似複雜的東西,背後其實就是簡單的原理和機制,宇宙萬物存在的事物亦是如此。深刻複雜問題內部,去看它簡單的運行邏輯。將繁雜嵌套事物轉化爲可用簡單動畫表現的事物。這是一個由繁變簡的過程,簡單,簡而不單,又單而不簡。數組
誘餌:2Sum 兩數之和ide
捕魚,先要學會佈網,看似一個簡單題目,其實做爲誘餌,引伸出背後的終極 Boss。性能
拋出誘餌:優化
給定一個整數數組 nums 和一個目標值 target,請你在該數組中找出和爲目標值的那 兩個 整數,並返回他們的數組下標。動畫
示例: 給定 nums = [2, 7, 11, 15], target = 9 由於 nums[0] + nums[1] = 2 + 7 = 9 因此返回 [0, 1]
大腦最早下意識想到的是,遍歷全部數據,找出知足條件的這兩個值,俗稱暴力破解法。設計
解法一:暴力破解法指針
讓目標值減去其中一個值,拿着差去數組中查找匹配是否存在,若是存在則返回下標。code
/** * 解法一:暴力破解 * @param {number[]} nums * @param {number} target * @return {number[]} */ var twoSum = function(nums, target) { // 判斷數組爲空的狀況 if (nums == null || nums.length == 1) { return []; } for (let i = 0; i < nums.length; i++) { let item = nums[i]; if (nums.indexOf(target - item, i + 1) !== -1) { return [i, nums.indexOf(target - item, i + 1)]; } } return []; };
最壞的狀況,須要兩層 for 循環遍歷全部狀況。時間複雜度爲 O(n²)。在這個過程當中,只須要常量大小的額外內存空間,空間複雜度爲 O(1)。
上述解法,耗費時間太多,可是宇宙萬物任何存在對立的兩種事物都是能夠互相轉化,時間和空間也是如此。
解法二:哈希表
由於咱們在遍歷的時候,用 target 取數據的時候,須要再遍歷一遍數組,這才致使了耗費時間過長。咱們把這部分時間轉化爲空間,用空間換時間,能將空間換時間的非哈希表莫屬。
數組原本就存在映射,經過下標取出對應值,可是咱們此次是經過 target 值減去其中一個值,獲得另外一個值,經過這個值得出下標確不能。
因此須要讓數組的值和下標索引作一層映射,若是已知值,能夠經過哈希映射獲得下標索引 index。
/** * 解法二:兩遍哈希表法 * @param {number[]} nums * @param {number} target * @return {number[]} */ var twoSum2 = function(nums, target) { // 將值存儲到哈希表中 let map = new Map(); // 存儲 nums.forEach((item,index) => { map.set(item, index); }); // 判斷 for (let i = 0; i < nums.length; i++) { let item = nums[i]; console.log(map.get(target - item)) if (map.has(target - item) && map.get(target - item) !== i) { return [i, map.get(target - item)]; } } return []; };
藉助哈希表,空間換時間,時間效率下降了一個維度,時間複雜度爲 O(n)。空間須要 n 大小的額外內存空間開闢哈希表的空間大小,空間複雜度爲 O(n)。
解法三:哈希表優化
對於以上哈希表,咱們須要一遍先去存儲值和索引的映射,若是咱們在遍歷查找的時候存儲,不是能夠節省這個步驟嗎?
若是咱們查找目標值的時候,在哈希表中查找,若是可以找到,就返回該值的下標,若是找不到,則將改值的映射加入到哈希表中,這樣一邊就完成查找數據和添加數據。
/** * 解法三:哈希表法優化 * @param {number[]} nums * @param {number} target * @return {number[]} */ var twoSum3 = function(nums, target) { // 將值存儲到哈希表中 let map = new Map(); // 存儲 for (let i = 0; i < nums.length; i++) { let item = nums[i]; if (map.has(target - item) && map.get(target - item) !== i) { return [map.get(target - item), i]; } map.set(item, i); } return []; };
上鉤:3Sum 三數之和
此時,咱們的解答和優化受到面試官的表揚,你認爲這完美的解題思路能夠拿到 offer 的時候,但卻這只是個熱身,由於你已經上鉤了。
上鉤誘導:
給你一個包含 n 個整數的數組 nums,判斷 nums 中是否存在三個元素 a,b,c ,使得 a + b + c = 0 ?請你找出全部知足條件且不重複的三元組。
例如:
給定數組 nums = [-1, 0, 1, 2, -1, -4], 知足要求的三元組集合爲: [ [-1, 0, 1], [-1, -1, 2] ]
此次從兩個數升級到了三個數,此時你內心的感覺是既高興又擔憂。咱們有了上一題的解題優化思路,你想着這道題會不會是一樣的思路呢?
暴力破解三層 for 循環當然能解,可是確定耗費時間比 n² 還要長,因此你想着用哈希表作優化。
一、解法一:哈希表
先用兩層 for 循環,固定兩個數,而後在用哈希表去找第三個數,直到找到爲止。
/** * 解法一:哈希表優化 * @param {number[]} nums * @return {number[][]} */ var threeSum = function(nums) { var res = []; var map = new Map(); for (let i = 0; i < nums.length - 2; i++) { for (let j = i + 1; j < nums.length - 1; j++) { let item = 0 - nums[i] - nums[j]; if (map.has(item)) { res.push([item, nums[i], nums[j]]); } else { map.set(item, 1); } } } return res; };
可是此次,並非空間換時間,由於兩層 for 循環,致使了時間複雜度爲 O(n²),並且空間上須要額外大小爲 n 的內存空間存儲哈希表,不只時間效率仍是空間效率,都是不樂觀的。
此時的你,陷入了深思...
二、解法二:排序 + 雙指針
以往的面試者到這裏基本被淘汰掉了,剩下的爲有經驗的應聘者,他會根據以往的作題經驗總結的來優化本題。
若是我先固定一個數,另外兩個數我要懂得變通,若是三者和小於目標值,我再讓另外兩個數其中之一換一個大點的。若是三者之和大於目標值,我就讓兩個數其中一個換一個小點的。
對於換個大點的或者小的數據,一個數據要有階梯的層次,就必須進行排序,排序最好的時間複雜度爲 O(nlogn)。
咱們用兩個指針,分別指向最大值和最小值,固定其中一個數,讓這個固定的數和其他兩個指針指向的數三者相加,若是小於目標值,就讓指向最小值的數右移,變的大一些,不然,指向最大值的指針左移,指向的數稍微小一些。
/** * 解法二:排序 + 雙指針(去重) * @param {number[]} nums * @return {number[][]} */ var threeSum = function(nums) { var res = []; var len = nums.length; // 判斷特殊狀況 if (nums == null || len < 3) return res; nums.sort((a, b) => a - b); // 從小到大排序 for (let i = 0; i < len; i++) { // 若是固定的數爲正整數,不可能存在爲 0 狀況 if (nums[i] > 0) break; // 去重(若是下一個固定數和前一個相等,後邊會出現重複結果) if (i > 0 && nums[i] == nums[i - 1]) continue; // 定義左右指針 let L = i + 1; let R = len - 1; while (L < R) { // 結束遍歷條件 let sum = nums[i] + nums[L] + nums[R]; if (sum == 0) { // 去重 res.push([nums[i], nums[L], nums[R]]); while (L < R && nums[L] == nums[L + 1]) L++; while (L < R && nums[R] == nums[R - 1]) R--; L++; R--; } else if (sum < 0) { L++; } else if (sum > 0) { R--; } } return res; } };
若是你實踐了,發現以前的解法也是行不通的,爲啥?由於沒有去重,好比[-1,0,1,-1]。其中[-1, 0, 1]、[0, 1, -1]結果都會讓值等於目標值 0,可是這兩個結果重複了。
要想作到去重,咱們就要找到去重的規律。我分爲如下幾個點:
num[i] > 0 時,不管左右指針如何移動,找不到任何知足條件的值。
// 若是固定的數爲正整數,不可能存在爲 0 狀況 if (nums[i] > 0) break;
num[i] = num[i - 1] 當前值和前一個值重複,尋找的值也會重複,全部跳過。
// 去重(若是下一個固定數和前一個相等,後邊會出現重複結果) if (i > 0 && nums[i] == nums[i - 1]) continue;
sum = 0 時,左右指針移動也會存在重複的值。
nums[L] = nums[L + 1],讓 L++ 繼續尋找下一個匹配的值。
while (L < R && nums[L] == nums[L + 1]) L++;
nums[R] = nums[R - 1],讓 R-- 繼續尋找下一個匹配的值。
和以上同理!
while (L < R && nums[R] == nums[R - 1]) R--;
經過上邊對各個查重邊界條件的判斷,最後的結果不會有重複數據了。
在空間上,不須要空間大小爲 n 的內存空間,空間複雜度降到 O(1)。
你覺得完事了,其實尚未,這纔到了中期,也是在有經驗的應聘者中篩選,面試官想要在最後的應聘者中再進行篩選,確定還要進一步考察你對本題的思考。
再來:4 Sum 四數求和
三數之和求解巧妙的排序設計和雙指針的運用,已經讓咱們對齊有些心有餘力而力不足。
繼續升級到 4 Sum 四數求和問題,若是有了以上的思路,4 Sum 求和難不到你,運用一樣的思路,先固定兩個數,而後仍是運用雙指針求另外兩個知足條件的數字。
/** * @param {number[]} nums * @param {number} target * @return {number[][]} */ var fourSum = function(nums, target) { var res = []; var len = nums.length; if (nums == null || len < 4) return res; nums.sort((a, b) => a - b); for (let i = 0; i < len - 3; i++) { // 去重(若是下一個固定數和前一個相等,後邊會出現重複結果) if (i > 0 && nums[i] == nums[i - 1]) continue; //計算當前的最小值,若是最小值都比target大,不用再繼續計算了 if (nums[i] + nums[i + 1] + nums[i + 2] + nums[i + 3] > target) break; //計算當前最大值,若是最大值都比target小,不用再繼續計算了 if (nums[i] + nums[len - 1] + nums[len - 2] + nums[len - 3] < target) continue; // 肯定第二個指針的位置 for (let j = i + 1; j < len - 2; j++) { // 去重 if (j > i + 1 && nums[j] == nums[j - 1]) continue; // 定義第三/四個指針 let L = j + 1; let R = len - 1; //計算當前的最小值,若是最小值都比target大,不用再繼續計算了 let min = nums[i] + nums[j] + nums[L] + nums[L + 1]; if (min > target) continue; //計算當前最大值,若是最大值都比target小,不用再繼續計算了 let max = nums[i] + nums[j] + nums[R] + nums[R - 1]; if (max < target) continue; while (L < R) { let sum = nums[i] + nums[j] + nums[L] + nums[R]; if (sum == target) { res.push([nums[i], nums[j], nums[L], nums[R]]); } if (sum < target) { while (nums[L] === nums[++L]); } else { while (nums[R] === nums[--R]); } } } } return res; };
一樣,對於 4 sum 四數求和的性能,藉助排序 + 雙指針方法並無使得效率和空間變壞,因此一樣適用。
可是惟一不一樣的就是一些特殊的邊界條件變化,好比:
//計算當前的最小值,若是最小值都比target大,不用再繼續計算了 if (nums[i] + nums[i + 1] + nums[i + 2] + nums[i + 3] > target) break; //計算當前最大值,若是最大值都比target小,不用再繼續計算了 if (nums[i] + nums[len - 1] + nums[len - 2] + nums[len - 3] < target)
Boss:K Sum K 數求和
從 2Sum,上升到 3Sum,而後到了 4Sum,最後能夠歸結爲 KSum 問題。
解題的關鍵不只僅是利用排序和雙指針問題,並且對於幾個特殊狀況的優化,對總體d代碼的執行效率也有很大關係。
在 4Sum 求和中,我嘗試着增長了對幾個特殊狀況判斷,如:
//計算當前的最小值,若是最小值都比target大,不用再繼續計算了 let min = nums[i] + nums[j] + nums[L] + nums[L + 1]; if (min > target) continue; //計算當前最大值,若是最大值都比target小,不用再繼續計算了 let max = nums[i] + nums[j] + nums[R] + nums[R - 1]; if (max < target) continue;
針對特殊狀況優化的執行結果對好比下:
小結
經過對這個面試題深刻的分析和總結,收穫不少,不只是對本題的收穫,更多的是對全部算法題的一個歸納。
鹿哥,你這不僅是分析了一個算法題嗎?你咋就對其餘題目也有收穫呢?
題不在於刷多,刷更多的題是爲了熟悉更多的題型和練習本身對題目的敏感度或者能夠說總結出算法題的一些套路。
這個題它自己就能夠全部算法題從繁雜到簡化的一個過程,跑不出空間與時間的轉化,也跑不出對一些邊界條件的思考,之後不管作什麼算法題,都跑不出這兩樣東西。
咱們雖然看它表面在變化,可是它的實質並無變化,任何事物都由最簡單的事物構成,所謂的複雜,只是你把它想象的過於複雜。