1.插入排序(Insertion Sort)
插入排序其实就是借助这样的思想,首先我们将数组中的数据分为两个区间,一个是已排序区间,另一个是未排序区间,同时这两个区间都是动态的。开始时,假设最左侧的元素已被排序,即为已排序区间,每一次将未排序区间的首个数据放入排序好的区间中,直达未排序空间为空。
#include<iostream>
#include<vector>
using namespace std;
void InsertionSort(vector<int>&, int);
int main() {
vector<int> test = { 3, 7, 6, 4, 5, 1, 2, 8 };
InsertionSort(test, test.size());
for (auto x : test)
cout << x << " ";
return 0;
}
void InsertionSort(vector<int>& arr, int len) {
for (int i = 1; i < len; ++i) { //注意i从1开始
int key = arr[i]; //需要插入的元素
int j = i - 1; //已排序区间
while ((j >= 0) && (arr[j] > key)) {
arr[j + 1] = arr[j]; //元素向后移动
j--;
}
arr[j + 1] = key;
}
}
算法分析
最好情况: 即该数据已经有序,我们不需要移动任何元素。于是我们需要从头到尾遍历整个数组中的元素O(n).
最坏情况: 即数组中的元素刚好是倒序的,每次插入时都需要和已排序区间中所有元素进行比较,并移动元素。因此最坏情况下的时间复杂度是O(n^2).
平均时间复杂度:类似我们在一个数组中插入一个元素那样,该算法的平均时间复杂度为O(n^2).
稳定性:其实,我们在插入的过程中,如果遇到相同的元素,我们可以选择将其插入到之前元素的前面也可以选择插入到后面。所以,插入排序可以是稳定的也可能是不稳定的。
2.选择排序(Selection Sort)
选择排序和插入排序类似,也将数组分为已排序和未排序两个区间。但是在选择排序的实现过程中,不会发生元素的移动,而是直接进行元素的交换。
选择排序的实现过程: 在不断未排序的区间中找到最小的元素,将其放入已排序区间的尾部。
#include<iostream>
#include<vector>
using namespace std;
void SelectionSort(vector<int>&);
int main() {
vector<int> test = { 3, 7, 6, 4, 5, 1, 2, 8 };
SelectionSort(test);
for (auto x : test)
cout << x << " ";
return 0;
}
void SelectionSort(vector<int>& arr) {
for (int i = 0; i < arr.size()-1; i++) {
int min = i;
for (int j = i + 1; j < arr.size(); j++)
if (arr[j] < arr[min]) min = j;
swap(arr[i], arr[min]);
}
}
最好情况,最坏情况:都需要遍历未排序区间,找到最小元素。所以都为O(n2).因此,平均复杂度也为O(n2).
稳定性:答案是否定的,因为每次都要在未排序区间找到最小的值和前面的元素进行交换,这样如果遇到相同的元素,会使他们的顺序发生交换。
3.冒泡排序(Bubble Sort)
冒泡排序和插入排序和选择排序不太一样。冒泡排序每次只对相邻两个元素进行操作。每次冒泡操作,都会比较相邻两个元素的大小,若不满足排序要求,就将它俩交换。每一次冒泡,会将一个元素移动到它相应的位置,该元素就是未排序元素中最大的元素。
#include<iostream>
#include<vector>
using namespace std;
void BubbleSort(vector<int>&);
int main() {
vector<int> test = { 3, 7, 6, 4, 5, 1, 2, 8 };
BubbleSort(test);
for (auto x : test)
cout << x << " ";
return 0;
}
void BubbleSort(vector<int>& arr) {
for (int i = 0; i < arr.size() - 1; i++)
for (int j = 0; j < arr.size() - i - 1; j++)
if (arr[j] > arr[j+1])
swap(arr[j], arr[j+1]);
}
冒泡排序的递归实现
如果我们仔细观察冒泡排序算法,我们会注意到在第一次冒泡中,我们已经将最大的元素移到末尾。在第二次冒泡中,我们将第二大元素移至倒数第二个位置,然后以此类推,所以很容易想到利用递归来实现冒泡排序。
递归思路:
如果数组大小为1,则直接返回。
每进行一次冒泡排序,可修复当前数组的最后一个元素。
每完成一次冒泡操作,对剩下未排序所有元素进行递归冒泡。
冒泡排序递归代码实现:
#include<iostream>
#include<vector>
using namespace std;
void Recursive_BubbleSort(vector<int>&, int);
int main() {
vector<int> test = { 3, 7, 6, 4, 5, 1, 2, 8 };
Recursive_BubbleSort(test,test.size());
for (auto x : test)
cout << x << " ";
return 0;
}
void Recursive_BubbleSort(vector<int>& arr, int n) {
if (n == 1) return;
for (int i = 0; i < arr.size() - 1; i++) {
if (arr[i] > arr[i + 1])
swap(arr[i], arr[i + 1]);
}
Recursive_BubbleSort(arr, n - 1);
}
算法分析:
最好情况:我们只需要进行一次冒泡操作,没有任何元素发生交换,此时就可以结束程序,所以最好情况时间复杂度是O(n).
最坏情况: 要排序的数据完全倒序排列的,我们需要进行n次冒泡操作,每次冒泡时间复杂度为O(n),所以最坏情况时间复杂度为O(n^2)。
平均复杂度:O(n^2)
稳定性:在冒泡排序的过程中,只有每一次冒泡操作才会交换两个元素的顺序。所以我们为了冒泡排序的稳定性,在元素相等的情况下,我们不予交换,此时冒泡排序即为稳定的排序算法。
4.归并排序(Merge Sort)-分治的思想
运用递归法实现归并操作的主要步骤:
- 申请空间,使其大小为两个已经排序序列之和,该空间用来存放合并后的序列。
- 设定两个指针,最初位置分别为两个已经排序序列的起始位置。
- 比较两个指针所指向的元素,选择较小的元素放入到合并空间,并将指针移动到下一位置。
- 重复步骤3直到某一指针到达序列尾,然后将另一序列剩下的所有元素直接复制到合并序列尾
#include <stdio.h>
#include <stdlib.h>
long long int a[100005], b[100005];
long long int sum = 0;
void sort(long long int l1, long long int r1, long long int l2, long long int r2)
{
long long int top = 0;
long long int i = l1, j = l2;
while(i <= r1 && j <= r2)
{
if(a[i] > a[j])
{
sum+=(r1-i+1);//求逆序对
b[top++] = a[j++];
}
else
{
b[top++] = a[i++];
}
}
while(i <= r1)b[top++] = a[i++];
while(j <= r2)b[top++] = a[j++];
for(i = l1, j = 0; j < top; j++, i++)
{
a[i] = b[j];
}
return;
}
void de(long long int l, long long int r)
{
if(l >= r)return;
long long int m = (l + r) / 2;
de(l, m);
de(m + 1, r);
sort(l, m, m + 1, r);
return;
}
int main()
{
long long int n, i;
scanf("%lld", &n);
for(i = 0; i < n; i++)scanf("%lld", &a[i]);
de(0, n - 1);
printf("%lld\n",sum);
return 0;
}
算法分析:
时间复杂度为O(nlongn)
归并排序是原地排序吗?
从原理中可以看出,在归并排序过程中我们需要分配临时数组temp,所以不是原地排序算法,空间复杂度为O(n).
归并排序是稳定的排序算法吗?
当我们遇到左右数组中的元素相同时,我们可以先把左边的元素放入temp数组中,再放入右边数组的元素,这样就保证了相同元素的前后顺序不发生改变。所以,归并排序是一个稳定的排序算法。
5.快速排序(Quicksort)
快速排序,也就是我们常说的“快排”。其实,快排也是利用的分治思想。它具体的做法是在数组中取一个基准pivot,pivot位置可以随机选择(一般我们选择数组中的最后一个元素)。选择完pivot之后,将小于pivot的所有元素放在pivot左边,将大于pivot的所有元素放在右边。最终,pivot左侧元素都将小于右侧元素。接下来我们递归将左侧的子数组和右侧子数组进行快速排序。如果左右两侧的数组都是有序的话,那么我们的整个数组就处于有序的状态了。
//快速排序(从小到大)
void quickSort(int left, int right, vector<int>& arr)
{
if(left >= right)
return;
int i, j, base, temp;
i = left, j = right;
base = arr[left]; //取最左边的数为基准数
while (i < j)
{
while (arr[j] >= base && i < j)
j--;
while (arr[i] <= base && i < j)
i++;
if(i < j)
{
temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
}
//基准数归位
arr[left] = arr[i];
arr[i] = base;
quickSort(left, i - 1, arr);//递归左边
quickSort(i + 1, right, arr);//递归右边
}
算法分析:
快速排序的时间复杂度?
快排的时间复杂度也可以像归并排序那样用递推公式计算出来。如果每次分区都刚好把数组分成两个大小一样的区间,那么它的时间复杂度也为O(nlogn).但是如果遇到最坏情况下,该算法可能退化成O(n^2).
快速排序是原地排序吗?
根据上述原理可以知道,快速排序也没有额外的内存消耗,故也是一种原地排序算法。
快速排序是稳定的排序算法吗?
因为分区操作涉及元素之间的交换(如下图),当遍历到第一个小于2的元素1时,会交换1与前面的3,因此两个相等3的顺序就发生了改变。所以快速排序不是一个稳定的排序算法。
6.计数排序(Counting Sort)
7.桶排序(Bucket Sort)
#include <stdio.h>
#include <stdlib.h>
int main()
{
int n,x,i,a[101];
scanf("%d",&n);
for(i=0; i<101; i++)a[i]=0;
for(i=0; i<n; i++)
{
scanf("%d",&x);
if(x>=100)a[100]++;
else a[x]++;
}
for(i=0; i<101; i++)
{
if(a[i]!=0)printf("%d %d\n",i,a[i]);
}
return 0;
}
桶排序思路比较简单,如果桶的数量等于数组元素的数量,那么桶排序就变成了计数排序。所以在代码中可以看到与计数排序相似的地方。
假设我们需要排序的数组元素有n个,同时用m个桶来存储我们的数据。那么平均每个桶的元素个数为k = n/m个.如果在桶内我们使用快速排序,那么时间复杂度为klogk,总的时间复杂度即为nlog(n/m).如果桶的数量接近元素的数量,桶排序的时间复杂度就是O(n) 了。但是如果运气不好,所有的元素都到了一个桶了,那么它的时间复杂度就退化成 O(nlogn) 了。
8.基数排序(Radix sort)
基数排序其实也是一个非比较型的整数排序算法,其原理是将整数按位切割成不同的数字,然后按每个位数分别比较。但是在计算机中字符串和浮点数也可以用整数表示,所以也可以用基数排序。
由此可见,基数排序是基于位数的比较,所以再处理一些位数较多的数字时基数排序就有明显的优势了。例如在给手机号排序,或者给一些较长的英语专业名词排序等。
基数排序的主要步骤:
找出数组中的最大值并求其位数bit
初始化一个数组count[],长度与当前数组所包含数字的进制相同。例如对于整数排序,数组长度应该是10。
运用计数排序的思想对数组进行排序,循环bit次
基数排序算法图解如下:
#include<iostream>
#include<vector>
using namespace std;
void radixsort(vector<int>&);
int maxbit(vector<int>);
int main() {
vector<int> test = { 77, 15, 31, 50, 8, 100, 24, 3, 43, 65 };
radixsort(test);
for (auto x : test)
cout << x << " ";
return 0;
}
int maxbit(vector<int> arr) //求数据的最大位数
{
int max = arr[0];
for (auto x : arr)
if (x > max)
max = x;
int bit = 1;
while (max >= 10)
{
max /= 10;
++bit;
}
return bit;
}
void radixsort(vector<int>& arr) //基数排序
{
int bit = maxbit(arr);
vector<int> tmp(arr.size());
vector<int> count(10); //0-9计数器
int i, j, k;
int radix = 1;
for (i = 1; i <= bit; i++) //进行bit次排序
{
for (j = 0; j < 10; j++)
count[j] = 0; //每次分配前清空计数器
for (j = 0; j < arr.size(); j++)
{
k = (arr[j] / radix) % 10;
count[k]++;
}
for (j = 1; j < 10; j++)
count[j] = count[j - 1] + count[j];
for (j = arr.size() - 1; j >= 0; j--)
{
k = (arr[j] / radix) % 10;
tmp[count[k] - 1] = arr[j];
count[k]--;
}
for (j = 0; j < arr.size(); j++)
arr[j] = tmp[j];
radix = radix * 10;
}
}
根据上面的讲解大家也可以很容易地看出,基数排序的时间复杂度是O(k*n),其中n是排序的元素个数,k是元素中最大元素的位数。因此,基数算法也是线性的时间复杂度,但是由于k取决于数字的位数,所以在某些情况下该算法不一定优于O(nlogn).
9.希尔排序(Shellsort)
希尔排序,也称递减增量排序算法,是插入排序的一种更高效的改进版本。
所以在具体讲解希尔排序之前,我们还是先来回顾一下插入排序的整个实现过程:
首先我们将数组中的数据分为两个区间,一个是已排序区间,另一个是未排序区间,同时这两个区间都是动态的,需要添加和移动元素。开始时,假设最左侧的一个元素已被排序,即为已排序区间,每一次将未排序区间的首个数据插入排序好的区间中,直达未排序空间为空
那么插入排序有哪些不足的地方呢?
在插入排序中,我们每次只交换两个相邻元素,当一个元素需要向前移动至它的正确位置时,只能一步一步地移动。因此插入排序的平均时间复杂度为O(n^2).
而希尔排序的想法是实现具有一定间隔元素之间的交换,即首先排序有一定间隔的元素,同时按顺序依次减小间隔,这样就可以让一个元素一次性地朝最终位置前进一大步,当间隔为1时就是插入排序了。
希尔排序算法步骤:
计算步长间隔值gap
将数组划分为这些子数组
按照插入排序思想进行排序
重复此过程,直到间隔为1,进行普通的插入排序。
希尔算法图解如下:
#include<iostream>
#include<vector>
#include<algorithm>
#include<queue>
using namespace std;
void shell_sort(vector<int>&);
int main() {
vector<int> test = { 40, 8, 2, 15, 37, 42, 11, 29, 24, 7 };
shell_sort(test);
for (auto x : test)
cout << x << " ";
return 0;
}
void shell_sort(vector<int>& arr) {
for (int gap = arr.size() / 2; gap > 0; gap /= 2) {
for (int i = gap; i < arr.size(); i++) {
int temp = arr[i];
int j;
for (j = i; j >= gap && arr[j - gap] > temp; j -= gap) {
arr[j] = arr[j - gap];
}
arr[j] = temp;
}
}
}
在希尔算法中,步长的选择尤其重要。在上面的代码中,最初的步长选择为n/2,并且不断对步长取半,直到最后为1.虽然这样可以比普通的插入排序O(n^2)更好,但是根据步长的选择不同,希尔排序的平均时间复杂度还可以更优。下图为不同步长序列选择下的最坏时间复杂度。
因为希尔排序在实现过程中没有分配额外的内存空间,所以是一种原地排序算法。但是由于希尔排序将数组分组并存在元素的交换,所以并不是一个稳定的排序算法。
10.堆排序(Heap sort)
堆一般具有下面两个性质:
堆总是一棵完全二叉树。(完全二叉树是指即除了最底层,其他层的节点都必须被元素填满,同时最底层的叶子节点必须全部。堆中的任意节点的值都必须大于等于或小于等于它的子节点。其中,将每个节点的值都大于等于其子节点值的堆称为“大顶堆”(max heap),将每个节点的值小于等于子节点值的堆称为“小顶堆”(min heap).
因为我们常用数组来存储完全二叉树,所以我们也可以用数组来存储堆。
堆在数组中的存储图如下:
- List item2*i+1为它的左子节点
- 2*i+2为它的右子节点
- i/2是它的前继节点
#include <stdio.h>
#include <stdlib.h>
int a[15];
void down(int a[],int i,int n)
{
int l=2*i;
int r=2*i+1;
int min=i;
if(l<=n)
{
if(l<=n&&a[l]<=a[min])min=l;
if(r<=n&&a[r]<=a[min])min=r;
if(i!=min)
{
int t=a[i];
a[i]=a[min];
a[min]=t;
down(a,min,n);
}
}
return ;
}
void creat(int a[],int n)
{
int i;
for(i=n/2;i>=1;i--)down(a,i,n);
return ;
}
void sort(int a[],int n)
{
if(n<1)return ;
creat(a,n);
int t=a[1];
a[1]=a[n];
a[n]=t;
sort(a,n-1);
return ;
}
int main()
{
int n,m;
while(scanf("%d%d",&n,&m)!=EOF)
{
int i,k;
for(i=1;i<=m;i++)scanf("%d",&a[i]);
creat(a,m);
for(i=m+1;i<=n;i++)
{
scanf("%d",&k);
if(a[1]<k)a[1]=k;
creat(a,m);
}
sort(a,m);
for(i=1;i<=m;i++)
{
if(i==1)printf("%d",a[i]);
else printf(" %d",a[i]);
}
printf("\n");
}
return 0;
}
最好最坏以及平均情况下的时间复杂度均为O(nlogn).
参考:https://segmentfault.com/a/1190000022049588?utm_source=tag-newest