【数据结构基础/经典排序汇总】各类排序汇总第三弹之快速排序(快排)&归并排序&计数排序(编写思路加逻辑分析加代码实操,一应俱全的汇总)

快速排序

hoare版本

编写思路

一般选择最左边/最右边做key,单趟排序的目标是:左边的值比key要小,右边的值比key大,key来到了正确的地方了。所以要求

  • 左边找到大,右边找小,都找到之后交换顺序,

  • 注意最左边做key值时,需要右边先走(此时左右相遇点比key小)。右边做key,左边先走(此时左右相遇点比key大)。

  • 左边和右边最终会相遇,相遇点跟左边的key位置的值要交换。

    void PartQuickSort(int *a, int n)
    {
        int left = 0;
        int right = n - 1;
        int key = 0;
        //单趟
        //找到比key小的值
        while (right < left)
        {
          	//左边先走
            while (a[right] > a[key])
            {
                right--;
            }
            while (a[left] < a[key])
            {
                left++;
            }
            Swap(a[right], a[left]);
        }
        Swap(a[right], a[key]);
    }
    

    考虑下面两个特殊情况

    1
    2
    3
    4
    5

    会导致right找不到比key小的数,但是right–并不会中止程序最终导致越界。所以需要加对循环中止的判断。

    5
    5
    5
    5
    5

    程序判断的时候不会进入两个while语句,直接立即交换,会陷入死循环。所以需要修改>为>=,方便进入循环。

    所以正确的单趟快排代码如下:

    void PartQuickSort(int *a, int left,int right)
    {
        int key = left;
        //单趟
        //找到比key小的值
        while (right > left)
        {
            //左边先走
            while (right > left && a[right] >= a[key])
            {
                right--;
                //如果面临 5 6 7 8 9 的情况
                //就会排序排出去加个判断
            }
            while (right > left && a[left] <= a[key])
            {
                left++;
            }
            Swap(&a[right], &a[left]);
        }
        Swap(&a[right], &a[key]);
    }
    

    单趟排完,比key小的都在左边,比key大的都在右边。如果两个子区间有序,那就整体有序了。左、右子区间再进行快排,左子区间的左右子区间再进行快排,以此类推,进行一个递归。

    void QuickSort(int *a, int left, int right)
    {
        if (left >= right)
            return;
        int key = PartQuickSort(a, left, right);
        QuickSort(a, left, key - 1);
        QuickSort(a, key + 1, right);
    }
    
时间复杂度

考虑一种极限情况,如果每次key都是选到中位数,那么其排序过程就类似于二叉树。

单趟情况的是O(N)

考虑每次找到key,key的左边和右边元素个数相加起来依旧接近于N,进行快速排序递归的次数为log2N,所以时间复杂度近似于:O(N*log2N)

快排的缺陷

key选最左边,如果单趟排完序之后key依然在很靠左的位置,那么会导致它的时间复杂度为O(N2)。

递归程序缺陷:

  • 相比循环程序,性能差。(针对早期编译器是这样。因为对于递归调用,建立栈帧优化不大。现在新编译器优化很好,递归的性能与循环相比差不了多少)
  • 递归深度太深,会导致栈溢出。

如何解决快排面对有序数列选key的问题呢?

  • 随机选key ()
  • 三数取中,在左边中间和右边三者中选不大不小的值。

而且快排面对一些极端情景,比如全部是相同的值,或者是全部是232323232323232323这种交替重叠数列,是非常难处理的。如果面对这种可以考虑换用希尔排序。

接下来我们用三数取中去优化hoare版本代码。

代码优化

三数取中,我们先写一个三数取中函数。

int GetMidIndex(int *a, int left, int right)
{
    // int mid = (left + right) / 2;
    int mid = left + (right - left) / 2; //减少溢出
    // int mid = left + ((right-left)>>1);//移位和上面的没差别,但是移位运算优先级低,要注意加括号。
    if (a[left] < a[mid])
    {
        if (a[mid] < a[right])
            return mid;
        else if (a[right] < a[left])
            return left;
        else
            return right;
    }
    else
    {
        if (a[mid] > a[right])
            return mid;
        else if (a[left] > a[right])
            return right;
        else
            return left;
    }
}

