别总想着排序!我在数据看板中悟出的O(N)求第三大数神技(414. 第三大的数)

别总想着排序!我在数据看板中悟出的O(N)求第三大数神技😎

大家好,我是一个热爱在代码海洋里冲浪的老兵。今天想和大家分享一个我在实际项目中遇到的趣事,以及它如何让我对一个看似简单的算法题有了更深的理解。这趟旅程充满了“踩坑”的教训和“原来如此”的顿悟,希望能给你带来一些启发!

我遇到了什么问题

故事发生在一个数据看板的开发任务中。产品经理小姐姐(PM)希望在我们的电商后台首页加一个“爆款商品追踪”模块,实时展示销量排名前三的商品。

听起来很简单,对吧?我当时也是这么想的。但PM接着补充了几个细节:

  1. 销量数据是实时流动的,我们需要一个高效的方法来更新排名。
  2. 排名是基于不同商品的。一个商品卖出多少件都只算一个排名位。
  3. 如果当天开张,商品种类还不足3种,那就显示当前销量最高的商品。

这下问题就变得具体了。我需要处理一个动态的数据流,并快速找出其中“第三大的不同数值”。如果不够三个,就返回最大的那个。

这场景,简直和 LeetCode 上的一道题不谋而合:

414. 第三大的数

给你一个非空数组,返回此数组中 第三大的数 。如果不存在,则返回数组中最大的数。

在动手之前,我们先看看题目的“友情提示”里藏着什么玄机:

  • 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)。在最坏情况(所有商品都不同)下,HashSetArrayList 都需要存储 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 条评论。

练练手,更熟练

如果你对这类问题意犹未尽,不妨挑战一下这些“亲戚”题目:

希望这次的分享能让你有所收获。记住,面对问题,别总想着排序,有时候换个思路,就能发现一片新天地!😎

解法对比

特性解法一 (排序去重)解法二 (有序集合)解法三 (一次遍历)
核心思想先去重,再排序,最后取值边遍历边维护一个大小为K的有序集合用K个变量实时追踪Top-K元素
代码简洁度中等低(逻辑判断多)
时间复杂度O(N log N)O(N)O(N)
空间复杂度O(N)O(1)O(1)
适用场景数据量小,或一次性计算需要高效实时更新,代码优雅追求极致性能,不介意稍复杂的逻辑
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值