分治算法學習
1. 遞歸與分治
1.1 遞歸
遞去,歸來。
-
能夠用遞歸解決的問題需要滿足三個條件:
-
原問題可以轉換為一個或多個子問題來求解,而這些子問題的求解方法和原問題完全相同,只是規模不同;
-
遞歸調用次數必須是有限的;
-
必須有結束遞歸的條件(遞歸出口)來終止遞歸。
-
-
何時使用遞歸:
-
定義是遞歸的(斐波那契);
-
數據結構是遞歸的(二叉樹、鏈表);
-
問題求解的方法是遞歸的。
-
-
遞歸轉非遞歸:
-
通常,尾遞歸可以轉換為等價的非遞歸算法。
-
對於非尾遞歸的情況,可以在理解遞歸的實現的基礎上 使用棧來模擬
-
-
設計遞歸算法:
-
先求解問題的遞歸模型。
-
在設計遞歸算法的時候,如果糾結遞歸樹的每一個階段的話,就會極為復雜。因此,只考慮遞歸樹中的第一層和第二層之間的關系即可,即“大問題” 和 “小問題” 的關系,其他關系類似。
-
求解問題的遞歸模型:
-
對原問題
f(n)
進行分析,假設出合理的小問題f(n-1)
; -
假設小問題
f(n-1)
是可解的,在此基礎上確定大問題f(n)
的解,即給出f(n)
與f(n-1)
之間的關系,也就是確定了遞歸體。(與數學歸納法中確定i=n-1
i=n
時等式成立的過程相似) -
確定一個特定情況(如
f(0)
或f(1)
)的解,由此作為遞歸出口。(與數學歸納法中求證i=0
或i=1
時等式成立相似)
-
-
如,求數組中最小值:
#可轉換為:當前元素 和 它前面所有元素中的最小值 相比較,這是一個遞歸的問題。 def Min(arr:list): if len(arr) <= 1: #遞歸出口:只含有一個元素,即認為最小(特定問題的解) return arr[0] m = Min(arr[:-1]) #假設f(n-1)是可解的,即求出來了arr[:-1]中的最小值 if m < arr[-1]: #然後將最後一個元素與“假設存在的”最小值比較 return m else: return arr[-1]
-
1.2 分治法
分治法就是:把一個復雜的問題 分解成兩個或更多的相同或相似的子問題,再把子問題分成更小的子問題……直到最後子問題可以簡單的 直接求解 (各個擊破),原問題的解即 子問題的解的合並。
-
因此分治法的步驟為:
-
第一步 分: 將原來復雜的問題分解為若幹個規模較小、相互獨立、與原問題形式相同的子問題,分解到可以直接求解為止。
-
第二步 治: 此時可以直接求解。
-
第三步 合: 將小規模的問題的解合並為一個更大規模的問題的解,自底向上 逐步求出原來問題的解。
-
-
算法的設計模式:
Divide_and_Conquer(P){ if(xxx) //遞歸出口:如果規模足夠小,克制直接求解,則開始“治” return ADHOC(P); //ADHOC是治理可直接求解子問題的子過程 <divide P into smaller subinstances P1,P2,...Pk>; //將P“分”解為k個子問題 for(int i = 0; i < k; ++i) yi = Divide_and_Conquer(Pi); //遞歸求解各個子問題 return merge(y1, y2, ..., yk); //將各個子問題的解“合”並為原問題的解 }
-
設計劃分策略,把原問題P分解成K個規模較小的子問題,這個步驟是分支算法的基礎和關鍵。需要遵循兩個原則:
-
平衡子問題原則:分解出的k個子問題規模最好大致相當;
-
獨立子問題原則:分解出的k個子問題之間重疊越少越好,最好k個子問題相互獨立,不存在重疊子問題。
-
-
分治法的復雜度分析(主定理):
-
有遞推關系式
T(n) = aT(n/b) + f(n)
,其中n為問題規模,a為遞推的子問題數量,n/b 為子問題的規模(假設每個子問題的規模都一樣),f(n) 為遞推以外進行的計算工作, a>=1, b>=1 是常數。則:-
若對於常數ε >0,有 f(n) = O(nlogb(a)-ε) ,ε > 0,則 T(n) = Θ(nlogba) ;(O(xxx)是f(n)的上界,T(n)的漸進階為較大的那個,較大的就是 nlogba,因此T(n)就等於它)
-
若 f(n) = θ(nlogba) ,則 T(n) = Θ(nlogba·logn) (小θ表示同階)
-
若常數 ε > 0,有 f(n) = Ω(nlogb(a)+ε) ,且對於某常數 c > 1 和所有充分大的額正整數n有
af(n/b) <= cf(n)
,則 T(n) = Θ(f(n)) 。(Ω(xxx)是f(n)的下界,T(n)的漸進階為較大的那個,較大的就是f(n),因此T(n)就等於它)
-
-
例1:
-
求 T(n) = 9T(n/3) + n 漸進階(Θ) :
-
a = 9, b = 3, f(n) = n; n^(logba) = n^2; 取 ε = 0.1 ,f(n) = O(nlogb(a) - ε) = O(n^1.9),因此 T(n) = Θ(n^2)
-
-
例2:
-
T(n) = T(2n/3) + 1
-
a = 1, b = 3/2, f(n) = 1, n^(logba) = n^0 = 1, f(n) = 1, f(n) 與 n^(logba) 同階,則 T(n) = Θ(logn)
-
-
例3:
-
T(n) = 2T(n/2) + nlogn
-
a = 2, b= 2, f(n) = nlogn, n^(logba) = n^1 = n, f(n) = nlogn,n^(logba) 是 f(n) 的下限,但不滿足 f(n) = Ω(nlogb(a)+ε) ,因此無法應用Master定理。
-
-
-
例:分治 - 求和
def sum(arr:list): if len(arr) == 1: return arr #“治” 直接求解 mid = len(arr)//2 l = arr[:mid] r = arr[mid:] #“分” 分解成兩個子問題 return sum(l) + sum(r) #“合” 自底向上求子數組l和r的和 ? sum([1,2,3,4]) #10
-
例:分治 - 歸並排序
對於一個含有很多元素的數組,直接排序不容易,但如果分解成多個規模很小的數組(只有一個元素),那麽這時候兩個子數組排序(歸並)就很容易了。
def merge(arr:list, low:int, mid:int, high:int): l = arr[low:mid] r = arr[mid:high] i = j = 0 # 用於叠代子序列l、r k = low len_l = mid-low len_r = high-mid while i < len_l or j < len_r: if j == len_r or (i < len_l and l[i] <= r[j]): arr[k] = l[i] k += 1 i += 1 else: arr[k] = r[j] k += 1 j += 1 ? def mergeSort(arr:list, low:int, high:int): if high-low == 1: #一個元素本身已經有序 return arr #“治” 直接求解 mid = (high+low) // 2 mergeSort(arr, low, mid) mergeSort(arr, mid, high) #“分” 分解成兩個子問題 merge(arr, low, mid, high) #“合” 自底向上歸並兩個有序子數組 arr = [3,1,2,4,5] mergeSort(arr, 0, len(arr)) #[1,2,3,4,5]
-
例:分治 - 求逆序對數目
-
如,
{3, 1, 2, 4}
中逆序對有<3,1> <3,2>
-
如果枚舉的話,時間復雜度是O(n^2)。可以借用歸並排序(O(nlogn))。
-
如果數組只有一個元素,逆序對為0;
-
原數組A中逆序對可以分為三個部分:第一部分為子數組A1中的逆序對數C1;第二部分為A2中的C2;第三部分是一個元素在A1,而另一個元素在A2,這兩個元素構成一個逆序對,C3。
-
對於C3,可以借助歸並的過程判斷。
-
def merge(arr:list, low:int, mid:int, high:int): num = 0 #逆序對數 l = arr[low:mid] r = arr[mid:high] i = j = 0 # 用於叠代子序列l、r k = low len_l = mid-low len_r = high-mid while i < len_l or j < len_r: if j == len_r or (i < len_l and l[i] <= r[j]): arr[k] = l[i] k, i = k + 1, i + 1 else: arr[k] = r[j] k, j = k + 1, j + 1 if i != len_l: #判斷是因為,當i==len_l時,就會多加 num += 1 #【如果 l[i] < r[j] 則存在逆序對】 return num ? def mergeSort(arr:list, low:int, high:int): if high-low == 1: #一個元素本身已經有序 return 0 #“治” 直接求解,只含有一個元素的數組的逆序對的數目為0 mid = (high+low) // 2 C1 = mergeSort(arr, low, mid) C2 = mergeSort(arr, mid, high) #“分” 分解成兩個子問題 C3 = merge(arr, low, mid, high) #“合” 自底向上歸並兩個有序子數組 return C1 + C2 + C3 arr = [3,1,2,4,5] mergeSort(arr, 0, len(arr)) #3
-
-
例:分治 - 快速排序
思想:通過一趟排序將要排序的數據分割成獨立的兩部分,其中一部分的所有數據都比另外一部分的所有數據都要小,然後再按此方法對這兩部分數據分別進行快速排序,整個排序過程可以遞歸進行,以此達到整個數據變成有序序列。
-
過程:
-
分: 對於一個整數集合
A = {a[l], ..., a[r]}
,以 a[l] 為基準將A劃分為三個部分:A1、A2、A3,其中A2 = {a[l]}
,A1所有元素都小於等於A2,A3所有元素都小於A1。然後分別對A1、A3進行遞歸處理,從而使得整個集合有序。 -
治: 如果劃分後的A1和A3只含有一個元素,則已經有序,直接返回即可。
-
合: 因為在分的過程中A1和A3已經排好序了,所以A1、A2和A3已經有序了,在合並階段無需處理。
-
def partition(arr:list, low:int, high:int): base = arr[low] # 備份基準元素,現在arr[low]空出來了 while low < high: while (low < high) and (arr[high] > base): #從右至左找到一個比基準元素小的 high -= 1 arr[low] = arr[high] #arr[low]已經空出,將比基準元素小的arr[high]填入空出的位置,然後arr[high]空出 while (low < high) and (arr[low] <= base): #從左至右找到一個比基準元素大的 low += 1 arr[high] = arr[low] #arr[high]已經空出,將比基準元素大的arr[low]填入空出的位置,然後arr[low]空出 arr[low] = base #while終止時 high==low,最後終止的位置,一定是high或low空出的(因為內while終止時,是被另一個位置拿走一個值,然後空出),所以可以直接將基準元素填入 return high ? def quickSort(arr:list, low:int, high:int): if low < high: k = partition(arr, low, high) quickSort(arr, low, k - 1) quickSort(arr, k + 1, high) ? ### === 其他劃分思路 === ### 雙路快排【困擾了半個月】 def partition(arr, l, r): base = arr[l] ll = l #l += 1 #不能加!加了之後只有兩個元素的情況無法進入循環。如以下兩種: # [1,2] # [2,1] while l < r: while l < r and arr[r] > base: #必須從右端開始,因為只有這樣,循環因為l<r的條件不符合時終止的位置的元素才能比base小。如果先從左端開始的話,最後l==r,r此時指向的元素極有可能大於base,因此最後交換的話,比base大的值會放到base的左邊。如[3,4,2,1,5]畫圖可看出。 r -= 1 while l < r and arr[l] <= base: l += 1 arr[l], arr[r] = arr[r], arr[l] arr[ll], arr[r] = arr[r], arr[ll] return r ? ? ### ll+1 ~ l 是小於base的區域 def pt(arr, l, r): ll = l base = arr[l] for i in range(ll+1, r+1): if arr[i] < base: l += 1 arr[l], arr[i] = arr[i], arr[l] arr[ll], arr[l] = arr[l], arr[ll] return l
(圖:學堂在線鄧俊輝的老師MOOC )
-
-
性能分析:
-
不穩定排序:因為low和high移動方向相反,左右側的相同大小的元素可能顛倒。
-
就地(空間復雜度O(1))。
-
最好情況時間復雜度:每次劃分都(接近)平均,軸點總是(接近)中點;復雜度為
T(n) = 2·T((n-1)/2) + O(n) = O(nlogn)
(減一是中間的軸點沒包括)。 -
最壞情況:每次劃分都極不均衡,復雜度為
T(n) = T(n-1) + T(0) + O(n) = O(n^2)
。 -
平均情況:
O(nlogn)
。
-
分治算法學習