两个for做数据插入_数据结构与算法学习笔记:十种常见排序算法

十大排序算法的时间复杂度和空间复杂度如下图所示,要排序的元素均以数组存储。

1ee506af5d24674bf8ecea96474c3e0b.png

原地算法(In-place Algorithm):

不依赖额外的资源或者仅仅依赖少数的额外资源,仅依靠输出来覆盖输入。

空间复杂度为O(1)的算法都可以认为是原地算法。

排序算法的稳定性:

7b5c8c385f59ccc83cb30a073ab3df56.png

以下排序算法同一默认为升序,且以int为待排序数据类型

冒泡排序(起泡排序):稳定

算法过程:

a.从第二个元素开始,和前一个元素进行比较大小,如果比前一个元素小,那么就执行元素交换操作,交换后继续向后处理下一个元素。如果大于等于前一个操作,那么就什么都不做,继续向后处理下一个元素。这样一直重复直到末尾元素操作完成。

b.进行完一轮的1操作后,待排序数据中的最大元素已经到达了末尾元素位置。此时逻辑上忽略该末尾元素位置,也就是将末尾元素视作原来的末尾元素的前一个元素(操作的数据规模 - 1)。继续进行a操作,这一轮操作后待排序的第二大元素也会到达对应位置。继续重复以上操作直到操作的数据规模为1就停止,此时全部元素已经按升序排好。

//冒泡排序
void bubbleSort(vector<int>& arr)
{
    for(int end = arr.size() - 1; end > 0; end--)//end是每轮a操作的结尾元素下标,当end等于0退出
        for(int i = 1; i <= end; i++)//每轮a操作
        {
            if(arr[i] < arr[i - 1])
            {
                int temp = arr[i - 1];
                arr[i - 1] = arr[i];
                arr[i] = temp;
            }

        }
}


//经过改进的冒泡排序
//待排序的数据可能存在局部有序,局部有序的部分如果到达了对应位置后不需要进行a操作
//设置一个标志位,记录上一轮的a操作中最后一次交换的前一个元素位置,下一轮的a操作的末尾元素就是该下标
//通过设置这个标志位,当待排序元素本来就是升序时,只需要一轮扫描就可以结束,而不是一直重复扫描
void bubbleSort1(vector<int>& arr)
{
    for(int end = arr.size() - 1; end > 0;)//end是每轮a操作的结尾元素下标,当end等于0退出
    {
        int lastFinalSwap = 0;
        for(int i = 1; i <= end; i++)//每轮a操作
        {
            if(arr[i] < arr[i - 1])
            {
                int temp = arr[i - 1];
                arr[i - 1] = arr[i];
                arr[i] = temp;
                lastFinalSwap = i - 1;//记录最后一次交换的前一个元素下标
            }

        }
        end = lastFinalSwap;//下一轮a操作的结尾就是上一轮a操作的最后一次交换元素前一个元素的下标

     }

}

冒泡排序的时间复杂度分析:

最好的情况下,原来的元素本来就是升序,不需要进行交换操作,此时只需要扫描n - 1个元素后就可以结束。这时算法时间复杂度为

最坏的情况下,原来的元素是逆序,每轮a操作所需要的交换操作数量都达到了最大值。

第1轮,需要交换n - 1次,第2轮,需要交换n-2次。。。最后一轮(第n - 1轮)需要交换1次。

根据等差数列求和,最后的算法时间复杂度为

综上,平均情况下冒泡排序的时间复杂度是

冒泡排序的空间复杂度分析:

冒泡排序过程仅仅是生成了一些辅助变量。空间复杂度为

,属于原地算法。

选择排序:不稳定

选择排序的算法过程同样可以分为以下两个步骤:

a.从待排序的数组元素范围中选择出最大的一个元素的下标位置

b.将a中选择出的最大元素与数组搜索范围的最后一个元素交换位置,并将待排序的数组元素范围减1。继续重复a,b操作。直到搜索范围缩减为1。

