動畫:一道 K Sum 面試題引發的血案

動畫:一道 K Sum 面試題引發的血案

每當我遇到一個難道,腦子裏下意識出現的第一個想法就是幹掉它。將複雜問題簡單化,簡單到不能再簡單的地步,也是我文章一直追求的一點。

K Sum 求和問題這種題型套娃題,備受面試官喜愛,通過層層拷問,來考察你對事物的觀察能力和解決能力,這似乎成爲了每個面試官的習慣和套路。打敗對手,首先要了解你的對手。

看似複雜的東西,背後其實就是簡單的原理和機制,宇宙萬物存在的事物亦是如此。深入複雜問題內部,去看它簡單的運行邏輯。將繁雜嵌套事物轉化爲可用簡單動畫表現的事物。這是一個由繁變簡的過程,簡單,簡而不單,又單而不簡。

誘餌:2Sum 兩數之和

捕魚,先要學會佈網,看似一個簡單題目,其實作爲誘餌,引申出背後的終極 Boss。

拋出誘餌:

給定一個整數數組 nums 和一個目標值 target,請你在該數組中找出和爲目標值的那 兩個 整數,並返回他們的數組下標。

示例:
給定 nums = [2, 7, 11, 15], target = 9
因爲 nums[0] + nums[1] = 2 + 7 = 9
所以返回 [0, 1]

大腦最先下意識想到的是,遍歷所有數據,找出滿足條件的這兩個值,俗稱暴力破解法。

解法一:暴力破解法

讓目標值減去其中一個值,拿着差去數組中查找匹配是否存在,如果存在則返回下標。

動畫:一道 K Sum 面試題引發的血案


/**
 * 解法一:暴力破解
 * @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。

動畫:一道 K Sum 面試題引發的血案


/**
 * 解法二:兩遍哈希表法
 * @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)。

解法三:哈希表優化

對於以上哈希表,我們需要一遍先去存儲值和索引的映射,如果我們在遍歷查找的時候存儲,不是可以節省這個步驟嗎?

如果我們查找目標值的時候,在哈希表中查找,如果能夠找到,就返回該值的下標,如果找不到,則將改值的映射加入到哈希表中,這樣一邊就完成查找數據和添加數據。

動畫:一道 K Sum 面試題引發的血案


/**
 * 解法三:哈希表法優化
 * @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² 還要長,所以你想着用哈希表做優化。

1、解法一:哈希表

先用兩層 for 循環,固定兩個數,然後在用哈希表去找第三個數,直到找到爲止。

動畫:一道 K Sum 面試題引發的血案


/**
 * 解法一:哈希表優化
 * @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 的內存空間存儲哈希表,不僅時間效率還是空間效率,都是不樂觀的。

此時的你,陷入了深思...

2、解法二:排序 + 雙指針

以往的面試者到這裏基本被淘汰掉了,剩下的爲有經驗的應聘者,他會根據以往的做題經驗總結的來優化本題。

如果我先固定一個數,另外兩個數我要懂得變通,如果三者和小於目標值,我再讓另外兩個數其中之一換一個大點的。如果三者之和大於目標值,我就讓兩個數其中一個換一個小點的。

對於換個大點的或者小的數據,一個數據要有階梯的層次,就必須進行排序,排序最好的時間複雜度爲 O(nlogn)。

我們用兩個指針,分別指向最大值和最小值,固定其中一個數,讓這個固定的數和其餘兩個指針指向的數三者相加,如果小於目標值,就讓指向最小值的數右移,變的大一些,否則,指向最大值的指針左移,指向的數稍微小一些。
動畫:一道 K Sum 面試題引發的血案


/**
 * 解法二:排序 + 雙指針(去重)
 * @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 時,無論左右指針如何移動,找不到任何滿足條件的值。

動畫:一道 K Sum 面試題引發的血案


// 如果固定的數爲正整數,不可能存在爲 0 情況
if (nums[i] > 0) break;

num[i] = num[i - 1] 當前值和前一個值重複,尋找的值也會重複,所有跳過。

動畫:一道 K Sum 面試題引發的血案


// 去重(如果下一個固定數和前一個相等,後邊會出現重複結果)
if (i > 0 && nums[i] == nums[i - 1]) continue;

sum = 0 時,左右指針移動也會存在重複的值。

nums[L] = nums[L + 1],讓 L++ 繼續尋找下一個匹配的值。

動畫:一道 K Sum 面試題引發的血案


 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 求和難不到你,運用同樣的思路,先固定兩個數,然後還是運用雙指針求另外兩個滿足條件的數字。

動畫:一道 K 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;

針對特殊情況優化的執行結果對比如下:

動畫:一道 K Sum 面試題引發的血案

小結

通過對這個面試題深入的分析和總結,收穫很多,不僅是對本題的收穫,更多的是對所有算法題的一個概括。

鹿哥,你這不只是分析了一個算法題嗎?你咋就對其他題目也有收穫呢?

題不在於刷多,刷更多的題是爲了熟悉更多的題型和練習自己對題目的敏感度或者可以說總結出算法題的一些套路。

這個題它本身就可以所有算法題從繁雜到簡化的一個過程,跑不出空間與時間的轉化,也跑不出對一些邊界條件的思考,以後無論做什麼算法題,都跑不出這兩樣東西。

我們雖然看它表面在變化,但是它的實質並沒有變化,任何事物都由最簡單的事物構成,所謂的複雜,只是你把它想象的過於複雜。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章