Fork me on GitHub

算法导论读书笔记(2)

分治法

算法设计的方法有很多。插入排序 使用的是 增量 (incremental)方法:在排好子数组 A [ 1 .. j - 1 ]后,将元素 A [ j ]插入,形成排好序的子数组 A [ 1 .. j ]。

此外,有很多算法在结构上是 递归 的:为了解决一个给定的问题,算法要一次或多次地递归调用其自身来解决相关子问题。这些算法采用的是 分治策略 (divide-and-conquer):将原问题划分成 n 个规模较小而结构与原问题相似的子问题;递归地解决这些子问题,然后再合并其结果,就得到原问题的解。

分治模式在每一层递归上都有三个步骤:

分解(Divide):
将原问题分解成一系列子问题;
解决(Conquer)
递归地解各个子问题。若子问题足够小,则直接求解;
合并(Combine)
将子问题的结果合并成原问题的解。

归并排序

归并排序(merge sort)完全依照了上述模式,直观的操作如下:

分解:
n 个元素分成各含 n / 2个元素的子序列;
解决:
用归并排序法对两个子序列递归地排序;
合并:
合并两个已排序的子序列以得到排序结果。

对子序列排序时,其长度为1时递归结束。单个元素被视为是已排好序的。

归并排序的关键步骤在于合并两个已排序的子序列。这里引入一个辅助过程 MERGE(A, p, q, r) ,其中 A 为数组, pqr 都是下标,有 p <= q < r 。该过程假设子数组 A [ p .. q ]和 A [ q + 1 .. r ]都已排好序,并将它们合并成一个已排好序的子数组代替当前子数组 A [ p .. r ]。

MERGE(A, p, q, r)
1  n1 = q - p + 1
2  n2 = r - q
3  let L[1 .. n1 + 1] and R[1 .. n2 + 1] be new arrays
4  for i = 1 to n1
5      L[i] = A[p + i - 1]
6  for j = 1 to n2
7      R[j] = A[q + j]
8  L[n1 + 1] = MAX
9  R[n2 + 1] = MAX
10 i = 1
11 j = 1
12 for k = p to r
13     if L[i] <= R[j]
14         A[k] = L[i]
15         i = i + 1
16     else 
17         A[k] = R[j]
18         j = j + 1

MERGE 过程的时间代价为 Θ ( n ),其中 n = r - p + 1。

现在,就可以讲 MERGE 过程作为归并排序中的一个子程序来使用了。下面的过程 MERGE-SORT(A, p, r) 对子数组 A [ p .. r ]排序。如果 p >= r ,则该子数组中至多只有一个元素,视为已排序。否则,分解步骤就计算出一个下标 q ,将 A [ p .. r ]分成 A [ p .. q ]和 A [ q + 1 .. r ],各含 FLOOR(n / 2) 1 个元素。

MERGE-SORT(A, p, r)
1 if p < r
2     q = (p + r) / 2
3     MERGE-SORT(A, p, q)
4     MERGE-SORT(A, q + 1, r)
5     MERGE(A, p, q, r)

下图自底向上地展示了当 n 为2的幂时,整个过程中的操作。算法将两个长度为1的序列合并成已排序的,长度为2的序列,接着又将长度为2的序列合并成长度为4的序列,直到最终形成排好序的 n 的序列。

归并排序的简单Java实现:

/**
 * 归并排序
 *
 * @param array
 */
public static void mergeSort(int[] array) {
    mergeSort(array, 0, array.length - 1);
}

private static void mergeSort(int[] array, int p, int r) {
    int q;
    if (p < r) {
        q = (p + r) >> 1;
        mergeSort(array, p, q);
        mergeSort(array, q + 1, r);
        merge(array, p, q, r);
    }
}

private static void merge(int[] array, int p, int q, int r) {
    int lLen = q - p + 1;
    int rLen = r - q;
    int[] left = new int[lLen + 1];
    int[] right = new int[rLen + 1];
    int i, j;
    for (i = 0; i < lLen; i++)
        left[i] = array[p + i];
    for (j = 0; j < rLen; j++)
        right[j] = array[q + j + 1];
    left[i] = Integer.MAX_VALUE;
    right[j] = Integer.MAX_VALUE;
    i = j = 0;
    for (int k = p; k <= r; k++) {
        if (left[i] <= right[j])
            array[k] = left[i++];
        else
            array[k] = right[j++];
    }
}

分治法分析

当一个算法中含有对其自身的递归调用时,其运行时间可以用一个 递归方程 (或 递归式 )来表示。该方程通过描述子问题与原问题的关系,来给出总的运行时间。

T ( n )为一个规模为 n 的问题的运行时间。如果问题的规模足够小,如 n <= cc 为一个常量),则得到它的直接解的时间为常量,写作 Θ (1)。假设我们把原问题分解成 a 个子问题,每一个的大小是原来的1 / b 。如果分解该问题和合并解的时间各为 D ( n )和 C ( n ),则得到递归式:

归并排序算法的分析

为简化分析,假定原问题的规模是2的幂,这样每次分解产生的子序列长度就恰好为 n / 2。

以下给出了归并排序 n 个数的运行时间。归并排序一个元素的时间是常量。当 n > 1时,将运行时间分解如下:

分解:
计算出子数组的中间位置,需要常量时间,因而 D ( n ) = Θ (1)。
解决:
递归地解两个规模为 n / 2的子问题,时间为2 T ( n / 2)。
合并:
MERGE 过程的运行时间为 Θ ( n ),则 C ( n ) = Θ ( n )。

如此得到归并排序最坏情况下运行时间 T ( n )的递归表示:

递归式1