void selectSort(vector<int>& arr)
{
    for(int end = arr.size() - 1; end > 0; end--)//每轮a操作后搜索范围减1
    {
        int maxIndex = 0;//不能放在外for循环的外边定义,每轮循环都要寻找对应范围内最大的值 
        //a操作
        for(int i = 1; i <= end; i++)
        {
            if(arr[i] > arr[maxIndex])
                maxIndex = i;
        }
        //b操作
        int temp = arr[end];
        arr[end] = arr[maxIndex];
        arr[maxIndex] = temp;
    }
}

选择排序的时间复杂度分析:

最好情况下,也就是数组内元素本来就是升序,此时选择排序没有任何优势,交换操作和扫描操作仍然进行,时间复杂度为

最坏的情况下,同最好的情况。

最好最坏的情况时间复杂度都一样,那么平均的情况下时间复杂度就是

选择排序的空间复杂度分析:

选择排序只生成了若干辅助变量,因此空间复杂度为

,属于原地算法。

堆排序:不稳定

堆排序可以认为是选择排序的一种优化。堆排序与选择排序的主要区别就是选择排序通过遍历来获得给定范围内的最大元素,而堆排序使用二叉堆这一数据结构来获得给定范围内最大元素。

算法步骤:

1.对待排序元素原地建最大堆

2.从最大堆中取出最大元素,并与当前范围内最后一个元素进行交换

3.将待处理元素范围减1,继续重复以上操作,直到待处理元素只有1个

//省略了二叉堆数据结构的声明和实现
void heapSort(vector<int>& arr)
{
    for(int i = arr.size()/2 - 1; i >= 0; i--)//自下而上的下滤,原地建最大堆
        Siftdown(arr, i, arr.size());//下滤操作
    for(int end = arr.size() - 1; end >=0 ;)
    {
        int temp = arr[0];//取出最大元素
        arr[0] = arr[end];//交换元素
        arr[end] = temp;
        end--;//要在这里就要把end减小,这一下滤就会忽略最后一个元素
        Siftdown(arr, 0, end + 1);//对二叉堆根节点进行下滤,第二个传值元素是二叉堆的元素数量
    }
}

//下滤操作
void Siftdown(vector<int>& arr, int index, int size)
{
    int temp = arr[index];
    int max_child;
    while(index < size/2)
    {
        int max_child = 2*index + 1;
        int rc = max_child + 1;
        if(rc <= size - 1)
        {
            if(arr[rc] < arr[max_child])
            {
                max_child = rc;
            }
        }
        if(temp <= arr[max_child])
        {
            break;
        }
        arr[index] = arr[max_child];
        index = max_child;
    }
    arr[index] = temp;
}

堆排序的时间复杂度分析:

不论最好的情况还是最坏的情况,首先都要进行批量建堆,这一过程时间复杂度为

。之后的类似于选择排序的过程需要
的时间。总体上看,堆排序的最好,最坏,平均情况下的时间复杂度均为

堆排序的空间复杂度分析:

堆排序只是创建了部分的辅助变量,因此空间复杂度为

,是一种原地算法。

插入排序:稳定

插入排序的主要思路是将待排序元素分为已经排好序和还没排序的两部分,然后执行以下步骤

a.选择还没排序的那一部分的第一个元素

b.将选中的元素通过一一比较交换插入到已经排好序的部分中,并且将还没排序的那一部分元素数量减1,继续重复a,b操作直到未排序的部分元素为0。

//插入排序
void insertSort(vector<int>& arr)
{
    for(int i = 1; i < arr.size(); i++)//一开始排好序的部分只有下标为0这一个元素,所以从下标1开始选择
    {   
        int end = i; 
        while(end > 0 && arr[end] < arr[end - 1])
        {
            int temp = arr[end - 1];
            arr[end - 1] = arr[end];
            arr[end] = temp;
            end--;
        }
    }
}

插入排序的时间复杂度分析:

最好的情况,也就是原来的待排序数据本来就是升序时,插入排序的过程只需要遍历一遍数组即可,此时的时间复杂度为

最坏的情况,也就是原来的待排序数据本来就是降序时,类似于冒泡排序,此时时间复杂度为

插入排序的平均时间复杂度为

值得一提的是,插入排序的时间复杂度主要取决于待排序数据中的逆序对数量。

41514b83b12fd23f99ed94688852cdb5.png

d91fc30c775be2c1633232d403e6099d.png

插入排序的空间复杂度分析:

插入排序只生成了一部分的辅助变量,空间复杂度为

,是一种原地算法。

插入排序的改进1:

以上插入排序的插入过程中对于每个逆序对都采用了交换操作。可以将交换操作改为将移动操作,这样可以降低插入排序的时间复杂度。具体思路如下:

a.选择还没排序的那一部分的第一个元素

b.将a中选中的元素暂时存储,并且依次将暂存的元素与排好序的部分的元素从最后一个元素开始比较,如果是比这个元素小,那么这个元素向右移动,继续判断倒数第二个元素,以此类推直到遇到第一个比待插入元素小的元素。此时将待插入元素插在那个元素的后边即可。然后继续重复a,b步骤直到待排序部分元素为0。

//插入排序的优化版本1
void insertSortImprove_1(vector<int>& arr)
{
    for(int i = 1; i < arr.size(); i++)//一开始排好序的部分只有下标为0这一个元素,所以从下标1开始选择
    {
        int temp = arr[i];//暂存待插入元素
        int end = i;
        while(end > 0 && arr[end - 1] > temp)
        {
            arr[end] = arr[end - 1];
            end--;
        }
        //循环结束后end指向的位置就是插入位置
        arr[end] = temp;
    }
}

插入排序的改进2:

之前的插入排序中对于待插入的元素在已排序的序列中的插入位置的查找采用的是遍历方法,遍历元素导致耗费了很多时间。因此这里的改进是用二分搜索替代遍历来寻找插入位置。

二分搜索查找插入位置:

使用v

m就取右半部分查找是为了保证排序算法的稳定性,也就是保证待插入元素如果已经在已排好序列中存在大小相同元素,那么待插入元素一定是插入在这个元素的右边。

下图就是一个递归的过程,递归基就是begin与end相等,并且此时两者的值就是插入位置

2599a6c7d47cb054e27e67a8b6bf732a.png
//插入排序的优化版本2
void insertSortimprove2(vector<int>& arr)
{
    for(int i = 1; i < arr.size(); i++)//一开始排好序的部分只有下标为0这一个元素,所以从下标1开始选择
    {
        int temp = arr[i];
        int index = getPosition(arr, 0, i, arr[i]);
        for(int begin = i; begin > index ; begin--)
            arr[begin] = arr[begin - 1];
        arr[index] = temp;
    }

}


//二分查找
//传入待插入的范围[begin,end)以及待插入元素大小value,返回要插入的位置
int getPosition(vector<int>& arr, int begin, int end, int value)
{
    if(begin == end)
        return begin;
    int mid = (begin + end)/2;
    if(value < arr[mid])
        return getPosition(arr, begin, mid, value);
    else
        return getPosition(arr, mid + 1, end, value);

}

值得一提的是,经过二叉搜索优化后只是相对减少了比较次数,降低了一部分的时间复杂度,但是选择排序平均上的时间复杂度仍然是

归并排序:(稳定)

归并排序的算法流程如下:

a.不断对待排序元素平均分割为两个子序列,直到不能再分割。(divide过程)

b.对子序列进行排序,并且对子序列合成的较大的子序列进行排序,最终所有在a过程中出现的子序列都被排序完毕后整个待排序元素序列也排序完毕。(merge过程)

以上过程如图所示

