计数排序(Counting Sort)的进阶应用:处理大范围整数的高效内存与稳定性优化
题目描述
你被给定一个包含 n 个整数的数组 arr,数组中的元素是整数,但其值范围可能很大(例如,从 -10^9 到 10^9)。你需要设计一个基于计数排序原理的排序算法,使其能够:
- 高效处理大范围整数:传统的计数排序需要创建一个长度为值域大小的计数数组,当值域很大时,这会消耗巨大且不切实际的内存。你需要优化这一点。
- 保持计数排序的稳定性:优化后的算法在排序过程中,必须保持相等元素的原始相对顺序。
- 时间复杂度与空间复杂度分析:在 n 远小于值域范围的情况下,你的算法应具有接近 O(n) 的期望时间复杂度,并优化空间使用。
核心挑战:如何在不直接创建一个覆盖整个值域的巨大数组的情况下,利用计数排序的核心思想(统计频率、计算前缀和、确定位置)来实现稳定排序?
解题过程
第一步:理解传统计数排序的限制
传统计数排序的步骤如下:
- 找出数组中的最大值
max和最小值min。 - 创建一个计数数组
count,长度为max - min + 1,用于统计每个值出现的次数。 - 对
count数组求前缀和,此时count[i]表示值(min + i)在排序后数组中最后一次出现的位置索引(从1开始计,或理解为小于等于该值的元素个数)。 - 反向遍历原数组,根据
count数组确定每个元素在输出数组中的位置,并将该元素放入输出数组,同时将对应计数减1,以维持稳定性。
问题:当 max - min + 1 非常大(比如 2×10^9)时,创建如此大的 count 数组是内存灾难,即使使用稀疏表示,也可能效率低下。
第二步:优化思路——分桶 + 多轮计数排序
我们可以将大范围整数的排序分解为多个步骤,每次只依据整数的一部分(比如若干位)进行排序。这类似于基数排序(Radix Sort) 的思想,但我们将利用计数排序作为每一轮的稳定排序子过程。
具体来说,我们可以选择一个合适的“基数”(base)或“位宽”(bit-width),例如 16 位。然后将每个整数视为由多个“数字”(digit)组成,每个“数字”的值域较小(比如 0 到 2^16-1)。我们从最低有效“数字”到最高有效“数字”进行多轮稳定的计数排序。
为什么这样有效?
- 内存可控:每轮计数排序的
count数组大小只与“基数”有关,例如基数为 2^16=65536,这个大小是固定的、可接受的。 - 稳定性保证:计数排序本身是稳定的,我们从低位到高位进行排序,最终能保证整体顺序的正确性(这称为最低有效位优先 LSD 基数排序)。
第三步:详细算法步骤
假设我们选择基数为 R = 65536 (2^16),即每次处理整数的低16位。对于一个32位整数,我们需要2轮;对于64位整数,需要4轮。
-
预处理:将整数数组视为包含负数和正数。为了统一处理,我们将所有整数转换为无符号整数表示,通过加上一个偏移量(offset)使其值非负。对于有符号整数,一个常用的偏移是
-INT_MIN或对每个数位取补码表示。但更通用的方法是:在每一轮提取“数字”时,我们直接处理整数的二进制位,并提取特定的位段。 -
逐轮排序:
- 设总轮数
d = ceil(总位数 / 16)。对于32位整数,d=2。 - 对于每一轮
k(从0到 d-1,对应从最低有效位到最高有效位):
a. 提取当前位的“数字”:对于数组中的每个整数num,计算当前轮次对应的“数字”:digit = (num >> (16*k)) & 0xFFFF。这里& 0xFFFF是取低16位。
b. 对提取出的“数字”数组进行计数排序:
i. 创建计数数组count,长度为R(即65536),初始化为0。
ii. 遍历原数组,对每个元素的当前位数字digit,执行count[digit]++。
iii. 对count数组求前缀和,使得count[i]表示“数字”小于等于i的元素个数。
iv. 创建一个临时输出数组output,长度与原数组相同。
v. 反向遍历原数组(为了保持稳定性):
- 对于当前元素,计算其当前位数字digit。
- 查找其在输出数组中的位置:pos = count[digit] - 1。
- 将整个元素(而不仅仅是数字)放入output[pos]。
- 执行count[digit]--。
vi. 将output数组复制回原数组,作为下一轮排序的输入。 - 经过 d 轮后,数组完全有序。
- 设总轮数
-
处理负数:对于有符号整数,如果我们直接按上述位提取,负数(补码表示)的高位会全是1,导致排序不正确。我们需要一个技巧:在提取“数字”之前,先将整数重新解释为无符号整数,并调整最高位的比较顺序。一种标准方法是:
- 对于有符号整数,在最后一轮(最高有效位)排序时,我们对提取出的“数字”进行转换:将最高位(符号位)取反,使得负数映射到较小的无符号范围,正数映射到较大的范围。但更简单且通用的做法是:
- 在排序前,将所有整数与一个偏移量做异或,使得最高位取反。例如,对于32位整数,可以在排序前执行:
num ^= 0x80000000(将符号位取反)。这样,负数就变成了较大的正数,正数变成了较小的正数。排序完成后再将每个数异或同样的值恢复。
第四步:时间复杂度与空间复杂度分析
- 时间复杂度:设数组长度为
n,基数为R(例如65536),总轮数为d(例如对于32位整数,d=2)。每轮计数排序的时间复杂度为 O(n + R)。因此总时间复杂度为 O(d * (n + R))。当 n 较大且 R 是常数时,近似为 O(n)。 - 空间复杂度:我们需要一个大小为 R 的计数数组,以及一个大小为 n 的临时输出数组。因此空间复杂度为 O(n + R)。由于 R 是常数(例如65536),空间复杂度主要取决于 n,是可接受的。
第五步:稳定性证明
算法的稳定性由每轮计数排序的稳定性保证。计数排序是稳定的,因为我们反向遍历数组并放置元素,确保了相同“数字”的元素保持原有的相对顺序。由于我们是从最低有效位到最高有效位进行排序,高位排序不会破坏低位已建立的顺序,因此最终排序结果是稳定的。
第六步:扩展与思考
- 基数选择:基数 R 的选择是一个权衡。R 越大,需要的轮数 d 越少,但每轮计数数组也越大,可能占用更多内存。R 越小,轮数增多,但每轮计数数组小。通常选择 R=256(1字节)或 R=65536(2字节)以平衡内存和速度。
- 自适应优化:可以动态检测数组中整数的实际范围,如果范围很小,则退化为单轮传统计数排序;如果范围很大,则采用多轮分治策略。
- 与其他排序结合:对于非常大的 n 和极大值域,可以将分桶后的数据使用快速排序或归并排序进一步处理,形成混合排序策略。
总结:通过将大范围整数按位分解,并利用稳定的计数排序逐位排序,我们能够在可控的内存消耗下,以接近线性的时间复杂度完成排序,同时保持算法的稳定性。这种方法结合了计数排序和基数排序的优点,是对传统计数排序在处理大范围整数时的重要进阶优化。