基本概念

  分治法是广为人知的算法思想,很容易理解。人们在遇到一个难以直接解决的大问题时,自然会想到把它划分成一些规模较小的子问题,各个击破,“分而治之(Divide and Conquer)”.
  分治算法的具体操作是把原问题分成k个较小规模的子问题,对这k个子问题分别求解。如果子问题不够小,那么把每个子问题再划分为规模更小的子问题。这样一直分解下去,直到问题足够小,很容易求出这些小问题的解为止。
  能用分治法的题目需要符合以下特征。
   (1) 平衡子问题:子问题的规模大致相同,能把问题划分成大小差不多相等的k个子问题,最好k=2,即分成两规模相等的子问题。子问题规模相等的处理效率比子问题规模不等的处理效率要高。
   (2) 独立子问题:子问题直接相互独立。这是区别于动态规划算法的根本特征,在动态规划算法中,子问题是相互联系的,而不是相互独立的。
  特别需要说明的是,分治法不仅能够让问题变得更容易理解和解决,而且能大大优化算法的复杂度,在一般情况下能把 $ O(n) $ 的复杂度优化到 $ O(log_{2}n) $ 。这是因为,局部的优化有利于全局;一个子问题的解决,其影响力扩大了k倍,即扩大了到了全局。
  举一个简单的例子:在一个有序的数列中查找一个数。简单的办法是从头找到尾,复杂度是 $ O(n) $ 。如果用分治法,即“折半查找”,则最多只需要 $ O(log_{2}n) $ 次就能找到。
  分治法是一种“并行”算法。由于子问题是相互独立的,因此可以把子问题分给不同的计算机,分开单独解决。
  分治法如何编程?分治法的思想几乎就是递归的过程,用递归程序实现分治法是很自然的。
  在用分治法建立模型时,解题步骤分为以下3步。
   (1)分解(Divide): 把问题分解成独立的子问题。
   (2)解决(Conquer): 递归解决子问题。
   (3)合并(Combine): 把子问题的结果合并成原问题的解。
  分治法的经典应用有汉诺塔、快速排序、归并排序等。

归并排序

  归并排序和快速排序都是非常精美的算法,学习他们,对于理解分治法思想、提高算法思维能力十分有帮助。在学习归并排序和快速排序之前,需要先了解交换排序、选择排序、冒泡排序等暴力的排序方法。
  根据分治法的分解、解决、合并三步骤,具体思路如下:
   (1) 分解。把原来无序的数列分成两个部分,对每个部分,再继续分解成更小的两部分,一直循环下去。在归并排序中,只是简单地把数列分成两边。在快速排序中,是把序列分成左、右两个部分,左部分的元素都小于右部分的元素。分解操作是快速排序的核心操作。
   (2) 解决。分解到最后不能再分解,排序。
   (3) 合并。把每次分开的两个部分合并在一起。归并排序的核心操作是合并,其过程类似于交换排序。快速排序并不需要合并操作,因为在分解过程中左、右部分已经是有序的。

归并排序示例

  下面的例子给出了归并排序的操作步骤。初始数列经过3趟归并之后得到一个从小到大的有序数列,如下图所示。
Divide1.jpg
   (1) 分解。把初始序列分成长度相同的左、右两个子序列,然后把每个子序列再分成更小的两个子序列,直到子序列只包含1个数。这个过程用递归实现,图中的第1行是初始序列,每个数是一个子序列,可以看成递归到达的最底层。
   (2) 求解子问题,对子序列排序。最底层的子序列只包含1个数,其实不用排序。
   (3) 合并。归并两个有序的子序列,这是归并排序的主要操作,过程如下图所示。例如在(a)中,i和j分别指向子序列{13,94,99}和{34.56}的第一个数,进行第1次比较,发现 a[i] < a[j], 把 a[i] 放到临时空间 b[ ]中。总共进过4次比较,得到了b[ ]={13,34,56,94,99}。
