前端學數據結構與算法(十二):有趣的算法 - 多指針與滑動窗口

前言

若是說如何用算法高效有趣的解決某些問題,那多指針和滑動算法絕對是算其中的佼佼者。這也是筆者最初接觸算法時以爲最有意思的一點,由於解決的問題是熟悉的,但配方卻徹底不一樣,本章咱們從一個簡單的交集問題出發,一步步的認識到多指針及滑動窗口解決某些問題時的巧妙與高效,本章主要以解LeetCode裏高頻題爲參考~面試

多指針

349 - 兩個數組的交集 ↓

給定兩個數組,編寫一個函數來計算它們的交集。

輸入:nums1 = [1,2,2,1], nums2 = [2,2]
輸出:[2]

輸入:nums1 = [4,9,5], nums2 = [9,4,9,8,4]
輸出:[9,4]

來源:力扣(LeetCode)
連接:https://leetcode-cn.com/problems/intersection-of-two-arrays

暴力解:算法

將兩個數組共有的元素放入一個數組進行去重便可,去重須要使用set,那直接存入set完事。代碼以下:api

解法1:

var intersection = function (nums1, nums2) {
  const set = new Set()
  nums1.forEach(num => {
    if (nums2.includes(num)) {
      set.add(num)
    }
  })
  return [...set]
};

如下簡單假設兩個數組的長度都是n,該解法屬於暴力解,須要兩重的循環,includes須要在數組裏查找,因此內部也是遍歷,最終的複雜度是O(n²),有沒有更高效的解法?數組

二分查找:函數

固然有,由於是查找問題,咱們能夠對兩個數組分別排序,而後運用上一章咱們學習到的二分查找法進行查找,替換includes的查找,那麼最終的解法咱們能優化到O(nlogn)級別,代碼以下:學習

解法2:

var intersection = function (nums1, nums2) {
  nums1.sort((a, b) => a - b)
  nums2.sort((a, b) => a - b)
  const set = new Set()
  nums1.forEach(num => {
    if (binarySearch(nums2, num)) {
      set.add(num)
    }
  })
  return [...set]
};

function binarySearch(arr, target) { // 二分查找
  let l = 0;
  let r = arr.length
  while (r > l) {
    const mid = l + (r - l) / 2 | 0
    if (arr[mid] === target) {
      return true
    } else if (arr[mid] > target) {
      r = mid
    } else {
      l = mid + 1
    }
  }
  return false
}

首先對數據進行預處理,最終的代碼行數比方法1多了很多,不過總的複雜度是3O(nlogn),比O(n²)快很多,還有更高效的解法麼?優化

雙指針:指針

固然,還可使用一種雙指針的解法,首先仍是對兩個數組進行排序,而後使用兩個指針分別指着兩個數組的開頭,誰的數值小誰向後滑動,遇到相同的元素就放入set內,直至兩個數組中有一個到頭爲止。代碼以下:code

解法3:

var intersection = function (nums1, nums2) {
  nums1.sort((a, b) => a - b)
  nums2.sort((a, b) => a - b)
  let i = 0;
  let j = 0;
  const set = new Set()
  while (i < nums1.length && j < nums2.length) { //有一個到頭結束循環
    if (nums1[i] === nums2[j]) { // 找到了交集
      set.add(nums1[i]) // 放入set內
      i++
      j++
    } else if (nums1[i] > nums2[j]) { // 誰數值小,+1 移動
      j++
    } else {
      i++
    }
  }
  return [...set]
};

整個複雜度須要對兩個數組快排,而後雙指針要走完兩個數組,最終的複雜度是O(nlogn) + O(nlogn) + 2n,雖然總的複雜度仍是O(nlogn),不過效率會優於二分查找。blog

167 - 兩數之和 II - 輸入有序數組 ↓

給定一個已按照升序排列的有序數組,找到兩個數使得它們相加之和等於目標數。
函數應該返回這兩個下標值 index1 和 index2,其中 index1 必須小於 index2。

說明:
返回的下標值(index1 和 index2)不是從零開始的。
你能夠假設每一個輸入只對應惟一的答案,並且你不能夠重複使用相同的元素。

