初阶数据结构(9)(排序的概念、常见的排序算法【直接插入排序,希尔排序,选择排序,堆排序,冒泡排序,快速排序和归并排序】、排序算法复杂度及稳定性分析、其他比较排序【计数排序、基数排序、桶排序】)

接上次博客:初阶数据结构(8)(优先级队列的模拟实现:堆的概念、性质、存储、创建——向下和向上调整、插入与删除、PriorityQueue常用接口介绍、构造、常见方法、扩容、top-K问题、堆的排序、对象的比较)_di-Dora的博客-CSDN博客

目录

排序的概念及引用

排序的概念

常见的排序算法

 常见排序算法的实现

 插入排序

直接插入排序

希尔排序( 缩小增量排序 )

选择排序

直接选择排序

堆排序

交换排序

冒泡排序

快速排序

1、Hoare版

2. 挖坑法

3. 前后指针

快速排序优化

快速排序非递归

归并排序

基本思想

 归并算法的非递归实现

归并排序特点总结 :

海量数据的排序问题

排序算法复杂度及稳定性分析

其他非基于比较排序(了解)

1、计数排序

2、基数排序

3、桶排序


排序的概念及引用

排序的概念

排序:排序是一种操作,用于将一串记录按照某个或某些关键字的大小进行排列,可以是递增或递减的顺序。排序在计算机科学和数据处理中是一个常见的问题,它有着广泛的应用。排序的目的是使数据具有一定的有序性,以便于后续的查找、插入、删除等操作。

排序算法根据其实现方式和性质的不同可以分为多种类型,常见的排序算法包括插入排序、选择排序、冒泡排序、归并排序、快速排序、堆排序等等。这些算法在时间复杂度、空间复杂度、稳定性以及适用场景等方面有所差异。

稳定性:稳定性是排序算法的一个重要性质。当待排序的记录序列中存在多个具有相同关键字的记录时,稳定的排序算法会保持它们在排序后的相对次序不变。也就是说,如果在原序列中r[i]=r[j],且r[i]在r[j]之前,那么在排序后的序列中,r[i]仍然会在r[j]之前。相反,如果排序算法不保持相同关键字记录的相对次序不变,则被称为不稳定的排序算法。 

内部排序:内部排序是指所有待排序的数据元素能够全部放在内存中进行排序的情况。在内部排序过程中,排序算法可以直接访问内存中的数据,因此其执行速度相对较快。常见的排序算法大多数适用于内部排序。

外部排序:外部排序是指数据元素太多,无法同时放入内存,需要通过在内外存之间移动数据来完成排序的情况。外部排序需要使用一些特殊的算法和技巧来处理数据的分块读取、归并等操作,以实现高效的排序。外部排序通常用于处理大规模数据集,如大型文件或数据库。

常见的排序算法

常见的排序算法有:直接插入排序,希尔排序,选择排序,堆排序,冒泡排序,快速排序和归并排序,接下来,我们将一一介绍。

 常见排序算法的实现

 插入排序

插入排序是一种简单而直观的排序算法,其基本思想是将待排序的记录逐个插入到已经排好序的序列中,直到所有记录都插入完毕,形成一个有序序列。

具体步骤如下:
1. 假设初始时,第一个记录被认为是已经排好序的序列。
2. 取出下一个记录,将其插入到已排序序列中的适当位置,使得插入后的序列仍然有序。
3. 重复步骤2,直到所有的记录都被插入到有序序列中。

在每次插入过程中,将待插入记录与已排序序列中的记录逐个比较,并找到合适的位置插入。为了将待插入记录插入到正确的位置,需要不断地将比待插入记录大的记录后移,为其腾出位置。这个过程就像是在玩扑克牌时,将新抽到的牌插入到已经按照大小排好序的牌中的正确位置。

 插入排序的特点是,它是一种稳定的排序算法,相同关键字的记录在排序前后的相对顺序保持不变。此外,插入排序是原地排序算法,不需要额外的空间存储数据。

对于较大规模的数据集,插入排序的性能可能不如其他更高效的排序算法,如快速排序或归并排序。但在数据量较小或已经部分有序的情况下,插入排序表现出良好的性能,并且实现简单、易于理解。

直接插入排序

直接插入排序(Straight Insertion Sort)是插入排序的一种常见实现方法。在直接插入排序中,待排序的记录逐个插入到已经排好序的序列中,通过比较找到合适的位置插入,并将后面的元素依次后移。

具体步骤如下:
1. 假设初始时,第一个记录array[0]被认为是已经排好序的序列。
2. 从待排序序列中取出下一个元素array[i],将其与已排序序列进行比较。
3. 将array[i]与已排序序列中的元素array[j]逐个比较,直到找到插入位置或者遍历完已排序序列。
4. 将待插入元素array[i]插入到已排序序列的合适位置,将后面的元素依次后移,腾出插入位置。
5. 重复步骤2~4,直到所有元素都插入到有序序列中。

在每次插入过程中,将待插入元素与已排序序列中的元素逐个比较(当插入第i(i>=1)个元素时,前面的array[0],array[1],…,array[i-1]已经排好序,此时用array[i]的排序码与array[i-1],array[i-2],…的排序码顺序进行比较),如果待插入元素比当前元素小(或大,取决于排序顺序),则将当前元素后移,为待插入元素腾出插入位置。最终,待排序序列中的所有元素都会被插入到合适的位置,形成一个有序序列。

    /**
     * 时间复杂度:
     *      最好情况:数据完全有序的时候 1 2 3 4 5 :O(N)
     *      最坏情况:数据完全逆序的时候 5 4 3 2 1 :O(N^2)
     *  结论:当所给的数据 越有序 排序 越快。
     *  场景:现在有一组基本有序的数据,那么你用哪个排序好点?
     *
     * 空间复杂度:O(1)
     * 稳定性:稳定的排序
     *    一个本身就是稳定的排序  是可以实现为不稳定的排序的
     *    但是相反 一个本身就不稳定的排序  是不可能实现为稳定的排序的
     */   

 public static void insertSort(int[] array) {
        for (int i = 1; i < array.length; i++) {
            int tmp = array[i];
            int j = i-1;
            for (; j >= 0 ; j--) {
                if(array[j] > tmp) {
                    array[j+1] = array[j];
                }else {
                    //array[j+1] = tmp;
                    break;
                }
            }
            array[j+1] = tmp;
        }
    }

 直接插入排序的特性总结:

1. 元素集合越接近有序,直接插入排序算法的时间效率越高

2. 时间复杂度:O(N^2),其中N是待排序序列的长度。当待排序序列已经近乎有序时,直接插入排序的性能会更好,接近O(N)的线性时间复杂度。

也就是说,时间复杂度:

  • 最好情况:数据完全有序的时候 1 2 3 4 5 :O(N)
  • 最坏情况:数据完全逆序的时候 5 4 3 2 1 :O(N^2)

 结论:当所给的数据越有序、排序越快。

所以问:现在有一组基本有序的数据,那么你用哪个排序好点?——当然是直接插入法。

当然,我们这里也可以借助代码运算出结果的时间来做一个简单的参考、对比:

import java.util.Arrays;
import java.util.Random;


    public class Test {

        //有序,从小到大
        public static void orderArray(int[] array) {
            for (int i = 0; i < array.length; i++) {
                array[i] = i;
            }
        }

        //无序,从大到小
        public static void notOrderArray(int[] array) {
            for (int i = 0; i < array.length; i++) {
                array[i] = array.length-i;
            }
        }

        //乱序
        public static void notOrderArrayRandom(int[] array) {
            Random random = new Random();
            for (int i = 0; i < array.length; i++) {
                array[i] = random.nextInt(100000);
            }
        }

        public static void testInsertSort(int[] array) {
            /*注意,这里最好用拷贝数据,否则以后测试其他排序方法的时候,
            测试的就是已经经过直接插入法排序为有序的数组了*/
            int[] tmpArray = Arrays.copyOf(array,array.length);

            long startTime = System.currentTimeMillis();
            Sort.insertSort(tmpArray);
            long endTime = System.currentTimeMillis();
            System.out.println("插入排序耗时:"+ (endTime-startTime));
        }

        public static void main(String[] args) {
            int[] array = new int[100000];
            orderArray(array);
            System.out.println("顺序的:");
            testInsertSort(array);
            int[] array2 = new int[100000];
            notOrderArray(array2);//逆序的
            System.out.println("逆序的:");
            testInsertSort(array2);
            int[] array3 = new int[100000];
            notOrderArrayRandom(array3);//乱序的
            System.out.println("乱序的:");
            testInsertSort(array3);
        }
    }

3. 空间复杂度:O(1),它是一种稳定的排序算法

4. 稳定性:直接插入排序是一种稳定的排序算法,相同关键字的记录在排序前后的相对顺序保持不变。

