为什么插入排序是使用频率最高的排序算法?

想象你在玩麻将或扑克,每次抽牌后都会把新牌插入到手中已经排好序的牌堆中。这个直观的过程与插入排序算法很像。

在插入排序中,初始状态下我们假定数组的第一个元素是已排序的,从第二个元素开始,将每个元素依次插入到正确的位置,重复这个过程,直至数组完全有序。

插入排序操作的核心在于两点:比较移动

通过比较,我们能够确定新元素的插入点;通过移动,我们为新元素的插入腾出空间。

插入排序复杂度分析

在最坏情况下,即数组完全逆序,第 n 个元素可能需要与前面所有 n-1 个元素比较才能找到自己的位置,比较次数达到 1+2+3+...+n-1 = (n-1)(n)/2 次,复杂度为 O(n^2)

同样地,移动操作此时也是如此。插入第 n 个元素,需要移动 n-1 个元素,因此总的移动次数也是 O(n^2)

然而,在最佳情况下,即数组已经有序,插入排序则非常高效,每次仅需进行一次比较,总比较次数为 n-1 次,复杂度为 O(n),而且无需移动任何元素。

插入排序优势

虽然插入排序的理论时间复杂度为 O(n^2),并不如 O(nlogn) 的高效排序算法,但得益于其简洁的操作,在小规模数据集上的表现通常非常出色。

快速排序等 O(nlogn) 级别的算法,虽然在大规模数据集上效率高,但涉及的基本操作更多。当处理的数据量小的时候,n^2nlogn 之间的差距不大,复杂度不占主导作用,每轮的单元操作数量起到决定性因素。

现在来看看简洁的插入排序算法的实现:

python 复制代码
def insertion_sort(arr):

    for i in range(1, len(arr)):
        val = arr[i]
        j = i - 1

        while j >= 0 and arr[j] > val:
            arr[j + 1] = arr[j]  # move
            j -= 1
        arr[j + 1] = val  # insert

源代码:insertionsort01

插入排序中确定的移动次数

这里有一个有趣的结论:插入排序中移动元素的次数与数组中逆序对的数量相等。

这个结论初看上去不太好理解,我们考虑一个数组 [4,2,3,1],从中可以找出以下六个数据对:(4,2)(4,3)(4,1)(2,3)(2,1)(3,1)

在这些数据对中,除了 (2,3) 是正序对之外,其他都是逆序对。

  • 当数字 2 被处理时,它移动到 4 的前面,消除了逆序对 (4,2)
  • 当数字 3 被处理时,它移动到 4 的前面,消除了逆序对 (4,3)
  • 当数字 1 被处理时,它移动到每一个数字的前面,消除了逆序对 (4,1)(3,1)(2,1)

每次移动都改变了两个逆序的元素的位置,相当于减少了一个逆序对。

因此对于一个给定的数组,移动的总次数在插入排序开始前就已经固定了。

插入排序的优化

既然移动次数是固定的,那么我们可以尝试从减少比较次数的角度来优化插入排序。

在插入排序中,数据被分为已排序部分和未排序部分。在已排序部分,我们寻找未排序部分下一个元素的正确位置时,通常会用到线性查找。

然而,一个优化手段是利用二分查找法,在已排序的序列中寻找插入点。

通过这种方式,我们可以将单次插入的比较次数降至 O(log n),总的比较次数减少至 O(nlog n)。然而,由于移动操作的次数保持不变,整个算法的时间复杂度仍为 O(n^2)

尽管如此,对于小规模的数据集而言,这样的优化是有益的,可以明显提高排序效率。

插入排序的一个重要特性是其稳定性。例如,在序列 [4a, 4b, 3, 5] 中,即使 4a4b 的值相等,它们在排序后也应保持原有顺序。

使用普通的二分查找可能会破坏这一稳定性,因为它可能会无视相同元素的原始顺序。

为了维护稳定性,我们对二分查找进行调整,确保遇到相等元素时总是将搜索范围移至右侧。 这保证了我们可以定位到相等元素中最右侧的位置,并将新元素插入其后。

采用这种方法,我们可以得到一种稳定的二分插入排序算法(stable binary insertion sort)

稳定的二分插入排序算法代码实现:

python 复制代码
def binary_search(arr, val, start, end):
    while start <= end:
        mid = (start + end) // 2
        if arr[mid] < val:
            start = mid + 1
        elif arr[mid] > val:
            end = mid - 1
        else:  # arr[mid] == val
            start = mid + 1  # move to right for stability

    return start

def insertion_sort(arr):
    for i in range(1, len(arr)):
        selected = arr[i]
        j = i - 1

        # find location where selected should be inserted
        loc = binary_search(arr, selected, 0, j)

        # Move all elements after location to create space
        while j >= loc:
            arr[j + 1] = arr[j]
            j -= 1
        arr[j + 1] = selected

源代码:insertionsort02

总结

优化后的算法,以其在处理小型数据集时的卓越性能,已被集成到 TimSort 复合排序算法中。考虑到 TimSort 是众多编程语言默认的 sort() 方法算法,这种稳定的二分插入排序算法可能是世界上应用最普遍的排序算法之一。

更多内容请关注我的公众号:dingtingli

WWH 系列文章列表:

[1] Why - 为什么 JS 更像一门编译型语言?

[2] What - 什么是依赖注入?

[3] What - 什么是 Big O?

[4] How - 不同的语言都如何处理错误?

[5] How - 面向对象语言如何处理异常?

[6] Why - 为什么排序算法复杂度上限是 O(NlogN)?

最近文章列表:

[1] 在 C 语言中实现简单的哈希表

[2] 成就卓越:事业成功的核心要素

[3] C++异常处理的底层机制

[4] .git 目录里到底包含了什么?

[5] 看图聊算法:一个游戏让你理解二分法的本质

[6] 看图聊算法:超越二分法,探索大厂经典面试题

[7] 看图聊算法:插入排序,使用频率最高的排序算法

[8] 看图聊算法:归并排序的原理与优化

[9] 看图聊算法:冯·诺依曼的第一个计算机程序

[10] 看图聊算法:快速排序为什么快?

更多内容请关注我的公众号:dingtingli

相关推荐
学习使我飞升18 分钟前
spf算法、三类LSA、区间防环路机制/规则、虚连接
服务器·网络·算法·智能路由器
庞传奇40 分钟前
【LC】560. 和为 K 的子数组
java·算法·leetcode
SoraLuna1 小时前
「Mac玩转仓颉内测版32」基础篇12 - Cangjie中的变量操作与类型管理
开发语言·算法·macos·cangjie
daiyang123...1 小时前
Java 复习 【知识改变命运】第九章
java·开发语言·算法
田梓燊2 小时前
湘潭大学软件工程算法设计与分析考试复习笔记(六)
笔记·算法·软件工程
重生之Java开发工程师2 小时前
算法笔记:前缀和
笔记·算法
sweetheart7-72 小时前
LeetCode155. 最小栈(2024冬季每日一题 12)
算法··模拟栈·最小元素
藏鹤虞渊3 小时前
【ONE·基础算法 || 动态规划(二)】
算法·动态规划
kcwqxx3 小时前
day23|leetCode 39. 组合总和 , 40.组合总和II , 131.分割回文串
c++·算法·leetcode
kitesxian3 小时前
Leetcode155. 最小栈(HOT100)
算法