遞歸 & 回溯 & 分治 & 貪心


遞歸 Recursion

通過函數體來進行的循環,一種編程技巧。倒着思考,看到問題的盡頭。思路簡單但效率低(建立函數的副本,消耗大量時間和內存)。遞歸是分治和動態規划的基礎,而貪心是動態規划中的一種特殊情況(局部最優也是全局最優)。

終止條件(最簡子問題的答案) + 自身調用(解決子問題),不要試圖去搞清楚函數內部如何實現的,就先認為它可以實現這個功能。

比如,遍歷一顆樹

def traverse(root):
    if root is None:
        return
    for child in root.children:
        traverse(child)

  

 

計算n階乘,遞歸實現。

def Factorial(n):
    if n <= 1:   # 終止條件
        return 1
    return n*Factorial(n-1)

 

層層深入再回溯:

 

 

遞歸的代碼模板:

def recursion(level, param1, param2, ...):
    # recursion terminator
    if level > MAX_LEVEL:
        print_result
        return 
    
    # process logic in current level
    process_data(level, data, ...)
    
    # drill down
    recursion(level + 1, p1, ...)

    # reverse the current level status if needed
    reverse_state(level)

  

有些情況下遞歸處理問題是高效的,比如歸並排序。但有些情況下,非常低效。比如斐波那契數列,顯然遞推是簡單快速的,但如果非要遞歸但話也可以,低效。

 

Fibonacci數列,函數調用自身,注意遞歸的停止條件。分為調用和回溯兩個階段。

但是過程中存在大量重復計算,遞歸效率並不高。(因為存在重復的子問題,可以用判重或記錄結果)

# 遞歸
class Solution:
    def fib(self, N: int) -> int:
        if N <= 1:
            return N
        return self.fib(N-1) + self.fib(N-2)

# 迭代
class Solution:
    def fib(self, N: int) -> int:
        if N <= 1:
            return N
        tmp1 = 0
        tmp2 = 1
        for i in range(2, N+1):
            res = tmp1 + tmp2
            tmp1 = tmp2
            tmp2 = res
        return res

 

 

任意長度的字符串反向,遞歸實現

# 需要額外存儲空間
def reverseStr(string):
    if string == None or len(string) == 0:
        return None
    if len(string) == 1:
        return string
    return reverseStr(string[1:])+string[0]
#leetcode,O(1)額外空間,原地修改。雙指針
class Solution:
    def reverseString(self, s: List[str]) -> None:
        """
        Do not return anything, modify s in-place instead.
        """
        if s == None or len(s) <= 1:
            return None
        i, j = 0, len(s)-1
        while i<j:
            s[i], s[j] = s[j], s[i]
            i += 1
            j -= 1
        return
# 超時的遞歸解法
class Solution:
    def reverseString(self, s: List[str]) -> None:
        """
        Do not return anything, modify s in-place instead.
        """
        if s == None or len(s) <= 1:
            return None
        cur = s.pop(0)
        self.reverseString(s)
        s.append(cur)

 

漢諾塔問題:

def move(n, a, b, c):
    """n個盤子從a借助b移動到c上"""
    if n==1:
        print(a+'->'+c)
    else:
        move(n-1, a, c, b)
        move(1, a, b, c)
        move(n-1, b, a, c)

 

回溯 backtrack

回溯算法可以抽象理解為一個N叉樹的遍歷,比如斐波那契數列可以理解成一個二叉樹,而零錢兌換的例子就是一個N叉樹。

# 二叉樹遍歷
def traverse(root):
    if root is None:
        return
    # 前序代碼在這
    traverse(root.left)
    # 中序代碼在這
    traverse(root.right)
    # 后序代碼在這

# N叉樹遍歷
def traverse(root):
    if root is None:
        return
    for child in root.childen:
        # 前序代碼在這
        traverse(child)
        # 后序代碼在這

  

回溯的代碼模板:

