[Erlang]List结构和性能分析

Erlang里通过尾递归方式对列表中元素依次进行操作时,程序员们采用的方法总是先在尾递归中将处理后的元素加在已处理列表的头部,最后通过lists:reverse(List)来恢复原来次序。为什么不直接以自然顺序将表头元素加到已处理列表的尾部呢?这里面都是有故事的。

先看两个函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
-module(list_time).
-export([add_list_head/1, add_list_tail/1]).
 
add_list_head(N) ->
     add_list_head(N, []).
add_list_head(0, L) ->
     lists:reverse(L),
     ok;
add_list_head(N, L) ->
     add_list_head(N-1, [65 | L]).
 
add_list_tail(N) ->
     add_list_tail(N, []).
add_list_tail(0, L) ->
     ok;
add_list_tail(N, L) ->
     add_list_tail(N-1, L ++ [65]).

add_list_head/1和add_list_tail/1都用于依次向列表中加入一个元素,元素个数由参数N指定。add_list_head/1中,新加入的元素放在当前列表的表头,最后调用lists:reverse(L)恢复顺序;add_list_tail/1中,新加入的元素直接放在当前列表的表尾。add_list_tail/1的实现方法更符合我们的逻辑,但效率上却是add_list_head/1远远占优。有如下基准测试数据为证:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
1> c (list_time).
{ok,list_time}
2> timer:tc(list_time, add_list_head, [10000]).
{564,ok}
3> timer:tc(list_time, add_list_head, [100000]).
{8875,ok}
4> timer:tc(list_time, add_list_head, [1000000]).
{98275,ok}
5> timer:tc(list_time, add_list_head, [10000000]).
{1745467,ok} 
6>
6> timer:tc(list_time, add_list_tail, [10000]).
{561634,ok}
7> timer:tc(list_time, add_list_tail, [100000]).
{59663921,ok}
8> timer:tc(list_time, add_list_tail, [1000000]).  %半小时仍未返回 =。=

从测试数据看,add_list_head/1在依次添加10k, 100k, 1m, 10m个元素的时间分别为0.56ms, 8.87ms, 98.27ms, 1745.46ms。而add_list_tail/1添加10k, 100k个元素的时间分别为561.63ms和59663.92ms,当元素个数为1m时,运行半个小时仍未返回。造成性能差距如此之大的原因得从列表在Erlang里的实现原理中找。

在Erlang里,空列表用[]表示,所有非空列表都可以用[Element | Remain]的方式表示,比如,[a]可以表示成[a | []],[a, b, c]等价于[a | [b | [c | []]]]。但列表不是通过数组实现的,实际上,列表的底层数据结构是一个单链表(singly-linked list)。如下列表定义:

1
Foo = [a, b].

其对应的存储结构为:

1
2
3
Foo
 ↓
 a → b → null

其中,Foo可以看做是单链表a→b的表头指针。

现在分析向表头添加元素生成列表的情况,向Foo表头添加元素c:

1
Bar = [c | Foo].

它的存储结构为:

1
2
3
Bar Foo
 ↓   ↓
 c → a → b → null

向表头添加元素c只需把该节点的next指针指向Foo列表的表头即可,时间复杂度是O(1)。如果再向Foo的表头添加其他元素,如:

1
Baz = [d, e | Foo].

那么,Baz的存储结构为:

扫描二维码关注公众号,回复: 2790498 查看本文章
1
2
3
4
5
6
7
Bar Foo
 ↓   ↓
 c → a → b → null
     ↑
 d → e
 ↑
Baz

这个例子中, Bar, Baz直接重用了Foo的元素(存储空间),因为Foo是不可变的,所以这样的处理方式没有副作用。由此可以看出,通过向表头添加元素生成列表的方式的时间复杂度是O(n)。

现在再来分析向表尾添加元素的处理流程。实际上,Erlang中构造列表总是从表尾开始,然后依次在表头添加元素。通过++操作符和append/2函数向表尾“追加“元素只是一种假象,例如:

1
2
3
List1 = [a, b],
List2 = [c, d],
List3 =  List1 ++ List2.

构造列表List3的过程中,List1的数据被复制了一份,依次加到列表List2的表头,每次++操作,都会产生一个新的临时列表!从底层的指令看,每次表尾追加元素总是相当于先遍历一遍List1,所以向表尾追加元素的时间复杂度是O(n2)!

通过表头和表尾来操作列表的时间复杂度分别是O(n)和O(n2),因此,提倡以add_list_head/1的方式来操作列表。

猜你喜欢

转载自blog.csdn.net/boiled_water123/article/details/80879309