算法和数据结构:JS 实现各种排序算法

175 阅读2分钟

提醒:默认都是从小到大排。

冒泡排序

每一轮把未排完序部分的最大数放在该部分的最后(当然,也可以每次把最小的放最前面)。

let bubbleSort = function (array) {
    let length = array.length
    for (let i = length - 1; i > 0; i--) {
        for (let j = 0; j < i; j++) {
            if (array[j] > array[j + 1]) {
                [array[j], array[j + 1]] = [array[j + 1], array[j]] //看不懂请搜索 ES6 解构赋值
            }
        }
    }
    return array
};

时间复杂度:O(N2)

空间复杂度:O(1)

选择排序

选择排序的比较过程与冒泡排序一致。但是,在遍历的过程中会选取出最大数及其下标,直到本轮遍历完再做交换,所以选择排序相对冒泡排序的好处是交换操作的次数减少了

let selectSort = function (array) {
    let length = array.length
    for (let i = length - 1; i > 0; i--) {
        let max = array[0]
        let index = 0
        for (let j = 1; j <= i; j++) {
            if (max < array[j]) {
                max = array[j]
                index = j
            }
        }
        [array[index], array[i]] = [array[i], array[index]]
    }
    return array
};

时间复杂度:O(N2)

空间复杂度:O(1)

插入排序

局部有序思想,从下标 0 开始,每遍历到一个数就把它与左边的数进行比较和交换,直到放入正确的位置,那么每遍历到一个数,它的左边都是有序的,这与我们平时打牌时放牌的习惯是一致的。

let insertSort = function (array) {
    let length = array.length
    for (let i = 1; i < length; i++) { //1开始是因为1个数就不需要比较插入了
        let current = i
        while (current > 0 && array[current] < array[current - 1]) {
            [array[current], array[current - 1]] = [array[current - 1], array[current]]
            current--
        }
    }
    return array
};

当然,对于局部有序部分的比较插入,使用二分查找应该会更有效率,插入操作可以用 splice 方法进行删除一次和插入一次,大家可以自己试一下。

插入排序最坏的情况时与冒泡排序基本一致,也就是每一次都要完整的比较完的情况,比如 [7,6,5,4,3,2,1]

时间复杂度:最坏O(N2),不稳定

空间复杂度:O(1)

希尔排序

其思想是按间隔进行分组,然后各自排序,而间隔是先大后小,直到间隔为 1,每组的排序过程使用选择排序完成。

间隔比较的过程,举个例子,比如数组 [2, 5, 4, 3, 4, 1],以 2 为间隔划分得到 [4, 5, 2, 3,  6, 1] ,就是正常字体的数为一组,加粗斜体的数为一组,各自进行比较和交换,得到 [2, 1, 4, 3,  6, 5],也就是就是每次排完,都是等间隔有序,排完之后将间隔减小再排序,直到间隔为1在排一次序,就能保证全部有序了。

这里以初始间隔为数组长度的一半,每次间隔减半为例,给出代码:

let shellSort = function (array) {
    let length = array.length
    let gap = Math.floor(length / 2) //初始间隔
    while (gap >= 1) {
        for (let i = gap; i < length; i++) {
            let current = i
            while (current >= gap && array[current] < array[current - gap]) {
                [array[current], array[current - gap]] = [array[current - gap], array[current]]
                current = current - gap
            }
        }
        gap = Math.floor(gap / 2) //间隔减小
    }
    return array
};

时间复杂度:与间隔取法有关,介于 O(N1) 到 O(N2) 之间。

空间复杂度:O(1)

快速排序

原始方法

其思想是分而治之,选取某一个数(中间枢纽),可以在每一次比较过程中把小于这个数的所有数放在它的左边,把大于这个数的所有数放在它的右边,也就是让这个数在本次操作中就处于最终排完序的位置,然后左右各自递归该操作。

关键步骤就是如何将一个选定好的数,放在其最终排完序的位置上。我们先来实现完成整个步骤的需要递归的函数。

首先确定中间枢纽的取法,选择数组最左边、最右边以及中间的 3 个数,对其进行排序和交换,取位于中间的数为中间枢纽,将其放在数组的最右边。

function pivot(arr, left, mid, right) {    if (arr[left] > arr[mid]) {        swap(arr, left, mid)    }    if (arr[left] > arr[right]) {        swap(arr, left, right)    }    if (arr[mid] < arr[right]) {        swap(arr, mid, right)    }}

然后,通过双指针,分别从下标 0 和 length - 2 开始累加和累减,左指针找到比枢纽大的数,右指针找到比枢纽小的数,二者进行交换,直到左指针大于右指针。显然,此时左指针找到的是目前最靠左的并且大于中间枢纽的数,将其与枢纽进行交换,就达成了将枢纽放在正确位置的操作。

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

function quick(arr, left, right) {
    if (left >= right) return
    let mid = Math.floor(left + (right - left) / 2)
    pivot(arr, left, mid, right)
    let i = left
    let j = right - 1
    while (true) {
        while (arr[i] < arr[right]) {
            i++
        }
        while (arr[j] > arr[right]) {
            j--
        }
        if (i < j) {
            swap(arr, i, j)
            i++
            j--
        } else {
            break
        }
    }
    //最多就是i=right
    swap(arr, i, right)
    //分而治之
    quick(arr, left, i - 1)
    quick(arr, i + 1, right)
}

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

