文章目录
- 选择排序
- 算法介绍
- 代码实现
- 复杂度和稳定性
- 堆排序
- 算法介绍
- 代码实现
- 复杂度和稳定性
选择排序
算法介绍
选择排序是一种简单直观的排序算法。它的工作原理是:首先在未排序序列中找到最小(大)元素,存放到排序序列的起始位置,然后,再从剩余未排序元素中继续寻找最小(大)元素,然后放到已排序序列的末尾。以此类推,直到所有元素均排序完毕。
算法步骤:
- 在未排序序列中找到最小(大)元素,存放到排序序列的起始位置。
- 再从剩余未排序元素中继续寻找最小(大)元素,然后放到已排序序列的末尾。
- 重复第二步,直到所有元素均排序完毕。
【图示】
代码实现
public void selectSort(int[] array) {
for (int i = 0; i < array.length; i++) {
int min = i;
for (int j = i + 1; j < array.length; j++) {
//不断更新最小值的下标
if(array[j] < array[min]) {
min = j;
}
}
//交换
int tmp = array[i];
array[i] = array[min];
array[min] = tmp;
}
}
复杂度和稳定性
时间复杂度:O(N^2)
空间复杂度:O(1)
稳定性:不稳定,因为相同的元素可能在排序过程中被交换到不同的位置。
堆排序
算法介绍
堆排序是一种基于比较选择的排序算法,它利用堆这种数据结构所设计。堆是一个近似完全二叉树的结构,并同时满足堆积的性质:即子节点的键值或索引总是小于(或者大于)它的父节点。
堆排序就要用到堆。关于堆以及建堆,在这一篇Java数据结构(七)——优先级队列与PriorityQueue-CSDN博客中已经介绍了。不过,为了方便以及复习需要,我们再介绍一遍堆的一些知识点以及建堆的思考过程:
堆
堆的逻辑结构是一棵完全二叉树;堆的物理结构是一个数组,通过下标表示父子结点的关系,这个数组的元素是按照层序遍历(广度优先)的方式存储的。
以下左图为例,其堆的数组元素为 { 6 ,8,10,12,14,16,18 }
堆的重要公式:(parent、child、leftchild、rightchild均为下标值)
- parent = (child - 1) / 2
- leftchild = parent * 2 + 1
- rightchild = parent * 2 + 2
堆的两个特性:
- 结构性:用数组表示一棵完全二叉树
- 有序性:任一结点的关键字是其子树所有节点的最大值(或最小值)。
“最大堆”,也称“大顶堆”(或“大根堆”),简称大堆。特点是:父亲大于等于孩子
“最小堆”,也称“小顶堆”(或“小根堆”),简称小堆。特点是:父亲小于等于孩子
建堆以及排序的思考
建堆的关键是向下调整算法:
向下调整算法
前提:左右子树必须同为大堆或小堆
以小堆为例,父亲跟它的左右孩子的较小值比较,如果父亲比较小值大,那么交换较小孩子和父亲,父亲的值不变位置变化,然后继续和现在位置的左右孩子的较小值比较,父亲大继续交换,直到叶子节点终止(这是算法终止的第一种情况)。如果在到达叶子节点前,出现父亲比左右孩子较小值还要小的情况,那么不执行交换,终止算法执行(第二种终止情况)。
以大堆为例,父亲跟它的左右孩子的较大值比较,如果父亲比较大值小,那么交换较大孩子和父亲,父亲的值不变位置变化,然后继续和现在位置的左右孩子的较大值比较,父亲小继续交换,直到叶子节点终止(这是算法终止的第一种情况)。如果在到达叶子节点前,出现父亲比左右孩子较大值还要大的情况,那么不执行交换,终止算法执行(第二种终止情况)。
如果逻辑上的完全二叉树不满足前提条件,怎么办?
既然对根节点没法使用向下调整算法,我们不妨从树的其他满足前提条件的结点开始使用向下调整算法。
从哪些结点开始使用向下调整算法?从叶子结点?
叶子节点没有左右孩子,那么默认就是一个小堆(或大堆),没必要对它使用向下调整算法。
因此,我们开始使用向下调整算法的结点是倒数第一个非叶子节点,然后将下标减1,就是倒数第二个非叶子节点,以此类推,结果建堆成功。
怎么找到倒数第一个非叶子结点?
堆数组最后一个元素是逻辑完全二叉树的最右边的叶子节点,它的父亲就是倒数第一个非叶子结点。
最右边的叶子节点的在数组中的下标是len - 1
(len是数组的元素个数),要找它的父亲,用到父子结点关系公式:parent = (child - 1) / 2
代入得到倒数第一个非叶子节点对应的下标值:(len - 1 - 1) / 2
建堆的过程了解了,假如我要排升序,那么我该建大堆还是小堆?建堆后怎么使用堆实现排序?
第一反应就是建小堆,最小数在堆顶,堆的物理结构是数组,当我们把第一个元素(建小堆后第一个元素是序列中的最小元素)拿出来后,之后需要在剩下的数中再去选数,但是剩下的树的结构都乱了,需要重新建堆才能选出下一个数,建堆的时间复杂度为O(N),这样反复建堆可以实现排序的目的,不过堆排序就失去了效率优势。
所以我们排升序需要建大堆,最大数在堆顶,每次取堆顶元素,与末尾元素交换,之后再对交换后的堆顶元素执行一次向下调整算法即可继续保持大堆,并且每次交换后待排序的元素就会减少一个。
比如:某个序列建大堆后数组元素顺序为:{ 9,8,6,7,3,2,1,5,4,0 },9是堆顶元素,将堆顶元素取出来与0元素交换,得到{ 0,8,6,7,3,2,1,5,4,9 },9是最大元素,不需要再参与排序,除去9后前 len - 1 个元素可以执行向下调整算法,0的左右子树都是大堆,算法执行完毕后,次大的元素到达堆顶,将次大的元素与倒数第二个元素交换,此时倒数一二个元素已经不需要参与排序,以此类推,便实现了顺序堆排序。
所以,堆排序的过程分为两个主要阶段:
- 建堆:将无序序列构建成一个堆,根据升序或降序需求选择最大堆或最小堆。
- 排序:将堆顶元素(最大或最小元素)与堆的末尾元素进行交换,此时末尾就为最大值(或最小值)。然后将剩余n-1个元素重新调整结构,使其满足堆的定义,之后再重复此过程,直到整个序列有序。
【图示】
仍然以排升序为例,按照上面的分析,我们首先要将序列建为大根堆,建堆:
接着排序:
代码实现
//向下调整
private void shitDown(int[] array, int root, int len) {
int parent = root;
int child = parent * 2 + 1;
while(child < len) {
if(child + 1 < len && array[child] < array[child+1]) {
child++;
}
if(array[child] > array[parent]) {
int tmp = array[child];
array[child] = array[parent];
array[parent] = tmp;
parent = child;
child = parent * 2 + 1;
}else {
break;
}
}
}
//堆排序
public void heapSort(int[] array) {
//建堆(从第一个非叶子结点开始)
for(int i = (array.length - 1 - 1) / 2; i >= 0; i--) {
shitDown(array, i, array.length);
}
//排序(每趟:交换+向下调整)
int end = array.length - 1;
while(end > 0) {
int tmp = array[0];
array[0] = array[end];
array[end] = tmp;
shitDown(array, 0, end);
end--;
}
}
- 注意排序时,每趟排好一个元素,排好的元素不参与堆的组成了,即向下调整算法与它们无关,体现在代码中为
end--;
复杂度和稳定性
时间复杂度:O(N*log2N)
堆排序的整体时间复杂度由建堆和排序两部分组成:
- 建堆时间复杂度:
O(N)
- 排序时间复杂度:
O(N*log2N)
由于排序部分的时间复杂度更高,所以堆排序的整体时间复杂度是 O(n log n)
。
空间复杂度:O(1)
稳定性:不稳定,相同值的元素可能会经过多次交换,从而导致它们在排序后的相对位置发生改变。
完