Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

分治法

我们可以选择使用的算法设计技术有很多。插入排序使用了增量方法。本节我们考查另一种称为“分治法”的设计方法。分治算法通过递归拆分问题,显著降低时间复杂度,是算法设计中的重要范式。

许多有用的算法在结构上是递归1的: 为了解决一个给定的问题,算法一次或多次递归地调用其自身以解决紧密相关的若干子问题。这些算法典型地遵循分治法的思想: 将原问题分解为几个规模较小但类似于原问题的子问题,递归地求解这些子问题,然后再合并这些子问题的解来建立原问题的解。

分治法通过下面的这些步骤解决问题:

  • 分解(Divide): 将原问题划分为多个规模较小的子问题(通常与原问题结构相同)。
  • 解决(Conquer): 递归地解决子问题。若子问题规模足够小,则直接求解2
  • 合并(Combine): 将子问题的解合并为原问题的解。

可以看出分治主张 “分而治之” 的哲学,形式上一般通过递归来解决问题,它的子问题是独立、不重叠的。

我们将用分治法来设计一个排序算法,该算法的最坏情况运行时间比插入排序要少得多。

归并排序

我们来看一个很早就接触过的主问题:

输入

个数的数组

输出

输入数组的升序排列 ,满足

对于这个问题,我们进行分割将数组分割成两个n/2的子数组(实际上,n/2会被截断小数部分,所以当n为奇数时,右半部分(right)会多出一个元素3),然后对这两个子数组重复上面分割。不可分割时,需进行排序,然后合并已排序的数组。这是一个先自上而下分解问题,再**逐步回升(自底向上)**解决问题的过程。

实现1

pub fn realize<T: Ord + Clone>(arr: &mut [T]) {
    if arr.len() <= 1 {
        return; // 基本情况: 数组长度为0或1时直接返回
    }

    let mid = arr.len() / 2;
    let mut left = arr[..mid].to_vec(); // 分割左半部分
    let mut right = arr[mid..].to_vec(); // 分割右半部分

    // 递归排序左右子数组
    merge_sort(&mut left);
    merge_sort(&mut right);

    // 合并排序后的左右子数组
    merge(arr, &left, &right);
}

// 合并两个有序数组的辅助函数
fn merge<T: Ord + Clone>(arr: &mut [T], left: &[T], right: &[T]) {
    let (mut i, mut j, mut k) = (0, 0, 0);

    // 比较左右子数组的元素,按顺序合并到原数组
    while i < left.len() && j < right.len() {
        if left[i] <= right[j] {
            arr[k] = left[i].clone();
            i += 1;
        } else {
            arr[k] = right[j].clone();
            j += 1;
        }
        k += 1;
    }

    // 处理剩余元素(左子数组或右子数组可能未完全合并)
    while i < left.len() {
        arr[k] = left[i].clone();
        i += 1;
        k += 1;
    }
    while j < right.len() {
        arr[k] = right[j].clone();
        j += 1;
        k += 1;
    }
}

在合并两个有序数组中,由于两个数组都已排序,所以我们就要从中观察两个数字的首项,找到较小的项加入新数组,这个过程中保证了新数组有序。其中一个子数组全部排完后,直接将剩下的有序数组放入新数组。我们来观察一组输入:

1 2 4
3 7 8

它的变化过程是这样的:

1.
left: 2 4
right: 3 7 8
new: 1
2.
left: 4
right: 3 7 8
new: 1 2
3.
left: 4
right: 7 8
new: 1 2 3
4.
left: 
right: 7 8
new: 1 2 3 4
5. (将剩下数组放入)
left:
right:
new: 1 2 3 4 7 8

当然,这只是一种思维方式。实际的实现中,我们通常不改变左右数组,而是简单的通过一个索引来从中获取数据并模拟扔出数据的过程。这个过程需要遍历两个数组的所有元素,所以它是4的。

《算法导论》中,将左右数组的最后一项设为,只需要遍历到就意味着其中一个数组已经遍历完,不需要像 实现一 一样检查剩余元素。这里用于简化边界条件的便是哨兵值

哨兵(Sentinel) 是一种用于简化算法边界条件的编程技巧。它通常是一个特殊的值或节点,用于标记数据结构的边界,从而避免在循环或递归中频繁检查边界条件,提升代码的简洁性和效率。

在Rust我们做不到使用,哪怕用Default::default也会导致数据冲突(如数字0),所以我们不采用哨兵值。如果你认为你的数据里不会产生0-1这样的值,那加上哨兵值也是可取的。


练习与回答

  1. 试给出归并排序的循环不变式并证明。

循环不变式

MERGE(辅助函数)过程的while i < left.len() && j < right.len()循环的每次迭代开始时,子数组 A[p..k-1](即arr) 包含 L[1..i-1]R[1..j-1] (即 leftright)中的 k-p 个最小元素,并且 A[p..k-1] 是有序的。进而,L[i]R[j] 是各自数组中未被复制回 A 的最小元素。

证明

初始化

在循环开始之前,k = p,因此A[p..k-1]是空的,即不包含任何元素。i = 1j = 1,所以L[1..i-1]R[1..j-1]也是空的。因此,A[p..k-1]包含0个最小元素,且是有序的。L[1]R[1]分别是LR中未被复制的最小元素(因为还没有任何元素被复制)。因此,初始化时循环不变式成立。

保持

假设在循环的某次迭代开始时,循环不变式成立。即A[p..k-1]包含L[1..i-1]R[1..j-1]中的k-p个最小元素,且A[p..k-1]是有序的。L[i]R[j]是各自数组中未被复制的最小元素。

在循环体中,我们比较L[i]R[j]

  • 如果L[i] ≤ R[j],则将L[i]复制到A[k],然后ik都加 1
    • 因为L[i]L中未被复制的最小元素,且L[i] ≤ R[j],所以L[i]LR中未被复制的最小元素。
    • L[i]放入A[k]后,A[p..k]现在包含L[1..i]R[1..j-1]中的个最小元素,且是有序的。
    • L[i+1]R[j]现在是各自数组中未被复制的最小元素。
  • 如果L[i] > R[j],则将R[j]复制到A[k],然后jk都加 1
    • 类似地,R[j]R中未被复制的最小元素,且R[j] < L[i],所以R[j]LR中未被复制的最小元素。
    • R[j]放入A[k]后,A[p..k]现在包含L[1..i-1]R[1..j]中的个最小元素,且是有序的。
    • L[i]R[j+1]现在是各自数组中未被复制的最小元素。

因此,在每次迭代后,循环不变式仍然成立。

终止

当循环终止时,k = r + 1。根据循环不变式,A[p..k-1]A[p..r]包含L[1..i-1]R[1..j-1]中的个最小元素,且是有序的。因为LR总共有个元素,所以A[p..r]包含了LR的所有元素,且是有序的。


  1. 递归(Recursion) 是算法设计中的一种重要技术,指函数或过程直接或间接调用自身来解决问题的方法。

  2. 这种可直接求解的边界叫做基线条件(Base Case)。如 实现一 的基本情况。

  3. 这意味着右数组有个元素,左数组有个元素。其中表示大于或等于的最小整数,表示小于或等于的最大整数。

  4. 这只是辅助函数的时间复杂度,归并排序的实际复杂度在后面解答。