一道褒贬不一的 SQL 考试题

《一道褒贬不一的 SQL 考试题》
playyuer 命题,playyuer、ccat 撰稿

相信有不少网友最近在网上见过一则名为《一道褒贬不一的 SQL 考试题》的帖子,这份
试题应当说还是有很多值得思考的地方。有兴趣的读者可以在阅读本文的答案分析部分
之前,尝试着作一下,如果你可以顺利地完成,至少说明你是一个有经验的 SQL 使用者。
下面我们先看一下题设:
二维表 T(F1,F2,F3,F4,F5,F6,F7) 表示如下关系:
┌──────────┬──────────┬──────────┬──────────┬──────────┬──────────┬──────────┐
│ 学生ID │ 学生姓名 │ 课程ID │ 课程名称 │ 成绩 │ 教师ID │ 教师姓名 │
├──────────┼──────────┼──────────┼──────────┼──────────┼──────────┼──────────┤
│ S3 │ 王五 │ K4 │ 政治 │ 53 │ T4 │ 赵老师 │
├──────────┼──────────┼──────────┼──────────┼──────────┼──────────┼──────────┤
│ S1 │ 张三 │ K1 │ 数学 │ 61 │ T1 │ 张老师 │
├──────────┼──────────┼──────────┼──────────┼──────────┼──────────┼──────────┤
│ S2 │ 李四 │ K3 │ 英语 │ 88 │ T3 │ 李老师 │
├──────────┼──────────┼──────────┼──────────┼──────────┼──────────┼──────────┤
│ S1 │ 张三 │ K4 │ 政治 │ 77 │ T4 │ 赵老师 │
├──────────┼──────────┼──────────┼──────────┼──────────┼──────────┼──────────┤
│ S2 │ 李四 │ K4 │ 政治 │ 67 │ T5 │ 周老师 │
├──────────┼──────────┼──────────┼──────────┼──────────┼──────────┼──────────┤
│ S3 │ 王五 │ K2 │ 语文 │ 90 │ T2 │ 王老师 │
├──────────┼──────────┼──────────┼──────────┼──────────┼──────────┼──────────┤
│ S3 │ 王五 │ K1 │ 数学 │ 55 │ T1 │ 张老师 │
├──────────┼──────────┼──────────┼──────────┼──────────┼──────────┼──────────┤
│ S1 │ 张三 │ K2 │ 语文 │ 81 │ T2 │ 王老师 │
├──────────┼──────────┼──────────┼──────────┼──────────┼──────────┼──────────┤
│ S4 │ 赵六 │ K2 │ 语文 │ 59 │ T1 │ 王老师 │
├──────────┼──────────┼──────────┼──────────┼──────────┼──────────┼──────────┤
│ S1 │ 张三 │ K3 │ 英语 │ 37 │ T3 │ 李老师 │
├──────────┼──────────┼──────────┼──────────┼──────────┼──────────┼──────────┤
│ S2 │ 李四 │ K1 │ 数学 │ 81 │ T1 │ 张老师 │
├──────────┼──────────┼──────────┼──────────┼──────────┼──────────┼──────────┤
│ .... │ │ │ │ │ │ │
├──────────┼──────────┼──────────┼──────────┼──────────┼──────────┼──────────┤
│ .... │ │ │ │ │ │ │
└──────────┴──────────┴──────────┴──────────┴──────────┴──────────┴──────────┘
为便于大家更好的理解,我们将 T 表起名为"成绩表"

1.如果 T 表还有一字段 F 数据类型为自动增量整型(唯一,不会重复),
而且 T 表中含有除 F 字段外,请删除其它字段完全相同的重复多余的脏记录数据:

本问题就是一个清理"逻辑重复"记录的问题,当然,这种情况完全可以利用主键约束来
杜绝!然而,现实情况经常是原始数据在"洗涤"后,方可安全使用,而且逻辑主键过早的
约束,将会给采集原始数据带来不便,例如:从刷卡机上读取考勤记录。到了应用数据
的时候,脏数据就该被扫地出门了! 之所以题中要保留一个自动标识列,是因为它的确
是下面答案所必须的前提:

DELETE L
FROM "成绩表" L
JOIN "成绩表" R
ON L."学生ID" = R."学生ID" AND L."课程ID" = R."课程ID" AND L.F > R.F

这是思路最精巧且最直接有效的方法之一。用不等自联接,正好可以将同一组重复数
据中 F 字段值最小的那一条留下,并选出其它的删掉,如果只有一条,自然也不会被选
中了。这里还要强调一下,大家一定要分清楚被操作的基本表也就是 DELETE 关键字
后的表和过滤条件所使用的由基本表连接而成的二维表数据集,也就是 FROM 子句的
全部。在自连接的 FROM 子句至少要取一个别名来引用基本表。别名的使用在编写大
量类似结构的 SQL 时非常方便,而且利于统一程序构造动态 SQL。如有必要加强条件,
还可继续使用 WHERE 子句。如果上面的例子还不够直观,下面模仿一个不等自联接,
有一组数 (1,2,3),作一个不等自联接,令左子集大于右子集,是:
2 1
3 1
3 2
如果现在选出左子集,就是 2 和 3 了。1 在右边没有比它更小的数据可以与之匹配,
因此被过滤了。如果数据大量重复,效率会差强人意,幸亏不是 SELECT ,而是 DELETE
无需返回结果集,影响自然小多了。

