《聊聊数据结构与算法》之排序算法-下篇
--- 心素如简,人淡如茶,得之坦然,失之淡然,争其必然,顺其自然,是为茶也。
写在前面
关于《聊聊数据结构与算法》之排序算法,本人写了上下两篇文章,其中上篇请读者自行查看本人博文清单。
内部排序
1、快速排序(重点)
-
算法原理
选择一个基准元素,通常选择第一个元素或者最后一个元素,通过一趟扫描,将待排序列分成两部分,一部分比基准元素小,一部分大于等于基准元素,此时基准元素在其排好序后的正确位置,然后再用同样的方法递归地排序划分的两部分。
-
示例
-
实现
注:指向基准元素的指针不移动。(“基准 不能动”)
2、堆排序(重点)
-
算法原理
堆排序是一种树形选择排序,是对直接选择排序的有效改进。堆排序(Heapsort)是指利用堆这种数据结构所设计的一种排序算法。堆是一种经过排序的完全二叉树,并同时满足堆积的性质:即子结点的键值总是小于(或者大于)它的父节点。
最大堆:根结点的键值是所有堆结点键值中最大者。(大根堆)
最小堆:根结点的键值是所有堆结点键值中最小者。(小根堆)
①什么是堆
我们这里提到的堆一般都指的是二叉堆,它满足二个特性:
-- 父结点的键值总是大于或等于(小于或等于)任何一个子节点的键值。
-- 每个结点的左子树和右子树都是一个二叉堆(都是最大堆或最小堆)。
如下为一个最小堆(父结点的键值总是小于任何一个子节点的键值)。
②什么是堆调整(Heap Adjust)
这是为了保持堆的特性而做的一个操作。对某一个节点为根的子树做堆调整,其实就是将该根节点进行“下沉”操作(具体是通过和子节点交换完成的),一直下沉到合适的位置,使得刚才的子树满足堆的性质。
这里需要提一下的是,一般做一次堆调整的时间复杂度为log(n)。
如下为我们对4为根节点的子树做一次堆调整的示意图,可帮我们理解。
③如何建堆
建堆是一个通过不断的堆调整,使得整个二叉树中的数满足堆性质的操作。在数组中的话,我们一般从下标为n/2的数(最后一个非叶子节点)开始做堆调整,一直到下标为0的数 (因为下标大于n/2的节点都是叶子节点,其子树已经满足堆的性质了)。下图为其一个图示:
很明显,对叶子结点来说,可以认为它已经是一个合法的堆了即20,60,65,4,49都分别是一个合法的堆。只要从A[4]=50开始向下调整就可以了。然后再取A[3]=30,A[2] = 17,A[1] = 12,A[0] = 9分别作一次向下调整操作就可以了。
-
如何进行堆排序
- 对数组建堆(初始二叉树+堆调整)
- 根节点与最后一个叶子节点交换,再重新调整堆。
- 堆还原成数组(层次遍历)
数组储存成堆的形式之后(针对最大堆),第一次将A[0]与A[n - 1]交换,再对A[0…n-2]重新恢复堆。第二次将A[0]与A[n-2]交换,再对A[0…n-3]重新恢复堆,重复这样的操作直到A[0]与A[1]交换。由于每次都是将最大的数据并入到后面的有序区间,故操作完成后整个数组就有序了(增序数组)。
--大堆输出是递增数列(若交换并输出则是逆序输出,相当于从增序数组尾部输出)
-
示例:
-
堆排序--->递减序列 (小根堆)
-
补充
- 优化思想
-
- 用数组表示二叉树
注:默认数组的元素顺序就是对应二叉树层次遍历的顺序。
-
- 堆排序--->递增序列 (大根堆)
3、归并排序
-
算法原理
归并(Merge)排序法把待排序序列分为若干个子序列,每个子序列是有序的。然后再把有序子序列合并为整体有序序列。
(将待排序序列R[0...n-1]看成是n个长度为1的有序序列,将相邻的有序表成对归并,得到n/2个长度为2的有序表;将这些有序序列再次归并,得到n/4个长度为4的有序序列;如此反复进行下去,最后得到一个长度为n的有序序列。)
若将两个有序表合并成一个有序表,称为2-路归并。
归并排序是建立在归并操作上的一种有效的排序算法。该算法是采用分治法(Divide and Conquer)的一个非常典型的应用。归并排序是一种稳定的排序方法。
综上可知:
归并排序其实要做两件事:
-
- “分解”——将序列每次折半划分。
- “合并”——将划分后的序列段两两合并排序。
-
示例
-
代码实现
补充:
如何将两个有序队列A、B归并成一个有序数列C?
算法思想:
采用两个指针分别指向A、B两个队列的第一个元素,每次取两者中较小的元素放入C中,最后若一个队列元素耗尽,则将另一个队列剩余元素都拷贝到C尾部。
实现:
4、桶排序
-
算法原理
桶排序 (Bucket sort)或所谓的箱排序,工作原理是将数组分到有限数量的桶子里。每个桶子再个别排序(有可能再使用别的排序算法或是以递归方式继续使用桶排序进行排序)。
桶排序的基本思想是:把数组 arr 划分为n个大小相同子区间(桶),每个子区间各自排序,最后合并。
桶排序是稳定的,且在大多数情况下常见排序里最快的一种,比快排还要快,缺点是非常耗空间,基本上是最耗空间的一种排序算法,而且只能在某些情形下使用。
①桶排序适用数据范围
桶排序可用于最大最小值相差较大的数据情况,比如[9012,19702,39867,68957,83556,102456]。
但桶排序要求数据的分布必须均匀,否则可能导致数据都集中到一个桶中。比如[104,150,123,132,20000], 这种数据会导致前4个数都集中到同一个桶中。导致桶排序失效。
②算法描述
-- 找出待排序数组中的最大值max、最小值min
-- 我们使用 动态数组ArrayList 作为桶,桶里放的元素也用 ArrayList 存储。桶的数量为(max-min)/arr.length+1
-- 遍历数组 arr,计算每个元素 arr[i] 放的桶
-- 每个桶各自排序
--遍历桶数组,把排序好的元素放进输出数组
③数组元素分配到桶中规则:(常用规律:类似与hash算法)
-- 对最大权重取模(eg:直接对10取整数部分)
-- num = (arr[i] - min) / (arr.length) (取模运算)
-
示例
-
实现
注:
①
②
5、计数排序
-
算法原理
计数排序(Counting sort)是一种稳定的排序算法。计数排序使用一个额外的数组C,其中第i个元素是待排序数组A中值等于i的元素的个数。然后根据数组C来将A中的元素排到正确的位置。它只能对整数进行排序。(规律:计数:统计个数/次数)
计数排序的基本思想是:对每一个输入的元素arr[i],确定小于 arr[i] 的元素个数m。则arr[i]的最终排好序的位置为m+1。
所以可以直接把 arr[i] 放到它输出数组中的位置上。假设有5个数小于 arr[i],所以 arr[i] 应该放在数组的第6个位置上。
使用场景:
①计数排序适用数据范围
计数排序需要占用大量空间,它仅适用于数据比较集中的情况。比如 [0~100],[10000~19999] 这样的数据。
②计数排序是桶排序的一种特殊情况,可以把计数排序当成每个桶里只有一个元素的情况。
-
算法流程
需要三个数组:
待排序数组 int[] arr = new int[]{4,3,6,3,5,1};
辅助计数数组 int[] help = new int[max - min + 1]; //该数组大小 或者直接取max
输出数组 int[] res = new int[arr.length];
步骤:
- 求出待排序数组的最大值max=6, 最小值min=1
- 实例化辅助计数数组help,help数组中每个下标对应arr中的一个元素,help用来记录每个元素出现的次数。
- 计算 arr 中每个元素在help中的位置 position = arr[i] - min,(进行偏移调整,保证help数组索引从0开始:左移min个单位),此时 help = [1,0,2,1,1,1]; (3出现了两次,2未出现)(规律:统计次数数组默认是按照从小到大统计,所以实际上潜在地实现了排序)
- 根据 help 数组求得排序后的数组,此时 res = [1,3,3,4,5,6]
-
实现
注:
①
②
6、基数排序
-
算法原理
将所有待比较数值(正整数)统一为同样的数位长度,数位较短的数前面补零。然后,从最低位开始,依次进行一次排序。这样从最低位排序一直到最高位排序完成以后,数列就变成一个有序序列。(数值整数,高位数值权重比低位大,放在后面排序,决定数值整体大小)
基数排序是一种非比较型整数排序算法,其原理是将整数按位数切割成不同的数字,然后按每个位数分别排序。由于待排序元素也可以是字符串(比如名字或日期)和特定格式的浮点数(可以依据相应的数值比较),所以基数排序也不是只能使用于整数。(规律:按照基数进行排序)
-
算法流程
- 某个基数位的排序:是通过先分配后收集实现 ;(按照该位(基数)进行分配,收集完即排好序)
-
- 从低位到高位重复进行分配、收集。直到最高位排序完成以后,数列就变成一个有序序列。
-
示例
①
②
注:
①桶排序、计数排序、基数排序属于非比较排序。(基于收集策略)
②其实按照位号大小进行放置数即是按序分配,生成的序列即是有序的。(典型应用:计数排序、基数排序)
小结
快速排序,在平均状况下,排序 n 个项目要Ο(nlogn)次比较。在最坏状况下则需要 Ο(n2)次比较,但这种状况并不常见。事实上,快速排序通常明显比其他 Ο(nlogn) 算法更快,因为它的内部循环(inner loop)很容易实现。
快速排序在平均情况下是的排序算法中时间常数最小的,但是最坏情况下会退化到O(n^2),而一般快速排序使用的是固定选取的中位数,为了防止有人使用精心构造的数据来攻击排序算法,STL中的std::sort等实现都会采取先快速排序,如果发现明显退化迹象则回退到堆排序这样的时间复杂度稳定的排序上。
不同条件下,排序方法的选择:
- 若n较小(如n≤50),可采用直接插入或直接选择排序。当记录规模较小时,直接插入排序较好;否则因为直接选择移动的记录数少于直接插人,应选直接选择排序为宜。
- 若文件初始状态基本有序,则应选用直接插人、冒泡或快速排序为宜;
- 若n较大,则应采用时间复杂度为O(nlgn)的排序方法:快速排序、堆排序或归并排序。
快速排序是目前基于比较的内部排序中被认为是最好的方法,当待排序的关键字是随机分布时,快速排序的平均时间最短;
堆排序所需的辅助空间少于快速排序,并且不会出现快速排序可能出现的最坏情况。这两种排序都是不稳定的。
若要求排序稳定,则可选用归并排序。但本章介绍的从单个记录起进行两两归并的排序算法并不值得提倡,通常可以将它和直接插入排序结合在一起使用。先利用直接插入排序求得较长的有序子序列,然后再两两归并之。因为直接插入排序是稳定 的,所以改进后的归并排序仍是稳定的。
优先队列通常用堆排序来实现。
注意:
- Arrays.sort() 采用了2种排序算法 -- 基本类型数据使用快速排序法,对象数组使用归并排序。
- java的Collections.sort()算法调用的是归并排序,它是稳定排序。
- 有序树左子树数值都小于等于当前节点数值,右子树节点数值都大于等于当前节点数值。堆排序是利用堆结构操作生成有序序列的过程。两者截然不同。
本文部分原理阐述与示例图形参考于相关书籍与网络知识。感谢发明第一个甲骨文的原始先祖,感谢发明第一个字母表的腓尼基人,感谢每位知识的贡献者,文字是美妙的。