前言
若是說如何用算法高效有趣的解決某些問題,那多指針和滑動算法絕對是算其中的佼佼者。這也是筆者最初接觸算法時以爲最有意思的一點,由於解決的問題是熟悉的,但配方卻徹底不一樣,本章咱們從一個簡單的交集問題出發,一步步的認識到多指針及滑動窗口解決某些問題時的巧妙與高效,本章主要以解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
多了很多,不過總的複雜度是3
個O(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
是固定不動的,讓l
和j
對撞,最後根據它們相加的和來移動l
和r
指針。若是和正好等於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
鼓搗一番,當時以爲代碼量少還挺好。不過在深刻理解了算法以後才明白,代碼少不表明效率高,解題的邏輯思惟能力纔是最重要的。