算法分析与设计「四」分治算法

一、什么是分治算法


分治算法的基本思想是将一个规模为 N 的问题分解为 K 个规模较小的子问题,这些子问题相互独立且与原问题性质相同。求出子问题的解,就可得到原问题的解

一般思路

  • 分解:将要解决的问题划分成若干规模较小的同类问题;
  • 求解: 当子问题划分得足够小时,用较简单的方法解决;
  • 合并: 按原问题的要求,将子问题的解逐层合并构成原问题的解。
    在这里插入图片描述

例如下面的找出伪币问题,就是一个典型的分治法应用:

问题描述:16 硬币,有可能有1枚假币,假币比真币轻。有一架天平,用最少称量次数确定有没有假币,若有的话,假币是哪一枚。

分治解决:将对 16 个硬币的搜索问题,转为对两组 8 个硬币搜索的问题。在 8 - 8 个硬币进行称量,找出较轻的;然后 4 - 4 个硬币称量,找出较轻的;之后 2 - 2 个称量,找出较轻的;最后 1 - 1 个称量,找出较轻的即为假币。
 

二、分治的典型应用


应用一:归并排序

归并排序(Merge sort)是建立在归并操作上的一种有效的排序算法。该算法是采用分治法(Divide and Conquer)的一个非常典型的应用。其实现排序时的时间复杂度为 O ( n l o g n ) O(nlogn) O(nlogn)

思路分析:数组排序任务可以如下完成:① 把前一半排序 ② 把后一半排序 ③ 把两半归并到一个新的有序数组,然后再拷贝回原数组,排序完成。参考下图:

在这里插入图片描述
代码实现:

#include <iostream>
using namespace std;

int a[8] = {8, 4, 5, 7, 1, 3, 6, 2};
int b[8]; // 用于存放中间结果

// 将两个半有序数组有序地排到 tmp[] 中,之后拷贝给 a[]。复杂度 O(n)
void Merge(int a[], int s, int m, int e, int tmp[])
{
    int pb = 0;
    int p1 = s, p2 = m + 1;
    while (p1 <= m && p2 <= e)
    {
        if (a[p1] < a[p2])
            tmp[pb++] = a[p1++];
        else
            tmp[pb++] = a[p2++];
    }
    while (p1 <= m)
        tmp[pb++] = a[p1++];
    while (p2 <= e)
        tmp[pb++] = a[p2++];

    // 拷贝回原数组
    for (int i = 0; i < e - s + 1; ++i)
        a[s + i] = tmp[i];
}

// 利用递归思想
void MergeSort(int a[], int s, int e, int tmp[])
{
    if (s < e)
    {
        int m = s + (e - s) / 2;     // 中点
        MergeSort(a, s, m, tmp);     // 前段排序
        MergeSort(a, m + 1, e, tmp); // 后段排序
        Merge(a, s, m, e, tmp);      // 归并
    }
}

int main()
{
    int size = sizeof(a) / sizeof(int);
    MergeSort(a, 0, size - 1, b);
    for (int i = 0; i < size; ++i)
        cout << a[i] << ",";
    cout << endl;
    return 0;
}

归并排序时间复杂度分析:
在这里插入图片描述
 

应用二:快速排序


基本思想:

  1. 在待排序数组中首先选取一个记录作为基准(pivotkey),通常选第一个元素。
  2. 经过一趟排序,将小于基准的元素放在左侧,大于基准的元素放在右侧,基准元素放置在分解处。这样,待排序数组就分成了两个子表。(需要时间 O ( n ) O(n) O(n))
  3. 递归地将左侧和右侧的两个子表进行排序,直至每个子表只有一个元素。

具体步骤:

  1. 暂时指定第一个记录为基准,同时附设两个指针 i,j 分别指向数组的第一个元素和最后一个元素。
    在这里插入图片描述
  2. 从表的最右侧位置依次向左搜索,找到小于基准的元素,与基准元素进行交换;如果没有找到,则指针左移。
    在这里插入图片描述
    在这里插入图片描述
  3. 再从数组最左侧位置开始,找到大于基准的元素,与基准进行交换;若没找到,则指针右移。
    在这里插入图片描述
    在这里插入图片描述
    在这里插入图片描述
    在这里插入图片描述
  4. 重复步骤 2 和 3,直至指针 i 和 j 相等。这样第一趟递归排序就完成了,原表被分为了左右两个子表。下面只需递归操作即可。
    在这里插入图片描述
    在这里插入图片描述

代码实现:

#include <iostream>
using namespace std;

void swap(int &a, int &b)
{
    int tmp = a;
    a = b;
    b = tmp;
}

void QuickSort(int a[], int s, int e)
{
    if (s >= e)
        return;
    int k = a[s];     // k 为基准
    int i = s, j = e; // 左右指针
    // 下面进行一趟排序
    while (i != j)
    {
        while (j > i && a[j] >= k)
            --j;
        swap(a[i], a[j]);
        while (i < j && a[i] <= k)
            ++i;
        swap(a[i], a[j]);
    } // 此时 a[i] = k
    QuickSort(a, s, i - 1);
    QuickSort(a, i + 1, e);
}

