Java-常用排序算法

232 阅读8分钟

排序算法,即通过特定的算法因式将一组或多组数据按照既定模式进行重新排序。这种新序列遵循着一定的规则,体现出一定的规律,因此,经处理后的数据便于筛选和计算,大大提高了计算效率。

排序算法作为最基础且最重要的算法之一,这篇文章为大家分享下几个最经典的、最常用的:冒泡排序、插入排序、选择排序、归并排序、快速排序、计数排序、基数排序、桶排序等

首先按照时间复杂度把它们分成了三类

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

概念

执行效率

对于排序算法执行效率的分析,我们一般会从这几个方面来衡量:

  • 最好情况、最坏情况、平均情况时间复杂度
  • 排序的数据大小
  • 比较次数和交换(或移动)次数。

内存消耗

算法的内存消耗可以通过空间复杂度来衡量,不过针对排序算法的空间复杂度,我们还引入了一个新的概念,原地排序(Sorted in place)。 原地排序算法,就是特指空间复杂度是 O(1) 的排序算法

稳定性

针对排序算法,我们还有一个 重要的度量指标,稳定性。如果待排序的序列中存在值相等的元素,经过排序之后,相等元素之间原有的先后顺序不变。

比如我们有一组数据 2,9,3,4,8,3,按照大小排序之后 就是 2,3,3,4,8,9。

如果两个3的前后顺序没有改变,就把这种排序算法叫作稳定的排序算法;如果前后顺序发生变化叫作不稳定的排序算法

在讲排序的时候,都是用整数来举例,但在真正软件开发中,我们要排序的往往不是单纯的整数,而是一组对象,我们需要按照对象的某个 key 来排序。稳定排序算法可以保持key相同的两个对象,在排序之后的前后顺序不变。

排序算法

冒泡排序(Bubble Sort)

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

我们要对一组数据 4,5,6,3,2,1,从小到到大进行排序。第一次冒泡操作的详细过程就是这样

image.png

经过一次冒泡操作之后,6 这个元素已经存储在正确的位置上。要想完成所有数据的排序,我们只要进行 6 次这样的冒泡操作就行了

image.png

640.gif

// 冒泡排序,a 表示数组,n 表示数组大小
public void bubbleSort(int[] a, int n) {
    if (n <= 1) return;
    for (int i = 0; i < n; ++i) {
        // 提前退出冒泡循环的标志位
        boolean flag = false;
        for (int j = 0; j < n - i - 1; ++j) {
            if (a[j] > a[j+1]) { // 交换
                int tmp = a[j];
                a[j] = a[j+1];
                a[j+1] = tmp;
                flag = true; // 表示有数据交换 
            }
        }
        if (!flag) break; // 没有数据交换,提前退出
    }
}
  • 冒泡的过程只涉及相邻数据的交换操作,只需要常量级的临时空间,所以它的空间复杂度为 O(1)O(1),是一个原地排序算法
  • 当有相邻的两个元素大小相等的时候,我们不做交换,相同大小的数据在排序前后不会改变顺序,所以冒泡排序是稳定的排序算法
  • 最好情况下,要排序的数据已经是有序的了,我们只需要进行一次冒泡操作,就可以结束了,所以最好情况时间复杂度是 O(n)O(n)。而最坏的情况是,要排序的数据刚好是倒序排列的,我们需要进行 n 次冒泡操作,所以最坏情况时间复杂度为 O(n2)O(n^2)

插入排序(Insertion Sort)

一个有序的数组,我们往里面添加一个新的数据后,如何继续保持数据有序呢?很简单,我们只要遍历数组,找到数据应该插入的位置将其插入即可。

image.png

这是一个动态排序的过程,即动态地往有序集合中添加数据,我们可以通过这种方法保持集合中的数据一直有序。而对于一组静态数据,我们也可以借鉴上面讲的插入方法,来进行排序,于是就有了插入排序算法

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

