算法设计技巧: 分治法 (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^{d} \\ \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阅读 205,236评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,867评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,715评论 0 340
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,899评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,895评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,733评论 1 283
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,085评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,722评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,025评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,696评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,816评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,447评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,057评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,009评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,254评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,204评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,561评论 2 343

推荐阅读更多精彩内容

  • 什么是分治法 在昨天的文章《漫谈数据库中的join》的最后,提到Grace hash join和Sort-merg...
    LittleMagic阅读 3,991评论 4 17
  • 分治策略 本文包括分治的基本概念二分查找快速排序归并排序找出伪币棋盘覆盖最大子数组 源码链接:https://gi...
    廖少少阅读 1,832评论 0 7
  • 本文排序全部基于升序,为了方便阅读全部基于C,代码将全部部署到github上。(为方便各位看官调试,代码中的打印数...
    _onePiece阅读 433评论 0 1
  • 本文来自我的个人博客 https://www.zhangshenghai.com/posts/57540/ 分治法...
    shenghaishxt阅读 615评论 0 0
  • 我小时候家里没啥钱~可是学校要给洪水灾区捐款,于是我拿出了我的全部,大约有四个和五个大文具盒那多,五角~一分,五分...
    读书人一枚阅读 291评论 1 1