分治策略
本文包括
- 分治的基本概念
- 二分查找
- 快速排序
- 歸并排序
- 找出偽幣
- 棋盤覆蓋
- 最大子數(shù)組
源碼鏈接:https://github.com/edisonleolhl/DataStructure-Algorithm/tree/master/Divede-and-conquer
分治的基本概念
-
在排序算法中有種算法叫做歸并排序,它采用了分治策略,在策略中,我們遞歸地求解一個(gè)問題,在每層遞歸中應(yīng)用如下三個(gè)步驟:
- 分解(Divide)步驟將問題劃分為一些子問題,子問題的形式與原問題一樣,只是規(guī)模更小。
- 解決(Conquer)步驟遞歸地求解出子問題,如果子問題的規(guī)模足夠小,則停止遞歸,直接求解。
- 合并(Combine)步驟將子問題的解組合成原問題的解。
當(dāng)子問題足夠大時(shí),需要遞歸求解,我們稱之為遞歸情況(recursive case)。
當(dāng)子問題足夠小,不需要遞歸求解時(shí),我們稱之為“觸底”,進(jìn)入了基本情況(base case)。
-
很多時(shí)候,問題看上去并不是一目了然的,能否用分治策略,可以取決于問題是否滿足以下四條特征:
- 該問題的規(guī)模縮小到一定的程度就可以容易地解決;
- 該問題可以分解為若干個(gè)規(guī)模較小的相同問題,即該問題具有最優(yōu)子結(jié)構(gòu)性質(zhì);
- 利用該問題分解出的子問題的解可以合并為該問題的解;
- 該問題所分解出的各個(gè)子問題是相互獨(dú)立的,即子問題之間不包含公共的子問題。
-
遞歸式(recurrence)就是一個(gè)等式或者不等式,比如歸并排序的最壞情況運(yùn)行時(shí)間:
{ θ(1) 若 n=1 T(n)=| { 2T(n/2)+θ(n) 若 n>1
求解可得 T(n)=θ(nlogn)
二分查找
- 我們先看個(gè)簡(jiǎn)單的例子,二分查找,假設(shè)我們想要查找 x 是否存在于已排序列 a[] 中。
百度百科:如果線性表里只有一個(gè)元素,則只要比較這個(gè)元素和x就可以確定x是否在線性表中。因此這個(gè)問題滿足分治法的第一個(gè)適用條件;同時(shí)我們注意到對(duì)于排好序的線性表L有以下性質(zhì):比較x和L中任意一個(gè)元素L[i],若x=L[i],則x在L中的位置就是i;如果x<L[i],由于L是遞增排序的,因此假如x在L中的話,x必然排在L[i]的前面,所以我們只要在L[i]的前面查找x即可;如果x>L[i],同理我們只要在L[i]的后面查找x即可。無(wú)論是在L[i]的前面還是后面查找x,其方法都和在L中查找x一樣,只不過是線性表的規(guī)模縮小了。這就說(shuō)明了此問題滿足分治法的第二個(gè)和第三個(gè)適用條件。很顯然此問題分解出的子問題相互獨(dú)立,即在L[i]的前面或后面查找x是獨(dú)立的子問題,因此滿足分治法的第四個(gè)適用條件。
-
二分查找的基本思想是將n個(gè)元素分成大致相等的兩部分,取a[n/2]與x做比較。
- 如果x=a[n/2],則找到x,算法中止;
- 如果x<a[n/2],則只需要在 a[] 的左半部分繼續(xù)搜索 x;
- 如果x>a[n/2],則只需要在 a[] 的右半部分繼續(xù)搜索 x。
容易理解,時(shí)間復(fù)雜度無(wú)非就是迭代的次數(shù),O(logn)
-
利用 while 循環(huán)的偽代碼:
BinarySearch(max,min,des) mid-<(max+min)/2 while(min<=max) mid=(min+max)/2 if mid=des then return mid elseif mid >des then max=mid-1 else min=mid+1 return
-
利用迭代的 Python 代碼:
def binary_search(A, x, low, high): if low == high: return -1 mid = (low + high) // 2 if A[mid] == x: return mid result_left = binary_search(A, x, low, mid) print("left", result_left) result_right = binary_search(A, x, mid+1, high) print("right", result_right) if result_left != -1: return result_left elif result_right != -1: return result_right else: return -1 A = list(range(10)) print(binary_search(A, 3, 0, len(A)-1))
-
輸出
left -1 right -1 left -1 right -1 left -1 right 3 left 3 left -1 right -1 left -1 right -1 left -1 left -1 right -1 right -1 right -1 3
快速排序
快速排序的基本思想是基于分治法的
-
對(duì)于輸入的子序列L[p..r],如果規(guī)模足夠小則直接進(jìn)行排序,否則分三步處理:
- 分解(Divide):將輸入的序列L[p..r]劃分成兩個(gè)非空子序列L[p..q]和L[q+1..r],使L[p..q]中任一元素的值不大于L[q+1..r]中任一元素的值。
- 遞歸求解(Conquer):通過遞歸調(diào)用快速排序算法分別對(duì)L[p..q]和L[q+1..r]進(jìn)行排序。
- 合并(Merge):由于對(duì)分解出的兩個(gè)子序列的排序是就地進(jìn)行的,所以在L[p..q]和L[q+1..r]都排好序后不需要執(zhí)行任何計(jì)算L[p..r]就已排好序。
這個(gè)解決流程是符合分治法的基本步驟的,因此,快速排序法是分治法的經(jīng)典應(yīng)用實(shí)例之一。
詳情見之前寫的排序算法:http://www.lxweimin.com/p/7cb29ad6d0f7
歸并排序
- 也是分治策略的典型應(yīng)用,具體見排序算法,文章了鏈接同上。
x^n
輸入 x 與 n,求 x^n
最樸素的方法就是把 x 連乘 x,這樣需要時(shí)間復(fù)雜度為 Θ(n)
-
但是如果把計(jì)算式分解成奇數(shù)和偶數(shù)的情況,時(shí)間復(fù)雜度降為 Θ(logn)
x^n = x^(n/2) × x^(n/2) 當(dāng) n 為偶數(shù) x^(n-1/2) × x^(n-1/2) × 當(dāng) n 為奇數(shù)
找出偽幣
給你一個(gè)裝有16個(gè)硬幣的袋子。16個(gè)硬幣中有一個(gè)是偽造的,并且那個(gè)偽造的硬幣比真的硬幣要輕一些。你的任務(wù)是找出這個(gè)偽造的硬幣。為了幫助你完成這一任務(wù),將提供一臺(tái)可用來(lái)比較兩組硬幣重量的儀器,利用這臺(tái)儀器,可以知道兩組硬幣的重量是否相同。
-
可以想到,一個(gè)很簡(jiǎn)單的方法就是暴力枚舉法:
比較硬幣1與硬幣2的重量。假如硬幣1比硬幣2輕,則硬幣1是偽造的;假如硬幣2比硬幣1輕,則硬幣2是偽造的。這樣就完成了任務(wù)。
假如兩硬幣重量相等,則比較硬幣3和硬幣4。同樣,假如有一個(gè)硬幣輕一些,則尋找偽幣的任務(wù)完成。假如兩硬幣重量相等,則繼續(xù)比較硬幣5和硬幣6。按照這種方式,可以最多通過8次比較來(lái)判斷偽幣的存在并能夠找出這一偽幣。
-
另外一種方法就是利用分而治之的方法:
假如把16硬幣的例子看成一個(gè)大的問題。
第一步,把這一問題分成兩個(gè)小問題。隨機(jī)選擇8個(gè)硬幣作為第一組稱為A組,剩下的8個(gè)硬幣作為第二組稱為B組。這樣,就把16個(gè)硬幣的問題分成兩個(gè)8硬幣的問題來(lái)解決。
第二步,判斷A和B組中是否有偽幣。可以利用儀器來(lái)比較A組硬幣和B組硬幣的重量。假如兩組硬幣重量相等,則可以判斷偽幣不存在,此時(shí)直接結(jié)束算法。假如兩組硬幣重量不相等,則存在偽幣,并且可以判斷它位于較輕的那一組硬幣中,然后繼續(xù)把較輕組的硬幣繼續(xù)劃分為兩組,放在儀器中比較重量。一直這樣迭代下去,直至兩枚硬幣比較,較輕的那枚就是偽幣。
當(dāng)然,如果硬幣數(shù)量為奇數(shù)的話就不能這么簡(jiǎn)單了,但是這里只是為了體現(xiàn)分治的思想,所以先用偶數(shù)說(shuō)明概念。
棋盤覆蓋
-
問題描述:在一個(gè) 2k×2k 個(gè)方格組成的棋盤中,恰有一個(gè)方格與其他方格不同,稱該方格為一特殊方格,且稱該棋盤為一特殊棋盤。在棋盤覆蓋問題中,要用圖示的4種不同形態(tài)的L型骨牌覆蓋給定的特殊棋盤上除特殊方格以外的所有方格,且任何2個(gè)L型骨牌不得重疊覆蓋。
-
分析:
當(dāng) k>0 時(shí),將 2k×2k 棋盤分割為 4 個(gè) 2(k-1)×2(k-1) 的 子棋盤,如下圖 a 所示。
-
特殊方格必位于4個(gè)較小子棋盤之一中,其余3個(gè)子棋盤中無(wú)特殊方格。為了將這3個(gè)無(wú)特殊方格的子棋盤轉(zhuǎn)化為特殊棋盤,可以用一個(gè)L型骨牌覆蓋這3個(gè)較小棋盤的會(huì)合處,如 (b)所示,從而將原問題轉(zhuǎn)化為4個(gè)較小規(guī)模的棋盤覆蓋問題。遞歸地使用這種分割,直至棋盤簡(jiǎn)化為棋盤1×1。
實(shí)現(xiàn):每次都對(duì)分割后的四個(gè)小方塊進(jìn)行判斷,判斷特殊方格是否在里面。這里的判斷的方法是每次先記錄下整個(gè)大方塊的左上角(top left coner)方格的行列坐標(biāo),然后再與特殊方格坐標(biāo)進(jìn)行比較,就可以知道特殊方格是否在該塊中。如果特殊方塊在里面,這直接遞歸下去求即可,如果不在,則根據(jù)分割的四個(gè)方塊的不同位置,把右下角、左下角、右上角或者左上角的方格標(biāo)記為特殊方塊,然后繼續(xù)遞歸。這樣我們就按照要求填充了整個(gè)棋盤。
最大子數(shù)組
題目描述:輸入一個(gè)整形數(shù)組,數(shù)組里有正數(shù)也有負(fù)數(shù)。數(shù)組中連續(xù)的一個(gè)或多個(gè)整數(shù)組成一個(gè)子數(shù)組,每個(gè)子數(shù)組都有一個(gè)和。設(shè)計(jì)一種算法求出輸入數(shù)組的最大子數(shù)組以及對(duì)應(yīng)的子數(shù)組和是多少。
-
例如輸入的數(shù)組為
13,-3,-25,20,-3,-16,-23,18,20,-7,12,-5,-22,15,-4,7
那么最大的子數(shù)組為
18,20,-7,12
因此輸出該子數(shù)組
43, 7, 10
解法一:暴力求解
兩個(gè) for 循環(huán),時(shí)間復(fù)雜度 O(n^2)
思路:
-
代碼:
def di_cal_wrong(A): max_sub_sum = -float('inf') # init for i in range(len(A)): for j in range(i+1, len(A)): if sum(A[i:j+1]) > max_sub_sum: max_sub_sum = sum(A[i:j+1]) low = i high = j return(max_sub_sum, low, high) A = [13,-3,-25,20,-3,-16,-23,18,20,-7,12,-5,-22,15,-4,7] print(di_cal(A))
-
輸出:
(43, 7, 10)
思考:上述代碼有錯(cuò)嗎?
Python 列表的切片操作,如果放在類似于 Java 、C 之類的語(yǔ)言中,想要實(shí)現(xiàn)的話得通過 for 循環(huán)然后依次累加,這時(shí)豈不是3個(gè) for 循環(huán)嵌套了?
更正:每當(dāng) j 增加1后,令 sum = sum + A[J],然后再比較 sum 是否小于 max_sub_sum,這樣就實(shí)現(xiàn)了兩次 for 循環(huán)嵌套。
-
更正后的代碼:
def di_cal(A): sum = A[0] max_sub_sum = -float('inf') # init for i in range(len(A)): sum = A[i] for j in range(i+1, len(A)): sum += A[j] if sum > max_sub_sum: max_sub_sum = sum low = i high = j return(max_sub_sum, low, high)
-
利用 timeit 模塊測(cè)試兩個(gè)函數(shù)的性能
print(di_cal_wrong(A)) print(di_cal(A)) t1 = Timer("di_cal([13,-3,-25,20,-3,-16,-23,18,20,-7,12,-5,-22,15,-4,7])", "from __main__ import di_cal") print("di_cal ", t1.timeit(number=1000), "seconds") t1 = Timer("di_cal_wrong([13,-3,-25,20,-3,-16,-23,18,20,-7,12,-5,-22,15,-4,7])", "from __main__ import di_cal_wrong") print("di_cal_wrong ", t1.timeit(number=1000), "seconds")
-
輸出
(43, 7, 10) (43, 7, 10) di_cal 0.017742687098883683 seconds di_cal_wrong 0.05528770369116012 seconds
可以看到,很明顯 di_cal_wrong(A) 代碼的性能要差于 di_cal(A),所以實(shí)現(xiàn)算法時(shí)一定要細(xì)心,Python 容易使用,但是不能把集成好的功能當(dāng)作基本操作來(lái)使用。
解法二:分治策略
假定我們要尋找 A[low..high] (假設(shè) low<high)的最大子數(shù)組,使用分治策略意味著要將這個(gè)數(shù)組分解為兩個(gè)規(guī)模盡量相等的子數(shù)組,即找到中央位置:mid=(low+high)/2。
然后分解為兩個(gè)子數(shù)組:A[low..mid],A[mid+1..high]
-
可以想象,A[low..high] 的最大子數(shù)組必然是以下三種情況之一
- 完全位于左邊子數(shù)組中 A[low,mid] 中,此時(shí)可以解可以表示為:A[i..mid]
- 完全位于右邊子數(shù)組中 A[mid,high] 中,此時(shí)可以解可以表示為:A[mid+1..high]
- 跨越了中點(diǎn),此時(shí)解可以表示為 A[i..mid]+A[mid+1..j]
這樣,在任何情況下,一個(gè)問題可以分為3個(gè)子問題,我們可以遞歸地求解 A[i..mid] 與 A[mid+1..high],因?yàn)檫@兩個(gè)子問題仍然是最大子數(shù)組問題,只是規(guī)模縮小了一半而已。
-
因此我們先解決第三種情況:
以 mid 為中心,向左向右依次找最大子數(shù)組,這個(gè)時(shí)候只需要線性時(shí)間就可以找到了
-
find_max_crossing_subarray:
def find_cross_suming_subarray(A, mid, low, high): # 最大子數(shù)組橫跨中點(diǎn),所以最大子數(shù)組的左邊是A[i..mid],右邊是A[mid+1..j] # 求 A[i..mid] 可以直接用暴力求解法,從 mid 開始從左依次相加,判斷一下,然后賦值即可,求 A[mid+1..j] 是同樣的方法 left_sum, right_sum = 0, 0 max_left_sum, max_right_sum = -float('inf'), -float('inf') # 注意 range(start,stop,step),包括start,不包括stop,所以對(duì)應(yīng)的low-1 與 high+1 for i in range(mid, low-1, -1): left_sum += A[i] if left_sum > max_left_sum: max_left_sum = left_sum low = i for j in range(mid+1, high+1, 1): right_sum += A[j] if right_sum > max_right_sum: max_right_sum = right_sum high = j return max_right_sum+max_left_sum, low, high
-
有了第三種情況的處理,接下來(lái)可以編寫遞歸的函數(shù)了,注意要在開頭判斷跳出遞歸的條件:
def divide_and_conquer(A, low, high): if low == high: return A[low], low, high mid = (low + high) // 2 left_sum, left_low, left_high = divide_and_conquer(A, low, mid) print("left:", left_sum, left_low, left_high) right_sum, right_low, right_high = divide_and_conquer(A, mid+1, high) print("right:", right_sum, right_low, right_high) cross_sum, cross_low, cross_high = find_cross_suming_subarray(A, mid, low, high) print("cross:", cross_sum, cross_low, cross_high) if left_sum > right_sum and left_sum > cross_sum: return left_sum, left_low, left_high elif right_sum > left_sum and right_sum > cross_sum: return right_sum, right_low, right_high else: return cross_sum, cross_low, cross_high
-
注意到遞歸后每次的結(jié)果都輸出到控制臺(tái)上,在處理遞歸問題時(shí),我發(fā)現(xiàn) log 比 debug 代碼要容易理解得多了,有興趣的朋友可以一步一步看看是怎么樣輸出的
left: 13 0 0 right: -3 1 1 cross: 10 0 1 left: 13 0 0 left: -25 2 2 right: 20 3 3 cross: -5 2 3 right: 20 3 3 cross: 5 0 3 left: 20 3 3 left: -3 4 4 right: -16 5 5 cross: -19 4 5 left: -3 4 4 left: -23 6 6 right: 18 7 7 cross: -5 6 7 right: 18 7 7 cross: -21 5 7 right: 18 7 7 cross: 17 3 4 left: 20 3 3 left: 20 8 8 right: -7 9 9 cross: 13 8 9 left: 20 8 8 left: 12 10 10 right: -5 11 11 cross: 7 10 11 right: 12 10 10 cross: 25 8 10 left: 25 8 10 left: -22 12 12 right: 15 13 13 cross: -7 12 13 left: 15 13 13 left: -4 14 14 right: 7 15 15 cross: 3 14 15 right: 7 15 15 cross: 18 13 15 right: 18 13 15 cross: 16 8 15 right: 25 8 10 cross: 43 7 10 (43, 7, 10)
解法三:聯(lián)機(jī)算法
《算法導(dǎo)論》中的練習(xí)4.1-5提供了一種更快速地解決最大子數(shù)組的算法
從數(shù)組的左邊界開始,從左往右處理,記錄到目前為止已經(jīng)處理過的最大子數(shù)組。
-
假設(shè)我們已經(jīng)知道了 A[1..j] 的最大子數(shù)組,那么往右處理時(shí),可以遵從如下性質(zhì):
-
數(shù)組 A[1...j+1] 的最大子數(shù)組,有兩種情況:
A[1...j] 的最大子數(shù)組
A[i...j+1]
-
-
那么如何求得 A[i...j+1] 呢?
首先不難想通:如果一個(gè)數(shù)組 a[1..r] 求和得到負(fù)值,那么下一次往右處理時(shí),可以直接把之前的記錄全部清空,因?yàn)橄麓尾僮鲿r(shí)的 a[r+1],還不如直接把自己當(dāng)作解(至少起點(diǎn)要從這里開始),因?yàn)?a[1..r]+a[r+1]<a[r+1]。
所以只要某次操作時(shí),求和為負(fù),那么直接把和清0,重新計(jì)算最大子數(shù)組,并且把起點(diǎn)設(shè)置為下一個(gè)要操作的序數(shù)。
-
代碼:
def linear_time(A): sum, max_sub_sum, low, high, cur = 0, 0, 0, 0, 0 for i in range(0, len(A)): sum += A[i] if sum > max_sub_sum: max_sub_sum = sum # 起點(diǎn)從0開始,從左往右操作 low = cur high = i # 每當(dāng)和小于0時(shí),丟棄之前處理過的所有記錄,最大和清0,并且起點(diǎn)從下一位開始 if sum < 0: sum = 0 cur = i + 1 return max_sub_sum, low, high
在網(wǎng)上查閱了許久,上述解法應(yīng)該屬于聯(lián)機(jī)算法,不過挺容易理解的,并且時(shí)間復(fù)雜度的確是 O(n),常量空間,不需要輔助空間進(jìn)行,非常快。
百度百科:聯(lián)機(jī)算法是在任意時(shí)刻算法對(duì)要操作的數(shù)據(jù)只讀入(掃描)一次,一旦被讀入并處理,它就不需要在被記憶了。而在此處理過程中算法能對(duì)它已經(jīng)讀入的數(shù)據(jù)立即給出相應(yīng)子序列問題的正確答案。
解法四:動(dòng)態(tài)規(guī)劃
這種解法同樣可以實(shí)現(xiàn)線性時(shí)間復(fù)雜度
-
假設(shè)有一個(gè)數(shù)組a[1..n],若記 b[i] 為:以 a[i] 結(jié)尾的子數(shù)組的最大和,即
b[i]=max{sum(a[j~k])}, 其中0<=j<=i,j<=k<=i。
-
因此對(duì)于數(shù)組 a[0..n] 的最大子數(shù)組的和為
max{b[0], b[1], b[2], .., b[n]}
即求 b[] 的最大值
-
由 b[i] 的定義可易知
當(dāng) b[i-1]>0 時(shí),b[i]=b[i-1]+a[i] 否則 b[i]=a[i]。
-
故b[i]的動(dòng)態(tài)規(guī)劃遞歸式為:
b[i] = max(b[i-1]+a[i], a[i]),1<=i<=n
-
代碼如下:
def dp(A): low, high = 0, 0 B = list(range(len(A))) B[0] = A[0] max_sub_sum = A[0] for i in range(1, len(A)): if B[i-1] > 0: B[i] = B[i-1] + A[i] else: B[i] = A[i] low = i if B[i] > max_sub_sum: max_sub_sum = B[i] high = i return max_sub_sum, low, high
感覺解法三、解法四很像,等到時(shí)候?qū)W習(xí)動(dòng)態(tài)規(guī)劃了,再來(lái)好好琢磨琢磨解法四的精髓。
找出數(shù)組中最大的兩個(gè)數(shù)
我感覺視頻中前兩種方法有點(diǎn)點(diǎn)問題,把 A[0] 作為 x1, x2 的初值,有些刁鉆的測(cè)試用例會(huì)有錯(cuò)誤的輸出,所以我把初值都改為了負(fù)無(wú)窮大,相應(yīng)的,比較的次數(shù)會(huì)多一次,但是分析的思想沒變。
輸入:一個(gè)數(shù)組
輸出:數(shù)組中的最大值 x1 以及次大值 x2
-
如果數(shù)組允許重復(fù)元素,那么必有 x1 > x2 ; 否則 x1 ≥ x2
視頻中期望的輸出是 x1 ≥ x2,這樣的要求放寬了限制,把 A[0] 作為 x1, x2 的初值似乎沒毛病,但是為了深入思考,我決定嚴(yán)格限制輸出,這樣對(duì)編程思想的提高會(huì)有較大幫助。
-
方法一——暴力枚舉法:
第一趟循環(huán)找出數(shù)組的最大值 x1,n 次比較
第二趟循環(huán)找出數(shù)組開頭到最大值序號(hào)之間的次大值
第三趟循環(huán)找出數(shù)組最大值的序號(hào)到最后之間的次大值,與上面加起來(lái)是 n-1 次比較
-
大約需要經(jīng)過 2n-1 次比較
def max2_force(A): x1 = -float('inf') x2 = -float('inf') for i in range(len(A)): if A[i] > x1: x1 = A[i] j = i for i in range(j): if A[i] > x2: x2 = A[i] for i in range(j + 1, len(A)): if A[i] > x2: x2 = A[i] return x1, x2
-
方法二——暴力枚舉法改進(jìn)版:
只有一趟循環(huán),每次循環(huán)時(shí),先把當(dāng)前值與次大值比較,再進(jìn)一步把次大值與最大值比較
最好情況:每次比較都小于次大值,所以比較次數(shù)為 n
-
最壞情況:每次比較都大于次大值,所以比較次數(shù) 2n
def max2_force_improve(A): x1 = -float('inf') x2 = -float('inf') for i in range(len(A)): if A[i] > x2: x2 = A[i] if x2 > x1: x1, x2 = x2, x1 return x1, x2
-
方法三——分治法:
-
時(shí)間復(fù)雜度:T(n) = 2*T(n/2) + 2 = 5n/3 - 2
def max2_divide_and_conquer(A, low, high): if low == high: return A[low], A[low] elif low + 1 == high: if A[low] > A[high]: return A[low], A[high] else: return A[high], A[low] else: mid = (low + high) // 2 x1_left, x2_left = max2_divide_and_conquer(A, low, mid) x1_right, x2_right = max2_divide_and_conquer(A, mid + 1, high) if x1_left > x1_right: if x2_left > x1_right: return x1_left, x2_left else: return x1_left, x1_right else: if x2_right > x1_left: return x1_right, x2_right else: return x1_right, x1_left
-