前端面试算法篇——各种排序算法

316 阅读3分钟

1.选择排序

图片转自 www.runoob.com/w3cnote/sel…

function selectSort(arr){
  for(let i = 0;i < arr.length - 1;i++){
    let min_index = i
    for(let j = i + 1; j < arr.length;j++){
      if(arr[j]<arr[min_index]){
        min_index = j
      }
    }
    let tmp = arr[i]
    arr[i] = arr[min_index]
    arr[min_index] = tmp
  }
  return arr
}
selectSort(array)

对数组中的前n-1个数进行遍历,对于每一次遍历初始化最小值坐标为当前坐标,之后再对当前坐标之后的所有数进行遍历,不断更新最小值坐标。获得当前位置最小值坐标后,交换当前坐标的数与最小值坐标的数。这样就可以逐次放置好最小的,第二小的,第三小的...直到第n-1小的,最后一个位置自然放置的是第n小的也就是最大的。时间复杂度为稳定的O(n²),因为无论之前的顺序是怎样,每次更新min_index都要从当前位置遍历到最后一位。

2.插入排序

图片转自 www.runoob.com/w3cnote/ins…

function insertSort(arr){
  for(let i = 1;i < arr.length; i++){
    let j = i - 1 //已经有序的最后一个坐标
    let cur = arr[i] //把当前需要插入的值记录下来
    while(arr[j]>cur && j >= 0){ //从后往前遍历,如果比cur大的,就向后推一位
      arr[j+1] = arr[j]
      j -= 1
    }
    arr[j+1] =cur // 此时j指向比cur小的数,j+1,j+2相同,指向比cur大的数,所以把cur的值放在j+1的位置上
  
}
  return arr
}

将数组的第i个数字之前视为有序,从第i+1个元素开始扫描并插入到在它前面已经有序的排列中。注意代码中插入部分的写法。算法复杂度,最差的情况是完全逆序,每次插入都要比较到第0位,复杂度为O(n²);最优的情况是已经正序,每次插入只需比较一次,复杂度为O(n)。

3.冒泡排序

图片转自 www.runoob.com/w3cnote/bub…

function bubbleSort(arr){
  for(let i = 0; i < arr.length; i++){ //i可以理解为目前已经排序好了前i大的数
    for(let j = 0; j < arr.length-1-i;j++){ //arr.length-1-i指排序好的数的第之前2位的坐标,因为每次是两两比较,所以取不到排序好的数的之前一位
      if(arr[j]>arr[j+1]){
        let temp = arr[j]
        arr[j] = arr[j+1] 
        arr[j+1] = temp
      }
    }
  }
  return arr
}

数组中两两数比较,如果前面的比后面的大,则交换位置,每一大轮可以获得当前未排序完成的数中最大的数。时间复杂度稳定为O(n²)。

4.归并排序

图片转自 en.wikipedia.org/wiki/Merge_…

function mergeSort(arr){
  if(arr.length<=1){ //base case
    return arr
  }
  let mid = Math.floor(arr.length/2) 
  let left = mergeSort(arr.slice(0,mid)) //不停的将问题分解成更小的问题
  let right = mergeSort(arr.slice(mid))
  return merge(left,right)

}

function merge(arr1,arr2){ //经典的使用双指针法合并两有序数组
  let res = []
  let i = 0
  let j = 0
  while(i < arr1.length && j < arr2.length){
    if(arr1[i]<arr2[j]){
      res.push(arr1[i])
      i++
    }else{
      res.push(arr2[j])
      j++
    }}
  res = res.concat(arr1.slice(i),arr2.slice(j))
  return res
}

归并排序使用了经典的分治法(Divide and Conquer)思路。将一个大的问题分解成能够解决的小问题,再把小问题的答案合并出大问题的答案。归并排序的思路是,先将一个数组分成两部分(习惯上取中点分开),对每一部分递归调用归并排序(即将分开的每一半再分成两部分,直到base case),再将已经通过归并排序排序好的两部份,通过双指针合并两有序数组得到答案。时间复杂度分析,将一个长度为n的数组通过每次取一半的方式分解成长度为1的数组,时间复杂度为O(logn),而每次合并数组需要的时间复杂度为O(n),总共需要O(logn)次的合并,所以总体时间复杂度为O(nlogn),这个时间复杂度是稳定的。

5.快速排序

图片转自 www.runoob.com/w3cnote/qui…

function quickSort(arr){
  qs_helper(arr,0,arr.length-1)
}

function qs_helper(arr,left,right){
  if(left >= right){
    return 
  }
  let pivot = arr[Math.floor((left+right)/2)]
  let i = left
  let j = right
  while(i <= j){ //在头尾双指针没有相遇前,不断寻找左边比pivot大的数字的坐标,右边比pivot小的数字的坐标,然后在数组中交换这两个数
    while(i<=j && arr[i]<pivot){
      i += 1
    };
    
    while(i<=j && arr[j]>pivot){
      j -= 1
    };
    
    if(i <= j){
      let tmp = arr[i]
      arr[i] = arr[j]
      arr[j] = tmp
      i += 1
      j -= 1
    }
  }
  qs_helper(arr,left,j)
  qs_helper(arr,i,right)
}

