排序算法

排序算法分类

  1. 非线性时间 比较类
  • 交换
    • 冒泡排序
    • 快速排序
  • 插入
    • 插入排序
    • 希尔排序
  • 选择
    • 选择排序
    • 堆排序
  • 归并
    • 归并排序
  1. 线性时间 非比较类:计数排序、桶排序、基数排序

性能评估术语

稳定:如果a原本在b前面,而a=b时,排序之后a仍然在b的前面
不稳定:如果a原本在b的前面,而a=b时,排序之后a可能出现在b的后面

内排序:所有排序操作都在内存中完成
外排序:通常是由于数据太大,不能同时存放在内存中,根据排序过程的需要而在外存与内存之间 数据传输才能进行

时间复杂度:时间频度,一个算法执行所耗费的时间。算法中通常用 数据比较次数与数据移动次数 进行衡量
空间复杂度:算法执行所需要的内存大小


1. 冒泡排序

基本实现

冒泡排序是一种交换排序,核心在于冒泡,把数组中最小的那个往上冒,若发生逆序,则交换

每次迭代 能确定一个数在序列中的最终位置

  • 比较相邻的元素。如果第一个比第二个大,就交换他们两个
  • 对每一对相邻元素作同样的工作,从开始第一对到结尾的最后一对。在这一点,最后的元素应该会是最大的数
  • 针对所有的元素重复以上的步骤,除了最后一个
  • 持续每次对越来越少的元素重复上面的步骤,直到没有任何一对数字需要比较

通过两层循环控制:

  • 第一个循环(外循环),负责把需要冒泡的那个数字排除在外;
  • 第二个循环(内循环),负责两两比较交换
// 冒泡排序(C++)
void bubble_sort(int arr[], int len)  
{  
    int i, j;  
    for (i = 0; i < len; i++)  
        for (j = 1; j < len - i; j++)  
            if (arr[j - 1] > arr[j])  
                swap(arr[j - 1], arr[j]);  
}

性能分析

  • 平均时间复杂度:\(O(N^2)\)
  • 最佳时间复杂度:\(O(N)\)
  • 最差时间复杂度:\(O(N^2)\)
  • 空间复杂度:\(O(1)\)
  • 排序方式:In-place
  • 稳定性:稳定

优化一

场景:数组本身就是有序的;如果用基础的冒泡排序方法,仍然还要比较 \(O(N^2)\) 次,但无交换次数

改进:通过标志位记录是否发生交换,若没有直接返回即可

// 冒泡排序改进①
void bubble_sort(int arr[], int len)
{
    for (int i = 0; i < len-1; i++){        //比较n-1次
        bool exchange = true;               //冒泡的改进,若在一趟中没有发生逆序,则该序列已有序
        for (int j = len-1; j >i; j--){     //每次从后边冒出一个最小值
            if (arr[j] < arr[j - 1]){       //发生逆序,则交换
                swap(arr[j], arr[j - 1]);
                exchange = false;			//记录下是否发生交换
            }
        }
        if (exchange){
            return;
        }
    }
}

优化二

场景:如果有100个数的数组,仅前面10个无序,后面90个都已排好序且都大于前面10个数字,那么在第一趟遍历后,最后发生交换的位置 必定小于10,且这个位置之后的数据必定已经有序了

改进:通过标志位记录下这个最后发生交换的位置,下一次迭代只需扫描到这个位置即可

// 冒泡排序改进②
void bubble_sort(int arr[], int len)  
{  
    int j, k;  
    int flag;  
    flag = len;  
    while (flag > 0)  
    {  
        k = flag;  
        flag = 0;  
        for (j = 1; j < k; j++)  
            if (arr[j - 1] > arr[j])  
            {  
                swap(arr[j - 1], arr[j]);  
                flag = j;  					//记录下最后发生交换的位置
            }  
    }  
}

总结

冒泡排序毕竟是一种效率低下的排序方法,在数据规模很小时,可以采用。数据规模比较大时,建议采用其它排序方法


