一次性弄懂到底什么叫做分治思想(含有大量经典例题,附带详细解析)

期末了,通过写博客的方式复习一下算法,把自己知道的全部写出来

分治:分而治之,把一个复杂的问题分解成很多规模较小的子问题,然后解决这些子问题,把解决的子问题合并起来,大问题就解决了

但是我们应该在什么时候用分治呢?这个问题也困扰了我很久,做题的时候就不知道用什么算法

能用分治法的基本特征:

1.问题缩小到一定规模容易解决

2.分解成的子问题是相同种类的子问题,即该问题具有最优子结构性质

3.分解而成的小问题在解决之后要可以合并

4.子问题是相互独立的,即子问题之间没有公共的子问题

第一条大多数问题都可以满足

第二条的大多数问题也可以满足,反应的是递归的思想

第三条:这个是能分治的关键,解决子问题之后如果不能合并从而解决大问题的话,那凉凉,如果满足一,二,不满足三,即具有最优子结构的话,可以考虑贪心或者dp

第四条:如果不满足第四条的话,也可以用分治,但是在分治的过程中,有大量的重复子问题被多次的计算,拖慢了算法效率,这样的问题可以考虑dp(大量重复子问题)

了解了什么问题可以采用分治,那么分治到达怎么用?步骤是什么呢

三个步骤:

1.分解成很多子问题

2.解决这些子问题

3.将解决的子问题合并从而解决整个大问题

化成一颗问题树的话,最底下的就是很多小问题,最上面的就是要解决的大问题,自底向上的方式求解问题

说的再多不如看经典的样例,更好的体会分治的思想

样例1:二分查找

条件:数组有序,假设是升序数组

虽然二分很容易,但是我还是要具体从算法思想分治的方向分析一下

现在我们要在一个有序的升序数组里面查找一个数x有没有

暴力的做法就是拿跟数组里面每个数比较一下,有的话就返回下标,这个是大问题

仔细想一下,就知道这个大问题是由很多小问题组成的,小问题:在数组的一部分里面找x

那么我们可以把数组分成很多部分,在很多部分里面找x,如果在这些部分里面没有找到x,那么把这些子问题合并起来,就是大数组里面没有x,否则就是有x

这个真的很好的反应了分治的思想,先分解成很多小问题,解决这些小问题,把解决的小问题合并起来,大问题就解决了,二分具体的做法我就不多说了,都知道,贴个代码

#include<string.h>
#include<stdio.h>
int k;
int binarysearch(int a[],int x,int low,int high)//a表示需要二分的有序数组(升序),x表示需要查找的数字,low,high表示高低位
{
    if(low>high)
    {
        return -1;//没有找到
    }
    int mid=(low+high)/2;
    if(x==a[mid])//找到x
    {
        k=mid;
        return x;
    }
    else if(x>a[mid]) //x在后半部分
    {
        binarysearch(a,x,mid+1,high);//在后半部分继续二分查找
    }
    else//x在前半部分
    {
        binarysearch(a,x,low,mid-1);
    }
}
int main()
{
    int a[10]={1,2,3,4,5,6,7,8,9,10};
    printf("请输入需要查找的正数字:\n");
    int x;
    scanf("%d",&x);
    int r=binarysearch(a,x,0,9);
    if(r==-1)
    {
        printf("没有查到\n");
    }
    else
    {
        printf("查到了,在数列的第%d个位置上\n",k+1);
    }
    return 0;
}

经典样例二:全排列问题

有1,2,3,4个数,问你有多少种排列方法,输出来

仔细想想,采用分治的话,我们就要把大问题分解成很多的子问题,大问题是所有的排列方法

那么我们分解得到的小问题就是以1开头的排列,以2开头的排列,以3开头的排列,以4开头的排列

现在这些问题有能继续分解,比如以1开头的排列中,只确定了1的位置,没有确定2,3,4的位置,把2

3,4三个又看成大问题继续分解,2做第二个,3做第二个,或者4做第二个

一直分解下去,直到分解成的子问题只有一个数字的时候,不再分解

