- 一、插入排序
- 1、直接插入排序
- 2、折半插入排序
- 3、希尔排序
- 二、交换排序
- 1、冒泡排序
- 2、快速排序
- 三、选择排序
- 1、简单选择排序
- 2、堆排序
- (1)调整堆
- (2)创建堆
- 四、归并排序
- 五、基数排序
- 六、各种排序方法的比较
将一组杂乱无章的数据按一定规律顺次排列起来(由小到大或由大到小) ,即将无序序列排成一个有序序列的运算。
排序方法的分类:
一、插入排序
基本思想:
每步将一个待排序的对象,按其关键码大小,插入到前面已经排好序的一组对象的适当位置上,直到对象全部插入为止。
类似我们玩扑克牌,每次抓到一张牌就放到合适的位置上,即边插边排序。
有序插入方法:
- 在插 a[i] 前,数组a的前半段(a[0] ~ a[i-1]) 是有序段,后半段(a[i]~a[n-1])是停留于输入次序的“无序段
- 插入ali]使a[0]~ali-1]有序,也就是要为a[i]找到有序位置i (0<=j<=i)将a[i]插入在a[i]的位置上
如图所示:
下标由0~4的为有序段,4 ~ 10 为无序段
查找插入位置的方法:
1、直接插入排序
如图所示,使用三个变量 i、j、x
i:指向无序段的元素
j :指向有序段的元素
x:保存即将插入的元素
插入的步骤:
1、使用x变量复制元素
2、x 与 a[j] 比较,若大于则直接插入在 j+1 的位置上,若小于将 j 指向的元素往后移,然后j 指针向前移动
【过程演示】
1、假设即将插入的元素为 7 ,使用 x 保存即将插入的元素 x = a[4]
2、将 x 与a[j] 比较, 即 a[3] > x ,将 j 指向的元素向后移,j 向前移动。
3、继续将 x 与 a[j] 比较,a[j] 仍然小于 x,继续向后移动
4、直到 a[j] < x 或者 j <= 0 停止移动,对 j+1 的位置赋值,a[j+1] = x
7、移动完,继续比较下一个元素,即 i++, j = i - 1,不断重复执行以上步骤。
【代码实现】
时间复杂度:O( n2 )
/**
* 直接插入排序
* */
public static int[] sort(int[] arr) {
// i 指向即将要插入的元素
for (int i = 0; i < arr.length; i++) {
// x 作为 i 指针指向的元素的备份
int x = arr[i];
// j 指向有序段的元素,配合
int j = i - 1;
// 若 j 指向的元素 大于 即将插入的元素,则将有序段的元素向后移
for (; j >= 0 && arr[j] > x; j--) {
// j指向的元素向后移动
arr[j + 1] = arr[j];
}
// 插入的位置
arr[j + 1] = x;
}
return arr;
}
2、折半插入排序
可以使用折半查找方法在有序段中查找插入的位置。
- 计算mid值 。
- 比较 mid 和 插入元素的值,a[mid] 和 a[i]
- 若 a[mid] < a[i] , 在右半区查找 low = mid + 1
- 若 a[mid] > a[i] , 在左半区查找 high= mid -1
- 不断循环以上操作,直到 low 超过了 high ,则 high+1 的位置则是插入的位置
- 找到插入位置之后,需要将 high + 1 后面的元素向后移动,腾出位置,插入元素 a[i]
代码实现
-
折半查找减少了比较次数,但是没有减少移动次数
-
平均性能优于直接插入排序
时间复杂度仍然为:O(n2)
public static int[] sort(int[] arr) {
// i 指向即将要插入的元素
for (int i = 0; i < arr.length; i++) {
// x 作为 i 指针指向的元素的备份
int x = arr[i];
int low = 0;
int high = i -1;
while(low <= high) {
int mid = (low + high ) /2;
if (arr[mid] > x) {
// 去左区间查找
high = mid -1;
}else {
// 右区间查找
low = mid + 1;
}
}
// high+1为插入的位置,需要移动 high+1 后边的元素
for (int j = i-1; j >= high + 1; j--) arr[j+1] = arr[j];
// 插入的位置
arr[high + 1] = x;
}
return arr;
}
3、希尔排序
在我们之前的插入排序算法,都是一个个移动,一个个比较,这样就导致了效率较慢的问题,那么如果能增加移动的步幅,效率是不是就会提高了。这就是希尔排序的思想出发点。
基本思想
先将整个待排记录序列分割成若干个子序列,分别进行直接插入排序待整个序列中的记录“基本有序”时,再对全体记录进行一次直接插入排序。
特点
- 缩小增量
- 多遍插入排序
【演示】
如何分割成若干个子序列,使待排序元素减少呢?
我们可以设置一个间隔增量,假设设置为5,每相隔 5个元素为一组,如下图所示:
这样,我们对每一组进行一次插入排序,保证组内有序,并且我们可以看到,当使用间隔增量时,每次移动的步幅是不是就很大了。提高了效率
接下来,缩小增量,假设设置为3,每相隔3个元素为一组,如下图所示:
然后对每一组进行插入排序
最后,对整组元素进行插入排序,此时序列保证基本有序
【思路】
- 定义增量序列,Dk : DM > DM-1 > … > D1 = 1
- 刚才的例子中:D3 = 5、D2 = 3、D2 = 1
- 对每个 Dk 进行 “Dk - 间隔” 插入排序
【代码实现】
希尔排序是一种不稳定的排序方法
public static int[] shell(int[] arr) {
// 设置增量,逐渐缩小,直到缩小为1
for (int gap = arr.length / 2; gap >= 1; gap = gap / 2) {
// 以gap为间隔,对整个序列进行分组。然后每一组进行一次插入排序
for (int i = 0; i < arr.length; i += gap) {
int j = i - gap;
int x = arr[i];
for (; j >= 0 && arr[j] > x; j--) {
// 向后移动,只不过步幅不再是1,而是增量gap
arr[j + gap] = arr[j];
}
// 找到插入的位置
arr[j + gap] = x;
}
}
return arr;
}
二、交换排序
1、冒泡排序
基于简单的交换思想,每趟俩俩比较,将元素小的放前面。
【案例演示】
初始:21,25,49,25* , 16,8
总结
- 有n个元素时,需要 比较 n-1 趟
- 第 m 趟,需要比较 n-m趟
代码实现:
时间复杂度:O(n2)
// 冒泡排序
public static int[] sort(int[] arr) {
int length = arr.length -1;
for (int i = 0; i < length ; i++) { // 趟数
for (int j = 0; j < length - i; j++) { // 每一趟比较的次数
// 如果前一个元素大于后一个元素,就交换
if (arr[j] > arr[j + 1]) {
// 交换
int res = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = res;
}
}
}
return arr;
}
优点:
每趟结束时,不仅能挤出一个最大值到后面位置,还能同时部分理顺其他元素。
优化
1、在比较交换的过程中,如果某一趟中没有发生交换,其实我们就没必要在比较后面的几趟了,因为它已经是有序了。
如下图所示,在第六趟中,就没有发生交换。那么第七趟就没必要在进行比较了。
2、在交换的过程中,使用了额外变量临时保存交换的值,其实我们可以利用异或运算,无需使用额外空间,并且异或运行要比普通交换快得多。
int res = arr[i];
arr[i] = arr[j];
arr[j] = res;
// 相同为0,任何数与0异或等于它本身
// 相同为0,任何元素与0异或等于它本身
arr[j] = arr[j] ^ arr[j+1];
arr[j+1] = arr[j] ^ arr[j+1];
arr[j] = arr[j] ^ arr[j+1];
优化后的代码:
// 冒泡排序
public static int[] sort(int[] arr) {
int length = arr.length -1;
for (int i = 0; i < length ; i++) { // 趟数
boolean flag = false;
for (int j = 0; j < length - i; j++) { // 每一趟比较的次数
// 如果前一个元素大于后一个元素,就交换
if (arr[j] > arr[j + 1]) {
// 交换
// int res = arr[j];
// arr[j] = arr[j + 1];
// arr[j + 1] = res;
// 相同为0,任何元素与0异或等于它本身
arr[j] = arr[j] ^ arr[j+1];
arr[j+1] = arr[j] ^ arr[j+1];
arr[j] = arr[j] ^ arr[j+1];
flag = true;
}
}
// 如果未发生交换,就无需比较后面的几趟了
if (!flag) break;
}
return arr;
}
2、快速排序
快速排序是冒泡法的一种改进,每一趟排序都将待排序的序列分成左右俩个部分,然后对俩个部分再次进行递归操作。
基本思想
- 任取一个元素作为中心,称为pivot:中心点
- 所有比它小的元素一律放在 pivot 的前面,比它大的元素放在 pivot 的后边
- 形成左右俩个子表,对俩个子表重新调整 中心元素,然后分别对俩个子表在进行快速排序
【案例演示】
- 利用 low、high 俩个指针,扫描序列
- 不断移动high指针,当 high 指针遇到比 pivot 小的,停止移动
- 不断移动 low指针,当low指针遇到比pivot大的,停止移动
- low 和 high 停止移动后,则交换俩个元素的值
- 不断重复 2,3,4 步,直到 low 和 high 指针相遇时,说明扫描完一趟了,将pivot 和 low或者 right 交换
- 此时 pivot 左边比它小,右边比它大,分割成了俩部分,在进行递归,重复2,3,4,5 操作。
1、假设选取第一个元素为中心点 pivot = 49
2、移动 high 指针,直到遇见比 49 小的,停止移动
3、high 停止移动后,开始移动 low 指针直到遇见比 pivot 大的
4、low 和 high 都停止后,交换low和high的元素
5、重复操作第2、3、4步,移动 high、low,然后进行交换
6、继续移动 high 指针,此时发现 low 和high 重合,交换 pivot 与重合位置的元素
7、此时我们发现,pivot 左边比它小,右边比它大。完成了第一趟比较。
8、此时分割成的俩部分,继续递归重复以上操作
代码实现
// 重载
public static void sort(int[] arr) {
sort(arr, 0, arr.length-1);
}
public static void sort(int[] arr, int low, int high) {
if (low > high) return;
// 使用额外的两个变量保存左右指针,方便操作
// 真正移动的是 l和h指针
int l = low;
int h = high;
// 第一个元素设置为中心元素
int pivot = arr[low];
while (l != h) {
// 移动右指针,当遇到比pivot小的停止
while (arr[h] >= pivot && l < h) h--;
// 移动左指针,当遇到比pivot大的停止
while (arr[l] <= pivot && l < h) l++;
// 当左右指针都停止后,交换元素为止
int temp = arr[l];
arr[l] = arr[h];
arr[h] = temp;
}
// 当左右指针相遇时,交换pivot和相遇的位置元素
// 此时 low 并没有动,一直指向第一个元素,也就是pivot
arr[low] = arr[l];
arr[l] = pivot;
// 递归操作
sort(arr, low, l - 1);
sort(arr, l + 1, high);
}
总结
假设对 {95、85、79、74、68、50、46} 这样的有序序列进行划分 ,那么在第一次划分后,会得到其中一个子序列的长度为0,这时其实就退化成了冒泡排序
因此,快速排序不适于对原本有序或者基本有序的序列进行排序,这与插入排序时相反的。
三、选择排序
1、简单选择排序
基本思想
在待排序的数据中选出最小(大)的元素,放在最终的位置
基本操作
-
首先通过n-1次关键字比较,从n个记录中找出关键字最小的记录,将它与第一个记录交换
-
再通过 n-2次 比较,从剩余的 n-1个记录中找出关键字次小的记录,将它与第二个记录交换
-
重复上述操作,共进行n-1趟排序后,排序结束
代码实现
public static void sort(int[] arr) {
for (int i = 0; i < arr.length; i++) {
// 假设每一趟的第一个元素为最小值
int k = i;
for (int j = i + 1; j < arr.length; j++) {
if (arr[j] < arr[k]) k =j;
}
if (k != i) {
arr[i] = arr[k] ^ arr[i];
arr[k] = arr[k] ^ arr[i];
arr[i] = arr[k] ^ arr[i];
}
}
}
2、堆排序
堆排序(Heapsort)是指利用堆这种数据结构所设计的一种排序算法。堆积是一个近似完全二叉树的结构,并同时满足堆积的性质:即子结点的键值或索引总是小于(或者大于)它的父节点。堆排序可以说是一种利用堆的概念来排序的选择排序。分为两种方法:
- 大顶堆:每个节点的值都大于或等于其子节点的值,在堆排序算法中用于升序排列;
- 小顶堆:每个节点的值都小于或等于其子节点的值,在堆排序算法中用于降序排列;
堆排序的平均时间复杂度为 Ο(nlogn)。
【案例演示】
将以下俩个序列转换成二叉树,判断是否是堆
{ 98、 77 、35 、62 、55 、14 、35 、48}
{14 、48、 35、 62、 55、 98、 35、 77]
转换之后,如下图所示:
左边的二叉树每个非叶子节点都大于它的孩子结点,因此它是大根堆
相反,右边的每个非叶子节点都小于它的孩子结点,因此它是小根堆
从图中可以看出,对于大根堆,根节点就是最大的值,相反,小根堆的根节点就是最小的值。
因此我们只需要输出最大值(最小值),将剩下的元素继续调整一个堆,继续输出最大值(最小值)…如此反复,便能得到一个有序序列。
所以,堆排序最主要的俩个问题就是:
- 如何将一个序列构建堆
- 输出栈顶元素后,剩余元素如何调整成一个堆
以小根堆为例
(1)调整堆
- 输出堆顶元素之后,以堆中最后一个元素替代之
- 然后将根结点值与左、右子树的根结点值进行比较,并与其中小者进行交换
- 如果是大根堆,则与其较大者进行交换
- 重复上述操作,直至叶子结点,将得到新的堆,称这个从堆顶至叶子的调整过程为"筛选"
【演示】
1、初始情况
2、输出根顶元素,并以最后一个元素 97 替换
3、比较左右孩子结点,并与其较小者交换,直到成为叶子结点
: 38 < 27 , 将 97 与 27 交换
: 65 < 49 ,将 97 与 49 交换
4、重复执行 2、3 步,不断输出根顶结点,然后调整堆
【代码实现】
/**
* @description 调整小根堆
* @date 2023/9/13 18:12
* @param arr 数组
* @param i 需要开始调整结点的下标
* @param length 数组的长度
* @return void
*/
public static void adjustHeap(int[] arr, int i, int length) {
// 先保存该结点
int parent = arr[i];
// i的左孩子结点: 2i+1
// i的右孩子结点:2i+2
for (int childIndex = 2 * i + 1; childIndex < length; childIndex = 2 * childIndex + 1) {
// 判断左孩子与右孩子的大小,如果右孩子大,则将j指针指向右孩子
if (childIndex+1 < length && arr[childIndex] < arr[childIndex+1]) {
childIndex++;
}
// 判断根结点与左右孩子结点的大小
if (parent < arr[childIndex]) {
// 如果小于左右孩子结点,将左右孩子结点复制给父结点
// 此时 arr[i]为较小左右孩子结点的值
arr[i] = arr[childIndex];
// 此时 i 指针指向较小的左右孩子结点
i = childIndex;
}else {
break;
}
}
// 将父结点的值赋值给较小的左右孩子结点,实现了交换
arr[i] = parent;
}
(2)创建堆
创建堆其实就是一个 反复 筛选 的过程
- 单结点的二叉树是堆
- 在完全二叉树中所有的叶子结点都是堆,无需调整,直接从最后一个非叶子结点开始即可。
代码实现
for (int i = arr.length / 2 - 1; i >= 0; i--) {
adjustHeap(arr, i, arr.length);
}
整体代码
package ChapterEight;
import java.util.Arrays;
/**
*
* Author: YZG
* Date: 2023/9/13 18:12
* Description:
*/
public class HeepSortClass {
public static void main(String[] args) {
int[] arr = {1, 3, 2, 8, 1, 9};
HeepSortClass.heepSort(arr);
}
/**
* @description 调整小根堆
* @date 2023/9/13 18:12
* @param arr 数组
* @param i 需要开始调整结点的下标
* @param length 数组的长度
* @return void
*/
public static void adjustHeep(int[] arr, int i, int length) {
// 先保存该结点
int parent = arr[i];
// i的左孩子结点: 2i+1
// i的右孩子结点:2i+2
for (int childIndex = 2 * i + 1; childIndex < length; childIndex = 2 * childIndex + 1) {
// 判断左孩子与右孩子的大小,如果右孩子大,则将j指针指向右孩子
if (childIndex+1 < length && arr[childIndex] < arr[childIndex+1]) {
childIndex++;
}
// 判断根结点与左右孩子结点的大小
if (parent < arr[childIndex]) {
// 如果小于左右孩子结点,将左右孩子结点复制给父结点
// 此时 arr[i]为较小左右孩子结点的值
arr[i] = arr[childIndex];
// 此时 i 指针指向较小的左右孩子结点
i = childIndex;
}else {
break;
}
}
// 将父结点的值赋值给较小的左右孩子结点,实现了交换
arr[i] = parent;
}
public static void heepSort(int[] arr) {
//从最后一个非叶子节点开始构建小顶堆
for (int i = arr.length / 2 - 1; i >= 0; i--) {
adjustHeep(arr, i, arr.length);
}
//进行 arr.length - 1 趟排序
for (int j = arr.length - 1; j > 0; j--) {
//将首个元素与末尾元素进行交换
int temp = arr[0];
arr[0] = arr[j];
arr[j] = temp;
//交换完,继续调整,使其符合小顶堆
adjustHeep(arr, 0, j);
}
}
}
四、归并排序
归并排序(MERGE-SORT)是利用归并的思想实现的排序方法,该算法采用经典的分治(divide-and-conquer)策略(分治法将问题分(divide)成一些小的问题然后递归求解,而治(conquer)的阶段则将分的阶段得到的各答案"修补"在一起,即*分而治之)。
分: 将整个序列拆分成最小的序列,也就是一个元素一个序列。
治:将每个序列合并成有序的序列,最终合成一个大的有序序列
如下图所示:
第一步:将整个无序序列拆分成不可再分的序列
第二步:将拆分的序列合并成有序的序列
在合并俩个序列时,我们可以利用一个额外的数组temp[] 和俩个指针 i、j:
- i、j 分别指向俩个即将合并的序列中的元素
- 比较俩个指针指向的元素,哪个小就加到 temp 中,并且移动对应的指针,直到某个指针指向某个序列的结尾
- 将剩余的元素增加到 temp 中
- 拷贝 temp 到原数组
代码实现
package ChapterEight;
import java.util.Arrays;
/**
*
* Author: YZG
* Date: 2023/9/13 22:37
* Description:
*/
public class MergeSort {
public static void main(String[] args) {
int[] arr = {1, 3, 2, 8, 1, 9};
MergeSort.divide(arr,0,arr.length-1);
}
//分解
public static void divide(int[] arr, int left, int right) {
if (left < right) {
int mid = (left + right) / 2; //中间指针
//对左边进行分解
divide(arr, left, mid);
//对右边进行分解
divide(arr, mid + 1, right);
//合并
conquer(arr, left, mid, right);
}
}
/**
* 功能:将俩个部分的子序列进行合并
*
* @param arr 待排序的数组
* @param left 指向左子序列的指针
* @param mid 指向中间
* @param right 指向右子序列的指针
*/
public static void conquer(int[] arr, int left, int mid, int right) {
// 临时数组
int[] temp = new int[arr.length];
int l = left;
int r = mid + 1;
int index = 0;//指向临时数组
while (l <= mid && r <= right) {
if (arr[l] <= arr[r]) {
//如果左边的数小于等于右边的数,就将左边的数放到temp中
temp[index++] = arr[l++];
} else {
//相反右边的数小于左边的数,就将右边的数放到temp中
temp[index++] = arr[r++];
}
}
//当左边有剩余元素的时候,加到temp中
while (l <= mid) {
temp[index++] = arr[l++];
}
//当右边有剩余元素时,加到temp中
while (r <= right) {
temp[index++] = arr[r++];
}
//最后将temp数组拷贝到原数组中
index = 0;
while (left <= right) {
arr[left++] = temp[index++];
}
}
}
五、基数排序
基本思想:分配+收集
也叫桶排序或者箱排序:设置若干个箱子,将关键字为 k 的记录放入第 k 个箱子,然后再按序号将非空的连接。
基数排序: 数字是有范围的,由 0~9 这十个数字组成,则需要设置10个箱子,相继按照 个、十、百…进行排序
【案例演示】
假设对以下序列进行排序
(614,738,921,485,637,101,215,530,790,306)
此时,排序已经完成,需要注意的是,每次进行收集之后,都要将统里面的元素进行清空。
代码实现
//基数排序
public static void radix(int[] arr) {
//1、获取数组中最大的数
int max = 0;
for (int i = 0; i < arr.length; i++) {
if (max < arr[i]) {
max = arr[i];
}
}
//求最大数的位数
int length = (max + "").length();
//2、创建10个桶子,分别对应数字0~9.每个桶子的容量为数组的长度
int[][] bucket = new int[10][arr.length];
//用来表示桶子中数据的个数 比如:count[0] = 3 .表示第一个桶子中有三个数据
int[] count = new int[10];
for (int i = 0, n = 1; i < length; i++, n = n * 10) {
for (int j = 0; j < arr.length; j++) {
//3、对数组进行遍历。获取每一个数据的个位数,十位数,百位数
int digit = arr[j] / n % 10;
//4、按对应的数字放到对应的桶子中,桶子的容量并+1
bucket[digit][count[digit]++] = arr[j];
}
int index = 0; //指向原数组的指针
//6、将桶子中的数据依次取出来放回原数组中[共有10个桶子]
for (int l = 0; l < 10; l++) {
if (count[l] != 0) {//判断桶子中的数据是否等于0
for (int k = 0; k < count[l]; k++) {
arr[index++] = bucket[l][k];
}
}
//每遍历一个桶,将桶中的数据进行清 0
count[l] = 0;
}
}
}
六、各种排序方法的比较
1、时间性能
按平均的时间性能来分,有三类排序方法
时间复杂度为O(nlogn)的方法有:
快速排序、堆排序和归并排序,其中以快速排序为最好
时间复杂度为O(n2)的有
直接插入排序、冒泡排序和简单选择排序,其中以直接插入为最好特别是对那些对关键字近似有序的记录序列尤为如此;
时间复杂度为O(n)的排序方法只有: 基数排序。
2、当待排记录序列按关键字顺序有序时,直接插入排序和冒泡排序能达到 O(n) 的时间复杂度,而对于快速排序而言,这是最不好的情况,此时的时间性能退化为 O(n2)
3、简单选择排序,堆排序和归并排序的时间性能不随记录序列的关键的分布而改变。