Power Query 学习 03 如何系统学会 List.Accumulate

前言:List.Accumulate是M语言里,非常常用的函数。施神的帖子已经有了比较详细的论述,https://pqfans.com/1897.html ,如果你已经完全理解掌握了此帖,本文就不用看了。如果你看不懂大神的帖子,或看懂了,但自己写不出来,或写不顺手总写错。本文将提供一个比较系统的问题解决思路,来帮助大家用好这个函数。


正文:List.Accumulate之所以常用,原因是它的功能是循环迭代。M是函数语言,里没有for语句,只能用List.Transform,List.Accumulate等函数实现循环功能。不掌握这个函数,就相当于使用M语言却不能使用循环,结果可想而知!函数语言的优点是简洁,但是由于参数位的限制,导致函数语言的参数编写不够灵活。List.Accumulate就是典型的,需要提前构造好第二参数(需要迭代的变量初值)的数据结构,难就难在这里!So,开始:


案例1:从 1 到 9 累加
为了便于理解,这里用Python作对比,以帮助初学者理解函数式语言。
先看两种代码(请仔细对比代码区别,以便理解M语言的语法特点):

Python:
    s=0
    for i in range(1,10):   #Range取值左闭右开,所以1到9要表达为(1,10)
        s+=i                #等效于s=s+i
M:
    = List.Accumulate({1..9},0,(s, i)=> s+i) //还是很简洁的,就是这个函数名,太长了。。。

简单解读一下:

变量 s 为累加器,所以要放在循环外边定义,并附初值 0
变量 i 为循环变量,随着每次循环,i 的值依次取 1 到 9
循环表达式:s=s+i ,如此循环 9 次:
s1=s0+i0    0+1=1
s2=s1+i1    1+2=3
s3=s2+i2    3+3=6
s4=s3+i3    6+4=10
...
s9=s8+i8    36+9=45

下面我们来看正式看List.Accumulate的语法说明和我的“翻译”

List.Accumulate(list as list, seed as any, accumulator as function) as any
List.Accumulate(要循环的列表, 迭代初值,(迭代变量名,循环变量名)=> 循环运算表达式) 

这里要注意迭代初值的数据类型为any,这里既可以是数字、文本,也可以是列表、记录甚至表都可以。
另外,如果需要的结果是整个列表,只是按一定规律的转换一遍,应该用List.Transform更加合适,当然用List.Accumulate也可以实现,比如下面两行代码是等效的:

= List.Transform({1..9},each _*2)
= List.Accumulate({1..9},{},(x,y)=> x&{y*2})

好,如果你理解了案例1,并能自己写出一个 1 到 9 阶乘,就继续往下:


案例2:把 1 到 9 按小于 5 和大于等于 5 分成两组
先看Pythom代码:

l1,l2=[],[]
for i in range(1,10):
    if i<5:
        l1=l1+[i]
    else:
        l2=l2+[i]

原理很简单,定义两个空列表,小于5装第一个,否则装第二个。
可这会儿我们要改写成List.Accumulate时就出问题了,因为只有一个第2参数,没法放进 l1、l2 这 2 个列表,那能不能把两个迭代变量改成一个呢?当然可以,只要把两个列表组合成一个就行了。下面是组合后的PythonM代码:

Python:
    l=[[],[]]
    for i in range(1,10):
            if i<5:
                l=[l[0]+[i],l[1]]
            else:
                l=[l[0],l[1]+[i]]
M:
    = List.Accumulate({1..9},{{},{}},(x,y)=> 
            if y<5 then 
                {x{0}&{y},x{1}}
            else 
                {x{0},x{1}&{y}})

很容易发现,只要把要迭代的变量,组合成一个列表List,就可以。当然组合成记录Record,甚至组和表Table都行,相应的修改后面的函数表达式就可以了。此处用List比较简洁,用Record比较易读,没有本质区别,比如下面两段代码,作用是等效的:

= List.Accumulate({1..9},{{},{}},(x,y)=> 
        if y<5 then 
            {x{0}&{y},x{1}} 
        else 
            {x{0},x{1}&{y}})

= List.Accumulate({1..9},[a={},b={}],(x,y)=> 
        if y<5 then 
            [a=x[a]&{y},b=x[b]] 
        else 
            [a=x[a],b=x[b]&{y}])

本文为了代码相对简化,案例中List.Accumulate的第 2 参数,统一使用List类型。
好,再举两个例子,熟悉一下构造和条件写法,这两个案例都是把数字和字母分开,但其中一个是文本型数字,所以条件的写法不同,一个是直接判断数据类型,另一个则是用比较字符大小,来判断字符类型(可参考ASCII编码表):
file
file
如果这些都看懂了,并且学会了,下面继续,由于对函数构造有了一定了解,后面就不再写对比Python代码。


案例3:分组相同的字母
就是要把"AABBBBCCC",分成{"AA","BBBB","CCC"}。分析一下这个题:
首先需要把文本拆成列表,然后逐个进行前后比较,相同的合并成一组,不同另起新组并把合并好的放入结果组。这个有了前后比较关系的题目,明显比前面的问题更棘手。要想实现这个功能需要解决如下问题:

1、前后比较,也就是需要一个 容器 每次需要保留当前字母,以便和下一个比较
2、需要有一个 容器 ,来存放合并的文本
3、需要一个 容器 ,来存放最终结果列表
4、首次和末次比较,需要特殊处理
5、深化引用列表首项得到最终结果

接下来我们来构造第 2 参数,为了满足前 3 个问题,就需要构建由 3 个容器组成一个List,模式如下:

{ 最终列表 , 合并文本 , 保留字母 } 再根据数据类型,可确定 2 参数为 { {} , "" , "" }

下面再看比较逻辑:

如果 保留字母=新字母,那么:
    最终列表:不变
    合并文本:加入新字母
    保留字母:变为新字母
如果 保留字母<>新字母,那么:
    最终列表:加入 合并文本
    合并文本:变为新字母
    保留字母:变为新字母

根据以上内容,我们写出第 1 版代码:

let
    源 = List.Accumulate(Text.ToList("AABBBBCCC"), {{},"",""},(x,y)=> 
    if x{2} = y then 
            {x{0} , 
            x{1}&y , 
            y } 
    else 
            {x{0}&{x{1}} , 
            y , 
            y }
    ){0}
in
    源

验证一下:
file
发现结果不对,前面多了个空文本,后面少了一组,下面逐步分析一下迭代过程变化过程:

{{},"",""} and "A"
{""},"A","A"} and "A"
{""},"AA","A"} and "B"
{"","AA"},"B","B"} and "B"
{{"","AA"},"BB","B"} and "B"
{{"","AA"},"BBB","B"} and "B"
{{"","AA","BBBB"},"BBBB","B"} and "C"
{{"","AA","BBBB"},"C","C"} and "C"
{{"","AA","BBBB"},"CC","C"} and "C"

通过分析不难发现,原来刚才的问题4、首次和末次比较,需要特殊处理还没处理,那怎么办呢?

第一次比较,因为初始值是写好的,我们知道,那就增加一个判断专门处理
最后由于缺少一次比较,我们在列表最后增加一个元素,来完成处理(要能确定这个元素和列表里的都不同)

升级后的代码如下:

let
    源 = List.Accumulate(Text.ToList("AABBBBCCC")&{null}, {{},"",""},(x,y)=> //列表最后增加了一个null
    if x{2} = "" then //首次比较处理条件
            {x{0} ,
            y ,
            y }
    else if x{2} = y then 
            {x{0} ,
            x{1}&y ,
            y } 
    else 
            {x{0}&{x{1}} ,
            y ,
            y }
    ){0}
in
    源

