八大基本排序算法

335 阅读9分钟

排序

0 排序算法的简介

0.1 定义

对一序列对象根据某个关键字进行排序。

0.2 术语说明

  • 稳定:如果a原本在b前面,而a=b,排序之后a仍然在b的前面;
  • 不稳定:如果a原本在b前面,而a=b,排序之后a有可能会出现在b的后面;
  • 内排序:所有排序操作都在内存中完成;
  • 外排序:由于数据太大,因此把数据放在磁盘中,而排序通过磁盘和内存的数据传输才能进行;
  • 时间复杂度:描述算法运行时间的函数,用大O符号表述;
  • 空间复杂度:描述算法所需要的内存空间大小。

0.3 算法分类

十种常见排序算法可以分为两大类:

  • 非线性时间比较类排序: 通过比较来决定元素间的相对次序,由于其时间复杂度不能突破O(nlogn),因此称为非线性时间比较类排序。
  • 线性时间非比较类排序: 不通过比较来决定元素间的相对次序,它可以突破基于比较排序的时间下界,以线性时间运行,因此称为线性时间非比较类排序

img18.png

img17.png

0.4 算法总结

img15.png

图片名词解释

  • n: 数据规模
  • k: “桶”的个数
  • In-place: 占用常数内存,不占用额外内存
  • Out-place: 占用额外内存

0.5 算法的数据结构

img16.png

0.6 比较和非比较的区别

常见的快速排序、归并排序、堆排序、冒泡排序等属于比较排序。在排序的最终结果里,元素之间的次序依赖于它们之间的比较。每个数都必须和其他数进行比较,才能确定自己的位置。

在冒泡排序之类的排序中,问题规模为n,又因为需要比较n次,所以平均时间复杂度为O(n²)。在归并排序、快速排序之类的排序中,问题规模通过分治法消减为logn次,所以时间复杂度平均O(nlogn)。比较排序的优势是,适用于各种规模的数据,也不在乎数据的分布,都能进行排序。可以说,比较排序适用于一切需要排序的情况。

计数排序、基数排序、桶排序则属于非比较排序。非比较排序是通过确定每个元素之前,应该有多少个元素来排序。针对数组arr,计算arr之前有多少个元素,则唯一确定了arr在排序后数组中的位置。

非比较排序只要确定每个元素之前的已有的元素个数即可,所有一次遍历即可解决。算法时间复杂度O(n) 。非比较排序时间复杂度低,但由于非比较排序需要占用空间来确定唯一位置。所以对数据规模和数据分布有一定的要求。

1 冒泡排序

冒泡排序是一种简单的排序算法。它重复地走访过要排序的数列,一次比较两个元素,如果它们的顺序错误就把它们交换过来。走访数列的工作是重复地进行直到没有再需要交换,也就是说该数列已经排序完成。这个算法的名字由来是因为越小的元素会经由交换慢慢“浮”到数列的顶端。

1.1 算法描述

  • 比较相邻的元素。如果第一个比第二个大,就交换它们两个;
  • 对每一对相邻元素作同样的工作,从开始第一对到结尾的最后一对,这样在最后的元素应该会是最大的数;
  • 针对所有的元素重复以上的步骤,除了最后一个;
  • 重复步骤1~3,直到排序完成。

1.2 动图演示

img19.gif

1.3 算法代码

public int[] bubbleSort(int[] nums) {
    for (int i = 1; i < nums.length; i++) {
        //标记这一轮排序元素有没有发生交换,true 无,false 有
        boolean flag = true;
​
        for (int j = 0; j < nums.length - i; j++) {
            if (nums[j] > nums[j + 1]) {
                int tmp = nums[j];
                nums[j] = nums[j + 1];
                nums[j + 1] = tmp;
                flag = false;    //发生了交换
            }
        }
        //如果没有发生交换,表示当前已经是有序序列
        if (flag)
            break;
    }
    return nums;
}

1.4 算法分析

  • 平均时间复杂度:O(n²)
  • 最好情况:O(n)
  • 最坏情况:O(n²)
  • 空间复杂度:O(1)
  • 稳定性:稳定

2 选择排序

选择排序是一种简单直观的排序算法,无论什么数据进去都是 O(n²) 的时间复杂度。所以用到它的时候,数据规模越小越好。唯一的好处可能就是不占用额外的内存空间了吧。

2.1 算法描述

  • 首先在未排序序列中找到最小(大)元素,存放到排序序列的起始位置
  • 再从剩余未排序元素中继续寻找最小(大)元素,然后放到已排序序列的末尾
  • 重复第二步,直到所有元素均排序完毕。

2.2 动图演示

img20.gif

2.3 算法代码

public int[] selectSort(int[] nums) {
    // 总共要经过 N-1 轮比较
    for (int i = 0; i < nums.length; i++) {
        // 记录最小值的元素下标,默认是 第一个
        int min = i;
​
        // 每轮需要比较的次数 N-i
        for (int j = i + 1; j < nums.length; j++) {
            if (nums[i] > nums[j]) {
                // 记录目前内找到的最小值的元素下标
                min = j;
            }
        }
​
        // 若最小元素不是本身,将找到的最小值和i位置所在的值进行交换
        if (min != i) {
            int tmp = nums[i];
            nums[i] = nums[min];
            nums[min] = tmp;
        }
    }
    return nums;
}