def backtrack(choiceList, track, answer):
    """choiceList, 當前可以進行的選擇列表
        track, 決策路徑,即已經作出的一系列選擇
        answer, 儲存符合條件的決策路徑
    """
    if track is OK:
        answer.add(track)
    else:
        for choice in choiceList:
            # choose: 選擇一個choice 加入track
            backtrack(choices, track, answer)
            # unchoose: 從track中撤銷上面的選擇

  

全排列問題:給定一個沒有重復數字的序列,返回其所有可能的全排列。

class Solution:
    def permute(self, nums: List[int]) -> List[List[int]]:
        if not nums:
            return[[]]
        
        ans = []
        
        def backtrack(nums, track):
            nonlocal ans
            if not nums:
                ans.append(track)
            else:
                for i in range(len(nums)):
                    # track加入當前選的nums[i], 下一層nums[i]也不能選了
                    backtrack(nums[:i]+nums[i+1:], track+[nums[i]])
                    # track自然的回退了,因為沒有真的append上去
        
        backtrack(nums, [])
        return ans

  

子集:給定一組不含重復元素的整數數組 nums,返回該數組所有可能的子集(冪集)。解集不能包含重復的子集。

class Solution:
    def subsets(self, nums: List[int]) -> List[List[int]]:
        ans = []
        def backtrack(nums, track):
            nonlocal ans
            ans.append(track)  # 每次都記錄track
            for i in range(len(nums)):
                backtrack(nums[i+1:], track+[nums[i]])
        backtrack(nums, [])
        return ans

  

八皇后問題:

如何能夠在 8×8 的國際象棋棋盤上放置八個皇后,使得任何一個皇后都無法直接吃掉其他的皇后?為了達到此目的,任兩個皇后都不能處於同一條橫行、縱行或斜線上。八皇后問題可以推廣為更一般的n皇后擺放問題:這時棋盤的大小變為n×n,而皇后個數也變成n。當且僅當 n = 1 或 n ≥ 4 時問題有解。  

 

當在棋盤上放置了幾個皇后且不會相互攻擊。但是選擇的方案不是最優的,因為無法放置下一個皇后。此時該怎么做?回溯:回退一步,來改變最后放置皇后的位置並且接着往下放置。如果還是不行,再回溯。

一行只可能有一個皇后且一列也只可能有一個皇后。這意味着沒有必要再棋盤上考慮所有的方格。按行往下找皇后,對於每個皇后的位置只需要按列循環即可。對於所有的主對角線有:行號 - 列號 = 常數,對於所有的次對角線有 行號 + 列號 = 常數。

class Solution:
    def solveNQueens(self, n: int) -> List[List[str]]:
        def could_place(row, col):
            # row這一行是沒有放置過的行,要檢查col這一列、(row,col)所占兩條對角線有沒有被放置過,如果都沒有,(row,col)可以放皇后
            return not (cols[col]+hill_diagonals[row-col]+\
                        dale_diagonals[row+col]) 
        
        def place_queen(row, col):
            queens.add((row, col))  # 放皇后,記錄位置,標記列和兩對角線
            cols[col] = 1
            hill_diagonals[row-col] = 1
            dale_diagonals[row+col] = 1
        
        def remove_queen(row, col):
            queens.remove((row, col))  # 移除皇后,清空列和兩對角線的標記
            cols[col] = 0
            hill_diagonals[row-col] = 0
            dale_diagonals[row+col] = 0
        
        def add_solution():
            # 如果找到一個解,按要求記錄下來
            solution = []
            for _, col in sorted(queens):
                solution.append('.'*col + 'Q' + '.'*(n-col-1))
            output.append(solution)
        
        def backtrack(row):
            # 從第一行row=0開始放置皇后,放到n-1行
            for col in range(n):  # 對於確定的row,遍歷所有列col
                if could_place(row, col):
                    place_queen(row, col)  # 如果(row, col)可以放皇后,就放
                    if row == n-1:  # 如果已經放了最后一個,說明找到一個解
                        add_solution()
                    else:  # 沒有放到最后一個的話
                        backtrack(row+1)  # 去找row行之后所有可能的放置解法
                    remove_queen(row, col)  # 不管是哪種情況都要回溯,移除當前皇后,進入(row, col+1)的情況
            
        cols = [0] * n
        hill_diagonals = [0] * (2 * n -1)
        dale_diagonals = [0] * (2 * n -1)
        queens = set()
        output = []
        backtrack(0)
        return output

  

 