2. 插入排序

基本实现

类似于打扑克牌时整理手中牌的场景

插入排序的工作原理是 通过构建有序序列,对于未排序数据,在已排序数据中从后向前扫描,在找到相应位置后进行插入,注在插入时需要将已排序元素逐个向后挪位

实现逻辑

① 从第一个元素开始,该元素可以认为已经被排序
② 取出下一个元素,在已经排序的元素序列中从后向前扫描
③如果该元素(已排序)大于新元素,将该元素移到下一位置
④ 重复步骤③,直到找到已排序的元素小于或者等于新元素的位置
⑤将新元素插入到该位置后
⑥ 重复步骤②~⑤

性能分析

平均时间复杂度:\(O(N^2)\)
最差时间复杂度:\(O(N^2)\)
空间复杂度:\(O(1)\)
排序方式:In-place
稳定性:稳定

// 插入排序
void InsertSort(int arr[], int len){
    // 检查数据合法性
    if(arr == NULL || len <= 0){
        return;
    }
    for(int i = 1; i < len; i++){
        int tmp = arr[i];
        int j;
        for(j = i-1; j >= 0; j--){
            //如果比tmp大把值往后移动一位
            if(arr[j] > tmp){
               arr[j+1] = arr[j];
            }
            else{
               break;
            }
        }
        arr[j+1] = tmp;
    }
}

优化一

场景:直接插入排序每次往前插入时,是按顺序依次往前查找,数据量较大时,必然比较耗时,效率低

改进:在往前找合适的插入位置时采用二分查找的方式,即折半插入

// 插入排序改进:二分插入排序
void BinaryInsertSort(int arr[], int len)   
{   
    int key, left, right, middle;   
    for (int i=1; i<len; i++)   
    {   
        key = a[i];   
        left = 0;   
        right = i-1;   
        while (left<=right)   
        {   
            middle = (left+right)/2;   
            if (a[middle]>key)   
                right = middle-1;   
            else   
                left = middle+1;   
        }   

        for(int j=i-1; j>=left; j--)   
        {   
            a[j+1] = a[j];   
        }   

        a[left] = key;          
    }   
}

优化二

场景:(1) 插入排序对几乎已排好序的数据操作时,效率很高,可以达到线性排序的效率;(2) 插入排序在每次往前插入时只能将数据移动一位,效率比较低

改进:先将整个待排元素序列分割成若干个子序列(由相隔某个“增量”的元素组成的)分别进行直接插入排序,然后依次缩减增量再进行排序,待整个序列中的元素基本有序(增量足够小)时,再对全体元素进行一次直接插入排序

其实就是后面会说明的希尔排序

总结

插入排序不适合对于数据量比较大的排序应用。但是,如果需要排序的数据量很小,例如,量级小于千,那么插入排序还是一个不错的选择

尤其当数据基本有序时,采用插入排序可以明显减少数据交换和数据移动次数,进而提升排序效率

在STL的sort算法和stdlib的qsort算法中,都将插入排序作为快速排序的补充,用于少量元素的排序


3. 希尔排序

【希尔排序就这么简单】

希尔排序的实质就是 分组插入排序,该方法又称 递减增量排序算法,希尔排序是非稳定的排序算法

先将整个待排元素序列分割成 若干个子序列(由相隔某个“增量”的元素组成的)分别进行直接插入排序,然后 依次缩减增量 再进行排序,待整个序列中的元素基本有序(增量足够小)时,再对全体元素进行一次直接插入排序

因为直接插入排序在元素基本有序的情况下(接近最好情况),效率是很高的,因此希尔排序在时间效率上比前两种方法有较大提高

性能分析

平均时间复杂度:\(O(Nlog2N)\)
最佳时间复杂度:
最差时间复杂度:\(O(N^2)\)
空间复杂度:\(O(1)\)
稳定性:不稳定
复杂性:较复杂

