Bootstrap

最详细排序解析,七大排序

前言

注:

  1. lgN在这里为log2N简写
  2. 为了方便描述,本文默认用int类型比较,从小到大排序
  3. 本文排序算法以java语言实现
  4. 本文的排序都是比较排序
  5. 比较次数和赋值和交换次数有的排序不好分析,可能不准确

一.冒泡排序

重复的走访过要排序的数列,一次比较两个元素,如果它们的顺序错误就把它们交换过来,走访数列的工作是重复地进行直到没有再需要交换

  1. 初始状态:无序区为R[1…n],有序区为空;
  2. 第i趟排序(i=0,1,2…n-1)开始时,当前无序区和有序区分别为R[0…n-i]和R[n-i+1…n]。对每一对相邻元素进行比较,从开始第一对到结尾的最后一对,如果第一个比第二个大,就交换它们两个,这样在最后的元素应该会是最大的数,使R[1…n-i-1]和R[n-i…n]分别变为记录个数减少1个的新无序区和记录个数增加1个的新有序区;
  3. 循环n-1次,直到排序完成。

算法思想:

  1. 比较相邻的元素。如果第一个比第二个大,就交换他们两个。
  2. 对每一对相邻元素作同样的工作,从开始第一对到结尾的最后一对。这步做完后,最后的元素会是最大的数。
  3. 针对所有的元素重复以上的步骤,除了最后一个。
  4. 持续每次对越来越少的元素重复上面的步骤,直到没有任何一对数字需要比较。

在这里插入图片描述

java实现

public static void  bubbleSort(int[] nums) {
        //外层循环,从数组第一个元素到倒数第二个元素,时间复杂度为N
        for (int i = 0; i <nums.length-1 ; i++) {
            //内层循环,从数组第一个元素到剩余的元素(减去有序区的元素)
            for (int j = 0; j <nums.length-i-1 ; j++) {
                if (nums[j]>nums[j+1]){//相邻元素只要前面的比后面的大,就交换位置
                    int temp =nums[j];
                    nums[j]=nums[j+1];
                    nums[j+1]=temp;
                }
            }
        }
    }

冒泡排序在代码实现上是最简单的,不需要什么思考,两层for循环嵌套,比大小交换。因为冒泡通常的例子都是让大的往后移,对于刚接触排序的人来说看来上面可能认为冒泡排序与选择排序是反向操作,其实冒泡排序也可以把小数向前移,这样能明显的看出冒泡排序和选择的排序的不同,针对无序区的元素,冒泡排序总是不断地交换,而选择排序是先找出最小的元素再做一次交换。

衍生算法,鸡尾酒排序,该排序从左往右找出最大值后,再从右往左,找出最小值,类似鸡尾酒搅拌左右循环,在某些情况下,优于冒泡排序。

二.选择排序

在未排序序列中找到最小元素,放到排序序列的起始位置,然后,再从剩余未排序元素中继续寻找最小元素,然后放到已排序列的末尾,循环直到所有元素均排序完毕。

  1. 初始状态:无序区为R[1…n],有序区为空;
  2. 第i趟排序(i=1,2,3…n-1)开始时,当前有序区和无序区分别为R[1…i-1]和R[i…n]。该趟排序从当前无序区中选出最小的记录R[k],将它与无序区的第1个记录R[i]交换,使R[1…i]和R[i+1…n]分别变为记录个数增加1个的新有序区和记录个数减少1个的新无序区;
  3. 循环n-1次,排序完成。

算法思想:

  1. 在未排序序列中找到最小(大)元素,存放到排序序列的起始位置。
  2. 从剩余未排序元素中继续寻找最小(大)元素,然后放到已排序序列的末尾。
  3. 依次类推,直到所有元素均排序完毕。

在这里插入图片描述

