块状链表详解

众所周知,数组可以以 \(O(1)\) 的复杂度查询、修改元素,但删除和插入元素的复杂度是 \(O(n)\) 的;链表恰好相反,插入、删除元素的复杂度为 \(O(1)\),但查询为 \(O(n)\)。

有没有能以较优复杂度完成插入、删除、查询、修改操作的线性数据结构?

本篇介绍的块状链表就可以。

顾名思义,块状链表运用分块的思想,将数组与链表结合起来。这里引用一张经典的图来展示它大概的样子:

可以看出块状链表就是以数组为节点(块)的链表。

例一

我们借P4008 [NOI2003] 文本编辑器这道题来理解块状链表的各个操作。

维护一个字符串,支持在指定位置插入字符串、删除字符串、查询指定长度的连续字串。

内存分配

与正常的链表一样,块状链表中会出现很多新建、删除节点的操作,必须让被删除节点的位置能被新节点再次占用,否则会产生很多空节点,从而使内存巨大。为此我们使用作为内存池的栈 \(pool[]\)。

复制代码
void init()
{
    for(int i=1;i<=mxnum;i++)//初始化内存池
        pool[i]=i;
    sz[0]=0;
    nxt[0]=-1;//标记链表的结尾
}

int new_block()
{
    return pool[++num];//取出栈顶空节点
}

void del_block(int x)
{
    pool[num--]=x;//将被删除的节点压入栈顶
}

合并

为了保证复杂度,需要同时限制块的总数与最大块长。一个很好的方法是,对于相邻的块,如果他们的长度和不超过最大块长,就将他们合并。

复制代码
void merge(int x,int y)
{
    cpy(dt[x]+sz[x],dt[y],sz[y]);//手写的复制函数,与memcpy功能相同
    nxt[x]=nxt[y];
    sz[x]+=sz[y];
    del_block(y);//合并后,多出来的块要删掉
}

void maintain()//遍历整个链表,合并所有可以合并的相邻块
{
    for(int x=0,y=nxt[0];x!=-1;x=nxt[x],y=nxt[x])
        while(y!=-1 && sz[x]+sz[y]<=mxsize)
        {
            merge(x,y);
            y=nxt[x];
        }
}

分裂

在插入或删除元素时,需要将待操作位置单独分成块,再进行类似链表的插入、删除操作。

复制代码
void update(int x,int y,int len,char *s)
{
    nxt[y]=nxt[x];
    nxt[x]=y;
    sz[y]=len;
    cpy(dt[y],s,len);
}

void split(int x,int pos)
{
    if(x==-1 || pos==sz[x])
        return;
    
    int y=new_block();
    update(x,y,sz[x]-pos,dt[x]+pos);//新建块
    sz[x]=pos;//记得更新原块长
}

具体操作

  • 插入:将插入位置的块分裂,把待插入字符串分成长度不超过 \(mxsize\) 的块,依次插入。

  • 删除:将要删除的左右端点的块分裂,删去中间所有块。

  • 查询:将会被查到的所有块复制到答案字符串上。

    int get_pos(int &pos)//将输入的位置转换成块上的位置
    {
    int x=0;
    while(x!=-1 && pos>sz[x])
    {
    pos-=sz[x];
    x=nxt[x];
    }
    return x;
    }

    void insert(int pos,int len)
    {
    int x=get_pos(pos),y,sum=0;
    split(x,pos);

    复制代码
      while(sum+mxsize<=len)
      {
          y=new_block();
          update(x,y,mxsize,s+sum);
          sum+=mxsize;
          x=y;
      }
    
      if(len>sum)//如果切完后还有剩余,尾部自成一块
      {
          y=new_block();
          update(x,y,len-sum,s+sum);
      }
    
      maintain();//插入的左、右端点处可能可以合并,后面删除操作的maintain()同理

    }

    void erase(int pos,int len)
    {
    int x=get_pos(pos),y;
    split(x,pos);
    y=nxt[x];

    复制代码
      while(y!=-1 && len>0)
      {
          if(sz[y]>len)
              split(y,len);
          len-=sz[y];
          del_block(y);
          y=nxt[y];
      }
    
      nxt[x]=y;//记得指向下一块
      
      maintain();

    }

    void query(int pos,int len)
    {
    int x=get_pos(pos),sum=sz[x]-pos;
    if(len<sum)
    sum=len;

    复制代码
      cpy(s,dt[x]+pos,sum);
      x=nxt[x];
    
      while(x!=-1 && sum+sz[x]<=len)
      {
          cpy(s+sum,dt[x],sz[x]);
          sum+=sz[x];
          x=nxt[x];
      }
    
      if(x!=-1 && len>sum)
          cpy(s+sum,dt[x],len-sum);
    
      s[len]=0;//标记串的结尾
      printf("%s\n",s);

    }

