数据结构与算法学习cpp(三):希尔排序、归并排序、快速排序

1、概述

对于数据量较小的排序,使用冒泡、插入、选择即可,时间复杂度为 O ( n 2 ) O(n^2) O(n2),数据量较大时会很耗时。本文介绍时间复杂度为 O ( n l o g n ) O(nlogn) O(nlogn)的三种排序:希尔排序、归并排序、快速排序。

其中,希尔排序属于插入排序,在普通插入排序上作了策略性的改进;而归并、快速排序则使用了分治的思想。

2、希尔排序

2.1 思路

希尔排序是将待排序的数组元素 按下标的一定增量分组 ,分成多个子序列,然后对各个子序列进行直接插入排序算法排序;然后依次缩减增量再进行排序,直到增量为1时,进行最后一次直接插入排序,排序结束。

最后一次排序,增量必为1。

示例:
对11个数{158,76,95,33,59,258,62,12,5,23,11}进行希尔排序。

说明:本文只为了解希尔排序本身机制,故此处只选用希尔增量,希尔增量为{ N 2 , N 4 , . . . N 2 k , 1 \frac{N}{2},\frac{N}{4},...\frac{N}{2^k},1 2N,4N,...2kN,1}可根据实际需要改变增量。

在这里插入图片描述
在这里插入图片描述

初始步长为5,分出5个子数组(已用不同颜色区分),对每个子数组插入排序;
步长逐步变化,重复上述步骤。

为何能降低时间复杂度
比如例子中的初始第9位元素:5,如果执行普通插入排序,要插到第一个需要挨个比较,移动,但是使用图示的方法,第一次排序就直接排到了第4位,相比而言,省去了很多操作步骤。

整体策略相当于,先粗略的把大小相近的数放在一起,然后逐步细化排序,而简单排序则是不管数据如何分布,都是从头排序,相比较而言,希尔排序更优秀。

由于数学水平有限,只能作粗略的定性分析,如果要深入理解,需要查阅相关文献。

2.2 cpp代码(希尔增量 )

新建类:

class Complexsort
{
public:
	void PrintResult(int* Arr, int n);
	void GetShellGap(vector<int> &Gap, int n);
	void ShellSort(const vector<int>& Gap,int* Arr, int n);
};

**获取增量:**可根据实际情况更改此函数来使用不同增量策略;

void Complexsort::GetShellGap(vector<int> &Gap, int n)
{
    if (n <= 1)
    {
        return;
    }
    Gap.clear();
    for (int i = n >> 1; i >= 1; i = i >> 1)//右移1即除以2
    {
        Gap.push_back(i);
    }

    cout << "shell gap :" << endl;
    for (vector<int>::iterator itergap = Gap.begin(); itergap != Gap.end(); itergap++)
    {
       
        cout << setw(4) << *itergap;
    }
    cout << endl;
}

排序实现:

void Complexsort::ShellSort(const vector<int>& Gap, int* Arr, int n)
{
    if (n <= 1|| Arr==nullptr|| Gap.empty())
    {
        return;
    }
    for (vector<int>::const_iterator itergap = Gap.begin(); itergap != Gap.end(); itergap++)//每次执行完更改步长
    {
        for (int i = *itergap; i < n; i++)//分组,共*itergap组,每组插入排序从第二个元素开始,所以初始i = *itergap;
        {
            //每一组内部执行插入排序
            int InsertVal = Arr[i];
            int j = i - *itergap;
            for (; j >= 0; j -= *itergap)
            {
                if (Arr[j] > InsertVal)
                {
                    Arr[j+*itergap] = Arr[j];
                }
                else
                {
                    break;//如果在已排序区间内找到<=插入值的数,则退出
                }
            }
            Arr[j+*itergap] = InsertVal;
        }
    }

}

测试:

int main()
{
    int TestA[11] = { 158,76,95,33,59,258,62,12,5,23,11 };
    int len = 11;
    Complexsort Comsort;
    cout << "testA :" << endl;
    Comsort.PrintResult(TestA, len);

    vector<int> Gap;
    Comsort.GetShellGap(Gap, len);
    Comsort.ShellSort(Gap,TestA, len);

    cout << "sort result :" << endl;
    Comsort.PrintResult(TestA, len);  
}

结果:
在这里插入图片描述
说明:
如果将这里的步长设为1,则这里的子数组排序算法与数据结构与算法学习cpp(二):冒泡排序、插入排序、选择排序中的插入排序代码相同。

如果不习惯j出现负值,可将代码修改如下

void Complexsort::ShellSort(const vector<int>& Gap, int* Arr, int n)
{
    if (n <= 1 || Arr == nullptr || Gap.empty())
    {
        return;
    }
    for (vector<int>::const_iterator itergap = Gap.begin(); itergap != Gap.end(); itergap++)//每次执行完更改步长
    {
        for (int i = *itergap; i < n; i++)//分组,共*itergap组,每组插入排序从第二个元素开始,所以初始i = *itergap;
        {
            //每一组内部执行插入排序
            int InsertVal = Arr[i];
            unsigned int j = i ; //j不会为负
            for (; j >= *itergap; j -= *itergap)
            {
                if (Arr[j - *itergap] > InsertVal)
                {
                    Arr[j ] = Arr[j- *itergap];
                }
                else
                {
                    break;//如果在已排序区间内找到<=插入值的数,说明已找到位置,退出
                }
            }
            Arr[j] = InsertVal;
        }
    }

}

意思相同。

2.3 性能

稳定性: 由于每次步长不同,很容易交换相同大小的元素位置,所以为不稳定;

空间复杂度: 未申请新空间,为 O ( 1 ) O(1) O(1)

时间复杂度: 希尔排序的子数组都是用插入排序,若使用希尔增量且为最坏情况,则时间复杂度也为 O ( n 2 ) O(n^2) O(n2),平均复杂度为 O ( n l o g n ) O(nlogn) O(nlogn),不知如何证明平均复杂度,故只做了解。不同增量的时间复杂度也不同,如何选取增量是一个数学难题。

3、归并排序

3.1 思路

分治:
先把数组从中间分成前后两部分,然后对前后两部分分别排序,再将排好序的两部分合并在一起。

递归思维:
将大问题分解成小问题,小问题继续分解成更小的问题,只要一层的问题模型是一样的,就可以一直进行下去,直到满足终止条件。
所以只需要关注:1、递推公式;2、终止条件;

思维误区:
递归是一种从顶向下的思维,而我们分析问题时,习惯性的先把当前问题解决,然后将返回值代入到下一层问题去解,这样在跟逻辑的时候,很容易被一层层的问题绕进去。

我们正常思维更像是是一种从底向上的方式,所以我们更容易理解迭代的逻辑,只是要实现迭代的逻辑需要把握整体规律,而递归只需要处理单个问题模型即可。

所以递归代码实现简单,但是想跟每一步的处理逻辑很难;迭代的代码实现起来困难,但是逻辑符合我们的思维方式,理解起来容易。

示例:
对11个数{158,76,95,33,59,258,62,12,5,23,11}进行归并排序。

图示:
在这里插入图片描述

用偶数个数的排序,作图分析更方便,不过为了说明情况,在此用奇数举例。图中可以看出,奇数个数排序的情况下,有些数连续两步都没有变化,如初始第2、5、8、10个元素。看起来比较特殊,但是实际上这跟偶数个数排序是一样的,因为分割到最后都是单个元素,然后再逐步合并。

归并:
归并的主要思想是申请一个临时数组,定义两个指针分别操作两个待合并的数组,依次比较两个指针指向元素大小,选出小的放进临时数组,然后将当前指针移动到下一位继续比较,直到其中一个数组的指针到底部,此时如果另外一个数组还有元素未处理完,则直接复制到临时数组。最后将临时数组覆盖原数组即可。

由于是递归,所以待合并的两个数组,默认其内部已经完成从小到大的排序。

3.2 cpp代码

3.2.1递归方式

为了清楚地显示排序过程,每一次归并开始都打印了数组起始情况及归并结果。
归并:

//合并并排序数组
void Complexsort::Merge(int* Arr, int Lstart, int Lend, int Rstart, int Rend)
{
    static int step = 1;
    cout << "step"<< step++ <<":" << endl;
    cout << " Lstart = " << Lstart << " Lend = " << Lend <<"   ****    Rstart = " << Rstart << " Rend = " << Rend << endl;

    int i = Lstart;
    int j = Rstart;
    int n = Rend - Lstart + 1;
    int* temp = new int[n];
    int k = 0;
    while (i <= Lend && j <= Rend)
    {
        if (Arr[i] < Arr[j])//把小的放进temp
        {
            temp[k++] = Arr[i++];
        }
        else
        {
            temp[k++] = Arr[j++];
        }
    }
    /*如果两个数组中有一个指针没有遍历完,则直接复制该数组剩余元素(该数组在上一步已经排序完成)*/
    while (i <= Lend)
    {
        temp[k++] = Arr[i++];
    }
    while (j <= Rend)
    {
        temp[k++] = Arr[j++];
    }
    cout << "cur Arr = ";
    for (k = 0; k < n; k++)
    {
        Arr[Lstart + k] = temp[k];
        cout << setw(4) << Arr[Lstart + k];
    }
    delete [] temp;
    cout << endl<<endl;
}
  

递归排序:

//由于递归操作,每次数组的下标不确定,所以需要传入当前数组的下标范围
void Complexsort::RecurseMergeSort(int* Arr, int start, int end)
{
    if (start>= end ||Arr == nullptr)//只有一个元素无需排序
    {
        return;
    }
    //将传入的数组对半分
    int LeftStart = start;
    int LeftEnd   = (start+end)/2;
    int RightStart = LeftEnd + 1;
    int RightEnd = end;

    //对数组左右两部分排序后合并
    RecurseMergeSort(Arr, LeftStart, LeftEnd);
    RecurseMergeSort(Arr, RightStart, RightEnd);

    Merge(Arr, LeftStart, LeftEnd, RightStart, RightEnd);
}

测试:

int main()
{
    int TestA[11] = { 158,76,95,33,59,258,62,12,5,23,11 };
    int len = 11;
    Complexsort Comsort;

    cout << "testA :" << endl;
    Comsort.PrintResult(TestA, len);
    cout << endl;

    Comsort.RecurseMergeSort(TestA,0,len-1);

    cout << "sort result :" << endl;
    Comsort.PrintResult(TestA, len);
}

结果:
在这里插入图片描述
从结果可以看出,归并时候先归并0 ~ 5,再归并6~10。

3.2.2迭代方式

递归调用次数多,会调用大量的栈空间,且容易出现重复计算的情况,影响性能,可将排序用迭代方式来实现。
迭代归并排序:

void Complexsort::IterMergeSort(int* Arr, int n )
{
    if (n <= 1 || Arr == nullptr)//只有一个元素无需排序
    {
        return;
    }

    for (int step = 1; step < n; step <<=1)
    {
        //将数组分成2*step大小的小块
        for (int index = 0; index < n; index += step * 2 )
        {
            Merge(Arr, index, min(index + step - 1, n - 1), min(index + step , n - 1), min(index + step + step - 1, n - 1));
        }
    }

}

测试:

int main()
{
    int TestA[11] = { 158,76,95,33,59,258,62,12,5,23,11 };
    int len = 11;
    Complexsort Comsort;

    cout << "testA :" << endl;
    Comsort.PrintResult(TestA, len);
    cout << endl;

    Comsort.IterMergeSort(TestA, len);

    cout << "sort result :" << endl;
    Comsort.PrintResult(TestA, len);
}

结果:
在这里插入图片描述
迭代和递归的方式在分组上有所不同。递归是从顶向下,逐步分割,最后分割成单个元素,再两两重组;而迭代是自下而上,直接从单个元素开始,两两组合。

3.3 性能

仅分析递归方式。
稳定性:
归并时,如果前后位置已拍好,不会再作改变,稳定。
时间复杂度:
根据递推公式得:
T ( n ) = 2 ∗ T ( n / 2 ) + n T(n) = 2*T(n/2) + n T(n)=2T(n/2)+n
= 2 ∗ ( 2 ∗ T ( n / 4 ) + n / 2 ) + n = 2*(2*T(n/4) + n/2) + n =2(2T(n/4)+n/2)+n
= 4 ∗ T ( n / 4 ) + 2 ∗ n = 4*T(n/4) + 2*n =4T(n/4)+2n
= 4 ∗ ( 2 ∗ T ( n / 8 ) + n / 4 ) + 2 ∗ n = 8 ∗ T ( n / 8 ) + 3 ∗ n = 4*(2*T(n/8) + n/4) + 2*n = 8*T(n/8) + 3*n =4(2T(n/8)+n/4)+2n=8T(n/8)+3n
= 8 ∗ ( 2 ∗ T ( n / 16 ) + n / 8 ) + 3 ∗ n = 16 ∗ T ( n / 16 ) + 4 ∗ n = 8*(2*T(n/16) + n/8) + 3*n = 16*T(n/16) + 4*n =8(2T(n/16)+n/8)+3n=16T(n/16)+4n
. . . . . . ...... ......
= 2 k ∗ T ( n / 2 k ) + k ∗ n = 2^k * T(n/2^k) + k * n =2kT(n/2k)+kn

n = 2 k n= 2^k n=2k,则 k = l o g 2 n k=log_2n k=log2n;
T ( 2 k ) = 2 k ∗ T ( 1 ) + k ∗ 2 k T(2^k) =2^k * T(1)+k*2^k T(2k)=2kT(1)+k2k
T ( n ) = n ∗ T ( 1 ) + n l o g 2 n = o ( n l o g n ) T(n) =n* T(1)+nlog_2n=o(nlogn) T(n)=nT(1)+nlog2n=o(nlogn)
空间复杂度:
每次归并申请临时空间后删除,为 o ( n ) o(n) o(n);

4、快速排序

4.1 思路

如果要排序数组中下标从 start 到 end之间的一组数据,我们选择 start 到 end之间的任意一个数据作为 pivot(枢轴)。
我们遍历 start 到 end 之间的数据,将小于 pivot 的放到左边,将大于 pivot 的放到右边,将 pivot 放到中间。经过这一步骤之后,数组 start 到 end之间的数据就被分成了三个部分,前面 start 到 pivot-1之间都是小于 pivot 的,中间是 pivot,后面的 pivot+1 到 end 之间是大于 pivot 的。

关键是找到枢轴pivot ,把它放在正确位置。

示例:
对11个数{158,76,95,33,59,258,62,12,5,23,11}进行归并排序。
单次操作图示:
在这里插入图片描述
如何将多次操作组合起来完成整个数组排序,常见有以下两种方式:

4.1.1 递归方式

根据分治、递归的处理思想,我们可以用递归排序下标从 start 到 pivot-1之间的数据和下标从 pivot+1 到 end 之间的数据,直到区间缩小为 1,就说明所有的数据都有序了。

4.1.2 栈方式

归并迭代与递归的思考方式相反,因为我们能判断出,归并排序中分解的最终状态一定是单个元素,所以我们从底向上思考,让他们按步长两两排序即可。但是对于快速排序,用这种方式思考比较困难,因此还是按从上向下的方式思考问题,用栈来代替递归。
用栈保存每一个待排序子串的首尾元素下标,下一次while循环时取出这个范围,对这段子序列进行partition操作。
当然,也有大神实现了这个算法,本文暂不研究,留坑以后研究,链接供参考:
知乎:如何用非递归、不用栈的方法,实现原位(in-place)的快速排序?

4.2 cpp代码

找枢轴:

int Complexsort::Partion(int* Arr, int start, int end)
{
    int i = start, j = end;//两个指针分别从头尾向相向出发
    int pivot = start;//选取基准点,如果选择左边,则要右边指针先动

    //打印信息
    cout <<"start = "<<start<<", end = "<<end<< ", Arr[start] = Arr["<<start<<"] = "<< Arr[start] << ":" << endl << "before partion, Arr = ";
    PrintResult(Arr, 11);

    while (i < j)
    {
        while (i < j && Arr[j] >= Arr[pivot]) //从后向前一直找到比基准点小的值
        {
            j--;
        }
        while (i < j && Arr[i] <= Arr[pivot])//从前向后一直找比基准点大的值
        {
            i++;
        }
        swap(Arr[i], Arr[j]);
    }

    pivot = i;
    swap(Arr[i], Arr[start]);//将pivot放入正确位置

    //打印信息
    cout << "pivot = " << i << endl;
    cout << "after partion,  Arr = ";
    PrintResult(Arr, 11);
    cout << endl;
    return pivot;
}

4.2.1递归方式

递归排序:

void Complexsort::RecurseQuickSort(int* Arr, int start, int end)
{
    int pivot;
    if (start < end)
    {
        pivot = Partion(Arr, start, end);
        RecurseQuickSort(Arr,start, pivot-1);
        RecurseQuickSort(Arr, pivot + 1, end);
    }
}

测试:

int main()
{
    int TestA[11] = { 158,76,95,33,59,258,62,12,5,23,11 };
    int len = 11;
    Complexsort Comsort;

    cout << "testA :" << endl;
    Comsort.PrintResult(TestA, len);
    cout << endl;

    Comsort.RecurseQuickSort(TestA,0,10);

    cout << "sort result :" << endl;
    Comsort.PrintResult(TestA, len);
}

结果:
在这里插入图片描述
从打印结果可看出整体步骤:

1、先确定元素158的位置为9,然后对第0-8和第10个位置操作,由于右侧只有一个元素,所以无需排序;
2、确定元素23的位置为3,然后对第0-2和第4-8个位置元素继续操作
…以此类推,直到start == end,即只有一个元素时,操作停止。

4.2.2 栈方式

据说stl里的栈在这里用性能不佳(未测试),因此自己实现,正好加深一下入栈出栈的理解。
栈方式排序:

void Complexsort::IterQuickSort(int* Arr, int start, int end)
{
    int mystack[100] = {0};

    int top = -1;

    mystack[++top] = start;
    mystack[++top] = end;

    while (top >= 0)
    {
        end = mystack[top--];
        start = mystack[top--];

        int pivot = Partion(Arr, start, end);//找枢轴
        //枢轴左侧大于1个元素则对左侧进行操作,仅剩一个元素或没有则无需操作
        if (pivot - 1 > start)
        {
           mystack[++top] = start;
           mystack[++top] = pivot - 1;
        }
        //右侧同理
        if (pivot + 1 < end)
        {
            mystack[++top] = pivot + 1;
            mystack[++top] = end;
        }
    }
}

测试:

int main()
{
    int TestA[11] = { 158,76,95,33,59,258,62,12,5,23,11 };
    int len = 11;
    Complexsort Comsort;

    cout << "testA :" << endl;
    Comsort.PrintResult(TestA, len);
    cout << endl;

    Comsort.IterQuickSort(TestA, 0, 10);

    cout << "sort result :" << endl;
    Comsort.PrintResult(TestA, len);
}

结果:

在这里插入图片描述
说明:
1、栈使用,注意入栈先加后赋值,出栈先取值后减;
2、从结果可看出,辅助栈方式与递归方式的实现过程几乎相同,顺序略有不同,不过性能上使用辅助栈的方式应该比递归要好(未实测),因为除了多了下标的存储,并未存储多余的函数栈;

4.3 性能

仅分析递归方式。
稳定性:
已有顺序会改变,不稳定。
时间复杂度:
与归并思路相似,正常情况下时间复杂度均为 o ( n l o g n ) o(nlogn) o(nlogn),但是假设从小到大排序,初始选择的基准点为最大,则快排就退化成了冒泡排序,为 o ( n 2 ) o(n^2) o(n2);
为减少出现最坏情况概率,可以选则基准点时运用一些策略,如取随机数,三点取中等,具体做法是按策略选择数组下标,然后将该元素与最左边元素调换,剩下步骤跟上述处理相同。
空间复杂度:
快排的递归与归并的递归略有不同。

消耗栈空间的都是算法核心部分,归并为Merge,快排为Partion,但是位置不同,导致归并中本次递归的栈释放了才执行下一次递归,但是快排没有(这也是为什么快排的迭代不好从底向上思考的原因,只能按照递归的从上向下的思想,用辅助栈去实现),所以栈每递归一次增加一次。(水平有限,不知道如何用准确的数学语言描述)

快速排序为原地排序,单次递归为 O ( 1 ) O(1) O(1),共递归 o ( l o g n ) o(logn) o(logn)次,所以总的空间复杂度为 o ( l o g n ) o(logn) o(logn);最坏情况下递归n次,为 O ( n ) O(n) O(n)

5、总结

1、实际处理较大数据量的排序时,可以将这些算法相结合,libc库自带qsort用的就是这种策略;我们从希尔排序中也能体会到这一点,按策略划分后对子数组使用插入排序;

2、如果对处理效率很敏感,则应根据实际情况选择适当策略,如希尔排序的增量选择,快速排序的基准数选择等。
gitee代码

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值