java实现

    public static void  selectionSort(int[] nums){
        int minIndex,temp;
        //外层循环,从无序区第一个元素开始到数组倒数第二个元素,时间复杂度N
        for (int i = 0; i <nums.length ; i++) {
            minIndex=i;//每次外层循环假设无序区第一个元素是最小元素

            //内层循环,从假设的最小元素的后一个位置开始,到数组最后一个元素,时间复杂度N
            for (int j = i+1; j < nums.length; j++) {
                    if (nums[j]<nums[minIndex]){//判断内层循环的元素是否小于假设的最小元素
                        minIndex=j;
                        //如果比最小元素小,标记该元素的位置为新的最小元素的位置,内层循环完毕,会找出无序区的最小值
                    }
            }

            temp=nums[i];
            nums[i]=nums[minIndex];
            nums[minIndex]=temp;//如果比最小元素小,标记该元素的位置为新的最小元素的位置,内层循环完毕,会找出无序区的最小值
        }
    }

选择排序为两层for循环嵌套,内层循环始终去找最小值,放到最前面。交换次数比冒泡排序少很多,所以实际执行效率比冒泡排序快。

衍生算法,双向选择排序(每次循环,同时选出最大值放在末尾,最小值放在前方),可以提高选择效率。

三. 插入排序

对于未排序数据,在已排序序列中从后往前扫描,找到相应位置插入。

  1. 从第一个元素开始,该元素认为已经被排序。
  2. 取出下一个元素,在已排序的元素序列中从后向前扫描。
  3. 如果已排序元素大于新元素,新元素继续比较前一个元素,直到找到已排序的元素小于或者等于新元素的位置。
  4. 将新元素插入到该位置后。
  5. 重复步骤2~4.

在这里插入图片描述

java实现

   public static void  insertionSort(int[] nums){
        int insertIndex,insertElement;
        //外层循环,默认第一个元素有序,从第二个元素开始,时间复杂度N
        for (int i = 1; i <nums.length ; i++) {
            insertIndex=i-1;//插入的位置,默认有序序列的最后一个元素位置
            insertElement=nums[i];//新插入的元素,默认外层循环的元素

            while (insertIndex>=0&&nums[insertIndex]>insertElement){
                nums[insertIndex+1]=nums[insertIndex];//比待插入元素大的元素后移一位
                insertIndex--;//插入位置前移一位
            }
            nums[insertIndex+1]=insertElement;//内层循环结束,把新元素放到插入位置后面
        }
    }

插入排序为两层循环嵌套,时间复杂度O(N2),插入排序的while循环是先比较,移动待插入的位置,循环结束才真正交换数据位置,这里需要注意,常用的for循环嵌套进行插入排序会每次比较都和前面元素交换直到插入到待插入位置,上面的内循环用while寻找待插入位置,把其他元素后移的算法更合理,每次插入只一次进行一次交换。

上面的内循环用while寻找待插入位置,把其他元素后移的算法更合理,每次插入只一次进行一次交换。

四.希尔排序

插入排序的改进版,优先比较距离远的元素,减少交换次数。

  1. 选择一个增量序列t1,t2,…,tk,其中ti>tj,tk=1;
  2. 按增量序列个数k,对序列进行k 趟排序;
  3. 每趟排序,根据对应的增量ti,将待排序列分割成若干长度为m 的子序列,分别对各子表进行直接插入排序。仅增量因子为1 时,整个序列作为一个表来处理,表长度即为整个序列的长度。
    在这里插入图片描述

java实现

   public static void  shellSort(int[] nums) {
       int h=1;//希尔排序是使间隔为h的元素有序
        int temp;
        while (h<nums.length/3){//while循环,扩大h
            h=3*h+1;//这里用3倍作为希尔排序的间隔,是常用的值,加1是为了防止排序的都是3的倍数
        }

        while (h>=1){//while循环让h从大到小插入排序
            //从h位置开始,对整个数组遍历,i为插入元素的位置
            for (int i = h; i < nums.length; i++) {
                //从i开始,前面每隔h位与i进行进行比较,如果a[j]比a[j-h]小则交换
                for (int j = i; j >=h && nums[j]<nums[j-h]; j-=h) {
                    temp=nums[j-h];
                    nums[j-h]=nums[j];
                    nums[j]=temp;
                }
            }
            h=h/3;//更大间隔的插入完成,缩小插入间隔
        }
    }

上面是常用的写法,每次比较,如果前面的更大都会交换,可以优化一下,直接把上面插入算法嵌入内循环,比较的间隔由1改为h,比较的时候只移动插入位置,比较完只需交换一次

java实现

