1. 程式人生 > >python常用演算法(7)——動態規劃,回溯法

python常用演算法(7)——動態規劃,回溯法

引言:從斐波那契數列看動態規劃

  斐波那契數列:Fn = Fn-1 + Fn-2    ( n = 1,2     fib(1) = fib(2) = 1)

練習:使用遞迴和非遞迴的方法來求解斐波那契數列的第 n 項

  程式碼如下:

# _*_coding:utf-8_*_

def fibnacci(n):
    if n == 1 or n == 2:
        return 1
    else:
        return fibnacci(n - 1) + fibnacci(n - 2)

# 寫這個是我們會發現計算f(5) 要算兩邊f(4) 
# f(5) = f(4)+f(3)
# f(4) = f(3)+f(2)
# f(3) = f(2)+f(1)
# f(3) = f(2)+f(1)
# f(2) = 1
# 那麼同理,算f(6),我們會計算兩次f(5),三次f(4)....
# 當然不是說所有的遞迴都會重複計算,

# 時間隨著數字越大,時間越長
print(fibnacci(10))  # 55


def fibnacci_n_recurision(n):
    f = [0, 1, 1]
    if n > 2:
        for i in range(n - 2):
            num = f[-1] + f[-2]
            f.append(num)
    return f[n]


print(fibnacci_n_recurision(10))

  為了讓我們的說服更有理一些,這裡寫了一個裝飾器,我們通過執行時間看。同樣對於上面兩個函式,一個遞迴,一個非遞迴,我們輸入 n=15

# cal_time.py 函式程式碼如下:

import time

def cal_time(func):
    def wrapper(*args, **kwargs):
        t1 = time.time()
        result = func(*arg, **kwargs)
        t2 = time.time()
        print("%s running time: %s secs." % (func.__name__, t2 - t1))
        return result
    return wrapper
    

執行結果:

fibnacci running time: 0.01000070571899414 secs.
fibnacci_n_recurision running time: 0.0 secs.

  總結來說,就是遞迴非常非常的慢,那非遞迴相對來說就比較快了。那為什麼呢?就是為什麼遞迴的效率低。我們上面程式碼也說過了,就是對子問題進行重複計算了。那第二個函式為什麼快呢,我們將每次的計算結果存在了函式裡,直接呼叫,避免了重複計算(當然不是說所有的遞迴都會重複計運算元問題),第二個函式我們其實可以看做是動態規劃的思想,從上面的程式碼來看:

  動態規劃的思想==遞推式+重複子問題

  怎麼理解呢,下面繼續學習。

1,什麼是動態規劃

  動態規劃(dynamic programming)是運籌學的一個分支,是求解決策過程(decision process)最優化的數學方法。把多階段過程轉化為一系列單階段問題,利用各階段之間的關係,逐個求解,創立了解決這類過程優化問題的新方法——動態規劃。

1.1,使用動態規劃特徵

  • 1. 求一個問題的最優解 
  • 2. 大問題可以分解為子問題,子問題還有重疊的更小的子問題 
  • 3. 整體問題最優解取決於子問題的最優解(狀態轉移方程) 
  • 4. 從上往下分析問題,從下往上解決問題 
  • 5. 討論底層的邊界問題

1.2,動態規劃的基本思想

  若要解一個給定問題,我們需要解其不同部分(即子問題),再合併子問題的解以得出原問題的解。通常許多子問題非常相似,為此動態規劃法試圖僅僅解決每個子問題一次,從而減少計算量:一旦某個給定子問題的解已經算出,則將其記憶化儲存,以便下次需要同一個子問題解之時直接查表。這種做法在重複子問題的數目關於輸入的規模呈指數增長時特別有效。

  動態規劃最重要的有三個概念:1、最優子結構 2、邊界 3、狀態轉移方程

  所以我們在學習動態規劃要明白三件事情:

1,目標問題

2,狀態的定義:opt[n]

3,狀態轉移方差:opt[n] = best_of(opt[n-1], opt[n-2])

 

2,鋼條切割問題

  某公司出售鋼條,出售價格與鋼條長度直接的關係如下表:

   問題:現在有一條長度為 n 的鋼條和上面的價格表,求切割鋼條方案,使得總收益最大。

  分析:長度為4的鋼條的所有切割方案如下:(C方案最優)

   思考:長度為 n 的鋼條的不同切割方案有幾種?

  下面是當長度為n的時候,最優價格的表格( i 表示長度為 n ,r[i] 表示最優價格)

