☰
数据库课后习题答案可执行验证:关系代数、范式分解与B+树索引
2026/10/27 10:45:34 网站建设 项目流程

简介:面向学习《数据库原理与设计》万常选版教材的高校学生与备考者,这套课后习题答案覆盖第2至6章及第9章,可作为课堂同步练习与期末复习的对照材料。压缩包共7个文件,以3份doc解答文档为主,另含2个sql示例脚本、1份txt作业说明和1个zip扩展资料包,整体约85KB;doc文档对应关系模型与关系代数、E-R建模、关系数据理论与模式求精等章节,sql脚本则可用于建表与完整性约束的动手验证。目前已有3906人学习下载,在数据库课程学习者中具有较高认可度。读者可据此核对习题思路,理解从现实世界到E-R图、再到关系模式的抽象过程,掌握函数依赖分析与1NF至BCNF的规范化判定,并借助脚本在真实数据库中复现练习,适合作为课程学习与考试准备的辅助参考。

1. 数据库课后习题答案为什么抄来的多半是错的

翻开任何一份流传的数据库课后习题答案,前五页基本都是对的,从关系代数那一章开始,错法就开始分化:有人把投影写成不去重,有人把除运算里的 NOT EXISTS 层数搞反,有人在范式分解题里给出了一个无损但不保持依赖的方案还浑然不知。这些错误不会在纸面上暴露,因为纸面答案没有读者,只有抄写者。

这篇讲的是另一条路:把数据库课后习题答案从“记忆材料”变成“可执行结论”。每道题先在本地库里跑一遍、用手写脚本算一遍,再回头对教材答案,对不上就以跑出来的为准。适合三类人:期末突击想把题型一次吃透的学生、复试或面试前要复盘关系代数与范式的人、以及替别人看作业的助教。工具只要一个单文件数据库加二十行 Python,不需要装服务端。

2. 关系代数与 SQL 双答案对照:把习题搬进本地库跑一遍

同一道题在教材里通常有两份答案:一份关系代数式,一份 SQL。做题时最省力的做法是只写一份,然后抄另一份的形。这种做法的代价是,两份答案只要有一处语义不同,你就同时错了两遍,而且不知道自己错在哪。把两边都落到可执行的层面,差异会自己跳出来。

2.1 同一道题为什么会有两个答案

关系代数和 SQL 建立在同一个关系模型上,理论上等价,但落到纸面和落到数据库上,中间隔着三层语义差。

第一层是集合与包。关系代数里关系是集合,投影之后自动去重;SQL 默认是包语义,SELECT sname FROM student出来几行就是几行。教材答案按集合写,你按 SQL 跑,行数对不上是必然的,DISTINCT不是可选项。

第二层是空值。关系代数里没有 NULL 这个成员,教材习题表里一旦出现空成绩、空系别,纸面推导会默认这些行“不参与比较”,而 SQL 会按三值逻辑处理,WHERE grade > 80直接把 NULL 行过滤掉,WHERE grade <= 80也过滤掉。很多人对答案时发现少了一行,其实是空值在起作用。

第三层是除运算。教材用R ÷ S表达“对所有”的语义,SQL 没有对应算子,必须用双重NOT EXISTS展开,或者用分组计数凑,两种写法在空集上结果不同。这一层是丢分重灾区。

本地验证我一般用 SQLite:单文件、不用起服务、语法覆盖选择投影连接分组这些高频题型。遇到FULL OUTER JOIN或者窗口函数的题,切到 PostgreSQL 跑,因为 SQLite 的老版本对前者支持有限,跑出来不报错但结果不对,这种坑不值得踩。

2.2 三张最小表:把选择、投影、连接跑通

教材里绝大多数关系代数题都围绕“学生—课程—选课”这个三元结构,把这三张表建起来,后面所有题都能复用。

-- 学生选课最小模型:覆盖教材里大部分选择、投影、连接题 CREATE TABLE student ( sno TEXT PRIMARY KEY, -- 学号,用 S1/S2 这种记号,和教材写法直接对齐 sname TEXT NOT NULL, sdept TEXT, -- 系别允许为空,专门用来复现空值题 age INTEGER ); CREATE TABLE course ( cno TEXT PRIMARY KEY, cname TEXT NOT NULL, credit INTEGER CHECK (credit > 0) ); CREATE TABLE sc ( sno TEXT NOT NULL REFERENCES student(sno), cno TEXT NOT NULL REFERENCES course(cno), grade INTEGER CHECK (grade BETWEEN 0 AND 100), PRIMARY KEY (sno, cno) -- 复合主键:一人一课只能选一次 ); INSERT INTO student VALUES ('S1','A同学','计算机',20), ('S2','B同学','计算机',21), ('S3','C同学','数学',NULL), -- 年龄未知 ('S4','D同学','数学',22); INSERT INTO course VALUES ('C1','数据库',3),('C2','数据结构',4),('C3','离散数学',2); INSERT INTO sc VALUES ('S1','C1',92),('S1','C2',85), ('S2','C1',78), ('S3','C2',NULL), -- 成绩未知,用来验证聚合题的空值行为 ('S4','C3',66);

逻辑说明:sno用文本而不是自增整数,是为了和教材里的 S1、S2 记号一一对应,抄答案时不用在脑子里做一层映射,省下的注意力留给语义判断。sc的主键设成(sno, cno)复合主键,正好对应“一个人一门课只能选一次”的业务语义,同时它也让后面验证除运算时不必额外写DISTINCT——数据库已经替你去过重了。

参数说明:CHECK (grade BETWEEN 0 AND 100)这类约束在造测试数据时看着多余,但它是后面第 4 章验证建表答案的基础设施。REFERENCES后面不写ON DELETE动作,默认是 RESTRICT,删除被引用的学生行会直接报错,这一点恰好能用来验证参照完整性题。

建好表之后,把教材上第一道连接题落下来:

-- 题:查计算机系学生的姓名和他们选的课名 -- 纸面写法 π_sname,cname(σ_sdept='计算机'(student) ⋈ sc ⋈ course) SELECT DISTINCT s.sname, c.cname FROM student s JOIN sc ON sc.sno = s.sno JOIN course c ON c.cno = sc.cno WHERE s.sdept = '计算机' ORDER BY s.sname, c.cname;

逻辑说明:关系代数里“先做选择再连接”是划重点的优化思路,SQL 里你不按这个顺序写,优化器也会自己重排,但纸面答案该按“选择—连接—投影”的顺序写,因为教材判卷看的是推导过程。DISTINCT在这里不能省:一个学生选了两门课,投影到(sname, cname)上不会重复,但如果题目改成只投影sname,不去重就会比标准答案多出好几行。

参数说明:JOIN写成显式内连接而不是逗号加WHERE,是为了让连接条件和过滤条件在语法上分开,出问题时一眼能看出是哪一类条件写错。ORDER BY只影响可读性,不影响结果集内容,但对着答案逐行核对时,排序能省掉你肉眼比对的时间。

2.3 除运算与“全部”语义:把关系代数翻译成双重 NOT EXISTS

教材里最容易被抄错的一类题是“查询选修了全部课程的学生学号”。纸面答案是π_sno, cno(sc) ÷ π_cno(course),SQL 里没有这个算子,用COUNT凑是一种写法,但它在“课程表为空”时结果会翻车。稳妥的写法是双重否定:

-- 关系代数 R ÷ S 的 SQL 模板:不存在一门课,该学生没有选 SELECT DISTINCT sno FROM sc AS x WHERE NOT EXISTS ( SELECT 1 FROM course c WHERE NOT EXISTS ( SELECT 1 FROM sc y WHERE y.sno = x.sno AND y.cno = c.cno ) );

逻辑说明:读法是“对每一个学生 x,不存在这样一门课 c:x 没有选 c”。两层NOT EXISTS加一个普通EXISTS的语义嵌套,是这类题的通用骨架。外层用sc做驱动而不是student,是因为没选过任何课的学生在sc里根本没有行,标准答案通常也不把他算进去;如果题目要求把这种学生也算进来,驱动表换成student即可。

参数说明:三个表别名x、c、y分别代表“待判定的学生”“被遍历的课程全集”“该学生的选课记录”,别名写错不会报错,只会静默地返回全集或者空集,所以改这段代码时一定要跑一次小数据集对结果。

这个骨架还有一个变体,能一次覆盖教材里一整类题:把内层的course换成任意集合。比如“查询至少选修了 S2 所选全部课程的学生”:

SELECT DISTINCT sno FROM sc AS x WHERE NOT EXISTS ( SELECT 1 FROM (SELECT cno FROM sc WHERE sno = 'S2') c WHERE NOT EXISTS ( SELECT 1 FROM sc y WHERE y.sno = x.sno AND y.cno = c.cno ) );

要改的只有一个派生表,其余结构不动。这也是我建议把除运算模板背下来的原因——它把“换集合”变成了“换一行子查询”,比每次重新推导省事得多。

2.4 分组聚合题:分母里到底算不算空值

分组题的答案差异几乎全在分母上。教材写AVG(成绩)时不会讨论空值,SQL 会忽略 NULL,于是同一个班三个人、其中一个没成绩,AVG的分母是 2,COUNT(*)的分母是 3。这两种分母对应的是两道不同的题,抄答案时最忌讳把两者混着用。

-- 题:查平均分高于 80 的课程号、平均分与有效人数 SELECT cno, AVG(grade) AS avg_grade, -- NULL 不计入分母 COUNT(grade) AS cnt_valid, -- 有效成绩人数 COUNT(*) AS cnt_all -- 选课人数,含未出分 FROM sc GROUP BY cno HAVING AVG(grade) > 80;

逻辑说明:HAVING作用在分组之后,条件里可以直接用聚合函数;WHERE作用在分组之前,条件里不能出现聚合函数。教材题里“平均分大于多少的课”和“选课人数大于多少的课”看起来像同一类,其实前者必须用HAVING,后者可以用WHERE加子查询或者HAVING两种写法,答案不唯一,但推导过程必须自洽。

参数说明:GROUP BY cno之后,SELECT列表里只能出现分组列和聚合函数,SQLite 对这条规则比较宽松,PostgreSQL 会直接报错。如果你的答案在 SQLite 上跑通了但在 PG 上报错,不是答案错,是分组列没写全,按 PG 的严格模式改反而更接近标准答案。

对照表把差异摊开看更清楚:

题型纸面答案常见写法SQL 正确写法差异根源
平均成绩Σ成绩/选课人数AVG(grade)空值是否计入分母
选课人数计数关系元组COUNT(*)或COUNT(grade)统计口径不同
没选课的学生差集LEFT JOIN ... IS NULL差集要显式表达
每门课的最高分分组取极值MAX(grade)极值天然忽略空值

把这张表贴在显示器旁边,分组题基本不会再错在同一处。

3. 函数依赖与范式分解:用闭包算法自己算出标准答案

范式题的答案在流传版本里错得最有规律:候选码少一个、分解多一个子模式、无损和保持依赖写反。原因是这类题手工推导的步骤长,中间任何一步算错,后面全错,而且错得很像对的。把闭包计算交给代码,是让这类题变成“运行一下就知道”的最短路径。

3.1 手算闭包为什么会漏属性

属性闭包 X⁺ 的定义是:从 X 出发,反复使用 F 中的函数依赖向右扩张,直到集合不再变化。手算出错的根源有两个,一是只扫一遍依赖就停手,二是把“左部被包含”判断成了“左部包含”。

举个具体的:F = {A→B, B→C, AC→D, D→E},求 A⁺。第一趟扫到 A→B,得到 AB;扫到 B→C 时如果已经用上了新得到的 B,就能继续得到 C。手快的人第二趟才反应过来,手更快的人直接漏掉。这类题属性数一般在 4 到 6 个,子集总数最多 63 个,全部枚举一遍的成本远低于手算复核的成本。

候选码的判定条件是两条:X⁺ 等于全部属性集 U,且 X 的任意真子集都不满足这一条。第二条是极小性,最容易漏。很多人找到一个能推出全部属性的集合就写上去,那是超码不是候选码,两者在判卷时差一整分。

3.2 三十行 Python 算出闭包、候选码与超码

# 输入属性集与函数依赖,输出闭包、候选码、超码 from itertools import combinations attrs = set("ABCDE") # 每个依赖写成 (左部集合, 右部集合),顺序无关 F = [(set("A"), set("B")), (set("B"), set("C")), (set("AC"), set("D")), (set("D"), set("E"))] def closure(x, F): """求属性集 x 在 F 下的闭包,必须迭代到不动点""" res = set(x) changed = True while changed: # 一趟扫描是手算出错的头号原因 changed = False for l, r in F: if l <= res and not r <= res: # 左部被包含 且 右部还没进来 res |= r changed = True return res def candidate_keys(attrs, F): keys = [] # 按基数从小到大枚举,保证先找到的一定是极小的 for k in range(1, len(attrs) + 1): for combo in combinations(sorted(attrs), k): s = set(combo) if closure(s, F) != attrs: continue if any(set(c) < s for c in keys): # 已有候选码的真超集不算 continue keys.append(s) return keys print("A+ =", "".join(sorted(closure({"A"}, F)))) print("候选码:", ["".join(sorted(k)) for k in candidate_keys(attrs, F)])

逻辑说明:closure里的while changed是不动点循环,只要这一轮还有新属性进来就再扫一遍,这是和手算唯一但关键的差别。l <= res是集合的子集判断,左部所有属性都在当前闭包里才能用这条依赖。candidate_keys按基数从小到大枚举,配合“已有候选码的真超集跳过”的判定,一次就能得到全部极小候选码,不会把超码混进来。

参数说明:属性集用set存,不能用字符串,否则"AB"和"BA"会被当成两个不同的东西。F里左部写set("AC"),属性顺序不影响判定,但排序后打印出来更接近教材写法,方便逐字核对。上面的例子跑完输出候候选码是A,因为 A→B→C,加上 A 和 C 推出 D,D 推出 E,A 单独就能覆盖全部属性。这个结论手算也能得到,但一旦依赖列表长到七八条,代码的优势就出来了。

超码不用单独写函数:任何包含候选码的集合都是超码。教材题里“求所有候选码”和“求某个属性集是否为超码”是两种问法,前者用上面的函数,后者只要一行closure(s, F) == attrs。

3.3 无损连接判定:二分分解的捷径与多分量的表格法

无损连接的判定,教材给了两个方法:表格法(chase)和定理法。定理法只适用于把 R 分解成两个子模式的情况,判据是公共属性集的闭包是否包含其中一个子模式的全部属性。这条捷径能覆盖八成的习题,剩下的多分量分解必须走 chase。

用刚才的 F = {A→B, B→C, AC→D, D→E},U = ABCDE,分解成 R1(AB)、R2(ACDE)。公共属性是 A,A⁺ = ABCDE 包含 R2 的全部属性,所以这个分解是无损的。判据只要求包含其中一个,包含哪个都行。

多分量分解就得画表。以下判定流程适合手算,也适合写成脚本:

阶段操作终止条件
初始化建 k 行 n 列表格,第 i 行第 j 列,若属性 j 在子模式 Ri 中填 aⱼ,否则填 bᵢⱼ表格建成
应用依赖对每条 X→Y,找 X 列取值相同的行,把这些行的 Y 列改成一致(优先改成 a)无行可改
判定出现一行全是 a 即无损,否则有损一次判定

保持依赖的判定是另一回事,判据是:把 F 投影到每个子模式上得到 Fi,若 ∪Fi 能推出原来的每一条依赖,则保持依赖。实操时对每条原依赖 X→Y,计算 X 在 ∪Fi 下的闭包,看 Y 是否被包含。这两件事必须分开判,3NF 分解要求两者都满足,BCNF 分解只保证无损。

def preserves_dependency(F, subschemas): """判断分解是否保持函数依赖:对每条 X→Y,X 在投影依赖并集下的闭包要含 Y""" proj = [] for sub in subschemas: for l, r in F: if l <= sub and r <= sub: # 只保留完全落在子模式内的依赖 proj.append((l, r)) for l, r in F: if not r <= closure(l, proj): return False, f"丢失依赖 {''.join(sorted(l))}->{''.join(sorted(r))}" return True, "保持依赖"

逻辑说明:投影这一步只保留左右部都在同一个子模式内的依赖,这是定义;判定这一步对每条原依赖重算闭包,闭包里没有右部就说明这条依赖在分解后推不出来。返回字符串而不是布尔值,是为了直接看到是哪条依赖丢了,改分解方案时有的放矢。

参数说明:subschemas传的是子模式属性集的列表,元素也是set;如果只传一个子模式,函数等价于判断单表投影是否保持全部依赖,这种情况几乎总是 False,可以作为反例验证。

3.4 分解答案不唯一:怎么确认自己那份“合法”

范式分解的结果天然不唯一。3NF 合成法要先求最小依赖集,最小依赖集本身就可能有多份;BCNF 分解法每轮挑一条违反的依赖拆,挑哪条不同,结果就不同。流传答案和你算出来的不一样,未必是你错。

判断自己的答案是否合法,查三件事,缺一不可:分解是否无损、是否保持函数依赖(目标范式是 3NF 时必查)、每个子模式是否真的达到了目标范式。前两条上面已经有代码,第三条要对每个子模式单独求候选码,再检查有没有非主属性对码的部分依赖和传递依赖。

写答案时的顺序建议固定成:先列候选码,再求最小依赖集,再写分解过程,最后补一句“该分解具有无损连接性且保持函数依赖”。判卷看的是这条链是否闭合,只给一个分解结果而不给依据,即使答案对也容易被扣过程分。这一条经验来自一次批改:同一道题十份作业里有六份分解结果不同,其中只有两份写清了判定依据,另外四份哪怕结果碰巧正确也没拿到分。

4. ER 图转关系模式:把文字答案写成能建表的 DDL

ER 图转关系模式是教材里最容易“看着会、写着错”的一类题。规则只有几条,但每条都有边界情形:1:1 联系往哪边并、弱实体的主码怎么组、多值属性什么时候必须独立成表。把这些规则落成 DDL,让数据库的约束替你判卷,比对着标准答案逐字比对可靠得多。

4.1 五条转换规则与习题里的高频变体

不同教材的表述有出入,但判定逻辑一致。下面这张表按“结构 → 转换结果 → 键怎么处理”整理,覆盖了这类题的绝大部分问法。

ER 结构转换结果键的处理习题常见问法
1:1 联系并入任一端,或独立成表并入端加另一端的码作外键,并加唯一约束为什么并入参与度全部的一端
1:n 联系并入 n 端n 端加 1 端的主码作外键外键放在哪边,为什么
m:n 联系独立成表两端主码组合成主键联系属性放哪里
多值属性独立成表属主主码 + 属性值构成主键能不能留在原表里
弱实体独立成表属主主码 + 部分码构成主键主码为什么不能只用部分码

1:1 联系往哪边并是最常被问的。规则不是“随便挑一边”,而是优先并入参与度是「全部」的那一端,因为全部参与意味着这一端的每个实体都必须参与联系,并进去不会产生空值。两端都是部分参与时,并入任何一端都可以,答案不唯一,但要说明理由。

4.2 把一道 ER 题写成 DDL

假设题目是:部门与员工是 1:n,员工与项目是 m:n 且联系上带“工时”属性,员工可以有多个联系电话。

-- 部门—员工 1:n 并入 n 端,员工—项目 m:n 独立成表,电话是多值属性 CREATE TABLE dept ( dno TEXT PRIMARY KEY, dname TEXT NOT NULL ); CREATE TABLE emp ( eno TEXT PRIMARY KEY, ename TEXT NOT NULL, dno TEXT NOT NULL REFERENCES dept(dno) -- 1:n 的外键在 n 端 ); CREATE TABLE proj ( pno TEXT PRIMARY KEY, pname TEXT NOT NULL ); CREATE TABLE works ( eno TEXT NOT NULL REFERENCES emp(eno), pno TEXT NOT NULL REFERENCES proj(pno), hours NUMERIC CHECK (hours > 0), PRIMARY KEY (eno, pno) -- m:n 联系:两端主码组合成主键 ); CREATE TABLE emp_tel ( eno TEXT NOT NULL REFERENCES emp(eno), tel TEXT NOT NULL, PRIMARY KEY (eno, tel) -- 多值属性:属主码 + 值 );

逻辑说明:外键位置是这类题的判分点。1:n 的联系并到 n 端,因为一个员工只属于一个部门,把dno放进emp不会产生重复组;反过来把eno放进dept就要存一个集合,违反第一范式。m:n 必须独立成表,因为联系本身有属性hours,塞进任何一端都会丢失信息。

参数说明:works的主键是(eno, pno)而不是加一个自增 id,因为业务上“一个人在一个项目上只能有一条工时记录”,复合主键直接表达了这条约束。emp_tel的主键必须包含eno,如果只用tel做主键,两个员工共用一个号码就会被拒绝,与题意不符。

4.3 用“应该插不进去”的数据验证约束

DDL 建完不等于答案对。真正的验证是:往里面插一批故意违规的数据,看数据库是不是全部拒绝。全部报错,说明约束齐了;有一条插进去了,说明你漏了一个约束,而漏掉的这个约束很可能就是标准答案里写的那一句。

-- 下面每条都应该报错,任何一条成功插入都说明答案缺约束 INSERT INTO emp(eno, ename, dno) VALUES ('E9','X同学','D9'); -- D9 不存在:参照完整性 INSERT INTO emp(eno, ename, dno) VALUES ('E1','Y同学', NULL); -- 若题目要求全部参与,这行不该成功 INSERT INTO works(eno, pno, hours) VALUES ('E1','P1', -3); -- 工时为负:CHECK 拦截 INSERT INTO works(eno, pno, hours) VALUES ('E1','P1', 10); INSERT INTO works(eno, pno, hours) VALUES ('E1','P1', 20); -- 主键重复 INSERT INTO emp_tel(eno, tel) VALUES ('E1', '123456'); INSERT INTO emp_tel(eno, tel) VALUES ('E1', '123456'); -- 同一号码重复

逻辑说明:这几条语句是按“主键、外键、CHECK、唯一性”四类约束各来一条组织的。第一条验证外键指向的部门不存在时会不会被拒;第二条验证“全部参与”这个语义在 DDL 里有没有体现——如果题目写明每个员工必须属于一个部门,dno就应该加NOT NULL,这一行必须失败;第三、四条验证取值范围和复合主键。

参数说明:跑之前先插一条合法的部门和员工数据,否则所有语句都因为父表为空而报错,你分不清是约束生效还是数据缺失。测试完记得把成功的那些行删掉,或者干脆用一个临时库文件反复重建,避免脏数据影响后续题目的验证。

4.4 参照完整性动作:删除属主时该发生什么

教材里常有一问:“删除一个部门,其下员工应如何处理”。这不是让你背三个关键字,而是让你回到 ER 图看参与度。员工的参与度是全部,意味着员工必须挂在某个部门下,那删除部门时就不能把员工的外键置空,只能级联删除或者拒绝删除。参与度是部分,置空才是合理选择。

参与度合理动作DDL 写法
全部参与级联删除,或拒绝删除ON DELETE CASCADE或默认RESTRICT
部分参与外键置空ON DELETE SET NULL,且外键列允许 NULL

写答案时把这层对应关系写出来,比只写一个关键字有说服力。验证方式也简单:插两条数据,删掉父行,看子行是消失了、变 NULL 了、还是报错。三种结果对应三种答案,跑一次就确定。

5. 排查清单:习题答案最容易翻车的五处

这一节记录的是我在对答案过程中反复撞上的五类问题,每一条都按“现象、原因、解决”写。它们共同的特征是:不报错,结果看着合理,但和标准答案差一点。

5.1 三条查询类坑:空集、分母、去重

坑一:NOT IN子查询返回空集。

现象:一道“查询没有选修 C1 的学生”的题,NOT IN写法跑出来零行,换成NOT EXISTS立刻就对了。

原因:子查询结果里只要出现一个 NULL,x NOT IN (..., NULL)对任何 x 都返回 UNKNOWN,WHERE把 UNKNOWN 当假处理,整张表被过滤干净。选课表里成绩允许为空,但学号一般不为空,所以这个坑往往藏在“子查询选了允许为空的列”这种细节里。

解决:涉及子查询的否定判断一律用NOT EXISTS,它对 NULL 免疫。如果非要用NOT IN,在子查询里加WHERE col IS NOT NULL,但这等于给自己加了一道需要记住的规则,不如直接换写法。

坑二:聚合函数的分母混进空值。

现象:一道求平均分的题,你算出来 82.5,答案写 78.3。

原因:AVG(grade)的分母是有效成绩数,SUM(grade)/COUNT(*)的分母是选课人数,两者在有空成绩时结果不同。教材写Σ成绩/人数时,“人数”指的是有成绩的人数还是选课人数,取决于题目前文,很多人默认成后者。

解决:写答案时把分母显式写出来,AVG(grade)和SUM(grade)/COUNT(grade)等价,和SUM(grade)/COUNT(*)不等价。跑一次对比两条 SQL 的结果,差异立刻现形。

坑三:投影后没去重,行数多出来。

现象:一道只投影单列的题,SQL 跑出来 5 行,关系代数答案写 3 行。

原因:关系代数的投影是集合运算,自动去重;SQL 的SELECT是包运算。教材答案按集合写,你按包跑,多出来的就是重复行。

解决:所有单表投影、以及投影后可能产生重复的题,一律加DISTINCT。判断标准很简单:如果纸面答案的元组数少于 SQL 的行数,先怀疑去重,再怀疑连接条件。

5.2 两条建模类坑:范式概念混淆与约束失效

坑四:把“无损连接”和“保持函数依赖”当成一回事。

现象:范式题写了“该分解无损且保持依赖”,被批注“只满足其一”。

原因:这是两个独立性质。无损说的是分解后自然连接能还原原关系,不丢元组也不多出伪元组;保持依赖说的是原来的函数依赖能不能从分解后的投影依赖里推出来。3NF 分解可以同时满足两者,BCNF 分解只保证无损,可能丢掉依赖。很多人做完 BCNF 分解顺手把两句话都写上,直接错。

解决:判定顺序固定成“先判无损,再判保持依赖,最后判目标范式”。目标范式是 BCNF 时,保持依赖那一句要么不写,要么写“该分解不保持函数依赖,丢失的是 X→Y”。写清楚丢失了哪条,反而比含糊带过更容易得分。

坑五:CHECK约束对 NULL 放行。

现象:给成绩加了CHECK (grade BETWEEN 0 AND 100),插入 NULL 居然成功了,你以为是约束没生效。

原因:SQL 的三值逻辑里,NULL BETWEEN 0 AND 100的结果是 UNKNOWN,CHECK只在结果为 FALSE 时拒绝,UNKNOWN 放行。这不是 bug,是标准行为。

解决:如果题意要求成绩不能为空,就写grade INTEGER NOT NULL CHECK (grade BETWEEN 0 AND 100),用NOT NULL兜住空值,CHECK只管范围。验证方式是插一条 NULL 看它失不失败,别靠读文档确认。

这五条的共同点是都不报错。数据库不会告诉你“你的答案语义错了”,它只会安静地给你一个看起来合理的错误结果。所以每改一次 SQL,跑一次小数据集、对一次行数,是最便宜的自检手段。

6. 用执行计划反推 B+ 树计算题,顺手验证索引判断题

B+ 树那章的习题分两类:一类是纯计算,给块大小、键长、指针大小,求阶数和树高;一类是判断,问某条查询能不能用上某个索引。前一类靠公式,后一类靠最左前缀原则,两者都能用本地库做交叉验证。

计算题的关键是先分清阶数 m 的定义:m 表示每个节点最多有 m 个孩子,对应最多 m−1 个关键字。教材有的用“最多 m 个关键字”定义,差一位,答案就全错。我吃过这个亏,一道求树高的题算出来比标准答案少一层,回头查了半天公式,问题出在阶数的定义上。做题时第一件事是把题目里阶数的定义抄在草稿纸顶上。

单节点能放多少关键字,算法是:(块大小 - 节点头开销) / (键长 + 指针大小)向下取整。树高则由叶子层往上推,每层能挂的子节点数就是阶数,用总记录数反复取对数直到收敛。

索引判断题更适合用执行计划实证。SQLite 里跑:

-- 复合主键 (sno, cno) 上,按 cno 单独查能不能走索引? EXPLAIN QUERY PLAN SELECT * FROM sc WHERE cno = 'C1'; -- 按 sno 查,最左前缀命中 EXPLAIN QUERY PLAN SELECT * FROM sc WHERE sno = 'S1';

第一条输出的关键词是SCAN sc,说明全表扫描,索引用不上;第二条输出SEARCH sc USING INDEX sqlite_autoindex_sc_1 (sno=?),说明命中了复合主键的最左列。结论就是最左前缀原则的实证:复合索引只有从最左列开始连续匹配才能用,跳列、乱序都不行。把这两条输出抄进答案,比背定义有说服力。

换成 PostgreSQL 时看的是EXPLAIN输出里的Index Scan和Seq Scan,判定逻辑一样。注意小表上优化器可能主动选择全表扫描,因为走索引的代价更高,这时候输出是Seq Scan不代表索引用不上,加个SET enable_seqscan = off再跑一次就能看到真实情况。这一步很容易被忽略,我头几次做这类题就是被小表的执行计划误导过。

最后一个习惯:每做完一章习题,把这一章用到的表和查询存成一个脚本文件,命名带上章节号。下次遇到同类题,改几个字段就能复用,不用从零搭环境。这个习惯让我在复盘范式题时省下了大量重复劳动,也让我意识到,数据库课后习题答案真正的价值不在那些元组上,而在你能不能把它们跑起来、跑对、并且知道为什么对。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询