在程序设计中,通常使用一下排序算法,分为三类:
- 选择排序、插入排序、冒泡排序
- 堆排序、归并排序、快速排序
- 计数排序、基数排序、桶排序
前两类是基于比较的排序,第一类的时间复杂度度为 O ( n 2 ) O(n ^ 2) O(n2) ,第二类的时间复杂度是 O ( n × l o g ( n ) ) O(n\times log(n)) O(n×log(n)) 。基于比较的排序算法,时间复杂度的下界就是 O ( n × l o g ( n ) ) O(n\times log(n)) O(n×log(n)) 。
第三类则不直接比较大小,而是针对排序数值,这时时间复杂度不只和 n n n 有关,还和数值大小 m m m 挂钩。
离散化
排序算法的第一个运用就是离散化,离散化就是把无穷大集合中的若干元素映射为有限集合以便于计数的方法。
通常问题涉及的数字很大,但是数字的个数不多时,利用离散化可以将问题的空间复杂度大幅度下降,更好的处理数据。
比如,问题涉及int
范围内的
n
n
n 个整数
a
[
1
]
a[1]
a[1] ~
a
[
n
]
a[n]
a[n] ,这
n
n
n 个整数有重复,去重以后共有
m
m
m 个整数。我们要把每个整数
a
[
i
]
a[i]
a[i] 用一个
1
1
1 ~
m
m
m 之间的整数代替,并且大小顺序不变,即
a
[
i
]
≤
a
[
j
]
a[i] \le a[j]
a[i]≤a[j] ,那么所对应的
m
i
<
m
j
m_i < m_j
mi<mj 。
离散化的方法:
- 对待离散化的数组 a a a 排序;
- 去除重复的元素(可以使用
STL
里的unique
函数。
要查找整数 x x x 映射的 m m m 是多少,我们采用二分查找。
对应的代码如下:
void discrete() { // 离散化
sort(a + 1, a + 1 + n);
for(int i = 1; i <= n; ++i) { // 也可以使用 unique函数
if(i == 1 || a[i] != a[i - 1])
b[++m] = a[i];
}
}
int query(int x) { // 查询 x 映射为那个 1 ~ m 之间的整数
return lower_bound(b + 1, b + 1 + m, x) - b;
}
【例题】电影
有 m m m 部正在上映的电影,每部电影的语音和字幕都采用了不同的语言,用一个 i n t int int 范围内的整数来表示语言。有 n n n 个人相约一起去看其中一部电影,每个人只会一种语言,如果一个人能听懂电影的语言,他会很高兴;如果能看懂字幕,他会比较高兴;如果语音和字幕都不懂,他会不开心。请你选择一部电影让这 n n n 个人一起看,使很高兴的人最多。若答案不唯一,则在此前提下再让比较高兴的人最多。 n , m ≤ 2 × 1 0 5 n,m\le 2 \times 10^5 n,m≤2×105 。
分析:
虽然语言的范围是在
i
n
t
int
int 内,但也是
2
×
1
0
9
2\times 10^9
2×109 之多,很难计数(当然可以使用 map
, map
也是一种离散化的手段),因为我们要选择高兴的人最多的电影,所以肯定要快速获得会某一种语言的人数。
离散化最好是离散化数值中可能出现的所有数值,比如题目中所需要的就是语言的种类。假设每个人的语言都不一样、电影语音也不一样,字幕也是,那么 m m m 部电影与 n n n 个人最多涉及了 2 × m + n 2 \times m + n 2×m+n 种语言,因此离散化数值要开到 3 × 2 × 1 0 5 3 \times 2 \times 10^5 3×2×105 。查找映射时间复杂度是 O ( l o g ( n + m ) ) O(log(n + m)) O(log(n+m)) ,那么最终时间复杂度就是 O ( ( n + m ) l o g ( n + m ) ) O((n + m)log(n + m)) O((n+m)log(n+m))
代码如下:
#include <bits/stdc++.h>
using namespace std;
typedef long long LL;
const int N = 200005, M = 3 * N;
int n, m, cnt, Hash[M];
LL a[N], b[N], c[N], dis[M];
int query(LL x) {
return lower_bound(dis + 1, dis + cnt + 1, x) - dis;
}
int main()
{
scanf("%d", &n);
for(int i = 1; i <= n; ++i)
scanf("%lld", a + i), dis[++cnt] = a[i];
scanf("%d", &m);
for (int i = 1; i <= m; i ++ )
scanf("%lld", b + i), dis[++cnt] = b[i];
for (int i = 1; i <= m; i ++ )
scanf("%lld", c + i), dis[++cnt] = c[i];
sort(dis + 1, dis + 1 + cnt);
cnt = unique(dis + 1, dis + 1 + cnt) - dis;
for(int i = 1; i <= n; ++i) {
int d = query(a[i]);
Hash[d] ++ ;
}
vector<int> ans;
int max_d = -1;
for(int i = 1; i <= m; ++i) {
int d = query(b[i]);
max_d = max(max_d, Hash[d]);
}
for(int i = 1; i <= m; ++i) {
int d = query(b[i]);
if(Hash[d] == max_d)
ans.push_back(i);
}
int t = ans[0];
for(int i = 1; i < ans.size(); ++i) {
int j = ans[i];
int d = query(c[j]);
int d_t = query(c[t]);
if(Hash[d] > Hash[d_t])
t = j;
}
cout << t ;
return 0;
}
中位数
在有序序列中,中位数具有一些很优美的性质。
【例题】货仓选址
在一条数轴上有 N N N 家商店,它们的坐标分别为 A [ 1 ] A[1] A[1] ~ A [ N ] A[N] A[N] 。现在需要在数轴上建立一家货仓,每天清晨,从货仓到每家商店都要运送一车商品。为了提高效率,求把货仓建在何处,可以使得货仓到每家商店的距离之和最小。
分析:
这里我觉得书上的解释太棒了,推导得很流畅!
把
a
a
a 排序,设货仓位置为
x
x
x ,则
x
x
x 左侧有
P
P
P 家商店,右侧有
Q
Q
Q 家商店。很显然总距离可以表达为:
s
u
m
=
P
×
x
−
∑
i
=
1
P
x
i
+
∑
i
=
P
+
1
N
x
i
−
Q
×
x
sum = P\times x -\sum_{i = 1}^{P}x_i + \sum_{i = P + 1}^{N}x_i - Q \times x
sum=P×x−i=1∑Pxi+i=P+1∑Nxi−Q×x
若
P
<
Q
P < Q
P<Q 则货仓的位置向右移动
1
1
1 单位距离,距离之和就会减小
Q
−
P
Q - P
Q−P ;
反之,向左移动,距离之和也会减小。
故最优的情况是 P = = Q P == Q P==Q 时,也就是中位数处。
因此如果当 N N N 为奇数时,货仓建在 A [ ( N + 1 ) / 2 ] A[(N + 1) / 2] A[(N+1)/2] 处最优;当 N N N 为偶数时,货仓建在 A [ N / 2 ] A[N/2] A[N/2] ~ A [ N / 2 + 1 ] A[N/2 + 1] A[N/2+1] 之间的任何位置都是最优解。
代码如下:
#include <bits/stdc++.h>
using namespace std;
const int N = 100010;
int n, a[N];
int main()
{
scanf("%d", &n);
for (int i = 0; i < n; i ++ )
scanf("%d", a + i);
sort(a, a + n);
int res = 0;
// 使用两倍等式,排除奇偶
for(int i = 0; i < n; ++i)
res += abs(a[i] - a[n - i - 1]);
cout << res / 2 ;
return 0;
}
【例题】七夕祭
简述:有一个长为
n
n
n 、宽为
m
m
m 的
01
01
01 矩阵,现在给出了矩阵上
1
1
1 的位置,
1
1
1 可向上、向下、向左、向右的相邻的数值交换,现在问:能否通过移动
1
1
1 的位置使得,矩阵中每一行、每一列
1
1
1 的个数相同。注意:第一行或者第一列与最后一行或最后一列也算相邻。
如果行和列都能满足要求输出 both
,仅行能输出 row
,仅列能输出 column
,后面跟上最小移动次数;行列都不能输出 impossible
。
分析:
【例题】动态中位数
动态维护中位数问题:以此读入整数序列,每当已经读入的整数个数为奇数时,输出已读入的整数构成的序列的中位数。
分析:
书上提出了两种做法,一是 “对顶堆” ;二是 “链表 + Hash” 。一个是在线做法,一个是离线做法。当然我认为还可使用树状数组、线段树做(数据结构的神!)。
对顶堆的做法:
对顶堆,即使用两个相对的堆来维护中位数信息,一个大根堆、一个小根堆。在依次读入这个整数序列过程中,设当前序列长度为
M
M
M ,堆中的数据一定要按照下面两个规则存放:
- 把序列按大小排序的前 1 1 1 ~ M / 2 M/2 M/2 个放在大根堆里;
- 把序列按大小排序的前 M / 2 + 1 M/2 + 1 M/2+1 ~ M M M 个放在小根堆里;
所以中位数就在小根堆的堆顶,对于每一个新插入的元素而言,如果插入元素值小于当前序列的中位数,则插入小根堆里;反之,插入大根堆里。
存放过后,要维护上面的两个规则,所以违背了就得把堆的元素个数多的放在小的里面。
代码如下:
#include <bits/stdc++.h>
using namespace std;
const int N = 100005;
int a[N];
priority_queue<int> bigH;
priority_queue<int, vector<int>, greater<int> > smallH;
int query() {
return smallH.top();
}
void insert(int x) {
if(smallH.empty()) {
smallH.push(x);
return ;
}
if(x < query()) {
bigH.push(x);
} else {
smallH.push(x);
}
if(bigH.size() > smallH.size()) {
smallH.push(bigH.top());
bigH.pop();
}
if(bigH.size() + 2 < smallH.size()) {
bigH.push(smallH.top());
smallH.pop();
}
}
int main()
{
int t, tt;
cin >> t;
while (t -- ) {
int n, x;
cin >> tt >> n;
for(int i = 1; i <= n; ++i)
scanf("%d", a + i);
cout << tt << ' ' << (n + 1) / 2 << endl;
vector<int> ans;
for (int i = 1; i <= n; i ++ ) {
insert(a[i]);
if(i & 1) {
ans.push_back(query());
}
}
for(int i = 0; i < ans.size(); ++i){
printf("%d", ans[i]);
if((i + 1) % 10 == 0){
puts("");
} else if(i != ans.size() - 1) {
putchar(' ');
}
}
if(ans.size() % 10 != 0) puts("");
while(!smallH.empty())
smallH.pop();
while(!bigH.empty())
bigH.pop();
}
return 0;
}
第 k k k 大数
给定 n n n 个整数,如何求出第 k k k 大的数?最简单的方法就是排序这 n n n 个数,然后第 k k k 个数,时间复杂度为 O ( n l o g ( n ) ) O(n ~log(n)) O(n log(n)) 。
不过如果利用快速排序的思想,只需要 O ( n ) O(n) O(n) 的时间求出第 k k k 大数。
在快速排序过程中,每层递归中,我们会将元素划为两个部分,如果第 k k k 大数在左部分,我们很显然只需要递归下去左部分,反之只需要递归右部分。这样下去,时间复杂度就可以看作 n + n / 2 + n / 4 + ⋯ + 1 = O ( n ) n + n/2 + n/4+\cdots+1 = O(n) n+n/2+n/4+⋯+1=O(n) 。
代码如下:
int qselect(int q[], int l, int r, int k) {
if(l >= r) return q[l];
int x = q[l + r >> 1], i = l - 1, j = r + 1;
while(i < j) {
while(q[++i] < x);
while(q[--j] > x);
if(i < j) swap(q[i], q[j]);
}
// 此时 l ~ j 是左部分
int cnt = j - l + 1;
if(k <= cnt) return qselect(q, l, j, k);
else return qselect(q, j + 1, r, k - cnt);
}
逆序对
对于一个序列 a a a ,若 i < j i < j i<j 且 a [ i ] > a [ j ] a[i] > a[j] a[i]>a[j] ,则称 a [ i ] a[i] a[i] 与 a [ j ] a[j] a[j] 构成逆序对。
使用归并排序可以在 O ( n l o g ( n ) ) O(n~log(n)) O(n log(n)) 时间找出长度 n n n 的序列的逆序对个数。
归并排序每次把序列二分,然后递归两个部分,最后返回后是两个有序的序列。
那么这时如果 a [ i ] > a [ j ] a[i] > a[j] a[i]>a[j] , a [ i ] a[i] a[i] 是序列左部分的、 a [ j ] a[j] a[j] 是序列右部分的,则此时就一定有了 m i d − i + 1 mid - i + 1 mid−i+1 个逆序对了。每次我们都将这些统计起来,最后就计算出了总的逆序对数了。
代码如下:
LL merge_sort(int q[], int l, int r) {
if(l >= r) return 0;
int mid = (l + r) >> 1;
LL res = merge_sort(q, l, mid) + merge_sort(q, mid + 1, r);
int i = l, j = mid + 1, k = 0;
while(i <= mid && j <= r) {
if(q[i] <=q[j]) temp[k++] = q[i++];
else {
temp[k++] = q[j++];
res += mid - i + 1; // 统计逆序对
}
}
while(i <= mid) temp[k++] = q[i++];
while(j <= r) temp[k++] = q[j++];
for(int i = l, j = 0; i <= r; ++i) q[i] = temp[j++];
return res;
}
除了归并排序,还可以使用树状数组。
【例题】超快速排序
给定一个长度为 n ( n ≤ 5 × 1 0 5 ) n~(n \le 5\times 10^5) n (n≤5×105) 的序列 a a a ,如果只允许进行比较和交换相邻两个数的操作,求至少需要多少次交换才能把 a a a 从小到大排序。
分析:
即使没有做过这道题,也可以知道这个 “超快速排序” 就是冒泡排序。不过应该从来没有关心过冒泡排序所使用的交换次数是多少。而这道题就是问这个。
我们来看冒泡排序的代码:
for (i = 1; i <= 9; i++){
for (j = 0; j <= 9 - i; j++){
if (n[j] > n[j + 1])//相邻两个数如果逆序,则交换位置
{
temp = n[j];
n[j] = n[j + 1];
n[j + 1] = temp;
}
}
}
可以看出只会在逆序的时候交换,所以序列交换的总次数就是逆序对总数。
代码如下:
#include <bits/stdc++.h>
using namespace std;
typedef long long LL;
const int N = 500010;
LL temp[N], a[N];
LL merge_sort(LL q[], int l, int r) {
if(l >= r) return 0;
int mid = (l + r) >> 1, cnt = 0;
LL res = 0;
res += merge_sort(q, l, mid);
res += merge_sort(q, mid + 1, r);
int i = l, j = mid + 1;
while(i <= mid && j <= r)
if(q[i] <= q[j])
temp[cnt++] = q[i++];
else {
temp[cnt++] = q[j++];
res += mid - i + 1;
}
while(i <= mid) temp[cnt++] = q[i++];
while(j <= r) temp[cnt++] = q[j++];
for(i = l, j = 0; i <= r;i++,j++)
q[i] = temp[j];
return res;
}
int main()
{
int n;
while(cin >> n && n) {
for(int i = 0; i < n; ++i)
scanf("%lld", a + i);
cout << merge_sort(a, 0, n - 1) << endl;
}
return 0;
}
【例题】奇数码问题
你一定玩过八数码游戏,它实际上是在一个 3 × 3 3\times3 3×3 的网格中进行的, 1 1 1 个空格和 1 1 1∼ 8 8 8 这 8 8 8 个数字恰好不重不漏地分布在这 3 × 3 3\times3 3×3 的网格中。
例如:
5 2 8
1 3 _
4 6 7
在游戏过程中,可以把空格与其上、下、左、右四个方向之一的数字交换(如果存在)。
例如在上例中,空格可与左、上、下面的数字交换,分别变成:
5 2 8 5 2 _ 5 2 8
1 _ 3 1 3 8 1 3 7
4 6 7 4 6 7 4 6 _
奇数码游戏是它的一个扩展,在一个 n × n n\times n n×n 的网格中进行,其中 n n n 为奇数, 1 1 1 个空格和 1 1 1∼ n 2 − 1 n^2−1 n2−1 这 n 2 − 1 n^2−1 n2−1 个数恰好不重不漏地分布在 n × n n\times n n×n 的网格中。
空格移动的规则与八数码游戏相同,实际上,八数码就是一个 n = 3 n=3 n=3 的奇数码游戏。
现在给定两个奇数码游戏的局面,请判断是否存在一种移动空格的方式,使得其中一个局面可以变化到另一个局面。
分析:
书上之只给了一个定理,没有证明,因为证明其充分条件较为复杂。定理就是:奇数码状态 1 1 1 能否转化为奇数码状态 2 2 2 看的是它们排成一列后的逆序对总数(排除空位)的奇偶性是否一致。
奇数码 n × n n\times n n×n 还可拓展到 n × m n \times m n×m 。
代码如下:
#include <bits/stdc++.h>
using namespace std;
typedef long long LL;
const int N = 510 * 510;
int a1[N], a2[N], temp[N];
LL merge_sort(int q[], int l, int r) {
if(l >= r) return 0;
int mid = (l + r) >> 1, cnt = 0;
LL res = 0;
res += merge_sort(q, l, mid);
res += merge_sort(q, mid + 1, r);
int i = l, j = mid + 1;
while(i <= mid && j <= r)
if(q[i] <= q[j])
temp[cnt++] = q[i++];
else {
temp[cnt++] = q[j++];
res += mid - i + 1;
}
while(i <= mid) temp[cnt++] = q[i++];
while(j <= r) temp[cnt++] = q[j++];
for(i = l, j = 0; i <= r;i++,j++)
q[i] = temp[j];
return res;
}
int main()
{
int n;
while (cin >> n) {
int x;
for(int i = 0; i < n * n; ++i) {
cin >> a1[i];
if(a1[i] == 0) {
x = i;
}
}
while(x < n * n - 1) {
swap(a1[x], a1[x + 1]);
x ++ ;
}
for(int i = 0; i < n * n; ++i) {
cin >> a2[i];
if(a2[i] == 0) {
x = i;
}
}
while(x < n * n - 1) {
swap(a2[x], a2[x + 1]);
x ++ ;
}
if(merge_sort(a1, 0, n * n - 2) % 2 != merge_sort(a2, 0, n * n - 2) % 2)
puts("NIE");
else
puts("TAK");
}
return 0;
}