然而,直接插入排序对于大规模数据集的性能可能较差,因为每次插入都需要将后面的元素逐个后移,导致较多的数据移动操作。对于这种情况,更高效的排序算法如快速排序或归并排序可能更适合使用。但在数据量较小或已经部分有序的情况下,直接插入排序具有简单、稳定的特点,且实现相对容易理解。

注意:一个本身就是稳定的排序是可以实现为不稳定的排序的;但是相反,一个本身就不稳定的排序  是不可能实现为稳定的排序的

例如:我们将原来的

if(array[j] > tmp) {
  array[j+1] = array[j];
  }else {
         //array[j+1] = tmp;
         break;
         }

修改为:

if(array[j] >= tmp) {
  array[j+1] = array[j];
  }else {
         //array[j+1] = tmp;
         break;
         }

此时,它就变成了一个不稳定的排序。

练习:

对记录(54,38,96,23,15,72,60,45,83)进行从小到大的直接插入排序时,当把第8个记录45插入到有序表时,为找到插入 位置需比较 (  ) 次?(采用从后往前比较)

A: 3

B: 4

C: 5

D: 6

答案:C

15   23   38   54   60   72   96        45

希尔排序( 缩小增量排序 )

希尔排序是一种改进的插入排序算法,也被称为缩小增量排序。它的基本思想是先将待排序的数据按照一定的间隔分成多个子序列,对每个子序列进行插入排序,然后逐渐缩小间隔,重复进行插入排序,直到间隔为1时完成最后一次排序,从而使整个序列达到有序。

我们正常人来分组:

 发明希尔排序的科学家是怎么分组的?

希尔排序的具体步骤如下:

1. 选择一个增量序列,通常为初始序列长度的一半,然后将待排序的数据按照这个增量分成多个子序列。
2. 对每个子序列进行插入排序,即使用常规的插入排序算法对每个子序列进行排序。
3. 不断缩小增量,重复步骤2,直到增量为1,此时进行最后一次插入排序。
4. 最后一次插入排序完成后,整个序列就变成了有序序列。

希尔排序通过先排序间隔较大的元素,使得整个序列变得部分有序,然后逐渐减小增量,最终使得整个序列有序。相比于传统的插入排序,希尔排序的优势在于它可以在初始阶段快速将较大的元素移动到正确的位置,从而减少了后续插入排序的工作量,也就是一个预排序的过程。

    /**
     * 时间复杂度:
     *     n^1.3 - n^1.5
     * 空间复杂度:O(1)
     *
     * 稳定性:不稳定的排序
     */
    public static void shellSort(int[] array) {
        int gap = array.length;
        while (gap > 1) {
            gap /= 2;
            shell(array,gap);
        }
        //shell(array,gap); 最后唯一整体1组的时候,其实已经排完了,我们不需要它了
    }
    private static void shell(int[] array,int gap) {
        //此处:for (int i = gap; i < array.length; i+=gap)也可以
        for (int i = gap; i < array.length; i++) {
            int tmp = array[i];
            int j = i-gap;
            for (; j >= 0 ; j-=gap) {
                if(array[j] > tmp) {
                    array[j+gap] = array[j];
                }else {
                    break;
                }
            }
            array[j+gap] = tmp;
        }
    }

在这段代码中,我们认为两种方式都是可以的,即使用 'for (int i = gap; i < array.length; i+=gap)' 或者 'for (int i = gap; i < array.length; i++)'。

这是因为在希尔排序的过程中,我们可以选择不同的增量序列来确定子序列的间隔,而每个子序列内部的元素顺序是相对独立的。因此,在进行插入排序时,我们可以选择从每个子序列的第一个元素开始,然后以增量为步长逐渐递增,也可以直接使用步长为1进行遍历。

当使用 'for (int i = gap; i < array.length; i+=gap) ' 时,每次循环 ' i ' 的增量为 ' gap ',即每次遍历的是同一个子序列内的元素,通过插入排序将该子序列内的元素排序。但是只可以每次排序一组数据。

当使用 ' for (int i = gap; i < array.length; i++) ' 时,每次循环 ' i ' 的增量为1,即每次遍历的是不同子序列内的元素,通过插入排序将各个子序列内的元素分别排序。

无论使用哪种方式,最终的结果都是将整个数组分成多个子序列,然后对每个子序列进行插入排序,从而逐步减小间隔、增加有序性。

至于为什么直接加gap是可以的?因为总之我们最后都要把整个数组作为一个整体进行排序。

需要注意的是,这里的增量序列(gap sequence)的选择会影响排序的效率和性能。不同的增量序列可能导致不同的排序时间复杂度和比较次数。因此,在实际应用中,我们可以尝试不同的增量序列,进行性能测试和对比,以找到最佳的排序方案。

希尔排序的特性可以总结如下:

1. 优化的插入排序:希尔排序是对直接插入排序的改进,通过预排序的方式使数组更接近有序状态。直接插入排序每次只移动相邻的元素,而希尔排序通过较大的间隔进行插入排序,从而更快地将较大的元素移动到正确的位置。这种优化能够减少插入排序的比较和交换次数,提高排序的效率。

2. 预排序和最终排序:在希尔排序的执行过程中,当增量(gap)大于1时,数组进行预排序,即通过较大的间隔进行插入排序。预排序的目的是让数组更接近于有序状态,从而在最后一次增量为1的排序时,减少比较和交换的次数,提高排序速度。当增量减小到1时,数组已经接近有序,此时进行最后一次插入排序,由于之前的预排序,最后一次排序的工作量大大减少,因此排序速度很快。

3. 时间复杂度难以确定:希尔排序的时间复杂度较为复杂,因为增量序列的选择有很多种方法,不同的增量序列会对排序的性能产生影响。目前没有一种确定的方法可以准确计算希尔排序的时间复杂度。常见的增量序列有希尔增量序列(n/2,n/4,...,1)、Knuth增量序列(1,4,13,...,(3^k-1)/2)等。对于某些增量序列,最坏情况下希尔排序的时间复杂度是O(N^2),但在平均情况下,希尔排序的时间复杂度可以达到O(N log N)。

4. Knuth增量序列:在实际应用中,常用的增量序列是由Donald Knuth提出的,Knuth增量序列是通过(3^k-1)/2计算得到的,其中k为增量的次数。Knuth进行了大量的试验和统计,认为这个增量序列能够在实际应用中达到较好的排序效果。因此,一般情况下,希尔排序的时间复杂度会按照Knuth增量序列进行估算。

看得出来,这里建议我们取的是素数。 

总之,因为咋们的gap是按照Knuth提出的方式取值的,而且Knuth进行了大量的试验统计,我们暂时就按照:O(N^1.25)到O(1.6*N^1.25)来计算!

我们现在可以对比看一看希尔排序和直接插入排序之间的效率了:


import java.util.Arrays;
import java.util.Random;


    public class Test {

        //有序,从小到大
        public static void orderArray(int[] array) {
            for (int i = 0; i < array.length; i++) {
                array[i] = i;
            }
        }

        //无序,从大到小
        public static void notOrderArray(int[] array) {
            for (int i = 0; i < array.length; i++) {
                array[i] = array.length-i;
            }
        }

        //乱序
        public static void notOrderArrayRandom(int[] array) {
            Random random = new Random();
            for (int i = 0; i < array.length; i++) {
                array[i] = random.nextInt(100000);
            }
        }
        
        public static void testShellSort(int[] array) {
            int[] tmpArray = Arrays.copyOf(array,array.length);

            long startTime = System.currentTimeMillis();
            Sort.shellSort(tmpArray);
            long endTime = System.currentTimeMillis();
            System.out.println("希尔排序耗时:"+ (endTime-startTime));
        }
        public static void main(String[] args) {
            int[] array = new int[100000];
            orderArray(array);
            System.out.println("顺序的:");
            testShellSort(array);
            int[] array2 = new int[100000];
            notOrderArray(array2);//逆序的
            System.out.println("逆序的:");
            testShellSort(array2);
            int[] array3 = new int[100000];
            notOrderArrayRandom(array3);//乱序的
            System.out.println("乱序的:");
            testShellSort(array3);
       }
    }

相较于直接插入排序,效率明显偏高。

5、稳定性:不稳定,就如例子里面的5一样,两个5的顺序已经颠倒过了。

选择排序

选择排序是一种简单直观的排序算法,其基本思想是:

每次从待排序的数据元素中选出最小(或最大)的一个元素,然后将其放置在序列的起始位置,重复这个过程,直到全部待排序的数据元素被排列完成。

直接选择排序

具体来说,直接选择排序的步骤如下:

1. 首先,从待排序的序列中找到最小(或最大)的元素。
2. 将最小(或最大)的元素与序列的第一个元素进行交换,将其放置在序列的起始位置。
3. 在剩余的未排序部分中,继续寻找最小(或最大)的元素。
4. 将找到的最小(或最大)元素与未排序部分的第一个元素进行交换,将其放置在已排序部分的末尾。
5. 重复步骤3和步骤4,直到所有的元素都被排列完成。