因为1个数字肯定只有一种排列方式啊,现在我们分解成了很多的小问题,解决一个小问题就合并,合并成

一个大点的问题,合并之后这个大点的问题也解决了,再将这些大点的问题合并成一个更大的问题,那么这

个更大点的问题也解决了,直到最大的问题解决为止

这个就是用分治的思想解决全排列问题,我主要想分析的是分治的思想者全排列问题上是怎么用的,不想分析具体全排列的做法,因为我觉得思想比方法更重要,在解题的时候深有体会,因为又的时候没有题是你做过的原题,全排列问题的具体做法参考我的这篇博客:https://www.cnblogs.com/yinbiao/p/8684313.html,也贴一下代码

#include<string.h>
#include<stdio.h>
int k=0;
char a[100];
long long count=0;//全排列个数的计数
void s(char a[],int i,int k)//将第i个字符和第k个字符交换
{
    char t=a[i];
    a[i]=a[k];
    a[k]=t;
}
void f(char a[],int k,int n)
{
    if(k==n-1)//深度控制,此时框里面只有一个字符了,所以只有一种情况,所以输出
    {
       puts(a);
       count++;
    }
    int i;
    for(i=k;i<n;i++)
    {
        s(a,i,k);
        f(a,k+1,n);
        s(a,i,k);//复原,就将交换后的序列除去第一个元素放入到下一次递归中去了,递归完成了再进行下一次循环。这是某一次循环程序所做的工作,这里有一个问题,那就是在进入到下一次循环时,序列是被改变了。可是,如果我们要假定第一位的所有可能性的话,那么,就必须是在建立在这些序列的初始状态一致的情况下,所以每次交换后,要还原,确保初始状态一致。
    }
}
int main()
{
    gets(a);
    int l=strlen(a);//字符串长度
    f(a,k,l);
    printf("全排列个数:%lld\n",count);
    return 0;
}

 

经典样例三:整数划分问题

给你一个数,问你所有的划分方式,比如4,4=1+3,4=1+1+2,4=2+2,4=1+1+1+1

我们来分析一下,我们想用分治的话,就要找子问题,假设n是要划分的数,m说最大的加数,n=4,m=3

分解成两类的子问题,一个是:一个是有m的情况,一个是没有m的情况,然后将有m的情况继续划分,分

解成有m-1和没有m-1的情况,一直划分下去,直到m=1,比如n=4,m=3,划分成的子问题:有3,无

3,有2,无2,有1,无1(没有意义,除非0+4=4),将这些子问题合并起来大问题就解决了,比如有

3:1+3,没有3分成有2,和无2,有2:1+1+2,2+2,无2分成有1:1+1+1+1,一共四种解决方案

我们来理一下思路:划分成子问题,解决这些子问题,合并

但是注意:这个问题里面的子问题有很多是重复的,大量重复子问题,比如n=5,m=4,1+4=5,1+1+

3=5,2+3=5,求3有几种划分方法的时候求了2次,如果n很大的话,那么就会有大量的重复子问题,这个时候可以采用dp(自己有点不理解重复子问题重复在哪里,觉得哪里有点不对劲)

分析了一下题中分治的思想,具体做法参考我的这篇博客:https://www.cnblogs.com/yinbiao/p/8672198.html,也贴个代码

/*
整数划分问题
:将一个整数划分为若干个数相加
例子:
整数4 最大加数 4
4=4
1+3=4
1+1+2=4
2+2=4
1+1+1+1=4
一共五种划分方案
注意:1+3=4,3+1=4被认为是同一种划分方案
*/

