算法設計技巧: 分治法 (Divide & Conquer)

分治法是一種非常通用的算法設計技巧. 在很多實際問題中, 相比直接求解, 分治法往往能顯著降低算法的計算復雜度. 常見的可以用分治法求解的問題有: 排序, 矩陣乘法, 整數乘法, 離散傅里葉變換等. 分治法的一般思路如下:

  1. [Divide] 把原問題拆分成同類型的子問題.
  2. [Conquer] 用遞歸的方式求解子問題.
  3. [Combine] 根據子問題的解構造原問題的解.

分治法最關鍵的步驟是如何 低成本地 利用子問題的解構來造原問題的解. 它包含兩個方面: 1. 可行性, 即, 可以用子問題的解來構造原問題的解; 2. 高效性, 即構造原問題解的時間復雜度較低. 換句話說, 分治法需要比直接求解效率高. 分治法一般是通過遞歸求解子問題, 其時間復雜度的分析需要用到如下定理.

Master Theorem[1]. 考慮遞歸式T(n) = a T(n/b) + f(n). 當f(n) \in \Theta(n^d), d\geq 0時, 我們有
\begin{aligned} T(n) = \begin{cases} \Theta(n^d) & \text{ if } a < b^vopbpqi \\ \Theta(n^d \log n) & \text{ if } a = b^d \\ \Theta(n^{\log_b a}) & \text{ if } a > b^d \end{cases} \end{aligned}

Counting Inversions[2]

在推薦場景中, 一個常用的方法是協同過濾(Collaborative Filtering), 即為相似的用戶推薦它們共同喜歡的事物. 以推薦歌曲為例, 我們把用戶AB對歌曲的偏好分別進行排序, 然后計算有多少首歌在AB的排序是"不同的". 最后根據這種不同來定義用戶AB的相似性, 從而進行歌曲推薦. 具體來說, 對用戶A對歌曲的偏好按1, 2, \ldots, n編號. 用戶B對歌曲的偏好可以表示為
a_1 < a_2 < \ldots < a_n.

對任意的i<j, 如果a_i > a_j, 我們稱a_ia_j稱為 反序(inversion). 換句話說, 用戶A把歌曲i排在歌曲j的前面, 但是用戶B把歌曲j排在了歌曲i的前面.

問題描述

給定無重復數字的整數序列a_1, a_2, \ldots, a_n, 計算其反序的數量.

算法設計

直接求解的思路是考慮所有的二元組(a_i, a_j)并判斷它們是否反序. 這個算法的時間復雜度是O(n^2). 下面我們用分治法來降低計算復雜度.

注意到這個問題實際上與排序非常類似. 通過對序列進行排序的同時記錄不滿足"順序"的二元組數量, 即反序的數量. 令k=\lfloor n/2 \rfloor. 把序列分成兩部分:

\begin{aligned} & \text{left} = \{a_1, a_2, \ldots, a_k\} \\ & \text{right} = \{a_{k+1}, a_{k+2}, \ldots, a_n\}. \end{aligned}

用遞歸的方式對left和right進行排序, 同時計算left和right中的反序數量. 當序列的長度為1時, 返回0. 下一步是合并子問題的解. 注意到left和right已經是按照從小到大的順序進行排列. 比較left和right的第一個元素并把較小的元素添加到結果中直到left或right為空, 最后再把剩余的序列添加到結果集. 在比較過程中我們需要記錄反序的數量. 當right中的元素小于left中的元素時, 反序的增量為"left中剩余元素的數量". 最終的結果包含三部分之和: left中反序的數量, rihgt中反序的數量和合并時反序的數量.

Python實現

整體的計算過程.

def sort_and_count(x):
    if len(x) == 1:
        return x, 0
    k = len(x) // 2
    left, count_left = sort_and_count(x[0: k])
    right, count_right = sort_and_count(x[k:])
    # 把子問題的解拼接成原問題的解
    combined, count = merge_and_count(left, right)
    return combined, count + count_left + count_right