分治 Divde & Conquer

將問題分成幾個小模塊,逐一解決。典型的遞歸結構。分治可以高效率解決的,是沒有中間結果(沒有所謂的重復計算)的問題。 (適合的解決方法:動態規划、子問題記憶)

 

給定一個字符串,將小寫字母變為大寫。循環或者遞歸都可以。分治的做法:

子問題互不相關,可以並行計算。

 

典型的分治思想,歸並排序。將數組分解最小之后,把n個記錄看成是n個有序的子序列,每個子序列長度為1。然后兩兩歸並,得到ceil(n/2)個長度為2或者1的有序子序列,再兩兩歸並...,如此重復直到得到長度為n的有序序列為止。

用遞歸實現的話就很簡潔,直接左右兩邊遞歸的歸並排序,再merge左右兩邊就行了。

def merge_sort(alist):
    if len(alist) <= 1:
        return alist
    # 二分分解
    num = len(alist)//2
    left = merge_sort(alist[:num])
    right = merge_sort(alist[num:])
    # 合並
    return merge(left,right)

  

剩下的細節無非就是寫一下如何合並兩個有序數組,雙指針同時向后掃,小的就放進結果指針后移,大的就指針不動。

def merge(left, right):
    '''合並操作,將兩個有序數組left[]和right[]合並成一個大的有序數組'''
    #left與right的下標指針
    l, r = 0, 0
    result = []
    while l<len(left) and r<len(right):
        if left[l] < right[r]:
            result.append(left[l])
            l += 1
        else:
            result.append(right[r])
            r += 1
 
    if l < len(left):
        result += left[l:]
    elif r < len(right):
        result += right[r:]
    return result

  

完事了。用迭代寫的話要利用mod的技巧來操作索引,還是比較繁瑣的。代碼放到排序https://www.cnblogs.com/chaojunwang-ml/p/11296423.html 中了。

 

分治的代碼模板:

def divide_conquer(problem, param1, param2, ...):
    # recursion terminator
    if problem is None:
        print_result
        return
    
    # prepare data
    data = prepare_data(problem)
    subproblems = split_problem(problem, data)
    
    # conquer subproblems
    subresult1 = divide_conquer(subproblems[0], p1, ...)
    subresult2 = divide_conquer(subproblems[1], p1, ...)
    ...
    
    # process and generate the final result
    result = process_result(subresult1, subresult2, ...)
    

  

 

二分搜索,思路很簡單,但細節很蛋疼。

# 最普通的情況,規定有序數組不重復
class Solution:
    def search(self, nums: List[int], target: int) -> int:
        if nums == None or len(nums) == 0:
            return -1
        low = 0
        high = len(nums) - 1
        while low <= high:   # 雙端閉區間[low, high]查找 
            mid = (low + high) // 2
            if nums[mid] == target:
                return mid
            elif nums[mid] > target:
                high = mid - 1
            elif nums[mid] < target:
                low = mid + 1  
        return -1
# 尋找左側邊界的二分搜索。初始化 right = nums.length,決定了「搜索區間」是 [left, right),所以決定了 while (left < right),同時也決定了 left = mid + 1 和 right = mid
# 因為需找到 target 的最左側索引,所以當 nums[mid] == target 時不要立即返回,而要收緊右側邊界以鎖定左側邊界。

def search(nums, target):
        if nums == None or len(nums) == 0:
            return -1
        low = 0
        high = len(nums)
        while low < high:   # [low, high) 上搜索
            mid = (low + high) // 2
            if nums[mid] == target:
                high = mid   # 找到target之后不要立即返回,縮小搜索區間上界,在[low, mid)中繼續搜索,鎖定左側邊界low
            elif nums[mid] > target:
                high = mid
            elif nums[mid] < target:
                low = mid + 1 
        if low == len(nums): # target 比所有數都大
            return -1
        return low if nums[low] == target else -1  # 如果找到,low應該指向左側邊界
