推荐算法Java实战:基于内容、ItemCF与UserCF完整实现
2026/9/2 5:31:07 网站建设 项目流程

简介:面向 Java 开发者和推荐系统初学者的算法实现集合,涵盖 Slope One、SVD(奇异值分解)、随机化 SVD 以及基于物品邻接的 SVD 四种推荐算法。代码基于 Maven 工程组织,共 69 个文件,以 Java 源文件、class 编译文件为主,辅以配置文件、prefs 工程设置、properties 与 pom.xml,便于直接导入 IDE 运行调试;另有 regeneration.py 辅助脚本和 readme.txt 说明文档,压缩包整体仅 86KB,结构紧凑。已有 11771 人学习下载。资源的价值在于通过可运行的 Java 实现,帮助读者理解评分矩阵分解、物品相似度计算等推荐系统核心思路;结合目录中的 dami 数据集,可进行小规模实验与算法效果对比。对照 readme.txt 可以快速梳理项目结构,调整评分矩阵或相似度阈值来观察推荐结果差异,从而加深对协同过滤与矩阵分解原理的理解。对于想要掌握推荐算法落地细节、提升工程实践能力的开发者而言,这是一份轻量而实用的参考资料。 推荐算法这个词,过去几年在技术圈都快被说烂了,但落到实际开发里,很多人第一反应是Spark、Python、TensorFlow那一套,好像跟Java没什么关系。其实不然,Java在推荐系统里的位置一直很稳,尤其在企业级后端,算法算完结果,最终还是要靠Java服务把推荐列表发出去。我见过不少团队,算法那边用Python出模型,线上Java服务负责加载物品相似度矩阵、实时算用户得分,整条链路里Java承担的角色一点都不轻。

这篇文章我就用自己的代码,把几个经典推荐算法的Java实现完整走一遍。包含基于内容的推荐、ItemCF(基于物品的协同过滤)、UserCF(基于用户的协同过滤),以及它们在实际工程里最常见的几种组合玩法。代码都是可以直接复制跑起来的,我尽量用最简单的数据结构,不依赖任何第三方框架,方便你理解核心逻辑。适合正在学Java、准备面试、或者刚接手推荐相关需求还不太知道从哪下手的同学。

1. 推荐算法选型:为什么从这三个入手

1.1 基于内容推荐:适合冷启动

基于内容的推荐,核心逻辑是“物以类聚”——给用户推荐跟他历史上喜欢过的物品相似的物品。这里的“相似”不是靠用户行为算的,而是靠物品自身的属性算的,比如文章的标签、商品的分类、电影的题材。

这种算法最大的优点就是没有冷启动问题。一个新用户,只要他点过一篇关于“Java并发”的文章,系统就能立刻给他推“Java线程池”“JVM调优”相关的内容,完全不需要等他积累足够多的历史行为。所以在新闻资讯、博客、商品详情页这类场景里,基于内容的推荐是上线最快、效果最容易解释的方案。

我刚接手推荐需求那会儿,第一版就是用基于内容推荐做的,因为当时用户行为数据几乎为零,你给我讲协同过滤我也用不起来。后来数据慢慢多了,才逐步把协同过滤加进来。

1.2 协同过滤:UserCF 与 ItemCF 如何取舍

协同过滤的核心思想是“人以群分”或者“物以群分”的另一种表达:如果你和另一个用户过去的行为高度相似,那ta喜欢的东西你也很有可能喜欢(UserCF);或者反过来,一个物品和另一个物品经常被同一批人喜欢,那喜欢A的人也会喜欢B(ItemCF)。

这两个算法没有绝对的好坏,关键看场景:

  • UserCF更适合用户少、物品多的场景,比如新闻推荐、社交推荐,用户之间的兴趣迁移比较明显。
  • ItemCF更适合物品数量相对稳定、用户兴趣变化较慢的场景,比如电商、视频网站,因为物品相似度矩阵可以离线算好,线上只做查表加排序,性能非常可控。

电商场景下,用户量远远大于物品量,计算用户相似度的开销会非常大,所以工业界大多数场景都倾向于用ItemCF。我做过的项目里,ItemCF的落地频率比UserCF高得多。

2. 数据模型设计:先把手里的数据捋清楚

2.1 评分数据模型