这样选择中位数之后,是不是就违背了我们key选最左,从右开始找的原则呢?是的。因此,我们需要继续调整,把找出来的中间值换到最左边去,从而让代码逻辑一致。

int PartQuickSort(int *a, int left, int right)
{
    //三数取中
    int mid = GetMidIndex(a, left, right);
    Swap(&a[mid], &a[left]);
    int key = left;
    //单趟
    //找到比key小的值
    while (right > left)
    {
        //左边先走
        while (right > left && a[right] >= a[key])
        {
            right--;
            //如果面临 5 6 7 8 9 的情况
            //就会排序排出去加个判断
        }
        while (right > left && a[left] <= a[key])
        {
            left++;
        }
        Swap(&a[right], &a[left]);
    }
    Swap(&a[right], &a[key]);
    return right;
}

三数取中,让原来有序的最坏情况,通过选中位数做key,变成了最好情况。

挖坑法

挖坑法是hoare版本的变形,主要针对于原来key选左边,要从右边先走的问题。

左边key位置挖坑(存储key的值),然后坑的位置在right的左侧,所以right向左走,找比key小的值放到坑里。right的位置变成新的坑,坑的位置在left右边,所以left向右走。直到二者相遇中止。

void Partion(int *a, int left, int right)
{
    //三数取中
    int mid = GetMidIndex(a, left, right);
    Swap(&a[mid], &a[left]);
    int key = a[left];
    int hole = left;
    while (right > left)
    {
        while (a[right] >= key && right > left)
        {
            right--;
        }
        a[hole] = a[right];
        hole = right;
        while (left < right && a[left] <= key)
        {
            left++;
        }
        a[hole] = a[left];
        hole = left;
    }
    a[hole] = key;
    key = hole;
    return key;
}

挖坑法的性能与hoare版本并没有本质的区别,只是略优,因为不用再处理key在左边,先走右边的步骤了。

前后指针作key

这里的前后指针实际上是前后下标,其中cur找小,并把小的往左边翻,prev把大的序列往右边推。

这个方法很简洁,而且写起来非常快,所以这三种方法里面首推这一种。但是效率并没有本质提升,因为这三种单趟排序都是O(N)。

void PrevCurPointer(int *a, int left, int right)
{
    //三数取中挪过来,防止最坏情况发生
    int mid = GetMidIndex(a, left, right);
    Swap(&a[mid], &a[left]);
    int key = left;

    int prev = left;
    int cur = left + 1;
    while (cur <= right)
    {
        if (a[cur] <= a[key])
        {
            Swap(a[cur], a[prev++]);
        }
        else
        {
            cur++;
        }
    }
    Swap(&a[prev], &a[key]);
    return prev;
}

对递归的优化

因为划分子区间后实际上递归的次数是每次都成倍增长的,尤其是最后几次的递归次数是非常多的。如果我们用条件判断一下,来提前终止最后几次的递归,不采用递归的方式解决后几层的排序了,而是采用其他简单排序的方式,刚刚比较了用直接插入排序比较好。

void QuickSort(int *a, int left, int right)
{
    if (left >= right)
        return;
    //快排的小区间优化,当分割到小区间后,不再用递归分割思想让这段子区间有序
    //对于递归快排,减少递归次数
    if(right-left+1<10)
    {
        InsertSort(a+left, right - left + 1);
    }
    int key = PartQuickSort(a, left, right);
    QuickSort(a, left, key - 1);
    QuickSort(a, key + 1, right);
}

非递归快排

思考递归快排,在排序的时候,找到key,分左右区间, 左右区间都在栈帧中储存,所以不会弄丢。如果用非递归快排,就必须先储存好左右区间的值,否则我们等我们排完左区间,不知道右区间的起始和中止。

