八大排序详解

news2024/11/24 1:07:35

目录

1.排序的概念及应用

1.1 排序的概念

1.2 排序的应用

1.3 常见的排序算法

2.常见排序算法的实现

2.1 直接插入排序

2.1.1 基本思想

2.1.2 动图解析

2.1.3 排序步骤(默认升序)

2.1.4 代码实现

2.1.5 特性总结

2.2 希尔排序

2.2.1 基本思想

2.2.2 排序步骤

2.2.3 代码实现

2.2.4 动图解析

2.2.5 特性总结

2.3 选择排序

2.3.1 基本思想

2.3.2 排序步骤(升序)

2.3.3 代码实现

2.3.4 动图解析

2.3.5 特性总结

2.4 堆排序

2.5 冒泡排序

2.5.1 基本思想

2.5.2 代码实现

2.5.3 动图解析

2.5.4 特性总结

2.6 快速排序

2.6.1 基本思想

2.6.2 排序步骤

2.6.3 代码实现

区间划分算法(hoare初始版本):

主框架:

2.6.4 区间划分算法

hoare法

挖坑法

前后指针法

2.6.5 快排优化

取key方面的优化

递归方面的优化

2.6.6 快排非递归实现

栈实现(代码+图解):

队列实现:

2.6.7 特性总结

2.7 归并排序​

2.7.1 基本思想

2.7.2 排序步骤

2.7.3 代码实现

2.7.4 动图解析

2.7.5 非递归实现 

2.7.6 特性总结

2.8计数排序

2.8.1 基本思想

2.8.2 排序步骤

2.8.3 代码实现

2.8.4 特性分析

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


1.排序的概念及应用


1.1 排序的概念

排序:所谓排序,就是使一串记录,按照其中的某个或某些关键字的大小,递增或递减的排列起来的操作。

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

内部排序:数据元素全部放在内存中的排序。 外部排序:数据元素太多不能同时放在内存中,根据排序过程的要求不能在内外存之间移动数据的排序。

1.2 排序的应用

排序算法在计算机科学和实际应用中有广泛的应用。以下是一些常见的排序算法的应用示例:

  1. 数据库查询:在数据库中,经常需要根据某个字段对数据进行排序,以便更快地进行查询和检索。排序算法可以用于对数据库中的数据进行排序,从而提高查询效率。

  2. 搜索算法:许多搜索算法的实现需要对数据进行排序。例如,在二分查找算法中,要求待搜索的数据必须是有序的。因此,使用排序算法对数据进行排序,可以提高搜索算法的效率。

  3. 负载均衡:在分布式系统中,负载均衡是一种优化策略,通过将工作负载均匀地分配给各个节点,以提高系统的性能和吞量。排序算法可以用于对请求或任务进行排序,以便将工作负载均匀地分布给各个节点。

  4. 数据压缩:在数据压缩算法中,排序算法可以用于对数据进行预处理,以便更好地利用压缩算法的特性。例如,在哈夫曼编码中,可以根据字符频率对字符进行排序,以便构建最优的编码树。

  5. 排序和统计:在统计分析中,需要对数据进行排序,以便进行数据的聚合、分组和分析。排序算法可以用于对数据进行排序,从而更方便地进行统计分析。

  6. 任务调度:在任务调度算法中,排序算法可以用于对任务进行排序,以便根据任务的优先级、截止时间或其他标准进行合理的任务调度和分配。

总之,排序算法在各个领域中都有广泛的应用。通过对数据进行排序,可以提高系统的性能、搜索算法的效率,以及优化各种应用场景中的数据处理和分析。

1.3 常见的排序算法

2.常见排序算法的实现


2.1 直接插入排序

2.1.1 基本思想

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

直接插入排序希尔排序都属于插入排序。

我们在实际生活中的摸扑克牌用的就是这个思想。

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

2.1.2 动图解析

要点:

  1. 待插入数组已经排好序的。

  2. 待插入数据依次与数组中的元素比较,找到合适的位置。

  3. 该位置及其后面的元素全部后移,待插入数据插入该位置。

  4. 数组中除了第一个元素array[0](默认有序),所有元素都看做待插入数据依次插入数组中,完成排序。

2.1.3 排序步骤(默认升序)

  1. 从第一个元素开始,该元素可以被认为已经有序。

  2. 取下一个元素tmp(待插入元素),从已排序好的数组(待插入数组)中从后往前扫描。

  3. 如果tmp小于该元素,该元素向后挪动一位。

  4. 重复步骤3,直到tmp大于等于已排序数组中的某个元素。

  5. tmp插入到该元素的后面,如果tmp小于该数组中的所有元素,则tmp插入到下标为0的位置。

  6. 重复2~5。

2.1.4 代码实现

我们先写单趟插入:

void Insert(int* a, int n)
{
    //一次插入
    int end = 0;
    int tmp = a[end + 1];
    while (end >= 0)
    {
        if (tmp < a[end])
        {
            a[end + 1] = a[end];
        }
        else
        {
            break;
        }
​
        end--;
    }
​
    a[end + 1] = tmp;
}

