插入、选择、冒泡、希尔、归并排序算法-算法学习笔记(一)

 

1.前言

掐指一算,自己上一次学习排序算法已经是一年以前的事情了,本来当初上课就没有认真听,加之后面也没怎么用,导致现在提到排序算法,自己能写出代码的只有最简单的插入选择和冒泡了,什么快排合并堆之类的忘得一干二净,于是乎打算重新拾起算法导论这本书,给自己复习一遍常见的几种排序方法,写下这篇学习笔记,加深自己的印象。

2.几种常见的排序算法

首先提到一点知识:原址排序。原址排序指的是:(1)算法在数组A中重排这些数,在任何时候,最多只有其中的常数个数字存储在数组外面(算法导论第三版)(2)除了函数调用所需要的栈和固定数目的实例变量之外无需额外的内存(算法第四版)。接下来讲的算法我们都将分析其是否为原址排序

排序算法的稳定性 :假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,r[i]=r[j],且r[i]在r[j]之前,而在排序后的序列中,r[i]仍在r[j]之前,则称这种排序算法是稳定的;否则称为不稳定的。

2.1选择排序

选择排序可以说是简单的一种算法,其思想也很容易理解,找到数组中最小的那个元素,与数组的第一个元素进行交换,然后在剩下的元素中找到最小的元素,将它和数组的第二个元素进行交换,如此往复,直到将整个数组都进行了排序。选择排序,顾名思义,就是选择了最小的元素选择了特定的位置。

java的实现代码如下:

import java.util.Scanner;

public class SelectSort {
    public static void main(String[] args) {
        Scanner scanner = new Scanner(System.in);
        int n = scanner.nextInt() ;
        int[]a = new int[n];
        for( int i = 0 ; i < a.length ; i++ ){
            a[i] = scanner.nextInt();
        }
        int minIndex , temp ;
        //升序排序
        for ( int i = 0 ; i <a.length -1 ; i++ ){
            minIndex = i ; //数组最小元素索引
            for ( int j = i + 1 ; j <a.length ; j++ ){
                if( a[j] < a[minIndex]){
                    minIndex = j; //更新数组最小元素
                }
            }
            //交换
            temp = a[i] ;
            a[i] = a[minIndex];
            a[minIndex] = temp ;
        }
        for( int i = 0 ; i < a.length ; i++){
            System.out.print(a[i]+" ");
        }

    }
}

运行结果:

选择排序由于每一次交换都能够排定一个元素,所以交换的总长度是N,而比较次数则是(N-1)+ (N-2)+……+1 = N(N-1)/2次,由此可见选择排序的交换次数是根据数组长度线性增长的,且无论输入的数组是否有序,排序算法的运行时间都是与其无关的。当然,选择排序明显是一个原址排序。

2.2插入排序

讲到插入排序的算法思想,都喜欢那打牌做类比。我们现在想象一个场景,你正在打牌,桌上还没有摸到手中的便是你还需要进行排序的数 ,而你手中的牌已经是排好序的。这时候我们从桌上摸起一张牌,需要从右到左依次比较扑克牌大小,直到找到合适的位置,将这张牌插入进去。直到桌上的扑克牌摸完为止,排序结束。

java代码实现如下:

import java.util.Scanner;

public class InsertSort {
    public static void main(String[] args){
        Scanner scanner = new Scanner(System.in);
        int n = scanner.nextInt() ;
        int[]a = new int[n];
        for( int i = 0 ; i < a.length ; i++ ){
            a[i] = scanner.nextInt();
        }
        //升序排列
        for( int i = 1 ; i < a.length ; i ++ ){
            int temp = a[i];
            int j ;
            for(j = i- 1 ; j >= 0 ; j -- ){
                if ( a[j] > temp ){
                    a[j+1]=a[j]; //元素向右移动一位
                }
                else{
                    break;
                }
            }
            a[j+1] = temp ;
        }
        for( int i = 0 ; i < a.length ; i ++ ){
            System.out.print(a[i]+" ");
        }
    }
}

运行结果:

现同样来对插入排序进行分析,与选择排序不同的是,插入排序的时间复杂度与其输入相关:

 (1) 最好:输入的数组原本有序,那么只需要进行N-1次比较,进行0次交换。

 (2) 最差:输入的数组倒叙,那么需要进行N(N-1)/2次比较 ,N(N-1)/2次交换。

插入排序在处理部分有序的数组时候很有效,当倒置的数量很少时,插入排序甚至比其他提到的排序算法都要快。当然插入排序也是原址排序,也是稳定的排序。

2.3冒泡排序

