排序
一、内排序与外排序
-
内排序:在排序的整个过程中,待排序的所有记录都被放置在内存之中。
-
外排序:由于排序的记录个数太多,不能同时放置在内存之中,整个排序过程中需要在内外存之间多次交换数据才能进行。
在数据结构中,我们主要涉及的是内排序。而根据排序过程中借助的主要操作,我们把内部排序分为:插入排序、交换排序、选择排序、归并排序。
二、简单排序
下列是三种最基础、最简单的排序方法。建议掌握(我觉得应该都会最基础的冒泡,其他的就难说捏)
提前申明,特别熟悉的我就没写注释了。如果看到我自己加了贼多的注释,这只能说明林小颐对此并不熟悉捏。
(一)、冒泡排序(Bubble)
1.传统的Bubble!
for (int i = 0; i < n; i++) {
for (int j = 0; j < n - i; j++) {
if (a[j] > a[j + 1])swap(a[j],a[j+1]);
}
}
2.改进的Bubble!
假如给出的序列只有部分是无序的,难道我们还要从头到尾来一遍吗?
2 1 3 4 5 6 7 8 9
可以看见这个序列我们只需要把 1 和 2 交换一下位置。但是如果是传统的bubble,就会从头到尾、管他三七二十一全部比较一遍。会不会有些多余呢?答案是肯定的!
当 i = 2 时,我们可以得出该趟没有进行任何的比较过程,那么我们增设一个flag作为标记(以便从循环中跑出来)。
bool flag = true;
for (int i = 0; i < n && flag; i++) {
flag = false;
for (int j = 0; j < n - i; j++) {
if (a[j] > a[j + 1]) {
flag = true; //证明进行了比较
swap(a[j],a[j+1]);
}
}
}
(二)、简单选择排序
通过在比较,从剩余的元素中找到关键字最小的记录,并和当前元素进行比较交换。
虽然改动不多,但是其速度较之Bubble已经提高了不少!
对其的算法复杂度分析:
最好情况下:不需要交换。
最坏情况下:序列逆序,需要交换n-1次。
int min;
for (int i = 0; i < n-1; i++) {//简单选择排序最后一个i正好在前面一个
min = i;
for (int j = i + 1; j < n; j++) {//j可以比较到最后一个元素
if (a[min] > a[j]) min = j;
}
if (i != min)swap(a[i],a[min]);
}
(三)、直接插入排序
直接插入排序的基操是将一个记录插入到已经排好序的有序表中,得到一个新的有序表。
对其的算法复杂度分析:平均比较移动次数是: n 2 4 \frac {n^2}{4} 4n2次
最好的情况:本身有序,不进行移动。比较次数: ( n − 1 ) ( ∑ i = 2 n i ) (n-1) (\sum_{i=2}^n i) (n−1)(∑i=2ni)
最坏的情况:逆序,比较 ( n − 1 ) ( n + 2 ) 2 \frac {(n-1)(n+2)}2 2(n−1)(n+2)次,移动次数: ( n − 1 ) ( n + 4 ) 2 \frac {(n-1)(n+4)}2 2(n−1)(n+4)
for (int j, i = 2; i <= n; i++) {//一开始的有序表中只有一个元素,还有一个想要插入的元素。
if (a[i] < a[i - 1]) { //将a[i]插入前面i-1的有序表中
a[0] = a[i]; //让哨兵的值等于a[i]这个想要插入的值
for (j = i - 1; a[j] > a[0]; j--)a[j + 1] = a[j]; //后移
a[j + 1] = a[0];
cnt++;
}
}
以下是对于5000数据运行结果:
三、改进排序
优秀排序的首要条件就是速度。改进排序的目的就是为了提高排序的速度。
(一)、希尔排序
该排序的主要思想是:跳跃分割
将相距某个“增量”的记录组成一个子序列,才能保证在子序列内分别进行直接插入排序后得到的结果是基本有序而不是局部有序。
值得注意的是:增量序列的最后一个增量值必须等于1
原本序列: 9 1 5 | 8 3 7 | 4 6 2
局部有序: 1 5 9 | 3 7 8 | 2 4 6
基本有序: 2 1 3 | 6 4 7 | 5 8 9
其时间复杂度为:
O
(
n
3
2
)
O(n^{\frac 3 2})
O(n23)
int increment = 5000;
do {
increment = increment / 3 + 1; //选取划分增量
for (int j, i = increment + 1; i <= 5000; i++) {
if (a[i] < a[i - increment]) { //把a[i]插入有序的增量子表,一下操作参考直接插入排序
a[0] = a[i];//步长是increment
for (j = i - increment; j > 0 && a[0]; j -= increment)a[j + increment] = a[j];
a[j + increment] = a[0];
cnt++;
}
}
} while (increment > 1);
(二)、堆排序
改进的地方:前面的这些操作并没有把每一趟进行的比较结果存起来。没有充分的利用每一次排序的结果
有许多的比较操作其实我们在前一趟时做过,但是还是在不停地进行比较,重复地无效比较。
在介绍堆排序之前,我们不得不提一下堆这种数据结构。
堆是具有下列性质的完全二叉树:
大顶堆:每个结点的值都 ≥ \ge ≥其左右孩子结点的值
小顶堆:每个结点的值都 ≤ \le ≤其左右孩子结点的值
思想:先将待排序列构造成一个大顶堆。此时的最大值是堆顶的根节点。把该值与堆末尾的元素交换,再将剩下的元素进行重新构造成一个堆。反复执行该操作,可以得到有序序列。
解决措施:
将无序序列构建成一个堆
输出堆顶元素后,将剩下的元素调整成一个新的堆
为了程序的简洁,在此我们不做结构体构造二叉树;而是选用数组。
我必须说一句,堆排序比上述的算法显然要快的多:
对其来说,50万仅需要0.047s,之前对于那些排序算法测试5000的数据量就已经0.004s左右。如果数据量是其100倍,速度又会更慢。
因此对其算法的时间复杂度是: O ( n l o g n ) O(nlogn) O(nlogn)
int n;//元素的个数
double a[200000000] = {0};
void HeapAdjust(int pos, int len) {
//pos定位
int temp = a[pos];
for (int i = 2 * pos; i <= len; i *= 2) {
if (i < len && a[i] < a[i + 1])i++;
if (temp >= a[i])break;
a[pos] = a[i];
pos = i;
}
a[pos] = temp;
}
void HeapSort() {
for (int i = n >> 1; i; i--)HeapAdjust(i, n);
for (int i = n; i; i--) {
swap(a[1], a[i]);
HeapAdjust(1, i - 1);
}
return;
}
(三)、归并排序
注意,有多种排序方式,我们在此只讨论2路归并。即:初始序列含有n个记录(n个有序的子序列)然后我们两两归并,直至最后剩下一个有序的长度为n的序列。
注意,有多种排序方式,我们在此只讨论2路归并。即:初始序列含有n个记录(n个有序的子序列)然后我们两两归并,直至最后剩下一个有序的长度为n的序列。
由于在程序中有比较而不是跳跃的操作,因此它是一种稳定的排序算法。但是会比较占用内存。
对该算法复杂度分析:
时间复杂度: O ( n l o g n ) O(nlog n) O(nlogn)
空间复杂度: O ( n + l o g n ) O(n+logn) O(n+logn)
对于50万的数据又比堆排稍微快了一点哦!
值得一提的是,该算法我的传参比较冗余,建议自行改进。
int TR2[1000001] = {0}, a[1000001] = {0};
int cnt = 0;
void Merge(int *SR, int *TR, int i, int m, int n) {
int j, k;
for (j = m + 1, k = i; i <= m && j <= n; k++) { //将SR中记录由小到大的并入TR
if (SR[i] < SR[j]) {
TR[k] = SR[i++];
cnt++;
} else TR[k] = SR[j++];
}
if (i <= m) {
for (int p = 0; p <= m - i; p++)TR[k + p] = SR[i + p];
}
if (j <= n) {
for (int p = 0; p <= n - j; p++)TR[k + p] = SR[j + p];
}
}
void MergeSort(int *SR, int *TR1, int s, int t) { //递归调用
if (s == t) TR1[s] = SR[s];
else {
int m = (s + t) >> 1; //把SR分为两个子列,不断向下递归分为单个元素的子列
MergeSort(SR, TR2, s, m); //该子列是SR的左边1/2 copy到TR2左1/2
MergeSort(SR, TR2, m + 1, t); //是SR的右边1/2 copy到TR2右1/2
Merge(TR1, TR2, s, m, t); //把TR2的两个子列归并成TR1捏
}
}
要了解的一点是:以上是归并的递归方法。尽管会使得代码较为清晰易懂,但是会造成时间和空间上的性能损耗。所以还有一种改进措施:将递归转化为迭代。这样可以进一步提高效率。(如果我有空了就来写写,这样是立flag吗?)
(四)、快速排序(Quick)
快速排序又被誉为20世纪十大算法之一。你确定要不学它吗?
基本思想:通过一趟排序将待排的序列分成两部分,其中一个部分的元素均比另一个小,然后继续地划分下去,直至序列有序(也就是划分到最小),其核心代码区在partition(划分)函数。
对其算法复杂度进行分析:
最好情况:Partition每次都划分的是最均匀。 O ( n l o g n ) O(nlogn) O(nlogn)。
最坏情况:待排序列为正(逆)序。每一次的划分都只得到(NULL)|(n-1)的序列,所以为 O ( n 2 ) O(n^2) O(n2)。
以下是进行了50万数据测试的结果,但是还是有点出乎我的意料(我以为它会更快地,但是nein)
1.普通的快排!
int cnt = 0;
int a[10000000] = {0};
int Partition(int low, int high) {
//调整表中元素位置,让pviot位于中间,左边的元素均<=,右边>=
int key = a[low]; //选取第一个值作为枢轴值
while (low < high) {
while (low < high && key <= a[high]) {
high--;cnt++;
}
swap(a[low], a[high]); //把比枢轴元素小的元素放到左边捏
while (low < high && key >= a[low]) {
low++;cnt++;
}
swap(a[low], a[high]); //把比枢轴元素大的元素放到右边捏
}
return low;
}
void Quick(int low, int high) {
if (low < high) {
int pivot = Partition(low, high); //选取枢轴量
Quick(low, pivot - 1); //对低子表递归排序
Quick(pivot + 1, high); //对高子表递归排序
}
}
int main() {
srand((int)time(0));
int n;
cout << "How Much Element?" << endl;
cin >> n;
for (int i = 1; i <= n; i++)a[i] = rand() % 10000;
int start = clock();
Quick(1, n);//start
int finish = clock();
cout << "Quick排序的用时是:" << ((double)(finish - start) / CLOCKS_PER_SEC) * 1000 << "ms" << endl;
cout << "Quick排序的比较次数是:" << cnt << endl;
return 0;
}
2.优化的快排!
我们可以从以下三个方面考虑优化方案:
- 优化选取枢轴–优化key=a[low]
- 优化不必要的交换–个人感觉并不是很必要的说
- 优化小数组时的排序方案–在小数组时直接使用插入排序
- 优化递归操作–尾递归改进
(1)三数取中优化枢轴
解释:即取出三个关键字(low、mid、high)先进行排序,再将中间数作为枢轴进行优化。
运行结果:
int Partition(int low, int high) {
//调整表中元素位置,让pviot位于中间,左边的元素均<=,右边>=
int mid = low + (high - low) / 2;
//取a[low]
if (a[low] > a[high])swap(a[low], a[high]);
if (a[mid] > a[high])swap(a[mid], a[high]);
if (a[mid] > a[low])swap(a[low], a[mid]); //使low变成中间值
int key = a[low]; //选取第一个值作为枢轴值
while (low < high) {
while (low < high && key <= a[high]) {
high--;cnt++;
}
swap(a[low], a[high]); //把比枢轴元素小的元素放到左边捏
while (low < high && key >= a[low]) {
low++;cnt++;
}
swap(a[low], a[high]); //把比枢轴元素大的元素放到右边捏
}
return low;
}
(2)尾递归优化改进
递归对性能有一定的影响,我们可以采用尾递归的方式来减少递归次数,从而大大提高性能。
运行截图:
void Quick(int low, int high) {
if (low < high) {
while (low < high) {
int pivot = Partition(low, high); //选取枢轴量
Quick(low, pivot - 1); //对低子表递归排序
low = pivot + 1;
}
}
}
四、小结
对于以上算法来说,:
希尔排序是直接插入排序的升级版,同属于插入排序类。
堆排序是简单选择排序的升级版,同属于选择排序类。
快速排序则是(最慢的)冒泡排序的升级版,同属于交换排序类。
对于各种算法的指标对比:
排序算法 | 平均情况 | 最好情况 | 最坏情况 | 辅助空间 | 稳定性 |
---|---|---|---|---|---|
Bubble | O ( n 2 ) O(n^2) O(n2) | O ( n ) O(n) O(n) | O ( n 2 ) O(n^2) O(n2) | O ( 1 ) O(1) O(1) | 稳定 |
Select | O ( n 2 ) O(n^2) O(n2) | O ( n 2 ) O(n^2) O(n2) | O ( n 2 ) O(n^2) O(n2) | O ( 1 ) O(1) O(1) | 稳定 |
Insert | O ( n 2 ) O(n^2) O(n2) | O ( n ) O(n) O(n) | O ( n 2 ) O(n^2) O(n2) | O ( 1 ) O(1) O(1) | 稳定 |
Shell | O ( n l o g n ) O(nlogn) O(nlogn)~ O ( n 2 ) O(n^2) O(n2) | O ( n 1.3 ) O(n^{1.3}) O(n1.3) | O ( n 2 ) O(n^2) O(n2) | O ( 1 ) O(1) O(1) | 不稳定 |
Heap | O ( n l o g n ) O(nlogn) O(nlogn) | O ( n l o g n ) O(nlogn) O(nlogn) | O ( n l o g n ) O(nlogn) O(nlogn) | O ( 1 ) O(1) O(1) | 不稳定 |
Merge | O ( n l o g n ) O(nlogn) O(nlogn) | O ( n l o g n ) O(nlogn) O(nlogn) | O ( n l o g n ) O(nlogn) O(nlogn) | O ( n ) O(n) O(n) | 稳定 |
Quick | O ( n l o g n ) O(nlogn) O(nlogn) | O ( n l o g n ) O(nlogn) O(nlogn) | O ( n 2 ) O(n^2) O(n2) | O ( l o g n ) O(logn) O(logn)~ O ( n ) O(n) O(n) | 不稳定 |
我们从以下各方面来分析这七种算法:
平均情况:改进算法更优!
最坏情况:堆排序、归并排序更优。
最好情况:(基本有序下)冒泡、直接插入更优。
空间复杂度:如果比较在乎内存使用,就选择堆排序。
稳定性:归并排序妙妙妙!
元素个数:元素越少,使用简单排序会更好;同样,在元素十分多时,使用改进算法更好。
不过要是能用STL,我选择sort(doge