【算法与数据结构】万字长文总结——图解那些让你凌乱的七大排序!

算法和数据结构在学习中的重要性是不言而喻的,而排序算法又是面试中的最常考点。由于各个方法的既有差异又有相同以及各自的时间复杂度和空间复杂度都很容易让人混淆,今天自己也将所有的算法总结起来整理归纳,希望能帮到大家。

一、直接插入排序

1.思路讲解

直接插入排序就是每次从无序区间选择第一个数,插入到有序区间的合适位置。可以参考平时打扑克排时,摸到牌就会插入到自己的已有的牌中去。

2.图解示例

有待排序序列[3, 5, 9, 4, 2, 1, 7, 6, 8] 将整个空间分为无序区间和有序区间,每次从无序区间中拿出一个数插入到有序空间中去。

  • 第一次有序区间只有第一个数 3 后面都是无序区间。其中第一次拿出无序区间的第一个数 5 来插入有序区间中
  • 第二次有序区间就变成了橙色区域的3,5,再对无需区间的第一个数 9 来插入排序得到 新的有序区间。

以此类推。
在这里插入图片描述
这样,一个有9个数的序列就需要对无序区间进行8次插入操作。因此看出,当有n个数时就需要循环n-1次。
而对于每一个要插入的数来说,都要去比较有序区间中的数,来确定自己的位置,所以对于内层循环而言要循环有序区间数字个数次。
这里也借鉴了优秀博主的动图演示帮助大家理解:
在这里插入图片描述

3.代码演示

有序区间 [0, i]
无序区间 [i + 1 ,array.length]
待插入的数据 array[i +1]插入过程在有序区间内查找
每次要插入的数据会在有序区间内从后往前依次寻找自己的位置,如果不是自己的位置,就顺便进行数据的搬移。

    public static void insertSort(int[] array){
        for (int i = 0;i < array.length - 1; i++) {
            int key = array[i+1];
            int j;
            for (j = i; j >= 0 && key < array[j]; j--){
                array[j + 1] = array[j];  //数据搬移
            }
            array[j + 1] = key;
        }
    }
    

步骤:

  1. 每次把无序区间的第一个数,在有序区间内遍历(从后往前遍历)
  2. 找到合适的位置
  3. 搬移原有数据,腾出位置

4.性能分析

(1)时间复杂度
最好最坏平均
O(n)O( n 2 n^2 n2)O( n 2 n^2 n2)

最好情况:数组有序,只执行了外层循环
最坏情况:数组倒序,每次都要执行双重循环

插入排序越接近有序,执行时间效率越高

(2)空间复杂度 —— O(1)

因为没有多余使用的空间所以空间复杂度为常数级别。

(3)稳定性 —— 稳定

每次插入数据时,在有序区间里从后向前遍历插入,可以保证维持原有的顺序

二、冒泡排序

1. 思路讲解

冒泡排序也是插入排序的一种,它的思想是重复遍历无序区间,每次比较相邻两个数的大小,进行交换,直到最后将所有数字都排好序。这个算法的名字由来是因为越小的元素会经由交换慢慢“浮”到数列的顶端。

2.图解示例

3.代码演示

代码中添加isSorted标志位来检验整个数组是否是有序的,如果外部循环一次后都没改变标志位的值,那么就可以得出整个数组是有序的。

 public void bubbleSort (int[] arr) {
 		boolean isSorted = true;
        for(int i = 0; i < arr.length - 1; i ++) {
            for(int j = 0; j < arr.length - i - 1; j ++) {
                if(arr[j] > arr[j + 1]) {
                    int tmp = arr[j];
                    arr[j] = arr[j + 1];
                    arr[j + 1] = tmp;
                    isSorted = false;
                }
            }
            if(isSorted){
            	return ;
            }
        }
    }

4.性能分析

(1)时间复杂度
最好最坏平均
O(n)O( n 2 n^2 n2)O( n 2 n^2 n2)

最好情况:数组有序,只执行了外层循环
最坏情况:数组倒序,每次都要执行双重循环

