C++算法之计数排序
一、算法描述
给定长度为n
的序列,假设已知序列元素的范围都是[0..K]
中的整数,并且K
的范围比较小(例如10^6
,开长度为10^6
左右的int
类型数组所占用的内存空间只有不到4M
)。解决该问题的计数排序算法描述如下:
- 使用整数数组
cnt
统计[1..K]
范围内所有数字在序列中出现的个数。 - 使用变量
i
枚举1
到K
,如果i
出现了cnt[i]
次,那么在答案序列的末尾添加cnt[i]
个i
。
下图是一个n=6
, K=3
的例子:
值得一提的是,如果元素的范围可以被很容易转换到[0..K]
,我们也可以使用计数排序。如果元素范围是[A..B]
,我们可以通过简单的平移关系将其对应到[0..B-A]
上。或者所有数值均为绝对值不超过100
的两位小数,那么我们可以通过将所有数字放大100
倍将其转换为整数。
找出原序列中元素在答案中的位置
在有些场景中,比如我们根据(key, value)
中的key
关键字进行排序,如果只是使用上面的计数排序,我们无法将value
放到相应的key
在答案序列中的对应位置中。但是,如果我们可以将原序列和答案序列元素的位置对应求出来,那么这个问题就能得到解决。
试想,对于原序列中的数字x
,它排序后的位置可能出现在哪里呢?
因为在排序后的序列中,假设x
第一次出现的位置是i
,最后一次出现的位置是j
,那么i
之前的元素一定比x
小,j
出现的位置之后的元素一定比x
大。假设原序列中<x
元素的个数是A
,≤x
的元素个数是B
,那么x
可能出现的位置一定是[(A+1)..B]
!
sum数组的求法和意义
那么,我们怎样求出A
和B
呢?假设我们对cnt
数组求前缀和,如下图所示,cnt
数组元素求前缀和后为sum
数组:
这里,我们指出sum数组的意义:对于一个序列中可能出现的值x
,sum[x]
的含义是“小于等于x
的数字个数”,同时,也可以看作指向答案序列中最后一个x出现的位置的指针。
利用sum数组分配位置
所以对于值x
,A
即为sum[x - 1]
,B
即为sum[x]
,x
出现的排名为(sum[x - 1] + 1)..sum[x]]
,等价于[(sum[x] - cnt[x] + 1)..sum[x]]
。我们将sum
数组的位置标出来:
然后我们从后往前扫描每个元素,把它填到当前的sum
对应值指向的格子中,并把sum
向前移动。如下图:
有了原序列和答案序列的位置对应,我们也可以据此将对应元素放入答案数组中。所以该版本的计数排序算法描述如下:
- 统计原序列中每个值的出现次数,记为
cnt
数组。 - 从小到大枚举值的范围,对cnt数组求前缀和,记为
sum
数组。 - 从后往前枚举每个元素
a[i]
,分配其在答案中的位置idx[i]
为当前的sum[a[i]]
,也就是将其放在所有值等于a[i]
中的最后一个。并且将sum[a[i]]
减少1
,保证下次再遍历到同样的值时,它分配的位置正好在idx[i]
前面一个。
二、代码实现
代码如下(示例):
#include <bits/stdc++.h>
#define N 1000005
#define K 1000001 // 假设非负整数最大元素范围为1000000
using namespace std;
int a[N], n, b[N];
int cnt[K];
int main() {
// 输入
cin >> n;
for (int i = 1; i <= n; ++i) {
cin >> a[i];
++cnt[a[i]]; // 这里通过计数数组cnt来维护每一种值出现的次数
}
// 维护最终有序序列
for (int i = 0, j = 0; i < K; ++i) // 枚举每一种值i,指针j用来枚举填充答案数组中的位置
for (int k = 1; k <= cnt[i]; ++k) // 根据该值出现的次数
b[++j] = i; // 添加对应个数的i到答案序列
// 输出
for (int i = 1; i <= n; ++i)
cout << b[i] << ' ';
cout << endl;
return 0;
}
三、复杂度分析
空间复杂度
因为在上面的代码中一共开了3个数组,长度分别为O(N)
(对于a
和b
)和O(K)
(对于cnt
)。整个空间复杂度为O(N + K)
。
时间复杂度
容易发现,算法的输入输出部分所占时间复杂度为O(n)
。
在“维护有序序列”的部分,我们首先考虑最外层循环,因为它遍历了所有[0..K]
的数字,所以它的复杂度是O(K)
。
其次,我们考虑内层循环的循环次数,其在外层循环为i
时为cnt[i]
。因为对于不同的输入,以及外层循环枚举到的不同的i
,cnt[i]
差别很大。但如果我们把所有i对应的内层循环次数相加,即可得到:
所以,整个算法的复杂度为O(n + K)
。
我们提到过,有一条结论
- 所有基于比较的排序算法的时间复杂度都为
Ω(nlogn)
。(Ω
和O
记号类似,但O
表示的是“不会超过”,而Ω
表示的是“不会少于”)。
我们看到当K = O(n)
时,整个算法的时间复杂度为O(n)
。之所以计数排序可以达到比O(nlogn)
更好的时间复杂度,就是因为它并不是基于比较的排序。
对于基于原序列和答案序列位置对应设计的计数排序,经过分析可以发现其复杂度和第一种一样。大家可以自己尝试分析一下。