分治算法基本原理和實踐

1、基本概念

在計算機科學中,分治法是一種很重要的算法。字面上的解釋是「分而治之」,就是把一個複雜的問題分紅兩個或更多的相同或類似的子問題,再把子問題分紅更小的子問題……直到最後子問題能夠簡單的直接求解,原問題的解即子問題的解的合併。這個技巧是不少高效算法的基礎,如排序算法(快速排序,歸併排序),傅立葉變換(快速傅立葉變換)……css

任何一個能夠用計算機求解的問題所需的計算時間都與其規模有關。問題的規模越小,越容易直接求解,解題所需的計算時間也越少。html

例如,對於 n 個元素的排序問題,當 n=1 時,不需任何計算。n=2 時,只要做一次比較便可排好序。n=3 時只要做 3 次比較便可,…。而當 n 較大時,問題就不那麼容易處理了。要想直接解決一個規模較大的問題,有時是至關困難的。算法

2、基本思想及策略

分治法的設計思想是:將一個難以直接解決的大問題,分割成一些規模較小的相同問題,以便各個擊破,分而治之。設計模式

分治策略是:對於一個規模爲 n 的問題,若該問題能夠容易地解決(好比說規模 n 較小)則直接解決,不然將其分解爲 k 個規模較小的子問題,這些子問題互相獨立且與原問題形式相同,遞歸地解這些子問題,而後將各子問題的解合併獲得原問題的解。這種算法設計策略叫作分治法。數組

若是原問題可分割成 k 個子問題,1<k≤n,且這些子問題均可解並可利用這些子問題的解求出原問題的解,那麼這種分治法就是可行的。dom

由分治法產生的子問題每每是原問題的較小模式,這就爲使用遞歸技術提供了方便。在這種狀況下,反覆應用分治手段,可使子問題與原問題類型一致而其規模卻不斷縮小,最終使子問題縮小到很容易直接求出其解。這天然致使遞歸過程的產生。分治與遞歸像一對孿生兄弟,常常同時應用在算法設計之中,並由此產生許多高效算法。ide

3、分治法適用的狀況

分治法所能解決的問題通常具備如下幾個特徵:函數

  1. 該問題的規模縮小到必定的程度就能夠容易地解決post

  2. 該問題能夠分解爲若干個規模較小的相同問題,即該問題具備最優子結構性質。性能

  3. 利用該問題分解出的子問題的解能夠合併爲該問題的解

  4. 該問題所分解出的各個子問題是相互獨立的,即子問題之間不包含公共的子子問題。

第一條特徵是絕大多數問題均可以知足的,由於問題的計算複雜性通常是隨着問題規模的增長而增長;

第二條特徵是應用分治法的前提它也是大多數問題能夠知足的,此特徵反映了遞歸思想的應用;、

第三條特徵是關鍵,可否利用分治法徹底取決於問題是否具備第三條特徵,若是具有了第一條和第二條特徵,而不具有第三條特徵,則能夠考慮用貪心法或動態規劃法。

第四條特徵涉及到分治法的效率,若是各子問題是不獨立的則分治法要作許多沒必要要的工做,重複地解公共的子問題,此時雖然可用分治法,但通常用動態規劃法較好。

4、可以使用分治法求解的一些經典問題

  1. 二分搜索

  2. 大整數乘法

  3. Strassen矩陣乘法

  4. 棋盤覆蓋

  5. 合併排序

  6. 快速排序

  7. 線性時間選擇

  8. 最接近點對問題

  9. 循環賽日程表

  10. 漢諾塔

 

5、分治法的基本步驟

分治法在每一層遞歸上都有三個步驟:

  1. 分解:將原問題分解爲若干個規模較小,相互獨立,與原問題形式相同的子問題;

  2. 解決:若子問題規模較小而容易被解決則直接解,不然遞歸地解各個子問題

  3. 合併:將各個子問題的解合併爲原問題的解。

它的通常的算法設計模式以下:

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 的解。

6、依據分治法設計程序時的思惟過程

實際上就是相似於數學概括法,找到解決本問題的求解方程公式,而後根據方程公式設計遞歸程序。
  1. 必定是先找到最小問題規模時的求解方法

  2. 而後考慮隨着問題規模增大時的求解方法

  3. 找到求解的遞歸函數式後(各類規模或因子),設計遞歸程序便可。

 

7、示例

7.1 快速排序

簡述

快速排序是一種排序執行效率很高的排序算法,它利用分治法來對待排序序列進行分治排序,它的思想主要是經過一趟排序將待排記錄分隔成獨立的兩部分,其中的一部分比關鍵字小,後面一部分比關鍵字大,而後再對這先後的兩部分分別採用這種方式進行排序,經過遞歸的運算最終達到整個序列有序,下面咱們簡單進行闡述。

快排思路

咱們從一個數組來逐步逐步說明快速排序的方法和思路。

  1. 假設咱們對數組{7, 1, 3, 5, 13, 9, 3, 6, 11}進行快速排序。

  2. 首先在這個序列中找一個數做爲基準數,爲了方即可以取第一個數。

  3. 遍歷數組,將小於基準數的放置於基準數左邊,大於基準數的放置於基準數右邊

  4. 此時獲得相似於這種排序的數組{3, 1, 3, 5, 6, 7, 9, 13, 11}。

  5. 在初始狀態下7是第一個位置,如今須要把7挪到中間的某個位置k,也即k位置是兩邊數的分界點。

  6. 那如何作到把小於和大於基準數7的值分別放置於兩邊呢,咱們採用雙指針法從數組的兩端分別進行比對

  7. 先從最右位置往左開始找直到找到一個小於基準數的值,記錄下該值的位置(記做 i)。

  8. 再從最左位置往右找直到找到一個大於基準數的值,記錄下該值的位置(記做 j)。

  9. 若是位置i<j,則交換i和j兩個位置上的值,而後繼續從(j-1)的位置往前(i+1)的位置日後重複上面比對基準數而後交換的步驟。

  10. 若是執行到i==j,表示本次比對已經結束,將最後i的位置的值與基準數作交換,此時基準數就找到了臨界點的位置k,位置k兩邊的數組都比當前位置k上的基準值或都更小或都更大。

  11. 上一次的基準值7已經把數組分爲了兩半,基準值7算是已歸位(找到排序後的位置)

  12. 經過相同的排序思想,分別對7兩邊的數組進行快速排序,左邊對[left, k-1]子數組排序,右邊則是[k+1, right]子數組排序

  13. 利用遞歸算法,對分治後的子數組進行排序。

快速排序之因此比較快,是由於相比冒泡排序,每次的交換都是跳躍式的,每次設置一個基準值,將小於基準值的都交換到左邊,大於基準值的都交換到右邊,這樣不會像冒泡同樣每次都只交換相鄰的兩個數,所以比較和交換的此數都變少了,速度天然更高。固然,也有可能出現最壞的狀況,就是仍可能相鄰的兩個數進行交換。

快速排序基於分治思想,它的時間平均複雜度很容易計算獲得爲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;
    }
}

能夠發現的是,這道題目跟前面的仍是很像的。

 

參考文章

分治算法詳解及經典例題 

相關文章
相關標籤/搜索