《算法笔记》第四章——基础算法之排序 学习记录
选择排序
简单选择排序是指:对一一个序列A中的元素A[1] ~ A[n],令i从1到n枚举,进行n趟操作,每趟从待排序部分[i, n]中选择最小的元素,令其与待排序部分的第一个元素A[i]进行交换,这样元素A[i]就会与当前有序区间[1, i- 1]形成新的有序区间[1, i]。于是在n趟操作后,所有元素就会是有序的。
void selectSort()
{
for(int i=0;i<n;i++)
{
int k=i;
for(int j=i;j<n;j++)
if(a[j] < a[k])
k=j;
swap(a[i],a[k]);
}
}
插入排序
直接插入排序是指:对序列A的n个元素A[1] ~ A[n],令i从2到n枚举,进行n-1趟操作。假设某一趟时,序列A的前i-1个元素A[1]~ A[i- 1]已经有序,而范围[i, n]还未有序,那么该趟从范围[1, i- 1]中寻找某个位置j,使得将A[i]插入位置j后(此时A[j]~A[i- 1]会后移一位至A[j+ 1]~ A[i]),范围[1, i]有序。
归并排序
归并排序是一种基于“归并”思想的排序方法,本节主要介绍其中最基本的2-路归并排序。2-路归并排序的原理是,将序列两两分组,将序列归并为\(\lceil \frac{n}{2} \rceil\) 个组,组内单独排序;然后将这些组再两两归并,生成一个组,组内再单独排序;以此类推,直到只剩下一个组为止。
归并排序的时间复杂度为\(O(nlogn)\)。
下面来看一一个例子,要将序列{66, 12, 33, 57, 64, 27, 18}进行2-路归并排序。
- 第一趟。两两分组,得到四组: {66, 12}、 {33, 57}、 {64, 27}、 {18},组内单独排序,得到新序列{{12, 66}, {33, 57}, {27, 64}, {18}}。
- 第二趟。将四个组继续两两分组,得到两组: {12, 66, 33,57}、{27, 64, 18},组内单独排序,得到新序列{{12, 33, 57, 66}, {18, 27, 64}}。
- 第三趟。将两个组继续两两分组,得到一组: {12, 33, 57, 66, 18, 27, 64},组内单独排序,得到新序列{12, 18, 27, 33, 57, 64, 66}。算法结束。
从上面的过程中可以发现,2-路归并排序的核心在于如何将两个有序序列合并为一个有序序列,而这个过程在上一小节的“序列合并问题”中已经讲解。接下来讨论2-路归并排序的递归版本和非递归版本的具体实现。
2-路归并排序的递归写法非常简单,只需要反复将当前区间[left, right]分为两半,对两个子区间[eft, mid]与[mid + 1, right]分别递归进行归并排序,然后将两个已经有序的子区间合并为有序序列即可,代码如下,其中merge函数为上一节的代码改编而来:
const int maxn=100;
//l2==r1+1
void merge(int a[],int l1,int r1,int l2,int r2)
{
int i=l1,j=l2;
int temp[maxn],index=0;
while(i<=r1 && j<=r2)
{
if(a[i] <= a[j])
temp[index++]=a[i++];
else
temp[index++]=a[j++];
}
while(i<=r1) temp[index++]=a[i++];
while(i<=r2) temp[index++]=a[j++];
for(int i=0;i<index;i++) a[l1+i]=temp[i];
}
void mergeSort(int a[],int left,int right)
{
if(left < right)
{
int mid=(left+right)/2;
mergeSort(A,left,mid);
mergeSort(A,mid+1,right);
merge(A,left,mid,mid+1,right);
}
}
2-路归并排序的非递归实现主要考虑到这样一点:每次分组时组内元素个数上限都是2的幂次。
于是就可以想到这样的思路令步长step的初值为2,然后将数组中每step个元素作为一组,将其内部进行排序(即把左step/2个元素与右step/2个元素合并,而若元素个数不超过step/2,则不操作);再令step乘以2,重复上面的操作,直到step/2超过元素个数n。
void mergeSort(int a[])
{
for(int step=2;step/2<=n;step*=2)
for(int i=1;i<=n;i+=step)
{
int mid=i+step/2-1;
if(mid+1 <= n)
merge(A,i,mid,mid+1,min(i+step-1,n));
}
}
快速排序
快速排序是排序算法中平均时间复杂度为\(O(nlogn)\)的一种算法,其实现需要先解决这样一个问题:对一个序列A[1]、A[2]、...A[n], 调整序列中元素的位置,使得A1的左侧所有元素都不超过A[1]、右侧所有元素都大于A[1]。例如对序列{5, 3, 9, 6, 4, 1}来说,可以调整序列中元素的位置,形成序列{3,1,4,5,9,6}, 这样就让A[1]=5左侧的所有元素都不超过它、右侧的所有元素都大于它。
对这个问题来说可能会有多种方案,所以只需要提供其中一种方案。下面给出速度最快的做法,思想就是two pointers:
- 先将A[1]存至某个临时变量temp,并令两个下标left、right 分别指向序列首尾(如令left=1、right=n)。
- 只要right指向的元素A[right]大于temp,就将right不断左移;当某个时候A[right]≤temp时,将元素A[right]挪到left指向的元素A[left]处。
- 只要left指向的元素A[left]不超过temp,就将left不断右移;当某个时候A[left]>temp时,将元素A[left]挪到right指向的元素A[right]处。
- 重复3、4,直到left与right相遇,把temp (也即原A[1])放到相遇的地方。
int Partition(int a[],int l,int r)
{
int temp=a[l];
while(l<r)
{
while(l<r && a[r] > temp) r--;
a[l]=a[r];
while(l<r && a[l] <= temp) l++;
a[r]=a[l];
}
a[l]=temp;
return l;
}
接下来就可以正式实现快速排序算法了。快速排序的思路是:
- 调整序列中的元素,使当前序列最左端的元素在调整后满足左侧所有元素均不超过该元素、右侧所有元素均大于该元素。
- 对该元素的左侧和右侧分别递归进行1的调整,直到当前调整区间的长度不超过1。
快速排序的递归实现如下:
void quickSort(int a[],int l,int r)
{
if(l<r)
{
int pos=Partition(a,l,r);
quickSort(a,left,pos);
quickSort(a,pos+1,right);
}
}
快速排序算法当序列中元素的排列比较随机时效率最高,但是当序列中元素接近有序时,会达到最坏时间复杂度\(O(n2)\),产生这种情况的主要原因在于主元没有把当前区间划分为两个长度接近的子区间。
有什么办法能解决这个问题呢?其中一个办法是随机选择主元,也就是对A[left...right]来说,不总是用A[left]作为主元,而是从A[left]、A[left+1]、...A[right]中随机选择一个作为主元,这样虽然算法的最坏时间复杂度仍然是\(O(n2)\) (例如,总是选择了A[left]作为主元),但对任意输入数据的期望时间复杂度都能达到\(O(nlogn)\),也就是说,不存在一组特定的数据能使这个算法出现最坏情况(详细证明可以参考算法导论)。
下面来看看怎样生成随机数。
C语言中有可以产生随机数据的函数,需要添加stdlib.h头文件与time.h头文件。首先在main函数开头加上“srand((unsigned)time(NULD);", 这个语句将生成随机数的种子(不懂也没关系,只要记住这个语句,并且知道srand是初始化随机种子用的即可)。然后,在需要使用随机数的地方使用rand()函数。
同时还需要知道,rand()函数只能[0, RAND_MAX]范围内的整数(RAND_MAX是stdlib.h 中的一个常数,在不同系统环境中,该常数的值有所不同,本书中使用的是32767),因此如果想要输出给定范围[a, b]内的随机数,需要使用rand()%(b-a+1)+a。显然rand()%(b-a+1)的范围是[0,b-a],再加上a之后就是[a, b]。
可以发现,这种做法只对左右端点相差不超过RAND_MAX的区间的随机数有效,如果需要生成更大的数(例如[a,b],b大于32767)就不行了。
想要生成大范围的随机数有很多方法,可以多次生成rand随机数,然后用位运算拼接起来(或者直接把两个rand随机数相乘);也可以随机选每一个数位的值(0~9),然后拼接成一个大整数;当然,也可以采用另
一种思路:先用rand0生成一个[0, RAND_MAX]范围内的随机数,然后用这个随机数除以RAND_MAX,这样就会得到一个[0, 1]范围内的浮点数。我们只需要用这个浮点数乘以范围长度(b-a),再加上a即可,即(int)(round(1.0rand()/RAND_MAX(b-a)+a)),相当于这个浮
点数就是[a, b]范围内的比例位置。
下面是一个生成[10000, 60000]范围内随机数的示例:
int main()
{
srand(time(0));
for(int i=0;i<10;i++)
printf("%d ",(int)(round(1.0*rand()/RAND_MAX*50000+10000)));
return 0;
}
在此基础上继续讨论随机快排的写法。由于现在需要在A[left...right]中随机选取一个主元,因此不妨生成一个范围在[left, right]内的随机数p,然后以A[p]作为主元来进行划分。
具体做法是:将A[p]与A[left]交换,然后按原先Partition函数的写法即可。可以注意到,randPartition 函数只需要在Partition函数的最前面加上两句话即可,显然quickSort函数不需要进行任何改变。