?LeetCode刷題實(shí)戰(zhàn)15題: 三數(shù)之和
算法的重要性,我就不多說了吧,想去大廠,就必須要經(jīng)過基礎(chǔ)知識和業(yè)務(wù)邏輯面試+算法面試。所以,為了提高大家的算法能力,這個(gè)公眾號后續(xù)每天帶大家做一道算法題,題目就從LeetCode上面選 !
今天和大家聊的問題叫做三數(shù)之和?,我們先來看題面:
https://leetcode.com/problems/3sum/
描述
給定一個(gè)整數(shù)的數(shù)組,要求尋找當(dāng)中所有的a,b,c三個(gè)數(shù)的組合,使得三個(gè)數(shù)的和為0.注意,即使數(shù)組當(dāng)中的數(shù)有重復(fù),同一個(gè)數(shù)也只能使用一次。
Given an array?nums?of?n?integers, are there elements?a?,?b?,?c?innums?such that?a?+?b?+?c?= 0? Find all unique triplets in the array
which gives the sum of zero.
Note:
The solution set must not contain duplicate triplets.
樣例
給定數(shù)組 nums = [-1, 0, 1, 2, -1, -4],
滿足要求的三元組集合為:
[
??[-1, 0, 1],
??[-1, -1, 2]
]
題解
這道題是之前LeetCode第一題2 Sum的提升版,在之前的題目當(dāng)中,我們尋找的是和等于某個(gè)值的兩個(gè)數(shù)的組合。而這里,我們需要找的是三個(gè)數(shù)。從表面上來看似乎差別不大,但是實(shí)際處理起來要麻煩很多。
? 暴力求解??
我們先理一下思路,從最簡單的方法開始入手。這題最簡單的方法當(dāng)然就是暴力法,我們已經(jīng)明確了要找的是三個(gè)數(shù)的和,既然數(shù)量確定了,就好辦了,我們直接枚舉所有三個(gè)數(shù)的組合,然后所有和等于0的組合就是答案。但是這里有一個(gè)小問題,當(dāng)我們找到了答案之后,我們并不能直接返回,因?yàn)閿?shù)組當(dāng)中重復(fù)的元素很有可能會(huì)導(dǎo)致答案的重復(fù),我們必須要去掉這些重復(fù)的答案,保證答案當(dāng)中每一個(gè)都是唯一的。
那我們先對原數(shù)組做處理,去除掉其中重復(fù)的元素之后再來尋找答案可不可以呢?
很遺憾,這個(gè)想法很好,但是不可行。原因也很簡單,因?yàn)榇鸢覆荒苤貜?fù),但是答案里的數(shù)是可以重復(fù)的。
舉個(gè)例子,比如數(shù)組是[-1, -1, 2, 0, -2],那么[-1, -1, 2]是一個(gè)答案,如果一開始就出去掉了重復(fù)的-1,那么這個(gè)答案顯然就無法構(gòu)成了。唯一的解決方法是用容器來維護(hù)答案,保證容器內(nèi)的答案是唯一的,不過這個(gè)會(huì)帶來額外的時(shí)間和空間開銷。
所以,總體看來,暴力枚舉并不是個(gè)好方法,復(fù)雜度不低,如果使用C++和Java等語言的話,使用容器也很麻煩。
ret = set()for i in range(n):for j in range(i+1, n):for k in range(j+1, n):if a[i] + a[j] + a[k] == 0:ret.add((i, j, k))return?list(ret)
??利用2 Sum??
還有一個(gè)思路是利用之前的2 Sum的解法,在之前的2 Sum問題當(dāng)中,我們通過巧妙地使用map,來達(dá)成了在
復(fù)雜度內(nèi)找到了所有和等于某個(gè)值的元素對。所以,我們可以先枚舉第一個(gè)數(shù)的大小,然后在剩下的元素當(dāng)中進(jìn)行2 Sum操作。
假設(shè)我們枚舉的數(shù)是a[i],那么我們在剩下的元素當(dāng)中做2 Sum,來尋找和等于-a[i]的兩個(gè)數(shù)。最后,將這三個(gè)數(shù)組成答案。如果遺忘2 Sum解法的同學(xué)可以點(diǎn)擊下方鏈接回到之前的文章。LeetCode 1 Two Sum——在數(shù)組上遍歷出花樣
這個(gè)方法看起來巧妙很多,但是還是逃不掉重復(fù)的問題。舉個(gè)例子:[-1, -1, -1, -1, -1, 2]。如果我們枚舉-1,那么會(huì)出現(xiàn)多個(gè)[-1, -1, 2]的結(jié)果。所以我們依然免不了手動(dòng)過濾重復(fù)的答案。不過利用2 Sum的解法要比暴力快一些,因?yàn)? Sum的時(shí)間復(fù)雜度是
,再乘上枚舉元素的復(fù)雜度,不考慮去重情況下的整體復(fù)雜度是O(n^2),要比枚舉的O(n^3)更優(yōu)。
我們利用2 sum寫出新的算法:
def two_sum(array, idx, target):"""two sum的部分"""n = len(array)ret = []# 用來記錄所有出現(xiàn)過的元素appear = set()# 用來判斷2 sum的答案出現(xiàn)重復(fù)used = set()for i in range(idx + 1, n):# 如果 target - array[i]之前出現(xiàn)過,說明可以構(gòu)成答案if target - array[i] in appear:# 判斷答案是否重復(fù)if array[i] in used or target - array[i] in used:continue# 記錄used.add(array[i])used.add(target - array[i])ret.append((array[i], target - array[i]))appear.add(array[i])return retdef three_sum(array):n = len(array)# 記錄枚舉過的元素used = set()ret = []# 防止答案重復(fù)duplicated = set()for i in range(n):# 如果出現(xiàn)過,說明已經(jīng)枚舉過,跳過if array[i] in used:continue# 拿到2 sum的答案combinations = two_sum(array, i, -array[i])if len(combinations) > 0:for combination in combinations:# 組裝答案answer = tuple(sorted((array[i], *combination)))# 判斷答案是否重復(fù)if answer in duplicated:continue# 記錄ret.append(answer)duplicated.add(answer)used.add(array[i])return ret
? 尺取法??
這題的另一個(gè)解法是尺取法,也就是two pointers,也叫做兩指針?biāo)惴?。這個(gè)在我們之前的文章當(dāng)中也有過介紹,有遺忘或者錯(cuò)過的同學(xué)可以點(diǎn)擊下方的鏈接回顧一下。LeetCode3 一題學(xué)會(huì)尺取算法
尺取法的精髓是通過兩個(gè)指針控制一個(gè)區(qū)間,保證區(qū)間滿足一定的條件。在這題當(dāng)中,我們要控制的條件其實(shí)是三個(gè)數(shù)的和。由于我們的指針數(shù)量是2,也就是說我們只有兩個(gè)指針,但是我們卻需要找到三個(gè)數(shù)組成的答案。顯然,我們直接使用尺取法是不行的。我們稍作變通就可以解決這個(gè)問題,就是第一個(gè)解法的思路,我們先枚舉一個(gè)數(shù),然后再通過尺取法去尋找另外兩個(gè)數(shù)。
使用尺取法需要我們根據(jù)現(xiàn)在區(qū)間內(nèi)的信息,制定策略如何移動(dòng)區(qū)間。顯然,如果區(qū)間里的數(shù)雜亂無章,我們是很難知道應(yīng)該怎么維護(hù)區(qū)間的。所以我們首先對數(shù)組當(dāng)中的元素進(jìn)行排序,保證元素的有序性。區(qū)間里的元素有序了,那么我們就方便了。
假設(shè)我們當(dāng)前枚舉的數(shù)是a[i],那么我們就需要找到另外的兩個(gè)數(shù)b和c,使得b + c = -a[i]。對于每一個(gè)i來說,這樣的b和c可能存在,也可能不存在,我們必須要尋找過了才知道。
和2 Sum一樣,為了優(yōu)化時(shí)間復(fù)雜度,加快算法的效率,我們需要人為設(shè)置一些限制。我們限制b和c只能在a的右側(cè),當(dāng)然也可以限制在一左一右,總之,我們需要把這三個(gè)數(shù)的順序固定下來。因?yàn)槿齻€(gè)數(shù)調(diào)換順序只會(huì)產(chǎn)生重復(fù),所以我們固定順序可以避免重復(fù)。所以我們枚舉a的位置之后,在a的右側(cè)通過尺取法尋找另外兩個(gè)元素。
方法也很簡單,我們一開始設(shè)置b的位置是i+1, c的位置是n。如果b+c > -a,那么說明兩者的和過大,因?yàn)閎已經(jīng)是最小值了,所以只能將c向左移動(dòng)。如果b+c < -a,說明兩者的和過小,需要增大,所以應(yīng)該將b往右側(cè)移動(dòng)增大數(shù)值。如此往復(fù),當(dāng)這個(gè)區(qū)間遍歷完成之后,繼續(xù)移動(dòng)a的位置,尋找下一組解,這里需要注意,a需要跳過所有重復(fù)的數(shù)字,避免重復(fù)。
我們寫出代碼:
def three_sum(array):n = len(array)# 先對array進(jìn)行排序array = sorted(array)ret = []for i in range(n-2):# 判斷第一個(gè)數(shù)是否重復(fù)if i > 0 and array[i] == array[i-1]:continueused.add(array[i])# 進(jìn)行two pointers縮放j = i + 1k = n - 1target = -array[i]if target < 0:breakwhile j < k:cur_sum = array[j] + array[k]# 判斷當(dāng)前區(qū)間的結(jié)果和目標(biāo)的大小if cur_sum < target:j += 1continueelif cur_sum > target:k -= 1continue# 記錄ret.append(answer)# 繼續(xù)縮放區(qū)間,尋找其他可能的答案j += 1while j < k and array[j] == array[j-1]:j += 1k -= 1while j < k-1 and array[k] == array[k+1]:k -= 1return ret
寫出代碼之后,我們來分析一下算法的復(fù)雜度。一開始的時(shí)候,我們對數(shù)組進(jìn)行排序,眾所周知,排序的復(fù)雜度是
。之后,我們枚舉了第一個(gè)數(shù),開銷是
,我們進(jìn)行區(qū)間縮放的復(fù)雜度也是
,所以整個(gè)主體程序的復(fù)雜度是
??此坪蜕厦嬉环N方法區(qū)別不大,但是我們節(jié)省了set重復(fù)的判斷,由于hashset讀取會(huì)有時(shí)間開銷,所以雖然算法的量級上沒什么差別,但是常數(shù)更小,真正運(yùn)行起來這種算法要快很多。
這題官方給的難度是Medium,但實(shí)際上我覺得比一般的Medium要難上一些,代碼量也要大上一些。今天文章當(dāng)中列舉的并不是全部的解法,其他的做法還有很多,比如對所有數(shù)進(jìn)行分類,分成負(fù)數(shù)、零和正數(shù),然后再進(jìn)行組裝等等。感興趣的同學(xué)可以自己思考,看看還有沒有其他比較有趣的方法。
今天的文章就到這里,如果覺得有所收獲,請順手點(diǎn)個(gè)在看或者轉(zhuǎn)發(fā)吧,你們的支持是我最大的動(dòng)力。
上期推文:
LeetCode刷題實(shí)戰(zhàn)1:在數(shù)組上遍歷出花樣
LeetCode刷題實(shí)戰(zhàn)2:用鏈表模擬加法
LeetCode刷題實(shí)戰(zhàn)3:最長不重復(fù)子串
LeetCode刷題實(shí)戰(zhàn)4:兩個(gè)正序數(shù)組的中位數(shù)
LeetCode刷題實(shí)戰(zhàn)5:判斷回文子串
LeetCode刷題實(shí)戰(zhàn)6:Z字形變換
LeetCode刷題實(shí)戰(zhàn)7:整數(shù)反轉(zhuǎn)
LeetCode刷題實(shí)戰(zhàn)8:字符串轉(zhuǎn)換整數(shù)
LeetCode刷題實(shí)戰(zhàn)9:求解回文數(shù)
LeetCode刷題實(shí)戰(zhàn)10:字符串正則匹配
LeetCode刷題實(shí)戰(zhàn)11: 盛最多水的容器
LeetCode刷題實(shí)戰(zhàn)12: 整數(shù)轉(zhuǎn)羅馬數(shù)字
LeetCode刷題實(shí)戰(zhàn)13: 羅馬數(shù)字轉(zhuǎn)整數(shù)
LeetCode刷題實(shí)戰(zhàn)14: 最長公共前綴
