排序上(冒泡/选择/插入)
4.1.4 归并排序(Merge Sort)
基本概念
归并排序是一种高效的排序算法,建立在归并操作上。该算法采用分治法(Divide and Conquer)的思想,将问题分解为更小的子问题,解决子问题后再将结果合并。归并排序的核心思想是:先递归地将数组分成两半分别排序,然后将排好序的两半合并成一个有序数组。
生活中的例子
想象你和朋友在整理一大堆扑克牌:
- 你们将牌堆平均分成两半
- 每人负责整理一半
- 如果牌太多,你们可以继续将自己的那一半再分给其他朋友
- 当每个人手中只有少量牌时,各自将牌排好序
- 然后两两合并已排序的牌堆,直到所有牌都合并成一个有序的牌堆
这就像是公司的组织结构,大任务分解给部门,部门再分解给小组,最后汇总结果。
算法步骤
- 分解:将待排序数组分成两个子数组,分别包含前半部分和后半部分。
- 递归排序:递归地对两个子数组进行归并排序。
- 合并:将两个已排序的子数组合并成一个有序数组。
图解过程
假设我们有数组:[38, 27, 43, 3, 9, 82, 10]
分解过程:
[38, 27, 43, 3, 9, 82, 10]
/\
/ \
[38, 27, 43, 3] [9, 82, 10]
/\ /\
/ \ / \
[38, 27] [43, 3] [9] [82, 10]
/\ /\ /\
/ \ / \ / \
[38] [27] [43] [3] [9] [82] [10]
合并过程:
[38] [27] [43] [3] [9] [82] [10]
\ / \ / \ / /
[27, 38] [3, 43] [9, 82] [10]
\ / \ /
[3, 27, 38, 43] [9, 10, 82]
\ /
[3, 9, 10, 27, 38, 43, 82]
代码实现
java
public static void mergeSort(int[] arr, int left, int right) {
// 基本情况:如果左索引小于右索引(至少有两个元素)
if (left < right) {
// 找出中间点
int mid = left + (right - left) / 2;
// 递归排序左半部分
mergeSort(arr, left, mid);
// 递归排序右半部分
mergeSort(arr, mid + 1, right);
// 合并已排序的两半
merge(arr, left, mid, right);
}
}
private static void merge(int[] arr, int left, int mid, int right) {
// 计算两个子数组的大小
int n1 = mid - left + 1;
int n2 = right - mid;
// 创建临时数组
int[] L = new int[n1];
int[] R = new int[n2];
// 将数据复制到临时数组
for (int i = 0; i < n1; i++) {
L[i] = arr[left + i];
}
for (int j = 0; j < n2; j++) {
R[j] = arr[mid + 1 + j];
}
// 合并临时数组
int i = 0, j = 0; // 初始化两个子数组的索引
int k = left; // 初始化合并后数组的索引
// 比较两个子数组的元素并合并
while (i < n1 && j < n2) {
if (L[i] <= R[j]) {
arr[k] = L[i];
i++;
} else {
arr[k] = R[j];
j++;
}
k++;
}
// 复制L[]的剩余元素(如果有)
while (i < n1) {
arr[k] = L[i];
i++;
k++;
}
// 复制R[]的剩余元素(如果有)
while (j < n2) {
arr[k] = R[j];
j++;
k++;
}
}
性能分析
-
时间复杂度:
- 最坏情况:O(n log n)
- 最好情况:O(n log n)
- 平均情况:O(n log n)
-
空间复杂度:O(n) - 需要额外的空间来存储临时数组
-
稳定性:稳定 - 相等元素的相对位置不会改变
适用场景
- 需要稳定排序的场景
- 对时间复杂度要求为O(n log n)的场景
- 数据量较大的排序(相比O(n²)的算法)
- 外部排序(当数据太大无法全部加载到内存中时)
归并排序的优缺点
优点:
- 时间复杂度稳定,始终是O(n log n)
- 稳定排序
- 适合处理大数据量
- 可以轻松实现外部排序
缺点:
- 需要额外的O(n)空间
- 对于小数据集,可能不如插入排序等简单算法
- 递归调用会带来额外的函数调用开销
4.1.5 快速排序(Quick Sort)
基本概念
快速排序是一种高效的排序算法,也是实际应用中最常用的排序算法之一。它采用分治法的思想,通过选择一个"基准"元素,将数组分为两个子数组,一个子数组的所有元素都小于基准,另一个子数组的所有元素都大于基准,然后递归地对这两个子数组进行排序。
生活中的例子
想象你是一位老师,需要按照学生的身高排队:
- 你选择一名学生作为参考(基准)
- 让所有比这名学生矮的站到左边,比这名学生高的站到右边
- 现在这名参考学生已经站在了正确的位置
- 然后你分别对左边和右边的学生重复这个过程
- 最终所有学生都会按照身高顺序排好队
这就像是在图书馆整理书籍时,先按照书的大类分类,再在每个大类中进一步细分。
算法步骤
- 选择基准:从数组中选择一个元素作为"基准"(pivot)。
- 分区操作:重新排列数组,使所有比基准值小的元素都在基准的左边,所有比基准值大的元素都在基准的右边。
- 递归排序:递归地对基准左边和右边的子数组应用前两步。
图解过程
假设我们有数组:[10, 80, 30, 90, 40, 50, 70]
第一次分区:
- 选择70作为基准(最后一个元素)
- 分区后:[10, 30, 40, 50, 70, 90, 80]
- 70现在在正确的位置上
递归排序左子数组:[10, 30, 40, 50]
- 选择50作为基准
- 分区后:[10, 30, 40, 50]
- 50现在在正确的位置上
递归排序左子数组的左子数组:[10, 30, 40]
- 选择40作为基准
- 分区后:[10, 30, 40]
- 40现在在正确的位置上
递归排序左子数组的左子数组的左子数组:[10, 30]
- 选择30作为基准
- 分区后:[10, 30]
- 30现在在正确的位置上
递归排序右子数组:[90, 80]
- 选择80作为基准
- 分区后:[80, 90]
- 80现在在正确的位置上
最终排序结果:[10, 30, 40, 50, 70, 80, 90]
代码实现
java
public static void quickSort(int[] arr, int low, int high) {
// 基本情况:如果low >= high,说明数组已经排序完成
if (low < high) {
// 找到基准位置,使得基准左边的元素都小于基准,右边的元素都大于基准
int pivotIndex = partition(arr, low, high);
// 递归排序基准左边的子数组
quickSort(arr, low, pivotIndex - 1);
// 递归排序基准右边的子数组
quickSort(arr, pivotIndex + 1, high);
}
}
private static int partition(int[] arr, int low, int high) {
// 选择最右边的元素作为基准
int pivot = arr[high];
// i是小于基准的元素的最后一个索引
int i = low - 1;
// 遍历从low到high-1的元素
for (int j = low; j < high; j++) {
// 如果当前元素小于等于基准
if (arr[j] <= pivot) {
// 将i向右移动一位
i++;
// 交换arr[i]和arr[j]
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
}
// 将基准放到正确的位置上(i+1)
int temp = arr[i + 1];
arr[i + 1] = arr[high];
arr[high] = temp;
// 返回基准的索引
return i + 1;
}
性能分析
-
时间复杂度:
- 最坏情况:O(n²) - 当数组已经排序或逆序时(可以通过随机选择基准来改善)
- 最好情况:O(n log n) - 当每次分区都将数组平均分成两半时
- 平均情况:O(n log n)
-
空间复杂度:O(log n) - 递归调用栈的深度
-
稳定性:不稳定 - 相等元素的相对位置可能会改变
适用场景
- 大多数实际应用场景(平均性能优秀)
- 内部排序(数据完全加载到内存中)
- 对时间复杂度要求为O(n log n)的场景
- 不要求排序稳定性的场景
快速排序的优化
-
随机选择基准:避免最坏情况
javaprivate static int randomPartition(int[] arr, int low, int high) { int randomIndex = low + new Random().nextInt(high - low + 1); swap(arr, randomIndex, high); return partition(arr, low, high); }
-
三数取中法:选择左端、右端和中间位置三个元素的中间值作为基准
-
当子数组规模较小时使用插入排序:对于小规模数组,插入排序可能更高效
-
尾递归优化:减少递归调用栈的深度
快速排序与归并排序的比较
- 快速排序通常比归并排序快,因为它的常数因子更小
- 快速排序是原地排序(不需要额外的数组空间),而归并排序需要O(n)的额外空间
- 快速排序不稳定,归并排序稳定
- 快速排序在最坏情况下性能较差,归并排序始终保持O(n log n)的时间复杂度
4.1.6 堆排序(Heap Sort)
基本概念
堆排序是一种基于比较的排序算法,它利用堆这种特殊的数据结构进行排序。堆是一个近似完全二叉树的结构,并同时满足堆的性质:每个节点的值都大于或等于(或小于或等于)其子节点的值。
- 大顶堆:每个节点的值都大于或等于其子节点的值,堆顶元素是最大值
- 小顶堆:每个节点的值都小于或等于其子节点的值,堆顶元素是最小值
堆排序通常使用大顶堆来实现升序排序。
生活中的例子
想象一个公司的组织结构:
- CEO在最顶层,是公司权力最大的人
- 中层管理者的权力小于CEO,但大于普通员工
- 普通员工在最底层,权力最小
如果我们按照权力大小排序,可以:
- 先将所有人按照权力大小组织成一个金字塔结构(大顶堆)
- 然后每次取出权力最大的人(堆顶),放到排序结果的最后
- 重新调整剩余人员的结构,继续取出权力最大的人
- 重复这个过程,直到所有人都按权力从小到大排好序
算法步骤
- 构建初始堆:将无序数组构建成一个大顶堆。
- 交换并调整 :
- 将堆顶元素(最大值)与末尾元素交换
- 将剩余n-1个元素重新构建成大顶堆
- 重复步骤2,直到整个数组排序完成。
图解过程
假设我们有数组:[4, 10, 3, 5, 1]
步骤1:构建初始大顶堆
首先,我们将数组看作一个完全二叉树:
4
/ \
10 3
/ \
5 1
从最后一个非叶子节点开始,自下而上进行堆化:
- 调整节点5(已经是叶子节点,不需要调整)
- 调整节点10(比子节点5和1都大,不需要调整)
- 调整节点3(已经是叶子节点,不需要调整)
- 调整节点4(比子节点10小,交换4和10)
交换后:
10
/ \
4 3
/ \
5 1
再次调整节点4:
10
/ \
5 3
/ \
4 1
步骤2:交换并调整
-
交换堆顶元素10和末尾元素1:[5, 4, 3, 1, 10]
-
对剩余元素[5, 4, 3, 1]重新堆化:
5 / \ 4 3 / 1
-
交换堆顶元素5和末尾元素1:[4, 1, 3, 5, 10]
-
对剩余元素[4, 1, 3]重新堆化:
4 / \ 1 3
-
交换堆顶元素4和末尾元素3:[3, 1, 4, 5, 10]
-
对剩余元素[3, 1]重新堆化:
3 / 1
-
交换堆顶元素3和末尾元素1:[1, 3, 4, 5, 10]
排序完成!
代码实现
java
public static void heapSort(int[] arr) {
int n = arr.length;
// 步骤1:构建初始大顶堆
// 从最后一个非叶子节点开始,自下而上进行堆化
for (int i = n / 2 - 1; i >= 0; i--) {
heapify(arr, n, i);
}
// 步骤2:一个个从堆顶取出元素
for (int i = n - 1; i > 0; i--) {
// 将堆顶元素(当前最大值)与末尾元素交换
int temp = arr[0];
arr[0] = arr[i];
arr[i] = temp;
// 对剩余元素重新调整堆结构
heapify(arr, i, 0);
}
}
// 堆化过程,调整以i为根的子树
private static void heapify(int[] arr, int n, int i) {
int largest = i; // 初始化最大元素为根节点
int left = 2 * i + 1; // 左子节点索引
int right = 2 * i + 2; // 右子节点索引
// 如果左子节点存在且大于当前最大值
if (left < n && arr[left] > arr[largest]) {
largest = left;
}
// 如果右子节点存在且大于当前最大值
if (right < n && arr[right] > arr[largest]) {
largest = right;
}
// 如果最大值不是根节点,则交换并继续堆化
if (largest != i) {
// 交换arr[i]和arr[largest]
int swap = arr[i];
arr[i] = arr[largest];
arr[largest] = swap;
// 递归地堆化受影响的子树
heapify(arr, n, largest);
}
}
性能分析
-
时间复杂度:
- 建堆操作:O(n)
- 调整操作:O(n log n)
- 总体时间复杂度:O(n log n)
-
空间复杂度:O(1) - 原地排序,不需要额外空间
-
稳定性:不稳定 - 相等元素的相对位置可能会改变
适用场景
- 需要找出数组中最大或最小的几个元素时
- 需要一个稳定的O(n log n)时间复杂度的排序算法
- 内存空间有限,需要原地排序的场景
- 实现优先队列时
堆排序的优缺点
优点:
- 时间复杂度稳定,始终是O(n log n)
- 空间复杂度低,是原地排序
- 适合处理大数据量
缺点:
- 不稳定排序
- 在实际应用中,常数因子较大,性能可能不如快速排序
- 对于近乎有序的数据,表现不如插入排序