问题引入
众所周知,数组索引方便,但插入和删除困难(复杂度极高);而链表恰恰相反,索引困难,而插入和删除简单。
那么当我们遇到既要动态插入删除,又要快速索引元素的问题时,有没有一种数据结构能够同时快速支持这两个操作呢?
以我们老祖宗的强大的智慧,答案当然是有,它就是——
不好意思,走错片场了……重来一次。它就是——
整体思想
看到它的名字,估计你都能猜出来它的核心思想——分块~~(暴力!)~~。
既然数组和链表都有各自的优缺点,那么我们就考虑把他们组合一下,以达到平衡复杂度的效果。
块状链表的思想是把链表强行套在数组上——链表中的每一个节点对应原数组中的一段。不说估计你也知道,每个节点对应的数组长度是
n
\sqrt{n}
n级别的(应用分块思想)。于是我们在查询的时候,可以先定位元素所在块的编号,接着在块内查找元素,做到
O
(
n
)
O(\sqrt{n})
O(n)查询。同理,在插入(或删除)的时候,可以把插入的序列先分块,然后把这些块接在指定的位置后面(或把指定的块删除),复杂度也可以保持在
O
(
n
)
O(\sqrt{n})
O(n)。所以对于操作数与元素数量同阶时,复杂度为
O
(
n
n
)
O(n\sqrt{n})
O(nn)。不得不说一句,Splay NB!!!
具体实现
准备工作
内存池:
动态回收和分配节点(其实跟块状链表并没有什么关系)
int pool[MAXN], tot; //池中存放可以使用的节点
int new_node() { //分配节点
return pool[tot--];
}
void del(int x) { //回收节点
pool[++tot] = x;
}
void init() { //初始状态所有节点都可以使用
for(int i = 1; i < MAXN; ++i) {
pool[++tot] = MAXN-i;
}
}
链表节点:
struct node {
int sz, nxt; //节点对应的数组长度,链表中下一个节点
char s[MAXB*4]; //节点对应的数组实际值(本题要求为字符)
}
查询位置
//返回原数组中下标为p的元素所在的块的编号,并将传入的引用(原数组下标)改为块内的下标。
int pos(int &p) {
int x = 0;
while(x != -1 && a[x].sz < p) { //为方便下面的split操作,定义下标在[1,sz]内,但实际数组下标从0开始。
p -= a[x].sz, x = a[x].nxt; //一次跳整个节点
}
return x;
}
暴力插入。
//在编号为x的节点后插入一个编号为y,长度为len,对应数组为s的节点
void add(int x, int y, int len, char *s) {
if(y != -1) { //特判x指向-1(即链表的末尾)
a[y].sz = len; //对编号为y的节点赋值
a[y].nxt = a[x].nxt;
memcpy(a[y].s, s, len); //memcpy的第三个参数是复制的字节数,所以这里的len本来应该乘上相应元素类型的size,但本题由于sizeof(char)=1,所以可以不乘。
}
a[x].nxt = y; //x指向y
}
节点合并
//把编号为x和y的两个节点合并
//我们选择把y暴力接在x的后面,然后把y删除
void merge(int x, int y) {
memcpy(a[x].s+a[x].sz, a[y].s, a[y].sz); //把y对应的数组接到x后面
a[x].sz += a[y].sz, a[x].nxt = a[y].nxt; //用y的信息更新x
del(y); //删除y
}
节点分裂
//把第x个节点从pos位置分裂成两个节点(即前pos个和后sz-pos个分离)
void split(int x, int pos) {
if(x == -1 || pos == a[x].sz) return; //如果节点为空或者根本不需要分裂(分裂位置在块的末尾)
int t = new_node(); //分裂后多出一个节点
add(x, t, a[x].sz-pos, a[x].s+pos); //把分离出来的后半部分接在原节点的后面
a[x].sz = pos; //把sz赋值为pos,相当于只保留了前半部分的元素
}
正式开肝
我们做了这么多准备工作,是不是开始有点感到恶心累了?别着急,正式的操作代码更长。这也是块状链表的特点,虽然易于理解,但码量巨较大,并且总会被出其不意的细节坑到(比如我被忘记特判的
−
1
-1
−1坑了一个晚上+半个早上)。
插入:
这里要区分正式的插入与上文准备工作中的暴力插入:暴力插入是不考虑每个节点的大小和插入后链表的平衡性的。
这意味着如果我们使用暴力插入,会出现以下情况:
我们原本的链表是基本平衡的,也就是能保证一次操作复杂度在 n \sqrt{n} n级别的。但是此时有一个非常长的串 T T T想要插入到 S S S的后面,那么如果直接插入,就会导致中间的节点长度巨大,使得一次操作复杂度退化为近似 O ( n ) O(n) O(n)。
那么我们怎样来避免这种情况呢?
其实很容易想到,既然我们直接插入会使节点长度太长,那么我们可以先把插入的串进行分块(以 n \sqrt{n} n作为块的长度),构建成一个链表,然后插入到原链表中即可。
但同时我们也会遇到这样的情况:
我们在插入的时候并不一定能将插入串完整地分成若干个块,可能会留下如图中 T T T这样的长度很小的块,于是可能出现在多次操作后链表中出现了很多小块,使时间复杂度退化到一次操作 O ( n ) O(n) O(n)。
那么这时候就要用到之前准备的合并操作了,因为我们插入使中间的块长度一定是 n \sqrt{n} n,所以我们只考虑插入串的左右端点,即插入时的两个“接口”。如果接口两端的块长度和小于 n \sqrt{n} n,就直接合并。
还有一个细节问题,我们不一定是在完整地块后面插入,也有可能是在块内的某个元素后面插入,那么我们就需要通过 p o s pos pos和 s p l i t split split操作的配合,把插入位置强行变成一个完整块的末尾,再进行插入即可。
void insert(int x, int len, char *s) { //在位置x后插入长度为len的序列
int now = pos(x), nxt = now, tot = 0, t; //查询元素位置
split(now, x); //分裂插入位置所在节点
while(tot+MAXB <= len) { //运用准备操作中的add将插入串分块插入,最后会剩下单独长度为len-tot的一个串
t = new_node();
add(nxt, t, MAXB, s+tot); //MAXB是钦定的块长
nxt = t;
tot += MAXB;
}
if(tot < len) { //插入最后单独的串
t = new_node();
add(nxt, t, len-tot, s+tot);
}
//暴力合并接口两端,注意对-1的特判
if(t != -1 && a[nxt].sz+a[t].sz < MAXB) {
merge(nxt, t);
}
if(a[now].nxt != -1 && a[now].sz+a[a[now].nxt].sz < MAXB) {
merge(now, a[now].nxt);
}
}
删除
有了插入操作的基础后,删除操作就比较简单了。
例如我们要删除区间 [ p , q ] [p,q] [p,q]之间的序列,那么我们就可以像下图这样操作:
我们先把两端的节点分裂,使得删除的区间由完整的块构成,然后直接删除,并同时维护两个端点的信息即可。例如上图最后形成了两个小块,那么我们把它们合并即可。
void remove(int x, int len) { //从x开始删掉长度为len的串
int now = pos(x); //定位,然后在左端点进行分割
split(now, x);
int nxt = a[now].nxt;
while(nxt != -1 && len >= a[nxt].sz) { //能删整块就删
len -= a[nxt].sz;
nxt = a[nxt].nxt;
}
if(nxt != -1) { //还是要特判节点为空
split(nxt, len); //在右端点分割
nxt = a[nxt].nxt; //定位到删除后的右边部分
}
for(int i = a[now].nxt; i != nxt; i = a[now].nxt) { //维护左端点的nxt并且回收节点
a[now].nxt = a[i].nxt;
del(i);
}
while(nxt != -1 && a[now].sz+a[nxt].sz < MAXB) { //暴力合并左右端点(能合并就合并)
merge(now, nxt);
nxt = a[nxt].nxt;
}
}
查询
这个操作其实没什么技术含量,无非就是定位之后整块输出。不过要注意一些细节
void get(int x, int len) {
int now = pos(x), tot = min(a[now].sz-x, len); //先处理左边不完整的部分
memcpy(ans, a[now].s+x, tot);
int nxt = a[now].nxt;
while(nxt != -1 && len >= a[nxt].sz+tot) { //整块合并答案
memcpy(ans+tot, a[nxt].s, a[nxt].sz);
tot += a[nxt].sz;
nxt = a[nxt].nxt;
}
if(nxt != -1 && tot < len) { //处理右边不完整的部分
memcpy(ans+tot, a[nxt].s, len-tot);
}
ans[len] = '\0'; //标记字符串结尾
printf("%s\n", ans);
}
小小的小结
块状链表虽然思想简单,但是码量较大,细节较多,感觉并不适合当做模板来背,同时复杂度也并不是特别优秀~~(Splay NB!!!)~~。就这道题调了四五个小时,简直毒瘤。但是作为对分块思想的练习还是不错的,同时代码也比较直观。