2.4 算法分析

  • 平均时间复杂度:O(n²)
  • 最好情况:O(n²)
  • 最坏情况:O(n²)
  • 空间复杂度:O(1)
  • 稳定性:不稳定

3 插入排序

插入排序的代码实现虽然没有冒泡排序和选择排序那么简单粗暴,但它的原理应该是最容易理解的了,因为只要打过扑克牌的人都应该能够秒懂。插入排序是一种最简单直观的排序算法,它的工作原理是通过构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入。

插入排序和冒泡排序一样,也有一种优化算法,叫做折半插入排序。

3.1 算法描述

  • 将排序序列的第一个元素看做一个有序序列,把第二个元素到最后一个元素当成是未排序序列。
  • 从头到尾依次扫描未排序序列,将扫描到的每个元素插入有序序列的适当位置。(如果待插入的元素与有序序列中的某个元素相等,则将待插入元素插入到相等元素的后面。)

3.2 动图演示

img21.gif

3.3 算法代码

public int[] insertSelect(int[] nums) {
    // 从下标为 1 的元素开始选择合适的位置插入,因为下标为 0 的只有一个元素,默认是有序的
    for (int i = 1; i < nums.length; i++) {
        // 记录要插入的元素
        int tmp = nums[i];
​
        // 从已经排序的序列最右边开始比较,找到比其小的数
        int j = i;
        while (j > 0 && tmp < nums[j - 1]) {
            nums[j] = nums[j - 1];
            j--;
        }
​
        // 存在比其小的数,插入
        if (j != i) {
            nums[j] = tmp;
        }
    }
    return nums;
}

3.4 算法分析

  • 平均时间复杂度:O(n²)
  • 最好情况:O(n)
  • 最坏情况:O(n²)
  • 空间复杂度:O(1)
  • 稳定性:稳定

4 折半插入排序

折半插入排序(binary Insertion Sort)就是对前面插入排序算法的一种改进,就是将未排序区域的元素插入到已排序区域的时候,寻找合适位置的方法是二分查找法,就是说,每次都找的是当前区间的中点元素,中点元素将已排序区间划分成两部分,小于它的区域(左半部分)和大于它的区域(右半区域),如果要插入元素大于中点元素,那么就在右区域继续这样寻找,左区域同理;直到最后的区域只剩下一个元素,比较后直接插入这最后一个元素的前面或者后面。

4.1 算法描述

二分查找具体实现方法,三个指针下标,left\mid\right,开始的时候,left指向当前数组的最左边,mid为中间下标的元素,right为最右边元素的下标,当待插入元素跟mid元素比较一次之后,如果大于mid,就继续在右边区域查找,left重新指向mid+1处的元素;如果小于,right指向mid-1处的元素;然后继续跟接下来的区间mid元素比较,直到区间剩下最后不能再划分左右区间的时候停止。

4.2 算法代码

public int[] binaryInsertSort(int[] nums) {
    for (int i = 1; i < nums.length; i++) {
        // 记录要插入的元素
        int tmp = nums[i];
​
        //在有序序列中进行二分查找,查找元素插入的位置
        int left = 0;
        int right = i - 1;
        while (left <= right) {
            int mid = (left + right) / 2;
            if (tmp < nums[mid]) {     //在左半部分查找
                right = mid - 1;
            } else {
                left = mid + 1;       //在右半部分查找
            }
        }
​
        //循环结束后mid=left=high
        for (int j = i - 1; j > right; j--) {
            nums[j + 1] = nums[j];
        }
        nums[right + 1] = tmp;
    }
    return nums;
}

4.3 算法分析

  • 时间复杂度:O(n²)
  • 空间复杂度:O(1)
  • 稳定性:稳定

5 希尔排序

希尔排序,也称递减增量排序算法,是插入排序的一种更高效的改进版本。但希尔排序是非稳定排序算法。希尔排序是基于插入排序的以下两点性质而提出改进方法的:

  • 插入排序在对几乎已经排好序的数据操作时,效率高,即可以达到线性排序的效率;
  • 但插入排序一般来说是低效的,因为插入排序每次只能将数据移动一位;

希尔排序的基本思想是:先将整个待排序的记录序列分割成为若干子序列分别进行直接插入排序,待整个序列中的记录"基本有序"时,再对全体记录进行依次直接插入排序。

5.1 算法描述

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

5.2 动图演示

img23.gif

img24.png

5.3 算法代码

public int[] shellSort(int[] nums) {
    // 设置增量
    int gap = nums.length / 2;
​
    while (gap > 0) {
        for (int i = gap; i < nums.length; i++) {
            // 记录要插入的元素
            int tmp = nums[i];
            int j = i - gap;
            while (j >= 0 && nums[j] > tmp) {
                nums[j + gap] = nums[j];    //记录后移,直到找到插入位置
                j -= gap;
            }
            nums[j + gap] = tmp;    //插入
        }
        gap = gap / 2;
    }
    return nums;
}

