目录
七大排序
1. 概念
数据结构ds:关心的是如何高效的管理数据,CURD 增删改查;链表,数组,哈希表,二叉树等。
算法:如何利用好数据。
排序算法:如何按照的一定的规则、方法将给定的一组数据进行由大到小的排序。排序,就是使一串记录,按照其中的某个或某些关键字的大小,递增或递减的排列起来的操作。如果提到排序,通常指的是排升序(非降序)。通常意义上的排序,都是指的原地排序(in place sort)。
升序和降序
严格升序(不包含重复元素):数字由小到大依次排列i1 < i2非降序:前一个元素 <= 后一个元素,依次类推。在指定的集合中若发现i1 > i2,则这个集合一定不是非降序集合。
非升序:前一个元素 >= 后一个元素,依次类推。
严格降序(不包含重复元素):数字由大到小依次排列i1 > i2
原地堆排序:不开辟额外的数组空间进行的排序。
2. 排序的稳定性
在选择排序算法时,除了时间复杂度和空间复杂度之外。某些场景下,排序的稳定性也是我们考量的因素。
稳定性:待排序的序列中,存在值相等的元素,经过排序之后,值相等元素的先后顺序没有发生变化的排序算法,称之为稳定的排序算法。
排序之前:
9,2,3,5(a),4,1,5(b),6
经过排序算法A之后——>稳定的排序,5a和5b先后顺序没有发生变化。
A:1,2,3,4,5(a),5(b),6,9
经过排序算法B之后——>排序B算法称之为不稳定的排序,5a和5b的先后顺序发生改变。
B:1,2,3,4,5(b),5(a),6,9
例如:商城订单系统
每个订单的属性(单号,金额,下单时间),默认按照单号在排序。假设我现在要按照订单金额由小到大进行订单的排序,最希望的是按照订单金额从小到大排序后,时间顺序不要发生改变。
选择稳定性的排序算法,按照金额排序之后,彼此的下单顺序没有改变。
3. 关于内部排序和外部排序
3.1 内部排序(七大排序)
内部排序:待排序的数据都存放在内存中,都是基于元素的比较来进行的,存储的元素有明确的大小关系。
例如:i1 = 10; i2 = 20;
排序i1和i2时,就是比较i1和i2的大小关系。
快速排序:20世纪最伟大的算法之一。
归并排序:在21世纪发挥出重要价值。
如:TimSort现在已经是默认主流编程语言的排序算法,Python,Java。Arrays.sort(int[]arr)的内部是基于TimSort排序的
3.2 外部排序
外部排序:数据存储在硬盘中,每次排序都需要从硬盘读取一部分内容到内存中,把这部分数据排序之后写回硬盘。
例如:桶排序、基数排序、计数排序,这三个算法时间复杂度为O(n),不具备普遍性,每个算法都需要特殊的数据场景下才能使用。
4. 基础排序算法O(n^2)
基础的排序算法往往作为高阶排序算法的优化手段。例如插入排序经常用在高阶排序的优化中。
一定要定义好待排序数组区间和已经排序好的数组区间。
4.1 选择排序
直接选择排序(不稳定)
每次从无序区间选择一个最大或最小值的一个元素放在无序区间的最后或者最前,直到待排序的所有元素排序完毕。
最开始时,待排序数组(无序区间),取值:[i..n)。
已排序的数组(有序区间),取值:[],区间中没有任何元素。
进行第一次排序:选择无序区间的最小值,放在无序区间的最开始位置。选择排序在排序过程中无法保证相同元素的先后顺序,故不是一个稳定性的算法。
/** * 选择排序 */ public static void selectionSort(int[] arr) { // 最开始无序区间[i...n) // 有序区间[] // 最外层的for循环指的循环走的趟数,每走一趟外层循环,就有一个最小值放在了正确的位置 for (int i = 0; i < arr.length - 1; i++) { // min指的是最小元素的索引下标 int min = i; // 内层循环在查找当前无序区间的最小值索引 for (int j = i + 1; j < arr.length; j++) { if (arr[j] < arr[min]) { // j对应的元素比当前最小值还小 min = j; } } // min这个变量一定保存了当前无序区间的最小值索引 // 有序区间[0..i) + 1 // 无序区间[i..n) - 1 swap(arr,i,min); } }
双向选择排序
每次从无序区间中选出最小值和最大值,存放在无序区间的最开始和最后面位置,重复上述过程。
/** * 双向选择排序 */ public static void selectionSortOP(int[] arr) { int low = 0; int high = arr.length - 1; // low == high => 无序区间只剩下最后一个元素,其实整个数组已经有序了。 while (low < high) { int min = low; int max = low; for (int i = low + 1; i <= high; i++) { if (arr[i] < arr[min]) { min = i; } if (arr[i] > arr[max]) { max = i; } } // 此时min对应了最小值索引,交换到无序区间的最前面 swap(arr, min, low); // 边界条件 low == max if (max == low) { max = min; } swap(arr, max, high); low++; high--; } }
若恰好max的索引就是low的索引(边界条件),交换最小值到最前面,就会将9换到min的索引位置, 此时更新max的索引为min。
4.2 冒泡排序(稳定)
/** * 冒泡排序 */ public static void bubbleSort(int[] arr) { for (int i = 0; i < arr.length - 1; i++) { boolean isSwaped = false; for (int j = 0; j < arr.length - i - 1; j++) { if (arr[j] > arr[j + 1]) { swap(arr, j, j + 1); isSwaped = true; } } if (!isSwaped) { // 内层没有元素交换,此时整个数组已经有序 break; } } }
4.3 插入排序
直接插入排序 (稳定)
插入排序:类似打牌码牌的过程。
直接插入排序:将待排序的集合看做两部分,已排序的区间[0..i);待排序的区间[i...n);每次选择无序区间的第一个元素插入到有序区间的合适位置,直到整个数组有序。
初始数据越接近有序,插入排序效率越高,经常作为高阶排序算法优化手段。
当arr[i] > arr[i - 1],说明此时arr[i]大于有序区间的所有元素,直接进行i++操作。
有序区间[1,2,3,4,5],无序区间[6,9,8,7,2,10]——>有序区间[1,2,3,4,5,6],无序区间[9,8,7,2,10]。
/** * 直接插入排序 * 已排序区间[0..i) => 默认第一个元素就是已经排好序的区间 * 待排序区间[i...n) */ public static void insertionSort(int[] arr) { for (int i = 1; i < arr.length; i++) { // 已排序区间[0...1) // 待排序区间[i ..n) // 选择无序区间的第一个元素,不断向前看 // 注意看内层循环的终止条件 j >= 1而不是 j >= 0 ? // 因为此时arr[j] 不断向前看一个元素。 j - 1 要合法 j - 1 >= 0 for (int j = i; j >= 1 && arr[j] < arr[j - 1]; j--) { swap(arr, j, j - 1); // // 边界 // if (arr[j] > arr[j - 1]) { // // arr[i] 恰好是有序区间的后一个元素,无序区间的第一个元素 // // 当前无序区间的第一个元素 > 有序区间最后一个元素 // break; // }else { // swap(arr,j,j - 1); // } } } }
插入排序和选择排序相比到底优在哪?
和选择排序最大的区别,当已经排序的集合的最后元素 < 当前无序区间的第一个元素,内层循环可以直接退出,大大降低了时间复杂度。极端情况:若待排序的数组就是一个完全升序数组,插入排序就会进化为O(n),内层循环一次也不走(最好情况时间复杂度)。
为什么插入排序稳定?
只有交换会打破原有的元素顺序,当arr[j] >= arr[j- 1] 不会交换其顺序,循环退出。
例如:数组[5a,5b],两个元素相等。不进入循环,故不交换。
二分插入排序
二分插入排序:选择无序区间的第一个元素插入到"有序区间"的位置时,使用二分查找优化插入位置的查找次数。
/** * 二分插入排序 * @param arr */ public static void insertionSortBS(int[] arr) { // 有序区间[0..i) // 无序区间[i..n) for (int i = 0; i < arr.length; i++) { int val = arr[i]; // 有序区间[left...right) int left = 0; int right = i; while (left < right) { int mid = (left + right) / 2; if (val < arr[mid]) { right = mid; }else { // val >= arr[mid] left = mid + 1; } } // 搬移[left..i)的元素 for (int j = i; j > left ; j--) { arr[j] = arr[j - 1]; } // left就是待插入的位置 arr[left] = val; } }
5. 高阶排序算法
5.1 希尔排序(不稳定)
希尔排序:就是插入排序的优化。(缩小增量排序)
不断将小数组调整为近乎有序,整个大数组就接近有序状态,这个时候使用插入排序效率很高。核心思想:我们发现,当数组近乎有序时,插入排序的效率非常好。
思路:
step1. 先选定一个整数(gap),将待排序的数据分成gap组,所有距离为gap的为同一组。对每一个小数组先进行插入排序。step2. gap = gap / 2(3),重复step1。
当gap == 1。说明整个数组已经被调整的近乎有序,此时针对整个数组进行一次插入排序即可,效率一定是比较高的。稳定性:不稳定,再按照gap对子数组进行排序时,有可能值相等的两个元素被分到了两个不同的数组,他们的顺序可能交换。如下图,相等的5,在第一趟排序就交换了位置。
/** * 希尔排序 - 缩小增量排序, * 按照gap将原数组分为gap个子数组,子数组内部先排序,不断缩小gap值,直到gap = 1 * 当gap = 1时,整个数组已经近乎有序,只需要最后来一次插入排序即可 */ public static void shellSort(int[] arr) { int gap = arr.length >> 1; // 预处理阶段 while (gap >= 1) { // 按照gap分组之后,组内进行插入排序 insertionSortByGap(arr, gap); gap = gap >> 1; } } /** * 极端情况,假设此时gap = 1,类似与插入排序 */ private static void insertionSortByGap(int[] arr, int gap) { // [9,1,2,5,7,4,8,6,3,5] gap = 5 // i = 5 for (int i = gap; i < arr.length; i++) { for (int j = i; j - gap >= 0 && arr[j] < arr[j - gap]; j -= gap) { swap(arr, j, j - gap); } } }
5.2 堆排序(不稳定)
给定任意的数组,在这个数组的基础上进行堆排序,不创建任何额外空间。[15,19,11,18,14,17,6,3,8,1,9]
1. 任意数组其实就可以看做是一个完全二叉树,将这个数组调整为最大堆。heapify调整为最大堆。从最后一个非叶子结点开始进行元素siftDown操作。
2. 不断交换堆顶元素和最后一个元素的位置,将堆顶元素继续进行siftDown。最大值放在最终位置,直到数组剩下一个未排序的元素为止。package sort; import java.util.Arrays; public class SevenSort { public static void main(String[] args) { int[] arr = {15,19,11,18,14,17,6,3,8,1,9}; heapSort(arr); System.out.println(Arrays.toString(arr)); } // 原地堆排序 public static void heapSort(int[] arr) { //将任意数组进行heapify操作,调整为最大堆 for (int i = (arr.length - 1 - 1) / 2; i >= 0; i--) { siftDown(arr, i, arr.length); } // 2.不断交换堆顶元素到数组末尾, // 每交换一个元素就有一个元素落在了最终位置 for (int i = arr.length - 1; i > 0; i--) { //arr[i]就是未排序数组的最大值,交换到末尾 swap(arr, 0, i); siftDown(arr, 0, i); } } private static void swap(int[] arr, int i, int j) { int tmp = arr[i]; arr[i] = arr[j]; arr[j] = tmp; } /** * 元素下沉操作,最大堆——顶大底小 */ private static void siftDown(int[] arr, int i, int length) { while ((2 * i + 1) < length) { int j = 2 * i + 1; if (j + 1 < length && arr[j + 1] > arr[j]) { j = j + 1; } if (arr[i] > arr[j]) { break; } else { swap(arr, i, j); i = j; } } } }
5.3 归并排序(稳定)
归并排序:归而为一
归:不断将原数组拆分为子数组(一分为二),直到每个子数组只剩下一个元素,归过程结束。
并:不断合并相邻的两个子数组为一个大的子数组,合并的过程就是将两个已经有序的子数组合并为一个大的有序子数组,直到合并到整个数组。最核心的merge操作:需要开辟额外空间,空间大小就是合并后的数组大小。
1. 先将两个子数组的所有内容复制到新数组中。
2. 遍历两个子数组,将较小值写回原数组。
归并排序递归写法
/** * 归并排序 */ public static void mergeSort(int[] arr) { mergeSortInternal(arr,0,arr.length - 1); } /** * 在arr[l...r]上进行归并排序 */ private static void mergeSortInternal(int[] arr, int l, int r) { // 2.小数组直接使用插入排序 if (r - l <= 15) { insertionSort(arr,l,r); return; } // int mid = (l + r) / 2; // l = 2,r = 4 mid = 3 = 2 + (4 - 2) / 2 = 3 int mid = l + ((r - l) / 2); mergeSortInternal(arr,l,mid); mergeSortInternal(arr,mid + 1,r); // arr[l..mid] 和 arr[mid + 1...r]有序 只需要合并这两个子数组即可 // 1.到底什么时候才需要合并 arr[mid] < arr[mid + 1]? // arr[mid] 数组1的最大值 < arr[mid + 1]数组2的最小值 // 整个数组已经有序了,不用合并 if (arr[mid] > arr[mid + 1]) { // 前后两个子数组还存在乱序,才需要合并 merge(arr,l,mid,r); } } /** * 在arr[l..r]上进行插入排序 */ private static void insertionSort(int[] arr, int l, int r) { for (int i = l + 1; i <= r; i++) { for (int j = i; j >= l + 1 && arr[j] < arr[j - 1]; j--) { swap(arr,j,j - 1); } } } /** * 将有序子数组arr[l..mid] 和 有序arr[mid + 1...r] 合并为一个大的有序数组arr[l..r] */ private static void merge(int[] arr, int l, int mid, int r) { // 先创建一个新的数组aux,将子数组的值复制给新数组 int[] aux = new int[r - l + 1]; // l = 2,r = 4 // arr[2..4] // aux[0..2] 索引下标差了个l偏移量 for (int i = 0; i < aux.length; i++) { // aux的索引下标0...arr.length - 1 // arr的下标l...r aux[i] = arr[i + l]; } // 数组1的开始下标 int i = l; // 数组2的开始下标 int j = mid + 1; for (int k = l; k <= r; k++) { if (i > mid) { // 第一个数组已经遍历完毕 arr[k] = aux[j - l]; j ++; }else if (j > r) { // 第二个子数组遍历完毕 arr[k] = aux[i - l]; i ++; }else if (aux[i - l] <= aux[j - l]) { // 将aux[i - l]写回arr[k] arr[k] = aux[i - l]; i ++; }else { // aux[i - l] > aux[j - l] 写回aux[j - l] arr[k] = aux[j - l]; j ++; } } }
迭代写法
/** * 迭代实现归并排序(了解) * @param arr */ public static void mergeSortNonRecursion(int[] arr) { // 最外层循环表示每次合并的子数组的元素个数 // 子数组为1个元素,第二次循环2个元素,第三次循环合并4个元素,第四次8个元素 .. // 直到整个数组合并完成 for (int sz = 1;sz <= arr.length ;sz += sz) { // 内存循环变量i就是每次合并操作的开始索引l // [8,6,2,3,1,5,7,4] => [2,3,6,8] [1,4,5,7] // i for (int i = 0; i + sz < arr.length; i += sz + sz) { // 边界i + sz + sz - 1 > arr.length merge(arr,i,i + sz - 1,Math.min(i + sz + sz - 1,arr.length - 1)); } } }
时间复杂度:稳定O(nlogn)
空间复杂度:O(n)
稳定性:稳定与否就看merge的操作。此处的 <= 使相等元素的相对位置不变,保证合并过程中的稳定性。
经典面试题:海量数据处理
用到外部存储器
内存只有1G,待排序的数据有100G,该如何对这100G的数据进行排序?与数据特征(接近有序 / 无序)无关。a. 先把这100G的数据分为200份,每份为0.5G
b. 分别将0.5G的数据读入内存,进行内部排序(归并,快排,堆排)c. 进行200个文件的merge操作即可,整个结果就有序了。
5.3 快速排序(不稳定)
快速排序:20世纪最伟大的算法之一
核心思路:分区
分区值:默认选择最左侧元素 pivot
从无序区间选择一个值作为分界点 pivot ,开始扫描原集合,将数组中所有 < 该pivot的元素放在分界点左侧;>= 该元素的值放在分区点的右则。经过本轮交换,pivot放在了最终位置,pivot的左侧都是小于该值的元素,pivot的右侧都是大于该值的元素,在这两个子区间重复上述过程,直到整个集合有序。
分区方法1 <<算法导论>>中的分区思想
递归写法(没有优化)
public static void quickSort(int[] arr) { quickSortInternal(arr,0,arr.length - 1); } private static void quickSortInternal(int[] arr, int l, int r) { if (l <= r) { return; } int p = partition(arr,l,r); // 继续在左右两个子区间进行快速排序 // 所有 < v的元素 quickSortInternal(arr,l,p - 1); // 所有 >= v的元素 quickSortInternal(arr,p + 1,r); } private static int partition(int[] arr, int l, int r) { int v = arr[l]; // arr[l + 1..j] < v // 最开始区间没有元素 int j = l; // arr[j + 1..i) >= v // 最开始大于区间也没有元素 for (int i = l + 1; i <= r; i++) { if (arr[i] < v) { swap(arr,i,j + 1); j ++; } } // 此时元素j就是最后一个 < v的元素,就把v换到j的位置 swap(arr,l,j); return j; }
迭代写法,借助栈的结构
/** * 快速排序(迭代) */ public static void quickSortNonRecursion(int[] arr) { Deque<Integer> stack = new ArrayDeque<>(); // r stack.push(arr.length - 1); // l stack.push(0); // 每次从栈中取出两个元素,这辆个元素就是待排序区间的l..r while (!stack.isEmpty()) { int l = stack.pop(); int r = stack.pop(); if (l >= r) { // 当前子数组已经处理完毕 continue; } int p = partition(arr,l,r); // 继续入栈两个子区间 // 左 stack.push(p - 1); stack.push(l); // 右 stack.push(r); stack.push(p + 1); } } private static int partition(int[] arr, int l, int r) { // 1.优化1.使用一个随机位置作为分区点,避免快排在近乎有序数组上的性能退化 int randomIndex = random.nextInt(l, r); swap(arr, l, randomIndex); int v = arr[l]; // arr[l + 1..j] < v // 最开始区间没有元素 int j = l; // arr[j + 1..i) >= v // 最开始大于区间也没有元素 for (int i = l + 1; i <= r; i++) { if (arr[i] < v) { swap(arr, i, j + 1); j++; } } //此时元素j就是最后一个 < v的元素,就把v换到j的位置 swap(arr, l, j); return j; }
问题:迭代与递归两种方法的空间复杂度一样吗?
答:一样
递归方法空间复杂度是递归的调用次数,迭代写法就是栈的深度,和递归的调用次数相同O(logn)。
分区方法2 Hoare挖坑法
两个索引 i 和 j
先让 j 从后向前找到第一个小于 v 的元素停止while(i < j && arr[i] >= v){ j --; }
当 j 走到了小于 v 的元素索引,交换:arr[i]= arr[j];
再让 i 从前向后找到第一个大于 v的元素停止while(i < j && arr[i] <= v){ i ++; }
当 i 走到了大于 v 的元素索引,交换:arr[j] = arr[i];
当 i 和 j 重合时,执行arr[i] = pivot 即可。
没有元素交换,都是直接赋值,理论上会减少因为交换带来的时间损耗。
挖坑法代码实现
/** * 挖坑分区法 */ public static void quickSortHoare(int[] arr) { quickSortInternalHoare(arr, 0, arr.length - 1); } private static void quickSortInternalHoare(int[] arr, int l, int r) { // 2.小区间上使用插入排序来优化,不用递归到底 if (r - l <= 15) { insertionSort(arr, l, r); return; } int p = partitionHoare(arr, l, r); // 继续在左右两个子区间进行快速排序 // 所有 < v的元素 quickSortInternalHoare(arr, l, p - 1); // 所有 >= v的元素 quickSortInternalHoare(arr, p + 1, r); } private static int partitionHoare(int[] arr, int l, int r) { int randomIndex = random.nextInt(l, r); swap(arr, l, randomIndex); int pivot = arr[l]; int i = l; int j = r; while (i < j) { // 先让j从后向前扫描到第一个 < v的元素停止 while (i < j && arr[j] >= pivot) { j--; } arr[i] = arr[j]; // 再让i从前向后扫描到第一个 > v的元素停止 while (i < j && arr[i] <= pivot) { i++; } arr[j] = arr[i]; } arr[i] = pivot; return i; }
复杂度分析
时间复杂度:O(nlogn)
n -每次 partition 分区函数的数组扫描。logn -递归次数,"递归树"的高度。
空间复杂度:递归调用次数O(logn)
递归调用次数平均情况下,就是一个二叉树的高度logn。
与归并排序的差异
归并排序:无论数据长啥样子,都一分为二,保证递归次数一定是logn级别,非常稳定的nlogn的算法。
快速排序:性能严格受制于初始数据的情况而定。关于分区点选择问题:极端情况下,数组就是一个完全有序的数组。当原数组近乎有序时,按照最左侧元素进行分区的时候,造成左右两颗递归树严格不平衡,甚至极端情况下退化为链表(冒泡排序)。
内层:O(logn) - > O(n)
解决办法
时间复杂度退化:nlogn -> n^2分区值选择不能武断的就选择最左侧或者最右侧。
a. 三数取中最左侧,最右侧,中间值选择其中之一
b. 每次递归时选择数组中任意一个元素作为分区点。
关于分区点的选择。使用随机数随机取一个数组索引的元素作为分区点,基本上不可能出现单支树的情况。避免近乎有序数组上快排退化问题。
优化后的快速排序
public static void quickSort(int[] arr) { quickSortInternal(arr,0,arr.length - 1); } private static void quickSortInternal(int[] arr, int l, int r) { // 优化2.小区间上使用插入排序来优化,不用递归到底 if (r - l <= 15) { insertionSort(arr,l,r); return; } int p = partition(arr,l,r); // 继续在左右两个子区间进行快速排序 // 所有 < v的元素 quickSortInternal(arr,l,p - 1); // 所有 >= v的元素 quickSortInternal(arr,p + 1,r); } /** * 在arr[l..r]上进行插入排序 */ private static void insertionSort(int[] arr, int l, int r) { for (int i = l + 1; i <= r; i++) { for (int j = i; j >= l + 1 && arr[j] < arr[j - 1]; j--) { swap(arr, j, j - 1); } } } private static ThreadLocalRandom random = ThreadLocalRandom.current(); private static int partition(int[] arr, int l, int r) { // 1.优化1.使用一个随机位置作为分区点,避免快排在近乎有序数组上的性能退化 int randomIndex = random.nextInt(l,r); swap(arr,l,randomIndex); int v = arr[l]; // arr[l + 1..j] < v // 最开始区间没有元素 int j = l; // arr[j + 1..i) >= v // 最开始大于区间也没有元素 for (int i = l + 1; i <= r; i++) { if (arr[i] < v) { swap(arr,i,j + 1); j ++; } } // 此时元素j就是最后一个 < v的元素,就把v换到j的位置 swap(arr,l,j); return j; }