排序算法

时间复杂度O(n^2)的排序

1.冒泡排序

冒泡排序通过两两比较相邻记录的关键字,反序则交换,从而达到排序的效果

for(int i=0; i<o.length; i++){
    for(int j=0; j<o.length-i-1; j++){
        if(o[j]>o[j+1]){
            swap(o,j,j+1);
        }
    }
}

上面的代码有个小问题,设想存在待排序序列{1,3,5,7,11,9},经过第一遍遍历交换11和9之后已经达到排序效果,接下来的遍历已无意义。考虑增加一个标志位,若上一轮遍历没有引起序列任何变化,则退出整个遍历,不再做无意义的比较。

boolean flag = true;
for(int i=0; i<o.length&&flag; i++){
    flag = false;
    for(int j=0; j<o.length-i-1; j++){
        if(o[j]>o[j+1]){
            swap(o,j,j+1);
            flag = true;
        }
    }
}

冒泡排序还有几种变形,或是向上冒泡、向下冒泡,但都差别不大。分析时间复杂度:最好情况下无需交换,只要进行n-1次比较,时间复杂度O(n);最坏情况下,即待排序序列为逆序,需要比较n*(n-1)/2次,并作等数量级的交换,时间复杂度O(n^2)。

2.简单选择排序

与冒泡排序区别的是,冒泡排序只要发现反序就会交换相邻的两个数,而简单选择排序是先找出最小或最大的数,与特定位置交换,可以看出其优点是交换移动次数相当少。

int min;
for(int i=0; i<o.length-1; i++){
    min = i;
    for(int j=i+1; j<o.length; j++){
        if(o[min]>o[j]){
            min = j;
        }
    }
    if(min != i){
        swap(o,min,i);
    }
}

分析简单选择排序的复杂度,无论最好最坏情况下,比较次数都是n*(n-1)/2,但最好情况下交换0次,最坏情况也只有n-1次,总的复杂度仍是O(n^2)。

3.直接插入排序

直接插入排序的思想是向一个有序序列逐步插入记录,得到记录数不断增加的序列,若初始为乱序序列,则选取第一个元素作为一个有序序列,其他元素依次插入这个有序序列,从而达到排序效果。

int j,temp;
for(int i=1; i<o.length; i++){
    if(o[i] < o[i-1]){
        temp = o[i];
        for(j=i-1; j>=0&&o[j]>temp; j--){
            o[j+1] = o[j];
        }
        o[j+1] = temp;
    }
}

直接插入与理扑克牌有点相似,最好情况下需要比较n-1次,无需交换;最坏情况下比较(n+2)(n-1)/2次,移动(n+4)(n-1)/2次。

时间复杂度O(n^3/2)的排序

1.希尔排序

希尔排序是基于直接插入排序的,直接插入排序在元素较少和元素基本有序时效率是不错的,但随着元素增多和有序性破坏,效率会下降的很明显。希尔排序通过分组实现跳跃式移动,保证待排序序列基本有序后再排序,就能提高效率。

int j,temp;
int increment = o.length;
do{
    increment = increment/3+1;
    for(int i=increment; i<o.length; i++){
        temp = o[i];
        for(j=i-increment; j>=0&&o[j]>temp; j-=increment){
            o[j+increment] = o[j];
        }
        o[j+increment] = temp;
    }
    
}while(increment>1);

希尔排序的关键在于增量increment的选择,最坏情况下,可以取得时间复杂度O(n^3/2)的算法。

时间复杂度O(nlgn)的排序

1.堆排序

堆是特殊的完全二叉树,每个节点的值都大于等于(小于等于)其左右孩子节点的值。堆排序其实类似简单选择排序,每次找出最大最小元素,移到特定位置完成排序。堆排序相对于简单选择排序的优点是,之前排序的结果得以保存,能被后面的排序利用。

//堆排序
for(int i=o.length/2-1; i>=0; i--){
    //从最后一个有子节点的节点开始依次往前调整对应节点来生成大顶堆
    heapAdjust(o,i,o.length-1);
}

for(int i=0; i<o.length-1; i++){
    //交换堆顶元素与未排序堆最后一个元素
    swap(o,0,o.length-i-1);
    //根据调整节点重新生成大顶堆
    heapAdjust(o,0,o.length-2-i);
}
//构建大顶堆
void heapAdjust(int[] o,int i,int j){
    for(int m=i*2+1; m<=j; m=2*i+1){
        if(m<j&&o[m]<o[m+1]){
            m ++;
        }
        if(o[m]>o[i]){
            swap(o,m,i);
        }
        i = m;
    }
}