歸并過程.

def merge_and_count(left, right):
    """ 把left和right合并且計算inversion的數量
    注意: left和right已經排好序
    """
    combined = []
    count = 0
    while len(left) and len(right):
        if left[0] > right[0]:  # 反序(左邊的編號小于右邊的編號是正序)
            combined.append(right.pop(0))
            count += len(left)
        else:  # 正序
            combined.append(left.pop(0))
    return combined + left + right, count

完整代碼

計算復雜度

容易分析歸并過程的時間復雜度是O(n). 令T(n)代表算法的時間復雜度, 我們有

T(n) \leq 2T(n/2) + cn, \quad c \text{ is constant}.

根據Master Theorem, 我們得到T(n) = \Theta(n\log n).

Closest Pair[2]

Closest Pair是計算幾何里的一個基本問題: 給定二維平面上的n個點, 找到距離最近的兩個點. 通過計算任意兩點的距離可以在O(n^2)找到距離最近的兩點. 下面利用分治法可以把時間復雜度降低到O(n\log n).

算法設計

如果所有點是一維的, 我們可以把它們排序, 然后計算所有相鄰兩點的最小距離. 排序耗時O(n\log n), 計算相鄰點的最小距離耗時O(n), 因此算法的時間復雜度為O(n\log n). 在二維情形, 我們的思路是類似的:

  1. 沿著x軸方向對點集P進行排序得到P_x = \{(x_1, y_1), (x_2, y_2), \ldots, (x_n, y_n) \}.

  2. P_x按與x軸垂的方向均分成兩部分QR:
    \begin{aligned} & Q = \{(x_1, y_1), (x_2, y_2), \ldots, (x_{k}, y_{k})\}, \quad k=\lfloor n/2\rfloor \\ & R = \{(x_{k+1}, y_{k+1}), (x_{k+2}, y_{k+2}), \ldots, (x_n, y_n)\}. \end{aligned}

  3. 遞歸地求解QR中的closest pair(如下圖所示).

    image
  4. 根據QR的計算結果構造原問題的解(見下文).

合并(Combine)

(q_0, q_1), (r_0, r_1)分別是QR中的closest pair. 如果P的closest pair在PQ中, 我們只需要從(q_0,q_1)(r_0, r_1)選擇距離小的pair作為結果輸出. 否則P的closest pair其中一點在Q中, 另一點在R中, 這時我們需要比較QR中的點. 這樣一來, 合并的時間復雜度為O(n^2)! 接下來我們要把合并的時間復雜度降低為O(n).

\delta = \min \{d(q_0, q_1), d(r_0, r_1)\}, 其中d(x,y)代表x, y兩點之間的距離. 設L = \{x = x_0 \} 代表QR的分割線. 如果存在q\in Q, r\in R使得d(p,r) < \delta, 那么qrx軸方向距離L一定不超過\delta. 令S = \{(x,y)\in P, \text{s.t. } |x-x_0| < \delta \}, 因此q, r \in S. 如下圖所示, S中的點在藍色虛線之間.
[圖片上傳失敗...(image-facf8c-1586865336187)]
S中的點按y軸從小到大排序, 得到集合S_y = \{s_1, s_2 \ldots \}, 其中s_i是一個二元組(代表它在平面中的位置). 我們有如下定理(稍后給出證明):

定理 如果存在s_i, s_j \in S_y滿足d(s_i,s_j) < \delta, 那么|i-j| \leq 15.

這樣一來, 我們可以在O(n)的時間內找到所有距離不超過\delta的點對, 并記錄距離最小的點對作為結果輸出(如果存在). 思路思路如下:

pairs_within_delta = []  # S中距離不超過delta的點的集合
for s in Sy:
    for t in 15 points after s:
        if d(s, t) < delta:
            add (s,t) to pairs_within_delta
output the minimum distance pair in pairs_within_delta

