浅谈排序
说到排序,那必然得先确定一种排序依据,或为数字大小,或为事物发生先后之顺序,或为父子等辈分关系。我们将所有要排序的元素放在一个集合里面,若这个集合是自反的,反对称,传递的,我们就称在这个集合上具有某种偏序R关系。也就是说只有满足这个条件,我们才能对这个集合做关于R关系的排序。
现实世界中,很多待排序的对象不是单一的数而是一个记录,其中的某个关键字域key,它是排序的依据。记录的其他数据称为卫星数据,它们以关键字为中心,当关键字移动时,卫星数据也要一起移动。
评价排序算法好坏的标准主要有两条:
1、执行时间和所需的辅助空间。执行时间主要与关键字的比较和移动次数有关。
2、算法本身的复杂程度。
算法
1.1简单插入排序
1.2希尔排序
2.1 冒泡排序
2.2快速排序
3.1 选择排序
3.2堆排序
4.1 归并排序
5.1 计数排序
5.2 桶排序
5.3基数排序
1.1简单插入排序
对r[1...n],将其分为两个部分r[1....i-1],r[i...n],其中r[1....i-1]排好序,依次选取r[i...n]中的数据,插入到r[1....i]中,是其继续保持有序。
void insertsort(record r[],int n)
{
for(i=2;i<=n;++i)
{
if(r[i-1]>r[i])
/*如果新纪录比最后一个小,插入排好的序列*/
{
r[0]=r[i];
for(j=i-1;r[0]<r[j];--j)
r[j+1]=r[j];/*记录后移*/
r[j]=r[0];
}
}
}
若此序列是排好的序列,显然需要n次比较。若是最差情况,第i个需要比较i次, =n(n+1)/2,平均为,所以其复杂度为O(n^2),辅助空间复杂度为O(1)。
可以进行改进,因为r[1..i-1]为有序,可对其进行二分查找,使第i个查找的时间变为log(i),其最差情况为。
void BIinsertsort(record r[],int n)
{
for(i=2;i<=n;++i)
{
if(r[i-1]>r[i])
/*如果新纪录比最后一个下小,插入排好的序列*/
{
r[0]=r[i];
low=1,high=i-1;
while(low<=high)/*二分查找插入位置*/
{
m=low+(high-low)/2
if(a[m]>a[0])
high=m-1;
else
low=m+1;
}
for(j=i-1;j>=low;--j)
r[low+1]=r[low];/*往后移动*/
r[low]=r[o];
}
}
}
1.2希尔排序
希尔排序又称“缩小增量法排序”,算法思想是将序列不断的按间隔分成若干小组,然后对每一组进行排序。是由插入排序改变来的。
先取间隔d1,然后去d2(d2<d1),直至di<1为止。
希尔提出的取法是d1=n/2,di=dm/2(BY:m=i-1);克努特则提出di=dm/3(BY:m=i-1);
void shellsort(record r[],int n)
{
int bool;
int i,j,d;
int x;
d=n;
do
{
d=d/2;
do
{
bool=1;
for(i=1;i<=n-d;i++)
{
j=i+d;
if(a[i]>a[j])
{
x=a[i];
a[i]=a[j];
a[j]=x;
bool=0;
}
}
}while(!bool);
}while(d>1)
}
希尔排序平均比较次数与移动次数都是n^1.3,希尔排序的复杂度依赖于所选取的di序列,一般认为是O(nlogn),希尔排序不稳定。
2.1 冒泡排序
void bubblesort(record r[],int n)
{
int i,j,flag;
flag=1;
for(i=1;i<n&&flag==1;i++)
{
flag=0;
for(j=0;j<n-i;j++)
{
if(r[i]>r[i+1])
{
swap(r[i],r[i+1]);
flag=1;
}
}
}
}
冒泡排序的第一个循环n是控制冒泡次数为n-1,若某次冒泡过程没有发生数字移动,则排序成功。第二重循环,冒泡原理,使大的数一直往右冒泡,第一次冒泡最大,依次类推。简单看出来,若排好序,则只需要n-1次比较。若是逆序,则比较次数为n*(n-1)/2,移动次数3*n*(n-1)/2(每次比较后交换需要移动三次)。所以复杂度为O(n^2)。
2.2快速排序
快速排序是一种基于分治策略的一种算法,先选取序列中的一个数r[i]为基准,将数组分为大于等于r[i],小于等于r[i],r[i],然后对左右两个区间继续进行递归选取基准分组。
由算法可以看出,实现快速排序最自然的方法是进行递归实现。
void quicksort(record r[i],int l,int r)
{
int i;
if(r<=1) return ;
i=partition(a,l,r);
quicksort(a,l,i-1);
quicksort(a,i+1,r);
}
int partition(record r[],int l,int r)
{
int i=l,j=r;
record v=a[r];
while(i<j)
{
while(a[i]<v&&i<j) i++;/*小于a[i],须得有i<j这一判定条件,否则当有序时会一直往后跑*/
while(a[j]>v&&i<j) j--;/*大于a[i]*/
swap(a[i],a[j]);
}
swap(a[i],a[r]);
return i;
}
在最坏情况下,每次分化都不对称划分
T(n)={O(1),n<=1;T(n-1)+O(n),n>1},可以解得T(n)=n^2;
当处于最好情况下时,每次划分都是对称均匀的,
T(n)={O(1),n<=1;2T(n/2)+O(n),n>1}
可得T(n)=O(n logn);
也可以理解快速排序是递归实现的,在系统内部需要用一个栈来实现。若每次都分布均匀,则递归树的高度为logn,则复杂度为O(nlogn)。最坏情况下,即刚好有序的情况下,则递归树的高度为n,则复杂度为O(n^n)。所以可以改进,取头、中、尾,取中间值,来做基准。
一般而言,快速排序的复杂度为O(nlogn),优于大部分算法,当递归进行比较慢时,可以转换成非递归形式;当n还很小是,用快速排序不合算,一般n>20,才使用快速排序。快速排序不是一种稳定的排序方法。
3.1 选择排序
选择排序是将序列中最小的数选出来,然后和第一个数交换,然后划去第一个数所在位置,再在剩下数种选最小,放在第二个数的位置,依次进行。
不难看出复杂度为 n*(n-1),不给代码,可以自己实现。
选择排序还有一种树形选择,将所给数组依次排列,作为叶节点,每顺序两个为一组,选出较小者,变成此两个的根节点,依次推导,树的高度易得为logn,所以复杂度为O(nlogn),但实际上所需要的辅助空间和存放中间结果的空间较复杂,难实现。故一般不用来排序,只用来做某些证明。
3.2堆排序
堆是利用完全二叉树的性质,使其具有鲜明的结构特点,r[i]的子节点为r[2*i],r[2*i+1]。成为一个堆的充要条件是,r[i]是其与两个子节点中的最小值(此时成为小根堆),或者最大值(大根堆)。很显然,我们要建堆,首先得把数据按完全二叉树的的结构特点进行树形排列。接下来就是对其调整,使其满足堆的性质,若是自顶向下进行调整,由于无法保证其子树是堆,还得继续往下寻找。由此,我们不难推断出,堆的调整要从其序号最大的其子树接满足堆性质的节点开始,也就是从最后一个子节点是叶子节点的根节点开始进行调整,也就是n/2的那个节点,很容易理解,总共有n个节点,也就是最后一个叶子节点序号为n,便必须从其父亲节点开始,也就是n/2。
void heapadjust(record r[],int i,int n)
{
if(i<=n/2)
{
int min=i;
//比较找出根结点与两个子节点中最小值,记录位置
if(r[i]>r[2*i]&&2*i<=n)
min=2*i;
if(r[min]>r[2*i+1]&&2*i+1<=n)
min=2*i+1;
if(min!=i)
{
swap(r[i],r[min]);
heapadjust(r,min,n);//有交换,继续深入,使子树依旧为堆
}
}
}
void buildheap(record r[],int n)//建堆
{
int i;
for(i=n/2;i>0;i--)//i取n/2的原因使使其子树都是堆
{
heapadjust(r,i,n);
}
}
void heapsort(record r[],int n)//输出逆向序列
{
int i;
for(i=n;i>=1;i--)
{
swap(r[1],r[i]);
heapadjust(r,1,i-1);
}
}
很显然,这是一种不稳定的排序,因为若是中间有与最后一个数相同的数,我们一次取堆首元素后将最后一个元素调到堆顶,无法使其稳定。
分析其复杂度,其树高度是logn,所以为O(nlogn)。也可由递归方程
T(n)={O(1),n<=1;2T(n/2)+O(n),n>1}求解得到,求解方法是由下往上递推。
4.1 归并排序
何为归并,简单说就是把两个有序集合合并成一个有序集合,很显然,只需要遍历两个集合就可以实现这种归并。给我们一组无序的数据。我们这样想,当我们把这组数据一分为二,二分为四,依次进行,直到最后的子集只存在一个数,此时,我们可以两两集合进行归并,这就是我理解的归并排序。很显然,按照对算法的理解,最自然的方法是递归实现。
void merge(record r[],int l,int m,int h)
{
int i,j,k;
//使b[]是r[]中的l到m的正序和m+1到h的倒叙
for(i=m;i<=l;i--) b[i]=r[i];
for(j=m+1;j<=h;j++) b(h-(j-(m+1)))=r[j];
for(k=1;k<=h;k++)
{
if(b[j]>=b[i]) r[k]=b[i++];
else r[k]=b[j--];
}
}
void mergesort(record r[],int l,int h)
{
int m=(l+h)/2;
if(h<=1);
mergesort(r,l,m);
mergesort(r,m+1,h);
merge(r,l,m,h);
}
按照我们对算法的描述与实现,我们发现和堆排序很类似的递归,
T(n)={O(1),n<=1;2T(n/2)+O(n),n>1},由此也可解出,其复杂度为O(nlogn),按照算法实现的过程我们可以看出,这是一个稳定的排序。
5.1 计数排序
我们开一个很大的数组c[],其范围大于待排序数组a的最大值,将待排序数组的值映射到c数组中,即c[a[i]],然后统计映射到同一个位置的个数,c[i]=c[i]+c[i-1],求得c[i]表示a中小于等于i的元素的个数,再通过一次映射取出a[i]所在位置,即有几个小于等于它的数,它就该排在第几个。每次映射一个a[i]出来,就使c[a[i]]--,即剩下待排数组里小于等于a[i]的数少一个了。而对于剩余待排数组里大于a[i]的数,取出小于它的数,不会影响它排序的最终位置,故不做修改。
#define m 10000
void countsort(int a[],int b[],int n)
{
int c[m+1],i;
for(i=0;i<=m;i++) c[i]=0;
for(i=0;i<n;i++) c[a[i]]++;
//计算出c[i]表示小于等于i的数的总数
for(i=1;i<=m;i++) c[i]=c[i]+c[i-1];
for(i=n-1;i>=0;i--)
{
b[c[a[i]]-1]=a[i];
c[a[i]]--;
}
}
从代码我们可以看出,这是一种稳定的排序方法。其复杂度,为O(m+n)。
5.2 桶排序
设m个桶排成一列,将关键字等于i的放入第i个桶,然后从第一个桶开始,顺序输出。
初始化桶和输出都需要O(m),将元素装入桶需要O(n),故复杂度为O(m+n)。在此就不实现了。
5.3基数排序
基数排序,若以三位数为例子,我们先将个位的数排列,再十位,再百位。这样称作LSD(Least Significant Digital)。若是从百位开始,则称为MSD(Most Significant Digital),一般对数字我们采用LSD,因为若采用MSD,则需要将百位的先分割成不同片,然后再每一片区域内再次分割。而采用LSD,则可以分别取出个位,十位,百位的数字,然后进行计数排序。其复杂度为O(d*(r+n)),其中d为位数,n为由几个数,而r为基数,比如对于数字,其只有0-9十种可能,则其基数为10。由于时间仓促,就先不给代码了,等寒假有时间再敲一下。
总结:
对于排序算法,我们根据几种基础的算法,选择排序,冒泡排序,插入排序,计数排序而衍生出其他的排序算法。我们根据不同的要求,不同的限制条件下,尽量选择更优的算法,这就要求我们对算法有一个全面的认知。熟悉每一种算法的原理与实现过程。比如,一个排序算法是不是稳定的,靠死记硬背我觉得意义不大,这个时刻,你需要回想一下原理,实现步骤。而分析复杂度的时候,也不要去背,毫无意义。介绍的排序算法也就这几种,每一种回想一下过程,便能想出其复杂度。算法浩瀚如烟,我们无法了解全面,这就要求我们对基本的思想,实现烂熟于胸,解问题,自然有其新意。赵括言:半部论语可治天下。虽不全对,但也有其深意。
诸君共勉。