数据结构进阶:快速排序深度解析与折半查找实战
引用:
在之前的《数据结构-八大选择排序(冒泡排序、简单选择排序、插入排序、希尔排序)》中,我们学习了冒泡排序、简单选择排序、插入排序和希尔排序这四种基础排序算法,其中前三者时间复杂度为O(n²),希尔排序通过分组优化将复杂度降至O(n log₂n),为处理中大规模数据提供了思路。
本文将在此基础上,进一步学习快速排序(八大排序中效率顶尖的排序算法)和折半查找(有序数组的高效查找算法)。快速排序作为八大排序的核心成员,时间复杂度稳定在O(n log n),是实际开发中排序场景的首选;折半查找则依托有序数组,将查找复杂度从线性的O(n)降至对数级的O(log n),二者结合能大幅提升数据处理效率。
一、快速排序:八大排序中的 “性能王者”
快速排序(Quick Sort)由计算机科学家 Tony Hoare 于 1960 年提出,其核心思想是 “分治法”—— 通过一趟排序将数组划分为 “左小右大” 的两部分,再对两部分递归执行排序,最终实现全局有序。它因排序效率高、适用场景广,成为八大排序中最常用的算法之一。
1.1 核心思想与执行步骤
快速排序的关键是 “基准数选择” 和 “数组划分”,以 “将待排序数组第一个元素作为基准数” 为例,完整步骤如下:
- 选基准:取数组待排序区间的第一个元素作为基准数(key),如区间[L, R]的基准数为a[L];
- 定指针:定义左右指针i和j,分别指向区间的起始(L)和末尾(R);
- j 指针移动:j从右向左移动,找到第一个小于基准数的元素后停止(若a[j] >= key则继续移动);
- i 指针移动:判断i是否小于j(未相遇),若未相遇,i从左向右移动,找到第一个大于基准数的元素后停止(若a[i] <= key则继续移动);
- 交换元素:再次判断i是否小于j(未相遇),若未相遇,交换a[i]和a[j]的值,重复步骤 3-5;
- 基准归位:当i与j相遇时,将基准数(a[L])与相遇位置的元素(a[i])交换,此时基准数左侧元素均小于它,右侧元素均大于它;
- 递归排序:以相遇位置i为分界点,将数组拆分为[L, i-1](左区间)和[i+1, R](右区间),对两个区间重复上述步骤,直到区间长度为 1(递归出口)。
1.2 时间复杂度与空间复杂度
- 时间复杂度:
- 最好情况 / 平均情况:O(n log n)(每次划分能将数组均匀分为两部分,递归层数为log n,每层处理n个元素);
- 最坏情况:O(n²)(数组已完全有序或逆序,每次划分仅能将数组分为 “1 个元素” 和 “n-1 个元素”,递归层数为n);
- 优化方案:通过 “随机选择基准数” 或 “三数取中(取左、中、右三个位置元素的中位数作为基准)”,可将最坏情况概率降至极低,实际应用中复杂度接近O(n log n)。
- 空间复杂度:
- 主要来自递归调用的栈空间,递归层数为log n(平均情况),因此空间复杂度为O(log n);
- 若不优化基准选择,最坏情况下递归层数为n,空间复杂度为O(n)。
- 稳定性:不稳定。例如数组[3, 2, 2, 1],基准数为 3,排序过程中会导致两个 2 的相对顺序改变。
1.3 Java 实现(双边指针法)
以下代码采用 “双边指针划分”,以第一个元素为基准数,包含完整的递归逻辑和测试案例(注:修复原代码中递归右区间的笔误,将qSort(a, L, pivot - 1)修正为qSort(a, pivot + 1, R)):
public class QuickSort {
public static void main(String[] args) {
// 测试用例:包含重复元素、无序数组
int[] arr = {5, 2, 9, 4, 7, 1, 3, 2};
System.out.print("排序前数组:");
for (int n : arr) System.out.print(n + " ");
// 执行快速排序
quickSort(arr);
System.out.print("\n排序后数组:");
for (int n : arr) System.out.print(n + " ");
// 输出:排序后数组:1 2 2 3 4 5 7 9
}
/**
* 快速排序入口方法
* @param a 待排序的int数组
*/
public static void quickSort(int[] a) {
// 边界判断:数组为空或长度小于2,无需排序
if (a == null || a.length < 2) return;
// 调用递归排序方法,初始区间为[0, a.length-1]
qSort(a, 0, a.length - 1);
}
/**
* 递归排序核心方法:对[L, R]区间的数组进行快速排序
* @param a 待排序数组
* @param L 区间左边界
* @param R 区间右边界
*/
private static void qSort(int[] a, int L, int R) {
// 递归出口:当左边界 >= 右边界时,区间长度为1或0,无需排序
if (L >= R) return;
// 一趟划分:返回基准数最终位置(相遇位置)
int pivot = partition(a, L, R);
// 递归排序左区间[L, pivot-1]
qSort(a, L, pivot - 1);
// 递归排序右区间[pivot+1, R](修复原笔误)
qSort(a, pivot + 1, R);
}
/**
* 双边指针划分法:将[L, R]区间划分为“左小右大”两部分,返回基准数位置
* @param a 待划分数组
* @param L 区间左边界(基准数初始位置)
* @param R 区间右边界
* @return 基准数最终位置(i与j的相遇位置)
*/
private static int partition(int[] a, int L, int R) {
int key = a[L]; // 1. 选择区间第一个元素作为基准数
int i = L; // 2. 左指针初始指向左边界
int j = R; // 2. 右指针初始指向右边界
// 循环:直到i与j相遇
while (i < j) {
// 3. j指针从右向左移动:找第一个小于基准数的元素(跳过>=key的元素)
// 注意:必须先移动j,确保相遇时元素小于基准数,保证基准归位正确
while (i < j && a[j] >= key) {
j--;
}
// 4. i指针从左向右移动:找第一个大于基准数的元素(跳过<=key的元素)
while (i < j && a[i] <= key) {
i++;
}
// 5. 若i<j(未相遇),交换i和j指向的元素
if (i < j) {
int tmp = a[i];
a[i] = a[j];
a[j] = tmp;
}
}
// 6. 基准归位:将基准数与相遇位置的元素交换
a[L] = a[i];
a[i] = key;
return i; // 7. 返回基准数位置,作为后续递归的分界点
}
}
关键说明:为什么先移动 j 指针?
若先移动 i 指针,可能导致相遇时元素大于基准数,此时与基准数交换会破坏 “左小右大” 的规则。例如数组[3, 1, 2]:
- 先移动 i:i 会找到 2(大于 3),此时 j 仍在 2 位置,i 与 j 相遇,交换 3 和 2 后数组变为[2, 1, 3],基准数 3 左侧的 2>1,不符合 “左小右大”;
- 先移动 j:j 会找到 2(小于 3),再移动 i(i=0 时 a [i]=3<=3,i++ 到 1,a [1]=1<=3,i++ 到 2,此时 i=j=2),交换 3 和 2 后数组变为[2, 1, 3],后续递归可正常处理左区间[0,1]。
1.4 快速排序与八大排序的关联
在八大排序(冒泡、选择、插入、希尔、归并、快速、堆、基数)中,快速排序与希尔排序、归并排序、堆排序同属 “高效排序算法”,时间复杂度均为O(n log n),但各有特点:
|
排序算法 |
时间复杂度(平均) |
空间复杂度 |
稳定性 |
核心优势 |
|
快速排序 |
O(n log n) |
O(log n) |
不稳定 |
原地排序、实际效率最高,适合大规模数据 |
|
希尔排序 |
O(n log₂n) |
O(1) |
不稳定 |
原地排序、无递归栈开销,适合中等规模数据 |
|
归并排序 |
O(n log n) |
O(n) |
稳定 |
稳定排序、适合外排序(数据存磁盘) |
|
堆排序 |
O(n log n) |
O(1) |
不稳定 |
原地排序、适合 Top-K 问题 |
快速排序凭借 “原地排序 + 低常数因子” 的优势,在内存中处理大规模数据时,效率通常优于其他O(n log n)级排序算法,是工业界的首选排序方案(如 Java 的Arrays.sort()对基本类型数组的实现就基于快速排序的优化版本 —— 双轴快速排序)。
二、折半查找:有序数组的 “高效查找利器”
折半查找(Binary Search)又称二分查找,仅适用于有序数组(升序或降序),其核心思想是 “每次排除一半不符合条件的元素”,大幅减少查找次数,是查找算法中的经典案例。
2.1 核心思想与两种区间模式
折半查找的本质是通过 “中间元素” 与 “目标元素” 的对比,不断缩小查找范围,直到找到目标或范围为空。根据查找区间的定义不同,可分为 “左闭右闭” 和 “左闭右开” 两种模式,两种模式的边界处理略有差异。
核心步骤(以升序数组为例):
- 定义查找区间的左右边界(left和right);
- 计算区间中间位置mid(避免溢出:mid = left + (right - left) / 2,而非(left + right) / 2,后者在left和right均为大值时可能超出 int 范围);
- 对比a[mid]与目标值target:
- 若a[mid] == target:找到目标,返回mid;
- 若a[mid] < target:目标在mid右侧,调整左边界;
- 若a[mid] > target:目标在mid左侧,调整右边界;
- 重复步骤 2-3,直到区间为空(left > right或left == right,取决于区间模式),若未找到目标,返回 - 1(或其他标记值)。
2.2 两种区间模式的完整 Java 实现
模式 1:左闭右闭区间([left, right])
- 初始边界:left = 0,right = a.length - 1(包含数组最后一个元素,因此right取数组长度减 1);
- 边界调整:
- 若a[mid] < target:目标在mid右侧,且mid已排除(a[mid]小于目标,不可能是结果),左边界更新为mid + 1;
- 若a[mid] > target:目标在mid左侧,且mid已排除,右边界更新为mid - 1;
- 循环终止条件:left > right(区间内无元素可查,如left=3、right=2,此时区间为空)。
public class BinarySearchClosed {
public static void main(String[] args) {
// 前提:数组必须有序(此处为升序)
int[] sortedArr = {1, 2, 3, 4, 5, 6, 7, 8, 9};
int target1 = 5; // 存在的目标值(预期索引4)
int target2 = 10; // 不存在的目标值(预期返回-1)
int target3 = 1; // 目标值在数组首位(预期索引0)
// 执行折半查找
int index1 = binarySearch(sortedArr, target1);
int index2 = binarySearch(sortedArr, target2);
int index3 = binarySearch(sortedArr, target3);
// 输出结果
System.out.printf("目标值%d的索引:%d\n", target1, index1); // 输出:4
System.out.printf("目标值%d的索引:%d\n", target2, index2); // 输出:-1
System.out.printf("目标值%d的索引:%d\n", target3, index3); // 输出:0
}
/**
* 左闭右闭区间的折半查找
* @param a 有序数组(升序)
* @param target 目标值
* @return 目标值索引(未找到返回-1)
*/
public static int binarySearch(int[] a, int target) {
// 边界判断:数组为空,直接返回-1(避免后续空指针异常)
if (a == null || a.length == 0) return -1;
int left = 0;
int right = a.length - 1; // 右边界初始为数组最后一个元素索引
// 循环:left <= right 表示区间内仍有元素可查(如left=right时,区间为[left,left],仍需判断)
while (left <= right) {
// 计算mid:避免left+right溢出(等价于(left+right)/2,但更安全)
int mid = left + (right - left) / 2;
if (a[mid] == target) {
return mid; // 找到目标,直接返回索引
} else if (a[mid] < target) {
// 目标在右侧,左边界更新为mid+1(mid已排除)
left = mid + 1;
} else {
// 目标在左侧,右边界更新为mid-1(mid已排除)
right = mid - 1;
}
}
// 循环结束仍未找到,返回-1(表示目标不存在)
return -1;
}
}
模式 2:左闭右开区间([left, right))
- 初始边界:left = 0,right = a.length(不包含数组最后一个元素,因此right取数组长度,如数组长度为 6,right=6,最后一个元素索引为 5,不包含在区间内);
- 边界调整:
- 若a[mid] < target:目标在mid右侧,mid已排除(a[mid]小于目标),左边界更新为mid + 1;
- 若a[mid] > target:目标在mid左侧,因区间右开,mid不包含在区间内,直接将右边界更新为mid;
- 循环终止条件:left == right(区间为空,如left=3、right=3,此时区间[3,3)无任何元素)。
public class BinarySearchOpen {
public static void main(String[] args) {
// 前提:数组必须有序(此处为升序,若为降序需调整判断逻辑)
int[] sortedArr = {1, 3, 5, 7, 9, 11, 13, 15};
// 测试用例:包含存在、不存在、边界值三种场景
int target1 = 7; // 存在的目标值(预期索引3)
int target2 = 8; // 不存在的目标值(预期返回-1)
int target3 = 15; // 目标值在数组末尾(预期索引7)
int target4 = 1; // 目标值在数组首位(预期索引0)
// 执行左闭右开区间的折半查找
int index1 = binarySearch(sortedArr, target1);
int index2 = binarySearch(sortedArr, target2);
int index3 = binarySearch(sortedArr, target3);
int index4 = binarySearch(sortedArr, target4);
// 打印查找结果
System.out.printf("目标值%d的索引:%d(预期:3)\n", target1, index1);
System.out.printf("目标值%d的索引:%d(预期:-1)\n", target2, index2);
System.out.printf("目标值%d的索引:%d(预期:7)\n", target3, index3);
System.out.printf("目标值%d的索引:%d(预期:0)\n", target4, index4);
}
/**
* 左闭右开区间的折半查找(核心方法)
* @param a 有序数组(升序)
* @param target 待查找的目标值
* @return 目标值在数组中的索引,未找到返回-1
*/
public static int binarySearch(int[] a, int target) {
// 边界判断1:数组为空,直接返回-1(避免空指针异常)
if (a == null || a.length == 0) {
return -1;
}
int left = 0;
int right = a.length; // 右边界初始为数组长度,符合左闭右开[left, right)定义
// 循环条件:left < right(当left == right时,区间为空,终止循环)
while (left < right) {
// 计算中间位置mid:避免left+right溢出(等价于(left+right)/2,但更安全)
int mid = left + (right - left) / 2;
if (a[mid] == target) {
// 找到目标值,直接返回索引
return mid;
} else if (a[mid] < target) {
// 目标值在mid右侧:mid已排除,左边界更新为mid+1
left = mid + 1;
} else {
// 目标值在mid左侧:因区间右开,右边界直接更新为mid(mid不包含在新区间内)
right = mid;
}
}
// 循环结束后仍未找到目标值,返回-1
return -1;
}
}
2.3 两种区间模式的差异对比与选择建议
为了帮助大家更清晰地掌握两种模式的核心区别,避免在实际开发中混淆边界处理逻辑,这里通过表格总结关键差异,并给出选择建议:
| 对比维度 | 左闭右闭区间 [left, right] | 左闭右开区间 [left, right) |
|---|---|---|
| 初始边界 | right = a.length - 1(包含最后一个元素) | right = a.length(不包含最后一个元素) |
| 循环终止条件 | left > right(区间无元素时终止) | left == right(区间无元素时终止) |
| 右边界调整逻辑 | 当a[mid] > target时,right = mid - 1 | 当a[mid] > target时,right = mid |
| 适用场景 | 更符合人类直觉(如 “查找 1-10 的数字”),适合新手 | 更贴近计算机底层逻辑(如数组切片),适合框架开发 |
| 代码容错性 | 边界处理稍复杂(易漏写-1) | 边界处理更简洁(无需计算length-1) |
选择建议:
- 新手入门:优先选择左闭右闭区间。其逻辑更符合日常认知(比如 “查找数组从第 0 个到第 n-1 个元素”),边界调整时的
+1和-1更容易理解,能减少初期学习的混淆成本。 - 框架 / 工具开发:优先选择左闭右开区间。这种模式在处理 “数组切片”“子区间划分” 时更高效(例如从数组
[0, 8)中截取[2, 5)子区间,直接复用边界值即可),且无需计算length-1,能减少代码中的硬编码错误。
2.4 折半查找的时间复杂度与应用场景
(1)时间复杂度分析
折半查找的核心优势是 “每次排除一半元素”,其时间复杂度与 “查找次数” 直接相关:
- 假设数组长度为
n,每次查找后剩余元素数量为n/2、n/4、n/8... 直到剩余 1 个元素; - 查找次数
k满足n/(2^k) ≥ 1,即k ≤ log₂n,因此时间复杂度为 O(log n)(对数级复杂度,效率极高)。
对比线性查找(O(n)):当数组长度n=1000000时,线性查找最坏需要 100 万次比较,而折半查找最坏仅需 20 次(log₂1000000≈20),效率差距悬殊。
(2)应用场景
折半查找虽高效,但依赖 “有序数组” 这一前提,因此适合以下场景:
- 静态数据查询:数据一经初始化后很少修改(如字典词典、配置表),只需一次排序,后续可反复高效查询;
- 数据库索引优化:数据库中的 B + 树索引(主流索引结构),底层核心逻辑就是基于折半查找的思想,实现快速定位数据行;
- 二分答案问题:在 “寻找满足条件的最值” 场景中(如 “木材切割的最大长度”“平方根的近似值”),可通过折半查找缩小答案范围,而非遍历所有可能值。
(3)局限性
- 不适合动态数据:若数据频繁插入 / 删除,需频繁重新排序,排序的
O(n log n)成本会抵消折半查找的优势(此时建议用二叉搜索树、红黑树等动态结构); - 不适合小规模数据:当
n较小时(如n<10),线性查找与折半查找的效率差距可忽略,但折半查找需额外处理边界逻辑,反而增加代码复杂度。
三、快速排序与折半查找的联动实战:高效处理 “排序 + 查询” 需求
在实际开发中,“先排序、后查询” 是高频需求(如用户列表按 ID 排序后查询指定用户、商品列表按价格排序后查询指定价格区间的商品)。下面通过一个完整案例,演示如何结合快速排序与折半查找,实现高效的数据处理流程。
实战需求:
现有一个无序的用户 ID 数组(int[] userIds = {5, 2, 9, 4, 7, 1, 3, 8, 6}),需完成以下操作:
- 对用户 ID 数组进行升序排序;
- 快速查询指定用户 ID 是否存在,若存在则返回其索引,不存在则提示 “用户不存在”。
完整实现代码:
import java.util.Scanner;
public class SortAndSearchDemo {
public static void main(String[] args) {
// 1. 定义无序的用户ID数组
int[] userIds = {5, 2, 9, 4, 7, 1, 3, 8, 6};
System.out.print("原始用户ID数组:");
printArray(userIds);
// 2. 调用快速排序,对数组升序排序
quickSort(userIds);
System.out.print("排序后用户ID数组:");
printArray(userIds);
// 3. 接收用户输入的目标ID,进行折半查找
Scanner scanner = new Scanner(System.in);
System.out.print("\n请输入要查询的用户ID:");
int targetId = scanner.nextInt();
// 选择左闭右开区间的折半查找(也可替换为左闭右闭模式)
int index = binarySearchOpen(userIds, targetId);
// 4. 输出查询结果
if (index != -1) {
System.out.printf("查询成功!用户ID%d在数组中的索引为:%d\n", targetId, index);
} else {
System.out.printf("查询失败!用户ID%d不存在\n", targetId);
}
scanner.close();
}
// ---------------------- 快速排序相关方法 ----------------------
public static void quickSort(int[] a) {
if (a == null || a.length < 2) return;
qSort(a, 0, a.length - 1);
}
private static void qSort(int[] a, int L, int R) {
if (L >= R) return;
int pivot = partition(a, L, R);
qSort(a, L, pivot - 1);
qSort(a, pivot + 1, R);
}
private static int partition(int[] a, int L, int R) {
int key = a[L];
int i = L, j = R;
while (i < j) {
while (i < j && a[j] >= key) j--;
while (i < j && a[i] <= key) i++;
if (i < j) {
int tmp = a[i];
a[i] = a[j];
a[j] = tmp;
}
}
a[L] = a[i];
a[i] = key;
return i;
}
// ---------------------- 左闭右开折半查找相关方法 ----------------------
public static int binarySearchOpen(int[] a, int target) {
if (a == null || a.length == 0) return -1;
int left = 0;
int right = a.length;
while (left < right) {
int mid = left + (right - left) / 2;
if (a[mid] == target) {
return mid;
} else if (a[mid] < target) {
left = mid + 1;
} else {
right = mid;
}
}
return -1;
}
// ---------------------- 辅助方法:打印数组 ----------------------
public static void printArray(int[] a) {
for (int i = 0; i < a.length; i++) {
System.out.print(a[i] + " ");
}
System.out.println();
}
}
运行结果示例:
原始用户ID数组:5 2 9 4 7 1 3 8 6
排序后用户ID数组:1 2 3 4 5 6 7 8 9
请输入要查询的用户ID:7
查询成功!用户ID7在数组中的索引为:6
效率分析:
- 排序阶段:快速排序时间复杂度
O(n log n),对n=9的数组仅需 3-4 轮递归,效率极高; - 查询阶段:折半查找时间复杂度
O(log n),对n=9的数组最坏仅需 4 次比较(log₂9≈3.17); - 整体流程:若需多次查询,只需一次排序(
O(n log n)),后续每次查询均为O(log n),相比 “每次查询都线性遍历(O(n))”,效率提升显著。
四、总结与拓展学习建议
4.1 核心知识点总结
-
快速排序:
- 核心思想:分治法 + 基准归位,通过双边指针划分数组为 “左小右大” 两部分,递归排序;
- 关键优势:原地排序、平均时间复杂度
O(n log n),实际开发中处理大规模数据效率最高; - 注意事项:基准选择影响最坏复杂度,建议用 “三数取中” 优化,且算法不稳定。
-
折半查找:
- 核心前提:仅适用于有序数组;
- 两种模式:左闭右闭(新手友好)、左闭右开(框架开发友好),核心差异在边界处理;
- 关键优势:时间复杂度
O(log n),远超线性查找,适合静态数据的高频查询场景。
-
联动价值:快速排序为折半查找提供 “有序数据” 基础,折半查找为排序后的数据提供 “高效查询” 能力,二者结合是处理 “排序 + 查询” 需求的经典方案。
4.2 拓展学习建议
- 快速排序优化:深入学习 “随机基准”“三数取中”“尾递归优化” 等进阶技巧,理解 Java 中
Arrays.sort()的双轴快速排序实现原理; - 折半查找变种:学习 “查找第一个大于等于目标值的元素”“查找最后一个小于目标值的元素” 等变种场景,这些是二分答案问题的核心;
- 对比其他算法:将快速排序与归并排序(稳定排序)、堆排序(Top-K 问题)对比,将折半查找与哈希表查找(
O(1)平均复杂度)对比,理解不同算法的适用边界; - 实战练习:尝试用 “快速排序 + 折半查找” 解决实际问题(如成绩排名查询、商品价格区间定位),或在 LeetCode 上完成相关题目(如 LeetCode 912. 排序数组、LeetCode 704. 二分查找)。
通过本文的学习,相信大家已掌握快速排序与折半查找的核心逻辑与实战方法。数据结构与算法的学习核心在于 “理解思想 + 多练多写”,建议大家多手动模拟算法流程、多修改代码测试边界场景,逐步提升解决复杂问题的能力。
609

被折叠的 条评论
为什么被折叠?



