常见排序算法实现和总结

一.插入排序

  1. 直接插入排序

思路:

把待排序的记录按其关键码值的大小逐个插入到一个已经排好序的有序序列中,直到所有的记录插入完为止,得到一个新的有序序列 .

其实我们在玩扑克牌的时候,也就无意间使用了直接插入排序,将还未排好的牌,逐个比较,将其插入到正确的位置,就是我们的直接插入排序算法.

具体实现:

  1. 先确定单趟排序需要进行的操作

假设排序后的序列a[n],最后一个元素的下标为end,那需要插入的新元素的下标就是end

+ 1,[1]我们将需要插入的元素先用一个临时变量tmp保存起来.

然后就是 [2]寻找tmp应该放置的位置,这里按升序进行举例

假如tmp 比当前位置的元素要小,那它应该放置在更前面的位置,当前位置的元素应该往后

移动,假如tmp比当前位置的元素要大,那我们就寻找到正确的位置,或者到了序列的开头

位置,则停止循环,放置相应的tmp即可.

// 插入排序
void InsertSort(int* a, int n)
{
    //注意:应该是< n-1,而不是< n,否则可能会出现数组越界访问的情况
    for (int i = 0; i < n - 1; ++i)
    {
        //end始终指向排序好的数组最后一个元素
        int end = i;
        //tmp为待排序的新元素,暂时保存起来
        int tmp = a[end + 1];
        while (end >= 0)
        {
            //如果待排元素,比数组中的元素小,则往后移动
            if (tmp < a[end])
            {
                a[end + 1] = a[end];
                //end--不要遗忘
                end--;
            }
            else
            {
                break;
            }
        }
        //在end + 1的位置放置上tmp
        a[end + 1] = tmp;
    }
}

最好情况: 在整个待排数组本身就是升序的情况下(正序),我们可以发现完全不需要移动,只需要比较n -1次即可,

其速度是非常快的.

最坏情况:整个待排数组本身就是降序的情况下(逆序),记录移动的次数也达到最大值,第一次循环移动

1次,第二次循环移动2次,一直到最后一次循环,移动n - 1次,全部加起来,利用等差公式可知,需

要移动n(n - 1)/2次.同样比较次数也达到最大值,其量级也是.

所以按照平均时间复杂度来看,直接插入排序是,由于没有开辟新的空间,所以空间复杂度是

.

  1. 其它插入排序

尽管直接插入排序算法很简便,且容易实现,但是,当数据量n很大时,是不宜采用直接插入排序的.

因此,我们需要在直接插入排序的基础上进一步进行改造.

大体上,可以分为两大思路,但不论是哪种思路,其本质都是想要尽快确定放置我们tmp的位置.

1.折半查找插入

第一个思路是减少比较

我们学习过二分查找,它能够在有序序列里面迅速查找到相应的元素,我们能否将其和直接插入排序结合

在一起呢?

答案是可以的.

比如我们比较的时候,我们的tmp不是和有序序列的元素直接一个个进行比较,而是和a[mid]

进行比较,这样一次比较,就可以减少一半需要比较的元素区间,从而达到更快确定tmp放置位置的

目的.

void BInsertSort(int* a, int n)
{
    for (int i = 0; i < n - 1; i++)
    {
        int end = i;
        int tmp = a[end + 1];
        int left = 0, right = end;
        //查找应该放置tmp的位置(二分查找)
        while (left <= right)
        {   
            //避免超出int范围,因此采取这样的方式编写
            int mid = left + (right - left) / 2;
            if (tmp <= a[mid])
            {
                right = mid - 1;
            }
            else
            {
                left = mid + 1;
            }
        }
       //从查找到的位置逐个往后移动
        while (end >= right + 1)
        {
            if (tmp < a[end])
            {
                a[end + 1] = a[end];
                end--;
            }
            else
            {
                break;
            }
        }
        a[end + 1] = tmp;
    }
}

时间比较上来看,折半查找减少了关键字间的比较次数,而记录的移动次数不变.

因此总体上来看,虽然效率有所提高,不过还是量级的.

2.2-路插入排序

第二个思路是减少我们的移动次数

2-路插入排序是在折半插入排序上再进行改造,其目的是减少排序过程中移动记录的次数.

它的大致思路是开辟一个与原数组同样大小的辅助空间,在新数组temp下标为0的位置,放置原数组下

标为0的元素,并假定它是排好序列中间的元素.

然后我们把新的辅助数组,看作是我们之前实现过的循环数组,并设两个指针first 和 final分别指示排

序过程中得到的有序序列的第一个记录和最后一个记录在新数组中的位置.

比temp[0]要大的元素,则一定放置在temp[0]的右边

lg.比如i = 5时,76比49要大,则我们直接移动49的右半部分即可.

比temp[0]要小的元素,则一定放置在temp[0]的左边

lg.比如i = 7时,27比49要小,则我们直接移动49的左半部分即可.

