七种常见排序算法的JS实现及其稳定性的讨论

695 阅读4分钟

排序算法的稳定性,通俗地讲就是能保证排序前2个相等的数其在序列的前后位置顺序和排序后它们两个的前后位置顺序相同。再简单形式化一下,如果Ai = Aj,Ai原来在位置前,排序后Ai还是要在Aj位置前。稳定的排序算法主要有:冒泡排序、插入排序、归并排序等;不稳定的主要有:选择排序、希尔排序、快速排序、堆排序等。

我的总结:冒泡排序、插入排序、归并排序都在前面排序的基础上进行,不会把相等元素放到另一个相等元素的前面;选择排序、快速排序、堆排序会对元素进行远距离的调换,所以不稳定;一次插入排序是稳定的,但是希尔排序分组进行插入排序,相等元素可能在不同的组内被交换,所以不稳定。

1.冒泡排序

嵌套for循环(O(n2)),在原数组上进行元素交换(O(1))。代码感悟:!!!每次比较都是从第一位开始的。

// 交换函数
function swap(arr, i, j) {
    var temp = arr[i]
    arr[i] = arr[j]
    arr[j] = temp
    return arr
}

// 1.冒泡排序
function bubbleSort(nums) {
    let len = nums.length
    if (len == 0 || len == 1) return nums
    // 从 len-1 遍历到 1 的位置
    for (let i = len - 1; i > 0; i--) {
        // j从 0 遍历到 i-1 的位置
        for (let j = 0; j < i; j++) {
            // 和 j+1 的元素进行比较
            if (nums[j] > nums[j + 1]) {
                swap(nums, j, j + 1)
            }
        }
    }
    return nums
}

2.选择排序:给每个位置选择剩余最小的元素

嵌套for循环(O(n2)),在原数组上进行元素交换(O(1))。代码感悟:要把最小值的索引保存下来挺麻烦的。

// 2.选择排序:选择未排序元素中的最小值和当前位(从左往右遍历)元素进行交换
function selectSort(nums) {
    var len = nums.length
    if (len == 0 || len == 1) return nums
    // 从左到右遍历每一个位置(最后一个不用)
    var curPosition = 0
    for (let i = 0; i < len - 1; i++) {
        // 选出从当前位置到最后一个元素中最小的元素并记录其索引index
        var min = nums[i]
        var index = i
        for (let j = i + 1; j < len; j++) {
            if (nums[j] < min) {
                min = nums[j]
                index = j
            }
        }
        // 交换当前位置元素和最小值所在的位置
        swap(nums, i, index)
    }
    return nums
}

3.插入排序:在一个已经有序的子序列的基础上,一次插入一个元素

嵌套for循环(O(n2))(最好的情况是待排数组是有序数组O(n)),在原数组上进行元素交换(O(1))。

// 3.插入排序:从左到右遍历每个位置,将当前位置元素插入到前面已排序数组的合适位置
function insertSort(nums) {
    let len = nums.length
    if (len == 0 || len == 1) return nums
    // 从左到右遍历每一个位置(第一个可以除外)
    for (let i = 1; i < len; i++) {
        // 和前面已经排好序的元素比较
        let j = i - 1
        while (j >= 0) {
            if (nums[j + 1] < nums[j]) {
                swap(nums, j, j + 1)
                j--
            } else break // 否则就退出循环
        }
    }
    return nums
}

4.希尔排序:根据不同增量对元素进行插入排序

算法复杂度(O(n1.3)-O(n2)),在原数组上进行元素交换(O(1))。代码感悟:设置增量(增量需要互质)为gap/2,最坏情况仍然是O(n2)。

// 4.希尔排序:根据增量进行分组插入排序(多个增量需互质,且最后一个为1)
function shellSort(nums) {
    var len = nums.length
    if (len == 0 || len == 1) return nums
    var gap = parseInt(len / 2) // 设置增量(步长)的初始值
    while (gap) { // nlogn 次
        // 循环 步长 次
        for (let n = 0; n < gap; n++) {
            var num = Math.ceil(len / gap) // 定义组数
            // 对间隔步长的元素进行插入排序
            // 从左到右遍历每一个位置(第一个可以除外)
            for (let i = gap; i < num + gap; i++) {
                if (i < len) { // 最后一组可能不是gap个
                    // 和前面已经排好序的元素比较
                    var j = i - gap
                    while (j >= n) {
                        if (nums[j + gap] < nums[j]) {
                            swap(nums, j, j + gap)
                            j -= gap
                        } else break
                    }
                }
            }
        }
        gap = parseInt(gap / 2)
    }
    return nums
}

