😎 从杂乱的用户评分到核心洞见:一个“和谐”算法如何帮我定位爆款产品
大家好,今天想跟大家聊聊一个我在工作中遇到的真实案例,它看似是一个简单的数据分析需求,却让我对一个基础算法——哈希表——有了全新的认识。故事的起点,是我们的产品经理(PM)提出了一个棘手的问题。
一、我遇到了什么问题?PM的灵魂拷问
我在一家电商公司工作,我们的平台上有成千上万的商品,每个商品都有大量的用户评分(1到5星)。有一天,PM找到我,眉头紧锁地说:“我想知道哪些商品是我们的‘潜力爆款’,哪些是‘口碑两极分化’的。光看平均分完全没用啊!”
他说的没错。一个平均分3.0的商品,可能是所有用户都给了3星(评价中庸),也可能是一半用户给了1星,另一半给了5星(口碑崩盘)。
PM进一步阐述了他的想法:“一个真正的好产品,它的评分应该是高度集中的,比如绝大多数用户都给了4星和5星。我们能不能找到‘评分最集中的相邻分数组合’,并且这个组合覆盖的用户量是最大的?”
我立刻get到了他的点。他要找的,其实就是由 x
星和 x+1
星组成的、人数最多的那个用户群体。比如,对于商品A,它的所有评分记录是 [4, 5, 4, 4, 5, 5, 5, 1]
,那么由4星和5星组成的这个群体共有7人。这就是它的“核心好评用户群”。
这个问题,瞬间让我想起了 LeetCode 上的那道题:594. 最长和谐子序列。
- 和谐子序列:最大值和最小值之差正好是1。
- 我的问题:找到由
x
星和x+1
星构成的最大用户群。
这不就是一回事嘛!看来刷题真的能转化为生产力!💪
二、我是如何用[哈希表]解决的
踩坑实录:差点就写了个O(N^2)
的循环地狱 🤦♂️
我的第一反应可能和很多同学一样:暴力出奇迹!用两层循环,遍历所有评分,看看能不能凑成和谐对子。但我们的商品可能有几十万条评分,N
这么大,N
的平方简直是灾难,服务器分分钟被我干趴下。
恍然大悟的瞬间 💡
我突然意识到,PM并不关心是哪个用户在哪天给的评分,他只关心每种评分(1星、2星...)各有多少个。评分的先后顺序、谁评的,都无关紧要。
题目的“子序列”这个词也是在给我暗示!子序列不要求元素连续,这说明我们只需要关注值的频率,而不需要关注值的位置。
一旦想明白这点,思路就豁然开朗了:这不就是一个经典的频率统计问题吗?而解决频率统计问题的神器,就是哈希表(HashMap)!
解法一:哈希表计数法 (空间换时间的最优解)
这个方法分两步走,非常清晰:
- 扫一遍,全记下:遍历所有的评分数据,用一个
HashMap
来记录每个星级(1星、2星...)出现了多少次。 - 看一遍,找邻居:再遍历一遍我们刚建好的
HashMap
,对于每个星级x
,去查一下它的“邻居”x+1
在不在表里。如果在,就把它们的次数加起来,然后和我手里记录的“最大和谐用户群”比一下,取个大的。
关键API和它们的作用:
Map<Integer, Integer> frequencyMap = new HashMap<>();
- 为什么用
HashMap
? 题目的提示说了,数字范围可能很大。在我的实际场景里,虽然评分是1-5,但如果处理的是用户ID或者订单金额,那范围就大了去了。HashMap
可以无视这个范围,高效地存储键值对。
- 为什么用
map.put(num, map.getOrDefault(num, 0) + 1);
getOrDefault
真的是个宝藏API!它一句话就干了两件事:“如果map里已经有num
这个键,就返回它的值;如果没有,就返回我指定的默认值0”。这让我的代码从if-else
的繁琐中解脱出来,变得超级简洁。
frequencyMap.keySet()
- 当我们统计完频率后,我们只需要关心有哪些星级出现过,所以遍历它的键集合
keySet()
就足够了。
- 当我们统计完频率后,我们只需要关心有哪些星级出现过,所以遍历它的键集合
代码实现:
/*
* 思路:用哈希表统计频率,再遍历哈希表寻找相邻数对,计算最大长度。
*/
class Solution {
public int findLHS(int[] nums) {
// key: 评分星级, value: 该星级的用户数
Map<Integer, Integer> frequencyMap = new HashMap<>();
// 1. 统计所有评分的频率
for (int num : nums) {
frequencyMap.put(num, frequencyMap.getOrDefault(num, 0) + 1);
}
int maxLen = 0;
// 2. 遍历所有出现过的星级
for (int key : frequencyMap.keySet()) {
// 寻找它的“好邻居” x+1
if (frequencyMap.containsKey(key + 1)) {
// 如果找到了,计算这个“和谐”组合的总人数
int currentLen = frequencyMap.get(key) + frequencyMap.get(key + 1);
// 更新我们的“最大核心用户群”记录
maxLen = Math.max(maxLen, currentLen);
}
}
return maxLen;
}
}
解法二:排序 + 滑动窗口法 (另一种思路)
如果面试官追问:“如果内存很宝贵,不让你用哈希表这么大的额外空间怎么办?” 别慌,我们还有后手!
我们可以先对评分数组进行排序。排序后,相同星级的评分就会聚在一起。然后,我们就可以用一个“滑动窗口”来解决问题。想象一下尺子,我们在排好序的数组上移动这把尺子,保证尺子两端的值的差不大于1,然后找到尺子能覆盖的最长那一段。
代码实现:
/*
* 思路:排序后,问题转化为寻找最长的、最大值与最小值之差为1的连续子数组。
*/
import java.util.Arrays;
class Solution2 {
public int findLHS(int[] nums) {
// 1. 先让所有评分排排坐
Arrays.sort(nums);
int maxLen = 0;
int left = 0; // 窗口的左边界
// 2. right指针不断向右移动,扩大窗口
for (int right = 0; right < nums.length; right++) {
// 如果窗口内的最大值和最小值之差 > 1,说明窗口太大了,左边该收缩了
while (nums[right] - nums[left] > 1) {
left++;
}
// 只有当差值正好为1时,才是一个有效的和谐序列
if (nums[right] - nums[left] == 1) {
maxLen = Math.max(maxLen, right - left + 1);
}
}
return maxLen;
}
}
这个方法虽然在时间上慢一点(因为排序),但在空间上更优。
四、解读力扣上的“提示”
1 <= nums.length <= 2 * 10^4
: 数组长度最多2万。O(N^2)
(大约4亿次操作)肯定会超时。O(N log N)
(大约20000 * log(20000)
≈ 28万次)和O(N)
(2万次)都是秒过。这印证了我们选择这两种解法的正确性。-10^9 <= nums[i] <= 10^9
: 数字范围巨大。这个提示几乎是明示我们:“不要用数组下标来计数,快用哈希表!”
五、举一反三:这个模式还能用在哪?
这个“统计频率,寻找相邻关系”的模式非常实用!
- 用户行为分析:分析用户连续登录天数,找到
x
天和x+1
天登录的用户群体,这可能代表了我们最忠实的用户。 - 物联网(IoT)数据处理:分析传感器在一分钟内收集的温度数据,找到温度波动在1度内的最长数据序列,可以用来判断设备是否工作稳定。
- 金融风控:分析用户的交易金额,如果大量交易集中在两个非常相近的金额上(如499元和500元),可能是在规避某种金额上限的监管策略。
六、更多练手机会
掌握了这个模式,不妨试试下面这些题目,它们能让你对哈希表的应用更加得心应手:
- 基础频率统计:1. 两数之和 (哈希表的经典入门)
- 寻找最长序列:128. 最长连续序列 (本题的进阶版,也是用哈希表)
- 子数组问题:560. 和为 K 的子数组 (用哈希表和前缀和解决,非常经典)
希望这次的分享,能让你看到算法是如何在真实世界中发光发热的。下次当你面对一堆看似杂乱的数据时,别忘了,一个巧妙的算法可能就是你找到宝藏的那把钥匙!
编码愉快!🚀