【程序员必修数学课】->基础思想篇->递归(下)->分而治之从归并排序到MapReduce
遞歸(下)
- 前言
- 歸并排序中的分治思想
- 分布式系統(tǒng)中的分治思想
- 1.數(shù)據(jù)分割和映射
- 2.歸約
- 3.合并
- 總結
前言
在上一篇中,我介紹了如何使用遞歸,來處理迭代法中比較復雜的數(shù)值計算。但是我們知道,有些迭代法并不是簡單的數(shù)值計算,而是要通過迭代的過程進行一定的操作,過程更加復雜,需要考慮很多中間數(shù)據(jù)的分配或者保存。比如我在迭代法中提到的使用二分查找進行數(shù)據(jù)匹配,或者這篇文章里將要講解的歸并排序中的數(shù)據(jù)排序等等。在這種情況下,要怎么使用遞歸法呢?
【程序員必修數(shù)學課】->基礎思想篇->迭代法
【程序員必修數(shù)學課】->基礎思想篇->遞歸(上)->泛化數(shù)學歸納
我們可以先分析一下這些復雜的問題是否可以簡化成更小的、更簡單的子問題來解決,這是一般思路。如果可以,那就意味著我們可以使用遞歸的核心思想,將復雜的問題逐步簡化成最基本的情況來求解。在這篇文章里,我將從歸并排序開始,延伸到多臺機器的并行處理,詳細介紹遞歸思想在“分而治之”這個領域的應用。
歸并排序中的分治思想
首先,我們先考慮如何使用遞歸編程解決數(shù)字的排序問題。
對一堆雜亂無序的數(shù)字,按照從小到大或者從大到小的規(guī)則進行排序,這是計算機領域非常經(jīng)典,也非常流行的問題。小到Excel電子表格,大到搜索引擎,都需要對一堆數(shù)字進行排序。因此,計算機領域的前輩們研究排序問題已經(jīng)很多年了,也提出了許多優(yōu)秀的算法,比如歸并排序、快速排序、堆排序等等。其中,歸并排序和快速排序都很好地體現(xiàn)了分治的思想,這篇文章主要就說一說歸并排序(merge sort)。
很顯然,歸并排序算法的核心就是“歸并”,也就是把兩個有序的數(shù)列合并起來,形成一個更大的有序數(shù)列。
假設我們需要按照從小到大的順序,合并兩個有序數(shù)列 A 和 B。我們需要開辟一個新的存儲空間 C,用于保存合并后的結果。
我們首先比較兩個數(shù)列的第一個數(shù),如果 A 數(shù)列的第一個數(shù)小于 B 數(shù)列的第一個數(shù),那么就先取出 A 數(shù)列的第一個數(shù)放入 C,并把這個數(shù)從 A 數(shù)列中刪除。如果是 B 的第一個數(shù)更小,那么就先取出 B 數(shù)列的第一個數(shù)放入 C,并把它從 B 數(shù)列里刪除。
以此類推,直到 A 和 B 里所有的數(shù)據(jù)都被取出來放入 C。如果到某一步, A 或 B 數(shù)列為空,那直接將另一個數(shù)列的數(shù)據(jù)依次取出放入 C 就可以了。這種操作,可以保證兩個有序的數(shù)列 A 和 B 合并到 C 之后,C 數(shù)列仍然是有序的。
比如說合并有序數(shù)組 {6, 11, 13, 17} 和 {8, 10, 16}的過程👇
為了保證得到有序的 C 數(shù)列,我們必須保證參與合并的 A 和 B 也是有序的。但是,等待排序的數(shù)組一開始都是亂序的,如果無法保證這點,那歸并又有什么意義呢?
這就需要用到遞歸了。我們可以利用遞歸的思想,把問題不斷簡化,也就是把數(shù)列不斷簡化,一直簡化到最后只有一個數(shù),那它本身就是有序的了。那么如何進行每一次的簡化呢?
最簡單的想法就是把長度為 n 的數(shù)列,每次簡化為長度為 n - 1 的數(shù)列,直至長度為 1。不過,這樣的處理沒有并行性,要進行 n - 1 次的歸并操作,效率就會很低。
所以,我們可以在歸并排序中引入了分而治之(Divide and Conquer) 的思想。分而治之,我們通常簡稱為分治。它的思想就是,將一個復雜的問題,分解成兩個甚至多個規(guī)模相同或類似的子問題,然后對這些子問題再進一步細分,直到最后的子問題變得簡單,很容易就能被求解出來,這樣這個復雜的問題就求解出來了。
歸并排序通過分治的思想,把長度為 n 的數(shù)列,每次簡化為兩個長度為 n / 2 的數(shù)列。這樣更有利于計算機的并行處理,只需要 log2n 次歸并。
我們把歸并和分治的思想結合起來,這其實就是歸并排序算法。這種算法每次把數(shù)列進行二等分,直到唯一的數(shù)字,也就是最基本的有序數(shù)列。然后從這些最基本的有序數(shù)列開始,兩兩合并有序的數(shù)列,直到所有的數(shù)字都參與了歸并排序。
我用一個包含 0~9 這 10 個數(shù)字的數(shù)組,分析一下歸并排序的過程。
- 假設初始的數(shù)組為 {7, 6, 2, 4, 1, 9, 3, 8, 0, 5},我們要對它進行從小到大的排序。
- 第一次分解后,變成兩個數(shù)組 {7, 6, 2, 4, 1} 和 {9, 3, 8, 0, 5}。
- 然后,我們將 {7, 6, 2, 4, 1} 分解成 {7, 6} 和 {2, 4, 1},將 {9, 3, 8, 0, 5} 分解成 {9, 3} 和 {8, 0, 5}。
- 按照這個規(guī)律繼續(xù)細分下去,直到每個組只包含一個數(shù)字。到這里,都是遞歸的嵌套調(diào)用過程。
- 接下來,就要開始進行合并了。我們可以將 {4, 1} 分解為 {4} 和 {1}。現(xiàn)在無法再細分了,我們開始合并,在合并的過程中進行排序,所以合并的結果為 {1, 4}。合并后的結果將返回當前函數(shù)的調(diào)用者,這就是函數(shù)返回的過程。
- 重復上述合并的過程,直到完成整個數(shù)組的排序,得到 {0, 1, 2, 3, 4, 5, 6, 7, 8, 9}。
這個過程可以畫一張圖來理解👇
可以看到 歸并排序使用了分治的思想,而這個過程需要使用遞歸來實現(xiàn)。
歸并排序算法用分治的思想把數(shù)列不斷地簡化,直到每個數(shù)列僅剩下一個單獨的數(shù),然后再使用歸并逐步合并有序的數(shù)列,從而達到將整個數(shù)列進行排序的目的。而這個歸并排序,正好可以使用遞歸的方式來實現(xiàn)。我們可以看看下面這張圖,可以發(fā)現(xiàn),分治的過程和遞歸的過程是一致的。
分治的過程可以通過遞歸來表達,因此,歸并排序最直觀的實現(xiàn)方式就是遞歸。所以,我們從遞歸的步驟出發(fā),來看歸并排序如何實現(xiàn)。
我們假設 n = k - 1 的時候,我們已經(jīng)對較小的兩組數(shù)進行了排序。那我們只要在 n = k 的時候,將這兩組數(shù)合并起來,并且保證合并后的數(shù)組仍然是有序的就行了。
所以,在遞歸的每次嵌套調(diào)用中,代碼都將一組數(shù)分解成更小的兩組,然后將這兩個小組的排序交給下一次的嵌套調(diào)用。而本次調(diào)用只需要關心,如何將排好序的兩個小組進行合并。
在初始狀態(tài),也就是 n = 1 的時候,對于排序的案例而言,只包含單個數(shù)字的分組。由于分組里只有一個數(shù)字,所以它已經(jīng)是排好序的了,之后就可以開始遞歸調(diào)用的返回階段。
現(xiàn)在我用Java簡單實現(xiàn)一下歸并排序。
測試代碼如下👇
package com.tyz.merge_sort.test;import com.tyz.merge_sort.core.MergeSort;public class Test {public static void main(String[] args) {int[] arr = {78, 12, 444, 710, 18, 322, 0, 45, 95471, 99, 1024};int[] sorted = MergeSort.mergeSort(arr);for (int i = 0; i < sorted.length; i++) {System.out.println(sorted[i]);}}}結果如下👇
分布式系統(tǒng)中的分治思想
到這里我們對分而治之的思想已經(jīng)有了一個基本的認識了,不過,分而治之更有趣的應用其實是在分布式系統(tǒng)中。
舉個例子,當需要排序的數(shù)組很大很大,比如 1024GB ,我們沒法把這些數(shù)據(jù)都塞入一臺普通的計算機的內(nèi)存里。有一個辦法,我們可以把這個超級大的數(shù)據(jù)集,分解成多個更小的數(shù)據(jù)集(比如 16GB),然后分配到多臺機器上,讓它們并行地處理。
等所有機器處理完后,中央服務器再進行結果的合并。由于多個小任務間不會相會干擾,可以同時處理,這樣會大大增加處理的速度,減少等待時間。
在單臺機器上實現(xiàn)歸并排序的時候,我們只需要在遞歸函數(shù)內(nèi),實現(xiàn)數(shù)據(jù)分組以及合并就行了。而在多個機器之間分配數(shù)據(jù)的時候,遞歸函數(shù)內(nèi)除了分組及合并,還要負責把數(shù)據(jù)分發(fā)到某臺機器上。
可以看到,分布式集群種話的數(shù)據(jù)切分和合并,同單臺機器上歸并排序的過程是一樣的,因此也是使用了分治的思想。從理論的角度來看,上面這個圖很容易理解。不過在實際運用中,有個地方需要注意一下。
上圖中的父節(jié)點,例如機器1、2、3,它們都沒有被分配排序的工作,只是在子節(jié)點的排序完成后進行有序數(shù)組的合并,因此集群的性能沒有得到充分的利用。那么,另一種可能的數(shù)據(jù)切分方式是,每個機器拿出一半的數(shù)據(jù)給另一臺機器處理,而自己完成剩下的一半數(shù)據(jù)。
如果分治的時候,只進行一次問題切分,那么上述層級型的分布式架構就可以轉化為類似 MapReduce 的架構。下圖我接用黃申老師畫的主要步驟,以供參考。
這里面主要有三個步驟用到了分治的思想。
1.數(shù)據(jù)分割和映射
分割是指將數(shù)據(jù)源進行切分,并將分片發(fā)送到 Mapper 上。映射是指 Mapper 根據(jù)應用的需求,將內(nèi)容按照 鍵 - 值 的匹配,存儲到哈希結構中。這兩個步驟將大的數(shù)據(jù)集合切分為更小的數(shù)據(jù)集,降低了每臺機器節(jié)點的負載,因此和分治中的問題分解類似。不過,MapReduce 采用了哈希映射來分配數(shù)據(jù),而普通的分治或遞歸不一定需要。
2.歸約
歸約是指接受到的一組鍵值對,如果是鍵內(nèi)容相同的配對,就將它們的值歸并。這和本機的遞歸調(diào)用后返回結果的過程類似。不過,由于哈希映射的關系,MapReduce 還需要洗牌的步驟,也就是將 鍵 - 值 的配對不斷地發(fā)給對應的 Reducer 進行歸約。普通的分治或遞歸不一定需要洗牌的步驟。
3.合并
為了提升洗牌階段的效率,可以選擇減少發(fā)送到歸約階段的 鍵 - 值 配對。具體做法是在數(shù)據(jù)映射和洗牌之間,加入合并的過程,在每個 Mapper 節(jié)點上先進行一次本地的歸約。然后只將合并的結果發(fā)送到洗牌和歸約階段。這和本機的遞歸嗲用后返回結果的過程類似。
總結
遞歸采用了和數(shù)學歸納法類似的思想,但是它用的是逆向遞推,化繁為簡,把復雜的問題逐步簡化。再加上分治原理,我們就可以更有效地把問題細分,進行并行化的處理。
而計算機編程中的函數(shù)嵌套調(diào)用,正好對應了數(shù)學中遞歸的逆向遞推,所以你只要弄明白了數(shù)學遞推式,就能非常容易地寫出對應的遞歸編碼。需要注意的是,遞歸編程在沒有開始返回結果之前,保存了大量的中間結果,所以比較消耗系統(tǒng)資源,這也是一般的編程語言都會限制遞歸的深度(嵌套的次數(shù))的原因。
另,這篇文章的知識點來源于極客時間黃申的《程序員的數(shù)學基礎課》。
總結
以上是生活随笔為你收集整理的【程序员必修数学课】->基础思想篇->递归(下)->分而治之从归并排序到MapReduce的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: webpack学习笔记(六):图片打包处
- 下一篇: Cognex ToolBlockEdit