void TwowayInsertSort(int* a, int n) 
{
    // 动态开辟辅助数组
    int * temp = (int *)malloc(n*sizeof(int));  
    if (temp == NULL)
    {
        perror("malloc fail.");
        exit(-1);
    }
    int first = 0,final = 0;  // first指示开始位置,final指示最后位置
    temp[0] = a[0];  // 加入第一个元素 
    // 遍历未排序序列 
    for (int i = 1; i < n; ++i) 
    {   
        //存储需要排序的元素
        int tmp = a[i];
        // 由于循环使用数组,以下过程都需要取余操作保证数组下标合法 
        // first始终指向最小位置,此时待排序元素比最小的元素小
        if (tmp < temp[first]) 
        {  
            // 开始位置前移
            first = (first - 1 + n) % n;   
            //在first位置放置待排序元素
            temp[first] = tmp;          
        }
        // final始终指向最大位置,待插入元素比最大的元素大
        else if (tmp > temp[final]) 
        {   
            // 最后位置后移,final不会越界,可取余也可不取余
            final = (final + 1 + n) % n;   
            // 在final位置放置待排序元素
            temp[final] = tmp;  
        }
        // 插入元素比最小元素大,比最大元素小
        else 
        {   
            //插入元素比temp[0]要大,则在右半部分插入
            if (tmp >= temp[0])
            {
                int end = final;
                // 数组元素比tmp大,则往后移
                while (temp[(end + n) % n] > tmp)
                {
                    if (tmp < temp[end])
                    {
                        //相当于元素往后移动,temp[end + 1] = temp[end]
                        temp[(end + 1 + n) % n] = temp[(end + n) % n];
                        //相当于end = end - 1,不过要考虑循环数组越界问题
                        end = (end - 1 + n) % n;
                    }
                    else
                    {
                        break;
                    }
                }
                // 相当于在end + 1插入tmp
                temp[(end + 1 % n) % n] = tmp;
                // final指向最后位置
                final = (final + 1 + n) % n;
            }
            //插入元素比temp[0]要小,则在左半部分插入
            else
            {
                int begin = first;
                while (temp[(begin + n) % n] < tmp)
                {
                    if (tmp > temp[begin])
                    {
                        //相当于元素往前移动,temp[begin - 1] = temp[begin]
                        temp[(begin - 1 + n) % n] = temp[(begin + n) % n];
                        //相当于begin = begin + 1,不过要考虑循环数组越界问题
                        begin = (begin + 1 + n) % n;
                    }
                    else
                    {
                        break;
                    }
                }
                // 相当于在begin - 1插入tmp
                temp[(begin - 1 % n) % n] = tmp;
                // first指向最前位置
                first = (first - 1 + n) % n;

            }
        }
    }
    // 将排序记录复制到原来的顺序表里
    for (int k = 0; k < n; ++k) 
    {
        a[k] = temp[(first + k) % n];
    }
}

当temp[0]元素恰好是数组中最大或最小的元素时,2-路插入排序就会失去它的优势.

而且它只能减少移动次数,而不能绝对避免移动次数.

因此,它的时间复杂度仍然是的,不过相比之前有了较大的提升.

3.希尔排序

后面有人研究发现,直接插入排序之所以效率比较慢,和逆序数密不可分.

那什么是逆序数呢?

学过线性代数的人可能会对这个概念有所了解,假如一个序列按照升序排列1 2 3 4 5,我们规定它的逆

序数就是0,而假如此时4和5调换了位置,5在4的前面(1 2 3 5 4).此时逆序数就为1.

通俗点讲,假如原本排在后面的数字,插队,排在了前面,它就和比它小的数字构成了一个逆序对.

假如我们能够一次交换,能够消除多个逆序对,那排序的速度自然会大大提高,于是就有人提出了希尔排

序.

希尔排序的核心思想就是:

通过预排序,将大的数字尽快放到后面,将小的数字尽快放到前面,也就是达到一次交换,消除多个逆序

对的目的.

【1】预排序,预排序就是通过设置增量gap,将一个待排数组,划分为n - gap组,先分别对

每组进行直接插入排序.

【2】调整gap,使其不断减小,同时重复【1】步骤

【3】当gap == 1时,也就等同于直接插入排序,但此时,数组已经基本有序,所以直接插入排序的优

势会非常显著发挥出来.

比如说下图,我们一开始设gap == 5,那9和3就被分为1组,对其进行直接插入排序,可以发现9直

接就移动到数组的后面去,一次就消除了多个逆序对.

具体实现:

  1. 先确定单趟排序需要进行的操作

假设每个分好组的序列a[n],它最后一个元素的下标为end,那需要插入的新元素的下标

就是end + gap,[1]我们将需要插入的元素先用一个临时变量tmp保存起来.

然后就是 [2]寻找tmp应该放置的位置,这里按升序进行举例

假如tmp 比当前位置的元素要小,那它应该放置在更前面的位置,当前位置的元素应该往后

移动,不同于直接插入排序,end一次需要移动gap 步.假如tmp比当前位置的元素要大,

那我们就寻找到正确的位置,或者到了序列的开头,则停止循环,放置相应的tmp即可.

// 希尔排序
void ShellSort(int* a, int n)
{   
    int gap = n;
    while (gap > 1)
    {
        //增量的调整并没有限定,有各式各样的增量减小方式
        //这里采取除3的方式,也可以采取除2,不过需要注意除3,为了保证gap最后为1,还需要加上1
        gap = gap / 3 + 1;
        //n-gap组排序
        for (int j = 0; j < gap; j++)
        {
            //一组排序
            for (int i = j; i < n - gap; i += gap)
            {
                int end = i;
                int tmp = a[end + gap];
                while (end >= 0)
                {
                    if (tmp < a[end])
                    {
                        a[end + gap] = a[end];
                        end -= gap;
                    }
                    else
                    {
                        break;
                    }
                }
                a[end + gap] = tmp;
            }
        }
    }
}

这里是每组直接进行预排序,先把每组排好,再排下一组,总共排gap次.

我们也可以多组同时进行排序,此时将循环条件修改下即可,这种方法也被称为希尔排序-并排.

//希尔排序并排
void ShellSort(int* a, int n)
{
    int gap = n;
    while (gap > 1)
    {
        gap = gap / 2;
        //由原来的i + gap,改为i++
        for (int i = 0; i < n - gap; i++)
        {
            int end = i;
            int tmp = a[end + gap];
            while (end >= 0)
            {
                if (tmp < a[end])
                {
                    a[end + gap] = a[end];
                    end -= gap;
                }
                else
                {
                    break;
                }
            }
            a[end + gap] = tmp;
        }
    }
}

希尔排序的时间复杂度分析是一个比较复杂的问题,因为它和增量的变化序列密切相关.

在Knuth所著的《计算机程序设计技巧》第3卷中指出,利用大量的实验统计资料得出,当n很大时,

关键字平均比较次数和对象平均移动次数大约在范围内.

为方便记忆,我们以后将其时间复杂度当作来看待,空间复杂度依旧为O(1).

二.选择排序

  1. 简单选择排序

简单选择排序非常直白,就是每次遍历待排数组,找到最大的元素,放到数组最后面即可.

这里我们稍微改进一下,每次遍历的时候,同时找出最小和最大的元素,然后分别放到待排数组最前和最

后的位置,以此来提高速度.

具体实现:

【1】遍历数组,找出最小元素和最大元素的位置(下标)

【2】分别和待排数组最前面和最后面的元素交换位置

【3】重复上述步骤,直到数组有序

PS:

有可能待排数组放置的元素恰好是最大的元素,那此时交换的话,最大元素对应的下标就会改变,需要重

新进行调整.

// 交换两个元素
void Swap(int* p, int* q)
{
    int tmp = *p;
    *p = *q;
    *q = tmp;
}

// 选择排序
void SelectSort(int* a, int n)
{
    int begin = 0, end = n - 1;
    while (begin < end)
    {
        int maxi = begin, mini = begin;
        //因为end = n - 1,即最后一个元素的下标.因此i可以取到end
        for (int i = begin + 1; i <= end; i++)
        {
            if (a[i] > a[maxi])
            {
                maxi = i;
            }
            if (a[i] < a[mini])
            {
                mini = i;
            }
        }
        //把最小元素放到begin位置
        Swap(&a[begin], &a[mini]);
        //假如begin位置,恰好是最大元素放置的位置,那交换则会失败
        if (begin == maxi)
            maxi = mini;
        //把最大元素放到end位置
        Swap(&a[end], &a[maxi]);
        begin++;
        end--;
    }
}
  1. 堆排序

堆排序我们已经在堆数据结构中已经详细介绍过,这里不再过多介绍.

(37条消息) 堆的实现和总结_·present·的博客-CSDN博客

假如为大堆,则堆顶的元素就是整个数组中最大的元素,可以将其和最后一个元素交换位置.

同时堆的结构决定了,即便换了位置,我们也可以采取向下调整算法,使其迅速还原为一个堆(只和树的

深度相关),因此堆排序的时间复杂度,还是比较低的,为.

// 交换两个元素
void Swap(int* p, int* q)
{
    int tmp = *p;
    *p = *q;
    *q = tmp;
}

//向下调整算法
void AdjustDown(int* a, int n, int parent)
{
    int child = parent * 2 + 1;
    while (child < n)
    {
        //,右孩子存在,同时确定左右孩子较大的那个
        if (child + 1 < n && a[child + 1] > a[child])
        {
            child++;
        }
        if (a[child] > a[parent])
        {
            Swap(&a[child], &a[parent]);
            parent = child;
            child = parent * 2 + 1;
        }
        else
        {
            break;
        }
    }
}

// 堆排序
void HeapSort(int* a, int n)
{
    //向下建堆
    for (int i = (n - 1 - 1) / 2; i >= 0; --i)
    {
        AdjustDown(a, n, i);
    }
    for (int i = n - 1; i > 0; i--)
    {
        //交换堆顶元素和最后一个元素
        Swap(&a[0], &a[i]);
        //向下调整建大堆
        AdjustDown(a, i, 0);
    }
}

三.交换排序

  1. 冒泡排序

冒泡排序在之前的文章也已经详细讲解过,并且也进行了相应的改造和优化,这里不再详细介绍.

写文章-CSDN创作中心

它的核心思想就是交换,和直接插入排序相比,它也同样要一趟趟遍历,但每一趟都需要两两进行比较,

因此,它的整体效率是比较慢的,时间复杂度为

void bubble_sort(int arr[],int sz)//sz表示数组中的元素个数
{
    for (int i = 0; i < sz - 1; i++)//sz - 1趟
    {
        int flag = 1;//哨兵为1,代表数组已经有序
        for (int j = 0; j < sz - 1 - i; j++)//每次循环只需要比较sz - 1 - i次
        {
            if (arr[j] > arr[j + 1])//两个数满足前大于后,交换位置
            {
                int tmp = arr[j + 1];
                arr[j + 1] = arr[j];
                arr[j] = tmp;
                flag = 0;
            }
        }
        if (flag)  break;
     }
}
  1. 快速排序

1.递归实现

快速排序是对冒泡排序的一种改进.

基本思想:

【1】选取待排序序列中的某一关键字作为基准值

【2】通过一趟排序将待排序列分割成两个独立的部分,其中一部分序列的关键字均比基准值小,另一部

分序列均比基准值大.(即一趟排序,就将基准值放置到了最后的正确位置)

【3】重复上述操作,直至序列完全有序.(递归实现)

方便起见,我们先将每次待排序列第一个元素(最左边)当作基准值.

那么如何在一趟循环下,就完成确定基准值的最终位置,并把比它小的元素都放到左边,比它大的元素都

放到右边呢?

这里我们介绍三种方法:

  1. Hoare版本

思想:

【1】准备好两个指针left,right,left刚开始指向序列最左边,right指向序列的最右边

【2】right先移动,找比基准值小的,然后停下来

【3】left后移动,找比基准值大的,然后停下来

此时right指向比基准值小的,left指向比基准值大的,我们就可以交换两个元素,把比基准值小的放在

前面,大的放置在后面.

【4】不断循环上述过程,直到left和right相遇,相遇位置的元素和我们的基准值交换即可.

思想设计上,快排是十分巧妙的,不仅仅在于left和right指针的设计,还在于最后的交换.

left和right指针相遇地方的元素,一定是比基准值小或相等的元素.

我们可以分两种情况讨论

第一种,假设right移动遇到left,则此时right指针停下的位置,一定是比基准值小的元素(因为right指

针先移动,left指针和right指针所指的元素已经经过了交换)或者left此时指的位置就是基准值(基准值右

边所有元素都比基准值大,right指针一直向左移动)

第二种,假设left移动遇到right,由于right指针指向的是比基准值小的元素,所以相遇位置的元素也是

比基准值小

PS:虽然思路清晰,但还是有一些细节地方需要注意

1.right指针和left指针移动的循环条件

1)和基准值相等也必须移动,否则就会死循环,right和left指针一直在原地,交换元素.

2)限定边界,right不能一直减小,left也不能一直增大,两个指针都只能在数组内移动.

2.end指向的是数组最后一个元素的下标,因此,end直接赋给right即可.

// 交换两个元素
void Swap(int* p, int* q)
{
    int tmp = *p;
    *p = *q;
    *q = tmp;
}

//快速排序hoare版本(一趟排序)
int QuickSortPart1(int* a, int begin, int end)
{
    int left = begin, right = end;
    int keyi = left;
    while (left < right)
    {
        //右边先移动,找小
        while (right > left && a[right] >= a[keyi])
        {
            --right;
        }
        //左边后移动,找大
        while (right > left && a[left] <= a[keyi])
        {
            ++left;
        }
        //交换两个数字所在的位置
        Swap(&a[left], &a[right]);
    }
    Swap(&a[left], &a[keyi]);
    keyi = left;

    return keyi;
}
  1. 挖坑法

借鉴Hoare版本的思路,我们其实就可以调整一下代码,不需要Swap函数,其实就可以实现相同的功

能.

思路:

【1】我们同样准备好两个指针left,right,left刚开始指向序列最左边,right指向序列的最右边

不过,我们还设置一个坑位hole,它是不断变化的,一开始它和基准值的位置相同.

【2】right先移动,找比基准值小的,然后停下来,和坑位所指的位置交换,并调整坑位,使其成为新

坑.

【3】left后移动,找比基准值大的,然后停下来,和坑位所指的位置交换,并调整坑位,使其成为新坑.

【4】不断循环上述过程,直到left和right相遇,相遇位置直接放上我们的基准值.

假如理解Hoare版本的快排,挖坑法的本质其实和它是相同的.

不需要left指针找到大或者right指针找到小的时候才交换元素,完全可以直接放置元素(填坑),然后成

为新的需要填坑的位置.

//挖坑法(一趟排序)
int QuickSortPart2(int* a, int begin, int end)
{
    int left = begin, right = end;
    int key = a[left];
    int hole = left;
    while (left < right)
    {   
        //右边开始,找小,填坑,并成为新坑
        while (left < right && key <= a[right])
        {
            --right;
        }
        a[hole] = a[right];
        hole = right;

        //左边开始,找大,填坑,并成为新坑
        while (left < right && key >= a[left])
        {
            ++left;
        }
        a[hole] = a[left];
        hole = left;
    }
    //填上left和right指针相遇处的坑
    a[hole] = key;
    return hole;
}
  1. 快慢指针

思路:

【1】定一个prev指针位于起始端,cur指针位于它的后方.

【2】cur指针的用处就是找小,假如找到比基准值小的元素,就和prev指向的后一个元素交换位置,

之后cur指针再往后移动,这样就实现了把比基准值小的元素都甩到后面的目的.

而不管有没有找到比基准值小的元素,cur指针都一直往后移动,直到越过待排序序列.

【3】prev的位置,和基准值对应位置元素交换.

想要理解快慢指针的核心,关键点在于prev指针和cur指针什么时候会拉开大于1个元素的距离?

仔细思索,其实我们就可以知道,当cur指针遇到比基准值大的元素,它们两个就开始拉开距离.

此时prev指针(慢)和cur指针(快)之间的元素,一定比基准值大.

否则,两个指针并无快慢区别之分.

//快慢指针(一趟排序)
int QuickSortPart3(int* a, int begin, int end)
{
    int keyi = begin;
    int prev = begin, cur = begin + 1;
    //闭区间
    while (cur <= end)
    {
        //找到比key小的值时,与++prev的位置交换,小的往前翻,大的往后翻
        if (a[cur] < a[keyi] && ++prev != cur)
            Swap(&a[prev], &a[cur]);
        //无论比key小或者大,cur都要移动
        cur++;
    }

    Swap(&a[prev], &a[keyi]);
    keyi = prev;
    return keyi;
}

实现完一趟排序,剩下就是递归实现排序了.

每一趟排序下来后,序列被划分为了两个区间.

[begin keyi - 1] keyi [keyi + 1 end]

分别对两个区间再次调用快排即可.

//快速排序——递归实现
void QuickSort(int* a, int begin, int end)
{   
    // 递归停止条件
    // 假如区间只剩下一个元素
    if (begin >= end)
    {
        return;
    } 
    //一趟快排实现(任选一种)
    //Hoare版本  int keyi = QuickSortPart1(a, begin, end);
    //挖坑法     int keyi = QuickSortPart3(a, begin, end);
    //快慢指针
    int keyi = QuickSortPart3(a, begin, end);
    //递归调用
    //[begin   keyi - 1]  keyi  [keyi + 1   end]
    QuickSort(a,begin, keyi - 1);
    QuickSort(a,keyi + 1, end);
}

2.快排优化

虽然我们已经完成快速排序的递归实现,但在某些特殊情况下,快排的效率实际上是非常差的,而快排的

优化,就是针对这几种特殊情况进行处理,使其效率进一步提高.

  1. 三数取中/随机取中

快排的优势在于每次都可以将待排区间,递归划分为两个子区间,并确定一个数字(key)的最终位置.

最后其逻辑结构会类似一棵树的形状,又由于树的深度是量级的,所以其整体平均时间复杂度才

会是.

若序列本身有序或基本有序时,快速排序会退化成冒泡排序,其时间复杂度为.

就拿9 8 7 6 5 4这个序列来讲,由于我们每次都取最左边的数字为key,则此时每次划分都相当于没有划

分,都只有一个区间,相当于树全部倾向一边,变成一个单链表.

一个解决方法是三数取中法.

由于遇到最左边的数字,最右边的数字,和中间位置的数字,三者相等的序列概率很小.

我们可以考虑先将三个数字进行比较,找出三者之中,排在中间的数字,把它放在待排序列的最左边,然

后再进行快排操作.

这样就可以解决逆序序列所带来的问题.

//三数取中法
int GetMidIndex(int * a,int begin, int end)
{
    int mid = (begin + end) / 2;
    if (a[begin] < a[mid])
    {
        if (a[mid] < a[end])
        {
            return mid;
        }
        else if (a[begin] > a[end])
        {
            return begin;
        }
        else
        {
            return end;
        }
    }

    else//a[begin] > a[mid]
    {
        if (a[mid] > a[end])
        {
            return mid;
        }
        else if (a[end] > a[begin])
        {
            return begin;
        }
        else
        {
            return end;
        }
    }
}

有了三数取中法实现后,我们就可以将快排进一步改造.

void QuickSort(int* a, int begin, int end)
{
    // 递归停止条件
    // 假如区间只剩下一个元素
    if (begin >= end)
    {
        return;
    }

    //保证key一定不是整个数组中最大的数
    //三数取中,将中间数交换至序列最左边
    int mid = GetMidIndex(a, begin, end);
    Swap(&a[begin], &a[mid]);
    //快慢指针
    int keyi = QuickSortPart3(a, begin, end);

    //递归调用
    //[begin   keyi - 1]  keyi  [keyi + 1   end]
    QuickSort(a, begin, keyi - 1);
    QuickSort(a, keyi + 1, end);
}

不过,它依旧没有完全解决问题,我可以设计三数始终保持相等的序列或接近有序的待排序列.

则快排的优势依旧发挥不出来.

在leetcode有关排序数组算法里快排(三数取中)是通不过的.

于是,有人又提出了随机取中法.

随机取中法,顾名思义,就是从序列里面任选一个数,作为基准值.

这样每次随机选择,即便是设计过的待排序列,也不能保证,我每次选择的基准值会是已排序好序列偏左

或偏右的数字,多多少少会选中偏中心的数字.

int GetMidIndex(int* a, int begin, int end)
{
    //三数随机取中法
    int mid = begin + rand()%(end - begin);
    if (a[begin] < a[mid])
    {
        if (a[mid] < a[end])
        {
            return mid;
        }
        else if (a[begin] > a[end])
        {
            return begin;
        }
        else
        {
            return end;
        }
    }
    else//a[begin] > a[mid]
    {
        if (a[mid] > a[end])
        {
            return mid;
        }
        else if (a[end] > a[begin])
        {
            return begin;
        }
        else
        {
            return end;
        }
    }
}
  1. 左右小区间

同样的,我们知道越往下递归,其递归次数就越多,但此时待排序列长度已经比较小,或者说待排序列本

身已经接近有序,因此此时采用快排,其优势其实并不大.

我们可以修改成,当待排序列长度小于某个值的时候,不采用快排进行排序,而是采用直接插入排序.

不过这里有一点需要注意,即调用直接插入排序的时候,需要对哪一段序列进行排序.

由于end始终指向待排序列最后一个元素的下标,开始元素的下标为0

所以整个待排序列的元素个数应该为end - begin + 1

void QuickSort(int* a, int begin, int end)
{
    // 递归停止条件
    // 假如区间只剩下一个元素
    if (begin >= end)
    {
        return;
    }
    //小区间,直接调用直接插入排序算法,而不递归
    if ((end - begin + 1) < 15)
    {
        InsertSort(a + begin, end - begin + 1);
    }
    else
    {
        //保证key一定不是整个数组中最大的数
        //三数取中
        int mid = GetMidIndex(a, begin, end);
        Swap(&a[begin], &a[mid]);

        int keyi = QuickSortPart3(a, begin, end);

        //递归调用
        //[begin   keyi - 1]  keyi  [keyi + 1   end]
        QuickSort(a, begin, keyi - 1);
        QuickSort(a, keyi + 1, end);
    }
}
  1. 三路快排

解决了逆序序列问题后,我们再来解决一类特殊问题——整个序列里面的数字都是相同的或者有多个数字

都是相同的,此时快排依旧显得效率低下.

于是有人提出了改进方法,将原本划分为两个区间,改进为划分为三个区间.

把排序的数据分为三部分,分别为小于 v,等于 v,大于 v,v 为标定值

这样三部分的数据中,等于 v 的数据在下次递归中不再需要排序,小于 v 和大于 v 的数据也

不会出现某一个特别多的情况

具体如何进行处理呢?

有人就根据快慢指针的核心思想进行了改造,使其能够划分为三路.

核心思想:比基准值小的甩到左边,和基准值相等的不动,比基准值大的甩到右边.

【1】定义三个指针,分别是left,cur,right.(cur指针充当中转站的作用.)

left指针最开始指向待排序列最左边的位置,right指向待排序列最右边的位置.cur指针指向left指针的后

一个位置.

【2】当cur指向的元素比基准值小的时候,cur指针指向的元素和left指针指向的元素交换(比基准值小

的甩到左边)两者都往后移动.

【3】当cur指向的元素和基准值相等的时候,cur指针向后移动

【4】当cur指向的元素比基准值大的时候,cur指针指向的元素和right指针指向的元素交换(比基准值

大的甩到右边),right向前移动.

(PS:为什么cur指针不需要向后移动呢?因为此时交换后,cur指针指向的元素可能还需要进行交换,甩到

右边,左边都是可能的,发挥其中转站的作用)

【5】重复上述操作,直到cur指针和right指针相遇.

void QuickSort(int* a, int begin, int end)
{
    // 递归停止条件
    // 假如区间只剩下一个元素
    if (begin >= end)
    {
        return;
    }
    //小区间,直接调用直接插入排序算法,而不递归
    if ((end - begin + 1) < 15)
    {
        InsertSort(a + begin, end - begin + 1);
    }
    else
    {
        //保证key一定不是整个数组中最大的数
        //三数取中
        int mid = GetMidIndex(a, begin, end);
        Swap(&a[begin], &a[mid]);

        int left = begin, right = end;
        int key = a[begin];
        int cur = begin + 1;
        while (cur <= right)
        {
          //假如当前cur指向的数比key小,则交换两个数,并且left,cur都向后移动
            if (a[cur] < key)
            {
                Swap(&a[left], &a[cur]);
                ++left;
                ++cur;
            }
        //假如当前cur指向的数比key小,则交换两个数,但只移动right,因为Key此时指向的数有可能还有移向前方
            else if (a[cur] > key)
            {
                Swap(&a[right], &a[cur]);
                --right;
            }
            //cur指向的数和key相等
            else
            {
                cur++;
            }
        }

        //递归调用
        //划分为三个区间  [begin  left - 1][left  right][right + 1  end]
        QuickSort(a, begin, left - 1);
        QuickSort(a, right + 1, end);
    }
}

3.快排非递归实现

这里我们采用栈来模拟实现快排的非递归实现.

那将什么压入栈中呢?仔细思考后,我们发现,将begin,end两个表示区间位置的值压入栈中是最合适不

过的,毕竟每次递归,实际上,只是在计算不同的begin和end,然后递归传进一趟排序里面,利用它们

进行排序.

换言之,假如我们能够将需要排序的区间位置逐个压入栈中,再反复调用单趟快排算法,实际上,就能够

成功实现快排的非递归算法.

当然,还有一些细节问题需要进行处理.

1.由于栈是先进后出的结构,所以假如我们把区间按【begin end】压入栈中,出栈的时候,要注意先

取出来的是end,后取出来的是begin.

2.假如区间长度小于一个元素,则不需要入栈.

// 快速排序 非递归实现
void QuickSortNonR(int* a, int begin, int end)
{
    ST st;
    StackInit(&st);
    //把区间按【begin   end】压入栈中
    StackPush(&st, begin);
    StackPush(&st, end);
    //只要栈不为空,则一直循环,把区间压入其中
    while (!StackEmpty(&st))
    {
        //取的时候,先取到的是右区间
        int right = StackTop(&st);
        StackPop(&st); 
        int left = StackTop(&st);
        StackPop(&st);

        //完成单趟排序
        int keyi = QuickSortPart3(a, left, right);
        //区间现在被划分为三个部分[left  keyi - 1] keyi [keyi + 1  right]
        if (keyi + 1 < right)
        {
            StackPush(&st, keyi + 1);
            StackPush(&st, right);
        }
        if (left < keyi - 1)
        {
            StackPush(&st, left);
            StackPush(&st, keyi - 1);
        }
    }
    DestroyStack(&st);
}

四.归并排序

归并排序是分治法的一大应用,它的核心思想是

递归将待排区间不断划分,直到划分为单个元素,此时我们可以把单个元素看作有序的.

然后将有序序列两两合并成为一个有序序列.

  1. 递归实现

在实现归并排序之前,我们需要解决归并排序的核心之一

——如何将两个有序序列合并成一个有序序列?

这个问题其实很好解决,看图片我们就可以知道.

我们设置相应begin1,end1分别指向第一个有序序列的首尾.

begin2,end2分别指向第二个有序序列的首尾.

然后循环遍历进行比较元素大小即可.

比如说1比2要小(begin1指向的元素比begin2要小),那begin1指向的元素,拷到临时数组tmp里,

然后begin1往后移动.

2要比6小(begin2指向的元素比begin1要小),那begin2指向的元素,拷到临时数组tmp中,然后

begin2往后移动.

直到begin1指针遇到end1指针,或者begin2指针遇到end2指针,循环就可以停止.

这时候,意味着其中一个有序序列所有的元素都已经排序进tmp数组了.

剩下的,将另一个有序序列的数组中的元素全部拷贝进tmp里就完成排序.

另一个问题就是如何递归?

每一次递归,我们会将区间划分两个部分

一半是【begin mid】,另一半是【mid + 1 end】

mid = (begin + end)/2

区间递归调用即可.

//归并排序
//非整体拷贝版本
void _MergeSort(int* a,int begin,int end,int* tmp)
{
    //递归结束条件
    if (begin >= end)
    {
        return;
    }

    int mid = (begin + end) / 2;
    //递归
    //区间被划分为[begin  mid][mid+1   end]
    _MergeSort(a, begin, mid, tmp);
    _MergeSort(a, mid + 1, end, tmp);
    //单趟合并两个有序序列
    int begin1 = begin, end1 = mid;
    int begin2 = mid + 1, end2 = end;
    int i = begin;
    while (begin1 <= end1 && begin2 <= end2)
    {
        if (a[begin1] <= a[begin2])
        {
            tmp[i++] = a[begin1++];
        }
        else
        {
            tmp[i++] = a[begin2++];
        }
    }
    while (begin1 <= end1)
    {
        tmp[i++] = a[begin1++];
    }
    while (begin2 <= end2)
    {
        tmp[i++] = a[begin2++];
    }
    //将数据拷贝回原数组,要加上对应的begin
    memcpy(a + begin, tmp + begin, sizeof(int) * (end - begin + 1));
}

// 归并排序递归实现
void MergeSort(int* a, int n)
{
    //开辟临时空间tmp
    int* tmp = (int*)malloc(sizeof(int) * n);
    if (tmp == NULL)
    {
        perror("malloc fail.");
        exit(-1);
    }
    _MergeSort(a,0,n-1,tmp);

    free(tmp);
    tmp = NULL;
}
  1. 非递归实现

我们可以设置MergeN,代表每趟待排序序列的元素个数.

比如说MergeN =1时,就是我们的第一趟排序,此时每个待排序序列里面只有一个元素.

当MergeN乘2,此时就是我们的第二趟排序,此时每个待排序列里面有两个元素.

以此,4个,8个...直到待排序序列元素个数超出我们待排数组总的元素个数.

void MergeSortNonR(int* a, int n)
{
    int* tmp = (int*)malloc(sizeof(int) * n);
    if (tmp == NULL)
    {
        perror("malloc fail.");
        exit(-1);
    }

    //归并每组数据,数据从1开始,1个数据可以认为是有序的
    int MergeN = 1;
    int begin = 0, end = n - 1;
    while (MergeN < n)
    {
        for (int i = 0; i < n; i += 2 * MergeN)
        {
            //[begin1   end1][begin2   end2]
            int begin1 = i, end1 = i + MergeN - 1;
            int begin2 = i + MergeN, end2 = i + 2 * MergeN - 1;
            int j = i;
            while (begin1 <= end1 && begin2 <= end2)
            {
                if (a[begin1] <= a[begin2])
                {
                    tmp[j++] = a[begin1++];
                }
                else
                {
                    tmp[j++] = a[begin2++];
                }
            }
            while (begin1 <= end1)
            {
                tmp[j++] = a[begin1++];
            }
            while (begin2 <= end2)
            {
                tmp[j++] = a[begin2++];
            }
        }
        //将数据整体拷贝回原数组
        memcpy(a, tmp, sizeof(int) * (end - begin + 1));
        MergeN *= 2;
    }

    free(tmp);
    tmp = NULL;
}

我们也可以发现,当数组元素为2的倍数的时候,结果符合预期.

可是,当我们传进去非2的倍数,数组元素个数序列,比如说上面的arr,程序就会报错,没有任何结果

输出.

为什么会发生这样的情况呢?

我们可以在每次排序的时候,输出区间位置,以此来判断程序是否符合我们的预期.

数组总共12个元素,下标最大为11

但我们输出的区间位置,下标明显有越界的情况.

其实仔细思索下,我们也比较好理解.

下面红色部分是两两待排的有序序列,在排序前面两个序列的时候,一般不会出现问题.

但是,由于我们循环条件的控制,是一次循环越过两个需合并有序序列的长度.

因此往往排序,并非只排前面两个序列,后面的两个序列也会进行排序.

这时候自然就会出现数组越界访问的问题.

因此,我们需要调整,对于前两种情况,我们不对后面两个待排序列排序即可,将其区间长度改为右小于

左.

对于最后一种情况,直接将end2改为数组的最后一个元素下标即可.

 //归并排序非递归实现
 //整体拷贝版本
void MergeSortNonR(int* a, int n)
{
    int* tmp = (int*)malloc(sizeof(int) * n);
    if (tmp == NULL)
    {
        perror("malloc fail.");
        exit(-1);
    }

    //归并每组数据,数据从1开始,1个数据可以认为是有序的
    int MergeN = 1;
    int begin = 0, end = n - 1;
    while (MergeN < n)
    {
        for (int i = 0; i < n; i += 2 * MergeN)
        {
            //[begin1   end1][begin2   end2]
            int begin1 = i, end1 = i + MergeN - 1;
            int begin2 = i + MergeN, end2 = i + 2 * MergeN - 1;
            int j = i;
            //总共有三种情况:
            //需要进行修正
            //1.end1,begin2,end2都大于n
            if (end1 >= n)
            {
                end1 = n - 1;
                //调整为不存在的区间,这样就不会进入两个序列合并
                begin2 = n;
                end2 = n - 1;
            }
            //2.begin2,end2都大于n,也是调整为不存在的区间
            else if (begin2 >= n)
            {
                begin2 = n;
                end2 = n - 1;
            }
            //3.end2大于n,将其修改为指向最后一个元素
            else if (end2 >= n)
            {
                end2 = n - 1;
            }
            //区间长度正确,两个有序序列才进行合并
            while (begin1 <= end1 && begin2 <= end2)
            {
                if (a[begin1] <= a[begin2])
                {
                    tmp[j++] = a[begin1++];
                }
                else
                {
                    tmp[j++] = a[begin2++];
                }
            }
            while (begin1 <= end1)
            {
                tmp[j++] = a[begin1++];
            }
            while (begin2 <= end2)
            {
                tmp[j++] = a[begin2++];
            }
        }
        //将数据整体拷贝回原数组
        memcpy(a, tmp, sizeof(int) * (end - begin + 1));
        MergeN *= 2;
    }

    free(tmp);
    tmp = NULL;
}

结果也可以看出我们的思路,区间[12 11]的不会进行合并

原本[8 15]的序列,修改为正确的[8 11]序列.

当然,上述是每一趟进行完相应合并后,将整个tmp临时数组中的元素,全部拷回待排序列里面.

我们还可以在每两两待合并有序序列合并为一个有序序列后,就直接拷回原数组.

 归并排序非递归实现
// //非整体拷贝版本
void MergeSortNonR(int* a, int n)
{
    int* tmp = (int*)malloc(sizeof(int) * n);
    if (tmp == NULL)
    {
        perror("malloc fail.");
        exit(-1);
    }
    
    //归并每组数据,数据从1开始,1个数据可以认为是有序的
    int MergeN = 1;
    int begin = 0, end = n - 1;
    while (MergeN < n)
    {   
        for (int i = 0; i < n; i += 2 * MergeN)
        {   
            //[begin1   end1][begin2   end2]
            int begin1 = i, end1 = i + MergeN - 1;
            int begin2 = i + MergeN, end2 = i + 2 * MergeN - 1;
            printf("[%d   %d][%d   %d]\n", begin1, end1, begin2, end2);
            int j = i;
            //总共有三种情况:
            //1.end1,begin2,end2都大于n
            if (end1 >= n)
            {
                break;
            }
            //2.begin2,end2都大于n
            else if (begin2 >= n)
            {
                break;
            }
            //3.end2大于n
            else if (end2 >= n)
            {
                end2 = n - 1;
            }

            while (begin1 <= end1 && begin2 <= end2)
            {
                if (a[begin1] <= a[begin2])
                {
                    tmp[j++] = a[begin1++];
                }
                else
                {
                    tmp[j++] = a[begin2++];
                }
            }
            while (begin1 <= end1)
            {
                tmp[j++] = a[begin1++];
            }
            while (begin2 <= end2)
            {
                tmp[j++] = a[begin2++];
            }
            //将数据拷贝回原数组
            memcpy(a + i, tmp + i, sizeof(int) * (end2 - i + 1));
        }
        
        MergeN *= 2;
    }

    free(tmp);
    tmp = NULL;
}