验证一下,结果终于对了:
file
再来一个连续数分组{1,2,4,6,7,8}变为{{1,2},{4},{6,7,8}}思路完全一样,先构建第 2 参数:

{ 最终列表 , 每组连续数 , 用于比较的数字 } ,再根据数据类型确定为 {{},{},""}

这里每组连续数类型变为List,后面的操作也都做了相应改变,注意和上一个案例的细微区别:

let
    源 = List.Accumulate({1,2,4,6,7,8} & {null}, {{},{},""},(x,y)=> //列表最后增加了一个null
    if x{2} = "" then //首次比较处理
            {x{0},
            {y},
            y }
    else if x{2} = y-1 then 
            {x{0} ,
            x{1}&{y} ,
            y } 
    else 
            {x{0}&{x{1}} ,
            {y} ,
            y }
    ){0}
in
    源

file


上面都学会那继续增加难度:
案例4:连续字母合并,连续数字相加,然后分到一组
也就是"A345BC222WWW012"变为:{{"A",12},{"BC",6},{"WWW",3}},WTF,这个要怎么搞?
其实,思路还是一样的!

1、前后比较,也就是需要一个 容器 每次需要保留当前字符,以便和下一个比较
2、需要有一个 容器 ,来存放合并的文本
3、需要一个 容器 ,来求和数字
4、需要一个 容器 ,来存放最终结果
5、首次和末次比较,需要特殊处理
6、深化引用列表首项得到最终结果

其实就是多了一个中间容器吗。我们来构造第 2 参数如下:

{ 最终列表 , 连续字母合并 , 连续数字求和 , 用于比较的字符 } ,再根据数据类型确定为 {{},"",0,""}

同样变复杂的还有前后比较的条件:
1、首次条件 : 写入字母
2、字母 字母 : 合并字母
3、字母 数字 : 写入数字
4、数字 数字 : 加数字
5、数字 字母 : 写入合并的字母和数字的和,写入字母,数字清零
通过分析我们可以合并化简3\4条,并需要在最后补一个字母,最终代码如下

let
    源 = List.Accumulate(Text.ToList("A345BC222WWW012") & {"A"}, {{},"",0,""},(x,y)=> //列表最后增加了一个"A“
    if x{3} = "" then //首次比较处理
            {x{0} ,
            y ,
            x{2} ,
            y }
    else if x{3}>="A" and y>="A" then //条件2
            {x{0} ,
            x{1}&y ,
            x{2} ,
            y } 
    else if y<"A" then //条件3、4
            {x{0} ,
            x{1} ,
            x{2}+Number.From(y) , //相加之前要先转为数字格式
            y } 
    else //最后一个条件省略
            {x{0}&{{x{1},x{2}}} ,  //注意列表层数
            y , 
            0 , 
            y } 
    ){0}
in
    源

file


敲黑板,总结!

1、List.Accumulate 相当于 M语言 的 For 循环,1\2\3参数为:循环列表(List),迭代初值(Any),循环表达式(function)
2、需根据题目具体需求,设置第 2 参数,较复杂问题使用 List 或 Record 均可
3、前后比较分组问题,第 2 参数构建模型为:{ 最终列表容器 , 过程容器1 , 过程容器2... , 比较容器 }
4、前后比较分组问题,条件书写通用模式为:(x,y)=> if (x{n}与 y 的各种属性和关系判断) then {x{0} , x{1} ... , y }
5、前后比较分组问题,需考虑首次比较和末次比较处理,一般首次用条件处理,末次用补值处理,补值类型要满足逻辑需求

砸黑板,作业!
自我检测一下呗:

用List.Accumulate把 "AAbb123BBcc234" 转换成如下结果:
1、{"AAbbBBcc","123234"} //20分
2、{"AAbbBBcc",15} //40分
3、{"AAbb123","BBcc234"} //60分
4、{{"AAbb","123"},{"BBcc","234"}} //80分
5、{{"AA","bb",6},{"BB","cc",9}} //100分

看到这都没放弃啊。。。