(2)空间复杂度 —— O(1)

因为没有多余使用的空间所以空间复杂度为常数级别。

(3)稳定性 —— 稳定

冒泡排序只在相邻元素大小不符合要求时才调换他们的位置, 它并不改变相同元素之间的相对顺序, 因此它是稳定的排序算法。

三、希尔排序(shell sort)

1.思路讲解

由于插入排序的时间复杂度很不理想,平均的复杂度都要是O( n 2 n^2 n2),所以我们就需要新的算法来对其进行优化。
希尔排序就是对插入排序的优化,那么希尔排序是怎么做到的呢?
希尔排序是根据插入排序的特性:数组越接近有序,则效率越高。便使得数组尽可能趋于有序。首先将所有的数每次进行分组,一组数之间的间隔我们将其称为分组增量。分组的增量由大变小最后逐渐减小为0。对于每一组都进行插入排序,每排好一次序,数据就更加接近有序
那么就有一个问题了,每次应该怎样确定增量才合理呢?
一般情况下,如果数据的长度为size,那么每一次取得的增量值一般记作gap,它们之间就有如下关系:
gap = size; gap = gap / 3 + 1; 或者是gap = gap / 2;
例如:一个数组长度是10,那么可以将增量依次确定为4,2,1;也可以确定为 5 ,2,1

2.图解示例

现在有待排序序列 [3, 5, 9, 4, 2, 1, 7, 6, 8,0]
假设将 gap 分别取4, 2, 1
每次的同一个颜色的数字为一组
经过三次排序:
在这里插入图片描述
每次在小组中都进行简单的插入排序使得数据越来越接近有序化,最终当gap为1 时进行插排也会更快

3.代码演示

	//对每个小组进行插入排序
	private static void insertSortWithGap(int[] array, int gap) {
		for (int i = 0; i < array.length - gap; i++) {
			int key = array[i + gap];
			int j;
			for (j = i; j >= 0 && key < array[j]; j -= gap) {
				array[j + gap] = array[j];
			}
			array[j + gap] = key;
		}
	}
//确定每次的分组
	public static void shellSort(int[] array) {
	    int gap = array.length;
	    while (true) {
	        gap = gap / 3 + 1;
	        insertSortWithGap(array, gap);
	        if (gap == 1) {
	            return;
			}
		}
	}

4.性能分析

(1)时间复杂度

由于数据逐渐接近有序,因此,希尔排序的平均复杂度要优于直接插排

最好最坏平均
O(n)O( n 2 n^2 n2)O(n^(1.3-1.4))
(2)空间复杂度 —— O(1)

没有额外消耗空间,因此空间复杂度为常数级

(3)稳定性 —— 不稳定

有可能相同的两个数被分到不同的组别里,因此无法保证排序后的结果

四、选择排序

1.思路讲解

每次都遍历无序区间的数(这里可以直接遍历或者使用堆),选择出无序区间中最大的数,再把最大的数放到无序区间最后。一直选择n-1个数字后,数据完全有序。选择排序对总体数据不敏感,也即无论给定的数据的顺序,都不会影响复杂度。

2.图解示例

同样有待排序序列 [3, 5, 9, 4, 2, 1, 7, 6, 8,0]
这里演示简单的直接遍历方式
其中,黄色表示无序区间,蓝色表示有序区间
每次在无序区间内遍历选择出最大的数字与下一个要成为有序区间的位置进行交换,红色字体表示每次交换位置后的两个数字。在执行 n-1 次后,数据就完全有序
在这里插入图片描述
也可以每次在无序区间内选择处最小的数字进行交换n-1次,也是同样的排好了序。这里有动图帮助大家更好的理解:
在这里插入图片描述

3.代码演示

