开发一个简易的图书推荐系统并不一定要依赖复杂的机器学习平台。在Java语言中,我们可以利用基础集合类和简单的数学公式,快速实现一个基于协同过滤思想的图书推荐模块。这种方式特别适合教学演示、小型内部工具或者原型验证。

一、推荐模块的核心思路
图书推荐的本质是预测某用户对某本书的偏好程度。协同过滤算法认为,如果读者A和读者B在过去对多本书的评分趋势一致,那么B喜欢而A没读过的书,A也可能喜欢。这种思路不需要知道书的分类标签,只依赖用户行为数据。
在简易实现里,我们把每位用户的图书评分放到一个映射结构中,整体形成一个用户到图书再到分数的二级Map。随后通过余弦相似度来量化两个用户之间的兴趣重合度,挑出最相似的若干邻居,最后汇总邻居的高分图书作为推荐候选。
1.1 数据结构的设计
使用Map<String, Map<String, Double>>来表达评分矩阵非常直观。外层键是用户编号,内层键是图书编号,值为评分,比如五分制。这样在查找和维护时都很方便,也不需要引入额外实体类。
为了避免后续计算时空指针,初始化时要保证内层Map不为空。我们在添加评分的方法里做懒加载处理,如果用户不存在就新建一个内层Map再放分值,保持调用方代码简洁。
import java.util.*;
public class RatingMatrix {
// 用户-图书-评分
private Map<String, Map<String, Double>> data = new HashMap<>();
public void addRating(String user, String book, double score) {
data.computeIfAbsent(user, k -> new HashMap<>()).put(book, score);
}
public Map<String, Double> getRatings(String user) {
return data.getOrDefault(user, Collections.emptyMap());
}
public Set<String> getUsers() {
return data.keySet();
}
}
二、相似度计算与邻居查找
余弦相似度把两个用户的评分向量看作空间中的线,用点积除以模长乘积得到夹角余弦值。值越接近1,兴趣越相似。对于没有共同评分书的用户,相似度直接记为0,防止推荐噪声。
实现时先取出两个用户各自的评分Map,遍历交集图书累加乘积与平方,再套公式。由于是简易系统,我们可以在每次推荐时实时计算,数据量不大时性能完全够用。
2.1 余弦相似度代码
下面方法接收两个用户的评分映射,返回双精度相似度。注意处理分母为0的情况,否则会出现算术异常。
public class SimilarityUtil {
public static double cosine(Map<String, Double> a, Map<String, Double> b) {
double dot = 0.0, normA = 0.0, normB = 0.0;
for (String book : a.keySet()) {
if (b.containsKey(book)) {
dot += a.get(book) * b.get(book);
}
normA += a.get(book) * a.get(book);
}
for (double v : b.values()) {
normB += v * v;
}
if (normA == 0 || normB == 0) return 0.0;
return dot / (Math.sqrt(normA) * Math.sqrt(normB));
}
}
2.2 查找最近邻居
针对目标用户,我们遍历其他所有用户,算出相似度后放进列表排序,取前N个作为邻居。这个N就是常说的邻居数量参数,太小易片面,太大易引入不相关用户。
在示例里设定邻居数为2,用优先队列或简单排序均可。下面代码用流排序获得最相似的两个用户编号,方便后续汇总推荐。
import java.util.*;
public class NeighborFinder {
public static List<String> findTopNeighbors(String target, RatingMatrix matrix, int k) {
Map<String, Double> targetRatings = matrix.getRatings(target);
return matrix.getUsers().stream()
.filter(u -> !u.equals(target))
.map(u -> new AbstractMap.SimpleEntry<>(u,
SimilarityUtil.cosine(targetRatings, matrix.getRatings(u))))
.sorted((e1, e2) -> Double.compare(e2.getValue(), e1.getValue()))
.limit(k)
.map(Map.Entry::getKey)
.toList();
}
}
三、生成图书推荐列表
得到邻居后,我们把邻居评过而目标用户没评过的书收集起来,按邻居评分加权或简单平均排序。简易版直接取邻居里评分高于某阈值的书,按平均分倒序输出前几条。
这种策略实现成本低,也能体现协同过滤的基本效果。如果希望更平滑,可以用相似度作为权重做加权平均,但核心流程不变。
3.1 推荐方法示例
以下代码整合前面组件,对目标用户给出最多五本推荐图书。先找邻居,再过滤已读,最后算平均分排序。
import java.util.*;
public class BookRecommender {
public static List<String> recommend(String target, RatingMatrix matrix) {
List<String> neighbors = NeighborFinder.findTopNeighbors(target, matrix, 2);
Map<String, Double> targetBooks = matrix.getRatings(target);
Map<String, Double> scoreSum = new HashMap<>();
Map<String, Integer> count = new HashMap<>();
for (String n : neighbors) {
for (Map.Entry<String, Double> e : matrix.getRatings(n).entrySet()) {
if (!targetBooks.containsKey(e.getKey()) && e.getValue() >= 4.0) {
scoreSum.merge(e.getKey(), e.getValue(), Double::sum);
count.merge(e.getKey(), 1, Integer::sum);
}
}
}
return scoreSum.entrySet().stream()
.map(e -> new AbstractMap.SimpleEntry<>(e.getKey(),
e.getValue() / count.get(e.getKey())))
.sorted((a, b) -> Double.compare(b.getValue(), a.getValue()))
.limit(5)
.map(Map.Entry::getKey)
.toList();
}
}
3.2 模块调用演示
写一个简单的main方法填充数据并调用推荐,就能看到结果。这也方便打包成单元测试验证逻辑。
public class Demo {
public static void main(String[] args) {
RatingMatrix rm = new RatingMatrix();
rm.addRating("u1", "b1", 5.0);
rm.addRating("u1", "b2", 4.0);
rm.addRating("u2", "b1", 4.5);
rm.addRating("u2", "b3", 5.0);
rm.addRating("u3", "b2", 4.0);
rm.addRating("u3", "b3", 4.5);
System.out.println(BookRecommender.recommend("u1", rm));
}
}
四、优缺点与扩展方向
上述方案优点在于零依赖、逻辑透明,初学者能在一百行内搞清推荐流程。但它把所有数据放内存,重启即丢,且相似度实时计算在万级用户时会变慢。
若投入实际使用,可把评分存到关系数据库或Redis,相似度计算改成离线任务,定时更新邻居表。另外可引入基于物品的协同过滤,缓解新用户冷启动问题,或混合内容标签提升准确率。
4.1 冷启动的应对
新用户没有评分时,任何协同过滤都难办。简易系统可先给热门书或编辑精选书做默认推荐,等产生行为后再切入算法推荐,保证体验不空窗。
在Java里可以用一个静态热门列表兜底,当推荐结果为空时返回该列表,代码改动很小却实用。这也是很多小型图书站点起步阶段的有效做法。