题目描述

✅ 面试题 17.26. 稀疏相似度

image-20260929010650402

image-20260929010650403

题意分析

每篇文档由一组互不重复的整数单词表示,文档编号就是数组下标。两文档的相似度为交集大小除以并集大小,只输出相似度大于零的文档对,较小编号在前,数值格式化为四位小数;结果数组的组间顺序任意,空文档忽略。

解法:倒排索引只累计非零交集

核心思路

[!blue]

只有共享至少一个单词的文档对才需要输出。逐对比较所有文档会把大量时间花在交集为空的组合上,可以反过来从单词出发:用 wordToDocs[word] 保存包含这个词的所有文档编号,也就是倒排索引。

对某个单词,枚举它的列表中全部不同文档对,为每对的交集计数加一。单篇文档内没有重复词,所以一个文档在该列表中只出现一次,这个词也只给同一文档对贡献一次。处理全部单词后,一对文档被累加的次数,恰好等于它们共享的不同单词数。

这种方式不会漏掉需要输出的组合:交集非空的任意文档对,总能在至少一个共同词的列表中被枚举到;交集为空的组合不会出现在任何同词列表中,也就不会被写入计数表。空文档没有词,自然不会进入任何列表,无需额外计算零相似度。

建表时按文档编号升序扫描,倒排列表也天然升序。在同一个列表里只取 left < right 的两个位置,就能保证 firstDoc < secondDoc,每对只按一个方向记录。代码用 firstDoc * N + secondDoc 编码文档对,其中 N 是文档总数;因为第二个编号在 [0, N) 内,整除与取余可以唯一还原两个编号,不会混淆不同组合。

对每个已记录的正交集,使用集合恒等式 union = len(first) + len(second) - intersection 得到并集,再做浮点除法。两篇长度相加时,共同元素被算了两次,所以必须减去一份交集。最后按指定格式输出四位小数;原实现加上很小的 1e-9 修正,处理本题有限文档长度下的舍入临界值,而不是截断小数。

解题步骤

  1. 按文档编号遍历每个词,建立单词到文档编号列表的映射。
  2. 对每个列表枚举两两不同编号,编码成键并增加对应交集计数。
  3. 遍历交集计数表,解码文档编号,用两文档长度求并集。
  4. 计算浮点相似度,格式化为 id1,id2: similarity,保留四位小数后返回。

代码实现

class Solution {
    public List<String> computeSimilarities(int[][] docs) {
        int docCount = docs.length;
        Map<Integer, List<Integer>> wordToDocs = new HashMap<>();

        for (int docId = 0; docId < docCount; docId++) {
            for (int word : docs[docId]) {
                wordToDocs.computeIfAbsent(word, key -> new ArrayList<>()).add(docId);
            }
        }

        Map<Long, Integer> pairToIntersection = new HashMap<>();

        for (List<Integer> docIds : wordToDocs.values()) {
            for (int left = 0; left < docIds.size(); left++) {
                for (int right = left + 1; right < docIds.size(); right++) {
                    int firstDoc = docIds.get(left);
                    int secondDoc = docIds.get(right);
                    long pairKey = (long) firstDoc * docCount + secondDoc;

                    pairToIntersection.put(
                            pairKey, pairToIntersection.getOrDefault(pairKey, 0) + 1);
                }
            }
        }

        List<String> answer = new ArrayList<>();

        for (long pairKey : pairToIntersection.keySet()) {
            int firstDoc = (int) (pairKey / docCount);
            int secondDoc = (int) (pairKey % docCount);
            int intersection = pairToIntersection.get(pairKey);
            int union = docs[firstDoc].length + docs[secondDoc].length - intersection;
            double similarity = (double) intersection / union;

            answer.add(String.format("%d,%d: %.4f", firstDoc, secondDoc, similarity + 1e-9));
        }

        return answer;
    }
}
import (
    "fmt"
)

func computeSimilarities(docs [][]int) []string {
    docCount := len(docs)
    wordToDocs := map[int][]int{}

    for docId, doc := range docs {
        for _, word := range doc {
            wordToDocs[word] = append(wordToDocs[word], docId)
        }
    }

    pairToIntersection := map[int]int{}
    for _, docIds := range wordToDocs {
        for left := 0; left < len(docIds); left++ {
            for right := left + 1; right < len(docIds); right++ {
                firstDoc := docIds[left]
                secondDoc := docIds[right]
                pairKey := firstDoc*docCount + secondDoc
                pairToIntersection[pairKey]++
            }
        }
    }

    answer := make([]string, 0, len(pairToIntersection))
    for pairKey, intersection := range pairToIntersection {
        firstDoc := pairKey / docCount
        secondDoc := pairKey % docCount
        union := len(docs[firstDoc]) + len(docs[secondDoc]) - intersection
        similarity := float64(intersection) / float64(union)
        answer = append(answer, fmt.Sprintf("%d,%d: %.4f", firstDoc, secondDoc, similarity+1e-9))
    }

    return answer
}

复杂度分析

  • 时间复杂度:期望 $O(N+T+\sum_w f_w^2)$,其中 N 是文档数、T 是全部词条数,f_w 是包含词 w 的文档数量。建表处理全部词条,每个倒排列表枚举 $\binom{f_w}{2}$ 对;非零组合的输出数量也被这些枚举次数覆盖。
  • 空间复杂度:$O(T+P+1)$,其中 P 是交集非空的文档对数,用于倒排列表、交集计数与结果。

关键点总结

[!green]

  • 按共同词枚举文档对,直接跳过所有没有共享词的组合。
  • 文档内部词互异,保证每个共同词恰好为交集贡献一次。
  • 倒排列表已经按编号有序,输出数组也不要求排序,二者都无需额外排序。

易错点总结

[!yellow]

  • 不能把单词出现的文档数直接当作交集,交集要按每个具体文档对分别累计。
  • 并集必须从两文档长度之和中减去交集,且除法前要转成浮点类型。
  • 配对时只枚举列表中不同的两个位置,避免自身配对与正反重复。
  • 编码和解码都必须使用同一个文档总数 N,不能改用某个倒排列表的长度。
  • 删除高频词会改变原文档的交并集,不能作为精确相似度算法的剪枝。

相似题目

题目 难度 关联与区别
350. 两个数组的交集 II 简单 同样求交集,本题输入是集合并要求多组非零交集,倒排索引避免枚举无共同词的文档对。
补充题 168. 汉字总频次与文档频次统计 中等 同样区分词频与出现文档集合,本题进一步用共享文档列表累计文档对交集。
转载与许可
作者
链接 https://hgnulb.github.io/blog/2026/84425278
许可 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处!