本篇是承数据结构与算法指北的内容篇。为了更详细的了解排序而单独开篇。
排序
如何分析一个排序算法
排序算法的执行效率
-
最好情况、最坏情况、平均情况时间复杂度 我们在分析排序算法的时间复杂度时,要分别给出最好情况、最坏情况、平均情况下的时间复杂度。除此之外,你还要说出最好、最坏时间复杂度对应的要排序的原始数据是什么样的。
-
时间复杂度的系数、常数 、低阶
我们知道,时间复杂度反映的是数据规模n很大的时候的一个增长趋势,所以它表示的时候会忽略系数、常数、低阶。但是实际的软件开发中,我们排序的可能是10个、100个、1000个这样规模很小的数据,所以,在对同一阶时间复杂度的排序算法性能对比的时候,我们就要把系数、常数、低阶也考虑进来。
- 比较次数和交换(或移动)次数 基于比较的排序算法的执行过程,会涉及两种操作,一种是元素比较大小,另一种是元素交换或移动。所以,如果我们在分析排序算法的执行效率的时候,应该把比较次数和交换(或移动)次数也考虑进去。
排序算法的内存消耗
算法的内存消耗可以通过空间复杂度来衡量,排序算法也不例外。
不过,针对排序算法的空间复杂度,有一个新的概念,原地排序(Sorted in place)。原地排序算法,就是特指空间复杂度是O(1)的排序算法。
排序算法的稳定性
仅仅用执行效率和内存消耗来衡量排序算法的好坏是不够的。针对排序算法,还有一个重要的度量指标,稳定性。这个概念是说,如果待排序的序列中存在值相等的元素,经过排序之后,相等元素之间原有的先后顺序不变。
冒泡排序
冒泡排序只会操作相邻的两个数据。每次冒泡操作都会对相邻的两个元素进行比较,看是否满足大小关系要求。如果不满足就让它俩互换。一次冒泡会让至少一个元素移动到它应该在的位置,重复n次,就完成了n个数据的排序工作。
- 冒泡的过程只涉及相邻数据的交换操作,只需要常量级的临时空间,所以它的空间复杂度为O(1),是一个原地排序算法。
- 在冒泡排序中,只有交换才可以改变两个元素的前后顺序。为了保证冒泡排序算法的稳定性,当有相邻的两个元素大小相等的时候,我们不做交换,相同大小的数据在排序前后不会改变顺序,所以冒泡排序是稳定的排序算法。
- 最好情况下,要排序的数据已经是有序的了,我们只需要进行一次冒泡操作,就可以结束了,所以最好情况时间复杂度是O(n)。而最坏的情况是,要排序的数据刚好是倒序排列的,我们需要进行n次冒泡操作,所以最坏情况时间复杂度为O(n2)。
插入排序
一个有序的数组,我们往里面添加一个新的数据后,如何继续保持数据有序呢?很简单,我们只要遍历数组,找到数据应该插入的位置将其插入即可。
插入排序(点击
// 有序度是数组中具有有序关系的元素对的个数。
// 2,4,3,1,5,6 这组数据的有序度为11.
// 冒泡排序的交换次数为逆序度。
// 插入排序移动元素的次数也为逆序度
// 但冒泡排序需要三个赋值操作(元素交换)
// 插入排序只需要一个。
// 插入排序. n^2
const insert_sort = (arr: number[]) => {
if(arr.length <= 1) return arr
for(let i = 1; i < arr.length; i++){
let j = i - 1
let val = arr[i]
while(j >= 0){
if(arr[j] > val){
arr[j+1] = arr[j--]
} else {
break
}
}
//j自减,需要+1
arr[j+1] = val
}
return arr
}
- 过程可以很明显地看出,插入排序算法的运行并不需要额外的存储空间,所以空间复杂度是O(1),也就是说,这是一个原地排序算法。
- 在插入排序中,对于值相同的元素,我们可以选择将后面出现的元素,插入到前面出现元素的后面,这样就可以保持原有的前后顺序不变,所以插入排序是稳定的排序算法。
- 如果要排序的数据已经是有序的,我们并不需要搬移任何数据。如果我们从尾到头在有序数据组里面查找插入位置,每次只需要比较一个数据就能确定插入的位置。所以这种情况下,最好是时间复杂度为O(n)。注意,这里是从尾到头遍历已经有序的数据。如果数组是倒序的,每次插入都相当于在数组的第一个位置插入新的数据,所以需要移动大量的数据,所以最坏情况时间复杂度为O(n2)。
选择排序
选择排序算法的实现思路有点类似插入排序,也分已排序区间和未排序区间。但是选择排序每次会从未排序区间中找到最小的元素,将其放到已排序区间的末尾。
- 序空间复杂度为O(1),是一种原地排序算法。选择排序的最好情况时间复杂度、最坏情况和平均情况时间复杂度都为O(n2)。
- 序是一种不稳定的排序算法。选择排序每次都要找剩余未排序元素中的最小值,并和前面的元素交换位置,这样破坏了稳定性。
归并排序
归并排序的核心思想就是将数组分成前后两部分,将前后两部分分别排序。再将两个排序好的部分合并,合成的数组就有序了。
归并排序--数组(点击
// 合并俩个有序数组。
const merge = (arr1: number[], arr2: number[]) => {
const sortedArr = []
let i = 0, j = 0
while(i < arr1.length || j < arr2.length) {
if(i === arr1.length){
sortedArr.push(...arr2.slice(j))
//结束循环,将剩下的全部放入数组
break
}
if(j === arr2.length){
sortedArr.push(...arr1.slice(i))
break
}
arr1[i] > arr2[j] ? sortedArr.push(arr2[j++]) : sortedArr.push(arr1[i++])
}
return sortedArr
}
// 归并排序
const merge_sort = (arr: number[]) => {
const _merge_sort = (_arr: number[], startIndex: number, endIndex: number) => {
if(startIndex >= endIndex) return [_arr[endIndex]]
const middleIndex = Math.floor((endIndex + startIndex) / 2)
return merge(_merge_sort(_arr, startIndex, middleIndex), _merge_sort(_arr, middleIndex + 1, endIndex))
}
return _merge_sort(arr, 0, arr.length - 1)
}
归并排序--链表(点击
// 合并俩个有序数组。
const mergeList = (head1, head2) => {
const header = new ListNode(null)
let cur = header
while(head1 && head2) {
if(head1.val >= head2.val) {
cur.next = head2
head2 = head2.next
} else {
cur.next = head1
head1 = head1.next
}
cur = cur.next
}
if(!head1) cur.next = head2
if(!head2) cur.next = head1
return header.next
}
// 归并排序
function sortList(head: ListNode | null): ListNode | null {
if(!head?.next) return head
const header = new ListNode(null, head)
let slow = header
let fast = header
while(fast?.next) {
slow = slow.next
fast = fast.next.next
}
const right = sortList(slow.next)
slow.next = null
const left = sortList(head)
return mergeList(right, left)
};
- 归并排序稳不稳定关键要看merge()函数,也就是两个有序子数组合并成一个有序数组的函数。在值相同的情况下保证顺序不变,归并排序可以写成稳定算法。
- 归并排序的执行效率与要排序的原始数组的有序程度无关,所以其时间复杂度是非常稳定的,不管是最好情况、最坏情况,还是平均情况,时间复杂度都是O(nlogn)。
- 归并排序的合并函数,在合并两个有序数组为一个有序数组时,需要借助额外的存储空间。归并排序的空间复杂度是O(n)
快速排序
快速排序算法的思想是:在数组中任意选一个数字作为锚点,遍历数字,将大于锚点的数字放左边,小于锚点的放右边,直至区间为1,整个数组就排序好了。
快速排序的算法(点击
// 快速排序 nlogn
const quick_sort = (arr: number[]) => {
if(arr.length <= 1) return arr
const index = Math.floor(arr.length / 2)
const point = arr.splice(index, 1)[0]
const less = arr.filter(v => v <= point)
const greater = arr.filter(v => v > point)
return [...quick_sort(less), point, ...quick_sort(greater)]
// 原地快排(不占用额外内存,非稳定排序)
// if(arr.length <= 1) return arr
// const index = Math.floor(arr.length / 2)
// const point = arr[index]
// let j = 0
// for(let i = 0; i < arr.length; i++){
// if(arr[i] < point){
// // 从j=0开始交换,j下标之前的,都是比point小的
// [arr[j], arr[i]] = [arr[i], arr[j]]
// j++
// }
// }
// return [...quick_sort(arr.slice(0, j)), point, ...quick_sort(arr.slice(j + 1, arr.length))]
}
桶排序
核心思想是将要排序的数据分到几个有序的桶里,每个桶里的数据再单独进行排序。桶内排完序之后,再把每个桶里的数据按照顺序依次取出,组成的序列就是有序的了。 如果要排序的数据有n个,我们把它们均匀地划分到m个桶内,每个桶里就有k=n/m个元素。每个桶内部使用快速排序,时间复杂度为O(k * logk)。m个桶排序的时间复杂度就是O(m * k * logk),因为k=n/m,所以整个桶排序的时间复杂度就是O(n*log(n/m))。当桶的个数m接近数据个数n时,log(n/m)就是一个非常小的常量,这个时候桶排序的时间复杂度接近O(n)。
计数排序
计数排序其实是桶排序的一种特殊情况。当要排序的n个数据,所处的范围并不大的时候,比如最大值是k,我们就可以把数据划分成k个桶。每个桶内的数据值都是相同的,省掉了桶内排序的时间。
计数排序(点击
// 计数排序 n
const count_sort = (arr: number[]) => {
const max = Math.max(...arr)
const c = new Array(max + 1).fill(0)
// 计数
arr.forEach(i => c[i] ? c[i] += 1 : c[i] = 1)
// 计数求和
for(let i = 1; i < c.length; i++) {
c[i] = c[i] + c[i-1]
}
const result = []
// 倒序遍历,保证排序的稳定性
for(let i = arr.length -1; i >= 0; i--) {
result[ --c[arr[i]] ] = arr[i]
}
return result
}