目录
以升序举例
冒泡排序
排序的入门方法
思想:进行n-1轮比较,每一轮都两两比较相邻关键字,如果不满足升序,就进行交换,每一轮结束后都可以得到第k小的数字
n 个数字,需要(n-1)轮排序,第i轮排序,需要(n-i)次比较
冒泡排序的最好、最坏、平均 时间复杂度为, 空间复杂度为, 因为交换的时候需要一个临时变量
代码实现
template<typename T>
void Sort<T>::bubble_sort(vector<T> v)
{
cout<<"------Bubble Sort--------"<<endl;
cout<<"befor: ";
myPrint(v);
int length = v.size();
for(int i=0; i<length-1;++i)
{
for(int j=i+1;j<length;++j)
{
if(v[i]>v[j])
{
T temp = v[i];
v[i] = v[j];
v[j] = temp;
}
}
}
cout<<"after: ";
myPrint(v);
}
优化
上述排序方法,在数组本身已经大部分有序的时候,比较浪费时间。因为冒泡排序还是会把那些都两两比较,不过就是比较之后发现不满足条件而不用交换。
eg. 1 2 4 5 6 8
加粗部分表示经过两轮排序得到了最大和次大。 仔细观察发现这个时候前面的数字也都已经是按顺序排好的了,已经不需要再进行第3、4、5轮的冒泡了
思想:增加一个标记变量flag,该标记记录在一轮循环中是否发生过数据交换,如果没有,说明此是已经时按顺序排好,则不需要下一轮的比较了。
此时,在最好情况下,只需要比较 一轮,即n-1次,就可以结束排序,时间复杂度为
不过在最坏情况下,还是和优化前一样,时间复杂度为, 空间复杂度为。
template<typename T>
void Sort<T>::bubbleSort_Adv(vector<T> v)
{
cout<<"------Bubble Sort--------"<<endl;
cout<<"befor: ";
myPrint(v);
int length = v.size();
int flag = 1;
for(int i=0; i<length-1 && flag;++i){
flag = 0;
for(int j=i+1;j<length;++j){
if(v[i]>v[j]){
flag = 1;
mySort(v[i],v[j]);
}
}
}
cout<<"after: ";
myPrint(v);
}
选择排序
和冒泡排序有点类似,但有个最本质的区别就是:冒泡排序是相邻两元素两两比较,不满足升序就交换(交换次数很多), 而选择排序是想要减少交换次数,每一轮比较记录较小值(升序)的下标,等一轮结束之后,再进行交换,以此减少交换次数。
n-1轮比较,每一轮从n-i-1个记录种选出关键字最大或最小的记录,并和第i个记录交换关键值。
特点:数据交换的次数相当少,但无论最好还是最差的情况,需要比较的次数一样
最好、最坏、平均 时间复杂度 , 空间复杂度
存在的问题:与冒泡一样,需要一直比较,非常耗时
代码实现
template<typename T>
void Sort<T>::selectSort(vector<T> v)
{
cout<<"------Select Sort--------"<<endl;
cout<<"befor: ";
myPrint(v);
int length = v.size();
for(int i=0;i<length;++i)
{
int minIdex = i; //存放当前轮最小值的下标
for (int j = i+1; j < length; ++j)
{
if(v[minIdex]>v[j])
minIdex=j;
}
if(minIdex!=i)
mySwap(v[i],v[minIdex]);
}
cout<<"after: ";
myPrint(v);
}
优化
堆排序
直接插入排序
联想 -- 打扑克整牌
新来一张牌,根据大小,插入到手里已经排好序的扑克里面。
插入的方法就是:从后往前,如果这张扑克比要插入的扑克大,就往后挪一个,直到找到新来扑克的位置,把最新的扑克放进去为止。
基本思想
取无序表中的一个元素,在已经排好的有序表中从后向前扫描,找到它的位置并插进去,使有序表依然有序。从而得到新的、元素数增1的有序表。
将待排序序列的第一个元素看做一个有序序列,从数组的第二个元素开始,将数组中的每一个元素按照(升序或者降序)规则插入到已排好序的数组中以达到排序的目的. 一般情况下将数组的第一个元素作为起始元素,从第二个元素开始依次插入。由于要插入到的数组是已经排好序的,所以只要从右向左(或者从后向前)找到排序插入点插入元素,以此类推,直到将最后一个数组元素插入到数组中,整个排序过程完成。
特点: 稳定排序、小规模数据或数据基本有序时效率比较高
n个元素,比较n-1轮,第i轮,即arr[i](第i+1个) ,最多比较i次找到插入位置
平均、最坏时间复杂度
最好时间复杂度
空间复杂度
Note:尽管插入排序的时间复杂度也是O(n²),但一般情况下,插入排序会比冒泡排序快一倍,要比选择排序还要快一点。
代码实现
template<typename T>
void Sort<T>::insertSort(vector<T> v)
{
cout<<"------Insert Sort--------"<<endl;
cout<<"befor: ";
myPrint(v);
int length = v.size();
for(int i=1;i<length;++i)
{
int temp = v[i]; //要记录下新扑克的值 !!!!
int j=i-1; //有序部分的最后一个位置
while(j>=0 && v[j]>temp)
{
v[j+1] = v[j];
--j;
}
v[j+1] = temp; //把新扑克插入它应该的位置
}
cout<<"after: ";
myPrint(v);
}
优化
存在的问题:
直接插入排序每次往前插入时,是按顺序依次往前查找,数据量较大时,必然比较耗时,效率低。
改进思路:
在往前找合适的插入位置时采用二分查找的方式,即折半插入。减少比较次数
二分插入排序
(Binary Insert Sort)
排序是稳定的,但排序的比较次数与初始序列无关
- 先折半查找元素的应该插入的位置,然后统一移动应该移动的元素,再将这个元素插入到正确的位置。
- 优点 : 稳定,相对于直接插入排序元素减少了比较次数;
- 缺点 : 相对于直接插入排序元素的移动次数不变;
- 时间复杂度: 折半插入排序减少了比较元素的次数,约为O(nlogn),比较的次数取决于表的元素个数n。因此 二分插入排序的时间复杂度是O(N^2)
- 为什么二分查找还是O(N^2)呢?因为不管是二分插入还是折半插入,大头都在遍历和元素的后移上,二分查找只能在查找位置上节约时间。
一共有n个元素,假设现在已经插入L个元素了。 从剩下的元素中拿出一个元素x,往这L个元素中进行插入。 |位置1|元素1|位置2|元素2|……|位置L|元素L|位置L+1| 一共有L+1个位置,由于x的值是随机的,所以x会随机放入这L+1个位置中的某一个位置。所以,x位置的期望值为 (L+1)/2。 但是,要想找到x的具体位置,也是需要计算的,这个过程的相当于一个二分查找,时间复杂度为。 找到之后,需要将该位置及往后的元素全部都往后移一个单位。这个过程平均要移动L/2个元素。 因此,移动位置的平均时间复杂度为O(L/2)。 算法整体计算次数为 i=0…n-1 求和。 故,算法的时间复杂度为 O(n^2)
- 空间复杂度: 二分插入排序的空间复杂度是O(1),因为移动元素是需要一个防止被覆盖的临时变量。
- 稳定性: 二分插入排序是稳定的算法,它满足稳定算法的定义:假设在数列中存在a[i]=a[j],若在排序之前,a[i]在a[j]前面;并且排序之后,a[i]仍然在a[j]前面。则这个排序算法是稳定的!
代码实现
void Insertion_Sort(int *arr, int len)
{
int tmp,mid;
for(int i=1;i<len ;i++)
{
int left=0;
int right =i-1; //置查找区间初值
tmp = arr[i]; //将待插入的记录暂存到监视哨中
while(left <= right) //!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
//在r[left..right]中折半查找插入的位置,就是第一个大于待插入值的元素的位置,以此保证排序的稳定性
{
mid = (left+right)/2;
if(arr[mid]>tmp)
right = mid-1; //插入点在前一子表
else
left = mid+1; //插入点在后一子表
}
{
for(int j=i-1;j>=left;j--) //或 for(int j=i-1;j>right;j--)
{
arr[j+1] = arr[j];
}
arr[left] = tmp;
}
}
}
希尔排序
shell sort , 又称:缩小增量排序。 是首个打破复杂度的排序算法。
本质就是分组插入排序
!!! 不稳定排序 分组,跳跃性插入,会破坏稳定性
平均时间复杂度O(nlogn)$还是O(n^{1.3-2}) ?????
空间复杂度O(1)
直接插入排序的优化版,由于直接插入排序只适用于小规模数据或基本有序的数据,并且插入排序每次只能移动一个数据,所以对此进行改进,先把数据调整成基本有序
(注:为方便记忆算法,将其记作“三层for循环+if” ------** for(for(for(if)))**)
基本思想
将整个待排序的序列分成若干个子序列(相隔某个"增量"的元素为一组),再每个子序列内分别进行直接插入排序,然后再减小"增量"大小,再次排序,直至增量减为1,这个时候就是整个序列是一组,并且已经是基本有序的状态,然后再最后对所有元素进行直接插入排序。
这样保证每次进行直接插入排序时序列都满足基本有序或小规模,提高了排序效率。
增量gap的确定准则之一:
int gap = 1;
while(gap < length/2)
h = 2*h+1;
//循环结束后我们就可以确定gap的最大值
//gap的减小规则
gap = gap/2;
也可以直接就从length/2开始。
代码实现
template<typename T>
void Sort<T>::ShellSort(vector<T> v)
{
cout << "------Shell Sort--------" << endl;
cout << "befor: ";
myPrint(v);
int length = v.size();
//确定增量gap的初始值
int gap = 1;
while (gap < length / 2)
gap = 2 * gap + 1;
//最外层循环:增量变化,最小增量为1
for (; gap >= 1; gap /= 2) //while(gap>=1)
{
//下标为gap的元素就是无序部分的第一个
for (int i = gap; i < length; ++i)
{
int temp = v[i];
int j = i - gap;
//移位置
for (; v[j]>temp && j >= 0; j -= gap) //!!!注意这里的判断条件
v[j + gap] = v[j];
v[j + gap] = temp;
}
}
cout << "after: ";
myPrint(v);
}
算法复杂度分析
它适合于数据量在5000以下并且速度并不是特别重要的场合。它对于数据量较小的数列重复排序是非常好的。
时间复杂度 O(n^(1.3—2)), 一般认为就是亚于平方的
空间复杂度:O(1)
『希尔排序的时间复杂度与增量序列的选取有关系』
『Hibbard增量序列:1,4,7,…,2k-1。这个增量的特点是增量没有公因子。使用Hibbard增量的希尔排序的最坏情形运行时间为O(N^{3/2})。』
堆排序
堆排序是简单选择排序的一种改进。 一种选择排序
不稳定排序
升序采用大顶堆、降序采用小顶堆
基本思想
堆排序是利用堆进行排序的方法
- 将待排序的序列构造成一个大顶堆,此堆为初始的无序区
- 将顶堆arr[1]和arr[n]交换,此时,最大值就处于最后一个位置,处于有序的状态
- 交换后可能会使无序部分违反堆的性质,因此接下来需要对无序部分(arr[1] ... arr[n-1])调整为新堆
- 重复2、3步,直到完成最后两个元素的排序
图解
设有一个无序序列 { 1, 3, 4, 5, 2, 6, 9, 7, 8, 0 }。
构造了初始堆后,我们来看一下完整的堆排序处理:
还是针对前面提到的无序序列 { 1, 3, 4, 5, 2, 6, 9, 7, 8, 0 } 来加以说明。
堆排序是基于完全二叉树实现的,在将一个数组调整成一个堆的时候,关键之一的是确定最后一个非叶子节点的序号,这个序号为n/2-1,n为数组的长度。
上一条结论在认为根结点的下标为0时成立,如果下标从1开始,因此,第一个非叶子结点的下标为 ⌊arr.length/2⌋
但是为什么呢?
可以分两种情形考虑:
①堆的最后一个非叶子节点若只有左孩子
②堆的最后一个非叶子节点有左右两个孩子
完全二叉树的性质之一是:如果节点序号为i,在它的左孩子序号为2*i+1,右孩子序号为2*i+2。
对于①左孩子的序号为n-1,则n-1=2*i-1,推出i=n/2-1;
对于②左孩子的序号为n-2,在n-2=2*i-1,推出i=(n-1)/2-1;右孩子的序号为n-1,则n-1=2*i+2,推出i=(n-1)/2-1;
很显然,当完全二叉树最后一个节点是其父节点的左孩子时,树的节点数为偶数;当完全二叉树最后一个节点是其父节点的右孩子时,树的节点数为奇数。
根据java语法的特征,整数除不尽时向下取整,则若n为奇数时(n-1)/2-1=n/2-1。
因此对于②最后一个非叶子节点的序号也是n/2-1。
得证。
显然序号是从0开始的。
代码实现
template<typename T>
void Sort<T>::HeapSort(vector<T> &v)
{
cout << "------Heap Sort--------" << endl;
cout << "befor: ";
myPrint(v);
int length = v.size();
//1. 先把数组调整成大根堆的对应位置
for(int i=0;i<length;++i)
HeapAdjust(v,length,i);
//2. 调整
for(int i=length-1;i>=0;--i)
{
//2.1 大根堆的堆顶就是最大值,先把它交换到无序的最后一个位置,现在它就是有序的了
mySwap(v[i],v[0]);
//2.2 由于上一步的交换,可能会导致堆顶往下不满足大根堆的条件,因此要从这个节点进行调整
HeapAdjust(v,i,0);
}
cout << "after: ";
myPrint(v);
}
template<typename T>
void Sort<T>::HeapAdjust(vector<T> &v, int length, int index)
{
int temp = v[index];
for (int j = 2 * index + 1; j < length; j = 2 * j + 1)
{
if (j + 1 < length && v[j + 1] > v[j])
++j;
if (v[j] <= temp)
break;
v[index] = v[j];
index = j;
}
v[index] = temp;
}
建堆的时间复杂度为
调整堆的时间复杂度为
首先借用《大话数据结构》的描述:
在正式排序时,第i次取堆顶记录重建堆需要用O(logi)的时间(完全二叉树的某个结点到根结点的距离为$log_2i+1$), 并且需要取n-1次堆顶记录,因此,重建堆的时间复杂度为O(nlogn)
从堆调整的代码可以看到是当前节点与其子节点比较两次,交换一次。父节点与哪一个子节点进行交换,就对该子节点递归进行此操作,设对调整的时间复杂度为T(k)(k为该层节点到叶节点的距离),那么有 T(k)=T(k-1)+3, k∈[2,h] T(1)=3 迭代法计算结果为: T(h)=3h=3floor(log n) 所以堆调整的时间复杂度是O(log n) 。
归并排序
典型的分治思想。
先把大数组划分成小数组,直至每个数组中只有一个元素,此时,每个小数组分别是有序的,接下来,再两两合并小数组,合并成多个有序的小数组,再继续合并,合并的同时并进行排序,当小数组合并成一个大数组的时候,就结束了。
基本思想
步骤
-
分解:将n个待排序元素组成的序列划分成具有n/2个元素的两个子序列
-
解决:使用归并排序递归地对两个子序列排序
-
合并:将排序好的两个子序列合并,产生一个已经排好序的子序列,直至最后得到最终的排序序列
- 作为一种典型的分而治之思想的算法应用,归并排序的实现分为两种方法:
- 自上而下的递归;
- 自下而上的迭代;
特点
稳定的排序算法。(归并排序严格遵循从左到右或从右到左的顺序合并子数据序列, 它不会改变相同数据之间的相对顺序, 因此归并排序是一种稳定的排序算法.)
最优时间复杂度、平均时间复杂度和最坏时间复杂度均为O(nlogn) 。
空间复杂度为 O(n)。因为需要辅助数组存放合并后有序的新数组
图解
代码实现
template<typename T>
void Sort<T>::MergeSort(vector<T> &v) {
cout << "------Merge Sort--------" << endl;
cout << "befor: ";
myPrint(v);
//因为要分段处理,所以这里要有边界信息
MergeSortCore(v,0,v.size()-1);
cout << "after: ";
myPrint(v);
}
template<typename T>
void Sort<T>::MergeSortCore(vector<T> &v, int left, int right) {
//传入的边界信息必须要有效 即left<right
if(left<right)
{
//计算中间索引,以便将当前数组分隔成两个小数组
int mid = left+(right-left)/2;
//再继续往下分隔左边小数组
MergeSortCore(v,left,mid);
//分割右边小数组
MergeSortCore(v,mid+1,right);
//合并小数组
Merge(v,left,mid,right);
}
}
//传入边界信息,进行两个小数组的合并
template<typename T>
void Sort<T>::Merge(vector<T> &v, int left, int mid, int right){
//定义辅助数组,存放合并后有序的新数组
vector<T> temp(right-left+1);
//灵魂:三个指针完成合并
int pLeft=left,pRight=mid+1,pNew=0;
while(pLeft<=mid && pRight<=right)
{
temp[pNew++] = v[pLeft]<v[pRight]?v[pLeft++]:v[pRight++];
}
while(pLeft<=mid)
temp[pNew++] = v[pLeft++];
while(pRight<=right)
temp[pNew++] = v[pRight++];
//更新原数组
for(int i=left;i<=right;++i)
v[i] = temp[i-left];
}
复杂度分析
快速排序
冒泡算法的升级
基本思想
从待排序数组中找到一个枢轴(pviot), 将原序列经过交换操作分成两部分,使得枢轴左边的值都比它小,右边的值都比它大。
然后再递归对左子数组和右子数组(不包括枢轴)进行同样操作,最终使得整个序列有序。
- 要注意,枢轴将待排序序列分为两个子序列,但这两个子序列的长度并不一定,也就是说枢轴并不是就是在序列正中间。因为取的是第一个数为枢轴,因此它的大小决定了它的位置,如果它是整个序列的最小值,则无左序列,因为没有比它更小的值了。
- 递归到最底部的判断条件是数列的大小是零或一,此时该数列显然已经有序
代码实现
template<typename T>
void Sort<T>::QuickSort(vector<T> &v) {
cout << "------Quick Sort--------" << endl;
cout << "befor: ";
myPrint(v);
QuickSortCore(v,0,v.size()-1);
//QuickSortCore02(v,0,v.size()-1); //把两个函数合并成一个
cout << "after: ";
myPrint(v);
}
//方法1:
//对数组v中从索引left到right索引处的元素进行分组
template<typename T>
void Sort<T>::QuickSortCore(vector<T> &v, int left, int right){
//边界安全性校验
if(left<right)
{
//找到枢轴的位置
int position = QuickSort_Partion(v,left,right);
//递归处理枢轴左边
QuickSortCore(v,left,position-1);
//递归处理枢轴右边
QuickSortCore(v,position+1,right);
}
}
template<typename T>
int Sort<T>::QuickSort_Partion(vector<T> &v, int left, int right){
//取第一个数作为枢轴,也可以取最后一个,或前中后的中间值,以保证能够通过枢轴将数组分成均匀长度的两个小数组
int pviot = v[left];
int pL = left;
int pR = right;
while(pL<pR)
{
while(pR>pL && v[pR]>=pviot)
pR--;
while(pL<pR && v[pL]<=pviot)
pL++;
if(pL<pR)
mySwap(v[pL],v[pR]);
}
//pL就是枢轴应该再的位置索引,v[pL]是小于枢轴的元素中的最后一个
//将v[pL]和v[left]进行交换,就是把枢轴交换到它应该在的位置
mySwap(v[pL],v[left]);
return pL;
}
//方法2: 和1相同
template<typename T>
void Sort<T>::QuickSortCore02(vector<T> &v, int left, int right){
if(left<right)
{
int pviot = v[left];
int pL = left;
int pR = right;
while(pL<pR)
{
while(pR>pL && v[pR]>=pviot)
pR--;
while(pL<pR && v[pL]<=pviot)
pL++;
if(pL<pR)
mySwap(v[pL],v[pR]);
}
mySwap(v[pL],v[left]);
QuickSortCore02(v,left,pL-1);
QuickSortCore02(v,pL+1,right);
}
}
//方法3
// 这里不采用交换,挖坑法,减少交换次数
template<typename T>
void Sort<T>::QuickSortCore03(vector<T> &v, int left, int right){
if(left<right)
{
int pviot = v[left];
int pL = left;
int pR = right;
while(pL<pR)
{
while(pR>pL && v[pR]>=pviot)
pR--;
v[pL]=v[pR];
while(pL<pR && v[pL]<=pviot)
pL++;
v[pR]=v[pL];
}
v[pL]=pviot;
QuickSortCore02(v,left,pL-1);
QuickSortCore02(v,pL+1,right);
}
}
复杂度分析
快速排序和归并排序的区别
稳定性分析
排序算法的选择
如果只有一次排序,就尽量选择高性能的排序算法
如果有多次,且对稳定性有要求,尽量选择稳定性的排序算法