【数据结构】块状链表(分块大法好)

问题引入

众所周知,数组索引方便,但插入和删除困难(复杂度极高);而链表恰恰相反,索引困难,而插入和删除简单。

那么当我们遇到既要动态插入删除,又要快速索引元素的问题时,有没有一种数据结构能够同时快速支持这两个操作呢?

以我们老祖宗的强大的智慧,答案当然是有,它就是——

序列之王Splay!!!

不好意思,走错片场了……重来一次。它就是——

块状链表!!!

整体思想

看到它的名字,估计你都能猜出来它的核心思想——分块~~(暴力!)~~。

既然数组和链表都有各自的优缺点,那么我们就考虑把他们组合一下,以达到平衡复杂度的效果。

块状链表的思想是把链表强行套在数组上——链表中的每一个节点对应原数组中的一段。不说估计你也知道,每个节点对应的数组长度是 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!!!

具体实现

洛谷P4008 文本编辑器为例

准备工作

内存池:

动态回收和分配节点(其实跟块状链表并没有什么关系)

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坑了一个晚上+半个早上)。

插入:

这里要区分正式的插入与上文准备工作中的暴力插入:暴力插入是不考虑每个节点的大小和插入后链表的平衡性的。

这意味着如果我们使用暴力插入,会出现以下情况:

error.png

我们原本的链表是基本平衡的,也就是能保证一次操作复杂度在 n \sqrt{n} n 级别的。但是此时有一个非常长的串 T T T想要插入到 S S S的后面,那么如果直接插入,就会导致中间的节点长度巨大,使得一次操作复杂度退化为近似 O ( n ) O(n) O(n)

那么我们怎样来避免这种情况呢?

其实很容易想到,既然我们直接插入会使节点长度太长,那么我们可以先把插入的串进行分块(以 n \sqrt{n} n 作为块的长度),构建成一个链表,然后插入到原链表中即可。

但同时我们也会遇到这样的情况:

EcEQHg.png

我们在插入的时候并不一定能将插入串完整地分成若干个块,可能会留下如图中 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]之间的序列,那么我们就可以像下图这样操作:

EgI2xP.png

我们先把两端的节点分裂,使得删除的区间由完整的块构成,然后直接删除,并同时维护两个端点的信息即可。例如上图最后形成了两个小块,那么我们把它们合并即可。

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!!!)~~。就这道题调了四五个小时,简直毒瘤。但是作为对分块思想的练习还是不错的,同时代码也比较直观。

猜你喜欢

转载自blog.csdn.net/qq_30115697/article/details/90046629