(重学算法系列)---排序算法1

225 阅读10分钟

总体比较

排序方法与复杂度归类

几种最经典、最常用的排序方法:

  • 冒泡排序、
  • 插入排序、
  • 选择排序、
  • 快速排序、
  • 归并排序、
  • 计数排序、
  • 基数排序、
  • 桶排序。

复杂度归类

  • 冒泡排序、插入排序、选择排序 O(n^2)
  • 快速排序、归并排序 O(nlogn)
  • 计数排序、基数排序、桶排序 O(n)

如何分析一个 “ 排序算法 ” ?

  • 排序算法的执行效率

    1. 最好情况、最坏情况、平均情况时间复杂度
    2. 时间复杂度的系数、常数 、低阶
    3. 比较次数和交换(或移动)次数
  • 排序算法的内存消耗

  • 排序算法的稳定性

    • 这个概念是说,如果待排序的序列中存在 值相等的元素,经过排序之后,相等元素之间原有的先后顺序不变,稳定排序算法可以保持金额相同的两个对象,在排序之后的前后顺序不变。

冒泡排序( Bubble Sort )

特点:

  • 空间复杂度为 O(1) ,是一个原地排序算法
  • 冒泡排序是稳定的排序算法。
  • 最好情况时间复杂度是 O(n)
  • 最坏情况时间复杂度为O(n 2 )。

有序度

对于一个倒序排列的数组,比如6 , 5 , 4 , 3 , 2 , 1 ,有序度是 0 ;对于一个完全有序的数组,比如1 , 2 , 3 , 4 , 5 , 6,有序度就是 n*(n-1)/2,也就是 15。我 们把这种完全有序的数组的有序度叫作满有序度。

有序度就是 n(n-1)/2*

逆序度=满有序度-有序度。

示例代码

/**
 * 注意:冒泡排序是两两相邻的数据作比较
 */
public static class BubbleSort implements ISort {
    @Override
    public int[] sort(int[] origin) {
        // 循环对比origin.length次
        for (int i = 0; i < origin.length; i++) {
            // 两两对比 0-1,1-2,...n-1 - n;
            boolean changed = false;
            // 在排好的i个(-i)的0-(origin.length-1)两两对比
            for (int j = 0; j < origin.length - i - 1; j++) {
                if (origin[j] > origin[j + 1]) {
                    int temp = origin[j + 1];
                    origin[j + 1] = origin[j];
                    origin[j] = temp;
                    changed = true;
                }
                System.out.println("No." + (i + 1) + " :" + arrayToString(origin));
            }
            if (!changed) break;
        }
        return origin;
    }
}

插入排序 (Insert Sort)

排序思想:

首先,我们将数组中的数据分为两个区间,已排序区间和未排序区间。初始已排序区间只有一个元素,就是数组的第一个元素。插入算法的核心思想是取未排序 区间中的元素,在已排序区间中找到合适的插入位置将其插入,并保证已排序区间数据一直有序。重复这个过程,直到未排序区间中元素为空,算法结束

插入排序也包含两种操作,一种是元素的比较,一种是元素的移动。当我们需要将一个数据a插入到已排序区间时,需要拿a与已排序区间的元素依次比较大小, 找到合适的插入位置。找到插入点之后,我们还需要将插入点之后的元素顺序往后移动一位,这样才能腾出位置给元素 插入。

但对于一个给定的初始序列,移动操作的次数总是固定的,就等于逆序度。

冒泡排序的数据交换要比插入排序的数据移动要复杂,冒泡排序需要 3 个赋值操作,而插入排序只需要 1 个。

/**
 *  i为要插入的元素,从第1个开始,比较前面的元素,
 *  若当前为n个,就是比较0~n-1个前面的元素。一发现有比他小的,就先j到i间所有元素后移一位,再插入。
 *  - 原地排序算法.
 *  - 稳定的排序算法
 * 最好是时间复杂度为O(n)
 * 最坏情况时间复杂度为O(n^2)
 * 平均时间复杂度为O(n^2)。
 */
public class InsertSort implements ISort {
    @Override
    public int[] sort(int[] origin) {
        for (int i = 1; i < origin.length; i++) {
            int j = i - 1;
            int value = origin[i];
            for (; j >= 0; --j) {
                if (origin[j] > value) {
                    origin[j + 1] = origin[j];
                } else {
                    break;
                }
            }
            origin[j+1] = value;
        }
        return origin;
    }
}