代码解析:

  1. end变量:已排序好的数组中最后一个元素的下标,第一次插入从0开始(数组中只有一个元素array[0])

  2. tmp变量:待插入数据,防止被a[end]覆盖,单独用一个变量保存起来。

  3. tmp依次和数组中的元素比较,如果tmp < a[end]则a[end]向后移,如果tmp >= a[end]则找到了插入位置,break退出循环。

整个数组插入(最终版本):

void Insert(int* a, int n)
{
    //一组插入
    for(int i = 0; i < n - 1; i++)
    {
        //i用于控制end(end:待插入数组中最后一个元素的下标)
        int end = i;
        int tmp = a[end + 1];
        while (end >= 0)
        {
            if (tmp < a[end])
            {
                a[end + 1] = a[end];
            }
            else
            {
                break;
            }
​
            end--;
        }
        a[end + 1] = tmp;
    }
}

用一个循环控制end即可完成整个数组的插入,初始end == 0(数组中一个元素array[0]),末尾end == n - 2(数组中有n - 1个元素)。

2.1.5 特性总结

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

2.时间复杂度:

最坏情况(假设升序):数组逆序,时间复杂度O(N^2)

最好情况:数组升序,时间复杂度O(N)

综合来看,时间复杂度O(N^2)

我们可以看出:元素集合越接近有序,直接插入排序算法的时间效率越高。

2.2 希尔排序

前面我们分析得出:元素集合越接近有序,直接插入排序的时间效率越高,那么我们是不是可以利用某种算法让数组不断接近有序,然后再进行直接插入排序?这里就要介绍希尔排序了。


2.2.1 基本思想

希尔排序法又称缩小增量法。希尔排序法的基本思想是:将待排记录序列分割成为若干子序列分别进行插入排序,待整个序列中的记录"基本有序"时,再对全体记录进行一次直接插入排序。

2.2.2 排序步骤

  1. 选定一个整数gap作为第一增量。

  2. 间隔为gap的元素为一组数据,把整个数组分成gap组。

  3. 每一组数据都进行直接插入排序,gap组数据排完之后数组就接近有序。

  4. 取一个比第一增量小的数作为第二增量,重复2~3。

  5. gap == 1时,整个数组为一组进行直接插入排序,完成排序。

  • 间隔为gap,数组就被分成gap组,每组有n/gap个元素。

  • 以gap为间隔进行一趟排序,数组就更加接近有序。

  • gap == 1,就是直接插入排序。

2.2.3 代码实现

  • 间隔为gap的一组数据进行直插排序

void ShellSort(int* a, int n)
{
    int gap = n / 2;
    //间隔为gap的一组元素进行直插排序
    for (int i = 0; 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]; 
            }
            else
            {
                break;
            }
​
            end -= gap; 
        }
​
        a[end + gap] = tmp;
    }
}

这里for循环控制的end变量,应该控制成i < n - gap,因为tmp的下标为end + gap,要保证end + gap < n,所以end < n - gap。或者可以类比直接插入排序,直插的循环控制成i < n - 1 ,gap为1,所以i < n - gap;

  • gap组数据分开排序:

void ShellSort(int* a, int n)
{
    int gap = n / 2;
    //gap组数据分开排序
    for (int j = 0; j < gap; j++)
    {
        //间隔为gap的一组元素进行直插排序
        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];
                }
                else
                {
                    break;
                }
​
                end -= gap;
            }
​
            a[end + gap] = tmp;
        }
    }
}

这里代码有了三层循环,再加上对gap的缩减就要有四层循环,相对比较复杂,所以我们进行优化:

  • gap组数据并排:

void ShellSort(int* a, int n)
{
    int gap = n / 2;
    //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];
            }
            else
            {
                break;
            }
​
            end -= gap;
        }
​
        a[end + gap] = tmp;
    }
}

这里的代码的循环变量迭代从 i += gap 变成了 i++,从三层循环优化成了两层循环,但是达到的效果是一样的。

gap组并排和的分开排序不同的是:并排在一组数据还没有插完的情况下又去插入另一组数据,而分开排序是插完一组后才接着插入下一组。

  • 控制gap进行多次直插:(最终版)

void ShellSort(int* a, int n)
{
    int gap = n;
    while (gap > 1) 
    {
        gap = gap / 3 + 1;
        //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]; 
                }
                else
                {
                    break;
                }
​
                end -= gap; 
            }
​
            a[end + gap] = tmp; 
        }
    }
}

注意这里多gap的控制,要保证最后gap == 1 进行直接插入排序,可以是gap = gap / 2,也可以是gap = gap / 3 + 1;一般使用后者,这样增量缩小的快。

2.2.4 动图解析

2.2.5 特性总结

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

  2. 时间复杂度:

希尔排序的时间复杂度计算相当麻烦,要用到专业的数学知识,官方复杂度为O(N^1.3),比O(N*logN)差一点。

但是我们可以定性分析一下(数组中n个元素):

  • gap很大,比如gap == n / 3:数组分为n / 3组,每组3个数据,每组比较3次,合计(n / 3) * 3 = n次。

  • gap很小,比如gap = = 1:数组接近有序,直接插入,合计n次。

  • gap取中间的值,如n / 9:每组比较1+2+...+8=36次,合计36 * (n / 9) == 4n次。

里面的比较次数是逐渐变化的,两端比较次数少,越往中间比较次数越多。

