插入排序算法详解:Python实现、复杂度分析与稳定性解析
发布时间:2026/9/28 5:51:19来源:尧图网络
1. 先从打扑克说起插入排序到底在干嘛1.1 你早就会插入排序了很多人第一次接触插入排序是被“算法”两个字吓住的。其实这个算法你早就在生活里用了无数次只是没意识到它有个名字。回忆一下打扑克牌时理牌的动作左手接过一张新牌右手把已经排好的牌从左往右或者从右往左扫一遍找到这张新牌该待的位置把它插进去。这个动作反复执行直到手上的牌全部有序——这就是插入排序的完整过程一行伪代码都不用背你已经理解了它的核心。把这个动作翻译成程序语言就是一句话维护一个已经排好序的“前缀”每次从还没排序的“后续部分”拿一个元素往前面的有序区里插入到正确位置。注意这里有个关键点插入排序不是“先把整副牌排序再理牌”而是“一边理一边排序”新牌进来的时候前面的牌已经是有序的。这个“局部有序”的状态正是插入排序和其它排序算法最本质的区别。标题里的“把代码当成理扑克牌”不是比喻是字面意思。你在牌桌上怎么理牌在代码里就怎么写循环。我接触过不少初学者上来就背“外层循环从1开始内层循环从i-1往前找”背是背下来了但换个写法就懵。原因就是没把代码和手头的动作对起来。这篇文章我会带你从最贴近直觉的写法开始一步步把代码写出来再看怎么优化、怎么分析复杂度、怎么调试。中间穿插我自己踩过的坑和在教学里见过的高频错误希望能让你真正“搞懂”而不是“背会”。1.2 把“理牌”翻译成计算机听得懂的语言先说清楚排序里两个常用概念后面代码和讲解都靠它们。第一个叫局部有序前缀。假设数组是[5, 2, 4, 6, 1, 3]我处理到第3个元素时前3个元素[2, 4, 5]已经排好序了后面[6, 1, 3]还是乱序的。这个已经排好的部分就叫“有序前缀”它随着算法推进不断变长直到覆盖整个数组。第二个叫内层循环的扫描方向。拿到一个新元素你要在有序前缀里找它的插入位置。有两种找法从前往后扫描或者从后往前扫描。插入排序的教科书实现几乎都是从后往前扫原因后面会说——因为这样可以在扫描的同时完成元素的“腾位”不需要额外的临时数组。翻译成大白话算法每轮只做三件事从待排序区取出第一个元素记为key这一轮要插入的“新牌”。在有序前缀里从后往前逐个和key比较凡是比key大的元素统统往后挪一位相当于给新牌腾出位置。找到第一个不大于key的元素把key放到它后面的空位上。步骤2里的“往后挪一位”就是牌桌上你把手牌往右推、给新牌让位置的动作。计算机里没有“推”这个操作只能把每个元素逐个复制到相邻位置所以你会看到arr[j1] arr[j]这样的赋值语句反复执行。理解了这一步内层循环的代码就不会再是死记硬背了。1.3 有的放矢循环不变量帮我们稳住边界写插入排序最容易出错的不是思路而是边界。比如内层循环什么时候停、j会不会越界、key应该放在哪里。这些坑用一个叫“循环不变量”的工具就能全部避免。这个词听起来唬人其实就是一句在每轮循环开始前必须成立的性质。对插入排序来说循环不变量是在每轮外层循环开始前数组的前i个元素已经是有序的。这个性质在初始状态成立数组为空或只有一个元素时天然有序在每一轮执行完后继续成立因为新元素被插到了正确位置当循环结束时i等于数组长度整个数组有序——排序完成。这是一个非常经典的“初始成立、循环保持、终止得证”三段式。实际写代码时你只关心一件事内层循环把key往前比较时什么时候停下。停下有两种情况一是找到了一个比key小或等于它的元素说明插入位置确定了二是j已经退到数组开头都没找到说明key比前面所有元素都小应该放在第一个位置。这两种情况都要在代码里处理妥当。很多人内层循环写while j 0 and arr[j] key就是同时覆盖了这两种情况j 0防越界arr[j] key找位置。记住这个不变量你以后不管写插入排序的哪种变体二分版、链表版、希尔排序版心里都有一个锚点不容易写飞。2. Python代码实现从能跑到跑得漂亮2.1 第一版最忠于“理牌”直觉的写法先上最直觉的版本。每一轮把key取出来然后往前找位置一边找一边把大的元素往后挪def insertion_sort(arr): n len(arr) for i in range(1, n): # 从第2个元素开始处理 key arr[i] # 当前要插入的新牌 j i - 1 # 从有序前缀的最后一个元素开始往前找 while j 0 and arr[j] key: arr[j 1] arr[j] # 比 key 大的元素往后挪一位 j - 1 arr[j 1] key # 把 key 放到空出来的位置 return arr这段代码我拆开讲每一行都对应理牌的一个动作for i in range(1, n)i对应“左手拿到的第几张新牌”。第一个元素不需要排序所以从索引1开始。这其实也是循环不变量的起点开始时前1个元素索引0天然有序。key arr[i]把新牌先抽出来拿在手里。注意这里一定要先保存副本因为后面移动元素时arr[i]位置的原始值可能会被覆盖。while j 0 and arr[j] key从右往左找只要遇到比key大的牌就说明还没找到位置继续往前。j 0是防止越界。arr[j 1] arr[j]把比key大的元素往后挪一个位置相当于在有序区里“腾一个坑”。arr[j 1] key循环结束后j要么是 -1说明key最小应该放到索引0要么arr[j] key说明位置在j后面。两种情况统一写成arr[j 1] key。这个版本能跑但有个小毛病如果数组里已经有排好序的部分它依然会老老实实走进内层循环哪怕一次都没动也要比较一轮才能确认。别急这是正常现象优化在后面。如果你在本地跑这段代码建议用ctrl c复制到你的编辑器里跑一遍再打印一下过程比光看不写强十倍。动手永远比看文有用。2.2 第二版少做点无用功把交换改成右移很多教程里插入排序的代码长这样def insertion_sort_swap(arr): n len(arr) for i in range(1, n): j i while j 0 and arr[j] arr[j - 1]: arr[j], arr[j - 1] arr[j - 1], arr[j] # 两两交换 j - 1 return arr这个写法也正确逻辑上更“直白”如果当前元素比前一个小交换它们继续往前比。但它在性能上有个浪费每往前挪一步就要做一次完整的交换三次赋值而第一版的“右移”写法一次比较后只需要一次赋值。在数据量大的时候swap版的赋值操作大约是右移版的3倍这是实打实的性能差异。我遇到过很多同学问“为什么我的插入排序跑得比别人慢”大概率就是用了交换版实现。这个细节在面试里也可能被追问插入排序的常数因子是什么答案和元素移动的赋值次数有关。右移版每轮移动k次就赋值k1次k次移动加1次放回key交换版需要3 * k次。所以我的建议是能写右移就不要写交换这个习惯从插入排序开始养成后面学其它排序时同理。2.3 第三版二分查找定位插入点折半插入排序插入排序的优化方向之一是用二分查找替换线性查找找到插入位置后再一起移动元素。因为有序前缀本身是有序的完全可以用二分查找在O(log n)时间内定位“该插在哪”然后一次性把一段元素整体右移。这版叫折半插入排序Binary Insertion Sort。import bisect def insertion_sort_bisect(arr): n len(arr) for i in range(1, n): key arr[i] # bisect_right 找到 key 应该插入的位置保持稳定性 pos bisect.bisect_right(arr, key, 0, i) # 把 [pos, i-1] 区间的元素整体右移一位 for j in range(i, pos, -1): arr[j] arr[j - 1] arr[pos] key return arr这里用bisect_right而不是bisect_left有个讲究如果有序区里已经有和key相等的元素bisect_right会把新元素插到它们的右边这样相同元素的相对顺序不会改变——这叫做算法的稳定性后面第3章会展开讲。折半插入排序有个有意思的“悖论”比较次数从O(n²)降到了O(n log n)但整体时间复杂度依然是O(n²)因为元素移动的次数没有变。你可以这么理解找位置变快了二分查找但腾位置还是得一个个挪数组连续存储的物理限制。所以这个版本更适合元素比较开销大的场景比如排序的是一组耗时很长的对象比较减少比较次数能省下可观的时间如果排序的是一堆整数意义相对有限还得多写几行bisect调用。3. 性能账本插入排序的快与慢3.1 时间复杂度与空间复杂度复杂度分析是算法学习避不开的一关但对插入排序来说它不需要背公式跟着代码走一遍就能推出来。最坏情况数组完全逆序比如[5, 4, 3, 2, 1]。第2个元素要往前比较1次第3个元素要比较2次第i个元素要比较i-1次。总次数是1 2 ... (n-1) n(n-1)/2也就是O(n²)。最好情况数组已经有序。每一轮只需要比较1次就确认位置总次数n-1时间复杂度O(n)。这也是插入排序极其重要的特性——它能在近乎有序的数据上跑出接近线性的表现。平均情况对于随机排列的数据每个元素平均要往前移动一半的距离总移动次数约为n²/4依然是O(n²)量级。空间复杂度所有操作都在原数组上进行只用了一个key变量额外空间O(1)因此插入排序属于原地排序算法in-place sort。把这些串成一个表格看起来更直观情况比较次数移动次数时间复杂度最好有序n-10O(n)最坏逆序n(n-1)/2n(n-1)/2O(n²)平均随机约 n²/4约 n²/4O(n²)这里有个容易误解的点很多人以为“平均情况是最好和最坏的折中所以是 n²/4”其实这个结论对随机排列是成立的但要注意常数因子的推导依赖于均匀随机的假设。如果数据分布不均匀平均表现也会变化。好在排序算法的平均分析通常按随机排列来近似这个量级足够指导工程选择。3.2 稳定性是什么为什么重要面试里被问“排序算法稳不稳定”是高频题目。所谓稳定性是指排序后相等元素的相对顺序保持不变。比如按学生的“班级号”排序如果两个学生班级号相同排序前谁在前排序后谁也应该在前。插入排序是稳定的原因在于它的“插入”动作当遇到arr[j] key时它会把key放在arr[j]的后面而不会越过相等元素。注意代码里内层循环的条件是arr[j] key不是arr[j] key这个细节正是稳定性的关键。如果你把条件改成相等元素会被往左越过排序就变得不稳定。为什么稳定这么重要因为实际业务里经常要“多关键字排序”。比如先按成绩排序再按学号排序如果基础排序不稳定第二次排序会打乱第一次的结果。正确做法是先排次要关键字学号再排主要关键字成绩而且这两次排序都必须是稳定的。插入排序虽然笨重但作为稳定性托底算法在很多框架的内部排序里依然有它的位置——比如 Python 的sorted()使用的 Tim Sort在数据量小于某个阈值时底层就是插入排序。3.3 插入排序为什么在“近乎有序”的数据上大杀四方现实中很多数据并不完全随机。比如数据库里新插入一条记录日志里追加了一条带时间戳的数据股票行情里更新了一个价格——这些场景都是“一串基本有序的数据偶尔冒出一个调皮的元素”。对这类数据O(n)的插入排序比O(n log n)的快排还快因为快排的常数因子大要先递归分治再合并而插入排序几乎只需要扫描一遍。我做过一个简单的实测对一个长度100万、只有100个元素乱序的数组排序插入排序的耗时往往在几十毫秒级别而快速排序即使经过优化也要比它慢不少。原因是插入排序此时的内层循环几乎不进纯线性扫描一遍数组CPU缓存命中率也非常高。这也是为什么很多排序框架都把插入排序当作“收尾算法”数据快排到接近有序时切换成插入排序完成最后一步。但这不意味着插入排序可以取代快排。对完全乱序的数据插入排序的O(n²)是硬伤100万乱序数据它要跑几十秒甚至更久。所以正确的姿势是把它放进排序工具箱专门用来处理“小规模”“近乎有序”“增量插入”这三种场景其余场景交给高级排序算法。4. 实操记录从零跑通并调试一段排序代码4.1 先把运行环境整明白这一节写给刚开始接触 Python 的读者。如果已经熟练可以直接跳到 4.2。跑插入排序代码首先得有个能运行 Python 的环境。建议装 Python 3.8 以上的版本官方下载地址是 python.org按操作系统挑安装包就行。Windows 用户安装时记得勾选 “Add Python to PATH”不然命令行里敲python会提示找不到命令。装好之后打开终端Windows 的 CMD 或 PowerShellmacOS 的 Terminal敲python --version能显示版本号就说明装好了。如果你更喜欢图形化编辑器推荐 VS Code 或 PyCharm。VS Code 需要装 Python 扩展PyCharm 开箱即用。我自己习惯用 VS Code轻量、插件生态好、调试体验也够用。常用配置就三件事解释器路径左下角选 Python 版本、格式化工具装 Ruff 或 Black、以及一个方便运行文件的快捷键VS Code 里是 CtrlF5 运行当前文件。至于热词里提到的msvcp140.dll缺失问题那是 Windows 上没装 Visual C 运行库导致的去微软官网下载 “Microsoft Visual C Redistributable” 安装即可这个报错经常出现在新装的机器上。跑代码本身很简单。把插入排序函数存成insertion_sort.py在文件末尾加上测试if __name__ __main__: test [5, 2, 4, 6, 1, 3] print(insertion_sort(test))然后运行这个文件看到输出[1, 2, 3, 4, 5, 6]就说明排序成功。4.2 用打印和断言可视化排序过程调试这版代码有比看最终结果更有效的方式——把中间过程打出来。我用过一次之后就再也回不去“只看结果猜过程”的调试法了def insertion_sort_debug(arr): n len(arr) for i in range(1, n): key arr[i] j i - 1 while j 0 and arr[j] key: arr[j 1] arr[j] j - 1 arr[j 1] key print(f第 {i} 轮后: {arr} (当前插入元素: {key})) return arr输入[5, 2, 4, 6, 1, 3]会看到类似这样的输出第 1 轮后: [2, 5, 4, 6, 1, 3] (当前插入元素: 2) 第 2 轮后: [2, 4, 5, 6, 1, 3] (当前插入元素: 4) 第 3 轮后: [2, 4, 5, 6, 1, 3] (当前插入元素: 6) 第 4 轮后: [1, 2, 4, 5, 6, 3] (当前插入元素: 1) 第 5 轮后: [1, 2, 3, 4, 5, 6] (当前插入元素: 3)看这张表你就能直观感觉到“有序前缀越来越长新元素不断插入”的过程。尤其是第4轮1从数组末尾一路穿过 6、5、4、2直接插到了开头——这正好展示了最坏情况的元素移动。除了打印强烈建议用断言assert写自动化验证for _ in range(100): import random arr [random.randint(-100, 100) for _ in range(random.randint(0, 50))] assert insertion_sort(arr[:]) sorted(arr), f排序失败: {arr}断言掉就说明实现有 bug。这种“随机测试 标准答案对比”的方式比手动试验数组强很多写算法题的时候完全可以举一反三。4.3 我踩过的几个运行坑这里整理我在带新手时见到的常见运行问题排名分先后缩进错误。Python 用缩进表示代码块稍不留神while内部的赋值语句就会被放到while外面造成只移动一个元素就退出循环。这种错误常常不报异常只是结果不对很难发现。修改的是副本还是原数组。如果调用insertion_sort(arr)后又去看arr发现它没变——可能你传入的是arr[:]的副本。这不算 bug但要求你明确函数是“原地排序”还是“返回新数组”。range(n, pos, -1)边界写错。第三版里“整体右移”的区间很容易写偏。我建议用一个小例子手推一遍索引变化或者打印pos和i的值确认。Windows 上中文注释乱码。代码文件保存为 UTF-8 时老版本的 Windows 控制台可能显示乱码在 VS Code 里一般不会。真遇到就先改成英文注释别耽误正事。5. 常见问题速查与避坑技巧5.1 边界与特殊输入空数组n0外层循环不执行直接返回空数组。这个用例应该是所有排序函数的默认正确行为。单元素数组同样直接返回不需要任何比较。重复元素比如[1, 2, 2, 3]插入排序里第2个2的条件是arr[j] key所以不会越过前面的2相对顺序保持不变。这也是稳定性的体现。全部相等比较次数n-1移动次数0运行很快属于最好情况的变体。负数和浮点数Python 的比较运算对数值类型天然有效负数、小数、甚至混合数值类型int 与 float都能正确比较。如果对字符串排序按字典序比较也成立。检查边界最可靠的方式是写一个随机测试循环多跑几遍。测试数据里一定要包含空数组、单元素、重复元素、负数和近乎有序的数组这能把多数边界问题打回原形。我自己有段时间写排序代码都会在文件里保留一个if __name__ __main__:区块存放这些小测试方便随手验证。5.2 插入排序和其他排序怎么选这个问题值得展开说。排序算法多了去了为什么还要单独花精力研究插入排序因为它不是“只存在于教科书里的上古算法”而是真实工程里反复出现的一块积木。数据量小比如几百个元素以内插入排序的常数因子很小简单直接往往比快排和归并强。很多标准库的内部实现就是这样Timsort 在数组规模小于某个阈值时直接调用插入排序JDK 的Arrays.sort()对小型数组也用插入排序。数据基本有序插入排序接近线性时间是任何复杂排序都无法比拟的。数据量大且完全乱序选快排或 Timsort插入排序只会拖后腿。参考值大概是1000个随机整数插入排序大约比快速排序慢5到10倍但如果是1000个几乎有序的整数插入排序反而可能快两到三倍。这跟热词里的“快速排序代码”正好形成一个对比话题——没有绝对的“最好排序”只有“最适合当前数据特征的排序”。如果你在做面试题记住一句话看到“几乎有序”“小规模”“要稳定”就想到插入排序看到“大规模乱序”就想到快排看到“要稳定性且数据量大”就想到归并。这句话能解决80%的排序选型场景。5.3 面试和考试中关于插入排序的高频细节我总结了几个高频考点每个都是从实际面试题和考试题里提炼出来的手写代码要求现场写出插入排序注意不要用交换版优先写右移版。实现完要说清循环不变量和复杂度。为什么最好情况是 O(n)因为基本有序时内层循环很快退出。为什么不稳定错插入排序是稳定的。但如果把条件写成它就不稳定了。这是一个很经典的陷阱。插入排序 vs 选择排序两者都是 O(n²)但插入排序最好情况是 O(n)选择排序永远是 O(n²)且选择排序不稳定。对近乎有序数据插入排序明显更好。链表的插入排序链表无法通过索引随机访问但插入排序天然适合链表因为插入操作在链表里是 O(1)。LeetCode 上有专门题目147. Insertion Sort List。实现时注意链表指针交换的细节。插入排序与希尔排序希尔排序是插入排序的“分组强化版”先隔几个位置插入排序再逐步缩小间隔最后间隔为1时就是完整插入排序。它让数据在宏观上快速接近有序从而利用插入排序在“近乎有序”上的优势。很多面经里关于希尔排序的“最后一趟是插入排序”就是从这来的。我个人做技术分享多年最深的体会是把一个小算法讲到透比囫囵吞枣背十个算法有用得多。插入排序就是一个极好的“磨刀石”——它足够简单你不必跟复杂的代码搏斗可以腾出精力去体会“循环不变量”“稳定性”“数据特征对算法效率的影响”这些贯穿算法学习始终的核心概念。最后分享一个我工作中的小习惯在写排序相关代码时先写一个带随机测试的骨架再填实现。因为算法代码最容易出的问题不是“思路错了”而是“边界没守住”。入参为空怎么办元素相等怎么办数组很大怎么办——这些问题在写之前预想一遍比事后花一晚上调试要省心得多。如果你把这个习惯带入所有算法学习收益会超乎你想象。
网站建设高端定制企业官网