2.1,遞推式解決鋼條切割問題

  設長度為 n 的鋼條切割後最優收益值為 Rn,可以得到遞推式:

  第一個引數Pn 表示不切割

  其他 n-1個引數分別表示另外 n-1種不同切割方案,對方案 i=1,2,...n-1 將鋼條切割為長度為 i 和 n-i 兩段

  方案 i  的收益為切割兩段的最優收益之和,考察所有的 i,選擇其中收益最大的方案

2.2,最優子結構解決鋼條切割問題

  可以將求解規模為 n 的原問題,劃分為規模更小的子問題:完成一次切割後,可以將產生的兩段鋼條看成兩個獨立的鋼條切割問題。

  組合兩個子問題的最優解,並在所有可能的兩段切割方案中選取組合收益最大的,構成原問題的最優解。

  鋼條切割滿足最優子結構:問題的最優解由相關子問題的最優解組合而成,這些子問題可以獨立求解。

  鋼條切割問題還存在更簡單的遞迴求解方法:

  • 從鋼條的左邊切割下長度為 i 的一段,只對右邊剩下的一段繼續進行切割,左邊的不再切割
  • 遞推式簡化為:
  • 不做切割的方案就可以描述為:左邊一段長度為 n,收益為 pn,剩下一段長度為0,收益為 r0=0.

2.3,鋼條切割問題——自頂向下遞迴程式碼及其時間複雜度

  程式碼如下:

def _cut_rod(p, n):
    if n == 0:
        return 0
    q = 0
    for i in range(1, n+1):
        q = max(q, p[i] + _cut_rod(p, n-i))
    return q

  如下圖所示,當鋼條的長度增加時候,切割的方案次數隨著指數增加。當n=1的時候切割1次,n=2的時候切割2次,n=3的時候切割4次,n=4的時候切割8次。。。。

  所以:自頂向下遞迴實現的時間複雜度為 O(2n)

2.4,兩種方法的程式碼實現

  程式碼如下:

# _*_coding:utf-8_*_
import time


# 給遞迴函式一個裝飾器,它就遞迴的裝飾!! 所以為了防止這樣我們再套一層即可
def cal_time(func):
    def wrapper(*args, **kwargs):
        t1 = time.time()
        result = func(*args, **kwargs)
        t2 = time.time()
        print('%s running time : %s secs' % (func.__name__, t2 - t1))
        return result

    return wrapper


# p = [0, 1, 5, 8, 9, 10, 17, 17, 20, 21, 23, 24, 26, 27, 28, 30, 33, 36, 39, 40]
p = [0, 1, 5, 8, 9, 10, 17, 17, 20, 24, 30]


def cut_rod_recurision_1(p, n):
    if n == 0:
        return 0
    else:
        res = p[n]
        for i in range(1, n):
            res = max(res, cut_rod_recurision_1(p, i) + cut_rod_recurision_1(p, n - i))
        return res


# print(cut_rod_recurision_1(p, 9))


def cut_rod_recurision_2(p, n):
    if n == 0:
        return 0
    else:
        res = 0
        for i in range(1, n + 1):
            res = max(res, p[i] + cut_rod_recurision_2(p, n - i))
        return res


# print(cut_rod_recurision_2(p, 9))

@cal_time
def c1(p, n):
    return cut_rod_recurision_1(p, n)

@cal_time
def c2(p, n):
    return cut_rod_recurision_2(p, n)


print(c1(p, 10))
print(c2(p, 10))
'''
c1 running time : 0.02000117301940918 secs
30
c2 running time : 0.0010001659393310547 secs
30
'''

  我們通過計算時間,發現第二個遞迴方法明顯比第一個遞迴方法快很多。那麼是否還有更簡單的方法呢?肯定有,下面學習動態規劃。

2.5,動態規劃解決鋼條切割問題

  遞迴演算法由於重複求解相同子問題,效率極低。即使優化過後的遞迴也效率不高。那這裡使用動態規劃。

  動態規劃的思想:

  1. 每個子問題只求解一次,儲存求解結果
  2. 之後需要此問題時,只需要查詢儲存的結果

  動態規劃解法程式碼:

def cut_rod_dp(p, n):
    r = [0 for _ in range(n+1)]
    for j in range(1, n+1):
        q = 0
        for i in range(1, j+1):
            q = max(q, p[i]+r[j-i])
        r[j] = q
    return r[n]

  或者便於理解這樣:

def cut_rod_dp(p, n):
    r = [0]
    for i in range(1, n+1):
        res = 0
        for j in range(1, i+1):
            res = max(res, p[j]+r[i-j])
        r.append(res)
    return r[n]

  時間複雜度: O(n2)

2.6,鋼條切割問題——重構解

  如何修改動態規劃演算法,使其不僅輸出最優解,還輸出最優切割方案?

  對於每個子問題,儲存切割一次時左邊切下的長度

  下圖為r[i] 表示最優切割的價格,s[i]表示切割左邊的長度。

   程式碼如下:

def cut_rod_extend(p, n):
    r = [0]
    s = [0]
    # 這個迴圈的意思是從底向上計算
    for i in range(1, n+1):
        res_r = 0  # 用來記錄價格的最優值
        res_s = 0  # 用來記錄切割左邊的最優長度
        for j in range(1, i+1):
            if p[j] + r[i-j] > res_r:
                res_r = p[j] + r[i-j]
                res_s = j
        r.append(res_r)
        s.append(res_s)
    return r[n], s

def cut_rod_solution(p, n):
    r, s = cut_rod_extend(p, n)
    ans = []
    while n>0:
        ans.append(s[n])
        n-= s[n]
    return ans


print(cut_rod_extend(p, 10))
# (30, [0, 1, 2, 3, 2, 2, 6, 1, 2, 3, 10])
print(cut_rod_solution(p, 9))
# [3, 6]

2.7,什麼問題可以使用動態規劃方法?

  1,最優子結構

  • 原問題的最優解中涉及多少個子問題
  • 在確定最優解使用那些子問題時,需要考慮多少種選擇

  2,重疊子問題

 

3,最長公共子序列

  一個序列的子序列是在該序列中刪去若干元素後得到的序列。例如:ABCD 和 BDF 都是 ABCDEFG 的子序列。

  在一個序列中,子串是連續的,子序列可以不連續。

  最常公共子序列(LCS)問題:給定兩個序列 X 和 Y,求 X 和 Y 長度最大的公共子序列。例如 X = ABBCBDE,  Y = DBBCDB ,  LCS(X, Y) = BBCD 。

   應用場景:字串相似度比對。

3.1,最長公共子序列的思路——暴力窮舉法

  當X的長度為m,Y的長度為n,則時間複雜度為: 2^(m+n) 

  雖然我們最先想到的時暴力窮舉法,但是很顯然,由其時間複雜度可知,這是不可取的。

3.2,最長公共子序列的思路——LCS是否具有最優子結構性質

  例如:要求 a = ABCBDAB  與 b = BDCABA 的LCS:

  由於最後一位 B!= A 

  因此LCS(a, b)應該來源於  LCS(a[: -1], b)與 LCS(a, b[: -1]) 中更大的哪一個。

   最優解的遞推式如下:

   c[i,j] 表示 Xi 和 Yj 的LCS 長度。

3.3,最長公共子序列的程式碼

   程式碼如下:

def lcs_length(x, y):
    m = len(x)
    n = len(y)
    c = [[0 for _ in range(n + 1)] for _ in range(m + 1)]
    for i in range(1, m + 1):
        for j in range(1, n + 1):
            if x[i - 1] == y[j - 1]:  # i,j位置上的字元匹配的時候,來自於左上方
                c[i][j] = c[i - 1][j - 1] + 1
            else:
                c[i][j] = max(c[i - 1][j], c[i][j - 1])

    # 逐行列印
    for _ in c:
        print(_)
    return c[m][n]


