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;
} 
v

归并排序的时间复杂度为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代码的发布,剩下的将不会再呈现了,那么笔者先溜了-.-

posted @   banyanrong  阅读(50)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 全程不用写代码,我用AI程序员写了一个飞机大战
· DeepSeek 开源周回顾「GitHub 热点速览」
· 记一次.NET内存居高不下排查解决与启示
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· .NET10 - 预览版1新功能体验(一)
点击右上角即可分享
微信分享提示