2.3 选择排序

2.3.1 基本思想

每一次从待排序的数据元素中选出最小和最大的元素,存放在序列的起始位置和末尾位置,直到全部待排序的数据元素排完 。

选择排序堆排序都属于选择排序。

2.3.2 排序步骤(升序)

  1. 选取一个基准值作为最大值和最小值(默认array[begin])

  2. 遍历array[begin + 1]~array[end]选出最大值max和最小值min

  3. array[begin]和min交换,array[end]和max交换

  4. begin++,end--

  5. 重复1~4

2.3.3 代码实现

步骤相对简单,我们直接写代码

void SelectSort(int* a, int n)
{
    int begin = 0, end = n - 1;
    while (begin < end)
    {
        int minI = begin, maxI = begin; 
        for (int i = begin + 1; i <= end; i++)
        {
            if (a[i] < a[minI])
            {
                minI = i; 
            }
            if (a[i] > a[maxI])
            {
                maxI = i;
            }
        }
        Swap(&a[begin], &a[minI]);
​
        if (maxI == begin)
        {
            maxI = minI;
        }
        Swap(&a[end], &a[maxI]); 
        begin++;
        end--;
    }
}

注意:

Swap(&a[begin], &a[minI])时,下标maxI可能就是begin,max提前被换走,所以就要调整一下maxI的下标。

2.3.4 动图解析

2.3.5 特性总结

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

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

2.4 堆排序

详见:堆排序

2.5 冒泡排序

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

冒泡排序快速排序都属于交换排序。


2.5.1 基本思想

知名度最高的排序,不多做解释。

如果右边 < 左边,交换左右的值,一直往后交换,每一躺下来最大的数据在右边。

2.5.2 代码实现

void BubbleSort(int* a, int n)
{
    for (int i = 0; i < n; i++)
    {
        int exchange = 0; 
        for (int j = 1; j < n - i; j++)
        {
            if (a[j] < a[j - 1])
            {
                Swap(&a[j], &a[j - 1]);
                exchange = 1;
            }
        }
        //一趟下来没有交换
        if (exchange == 0)
            break;
    }
}

代码优化:

每一趟冒泡排序设置一个exchange变量为0;

如果一趟冒泡发生了交换,exchange置1;

如果一趟冒泡下来exchange还为0,说明没有交换,已经有序,直接break;

2.5.3 动图解析

2.5.4 特性总结

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

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

2.6 快速排序

2.6.1 基本思想

快速排序采用分治法,任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止。

2.6.2 排序步骤

  1. 选取基准值,通过区间划分算法把待排序序列分割成左右两部分。

  2. 左右序列中递归重复1。

2.6.3 代码实现

区间划分算法(hoare初始版本):

区间划分算法有三个版本:hoare法,挖坑法,前后指针法,这里介绍hoare法,也是快排的初始划分法。

三种划分方法的结果可能不同,但都是让小的值往前靠拢,大的值往后靠拢,让整个序列逐渐趋于有序。

步骤:

  1. 默认序列最左边的元素为基准值key,设置left,right指针;

  2. left找大,right找小,right要先找,都找到后交换a[left]和a[right];

  3. 重复步骤3

  4. 当left == right时,交换key和相遇位置的元素,完成分割。

走完这一趟后,key值左边都不比key大,key值右边都不比key小,key值到了他排序后应该在的位置,不需要挪动这个元素了。

图解:

算法分析:

为什么能保证相遇位置的值一定比key值小,然后交换?

关键点就是让right先找!

相遇有两种情况:

  • left往right靠拢,与right相遇:right先找到了小的元素,相遇后的值一定比key小。

  • right往left靠拢,与left相遇:left指针指向的元素是上一波交换过后的元素,该元素比key小。

假如我们让left先找的话,相遇位置比key值大,不能交换。

代码:

int Partion(int* a, int left, int right)
{
	int keyI = left;

	//left == right两个指针相遇,退出循环
	while (left < right)
	{
	
		//right先找,right找小
		while (left < right && a[right] >= a[keyI])
		{
			right--;
		}

		//left找大
		while (left < right && a[left] <= a[keyI])
		{
			left++;
		}

		//都找到了,交换
		Swap(&a[left], &a[right]);
	}

	//left和right相遇,交换key和相遇位置元素
	Swap(&a[keyI], &a[left]);

	return left;
}

划分方法一般不用hoare,是因为这种算法实现的代码很容易出现bug,比如:

  1. right找小和left找大的过程中,要保证left < right,否则可能出现数组越界,比如1,9,6,4,2,7,8,2 ;右边的值都比key大,会导致越界。

  2. a[right] >= a[keyI]或者a[left] <= a[keyI]时,才能--right或者++left;如果是a[right] > a[keyI]或者a[left] < a[keyI]可能出现死循环,比如a[left] == a[right] == key时,交换完后不进入内部while,外部while陷入死循环。

主框架:
void _QuickSort(int* a, int begin, int end)
{
	if (begin >= end)
		return;

	//根据基准值把数组划分成左右序列
	int keyI = Partion(a, begin, end);

	//左右序列递归划分下去
	_QuickSort(a, begin, keyI - 1);
	_QuickSort(a, keyI + 1, end);
}

