本节讲解的是排序和二分,排序讲解了快排和归并,二分讲解了整数二分和浮点数二分。
快排,归并的时间复杂度都是 O ( n l o g n ) O(nlogn) O(nlogn),可以这样想,他们的思想都是分治,而分治在代码实现上是通过递归去做的,他们的递归层数都是 l o g n logn logn 层,每一层的处理都是 n n n,所以复杂度是 O ( n l o g n ) O(nlogn) O(nlogn)
quick_sort(int q[], int l, int r)
q
是待排序数组,l
是待排序区间的左边界,r
是右边界
基本思路
选取一个基准值x
可以取左边界的值q[l]
,或右边界的值q[r]
,或者中间位置的值q[l + r >> 1]
⭐️根据基准值,调整区间,使得左半边区间的值全都≤x
,右半边区间的值全都≥x
采用双指针,左指针i
从左边界l
开始,往右扫描,右指针j
从右边界r
开始,往左扫描。
当满足条件q[i] < x
时,i
右移;直到不满足条件时,i
停下;开始移动j
当满足条件q[j] > x
时,j
左移;直到不满足条件时,j
停下;交换q[i]
和q[j]
;
将i
右移一位,j
左移一位。
重复上面的操作,直到i
和j
相遇。此时左半区间的数都满足≤x
,且左半区间的最后一个数的下标为j
,右半区间的数都满足≥x
,且右半区间的第一个数的下标为i
。i
和j
之间的关系为:i = j + 1
或i = j
对左右两边的区间,做递归操作
递归操作[l, j]
,[j + 1, r]
区间,或者[l, i - 1]
,[i, r]
区间即可
算法题目
Acwing - 785 快速排序
#include
using namespace std;
const int N = 100010;
int n;
int q[N];
void quick_sort(int q[], int l, int r) {
if(l >= r) return; // 递归退出条件, 不要写成 l == r, 可能会出现 l > r 的情况,可以尝试用例[1,2]
int x = q[l + r >> 1], i = l - 1, j = r + 1; // 这里 i 置为 l - 1, j 置为 r + 1, 是因为下面更新i, j 时采用了do while循环,
while(i < j) { // 这里不要写成 i <= j
do i++; while(q[i] < x);// 不能写为 <= x , 那样写 i 可能会越界, 考虑 [1,1,1]。
// 因为基准值x是数组中的一个数,i从左往右移动的过程中,一定会遇到这个数x,此时不满足小于条件, i 一定会停下,也就变相保证了 i 不会越界。
do j--; while(q[j] > x);// 不能写为 >= x , 因为 j 可能会越界, 原因同上
if(i < j) swap(q[i], q[j]); // 若 i 和 j 还未相遇, 则交换2个数
}
quick_sort(q, l, j);
quick_sort(q, j + 1, r);
}
int main() {
scanf("%d", &n);
for(int i = 0; i < n; i++) scanf("%d", &q[i]);
quick_sort(q, 0, n - 1);
for(int i = 0; i < n; i++) printf("%d ", q[i]);
}
注意要点
当区间划分结束后,左指针i
和右指针j
的相对位置,只有2种情况
i
= j + 1
i
= j
(此时i
和j
指向的元素,恰好等于基准值x
)若用j
来作为区间的分界,则[l, j]
都是≤x
,[j + 1, r]
都是≥x
若用i
来作为区间的分界,则[l, i - 1]
都是≤x
,[i, r]
都是≥x
当取i
作为分界的话,基准值x
不能取到左边界q[l]
,否则会出现死循环,比如用例[1,2]
。此时基准值可以取q[r]
,或者q[l + r + 1 >> 1]
,注意取中间位置的数时,要加个1
,避免l + r >> 1
的结果为l
当取j
作为分界的话,基准值x
不能取到右边界q[r]
,否则会出现死循环。此时基准值可以取q[l]
,或者q[l + r >> 1]
另外,上面的代码是采用的do while
循环,如果改写成while
循环,需要注意一个问题,比如改成如下
void quick_sort(int q[], int l, int r) {
if (l >= r) return ;
int x = q[l + r >> 1], i = l, j = r;
while (i < j) {
while (q[i] < x) i++;
while (q[j] > x) j--;
if (i < j) {
swap(q[i], a[j])
i++;
j--;
}
}
quick_sort(q, l, j);
quick_sort(q, j + 1, r);
}
提交会发现WA,可以尝试用例 83, 67, 98, 90, 67, 128, 116, 133, 11, 60
,第一次划分出来得到如下结果(其中基准值选取了67)
60, 11, 67, 90, 98, 128, 116, 133, 67, 83
,划分结束后i = j = 3
,而q[3] = 90
。此时由于已经不满足i < j
这个条件,所以会直接退出while
循环,这会导致q[3]
这个位置没有判断其和基准值的关系,导致递归时的左侧区间[0, 3]
,并不满足所有的值 <= 基准值
这一条件,于是最终排序出来的结果就是 11 60 67 90 67 83 98 133 116 128
,是错误的。
可以这样修改代码,对i == j
时退出循环的情况额外加一下判断
void quick_sort(int a[], int l, int r) {
if (l >= r) return ;
int x = a[l + r >> 1], i = l, j = r;
while (i < j) {
while (a[i] < x) i++;
while (a[j] > x) j--;
if (i < j) {
std::swap(a[i], a[j])
i++;
j--;
}
}
// 对 i == j 的情况额外加一下判断
while (a[j] > x) j--;
while (a[i] < x) i++;
quick_sort(a, l, j);
quick_sort(a, j + 1, r);
}
而如果用do while
循环进行操作,每次交换完2个位置后,是在下一次 while
循环时才会移动左右指针i
和 j
,而不是在本次循环就移动i, j
指针,所以不会在两个指针相遇时漏判。如果用while
的写法,可以这样写
void quick_sort(int a[], int l, int r) {
if (l >= r) return ;
int x = a[l + r >> 1], i = l - 1, j = r + 1;
while (i < j) {
while (a[++i] < x) ;
while (a[--j] > x) ;
if (i < j) std::swap(a[i], a[j]);
}
quick_sort(a, l, j);
quick_sort(a, j + 1, r);
}
其实和do while
是差不多的,只要记住,在循环中,要先移动左右两个指针,然后再进行判断
算法模板
// 任选一种模板进行记忆即可, 下面采用: 基准值取中间位置, 递归时使用j作分界
void quick_sort(int q[], int l, int r) {
if(l >= r) return;
int x = q[l + r >> 1], i = l - 1, j = r + 1;
while(i < j) {
do i++; while(q[i] < x);
do j--; while(q[j] > x);
if(i < j) swap(q[i], q[j]);
}
quick_sort(q, l, j);
quick_sort(q, j + 1, r);
}
思想总结
x
≤x
,右边的全都≥x
练习题:Acwing - 786 第k个数
借用快排的思路,写出快速选择算法(找分界点然后切分数组,缩小查找范围),时间复杂度 O ( n ) O(n) O(n)。
时间复杂度可以这样想:第一层时,需要处理n
次,然后会期望将数组切分成左右两半边,然后会选择其中一半区间,我们每次切分都期望将区间缩小为一半,所以第二层,需要处理n/2
次,同理,第三层需要处理n/4
。所以总的运算次数就是:
n + n/2 + n/4 + ....
这个结果是 <= 2n
,所以总的时间复杂度就是 O ( n ) O(n) O(n)
#include
using namespace std;
const int N = 1e5 +10;
int n, k;
int q[N];
// 选取[l, r]区间内数组q第k小的数
int quick_select(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]);
}
int left = j - l + 1;
if(k <= left) return quick_select(q, l, j, k);
else return quick_select(q, j + 1, r, k - left);
}
int main() {
scanf("%d%d", &n, &k);
for(int i = 0; i < n; i++) scanf("%d", &q[i]);
printf("%d", quick_select(q, 0, n - 1, k));
return 0;
}
merge_sort(int q[], int l, int r)
基本思路
确定分界点,一般是中间位置
从分界点将数组切成两半,对左右两部分做递归排序
⭐️将左右两部分区间合并成一个区间(将2个有序数组,合并成1个有序数组,使用双指针即可)
算法题目
Acwing - 787 归并排序
#include
using namespace std;
const int N = 1e6 + 10;
int n;
int q[N], tmp[N];
void merge_sort(int q[], int l, int r) {
if(l >= r) return;
int mid = l + r >> 1;
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]) tmp[k++] = q[i++];
else tmp[k++] = q[j++];
}
while(i <= mid) tmp[k++] = q[i++];
while(j <= r) tmp[k++] = q[j++];
for(i = l, j = 0; i <= r; i++, j++) q[i] = tmp[j];
}
int main() {
scanf("%d", &n);
for(int i = 0; i < n; i++) scanf("%d", &q[i]);
merge_sort(q, 0, n - 1);
for(int i = 0; i < n; i++) printf("%d ", q[i]);
}
练习题:Acwing - 788: 逆序对的数量
#include
using namespace std;
typedef long long LL;
const int N = 1e5 + 10;
int n;
int q[N], tmp[N];
// 返回区间[l, r]中的逆序对的数量
LL merge_sort(int q[], int l, int r) {
if(l >= r) return 0;
int mid = l + r >> 1;
LL cnt = 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]) tmp[k++] = q[i++];
else {
cnt += mid - i + 1;
tmp[k++] = q[j++];
}
}
while(i <= mid) tmp[k++] = q[i++];
while(j <= r) tmp[k++] = q[j++];
for(int i = l, k = 0; i <= r; i++, k++) q[i] = tmp[k];
return cnt;
}
int main() {
scanf("%d", &n);
for(int i = 0; i < n; i++) scanf("%d", &q[i]);
LL cnt = merge_sort(q, 0, n - 1);
printf("%lld", cnt);
return 0;
}
关于为什么能用归并排序来求逆序对。
首先,根据逆序对的定义,我们容易想到,什么时候不存在逆序对呢?显然,当整个数组有序时,逆序对的数量为0。什么时候会出现逆序对呢?只有两个数的位置关系,和这两个数的数值的大小关系,不一致时,才出现逆序对,而排序算法中,总是会调整逆序对的相对位置,来使得整个数组有序,所以排序算法可以在排序的过程中,统计出逆序对的数量。比如冒泡排序中,交换的总次数其实就是整个数组中逆序对的个数,因为只有每出现一个逆序对的时候才需要进行一次交换。但是冒泡排序的复杂度比较高,而我们恰好发现,归并排序也可以统计出逆序对的数量。
归并排序的过程中,总是会将当前区间分为左右两半,我们可以将整个区间中的逆序对分为3种情况:
在归并排序中,递归的边界是当前区间长度为1,此时区间内的逆序对的数量明显是0。在递归的倒数第二层,我们是合并两个长度为1的区间,那么这个区间内的逆序对,一定全部都是第三种情况,即逆序对的2个数跨了左右两个区间,只需要统计这种情况的逆序对个数即可,而统计跨左右区间的逆序对时,左右区间内部是否排序,不影响逆序对的个数,而统计出的结果就是当前区间内的逆序对的个数,于是在倒数第三层递归时,左右两半区间的逆序对的个数都已经求解完毕,也只需要统计第三种逆序对的个数即可,于是,每层归并,都能统计出当前区间内的逆序对个数。
算法模板
int binary_search_1(int l, int r) {
while(l < r) {
int mid = l + r >> 1;
if(check(mid)) r = mid;
else l = mid + 1;
}
return l;
}
int binary_search_2(int l, int r) {
while(l < r) {
int mid = l + r + 1 >> 1; // 当下面是 l = mid 这样来更新的话,这里计算mid时要多加1,否则会出现边界问题
if(check(mid)) l = mid;
else r = mid - 1;
}
return l;
}
二分的本质
注意:二分的本质不是单调性。单调性可以理解为函数单调性,如一个数组是升序排列或降序排列,此时可以用二分来查找某一个数的位置。
有单调性一定可以二分,但没有单调性,也有可能能够二分。
二分的本质是边界。假设给定一个区间,如果能够根据某个条件,将区间划分为左右两部分,使得左半边满足这个条件,右半边不满足这个条件(或者反之)。此时就可以用二分来查找左右两部分的边界点。
注意左右两半部分的边界不是同一个点,而是相邻的2个点,因为是整数二分(离散的)。上面的2个算法模板,就分别对应了求左半部分的边界(上图红色区域最右边的点),和求右半部分的边界(上图绿色区域最左边的点)
比如,我们要找上图中左边红色部分的边界点,我们取mid = l + r >> 1
,判断一下q[mid]
是否满足条件x,若满足,说明mid
位置在红色区域内,我们的答案在mid
右侧(可以取到mid
),即[mid, r]
,则此时更新l = mid
;若q[mid]
不满足条件x,则说明mid
位置在右边的绿色区域,我们的答案在mid
左侧(不能取到mid
),即[l, mid - 1]
,此时更新r = mid - 1
。
注意,当采用l = mid
和r = mid - 1
这种更新方式时,计算mid
时,要加上1(向上取整),即mid = l + r + 1 >> 1
。否则,在l = r - 1
时,计算mid
时若不加1,则mid = l + r >> 1 = l
,这样更新l = mid
,就是l = l
,会导致死循环。所以要向上取整,采用mid = l + r + 1 >> 1
。
同理,当采用r = mid
和 l = mid + 1
这种更新方式时,计算mid
时不能加1,在l = r - 1
时,若计算mid
时加1,则mid = l + r + 1 >> 1 = r
,这样更新r = mid
。就是r = r
,会导致死循环。
简单的记忆就是,仅当采用l = mid
这种更新方式时,计算mid
时需要加1。
练习题:Acwing-789:数的范围
#include
using namespace std;
const int N = 100010;
int arr[N];
int n,q;
int main() {
scanf("%d%d", &n, &q);
for(int i = 0; i < n; i++) scanf("%d", &arr[i]);
while(q--) {
int k;
scanf("%d", &k);
int l = 0, r = n - 1;
while(l < r) {
int mid = l + r >> 1;
if(arr[mid] >= k) r = mid;
else l = mid + 1;
}
if(arr[l] != k) printf("-1 -1\n");
else {
printf("%d ", l);
l = 0, r = n - 1;
while(l < r) {
int mid = l + r + 1 >> 1;
if(arr[mid] <= k) l = mid;
else r = mid - 1;
}
printf("%d\n", l);
}
}
}
相比整数二分,浮点数二分无需考虑边界问题,比较简单。
当二分的区间足够小时,可以认为已经找到了答案,如当r - l < 1e-6
,停止二分。
或者直接迭代一定的次数,比如循环100次后停止二分。
练习题:Acwing-790:数的三次方根
#include
using namespace std;
int main() {
double n;
scanf("%lf", &n);
double l = -10000, r = 10000;
while(r - l > 1e-8) {
double mid = (l + r) / 2;
if(mid * mid * mid >= n) r = mid;
else l = mid;
}
printf("%.6f", l);
}