public static void shellSort(int[] arrays) {
	//增量每次都/2
	for (int step = arrays.length / 2; step > 0; step /= 2) {
		//从增量那组开始进行插入排序,直至完毕
		for (int i = step; i < arrays.length; i++) {
			int j = i;
			int temp = arrays[j];

			// j - step 就是代表与它同组隔壁的元素
			while (j - step >= 0 && arrays[j - step] > temp) {
				arrays[j] = arrays[j - step];
				j = j - step;
			}
			arrays[j] = temp;
		}
	}
}

4. 选择排序

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

复杂度分析

平均时间复杂度:\(O(N^2)\)
最佳时间复杂度:\(O(N^2)\)
最差时间复杂度:\(O(N^2)\)
空间复杂度:\(O(1)\)
排序方式:In-place
稳定性:不稳定

// 选择排序(Java)
public static void selection_sort(int[] arr) {
    int i, j, min, temp, len = arr.length;
    for (i = 0; i < len - 1; i++) {
        min = i;
        for (j = i + 1; j < len; j++)
            if (arr[min] > arr[j])
                min = j;
        temp = arr[min];
        arr[min] = arr[i];
        arr[i] = temp;
    }
}

优化一:每次循环选择确定两个元素

优化二:堆排序


5. 快速排序

基本实现

快速排序,又称划分交换排序

通过一趟排序将待排记录分隔成独立的两部分,其中一部分记录的关键字均比另一部分的关键字小(大),则可分别对这两部分记录继续进行排序,以达到整个序列有序

快速排序使用分治法(Divide and conquer)策略来把一个序列(list)分为两个子序列(sub-lists)

① 从数列中挑出一个元素,称为 “基准”(pivot),
② 重新排序数列,所有元素比基准值小的摆放在基准前面,所有元素比基准值大的摆在基准的后面(相同的数可以到任一边)。在这个分区退出之后,该基准就处于数列的中间位置。这个称为分区(partition)操作。
③ 递归地(recursive)把小于基准值元素的子数列和大于基准值元素的子数列排序

复杂度

平均时间复杂度:\(O(NlogN)\)
最佳时间复杂度:\(O(NlogN)\)
最差时间复杂度:\(O(N^2)\)
空间复杂度:根据实现方式的不同而不同

class quick_sort {
    int[] arr;
    private void swap(int x, int y) {
        int temp = arr[x];
        arr[x] = arr[y];
        arr[y] = temp;
    }
    private void quick_sort_recursive(int start, int end) {
        if (start >= end)
            return;
        int mid = arr[end];
        int left = start, right = end - 1;
        while (left < right) {
            while (arr[left] <= mid && left < right)
                left++;
            while (arr[right] >= mid && left < right)
                right--;
            swap(left, right);
        }
        if (arr[left] >= arr[end])
            swap(left, end);
        else
            left++;
        quick_sort_recursive(start, left - 1);
        quick_sort_recursive(left + 1, end);
    }
    public void sort(int[] arrin) {
        arr = arrin;
        quick_sort_recursive(0, arr.length - 1);
    }
}

总结

快速排序在排序算法中具有排序速度快,而且是就地排序等优点,

使得在许多编程语言的内部元素排序实现中采用的就是快速排序,很多面试题中也经常遇到

对于其算法的改进,除了将递归转化为迭代,因为一方面,是函数调用的负担;另一方面,是堆栈占用的负担(堆栈的大小是有限的)

根据实际场景还有诸多改进方法,包括对小序列采用插入排序替代,三平均划分,三分区划分等改进方法(相关的改进方法就不一一说明,有兴趣的读者可上网查阅了解)


6. 归并排序

归并排序是用分治思想,分治模式在每一层递归上有三个步骤:

  • 分解(Divide):将n个元素分成个含n/2个元素的子序列
  • 解决(Conquer):用合并排序法对两个子序列递归的排序
  • 合并(Combine):合并两个已排序的子序列已得到排序结果

