算法-04-分治算法与排序

24 年 4 月 30 日 星期二
4055 字
21 分钟

分治算法概述

分治算法(Divide-and-Conquer)是一种最著名的算法设计策略,主要包括三个步骤:

  1. 分解(Divide):将问题实例分解为两个或多个较小的实例
  2. 解决(Conquer):递归地解决较小的实例
  3. 合并(Combine):将较小实例的解组合成原始实例的解

分治算法的应用实例

分治算法的一般递归关系

对于规模为nn的问题,将其分解为aa个规模为n/bn/b的子问题。

T(n)T(n)表示规模为nn的问题的时间复杂度,f(n)f(n)表示分解和合并子问题的时间代价,那么有如下递归关系:

T(n)=aT(nb)+f(n),for n=bk,k=1,2,T(n) = aT(\frac{n}{b}) + f(n), \text{for } n=b^k, k=1,2,\dots T(1)=cT(1) = c

其中a1,b>1,c>0,f(n)Θ(nd),d0a \geq 1, b > 1, c > 0, f(n) \in \Theta(n^d), d \geq 0

主定理(Master Theorem)

对于上述递归关系,有以下结论:

  • a<bda < b^d,则T(n)Θ(nd)T(n) \in \Theta(n^d)
  • a=bda = b^d,则T(n)Θ(ndlogn)T(n) \in \Theta(n^d \log n)
  • a>bda > b^d,则T(n)Θ(nlogba)T(n) \in \Theta(n^{\log_b a})

注:对于OOΩ\Omega记号也有类似的结论。

排序算法

Briefing

Problem

Given a list of 𝑛 orderable items (e.g., numbers, characters from some alphabets, character strings), rearrange them in ascending order.

Properties

  • Stable: preserves relative order of any two equal elements in input.
  • In-place: not require extra memory, except, possibly, for a few memory units.

Brute-Force Solutions (Θ(n2)\Theta (n^2))

  • Selection Sort
  • Bubble Sort

归并排序(Merge sort)

归并排序由John von Neumann于1945年发明。其基本思想为:

  1. 将数组A[0..n1]A[0..n-1]平均分成两半,分别拷贝到数组BBCC
  2. 递归地对数组BB进行排序
  3. 递归地对数组CC进行排序
  4. 将排好序的数组BBCC合并到数组AA

归并两个有序数组

重复以下步骤,直到其中一个数组中没有剩余元素:

  • 比较两个数组中当前未处理部分的第一个元素
  • 将较小的元素复制到AA中,同时将该数组的未处理部分索引加1

一旦一个数组的所有元素都已处理完,就将另一个数组中剩余的未处理元素复制到AA中。

归并排序的分析

假设nn是2的幂(即n=2kn=2^k),那么在最坏情况下的关键字比较次数Cworst(n)C_{worst}(n)满足以下递归关系:

Cworst(n)=2Cworst(n2)+n1,for n>1C_{worst}(n) = 2C_{worst}(\frac{n}{2}) + n - 1, \text{for } n > 1 Cworst(1)=0C_{worst}(1) = 0

通过反向替换法或主定理可求解得:

Cworst(n)=nlog2nn+1Θ(nlogn)C_{worst}(n) = n\log_2 n - n + 1 \in \Theta(n\log n)

归并排序的所有情况(最好、最坏、平均)的时间复杂度都是Θ(nlogn)\Theta(n\log n),但其空间复杂度为Θ(n)\Theta(n),不是In-Place。

C++ Code

C++ Code:

cpp
// C++ program for Merge Sort
#include <bits/stdc++.h>
using namespace std;

