算法概述
01.算法分类
十种常见排序算法可以分为两大类:
-
比较类排序:通过比较来决定元素间的相对次序,由于其时间复杂度不能突破O(nlogn),因此也称为非线性时间比较类排序。
-
非比较类排序:不通过比较来决定元素间的相对次序,它可以突破基于比较排序的时间下界,以线性时间运行,因此也称为线性时间非比较类排序。
02.相关概念
- 稳定:如果a原本在b前面,而a=b,排序之后a仍然在b的前面。
- 不稳定:如果a原本在b的前面,而a=b,排序之后 a 可能会出现在 b 的后面。
1.1 冒泡排序
概述
- 冒泡排序(Bubble sort)是一种交换排序,它的基本思想是:两两比较相邻元素的关键字,如果反序则交换,直到没有反序的记录(数据元素)为止。
- 时间复杂度为O(n^2),空间复杂度为O(1)
- 稳定
- 算法步骤
- 比较相邻的元素。如果第一个比第二个大,就交换它们两个;
- 对每一对相邻元素作同样的工作,从开始第一对到结尾的最后一对,这样在最后的元素应该会是最大的数;
- 针对所有的元素重复以上的步骤,除了最后一个;
- 重复步骤1~3,直到排序完成
代码
#include <iostream>
#include <vector>
using namespace std;
class Bubble {
public:
void mysort(vector<int> &nums) {
int n = nums.size();
for (int i = 0;i < n-1;i++) { //i从0开始,小于n-1;i代表比较几轮,每一轮比较完,当轮最大的数放到后面
for (int j = 0; j < n - i - 1; j++) { //j从0开始,小于n-1-i
//假设i为2,代表已经比较(0,1)两轮了,最后两个数确定了。倒数那个数最大
if (nums[j] > nums[j+1]) {
int temp = nums[j];
nums[j] = nums[j+1];
nums[j+1] = temp;
}
}
}
}
};
int main() {
vector<int> vec = { 9,7,5,3,1,0,8,4,6,2 };
Bubble bubble;
cout << "排序前:" << endl;
for (auto& num : vec) {
cout << num << " " ;
}
cout << endl;
bubble.mysort(vec);
cout << "排序后:" << endl;
for (auto& num : vec) {
cout << num << " " ;
}
cout << endl;
return 0;
}
1.2 选择排序
概述
-
基本思想:首先在未排序序列中找到最小(大)元素,存放到排序序列的起始位置,然后,再从剩余未排序元素中继续寻找最小(大)元素,然后放到已排序序列的末尾。以此类推,直到所有元素均排序完毕。
-
时间复杂度为O(n^2),空间复杂度为O(1)
-
非稳定排序,适用于数据量少的情况
-
算法步骤:
- 首先通过n-1次关键字比较,从n个记录中找出关键字最小的记录,将它与第一个记录交换
- 再通过n-2次比较,从剩余的n-1个记录中找出关键字次小的记录,将它与第二个记录交换
- 重复上述操作,共进行n-1躺排序后,排序结束
代码
#include <iostream>
#include <vector>
using namespace std;
class Select {
public:
void mysort(vector<int>& nums) {
int n = nums.size();
for (int i = 0; i < n - 1; i++) { //i表示要交换的位置,先把最小的放到第一个,再把次小的放到第2
int k = i;
for (int j = i+1; j < n; j++) { //j表示i要比较的数,i从i+1开始比较
if (nums[k] > nums[j]) {
k = j; //记录最小值位置
}
}
if (k != i) { //如果k!=i,说明这一轮最小值的位置k,不是i。所以要交换
int temp = nums[i];
nums[i] = nums[k];
nums[k] = temp;
}
}
}
};
int main() {
vector<int> vec = { 9,7,5,3,1,0,8,4,6,2 };
Select select;
cout << "排序前:" << endl;
for (auto& num : vec) {
cout << num << " ";
}
cout << endl;
select.mysort(vec);
cout << "排序后:" << endl;
for (auto& num : vec) {
cout << num << " ";
}
cout << endl;
return 0;
}
1.3 插入排序
概述
-
插入排序(Insertion-Sort)的算法描述是一种简单直观的排序算法。它的工作原理是通过构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入。每一步将一个待排序的对象,按其关键字大小,插入到前面已经排好序的一组对象的适当位置,直到对象全部插入为止。(即边插入边排序,保证子序列中随时都是排好序的)
-
时间复杂度为O(n^2),空间复杂度为O(1),适用于基本有序的情况
-
算法步骤:
-
从第一个元素开始,该元素可以认为已经被排序;
-
取出下一个元素,在已经排序的元素序列中从后向前扫描;
-
如果该元素(已排序)大于新元素,将该元素移到下一位置;
-
重复步骤3,直到找到已排序的元素小于或者等于新元素的位置;
-
将新元素插入到该位置后;
-
重复步骤2~5。
-
代码
#include <iostream>
#include <vector>
using namespace std;
class Insert {
public:
void mysort(vector<int>& nums) {
int n = nums.size();
for (int i = 1; i < n; i++) {
if (nums[i] < nums[i - 1]) { //若"<",需将nums[i]插入有序字表
int temp = nums[i]; //复制为哨兵
int j=i-1;
while (j >= 0 && temp < nums[j]) { //j代表前面有序段的最后一个值
nums[j + 1] = nums[j]; //数组依次复制
j--;
}
nums[j + 1] = temp; //插入到正确位置,即nums[j]<temp,插入到j+1
}
}
}
};
int main() {
vector<int> vec = { 9,7,5,3,1,0,8,4,6,2 };
Insert insert;
cout << "排序前:" << endl;
for (auto& num : vec) {
cout << num << " ";
}
cout << endl;
insert.mysort(vec);
cout << "排序后:" << endl;
for (auto& num : vec) {
cout << num << " ";
}
cout << endl;
return 0;
}
1.4 希尔排序
概述
-
先将整个待排记录序列分割为若干个子序列,分别进行直接插入排序。待整个序列中的记录基本有序时,再对全体记录进行一次直接插入排序。(注意只是基本有序时,再对全体记录进行一次直接插入排序)
-
此时一定有同学开始疑惑了。这不对呀,比如我们现在的序列是{9,1,5,8,3,7,4,6,2},现在将它分成三组,{9,1,5},{8,3,7},{4,6,2},哪怕将它们各自排序排好了,变成{1,5,9},{3,7,8},{2,4,6},再合并它们成(1,5,9,3,7,8,2,4,6},此时,这个序列还是杂乱无序,谈不上基本有序,要排序还是重来一遍直接插入有序,这样做有用吗?需要强调一下,所谓的基本有序,就是小的关键字基本在前面,大的基本在后面,不大不小的基本在中间,像{2,1,3,6,4,7,5,8,9}这样可以称为基本有序了。但像(1,5,9,3,7,8.2,4,6}这样的9在第三位,2在倒数第三位就谈不上基本有序。问题其实也就在这里,我们分割待排序记录的目的是减少待排序记录的个数,并使整个序列向基本有序发展。而如上面这样分完组后就各自排序的方法达不到我们的要求。
-
因此,我们需要采取跳跃分割的策略;将相距某个“增量”的记录组成一个子序列,这样才能保证在子序列内分别进行直接插入排序后得到的结果是基本有序而不是局部有序。希尔排序的关键并不是随便分组后各自排序,而是将相隔某个”增量“的记录组成一个子序列,实现跳跃式移动。需要重点关注的是,增量序列最后的一个增量值必须等于1。
-
特点为:1)缩小增量;2)多遍插入排序
-
时间复杂度为O(nlogn),空间复杂度为O(1),非稳定排序。适用:数据量大
代码
#include <iostream>
#include <vector>
using namespace std;
class Shell {
public:
void mysort(vector<int>& nums) {
int n = nums.size();
int increment = n/3; //间隔为n/3,n/2也行。如果为n/3+1这种,考虑使用do-whlie(increment>1),因为increment不能为0
while (increment > 0) {
//while (increment > 0) 内就是进行一次插入排序,把i-1,+1都换成-increment,+increment
for (int i = increment; i < n; i++) { //从increment开始,依次++,这个++别变
if (nums[i] < nums[i - increment]) { //若"<",需将nums[i]插入有序字表
int temp = nums[i]; //复制为哨兵
int j = i - increment;
while (j >= 0 && temp < nums[j]) { //j代表前面有序段的最后一个值
nums[j + increment] = nums[j]; //数组依increment次复制
j-=increment;
}
nums[j + increment] = temp; //插入到正确位置,即nums[j]<temp,插入到j+1
}
}
increment = increment / 3;
}
}
};
int main() {
vector<int> vec = { 9,7,5,3,1,0,8,4,6,2 };
Shell shell;
cout << "排序前:" << endl;
for (auto& num : vec) {
cout << num << " ";
}
cout << endl;
shell.mysort(vec);
cout << "排序后:" << endl;
for (auto& num : vec) {
cout << num << " ";
}
cout << endl;
return 0;
}
1.5 归并排序
概述
-
基本思想:“归并”即合并,并入。递归思想。假设初始序列含有n个记录,则可以看成是n个有序的字序列,每个子序列长度为1,然后两两归并,得到(n/2)个长度为2或者1的有序子序列。再两两归并,如此重复,直到得到一个长度为n的有序序列为止,这种也称为2路归并排序。
-
归并排序是建立在归并操作上的一种有效的排序算法。该算法是采用分治法(Divide and Conquer)的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为2-路归并。
-
时间复杂度为O(nlogn)。因为需要一个与原始序列同样大小的辅助序列,故空间复杂度为O(n)、稳定
-
算法步骤:
-
把长度为n的输入序列分成两个长度为n/2的子序列;
-
对这两个子序列分别采用归并排序;
-
将两个排序好的子序列合并成一个最终的排序序列。
-
代码
#include <iostream>
#include <vector>
using namespace std;
class Merge {
public:
void mysort(vector<int>& nums,int left,int right) {
if (left < right) {
int mid = left + (right - left) / 2; /*将nums分为,nums[left~mid]和nums[mid+1~right]*/
mysort(nums, left, mid); /*递归地将nums[left,mid]归并为有序的nums[left,mid]*/
mysort(nums, mid+1, right); /*递归地将nums[left,mid]归并为有序的nums[left,mid]*/
merge(nums, left, right); /*将nums[left~mid]和nums[mid+1~right]归并为
nums[left~right]*/
}
}
void merge(vector<int>& nums, int left, int right) {
vector<int> temp(nums.size());
int mid = left + (right - left) / 2;
int p = left, k = left, q = mid+1;
while (p <= mid && q <= right) /*将nums中记录由小到大地并入temp*/
{
if (nums[p] < nums[q]) {
temp[k++] = nums[p++];
}
else {
temp[k++] = nums[q++];
}
}
while (p <= mid) { /*将剩下的nums[p~mid]复值到辅助数组*/ /*把剩下的9,10复制到*/
temp[k++] = nums[p++];
}
while (q <= right) {
temp[k++] = nums[q++]; /*将剩下的nums[q~right]复值到辅助数组*/
}
for (int i = left; i <=right; i++) {
nums[i] = temp[i]; /*将排好序的辅助数组复值到nums中*/
}
}
};
int main() {
vector<int> vec = { 10,9,7,5,3,1,0,8,4,6,2 };
Merge merge;
cout << "排序前:" << endl;
for (auto& num : vec) {
cout << num << " ";
}
cout << endl;
merge.mysort(vec,0,vec.size()-1);
cout << "排序后:" << endl;
for (auto& num : vec) {
cout << num << " ";
}
cout << endl;
return 0;
}
1.6 快速排序
概述
-
基本思想:1.任取一个元素(如:第一个)为中轴元素;2.所有比它小的元素放在它前面,所有比它小的元素放在它后面,形成左右两个字表;3.对各字表重新选择中心并依次规则调整(递归思想);4.直到每个字表元素只剩一个(递归的终止条件)
-
时间复杂度:为O(nlongn)空间复杂度:由于快速排序不是原地排序,由于递归调用栈的使用,最好情况递归树深度为log2n,其空间复杂度也为O(log2n),最坏情况需要n-1次调用,空间复杂度为O(n),平均空间复杂度为O(log2n)。不稳定
-
适用:输入次序乱的情况。因为有序情况,每次划分后有子序列长度为0,这时退化为没有改进措施的冒泡排序。
代码
#include <iostream>
#include <vector>
using namespace std;
class Quick {
public:
void mysort(vector<int>& nums, int left, int right) {
int pivot = 0;
if (left < right) {
pivot = partition(nums, left, right); /*将nums一分为2,算出中枢值pivot*/
mysort(nums, left, pivot-1); /*对低子表递归排序*/
mysort(nums, pivot + 1, right); /*对高子表递归排序*/
}
}
int partition(vector<int>& nums, int left, int right) {
/*交换子表中的记录,使中枢记录到位,并返回其位置,此时在它之前(后)均不大(小)于它*/
/*partition就是将选取的pivotKey不断交换,将比它小的换到它的左边,比它大的换到它的右边,在交换中也不断改变自己的位置,直到满足要求*/
int pivotKey = nums[left]; /*用子表的第一个记录为中枢*/
while (left < right) { /*从表的两端交替地向中间扫描*/
while (left < right && nums[right] >= pivotKey) right--;
nums[left] = nums[right]; /*将比中枢小的值交换到低端*/
while (left < right && nums[left] <= pivotKey) left++;
nums[right] = nums[left]; /*将比中枢大的值交换到高端*/
}
nums[left] = pivotKey;
return left; /*返回中枢位置*/
}
};
int main() {
vector<int> vec = { 5,9,7,10,3,1,0,8,4,6,2 };
Quick quick;
cout << "排序前:" << endl;
for (auto& num : vec) {
cout << num << " ";
}
cout << endl;
quick.mysort(vec, 0, vec.size() - 1);
cout << "排序后:" << endl;
for (auto& num : vec) {
cout << num << " ";
}
cout << endl;
return 0;
}
1.7 堆排序
概述
-
完全二叉树,层序遍历构建
-
基本思路:将待排序的序列构造成一个大顶堆。此时,序列的最大值就是堆顶的根节点。将它移走,然后将剩余的n-1个元素重新构造成堆,这样会得到n个元素的次大值。如此反复执行,便能得到一个有序序列了。
-
时间复杂度:它的运行时间主要消耗在初始构建堆和在重建堆时的反复筛选上。在构建堆的过程中,因为我们是完全二叉树从最下层最右边的非终端结点开始构建,将它与其孩子进行比较,若有必要进行互换,对于每个非终端结点来说,其实最多进行两次比较和互换操作,因此整个构建堆的时间复杂度为O(n)。
在正式排序时,一次重新堆化所需时间不超过O(logn),n-1次所需时间不超过O(nlogn)因此,重建堆的时间复杂度为O(nlogn)。由于堆排序对原始记录的排序状态并不敏感,因此它无论是最好、最坏和平均时间复杂度均为O(nlogn)。这在性能上显然要远远好过于冒泡、简单选择、直接插入的O(n^2)的时间复杂度了。 -
空间复杂度:,它只有一个用来交换的暂存单元,也非常的不错。不过由于记录的比较与交换是跳跃式进行的,因此堆排序也是一种不稳定的排序方法,
另外,由于初始构建堆所需的比较次数较多,因此,它并不适合待排序序列个数较少的情况。 -
稳定性:非稳定
代码
#include <iostream>
#include <vector>
using namespace std;
class Heap {
public:
void mysort(vector<int> &nums) {
int n = nums.size();
for (int i = (n - 1) / 2; i >= 0; i--) { /*将现在的序列构建为一个大顶堆*/
heapAdjust(nums, i, n-1);
}
for (int i = n - 1; i > 0; i--) {
int temp = nums[i];
nums[i] = nums[0];
nums[0] = temp; /*将堆顶记录和大顶堆的最后一记录交换*/
heapAdjust(nums, 0, i-1); /*将nums[0~i-1]重新调整为大顶堆*/
}
}
void heapAdjust(vector<int>& nums, int s, int m) {
int temp = nums[s];
//找到nums[s]最大的子节点,并与它交换
for (int j = 2 * s+1; j <= m; j = 2*j+1) { /*沿左节点向右筛选*/ /*这里相比二叉树性质两个式子都多加1,是因为数组从0开始,二叉树默认从1开始 */
if (j < m && nums[j] < nums[j + 1]) j++; /*一个节点只有两个子节点,所以用if就够了,不用while。j为关键字较大的记录的下标*/
if (temp >= nums[j]) break;
nums[s] = nums[j];
s = j;
}
nums[s] = temp; /*把temp值复制给上面的最大子节点的位置*/
}
};
int main() {
vector<int> vec = {9,7,5,3,1,0,8,4,6,2 };
Heap heap;
cout << "排序前:" << endl;
for (auto& num : vec) {
cout << num << " ";
}
cout << endl;
heap.mysort(vec);
cout << "排序后:" << endl;
for (auto& num : vec) {
cout << num << " ";
}
cout << endl;
return 0;
}
1.8 计数排序
概述
-
思路:借助足够大的辅助数组,把数字排在一个相对位置不会错的地方,最后并拢
-
特点:时间:O(n+k)、空间:O(n+k)——非原地
-
适用:max和min的差值不大,计数排序要求输入的数据必须是有确定范围的整数。
-
算法步骤:
-
找出待排序的数组中最大和最小的元素;
-
统计数组中每个值为i的元素出现的次数,存入数组C的第i项;
-
对所有的计数累加(从C中的第一个元素开始,每一项和前一项相加);
-
反向填充目标数组:将每个元素i放在新数组的第C(i)项,每放一个元素就将C(i)减去1。
-
代码
#include <iostream>
#include <vector>
#include <algorithm>
#include <string>
#include <cmath>
using namespace std;
class Count {
public:
void mysort(vector<int>& nums, int min,int max) {
vector<int> temp(max - min + 1);
for (int num : nums) {
temp[num - min]++;
}
int index = 0;
for (int i = 0; i < temp.size(); i++) {
int cnt = temp[i]; //cnt就是这个值出现多少次
while (cnt != 0) {
nums[index++] = i + min;
cnt--;
}
}
}
};
int main() {
vector<int> vec = { 10,9,7,5,3,2,8,4,6,2,15 };
Count count;
cout << "排序前:" << endl;
for (auto& num : vec) {
cout << num << " ";
}
cout << endl;
int minNum = *min_element(vec.begin(), vec.end());
int maxNum = *max_element(vec.begin(), vec.end());
count.mysort(vec,minNum,maxNum);
cout << "排序后:" << endl;
for (auto& num : vec) {
cout << num << " ";
}
cout << endl;
return 0;
}
1.9 桶排序
概述
-
桶排序是计数排序的升级版。它利用了函数的映射关系,高效与否的关键就在于这个映射函数的确定。桶排序 (Bucket sort)的工作的原理:假设输入数据服从均匀分布,将数据分到有限数量的桶里,每个桶再分别排序(有可能再使用别的排序算法或是以递归方式继续使用桶排序进行排)
-
思路:先粗略分类分桶,再各桶排序
-
特点:时间:O(n+k)、空间:O(n+k)——非原地
-
适用:均匀分布的数据
-
算法步骤:
- 设置一个定量的数组当作空桶;
- 遍历输入数据,并且把数据一个一个放到对应的桶里去;
- 对每个不是空的桶进行排序;
- 从不是空的桶里把排好序的数据拼接起来。
代码
#include <iostream>
#include <vector>
#include <string>
#include <list>
#include <algorithm>
#include <cmath>
using namespace std;
class Bucket {
public:
void mysort(vector<int>& nums) {
/*1.初始化桶*/
int n = nums.size();
vector<list<int>> buckets(n);
/*2.数据放入桶中并完成排序*/
for (int num : nums) {
int i = getBucketIndex(num);
insertSort(buckets[i], num);
}
/*3.从桶中取数据,放入nums中*/
int index = 0;
for (list<int> bucket : buckets) {
for (int num : bucket) {
nums[index++] = num;
}
}
}
/*辅助函数一:获得桶的序号*/
int getBucketIndex(int num) {
return num / 3;
}
/*辅助函数二:把数据插入对应桶(这里用插入排序简化步骤)*/
void insertSort(list<int>& bucket, int num) {
int n = bucket.size();
bool flag = true;
for (auto it = bucket.begin(); it != bucket.end(); it++) {
if (num <= *it) {
bucket.insert(it, num);
flag = false;
break;
}
}
if (flag) {
bucket.push_back(num);
}
}
};
int main() {
vector<int> vec = { 10,9,7,5,3,2,8,4,6,2,15 };
Bucket bucket;
cout << "排序前:" << endl;
for (auto& num : vec) {
cout << num << " ";
}
cout << endl;
bucket.mysort(vec);
cout << "排序后:" << endl;
for (auto& num : vec) {
cout << num << " ";
}
cout << endl;
return 0;
}
1.10 基数排序
概述
-
基本思想:分配+收集。
-
桶排序的一种。按数位分桶:从低位开始->分桶、收集->下一位…
-
特点:时间复杂度O(k*(n+m)),k为关键字个数,关键字取值范围为m个值(m也为桶的个数)
-
空间复杂度O(n+m)–非原地
-
稳定性:稳定
代码
#include <iostream>
#include <vector>
#include <algorithm>
#include <string>
#include <cmath>
using namespace std;
class Radix {
public:
void mysort(vector<int>& nums, int d) {
int p = 1;
int n = nums.size();
vector<vector<int>> buckets(10, vector<int>(n));
vector<int> order(10);
while (p < d) {
/*进行一轮分桶*/ /*假如最大的数为899,代表有三位,d则为1000(最小的四位)*/
for (int num : nums) {
int index = num / p % 10;
buckets[index][order[index]] = num; /*num放入index号桶的第order[index]位置*/
order[index]++; /*位置++*/
}
/*进行一轮排序*/
int k = 0;
for (int i = 0; i < 10; i++) {
if (order[i] == 0) continue;
for (int j = 0; j < order[i]; j++) {
nums[k] = buckets[i][j];
k++;
}
order[i] = 0; /*各桶计数器清零*/
}
p*=10;
}
}
};
int main() {
vector<int> vec = { 9,7,5,3,1,0,8,4,6,2 };
Radix radix;
cout << "排序前:" << endl;
for (auto& num : vec) {
cout << num << " ";
}
cout << endl;
/*确定vec数组的最大值,然后确定它的位数*/
int maxNum = *max_element(vec.begin(), vec.end());
int N = to_string(maxNum).size();
radix.mysort(vec,pow(10,N+1));
cout << "排序后:" << endl;
for (auto& num : vec) {
cout << num << " ";
}
cout << endl;
return 0;
}
算法总结
- 从算法的简单性来看,我们将7种算法分为以下两类。
- 简单算法:冒泡、简单选择、直接插入。
- 改进算法:希尔、堆、归并、快速。
- 从平均情况来看,显然最后3种改进算法要胜过希尔排序,并远远胜过前3种简单算法。从最好情况看,反而冒泡和直接插入排序要更胜一筹,也就是说,如果你的待排序序列总是基本有序,反而不应该考虑4种复杂的改进算法。
- 从最坏情况看,堆排序与归并排序又强过快速排序以及其他简单排序。
- 从这三组时间复杂度的数据对比中,我们可以得出这样一个认识。堆排序和归并排序就像两个参加奥数考试的优等生,心理素质强,发挥稳定。而快速排序像是很情绪化的天才,心情好时表现极佳,碰到较糟糕的环境会变得差强人意。但是他们如果都来比赛计算个位数的加减法,它们反而算不过成绩极普通的冒泡和直接插入。
- 从空间复杂度来说,归并排序强调要马跑得快,就得给马吃个饱。快速排序也有相应的空间要求,反而堆排序等却都是少量索取,大量付出,对空间要求是O(1)。如果执行算法的软件所处的环境非常在乎内存使用量的多少时,选择归并排序和快速排序就不是一个较好的决策了。
- 从稳定性来看,归并排序独占鳌头,我们前面也说过,对于非常在乎排序稳定性的应用中,归并排序是个好算法。
- 从待排序记录的个数上来说,待排序的个数n越小,采用简单排序方法越合适。反之,n越大,采用改进排序方法越合适。这也就是我们为什么对快速排序优化时,增加了一个阈值,低于阈值时换作直接插入排序的原因。
参考资料
[1].十种常见排序算法:https://blog.csdn.net/liyongchunscr/article/details/121350704
[2].大话数据结构
[3].王卓-《数据结构与算法基础》课程PPT