前面的话
本次罗列了以下排序实现方式:
1. 冒泡排序 时间复杂度:O(n^2) 空间复杂度:O(1) 稳定性:由于交换的if条件是a[j]>a[j+1],所以如果等于的话并没有交换,所以冒泡排序是一种稳定排序算法。 冒泡排序较稳定,可用于链式存储结构,时间复杂度较高,当n较大,初始记录无序时,不宜采用此方法 2. 选择排序 时间复杂度:O(n^2) 空间复杂度:O(1) 稳定性:就算法本身来说,是一种稳定排序 选择排序可用于链式存储结构,移动记录次序较少,当每一记录占用空间较多时,此方法比插入排序快 3. 插入排序 时间复杂度:O(n^2) 空间复杂度:O(1) 稳定性:稳定排序 算法简单稳定,容易实现,也适用于链式存储结构,在单链表中只需修改指针,更适用于初始记录基本有序的情况。对与查找插入位置,我们可以用二分查找获取位置。 4. 希尔排序 时间复杂度:时间复杂度比较复杂,通常认为O(n*log 2 n),当n趋近于无穷大时,可以为O(n(log2 n)^2) 空间复杂度:O(1) 稳定性:不稳定 希尔排序只能用于顺序存储结构,不能用于链式存储结构,增量gap可以有各种取法,但最后一次gap必须等于1, 总比较次数和移动次数较直接插入排序少,当n越大,序列越无序时,效果越明显。 5. 归并排序 时间复杂度:O(nlog2n) 空间复杂度:O(n) 稳定性:稳定排序 归并排序可用于链式结构,且不需要附加存储空间,但递归实现任需要开辟相应的递归工作栈。 6. 快速排序 时间复杂度:O(nlogn) 空间复杂度:O(log2n)—O(n) 稳定性:不稳定 快速排序过程中需要地位表的上界和下界,所以适合顺序结构,很难用于链式结构,当n非常大时,快速排序是所有排序过程中最快的一种,所以适合用于初始记录无序、n较大时的情况。 7. 堆排序 时间复杂度:O(nlog2 n) 空间复杂度:O(1) 稳定性:不稳定 堆排序只能用于顺序存储结构,初始建堆比较次数较多,记录较少时不宜采用,在最坏情况下较快速排序而言是一个有点,记录较多时较为高效。
冒泡排序基本思想:
两两相邻记录的关键字,如果反序则交换,直到没有反序的记录为止。之所以叫做冒泡排序,是因为每次将一个最大或最小的数往后沉淀,沉淀到后面的数比它大或比它小为止,类似于泡泡向上浮起来的过程。冒泡排序是排序方法中最直观也是最简单的,我们接触的第一种排序方法也是冒泡排序,基本步骤:
有一个数组a[10],用变量i表示它的下标(i从0开始)
(1) 比较两个相邻元素a[i]和a[i+1],如果a[i]>a[i+1],就交换这两个数的位置;
(2)重复执行第一步,直到比较到最后一对的时候(例:首次是a[8]和a[9],此时,a[9]的值为该数组的最大值,这个值属于有序数列);
(3)对所有元素(除了有序数列里的元素),重复执行第一步和第二步,每执行完一次,都会找到当前比较的数里最大的那个(有序数列就会增加一个);
(4)随着参与比较的元素越来越少,最终没有任何一对元素需要比较的时候,排序完成。
选择排序基本思想:
通过n-1次关键字比较,从n-i+1个记录中选择关键字最小的记录,并和第i个记录交换。选择排序是经过一次一次在无序区间中找到最值,放到无序区间的前一个位置,基本步骤:
(1)设待排序的记录存放在数组r[n]中,第一趟从r[1]开始,通过n-1次比较,从n个记录中选取最小的记录,记为r[k],交换r[1]和r[k]
(2)第二趟从r[2]开始,通过n-2次比较,从n-1个记录中选出关键字最小的记录,记为r[k],交换r[2]和r[k]
(3)以此类推,第i趟从r[i]开始,通过n-i次比较,从n-i+1个记录中选取最小关键字记录,记为r[k],交换 r[i]和r[k]
(4)经过n-1趟,排序完成
插入排序基本思想:
插入排序(直接插入排序)是一种最简单的排序方法,其基本操作是将一条记录插入到已排号的序列当中,从而得到一个有序的记录。算法步骤:
(1)设待排序数组a[n],默认a[0]是一个有序序列
(2)循环n-1次,每次将为排序序列插入到前面的已排序序列当中,将已排序序列区间长度加1,未排序区间长度减一
(3)重复(2)直到未排序区间长度为0
希尔排序基本思想:
希尔排序实质上是采用分组插入的方法,先将整个待排序记录序列分成几组,从而减少参与直接插入排序的数据量,对每组分别进行直接插入排序,然后增加每组的数据量,重新分组,这样经过几次分组后,整个序列基本有序,在对全体进行插入排序,希尔排序记录的分组,不是简单的逐段分组,而是将相隔某个记录增量的记录分成一组。希尔排序的基本步骤:
(1)第一趟取增量gap(gap<n)把全部几记录分成gap个组,对每组进行直接插入排序
(2)第二趟取gap=gap/2,重复第一步
(3)以此类推,直到gap=1,再对整个序列排序一次
归并排序类似与二分查找,都是二分当前区间,分别进行操作,可通过递归与送代进行实现,书面上定义是将两个或两个以上的有序表合成一个有序表的过程,将两个有序表合成一个有序表的过程称为2-路归并,2-路归并最为简单常用。
归并排序的算法思想:
假设初始序列含有n个记录,则可看成是n个有序的子序列,每个子序列的长度为1,然后两两归并,得到n/2个长度为2或1的有序子序列,再两两归并。。。如此重复,直到得到一个长度为n的有序序列为止。归并排序将r[n]中的记录放到t[n]中,当序列长度等于1时,递归结束,否则:
(1)将当前序列一分为二,求出分裂点mid=(l+r)/2
(2)对子序列r[l]-r[mid]递归,进行归并排序,结果放在t[l,mid]中
(3)对子序列r[mid+1,r]递归,进行归并排序,结果放在t[mid+1,r]
(4)将两个有序的子序列t[l,mid],t[mid+1,r]归并为一个有序的序列放入r[l,r]中
快速排序基本思想:
快速排序在算法竞赛中用到的比较多,快速排序比冒泡排序要快很多,C语言中也有快速排序的函数qsort(),不过最好去理解一下底层代码实现。在冒泡排序过程中,只对两个相邻的记录进行比较,因此每次比较只能消除一个逆序,如果能通过两个(不相邻)记录的一次交换,消除多个逆序,则会大大加快排序的速度,快速排序就是利用这个原理。基本步骤:
(1)选择待排序记录中第一个记录作为基准,将基准暂存在r[0]的位置上,假设两个指针low和high,初始分别指向表的下界和上界
(2)从表的最右侧位置向左寻找第一个比基准数小的位置,从表的最左侧寻找第一个比基准数大的位置,交换两个元素
(3)重复(2)当low==high时,将基准数放到low位置处的元素判断大于还是小于后,与基准数交换
(4)此时以基准数的位置为分界点,将序列分为两个子序列,分别进行(1),(2),(3)操作,直到序列的长度为1为止
堆排序基本思想:
堆排序是一种树性选择排序,再排序过程中,将排序序列a[n]看成一个完全二叉树,利用二叉树双亲节点与孩子节点的内在联系,在当前无序的序列中,选择的关键字最大(或最小)的记录。
堆的定义为 a[i]>=a[2i]&&a[i]>=a[2i+1] 或 a[i]<=a[2i]&&a[i]<=a[2i+1],通俗的理解就是在完全二叉树中,一个节点必须同时大于它的左节点和右节点。前者为大根堆后者为小根堆。
堆排序利用了大根堆(或小根堆)堆顶记录的关键字最大(或最小)这一特征,使得当前无序的序列中选择关键字最大(或最小)的记录变得简单,步骤为:
(1)按照堆的定义将待排序序列a[n] 调整为大根堆,这个过程称为初建堆,交换a[1]和a[n] ,则a[n]为关键字的最大记录
(2)将a[n-1]重新调整为堆,交换ra[1]和a[n-1],此时a[n-1]为关键字最大记录
(3)循环n-1次,直到交换a[1]和a[2]为止
所以实现堆排序需要熟悉两个问题:建初堆、调整堆
调整堆就是比较此节点与它的左右孩子节点,选孩子节点中取最大或者最小的一个,与此节点比较,如果不满足条件就交换,直到进行到叶子节点为止。而要将一个无序序列调整为堆,就必须将其所在的二叉树中以一个节点为根的子树都调整为堆。
0. 通用公共方法
/**
* 比较v元素是否大于w元素
*/
private static boolean greater(Comparable v, Comparable w) {
return v.compareTo(w) > 0;
}
/**
* 数组元素i和j交换位置
*/
private static void exch(Comparable[] a, int i, int j) {
Comparable temp;
temp = a[i];
a[i] = a[j];
a[j] = temp;
}
public static Integer[] createRandomIntegerArr(Integer star, Integer end, Integer length) {
Integer[] arr = new Integer[length];
Random random = new Random();
for (int i = 0; i < length; i++) {
arr[i] = star + random.nextInt(end - star);
}
System.out.println("原始随机:" + Arrays.toString(arr));
return arr;
}
1. 冒泡排序
要求
- 能够用自己语言描述冒泡排序算法
- 能够手写冒泡排序代码
- 了解一些冒泡排序的优化手段
算法描述
- 依次比较数组中相邻两个元素大小,若 a[j] > a[j+1],则交换两个元素,两两都比较一遍称为一轮冒泡,结果是让最大的元素排至最后
- 重复以上步骤,直到整个数组有序
算法实现
public class A1Bubble { /** * 对数组a中的元素进行排序 * 从左到右,逐一比较,最后把最大的放最右边 */ public static void sort(Comparable[] a) { for (int i = a.length - 1; i > 0; i--) { for (int j = 0; j < i; j++) { //{6,5,4,3,2,1} //比较索引j和索引j+1处的值 if (greater(a[j], a[j + 1])) { exch(a, j, j + 1); } } } } }
- 优化点1:每经过一轮冒泡,内层循环就可以减少一次
- 优化点2:如果某一轮冒泡没有发生交换,则表示所有数据有序,可以结束外层循环
进一步优化:
public static void bubble_v2(int[] a) { int n = a.length - 1; while (true) { int last = 0; // 表示最后一次交换索引位置 for (int i = 0; i < n; i++) { System.out.println("比较次数" + i); if (a[i] > a[i + 1]) { Utils.swap(a, i, i + 1); last = i; } } n = last; System.out.println("第轮冒泡" + Arrays.toString(a)); if (n == 0) { break; } } }
2. 选择排序
要求
- 能够用自己语言描述选择排序算法
- 能够比较选择排序与冒泡排序
- 理解非稳定排序与稳定排序
算法描述
- 将数组分为两个子集,排序的和未排序的,每一轮从未排序的子集中选出最小的元素,放入排序子集
- 重复以上步骤,直到整个数组有序
算法实现
public class A2Selection { /** * 对数组a中的元素进行排序 * 从左到右找到最小的值,然后获取最小数的索引,最后替换至最左边 */ public static void sort(Comparable[] a) { for (int i = 0; i <= a.length - 2; i++) { //定义一个变量,记录最小元素所在的索引,默认为参与选择排序的第一个元素所在的位置 int minIndex = i; for (int j = i + 1; j < a.length; j++) { //需要比较最小索引minIndex处的值和j索引处的值; if (greater(a[minIndex], a[j])) { minIndex = j; } } if (i != minIndex) { //交换最小元素所在索引minIndex处的值和索引i处的值 exch(a, i, minIndex); } System.out.println(Arrays.toString(a)); } } }
- 优化点:为减少交换次数,每一轮可以先找最小的索引,在每轮最后再交换元素
与冒泡排序比较
二者平均时间复杂度都是 O ( n 2 ) O(n^2) O(n2)
选择排序一般要快于冒泡,因为其交换次数少
但如果集合有序度高,冒泡优于选择
冒泡属于稳定排序算法,而选择属于不稳定排序
- 稳定排序指,按对象中不同字段进行多次排序,不会打乱同值元素的顺序
- 不稳定排序则反之
3. 插入排序
要求
- 能够用自己语言描述插入排序算法
- 能够比较插入排序与选择排序
算法描述
将数组分为两个区域,排序区域和未排序区域,每一轮从未排序区域中取出第一个元素,插入到排序区域(需保证顺序)
重复以上步骤,直到整个数组有序
算法实现
public class A3Insertion { /** * 对数组a中的元素进行排序 */ public static void sort(Comparable[] a) { for (int i = 1; i < a.length; i++) { for (int j = i; j > 0; j--) { //比较索引j处的值和索引j-1处的值,如果索引j-1处的值比索引j处的值大,则交换数据,如果不大,那么就找到合适的位置了,退出循环即可; if (greater(a[j - 1], a[j])) { exch(a, j - 1, j); } else { break; } } } } }
与选择排序比较
二者平均时间复杂度都是 O ( n 2 ) O(n^2) O(n2)
大部分情况下,插入都略优于选择
有序集合插入的时间复杂度为 O ( n ) O(n) O(n)
插入属于稳定排序算法,而选择属于不稳定排序
提示:
插入排序通常被所轻视,其实它的地位非常重要。小数据量排序,都会优先选择插入排序
4. 希尔排序
要求
- 能够用自己语言描述希尔排序算法
算法描述
首先选取一个间隙序列,如 (n/2,n/4 … 1),n 为数组长度
每一轮将间隙相等的元素视为一组,对组内元素进行插入排序,目的有二
① 少量元素插入排序速度很快
② 让组内值较大的元素更快地移动到后方
当间隙逐渐减少,直至为 1 时,即可完成排序
算法实现
public class A4Shell { /* 对数组a中的元素进行排序 */ public static void sort(Comparable[] a) { //1.根据数组a的长度,确定增长量h的初始值; int h = 1; while (h < a.length / 2) { h = 2 * h + 1; } //2.希尔排序 while (h >= 1) { //排序 //2.1.找到待插入的元素 for (int i = h; i < a.length; i++) { //2.2把待插入的元素插入到有序数列中 for (int j = i; j >= h; j -= h) { //待插入的元素是a[j],比较a[j]和a[j-h] if (greater(a[j - h], a[j])) { //交换元素 exch(a, j - h, j); } else { //待插入元素已经找到了合适的位置,结束循环; break; } } } //减小h的值 h = h / 2; } } }
// 方式二 private static void shell(int[] a) { int n = a.length; for (int gap = n / 2; gap > 0; gap /= 2) { // i 代表待插入元素的索引 for (int i = gap; i < n; i++) { int t = a[i]; // 代表待插入的元素值 int j = i; while (j >= gap) { // 每次与上一个间隙为 gap 的元素进行插入排序 if (t < a[j - gap]) { // j-gap 是上一个元素索引,如果 > t,后移 a[j] = a[j - gap]; j -= gap; } else { // 如果 j-1 已经 <= t, 则 j 就是插入位置 break; } } a[j] = t; System.out.println(Arrays.toString(a) + " gap:" + gap); } } }
5. 归并排序
public class A5Merge {
//归并所需要的辅助数组
private static Comparable[] assist;
/**
* 比较v元素是否小于w元素
*/
private static boolean less(Comparable v, Comparable w) {
return v.compareTo(w) < 0;
}
/**
* 对数组a中的元素进行排序
*/
public static void sort(Comparable[] a) {
//1.初始化辅助数组assist;
assist = new Comparable[a.length];
//2.定义一个lo变量,和hi变量,分别记录数组中最小的索引和最大的索引;
int lo = 0;
int hi = a.length - 1;
//3.调用sort重载方法完成数组a中,从索引lo到索引hi的元素的排序
sort(a, lo, hi);
}
/**
* 对数组a中从lo到hi的元素进行排序
*/
private static void sort(Comparable[] a, int lo, int hi) {
//做安全性校验;
if (hi <= lo) {
return;
}
//对lo到hi之间的数据进行分为两个组
int mid = lo + (hi - lo) / 2;// 5,9 mid=7
//分别对每一组数据进行排序
sort(a, lo, mid);
sort(a, mid + 1, hi);
//再把两个组中的数据进行归并
merge(a, lo, mid, hi);
}
/**
* 对数组中,从lo到mid为一组,从mid+1到hi为一组,对这两组数据进行归并
*/
private static void merge(Comparable[] a, int lo, int mid, int hi) {
//定义三个指针
int i = lo;
int p1 = lo;
int p2 = mid + 1;
//遍历,移动p1指针和p2指针,比较对应索引处的值,找出小的那个,放到辅助数组的对应索引处
while (p1 <= mid && p2 <= hi) {
//比较对应索引处的值
if (less(a[p1], a[p2])) {
assist[i++] = a[p1++];
} else {
assist[i++] = a[p2++];
}
}
//遍历,如果p1的指针没有走完,那么顺序移动p1指针,把对应的元素放到辅助数组的对应索引处
while (p1 <= mid) {
assist[i++] = a[p1++];
}
//遍历,如果p2的指针没有走完,那么顺序移动p2指针,把对应的元素放到辅助数组的对应索引处
while (p2 <= hi) {
assist[i++] = a[p2++];
}
//把辅助数组中的元素拷贝到原数组中
for (int index = lo; index <= hi; index++) {
a[index] = assist[index];
}
}
}
6. 快速排序
要求
- 能够用自己语言描述快速排序算法
- 掌握手写单边循环、双边循环代码之一
- 能够说明快排特点
- 了解洛穆托与霍尔两种分区方案的性能比较
算法描述
- 每一轮排序选择一个基准点(pivot)进行分区
- 让小于基准点的元素的进入一个分区,大于基准点的元素的进入另一个分区
- 当分区完成时,基准点元素的位置就是其最终位置
- 在子分区内重复以上过程,直至子分区元素个数少于等于 1,这体现的是分而治之的思想 (divide-and-conquer)
- 从以上描述可以看出,一个关键在于分区算法,常见的有洛穆托分区方案、双边循环分区方案、霍尔分区方案
单边循环快排(lomuto 洛穆托分区方案)
- 选择最右元素作为基准点元素
- j 指针负责找到比基准点小的元素,一旦找到则与 i 进行交换
- i 指针维护小于基准点元素的边界,也是每次交换的目标索引
- 最后基准点与 i 交换,i 即为分区位置
public static void quick(int[] a, int l, int h) { if (l >= h) { return; } int p = partition(a, l, h); // p 索引值 quick(a, l, p - 1); // 左边分区的范围确定 quick(a, p + 1, h); // 左边分区的范围确定 } private static int partition(int[] a, int l, int h) { int pv = a[h]; // 基准点元素 int i = l; for (int j = l; j < h; j++) { if (a[j] < pv) { if (i != j) { swap(a, i, j); } i++; } } if (i != h) { swap(a, h, i); } System.out.println(Arrays.toString(a) + " i=" + i); // 返回值代表了基准点元素所在的正确索引,用它确定下一轮分区的边界 return i; }
双边循环快排(不完全等价于 hoare 霍尔分区方案)
- 选择最左元素作为基准点元素
- j 指针负责从右向左找比基准点小的元素,i 指针负责从左向右找比基准点大的元素,一旦找到二者交换,直至 i,j 相交
- 最后基准点与 i(此时 i 与 j 相等)交换,i 即为分区位置
要点
- 基准点在左边,并且要先 j 后 i
- while( i < j && a[j] > pv ) j–
- while ( i < j && a[i] <= pv ) i++
private static void quick(int[] a, int l, int h) { if (l >= h) { return; } int p = partition(a, l, h); quick(a, l, p - 1); quick(a, p + 1, h); } private static int partition(int[] a, int l, int h) { int pv = a[l]; int i = l; int j = h; while (i < j) { // j 从右找小的 while (i < j && a[j] > pv) { j--; } // i 从左找大的 while (i < j && a[i] <= pv) { i++; } swap(a, i, j); } swap(a, l, j); System.out.println(Arrays.toString(a) + " j=" + j); return j; }
快排特点
平均时间复杂度是 O ( n l o g 2 n ) O(nlog_2n ) O(nlog2n),最坏时间复杂度 O ( n 2 ) O(n^2) O(n2)
数据量较大时,优势非常明显
属于不稳定排序
洛穆托分区方案 vs 霍尔分区方案
- 霍尔的移动次数平均来讲比洛穆托少3倍
public class A6Quick { /** * 比较v元素是否小于w元素 */ private static boolean less(Comparable v, Comparable w) { return v.compareTo(w) < 0; } //对数组内的元素进行排序 public static void sort(Comparable[] a) { int lo = 0; int hi = a.length - 1; sort(a, lo, hi); } //对数组a中从索引lo到索引hi之间的元素进行排序 private static void sort(Comparable[] a, int lo, int hi) { //安全性校验 if (hi <= lo) { return; } //需要对数组中lo索引到hi索引处的元素进行分组(左子组和右子组); int partition = partition(a, lo, hi);//返回的是分组的分界值所在的索引,分界值位置变换后的索引 //让左子组有序 sort(a, lo, partition - 1); //让右子组有序 sort(a, partition + 1, hi); } //对数组a中,从索引 lo到索引 hi之间的元素进行分组,并返回分组界限对应的索引 public static int partition(Comparable[] a, int lo, int hi) { //确定分界值 Comparable key = a[lo]; //定义两个指针,分别指向待切分元素的最小索引处和最大索引处的下一个位置 int left = lo; int right = hi + 1; //切分 while (true) { //先从右往左扫描,移动right指针,找到一个比分界值小的元素,停止 while (less(key, a[--right])) { if (right == lo) { break; } } //再从左往右扫描,移动left指针,找到一个比分界值大的元素,停止 while (less(a[++left], key)) { if (left == hi) { break; } } //判断 left>=right,如果是,则证明元素扫描完毕,结束循环,如果不是,则交换元素即可 if (left >= right) { break; } else { exch(a, left, right); } } //交换分界值 exch(a, lo, right); return right; } }
7. 堆排序
堆排序是一种树性选择排序,再排序过程中,将排序序列a[n]看成一个完全二叉树,利用二叉树双亲节点与孩子节点的内在联系,在当前无序的序列中,选择的关键字最大(或最小)的记录。
堆的定义为 a[i]>=a[2i]&&a[i]>=a[2i+1] 或 a[i]<=a[2i]&&a[i]<=a[2i+1],通俗的理解就是在完全二叉树中,一个节点必须同时大于它的左节点和右节点。前者为大根堆后者为小根堆。
堆排序利用了大根堆(或小根堆)堆顶记录的关键字最大(或最小)这一特征,使得当前无序的序列中选择关键字最大(或最小)的记录变得简单,步骤为:
(1)按照堆的定义将待排序序列a[n] 调整为大根堆,这个过程称为初建堆,交换a[1]和a[n] ,则a[n]为关键字的最大记录
(2)将a[n-1]重新调整为堆,交换ra[1]和a[n-1],此时a[n-1]为关键字最大记录
(3)循环n-1次,直到交换a[1]和a[2]为止
所以实现堆排序需要熟悉两个问题:建初堆、调整堆
调整堆就是比较此节点与它的左右孩子节点,选孩子节点中取最大或者最小的一个,与此节点比较,如果不满足条件就交换,直到进行到叶子节点为止。而要将一个无序序列调整为堆,就必须将其所在的二叉树中以一个节点为根的子树都调整为堆。
时间复杂度:O(nlog2 n)
空间复杂度:O(1)
稳定性:不稳定
堆排序只能用于顺序存储结构,初始建堆比较次数较多,记录较少时不宜采用,在最坏情况下较快速排序而言是一个有点,记录较多时较为高效。
public class HeapSort {
private static void heap(int[] a) {
int count = a.length;
heapify(a, count);
System.out.println(Arrays.toString(a));
int end = count - 1;
while (end > 0) {
swap(a, end, 0);
end--;
siftDown(a, 0, end);
}
}
private static void heapify(int[] a, int count) {
int start = getParent(count - 1);
System.out.println(Arrays.toString(a));
while (start >= 0) {
siftDown(a, start, count - 1);
System.out.println(Arrays.toString(a));
start--;
}
}
private static void siftDown(int[] a, int start, int end) {
int root = start;
while (getLeftChild(root) <= end) {
int child = getLeftChild(root);
int swap = root;
if (a[swap] < a[child]) {
swap = child;
}
if (child + 1 <= end && a[swap] < a[child + 1]) {
swap = child + 1;
}
if (swap == root) {
return;
} else {
swap(a, root, swap);
root = swap;
}
}
}
private static int getParent(int i) {
return (i - 1) / 2;
}
private static int getLeftChild(int i) {
return 2 * i + 1;
}
private static int getRightChild(int i) {
return 2 * i + 2;
}
}