常见的十种排序算法C++实现(附时空复杂度,稳定性分析)

本文主要描述排序算法的实现和大体思路,如果大家不了解其中某种算法,可以先去搜索,看看大概流程,再回来看代码就很清晰了。

一、冒泡排序

二、选择排序

三、插入排序

四、希尔排序

五、归并排序

六、快速排序

七、堆排序

八、计数排序 

九、基数排序 

十、桶排序 


一、冒泡排序

思路:每一轮排序使得当前无序的部分的最大值冒到当前无序数组的末尾,下一轮中无序部分的长度等于上一轮减一,原数组的末端不断的变成有序的部分。

void bubble_sort(vector<int> &nums)
{
    // 用于标记当前轮是否有交换发生
    bool swaped;
    // 从第一个数到倒数第二个数(n个数需要冒泡n-1次)
    for(int i = 0; i < nums.size() - 1; ++i)
    {
        swaped = false;
        // 因为是当前的和后面的比,所以结束条件是一直到冒泡完毕的元素的前一个
        for (int j = 0; j < nums.size()-1-i; ++j)
        {    
            // 通过交换两个元素,使得最大的冒到数组的末尾去,并且标记交换过
            // 若要从大到小排序,只需要把这个 '>' 改成 '<' 即可
            if (nums[j] > nums[j+1])
            {
                int tmp = nums[j];
                nums[j] = nums[j+1];
                nums[j+1] = tmp;
                swaped = true;
            }
        }
        // 若该轮没有交换发生,说明数组已经有序,跳出循环
        if (!swaped)
            break;
    }
}

分析:时间复杂度最坏和平均情况是O(n^2),最好的情况是已经有序所以是O(n),空间复杂度O(1),且因为两个相同的元素是不会交换位置的,因此是一种稳定的排序。

二、选择排序

思路:每一轮都将未排序数组中最小的/最大的选择出来,然后与未排序数组的第一个交换。接着未排序数组的长度就应该减一,然后继续做这个操作,直到未排序数组的长度为1

void select_sort(vector<int> &nums)
{   
    // 用于存储每一轮中最小的数的下标
    int min_idx;
    // 循环从第一个数开始到倒数第二个数
    for(int i = 0; i < nums.size() - 1; ++i)
    {
        min_idx = i;
        // 在未排序部分寻找最小的数
        for (int j = i+1; j < nums.size(); ++j)
        {
            // 这里将 '<' 换成 '>' 就可以从大到小排序
            if (nums[j] < nums[min_idx])
                min_idx = j;
        }
        // 将未排序部分最小的数与未排序部分的第一数交换位置
        int tmp = nums[min_idx];
        nums[min_idx] = nums[i];
        nums[i] = tmp;
    }
}

分析:时间复杂度最坏、最好和平均情况都是O(n^2),空间复杂度O(1)。因为每次选择最小元素要与未排序部分的第一个元素进行交换,因此可能会出现两个相同元素相对位置发生改变的情况,因此者是一种不稳定的排序。

三、插入排序

思路:从第二个元素作为当前位置,往前看,判断前面的元素是否大于当前位置,是的话就把前面的元素往后移动一个,依次类推,直到到了数组头或者碰到一个元素比当前元素小则循环终止,此时的位置就应该是当前位置元素的排序合适的位置。接着向后移动,把第三个元素作为当前位置,一直到最后一个元素。

void insert_sort(vector<int> &nums)
{
    // 从第二个元素到最后一个元素
    for(int i = 1; i < nums.size(); ++i)
    {
        // tmp存下当前元素的值,将i赋给j
        int tmp = nums[i];
        int j = i;
        // j从当前元素往前找,一直找到第二个元素位置
        for (; j > 0; j--)
        {
            // 判断tmp是否小于j-1位置的元素,是的话j-1的元素往后移动
            // 要从大到小排序,只需要把 '<' 改为 '>' 即可
            if (tmp < nums[j-1])
                nums[j] = nums[j-1];
            // 否的话,j位置就应该是tmp的最终合适的位置
            else
                break;
        }
        // 将tmp赋给j位置的元素
        nums[j] = tmp;
    }
}

