【算法学习】排序算法汇总
1、
冒泡排序的英文Bubble Sort,是一种最基础的交换排序。之所以叫做冒泡排序,因为每一个元素都可以像小气泡一样,根据自身大小一点一点向
冒泡排序的原理:
冒泡轮数:
相邻数比较次数:每一轮冒泡都需从第 1 位开始进行相邻两个数的比较,将较大的数放后面(若前面的数比后面的数大,则交换位置)。如第一轮冒泡需要从第 1 个数开始,对共 n 个数进行相邻数比较,共比较 n-1 次。第一轮冒泡的比较结束后,开始第二轮冒泡,即对剩余 n-1 个数进行相邻两个数的比较,共比较 n-2 次。以此类推,直至完成第 n-1 轮冒泡 (比较1次)。
2、冒泡排序演示
3、代码实现(C++)
//对n个数进行升序排列
void BubbleSort(int arr[], int n)
{
int i=0;
int j=0;
int tmp=0;
for(i=0;i<n-1;i++)
{
for(j=0;j<n-i-1;j++)
{
if(arr[j]>arr[j+1])
{
tmp = arr[j];
arr[j] = arr[j+1];
arr[j+1] = tmp;
}
}
}
return;
}
/*
外循环:for(i=0;i<n-1;i++)
表示进行冒泡的轮数,n个数,共需要n-1轮冒泡。
i=0,1,...,n-2分别表示第1,2,...,n-1轮冒泡;
内循环:for(j=0;j<n-i-1;j++)
表示某轮冒泡所需要的相邻数比较次数。
第1轮(i=0)冒泡,开始共n个数,需要相邻数比较n-1次;
第2轮(i=1),剩下n-1个数,需要比较n-2次;
...
第n-2轮(i=n-3),剩下3个数,需要比较2次;
第n-1轮(i=n-2),剩下2个数,需要比较1次;
其中,内循环进行相邻数比较,若前一个数比后一个数大,则交换两者位置,因此保证了每轮冒泡能将该轮中最大的数排在最后面。
根据冒泡轮数对应i值与比较次数的关系可以得到,某轮的比较次数 = 该轮剩余的数的个数-1 = n-i-1
内循环(j=0;j<n-i-1;j++),即j取0,1,...,n-i-2,表示相邻比较数的下标,即arr[0],arr[1],...,arr[n-i-2]分别与后一个数比一次大小,共比较n-i-1次
*/
循环的另一种写法
//外循环:for(i=n-1;i>0;i--)
//内循环:for(j=0,j<i,j++)
//外循环表示冒泡轮数:i=n-1,...,1分别表示第1,2,...,n-1轮冒泡
//内循环表示某轮相邻数比较次数,比较次数分别为:n-1,...,1,对应相邻数比较数的下标:j=0,...,n-2;...;j=0,1;j=0
完整 c++ 代码
冒泡排序的改进
void bubbleSort(int arr[], int n)
{
bool flag = true; //布尔类型初始化
for(i=0;i<n-1;i++)
{
flag = true; //每次先重置为true
for(j=0;j<n-i-1;j++)
{
if(arr[j]>arr[j+1])
{
tmp = arr[j];
arr[j] = arr[j+1];
arr[j+1] = tmp;
flag = false; //如果该轮冒泡的相邻数比较中,只要有相邻数发生了交换,标志位就置为false,说明数列无序
}
}
//如果该轮冒泡,没有相邻数发生交换,标志位为true,则说明该轮冒泡中的所有数已经有序,故退出整个循环
if (flag)
break;
}
}
4、性能分析
-
是否是稳定的
-
冒泡排序就是把小的元素往前调或者把大的元素往后调。交换发生在两个相邻的元素之间的比较之后,如果两个相等的元素相邻,那么根据我们的算法。它们之间没有发生交换;如果两个相等的元素没有相邻,那么即使通过前面的两两交换把两个相邻起来,这时候也不会交换,所以相同元素的前后顺序并没有改变,所以冒泡排序是稳定的。
-
-
是否是原地排序算法
-
因为冒泡排序过程中并不需要开辟新的数组空间,只需要常数个变量用于标记或者交换,所以冒泡排序是原地排序算法。
-
-
空间复杂度:
-
空间复杂度:因为冒泡排序都是在原数组上进行操作的,并没有额外开辟新的数组空间(冒泡排序的辅助变量空间仅仅是一个临时变量,并且不会随着排序规模的扩大而进行改变。),是一种原地排序,
-
-
有序度
-
冒泡排序包含两个操作原子,比较和交换(比较次数总不小于交换次数);
-
每交换1次,逆序度减 1,有序度加 1。不管算法怎么改进,交换次数总是确定的,即为逆序度;
-
最好情况,有序度为 n*(n-1)/2;最坏情况,有序度为0;
-
-
时间复杂度:外循环和内循环以及判比较大小和交换元素的时间开销
排序过程每交换一次,有序度加1,逆序度减1,比如刚开始原数组的有序度为 m,那么逆序度为 n*(n-1)/2-m,也就是最终有序需要交换的次数。如果有序度 m=0,那就是最坏的情况,此时逆序度等于满有序度,即 n*(n-1)/2;如果有序度 m 等于满有序度,即 n*(n-1)/2 ,那这就是最好的情况,逆序度为0,根本不需要交换
-
最好:O(n) (考虑改进的冒泡算法) 完全有序:m 等于满有序度,即 n*(n-1)/2,逆序度为0,不需要交换。只需要进行第1轮冒泡,n-1 次相邻数比较即可(最后跳出循环)
-
最坏:O(n²) 完全逆序:m 等于0,即有序度为0,逆序度为 n*(n-1)/2,需要进行共 n-1 轮冒泡,对应相邻数比较次数为 n-1,...,2,1,共比较 n*(n-1)/2次,交换次数也为 n*(n-1)/2次;
-
平均:O(n²): 理解1:平均情况下,需要 n*(n-1)/4 次交换操作,比较操作 n*(n-1)/2 次,比较操作肯定比交换操作多,复杂度上限为 O(n²) 。所以平均情况下的时间复杂度就是 O(n²)。
-
5、冒泡排序总结
-
冒泡排序只会操作相邻的两个数据;
-
冒泡排序会将相邻的两个数据两两比较,如果不符合要求则进行位置交换(即每次比较时都可能发生元素交换);
-
优化:当某次遍历没有数据交换的时候,则停止排序(跳出循环);
-
是稳定排序;
-
是原地排序;
-
时间复杂度最好为 O(n) ,最坏为 O(n²),平均时间复杂度为 O(n²);
-
因为每轮冒泡都可能要进行数据比较、交换,效率是排序算法中最低的。
选择排序
1、
简单选择排序(Selection Sort)是一种简单直观的排序算法。
选择排序的原理: 选择排序在开始的时候,先扫描整个数组,以找到数组 n 个元素中最小元素,然后将这个元素与第 1 个元素进行交换。这样最小元素就放到它的最终位置上。然后,从第 2 个元素开始扫描,找到 n-1 个元素中的最小元素,然后再与第二个元素进行交换。以此类推,直到第 n-1 个元素(如果前 n-1 个元素都已在最终位置,则最后一个元素也将在最终位置上)。
选择排序的基本思想是: 每一轮在 n − i + 1 ( i = 1 , 2 , . . . , n − 1 ) 个元素中选择最小的元素,并将其作为有序序列中第 i 个元素。
2、选择排序演示
对数组中 n=6 个元素进行选择排序(以升序为例),如下图所示为选择排序原理示意图
① 第1轮,选出 最初的 n=6 个元素中最小的元素(共比较5次,得到最小值下标),将该元素与第1个元素交换; ② 第2轮,选出剩余 n-1=5 元素中最小的元素(共比较4次,得到最小值下标),将该元素与第2个元素交换; ③ 第3轮,选出剩余 n-2=4 元素中最小的元素(共比较3次,得到最小值下标),将该元素与第3个元素交换; ④ 第4轮,选出剩余 n-3=3 元素中最小的元素(共比较2次,得到最小值下标),将该元素与第4个元素交换; ⑤ 第5轮,选出剩余 n-4=2 元素中最小的元素(共比较1次,得到最小值下标),将该元素与第5个元素交换; ⑥ 选出剩余 n-5=1 元素中最小的元素,将该元素与第6个元素交换;省略,⑤中剩余的元素即为最大元素,已排好故不需要操作)
选择最小元素以及元素交换的操作,共计 n-1=5 轮
下图为选择排序的动态演示图
由上图可知,选择排序的关键步骤包含:选择出最小元素+交换到第一个位置
将数据及分为 已排序(有序区间) 和 待排序(无序区间) 两个区间
(1) 首先初始化整个数组为一个无序区间
(2) 先从这些元素中选出最小的元素,将其和第一个元素进行交换,这样第一个元素就是最小的,第一个元素位置就变成有序区间了
(3) 同理,在剩下的无序区间选择最小的元素,将最小元素与无序区间的第一个元素进行交换,交换后原来无序区间的第一个元素就变为有序区间的最后一个元素了,有序区间递增1;
(4) 以此类推,直至从最后两个元素中选出最小元素,放到第 n-1 个元素位置上,此时剩下最后一个元素为无序区间的唯一元素,刚好该元素变成有序元素最后一个元素,此时整个数组变为有序区间
像上面这样,每轮选出最小元素的直接交换到左侧的思路,就是选择排序的思路。这种排序的最大优势就是省去了多余的元素交换。
但是如何选出最小的一个元素呢?(通过遍历比较每轮的元素,得到最小值得下标) 先随便选一个元素假设它为最小的元素(初始化为无序区间第一个元素),然后让这个元素与无序区间中的每一个元素进行比较,如果遇到比自己小的元素,那更新最小值下标,直到把无序区间遍历完,那最后的最小值就是这个无序区间的最小值
得到最后的最小值下标后,就可以进行元素位置的交换,最后得到排列好的数组
3、代码实现(c++)
算法思想: 给定一个含 n 个元素的数组 arr[n],第一次从arr[0]~arr[n-1]中选出最小值,与arr[0]交换,第二次从arr[1]~arr[n-1]中选取最小值,与arr[1]交换,···,第i次从arr[i-1]~arr[n-1]中选取最小值与arr[i-1]交换,···,第n-1次,从arr[n-2]和arr[n-1]中选取最小值与arr[n-1]交换,总共 n-1 轮,得到升序序列。
代码
void SelectionSort(int arr[], int n)
{
int i, j=0;
int min_index=0;
int tmp;
for(i=0; i<n-1; i++)
{
min_index = i;
for(j=i+1; j<n ; j++)
{
if(a[j] < a[min_index])
min_index = j;
}
if (min_index != i)
{
tmp = a[i];
a[i] = a[min_index];
a[min_index] = tmp;
}
}
}
4、性能分析
给定一个含 n 个元素的数组 arr[n]:
第1轮(i=0):从arr[0]~arr[n-1]中选出最小值,与arr[0]交换:共比较n-1次,最多交换1次; 第2轮(i=1),从arr[1]~arr[n-1]中选出最小值,与arr[1]交换:共比较n-2次,最多交换1次; ... 第m轮(i=m-1),从arr[m-1]~arr[n-1]中选取最小值,与arr[m-1]交换;共比较n-m次,最多交换1次; ... 第n-1轮(i=n-2),从arr[n-2]和arr[n-1]中选取最小值,与arr[n-1]交换,共比较1次,最多交换1次;
-
选择排序是原地
-
选择排序不需要额外的存储空间,空间复杂度为 O(1),所以是原地排序算法。
-
-
选择排序是稳定排序算法吗?
-
不稳定,比如 {5,8,5,2,9} 这个数组,使用选择排序算法第一轮找到的最小元素就是2,与第一个位置的元素5交换位置,那第一个5和中间的5的顺序就变了,所以就不稳定了。所以在稳定性上选择排序算法比起
-
-
选择排序算法的
-
最好、最差、平均时间复杂度都是 O(n^2)。
-
插入排序
1、插入排序简介
插入排序的原理: 一般也被称为直接插入排序。对于少量元素的排序,它是一个有效的算法 。插入排序是一种最简单的排序方法,它的基本思想是将一个记录插入到已经排好序的有序表中,从而得到一个新的、记录数增 1 的有序表。在其实现过程使用双层循环,外层循环对除了第一个元素之外的所有元素,内层循环对当前元素前面有序表进行待插入位置查找,并进行移动 。
基本思想: 将未排序的元素一个一个地插入到有序的集合中,插入时把所有有序集合从后向前扫一遍,找到合适的位置插入。
2、插入排序演示
算法实现:直接插入排序是将无序序列中的数据插入到有序的序列中,在遍历无序序列时,首先拿无序序列中的首元素去与有序序列中的每一个元素比较并插入到合适的位置,一直到无序序列中的所有元素插完为止。对于一个无序序列arr{4, 6, 8, 5, 9}来说,我们首先先确定首元素4是有序的,然后在无序序列中向右遍历,6大于4则它插入到4的后面,再继续遍历到8,8大于6则插入到6的后面,这样继续直到得到有序序列{4, 5, 6, 9, 7}。
实现插入的一般方式(插入= 比较+交换)
如下图所示,以下为对数组 {5, 8, 6, 3, 9, 2,1,7}的 第三轮排序
让元素3和有序区的元素依次比较。
3<8,所以把元素3和元素8进行交换:
3<6,所以把元素3和元素6进行交换:
3<5,所以把元素3和元素5进行交换:
此时有序区的元素增加到四个:
直接插入排序的不同情况(插入=比较+交换)
最好情况,假设有序 数组 arr={1, 2, 3, 4, 5} ,n=5 起始:1(有序),2,3,4,5(无序); 第1轮排序后:1,2(有序),3,4,5(无序);比较1次,不用交换 第2轮排序后:1,2,3(有序),4,5(无序);比较1次,不用交换 第3轮排序后:1,2,3,4(有序),5(无序);比较1次,不用交换 第4轮排序后:1,2,3,4,5(有序) ;比较1次,不用交换 共需要 n-1=4轮,共比较n-1次,交换0次
最坏情况,假设逆序数组 arr={5, 4, 3, 2, 1} ,n=5 起始:5(有序),4,3,2,1(无序); 第1轮排序后:4,5(有序),3,2,1(无序);比较1次,交换1次 第2轮排序后:3,4,5(有序),2,1(无序);比较2次,交换2次 第3轮排序后:2,3,4,5(有序),1(无序);比较3次,交换3次 第4轮排序后:1,2,3,4,5(有序) ;比较4次,交换4次 共需要 n-1=4轮,,共比较1+2+3+4(1+...+(n-1))次,交换1+2+3+4(1+...+(n-1))次
一般情况,对包含n=5个元素的数组arr{4, 6, 8, 5, 9}排序如下 起始:4(有序),6,8,5,9(无序); 第1轮排序后:4,6(有序),8,5,9(无序);比较1次,不用交换 第2轮排序后:4,6,8(有序),5,9(无序);比较1次,不用交换 第3轮排序后:4,5,6,8(有序),9(无序);比较3次,交换2次 第4轮排序后:4,5,6,8,9(有序) ;比较1次,不用交换 共需要 n-1=4轮,比较6次,交换2次 (比较数总不小于交换数)
实现插入的另一种改进方式(插入 = 比较 + (复制+赋值)):
当我们把每一个新元素插入到有序区间的时候,并不需要老老实实进行元素的两两交换,比如在第三轮的时候:
原本是让元素3逐个与有序区的元素进行比较和交换,与8交换、与6交换、与5交换,最终交换到有序区的第一个位置。
但是我们并不需要真的进行完整交换,只需把元素3暂存起来,再把有序区的元素从左向右逐一复制
第一步,暂存元素3:
第二步,和前一个元素比较,由于3<8,复制元素8到它下一个位置:
第三步,和前一个元素比较,由于3<6,复制元素6到它下一个位置:
第四步,和前一个元素比较,由于3<5,复制元素5到它下一个位置:
第五步,也是最后一步,把暂存的元素3赋值到数组的首位:
显然,这样的优化方法减少了许多无谓的交换。
3、代码实现(c++)
// 插入排序
void InsertSort(int* a, int n)
{
//因为tmp是当前下标的后一个元素比较,所以最后一个元素的位置是n-1
for (int i = 0; i < n-1; i++)
{
int n = i;
int tmp = a[1 + n];
//假设每次都要比到最后
while (n >= 0)
{
//如果小于,就让这个位置元素被替代,然后再跟前一个比较
if (tmp < a[n])
{
a[n + 1] = a[n];
n -= 1;
}
//如果不小,那就退出
else
{
break;
}
}
//它停下的位置就是前面已经没有比它小的元素了,但是因为上面替换之前-1,所以这里要+1
a[n + 1] = tmp;
}
}
4、性能分析
-
时间复杂度
-
最好情况是数组已经有序,进行 n-1 轮,每轮进行1次比较,不交换。共进行 n-1 次比较,0次交换,时间复杂度为 O(n)
-
最坏情况是数组逆序排序,进行 n-1 轮,共比较 1+...+(n-1) 次,即 n*(n-1)/2 次,以及 n*(n-1)/2 次交换,时间复杂度为 O(n^2)。
-
平均来说插入排序算法的复杂度为 O(n^2)
-
-
空间复杂度
-
空间复杂度上,直接插入法是原地排序,空间复杂度为 O(1)
-
-
稳定性
-
希尔排序
1、希尔排序简介
希尔排序的实质就是分组插入排序。该方法因 D.L.Shell 于 1959 年提出而得名。
直接插入排序的问题 逆序有序的数组排序时,时间复杂度为 O(n^2),此时效率最低; 顺序有序的数组排序时,时间复杂度为 O(n),此时效率最高; 我们发现,当被排序的对象越接近有序时,插入排序的效率越高,那我们是否有办法将数组变成接近有序后再用插入排序,此时希尔大佬就发现了这个排序算法,并命名为希尔排序
具体举例: 例如,当长度为100的数组,前面有序区域的数组长度为80,此时我们用第81个数去跟前面有序区域的所有元素比较大小,但恰巧第81个数又是这100个数里最小的,它本应该在索引为1的位置,如图所示
本例中第81个数据的值为1,那么前面有序区域里的80个元素都要往后移动一个位置,这种情况就非常影响排序性能。 因此,我们就要想办法尽可能早点让小的值靠前,让大的值靠后,这样就能避免上述情况了,这就是希尔排序要解决的问题。
希尔排序思路: 希尔排序是对插入排序的优化,基本思路是先选定一个整数作为增量,把待排序文件中的所有数据分组,以每个距离的等差数列为一组,对每一组进行排序,然后将增量缩小,继续分组排序,重复上述动作,直到增量缩小为1时,排序完正好有序。
希尔排序原理是每一对分组进行排序后,整个数据就会更接近有序,当增量缩小为1时,就是插入排序,但是现在的数组非常接近有序,移动的数据很少,所以效率非常高,所以希尔排序又叫缩小增量排序。
每次排序让数组接近有序的过程叫做预排序,最后一次插入是直接插入排序
2、希尔排序演示
步骤:
-
先选定一个小于N的整数gap (gap一般取N/2..)作为第一增量,然后将所有距离为gap的元素分在同一组,并对每一组的元素进行直接插入排序。然后再取一个比第一增量小的整数作为第二增量 (一般为gap/2向下取整),重复上述操作…
-
当增量的大小减到1时,就相当于整个序列被分到一组,进行一次直接插入排序,排序完成。
具体过程如下:
为了方便大家理解,我用一个例子来展示一个完整的希尔排序过程,首先数据的初始状态如图所示,这里为了更好地体现希尔排序的优点,我特地把值较大的元素放到了靠左的位置,把值较小的元素放到了靠右的位置
该数组长度为8,因此我们设置初始的增量为 8 / 2 = 4,那么该数组的分组情况如下图所示:
图中颜色相同的元素为一组,每组内的各个元素间隔都为4,现在对每个组内进行从小到大排序,排序结果如下图所示:
此时我们将增量缩小一半,即 4 / 2 = 2,同样的,现在将所有元素重新组合,把所有间隔为2的元素视作一组,分组结果如下图所示:
图中颜色相同的元素为一组,每组内的各个元素间隔都为2,现在对每个组内进行从小到大排序,排序结果如下图所示:
我们继续将增量缩小一半,即 2 / 2 = 1,同样的,现在将所有元素重新组合,把所有间隔为1的元素视作一组,此时所有的元素都为同一组了,就相当于对所有的数据进行普通的插入排序,我们可以看到,对比最开始的数据,总得来说,小的值都比较靠左了,大的值也都比较靠右了,这样排序起来效率就很高了。结果如下图所示:
接下来用一个动图,演示一下完整的希尔排序全过程
3、代码实现(c++)
-
增量选择:gap = gap / 2
//希尔排序
void ShellSort(int* arr, int n)
{
int gap = n;
while (gap>1)
{
//每次对gap折半操作
gap = gap / 2;
//单趟排序
for (int i = 0; i < n - gap; ++i)
{
int end = i;
int tem = arr[end + gap];
while (end >= 0)
{
if (tem < arr[end])
{
arr[end + gap] = arr[end];
end -= gap;
}
else
{
break;
}
}
arr[end + gap] = tem;
}
}
}
-
选择:gap = gap / 3 + 1
void ShellSort(int* arr, int size)
{
int gap = size;
while (gap > 1)
{
gap = gap / 3 + 1; //调整希尔增量
int i = 0;
for (i = 0; i < size - gap; i++) //从0遍历到size-gap-1
{
int end = i;
int temp = arr[end + gap];
while (end >= 0)
{
if (arr[end] > temp)
{
arr[end + gap] = arr[end];
end -= gap;
}
else
{
break;
}
}
arr[end + gap] = temp; //以 end+gap 作为插入位置
}
}
}
void shellsSort(int arr[], int len) {
int d, i, j, temp;
for (d = len / 2; d >= 1; d /= 2) {
// 这里类似直接插入排序
for (i = d; i < len; i++) {
temp = arr[i];
for (j = i - d; j >= 0 && temp < arr[j]; j -= d) {
arr[j + d] = arr[j];
}
arr[j + d] = temp;
}
}
}
4、性能分析
-
稳定性 希尔排序是直接插入排序的优化版,在排序过程中,会根据间隔将一个序列划分为不同的逻辑分组,在不同的逻辑分组中,有可能将相同元素的相对位置改变。如 [2,2,4,1],按间隔为2,降序排序,前两个元素的相对位置就会改变。因此,希尔排序是不稳定的排序方式。
-
时间复杂度
-
希尔排序在最坏情况下的时间复杂度为 O(n^2),最好情况下的时间复杂度为O(n), 当n在某个范围内,平均情况下的时间复杂度为 O(n^{1.3})。
-
希尔排序的时间复杂度跟增量也有关系,我们上面是通过数组长度一直取一半获取的增量,其实还有一些别的增量规则,可以使得希尔排序的效率更高,例如Hibbard增量序列、Sedgewick增量序列等;
-
希尔排序的核心在于间隔序列的设定。既可以提前设定好间隔序列,也可以动态的定义间隔序列。 希尔排序的时间复杂度与增量(即,步长gap)的选取有关。例如,当增量为1时,希尔排序退化成了直接插入排序,此时的时间复杂度为 O(n^2),而Hibbard增量的希尔排序的时间复杂度为 O(n^{3/2})
-
-
空间复杂度
-
希尔排序的空间复杂度为 O(1),因为希尔排序并没有使用第二个数组,它仅仅只是就着他自己的原来的数组进行排序,使用存储空间为常数个空间,因而其空间复杂度为 O(1)。
-
-
希尔排序仅适用于线性表为顺序存储情况,且不适合规模大的情况
-
希尔排序相对于直接插入排序,在较大的数组上更能发挥优势,因为步子迈的更大,减少插入排序的移动次数更多
-
如何选择希尔增量
-
希尔排序的分析是一个复杂的问题,它的时间是一个关于增量序列的函数,这涉及到一些数学上未能攻克的难题,所以目前为止对于希尔增量到底怎么取也没有一个最优的值,但是经过大量研究已经有一些局部的结论,在这里并不展开叙述。
-
最初希尔提出的增量是 gap = n / 2,每一次排序完让增量减少一半gap = gap / 2,直到gap = 1时排序变成了直接插入排序。直到后来Knuth提出的gap = [gap / 3] + 1,每次排序让增量成为原来的三分之一,加一是防止gap <= 3时gap = gap / 3 = 0的发生,导致希尔增量最后不为1,无法完成插入排序。到目前为止业内对于两个大佬的方法依然是看法不一,都没有比出个上下
-
快速排序
1、快速排序简介
快速排序(Quick Sort)是对冒泡排序的一种改进。它的基本思想是:通过一趟排序将待排序记录分割成独立的两部分,其中一部分记录的关键字均比另一部分记录的关键字小,则可以分别对着两部分记录继续进行排序,以达到整个序列有序。
一趟快速排序的具体过程可描述为:从待排序列中任意选取一个记录(通常选取第一个记录)作为基准值,然后将记录中关键字比它小的记录都安置在它的位置之前,将记录中关键字比它大的记录都安置在它的位置之后。这样,以该基准值为分界线,将待排序列分成的两个子序列。
一趟快速排序的具体做法为:设置两个指针low和high分别指向待排序列的开始和结尾,记录下基准值baseval(待排序列的第一个记录),然后先从high所指的位置向前搜索直到找到一个小于baseval的记录并互相交换,接着从low所指向的位置向后搜索直到找到一个大于baseval的记录并互相交换,重复这两个步骤直到low=high为止。
2、快速排序演示
假设待排序的序列为 arr[0]~arr[n-1],首先任意选取一个元素(通常选取第一个)为基准元素(pivot),或者叫枢轴,然后按照下述原则重新排列其余记录:将所有比它小的元素都安置在它的位置之前,将所有比它大的元素都安置在它的位置之后。最后以该基准元素所落的位置 i 作为分界线,可以将序列分割成 arr[0]~arr[i-1]、arr[i+1]~arr[n-1] 两个子序列。这个过程叫做一趟快速排序(或一次划分)。
快速排序法的排序过程示意图如下所示:
3、代码实现(c++)
4、性能分析
-
时间复杂度
-
空间复杂度
算法效率:
-
稳定性
快速排序是一种不稳定的算法
堆排序
1、堆排序简介
-
什么是堆,大根堆、小根堆?
-
堆的逻辑结构是个特殊的完全二叉树,数组的存储可以通过层序遍历对应成一个完全二叉树(数组和完全二叉树是一一对应的)
-
如果完全二叉树中,每棵树根节点大于子树所有节点,则该完全二叉树是大根堆
-
如果完全二叉树中,每棵树根节点小于子树所有节点,则该完全二叉树是小根堆
-
-
如何建立大根堆、小根堆?
-
初始建堆:调整数组,使得其对应完全二叉树为大根堆、小根堆的过程(HeapInsert)
-
再次堆化:根节点改变,重新形成大大根堆、小根堆的过程(Heapify)
-
2、堆排序演示
堆排序(HeapSort) = 先将整个数组变成大根堆(heapInsert),把最大值和堆的最后一个位置做交换,heapsize--,剩下的进行heapify,继续把最大值和堆的最后一个位置做交换,heapsize--
3、代码实现(c++)
HeapInsert和Heapify的实现
// 某个数现在处在index位置,往上继续移动 数组形成大根堆
void HeapInsert(vector<int>& arr, int index){
while (arr[index] > arr[(index - 1) / 2]){ //当前的数大于父位置的数
swap(arr, index, (index - 1) / 2);
index = (index - 1) / 2;
}
}
//某个数在index位置,能否往下移动
void Heapify(vector<int>& arr, int index, int heapSize){
int left = index * 2 + 1; //左孩子的下标
while (left < heapSize){
//两个孩子中,谁的值大,把下标给largest
int largest = left + 1 < heapSize && arr[left + 1] > arr[left] ? left + 1 : left;
//父和较大的孩子之间,谁的值大,把下标给largest
largest = arr[largest] > arr[index] ? largest : index;
if(largest == index){
break;
}
swap(arr, largest, index);
index = largest;
left = index * 2 + 1;
}
}
堆排序代码
void heapSort(vector<int>& arr){
if(arr.size() < 2){
return;
}
// 用户一次只给一个数: O(NlogN)
for (int i = 0; i < arr.size(); i++){ //O(N)
heapInsert(arr, i); //O(logN)
}
// 用户一次给出所有的数: O(N)
// for (int i = arr.size() - 1; i >= 0; i--){
// heapify(arr, i, arr.size());
// }
int heapSize = arr.size();
swap(arr, 0, --heapSize); //0位置的数与堆上最后一个数做交换
while (heapSize > 0){ //O(N)
heapify(arr, 0, heapSize); //O(logN)
swap(arr, 0, --heapSize); //O(1)
}
}
HeapInsert的改进
当用户一次性给出很多数据时,首先构建完全二叉树,然后从倒数第二层开始向下Heapify,一直到根结点
4、性能分析
-
时间复杂度: O(nlogn)
分成两部分:
-
添加新元素,形成大根堆(HeapInsert) ,向上沿父节点路径比较,比较次数为完全二叉树高度 h = O(logn)
-
将根节点与最后的节点交换,heapsize-- , 再将heapsize个节点的树重新调整为大根堆(Heapify),再将根节点与最后的节点交换...直至heapsize = 0,得到最后的排序好的完全二叉树,比较次数也为O(logn)
-
-
空间复杂度O(1)
归并排序
1、归并排序
将一个大的无序数组有序,可以把大的数组分成两个,然后对这两个数组分别进行排序,之后在把这两个数组合并成一个有序的数组
2、归并排序演示
3、代码实现(c++)
vector<int> mergeSort(vector<int>& nums) {
process(nums, 0, nums.size() - 1);
return nums;
}
void process(vector<int>& nums, int L, int R){ //让数组有序
if(L == R){
return;
}
int mid = L + ((R - L) >> 1);
process(nums, L, mid);
process(nums, mid + 1, R);
merge(nums, L, mid, R); // merge归并
}
void merge(vector<int>& nums, int L, int M, int R){
vector<int> help; //!额外空间复杂度
int p1 = L;
int p2 = M + 1;
while(p1 <= M && p2 <= R){ //左右部分都不越界
help.push_back(nums[p1] <= nums[p2] ? nums[p1++] : nums[p2++]);
}
while(p1 <= M){ //右部分先越界,只可能发生一种
help.push_back(nums[p1++]);
}
while(p2 <= R){ //左部分先越界
help.push_back(nums[p2++]);
}
for(int i = 0; i < help.size(); i++){
nums[L + i] = help[i];
}
}
4、性能分析
-
时间复杂度:O(nlogn)
T(n) = 2 * T(N/2) + Merge函数时间复杂度
Merge函数时间复杂度:左右部分比较放入辅助数组中O(n) + 复制到原数组O(n)
因此T(n) = 2 * T(N/2) + O(n)
根据master公式得归并排序得时间复杂度为O(nlogn)
-
空间复杂度:O(n)
由于使用了其他数组作为额外辅助空间,空间复杂度为O(n)
-
稳定性
左右部分归并过程,相等的元素总是按照原先后顺序排列在一起,因此是稳定的
基数排序
1、基数排序
基数排序(Radix Sort)是一种非比较排序算法,它的基本思想是将待排序的元素按照每位取值范围划分为多个桶,然后对每个桶中的元素进行多躺桶排序,最后将所有桶中的元素按照顺序合并起来得到排序结果。
基数排序的时间复杂度为O(d*(n+r)),其中n是待排序元素的个数,r是每个元素的取值范围(与排序数的进制有关,决定了每趟初始化的桶的个数),d是元素的位数(按最长的位数算),决定了桶排序分配和收集的趟数。由于d通常比较小,因此基数排序可以在O(n)的时间内完成排序。
基数排序的空间复杂度取决于创建桶数组和输出数组的空间开销,通常情况下是O(n+r)。若每轮桶排序后放回原数组,则不需要创建输出数组,只需要创建桶数组,空间复杂度为O(r)。
2、基数排序演示
依次从低位到高位依次分配、收集
3、代码
4、性能分析
基数排序适用于排序个数比较多(n较大)元素取值范围较小的情况(d、r均较小) 如果取值范围非常大,则需要使用更高效的排序算法。
计数排序
1、计数排序
计数排序(Counting sort)是一种基于非比较的排序算法,其核心在于将输入的数据值转化为键存储在额外开辟的计数数组空间中以达到排序的效果
2、算法原理
给定一组取值范围为0到9的无序序列:1、7、4、9、0、5、2、4、7、3、4,建立一个长度为10的计数数组,值初始化为0
遍历无序序列,将每个序列元素值对应的计数数组下标的元素加1
如:第一个序列元素为1,则计数数组中下标为1的元素 加1
第二个序列元素为7,计数数组中下标为7的元素加1
继续遍历序列,当序列遍历完毕,计数数组的最终状态如下:
遍历计数数组,输出计数数组下标值,元素的值是多少,就输出几次
输出结果如下:0、1、2、3、4、4、4、5、7、7、9
此时,元素已是有序的了
算法优化(节省额外空间,并将计数算法变为稳定)
在前面的例子中,我们以序列的最大值来确定计数数组长度,假设有一个序列83、80、88、90、88、86,那么我们创建一个长度为91的计数数组,计数数组的0到79位都浪费了
我们可以创建一个长度为90-80+1=11(最大值-最小值+1)的计数数组,计数数组的偏移量为序列的最小值80
如图:计数数组下标0对应序列80,下标1对应序列81,以此类推
此外,我们前面的例子中,我们在新建的计数数组中记录序列中每个元素的数量,如果序列有相同的元素,则在输出时,无法保证元素原来的排序,是一种不稳定的排序算法,可通过优化,将其改为稳定排序算法
以序列83、80、88、90、88、86为例,首先填充计数数组
将计数数组从第二个元素开始,每个元素都是自己加上前面一个元素的和,此时,计数数组的值表示的是元素在序列中的排序
接下来我们创建输出数组,长度与待排序序列一致,从后往前遍历待排序序列
首先,遍历最后一个元素86,我们在计数数组中找到86对应的值为3,则在输出数组的第3位(下标为2)填入86,计数数组86对应的值减1,既当前的86排序是3,下次遇到86则排序是2
接着,遍历下一个元素88,在计数数组中找到88对应的值为5,在输出数组的第5位(下标为4)填入88,计数数组88对应的值减1
然后,遍历下一个元素90,在计数数组中找到90对应的值为6,在输出数组的第6位(下标为5)填入90,计数数组90对应的值减1
继续遍历下一个元素88,在计数数组中找到88对应的值为4,在输出数组的第4位(下标为3)填入88,计数数组88对应的值减1
以此类推,将所有元素遍历完,填入输出数组
原序列第3位、第5位均为88,从上面的步骤我们可以看到,在第二步,第5位的88填入输出数组的第5位,在第四步,第3位的88填入输出数组的第4位,没有改变原序列相同元素的顺序
3、代码
未优化代码
void countingSort(int arr[], int n, int max) {
int count[max + 1] = {0}; // 初始化计数数组
// 计算每个元素的出现次数
for (int i = 0; i < n; i++) {
count[arr[i]]++;
}
// 计算每个元素在排序数组中的位置
for (int i = 1; i <= max; i++) {
count[i] += count[i - 1];
}
// 构造排序数组
int output[n];
for (int i = n - 1; i >= 0; i--) {
output[count[arr[i]] - 1] = arr[i];
count[arr[i]]--;
}
// 将排序数组复制回原数组
for (int i = 0; i < n; i++) {
arr[i] = output[i];
}
}
优化后的代码