介绍
记住两个词时,搜索应该缩小到同时包含它们的笔记。两个词可以分处不同行,而且同名的两篇笔记仍须保持独立。
这个独立实验提供正确的词到文档索引。你将实现查询交集,重建真实的 CLI 可执行程序,区分正常空结果与非法输入,并在修改练习笔记后重新搜索。它承接上一个实验的 map、set、ASCII 词元以及终端和文件操作。55 分钟是作者估计的时长。
只保留每个词都共有的文档
本步骤实现文档级 AND:笔记只有包含每一个查询词才匹配,即使这些词分处不同行也如此。本实验独立初始化,已经提供正确索引,不依赖上一个环境。使用 Terminal 标签页,而非 WebIDE 内部终端。它会打开 UTF-8 shell,不改变你的文件。cd 改变当前 shell 的目录;如果打开另一连接,请重新执行它。
cd /home/labex/project/note-search
完整样例接收目录和查询。单引号让两个词保持为一个 shell 参数。程序用与笔记相同的 ASCII 规则解析这个参数:
./sample notes 'cat blue'
应出现 practice/旅行 notes.txt 和 reference/archive/notes.txt。归档笔记的文档组包含:
File: reference/archive/notes.txt
1: CAT, cat!
2: Blue ledger — 東京
3: cat blue together
凡包含任一查询词的行都会出现一次,并带有从 1 开始的真实行号。AND 判断作用于整个文档,而非单独一行。即使某篇笔记只在第 1 行含有 cat,只在第 2 行含有 blue,它仍然必须匹配。同一行里的多次出现或两个查询词不会导致重复显示该行。片段中保留原始 Unicode 字节。
cat 打印文件。阅读接口和准备好的索引实现:
cat search.hpp
cat provided.inc
Index 是将词映射到完整相对文档标识集合的 map。一个词对应的文档集合也称为它的倒排集合。本单元的 provided.inc 提供正确的 buildIndex。engine.cpp 中包含尚未完成的 findAll:
cat engine.cpp
保留 search.hpp、provided.inc、support.cpp、main.cpp、sample,以及 notes/reference/ 下的全部路径和内容。编辑 engine.cpp;允许辅助函数和等价算法。只有 notes/practice/ 是可变数据,不要求维持其原有文件成员集合。准备好的加载器递归读取普通的 UTF-8 LF .txt 文件。支持空格和 Unicode 路径;路径中的制表符或换行符、符号链接、NUL 字节和任意二进制文件不属于输入格式。读取失败会产生错误,不会返回部分搜索结果。
准备好的解析器 words() 产生最长连续 ASCII 字母片段,并将 A-Z 转为 a-z。数字、下划线、标点、空格和每个非 ASCII 字节都分隔词元。cat 不是 catalog;CAT-blue_42 产生 cat 和 blue;猫cat狗 产生 cat;café 产生 caf,而不是 cafe。这不声称支持多语言分词。findAll 接收该解析器已经规范化的小写 ASCII 查询词,可能有重复。对于空词列表,它必须安全返回空 vector。CLI 会单独拒绝不含任何 ASCII 词的原始查询,退出状态为 2,stdout 不输出结果;未知词或没有匹配的合法查询则正常返回 0,并显示 No matching documents.
bash 运行自检;query 选择完整查询行为。起始代码会有意失败,通常输出 FAIL all documents, not only the first。现在的非零状态表示还有工作未完成:
bash /opt/labex-note-search/check.sh query
对于 AND 查询,先复制第一个词的倒排集合。随后逐一检查其余查询词的倒排集合:候选标识只要不属于其中一个集合,就删除它。这就是集合交集。如果某个查询词未知,就没有文档能包含全部词。不要把所有倒排集合的元素直接加到一起:那是 OR,会接受只包含其中一个所需词的笔记。
在 terms.front() 访问首元素之前,必须先检查 terms.empty()。index.find(word) 查找 map 键而不插入新键;index.end() 表示不存在该键。不要解引用不存在的条目。对找到的条目,first->second 或 posting->second 使用 -> 通过迭代器访问条目;second 是条目的值,即标识集合。把它复制到 found,使查询不会改变索引。
循环使用迭代器,也就是集合中的一个位置。found.begin() 是首个位置;found.end() 标记最后一个元素之后的位置。*it 是当前标识。当该标识不在另一个倒排集合中时,.count(*it) 为 0。删除会使旧位置失效,因此 erase(it) 返回下一个有效迭代器。否则,用 ++it 正常前进。删除之后不要再次额外递增。
用以下实现替换 engine.cpp。与上一个实验一样,here-document 写出完整文件;> 替换该文件,带引号的 'CPP' 分隔符防止 shell 展开。CPP 必须单独占一行结束命令。include 指令保留接口和已准备好的正确索引。
cat > engine.cpp <<'CPP'
#include "search.hpp"
#include "provided.inc"
std::vector<std::string> findAll(const Index& index,
const std::vector<std::string>& terms) {
if (terms.empty()) return {};
auto first = index.find(terms.front());
if (first == index.end()) return {};
std::set<std::string> found = first->second;
for (const auto& term : terms) {
auto posting = index.find(term);
if (posting == index.end()) return {};
for (auto it = found.begin(); it != found.end();) {
if (posting->second.count(*it) == 0) it = found.erase(it);
else ++it;
}
}
return std::vector<std::string>(found.begin(), found.end());
}
CPP
最后的构造函数把集合中剩余的区间复制进 vector。set 的标识唯一,因此每个文档只返回一次。在循环中再次检查第一个词没有影响:集合与自身求交不会改变它。重复查询词同理。倒排集合交集为空时自然返回空 vector。允许其他可重复的文档组顺序,包括路径降序;但每组内部的行号仍须递增。
编译当前源码。g++ 编译三个 .cpp 文件;-std=c++17 选择 C++17,-Wall -Wextra 启用警告,-o 指定可执行文件名。include 的文件会通过 engine.cpp 自动参与编译:
g++ -std=c++17 -Wall -Wextra engine.cpp support.cpp main.cpp -o findnotes
成功时通常没有输出。如果失败,阅读第一条文件与行号诊断,修正 engine.cpp 或重新替换它,然后重建。编译失败可能留下旧二进制文件;检查既测试源码函数,也测试实际现有的 findnotes,不会替你重建它。缺失或过期的可执行文件不算完成交付。
./findnotes notes 'cat blue'
把完整文档组和行证据与样例比较。准备好的渲染器用你返回的标识选择真实原始行;修改原始笔记或打印固定成功文本不等于实现搜索。
区分空结果与非法查询
本步骤练习重复词、词元边界、错误和当前文件重建。留在 Terminal 的项目目录。完成后的函数不得修改 const Index& 输入,也不能在词重复时产生重复文档:
./findnotes notes 'cat cat blue'
文档组和行与 cat blue 相同。现在加入一个未知词:
./findnotes notes 'cat absent'
应显示 No matching documents.。仅包含 cat 的文档还不够。这是没有答案的合法查询,因此退出状态为 0。命令刚结束时,shell 变量 $? 保存它的退出状态;双引号将其展开为一个参数。printf 显示这个值,\n 结束输出行:
printf 'Exit status: %s\n' "$?"
它打印 Exit status: 0。测试大小写折叠和分隔符:
./findnotes notes 'CAT-blue_42'
其含义与 cat blue 相同。数字和下划线都不会成为词元的一部分。完整词查询不采用子串匹配:
./findnotes notes catalog
只有 reference/catalog.txt 和包含 catalog 的练习笔记匹配。它不表示 cat。
空字符串写成两个相邻单引号 ''。这是一个已传入、但内容为空的 shell 参数。下一条命令会有意失败,在 stderr 打印 Query needs at least one ASCII word,stdout 不输出结果:
./findnotes notes ''
立即读取它的状态:
printf 'Exit status: %s\n' "$?"
它打印 2。仅含数字、分隔符和非 ASCII 字符的查询也因相同原因非法:
./findnotes notes '42_東京'
这同样会有意以状态 2 失败,并不表示 Unicode 存储损坏:查询只是没有受支持的 ASCII 词。请提供含词的查询来恢复,不要编辑原文或改变分词器。
目录拼错属于另一种错误。下一条命令有意指定一个不存在的目录:
./findnotes missing-notes cat
它打印 Cannot read notes directory,以 2 退出且不显示部分结果。如需查看实际路径,可用 find notes -type f,再用正确的根目录恢复:
./findnotes notes cat
正常结果重新出现。如果任意原始文件无法读取,同样适用要么全部读取成功、要么报错的规则。相反,一个为空但可读取的笔记目录是合法的,只是没有匹配。
现在修改准备好的可写练习副本。单引号保留含空格和 Unicode 的文件名。printf 写出文本,\n 产生 LF 换行,> 替换该练习文件。reference 下的原件保持不变:
printf 'cat green\n東京 — preserved\n' > 'notes/practice/旅行 notes.txt'
./findnotes notes 'cat blue'
现在只剩归档文档:练习笔记不再包含 blue。每次调用都会重读当前文件,建立新的内存索引。没有保存的缓存或额外刷新命令。数据改变需要重新运行;C++ 改变需要重新编译。
新增一篇查询词分处不同行的笔记:
printf 'BLUE here\ncat elsewhere 東京\n' > 'notes/practice/new notes.txt'
./findnotes notes 'cat blue'
practice/new notes.txt 现在匹配,并与归档结果一起显示第 1、2 行。这直接证明了跨行的文档级 AND。rm 只删除指定的练习文件:
rm 'notes/practice/new notes.txt'
./findnotes notes 'cat blue'
额外文档组消失了。如果路径拼错,请列出文件并使用准确路径,不要重置 VM。如果操作目标是受保护的原件,请保持它不变并使用练习文件。所有合法的练习改动仍然被评分接受。
运行自检。它读取现场项目,并在一次性测试数据中测试其他输入;不会修复二进制文件、编辑现场笔记或重置进度。非法输入探测可能在 PASS 行旁打印预期错误;成功时以 CLI CHECKS PASSED 结束:
bash /opt/labex-note-search/check.sh query
如果源码测试通过、实际可执行程序却失败,请从当前源码重新编译 findnotes 再试。重复检查应使引擎、可执行文件、准备好的文件、原件和当前练习内容都保持不变。
总结
你把 AND 实现为文档标识的交集,包括词分处不同行的情况。重复词不影响结果,未知词产生正常空结果,非法查询或不可读输入会明确失败。现有可执行程序现在读取当前练习数据,并将每个相关原始行显示一次。挑战会把此查询行为与索引构建结合起来,用于另一套笔记集合。