#include<stdio.h>
int q(int n,int m)//n表示需要划分的数字,m表示最大的家数不超过m
{
    if(m==1||n==1)//只要存在一个为1,那么划分的方法数肯定只有一种,那就是n个1相加
    {
        return 1;
    }else if(n==m&&n>1)//二者相等且大于1的时候,问题等价于:q(n,n-1)+1;意味着将最大加数减一之后n的划分数,然后加一,最后面那个一代表的是:0+n,这个划分的方案
    {
        return q(n,n-1)+1;
    }else if(n<m)//如果m>n,那么令m=n就ok,因为最大加数在逻辑上不可能超过n
    {
        return q(n,n);
    }else if(n>m)
    {
        return q(n,m-1)+q(n-m,m);//分为两种:划分方案没有m的情况+划分方案有m的情况
    }
    return 0;
}
int main()
{
    printf("请输入需要划分的数字和最大家数:\n");
    int n,m;
    scanf("%d %d",&n,&m);
    int r=q(n,m);
    printf("%d\n",r);
    return 0;
}

 

经典样例4:归并排序

把一个无序的数组,变成一个有序的数组,这个是大问题,根据分治的思想,要分解成很多的小问题,比如

无序数组8个数,要使得数组有序,即使得这8个数有序,分解成两个子问题:使得前面4个数有序,使得后

面的四个数有序,然后继续分解,在前面的4个数字中,又把它看成一个大问题,继续分解成两个小问题:

使得前面两个数有序,使得后面两个数有序,直到小问题数组中只有一个数为止,因为一个数的数组肯定是

有序的,小问题解决之后,还需要合并成一个大一点的问题,这样这个大一点的问题就也解决了,然后将两

个大一点的问题继续合并成一个更大一点的问题,这样这个更大一点的问题也解决了,直到最后,最大的问

题也解决了,这个就是分治思想在归并排序中的应用

也贴个代码,附带详细的解析

/*
归并排序
思想:
1.分而治之,将一个无序的数列一直一分为二,直到分到序列中只有一个数的时候,这个序列肯定是有序的,因为只有一个数,然后将两个只含有一个数字的序列合并为含有两个数字的有序序列,这样一直进行下去,最后就变成了一个大的有序数列
2.递归的结束条件是分到最小的序列只有一个数字的时候
时间复杂度分析:
最坏情况:T(n)=O(n*lg n)
平均情况:T(n)=O(n*lg n)
稳定性:稳定(两个数相等的情况,不用移动位置
辅助空间:O(n)
特点总结:
高效
耗内存(需要一个同目标数组SR相同大小的数组来运行算法)
*/
#include<stdio.h>
#define max 1024
int SR[max],TR[max];
int merge(int SR[],int TR[],int s,int m,int t)//SR代表两个有序序列构成的序列,s表示起始位置,m表示两个序列的分解位置,但是SR[m]仍是属于前面一个序列,t表示结束位置
{//TR是一个空数组,用来存放排序好之后的数字
    int i=s,j=m+1,k=s;
    while(i<=m&&j<=t)
    {
        if(SR[i]<SR[j])
        {
            TR[k++]=SR[i++];
        }else
        {
            TR[k++]=SR[j++];
        }
    }
    while(i<=m)//当前面一个序列有剩余的时候,直接把剩余数字放在TR的后面
    {
        TR[k++]=SR[i++];
    }
    while(j<=t)//当后面一个序列有剩余的时候,直接把剩余数字放在TR的后面
    {
        TR[k++]=SR[j++];
    }
    return 0;
}//该函数要求SR是由两个有序序列构成
void copy(int SR[],int TR[],int s,int t)//把TR赋给SR
{
    int i;
    for(i=s;i<=t;i++)
    {
        SR[i]=TR[i];
    }
}
int mergesort(int SR[],int s,int t)
{
    if(s<t)//表示从s到t有多个数字
    {
        int m=(s+t)/2;//将序列一分为二
        mergesort(SR,s,m);//前一半序列继续进行归并排序
        mergesort(SR,m+1,t);//后一半序列同时进行归并排序,
        //以上递归调用的结束条件是s!<t,也就是进行分到只有一个数字进行归并排序的时候,一个序列只有一个数字,那么这个序列肯定是有序的
        //以上都是属于“分”的阶段,目的是获得两个有序的数列
        merge(SR,TR,s,m,t);//对这两个有序的数列,进行排序,变成一个同样大小但是有序的数列
        copy(SR,TR,s,t);//将在TR中排序好的数列给SR,方便SR递归调用归并排序,因为每次两个归并排序的结果都是保存在TR中的,现在要进行下一步就必须在TR数列的基础上面=进行,所以我们把TR给SR
    }else//表示从s到t只有一个数字(s==t),或者没有数字(s>t)
    {
        ;//空,也可省略,加一个else只是为了更好的理解程序
    }
    return 0;
}
int main()
{
    int n;
    printf("请输入排序数字的个数:\n");
    scanf("%d",&n);
    int i;
    for(i=0;i<n;i++)
    {
        scanf("%d",&SR[i]);
    }
    mergesort(SR,0,n-1);//升序排列
    for(i=0;i<n;i++)
    {
        printf("%d ",SR[i]);
    }
    printf("\n");
    return 0;
}

 