void QucikSort(int* a, int n)
{
	_QuickSort(a, 0, n - 1);
}

上述为快速排序递归实现的主框架,与二叉树前序遍历规则非常像。

二叉树的递归终止条件是空树,快排的终止条件是数组只有一个元素(left==right)或者数组区间不存在(left>right)。

浅画一下展开图:

2.6.4 区间划分算法

前面所说,hoare划分法有一定的缺陷,我们下面重点介绍其他两种常用的划分方法。

hoare法
int Partion(int* a, int left, int right)
{
	int keyI = left;

	//left == right两个指针相遇,退出循环
	while (left < right)
	{
	
		//right先找,right找小
		while (left < right && a[right] >= a[keyI])
		{
			right--;
		}

		//left找大
		while (left < right && a[left] <= a[keyI])
		{
			left++;
		}

		//都找到了,交换
		Swap(&a[left], &a[right]);
	}

	//left和right相遇,交换key和相遇位置元素
	Swap(&a[keyI], &a[left]);

	return left;
}
‍
挖坑法

步骤:

  1. 默认序列最左边的元素为基准值key,把值挖走用key变量保存,该位置为一个坑。

  2. 右边找小,找到后把值填给坑位,该位置成为新的坑位。

  3. 左边找大,找到后把值填给坑位,该位置成为新的坑位。

  4. 重复步骤2~3。

  5. 左右相遇,相遇位置也是个坑位,key值填入坑位。

图解:

代码:

int Partion2(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;
}

与前面代码不同的是,这里的key值我们不存下标,用一个变量保存。

前后指针法

步骤:

  1. 默认序列最左边的元素为基准值key,设置prev指针 == left,cur指针 == left+1。

  2. cur找小,找到后,prev++,a[prev]和a[cur]交换。

  3. 重复步骤2。

  4. cur走完以后,a[prev]和key交换。

图解:

代码:

int Partion3(int* a, int left, int right)
{
	int keyI = left;
	int prev = left, cur = prev + 1;

	while (cur <= right)
	{
		if (a[cur] < a[keyI] && ++prev != cur)
			Swap(&a[prev], &a[cur]);

		++cur;
	}
	Swap(&a[prev], &a[keyI]);

	return prev; 
}

为了避免自己和自己交换,prev先++判断和cur是否相等,相等就不交换。

很明显这种分割方法的代码相比前面两种简单了许多,这种划分法也是最常用的。

2.6.5 快排优化

取key方面的优化

最理想的情况就是key值每次都是中间的值,快排的递归就是一个完美的二分。

快排在面对一些极端数据时效率会明显下降;就比如完全有序的序列,这种序列的基准值key如果再取最左边或者最右边的数,key值就是这个序列的最值,复杂度会变成O(N^2):

这时候就可以用三数取中法来解决这个弊端,三个数为:a[left],a[mid],a[right],这样就可以尽量避免key值选到最值的情况。  

//三数取中法选key值
int GetMidIndex(int* a, int left, int right) 
{
	int mid = (left + right) / 2; 

	if (a[left] < a[mid])
	{
		if (a[mid] < a[right])
			return mid;
		else if (a[left] > a[right])
			return left; 
		else
			return right; 
	}
	else  //mid < left
	{
		if (a[left] < a[right])
			return left;
		else if (a[mid] > a[right])
			return mid; 
		else
			return right; 
	}
}
//前后指针划分
int Partion3(int* a, int left, int right)
{
    //中间值的下标为midI,a[left]替换为此中间值
	int midI = GetMidIndex(a, left, right);  
	Swap(&a[left], &a[midI]); 

	int keyI = left;
	int prev = left, cur = prev + 1;

	while (cur <= right)
	{
		if (a[cur] < a[keyI] && ++prev != cur)
			Swap(&a[prev], &a[cur]);

		++cur;
	}
	Swap(&a[prev], &a[keyI]);

	return prev; 
}
递归方面的优化

我们知道,递归深度太深并不是一件好事,所以我们可以针对递归方面来进行优化,减少绝大多数的递归调用。

如何优化呢?当递归到区间内元素个数<=10时,调用直接插入排序。

void _QuickSort(int* a, int begin, int end)
{
	if (begin >= end)
		return;

	//区间内元素个数 <= 10,调用直接插入排序
	if (end - begin + 1 <= 10)
	{
		InsertSort(a + begin, end - begin + 1);
		//注意:起始地址是a + begin,不是a
	}
	else
	{
		//根据基准值把数组划分成左右序列
		int keyI = Partion3(a, begin, end); 

		//左右序列递归划分下去
		_QuickSort(a, begin, keyI - 1); 
		_QuickSort(a, keyI + 1, end); 
	}
}

这种优化其实可以减少绝大多数的递归调用,我们把快排的递归划分想象成一颗二叉树,区间长度小于10的数组大概在这棵二叉树的最后三层,而最后三层占了整棵树结点个数的80%多(最后一层50%,倒数第二层25%...),类比快排的递归来看,我们省去了80%多的递归调用,并且对于数据规模较小的情况下,直插和快排的效率差不了多少,所以这是一个极大的优化,算法库中的sort函数也大多是这种优化。