輸入: numbers = [2, 7, 11, 15], target = 9
輸出: [1,2]
解釋: 2 與 7 之和等於目標數 9 。所以 index1 = 1, index2 = 2。

來源:力扣(LeetCode)
連接:https://leetcode-cn.com/problems/two-sum-ii-input-array-is-sorted

很天然的能想到暴力解,兩層循環遍歷,最終的複雜度是O(n²),但這不是咱們想到的。

很顯然暴力解並無利用到題目描述裏的升序排列這個特性,而最終的結果是須要查找的,因此咱們很天然能想到使用二分查找法。這確實也是一種更快的解題思路,能將最終的複雜度下降到O(nlogn)級別,你們能夠嘗試解決。

這裏提供一種更巧妙的解題思路,對撞指針。咱們能夠設置頭尾兩個指針,每一次將它們的和與目標進行比較,若是比目標值大,尾指針向中間移動,減小它們相加的和;反之它們的和若是比目標值小則把頭指針向中間移動,增長它們相加的和。由於是有序的數組,因此不用擔憂移動的過程當中錯過了目標值。代碼以下:

var twoSum = function (numbers, target) {
  let l = 0 // 左指針
  let r = numbers.length - 1 // 右指針
  while (r > l) { // 不能 r >= l,由於不能使用同一個值兩次
    if (numbers[r] + numbers[l] === target) {
      return [l + 1, r + 1]
    } else if (numbers[r] + numbers[l] > target) {
      r-- // 右指針向中間移動
    } else {
      l++ // 左指針向中間移動
    }
  }
  return []
};

11 - 盛最多水的容器 ↓

給你 n 個非負整數 a1,a2,...,an,每一個數表明座標中的一個點 (i, ai) 。
在座標內畫 n 條垂直線,垂直線 i 的兩個端點分別爲 (i, ai) 和 (i, 0) 。
找出其中的兩條線,使得它們與 x 軸共同構成的容器能夠容納最多的水。

示例:
輸入:[1,8,6,2,5,4,8,3,7]
輸出:49 
解釋:圖中垂直線表明輸入數組 [1,8,6,2,5,4,8,3,7]。
在此狀況下,容器可以容納水(表示爲藍色部分)的最大值爲 49。

來源:力扣(LeetCode)
連接:https://leetcode-cn.com/problems/container-with-most-water

初看這題可能很懵逼,或者就是使用兩層循環的暴力解,求出每種可能,找裏裏面最大值,面試官對這個解法確定不會滿意。

而這道經典題目,咱們一樣可使用對撞指針解法,首先設置首尾兩個指針,依次向中間靠近,但這題麻煩的地方在於兩個指針之間誰動誰不動的問題。

通過觀察不難發現,就是指針所指向的值,誰的數值小,誰就須要移動。由於若是數值大的指針向中間移動,小的那個值的指針並不會變,而它們之間的距離會縮短,乘積也會變小。一次遍歷便可解決戰鬥,解題代碼以下:

var maxArea = function (height) {
  let max = 0 // 保存最大的值
  let l = 0;
  let r = height.length - 1
  while (r > l) { // 不能相遇
    const h = Math.min(height[r], height[l])
    max = Math.max(h * (r - l), max) // 容量等於距離差 * 矮的那邊條軸
    height[r] > height[l] ? l++ : r-- // 移動矮軸的指針
  }
  return max
};

15 - 三數之和 ↓

給你一個包含 n 個整數的數組 nums,判斷 nums 中是否存在三個元素a,b,c,使得a+b+c=0?
請你找出全部知足條件且不重複的三元組。
注意:答案中不能夠包含重複的三元組。

nums = [-1, 0, 1, 2, -1, -4]
知足要求的三元組集合爲:
[
  [-1, 0, 1],
  [-1, -1, 2]
]

來源:力扣(LeetCode)
連接:https://leetcode-cn.com/problems/3sum

很容易想到的就是暴力解,使用三層遍歷,將三個數字累加和的可能性都計算一遍,提取須要的組合便可,暴力解的複雜度是O(n³)。若是這題是要返回它們對應的下標,那還真沒辦法,不過既然是返回組合的數字,那咱們就能夠利用有序數組的特性,仍是使用對撞指針更有效率的解決此題。