经典样例五:棋盘覆盖问题

不知道棋盘覆盖问题的请自行百度

在棋盘的某个位置给了你一个不可覆盖点,现在大问题是问我们怎么用L形状块覆盖整个棋盘,现在我们要把大问题分解成很多的子问题:把整块大棋盘分成同样大小的四个棋盘,直到分解成的棋盘大小为1,就是只有一个格子的时候,不再分解,所以最小的子问题就是四个格子的棋盘,如果这个四个格子的棋盘有不可覆盖点的话,那么就进行棋盘覆盖,如果没有的话就进行覆盖点的构造然后在覆盖(先不讲怎么判断,怎么构造,只讲思想,具体做法我有专门的博客),所以这样我们就解决了这个四个格子的棋盘,把所有的这样的小问题解决的,也就是把解决好的小棋盘合并起来不就构成了我们需要的大棋盘吗?

理清一下思路:

分解棋盘(分解成四个小棋盘,一直分解下去,直到棋盘大小为1)

解决问题(是直接覆盖还是先构造再覆盖)

合并已经解决的问题(将已经解决的所有小问题合并起来就构成了我们需要覆盖的大棋盘,且此时大棋盘也

已经覆盖好了)

棋盘问题具体做法请参考我的这篇博客:https://www.cnblogs.com/yinbiao/p/8666209.html

也贴一下代码吧

#include<stdio.h>
#define max 1024
int cb[max][max];//最大棋盘
int id=0;//覆盖标志位
int chessboard(int tr,int tc,int dr,int dc,int size)//tr,tc代表棋盘左上角的位置,dr ,dc代表棋盘不可覆盖点的位置,size是棋盘大小
{
    if(size==1)//如果递归到某个时候,棋盘大小为1,则结束递归
    {
        return 0;
    }
    int s=size/2;//使得新得到的棋盘为原来棋盘大小的四分之一
    int t=id++;
    if(dr<tr+s&&dc<tc+s)//如果不可覆盖点在左上角,就对这个棋盘左上角的四分之一重新进行棋盘覆盖
    {
        chessboard(tr,tc,dr,dc,s);
    }else//因为不可覆盖点不在左上角,所以我们要在左上角构造一个不可覆盖点
    {
        cb[tr+s-1][tc+s-1]=t;//构造完毕
        chessboard(tr,tc,tr+s-1,tc+s-1,s);//在我们构造完不可覆盖点之后,棋盘的左上角的四分之一又有了不可覆盖点,所以就对左上角棋盘的四分之一进行棋盘覆盖
    }

    if(dr<tr+s&&dc>=tc+s)//如果不可覆盖点在右上角,就对这个棋盘右上角的四分之一重新进行棋盘覆盖
    {
        chessboard(tr,tc+s,dr,dc,s);
    }else//因为不可覆盖点不在右上角,所以我们要在右上角构造一个不可覆盖点
    {
        cb[tr+s-1][tc+s]=t;
        chessboard(tr,tc+s,tr+s-1,tc+s,s);//在我们构造完不可覆盖点之后,棋盘的右上角的四分之一又有了不可覆盖点,所以就对右上角棋盘的四分之一进行棋盘覆盖
    }


     if(dr>=tr+s&&dc<tc+s)//如果不可覆盖点在左下角,就对这个棋盘左下角的四分之一重新进行棋盘覆盖
    {
        chessboard(tr+s,tc,dr,dc,s);
    }else//因为不可覆盖点不在左下角,所以我们要在左下角构造一个不可覆盖点
    {
        cb[tr+s][tc+s-1]=t;
        chessboard(tr+s,tc,tr+s,tc+s-1,s);//在我们构造完不可覆盖点之后,棋盘的左下角的四分之一又有了不可覆盖点,所以就对左下角棋盘的四分之一进行棋盘覆盖
    }

    if(dr>=tr+s&&dc>=tc+s)//如果不可覆盖点在右下角,就对这个棋盘右下角的四分之一重新进行棋盘覆盖
    {
        chessboard(tr+s,tc+s,dr,dc,s);
    }else//因为不可覆盖点不在右下角,所以我们要在右下角构造一个不可覆盖点
    {
        cb[tr+s][tc+s]=t;
        chessboard(tr+s,tc+s,tr+s,tc+s,s);//在我们构造完不可覆盖点之后,棋盘的右下角的四分之一又有了不可覆盖点,所以就对右下角棋盘的四分之一进行棋盘覆盖
    }

    //后面的四个步骤都跟第一个类似
}
int main()
{
    printf("请输入正方形棋盘的大小(行数):\n");
    int n;
    scanf("%d",&n);
    printf("请输入在%d*%d棋盘上不可覆盖点的位置:\n",n,n);
    int i,j,k,l;
    scanf("%d %d",&i,&j);
    printf("不可覆盖点位置输入完毕,不可覆盖点的值为-1\n");
    cb[i][j]=-1;
    chessboard(0,0,i,j,n);
    for(k=0;k<n;k++)
    {
        printf("%2d",cb[k][0]);
        for(l=1;l<n;l++)
        {
            printf(" %2d",cb[k][l]);
        }
        printf("\n");
    }
    return 0;
}

 