5e049b417692201b21bcb6e1230a111c.png
//归并排序的算法过程描述按时归并排序可以很简单的通过递归来实现
//通过[begin,end)来指定归并排序的元素
void mergeSort(vector<int>& arr, int begin, int end)
{
    if(end - begin < 2)
        return;
    int mid = (begin + end)/2;
    mergeSort(arr, begin, mid);//继续对[bgein, mid)这一范围进行分割
    mergeSort(arr, mid, end);//继续对[mid, end)这一范围进行分割
    sort(arr, begin, mid, end);
}

vector<int> temp(n/2);//n为待排序的元素数量
//对由两部分有序子序列构成的序列进行排序
void sort(vector<int>& arr, int begin, int mid, int end)
{
    for(int i = 0; i < mid - begin; i++)
        temp[i] = arr[i + begin];
    //对两个有序子序列进行比较插入,实现对序列的排序
    int cur_temp = 0;//当前处理的temp元素下标,起点为0
    int end_temp = mid - begin;//temp终点
    int cur = begin;//当前处理的序列下标,起点为begin
    int cur_r = mid;//当前处理的右子序列下标,起点为mid
    int end_r = end;//右子序列终点
    while(cur_temp < end_temp)//如果cur_temp先到终点,那么就可以直接结束,因为原来右子序列肯定是有序的
    {                         //如果cur_r先到终点,那么直接把剩下的temp元素依次覆盖即可
        if(cur_r < end_r && arr[cur_r] < temp[cur_temp])//为了实现稳定排序,这里不能是<=
            arr[cur++] = arr[cur_r++];               
        else
            arr[cur++] = temp[cur_temp++];

    }
    
   
}

归并排序的时间复杂度分析:

递推法:

b919c574c89199531497b9bafc5ffda7.png

因为归并排序总是对待排序序列进行分割并且合并,所以最好最坏和平均条件下的时间复杂度均为

归并排序的空间复杂度分析:

4f2a7275a856d76ac3dab8956d46b4dc.png

因为使用了额外的存储空间来存储待排序元素,所以归并算法不是原地算法。

快速排序:(不稳定)

快速排序的过程:

a.选定一个轴点元素,选定规则可以自己定,一般选第一个元素。

b.将轴点元素为评判标准,大于等于轴点元素的元素移动到轴点元素右侧,小于轴点元素的元素移动到元素左侧,这样就以轴点元素为分界左右侧分出了两个子序列。

c.对b中的子序列继续调用a,b操作,又会得到更多的子序列,继续对这些子序列执行a,b操作直到得到的子序列只有一个元素,此时待排序的元素全都已经被指定为轴点元素,这时全部元素都已经有序。

0861d1732d8d5d55287e7aa441748957.png
//快速排序
//在比较当前元素和pivot元素时,使用的是大于号小于号,这样等于情况发生的时候一定会转遍插入方向,这样可以让所有元素大小相同时都能保证子序列长度是均匀的

int pivotIndex(vector<int>& arr, int begin, int end)
{
    int pivot = arr[begin];//取处理范围内元素的第一个为轴点元素
    int left = begin;//左起点,从第一个元素开始比对
    int right = end - 1;//右起点,从最后一个元素开始比对
    while(left < right)
    {
        while(left < right)//一定是先从右边开始比较,要不然没办法实现从第一个元素处腾出位置,会造成覆盖
        {
            if(arr[right] > pivot)//大于等于轴点元素,放右边,所以不用覆盖
                right--;//直接指向下一个
            else//小于轴点元素
            {
                arr[left] = arr[right];//复制后比对下一个元素,需要转向
                left++;
                break;//退出当前循环,转变方向到从左边开始
            }
                
        }
        while(left < right)//首先从左边开始比较
        {
            if(arr[left] < pivot)//小于轴点元素,放左边,所以不用覆盖
                left++;//直接指向下一个
            else//大于等于轴点元素
            {
                arr[right] = arr[left];//赋值后比对下一个元素,需要转向
                right--;
                break;//退出当前循环,转变方向到从右边开始
            }
                
        }

    }
    //退出循环后left == right,这个下标就是轴点元素应该赋值的位置
    arr[left] = pivot;
    return left;
}
void quickSort(vector<int>& arr, int begin, int end)
{
    if(end - begin < 2)//递归基,如果待处理的元素少于两个那么就终止递归
        return;
    int mid = pivotIndex(arr, begin, end);//将arr中比轴点元素小的元素移动到轴点元素左边,大于等于轴点元素的元素移动到轴点元素右边,并返回最后的轴点元素位置
    quickSort(arr, begin, mid);//对[begin, mid)继续以上操作
    quickSort(arr, mid + 1, end);//对[mid + 1, end)继续以上操作
    
    
}