5.归并排序:通过不断合并两个有序的子序列得到最后的结果

算法复杂度(O(nlogn)),空间复杂度O(n)。代码感悟:分治思想

// 5.归并排序:分治
function mergeSort(nums) {
    var len = nums.length
    if (len < 2) return nums
    var mid = Math.floor(len / 2)
    var left = nums.slice(0, mid) // 包括 begin,不包括end
    var right = nums.slice(mid) // 如果 end 被省略,则 slice 会一直提取到原数组末尾
    return merge(mergeSort(left), mergeSort(right))
}
// 归并两个有序数组
function merge(left, right) {
    var newArr = []
    while (left.length && right.length) {
        if (left[0] <= right[0]) {
            newArr.push(left.shift())
        } else newArr.push(right.shift())
    }
    return newArr.concat(left).concat(right)
}

6.快速排序:选择基准点,通过一趟排序将待排序的数组分为独立的两个部分,前半部分都比基准元素小,后半部分都比基准元素大。

算法复杂度(O(nlogn)),空间复杂度O(logn)。代码感悟:递归(递归会导致栈溢出;非递归:可用栈存储左边和右边的子序列直到栈为空(参考有效的括号)、双指针;最左元素为基准点,右指针先动。当待排数组有序或倒序的时候是最坏情况,O(n2);

// 6.快速排序:分治、双指针;设置最左元素为基准点,右指针先动 // 内存不足
function quickSort(nums) {
    var len = nums.length
    if (len == 0 || len == 1) return nums
    var piovt = nums[0] // 设置基准点的值为nums[0]
    var left = 0 // 设置左指针
    var right = len - 1 // 设置右指针
    while (left < right) {
        // 右指针先动,找到比基准点小的元素
        if (nums[right] < piovt) {
            // 再动左指针,找到比基准点大的元素
            if (nums[left] > piovt) {
                // 进行交换
                swap(nums, left, right)
            } else left++
        } else right--
    }
    if (left == right) {
        swap(nums, 0, left)
    }
    // 拼接字符串:对当前基准点元素的左边排序 + 基准点元素 + 对当前基准点元素的右边排序
    return quickSort(nums.slice(0, left)).concat(nums[left]).concat(quickSort(nums.slice(left + 1)))
}

7.堆排序:通过构建最大堆,将堆顶元素和堆中的最后一个元素进行交换。

算法复杂度(O(nlogn)),空间复杂度O(1)。代码感悟:(分治)构建最大堆、不适用额外的内存空间(最大堆函数加一个end形参)。

// 7.堆排序:
function heapSort(nums) {
    var len = nums.length
    if (len < 2) return nums
    for (let i = len - 1; i >= 0; i--) {
        // 1.将未排序部分的nums[0-i]调整为最大堆
        nums = adjustMaxHeap(nums, 0, i)
        // 2.交换堆顶和数组最后一个元素
        nums = swap(nums, 0, i)
    }
    return nums
}
// 构建最大堆
function adjustMaxHeap(nums, start, end) { // 为了不浪费新的内存空间,新增end参数,保证后面已经排好的元素可以保留
    var left = 2 * start + 1 // 左节点
    var right = 2 * start + 2 // 右节点
    // 如果当前节点是不是根节点
    if (left > end) return nums
    // 如果当前节点是根节点,调整他的左右子节点为最大堆
    adjustMaxHeap(nums, left, end)
    right <= end && adjustMaxHeap(nums, right, end)

    // 先和左节点比较
    if (nums[start] < nums[left]) {
        swap(nums, start, left)
    }
    // 如果有右节点还需要和右节点进行比较
    if (right <= end && nums[start] < nums[right]) {
        swap(nums, start, right)
    }
    return nums
}

其他的还有:计数排序、桶排序、基数排序等,待补充... ...