排序算法

目录

冒泡排序

代码实现

优化

选择排序

代码实现

优化

直接插入排序

基本思想

 代码实现

优化

二分插入排序

希尔排序

基本思想

代码实现

堆排序

基本思想

图解

代码实现

归并排序

基本思想

特点

图解

代码实现

复杂度分析

快速排序

基本思想

代码实现

 复杂度分析 

 快速排序和归并排序的区别

稳定性分析

排序算法的选择


 

以升序举例

冒泡排序

排序的入门方法

思想:进行n-1轮比较,每一轮都两两比较相邻关键字,如果不满足升序,就进行交换,每一轮结束后都可以得到第k小的数字

n 个数字,需要(n-1)轮排序,第i轮排序,需要(n-i)次比较

冒泡排序的最好、最坏、平均 时间复杂度为O(n^2), 空间复杂度为O(1), 因为交换的时候需要一个临时变量

代码实现

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次,就可以结束排序,时间复杂度为O(n)

不过在最坏情况下,还是和优化前一样,时间复杂度为O(n^2), 空间复杂度为O(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个记录交换关键值。

特点:数据交换的次数相当少,但无论最好还是最差的情况,需要比较的次数一样

           最好、最坏、平均 时间复杂度O(n^2)   ,  空间复杂度O(1)

存在的问题:与冒泡一样,需要一直比较,非常耗时

代码实现

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次找到插入位置

             平均、最坏时间复杂度 O(n^2)

             最好时间复杂度 O(n)

             空间复杂度 O(1)

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的具体位置,也是需要计算的,这个过程的相当于一个二分查找,时间复杂度为log_2N 找到之后,需要将该位置及往后的元素全部都往后移一个单位。这个过程平均要移动L/2个元素。 因此,移动位置的平均时间复杂度为O(L/2)。 算法整体计算次数为(log_2i + i/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(n^2)复杂度的排序算法。

本质就是分组插入排序

!!! 不稳定排序 分组,跳跃性插入,会破坏稳定性

平均时间复杂度O(nlogn)$还是O(n^{1.3-2})        ?????

空间复杂度O(1)

直接插入排序的优化版,由于直接插入排序只适用于小规模数据或基本有序的数据,并且插入排序每次只能移动一个数据,所以对此进行改进,先把数据调整成基本有序

注:为方便记忆算法,将其记作“三层for循环+if” ------** for(for(for(if)))**)

基本思想

​ 将整个待排序的序列分成若干个子序列(相隔某个"增量"的元素为一组),再每个子序列内分别进行直接插入排序,然后再减小"增量"大小,再次排序,直至增量减为1,这个时候就是整个序列是一组,并且已经是基本有序的状态,然后再最后对所有元素进行直接插入排序

​ 这样保证每次进行直接插入排序时序列都满足基本有序或小规模,提高了排序效率。

img

 

增量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})。』

 

堆排序

堆排序是简单选择排序的一种改进。 一种选择排序

不稳定排序

升序采用大顶堆、降序采用小顶堆

基本思想

堆排序是利用堆进行排序的方法

  1. 将待排序的序列构造成一个大顶堆,此堆为初始的无序区
  2. 将顶堆arr[1]和arr[n]交换,此时,最大值就处于最后一个位置,处于有序的状态
  3. 交换后可能会使无序部分违反堆的性质,因此接下来需要对无序部分(arr[1] ... arr[n-1])调整为新堆
  4. 重复2、3步,直到完成最后两个元素的排序

图解

设有一个无序序列 { 1, 3, 4, 5, 2, 6, 9, 7, 8, 0 }。

                                                                                    排序(6):堆排序

 

构造了初始堆后,我们来看一下完整的堆排序处理:

还是针对前面提到的无序序列 { 1, 3, 4, 5, 2, 6, 9, 7, 8, 0 } 来加以说明。

                                                                   排序(6):堆排序

 

 

堆排序是基于完全二叉树实现的,在将一个数组调整成一个堆的时候,关键之一的是确定最后一个非叶子节点的序号,这个序号为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;
}

建堆的时间复杂度为O(n)

调整堆的时间复杂度为O(NlogN)

首先借用《大话数据结构》的描述:

在正式排序时,第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) 。

排序(6):堆排序

 

归并排序

典型的分治思想。

先把大数组划分成小数组,直至每个数组中只有一个元素,此时,每个小数组分别是有序的,接下来,再两两合并小数组,合并成多个有序的小数组,再继续合并,合并的同时并进行排序,当小数组合并成一个大数组的时候,就结束了。

基本思想

步骤

  1. 分解:将n个待排序元素组成的序列划分成具有n/2个元素的两个子序列

  2. 解决:使用归并排序递归地对两个子序列排序

  3. 合并:将排序好的两个子序列合并,产生一个已经排好序的子序列,直至最后得到最终的排序序列

  • 作为一种典型的分而治之思想的算法应用,归并排序的实现分为两种方法:
    • 自上而下的递归;
    • 自下而上的迭代;

特点

稳定的排序算法。(归并排序严格遵循从左到右或从右到左的顺序合并子数据序列, 它不会改变相同数据之间的相对顺序, 因此归并排序是一种稳定的排序算法.)

最优时间复杂度、平均时间复杂度和最坏时间复杂度均为O(nlogn) 。

空间复杂度为 O(n)。因为需要辅助数组存放合并后有序的新数组

 

图解

 

                                                                             img

代码实现

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);
    }
}
  

 复杂度分析 

 

 

 快速排序和归并排序的区别

 

 

稳定性分析

 

排序算法的选择

如果只有一次排序,就尽量选择高性能的排序算法

如果有多次,且对稳定性有要求,尽量选择稳定性的排序算法

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值