SPL 的定位运算与位置利用
以有序集合为基础,可以延申出多种运算,最简单的是位置访问,即按下标或位置获取记录集合中的成员。与之相对的是定位运算,即按某种方式获取记录集合中成员的位置,比如字段极值所在记录的位置、符合条件的记录的位置、排序后的记录在原集合里的位置等。定位运算种类丰富功能强大,增强并拓展了结构化数据运算的表达能力,熟练掌握定位运算并灵活利用位置,可以大幅简化复杂的计算目标,提高低效的计算性能。
SQL 的基础数据类型(表)没有天然序号,属于无序集合。虽然有 rownum 等伪字段生成临时序号,但限制较多门槛较高,也没有直接可用的定位函数,这些原因导致 SQL 程序员通常没有意识主动利用位置去解决问题。
SPL 的基础数据类型(序表)具有天然序号,属于有序集合。SPL 内置了丰富的定位运算函数,方便 SPL 程序员计算位置并善加利用。
从定位成员开始
定位成员指获得指定成员(记录)在集合中的位置,是最基础的定位运算,SPL 提供了函数 pos 实现定位成员。
例 1,从水果集合中查找成员香蕉的位置,找不到时返回 null:
["Orange","Banana","Apple","Banana"].pos("Banana") //2
通过可选参数和函数选项,还可以扩展函数 pos 的能力,比如从后往前找、数据有序时用二分查找、返回成员多次出现的位置等等,其他定位函数都有类似的功能。
利用定位成员解决问题
定位成员虽然简单,但建立起利用位置的思维并灵活应用,往往能简化复杂的计算目标。
例 2:现有某班级的课程表,以及各位教授根据课程表做出的授课意向表。授课意向表通常有冲突和空缺,即多位教授可能选择了同一课程,或某课程没有教授选择。现在要计算出课程冲突表,以便教务部门下一步调整并确定最终的授课安排表。
课程表 schedule.txt
Monday |
Tuesday |
Wednesday |
Thursday |
Friday |
lua |
maa |
mia |
jua |
via |
lub |
mab |
mib |
jub |
vib |
luc |
mac |
mic |
juc |
vic |
lud |
mad |
mid |
jud |
vid |
lue |
mae |
mie |
jue |
vie |
luf |
maf |
mif |
juf |
vif |
lug |
mag |
mig |
jug |
vig |
说明:课的概念是具体时间段的科目,比如周二第 3 节的地理课,一般用代码表示,比如 luc;本例用不到科目的概念。
授课意向表 Intent.txt
Petitti |
mif |
mig |
vif |
vig |
null |
null |
… |
Canales |
luc |
lud |
mac |
mad |
mic |
mid |
… |
Lucero |
lub |
luc |
lud |
lue |
mab |
mac |
… |
Bergamaschi |
lua |
luf |
maa |
maf |
mia |
mif |
… |
… |
… |
… |
… |
… |
… |
… |
… |
说明:上面数据没有列头,第 1 列是教授的名字,后面每列都是课程意向,null 表示空缺。
要计算课程冲突,重点是查出每节课对应的教授名单,只用条件查询很难算出理想结果,但配合 pos 函数,先算出课程表里是否存在某教授,后续就容易多了。SPL 代码:
A |
B |
|
1 |
=file("Intent.txt").import() |
/ 教授课程意向表 |
2 |
=A1.new(#1:professor,~.array().to(2,).select(~):codeArray) |
/ 生成新序表,第一列是教授名,第二列是课程列表(集合) |
3 |
=file("schedule.txt").import@t().conj(~.array()) |
/ 将所有课程先横向再纵向合并成一个集合 |
4 |
=A3.(A2.select(codeArray.pos(A3.~)).(professor)) |
/ 利用位置计算出课程冲突。 |
5 |
=create(Monday,Tuesday,Wednesday,Thursday,Friday).record(A4.(~.concat@c())) |
/ 将冲突数据呈现成形象的课程冲突表。 |
A4:循环 A3 的课程集合,使用 pos 函数在授课意向中查找当前课程,选出每节课对应的教授集合,教授集合可能包含一位或多位教授,也可以为空。
[Bergamaschi,Puebla] |
[Bergamaschi,Puebla] |
[Bergamaschi,Puebla] |
… |
A5:将 A4 的课程冲突数据按先横再纵的顺序填入课程表,多位教授对应一个课程时用逗号合并,结果如下:
Monday |
Tuesday |
Wednesday |
Thursday |
Friday |
Bergamaschi,Puebla |
Bergamaschi,Pue… |
Bergamaschi,Puebla |
Bergamaschi,Pue… |
Bergamaschi,Puebla |
Lucero,Puebla,Lu… |
Lucero,Mazza,Pu… |
Lucero,Puebla,Chi… |
Lucero,Mazza,Pe… |
Lucero,Puebla,Vel… |
Canales,Lucero,P… |
Canales,Lucero,M… |
Canales,Lucero,P… |
Canales,Lucero,M… |
Lucero,Velasco,Lu… |
… |
… |
… |
… |
… |
常用的定位运算
很多定位运算以结构化数据为基础,比如 pmax、pmin,获得最大值、最小值所在的位置;ptop 定位前 N 名或后 N 名所在的位置;pselect,获得满足条件的记录所在的位置。灵活应用这些定位运算,可以利用位置简化复杂的结构化数据计算问题。
例 3:根据某股票指数,计算出收盘价最高的那一天的增长率。部分数据如下:
Date |
Open |
Close |
Amount |
2019-12-31 |
3036 |
3050 |
2.27E+11 |
2019-12-30 |
2998 |
3040 |
2.67E+11 |
2019-12-27 |
3006 |
3005 |
2.58E+11 |
2019-12-26 |
2981 |
3007 |
1.96E+11 |
2019-12-25 |
2980 |
2981 |
1.91E+11 |
2019-12-24 |
2983 |
3040 |
1.92E+11 |
2019-12-23 |
2910 |
2991 |
1.99E+11 |
如果不利用位置,通常的办法是先求出每条记录的增长率,再用“收盘价最高“来过滤记录。代码如下:
A |
|
1 |
=file("d:/000001.txt").import@t().sort(Date) |
2 |
=A1.derive(Close/Close[-1]-1:rate) |
3 |
=A2.select(Close==A1.max(Close)) |
这么做比较直观,但要计算每条记录的增长率,性能不算最佳。
想要提高性能,应当先找到收盘价最高的记录,只计算这条记录的增长率,pmax 函数可以方便地实现这一目标:
A |
|
1 |
=file("d:/000001.txt").import@t().sort(Date) |
2 |
=A1.pmax(Close) |
3 |
=A1(A2).Close/A1(A2-1).Close-1 |
收盘价最高的日期可能不止一天,pmax@a 可以求出多个最大值所在的位置,循环这些位置,可以求出多个增长率:
A |
|
1 |
=file("d:/000001.txt").import@t().sort(Date) |
2 |
=A1.pmax@a(Close) |
3 |
=A2.new(A1(~).Date, A1(~).Close/A1(~-1).Close -1) |
利用前 N 名或后 N 名所在的位置,也可以简化问题并提高性能。例 4:计算收盘价最高的三天的成交量的涨幅。
A |
|
1 |
=file("d:/000001.txt").import@t().sort(Date) |
2 |
=A1.ptop(-3, Close) |
3 |
=A2.new(A1(~).Date, A1(~).Amount / A1(~-1).Amount) |
类似地,可以利用满足查询条件的记录所在的位置来解题。例 5:收盘价涨幅大于 1.03% 的那些交易日,当天成交量的涨幅是多少?
A |
|
1 |
=file("d:/000001.txt").import@t().sort(Date) |
2 |
=A1.pselect@a(Close/Close[-1]>1.03) |
3 |
=A2.new(A1(~).Date, A1(~).Amount/A1(~-1).Amount) |
定位运算的搭档
定位运算的结果经常是位置集合,循环计算位置集合的每一个成员在 SPL 中很常见,利用 SPL 的基本语法和函数可以完成循环计算,但因为循环的主体是位置集合,而不是常见的记录集合,导致代码不够直观,也稍显复杂。比如例 3 中循环处理多个收盘价最高的位置,最后的代码是:A2.new(A1(~).Date, A1(~).Amount / A1(~-1).Amount)
为了方便循环处理位置集合,SPL 为定位运算搭配了 calc 函数,用循环记录集合的形式代替循环位置集合,循环变量改为更直观的当前记录,代码也因此简化。例 3 可以简写成:A1.calc(A2,[Date,Close/Close[-1]-1])
函数 calc 可以配合各类定位运算,例 4 例 5 可以简化为(代码一样):A1.calc(A2,[Date,Amount / Amount[-1]])
进一步利用位置
在结构化数据计算中,有时候需要对同一份数据进行两种排序,并在两种排序间互相切换,不利用位置的话很难直接进行切换。SPL 提供了 psort 函数,用来计算排序后的数据在原序中的位置,这就可以在两种排序中方便地进行切换。
例 6:userConsume 表存储电商的大量用户及其消费金额,现在要根据外部参数 p_userID,计算出该用户需要再花多少钱,才能使自己的消费排名上升。这个算法会频繁执行。
数据量较大且频繁执行,可以将 userConsume 表常驻内存。计算某用户花多少钱才能提高排名,即找到该用户所在的记录,再与上一名用户的消费金额进行跨行计算,金额之差即所求目标。难点在于,数据应当按金额逆序排序,这样才能与上一名用户进行跨行计算;但找到指定用户所在的记录,较快的办法是将数据按用户 ID 排序,这样可以用二分法快速查找(也可以用哈希索引等办法,另议)。
在没有 psort 函数之前,很难同时使用两种排序,并在两者之间进行转换,妥协的做法是常驻内存时只按金额排序:
A |
|
1 |
=connect("orcl").query@x("select userID, amount from userConsume order by amount") |
2 |
=env(userConsume,A1) |
内存查询时就不能用二分法了:
A |
|
1 |
=userConsume.pselect(userID: p_userID) |
2 |
=userConsume.calc(A1,if(#>1,amount[-1]-amount,0)) |
函数 psort 的好处是以位置为中介,在两种排序的数据间方便地进行切换。原理如下:
原数据 UserConsume:
100 |
300 |
200 |
用 psort 计算排序后的数据在原序中的位置,oPos=userConsume.psort():
1 |
3 |
2 |
用 psort 的结果生成新序数据,index=userConsume(oPos):
100 |
200 |
300 |
用 psort 的结果计算新序的位置在原序中对应的数据:oPos(3)=300
使用 psort 函数后,就可以用两种排序显著提高性能。常驻内存代码改为:
A |
B |
|
1 |
=connect("orcl").query@x("select userID, amount from userConsume order by amount") |
|
2 |
=env(userConsume,A1) |
原序 |
3 |
=env(oPos, userConsume.psort(userID)) |
排序后的记录在原序中的位置 |
4 |
=env(index, userConsume(oPos)) |
利用位置制造排序后的数据,等价于索引 |
内存查询时可以使用二分法:
A |
B |
|
1 |
=oPos(index.pselect@b(userID: p_userID)) |
用排序后的数据进行二分查找,并转为原序位置 |
2 |
=userConsume.calc(A1,if(#>1,amount[-1]-amount,0)) |
在原序中进行相对位置计算 |
除了上面提到的,SPL 还提供了pseg、pfind等定位函数,灵活运用这些函数,同样可以简化复杂的计算目标,或提高低效的计算性能。