可以考虑存放在栈中,栈先入后出,先处理左区间,就先入右区间。左区间出的时候入左区间的右、左区间,直到不需要处理的时候不入栈了。当栈中为空,则排序完毕。

//用栈
void QuickSortNonR(int *a, int left, int right)
{
    ST st;
    StackInit(&st);
    StackPush(&st, left);
    StackPush(&st, right);
    while (!StackEmpty(&st))
    {
        int end = StackTop(&st);
        StackPop(&st);
        int begin = StackTop(&st);
        StackPop(&st);

        int key = Pariton(a, begin, end);

        if(key+1 <end)
        {
            StackPush(&st, key + 1);
            StackPush(&st, end);
        }
        if(begin <key-1)
        {
            StackPush(&st, begin);
            StackPush(&st, key);
        }
    }
    StackDestroy(&st);
}

虽然是采用非递归,但是还是递归思想。先走左边,走遍左边子区间后再走右区间。又是因为用的是栈,先进后出。

为什么采用非递归?因为递归深度太深的程序,只能考虑非递归,储存在堆里面。

归并排序

如果给两个有序数组,我们可以把他们归并成一个有序数组。

假设我们排升序

假设数组的左边有序,右边也有序,O(N)可以归并成一个有序数组。开辟一个数组空间,设定双指针,谁小放谁,先走完的程序终止并把没走完的元素放到新数组后面去。

前提**是左右两边都有序才可以用O(N)的时间复杂度完成排序。**如果给我们一个完全无序的数组呢?我们依旧采用分置思想,让左右区间有序就实际上让左右区间的左右区间有序,递归解决。

创立子函数,

void _MergeSort(int *a, int left, int right, int *tmp)

函数创建好之后首先要写判断条件,记得写。可能一开始对于递归种植条件不清晰,但是一定要记得写,否则递归无法停止了。然后子区间进行递归调用,

    if (left >= right)
    {
        return;
    }
    int mid = left + (right - left) / 2;
    // [left, mid] [mid+1, right]
    _MergeSort(a, left, mid, tmp);
    _MergeSort(a, mid + 1, right, tmp);

划分了区间,[left, mid] , [mid+1, right]。

首先调用左区间的left和right即:left & mid

右区间的left和right即:mid+1 & right

数组
左区间
右区间
左区间
右区间
左区间
右区间

划分到左区间的left = right时,只剩下一个元素了,有序。同时右区间也变成有序了,开始归并,合并到tmp数组里。合并到tmp数组里的是一个有序的数组,我们知道有序的数组才能进行继续归并,所以可以以此类推,直至整个数组有序。

    int begin1 = left, end1 = mid;
    int begin2 = mid + 1, end2 = right;
    int tmpleft = left;
    while (begin1 <= end1 && begin2 <= end2)
    {
        if (a[begin1] < a[begin2])
        {
            tmp[tmpleft++] = a[begin1++];
        }
        else
        {
            tmp[tmpleft++] = a[begin2++];
        }
    }

随后我们需要注意,归并的结束时左右两边只有一个走到头了,但是因为都有序,我们可以把没走到头的直接都给到tmp数组的后面。

    while (begin1 <= end1)
    {
        tmp[tmpleft++] = a[begin1++];
    }
    while (begin2 <= end2)
    {
        tmp[tmpleft++] = a[begin2++];
    }
    //tmp 数组拷贝回a
    for (int j = left; j <= right;j++)
    {
        a[j] = tmp[j];
    }

因为我们的tmp数组是需要临时创建的,所以主函数进行一个开辟空间和子函数的调用即可。不要忘记free掉malloc的空间!

