排序算法之:基于“最小差值排序”(MinDiff Sort)的进阶应用:优化相邻元素最大差值问题
题目描述
我们有一个未排序的整数数组 nums,其长度为 n。 我们知道这个数组中的元素都是非负整数,并且其数值范围明确,比如在 0 到某个最大值 max_val 之间。 注意,这里的 max_val 通常可以是一个很大的数,但题目会保证其已知,并且我们无法假设 nums 中包含所有从 0 到 max_val 的整数,数组元素可能有重复,并且不一定是均匀分布的。
我们的目标是:在不对整个数组进行完全排序的前提下,找出数组中任意两个相邻元素在排序后(即数值上相邻)的最大差值。
更形式化地定义:设数组 nums 排序后的结果为 sorted_nums。我们需要计算:
max_gap = max(sorted_nums[i] - sorted_nums[i-1]),其中 1 <= i < n。
要求:
- 算法的时间复杂度应为 O(n)。
- 算法的空间复杂度应为 O(n) 或更低。
- 不能使用基于比较的排序(如快排、归并),因为它们的最坏时间复杂度是 O(n log n)。我们需要利用“数值范围已知”这一条件,设计出线性时间的算法。
这是一个经典的利用“桶排序”思想来解决的优化问题。下面我将循序渐进地讲解。
第一步:理解问题的核心与难点
如果数组已经排序,那么问题就变得很简单:遍历一遍排序后的数组,计算相邻差值,记录最大值即可。但排序本身至少需要 O(n log n) 时间。
问题的核心挑战在于:我们需要找出最大差值,但不需要知道每个元素具体在排序后的哪个精确位置,只需要一个“足够好”的近似信息。完全排序是“过度计算”。
关键洞察:
- 最大差值一定不会出现在同一个桶内部,如果我们把数值范围划分为等宽的区间(桶)的话。
- 最大差值只可能出现在前一个桶的最大值和后一个桶的最小值之间。
例如,数组 [1, 10, 5, 3, 9],范围是 0~10。排序后是 [1, 3, 5, 9, 10],相邻差值为 2, 2, 4, 1,最大差值是 4(在 5 和 9 之间)。我们可以通过划分桶来避免完整排序,就找到这个 4。
第二步:算法构思——桶的划分
-
确定桶的数量和大小:
- 设数组长度为
n,最小值为min_val,最大值为max_val。 - 如果我们创建
n个桶,每个桶负责的数值区间长度为:
bucket_size = (max_val - min_val) / (n - 1),但这里有一个非常重要的细节。如果max_val == min_val,那么最大差值就是0,可以直接返回。 - 实际上,更鲁棒的计算是:
bucket_size = max(1, (max_val - min_val) / (n - 1))。但为了后续逻辑清晰,我们通常使用bucket_size = (max_val - min_val) / n或(max_val - min_val) / (n-1)的向上取整。最常用且简单的策略是创建n+1个桶,但只使用n个桶,每个桶的区间为:
bucket_size = (max_val - min_val) / n(浮点数结果,但我们需要整数索引,所以用整数除法或浮点数向下取整后加1处理边界,但更常见的实现是用浮点数然后取整)。
一个更精确且无歧义的公式是:
bucket_range = (max_val - min_val) / (n - 1) (浮点数) bucket_index = (num - min_val) / bucket_range (浮点数除法,然后取整)但为了在整数环境下实现,我们可以用以下方法避免浮点数:
bucket_size = (max_val - min_val + n - 2) / (n - 1) // 整数除法向上取整 bucket_index = (num - min_val) / bucket_size // 整数除法然而,经典且广泛使用的解法是创建 n 个桶,并用以下公式计算桶索引:
bucket_size = (max_val - min_val) / n + 1 // 确保每个桶的范围至少为1,并且覆盖整个区间 bucket_index = (num - min_val) / bucket_size但为了讲解的简洁性和算法的普遍性,我们采用一种更直观的、在《算法导论》等资料中出现的思路:创建 n 个桶,每个桶只记录落在该区间的数的最大值和最小值,并且我们保证最大差值一定出现在跨桶的元素之间,而不是桶内。
- 设数组长度为
-
为什么 n 个桶就够了?
- 我们有 n 个数,n 个桶。
- 鸽巢原理:如果每个桶的区间是
(max_val - min_val) / (n-1),那么平均每个桶的宽度就是这个值。最大的差值一定大于等于这个平均宽度。为什么?因为如果所有差值都小于这个平均宽度,那么 n 个数之间的总跨度(从最小值到最大值)将小于(n-1) * 平均宽度 = max_val - min_val,这与定义矛盾。因此,最大差值至少是平均宽度。 - 这个结论非常重要:最大差值的最小可能值就是桶的宽度。这意味着,产生最大差值的那两个数不可能在同一个桶里!因为同一个桶内的数的差值最大也就是桶的宽度减1(如果区间是左闭右开),而最大差值至少是桶的宽度。
- 所以,我们只需要维护每个桶内的最小值和最大值。最大差值只可能出现在前一个非空桶的最大值和后一个非空桶的最小值之间。我们完全不需要关心一个桶内部元素的顺序。
第三步:算法步骤详解
-
边界情况处理:
- 如果数组长度
n < 2或max_val == min_val,最大差值为0。
- 如果数组长度
-
初始化:
- 遍历数组,找到最小值
min_val和最大值max_val。 - 创建
n个桶。每个桶只需要记录两个值:bucket_min和bucket_max。初始化bucket_min为无穷大 (INT_MAX),bucket_max为无穷小 (INT_MIN),表示桶为空。
- 遍历数组,找到最小值
-
确定桶的区间大小:
bucket_range = max(1, (max_val - min_val) / (n - 1))。这里使用n-1是因为有n-1个“间隙”需要被 n 个数覆盖。用max(1, ...)防止bucket_range为0(当所有元素相等时,前面边界情况已处理,这里不会出现0,但保持安全)。
-
分配数字到桶中:
- 遍历数组中的每个数字
num。 - 计算其应属的桶索引:
bucket_index = (num - min_val) / bucket_range。 - 更新对应桶的
bucket_min和bucket_max:bucket_min[bucket_index] = min(bucket_min[bucket_index], num)bucket_max[bucket_index] = max(bucket_max[bucket_index], num)
- 遍历数组中的每个数字
-
计算最大差值:
- 初始化
max_gap = 0。 - 初始化
prev_max = min_val。prev_max表示上一个非空桶的最大值。一开始,我们把最小值当作第一个“虚拟”桶的最大值。 - 遍历所有桶(从 0 到 n-1):
- 如果当前桶是空的(
bucket_min[i] == INT_MAX),跳过。 - 否则,当前桶的最小值
bucket_min[i]与prev_max的差值可能是一个候选的最大差值:current_gap = bucket_min[i] - prev_max。更新max_gap = max(max_gap, current_gap)。 - 然后更新
prev_max = bucket_max[i],继续检查下一个非空桶。
- 如果当前桶是空的(
- 初始化
-
返回结果:
- 遍历完成后,
max_gap即为所求。
- 遍历完成后,
第四步:举例说明
让我们用一个例子来走一遍算法。
数组:nums = [3, 6, 9, 1], n=4。
min_val = 1,max_val = 9。bucket_range = max(1, (9-1)/(4-1)) = max(1, 8/3) ≈ max(1, 2) = 2。(这里用整数除法,8/3=2)- 创建4个桶,初始化全为空。
- 分配数字:
num=3:index = (3-1)/2 = 1。桶1:min=3, max=3。num=6:index = (6-1)/2 = 2(5/2=2)。桶2:min=6, max=6。num=9:index = (9-1)/2 = 4(8/2=4)。索引4超出了桶的数量(0~3)。这是常见问题。我们需要确保索引在范围内。一个简单的处理方式是,对于最大值max_val,我们将其放入最后一个桶(索引 n-1)。所以修正:index = min((num - min_val) / bucket_range, n-1)。
所以9的索引是min(4, 3) = 3。桶3:min=9, max=9。num=1:index = (1-1)/2 = 0。桶0:min=1, max=1。
- 现在桶的状态:
- 桶0: min=1, max=1
- 桶1: min=3, max=3
- 桶2: min=6, max=6
- 桶3: min=9, max=9
- 计算最大差值:
prev_max = min_val = 1。- 桶0非空:
current_gap = 1 - 1 = 0。max_gap=0。prev_max = 1。 - 桶1非空:
current_gap = 3 - 1 = 2。max_gap=2。prev_max = 3。 - 桶2非空:
current_gap = 6 - 3 = 3。max_gap=3。prev_max = 6。 - 桶3非空:
current_gap = 9 - 6 = 3。max_gap=3。prev_max = 9。
- 最终
max_gap = 3。排序后的数组是[1, 3, 6, 9],相邻差值为 2, 3, 3,最大值是3。正确。
为什么最大值9被放入最后一个桶是合理的?
因为 bucket_range * n >= (max_val - min_val)。对于最大值,计算出的索引可能等于 n(如果 (max_val - min_val) / bucket_range 恰好等于 n)。为了确保索引在 [0, n-1] 内,我们使用 min(index, n-1)。由于最大值只有一个,放入最后一个桶不会影响最大差值的计算,因为最大差值如果涉及最大值,它一定是作为“前一个非空桶的最大值”参与计算,而不会作为“后一个桶的最小值”(因为后面没有桶了)。
第五步:复杂度分析
-
时间复杂度:O(n)。
- 第一次遍历找最小最大值:O(n)。
- 第二次遍历分配数字到桶:O(n)。
- 第三次遍历所有桶(n个)计算最大差值:O(n)。
- 总计 O(3n) = O(n)。
-
空间复杂度:O(n)。
- 我们需要存储 n 个桶,每个桶两个整数,所以是 O(2n) = O(n)。
第六步:关键点总结
- 思想核心:利用桶将元素分组,但目的不是排序,而是排除桶内元素产生最大差值的可能性。最大差值必然跨越不同的桶。
- 桶的数量:通常设置为 n 个(与元素个数相同)。这确保了平均情况下每个桶最多有一个元素(在元素分布均匀时),但即使分布不均,我们也不需要关心桶内顺序。
- 桶的范围:
bucket_range = (max_val - min_val) / (n-1)或类似公式。确保最大差值至少等于桶的宽度,从而保证最大差值不会出现在同一个桶内。 - 索引计算与边界处理:使用
bucket_index = (num - min_val) / bucket_range,并对结果用min(index, n-1)来限定,防止越界。 - 遍历顺序:计算最大差值时,是按桶的索引顺序遍历,只关心非空桶的
min和上一个非空桶的max的差值。
这个算法巧妙地将一个看似需要排序的 O(n log n) 问题,通过“分桶”和“鸽巢原理”降级到了 O(n) 的复杂度,是桶排序思想的一个非常精妙的应用。