不管哪种推荐算法,最底层的数据结构无非就是“用户-物品-行为”。我习惯用一个最简单的Rating类来承载。

public class Rating { private long userId; private long itemId; private double score; public Rating(long userId, long itemId, double score) { this.userId = userId; this.itemId = itemId; this.score = score; } public long getUserId() { return userId; } public long getItemId() { return itemId; } public double getScore() { return score; } }

score可以是显式评分(比如用户打了5分),也可以是隐式反馈的量化值(比如点击次数、浏览时长、购买金额的归一化结果)。实际项目里,隐式反馈数据占绝大多数,千万不要只盯着评分表,用户的行为日志才是金矿。

2.2 相似度计算工具

相似度计算是推荐算法的数学地基。我用得最多的两个相似度是余弦相似度和皮尔逊相关系数。

余弦相似度的计算方式很简单:

public class SimilarityUtils { /** * 计算两个用户/物品之间基于集合的余弦相似度 * 这里用Set存储用户交互过的物品ID集合 */ public static double cosineSimilarity(Set<Long> setA, Set<Long> setB) { if (setA == null || setB == null || setA.isEmpty() || setB.isEmpty()) { return 0.0; } Set<Long> intersection = new HashSet<>(setA); intersection.retainAll(setB); double dotProduct = intersection.size(); double normA = Math.sqrt(setA.size()); double normB = Math.sqrt(setB.size()); if (normA == 0.0 || normB == 0.0) { return 0.0; } return dotProduct / (normA * normB); } }

注意这里有个小陷阱:用集合算余弦相似度时,如果两个集合都是空集合,分母会变成0,必须提前判空。真跑线上数据的时候,空集合的情况很常见,不是每个用户都有行为记录的。

3. 基于内容的推荐:Java 实现标签匹配

3.1 核心思路

基于内容的推荐,实现路径一般是这样的:给每个物品打标签,给每个用户维护一个兴趣标签权重表,用户每产生一次正反馈行为(点击、收藏、购买),就把对应物品的标签权重累加到用户画像上。推荐的时候,遍历候选物品,算一下物品标签和用户画像的匹配得分,按得分倒序输出。

这里有个关键点:用户画像的更新不能只是简单累加,不然高频标签会迅速霸榜。我一般会做时间衰减,让远期行为的权重逐渐降低。

3.2 推荐全流程代码

public class ContentBasedRecommender { // 物品ID -> 物品标签集合 private Map<Long, Set<String>> itemTags; // 用户ID -> 标签权重表 private Map<Long, Map<String, Double>> userProfiles; public ContentBasedRecommender(Map<Long, Set<String>> itemTags) { this.itemTags = itemTags; this.userProfiles = new HashMap<>(); } /** * 用户对某个物品产生正向行为,更新用户画像 */ public void updateUserProfile(long userId, long itemId, double weight) { Set<String> tags = itemTags.getOrDefault(itemId, Collections.emptySet()); Map<String, Double> profile = userProfiles.computeIfAbsent(userId, k -> new HashMap<>()); for (String tag : tags) { profile.put(tag, profile.getOrDefault(tag, 0.0) + weight); } } /** * 给用户推荐topN个物品 */ public List<Long> recommend(long userId, int topN) { Map<String, Double> profile = userProfiles.getOrDefault(userId, Collections.emptyMap()); if (profile.isEmpty()) { return Collections.emptyList(); } Map<Long, Double> scores = new HashMap<>(); for (Map.Entry<Long, Set<String>> entry : itemTags.entrySet()) { long itemId = entry.getKey(); Set<String> tags = entry.getValue(); double score = 0.0; for (String tag : tags) { score += profile.getOrDefault(tag, 0.0); } if (score > 0) { scores.put(itemId, score); } } return scores.entrySet().stream() .sorted(Map.Entry.<Long, Double>comparingByValue().reversed()) .limit(topN) .map(Map.Entry::getKey) .collect(Collectors.toList()); } }

这套代码在工程上已经够用了。实际落地的时候可以做两点优化:一是提前把物品标签表加载到内存的HashMap里,二是给每个用户的画像只保留权重最高的几十个标签,防止内存膨胀。用户画像如果无限增长,内存再大也扛不住。

4. 协同过滤:ItemCF 完整实现

4.1 构建物品共现矩阵

ItemCF的第一步,是根据用户行为数据构建物品与物品之间的共现关系。所谓共现,就是两个物品被同一个用户产生过行为。比如用户A同时买过《Java编程思想》和《Effective Java》,那这两本书就累计一次共现。所有用户的行为叠加在一起,就形成了一个物品共现矩阵。

public class ItemCF { private Map<Long, Map<Long, Integer>> itemCooccurrence; public ItemCF(List<Rating> ratings) { itemCooccurrence = new HashMap<>(); // 第一步:把用户行为转换成 用户->物品集合 Map<Long, Set<Long>> userItems = new HashMap<>(); for (Rating r : ratings) { userItems.computeIfAbsent(r.getUserId(), k -> new HashSet<>()) .add(r.getItemId()); } // 第二步:统计物品共现次数 for (Set<Long> items : userItems.values()) { List<Long> itemList = new ArrayList<>(items); for (int i = 0; i < itemList.size(); i++) { long itemA = itemList.get(i); for (int j = i + 1; j < itemList.size(); j++) { long itemB = itemList.get(j); itemCooccurrence.computeIfAbsent(itemA, k -> new HashMap<>()) .merge(itemB, 1, Integer::sum); itemCooccurrence.computeIfAbsent(itemB, k -> new HashMap<>()) .merge(itemA, 1, Integer::sum); } } } } }

计算共现矩阵的时间复杂度是O(用户数 x 用户行为数²),当某个用户行为特别多的时候,这个循环会非常耗时,后面我会专门讲怎么优化。

4.2 物品相似度与推荐打分

有了共现矩阵,还需要归一化得到相似度。经典做法是余弦归一化:两个物品的相似度 = 共现次数 / sqrt(物品A被交互次数 * 物品B被交互次数)。

推荐打分的时候,遍历用户已经交互过的物品,找这些物品的相似物品,然后累加得分。这个“累加”过程,本质上是把用户的历史兴趣映射到相似物品上。

public class ItemCFRecommender { private Map<Long, Map<Long, Double>> itemSimilarity; private Map<Long, Set<Long>> itemUsers; public ItemCFRecommender(List<Rating> ratings) { // 统计每个物品被哪些用户交互过 itemUsers = new HashMap<>(); Map<Long, Integer> itemFreq = new HashMap<>(); for (Rating r : ratings) { itemUsers.computeIfAbsent(r.getItemId(), k -> new HashSet<>()).add(r.getUserId()); itemFreq.merge(r.getItemId(), 1, Integer::sum); } // 计算共现 Map<Long, Map<Long, Integer>> cooccurrence = new HashMap<>(); Map<Long, Set<Long>> userItems = new HashMap<>(); for (Rating r : ratings) { userItems.computeIfAbsent(r.getUserId(), k -> new HashSet<>()).add(r.getItemId()); } for (Set<Long> items : userItems.values()) { List<Long> list = new ArrayList<>(items); for (int i = 0; i < list.size(); i++) { for (int j = i + 1; j < list.size(); j++) { long a = list.get(i), b = list.get(j); cooccurrence.computeIfAbsent(a, k -> new HashMap<>()).merge(b, 1, Integer::sum); cooccurrence.computeIfAbsent(b, k -> new HashMap<>()).merge(a, 1, Integer::sum); } } } // 归一化得到相似度 itemSimilarity = new HashMap<>(); for (Map.Entry<Long, Map<Long, Integer>> entry : cooccurrence.entrySet()) { long itemA = entry.getKey(); for (Map.Entry<Long, Integer> e : entry.getValue().entrySet()) { long itemB = e.getKey(); int count = e.getValue(); double sim = count / Math.sqrt(itemFreq.getOrDefault(itemA, 1) * itemFreq.getOrDefault(itemB, 1)); itemSimilarity.computeIfAbsent(itemA, k -> new HashMap<>()).put(itemB, sim); } } } public List<Long> recommend(long userId, int topN) { Set<Long> interactedItems = new HashSet<>(); for (Map.Entry<Long, Set<Long>> entry : itemUsers.entrySet()) { if (entry.getValue().contains(userId)) { interactedItems.add(entry.getKey()); } } Map<Long, Double> scores = new HashMap<>(); for (long itemA : interactedItems) { Map<Long, Double> simMap = itemSimilarity.getOrDefault(itemA, Collections.emptyMap()); for (Map.Entry<Long, Double> simEntry : simMap.entrySet()) { long itemB = simEntry.getKey(); if (interactedItems.contains(itemB)) { continue; } scores.merge(itemB, simEntry.getValue(), Double::sum); } } return scores.entrySet().stream() .sorted(Map.Entry.<Long, Double>comparingByValue().reversed()) .limit(topN) .map(Map.Entry::getKey) .collect(Collectors.toList()); } }

这套逻辑跑起来之后,效果一般都不会太差。我踩过的一个坑是:有时候推荐列表里会混进一些热门物品,什么人都被推荐同一个东西,这就是长尾效应没处理好。解决方案是训练的时候对所有相似度做一次平滑,或者对流行度做一个惩罚系数,比如把相似度乘以一个衰减因子。

5. UserCF 与组合玩法

5.1 基于用户的协同过滤实现

UserCF的思路是:算用户与用户之间的相似度,找到跟当前用户最像的几个用户,把这几个人喜欢的物品推荐给当前用户。

public class UserCFRecommender { private Map<Long, Set<Long>> userItems; public UserCFRecommender(List<Rating> ratings) { userItems = new HashMap<>(); for (Rating r : ratings) { userItems.computeIfAbsent(r.getUserId(), k -> new HashSet<>()).add(r.getItemId()); } } public List<Long> recommend(long userId, int topN) { Set<Long> myItems = userItems.getOrDefault(userId, Collections.emptySet()); // 找最相似的K个用户 int k = 10; PriorityQueue<Map.Entry<Long, Double>> pq = new PriorityQueue<>( (a, b) -> Double.compare(a.getValue(), b.getValue()) ); for (Map.Entry<Long, Set<Long>> entry : userItems.entrySet()) { long otherUserId = entry.getKey(); if (otherUserId == userId) { continue; } double sim = SimilarityUtils.cosineSimilarity(myItems, entry.getValue()); if (sim <= 0) { continue; } pq.offer(new AbstractMap.SimpleEntry<>(otherUserId, sim)); if (pq.size() > k) { pq.poll(); } } // 邻居喜欢的物品加权汇总 Map<Long, Double> scores = new HashMap<>(); for (Map.Entry<Long, Double> neighbor : pq) { long neighborId = neighbor.getKey(); double sim = neighbor.getValue(); Set<Long> neighborItems = userItems.getOrDefault(neighborId, Collections.emptySet()); for (long item : neighborItems) { if (!myItems.contains(item)) { scores.merge(item, sim, Double::sum); } } } return scores.entrySet().stream() .sorted(Map.Entry.<Long, Double>comparingByValue().reversed()) .limit(topN) .map(Map.Entry::getKey) .collect(Collectors.toList()); } }

PriorityQueue的用法需要注意:默认是小顶堆,所以当堆的大小超过k时,直接poll掉堆顶元素,留在堆里的就是相似度最高的K个用户。好几个面试官问过我这个数据结构选择的问题,Java里求TopN,PriorityQueue是标准答案。

5.2 多种算法组合策略

单一算法很难满足所有场景需求,实际工程里我更倾向于做组合。常见的组合方式有两种:

第一种是加权融合,给不同算法算出的得分乘上一个权重系数再加总。比如最终得分 = 0.6 * ItemCF得分 + 0.3 * 内容推荐得分 + 0.1 * UserCF得分,权重可以在离线评估阶段用网格搜索确定。

第二种是召回-排序分离,先用简单的规则或算法召回几千个候选物品,再用更精细的模型(比如LR、GBDT)对候选排序。这套架构在工业界已经是标配了,Java服务通常负责召回这一步,把分数算完传给上层的排序模型。

我做过一个比较顺手的方案是:离线用ItemCF算好每个用户最近邻的物品列表,存到Redis里,线上Java服务直接查Redis拿候选,再用基于内容的分数做一个轻量级的排序修正。这样速度极快,P99响应时间能控制在50ms以内,而且效果比纯ItemCF要好,因为内容分能够修正协同过滤在新物品上的盲区。

6. 常见问题与排查技巧

6.1 稀疏矩阵与内存优化

推荐系统的数据天生就是稀疏的。几百万用户、几十万物品,如果用一个二维数组存评分矩阵,内存直接爆炸。比如10万物品 x 10万物品的相似度矩阵,double类型占用约80GB,根本没法在普通服务器上跑。

方法很简单:用稀疏矩阵。Java里最常用的是Map<Long, Map<Long, Double>>或者HashMap<Key, Double>这类嵌套结构,只存有值的格子。空间复杂度跟非零元素数量成正比,能省下好几个数量级的内存。

我还有个习惯:如果物品相似度矩阵太大了,干脆不存全量相似度,只给每个物品保留Top50的相似物品。效果上差异很小,内存却降了99%。

6.2 数值稳定性与排序细节

做相似度归一化时容易出NaN。比如分母为0,或者两个数相乘溢出。推荐算法里最常见的数值问题就是分母为0,比如物品A被交互次数是0,Math.sqrt(0 * 10)为0,除以0就是NaN。

排查线上数据时,我一般先加一个工具方法,检测所有相似度值里有没有NaN:

public static boolean hasNaN(Map<Long, Map<Long, Double>> matrix) { for (Map<Long, Double> row : matrix.values()) { for (Double v : row.values()) { if (v.isNaN()) { return true; } } } return false; }

排序的时候也要小心。Java 8之后的Stream.sorted是稳定排序,但如果你用parallelStream,结果顺序就不保证了。生产环境我都是老老实实用单线程stream,或者直接Collections.sort,省得线上排序结果时好时坏、不好排查。

6.3 冷启动处理三板斧

冷启动问题是推荐系统里绕不开的坎。我总结下来就三板斧:

第一,规则兜底。用户没有行为数据时,直接按热度推荐,什么点击多、销量高就推什么,这是最简单粗暴也最有效的方案。

第二,内容推荐顶上。用户哪怕只有一个点击行为,基于内容的推荐就能发挥作用,在用户画像足够丰富之前,内容推荐是承接新用户的最佳方案。

第三,探索与利用。在推荐列表里故意混入一些新物品、长尾物品,让系统有机会探索用户的兴趣边界。工业界常用的策略是ε-greedy,大概5%-10%的流量用来试探。

这三板斧看着简单,但能把冷启动做好就已经解决了推荐系统落地过程中一半的问题。新用户转化率上不去,很多时候不是算法不够好,而是冷启动策略没做到位。

6.4 数据量与性能平衡

最后聊一下大数据量下的性能优化问题。虽然文章里的实现是纯Java单机版,但真实业务里数据量一上来,有几个地方一定要提前设计:

  • 离线计算与在线服务分离。物品相似度矩阵、用户相似度矩阵这类结果,一定要用离线任务算好,线上服务只做加载查询。千万不能在请求链路里现场算相似度,那会慢到你怀疑人生。
  • 缓存设计。用户最近交互的物品、每个物品的TopN相似列表,都适合放到Redis里。Redis的读取速度在毫秒级,能大大降低在线推理延迟。
  • 向量化计算。如果你真的需要在服务端现场算用户向量和物品向量的相似度,Java里可以考虑用jblasnd4j这些线性代数库,比手写for循环快很多。当然,对于大多数中小规模场景,手写for循环配合稀疏存储已经完全够用了。

最后说两句

这几个推荐算法的Java实现,我从前到后完整写了一遍,代码量不多,但每段都代表了一类典型的推荐思路。我自己的体会是:不要在面试里把推荐算法描述得天花乱坠,背八股文式的概念一点用没有。把UserCF和ItemCF的数学原理用Java代码写出来,把内容推荐的标签匹配逻辑落成一个可运行的类,这些实践的深度比背十个概念都管用。

如果你正准备面Java岗位,可以试着把这里的代码自己敲一遍,然后把几个关键问题想清楚:为什么ItemCF适合电商场景?余弦相似度为什么能过滤掉用户打分尺度的影响?PriorityQueue求TopN的时间复杂度是多少?这些问题一旦在面试现场被问到,你的代码功底扎实不扎实,一眼就能看出来。

最后再分享一个小技巧:写完推荐算法之后,一定要写个测试类,造一点假数据,比如5个用户、10个物品、几十条评分记录,然后手工算一遍期望结果,跟代码输出比对。我每次都是这么验证算法的,既快又准,比逮着大数据集一顿跑、看着指标瞎猜要靠谱得多。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询