1. 程式人生 > 實用技巧 >【演算法題】LeetCode刷題(二)

【演算法題】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)解題方法

方法轉自:https://leetcode-cn.com/problems/container-with-most-water/solution/sheng-zui-duo-shui-de-rong-qi-by-leetcode-solution/

方法:雙指標

本題是一道經典的面試題,最優的做法是使用「雙指標」。如果讀者第一次看到這題,不一定能想出雙指標的做法。
雙指標法就是將兩個指標(x和y)一頭一尾放置,每次計算面積A=min(x,y)t,其中t是x和y之間的距離,每次將較小的那個指標往中間移動,繼續計算A,得出最大值。用反證法很容易證明為什麼要移動最小的那個而不是最大的,這很簡單,假設x<y,那麼A0=min(x,y)

t=xt,移動y後A1=min(x,y')(t-1),如果y'>x,那麼A1=x(t-1)<A0,如果y'<=x,那麼A1=y'(t-1)<x*(t-1)<A0。

  • 時間複雜度: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(log⁡N)。我們忽略儲存答案的空間,額外的排序的空間複雜度為 O(log⁡N)。然而我們修改了輸入的陣列 nums,在實際情況下不一定允許,因此也可以看成使用了一個額外的陣列儲存了 nums 的副本並進行排序,空間複雜度為 O(N)。

(3)虛擬碼

函式頭:List<List> threeSum(int[] nums)

方法:排序+雙指標

  • 將陣列進行排序(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)解題方法

方法轉自:https://leetcode-cn.com/problems/letter-combinations-of-a-phone-number/solution/dian-hua-hao-ma-de-zi-mu-zu-he-by-leetcode/

方法:回溯法

這個題首先他就是一個排列組合的問題,而且你會發現這是沒法用幾重迴圈做的,因為每多按一次按鍵就會多一重迴圈,所以只能用遞迴的演算法來解決這個問題。
如下圖所示,所有的情況都可以用這個樹狀圖表示,那麼通過對樹的先序遍歷的理解,這個題也只需要按照這個思路進行求解即可。

  • 時間複雜度: 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 letterCombinations(String digits)

方法:回溯法

宣告一個全域性的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)解題方法

方法轉自:https://leetcode-cn.com/problems/merge-two-sorted-lists/solution/he-bing-liang-ge-you-xu-lian-biao-by-leetcode-solu/

方法一:遞迴

方法其實很簡答,遞迴必然是理解起來最簡單的方法,具體操作為:
取兩個頭結點進行比較(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)解題方法

方法轉自:https://leetcode-cn.com/problems/generate-parentheses/solution/gua-hao-sheng-cheng-by-leetcode-solution/

方法一:暴力法

暴力法思路很簡單,我就是生成2^2n個字串,然後一個一個判斷是否滿足要求,但是注意,這兩步並不是分開的,也就是說不需要遞迴兩次,只需要遞迴到全部括號都用完了之後進行判斷就可以了,而判斷的方法也很簡單,要想括號合理,只需要從左開始數,右括號永遠不多於左括號就行。

方法二:回溯法

眾所周知,回溯法精髓也在於遞迴,本質是一棵二叉樹,每次遇到分支後執行遞迴後都需要把之前的操作抹除,這樣就能進入到另一個路口了。而回溯法在解這個題的時候,相比暴力法的好處就是,我永遠不生成不符合要求的字串,就是邊做邊檢查,保證自己的右括號始終不多於左括號。

方法三:按括號序列的長度遞迴(看起來有點複雜,複雜度和回溯法也差不多,這裡就不展開了)

(3)虛擬碼

函式頭:List generateParenthesis(int n)

方法一:暴力法

  • 宣告一個字串列表,用於返回Listres
  • 呼叫generate(new char[2 * n], 0, res)

定義一個生成括號陣列的函式:generate(char[] curr, int pos, List res)

  • 如果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

方法二:回溯法

  • 宣告一個字串列表,用於返回Listres
  • 呼叫backtrack(res, new StringBuilder(), 0, 0, n)

定義一個回溯函式backtrack(List res, StringBuilder str, int left, int right, int max)

  • 如果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)程式碼示例