分治演算法基本原理和實踐
一、基本概念
在電腦科學中,分治法是一種很重要的演算法。字面上的解釋是“分而治之”,就是把一個複雜的問題分成兩個或更多的相同或相似的子問題,再把子問題分成更小的子問題……直到最後子問題可以簡單的直接求解,原問題的解即子問題的解的合併。這個技巧是很多高效演算法的基礎,如排序演算法(快速排序,歸併排序),傅立葉變換(快速傅立葉變換)……
任何一個可以用計算機求解的問題所需的計算時間都與其規模有關。問題的規模越小,越容易直接求解,解題所需的計算時間也越少。
例如,對於 n 個元素的排序問題,當 n=1 時,不需任何計算。n=2 時,只要作一次比較即可排好序。n=3 時只要作 3 次比較即可,…。而當 n 較大時,問題就不那麼容易處理了。要想直接解決一個規模較大的問題,有時是相當困難的。
二、基本思想及策略
分治法的設計思想是:將一個難以直接解決的大問題,分割成一些規模較小的相同問題,以便各個擊破,分而治之。
分治策略是:對於一個規模為 n 的問題,若該問題可以容易地解決(比如說規模 n 較小)則直接解決,否則將其分解為 k 個規模較小的子問題,這些子問題互相獨立且與原問題形式相同,遞迴地解這些子問題,然後將各子問題的解合併得到原問題的解。這種演算法設計策略叫做分治法。
如果原問題可分割成 k 個子問題,1<k≤n,且這些子問題都可解並可利用這些子問題的解求出原問題的解,那麼這種分治法就是可行的。
由分治法產生的子問題往往是原問題的較小模式,這就為使用遞迴技術提供了方便。在這種情況下,反覆應用分治手段,可以使子問題與原問題型別一致而其規模卻不斷縮小,最終使子問題縮小到很容易直接求出其解。這自然導致遞迴過程的產生。分治與遞迴像一對孿生兄弟,經常同時應用在演算法設計之中,並由此產生許多高效演算法。
三、分治法適用的情況
分治法所能解決的問題一般具有以下幾個特徵:
-
該問題的規模縮小到一定的程度就可以容易地解決
-
該問題可以分解為若干個規模較小的相同問題,即該問題具有最優子結構性質。
-
利用該問題分解出的子問題的解可以合併為該問題的解;
-
該問題所分解出的各個子問題是相互獨立的,即子問題之間不包含公共的子子問題。
第一條特徵是絕大多數問題都可以滿足的,因為問題的計算複雜性一般是隨著問題規模的增加而增加;
第二條特徵是應用分治法的前提它也是大多數問題可以滿足的,此特徵反映了遞迴思想的應用;、
第三條特徵是關鍵,能否利用分治法完全取決於問題是否具有第三條特徵,如果具備了第一條和第二條特徵,而不具備第三條特徵,則可以考慮用貪心法或動態規劃法。
第四條特徵涉及到分治法的效率,如果各子問題是不獨立的則分治法要做許多不必要的工作,重複地解公共的子問題,此時雖然可用分治法,但一般用動態規劃法較好。
四、可使用分治法求解的一些經典問題
-
二分搜尋
-
大整數乘法
-
Strassen矩陣乘法
-
棋盤覆蓋
-
合併排序
-
快速排序
-
線性時間選擇
-
最接近點對問題
-
迴圈賽日程表
-
漢諾塔
五、分治法的基本步驟
分治法在每一層遞迴上都有三個步驟:
-
分解:將原問題分解為若干個規模較小,相互獨立,與原問題形式相同的子問題;
-
解決:若子問題規模較小而容易被解決則直接解,否則遞迴地解各個子問題
-
合併:將各個子問題的解合併為原問題的解。
它的一般的演算法設計模式如下:
Divide-and-Conquer(P) 1. if |P|≤n0 2. then return(ADHOC(P)) 3. 將P分解為較小的子問題 P1 ,P2 ,…,Pk 4. for i←1 to k 5. do yi ← Divide-and-Conquer(Pi) △ 遞迴解決Pi 6. T ← MERGE(y1,y2,…,yk) △ 合併子問題 7. return(T)
其中 |P| 表示問題 P 的規模;n0 為一閾值,表示當問題 P 的規模不超過 n0 時,問題已容易直接解出,不必再繼續分解。ADHOC(P) 是該分治法中的基本子演算法,用於直接解小規模的問題P。
因此,當 P 的規模不超過 n0 時直接用演算法 ADHOC(P) 求解。演算法 MERGE(y1,y2,…,yk) 是該分治法中的合併子演算法,用於將 P 的子問題 P1 ,P2 ,…,Pk 的相應的解 y1,y2,…,yk 合併為 P 的解。
六、依據分治法設計程式時的思維過程
實際上就是類似於數學歸納法,找到解決本問題的求解方程公式,然後根據方程公式設計遞迴程式。-
一定是先找到最小問題規模時的求解方法
-
然後考慮隨著問題規模增大時的求解方法
-
找到求解的遞迴函式式後(各種規模或因子),設計遞迴程式即可。
七、示例
7.1 快速排序
簡述
快速排序是一種排序執行效率很高的排序演算法,它利用分治法來對待排序序列進行分治排序,它的思想主要是通過一趟排序將待排記錄分隔成獨立的兩部分,其中的一部分比關鍵字小,後面一部分比關鍵字大,然後再對這前後的兩部分分別採用這種方式進行排序,通過遞迴的運算最終達到整個序列有序,下面我們簡單進行闡述。
快排思路
我們從一個數組來逐步逐步說明快速排序的方法和思路。
-
假設我們對陣列{7, 1, 3, 5, 13, 9, 3, 6, 11}進行快速排序。
-
首先在這個序列中找一個數作為基準數,為了方便可以取第一個數。
-
遍歷陣列,將小於基準數的放置於基準數左邊,大於基準數的放置於基準數右邊。
-
此時得到類似於這種排序的陣列{3, 1, 3, 5, 6, 7, 9, 13, 11}。
-
在初始狀態下7是第一個位置,現在需要把7挪到中間的某個位置k,也即k位置是兩邊數的分界點。
-
那如何做到把小於和大於基準數7的值分別放置於兩邊呢,我們採用雙指標法,從陣列的兩端分別進行比對。
-
先從最右位置往左開始找直到找到一個小於基準數的值,記錄下該值的位置(記作 i)。
-
再從最左位置往右找直到找到一個大於基準數的值,記錄下該值的位置(記作 j)。
-
如果位置i<j,則交換i和j兩個位置上的值,然後繼續從(j-1)的位置往前和(i+1)的位置往後重複上面比對基準數然後交換的步驟。
-
如果執行到i==j,表示本次比對已經結束,將最後i的位置的值與基準數做交換,此時基準數就找到了臨界點的位置k,位置k兩邊的陣列都比當前位置k上的基準值或都更小或都更大。
-
上一次的基準值7已經把陣列分為了兩半,基準值7算是已歸位(找到排序後的位置)。
-
通過相同的排序思想,分別對7兩邊的陣列進行快速排序,左邊對[left, k-1]子陣列排序,右邊則是[k+1, right]子陣列排序。
-
利用遞迴演算法,對分治後的子陣列進行排序。
快速排序之所以比較快,是因為相比氣泡排序,每次的交換都是跳躍式的,每次設定一個基準值,將小於基準值的都交換到左邊,大於基準值的都交換到右邊,這樣不會像冒泡一樣每次都只交換相鄰的兩個數,因此比較和交換的此數都變少了,速度自然更高。當然,也有可能出現最壞的情況,就是仍可能相鄰的兩個數進行交換。
快速排序基於分治思想,它的時間平均複雜度很容易計算得到為O(NlogN)。
程式碼實現
/** * 快速排序 * @param array */ public static void quickSort(int[] array) { int len; if(array == null || (len = array.length) == 0 || len == 1) { return ; } sort(array, 0, len - 1); } /** * 快排核心演算法,遞迴實現 * @param array * @param left * @param right */ public static void sort(int[] array, int left, int right) { if(left > right) { return; } // base中存放基準數 int base = array[left]; int i = left, j = right; while(i != j) { // 順序很重要,先從右邊開始往左找,直到找到比base值小的數 while(array[j] >= base && i < j) { j--; } // 再從左往右邊找,直到找到比base值大的數 while(array[i] <= base && i < j) { i++; } // 上面的迴圈結束表示找到了位置或者(i>=j)了,交換兩個數在陣列中的位置 if(i < j) { int tmp = array[i]; array[i] = array[j]; array[j] = tmp; } } // 將基準數放到中間的位置(基準數歸位) array[left] = array[i]; array[i] = base; // 遞迴,繼續向基準的左右兩邊執行和上面同樣的操作 // i的索引處為上面已確定好的基準值的位置,無需再處理 sort(array, left, i - 1); sort(array, i + 1, right); }
7.2 215. 陣列中的第K個最大元素
在未排序的陣列中找到第 k 個最大的元素。請注意,你需要找的是陣列排序後的第 k 個最大的元素,而不是第 k 個不同的元素。
示例 1:
輸入: [3,2,1,5,6,4] 和 k = 2 輸出: 5
示例 2:
輸入: [3,2,3,1,2,4,5,5,6] 和 k = 4 輸出: 4
說明:
你可以假設 k 總是有效的,且 1 ≤ k ≤ 陣列的長度。
思路和演算法
我們可以用快速排序來解決這個問題,先對原陣列排序,再返回倒數第 kk 個位置,這樣平均時間複雜度是 O(n \log n)O(nlogn),但其實我們可以做的更快。
在分解的過程當中,我們會對子陣列進行劃分,如果劃分得到的 q 正好就是我們需要的下標,就直接返回 a[q];否則,如果 q 比目標下標小,就遞迴右子區間,否則遞迴左子區間。這樣就可以把原來遞迴兩個區間變成只遞迴一個區間,提高了時間效率。這就是「快速選擇」演算法。
我們知道快速排序的效能和「劃分」出的子陣列的長度密切相關。直觀地理解如果每次規模為 n 的問題我們都劃分成 1 和 n - 1,每次遞迴的時候又向 n−1 的集合中遞迴,這種情況是最壞的,時間代價是O(n ^ 2)O(n2)。
我們可以引入隨機化來加速這個過程,它的時間代價的期望是 O(n),證明過程可以參考「《演算法導論》9.2:期望為線性的選擇演算法」。
class Solution { public int findKthLargest(int[] nums, int k) { int len = nums.length; int targetIndex = len - k; int low = 0, high = len - 1; while (true) { int i = partition(nums, low, high); if (i == targetIndex) { return nums[i]; } else if (i < targetIndex) { low = i + 1; } else { high = i - 1; } } } /** * 分割槽函式,將 arr[high] 作為 pivot 分割槽點 * i、j 兩個指標,i 作為標記“已處理區間”和“未處理區間”的分界點,也即 i 左邊的(low~i-1)都是“已處理區”。 * j 指標遍歷陣列,當 arr[j] 小於 pivot 時,就把 arr[j] 放到“已處理區間”的尾部,也即是 arr[i] 所在位置 * 因此 swap(arr, i, j) 然後 i 指標後移,i++ * 直到 j 遍歷到陣列末尾 arr[high],將 arr[i] 和 arr[high](pivot點) 進行交換,返回下標 i,就是分割槽點的下標。 */ private int partition(int[] arr, int low, int high) { int i = low; int pivot = arr[high]; for (int j = low; j < high; j++) { if (arr[j] < pivot) { swap(arr, i, j); i++; } } swap(arr, i, high); return i; } private void swap(int[] arr, int i, int j) { int tmp = arr[i]; arr[i] = arr[j]; arr[j] = tmp; } }
其實這段程式碼和快排很像,但是兩者得目的是不一樣的。
7.3 973. 最接近原點的 K 個點
我們有一個由平面上的點組成的列表 points。需要從中找出 K 個距離原點 (0, 0) 最近的點。(這裡,平面上兩點之間的距離是歐幾里德距離。)
你可以按任何順序返回答案。除了點座標的順序之外,答案確保是唯一的。
示例 1:
輸入:points = [[1,3],[-2,2]], K = 1 輸出:[[-2,2]] 解釋: (1, 3) 和原點之間的距離為 sqrt(10), (-2, 2) 和原點之間的距離為 sqrt(8), 由於 sqrt(8) < sqrt(10),(-2, 2) 離原點更近。 我們只需要距離原點最近的 K = 1 個點,所以答案就是 [[-2,2]]。
示例 2:
輸入:points = [[3,3],[5,-1],[-2,4]], K = 2 輸出:[[3,3],[-2,4]] (答案 [[-2,4],[3,3]] 也會被接受。)
思路
我們想要一個複雜度比 N logN 更低的演算法。 顯然,做到這件事情的唯一辦法就是利用題目中可以按照任何順序返回 K 個點的條件,否則的話,必要的排序將會話費我們 N logN 的時間。
我們隨機地選擇一個元素 x = A[i] 然後將陣列分為兩部分: 一部分是到原點距離小於 x 的,另一部分是到原點距離大於等於 x 的。 這個快速選擇的過程與快速排序中選擇一個關鍵元素將陣列分為兩部分的過程類似。
如果我們快速選擇一些關鍵元素,那麼每次就可以將問題規模縮減為原來的一半,平均下來時間複雜度就是線性的。
演算法
我們定義一個函式 work(i, j, K),它的功能是部分排序 (points[i], points[i+1], ..., points[j]) 使得最小的 K 個元素出現在陣列的首部,也就是 (i, i+1, ..., i+K-1)。
首先,我們從陣列中選擇一個隨機的元素作為關鍵元素,然後使用這個元素將陣列分為上述的兩部分。為了能使用線性時間的完成這件事,我們需要兩個指標 i 與 j,然後將它們移動到放錯了位置元素的地方,然後交換這些元素。
然後,我們就有了兩個部分 [oi, i] 與 [i+1, oj],其中 (oi, oj) 是原來呼叫 work(i, j, K) 時候 (i, j) 的值。假設第一部分有 10 個元,第二部分有15 個元素。如果 K = 5 的話,我們只需要對第一部分呼叫 work(oi, i, 5)。否則的話,假如說 K = 17,那麼第一部分的 10 個元素應該都需要被選擇,我們只需要對第二部分呼叫 work(i+1, oj, 7) 就行了。
class Solution { int[][] points; public int[][] kClosest(int[][] points, int K) { this.points = points; work(0, points.length - 1, K); return Arrays.copyOfRange(points, 0, K); } public void work(int i, int j, int K) { if (i >= j) return; int oi = i, oj = j; int pivot = dist(ThreadLocalRandom.current().nextInt(i, j)); while (i < j) { while (i < j && dist(i) < pivot) i++; while (i < j && dist(j) > pivot) j--; swap(i, j); } if (K <= i - oi + 1) work(oi, i, K); else work(i+1, oj, K - (i - oi + 1)); } public int dist(int i) { return points[i][0] * points[i][0] + points[i][1] * points[i][1]; } public void swap(int i, int j) { int t0 = points[i][0], t1 = points[i][1]; points[i][0] = points[j][0]; points[i][1] = points[j][1]; points[j][0] = t0; points[j][1] = t1; } }
可以發現的是,這道題目跟前面的還是很像的。