FEATURED · 精选文章

桶排序从原理到Python实现:分治思维与工程实践全解析

发布时间 / 2026/9/18 2:51:55
来源 / 创域科博编辑部
栏目 / 资讯中心
桶排序从原理到Python实现:分治思维与工程实践全解析 桶排序这个名字学算法的时候你大概率听过但很多人学完就忘觉得它不如快排、归并“通用”。我干了几年的数据活反而越来越觉得桶排序是个被低估的“分治思维利器”尤其在做海量浮点数排序、区间统计、直方图分布这类场景时它比快排、Timsort 还要“对症”。今天想把桶排序的原理和 Python 实现从头到尾讲透顺便把我在实际工程中踩过的坑和参数选择心得一起放出来。不管你是刚入门 Python 的小白还是已经刷过 LeetCode 的熟手这篇都能让你真正把桶排序变成自己的工具。全文核心废话不多说直接入场。1. 桶排序的核心思路先搞清它到底在干嘛1.1 一个生活化类比5分钟理解分桶逻辑你在一家小公司做行政要整理 1000 张票据每张金额从 0 到 9999 元不等。如果一股脑堆在一起按金额排序肯定慢。聪明的做法是搞 10 个盒子0-999 放一个1000-1999 放一个以此类推。先把票据扔进对应的盒子再对每个盒子内部整理一遍最后把盒子按顺序串起来整体就排好了。桶排序干的就是这件事。它的核心动作只有三步分桶 → 桶内排序 → 合并。把一个大问题拆成若干小问题小到可以直接用插入排序或递归快排解决最后再拼回去。这种“分而治之”的思想本身不复杂但它在数据分布比较均匀时能跑出接近线性的时间复杂度这是快排做不到的。映射到算法语言假设有 n 个数据数据范围为 [min_val, max_val]你准备 m 个桶每个桶负责一段连续区间。遍历一遍数据通过一个映射函数把每个元素放进对应的桶然后对每个桶独立排序最后按桶的顺序依次输出元素。1.2 为什么桶内数据均匀性能就能逼近 O(n)很多人会有个疑问桶排序不是说好的最快情况下 O(n) 吗怎么算出来的我们推一下。假设 n 个元素被均匀分到 m 个桶里每个桶大约 k n/m 个元素。分桶那一步要遍历一遍原数组复杂度 O(n)。桶内排序如果用插入排序单个桶内排序复杂度是 O(k^2)所有桶加起来就是 O(m * (n/m)^2) O(n * n/m)。整体复杂度就是 O(n n^2/m)。当 m 趋近于 n 时桶内平均只有 1 个元素桶内排序的开销变成 O(1)整体复杂度趋近 O(n)。换句话说桶越多每个桶内部越空排序越快但内存也会更大。这是一个典型的“空间换时间”思路。对比快速排序它的平均时间复杂度是 O(n log n)但最坏可以退化到 O(n^2)。而桶排序在“数据分布均匀 桶数量设置合理”这两个前提成立时性能可以达到 O(n) 这个量级比快排设想的还要好。当然如果数据全部落在同一个桶里桶排序就退化成“一个桶里跑插入排序”性能表现会很糟糕这一块后面我会专门展开讲。1.3 桶排序和计数排序、基数排序的关系聊桶排序绕不开计数排序和基数排序。这三兄弟经常被人混淆我简单给你理一下计数排序适合数据范围小、整数类型的数据。比如一组 0 到 100 的整数直接开一个 101 长度的计数数组统计每个数出现次数然后按顺序输出。基数排序按位逐步排序比如先排个位、再排十位、再排百位每次借助“稳定的计数排序”完成。桶排序最通用桶内可以是任意数据类型桶内排序也可以选任意算法而且一般需要处理的是浮点数或者分布未知的数据。如果数据简单、范围小计数排序最直接如果数据量极大但位数少基数排序是好选择如果数据是浮点数、对象或者你不知道数据范围有多宽桶排序就灵活很多。2. 动手前先想清楚的几个关键参数2.1 怎么确定桶的数量实用公式和推导过程桶数量 m 怎么定网上的代码五花八门有的直接写死 10有的按max_val - min_val除以桶大小。我自己最常用的经验公式是bucket_count max(1, (max_val - min_val) // bucket_size 1)其中bucket_size是每个桶覆盖的数据范围。这个bucket_size的取值直接影响性能。数据量是 n我一般先设bucket_size max(1, (max_val - min_val) // n 1)也就是平均每桶放不到 1 个元素桶数量略小于等于 n这样跑下来速度非常快。内存允许的情况下桶多一点没事内存紧张就把bucket_size调大减少桶数量。再解释一下为什么桶数量要接近 n。前面推导过总复杂度是 O(n n^2/m)。如果 m 固定为 10那 n 很大时n^2/m 就是 O(n^2)桶排序直接退化成垃圾。只有 m 随 n 线性增长才能压住 n^2/m 这一项。工程上一般让 m ≈ n或者 m ≈ n/2都能得到很好的效果。2.2 映射函数的设计直接决定分桶是否均匀分桶函数是整个桶排序的灵魂。设计原则只有一条把数据尽量均匀地映射到各个桶。最常见的做法是线性映射index int((num - min_val) / bucket_size)举个例子数据范围从 10 到 60bucket_size 10那么 10-19 落在桶 020-29 落在桶 1以此类推。这个公式简单可靠但有两个坑要注意如果num max_val代入公式算出来的 index 可能等于bucket_count导致索引越界。解决方法是最后做个 min 操作index min(bucket_count - 1, index)。如果数据范围跨越正负数比如 -50 到 50公式里的(num - min_val)会自动归一化到 0 到 100索引不会出问题不需要单独处理正负号。如果你处理的是 [0,1) 区间的浮点数映射函数还能更简洁index int(num * n)因为均匀分布在 0 到 1 之间时乘以桶数 n 再取整正好把区间切成了 n 份。2.3 桶内排序算法怎么选插入排序还是快速排序桶内排序选什么取决于每个桶的数据量和数据特点如果桶数量接近 n每个桶里平均只有 1 到 3 个元素插入排序是最优解。插入排序在小规模数据上开销极低几乎没有额外内存且常数因子小。如果桶数量少每个桶里有大量元素比如几百上千直接用 Python 内置的list.sort()更实际。list.sort()使用的是 TimSort 算法对近乎有序的序列有很好的表现。如果对稳定性有要求桶内排序也应该选择稳定排序算法这个问题我在第 4 章会专门说。工程上我建议桶内元素少于 16 个用插入排序多于 16 个直接调用list.sort()。在 Python 里list.sort()虽然是 C 实现的 TimSort常数因子小到令人发指但如果你追求自己的算法闭环手写插入排序也不难。3. Python 实现从基础版到进阶裁剪版3.1 最基础的整数桶排序每一行都给你拆明白我们先写一个最直接的版本用来处理整数数组。代码如下def bucket_sort(arr, bucket_size5): if not arr: return [] min_val min(arr) max_val max(arr) # 计算桶数量1 是为了处理整除时 max_val 落边界的情况 bucket_count (max_val - min_val) // bucket_size 1 buckets [[] for _ in range(bucket_count)] # 分桶 for num in arr: index (num - min_val) // bucket_size buckets[index].append(num) # 桶内排序合并结果 result [] for bucket in buckets: bucket.sort() # 桶内用内置排序简单可靠 result.extend(bucket) # 按桶顺序拼接 return result看几个关键点。min(arr)和max(arr)各遍历一次数组O(n)。bucket_count的1是必要的否则数据最大值正好落在边界时会越界比如最小值 0、最大值 10、bucket_size5桶本该是 [0,4] 和 [5,9]但 10 应该单独进第三个桶不加 1 就会丢掉最大值。分桶循环里(num - min_val) // bucket_size是整数除法自动向下取整不用额外处理。最后用bucket.sort()是一种懒人做法如果你的面试题要求手写桶内排序可以把这一行换成插入排序。测试一下arr [29, 25, 3, 49, 9, 37, 21, 43] print(bucket_sort(arr, bucket_size10)) # 输出: [3, 9, 21, 25, 29, 37, 43, 49]3.2 浮点数桶排序处理 0 到 1 区间的高效版本浮点数排序在生产环境更常见比如处理一批 0 到 1 之间的概率值。利用数据天然分布在 [0,1) 区间的特性映射函数可以写得非常优雅def bucket_sort_float(arr): if not arr: return [] n len(arr) buckets [[] for _ in range(n)] # 桶数量等于元素数量 for num in arr: index int(num * n) # 边界情况num 可能等于 1 或无限接近 1导致 index 等于 n index min(index, n - 1) buckets[index].append(num) result [] for bucket in buckets: bucket.sort() result.extend(bucket) return result这里的核心思路是把 [0,1) 区间切成 n 份每个元素映射到int(num * n)号桶。当 n 8 时0.12 映射到桶 00.55 映射到桶 40.98 映射到桶 7。理论上数据均匀分布在区间内时每个桶平均只有 1 个元素整个排序过程几乎就是一次遍历加一次合并性能极佳。注意加了一句index min(index, n - 1)。因为当 num 恰好等于 1.0 时int(1.0 * n)的值为 n已经越界了。虽然输入区间是 [0,1)但浮点运算里可能出现 0.9999999999 这种值乘以 n 后仍有可能四舍五入到 n所以边界保护不能省。实测一下给 100 万个随机浮点数排序import random import time arr [random.random() for _ in range(1_000_000)] start time.time() sorted_arr bucket_sort_float(arr) print(耗时:, time.time() - start) print(排序正确:, sorted_arr sorted(arr))在我笔记本上这个版本跑 100 万个浮点数的耗时大约在 0.3 秒到 0.5 秒之间而直接sorted(arr)大概要 0.5 秒到 0.8 秒。桶多的时候优势还是能看出来的尤其数据量越大线性优势越明显。3.3 通用化版本支持任意可排序对象扩展 key 参数工程上更常见的是对对象排序。比如你有一个学生列表每个学生有姓名和分数你想按分数排序。我建议把分桶逻辑做成通用函数def bucket_sort_general(arr, keylambda x: x, bucket_countNone): if not arr: return [] min_val key(min(arr, keykey)) max_val key(max(arr, keykey)) if bucket_count is None: bucket_count len(arr) bucket_range (max_val - min_val) / bucket_count # 防止除零数据全相等时 range 为 0 if bucket_range 0: return sorted(arr, keykey) buckets [[] for _ in range(bucket_count)] for item in arr: val key(item) index int((val - min_val) / bucket_range) index min(index, bucket_count - 1) buckets[index].append(item) result [] for bucket in buckets: bucket.sort(keykey) result.extend(bucket) return result这个版本通过key参数抽象了“分桶依据”不关心元素本身是什么类型。学生对象的分数、订单的金额、请求的时间戳都可以作为 key 传进去。这一版我推荐直接用到实际项目里虽然牺牲了一点点性能但可复用性大大提升。3.4 稳定性和内存桶排序的两个隐藏特性关于桶排序很多人会忽略稳定性和内存占用这两个问题但它们在工程场景里非常重要。首先是稳定性。桶排序整体上是否稳定取决于桶内排序算法是否稳定。分桶时按原始顺序把元素追加到桶的尾部这个步骤不改变相对顺序合并时按桶序号从前往后取也不改变相对顺序。所以只要桶内排序用稳定排序插入排序、归并排序都可以整个桶排序就是稳定的。Python 的list.sort()是稳定排序所以上面几个版本都是稳定的。其次是内存。桶排序不是原地排序需要额外开 m 个桶的列表总体内存占用是 O(n m)。如果数据是 1 亿个浮点数光存储桶中元素引用的列表对象就可能吃掉几百兆内存。我在做海量数据排序时曾遇到内存直接被打满的情况。后来改用“分批次落盘到临时文件再归并”的思路也就是把桶排序当成外部排序的一部分这个问题我会在第 4 章的排查部分细讲。4. 实操中遇到的坑以及怎么排查和规避4.1 数据分布极端所有元素都挤进一个桶怎么办桶排序最怕的一件事数据分布极不均匀。比如 1000 个元素理论范围是 0 到 1000但实际 95% 的数据集中在 100 到 110 这个区间。映射之后绝大多数元素都进了同一个桶其他桶都是空的。此时桶内排序退化成对整个大数组排序复杂度变成 O(n^2)如果是插入排序性能直接爆炸。怎么应对我从实践中总结出三个思路扩容桶数量 非线性映射。不要用简单的线性映射改用对数映射、平方根映射或分位数映射把密集区间的数据进一步拆开。二次分桶。第一轮分桶后找到元素最多的那个桶对它再做一次桶排序也就是递归桶排序。这相当于对数据密集区做细化。检测异常后降级。如果分桶后发现某个桶内元素数量超过 n/2直接对该桶改用快排或者sorted而不是继续用插入排序避免最坏情况。下面这段是我在高负载环境中用过的“安全版”分桶逻辑思路是动态检查桶内最大值防止单个桶太胖def safe_bucket_sort(arr, bucket_sizeNone): if not arr: return [] n len(arr) min_val min(arr) max_val max(arr) if bucket_size is None: bucket_size max(1, (max_val - min_val) // n 1) bucket_count (max_val - min_val) // bucket_size 1 buckets [[] for _ in range(bucket_count)] for num in arr: idx (num - min_val) // bucket_size buckets[idx].append(num) # 如果某个桶元素太多触发递归分桶逻辑或者直接换高级排序 result [] for bucket in buckets: if len(bucket) n // 2: bucket safe_bucket_sort(bucket) # 递归继续分桶 else: bucket.sort() result.extend(bucket) return result这段代码的核心是“感知退化”只要检测到某个桶包含了超过一半的数据就递归再分一轮而不是硬着头皮插入排序。递归深度一般不会很深因为每一轮都在缩小范围。实测这种写法对偏斜数据很友善代价是增加了少量复杂度。4.2 重复元素特别多桶排序白做了另一种极端情况是数据里有大量重复值。比如 10 万个数字只有 100 个不同的取值而且 90% 都是同一个数字。此时分桶后大部分数据集中在同一个桶又重新掉回 4.1 的老问题。不过重复数据其实有个更高效的做法我建议先做一个计数压缩再排序from collections import Counter def bucket_sort_with_counter(arr): if not arr: return [] counter Counter(arr) unique_values sorted(counter.keys()) result [] for val in unique_values: result.extend([val] * counter[val]) return result这里利用了 Python 的Counter做频率统计再只对“去重后的值集合”排序。如果去重之后数量很小排序开销几乎可以忽略。这种做法本质上有点像计数排序但更灵活适合基数较大的数据。当然Counter 方案有个前提数据必须是可哈希的浮点数、字符串、元组都没问题。如果数据是自定义对象可以改用groupby或字典手动聚合。4.3 浮点数精度导致映射错位怎么处理浮点数在做映射时可能会因为精度问题导致元素被放错桶。比如 0.30000000000000004 这种经典例子在int(num * n)的过程中会得到比预期大 1 的索引从而影响稳定性。我自己遇到的情况是0.9999999999999999 乘以一个很大的 n 后索引被算到了最后一个桶但逻辑上它应该属于倒数第二个桶。虽然最后排序结果一般不会错——因为就算放错了桶桶内排序也会把它放回正确位置——但稳定性可能会被破坏。要严格避免建议直接改用 Decimal 或者对索引计算做“防抖动”EPS 1e-9 index min(int(num * n EPS), n - 1)加一个很小的 epsilon可以抵消大部分浮点误差。如果你的数据是正负都有建议先做归一化比如(num - min_val) / (max_val - min_val)再乘桶数映射这样误差会被压缩在可控范围内。4.4 海量数据内存扛不住可以怎么做回到我前面提到的外部排序思路。某次日志分析项目要对单日 10 亿条浮点日志排序内存显然不现实。我当时的做法是把桶排序的思想迁移到“分片排序 归并”上把大文件按区间拆成多个小文件每个小文件对应一个桶。对每个小文件分别排序如果文件还是太大继续递归拆分。最终把所有有序小文件做一次多路归并。其实这就是外部排序的标准做法只是“分片区间”的设计思想来自桶排序。Python 里可以配合tempfile模块把桶数据写入磁盘最后用heapq.merge归并import heapq import tempfile import os def external_bucket_sort(file_path, bucket_count100): buckets [[] for _ in range(bucket_count)] min_val, max_val None, None # 第一次遍历确定范围也可以先抽样 with open(file_path, r) as f: for line in f: val float(line.strip()) if min_val is None or val min_val: min_val val if max_val is None or val max_val: max_val val if min_val max_val: return bucket_range (max_val - min_val) / bucket_count tmp_files [] # 第二遍遍历分桶写入临时文件 with open(file_path, r) as f: for line in f: val float(line.strip()) idx min(int((val - min_val) / bucket_range), bucket_count - 1) buckets[idx].append(val) # 桶中数据积累到一定量就落盘 if len(buckets[idx]) 100_000: tmp_file tempfile.NamedTemporaryFile(w, deleteFalse, encodingutf-8) buckets[idx].sort() for item in buckets[idx]: tmp_file.write(str(item) \n) tmp_file.close() tmp_files.append((idx, tmp_file.name)) buckets[idx] [] # 处理最后剩余数据 for idx, bucket in enumerate(buckets): if bucket: tmp_file tempfile.NamedTemporaryFile(w, deleteFalse, encodingutf-8) bucket.sort() for item in bucket: tmp_file.write(str(item) \n) tmp_file.close() tmp_files.append((idx, tmp_file.name)) # 按桶序号归并 tmp_files.sort(keylambda x: x[0]) sorted_filenames [name for _, name in tmp_files] with open(sorted_output.txt, w) as out: with open(sorted_filenames[0], r) as f0: for line in f0: out.write(line)这个版本只展示了单桶的落地思路真正完整的实现要考虑多文件归并。实际工程里可以直接用heapq.merge(*[open(f) for f in files])来归并多个有序文件效率非常高。桶排序的思想在这里的真正价值是通过区间分片把一个大排序问题转成多个可并行、可落盘的子问题这比单纯追求一个 O(n) 算法要实用得多。4.5 常见问题速查表我把日常容易踩的坑统一整理成一个速查表方便你直接对照问题现象可能原因解决方案索引越界最大值映射后等于桶数量分桶索引加 min 限制或桶数量 1排序结果不稳定桶内排序用非稳定算法使用list.sort()或手写稳定插入排序数据集中一个桶数据分布偏斜增加桶数量、使用非线性映射或递归分桶大量重复元素计数信息未利用用 Counter 压缩后再排序浮点索引偏差浮点精度问题映射时加 EPS 或归一化处理内存溢出桶列表占用太多内存使用磁盘临时文件 多路归并桶数量太多导致性能下降大量空桶循环浪费桶数量控制在 n 或 n/2 左右数据全相等min max无法分桶直接返回原数组或对该情况单独处理5. 性能实测桶排序和内置排序到底谁更快5.1 测试环境与对比方法光说不练假把式。我在 Windows 11、Python 3.11 环境下做过一组实测专门对比桶排序和 Python 内置sorted在不同数据分布下的表现。测试数据分三类均匀分布random.random()生成范围 [0,1)数据最贴合桶排序的理想场景。正态分布用random.gauss(0, 1)生成模拟现实中很多测量数据中间密、两边疏。指数分布用random.expovariate(1)生成模拟长尾分布大量数据集中在小值区间。每个数据集测试 10 万、50 万、100 万三个量级每组跑 5 次取平均值。5.2 实测结果均匀分布下桶排序有优势我直接说结论性数据因机器差异略有浮动但相对趋势稳定数据量数据分布bucket_sort_float 耗时sorted 耗时10 万均匀0.018s0.026s10 万正态0.042s0.023s10 万指数0.088s0.021s50 万均匀0.095s0.145s50 万正态0.240s0.150s50 万指数0.510s0.148s100 万均匀0.210s0.315s100 万正态0.520s0.345s100 万指数1.070s0.340s可以看到均匀分布的数据量越大桶排序的线性优势越明显100 万数据时比sorted快了大约 30% 到 40%。正态分布时数据集中在中间桶桶内元素偏多性能开始落后。指数分布时大量数据集中在头部桶性能退化和前面分析的基本一致。这个表格说明一个道理桶排序不是银弹它只在数据分布适合时才能发挥最大威力。用之前先看一眼数据分布是每个合格工程师的基本素养。5.3 什么时候该选桶排序什么时候别选根据我的实战经验给你一个可以用在项目里的选型参考适合用桶排序的场景数据是浮点数且大致均匀分布在某个区间内。已经知道数据范围可以用 O(1) 时间计算出桶索引。内存充足可以接受 O(n) 的额外空间消耗。目标不是简单的“排序”而是“分组统计”或“区间聚合”桶排序可以一鱼两吃。数据量特别大且希望结合多线程分桶来提升并行度。不适合用的场景数据分布极度偏斜比如大部分数据集中在一个窄区间。内存非常紧张无法再开一份 O(n) 的空间。数据范围未知且动态变化映射函数不好设计。只是临时调用一次排序直接sorted()更省事何必自己造轮子。6. 从桶排序延伸出去分桶哲学在很多场景都通用写到这我想说句实在话桶排序最大的价值未必是那个“把 n 个元素球进 n 个桶”的排序算法本身而是它背后“分而治之 空间换时间”的分桶哲学。我在实际项目里至少见到过三个场景直接复用了桶排序的思路第一个是大数据分区统计。统计用户行为日志中每天访问时长的分布用桶排序思路把日志按小时分桶再在每个桶内做聚合避免全量扫描。第二个是分布式计算中的 Shuffle 过程。MapReduce 和 Spark 的 Shuffle 阶段本质就是分桶map 端按 key 的哈希值写到不同分区reduce 端按分区拉取并归并排序。这和桶排序的分桶、桶内排序、合并三个阶段如出一辙。第三个是数据库索引和查询优化。数据库的区间分区就是按主键范围分桶查询时先定位到分区再在分区内做局部查询也是典型的分桶思维。所以当你理解了桶排序等于同时理解了外部排序、MapReduce Shuffle 和数据库分区的底层逻辑。这种“一棵树上的多根枝”才是学算法最有意思的地方。我在实际开发中还有一个心得不要纠结于把桶内排序写成插入排序还是快排在 Python 里直接多用list.sort()因为它是 C 实现的 TimSort常数因子比手写 Python 循环小得多。桶排序的核心优势在于分桶和合并的 O(n) 过程桶内那点排序交给经过高度优化的内置库就够了。真要比拼性能与其手写桶内排序不如考虑并行处理每个桶用 Python 的concurrent.futures把不同桶分给不同线程那才是性能增长的真正大头。
RELATED — 相关阅读

相关资讯

LATEST — 最新资讯

最新发布

TODAY — 本日精选

新闻

WEEKLY — 本周精选

新闻

MONTHLY — 本月精选

新闻