public static void  shellSort(int[] nums) {
       int h=1;//希尔排序是使间隔为h的元素有序
        int insertIndex, insertElement;
        while (h<nums.length/3){//while循环,扩大h
            h=3*h+1;//这里用3倍作为希尔排序的间隔,是常用的值,加1是为了防止排序的都是3的倍数
        }

        while (h>=1){//while循环让h从大到小插入排序
            //从h位置开始,对整个数组遍历,i为插入元素的位置
            for (int i = h; i < nums.length; i++) {
                insertIndex=i-h;//插入的位置,默认前面间隔h的位置
                insertElement=nums[i];//新插入的元素,默认外层循环的最后一个元素
                //内层循环,只要新元素比待插入位置的元素小就继续
                while (insertIndex>=0&&nums[insertIndex]>insertElement){
                    nums[insertIndex+h]=nums[insertIndex];
                    insertIndex-=h;
                }
                nums[insertIndex+h]=insertElement; //内层循环结束,把新元素放到插入位置后面
            }
            h=h/3;//更大间隔的插入完成,缩小插入间隔
        }

    }

希尔排序的时间复杂度与增量序列的选取有关,例如希尔增量时间复杂度为O(N²),而Hibbard增量的希尔排序的时间复杂度为O(N1.5),希尔排序时间复杂度的下界是Nlg2N。希尔排序没有快速排序算法快 O(N(lgN)),因此中等大小规模表现良好,对规模非常大的数据排序不是最优选择。但是比O(N²)复杂度的算法快得多。并且希尔排序非常容易实现,算法代码短而简单。此外,希尔算法在最坏的情况下和平均情况下执行效率相差不是很多,与此同时快速排序在最坏的情况下执行的效率会非常差。

五.堆排序

堆就是完全二叉树,分为最大堆和最小堆,最大堆要求节点的元素都要不小于其孩子(最小堆要求节点的元素都不大于其孩子),对左右孩子的大小关系不做要求,所以处于最大堆的根节点的元素一定是这个堆中的最大值。堆排序算法就是抓住了堆的这一特点,每次都取堆顶的元素,将其放在序列最后面,然后将剩余的元素重新调整为最大堆,依此类推,最终得到排序的序列。

  1. 将初始待排序关键字序列(R1,R2….Rn)构造成最大堆,此堆为初始的无序区;
  2. 将堆顶元素R[1]与最后一个元素R[n]交换,此时得到新的无序区(R1,R2,……Rn-1)和新的有序区(Rn),且满足R[1,2…n-1]<=R[n];
  3. 由于交换后新的堆顶R[1]可能违反堆的性质,因此需要对当前无序区(R1,R2,……Rn-1)调整为新堆,然后再次将R[1]与无序区最后一个元素交换,得到新的无序区(R1,R2….Rn-2)和新的有序区(Rn-1,Rn)。不断重复此过程直到有序区的元素个数为n-1,则整个排序过程完成。

完全二叉树
完全二叉树的特点:叶子结点只能出现在最下层和次下层,且最下层的叶子结点集中在树的左部。需要注意的是,满二叉树肯定是完全二叉树,而完全二叉树不一定是满二叉树。

  • 完全二叉树的第 i 层至多有 2^i - 1 个结点。
  • 完全二叉树的第 i 层至少有 2^(i - 1) 个结点。
  • 完全二叉树的叶子结点只出现在最底层和次底层。
  • 完全二叉树每一层的结点个数都达到了最大值

在这里插入图片描述

