分治算法介紹
今天我們聊一聊計算機中非常重要和常用的一種算法:分治算法。它在計算機領域應用廣泛,幾乎無處不在。不僅計算機領域,在信號處理領域,分而治之也是十分常見的一種信號處理方法。著名快速傅里葉變換算法 (FFT) 正是使用了分而治之的思路,才使得數字信號處理算能廣泛使用,這也才造就了我們今天豐富多彩的生活。
1. 分治算法思想
分而治之是計算機領域中非常重要的一種思想:即將大規模問題每次通過分解成小問題進行處理,在處理完小問題之后再將結果合并成大問題的最終結果。
2. 舉例說明分治算法的典型應用
非常典型的一個示例是排序算法中的歸并排序。假設我們要排序 8 個數,我們首先將 8 個數的列表分解成兩個 4 個數的列表,一直劃分下去直到列表中最多只有 2 個元素。接下來對所有的 2 個或者 1 個元素的列表進行排序,然后再兩兩合并,直到最后合并成最終長度為 8 的列表,則排序結束。其算法示意圖如下所示:
可以看到:歸并排序中的一個核心步驟是對兩個有序數組的合并過程,對此使用的合并算法過程如下圖所示:
3. 分治算法的 Python 代碼實現
有了上面的算法過程,我們就可以寫出合并兩個有序列表的代碼,如下:
def merge_sorted_list(nums1, nums2):
"""
合并有序列表
"""
nums = [0] * (len(nums1) + len(nums2))
id, id1, id2 = 0, 0, 0
# 循環條件,當有一個等于數組長度時,終止
while id1 < len(nums1) and id2 < len(nums2):
# 對應著上圖中第三個操作,多加了邊界操作
while id1 < len(nums1) and id2 < len(nums2) and nums1[id1] <= nums2[id2]:
nums[id] = nums1[id1]
id += 1
id1 += 1
# 對應著上圖第二個部分操作,多加了邊界操作
while id1 < len(nums1) and id2 < len(nums2) and nums2[id2] < nums1[id1]:
nums[id] = nums2[id2]
id += 1
id2 += 1
# 對應于圖片的最后部分,如果一個數組先走完,另一個數組剩余部分直接添加到新數組的最后
if id1 != len(nums1):
nums[id:] = nums1[id1:]
if id2 != len(nums2):
nums[id:] = nums2[id2:]
return nums
接下來從分解到合并這個過程是不是特別像遞歸?看看遞歸三要素應該怎么做,第一是終止條件,很明顯在列表長度為 1 或者 2 時終止;第二是方法的返回值,可以是列表頭,表示得到新的排序的列表;第三是遞推公式,即將已經排好序的結果合并成完整的排序結果,用的正是上面的合并方法,這樣我們就可以寫出如下歸并排序的代碼:
def merge_sort(nums):
"""
遞歸終止條件
"""
if len(nums) == 1:
return nums
if len(nums) == 2:
if nums[0] > nums[1]:
nums[0], nums[1] = nums[1], nums[0]
return nums
# 遞歸公式,將列表平均分成兩份,然后遞歸排序下去
half = len(nums) // 2
nums1 = merge_sort(nums[:half])
nums2 = merge_sort(nums[half:])
# 調用合并操作,得到最終結果
return merge_sorted_list(nums1, nums2)
從上面的排序算法示例中我們可以看到典型分治算法的解題步驟如下:
- 分解:要解決的問題劃分成若干規模較小的同類問題;
- 求解:當子問題劃分得足夠小時,可以采用已有或者簡單的算法解決;
- 合并:再得到每個子問題的解后,還需要將這些結果合并成最后大問題的結果,這才是最終答案。
4. 另一個分而治之的應用介紹
另一個分治算法的典型應用是大整數相乘問題。對于兩個以字符串形式表示的長整數,計算其相乘結果。關于分治法比較核心的一個問題就是,找到如何將子問題的解合并成父問題的解。我們簡單描述一下這個基于分治思想的大數乘法算法,又叫 Karatsuba 算法。
假設 x 和 y 是兩個要做乘法的十進制大數,并且位數分別為 m 和 n。若將 x 和 y 分別均分成兩部分,則 x 和 y 可以表示如下:
此時有:
這樣大整數乘法就被分解成 4 個長度減半的整數的乘法。如此執行下去,直到執行的乘法的兩數足夠小時,然后計算反推最終結果。根據上面的執行思路,我們開始一步一步得到大整數相乘的分治代碼。
首先處理初始以及最后分治的終止條件:如果 s1 或者 s2 中有一個為空串,那么相乘結果為 '0‘,注意輸出字符串;此外,分解到最后 s1 和 s2 字符串的長度均為1 時,我們可以直接將兩個字符串強裝成數字然后進行相乘,最后返回的結果也要再轉成字符串:
if not s1 or not s2:
# 有一個為空,則結果為0
return '0'
elif len(s1) == 1 and len(s2) == 1:
# 終止條件
return str(int(s1) * int(s2))
接下來開始對字符串 s1 和 s2 進行分治求解:
# 其余情況
l1 = len(s1) // 2
l2 = len(s2) // 2
# 將s1分成兩部分
a = s1[:l1]
b = s1[l1:]
# 將s2分成兩部分
c = s2[:l2]
d = s2[l2:]
接下來我們分別得到了4個子串:a、b、c、d。按照前面的公式,我們分別要計算 ac
、bc
、ad
和 bd
的值。這些值可以使用遞歸方法得到,要注意相乘結果的10次冪需要保留,以便后續能合成最終問題的解。
mi = len(b) # 對于a的10次冪
ni = len(d) # 對于c的10次冪
# 分別計算ac, ad, bc, bd,分別補上相應的冪次,使用分治計算
x1 = bigDataMultiple(a, c) + '0' * (mi + ni)
x2 = bigDataMultiple(a, d) + '0' * mi
x3 = bigDataMultiple(b, c) + '0' * ni
x4 = bigDataMultiple(b, d)
最后是計算 x1+x2+x3+x4
的值便可得到原大問題的解,由于 x1~x4
均為字符串,我們按照字符串上每位數字相加來進行。首先需要將所有字符串的長度統一,對于短字符串需要將前面補零:
max_len = max(len(x1), len(x2), len(x3), len(x4))
x1 = '0' * (max_len - len(x1)) + x1
x2 = '0' * (max_len - len(x2)) + x2
x3 = '0' * (max_len - len(x3)) + x3
x4 = '0' * (max_len - len(x4)) + x4
接著從字符串最后一位開始,計算每位上的值,注意進位問題即可:
res = ''
c = 0
for i in range(max_len - 1, -1, -1):
s = int(x1[i]) + int(x2[i]) + int(x3[i]) + int(x4[i]) + c
res = str(s % 10) + res
c = s // 10
# 注意,如果循環完后進位>0,需要補上進位值
if c > 0:
res = str(c) + res
然而,通過提交代碼發現有時候在合成最終結果時,字符串前面會出現 ‘0’。因此下面的代碼就是找到前面非 ‘0’ 開頭字符的位置:
# 去掉res最前面的'0'
k = 0
while res and k < len(res) and res[k] == '0':
k += 1
最后得到大整數相乘的最終結果為:res[k:]
。
綜合前面的分析與討論,得到完成的處理大整數相乘問題的 Python 實現如下:
def bigDataMultiple(s1, s2):
"""
計算 s1 * s2,返回大整數相乘結果
"""
if not s1 or not s2:
# 有一個為空,則結果為0
return '0'
elif len(s1) == 1 and len(s2) == 1:
# 終止條件
return str(int(s1) * int(s2))
# 其余情況
l1 = len(s1) // 2
l2 = len(s2) // 2
# 將s1分成兩部分
a = s1[:l1]
b = s1[l1:]
# 將s2分成兩部分
c = s2[:l2]
d = s2[l2:]
mi = len(b) # 對于a的10次冪
ni = len(d) # 對于c的10次冪
# 分別計算ac, ad, bc, bd,分別補上相應的冪次,使用分治計算
x1 = bigDataMultiple(a, c) + '0' * (mi + ni)
x2 = bigDataMultiple(a, d) + '0' * mi
x3 = bigDataMultiple(b, c) + '0' * ni
x4 = bigDataMultiple(b, d)
# 將計算的結果根據最長的補零,方便后面直接相加計算
max_len = max(len(x1), len(x2), len(x3), len(x4))
x1 = '0' * (max_len - len(x1)) + x1
x2 = '0' * (max_len - len(x2)) + x2
x3 = '0' * (max_len - len(x3)) + x3
x4 = '0' * (max_len - len(x4)) + x4
# 計算x1+x2+x3+x4的值,也就是原問題的解
res = ''
c = 0
for i in range(max_len - 1, -1, -1):
s = int(x1[i]) + int(x2[i]) + int(x3[i]) + int(x4[i]) + c
res = str(s % 10) + res
c = s // 10
# 注意,如果循環完后進位>0,需要補上進位值
if c > 0:
res = str(c) + res
# 去掉res最前面的'0'
k = 0
while res and k < len(res) and res[k] == '0':
k += 1
return res[k:]
這道題是牛客網上的原題,我實現的分治算法略微復雜,大家可以參考上面的一些題解,有非常精簡和高效的實現。多參考優秀的代碼對我們提升自己編程能力也是有莫大好處的。
5. 分治法的時間復雜度分析
分治法的時間復雜度可以這樣來分析,假設對于規模為 n 的問題,其直接計算的復雜度為 。那么我們看一下分解成 2 個子問題后其復雜度如何?我們以前面的歸并排序的為例,如果是采用前面的選擇排序、冒泡排序,算法的復雜度為 ,我們將其分解成兩個 長度的列表進行排序后再合并,這樣一次分解的時間復雜度為:
注意:后面的 n 為合并方法的時間復雜度。如果問題規模 n 非常大,此時一次分解再合并后的時間復雜度相比原算法少了一半,如果我們再次分解問題規模呢?
可以看到再次減少大約一半的計算量?。。∪绻瓎栴}的復雜度是 甚至 ,那么使用分治算法能明顯改進原算法性能,當前前提是該算法能進行分治求解。對于前面歸并排序問題,其平均的時間復雜度最終可以達到 。
6. 小結
本小節我們使用兩個典型的例子對分治算法的原理以及算法實現步驟進行了相應的說明,通過這兩個簡單的例子我們能體會分治算法的特點:簡單高效。接下來我們會用更多 leetcode 習題來幫助我們鞏固這一節所學的算法。