Hoare 的两种写法
要不要我帮你把这段代码改写成可以直接运行的完整程序,方便你测试和验证?
“Hoare分区方案”的一个变种。
void quickSort(int a[], int l, int r)
{
if (l < r)
{
int i = l, j = r;
int x = a[(l + r) / 2]; // 选择基准值
while (i < j) // 没碰面
{
while (a[i] < x) ++i; // 左哨兵往右走
while (a[j] > x) --j; // 右哨兵往左走
// 如果i小于等于j,此时是小j的太小,小j的太小,要交换
// 交换完继续走,i走到右边,j走到左边,确保没落下的
if (i <= j) swap(a[i++], a[j--]);
}
quickSort(a, l, j); // j是左边最后一个
quickSort(a, i, r); // i是右边第一个
}
}
为了更清晰地理解它的定位,以下是详细的说明:
1. 核心分区方案:Hoare 分区法的变种
图中代码的核心思想与 Tony Hoare 爵士在1960年提出的原始分区方案 同源,主要特征是:
- 双指针相向扫描:使用
i和j两个指针分别从数组两端向中间移动。 - 基准值的选择:选择数组中间位置的元素作为基准值(
x = a[(l+r)/2])。Hoare的原始方案通常选择第一个元素作为基准,但选择中间元素是常见的、用于优化已排序数组的变种。 - 交换逻辑:当左指针找到不小于基准的元素,且右指针找到不大于基准的元素时,交换它们的位置。
所以,最准确的归类是:这是基于Hoare分区思想、并采用中间值作为基准的快速排序实现。
手写代码与 Hoare 分区方案 的核心算法逻辑一致(双指针从两端向中间扫描交换),但在实现结构和细节上存在差异。
一、 关键区别对比
| 特征 | 手写代码 | 标准 Hoare 分区(教科书版) |
|---|---|---|
| 指针初始化 | i = l, j = r(指向两端) |
i = l-1, j = r+1(指向两端外侧) |
| 循环结构 | while (i < j) 外层包裹 |
while (true) 死循环,内部判断返回 |
| 指针移动时机 | 先扫描定位(while a[i]<x),交换后移动(i++, j--) |
先移动(do i++),再比较,最后交换 |
| 终止条件 | 当 i >= j 时外层循环结束 |
当 i >= j 时 return j |
| Bug 风险 | swap(a[++i], a[j--]) 可能因参数求值顺序导致未定义行为 |
通常使用 swap(a[i], a[j]) 后单独移动指针 |
二、 流程差异图解
假设数组 [5, 4, 8, 2, 3],pivot = 8(中间元素)
手写代码执行流程
初始化: i=0(5), j=4(3), pivot=8
第1轮:
i右移直到≥8 → 停在索引2(8)
j左移直到≤8 → 停在索引4(3)
i(2) <= j(4) 成立,交换 a[2]↔a[4] → [5,4,3,2,8]
i++, j-- → i=3, j=3
第2轮:
i右移直到≥8 → 停在索引4(8)(但i<j即3<3不成立,实际上这轮不会执行...)
【注意】此时 while(i<j) 即 3<3 不成立,退出
标准 Hoare 执行流程
初始化: i=-1, j=5, pivot=8
第1轮:
do i++ → i=0(5)
do j-- → j=4(3)
swap(5,3) → [3,4,8,2,5]
第2轮:
do i++ → i=1(4)
do j-- → j=3(2)
swap(4,2) → [3,2,8,4,5]
第3轮:
do i++ → i=2(8)
do j-- → j=2(8)
i >= j (2>=2) 成立,return j=2
三、 考试辨识要点(快速判断法)
1. 一眼识别 Hoare vs Lomuto
这是最重要的区分,考试中常考:
| 方案 | 扫描方向 | 递归参数 | 核心特征 |
|---|---|---|---|
| Hoare | 双指针,i从左→右,j从右→左 | qsort(l, j) 和 qsort(i, r) |
两个 while 循环分别控制 i 和 j |
| Lomuto | 单指针,i从左→右遍历 | qsort(l, p-1) 和 qsort(p+1, r) |
维护 storeIndex,最后 swap(pivot, a[i]) |
| 挖坑法 | 单指针双向填补 | 类似 Hoare | 用临时变量存 pivot,出现"坑"位 |
2. 识别 Hoare 的两种写法
看到双指针(i,j 从两端出发),还需判断是标准版还是变体版:
标准版特征(do-while 结构):
i = l - 1; j = r + 1;
while (true) {
while (a[++i] < x); // 先移动再判断
while (a[--j] > x);
if (i >= j) break; // 或 return j
swap(a[i], a[j]);
}
变体版特征(while 嵌套结构,即手写代码):
i = l; j = r;
while (i < j) { // 或 while (i <= j)
while (a[i] < x) i++; // 先判断再移动
while (a[j] > x) j--;
if (i <= j) { // 注意这里是 <=
swap(a[i], a[j]);
i++; j--; // 交换后內缩
}
}
四、 总结
手写代码本质上是 Hoare 分区的一种常见变体,逻辑正确(如果修正 swap 的参数问题),与标准版的区别主要在于指针移动与循环结构的组织方式。考试中只要看到双指针从两端向中间扫描交换,即可判定为 Hoare 分区思路,不必担心是标准版还是变体版,除非题目特别问细节。
| 特征 | 手写代码 | 标准 Hoare 分区(教科书版) |
|---|---|---|
| 指针初始化 | i = l, j = r(指向两端) |
i = l-1, j = r+1(指向两端外侧) |
| 循环结构 | while (i < j) 外层包裹 |
while (true) 死循环,内部判断返回 |
| 指针移动时机 | 先扫描定位(while a[i]<x),交换后移动(i++, j--) |
先移动(do i++),再比较,最后交换 |
| 终止条件 | 当 i >= j 时外层循环结束 |
当 i >= j 时 return j |
| Bug 风险 | swap(a[++i], a[j--]) 可能因参数求值顺序导致未定义行为 |
通常使用 swap(a[i], a[j]) 后单独移动指针 |
2. 与另一种主流方案(Lomuto分区法)的对比
为了帮助您更好地区分,这里将图中的实现与另一种著名的、更简单的分区方案进行对比:
| 特性 | 图中的实现 (Hoare变种) | Lomuto 分区法 |
|---|---|---|
| 指针方向 | 双指针,从两端相向移动 | 单指针,从一端同向移动 |
| 分区过程 | 通过交换使小元素左移、大元素右移 | 维护一个“小于基准”的区间,逐个元素放置 |
| 平均交换次数 | 更少,效率通常更高 | 更多 |
| 代码简洁性 | 相对复杂,需处理指针交错 | 非常简单直观 |
| 基准选择 | 通常为第一个或中间元素 | 通常为最后一个元素 |
| 图中的对应点 | while (i < j) 和两个内层 while 循环 |
未使用 |
结论:图中的写法在效率上通常优于Lomuto方案,是工程中更受推崇的实现方式之一。
3. 图中代码的一个关键细节与常见误区
您需要特别注意图中代码的 第13行:
if (i <= j) swap(a[++i], a[j--]); // 注意:这里是 ++i 和 j--
这里在交换的同时提前移动了指针。这种写法虽然紧凑,但会导致跳过当前已交换的元素,并可能使递归边界 (l, j) 和 (i, r) 变得不那么直观,更容易出错。
更稳健、更推荐的写法是:
if (i <= j) {
swap(a[i], a[j]); // 先交换
i++; // 后移动指针
j--;
}

浙公网安备 33010602011771号