// Merges two subarrays of array[].
// First subarray is arr[begin..mid]
// Second subarray is arr[mid+1..end]
void merge(int array[], int const left, int const mid,
           int const right)
{
    int const subArrayOne = mid - left + 1;
    int const subArrayTwo = right - mid;

    // Create temp arrays
    auto *leftArray = new int[subArrayOne],
         *rightArray = new int[subArrayTwo];

    // Copy data to temp arrays leftArray[] and rightArray[]
    for (auto i = 0; i < subArrayOne; i++)
        leftArray[i] = array[left + i];
    for (auto j = 0; j < subArrayTwo; j++)
        rightArray[j] = array[mid + 1 + j];

    auto indexOfSubArrayOne = 0, indexOfSubArrayTwo = 0;
    int indexOfMergedArray = left;

    // Merge the temp arrays back into array[left..right]
    while (indexOfSubArrayOne < subArrayOne
           && indexOfSubArrayTwo < subArrayTwo) {
        if (leftArray[indexOfSubArrayOne]
            <= rightArray[indexOfSubArrayTwo]) {
            array[indexOfMergedArray]
                = leftArray[indexOfSubArrayOne];
            indexOfSubArrayOne++;
        }
        else {
            array[indexOfMergedArray]
                = rightArray[indexOfSubArrayTwo];
            indexOfSubArrayTwo++;
        }
        indexOfMergedArray++;
    }

    // Copy the remaining elements of
    // left[], if there are any
    while (indexOfSubArrayOne < subArrayOne) {
        array[indexOfMergedArray]
            = leftArray[indexOfSubArrayOne];
        indexOfSubArrayOne++;
        indexOfMergedArray++;
    }

    // Copy the remaining elements of
    // right[], if there are any
    while (indexOfSubArrayTwo < subArrayTwo) {
        array[indexOfMergedArray]
            = rightArray[indexOfSubArrayTwo];
        indexOfSubArrayTwo++;
        indexOfMergedArray++;
    }
    delete[] leftArray;
    delete[] rightArray;
}

// begin is for left index and end is right index
// of the sub-array of arr to be sorted
void mergeSort(int array[], int const begin, int const end)
{
    if (begin >= end)
        return;

    int mid = begin + (end - begin) / 2;
    mergeSort(array, begin, mid);
    mergeSort(array, mid + 1, end);
    merge(array, begin, mid, end);
}

// UTILITY FUNCTIONS
// Function to print an array
void printArray(int A[], int size)
{
    for (int i = 0; i < size; i++)
        cout << A[i] << " ";
    cout << endl;
}
// This code is contributed by Mayank Tyagi
// This code was revised by Joshua Estes
// [归并排序 - 数据结构和算法教程 - GeeksforGeeks --- Merge Sort - Data Structure and Algorithms Tutorials - GeeksforGeeks](https://www.geeksforgeeks.org/merge-sort/)

快速排序(Quick sort)

快速排序由Tony Hoare于1959年发明,1961年发表。其基本思想为:

  1. 分解(Divide)
    • 选择一个主元(pivot),这里取第一个元素
    • 重排数组使得主元左边的元素都小于等于它,右边的元素都大于等于它
    • 将主元与第一个子数组的最后一个元素交换,此时主元在其最终位置上
  2. 解决(Conquer):递归地对两个子数组进行排序
  3. 合并(Combine):无需额外操作,因为子数组已经有序

Hoare’s Partitioning算法

Hoare’s Partitioning算法从数组的两端开始扫描,将元素与主元进行比较:

Note: may also pick last element or random element or median element as pivot

  • 从左到右扫描(索引ii),直到遇到一个大于等于主元的元素
  • 从右到左扫描(索引jj),直到遇到一个小于等于主元的元素

当两个扫描索引都停止时:

  1. Case1:
    • i<ji < j,交换A[i]A[i]A[j]A[j],增加ii,减少jj,继续扫描 (scanning indices have not crossed)
  2. Case2&3:
    • iji \geq j,将主元与A[j]A[j]交换 (scanning indices have crossed over or point to the same element)