求解子問題QR之前, 首先把P根據y軸從小到大排序得到P_y, 這樣一來可以在O(n)時間內構造S_y, 即依次過濾掉P_y中距離L超過\delta的點. 在上述算法中, 外層循環次數是O(n), 內層循環是常數, 因此在合并步驟中構造closest pair的時間復雜度最終降低為O(n).

Python實現

先把輸入點集P分別按x軸和y軸排序, 得到P_xP_y. 遞歸求解的過程參考函數closest_pair_xy.


def closest_pair(points):
    """ 計算二維點集中的closest pair.
    :param points: P = [(x1,y1), (x2,y2), ..., (xn, yn)]
    :return: 兩個距離最近的點
    """
    
    # 把P按x軸和y軸分別進行排序, 得到Px和Py
    # 注意: P, Px, Py 三個集合是相同的(僅僅排序不同)
    Px = sorted(points, key=lambda item: item[0])
    Py = sorted(points, key=lambda item: item[1])
    return closest_pair_xy(Px, Py)
    
    
def closest_pair_xy(Px, Py):
    """ 計算closest pair
    :param Px: 把points按x軸升序排列
    :param Py: 把points按y軸升序排列
    :return: point1, point2
    """
    if len(Px) <= 3:
        return search_closest_pair(Px)
    # 構造子問題的輸入: Qx, Rx, Qy, Ry
    k = len(Px) // 2
    Q, R = Px[0: k], Px[k:]
    Qx, Qy = sorted(Q, key=lambda x: x[0]), sorted(Q, key=lambda x: x[1])
    Rx, Ry = sorted(R, key=lambda x: x[0]), sorted(R, key=lambda x: x[1])
    # 求解子問題
    q0, q1 = closest_pair_xy(Qx, Qy)
    r0, r1 = closest_pair_xy(Rx, Ry)
    # 合并子問題的解
    return combine_results_of_sub_problems(Py, Qx, q0, q1, r0, r1)


def search_closest_pair(points):
    """ 用枚舉的方式尋找closest pair
    :param points: [(x1,y1), (x2,y2), ...]
    :return: closest pair
    """
    n = len(points)
    dist_min, i_min, j_min = math.inf, 0, 0
    for i in range(n-1):
        for j in range(i+1, n):
            d = get_dist(points[i], points[j])
            if d < dist_min:
                dist_min, i_min, j_min = d, i, j
    return points[i_min], points[j_min]


def get_dist(a, b):
    """ 計算兩點之間的歐式距離
    """
    return math.sqrt(math.pow(a[0]-b[0], 2) + math.pow(a[1]-b[1], 2))

T(n)代表closest_pair_xy的計算時間. 根據前文分析, 合并子問題的解并輸出原問題的解的時間復雜度為O(n), 因此我們有
T(n) \leq 2T(n/2) + cn, \quad c \text{ is constant}.
根據Master Theorem, 我們有T(n) = \Theta(n\log n). 此外, 把P分別按x,y軸排序的時間復雜度為O(n\log n), 因此算法整體的時間復雜度是O(n\log n).

下面是合并過程的實現.

def combine_results_of_sub_problems(Py, Qx, q0, q1, r0, r1):
    """
    :param Py: P按y軸排序的結果
    :param Qx: P在x=x0處被切分成Q和R. Qx是Q按x軸排序的結果
    :param q0: (q0, q1)是Q中的closest pair
    :param q1: 參考q0
    :param r0: (r0, r1)是R中的closest pair
    :param r1: 參考r0
    :return: closest pair in P
    """
    # 計算Sy
    d = min(get_dist(q0, q1), get_dist(r0, r1))
    Sy = get_sy(Py, Qx, d)
    # 檢查是否存在距離更小的pair
    s1, s2 = closest_pair_of_sy(Sy)
    if s1 and s2 and get_dist(s1, s2) < d:
        return s1, s2
    elif get_dist(q0, q1) < get_dist(r0, r1):
        return q0, q1
    else:
        return r0, r1