分析: 时间复杂度最坏和平均情况都是O(n^2),最好的情况是O(n)(最好情况就是已经有序,因此内层循环只需要判断一次就可以break,空间复杂度O(1)。因为是依次往前找的,元素移动也是相邻位置的移动,因此这个方法是稳定的。

四、希尔排序

思路:希尔排序是插入排序的一种扩展,它不是一个个的找然后插入。而是将与原数组根据间隔分成不同的组,比如十个数{1,2,3,4,5,6,7,8,9,10}可以设置gap为2,这样就分成了{1, 3, 5, 7, 9}和{2, 4, 6, 8, 10}两个组,分的组数就等于gap的数量,每组的包含的元素数量就等于数组的长度除以gap。通过这样操作,再最后gap为1的时候,数组就是基本有序的了,因此这时只需要很少的操作就可以将整个数组变为有序的。希尔排序gap的设置很关键,设置一组好的gap序列往往可以很大程度上提高希尔排序的效率

void shell_sort(vector<int> &nums)
{
    // 初始将gap设置为数组长度的一半,每次gap等于上次gap的一半,一直循环一直到gap等于1
    for(int gap = nums.size()/2; gap > 0; gap/=2)
    {
        // 每一组的元素数量
        int ele_sz = nums.size() / gap; 
        // 遍历每一组
        for (int i = 0; i < gap; ++i)
        {
            // 对每一个组进行插入排序,下面的代码和插入排序那基本一样
            for (int j = 1; j < ele_sz; ++j)
            {
                int tmp = nums[j*gap];
                int k = j;
                for (; k > 0; --k)
                {
                    // 注意这里的下标要符合希尔排序间隔\
                    // 要从大到小排序,只需要把 '<' 改为 '>' 即可
                    if (tmp < nums[(k-1)*gap])
                        nums[k*gap] = nums[(k-1)*gap];
                    else
                        break;
                }
                // 找到了最终插入的位置
                nums[k*gap] = tmp;
            }
        }
        // 因为每次gap等于上次一半,当gap为1的时候就陷入死循环,所以这里设置一个判断用于跳出
        if (gap == 1)
            break;
    }
}

分析:希尔排序最好情况的时间复杂度为O(nlogn),而最坏情况和平均情况要根据它的增量序列来确定,好的增量序列可以把最坏情况的时间复杂度控制为O(n^\frac{4}{3})。希尔排序是原地排序因此空间复杂度为O(1)。由于增量序列的存在,使得比较的元素存在间隔,因此就可能导致相同元素的相对位置发生改变,因此希尔排序是不稳定的。

五、归并排序

思路:归并排序是分治法的典型应用之一。首先先把大的数组拆分成两半,再继续拆,直到只剩一个数位置,拆完之后要合并,合并的时候就需要对两边的数进行排序,合并完成的时候就变成了有序的数组。以此类推,对左边的已经有序和右边已经有序的数组进行合并,最终得到了整体有序的数组。

// 合的过程
vector<int> merge_vec(vector<int> ivec1, vector<int> ivec2)
{
    // 定义结果数组res, i和j分别用于遍历ivec1和ivec2
    vector<int> res;
    int i = 0, j = 0;
    // 当两个数组都没遍历到底
    while(i < ivec1.size() && j < ivec2.size())
    {
        // 谁的当前数字小,谁就加入结果数组,并且下标后移
        // 这里将 '<' 改为 '>' 就可以从大到小排序
        if (ivec1[i] < ivec2[j])
            res.push_back(ivec1[i++]);
        else
            res.push_back(ivec2[j++]);
    }
    // 处理未遍历完的数组,直接全部加入最后面
    while (j < ivec2.size())
        res.push_back(ivec2[j++]);
    while (i < ivec1.size())
        res.push_back(ivec1[i++]);
    // 返回合并好的数组
    return res;
}

// 分的过程
vector<int> merge_sort(vector<int> nums)
{
    // 递归结束条件是只剩一个元素,则无序再分,直接返回
    if (nums.size() == 1)
        return nums;
    
    // 将大数组分治为左边的和右边的
    vector<int> ivec1 = merge_sort(vector<int> (nums.begin(), nums.begin()+nums.size()/2));
    vector<int> ivec2 = merge_sort(vector<int> (nums.begin()+nums.size()/2, nums.end()));
    // 返回合并后的大数组
    return merge_vec(ivec1, ivec2);
}

分析:拆的过程时间复杂度是O(logn),合的过程时间复杂度是O(n),因此总的最好、最坏和平均时间复杂度是O(nlogn)。这种排序方法并非是原地排序,所以其空间复杂度是O(n)。这种方法并不会破坏相等元素之间的相对位置因此这是一种稳定的排序算法。

六、快速排序

思路:快速排序同样也是利用分治法的思想,但是快速排序可以实现原地排序因此无需额外的空间。快速排序和归并排序的分的过程不同,快速排序分是根据小于和大于某个元素来分成两部分,而归并排序总是以中间的地方来划分。

注意:算法中每一轮quicksort会确定一个元素的最终位置,我们递归处理这个元素左边的剩余数组和右边的剩余数组。lp和rp在走的时候,需要注意的只要rp指向的元素大于等于pivot就一直往左,lp指向的元素小于等于pivot就一直往右,若不加入等于这个判断条件可能就会在中途停下来而陷入死循环。而且pivot的选取也很关键,我们是以pivot大小来划分左右的,常见的方法可以以最左边或者最右边的元素作为pivot(但是这样在已经有序的数组中效率很差),或者随机选择pivot。

void quicksort(vector<int> &nums, vector<int>::iterator lp, vector<int>::iterator rp)
{
    // 若左右相等,说明只有一个元素,就不需要排序
    if (rp - lp > 0)
    {
        // old和orp存取起始位置和结束为止
        auto olp = lp, orp = rp;
        
        // 下面两行代码可以要也可以去掉,其作用是随机选定一个范围在lp和rp之间的数作为pivot
        // 将这个数与最左边的数调换位置就可以通过*lp得到这个数
        int random = rand() % (rp - lp + 1);
        swap(*lp, *(lp + random));

        int pivot = *lp;

        // 当lp和rp没遇到的时候
        while (lp != rp)
        {
            // 只要rp指向的元素大于等于pivot而且lp和rp没遇到,rp自减
            // 将这里的 '>=' 换成 '<=' 并且把下面的 '<=' 换成 '>=' 就是从大到小
            while (*rp >= pivot && lp != rp)
                --rp;
            // 只要lp指向的元素小于等于pivot而且lp和rp没遇到,lp自增
            while (*lp <= pivot && lp != rp)
                ++lp;
            // 停下来的时候交换值
            swap(*lp, *rp);
        }
        
        // lp遇到rp说明比pivot小的已经放在左边,比pivot大的已经放在右边了
        // 因为while循环中rp先走,说明rp所指的元素一定是小于pivot或者正好是pivot
        // 因此需要交换二者的值,同时pivot就被放在了正确的排序位置上
        swap(*olp, *rp);
        // 递归处理pivot的左边和右边
        quicksort(nums, olp, lp-1);
        quicksort(nums, rp+1, orp);
    }
}


void quick_sort(vector<int> &nums)
{
    quicksort(nums, nums.begin(), nums.end()-1);
}

分析:快速排序算法的时间复杂度分是O(logn),治是O(n),因此其最好和平均的复杂度是O(nlogn)。考虑一种情况,当数组有序而且我们选取pivot的方法是选最左边的或者最右边的元素时复杂度退化为O(n^2)。原地排序因此不需要额外空间,但是由于是递归函数,因此需要栈来保存状态,最好和平均情况下需要O(logn),最坏的情况下需要O(n)。由于快速排序涉及到随机位置值的交换,因此快速排序是一种不稳定的排序方法。

七、堆排序

思路:堆排序顾名思义需要一个堆,堆是一个完全二叉树,可以分为大顶堆和小顶堆,其中大顶堆是满足子节点的值小于父节点的堆,小顶堆是满足子节点的值大于父节点的堆。建好了堆之后我们就可以通过取堆顶元素再不断调整堆的方法来得到排序结果。因为堆是完全二叉树,因此其父节点和子节点的关系容易得到,所以我们可以用数组来模拟一棵树(一个堆),下面这个视频将堆排序讲的很清晰。

【从堆的定义到优先队列、堆排序】 10分钟看懂必考的数据结构——堆_哔哩哔哩_bilibili

建堆:建立堆的方式可以分为从下往上和从上往下。以大顶堆为例,从下往上的话,对于每个父节点,判断其子节点值是否存在大于父节点的情况,是的话就交换这父节点和子节点的值,然后继续调整子节点(若子节点也存在节点的话则继续往下调整)。如果是从上往下建堆,就相当于是堆从从空的开始,不断插入元素再进行调整,判断当前插入的选择值是否大于其父节点的值,若是的话则交换当前节点与父节点,并继续向上调整直到无需调整或者到根节点。可以判断,从下往上的方式其时间复杂度为O(n),而从上往下调整的方式其时间复杂度为O(nlogn)。同时需要注意的是,对于同一个数组,采用从下往上和从上往下的方式建立可能会导致节点的排列方式不一样,但是都满足大顶堆/小顶堆的性质。

下面是从下往上建大/小顶堆的代码:

// 接收参数为数组nums,当前调整节点下标i,当前堆的大小sz
void adjust_from_buttom(vector<int> &nums, int i, int sz)
{
    // lpos和rpos分别代表左孩子和右孩子,largest用来存储左右孩子中较大的那个的下标
    // 建小顶堆需将largest改为minest,此时这个变量用于存储较小的元素
    int lpos = 2*i+1, rpos = 2*i+2, largest = i;
    // 若有左孩子(下标小于sz)而且左孩子数值大于当前节点数值,就记录下左孩子下标
    // 建小顶堆需将 '>' 改为 '<'
    if (lpos < sz && nums[lpos] > nums[i])
        largest = lpos;
    // 若有右孩子(下标小于sz)而且右孩子数值大于最大的节点值,就记录下右孩子下标
    // 建小顶堆需将 '>' 改为 '<'
    if (rpos < sz && nums[rpos] > nums[largest])
        largest = rpos;
    // 若存在某个孩子的值比当前值大
    if (largest != i)
    {
        // 交换当前节点和较大的那个孩子的值
        swap(nums[largest], nums[i]);
        // 交换完之后继续往上调整树
        adjust_from_buttom(nums, largest, sz);
    }
}

void build_heap_from_buttom(vector<int> &nums)
{
    // 若数组只有一个元素则无需建立堆
    if (nums.size() != 1)
        // 从最后一个父节点开始循环,一直到根节点
        for (int i = nums.size()/2 -1; i >= 0; --i)
            // 不断对堆进行调整
            adjust(nums, i, nums.size());
}

下面是从上往下建大/小顶堆的代码:

void adjust_from_top(vector<int> &nums, int i, int sz)
{
    // 如果i不等于0
    if (i)
    {
        // 若i为偶数,其父节点下标为i/2-1。若满足当前节点的值比父节点的值大
        // 建小顶堆需将 '>' 改为 '<'
        if (!(i%2) && nums[i] > nums[i/2-1])
        {   
            // 交换当前节点与其父节点,再检查父节点需不需要调整
            swap(nums[i], nums[i/2-1]);
            adjust_from_top(nums, i/2-1, sz);
        }
        // 若i为奇数,其父节点下标为i/2,则当前节点比父节点的值大
        // 建小顶堆需将 '>' 改为 '<'
        if (i%2 && nums[i] > nums[i/2])
        {
            // 交换当前节点与其父节点,再检查父节点需不需要调整
            swap(nums[i], nums[i/2]);
            adjust_from_top(nums, i/2, sz);
        }
    }
}

void build_heap_from_top(vector<int> &nums)
{   // 从第一个元素开始到最后一个元素结束
    for (int i = 0; i < nums.size(); ++i)
        // 插入新的元素并调整
        adjust_from_top(nums, i, i+1);
}

建立好的堆之后就可以通过取出堆的根节点,再不断调整堆来进行堆排序,我们可以把取出的节点与堆的最后一个节点进行交换,然后把堆的大小减一,这样就可以利用堆原有的空间来存储排序的结果,而无需额外开辟空间来存储。堆排序代码如下:

void heap_sort(vector<int> &nums)
{
    build_heap_from_buttom(nums); // 或者是build_heap_from_buttom(nums);
    // 从最后一个节点开始往上走
    for(int i = nums.size()-1; i > 0; --i)
    {
        // 将最后一个节点与堆的根节点交换
        swap(nums[i], nums[0]);
        // 调整堆,堆的大小为原大小减一
        adjust_from_buttom(nums, 0, i);
    }
}

我们可以注意到堆排序的过程交换了节点之后,只有根节点发生了改变,因此只有可能是根节点不满足堆的性质,所以在调整的时候我们调用了adjust_from_buttom()方法来堆根节点进行调整。

分析:堆排序的时间复杂度包括建堆和调整,从下往上建堆时间复杂度为O(n),调整堆的时间复杂度为(logn),因此堆排序最好、最坏和平均情况的时间复杂度为O(nlogn)。堆排序不需要额外的空间因此其空间复杂度为O(1),但是由于建堆的过程中可能出现位置调整,因此堆排序是一种不稳定的排序方法。

八、计数排序 

思路:计数排序主要是针对一定范围内的整数来进行了,先确定序列中的最大值和最小值,从而开辟max-min+1大小的空间,空间的每一个位置代表一个数,若遍历到了某个数就将该空间的计数值自增,那么遍历完原数组。排序结果可以通过遍历这个空间来得到。在实现上C++中可以通过map来实现这个操作,因为map本身是保证有序的。当然因为map内部要进行排序,所以如果要实现极致的空间换时间的话,需要自己手动来实现这样的map。

void count_sort(vector<int> &nums)
{   
    // 用于存储数与出现次数的对应关系
    map<int, int> cnt_map;
    // 遍历数组以将元素及出现次数添加进map
    for (auto i : nums)
    {
        //
        if (cnt_map.find(i) == cnt_map.end())
            cnt_map[i] = 1;
        else
            ++cnt_map[i];
    }
    // idx代表原数组下标
    int idx = 0;
    // 遍历map以将map中的元素放回原数组
    for (auto it = cnt_map.begin(); it != cnt_map.end(); ++it)
    {
        while (it->second != 0)
        {
            nums[idx++] = it->first;
            --it->second;
        }
    }
}

分析:计数排序最好、最坏和平均情况下的时间复杂度都是O(n+k),n是表示遍历原数组的时间,k(数据范围)是表示遍历辅助空间的时间,其空间复杂度为O(k)。计数排序可以看做是一种稳定的排序,比如我们把计数排序存储每个元素的位置看成队列,那么就符合先进先出,所以是稳定的。

九、基数排序 

基数排序通常可以用在整数或字符串上,比如整数上就可以分0-9十个桶,通过比较个位,十位一直到最高位,分别把数装进相应的桶中,再依次取出,比较更高位再装进桶中,再取出,直到所有的数都在0这个桶中说明所有的数都排好序了。

但是需要注意的是,一般的基数排序无法处理有负数的情况。针对负数,一般有两种解决方法:

1、另外开辟10个桶用来存储负数的情况(需要占用更多的空间)

2、将所有元素变为正数再处理(可以将所有元素都加上最小负数的绝对值,但存在溢出风险),下面代码实现这种方式

void radix_sort(vector<int> &nums)
{
    // 开辟二维数组,第一维10代表十个桶,第二维当前为空
    vector<vector<int>> ivec(10, vector<int>(0));
    // 用于取出最小元素,将所有元素都变为正数
    int min_num = *min_element(nums.begin(), nums.end());
    if (min_num < 0)
        for (auto &i : nums)
            i -= min_num;
    // 用来控制比较的位数,初始为1代表比较个位数
    int digit = 1;
    // 循环一直进行
    while (true)
    {
        // 对nums中所有元素遍历,将每个元素放入对应桶中
        for(auto i : nums)
            ivec[i%(digit*10)/digit].push_back(i);
        
        // 这是循环终止条件,即所有元素都在0号桶中
        if (ivec[0].size() == nums.size())
            break;
        // 记录nums中的下标用于放回nums中
        int idx = 0;
        // 依次从每个桶中拿出元素放回nums,取完之后清空桶
        for (int i = 0; i < 10; ++i)
        {
            for(int j = 0; j < ivec[i].size(); ++j)
                nums[idx++] = ivec[i][j];
            ivec[i].clear();
        }
        // 下一次循环比较更高位
        digit *= 10;
    }
    // 排序完毕后将元素恢复为初始值
    if (min_num < 0)
        for (auto &i : nums)
            i += min_num;
}

分析:基数排序的时间复杂度取决于待排序的元素中的最大位数k,如最大元素为1023,那么最大位数k=4。对于每一位我们需要遍历两遍数组,但是这里的两遍可以省略,因此最好、最坏和平均的时间复杂度都是O(k*n)。由于我们需要开辟一个额外的空间来把每个数放入桶中,因此空间复杂度为O(n)。又因为我们放入桶和从桶中拿出的操作都保证了先进先出,因此基数排序是一种稳定的排序方法。

十、桶排序 

前面计数排序和基数排序都是桶排序的一种特例。桶排序是这样一种排序,它将原来的数组分到不同的桶里,再分别对每个桶内部采用排序算法进行排序(可以用快排、可以用选择、冒泡等都没问题),最后将每个桶里的元素依次取出就得到了排好序的结果。因此桶排序如何分桶是一个关键的问题,如果分的好的话,每个桶都能均匀地承载一部分数据,最后时间复杂度就会比较理想。若果分的不好,很多数据都集中在一个桶里,那么最后的时间复杂度就很差(最差情况下会略大于桶内排序算法的效率),此外桶排序还需要额外的空间。

void bucket_sort(vector<int> &nums, int bucket_cnt)
{
    // 建立bucket_cnt个桶,每个桶初始为空
    vector<vector<int>> ivec(bucket_cnt, vector<int>(0));
    // 拿出数组中最大和最小的元素来确定每个桶之间的间隔
    int min_ele = *min_element(nums.begin(), nums.end());
    int max_ele = *max_element(nums.begin(), nums.end());
    int gap = (max_ele - min_ele) / bucket_cnt;
    // 如果间隔比桶个数都小,那么直接排序可能更快
    if (gap > bucket_cnt)
    {
        // 遍历数组中每个元素
        for (auto i : nums)
        {
            // 确定元素所在的桶序号
            int bucket_num = (i-min_ele)/gap;
            // 若算出的序号等于桶个数,那么说明越界了(比如数组中最大元素就会出现这样的情况)
            if (bucket_num == bucket_cnt)
                --bucket_num;
            // 将该元素装入桶中
            ivec[bucket_num].push_back(i);
        }
        // idx记录原数组nums中的下标用于装回元素
        int idx = 0;
        // 对每个桶先排序再往nums中装入元素,最后清空桶
        for (auto &vec : ivec)
        {
            quick_sort(vec);
            for (const auto ele : vec)
                nums[idx++] = ele;
            vec.clear();
        }
    } 
    // 直接排序
    else
        quick_sort(nums);
}

分析:桶排序将原数组分为k个桶来处理,假设数组总数为n,那么每个桶装了n/k个数据,假设我们在桶的内部使用快速排序,其时间复杂度为O(klogk),那么对于所有桶来说,总的时间复杂度就是k*O((n/k)*log(n/k)) = O(n*log(n/k))。最好的情况是每个数据都装进一个桶,这样就无需排序了,因此最好的时间复杂度是O(n),平均情况的时间复杂度是O(n*log(n/k)),最坏的情况的可以接近O(nlogn),但是比O(nlogn)要更差(因为存在分桶等操作)。桶排序的空间复杂度是O(n+k),因为桶排序除了要装下原始数组长度的元素,还需要额外的k个桶,这k个桶系统会默认分配内存。桶排序是否稳定依赖于其内部的排序算法,若内部使用快排则不稳定,若内部使用归并则是稳定的。

  • 3
    点赞
  • 39
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值