选择排序( Selection Sort )

选择排序算法的实现思路有点类似插入排序,也分已排序区间和未排序区间。但是选择排序每次会从未排序区间中找到最小的元素,将其放到已排序区间的末 尾

使用选择排序算法来排序的话,第一次找到最小元素 2 ,与第一个 5 交换位置,那第一个 5 和中间的 5 顺序就变了,所以就不稳定 了。正是因此,相对于冒泡排序和插入排序,选择排序就稍微逊色了。

/**
 * 选择排序 :
 *    与插入排序有类似的地方,它把数据组分为两块,已经排好的区域和未排好的区域。
 *    初始时数据都看做是没有排好的,然后从数组中找到最小的数据下标,与第一个对换位置。
 *    每次跑完即确定了一个最小的数,直到完成。
 * - 原地排序
 * - 最好情况时间复杂度、最坏情况和平均情况时间复杂度都为O(n^2)
 * - 不稳定的排序
 */
public class SelectionSort implements ISort {
    @Override
    public int[] sort(int[] origin) {
        for (int i = 0; i < origin.length - 1; i++) {
            int min = i;
            for (int j = i + 1; j < origin.length; j++) {
                if (origin[j] < origin[min]) {
                    min = j;
                }
            }
            int temp = origin[i];
            origin[i] = origin[min];
            origin[min] = temp;
        }
        return origin;
    }
}

归并排序(Merge Sort)

分治算法一般都是用递归来实现的。分治是一种解决问题的处理思想,递归 是一种编程技巧,这两者并不冲突。

步骤:

  • 分析得出递推公式
  • 然后找到终止条件
  • 将递推公式翻译成递归代码

递推公式: merge_sort(p…r) = merge(merge_sort(p…q), merge_sort(q+1…r))

终止条件: p >= r 不用再继续分解

i++等于 i:=i+1

实现:

private void mergeArray(int[] data, int left, int right) {
        if (left >= right) {
            return;
        }
        int mid = left + (right - left) / 2;
        mergeArray(data, left, mid);
        mergeArray(data, mid + 1, right);
        mergeBySentry(data, left, mid, right);
    }

    /**
     * 哨兵思想
     *
     * @param data
     * @param left
     * @param mid
     * @param right
     */
    private void mergeBySentry(int[] data, int left, int mid, int right) {
        // 数组下标相差加 1 ,才为数组的长度。
        // 这里,插入了一个哨兵,故加 2
        int[] leftArr = new int[mid - left + 2];
        int[] rightArr = new int[(right - (mid + 1)) + 2];
        
        // 遍历数组 0 ~ N 有两种结束标志:
        // 1. i < len , 或 i <= len-1;
        // 2. i <= 下标差,或 i < 下标差加 1
        // 总的来说,相同条件下,< 比 <= 少遍历一次,故注意使用情景。
        for (int i = 0; i <= mid - left; i++) {
            leftArr[i] = data[left + i];
        }
        leftArr[mid - left + 1] = Integer.MAX_VALUE;

        for (int i = 0; i <= right - mid - 1; i++) {
            rightArr[i] = data[mid + 1 + i];
        }
        rightArr[right - mid] = Integer.MAX_VALUE;

        int st = left;
        int ls = 0;
        int rs = 0;
        while (st <= right) {
            /**
             * 这里的小于等于,等于不影响结果,但是左边的小数,排序后也应该放在左边,
             * 影响排序稳定性
             */
            if (leftArr[ls] <= rightArr[rs]) {
                data[st++] = leftArr[ls++];
            } else {
                data[st++] = rightArr[rs++];
            }
        }
    }


    private void merge(int[] data, int left, int mid, int right) {
        int l = left, r = mid + 1, len = right - left + 1;
        int k = 0;
        int[] temp = new int[len];
        while (l <= mid && r <= right) {
            if (data[l] < data[r]) {
                temp[k++] = data[l++];
            } else {
                temp[k++] = data[r++];
            }
        }

        int r_start = l;
        int r_end = mid;
        if (r <= right) {
            r_start = r;
            r_end = right;
        }

        while (r_start <= r_end) {
            temp[k++] = data[r_start++];
        }

        for (int i = 0; i < len; i++) {
            data[left + i] = temp[i];
        }
    }

快速排序算法( Quicksort )