# 最優值出來了,但是過程沒有出來,也就是隻有最長,不知道公共子序列
# print(lcs_length("ABCBDAB", "BDCABA"))
'''
[0, 0, 0, 0, 0, 0, 0]
[0, 0, 0, 0, 1, 1, 1]
[0, 1, 1, 1, 1, 2, 2]
[0, 1, 1, 2, 2, 2, 2]
[0, 1, 1, 2, 2, 3, 3]
[0, 1, 2, 2, 2, 3, 3]
[0, 1, 2, 2, 3, 3, 4]
[0, 1, 2, 2, 3, 4, 4]
4
'''


def lcs(x, y):
    m = len(x)
    n = len(y)
    c = [[0 for _ in range(n + 1)] for _ in range(m + 1)]
    # 1左上方 2上方 3 左方
    b = [[0 for _ in range(n + 1)] for _ in range(m + 1)]
    for i in range(1, m + 1):
        for j in range(1, n + 1):
            if x[i - 1] == y[j - 1]:  # i,j位置上的字元匹配的時候,來自於左上方
                c[i][j] = c[i - 1][j - 1] + 1
                b[i][j] = 1
            elif c[i - 1][j] > c[i][j - 1]:  # 來自於上方
                c[i][j] = c[i - 1][j]
                b[i][j] = 2
            else:
                c[i][j] = c[i][j - 1]
                b[i][j] = 3

    return c[m][n], b


# c, b = lcs("ABCBDAB", "BDCABA")
# for _ in b:
#     print(_)
'''
[0, 0, 0, 0, 0, 0, 0]
[0, 3, 3, 3, 1, 3, 1]
[0, 1, 3, 3, 3, 1, 3]
[0, 2, 3, 1, 3, 3, 3]
[0, 1, 3, 2, 3, 1, 3]
[0, 2, 1, 3, 3, 2, 3]
[0, 2, 2, 3, 1, 3, 1]
[0, 1, 2, 3, 2, 1, 3]
'''


def lcs_trackback(x, y):
    c, b = lcs(x, y)
    i = len(x)
    j = len(y)
    res = []
    while i > 0 and j > 0:
        if b[i][j] == 1:  # 來自左上方 =》匹配
            res.append(x[i - 1])
            i -= 1
            j -= 1
        elif b[i][j] == 2:  # 來自上方=》 不匹配
            i -= 1
        else:  # ==3  來自左方 =》不匹配
            j -= 1
    # 因為是回溯法,所以倒著寫的,我們最後需要reverse回來
    return "".join(reversed(res))

print(lcs_trackback("ABCBDAB", "BDCABA"))
# BDAB

  

4,最大子序和

  給定一個整數陣列 nums ,找到一個具有最大和的連續子陣列(子陣列最少包含一個元素),返回其最大值。

  示例:輸入:[-2, 1, -3, 4, -1, 2, 1, -5, 4]   輸出:輸出:6

  思路:我們首先分析題目,為什麼最大和的連續子陣列不包括其他的元素而是這幾個呢?如果我們想在現有的基礎上去擴充套件當前連續子陣列,相鄰的元素是一定要被加入的,而相鄰元素可能會減損當前的和。

4.1,遍歷法

  演算法過程:遍歷陣列,用 sum 去維護當前元素加起來的和,當 sum 出現小於0的情況時,我們把它設為0,然後每次都更新全域性最大值。

 def maxSubArray(self, nums: List[int]) -> int:
        sum = 0
        MaxSum = nums[0]
        for i in range(len(nums)):
            sum += nums[i]
            MaxSum = max(sum, MaxSum)
            if sum <0:
                sum = 0
        return MaxSum

  那看起來這麼簡單,如何理解呢?一開始思考陣列是個空的,我們每次選一個 nums[i] 加入當前陣列中新增了一個元素,也就是用動態的眼光去考慮。程式碼簡單為什麼就能達到效果呢?

  我們進行的加和是按照順序來的,當我們i 選出來後,加入當前 sum,這時候有兩種情況:

1,假設我們當前 sum 一致都大於零,那每一次計算的 sum 都是包括開頭元素的一端子序列。

2,出現小於0的時候,說明我們當前子序列第一次小於零,所以我們現在形成的連續陣列不能包括之前的連續子序了,於是拋棄他們,重新開始新的子序。