通过不断选择最小(或最大)的元素并交换位置,选择排序每次都能确保将当前未排序部分的最小(或最大)元素放置到已排序部分的末尾,逐步将整个序列变得有序。

  • 在待排序的元素集合array[i]--array[n-1]中选择关键码最大(或最小)的数据元素。
  • 如果它不是这组元素中的最后一个(或第一个)元素,则将它与这组元素中的最后一个(或第一个)元素交换位置。
  • 然后,在剩余的array[i]--array[n-2](或array[i+1]--array[n-1])集合中,重复上述步骤,直到集合剩余1个元素,完成整个排序过程。

  /**
     * 选择排序:
     *     时间复杂度:不管最好还是最坏 都是O(n^2)
     *     空间复杂度:O(1)
     *     稳定性:不稳定的排序
     */
    public static void selectSort(int[] array) {
        for (int i = 0; i < array.length; i++) {
            int minIndex = i;
            for (int j = i+1; j < array.length; j++) {
                if(array[j] < array[minIndex]) {
                    minIndex = j;
                }
            }
            swap(array,minIndex,i);
        }
    }

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

我们可以来测试一下它的效率: 

import java.util.Arrays;
import java.util.Random;


    public class Test {

        //有序,从小到大
        public static void orderArray(int[] array) {
            for (int i = 0; i < array.length; i++) {
                array[i] = i;
            }
        }

        //无序,从大到小
        public static void notOrderArray(int[] array) {
            for (int i = 0; i < array.length; i++) {
                array[i] = array.length-i;
            }
        }

        //乱序
        public static void notOrderArrayRandom(int[] array) {
            Random random = new Random();
            for (int i = 0; i < array.length; i++) {
                array[i] = random.nextInt(100000);
            }
        }

        public static void testSelectSort(int[] array) {
            int[] tmpArray = Arrays.copyOf(array,array.length);
            long startTime = System.currentTimeMillis();
            Sort.selectSort(tmpArray);
            long endTime = System.currentTimeMillis();
            System.out.println("选择排序耗时:"+ (endTime-startTime));
        }
        public static void main(String[] args) {
            int[] array = new int[100000];
            orderArray(array);
            System.out.println("顺序的:");
            testSelectSort(array);
            int[] array2 = new int[100000];
            notOrderArray(array2);//逆序的
            System.out.println("逆序的:");
            testSelectSort(array2);
            int[] array3 = new int[100000];
            notOrderArrayRandom(array3);//乱序的
            System.out.println("乱序的:");
            testSelectSort(array3);

        }
    }

可以感受到,它的效率不高。

你可能会问为什么直接插入排序的时间复杂度明明和直接选择排序的时间复杂度是一样的,但是效率却相对要高一点?

那是因为,直接插入排序,它是一个慢慢变得有序的过程,每一次都比上一次更有序。

其实,我们还有一种选择排序,它是这样的:

    public static void selectSort2(int[] array) {
        int left = 0;
        int right = array.length-1;
        while (left < right) {
            int minIndex = left;
            int maxIndex = left;
            for (int i = left+1; i <= right ; i++) {
                if(array[i] < array[minIndex]) {
                    minIndex = i;
                }
                if(array[i] > array[maxIndex]) {
                    maxIndex = i;
                }
            }
            swap(array,left,minIndex);
            //最大值刚好 在最小值的位置 已经交换到了minIndex
            if(maxIndex == left) {
                maxIndex = minIndex;
            }
            swap(array,right,maxIndex);
            left++;
            right--;
        }
    }

如上这段代码实现了一种改进的选择排序算法,其步骤如下所示:

  1. 初始化左边界 left 为数组的起始位置,右边界 right 为数组的末尾位置。
  2. 进入循环,只要左边界 left 小于右边界 right,继续执行以下步骤:
  3. 在当前未排序的区间内(即 left 到 right),找到最小元素的索引 minIndex 和最大元素的索引 maxIndex,分别初始化为 left。
  4. 从 left + 1 开始,遍历区间内的元素,若发现比当前最小元素小的元素,则更新 minIndex 为该元素的索引;若发现比当前最大元素大的元素,则更新 maxIndex 为该元素的索引。
  5. 将最小元素与左边界 left 处的元素进行交换,将最大元素与右边界 right 处的元素进行交换。
  6. 如果最大元素的索引 maxIndex 恰好等于左边界 left,说明最大元素原本在最小元素的位置,交换后它已经移动到 minIndex 的位置,因此需要将最大元素的索引更新为 minIndex。
  7. 左边界 left 向右移动一位,右边界 right 向左移动一位,缩小未排序区间的范围。
  8. 重复步骤2到步骤7,直到左边界 left 大于或等于右边界 right,此时排序完成。

这种改进的选择排序算法在每次循环中同时找到最小元素和最大元素,并将它们分别放置在已排序区间的起始位置和末尾位置。通过同时处理最小和最大元素,减少了交换次数,从而提高了排序效率。

这种改进的选择排序算法仍然具有选择排序的特性,包括时间复杂度为O(N^2),空间复杂度为O(1),以及不稳定性。通过同时处理最小和最大元素,它在一次循环中能够完成两个元素的交换,相较于传统的选择排序,可以稍微提高一些效率。然而,相对于其他更高效的排序算法,这种改进的选择排序仍然是一种较为简单和低效的排序算法。

直接选择排序的特性可以总结如下:

1. 简单直观:直接选择排序的思路非常容易理解,每次选择最大(或最小)的元素并交换位置,逐步将序列变得有序。算法的实现也相对简单。

2. 效率不高:尽管直接选择排序思路简单,但是其效率相对较低。直接选择排序的时间复杂度为O(N^2),其中N是待排序序列的长度。无论输入数据的初始状态如何,直接选择排序的时间复杂度都是一样的,因此在处理大规模数据集合时,效率较低。

3. 原地排序:直接选择排序是一种原地排序算法,只需要常数级别的额外空间——O(1)。它不需要借助其他数据结构来进行辅助存储。

4. 不稳定性:直接选择排序是一种不稳定的排序算法。在选择最大(或最小)元素并交换位置的过程中,可能会改变相同元素之间的相对顺序。

尽管直接选择排序的效率相对较低,并且不稳定,从而实际中很少使用它来进行排序,但是对于小规模的数据集合或教学目的,直接选择排序仍然可以是一个简单有效的选择。但是在面对大规模数据或对排序效率有更高要求的场景,更常使用其他高效的排序算法,如快速排序、归并排序等。

堆排序

堆排序(Heapsort)是指利用堆积树(堆)这种数据结构所设计的一种排序算法,它是选择排序的一种。它是通过堆来进行选择数据。需要注意的是排升序要建大堆,排降序建小堆。

   /**
     * 时间复杂度:
     *          O(n*logN)        N^1.3 -->数据量非常非常大的时候希尔效率<堆效率
     *          因为一个呈指数增长,一个是对数,函数图像相交,有一个临界点
     * 空间复杂度:O(1)
     * 稳定性:不稳定的
     *    数据量非常大的时候 堆排序一定比希尔快

     */
    public static void heapSort(int[] array) {
        createBigHeap(array);
        int end = array.length-1;
        while (end > 0) {
            swap(array,0,end);
            siftDown(array,0,end);
            end--;
        }
    }

    private static void createBigHeap(int[] array) {
        for (int parent = (array.length-1-1)/2; parent >= 0 ; parent--) {
            siftDown(array,parent,array.length);
        }
    }

    private static void siftDown(int[] array,int parent,int end) {
        int child = 2*parent+1;
        while (child < end) {
            if(child + 1 < end && array[child] < array[child+1]) {
                child++;
            }
            if(array[child] > array[parent]) {
                swap(array,child,parent);
                parent = child;
                child = 2*parent+1;
            }else {
                break;
            }
        }
    }
import java.util.Arrays;
import java.util.Random;


    public class Test {

        //有序,从小到大
        public static void orderArray(int[] array) {
            for (int i = 0; i < array.length; i++) {
                array[i] = i;
            }
        }

        //无序,从大到小
        public static void notOrderArray(int[] array) {
            for (int i = 0; i < array.length; i++) {
                array[i] = array.length-i;
            }
        }

        //乱序
        public static void notOrderArrayRandom(int[] array) {
            Random random = new Random();
            for (int i = 0; i < array.length; i++) {
                array[i] = random.nextInt(100000);
            }
        }

        public static void testHeapSort(int[] array) {
            int[] tmpArray = Arrays.copyOf(array,array.length);
            long startTime = System.currentTimeMillis();
            Sort.heapSort(tmpArray);
            long endTime = System.currentTimeMillis();
            System.out.println("堆排序耗时:"+ (endTime-startTime));
        }

        public static void main(String[] args) {
            int[] array = new int[100000];
            orderArray(array);
            System.out.println("顺序的:");
            testHeapSort(array);
            int[] array2 = new int[100000];
            notOrderArray(array2);//逆序的
            System.out.println("逆序的:");
            testHeapSort(array2);
            int[] array3 = new int[100000];
            notOrderArrayRandom(array3);//乱序的
            System.out.println("乱序的:");
            testHeapSort(array3);
        }
    }