堆排序的时间复杂度主要取决于构建堆和重建堆两部分,构建堆的时间复杂度O(n^2),重建堆的时间复杂度为O(nlgn),总的时间复杂度为O(nlgn)。

2.归并排序

归并排序的思想是逐层将序列子元素两两归并成有序序列,每个子序列长度经历1、2、4、...、n,最后得到完整有序序列。

int[] sort(int[] o,int m,int n){
    int mid;
    int[] result = new int[o.length];
    if(o.length == 1|| m==n){
        result[0] = o[0];
    }else{
        mid = (m+n)/2;
        int[] temp1 = new int[mid-m+1];
        int[] temp2 = new int[o.length-mid+m-1];
        System.arraycopy(o,0,temp1,0,mid-m+1);
        System.arraycopy(o,mid-m+1,temp2,0,o.length-mid+m-1);
        int[] result1 = sort(temp1,m,mid);
        int[] result2 = sort(temp2,mid+1,n);
        result = Merge(result1,result2);
    }
    return result;
}

int[] Merge(int[] i,int[] j){
    int m=0,n=0,k=0;
    int[] result = new int[i.length+j.length];
    
    for(; m<i.length&&n<j.length; k++){
        if(i[m]<j[n]){
            result[k] = i[m++];
        }else{
            result[k] = j[n++];
        }
    }
    
    if(m<i.length){
        while(m<i.length){
            result[k++] = i[m++];
        }
    }
    
    if(n<j.length){
        while(n<j.length){
            result[k++] = j[n++];
        }
    }
    return result;
}

上面的算法用了大量临时数组,是一种不好的设计,其实只需要一个与原数组大小相同的临时数组(必须有,如果只有数组本身,那么排序时可能覆盖原有元素),就能完成归并排序。

void sort(int[] o, int lo, int high){
    if(hi <= lo) return;
    int mid = lo+(high-lo)/2;
    sort(o,lo,mid);
    sort(o,mid+1,hi);
    merge(o,lo,mid,hi);
}

void merge(int[] o,int lo,int mid,int high){
    int i = lo,j = mid+1;
    int[] aux = new int[hi-lo+1];
    for(int k=lo; k<=hi, k++)
        if(i>mid) a[k] = aux[j++];
        else if(j>hi) a[k] = aux[i++];
        else if(aux[j]<aux[i]) a[k] = aux[j++];
        else a[k] = aux[i++];
}

归并排序需要扫描序列中所有记录,耗费O(n)时间,整个归并排序需要logn,因此,总的时间复杂度为O(nlgn),空间复杂度为O(n+logn)。

归并排序可以从几个方面优化:对小规模子数组使用插入排序;测试数组是否已经有序,有序就可以跳过merge方法;不将元素复制到辅助数组(复制改为排序)。

3.快速排序

快速排序的思想是分割,确保一个元素左边的元素都小于这个元素,右边的元素都大于这个元素,然后对这两部分分别继续进行分割,从而达到排序的效果。

void sort(int[] i,int low,int high){
    int temp;
    if(low<high){
        temp = partition(i,low,high);
        sort(i,low,temp-1);
        sort(i,temp+1,high);
    }
}

int partition(int[] i,int low,int high){
    int privotkey = i[low];
    while(low<high){
        while(low<high&&i[high]>=privotkey)
            high --;
        swap(i,low,high);
        while(low<high&&i[low]<=privotkey)
            low ++;
        swap(i,low,high);
    }
    return low;
}

快速排序的时间复杂度最优和平均都是O(nlgn),最坏情况下为O(n^2),空间复杂度为O(lgn),快速排序很脆弱,有一些优化手段,比如转插入等;希尔排序效率高,代码简单,不需要额外空间,较为常用;

总结

直接插入排序在序列基本有序时效率较高,对于随机排序序列,插入排序和选择排序的运行时间是平方级别的,两者之比应该是一个较小的常数。

排序算法 平均情况 最好情况 最坏情况 辅助空间 稳定性
冒泡排序 O(n^2) O(n) O(n^2) O(1) 稳定
简单选择排序 O(n^2) O(n^2) O(n^2) O(1) 稳定
直接插入排序 O(n^2) O(n) O(n^2) O(1) 稳定
希尔排序 O(nlogn)~O(n^2) O(n^1.3) O(n^2) O(1) 不稳定
堆排序 O(nlogn) O(nlogn) O(nlogn) O(1) 不稳定
归并排序 O(nlogn) O(nlogn) O(nlogn) O(n) 稳定
快速排序 O(nlogn) O(nlogn) O(n^2) O(logn)~O(n) 不稳定

参考:《大话数据结构》、《算法》。


只吃全麦面包的人
28 声望2 粉丝