经典样例六:快速排序

快速排序中分治的思想体现在哪里呢?

首先我们要了解快速排序的思想,选择一个基准元素,比基准元素大的放基准元素后面,比基准元素小的放

基准元素前面,这个叫做分区,每次分区都使得一个元素有序,进行很多次分区以后,数组就是有序数组

了,为什么是这样呢?因为每次分区,我们都使得了基准元素有序,以比基准元素小的为例,这些元素都比

基准元素小,放在基准元素前面,但这些比基准元素小的元素自己是无序的,确定的位置只有基准元素位

置,有序之后这些元素与基准元素的相对位置是不会变的,变的只有这些元素自己内部的位置,因为进行一

次分区就可以使得一位元素有序,所以进行很夺次分区以后,数组就是有序的了,

那么分治的思想到底体现在哪里呢/

第一步:把大问题分解成很多子问题(每次使得一位元素有序,分区操作可以做到)

第二步:解决子问题(进行分区操作,每次使得一位元素有序)

第三步:所有子问题解决了那么最大的问题也解决了

再简单分析一下:第一次分区是对整个数组进行分区,确定了第一个基准元素的位置,然后对比基准元素大

的和比基准元素小的进行分区,确定第二个和第三个基准元素的位置,如果序列够好的话(每次分区时,比

基准元素大的元素和比基准元素小的元素每次都一样多)n*logn时间可解决

关于快排的具体做法请参考我的这篇博客:https://www.cnblogs.com/yinbiao/p/8805233.html

也贴个代码吧(随机化快排,基准元素选择是随机的)

