分治法——排序问题
归并排序
对于两个已经排序完成的序列,假设长度分别为m和n,那么将二者合并成为一个排序完成的序列,其实就是不断从两个序列中挑出最小的元素,放到新生成的序列中,当一个序列完了之后把另一个序列剩下的放在新生成的序列末端即可,为此需要开辟m+n的额外空间。
根据此思路,为了将一个给定的序列排序,可以将其划分为两个子序列,然后对两个子序列分别排序,再按照此思路合并即可。当问题规模退化为1时,最小的问题显然是可解的。假设给定序列的长度为n,开辟大小为n的额外内存空间即可。
const int maxsize = 205;
int tmp[maxsize];
// 将两个排序完成的序列合并成一个新的排序完成的序列
void merge(int a[],int left,int mid,int right){
int j = left;
int k = mid+1;
int t = left;
while(true){
if(j>mid){
for(int i = k;i <= right;i++){
tmp[t] = a[i];
t++;
}
break;
}
if(k>right){
for(int i = j;i <= mid;i++){
tmp[t] = a[i];
t++;
}
break;
}
if(a[j]<a[k]){
tmp[t] = a[j];
j++;
}else{
tmp[t] = a[k];
k++;
}
t++;
}
}
//将序列tmp[left,right]的内容拷贝到a中的相同位置
void copy(int a[],int left,int right){
for(int i = left;i <= right;i++)
a[i] = tmp[i];
}
// 将序列a按照归并排序的方法进行非降序排列
// 输入:待排序序列a[left,right]
// 输出:排序完成的序列a
void mergeSort(int a[],int left,int right){
// 将序列[left,right]划分为[left,mid]和[mid+1,right]
int mid = (left+right)/2;
// 最小子问题,此时一个元素不需要排序,为了体现分治法才这样写,实际上可以直接忽略
if(left == right){
// nothing
return;
}else{
// 对划分的两个子序列分别进行排序
mergeSort(a, left, mid);
mergeSort(a, mid+1, right);
// 排序完成后将两个子序列合并到额外空间中,生成已经排序完成的序列
merge(a,left,mid,right);
// 将额外空间中储存的排序完成的序列拷贝回原序列
copy(a,left,right);
}
}
复杂度分析:
输入规模:待排序序列长度n
基本操作:合并操作merge中填入额外空间的操作
复杂度只与问题输入规模有关,无论输入是否有序,都要从最小规模的问题开始一步一步合并出最终的结果
递归算法,复杂度递推式为:T(n) = 2T(n/2) + O(n);T(1) = O(1);
计算出复杂度为O(nlogn)
快速排序
和归并排序类似,不同的是,归并排序是以序列的中点来划分子序列的,而快排的划分基准点则是某个元素的大小,称为“哨兵”。经过一次遍历,序列被分为两部分,哨兵左边的元素小于等于哨兵的大小,哨兵右边的元素大于等于哨兵的大小。与归并排序不同的是,归并排序的求解重点在于“合”,从最小子问题开始一步一步求解直到合并出原问题;快排则是自顶向下,当最小子问题被解决时,问题就已经解决了。
如何在一次遍历完成哨兵的划分而又尽量减少元素移动的数量,参照以下图解:
代码:
// 快排
// 输入:序列a[left,right]
// 输出:排序完成的序列a
void quickSort(int a[],int left,int right){
// 选定哨兵并记录序列的起止
int aMid = a[left];
int leftBack = left;
int rightBack = right;
// 同样的理由以显示最小子问题,实际写的时候可以写成left<right则处理问题,注意是>=而不是==,防止越界问题
if(left>=right){
//nothing
}else{
// 从右指针开始移动,若发生替换则移动被替换的位置的指针,否则移动上次移动的指针
bool rightFlg = true;
while(left<right){
if(rightFlg){
if(a[right] < aMid){
a[left] = a[right];
left++;
rightFlg = false;
}else{
right--;
}
}else{
if(a[left] > aMid){
a[right] = a[left];
right--;
rightFlg = true;
}else{
left++;
}
}
}
// 哨兵位于最后两指针相遇的位置
a[left] = aMid;
// 这里为了减小子问题的规模,不能将哨兵放入子问题中,否则子问题可能一直减小不了,这一点和归并排序不同
quickSort(a, leftBack, left-1);
quickSort(a, right+1, rightBack);
}
}
输入规模:待排序序列长度n
基本操作:发生比较与替换的次数
复杂度不止与问题输入规模有关,还与问题输入本身有关,需要分析最好、最坏与平均时间复杂度
最好情况下,快速排序与归并排序一样,都是T(n) = 2T(n/2) + O(n);T(1) = O(1);复杂度为O(nlogn)
最坏情况下,每次哨兵都是最小的那个元素,递推式就是T(n) = T(n-1) + O(n);T(1) = O(1);复杂度为O(n2)
一般情况下的证明过于复杂,此处不讨论
一些比较
快速排序和归并排序的实现类似,都是分治思想的体现。
不同的是,归并排序是稳定的,快排则不稳定。
此外,快排最好的时间复杂度和归并一样都是O(nlogn),最坏情况下退化为冒泡排序比归并复杂度要高,那么为什么快排比归并快呢?因为快排所涉及的内存读写次数更少。