冒泡排序 一句话描述 左侧无序区域中的最大数字交换到右侧已排序区域的最左侧。
冒泡排序代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 class BubbleSort { fun sort (nums: IntArray ) { val n = nums.size for (i in 0 until n) { var isNotSwapped = true for (j in 0 until n - 1 - i) { if (nums[j] > nums[j + 1 ]) { val tmp = nums[j + 1 ] nums[j + 1 ] = nums[j] nums[j] = tmp isNotSwapped = false } } if (isNotSwapped) break } } }
稳定性 冒泡排序是稳定的 。
因为交换是当前数大于下一个数才会交换。
如果有x1 == x2
,x1
在x2
左边,x1
始终不会被交换到x2
右边。
选择排序 一句话描述 右侧未排序区域选取一个最小的数,交换到前面已排序区域的末尾。
选择排序代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 class SelectionSort { fun sort (nums: IntArray ) { val n = nums.size for (i in 0 until n) { var minIndex = i for (j in i + 1 until n) { if (nums[j] < nums[minIndex]) { minIndex = j } } val tmp = nums[minIndex] nums[minIndex] = nums[i] nums[i] = tmp } } }
稳定性 选择排序不稳定 ,因为发生了位置交换。
由于每次会在后面的未排序区域选择最小的数字与前面的已排序区域末尾元素交换,如果未排序区域交换的位置的前面有与已排序区域末尾元素相等的元素,这两个元素的相对位置就变了。
例如[2, 3, 4, 2, 1],第一趟选择会把最小的1放到最前面,第一个2交换到最后面,这样两个2的相对顺序就变了。
如何不发生位置交换呢?
有两种做法:
一个是开辟一个新数组,把最小的放到第一个位置上,把第二小的放到第二个位置上等等。空间复杂度是O(n)。
一个是使用链表,空间复杂度是O(1)。
特点 交换次数比冒泡排序少,交换次数是跟数组长度呈线性关系。
直接插入排序 一句话描述 扑克牌拿牌后插牌的排序:把数组右侧未排序区域的最左侧元素插入数组左侧已排序区域中。
直接插入排序代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 class InsertSort { fun sort (nums: IntArray ) { val n = nums.size for (i in 0 until n) { for (j in i downTo 1 ) { if (nums[j] < nums[j - 1 ]) { val tmp = nums[j] nums[j] = nums[j - 1 ] nums[j - 1 ] = tmp break } } } } }
优化 已排序区域寻找插入位置可以使用二分查找。
适用场景 适合部分有序的数组,这样比较次数就会大大减少,从而提高效率。
对于大规模乱序的数组,插入排序很慢,因为只会交换相邻的元素,元素只能一步一步的从数组的一端移动到另一端。
例如,如果是升序排序,数组最小的元素在数组末尾,那么移动到开头就要交换N-1次。如果有一个完全降序的数组,用插入排序变为升序的话,要做的事情太多了。
给定一个10万个元素的数组,部分有序,部分无序,选择哪一种排序算法最好?
用插入排序,插入排序在已排序区域寻找插入位置可以用二分法加快寻找
希尔排序 一句话描述 先大步再小步的插入排序。
大步插入排序使得用很少的交换次数让数组变得部分有序,从而在小步排序时发挥插入排序的优势,达到总体的比较和交换次数变少。
希尔排序代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 class ShellSort { fun sort (nums: IntArray ) { val n = nums.size var d = 1 while (d < n / 3 ) { d = 3 * d + 1 } while (d >= 1 ) { for (i in d until n) { for (j in i downTo d step d) { if (nums[j] < nums[j - d]) { val tmp = nums[j] nums[j] = nums[j - d] nums[j - d] = tmp break } } } d /= 3 } } }
递增序列如何选择?不同的递增序列有什么影响?
《算法(第4版)》
如何选择递增序列呢?要回答这个问题并不简单。算法的性能不仅取决于h,还取决于h 之间的数学性质,比如它们的公因子等。
有很多论文研究了各种不同的递增序列,但都无法证明某个序列是 “ 最好的” 。
算法2.3中递增序列的计算和使用都很简单,和复杂递增序列的性能接近。但可以证明复杂的序列在最坏情况下的性能要好于我们所使用的递增序列。更加优秀的递增序列有待我们去发现。
一个简单的序列选择: 从1开始,一直 d * 3 + 1,直到小于 n / 3。
希尔排序更高效的原因? 希尔排序权衡了数组的规模和有序性。
排序之初,各个子数组都很短,排序之后子数组都是部分有序的,这两种情况都很适合插入排序。
子数组部分有序的程度取决于递增序列的选择。
适用场景 在大规模乱序的情况下,希尔排序可以减少元素交换的次数,数组越大优势越大。
快速排序 一句话 选取一个轴心元素,将数组划分成比这个数小的和比这个数字大的两个子数组,分别对两个子数组递归调用划分。
快速排序代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 fun sort (nums: IntArray ) { if (nums.isEmpty()) { return } return quickSort(nums, 0 , nums.size - 1 ) } fun quickSort (nums: IntArray , low: Int , high: Int ) { if (nums.isEmpty()) { return } if (low < high) { val pivotIndex = partition(nums, low, high) quickSort(nums, low, pivotIndex - 1 ) quickSort(nums, pivotIndex + 1 , high) } } fun partition (nums: IntArray , low: Int , high: Int ) : Int { var start = low var end = high val pivot = nums[start] while (start < end) { while (start < end && nums[end] >= pivot) { end-- } nums[start] = nums[end] while (start < end && nums[start] <= pivot) { start++ } nums[end] = nums[start] } nums[start] = pivot return start }
为什么partition()里的while判断条件里low不能等于high?
因为一开始取pivot就已经挖空了一个位置。
特点 每次划分,轴心元素就在最终排序完成后的位置上。
快排为什么是O(n log n)复杂度? 根据主定理推导。
参考: 如何证明快速排序法的平均复杂度为 O(nlogn)? - 知乎
什么会影响时间复杂度? 用于划分数组的中枢元素的选择会影响时间复杂度,划分的左右子数组数量越接近效果越好,否则会让整个快速排序退化到O(n^2)级别。
具体怎么划分要根据数组本身的数据分布特性来决定
以下情况会变得低效:
(1)近乎有序的数列
对于一个近乎有序的数列,当直接使用第一个元素作为基准点的时候,将会导致划分的子数组大小差距太大,进而无法发挥快排划分的优势
(2)含有大量重复数据的数列
選取的數字如果是重複較多的數字,划分出的两个子数组有一边的长度会很大,因为移动指针的时候,判断条件是大于等于和小于等于枢纽元素
如何优化时间复杂度? 针对近乎有序的数组:
三数取中法
选取基准点之前我们可以拿出数列中间位置元素的值,将它和首尾的元素进行比较,之后将这三个数中的中间大的数交换到数列首位的位置,之后将这个数作为基准点,尽量减小之后的分区后左右两边的区间长度之差。
LeetCode.75.颜色分类(中等) 正好就是用到这种方法。
随机交换法
选取基准点之前设计随机种子,通过随机函数得到一个在数列长度内的数,将这个随机数作为索引所指的数和第一个元素进行交换,之后将首位元素作为基准点。即随机选一个数放到首位的地方。这样一来,第一次就将最小的数交换到首位的概率是非常小的,第二次将次小的数交换到首位的概率依然非常的小。
堆排序 一句话说明 堆就是一个完全二叉树,堆排序两步走: 建堆:从最后一个非叶子节点到根结点不停的向下调整堆。 排序调整:堆顶元素与数组末尾元素交换,再向下调整堆顶元素。
堆排序代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 class HeapSort { fun sort (nums: IntArray ) { buildHeap(nums) for (length in nums.size downTo 1 ) { nums.swap(0 , length - 1 ) sink(nums, 1 , length - 1 ) } } private fun buildHeap (nums: IntArray ) { val n = nums.size for (parent in n / 2 downTo 1 ) { sink(nums, parent, n) } } private fun sink (nums: IntArray , k: Int , length: Int ) { var parent = k while (parent <= length / 2 ) { var child = parent * 2 if (child < length && nums[child - 1 ] < nums[child]) child++ if (nums[parent - 1 ] >= nums[child - 1 ]) break nums.swap(parent - 1 , child - 1 ) parent = child } } private fun IntArray.swap (i: Int , j: Int ) { val tmp = this [i] this [i] = this [j] this [j] = tmp } }
堆排序的时间复杂度也是O(n * logn),为什么一般排序用快速排序? 因为堆是一种完全二叉树,访问的数据不在内存中连续的区域,空间访问局部性效果不太好,缓存命中率低,进而降低了程序运行速度。
快速排序会访问数组相邻的元素,空间访问局部性比较好,程序运行速度快。
归并排序 一句话说明 归并两个子数组为一个有序数组。
可以自顶向下递归进行,也可以自底向上迭代进行。
归并排序代码 自顶向下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 class MergeSort { fun sort (nums: IntArray ) { mergeSort(nums, 0 , nums.size - 1 ) } private fun mergeSort (nums: IntArray , low: Int , high: Int ) { if (low < high) { val mid = low + (high - low) / 2 mergeSort(nums, low, mid) mergeSort(nums, mid + 1 , high) merge(nums, low, mid, high) } } private fun merge (nums: IntArray , low: Int , mid: Int , high: Int ) { val copiedNums = nums.copyOf() var p = low var p1 = low var p2 = mid + 1 for (i in low..high) { when { p1 > mid -> nums[p++] = copiedNums[p2++] p2 > high -> nums[p++] = copiedNums[p1++] copiedNums[p1] < copiedNums[p2] -> nums[p++] = copiedNums[p1++] else -> nums[p++] = copiedNums[p2++] } } } }
自底向上:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 class MergeSort { fun sort (nums: IntArray ) { mergeSort(nums) } private fun mergeSort (nums: IntArray ) { val n = nums.size var size = 1 while (size < n) { var low = 0 while (low < n - size) { val mid = low + size - 1 val high = min(low + 2 * size - 1 , n - 1 ) merge(nums, low, mid, high) low += size * 2 } size *= 2 } } private fun merge (nums: IntArray , low: Int , mid: Int , high: Int ) { val copiedNums = nums.copyOf() var p = low var p1 = low var p2 = mid + 1 for (i in low..high) { when { p1 > mid -> nums[p++] = copiedNums[p2++] p2 > high -> nums[p++] = copiedNums[p1++] copiedNums[p1] < copiedNums[p2] -> nums[p++] = copiedNums[p1++] else -> nums[p++] = copiedNums[p2++] } } } }
计数排序 一句话说明
记录待排序数组每个取值的个数
用一个数组累加记录有多少数是小于等于当前索引I
逆序输出
计数排序代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 class CountSort { private val w = 10000 fun sort (nums: IntArray ) { val n = nums.size val copied = nums.copyOf() val count = IntArray(w + 1 ) { 0 } for (num in nums) { count[num]++ } for (i in 1. .w) { count[i] += count[i - 1 ] } for (i in n - 1 downTo 0 ) { val index = count[copied[i]] - 1 nums[index] = copied[i] count[copied[i]]-- } } }
为什么最后要逆序遍历原数组?
这里必须从后向前遍历,只有这样出现重复的元素,才会保持顺序的把最后面的重复元素,永远放在最右边,从而保证排序的稳定性
如果从前向后排序,重复元素的顺序,刚好相反,所以就不是稳定的算法,但如果不关注稳定性,那么结果还是正确的
保证相同的数字还是按照原数组中的顺序,保证稳定性
比如[1,2,3,4,5,5,5]
最后从后向前遍历原数组,3个5的输出顺序还是跟原数组的顺序是一致的
如果是从前向后输出,3个5的位置正好倒过来了,因为最终排序的索引是通过计数来得到的,计数是从大到小的,所以最后相同值的索引位置的计算是从大到小的,也就是说相同值的索引位置是从后往前的,如果顺序遍历原数组,遇到几个相同的数字,会先把前面的数字先放到后面了
特点 O(n)时间复杂度
适用场景 数组取值范围是常数范围。