插入排序
🎴 生活中的例子
想象你在打扑克牌,每次抽一张牌放入手中。当你拿到新牌时,会将它与手中已经排好序的牌进行比较,然后插入到合适的位置。这就是插入排序的基本思想!
📝 工作原理
- 将数组分成"已排序"和"未排序"两部分
- 从未排序区域取出一个元素
- 将该元素与已排序区域的元素从后向前比较
- 找到合适的位置插入
🔄 流程图
flowchart TD
A[开始] --> B[将第一个元素视为已排序]
B --> C[取出下一个元素]
C --> D{是否有下一个元素?}
D -- 是 --> E[与已排序元素比较]
E --> F{是否需要交换?}
F -- 是 --> G[交换位置]
G --> E
F -- 否 --> C
D -- 否 --> H[结束]
💻 Java代码示例
public class InsertionSort {
public static void insertionSort(int[] arr) {
// 从第二个元素开始遍历
for (int i = 1; i < arr.length; i++) {
// 保存当前要插入的元素
int current = arr[i];
// j指向已排序区域的最后一个位置
int j = i - 1;
// 从后向前比较,寻找插入位置
while (j >= 0 && arr[j] > current) {
// 元素后移
arr[j + 1] = arr[j];
j--;
}
// 插入元素
arr[j + 1] = current;
}
}
}
📊 图解过程
假设我们有数组:[5, 2, 4, 6, 1, 3]
- 初始状态:[5 | 2, 4, 6, 1, 3](竖线左边是已排序区域)
- 取2:[2, 5 | 4, 6, 1, 3]
- 取4:[2, 4, 5 | 6, 1, 3]
- 取6:[2, 4, 5, 6 | 1, 3]
- 取1:[1, 2, 4, 5, 6 | 3]
- 取3:[1, 2, 3, 4, 5, 6]
⭐ 特点总结
-
时间复杂度:
- 最好情况:O(n)(已经排好序)
- 最坏情况:O(n²)(完全逆序)
- 平均情况:O(n²)
-
空间复杂度:O(1)
-
稳定性:稳定排序
-
适用场景:
- 小规模数据排序
- 基本有序的数据排序
- 作为其他复杂排序算法的子过程
🎯 优缺点
优点:
- 实现简单
- 对于小规模数据很高效
- 对于基本有序的数据特别高效
- 是稳定排序
- 原地排序算法
缺点:
- 对于大规模数据效率较低
- 平均时间复杂度较高
算法过程详细打印
public class InsertionSort {
public static void insertionSort(int[] arr) {
System.out.println("开始插入排序...\n");
// 从第二个元素开始遍历
for (int i = 1; i < arr.length; i++) {
System.out.printf("\n第 %d 轮插入:\n", i);
System.out.printf("当前数组:%s\n", arrayToString(arr));
System.out.printf("要插入的元素:arr[%d] = %d\n", i, arr[i]);
// 保存当前要插入的元素
int current = arr[i];
// j指向已排序区域的最后一个位置
int j = i - 1;
System.out.println("\n开始寻找插入位置:");
System.out.printf("已排序区域:%s\n",
arrayToString(Arrays.copyOfRange(arr, 0, i)));
// 从后向前比较,寻找插入位置
while (j >= 0 && arr[j] > current) {
System.out.printf("比较:arr[%d]=%d > %d,元素后移\n",
j, arr[j], current);
// 元素后移
arr[j + 1] = arr[j];
System.out.printf("后移后数组:%s\n", arrayToString(arr));
j--;
}
// 插入元素
arr[j + 1] = current;
System.out.printf("\n找到插入位置:%d,插入元素:%d\n", j + 1, current);
System.out.printf("插入后数组:%s\n", arrayToString(arr));
System.out.println("=" .repeat(50));
}
System.out.println("\n排序完成!");
System.out.printf("最终数组:%s\n", arrayToString(arr));
}
private static String arrayToString(int[] arr) {
StringBuilder sb = new StringBuilder("[");
for (int i = 0; i < arr.length; i++) {
sb.append(arr[i]);
if (i < arr.length - 1) {
sb.append(", ");
}
}
sb.append("]");
return sb.toString();
}
public static void main(String[] args) {
int[] arr = {64, 34, 25, 12, 22};
System.out.println("原始数组:" + arrayToString(arr));
System.out.println("=" .repeat(50));
insertionSort(arr);
}
}
运行结果:
原始数组:[64, 34, 25, 12, 22]
==================================================
开始插入排序...
第 1 轮插入:
当前数组:[64, 34, 25, 12, 22]
要插入的元素:arr[1] = 34
开始寻找插入位置:
已排序区域:[64]
比较:arr[0]=64 > 34,元素后移
后移后数组:[64, 64, 25, 12, 22]
找到插入位置:0,插入元素:34
插入后数组:[34, 64, 25, 12, 22]
==================================================
第 2 轮插入:
当前数组:[34, 64, 25, 12, 22]
要插入的元素:arr[2] = 25
开始寻找插入位置:
已排序区域:[34, 64]
比较:arr[1]=64 > 25,元素后移
后移后数组:[34, 64, 64, 12, 22]
比较:arr[0]=34 > 25,元素后移
后移后数组:[34, 34, 64, 12, 22]
找到插入位置:0,插入元素:25
插入后数组:[25, 34, 64, 12, 22]
==================================================
第 3 轮插入:
当前数组:[25, 34, 64, 12, 22]
要插入的元素:arr[3] = 12
开始寻找插入位置:
已排序区域:[25, 34, 64]
比较:arr[2]=64 > 12,元素后移
后移后数组:[25, 34, 64, 64, 22]
比较:arr[1]=34 > 12,元素后移
后移后数组:[25, 34, 34, 64, 22]
比较:arr[0]=25 > 12,元素后移
后移后数组:[25, 25, 34, 64, 22]
找到插入位置:0,插入元素:12
插入后数组:[12, 25, 34, 64, 22]
==================================================
第 4 轮插入:
当前数组:[12, 25, 34, 64, 22]
要插入的元素:arr[4] = 22
开始寻找插入位置:
已排序区域:[12, 25, 34, 64]
比较:arr[3]=64 > 22,元素后移
后移后数组:[12, 25, 34, 64, 64]
比较:arr[2]=34 > 22,元素后移
后移后数组:[12, 25, 34, 34, 64]
比较:arr[1]=25 > 22,元素后移
后移后数组:[12, 25, 25, 34, 64]
找到插入位置:1,插入元素:22
插入后数组:[12, 22, 25, 34, 64]
==================================================
排序完成!
最终数组:[12, 22, 25, 34, 64]
关键点解释:
- 插入排序的核心思想:
像打扑克牌一样,每次拿到一张新牌,
都要把它插入到已经排好序的牌中的正确位置
- 算法步骤:
- 从第二个元素开始,认为第一个元素已经排好序
- 取出当前要插入的元素
- 从后向前扫描已排序区域
- 如果已排序的元素比要插入的大,则后移一位
- 重复上一步,直到找到合适的插入位置
- 将元素插入到正确位置
- 变量含义:
i:当前要插入的元素的位置
current:当前要插入的元素的值
j:已排序区域从后向前扫描的位置
这样的详细输出可以帮助我们更好地理解插入排序的工作原理和过程。
j--的作用
也就是说j-- 的作用有两个 一个等于-1的停止循环 第二个作用就是为了确定要插入数值在已经排序的区域内的位置(还有就是将要插入的数要和所有已排区域内的数据作比较)
数组:[64, 34, 25, 12, 22]
第2轮插入时(i=2):要插入 25
[34, 64 | 25, 12, 22] // 已排序区域 | 未排序区域
↑
j=1 开始
步骤分解:
1) j = 1, arr[1] = 64 > 25
[34, 64, 64, 12, 22] // 64后移
j-- // j变成0
2) j = 0, arr[0] = 34 > 25
[34, 34, 64, 12, 22] // 34后移
j-- // j变成-1
3) j = -1, 循环结束
[25, 34, 64, 12, 22] // 在j+1=0处插入25
让我们写一个更清晰的示例:
public static void insertionSortExample(int[] arr) {
// 以插入25为例
System.out.println("要插入的元素是:25");
System.out.println("已排序区域是:[34, 64]\n");
int current = 25; // 要插入的元素
int j = 1; // 从已排序区域的最后一个位置开始
System.out.println("开始从后向前查找插入位置:");
while (j >= 0 && arr[j] > current) {
System.out.printf("\n当前比较位置j=%d\n", j);
System.out.printf("比较:arr[%d]=%d > %d\n", j, arr[j], current);
System.out.println("需要后移元素");
arr[j + 1] = arr[j]; // 元素后移
System.out.printf("后移后数组:%s\n", Arrays.toString(arr));
j--; // 向前移动,继续比较前一个元素
System.out.printf("j--后,j=%d\n", j);
}
System.out.printf("\n最终插入位置:j+1=%d\n", j+1);
arr[j + 1] = current;
System.out.printf("插入后数组:%s\n", Arrays.toString(arr));
}
运行结果:
要插入的元素是:25
已排序区域是:[34, 64]
开始从后向前查找插入位置:
当前比较位置j=1
比较:arr[1]=64 > 25
需要后移元素
后移后数组:[34, 64, 64, 12, 22]
j--后,j=0
当前比较位置j=0
比较:arr[0]=34 > 25
需要后移元素
后移后数组:[34, 34, 64, 12, 22]
j--后,j=-1
最终插入位置:j+1=0
插入后数组:[25, 34, 64, 12, 22]
j-- 的作用是:
- 不断向前查找合适的插入位置
- 让已排序区域中的较大元素不断后移
- 直到找到第一个不大于要插入元素的位置
可以类比插入扑克牌:
手里的牌(已排序):3, 7
要插入的牌:5
1. 先和7比较(j=1)
7 > 5,7要后移
[3, 7, 7]
j--
2. 再和3比较(j=0)
3 < 5,找到位置了
[3, 5, 7]
所以 j-- 就像是:
- 从右向左
- 一个一个地比较
- 直到找到合适的插入位置
这就是为什么需要 j--,它帮助我们从后向前扫描已排序区域。
arr[j + 1] = current插入操作的解释
让我用一个具体的例子来解释这个插入排序算法的最后一步。
假设我们有一个数组:[5, 2, 4, 6, 1, 3]
让我们看第二个元素 2 是如何被插入的:
- i = 1,current = arr[1] = 2
- j = 0,比较 arr[0] = 5 和 current = 2
- 因为 5 > 2,所以:
- arr[1] = arr[0] // 5 向后移动一位
- 数组变成:[5, 5, 4, 6, 1, 3]
- j 减少 1,变成 -1
- 因为 j < 0,while 循环结束
- 最后执行 arr[j + 1] = current
- 也就是 arr[0] = 2
- 最终数组变成:[2, 5, 4, 6, 1, 3]
为什么是 j + 1?
- 当 while 循环结束时,j 指向的是第一个小于等于 current 的位置的前一个位置
- 所以 current 应该放在 j + 1 的位置
再举个例子,插入 4:
初始:[2, 5, 4, 6, 1, 3]
current = 4
比较 5:5 > 4,5后移
[2, 5, 5, 6, 1, 3]
比较 2:2 < 4,停止
插入位置就是 j + 1 = 1 + 1 = 2
最终:[2, 4, 5, 6, 1, 3]
关键点:
- j + 1 表示找到的插入位置
- j 会一直向前移动,直到找到第一个小于等于 current 的元素
- j + 1 就是 current 应该插入的正确位置
这就像打扑克牌时插入一张牌:
- 从右向左比较已排序的牌
- 比当前牌大的都向右移动一位
- 最后在空出来的位置插入当前的牌
快速排序
🎯 快速排序的基本思想
快速排序就像在图书馆整理书籍一样。假设我们要按照书的高度排序:
- 首先,我们随机选择一本书作为"基准书"(pivot)
- 然后把其他书分成两堆:
- 左边:比基准书矮的
- 右边:比基准书高的
- 对这两堆书重复同样的操作,直到所有的书都排好序
📝 具体步骤流程图
flowchart TD
A[开始] --> B[选择基准元素pivot]
B --> C[将小于pivot的元素放在左边]
B --> D[将大于pivot的元素放在右边]
C --> E[递归处理左子数组]
D --> F[递归处理右子数组]
E --> G[合并结果]
F --> G
G --> H[结束]
💻 Java代码实现
public class QuickSort {
public static void quickSort(int[] arr, int left, int right) {
if (left < right) {
// 获取基准点
int pivot = partition(arr, left, right);
// 递归处理左边
quickSort(arr, left, pivot - 1);
// 递归处理右边
quickSort(arr, pivot + 1, right);
}
}
private static int partition(int[] arr, int left, int right) {
// 选择最右边的元素作为基准
int pivot = arr[right];
int i = left - 1;
// 把小于pivot的元素都放到左边
for (int j = left; j < right; j++) {
if (arr[j] <= pivot) {
i++;
swap(arr, i, j);
}
}
// 把pivot放到中间
swap(arr, i + 1, right);
return i + 1;
}
private static void swap(int[] arr, int i, int j) {
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
}
代码分析
(1) 为什么 i = -1?
-
i 的作用
- i 表示"小于基准值区域"的边界
- i 的左边(包括i)都是比基准值小的数
- i+1 位置就是下一个比基准值小的数应该放的位置
-
为什么从 -1 开始?
- 因为一开始我们还没有找到任何小于基准值的数
- i = -1 相当于说"小于基准值的区域"目前是空的
- 当找到第一个小于基准值的数时,i 会加 1 变成 0,正好放在数组的开始位置
📝 举例说明
假设数组:[6, 1, 2, 7, 9, 3],基准值是 3
初始状态:
[6, 1, 2, 7, 9, 3]
↑
j
i = -1 (表示还没有比3小的数)
找到第一个比3小的数(1):
[6, 1, 2, 7, 9, 3]
↑
j
i先加1变成0,然后交换i和j位置的数
变成:[1, 6, 2, 7, 9, 3]
↑
i=0
🎨 如果 i 从 0 开始会怎样?
如果 i 从 0 开始:
初始状态:
[6, 1, 2, 7, 9, 3]
↑
i和j
这样的话,即使6比3大,也会被算入"小于基准值的区域",
这显然是错误的!
🌟 生活中的例子
想象你在分类整理玩具:
- 你面前有一排玩具,要把小玩具放左边
- 一开始,你还没有找到任何小玩具
- 你的左手食指指向第一个玩具的左边(-1位置)
- 当找到小玩具时,你的食指才向右移动一格,并把小玩具放在那里
📌 总结
- i = -1 是一个初始状态,表示"还没有找到任何符合条件的元素"
- 这样设计可以确保第一个比基准值小的数正好被放到数组的开始位置
- 这是一个很巧妙的设计,避免了额外的边界条件判断
所以,i = -1 不是一个随意的选择,而是算法设计中经过深思熟虑的起始位置!
(2)partition 方法详解
好的,让我用更通俗的方式解释这个 partition 方法。
🎯 partition 方法的目的
把一堆数字分成三部分:
- 左边:比基准值小的数
- 中间:基准值
- 右边:比基准值大的数
🎲 举个生动的例子
假设我们有一排小朋友站成一排,要按身高排序:[6, 1, 2, 7, 9, 3]
我们选择最后一个小朋友(身高3)作为基准。
📝 具体步骤
- 初始设置
[6, 1, 2, 7, 9, 3] ← 3是基准值
↑
j从这里开始移动
i在最左边的前一位(-1位置)
- 开始遍历(j 向右移动)
第1步:j指向6
[6, 1, 2, 7, 9, 3]
j
i=-1
6比3大,不动
第2步:j指向1
[6, 1, 2, 7, 9, 3]
j
i=-1
1比3小,i前进一步,交换i和j的位置
变成:[1, 6, 2, 7, 9, 3]
i=0
第3步:j指向2
[1, 6, 2, 7, 9, 3]
j
i=0
2比3小,i前进一步,交换i和j的位置
变成:[1, 2, 6, 7, 9, 3]
i=1
第4步:j指向7
[1, 2, 6, 7, 9, 3]
j
i=1
7比3大,不动
第5步:j指向9
[1, 2, 6, 7, 9, 3]
j
i=1
9比3大,不动
- 最后一步:放置基准值
把基准值3放到i+1的位置
[1, 2, 3, 7, 9, 6]
↑
i+1位置
🎯 关键变量解释
i:就像是一个分界线,i的左边都是比基准值小的数j:像一个探索者,一直往右走,寻找比基准值小的数pivot:基准值,用来做比较的标准
🌟 生活中的例子
想象你在整理一堆扑克牌:
- 抽出最后一张牌作为参照(pivot)
- 用左手指着一个位置(i)
- 右手翻牌(j),遇到比参照牌小的,就放到左手指着的位置后面
- 最后把参照牌放到中间
就这样,通过一次遍历,我们就把所有数据分成了三部分:
- 左边都是比基准值小的
- 中间是基准值
- 右边都是比基准值大的
这就是 partition 方法的核心思想!
🌟 举个生动的例子
假设我们有一组数字:[6, 1, 2, 7, 9, 3, 4, 5, 8]
-
第一轮:
- 选择 5 作为基准
- 分区后:
[1, 2, 3, 4, 5, 9, 7, 6, 8]
-
左边部分
[1, 2, 3, 4]:- 选择 4 作为基准
- 分区后:
[1, 2, 3, 4]
-
右边部分
[9, 7, 6, 8]:- 选择 8 作为基准
- 分区后:
[6, 7, 8, 9]
⚡ 性能特点
- 平均时间复杂度:O(nlogn)
- 最坏时间复杂度:O(n²)
- 空间复杂度:O(logn)
- 不稳定排序
📌 优缺点总结
优点:
- 速度快(平均情况)
- 原地排序,不需要额外空间
- 适合大规模数据排序
缺点:
- 在最坏情况下性能下降
- 对于小规模数据,可能不如插入排序
- 不稳定排序
🎯 使用场景
- 大规模数据排序
- 内部排序
- 需要较好平均性能的场景
这就是快速排序的核心思想和实现方式。它之所以"快",是因为通过基准元素的选择,每次都能将问题规模缩小,并且可以原地完成排序。就像整理书架时,我们总是会选择一个参照物,然后快速地将书分类放置一样!
补充快速排序的递归推导流程
让我用一个完整的例子,一步步演示快速排序的递归过程。
🌟 假设要排序数组:[6, 1, 2, 7, 9, 3]
第一层递归 (整个数组)
原始数组:[6, 1, 2, 7, 9, 3]
left=0, right=5
1. partition过程:
基准值pivot = 3
结果:[1, 2, 3, 7, 9, 6]
↑ ↑ ↑
left pivot right
2. 基准值3已经在正确位置(索引2),数组被分成两部分:
左边:[1, 2] (需要递归处理)
右边:[7, 9, 6] (需要递归处理)
第二层递归 (处理左边[1, 2])
处理左边:[1, 2]
left=0, right=1
1. partition过程:
基准值pivot = 2
结果:[1, 2]
2. 因为left >= right,左边部分排序完成
第二层递归 (处理右边[7, 9, 6])
处理右边:[7, 9, 6]
left=3, right=5
1. partition过程:
基准值pivot = 6
结果:[6, 9, 7]
↑ ↑
left right
2. 基准值6放到正确位置后,继续分成两部分:
左边:[] (空,不需要处理)
右边:[9, 7] (需要递归处理)
第三层递归 (处理[9, 7])
处理:[9, 7]
left=4, right=5
1. partition过程:
基准值pivot = 7
结果:[7, 9]
2. 递归结束,因为子数组都只有一个元素了
🎨 整个过程的树形图
[6,1,2,7,9,3]
↓
[1,2,3] | 3 | [7,9,6]
↓ ↓
[1,2] | 2 [6,7,9]
↓ ↓
[1] | 1 [7,9]
↓
[7] | [9]
📝 关键点说明
-
递归的触发条件
- 当 left < right 时继续递归
- 每次递归都会把数组分成更小的部分
-
递归的结束条件
- 当 left >= right 时
- 也就是当子数组长度为1或0时
-
每一层递归都会
- 选择一个基准值
- 进行partition操作
- 将数组分成两部分
- 对两部分分别递归
🌟 生活中的例子
想象你在整理一摞扑克牌:
- 第一次,你选一张牌把整摞牌分成两堆
- 然后对每一堆重复同样的操作
- 当一堆里只剩1张牌时,就不用再分了
- 最后所有的牌都会排好序
📌 总结
- 快速排序的核心就是"分而治之"
- 每次partition都会确定一个元素的最终位置
- 通过递归,逐步把大问题变成小问题解决
- 当所有的小问题都解决了,整个数组就排好序了
这就像是把一个大的整理任务,分解成很多个小任务,每完成一个小任务,就离最终目标更近一步!
算法过程详细打印
public class QuickSort {
public static void quickSort(int[] arr, int left, int right) {
if (left < right) {
System.out.printf("\n开始处理区间 [%d, %d]:\n", left, right);
System.out.printf("当前数组:%s\n", arrayToString(arr));
System.out.printf("当前区间:%s\n",
arrayToString(Arrays.copyOfRange(arr, left, right + 1)));
// 获取基准点
int pivot = partition(arr, left, right);
System.out.printf("基准值 %d 已放置到位置 %d\n", arr[pivot], pivot);
System.out.printf("分区后数组:%s\n", arrayToString(arr));
System.out.println("=" .repeat(50));
// 递归处理左边
System.out.printf("\n处理左半部分 [%d, %d]:\n", left, pivot - 1);
quickSort(arr, left, pivot - 1);
// 递归处理右边
System.out.printf("\n处理右半部分 [%d, %d]:\n", pivot + 1, right);
quickSort(arr, pivot + 1, right);
}
}
private static int partition(int[] arr, int left, int right) {
// 选择最右边的元素作为基准
int pivot = arr[right];
System.out.printf("\n选择基准值:arr[%d] = %d\n", right, pivot);
int i = left - 1;
System.out.printf("初始化 i = %d\n", i);
// 把小于pivot的元素都放到左边
for (int j = left; j < right; j++) {
System.out.printf("\n当前 j = %d, arr[j] = %d\n", j, arr[j]);
System.out.printf("比较:arr[%d]=%d <= %d(基准值): ", j, arr[j], pivot);
if (arr[j] <= pivot) {
i++;
System.out.printf("true,i++变成%d,交换arr[%d]和arr[%d]\n", i, i, j);
swap(arr, i, j);
System.out.printf("交换后数组:%s\n", arrayToString(arr));
} else {
System.out.println("false,不需要交换");
}
}
// 把pivot放到中间
System.out.printf("\n将基准值放到正确位置,交换arr[%d]和arr[%d]\n", i + 1, right);
swap(arr, i + 1, right);
System.out.printf("最终分区结果:%s\n", arrayToString(arr));
return i + 1;
}
private static void swap(int[] arr, int i, int j) {
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
private static String arrayToString(int[] arr) {
StringBuilder sb = new StringBuilder("[");
for (int i = 0; i < arr.length; i++) {
sb.append(arr[i]);
if (i < arr.length - 1) {
sb.append(", ");
}
}
sb.append("]");
return sb.toString();
}
public static void main(String[] args) {
int[] arr = {64, 34, 25, 12, 22};
System.out.println("原始数组:" + arrayToString(arr));
System.out.println("=" .repeat(50));
quickSort(arr, 0, arr.length - 1);
System.out.println("\n最终排序结果:" + arrayToString(arr));
}
}
运行结果:
原始数组:[64, 34, 25, 12, 22]
==================================================
开始处理区间 [0, 4]:
当前数组:[64, 34, 25, 12, 22]
当前区间:[64, 34, 25, 12, 22]
选择基准值:arr[4] = 22
初始化 i = -1
当前 j = 0, arr[j] = 64
比较:arr[0]=64 <= 22(基准值): false,不需要交换
当前 j = 1, arr[j] = 34
比较:arr[1]=34 <= 22(基准值): false,不需要交换
当前 j = 2, arr[j] = 25
比较:arr[2]=25 <= 22(基准值): false,不需要交换
当前 j = 3, arr[j] = 12
比较:arr[3]=12 <= 22(基准值): true,i++变成0,交换arr[0]和arr[3]
交换后数组:[12, 34, 25, 64, 22]
将基准值放到正确位置,交换arr[1]和arr[4]
最终分区结果:[12, 22, 25, 64, 34]
基准值 22 已放置到位置 1
分区后数组:[12, 22, 25, 64, 34]
==================================================
处理左半部分 [0, 0]:
处理右半部分 [2, 4]:
开始处理区间 [2, 4]:
当前数组:[12, 22, 25, 64, 34]
当前区间:[25, 64, 34]
选择基准值:arr[4] = 34
初始化 i = 1
当前 j = 2, arr[j] = 25
比较:arr[2]=25 <= 34(基准值): true,i++变成2,交换arr[2]和arr[2]
交换后数组:[12, 22, 25, 64, 34]
当前 j = 3, arr[j] = 64
比较:arr[3]=64 <= 34(基准值): false,不需要交换
将基准值放到正确位置,交换arr[3]和arr[4]
最终分区结果:[12, 22, 25, 34, 64]
基准值 34 已放置到位置 3
分区后数组:[12, 22, 25, 34, 64]
==================================================
处理左半部分 [2, 2]:
处理右半部分 [4, 4]:
最终排序结果:[12, 22, 25, 34, 64]
快速排序的关键点:
- 基准值(pivot)的选择:
- 这里选择最右边的元素作为基准
- 目标是将数组分成两部分:小于等于基准值和大于基准值
- 分区过程(partition):
i:指向小于等于pivot的区域的边界
j:遍历数组,寻找小于等于pivot的元素
- 递归处理:
- 对基准值左边的部分递归排序
- 对基准值右边的部分递归排序
- 终止条件:
left >= right:表示区间长度小于等于1,不需要再排序
这样的详细输出可以帮助我们更好地理解快速排序的工作原理和过程。
int i = left - 1条件的意义
就是这个i就是代表了两个意义 第一个意义就是发现小于pivot的个数 第二个意义就是发现第一个小于pivot的就是从下标0开始的 所以他要从-1开始
让我再用图解来强化这两个关键意义:
数组:[64, 34, 25, 12, 22] pivot = 22
1. i 代表小于pivot的个数:
i = -1 表示初始时有0个小于pivot的数
i = 0 表示找到1个小于pivot的数
i = 1 表示找到2个小于pivot的数
...
2. i+1 表示下一个小于pivot的数应该放的位置:
i = -1 → 第一个小于pivot的数放在位置0
i = 0 → 第二个小于pivot的数放在位置1
i = 1 → 第三个小于pivot的数放在位置2
...
具体过程示例:
原数组:[64, 34, 25, 12, 22] pivot = 22
第1次:i = -1
[64, 34, 25, 12, 22] // 64 > 22
↑ // i不变,表示还没有找到小于pivot的数
j
第2次:i = -1
[64, 34, 25, 12, 22] // 34 > 22
↑ // i不变,仍然没有找到小于pivot的数
j
第3次:i = -1
[64, 34, 25, 12, 22] // 25 > 22
↑ // i不变,仍然没有找到小于pivot的数
j
第4次:i = -1 → 0
[64, 34, 25, 12, 22] // 12 < 22
↑ // i++,找到第一个小于pivot的数
j // 交换i和j位置的数
交换后:[12, 34, 25, 64, 22]
总结 i 的两个核心作用:
1. 计数作用:
i + 1 = 小于等于pivot的元素个数
2. 位置指示作用:
i + 1 = 下一个小于等于pivot的元素应该放的位置
为什么要从 -1 开始:
1. 逻辑上的需要:
- 初始时没有任何小于pivot的数
- 第一个小于pivot的数应该放在位置0
2. 技术上的实现:
- i = left - 1 使得 i++ 后正好是数组起始位置
- 保证了第一个小于pivot的数能正确放置
生活类比:
想象你在分类糖果:
- 左边放便宜的糖果(小于10元)
- 右边放贵的糖果(大于等于10元)
- i 就像是分界线
最开始:
i = -1(分界线在最左边之前)
[未分类的糖果....]
找到便宜糖果时:
1. i++(分界线向右移)
2. 把糖果放到新的分界线位置
这种设计的优点:
- 简洁:一个变量同时实现两个功能
- 高效:一次遍历完成分区
- 直观:i的位置自然形成分界线
- 优雅:不需要额外的计数器
数组:[64, 34, 25, 12, 22] 基准值pivot = 22(最右边的数)
初始:i = -1 (left - 1 = 0 - 1)
i 的作用:维护"小于等于基准值的区域"的边界
─────────────────────────────────────
第1步:j = 0, arr[0] = 64
[64, 34, 25, 12, 22]
↑
j
i = -1 // 64 > 22,不动
第2步:j = 1, arr[1] = 34
[64, 34, 25, 12, 22]
↑
j
i = -1 // 34 > 22,不动
第3步:j = 2, arr[2] = 25
[64, 34, 25, 12, 22]
↑
j
i = -1 // 25 > 22,不动
第4步:j = 3, arr[3] = 12
[64, 34, 25, 12, 22]
↑
j
i++ (变成0) // 12 < 22,i++后交换i和j位置的元素
交换后:[12, 34, 25, 64, 22]
↑
i
图解说明:
i 的位置标记了"小于等于基准值的区域"的边界:
[小于等于pivot的区域 | 大于pivot的区域]
↑
i
初始时 i = left - 1 表示:
- 一开始还没有任何元素被处理
- 小于等于基准值的区域为空
- i 指向这个空区域的前一个位置
再举个生活中的例子:
想象你在分类整理书籍:
- 左边放小于等于500页的书
- 右边放大于500页的书
- i 就像是一个分界线
最开始:
[还未分类的书籍....]
↑
i(在最左边之前,表示还没有分类任何书)
当找到一本小于等于500页的书时:
1. i 向右移动一步(i++)
2. 把这本书放到 i 的位置
所以 i = left - 1 的意义是:
- 初始化一个空的"小于等于基准值的区域"
- i 指向这个区域的边界
- 当找到小于等于基准值的元素时:
- i 向右扩展一位(i++)
- 将该元素交换到边界处
完整的分区过程:
原始数组:[64, 34, 25, 12, 22] pivot = 22
1. 初始状态:
i = -1
[64, 34, 25, 12 | 22]
↑
j
2. j=0, arr[0]=64 > pivot
i = -1 // 不变
[64, 34, 25, 12 | 22]
↑
j
3. j=3, arr[3]=12 < pivot
i++ (变成0)
交换 arr[0] 和 arr[3]
[12, 34, 25, 64 | 22]
↑
j
4. 最后把基准值放到正确位置:
交换 arr[i+1] 和 arr[right]
[12, 22, 25, 64, 34]
↑
pivot的最终位置
这样设计的好处:
- 自然地形成了分区
- 保证了基准值的正确位置
- 一次遍历就完成了分区操作
- 代码简洁高效
partition方法中 return i + 1;的意义
让我详细解释 return i + 1 的意义:
原数组:[64, 34, 25, 12, 22] pivot = 22
分区过程结束时:
[12 | 22 | 25, 64, 34]
↑ ↑
i i+1
pivot的位置
return i + 1 表示:
1. 返回基准值(pivot)的最终位置
2. 这个位置将数组分成两部分:
- 左边:小于等于pivot的元素 [0 到 i]
- 右边:大于pivot的元素 [i+2 到 right]
为什么要返回这个位置?因为快速排序需要知道:
public static void quickSort(int[] arr, int left, int right) {
if (left < right) {
// 获取基准点位置
int pivot = partition(arr, left, right);
// 递归排序左半部分:[left, pivot-1]
quickSort(arr, left, pivot - 1);
// 递归排序右半部分:[pivot+1, right]
quickSort(arr, pivot + 1, right);
}
}
完整示例:
第一次分区:
原始:[64, 34, 25, 12, 22]
过程:
1. i = -1
2. 找到 12 < 22
i++ = 0
[12, 34, 25, 64, 22]
3. 最后交换 pivot
[12, 22, 25, 64, 34]
↑
return 1 (i+1)
递归处理:
左边:[12] // quickSort(arr, 0, 0)
右边:[25, 64, 34] // quickSort(arr, 2, 4)
图解说明:
第一次分区后:
[12 | 22 | 25, 64, 34]
↑
return 1
这个返回值告诉quickSort:
1. 位置1的元素(22)已经在最终位置
2. 左边[0,0]需要继续排序
3. 右边[2,4]需要继续排序
生活类比:
想象你在给书按页数排序:
1. 选择500页作为基准
2. 分类完成后:
[小于500页的书 | 500页的书 | 大于500页的书]
↑
返回这个位置
这个位置很重要,因为:
1. 它左边的书都需要继续排序
2. 它右边的书都需要继续排序
3. 它自己已经在正确的位置上
所以 return i + 1 的作用是:
- 返回基准值的最终位置
- 帮助快速排序确定下一步的递归范围
- 保证基准值左右两边可以独立排序
这就是为什么这个返回值对快速排序算法如此重要:
- 它确保了分治的正确性
- 它帮助确定递归的边界
- 它保证了算法的完整性