Java中经典排序算法

经典排序算法

排序算法有很多,但最经典最常用的排序算法逃不过:冒泡排序、插入排序、选择排序、快速排序、归并排序、计数排序、基数排序和桶排序。

1 衡量排序算法好坏的三方面

1.1 执行效率

  • 最好情况、最坏情况、平均情况时间复杂度
  • 时间复杂度的系数、常数和低阶(在数据规模小的时候,往往需要考虑)
  • 比较次数和交换或移动次数

1.2 内存消耗

针对排序算法而言,内存消耗即空间复杂度。空间复杂度为O(1)的排序算法也被称为原地排序算法。

1.3 稳定性

如果待排序的序列中存在值相等的元素,经过排序之后,相等元素之间原有的先后顺序不变,即是稳定的排序算法

2 有序度、逆序度、满有序度

有序元素对:a[i] <= a[j],如果i<j,那么(a[i],a[j])是一个有序对。
    
如2,1,3,4按从小到大排序,有序元素对为(1,3),(1,4),(3,4),(2,3),(2,4),有序度为5。
同理,逆序元素对的个数为(2,1),逆序度为1。 

满有序度:完全有序的数组的有序度,如一个数组有n个元素,那么两两组成的数对都是有序的。即满有序度 = 有序度 = n*(n-1)/2。

所以,满有序度 = 有序度 + 逆序度

3 冒泡排序

基本原理:每次冒泡操作都会对相邻的两个元素进行比较,看是否满足大小关系要求。如果不满足就让他俩互换(有序度+1)。一次冒泡会让至少一个元素移动到它应该在的位置,重复n-1次,就完成了n个数据的排序工作。

冒泡优化:当某次冒泡操作已经没有数据交换时,说明已经达到完全有序,不用在继续后续的冒泡操作。

    // 冒泡排序,从小到大排序
    public void bubbleSort(int[] a) {
        int n = a.length;
        if (n <= 1) {
            return;
        }
        // 循环n次
        for (int i = 0; i < n; ++i) {
            // 数据交换标志位
            boolean flag = false;
            // 第(n-i-1)及其之后的元素已经有序 
            for (int j = 0; j < n - i - 1; ++j) {
                if (a[j] > a[j + 1]) {
                    int temp = a[j];
                    a[j] = a[j + 1];
                    a[j + 1] = temp;
                    flag = true;
                }
            }
            if (!flag) {
                break;
            }
        }
    }

冒泡排序特点:

  • 原地排序:空间复杂度O(1)

  • 稳定排序:

  • 最好时间复杂度O(n):原数据已经全部有序,只需进行一次冒泡,如[1,2,3,4,5,6,7,8]。

  • 最坏时间复杂度O(n²):原数据刚好是倒序,需进行n次冒泡,如[8,7,6,5,4,3,2,1]。

  • 平均时间复杂度O(n²):对包含n个元素的数组进行冒泡排序,最坏情况下初始状态有序度是0,需要进行n(n-1)/2次交换。最好情况下,初始状态有序度为满有序度,即n(n-1)/2,无需进行交换。平均情况下,有序度取中间值为n(n-1)/4,表示初始有序的的平均情况。也就是平均情况下需要n(n-1)/4次交换操作,比较操作肯定要比交换操作多,而这个复杂度的上限是O(n²),所以可粗略地认为冒泡排序平均情况下时间复杂度是O(n²)。

4 插入排序

基本原理:动态地往有序集合中添加数据。将数组分成已排序区未排序区,初始已排序区只有一个元素,就是数组的第一个元素。插入排序取未排序区间中的元素,在已排序区间找到合适的插入位置将其插入,并保证已排序区间数据一直有序。重复这个过程,直到未排序区间中元素为空,算法结束。

    // 插入排序,从小到大排序
	public static void insertionSort(int[] a){
        int n = a.length;
        if (n <= 1) {
            return;
        }
        for (int i = 1; i < n; ++i){
            int value = a[i];
            int j = i-1;
            // 查找插入位置
            for(; j >= 0; --j){
                if (a[j] > value){
                    // 数据移动
                    a[j+1] = a[j];
                }else{
                    break;
                }
            }
            a[j+1] = value;
        }
    }

