8.2 再谈排序与检索
如何排序
下面将介绍排序函数的工作原理
8.2.1 归并排序
第一种高效算法是归并排序。按照分治三步法,对归并排序算法介绍如下:
划分问题:把序列分成元素个数尽量相等的两半
递归求解:把两半元素分别排序
合并问题:把两个有序表合并成一个
这边的关键在于合并问题的实现,代码实现如下:
点击查看代码
void merge_sort(int* A, int x, int y, int* T) {
if(y-x > 1) {
int m = x + (y-x)/2;//划分,说明不是数组元(也就是最小的单位数组)
int p = x, q = m, i = x;
merge_sort(A, x, m, T);//递归求解,实现[x,m)的排序
merge_sort(A, m, y, T);//递归求解,实现[m,y)的排序
while(p < m || q < y) {//两边都到达边界才是真正的边界条件
if(q >= y || (p < m && A[p] <= A[q])) T[i++] = A[p++];
//从左半数组复制到临时空间,注意这边的q>=y
else T[i++] = A[q++];//从右半数组复制到临时空间
}
for(i = x; i < y; i++) A[i] = T[i];//从辅助空间复制回A数组
}
}
这边有两个关键,首先只要有一个序列非空,就要继续合并,因此在比较时不能直接比较A[p]和A[q],因为可能其中一个序列为空,从而A[p]或者A[q]代表的是一个实际不存在的元素,正确的方式如下:
1.如果第二个序列为空(此时第一个序列一定非空),复制A[p]
2.否则(第二个序列非空),当且仅当第一个序列也非空,且A[p]<=A[q]的时候,才复制A[p]
上面的代码巧妙地利用短路运算符"||"将两个条件连接起来,如果条件1满足,那么就不会执行条件2,如果条件1不满足,才会执行条件2
T[i++] = P[p++],复制后移动下标的方式挺重要的
这个时间复杂度和最大连续和的分治算法是O(nlogn)
以下是对归并的一种拓展
逆序对问题
如果n很大,很明显O(n^2)的枚举会超时,受到归并排序的启发,我们同样可以采用,划分问题,递归求解,合并问题的思路来解决
1.把序列元素尽量分成相等的两半
2.递归求解是统计i和j均在左边或者均在右边的逆序对个数
3.统计i在左边,但j在右边的逆序对个数
本题的关键在于对于合并的理解,统计的常见技巧是分类,下面按照j的不同把这些跨越两边的逆序对进行分类,只要对于右边的每个j,统计左边比他大的元素个数f(j),则所有f(j)之和便是答案
归并排序可以顺便完成f(j)的计算(感觉该递归求解的过程,改变小区域,但是不影响大区域的答案,同时进行分类,非常巧妙,对于子问题的改造,可能简化父问题)
由于合并操作是从小到大进行的,当右边的A[j]复制到T中,左边还没来得及复制到T的那些数就是左边所有比A[j]大的数,此时在累加器中加上左边元素个数m-p即可,左边所剩的元素在区间[p,m),因此元素个数为m-p
代码实现如下:
点击查看代码
#include<iostream>
using namespace std;
typedef unsigned long long ull;
constexpr int MAXN = 5*10e5 + 10;
int n, num[MAXN];
ull merge(int x, int y) {
if(x+1 == y) return 0;
int m = x + (y-x)/2;
ull cnt = 0, l = merge(x, m), r = merge(m, y);
int pos = 0, i = x, j = m, temp[y-x];
while(i < m || j < y) {
if(j == y || (i < m && num[i] <= num[j])) { cnt += j-m; temp[pos++] = num[i++]; }
else { temp[pos++] = num[j++]; }
}
for(int i = 0; i < pos; i++) num[x+i] = temp[i];
return l+r+cnt;
}
int main() {
scanf("%d", &n);
for(int i = 0; i < n; i++) scanf("%d", &num[i]);
cout << merge(0, n) << endl;
return 0;
}
归并排序的时间复杂度为O(nlogn),对该算法稍加修改,可以统计序列中的逆序对的个数,时间复杂度不变
8.2.2 快速排序
快速排序是最快的通用内部排序算法,相对于归并排序来说,不仅速度更快,并且不需要辅助空间,仍然采用分治三步法
划分问题:把数组的各个元素重排后分成左右两部分,使得左边的任意元素都小于或等于右边的任意元素
递归求解:把左右两部分分别排序
合并问题:不用合并,因为此时数组已经完全有序
笔者试写的快速排序代码如下(后续将会特别开一张快速排序)
点击查看代码
#include<iostream>
#include<cstdlib>
#include<ctime>
using namespace std;
constexpr int MAXN = 10e5+10;
int n, num[MAXN];
void swap(int& x, int& y) {
int temp = x; x = y; y = temp;
}
void qsort(int x, int y) {
if(x+1 >= y) return;
int pos = x, npos = x + rand()%(y-x), temp = num[npos];
int t = num[npos]; num[npos] = num[y-1]; num[y-1] = t;
for(int i = x; i < y-1; i++)
if(num[i] < temp) {
t = num[pos]; num[pos++] = num[i]; num[i] = t;
}
swap(num[pos], num[y-1]);
qsort(x, pos); qsort(pos+1, y);
}
int main() {
srand(time(NULL));
cin >> n;
for(int i = 0; i < n; i++) scanf("%d", &num[i]);
qsort(0, n);
for(int i = 0; i < n; i++) printf("%d ", num[i]);
return 0;
}
快速排序有很多种实现方法
快速选择问题
这样选择第k大的数,最容易想到的方法是先排序,然后直接输出下标为k-1的元素,但10^7的规模即使对于O(nlogn)的算法来说较大,那么更加快速的方法就是
假设在快速排序的划分结束后,数组A[p...r]被分成了A[p...q]和A[p+1...r],则可以根据左边的元素个数q-p+1和k的大小关系只在左边或者右边递归求解。可以证明,在期望意义上,程序时间复杂度为O(n)
快速排序的时间复杂度为O(n^2),平均情况O(nlogn),但实践中几乎不可能达到最坏情况,效率非常高。根据快速排序思想,可以在平均O(n)时间内选出数组中第k大的元素。
8.2.3 二分查找
排序的重要意义之一,就是为检索带来方便,想象有10^6个整数,希望确认其中是否包含12345,最容易的方法就是把它放到数组A中,然后依次查看这些整数是否等于12345。这样的方式对于“单次查询”来说运行的很好,但如果需要多次查找,就需要遍历多次(此时会产生大量的冗余信息),如果此时对于数组A排序,就可以查找的更快,好比在字典中查找单词不必一页一页翻过去
在有序表中查找特定元素,常常使用二分查找(Binary Search),又是也叫折半查找
具体思路就是二分法的体现(二分或许比分治更加广泛)
逐步缩小范围法是一种常见的思维方法,二分查找便是基于这种思路,它遵循分治三步法,把原序列划分成元素个数尽量接近的两个子序列,然后递归查找。二分查找适用于有序序列,时间复杂度为O(logn)
尽管可以用递归实现,但一般把二分查找写成非递归的
笔者对于这些基本经典的算法会跳过部分,自我消化,但是仍然会对本章的例题进行自我解析,以及训练题ac代码的发布,剩下的将不会再呈现了,那么笔者先溜了-.-
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 全程不用写代码,我用AI程序员写了一个飞机大战
· DeepSeek 开源周回顾「GitHub 热点速览」
· 记一次.NET内存居高不下排查解决与启示
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· .NET10 - 预览版1新功能体验(一)