块状链表

用途是用$O(\sqrt{n})$的方式线段计算(线段树是二分$O(logN)$)

是不是和莫队很像2333

  • 相当于(数组+链表)
  • 网站part

基本操作

(1)定位:先定位元素所在的链表节点,然后再定位该元素在数组中的位置。

(2)分裂:将某个链表节点分裂成两个节点。

(3)插入:首先定位要插入的位置,然后将所在节点分裂成两个节点,并将数据放到第一个节点的末尾。 如果要插入的是一大块数据,首先要将数据切成多个block(每个block对应一个块状链表的一个节点)并将这些block链起来,然后将它们插入那两个节点之间。

关键点和复杂度分析

该算法的核心是确定链表长度和每个节点的数组长度,以及怎么保证这个长度值?设块状链表中元素总个数为X,链表长度为n,每个节点中数据长度为m,则当m=n=sqrt(X)时,可保证m和n同时最小,此时各种操作的时间复杂度最低。在实际应用时,需维持块状链表的每个节点大小在[sqrt(n)/2, 2*sqrt(n)],否则,块状链表会退化。维护方法是,适当的时候,对节点进行合并与分裂(维护本身不会使复杂度增加)

  • 论文part

而且块状链表非常好扩展,只要是序列操作,比如:统一赋值,翻转,求和,维护最小值等等,都可以使用块状链表得到img的复杂度,而如果将整个块状链表维护成有序的,它甚至可以实现平衡树的一些操作[1],毕竟平衡树也可以看作是一种维护序列的方法。

又因为块状链表只在每个分块记录一些额外信息,它的空间利用率很高,而同是模拟方法的Splay需要在每个节点上维护全部额外信息,虽然速度比较快,却占用大量内存[2]

其实,在日常生活中我们经常会用到块状链表:传统的FAT文件系统就是将磁盘扇区分簇,然后用FAT表(FileAllocation Table 文件分配表)来记录每一个簇的状态:是否损坏,是否被使用,如果被使用那么它的下一个簇是哪一个簇。可见,FAT文件系统的思想和块状链表是一致的。

而且因为块状链表空间利用率很高,分块的结构又能很方便的和缓冲区结合使用,Vim[3]也使用了块状链表,在内存的存储和在磁盘上的缓冲都使用了类似块状链表的结构[4]。试想如果用Splay去写一个文本编辑器会是多么复杂而抽象,它又如何方便地利用缓冲区,一旦发生崩溃、断电等意外事件,又如何从磁盘缓冲中重构树结构、恢复数据?

另外,已经有人在g++的库中写了一个基本的块状链表模板:__gnu_cxx::rope,也就是说,使用C++的同学可以很方便的得到一个现成的块状链表[5]


[1] 我用块状链表做NOI2006happybirthday,略微比我写的SBT短一些,可以过8个点(第8个点接近时限)。

[2] 利用Splay可以把模拟操作的复杂度降到img(其实这也是空间换时间的例子:线段树、Splay维护的额外信息多,空间占用大,但是速度也快),关于Splay的讨论可以参考2007年余江伟的集训队论文《如何解决好动态统计问题》,2004年杨思雨的集训队论文《伸展树的基本操作与应用》。

[3] Vi IMproved,应该算是很有名了吧。

[4] 就像BramMoolenaar所说:A texteditor is used all day by many people; it is worth investing time and effort inmaking it work well. Vim使用的数据结构很复杂,但是原理和块状链表是相似的。

[5] http://www.sgi.com/tech/stl/Rope.html上有一份rope的文档。

例程 4+2 07

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
#include<stdio.h>
#include<string.h>
#include<math.h>

#define maxc 1000000 //数列的最大长度
int C; //数列的长度
int val[maxc]; //数列中的每个元素

struct NODE { //每个元素为链表中的一个节点
int next; //链表的下一节点
int jump; //链表的下sqrt(N)个节点
long long sum; //链表中以这个节点开始的连续sqrt(N)个节点的和
};
NODE list[maxc]; //链表的每个节点
struct LIST { //连续段
int head; //连续段的起点
int len; //连续段的长度
};
int J; //保存sqrt(N)的值
LIST l; //整个链表看作一个连续段

void init() { //初始化函数
J = sqrt(C); //计算sqrt(N)的值
if (J<1) J=1; //最少为1

int i;
for (i=0;i<C;i++){ //初始化链表
//链表的下一元素指针
if(i<C-1) list[i].next=i+1; else list[i].next=-1;
//链表的下J元素指针
if(i<=C-J) list[i].jump=i+J; else list[i].jump=-1;
list[i].sum = 0; //清空和为0
}

long long k;
for(i=0,k=0;i<J;i++)k+=val[i]; //计算前J项和
for(i=0;i<C-J;i++){ //计算每个数之后的连续J项和
list[i].sum = k;
//从前一个连续J项和,减去第一项,加上最后一项,得到新的连续J项和
k = k - val[i] + val[i+J];
}
list[i].sum = k;

l.head = 0; //初始连续段的起点
l.len = C; //初始连续段的长段
}

