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

前言

如果說如何用算法高效有趣的解決某些問題,那多指針和滑動算法絕對是算其中的佼佼者。這也是筆者最初接觸算法時覺得最有意思的一點,因爲解決的問題是熟悉的,但配方卻完全不同,本章我們從一個簡單的交集問題出發,一步步的認識到多指針及滑動窗口解決某些問題時的巧妙與高效,本章主要以解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完事。代碼如下:

解法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內,直至兩個數組中有一個到頭爲止。代碼如下:

解法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),不過效率會優於二分查找。

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鼓搗一番,當時覺得代碼量少還挺好。不過在深入理解了算法之後才明白,代碼少不代表效率高,解題的邏輯思維能力纔是最重要的。

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