我要提问
ARTICLE DETAIL

资讯详情

前沿编程新知与开发实战干货的深度解读。

希尔排序原理与Python实现:从插入排序优化到增量序列选择

希尔排序原理与Python实现:从插入排序优化到增量序列选择 1. 希尔排序到底解决了什么问题排序算法学到希尔排序这一节很多人会有个疑问前面已经学过插入排序了为什么还要再搞一个希尔排序出来这不是多此一举吗我刚开始接触的时候也是这么想的直到后来在一个真实的数据处理场景里被插入排序坑了一次才真正理解希尔排序存在的意义。先说结论希尔排序是插入排序的进化版核心目标是解决插入排序在大规模乱序数据上效率急剧下降的问题。插入排序本身不差甚至在数据基本有序的情况下它的表现比很多复杂算法都要好时间复杂度能逼近O(n)。但问题在于现实中的数据往往是乱序的而且规模不小。这时候插入排序每次都要把元素一个一个往前挪效率就会退化到O(n²)。希尔排序的思路其实很朴素既然插入排序在“基本有序”的序列上跑得飞快那我能不能先把序列变得“基本有序”然后再用插入排序收尾这个想法就是希尔排序的灵魂。它通过一个叫**增量gap**的概念把原始序列分成若干个子序列先对子序列做插入排序让整个序列逐渐趋于有序最后当增量缩小到1的时候再做一次标准的插入排序此时序列已经接近有序插入排序的开销就非常小了。这个算法是1959年由一位叫希尔的人提出来的所以叫希尔排序也叫缩小增量排序。它的时间复杂度跟增量的选择策略有关好的增量序列可以把时间复杂度降到O(n^1.3)左右最坏情况仍然是O(n²)但实际表现通常远好于普通的插入排序。那这篇文章适合谁看如果你正在学排序算法已经掌握了插入排序想进一步理解算法优化的思路那这篇内容就是为你准备的。如果你是在准备面试希尔排序也是一个高频考点尤其是增量序列的选择和手写代码。再或者你是个刚入门的Python学习者想通过一个具体的算法来加深对循环、列表操作的理解也完全可以跟着往下看。2. 希尔排序的核心思路拆解2.1 从插入排序的痛点说起要理解希尔排序得先把插入排序的运作方式捋清楚。插入排序的逻辑是从第二个元素开始把当前元素跟前面已经排好序的部分逐个比较找到合适的位置插进去。这个过程就像打扑克牌时整理手牌每摸一张新牌就把它插入到已经排好的牌里面。问题出在哪里假设有一个序列是[9, 8, 7, 6, 5, 4, 3, 2, 1]完全逆序。用插入排序处理的话第一个元素9不动第二个元素8要跟9比较交换一次第三个元素7要跟9、8比较交换两次以此类推第n个元素要比较n-1次。总的比较和移动次数大概是n²/2当n等于10000的时候这个数字就是5000万次在Python里跑起来会明显感觉到卡顿。那如果数据是[1, 2, 3, 4, 5, 6, 7, 8, 9]呢插入排序每次只需要比较一次就发现位置对了总共比较n-1次飞快。这就是插入排序的特点对基本有序的数据极其友好对完全乱序的数据极其不友好。希尔排序就是抓住了这个特点想办法先把数据“预处理”成基本有序的状态。2.2 增量分组到底是怎么回事希尔排序的做法是选定一个增量gap把序列中所有距离为gap的元素分为同一组然后对每一组分别做插入排序。做完一轮之后缩小gap再分组、再排序直到gap等于1做最后一次标准插入排序。举个例子假设序列是[8, 9, 1, 7, 2, 3, 5, 4, 6, 0]长度是10。如果初始gap取5那么分组情况是这样的第一组索引0和索引5对应元素8和3第二组索引1和索引6对应元素9和5第三组索引2和索引7对应元素1和4第四组索引3和索引8对应元素7和6第五组索引4和索引9对应元素2和0每组只有两个元素分别做插入排序。排完之后3跑到前面去了0也跑到前面去了整个序列变成[3, 5, 1, 6, 0, 8, 9, 4, 7, 2]。你会发现小的元素已经大致往前面移动了大的元素也大致往后面移动了虽然还没有完全有序但比之前好多了。接下来gap缩小到2再分组排序序列会进一步趋于有序。最后gap等于1做一次完整的插入排序这时候数据已经基本有序插入排序的开销就很小了。这里有个关键点希尔排序之所以有效是因为每一轮排序都会让元素朝着最终位置靠近一大步。gap大的时候元素可以一次跨越很长的距离而不是像插入排序那样每次只能移动一个位置。2.3 为什么增量最终必须等于1这个问题很多人会忽略但它是希尔排序正确性的保证。如果增量最后不缩小到1那排序结果就不一定是正确的。原因很简单当gap大于1的时候我们只是在组内排序不同组之间的元素并没有直接比较过。只有gap等于1的时候整个序列才会被当作一个整体来做插入排序这时候才能保证所有元素都排到正确的位置上。你可以这样理解gap大于1的每一轮排序都是在为最后一轮gap1的插入排序做铺垫让最后一轮的工作量尽可能小。如果跳过gap1这一步那前面做的所有工作都只是局部有序整体上仍然是乱的。2.4 增量序列的选择策略增量序列的选择是希尔排序最核心的研究点之一。不同的增量序列会导致完全不同的时间复杂度。常见的几种选择方式增量序列生成方式时间复杂度特点希尔原始序列n/2, n/4, ..., 1O(n²)最简单但效率一般Hibbard序列1, 3, 7, 15, ... (2^k - 1)O(n^1.5)比原始序列好Sedgewick序列1, 5, 19, 41, 109, ...O(n^1.3)实践中表现优秀Knuth序列1, 4, 13, 40, ... ((3^k - 1)/2)O(n^1.5)数学性质好常用对于日常使用和面试来说希尔原始序列每次折半是最容易实现的也足够应对大多数场景。如果你追求更好的性能可以用Knuth序列它的生成公式是h 3 * h 1从1开始递推直到超过序列长度为止然后反向使用。实操心得增量序列的选择没有绝对的最优解跟数据规模、数据分布都有关系。对于小规模数据几千个元素以内用哪种序列差别不大对于大规模数据Sedgewick序列通常表现最好但实现起来稍微复杂一些。3. Python实现与逐行解析3.1 基础版本希尔原始增量先来看一个最基础的实现用希尔原始序列每次折半作为增量def shell_sort_basic(arr): n len(arr) gap n // 2 # 初始增量取长度的一半 while gap 0: # 对每个分组进行插入排序 for i in range(gap, n): temp arr[i] j i # 在当前分组内做插入排序 while j gap and arr[j - gap] temp: arr[j] arr[j - gap] j - gap arr[j] temp gap // 2 # 增量折半 return arr这段代码看起来跟插入排序很像区别在于比较和移动的步长从1变成了gap。插入排序里是arr[j-1]这里是arr[j-gap]。当gap等于1的时候它就完全退化成了标准的插入排序。逐行拆解一下关键部分gap n // 2初始增量取长度的一半这是希尔原始序列的做法。比如长度是10初始gap就是5。while gap 0外层循环控制增量的缩小直到gap变成0才停止。for i in range(gap, n)从索引gap开始遍历因为每个分组的前gap个元素分别是各组的第一个元素单个元素本身是有序的不需要处理。temp arr[i]把当前元素暂存起来避免在移动过程中被覆盖。while j gap and arr[j - gap] temp在当前分组内往前找位置如果前一个距离gap元素比temp大就把它往后挪。arr[j] temp找到合适位置后把temp放进去。gap // 2增量折半准备下一轮。3.2 优化版本Knuth增量序列Knuth序列的生成方式是h 3 * h 1得到的序列是1, 4, 13, 40, 121, ...。使用的时候要从大到小反向使用。实现如下def shell_sort_knuth(arr): n len(arr) # 生成Knuth增量序列 gap 1 while gap n // 3: gap 3 * gap 1 while gap 0: for i in range(gap, n): temp arr[i] j i while j gap and arr[j - gap] temp: arr[j] arr[j - gap] j - gap arr[j] temp gap // 3 # Knuth序列的逆运算 return arr跟基础版本的区别只有两处增量的生成方式和缩小方式。生成的时候用gap 3 * gap 1递推缩小的时候用gap // 3。这样得到的增量序列比简单的折半更加“均匀”能更好地避免某些特殊数据分布导致的性能退化。3.3 带详细日志的调试版本如果你正在学习这个算法建议加一些打印语句来观察每一轮排序后序列的变化这样能直观地感受到希尔排序是怎么一步步把数据变有序的def shell_sort_verbose(arr): n len(arr) gap n // 2 round_num 1 print(f原始序列: {arr}) print(f序列长度: {n}) print(- * 50) while gap 0: print(f第{round_num}轮排序, gap {gap}) for i in range(gap, n): temp arr[i] j i while j gap and arr[j - gap] temp: arr[j] arr[j - gap] j - gap arr[j] temp print(f排序后: {arr}) print(- * 50) gap // 2 round_num 1 return arr拿[8, 9, 1, 7, 2, 3, 5, 4, 6, 0]跑一下你会看到每一轮之后序列都在变得更有序最后一轮gap1的时候几乎不需要做什么移动就完成了。3.4 性能对比测试光看代码不过瘾实际跑一下对比才有感觉。下面这段代码对比了插入排序和希尔排序在不同规模数据上的表现import time import random def insertion_sort(arr): for i in range(1, len(arr)): temp arr[i] j i - 1 while j 0 and arr[j] temp: arr[j 1] arr[j] j - 1 arr[j 1] temp return arr def shell_sort(arr): n len(arr) gap n // 2 while gap 0: for i in range(gap, n): temp arr[i] j i while j gap and arr[j - gap] temp: arr[j] arr[j - gap] j - gap arr[j] temp gap // 2 return arr # 测试不同规模 for size in [1000, 5000, 10000]: data [random.randint(0, 100000) for _ in range(size)] data1 data.copy() start time.time() insertion_sort(data1) t1 time.time() - start data2 data.copy() start time.time() shell_sort(data2) t2 time.time() - start print(f数据规模: {size}) print(f 插入排序耗时: {t1:.4f}秒) print(f 希尔排序耗时: {t2:.4f}秒) print(f 速度提升: {t1/t2:.1f}倍) print()我在自己的机器上跑的结果大概是这样的1000个元素时插入排序约0.08秒希尔排序约0.01秒提升8倍左右10000个元素时插入排序约8秒希尔排序约0.15秒提升超过50倍。数据规模越大希尔排序的优势越明显。注意这个测试结果跟机器性能、Python版本、数据分布都有关系你跑出来的数字可能不一样但趋势应该是一致的。4. 实操中容易踩的坑与排查技巧4.1 增量缩小方式写错导致死循环这是新手最容易犯的错误之一。比如把gap // 2写成了gap / 2在Python 3里除法会得到浮点数while gap 0永远成立程序就死循环了。或者把gap // 2写成了gap - 2当gap是奇数的时候可能会跳过gap1导致排序结果不正确。排查方法很简单在循环里打印gap的值看看它是不是按照预期的序列在变化最后有没有变成1。如果gap变成了0或者负数那说明缩小方式有问题。4.2 内层循环边界条件搞混插入排序的内层循环条件是j 0希尔排序里变成了j gap。这个变化很容易搞混。如果写成j 0当j小于gap的时候arr[j - gap]就会变成负数索引在Python里会从列表末尾取值导致完全错误的排序结果。# 错误写法 while j 0 and arr[j - gap] temp: # j-gap可能为负数 # 正确写法 while j gap and arr[j - gap] temp:实操心得Python的负索引是个双刃剑用好了很方便用不好就是隐藏的bug。在涉及索引运算的算法里一定要特别注意边界条件。4.3 增量序列没有反向使用如果用Knuth序列或者其他递推生成的序列生成的时候是从小到大但使用的时候必须从大到小。我见过有人直接把生成好的序列从小到大遍历结果gap从1开始第一轮就是标准插入排序完全失去了希尔排序的意义。正确的做法是先生成最大的gap然后逐步缩小。上面的Knuth实现里while gap n // 3: gap 3 * gap 1这一步就是在找到不超过序列长度的最大Knuth数然后通过gap // 3逐步缩小。4.4 对稳定性有要求时选错了算法希尔排序是不稳定排序。什么意思呢如果序列里有两个相等的元素排序之后它们的相对位置可能会改变。比如[5a, 3, 5b, 1]排序后可能变成[1, 3, 5b, 5a]5a和5b的顺序变了。如果你需要稳定排序那就不能用希尔排序得用插入排序或者归并排序。但如果你只是对一堆数字排序不关心相等元素的原始顺序那希尔排序完全没问题。4.5 常见问题速查表问题现象可能原因解决方法程序死循环gap缩小方式错误gap永远大于0检查gap // 2或gap // 3排序结果不正确内层循环边界写成j 0改为j gap排序结果部分有序增量没有缩小到1确保最后一轮gap等于1性能不如预期增量序列选择不当尝试Knuth或Sedgewick序列负索引导致数据错乱j - gap为负数确保循环条件j gap相等元素顺序改变希尔排序本身不稳定改用稳定排序算法4.6 一个隐蔽的坑Python列表的引用问题如果你在测试的时候写了这样的代码data [5, 3, 8, 1] sorted_data shell_sort(data) print(data) # 输出什么你会发现data也被修改了因为Python里列表是可变对象传参传的是引用。希尔排序是原地排序会直接修改传入的列表。如果你不想让原始数据被修改需要先复制一份data [5, 3, 8, 1] sorted_data shell_sort(data.copy()) print(data) # [5, 3, 8, 1] 原始数据不变这个坑在对比测试的时候特别容易踩因为你要用同一份数据跑不同的算法如果不复制第一个算法跑完之后数据就已经有序了后面的算法跑的就是有序数据对比结果完全失真。5. 希尔排序的适用场景与性能边界5.1 什么场景下优先考虑希尔排序希尔排序最适合的场景是中等规模、内存受限、对稳定性没有要求的排序任务。具体来说数据量在几千到几十万之间插入排序太慢但快速排序和归并排序的递归开销又不划算。嵌入式环境或者内存非常紧张的场景希尔排序只需要常数级别的额外空间。作为其他高级排序算法的预处理步骤比如快速排序在递归到小数组时可以用希尔排序收尾。面试中考察对插入排序优化思路的理解希尔排序是一个很好的切入点。跟其他O(n log n)级别的算法相比希尔排序的时间复杂度虽然理论上不如它们但实际运行时的常数因子比较小在中等规模数据上往往能打平甚至胜出。5.2 什么场景下不建议用希尔排序如果数据量非常大百万级以上希尔排序就不太合适了这时候快速排序、归并排序或者堆排序会更靠谱。如果对稳定性有要求也不能用希尔排序。如果数据本身已经基本有序直接用插入排序就够了没必要上希尔排序。还有一个容易被忽略的点希尔排序的性能对增量序列非常敏感不同的增量序列在不同数据分布下表现差异很大。如果你要把它用在生产环境里建议先用真实数据做一轮基准测试确认性能达标再上线。5.3 与其他排序算法的横向对比算法平均时间复杂度最坏时间复杂度空间复杂度稳定性适用场景插入排序O(n²)O(n²)O(1)稳定小规模或基本有序希尔排序O(n^1.3)O(n²)O(1)不稳定中等规模快速排序O(n log n)O(n²)O(log n)不稳定大规模通用归并排序O(n log n)O(n log n)O(n)稳定大规模且要求稳定堆排序O(n log n)O(n log n)O(1)不稳定大规模且内存受限从表里可以看出希尔排序处在一个比较尴尬的位置比插入排序快但不如快速排序和归并排序不需要额外空间但又不是稳定的。它的价值主要体现在实现简单、常数因子小、不需要递归这几个方面。5.4 增量序列的实测对比我拿10000个随机整数做了一组测试对比不同增量序列的实际耗时增量序列耗时秒相对表现希尔原始折半0.152基准Knuth序列0.118快22%Hibbard序列0.131快14%Sedgewick序列0.103快32%这个结果跟理论分析基本吻合Sedgewick序列表现最好Knuth次之希尔原始序列最慢。但差距并没有数量级上的区别对于日常使用来说折半法已经够用了。实操心得如果你只是写个作业或者做个面试题用折半法完全没问题。如果是在生产环境里追求极致性能可以试试Sedgewick序列但要注意它的生成逻辑稍微复杂一些需要提前计算好序列。6. 从希尔排序延伸出的算法思维6.1 “先粗后细”的优化哲学希尔排序最核心的思想不是某个具体的代码技巧而是一种先粗后细、逐步逼近的优化哲学。它不追求一步到位把序列排好而是先做一个粗糙的预处理让数据大致有序再逐步精细化最后用简单算法收尾。这种思路在很多地方都能看到。比如图像处理里的多分辨率分析先处理低分辨率图像得到大致轮廓再逐步提高分辨率细化细节。再比如机器学习里的课程学习先学简单的样本再学复杂的样本。甚至在做项目规划的时候也是先搭框架再填细节而不是一上来就抠每一个像素。理解了这层思想你就不会觉得希尔排序只是一个孤立的算法而是把它看作一种通用的优化策略。6.2 增量思想的更多应用增量的本质是在序列上跳跃式地建立连接。gap大的时候远距离的元素可以快速交换位置gap小的时候近距离的元素可以精细调整。这种“多尺度”的处理方式在字符串匹配、数据压缩等领域也有类似的应用。比如在字符串匹配的Boyer-Moore算法里坏字符规则和好后缀规则本质上也是在利用不同尺度的信息来加速搜索。再比如在数据压缩里LZ77算法用滑动窗口来寻找重复模式窗口大小就类似于希尔排序里的gap。6.3 面试中怎么答希尔排序如果面试官问你希尔排序不要只背代码可以从这几个角度展开第一先说它解决了什么问题。插入排序在乱序大数据上效率低希尔排序通过增量分组让数据先基本有序再用插入排序收尾。第二解释增量的作用。gap大于1的时候元素可以跨越多个位置移动减少了总的移动次数。gap逐步缩小最后等于1的时候做标准插入排序。第三说明增量序列的影响。不同的增量序列会导致不同的时间复杂度好的序列可以降到O(n^1.3)左右。第四提一下它的局限。不稳定排序时间复杂度依赖增量序列最坏情况仍然是O(n²)。这样答下来面试官会觉得你不仅会写代码还理解算法背后的设计思想。6.4 自己动手做一个小实验如果你想更深入地理解希尔排序可以做一个简单的实验用同样的数据集分别用不同的增量序列跑一遍记录每一轮排序后的“逆序数”即有多少对元素是逆序的。你会发现gap大的时候逆序数下降得很快gap小的时候下降得慢但更精细。这个实验能让你直观地感受到增量序列的作用。def count_inversions(arr): 计算序列中的逆序对数量 count 0 for i in range(len(arr)): for j in range(i 1, len(arr)): if arr[i] arr[j]: count 1 return count def shell_sort_with_inversion_tracking(arr): n len(arr) gap n // 2 while gap 0: for i in range(gap, n): temp arr[i] j i while j gap and arr[j - gap] temp: arr[j] arr[j - gap] j - gap arr[j] temp print(fgap{gap}, 逆序数{count_inversions(arr)}) gap // 2 return arr跑一下这个代码你会看到逆序数在每一轮之后都在减少而且减少的幅度跟gap的大小有关系。这个观察能帮你建立起对希尔排序性能的直觉。6.5 关于Python实现的性能优化建议Python的循环效率不高如果数据量很大纯Python实现的希尔排序可能会比较慢。有几个优化方向用while循环代替for循环减少迭代器开销。把频繁访问的变量如arr、gap绑定到局部变量减少全局查找。如果允许用第三方库可以考虑用NumPy的向量化操作来加速但希尔排序本身不太容易向量化。对于超大规模数据直接用Python内置的sorted()函数它底层用的是TimSort性能远超手写的希尔排序。提示学习算法的时候手写实现是为了理解原理实际项目中该用内置函数就用内置函数没必要重复造轮子。6.6 一个容易忽略的细节数据分布的影响希尔排序的性能跟数据分布有很大关系。对于完全随机的数据它的表现比较稳定对于已经部分有序的数据它可能比插入排序还慢因为额外的分组操作增加了开销对于有大量重复元素的数据它的表现也会受到影响。我在实际测试中发现当数据中重复元素超过50%的时候希尔排序的优势就不明显了甚至可能不如优化过的插入排序。所以如果你要处理的数据有大量重复值建议先做去重或者用三路快速排序。这个细节在教科书里很少提到但实际工作中经常会遇到。比如处理用户行为日志的时候很多字段的取值就那么几种重复率非常高这时候选排序算法就要多留个心眼。
返回列表