目录
一、排序梦的开始------冒泡排序
冒泡排序可以说是很多人入门编程时接触的第一个排序算法,原理非常直观好理解
核心思想就是:从头开始,让数组中每一个数和它后面相邻的数做比较 ,根据想要升序还是降序,决定是否交换位置。比如我们要排成升序:如果当前数比后一个数大,就交换两者;如果更小,就保持不动。一轮比较下来,最大的数就像气泡一样 "冒" 到了数组末尾。重复这个过程,直到整个数组有序。下面是优化过的冒泡排序
java
/**
* 优化版冒泡排序:提前终止 + 减少多余循环
*/
public static void Msort(int[] arr) {
// 边界判断:空数组或长度<=1不用排序
if (arr == null || arr.length <= 1) {
return;
}
// 外层循环:最多跑 arr.length - 1 轮(你原来多跑了一轮)
for (int i = 0; i < arr.length - 1; i++) {
// 优化1:加一个标志,标记本轮是否发生过交换
boolean isSwapped = false;
// 内层循环:每轮减少 i 次比较
for (int j = 1; j <= arr.length - 1 - i; j++) {
if (arr[j - 1] > arr[j]) {
swap(arr, j - 1, j);
isSwapped = true; // 发生交换就标记
}
}
// 优化2:如果本轮没有交换,说明数组已经有序,直接退出!
if (!isSwapped) {
break;
}
}
}
因为交换十分常见所以将作为方法在这里给出,下面的所有代码几乎都会用到这个方法。
java
private static void swap(int[] arr, int i, int j) {
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
二、扑克的奥秘------插入排序
插入排序就像是我们在打扑克牌的时候要理牌,我们会把合适的牌插入到合适的位置,这个过程的逻辑和我们的插入排序是一样的。
核心思想就是:我们可以把数组看成左边已排序、右边未排序 两部分。要有两个指针,一个从下标 1 开始往后走(i),另一个每次从 i-1 的位置往前遍历(j)。先把下标 i 位置的值保存到一个 int 变量 temp 里,然后 j 向前逐个比较:如果 j 位置的值比 temp 大,就把 j 位置的值往后挪到 j+1 位置,继续往前找;如果遇到不比 temp 大的数,就把 temp 放到 j+1 这个位置,保证更大的值在后面。j 在整个过程中会不断往前移动(j--),循环结束后统一再执行一次 arr[j+1] = temp,这样就能处理 j 一直走到 -1、需要插入到数组最前面的极端情况。下面是代码演示
java
public static void Csort(int[] arr) {
// 从第二个元素开始,默认第一个元素已有序
for (int i = 1; i < arr.length; i++) {
// 记录当前要插入的元素
int temp = arr[i];
// j 从 i-1 开始向前遍历
int j = i - 1;
for (; j >= 0; j--) {
// 比 temp 大的元素向后移动
if (arr[j] > temp) {
arr[j + 1] = arr[j];
} else {
// 找到插入位置,放入 temp 并退出
arr[j + 1] = temp;
break;
}
}
// 处理插入到最前面的情况(j == -1)
arr[j + 1] = temp;
}
}
三、跨步的魔法------希尔排序
希尔排序简单来说就是分组进行插入排序
核心思想就是:设置一个 gap(步长)来决定分组数量,按照步长把数组分成多个小组,对每个小组分别进行插入排序。经过多轮分组排序后,整个数组会越来越接近有序 。最后当步长缩小到 1、只剩一组时,再执行一次插入排序,整个数组就排好序了。希尔排序可以看作是对直接插入排序的优化升级 ,它解决了普通插入排序一个很明显的短板。直接插入排序在数组基本有序 时效率很高,但如果数组整体很乱、而且数据量很大,每次都要一个个往前挪,移动次数非常多,效率就会明显下降。简单来说就是直接插入排序适合小数据、基本有序的数据;希尔排序适合更大规模、更混乱的数据,整体效率更高。下面是代码实现
java
/**
* 希尔排序入口方法
* 不断缩小增量 gap,直到 gap = 1
*/
public static void Shersort(int[] arr) {
// 初始化增量为数组长度
int gap = arr.length;
// 当增量大于1时,持续分组排序
while (gap > 1) {
// 增量每次除以2缩小
gap /= 2;
// 根据当前增量进行分组插入排序
Shesort(arr, gap);
}
}
/**
* 根据指定增量 gap 对数组进行分组插入排序
* @param arr 待排序数组
* @param gap 增量(步长)
*/
private static void Shesort(int[] arr, int gap) {
// 从第 gap 个元素开始,逐个对其所在组进行插入排序
for (int i = gap; i < arr.length; i++) {
// 保存当前待插入元素
int temp = arr[i];
// 同组内前一个元素下标
int j = i - gap;
// 同组内向前比较
for (; j >= 0; j -= gap) {
// 当前元素比待插入元素大,向后移动
if (arr[j] > temp) {
arr[j + gap] = arr[j];
} else {
// 找到插入位置,放入并退出
arr[j + gap] = temp;
break;
}
}
// 处理插入到组最前面的情况
arr[j + gap] = temp;
}
}
四、挑出最小的它------选择排序
选择排序也是一种入门级的排序算法,原理和前面的插入、冒泡排序一样好理解,核心逻辑简单直接,没有复杂的分组或步长设置,重点在于 "筛选" 出最小元素并放到对应位置。
选择排序的核心思想也很直观:同样需要两个指针 i 和 j,不同的是,j 每次从 i+1 开始往后遍历。先把当前 i 的下标存到一个叫 minIndex 的变量里,代表当前最小值的位置。然后 j 向后遍历,如果发现 j 位置的值比 minIndex 位置的值更小,就更新 minIndex 为 j。等 j 遍历完一轮,最后再把 i 位置和 minIndex 位置的元素交换。这样就能保证每一轮都把未排序区间里最小的元素放到最前面。下面是代码实现
java
/**
* 选择排序
* 核心思想:每一轮找到未排序部分的最小值,放到未排序部分的开头
*/
public static void Xsort(int[] arr) {
// 外层循环:控制每一轮要确定的位置 i
for (int i = 0; i < arr.length; i++) {
// 先假设当前 i 位置就是最小值的下标,存入 minindex
int minindex = i;
// 内层循环:从 i+1 开始往后遍历,寻找真正的最小值下标
for (int j = i + 1; j < arr.length; j++) {
// 如果发现 j 位置的值比当前最小值更小
if (arr[j] < arr[minindex]) {
// 更新最小值下标为 j
minindex = j;
}
}
// 内层循环结束,找到了最小值下标 minindex
// 交换 i 位置和 minindex 位置的值,把最小值放到 i 位置
swap(arr, i, minindex);
}
}
关于选择排序其实还有一种双向选择排序思路是差不多的,但也有一些不一样
核心思路其实就是:简单来说就是一个指针从头开始、一个从尾开始,每一轮同时找出未排序区间里的最小值和最大值,分别放到左边和右边对应的位置,这样一轮就能确定两个元素,减少整体循环次数。下面是代码
java
// 双向选择排序(一次找最小和最大)
public static void Xsort2(int[] arr) {
// 左边界,从开头开始
int l = 0;
// 右边界,从末尾开始
int r = arr.length - 1;
// 当左右边界未相遇时继续排序
while (l < r) {
// 假设当前左边界是最小值下标
int minindex = l;
// 假设当前左边界也是最大值下标
int maxindex = l;
// 在 l ~ r 区间里遍历找最小和最大值下标
for (int i = l + 1; i <= r; i++) {
// 找到更小值,更新最小值下标
if (arr[i] < arr[minindex]) {
minindex = i;
}
// 找到更大值,更新最大值下标
if (arr[i] > arr[maxindex]) {
maxindex = i;
}
}
// 把最小值交换到左边界
swap(arr, l, minindex);
// 特殊情况:如果最大值原来就在左边界
// 刚才交换会把它换到 minindex 位置,所以要修正 maxindex
if (maxindex == l) {
maxindex = minindex;
}
// 把最大值交换到右边界
swap(arr, r, maxindex);
// 左边界右移,右边界左移,缩小范围
l++;
r--;
}
}
五、金字塔的智慧------堆排序
因为上一篇在讲堆的时候就详细说过这个排序了这个给个大致的思路
思路:先把整个数组构建成一个大顶堆,此时堆顶元素就是整个数组的最大值。接着将堆顶元素与数组末尾元素交换,让最大值固定到最后。然后把剩下的部分重新调整成堆,重复这个 "取堆顶→交换→调整堆" 的过程,逐步缩小待排序区间,最终整个数组就会变成有序序列。下面是代码
java
/**
* 堆排序(升序)
*/
public static void Dsort(int[] arr) {
// 标记未排序的最后一个元素位置
int end = arr.length - 1;
// 先将数组构建成大顶堆
createHeap(arr);
// 逐步缩小未排序区间
while (end > 0) {
// 交换堆顶(最大值)与未排序区间最后一个元素
swap(arr, 0, end);
// 对新堆顶向下调整,重新形成大顶堆
siftDown(arr, 0, end);
// 未排序区间长度减一
end--;
}
}
/**
* 创建大顶堆
*/
private static void createHeap(int[] arr) {
// 从最后一个非叶子节点开始,向前逐个向下调整
for (int parent = (arr.length - 1 - 1) / 2; parent >= 0; parent--) {
siftDown(arr, parent, arr.length);
}
}
/**
* 向下调整(大顶堆)
* @param arr 数组
* @param parent 要调整的父节点下标
* @param length 待调整区间长度
*/
private static void siftDown(int[] arr, int parent, int length) {
// 初始左孩子下标
int child = parent * 2 + 1;
while (child < length) {
// 找到左右孩子中较大的那个
if (child + 1 < length && arr[child + 1] > arr[child]) {
child++;
}
// 如果孩子比父节点大,交换
if (arr[child] > arr[parent]) {
swap(arr, parent, child);
// 继续向下调整
parent = child;
child = parent * 2 + 1;
} else {
// 已经满足堆结构,退出
break;
}
}
}
六、效率的王者------快速排序
ok 啊,也是来到快速排序了。快速排序在效率这一块可谓是如它的名字一样,越乱的数据它排得越快 ,相反数组越有序反而会更慢。快速排序的实现有几种方法,这里详细讲解挖坑法,也是最重要、最常用的一种。
快速排序的思路也并不复杂:也是有两个指针一个从头开始一个从尾开始,把头元素存到名为 index 的 int 变量里作为基准值。先让右指针向左走,找到比基准值小的数,就把它填到左边的坑位;再让左指针向右走,找到比基准值大的数,就把它填到右边的坑位。左右指针相遇时,把基准值放到这个位置,一趟下来就把数组分成了左边小、右边大两部分。之后再递归对左右两部分重复这个过程,整个数组就排好序了。并且快速排序还有优化优化的方法一个称为三数取中法 思路是在区间头部、中部、尾部三个数里选一个大小适中的作为基准值,防止数组本身有序时快排退化成 O (n²)。另一个是小区间优化,当递归到数据量很小的区间时,不再继续递归快排,而是直接改用插入排序,减少递归开销,整体效率会更高。下面是代码
java
/**
* 快速排序入口
*/
public static void Ksort(int[] arr) {
// 对整个数组进行快速排序
quickSort(arr, 0, arr.length - 1);
}
/**
* 快速排序递归核心函数
*/
private static void quickSort(int[] arr, int start, int end) {
// 区间只有一个数或不存在,直接返回
if (start >= end) {
return;
}
// 小区间优化:数据量小时直接用插入排序,效率更高
if (end - start + 1 <= 10) {
// 对小区间执行插入排序
insertSort(arr, start, end);
return;
}
// 快排优化:三数取中,找到合适的基准值下标
int midIndx = getMiddleNum(arr, start, end);
// 把基准值交换到最左边
swap(arr, start, midIndx);
// 挖坑法分区,返回基准值最终位置
int pivot = partition(arr, start, end);
// 递归排序基准值左边
quickSort(arr, start, pivot - 1);
// 递归排序基准值右边
quickSort(arr, pivot + 1, end);
}
/**
* 挖坑法分区(快排核心)
* 返回基准值最终下标
*/
private static int partition(int[] arr, int left, int right) {
// 把最左边值作为基准值
int index = arr[left];
while (left < right) {
// 从右往左找比基准小的数
while (left < right && arr[right] >= index) {
right--;
}
// 填到左边坑位
arr[left] = arr[right];
// 从左往右找比基准大的数
while (left < right && arr[left] <= index) {
left++;
}
// 填到右边坑位
arr[right] = arr[left];
}
// 最后把基准值放入坑位
arr[left] = index;
// 返回基准下标
return left;
}
简单说一下另一种hoare方法:Hoare 法是快排最原始、最经典的分区写法,和你刚才学的挖坑法思路很像,但没有 "挖坑填数",而是直接双指针交换 。核心思路就一句话:选一个基准值(通常最左边),左指针从左往右找比基准大的数,右指针从右往左找比基准小的数,找到后直接交换两个数;重复到指针相遇,最后把基准值换到相遇位置,完成分区。
下面是代码
java
/**
* Hoare 法分区(左右指针交换版)
*/
private static int partitionHoare(int[] arr, int left, int right) {
// 把左边第一个数作为基准值
int pivot = arr[left];
// 保存基准值最开始的下标
int pivotIndex = left;
// 左右指针开始相向而行
while (left < right) {
// 右指针向左找:找到比基准小的就停下
while (left < right && arr[right] >= pivot) {
right--;
}
// 左指针向右找:找到比基准大的就停下
while (left < right && arr[left] <= pivot) {
left++;
}
// 交换左右指针指向的两个数
swap(arr, left, right);
}
// 指针相遇,把基准值交换到正确位置
swap(arr, pivotIndex, left);
// 返回基准值最终所在下标
return left;
}
快速排序还有一种非递归写法,底层思路和递归版完全一样,只是把递归用的栈,换成了我们手动创建的栈来模拟。
思路:先对整个数组做一次分区,找到基准值位置;然后把左右两个待排序区间的下标 存进栈里;接着循环从栈中取出区间下标,继续分区、继续存区间;直到栈为空,所有区间都处理完毕,数组也就排好序了。本质就是用栈模拟递归的过程,避免了递归深度过高导致栈溢出的问题,效率和稳定性更好。下面是代码
java
/**
* 快速排序非递归版本
* 用栈模拟递归,避免栈溢出
*/
public static void quickSortNor(int[] arr, int start, int end) {
// 创建一个栈,用来存储待排序区间的起止下标
Deque<Integer> stack = new LinkedList<>();
// 第一次分区,找到基准值位置
int pivot = Ki(arr, start, end);
// 把左区间 [start, pivot-1] 入栈
if (pivot - 1 > start) {
stack.push(start);
stack.push(pivot - 1);
}
// 把右区间 [pivot+1, end] 入栈
if (pivot + 1 < end) {
stack.push(pivot + 1);
stack.push(end);
}
// 栈不为空,说明还有待排序区间
while (!stack.isEmpty()) {
// 注意:先入后出,先弹结束下标,再弹开始下标
end = stack.pop();
start = stack.pop();
// 对当前区间分区
pivot = Ki(arr, start, end);
// 左区间有效则入栈
if (pivot > start + 1) {
stack.push(start);
stack.push(pivot - 1);
}
// 右区间有效则入栈
if (pivot < end - 1) {
stack.push(pivot + 1);
stack.push(end);
}
}
}
//挖坑
private static int Ki(int[] arr, int left, int right) {
int index = arr[left];
while (left < right) {
while (left < right && arr[right] >= index) {
right--;
}
arr[left] = arr[right];
while (left < right && arr[left] <= index) {
left++;
}
arr[right] = arr[left];
}
arr[left] = index;
return left;
}
七、分分又和和------归并排序
归并排序是比较有趣的,它采用的是分治思想。思路就是把一组数据一直不停地分组,拆到每组只剩一个元素为止,因为单个元素本身就是有序的。然后再把两个有序的小组两两合并排序,再和其他组合并继续排序,反复这样合并下去,最后整个数组就变成完整的有序序列了。
归排核心思想相对来说会复杂一点:先通过递归不断把数组从中间拆分,分成左右两个子区间,一直拆到每个区间只有一个元素;之后开始回溯合并,用两个指针分别遍历左右两个有序区间,依次比较大小,把较小的元素先放进临时空间,直到其中一个区间遍历完,再把剩下的元素直接追加进去;这样一层一层往上合并,每次合并的都是两个已经有序的数组,最终合并出来的整个数组就是有序的。下面是代码
java
/**
* 归并排序入口方法
*/
public static void Gsort(int[] arr) {
// 调用递归归并,范围是整个数组
GsortC(arr, 0, arr.length - 1);
}
/**
* 归并排序递归拆分函数(分治 - 分)
* 先拆分,再合并
*/
private static void GsortC(int[] arr, int left, int right) {
// 递归结束条件:区间只有一个元素,已经有序
if (left >= right) {
return;
}
// 计算中间位置,把数组分成左右两部分
int mid = (left + right) / 2;
// 递归拆分左半边
GsortC(arr, left, mid);
// 递归拆分右半边
GsortC(arr, mid + 1, right);
// 拆分完成,开始合并两个有序区间
merge(arr, left, mid, right);
}
/**
* 合并两个有序区间(分治 - 治)
* 左边:[left, mid]
* 右边:[mid+1, right]
*/
private static void merge(int[] arr, int left, int mid, int right) {
// 临时数组,用来存放合并后的有序数据
int[] tmp = new int[right - left + 1];
// 临时数组下标
int k = 0;
// 左区间起始下标
int s1 = left;
// 右区间起始下标
int s2 = mid + 1;
// 同时遍历左右两个有序区间,谁小就先放进 tmp
while (s1 <= mid && s2 <= right) {
if (arr[s1] <= arr[s2]) {
// 左区间当前元素更小,放入 tmp
tmp[k++] = arr[s1++];
} else {
// 右区间当前元素更小,放入 tmp
tmp[k++] = arr[s2++];
}
}
// 左边还有剩余元素,全部放进 tmp
while (s1 <= mid) {
tmp[k++] = arr[s1++];
}
// 右边还有剩余元素,全部放进 tmp
while (s2 <= right) {
tmp[k++] = arr[s2++];
}
// 把临时数组中排好序的数据,拷贝回原数组
for (int i = 0; i < k; i++) {
arr[i + left] = tmp[i];
}
}
八、七大排序的比较
讲完了七大排序算法,这里做一个完整的对比总结,把每个排序的核心特性(时间、空间、稳定性)都整理成表格,一目了然
| 排序算法 | 平均时间复杂度 | 最好情况 | 最坏情况 | 空间复杂度 | 稳定性 |
|---|---|---|---|---|---|
| 冒泡排序 | O(n²) | O(n) | O(n²) | O(1) | 稳定 |
| 选择排序 | O(n²) | O(n²) | O(n²) | O(1) | 不稳定 |
| 插入排序 | O(n²) | O(n) | O(n²) | O(1) | 稳定 |
| 希尔排序 | O(n¹·³) | O(n) | O(n²) | O(1) | 不稳定 |
| 堆排序 | O(nlogn) | O(nlogn) | O(nlogn) | O(1) | 不稳定 |
| 归并排序 | O(nlogn) | O(nlogn) | O(nlogn) | O(n) | 稳定 |
| 快速排序 | O(nlogn) | O(nlogn) | O(n²) | O(logn) | 不稳定 |
1.稳定排序:相等元素排序后相对位置不变冒泡、插入、归并是稳定排序
2.不稳定排序:相等元素位置可能被打乱选择、希尔、堆、快排都不稳定
3.空间复杂度归并需要额外数组 O (n),快排递归栈 O (logn),其他基本都是 O (1)
4.效率梯队慢:冒泡、选择、插入中:希尔快:堆、归并、快排
九、总结与回顾
不知不觉就把七大排序算法全部梳理完啦,从最入门的冒泡、插入、选择排序,到进阶的希尔、堆、快速、归并排序,每一种都有自己的特点,用起来也各有讲究,下面来总结一下,好记又好懂
首先说最基础的三个排序:冒泡、插入、选择,它们三个原理最简单,上手最快,代码也不复杂,适合数据量小、对效率要求不高的场景。冒泡排序靠 "相邻交换" 冒出头,插入排序像理扑克一样插位置,选择排序则是 "挑最小的往前面放",尤其是双向选择排序,一次能确定两个元素,稍微省点事。但它们的短板也很明显,时间复杂度都是 O (n²),数据量一大,效率就会明显下降,日常练手、小场景用完全没问题。
然后是进阶款的希尔排序,它算是插入排序的 "升级版",靠设置步长分组排序,解决了插入排序在乱序大数据下效率低的问题,平均时间复杂度降到了 O (n¹・³),不用额外空间,性价比很高,适合中等规模的数据排序,唯一不足就是不稳定,相等元素的位置可能会乱。
再到效率天花板的三个排序:堆、快速、归并。这三个是实际开发中用得最多的,尤其是快速排序,名副其实的 "快",越乱的数据排得越快,靠挖坑法分区,搭配三数取中、小区间优化,效率直接拉满,但要注意它在数组有序时会退化,非递归版本还能避免栈溢出。堆排序靠大顶堆筛选最大值,全程 O (nlogn),不占额外空间,适合对空间敏感的场景。归并排序则是唯一稳定的高效排序,靠 "分而治之" 拆分合并,虽然要用到临时数组(空间 O (n)),但稳定性拉满,适合对元素相对位置有要求的场景。
最后再梳理下核心要点:稳定排序就 3 个(冒泡、插入、归并),剩下的 4 个都是不稳定的;空间上,只有归并和快排需要额外空间,其他都是 O (1);效率上,冒泡、选择、插入属于 "慢梯队",希尔是 "中梯队",堆、归并、快排是 "快梯队"。
其实没有最好的排序算法,只有最适合的场景 ------ 小数据用基础排序,中等数据用希尔,大数据、追求效率用快排或堆排,需要稳定就用归并。把这些排序的思路和代码吃透,不管是应付面试,还是实际开发中处理数据,都能轻松拿捏
那么这一期就到这里了,想想数据结构也马上就要更新完了,就差哈希表了,因为我也还在学习之后可能就会更新Mysql数据库和自己刷的题这样的,谢谢大佬支持。之后也请多多指教了。