插入排序特点:

  • 原地排序:空间复杂度O(1)
  • 稳定排序:
  • 最好时间复杂度O(n):原数据已经全部有序,只需遍历一遍,不需要移动数据,如[1,2,3,4,5,6,7,8]。
  • 最坏时间复杂度O(n²):原数据刚好是倒序,每一次插入都需移动大量数据,如[8,7,6,5,4,3,2,1]。
  • 平均时间复杂度O(n²):在有序数组中插入一个数据的平均时间复杂度为O(n),插入排序每次插入操作都相当于在有序数组中插入一个数据,循环执行n次插入操作,所以平均时间复杂度是O(n²)

5 选择排序

基本原理:选择排序与插入排序一样,会将数组分为已排序区未排序区,但选择排序每次会从未排序区间中找最小的元素(如果是从小到大排序),将其放到已排序区间的末尾。

    // 选择排序,从小到大排序
    public static void selectionSort(int[] a) {
        int n = a.length;
        if (n <= 1) {
            return;
        }
        for (int i = 0; i < n - 1; ++i) {
            int k = i;
            // 查找未排序区的最小值的下标
            for (int j = i + 1; j < n; ++j) {
                if (a[j] < a[i]) {
                    k = j;
                }
            }
            // 将最小值与未排序区的第一个值交换(即放到已排序区末尾)
            if (k != i) {
                int temp = a[i];
                a[i] = a[k];
                a[k] = temp;
            }
        }
    }

选择排序特点:

  • 原地排序:空间复杂度O(1)
  • 不稳定排序:选择排序每次都要找剩余未排序元素中的最小值,并和前面的元素交换位置,这样会破坏稳定性。如[3(A),2,3(B),1],在第一遍检索到最小值[1],并且与[3(A)]互换位置,此时为[1,2,3(B),3(A)],稳定性被破坏。
  • 最好、最坏、平均时间复杂度O(n²):就算原数据全部有序,也得遍历n边,找n遍的最小值,只是少了交换的过程,所以最好、最坏、平均时间复杂度均为O(n²)

冒泡VS插入VS选择

因为选择排序是不稳定排序,所以相比冒泡和插入逊色。

冒泡排序不管怎么优化,元素交换的次数是一个固定值,即原始数据的逆序度。

插入排序不管怎么优化,元素的移动次数也等于原始数据的逆序度。

但从代码实现上看,冒泡排序的数据交换比插入排序的数据移动要复杂,冒泡需要三个赋值操作,插入排序只需要一个。

所以在数据规模大的时候,插入排序还是会优于冒泡排序。

6 归并排序

基本原理:如果要排序一个数组,先把数组从中间分成前后两部分,然后对前后两部分分别排序,再将排好序的两部分合并在一起,这样整个数组就都有序了。(分解–>合并,其实就是分治思想,分而治之,将大问题分解成小子问题来解决,小的子问题解决了,大问题也就解决了。而分治思想可以用递归去实现)

写递归代码的技巧:分析得出递推公式,然后找到终止条件,最后将递推公式翻译成递归代码。

归并排序的递推公式:
merge_sort(p...r) = merge(merge_sort(p...q),merge_sort(q+1...r))
终止条件:
p >= r,不能在继续分解
    // 归并排序,从小到大排序
	// 递归调用时或者多次循环里不要有申请大数组的操作,否则会导致超时
    // 所以临时数组tmp,外层传入更合适
	public static void mergeSort(int[] a, int[] tmp, int left, int right) {
        // 递归终止条件
        if (left >= right) {
            return;
        }
        // 取left到right的中间位置mid
        int mid = (left + right) / 2;
        // 分治递归
        mergeSort(a, tmp, left, mid);
        mergeSort(a, tmp, mid + 1, right);
        // 将a[left...mid]和a[mid+1...right]合并为a[left...right]
        merge(a, tmp, left, mid, right);
    }

    public static void merge(int[] a, int[] tmp, int left, int mid, int right) {
        int p1 = left;
        int p2 = mid + 1;
        int i = left;
        while (p1 <= mid && p2 <= right) {
            if (a[p1] <= a[p2]) {
                tmp[i++] = a[p1++];
            } else {
                tmp[i++] = a[p2++];
            }
        }
		// 如果第一个序列未检测完,就将第一个序列剩余部分直接拷贝到合并的序列中
        while (p1 <= mid) {
            tmp[i++] = a[p1++];
        }
         // 如果第二个序列未检测完,就将第二个序列剩余部分直接拷贝到合并的序列中
        while (p2 <= right) {
            tmp[i++] = a[p2++];
        }
        // 将tmp中的数组拷贝回a[left...right]
        for (i = left; i <= right; ++i) {
            a[left++] = tmp[i];
        }
    }