首先對數組進行排序,而後設置三個指針i、l、r,每一輪的循環下標i是固定不動的,讓lj對撞,最後根據它們相加的和來移動lr指針。若是和正好等於0,那就找到了一種組合結果;若是大於0,就r--r指針向中間移動;若是小於0,就l++l指針向中間移動,該解法的複雜度是O(n²)。解題代碼以下:

var threeSum = function (nums) {
  nums.sort((a, b) => a - b) // 排序
  const res = []
  for (let i = 0; i < nums.length; i++) {
    let l = i + 1
    let r = nums.length - 1
    if (nums[i] > 0) { // 若是第一個元素就大於0,後面也不用比了
      break;
    }
    if (i > 0 && nums[i] === nums[i - 1]) { // 跳過相同的數字
      continue
    }
    while (r > l) {
      const sum = nums[i] + nums[l] + nums[r];
      if (sum === 0) { // 正好找到
        res.push([nums[i], nums[l], nums[r]])
        while (r > l && nums[l] === nums[l + 1]) { // 跳過相同的數字,一個元素只用一次
          l++
        }
        while (r > l && nums[r] === nums[r - 1]) { // 跳過相同的數字,一個元素只用一次
          r--
        }
        r-- // 縮小範圍
        l++ // 縮小範圍
      } else if (sum > 0) {
        r-- // 右指針移動,減小下次計算的和
      } else { // sum < 0
        l++ // 左指針移動,增長下次計算的和
      }
    }
  }
  return res
};

滑動窗口

643 - 子數組最大平均數 I ↓

給定 n 個整數,找出平均數最大且長度爲 k 的連續子數組,並輸出該最大平均數。
輸入: [1,12,-5,-6,50,3], k = 4
輸出: 12.75
解釋: 最大平均數 (12-5-6+50)/4 = 51/4 = 12.75

以參數k的長度爲一個子數組,因此能夠把k當作是一個窗口,在原有數組上進行滑動,每通過一個子數組就求出的它的平均值。若是使用暴力解,會存在重複計算的問題,因此咱們每次滑動一步,只須要加上新的元素,而後減去窗口最左側的元素便可。

解題代碼以下:

var findMaxAverage = function (nums, k) {
  let max = 0
  let sum = 0
  for (let i = 0; i < k; i++) {
    sum += nums[i] // 先求出第一個窗口
  }
  max = sum / k // 第一個窗口的平均值
  let j = k
  while (nums.length > j) {
    sum += nums[j] - nums[j - k] // 加上新元素,減去最左側元素
    max = Math.max(sum / k, max) // 與以前窗口的平均值比較
    j++ // 向右滑動
  }
  return max // 返回最大窗口平均值
};

674 - 最長連續遞增序列 ↓

給定一個未經排序的整數數組,找到最長且連續遞增的子序列,並返回該序列的長度。

輸入:nums = [1,3,5,4,7]
輸出:3
解釋:最長連續遞增序列是 [1,3,5], 長度爲3。
儘管 [1,3,5,7] 也是升序的子序列, 但它不是連續的,由於 5 和 7 在原數組裏被 4 隔開。

輸入:nums = [2,2,2,2,2]
輸出:1
解釋:最長連續遞增序列是 [2], 長度爲1。

來源:力扣(LeetCode)
連接:https://leetcode-cn.com/problems/longest-continuous-increasing-subsequence

這題仍是使用滑動窗口解決,爲窗口定義兩個下標l、r,既然是遞增的,那麼咱們就要兩兩相鄰的進行比較,當遇到的元素大於窗口最右側值時,將下標l移至r處,從新開始判斷統計長度。圖示以下:

代碼以下:

var findLengthOfLCIS = function (nums) {
  let l = 0;
  let r = 0;
  let max = 0;
  while (nums.length > r) { // 只要窗口還在數組內活動
    if (r > 0 && nums[r - 1] >= nums[r]) { // 若是遇到的元素大於窗口最右側值
      l = r // 從新統計長度
    }
    max = Math.max(max, r - l + 1) // 統計最長的長度
    r++ // 向右滑動
  }
  return max
};