此处可以直观地看出 T ( n ) = Θ ( n lg n),重写递归式如下:

递归式2

其中常量 c 代表规模为1的问题所需的时间。

下图说明了如何解递归式2。它将 T ( n )扩展一种等价树形式。 c n 是树根(即顶层递归的代价),根的两棵子树是两个更小一点的递归式 T ( n / 2),它们的代价都是 c n / 2.继续扩展直到问题的规模降到了1,此时每个问题的代价为 c

接下来将树的每一层代价相加。一般来说,最顶层之下的第 i 层有 2i 个结点,每个的代价都是 c ( n / 2i ),于是,第 i 层的总代价为 2i c ( n / 2i )。

要计算递归式的总代价,只要将递归树中各层的代价加起来就可以。在该树中,共有lg n + 1层,每一层的代价都是 c n ,于是,树的总代价为 c n (lg n + 1) = c n lg n + c n 。忽略低阶项和常量,得到结果 Θ ( n lg n )。

练习

2.3-2

改写 MERGE 过程,不使用哨兵元素。

MERGE(A, p, q, r)
1  n1 = q - p + 1;
2  n2 = r - q;
3  let L[1 .. n1] and R[1 .. n2] be new arrays
4  for i = 1 to n1
5      L[i] = A[p + i - 1]
6  for j = 1 to n2
7      R[j] = A[q + j]
8  i = j = 1
9  k = p
10 while i <= n1 and j <= n2
11     if L[i] <= R[j]
12         A[k] = L[i]
13         k = k + 1
14         i = i + 1
15     else
16         A[k] = R[j]
17         j = j + 1
18 while i <= n1
19     A[k] = L[i]
20     k = k + 1
21     i = i + 1
22 while j <= n2
23     A[k] = R[j]
24     j = j + 1

2.3-4

将插入排序改写成递归过程,并写出运行时间的递归式。

INSERTION-SORT-RECURSIVE(A, p)
1 if p > 1
2     key = A[p]
3     p = p - 1
4     INSERTION-SORT-RECURSIVE(A, p)
5     INSERTION-ELEMENT(A, p, key)
INSERTION-ELEMENT(A, p, key)
1 while p > 0 and A[p] > key
2     A[p + 1] = A[p]
3     p = p - 1
4 A[p + 1] = key

该过程的运行时间如下分解:

分解:
缩小子数组规模,需要常量时间 D ( n ) = Θ (1)。
解决:
递归地解一个规模为 n - 1的子问题,时间为 T ( n - 1)。
合并:
INSERTION-ELEMENT 过程的运行时间是线性的,即 C ( n ) = Θ ( n )。

则递归版本插入排序的递归式可写为 T ( n ) = T ( n - 1) + Θ ( n )。最终结果就是 T ( n ) = Θ ( n2 )。

2.3-5

二分查找伪码:

BINARY-SEARCH(A, v)
1  front = 1
2  end = A.length
3  while front < end
4      middle = (front + end) / 2
5      if A[middle] < v
6          front = middle + 1
7      else if A[middle] > v
8          end = middle - 1
9      else
10         return middle
11 return -1

2.3-7

设计算法:查找集合 S 中是否存在两个其和等于 x 的元素。

CHECK-SUM(S, x)
1  A = MERGE-SORT(S)
2  for i = 1 to A.length
3      v = x - A[i]
4      if BINARY-SEARCH(A, v) > 0
5          return true
6  return false

思考题

在归并排序中对小数组采用插入排序

尽管归并排序的最坏情况运行时间为 Θ ( n lg n ),插入排序的最坏情况运行时间为 Θ ( n2 ),但插入排序中的常数因子使得它在 n 比较小时,运行得要更快一些。因此,在归并排序算法中,当子问题足够小的时候,采用插入排序就比较合适了。考虑对归并排序作这样的修改,即采用插入排序策略,对 n / k 个长度为 k 的子列表进行排序,然后,再用标准的合并机制将它们合并起来,此处 k 是一个待定值。

假设 n / k 是2的幂(这样可以很容易的算出树的高度),设 T ( n )为该算法最坏情况运行时间,则函数的等价树结构如下:

可以看到,树共有lg( n / k ) + 1层,最底层共有 n / k 个结点,每个结点都是长度为 k 的子列表。规模为 k 的插入排序的最坏情况运行时间是关于 k 的二次函数,表示为 T ( k ) = a k2 + b k + c 。共有 n / k 个这样的子序列,所以总的运行时间 L ( n ) = ( n / k ) T ( k )。最终可知, n / k 个子列表(每个子列表的长度为 k )可以用插入排序在 Θ ( n k )时间内完成排序。

可知树共有lg( n / k ) + 1层。除最后一层外,其余各层全部用于合并子列表,每一层的代价都是 c n 。最后一层的时间代价已知为 Θ ( n k )。所以算法总的运行时间就是 T ( n ) = c n lg( n / k ) + Θ ( n k )。舍弃低阶项和常数因子,有 T ( n ) = Θ ( n lg( n / k ))。

逆序对

A [ 1 .. n ]是一个包含 n 个不同数的数组。如果在 i < j 的情况下,有 A [ i ] > A [ j ],则( i , j )就称为 A 中的一个 逆序对 (inversion)。

降幂排列的数组拥有的逆序对是最多的,对于长度为 n 的数组来说,共有( n - 1)!个逆序对。

脚注

1

FLOOR(x) 记号表示小于等于 x 的最大整数, CEIL(x) 表示大于等于 x 的最小整数。

posted on 2014-04-12 18:53  sungoshawk  阅读(1865)  评论(0编辑  收藏  举报