int getlocate(LIST l, int t) //求连续段的第t个元素的位置
{

if(t<0 || t>=l.len) return -1; //输入位置不合法
int i;
i = l.head;
while(t>=J){ //还有超过J个元素
i = list[i].jump; //直接跳过J个元素
t -= J;
}
while(t){ //不超过J个元素
i = list[i].next; //超过1个元素
t --;
}
return i; //返加元素所在位置
}

LIST merge(LIST a, LIST b){ //数列合并
if(a.len==0) return b; //a数列为空时返回b数列
if(b.len==0) return a; //b数列为空时返回a数列
int ha, hb, ia, ib, i,j;

i = getlocate(a, a.len-1); //找到a数列最后一个元素
list[i].next = b.head; //指向b数列的第一个元素
LIST re;re.head=a.head, re.len=a.len+b.len; //合并后的数列
if(re.len<J) return re; //数列长度小于J,则不需要改变指针和连续段和

if(a.len<J){ //a的长度小于J,则改变指针要从a的第一个元素开始
ia = 0; //a的第一个元素
ib = J-a.len; //对应的J个元素后所在的位置
} else { //a的长度不小于J
ia = a.len - J; //从a的倒数第J个元素开始
ib = 0; //从b的第一个元素开始
}
ha = getlocate(a, ia); //求出第一个元素所在位置
hb = getlocate(b, ib); //求出最后一个元素所在位置
long long k;
//连续J个元素的和
for(i=k=0,j=ha;i<J;i++,j=list[j].next) k+=val[j];
while(ia<a.len && ib<=b.len){ //枚举所有需要改变的节点
list[ha].jump = hb; //修改跳过J个元素的指针
list[ha].sum = k; //修改连续J个元素的和
k -= val[ha]; //减去第一个元素
if(hb!=-1) k += val[hb]; //加上最后一个元素
ha = list[ha].next; //第一个元素的下一节点
hb = list[hb].next; //最后一个元素的下一节点
ia++, ib++; //下一个连续J个元素
}
return re; //返回合并后的数列
}

void move(int sa, int sb, int np){ //移动一段连续数列
int leftpartlen; //左边部分的元素个数
if(np<sa)leftpartlen=np+1; //移动段在插入位置的右边
else leftpartlen=np+1-(sb-sa+1);//移动段在插入位置的左边

LIST left, mid, right; //初始段分成三段
left.head = l.head; //左段的起始位置
left.len = sa; //左段的长度
mid.head = getlocate(l, sa); //中段的起始位置
mid.len = sb-sa+1; //中段的长度
right.head = getlocate(l, sb+1);//右段的起始位置
right.len = C-sb-1; //右段的长度
l = merge(left, right); //合并左右段
//在插入位置分成两段
left.head = l.head; //左段起始位置
left.len = leftpartlen; //左段长度
right.head = getlocate(l, leftpartlen);//右段起始位置
right.len = l.len - leftpartlen;//右段长度
l = merge(left, mid); //合并左段和中段
l = merge(l, right); //合并右段
}

void sum(int sa, int sb){ //求连续段的和
int i,t;
long long ans = 0; //初始化和为0
i = getlocate(l,sa); //起始元素位置
t = sb-sa+1; //连续段长度
while(t>=J){ //超过J个元素
ans += list[i].sum; //一次累加连续J个元素的和
i = list[i].jump; //跳过J个元素
t -= J;
}
while(t){ //不足J个元素
ans += val[i]; //累加一个元素
i = list[i].next; //跳过一个元素
t --;
}
printf("%I64d\n", ans); //输出答案
}

int main() {
int cs;
scanf("%d",&cs); //输入数据组数
while(cs--){
scanf("%d",&C); //输入数列长度
int i,j;
for(i=0;i<C;i++)scanf("%d",&val[i]); //输入每个元素
init(); //初始化

int S, sa, sb, np;
scanf("%d",&S); //输入操作数
char st[30];
for(i=0;i<S;i++){
scanf("%s",st);
if(strcmp(st,"Move")==0){//如果是移动操作
scanf("%d%d%d",&sa,&sb,&np);
sa--,sb--,np--;
move(sa,sb,np); //调用移动操作函数
}
else if(strcmp(st,"Sum")==0){//如果是求和操作
scanf("%d%d",&sa,&sb);
sa--,sb--;
sum(sa,sb); //调用求和操作函数
}
}
}
return 0;
}

参考资料

  • 中山大学程序设计竞赛(中山大学内部选拔赛) 真题解(一)
  • 对块状链表的一点研究 苏煜
文章目录
  1. 1. 例程 4+2 07