int a[] = {2, 1, 3, 7, 12, 11, 8, 9};

int main()
{
    int size = sizeof(a) / sizeof(int);
    QuickSort(a, 0, size - 1);
    for (int i = 0; i < size; ++i)
        cout << a[i] << ' ';
    cout << endl;
    return 0;
}

注意:有个小问题,在实现 swap 交换两个数时,刚开始用到了异或运算 a^=b; b^=a; a^=b; ,发现输出了好多 0。这时因为 i 最终等于 j,这时两个要交换的数相等,导致 i ^ j = 0

复杂度分析:

快速排序的最坏时间复杂度是 O ( n ² ) O(n²) O(n²),比如说顺序数列的快排。但它的平均时间复杂度是 O ( n l o g n ) O(nlogn) O(nlogn),且 O ( n l o g n ) O(nlogn) O(nlogn) 记号中隐含的常数因子很小,比复杂度稳定等于 O ( n l o g n ) O(nlogn) O(nlogn) 的归并排序要小很多。所以,对绝大多数顺序性较弱的随机数列而言,快速排序总是优于归并排序。
 

应用三:输出前 m 大的数


问题描述:给定一个数组包括 n 个元素,统计前 m 大的数并且把这 m 个数从大到小输出。

输入:第一行包含一个整数 n,表示数组的大小(n < 100000)。第二行包含 n个整数,表示数组的元素,整数之间以一个空格分开。每个整数的绝对值不超过100000000。第三行包含一个整数 m(m < n)。

输出:从大到小输出前m大的数,每个数一行。

解题思路:当然,你可以选择先排序后再进行输出,这时时间复杂度 O ( n l o g n ) O(nlogn) O(nlogn)。但其实我们可以选择一种时间复杂度更小的解法:把前 m 大的都弄到数组最右边,复杂度 O ( n ) O(n) O(n) 。然后对这最右边 m 个元素排序再输出, 复杂度 O ( l o g n ) O(logn) O(logn) 。总的时间复杂度为 O ( n + m l o g m ) O(n+mlogm) O(n+mlogm)。当 m << n 时,这种算法时间复杂度相当于 O ( n ) O(n) O(n),优势就比较明显了。

将前 m 大的都弄到数组最右边的时间为什么为 O ( n ) O(n) O(n)
在这里插入图片描述

// 输入样例
5
5 2 11 3 12
3
// 输出样例
5
11
12

代码实现:

#include <iostream>
#include <algorithm>
using namespace std;
#define MAXN 100005

int a[MAXN];
int n, m;

void swap(int &a, int &b)
{
    int tmp = a;
    a = b;
    b = tmp;
}

// 利用分治思想 ———— 先将前 m 大的数移至右边,再对这 m 个数排序
void arrangeRight(int a[], int s, int e, int k)
{
    if (s >= e)
        return;
    if (k == e - s + 1)
        return;
    int i = s, j = e;
    int key = a[s];
    // 基准元素归位,使得左侧小于基准元素,右侧大于基准元素
    while (i != j)
    {
        while (i < j && a[j] >= key)
            --j;
        swap(a[i], a[j]);
        while (i < j && a[i] <= key)
            ++i;
        swap(a[i], a[j]);
    }
    // 边界条件及递归方式
    if (k == e - i + 1)
        return;
    else if (k < e - i + 1)
        arrangeRight(a, i + 1, e, k);
    else
        arrangeRight(a, s, i - 1, k - e + i - 1);
}

int main()
{
    cin >> n;
    for (int i = 0; i < n; ++i)
        cin >> a[i];
    cin >> m;
    // 前 m 大的数弄到右边
    arrangeRight(a, 0, n - 1, m);
    // 再对这 m 个数排序,sort 类似快排,n*log(n)。
    sort(a + n - m, a + n);
    while (m--)
        cout << a[--n] << endl;
    return 0;
}

 

应用四:求排列的逆序数


问题描述:
在这里插入图片描述
解题思路:

当然,你可以采取枚举方式,对每一个元素都遍历,复杂度 O ( n 2 ) O(n^{2}) O(n2) 会超时,因此这里暂且不提此解法。

下面来看分治算法解决此问题,其复杂度为 O ( n l o g n ) O(nlogn) O(nlogn)

  1. 将数组分成两半,分别求出左半边的逆序数和右半边的逆序数;
  2. 再算有多少逆序是由左半边取一个数和右半边取一个数构成的。(要求 O ( n ) O(n) O(n) 实现)

如何用 O ( n ) O(n) O(n) 时间实现第二步呢 ?关键就是:左半边和右半边都是排好序的。比如,都是从大到小排序的。这样,左右半边只需要从头到尾各扫一遍,就可以找出由两边各取一个数构成的逆序个数。

在这里插入图片描述
其实总结而言,此问题解法可以由归并排序改进所得,只需加上计算逆序的步骤即可。