2.6.6 快排非递归实现

快排的非递归我们可以使用一个栈(深度优先遍历)或者一个队列实现(广度优先遍历)

栈实现(代码+图解):
void QuickSortNonRByStack(int* a, int n)
{
	Stack st; 
	StackInit(&st);
	int begin = 0, end = n - 1;
	//先Push右边界,在Push左边界
	//记住push顺序,取top的时候左右不要取反了
	StackPush(&st, end);
	StackPush(&st, begin);

	while (!StackEmpty(&st))
	{
		int begin = StackTop(&st);  
		StackPop(&st);  
		int end = StackTop(&st); 
		StackPop(&st); 

		int keyI = Partion3(a, begin, end);
		//[begin, keyI - 1]  keyI  [keyI + 1, end]

		//先递归到左区间,所以右区间先入栈
		if (keyI + 1 < end) 
		{
			//先Push右边界,在Push左边界
			StackPush(&st, end);   
			StackPush(&st, keyI + 1);   
		}

		if (begin < keyI - 1)
		{
			//先Push右边界,在Push左边界
			StackPush(&st, keyI - 1); 
			StackPush(&st, begin);
		}
	}

	StackDestory(&st);
}

 

队列实现:
void QuickSortNonRByQueue(int* a, int n)
{
	Queue q;
	QueueInit(&q);
	int begin = 0, end = n - 1; 
	QueuePush(&q, begin); 
	QueuePush(&q, end); 

	while (!QueueEmpty(&q))
	{
		int begin = QueueFront(&q);
		QueuePop(&q);
		int end = QueueFront(&q); 
		QueuePop(&q); 

		int keyI = Partion3(a, begin, end); 

		if (begin < keyI - 1) 
		{
			QueuePush(&q, begin);  
			QueuePush(&q, keyI - 1);
		}

		if (keyI + 1 < end) 
		{
			QueuePush(&q, keyI + 1); 
			QueuePush(&q, end);   
		}
	}
	QueueDestory(&q); 
}

2.6.7 特性总结

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

  • 时间复杂度:O(NlogN)

最好情况,每次key都在中间位置,正好二分

最坏情况,每次key都是最值,复杂度O(N^2)

平均情况(带优化),复杂度O(NlogN)

  • 空间复杂度:O(logN)

2.7 归并排序​

2.7.1 基本思想

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

2.7.2 排序步骤

归并排序和快速排序一样,采用的是分治法:

  1. 将待排序序列分成两个子序列。

  2. 递归两个子序列使两个子序列有序。

  3. 合并两个子序列使整个序列有序。

  4. 左右子序列的排序重复1~3.

2.7.3 代码实现

几个注意点:

  • 我们使用归并排序,能在原数组上进行归并吗?当然不能!假如后一个值小于前一个,那么就对前一个值进行了覆盖。所以我们要开辟一个临时空间,数据归并到这个临时空间里,然后再拷贝到原数组。这也是归并排序的一个缺点,需要额外空间。

  • tmp数组的数据拷贝到原数组时,注意两个空间的起始地址:tmp + left , a + left

void _MergeSort(int* a, int* tmp, int left, int right)
{
	if (left >= right)
		return;

	int  mid = (left + right) / 2;
	//[left, mid]  [mid + 1, right]
	_MergeSort(a, tmp, left, mid);
	_MergeSort(a, tmp, mid + 1, right);

	int begin1 = left, end1 = mid;
	int begin2 = mid + 1, end2 = right;
	int index = left;

	while (begin1 <= end1 && begin2 <= end2)
	{
		if (a[begin1] <= a[begin2])
		{
			tmp[index++] = a[begin1++];  
		}
		else
		{ 
			tmp[index++] = a[begin2++]; 
		}
	}

	while (begin1 <= end1)
	{ 
		tmp[index++] = a[begin1++]; 
	}
	while (begin2 <= end2)
	{
		tmp[index++] = a[begin2++]; 
	}

	//tmp局部排好的数据拷贝到a中
	//注意这里两个空间的起始地址,不要传tmp和a了
	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("Merge:");
		exit(-1);
	}

	_MergeSort(a, tmp, 0, n - 1);

	free(tmp);
}

2.7.4 动图解析

2.7.5 非递归实现 

归并排序的非递归在逻辑上不难理解,但是他在边界上的控制却相当麻烦,我们先看一种不麻烦的情况:数组size为2^n的情况下。

假设数组个数为8,我们可以先一个数据和一个数据归并,然后两个数据和两个数据归并,然后四个数据和四个数据归并,完成排序。

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

	int gap = 1;
	while (gap < n)
	{
		for (int i = 0; i < n; i += 2 * gap)
		{
			int begin1 = i, end1 = i + gap - 1;
			int begin2 = i + gap, end2 = i + 2 * gap - 1;
			int index = begin1; 

			//[begin1, end1]  [begin2, end2]两个区间进行归并
			while (begin1 <= end1 && begin2 <= end2)
			{
				if (a[begin1] <= a[begin2])
				{
					tmp[index++] = a[begin1++];
				}
				else
				{
					tmp[index++] = a[begin2++];
				}
			}

			while (begin1 <= end1)
			{
				tmp[index++] = a[begin1++];
			}
			while (begin2 <= end2)
			{
				tmp[index++] = a[begin2++];
			}

			memcpy(a + i, tmp + i, sizeof(int) * (end2 - i + 1)); 

		}
		gap *= 2;
	}

	free(tmp);
}
‍