快速排序同样使用了分而治之的思想,在每一次选择一个基准值(随意选择,通常为中点值),通过前后双指针i,j将左边大于pivot的值与右边小于pivot的值交换,直到左指针越过右指针。此时再分别递归地对所有小于基准值的和大于基准值的数进行快排,直到出现长度为1的递归出口。时间复杂度分析,最差情况是每次选择的基准值都是最大值或者最小值,这样每次排序只能获得一个最大值或最小值,相当于是冒泡排序,递归次数O(n),每次排序O(n),最差时间复杂度O(n²);最优的情况是,每次的基准值都是中位数,这样每次都可以将当前数组的一半部分有序,递归次数O(logn),每次排序O(n),最优时间复杂度O(nlogn)。

快速排序的最坏运行情况是 O(n²),比如说顺序数列的快排。但它的平摊期望时间是 O(nlogn),且 O(nlogn) 记号中隐含的常数因子很小,比复杂度稳定等于 O(nlogn) 的归并排序要小很多。所以,对绝大多数顺序性较弱的随机数列而言,快速排序总是优于归并排序。

----《算法艺术与信息学竞赛》

6.堆排序

要理解堆排序首先要明白几个概念。

完全二叉树:若设二叉树的深度为h,除第 h 层外,其它各层 (1~h-1) 的结点数都达到最大个数,第 h 层所有的结点都连续集中在最左边,这就是完全二叉树(百度百科)。 换言之,如果要在完全二叉树上新增一个节点,必须沿从左往右的顺序逐一加入。

:堆通常是一个可以被看做一棵完全二叉树的数组对象(百度百科)。根据完全二叉树的性质,如果对完全二叉树做宽度优先遍历(BFS),则正好是一个无空值的连续数组。根结点为数组0位置,根结点的左右子节点分别对应索引1,索引2,以此类推...

最大堆:又叫大顶堆(这个名字太土味了)。最大堆所对应的完全二叉树的所有节点的值都大于等于它的子节点的值。

下面将分步骤的实现堆排序:

function swap(arr,i,j){
  let tmp = arr[i]
  arr[i] = arr[j]
  arr[j] = tmp
}


function heapify(arr,n,i){ //堆调整,n是节点个数,i是要被调整的节点坐标,将i位置的数字下沉到满足条件的位置
  if(i>=n){
    return 
  }
  let left = 2 * i + 1
  let right = 2 * i + 2 //左右子节点与父节点的序号数学关系
  let lgest = i //初始化根左右三节点中的最大值序号为根结点序号
  if(left<n && arr[left]>arr[lgest]){ //左子存在且对应的值大于当前lgest对应的值,则更新lgest
    lgest = left
  }
  if(right<n && arr[right]>arr[lgest]){
    lgest = right
  }
  if(lgest !== i){ //在更新lgest后,如果证明最大的不是根结点,则需要交换根结点和最大值,即把i位置的数字下沉
    swap(arr,lgest,i)
    heapify(arr,n,lgest) //递归调用,继续下沉
  }
}

let test = [1,2,3,4,5,6,7]
heapify(test,7,0)
console.log(test) //这个例子中,0位置的1被下沉到了最后的位置 
function build_max_heap(arr,n){
  let last = n-1
  let last_parent = Math.floor((n-1)/2)
  for(let i = last_parent; i > -1; i--){ //从最后一个父节点一直到根结点,逐一做堆调整,便生成了最大堆
    heapify(arr,n,i)
  }
}
function heapSort(arr){ 
  let n = arr.length
  build_max_heap(arr,n) //建最大堆后,每次将第一个(当前最大)与最后一个交换,pop出来,然后heapify新的根节点,新的最大值此时回到头节点,重复以上操作
  for(let i = n - 1; i > -1; i--){
    swap(arr,0,i)
    heapify(arr,i,0) //其实来到最后一位的最大值不需要pop出来,只要在堆调整时缩减节点总数,不把已排序好的后面几位考虑在内即可,这也是为什么第二个参数是i而不是n
  }
}

堆排序的具体操作和理解在以上的代码和注释中。总而言之,堆排序就是首先把一个数组建立成最大堆,然后不断的交换最大堆的头节点值和尾节点值,再对未排序的部分的头节点重新做堆调整,直到完成所有的排序。时间复杂度分析,首先堆调整的时间复杂度为O(logn),原因是下沉过程中的比较和交换次数与层数成正比,二叉树的层数与元素个数自然是对数关系,建立最大堆过程需要对n个元素分别进行堆调整,自然是O(nlogn)的复杂度。堆排序的过程中,先进行堆调整,再进行n-1次的交换和堆调整,所以O(2nlogn)=O(nlogn),总的时间复杂度是稳定的O(nlogn)。

堆排序flash动画可以帮助理解

opendsa-server.cs.vt.edu/ODSA/Books/…

如果您对任何内容有异议或建议,请随时指出交流,非常感谢!

待续...