😎 从“热点追踪”到“精彩集锦”:一个算法让我搞定了动态 Top K 排序难题
大家好,我是一个在代码世界里摸爬滚打了快N年的老兵 😉。今天想跟大家聊一个我最近在项目中遇到的真实挑战,以及我是如何通过一个看似简单的算法题找到了完美解决方案的。
我遇到了什么问题?
我所在的项目组正在开发一个短视频平台,其中有一个核心功能叫做“AI 精彩集锦”。它的作用是,用户上传一个长视频(比如一场游戏录播、一次旅行记录),我们的后台服务能自动分析视频,并剪辑出其中最精彩的 k
个片段,最后按时间顺序拼接成一个集锦视频。
听起来很酷,对吧?但挑战也随之而来。我们的 AI 模型会分析视频的每一帧,并给出一个“精彩度”评分。比如,游戏中击杀的瞬间、旅行中看到壮丽日出的时刻,得分就会很高。
所以,我的任务输入是这样的一个数据流:
[(时间点1, 精彩度A), (时间点2, 精彩度B), (时间点3, 精彩度C), ...]
我需要做的是:
- 从成千上万个时间点中,找出精彩度最高的
k
个。 - 这
k
个片段在最终的集锦视频里,必须按照它们在原视频中出现的时间顺序播放。
一开始我有点想当然:“这不简单嘛!先把所有片段按精彩度排个序,取前 k
个,再按时间排个序不就好了?”
是的,逻辑上是这样,但当数据量达到几十万甚至上百万帧时,对整个数据集进行全量排序,然后再对筛选出的 k
个数据进行二次排序,性能开销非常大。特别是如果用户只需要一个很短的集锦(比如 k
很小),那么对整个长视频的所有帧数据进行排序就显得非常浪费。
我意识到,我需要一个更高效、更优雅的方案。这让我想起了 LeetCode 上的一道题,它完美地抽象了我遇到的问题。
给你一个整数数组
nums
和一个整数k
。你需要找到nums
中长度为k
的 子序列 ,且这个子序列的 和最大 。请你返回 任意 一个长度为
k
的整数子序列。子序列 定义为从一个数组里删除一些元素后,不改变剩下元素的顺序得到的数组。
你看,这不就是我的问题吗?
nums
数组就是视频所有帧的精彩度列表。- 数组的索引就是每个片段的时间点。
k
就是我要生成的集锦片段数量。- “和最大”就是要找到精彩度最高的片段。
- “子序列”意味着最终结果必须保持原始的时间顺序。
🤔 提示解读:
在解决问题前,我们先看看题目的提示,这就像产品经理给的“需求边界”:
1 <= nums.length <= 1000
:数组长度不大,这意味着 O(N log N) 的复杂度是完全可以接受的。这给了我信心,基于排序的方案是可行的。-10^5 <= nums[i] <= 10^5
:数值有正有负,范围比较大。这意味着用计数排序这种依赖值范围的算法可能不太合适(内存开销大)。1 <= k <= nums.length
:k
是一个有效的正整数,不需要处理边界异常。
好了,需求明确,边界清晰,开干!下面我分享两种思路,从“直观解法”到“最优解法”,希望能给你带来启发。
我是如何用算法解决的
解法一:两阶段排序法 —— 直观但有效
这是我最开始想到的“笨办法”,但它非常直观,也完全能解决问题。思路就是把“选谁”和“怎么排”这两个步骤分开处理。
💡 思路:
- “选谁”阶段:为了在排序后还能找回原始的时间点(索引),我先把每个“精彩度”(值)和它的“时间点”(索引)绑定在一起,存成一个
[值, 索引]
的结构。然后,我按“精彩度”对它们进行降序排序。这样,排在最前面的k
个就是最精彩的片段。 - “怎么排”阶段:现在我手里有了
k
个最精彩的片段,但它们是按精彩度排列的,时间线是乱的。没关系,我再对这k
个片段按“时间点”进行升序排序。 - 大功告成:最后,我只需要提取出这
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
的最小堆来解决这个问题。最小堆的堆顶永远是堆中最小的元素。
- 遍历所有视频片段(
nums
数组)。 - 对于每个片段,都将它加入最小堆。
- 如果此时堆的大小超过了
k
,就弹出堆顶元素(也就是当前已见过的k+1
个片段里最不精彩的那个)。 - 这样,当我们遍历完所有片段后,堆里剩下的,就是整个视频里最精彩的
k
个片段! - 最后,和解法一一样,我们把堆里的这
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”这个问题的解法后,你会发现它在很多场景下都非常有用:
- 电商推荐:展示某个商品类目下“评分最高”的
k
个商品,并保持它们在类目下的“默认展示顺序”。 - 新闻聚合:从海量新闻中,推送给用户当天“热度最高”的
k
条新闻,并按“发布时间”排序。 - 金融数据分析:找出一天中某支股票“交易量最大”的
k
个分钟,并按时间顺序展示,以分析关键的交易时刻。
本质上,只要你遇到需要在满足某个最优指标的同时,还要保持其固有顺序的场景,都可以套用这个思路来解决。
类似题目练习
为了巩固这个知识点,我推荐大家去 LeetCode 上刷几道类似的题目,它们的核心思想都和 Top K 相关:
- 215. 数组中的第K个最大元素 - 经典的 Top K 问题,练习堆的绝佳题目。
- 347. 前 K 个高频元素 - 综合了哈希表和堆,考察对频率的排序。
- 692. 前K个高频单词 - 347题的进阶版,增加了对字典序的处理,非常考验对堆的自定义排序能力。
希望我今天的分享能对你有所帮助。技术成长之路,就是这样不断从实际问题出发,找到理论模型,再用优雅的代码去实现。下次再遇到类似的问题,相信你也能从容应对了!😉