具体介绍请看本篇博客的置顶链接,里面有详细的解释说明。

交换排序

基本思想:所谓交换,就是根据序列中两个记录键值的比较结果来对换这两个记录在序列中的位置,交换排序的特点是:将关键值较大的记录向序列的尾部移动,关键值较小的记录向序列的前部移动。

冒泡排序

冒泡排序是一种简单直观的排序算法,其基本思想是重复地遍历待排序的元素,比较相邻的两个元素,如果它们的顺序不符合要求(如升序排序要求前面的元素小于后面的元素),则交换它们的位置,直到整个序列排序完成。

具体来说,冒泡排序的步骤如下:

1. 从序列的第一个元素开始,依次比较相邻的两个元素,比较过程中较大(或较小)的元素会逐渐“浮”到序列的右端(或左端)。
2. 如果当前元素大于(或小于)它的下一个元素,交换这两个元素的位置,使较大(或较小)的元素“浮”到右端(或左端)。
3. 继续进行相邻元素的比较和交换,直到整个序列的最右端(或最左端)已经排序完成。
4. 重复以上步骤,每次遍历时,序列的未排序部分长度减1,直到整个序列排序完成。

冒泡排序的特点可以总结如下:

  1. 简单直观:冒泡排序的思想非常简单直观,通过相邻元素的比较和交换,逐渐将较大(或较小)的元素移动到序列的右端(或左端)。
  2. 时间复杂度:冒泡排序的时间复杂度为O(N^2),其中N是待排序序列的长度。在最坏情况下,即序列是逆序的情况下,冒泡排序需要进行N-1次遍历,每次遍历需要比较和交换相邻元素。
  3. 空间复杂度:冒泡排序的空间复杂度为O(1),只需要常数级别的额外空间。
  4. 稳定性:冒泡排序是一种稳定的排序算法。相等元素的相对顺序不会被改变,只有相邻元素之间的比较和交换。
  5. 最优情况优化:如果在一次遍历中没有发生任何交换操作,说明序列已经有序,可以提前结束排序。

尽管冒泡排序思路简单,但由于其时间复杂度较高,在处理大规模数据集合时效率相对较低。因此,冒泡排序通常适用于小规模数据或者教学演示,而不是应用于实际的大规模数据排序场景。在实际应用中,更常使用其他高效的排序算法,如快速排序、归并排序等。

   /**
     * 冒泡排序:
     *  时间复杂度: o(n^2)   如果加了优化,如下:  最好情况O(N)
     *  空间复杂度:
     *  稳定性:
     */
    public static void bubbleSort(int[] array) {
        for (int i = 0; i < array.length-1; i++) {
            boolean flg = false;
            for (int j = 0; j < array.length-1-i; j++) {
                if(array[j] > array[j+1]) {
                    swap(array,j,j+1);
                    flg = true;
                }
            }
            if(!flg) {
                return;
            }
        }
    }

import java.util.Arrays;
import java.util.Random;


    public class Test {

        //有序,从小到大
        public static void orderArray(int[] array) {
            for (int i = 0; i < array.length; i++) {
                array[i] = i;
            }
        }

        //无序,从大到小
        public static void notOrderArray(int[] array) {
            for (int i = 0; i < array.length; i++) {
                array[i] = array.length-i;
            }
        }

        //乱序
        public static void notOrderArrayRandom(int[] array) {
            Random random = new Random();
            for (int i = 0; i < array.length; i++) {
                array[i] = random.nextInt(100000);
            }
        }

        public static void testbubbleSort(int[] array) {
            int[] tmpArray = Arrays.copyOf(array,array.length);
            long startTime = System.currentTimeMillis();
            Sort.bubbleSort(tmpArray);
            long endTime = System.currentTimeMillis();
            System.out.println("冒泡排序耗时:"+ (endTime-startTime));
        }

        public static void main(String[] args) {
            int[] array = new int[100000];
            orderArray(array);
            System.out.println("顺序的:");
            testbubbleSort(array);
            int[] array2 = new int[100000];
            notOrderArray(array2);//逆序的
            System.out.println("逆序的:");
            testbubbleSort(array2);
            int[] array3 = new int[100000];
            notOrderArrayRandom(array3);//乱序的
            System.out.println("乱序的:");
            testbubbleSort(array3);
        }
    }

 看得出来,效率很低。

快速排序

快速排序是一种高效的排序算法,是于1962年由Tony Hoare提出的一种二叉树结构的交换排序方法,它采用了分治法(Divide and Conquer)的思想。

快速排序的基本思想如下:
1. 从待排序元素序列中选择一个基准值(pivot),通常是取序列的第一个元素。
2. 将待排序序列按照基准值进行分割,使得左子序列中的所有元素都小于基准值,右子序列中的所有元素都大于基准值。这个过程称为分割(partition)。
3. 对左子序列和右子序列分别递归地应用上述步骤,直到每个子序列只包含一个元素或为空,此时所有元素都排列在相应的位置上。

具体步骤如下:
1. 选择基准值pivot(通常是序列的第一个元素)。
2. 通过一趟分割操作,将序列分割成两个子序列,小于基准值的元素在左边,大于基准值的元素在右边。这一步通常称为划分(partition)。
3. 对左子序列和右子序列分别递归地应用上述步骤,继续进行划分和排序,直到每个子序列只包含一个元素或为空。

将区间按照基准值划分为左右两半部分的常见方式有(以下三种都是递归的) 

1、Hoare版

 

 

这样就排序为了:1 2 3 4 5 6 7 8 9 10 

    /**
     * 时间复杂度:
     *      最好情况:
     *              O(N*logN)   满二叉树/完全二叉树
     *      n--->n    分后,左n/2+右n/2--->n    
     *      分后,左的左n/4+左的右n/4 + 右的左n/4+右的右n/4--->n
     *      最坏情况:
     *            O(N^2) 单分支的树,每个元素都会当一次基准
     * 空间复杂度:
     *   最好情况:
     *           O(logN)   满二叉树/完全二叉树
     *  最坏情况:
     *          O(N)   单分支的树,高度为N
     * 稳定性:不稳定
     * @param array
     */
    public static void quickSort(int[] array) {
        quick(array,0,array.length-1);
    }

    private static void quick(int[] array,int start,int end) {

        if(start >= end) return;//左边是一个节点 或者 连一个节点都没有

        int pivot = partition(array,start,end);

        quick(array,start,pivot-1);

        quick(array,pivot+1,end);

    }

    private static int partition(int[] array,int left,int right) {
        int key = array[left];
        int i = left;
        while (left < right) {
            while (left < right && array[right] >= key) {//这里为什么要取等号
                right--;
            }
            //right 下标一定是 比key小的数据
            while (left < right && array[left] <= key) {//这里为什么要取等号
                left++;
            }
            //left 下标一定是 比key大的数据

            swap(array,left,right);
        }
        //相遇的位置 和 i 位置进行交换
        swap(array,left,i);

        return left;
    }

里面的第一个问题——“这里为什么要取等号?”:拿我们图里面的例子来说,如果最开始的right是 6 ,那么就一直 6 和 6 交换: 

 第二个问题——“为啥要先走右边再走左边?”:因为这样就不能在 left 和 right 相遇的时候确保在基准值前面的都比其小,在其后面的都比其大。

import java.util.Arrays;
import java.util.Random;


    public class Test {

        //有序,从小到大
        public static void orderArray(int[] array) {
            for (int i = 0; i < array.length; i++) {
                array[i] = i;
            }
        }

        //无序,从大到小
        public static void notOrderArray(int[] array) {
            for (int i = 0; i < array.length; i++) {
                array[i] = array.length-i;
            }
        }

        //乱序
        public static void notOrderArrayRandom(int[] array) {
            Random random = new Random();
            for (int i = 0; i < array.length; i++) {
                array[i] = random.nextInt(10000);
            }
        }

        public static void testQuickSort(int[] array) {
            int[] tmpArray = Arrays.copyOf(array,array.length);
            long startTime = System.currentTimeMillis();
            Sort.quickSort(tmpArray);
            long endTime = System.currentTimeMillis();
            System.out.println("快速排序耗时:"+ (endTime-startTime));
        }

        public static void main(String[] args) {
            int[] array = new int[10000];
            orderArray(array);
            System.out.println("顺序的:");
            testQuickSort(array);
            int[] array2 = new int[10000];
            notOrderArray(array2);//逆序的
            System.out.println("逆序的:");
            testQuickSort(array2);
            int[] array3 = new int[10000];
            notOrderArrayRandom(array3);//乱序的
            System.out.println("乱序的:");
            testQuickSort(array3);
        }
    }

 如果你细心的话,相信你会发现我将数组元素大小从100万改为了10万。