DELETE T
FROM 成绩表 T
WHERE F NOT IN (SELECT MIN(F)
FROM 成绩表 I
GROUP BY I.学生ID,I.课程ID
HAVING COUNT()>1
)
AND F NOT IN (SELECT MIN(F)
FROM 成绩表 I
GROUP BY I.学生ID, I.课程ID
HAVING COUNT(
)=1
)

这种方法思路很简单,就像翻译自然语言,很精确地描述了符合条件记录的特性,甚至
第二个条件的确多余。至少应该用一个 >= 号合并这两个条件或只保留任意一个条件,
提高效率。

DELETE T
FROM 成绩表 T
WHERE F > (SELECT MIN(F)
FROM 成绩表 AS I
WHERE I.学生ID = T.学生ID
AND I.课程ID = T.课程ID
GROUP BY I.学生ID, I.课程ID
)

这种方法,基本上是方法一的相关子查询版本,了解笛卡尔积的读者能会好理解些,而
且用到了统计函数,因此效率不是太高。细心的读者会发现子查询里的 GROUP BY 子
句没有必要,去掉它应该会提高一些效率的。

关于 DELETE 语句的调试,有经验的程序员都会先用无害的 SELECT 暂时代替危险的
DELETE。例如:

SELECT L.*
--DELECT L 暂时注释掉
FROM "成绩表" L
JOIN "成绩表" R
ON L."学生ID" = R."学生ID" AND L."课程ID" = R."课程ID" AND L.F>R.F

这样,极大地减小了在线数据被无意破坏的可能性,当然数据提前备份也很重要。同理
UPDATE 和 INSERT 写操作也应照此行事。从数据库原理的关系运算的角度来看 INSERT、
UPDATE 和 DELETE 这些写操作都属于典型的"选择(Selection)"运算,UPDATE 和 INSERT
而且还是"投影(Projection)"运算,它们都是这些关系运算的"写"应用的表现形式。
其实,查询的目的也本来无非就是浏览、删除、更
新或插入。通常写操作也比读操作消耗更大,如果索引过多,只会降低效率。

选择"子查询"还是"连接"在效率是有差别的,但最关键的差别还是表现在查询的结果
集的读写性上,开发人员在写一个"只读"应用的查询记录集时,"子查询"和"连接"各自
的效率就是应该首先考虑的问题,但是如果要实现"可写"应用的查询结果集,则无论是
相关还是非相关子查询都是在复杂应用中难以避免的。

以上解决方案中,应该说第一种方法,简洁有效,很有创意,是值得推荐的方法。当然,
最简单的写法应该是:

DELETE T
FROM T,T T1
WHERE T.学生ID=T1.学生ID and T.课程ID=T.课程ID and T.F < T1.F

其实这就是方法一的"标准"(但确实实不是《ANSI/ISO SQL》标准)连接写法,以下各
题答案为了便于读者理解,一般不采用这种写法,这也是《ANSI/ISO SQL》标准所鼓
励的,JOIN 确实更容易地表达表之间的关系,有兴趣的读者可自行改写。如果使用
"*="实现两表以上的外连接时,要注意此时 WHERE 子句的 AND 条件是有顺序的,尽
管《ANSI/ISO SQL》标准不允许 WHERE 条件的顺序影响查询结果,但是 FROM 子句
的各表连接的顺序可以影响查询结果。

2.列印各科成绩最高和最低的相关记录: (就是各门课程的最高、最低分的学生和老师)
课程ID,课程名称,最高分,学生ID,学生姓名,教师ID,教师姓名,最低分,学生ID,学生姓名,教师ID,教师姓名

如果这道题要是仅仅求出各科成绩最高分或最低分,则是一道非常简单的题了:

SELECT L.课程ID, MAX(L.课程名称), MAX(L.成绩) AS 最高分, MIN(L.成绩) AS 最低分
FROM 成绩表 L
GROUP BY L.课程ID

但是,刁钻的题目却是要列出各科最高和最低成绩的相关记录,这也往往才是真正需求。
既然已经选出各科最高和最低分,那么,剩下的就是把学生和教师的信息并入这个结果
集。如果照这样写下去,非常麻烦,因为要添加的字段太多了,很快就使代码变得难于
管理。还是换个思路吧:

SELECT L.课程ID,L.课程名称,L.[成绩] AS 最高分,L.[学生ID],L.[学生姓名],L.[教师ID],L.[教师姓名]
,R.[成绩] AS 最低分,R.[学生ID],R.[学生姓名],R.[教师ID],R.[教师姓名]
FROM 成绩表 L
JOIN 成绩表 AS R ON L.[课程ID] = R.[课程ID]
WHERE L.[成绩] = (SELECT MAX(IL.[成绩])
FROM 成绩表 AS [IL]
WHERE L.[课程ID] = IL.[课程ID]
GROUP BY IL.[课程ID]
)
AND
R.[成绩] = (SELECT MIN(IR.[成绩])
FROM 成绩表 AS [IR]
WHERE R.[课程ID] = IR.[课程ID]
GROUP BY IR.[课程ID]
)