快速排序的时间复杂度分析:递推法

1cbefcc8929c2173b4c3ac73b3685a1d.png

最坏情况的一个举例:

36f2cc45f3a33688ded4a1b188458b52.png

为了避免这种最坏情况下快速排序退化为

时间复杂度,在每次取轴点元素的时候都让第一个元素和随机一个位置的元素交换一下元素值,再取第一个元素的值,而不是就单纯的取第一个元素。

快速排序的空间复杂度分析:

快速排序使用了递归调用,递归深度是n的二分深度,所以空间复杂度为

。没有开辟新的空间来保存待排序元素,所以是原地算法。

希尔排序:(不稳定)

希尔排序,根据待排序元素数量指定一定规律缩减到1的正整数序列。对该序列中的每一个整来对待排序元素进行逻辑上的重排,每一个整数代表列数,这样就可以将待排序元素等价为逻辑上的矩阵。每一轮衰减都对矩阵的列进行排序,当n衰减到1后的最后依次排序结束后所有的待排序元素都会有序。

过程示意如图:

72a01757f24043b78c24587775b4e8a4.png

173430e143860f82f4b2417b8f229db8.png

db66757ea55ede40c65b85763abfa089.png

从上图出可以看出,希尔排序的本质就是通过每一轮逻辑上的按列排序来减少待排序元素中的逆序对。希尔排序的底层实现使用插入排序,希尔排序可以视作插入排序的一种改进。

//希尔排序
//缩减序列按每轮都减半来获得
void sheLLSort(vector<int>& arr)
{
    //为空或者只有一个元素,那么就直接返回
    if(arr.size() == 0||arr.size() == 1)
        return;
    //获得递减序列
    vector<int> list;
    int temp = arr.size()/2;
    while(temp > 0)
    {
        list.push_back(temp);
        temp = temp/2;
    }
    //逻辑上按列进行插入排序
    for(int step:list)//对递减序列中所有元素都来一次逻辑上的列排序
    {

        //插入排序
        //step即是列数量,也是每一行的步长
        for(int cur_col = 0; cur_col < step; cur_col++)//从第一列开始进行插入排序
        {
            
            for(int i = cur_col + step; i < arr.size(); i += step)//对某一列的插入排序
            {
                
                int temp = arr[i];//暂存待插入元素
                int end = i;
                while(end > cur_col && arr[end - step] > temp)
                {
                    arr[end] = arr[end - step];
                    end = end - step;
                }
                //循环结束后end指向的位置就是插入位置
                arr[end] = temp;
                
            }
        }
        
        
    }
        
}

希尔排序的时间复杂度分析:

最好情况下,也就是原来的元素就是有序的,此时希尔排序只是遍历一遍元素,时间复杂度为

最坏情况下,这个不好分析,取决于具体的衰减序列。

平均情况下,根据相关资料,目前最好的序列可以达到平均情况下

的时间复杂度,如果是单纯的二分递减序列,那么平均时间复杂度为

希尔排序的空间复杂度分析:

算法实现过程开辟了递减序列这一空间,按照序列不同有着不同的时间复杂度。

没有开辟空间来存储待排序元素,所以希尔排序是一种原地算法。

非比较排序算法:

以上介绍的七种排序算法的操作都是基于比较元素本身大小来进行排序的,接下来要介绍的三种排序算法都不需要进行待排序元素本身的比较大小操作。非比较排序在某些情况下的时间复杂度有可能比比较算法中的快速排序还要低,非比较排序是很典型的空间换时间策略算法。

计数排序:(稳定)

计数排序一般只用于对整数进行排序。

计数排序的核心思想就是结合数组的随机访问特性确定待排序元素在对应有序序列中应该是第几次出现来决定待排序元素在有序序列中的位置。

算法过程:

a.遍历依次待排序元素,确定最小元素min和最大值max。

b.创建记录数组,大小为max - min + 1,初始元素均为0。再次遍历待排序元素数组,将每个遍历到的元素记为k,在记录数组中k - min索引处自增1。

c.对b中创建的记录数组从左向右再次遍历,从第二个元素开始,右边等于本身的元素值加上左边。这样记录数组中每一个元素的值就等于对应位置的待排序元素在有序序列中第几次出现。

d.创建一个新的数组,用来存放根据c中记录数组来对待排序数组中的待排序元素进行排序的结果。

//计数排序
vector<int> countSort(vector<int>& arr)
{
    if(arr.empty())
        return vector<int>(0);
    int min = arr[0];
    int max = arr[0];
    //找到最小值和最大值
    for(int value:arr)
    {
        if(value < min)
            min = value;
        if(value > max)
            max = value;
    }
    vector<int> record(max - min + 1, 0);//记录数组
    //b过程
    for(int value:arr)
        record[value - min]++;
    //c过程
    for(int i = 1; i < record.size(); i++)
        record[i] += record[i - 1];
    //d过程
    vector<int> ans(arr.size(),0);
    for(int i = arr.size() - 1; i >=0; i--)//倒着读取arr,这样就可以保证了稳定性
        ans[--record[arr[i] - min]] = arr[i];
    
    return ans;
    

    
}

计数排序的时间复杂度分析:

待排序序列的排序状态对计数排序的时间复杂度没有影响。由代码实现可以得到时间复杂度为

,k就是max - min +1,最大最小元素之间的举例决定了记录数组的遍历所花时间。

计数排序的空间复杂度分析:

由算法代码可知计数排序空间复杂度为

,并且生成了新的空间来存储待排序元素,所以不是原地算法。

基数排序:(稳定)

基数排序分别对待排序元素的个位,十位....直到最高位的基数进行计数排序,对各位的基数排序完毕后待排序元素也已经完成排序。一般用来对正整数进行排序。

//基数排序,以十进制为例
void baseSort(vector<int>& arr)
{
    //先找最大值
    int max = arr[0];
    for(int value:arr)
    {
        if(value > max)
            max = value;
    }
    int divide = 1;
    while(max/divide > 0)
    {
        arr = countSort(arr, divide);
        divide *= 10;
    }
    
}
//用于基数排序的计数排序
vector<int> countSort(vector<int>& arr , int divide)
{
    if(arr.empty())
        return vector<int>(0);
    vector<int> record(10, 0);//记录数组,
    //b过程
    for(int value:arr)
        record[value/divide%10]++;
    //c过程
    for(int i = 1; i < record.size(); i++)
        record[i] += record[i - 1];
    //d过程
    vector<int> ans(arr.size(),0);
    for(int i = arr.size() - 1; i >=0; i--)//倒着读取arr,这样就可以保证了稳定性
        ans[--record[arr[i]/divide%10]] = arr[i];
    
    return ans;
    

    
}

基数排序的时间复杂度:

由代码实现可得基数排序的时间复杂度位

,d是位数,也就是要进行的计数排序次数,n是待排序元素总数,k是进制。

基数排序的空间复杂度:

基数排序调用了计数排序,但都不是同时调用,而是分别调用,所以空间复杂度为

,n为待排序元素总数,并且不是原地算法。

桶排序:(稳定)

77122145fcb57b09b63a84926b623428.png

ebde1405b05a2751750e6d387cefdccd.png
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值