因为我发现在100万的时候无法处理相关数据,弹出了栈溢出的错误。——当给的数据是逆序的时候,空间复杂度是O(N)。

那这么对比的来看,好像快速排序的效率也不是很高啊,为啥叫快速排序?

那是因为快速排序经常需要结合它的优化来使用,我们接下来会提到快速排序的优化。

2. 挖坑法

    private static int partition2(int[] array,int left,int right) {
        int key = array[left];
        int i = left;
        while (left < right) {
            while (left < right && array[right] >= key) {//这里为什么要取等号
                right--;
            }
            array[left]=array[right];
            //right 下标一定是 比key小的数据
            while (left < right && array[left] <= key) {//这里为什么要取等号
                left++;
            }
            //left 下标一定是 比key大的数据
            array[right]=array[left];
        }
        array[left]=key;

        return left;
    }

 优先使用挖坑法。

3. 前后指针

 

写法一:

   private static int partition3(int[] array, int left, int right) {
        int prev = left ;
        int cur = left+1;
        while (cur <= right) {
            if(array[cur] < array[left] && array[++prev] != array[cur]) {
                swap(array,cur,prev);
            }
            cur++;
        }
        swap(array,prev,left);
        return prev;
    }

写法二:

    private static int partition3(int[] array, int left, int right) {
        int d = left + 1;
        int pivot = array[left];
        for (int i = left + 1; i <= right; i++) {
            if (array[i] < pivot) {
                swap(array, i, d);
                d++;
            }
        }
        swap(array, d - 1, left);
        return d - 1;
    }

你会发现,三个方法基准值前面的数据顺序是不一样的,所以三个方法的运行时间有一点不同,但是效率是一样的。

如果你想要找到运行时间最少的一种方法,每道题都是不一样的,你需要一个一个去试。

建议你:先试挖坑法,再试 Hoare法,最后是前后指针法。

快速排序优化

我们之前注意到,当数据量非常大的时候,快速排序会发生栈溢出的异常。

为了解决这个问题,可以进行以下优化:

1. 三数取中法选key:因为最坏情况下,我们是一颗单支的树,如果想要减少空间,那么我们需要降低树的高度。准确来说,在原始的快速排序算法中,通常选择待排序序列的第一个元素或最后一个元素作为基准值(即key)。但是,当输入数据已经有序或接近有序时,选择第一个或最后一个元素作为key可能导致快速排序的性能下降到O(n^2)的时间复杂度。为了避免这种情况,可以使用"三数取中法"来选择一个合适的key。具体做法是,从待排序序列选择 left 和 right 后,再取二者中间的数据,然后取这三个元素中第二大的数据作为key,即中间大的数字和 left 交换。这样可以尽量保证key的选择对数据的划分是均匀的,提高排序的效率。

总之,三数取中法可以规避掉O(N^2)的情况,把单分支的树变成别的形式的二叉树。

三数取中法已经尽可能的将我们的序列变成二分查找了。

    private static void quick(int[] array, int start, int end) {

        if(start >= end) return;//左边是一个节点 或者 连一个节点都没有

        int pivot = partition2(array,start,end);

        //三数取中
        int index = midOfThree(array,start,end);

        swap(array,index,start);//此时,交换完成之后,一定能保证start下标是中间大的数字。

        quick(array,start,pivot-1);

        quick(array,pivot+1,end);

    }
    private static int midOfThree(int[] array, int left, int right) {
        int mid = (left + right) / 2;
        if (array[left] < array[right]) {
            if (array[mid] < array[left]) {
                return left;
            } else if (array[mid] > array[right]) {
                return right;
            } else {
                return mid;
            }
        } else {
            if (array[mid] < array[right]) {
                return right;
            } else if (array[mid] > array[left]) {
                return left;
            } else {
                return mid;
            }
        }
    }

改进后,重新设置数据量大小为100000

从: 

变成(逆序的还是不太行): 

当然,IDEA 本身默认的栈的大小偏小,我们可以选择修改其默认栈的大小。

 2. 递归到小的子区间时,可以考虑使用插入排序:

我们在递归过程中,每个节点都需要递归一遍。而后面的两排节点几乎占了整棵树的 2/3 ,而这些子树其实是没有必要递归三次的(三个节点),这其实是加大了运算量的没有必要的步骤。因为随着我们的排序,整个序列都在趋于有序。区间越来越小了,数据越来越有序了,我们应该把它变成插入排序,插入排序就不会递归了,提高了效率。

所以当递归到较小的子区间时,我们可以考虑切换到插入排序。插入排序对于较小规模的数据集具有较好的性能,并且不会引起栈溢出的异常,最重要的是插入排序是月有序,效率越高。因此,当待排序序列的规模小于一定阈值时,可以切换到插入排序算法来完成排序。这样可以避免递归调用过深而导致的栈溢出问题。

    private static void quick(int[] array, int start, int end) {

        if(start >= end) return;//左边是一个节点 或者 连一个节点都没有

        int pivot = partition2(array,start,end);
        
        //取某个范围大小的子树
        if(end - start + 1 <= 7){
            //插入排序
            insertSortRange(array,start,end);
            return;
        }

        //三数取中
        int index = midOfThree(array,start,end);

        swap(array,index,start);//此时,交换完成之后,一定能保证start下标是中间大的数字。

        quick(array,start,pivot-1);

        quick(array,pivot+1,end);

    }
    private static int midOfThree(int[] array, int left, int right) {
        int mid = (left + right) / 2;
        if (array[left] < array[right]) {
            if (array[mid] < array[left]) {
                return left;
            } else if (array[mid] > array[right]) {
                return right;
            } else {
                return mid;
            }
        } else {
            if (array[mid] < array[right]) {
                return right;
            } else if (array[mid] > array[left]) {
                return left;
            } else {
                return mid;
            }
        }
    }
    private static void insertSortRange(int[] array,int begin,int end) {
        for (int i = begin+1; i <= end; i++) {
            int tmp = array[i];
            int j = i-1;
            for (; j >= begin ; j--) {
                if(array[j] > tmp) {
                    array[j+1] = array[j];
                }else {
                    break;
                }
            }
            array[j+1] = tmp;
        }
    }

没有插入排序的: 

 加了插入排序的:

嗯???你会发现,以插入排序来优化代码,运行速度好像反而变慢了?

当然,递归的次数肯定减少了,但是不一定减少了运行时间。

通过以上两个优化措施,可以改进快速排序算法在处理大规模数据集时的性能和稳定性。三数取中法选key可以提高快速排序的划分效果,而在递归到较小的子区间时切换到插入排序可以避免栈溢出异常。综合使用这些优化技巧,可以使快速排序在大规模数据集上更加高效可靠。

快速排序非递归

 

以此类推…… 

 具体步骤:

1. 在代码的开始,创建一个栈对象 `stack`,用于存储待处理的子序列的边界索引。

2. 初始化左边界索引 `left` 为 0,右边界索引 `right` 为数组长度减 1。

3. 调用 `partition` 方法,将数组划分为两个部分,并返回枢轴元素的索引,将其赋值给变量 `piovt`。

4. 如果枢轴元素的左边存在元素,即 `piovt - 1` 大于左边界 `left`,将左边界 `left` 和 `piovt - 1` 入栈,表示需要对左子序列进行后续处理。

5. 如果枢轴元素的右边存在元素,即 `piovt + 1` 小于右边界 `right`,将 `piovt + 1` 和右边界 `right` 入栈,表示需要对右子序列进行后续处理。

6. 进入循环,只要栈不为空,就继续进行下面的操作。

7. 弹出栈顶的右边界索引 `right` 和左边界索引 `left`。

8. 再次调用 `partition` 方法,对当前的子序列进行划分,并返回新的枢轴元素的索引 `piovt`。

9. 如果新的枢轴元素的左边存在元素,将左边界 `left` 和 `piovt - 1` 入栈,表示需要对左子序列进行后续处理。

10. 如果新的枢轴元素的右边存在元素,将 `piovt + 1` 和右边界 `right` 入栈,表示需要对右子序列进行后续处理。

11. 重复步骤 7 到步骤 10,直到栈为空,所有的子序列都被处理完毕,排序完成。

总体而言,非递归算法使用栈来模拟递归调用的过程,将待处理的子序列的边界索引存储在栈中,通过循环不断处理栈中的子序列,直到所有的子序列都被处理完毕,实现了快速排序的非递归版本。

    public static void quickSortNor(int[] array) {
        Stack<Integer> stack = new Stack<>();
        int left = 0;
        int right = array.length-1;
        int piovt = partition(array,left,right);
        //左边一定有至少2个元素
        if(piovt - 1 > left) {
            stack.push(left);
            stack.push(piovt-1);
        }
        //右边一定有至少2个元素
        if(piovt + 1 < right) {
            stack.push(piovt+1);
            stack.push(right);
        }
        while (!stack.isEmpty()) {
            right = stack.pop();
            left = stack.pop();
            piovt = partition(array,left,right);
            if(piovt - 1 > left) {
                stack.push(left);
                stack.push(piovt-1);
            }
            if(piovt + 1 < right) {
                stack.push(piovt+1);
                stack.push(right);
            }
        }
    }