快速排序的分析

  • 最好情况:每次划分都将数组均匀地一分为二,递归树的高度为logn\log n,总的划分时间为O(n)O(n)。此时比较次数Cbest(n)Θ(nlogn)C_{best}(n) \in \Theta(n\log n)

  • 最坏情况:每次划分都将数组分得尽可能不均匀,此时主元是数组中的最大或最小元素。比较次数为:

Cworst(n)=n+1+n++3=(n+1)(n+2)23Θ(n2)C_{worst}(n) = n + 1 + n + \dots + 3 = \frac{(n+1)(n+2)}{2} - 3 \in \Theta(n^2)
  • 平均情况:假设每个划分点ss都以相同的概率1/n1/n出现,那么比较次数约为:
Cavg(n)2nlnn1.39nlog2nC_{avg}(n) \approx 2n\ln n \approx 1.39n\log_2 n

即平均情况下,快速排序仅比最好情况多39%的比较次数。一些常见的快速排序改进方法包括:

  • 更好的主元选取:随机化快速排序、三数取中划分
  • 对小子数组切换到插入排序
  • 消除递归

这些优化可使快速排序提速20-25%。

大整数乘法

给定两个(大)nn位整数aabb,计算a×ba \times b

常规的逐位相乘算法

逐位相乘需要n2n^2次一位数乘法,其中nn是要相乘的数字的位数。例如,两个3位数相乘需要9次乘法。

第一个分治算法

以相乘两个2位数a=12,b=34a=12,b=34为例,我们有:

a=1101+2100b=3101+4100\begin{aligned} a &= 1 \cdot 10^1 + 2 \cdot 10^0 \\ b &= 3 \cdot 10^1 + 4 \cdot 10^0 \end{aligned} a×b=(1101+2100)×(3101+4100)=1×3102+(1×4+2×3)101+2×4100\begin{aligned} a \times b &= (1 \cdot 10^1 + 2 \cdot 10^0) \times (3 \cdot 10^1 + 4 \cdot 10^0) \\ &= 1 \times 3 \cdot 10^2 + (1 \times 4 + 2 \times 3) \cdot 10^1 + 2 \times 4 \cdot 10^0 \end{aligned}

一般地,若a=a1a2,b=b1b2a=a_1a_2, b=b_1b_2,其中aabbnn位数,a1,a2,b1,b2a_1,a_2,b_1,b_2n/2n/2位数,那么有:

a×b=(a1×b1)10n+(a1×b2+a2×b1)10n/2+(a2×b2)a \times b = (a_1 \times b_1) \cdot 10^n + (a_1 \times b_2 + a_2 \times b_1) \cdot 10^{n/2} + (a_2 \times b_2)

该算法的单位数字乘法次数M(n)M(n)满足递归关系:

M(n)=4M(n2),M(1)=1M(n) = 4M(\frac{n}{2}), M(1)=1

求解得M(n)=Θ(n2)M(n) = \Theta(n^2),与逐位相乘算法的效率相同

Karatsuba算法

Karatsuba和Ofman在1962年提出了一种更高效的大整数乘法算法。其思想是将乘法转化为加法和减法:

a×b=(a1×b1)10n+(a1×b2+a2×b1)10n/2+(a2×b2)a \times b = (a_1 \times b_1) \cdot 10^n + (a_1 \times b_2 + a_2 \times b_1) \cdot 10^{n/2} + (a_2 \times b_2) (a1×b2+a2×b1)=(a1+a2)×(b1+b2)a1×b1a2×b2(a_1 \times b_2 + a_2 \times b_1) = (a_1+a_2) \times (b_1+b_2) - a_1 \times b_1 - a_2 \times b_2
  • Example: 计算 123×456123 \times 456:

    首先将两个因数分割成高位和低位:

    • a1=12,a2=3a_1 = 12, a_2 = 3
    • b1=45,b2=6b_1 = 45, b_2 = 6

    接下来计算:

    • a1×b1=12×45=540a_1 \times b_1 = 12 \times 45 = 540
    • a2×b2=3×6=18a_2 \times b_2 = 3 \times 6 = 18
    • (a1+a2)×(b1+b2)=15×51=765(a_1 + a_2) \times (b_1 + b_2) = 15 \times 51 = 765

    然后根据公式: (a1×b2+a2×b1)=(a1+a2)×(b1+b2)a1×b1a2×b2(a_1 \times b_2 + a_2 \times b_1) = (a_1 + a_2) \times (b_1 + b_2) - a_1 \times b_1 - a_2 \times b_2 =76554018= 765 - 540 - 18 =207= 207

    所以最终结果为: 123×456=(540×102)+(207×10)+18123 \times 456 = (540 \times 10^2) + (207 \times 10) + 18 =54000+2070+18= 54000 + 2070 + 18
    =56088= 56088

这减少了乘法次数,从4次降到3次。Karatsuba算法的单位数字乘法次数M(n)M(n)满足递归关系:

M(n)=3M(n2),M(1)=1M(n) = 3M(\frac{n}{2}), M(1)=1

求解得M(n)=Θ(nlog23)=Θ(n1.58496)M(n) = \Theta(n^{\log_2 3}) = \Theta(n^{1.58496\dots})

大整数乘法在密码学、求pi、寻找大素数、几何和代数问题等领域有广泛应用。目前仍有许多研究致力于寻找更高效的大整数乘法算法。

最近点对问题(Closest-Pair Problem)

给定二维平面上的nn个点,找出其中距离最近的两个点。

蛮力算法

计算每对不同点之间的欧几里得距离,返回距离最小的点对的索引。该算法的时间复杂度为Θ(n2)\Theta(n^2)

分治算法