4.2,動態規劃

  設sum[i]為以第i個元素結尾的最大的連續子陣列的和。假設對於元素i,所有以它前面的元素結尾的子陣列的長度都已經求得,那麼以第i個元素結尾且和最大的連續子陣列實際上,要麼是以第i-1個元素結尾且和最大的連續子陣列加上這個元素,要麼是隻包含第i個元素,即sum[i]= max(sum[i-1] + a[i], a[i])。可以通過判斷sum[i-1] + a[i]是否大於a[i]來做選擇,而這實際上等價於判斷sum[i-1]是否大於0。由於每次運算只需要前一次的結果,因此並不需要像普通的動態規劃那樣保留之前所有的計算結果,只需要保留上一次的即可,因此演算法的時間和空間複雜度都很小。

  程式碼如下:

def maxSubArray4(self, nums: List[int]) -> int:
    length = len(nums)
    for i in range(1, length):
        # 當前值的大小與前面的值之和比較,若當前值更大,則取當前值,捨棄前面的值之和
        subMaxSum = max(nums[i]+nums[i-1], nums[i])
        # 將當前和最大的賦給 nums[i], 新的nums 儲存的為何值
        nums[i] = subMaxSum
    return max(nums)

  只要遍歷一遍。nums[i]表示的是以當前這第i號元素結尾(看清了一定要包含當前的這個i)的話,最大的值無非就是看以i-1結尾的最大和的子序能不能加上我這個nums[i],如果nums[i]>0的話,則加上。注意我程式碼中沒有顯式地去這樣判斷,不過我的Max表達的就是這個意思,然後我們把nums[i]確定下來。

  動態規劃需要和回溯法搭配著使用,動態規劃只負責求最優解,而回溯法則可以找到最優值的路徑。

5,回溯法

  回溯法是一種選優搜尋法,按選優條件向前搜尋,以達到目標。但當探索到某一步時,發現原先選擇並不優或達不到目標,就退回一步重新選擇,這種走不通就退回再走的技術為回溯法,而滿足回溯條件的某個狀態的點稱為 “回溯點”。許多複雜的,規模較大的問題都可以使用回溯法,有“通用解題方法”的美稱。回溯法有“通用的解題法”之稱,也叫試探法,它是一種系統的搜尋問題的解的方法。簡單來說,回溯法採用試錯的方法解決問題,一旦發現當前步驟失敗,回溯方法就返回上一個步驟,選擇另一種方案繼續試錯。

5.1  回溯法的基本思想

  從一條路往前走,能進則進,不能進則退回來,換一條路再試。

5.2  回溯法的一般步驟

1,定義一個解空間(子集樹,排序樹二選一)

2,利用適用於搜尋的方法組織解空間

3,利用深度優先法搜尋解空間

4,利用剪枝函式避免移動到不可能產生解的子空間

5.3  回溯法針對問題的特點

  回溯演算法針對的大多數問題有以下特點:問題的答案有多個元素(可向下成走迷宮是有多個決定),答案需要一些約束(比如數獨),尋找答案的方式在每一個步驟相同。回溯演算法逐步構建答案,並在確定候選元素不滿足約束後立刻放棄候選元素(一旦碰牆就返回),直到找到答案的所有元素。  

5.4回溯法題目——查詢單詞

  問題描述:你玩過報紙上那種查詢單詞的遊戲嗎?就是那種在一堆字母中橫向或豎向找出單詞的遊戲。小明在玩一個和那個很像的遊戲,只不過現在不僅可以上下左右連線字母,還可以拐彎。如圖所示,輸入world,就會輸出“找到了”。

 

 

5.5  回溯法題目——遍歷所有的排列方式

  問題描述:小米最近有四本想讀的書:《紅色的北京》,《黃色的巴黎》,《藍色的夏威夷》,《綠色的哈薩里》,如果小明每次只能從圖書館借一本書,他一共有多少種借書的順序呢?

   回溯法是一種通過探索所有可能的候選解來找出所欲的解的演算法。如果候選解被確認,不是一個解的話(或者至少不是最後一個解),回溯演算法會通過在上一步進行一些變換排期該解。即回溯並且再次嘗試。

  這裡有一個回溯函式,使用第一個整數的索引作為引數  backtrack(first)。

1,如果第一個整數有索引 n,意味著當前排列已完成。

