别总想着排序!我在数据看板中悟出的O(N)求第三大数神技😎
大家好,我是一个热爱在代码海洋里冲浪的老兵。今天想和大家分享一个我在实际项目中遇到的趣事,以及它如何让我对一个看似简单的算法题有了更深的理解。这趟旅程充满了“踩坑”的教训和“原来如此”的顿悟,希望能给你带来一些启发!
我遇到了什么问题
故事发生在一个数据看板的开发任务中。产品经理小姐姐(PM)希望在我们的电商后台首页加一个“爆款商品追踪”模块,实时展示销量排名前三的商品。
听起来很简单,对吧?我当时也是这么想的。但PM接着补充了几个细节:
- 销量数据是实时流动的,我们需要一个高效的方法来更新排名。
- 排名是基于不同商品的。一个商品卖出多少件都只算一个排名位。
- 如果当天开张,商品种类还不足3种,那就显示当前销量最高的商品。
这下问题就变得具体了。我需要处理一个动态的数据流,并快速找出其中“第三大的不同数值”。如果不够三个,就返回最大的那个。
这场景,简直和 LeetCode 上的一道题不谋而合:
给你一个非空数组,返回此数组中 第三大的数 。如果不存在,则返回数组中最大的数。
在动手之前,我们先看看题目的“友情提示”里藏着什么玄机:
1 <= nums.length <= 10^4
:数组长度不算太大,O(N log N)
的解法(比如排序)是可以通过的。但这暗示了有更优的O(N)
解法,这也是进阶目标。-2^31 <= nums[i] <= 2^31 - 1
:这是一个关键的“坑”!数值范围覆盖了整个int
类型。这意味着,如果你想用一个很小的数(比如Integer.MIN_VALUE
)作为初始的“最大值”,一旦数组里真的出现了Integer.MIN_VALUE
,你的逻辑可能就乱套了。我们得小心处理!
好,热身完毕,开干!
我是如何用算法“组合拳”解决的
解法一:先排序,大力出奇迹
这是我最开始想到的方法,简单粗暴,但有效。思路三步走:去重 -> 排序 -> 取值。
先把数组里所有重复的商品 ID 去掉,然后从大到小排个序,第三个不就是我们想要的嘛!
import java.util.Collections;
import java.util.HashSet;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
/*
* 思路:排序与去重。先用 HashSet 去除重复元素,然后将 Set 转换为 List 进行排序,
* 最后根据 List 的大小判断返回第三大还是最大的数。
* 时间复杂度:O(N log N),主要耗时在排序上。
* 空间复杂度:O(N),最坏情况下所有元素都不同,需要存储所有元素。
*/
class Solution {
public int thirdMax(int[] nums) {
// 1. 去重
// 为啥用 HashSet?因为它基于哈希表,add 操作的平均时间复杂度是 O(1),
// 能非常高效地帮我们过滤掉重复的商品ID。
Set<Integer> distinctNums = new HashSet<>();
for (int num : nums) {
distinctNums.add(num);
}
// 2. 排序
// Set 是无序的,没法按下标取值。所以要转成 List。
List<Integer> sortedList = new ArrayList<>(distinctNums);
// Collections.sort() 是 Java 的标准排序工具,配合 Collections.reverseOrder() 实现降序,简单好用。
Collections.sort(sortedList, Collections.reverseOrder());
// 3. 取值
// 如果去重后的商品种类少于3种,说明没有“第三名”
if (sortedList.size() < 3) {
// 按要求返回“冠军”
return sortedList.get(0);
} else {
// 否则,返回光荣的“季军”,也就是列表里下标为2的元素
return sortedList.get(2);
}
}
}
复杂度
时间复杂度:O(N log N)。遍历数组构建 HashSet
是 O(N),但瓶颈在于对去重后的列表排序,最坏情况下是 O(N log N)。
空间复杂度:O(N)。在最坏情况(所有商品都不同)下,HashSet
和 ArrayList
都需要存储 N 个元素。
这个方法虽然能解决问题,但每次来新数据都要全盘排序一次,效率不高。PM小姐姐要的是“实时”,这显然不够快。
解法二:有序集合,优雅永不过时
排序太慢,那有没有办法在遍历的时候就顺便把大小关系维护好呢?当然有!这时候,TreeSet
闪亮登场。
TreeSet
是一个神奇的数据结构,它不仅能像 HashSet
一样自动去重,还能随时保持内部元素处于有序状态!我们可以用它来维护一个始终不超过3个元素的“优胜者小组”。
import java.util.TreeSet;
/*
* 思路:使用有序集合 TreeSet。在一次遍历中维护一个最多包含3个元素的
* 有序集合。这样可以同时完成去重、排序和筛选前三大数的操作。
* 时间复杂度:O(N),因为每次 TreeSet 操作是 O(log K),K最大为3,是常数。
* 空间复杂度:O(1),因为 TreeSet 最多存储3个元素。
*/
class Solution {
public int thirdMax(int[] nums) {
// TreeSet 是一个有序且唯一的集合,基于红黑树实现。
// 它的 add, remove, first, last 操作都是 O(log K) 级别,K是集合大小。
// 在我们这,K最多是3,所以这些操作几乎是 O(1) 的,非常快!
TreeSet<Integer> topThree = new TreeSet<>();
for (int num : nums) {
topThree.add(num);
// 如果“优胜者小组”人数超过了3个
if (topThree.size() > 3) {
// 就把成绩最差的(最小的那个)淘汰掉
topThree.remove(topThree.first());
}
}
// 遍历结束后,看看小组里有几个人
if (topThree.size() == 3) {
// 如果正好3个人,第三大的就是里面成绩最差的那个(因为TreeSet升序排列)
return topThree.first();
} else {
// 如果不足3个人,就返回成绩最好的那个(最大的)
return topThree.last();
}
}
}
复杂度
时间复杂度:O(N)。我们遍历数组一次,每次对 TreeSet
的操作是 O(log K)。因为我们把K的大小限制在3,所以 O(log 3) 是一个常数。总复杂度就是 O(N)。
空间复杂度:O(1)。TreeSet
里最多只放3个元素,占用的空间是恒定的。
这个解法简直太棒了!😉 它不仅满足了 O(N) 的进阶要求,代码还非常优雅。这算是我当时的一个“恍然大悟”的瞬间。
解法三:一次遍历,手动挡的极致性能
作为一个追求极致的开发者,我还想:能不能不用任何额外的数据结构,只用几个变量就搞定?答案是肯定的,这就是“手动挡”的乐趣所在!
我们可以用三个变量 firstMax
, secondMax
, thirdMax
来实时追踪前三名。
这里有个大坑! 正如前面提示分析的,我们不能简单地把变量初始化为 Integer.MIN_VALUE
。我选择用 Integer
包装类,它们的默认值是 null
,这个 null
完美地代表了“这个名次目前虚位以待”的状态。
/*
* 思路:一次遍历,使用三个变量来维护当前找到的前三大数。
* 这种方法避免了使用额外的数据结构,实现了时间和空间的最优化。
* 时间复杂度:O(N),仅需遍历数组一次。
* 空间复杂度:O(1),只使用了固定的几个变量。
*/
class Solution {
public int thirdMax(int[] nums) {
// 使用 Integer 包装类,用 null 来表示“尚未找到”的状态,
// 这比用 Long.MIN_VALUE 或其他哨兵值更清晰,也更安全。
Integer firstMax = null;
Integer secondMax = null;
Integer thirdMax = null;
for (Integer num : nums) { // 自动装箱为 Integer,方便与 null 比较
// 如果这个商品ID已经榜上有名,直接跳过
if (num.equals(firstMax) || num.equals(secondMax) || num.equals(thirdMax)) {
continue;
}
// 如果 num 发现了新的“冠军”
if (firstMax == null || num > firstMax) {
thirdMax = secondMax; // 原亚军变季军
secondMax = firstMax; // 原冠军变亚军
firstMax = num; // 新科冠军登场
}
// 否则,如果 num 发现了新的“亚军”
else if (secondMax == null || num > secondMax) {
thirdMax = secondMax; // 原亚军变季军
secondMax = num; // 新亚军就位
}
// 否则,如果 num 发现了新的“季军”
else if (thirdMax == null || num > thirdMax) {
thirdMax = num; // 新季军补位
}
}
// 比赛结束,如果“季军”位置还是空的,说明商品不足3种
// 此时按规则返回“冠军”
return thirdMax == null ? firstMax : thirdMax;
}
}
复杂度
时间复杂度:O(N)。只需一次完整的遍历。
空间复杂度:O(1)。只用了三个变量,空间消耗是常数级别的。
这个解法是性能上的王者,它用最少的资源完成了任务。虽然逻辑判断稍微复杂一些,但这正是算法的魅力所在——于细微处见真章。
举一反三:Top-K 思想的应用
掌握了找“第三大”的技巧,实际上你已经掌握了更通用的“Top-K”问题的钥匙。这个思想可以用在很多地方:
- 游戏排行榜:实时更新的玩家得分 Top 10。
- 系统监控:找出系统中响应最慢的 5 个 API 接口。
- 社交网络:计算一篇文章下点赞数最多的 3 条评论。
练练手,更熟练
如果你对这类问题意犹未尽,不妨挑战一下这些“亲戚”题目:
- 215. 数组中的第K个最大元素:经典的 Top-K 问题,不过这次重复的数字也要算。
- 347. 前 K 个高频元素:结合了频率统计和 Top-K,更有挑战性。
- 703. 数据流中的第 K 大元素:和我们今天讨论的场景非常相似,是 Top-K 问题的绝佳实践。
希望这次的分享能让你有所收获。记住,面对问题,别总想着排序,有时候换个思路,就能发现一片新天地!😎
解法对比
特性 | 解法一 (排序去重) | 解法二 (有序集合) | 解法三 (一次遍历) |
---|---|---|---|
核心思想 | 先去重,再排序,最后取值 | 边遍历边维护一个大小为K的有序集合 | 用K个变量实时追踪Top-K元素 |
代码简洁度 | 中等 | 高 | 低(逻辑判断多) |
时间复杂度 | O(N log N) | O(N) | O(N) |
空间复杂度 | O(N) | O(1) | O(1) |
适用场景 | 数据量小,或一次性计算 | 需要高效实时更新,代码优雅 | 追求极致性能,不介意稍复杂的逻辑 |