乍一看答案,好像很复杂,其实如果掌握了构造交叉透视表的基本方法和相关子查询的
知识,问题迎刃而解。由于最低和最高分都是针对课程信息的,该答案巧妙地把课程信
息合并到了最高分的数据集中,当然也可以合并到最低分中。代码中规中矩,风格很好,
可读性也是不错的。

3.按平均成绩从高到低顺序,列印所有学生的四门(数学,语文,英语,政治)课程成绩: (就是每个学生的四门课程的成绩单)
学生ID,学生姓名,数学,语文,英语,政治,有效课程数,有效平均分
(注: 有效课程即在 T 表中有该学生的成绩记录,如不明白可不列印"有效课程数"和"有效平均分")

需要说明的是: 题目之所以明确提出"四门(数学,语文,英语,政治)课程"是有道理的,
因为实现时,的确无法避免使原基本表中的行上的数据的值影响列,这又是一个典型的
"行变列"的相关子查询:

SELECT 学生ID,MAX(学生姓名) AS 学生姓名
,(SELECT 成绩 FROM 成绩表 WHERE 学生ID=T.学生ID AND 课程ID='K1') AS 数学
,(SELECT 成绩 FROM 成绩表 WHERE 学生ID=T.学生ID AND 课程ID='K2') AS 语文
,(SELECT 成绩 FROM 成绩表 WHERE 学生ID=T.学生ID AND 课程ID='K3') AS 英语
,(SELECT 成绩 FROM 成绩表 WHERE 学生ID=T.学生ID AND 课程ID='K4') AS 政治
,COUNT(*) AS 有效课程数, AVG(T.成绩) AS 平均成绩
FROM 成绩表 AS T
GROUP BY 学生ID
ORDER BY 平均成绩

这可以说也是一个很规矩的解法,在这种应用场合,子查询要比联接代码可读性强得多。
如果数据库引擎认
为把它解析成联接更好,那就由它去吧,其实本来相关子查询也肯定含有连接。这里再补充一下,在实际应用
中如果再加一张表 Ranks(Rank,MinValue,MaxValue):
┌──────────┬──────────┬──────────┐
│ Rank │ MinValue │ MaxValue │
├──────────┼──────────┼──────────┤
│ A │ 90 │ 100 │
├──────────┼──────────┼──────────┤
│ B │ 89 │ 80 │
├──────────┼──────────┼──────────┤
│ C │ 79 │ 70 │
├──────────┼──────────┼──────────┤
│ D │ 69 │ 60 │
├──────────┼──────────┼──────────┤
│ E │ 60 │ 0 │
└──────────┴──────────┴──────────┘

就可以实现一个非常有实用价值的应用:

select 学生ID,MAX(学生姓名) as 学生姓名
,(select 成绩 from T where 学生ID=T0.学生ID and 课程ID='K1') as 数学
,(SELECT max(Rank)
from Ranks ,t
where t.成绩 >= Ranks.MinValue
and t.成绩 <= Ranks.MaxValue
and t.学生ID=T0.学生ID and t.课程ID='K1'
) as 数学级别
,(select 成绩 from T where 学生ID=T0.学生ID and 课程ID='K2') as 语文
,(SELECT min(Rank)
from Ranks ,t
where t.成绩 >= Ranks.MinValue
and t.成绩 <= Ranks.MaxValue
and t.学生ID=T0.学生ID and t.课程ID='K2'
) as 语文级别
,(select 成绩 from T where 学生ID=T0.学生ID and 课程ID='K3') as 英语
,(SELECT max(Rank)
from Ranks ,t
where t.成绩 >= Ranks.MinValue
and t.成绩 <= Ranks.MaxValue
and t.学生ID=T0.学生ID and t.课程ID='K3'
) as 英语级别
,(select 成绩 from T where 学生ID=T0.学生ID and 课程ID='K4') as 政治
,(SELECT min(Rank)
from Ranks ,t
where t.成绩 >= Ranks.MinValue
and t.成绩 <= Ranks.MaxValue
and t.学生ID=T0.学生ID and t.课程ID='K4'
) as 政治级别
,count(*),avg(t0.成绩)
,(SELECT max(Rank)
from Ranks
where AVG(T0.成绩) >= Ranks.MinValue
and AVG(T0.成绩) <= Ranks.MaxValue
) AS 平均级别
from T as T0
group by 学生ID

这里表面上使用了不等连接,再仔细想想,Ranks 表中每条记录的区间是没有交集的,
其实也可以认为是等值连接,这样的表设计无疑存在着良好的扩展性,如果题目只要求

列印(学生ID,学生姓名,有效课程数,有效平均分,平均分级别):

select 学生ID,MAX(学生姓名) as 学生姓名,count(*),avg(t0.成绩)
,(SELECT max(Rank)
from Ranks
where AVG(T0.成绩) >= Ranks.MinValue
and AVG(T0.成绩) <= Ranks.MaxValue
) AS 平均级别
from T as T0
group by 学生ID

则这样的解决方案就比较全面了。

回到原题,再介绍一个比较取巧的办法,仅需一个简单分组查询就可解决问题,有经验的读者可能已经想到了
,那就是 CASE:

SELECT 学生ID, MIN(学生姓名)
,SUM(CASE 课程ID WHEN 'K1' THEN 成绩 ELSE 0 END) AS 数学
,SUM(CASE 课程ID WHEN 'K2' THEN 成绩 ELSE 0 END) AS 语文
,SUM(CASE 课程ID WHEN 'K3' THEN 成绩 ELSE 0 END) AS 英语
,SUM(CASE 课程ID WHEN 'K4' THEN 成绩 ELSE 0 END) AS 政治
,COUNT(*) AS 有效课程数, AVG(T.成绩) AS 平均成绩
FROM 成绩表 AS T
GROUP BY 学生ID
ORDER BY 平均成绩 DESC

虽然可能初看答案感觉有点怪,其实很好理解,可读性并不低,效率也很高。但它不能
像前一个答案那样,在成绩中区分出某一门课这个学生究竟是缺考 (NULL),还是真得
零分。这个解法充分利用了 CASE 语句进行数据分类的作用: CASE 将成绩按课程分
成四类,SUM 用来消去多余的 0。

SELECT [T].[学生ID],MAX([T].[学生姓名]) AS 学生姓名
,MAX([T1].[成绩]) AS 数学,MAX([T2].[成绩]) AS 语文,MAX([T3].[成绩]) AS 英语,MAX([T4].[成绩]) AS 政治, COUNT([T].[课程ID]) AS 有效课程数
,(ISNULL(MAX([T1].[成绩]),0) + ISNULL(MAX([T2].[成绩]),0) + ISNULL(MAX([T3].[成绩]),0) + ISNULL(MAX([T4].[成绩]),0)) / COUNT([T].[课程ID]) AS 有效平均分
FROM 成绩表 T
LEFT JOIN 成绩表 AS [T1]
ON [T].[学生ID] = [T1].[学生ID] AND [T1].[课程ID] = 'K1'
LEFT JOIN 成绩表 AS [T2]
ON [T].[学生ID] = [T2].[学生ID] AND [T2].[课程ID] = 'K2'
LEFT JOIN 成绩表 AS [T3]
ON [T].[学生ID] = [T3].[学生ID] AND [T3].[课程ID] = 'K3'
LEFT JOIN 成绩表 AS [T4]
ON [T].[学生ID] = [T4].[学生ID] AND [T4].[课程ID] = 'K4'
GROUP BY [T].[学生ID]
ORDER BY 有效平均分 DESC

这个方法是相当正统的联接解法,尽管写起来麻烦了些,但还是不难理解的。再从实用
角度考虑一下,真实需求往往不是象本题明确提出"列印四门 (数学,语文,英语,政治)
课程"这样的相对静态的需求,该是动态 SQL 大显身手的时候了,很明显方法一的写法
无疑是利用程序构造动态 SQL 的最好选择,当然另两个 SQL 规律还是挺明显的,同样
不难构造。以 CASE 版答案为例: 先用一个游标遍历,取出所有课程凑成:
SUM(CASE '课程ID' WHEN '课程名称' THEN 成绩 ELSE 0 END) AS 课程名称 形式,
再补上 SELECT 和 FROM、WHERE 等必要条件,一个生成动态成绩单的 SQL 就诞生了,
只要再由相关程序调用执行即可,这样就可以算一个更完善的解决方案了。

其实,最类似的典型应用是在主、细关系中的主表投影中实现细表的汇总统计行,
例如两张表:
Master(F,f1,f2 ...) 一对多 Details(F,f3,f4 ...)
SELECT *
,( SELECT COUNT(*)
FROM Details
WHERE Master.F = Details.F
)
,( SELECT SUM(F3)
FROM Details
WHERE Master.F = Details.F
)
FROM Master

4.按各科不平均成绩从低到高和及格率的百分数从高到低顺序,统计并列印各科平均成绩和不及格率的百分数(用"N行"表示): (就是分析哪门课程难)
课程ID,课程名称,平均成绩,及格百分比

SELECT 课程ID,MAX(课程名称) AS 课程名称,AVG(成绩) AS 平均成绩
,100 * SUM(CASE WHEN 成绩 >=60 THEN 1 ELSE 0 END)/COUNT(*) AS 及格百分数
FROM 成绩表 T
GROUP BY 课程ID
ORDER BY 及格百分比 DESC

这道题应该说是算简单的了,就是用"行"来提供表现形式的。只要想明白要对数据如
何分组,取统计聚集函数,就万事大吉了。

5.列印四门课程平均成绩和及格率的百分数(用"1行4列"表示): (就是分析哪门课程难)
数学平均分,数学及格百分数,语文平均分,语文及格百分数,英语平均分,英语及格百分数,政治平均分,政治及格百分数

这道题其实就是上一题的"列"表现形式版本,相对于上一题,本题是静态的,因为本题
同第三题一样利用行上的数据构造了列,要实现扩展必须再利用另外的程序构造动态
SQL:

SELECT SUM(CASE WHEN 课程ID = 'K1' THEN 成绩 ELSE 0 END)/SUM(CASE 课程ID WHEN 'K1' THEN 1 ELSE 0 END) AS 数学平均分
,100 * SUM(CASE WHEN 课程ID = 'K1' AND 成绩 >= 60 THEN 1 ELSE 0 END)/SUM(CASE WHEN 课程ID = 'K1' THEN 1 ELSE 0 END) AS 数学及格百分数
,SUM(CASE WHEN 课程ID = 'K2' THEN 成绩 ELSE 0 END)/SUM(CASE 课程ID WHEN 'K2' THEN 1 ELSE 0 END) AS 语文平均分
,100 * SUM(CASE WHEN 课程ID = 'K2' AND 成绩 >= 60 THEN 1 ELSE 0 END)/SUM(CASE WHEN 课程ID = 'K2' THEN 1 ELSE 0 END) AS 语文及格百分数
,SUM(CASE WHEN 课程ID = 'K3' THEN 成绩 ELSE 0 END)/SUM(CASE 课程ID WHEN 'K3' THEN 1 ELSE 0 END) AS 英语平均分
,100 * SUM(CASE WHEN 课程ID = 'K3' AND 成绩 >= 60 THEN 1 ELSE 0 END)/SUM(CASE WHEN 课程ID = 'K3' THEN 1 ELSE 0 END) AS 英语及格百分数
,SUM(CASE WHEN 课程ID = 'K4' THEN 成绩 ELSE 0 END)/SUM(CASE 课程ID WHEN 'K4' THEN 1 ELSE 0 END) AS 政治平均分
,100 * SUM(CASE WHEN 课程ID = 'K4' AND 成绩 >= 60 THEN 1 ELSE 0 END)/SUM(CASE WHEN 课程ID = 'K4' THEN 1 ELSE 0 END) AS 政治及格百分数
FROM 成绩表 T

这一句看起来很长,但实际上是最经典的 CASE 运用,很实用的数据分析技术。先将原
表中的成绩一列连续投影 8 次备用于四门不同课程,充分利用 CASE 和数据的值域
['k1','k2','k3','k4']来划分数据,再利用 SUM() [1 + ...+ 1] 实现了看似本来应
该用 COUNT(*) 的计数器的功能,这里面不要说联接和子查询,甚至连 Group by 分组
的痕迹都找不到!如果读起来吃力,完全可以先只保留一个字段,相对好理解些,看懂后
逐一补全。本题也可以算一个"行变列"的交叉透视表示例吧! 另外,"行"相对于"列"
是动态的,"行"是相对无限的,"列"是相对有限的,"行"的增删是应用级的,可"随意"增
删,"列"的增删是管理级的,不要轻易变动!

6.按不同老师所教不同课程平均分从高到低列印: (就是分析哪个老师的哪个课程水平高)
教师ID,教师姓名,课程ID,课程名称,平均分

SELECT 教师ID,MAX(教师姓名) AS 教师姓名,课程ID,MAX(课程名称) AS 课程名称,AVG(成绩) AS 平均成绩
FROM 成绩表 T
GROUP BY 课程ID,教师ID
ORDER BY AVG(成绩) DESC

这道题的确没啥好说的,就算闭着眼,不动手,答案也应脱口而出!
如果平均分按去掉一个最高分和一个最低分后取得,则也不难写出:

SELECT 教师ID,MAX(教师姓名),课程ID,MAX(课程名称) AS 课程名称 --,AVG(成绩) AS 平均成绩
,(SUM(成绩)
-(SELECT MAX(成绩)
FROM 成绩表
WHERE 课程ID= T1.课程ID AND 教师ID = T1.教师ID)
-(SELECT MIN(成绩)
FROM 成绩表
WHERE 课程ID= T1.课程ID and 教师ID = T1.教师ID))
/ CAST((SELECT COUNT() -2
FROM 成绩表
WHERE 课程ID= T1.课程ID AND 教师ID = T1.教师ID) AS FLOAT) AS 平均分
FROM 成绩表 AS T1
WHERE (SELECT COUNT(
) -2
FROM 成绩表
WHERE 课程ID = T1.课程ID AND 教师ID = T1.教师ID) >0
GROUP BY 课程ID,教师ID
ORDER BY 平均分 DESC

7.列印数学成绩第 10 名到第 15 名的学生成绩单
或列印平均成绩第 10 名到第 15 名的学生成绩单
[学生ID],[学生姓名],数学,语文,英语,政治,平均成绩

如果只考虑一门课程,如:数学成绩,非常简单:
select Top 5 *
from T
where 课程id ='K1'
and 成绩 not in(select top 15 成绩
from T
order by 成绩 desc
)
order by 成绩 desc

select *
from T
where 课程id ='K1'
and 成绩 not in(select top 10 成绩
from T
order by 成绩 desc
)
and 成绩 in(select top 15 成绩
from T
order by 成绩 desc
)
order by 成绩 desc

从逻辑上说,第 10 名到第 15 名就是从原前 15 名,"再"挑出前 5 名不要,保留剩下
的 5 名。第二种写法是从前 15 名里挑出不属于原前 10 名的记录,把两个数据集做
一个差,因此要多用一个
子查询,效率相对较低,它,如果要有《ANSI/ISO SQL》的 EXCEPT
关键字就是最理想的了。

这种技巧在数据"分页"的应用中经常利用,只要遵循如下原则即可:

SELECT Top @PageSize *
FROM T
WHERE SortField NOT IN (SELECT TOP @PageSize * @Pagei SortField
FROM T
ORDER BY SortField
)
ORDER BY SortField

