【程序设计】9.2 [分类别] 枚举与对齐
9.2 枚举与对齐
将分组键相等的成员分到一个组,这种分组称为等值分组。等值分组满足这样两个特点:
1) 原集合的所有成员都在且只在唯一的组中;
2) 没有一个组是空集;
满足这种特点分组在数学上又称为完全划分。
那么是不是还有不完全划分呢?
是的,完全划分这两个满足有可能都不被满足,比如分组中可能有空集存在,也可能有些原集合的成员不在任何一个组中,或者在多个组中。
这一节我们来讲这种分组。
我们做过将人员表按性别分组的统计,一般来讲,我们会期望返回的序表有两条记录,也就是男、女各一条,甚至还应该能保证次序,这样我们能确定地知道用哪一条记录获得男性或女性的汇总结果。
但是,等值分组却不能保证这个结果。
因为,这个人员表中不见得两种性别都有,如果只有一种性别,那么返回的序表就只有一条记录了,这时,我们也必须通过再次比较才能知道这一条是男还是女。
这很不方便,我们希望确定返回有两条记录,并且次序也是确定的。如果待分组数据中只有一种性别,那么就让另一种性别的汇总值为 0 就好了。
这种分组称为对齐分组,它不是完全划分,因为可能出现空集。
A |
|
1 |
=100.new(string(~):name,if(rand()<0.5,"Male","Female"):sex,50+rand(50):weight,1.5+rand(40)/100:height) |
2 |
[Male,Female] |
3 |
=A1.align@a(A2,sex) |
4 |
=A3.new(A2(#):sex,~.avg(height):avg_height,~.max(weight):max_height) |
做对齐分组之前,需要有个基准的序列,比如这里的 A2。A3 中的 align@a 函数将把待分组集合(A1)拆分成若干子集,每个子集的分组键将基准序列成员一一对应,即把分组键值与基准序列成员相同的记录(待分组序列的成员)分到同一个组,这样得到的分组子集的个数和次序都被基准序列限定了,如果有一个基准序列成员不对应任何待分组序列成员的分组键值,那么它将对应一个空子集。
有了分组子集序列,再计算出分组键值及汇总值构成的序表就非常容易了。注意 A4 中的 new 函数,要用 A2(#) 去获取分组键值,因为可能有的分组子集是空集,不能从分组子集获取这个信息。
SPL 仅提供了生成分组子集的方法,没有进一步提供把汇总值一次计算出来的简化函数。因为一方面并不难写,另一方面相对于等值分组,对齐分组的常用程度要低一些。
再举一例,前面那个订单数据中,我们希望按最近两年的月份分组统计销售额,不管某个月是不是没有订单。
A |
|
1 |
=T("data.xlsx") |
2 |
=to(202001,202012)|to(202101,202112) |
3 |
=A1.align@a(A2,month@y(OrderDate)) |
4 |
=A3.new(A2(#):ym,~.sum(Amount),~.count(Amount)) |
这次的分组,不仅可能出现空集,还会发生有些原集合的记录没有被分到任何子集中的情况(那些不在这两年的订单)。
细心的读者可能会发现,这个 align 函数带了个选项 @a,那么没有选项时是什么表现?
它是用来排序的。
和对齐分组类似,我们有时需要把一批数据按我们期望的次序排列,而不是常规的字母或编码次序。
还是这个订单表,我们希望按地区统计销售额,但结果要按 East、West、North、South、Center 这样的次序排列。当然我们可以用刚才学过的 align@a。
A |
|
1 |
=T("data.xlsx") |
2 |
[East,West,North,South,Center] |
3 |
=A1.align@a(A2,Area) |
4 |
=A3.new(A2(#):Area,~.sum(Amount),~.count(Amount)) |
如果我们明确地知道在每个分组中都最多只有一条时,也可以换一种办法来写:
A |
|
1 |
=T("data.xlsx") |
2 |
[East,West,North,South,Center] |
3 |
=A1.groups(Area;sum(Amount),count(Amount)) |
4 |
=A3.align(A2,Area) |
在 A3 分组汇总后得到的序表中,每个地区的记录都最多只有一条(可能没有)。用没有选项的 align 函数可以把这些记录重新按 A2 的次序排列。
对齐排序比对齐分组更常见,人们有很多约定俗成的固定排列次序。比如,中国的各省排名通常会把北京排到第一位,而不会根据 unicode 编码的次序把安徽排到前面。美国人虽然对地区没有这种固定排序的观念,但也有 Sunday,Monday,Tuesday,…以及 Low,Medium,High 这些不能直接按字母来排序的东西。
还有一种很常见的对齐分组,其分组键值就是从 1 开始的正整数,而且目标次序也就是按些数从小到大排列。比如月份是 1-12 的数,星期也是 1-7 的整数。这种情况我们又称为序号分组,SPL 在 group 及 groups 函数提供了一个选项 @n 来支持序号分组。
A |
|
1 |
=T("data.xlsx") |
2 |
=A1.groups@n(month(OrderDate):M;sum(Amount),count(Amount)) |
使用时和没有 @n 时的 groups 很像,但它是个对齐分组,允许产生空集。如果分组键值小于 1,也会被丢弃。尝试一下我们前面生成过的股票数据:
A |
|
1 |
=100.new(now()-100+~:dt,rand()*100:price) |
2 |
=A1.select(day@w(dt)>1 && day@w(dt)<7) |
3 |
=A2.groups@n(day@w(dt):wday;min(price):min_p,max(price):max_p) |
A3 返回的序表会有 6 条记录,其中第 1 条对应周日数据的分组子集是个空集(其分组键值应该为 1),因为周日的数据已经在 A2 过滤掉了,剩下的数据中最大的分组键值就是 6(周六数据已经被过滤掉了,算不出 7),所以结果序表有 6 条记录。其中第 1 条对应空的分组子集,两个汇总字段都是 null,不过分组键值字段仍然有值。
这里的分组键值没有减 1,会是 2,…,6 对应周一、…、周五,和之前计算结果不同。
我们还可以改成这样,把分组键的减 1。
A |
|
1 |
=100.new(now()-100+~:dt,rand()*100:price) |
2 |
=A1.select(day@w(dt)<7) |
3 |
=A2.groups@n(day@w(dt)-1:wday;min(price):min_p,max(price):max_p) |
在 A2 只过滤掉周六的数据, A3 分组时将分组键值减 1 了,这样周日数据的分组键值会计算出 0 而被抛弃掉,A3 则会计算出 5 条记录(没有周六的数据,最大算出 5),这就会得出这之前一样的结果。
从这两个例子可以看出,序号分组并不是用序号当分组键值来做常规分组,它的行为更像是对齐分组。会出现空集,也会抛弃没有对应分组子集的成员。
对于 group 使用 @n 的效果与 groups 是类似的,这里不再细说了。
序号分组还有一个优点是计算速度快,因为可以直接用序号找到正确的分组子集,不需要做比较运算。
除了常规对齐分组,还会发生用条件来刻画的分组,最常见的是按段分组。比如根据年龄把人划分成若干组,或根据金额把订单划分成不同的组。
比如,我们将前面的人员表按 BMI 值分成四档:<20,过轻;20-25,正常;25-30:过重;>30 肥胖。再来统计每一档人的平均身高。
A |
|
1 |
=100.new(string(~):name,if(rand()<0.5,"Male","Female"):sex,50+rand(50):weight,1.5+rand(40)/100:height) |
2 |
[?<20,?>=20 && ?<25,?>=25 && ?<30,?>=30] |
3 |
[UnderWeight,Normal,OverWeight,Fat] |
4 |
=A1.enum(A2,weight/height/height) |
5 |
=A4.new(A3(#):Grade,~.avg(height):avg_height) |
不同档次的条件写成字符串构成一个序列,其中用? 表示将要代入计算的分组键值。enum 函数将会用待分组集合中每个成员的分组键去依次计算这些条件,如果得到 true,则将该成员分到相应的组中。最终的分组子集也会在数量和次序上和这些用作基准的条件一一对应。
这种分组称为枚举分组,显然,枚举分组也可能分出空集,以及会发生有些成员不会分到任何组中的现象。
类似地,SPL 只提供了 enum 函数返回分组子集,要得到汇总值需要自己进一步计算。和对齐分组不同,枚举分组通常还会有另一个序列用来标识每个分组的名称(这里的 A3)。
因为分段分组很常见,这种情况还可以用前面介绍过的 pseg 函数转换成序号分组,写法更为简单,而且执行速度也更快。
A |
|
1 |
… |
2 |
[20,25,30] |
3 |
[UnderWeight,Normal,OverWeight,Fat] |
4 |
=A1.group@n(A2.pseg(weight/height/height)+1) |
5 |
=A4.new(A3(#):Grade,~.avg(height):avg_height) |
这里用了 group@n 先算出分组子集,因为 pseg 函数对于小于第 1 成员的情况会返回 0,所以在作为分组键值需要加 1,否则这个分段就被舍弃了。
直接用 groups 计算出汇总值也可以,但还要一步把分段的标识填写正确,否则我们会看到分组键值本身,也就是 1,2,3,…这些。
A |
|
1 |
… |
2 |
[20,25,30] |
3 |
[UnderWeight,Normal,OverWeight,Fat] |
4 |
=A1.groups@n(A2.pseg(weight/height/height)+1:Grade;avg(height):avg_height) |
5 |
=A4.run(Grade=A3(Grade)) |
其实,对齐分组可以看成是一种特殊的枚举分组,对齐分组都可以用枚举分组写出来。
A |
|
1 |
… |
2 |
[?=="Male",?=="Female"] |
3 |
[Male,Female] |
4 |
=A1.enum(A2,sex) |
5 |
=A4.new(A3(#):sex,~.avg(height):avg_height,~.max(weight):max_height) |
enum 函数还支持可重分组(即某个成员可能分到多个分组子集中),但因为不太常见,我们就不再讲了,需要时可以查询帮助。
SQL 只有等值分组,碰到这些枚举分组(包括对齐分组)的计算需求就会非常麻烦。