[LeetCode] 讓 Two Pointers 解法更快的技巧 #16 3Sum Closest

上一篇我們用數學分析讓 3Sum 快 2 倍,這題也有辦法用數學加快嗎? 當然可以。

為什麼 Two Pointers 可以這樣用?

在數學分析前我們還是要來先了解一下為什麼 two pointers 會有用,如果只知道用卻不知道為什麼可以這樣用,面試時簡單問一句就會被考倒。

答案很簡單,如果我們要找比較小的數字,移動錯指標的話完全沒有幫助:


同理如果我們要找更大的數字,移動指到比較大的數字的指標也沒有幫助:


這其實和 LeetCode #11 的移動原理非常像。

什麼時候根本不用跑 Two Pointers?

如果第一個數字 A 固定的話,A+B+C 最小和最大的數值會是一開始跑和準備結束的時候:


如果我們想要找 A+B+C ≈ 300,中間的結果一定介於 [60, 180] 之間,我們幹嘛去跑 two pointers?

我們其實只要算一下最小和最大的可能性就可以決定要不要用 two pointers。

如果之後 A 固定住,我們要找 B+C ≈ 200,同理我們也可以先算最小和最大值來決定要不要跳過:


像這個例子 LeetCode 第二個解答就用 binary search,那我們知道範圍的話連跑都不用跑。

最後一個要提的陷阱就是我們可能會以為 wow 這樣做是不是 O(N) 因為我們可以直接找到 A 在哪裡,然後就去找 B+C 也是 O(N)。

但其實不是,因為我們可能會發現要找的目標可能會在很多範圍內:


而且我們也沒辦法事前知道我們要跑多少個 range,有可能完全沒有,也有可能全部都要跑。所以這解法只是優化過後的 O(N^2) 演算法。

優化: 暴力解

用 3 個 for loop 傻傻地去試所有組合的話結果就會很慢 (C++ code on GitHub):


但如果我們套用上面的優化之後結果就會快 2 倍 (C++ code on GitHub):


優化: Two Pointers 解

優化 two pointers 和沒有優化的版本其實只差了 8 行而已,優化的版本會長這樣子:


class Solution {
 public:
  int threeSumClosest(std::vector<int> &nums, int target) {
    std::sort(nums.begin(), nums.end());

    const int sz = (int)nums.size();
    for (int i = 0; i < sz - 2; i++) {
      const int lowestSum = nums[i] + nums[i + 1] + nums[i + 2];
      const int highestSum = nums[i] + nums[sz - 2] + nums[sz - 1];
      if (lowestSum <= target && target <= highestSum) {
        twoSumClosest(nums, target, i + 1);
      } else {
        checkCloser(nums, target, lowestSum);
        checkCloser(nums, target, highestSum);
      }
    }
    return m_closestSum;
  }

  void twoSumClosest(std::vector<int> &nums, const int target, int left) {
    const int num1 = nums[left - 1];
    int right = (int)nums.size() - 1;
    while (left < right) {
      const int sum = num1 + nums[left] + nums[right];
      checkCloser(nums, target, sum);
      if (sum < target) {
        left++;
      } else {
        right--;
      }
    }
  }

  void checkCloser(const std::vector<int> &nums, const int target,
                   const int sum) {
    const int diff = std::abs(sum - target);
    if (diff < m_minDiff) {
      m_closestSum = sum;
      m_minDiff = diff;
    }
  }

  int m_closestSum = 0;
  int m_minDiff = 99999;
};

優化的部分只有 8-15 行,其他都是原本 two pointers 的作法。

作法也很簡單,我們先算算看兩邊的端點,目標有在範圍裡面就去跑 two pointers 吧,沒有在範圍裡面我們就看看兩邊的端點是不是比較靠近目標。

有快很多?

沒優化的版本花了 12ms:


優化過後的版本花了 8ms:


那些寫到 0ms 的人就會覺得嗤之以鼻,什麼嘛也才快 4ms 有什麼好說的。但是這題目的 constraints 實在太小,所以看不出差異,如果我們把資料量 scale up 會發生什麼事呢?

真的快很多

於是我就寫了一個 benchmark 的程式碼,把題目敘述的 constraints 慢慢 scale up 2 倍、4 倍、8 倍... 比較看看兩個版本的速度。

結果資料量越大的話差異果然就很明顯:


當然這速度會根據資料的狀況有所差異,運氣好甚至 scale=16 的時候優化的版本竟然只花了 13ms,沒優化的慢了 67 倍 (數據資料)!有多誇張。

所以說這個優化可能在現實 project 上會比較有幫助。

進階優化

看了 code 你可能會發現雖然我們上面那樣講,但是其實還有兩個優化我們卻沒有做:

  1. 找 A+B+C 的 range 其實可以用 binary search 找到第一個符合資格的 range 和最後一個,因為他們就像 Google Maps 的 schedule explorer 功能一樣
  2. 我們 code 沒有做 B+C 先檢查 endpoints
第一點我沒做是因為我偷懶,我不想把 code 變亂,留給大家實驗。第二點是因為我做完發現並沒有明顯的差異,所以也是為了 code 簡潔就不寫了。反正大家知道概念就好。

結語

其實在寫 3Sum 題目之前我還真的從來沒碰過 two pointers,所以上一篇 3Sum 我就有點排斥用 two pointers 來寫,不過反而找到更快的解法

仔細看了一下才發現原來 LeetCode #11 我早就破解過 two pointers 了。

這次數學又得了一分,寫程式要不要學數學? 我想是肯定的,畢竟數學可以幫助你設計更好更快的演算法。

留言

此網誌的熱門文章

[試算表] 追蹤台股 Google Spreadsheet (未實現損益/已實現損益)

[Side Project] 互動式教學神經網路反向傳播 Interactive Computational Graph

[插件] 在 Chrome 網頁做區分大小寫的搜尋