import java.util.Arrays;
import java.util.Random;


    public class Test {

        //有序,从小到大
        public static void orderArray(int[] array) {
            for (int i = 0; i < array.length; i++) {
                array[i] = i;
            }
        }

        //无序,从大到小
        public static void notOrderArray(int[] array) {
            for (int i = 0; i < array.length; i++) {
                array[i] = array.length-i;
            }
        }

        //乱序
        public static void notOrderArrayRandom(int[] array) {
            Random random = new Random();
            for (int i = 0; i < array.length; i++) {
                array[i] = random.nextInt(10000);
            }
        }

        public static void testQuickSort(int[] array) {
            int[] tmpArray = Arrays.copyOf(array,array.length);
            long startTime = System.currentTimeMillis();
            Sort.quickSortNor(tmpArray);
            long endTime = System.currentTimeMillis();
            System.out.println("快速排序耗时:"+ (endTime-startTime));
        }

        public static void main(String[] args) {
            int[] array = new int[10000];
            orderArray(array);
            System.out.println("顺序的:");
            testQuickSort(array);
            int[] array2 = new int[10000];
            notOrderArray(array2);//逆序的
            System.out.println("逆序的:");
            testQuickSort(array2);
            int[] array3 = new int[10000];
            notOrderArrayRandom(array3);//乱序的
            System.out.println("乱序的:");
            testQuickSort(array3);
        }
    }

快速排序的特点总结如下:

1. 高效性:快速排序是一种高效的排序算法,在平均情况下,其时间复杂度为O(N * logN),其中N是待排序序列的长度。
2. 对于快速排序算法,其空间复杂度通常被定义为递归调用时使用的栈空间的大小。在每一次递归调用中,需要存储当前子问题的起始索引和结束索引等信息,以便在递归返回时能够正确恢复。因此,空间复杂度取决于递归调用的深度。

实际上,快速排序还是一个原地排序算法,它不需要额外的存储空间来存储排序过程中的中间结果。原地排序算法是指在排序过程中只使用常数级别的额外空间,不随着输入规模的增加而增加额外的空间需求。

快速排序的空间复杂度通常是指递归调用所使用的栈空间的大小,它与输入规模和递归深度相关。在最坏情况下,即每次划分操作都选择当前区间的最小或最大元素作为枢轴(key),递归深度将达到输入规模的大小,空间复杂度为O(N)。但在平均情况下,快速排序的递归深度通常较小,大致在logN级别,因此空间复杂度为O(logN)。

对于原地排序算法,我们通常关注的是排序过程中是否需要额外的存储空间,而不是递归调用所使用的栈空间。

3. 不稳定性:快速排序是一种不稳定的排序算法。在划分过程中,相等元素的相对顺序可能会发生改变。
4. 快速排序整体的综合性能和使用场景都是比较好的,所以才敢叫快速排序。
5. 优化方法:快速排序有一些优化方法,例如随机选择基准值、三数取中法(选择左端、右端和中间位置的三个元素的中位数作为基准值),可以提高算法的性能和避免最坏情况的发生。

下列排序算法中,在待排序数据已有序时,花费时间反而最多的是( )排序。

A.堆排序

B.归并排序

C.希尔排序

D.快速排序

 答案:D

解析:待排序数据已经有序,即此时是一颗单支树,时间复杂度和空间复杂度最大。

9.下列关于三数取中法快速排序的描述错误的是( )

A.三数取中法可以有效避免快排单链的情况,尤其对已经有序的序列的速度改善尤为明显

B.三数取中法依然无法完全解决针对某种特殊序列复杂度变为O(n)的情况

C.三数取中法一般选取首、尾和正中三个数进行取中

D.三数取中法的快速排序在任何情况下都是速度最快的排序方式

 答案:D

解释:

选项 B 表述的是三数取中法依然无法完全解决针对某种特殊序列复杂度变为 O(n) 的情况。

快速排序的平均时间复杂度是 O(n log n),但在某些特殊情况下,快速排序可能会出现最坏情况,导致时间复杂度达到 O(n^2)。这种最坏情况通常发生在输入序列已经部分有序的情况下。

然而,即使使用三数取中法,仍然存在某些特殊序列或数据分布,使得快速排序的复杂度变为 O(n)。例如,如果输入序列中的所有元素都相等,那么无论采用什么样的优化策略,每次划分得到的子序列都将完全相同,导致递归的层数达到序列长度,最终时间复杂度为 O(n)。

归并排序

基本思想

归并排序序(MERGE-SORT)是一种基于归并操作的有效排序算法,它是分治法(Divide and Conquer)的典型应用之一。该算法通过将已经有序的子序列合并来获得完全有序的序列。它的核心思想是先使每个子序列有序,然后再合并这些子序列段,最终得到完整的有序序列。归并排序通常采用二路归并,即将两个有序表合并成一个有序表。

归并排序的核心步骤如下:

1. 分解:将待排序的序列不断地二分,直到每个子序列只包含一个元素,即认为每个子序列是有序的。

2. 合并:将相邻的有序子序列进行合并,得到更大的有序子序列,不断地进行合并直到整个序列有序。

  

    /**    时间复杂度:严格的二分
    *              O(N*logN)
    *     空间复杂度:
    *              O(N)
    *      稳定性:
     *             本身就是稳定的
     * 
     * */
    public static void mergeSort(int [] array){
        mergeSortFunc(array,0,array.length-1);
    }
    public static void mergeSortFunc(int [] array,int left,int right) {
        if (left >= right) return;
        int mid = (left+right)/2;
        //分解左边
        mergeSortFunc(array,left,mid);
        //分解右边
        mergeSortFunc(array,mid+1,right);
        //合并
        merge(array,left,right,mid);
    }
    //合并两个有序的数组
    private static void merge(int [] array,int left,int right,int mid){
        int s1 = left;
        int s2 = mid+1;
        int [] tmp = new int[right-left+1];
        int k = 0;
        //证明两个区间现在都有数据。
        while(s1 <= mid && s2 <= right){
            if(array[s1]<=array[s2]){
                tmp[k++] = array[s1++];
            } else{
                tmp[k++]=array[s2++];
            }
        }
        while(s1<=mid){
            tmp[k++]=array[s1++];
        }
        while(s2<=right){
            tmp[k++]=array[s2++];
        }
        for (int i=0;i<tmp.length;i++){
            array[i+left]=tmp[i];
        }
    }
import java.util.Arrays;
import java.util.Random;


    public class Test {

        //有序,从小到大
        public static void orderArray(int[] array) {
            for (int i = 0; i < array.length; i++) {
                array[i] = i;
            }
        }

        //无序,从大到小
        public static void notOrderArray(int[] array) {
            for (int i = 0; i < array.length; i++) {
                array[i] = array.length-i;
            }
        }

        //乱序
        public static void notOrderArrayRandom(int[] array) {
            Random random = new Random();
            for (int i = 0; i < array.length; i++) {
                array[i] = random.nextInt(100000);
            }
        }

        public static void testMergeSort(int[] array) {
            int[] tmpArray = Arrays.copyOf(array,array.length);
            long startTime = System.currentTimeMillis();
            Sort. mergeSort(tmpArray);
            long endTime = System.currentTimeMillis();
            System.out.println("归并排序耗时:"+ (endTime-startTime));
        }

        public static void main(String[] args) {
            int[] array = new int[100000];
            orderArray(array);
            System.out.println("顺序的:");
            testMergeSort(array);
            int[] array2 = new int[100000];
            notOrderArray(array2);//逆序的
            System.out.println("逆序的:");
            testMergeSort(array2);
            int[] array3 = new int[100000];
            notOrderArrayRandom(array3);//乱序的
            System.out.println("乱序的:");
            testMergeSort(array3);
        }
    }

 归并算法的非递归实现

归并排序的非递归实现使用迭代的方式进行排序,而不是递归调用。

它通过不断将数组分割成小的子数组,然后按照一定的规则合并这些子数组,最终得到一个有序的数组。

下面是归并排序的非递归实现的具体步骤:

1. 首先,定义一个辅助数组 `temp`,用于存储归并排序过程中的临时数据。

2. 初始化子数组的大小 `size` 为 1。迭代地将 `size` 乘以 2,直到 `size` 大于等于数组的长度。

3. 对于每个 `size`,从数组的起始位置开始,按照 `size` 的步长,将数组分成若干个子数组。每个子数组的长度为 `size`,除了最后一个子数组可能长度不足 `size`。