实现逻辑

迭代法

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

递归法

① 将序列每相邻两个数字进行归并操作,形成floor(n/2)个序列,排序后每个序列包含两个元素
② 将上述序列再次归并,形成floor(n/4)个序列,每个序列包含四个元素
③ 重复步骤②,直到所有元素排序完毕

复杂度分析

平均时间复杂度:\(O(nlogn)\)
最佳时间复杂度:\(O(n)\)
最差时间复杂度:\(O(nlogn)\)
空间复杂度:\(O(n)\)
排序方式:In-place
稳定性:稳定


// 归并排序(Java-迭代版)
public static void merge_sort(int[] arr) {
    int len = arr.length;
    int[] result = new int[len];
    int block, start;

    // 原版代码的迭代次数少了一次,没有考虑到奇数列数组的情况
    for(block = 1; block < len*2; block *= 2) {
        for(start = 0; start <len; start += 2 * block) {
            int low = start;
            int mid = (start + block) < len ? (start + block) : len;
            int high = (start + 2 * block) < len ? (start + 2 * block) : len;
            //两个块的起始下标及结束下标
            int start1 = low, end1 = mid;
            int start2 = mid, end2 = high;
            //开始对两个block进行归并排序
            while (start1 < end1 && start2 < end2) {
            result[low++] = arr[start1] < arr[start2] ? arr[start1++] : arr[start2++];
            }
            while(start1 < end1) {
            result[low++] = arr[start1++];
            }
            while(start2 < end2) {
            result[low++] = arr[start2++];
            }
        }
    int[] temp = arr;
    arr = result;
    result = temp;
    }
    result = arr;       
}

// 归并排序(Java-递归版)
static void merge_sort_recursive(int[] arr, int[] result, int start, int end) {
    if (start >= end)
        return;
    int len = end - start, mid = (len >> 1) + start;
    int start1 = start, end1 = mid;
    int start2 = mid + 1, end2 = end;
    merge_sort_recursive(arr, result, start1, end1);
    merge_sort_recursive(arr, result, start2, end2);
    int k = start;
    while (start1 <= end1 && start2 <= end2)
        result[k++] = arr[start1] < arr[start2] ? arr[start1++] : arr[start2++];
    while (start1 <= end1)
        result[k++] = arr[start1++];
    while (start2 <= end2)
        result[k++] = arr[start2++];
    for (k = start; k <= end; k++)
        arr[k] = result[k];
}

public static void merge_sort(int[] arr) {
    int len = arr.length;
    int[] result = new int[len];
    merge_sort_recursive(arr, result, 0, len - 1);
}

优化改进

在规模较小时,合并排序可采用直接插入,避免递归调用; 在写法上,可以在生成辅助数组时,俩头小,中间大,这时不需要再在后边加俩个while循环进行判断,只需一次比完

为了节省将元素复制到辅助数组作用的时间,可以在递归调用的每个层次交换原始数组与辅助数组的角色

总结

归并排序和选择排序一样,归并排序的性能不受输入数据的影响,但表现比选择排序好的多,因为始终都是\(O(nlogn)\)的时间复杂度。代价是需要额外的内存空间


7. 堆排序

堆的性质

① 是一棵完全二叉树
② 每个节点的值都大于或等于其子节点的值,为最大堆;反之为最小堆

堆的存储

一般用数组来表示堆,下标为 \(i\) 的结点的父结点下标为 \((i-1)/2\);其左右子结点分别为 \((2i + 1)\)\((2i + 2)\)

堆的操作

在堆的数据结构中,堆中的最大值总是位于根节点(在优先队列中使用堆的话堆中的最小值位于根节点)。堆中定义以下几种操作:

最大堆调整(Max_Heapify):将堆的末端子节点作调整,使得子节点永远小于父节点
创建最大堆(Build_Max_Heap):将堆所有数据重新排序
堆排序(HeapSort):移除位在第一个数据的根节点,并做最大堆调整的递归运算