209 - 長度最小的子數組 ↓

給定一個含有n個正整數的數組和一個正整數s,找出該數組中知足其和≥s的長度最小的連續子數組,並返回其長度。
若是不存在符合條件的子數組,返回 0。

輸入:s = 7, nums = [2,3,1,2,4,3]
輸出:2
解釋:子數組 [4,3] 是該條件下的長度最小的子數組。

來源:力扣(LeetCode)
連接:https://leetcode-cn.com/problems/minimum-size-subarray-sum

題目的要求是要找一個連續子數組的和大於或等於傳入是s,因此咱們仍是可使用滑動窗口,統計窗口內的和,若是已經大於或等於s了,那麼此時窗口的長度就是連續子數組的長度。

當找到一個連續子數組後,讓左側的窗口向右滑動,減去最左側的值,減少窗口內的和,也讓窗口右側滑動。若是又找到了一個知足條件的子數組,與以前的子數組長度進行比較,更新最小窗口的大小便可。

有一個特例就是,若是整個數組加起來的和都比s小,那就不能返回窗口的長度,而是直接返回0。 代碼以下:

var minSubArrayLen = function (s, nums) {
  let l = 0
  let r = 0
  let sum = 0 // 窗口裏的和
  let size = nums.length + 1 
  // 窗口的大小, 由於是要找到最小的窗口,因此設置一個比最大還 +1 的窗口
  // 若是能找到一個符合條件的子數組纔會更新窗口的大小
  while (nums.length > l) { // 讓左邊界小於整個數組,爲了遍歷到每個元素
    if (s > sum) {
      sum += nums[r++] // 窗口和小於s,移動右窗口
    } else {
      sum -= nums[l++] // 窗口大於s,移動左窗口
    }
    if (sum >= s) { // 找到符合的子數組
      size = Math.min(size, r - l) // 更新最小窗口的值
    }
  }
  return size === nums.length + 1 ? 0 : size
  // 若是size等於初始值,表示沒有符合要求的子數組,不然有找到
};

3 - 無重複字符的最長子串 ↓

給定一個字符串,請你找出其中不含有重複字符的 最長子串 的長度。

輸入: "abcabcbb"
輸出: 3 
解釋: 由於無重複字符的最長子串是 "abc",因此其長度爲 3。

輸入: "bbbbb"
輸出: 1
解釋: 由於無重複字符的最長子串是 "b",因此其長度爲 1。

這題和上一題相似,滑動窗口不只僅能夠做用於數組,字符串也一樣適用。

這題麻煩一點的地方在於還要定義一個set用於查找,當新加入窗口的元素set裏沒有時,就加入其中,窗口右移;若是有這個元素,須要將窗口移動到set裏出現的位置,也就是在set裏將其自己及窗口左側的元素所有都移除,重複這個過程,直到窗口右側到達字符串的末尾。如圖所示:

解題代碼以下:

var lengthOfLongestSubstring = function (s) {
  const set = new Set();
  let l = 0;
  let r = 0;
  let max = 0;
  while (l < s.length && r < s.length) {
    if (!set.has(s[r])) { // 若是查找表裏沒有
      set.add(s[r++]); // 添加右移窗口
    } else {
      set.delete(s[l++]); 
      // 從左側開始刪除,直到把新加入的且在查找表裏有的元素刪除爲止
      // 而後窗口才會繼續開始右滑
    }
    max = Math.max(max, r - l); // 更新最大的值
  }
  return max;
};

最後

以上不少題目也有其餘的解法或暴力解,不只僅侷限只有多指針和滑動窗口才能解決,不過在應對難題時,有另外一種解題的思路供參考,不過這兩種算法對邊界的處理能力要求挺高,要特別注意定義指針時開/閉區間的含義。

想起筆者以前在遇到算法題目以前要麼暴力求解,或者就是使用各類遍歷api鼓搗一番,當時以爲代碼量少還挺好。不過在深刻理解了算法以後才明白,代碼少不表明效率高,解題的邏輯思惟能力纔是最重要的。

相關文章
相關標籤/搜索