4. 对于每一对相邻的子数组,进行合并操作。合并操作的具体步骤如下:

   a. 定义三个指针:`left` 指向当前子数组的起始位置,`mid` 指向当前子数组的中间位置,`right` 指向当前子数组的结束位置。

   b. 分别将左半部分和右半部分的数据拷贝到辅助数组 `temp` 中,左半部分的起始位置为 `left`,结束位置为 `mid`,右半部分的起始位置为 `mid + 1`,结束位置为 `right`。

   c. 依次比较左半部分和右半部分的元素,并将较小的元素放回原数组的对应位置。比较的过程中,使用两个指针 `i` 和 `j` 分别指向左半部分和右半部分的起始位置。

   d. 如果左半部分的元素已经全部放回原数组,将右半部分剩余的元素直接放回原数组的对应位置。

5. 完成一轮合并后,将 `size` 值乘以 2,继续进行下一轮的合并操作,直到 `size` 大于等于数组的长度。

6. 最终,当 `size` 大于等于数组的长度时,表示整个数组已经有序,排序完成。

归并排序的非递归实现利用了迭代和临时数组的方式,逐步进行子数组的合并操作,直到最终得到一个完全有序的数组。相比递归实现,非递归实现的归并排序具有更好的空间效率,并且避免了递归调用的开销。

    public static void mergeSortNor(int[] array) {
        int gap = 1;
        while (gap < array.length) {
            for (int i = 0; i < array.length; i += 2*gap) {
                int left = i;
                int mid =left+gap-1;
                int right = mid+gap;
                // right 和 mid 又可能会越界,left = i < array.length,绝对不会越界
                if(mid >= array.length) {
                    mid = array.length-1;
                }
                if(right >= array.length) {
                    right = array.length-1;
                }
                merge(array,left,right,mid);
            }
            gap *= 2;
        }
    }

归并排序特点总结 :

1. 归并的缺点在于需要O(N)的空间复杂度,也就是说,申请的临时数组和原数组一模一样,归并排序的思考更多的是解决在磁盘中的外排序问题。

2. 时间复杂度:O(N*logN), 其中N是待排序序列的长度。

3. 空间复杂度:O(N),主要用于存储临时的合并结果。在归并排序过程中,需要额外的O(N)的空间来存储合并操作的中间结果,因此相对于快速排序等原地排序算法,归并排序的空间复杂度较高。

4. 稳定性:归并排序的优点是具有稳定性,相同元素的相对顺序在排序前后保持不变,即它本身就是稳定的。

你可能会说,我们之前写的代码是稳定的:

        while(s1 <= mid && s2 <= right){
            if(array[s1]<=array[s2]){
                tmp[k++] = array[s1++];
            } else{
                tmp[k++]=array[s2++];
            }
        }

可以等价于:

        while (s1 <= mid && s2 <= right) {
            if(array[s2] <= array[s1]) {
                tmpArr[k++] = array[s2++];
            }else {
                tmpArr[k++] = array[s1++];
            }
        }

这样就不稳定了,因为“<=”,但是你把“=”去掉就又稳定了。

稳定的排序可以变成不稳定的,而不稳定的排序不可能变为稳定。所以规定排序本身就是一个稳定的排序。 

归并排序的缺点在于对于大规模数据集来说,需要较多的额外空间,并且在实际应用中,归并排序更多地被用于解决外部排序问题,例如在磁盘上排序大型文件时。对于内存受限的情况,归并排序可能不是最优选择。

总结起来,归并排序是一种有效的排序算法,具有稳定性和较好的时间复杂度。它通过分治法将问题划分为更小的子问题,然后合并得到最终的有序结果。然而,归并排序的空间复杂度较高,需要额外的O(N)空间来存储合并结果。对于内存受限的情况,可能需要考虑其他排序算法。

海量数据的排序问题

对于海量数据的排序问题,当数据量超过内存可容纳的限制时,需要采用外部排序算法来处理,而归并排序是最常用的外部排序。

外部排序:外部排序是指排序过程需要在磁盘等外部存储进行的排序,以解决内存不足的问题。

假设我们有内存大小为1GB(即约1024MB),而待排序的数据总大小为100GB。这时我们可以采用归并排序作为外部排序的算法,因为归并排序适用于将已经有序的子序列合并成一个完全有序的序列。

下面是一种解决海量数据排序问题的归并排序的具体步骤:

1. 将待排序的数据文件切分成多个小文件。在这个例子中,将100GB的数据文件分割成200个大小为512MB的小文件。这样做是为了确保每个小文件的大小在内存可容纳的范围内,方便进行内存内的排序操作。

2. 对每个小文件进行内部排序。由于每个小文件的大小已经小于内存的容量,可以使用任何适合的内部排序算法(如快速排序或堆排序)对每个小文件进行排序,得到200个有序的小文件。

3. 进行两两归并。使用两路归并算法,同时对这200个有序的小文件进行归并操作。每次从200个小文件中各取一个元素进行比较,并将较小的元素输出到一个新的有序文件中。这样逐步将小文件合并成更大的有序文件,直到最终得到一个完全有序的结果文件。

注意,你需要区分好数据和文件。我们每次从两个红色小文件中分别读取一个数据,把小的数据放到下面紫色框框的文件中,因为两个红色小文件中都已经是有序的数据,最后面按这种方式读取到紫色框的文件中的数据,也会是有序的,并且是1G的有序数据。

通过以上步骤,我们可以在限制内存容量的情况下完成对海量数据的排序。这种外部排序的方法充分利用了磁盘等外部存储空间,并通过归并操作逐步将小文件合并成更大的有序文件,最终得到完全有序的结果。

需要注意的是,外部排序的性能取决于磁盘的读写速度,因此在实际应用中,可能还需要考虑磁盘的IO优化、多路归并等技术来提高排序的效率。

排序算法复杂度及稳定性分析

 我们把所有的方法的运行时间都一起运算一遍对比一下:

        public static void main(String[] args) {
            int[] array = new int[10000];
            orderArray(array);
            System.out.println("顺序的:");
            testInsertSort(array);
            testShellSort(array);
            testSelectSort(array);
            testHeapSort(array);
            testbubbleSort(array);
            testQuickSort(array);
            testMergeSort(array);
            int[] array2 = new int[10000];
            notOrderArray(array2);//逆序的
            System.out.println("逆序的:");
            testInsertSort(array2);
            testShellSort(array2);
            testSelectSort(array2);
            testHeapSort(array2);           
            testbubbleSort(array2);
            testQuickSort(array2);
            testMergeSort(array2);
            int[] array3 = new int[10000];
            notOrderArrayRandom(array3);//乱序的
            System.out.println("乱序的:");
            testInsertSort(array3);
            testShellSort(array3);
            testSelectSort(array3);
            testHeapSort(array3);
            testbubbleSort(array3);
            testQuickSort(array3);
            testMergeSort(array3);
        }

其他非基于比较排序(了解)

1、计数排序

计数排序(Counting Sort)是一种非比较排序算法,它的基本思想是通过统计元素出现的次数来确定每个元素在排序后的序列中的位置。计数排序适用于一定范围内的整数或者可以映射到整数的数据。计数排序用于对一定范围内的整数进行排序。它的思想基于鸽巢原理(Pigeonhole Principle),并且可以看作是对哈希直接定址法的变形应用。

下面是计数排序的详细步骤:

1. 统计相同元素出现次数:遍历待排序的序列,统计每个元素出现的次数,并将统计结果存储在一个额外的计数数组中。计数数组的索引对应于待排序元素的取值范围,计数数组的值表示对应元素出现的次数。

2. 计算元素位置:根据统计结果,计算每个元素在排序后的序列中的位置。可以通过累加前面出现次数的方式,确定每个元素在排序后的序列中的起始位置。

3. 回收到原序列中:根据计算得到的位置信息,将元素回收到原来的序列中。回收的过程需要考虑相同元素的情况,确保相同元素的顺序不变。

计数排序的时间复杂度为O(n+k),其中n是待排序序列的长度,k是待排序序列中的元素范围。计数排序的空间复杂度为O(n+k),需要额外的计数数组来存储统计结果。

计数排序的特点是稳定性好,适用于待排序序列中元素范围较小的情况。然而,计数排序需要额外的存储空间来存储计数数组,当元素范围较大时,可能会导致空间的浪费。

以下是一个示例,说明计数排序的过程:

待排序序列:[4, 2, 2, 8, 3, 3, 1]
元素范围:1-8

1. 统计相同元素出现次数:
计数数组:[1, 2, 2, 2, 1, 0, 0, 1]

2. 计算元素位置:
累加计数数组:[1, 3, 5, 7, 8, 8, 8, 9]

3. 回收到原序列中:
排序后序列:[1, 2, 2, 3, 3, 4, 8]

在上述示例中,计数排序首先统计了每个元素的出现次数,然后计算每个元素在排序后序列中的位置。最后将元素回收到原序列中,得到排序结果[1, 2, 2, 3, 3, 4, 8]。注意到元素2在待排序序列中出现两次,在排序后的序列中,元素2的顺序保持不变,这体现了计数排序的稳定性。

