自古各門各家武學都存在套路,正所謂以不變應萬變,就在於臨戰之時,可以一招制敵。有的招數可能出奇制勝,但是最穩定的方式一定是多次訓練的套路,它不一定能讓你解決所有的問題,但是它足以讓你輕松應對一類問題。
$nSum$ 的問題,主要存在大量重復的數使得如果在數組中遍歷每個數,再比較查詢結果,時間復雜度會超過題目的要求。我們可以采用哈希表的方式,加速查詢的過程,同時對遍歷的過程,對相同的數或者不滿足條件的數適當的跳過,可以有效的提升效率,通過測試用例。
那么先從最簡單的兩數之和講起。
1、 Leetcode 1 兩數之和
最暴力的方式是從頭到尾枚舉 $nums$ 中的每一個數,然后再看是否在數組中存在 $j$ 使得 $nums[j] == target - nums[i]$ 且 $j \not= i$,這樣的方式,遍歷是 $O(n)$ 復雜度,每次循環在數組中查詢也是 $O(n)$ 復雜度,總的時間復雜度達到了 $O(n^2)$。代碼和運行時間如下:
1 class Solution: 2 def twoSum(self, nums: List[int], target: int) -> List[int]: 3 result = [] 4 for i in range(len(nums)): 5 if nums[i] in nums and target-nums[i] in nums: 6 j = nums.index(target-nums[i]) 7 if i!=j: 8 result.append(i) 9 result.append(j) 10 break 11 return result
這樣做顯然太過耗時,循環如果不做改變的話(做改變的話可以用雙指針法,這里不做過多介紹),那么考慮在查找過程中進行加速。我們注意到哈希表中查找元素的時間是 $O(1)$,因此可以把在數組中查找改為在哈希表中查找。對於這題而言,只要找到了答案就可以返回,不需要找出所有的解,那么可以邊遍歷邊向哈希表中添加元素。添加前,查詢是否有滿足條件的解,如果滿足條件,$return$ 結果就可以。
1 class Solution: 2 def twoSum(self, nums: List[int], target: int) -> List[int]: 3 dic = {} 4 for i,num in enumerate(nums): 5 tmp = target - num # a + b = target, a = num, b = target - num 6 if tmp in dic: # 哈希表中查詢是否有解 7 return [i, dic[tmp]] 8 dic[num] = i # 沒有解的話就存下當前的數和位置
運行結果如下:
最差的情況下,在線性時間內就可以解決問題
接下來考慮復雜一點的問題
2、 Leetcode 15 三數之和
首先題目要求解集里面不包含重復的元素,那么按照一定的規律找答案,就可以得到不重復的解。可以想到的方法是先進行排序,這樣就可以有規律的尋找了。排序以后,每個數也可能有多個重復的,假如每個解里不能包含相同的數字,那么簡單的在循環里加上
1 if i > index and nums[i] == nums[i-1]: 2 continue
其中 $index$ 是循環開始的值, 並且下一層循環從 $i + 1$ 開始,就可以保證無重復了。這樣的去重方式可以參考我的另一篇文章講到的第三類問題, https://www.cnblogs.com/HMJIang/p/13575005.html
然而這道題則是每個解里可以包含相同的數字,比如 $[-1, -1, 2]$ 和 $[0, 0, 0]$ 都可以得到和為 $0$,這時候去重就可以用到 $Python$ 中計數哈希表, $Counter$。先統計每個數字出現的次數,再對鍵值進行排序,每層循環里判斷剩余的數字是否夠當前的變量選擇。
本題解法參考 https://leetcode-cn.com/problems/3sum/solution/ji-shu-zi-dian-jian-zhi-you-hua-fei-pai-xu-shuang-/
代碼如下:
1 from collections import Counter 2 class Solution: 3 def threeSum(self, nums: List[int]) -> List[List[int]]: 4 res = [] 5 dic = Counter(nums) # Counter可以統計數組每個元素的個數 6 hash_nums = sorted(dic.keys()) #對鍵值進行排序 7 for i, a in enumerate(hash_nums): 8 dic[a] -= 1 # a已經取走了一個數字,字典里對應位置 -1 9 for b in hash_nums[i:]: 10 if dic[b] < 1: # b從i開始遍歷,i也是當前a的位置,如果減去1以后b不夠選了,跳過這一個位置 11 continue 12 c = -(a + b) 13 if c < b: #有序的查找,如果c都比b小,之后b再增大,肯定c更小,那么就跳出,防止重復 14 break 15 # 再判斷c和b的關系,如果相等,那就需要dic[c]至少為2,才夠選,如果不等,只要有,就夠選了 16 if (c > b and dic[c] > 0) or (c == b and dic[c] > 1): 17 res.append([a, b, c]) 18 return res
時間復雜度 $O(n^2)$
空間復雜度 $O(n)$
提交結果:
有了這樣的經驗以后,我們可以用已有的套路看更復雜的四數之和
3、Leetcode 18 四數之和
最外層循環遍歷到什么位置,就在對應位置上 $-1$,接下來內層循環里也把選擇的數 $-1$,方便后面進行判斷,只要不夠選了,就 $continue$ 跳過這一次循環,如果最終的 $d$ 比 $c$ 還大,依舊 $break$ 掉,和三數之和的差別在於,第二個數選擇的時候要 $-1$,最內層循環結束以后還要 $+1$,因為之后最外層的 $a$ 也會再遍歷到這個位置。
代碼如下:
1 from collections import Counter 2 class Solution: 3 def fourSum(self, nums: List[int], target: int) -> List[List[int]]: 4 res = [] 5 dic = Counter(nums) #對每個數出現的次數進行統計 6 arr = sorted(dic.keys()) #排序鍵值 7 for i, a in enumerate(arr): 8 dic[a] -= 1 #a用掉了一次,而且a的位置之后不會再遍歷到了,不需要加回 9 for j, b in enumerate(arr[i:]): #從arr[i]開始找b的值 10 if dic[b] < 1: #b可能等於a,判斷一下,如果dic[b]不夠1個,跳過這次循環 11 continue 12 dic[b] -= 1 13 for c in arr[i+j:]: #從arr[i+j]開始找c的值,注意上一層循環枚舉j以后,需要再加最外層的i 14 if dic[c] < 1: #同上層循環b的判斷 15 continue 16 d = target - (a + b + c) 17 if d < c: #因為是非遞減順序,如果d小於c,就直接跳出,這樣就可以避免重復 18 break 19 if (d == c and dic[d] > 1) or (d > c and dic[d] > 0): 20 res.append([a, b, c, d]) 21 dic[b] += 1 #b現在所處的位置,之后a還會遍歷到,因此需要加回1 22 return res
時間復雜度 $O(n^3)$
空間復雜度 $O(n)$
提交結果:
以此可以類推到更多數字的和。最外層循環每選到一個位置以后,都 $-1$,內層的循環也選到一個位置 $-1$,在更內層的循環結束以后 $+1$ 就可以。最內層轉化為兩數之間的大小關系的比較和查詢哈希表是否有滿足條件的值。
最后看一個變種問題
Leetcode 1577 數的平方等於兩數乘積的方法數
這一題如果暴力求解,必然超時,那么就需要一些優化策略。兩個數組里可能會存在很多相同的數,它們僅僅是位置不同,找到的 $j$, $k$ 的結果卻一樣,比如 $nums1 = [1,1,1,1]$,$nums2 = [1,1,1,1,1,1]$,$nums1$ 中每個數的平方,都等於 $nums2$ 中任意兩個不同位置的數的乘積,我們沒有必要對每個相同的 $nums1$ 中的數都找一遍 $nums2$ 中所有的數,這就又回到了 $nSums$ 問題,可以想到的去重的方式是哈希表。
這里的技巧在於如果對於每個平方數去找是否存在兩個數和它相等,每個平方數遍歷的時間是 $O(n)$, 再找兩個數,如果要達到 $O(m)$ 復雜度,就應該考慮雙指針的方式,然后需要各種比較,代碼相對復雜,容易出錯。如果換個思路,從右向左找,對於每個數字的乘積,都在哈希表里找是否存在相應的平方數,那么時間復雜度就是 $兩次遍歷數組的時間復雜度 × 哈希表查找的時間復雜度$,由於哈希表查找是 $O(1)$,最終等於兩次遍歷數組的時間復雜度。
代碼如下:
1 from collections import Counter 2 class Solution: 3 def numTriplets(self, nums1: List[int], nums2: List[int]) -> int: 4 square1 = Counter([i*i for i in nums1]) 5 square2 = Counter([i*i for i in nums2]) 6 res = 0 7 for i in range(len(nums2)): 8 for j in range(i+1, len(nums2)): 9 tmp = nums2[i] * nums2[j] # 可以用tmp存一下兩數之積,避免后面字典查詢的時候再次重復計算鍵值 10 if tmp in square1: 11 res += square1[tmp] 12 for i in range(len(nums1)): 13 for j in range(i+1, len(nums1)): 14 tmp = nums1[i] * nums1[j] # 同理 15 if tmp in square2: 16 res += square2[tmp] 17 return res
時間復雜度 $O(n^2 + m^2)$ 其中 $m$,$n$ 是 $nums1$ 和 $nums2$ 的數組長度
空間復雜度 $O(n+m)$
代碼執行結果如下:
之所以說在每次循環中要用 $tmp$ 存一下兩數的乘積,因為隨着數據量的增加,重復計算兩數乘積的代價也是相當大的,如果兩次都直接用
1 if nums2[i] * nums2[j] in square1: 2 res += square1[nums2[i] * nums2[j]]
運行時間將會明顯提升,提交結果如下: