数据结构之排序

排序的概念及其运用

排序的概念

排序:所谓排序,就是使一串记录,按照其中的某个或某些关键字的大小,递增或递减的排列起来的操作。
稳定性:假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,r[i]=r[j],且r[i]在r[j]之前,而在排序后的序列中,r[i]仍在r[j]之前,则称这种排序算法是稳定的;否则称为不稳定的。
内部排序:数据元素全部放在内存中的排序。
外部排序:数据元素太多不能同时放在内存中,根据排序过程的要求不能在内外存之间移动数据的排序。


常见排序算法

插入排序

直接插入排序是一种简单的插入排序法,其基本思想是:把待排序的记录按其关键码值的大小逐个插入到一个已经排好序的有序序列中,直到所有的记录插入完为止,得到一个新的有序序列。

 直接插入排序

思路:当插入第i(i>=1)个元素时,前面的array[0],array[1],…,array[i-1]已经排好序,此时用array[i]的排序码与array[i-1],array[i-2],…的排序码顺序进行比较,找到插入位置即将array[i]插入,原来位置上的元素顺序后移。

//时间复杂度最坏:O(N^2) --逆序
//时间复杂度最好:O(N) --顺序有序
void InsertSort(int* a, int n)
{
    for (int i = 0; i < n-1; i++)
    {
        int end = i;
        int tmp = a[i+1];//需要比较的数据先存起来,因为数据可能被覆盖
        while (end >= 0)
        {
            //如果tmp小于end值,end向右挪动
            if (tmp < a[end])
            {
                a[end + 1] = a[end];
                end--;
            }
            //tmp比end大不需要再调整
            else
            {
                break;
            }
        }
        //在end后插入新数据
        a[end + 1] = tmp;
    }
}

直接插入排序的特性总结:
1. 元素集合越接近有序,直接插入排序算法的时间效率越高
2. 时间复杂度:O(N^2)
3. 空间复杂度:O(1)
4. 稳定性:稳定

希尔排序( 缩小增量排序 )

希尔排序法又称缩小增量法。希尔排序法的基本思想是:先选定一个整数gap,把待排序文件中所有记录分成gap个组,所有距离为gap的记录分在同一组内,并对每一组内的记录进行排序。然后,缩小gap的值,重复上述分组和排序的工作。当到达gap=1时,所有记录在统一组内排好序。

希尔排序其实是对直接插入排序的优化思想:1.先对数据进行预排序 2.对预排序后的数据进行直接插入排序

说明:选定一个整数gap作为间隔将要排序的数据进行分组,对分组后的数据进行直接插入排序,逐渐缩小gap的值,直至gap等于1,当gap==1是再进行一次直接插入排序,所有数据就排好序了。gap越大,大的数可以更快的到后面,小的数可以更快的到前面,越不接近有序;gap越小,大的小的数挪动越慢,但是他越接近有序;gap等于1时,其实就是直接插入排序,但是在此之前已经进行了预排序,数据已经相对有序,这时直接插入排序就会快很多。

gap的取法与数据个数有关,但必须保证最后一次gap的值是1

常用的取法是:

1.gap = gap/2,这样不管数据个数n是奇数还是偶数,最后一次gap正好为1。
2.gap = gap/3 +1,注意需要加1,因为每次除3的情况下,gap最后一次取值不一定是1,加1保证最后gap一定取1。

void ShellSort(int* a, int n)
{
	// 1、gap > 1 预排序
	// 2、gap == 1 直接插入排序

    int gap = n;
    while (gap > 1)
    {
        //gap有两种常见的取法,并没有规定哪种取法更优
        //gap = gap / 2;
        gap = gap / 3 + 1;

        //gap组数据每一组都进行一次单趟排序
        for (int j = 0; j < gap; j++)
        {   
            //一次单趟排序,类似直接插入排序,不过gap不再只是1
            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;
            }
        }
    }
}

//省略一层循环
void ShellSort(int* a, int n)
{
    // 1、gap > 1 预排序
    // 2、gap == 1 直接插入排序

    int gap = n;
    while (gap > 1)
    {
        //gap有两种常见的取法,并没有规定哪种取法更优
        //gap = gap / 2;
        gap = gap / 3 + 1;

        //省略一层循环,但效果其实一样,第一种是每个gap组全都排序完再排序下一组,而这是依次对每个gap组进行一次排序,效果是一样的
        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;
        }
    }
}

希尔排序的特性总结:
1. 希尔排序是对直接插入排序的优化。
2. 当gap > 1时都是预排序,目的是让数组更接近于有序。当gap == 1时,数组已经接近有序的了,这样就会很快。这样整体而言,可以达到优化的效果。我们实现后可以进行性能测试的对比。
3. 希尔排序的时间复杂度不好计算,因为gap的取值方法很多,导致很难去计算,因此给出的希尔排序的时间复杂度都不固定.

4. 稳定性:不稳定


选择排序

基本思想:每一次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始位置,直到全部待排序的数据元素排完 。

直接选择排序

  • 在元素集合array[i]--array[n-1]中选择关键码最大(小)的数据元素。
  • 若它不是这组元素中的最后一个(第一个)元素,则将它与这组元素中的最后一个(第一个)元素交换。
  • 在剩余的array[i]--array[n-2](array[i+1]--array[n-1])集合中,重复上述步骤,直到集合剩余1个元素。

 优化版本:由于原版选择排序一次遍历只选出一个最值效率略低,但其实遍历一遍可以选出两个最值,第一次选出最小的和最大的放到首尾两个位置(假设用begin和end标识,第二次选出次大的和次小的再放到倒数第二和正数第二的位置(begin++,end- -)…,直到所有数据排完。虽然有优化,但是对时间复杂度没有实质性的改变。

void SelectSort(int* a, int n)
{
    int begin = 0;
    int end = n - 1;
    while (begin < end)
    {
        int maxi = begin;
        int mini = begin;
        for (int i = begin; i <= end; i++)
        {
            if (a[i] > a[maxi])
                maxi = i;
            if (a[i] < a[mini])
                mini = i;
        }
        Swap(&a[begin], &a[mini]);
        //关键:如果最小值和begin交换完而此时最大值就在begin位置,
        //最大值被交换到了原来的最小值位置,这种情况下需要重新确定最大值位置。
        if (begin == maxi)
            maxi = mini;
        Swap(&a[end], &a[maxi]);
        begin++;
        end--;
    }
}

 直接选择排序的特性总结:
1. 直接选择排序思考非常好理解,但是效率不是很好。实际中很少使用
2. 时间复杂度:O(N^2)
3. 空间复杂度:O(1)
4. 稳定性:不稳定

堆排序

堆排序(Heapsort)是指利用堆积树(堆)这种数据结构所设计的一种排序算法,它是选择排序的一种。它是通过堆来进行选择数据。需要注意的是排升序要建大堆,排降序建小堆。

堆排序具体分析在《数据结构之二叉树》文章中。

堆排序具体分析

void AdjustDown(int* a, int n,int parent)
{
    int child = 2 * parent + 1;
    while (child < n)
    {
        if (child + 1 < n && a[child + 1] > a[child])
            child++;
        if (a[parent] < a[child])
        {
            Swap(&a[parent], &a[child]);
            parent = child;
            child = 2 * parent + 1;
        }
        else
        {
            break;
        }
    }
   
}

void HeapSort(int* a, int n)
{
    for (int i = (n - 1 - 1) / 2; i >= 0; i--)
    {
        AdjustDown(a,n,i);
    }
    int end = n - 1;
    while (end > 0)
    {
        Swap(&a[0], &a[end]);
        AdjustDown(a, end,0);
        end--;
    }
}

堆排序的特性总结:

1. 堆排序使用堆来选数,效率就高了很多。
2. 时间复杂度:O(N*logN)
3. 空间复杂度:O(1)
4. 稳定性:不稳定


交换排序

基本思想:所谓交换,就是根据序列中两个记录键值的比较结果来对换这两个记录在序列中的位置,交换排序的特点是:将键值较大的记录向序列的尾部移动,键值较小的记录向序列的前部移动。

冒泡排序

冒泡排序动图演示:

 冒泡排序完整代码:

void BubbleSort(int* a, int n)
{
    for(int i=0;i<n;i++)
    {
        int flag = 1;
        for(int j=0;j<n-1-i;j++)
        {
            if(a[j]>a[j+1])
            {
                SWAP(&a[j],&a[j+1]);
                flag = 0;
            }
        }
        if(flag) break;
    }
}

冒泡排序的特性总结:

1. 冒泡排序是一种非常容易理解的排序

2. 时间复杂度:O(N^2)

3. 空间复杂度:O(1)

4. 稳定性:稳定

快速排序

  快速排序是Hoare于1962年提出的一种二叉树结构的交换排序方法,其基本思想为:任取待排序元素序列中某元素作为基准值(key),按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止。

// 假设按照升序对array数组中[left, right)区间中的元素进行排序
void QuickSort(int array[], int left, int right)
{
    if(right - left <= 1)
        return;
// 按照基准值对array数组的 [left, right)区间中的元素进行划分
    int div = partion(array, left, right);
// 划分成功后以div为边界形成了左右两部分 [left, div) 和 [div+1, right)
// 递归排[left, div)
    QuickSort(array, left, div);
// 递归排[div+1, right)
    QuickSort(array, div+1, right);
}

上述为快速排序递归实现的主框架,与二叉树前序遍历规则非常像,在写递归框架时可想想二叉树前序遍历规则即可快速写出来,后续只需分析如何按照基准值来对区间中数据进行划分的方式即可。 

快速排序递归版

将区间按照基准值划分为左右两半部分的常见方式有:        

1.hoare版本

我们先进行单趟排序:

  1. 选择一个 key , 一般是第一个或者最后一个。
  2. 单趟排序最后,要求小的数在key左边,大的数在key右边。

单趟排序的作用:

  1. key已经找到了最终位置,把key排好。
  2. 分割出了两个子区间,左区间比key小,右区间比key大,如果子区间有序,那整体有序。
int PartSort1(int* arr, int left, int right)
{
    int keyi = left;
    while (left < right)
    {
        //右边找小:
        while (left < right && arr[right] >= arr[keyi] )/*关键点1:此处应为加等于号,因为如果
a[right]=a[left]=a[keyi]时,陷入死循环。关键点2:如果a[left]是最小值,right最后会减为-1数组越
界,额外需要注意,最好将left<right条件放在&&的左边,避免先访问越界数组*/
        {
            right--;
        }
        //左边找大:
        while (left < right && arr[left] <= arr[keyi])
        {
            left++;
        }
        Swap(&arr[left], &arr[right]);
    }
    Swap(&arr[keyi], &arr[left]);
    return left;
}
void QuickSort(int* a,int begin,int end)
{
    if (begin >= end)  //1.区间只有1个值 2.区间不存在 这两种情况不需要再继续排序
        return;

    int keyi = PartSort(a, begin, end);
    //[begin,keyi-1] keyi [keyi+1,end]
    QuickSort(a, begin, keyi - 1);
    QuickSort(a, keyi + 1, end);
}

霍尔排序的关键:

如果左边作key,右边先走,那么相遇点的值一定小于key;

如果右边作key,左边先走,那么相遇点的值一定大于key。

以左端作key为例:

L与R相遇,无非两种情况:L遇R和R遇L

情况一:L遇R,R是停下来,L再走与R相遇。由于R是先走的,R此时停下来的位置的值一定小于key。

情况二:R遇L,在相遇这一轮,L停下来,R在移动,跟L相遇。相遇时L的位置有两种情况,一是L的位置就是key的位置,二是已经交换过一些轮次,R与L相遇时R与L刚交换完对应位置的值,此时L位置的值一定小于key。

2.挖坑法

挖坑法动图演示如下:

思路解析: 令最左边为key, 先把key单独拿出来,第一个坑位在最左边,所以右边先找小填坑,填完坑右边变成新坑,再左边找大填坑,填完坑左边变成新坑,以此类推,左右相遇时就是最后一个坑位,把key填入坑位。

好处:不需要考虑为什么L和R相遇处一定是最小或最大,相遇处一定是坑。

//挖坑法
int PartSort2(int* a, int left, int right)
{
    int key = a[left];
    int hole = left;
    while (left < right)
    {
        while (left < right && a[right] >= key)
        {
            right--;
        }
        a[hole] = a[right];
        hole = right;
        while (left < right && a[left] <= key)
        {
            left++;
        }
        a[hole] = a[left];
        hole = left;
    }
    a[hole] = key;
    return hole;
}
3.前后指针法

思路解析:双指针思想, [1,prev]区间维护比key小的值, [prev+1, cur]维护比cur大的值.

最开始prev应在left坐标位置, 代表区间[1,prev]内还没有值, cur应为left+1, 要开始去遍历数组维护区间了.

维护期间:

若cur遇到比key大的值, cur++, prev不动, 此时 prev+1 和 cur 之间的值都是比key大的值.

若cur找到比key小的, prev++, 然后与cur交换值, 这样大的值就到了右边, 小的值就到了左边.

(当prev和cur相邻时相当于自己和自己交换, 值不变.)

维护结束:

当cur遍历结束之后, 交换key和a[prev]的值即可, 因为prev下标的值一定比key小, prev+1往后的值一定大于等于key, prev是最后一个比key小的元素的下标, 其实就是数组的分界点

int PartSort3(int* a, int left, int right)
{
    int keyi = left;
    int prev = left;
    int cur = left + 1;
    while (cur <= right)
    {
        if (a[cur] <= a[keyi])
        {
            prev++;
            Swap(&a[cur], &a[prev]);
        }
        cur++;
    }
    Swap(&a[left], &a[prev]);
    keyi = prev;
    return keyi;
}
//精简版
int PartSort3(int* a, int left, int right)
{
	int prev = left;
	int cur = left+1;
	int keyi = left;
	while (cur <= right)
	{
		if (a[cur] < a[keyi] && ++prev != cur)
		{
			Swap(&a[prev], &a[cur]);
		}

		++cur;
	}

	Swap(&a[prev], &a[keyi]);
	keyi = prev;
	return keyi;
}
4.快速排序优化

1. 三数取中法选key/随机位置取key

之前固定选Key的方法(选第一个或最后一个元素), 如果数据已经有序或者接近有序,效率其实反而会变的很慢O(n^2).
首先比较理想的状态, 即我们的数据是比较随机的情况下, 我们选取第一个或最后一个数作为Key值, 最后交换之后Key可能正好处在比较中间的位置, 正好从中间把数据分成两个部分,然后然后再去递归排两个子区间。
一趟排序, 那就从两头向中间进行遍历再加几次交换, 时间复杂度差不多是O(N), 虽然每层递归排的数据一直在减少, 但N比较大的时候, 后面减的就可以忽略, 那总层数其实就是一棵二叉树的高度logN. 所以这种情况下时间复杂度可以认为是O(N*logN)

但是当数组有序或者接近有序的情况下,每次key选值都在两边,时间复杂度变成了等差数列求和,即O(N^2),而且不仅效率变慢,还有栈溢出的风险:

 

如何选Key:

1.三数取中:对一组待排序的数据[left,end],从left,中间位置(mid)和end中选取中间值,将其替换到left位置,只是把中间值移到left位置,选key的方式仍然是以left为key,对排序逻辑不用做修改。

2.随机位置取key:三数取中法选key也相对固定, 若每次left,mid,end的值都较小则数据仍是相对有序状态, 那么可以每次从left,[left,end]区间内随机位置和end处取中值, 这样能一定程度避免三数取中后key值仍太小.

int GetMidIndex(int* a, int left, int right)
{
    //三数取中:
    //int mid = left + (right - left) / 2;

    //随机位置取key
    int mid = left + rand() % (right - left);//mid为[left,right)区间内的数

    if (a[left] < a[mid])
    {
        if (a[mid < a[right]])
        {
            return mid;
        }
        else if (a[left] > a[right])
        {
            return left;
        }
        else
        {
            return right;
        }
    }
    else//a[left]>=a[mid]
    {
        if (a[mid] > a[right])
        {
            return mid;
        }
        else if (a[left] > a[right])
        {
            return right;
        }
        else
        {
            return left;
        }
    }
}
int PartSort1(int* a, int left, int right)
{
    int midi = GetMidIndex(a,left,right);
    Swap(&a[left], &a[midi]);

    int keyi = left;
    while (left < right)
    {
        {
            right--;
        }
        while (left < right && a[left] <= a[keyi])
        {
            left++;
        }
        Swap(&a[left], &a[right]);
    }
    Swap(&a[keyi], &a[left]);
    return left;
}

2. 递归到小的子区间时,可以考虑使用插入排序

如果待排区间比较小或者说待排数据比较少的时候(比如10个左右的时候),如果还递归去排,递归一次建立一个栈帧,需要递归调用很多次去搞定。
所以,可以再做一点小优化,就是在待排区间比较小的时候(一般选10),我们可以用直接插入排序(相比与其它排序比较好一点)去单独排一下这几个数,从而达到一个优化的效果。

为什么区间长度选10呢?

因为区间为10时递归大约要调用三次, 而二叉树的后三层递归调用占了大约87.5%的调用次数, 这样可以节省大量递归调用的时间.

void QuickSort(int* a,int begin,int end)
{
    if (end - begin + 1 < 10)
    {
        InsertSort(a+begin, end - begin + 1);
        return;
    }

    if (begin >= end)  //1.区间只有1个值 2.区间不存在 这两种情况不需要再继续排序
        return;

    int keyi = PartSort3(a, begin, end);
    //[begin,keyi-1] keyi [keyi+1,end]
    QuickSort(a, begin, keyi - 1);
    QuickSort(a, keyi + 1, end);
}

3.三路划分

三路划分是为了针对性地解决有大量重复数据的场景,快排在处理大量重复数据时,即使有三数取中效率也会下降。

 思路:

比key小的值与left位置交换,相当于把小的值都甩到left的左边,left和cur都++,因为left的值始终指向key的值,cur可以放心往后移。比key大的值与r交换,相当于把大的值都甩到right的右边,right--,cur不要++,因为不知道原来right指向什么值,需要在原位再次判断。和key相等的值cur++即可。

 三路划分本质:

1.小的值甩到左边,大的值甩到右边

2.和key相等的值推到中间

void QuickSortThreeRoads(int*a,int begin,int end)
{
    if (begin >= end)
        return;
    if (end - begin + 1 <= 10)
    {
        InsertSort(a+begin, end - begin + 1);
        return;
    }
    int left = begin;
    int right = end;
    int cur = left + 1;

    int midi = GetMidIndex(a, left, right);
    Swap(&a[left], &a[midi]);
    int key = a[left];  

    while (cur <= right)
    {
        if (a[cur] < key)
        {
            Swap(&a[cur], &a[left]);
            left++;
            cur++;
        }
        else if (a[cur] > key)
        {
            Swap(&a[cur], &a[right]);
            right--;
        }
        else
        {
            cur++;
        }
    }
    QuickSortThree(a, begin, left-1);
    QuickSortThree(a, right+1, end);
}
5.快速排序非递归版

为什么需要非递归:快排在大多数情况下一般都不会出现递归层次太深导致栈溢出,但是,不排除在某些极端情况下可能还是会溢出,因为栈区的空间毕竟还是没有特别大。

快排的非递归需要借助栈这种数据结构来实现,C语言模拟实现的栈使用的空间是在堆上开辟的, 堆区的空间就比较大了.

思路:栈的作用是存储区间,递归的快排中,每次递归传入的数组是一样的,不一样的是区间,非递归中用栈存储区间可以模拟递归的过程。

void QuickSortNonR(int* a, int left, int right)
{
    ST st;
    STInit(&st);
    //先把初始区间入栈
    STPush(&st, right);
    STPush(&st, left);
    while (!STEmpty(&st))
    {
        //栈是后进先出,注意出栈顺序
       left = STTop(&st);
        STPop(&st);
        right = STTop(&st);
        STPop(&st);

        int keyi = PartSort1(a, left,right);
        //对新的区间进行入栈,如果模拟递归的过程,则需要先将大区间入栈(先进后出),保证先处理左(小)区间。
        if (keyi + 1 < right)
        {
            //大区间入栈
            STPush(&st, right);
            STPush(&st, keyi+1);
        }
        if (left < keyi - 1)
        {
            //小区间入栈
            STPush(&st, keyi-1);
            STPush(&st, left);
        }
    }
    STDestroy(&st);
}
6.快速排序的特性总结:

1. 快速排序整体的综合性能使用场景都是比较好的,所以才敢叫快速排序

2. 时间复杂度:O(N*log_{2}N)

 

3.最好情况和最坏情况:每次选的key都是中位数,那么效果就很好,但是如果是有序数组,每次从端点选作key值,也就是变成等差数列,时间复杂度为O(N^2)​。

解决方法:1.随机数取key  2.三数取中 

4. 空间复杂度:O(logN)

5. 稳定性:不稳定


归并排序

基本思想:归并排序(MERGE-SORT)是建立在归并操作上的一种有效的排序算法,该算法是采用分治法(Divide andConquer)的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为二路归并。 归并排序核心步骤:

 

在顺序表和链表中,合并两个有序数组和合并两个有序链表,就用到了归并的思想。
两个指针分别遍历两个链表,依次取小的尾插,最终就将两个链表合并成一个有序链表(升序)。这其实就是归并的思想。
 

1.递归版

归并的重要前提是两组数据都要有序

现在有一组数据,首先可以从中间把它分为两组,如果这两组数据都变成有序的话,就可以对它们进行归并了,如何让它的左右两个区间变得有序?
对它的左右区间再划分,不断分割成子问题,当被划分出来的区间只有一个数时,就可以认为它是一个有序区间了,那我们就可以开始一层一层的往回合并了。将所有的区间归并完,排序也就完成了。不同于快速排序,归并排序和二叉树的后序遍历思想很相似。

void _MergeSort(int* a, int left,int right,int* tmp)
{
    if (left == right)//可以不写left<=right,因为这里不会像快排一样出现不存在的区间
        return;

    int mid = left+(right-left) / 2;
    _MergeSort(a, left, mid, tmp);
    _MergeSort(a, mid + 1, right, tmp);
    //归并,选小的尾插
    int begin1 = left, end1 = mid;
    int begin2 = mid + 1, end2 = right;
    int i = left;
    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++];
    }
    //拷贝到原数组
    memcpy(a+left, tmp+left, sizeof(int) * (right-left+1));
}

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

复杂度计算 

我们对原始数据一直分解,直到分割成不可再分的子问题,如果像上面那样一直从正中间分,最后分解完可以看成一棵满二叉树。
那它的高度(层数)我们可以认为是log_{2}N,然后每一层我们都进行合并:合并其实就是遍历找小尾插,那就是O(N),排完序再将数据放到原始的数组中,所以还要将尾插到新数组的数据拷贝回原数组,那也可以认为是O(N),两个O(N)算时间复杂度就还是O(N)
每层O(N),一共logN层,所以时间复杂度是O(N*log_{2}N)

注意这里我们尾插要放到一个新数组中,因为直接在原数组进行比较尾插有时候会覆盖有效数据。所以要借助一个新数组存储排序好的数据,最后再拷贝回原数组,即归并排序的空间复杂度是O(N)

小区间优化

同快速排序递归的小区间优化,区间长度小于10左右可以用直接插入排序减少递归调用次数

void _MergeSort(int* a, int left,int right,int* tmp)
{
    if (left == right)//可以不写left<=right,因为这里不会像快排一样出现不存在的区间
        return;

    //小区间优化
    if (right - left + 1 < 10)
    {
        InsertSort(a + left, right - left + 1);
        return;
    }

    int mid = left+(right-left) / 2;
    _MergeSort(a, left, mid, tmp);
    _MergeSort(a, mid + 1, right, tmp);
    //归并,选小的尾插
    int begin1 = left, end1 = mid;
    int begin2 = mid + 1, end2 = right;
    int i = left;
    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++];
    }
    //拷贝到原数组
    memcpy(a+left, tmp+left, sizeof(int) * (right-left+1));
}

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

2.非递归版 

先把原始数据一个一个分为一组,每组只有一个数据(gap=1),那就可以认为是有序了,然后从前到后两两进行归并:那这样一趟过后,再把每两个数看成一组(gap=2),每组数据也都是有序的了。以此类推即可。

但这只适用于数据个数为2^N的情况,如果数据为9、10等奇数或非2^N的偶数,归并到最后两个区间时会出现越界。

1.第一组部分越界,即end1越界


        如果end1越界的话,哪那begin2,end2必定也都超过了数组的有效范围,即第二组是不存在的。那只有一组,也就没法进行归并了,所以这种情况直接break就行了。
2.第一组没有越界,第二组全部越界(即begin2越界了)


 那这种情况第二组也是不存在的,直接break。
3.第一组未越界,第二组部分越界(即begin2没有越界但end2越界了)


        这时两组都是有数据的,只不过第二组数据少一些罢了,所以这种情况就不能直接break了,我们要修正一下end2的取值,然后对两组数据进行归并。那end2的取值应该怎么修改,n是数据个数,n-1就是最后一个元素下标,所以让end2等于n-1就行了。

注意:对越界部分的也可以全部区间修正,不存在的区间要确保end2等于n-1,begin2大于end2

void MergeSortNonR(int* a, int n)
{
    int* tmp = (int*)malloc(sizeof(int) * n);
    if (tmp == NULL)
    {
        perror("malloc fail");
        return;
    }
  
    int gap = 1;
    while (gap < n)
    {
        //int i = 0;//循环内用i = j或者循环外i = 0
        for (int j = 0; j < n; j += 2 * gap)
        {
            int begin1 = j;
            int end1 = j + gap - 1;
            int begin2 = j + gap;
            int end2 = j + 2 * gap - 1;
            //对越界部分进行处理
            if (end1 >= n)
            {
                break;
            }
            if (begin2 >= n)
            {
                break;
            }
            if (end2 >= n)
            {
                end2 = n - 1;
            }

            //对越界部分的也可以全部区间修正,不存在的区间要确保end2等于n-1,begin2大于end2
            //if (end1 >= n)
            //{
            //    end1 = n - 1;
            //    
            //    // 不存在区间
            //    begin2 = n;
            //    end2 = n - 1;
            //}
            //else if (begin2 >= n)
            //{
            //    // 不存在区间
            //    begin2 = n;
            //    end2 = n - 1;
            //}
            //else if(end2 >= n)
            //{
            //    end2 = n - 1;
            //}
        
            //不越界或者越界修正后,开始归并
            int i = j;
            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++];
            }
            //注意这里归并一次后就要将归并的数据拷贝一遍,
            memcpy(a + j, tmp + j, sizeof(int) * (end2 - j + 1));
        }
        gap *= 2;
    }

}

       

归并排序的特性总结:
1. 归并的缺点在于需要O(N)的空间复杂度,归并排序的思考更多的是解决在磁盘中的外排序问题。
2. 时间复杂度:O(N*logN)
3. 空间复杂度:O(N)
4. 稳定性:稳定 


非比较排序

计数排序

计数排序是一个非基于比较的排序算法,该算法于1954年由 Harold H. Seward 提出。它的优势在于在对一定范围内的整数排序时,它的复杂度为Ο(n+k)(其中k是整数的范围),快于任何比较排序算法。

思想: 计数排序又称为鸽巢原理,是对哈希直接定址法的变形应用。

1.绝对映射

操作步骤:1. 统计每个元素出现的次数保存到另一个数组中

 2. 根据统计的结果将序列回收到原来的序列中。

遍历数组C,下标为0的的值是2,就说明0出现了两次,那就向原数组中(也是从下标为0位置开始),放两个0进去,继续遍历C,下标1位置值是0,就说明原数据没有1,继续向后,下标2的位置值是2,就接着向原数组放两个2…
以此类推,直到遍历完C数组。这时原数组的数据就是排好序之后的:

 

2.相对映射

思路:如果对一组数据[109,104,110,100,105,106]进行计数排序,若按照绝对映射来创建数组则需要创建一个很大的数组,且其中很多空间都被浪费。现在要对绝对映射进行优化,可以考虑使用数据的相对映射,上面这组数据中最大值max是110最小值min是100,所以我们只需开一个大小range为(max-min+1)的数组就够了。该数组的下标范围就是[0,10],那按照相对映射,100是最小值,就映射到下标为0的位置,110映射到下标为10的位置。即对于待排数组中的数据a[i],就映射到下标为a[i]-min的位置,取数据时只需用下标加min即为原数据。

相对映射的优点:

1.绝对映射中如果待排数组中有负数可以吗?
假如数组中一个数据为-1,按照绝对映射-1出现的次数应存在下标为-1的位置,造成越界。
但用相对映射就可以,若-1是数组中的最小值,10是最大值,range = 10-(-1) +1=12, 区间范围为[0,11]下标为0的位置是-1,下标为11的位置为10。

2. 可以少开辟一些数组,节约空间。

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* tmp = (int*)malloc(sizeof(int) * range);
    if (tmp == NULL)
    {
        perror("malloc fail");
        exit(-1);
    }
    memset(tmp, 0, sizeof(int) * range);

    for (int j = 0; j < n; j++)
    {
        tmp[a[j] - min]++;
    }

    int t = 0;
    for (int k = 0; k <range; k++)
    {
        while (tmp[k]--)
        {
            a[t++] = k + min;
        }
    }
    free(tmp);
}

计数排序的缺点:

 1.计数排序一般适用于范围相对集中的数据,比如一组数据[2,5,3,0,1000],计数排序创建的数组浪费了很多空间。

2.无法对除整数外其他数据类型(浮点数等)进行排序。

计数排序的特性总结:
1. 计数排序在数据范围集中时,效率很高,但是适用范围及场景有限。
2. 时间复杂度:O(MAX(N,range))
3. 空间复杂度:O(range) 

4. 稳定性:稳定


排序算法复杂度及稳定性分析 

 

稳定性:假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,r[i]=r[j],且r[i]在r[j]之前,而在排序后的序列中,r[i]仍在r[j]之前,则称这种排序算法是稳定的;否则称为不稳定的。

注意:这里的稳定性不是指每次排序的时间稳不稳定,是指排序前相等数据的相对位置发不发生变化,不发生变化就是稳定的。

直接插入排序是稳定的,因为它每次从后往前比,遇到小(大)的值向后挪一个数据,相等的时候可以选择不挪动,所以是稳定的。

希尔排序是不稳定的,相同的数据可能分在不同的gap组,每一组的排序是稳定的,但不能保证相同的数据都分在同一组。

选择排序是不稳定的,选数时是稳定的,交换时是不稳定的。

 堆排序是不稳定的,排升序大的值被换到最后,其余和它相等的值一定会排到它前面

 冒泡排序是稳定的,两两比较,大(小)的交换,相等的可以不交换。

快速排序是不稳定的,key值位置一直变换,难保持稳定。

归并排序是稳定的,两数组归并时,相等的数据选左边的尾插即可。

一篇写的很好的排序文章:十大经典排序算法(动图演示) - 一像素 - 博客园 (cnblogs.com)


外排序

未完...

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值