void MergeSort(int *a, int n)
{
    int *tmp = (int *)malloc(sizeof(int) * n);
    if (tmp == NULL)
    {
        perror(malloc);
        exit(-1);
    }
    _MergeSort(a, 0, n - 1, tmp);

    free(tmp);
    tmp = NULL;
}
void _MergeSort(int *a, int left, int right, int *tmp)
{
    if (left >= right)
    {
        return;
    }
    int mid = left + (right - left) / 2;
    // [left, mid] [mid+1, right]
    _MergeSort(a, left, mid, tmp);
    _MergeSort(a, mid + 1, right, tmp);
    int begin1 = left, end1 = mid;
    int begin2 = mid + 1, end2 = right;
    int tmpleft = left;
    while (begin1 <= end1 && begin2 <= end2)
    {
        if (a[begin1] < a[begin2])
        {
            tmp[tmpleft++] = a[begin1++];
        }
        else
        {
            tmp[tmpleft++] = a[begin2++];
        }
    }
    while (begin1 <= end1)
    {
        tmp[tmpleft++] = a[begin1++];
    }
    while (begin2 <= end2)
    {
        tmp[tmpleft++] = a[begin2++];
    }
    // tmp 数组拷贝回a
    for (int j = left; j <= right; j++)
    {
        a[j] = tmp[j];
    }
}
非递归归并排序

非递归归并排序的编写思路就是先把数组一一归并,再两个两个归并,再四个,再八个。。直到gap>=数组元素个数n