方式一: 大数字作为有序区间,小数字作为无序区间,每次找最大数字放最后。
无序区间 [0, array.length - i)
有序区间 [array.lenngth - i, array.length)
max 表示在无序区间选择的最大数字的下标

 public void selectSort(int[] arr) {
        for(int i = 0; i < arr.length - 1; i ++) {
            int max = 0;
            //选出待排序区间内最大的值
            for(int j = 0; j < arr.length - i; j ++){
                if(arr[j] >= arr[max]) {
                    max = j;
                }
            }
            //交换
            int tmp = arr[arr.length - i - 1];
            arr[arr.length - 1 -i] = arr[max];
            arr[max] = tmp;
        }
    }

方式二: 小数字作为有序区间,大数字作为无序区间,每次找最小数字放最后。
有序区间 [0,i)
无序区间 [ i ,arr.length )
min 表示在无序区间选择的最小数字的下标

    public void selectSort(int[] arr) {
        
        for(int i = 0; i < arr.length - 1; i ++) {
            int min = i;
            //选出待排序区间内最小的值
            for(int j = i; j < arr.length ; j ++){
                if(arr[j] < arr[min]) {
                    min = j;
                }
            }
            //交换
            int tmp = arr[i];
            arr[i] = arr[min];
            arr[min] = tmp;
        }
    }

4.性能分析

