搜索引擎的基本组成
搜索的核心思路
对于一个搜索引擎来说,首先需要获取到很多的网页,然后再根据用户输入的查询词,在这些网页中进行查询。
但是有如下的问题需要解决:
- 搜索引擎的网页是怎么获取的?
此处主要涉及到 “爬虫”这样的程序。
- 用户输入了查询词之后,如何去让查询词和当前的这些网页进行匹配呢?
此处需要设计一个特殊的数据结构,叫 倒排索引。(此数据结构能够让用户非常高效的获取到结果)
倒排索引
在介绍倒排索引之前,先介绍几个概念:
- 文档(document) :指的是每个待搜索的页面(这些页面通过爬虫获取并保存的)。
- 正排索引:指的是 文档 id --> 文档内容(一般文档内容包含:查询词、段落、标题等等)。
- 倒排索引:指的是 词 --> 文档 id 列表。(通过查询词来获取跟词有关的文档列表)。
举个例子:
现在有如下文档列表:
| 文档 id | 文档内容 |
|---|---|
| 1 | 乔布斯发布了苹果手机 |
| 2 | 乔布斯买了四斤苹果 |
正排索引:
1 乔布斯发布了苹果手机
2 乔布斯买了四斤苹果
倒排索引:
乔布斯 1,2
发布 1
....
项目目标
实现一个针对 Java 文档的搜索引擎。
像百度、搜狗、必应 这种搜索引擎,都是属于 “全站搜索”,搜索整个互联网上所有的网站。
还有一类搜索引擎,称为“站内搜索”,只针对某个网站内部的内容进行搜索的。
我们要实现的搜索引擎就是站内搜索。
获取 Java 文档
我们需要把相关的网页文档获取到,这样才能够制作正排索引和倒排索引。
问题:通过爬虫技术,能否把这些文档给获取到呢?
完全可以的!爬虫就是一个 HTTP 客户端(和浏览器类似)。
爬虫,是获取到一个网站页面的一种通用手段。但是针对 Java 文档来说,我们有更简单的办法!
可以直接从官方网站上下载文档的压缩包。
官方网站的地址为:官网
进入网站后,下载压缩包
并在本地进行解压缩:
java 文档的搜索界面在 docs 文件夹下的 api 目录中:
随便点开一个网页,发现离线文档和在线文档的网址后面都是一样的:
我们可以利用这一点:
在本地基于离线文档来制作索引,实现搜索。当用户在搜索结果页点击具体的搜索结果的时候,就自动跳转到在线文档的页面。
模块划分
- 索引模块
-
- 扫描下载到的文档,分析文档的内容,构建出正排索引+倒排索引,并且把索引内容保存到文件中。
- 加载制作好的索引,并提供一些 API 实现查正排和查倒排的功能。
- 搜索模块
-
- 调用索引模块,实现一个搜索的完整过程。
- 输入:用户的查询词。
- 输出:完整的搜索结果(包含很多条记录,每个记录有标题、描述、展示 URL,并且能够跳转)。
- web 模块: 需要实现一个简单的 web 程序,能够通过网页的形式来和用户进行交互。
开始项目
创建 Maven 项目,并引入第三方分词库Ansj
<dependency>
<groupId>org.ansj</groupId>
<artifactId>ansj_seg</artifactId>
<version>5.1.6</version>
</dependency>
测试下 Ansj 的分词效果:
import org.ansj.domain.Term;
import org.ansj.splitWord.analysis.ToAnalysis;
import java.util.List;
public class TestAnsj {
public static void main(String[] args) {
String str = "小米比白米有更多的微量营养,小米取代白米不但有助控制血糖,而且可以有助逆转糖尿病,降低血脂,改善血压和降低心血管风险。";
List<Term> terms = ToAnalysis.parse(str).getTerms();
for (Term term : terms) {
System.out.println(term.getName());
}
}
}
结果:
这些警告是要我们引入词典,不过没必要,只是简单测试下:
分词结果:
注意:如果对英文进行分词,Ansj 会默认把单词转为小写进行分词。
一、实现索引模块
创建新类,命名为 Parser
Parser 类的整体流程
public class Parser {
// 指定一个加载文档的路径
private static final String INPUT_PATH = "E:\Coding\Java\java-code-exercise\A-MyProjects\docs\api";
public void run() {
// 整个 Parser 类的入口
// 1. 根据上面指定的路径,枚举出所有的文件(html),这个过程需要把所有子目录中的文件都能获取到
// 2. 针对上面罗列出的文件路径,打开文件,读取文件内容,并进行解析,并构建索引
// 3. 把在内存中构造好的索引数据结构,保存到指定的文件中。
}
public static void main(String[] args) {
// 通过 main 方法来实现制作索引的过程
Parser parser = new Parser();
parser.run();
}
}
递归枚举文件
public void run() {
// 整个 Parser 类的入口
// 1. 根据上面指定的路径,枚举出所有的文件(html),这个过程需要把所有子目录中的文件都能获取到
ArrayList<File> filelist = new ArrayList<>();
enumFile(INPUT_PATH,filelist);
// 2. 针对上面罗列出的文件路径,打开文件,读取文件内容,并进行解析,并构建索引
// 3. 把在内存中构造好的索引数据结构,保存到指定的文件中。
}
private void enumFile(String inputPath, ArrayList<File> filelist) {
File rootPath = new File(inputPath);
// 使用 listFiles 只能看到一级目录,看不到子目录
File[] files = rootPath.listFiles();
for (File file : files) {
if (file.isDirectory()) {
enumFile(file.getAbsolutePath(),filelist);
} else {
filelist.add(file);
}
}
}
我们来测试下 run 方法打印的东西:
发现除了 html 文件,还有别的文件类型:
所以接下来我们要排除 除html 文件之外的文件了。
排除非 HTML 文件
修改下 enumFile 方法的 else 语句块:
先将文件转换为路径字符串,然后在筛查出以 .html 为后缀的文件。
private void enumFile(String inputPath, ArrayList<File> filelist) {
File rootPath = new File(inputPath);
// 使用 listFiles 只能看到一级目录,看不到子目录
File[] files = rootPath.listFiles();
for (File file : files) {
if (file.isDirectory()) {
enumFile(file.getAbsolutePath(),filelist);
} else {
if (file.getAbsolutePath().endsWith(".html")) {
filelist.add(file);
}
}
}
}
查看结果:
获取到了 HTML 文件,接下来就要,解析 HTML 了。
解析 HTML
一条搜索结果,包含了 标题、描述、展示 URL,这些信息就来自于解析的 HTML。
因此当前的解析 HTML 操作,就是要把这个 HTML 文件的标题、描述 和 URL 给获取到。(其中,描述可以视为正文的一段摘要,因此要得到描述,就要先得到整个正文)
public void run() {
// 整个 Parser 类的入口
// 1. 根据上面指定的路径,枚举出所有的文件(html),这个过程需要把所有子目录中的文件都能获取到
ArrayList<File> filelist = new ArrayList<>();
enumFile(INPUT_PATH, filelist);
// 2. 针对上面罗列出的文件路径,打开文件,读取文件内容,并进行解析,并构建索引
for (File f : filelist) {
System.out.println("开始解析:" + f.getAbsolutePath());
// 通过这个方法来解析单个 HTML 文件
parserHTML(f);
}
// 3. 把在内存中构造好的索引数据结构,保存到指定的文件中。
}
private void parserHTML(File f) {
// 1. 解析出 HTML 的标题
String title = parserTitle(f);
// 2. 解析出 HTML 对应的 URL
String url = parserURL(f);
// 3. 解析出 HTML 对应的正文(有了正文才有后续的描述)
String content = parserContent(f);
}
解析标题
因为 Java 文档的标题跟他的文件名是相同的,只不过我们需要去掉 .html 后缀:
private String parserTitle(File f) {
String name = f.getName();
return name.substring(0, name.length() - ".html".length());
}
解析 URL
我们期望的结果是,用户点击搜索结果,就能够跳转到对应的线上文档的页面。
根据本地文档路径和线上文档路径的对比
我们最终跳转的 URL 以 为固定前缀,然后根据当前本地文档所在的路径,去和前缀进行拼接。
我们可以获取到本地文档路径的,只需要把后半部分提取出来:,再和前面的固定前缀进行拼接。
private String parserURL(File f) {
String part1 = "https://docs.oracle.com/javase/8/docs/api/";
String part2 = f.getAbsolutePath().substring(INPUT_PATH.length());
return part1 + part2;
}
测试此功能:
代码结果返回一个字符串:
我们使用此字符串进行访问:
可以访问,功能正常。
解析正文
比如我们打开一个本地的java文档:
我们需要从中读取正文,也就是标签里面的内容。
由于 HTML 的标签都是<>,我们可以根据这个特点去依次读取 HTML 中的每个字符。然后针对取出的每个字符进行判定,看结果是否是 <,如果是 <,那么从这个位置开始,直到遇到 >,都不把这些字符放到结果中。
换句话说,如果遇到的字符不是 <,就直接把当前的字符拷贝到一个结果中(StringBuilder)。
举个例子:
<div>这是一段内容</div>
先读到第一个字符:<,就从这个位置开始不进行数据拷贝。往后继续读的字符为 d,i,v,也都不进行拷贝,读到 >的时候仍不进行拷贝,但是不拷贝状态结束。
可以搞一个标志位 flag,为 true 就拷贝,为 false 就不拷贝。
遇到 <就把 flag 设为 false,遇到 > 就把 flag 设为 true。
后续读到“这是一段内容”就进行拷贝。
再后续读到 <又把 flag 设为 false,/div也就不进行拷贝,读到 >又把 flag 设为 true。
问题:如果 html 的内容中存在 < 或 > 怎么办?
html 就要求,内容中的 < 使用 < 来替换,> 使用 > 来替换。
读取文件的时候,有时候按照“字节”来读取,有时候按照“字符”来读取。
在 Java 标准库中,既提供了能够按照字节读取的类(FileInputStream),也提供了能按照字符读取的类(FileReader)。
public String parserContent(File f) {
// 先按照一个字符一个字符的方式来读取,以 < 和 > 来控制拷贝数据的开关
try (FileReader fileReader = new FileReader(f)) {
// 加上一个是否要进行拷贝的开关
boolean isCopy = true;
// content 用来存储结果
StringBuilder content = new StringBuilder();
while (true) {
// 注意,此处的 read,返回值是一个 int,而不是 char !
// 此处使用 int 作为返回值,主要为了表示一些非法情况!
// 如果读到文件末尾,继续读,就会返回 -1
int ret = fileReader.read();
if (ret == -1) {
// 表示文件读完了
break;
}
// 如果 ret 不为 -1,则为一个合法的字符
char c = (char)ret;
// 开关打开的状态,遇到普通字符拷贝到 StringBuilder 中
if (isCopy) {
if (c == '<') {
// 关闭开关
isCopy = false;
continue;
}
if (c == '\n' || c == '\r') {
// 为了去掉换行,把换行替换成空格
c = ' ';
}
content.append(c);
} else {
if (c == '>') {
isCopy = true;
}
}
}
return content.toString();
} catch (IOException e) {
e.printStackTrace();
}
return "";
}
测试代码:
代码结果:
结果都变为一行了,因为把换行替换成了空格。
Parser 类小结总代码:
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileReader;
import java.io.IOException;
import java.util.ArrayList;
public class Parser {
// 指定一个加载文档的路径
private static final String INPUT_PATH = "E:\Coding\Java\java-code-exercise\A-MyProjects\docs\api";
public void run() {
// 整个 Parser 类的入口
// 1. 根据上面指定的路径,枚举出所有的文件(html),这个过程需要把所有子目录中的文件都能获取到
ArrayList<File> filelist = new ArrayList<>();
enumFile(INPUT_PATH, filelist);
// 2. 针对上面罗列出的文件路径,打开文件,读取文件内容,并进行解析,并构建索引
for (File f : filelist) {
System.out.println("开始解析:" + f.getAbsolutePath());
// 通过这个方法来解析单个 HTML 文件
parserHTML(f);
}
// 3. TODO:把在内存中构造好的索引数据结构,保存到指定的文件中。
}
private void parserHTML(File f) {
// 1. 解析出 HTML 的标题
String title = parserTitle(f);
// 2. 解析出 HTML 对应的 URL
String url = parserURL(f);
// 3. 解析出 HTML 对应的正文(有了正文才有后续的描述)
String content = parserContent(f);
// 4. TODO:把解析出来的这些信息,加入到索引当中
}
public String parserContent(File f) {
// 先按照一个字符一个字符的方式来读取,以 < 和 > 来控制拷贝数据的开关
try (FileReader fileReader = new FileReader(f)) {
// 加上一个是否要进行拷贝的开关
boolean isCopy = true;
// content 用来存储结果
StringBuilder content = new StringBuilder();
while (true) {
// 注意,此处的 read,返回值是一个 int,而不是 char !
// 此处使用 int 作为返回值,主要为了表示一些非法情况!
// 如果读到文件末尾,继续读,就会返回 -1
int ret = fileReader.read();
if (ret == -1) {
// 表示文件读完了
break;
}
// 如果 ret 不为 -1,则为一个合法的字符
char c = (char)ret;
// 开关打开的状态,遇到普通字符拷贝到 StringBuilder 中
if (isCopy) {
if (c == '<') {
// 关闭开关
isCopy = false;
continue;
}
if (c == '\n' || c == '\r') {
// 为了去掉换行,把换行替换成空格
c = ' ';
}
content.append(c);
} else {
if (c == '>') {
isCopy = true;
}
}
}
return content.toString();
} catch (IOException e) {
e.printStackTrace();
}
return "";
}
private String parserURL(File f) {
String part1 = "https://docs.oracle.com/javase/8/docs/api/";
String part2 = f.getAbsolutePath().substring(INPUT_PATH.length());
return part1 + part2;
}
private String parserTitle(File f) {
String name = f.getName();
return name.substring(0, name.length() - ".html".length());
}
private void enumFile(String inputPath, ArrayList<File> filelist) {
File rootPath = new File(inputPath);
// 使用 listFiles 只能看到一级目录,看不到子目录
File[] files = rootPath.listFiles();
for (File file : files) {
if (file.isDirectory()) {
enumFile(file.getAbsolutePath(), filelist);
} else {
if (file.getAbsolutePath().endsWith(".html")) {
filelist.add(file);
}
}
}
}
}
在此类中,还有两个功能需要做:将信息加入到索引中 和 将索引的数据结果保存到文件中。
接下来就创建 Index 类来在内存中构造出索引结构:
Index 类构造索引结构
创建 新类 DocInfo用来表示文档的信息:
public class DocInfo {
private int docId;
private String title;
private String url;
private String content;
public int getDocId() {
return docId;
}
public void setDocId(int docId) {
this.docId = docId;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getUrl() {
return url;
}
public void setUrl(String url) {
this.url = url;
}
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
}
创建 新类 Weight用来表示查询词与文档之间的关联性:
public class Weight {
private int docId;
// 表示查询词和文档之间的关联性
// weight 越大,代表关联性越强
private int weight;
public int getDocId() {
return docId;
}
public void setDocId(int docId) {
this.docId = docId;
}
public int getWeight() {
return weight;
}
public void setWeight(int weight) {
this.weight = weight;
}
}
创建新类 Index用来创建索引:
import java.util.List;
public class Index {
// 1. 给定一个 docId,在正排索引中,查询文档的详细信息
public DocInfo getDocInfo(int docId) {
// TODO
return null;
}
// 2. 给定一个词,在倒排索引中,查哪些文档和这个词关联
// 因为 文档和词之间存在一定的相关性,所以返回值应和权重有关
public List<Weight> getInverted(String term) {
//TODO
return null;
}
// 3. 往索引中新增一个文档
public void addDoc(String title, String url, String content) {
// TODO
}
// 4. 把内存中的索引结构保存到磁盘中
public void save() {
// TODO
}
// 5. 把磁盘中的索引结构加载到内存
public void load() {
// TODO
}
}
实现索引结构
新增文档 -- 正排索引的构建
新增文档 -- 倒排索引的构建
倒排索引,词 --> 文档 id 之间的映射关系。
首先就需要先知道当前这个文档,里面有哪些词。
因此需要针对当前文档也进行分词:
- 针对标题。
- 针对正文。
然后就可以结合这个分词的结果,就知道当前这个文档 id 应该要加入到哪个倒排索引的 key 中了。
倒排索引是一个键值对结构(HashMap),key 分词结果(term),value 是一组和这个分词结果相关的文档 id 列表。
因此可以针对当前文档进行分词,然后根据每个分词结果,去倒排索引中,找到对应的 value,然后把当前文档 id 给加入到对应的 value 列表中即可。
如何来确定这个权重的值?
这个值描述了 词 和 文档 之间的“相关性”。
此处我们单纯的通过 词 出现的次数,来表示相关性。
代码的步骤如下:
// 1. 针对文档标题进行分词
// 2. 遍历分词结果,统计每个词出现的次数
// 3. 针对正文进行分词
// 4. 遍历分词结果,统计每个词出现的次数
// 5. 把上面的结果汇总到一个 HashMap 里面
// 最终文档的权重,就设定成 标题中出现的次数*10 + 正文中出现的次数
// 6. 遍历刚才的 HashMap,依次来更新倒排索引中的结构了
实现词频的统计
首先在方法 buildInverted 中,创建一个类,用来存储在标题和在正文出现的次数:
定义一个 HashMap,用来存放词和词频:
- 对文档标题进行统计
- 对文档正文进行统计
构造倒排索引逻辑梳理
比如现在有两个文档(已经分好词了):
- 乔布斯 发布 了 苹果 手机
- 乔布斯 买 了 四斤 苹果
则构建逻辑如下:
首先有个 HashMap 来存储索引结构:
一开始 HashMap 是为空的。
先对 1 号文档构建倒排索引:
第一个词为 乔布斯,发现 HashMap 中没有此 key,则把 乔布斯作为 key。value 是 Weight 类型的链表,而 Weight 有两个属性:docId 和 weight。
乔布斯在 1 号文档出现了一次,所以key-value如下:
1号文档后续的词也是同样操作:
接着对 2 号文档进行构建倒排:(画红框的都是2号文档新增的)
如果词在HashMap中已存在,则在此key的value部分,添加 Weight。
构造倒排索引代码实现
其中,invertedIndex 的结构为:
保存索引到磁盘中
如何将内存中的索引结构保存到磁盘呢?
这就需要 “序列化”了,将内存中的索引结构转为“字符串”,然后写进文件即可。
对应的,把特定结构的字符串,反向解析成一些结构化数据(类/对象/基础数据结构),称为反序列化。
我们使用 JSON 格式来进行序列化和反序列化。
首先引入 Jason 库:
<!-- https://mvnrepository.com/artifact/com.fasterxml.jackson.core/jackson-databind -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.14.1</version>
</dependency>
定义一个 ObjectMapper 用于序列化:
定义一个保存索引结构的路径:
编写代码:
加载索引结构到内存中
在保存的文件中,JSON 的格式大概如下:
把这个结构的字符串,转成啥样的对象呢?就需要指定一个具体对象的类型!
JackSon 专门提供了一个辅助的工具类:TypeReference<>
创建一个匿名内部类,这个类实现了 TypeReference,同时再创建一个这个匿名内部类的实例。
创建这个实例最主要的目的,就是为了把 ArrayList这个类型信息,告诉 readValue 方法。
关于 TypeReference<> 可查看此博客:Java泛型 | Jackson TypeReference获取泛型类型信息
代码实现:
Index 类总代码
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.ansj.domain.Term;
import org.ansj.splitWord.analysis.ToAnalysis;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class Index {
// 设置索引结构保存到磁盘的路径
private static final String INDEX_PATH = "E:\Coding\Java\java-code-exercise\A-MyProjects\";
private ObjectMapper objectMapper = new ObjectMapper();
// 使用数组下标表示 docId
private ArrayList<DocInfo> forwardIndex = new ArrayList<>();
// 使用哈希表来表示倒排索引
// key 为 词
// value 为一组和这个词关联的文档
private HashMap<String, ArrayList<Weight>> invertedIndex = new HashMap<>();
// 1. 给定一个 docId,在正排索引中,查询文档的详细信息
public DocInfo getDocInfo(int docId) {
return forwardIndex.get(docId);
}
// 2. 给定一个词,在倒排索引中,查哪些文档和这个词关联
// 因为 文档和词之间存在一定的相关性,所以返回值应和权重有关
public List<Weight> getInverted(String term) {
return invertedIndex.get(term);
}
// 3. 往索引中新增一个文档
public void addDoc(String title, String url, String content) {
// 新增文档操作,需要同时给正排索引和倒排索引新增信息
// 构建正排索引
DocInfo docInfo = buildForward(title, url, content);
// 构建倒排索引
buildInverted(docInfo);
}
private void buildInverted(DocInfo docInfo) {
class WordCount {
// 词在标题中出现的次数
public int titleCount;
// 词在正文中出现的次数
public int contentCount;
}
HashMap<String, WordCount> wordCntHashMap = new HashMap<>();
// 1. 针对文档标题进行分词
List<Term> terms = ToAnalysis.parse(docInfo.getTitle()).getTerms();
// 2. 遍历分词结果,统计每个词出现的次数
for (Term term : terms) {
// 判断 term 是否存在
String word = term.getName();
WordCount wordCount = wordCntHashMap.get(word);
if (wordCount == null) {
// 如果不存在,就创建一个新的键值对,插入进去,titleCount设为 1
WordCount wordCount1 = new WordCount();
wordCount1.titleCount = 1;
wordCount1.contentCount = 0;
wordCntHashMap.put(word, wordCount1);
} else {
wordCount.titleCount += 1;
}
}
// 3. 针对正文进行分词
terms = ToAnalysis.parse(docInfo.getContent()).getTerms();
// 4. 遍历分词结果,统计每个词出现的次数
for (Term term : terms) {
String word = term.getName();
WordCount wordCount = wordCntHashMap.get(word);
if (wordCount == null) {
// 如果不存在,就创建一个新的键值对,插入进去,contentCount设为 1
WordCount wordCount1 = new WordCount();
wordCount1.titleCount = 0;
wordCount1.contentCount = 1;
wordCntHashMap.put(word, wordCount1);
} else {
wordCount.contentCount += 1;
}
}
// 5. 把上面的结果汇总到一个 HashMap 里面
// 最终文档的权重,就设定成 标题中出现的次数*10 + 正文中出现的次数
// 6. 遍历刚才的 HashMap,依次来更新倒排索引中的结构了
for (Map.Entry<String, WordCount> entry : wordCntHashMap.entrySet()) {
// 先根据这里的词,去倒排索引中查一查
// 倒排拉链
List<Weight> invertedList = invertedIndex.get(entry.getKey());
if (invertedList == null) {
// 如果为空,就插入一个新的键值对
ArrayList<Weight> newInvertedList = new ArrayList<>();
// 把新的文档(当前的 DocInfo),构造成 Weight 对象,插入进来
Weight weight = new Weight();
weight.setDocId(docInfo.getDocId());
// 权重计算公式,标题中出现的次数*10 + 正文中出现的次数
weight.setWeight(entry.getValue().titleCount * 10 + entry.getValue().contentCount);
newInvertedList.add(weight);
invertedIndex.put(entry.getKey(),newInvertedList);
} else {
// 如果非空,就把当前这个文档,构造出一个 Weight 对象,插入到倒排拉链的后面
Weight weight = new Weight();
weight.setDocId(docInfo.getDocId());
weight.setWeight(entry.getValue().titleCount * 10 + entry.getValue().contentCount);
invertedList.add(weight);
}
}
}
private DocInfo buildForward(String title, String url, String content) {
DocInfo docInfo = new DocInfo();
docInfo.setDocId(forwardIndex.size());
docInfo.setTitle(title);
docInfo.setUrl(url);
docInfo.setContent(content);
forwardIndex.add(docInfo);
return docInfo;
}
// 4. 把内存中的索引结构保存到磁盘中
public void save() {
long start = System.currentTimeMillis();
System.out.println("开始保存索引!");
// 1. 判定索引对应的目录是否存在,不存在就创建
File indexPathFile = new File(INDEX_PATH);
if (!indexPathFile.exists()) {
indexPathFile.mkdirs();
}
File forwardIndexFile = new File(INDEX_PATH + "forward.txt");
File invertedIndexFile = new File(INDEX_PATH + "inverted.txt");
try {
objectMapper.writeValue(forwardIndexFile,forwardIndex); // 保存正排索引
objectMapper.writeValue(invertedIndexFile,invertedIndex); // 保存倒排索引
} catch (IOException e) {
e.printStackTrace();
}
long end = System.currentTimeMillis();
System.out.println("保存索引完成!耗时:" + (end-start) + " ms");
}
// 5. 把磁盘中的索引结构加载到内存
public void load() {
System.out.println("加载索引开始!");
// 1. 设置加载索引的路径
File forwardIndexFile = new File(INDEX_PATH + "forward.txt");
File invertedIndexFile = new File(INDEX_PATH + "inverted.txt");
try {
forwardIndex = objectMapper.readValue(forwardIndexFile, new TypeReference<ArrayList<DocInfo>>() {});
invertedIndex = objectMapper.readValue(invertedIndexFile, new TypeReference<HashMap<String, ArrayList<Weight>>>() {});
} catch (IOException e) {
e.printStackTrace();
}
System.out.println("加载索引结束!");
}
}
在Parser中调用Index
关于 Parser 类 和 Index 类之间的关系:
- Parser 相当于制作索引的入口 --> 对应到一个“可执行”程序。
- Index 相当于实现了索引的数据结构,提供了一些 api。
Index 类就要给 Parser 进行调用,才能完成整个制作索引的功能。
在 Parser 类中创建 Index 实例:
然后在 parserHTML 方法中使用 Index 类的添加文档的操作,将信息加入到索引:
在 run 方法中,使用 Index 类的 save 方法进行索引结构的保存:
Parser类 总代码
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileReader;
import java.io.IOException;
import java.util.ArrayList;
public class Parser {
// 指定一个加载文档的路径
private static final String INPUT_PATH = "E:\Coding\Java\java-code-exercise\A-MyProjects\docs\api";
private Index index = new Index();
public void run() {
// 整个 Parser 类的入口
// 1. 根据上面指定的路径,枚举出所有的文件(html),这个过程需要把所有子目录中的文件都能获取到
ArrayList<File> filelist = new ArrayList<>();
enumFile(INPUT_PATH, filelist);
// 2. 针对上面罗列出的文件路径,打开文件,读取文件内容,并进行解析,并构建索引
for (File f : filelist) {
System.out.println("开始解析:" + f.getAbsolutePath());
// 通过这个方法来解析单个 HTML 文件
parserHTML(f);
}
// 3.把在内存中构造好的索引数据结构,保存到指定的文件中。
index.save();
}
private void parserHTML(File f) {
// 1. 解析出 HTML 的标题
String title = parserTitle(f);
// 2. 解析出 HTML 对应的 URL
String url = parserURL(f);
// 3. 解析出 HTML 对应的正文(有了正文才有后续的描述)
String content = parserContent(f);
// 4. 把解析出来的这些信息,加入到索引当中
index.addDoc(title,url,content);
}
public String parserContent(File f) {
// 先按照一个字符一个字符的方式来读取,以 < 和 > 来控制拷贝数据的开关
try (FileReader fileReader = new FileReader(f)) {
// 加上一个是否要进行拷贝的开关
boolean isCopy = true;
// content 用来存储结果
StringBuilder content = new StringBuilder();
while (true) {
// 注意,此处的 read,返回值是一个 int,而不是 char !
// 此处使用 int 作为返回值,主要为了表示一些非法情况!
// 如果读到文件末尾,继续读,就会返回 -1
int ret = fileReader.read();
if (ret == -1) {
// 表示文件读完了
break;
}
// 如果 ret 不为 -1,则为一个合法的字符
char c = (char)ret;
// 开关打开的状态,遇到普通字符拷贝到 StringBuilder 中
if (isCopy) {
if (c == '<') {
// 关闭开关
isCopy = false;
continue;
}
if (c == '\n' || c == '\r') {
// 为了去掉换行,把换行替换成空格
c = ' ';
}
content.append(c);
} else {
if (c == '>') {
isCopy = true;
}
}
}
return content.toString();
} catch (IOException e) {
e.printStackTrace();
}
return "";
}
private String parserURL(File f) {
String part1 = "https://docs.oracle.com/javase/8/docs/api/";
String part2 = f.getAbsolutePath().substring(INPUT_PATH.length());
return part1 + part2;
}
private String parserTitle(File f) {
String name = f.getName();
return name.substring(0, name.length() - ".html".length());
}
private void enumFile(String inputPath, ArrayList<File> filelist) {
File rootPath = new File(inputPath);
// 使用 listFiles 只能看到一级目录,看不到子目录
File[] files = rootPath.listFiles();
for (File file : files) {
if (file.isDirectory()) {
enumFile(file.getAbsolutePath(), filelist);
} else {
if (file.getAbsolutePath().endsWith(".html")) {
filelist.add(file);
}
}
}
}
}
实现多线程制作索引
通过下面的代码,我们来进行各个部分的耗时测试:
测试结果:
可以发现:解析 HTML 文件是最耗时的。这也是我们要优化的点!
因此我们要引入多线程,让多个线程去执行 解析 HTML 的任务,减少耗时。
下面为代码:
但是呢,有个问题在图中的橙色框里:
index.save 操作一定在每个线程都解析完 HTML 操作后才会执行吗?
答案是不一定的。
submit 操作是往线程池中提交任务,这个操作本身极快(只是把 Runnable 对象给放到阻塞队列中)。因此很大可能存在:比如 1 w多次循环 submit 已经结束了,但是线程池里这 1 w多个文档还没有解析完,然后你就是执行 save 操作了。
所以,必须要等待所有线程把所有文档任务都处理完,才能进行 save 索引操作!
使用 CountDownLatch 协调线程同步
我们引入一个工具类:CountDownLatch
CountDownLatch 概念:
CountDownLatch是一个同步工具类,用来协调多个线程之间的同步,或者说起到线程之间的通信(而不是用作互斥的作用)。
CountDownLatch能够使一个线程在等待另外一些线程完成各自工作之后,再继续执行。使用一个计数器进行实现。计数器初始值为线程的数量。当每一个线程完成自己任务后,计数器的值就会减一。当计数器的值为0时,表示所有的线程都已经完成一些任务,然后在CountDownLatch上等待的线程就可以恢复执行接下来的任务。
下面为代码实现:
给制作索引代码加锁
我们要考虑同步安全问题。那什么时候会产生这种问题呢:那就是多个线程去修改了同一个对象!
我们来看看代码里,有哪些地方涉及多个线程去修改了同一个对象:
画个简图:
代码实现如下:
由于 方法buildInverted 和 bulidForward 操作的对象分别为:HashMap<String, ArrayList> invertedIndex 和 ArrayList forwardIndex,所以在 Index 类中创建两个锁对象来分别加锁:
然后加锁:
方法 buildInverted
方法bulidForward
验证多线程效果
测试代码:
运行结果对比:
解决进程不退出问题
我们发现引入多线程后,运行程序后,发现程序结束的时候进程并没退出:
这就是涉及到 守护线程 的概念了:
- 如果一个线程是守护线程(后台线程),此时这个线程的运行状态,不会印象到进程结束。
- 如果一个线程是非守护线程,此时这个线程的运行状态,就会影响到进程结束。
前面学过的线程创建手段:链接,默认创建出来的都是非守护线程,需要通过 setDaemon方法手动设置,才能成为守护线程。
这些通过线程池创建的线程,并不是守护线程。当 main 方法执行完,这些线程仍然在工作(等待新任务的到来)
因此,当线程的任务完成之后,我们需要手动干掉线程池里的线程:
再次运行程序:
优化文件读取速度
方法 parserContent 的核心操作就是读取文件,从磁盘进行访问!操作系统就会对“经常读取的文件”进行缓存!
首次运行的时候,当前的这些 Java 文档,都没有在内存中缓存,因此读取的时候只能直接从硬盘上读取(相对耗时)。
后面再运行的时候,由于前面已经读取过这些文档了,这些文档在操作系统中其实已经有一份缓存(在内存中的),这次的读取不必直接读硬盘,而是直接读取内存中的缓存(读取速度会快很多)
BufferedReader可以搭配 FileReader来使用。
BufferedReader 内部就会内置一个缓冲区,就能够自动的把 FileReader 中的一些内容预读到内存中,从而减少直接访问磁盘的次数。
二、搜索模块
搜索模块简要流程
搜索模块:调用索引模块,来完成搜索的核心过程。
- 分词:针对用户输入的 查询词 进行分词(用户输入的查询词,可能不是一个词,可能是一句话)
- 触发:拿着每个分词结果,去倒排索引中查,找到具有相关性的文档(调用 Index 类里面的 查倒排 的方法)
- 排序:针对上面触发的结果,进行排序(按照相关性,降序排序)
- 包装结果:根据排序后的结果,依次去查正排,获取到每个文档的详细信息,包装成一定结构的数据返回出去。
搜索模块入口:创建DocSearcher类
需要创建一个新类 Result,用来存储搜索结果的信息:
package Searcher;
public class Result {
private String title;
private String url;
// 描述为正文的一部分摘要
private String desc;
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getUrl() {
return url;
}
public void setUrl(String url) {
this.url = url;
}
public String getDesc() {
return desc;
}
public void setDesc(String desc) {
this.desc = desc;
}
}
整个搜索模块的入口:
package Searcher;
import Index.Index;
import java.util.List;
public class DocSearcher {
private Index index = new Index();
public DocSearcher() {
index.load();
}
// 完成整个搜索过程的方法
// 参数(输入部分)用户给出的查询词
// 返回值(输出部分)搜索结果的集合
public List<Result> search(String query) {
// 1.【分词】针对 query 查询词进行分词
// 2.【触发】针对分词结果来查倒排
// 3.【排序】针对触发结果按照权重降序排序
// 4.【包装结果】针对排序的结果,查正排,构造出要返回的数据
return null;
}
}
实现search方法
// 完成整个搜索过程的方法
// 参数(输入部分)用户给出的查询词
// 返回值(输出部分)搜索结果的集合
public List<Result> search(String query) {
// 1.【分词】针对 query 查询词进行分词
List<Term> terms = ToAnalysis.parse(query).getTerms();
// 2.【触发】针对分词结果来查倒排
List<Weight> allTermResult = new ArrayList<>();
for (Term term : terms) {
String word = term.getName();
List<Weight> invertedList = index.getInverted(word);
if (invertedList == null) {
// 说明这个查询词在所有文档中都不存在
continue;
}
allTermResult.addAll(invertedList);
}
// 3.【排序】针对触发结果按照权重降序排序
allTermResult.sort(new Comparator<Weight>() {
@Override
public int compare(Weight o1, Weight o2) {
return o2.getWeight() - o1.getWeight();
}
});
// 4.【包装结果】针对排序的结果,查正排,构造出要返回的数据
List<Result> results = new ArrayList<>();
for (Weight weight:allTermResult) {
DocInfo docInfo = index.getDocInfo(weight.getDocId());
Result result = new Result();
result.setTitle(docInfo.getTitle());
result.setUrl(docInfo.getUrl());
result.setDesc(GenDesc(docInfo.getContent(),terms));
}
return results;
}
方法 GenDesc 用于找到在正文中和查询词相关的一段语句:
private String GenDesc(String content, List<Term> terms) {
// 先遍历分词结果,看看哪个结果在 content 中存在
int firstPos = -1;
for (Term term:terms) {
// 分词库直接针对词进行转小写了
// 正因为如此,就必须把正文内容也先转为小写,然后再查询
String word = term.getName();
// 此处需要的是 “全字匹配” ,让 word 能够独立成词,才要查找出来,而不是只作为词的一部分
// 此处的 “全字匹配” 的实现不算特别严谨,更严谨的做法,可以使用正则表达式
firstPos = content.toLowerCase().indexOf(" " + word + " ");
if (firstPos >= 0) {
// 找到了位置
break;
}
}
if (firstPos == -1) {
// 所有的分词结果都不在正文中
// 属于比较极端的情况
// 可以直接读取正文的前160字符作为描述
if (content.length() > 160) {
return content.substring(0,160) + "...";
}
return content;
}
// 从 firstPos 作为基准,往前找 60 个字符,作为描述的起始位置
String desc = "";
int descBeg = firstPos > 60 ? firstPos-60:0;
if (descBeg+160 > content.length()) {
desc = content.substring(descBeg);
} else {
desc = content.substring(descBeg,descBeg+160) + "...";
}
return desc;
}
验证 search 方法:
结果:
但是发现一个问题:在有些搜索结果里,desc 里的内容为 HTML 文件里
这个内容就是 JavaScript 的代码。我们在处理文档的时候,只是对正文,进行了“去标签”,有的 HTML 里面包含了 script 标签,就导致去了标签之后,JS 的代码也被整理到索引里了。
下面通过正则表达式来解决这个问题。
使用正则表达式去除
简单介绍下要使用的正则表达式:
. 表示匹配一个非换行字符(不是\n 或者不是 \r)
* 表示前面的字符可以出现若干次。
.* 匹配非换行字符出现若干次。
去掉 script 标签和内容: <script.*?>(.*?)</script>
去掉 普通的标签(不去掉内容): <.*?> ,既能匹配到开始标签,也能匹配到结束标签。
? 表示 “非贪婪匹配”,匹配到一个符合条件的最短结果。
不带 ? 的 .*表示“贪婪匹配”,匹配到一个符合条件的最长结果。
使用在线正则表达式网站进行测试:
使用 <.*> :
<.*> 把整个正文都匹配到了,进行替换,也就自然把整个正文内容给替换没了
进行替换:
使用 <.*?> :
<.*?> 此时的匹配就是会分别匹配到四个标签,如果替换,只是替换标签,不替换内容。
使用替换:
在 Parser 类中添加方法 parserContentByRegex:
// 此方法基于正则表达式,实现去标签,就是去除 script
private String parserContentByRegex(File f) {
// 1. 把整个文件都读到 String 里面
String content = readFile(f);
// 2. 替换掉 script 标签
content = content.replaceAll("<script.*?>(.*?)</script>"," ");
// 3. 替换掉普通的 html 标签
content = content.replaceAll("<.*?>"," ");
// 4. 使用正则把多个空格合成一个
content = content.replaceAll("\s+"," ");
return content;
}
private String readFile(File f) {
try (BufferedReader bufferedReader = new BufferedReader(new FileReader(f))) {
StringBuilder content = new StringBuilder();
while (true) {
int ret = bufferedReader.read();
if (ret == -1) {
break;
}
char c = (char) ret;
if (c == '\n' || c == '\r') {
c = ' ';
}
content.append(c);
}
return content.toString();
} catch (IOException e) {
e.printStackTrace();
}
return "";
}
进行测试:
结果:
别忘了,在 Parser 类中,修改下 parserHTML 方法:
三、web 模块
基于Servlet的方式实现后端
约定前后端交互形式:
创建新类:DocSearcherServlet
@WebServlet("/searcher")
public class DocSearcherServlet extends HttpServlet {
private static DocSearcher docSearcher = new DocSearcher();
private ObjectMapper objectMapper = new ObjectMapper();
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
String query = req.getParameter("query");
if (query == null || query.equals("")) {
String msg = "参数缺失,未获取到 query 参数";
System.out.println(msg);
resp.sendError(404,msg);
return;
}
System.out.println("查询词为:" + query);
List<Result> results = docSearcher.search(query);
resp.setContentType("application/json; charset=utf-8");
objectMapper.writeValue(resp.getWriter(),results);
}
}
实现页面结构
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Java 文档搜索</title>
<link rel="stylesheet" type="text/css" href="common.css">
</head>
<body>
<div class="container">
<!-- 搜索界面 -->
<div class="header">
<input type="text">
<button type="submit" id="search_btn">搜索</button>
</div>
<!-- 搜索结果 -->
<div class="result">
<div class="item">
<a href="#">标题</a>
<div class="desc">描述</div>
<div class="url">www.baidu.com</div>
</div>
</div>
</div>
</body>
</html>
/*去除浏览器默认样式*/
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
/*给整体页面指定一个高度*/
html, body {
height: 100%;
/*设置背景图*/
background-image: url(img/m.jpg);
/*设置背景图不平铺*/
background-repeat: no-repeat;
/*设置图片位置*/
background-position: center center;
/*设置背景图大小*/
background-size: cover;
}
/*设置版心样式*/
.container {
width: 70%;
height: 100%;
margin: 0 auto;
/*设置背景色,让版心和背景图能够区分开*/
background-color: rgba(255, 255, 255, 0.8);
/*设置圆角*/
border-radius: 20px;
/*设置内边距,避免文字内容紧贴边界*/
padding: 20px;
/*加上这个属性,超出元素的部分会自动生成一个滚动条*/
overflow: auto;
}
.header {
width: 100%;
height: 50px;
display: flex;
justify-content: space-between;
align-items: center;
}
/*设置输入框样式*/
.header>input {
width: 77%;
height: 50px;
border-radius: 10px;
font-size: 22px;
line-height: 50px;
padding-left: 10px;
}
.header>button {
width: 20%;
height: 50px;
border-radius: 10px;
background-color: rgba(34,139,34, 0.9);
color: #fff;
font-size: 22px;
line-height: 50px;
border: none;
}
.header>button:active {
background: gray;
}
.item {
width: 100%;
margin-top: 20px;
}
.item a {
display: block;
height: 40px;
font-size: 22px;
line-height: 40px;
font-weight: 700;
color: #4007A2;
}
.item .desc {
font-size: 14px;
}
.item .url {
font-size: 10px;
color: rgb(0, 128, 0);
}
效果:
通过 Ajax 获取搜索结果
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Java 文档搜索</title>
<link rel="stylesheet" type="text/css" href="common.css">
<script src="http://code.jquery.com/jquery-2.1.1.min.js"></script>
</head>
<body>
<div class="container">
<!-- 搜索界面 -->
<div class="header">
<input type="text">
<button type="submit" id="search_btn">搜索</button>
</div>
<!-- 搜索结果 -->
<div class="result">
<!-- <div class="item">
<a href="#">标题</a>
<div class="desc">描述</div>
<div class="url">www.baidu.com</div>
</div> -->
</div>
</div>
<script>
let button = document.querySelector("#search_btn");
button.onclick = function() {
let input = document.querySelector(".header input");
let query = input.value;
jQuery.ajax({
type: "GET",
url: "searcher?query=" + query,
success: function(data,status) {
buildResult(data);
}
});
}
function buildResult(data) {
let result = document.querySelector(".result");
// 清空上次的结果
result.innerHTML = "";
for (let item of data) {
let itemDiv = document.createElement("div");
itemDiv.className = "item";
let title = document.createElement("a");
title.innerHTML = item.title;
title.href = item.url;
/*页面跳转,让原页面不会消失*/
title.target = '_blank';
itemDiv.appendChild(title);
let desc = document.createElement("div");
desc.className = "desc";
desc.innerHTML = item.desc;
itemDiv.appendChild(desc);
let url = document.createElement("div");
url.className = "url";
url.innerHTML = item.url;
itemDiv.appendChild(url);
result.appendChild(itemDiv);
}
}
</script>
</body>
</html>
注意:在代码第 47 行使用了 result.innerHTML = "";是为了防止将每次的搜索结果都存放在一个result中,然后一起展现出来的问题。
结果展示:
实现标红逻辑
可以发现在搜索结果的描述里,没有把查询词进行标红(对比必应搜索)
实现标红逻辑,需要前后端配合:
- 修改后端代码,生成搜索结果的时候(生成描述),就需要把其中包含查询词的部分,给加上一个标记。例如,给这个部分加上 标签。
- 然后给前端这里针对 标签设置样式,然后浏览器就可以根据 标签的样式来进行显示了,比如给 标签的字体颜色设为红色。
在 DocSearcher 类中,生成描述的 GenDesc 方法加一些东西:
在 css 文件中针对这个标签进行字体颜色的设置:
查看效果:
可以看到,查询词被标红了,并且带有斜体( 标签的作用)。
处理更复杂的查询词
当我们在搜索框中输入:array list的时候发现会搜索到一些不相关的文档:
这是因为,当我们的查询词为 array list的时候,此时的分词结果,不仅仅是 array 和 list,而是 array,空格,list。
因此代码中就会拿 空格 来查询索引。
此时会涉及到一个概念:暂停词(停用词):没啥意义的高频词,如 a,is,have 等。这些词是不应该参与触发的。
从网上下载英文停用词保存在本地:
首先在 DocSearcher 类中,添加停用词文件路径,并创建一个HashSet用来存放停用词:
创建方法 loadStopWord 用来将文件中的停用词加载到内存中:
记得在 DocSearcher 的构造函数中调用此方法:
最后在方法 searcher 中,调用此方法,将停用词排除在分词结果中:
测试:
发现此结果的描述中并没有出现查询词。为什么呢?我们接下来进行分析
处理生成描述的bug
我们进去有问题的搜索结果,查看他的源代码:
发现一个问题:这个地方是 array)。
回顾下我们写的后端代码:DocSearcher 类的GenDesc 方法中:
由于是 array)不符合 array ,所以就没找到,直接以文档内容的前160个字符作为返回,没有包含查询词。因此我们要修改这个地方。
针对这个问题,我们使用正则表达式来解决:
使用在线正则表达式来测试下:在查询词前后都加上\b就是全字匹配
但是在后端代码中,indexOf方法不支持正则表达式:
Java 中使用正则,一方面可以基于 String 中的一些特定方法,另一方面标准库里提供了 Pattern 和 Matcher 这两个类(Pattern 描述了一个匹配规则,Matcher 负责进行具体的匹配操作),由于这两个类使用复杂,因此不做考虑。
换一种思路:把两边不带空格(带标点)的情况转换成两边带空格的情况。使用方法 replace / replaceAll(这两个方法支持正则)
这样就可以支持 indexOf(" " + word + " ")的查找了。
测试:
可以看到被标记呈现出来了。
加上搜索结果的个数
在 html 中,构造 Result 的方法中,加入如下功能:
测试:
重复文档的问题
我们进行下测试:
发现 list 的搜索结果个数 + array 搜索结果的个数 = array list 的搜索结果个数。
也就是同一个页面在 array list 的搜索结果里被多次展示了:
前面我们计算权重的时候,先对分词结果依次进行触发。
- 一个文档不应该被展示两次
- 像 Collections 这样的文档,同时包含多个分词结果,其实意味着这个文档的”相关性“更高!
像这种情况,就应该提高这个文档的权重!既然相关性更高,提高权重之后就能排的更靠前了!
一个简单粗暴的方法,就是把权重进行相加
要实现这样的效果,就需要把触发结果进行合并!!
就需要把多个分词结果触发出的文档,按照 docId 进行去重,同时进行权重的合并。
去重的核心思路:
链表可以合并两个有序链表,利用类似的思路来合并两个数组。
先把分词结果进行一个排序处理(按照 docId 升序排序),再进行合并。合并的时候就可以根据 docId 值相同的情况,进行权重相加。
注意:当前的分词结果,不一定就只是分成两个部分,很可能是多个部分。此时就变成了 N 个数组合并了。
针对每个分词结果构建数组
在 DocSearcher 类中的 search 方法进行修改:
这块代码就是在构建如图所示的结构:
每一行是每个分词结果触发出的结果,每一列就是一个一个的 Weight 对象。
权重合并
【合并】针对多个分词结果触发出的相同文档,进行权重合并:权重的合并通过一个 mergeResult 自定义方法来进行:
此处的操作是针对每一行进行排序:
使用优先队列进行排序
接着,借助 优先队列 找出每一行最小的 docId 的 Weight 对象,把这个最小的对象给插入到结果 target 中,同时把对应行的下标往后移动。
最后整个函数返回 target:
mergeResult 方法总代码:
private List<Weight> mergeResult(List<List<Weight>> source) {
// 在进行合并的时候,把多个行合并成一行
// 合并过程中势必需要操作这个二维数组(二维 List)里面的每个元素
// 操作元素涉及到“行”“列”这样的概念
// 1. 先针对每一行进行排序,按照 id 进行升序排序
for (List<Weight> curRow : source) {
curRow.sort(new Comparator<Weight>() {
@Override
public int compare(Weight o1, Weight o2) {
return o1.getDocId() - o2.getDocId();
}
});
}
// 2. 借助优先队列,针对这些行进行合并
// target 表示合并的结果
List<Weight> target = new ArrayList<>();
// 2.1 创建一个优先级队列,并指定比较规则(按照 Weight 的 docId,取小的更优先)
PriorityQueue<Pos> queue = new PriorityQueue<>(new Comparator<Pos>() {
@Override
public int compare(Pos o1, Pos o2) {
// 先根据 pos 找到对应的 Weight 对象,再根据 Weight 的 docId 来排序
Weight w1 = source.get(o1.row).get(o1.col);
Weight w2 = source.get(o2.row).get(o2.col);
return w1.getDocId() - w2.getDocId();
}
});
// 2.2 初始化队列,把每一行的第一个元素放到队列中
for (int row = 0; row < source.size(); row++) {
// 初始插入的元素的 col 就是 0
queue.offer(new Pos(row, 0));
}
// 2.3 循环的队列取队首元素(也就是当前这若干行中最小的元素)
while (!queue.isEmpty()) {
Pos minPos = queue.poll();
Weight curWeight = source.get(minPos.row).get(minPos.col);
// 2.4 看看这个取到的 Weight 是否和前一个插入到 target 中的结果是相同的 docId
// 如果相同,就合并
if (target.size() > 0) {
// 取出上次插入的元素
Weight lastWeight = target.get(target.size() - 1);
if (lastWeight.getDocId() == curWeight.getDocId()) {
// 说明遇到了相同的文档
// 合并权重
lastWeight.setWeight(lastWeight.getWeight() + curWeight.getWeight());
} else {
// 如果 docId 不同,就直接把 curWeight 插入到 target 的末尾
target.add(curWeight);
}
} else {
// 如果 target 当前是空的,就直接插入即可
target.add(curWeight);
}
// 2.5 把当前元素处理完之后,要把对应这个元素的光标往后移动
// 去取这个一行的下一个元素
Pos newPos = new Pos(minPos.row, minPos.col + 1);
if (newPos.col >= source.get(newPos.row).size()) {
// 如果移动光标之后,超出这一行的列数,就说明达到末尾了
// 到达末尾之后说明这一行就处理完毕了
continue;
}
queue.offer(newPos);
}
return target;
}