“李杜文章在,光焰万丈长”,唐诗无疑是中国古代文学最灿烂的篇章之一。现代人发表论文,会互相引用,喝酒吃饭,也经常会谈及谁谁谁是我哥们。作为当时最重要的文学形式,唐代的诗人也经常会在诗文中提及自己的好朋友。杜甫比李白小十一岁,二者相识于杜甫父亲杜闲家中,彼时正是李白因触怒权贵放归山林之时。两人一见,杜秒变小迷弟。杜在《与李十二白同寻范十隐居》中描绘了两人的亲密关系:”余亦东蒙客,怜君如兄弟。醉眠秋共被,携手日同行”。不仅如此,在两人各奔东西后,杜甫压抑不住对李白的思念,写了多首提及李白的诗。例如《梦李白》中云:”三夜频梦君,情亲见君意”。能连续三个晚上做梦都梦到李白,可见交情不浅。
通过分析全唐诗中各位诗人之间的“引用”关系,可以描绘出当时诗坛的大致朋友圈图景:谁跟谁熟?谁是圈子里的带头大哥?全唐诗有4万多首,人工一首一首地筛查费时费力,这种重复的统计性质的工作正是计算机最擅长的。
本文引用自作者编写的下述图书; 本文允许以个人学习、教学等目的引用、讲授或转载,但需要注明原作者"海洋饼干叔 叔";本文不允许以纸质及电子出版为目的进行抄摘或改编。
1.《Python编程基础及应用》,陈波,刘慧君,高等教育出版社。免费授课视频 Python编程基础及应用
2.《Python编程基础及应用实验教程》, 陈波,熊心志,张全和,刘慧君,赵恒军,高等教育出版社Python编程基础及应用实验教程
3. 《简明C及C++语言教程》,陈波,待出版书稿。免费授课视频
23. 数据分析 - 唐代诗人的朋友圈
同类相比,同声相应,固天理也。——庄子
本章的代码和数据整合在一个名为C23_PoetsNetwork的文件夹中。注意,本章节所依赖的全唐诗文本以及《中国历代人物传记资料库》使用了繁体中文,所以读者在运行代码查询时,如果使用简体中文输入诗人姓名,结果将与预期不符。
本章内容受开源项目poetry_analyzer的启发。为方便读者理解,作者整理了相关数据并重写了代码。
本章的代码实现依赖于Qt平台的专有特性,只能通过Qt Creator集成开发环境来编写和构建。
23.1 创建程序框架
请读者按照22.1及22.3节所介绍的方法,在Qt Creator中创建一个名为PoetsNetwork的项目,该项目的主窗口如图23-1所示,作者在该图上人工标注了各关键部件的名称。表23-1列出了该项目创建过程中的一些注意事项,以及其主窗口中部分部件的用途。
表23-1 创建PoetsNetwork项目的注意事项及主窗口的主要部件
名目 | 说明 |
---|---|
项目模板 | Application(Qt)/Qt Widgets Application (Qt窗体应用程序)。 |
主窗口 | 类名:MainWidget;基类:QWidget。 |
pbParseQTS | 类型:QPushButton;文字:全唐诗数据整理;用途:将全唐诗文本数据整理并写入到数据库。 |
pbConstructNetwork | 类型:QPushButton;文字:构建关系网络;用途:对数据库中的全唐诗文本正行分析,找出诗人与诗人之间的引用关系并写入数据库。 |
leRefPoet1,leRefPoet2 | 类型:QLineEdit,单行输入框;用途:“引用查询”时输入第一/二个诗人的姓名。 |
pbQueryReference | 类型:QPushButton;文字:引用查询;用途:查询两个诗人之间的引用数量以及相互之间的引用诗文。 |
textBrowser | 类型:QTextBrowser,简单的文字型HTML浏览器;用途:显示程序的部分执行结果。 |
lePoetFriendCycle | 类型:QLineEdit;用途:查询“朋友圈”时输入诗人的姓名。 |
pbFriendCycle | 类型:QPushButton;用途:查询单个诗人的朋友圈,即找出与指定诗人存在引用关系的全部诗人,生成关系网络图,并用浏览器显示。 |
pbNetWork50/100/200/500 | 类型:QPushButton;用途:导出引用数量的前50/100/200/500行引用关系,将由相关诗人姓名及引用箭头组成的关系网络生成出来,并用浏览器显示。 |
图23-2则展示MainWidget主窗口的对象结构以及窗口组件之间的布局关系。如图所示,主窗口自身呈现竖向布局,其内包含4个横向布局以及textBrowser。为了构造出期望的界面效果,读者可能需要:(1) 调整布局的layoutSpacing(布局间隔);(2) 修改按钮、单行输入框的minimumSize(最小尺寸)。此外,在图23-2中,我们还看到了类型为Spacer的部件,这种类型的部件仅用于占据布局空间,其在最终的结果页面上不会有任何显示,它可以把别的部件“挤”到期望的位置。
23.2 数据整理与准备
23.2.1 sqlite数据库
为了便于统计分析以及向读者简单介绍数据库的入门知识和C++访问数据库的方法,本章使用sqlite数据库来存储相关数据。
对于结构化的数据,如个人的身份信息、银行的交易流水、图书馆的借还记录等,通常都存储在数据库系统中。数据库系统通常运行在一个服务器或者由多个服务器构成的集群中,软件使用者的计算机或者终端直接或者间接地透过TCP/IP访问数据库、查询或存储数据。大型的数据库系统软件有阿里蚂蚁金服的OceanBase、华为的GaussDB、开源的MySql以及私有的Oracle。
本章使用的sqlite是一个超级mini版的数据库系统,它本质上是一个运行于软件内部的C语言包。在本章的代码中,数据库的存储文件为C22_PoetsNetwork/data子目录下的data.db。
📍 注意 | 本章只能概要地介绍数据库系统的相关知识。对数据库系统的全面讨论是数据库系统课程的任务,读者如果对本实践所应用到的数据库技术感到疑惑,请查阅数据库系统课程的教材。 |
---|
📕 操作指南 SQLiteStudio的下载与安装 | |
---|---|
SQLiteStudio的下载与安装 |
为了便于查询数据库中的数据,请读者按照二维码链接所提供的方法,下载并安装一个名为SQLiteStudio的软件,SQLiteStudio是遵从GPL协议的开源软件,它可以帮助我们创建、编辑和查询sqlite数据库。作者安装时,其版本号为3.3.3。
如图23-3所示,运行SQLiteStudio,选择Database/Add a database(数据库/添加一个数据库)菜单项,将得到如图23-4所示的对话框。在该对话框中,将数据类型选择为SQLite 3,点击浏览按钮(
)定位到项目目录中已存在的data.db文件,此时,name(名称)被自动调整为data,点击“测试连接”,在测试通过后点击“OK”按钮即可打开本实践的数据库文件(data.db)。
数据库文件打开后,名为data数据库将显示在软件的左侧列表中,如图23-5所示。双击data数据库将其逐步展开,可以看到该数据库有4个表格,名称分别是altname(别名)、peom(诗)、poet(诗人)和reference(引用)。
选择peom表,可以看到表格中的数据,共有42948行,每一行存储了一首唐诗。这些数据来自于“全唐诗”,可以看到,排在最前面的是唐太宗李世民的诗。
数据库中的表(Table)都是二维的,每一行称为一条记录(Record), 在poem表中,一条记录存储一首唐诗。每一行又可以分为多列(Column),在数据库中,列也称为字段(Field)。peom表的结构如表23-2所示。
表23-2 诗人表 - poem
字段名 | 类型 | 用途 |
---|---|---|
id | int | 用一个数字来表示每首唐诗在表中的唯一编号,该字段不可重复 |
title | text | 题名字符串 |
author | text | 作者姓名 |
content | text | 全文 |
text是sqlite数据库中使用的字符串类型名,读者可以认为它就是Qt中的QString字符串类型。
23.2.2 数据库连接
应用程序与数据库之间的关系通常是客户机/服务器模式,读者可以把数据库想象成一个服务器,而应用程序则是客户机,数据库服务器通过网络向客户机提供数据存储和查询服务。当然,对于sqlite这种嵌入式数据库而言,数据库服务器并不真正存在,通信也不依赖于网络。
📕 操作指南 创建并添加新类 | |
---|---|
创建并添加新类 |
为了访问数据库,需要建立数据库连接。按照上述二维码链接提供的方法,创建一个名为DBHelper的类并加入到项目PoetsNetwork中。
其中,头文件dbhelper.h的代码如下:
//dbhelper.h
#ifndef DBHELPER_H
#define DBHELPER_H
#include <QSqlDatabase>
class DBHelper {
public:
static QSqlDatabase db;
static bool openDatabase();
static void closeDatabase();
static QString sProjectPath;
};
#endif // DBHELPER_H
类DBHelper是一个所谓的“帮助类”,其数据成员和方法都是静态的,这意味着我们可以在不实例化DBHelper对象的前提下使用这些属性和方法。
🚩第8行:QSqlDatabase类型的对象db用于保存“数据库连接”。
🚩第9行:openDatabase()用于“打开”/建立数据库连接,如果打开失败,则返回假。
🚩第10行:closeDatabase()用于关闭数据库连接。
🚩第11行:sProjectPath用于存储程序的“工作目录”,整个应用程序依赖于该目录定位所有的数据文件,包括sqlite数据库文件。
代码文件dbhelper.cpp的内容如下:
//dbhelper.cpp
#include "dbhelper.h"
#include <QFile>
#include <QDebug>
QSqlDatabase DBHelper::db;
bool DBHelper::openDatabase() {
QString sDBFile = sProjectPath + "/data/data.db";
if (!(QFile::exists(sDBFile))) {
qDebug() << "Error: missing database file " << sDBFile;
return false;
}
db = QSqlDatabase::addDatabase("QSQLITE");
db.setDatabaseName(sDBFile);
if (db.open())
qDebug() << "Database opened successfully:" << sDBFile;
else
qDebug() << "Database open failed:" << sDBFile;
return db.isOpen();
}
void DBHelper::closeDatabase(){
if (db.isOpen())
db.close();
}
QString DBHelper::sProjectPath = "d:/C2Cpp/C23_PoetsNetwork";
🚩第30行:如本书14.5节所述,类的静态数据成员必须在cpp文件中定义和初始化。这里的“d:/C2Cpp/C23_PoetsNetwork”是作者计算机上的项目工作目录,读者应根据自己计算机上的情况对该目录进行修改。在这个目录下,应有data和html两个子目录,其中,data子目录下存储了全唐诗文本文件及sqlite数据库文件,html子目录内则储存了后期用于关系网络可视化用的JavaScript及html文本文件。
🚩第6行:对db静态数据成员进行定义。
🚩第8 ~ 23行:openDatabase()的函数定义。其中,第9行借助于sProjectPath生成了数据库文件data.db的绝对路径,第10行则通过QFile::exists()函数判定数据库文件是否存在,如果不存在,在向调试控制台输出错误信息后,返回false。
🚩第25 ~ 28行:closeDataBase()函数定义。如果当前数据库db处于打开状态,通过close成员函数关闭它。
💥 警告 | |
---|---|
本实践的程序运行时会打开和访问sqlite数据库文件,前一小节中提到的SQLiteStudio软件也需要打开和访问同一个数据库文件。读者必须避免两者同时运行,即读者试图构建并运行PoetsNetwork项目时,需要先行退出SQLiteStudio软件,否则程序运行可能出错。 |
23.2.3 全唐诗数据整理
在子目录data下有一个名为qts_zht.txt的文本文件,共收录唐诗4万多首。格式如下:
...
128_65 哭孟浩然 王維 故人不可見,漢水日東流。借問襄陽老,江山空蔡州。
...
可见,文本文件的每一行是一首唐诗,用空格(部分为\t 制表符)可分为四个部分,从前至后分别是编号、题名、作者和全文。为了便于后续的数据分析,作者使用下述程序将全唐诗导入 sqlite 数据库中的 poem表 。
//mainwidget.h...
private:
int parsePoemsIntoDatabase(); //将全唐诗整理入库,返回成功整理入库的唐诗的数量
🚩第3行:在mainwidget.h中,parsePoemsIntoDatabase()函数被定义为私有成员,因为该函数预期仅用于MainWidget内部。
//mainwidget.cpp ...
int MainWidget::parsePoemsIntoDatabase() {
QString sFile = DBHelper::sProjectPath + "/data/qts_zht.txt";
if (!(QFile::exists(sFile))) {
qDebug() << "Error: missing QTS file " << sFile;
return -1;
}
auto q = QSqlQuery();
q.prepare("DROP TABLE IF EXISTS poem; ");
if (!q.exec()){
qDebug() << q.lastError();
return -1;
}
q.clear();
q.prepare("CREATE TABLE poem(id INT PRIMARY KEY ASC, "
"title TEXT, author TEXT, content TEXT);");
if (!q.exec()){
qDebug() << q.lastError();
return -1;
}
auto idx = 0;
QVariantList ids, titles, authors, contents;
QFile f(sFile);
Q_ASSERT(f.open(QIODevice::ReadOnly));
QTextStream fs(&f);
fs.setEncoding(QStringConverter::Utf8);
while (!fs.atEnd()){
auto line = fs.readLine();
line.replace("\t"," ");
auto r = line.split(" ");
if (r.size()==4){
ids << idx++; titles << r[1]; authors << r[2]; contents << r[3];
}
}
f.close();
q.clear();
q.prepare("INSERT INTO poem VALUES (?,?,?,?)");
q.addBindValue(ids); q.addBindValue(titles);
q.addBindValue(authors); q.addBindValue(contents);
DBHelper::db.transaction(); //开始数据库事务
if (!q.execBatch()){
qDebug() << q.lastError();
DBHelper::db.rollback(); //回滚数据库事务
return -1;
}
DBHelper::db.commit(); //提交数据库事务
return int(ids.size());
}
这很可能是读者第一次接触数据库的相关应用,我们先解释代码中的SQL语名。SQL是Structured Query Language的首字母拼写,是专门应用于关系数据库数据查询和操纵的“语言”。与函数parsePoemsIntoDatabase()相关的SQL语句解析请见表23-3。
表23-3 SQL语句解析1
语句 | DROP TABLE IF EXISTS poem; |
---|---|
说明 | 如果poem表存在,将其从数据库中删除(包括结构和数据)。当一个字符串中存在多条SQL语句时,语句之间使用分号作分隔。 |
语句 | CREATE TABLE poem(id INT PRIMARY KEY ASC, title TEXT, author TEXT, content TEXT); |
说明 | 创建poem表,id字段的类型为int,title、author、content字段的类型为text。请注意,id字段同时也被指定为表的主键(PRIMARY KEY),这意味着在这个表里面,每一行的id字段的值不可以重复。对于关系数据库而言,为每个表指定一个主键通常是必要的。ASC是ASCENDING的缩写,表示升序排列,这里就是指poem表内的各行依其主键键值在表中升序排列。 |
语句 | INSERT INTO poem VALUES (?,?,?,?) |
说明 | 用于向表格poem中插入一行数据,即一首唐诗。?在这里的作用类似于替换符,相关代码会将?号用实际数据替换,形成下述完整的SQL语句: INSERT INTO poem VALUES (0, ‘峨眉山月歌’,’李白’,’峨眉山月半轮秋,影入平羌江水流。…’) |
🚩第3 ~ 7行:通过DBHelper::sProjectPath生成全唐诗文件文件的绝对路径sFile。再通过QFile::exists()函数判断该文件是否存在,如不存在,报错后返回-1。
🚩第9 ~ 14行:执行SQL语句删除poem表(如果存在)。QSqlQuery是Qt中的数据库查询对象,专门用于执行SQL语句。如果q.exec()返回错误值,则报错并返回-1。在QSqlQuery类型的构造函数中,可以指定该查询对象所使用的数据库连接,如果没有指定,则使用默认数据库连接。由于本应用程序仅连接一个数据库,故总是使用默认数据库连接。
🚩第16 ~ 22行:使用类似于第9 ~ 14行的代码新建poem表格。读者可能会好奇为什么要重建poem表,而不是简单清空其表内数据,作者经过测试,确认相较于删除表内数据,直接重建表的速度更快。
🚩第24 ~ 39行:使用QFile打开全唐诗文本文件,逐行读取其中的唐诗,将题名、作者、内容分拆后,连同顺序号,按顺序装入QVariantList类型的容器ids、titles、authors和contents,为一次性地向数据库写入全部唐诗数据做好准备。
🚩第25行:QVariantList是用于存储QVariant类型的容器。在Qt中,QVariant代表“不确定类型”的对象,该型对象可以存储int、QString以及任意其它类型的对象。
🚩第30行:我们使用utf-8格式来解析文件文件,utf-8是一种支持多国文字共存的文字编码方案。读者应该知道,任何一个文件本质上都是字节流,而文本文件中的字节流,预期应该被“解读”为文字,应该有确定的编码方案将其中的字节流与文字字符进行相互映射。
🚩第33 行:将行字符串line中的制表符\t替换成空格。
🚩第34行:line.split(“ “)函数以空格作为分隔符,将字符串分成多个子串,r的类型为QStringList,是一个存储QString对象的容器。
🚩第41 ~ 52行:通过批量执行SQL语句将准备好的唐诗数据插入数据库的poem表。
🚩第43 ~ 44行:将ids、titles等准备好的QVariantList绑定到SQL查询对象q上。
🚩第47行:q.execBatch()按照第42行所设定的SQL语句模板,结合存储于ids、titles、authors及contents容量内的数据,批量生成并执行SQL语句,将所有数据一次性地写入数据库。
🚩第46、52行:为了提交数据写入的效率,我们采用了数据库的事务(transaction)管理技术。第46行开始数据库事务,第52行提交数据库事务。只有事务提交后,相关的数据库修改才会被确认并写入硬盘文件。想象一笔银行转账交易,如果细分下来,其实包含了多处数据修改:包括减少转出账户的余额、增加转入账户的余额、添加流水日志等。上述多处修改,如果部分成功,部分失败(因断电或系统故障等导致),数据库里的数据就会出现不一致的情况。为了避免一个完整操作的细分动作部分成功,部分失败,数据库系统通常提供事务管理功能,只有在事务提交时,之前的一系列数据修改动作才被确认。在本例当中,应用事务管理技术的目的更多是为了避免数据库文件的多次重写。如果不使用事务管理,每执行一条SQL语句,数据库文件就可能需要写入一次,效率十分低下。
🚩第49行:如果SQL语句的执行发生错误,回滚数据库事务。所谓回滚,就是把事务开始之后的全部操作作废,以避免一系列完整的数据库操作部分成功,部分失败。
按照22.3.6节所介绍的方法,我们为MainWidget里的pbParseQTS按钮的“released()”信号添加如下内容的槽函数。
 //mainwidget.cpp
void MainWidget::on_pbParseQTS_released(){
auto r = parsePoemsIntoDatabase();
ui->textBrowser->setHtml(r<0?QString("解析全唐诗文本并导入数据库失败!")
:QString("%1首全唐诗被解析并存入数据库peom表。").arg(r));
}
🚩第3行:调用执行parsePoemsIntoDatabase()函数。
🚩第4行:如果返回的r小于0,说明操作失败,大于等于0则表示解析成功的唐诗数量。textBrowser是简单的HTML文本浏览器,在本例中用于显示部分程序执行结果。
🚩第5行:arg()是QString对象的成员函数,本例中,它用参数值替换掉字符串中的占位符1%,返回一个完成格式化的新字符串。
构建并执行PoemsNetwork程序,并点击“全唐诗整理”按钮,一切无误的话,将得到如图23-6的执行结果。该执行结果显示,共有42948首唐诗被整理入库。再次提醒,执行PoemsNetwork程序前,应退出SQLiteStudio,以避免两个程序同时访问数据库文件data.db。
在退出PoetsNetwork程序的执行后,读者可以使用SQLiteStudio查看全唐诗数据整理的成果。如图23-7所示,在左侧的数据库浏览框中,右键单击poem表,并在弹出的菜单中选择Generate query for table(生成表查询)/SELECT(选择)。
接着,在SQL语句输入框中录入如图23-8所示的SQL语句,然后单击执行语句按钮(蓝色三角形)。语句成功执行后,下方的结果框显示poem表的数据总行数为42948。
如果把SQL语句修改成如图23-9所示,并再次执行,则可以查询并显示poem表数据的前10行。
此处涉及的两条SQL语句的语义解释请见表23-4。SQL语言本身是大小写不敏感的,即SELECT与select不作区分,等同使用。
表23-4 SQL语句解析2
语句 | SELECT count(*) FROM poem; |
---|---|
说明 | 从poem表统计并返回数据总行数。该语句的执行结果数据集包含一行数据且仅有一个名为count(*)的字段,该字段预期为一个整数。 |
语句 | SELECT id,title,author,content FROM poem LIMIT 10; |
说明 | 从poem表中查询并返回前10行记录,包括id、title、author及content共4个字段。该语句的执行结果数据集包含10行数据,4个字段。 |
练习巩固 👣
23-1 (李白的诗)在SQLiteStudio中执行下述SQL语句,查询全部作者为李白的诗。
SELECT * FROM POEM WHERE author = '李白';
23-2 (提到孟浩然的诗)在SQLiteStudio中执行下述SQL语句,查询标题中包含“孟浩然”的诗。
SELECT * FROM POEM WHERE title LIKE '%孟浩然%';
23.2.4 诗人名录及别名
要通过对唐诗的检索确定诗人之间的引用关系,并不容易。最大的困难在于古代中国人的别名太多。比如,杜甫,按字称子美,按排行称杜二,按官职称杜工部,有时还甚至被称为老杜。为了解决上述问题,我们下载了哈佛大学编撰的《中国历代人物传记资料库》,关于这个资料库的信息,请访问下述二维链接。
📕 资源下载 中国历代人物传记资料库 | |
---|---|
中国历代人物传记资料库 |
这个人物资料库中包含中国历代人物,并非特指唐代诗人,因此重名太多,例如可能存在多个王维、李良的情况。如果仅凭全唐诗的作者名,很难在人物资料库中准确定位那个作为诗人的王维以及他的别名王右丞。还好,我们还有生卒年可以用。唐朝建立于618年,灭亡于907年。我们删除了那些生卒年明确且与唐朝没有交集的全部人名记录,也删除了仅记录有生年或卒年,但从生年或卒年看明显跟唐朝没关系的人名记录。顺便,我们也删除了全部生卒年均不明确的人名记录。经过整理,我们得到了两个数据表,诗人(poet)表(23-5)和别名表(altname)(23-6)。
表23-5 诗人(poet)表字段清单
字段名 | 类型 | 用途 |
---|---|---|
id | int | 用作主键,表示一个诗人的唯一编号 |
name | text | 诗人的姓名, 比如“李白”、“刘禹锡” |
birthyear | int | 诗人的出生年份,如果为0,表示生年不详 |
deathyear | int | 诗人的死亡年份,如果为0,表示卒年不详 |
表23-6 别名(altname)表字段清单
字段名 | 类型 | 用途 |
---|---|---|
id | int | 人物在poet表中的id号,由于一个诗人可能拥有多个别名,因此,altname表中的id字段是允许重复的 |
name | text | 人物的别名 |
接下来,我们在SQLiteStudio中通过SQL语句来查询一下杜甫的别名。第一步,执行下述SQL语句:SELECT * FROM poet WHERE name = ‘杜甫’,得到如图23-10所示的查询结果。请读者注意,与C/C++语言不同,SQL语言中的字符串使用单引号包裹。
在上述结果中可见,杜甫在po