运用逆向思维:插入排序

一句话总结

插入排序是基于 选择排序 的一种优化,将 nums[sortedIndex] 插入到左侧的有序数组中。对于有序度较高的数组,插入排序的效率比较高。

前文 选择排序所面临的问题 中分析了选择排序遇到的几个问题,然后逐步优化写出了 冒泡排序,使得排序算法具有稳定性,且能够在输入数组的有序度较高时提前终止,提升效率。

回顾一下,冒泡排序的关键点在于对下面这段代码的优化:

// 对选择排序进行第一波优化,获得了稳定性
func sort(nums []int) {
    n := len(nums)
    sortedIndex := 0
    for sortedIndex < n {
        // 在未排序部分中找到最小值 nums[minIndex]
        minIndex := sortedIndex
        for i := sortedIndex + 1; i < n; i++ {
            if nums[i] < nums[minIndex] {
                minIndex = i
            }
        }

        // 优化:将 nums[minIndex] 插入到 nums[sortedIndex] 的位置
        // 将 nums[sortedIndex..minIndex] 的元素整体向后移动一位
        minVal := nums[minIndex]
        // 数组搬移数据的操作
        for i := minIndex; i > sortedIndex; i-- {
            nums[i] = nums[i - 1]
        }
        nums[sortedIndex] = minVal

        sortedIndex++
    }
}

为了避免 while 内存在两个 for 循环,我们使用了一种类似冒泡的方式逐步交换 nums[sortedIndex..] 中的逆序对,将最小值换到 nums[sortedIndex] 的位置。

好的,先停在这一步,让我们忘记冒泡排序的优化方法,你来思考一下,是否还有其他方法能够优化上述代码,把 while 循环中的两个 for 循环优化成一个 for 循环?

反向思维

上面的算法思路是:在 nums[sortedIndex..] 中找到最小值,然后将其插入到 nums[sortedIndex] 的位置。

那么我们能不能反过来想,在 nums[0..sortedIndex-1] 这个部分有序的数组中,找到 nums[sortedIndex] 应该插入的位置,然后进行插入呢?

当年我思考如何对插入排序进行优化时,是想到过这个思路的,因为我想利用数组的有序性呀:既然 nums[0..sortedIndex-1] 这部分是已经排好序的,那么我就可以用二分搜索来寻找 nums[sortedIndex] 应该插入的位置。

这样一来,上述代码中的内层第一个 for 循环,我可以给他优化成对数级别的复杂度。

但是仔细想想,用二分搜索好像是多此一举的。因为就算我用二分搜索找到了 nums[sortedIndex] 应该插入的位置,我还是需要搬移元素进行插入,那还不如一边遍历一遍交换元素的方法简单高效呢:

// 对选择排序进一步优化,向左侧有序数组中插入元素
// 这个算法有另一个名字,叫做插入排序
func sort(nums []int) {
    n := len(nums)
    // 维护 [0, sortedIndex) 是有序数组
    sortedIndex := 0
    for sortedIndex < n {
        // 将 nums[sortedIndex] 插入到有序数组 [0, sortedIndex) 中
        for i := sortedIndex; i > 0; i-- {
            if nums[i] < nums[i-1] {
                // swap(nums[i], nums[i - 1])
                tmp := nums[i]
                nums[i] = nums[i-1]
                nums[i-1] = tmp
            } else {
                break
            }
        }
        sortedIndex++
    }
}
插入排序

这个算法的名字叫做插入排序,它的执行过程就像是打扑克牌时,将新抓到的牌插入到手中已经排好序的牌中。

插入排序的空间复杂度是 O(1),是原地排序算法。时间复杂度是 O(n²),具体的操作次数和选择排序类似,是一个等差数列求和,大约是 n²/2 次。

插入排序是一种稳定排序,因为只有在 `nums[i] < nums[i - 1]` 的情况下才会交换元素,所以相同元素的相对位置不会发生改变

初始有序度越高,效率越高

显然,插入排序的效率和输入数组的有序度有很大关系,可以举极端例子来理解:

如果输入数组已经有序,或者仅有个别元素逆序,那么插入排序的内层 for 循环几乎不需要执行元素交换,所以时间复杂度接近 O(n)。

如果输入的数组是完全逆序的,那么插入排序的效率就会很低,内层 for 循环每次都要对 nums[0..sortedIndex-1] 的所有元素进行交换,算法的总时间复杂度就接近 O(n²)。

如果对比插入排序和冒泡排序,插入排序的综合性能应该要高于冒泡排序

直观地说,插入排序的内层 for 循环,只需要对 sortedIndex 左侧 nums[0..sortedIndex-1] 这部分有序数组进行遍历和元素交换,大部分非极端情况下,可能不需要遍历完 nums[0..sortedIndex-1] 的所有元素;而冒泡排序的内层 for 循环,每次都需要遍历 sortedIndex 右侧 nums[sortedIndex..] 的所有元素。

所以冒泡排序的操作数大约是 n²/2,而插入排序的操作数会小于 n²/2。

你可以把插入排序的代码拿去力扣第 912 题「排序数组」提交,它最终依然会超时,但可以说明算法代码的逻辑是正确的。之后的文章我们继续探讨如何对排序算法进行优化。