TODO:因为markdown上传图片的问题,最终版有待调试
引言:
本文重点介绍排序算法
目录
正文:
选择排序
- 选择排序的基本思路是:
- 按照
1~n
的顺序,将每个元素依次归位。 - 当归位第
i
个元素时,我们需要选择出第i
个元素到第n
个元素的最小值,并且与第i
个位置的元素交换。此时,1~i
的元素分别为第1
小到第i
小的元素。 - 当第
n
个元素归位完毕以后,整个序列的排序过程结束。
- 按照
#include <bits/stdc++.h>
using namespace std;
int a[1010];
int n;
int main() {
// 输入
cin >> n;
for (int i = 1; i <= n; ++i) cin >> a[i];
// 选择排序过程
for (int i = 1; i < n; ++i) { // 枚举应该归位的第i个元素,这里因为前n-1位归为以后,
// 第n位也会归位,所以我们只枚举到n-1。
int min_pos = i; // 将最小值位置设置为当前范围i~n的首位
for (int j = i + 1; j <= n; ++j) { // 将第i个元素和剩下的元素相比较
if (a[j] < a[min_pos]) { // 如果当前元素小于之前维护的最小值
min_pos = j; // 更改最小值出现的位置
}
}
swap(a[i], a[min_pos]); // 将最小值与第i个位置交换
}
// 输出
for (int i = 1; i <= n; ++i)
cout << a[i] << ' ';
return 0;
}
- 选择排序的时间复杂度是:O(n^2)
编程实践:
明明想在学校中请一些同学一起做一项问卷调查,为了实验的客观性,他先用计算机生成了N个1到1000之间的随机整数(N≤100),对于其中重复的数字,只保留一个,把其余相同的数去掉,不同的数对应着不同的学生的学号。然后再把这些数从小到大排序,按照排好的顺序去找同学做调查。请你协助明明完成“去重”与“排序”的工作。
注:请使用选择排序方法完成此题。
输入描述:
每组输入有2行,第1行为1个正整数,表示所生成的随机数的个数N,第2行有N个用空格隔开的正整数,为所产生的随机数。
输出描述:
每组输出也是2行,第1行为1个正整数M,表示不相同的随机数的个数。第2行为M个用空格隔开的正整数,为从小到大排好序的不相同的随机数。
示例 1:
输入:
10
20 40 32 67 40 20 89 300 400 15
输出:
8
15 20 32 40 67 89 300 400
代码:
#include <cstdio>
#include <cstring>
#include <iostream>
#include <algorithm>
#define N 110
using namespace std;
int a[N], n, cnt;
int main() {
scanf("%d", &n);
for (int i = 0; i < n; ++i)
scanf("%d", &a[i]);
for (int i = 0; i < n; ++i) {
int min_pos = i;
for (int j = i + 1; j < n; ++j) {
if (a[j] < a[min_pos]) // TODO 请补全右侧代码完成选择排序
min_pos = j;
}
swap(a[i],a[min_pos]) ; // TODO 请补全右侧代码完成选择排序
}
cnt = 0;
for (int i = 0; i < n; ++i)
if (i == 0 || a[i] != a[i - 1])
a[cnt++] = a[i];
printf("%d\n", cnt);
for (int i = 0; i < cnt; ++i)
printf("%d ", a[i]);
return 0;
}
判断下列说法是否正确:
选择排序是稳定的排序算法。(稳定的排序算法是指:在排序前的序列中,有超过一个相同的元素,排序后这两个元素的相对位置依然保持不变。)
答:错误。假定是由小到大排序,后面较小数字和当前位置的大数字交换位置时,后面若存在同样的大数字,则这两个大数字的相对位置有可能发生改变。例如序列10(1) 10(2) 1
(小括号代表顺序),最后的1
和10(1)
交换位置,最后序列变成了1 10(2) 10(1)
,两个10
的相对位置发生了改变。
冒泡排序:
- 冒泡排序和选择排序一样,都是将原问题转换为长度减一的子问题的过程。
- 冒泡排序分为
n-1
个阶段,每个阶段通过“冒泡”的过程,将未排序序列中的最大值移动到最后一位。 - 冒泡的过程,具体是通过一段连续交换过程使得最大元素被“传送”到最后一位。
冒泡排序的思路:
-
冒泡排序分为
n-1
个阶段。 -
在第
1
个阶段,通过“冒泡”,我们将前n
个元素的最大值移动到序列的最后一位。此时只剩前n-1
个元素未排序。 -
在第
i
个阶段,此时序列前n-i+1
个元素未排序。通过“冒泡”,我们将前n-i+1
个元素中的最大值移动到最后一位。此时只剩前n-i
个元素未排好序。对一段序列从左到右连续做交换操作的代码:
if (a[i] > a[i + 1]) swap(a[i], a[i + 1]);
- 最终到第
n-1
个阶段,前2个元素未排序。我们将其中的较大值移动到后一位,则整个序列排序完毕。
完整冒泡排序的代码实现:
#include <bits/stdc++.h>
#define N 1010
using namespace std;
int n, a[N];
int main() {
// 输入
cin >> n;
for (int i = 1; i <= n; ++i) cin >> a[i];
// 冒泡排序
for (int i = 1; i < n; ++i) { // 一共n-1个阶段,在第i个阶段,未排序序列长度从n-i+1到n-i。
for (int j = 1; j <= n - i; ++j) // 将序列从1到n-i+1的最大值,移到n-i+1的位置
if (a[j] > a[j + 1]) // 其中j枚举的是前后交换元素的前一个元素序号
swap(a[j], a[j + 1]);
}
// 输出
for (int i = 1; i <= n; ++i) cout << a[i] << ' ';
cout << endl;
}
复杂度分析
从代码中,我们可以看到冒泡排序的主干部分有两层循环,并且每一层的循环次数都在O(n)左右的数量级。
所以完整的冒泡排序时间复杂度是O(n^2)。
插入排序:
总结
-
插入排序的基本思想就是不断扩展有序序列的长度。
具体方式是对于一个有序序列,如果想在其中新加入一个元素,就应通过插入操作找出正确的插入位置,并且将插入位置空出来,然后插入新元素。
-
插入操作的基本思想就是从后向前不断“试探”分界线的位置。
一个合法的分界线,分界线前的元素需满足小于等于新元素大小,分界线后元素需满足大于新元素大小。所以寻找分界线的过程,就是不断把当前在分界线前,但本应该在分界线后的元素向后移动。
//这种描述很专业很准确,但一开始不好理解,后面方便记忆
插入操作的算法描述:
- 假设序列
1~(i-1)
已经有序, 从i
到1
枚举分界线的下标j
; - 如果分界线前面的元素
a[j-1]
大于x
,说明a[j-1]
应该在分界线后面。所以将a[j-1]
移动到a[j]
,分界线前移变成j-1
。 - 如果分界线前面没有元素(
j=1
),就将x
放在数组第1位。否则如果碰到一个j-1
号元素小于等于x
,说明分界线位置正确,就将x
插到j
位。
代码:
#include <bits/stdc++.h>
#define N 1550
using namespace std;
int a[N], n;
int main() {
// 输入
cin >> n;
for (int i = 1; i <= n; ++i) cin >> a[i];
// 插入排序
for (int i = 2; i <= n; ++i) { // 按照第2个到第n个的顺序依次插入
int j, x = a[i]; // 先将i号元素用临时变量保存防止被修改。
// 插入过程,目的是空出分界线位置j,使得所有<j的部分<=x,所有>j的部分>x。
// 循环维持条件,j>1,并且j前面的元素>x。
for (j = i; j > 1 && a[j - 1] > x; --j) {
// 满足循环条件,相当于分界线应向前移,
// 分界线向前移,就等于将分界线前面>x的元素向后移
a[j] = a[j - 1];
}
// 找到分界线位置,插入待插入元素x
a[j] = x;
}
// 输出
for (int i = 1; i <= n; ++i) cout << a[i] << ' ';
cout << endl;
return 0;
}
- 时间复杂度分析:
插入排序的总时间复杂度是O(n^2)
快速排序:
总结
- 快速排序是一种基于分治法的排序。其基本思想在于固定一个分界线,将整个序列按照小于分界线和大于分界线划分,然后再分别对划分好的子段进行排序。
- 快速排序的时间复杂度在理想情况下是O(n \log n),但如果选取分界线每次都是子段中的最大值或最小值的话,时间复杂度可能会退化到O(n^2)。在内存使用上,因为整个移动过程都在原数组中进行的,所以属于原地排序。
- sort函数是C++标准模板库(STL)中一种对快速排序的优化实现,可以通过传入头指针、尾指针和比较函数来对数组中的对象进行排序。
思路://有FFT算法的感觉了
1~n 元素排序
我们先思考一下刚刚描述的快速排序的基本思想:
- 当需要将
1
到n
的n
个数排序时,我们通过分解,将该问题分解为两个将n/2
个数排序的子问题;- 在每个子问题中,我们继续分解,直到最后子问题长度为
1
;- 此时,整个序列就完成排序了。
下面,我们以将1~8
的数字排序为例,详细介绍一下整个过程:
算法开始前,整个序列:
第一层(子段长度为8
)
- 首先,我们找到
4
的位置(因为4
刚好是8/2
),
- 然后把
<4
的数字移动到序列左边,>4
的数字移动到序列右边。此时,4
的位置已经固定了。
第二层(子段长度为<=4
)
- 在这里,我们对4左边和右边的部分分别运行刚才的算法。
这是因为我们把
<4
和>4
的部分移动到4
的两边以后,这两个部分形成了两个独立的子段,也就是说,在最终排好序的序列里,左边的元素不会移动到右边,右边的元素不会移动到左边。
所以,我们分别找出两边中点
2
和6
的位置,
- 然后把
<2 (or 6)
的和>2 (or 6)
的部分分别移动到数字两边。
至此,2
和6
的位置也固定下来了。
第三层(子段长度<=2
)
- 最后,我们仍需对
2
边和6
两边的子段重复刚才的算法。
然而,
2
两边,以及6
左边的子段长度已经只有1
了,这说明这些元素也已经放在正确的位置上了,只有
6
右边的部分长度仍然>1
。所以,我们只需对这部分重复刚才的操作,于是我们寻找出中点7
的位置,
- 最终,我们将
8
移动到7
的右边,7
的位置也放置正确了。
第四层(子段长度<=1
)
- 因为
7
右边部分子段长度为1
,所以直接说明该子段元素位置是正确的。所以,我们就完成了对整个序列的排序。
代码:
1.使用sort函数
sort
函数有三个参数,分别为头指针、尾指针和比较函数,其中如果排序对象定义了小于号的话,比较函数可省略。例如对于一个长为n
的数组排序:
#include <bits/stdc++.h>
using namespace std;
int a[10] = {2, 3, 1, 5, 4};
int n = 5;
int main() {
sort(a, a + n); //sort函数的两个参数,头指针和尾指针
for (int i = 0; i < n; ++i) cout << a[i] << ' ';
cout << endl;
}
2.不使用sort函数
将「整体框架」和「移动元素」进行合并,我们得到快速排序完整代码:
// 该代码参考 https://www.geeksforgeeks.org/quick-sort/
#include <bits/stdc++.h>
#define N 100010
using namespace std;
int n;
int a[N];
void quick_sort(int l, int r) {
// 设置最右边的数为分界线
int pivot = a[r];
// 元素移动
int k = l - 1;
for (int j = l; j < r; ++j)
if (a[j] < pivot) swap(a[j], a[++k]);
swap(a[r], a[++k]);
if (l < k - 1) quick_sort(l, k - 1); // 如果序列的分界线左边的子段长度>1,排序
if (k + 1 < r) quick_sort(k + 1, r); // 如果序列的分界线右边的子段长度>1,排序
// 上面的过程结束后,到这里左子段和右子段已经分别排好序。又因为确定分界线以后的移动操作
// 保证了左子段中的元素都小于等于分界线,右子段中的元素都大于分界线。所以整个序列也是有序的。
}
int main() {
// 输入
scanf("%d", &n);
for (int i = 1; i <= n; ++i) scanf("%d", &a[i]);
// 快速排序
quick_sort(1, n);
// 输出
for (int i = 1; i <= n; ++i) printf("%d ", a[i]);
return 0;
}
复杂度分析:
空间复杂度
首先该算法的空间复杂度是O(n)*O*(*n*),具体来说,在整个排序过程中,元素的移动都在原始数组中进行。所以快速排序是一种原地排序算法。
时间复杂度
可以看出,在「详细算法描述」中,我们的算法分为若干层。每一层中都是分治法的三个步骤:我们首先进行问题拆分,然后进入下一层,下一层的问题解决后,我们返回这一层进行子问题解的合并。
我们首先分析对1~n
的n
个数字进行快速排序的情况。
在每一层中,问题拆分的复杂度是O(n)*O*(*n*),因为我们移动数组元素的时候,需要将每个子段扫一遍。那么把所有层的子段一起看,就相当于在每一层都把整个序列完整扫了一遍。对于子段解的合并,其复杂度是O(1)O(1),因为有分界线的存在,当我们把左边和右边都排好序后,它们和分界线元素一起天然形成了原序列的完整排序。
那么一共有多少层呢?因为每次我们都知道当前子段的中位数,所以可以保证每次划分,两个字段长度比较平衡,所以下一层子段的长度都比上一层减少了一半,直到长度为1算法停止。所以整个算法有\log nlog*n*层。
那么我们分析出在这种情况下,算法的复杂度是O(n\log n)O(nlogn)。这样,在1秒之内,计算机能非常轻松地排序10^6106及以上的数据。
但对于任意n
个数的排序,每次划分情况取决于选取的分界线情况。如果每次分界线刚好取到最小值或者最大值,会导致划分时所有数字都会移动到同一边,整个算法的复杂度也会下降为O(n^2)*O*(*n*2)。如下图:
我们很容易想到两种尽量避免出现这种情况的方法:
- 在排序之前,先把整个数组随机打乱顺序。
- 在选取分界线时,与之前固定选取某个位置的方法相比,我们换成随机选择分界线的位置。
这两种方法都能极大概率避免上面提到的极端情况的发生。
编程实践:
归并排序:
总结
- 和快速排序一样,归并排序也是基于分治法的排序算法。其基本思想在于将待排序序列分成长度相差不超过1的两份,分别对左右子段进行同样的划分和排序过程,最后将两个已经排好序的子段合并成整个完整的有序序列。
- 归并排序的时间复杂度是O(n\log n)O(nlogn),在实现时,需要辅助数组帮助合并子段,所以是一种非原地排序算法。
- 和快速排序不同的是,归并排序是一种稳定排序,即相同元素在排序前后的数组中相对位置不变。
stable_sort
函数是C++标准模板库(STL)中一种对归并排序的优化实现,可以通过传入头指针、尾指针和比较函数来对数组中的对象进行排序。
思路://分而治之:1.排序:先分为2^n部分,然后进行排序 2.拼接:巧妙就在此处
假如我们想找出这个两个序列中的最小值,它有可能出现在哪些位置呢?
因为上面的序列中,第一个元素最小,而下面的序列中,也是第一个元素最小。所以最小值只可能出现在最左边两个元素中的一个!
所以通过比较最左边元素的大小,我们很容易就知道答案序列中第一个元素是1
。
同样的,剩下元素的最小值也必然出现在剩下两个序列的最左边,所以通过比较2
和3
的大小,我们能很容易确定答案序列中排第二的数字是2
。
通过不断重复这个过程,最终我们将两个有序序列合并成了一个有序序列。
代码:
代码可视化:
分解阶段
在分解阶段,我们一步一步将其分解到长度为1的子段。注意在这个阶段,我们仅仅是划分,并不改变元素在数组中的位置。
合并阶段
我们按照和刚才相反的顺序,自底向上合并。
首先在最底层,子段长度为1,每个字段已经是有序序列(因为长度为1的序列本身就是有序序列)。
回到上一阶段,子段长度为2。为了让每个子段中的序列都有序,我们需要将序列两两合并。
再回到上一层,子段长度为4。我们需要用同样的办法把相邻两个长度为2的有序子段合并。
最后,我们将两个长度为4的子段合并,就能得到完整的排好序的序列。
1.使用stable_sort
函数
#include <bits/stdc++.h>
using namespace std;
int a[10] = {0, 2, 3, 1, 5, 4}; // 1-base,0号元素无意义
int n = 5;
bool cmp(int x, int y) { // 比较函数,函数的参数是当前比较的两个数组中的元素
return x > y; // x和y分别为排序数组中的两个元素。
} // 当函数返回值为true时,x应该排在y的前面。
int main() {
stable_sort(a + 1, a + n + 1, cmp); // 比较函数作为第三个参数传入sort函数
for (int i = 1; i <= n; ++i) cout << a[i] << ' ';
cout << endl;
}
2.不使用stable_sort
函数
#include <bits/stdc++.h>
#define N 100010
using namespace std;
int n;
int a[N], b[N];
// 合并操作
void merge(int l, int r) {
for (int i = l; i <= r; ++i) b[i] = a[i]; // 将a数组对应位置复制进辅助数组
int mid = l + r >> 1; // 计算两个子段的分界线
int i = l, j = mid + 1; // 初始化i和j两个指针分别指向两个子段的首位
for (int k = l; k <= r; ++k) { // 枚举原数组的对应位置
if (j > r || i <= mid && b[i] < b[j]) a[k] = b[i++]; // 上文中列举的条件
else a[k] = b[j++];
}
}
void merge_sort(int l, int r) { // l和r分别代表当前排序子段在原序列中左右端点的位置
if (l >= r) return; // 当子段为空或者长度为1,说明它已经有序,所以退出该函数
int mid = l + r >> 1; // 取序列的中间位置,并将序列分成两部分(左右长度相差最多为1)
merge_sort(l, mid);
merge_sort(mid + 1, r);
merge(l, r); // 将l..mid和mid+1..r两个子段合并成完整的l..r的有序序列
}
int main() {
// 输入
scanf("%d", &n);
for (int i = 1; i <= n; ++i) scanf("%d", &a[i]);
// 归并排序
merge_sort(1, n);
// 输出
for (int i = 1; i <= n; ++i) printf("%d ", a[i]);
return 0;
}
复杂度分析:
空间复杂度
首先该算法的空间复杂度是O(n)*O*(*n*),但尽管如此,在整个排序过程中,元素的移动借助了另一个辅助数组。所以归并排序是一种非原地排序算法。
时间复杂度
因为归并排序有着和快速排序一样的框架,所以我们仍然通过分别分析每一层的时间复杂度和总层数来分析总时间复杂度。
在每一层中,问题拆分的复杂度是O(1)*O*(1),这是因为我们只是单纯分解,并没有枚举或者移动元素,唯一的操作仅是计算位置的分界线。对于子段解的合并,其复杂度是O(n)O(n),因为对于每个子段,我们需要将其枚举每个位置进行填写。而如果我们同时考虑整层的操作,总枚举的范围就是整个数组的范围。
那么一共有多少层呢?因为归并排序每次都是将序列平分,所以下一层子段的长度一定比上一层减少了一半,直到长度为1算法停止。所以整个算法有\log nlog*n*层。
所以归并排序的复杂度在任何情况下都是O(n\log n)O(nlogn)。
编程实践:
计数排序:
思路:
- 计数排序的基本思想是通过统计序列中不同的值出现的次数来排序。因为要用数组统计个数,所以要求在计数排序之前,整个序列中的元素需转换成在很小范围
[0..K]
的非负整数。 - 计数排序的算法描述:
- 统计原序列中每个值的出现次数,记为
cnt
数组。- 从小到大枚举值的范围,对
cnt
数组求前缀和,记为sum
数组。- 从后往前枚举每个元素
a[i]
,分配其在答案中的位置idx[i]
为当前的sum[a[i]]
,也就是将其放在所有值等于a[i]
中的最后一个。并且将sum[a[i]]
减少1
,保证下次再遍历到同样的值时,它分配的位置正好在idx[i]
前面一个。
代码:
#include <bits/stdc++.h>
#define N 1000005
#define K 1000001 // 假设非负整数最大元素范围为1000000
using namespace std;
int a[N], n, b[N];
int cnt[K];
int main() {
// 输入
cin >> n;
for (int i = 1; i <= n; ++i) {
cin >> a[i];
++cnt[a[i]]; // 这里通过计数数组cnt来维护每一种值出现的次数
}
// 维护最终有序序列
for (int i = 0, j = 0; i < K; ++i) // 枚举每一种值i,指针j用来枚举填充答案数组中的位置
for (int k = 1; k <= cnt[i]; ++k) // 根据该值出现的次数
b[++j] = i; // 添加对应个数的i到答案序列
// 输出
for (int i = 1; i <= n; ++i)
cout << b[i] << ' ';
cout << endl;
return 0;
}
其中:
- 在计数排序的输入部分,我们用
cnt
数组统计了每种值出现的个数。- 在维护最终有序序列的部分,我们按照值从小到大的顺序,放置相应
cnt
个元素到答案数组里。
- 上述计数排序实现方法的时间和空间复杂度都是O(n+K)*O*(*n*+*K*)。正因为它不是基于比较的排序,所以才能达到比O(n\log n)O(nlogn)更好的时间复杂度。
- 计数排序的基本思想还可以拓展成桶排序和基数排序。使用桶排序和基数排序的,可以对更大范围内的,甚至不是整数的序列进行排序。
复杂度分析:
编程实践:
据该值出现的次数
b[++j] = i; // 添加对应个数的i到答案序列
// 输出
for (int i = 1; i <= n; ++i)
cout << b[i] << ' ';
cout << endl;
return 0;
}
> 其中:
>
> - 在计数排序的输入部分,我们用`cnt`数组统计了每种值出现的个数。
> - 在维护最终有序序列的部分,我们按照值从小到大的顺序,放置相应`cnt`个元素到答案数组里。
- **上述计数排序实现方法的时间和空间复杂度都是O(n+K)\*O\*(\*n\*+\*K\*)**。正因为它不是基于比较的排序,所以才能达到比O(n\log n)*O*(*n*log*n*)更好的时间复杂度。
- **计数排序**的基本思想还可以拓展成[**桶排序**](https://www.runoob.com/w3cnote/bucket-sort.html)和[**基数排序**](https://www.runoob.com/w3cnote/radix-sort.html)。使用桶排序和基数排序的,可以对更大范围内的,甚至不是整数的序列进行排序。
复杂度分析:
编程实践:
```c++