今天我们的目标是选择排序:简单选择排序与堆排序。两者排序的过程都在于每次选择一个最大值或者最小值放到合适的位置,因此都属于选择排序的范畴。区别在于:简单选择排序暴力选择出最大最小值,而堆排序合理的利用完全二叉树的特性使得算法的时间复杂度大大降低。接下来我们详细讲解两种排序:
简单选择排序:
思想:每次从一组数据中,找到最小的,然后放置在队列前面(当然也可以每次找到最大的,甚至有一些优化,每次可以同时找到最大值和最小值进行排序,我们这里采用找最小值)。可以看一下动画演示(动画是用unity制作的):
SelectionSort.gif
接下来着手写代码:
public static void SelectionSort(int[] arr)
{
int length = arr.Length;
if (length < 2)
{
return;
}
for (int i = 0; i < length - 1; i++)
{
int min = arr[i];
int minIndex = i;
//找到数列中的最小值
for (int j = i + 1; j < length; j++)
{
if (min > arr[j])
{
min = arr[j];
minIndex = j;
}
}
//与其适应的位置交换
if (minIndex != i)
{
int temp = arr[i];
arr[i] = arr[minIndex];
arr[minIndex] = temp;
}
}
}
时间复杂度:
我们主要看两层循环,第一层执行次数为(length-1),第二层最小为1,最大为(length-1)。第1次外循环内循环次数(length-1)次,第2次外循环内循环次数(length-2)…第(length-1)次外循环内循环1次,相加就是(length-1)+(length-2)…+1,即(length)x(length -1)/2,用大O法,通常我们只关心数量级,而不关心系数,所以其复杂度就是:O(n2)。
空间复杂度: O(1)。
稳定性:这里要注意一下,简单选择排序是一种不稳定排序,这是因为在选择了最小值之后,最小值要与数列头部的值进行交换,这样会导致打乱相同数值序列,比如(4,4,2,1)的序列,第一次最小值为1,与头部的4交换位置之后,导致两个4的前后顺序被打乱了,因此是不稳定排序。
堆排序:
堆排序首先是构建最大堆或最小堆。最大堆是用来正序排序,最小堆是用来倒序排序。
最大堆是指二叉树中每个结点的值都比其左右子结点的值大。同理最小堆是指二叉树中每个结点的值都比其左右子结点的值小。
对于二叉树不了解,在这里可以只有一个印象就可以。二叉树就是一个结点最多只有两个左右子结点。至于什么是完全二叉树,这里就不在过多解释,以后有机会写数据结构的时候,会着重解释,但是有一点要知道,数列从上往下,从左往右,按照只有一个根结点,且每个结点有两个子结点这样构建二叉树,那么他就是一颗完全二叉树。
下面我用一张图,来表示上面的概念,并加深印象。
完全二叉树.png
完全二叉树:
可以发现其实每个结点的下标和其左右子结点的下标是有一定关系的,即结点下标为n,左子结点下标为:2n+1,右子结点的下标为:2n+2。
最大堆:
最大堆.png
上图为第一次构建最大堆的结果
可以看出因为根结点要比左右子结点数值大,而且其左右子结点要比其孙子结点数值大,以此类推,此时的根结点即为数列的最大值。
那么我们如何把一个无序构建成一个最大堆。首先看最大堆的最大特点就是:父结点的数值一定比左右结点数值大,我们依照这个规则不断的调整结点使其满足条件即可。
再仔细观察堆我们发现,由一半以上的结点是没有孩子结点的,这部分结点就称为叶子结点,那么也就是说,这部分结点是不需要向下调整的。我们选择从(length/2)-1的下标开始依次从0下标的方向进行调整。每次调整之后,调整的结点还要继续比较他的子结点看看是否仍然满足最大堆特点,一直调整到叶子结点。这样做的目的就是使数列的大值向上浮,小值向下沉。直到下标0结点(根结点)调整完成,此时就是一个最大堆。
此时根结点是一个最大值,我们把最大值排在无序数列最后,即把最大值与队尾交换位置。此时我们发现除了根结点,其他结点仍然是符合最大堆特点的(注意,从这个位置往后,我们讲述的情况都是排除了最后一个数,因为他已经排好了位置)。这时我们只用调整根结点就可以了,调整之后,就得到了数列的第二个最大值。依次调整,直到数列排好即可。
思路:演示动画如下:
HeapSort4.gif
代码如下:
public static void HeapSort(int[] arr)
{
int length = arr.Length;
if (length < 2)
{
return;
}
//初次构建最大堆。从后往前第一个非叶子结点开始调整。
for (int i = length / 2 - 1; i >= 0; i--)
{
AdjustHeap(arr, i, length);
}
//将堆顶最大值移动到数组末端,再次从根结点开始调整构建最大堆。
//注意长度要-1,因为队尾的元素已经是排好序的。
for (int i = 0; i < length -1; i++)
{
Swap(arr, 0, length - 1 - i);
AdjustHeap(arr, 0, length - i - 1);
}
}
/// <summary>
/// 构建最大堆
/// </summary>
/// <param name="arr">需要构建的数组</param>
/// <param name="index">需要开始调整的结点下标</param>
/// <param name="length">需要构建的数组长度</param>
private static void AdjustHeap(int[] arr, int index, int length)
{
int leftIndex = index * 2 + 1; //左孩子结点下标
int rightIndex = index * 2 + 2; //右孩子结点下标
//如果左孩子下标大于等于数组长,则说明其为叶子结点,不需要调整
if (leftIndex >= length)
{
return;
}
//找到左右结点最大值的下标
int maxIndex = leftIndex;
if (rightIndex < length)
{
if (arr[leftIndex] < arr[rightIndex])
{
maxIndex = rightIndex;
}
}
//如果孩子结点的值要大于父结点,则交换两个结点的值,
//并且从交换后的子结点继续向下调整
if (arr[maxIndex] > arr[index])
{
Swap(arr, maxIndex, index);
AdjustHeap(arr, maxIndex, length);
}
}
private static void Swap(int[] arr, int index1, int index2)
{
int length = arr.Length;
if (index1 >= length || index2 >= length)
{
return;
}
int temp = arr[index1];
arr[index1] = arr[index2];
arr[index2] = temp;
}
时间复杂度:
首先我们来看函数的递归算法,了解到算法是根据二叉树依次向下分支进行遍历,那么他的运行次数跟树的深度有关,比如index下标结点的调整则最多需要:最深叶子结点的深度减去index下标结点的深度值即可。深度(用h表示,此处根结点的深度按0处理,树的最大深度位log2n)的计算可以自行查阅资料。另外完全二叉树为相同深度的结点个数** 2h-1**。
然后我们看初次构建最大堆,总共有一半的结点数需要调整,其中调整1次的结点为2h-1,调整2次的结点为2h-2,…,调整h次的结点为20。每调整一次的交换次数为2(左右结点比较,然后和父类结点比较)。
所以总的次数为:
2x2h-1+4x2h-2+…+2xhx20
运用数学归纳法:
212h-1+222h-2+…+2h20 =S
即
1x2h+2x2h-1+…+hx21 =S 公式1;
两边乘以2:
2h+1+2X2h+…+hx22 =2S 公式2;
公式2-公式1:
2h+1+2h+…+ 22- hx21 =S 公式3;
两边乘以2:
2h+2+2h+1+…+ 23- hx22 =2S 公式4;
公式4-公式3:
2h+2- hx22-22+ hx21=S
简化后可的
S=2h+2-2*h-4
而
h= log2n
所以
S=2hx22+2-2xh-4 =4xn-2x log2n-4
采用大O法,则其复杂度为O(n)
我们再看排序调整部分,交换算法算1次执行,循环下来执行的次数为(length-1)次,结点的调整情况我们可以类似初始构建最大堆的情况分析。有21的结点调整了1次,有22的结点调整了2次,…,有2h个结点调整了h次。
累加可得:
2x1x21+2x1x22+…+2xhx2h =S
同样运用数学归纳法得到最终的S:
S=4n log2n-4n+4
这部分的复杂度为:
S +n-1 = 4n log2n-3n+3
采用大O法,其复杂度为O(n log2n)。
两个阶段总的复杂度即为O(n log2n)。
空间复杂度:O(1)。
稳定性:不稳定排序。道理同简单选择排序一样。
网友评论