总体代码

复制代码
#include <bits/stdc++.h>
using namespace std;

const int N=1024*1024+5;
const int mxsize=3000,mxnum=N*2/mxsize+5;//要开到N的两倍

int q;
int num,nxt[mxnum],sz[mxnum],pool[mxnum];
char dt[mxnum][mxsize],s[N];

void cpy(char *a,char *b,int len)
{
    for(int i=0;i<len;i++)
        *(a+i)=*(b+i);
}

void gets(int len)
{
    for(int i=0;i<len;i++)
    {
        s[i]=0;
        while(s[i]<32 || s[i]>126)
            s[i]=getchar();
    }
}

//省略

int main( void )
{
    scanf("%d",&q);

    init();
    int pos=0;

    while(q--)
    {
        char rd[10];
        scanf("%s",rd);

        if(rd[0]=='M')
            scanf("%d",&pos);

        else if(rd[0]=='I')
        {
            int len;
            scanf("%d",&len);
            gets(len);
            insert(pos,len);
        }

        else if(rd[0]=='D')
        {
            int len;
            scanf("%d",&len);
            erase(pos,len);
        }

        else if(rd[0]=='G')
        {
            int len;
            scanf("%d",&len);
            query(pos,len);
        }

        else if(rd[0]=='P')
            pos--;
        
        else if(rd[0]=='N')
            pos++;
    }

    return 0;
}

例二

P3391 【模板】文艺平衡树

给定一个长度为 \(n\) 的序列 \(a\),初始 \(a_i=i\),执行多次区间翻转操作,求操作后序列。

将需要翻转的区间中的所有块顺序翻转,并给每一块打上翻转标记,在合并、分裂时检查标记。

复制代码
void rev(int x)
{
    if(tag[x])
        reverse(a[x],a[x]+sz[x]);
    tag[x]=0;//翻转后取消标记
}

void rotate(int pos,int len)
{
    int x=get_pos(pos);
    split(x,pos);
    int y=nxt[x];
    top=0;

    while(len>0)
    {
        if(sz[y]>len)
            split(y,len);

        len-=sz[y];
        sta[++top]=y;
        tag[y]^=1;//注意这里不能直接=1
        y=nxt[y];
    }

    sta[++top]=x;
    sta[0]=y;
    for(;top>=1;top--)
        nxt[sta[top]]=sta[top-1];//反着连接块

    maintain();
}

例三

P2596 [ZJOI2006] 书架

给定一个 \(1\)~\(n\) 的排列,操作为将某个数放到最前面、放到最后面或向前、后移动一步,询问 \(s\) 位置的数或数 \(s\) 的位置。

这三种操作都可以视为从排列中删除一个数,再插入一个数。

这道题中比较麻烦的一点是,移动数的操作给定的都是数的值,因此可以给每一个块开一个桶 \(book[]\) 以记录其中是否包含某个数,进行移动操作之前先查询位置。

复制代码
#include <bits/stdc++.h>
using namespace std;

const int N=405;

