题目:汇总常见排序算法
一、冒泡排序法
核心思想:冒泡排序只会操作相邻的两个数据。每次冒泡操作都会对相邻的两个元素进行比较,看是否满足大小关系要求。如果不满足就让它俩互换。一次冒泡会让至少一个元素移动到它应该在的位置,重复 n 次,就完成了 n 个数据的排序工作。
1 import java.util.Arrays; 2 3 public class Test { 4 5 public static void main(String[] args) { 6 int[] data = new int[] { 26, 53, 67, 48, 57, 13, 48, 32, 60, 50 }; 7 sort1(data); 8 System.out.println(Arrays.toString(data)); 9 } 10 11 // 冒泡排序 12 public static void sort1(int[] arr) { 13 if (arr == null || arr.length < 2) { 14 return; 15 } 16 // 当某次冒泡操作已经没有数据交换时,说明已经达到完全有序,不用再继续执行后续的冒泡操作 17 boolean flag = false; // 提前退出冒泡循环的标志位 18 for (int j = 0; j < arr.length - 1; j++) {// 控制轮数 19 flag = false; 20 for (int i = 0; i < arr.length - 1 - j; i++) {// 控制相邻两数的比较次数 21 if (arr[i] > arr[i + 1]) { 22 arr[i] ^= arr[i + 1]; 23 arr[i + 1] ^= arr[i]; 24 arr[i] ^= arr[i + 1]; 25 flag = true;// 当发生数据交换时,将标志置位true,代表本轮遍历有发生元素交换 26 } 27 } 28 if (!flag) {// 没有数据交换,提前退出 29 break; 30 } 31 } 32 } 33 }
---------> [13, 26, 32, 48, 48, 50, 53, 57, 60, 67]
二、选择排序法
核心思想:选择排序算法的实现思路有点类似插入排序,也分已排序区间和未排序区间。但是选择排序每次会从未排序区间中找到最小的元素,将其放到已排序区间的末尾。
(已优化)算法:
// 选择排序 public static void sort(int[] arr) { int i, j, temp; for (i = 0; i < arr.length - 1; i++) {// 控制轮数 // 在每轮中,temp初始存储本轮最小的数的下标 temp = i; for (j = i + 1; j < arr.length; j++) { if (arr[j] < arr[temp]) { temp = j;// 保持temp存储的是本轮最小的数的下标 } } // 如果temp存储的本轮最小的数的下标与初始值不同,进行数据移动 if (temp != i) { arr[i] ^= arr[temp]; arr[temp] ^= arr[i]; arr[i] ^= arr[temp]; } } }
(初始)算法:
public static void sort(int[] arr) {// 选择排序 int i, j, t; for (i = 0; i < arr.length - 1; i++) { for (j = i + 1; j < arr.length; j++) { if (arr[i] > arr[j]) { t = arr[i]; arr[i] = arr[j]; arr[j] = t; } } } System.out.println(Arrays.toString(arr)); }
选择排序空间复杂度为 O(1),是一种原地排序算法。选择排序的最好情况时间复杂度、最坏情况和平均情况时间复杂度都为 O(n2)。
选择排序是一种不稳定的排序算法。比如 5,8,5,2,9 这样一组数据,使用选择排序算法来排序的话,第一次找到最小元素 2,与第一个 5 交换位置,那第一个 5 和中间的 5 顺序就变了,所以就不稳定了。正是因此,相对于冒泡排序和插入排序,选择排序就稍微逊色了。
三、插入排序
1、基本思想:每步将一个待排序的记录,按其顺序码大小插入到前面已经排序的子序列的合适位置(从后向前找到合适位置后),直到全部插入排序完为止。
2、实例
3、算法实现
// 插入排序 public static void sort2(int[] arr) { int temp = 0, j = 0; for (int i = 1; i < arr.length; i++) { // temp存储待插入数 temp = arr[i]; // j存储待插入数的前一个数 j = i - 1; // 查找插入的位置; for (; j >= 0; j--) { if (arr[j] > temp) { arr[j + 1] = arr[j];// 数据移动 } else { break;// 当没有数据交换时,说明已经达到完全有序 } } arr[j + 1] = temp;// 插入数据 } }
四、希尔排序
1、基本思想:希尔排序也称为“缩小增量排序”,其基本原理是,将待排序的数组元素分成多个子序列,使得每个子序列的元素个数相对较少,然后对各个子序列分别进行直接插入排序,待整个待排序列“基本有序”后,最后再对所有元素进行一次直接插入排序。因此,我们要采用跳跃分割的策略:将相距某个“增量”的记录组成一个子序列,这样才能保证在子序列内分别进行直接插入排序后得到的结果是基本有序而不是局部有序。希尔排序是对直接插入排序算法的优化和升级。
所谓的基本有序,就是小的关键字基本在前面,大的基本在后面,不大不小的基本在中间,例如{2,1,3,6,4,7,5,8,9,}就可以称为基本有序了。但像{1,5,9,3,7,8,2,4,6}这样,9在第三位,2在倒数第三位就谈不上基本有序。
2、复杂度分析:希尔排序的关键并不是随便分组后各自排序,而是将相隔某个“增量”的记录组成一个子序列,实现跳跃式移动,使得排序的效率提高。需要注意的是,增量序列的最后一个增量值必须等于1才行。另外,由于记录是跳跃式的移动,希尔排序并不是一种稳定的排序算法。 希尔排序最好时间复杂度和平均时间复杂度都是O(nlogn),最坏时间复杂度为O(n2)。
3、希尔排序思想图示:
4、算法实现
import java.util.Arrays; public class Test7 { public static void shellSortSmallToBig(int[] data) { int j = 0; int temp = 0; for (int increment = data.length / 2; increment > 0; increment /= 2) { for (int i = increment; i < data.length; i++) { temp = data[i]; for (j = i - increment; j >= 0; j -= increment) { if (temp < data[j]) { data[j + increment] = data[j]; } else { break; } } data[j + increment] = temp; } } } public static void main(String[] args) { int[] data = new int[] { 26, 53, 67, 48, 57, 13, 48, 32, 60, 50 }; shellSortSmallToBig(data); System.out.println(Arrays.toString(data)); } }
------>[13, 26, 32, 48, 48, 50, 53, 57, 60, 67]
五、归并排序
归并排序的核心思想还是蛮简单的。如果要排序一个数组,我们先把数组从中间分成前后两部分,然后对前后两部分分别排序,再将排好序的两部分合并在一起,这样整个数组就都有序了。
动图演示:
归并排序使用的就是分治思想。分治,顾名思义,就是分而治之,将一个大问题分解成小的子问题来解决。小的子问题解决了,大问题也就解决了。
从我刚才的描述,你有没有感觉到,分治思想跟我们前面讲的递归思想很像。是的,分治算法一般都是用递归来实现的。分治是一种解决问题的处理思想,递归是一种编程技巧,这两者并不冲突。分治算法的思想我后面会有专门的一节来讲,现在不展开讨论,我们今天的重点还是排序算法。
前面我通过举例让你对归并有了一个感性的认识,又告诉你,归并排序用的是分治思想,可以用递归来实现。我们现在就来看看如何用递归代码来实现归并排序。
写递归代码的技巧就是,分析得出递推公式,然后找到终止条件,最后将递推公式翻译成递归代码。所以,要想写出归并排序的代码,我们先写出归并排序的递推公式。
递推公式: merge_sort(p…r) = merge(merge_sort(p…q), merge_sort(q+1…r)) 终止条件: p >= r 不用再继续分解
我来解释一下这个递推公式。
merge_sort(p…r) 表示,给下标从 p 到 r 之间的数组排序。我们将这个排序问题转化为了两个子问题,merge_sort(p…q) 和 merge_sort(q+1…r),其中下标 q 等于 p 和 r 的中间位置,也就是 (p+r)/2。当下标从 p 到 q 和从 q+1 到 r 这两个子数组都排好序之后,我们再将两个有序的子数组合并在一起,这样下标从 p 到 r 之间的数据就也排好序了。
有了递推公式,转化成代码就简单多了。为了阅读方便,我这里只给出伪代码,你可以翻译成你熟悉的编程语言。
// 归并排序算法, A 是数组,n 表示数组大小 merge_sort(A, n) { merge_sort_c(A, 0, n-1) } // 递归调用函数 merge_sort_c(A, p, r) { // 递归终止条件 if p >= r then return // 取 p 到 r 之间的中间位置 q q = (p+r) / 2 // 分治递归 merge_sort_c(A, p, q) merge_sort_c(A, q+1, r) // 将 A[p...q] 和 A[q+1...r] 合并为 A[p...r] merge(A[p...r], A[p...q], A[q+1...r]) }
你可能已经发现了,merge(A[p…r], A[p…q], A[q+1…r]) 这个函数的作用就是,将已经有序的 A[p…q] 和 A[q+1…r] 合并成一个有序的数组,并且放入 A[p…r]。那这个过程具体该如何做呢?
如图所示,我们申请一个临时数组 tmp,大小与 A[p…r] 相同。我们用两个游标 i 和 j,分别指向 A[p…q] 和 A[q+1…r] 的第一个元素。比较这两个元素 A[i] 和 A[j],如果 A[i]<=A[j],我们就把 A[i] 放入到临时数组 tmp,并且 i 后移一位,否则将 A[j] 放入到数组 tmp,j 后移一位。
继续上述比较过程,直到其中一个子数组中的所有数据都放入临时数组中,再把另一个数组中的数据依次加入到临时数组的末尾,这个时候,临时数组中存储的就是两个子数组合并之后的结果了。最后再把临时数组 tmp 中的数据拷贝到原数组 A[p…r] 中。
代码实现:
1 import java.util.Arrays; 2 3 public class Test { 4 5 public static void main(String[] args) { 6 int[] arr = {3,1,5,7,8,9,6,4,2}; 7 mergeSort(arr); 8 System.out.println(Arrays.toString(arr)); 9 } 10 11 public static void mergeSort(int[] arr) { 12 sort(arr, 0, arr.length - 1); 13 } 14 15 public static void sort(int[] arr, int L, int R) { 16 if (L >= R) { //递归结束的条件 17 return; 18 } 19 int mid = L + ((R - L) >> 1); 20 sort(arr, L, mid); 21 sort(arr, mid + 1, R); 22 merge(arr, L, mid, R); 23 } 24 25 public static void merge(int[] arr, int L, int mid, int R) { 26 int[] temp = new int[R - L + 1]; 27 int i = 0; 28 int p1 = L; 29 int p2 = mid + 1; 30 // 比较左右两部分的元素,哪个小,把那个元素填入temp中 31 while (p1 <= mid && p2 <= R) { 32 temp[i++] = (arr[p1] <= arr[p2]) ? arr[p1++] : arr[p2++]; 33 } 34 // 上面的循环退出后,把剩余的元素依次填入到temp中 35 // 以下两个while只有一个会执行 36 while (p1 <= mid) { 37 temp[i++] = arr[p1++]; 38 } 39 while (p2 <= R) { 40 temp[i++] = arr[p2++]; 41 } 42 // 把最终的排序的结果复制给原数组 43 for (i = 0; i < temp.length; i++) { 44 arr[L + i] = temp[i]; 45 } 46 } 47 }
结果显示:
[1, 2, 3, 4, 5, 6, 7, 8, 9]
复杂度分析:
- 时间复杂度:O(nlogn);
- 空间复杂度:O(N),归并排序需要一个与原数组相同长度的数组做辅助来排序;
- 稳定性:归并排序是稳定的排序算法,
temp[i++] = arr[p1] <= arr[p2] ? arr[p1++] : arr[p2++];
这行代码可以保证当左右两部分的值相等的时候,先复制左边的值,这样可以保证值相等的时候两个元素的相对位置不变。
六、快速排序
快速排序算法(Quicksort)简称为“快排”。快排利用的也是分治思想。
快排的思想是这样的:如果要排序数组中下标从 p 到 r 之间的一组数据,我们选择 p 到 r 之间的任意一个数据作为 pivot(分区点)。
我们遍历 p 到 r 之间的数据,将小于 pivot 的放到左边,将大于 pivot 的放到右边,将 pivot 放到中间。经过这一步骤之后,数组 p 到 r 之间的数据就被分成了三个部分,前面 p 到 q-1 之间都是小于 pivot 的,中间是 pivot,后面的 q+1 到 r 之间是大于 pivot 的。
根据分治、递归的处理思想,我们可以用递归排序下标从 p 到 q-1 之间的数据和下标从 q+1 到 r 之间的数据,直到区间缩小为 1,就说明所有的数据都有序了。
如果我们用递推公式来将上面的过程写出来的话,就是这样:
递推公式:
quick_sort(p…r) = quick_sort(p…q-1) + quick_sort(q+1, r)
终止条件:
p >= r
我将递推公式转化成递归代码。跟归并排序一样,我还是用伪代码来实现,你可以翻译成你熟悉的任何语言。
1 // 快速排序,A 是数组,n 表示数组的大小 2 quick_sort(A, n) { 3 quick_sort_c(A, 0, n-1) 4 } 5 // 快速排序递归函数,p,r 为下标 6 quick_sort_c(A, p, r) { 7 if p >= r then return 8 9 q = partition(A, p, r) // 获取分区点 10 quick_sort_c(A, p, q-1) 11 quick_sort_c(A, q+1, r) 12 }
归并排序中有一个 merge() 合并函数,我们这里有一个 partition() 分区函数。partition() 分区函数实际上我们前面已经讲过了,就是随机选择一个元素作为 pivot(一般情况下,可以选择 p 到 r 区间的最后一个元素),然后对 A[p…r] 分区,函数返回 pivot 的下标。
如果我们不考虑空间消耗的话,partition() 分区函数可以写得非常简单。我们申请两个临时数组 X 和 Y,遍历 A[p…r],将小于 pivot 的元素都拷贝到临时数组 X,将大于 pivot 的元素都拷贝到临时数组 Y,最后再将数组 X 和数组 Y 中数据顺序拷贝到 A[p…r]。
但是,如果按照这种思路实现的话,partition() 函数就需要很多额外的内存空间,所以快排就不是原地排序算法了。如果我们希望快排是原地排序算法,那它的空间复杂度得是 O(1),那 partition() 分区函数就不能占用太多额外的内存空间,我们就需要在 A[p…r] 的原地完成分区操作。
原地分区函数的实现思路非常巧妙,我写成了伪代码,我们一起来看一下。
1 partition(A, p, r) { 2 pivot := A[r] 3 i := p 4 for j := p to r-1 do { 5 if A[j] < pivot { 6 swap A[i] with A[j] 7 i := i+1 8 } 9 } 10 swap A[i] with A[r] 11 return i
这里的处理有点类似选择排序。我们通过游标 i 把 A[p…r-1] 分成两部分。A[p…i-1] 的元素都是小于 pivot 的,我们暂且叫它“已处理区间”,A[i…r-1] 是“未处理区间”。我们每次都从未处理的区间 A[i…r-1] 中取一个元素 A[j],与 pivot 对比,如果小于 pivot,则将其加入到已处理区间的尾部,也就是 A[i] 的位置。
数组的插入操作还记得吗?在数组某个位置插入元素,需要搬移数据,非常耗时。当时我们也讲了一种处理技巧,就是交换,在 O(1) 的时间复杂度内完成插入操作。这里我们也借助这个思想,只需要将 A[i] 与 A[j] 交换,就可以在 O(1) 时间复杂度内将 A[j] 放到下标为 i 的位置。
文字不如图直观,所以我画了一张图来展示分区的整个过程。
因为分区的过程涉及交换操作,如果数组中有两个相同的元素,比如序列 6,8,7,6,3,5,9,4,在经过第一次分区操作之后,两个 6 的相对先后顺序就会改变。所以,快速排序并不是一个稳定的排序算法。
到此,快速排序的原理你应该也掌握了。
1 /* 2 * 快速排序 3 * 4 * @param arr 数组 5 * 6 */ 7 public static void quicksort(int[] arr) { 8 quicksort_c(arr, 0, arr.length - 1); 9 } 10 11 /* 12 * 快速排序递归算法 13 * 14 * @param arr 数组 15 * 16 * @param p 代表数组的首位下标 17 * 18 * @param r 代表数组的最后一位下标 19 * 20 */ 21 public static void quicksort_c(int[] arr, int p, int r) { 22 if (p >= r) {// 递归终止的条件 23 return; 24 } 25 int q = partition(arr, p, r); 26 quicksort_c(arr, p, q - 1); 27 quicksort_c(arr, q + 1, r); 28 } 29 30 /* 31 * 获取分区点 32 * 33 * @param arr 数组 34 * 35 * @param p 代表数组的首位下标 36 * 37 * @param r 代表数组的最后一位下标 38 * 39 */ 40 public static int partition(int[] arr, int p, int r) { 41 int pivot = arr[r]; 42 int i = p; 43 int temp = 0; 44 for (int j = p; j <= r - 1; j++) { 45 if (arr[j] < pivot) { 46 temp = arr[i]; 47 arr[i] = arr[j]; 48 arr[j] = temp; 49 i++; 50 } 51 } 52 // 交换arr[r]和arr[i] 53 temp = arr[r]; 54 arr[r] = arr[i]; 55 arr[i] = temp; 56 57 return i; 58 }
快速排序的性能分析
七、堆排序
堆排序是一种原地的、时间复杂度为 O(nlogn) 的排序算法。
如何理解“堆”?
前面我们提到,堆是一种特殊的树。我们现在就来看看,什么样的树才是堆。我罗列了两点要求,只要满足这两点,它就是一个堆。
-
堆是一个完全二叉树;
-
堆中每一个节点的值都必须大于等于(或小于等于)其子树中每个节点的值。
我分别解释一下这两点。
第一点,堆必须是一个完全二叉树。还记得我们之前讲的完全二叉树的定义吗?完全二叉树要求,除了最后一层,其他层的节点个数都是满的,最后一层的节点都靠左排列。
第二点,堆中的每个节点的值必须大于等于(或者小于等于)其子树中每个节点的值。实际上,我们还可以换一种说法,堆中每个节点的值都大于等于(或者小于等于)其左右子节点的值。这两种表述是等价的。
对于每个节点的值都大于等于子树中每个节点值的堆,我们叫作“大顶堆”。对于每个节点的值都小于等于子树中每个节点值的堆,我们叫作“小顶堆”。
如何实现一个堆?
我之前讲过,完全二叉树比较适合用数组来存储。用数组来存储完全二叉树是非常节省存储空间的。因为我们不需要存储左右子节点的指针,单纯地通过数组的下标,就可以找到一个节点的左右子节点和父节点。
我画了一个用数组存储堆的例子,你可以先看下。
代码实现:
1 public class HeapSort { 2 3 public static void main(String[] args) { 4 // 如果待排序的组合是(20, 8, 12, 9, 19, 7, 3),为了方面我们加一个0使得arr[i]的下标就是i 5 int[] a = { 0, 20, 8, 12, 9, 19, 7, 3 }; 6 // 第二个参数是: 待排序的数据个数 7 heapsort(a, a.length - 1); 8 // 输出结果 9 for (int i = 1; i < a.length; i++) { 10 System.out.print(a[i] + " "); 11 } 12 } 13 14 /** 15 * 堆排序 16 * 17 * @Param arr 数组 18 * @Param n 待排序的元素个数 19 * 20 */ 21 public static void heapsort(int[] arr, int n) { 22 // 1、建堆 23 buildHeap(arr, n); 24 // 2、排序 25 int k = n; 26 while (k > 1) { 27 swap(arr, 1, k); 28 k--; 29 heapify(arr, k, 1); 30 } 31 } 32 33 /** 34 * 建堆 35 * 36 * @Param arr 数组 37 * @Param n 待排序的元素个数 38 * 39 */ 40 public static void buildHeap(int[] arr, int n) { 41 for (int i = n / 2; i >= 1; i--) { 42 heapify(arr, n, i); 43 } 44 } 45 46 /** 47 * 自上往下堆化(将传入的堆顶元素arr[i]放至其正确的位置) 48 * 49 * @Param arr 数组 50 * @Param n 待排序的元素个数 51 * @Param i 开始堆化的元素下标 52 * @return 无返回值 53 * 54 */ 55 public static void heapify(int[] arr, int n, int i) { 56 int maxpos = 0; 57 while (true) { 58 maxpos = i; 59 if (2 * i <= n && arr[2 * i] > arr[i]) { 60 maxpos = 2 * i; 61 } 62 if (2 * i + 1 <= n && arr[2 * i + 1] > arr[maxpos]) { 63 maxpos = 2 * i + 1; 64 } 65 if (maxpos == i) { 66 break; 67 } 68 swap(arr, i, maxpos); 69 i = maxpos; 70 71 } 72 } 73 74 /** 75 * 交换数组中指定的两个元素值 76 * 77 * @Param arr 数组 78 * @Param x 数组待交换值下标1 79 * @Param y 数组待交换值下标2 80 * @return 无返回值 81 */ 82 public static void swap(int[] arr, int x, int y) { 83 int t = arr[x]; 84 arr[x] = arr[y]; 85 arr[y] = t; 86 } 87 88 }
输出:
3 7 8 9 12 19 20