但是我们并不能保证[begin1, end1][begin2, end2]这两个区间都存在,可能会有越界,我们可以肯定的是begin1一定不会越界,因为begin1 == i,而i < n,我们就是用i来控制一组数据归并的起始地址。所以可能越界的只会有:end1,begin2,end2

这三种越界我们根据右区间存不存在分两种谈论:

  1. end1和begin2越界,这种情况下第二个区间都不存在了,我们就不用归并了。

  2. end2越界,这种情况下第二个区间还存在,我们只需修正一下end2 = n - 1即可。

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

	int gap = 1;
	while (gap < n)
	{
		for (int i = 0; i < n; i += 2 * gap)
		{
			int begin1 = i, end1 = i + gap - 1;
			int begin2 = i + gap, end2 = i + 2 * gap - 1;
			int index = begin1;

			//[begin1, end1]  [begin2, end2]两个区间进行归并
			//可能越界的位置:end1,begin2,end2
			//end1和begin2越界,右区间不存在,直接不用归了
			//end2越界,右区间还存在,修正一下end2 == n - 1,接着归

			if (begin2 >= n) //end1越界,begin2肯定越界
			{
				break;
			}
			if (end2 >= n)
			{
				end2 = n - 1;
			}

			//printf("[%d, %d][%d, %d]  ", begin1, end1, begin2, end2); 

			while (begin1 <= end1 && begin2 <= end2)
			{
				if (a[begin1] < a[begin2])
				{ 
					tmp[index++] = a[begin1++]; 
				}
				else
				{
					tmp[index++] = a[begin2++]; 
				}
			}

			while (begin1 <= end1)
			{
				tmp[index++] = a[begin1++]; 
			}
			while (begin2 <= end2)
			{
				tmp[index++] = a[begin2++]; 
			}

			//一组归完直接拷贝,方便控制
			memcpy(a + i, tmp + i, sizeof(int) * (end2 - i + 1));  

		}
		gap *= 2;
		//printf("\n");
	}

	free(tmp); 
}

代码还有两点注意:

  • 一组数据归并时,begin1是一直++的,所以归并完后左区间的左边界并不是begin1,而是i。

  • 这里tmp数组拷贝到原数组的时候我们并不是拷贝整个数组,而是一组归并完后就直接拷贝,这样方便控制,不然我们不好控制拷贝的数据个数;如果整个tmp都拷贝,会带有随机值覆盖原数组,所以不建议整个拷贝。

2.7.6 特性总结

  1. 时间复杂度:O(N*logN) ,完美的二分

  2. 空间复杂度:O(N)

2.8计数排序

2.8.1 基本思想

计数排序相比于前面的所有排序都有所不同,因为这是一种非比较排序,也是一种哈希思维的应用。

哈希思维,就是健值和他的存储位置构成一种映射关系,也就是数组的值和他的下标存在一种映射关系。

2.8.2 排序步骤

排序步骤:

  1. 创建一个计数的数组,数组的值初始化为0。

  2. 遍历待排序数组,根据数组的值映射到计数数组的下标,让该下标的值++。

  3. 根据统计的结果,计数数组的数据覆盖到原数组中,完成排序。

这里有两点注意

  1. 但是我们不能进行直接映射(就是数组的值 == 映射的下标),这样很容易造成空间浪费。这里就要进行相对映射,先找到数组中的最小值min,映射下标 == 数组的值 - min。

  2. 计数数组的大小我们也不能随便开辟,大小应该为max - min + 1。

最终排序步骤:

  1. 找出数组的min和max。

  2. 创建一个计数的数组,大小为max - min + 1,数组的值初始化为0。

  3. 遍历待排序数组,根据数组的值映射到计数数组的下标,让该下标的值++。

  4. 根据统计的结果,计数数组的数据覆盖到原数组中,完成排序。

2.8.3 代码实现

void CountSort(int* a, int n)
{
	//找到max和min
	int max = a[0], min = a[0];
	for (int i = 1; i < n; i++)
	{
		if (a[i] < min)
		{
			min = a[i];
		}
		if (a[i] > max)
		{
			max = a[i];
		}
	}

	//创建计数数组并初始化为0
	int range = max - min + 1;
	int* countArr = (int*)malloc(sizeof(int) * range);
	if (countArr == NULL)
	{
		perror("CountSort:");
		exit(-1);
	}
	memset(countArr, 0, sizeof(int) * range); 

	//统计数组数据
	for (int i = 0; i < n; i++)
	{
		countArr[a[i] - min]++;
	}

	//根据统计结果,拷贝到原数组
	int index = 0; 
	for (int i = 0; i < range; i++)
	{
		while (countArr[i]--)
		{
			a[index++] = i + min; 
		}
	}

	free(countArr);
}
‍

2.8.4 特性分析

  1. 计数排序在数据范围集中时,效率很高,但是适用范围及场景有限。

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

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

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

稳定性概念:

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

下面我们研究一下七大排序的稳定性:

  • 直接插入排序:

待插入数据(tmp) >= a[end] 插入到该数据的后面,前后关系不变,直插排序是个稳定的排序

  • 希尔排序

假如两个相同的数据被分到了不同组,我们不能保证这两个数据的前后关系,所以希尔排序是个不稳定的排序

例如:

  • ​​​​​​​选择排序(最容易错的)

假如被交换的元素就是相同的元素,前后关系改变,所以选择排序是个不稳定的排序。

例如:

第一个3与最小元素1交换后,相同元素3的顺序发生了改变。

  • 堆排序

如果堆顶数据和他的一个孩子相同,前后关系改变,所以堆排序是个不稳定的排序

例如:

  • 冒泡排序

如果左边和右边相等,就不交换,这样前后关系不变,所以冒泡排序是个稳定的排序

  • 快速排序

假如一个值和key相等,并且相遇位置在这个值的右边,这样交换后前后关系改变,所以快速排序是个不稳定的排序

例如:

  • 归并排序

如果左区间的数 == 右区间的数时,把左区间的数归并下来,这样前后关系不变,所以归并排序是个稳定的排序

    //[begin1, end1]  [begin2, end2]两个区间进行归并
	while (begin1 <= end1 && begin2 <= end2)
	{
		if (a[begin1] <= a[begin2])
		{
			tmp[index++] = a[begin1++];
		}
		else
		{
			tmp[index++] = a[begin2++];
		}
	}

	while (begin1 <= end1)
	{
		tmp[index++] = a[begin1++];
	}
	while (begin2 <= end2)
	{
		tmp[index++] = a[begin2++];
	}
‍
排序方法时间复杂度空间复杂度稳定性
直接插入排序O(N^2)O(1)Y
希尔排序O(N^1.3)O(1)N
选择排序O(N^2)O(1)N
堆排序O(NlogN)O(1)N
冒泡排序O(N^2)O(1)Y
快速排序O(NlogN)O(logN)N
归并排序O(NlogN)O(N)Y
计数排序O(N+range)O(range)N

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1045139.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

基于Xilinx UltraScale+ MPSOC(ZU9EG/ZU15EG)的高性能PCIe数据预处理平台

PCIE707是一款基于PCIE总线架构的高性能数据预处理FMC载板&#xff0c;板卡具有1个FMC&#xff08;HPC&#xff09;接口&#xff0c;1路PCIe x4主机接口、1个RJ45千兆以太网口、2个QSFP 40G光纤接口。板卡采用Xilinx的高性能UltraScale MPSOC系列FPGA作为实时处理器&#xff0c…

苹果手机充电充不进去什么原因?尝试这些方法拯救!

虽然苹果手机价格比较昂贵&#xff0c;但也抵挡不了大家对它的喜爱与追捧。无论是在国内还是国外&#xff0c;苹果手机都拥有着十分庞大的用户群体。 一些使用过苹果手机的朋友表示&#xff0c;苹果手机耗电快并且还出现过充不进电的情况。那么&#xff0c;苹果手机充电充不进…

【面试高高手】——Spring(12题)

文章目录 1.Spring是什么&#xff1f;2.为什么需要Spring?3.说下你对Spring的AOP、IOC的理解&#xff1f;4.基于java的AOP实现有哪些&#xff1f;5.AOP的原理&#xff1f;6.如何使用Java实现动态代理?7. Spring AOP和AspectJ AOP有什么区别&#xff1f;8.SpringAOP通知类型&a…

518抽奖软件,支持半透明框,让界面布局更美观规整

518抽奖软件简介 518抽奖软件&#xff0c;518我要发&#xff0c;超好用的年会抽奖软件&#xff0c;简约设计风格。 包含文字号码抽奖、照片抽奖两种模式&#xff0c;支持姓名抽奖、号码抽奖、数字抽奖、照片抽奖。(www.518cj.net) 半透明框的用途 把零散的界面元素统一放置在…

3秒钟解析超买超卖和Renko图表关系

不管是刚进入市场中的外汇新手&#xff0c;还是已经在外汇市场中赚的盆满钵满&#xff0c;只要还是外汇市场中的一份子&#xff0c;一定在不止一次听说过超买和超卖。今天FPmarkets澳福和各位投资者一起探讨超买超卖和Renko图表的关系。 超买在FPmarkets看来就是指大部分市场参…

【Git】Deepin提示git remote: HTTP Basic: Access denied 错误解决办法

git remote: HTTP Basic: Access denied 错误解决办法 1.提交代码的时候提示2. 原因3.解决方案 1.提交代码的时候提示 git remote: HTTP Basic: Access denied 错误解决办法 2. 原因 本地git配置的用户名、密码与gitlabs上注册的用户名、密码不一致。 3.解决方案 如果账号…

9+铜死亡+缺氧+分型+单细胞+实验生信思路

今天给同学们分享一篇铜死亡缺氧分型实验的生信文章“Unraveling Colorectal Cancer and Pan-cancer Immune Heterogeneity and Synthetic Therapy Response Using Cuproptosis and Hypoxia Regulators by Multi-omic Analysis and Experimental Validation”&#xff0c;这篇文…

vscode快捷生成html标签