java实现

    // 堆排序方法
    public static void heapSort(int[] arr) {
        int n = arr.length;
        // 构建大根堆,
        // 这段代码是构建大根堆的过程,它的循环次数为n/2-1次,是因为在完全二叉树中,叶子节点不需要进行堆化操作,
        // 所以只需要对非叶子节点进行堆化,而非叶子节点的数量为n/2-1个。因此,只需要循环n/2-1次即可完成大根堆的构建。
        // 非叶子节点在一维数组中就是前面 n/2-1
        for (int i = n / 2 - 1; i >= 0; i--) {
            // 从最底层的根节点开始堆化,每次执行完成后,都找出最大值,并放在根节点位置
            // 逐层往上找,循环结束后,第一个元素肯定是最大值
            heapify(arr, n, i);
        }
        // 依次取出堆顶元素,并将余下元素继续堆化,得到有序序列
        for (int i = n - 1; i >= 0; i--) {
            // 第一个for循环已经找出最大值,所以先做交货,把最大值换到最后一个位置
            // 把最大值交换到最后一个位置,下一次循环最后一个位置就不比较了
            swap(arr, 0, i);
            // 继续找出最大值,放在第一个位置
            heapify(arr, i, 0);
        }
    }

    private static void heapify(int[] arr, int heapSize, int i) {
        int largest = i; // 初始化假设最大值为根节点
        int left = 2 * i + 1; // 相对于索引i的左节点索引
        int right = 2 * i + 2; // 相对于索引i的右节点索引
        // 找到左右子节点中的最大值
        if (left < heapSize && arr[left] > arr[largest]) {
            // 如果有左节点,且左节点大于根节点,则记录左节点为最大值
            largest = left;
        }
        if (right < heapSize && arr[right] > arr[largest]) {
            // 如果有右节点,且右节点大于最大值,则记录右节点为最大值
            largest = right;
        }
        // 上面两个if之后,肯定找到最大值
        if (largest != i) {
            // i 是根节点下标
            // 如果最大值不是根节点,则交换根节点与最大值节点,
            // 并递归地对最大值节点进行堆化
            swap(arr, i, largest);
            heapify(arr, heapSize, largest);
        }
    }

    private static void swap(int[] arr, int i, int j) {
        int temp = arr[i];
        arr[i] = arr[j];
        arr[j] = temp;
    }

在上面的代码中, heapSort 函数接受一个整数数组作为输入,并使用堆排序算法对该数组进行排序。该函数首先构建一个大根堆,然后依次取出堆顶元素,得到有序序列。 heapify 函数用于对一个节点进行堆化操作。它接受三个参数:待堆化的数组、数组的大小和要堆化的节点的索引。该函数首先找到左右子节点中的最大值,如果最大值不是根节点,则交换根节点与最大值节点,并递归地对最大值节点进行堆化。 swap 函数用于交换数组中的两个元素。

六.归并排序

递归的把已有序列均分为两个子序列,使子序列有序,合并子序列

  1. 把长度为n的输入序列分成两个长度为n/2的子序列;
  2. 对这两个子序列分别采用归并排序;
  3. 对这两个子序列分别采用归并排序;

在这里插入图片描述

java实现

private static int[] aux;//归并所需的辅助数组
    public static void  mergeSort(int[] nums) {
            aux=new int[nums.length];
            sort(nums,0,nums.length-1);//开始递归排序
    }

    /**
     * 递归的排序归并
     */
    public static void  sort(int[] nums,int left,int right) {
        if (right<=left){//排序从左到右,确保右边比左边大
            return;
        }

        int mid=(left+right)/2;//找出中间位置
        sort(nums,left,mid);//将左半边排序
        sort(nums,mid+1 ,right);//将右半边排序
        merge(nums,left,mid,right);//归并结果
    }
    /**
     * 原地归并方法
     */
    public static void  merge(int[] a,int left,int mid,int right) {//将a[left..mid]和a[mid+1..right]归并
        int i=left,j=mid+1;//左右半边起始位置
        for (int k = left; k <=right ; k++) {//将a[left..right]复制到aux[left..right]
            aux[k]=a[k];
        }

        for (int k = left; k <=right ; k++) {//归并回到a[left..right]
            if (i>mid){ //i比mid大代表左半边用完,只有右半边有元素
                a[k]=aux[j++];//右边元素给a[k]
            }else if (j>right){//j比right大代表右半边用完,只有左半边有元素
                a[k]=aux[i++];//左边元素给a[k]
            }else if (aux[j]>aux[i]){//如果右边元素大于左边
                a[k]=aux[j++];//右边元素给a[k]
            }else {//否则左边大于等于右边
                a[k]=aux[i++];//左边元素给a[k]
            }
        }

    }

归并排序是分治法的典型应用,高效稳定,但是归并排序需要一个数组长度的辅助空间,在空间成本高的时候不适合使用。

七.快速排序