冒泡排序这个名字本身就很形象,该算法的思想将相邻的两个数两两进行比较,将较大的数置于后面,这样经过一轮循环之后,最大的数则浮到了水面(数组的尾部)。以前在学习冒泡排序的时候,老是记不住循环里面的条件要怎么写,其实我们只需要知道,该算法的两层循环,最外层拿来控制有多少轮冒泡(每轮冒泡获得一个当前最大的数并将其放在尾部,因此需要N-1轮),而内层循环则是拿来控制元素的两两比较,注意已经冒泡完的数要去掉。

java代码实现如下 :

import java.util.Scanner;

public class BubbleSort {
    public static void main ( String[] args){
        Scanner scanner = new Scanner(System.in);
        int n = scanner.nextInt() ;
        int[]a = new int[n];
        for( int i = 0 ; i < a.length ; i++ ){
            a[i] = scanner.nextInt();
        }
        //升序排序
        for( int i = 0 ; i < a.length -1 ; i++ ){ //进行N-1轮循环
            for( int j = 0 ; j < a.length - 1 - i ; j++ ){ //每轮进行N-1-i次比较(因为有i个数已经完成排序了)
                if( a[j] > a[j+1]){
                    int temp = a[j] ;
                    a[j] = a[j+1];
                    a[j+1] = temp;
                }
            }
        }
        for( int i = 0 ; i < a.length ; i ++ ){
            System.out.print(a[i]+" ");
        }

    }

}

显而易见,该算法最好的情况便是数组原本有序,此时的时间复杂度为O(n) (要达到此复杂度需要在代码上进行改进,加上一个bool类型进行判断是否发生了交换,如果未发生交换,直接break循环,否则就上面的代码时间复杂度仍然为O(n^2));最差情况即为数组逆序 ,此时的时间复杂度为O(n^2),该排序同时也是原址排序和稳定的。

2.4希尔排序 

希尔排序就不像之前的那几个算法,在现实生活中有比较直观的解释了。该算法是利用插入排序的最佳时间代价特性,将待排序的数组变成基本有序,然后利用插入排序完成最后的排序工作。该排序也被称为缩小增量排序,通过某个增量将数组元素划分为若干组,然后分组进行插入排序,随后逐步缩小增量,继续按组进行插入排序操作,直至增量为1(通常这个最开始的增量我们选取为N/2,且增量每次都是通过除以2来实现缩增量)。

java代码实现如下:

import java.util.Scanner;

public class ShellSort {
    public static void main(String[] args){
        Scanner scanner = new Scanner(System.in);
        int n = scanner.nextInt() ;
        int[]a = new int[n];
        for( int i = 0 ; i < a.length ; i++ ){
            a[i] = scanner.nextInt();
        }
        System.out.println(n/2);
        //升序排列
        for( int gap = a.length/2 ; gap>=1 ; gap=gap/2){
           for( int j =gap ; j < a.length ; j++ ){ //对每个元素在其所在的组进行插入排序
               int temp = a[j] ;
               int k ;
               for(  k  = j - gap ; k>= 0 ; k= k -gap ){
                    if( a[k] > temp){
                        a[k + gap ] = a[k] ;
                    }
                    else
                        break;
               }
               a[k + gap ] = temp ;
           }
        }

        for( int i = 0 ; i < a.length ; i ++ ){
            System.out.print(a[i]+" ");
        }
    }

}

运行结果

排序示意图:

第一步:gap = 5/2=2(注意向下取整)

53214
21435

 

第二步:gap = 2/2 = 1(最后一步)

21435
12345

