减治法原理
何为减治?减治法是通过减小问题规模,产生一个子问题,那么只要找到这个子问题的解,然后再找到子问题解与原问题解的关系,那么就成功解决了这个问题。当然,减治操作是可以递归的,问题规模当然越小越好,常见的减治法主要有:减常数规模、减常因子规模、减可变规模三种。
插入排序
插入排序是经典的排序算法,但其实从一个角度讲,它同样是一种的减治法的体现:假如现在有N个数,需要输出非递减序列。这当然很简单!
但让我们从分治法的角度来分析一下,我们要将N个数排序,如果第一个数本身就是有序的就好了!!!我们不妨假设,第一个数就是有序的,那我们当然希望第二个数也是有序的,对吧?那如果第二个数有序,我当然希望第三……咳咳!!!幻想当然是美好的,但我们还是要面对现实。但这个幻想却给我们提供了一个解决问题的角度,也就是排好第一个结点,然后不断减小待排序数的规模,直至问题解决。
那我们来实现以下,要排好第一个数,就先要找到最小的数,这一点毋庸置疑。同样这也很好解决,我们先默认第一个结点下标index是最小的,然后遍历一遍链表,碰见更小的结点就更新一下index。这样一遍下来,就可以得到最小的结点,那么我存好的这个结点后,问题的规模是不是就变小了!!!那么为了充分复用空间,我们直接在外层循环中将下标为index和下标为i的两个数调换顺序即可,此时我只关心第一个结点要放在首位,至于一开始的i节点被换到哪里去了,I don't care!!!
viod InsertionSort(a[N]){
int index=0;
for(int i=0;i<N;i++){
index=i;
for(int j=i+1;j<N-i;j++){
if(a[index]>a[j])
index=j;
}
int temp=a[index];
a[index]=a[i];
a[i]=temp;
}
}
拓扑排序
那么讲完了超级简单的插入排序,我们稍微进阶一下,来看一看比较简单的拓扑排序。
首先拓扑是数学中的一个概念,当然我们今天不讲数学(单纯因为我真的不不不……喜欢)。我们今天简单讲一讲在它在实际生活中的例子:
大家对课表肯定不陌生,但是一张课表是怎么排出来的呢?因为我们知道,课程的开设肯定是有顺序的,以计算机大类课程为例,C语言程序设计肯定要在数据结构之前开吧!!!所以说有些课程是有先后顺序的,而有些课程是可以并行开设的。那如果将这些课程抽象成结点,那么这些课程的关系应该是一个有向无环图。有人会问哦,为什么是一个有向无环图?其实很好理解,可以想象一下,如果你课表上有一门大四课程需要先学一门大一的课程,而你发现这门大一的课程又需要先学这门大四的课程……哇哦!!!(是先有鸡,还是先有蛋呢?)这样很好理解了吧。
那我现在将其抽象出来:
假如现在有四门课程,其开设的先后关系由箭头表示(优先开设指向后开设)。
这当然可以用深度优先遍历的出栈顺序解决,但我们试着从减治法的角度来解决这个问题。
我们先思考一下,哪些结点是毫无疑问优先放置的?当然是那些没有入度的结点!!!一个结点没有入度说明它不受其他结点的约束,也就意味着它可以被无脑地放在第一位。那我就可以去除这一个节点,爽爽爽!!!问题的规模似乎又又又减小啦。同理,重复此操作即可,我们就可以输出:
C1->C2->C3->C4->C5
当然拓扑排序可以是不唯一的。
生成组合对象的算法
1.生成排列
排列问题是给定N个元素,生成其可能的全部序列。为简单起见,我们这里考虑,N各种整数的全部排列。
我们同样试着用减治的思想解决,先问大家一个问题,如果现在有N-1个数组成的序列(已给定),然后我将剩下的一个数随便插入两个数之间,会不会有重复的序列?
当然是没有的,其实很好理解,因为给定序列了,说明这N-1个数的相对位置已经确定了,所以不管第N个数插入哪里,所产生的序列都是唯一的。那么这就是,这道题可以用减治法解决的前提,简单说,完成N个数的排列,可以先完成N-1个数的排列。
举一个例子,N=3,先排好一个数(只选一个即可);然后再取第二个数,按顺序从左到右插到现有序列中;最后一个数重复上述操作,如下:
- -1-
- -2-1-、-1-2-
- -3-2-1-、-2-3-1-、-2-1-3-、-3-1-2-、-1-3-2-、-1-2-3-
PS:生成排列问题还可以用Johnson-Trotter算法、字典顺序生成算法,这里就不逐一介绍了。
2.生成子集
生成子集问题是指:给定由N个元素组成的集合A,求以集合A的所有子集为元素的集合。
试着用减治的思想来解决,我们先思考一下,假设任选一个元素a1,那么是不是幂集中的所有集合元素可以被划分为含a1的集合元素,以及不含a1的集合元素。并且将前者的每一个集合元素中加入a1,就变成了后者。有了这个思路,让我们反过来想,假如我们从集合A中任取一个元素ai,剩下的N-1个元素组成集合B,不妨假设有人已经求得了集合B的幂集。那么我们想要求集合A的幂集就会很简单,只需要将集合B的幂集中的每个集合元素中加上ai即可,最后之前的幂集合并哦。
举一个例子,N=3,则有集合A={1,2,3},先取一个元素(任选一个即可),写出它的幂集;然后再取一个元素,将其添加到上一步得到的幂集中,并补上空集,构成新的幂集;取最后一个元素,重复上述操作,如下:
- {1},幂集:{ Ø,{1} }
- {1,2},Ø∪{ 2 }={ 2 }、{ 1 }∪{ 2 }={ 1,2 }、与之前幂集合并,幂集:{ Ø,{1},{ 2 },{ 1,2 } }
- { 1,2,3 },Ø∪{ 3 }={ 3 }、{ 1 }∪{ 3 }={ 1,3 }、{ 2 }∪{ 3 }={ 2,3 }、{ 1,2 }∪{ 3}={ 1,2,3 }、幂集:{ Ø,{1},{ 2 },{ 3 },{ 1,2 },{ 1,3 },{ 2,3 },{ 1,2,3 } }
PS:生成子集问题还可以位串法解决,效率会更快。
减常因子规模算法
折半查找
折半查找其实就是一个减治法的典型案例,每一次遍历都能将处理数据的规模减半,时间复杂度为logN,算得上一个比较优的算法了。
代码实现:
#include <stdio.h>
#include <stdlib.h>
int main (){
int a[21];
printf("请按非减顺序输入20个整数:\n");
for(int i=1;i<=20;i++){
scanf("%d",&a[i]);
}
int rel=0;
int index=0;
printf("请输入你查找的数:\n");
scanf("%d",&rel);
int low=1;
int high=20;
int mid;
while(low<=high){
mid=(low+high)/2;
if(a[mid]==rel){
break;
}
if(rel<a[mid]){
high=mid-1;
}
if(rel>a[mid]){
low=mid+1;
}
}
index=low;
printf("%d的位置是:%d\n",rel,index);
return 0;
}
假币问题
问题简述:有N枚硬币,已知其中有一枚假币,并且假币的重量较轻。现有天枰,设计算法是的称最少次数找到这枚假币。
大家可以思考一下,我们一直假币的重量较轻,而天枰可以判断两堆硬币的重量。那我们不妨将所有硬币分成两堆(如果N为奇数,就剩下一枚),然后用天枰一称,不就知道假币在哪一堆里面了吗。
那么按照这种思路,每称一次,问题的规模就会减为一半 ,时间复杂度就为:
W(1)=0
W(N)=W(N/2)+1
由上面的递推公式,不难计算时间复杂度为:
那可不可以更快呢?当然是可以继续优化的,如果我们一开始就将硬币分成三堆,那么使用一次天枰,问题的规模会减少成多大呢?不难分析出,问题的规模减少为了N/3,同理,我们可以算出这种方案的时间复杂度:
从硬币问题这个实例可以知道,这里的减常因子规模,并不一定是规模变成一半,同样可以是1/3、1/4……也就是规模呈等比递减即可。
约瑟夫问题
讲到这里,不知道大家有没有反应过来,约瑟夫问题同样是体现减治法思想的经典问题。
我们刚才在前面总结到:如果每次处理数据能够将数据规模等比减小,这就是一种减治法。
让我们回顾一下约瑟夫问题:有N个人,从1-n编号,按编号顺序围成圈,从1号开始报数,报到m的人被淘汰。
我们模拟一下,淘汰过程(不失一般性的假设N=7,m=2):
PS:黑色数字是序号,红色数字代表被淘汰的次序(1就是第一个被淘汰)
那么不难看出,从1开始,当遍历完一圈后,剩下人数的规模已经减为1/2,而这个1/2是由m决定的。换句话说,m决定以遍历一圈后,减治的常因子,例如m=3,则减治规模是1/3。
减可变规模算法
计算中值和选择问题
选择问题就是,求一n个数组成序列中的,第k的最小值。这个值也被称为,第k个顺序统计量。
我们先试着用无脑的方式解决它,从蛮力法的角度是比较简单的,我们就把它当作一个排序问题问题,排好序后不就可以知道第k个顺序统计量了吗。这样想当然没有问题,但是我们似乎造成了算里的浪费,大家想一想,排好序的序列,我难道只知道第k个顺序统计量嘛?我其实可以知道任何一个位置的顺序统计量,但是其他元素的位置关系我并不关心。
所以我们要做的是设计一个算法,只找第k个最小值(第k个顺序统计量),而不要浪费时间到其他元素的排序上。那么我们该怎么思考?
同样用减治法的思考方式,假设这有一个非减有序整数列,为了更加形象我们将其画出来:
那么有一个问题,如果我将第k个数之前的所有数以及之后的所有数打乱,这一操作会不会影响K的相对位置呢?当然是不会的,因为可以明确,第k个数前面的数一定小于K,同理后面的数一定大于K。那么根据这个思路,我们是不是可以在一个无序整数列选定一个数,然后以这个数为中值进行划分,小于它的在前,大于它的在后,这样就产生了一个新的序列。最后返回刚才选定中值的位置index,如果index==k的话结束,如果index<k那就在k左边重复刚才操作,如果index>k那就在k右边重复刚才操作,直到index==k。
讲到这里熟悉的同学可能已经反应过来了,这有点像快速排序,而那个划分的过程被称为Pomuto划分方法。
这个思路是完全没有问题的,然后我们来实现它(ps:这里面有一个很巧妙的操作(●'◡'●)):
#include <stdio.h>
#include <stdlib.h>
// 交换*a和*b
void swap(int* a, int* b)
{
int temp = *a;
*a = *b;
*b = temp;
}
// 划分,a是数组首地址,low和high分别是两端的索引,要求low<=high
int PomutoPartition(int a[],int low,int high){
int s=low;
int p=a[low];
int j=low+1;
while(j<=high){
if(a[j]<p){
s++;
swap(&a[s],&a[j]);
}
j++;
}
swap(&a[s],&a[low]);
return s;
}
// 返回第k小的元素值。
// 注意:k不是下标,表示第k个
int Quickelect(int a[],int low,int high,int k){
int s=PomutoPartition(a,low,high);
if(s==low+k-1){
return a[s];
}
else if(s>low+k-1){
return Quickelect(a,low,s-1,k);
}
else{
return Quickelect(a,s+1,high,k-(s-low+1));
}
}
int main (){
int a[20];
int left=0;
int right=19;
printf("请输入20个无序整数:\n");
for(int i=0;i<20;++i){
scanf("%d",&a[i]);
}
int k=0;
printf("你想要找第几个顺序统计量:\n");
scanf("%d",&k);
int rel=Quickelect(a,0,19,k);
printf("%d\n",rel);
return 0;
}
//9 4 8 6 7 51 52 53 58 54 32 31 39 38 36 21 25 29 24 22
我们重点讲一下Potumo划分方法:
int PomutoPartition(int a[ ],int low,int high)
// 这里将数组输入,并且用两个参数low、high限制住划分的区间
int s=low;
int p=a[low]; // 划分时选取最左边的值作为中值p
int j=low+1; // 从j=low+1开始遍历
while(j<=high){ // 结束条件就是遍历到最后一个元素了,j>=high
if(a[j]<p){ // 这一部分为了更加形象,我结合下面的图讲
s++;
swap(&a[s],&a[j]);
}
j++;
}
PS:(s也是>=p的)大家可以先不用疑惑怎么到这一步,我一开始其实也有点懵,这里卡了很久,但其实应该先往后推一次,就知道原理了
现在 j 要继续往后遍历,也就是 j++,如果这时候 a[ j ]<p,将 s 往后挪一个,就是图中的 s
将a[ s ]与a[ j ]交换:
这里就是我前面说的巧妙之处,是将红色箭头中>=p集合中的第一个(也就是a[ s ]),与a[ j ]交换。那么结果就是,>=p集合中的第一个(也就是a[ s ]),变成了>=p集合中的最后一个,并且这个<p的a[ j ]被加到了<p集合的最后一个。(反正我当时看的时候是被惊艳到了!!!)
最后还有一步交换(也很巧妙!!!):
swap(&a[s],&a[low]);
return s;
这一步操作(此时的a[ s ]是<p集合的最后一个)的结果是使此时的a[ s ]是<p集合的最后一个,移动到了<p集合的第一个,并且p变成了>=p集合的第一个。
这样一次划分就做好了,然后调用int Quickelect(int a[],int low,int high,int k)递归解决即可。
待续
然后关于减可变规模的例子还有:插值查找、二叉查找树的查找与删除,有机会再给大家补充上去。
写在最后
总结一下,减治技术就是利用一个问题给出实例的解,与同样问题较小实例的解的关系,有了这种关系,就可以自顶向下(递归),或者自底向上(非递归)来解决。然后减治法又有三个变种:减去常数规模、减常因子规模、减可变规模。