五.非比较排序

  1. 计数排序

计数排序也被称为鸽巢原理,是对哈希直接定址法的变形应用.

它的具体思路是利用映射

因为我们知道数组下标是递增有序的,从0开始一直到n - 1(假设有n个数字)

那我们可以创建一个辅助数组tmp.

遍历数组中的每一个数字,也就是计数.

比方说原数组元素是6,则在tmp下标为6的地方加1.

原数组元素是11,则在tmp下标为11的地方加1.

那数组下标是有序递增的,只要tmp里面的元素不为0,则说明在原数组出现过,那直接按照下标拷回原

数组即可.

这就是计数排序的思路.

不过还需要在此之上进行改进

比如说原数组数字集中在1000左右,我们不可能开辟一个大小为1000的数组,而其中tmp前面大部分

的空间都被浪费,没有利用.

或者说原数组有负数,此时下标没有负数,无法进行映射对应.

解决这两个问题,只要利用一种巧妙的做法(均一化)即可.

将原数组最小的数字作为基准下标0,然后保证两两元素相对位置不变.

具体来说,就是让原数组每一个数减去数组的最小值即可.

//计数排序
void CountSort(int* a, int n)
{
    //1.找出最大和最小的数字
    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];
    }
    //2.创建辅助数组,并且全部初始化为0
    int range = max - min + 1;
    int* CountA = (int *)calloc(range,sizeof(int));
    if (CountA == NULL)
    {
        perror("malloc fail.");
        exit(-1);
    }
    //3.计数
    for (int i = 0; i < n; ++i)
    {
        CountA[a[i] - min]++;
    }
    //4.复制到原数组
    int k = 0;
    for (int j = 0; j < range; ++j)
    {
        while (CountA[j]--)
        {
            a[k++] = j + min;
        }
    }
    free(CountA);
}
  1. 基数排序

基数排序是一种借助多关键字排序的思想,对单逻辑关键字进行排序的方法.

比如说排序一堆扑克牌,花色和数字大小就分别是一种关键字.

假如我们人为规定花色是最主位关键字,数字是最次位关键字.

一般就会有两种排序方法

一种是先按最主位关键字排好,也就是先把花色相同的放到一起,分成若干个子序列,然后分别就每个子

序列按照次关键字排列,即按数字排列.最后将所有子序列连到一起,成为一个有序序列,我们称之为

高位优先法.(Most significant Digit first)MSD法.

另一种是从最次位关键字开始排序,也即是先排数字,然后再对高位关键字,即花色,进行排序,依次重

复,直到排成有序序列.我们称之为最低位优先法.(Least significant Digit first)LSD法.

具体来说,我们如何排序一个无序序列呢?

我们需要借助"分配"和"收集".

此时的基数RADIX就是0-9,我们要排总共三位数,K设为3

先按个位进行排序,将其分别存到相应基数队列中.(分配)

再收集,按顺序存储回原数组.(收集)

再按十位进行排序,将其分别存到相应基数队列中.(分配)

由于我们是按照原数组进行分配的,所以其个位在每一个基数队列仍然是有序排列的.

再收集,按顺序存储回原数组.(收集)

最后按百位进行排序,将其分别存到相应基数队列中.(分配)

同样,由于我们是按照原数组进行分配的,所以其十位在每一个基数队列仍然是有序排列的.

最后再收集,按顺序存储回原数组.(收集)

就可以得到有序序列8 63 83 109 184 269 278 505 589 930

#define K 3
#define RADIX 10
//创建一个结构体数组,每个元素,都是一个队列(结构体)
Queue q[RADIX];
int Getkey(int value, int k)
{   
    int key = 0;
    while (k >= 0)
    {  
        key = value % 10;
        value /= 10;
        --k;
    }
    return key;
}
//分发数据
//k用来告诉函数,这是第几趟排序
void Distribute(int* arr, int left, int right, int k)
{   
    for (int i = left; i < right; ++i)
    {   
        //对应趟数,取出每个数字相应的部分
        int key = Getkey(arr[i],k);
        //对应列压入相应的基数队列中
        QueuePush(&q[key], arr[i]);
    }
}
//回收数据
void Collect(int * arr)
{
    int k = 0;
    for (int i = 0; i < RADIX; ++i)
    {
        //假设该队列不为空,则把里面所有的队列数据全部按顺序输出
        while (!QueueEmpty(&q[i]))
        {
            arr[k++] = QueueFront(&q[i]);
            QueuePop(&q[i]);
        }
    }
}
//基数排序
void _radixSort(int* arr, int left, int right) //[left right)
{    
    //有多少个关键字,在这是每个数对应有多少位,则相应循环多少次
    for (int i = 0; i < K; ++i)
    {
        //分发数据
        Distribute(arr, left, right, i);
        //回收数据
        Collect(arr);
    }
}

//基数排序
void RadixSort(int* arr, int n)
{
    //初始化队列数组中的每一个队列
    for (int i = 0; i < RADIX; ++i)
    {
        QueueInit(&q[i]);
    }
    _radixSort(arr, 0, n);
}
  • 0
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值