如图所示,要排序的数据是 4,5,6,1,3,2,其中左侧为已排序区间,右侧是未排序区间。

image.png

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

640 (2).gif

// 插入排序,a 表示数组,n 表示数组大小
public void insertionSort(int[] a, int n) {
    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(1),是一个原地排序算法。
  • 在插入排序中,对于值相同的元素,我们可以选择将后面出现的元素,插入到前面出现元素的后面,这样就可以保持原有的前后顺序不变,所以插入排序是稳定的排序算法。
  • 如果要排序的数据已经是有序的,我们并不需要搬移任何数据。如果我们从尾到头在有序数据组里面查找插入位置, 最好是时间复杂度为O(n)O(n)。 如果数组是倒序的,对于插入排序来说,每次插入操作都相当于在数组中插入一个数据,循环执行 n 次插入操作,所以最坏的时间复杂度为 O(n2)O(n^2)

选择排序(Selection Sort)

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

image.png

640.gif

// 选择排序,a 表示数组,n 表示数组大小
public static void selectSort(int[] arr, int n) {
    for (int i = 0; i < n - 1; i++) {
        int minIndex = i; // 用来记录最小值的索引位置,默认值为i
        for (int j = i + 1; j < n; j++) {
            if (arr[j] < arr[minIndex]) {
                minIndex = j; // 遍历 i+1~length 的值,找到其中最小值的位置
            }
        }
        // 交换当前索引 i 和最小值索引 minIndex 两处的值
        if (i != minIndex) {
            int temp = arr[i];
            arr[i] = arr[minIndex];
            arr[minIndex] = temp;
        }
        // 执行完一次循环,当前索引 i 处的值为最小值,直到循环结束即可完成排序
    }
}
  • 选择排序空间复杂度为 O(1)O(1),是一种原地排序算法。
  • 选择排序的最好情况时间复杂度、最坏情况和平均情况时间复杂度都为 O(n2)O(n^2)
  • 选择排序是一种不稳定的排序算法。选择排序每次都要找剩余未排序元素中的最小值,并和前面的元素交换位置,这样破坏了稳定性。 比如 5,8,5,2,9 这样一组数据,使用选择排序算法来排序的话,第一次找到最小元素 2,与第一个 5 交换位置,那第一个 5 和中间的 5 顺序就变了,所以就不稳定了

归并排序(Merge Sort)

如果要排序一个数组,我们先把数组从中间分成前后两 部分,然后对前后两部分分别排序,再将排好序的两部分合并在一起,这样整个数组就都有序了。

image.png 归并排序使用的就是分治思想。将一个大问题分解成小的子问题来解决。分治算法一般都是用递归来实现的。

640 (3).gif

public class MergeSort {

  // 归并排序算法, a是数组,n表示数组大小
  public static void mergeSort(int[] a, int n) {
    mergeSortInternally(a, 0, n-1);
  }

  // 递归调用函数
  private static void mergeSortInternally(int[] a, int p, int r) {
    // 递归终止条件
    if (p >= r) return;

    // 取p到r之间的中间位置q,防止(p+r)的和超过int类型最大值
    int q = p + (r - p)/2;
    // 分治递归
    mergeSortInternally(a, p, q);
    mergeSortInternally(a, q+1, r);

    // 将A[p...q]A[q+1...r]合并为A[p...r]
    merge(a, p, q, r);
  }

  private static void merge(int[] a, int p, int q, int r) {
    int i = p;
    int j = q+1;
    int k = 0; // 初始化变量i, j, k
    int[] tmp = new int[r-p+1]; // 申请一个大小跟a[p...r]一样的临时数组
    while (i<=q && j<=r) {
      if (a[i] <= a[j]) {
        tmp[k++] = a[i++]; // i++等于i:=i+1
      } else {
        tmp[k++] = a[j++];
      }
    }

    // 判断哪个子数组中有剩余的数据
    int start = i;
    int end = q;
    if (j <= r) {
      start = j;
      end = r;
    }

    // 将剩余的数据拷贝到临时数组tmp
    while (start <= end) {
      tmp[k++] = a[start++];
    }

    // 将tmp中的数组拷贝回a[p...r]
    for (i = 0; i <= r-p; ++i) {
      a[p+i] = tmp[i];
    }
  }

  /**
   * 合并(哨兵)
   */
  private static void mergeBySentry(int[] arr, int p, int q, int r) {
    int[] leftArr = new int[q - p + 2];
    int[] rightArr = new int[r - q + 1];

    for (int i = 0; i <= q - p; i++) {
      leftArr[i] = arr[p + i];
    }
    // 第一个数组添加哨兵(最大值)
    leftArr[q - p + 1] = Integer.MAX_VALUE;

    for (int i = 0; i < r - q; i++) {
      rightArr[i] = arr[q + 1 + i];
    }
    // 第二个数组添加哨兵(最大值)
    rightArr[r-q] = Integer.MAX_VALUE;

    int i = 0;
    int j = 0;
    int k = p;
    while (k <= r) {
      // 当左边数组到达哨兵值时,i不再增加,直到右边数组读取完剩余值,同理右边数组也一样
      if (leftArr[i] <= rightArr[j]) {
        arr[k++] = leftArr[i++];
      } else {
        arr[k++] = rightArr[j++];
      }
    }
  }
  • 归并排序不是原地排序算法,空间复杂度空间复杂度是 O(n)O(n)
  • 归并排序的执行效率与要排序的原始数组的有序程度无关,所以其时间复杂度是非常稳定的,平均情况,时间复杂度都是 O(nlogn)O(nlogn)
  • 归并排序是一个稳定的排序算法

快速排序(Quick sort)

如果要排序数组中下标从 p 到 r 之间的一组数据,我们选择 p 到 r 之间的任意一个数据作为 pivot(分区点)。 我们遍历 p 到 r 之间的数据,将小于 pivot 的放到左边,将大于 pivot 的放到右边,将 pivot 放到中间。经过这一步骤之后,数组 p 到 r 之间的数据就被分成了三个部分,前面 p 到 q-1 之间都是小于 pivot 的,中间是 pivot,后面的 q+1 到 r 之间是大于 pivot 的。

image.png

根据分治、递归的处理思想,我们可以用递归排序下标从 p 到 q-1 之间的数据和下标从 q+1 到 r 之间的数据,直到区间缩小为 1,就说明所有的数据都有序了。

quickSort.gif

public class QuickSort {

  // 快速排序,a是数组,n表示数组的大小
  public static void quickSort(int[] a, int n) {
    quickSortInternally(a, 0, n-1);
  }

  // 快速排序递归函数,p,r为下标
  private static void quickSortInternally(int[] a, int p, int r) {
    if (p >= r) return;

    int q = partition(a, p, r); // 获取分区点
    quickSortInternally(a, p, q-1);
    quickSortInternally(a, q+1, r);
  }

  private static int partition(int[] a, int p, int r) {
    int pivot = a[r];
    int i = p;
    for(int j = p; j < r; ++j) {
      if (a[j] < pivot) {
        if (i == j) {
          ++i;
        } else {
          int tmp = a[i];
          a[i++] = a[j];
          a[j] = tmp;
        }
      }
    }

    int tmp = a[i];
    a[i] = a[r];
    a[r] = tmp;

    System.out.println("i=" + i);
    return i;
  }
}
  • 快速排序通过设计巧妙的原地分区函数,可以实现原地排序
  • 平均时间复杂度 O(nlogn)O(nlogn),在极端情况下为 O(n2)O(n^2)。比复杂度稳定归并排序要小很多。所以,对绝大多数顺序性较弱的随机数列而言,快速排序总是优于归并排序。
  • 快速排序是一个不稳定的排序算法

桶排序(Bucket sort)

桶排序,核心思想是将要排序的数据分到几个有序的桶里,每个桶里的数据再单独进行排序。桶内排完序之后,再把每个桶里的数 =据按照顺序依次取出,组成的序列就是有序的了。

image.png

public class BucketSort {

    /**
     * 桶排序
     * @param arr 数组
     * @param bucketSize 桶容量
     */
    public static void bucketSort(int[] arr, int bucketSize) {
        if (arr.length < 2) {
            return;
        }

        // 数组最小值
        int minValue = arr[0];
        // 数组最大值
        int maxValue = arr[1];
        for (int i = 0; i < arr.length; i++) {
            if (arr[i] < minValue) {
                minValue = arr[i];
            } else if (arr[i] > maxValue) {
                maxValue = arr[i];
            }
        }

        // 桶数量
        int bucketCount = (maxValue - minValue) / bucketSize + 1;
        int[][] buckets = new int[bucketCount][bucketSize];
        int[] indexArr = new int[bucketCount];

        // 将数组中值分配到各个桶里
        for (int i = 0; i < arr.length; i++) {
            int bucketIndex = (arr[i] - minValue) / bucketSize;
            if (indexArr[bucketIndex] == buckets[bucketIndex].length) {
                ensureCapacity(buckets, bucketIndex);
            }
            buckets[bucketIndex][indexArr[bucketIndex]++] = arr[i];
        }

        // 对每个桶进行排序,这里使用了快速排序
        int k = 0;
        for (int i = 0; i < buckets.length; i++) {
            if (indexArr[i] == 0) {
                continue;
            }
            quickSortC(buckets[i], 0, indexArr[i] - 1);
            for (int j = 0; j < indexArr[i]; j++) {
                arr[k++] = buckets[i][j];
            }
        }
    }

    /**
     * 数组扩容
     * @param buckets
     * @param bucketIndex
     */
    private static void ensureCapacity(int[][] buckets, int bucketIndex) {
        int[] tempArr = buckets[bucketIndex];
        int[] newArr = new int[tempArr.length * 2];
        for (int j = 0; j < tempArr.length; j++) {
            newArr[j] = tempArr[j];
        }
        buckets[bucketIndex] = newArr;
    }

    /**
     * 快速排序递归函数
     */
    private static void quickSortC(int[] arr, int p, int r) {
        if (p >= r) {
            return;
        }

        int q = partition(arr, p, r);
        quickSortC(arr, p, q - 1);
        quickSortC(arr, q + 1, r);
    }

    /**
     * 分区函数
     * @return 分区点位置
     */
    private static int partition(int[] arr, int p, int r) {
        int pivot = arr[r];
        int i = p;
        for (int j = p; j < r; j++) {
            if (arr[j] <= pivot) {
                swap(arr, i, j);
                i++;
            }
        }

        swap(arr, i, r);
        return i;
    }

    /**
     * 交换
     */
    private static void swap(int[] arr, int i, int j) {
        if (i == j) {
            return;
        }
        int tmp = arr[i];
        arr[i] = arr[j];
        arr[j] = tmp;
    }
}
  • 桶排序的时间复杂度为什么是O(n) O(n),如果数据都被划分到一个桶里,那就退化为 O(nlogn)O(nlogn) 的排序算法
  • 桶排序比较适合用在外部排序中。所谓的外部排序就是数据存储在外部磁盘中,数据量比较 大,内存有限,无法将数据全部加载到内存中。

计数排序(Counting sort)

计数排序的核心在于将输入的数据值转化为键存储在额外开辟的数组空间中。作为一种线性时间复杂度的排序,计数排序要求输入的数据必须是有确定范围的整数。比如 50 万考生,如何通过成绩快速排序得出名次呢

countingSort.gif

    public class CountingSort {
        // 计数排序,a是数组,n是数组大小。假设数组中存储的都是非负整数。
        public static void countingSort(int[] a, int n) {
            if (n <= 1) return;
            // 查找数组中数据的范围
            int max = a[0];
            for (int i = 1; i < n; ++i) {
                if (max < a[i]) {
                    max = a[i];
                }
            }

            // 申请一个计数数组c,下标大小[0,max]
            int[] c = new int[max + 1];

            // 计算每个元素的个数,放入c中
            for (int i = 0; i < n; ++i) {
                c[a[i]]++;
            }

            // 依次累加
            for (int i = 1; i < max + 1; ++i) {
                c[i] = c[i-1] + c[i];
            }

            // 临时数组r,存储排序之后的结果
            int[] r = new int[n];
            // 计算排序的关键步骤了,有点难理解
            for (int i = n - 1; i >= 0; --i) {
                int index = c[a[i]]-1;
                r[index] = a[i];
                c[a[i]]--;
            }

            // 将结果拷贝会a数组
            for (int i = 0; i < n; ++i) {
                a[i] = r[i];
            }
        }
    }
  • 计数排序使用一个额外的数组,对于数据范围很大的数组,需要大量内存
  • 计数排序是一种稳定的排序算法
  • 平均时间复杂度O(n) O(n)

基数排序(Radix sort)

基数排序是一种非比较型整数排序算法,其原理是将整数按位数切割成不同的数字,然后按每个位数分别比较。由于整数也可以表达字符串(比如名字或日期)和特定格式的浮点数,所以基数排序也不是只能使用于整数。

radixSort.gif public class RadixSort {

    public static void radixSort(int[] arr) {
        int max = arr[0];
        for (int i = 0; i < arr.length; i++) {
            if (arr[i] > max) {
                max = arr[i];
            }
        }
        // 从个位开始,对数组arr按"指数"进行排序
        for (int exp = 1; max / exp > 0; exp *= 10) {
            countingSort(arr, exp);
        }
    }

    /**
     * 计数排序-对数组按照"某个位数"进行排序
     */
    public static void countingSort(int[] arr, int exp) {
        if (arr.length <= 1) {
            return;
        }

        // 计算每个元素的个数
        int[] c = new int[10];
        for (int i = 0; i < arr.length; i++) {
            c[(arr[i] / exp) % 10]++;
        }

        // 计算排序后的位置
        for (int i = 1; i < c.length; i++) {
            c[i] += c[i - 1];
        }

        // 临时数组r,存储排序之后的结果
        int[] r = new int[arr.length];
        for (int i = arr.length - 1; i >= 0; i--) {
            r[c[(arr[i] / exp) % 10] - 1] = arr[i];
            c[(arr[i] / exp) % 10]--;
        }

        for (int i = 0; i < arr.length; i++) {
            arr[i] = r[i];
        }
    }
}
  • 基数排序的时间复杂度就近似于 O(n)O(n)
  • 是一个稳定的排序算法

总结

sort.png

  • 冒泡排序、插入排序、选择排序这三种排序算法,它们的时间复杂度都是 O(n2)O(n^2),比较高,适合小规模数据的排序

  • 归并排序和快速排序是两种稍微复杂的排序算法,它们用的都是分治的思想,代码都通过递归来实现。归并排序算法是一种在任何情况下时间复杂度都比较稳定的排序算法,但是空间复杂度比较高,是 O(n)O(n)。所以没有快排应用广泛。

  • 快速排序算法虽然最坏情况下的时间复杂度是O(n2) O(n ^2)但是概率很小,平均情况下时间复杂度都是O(nlogn)O(nlogn)。Java 中 Arrays.sort() 用的是快速排序算法。

  • 桶排序、计数排序、基数排序。它们对要排序的数据都有比较苛刻的要求,应用不是非常广泛。

    • 基数排序:根据键值的每位数字来分配桶;
    • 计数排序:每个桶只存储单一键值;
    • 桶排序:每个桶存储一定范围的数值;

参考