归并排序特点:

非原地排序:空间复杂度O(n)。

稳定排序:在合并的过程中,如果a[left…mid],a[mid+1…right]之间有相同的元素,我们只要先把a[left…mid]中的元素放tmp数组。即可保证排序稳定。

时间复杂度:最好、最坏、平均时间复杂度均为O(nlogn)

递归的适用场景时,一个问题a可以分解为多个子问题b、c,那求解问题a就可以分解为求解问题b、c。问题b、c解决后,我们再把b、c的结果合并成a的结果。

如果我们定义求解问题a的时间是T(a),求解问题b、c的时间是T(b)、T©,那么我们可以得到这样的递推关系式:

T(a)=T(b)+T©+k,其中k为将子问题b、c的结果合并成问题a的结果所消耗的时间。

假设对n个元素进行归并排序需要的时间是T(n),那分解成两个子数组排序的时间都是T(n/2)。而merger()函数的时间复杂度显而易见是O(n),所以归并排序的时间复杂度计算公式为:

T(1) = C; n = 1时,只需要常量级的执行时间。
T(n) = 2*T(n/2) + n; n>1

通过递推公式,求解T(n)

T(n) = 2*T(n/2) + n
	 = 2*(2*T(n/4) + n/2) + n = 4*T(n/4) + 2*n
	 = 4*(T(n/8) + n/4) + 2*n = 8*T(n/8) + 3*n
	 ...
	 = 2^k * T(n/2^k) + k*n
当T(n/2^k) = T(1)时,也就是n/2^k = 1,即k=logn。
则T(n) = Cn + nlogn
用大O标记法来表示的话,T(n)就等于O(nlogn)。

7 快速排序

基本原理:如果要排序数组是下标从p到r之间的一组数据,我们选择p到r之间的任意一个数据作为pivot(分区点)。我们遍历p到r之间的数据,将小于pivot的放在左边,将大于pivot的放在右边,将pivot放到中间。经过这一步骤之后,数组p到r之间的数据就被分成了三部分,前面p到q-1之间都是小于pivot的,中间是pivot,后面的q+1到r之间是大于pivot的。根据分治、递归的处理思想,我们可以用递归排序下标从p到q-1之间的数据和下标从q到r之间的数据,直到区间缩小为1,就说明所有数据都有序了。

快排的递推公式:
quick_sort(p...r) = quick_sort(p...q-1) + quick_sort(q+1...r)
终止条件:
p >= r,不能在继续分解
    public static void quickSort(int[] a, int left, int right) {
        if (left >= right) {
            return;
        }
        // 获取分区点
        int q = partition(a, left, right);
        quickSort(a, left, q - 1);
        quickSort(a, q + 1, right);

    }

    // 分区函数
    public static int partition(int[] a, int left, int right) {
        int pivot = a[right];
        int i = left;
        for (int j = left; j <= right - 1; ++j) {
            if (a[j] < pivot) {
                int tmp = a[i];
                a[i] = a[j];
                a[j] = tmp;
                i++;
            }
        }
        int tmp = a[i];
        a[i] = a[right];
        a[right] = tmp;
        return i;
    }

分区过程示意图:

在这里插入图片描述

快速排序特点:

原地排序:空间复杂度O(1)。

不稳定排序:分区过程中,相对顺序可能会改变。

时间复杂度:最好、平均复杂度为O(nlogn),分析过程与归并相同。

T(1) = C; n = 1时,只需要常量级的执行时间。
T(n) = 2*T(n/2) + n; n>1

上诉公式成立的前提是每次分区操作,我们选择的pivot都很合适,正好能将大区间对等的一分为二,但实际上这种情况很难达到。

极端例子1:[1,3,5,8,9],数组中原来的数据已经是有序的,如果我们每次选最后一个元素作为pivot,那么每次分区得到的两个取件都是不均等。我们大约需要n次分区操作,才能完成快排的整个过程,每次分区我们平均都要扫描n/2个元素,这种情况下,快排的时间复杂度就会退化成O(n²)。这就是快排的最坏时间复杂度。

归并VS快排

归并排序的处理过程是由下到上的,先处理子问题,然后再合并。

快排的处理过程是由上到下的,先分区,然后在处理子问题。

归并是稳定的非原地排序。

快排是不稳定的原地排序。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值