#include<bits/stdc++.h>
using namespace std;
#define n 5
int a[n];
void swap_t(int a[],int i,int j)
{
    int t=a[i];
    a[i]=a[j];
    a[j]=t;
}
int par(int a[],int p,int q)
{
    int i=p;//p是轴
    int x=a[p];
    for(int j=p+1;j<=q;j++)
    {
        if(a[j]<=x)
        {
            i++;
            swap_t(a,i,j);
        }
    }
    swap_t(a,p,i);
    return i;//轴位置
}
int Random(int p,int q)
{
    return rand()%(q-p+1)+p;
}
int Randomizedpar(int a[],int p,int q)
{
    int i=Random(p,q);
    swap_t(a,p,i);//第一个和第i个交换,相当于有了一个随机基准元素
    return par(a,p,q);
}
void RandomizedQuickSort(int a[],int p,int q)
{
    if(p<q)
    {
        int r=Randomizedpar(a,p,q);
        printf("%d到%d之间的随机数:%d\n",p,q,r);
        RandomizedQuickSort(a,p,r-1);
        RandomizedQuickSort(a,r+1,q);
    }
}
int main()
{
    int i;
    for(i=0;i<n;i++)
    {
        scanf("%d",&a[i]);

    }
    RandomizedQuickSort(a,0,n-1);
    for(i=0;i<n;i++)
    {
        printf("%d\n",a[i]);
    }
    return 0;
}

 

经典样例七:求第k小/大元素

这是快排分区思想的应用,也要进行分区操作,和快排不同的是,快排分区之后还有继续处理基准元素

两边的数据,而求k小/大不用,只用处理一边即可

假如现在这里5个元素,分为1,2,3,4,5号位置

第一种情况:假设求第3小元素,假设第一次分区的基准元素完成分区后在第2号位置,那么我们知道3>2

所以只要对基准元素后面的元素继续分区就可以(注意k的值要变了,k代表的是在升序有序数组的1相对位

置,现在对第一次分区的基准元素后面的元素进行分区操作,区间大小是变小了的,所以k值是要跟着变的)

讲了这么多,所以分治的思想到底体现在哪里呢?

跟快排一样,有分区操作,所以分治的思想在这里的体现和在快排的体现都是一样的,不同的是这里只要对

基准元素前面元素或者后面元素进行继续分区(如果需要继续分区的话),而快排是基准元素两边都要继续

分区的

贴个代码(采用的是随机分区)

#include<bits/stdc++.h>
using namespace std;
void swap_t(int a[],int i,int j)
{
    int t=a[i];
    a[i]=a[j];
    a[j]=t;
}
int par(int a[],int p,int q)//p是轴,轴前面是比a[p]小的,后面是比a[p]大的
{
    int i=p,x=a[p];
    for(int j=p+1;j<=q;j++)
    {
        if(a[j]>=x)
        {
            i++;
            swap_t(a,i,j);
        }
    }
    swap_t(a,p,i);
    return i;//返回轴位置
}
int Random(int p,int q)//返回p,q之间的随机数
{
    return rand()%(q-p+1)+p;
}
int Randomizedpar(int a[],int p,int q)
{
    int i=Random(p,q);
    swap_t(a,p,i);//第一个和第i个交换,相当于有了一个随机基准元素
    return par(a,p,q);
}
int RandomizedSelect(int a[],int p,int r,int k)
{
    if(p==r)
        return a[p];
    int i=Randomizedpar(a,p,r);
    int j=i-p+1;
    printf("i=%d j=%d\n",i,j);
    if(k<=j)
        return RandomizedSelect(a,p,i,k);
    else
        return RandomizedSelect(a,i+1,r,k-j);
}
int main()
{
    int n;
    scanf("%d",&n);
    int a[n];
    for(int i=0;i<n;i++)
    {
        scanf("%d",&a[i]);
    }
    int x=RandomizedSelect(a,0,n-1,2);
    printf("%d\n",x);
}

 

样例大概就是这些

 

 

还有一个很重要的知识点差点忘了复习,分治的主定理

分治的一般形式:

T(N)=aT(N/b)+f(n)

1.a==1 T(n)=O(logn)

2.a!=1  T(n)=O(n的logb a)次方

3. a==b T(n)=O(n*log b a)

4 a<b T(n)=O(n)

5. a>b  T(n)=O(n的log b a次方)

用于估算分治算法的时间复杂的(数学log 的指数和底数不好表示。。。)

 

735119-20170111112835275-168981902

735119-20170111112841431-2047172832

posted @ 2018-06-22 21:23  西*风  阅读(9323)  评论(2编辑  收藏  举报