最近准备开始重新过一遍常见算法,顺便写一下博客记录一下,也是为了激励自己坚持下去,就先从最基本的排序算法开始吧。对于每一个算法,会贴出源码,并尽量讲清楚算法的核心思路,这样不管对读者还是对自己都是一种提升。算法语言采用的kotlin。
关于排序算法稳定性的解释
假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,r[i]=r[j],且r[i]在r[j]之前,而在排序后的序列中,r[i]仍在r[j]之前,则称这种排序算法是稳定的;否则称为不稳定的。
在开始之前先贴一下算法基类和一个kotlin的extension函数swap。
interface ISort<T> {
fun sort(data: ArrayList<T>) : List<T>
}
fun <T> ArrayList<T>.swap(p1: Int, p2: Int) {
if (p1 == p2) return
val t = this[p1]
this[p1] = this[p2]
this[p2] = t
}
/**
* 冒泡排序
*/
class BubbleSort : ISort<Int> {
override fun sort(data: ArrayList<Int>): MutableList<Int> {
for (i in 0 until data.size) {
var exchange = false
for (j in 0 until data.size - 1 - i) {
if (data[j] > data[j + 1]) {
data.swapByBit(j, j + 1)
exchange = true
}
}
if (!exchange) {
break
}
}
return data
}
}
冒泡排序可以说是最简单的排序算法了,思想也很简单,就是将一次次的将数组中的相邻的较大者交换到靠后的位置(这里得到的是升序),就像气泡一样不断地从水底向上升起,并且不断变大。其中冒泡排序有一个优化的点就是,就是对于一个已经是升序的序列,遍历一遍之后发现没有出现两个位置的互换,所以这时候就不需要再继续遍历了,直接跳出循环。
冒泡排序就是把小的元素往前调或者把大的元素往后调。比较是相邻的两个元素比较,交换也发生在这两个元素之间。所以,如果两个元素相等,我想你是不会再无聊地把他们俩交换一下的;如果两个相等的元素没有相邻,那么即使通过前面的两两交换把两个相邻起来,这时候也不会交换,所以相同元素的前后顺序并没有改 变,所以冒泡排序是一种稳定的排序算法。
/**
* 直接选择排序
*/
class SelectionSort : ISort<Int> {
override fun sort(data: ArrayList<Int>): MutableList<Int> {
for (i in 0 until data.size) {
var minimum = Int.MAX_VALUE
var pos = i
for (j in i until data.size) {
if (minimum > data[j]) {
minimum = data[j]
pos = j
}
}
data.swap(i, pos)
}
return data
}
}
选择排序的思路就是,给每个位置选择当前元素最小的,比如给第一个位置选择最小的,在剩余元素里面给第二个元素选择第二小的,依次类推,直到第n-1个元素
选择排序不是一种稳定的算法,举个反例:2 3 6 2 4 1,算法在第一次扫描的时候将会把第一个2与1交换,得到序列:1 3 6 2 4 2,这个时候两个2位置关系发生了变化,因此选择排序不是一个稳定的排序算法。
/**
* 希尔排序
*/
class ShellSort : ISort<Int> {
override fun sort(data: ArrayList<Int>): MutableList<Int> {
var gab = data.size
while (gab > 1) {
gab = gab / 3 + 1
for (i in gab until data.size) {
val t = data[i]
var j = i
while (j >= gab && data[j - gab] > t) {
data[j] = data[j - gab]
j -= gab
}
data[j] = t
}
}
return data
}
}
直接插入排序是在一个已经有序的小序列的基础上,一次插入一个元素。当然,刚开始这个有序的小序列只有1个元素,就是第一个元素。比较是从有序序列的末尾开始,也就是想要插入的元素和已经有序的最大者开始比起,如果比它大则直接插入在其后面,否则一直往前找直到找到它该插入的位置。
希尔排序是插入排序的一种又称“缩小增量排序”(Diminishing Increment Sort),排序过程:先取一个正整数 d1 < n ,把所有序号相隔 d1 的数组元素放一组,组内进行直接插入排序;然后取 d2 < d1 ,重复上述分组和排序操作;直至 di = 1,即所有记录放进一个组中排序为止。
由于多次插入排序,我们知道一次插入排序是稳定的,不会改变相同元素的相对顺序,但在不同的插入排序过程中,相同的元素可能在各自的插入排序中移动,最后其稳定性就会被打乱,所以Shell排序是 不稳定的。
/**
* 快速排序
*/
class QuickSort : ISort<Int> {
override fun sort(data: ArrayList<Int>): MutableList<Int> {
quickSort(data, 0, data.size - 1)
return data
}
private fun quickSort(data: ArrayList<Int>, start: Int, end: Int) {
if (start >= end) {
return
}
val index: Int = partition(data, start, end)
quickSort(data, start, index - 1)
quickSort(data, index + 1, end)
}
private fun partition(data: ArrayList<Int>, start: Int, end: Int): Int {
val key = data[start]
var low = start
var high = end
while (low < high) {
while (low < high && data[high] >= key) {
high--
}
while (low < high && data[low] <= key) {
low++
}
data.swap(low, high)
}
data.swap(start, high)
return high
}
}
快速排序的核心:partition 函数,partition 函数的目的是选取一个数key,将给定的数组,分成大于该数和小于该数的两部分,然后递归对两部分执行快排。 一般取数组的第一个数或者最后一个数作为key,但是最好这个数应该随机从数组选取。
快速排序是一个不稳定的排序算法,不稳定发生在中枢元素和a[j] 交换的时刻。
/**
* 归并排序
*/
class MergeSort : ISort<Int> {
override fun sort(data: ArrayList<Int>): MutableList<Int> {
return mergeSort(data)
}
private fun mergeSort(data: ArrayList<Int>): ArrayList<Int> {
return if (data.size > 1) {
merge(
mergeSort(arrayListOf<Int>().apply {
addAll(data.subList(0, data.size / 2))
}),
mergeSort(arrayListOf<Int>().apply {
addAll(data.subList(data.size / 2, data.size))
})
)
} else {
data
}
}
private fun merge(left: ArrayList<Int>, right: ArrayList<Int>): ArrayList<Int> {
val list = arrayListOf<Int>()
var i = 0
var j = 0
while (i < left.size && j < right.size) {
if (left[i] < right[j]) {
list.add(left[i])
i++
} else {
list.add(right[j])
j++
}
}
if (i < left.size) {
list.addAll(left.subList(i, left.size))
}
if (j < right.size) {
list.addAll(right.subList(j, right.size))
}
ArrayList<Integer> res = new ArrayList<Integer>(10)
return list
}
}
二路归并排序就是先将数组递归的二分成小的数组,直到不能再分为止,然后再对数组进行两两归并。归并的含义是将两个或两个以上的有序表合并成一个新的有序表。归并排序有多路归并排序、两路归并排序 , 可用于内排序,也可以用于外排序。
合并过程中我们可以保证如果两个当前元素相等时,我们把处在前面的序列的元素保存在结 果序列的前面,这样就保证了稳定性。所以,归并排序也是稳定的排序算法。
/**
* 计数排序
*/
class CountingSort : ISort<Int> {
override fun sort(data: ArrayList<Int>): MutableList<Int> {
var min = Int.MAX_VALUE
var max = Int.MIN_VALUE
for (i in data.indices) {
min = kotlin.math.min(min, data[i])
max = kotlin.math.max(max, data[i])
}
val R = max - min + 1 // 最大最小元素之间范围[min, max]的长度
val count = IntArray(R + 1)
for (i in 0 until data.size) {
count[data[i] - min + 1]++
}
for (r in 0 until R) {
count[r + 1] += count[r]
}
val result: ArrayList<Int> = ArrayList(data)
for (i in 0 until data.size) {
result[--count[data[i] - min + 1]] = data[i]
}
return result
}
}
计数排序是一种非常快捷的稳定的的排序方法,时间复杂度O(n+k),其中n为要排序的数的个数,k为要排序的数的组大值。计数排序对一定量的整数排序时候的速度非常快,一般快于其他排序算法。但计数排序局限性比较大,只限于对整数进行排序。计数排序是消耗空间发杂度来获取快捷的排序方法,其空间发展度为O(K)同理K为要排序的最大值。
/**
* RadixSort 基数排序
*/
class RadixSort : ISort<Int> {
override fun sort(data: ArrayList<Int>): MutableList<Int> {
var max = Int.MIN_VALUE
data.forEach {
max = kotlin.math.max(max, it)
}
var times = 0
while (max > 0) {
max /= 10
times++
}
sort(data, times)
return data
}
fun sort(number: ArrayList<Int>, maxDigit: Int) {
var mod = 1
var bit= 1 //控制键值排序依据在哪一位
val temp = Array(10) { IntArray(number.size) } //数组的第一维表示可能的余数0-9
val order = IntArray(10) //数组order[i]用来表示该位是i的数的个数
while (bit<= maxDigit) {
for (i in number.indices) {
val lsd = number[i] / mod % 10
temp[lsd][order[lsd]++] = number[i]
}
var k = 0
for (i in 0..9) {
if (order[i] != 0) {
for (j in 0 until order[i]) {
number[k++] = temp[i][j]
}
}
order[i] = 0
}
mod *= 10
bit++
}
}
}
基数排序思想:将所有待比较的元素数值(正整数)统一为同样的数位长度,数位较短的元素在前面补零占位。然后,从最低位开始,依次进行每一趟排序,直到最高位排序完成,则整个数列就成为一个有序的序列。
基数排序和计数排序都属于桶排序,桶排序的工作原理是将数组分到有限数量的桶里。每个桶再个别排序(有可能再使用别的排序算法或是以递归方式继续使用桶排序进行排序),最后依次把各个桶中的记录列出来即可得到有序序列。桶排序是稳定的排序算法。
桶排序假设待排序的一组数均匀独立的分布在一个范围中,并将这一范围划分成几个子范围(桶)。然后基于某种映射函数f ,将待排序列的关键字 k 映射到第i个桶中 (即桶数组B 的下标i) ,那么该关键字k 就作为 B[i]中的元素 (每个桶B[i]都是一组大小为N/M 的序列 )。接着将各个桶中的数据有序的合并起来 : 对每个桶B[i] 中的所有元素进行比较排序 (可以使用快排)。然后依次枚举输出 B[0]….B[M] 中的全部内容即是一个有序序列。(这样感觉和Hash很像啊。)
桶排序利用了函数的映射关系,高效与否的关键就在于这个映射函数的确定。为了使桶排序更加高效,我们需要做到这两点:
- 在额外空间充足的情况下,尽量增大桶的数量。
- 使用的映射函数能够将输入的 N 个数据均匀的分配到 K 个桶中。
/**
* 堆排序(大顶堆)
*/
class HeapSort : ISort<Int> {
override fun sort(data: ArrayList<Int>): MutableList<Int> {
initHeap(data, data.size)
for (i in data.size - 1 downTo 0) {
data.swap(0, i)
adjustHeap(data, 0, i)
}
return data
}
/**
* 初始化大顶堆
*/
private fun initHeap(heap: ArrayList<Int>, length: Int) {
//从第一个非叶子结点从下至上,从右至左调整结构
for (i in heap.size / 2 - 1 downTo 0) {
adjustHeap(heap, i, length)
}
}
/**
* @param heap 待调整的堆
* @param position 父节点位置
* @param length 剩余堆大小
*/
private fun adjustHeap(heap: ArrayList<Int>, position: Int, length: Int) {
var parent = position
var child = parent * 2 + 1
while (child < length) {
//取左、右孩子节点较大者
if (child + 1 < length && heap[child + 1] > heap[child]) {
child++
}
//如果child值大于parent,则交换,并且评估交换过后的以child为父节点的堆的情况
if (heap[child] > heap[parent]) {
heap.swap(child, parent)
parent = child
child = parent * 2 + 1
} else {
break
}
}
}
/**
* @param heap 待调整的堆
* @param parent 父节点位置
* @param length 剩余堆大小
*/
private fun adjustHeapR(heap: ArrayList<Int>, parent: Int, length: Int) {
var child = parent * 2 + 1
if (child < length) {
//取左、右孩子节点较大者
if (child + 1 < length && heap[child + 1] > heap[child]) {
child++
}
//如果child值大于parent,则交换,并且评估交换过后的以child为父节点的堆的情况
if (heap[child] > heap[parent]) {
heap.swap(child, parent)
adjustHeapR(heap, child, length)
}
}
}
}
堆排序是利用堆(大顶堆或者小顶堆)这种数据结构而设计的一种排序算法。堆排序的步骤就是先构造大顶堆,然后将堆顶与堆最后一位交换位置,然后调整除了最后一位外其余的堆,使调整为大顶堆,继续交换堆顶与堆最后一位,直至遍历整个序列为止,此时整个序列已经是顺序的了。
堆是具有以下性质的完全二叉树:每个结点的值都大于或等于其左右孩子结点的值,称为大顶堆;或者每个结点的值都小于或等于其左右孩子结点的值,称为小顶堆
堆排序的最坏,最好,平均时间复杂度均为O(n*log2n),同时堆排序是不稳定的排序。
给你一个包含20亿个int类型整数的文件,计算机的内存只有2GB,怎么给它们排序?显然我们不可能把所有的数据一次性的都装进内存中进行排序(装不下。。。),这个时候就需要外部排序了。外部排序的一般思路是将大的文件(假如有8GB),分成内存(2GB)能装下的4个小的文件(2GB),然后对每个小文件进行排序,然后再对各个小文件进行多路归并排序,最后就能得到排序结果了(排序结果直接输出到文件系统中)。为什么要用多路归并而不是二路归并???因为多路归并可以减少IO次数(相比于二路归并)。
当然这里有一个可以优化的点,就是在拆分大文件成小文件时,不是粗暴的的直接将文件按内存大小直接拆开,而是利用小顶堆(或者大顶堆)的性质进行拆分,这样就可以将有序的小文件个数减少。步骤就是:
1、先在内存中建立一个小顶堆,然后将堆顶的数写到小文件file_1中;
2、然后在从大文件读取一个数字到内存中,如果这个数比刚才写入到file_1中的的数大,则把这个数插入到小顶堆中去,重新调整小顶堆结构,然后再把堆顶的数写入到file_1中(因为小顶堆的性质,这个数一定是比刚才写入到file_1中的数要大的,为因此保证了写入到file_1中的数据是有序的);否则,把这个数(记为numberA)暂放在一边(不是插入到堆中!!!,因为这个数numberA比file_1中最后一位(也是最大的)小,将numberA写道file_1终会破坏掉file_1的顺序序列,因此已经不能写到当前的file_1中了,只能等待写入下一个文件中),暂时不处理。之后调整堆结构,从堆顶选择一个数写入到file_1中。
3、重复执行步骤2直到堆空为止(这个时候表示,堆中已经没有能满足写入到file_1中的数据了),因此file_1写入完成;将步骤2中被放到一旁的所有数重新建立一个新的小顶堆,取出堆顶最小的数写入到file_2中去,然后重复执行步骤2。
4、重复执行步骤2、3,直到将大文件中所有数据都拆分到各个顺序小文件中,然后就是将各个顺序小文件进行多路归并就可以了。