分治算法的基本思想为:

  1. 假设点按xx坐标升序排列,将点集一分为二(SlS_lSrS_r各有n/2n/2个点)
  2. 递归计算SlS_lSrS_r中的最近点对,设最近距离分别为dld_ldrd_r
  3. d=min{dl,dr}d = \min\{d_l, d_r\},但dd不一定是全局最小距离(最近点对可能跨越SlS_lSrS_r
  4. 检查宽度为2d2d的垂直条带内的点对,更新dd

分治算法的时间复杂度T(n)T(n)满足递归关系:

T(n)=2T(n2)+f(n)T(n) = 2T(\frac{n}{2}) + f(n)

其中f(n)Θ(n)f(n) \in \Theta(n),因此T(n)O(nlogn)T(n) \in O(n\log n)

总结

主定理的三种情况

  • a<bda < b^d,则T(n)Θ(nd)T(n) \in \Theta(n^d)
  • a=bda = b^d,则T(n)Θ(ndlogn)T(n) \in \Theta(n^d \log n)
  • a>bda > b^d,则T(n)Θ(nlogba)T(n) \in \Theta(n^{\log_b a})

归并排序

  • 所有情况的时间复杂度均为Θ(nlogn)\Theta(n \log n)
  • 缺点:需要显著的额外存储空间

快速排序

  • 最好情况(每次都从中间划分):Θ(nlogn)\Theta(n \log n)
  • 最坏情况(已经有序):Θ(n2)\Theta(n^2)
  • 平均情况(随机数组):Θ(nlogn)\Theta(n \log n)

Karatsuba大整数乘法算法

时间复杂度为Θ(n1.58496)\Theta(n^{1.58496\dots})

最近点对问题

分治算法的时间复杂度为O(nlogn)O(n \log n)

凸包问题(Convex Hull Problem)

在二维平面上给定一个点集SS,凸包是包含SS中所有点的最小凸多边形。

蛮力算法

  1. 生成点集SS中所有可能的点对
  2. 对于每个点对,检查其他所有点是否都在由该点对形成的直线的同一侧
  3. 如果是,则该点对是凸包上的一条边

该算法的时间复杂度为O(n4)O(n^4)

分治算法

  1. 将点集SSxx坐标升序排序,分成两个子集S1S_1S2S_2,分别包含最左边的n/2\lfloor n/2 \rfloor个点和最右边的n/2\lceil n/2 \rceil个点
  2. 递归地计算S1S_1S2S_2的凸包H1H_1H2H_2
  3. 合并H1H_1H2H_2以获得SS的凸包HH

合并步骤需要找到H1H_1H2H_2的公共支撑线(上、下各一条)。可以通过在H1H_1H2H_2的边界上执行平行的扫描来实现,总时间为O(n)O(n)

因此,该分治算法的运行时间T(n)T(n)满足递归关系:

T(n)=2T(n2)+O(n)T(n) = 2T(\frac{n}{2}) + O(n)

根据主定理,T(n)=O(nlogn)T(n) = O(n \log n)

Graham扫描算法

Graham扫描是另一种求解凸包的有效算法,其时间复杂度为O(nlogn)O(n \log n)

  1. 选择yy坐标最小的点p0p_0(如果有多个,则取最左边的)
  2. p0p_0为原点,按照其他点与p0p_0连线和xx轴正方向的夹角排序(如果夹角相同,则距离p0p_0较近的点排在前面)
  3. 依次考虑排序后的每个点pip_i,维护一个栈SS来存储凸包的顶点:
    • 如果pip_i在栈顶两点所确定的直线上方,则将pip_i压入栈中
    • 否则,不断弹出栈顶元素,直到pip_i在栈顶两点所确定的直线上方,然后将pip_i压入栈中

可以证明,Graham扫描算法的时间复杂度为O(nlogn)O(n \log n)

矩阵乘法

给定n×nn \times n矩阵AABB,计算它们的乘积C=ABC = AB

定义

对于n×nn \times n矩阵A=(aij)A = (a_{ij})B=(bij)B = (b_{ij}),它们的乘积是n×nn \times n矩阵C=(cij)C = (c_{ij}),其中

cij=k=1naikbkjc_{ij} = \sum_{k=1}^n a_{ik}b_{kj}

朴素算法

根据定义,直接计算矩阵乘积的朴素算法需要n3n^3次标量乘法和加法运算。

Strassen算法

Strassen算法是一种使用分治思想的矩阵乘法算法,其时间复杂度优于朴素算法。

  1. AABBCC分块为等大小的子矩阵(每个子矩阵的大小为原矩阵的一半):
A=(A11A12A21A22),B=(B11B12B21B22),C=(C11C12C21C22)A = \begin{pmatrix} A_{11} & A_{12} \\ A_{21} & A_{22} \end{pmatrix}, B = \begin{pmatrix} B_{11} & B_{12} \\ B_{21} & B_{22} \end{pmatrix}, C = \begin{pmatrix} C_{11} & C_{12} \\ C_{21} & C_{22} \end{pmatrix}
  1. 递归地计算10个矩阵乘法(每个乘法的规模为原问题的一半):
M1=(A11+A22)(B11+B22)M2=(A21+A22)B11M3=A11(B12B22)M4=A22(B21B11)M5=(A11+A12)B22M6=(A21A11)(B11+B12)M7=(A12A22)(B21+B22)\begin{aligned} M_1 &= (A_{11} + A_{22})(B_{11} + B_{22}) \\ M_2 &= (A_{21} + A_{22})B_{11} \\ M_3 &= A_{11}(B_{12} - B_{22}) \\ M_4 &= A_{22}(B_{21} - B_{11}) \\ M_5 &= (A_{11} + A_{12})B_{22} \\ M_6 &= (A_{21} - A_{11})(B_{11} + B_{12}) \\ M_7 &= (A_{12} - A_{22})(B_{21} + B_{22}) \end{aligned}
  1. 组合子问题的解以获得原问题的解:
C11=M1+M4M5+M7C12=M3+M5C21=M2+M4C22=M1M2+M3+M6\begin{aligned} C_{11} &= M_1 + M_4 - M_5 + M_7 \\ C_{12} &= M_3 + M_5 \\ C_{21} &= M_2 + M_4 \\ C_{22} &= M_1 - M_2 + M_3 + M_6 \end{aligned}

Strassen算法的时间复杂度T(n)T(n)满足递归关系:

T(n)=7T(n2)+O(n2)T(n) = 7T(\frac{n}{2}) + O(n^2)

根据主定理,T(n)=O(nlog27)O(n2.81)T(n) = O(n^{\log_2 7}) \approx O(n^{2.81})

尽管Strassen算法在理论上优于朴素算法,但由于其较大的常数因子和较差的数值稳定性,在实践中只有当矩阵规模非常大时才能体现出优势。

二叉树遍历

二叉树遍历是一类基本的树算法,包括前序、中序、后序遍历。

递归算法

以中序遍历为例,递归算法的伪代码如下:

text
function inorder_traversal(node):
    if node is not null:
        inorder_traversal(node.left)
        visit(node)
        inorder_traversal(node.right)

对于一棵有nn个节点的二叉树,遍历的时间复杂度为O(n)O(n),空间复杂度(考虑递归调用栈)为O(h)O(h),其中hh是树的高度。

Morris遍历算法

Morris遍历算法是一种空间复杂度为O(1)O(1)的二叉树遍历算法,适用于前序、中序、后序遍历。以中序遍历为例,算法步骤如下:

  1. 如果当前节点curcur的左子树为空,则访问curcur并将curcur移动到其右子节点
  2. 如果curcur的左子树不为空,则找到curcur左子树上最右边的节点(即curcur的前驱prepre):
    • 如果prepre的右子节点为空,则将其右子节点指向curcur,并将curcur移动到其左子节点
    • 如果prepre的右子节点为curcur,则将prepre的右子节点重新设为空(恢复树的原状),访问curcur,并将curcur移动到其右子节点
  3. 重复上述步骤,直到curcur为空

Morris遍历算法的时间复杂度为O(n)O(n),空间复杂度为O(1)O(1)

分治算法与动态规划的比较

分治算法和动态规划都是解决复杂问题的重要算法范式,它们在某些问题上有相似之处,但也有明显的区别。

相似之处

  • 都是将原问题分解为若干个规模较小、结构相似的子问题
  • 子问题的解可以合并为原问题的解
  • 通常使用递归实现

区别

  • 子问题的独立性:
    • 分治算法通常假设子问题是相互独立的,不包含公共的子问题
    • 动态规划适用于子问题不独立的情况,子问题之间可能有重叠
  • 问题的性质:
    • 分治算法更适合于子问题规模大体相同的问题,如排序、线性时间选择等
    • 动态规划更适合于有优子结构和重叠子问题性质的最优化问题,如矩阵链乘、最长公共子序列等
  • 自顶向下与自底向上:
    • 分治算法通常采用自顶向下的递归实现
    • 动态规划可以采用自顶向下的记忆化搜索实现,也可以采用自底向上的迭代实现

总之,分治算法与动态规划都是解决问题的重要工具,需要根据具体问题的性质选择合适的算法。在某些情况下,两种算法可以结合使用以发挥各自的优势。

参考文献

文章标题:算法-04-分治算法与排序

文章作者:DWHITE

文章链接:https://dr9k69ai79.github.io/MyBlog/posts/schoolcoursesnotes/2024_04/dmt211_algorithm/04_分治算法与排序[复制]

最后修改时间:


商业转载请联系站长获得授权,非商业转载请注明本文出处及文章链接,您可以自由地在任何媒体以任何形式复制和分发作品,也可以修改和创作,但是分发衍生作品时必须采用相同的许可协议。
本文采用CC BY-NC-SA 4.0进行许可。