def get_sy(Py, Qx, d):
    """ 根據Py計算Sy.
    :param Py: P按y軸排序的結果
    :param Qx: P在x=x0處被切分成Q和R. Qx是Q按x軸排序的結果
    :param d: delta
    :return: S
    """
    x0 = Qx[-1][0]  # Q最右邊點的x坐標值
    return [p for p in Py if p[0] - x0 < d]


def closest_pair_of_sy(Sy):
    """ 計算集合Sy的closest pair
    """
    n = len(Sy)
    if n <= 1:
        return None, None
    dist_min, i_min, j_min = math.inf, 0, 0
    for i in range(n-1):
        for j in range(i + 1, i + 16):
            if j == len(Sy):
                break
            d = get_dist(Sy[i], Sy[j])
            if d < dist_min:
                dist_min, i_min, j_min = d, i, j
    return Sy[i_min], Sy[j_min]

完整代碼

定理證明

定理 如果存在s_i, s_j \in S_y滿足d(s_i,s_j) < \delta, 那么|i-j| \leq 15.

根據前文的描述, 已知S中的點在下圖藍色虛線之間. 把S中的點按y軸從小到大排序得到S_y = \{s_1, s_2, \ldots\}, 其中s_i代表平面中的一個點. 為了方便描述, 我們把下圖中藍色虛線之間用單位長度為\delta/2的網格劃分.
[圖片上傳失敗...(image-dad441-1586865336187)]
假設存在s_i,s_j使得d(s_i, s_j) < \delta. 我們要證明|i-j| \leq 15. 證明分為兩步:

  1. s_is_j必須在不同的網格中. 反證法. 假設s_i, s_j在同一個網格中(意味著s_i,s_j\in P or Q), 它們的距離d(s_i, s_j) \leq \frac{\delta}{2}\sqrt{2} < \delta. 注意\deltaPQ中的最短距離, 因此矛盾.
  2. |i-j|\leq 15. 反證法. 假設|i-j| \geq 16. 如上圖所示s_is_j之間至少相差3行(網格). 因此d(s_i, s_j) \geq \frac{\delta}{2} \cdot 3 > \delta, 矛盾.

參考文獻


  1. T.H. Cormen, C. E. Leiserson, R.L. Rivest and C. Stein. Introduction to Algorithms. Third edition. The MIT Press, 2009. ?

  2. J. Kleinberg and E. Tardos. Algorithm Design. Pearson, 2006. ? ?

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,646評論 6 533
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,595評論 3 418
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事?!?“怎么了?”我有些...
    開封第一講書人閱讀 176,560評論 0 376
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,035評論 1 314
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,814評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,224評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,301評論 3 442
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,444評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,988評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,804評論 3 355
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,998評論 1 370
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,544評論 5 360
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,237評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,665評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,927評論 1 287
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,706評論 3 393
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,993評論 2 374

推薦閱讀更多精彩內容

  • 什么是分治法 在昨天的文章《漫談數據庫中的join》的最后,提到Grace hash join和Sort-merg...
    LittleMagic閱讀 4,044評論 4 17
  • 分治策略 本文包括分治的基本概念二分查找快速排序歸并排序找出偽幣棋盤覆蓋最大子數組 源碼鏈接:https://gi...
    廖少少閱讀 1,867評論 0 7
  • 本文排序全部基于升序,為了方便閱讀全部基于C,代碼將全部部署到github上。(為方便各位看官調試,代碼中的打印數...
    _onePiece閱讀 445評論 0 1
  • 本文來自我的個人博客 https://www.zhangshenghai.com/posts/57540/ 分治法...
    shenghaishxt閱讀 641評論 0 0
  • 我小時候家里沒啥錢~可是學校要給洪水災區捐款,于是我拿出了我的全部,大約有四個和五個大文具盒那多,五角~一分,五分...
    讀書人一枚閱讀 301評論 1 1