至此,该题考察的主要目的已经达到。至于列印明晰成绩单:
[学生ID],[学生姓名],数学,语文,英语,政治,平均成绩 前面也有类似的题目,做起来
确实麻烦,因此下面仅提供参考答案,就不赘述了:

SELECT DISTINCT top 5
[成绩表].[学生ID],
[成绩表].[学生姓名] AS 学生姓名,
[T1].[成绩] AS 数学,
[T2].[成绩] AS 语文,
[T3].[成绩] AS 英语,
[T4].[成绩] AS 政治,
ISNULL([T1].[成绩],0) + ISNULL([T2].[成绩],0) + ISNULL([T3].[成绩],0) + ISNULL([T4].[成绩],0) as 总分
FROM [成绩表]
LEFT JOIN [成绩表] AS [T1]
ON [成绩表].[学生ID] = [T1].[学生ID] AND [T1].[课程ID] = 'k1'
LEFT JOIN [成绩表] AS [T2]
ON [成绩表].[学生ID] = [T2].[学生ID] AND [T2].[课程ID] = 'k2'
LEFT JOIN [成绩表] AS [T3]
ON [成绩表].[学生ID] = [T3].[学生ID] AND [T3].[课程ID] = 'k3'
LEFT JOIN [成绩表] AS [T4]
ON [成绩表].[学生ID] = [T4].[学生ID] AND [T4].[课程ID] = 'k4'
WHERE ISNULL([T1].[成绩],0) + ISNULL([T2].[成绩],0) + ISNULL([T3].[成绩],0) + ISNULL([T4].[成绩],0)
NOT IN
(SELECT
DISTINCT
TOP 15 WITH TIES
ISNULL([T1].[成绩],0) + ISNULL([T2].[成绩],0) + ISNULL([T3].[成绩],0) + ISNULL([T4].[成绩],0)
FROM [成绩表]
LEFT JOIN [成绩表] AS [T1]
ON [成绩表].[学生ID] = [T1].[学生ID] AND [T1].[课程ID] = 'k1'
LEFT JOIN [成绩表] AS [T2]
ON [成绩表].[学生ID] = [T2].[学生ID] AND [T2].[课程ID] = 'k2'
LEFT JOIN [成绩表] AS [T3]
ON [成绩表].[学生ID] = [T3].[学生ID] AND [T3].[课程ID] = 'k3'
LEFT JOIN [成绩表] AS [T4]
ON [成绩表].[学生ID] = [T4].[学生ID] AND [T4].[课程ID] = 'k4'
ORDER BY ISNULL([T1].[成绩],0) + ISNULL([T2].[成绩],0) + ISNULL([T3].[成绩],0) + ISNULL([T4].[成绩],0) DESC)

最后还要多说一句: 一般 TOP 关键字与 ORDER BY 子句合用才有真正意义。

8.统计列印各科成绩,各分数段人数:
课程ID,课程名称,[100-85],[85-70],[70-60],[<60]

尽管表面看上去不那么容易,其实用 CASE 可以很容易地实现:

SELECT 课程ID, 课程名称
,SUM(CASE WHEN 成绩 BETWEEN 85 AND 100 THEN 1 ELSE 0 END) AS [100 - 85]
,SUM(CASE WHEN 成绩 BETWEEN 70 AND 85 THEN 1 ELSE 0 END) AS [85 - 70]
,SUM(CASE WHEN 成绩 BETWEEN 60 AND 70 THEN 1 ELSE 0 END) AS [70 - 60]
,SUM(CASE WHEN 成绩 < 60 THEN 1 ELSE 0 END) AS [60 -]
FROM 成绩表
GROUP BY 课程ID, 课程名称

注意这里的 BETWEEN,虽然字段名都是从高至低,可 BETWEEN 中还是要从低到高,这里
如果不小心,会犯一个很难发现的逻辑错误: 在数学上,当a > b 时,[a, b]是一个空集。

9.列印学生平均成绩及其名次

select count(distinct b.f) as 名次,a.学生ID,max(a.学生姓名),max(a.f)
from (select distinct t.学生ID,t.学生姓名,(select avg(成绩)
from t t1
where t1.学生id = t.学生id) as F
from T
) as a,
(select distinct t.学生ID,t.学生姓名,(select avg(成绩)
from t t1
where t1.学生id = t.学生id) as F
from T
) as b
where a.f <= b.f
group by a.学生ID
order by count(b.f)

这里有很多值得一提的地方,先利用两个完全相同的自相关子查询生成两个派生表作
为基本表用于作小于或等于的连接,这样就可以通过表中小于或等于每个值的其他值
的 COUNT(distinct) 的计数聚集函数来体现名次了。

SELECT 1+(SELECT COUNT(distinct [平均成绩])
FROM (SELECT [学生ID],MAX([学生姓名]) AS 学生姓名 ,AVG([成绩]) AS [平均成绩]
FROM T
GROUP BY [学生ID]
) AS T1
WHERE [平均成绩] > T2.[平均成绩]) as 名次,
[学生ID],[学生姓名],[平均成绩]
FROM (SELECT [学生ID],max([学生姓名]) AS 学生姓名,AVG([成绩]) AS [平均成绩]
FROM T
GROUP BY [学生ID]
) AS T2
ORDER BY T2.[平均成绩] desc