代码实现:

#include <iostream>
using namespace std;

int a[8] = {3, 7, 8, 10, 2, 5, 11, 12};
int b[8];      // 用于存放中间结果
int count = 0; // 记录逆序数

// 归并有序序列,并计算逆序数个数
void MergeAndCountNum(int a[], int s, int m, int e, int tmp[])
{
    int pb = 0;
    int p1 = s, p2 = m + 1;
    while (p1 <= m && p2 <= e)
    {
        if (a[p1] < a[p2])
            tmp[pb++] = a[p1++];
        else
        {
            tmp[pb++] = a[p2++];
            // 当出现一个逆序时,其后 m+1-p1 个数都是逆序
            count += m + 1 - p1;
        }
    }
    while (p1 <= m)
        tmp[pb++] = a[p1++];
    while (p2 <= e)
        tmp[pb++] = a[p2++];
    for (int i = 0; i < e - s + 1; ++i)
        a[s + i] = tmp[i];
}

// 利用递归思想
void MergeSort(int a[], int s, int e, int tmp[])
{
    if (s < e)
    {
        int m = s + (e - s) / 2;           // 中点
        MergeSort(a, s, m, tmp);           // 前段排序
        MergeSort(a, m + 1, e, tmp);       // 后段排序
        MergeAndCountNum(a, s, m, e, tmp); // 归并同时计算逆序数
    }
}

int main()
{
    int size = sizeof(a) / sizeof(int);
    MergeSort(a, 0, size - 1, b);
    cout << count;
    return 0;
}

  • 1
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论
L型组件填图问题 1.问题描述 设B是一个n×n棋盘,n=2k,(k=1,2,3,…)。用分治法设计一个算法,使得:用若干个L型条块可以覆盖住B的除一个特殊方格外的所有方格。其中,一个L型条块可以覆盖3个方格。且任意两个L型条块不能重叠覆盖棋盘。 例如:如果n=2,则存在4个方格,其中,除一个方格外,其余3个方格可被一L型条块覆盖;当n=4时,则存在16个方格,其中,除一个方格外,其余15个方格被5个L型条块覆盖。 2. 具体要求 输入一个正整数n,表示棋盘的大小是n*n的。输出一个被L型条块覆盖的n*n棋盘。该棋盘除一个方格外,其余各方格都被L型条块覆盖住。为区别出各个方格是被哪个L型条块所覆盖,每个L型条块用不同的数字或颜色、标记表示。 3. 测试数据(仅作为参考) 输入:8 输出:A 2 3 3 7 7 8 8 2 2 1 3 7 6 6 8 4 1 1 5 9 9 6 10 4 4 5 5 0 9 10 10 12 12 13 0 0 17 18 18 12 11 13 13 17 17 16 18 14 11 11 15 19 16 16 20 14 14 15 15 19 19 20 20 4. 设计与实现的提示 对2k×2k的棋盘可以划分成若干块,每块棋盘是原棋盘的子棋盘或者可以转化成原棋盘的子棋盘。 注意:特殊方格的位置是任意的。而且,L型条块是可以旋转放置的。 为了区分出棋盘上的方格被不同的L型条块所覆盖,每个L型条块可以用不同的数字、颜色等来标记区分。 5. 扩展内容 可以采用可视化界面来表示各L型条块,显示其覆盖棋盘的情况。 经典的递归问题, 这是我的大代码, 只是本人很懒, 不想再优化
资源包主要包含以下内容: ASP项目源码:每个资源包中都包含完整的ASP项目源码,这些源码采用了经典的ASP技术开发,结构清晰、注释详细,帮助用户轻松理解整个项目的逻辑和实现方式。通过这些源码,用户可以学习到ASP的基本语法、服务器端脚本编写方法、数据库操作、用户权限管理等关键技术。 数据库设计文件:为了方便用户更好地理解系统的后台逻辑,每个项目中都附带了完整的数据库设计文件。这些文件通常包括数据库结构图、数据表设计文档,以及示例数据SQL脚本。用户可以通过这些文件快速搭建项目所需的数据库环境,并了解各个数据表之间的关系和作用。 详细的开发文档:每个资源包都附有详细的开发文档,文档内容包括项目背景介绍、功能模块说明、系统流程图、用户界面设计以及关键代码解析等。这些文档为用户提供了深入的学习材料,使得即便是从零开始的开发者也能逐步掌握项目开发的全过程。 项目演示与使用指南:为帮助用户更好地理解和使用这些ASP项目,每个资源包中都包含项目的演示文件和使用指南。演示文件通常以视频或图文形式展示项目的主要功能和操作流程,使用指南则详细说明了如何配置开发环境、部署项目以及常见问题的解决方法。 毕业设计参考:对于正在准备毕业设计的学生来说,这些资源包是绝佳的参考材料。每个项目不仅功能完善、结构清晰,还符合常见的毕业设计要求和标准。通过这些项目,学生可以学习到如何从零开始构建一个完整的Web系统,并积累丰富的项目经验。
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

imByte

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值