快速排序与快速选择
快速排序
快速排序(Quick Sort)是计算机科学中最著名、应用最广泛的排序算法之一。它的核心思想是分治(Divide and Conquer),这个策略可以分为三个步骤:
- 分解(Divide):
- 从待排序的数组中,挑选一个元素作为“基准”(Pivot)。
- 围绕这个基准,对数组进行“分区”(Partition)操作。分区操作完成后,数组会变成三个部分:
- 一个所有元素都小于基准的子数组。
- 基准元素本身(它现在已经位于其最终排好序的位置)。
- 一个所有元素都大于或等于基准的子数组。
- 解决(Conquer):
- 通过递归的方式,分别对第一步中产生的“小于基准的子数组”和“大于等于基准的子数组”重复进行快速排序。
- 合并(Combine):
- 这一步在快速排序中是“隐式”的,或者说不需要任何操作。因为当左右两个子数组都被排好序之后,由于分区操作已经保证了基准的正确位置以及左右两边的元素都小于或大于它,所以整个数组自然就是有序的了。
简单来说,快速排序就是通过不断地选择基准、进行分区,将一个大问题递归地分解成越来越小的子问题,直到子问题小到可以直接解决(数组只有一个或零个元素),最终完成排序。

Lumuto 分区方案
“分区”是快速排序算法的核心步骤,而 Lomuto 分区方案(Lomuto Partition Scheme)是实现分区的一种非常直观和流行的方法。
Lomuto 分区的思想
可以把它想象成整理书架的过程:
- 选定一本“基准书”:为了方便,可以选择书架上最右边的那本书作为基准。
- 划分“已整理区域”:在书架的最左边划定一个“已整理区域”,这个区域里将只放比“基准书”更薄的书。用一个标记
i来表示这个区域的右边界。初始时,这个区域是空的,所以i在书架的最左端之前(i = left - 1)。 - 遍历检查与整理:
- 从左到右(用一个指针
j)检查每一本书(除了最右边的基准书)。 - 如果发现一本比“基准书”薄的书(
arr[j] < pivot),需要把它放到“已整理区域”中。 - 如何放?先把“已整理区域”的边界
i向右移动一格(i++),然后把这本新发现的薄书arr[j]和边界i所在位置的书进行交换。这样,“已整理区域”就扩大了,并且包含了这本新发现的薄书。
- 从左到右(用一个指针
- “基准书”归位:
- 当检查完所有书后,
[left, i]这个区间就全都是比基准书薄的书了。 - 那么,
i+1这个位置,理所当然就是“基准书”应该在的最终位置。 - 最后一步,就是把“基准书”(原来在最右边
arr[right])和arr[i+1]进行交换。
- 当检查完所有书后,
- 完成:分区操作结束,返回基准书的新位置
i+1。
参考代码
#include <cstdio>
#include <vector>
#include <algorithm>
using std::vector;
using std::swap;
/**
* @brief Lumuto 分区函数
* @param arr 要分区的数组
* @param left 区间的左边界索引
* @param right 区间的右边界索引
* @return 基准元素在分区后的最终索引
*/
int lomutoPartition(vector<int> &arr, int left, int right) {
// 1. 选择区间的最后一个元素作为基准
int pivot_value = arr[right];
// i 是“小于基准”这个区域的右边界
// 初始时,这个区域为空,i 指向 left 的前一个位置
int i = left - 1;
// 2. 遍历数组从 left 到 right-1
for (int j = left; j < right; j++) {
// 如果当前元素 arr[j] 小于基准值
if (arr[j] < pivot_value) {
// 扩大“小于基准”的区域
i++;
// 将 arr[j] 交换到这个区域的末尾
swap(arr[i], arr[j]);
}
}
// 3. 遍历结束后,i+1 的位置就是基准应该在的地方
// 将基准(arr[right])与 arr[i+1] 交换
swap(arr[i + 1], arr[right]);
// 4. 返回基准的最终位置
return i + 1;
}
/**
* @brief 快速排序的递归实现
* @param arr 要排序的数组
* @param left 区间的左边界索引
* @param right 区间的右边界索引
*/
void quickSort(vector<int> &arr, int left, int right) {
// 递归的终止条件:当区间只有一个或零个元素时
if (left < right) {
// 调用 Lomuto 分区,获取基准的最终位置
int pivot_index = lomutoPartition(arr, left, right);
// 递归地对基准左边的子数组进行排序
quickSort(arr, left, pivot_index - 1);
// 递归地对基准右边的子数组进行排序
quickSort(arr, pivot_index + 1, right);
}
}
int main()
{
int n; scanf("%d", &n);
vector<int> arr(n);
for (int i = 0; i < n; i++) scanf("%d", &arr[i]);
// 调用快速排序
quickSort(arr, 0, n - 1);
for (int i = 0; i < n; i++) {
printf("%d%c", arr[i], i == n - 1 ? '\n' : ' ');
}
return 0;
}
上面这个程序并不能通过模板题的全部测试数据,这是因为标准快速排序存在弱点。
Lomuto 分区方案通常选择区间的最后一个元素作为基准(Pivot),问题就出在这个“固定”的选择上。
- 最坏情况:如果输入的数组恰好是已经排好序的或完全逆序的。
- 会发生什么?
- 假设数组是
[1, 2, 3, 4, 5],每次都选最后一个元素做基准。 - 第一次分区,基准是
5。分区后,左边子数组是[1, 2, 3, 4](\(n-1\) 个元素),右边子数组是空的。 - 第二次分区,基准是
4。分区后,左边是[1, 2, 3](\(n-2\) 个元素),右边是空的。 - ...以此类推。
- 假设数组是
- 灾难性后果:每次分区都极不均衡,递归的深度是 \(n\),算法的时间复杂度为 \(O(n^2)\)。
随机化思想
随机快速排序(Randomized Quick Sort) 的思想非常简单,却很有效。
在选择基准时,不再固定地选择最后一个元素,而是在当前待排序的 [left, right] 区间内,随机挑选一个元素作为基准。
这样做的好处是什么?简单来说,随机化用一个可以忽略不计的“坏运气”风险,换来了在任何输入下都极其稳定的高性能。
参考代码
#include <cstdio>
#include <vector>
#include <algorithm>
#include <random>
using std::vector;
using std::swap;
using std::random_device;
using std::mt19937;
using std::uniform_int_distribution;
/**
* @brief Lumuto 分区函数
* @param arr 要分区的数组
* @param left 区间的左边界索引
* @param right 区间的右边界索引
* @return 基准元素在分区后的最终索引
*/
int lomutoPartition(vector<int> &arr, int left, int right) {
int pivot_value = arr[right];
int i = left - 1;
for (int j = left; j < right; j++) {
if (arr[j] < pivot_value) {
i++;
swap(arr[i], arr[j]);
}
}
swap(arr[i + 1], arr[right]);
return i + 1;
}
/**
* @brief 快速排序的递归实现
* @param arr 要排序的数组
* @param left 区间的左边界索引
* @param right 区间的右边界索引
*/
void randomizedQuickSort(vector<int> &arr, int left, int right) {
if (left < right) {
// 使用 <random> 库生成随机数
// 1. 创建一个随机数生成引擎
static mt19937 generator(random_device{}());
// 2. 创建一个均匀分布,范围是 [left, right]
uniform_int_distribution<int> distribution(left, right);
// 3. 生成一个随机索引
int pivot_idx = distribution(generator);
// 将随机选择的基准与区间的最后一个元素交换
swap(arr[pivot_idx], arr[right]);
// 调用标准 Lomuto 分区
int partition_idx = lomutoPartition(arr, left, right);
// 4. 递归地对左右两个子数组进行排序
randomizedQuickSort(arr, left, partition_idx - 1);
randomizedQuickSort(arr, partition_idx + 1, right);
}
}
int main()
{
int n; scanf("%d", &n);
vector<int> arr(n);
for (int i = 0; i < n; i++) scanf("%d", &arr[i]);
randomizedQuickSort(arr, 0, n - 1);
for (int i = 0; i < n; i++) {
printf("%d%c", arr[i], i == n - 1 ? '\n' : ' ');
}
return 0;
}
上面的程序相比之前多通过了一个测试点,但依然不能通过全部测试数据。实际上,上面的算法在重复元素多时会遇到性能瓶颈。
回顾一下 Lomuto 分区方案:if (arr[j] < pivot_value) 这个条件将数组分成了两部分:
< pivot的部分>= pivot的部分
问题就出在这里:所有与基准值相等的元素,都被划分到了“大于等于”这一侧。
- 设想一个极端情况:数组为
[5, 5, 5, 5, 5, 5, 5]。 - 随机选择一个基准,必然是
5。 - Lomuto 分区后,会得到什么?
- 左边子数组(
< 5):空的(\(0\) 个元素)。 - 右边子数组(
>=5):[5, 5, 5, 5, 5, 5](\(n-1\) 个元素)。
- 左边子数组(
- 后果:这又一次导致了极不均衡的划分,时间复杂度退化为 \(O(n^2)\)。随机化在这里也无能为力,因为无论选哪个元素,基准都是
5。
荷兰国旗思想:三向切分(3-Way Partitioning)
荷兰国旗由红、白、蓝三色组成。荷兰国旗思想就是将一个数组一次性划分成三个部分,而不是两个。
应用到快速排序中,就是将数组根据基准 pivot 分成:
- 小于区:所有元素都比
pivot小。 - 等于区:所有元素都等于
pivot。 - 大于区:所有元素都比
pivot大。
这个思想的巨大优势在于:分区结束后,中间的“等于区”的所有元素已经处于其最终的排序位置了,完全不需要 在后续的递归中处理它们。只需要递归地去排序“小于区”和“大于区”即可。
如果一个数组包含大量重复元素,那么这个“等于区”会非常大,一次性就排好了很多元素,极大地减少了递归的规模。
三向切分的实现步骤
使用三个指针来完成这个精巧的操作:
low:指向“小于区”的 下一个位置。[left...low-1]是小于区。mid:当前正在遍历和检查的元素。[low...mid-1]是等于区。high:指向“大于区”的 前一个位置。[high+1...right]是大于区。
遍历过程(while (mid <= high))
- 如果
arr[mid] < pivot:- 说明
arr[mid]属于“小于区”。 - 将
arr[low]和arr[mid]交换。 low和mid都向右移动一位。
- 说明
- 如果
arr[mid] == pivot:- 说明
arr[mid]已经在正确的分区(等于区)了。 - 只需要移动
mid指针,扩大等于区即可。
- 说明
- 如果
arr[mid] > pivot:- 说明
arr[mid]属于“大于区”。 - 将
arr[high]和arr[mid]交换。 high向左移动一位。- 注意:此时
mid不移动。因为从high换过来的那个新arr[mid]还没有被检查过,它需要留在原地,在下一轮循环中被判断。
- 说明
当 mid 指针与 high 指针相遇后,整个分区就完成了。
参考代码
#include <cstdio>
#include <vector>
#include <utility>
#include <random>
using std::vector;
using std::pair;
using std::swap;
using std::mt19937;
using std::random_device;
using std::uniform_int_distribution;
/**
* @brief 荷兰国旗问题(三向切分)分区函数
* @param arr 要分区的数组
* @param left 区间的左边界索引
* @param right 区间的右边界索引
* @return 一个 pair,包含“等于区”的左右边界索引 {low, high}
*/
pair<int, int> threeWayPartition(vector<int> &arr, int left, int right) {
int pivot_value = arr[right];
int low = left; // 小于区的右边界+1
int mid = left; // 当前检查的元素
int high = right; // 大于区的左边界-1
while (mid <= high) {
if (arr[mid] < pivot_value) {
swap(arr[low], arr[mid]);
low++; mid++;
} else if (arr[mid] > pivot_value) {
swap(arr[high], arr[mid]);
high--;
} else { // arr[mid] == pivot_value
mid++;
}
}
// 循环结束后,[left, low-1] < pivot, [low, high] == pivot, [high+1, right] > pivot
// 但由于基准在最右边,所以最后返回的等于区的边界是 [low, high]
// 实际上,等于区的边界是 [low, mid-1],但 high 在循环结束时就是 mid-1
// 所以返回 {low, high} 是可以的,代表等于区的开始和结束
return {low, high};
}
/**
* @brief 随机快速排序函数(三向切分优化版)
* @param arr 要排序的数组
* @param left 区间的左边界索引
* @param right 区间的右边界索引
*/
void randomizedQuickSort(vector<int> &arr, int left, int right) {
if (left >= right) return;
// 随机化:选择一个随机基准并与最后一个元素交换
static mt19937 generator(random_device{}());
uniform_int_distribution<int> distribution(left, right);
int pivot_idx = distribution(generator);
swap(arr[pivot_idx], arr[right]);
// 执行三向切分
pair<int, int> partition_indices = threeWayPartition(arr, left, right);
// 递归地对“小于区”和“大于区”进行排序
// “等于区” [partition_indices.first, partition_indices.second] 不再需要处理
randomizedQuickSort(arr, left, partition_indices.first - 1);
randomizedQuickSort(arr, partition_indices.second + 1, right);
}
int main()
{
int n; scanf("%d", &n);
vector<int> arr(n);
for (int i = 0; i < n; i++) scanf("%d", &arr[i]);
randomizedQuickSort(arr, 0, n - 1);
for (int i = 0; i < n; i++) {
printf("%d%c", arr[i], i == n - 1 ? '\n' : ' ');
}
return 0;
}
Hoare 分区方案
Hoare 分区方案由快速排序算法的发明者 C. A. R. Hoare 本人提出,是最初的分区方法。它的思想与 Lomuto 方案有所不同,更为精巧。
核心思想:使用两个指针,一个从 左端开始向右 扫描,另一个从 右端开始向左 扫描,它们相向而行,寻找并交换那些“站错队”的元素。
- 选择基准(Pivot):
- Hoare 方案通常选择 区间的第一个元素
arr[left]作为基准。 - 注意:这个基准在整个分区过程中不会移动,它只是一个用于比较的“标杆”。
- Hoare 方案通常选择 区间的第一个元素
- 初始化指针:
- 左指针
i初始化为left - 1。 - 右指针
j初始化为right + 1。 - 这种“界外”初始化是为了方便在
do-while循环中先移动再访问。
- 左指针
- 相向扫描与交换:
- 进入一个无限循环
while (true)。 - 左指针
i的移动:i不断向右移动(i++),直到找到一个 大于或等于 基准pivot的元素arr[i]才停下。 - 右指针
j的移动:j不断向左移动(j--),直到找到一个 小于或等于 基准pivot的元素arr[j]才停下。 - 判断与操作:
- 如果此时
i >= j,说明两个指针已经相遇或交错,这意味着整个区间已经被扫描完毕,分区过程结束。返回j作为分割点。 - 如果
i < j,说明i和j各自找到了一个“站错队”的元素(arr[i]在左边但偏大,arr[j]在右边但偏小),此时 交换arr[i]和arr[j]。交换后,继续下一轮的扫描。
- 如果此时
- 进入一个无限循环
Hoare 方案的一个关键特性
与 Lomuto 方案不同,Hoare 分区结束后:
- 它不保证基准元素最终位于
j这个位置上。 - 它只保证
[left, j]区间内的所有元素都 小于或等于 基准。 - 它只保证
[j+1, right]区间内的所有元素都 大于或等于 基准。
这对递归调用意味着什么?
- 因为分割点
j可能不是基准的最终位置,所以递归处理的子数组必须是[left, j]和[j+1, right]。 - 不能像 Lomuto 方案那样将分割点排除在外(即
[left, j-1]),否则如果j恰好是基准所在的位置,可能会导致无限递归。
参考代码
#include <cstdio>
#include <vector>
#include <utility>
#include <random>
using std::vector;
using std::swap;
using std::mt19937;
using std::uniform_int_distribution;
using std::random_device;
/**
* @brief Hoare 分区函数
* @param arr 要分区的数组
* @param left 区间的左边界索引
* @param right 区间的右边界索引
* @return 一个分割点索引 j,使得 [left, j] <= pivot 且 [j+1, right] >= pivot
*/
int hoarePartition(vector<int> &arr, int left, int right) {
// 1. 选择第一个元素作为基准
int pivot_value = arr[left];
int i = left - 1, j = right + 1;
while (true) {
// 2. 从左向右找到第一个 >= pivot 的元素
do {
i++;
} while (arr[i] < pivot_value);
// 3. 从右向左找到第一个 <= pivot 的元素
do {
j--;
} while (arr[j] > pivot_value);
// 4. 如果指针相遇或交错,分区完成
if (i >= j) return j;
// 5. 交换站错队的元素
swap(arr[i], arr[j]);
}
}
/**
* @brief 随机快速排序函数(Hoare 分区版)
* @param arr 要排序的数组
* @param left 区间的左边界索引
* @param right 区间的右边界索引
*/
void randomizedQuickSort(vector<int> &arr, int left, int right) {
if (left < right) {
// 随机化:选择一个随机基准并与第一个元素交换
static mt19937 generator(random_device{}());
uniform_int_distribution<int> distribution(left, right);
int pivot_idx = distribution(generator);
swap(arr[pivot_idx], arr[left]);
// 执行 Hoare 分区
int partition_idx = hoarePartition(arr, left, right);
// 递归地对左右两个子数组进行排序
// 注意这里的递归边界
randomizedQuickSort(arr, left, partition_idx);
randomizedQuickSort(arr, partition_idx + 1, right);
}
}
int main()
{
int n; scanf("%d", &n);
vector<int> arr(n);
for (int i = 0; i < n; i++) scanf("%d", &arr[i]);
randomizedQuickSort(arr, 0, n - 1);
for (int i = 0; i < n; i++) {
printf("%d%c", arr[i], i == n - 1 ? '\n' : ' ');
}
return 0;
}
时间复杂度分析
快速排序的时间复杂度取决于 分区(Partition) 操作的好坏,而分区的好坏又取决于 基准(Pivot) 的选择。
- 最佳情况(Best Case):每次选择的基准都恰好是当前子数组的 中位数。这会将数组完美地划分为两个大小几乎相等的子数组。
- 最坏情况(Worst Case):每次选择的基准都恰好是当前子数组的 最小值 或 最大值。这会导致分区极不均衡,一边是 \(n-1\) 个元素,另一边是 \(0\) 个元素。
最佳情况分析
如果每次都能完美地平分数组,那么处理一个大小为 \(n\) 的问题,需要:
- 对大小为 \(n/2\) 的左子数组进行排序。
- 对大小为 \(n/2\) 的右子数组进行排序。
- 分区操作本身需要遍历整个数组,成本为 \(O(n)\)。
- 因此,递归关系式为:\(T(n) = 2 T(n/2) + O(n) = O(n \log n)\)。
最坏情况分析
对于 随机 快速排序,最坏情况是指 运气差到极点,每次随机选择都恰好选中了当前子数组的最大或最小值。
如果每次分区都产生一个 \(n-1\) 和一个 \(0\) 的划分,递归关系式为:\(T(n)=T(n-1)+O(n)=O(n^2)\)。
最坏情况 \(O(n^2)\) 听起来很吓人,但 随机化 使得这种情况在实践中几乎不可能发生。数学家证明这种算法的平均时间复杂度是 \(O(n \log n)\)。
当算法的最坏时间复杂度的发生概率较小时,平均意义下的时间耗用能够更加准确地度量算法的性能。算法在所有实例上的平均时间耗用,称为算法的平均时间复杂度。
选择题:假设快速排序算法的输入是一个长度为 \(n\) 的已排序数组,且该快速排序算法在分治过程总是选择第一个元素作为基准元素。以下哪个选项描述的是在这种情况下的快速排序行为?
- A. 快速排序对于此类输入的表现最好,因为数组已经排序。
- B. 快速排序对于此类输入的时间复杂度是 \(O(n \log n)\)。
- C. 快速排序对于此类输入的时间复杂度是 \(O(n^2)\)。
- D. 快速排序无法对此类数组进行排序,因为数组已经排序。
答案
C
选择题:考虑对 n 个数进行排序,以下最坏时间复杂度低于 \(O(n^2)\) 的排序方法是?
- A. 插入排序
- B. 冒泡排序
- C. 归并排序
- D. 快速排序
答案
正确答案是 C。
- 插入排序:最坏情况(例如,待排序数组是逆序的)下,每个元素都需要和前面所有已排序的元素进行比较和移动,时间复杂度为 \(O(n^2)\)。
- 冒泡排序:最坏情况(例如,待排序数组是逆序的)下,需要进行 \(n-1\) 轮比较和交换,总的时间复杂度为 \(O(n^2)\)。
- 归并排序:归并排序是一种分治算法,无论输入数组的初始顺序如何,它都稳定地将数组分成两半,然后合并,其分解和合并的过程所花费的时间都与 \(O(n \log n)\) 成正比。因此,它的最坏时间复杂度为 \(O(n \log n)\)。
- 快速排序:快速排序的平均时间复杂度是 \(O(n \log n)\),但其最坏情况(例如,每次选取的基准值都是当前数组的最小值或最大值)会导致算法退化,时间复杂度变为 \(O(n^2)\)。
综上所述,只有归并排序的最坏时间复杂度 \(O(n \log n)\) 低于 \(O(n^2)\)。
选择题:排序的算法很多,若按排序的稳定性和不稳定性分类,则下列哪个是不稳定排序?
- A. 冒泡排序
- B. 直接插入排序
- C. 快速排序
- D. 归并排序
答案
C。
程序阅读题
#include <iostream>
using namespace std;
const int N = 1000;
int c[N];
int logic(int x, int y) {
return (x & y) ^ ((x ^ y) | (~x & y));
}
void generate(int a, int b, int *c) {
for (int i = 0; i < b; i++)
c[i] = logic(a, i) % (b + 1);
}
void recursion(int depth, int *arr, int size) {
if (depth <= 0 || size <= 1) return;
int pivot = arr[0];
int i = 0, j = size - 1;
while (i <= j) {
while (arr[i] < pivot) i++;
while (arr[j] > pivot) j--;
if (i <= j) {
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
i++; j--;
}
}
recursion(depth - 1, arr, j + 1);
recursion(depth - 1, arr + i, size - i);
}
int main() {
int a, b, d;
cin >> a >> b >> d;
generate(a, b, c);
recursion(d, c, b);
for (int i = 0; i < b; ++i) cout << c[i] << " ";
cout << endl;
}
判断题
-
当 \(1000 \ge d \ge b\) 时,输出的序列是有序的。
-
当输入
5 5 1时,输出为1 1 5 5 5。 -
假设数组 \(c\) 长度无限制,该程序所实现的算法的时间复杂度是 \(O(b)\) 的。
选择题
- 函数
int logic(int x, int y)的功能是?
- A. 按位与
- B. 按位或
- C. 按位异或
- D. 以上都不是
- 当输入为
10 100 100时,输出的第 100 个数是?
- A. 91
- B. 94
- C. 95
- D. 98
答案
代码分析
logic(int x, int y)函数:这个函数执行了一系列位运算,乍一看是一个比较复杂的式子。但是考虑到位运算是独立的,因此把 \(x,y\) 的每一位等于 0 或 1 的情况代入计算就可发现,只有都等于 0 时结果才为 0,否则结果为 1,而这实际上就相当于是按位或。generate(int a, int b, int c)函数:这个函数会用logic函数的结果来填充数组c,它会计算c[i] = (a | i) % (b + 1),其中i从0到b-1。recursion(int depth, int *arr, int size)函数:这是一个实现了快速排序的递归函数。- 它使用数组的第一个元素作为基准值
pivot。 depth参数限制了递归的深度,如果depth耗尽,递归就会停止。- 如果
depth的值足够大(例如,大于等于数组大小b),这个函数就能将数组完全排序。如果depth很小,相当于快速排序只进行了几趟。
- 它使用数组的第一个元素作为基准值
判断题
-
正确。
recursion函数是一个快速排序实现,在最坏的情况下(例如,数组已经有序或逆序),快速排序需要的递归深度等于数组的长度b。题目条件d \ge b保证了递归深度d总是足够完成排序,不会因为深度限制而提前终止。 -
错误。根据前面对
generate函数的理解可以发现,c[0] = (5|0)%6 = 5; c[1] = (5|1)%6 = 5; c[2] = (5|2)%6 = 7%6 = 1; c[3] = (5|3)%6 = 7%6 = 1; c[4] = (5|4)%6 = 5;,初始数组c为{5, 5, 1, 1, 5}。depth为 1,表示只执行一层分区操作。pivot = c[0] = 5,分区过程会不停交换左侧的一个大于等于基准值的元素和右侧的一个小于等于基准值的元素,最终结果为{5, 1, 1, 5, 5}。 -
错误。程序总的时间复杂度由
generate和recursion决定,generate的复杂度是 \(O(b)\),recursion的复杂度是 \(O(d \times b)\),因为它执行 \(d\) 层的分区,每层分区操作的总时间是 \(O(b)\)。总复杂度为 \(O(b)+O(d \times b) = O(d \times b)\),因为 \(d\) 是一个输入变量,不能被当作常数,所以时间复杂度依赖于 \(d\) 和 \(b\)。
选择题
-
B。如上面的代码分析所示,功能为按位或。
-
C。输入满足
d >= b的条件,所以recursion函数会将数组c完全排序。问题所求的“第 100 个数”就是排序后数组的最后一个元素,也就是数组中的最大值。需要找到c[i] = (10 | i) % 101在i从 0 到 99 的范围内的最大值。分析(10 | i)的可能值,i的最大值是 99,所以10 | i的值不会超过 107。需要找到i使得(10 | i)在模 101 之后最大。情况一:10 | i >= 101,10 | 99 = 107, 107 % 101 = 6,10 | 98 = 106, 106 % 101 = 5,其他10 | i的值都小于 106,所以这种情况下,能产生的最大余数是 6。情况二:10 | i < 101,在这种情况下,(10 | i) % 101就等于10 | i。需要找到i使得10 | i的值最大,且小于 101。此时相当于优先满足最高位,权值为 \(2^6 = 64\) 的位可以为 1,这样按位或上 10 之后是 74,接下来考虑权值位 $2^5 = 32 $ 的位,这一位不能得到 1,不然结果就超过 101 了,以此类推,最终能得到的最大结果是 \(2^6 + 2^4 + 2^3 + 2^2 + 2^1 + 2^0 = 95\)。因此,数组c中的最大值是 95,排序后,第 100 个数就是 95。
快速选择
快速选择算法用于高效地从一个无序数组中找到第 k 大或第 k 小的元素。
一个直接的想法是先对整个数组进行排序,如何直接返回对应索引的元素。这样做时间复杂度是 \(O(n \log n)\),属于杀鸡用牛刀,为了仅仅找到一个元素,把整个数组都排好序了,做了很多不必要的工作。
快速选择算法的优势在于:它能在平均 \(O(n)\) 的线性时间内找到这个元素,这在理论上是能达到的最优平均时间复杂度。它避免了对整个数组进行排序,只关注包含目标元素的那一部分数据,从而大大减少了计算量。
它的核心思想巧妙地借鉴了快速排序。
- 核心操作——分区:快速排序的第一步是“分区”。
- 从数组中选一个元素作为基准。
- 重新排列数组,使得所有小于基准的元素都在它左边,所有大于基准的元素都在它右边。
- 完成这一步后,这个基准就已经被放到了它最终排好序后应该在的位置。
- 快速选择的选择性递归:以三向分区为例,在分区操作完成后,假设基准在排序后的最终位置应该是 \([l,r]\):
- 如果要找的最终索引
k正好在这个区间,那说明这个基准就是对应的元素值。 - 如果
k < l,说明要找的元素肯定在基准的左边。完全不需要关心右边的子数组了,只需要在左边的子数组中继续寻找。 - 如果
k > r,说明要找的元素肯定在基准的右边。同样舍弃左边的部分,只需要在右边的子数组中继续寻找。
- 如果要找的最终索引
与快速排序的关键区别:
- 快速排序需要递归处理左右两个子数组。
- 快速选择只递归处理其中一个子数组,问题规模迅速减小。
参考代码
#include <cstdio>
#include <vector>
#include <utility>
#include <random>
using namespace std;
pair<int, int> threeWayPartition(vector<int> &arr, int left, int right) {
int pivot_value = arr[right];
int low = left; // 小于区的右边界+1
int mid = left; // 当前检查的元素
int high = right; // 大于区的左边界-1
while (mid <= high) {
if (arr[mid] < pivot_value) {
swap(arr[low], arr[mid]);
low++; mid++;
} else if (arr[mid] > pivot_value) {
swap(arr[high], arr[mid]);
high--;
} else { // arr[mid] == pivot_value
mid++;
}
}
return {low, high};
}
int quickSelect(vector<int> &nums, int left, int right, int target_idx) {
while (left <= right) {
static mt19937 generator(random_device{}());
uniform_int_distribution<int> distribution(left, right);
int pivot_idx = distribution(generator);
swap(nums[pivot_idx], nums[right]);
// 执行三向划分,返回等于基准值区域的左右边界 [lt, gt]
pair<int, int> pivot_range = threeWayPartition(nums, left, right);
int lt = pivot_range.first, gt = pivot_range.second;
if (target_idx >= lt && target_idx <= gt) {
// 目标索引落在等于区域,找到了答案
return nums[lt];
} else if (target_idx < lt) {
// 目标在小于区域,更新右边界,继续在左边查找
right = lt - 1;
} else { // target_idx > gt
// 目标在大于区域,更新左边界,继续在右边查找
left = gt + 1;
}
}
// 理论上在有效输入下不会到达这里
return -1;
}
int main()
{
int n, k; scanf("%d%d", &n, &k);
vector<int> a(n);
for (int i = 0; i < n; i++) scanf("%d", &a[i]);
printf("%d\n", quickSelect(a, 0, n - 1, k));
return 0;
}
由于每次划分平均只需处理当前区间的一半数据,总耗时为 \(n + \frac{n}{2} + \frac{n}{4} + \cdots \approx 2n\),因此平均时间复杂度为线性的 \(O(n)\),在最坏情况下可能退化到 \(O(n^2)\)。
也可以采用 <algorithm> 头文件内置的 nth_element。
nth_element(first, nth, last);
nth_element(first, nth, last, comp); // 可选,自定义比较函数
first:迭代器的起始位置(包含)。nth:算法执行完后,这个位置将存放整个区间完全排序后本该在这儿的那个元素。last:迭代器的终止位置(不包含)。
参考代码
#include <iostream>
#include <algorithm>
using namespace std;
const int N = (int)5e6 + 5;
int a[N];
int main()
{
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, k;
cin >> n >> k;
for (int i = 0; i < n; i++) {
cin >> a[i];
}
// 使用 nth_element 将第k小的数放到对应的位置,时间复杂度为O(n)
nth_element(a, a + k, a + n);
// 执行 nth_element(a, a + k, a + n) 后,整个数组会被重新排列,并满足以下性质:
// 1. 位置 a[k] 上存放的绝对是整个区间内第 k 小的数
// 2. 区间 [a, a + k) 里的所有元素都不大于 a[k](但它们内部是无序的)
// 3. 区间 (a + k, a + n) 里的所有元素都不小于 a[k](同样内部无序)
cout << a[k] << "\n";
return 0;
}
nth_element 的底层原理就是基于快速选择算法实现的,为了防止基准点选取不佳导致最坏情况退化,标准库内部引入了高度优化的保护机制。

浙公网安备 33010602011771号