方法二也使用了两个完全相同的自相关子查询生成两个派生表作为基本表,再利用它
们之间作大于的相关子查询取 COUNT(distinct) + 1 的计数聚集函数同样实现了名
次的显示。

这道题从应用角度来看,查询结果是相当合理的,并列情况的名次也都一样。但如果想
实现类似自动序列的行号,该解决方案的局限性突显,不能处理并列相等的情况了,所
以有必要强调:一定要选择不重复的连接条件,可以根据实际情况利用字段组合的不等
连接 (T1.f1 + ... + T1.fn <= T2.f1 + ... + T2.fn)。继续引申还可以通过判断
COUNT(distinct) % 2 是否为 0 的 HAVING 或 WHERE 子句实现只显示偶数或奇数行:

HAVING count(distinct b.f) % 2 = 1
或:
WHERE 1+(SELECT COUNT(distinct [平均成绩])
FROM (SELECT [学生ID],MAX([学生姓名]) AS 学生姓名 ,AVG([成绩]) AS [平均成绩]
FROM T
GROUP BY [学生ID]
) AS T1
WHERE [平均成绩] > T2.[平均成绩]) % 2 =1

再简单说一下 HAVING 和 WHERE 在含有 GROUP BY 分组的查询中的区别,HAVING 是
在数据分组后才筛选记录的,WHERE 是先进行筛选在分组的,而且 HAVING 一般应与聚
集函数合用才有真正含义。

两种方法再次体现了子查询与连接可以殊途同归之妙,第二种子查询方法值得推荐,因
为比较利于程序构造,便于为没有该功能的原有查询添加此项功能。本题仅仅是为了示
范一种比较新颖的解题思路,回避了效率的问题。

10.列印各科成绩前三名的记录:(不考虑成绩并列情况)
学生ID,学生姓名,课程ID,课程名称,成绩,教师ID,教师姓名

如果仅从成绩考虑前三名的人,利用相关子查询的知识:

SELECT *
FROM 成绩表 t1
WHERE 成绩 IN (SELECT TOP 3 成绩
FROM 成绩表
WHERE t1.课程id = 课程id
ORDER BY 成绩 DESC
)
ORDER BY t1.课程id

这样查询的结果各科成绩前三名的记录数应该大于等于三,因为可能有并列情况,
如果小于三自然是该门课还没有那么多人考试!
如果不考虑并列情况,严格控制各科只列印三条记录,则使用"学生id"构造相关
子查询条件亦可:

SELECT *
FROM 成绩表 t1
WHERE 学生id IN (SELECT TOP 2 学生id
FROM 成绩表
WHERE t1.课程id = 课程id
ORDER BY 成绩 DESC
)
ORDER BY t1.课程id

如果利用第 10 题的思路也可实现该应用。

11.规范化
规范化的问题可以说是仁者见仁,智者见智。而且不做肯定不好,但过犹不及,搞到太
规范也不一定是好事。首先分析信息的对应关系,这个表中有四种信息。学生、课程、教师、成绩。其中前三个可以独立存在,最
后一个可以看做是基于前三个存在的。然后,我们按这四种分类,建立四个表:
关于学生的信息,有以下两个:学生ID,姓名;
教师则会有教师ID,姓名,课程ID 这也就是为什么我要把学生和教师会为两个表的原因;
课程则有课程ID,课程名称两种;
而最后一个成绩信息,就成为了联接它们的一个部分,在这里,它要有学生ID,教师ID,课程ID,成绩四项,相
对与其它表应属应用级别,除了成绩字段,其它都引用的另外的表。
这样一来,几个表的脚本大概是这个样子:
CREATE TABLE "学生信息"
(
"ID" CHAR(4),
"姓名" CHAR(16),
PRIMARY KEY ("ID")
)

CREATE TABLE "课程信息"
(
"ID" CHAR(4),
"名称" CHAR(16),
PRIMARY KEY ("ID"),
)

CREATE TABLE "教师信息"
(
"ID" CHAR(4),
"姓名" CHAR(16),
"课程ID" CHAR(4),
PRIMARY KEY ("ID"),
FOREIGN KEY("课程ID") REFERENCES "课程信息"("ID")
)

CREATE TABLE "成绩信息"
(
"学生ID" CHAR(4),
"教师ID" CHAR(4),
"课程ID" CHAR(4),
成绩 NUMERIC(5, 2),
PRIMARY KEY("学生ID", "教师ID", "课程ID"),
FOREIGN KEY("学生ID") REFERENCES "学生信息"("ID"),
FOREIGN KEY("教师ID") REFERENCES "教师信息"("ID"),
FOREIGN KEY("课程ID") REFERENCES "课程信息"("ID")
)

这样建表很明显是为了尽可能的细化信息的分类。它的好处在于各种信息分划明确,不
过问题也很明显,比如,一个教师不能同时带两门不同的课(当然,这可能正是业务规则所
要求的),而且,这样做分类过于细腻了。