堆排序(Heap Sort)

利用大顶堆(小顶堆)堆顶记录的是最大关键字(最小关键字)这一特性,使得每次从无序中选择最大记录(最小记录)变得简单

① 将待排序的序列构造成一个最大堆,此时序列的最大值为根节点
② 依次将根节点与待排序序列的最后一个元素交换
③ 再维护从根节点到该元素的前一个节点为最大堆,如此往复,最终得到一个递增序列

复杂度分析

  • 平均时间复杂度:\(O(nlogn)\)
  • 最佳时间复杂度:\(O(nlogn)\)
  • 最差时间复杂度:\(O(nlogn)\)
  • 稳定性:不稳定

堆排序其实也是一种选择排序,是一种树形选择排序

只不过直接选择排序中,为了从R[1…n]中选择最大记录,需比较n-1次,然后从R[1…n-2]中选择最大记录需比较n-2次。事实上这n-2次比较中有很多已经在前面的n-1次比较中已经做过,而 树形选择排序恰好利用树形的特点保存了部分前面的比较结果,因此可以减少比较次数

对于n个关键字序列,最坏情况下每个节点需比较log2(n)次,因此其最坏情况下时间复杂度为nlogn

堆排序为不稳定排序,不适合记录较少的排序


package hhh.sort_algori;

/**
 * @author hdbing
 * @create 2023-03-06 15:31
 */

import java.util.Arrays;

public class HeapSort {

	private int[] arr;

	public HeapSort(int[] arr) {
		this.arr = arr;
	}

	/**
	 * 堆排序的主要入口方法,共两步。
	 */
	public void sort() {
		/*
		 *  第一步:将数组堆化
		 *  beginIndex = 第一个非叶子节点。
		 *  从第一个非叶子节点开始即可。无需从最后一个叶子节点开始。
		 *  叶子节点可以看作已符合堆要求的节点,根节点就是它自己且自己以下值为最大。
		 */
		int len = arr.length - 1;
		int beginIndex = (len - 1) >> 1;
		for (int i = beginIndex; i >= 0; i--) {
			maxHeapify(i, len);
		}

		/*
		 * 第二步:对堆化数据排序
		 * 每次都是移出最顶层的根节点A[0],与最尾部节点位置调换,同时遍历长度 - 1。
		 * 然后从新整理被换到根节点的末尾元素,使其符合堆的特性。
		 * 直至未排序的堆长度为 0。
		 */
		for (int i = len; i > 0; i--) {
			swap(0, i);
			maxHeapify(0, i - 1);
		}
	}

	private void swap(int i, int j) {
		int temp = arr[i];
		arr[i] = arr[j];
		arr[j] = temp;
	}

	/**
	 * 调整索引为 index 处的数据,使其符合堆的特性。
	 *
	 * @param index 需要堆化处理的数据的索引
	 * @param len   未排序的堆(数组)的长度
	 */
	private void maxHeapify(int index, int len) {
		int li = (index << 1) + 1; // 左子节点索引
		int ri = li + 1;           // 右子节点索引
		int cMax = li;             // 子节点值最大索引,默认左子节点。

		if (li > len) return;       // 左子节点索引超出计算范围,直接返回。
		if (ri <= len && arr[ri] > arr[li]) // 先判断左右子节点,哪个较大。
			cMax = ri;
		if (arr[cMax] > arr[index]) {
			swap(cMax, index);      // 如果父节点被子节点调换,
			maxHeapify(cMax, len);  // 则需要继续 递归判断换下后的父节点 是否符合堆的特性
		}
	}