Divide3.jpg

  在暴力排序算法中,有一种算法是交换排序,归并排序可以看成是交换排序的升级版。
  交换排序的步骤如下:
   (1) 第1轮,检查第1个数 $ a_{1} $。把序列中后面所有的数一个个跟它比较,如果发现有一个比 $ a_{1} $ xioa ,就交换。第1轮结束后,最小的数就排在了第1个位置。
   (2) 第2抡,检查第2个数。第2轮结束后,第2小的数排在了第2个位置。
   (3) 继续上述过程,直到检查完最后一个数。
  交换排序的复杂度是 $ O(n^{2}) $
  在归并排序中,一次合并的操作和交换排序很相似,只是合并的操作是基于两个有序的子序列,效率更高。
  下面分析归并排序的计算复杂度。对n个数进行归并排序:①需要 $ log_{2}n $ 趟归并。 ②在每一趟归并中有很多次合并操作,一个需要 $ O(n) $ 次比较。所以计算复杂度是 $ O(nlog_{2}n) $
  空间复杂度:由于需要一个临时的b[ ]存储结果,所以空间复杂度是O(n)。
  我们可以从归并排序的例子可以体会到,对于整体上O(n)复杂度的问题,通过分治可以减少为 $ O(log_{2}n) $ 复杂度的问题。

逆序对问题

  排序是常用的功能,一般直接使用STL的sort()函数,并不需要自己再写一个排序的程序。不过也会遇到一些特殊的问题,需要在排序的时候做一些处理,就比如逆序对问题。

Inversion

  输入一个序列 $ { a_{1} , a_{2} , a_{3} , \dots , a_{n} } $ ,交换任意两个相邻元素,不超过k次。在交换之后,问最少的逆序对有多少个?
  序列中的一个逆序对是指存在两个数 $ a_{i} $ 和 $ a_{j} $ ,有 $ a_{i} > a_{j} $ 且 $ 1 \le i < j \le n $ 。也就是说,大的数排在小的数前面。
  输入:第1行是n和k,$ 1 \le n \le 10^{5} , 0 \le k \le 10^{9} $ ; 第2行包括n个整数
$ { a_{1} , a_{2} , a_{3} , \dots , a_{1} } , 0 \le a_{i} \le 10^{9} $
  输出: 最少的逆序对数量
  输入样例
  3 1
  2 2 1
  输出样例
  1

  当k=0时,就是求原始序列中有多少个逆序对。
  求k=0时的逆序对,用暴力法很容易:先检查第1个数 $ a_{1} $ ,把后面的所有数跟它比较,如果发现有一个比 $ a_{1} $ 小,就是一个逆序对;再检查第2个数,第3个数……直到最后一个数。其复杂度是 $ O(n^2) $ 。本题中n最大是10^5,所以暴力法会TLE。
考察暴力法的过程,会发现和交换排序很像。那么自然可以想到,能否用交换排序的升级版——归并排序来处理逆序对问题?
  观察上图一次合并可以发现,可以利用这个过程记录逆序对。观察到以下现象:
   (1)在子序列内部,元素都是有序的,不存在逆序对;逆序对只存在于不同的子序列之间。
   (2)在合并两个子序列时,如果前一个子序列的元素比后面的子序列元素小,那么不产生逆序对,如上图(a)所示。如果前一个子序列的元素比后面子序列的元素大,就会产生逆序列,如上图(b)所示。不过在一次合并中,产生的逆序对,不止一个,例如在上图(b)中,把34放到b[ ]中时,它与94、99产生了两个逆序对。在下面的程序中,相关代码是 “cnt+=mid-i+1;” 。
根据以上观察,只要在归并排序过程中记录逆序对就行了。
  以上解决了k=0时原始序列中有多少个逆序对的问题,现在考虑,当k不为0时(即把序列中任意两个相邻数交换不超过k次)逆序对最少有多少?注意,不超过k次的意思是可以少于k次,而不是一定要k次。
在所有相邻数中,只有交换那些逆序的才会影响逆序对的数量。设原始序列有cnt个逆序对,讨论以下两种情况:
   (1)如果 $ cnt \le k $ , 总逆序数量不够交换k次。所以进行k次交换后,最少的逆序对数量为0.
   (2)如果 $ cnt > k $, 让k次交换都发生在逆序的相邻数上,那么剩余的逆序对是cnt—k 。
  求逆序对的程序几乎可以完全套用归并排序的模板,差不多就是归并排序的裸题。在下面程序中,Mergesort()和Merge()是归并排序。与纯归并排序的程序相比,它只多了一句 “cnt+=mid-i+1;”

# include <bits/stdc++.h>
using namespace std;

const int MAXN = 100005;
typedef long long ll;
ll a[MAXN], b[MAXN], cnt;
void Merge(ll l, ll mid, ll r){
    ll i = l, j = mid + 1, t = l;
    while ( i <= mid && j <= r){
        if (a[i]>a[j]){
            b[t++] = a[j++];
            cnt += (mid - i + 1);  // 记录逆序对的数量
        }
        else b[t++] = a[i++];
    }

    //一个子序列中的数都处理完了,另一个还没有,把剩下的直接复制过来
    while (i <= mid) b[t++] = a[i++];
    while (j <= r) b[t++] = a[j++];
    for(i=0;i<t;i++) a[l+i] = b[l]; // 把排好序的b[]复制回a[]   
}