如果不需要对教师进行人事管理,那么,完全可以把教师信息和课程信息合为一表。也就是说,不同教师带的同
一名称课程,视做不同课程。这样做当然也有其应用背景,很多教师,特别是高等教育和名师,往往有他们自
己的风格,完全可以视做两种课程,相信同样教授 C++ , Lippman 和 Stroustrup 教出的学生总会有所不同。
要说问题,那就是,如果想要限制学生不能重复修某一门课,就得用触发器了,没有太好的办法,不过这个问题,
前面的第一种设计同样解决不了,就算针对教师和课程的关系单建一个表也不一定就可以,还把问题复杂化了。
现在把第二种设计的脚本列出来:

CREATE TABLE "学生信息"
(
"ID" CHAR(4),
"姓名" CHAR(16),
PRIMARY KEY ("ID")
)

CREATE TABLE "课程信息"
(
"ID" CHAR(4),
"课程分类" CHAR(4),
"名称 "CHAR(16),
"教师ID" CHAR(4),
"教师姓名" CHAR(16),
PRIMARY KEY ("ID")
)

CREATE TABLE "成绩信息"
(
"学生ID" CHAR(4),
"课程ID" CHAR(4),
成绩 NUMERIC(5, 2),
PRIMARY KEY("学生ID", "课程ID"),
FOREIGN KEY("学生ID") REFERENCES "学生信息"("ID"),
FOREIGN KEY("课程ID") REFERENCES "课程信息"("ID")-
)

这样是不是能清爽一点?这样一来,如果不存在一个教师教不同的课程的情况,并且我
们希望简化管理,甚至都可以不用"课程分类"和"教师ID"字段。当然,视业务需要而定,
如果希望在限制学生学习的课程分类的同时,不想带来额外的性能开销,使用第一种设
计,或将课程分类字段也列入成绩信息表,是一个更好的办法。

关于数据库的设计和管理,有几条经验,拿出来在这里和大家交流一下:
对数据进行规范化时,最好要符合它的应用背景。这样易于理解和管理;
数据的规范化不一定是越细化越好,粒度适当地大一点,后面的编程一般会容易一点;
虽说不是越细越好,不过要是不做规范化,却几乎是一定要出问题;
很重要的一点: 千万不要滥用自动标识列! 特别是,不要滥用自动标识列来做为一个表中唯一的约束条件,通常,
那和没有约束没什么不同!

关于这些试题,我们的看法就到这里,希望朋友们可以拿出更多更好的意见,我们一起讨论。

原题含答案:
CREATE TABLE [T] (
[ID] [int] IDENTITY (1, 1) NOT NULL,
[学生ID] [varchar] (50) NULL,
[学生姓名] [varchar] (50) NULL,
[课程ID] [varchar] (50) NULL,
[课程名称] [varchar] (50) NULL,
[成绩] [real] NULL,
[教师ID] [varchar] (50) NULL ,
[教师姓名] [varchar] (50) NULL,
CONSTRAINT [PK_T] PRIMARY KEY CLUSTERED
(
[ID]
) ON [PRIMARY]
) ON [PRIMARY]
GO

INSERT INTO T ([学生ID],[学生姓名],[课程ID],[课程名称],[成绩],[教师ID],[教师姓名])
valueS ('S3','王五','K2','语文',81,'T2','王老师')
INSERT INTO T ([学生ID],[学生姓名],[课程ID],[课程名称],[成绩],[教师ID],[教师姓名])
valueS ('S3','王五','K4','政治',53,'T4','赵老师')
INSERT INTO T ([学生ID],[学生姓名],[课程ID],[课程名称],[成绩],[教师ID],[教师姓名])
valueS ('S4','赵六','K1','数学',99,'T1','张老师')
INSERT INTO T ([学生ID],[学生姓名],[课程ID],[课程名称],[成绩],[教师ID],[教师姓名])
valueS ('S4','赵六','K2','语文',33,'T2','王老师')
INSERT INTO T ([学生ID],[学生姓名],[课程ID],[课程名称],[成绩],[教师ID],[教师姓名])
valueS ('S4','赵六','K4','政治',59,'T4','赵老师')
INSERT INTO T ([学生ID],[学生姓名],[课程ID],[课程名称],[成绩],[教师ID],[教师姓名])
valueS ('s1','张三','K4','政治',79,'T4','赵老师')
INSERT INTO T ([学生ID],[学生姓名],[课程ID],[课程名称],[成绩],[教师ID],[教师姓名])
valueS ('s1','张三','K1','数学',98,'T1','张老师')
INSERT INTO T ([学生ID],[学生姓名],[课程ID],[课程名称],[成绩],[教师ID],[教师姓名])
valueS ('s1','张三','K3','英语',69,'T3','李老师')
INSERT INTO T ([学生ID],[学生姓名],[课程ID],[课程名称],[成绩],[教师ID],[教师姓名])
valueS ('s7','peter','K1','数学',64,'T1','张老师')
INSERT INTO T ([学生ID],[学生姓名],[课程ID],[课程名称],[成绩],[教师ID],[教师姓名])
valueS ('s7','peter','K2','语文',81,'T2','王老师')
INSERT INTO T ([学生ID],[学生姓名],[课程ID],[课程名称],[成绩],[教师ID],[教师姓名])
valueS ('s7','peter','K4','政治',53,'T4','赵老师')
INSERT INTO T ([学生ID],[学生姓名],[课程ID],[课程名称],[成绩],[教师ID],[教师姓名])
&nbsp

Published At
Categories with 数据库类
Tagged with
comments powered by Disqus