(1)时间复杂度
最好最坏平均
O ( n 2 O(n^2 O(n2) O ( n 2 ) O(n^2) O(n2) O ( n 2 ) O(n^2) O(n2)
(2)空间复杂度 —— O(1)

没有额外消耗空间,因此空间复杂度为常数级

(3)稳定性 —— 不稳定

比如【5,5,3】第一次交换就将第一个5交换到原来3的位置,导致两个5的相对位置就发生了改变。

五、堆排序

1.思路讲解

堆通常是一个可以被看做一棵完全二叉树的数组对象,并同时满足堆积的性质:即子结点的键值或索引总是小于(或者大于)它的父节点。
这样我们就可以知道如果是一个小堆,那么堆顶节点一定是最小的;如果是大堆,堆顶节点就是最大的。通过这个性质,就有了堆排序。

首先我们先要知道怎样进行建堆以及堆化(以大堆为例):

  • 如果index已经是叶子节点,则整个调整过程结束
    (1)判断 index 位置有没有孩子
    (2) 因为堆是完全二叉树,没有左孩子就一定没有右孩子,所以判断是否有左孩子
    (3) 因为堆的存储结构是数组,所以判断是否有左孩子即判断左孩子下标是否越界,即 left >= size 越界
  • 确定 left 或 right,谁是 index 的最大孩子 max
    (1) 如果右孩子不存在,则 max = left
    (2) 否则,比较 array[left] 和 array[right] 值得大小,选择大的为 max
  • 比较 array[index] 的值 和 array[max] 的值,如果 array[index] >= array[max],则满足堆的性质,调整结束
  • 否则,交换 array[index] 和 array[mav] 的值
  • 然后因为 max 位置的堆的性质可能被破坏,所以把 max 视作 index,向下重复以上过程

将整个堆建好后,随着对堆的调整后,位于堆顶的就是最大的元素,此时就可以得到最大的元素。将最后一个叶子节点与堆顶元素进行交换后,再次进行堆化操作又可以得到堆顶元素,此次得到的堆顶元素就是次大的,再次交换堆化…以此类推,多次交换以后,就可以逐步的实现排序操作。

对于排升序而言,推荐使用大根堆,排降序时,使用小根堆。这是因为使用堆排序主要是用堆顶元素,而每次将堆顶元素与叶子节点交换相当于将大数放到了数组的后面,方便我们排序。而如果使用小根堆,当我们取出堆顶元素时,此时小根堆的性质就变了,那么下次就找不到第二小的元素了,还要重新建堆。

2.图解示例

在这里插入图片描述

3.代码演示

无序区间 [0,i]
有序区间 (i,arr.length - 1]
每次取出无序区间一个最大数,共要取出arr.length - 1次,每一次取出后与下一个有序区间的位置进行互换。
如果知道根节点index,那么左子树的位置就是2 * index + 1,右子树位置就是左子树的下一个节点。代码中的 j = (n - 1)/2是求得第一个非叶子节点的节点下标。
每次从第一个非叶子节点开始进行堆化找最大值,对于堆化,就像我们上面讲的原理来实现。

    public void heapSort(int[] arr) {
        for(int i = arr.length - 1; i > 0; i --) {
            heapify(arr, i);
            //交换堆顶元素
            int tmp = arr[0];
            arr[0] = arr[i];
            arr[i] = tmp;
        }
    }
    public void heapify(int[] arr,int n) {
        int child;//表示左右节点中较大的节点的下标
        //j表示第一个非叶子节点的节点的下标
        for( int j = (n - 1)/2 ; j >= 0; j --) {
            child = 2 * j + 1;//左子节点位置
            //右子树存在且大于左子树节点,child就变成右节点
            if (child < n && arr[child] < arr[child + 1]) {
                child ++;
            }
            //根节点如果小于子节点则交换
            if(arr[j] < arr[child]) {
                int tmp = arr[child];
                arr[child] = arr[j];
                arr[j] = tmp;
            }
        }
    }

4.性能分析

(1)时间复杂度
最好最坏平均
O ( n l o g n O(nlogn O(nlogn) O ( n l o g n O(nlogn O(nlogn) O ( n l o g n O(nlogn O(nlogn)
(2)空间复杂度 —— O(1)

没有额外消耗空间,因此空间复杂度为常数级

(3)稳定性 —— 不稳定

由于多次任意下标相互交换位置, 相同元素之间原本相对的顺序被破坏了。因此, 它是不稳定的排序。

六、快速排序

1.思路讲解

快排是一种分治的做法来对数据进行排序的。主要的步骤呢就是分为三步:

  1. 在整个待排序的区间中确定一个基准值(pivot)

  2. 遍历整个排序区间,把所有值和基准值进行比较,最终达到(partition):
    比基准值小的就放在基准值左边
    比基准值大的就放在基准值右边
    在这个分区结束之后,该基准就处于数列的中间位置。

  3. 这样再用同样的策略去处理左右的两个小区间,直到:
    小区间中已经没有数据了
    小区间中的数据是有序的

这样就完成了对整个区间的排序。
那么partition方法就需要实现以数组中的某一个数为基准值,比这个值大的就放到左边比这个数小的放到右边。

2.图解示例

在这里插入图片描述

3.代码演示

这里的partition方法的实现右很多种,我在这里列举几种:
partition1: hover法
详细的注释都标到代码中了,这里的partition实现的方式就是使用两个标记分别从数组的首尾向中间逼近,并再次在此过程中进行交换数据的元素。
partition2: 挖坑-填坑法
由于hover法导致进行很多次的交换,所以挖坑-填坑法就对其进行了改进。总体思路右类似的地方,都是通过两个标志来向中间靠,不同的是不需要多次交换而是采用覆盖的方式,这是因为一开始的pivot保存了最右边的数字,因此该位置就可以供下次找到大于基准值的数字时放到这个位置上,这样每覆盖一个数就留下一个空缺供下一次覆盖,到最后的循环结束后就讲最后一个“坑”用pivot来填充。
partition3: 前后下标法
这种方式也很直接,用一个less标记从头记录,遍历整个数组,小于pivot就将它与less位置的元素交换位置,再使标志位后移。

class QuickSort{
	public void quickSort(int[] arr) {
	        quickSortInternal(arr, 0, arr.length -1);
	    }
	public void swap(int[] arr, int i,int j) {
	        int tmp = arr[i];
	        arr[i] = arr[j];
	        arr[j] = tmp;
	    }
    private void quickSortInternal(int[] arr, int left, int right) {
        if(left >= right) return;
        //1.确定基准值arr[right]作为基准值
        //2.遍历,小的左,大的右
        int pivotIndex = partition(arr,left,right);
        //此时pivotIndex处的元素已经找到了自己的位置
        //以pivotIndex为基准值分成两个小区间,它是分区的下标指引
        //[left,pivotIndex-1]
        //[pivotIndex+1,right]
        quickSortInternal(arr,left,pivotIndex - 1);
        quickSortInternal(arr,pivotIndex + 1,right);
    }

    private int partition1(int[] arr, int left, int right) {
        int pivot = arr[right]; //以最右边的数字为基准值
        int less = left; //左标记
        int great = right; //右标记
        while(less < great) {
            //从前往后找大于基准值的数
            while(less < great && arr[less] <= pivot) {
                less ++;
            }
            //从后往前找小于基准值的数
            while(less < great && arr[great] >= pivot) {
                great --;
            }
            //找到后进行交换
            swap(arr,less,great);
        }
        //循环结束后,基准值还没进行交换,最后要交换
        swap(arr,less,right);
        //此时的less就是接下来继续分区的标志
        return less;
    }
    
	private int partition2(int[] arr, int left, int right) {
        int pivot = arr[right];
        int less = left;
        int great = right;
        while(less < great) {
            while(less < great && arr[less] <= pivot) {
                less ++;
            }
            arr[great] = arr[less];
            while(less < great && arr[great] >= pivot) {
                great --;
            }
            arr[less] = arr[great];
        }
        arr[less] = pivot;
        return less;
    }
    public int partition3(int[] arr,int left, int right){
        int pivot = arr[right];
        int less = left;
        for (int i = left; i < right; i ++) {
            if(arr[i] < pivot) {
                swap(arr,less,i);
                less ++;
            }
        }
        swap(arr, less, right);
        return less;
    }
}

以上是使用递归来进行排序,我们也有快排的非递归的方法。由于递归就是通过调用栈实现的,所以我们非递归实现的过程中,可以借助栈来保存中间变量就可以实现非递归了。在这里中间变量也就是通过Pritation函数划分区间之后分成左右两部分的首尾指针,只需要保存这两部分的首尾指针即可。

public static void quickSortNoR(int[] array) {
		Stack<Integer> stack = new Stack<>();
		stack.push(array.length - 1);
		stack.push(0);

		while (!stack.isEmpty()) {
		    int left = stack.pop();
		    int right = stack.pop();
		    if (left >= right) {
		        continue;
			}
			//用到的partition1方法到递归方式的代码中找
		    int pivotIndex = partition1(array, left, right);
		    // [left, pivotIndex - 1]
			// [pivotIndex + 1, right]
			stack.push(right);
			stack.push(pivotIndex + 1);

			stack.push(pivotIndex - 1);
			stack.push(left);
		}
	}

4.性能分析

(1)时间复杂度
最好最坏平均
O ( n l o g n O(nlogn O(nlogn) O ( n 2 O(n^2 O(n2) O ( n l o g n O(nlogn O(nlogn)

这里我们来看一下怎么计算时间复杂度,分两个部分来看:

  1. partition 的过程是对于数组中的每一个数都进行遍历,所以复杂度都为O(n)
  2. 要做多少次partition 呢?
    这里可以把分治的过程看作是一颗二叉树,其高度就是层数,而二叉树的高度为 O ( l o g ( n ) ) O(log(n)) O(log(n)) ~ O ( n ) O(n) O(n)

因此时间复杂度就是 O ( n ∗ l o g ( n ) ) O(n*log(n)) O(nlog(n)) ~ O ( n 2 ) O(n^2) O(n2)

(2)空间复杂度
最好最坏平均
O ( l o g n O(logn O(logn) O ( n O(n O(n) O ( l o g n O(logn O(logn)

接着来看看空间复杂度的计算:我们要考虑的就是递归方法需要调用的栈有多少层。实际上也就是二叉树的高度

因此就得到空间复杂度 O ( l o g ( n ) ) O(log(n)) O(log(n)) ~ O ( n ) O(n) O(n)

(3)稳定性 —— 不稳定

由于也会对数组中的数据进行交换,因此也是不稳定的

我们常使用的Arrays.sort(基本数据类型的数组)就基本上使用的是快排。 快排更加适合数据量较大的应用场景,数据量小时,往往使用插入排序。在Java中默认的阈值是48.

七、归并排序

1.思路讲解

归并排序算法是将两个(或两个以上)有序表合并成一个新的有序表,即把待排序序列分为若干个子序列,每个子序列是有序的。然后再把有序子序列合并为整体有序序列。
基本步骤:

  1. 找到中间位置,划分左右两个小区间,直到小区间长度为1或者小于1
  2. 分治思想,先排序左右两个小区间
  3. 合并有序数组

2.图解示例

在这里插入图片描述

3.代码演示

首先呢,就是要通过递归来进行每一次的区间划分,当区间大小为1或者0时就不用继续分割。接着在merge方法中要做的就是合并两个有序的数组,通过创建一个额外的辅助数组来进行,每次在两个数组之中找到最小的数字放到额外数组中去,最后合并结束再将整个数组拷贝回来,这样每一个小区间就都变成了有序区间。

 	public static void mergeSort(int[] array) {
        mergeSortInternal(array, 0, array.length);
    }
    private static void mergeSortInternal(int[] array, int low, int high) {
        if (low + 1 >= high) {
            return;
        }

        int mid = (low + high) / 2;
        // [low, mid)
        // [mid, high)
        mergeSortInternal(array, low, mid);
        mergeSortInternal(array, mid, high);
        merge(array, low, mid, high);
    }
   private static void merge(int[] array, int low, int mid, int high) {
        int length = high - low;
        int[] extra = new int[length];
        // [low, mid)
        // [mid, high)

        int iLeft = low;
        int iRight = mid;
        int iExtra = 0;
        //合并有序链表的循环条件
        while (iLeft < mid && iRight < high) {
            if (array[iLeft] <= array[iRight]) {
                extra[iExtra++] = array[iLeft++];
            } else {
                extra[iExtra++] = array[iRight++];
            }
        }
        //循环结束后保证将有剩余元素的数组的剩余元素都拷贝到extra数组
        while (iLeft < mid) {
            extra[iExtra++] = array[iLeft++];
        }

        while (iRight < high) {
            extra[iExtra++] = array[iRight++];
        }
        //将extra数组中的数重新拷贝回去
        for (int i = 0; i < length; i++) {
            array[low + i] = extra[i];
        }
    }

同样,归并排序也有非递归形式:

public static void mergeSortNoR(int[] array) {
		for (int i = 1; i < array.length; i = i * 2) {
			for (int j = 0; j < array.length; j = j + 2 * i) {
			    int low = j;
			    int mid = j + i;
			    int high = mid + i;
			    if (mid >= array.length) {
			        continue;
				}
			    if (high > array.length) {
			    	high = array.length;
				}

			    merge(array, low, mid, high); //该方法在递归形式实现中找
			}
		}
	}
	

4.性能分析

(1)时间复杂度
最好最坏平均
O ( n l o g n O(nlogn O(nlogn) O ( n l o g n O(nlogn O(nlogn) O ( n l o g n O(nlogn O(nlogn)

说明归并排序对数据是不敏感的,无论数据的形式,时间复杂度都不变

(2)空间复杂度 —— O(n)

在合并有序数组的过程中需要使用额外的数组

(3)稳定性 —— 稳定

整个操作不会影响数据的先后

优化点:

  1. 减少搬移次数
  2. 判断左边的最大数和右边的最小数

应用 : 我们常用的Arrays.sort引用数据类型)是使用的归并排序
归并排序是一种常见的外部排序也即不是左右的操作都需要借助内存的排序
如果数据量大到内存中都放不下了,就用归并排序来处理。把大的数据量分成多路,使用多路归并,最终合并有序。

八、七大排序的总结:

在这里插入图片描述
在这里插入图片描述

以上就是自己总结的七大排序的性质,虽然这七个排序确实很容易让人懵但是花点时间自己总结一下思路就会很清晰了。如果有问题欢迎指正,希望可以帮到你,也欢迎小伙伴们点赞关注一起进步!

  • 0
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值