算法学习—线性时间选择

本文最后更新于 2025年2月23日 下午

问题描述

给定线性序集中n个元素和一个整数k,1≤k≤n,要求找出这n个元素中第k小的元素。

解决策略

假设都使用 数组array[n] 存储这个线性序列

策略一: 先排序

算法思想

直接使用排序算法对这个线性序列进行排序,那么第k小的元素就是array[k-1]

时间复杂度

时间复杂度最好的排序算法,如「快速排序」,也需要O(nlogn)O(nlogn),排序后查找只需要O(1)O(1),故最终的时间复杂度为 O(nlogn)O(nlogn)

策略二:线性时间选择

算法思想

使用的是“线性时间选择算法”(Quickselect),这个算法基于快速排序的分区思想,能在 O(n) 平均时间复杂度内找到任意第k小元素。通过「快速排序」的学习,可以发现

划分函数partition() 每进行一轮划分之后,结果都能使 基准元素(x = a[p] 放到当前序列中的正确位置。所以,可以根据这个确定位置的次序与k的大小关系,往下查找第k个数:

  • if 确定位置 <= k: 在左区间 继续查找
  • if 确定位置 > k : 在右区间继续查找

流程

  • 使用partition函数进行分区,实现类似快速排序的分割

  • 利用select函数递归选择基于分区的信息来选择所需的第k个元素

核心算法描述

1
2
3
4
5
6
7
8
9
template<class Type>
Type Select(Type a[],int p,int r,int k)
{
if (p==r) return a[p];
int i=Partition(a,p,r);
j=i-p+1; // 计算新基准点的在现区间的次序
if (k<=j) return Select(a,p,i,k);
else return Select(a,i+1,r,k-j);
}

完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
#include <iostream>

using namespace std;

const int MAX = 100001; // 最大范围
int a[MAX];

// 根据基准点划分区间
int partition(int a[], int p, int r){
int i = p, j = r + 1;
int x = a[p]; // 基准元素
while (true) {
while (a[++i] < x && i < r) {} // 从左向右找第一个大于等于 x 的元素, 注意边界
while (a[--j] > x && j > l) {} // 从右向左找第一个小于等于 x 的元素, 注意边界
if (i >= j) break; // 左右指针汇合
swap(a[i], a[j]);
}
a[p] = a[j]; // 将分元素放到正确的位置
a[j] = x; // 将基准元素放到正确位置
return j; // 返回基准位置
}

int select(int a[], int p, int r, int k){
if (p == r) return a[p]; // 最小问题规模:只有一个元素

int i = partition(a, p, r); // 调用划分函数进行划分
int j = i - p + 1; // 计算新基准点的在现区间的次序

// 根据划分结果进行查找
if (j == k)
return a[i];
else if (j > k)
return select(a, p, i - 1, k);
else
return select(a, i + 1, r, k - j);
}

int main(){
int n, k;
cin >> n >> k;

for(int i = 0; i < n; ++i){
cin >> a[i];
}

// 求第k个数
int res = select(a, 0, n - 1, k);

cout << res << endl;

return 0;
}

时间复杂度

平均情况下为 O(n)O(n),最坏情况下可能达到O(n2)O(n^2)。可以通过随机选择基准值或加入中位数的中位数划分策略来改进。

优化

通过 随机选择基准值进行优化

随机选择基准值

1
2
3
4
5
6
7
8
9
// 随机选择基准值
int randomPartition(int a[], int l, int r)
{
int i = l + rand() % (r - l + 1); // 随机选择一个元素作为基准值
int temp = a[i]; // 将分界点元素与最前一个元素交换
a[i] = a[l];
a[l] = temp;
return partition(a, l, r); // 调用划分函数
}

优化后的完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
#include <iostream>

using namespace std;

const int MAXN = 100; // 设置最大 n 的范围
int a[MAXN]; // 数组

// 划分函数
int partition(int a[], int l, int r)
{
// 选择基准元素
int i = l, j = r + 1;
int x = a[l];

// 划分区间
while (true)
{
while (a[++i] < x && i < r) // 从左向右找第一个大于等于 x 的元素, 注意边界
;
while (a[--j] > x && j > l) // 从右向左找第一个小于等于 x 的元素, 注意边界
;
if (i >= j) // 如果两个指针相遇,则退出循环
break;
swap(a[i], a[j]); // 交换元素
}
// 此时 j 指向分界点元素
a[l] = a[j]; // 将分界点元素放到正确的位置
a[j] = x; // 将基准元素放到最终位置
return j; // 返回分界点位置
}

// 随机选择基准值
int randomPartition(int a[], int l, int r)
{
int i = l + rand() % (r - l + 1); // 随机选择一个元素作为分界点
int temp = a[i]; // 将分界点元素与最前一个元素交换
a[i] = a[l];
a[l] = temp;
return partition(a, l, r); // 调用划分函数
}

// 计算第 k 小的元素
int randomSelect(int a[], int l, int r, int k)
{
if (l == r)
return a[l];
int i = randomPartition(a, l, r); // 随机选择分界点
int j = i - l + 1; // 计算新基准点的在现区间的次序

// 根据划分结果进行查找
if (j == k)
return a[i];
else if (j > k)
return randomSelect(a, l, i - 1, k);
else
return randomSelect(a, i + 1, r, k - j);
}

// 主函数
int main(){
int n, k;
cin >> n >> k;

for(int i = 0; i < n; ++i){
cin >> a[i];
}

// 求第k个数
int res = select(a, 0, n - 1, k);

cout << res << endl;

return 0;

⚠️值得注意的

  1. partition函数中的数组越界问题

添加ij越界检查

  • && i < r检查左指针i是否越右界
  • && j > p检查右指针是否越左界
  1. select函数中关系选择正确的区间

递归选择条件分析

划分操作:
partition 方法在 a[i] 处划分数组,使得 a[p...i1]<=a[i]<a[i+1...r]a[p...i-1] <= a[i] < a[i+1...r]。变量 j 的意义:
j = i - p + 1 表示 a[i] 在当前子数组(从 p 到 r)中的第几小的位置(位置是相对于1开始)。递归部分的问题:
条件 j >= k:
这种条件会导致当 j == k 时进入错误的区间选择。在 j >= k 中,当 j == k 时已经找到所需元素 a[i],因此直接返回 a[i] 才是正确的选择。

右区间调用:
else 部分继续在右区间进行,减少k后递归,这部分通常是正确的。

修正:
根据 j 和 k 的关系选择正确的区间:当 j == k 时,返回 a[i]。当 j > k 时,递归在左区间 [p, i-1]。当 j < k 时,递归在右区间 [i+1, r],但此时需要调整 kk - j


算法学习—线性时间选择
https://huazzi.me/posts/743414990/
作者
Huazzi
发布于
2025年1月24日
许可协议