通过一趟排序将待排记录分隔成独立的两部分,其中一部分记录的关键字均比另一部分的关键字小,则可分别对这两部分记录继续进行排序,以达到整个序列有序。

  1. 从数列中挑出一个元素,称为 “基准”(pivot);
  2. 重新排序数列,所有元素比基准值小的摆放在基准前面,所有元素比基准值大的摆在基准的后面(相同的数可以到任一边)。在这个分区退出之后,该基准就处于数列的中间位置。这个称为分区(partition)操作;
  3. 递归地(recursive)把小于基准值元素的子数列和大于基准值元素的子数列排序。
    在这里插入图片描述

java实现

 public static void  quickSort(int[] nums) {
        sort(nums,0,nums.length-1);//开始递归排序
    }
 /**
     * 递归进行快速排序
     */
    public static void  sort(int[] nums,int left,int right) {
        if (right<=left){//排序从左到右,确保右边比左边大
            return;
        }

        int j=partition(nums,left,right);//切分
        sort(nums,left,j-1);//将左半边排序
        sort(nums,j+1 ,right);//将右半边排序
    }

public static int  partition(int[] nums,int left,int right) {
        int i=left,j=right+1;//左右扫描指针
        int v=nums[left];//选取切分元素,这里选第一个,实际数据可以自行挑选
        while (true){
            while (nums[++i]<v){//num[i]<v时增加i,只有比v小继续往右扫描
                if (i==right){//扫描到右边则终止
                    break;
                }
            }

            while (v<nums[--j]){//num[j]>v时减少j,只要比v大继续往左扫描
                if (j==left){//扫描到左边则终止
                    break;
                }
            }

            if(i>=j){//如果左右指针交叉,终止循环
                break;
            }
            exch(nums,i,j);//不满足上述条件(左边比v大,右边比v小,指针未交叉),左右元素交换位置
        }
        exch(nums,left,j);//将切分元素v放入正确的位置
        return j;//a[left..j-1]<=a[j]<=a[j+1..right],并返回j
    }

    /**
         * 交换两个元素
         */
    public static void  exch(int[] nums,int i,int j) {
        int temp=nums[i];
        nums[i]=nums[j];
        nums[j]=temp;
    }

快速排序是通常情况下的最优选择,高效只需要lgN级别的辅助空间,但是快速排序不稳定,受切分点的影响很大

七种排序总结

上面详细介绍了七种排序的实现细节和特点,下面的表格总结了七种排序的各种特征。
在这里插入图片描述

其中插入排序,选择排序,冒泡排序都是简单排序,时间复杂度是O(N2),其中插入排序和冒泡排序适合原始序列有序的数组,选择排序的交换和赋值次数会比较少,可以根据不同环境和数据的实际情况和长度选择具体的排序。整体插入排序优于选择排序优于冒泡排序。希尔排序是插入排序的优化,突破了前三个排序O(N2)的时间复杂度,但是本质还是插入排序,突破比较相邻元素的惯性思维,直接比较一定间隔的元素,大幅度减少了逆序调整的比较次数和交换次数,从而达到比较理想的算法复杂度,适合对中等规模数组进行排序。堆排序是利用了最大堆的特点,始终把堆顶元素(最大元素)和最后一个元素替换,再重新构造最大堆,重复执行达到排序的效果。堆结构的特性让算法的复杂度降低到NlgN级别,但是有不方便索引元素的确定,缓存命中率较低。而归并排序则是充分运用了分治原理,把大问题不断的拆分成小问题,进行排序,再合并小数组达到整体排序的目标,归并排序即高效又可靠,唯一的缺点是需要数组长度的辅助空间,在空间成本低的时候适合使用。快速排序则解决了归并排序占用空间的问题,在数组内部用很小的辅助栈,即可完成对元素的分离,再去解决分离后的更小的数组,正常情况下拥有和归并相同级别的时间复杂度,但是得注意选取好切分元素。
实际上一个复杂的序列可能用不止一种排序,例如分治和快速排序在分割到很小的序列时再进行分割反而效率不如插入排序等简单排序,可以设置一定的阈值,先用分治或者快速排序的方式分割数组,再转换成插入等简单排序完成最终的排序。

;