	/**
	 * 测试用例
	 * <p>
	 * 输出:
	 * [0, 0, 0, 1, 1, 1, 2, 2, 2, 3, 3, 3, 4, 4, 4, 5, 5, 5, 6, 6, 6, 7, 7, 7, 8, 8, 8, 9, 9, 9]
	 */
	public static void main(String[] args) {
		int[] arr = new int[]{3, 5, 3, 0, 8, 6, 1, 5, 8, 6, 2, 4, 9, 4, 7, 0, 1, 8, 9, 7, 3, 1, 2, 5, 9, 7, 4, 0, 2, 6};
		new HeapSort(arr).sort();
		System.out.println(Arrays.toString(arr));
	}
}

总结

堆是一种很好做调整的结构,在算法题里面使用频度很高。常用于想知道最大值或最小值的情况,比如优先级队列,作业调度等场景

堆排序相看似比较复杂(建堆的过程,堆调整的过程,堆排序等等),需要好好推敲揣摩理清思路

堆排序操作过程中其运行时间主要耗费在 建初始堆和 调整建新堆时进行的反复“筛选”


8. 计数排序

举个例子,假设有无序数列 nums=[2, 1, 3, 1, 5],

首先扫描一遍获取最小值和最大值,maxValue=5,minValue=1,于是开一个长度为 5 的计数器数组counter

(1) 分配
统计每个元素出现的频率,得到counter=[2, 1, 1, 0, 1],例如counter[0]表示值0+minValue=1出现了2次
(2) 收集
counter[0] = 2表示1出现了两次,那就向原始数组写入两个1,counter[1]=1表示2出现了1次,那就向原始数组写入一个2,

依次类推,最终原始数组变为[1,1,2,3,5],排序好了


计数排序算法只能使用在已知序列中的元素在0-k之间,且要求排序的复杂度在线性效率上

计数排序和基数排序很类似,都是非比较型排序算法。但是,它们的核心思想是不同的,基数排序主要是按照 进制位对整数进行依次排序,而计数排序主要侧重于对有限范围内对象的统计

基数排序可以采用计数排序来实现,所以为了使基数排序能够正确运行,计数排序必须是稳定的


9. 桶排序

  • 设置一个定量的数组当作空桶子
  • 寻访序列,并且把项目一个一个放到对应的桶子去
  • 对每个不是空的桶子进行排序
  • 从不是空的桶子里把项目再放回原来的序列中

桶排序是计数排序的变种,它利用了函数的映射关系,高效与否的关键就在于这个映射函数的确定

把计数排序中相邻的m个”小桶”放到一个”大桶”中,在分完桶后,对每个桶进行排序(一般用快排),然后合并成最后的结果

算法思想和散列中的开散列法差不多,当冲突时放入同一个桶中;可应用于数据量分布比较均匀,或比较侧重于区间数量时

桶排序最关键的是建桶,如果桶设计得不好的话桶排序是几乎没有作用的。通常情况下,上下界有两种取法,第一种是取一个10n或者是2n的数,方便实现。另一种是取数列的最大值和最小值然后均分作桶


10. 基数排序

原理是将整数按位数切割成不同的数字,然后按每个位数分别比较

基数排序的方式可以采用LSD(Least significant digital)或MSD(Most significant digital),LSD的排序方式由键值的最右边开始,而MSD则相反,由键值的最左边开始

  • MSD:先从高位开始进行排序,在每个关键字上,可采用计数排序
  • LSD:先从低位开始进行排序,在每个关键字上,可采用桶排序

实现逻辑

① 将所有待比较数值(正整数)统一为同样的数位长度,数位较短的数前面补零
② 从最低位开始,依次进行一次排序
③ 这样从最低位排序一直到最高位排序完成以后, 数列就变成一个有序序列


总结

基数排序与计数排序、桶排序这三种排序算法都利用了桶的概念,但对桶的使用方法上有明显差异:

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

基数排序不是直接根据元素整体的大小进行元素比较,而是将原始列表元素分成多个部分,对每一部分按一定的规则进行排序,进而形成最终的有序列表


参考

开发者1024 - 知乎 (zhihu.com)

posted @ 2023-03-06 21:24  黄一洋  阅读(8)  评论(0)    收藏  举报