主要的难点在于边界的控制,因为在这里非常容易越界访问。如果我们这么写:

    while (gap < n)
    {
        for (int i = 0; i < n; i += 2 * gap) // [i ~ i+gap-1] [i+gap ~ i+gap*2-1]
        {
            int begin1 = i, end1 = (i + gap - 1);
            int begin2 = i + gap, end2 = (i + gap * 2 - 1);
            int index = 0;
            while (begin1 <= end1 && begin2 <= end2)
            {
                if (begin1 < begin2)
                {
                    tmp[index++] = a[begin1++];
                }
                else
                {
                    tmp[index++] = a[begin2++];
                }
            }

这样会造成如果数组为奇数的时候会造成:

  • end1越界,此时end2和begin2也都越界了。
  • 只有end2越界。
  • end2和begin2越界,分出来的数组根本不存在。

我们需要修正这些边界让其不再越界。

void MergeSortNonR(int *a, int n)
{
    int *tmp = (int *)malloc(sizeof(int) * n);
    if (tmp == NULL)
    {
        perror(malloc);
        exit(-1);
    }
    int gap = 1;
    while (gap < n)
    {
        for (int i = 0; i < n; i += 2 * gap) // [i ~ i+gap-1] [i+gap ~ i+gap*2-1]
        {
            int begin1 = i, end1 = (i + gap - 1) < n ? (i + gap - 1) : (n - 1);
            int begin2 = (i + gap) < n ? (i + gap) : (n - 1), end2 = (i + gap * 2 - 1) < n ? (i + gap * 2 - 1) : (n - 1);
            int index = 0;
            // end1越界,begin2,end2不存在
            // begin2,end2 不存在

            while (begin1 <= end1 && begin2 <= end2)
            {
                if (begin1 < begin2)
                {
                    tmp[index++] = a[begin1++];
                }
                else
                {
                    tmp[index++] = a[begin2++];
                }
            }
            while (begin1 <= end1)
            {
                tmp[index++] = a[begin1++];
            }
            while (begin2 <= end2)
            {
                //如果是[8,8] [8,8]的情况,即begin1 = end1 , begin2 = end2 
                //index会越界加条件判断
                if(index >= end2)
                    break;
                tmp[index++] = a[begin2++];
            }
        }
        gap *= 2;
    }
    //归并数组数据拷贝回原数组
    for (int j = 0; j <= n - 1; j++)
    {
        a[j] = tmp[j];
    }
    free(tmp);
    tmp = NULL;
}

处理比较麻烦的原因是:tmp数组的拷贝在循环外面。如果在里面:

void MergeSortNonR2(int *a, int n)
{
    int *tmp = (int *)malloc(sizeof(int) * n);
    if (tmp == NULL)
    {
        perror(malloc);
        exit(-1);
    }
    int gap = 1;
    while (gap < n)
    {
        for (int i = 0; i < n; i += 2 * gap) // [i ~ i+gap-1] [i+gap ~ i+gap*2-1]
        {
            int begin1 = i, end1 = (i + gap - 1);
            int begin2 = (i + gap), end2 = (i + gap * 2 - 1);
            int index = 0;
            // end1越界,或者 begin2越界
            if(end1>n ||begin2 > n)
            {
                break;
            }
            // end2越界
            if(end2>n)
            {
                end2 = n - 1;
            }
            while (begin1 <= end1 && begin2 <= end2)
            {
                if (begin1 < begin2)
                {
                    tmp[index++] = a[begin1++];
                }
                else
                {
                    tmp[index++] = a[begin2++];
                }
            }
            while (begin1 <= end1)
            {
                tmp[index++] = a[begin1++];
            }
            while (begin2 <= end2)
            {
                tmp[index++] = a[begin2++];
            }

            //归并数组数据拷贝回原数组
            for (int j = i; j <= end2; j++)
            {
                a[j] = tmp[j];
            }
        }
        gap *= 2;

    }

    free(tmp);
    tmp = NULL;
}

计数排序

不去进行数据的比较,而是统计数据出现的次数。

创立一个数组然后遍历原数组,将原数组中每个数出现的次数分别放在新创立的数组对应的位置上。时间复杂度为O(N+range) 其中range是原数组数据的范围。

但是如果有如下一组数组:

1000
1200
1500
1800
2100
2200

如果我们按照原来那种思路,新的空间需要开辟0~2200个空间,优化一下,我们可以开辟 max - min + 1个空间,这样会节省空间。同时我们发现,这样新数组的下标就不对应着原数组的元素值了,这时候我们需要建立相对映射。新数组下标i,原数组元素大小为x, i = x - min。同样,我们如果要把新数组对应回原数组,我们应该用 i + min 得到 x。

计数排序适合数据范围比较集中的数组。

void CountSort(int*a,int n)
{
    int max=a[0], min=a[0];
    for (int i = 1; i < n;i++)
    {
        if(a[i]>max)
        {
            max = a[i];
        }
        if(a[i]<min)
        {
            min = a[i];
        }
    }
    int range = max - min + 1;
    int *count = (int *)malloc(sizeof(int) * range);
    memset(count, 0, sizeof(int) * range);
    if (!count)
    {
        perror(malloc);
        exit(-1);
    }
    //统计次数
    for (int i = 0; i < n;i++)
    {
        count[a[i] - min]++;
    }
    //根据次数进行排序
    int j = 0;
    for (int i = 0; i < range; i++)
    {
        while(count[i]--)
        {
            a[j++] = i + min;
        }
    }
}

时间复杂度

计数排序的时间复杂度为 O(Max(N, Range))

空间复杂度为O(range)

适合范围比较集中的整数数组。

范围较大或者是浮点数都不适合计数排序。

排序总结

O(N2)排序

直接插入、选择排序、冒泡排序

直接插入
直接插入最优
选择排序
冒泡排序

O(N*log2N)排序

希尔排序、堆排序、快排、归并排序

希尔排序
快排最优
堆排序
快排
归并排序

排序最首先就是掌握排序的思想,其次是所有的代码包括递归和非递归,必须能手搓出来。

稳定性

数组中相同的值,在排序以后相对位置是否变化。可能会变的就是不稳定。能保证不变就是稳定。

比如交卷排序,分数线痛,先交卷的排名在前,我们必须用稳定的排序,否则不能契合先交卷排名有限的特点。

插入排序
直接插入排序
稳定
希尔排序
不稳定
选择排序
直接选择排序
堆排序
交换排序
冒泡排序
快排
归并排序
计数排序

希尔排序,相同的值可能预排到不同的组里面。

选择排序的反例: 5 1 4 9 5 0 7

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值