总体思路

快排的思想是这样的:如果要排序数组中下标从 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+1 到 r 之间的数据,直到区间缩小为 1 ,就说明所有的数据都有序了。

原地分区函数的实现思路非常巧妙

partition(A, p, r) {
    pivot := A[r]
    i := p
    for j := p to r-1 do {
        if A[j] < pivot {
            swap A[i] with A[j]
            i := i+1
        }
    }
    swap A[i] with A[r]
return i

我们通过游标 i 把 A[p…r-1] 分成两部分。 A[p…i-1] 的元素都是小于 pivot 的,我们暂且叫它 “ 已处理区间 ” , A[i…r-1] 是 “ 未处理区 间 ” 。我们每次都从未处理的区间 A[i…r-1] 中取一个元素 A[j] ,与 pivot 对比,如果小于 pivot ,则将其加入到已处理区间的尾部,也就是 A[i] 的位置。

 private void quickSort(int[] origin, int left, int right) {
        if (left >= right) {
            return;
        }
        int pivot = partition(origin, left, right);
        quickSort(origin, left, pivot - 1);
        quickSort(origin, pivot + 1, right);
    }

    private int partition(int[] origin, int left, int right) {
        int pivot = origin[right];
        int j = left;
        for (int i = left; i < right; i++) {
            if (origin[i] < pivot) {
                if (i == j) {  // 优化一次交换操作。
                    j++;
                } else {
                    int temp = origin[j];
                    origin[j++] = origin[i];
                    origin[i] = temp;
                }
            }
        }

        int temp = origin[j];
        origin[j] = origin[right];
        origin[right] = temp;

        return j;
    }

快速排序的性能分析

如果数组中的数据原来已经是有序的了,比如 1 , 3 , 5 , 6 , 8 。如果我们每次选择最后一个元素作为 pivot ,那每次分区得到的两个区间 都是不均等的。我们需要进行大约 n 次分区操作,才能完成快排的整个过程。每次分区我们平均要扫描大约 n/2 个元素,这种情况下,快排的时间复杂度就 从O(nlogn)退化成了O(n 2 )。

思考小题

O(n)时间复杂度内求无序数组中的第K大元素。比如,4, 2, 5, 12, 3这样一组数据,第 3 大元素就是 4 。

普通解法

每次取数组中的最小值,将其移动到数组的最前面,然后在剩下的数组中继续找最小值,以此类推,执行 K 次,找到的数据不就
是第 K 大元素了吗?
不过,时间复杂度就并不是 O(n) 了,而是 O(K * n) 。

分区思想解法

我们选择数组区间 A[0…n-1] 的最后一个元素 A[n-1] 作为 pivot ,对数组 A[0…n-1] 原地分区,这样数组就分成了三部分, A[0…p-1] 、 A[p] 、 A[p+1…n-1] 。
如果 p+1=K ,那 A[p] 就是要求解的元素;如果 K>p+1, 说明第 K 大元素出现在 A[p+1…n-1] 区间,我们再按照上面的思路递归地在 A[p+1…n-1] 这个区间内查找。同
理,如果 K<p+1 ,那我们就在 A[0…p-1] 区间查找。

时间复杂度: 时间复杂度是 O(n)

第一次分区查找,我们需要对大小为 n 的数组执行分区操作,需要遍历 n 个元素。第二次分区查找,我们只需要对大小为 n/2 的数组执行分区操作,需要遍历 n/2 个元
素。依次类推,分区遍历元素的个数分别为、 n/2 、 n/4 、 n/8 、 n/16.…… 直到区间缩小为 1 。
如果我们把每次分区遍历的元素个数加起来,就是: n+n/2+n/4+n/8+…+1 。这是一个等比数列求和,最后的和等于 2n-1 。所以,上述解决思路的时间复杂度就
为 O(n) 。

小结

快速排序算法虽然最坏情况下的时间复杂度是O(n 2 ),但是平均情况下时间复杂度都是O(nlogn)。不仅如此,快速排序算法时间复杂度退化到O(n 2 )的概率非常小, 我们可以通过合理地选择 pivot 来避免这种情况

归并排序算法是一种在任何情况下时间复杂度都比较稳定的排序算法,这也使它存在致命的缺点,即归并排序不是原地排序算法,空间复杂度比较高,是 O(n) 。 正因为此,它也没有快排应用广泛。