【数据结构与算法】2 - 9 八大内部排序算法(上):交换与插入
§2-9 八大内部排序算法(上):交换与插入
排序算法可分为外部排序和内部排序。内部排序是数据记录在内存中作排序,而外部排序是因为排序的数据很大,一次不能容纳全部的排序记录,在排序过程中需要访问外存。
本节内容我们将讨论八大内部排序。
2-9.1 排序的分类和性能
排序:排序是将多个数据,按指定的顺序进行排列的过程。
排序可分为内部排序和外部排序。下图简要地说明了八大排序算法的关系:
内部排序(Internal sorting):待排序列完全存放在内存中所进行的排序过程,适合不太大的元素排列。
内部排序包括交换式排序法、选择式排序法、插入式排序法、归并排序、基数排序。插入排序又有直接插入排序和希尔排序,选择排序又有简单选择排序和堆排序,交换排序又有冒泡排序和快速排序。
其中,最为常用、重要的排序算法为冒泡排序、快速排序、插入排序和选择排序。
外部排序(External sorting):借助外部存储所进行的排序过程,适合数据量较大、无法加载到内存的元素排列。
外部排序包括合并排序法和直接合并排序法。
时间复杂度:算法的时间复杂度是一个输入长度的函数,用于量化算法运行时所消耗的时间。算法的时间复杂度并不等同于实际的执行时间,时间复杂度假设每个操作的执行时间相同,是一种估算。常使用大 O 表示法表示,即 \(T(n) = O(f(n))\),表示代码执行时间的增长趋势。
常见的时间复杂度量级(从快到慢)为:\(O(1)\)(常数阶)、\(O(\log n)\)(对数阶)、\(O(n)\)(线性阶)、\(O(n \cdot \log n)\)(线性对数阶)、\(O(n^2)\)(平方阶)、\(O(n^3)\)(立方阶)、\(O(n^k)\)(k 次方阶)、\(O(2^n)\)(指数阶)。
空间复杂度:算法在解决问题时所需要的内存大小称为算法的空间复杂度,也是一种估算,并不能用于计算算法的实际需要内存,只是反映一种趋势。
较为常用的空间复杂度(从简单到复杂)为:\(O(1)\)、\(O(n)\)、\(O(n^2)\)。
算法的时间复杂度和空间复杂度是两种常用的、用于判断算法是否更优的方法。
排序的稳定性:若带排序的序列中含有多个相同的关键字,排序完成后这些关键字的相对次序保持不变,则称这个排序算法是稳定的;反之,则是不稳定的。在所有的输入示例中,只要出现一个实例,使得算法不稳定,则称该算法就是不稳定的。
2-9.2 交换排序(Swap sort)
2-9.2.1 冒泡排序(Bubble sort)
排序思想:在要排序的一组数中,对尚未排好序的全部数,自上而下对相邻的两个数作比较和调整,让较大(较小)的数下沉,让较小(较大)的数往上冒。即每当两个相邻的数作比较后,发现而这排序与所要求排序不一致,则将他们互换。
算法实现:
public static void bubbleSort(int[] array) {
// 临时变量:用于变量值交换
int temp;
// 外层循环:判断循环走多少轮(数组元素数量决定循环次数,保证数组能完全排序)
for (int i = 0; i < array.length-1 ; i++) {
// 内层循环:遍历数组,开始排序。-i防止重复,减少排序(因为每轮比较都会产生一个最值)。
for (int j = 0; j < array.length - 1 - i; j++) {
if (array[j+1] < array[j]) {
temp = array[j];
array[j] = array[j+1];
array[j+1] = temp; // 变量值交换
}
}
}
}
注意:
- 每一轮比较都会产生一个最值,且该最值一定处于正确位置(即有序);
- 因此,下一轮只需要在剩余无序的部分排序即可,减少比较次数(内层循环中的判断条件多了个
-i); - 依次循环,直至结束。
该算法的平均时间复杂度为 \(O(n^2)\),最坏情况为 \(O(n^2)\) ,最好情况为 \(O(n)\),空间复杂度为 \(O(1)\),排序稳定。
优化一:增加标识位,优化外层循环
对于冒泡排序的常见改进方法是在外层循环中加入一个标识位,用于标志该数组是否已经按照要求顺序排好。若已经排好,则终止排序,减少不必要的排序过程。
public static void bubbleSort(int[] array) {
// 临时变量:用于变量值交换
int temp;
// 外层循环:判断循环走多少轮(数组元素数量决定循环次数,保证数组能完全排序)
for (int i = 0; i < array.length-1 ; i++) {
// 标识位:用于判断标识该数组是否已经按要求排好
// 每次遍历标识位都需先设为false,用于判断后面的元素是否已经排序好
boolean flag = false;
// 内层循环:遍历数组,开始排序。-i防止重复,减少排序(因为每轮比较都会产生一个最值)。
for (int j = 0; j < array.length - 1 - i; j++) {
if (array[j+1] < array[j]) {
temp = array[j];
array[j] = array[j+1];
array[j+1] = temp;
flag = true; // 若发生交换,则更更改标识位
}
}
if (!flag) {
break; // 若已经排好序,则第一轮排序时不满足上述 if 选择条件,跳出循环
}
}
}
优化二:优化内层循环
在刚刚增加了对外层循环的优化的基础上,我们针对内层循环也进行优化。
我们已经知道,每一轮循环都会在数组的 “最后一个元素” 产生一个最值(在array.length - 1 - i处),我们在这里引进一个变量,用于记录每一轮循环最后一次发生变量值交换的位置,在这个位置之后的元素已经完成排序,不再需要重新进行排序。
public static void bubbleSort(int[] array) {
int temp; // 临时变量:用于交换变量值;
int k = array.length - 1;
int pos = 0; // 记录发生交换的索引
for (int i = 0; i < array.length - 1; i++) {
// 标识位:用于判断用于判断后面的元素是否已经排好
boolean flag = false;
for (int j = 0; j < k; j++) {
if (array[j+1] < array[j]) {
temp = array[j+1];
array[j+1] = array[j];
array[j] = temp;
flag = true;
pos = j; // 记录最后发生交换的索引
}
k = pos; // 更新最后一次发生交换的索引,充当内层循环的终止条件
if (!flag) {
break; // 若已经排好序,则退出循环。
}
}
}
}
上述两个优化方法引用自 CSDN:
双向冒泡排序(bidirectional bubble sort):一轮循环同时排序最小和最大元素。结合上述两种冒泡排序的优化方案,算法实现为:
/**
* 双向冒泡排序。
*
* @param arr 数组
*/
public static void biBubbleSort(int[] arr) {
// 高低位指针
int low = 0;
int high = arr.length - 1;
// 交换位
int swapPos = 0;
// 交换标志
boolean swapped = false;
// 循环
while (low < high) {
// 正向冒泡:寻找最大值
for (int i = low; i < high; i++) {
if (arr[i] > arr[i + 1]) {
swap(arr, i, i + 1);
// 记录交换位置
swapPos = i;
// 更新交换标志
swapped = true;
}
}
// 移动高位指针
high = swapPos;
// 判断交换
if (!swapped) {
break;
}
// 逆向冒泡:寻找最小值
for (int i = high; i > low; i--) {
if (arr[i] < arr[i - 1]) {
swap(arr, i, i - 1);
// 记录交换位置
swapPos = i - 1;
}
}
// 移动低位指针
low = swapPos;
}
}
/**
* 交换数组元素。
*
* @param arr 数组
* @param a 交换元素索引
* @param b 交换元素索引
*/
public static void swap(int[] arr, int a, int b) {
int temp = arr[a];
arr[a] = arr[b];
arr[b] = temp;
}
2-9.2.2 快速排序(Quick sort)
排序思想:在数组中选取一个数作为基准数(一般为起始索引处的数),先确定这个基准数在数组中的正确位置。然后,以这个基准数,将数组分为其左右两边两个区间的元素(左区间数小于基准数,右区间数相反)。分别针对这两个区间,安排左右两个指针逐个扫描。将大于基准数的元素放在基准数右侧,小于基准数的元素放在左侧。一轮排序结束后,基准数就放在了正确的位置。当整个流程循环结时,整个数组排序完成。
快速排序是冒泡排序的升级优化版本,其中心思想为“分治+挖坑填数”。
算法实现:
public static void quickSort(int[] arr, int startIndex, int endIndex) {
// 递归结束条件
if (startIndex >= endIndex) {
return;
}
// 设置指针和基准数:以起始处为例
int start = startIndex;
int end = endIndex;
int pivot = arr[startIndex];
// 循环
while (start < end) {
// 内层循环再补上外层循环条件,防止错位等意外情况
// 先移动远端指针,找小的数
while (start < end && arr[end] >= pivot) {
end--;
}
// 始终保持在指针非重叠环境下执行,找到了满足条件的数据,挖出并给予静止指针
// 待填坑的不动,动另外一个指针
if (start < end) {
arr[start++] = arr[end];
}
// 再移动近端指针:找大的数
while (start < end && arr[start] <= pivot) {
start++;
}
if (start < end) {
arr[end--] = arr[start];
}
}
// 循环结束,判断两指针是否重叠
if (start == end) {
// 重叠:已经分好区,将被挖出的基准数放回去
arr[start] = pivot;
}
// 对于剩下的分区,递归解决
// 使用 if 判断减少递归次数
if (startIndex < start - 1) {
quickSort(arr, startIndex, start-1);
}
if (start + 1 < endIndex) {
quickSort(arr, start+1, endIndex);
}
}
解释:
- 选取基准数时,我们一般选择数组首个或末个元素。不同的选择会导致指针的扫描方向不同,但都是从远端向基准数所在处逐个扫描;
- 这里,我们的排序实际上为升序排序。因此,左指针找大数,右指针找小数;
- 被挖待填的指针不动,动另一个指针,直到找到满足条件的数时,将该数挖出填到坑里;
- 待填坑的指针不动,继续上述过程,直至两个指针重叠时,将挖出的基准数填回去,本轮排序完成;
- 一轮排序中总会有一个坑等待填充;
- 由于循环体内指针是可变化的,必须保证循环条件和循环体中内的所有条件表达式都应当使得双指针不重叠;
- 每一轮快速排序实际上可以拆分为三部分(在找到基准数和排序区间后):左右指针不重叠时扫描找数、指针重叠时放回基准数、以放回基准数为“中点”,向两侧区间用同样的方法递归完成排序。
快速排序是冒泡排序的升级版本,该算法的平均时间复杂度和最好情况为 \(O(n \cdot \log n)\)。其最坏情况的时间复杂度与冒泡排序相同,为 \(O(n^2)\) ,空间复杂度为 \(O(\log n)\),排序不稳定。
此部分内容参考、修改、引用自:
2-9.3 插入排序(Insertion sort)
2-9.3.1 直接插入排序(Direct insertion sort)
排序思想:将起始索引的元素到索引 n 的元素看作有序的,把其后的元素都视为无序的。遍历无序的数据,将遍历到的元素插入到有序序列中适当的位置,若遇到相同的数据,则插在后面。其中,n 的范围为 0 - length-1。
算法实现:
public static void insertSort(int[] arr) {
// 用于记录无序序列开始的索引
int startIndex = -1;
// 遍历,找到无序序列开始的索引
for (int i = 0; i < arr.length - 1; i++) {
if (arr[i] > arr[i + 1]) {
startIndex = i + 1;
break;
}
}
// 遍历无序序列,并为其中的元素找到合适位置
for (int i = startIndex; i < arr.length; i++) {
// 找到插入位置
for (int j = i; j > 0 && arr[j] < arr[j - 1]; j--) {
int temp = arr[j];
arr[j] = arr[j - 1];
arr[j - 1] = temp;
}
}
}
注意:
- 遍历数组找无序序列开始处时,应当注意若数组已经有序,在末尾处可能会抛出索引越界异常,应当注意循环的条件;
- 遍历无序序列插入元素时,还是使用交换变量的思想;
- 向后遍历有序序列,不断地将该元素与前一元素相比,期间不断进行变量交换,直至有序部分再次完全有序,即有序性不变;
- 插入元素时,若发现前一元素小于当前元素,则说明该元素已经位于正确的位置。
该算法的平均时间复杂度和最坏情况为 \(O(n^2)\),最好情况为 \(O(n)\),空间复杂度为 \(O(1)\),排序稳定。
优化:使用二分查找提高插入效率(二分插入排序)
若要插入的值为全序列中最小的数,从后往前便利有序序列查找位置将会耗费较长时间。使用二分查找,可以提高效率。
2-9.3.2 希尔排序(Shell sort)
排序思想:希尔排序,又称缩小增量排序,是直接插入排序的优化版本。其核心是通过合理地选取间隔,经过一轮排序,数组将变得大致有序,然后不断缩小间隔,直至间隔为 1,排序完成。间隔为 1 时的排序实际上就是直接插入排序。
一般地,选择数组长度的一半作为间隔,每次对半分,逐渐减小间隔,直至为 1。
算法实现:
public static void shellSort(int[] arr) {
// 增量选取:一般取数组对半长度,每次都取对半
for (int h = arr.length / 2; h > 0; h /= 2) {
// 排序进行的轮数:从增量开始
for (int i = h; i < arr.length; i++) {
// 定义比较的次数或条件
for (int j = i; j > h - 1; j -= h) {
if (arr[j] < arr[j - h]) {
int temp = arr[j];
arr[j] = arr[j - h];
arr[j - h] = temp;
}
}
}
}
}
注意:
- 最内层循环中的条件应当为
j > h - 1,防止下标越界或排序不到位等情况; - 注意每层循环的迭代语句,最外层为间隔迭代,中间层和内层用于比较排序;
- 注意每层循环的初始化条件。
希尔排序的平均时间复杂度为 \(O(n \cdot \log n)\),最好情况为 \(O(n^{1.3})\) ,最坏情况为 \(O(n^2)\),空间复杂度为 \(O(1)\),排序不稳定。
优化:使用克努特(Knuth)序列优化间隔选择:
前面提到过,希尔排序的核心就在于如何合理地选择间隔。使用克努特序列可以有效地提高希尔排序的效率。
数列以逆向形式从 1 开始,通过递归表达式 interval = interval * 3 + 1 产生,初始值为 1.
public static void shellKnuth(int[] arr) {
// 使用克努特序列计算间隔,提高效率
int interval = 1; // 首项为1
// 定义一个循环,正向使用该序列
while (interval <= arr.length / 3) {
interval = interval * 3 + 1; // 克努特序列的递归表达式
}
// 逆向使用间隔,使用希尔排序
for (int h = interval; h > 0; h = (h - 1) / 3) {
// 轮次:定义从增量开始
for (int i = h; i < arr.length; i++) {
// 定义比较的次数或条件
for (int j = i; j > h - 1; j -= h) {
if (arr[j] < arr[j - h]) {
int temp = arr[j];
arr[j] = arr[j - h];
arr[j - h] = temp;
}
}
}
}
}
克努特序列优化方案参考自:
数组排序之希尔排序+ kunth序列(克努特序列)---(文字解说+代码图示+代码实现+代码注释)_数据Java的博客-CSDN博客
浙公网安备 33010602011771号