# 尋找右側邊界的二分搜索
def search(nums, target):
        if nums == None or len(nums) == 0:
            return -1
        low = 0
        high = len(nums)
        while low < high:   # [low, high) 上搜索
            mid = (low + high) // 2
            if nums[mid] == target:
                low = mid + 1   # 找到target之后不要立即返回,縮小搜索區間下界,在[mid+1, high)中繼續搜索,鎖定右側邊界high-1
            elif nums[mid] > target:
                high = mid
            elif nums[mid] < target:
                low = mid + 1 
        if low == len(nums): # target 比所有數都大
            return -1
        return low-1 if nums[low-1] == target else -1  # 若找到,最后low == high,右側邊界在 high-1
# 遞歸實現二分搜索,和迭代是一樣的,因為沒有重疊子問題
class Solution:
    def search(self, nums: List[int], target: int) -> int:
        if nums == None or len(nums) == 0:
            return -1
        return self.recursiveSearch(nums, 0, len(nums)-1, target)
    
    def recursiveSearch(self, nums, low, high, target):
        if low > high:  # 雙端閉區間搜索
            return -1
        mid = (low+high)//2
        if nums[mid] == target:
            return mid
        elif nums[mid] > target:
            return self.recursiveSearch(nums, low, mid-1, target)
        elif nums[mid] < target:
            return self.recursiveSearch(nums, mid+1, high, target)
        return -1

 

 

貪心 Greedy

對問題求解的時候,總是做出在當前看來最優的選擇。但處處做貪心,總體未必是最優的。

適用貪心的場景:問題能夠分解成子問題來解決,子問題的最優解能夠遞推到最終問題的最優解。這種子問題最優解稱為最優子結構。

貪心和動態規划的區別在於,它對每個子問題的解決方案都做出選擇,不能回退。而動態規划會保存以前的運算結果,並根據以前的結果對當前進行選擇,有回退功能。貪心可以看作是動態規划的一個特例。

 

手里有面額20、10、5、1元的四種紙幣,問要湊夠36元最少需要多少張。

每次先選最大面額的,不能選了再選次大的;...

 

經典貪心,Interval Scheduling(區間調度問題),算出給定的一組[start, end]區間中最多有幾個互不相交的區間。例如 intvs = [[1, 3], [2, 4], [3, 6]],最多有兩個區間互不相交。邊界相同不算相交。

1. 從區間集合中選出 end 最小的區間x;2.把所有和這個區間相交的區間從 intvs 中刪除;3. 重復1.2. 直到intvs 為空。

可以先排個序,這樣如果一個區間不和 x 相交的話,start必須要大於等於x_end

class Solution:
    def eraseOverlapIntervals(self, intervals: List[List[int]]) -> int:
        if not intervals:
            return 0
        n = len(intervals)
        intervals.sort(key=lambda x: x[1])  # 先按 end 排序
        
        count = 1  # 至少一個區間不相交
        x_end = intervals[0][1]
        for i in range(1, n):
            if intervals[i][0] >= x_end:  # 如果一個區間的start大於等於x_end,那么區間必然不相交x,計數並且更新x即可
                count += 1
                x_end = intervals[i][1]
        return n-count

  

用最少的箭頭射爆氣球

在二維空間中有許多球形的氣球。對於每個氣球,提供的輸入是水平方向上,氣球直徑的開始和結束坐標。由於它是水平的,所以y坐標並不重要,因此只要知道開始和結束的x坐標就足夠了。開始坐標總是小於結束坐標。平面內最多存在104個氣球。

一支弓箭可以沿着x軸從不同點完全垂直地射出。在坐標x處射出一支箭,若有一個氣球的直徑的開始和結束坐標為 xstart,xend, 且滿足  xstart ≤ x ≤ xend,則該氣球會被引爆。可以射出的弓箭的數量沒有限制。 弓箭一旦被射出之后,可以無限地前進。我們想找到使得所有氣球全部被引爆,所需的弓箭的最小數量。

這題和區間調度問題一摸一樣,如果最多有n個不重疊區間,就至少需要n個箭頭射爆氣球。

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM