news 2026/10/27 10:45:39

把数据库课后题变成可执行用例:SQL互译、范式闭包与调度判定

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
把数据库课后题变成可执行用例:SQL互译、范式闭包与调度判定

简介:面向学习《数据库原理与设计》的学生与备考者,这份万常选版课后习题答案覆盖第2至6章及第9章,聚焦关系模型与关系代数、E-R建模、关系数据理论与模式求精等核心考点,可用于课后对照、考前查漏与概念梳理。压缩包共7个文件,以3份Word解答文档为主,辅以2个SQL脚本、1份说明文本和1个资料压缩包,整体约85KB,分别对应习题解析、示例数据库建表与完整性约束演示以及补充材料。已有3906人学习,说明其在数据库课程复习中具有一定参考价值。读者可依据分章答案核对解题过程,借助SQL脚本理解表结构、数据插入与约束设置,并通过说明文字把握作业要求与思路。对需要辨析范式、关系代数运算和E-R转换的学习者,这些经过验证的解答能提供较具体的对照样本,适合配合教材同步练习与阶段性复习。

1. 从一份课后习题答案说起:数据库这门课真正卡人的地方

搜「数据库课后习题答案」的人,十有八九不是想抄,而是想验证自己那道 SQL 到底错在哪。手上这本国内常见的数据库原理教材(下面统一叫「该版教材」),习题编排偏理论:关系代数、函数依赖与范式、事务与并发调度几块各占一大片,而配套答案往往只给最终结果,不给推导过程。于是最常见的场景是:题做完了,答案对不上,也说不清是参考答案有问题,还是自己漏了一步。

这篇讲的思路,是把该版教材的课后题当成一套可执行用例来用。能用机器验算的,写脚本验算:关系代数与 SQL 互译拿真实数据集跑,范式题用属性闭包和 Chase 表判,并发调度题画优先图找环。没法机器验算的,固定一套推导模板走一遍,保证每次写出来的步骤一致。适合两类人:正在跟这本教材过第一遍、想少走弯路的,以及工作几年后想把数据库基础重新捡起来、需要一个能落地的练习靶子的。

2. 关系代数与 SQL 互译:把查询题做成能跑出结果的用例

2.1 为什么先练互译,而不是先背 SQL 语法

查询类的课后题,形式上永远是两个方向:给一段关系代数表达式,让你写出等价的 SQL;或者给一段 SQL,让你反推关系代数。很多人卡住的地方不在语法,而在语义——关系代数里的除法、差、全称量词,落到 SQL 里就变成NOT EXISTS的嵌套与否定的再否定。这种结构光靠读答案记不住,必须自己写出来跑一遍,看见结果集才作数。

我的做法是先把习题里出现的数据依赖关系理顺:哪些题只涉及单表选择投影,哪些涉及连接,哪些涉及「全部」「至少」「不存在」这类量词。前两类可以直接套模板,第三类才是真正拉开差距的地方。该版教材里「检索选修了全部课程的学生」这类题反复出现,本质就是关系代数的除法运算,SQL 里没有除法运算符,只能等价变形。把这一类吃透,剩下的题基本都是它的变体。

另外一点,互译练习对理解执行计划很有帮助。你在关系代数里写的连接顺序,和数据库优化器最终选出来的顺序往往不一样,但你能说清「逻辑上应该是什么样」,看执行计划时才有参照。这一点在后面排查慢查询时会反复用到。

提示:先把每道题的输入关系(表)和输出属性(结果列)写在草稿上,再动手写表达式。输出的属性集合错了,表达式写得再花也是错的。

2.2 五张表的最小可跑环境

课后题里的表结构各章不一样,但核心就那几张:学生、课程、选课、教师、授课。与其每道题现造数据,不如一次建好一套最小库,后面所有查询题都往这套库里灌。用 SQLite 就够,一个文件、零配置,脚本跑完即走。

-- schema.sql:课后题通用最小库结构 CREATE TABLE student ( sno TEXT PRIMARY KEY, -- 学号 sname TEXT NOT NULL, -- 姓名 sdept TEXT, -- 所在系 sage INTEGER -- 年龄 ); CREATE TABLE course ( cno TEXT PRIMARY KEY, -- 课程号 cname TEXT NOT NULL, -- 课程名 ccredit REAL, -- 学分 cdept TEXT -- 开课系 ); CREATE TABLE sc ( sno TEXT, -- 学号 cno TEXT, -- 课程号 grade REAL, -- 成绩,NULL 表示尚未出分 PRIMARY KEY (sno, cno) ); CREATE TABLE teacher ( tno TEXT PRIMARY KEY, tname TEXT NOT NULL, tdept TEXT ); CREATE TABLE tc ( tno TEXT, -- 教师工号 cno TEXT, -- 讲授的课程号 PRIMARY KEY (tno, cno) );

建表语句本身没什么技术含量,值钱的是字段上的约束:sc用(sno, cno)做主键,等价于关系模型里「一个学生一门课只有一条选课记录」;grade允许 NULL,是为了后面区分「没选这门课」和「选了但没出分」——这两个语义在 SQL 里完全不同,也是课后题答案最容易出现分歧的地方。

灌数据的脚本我一般单独放一个文件,方便随时重置:

# seed.py import sqlite3 DDL = open("schema.sql", encoding="utf-8").read() conn = sqlite3.connect(":memory:") conn.executescript(DDL) conn.executemany("INSERT INTO student VALUES (?,?,?,?)", [ ("S1", "甲", "计算机系", 20), ("S2", "乙", "计算机系", 21), ("S3", "丙", "软件系", 19), ("S4", "丁", "软件系", 20), ]) conn.executemany("INSERT INTO course VALUES (?,?,?,?)", [ ("C1", "数据库", 4.0, "计算机系"), ("C2", "操作系统", 4.0, "计算机系"), ("C3", "编译原理", 3.0, "计算机系"), ]) conn.executemany("INSERT INTO sc VALUES (?,?,?)", [ ("S1", "C1", 92), ("S1", "C2", 85), ("S1", "C3", 78), ("S2", "C1", 66), ("S2", "C2", 71), ("S3", "C1", 88), # S4 一门课都没选,后面专门用它验证「全称量词」题的边界 ]) conn.commit()

这里有个刻意的设计:S4 一条选课记录都没有。做「选修了全部课程的学生」这种题时,S4 是否该出现在结果里,取决于你对题意的理解,也取决于你写的 SQL 结构。把它放进测试数据,就能立刻看出两种写法的差异。

参数说明上只有两条值得记:conn = sqlite3.connect(":memory:")建的是内存库,脚本一结束数据就没了,适合反复试验;如果需要保留中间结果做对比,换成文件路径即可。executescript一次执行多条 DDL,注意它会先隐式提交事务。

注意:SQLite 对类型检查很宽松,grade传字符串也不会报错。真要练严格语义,把列类型声明成NUMERIC并加CHECK (grade BETWEEN 0 AND 100),能提前暴露一部分写错的题。

2.3 「选修了全部课程」的三种写法与关系代数除法

这是该版教材查询章节的招牌题,也是最能体现互译价值的一道。题面:检索选修了全部课程的学生姓名。先看关系代数怎么写。

设R = π_sno,cno(sc)(谁选了什么),S = π_cno(course)(有哪些课),那么「选修了全部课程的学生」就是R ÷ S。除法展开成基本运算:

π_sno(R) − π_sno( (π_sno(R) × S) − R )

读法是从内往外:笛卡尔积造出「每个学生 × 每门课」的全部可能组合,减去实际存在的选课记录,剩下的就是「该选而没选」的缺口;再投影出学号,从全体学生里减掉这些有缺口的,剩下的就是一门不缺的。

对应到 SQL,最稳的写法是双重NOT EXISTS:

-- 写法一:双重否定,直接对应关系代数的差集语义 SELECT s.sname FROM student s WHERE NOT EXISTS ( SELECT 1 FROM course c WHERE NOT EXISTS ( SELECT 1 FROM sc WHERE sc.sno = s.sno AND sc.cno = c.cno ) );

外层NOT EXISTS的意思是「不存在这样一门课」,内层是「该生没有选这门课」。两层否定叠起来就是「不存在他没选的课」。这段 SQL 的执行方式和人类直觉相反,但对 NULL 和空表的处理最稳,也是考试里最不容易被判错的写法。

第二种用计数比较,写起来短,但坑多:

-- 写法二:计数比较,注意 DISTINCT 和课程总数的一致性 SELECT s.sname FROM student s JOIN sc ON sc.sno = s.sno GROUP BY s.sno, s.sname HAVING COUNT(DISTINCT sc.cno) = (SELECT COUNT(*) FROM course);

这里必须用COUNT(DISTINCT sc.cno)。如果sc允许同一学生对同一课程出现多条记录(比如重修),不加DISTINCT就会算多,把只选了两门课的学生误判成全选。另外这个写法依赖sc.cno的取值全部落在course.cno里,如果题库数据里存在「选了但课程表里没有」的脏数据,结果会偏。

第三种是照着关系代数逐句翻译,用EXCEPT实现差集:

-- 写法三:差集直译,逻辑最贴近关系代数 SELECT DISTINCT sno FROM sc EXCEPT SELECT sno FROM ( SELECT s.sno, c.cno FROM (SELECT DISTINCT sno FROM sc) s CROSS JOIN course c EXCEPT SELECT sno, cno FROM sc );

三段EXCEPT从左到右对应表达式里的三个减号,括号不能省——EXCEPT是左结合的,漏了括号语义就变了。这种写法的好处是你可以拿它去验证前两种写法:三种写法在同一份数据上跑出不同结果,说明你对题意的理解有分歧,而不是 SQL 写错了。

2.4 分组、空值与「平均分」类题的分歧点

分组聚合的题看着简单,实际是答案分歧的重灾区。典型题面:检索平均成绩高于 80 分的课程号和平均成绩。多数人第一反应是:

-- 注意 WHERE 和 HAVING 的分工 SELECT cno, AVG(grade) AS avg_grade FROM sc WHERE grade IS NOT NULL -- 显式排除未出分记录 GROUP BY cno HAVING AVG(grade) > 80 ORDER BY avg_grade DESC;

三个地方值得停下来想。第一,AVG会自动忽略 NULL,所以WHERE grade IS NOT NULL在数学上不改变结果,但它把「分母是什么」这件事写明白了——如果题目本意是「未出分按 0 分计」,那就得写AVG(COALESCE(grade, 0)),这是两种完全不同的评分口径。第二,WHERE在分组前过滤行,HAVING在分组后过滤组,把条件写反了 SQLite 不一定报错但结果一定错。第三,ORDER BY里用别名avg_grade各数据库支持程度不同,写完整表达式AVG(grade)最保险。

还有一类题会问「没选任何课的学生」。用NOT IN写要小心子查询里的 NULL:

-- 子查询里一旦出现 NULL,NOT IN 会整体返回 UNKNOWN SELECT sno FROM student WHERE sno NOT IN (SELECT sno FROM sc WHERE sno IS NOT NULL);

sc.sno有主键约束一般不会为 NULL,但如果是从别的表左连接出来的中间结果,就可能带 NULL。这种情况下NOT IN一条记录都返回不了,换成NOT EXISTS就不会有这个问题。这个坑在课后题里不一定考,但在真实库上排查「报表突然空了」时经常撞见。

3. 范式与函数依赖:用闭包算法给课后题验算

3.1 手算候选码为什么总漏

范式题的典型问法:给定关系模式R(U, F),求候选码、判断属于第几范式、给出分解。手算候选码的通用做法是先把属性分四类——只在左边出现的(L 类)、只在右边出现的(R 类)、两边都出现的(LR 类)、两边都不出现的(N 类)。L 类和 N 类一定属于每个候选码,R 类一定不属于任何候选码,剩下 LR 类才需要逐个试。

问题出在「逐个试」这一步。属性一多,LR 类的组合数就上去了,人脑试到第三第四个就开始乱,漏掉一个候选码,后面判断范式、做分解全部跟着错。更麻烦的是这种错误自己检查不出来——你只会觉得自己算的那几个是对的,不会意识到还有别的。

所以这一类题我基本不手算,全部交给脚本。思路很直白:枚举属性的所有子集,对每个子集求闭包,闭包等于全属性集的就是超码,再去掉包含其他候选码的那些,剩下的就是全部候选码。属性个数在 10 以内,2^10 = 1024次闭包计算,瞬间出结果。

3.2 属性闭包求候选码:一段二十行的脚本

先写两个基础函数:

from itertools import combinations def closure(attrs, fds): """求属性集 attrs 在函数依赖集 fds 下的闭包 X+""" result = set(attrs) changed = True while changed: changed = False for X, Y in fds: # 只要 X 被当前结果集包含,就把 Y 并进来 if set(X) <= result and not set(Y) <= result: result |= set(Y) changed = True return result def candidate_keys(all_attrs, fds): """枚举所有子集求候选码,并去掉非极小者""" all_attrs = list(all_attrs) found = [] for k in range(1, len(all_attrs) + 1): for combo in combinations(all_attrs, k): if closure(combo, fds) == set(all_attrs): found.append(frozenset(combo)) # 抠掉包含其他候选码的超集,只留极小集 return [k for k in found if not any(o < k for o in found)]

closure用的是一个朴素的迭代不动点算法:每一轮扫一遍所有函数依赖,能推就推,直到某一轮推不出新属性为止。写成while changed而不是固定循环次数,是因为依赖链的长度事先不知道,可能三四轮,也可能十几轮。这里的复杂度是O(|F| × 迭代轮数),题目规模下完全够用。

candidate_keys里的o < k是frozenset的真子集判断,利用这一点去掉所有「包含某个候选码」的超集。这一步不能省,否则求出来的会是一堆超码,候选码混在里面。

拿两道该版教材风格的题验一下:

# 题一:R(A,B,C,D),F = {A→B, B→C, AC→D} fds1 = [({"A"}, {"B"}), ({"B"}, {"C"}), ({"A", "C"}, {"D"})] print(candidate_keys({"A", "B", "C", "D"}, fds1)) # 输出 [frozenset({'A'})] # 题二:R(S,T,J),F = {SJ→T, ST→J, T→J} fds2 = [({"S", "J"}, {"T"}), ({"S", "T"}, {"J"}), ({"T"}, {"J"})] print(candidate_keys({"S", "T", "J"}, fds2)) # 输出 [frozenset({'S', 'J'}), frozenset({'S', 'T'})]

题一只有一个候选码 A:A 能推出 B,B 推出 C,凑齐 AC 后又推出 D,闭包直接打到全集。题二有两个候选码,SJ 和 ST 各自能推出全部属性,而单独 T 只能推出 J,凑不齐 S。如果手算时只找到了 SJ 就收笔,后面判断范式会得出「主属性是 S、J,T 是非主属性」的结论,而实际 T 也是主属性,范式等级直接判错。

参数上唯一需要留意的是属性集合的写法:我用set存属性,因为闭包运算全是集合操作,用列表还要处理去重。如果题目的属性名有重名风险(比如都用 A1、A2 这种),直接用字符串也没问题。

3.3 无损连接:二分解用定理,三分解以上用 Chase 表

无损连接判定的题分两类。分解成两个子关系时,有一个现成的充要条件:R分解为R1、R2,若R1 ∩ R2 → R1或R1 ∩ R2 → R2成立,则分解无损。

def is_lossless_binary(r1, r2, fds): """二分解的无损连接判定:公共属性能否函数决定其中一个子关系""" common = set(r1) & set(r2) if not common: return False # 公共属性为空,必然是笛卡尔积 return set(r1) <= closure(common, fds) or set(r2) <= closure(common, fds)

common为空时直接返回 False 是个容易被忽略的边界:两个子关系没有交集,连接结果就是笛卡尔积,元组数一定膨胀,无损无从谈起。这个判断放在函数最前面,避免closure对空集做无意义的计算。

三分解及以上就不能用这条定理了,得用 Chase 表(追逐法)。原理是构造一张初始表,行对应各个子关系,列对应全部属性;如果属性属于该子关系就填一个a变量,否则填b变量。然后对每个函数依赖X→Y,把X列取值相同的行在Y列上统一;反复迭代到不动点。最后如果某一行全是a变量,分解就是无损的。

def is_lossless_chase(all_attrs, decomp, fds): """Chase 表判定无损连接,支持任意元数的分解""" all_attrs = list(all_attrs) # 初始表:a 变量标记属性归属,b 变量用 (行号, 属性) 唯一标识 T = [] for i, ri in enumerate(decomp): row = {} for a in all_attrs: row[a] = ("a", a) if a in ri else ("b", i, a) T.append(row) changed = True while changed: changed = False for X, Y in fds: groups = {} for i, row in enumerate(T): key = tuple(row[a] for a in X) groups.setdefault(key, []).append(i) for idxs in groups.values(): if len(idxs) < 2: continue for y in Y: vals = [T[i][y] for i in idxs] # 优先统一成 a 变量,没有 a 则统一成字典序最小的 b target = next((v for v in vals if v[0] == "a"), min(vals)) for i in idxs: if T[i][y] != target: T[i][y] = target changed = True return any(all(v[0] == "a" for v in row.values()) for row in T)

这段代码有两个地方值得说清楚。一是groups的键用tuple(row[a] for a in X),如果X的属性顺序在两次调用间不一致,键就对不上,所以fds里的X最好固定成列表或元组,别用set。二是target的选择:优先挑a变量,是为了让「已经确定属于某子关系的属性」向下传播;如果没有a变量,取字典序最小的b变量,这一步只是为了让各行取值一致,选哪个都不影响最终判定,但必须选得确定,否则循环永远不收敛。

跑一个教材里的典型三分解:

attrs = {"A", "B", "C", "D", "E"} fds = [({"A"}, {"B"}), ({"B"}, {"C"}), ({"C"}, {"D"}), ({"D"}, {"E"})] decomp = [{"A", "B"}, {"B", "C"}, {"C", "D", "E"}] print(is_lossless_chase(attrs, decomp, fds)) # True

这个分解的公共属性是 B 和 C,链式依赖一路传下去,Chase 表能收到一行全a,判定无损。

注意:Chase 表一定要迭代到不动点。手算时最常见的错误是改了两轮觉得「差不多了」就下结论,而实际还需要再传播一轮才能收到全a行。

3.4 依赖保持:投影之后还要回头验一遍

无损连接和依赖保持是两个独立性质,教材里经常分开考,但判断题一旦组合起来就容易混。3NF 分解能同时保证无损和依赖保持,BCNF 分解只保证无损,不保证依赖保持——这条结论背下来容易,真让你判断一个具体分解保持了哪些依赖,还是得算。

做法是先求F在每个子关系上的投影,再把所有投影并起来得到F',最后检查F里每个函数依赖是否都被F'逻辑蕴含。

def project_fds(fds, sub): """求函数依赖集 F 在子关系模式 sub 上的投影 π_sub(F)""" out = [] for X, Y in fds: z = (set(X) | set(Y)) & set(sub) x_in = set(X) & set(sub) if x_in and z - x_in: # 左边非空且右边有新增属性 out.append((x_in, z - x_in)) return out def preserves_dependency(all_attrs, fds, decomp): """检查分解是否保持函数依赖""" projected = [] for sub in decomp: projected.extend(project_fds(fds, sub)) for X, Y in fds: if not set(Y) <= closure(set(X), projected): return False, (X, Y) # 返回第一个丢失的依赖,方便定位 return True, None

project_fds里的z - x_in判断不能省:如果某个依赖的属性全落在子关系内部,投影出来会得到一个右边为空的依赖,这种依赖没有意义,留着会干扰后续闭包计算。preserves_dependency返回丢失的那个依赖而不是单纯的布尔值,是为了做题时能直接写进答案——「分解 ρ 丢失了函数依赖 A→B」比「不保持」有说服力得多。

拿 3.3 的例子继续跑,decomp = [{A,B}, {B,C}, {C,D,E}],F = {A→B, B→C, C→D, D→E}。投影结果是{A→B}、{B→C}、{C→D, C→E},注意最后一项:C→D和D→E合起来在子关系上投影出了C→E。回头检查原依赖,D→E在投影集里找不到,只能由C→D和C→E推出来——而C→E本身又是投影的产物,这里就出现了语义上的等价,需要靠闭包验证而不是肉眼判断。跑一遍脚本,返回(True, None),说明这个分解确实保持了依赖。

4. 事务与并发调度题:把冲突序列画成有向图

4.1 冲突可串行化的三个条件

并发控制那一章的习题,通常给一串操作序列,问该调度是否冲突可串行化、是否满足两段锁协议、是否存在死锁。判断冲突可串行化的标准方法是构造优先图:把每个事务当成一个节点,若事务 Ti 的某个操作与 Tj 的某个操作满足下面三条,就从 Ti 连一条边到 Tj。

三条同时成立才算冲突:两个操作属于不同事务;两个操作访问同一数据项;两个操作中至少有一个是写。少了任何一条都不构成冲突。很多人做题时只看「同一数据项」就下结论,把两个读操作也连成边,图里凭空多出环,把可串行化的调度误判成不可串行化。

举个具体例子。调度T1:R(A) → T2:R(A) → T1:W(A) → T2:W(A)是不可串行化的:T1:R(A)与T2:W(A)冲突,边 T1→T2;T2:R(A)与T1:W(A)冲突,边 T2→T1;两条边构成环。而T1:R(A) → T1:W(A) → T2:R(A) → T2:W(A)只有 T1→T2 一条边,拓扑序唯一,是冲突可串行化的,等价于串行执行 T1 再 T2。

写操作的那条约束还带来一个推论:只读事务之间永远不冲突。这一点在做题时可以用来快速排除——如果调度里有两个事务全是读,它们之间不可能有边。

4.2 用优先图加拓扑排序跑一遍调度题

把上面的判定写成代码,输入是一个操作列表,输出是优先图的边集和判定结果:

def conflict_edges(schedule): """构造冲突等价意义下的优先图边集""" edges = set() for i in range(len(schedule)): t1, op1, item1 = schedule[i] for j in range(i + 1, len(schedule)): t2, op2, item2 = schedule[j] if t1 == t2: # 同一事务不构成冲突 continue if item1 != item2: # 访问不同数据项不冲突 continue if op1 == "W" or op2 == "W": # 至少一个是写才冲突 edges.add((t1, t2)) return edges def has_cycle(nodes, edges): """三色标记法检测有向环""" graph = {n: set() for n in nodes} for a, b in edges: graph[a].add(b) color = {n: 0 for n in nodes} # 0 未访问 1 在栈上 2 已完成 def dfs(u): color[u] = 1 for v in graph[u]: if color[v] == 1: return True # 回到栈上的节点,成环 if color[v] == 0 and dfs(v): return True color[u] = 2 return False return any(color[n] == 0 and dfs(n) for n in nodes)

conflict_edges是 O(n²) 的暴力比较,调度题通常十来个操作,够用。真要对长日志做分析,可以按数据项建索引,把同一数据项的操作挑出来再比,能降到接近线性。has_cycle用三色标记而不是拓扑排序,是因为它还顺带能告诉你环在哪——把dfs里碰到color[v] == 1时的路径打印出来,就是一条具体的环路,写答案时直接引用。

跑两组调度做对照:

s1 = [("T1", "R", "A"), ("T2", "R", "A"), ("T1", "W", "A"), ("T2", "W", "A")] s2 = [("T1", "R", "A"), ("T1", "W", "A"), ("T2", "R", "A"), ("T2", "W", "A")] for s in (s1, s2): e = conflict_edges(s) print(e, has_cycle({"T1", "T2"}, e)) # s1: {('T1','T2'), ('T2','T1')} True -> 不可串行化 # s2: {('T1','T2')} False -> 可串行化,串行序 T1→T2

输出和手算一致,就可以继续加事务数。三个以上事务的调度题手算很容易漏边,脚本跑一遍再对照答案,错的到底是自己还是参考答案就清楚了。

4.3 两段锁协议题的对照检查表

两段锁协议的判定和冲突可串行化是两套体系,题目经常放在一起考。2PL 的要求只有一句:事务的加锁阶段不能出现在解锁阶段之后,即「先只加锁,后只解锁」。严格 2PL 更进一步,要求所有排他锁持有到事务结束。

判定时的做法是把调度按事务拆开,每个事务单独看它的加锁解锁序列。我用一张对照表来检查:

检查项通过条件常见误判
加锁阶段完整事务开始到首次解锁之间,只出现 Lock把首次 Lock 前的读操作当作合法
解锁阶段无回退首次 Unlock 之后不再出现 Lock漏看中间夹着的读操作
锁类型匹配写操作前必须持排他锁共享锁直接写数据
严格性排他锁持有到 Commit/Abort提前释放后仍继续写
死锁可能优先图无环;2PL 本身不防死锁认为满足 2PL 就不会死锁

最后一行是重点:2PL 保证的是可串行化,不保证无死锁。教材里常见的一道题是「给出一个满足 2PL 但发生死锁的调度」,答案就是两个事务以相反顺序申请同一组数据项的锁。做题时如果看到「该调度满足两段锁协议,因此不会死锁」这样的表述,可以直接判定为错。

提示:判断 2PL 时先按事务分组,逐个事务画锁的时序线,比在全局调度表上逐行扫描快得多,也不容易看串行。

5. 对答案时最容易翻车的五个地方

5.1 语义层面的三个坑

坑一:除法题把「一门课没选」和「没选全」混为一谈。

现象是同一道「选修了全部课程的学生」题,三种写法跑出三个不同的结果集,其中一种把 S4 也带出来了。原因在于JOIN sc这一步——内连接会把没有任何选课记录的学生直接过滤掉,于是计数比较的写法永远返回不了 S4,而双重NOT EXISTS的写法则会把 S4 包含进去(因为在它看来,S4 确实「不存在没选的课」这个论断在课程表为空的边界下是成立的)。解决方式是在做题前先明确题意:课程表非空时两种写法等价,一旦允许空选课或空课程表,就必须按教材的定义走,通常教材的除法定义会把 S4 排除在外,那就得在计数写法里补一个WHERE EXISTS守卫。

坑二:把 NULL 当 0 参与聚合。

现象是「平均分」算出来总是偏高。原因是AVG(grade)忽略 NULL,分母只算有成绩的记录,而如果题目的口径是「未出分按 0 计」,正确答案会明显低一截。解决方式是先确认题目对 NULL 的约定,再决定写AVG(grade)还是AVG(COALESCE(grade, 0))。这一条在教材答案里常常不写明,属于「答案本身有歧义」,遇到时把两种口径的结果都写出来并注明假设,比硬选一个更稳妥。

坑三:范式等级判反。

现象是明明分解到 BCNF 了,答案却说「不满足 3NF」。原因是把「保持依赖」错当成 3NF 的必要条件。3NF 的定义只要求每个非主属性不部分依赖、不传递依赖于候选码,并不要求分解保持依赖;保持依赖是分解算法(3NF 合成法)的性质,不是范式的定义。解决办法是判断范式时严格按定义走:先求候选码,划出主属性和非主属性,再逐个检查函数依赖。别把分解算法的结论倒推回范式判断。

5.2 结构与判定层面的两个坑

坑四:关系代数写出重复元组。

现象是投影之后结果里出现了重复行,和 SQL 的结果对不上。原因是关系代数里投影默认去重(关系是集合),而 SQL 的SELECT默认保留重复(多重集语义)。解决方式是手写关系代数时明确标注是否去重,写 SQL 时在需要对齐关系语义的地方补DISTINCT。这一条在除法题里格外明显,因为除法的定义本身就建立在集合语义上。

坑五:Chase 表没迭代到不动点,或者把二分解定理套到三分解上。

现象是判定结果和参考答案相反,而且反复检查也看不出错在哪。原因有两类:一类是 Chase 表只跑了一两轮就下结论,依赖链还没传播完;另一类是把「公共属性决定其中一个子关系」的二分解定理直接用到三元分解上,而这个定理对三元以上不成立。解决方式是代码里把while changed循环写死,判定理适用范围时先数一下子关系个数,超过两个一律走 Chase。手算时在草稿纸上标好每一轮的表格状态,能看出到底卡在第几轮。

6. 把整本习题做成回归测试:一个能长期用的自检习惯

做到后面会发现,真正麻烦的不是某一道题不会,而是改了一道题的答案之后,前面几道题的结果跟着变了,自己却没察觉。这时候一个最朴素的做法特别管用:把每道题的标准答案写成断言,攒成一个测试文件,每次改完 SQL 跑一遍。

# test_answers.py import sqlite3 import pytest def setup_db(): conn = sqlite3.connect(":memory:") conn.executescript(open("schema.sql", encoding="utf-8").read()) # 灌入固定测试数据,与 seed.py 保持一致 conn.executemany("INSERT INTO student VALUES (?,?,?,?)", [("S1", "甲", "计算机系", 20), ("S4", "丁", "软件系", 20)]) conn.executemany("INSERT INTO course VALUES (?,?,?,?)", [("C1", "数据库", 4.0, "计算机系")]) conn.executemany("INSERT INTO sc VALUES (?,?,?)", [("S1", "C1", 92)]) conn.commit() return conn @pytest.mark.parametrize("sql, expected", [ ("SELECT sname FROM student WHERE NOT EXISTS " "(SELECT 1 FROM course c WHERE NOT EXISTS " "(SELECT 1 FROM sc WHERE sc.sno = student.sno AND sc.cno = c.cno))", [("甲",)]), ]) def test_all_courses(sql, expected): conn = setup_db() assert conn.execute(sql).fetchall() == expected

这套东西的价值不在测试框架本身,而在于强制你把「答案」从一段手写的文字变成一条可执行的判定。写断言的过程中,很多模糊的地方会自动暴露:结果列的顺序要不要固定、NULL 算不算命中、边界数据要不要单独造。这些在纸面上可以糊弄过去,写成断言就糊弄不了。

还有个具体技巧值得一试:把查询题对应的关系代数表达式翻译成 SQL 之后,用EXPLAIN QUERY PLAN看一眼执行计划,对照你在关系代数里推的连接顺序。

EXPLAIN QUERY PLAN SELECT s.sname FROM student s JOIN sc ON sc.sno = s.sno WHERE sc.grade > 90;

输出里会告诉你哪张表先扫、用了哪个索引、是嵌套循环还是哈希连接。初学者容易以为「我写的连接顺序就是执行顺序」,实际优化器会重排。看几次之后你对「逻辑顺序」和「物理顺序」的区别会有直观感受,这个感受在做查询优化题时很有用。

我自己踩过最狠的一次,是把一套范式题的判定脚本写完就丢在一边,过了两个月再回来做同一批题,忘了当时对「候选码要不要包含 N 类属性」这个细节的处理,结果两版答案对不上,还得从头翻推导过程。后来养成习惯:每个脚本开头用注释写清三条——输入是什么、关键假设是什么、和教材定义对齐的点在哪。这三行注释比代码本身更值钱,因为它记录的是当时为什么这么写,而不是写了什么。数据库这门课的习题不难,难的是每个结论都要能自己验一遍,验完之后还能在半年后看懂自己当初为什么那么验。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/27 10:45:25

抽奖免首单技术方案:概率、库存、幂等与风控对账

抽奖类营销活动的技术实现&#xff0c;说到底是三件事的平衡&#xff1a;预算有限、概率可查、并发不可控。最近在梳理一套“抽奖免首单”的玩法落地时&#xff0c;反复卡在超发、重复中奖、免单券被拆单套利这几个环节&#xff0c;网上资料大多停留在业务描述层面&#xff0c;…

作者头像 李华
网站建设 2026/10/27 10:45:11

CAFR跨光谱注意力特征精修:即插即用RGB-IR检测工程实践

做跨光谱目标检测的读者大概率遇到过这种局面&#xff1a;白天 RGB 图像里纹理清楚、边界锐利&#xff0c;一到夜间或者强逆光&#xff0c;可见光分支几乎交白卷&#xff1b;红外图像能把人从黑背景里"烧"出来&#xff0c;但轮廓糊、细节少、没有颜色和纹理。把两路特…

作者头像 李华
网站建设 2026/10/27 10:44:59

带长期记忆的AI角色扮演系统本地部署与验证

“渡妄间&#xff5c;我们做个约定吧”——第一次看到这个标题&#xff0c;容易以为是某个小说或者剧情企划&#xff0c;但把它放回技术语境里看&#xff0c;它指向的是一类很具体的东西&#xff1a; 带长期记忆的 AI 角色扮演与互动叙事系统 。项目名“渡妄间”是角色所在的…

作者头像 李华
网站建设 2026/10/27 10:43:29

ESP32模组选型指南:读懂料号中的N、R、H、U

1. 从一次选型翻车说起&#xff1a;为什么料号必须读懂第一次批量采购 ESP32 模组的时候&#xff0c;我吃过一次不大不小的亏。当时项目需求很明确&#xff1a;需要 8MB Flash、带外部天线接口、工业级温度范围。供应商发来的报价单上写着一串型号&#xff0c;我扫了一眼觉得差…

作者头像 李华
网站建设 2026/10/27 10:42:54

R语言钻石价格回归分析:从数据清洗到模型诊断的完整实战

简介&#xff1a;围绕钻石价格影响因素展开的R语言数据分析实践文档&#xff0c;面向具备一定R基础、希望将回归分析与可视化结合成一个完整项目的数据分析学习者和统计课程使用者。内容基于ggplot2自带的diamonds数据集&#xff0c;覆盖数据整体信息查看、缺失值与重复值检查、…

作者头像 李华
网站建设 2026/10/27 10:41:56

编译原理真题高频考点与DFA/LL(1)解题思维拆解

简介&#xff1a;本资源为常州工学院《编译原理》课程期末考试A卷真题试卷&#xff0c;面向计算机专业本科生及考研备考学生&#xff0c;聚焦词法分析、语法分析与中间代码生成等核心能力训练。试卷覆盖正规表达式构造与最简DFA设计、逆波兰表示转换、文法二义性判定与语言描述…

作者头像 李华