对Shell排序进行时间复杂度分析是一件很困难的事情,Shell排序的时间复杂度与其增量的序列选择有很大的关系,通常我们 不做证明的认为其平均时间复杂度为O(n^(3/2)同时Shell算法是一个原址但是不稳定的排序算法。

还需要提到的一点,上面贴的代码选取的增量数列,对于形如

1  9  2  10  3  11  4 12  5 13  6 14  7 15  8 16 

可以看到该数组直到最后增量序列变为 1的时候,才会发生插入排序。我们可以采用Hibbard{1, 3, ..., 2^k-1},Sedgewick:{1, 5, 19, 41, 109...}等其他增量序列。

2.5归并排序

归并排序是一种建立在归并操作上的递归排序算法,该算法是分治算法的典型应用。分治算法可以理解为分而治之,通俗得来讲,分治算法就是将一个大问题分解成很多个小问题,然后通过解决小问题最后解决掉大问题。对于数组的排序而言,即是可以递归的地将其分为两半进行排序,最后将结果归并起来。

实现这个算法我们首先考虑的问题是,对于两个已经排序好的数组我们如何将其归并为一个数组。为了避免递归频繁的开辟数组空间,我们从算法的一开始便创建一个与原数组等长的temp数组作为辅助数组。

先贴上这一小部分的代码

public class MergeSort {
    public static void merge( int left , int right , int mid , int a[] , int temp []){
        int i = left;
        int j = mid+1 ;
        int k =0;
        while( i <= mid && j<=right ){
            if( a[i] <=  a[j] ){
                temp[ left+ k ] = a[i] ;
                i++ ;
            }
            else{
                temp [ left + k ] = a[j];
                j++ ;
            }
            k++ ;
        }
        while( i <= mid){
            temp[ left + k ] = a[i++];
            k++;
        }
        while( j <= right ){
            temp[left + k ] = a[j++];
            k++ ;
        }
        while( left <= right ){
            a[left] = temp [left] ;
            left++;
        }
    }
    public static void mergeSort( int left ,int right , int mid , int a[] , int b[]){

    }
    public static void main( String[] args){
        int a[]={1 ,3 ,5 ,7 ,9 ,2 ,4 ,6 ,8 ,10};
        int b[] = new int[10];
        merge(0 , 9 , 4 , a ,b );
        for( int i = 0 ; i < 10 ; i++ ){
            System.out.print(b[i]+" ");
        }
    }
}

再讲讲思路:

首先数组a是原本需要进行排序的数组,它以mid为界限,分为左右两个部分,左右两个部分的数组都是升序排列的;然后temp数组是我们排序储值的辅助数组;为了使代码具有扩展性,我们假设数组a可以是一个大数组中的一小部分,left即为数组a最左边元素的下表,right为数组a最右边元素的下标。我们在用两个“指针”i和j分别指向a数组左右两部分的起始位置,并进行比较,将较小的数依次放入temp数组当中;这里循环结束会出现两种情况(1)左半部分的元素全部排列完毕,那么将右半部分的元素按顺序全部填充到temp数组当中 (2)右半部分的元素全部排列完毕,那么将左半部分的元素按顺序全部填充到temp数组当中;最后再利用temp数组修改原数组。

然后我们需要考虑如何实现分治思想,常见的归并排序有两种实现方法(1)自顶向下 (2)从下往上;我们将分别实现一遍

归并排序自顶向下递归实现:

 public static void mergeSort( int left ,int right , int mid , int a[] , int b[]){
        if(left < right ){
            mergeSort( left , mid  , (left+mid )/ 2 , a , b ); //对左半部分排序
            mergeSort( mid+1 , right , (right+mid+1)/2 , a ,b ); //右半部分排序
            merge( left , right ,mid ,a , b );//合并两个部分
        }
    }
    public static void main( String[] args){
      //  int a[]={1 ,3 ,5 ,7 ,9 ,2 ,4 ,6 ,8 ,10};
        int a[]={10 , 9 ,8 , 7 , 6 , 5 , 4 , 3 , 2, 1};
        int b[] = new int[10];
        mergeSort(0 , 9 , 4 , a ,b );
        for( int i = 0 ; i < 10 ; i++ ){
            System.out.print(b[i]+" ");
        }
    }
}

运行结果

递归的思路就是不停的将数组拆分为两部分,将这两部分进行排序,最后再合并这两部分,左半部分数组的下表是从left到mid的,右半部分下标是从mid+1到left的。

从下往上的实现方法:

 for ( int sz = 1 ; sz < a.length ; sz*=2){
            for( int i = 0 ; i<a.length-sz ; i+=2*sz){
                merge( i , Math.min(i+ 2*sz -1 , a.length-1) , i+sz-1 , a , b );
            }
        }

 从下往上的归并排序相对于自顶向下的方法来说,存在几个小陷阱,容易出错;我写第一遍的时候没能获得正确结果,然后通过debug才找到问题所在。首先从下往上,子数组的起始长度为1,且每合并一次后,长度翻倍直到大于总数组长度,这是第一个循环的判断条件,然后第二个循环则是实现子数组的合并,由于我们是从长度为1的数组开始合并的,所以参加合并的两个数组已经是满足了有序的条件,直接调用之前的merge方法即可,其中陷阱就出现在该函数的参数上:其中left很明显就是i,且i每次以两个数组长度递增,right则需要进行判断,最后一个数组是否长度也为sz(考虑数组越界的问题),而由于存在数组的长度可能小于sz,意思就是说参与归并的两个数组长度不一定相等,所以这里的mid不在用(left+right)/2了,由于我们知道第一个数组长度一定是等于sz的,所以我们选择mid=right+sz-1来表示。

对于归并排序,每一次归并的时间复杂度为O(n);一共需要进行log(n)次归并,所以归并排序的时间复杂度在最差最好的情况下都是O(nLog(n));该算法同时也是稳定的算法。


不华丽的分割线……

这一次的算法学习笔记先更新到这里 ,还剩下快速排序,堆排序,以及基数排序将会以后的博客中更新,未完待续……

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值