这篇博客把剩下的三个排序写完,写快速排序、堆排序和归并排序,并且总结一下:
我们先放入一个测试数组:
int[] array = new int[11] { 3, 7, 4, 88, 13, 2, 23, 16, 1, 55, 6 };
快速排序
快速排序是对冒泡排序的一种改进,它与冒泡不同的是比较是从两侧向中间进行的。
它在排序中不断根据两边的比较来改变数组的序列顺序。通过一个轴值划分出两个区域。再对这两个区域进行同样的划分,最终划分出一个有序的数组。
一般来说,我们选择第一个关键码作为轴值。
先上代码:
static void QuickSort(int[] array, int Low, int High)
{
if (Low > High)
{
return;
}
int l = Low;
int r = High;
int temp;
while (l < r)
{
if (array[r] == array[l])
{
r--;
}
while(array[l]<array[r])
{
r--;
}
temp = array[l];
array[l] = array[r];
array[r] = temp;
while (array[l] < array[r])
{
l++;
}
temp = array[l];
array[l] = array[r];
array[r] = temp;
}
QuickSort(array, Low, l-1);
QuickSort(array, l + 1, High);
}
我们实际上第一次传入快排代码的两边边界是:0和10。对于一个数组,第一趟快排的结果是这样生成的:
我们初始的下标索引是0和10,到数组中是3和6。有l=0,r=10。
此时,我们进行比对,即执行array[l]<array[r],我们看到,3小于6,那么让我们右边界r减一,看到下一个
同样的,55小于3,所以我们的r继续减一,我们可以看到:
此时1是小于3的,跳出我们while循环,我们使两边互换,有:
反转:此时我们还是3比对,这次比较的是l对应的值是否比我们当前的3大。看到7比3大,有:
我们使两边互换,得到:
反转:比较的是r对应的值是否比3大,我们看到直到r--减到array[r]=2的时候比3小,我们让它与3互换,得出:
反转:比较的是l对应的值是否比3大,l++刚刚走了一步就发现array[l]=4大于3,我们又让4与3互换,得出我们第一趟排序的结果:
而且此时,经历了四次反转,我们的数组清晰地由3这个一开始定义的轴值分为了两半:
我们这个时候执行递归,在代码中有:
对两方面分别进行同样的操作,不断递归之后就可以得到有序的数组。我们每一趟都检测逻辑情况也可以看出来:
我们写快速排序,也有需要注意的地方:
1.
使用递归必须添加能让递归停止的逻辑,左边界是不能大于右边界的。
2.
将两个while改成if也是可以进行排序,但是循环的次数会多很多,这个地方为了符合逻辑最好写成while而不是if。
3.
我们在递归时,总是刻意避开了我们当前的轴值l,下一次排序是在l的两侧进行的。
可以这样认为:在快速排序中,轴值经过一趟排序后的位置即为它在最终有序的数组里的位置。
4.
当在序列中出现两个同样的值的时候,需要做一定的规避动作,在代码中我们在while循环中加上一条判断语句来规避两边值相等造成的死循环问题。
快速排序性能:
快排的趟数取决于递归的深度,在具有n个记录的序列中,对一个记录定位要对整个待划分序列扫描一遍所需时间为:O(n)。
快速排序的时间复杂度为O(nLogn)。
快速排序是一种不稳定的排序方法。
堆排序
堆排序其实就在于构建一个大根堆,什么是大根堆呢?
大根堆:每一个结点的子节点都小于父节点的完全二叉树。
这里提到了完全二叉树,但是我们的代码中根本不会涉及到构建二叉树,而是使用了完全二叉树的性质。
对于一个完全二叉树,对它层序遍历,从1开始编号,我们可以得出一条规律:
- 左子节点的序号=父节点的序号*2
- 右子节点的序号=父节点的序号*2+1=左子节点的序号+1
这一点是我们把一个数组看做一个完全二叉树的关键,我们拿我们的数组为例,它构成的完全二叉树是这样的
那么,我们需要做的,就是
- 构建一个大根堆,将它每一个结点的子节点都小于它。
- 将堆顶(即数组中最大的那个)选出来,然后对剩下的重建大根堆。
我们的堆排序代码分为两个部分,第一个部分即为构建大根堆,第二个部分即为将堆顶摘出来,然后继续建堆的循环:
static void HeapSort(int[] array, int n)
{
int i;
for (i = n/2; i >=0; i--)
{
HeapBuilder(array, i, n);
}
for (i = n; i >= 1; i--)
{
int temp = array[0];
array[0] = array[i];
array[i] = temp;
HeapBuilder(array, 0, i - 1);
}
}
static void HeapBuilder(int[] array, int k, int n)
{
int i = k;
int j = i * 2;
while (j <= n)
{
if (j < n && array[j] < array[j + 1])
{
j++;
}
if (array[i] < array[j])
{
int temp = array[i];
array[i] = array[j];
array[j] = temp;
i = j;
j = i * 2;
}
else
{
break;
}
}
}
在这个代码中, HeapSort分为了两个部分,第一个for循环是构建初始大根堆,第二个for循环是依次摘出来大根堆然后进行大根堆的构建。我们看一下堆排序一趟的过程。
在堆排序中,初始构建堆的时候是从最后存在子树的节点开始的,对于一个完全二叉树来说,最后一个有子节点的节点序号为n/2,我们的大根堆也是从这里建立起来的:
第一次:
当i=n/2=5时,array[5]=2与array[10]=6相比较,6比2大所以互换,数组有:
第二次:
当i=4时,array[4]=13与array[8]=1相比较,但是,我们在比较二者前需要先比较看编号4的两个子节点大小,array[8]=1的兄弟结点是array[9]=55,比array[8]=1大,所以我们的子节点索引+1,然后比较,13比55小,所以13与55互换:
第三次:
当i=3时,array[3]与array[6]比较,array[6]和他的兄弟结点array[7]都比它小,所以不用互换。且array[6]的子节点都比它小,所以此时数组没有变化:
、
第四次:
当i=2时,array[2]与array[4]相比较,array[2]小于array[4],所以array[2]与array[4]互换:
该趟并没有结束,接着i=j为4,j=i*2为8,且有array[8]小于array[9],此时为array[4]与array[8]相比较,有array[4]=4与array[9]=13互换:
再根据这样的规则,经过i=1、 i=0之后,最终构建出来的大根堆为:
它的完全二叉树形式也是很明显的:
我们在代码中输出可以看出也是这个情况,和我们上面是一样一样的:
那么第二步,就是将大根堆顶移出,放到最后一个,然后对前面的构建堆,我们也可以在结果中观测到:
堆排序每一次都会将最大的放出去,然后对底下的数进行找出大根堆,这样就实现了堆排序。
注意
1.
大根堆的构建中,是从n/2这个最后一个有子节点的节点开始往上构建的,有两个原因:
- 只有小于n/2的节点才有子节点
- 若从0开始正序到n/2构建,那么处于前面的较小的节点则很难被正确的摆放到叶子结点上去。
2.
这里的j<n看似多此一举,但实际上这里是必不可少的,因为:
- 为了让叶子都能遍历到,j必须能取到n
- 若j取到n,则array[j]是最后一个叶子结点,array[j+1]显然也越界,所以这里要用j<n约束,不能造成数组越界。
堆排序性能
堆排序主要耗时在初始建堆和重建堆进行反复筛选的过程上。
初始建堆需要O(n)。第i次重建堆反复筛选需要O(Logi)。总的时间复杂度为O(nLogn)。
堆排序是一种不稳定的排序算法。
相较于快速排序,它对于原始记录的排列状态并不敏感,这也是它相较于快速排序的优势。
归并排序
归并排序算是比较难的,为了弄懂确实花了不少时间。
归并排序的基本思想是将无序序列拆分成若干个小无序序列,将这些小无序序列有序的合并,最终归并成一个有序序列。
说白了:拆——拆——拆到不能拆——有序地合并——合并——合并。
先上代码:
static void MergeSort(int[] array, int l, int r)
{
if (l == r)
{
return;
}
else
{
int middle = (l + r) / 2;
MergeSort(array, l, middle);
MergeSort(array, middle + 1, r);
Merge(array, l, middle, r);
}
}
static void Merge(int[] array, int l, int middle, int r)
{
int[] temp = new int[r - l + 1];
int i = l;
int j = middle + 1;
int k = 0;
while (i <= middle && j <= r)
{
if (array[i] < array[j])
{
temp[k] = array[i];
i++;
k++;
}
else
{
temp[k] = array[j];
j++;
k++;
}
}
while (i <= middle)
{
temp[k] = array[i];
i++;
k++;
}
while (j <= r)
{
temp[k] = array[j];
j++;
k++;
}
for (int t = 0; t < temp.Length; t++)
{
array[l + t] = temp[t];
}
}
在归并排序中,其实就是两步,拆——合。我们可以用图片来表现拆与合:
拆:我们通过middle=(l+r)/2,然后反复递归,直到最小的子序列长度为1为止:
合:合的时候在两边放上一个索引,两边谁小谁先进,流程就是这样的:
在我们的代码中,也可以随时输出检测这一个过程:
我们可以看到,在每次的拆分中,都最小拆分成了只有一个元素的数组,然后再将它们分别排序合并起来。成为一个有序数组。
合并函数也可以分开来写,这里给出创建左右数组来实现合并函数的代码:
static void Merge(int[] array, int l, int middle, int r)
{
middle += 1;
int[] leftArray = new int[middle - l];
int[] rightArray = new int[r - middle + 1];
for (int t = 0; t < leftArray.Length; t++)
{
leftArray[t] = array[l + t];
}
for (int t = 0; t < rightArray.Length; t++)
{
rightArray[t] = array[middle + t];
}
int i = 0;
int j = 0;
int k = l;
while (i < leftArray.Length && j < rightArray.Length)
{
if (leftArray[i] < rightArray[j])
{
array[k] = leftArray[i];
i++;
k++;
}
else
{
array[k] = rightArray[j];
j++;
k++;
}
}
while (i < leftArray.Length)
{
array[k] = leftArray[i];
i++;
k++;
}
while (j < rightArray.Length)
{
array[k] = rightArray[j];
j++;
k++;
}
}
注意
1.
与快速排序一样,代码在递归的时候需要及时的停住,防止无限次递归。
2.
使用创建右边界的索引j时,j的初始值应该是middle+1。
在逻辑中设定左半区索引范围是l~middle,右半区索引范围应该不与左半区重合,所以为middle+1~r,才能保证后面的比较中不出错。
同样的,在拆分时也存在将middle+1来正确划分两个数组的情况:
3.
当我们将合并数组的临时数组拆分成两个的时候,也会存在需要middle+1才能正常工作的情况。
由于我们在确定中心值的时候使用的除法,我们在合并0~1这样的区间的时候,正确的划分应该是array[0]在左数组,array[1]在右数组。但middle=(0+1)/2=0,两边的区间就会重合,所以我们要人为的将middle+1才能保证划分的正确性。
归并排序性能
归并排序首先需要将待排序序列进行扫描一遍,时间复杂度为O(n)。这样的扫描进行Logn趟,所以时间复杂度为O(nlogn)。
归并排序是一种稳定的排序算法。
排序总结:
我们目前写了七个排序:冒泡排序、选择排序、插入排序、希尔排序、快速排序、堆排序、归并排序。我们总结一下:
排序名称 | 时间复杂度 | 稳定性 | 空间复杂度 |
---|---|---|---|
冒泡排序 | O() | 稳定 | O(1) |
插入排序 | O() | 稳定 | O(1) |
选择排序 | O() | 不稳定 | O(1) |
希尔排序 | O(nLogn)~O() | 不稳定 | O(1) |
快速排序 | O(nLogn) | 不稳定 | O(Logn)~O() |
堆排序 | O(nLogn) | 不稳定 | O(1)(非递归情况下)O(Logn)(递归情况下) |
归并排序 | O(nLogn) | 稳定 | O(n) |
、
1.时间复杂度
关于时间复杂度,有:
- 较于简单的冒泡、插入、选择排序来说,时间复杂度都为O()。
- 希尔排序介于O(nLogn)和O()之间。
- 快速排序、堆排序、归并排序这些逻辑比较复杂的排序时间复杂度为O(nLogn)。
2.稳定性
关于排序稳定性,有:
- 冒泡排序、插入排序、归并排序都是稳定的排序算法。
- 选择排序不稳定是因为:在选择最小值与有序区最后一个值X交换的时候,若X此时有相等的值X1,那么X和X1的前后顺序就被打乱了。所以不稳定。
- 希尔排序不稳定是因为:由于希尔排序是跳跃式的交换,那么有可能两个相同的值一个交换了而另一个没有,所以不稳定。
- 快速排序不稳定是因为我们两边索引移动时若碰到三个相同连续的数字,有可能会与其他值进行比较和改变,所以不稳定。
- 堆排序不稳定是因为有可能一个大根堆里面有的值相等但处于完全二叉树的不同位置,但是堆顶出去以后,剩下的相同的值位置变换了,所以也不稳定。
我们可以看出,跳跃式比较然后交换的排序算法:希尔排序根据Gap跳跃比较交换、选择排序根据最小与前面的值跳跃交换、快速排序根据两边索引跳跃地比较交换、堆排序根据二叉树序号跳跃式比较交换。都造成了不稳定。
而其他的排序往往都是逐个比较的,是稳定的排序算法。
3.记录长度的大小
对于一个待排序列,n越大,采用改进的排序方法越合适。若n越小,排序方法越简单越合适。
因为n越小,于O(nLogn)和O()的差距越小。
4.空间复杂度
使用了递归方法的空间复杂度基本都为O(logn)。
归并排序再排序是创建了临时数组,所以为O(n)。
其他的非递归没有创建数组的排序方法的空间复杂度为O(1)。
5.结论
当n较大时,有以下几种选择:
- 关键码分布随机、对稳定性没有要求——快速排序
- 内存空间运行,要求排序稳定——归并排序
- 关键码可能正序或逆序,对稳定性没有要求——堆排序或归并排序
- 只需要找出前几个记录——堆排序或选择排序
当n较小时,也有以下几种选择:
- 记录基本有序,且要求稳定——插入排序
- 记录存储空间较大时——选择排序
我是个程序菜鸟,大家看到我的博客有啥毛病就跟我说,我立即飞速马上立刻改~~~