前言
各种排序算法已经被写烂了。以前也不屑写排序的博客,直到我第二次遇到逆序对这个题。
第一次解的时候,根据题解用归并排序和线段树两种方法通过,以为自己理解了。在次看到,知道用归并排序能通过,还是没有彻底弄明白。
学一个知识,学了和学会了真的是两码事。
只是学了,单纯的就是浪费时间。
--------------------------------------------------------------我是分割线-------------------------------------------------------
了解归并排序
基本思想:将一个序列划分为同样大小的两个子序列,然后对两个子序列分别进行排序,再将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为2-路归并(也有多路归并排序,不在此讨论范畴)。该算法是分治法(Divide and Conquer)的一个非常典型的应用。
总之记住两个字: 归、并
下面是归并排序的过程。图片来源:https://www.cnblogs.com/onepixel/articles/7674659.html
在合成的过程中,一般的实现都需要开辟一块与原序列大小相同的空间,以进行合并操作,所以我们需要最大O(n)的辅助空间用于存储合并序列。
时间开销:nlogn (典型的二分思想,最好最坏都是这么多)
空间开销:n
稳定性:稳定
实现过程:两种实现方式,一种自上而下(即先分再合),一种自下而上(先合再合)
自上而下的代码实现:
void merg(int a[],int start,int ed)//两个相邻有序区间合并成一个
{
int mid= (start+ed)/2;
int *temp = new int[ed-start+1];//辅助数组
int i=start,j=mid+1,k=0;//i:前半区间,j:后半区间,k:辅助数组下标
while(i<=mid&&j<=ed)
{
if(a[i]>=a[j]) //> 或 >= 代表了不稳定或稳定
temp[k++]=a[j++];
else
temp[k++]=a[i++];
}
while(i<=mid) //前半部分剩余的值
temp[k++] = a[i++];
while(j<=ed) //后半部分剩余的值
temp[k++] = a[j++];
for(i=0;i<k;i++) //辅助数组复制到原数组
a[start+i]=temp[i];
delete[] temp;
}
void mergeSort(int a[],int start,int ed)//自上而下归并
{
if(ed<=start) return;
int mid = (start+ed)/2;
mergeSort(a,start,mid); //递归
mergeSort(a,mid+1,ed);
merg(a,start,ed); //合并
}
只想了解基本思想,到这里可以结束了。
深入了解归并排序
以逆序对这个题来看一下具体执行过程。
题目:给一个数组,统计该数组中的逆序对数。
逆序对:在数组中的两个数字,如果前面一个数字大于后面的数字,则这两个数字组成一个逆序对。
比如: 数组:[7,5,6,4] , 7>5 ,7>6 , 7>4, 5>4 , 6>4 , 共5个逆序对
题解:来源:剑指offer
看到这个题目,我们的第一反应是顺序扫描整个数组。每扫描到一个数组的时候,逐个比较该数字和它后面的数字的大小。如果后面的数字比它小,则这两个数字就组成了一个逆序对。假设数组中含有n个数字。由于每个数字都要和O(n)这个数字比较,因此这个算法的时间复杂度为O(n^2)。
我们以数组{7,5,6,4}为例来分析统计逆序对的过程。每次扫描到一个数字的时候,我们不拿ta和后面的每一个数字作比较,否则时间复杂度就是O(n^2),因此我们可以考虑先比较两个相邻的数字。
在上图(a)和(b)中,我们先把数组分解成两个长度为2的子数组,再把这两个子数组分别拆成两个长度为1的子数组。接下来一边合并相邻的子数组,一边统计逆序对的数目。在第一对长度为1的子数组{7}、{5}中7大于5,因此(7,5)组成一个逆序对。同样在第二对长度为1的子数组{6}、{4}中也有逆序对(6,4)。由于我们已经统计了这两对子数组内部的逆序对,因此需要把这两对子数组排序如上图(c)所示,以免在以后的统计过程中再重复统计。
接下来我们统计两个长度为2的子数组子数组之间的逆序对。合并子数组并统计逆序对的过程如下图所示。
我们先用两个指针分别指向两个子数组的末尾,并每次比较两个指针指向的数字。如果第一个子数组中的数字大于第二个数组中的数字,则构成逆序对,并且逆序对的数目等于第二个子数组中剩余数字的个数,如下图(a)和(c)所示。如果第一个数组的数字小于或等于第二个数组中的数字,则不构成逆序对,如图b所示。每一次比较的时候,我们都把较大的数字从后面往前复制到一个辅助数组中,确保辅助数组中的数字是递增排序的。在把较大的数字复制到辅助数组之后,把对应的指针向前移动一位,接下来进行下一轮比较。
过程:先把数组分割成子数组,先统计出子数组内部的逆序对的数目,然后再统计出两个相邻子数组之间的逆序对的数目。在统计逆序对的过程中,还需要对数组进行排序。如果对排序算法很熟悉,我们不难发现这个过程实际上就是归并排序。
个人理解
说白了一句话:这就是个归并排序,只不过在排序的过程中多写一行代码统计一下逆序对个数。
当然,其实区别还是有一点的。有没有注意到,以上过程是从大到小的把数据复制到辅助数组,因此指针是从最后端往前走。
既然咱上面的代码是从前往后复制,那这里我们能不能也从前往后统计呢,不然多麻烦?
答案是肯定的!
由于2个子数组是有序的,只要第二个数组中的某个位置j的值比第一个数组i位置的值小,那么他一定比第一个数组中i位置后面的数都小。
例如:
4 比第一个数组中的5小,那么4一定比5后面的所有数都小。那么包含4的逆序对就是 mid - i + 1 (5 - 2 + 1 = 4)
代码和上面基本一样:
int count=0;
void merg(int a[],int start,int ed)//两个相邻有序区间合并成一个
{
int mid= (start+ed)/2;
int *temp = new int[ed-start+1];//辅助数组
int i=start,j=mid+1,k=0;//i:前半区间,j:后半区间,k:辅助数组下标
while(i<=mid&&j<=ed)
{
if(a[i]>a[j]) //这里没有等于!!!!!!!!!!!
temp[k++]=a[j++] , count += mid-i+1; //这里多了一句
else
temp[k++]=a[i++];
}
while(i<=mid) //前半部分剩余的值
temp[k++] = a[i++];
while(j<=ed) //后半部分剩余的值
temp[k++] = a[j++];
for(i=0;i<k;i++) //辅助数组复制到原数组
a[start+i]=temp[i];
delete[] temp;
}
void mergeSort(int a[],int start,int ed)//自上而下归并
{
if(ed<=start) return;
int mid = (start+ed)/2;
mergeSort(a,start,mid); //递归
mergeSort(a,mid+1,ed);
merg(a,start,ed); //合并
}
到这里相信你对归并排序的整个过程已经了如指掌了!
什么?没有? 自己去全程不参考的把代码敲出来。
现在还遗留两个问题:
1.一定需要辅助数组吗? 反正我看了思路后自己去实现的时候没想到
2.自下而上是怎么实现的呢?
测底理解
1.一定需要辅助数组吗?
答案是否定的!
先来想想辅助数组的作用:
当我们在合并两个子数组的时候,先把数据全部复制到辅助数组,然后再用辅助数组替换原数组。
也就是说我们只是借用了第三方数组来给自己有序的两个数组合并而已。
那我们能不能不通过辅助数组,直接把两个数组合并呢?
方法还是有的。
现在需要合并这两个数组,怎么办呢?
思想就是归并时要保证i指针之前的数字始终是两个子序列中最小的那些元素。
示例如图:
假设我们现在有两个有序子序列如图a,进行原地合并的图解示例如图b开始
如图b,首先第一个子序列的值与第二个子序列的第一个值3比较,如果序列一的值小于1,则指针i向后移,直到找到比1大的值,但是此时i大于j的值。我们知道指针i之前的值一定是两个子序列中最小的块。
i不动,先用一个临时指针记录j的位置,然后用第二个子序列的值与序列一i所指的值3比较,如果序列二的值小于3,则j后移,直到找到比3大的值,即j移动到4的下标;
如图c ,经过图b的过程,我们知道数组块 [index, j) 中的值一定是全部都小于指针i所指的值3,即数组块 [index, j) 中的值全部小于数组块 [i, index) 中的值,为了始终保证指针i之前的元素为两个序列中最小的那些元素,即i之前为已经归并好的元素。我们交换这两块数组的内存块,交换后i移动相应的步数,这个“步数”实际就是该步归并好的数值个数,即数组块[index, j)的个数。
从而得到图d。
重复上述过程,直到最后。
void swap(int a[],int x,int y){
int temp;
temp = a[x];
a[x] = a[y];
a[y] = temp;
}
void reverse(int a[],int begin,int end){
while(begin < end){
swap(a,begin++,end--);
}
}
void exchange(int a[],int begin,int mid,int end){
reverse(a,begin,mid);
reverse(a,mid+1,end);
reverse(a,begin,end);
}
void merge(int a[],int begin,int mid,int end){
int i = begin;
int j = mid + 1;
while( i < j && j <= end){
while(i < j && a[i] <= a[j]){
i++;
}
int index = j;
while(j <= end && a[j] < a[i]){
j++;
}
exchange(a,i,inedx-1,j-1);
i += (j - index);
}
}
void mergeSort(int a[],int begin,int end){
if(begin < end){
int mid = begin + (end - begin)/2;
mergeSort(a,begin,mid);
mergeSort(a,mid+1,end);
merge(a,begin,mid,end);
}
}
那么再来看一下具体的交互过程:
两步:先翻转两个区间,再首尾交换
以上排序过程有个专业名词:原地归并排序!
数据交换过程也有个专业名词:手摇算法!
时间复杂度分析:
手摇算法的时间复杂度:
最好的情况: 左子段和右子段直接全部交换 ,很明显此时原地归并的复杂度还是O(n*logn)
最坏的情况: 一段一段的缓慢前进的情况,此时算法的时间复杂度就是n*n ,原地归并的复杂度就是O(n*n*logn)
综合起来原地归并的时间复杂度在O(n*logn)--O(n*n*logn)之间,而空间复杂度降到了O(1)
也就是说:虽然咱们把空间复杂度降了下来,时间复杂度却增加了,甚至比n^2还大!
这就可以理解普遍的实现都需要一个辅助数组了。
第二个问题:
2.自下而上是怎么实现的呢?
其实思想很简单,把最开始那张图只看下半部分就是了。
void merg(int a[],int start,int ed)//两个相邻有序区间合并成一个
{
int mid= (start+ed)/2;
int *temp = new int[ed-start+1];//辅助数组
int i=start,j=mid+1,k=0;//k:辅助数组下标
while(i<=mid&&j<=ed)
{
if(a[i]>a[j]) temp[k++]=a[j++];
else temp[k++]=a[i++];
}
while(i<=mid) //前半部分剩余的值
temp[k++] = a[i++];
while(j<=ed) //后半部分剩余的值
temp[k++] = a[j++];
for(i=0;i<k;i++) //辅助数组复制到原数组
a[start+i]=temp[i];
delete[] temp;
}
/*自下而上归并其实就是自上而下的时候的回朔过程,
先对每一个数字排序,在两两排序,在对结果两两排序,直到完成*/
void mergSortDown2up(int a[],int start,int ed)
{
int len=ed-start+1;
int i,j;
for(i=1;i<len;i=i*2) //i为步数:1,2,4,8....l,即区间大小
{
for(j=start;j+2*i-1<=ed-1;j=j+2*i) //注意合并的区间首尾,自己体会
merg(a,j,j+2*i-1);
if(j+i-1<ed)//剩余一个子数组没有配对,将该子数组合并到已排序的数组中
merg(a,j+i-1,ed);
}
}
总算写完了。
要是还没测底理解。
那你肯定是看晕了(逃)。