vscode快捷生成代码片段 ul>li*2.silder会生成如下代码片段 再或者 ul>li*6.silder>p.silder$会生成如下代码片段 如果页面中涉及到上面这种情况的代码块的时候可以使用这种方法快捷生成对应的代码块

freeswitch-02

文章目录 1. 拨号计划1.1 定义1.2 XML Dialplan1.2.1 配置文件的结构1.2.2 默认配置文件简介 1.3 正则表达式1.4 通道变量1.5 工作机制1.6 常用的Dialplan App1.7 小结 2. SIP协议2.1 SIP协议基础2.2 HTTP协议和SIP协议2.3 3PCC 3. 媒体3.1 媒体处理3.1.1 音频编码3.1.2 透传、…

多路复用select epoll

目录 一、什么是多路复用&#xff1a; 二、select 1 基本原理 2 参数 3 使用例子 4 select的缺点 三、epoll 使用用例 一、什么是多路复用&#xff1a; 多路: 指的是多个socket网络连接;复用: 指的是复用一个线程、使用一个线程来检查多个文件描述符&#xff08;Socke…

OpenAI官方吴达恩《ChatGPT Prompt Engineering 提示词工程师》(2)如何迭代开发提示词

迭代/Iterative 在机器学习中&#xff0c;您经常有一个想法&#xff0c;然后实现它。编写代码&#xff0c;获取数据&#xff0c;训练模型&#xff0c;这就给您一个实验结果。然后您可以查看该输出&#xff0c;进行错误分析&#xff0c;找出哪些地方工作或不工作&#xff0c;然后…

瞬态执行攻击与防御的评估

作者&#xff1a;Claudio Canella, Jo Van Bulck, Michael Schwarz, Moritz Lipp, Benjamin von Berg, Philipp Ortner, Frank Piessens, Dmitry Evtyushkin, Daniel Gruss: 标题&#xff1a;A Systematic Evaluation of Transient Execution Attacks and Defenses. 发布&#…

写代码生成流程图

我们在写文档&#xff0c;博客的时候&#xff0c;一般都会使用markdown语法&#xff0c;最常见的就是一些github开源项目的README。有时候会去画一些流程图&#xff0c;例如使用process.on或者xmind等第三方网站&#xff0c;然后截图插入到文档中。 今天我们介绍一种使用代码直…

如何用手机给自己拍摄的视频静音?

我们在分享视频的时候常常会遇到这种情况&#xff0c;视频有杂音或音乐声太大&#xff0c;这个时候就需要用到视频静音这个功能了&#xff0c;将视频静音后&#xff0c;可以根据自己的需求重新配乐或配音&#xff0c;下面附上详细操作步骤&#xff0c;大家看好学好&#xff01;…

nginx配置密码访问

安装htpasswd 因为需要使用到htpasswd&#xff0c;htpasswd是Apache服务器中生成用户认证的一个工具&#xff0c;如果未安装&#xff0c;则使用如下命令安装htpasswd。 yum install -y httpd-tools设置用户名和密码 htpasswd 安装成功后&#xff0c;就可以设置用户名和密码&am…

搭建智能桥梁,Amazon CodeWhisperer助您轻松编程

零&#xff1a;前言 随着时间的推移&#xff0c;人工智能技术以惊人的速度向前发展&#xff0c;正掀起着全新的编程范式革命。不仅仅局限于代码生成&#xff0c;智能编程助手等创新应用也进一步提升了开发效率和代码质量&#xff0c;极大地推动着软件开发领域的快速繁荣。 当前…

火车头采集器python CHATGPT/AI改写插件使用教程!

大家好我是淘小白&#xff0c;关于火车头的AI改写插件的环境配置和使用教程&#xff0c;今天来给大家整理一下&#xff0c;请购买过的朋友&#xff0c;按照这个教程自行操作~ 1、规则&插件 这是我们拿到的演示规则和插件 2、配置环境 首先&#xff0c;要先安装Python&…

如何通过bat批处理实现快速生成文件目录,一键生成文件名和文件夹名目录

碰对了情人&#xff0c;相思一辈子。 具体方法步骤&#xff1a; 一、创建一个执行bat文件&#xff08;使用记事本即可&#xff09;&#xff1b; 1、新建一个txt文本空白记事本文件 2、复制以下内容进记事本内 dir/a/s/b>LIST.TXT &#xff08;其中LIST.TXT文件名是提取后将…

“益路同行”专访第0002期—张掖市汇仁爱心公益协会创始人谢建英

中国善网在本届&#xff08;第十届&#xff09;慈展会上特别推出了《益路同行》采访栏目&#xff0c;《益路同行》栏目旨在寻觅公益之路上同行者的故事&#xff0c;挖掘公益更深层次的内涵&#xff0c;探索新时代公益发展道路。希望公益企业、人物、故事被更多人看到&#xff0…

RFID服装工位管理提高生产管理效率

RFID服装工位管理是一种通过使用RFID电子标签来提高制造企业生产管理效率的方法&#xff0c;在传统的制造企业中&#xff0c;生产流程通常以单件为主&#xff0c;当生产环节繁复且工序众多时&#xff0c;容易出现各种问题。特别是在劳动密集型行业&#xff0c;如服装制造业&…