链表
前置芝士:一维数组
数组是最基础的数据结构,支持 O ( 1 ) \Omicron (1) O(1)随机访问(能够查询访问任意下标的值),但欲插入(或删除)某一下标的数,其后的所有数字需移动以腾出空位(或填补空位),其插入或删除操作时间复杂度为 O ( n ) \Omicron(n) O(n).可见数组的插入和删除效率是极其低下的.
为方便插入和删除操作,我们引入一种线性链式数据结构——链表.
链表支持 O ( 1 ) \Omicron(1) O(1)插入和删除,却不支持随机访问,只能按照顺序访问其中元素。链表通常采用指针以及动态分配内存方式实现,但是我不会。因此我们采用数组模拟链表.
单向链表
首先定义一个 i n t int int类型变量 h e a d head head表示链表头, t a i l tail tail表示表尾,数组 n u m [ i ] num[i] num[i]和 n x t [ i ] nxt[i] nxt[i]分别表示第 i i i个节点所存储的值以及其链接的下一个节点编号, c n t cnt cnt是一个简单计数器,用于为新节点赋编号.
操作如下:
const ll N=1e5+5;
ll head, cnt, tail;
ll num[N], nxt[N];
void build() {
tail=head=++cnt; //新建一个链表,表头为cnt
}
void Insert(int pos, int y) {
num[++cnt]=y; //新开一个节点cnt,存储数值y
nxt[cnt]=nxt[pos]; //将原先pos的后继作为新节点cnt的后继
nxt[pos]=cnt; //原先pos的后继变为新节点cnt
if (pos==tail) tail=cnt; //若在尾部插入,则更新表尾
//通过Insert操作,我们可以在链表的节点pos后插入带有数值为y的新节点cnt
//cnt链接的下个节点是插入前pos的后继,而pos新的后继为cnt
//这样就达到了在pos与nxt[pos]之间插入cnt的目的
}
void access() { //链表的遍历
for (int i=nxt[head]; i; i=nxt[i]) {
//若链表头不存储数值,则nxt[head]为第一个存储数值的节点,最后一个节点遍历完,其nxt未赋值,为0,故遍历到0结束
cout<<num[i];
}
}
void del(int goal) { //删除节点goal,首先需要找到pos的前驱节点
for (int i=head; i; i=nxt[i]) {
if (nxt[i]==goal){ //如果节点i的后继是goal,那i即为goal的前驱
nxt[i]=nxt[goal]; //将goal的后继继承给i,后续遍历便访问不到goal,即goal被删除
if (goal==tail) tail=i; //更新表尾
break; //删除完毕,终止遍历
}
}
}
从代码可见,若已知目标节点,单项链表的插入操作是 O ( 1 ) \Omicron(1) O(1)的,但是其删除操作却是 O ( n ) \Omicron(n) O(n),原因在于我们需要找到待删除节点的前驱节点.
如果我们记录每个节点的前驱,这样查询前驱的复杂度就降低为 O ( 1 ) \Omicron(1) O(1).同时记录节点前驱和后继的链表,就是双向链表.
双向链表
顾名思义,支持双向访问的链表.在单向链表的基础上,对每个节点添加一个 p r e pre pre变量. p r e [ i ] pre[i] pre[i]表示第 i i i个节点的前驱.特别地, p r e [ h e a d ] = 0 pre[head]=0 pre[head]=0
插入操作思路与单向链表基本一致,注意前驱转换的细节即可.
void Insert(int pos, int y) {//在pos后插入新节点,存储数值y
num[++cnt]=y; //新开一个节点cnt,存储数值y
nxt[cnt]=nxt[pos]; //将原先pos的后继作为新节点cnt的后继
nxt[pos]=cnt; //原先pos的后继变为新节点cnt
pre[nxt[cnt]]=cnt; //cnt后继的前驱是cnt本身,故记录为cnt
pre[cnt]=pos; //cnt插入在pos之后,故pre[cnt]=pos
}
区别于单向链表的是,双向链表支持 O ( 1 ) \Omicron(1) O(1)查询前驱,所以其删除操作是 O ( 1 ) \Omicron(1) O(1)的.
void del(int goal) { //删除节点goal,首先需要找到pos的前驱节点
ll rem=pre[goal]; //节点goal的前驱记录为rem
nxt[rem]=nxt[goal];//rem的后继继承goal的后继
pre[nxt[goal]]=rem;//goal后继的前驱继承goal的前驱
//此操作过后再次访问链表,访问到rem时下一个节点即nxt[goal]
//由此,goal节点已从链表中删去
}
循环链表
分为单项循环链表和双向循环链表.
其实两种链表的实现方式是相一致的,我们只需在建立空链表(仅有表头)的时候,改为
head=++cnt; nxt[head]=head;
pre[head]=head; //若为双向
这样若后续插入节点,我们尾节点的后继会继承为 h e a d head head,如此便可循环遍历链表.当然,在循环链表中我们一般会为 h e a d head head节点赋值,这样 h e a d head head节点的地位与其余节点是等价的,也符合循环的概念(没头没尾).
例题:P1996 约瑟夫问题
题目描述:
n n n 个人围成一圈,从第一个人开始报数,数到 m m m 的人出列,再由下一个人重新从 1 1 1 开始报数,数到 m m m 的人再出圈,依次类推,直到所有的人都出圈,请输出依次出圈人的编号。
思路:
构建双向循环链表,朝一个方向边遍历边计数,计数至 m m m时将当前遍历到的节点删去,然后重新计数,直到仅剩下一人.
code:
const int N=10000;
int pre[N], nxt[N], n, m, cnt;
//cnt 表示出局人数
int main() {
scanf("%d %d", &n, &m);
for (int i=1; i<=n; i++) nxt[i]=i+1, pre[i]=i-1; //链接建表
nxt[n]=1, pre[1]=n; //形成循环
int now=1, count=0; //当前遍历位置now, 当前计数count
while(cnt<n-1) { //n-1个人出局时结束循环
++count;
if (count==m) {
count=0; //计数器清零
pre[nxt[now]]=pre[now];
nxt[pre[now]]=nxt[now];
++cnt;
//删除now,当前人出局
printf("%d ", now);
}
now=nxt[now]; //继续往下遍历
}
printf("%d\n", now);
}