2,遍歷索引 first 到索引 n-1 的所有整數 ,則:

  • 在排列中放置第 i 個整數,即 swap(nums[first], nums[i])
  • 繼續生成從第 i 個整數開始的所有排列:backtrack(first +1)
  • 現在回溯,通過 swap(nums[first], nums[i]) 還原。

   程式碼如下:

class Solution:
    def permute(self, nums):
        """
        :type nums: List[int]
        :rtype: List[List[int]]
        """
        def backtrack(first = 0):
            # if all integers are used up
            if first == n:  
                output.append(nums[:])
            for i in range(first, n):
                # place i-th integer first 
                # in the current permutation
                nums[first], nums[i] = nums[i], nums[first]
                # use next integers to complete the permutations
                backtrack(first + 1)
                # backtrack
                nums[first], nums[i] = nums[i], nums[first]
        
        n = len(nums)
        output = []
        backtrack()
        return output

  便於理解的程式碼如下:

class solution:
    def solvepermutation(self, array):
        self.helper(array, [])

    def helper(self, array, solution):
        if len(array) == 0:
            print(solution)
            return
        for i in range(len(array)):
            newarray = array[:i] + array[i + 1:]  # 刪除書本
            newsolution = solution + [array[i]]  # 加入新書
            self.helper(newarray, newsolution)  # 尋找剩餘物件的排列組合


solution().solvepermutation(["紅", "黃", "藍", "綠"])

  方法二:走捷徑(直接使用Python的庫函式,迭代函式itertools)

li = ['A', 'B', 'C', 'D']
def solutoin(li):
    import itertools
    res = list(itertools.permutations(li))
    return len(res)

  

5.6  回溯法問題——經典問題的組合

  問題描述:小明想上兩門選修課,他有四種選擇:A微積分,B音樂,C烹飪,D設計,小明一共有多少種不同的選課組合?

   當然第一個方法就是走捷徑!,直接使用python的庫函式itertools進行迭代:

li = ['A', 'B', 'C', 'D']
def solutoin(li):
    import itertools
    res = list(itertools.permutations(li, 2))
    return len(res)

  下面開始回溯法的學習。

class solution():
    def solvecombination(self, array, n):
        self.helper(array, n, [])

    def helper(self, array, n, solution):
        if len(solution) == n:
            print(solution)
            return
        for i in range(len(array)):
            newarray = array[i + 1:]  # 建立新的課程列表,更新列表,即選過的課程不能再選
            newsolution = solution + [array[i]]  # 將科目加入新的列表組合
            self.helper(newarray, n, newsolution)


solution().solvecombination(["A", "B", "C", "D"], 2)

  

5.7  回溯法問題——八皇后問題

  問題描述:保安負責人小安面臨一個難題,他需要在一個8x8公里的區域裡修建8個保安站點,並確保每一行、每一列和每一條斜線上都只有一個保安站點。苦惱的小安試著嘗試佈置了很多遍,但每一次都不符合要求。小安求助程式設計師小明,沒過多久小明就把好幾個佈置方案(實際上,這個問題有92種答案)發給了小安,其中包括下面執行結果截圖,試問小明是怎麼做到的。

 

6,演算法綜合作業

  這是所有的演算法學完後的綜合作業,當然這也是演算法學習的一個總結。當然下面的問題我都有涉及,這裡不做一一解答。

1. 實現以下演算法並且編寫解題報告,解題報告中需要給出題目說明、自己對
題目的理解、解題思路、對演算法的說明和理解、以及演算法複雜度分析等內容

2. 實現氣泡排序、插入排序、快速排序和歸併排序

3. 以儘可能多的方法解決2-sum問題並分析其時間複雜度:給定一個列表和
一個整數,從列表中找到兩個數,使得兩數之和等於給定的數,返回兩個數
的下標。題目保證有且只有一組解

4. 封裝一個雙鏈表類,並實現雙鏈表的建立、查詢、插入和刪除

5. 使用至少一種演算法解決迷宮尋路問題

6. 使用動態規劃演算法實現最長公共子序列問題

  

傳送門:程式碼的GitHub地址:https://github.com/LeBron-Jian/BasicAlgorithmPractice 

 

參考分治與動態規劃參考文獻:https://blog.csdn.net/weixin_41250910/article/details/94502136

https://blog.csdn.net/weixin_43482259/article/details/9799