public class CountingSort {
    public static void countingSort(int[] arr) {
        // 寻找序列的最大值和最小值
        int minVal = Integer.MAX_VALUE;
        int maxVal = Integer.MIN_VALUE;
        for (int num : arr) {
            if (num < minVal)
                minVal = num;
            if (num > maxVal)
                maxVal = num;
        }
        //申请多大的数组好?
        // 创建计数数组,并初始化为0
        int range = maxVal - minVal + 1;
        int[] count = new int[range];
        
        // 统计每个元素出现的次数
        for (int num : arr) {
            count[num - minVal]++;
        }
        
        // 根据统计结果将元素回收到原来的序列中
        int index = 0;
        for (int i = 0; i < range; i++) {
            while (count[i] > 0) {
                arr[index] = i + minVal;
                count[i]--;
                index++;
            }
        }
    }
    
    public static void main(String[] args) {
        int[] arr = {4, 2, 2, 8, 3, 3, 1};
        
        countingSort(arr);
        
        // 输出排序后的结果
        for (int num : arr) {
            System.out.print(num + " ");
        }
    }
}

计数排序的特性总结如下:

1. 适用范围和场景有限:计数排序适用于元素范围比较小且已知的情况。当待排序序列中的元素范围较大时,可能会导致计数数组过大,造成空间的浪费。

2. 时间复杂度:计数排序的时间复杂度为O(MAX(N, 范围)),其中N是待排序序列的长度,范围是待排序元素的取值范围。计数排序的时间复杂度与待排序序列的长度和元素范围有关。

3. 空间复杂度:计数排序的空间复杂度为O(范围),需要额外的计数数组来存储统计结果。空间复杂度与待排序元素的范围相关。

4. 稳定性:计数排序是一种稳定的排序算法,即相同元素的顺序在排序后保持不变。

注意,这里要保持稳定性,一定要再遍历一遍整个数组:

总结起来,计数排序在元素范围集中且已知时,具有高效的特性。它的时间复杂度取决于待排序序列的长度和元素范围,空间复杂度取决于元素范围。计数排序是一种稳定的排序算法,适用于需要保持相同元素顺序的场景。

2、基数排序

基数排序是一种非比较排序算法,它根据元素的位数逐个将待排序的数字按照位数上的值进行排序。基数排序的基本思想是将整数按照个位、十位、百位等位置的值进行排序,直到最高位排序完成为止。每个位数的排序都是利用稳定的排序算法(如计数排序或桶排序)来完成的。

 

也就是说,我们先使个位有序,然后十位和个位有序,然后百位、十位、个位有序……

进出队列的次数是由待排序序列的最大的数字的位数决定的。 

以下是基数排序的具体步骤:

1. 找到待排序数组中最大的数,确定最大数的位数,假设为d。

2. 对每一位(个位、十位、百位等),使用稳定的排序算法对待排序数组进行排序。通常情况下,计数排序是常用的稳定排序算法。

3. 重复第2步,直到对所有位数排序完成。

4. 最后得到的数组即为排序后的结果。

基数排序的特点包括:

1. 稳定性:基数排序是一种稳定的排序算法,不会改变相同键值元素的相对顺序。

2. 对数据范围要求低:基数排序适用于整数或字符串等类型的数据。不像比较排序算法,它不需要直接比较元素的大小,因此对数据的范围要求较低。

3. 需要额外的空间:基数排序需要额外的空间来存储中间结果,因此它的空间复杂度较高。

4. 时间复杂度:基数排序的时间复杂度是O(d*(n+b)),其中d是最大数的位数,n是待排序数组的长度,b是基数的大小。通常情况下,基数排序的时间复杂度较低。

import java.util.Arrays;

public class RadixSort {
    public static void radixSort(int[] array) {
        if (array == null || array.length == 0) {
            return;
        }

        // 找到最大数确定位数
        int max = Arrays.stream(array).max().getAsInt();
        int digit = 1;
        while (max / 10 > 0) {
            max /= 10;
            digit++;
        }

        int[][] buckets = new int[10][array.length]; // 创建10个桶,每个桶存放对应位数上的数字
        int[] bucketSizes = new int[10]; // 记录每个桶的元素个数

        for (int i = 1; i <= digit; i++) {
            // 分配元素到各个桶
            for (int j = 0; j < array.length; j++) {
                int num = (array[j] / (int) Math.pow(10, i - 1)) % 10;
                buckets[num][bucketSizes[num]] = array[j];
                bucketSizes[num]++;
            }

            // 按照桶的顺序收集元素
            int k = 0;
            for (int j = 0; j < 10; j++) {
                if (bucketSizes[j] != 0) {
                    for (int l = 0; l < bucketSizes[j]; l++) {
                        array[k++] = buckets[j][l];
                    }
                    bucketSizes[j] = 0; // 清空桶中的元素个数
                }
            }
        }
    }

    public static void main(String[] args) {
        int[] array = { 170, 45, 75, 90, 802, 24, 2, 66 };
        radixSort(array);
        System.out.println(Arrays.toString(array));
    }
}

3、桶排序

桶排序是一种分布式排序算法,它将待排序的元素分到有限数量的桶中,对每个桶中的元素进行排序,然后按照桶的顺序将各个桶中的元素依次取出,即可得到排序后的结果。桶排序的基本思想是将元素分散到不同的桶中,每个桶内部使用其他排序算法(如插入排序或快速排序)对元素进行排序。

以下是桶排序的具体步骤:

1. 确定桶的数量和范围:根据待排序数组的特点和分布情况,确定需要的桶的数量。将待排序的元素分散到不同的桶中,可以根据元素的大小范围将桶的大小设置为合适的值。

2. 将待排序数组中的元素分配到各个桶中:遍历待排序数组,根据元素的大小将其分配到对应的桶中。可以使用简单的映射函数,将元素映射到桶的索引上。

3. 对每个非空桶中的元素进行排序:对每个非空桶中的元素使用其他排序算法进行排序,可以选择插入排序、快速排序等。如果桶的数量较少,也可以使用递归地应用桶排序来进行排序。

4. 按照桶的顺序依次取出各个桶中的元素:按照桶的顺序将每个非空桶中的元素取出,并放入最终的排序结果数组中。

桶排序的特点包括:

1. 适用于均匀分布的数据:桶排序在数据分布较均匀的情况下效果较好,可以使每个桶中的元素数量尽量接近,减少后续排序的时间。

当输入的数据可以均匀的分配到每一个桶中,最快

当输入的数据被分配到了同一个桶中,最慢

2. 需要额外的空间:桶排序需要额外的空间来存储桶和分配元素,因此它的空间复杂度较高。

3. 对数据范围要求较高:桶排序对数据的范围要求较高,需要事先确定数据的范围并设置合适的桶的数量和大小。

4. 时间复杂度:桶排序的时间复杂度主要取决于对每个非空桶中元素的排序算法的复杂度。如果桶的数量接近待排序数组的长度,那么桶排序的时间复杂度将接近O(nlogn)。

import java.util.ArrayList;
import java.util.Collections;

public class BucketSort {
    public static void bucketSort(int[] array) {
        if (array == null || array.length == 0) {
            return;
        }

        int min = array[0];
        int max = array[0];

        // 找到数组中的最小值和最大值
        for (int i = 1; i < array.length; i++) {
            if (array[i] < min) {
                min = array[i];
            } else if (array[i] > max) {
                max = array[i];
            }
        }

        // 计算桶的数量
        int bucketSize = (max - min) / array.length + 1;
        ArrayList<ArrayList<Integer>> buckets = new ArrayList<>(bucketSize);

        // 初始化桶
        for (int i = 0; i < bucketSize; i++) {
            buckets.add(new ArrayList<>());
        }

        // 将元素分配到桶中
        for (int i = 0; i < array.length; i++) {
            int bucketIndex = (array[i] - min) / array.length;
            buckets.get(bucketIndex).add(array[i]);
        }

        // 对每个非空桶中的元素进行排序
        for (ArrayList<Integer> bucket : buckets) {
            if (!bucket.isEmpty()) {
                Collections.sort(bucket);
            }
        }

        // 将排序后的元素依次放入结果数组
        int index = 0;
        for (ArrayList<Integer> bucket : buckets) {
            for (int num : bucket) {
                array[index++] = num;
            }
        }
    }

    public static void main(String[] args) {
        int[] array = { 170, 45, 75, 90, 802, 24, 2, 66 };
        bucketSort(array);
        for (int num : array) {
            System.out.print(num + " ");
        }
    }
}

在上述代码中,我们首先找到待排序数组中的最小值和最大值,并计算出桶的数量。然后创建一个ArrayList的数组来表示桶,每个桶都是一个ArrayList。接下来,我们将待排序数组中的元素分配到对应的桶中。然后,对每个非空桶中的元素进行排序,这里使用了Collections类的sort方法。最后,我们按照桶的顺序依次取出元素,并放入最终的排序结果数组中。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值