5.4 算法分析

  • 平均时间复杂度:O(n log n)
  • 最好情况:O(n log² n)
  • 最坏情况:O(n log² n)
  • 空间复杂度:O(1)
  • 稳定性:不稳定

6 归并排序

6.1 算法描述

  • 申请空间,使其大小为两个已经排序序列之和,该空间用来存放合并后的序列;
  • 设定两个指针,最初位置分别为两个已经排序序列的起始位置;
  • 比较两个指针所指向的元素,选择相对小的元素放入到合并空间,并移动指针到下一位置;
  • 重复步骤 3 直到某一指针达到序列尾;
  • 将另一序列剩下的所有元素直接复制到合并序列尾。

6.2 动图演示

img22.gif

6.3 算法代码

public int[] mergeSort(int[] nums) {
    int[] res = new int[nums.length];
    mSort(nums, res, 0, nums.length - 1);
    return res;
}
​
public void mSort(int[] nums, int[] tmp, int low, int high) {
    if (low < high) {   //当子序列中只有一个元素时结束递归
        int mid = (low + high) / 2;             //划分子序列
        mSort(nums, tmp, low, mid);             //对左侧子序列进行递归排序
        mSort(nums, tmp, mid + 1, high);   //对右侧子序列进行递归排序
        merge(nums, tmp, low, mid, high);       //合并
    }
}
​
//2路归并算法,两个排好序的子序列合并为一个子序列
public void merge(int[] nums, int[] tmp, int low, int mid, int high) {
    //p1、p2是检测指针,k是存放指针
    int p1 = low;
    int p2 = mid + 1;
    int k = low;
​
    while (p1 <= mid && p2 <= high) {
        if (nums[p1] <= nums[p2]) {
            tmp[k++] = nums[p1++];
        } else {
            tmp[k++] = nums[p2++];
        }
    }
    //如果第一个序列未检测完,直接将后面所有元素加到合并的序列中
    while (p1 <= mid)
        tmp[k++] = nums[p1++];
    while (p2 <= high)
        tmp[k++] = nums[p2++];
}

6.4 算法分析

  • 平均时间复杂度:O(n log n)
  • 最好情况:O(n log n)
  • 最坏情况:O(n log n)
  • 空间复杂度:O(n)
  • 稳定性:稳定

7 快速排序

快速排序,说白了就是给基准数据找其正确索引位置的过程。假设最开始的基准数据为数组第一个元素,则首先用一个临时变量pivot去存储基准数据,然后分别从数组的两端扫描数组,设两个指示标志:low指向起始位置,high指向末尾。

其实快速排序的本质就是把基准数大的都放在基准数的右边,把比基准数小的放在基准数的左边,这样就找到了该数据在数组中的正确位置。以后采用递归的方式分别对前半部分和后半部分排序,当前半部分和后半部分均有序时该数组就自然有序了。

7.1 算法描述

①先从队尾开始向前扫描且当low < high时,如果a[high] > pivot,则high--,但如果a[high] < pivot,则将high的值赋值给low,即arr[low] = a[high],同时要转换数组扫描的方式,即需要从队首开始向队尾进行扫描了

②同理,当从队首开始向队尾进行扫描时,如果a[low] < pivot,则low++,但如果a[low] > pivot了,则就需要将low位置的值赋值给high位置,即arr[low] = arr[high],同时将数组扫描方式换为由队尾向队首进行扫描

③不断重复①和②,直到low>=high时(其实是low=high),low或high的位置就是该基准数据在数组中的正确索引位置

7.2 画图演示

img25.png

7.3 算法代码

public int[] quickSort(int[] nums) {
    qSort(nums, 0, nums.length - 1);
    return nums;
}
​
public void qSort(int[] nums, int low, int high) {
    if (low < high) {
        int pivotIndex = partition(nums, low, high);
        qSort(nums, low, pivotIndex - 1);   // 对左子表递归排序
        qSort(nums, pivotIndex + 1, high);   // 对右子表递归排序
    }
}
​
public int partition(int[] nums, int low, int high) {
    int pivot = nums[low];  // 基准元素
​
    while (low < high) {
        // 当队尾的元素大于等于基准数据时,向前挪动high指针
        while (low < high && pivot <= nums[high]) {
            high--;
        }
        // 如果队尾元素小于tmp了,需要将其赋值给low
        nums[low] = nums[high];
​
        // 当队首元素小于等于tmp时,向前挪动low指针
        while (low < high && pivot >= nums[low]) {
            low++;
        }
        // 当队首元素大于tmp时,需要将其赋值给high
        nums[high] = nums[low];
    }
​
    // 跳出循环时low和high相等,此时的low或high就是基准元素的正确索引位置
    nums[low] = pivot;
    return low;
}

7.4 算法分析

  • 平均时间复杂度:O(n log n)
  • 最好情况:O(n log n)
  • 最坏情况:O(n²)
  • 空间复杂度:O(log n)
  • 稳定性:不稳定

8 堆排序(待更新)

参考文章