void Mergesort(ll l, ll r){
    if ( l < r ){
        ll mid = (l + r) / 2;  // 平分成两个子序列
        Mergesort(l, mid);  // 左半边
        Mergesort(mid + 1, r);  // 右半边
        Merge(l, mid, r);  // 合并
    }
}

int main(){
    ll n,k;
    while (scanf("% lld % lld",&n,&k)!=EOF){
        cnt = 0;  // 初始化逆序对计数
        for (ll i = 0; i < n; i++) scanf("%lld", &a[i]);  // 输入数组
        Mergesort(0, n - 1);  // 执行归并排序
        if (cnt <= k) printf("0\n");
        else printf("% I64d\n",cnt - k);  // 输出逆序对数量减去k的结果
    }
    
    return 0;
}

快速排序

  快速排序的思路是:把序列分成左右两个部分,使得左边所有的数都比右边的数小;递归这个过程,直到不能再分为止。
    那么如何把序列分成左、右两部分?最简单的办法是设定两个临时空间X、Y和一个基准数t;检查序列中所有的元素,比t小的放在X中,比t大的放在Y中。其实不用这么麻烦,直接在原序列上操作就行了,不需要使用临时空间X、Y。
  直接在原序列上进行划分的方法也有很多,下面的例子介绍了一种很容易操作的方法:
Divide2.jpg

用上述方法完成快速排序:

# include <bits/stdc++.h>
const int N = 10010;
int data[N];

# define swap(a,b) {int temp=a; a=b; b=temp;}   //交换

int partition(int left, int right){
    int i = left;
    int temp = data[right];  // 把尾部的数看成基数
    for(int j = left; j < right; j++){
        if(data[j] <= temp){  // 如果当前数小于等于基数
            swap(data[i], data[j]);  // 交换
            i++;  // 增加i的值
        }
    }
    swap(data[i], data[right]);  // 把基数放到正确的位置
    return i;  // 返回基数的位置
}


void quickSort(int left, int right){
    if(left < right){  // 如果左边小于右边
        int i = partition(left, right);  // 划分
        quickSort(left, i - 1);  // 对左边进行快速排序
        quickSort(i + 1, right);  // 对右边进行快速排序
    }
}

int main(){
    int n;
    scanf("%d", &n);  // 输入数据的个数
    for(int i = 0; i < n; i++){
        scanf("%d", &data[i]);  // 输入数据
    }   
    quickSort(1, n);  // 调用快速排序
    printf("%d\n", data[(n+1)/2]);  // 输出第一个数
    return 0;
}

  下面分析复杂度。
  每一次划分,都把序列分成了左右两部分,在这个过程中,需要比较所有的元素,有O(n)次。如果每次划分是对称的,也就是说左、右两部分的长度差不多,那么一共需要划分 $ O(log_{2} n) $ 次。其总复杂度是 $ O(nlog_{2} n) $ 。
  如果划分不是对称的,左部分和右部分的数量差异特别大,那么复杂度会高一些。在极端情况下,例如左部分只有一个数,剩下的全部都在右部分,那么最多可能划分n次,总复杂度是 $ O(n^{2}) $ 。所以,快速排序是不稳定的
  不过,一般情况下,快速排序效率很高,甚至比稳定的归并排序更好。我们可以观察到,快速排序的代码比归并排序的代码简洁,代码中的比较、交换、复制操作很少。快速排序几乎是目前所有排序法中速度最快的方法。STL的sort()函数就是基于快速排序算法的,并针对快速排序的缺点做了很多优化。
快  速排序思想可以用来解决一些特殊问题,例如求第k大数问题。
  求第k大的数,简单的方法是用排序算法进行排序,然后定位第k大的数,其复杂度是 $ O(nlog_{2} n) $ 。
  如果用快速排序的思想,可以在O(n)的时间内找到第k大的数。在快速排序程序中,每次划分的时候只要递归包含第k个数的那部分就好。


本站总访问量

免责声明

本站提供的一切软件、教程和内容信息仅限用于学习和研究目的。

不得将上述内容用于商业或非法用途,否则一切后果自负。

本站信息来自网络收集整理,版权争议与本站无关。

如果有侵权之处请第一时间联系站长删除。敬请谅解!