int n,m,k;
int a[N][N],sz[N],pool[N],nxt[N],book[N][80005];

void init()
{
    for(int i=1;i<=N-5;i++)
        pool[i]=i;
    nxt[0]=-1;
}

int new_block()
{
    return pool[++k];
}

void del_block(int x)
{
    for(int i=0;i<sz[x];i++)
        book[x][a[x][i]]=0;//记得清空book
    pool[k--]=x;
}

void cpy(int *a,int *b,int len)
{
    for(int i=0;i<len;i++)
        *(a+i)=*(b+i);
}

void merge(int x,int y)
{
    cpy(a[x]+sz[x],a[y],sz[y]);
    nxt[x]=nxt[y];
    sz[x]+=sz[y];

    for(int i=0;i<sz[y];i++)
        book[x][a[y][i]]=1;
    del_block(y);
}

void maintain()
{
    for(int x=0,y=nxt[0];x!=-1;x=nxt[x],y=nxt[x])
        while(y!=-1 && sz[x]+sz[y]<=400)
        {
            merge(x,y);
            y=nxt[x];
        }
}

void update(int x,int y,int len,int *c)
{
    nxt[y]=nxt[x];
    nxt[x]=y;
    sz[y]=len;
    cpy(a[y],c,len);

    for(int i=0;i<sz[y];i++)
        book[y][a[y][i]]=1;
}

void split(int x,int pos)
{
    if(x==-1 || pos==sz[x])
        return;

    int y=new_block();
    update(x,y,sz[x]-pos,a[x]+pos);
    sz[x]=pos;
    for(int i=0;i<sz[y];i++)
        book[x][a[y][i]]=0;
}

int get_pos(int &pos)
{
    int x=0;

    while(x!=-1 && pos>sz[x])
    {
        pos-=sz[x];
        x=nxt[x];
    }

    return x;
}

void insert(int pos,int num)
{
    int x=get_pos(pos);
    split(x,pos);

    int y=new_block();
    update(x,y,1,&num);

    maintain();
}

void remove(int pos)
{
    int x=get_pos(pos);
    split(x,pos);
   
    int y=nxt[x];
    split(y,1);
    nxt[x]=nxt[y];
    del_block(y);

    maintain();
}

int query(int pos)
{
    int x=get_pos(pos);
    return a[x][pos-1];
}

int askk(int id)
{
    int x=0,num=0;

    while(!book[x][id])
    {
        num+=sz[x];
        x=nxt[x];
    }//跳到包含id的块

    for(int i=0;i<sz[x];i++,num++)
        if(a[x][i]==id)//找到id具体位置
            return num;
}

void top(int id)
{
    if(askk(id)!=0)//判断边界
    {
        remove(askk(id));
        insert(0,id);
    }
}

void bottom(int id)
{
    if(askk(id)!=n-1)
    {
        remove(askk(id));
        insert(n-1,id);
    }
}

void step(int id,int t)
{
    if(!t)
        return;
    int pos=askk(id);
    
    if(t==1)
        if(pos!=n-1)
        {
            remove(pos);
            insert(pos+1,id);
        }

    if(t==-1)
        if(pos!=0)
        {
            remove(pos);
            insert(pos-1,id);
        }
}

int main( void )
{
    scanf("%d%d",&n,&m);
    init();

    for(int i=1;i<=n;i++)
    {
        int x;
        scanf("%d",&x);
        insert(i-1,x);
    }
    

    for(int i=1;i<=m;i++)
    {
        char c[10];
        int s,t;
        scanf("%s%d",c,&s);

        if(c[0]=='T')
            top(s);
        
        else if(c[0]=='B')
            bottom(s);
        
        else if(c[0]=='I')
        {
            scanf("%d",&t);
            step(s,t);
        }

        else if(c[0]=='A')
            printf("%d\n",askk(s));
        
        else if(c[0]=='Q')
            printf("%d\n",query(s));
    }

    return 0;
}