从“热点追踪”到“精彩集锦”:一个算法让我搞定了动态 Top K 排序难题(2099. 找到和最大的长度为 K 的子序列)


😎 从“热点追踪”到“精彩集锦”:一个算法让我搞定了动态 Top K 排序难题

大家好,我是一个在代码世界里摸爬滚打了快N年的老兵 😉。今天想跟大家聊一个我最近在项目中遇到的真实挑战,以及我是如何通过一个看似简单的算法题找到了完美解决方案的。

我遇到了什么问题?

我所在的项目组正在开发一个短视频平台,其中有一个核心功能叫做“AI 精彩集锦”。它的作用是,用户上传一个长视频(比如一场游戏录播、一次旅行记录),我们的后台服务能自动分析视频,并剪辑出其中最精彩的 k 个片段,最后按时间顺序拼接成一个集锦视频。

听起来很酷,对吧?但挑战也随之而来。我们的 AI 模型会分析视频的每一帧,并给出一个“精彩度”评分。比如,游戏中击杀的瞬间、旅行中看到壮丽日出的时刻,得分就会很高。

所以,我的任务输入是这样的一个数据流:
[(时间点1, 精彩度A), (时间点2, 精彩度B), (时间点3, 精彩度C), ...]

我需要做的是:

  1. 从成千上万个时间点中,找出精彩度最高的 k
  2. k 个片段在最终的集锦视频里,必须按照它们在原视频中出现的时间顺序播放

一开始我有点想当然:“这不简单嘛!先把所有片段按精彩度排个序,取前 k 个,再按时间排个序不就好了?”

是的,逻辑上是这样,但当数据量达到几十万甚至上百万帧时,对整个数据集进行全量排序,然后再对筛选出的 k 个数据进行二次排序,性能开销非常大。特别是如果用户只需要一个很短的集锦(比如 k 很小),那么对整个长视频的所有帧数据进行排序就显得非常浪费。

我意识到,我需要一个更高效、更优雅的方案。这让我想起了 LeetCode 上的一道题,它完美地抽象了我遇到的问题。

2099. 找到和最大的长度为 K 的子序列

给你一个整数数组 nums 和一个整数 k 。你需要找到 nums 中长度为 k子序列 ,且这个子序列的 和最大

请你返回 任意 一个长度为 k 的整数子序列。

子序列 定义为从一个数组里删除一些元素后,不改变剩下元素的顺序得到的数组。

你看,这不就是我的问题吗?

  • nums 数组就是视频所有帧的精彩度列表。
  • 数组的索引就是每个片段的时间点
  • k 就是我要生成的集锦片段数量
  • “和最大”就是要找到精彩度最高的片段。
  • “子序列”意味着最终结果必须保持原始的时间顺序

🤔 提示解读:
在解决问题前,我们先看看题目的提示,这就像产品经理给的“需求边界”:

  • 1 <= nums.length <= 1000:数组长度不大,这意味着 O(N log N) 的复杂度是完全可以接受的。这给了我信心,基于排序的方案是可行的。
  • -10^5 <= nums[i] <= 10^5:数值有正有负,范围比较大。这意味着用计数排序这种依赖值范围的算法可能不太合适(内存开销大)。
  • 1 <= k <= nums.lengthk 是一个有效的正整数,不需要处理边界异常。

好了,需求明确,边界清晰,开干!下面我分享两种思路,从“直观解法”到“最优解法”,希望能给你带来启发。


我是如何用算法解决的
解法一:两阶段排序法 —— 直观但有效

这是我最开始想到的“笨办法”,但它非常直观,也完全能解决问题。思路就是把“选谁”和“怎么排”这两个步骤分开处理。

💡 思路:

  1. “选谁”阶段:为了在排序后还能找回原始的时间点(索引),我先把每个“精彩度”(值)和它的“时间点”(索引)绑定在一起,存成一个 [值, 索引] 的结构。然后,我按“精彩度”对它们进行降序排序。这样,排在最前面的 k 个就是最精彩的片段。
  2. “怎么排”阶段:现在我手里有了 k 个最精彩的片段,但它们是按精彩度排列的,时间线是乱的。没关系,我再对这 k 个片段按“时间点”进行升序排序。
  3. 大功告成:最后,我只需要提取出这 k 个排好序的片段的“精彩度”值,就得到了最终的答案。
/*
 * 思路:将值和索引绑定,先按值排序筛选出 k 个最大元素,再按索引排序恢复它们的原始相对顺序。
 * 时间复杂度:O(N log N),空间复杂度:O(N)
 */
import java.util.Arrays;

class Solution1 {
    public int[] findSubsequence(int[] nums, int k) {
        int n = nums.length;

        // 步骤1:将值和索引绑定。用 int[2] 很轻量。
        // [value, index]
        int[][] indexedNums = new int[n][2];
        for (int i = 0; i < n; i++) {
            indexedNums[i][0] = nums[i];
            indexedNums[i][1] = i;
        }

        // 步骤2:按值(indexedNums[][0])降序排序,找到最精彩的 k 个。
        // Arrays.sort 是 Java 中对数组排序的标准库函数,非常高效(底层是快速排序或TimSort)。
        // 使用 Lambda 表达式 `(a, b) -> b[0] - a[0]` 是 Java 8+ 的现代写法,非常简洁。
        Arrays.sort(indexedNums, (a, b) -> b[0] - a[0]);

        // 步骤3:截取前 k 个元素。
        // Arrays.copyOfRange 是一个专用API,用于高效复制数组的某个范围。
        int[][] topK = Arrays.copyOfRange(indexedNums, 0, k);

        // 步骤4:对这 k 个元素,按索引(topK[][1])升序排序,恢复时间线。
        Arrays.sort(topK, (a, b) -> a[1] - a[1]);

        // 步骤5:提取结果。
        int[] result = new int[k];
        for (int i = 0; i < k; i++) {
            result[i] = topK[i][0];
        }
        return result;
    }
}

</代码>

✨ 恍然大悟的瞬间:
这个方法虽然简单,但它让我明白了一个道理:复杂问题可以通过分解成多个简单的、正交的步骤来解决。我把“找最大”和“保顺序”这两个相互制约的目标,通过两次排序干净利落地解决了。先满足最重要的“最大”,再回过头来满足“顺序”。


解法二:最小堆法 —— 更优的性能选择

虽然解法一可行,但作为一个有追求的开发者,我总觉得杀鸡焉用牛刀。如果视频特别长(N 很大),但用户只要一个很短的集锦(k 很小),那 O(N log N) 的全量排序就太浪费了。我需要一个只关心 Top K 元素的算法。

这时候,一个数据结构闪现在我的脑海里:堆(Heap)

💡 思路:
用一个大小固定为 k最小堆来解决这个问题。最小堆的堆顶永远是堆中最小的元素。

  1. 遍历所有视频片段(nums 数组)。
  2. 对于每个片段,都将它加入最小堆。
  3. 如果此时堆的大小超过了 k,就弹出堆顶元素(也就是当前已见过的 k+1 个片段里最不精彩的那个)。
  4. 这样,当我们遍历完所有片段后,堆里剩下的,就是整个视频里最精彩的 k 个片段!
  5. 最后,和解法一一样,我们把堆里的这 k 个元素取出来,按时间点(索引)排个序,就搞定了。

“踩坑”经验分享:
我一开始想用最大堆,想着把所有元素都放进去,然后取前 k 个。但很快发现这样做有两个问题:1)需要 O(N) 的空间来建堆;2) 时间复杂度还是 O(N log N)。
正确的姿势是维护一个大小为 k最小堆。这就像一个只有 k 个座位的“精彩片段候车室”,只有比候车室里“最差”的片段还要精彩的,才有资格进来,并把那个最差的踢出去。这样空间复杂度就降到了 O(k),时间复杂度也优化到了 O(N log k)。

/*
 * 思路:使用最小堆高效找出 k 个最大的元素,然后对这 k 个元素按索引排序恢复顺序。
 * 时间复杂度:O(N log k),空间复杂度:O(k)
 */
import java.util.ArrayList;
import java.util.List;
import java.util.PriorityQueue;

class Solution2 {
    public int[] findSubsequence(int[] nums, int k) {
        // PriorityQueue 是 Java 中堆的实现,默认是最小堆。
        // 我们可以通过提供一个 Comparator 来定制其行为。
        // 这里,我们让它比较 `int[]` 的第一个元素(值)。
        PriorityQueue<int[]> minHeap = new PriorityQueue<>((a, b) -> a[0] - b[0]);

        for (int i = 0; i < nums.length; i++) {
            // offer 方法用于向堆中添加元素,复杂度 O(log k)
            minHeap.offer(new int[]{nums[i], i});
          
            // 保持堆的大小不超过 k
            if (minHeap.size() > k) {
                // poll 方法用于移除堆顶(最小)元素,复杂度 O(log k)
                minHeap.poll();
            }
        }

        // 此时堆中有 k 个最精彩的片段,但顺序是乱的
        // 将它们转移到 List 中,方便排序
        List<int[]> topKList = new ArrayList<>(minHeap);
      
        // 按时间点(索引)排序
        topKList.sort((a, b) -> a[1] - b[1]);

        int[] result = new int[k];
        for (int i = 0; i < k; i++) {
            result[i] = topKList.get(i)[0];
        }
        return result;
    }
}

这个方案无论在时间还是空间上都更胜一筹,特别是在 N 远大于 k 的场景下,性能提升非常显著。这才是工业级的解决方案!💪


举一反三,触类旁通

掌握了“带顺序的 Top K”这个问题的解法后,你会发现它在很多场景下都非常有用:

  1. 电商推荐:展示某个商品类目下“评分最高”的 k 个商品,并保持它们在类目下的“默认展示顺序”。
  2. 新闻聚合:从海量新闻中,推送给用户当天“热度最高”的 k 条新闻,并按“发布时间”排序。
  3. 金融数据分析:找出一天中某支股票“交易量最大”的 k 个分钟,并按时间顺序展示,以分析关键的交易时刻。

本质上,只要你遇到需要在满足某个最优指标的同时,还要保持其固有顺序的场景,都可以套用这个思路来解决。

类似题目练习

为了巩固这个知识点,我推荐大家去 LeetCode 上刷几道类似的题目,它们的核心思想都和 Top K 相关:

希望我今天的分享能对你有所帮助。技术成长之路,就是这样不断从实际问题出发,找到理论模型,再用优雅的代码去实现。下次再遇到类似的问题,相信你也能从容应对了!😉

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值