作者:前端实习生鲸落
Github:鲸落(github.com)
归并排序
基本思路
- 它的基本思想是将待排序数组分成若干个子数组。
- 然后将相邻的子数组归并成一个有序数组。
- 最后再将这些有序数组归并(merge)成一个整体有序的数组。
实现步骤
-
归并排序是一种基于分治思想的排序算法,其基本思路可以分为三个步骤:
-
步骤一:分解(Divide)。归并排序使用递归算法来实现分解过程,具体实现中可以分为以下几个步骤
- 如果待排序数组长度为1,认为这个数组已经有序,直接返回;
- 将待排序数组分成两个长度相等的子数组,分别对这两个子数组进行递归排序
- 将两个排好序的子数组合并成一个有序数组,返回这个有序数组。
-
步骤二:合并(Merge)。合并过程中,需要比较每个子数组的元素并将它们有序地合并成一个新的数组:
- 可以使用两个指针i和j分别指向两个子数组的开头,比较它们的元素大小,并将小的元素插入到新的有序数组中。
- 如果其中一个子数组已经遍历完,就将另一个子数组的剩余部分直接插入到新的有序数组中。
- 最后返回这个有序数组。
-
步骤三:归并排序的递归终止条件
- 归并排序使用递归算法来实现分解过程,当子数组的长度为1时,认为这个子数组已经有序,递归结束。
代码实现
时间复杂度:O(n logn)
function mergeSort(arr){
// 1.3 递归结束条件
if(arr.length <= 1) return arr
const n = arr.length
// 1 分解:对数组进行分解(递归)
// 1.1 切割操作
const mid = Math.floor(n/2)
const leftArr = arr.slice(0, mid)
// 不写slice(mid, n)就默认从mid切割到末尾
const rightArr = arr.slice(mid)
// 1.2 递归切割
const newLeftArr = mergeSort(leftArr)
const newRightArr = mergeSort(rightArr)
// 当我们可以执行下述代码的时候,上述的newArr数组只有一个数了
// 2.合并:将两个子数组进行合并(双指针)
// 2.1 定义双指针
let newArr = []
let i = j = 0
while( i < newLeftArr.length && j < newRightArr.length ){
// 2.2 比较大小
if(newLeftArr[i] <= newRightArr[j]){
newArr.push(newLeftArr[i])
i++
}else{
newArr.push(newRightArr[j])
j++
}
}
// 结束条件是一边相等,为length结束,因为取值最多取到length-1
// 2.3 其中一个子数组已经遍历完,另一个子数组的可能还有剩余部分,直接插入到新的有序数组中
// 循环完左边有剩余
if( i < newLeftArr.length ){
newArr.push(...newLeftArr.slice(i))
}
// 循环完右边有剩余
if( j < newLeftArr.length ){
newArr.push(...newRightArr.slice(j))
}
// 3.返回新数组
return newArr
}
时间复杂度
-
复杂度的分析过程:
- 假设数组长度为n,需要进行logn次归并操作
- 每次归并操作需要o(n)的时间复杂度
- 因此,归并排序的时间复杂度为O(nlogn)
-
最好情况:o(log n)
- 最好情况下,待排序数组已经是有序的了,那么每个子数组都只需要合并一次,即只需要进行一次归并操作。
- 因此,此时的时间复杂度是O(log n)。
-
最坏情况:O(nlogn)
- 最坏情况下,待排序数组是逆序的,那么每个子数组都需要进行多次合并。
- 因此,此时的时间复杂度为o(nlogn)。
-
平均情况:O(nlogn)
- 在平均情况下,我们假设待排序数组中任意两个元素都是等概率出现的。
- 此时,可以证明归并排序的时间复杂度为O(nlogn)。
快速排序
基本思路
快速排序(Quick Sort)是一种基于分治思想的排序算法:
- 基本思路是将一个大数组分成两个小数组,然后递归地对两个小数组进行排序。
- 具体实现方式是通过选择一个基准元素(pivot),将数组分成左右两部分,左部分的元素都小于或等于基准元素,右部分 的元素都大于基准元素。
- 然后,对左右两部分分别进行递归调用快速排序,最终将整个数组排序。
实现步骤
- 1.首先,我们需要选择一个基准元素,通常选择第一个或最后一个元素作为基准元素。
- 2.然后,我们定义两个指针i和j,分别指向数组的左右两端。
- 3.接下来,我们从右侧开始,向左移动j指针,直到找到一个小于或等于基准元素的值
- 4.然后,我们从左侧开始,向右移动i指针,直到找到一个大于或等于基准元素的值
- 5.如果i指针小于或等于j指针,交换i和j指针所指向的元素
- 6.重复步骤3-5,直到i指针大于j指针,这时,我们将基准元素与i指针所指向的元素交换位置,将基准元素放到中间位置
- 7.接着,我们将数组分为两部分,左侧部分包含小于或等于基准元素的元素,右侧部分包含大于基准元素的元素
- 8.然后,对左右两部分分别进行递归调用快速排序,直到左右两部分只剩下一个元素
- 9.最终,整个数组就变得有序了
代码实现
时间复杂度:O(n logn)
// 第一次交换
function quickSort(arr){
const n = arr.length
//初始划分区域
position(0, n-1)
function position(left, right){
// 1.找到基准元素
const pivot = arr[right]
// 2.双指针:目的是左边都是比pivot小的数字,右边都是比pviot大的数字
let i = left
let j = right - 1 // right是基准,前一个
// 3.开始寻找对比
while(arr[i] < pivot){
// 找比pivot大的数,没有就继续++
i++
}
while(arr[j] > pivot){
// 找比pivot小的数,没有就继续--
j--
}
// 来到这,说明我们已经找到了,然后交换,并继续++--
if( i <= j){
[ arr[i], arr[j] ] = [ arr[j], arr[i] ]
i++
j--
}
}
return arr
}
//循环上述代码
function quickSort(arr){
const n = arr.length
//初始划分区域
position(0, n-1)
function position(left, right){
// 结束条件
if(left >= right) return
// 1.找到基准元素
const pivot = arr[right]
// 2.双指针:目的是左边都是比pivot小的数字,右边都是比pviot大的数字
let i = left
let j = right - 1 // right是基准,前一个
// 3.开始寻找对比:i>j的时候停止
while(i <= j){
while(arr[i] < pivot){
// 找比pivot大的数,没有就继续++
i++
}
while(arr[j] > pivot){
// 找比pivot小的数,没有就继续--
j--
}
// 来到这,说明我们已经找到了,然后交换,并继续++--
if( i <= j){
[ arr[i], arr[j] ] = [ arr[j], arr[i] ]
i++
j--
}
}
// 4. 交换基准元素与i位置的元素
[ arr[i], arr[right] ] = [ arr[right], arr[i] ]
//5.继续划分(递归调用)
position(left, i-1)
// position(left, j)
position(i+1, right) // i位置是之前的基准元素换到这里,已经固定了
}
return arr
}
时间复杂度
-
快速排序的时间复杂度主要取决于基准元素的选择、数组的划分、递归深度等因素。
-
下面是快速排序的复杂度算法分析过程:
-
最好情况:O(nlogn)
- 当每次划分后,两部分的大小都相等,即基准元素恰好位于数组的中间位置,此时递归的深度为O(log n)
- 每一层需要进行n次比较,因此最好情况下的时间复杂度为O(nlogn)。
-
最坏情况:O(n^2)
- 当每次划分后,其中一部分为空,即基准元素是数组中的最大或最小值,此时递归的深度为o(n)。
- 每一层需要进行n次比较,因此最坏情况下的时间复杂度为0(n^2)。
- 需要注意的是,采用三数取中法或随机选择基准元素可以有效避免最坏情况的发生。
-
平均情况:O(nlogn)
- 在平均情况下,每次划分后,两部分的大小大致相等,此时递归的深度为o(log n)
- 每一层需要进行大约n次比较,因此平均情况下的时间复杂度为O(nlogn)。
-
需要注意的是,快速排序是一个原地排序算法,不需要额外的数组空间。
堆排序
基本思路
-
堆排序(Heap Sort)是堆排序是一种基于比较的排序算法,它的核心思想是使用二叉堆来维护一个有序序列。
- 二叉堆是一种完全二叉树,其中每个节点都满足父节点比子节点大(或小)的条件。
- 在堆排序中,我们使用最大堆来进行排序,也就是保证每个节点都比它的子节点大。
-
堆排序和选择排序有一定的关系,因为它们都利用了“选择”这个基本操作。
- 选择排序的基本思想是在待排序的序列中选出最小(或最大)的元素,然后将其放置到序列的起始位置。
- 堆排序也是一种选择排序算法,它使用最大堆来维护一个有序序列,然后不断选择出最大的值。
实现步骤
在堆排序中,我们首先构建一个最大堆。
- 然后,我们将堆的根节点(也就是最大值)与堆的最后一个元素交换,这样最大值就被放在了正确的位置上。
- 接着,我们将堆的大小减小一,并将剩余的元素重新构建成一个最大堆。
- 我们不断重复这个过程,直到堆的大小为1。
- 这样,我们就得到了一个有序的序列。
代码实现
堆排序的两大步骤:构建最大堆和排序
function foo(arr){
const n = arr.length
// 原地建堆
const start = Math.floor( n / 2 - 1 )
for(let i = start; i >= 0;i--){
heapDown(arr, n, i)
}
// 思路
// 1. 对最大堆进行排序操作
// [ arr[0], arr[n-1] ] = [ arr[n-1], arr[0] ]
// heapDown(arr, n-1, 0)
// 2. 我们只需要n-1个了下一次,然后对n-1个的数组进行下滤操作
for (let i = n-1; i >= 0; i--) {
[ arr[0], arr[i] ] = [ arr[i], arr[0] ]
heapDown(arr, i, 0)
}
return arr
}
/**
*
* @param {*} arr 数组
* @param {*} n 范围/长度
* @param {*} index 对哪个位置间下滤
*/
function heapDown(arr, n, index){
while( 2 * index + 1 < n ){
let leftChildIndex = index * 2 + 1
let rightChildIndex = leftChildIndex + 1
let largerIndex = leftChildIndex
if(rightChildIndex < n && arr[rightChildIndex] > arr[leftChildIndex]){
largerIndex = rightChildIndex
}
if(arr[index] >= arr[largerIndex]){
break;
}
[ arr[index], arr[largerIndex] ] = [ arr[largerIndex], arr[index] ]
index = largerIndex
}
}
时间复杂度
-
堆排序的时间复杂度分析较为复杂,因为它既涉及到堆的建立过程 也涉及到排序过程。
-
下面我们分别对这两个步骤的时间复杂度进行分析。
-
步骤一:堆的建立过程
- 堆的建立过程包括n/2次堆的向下调整操作,每次调整的时间复杂度logn,因此它的时间复杂度为o(n log n)。
-
步骤二:排序过程
- 排序过程需要执行n次堆的删除最大值操作,每次操作都需要将堆的最后一个元素与堆顶元素交换,然后向下调整堆。
- 每次向下调整操作的时间复杂度为o(log n),因此整个排序过程的时间复杂度为o(n log n)。
-
综合起来,堆排序的时间复杂度为o(n log n)。
-
需要注意的是,堆排序的空间复杂度为o(1),因为它只使用了常数个辅助变量来存储堆的信息。
补充
其他文章推荐:
浏览器输入url会发生什么 - 巨详细完整版 - 掘金 (juejin.cn)
浏览器输入url会发生什么 - 巨详细完整版续集 - 掘金 (juejin.cn)
面试必会面试题之手写冒泡、选择、插入排序 - 掘金 (juejin.cn)