这里再举个例子,以便理解,以数组 [3, 2, 1, 5, 7, 4, 6] 为例,

选取三个数:[3, 2, 1, 5, 7, 4, 6]

选择枢纽为 5,放在数组最右边:[3, 2, 1, 6, 7, 4, 5]

左指针++,找到比枢纽大的数:6 

右指针--,找到比枢纽小的数:4

交换:[3, 2, 1, 4, 7, 6, 5]

左指针++,找到比枢纽大的数:7

右指针--,找到比枢纽小的数:4

左指针 > 右指针,枢纽与左指针进行交换:[3, 2, 1, 4, 5, 6, 7]

这样,就完成了一轮排序,使得中间枢纽左边小于它,右边大于它,中间枢纽所在的位置就是它最终排完序所处的位置。接下来只需要对中间枢纽左边和右边分别递归上述操作即可。

时间复杂度:以二分的速度划分,平均 O(NlogN)

空间复杂度:O(1)

偷懒的写法,需要牺牲空间复杂度。

let quickSort = function(array) {
  if (array.length <= 1) {
    return array;
  }
  let pivotIndex = Math.floor(array.length / 2);
  let pivot = array.splice(pivotIndex, 1)[0];
  let left = [];
  let right = [];
  for (let i = 0; i < array.length; i++) {
    if (array[i] < pivot) {
      left.push(array[i])
    } else {
      right.push(array[i])
    }
  }
  return [...quickSort(left), pivot, ...quickSort(right)]
}

时间复杂度:O(NlogN)

空间复杂度:O(N)

归并排序

思路就是递归的进行左右分组再归并,用递归的回归过程看就是先两两数字进行归并,然后以归并完的最小单元再两两分组进行归并,以此类推, 直到最外层的归并完成。

let mergeSort = (arr) => {  if (arr.length <= 1) {
    return arr;
  }
  let mid = Math.floor(arr.length / 2);
  let left = arr.slice(0, mid);
  let right = arr.slice(mid);
  return merge(mergeSort(left), mergeSort(right));
};

//左右归并函数
let merge = (left, right) => {
  if (left.length === 0) {
    return right;
  }
  if (right.length === 0) {
    return left;
  }
  return left[0] < right[0] ? [left[0]].concat(merge(left.slice(1), right)) 
                            : [right[0]].concat(merge(right.slice(1), left));
};

时间复杂度:T[n] = 2T[n/2] + O(n),即 O(NlogN)

空间复杂度:O(N)

计数排序

牺牲空间换时间。

遍历的过程中,用哈希表存放出现过的数字及其出现的次数,并记录所有出现的数字中最小的和最大的数,最后,从最小的数到最大的数依次放入数组中。

let countSort = (arr) => {
  if (arr.length < 2) return arr;
  let hashTable = {},
    max = arr[0],
    min = arr[0],
    result = [];
  for (let i = 0; i < arr.length; i++) {
    if (!(arr[i] in hashTable)) {
      hashTable[arr[i]] = 1;
    } else {
      hashTable[arr[i]] += 1;
    }
    if (arr[i] > max) max = arr[i];
    if (arr[i] < min) min = arr[i];
  }
  for (let i = min; i <= max; i++) {
    if (i in hashTable) {
      for (let j = 0; j < hashTable[i]; j++) {
        result.push(i);
      }
    }
  }
  return result;
};

时间复杂度: O(N+k) ,k 为哈希表的长度

空间复杂度:O(k)

桶排序

基本思路:先求出数组的最大值和最小值 ,设桶的个数为 k ,在最小值与最大值之间划分成 k 个区间,每个区间就是一个桶。将序列中的元素分配到各自的桶。每个桶内进行普通的排序,最后连接即可。基数排序和计数排序都可以看做是桶排序。

这里以 leetcode 347. 前 K 个高频元素为例,进行说明,题目的描述是:给定一个非空的整数数组,返回其中出现频率前 k 高的元素。

思路是在第一次遍历的过程中用哈希表记录出现的数字及其出现的次数。

然后创建一个数组,将数字出现的频率作为数组下标,存入对应的数组下标即可。

var topKFrequent = function(nums, k) {
    let map = new Map()
    for(let i = 0; i< nums.length; i++){
        if(map.has(nums[i])){
            map.set(nums[i], map.get(nums[i]) + 1)
        }else{
            map.set(nums[i], 1)
        }
    }

    //桶排序,按照出现的次数,放入对应的下标中
    let arr = []
    for(let [key,value] of map){
        if(arr[value]){
            arr[value].push(key) 
        }else{
            arr[value] = [key]
        }
    }
    let result = []
    let temp = arr.filter(item => item !== undefined)
    let current = temp.length - 1
    let count = k
    while(count > 0){
        result.push(...temp[current])
        count -= temp[current].length
        current--
    }
    return result
};

基数排序是一种很古老的排序了,一般用于大数的排序,从最低位开始,分别按各位上的数字进行排序,就不给出代码了。

堆排序

最后介绍一下比较难理解的堆排序。

堆是具有以下性质的完全二叉树:每个结点的值都大于或等于其左右孩子结点的值,称为大顶堆;或者每个结点的值都小于或等于其左右孩子结点的值,称为小顶堆。

时间复杂度:平均 Ο(NlogN)

说明:未经大量算例检测,如有错误,欢迎指正。

参考:

www.bilibili.com/video/BV1x7…

zhuanlan.zhihu.com/p/137576551