前言
如果說如何用算法高效有趣的解決某些問題,那多指針和滑動算法絕對是算其中的佼佼者。這也是筆者最初接觸算法時覺得最有意思的一點,因爲解決的問題是熟悉的,但配方卻完全不同,本章我們從一個簡單的交集問題出發,一步步的認識到多指針及滑動窗口解決某些問題時的巧妙與高效,本章主要以解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
多了不少,不過總的複雜度是3
個O(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
是固定不動的,讓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
鼓搗一番,當時覺得代碼量少還挺好。不過在深入理解了算法之後才明白,代碼少不代表效率高,解題的邏輯思維能力纔是最重要的。