一、快速排序
1、简单介绍:快速排序(Quick Sort)是一种高效的排序算法,由计算机科学家Tony Hoare在1960年提出。它是基于分治法的排序算法,其基本思想和步骤如下:
基本概念
快速排序的核心思想是将待排序的数据集分成两个子集,使得一个子集中的所有元素都小于或等于另一个子集中的所有元素,然后递归地对这两个子集进行排序。这样,最终整个数据集就会被排序好。
算法步骤
-
选择基准元素(Pivot): 从待排序数组中选择一个元素作为基准(pivot)。基准元素的选择方法有很多种,比如选择第一个元素、最后一个元素、中间元素或随机选择等。
-
分区(Partition): 重新排列数组,使得所有小于基准元素的元素都位于基准元素的左侧,而所有大于基准元素的元素都位于基准元素的右侧。基准元素最终被放置在其正确的位置上。
-
递归排序(Recursion): 对基准元素左侧和右侧的子数组分别应用快速排序算法。
-
终止条件(Base Case): 当子数组的长度为零或一时,递归结束,因为它们已经是有序的。
详细步骤
-
选择基准: 例如,选择数组中的最后一个元素作为基准。
-
分区过程:
- 设定两个指针,
i
和j
。i
指向数组的开头,j
从数组的开头到倒数第二个元素移动。 - 当
j
指向的元素小于等于基准元素时,将i
指向的元素与j
指向的元素交换,并将i
向右移动一位。 - 最终,将基准元素与
i
指向的元素交换,使基准元素位于其正确位置上。
- 设定两个指针,
-
递归调用: 对基准元素左侧和右侧的子数组递归执行快速排序。
示例
假设我们有一个数组 [3, 6, 8, 10, 1, 2, 1]
,并选择最后一个元素 1
作为基准:
-
选择基准:
1
-
分区过程:
- 初始数组:
[3, 6, 8, 10, 1, 2, 1]
- 移动指针
j
并将小于等于1
的元素放在左侧,结果会改变为:[1, 6, 8, 10, 3, 2, 1]
,1
和3
交换位置。
- 初始数组:
-
基准元素位置:
1
已经在它应该在的位置上,分区完成。 -
递归调用:
- 对基准左侧
[1]
和右侧[6, 8, 10, 3, 2, 1]
分别进行递归排序。
- 对基准左侧
时间复杂度
- 平均情况时间复杂度: O(n log n)
- 最坏情况时间复杂度: O(n^2)(发生在每次选择的基准都是最小或最大元素的情况,如已排序数组)
优点与缺点
优点:
- 平均情况下时间复杂度较低,适合大多数情况下的排序。
- 原地排序,不需要额外的存储空间(除了递归调用栈)。
缺点:
- 最坏情况下时间复杂度较高。
- 不稳定排序(相等元素的相对顺序可能会改变)。
快速排序因其高效和简单的特性,在实际应用中非常受欢迎。
2、模板题
给定你一个长度为 n的整数数列。
请你使用快速排序对这个数列按照从小到大进行排序。
并将排好序的数列按顺序输出。
输入格式
输入共两行,第一行包含整数 n。
第二行包含 n 个整数(所有整数均在 1~ 范围内),表示整个数列。
输出格式
输出共一行,包含 n个整数,表示排好序的数列。
数据范围
1≤n≤100000
输入样例:
5
3 1 2 4 5
输出样例:
1 2 3 4 5
#include<iostream>using namespace std;const int N=1e5+10;
//这个1e5是科学计数法,表示的是100000
int q[N];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;//i和j指针相错一位,会解决不必要的冲突。
//这是以中点为分界点,但是也可以两边的边界点为分界点,看个人习惯。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);}int main(){int n;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]);return 0;
}
这上面的这个题就是最普通的快速排序,是可以作为模板的,可以适当的理解与记忆。
对上述代码的自我理解:掌握一个算法最重要的是搞清楚思想,一般来说,代码是抽象的思想具化的表现,所以说,明白了思想,自然而然就会写了,对于里面过分的细节就不用追究,只需要将里面核心的思想用代码表示出来,这样一般就会写了。
上面的l+r>>1的意思是(l+r)/2,之所以写成位运算的形式是因为方便,不用加括号啥的,一个数右移一位就像于除以2,所以可以这样用。
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);}
这一部分是核心代码,首先,假如一个序列里面只有一个元素,那就没必要进行快速排序,所以就有if(l>=r) return;这句代码,表示的是直接返回,退出递归。然后勒,因为这个快速排序他是没有选择另外开辟数组的,就是在一个数组里面进行各种各样的操作,所以就直接开始咯。
以中点为分界点,注意:分界点是随情况而定的,不一定就能恰好分一半,而且这个如果不能整除的话,是要取整的,这点要注意。
整体的逻辑就是:分界点将整个序列分成了两部分,怎么分呢,在一个整体的数组里面。因为我们要左边的部分的值都小于分界点的值,右边部分的值都要大于分界点的值,i指针从左往右走,j指针从右往左走。当i指针所指的值确实是小于分界点的值,那么就是对的,就是放在左面的,这个时候i++;当j指针所指的值确实是大于分界点的值,那么就是是对的,就是放在右面的,这个时候j--。在这个过程中,若是没有满足这两种情况,那么在没有越界,符合范围的情况下,就将不符合情况的两个指针所指的数交换一下就可以了,就解决了问题,所以上面代码就是这么写的,按照我说的这个一步一步的逻辑。
最后分界点已找到位置,就成功的分成了正式的两部分,然后这个时候在分别去递归左面部分和右面部分的序列,最后把整体的结合起来就是整个的最后的答案,也就是上面的那个最终代码,所以说,写代码,还是得按照逻辑来写,特别是涉及到递归的更是如此,有些时候过分的细节不用去细究,想明白大概运行逻辑就可以写了。
注意: quick_sort(q,l,j);
quick_sort(q,j+1,r);
这两句代码是递归左边部分和右边部分的,但是要注意写的时候的细节,我这里是以中点为分界点,所以左边是l~j,右边是j+1,r;不要将j换成i来表示,就算换的一摸一样,但是会出现边界问题,会出现一些莫名的错误,所以我说:要是你取左边界来当分界点,那么就尽量用j来表示范围,中点也是如此;取右边界当分界点的时候,尽量用i表示范围。
具体叙述:
(1)int x = q[l]
和 int x = q[r]
与 int x = q[l + r >> 1]
有本质的区别,主要在于访问数组元素的具体位置以及它们在程序中的作用和结果。
1. int x = q[l]
和 int x = q[r]
-
int x = q[l]
: 这行代码从数组q
中获取索引l
处的元素,并将其赋值给x
。这个操作将x
设置为区间左边界l
的值。 -
int x = q[r]
: 类似地,这行代码从数组q
中获取索引r
处的元素,并将其赋值给x
。这个操作将x
设置为区间右边界r
的值。
2. int x = q[l + r >> 1]
int x = q[l + r >> 1]
: 这行代码从数组q
中获取区间中点位置l + r >> 1
处的元素,并将其赋值给x
。这个操作计算了区间[l, r]
的中间位置的值。
比较与问题
-
访问的元素位置不同:
q[l]
和q[r]
访问的是区间边界位置的元素,而q[l + r >> 1]
访问的是区间中间的元素。- 如果你在做二分查找或者分治算法,通常需要访问中间位置的元素(
q[l + r >> 1]
)来决定下一步的操作。
-
作用不同:
- 访问
q[l]
和q[r]
通常用于获取边界值,用于边界比较或者特定操作。而q[l + r >> 1]
用于获取中点值,这在许多算法(如二分查找、分治法)中至关重要。
- 访问
-
算法逻辑:
- 在二分查找中,访问
q[l + r >> 1]
是为了获取当前中点的值,以决定下一个搜索区间。在这种情况下,直接访问边界元素 (q[l]
或q[r]
) 不符合算法逻辑,可能会导致错误的搜索范围或不正确的操作。
- 在二分查找中,访问
-
具体场景:
- 如果你只需要边界值来进行比较或某些操作(例如寻找最大/最小值),那么访问
q[l]
或q[r]
是合适的。 - 如果你在进行中点分割(如在递归中分割区间),那么访问中点元素 (
q[l + r >> 1]
) 是必要的。
- 如果你只需要边界值来进行比较或某些操作(例如寻找最大/最小值),那么访问
总结
q[l]
和q[r]
用于获取区间边界的值,不适合用来确定区间的中点。q[l + r >> 1]
用于获取区间中点的值,适合用于需要中点信息的算法,如二分查找和分治法。
根据你具体的算法需求,选择适当的数组索引访问方式非常重要。如果你在实现二分查找或类似的算法,使用中点索引 q[l + r >> 1]
是必要的,否则可能无法正确地执行算法逻辑。
(2)在算法中,尤其是涉及到查找、排序或分治的场景时,选择左边界点、右边界点还是中间点对算法性能有重要影响。特别是对于二分查找等分治算法,选择中间点通常是优化性能的关键。
为什么选择中间点通常更优?
-
时间复杂度:
- 二分查找:二分查找的时间复杂度是 (O(\log n)),因为每次将问题规模减半。为了达到这个效果,你必须在每一步中准确地将搜索范围分成两半,这就需要选择中间点 (
l + r >> 1
) 来决定下一步的搜索区间。 - 选择边界点:如果你总是选择左边界点 (
q[l]
) 或右边界点 (q[r]
),你将无法缩小搜索区间,算法的复杂度可能会退化,导致性能问题。例如,如果总是选择最小值或最大值,你实际上可能需要遍历整个区间,从而导致线性时间复杂度 (O(n)),这会显著增加运行时间。
- 二分查找:二分查找的时间复杂度是 (O(\log n)),因为每次将问题规模减半。为了达到这个效果,你必须在每一步中准确地将搜索范围分成两半,这就需要选择中间点 (
-
搜索效率:
- 中间点:选择中间点可以有效地将问题规模减半,每一步操作都使搜索区间减小。这种策略可以快速收敛到目标位置,从而提高查找效率。
- 边界点:选择边界点(如
q[l]
或q[r]
)通常意味着你可能需要重新计算搜索区间的大小,从而增加了不必要的操作,无法利用有效的分治策略。
具体影响
-
超时问题:
- 如果你在进行查找或排序等操作时选择边界点而不是中间点,算法可能会变得非常慢,特别是在处理大规模数据时。对于二分查找,如果选择边界点,你实际上可能会失去二分查找的优势,从而导致超时。
-
实际例子:
- 二分查找:有效使用中间点确保每一步都在减小问题规模,保持 (O(\log n)) 的时间复杂度。
- 线性查找:如果你仅使用边界点,可能会退化为线性查找,时间复杂度变成 (O(n)),这在处理大量数据时可能导致程序超时。
结论
- 中间点 是处理二分查找和其他分治算法时的最佳选择,因为它可以确保问题规模的有效减少和算法的高效执行。
- 边界点 的选择可能导致搜索效率降低,特别是在需要快速定位或处理大数据时,可能会导致超时或性能问题。
因此,在需要高效查找和排序的算法中,始终选择中间点(尤其是在二分查找等算法中)是避免性能问题和超时的关键。
3、实战例题
给定一个长度为 n的整数数列,以及一个整数 k,请用快速选择算法求出数列从小到大排序后的第 k个数。
输入格式
第一行包含两个整数 n和 k。
第二行包含 n 个整数(所有整数均在 1∼ 范围内),表示整数数列。
输出格式
输出一个整数,表示数列的第 k 小数。
数据范围
1≤n≤100000,
1≤k≤n
输入样例:
5 3
2 4 1 5 3
输出样例:
3
//这个是快排选择排序
#include<iostream>
using namespace std;const int N = 1e5 + 10;
int q[N];void quick_sort(int q[], int l, int r, int k) {if (l >= r) return; /*我取的中点作为参照值,所以这里一旦发现只有一个元素,就直接退出递归了,直接返回,如果是以边界的l或者r作参照值的话,情况有不一样。*/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]);//老规矩,快排的模板。}int leftSize = j - l + 1;//左半部分的长度,这个还是得单独表示出来,方便点且避免出错。if (k <= leftSize) {quick_sort(q, l, j, k);//小于等于左半部分长度,就只需要递归左边。} else {quick_sort(q, j + 1, r, k - leftSize);//大于左边部分长度,就只需要右边递归。}
}int main() {int n, k;cin >> n >> k;for (int i = 0; i < n; i++) scanf("%d", &q[i]);quick_sort(q, 0, n - 1, k);cout << q[k - 1] << endl;//因为下标是从0开始,所以第k小的数是在k-1位置上。return 0;
}
模板是可以直接去记忆和理解的,但是遇到实际的情况要根据题目的要求来改进,反正需要快排的地方大概就和模板是差不多的。
需要注意的小细节:
在快速排序算法中,选择 j
作为递归分界点之所以合适,而 i
可能不行,主要是因为 i
和 j
的含义和位置不同。在分区过程中,i
和 j
的值代表了不同的状态:
1. j
的位置
在分区过程中,j
是右指针向左移动的结果,它代表了数组中第一个不满足条件的元素的位置。在经典的快速排序实现中,j
的最终值表示基准元素的正确位置或者其右侧的分界点。换句话说,所有在 q[l..j]
的元素都小于等于基准元素,而 q[j+1..r]
的元素都大于基准元素。因此:
q[l..j]
是基准元素左边的子数组。q[j+1..r]
是基准元素右边的子数组。
因此,递归地对这两个子数组进行排序是合适的。
2. i
的位置
i
是左指针向右移动的结果,它表示数组中第一个大于等于基准元素的位置。i
在分区操作过程中可能会在基准元素的右侧,也可能在基准元素的左侧。具体来说:
- 在分区过程中,
i
会不断向右移动,直到找到一个大于等于基准元素的值。 - 当
i
和j
相遇时,i
的位置实际上是j
的位置,表示基准元素的最终位置。
为什么用 j
更合适
-
准确的分割:
- 使用
j
作为分界点可以确保子数组[l..j]
是所有小于等于基准元素的元素,而[j+1..r]
是所有大于基准元素的元素。这保证了子数组的正确分割,使得快速排序能够递归地对这两个部分进行排序。
- 使用
-
避免错误的递归:
- 如果使用
i
作为递归的分界点,i
在分区结束时的值不一定能准确地表示左侧和右侧子数组的正确分界,特别是在i
和j
的位置不同的情况下,这可能导致递归调用错误的范围,导致无限递归或不正确的排序结果。
- 如果使用
总结
在快速排序算法中,j
作为分界点是合适的,因为它正确地表示了基准元素的位置或者右侧的分界点。而 i
是在寻找下一个要交换的元素的过程中移动的,它可能不会在分区的最后准确地表示两个子数组的分界。因此,使用 j
可以确保正确地递归排序左边和右边的子数组。