【演算法題】LeetCode刷題(二)
資料結構和演算法是程式設計路上永遠無法避開的兩個核心知識點,本系列【演算法題】旨在記錄刷題過程中的一些心得體會,將會挑出LeetCode等最具代表性的題目進行解析,題解基本都來自於LeetCode官網(https://leetcode-cn.com/),本文是第二篇。
目錄
1.盛水最多的容器(原第11題)
給你 n 個非負整數 a1,a2,...,an,每個數代表座標中的一個點 (i, ai) 。在座標內畫 n 條垂直線,垂直線 i 的兩個端點分別為 (i, ai) 和 (i, 0)。找出其中的兩條線,使得它們與 x 軸共同構成的容器可以容納最多的水。
說明:你不能傾斜容器,且 n 的值至少為 2。
示例:
輸入:[1,8,6,2,5,4,8,3,7]
輸出:49
(1)知識點
雙指標法
(2)解題方法
方法:雙指標
本題是一道經典的面試題,最優的做法是使用「雙指標」。如果讀者第一次看到這題,不一定能想出雙指標的做法。
雙指標法就是將兩個指標(x和y)一頭一尾放置,每次計算面積A=min(x,y)t,其中t是x和y之間的距離,每次將較小的那個指標往中間移動,繼續計算A,得出最大值。用反證法很容易證明為什麼要移動最小的那個而不是最大的,這很簡單,假設x<y,那麼A0=min(x,y)
- 時間複雜度:O(N),雙指標總計最多遍歷整個陣列一次。
- 空間複雜度:O(1),只需要額外的常數級別的空間。
(3)虛擬碼
函式頭:int maxArea(int[] height)
方法:雙指標
- 定義max作為返回值
- 定義i=0,j=len-1
- 第一重迴圈(當i<j)
- 更新max值
- 比較i和j位置的高度大小,將高度小的座標移動(++i或--j)
(4)程式碼示例
2.三數之和(原第15題)
給你一個包含 n 個整數的陣列 nums,判斷 nums 中是否存在三個元素 a,b,c ,使得 a + b + c = 0 ?請你找出所有滿足條件且不重複的三元組。
注意:答案中不可以包含重複的三元組。
示例:
給定陣列 nums = [-1, 0, 1, 2, -1, -4],
滿足要求的三元組集合為:
[
[-1, 0, 1],
[-1, -1, 2]
]
(1)知識點
排序+雙指標:這裡的排序雖然不是考察演算法的重點,但是是為後續查詢做鋪墊的。
(2)解題方法
方法轉自:https://leetcode-cn.com/problems/3sum/solution/san-shu-zhi-he-by-leetcode-solution/
方法:排序+雙指標
由於題目的要求是不能出現重複的結果,那麼如果當待查詢的資料中包含很多重複資料的時候,就必須對最後的結果進行去重操作了,解決這個問題的最好的辦法——排序,排好序的陣列遍歷的時候,遇到重複的跳過,那麼就能保證不輸出重複的結果了。
另外,其實這個題目的思路很簡單,固定一個動另外兩個,時間複雜度是O(N^3),常規方法是無法避免的,排序也只能避免重複資料。那麼要想降低複雜度,就需要針對這個題想一個策略。
試想一個這樣的排列:[-2,-1,0,1,2,3,4],第一步肯定是固定a=-2,b=-1,那麼第三重迴圈的c呢,如果c從0開始,那麼複雜度O(N^3)沒得跑,那麼我們可以讓c從4開始往左移動,這就是我們超好用的雙指標法。好處在哪呢,我們繼續看,c=4不符合要求,左移然後c=3,誒,符合了,這樣就沒必要繼續走了,輸出[-2,-1,3]即可,而且當b=0時,由於記錄了上一次迴圈的位置,c直接從3開始,就省了跑c=4這個無用的計算了。(為什麼b=0,c直接從3開始?你想,b是越來越大的,c=3剛好滿足前一個b的需求,那c=4必不可能滿足需求了。)
所以,這裡優化後的時間複雜度就變成了O(N2),可能看程式碼還會以為是O(N3),但實際上b和c是一起走的,他們兩個加起來走的長度只有一次迴圈的長度,當b的位置=c的位置的時候這重迴圈就結束了,b根本不需要跑到底。
劃重點:當我們需要列舉陣列中的兩個元素時,如果我們發現隨著第一個元素的遞增,第二個元素是遞減的,那麼就可以使用雙指標的方法,將列舉的時間複雜度從 O(N^2) 減少至 O(N)。
- 時間複雜度:O(N^2),其中 N 是陣列 nums 的長度。
- 空間複雜度:O(logN)。我們忽略儲存答案的空間,額外的排序的空間複雜度為 O(logN)。然而我們修改了輸入的陣列 nums,在實際情況下不一定允許,因此也可以看成使用了一個額外的陣列儲存了 nums 的副本並進行排序,空間複雜度為 O(N)。
(3)虛擬碼
函式頭:List<List
方法:排序+雙指標
- 將陣列進行排序(Array.sort(nums))
- 第一重迴圈:a=0->len-1
- 判斷如果a不為0且nums[a] == nums[a-1],則跳過下一重迴圈
- 宣告c=len-1用於當做雙指標的右側指標
- 第二重迴圈:b=a+1->len-1
- 判斷如果b不為a+1且nums[a] == nums[a-1],則跳過後面步驟
- 第三重迴圈(當c>b且三個位置數相加大於0)
- c--
- 如果c==b,跳出第二重迴圈
- 判斷三者和是否等於0
(4)程式碼示例
3.電話號碼的字母組合(原第17題)
給定一個僅包含數字 2-9 的字串,返回所有它能表示的字母組合。
給出數字到字母的對映如下(與電話按鍵相同)。注意 1 不對應任何字母。
示例:
輸入:"23"
輸出:["ad", "ae", "af", "bd", "be", "bf", "cd", "ce", "cf"].
(1)知識點
回溯法:回溯是一種通過窮舉所有可能情況來找到所有解的演算法。如果一個候選解最後被發現並不是可行解,回溯演算法會捨棄它,並在前面的一些步驟做出一些修改,並重新嘗試找到可行解。
(2)解題方法
方法:回溯法
這個題首先他就是一個排列組合的問題,而且你會發現這是沒法用幾重迴圈做的,因為每多按一次按鍵就會多一重迴圈,所以只能用遞迴的演算法來解決這個問題。
如下圖所示,所有的情況都可以用這個樹狀圖表示,那麼通過對樹的先序遍歷的理解,這個題也只需要按照這個思路進行求解即可。
- 時間複雜度: O(3^N * 4^M),其中 N 是輸入數字中對應 3 個字母的數目(比方說 2,3,4,5,6,8), M 是輸入數字中對應 4 個字母的數目(比方說 7,9),N+M 是輸入數字的總數。
- 空間複雜度:O(3^N * 4^M),這是因為需要儲存 3^N * 4^M個結果。
(3)虛擬碼
函式頭:List
方法:回溯法
宣告一個全域性的List
宣告一個全域性的HashMap<String, String> map用於儲存所有可能的數字對應的字串,如"2"對應著"abc","3"對應著"def",以此類推
定義一個遞迴函式(backtrack),引數為已經組合的字串(combStr)和剩餘的數字串(restDigits):
- 如果剩餘的數字串長度為0,則map新增這個combStr,否則執行下列步驟:
- 取得待求對映的數字字元(digit)
- 將對映的結果得出:letters=map.get(digit)
- 第一重迴圈:(從letters的第一個字母到最後一個字母(letter))
- 組合combStr和letter
- 遞迴:將組合後的字串和restDigits.subString(1)傳入backtrack函式(subString(1)表示從第二個字元到最後的字元的子串)
定義包裹函式(就是最後返回的那個函式):
- 將""和digits傳入backtrack
(4)程式碼示例
4.合併兩個有序連結串列(原第21題)
將兩個升序連結串列合併為一個新的 升序 連結串列並返回。新連結串列是通過拼接給定的兩個連結串列的所有節點組成的。
示例:
輸入:1->2->4, 1->3->4
輸出:1->1->2->3->4->4
(1)知識點
連結串列
(2)解題方法
方法一:遞迴
方法其實很簡答,遞迴必然是理解起來最簡單的方法,具體操作為:
取兩個頭結點進行比較(a,b)如果a是較小的那個,將a.next和b繼續遞迴,將返回的節點作為a的next節點。
- 時間複雜度:O(n+m),其中 n 和 m 分別為兩個連結串列的長度。因為每次呼叫遞迴都會去掉 l1 或者 l2 的頭節點(直到至少有一個連結串列為空),函式 mergeTwoList 至多隻會遞迴呼叫每個節點一次。因此,時間複雜度取決於合併後的連結串列長度,即 O(n+m)。
- 空間複雜度:O(n+m),其中 n 和 m 分別為兩個連結串列的長度。遞迴呼叫 mergeTwoLists 函式時需要消耗棧空間,棧空間的大小取決於遞迴呼叫的深度。結束遞迴呼叫時 mergeTwoLists 函式最多呼叫 n+m 次,因此空間複雜度為 O(n+m)。
方法二:迭代
迭代法的原理和遞迴是一樣的,但是實現形式不同,沒有用遞迴函式,而是採用迴圈的方式,不斷的檢測兩個連結串列的頭結點,從而構建出新的連結串列。
- 時間複雜度:O(n+m) ,其中 n 和 m 分別為兩個連結串列的長度。因為每次迴圈迭代中,l1 和 l2 只有一個元素會被放進合併連結串列中, 因此 while 迴圈的次數不會超過兩個連結串列的長度之和。所有其他操作的時間複雜度都是常數級別的,因此總的時間複雜度為 O(n+m)。
- 空間複雜度:O(1) 。我們只需要常數的空間存放若干變數。
(3)虛擬碼
函式頭:ListNode mergeTwoLists(ListNode l1, ListNode l2)
方法一:遞迴
- 如果l1為空,返回l2
- 如果l2為空,返回l1
- 如果l1.val > l2.val,l2.next=mergeTwoLists(l1, l2.next),返回l2
- 否則,l1.next=mergeTwoLists(l1.next, l2),返回l1
方法二:迭代
- 宣告一個preHead(哨兵節點),用於指向最後要返回的節點
- 宣告一個prev=preHead,這個節點始終指向我們判斷大小後的小節點前一個節點
- 第一重迴圈(當l1且l2不為空)
- 如果l1.val > l2.val,prev.next = l2,l2 = l2.next
- 否則prev.next = l1,l1 = l1.next
- prev = prev.next
- 上面迴圈做完後,可能較長的那個連結串列還沒跑完,同樣的讓prev.next指向那個沒跑完的節點就可以了。
- 最後返回preHead.next
(4)程式碼示例
5.括號生成(原第22題)
數字 n 代表生成括號的對數,請你設計一個函式,用於能夠生成所有可能的並且 有效的 括號組合。
示例:
輸入:n = 3
輸出:[
"((()))",
"(()())",
"(())()",
"()(())",
"()()()"
]
(1)知識點
遞迴:本題因為涉及到n的量級是指數級的,所以採用遞迴更為直觀
(2)解題方法
方法一:暴力法
暴力法思路很簡單,我就是生成2^2n個字串,然後一個一個判斷是否滿足要求,但是注意,這兩步並不是分開的,也就是說不需要遞迴兩次,只需要遞迴到全部括號都用完了之後進行判斷就可以了,而判斷的方法也很簡單,要想括號合理,只需要從左開始數,右括號永遠不多於左括號就行。
方法二:回溯法
眾所周知,回溯法精髓也在於遞迴,本質是一棵二叉樹,每次遇到分支後執行遞迴後都需要把之前的操作抹除,這樣就能進入到另一個路口了。而回溯法在解這個題的時候,相比暴力法的好處就是,我永遠不生成不符合要求的字串,就是邊做邊檢查,保證自己的右括號始終不多於左括號。
方法三:按括號序列的長度遞迴(看起來有點複雜,複雜度和回溯法也差不多,這裡就不展開了)
(3)虛擬碼
函式頭:List
方法一:暴力法
- 宣告一個字串列表,用於返回List
res - 呼叫generate(new char[2 * n], 0, res)
定義一個生成括號陣列的函式:generate(char[] curr, int pos, List
- 如果pos == curr.length,判斷是否符合要求(呼叫judge(char[] ch),如果滿足,res.add(curr)——遞迴終止條件
- 否則執行下列操作:
- 新增一個左括號即curr[pos] = '('
- 呼叫自己遞迴generate(curr, pos+1, res)
- 新增一個右括號即curr[pos]=')'
- 呼叫自己遞迴generate(curr, pos+1, res)
定義一個判斷字元陣列是否符合要求的函式judge(char[] ch)
- 宣告int banlance = 0
- 第一層迴圈:c=0->ch.length - 1
- 如果c=='(',balance++,否則balance--
- 如果迴圈過程中banlance < 0,直接返回false
- 返回balance == 0
方法二:回溯法
- 宣告一個字串列表,用於返回List
res - 呼叫backtrack(res, new StringBuilder(), 0, 0, n)
定義一個回溯函式backtrack(List
- 如果str.length() == 2*max,res.add(str.toString())——遞迴終止條件
- 否則執行下列操作:
- 如果left < max,新增一個左括號,str.append("("),呼叫自己backtrack(res, str, left+1, right, max),刪掉最後一個字元(回溯)str.deleteCharAt(str.length() - 1)
- 如果right < left,新增一個右括號,str.append(")"),呼叫自己backtrack(res, str, left, right+1, max),刪掉最後一個字元(回溯)str.deleteCharAt(str.length() - 1)
(4)程式碼示例