1. 概念
对链表而言,双向均可遍历是最方便的,另外首尾相连循环遍历也可大大增加链表操作的便捷性。因此,双向循环链表,是在实际运用中是最常见的链表形态。
2. 基本操作
与普通的链表完全一致,双向循环链表虽然指针较多,但逻辑是完全一样。基本的操作包括:
- 节点设计
- 初始化空链表
- 增删节点
- 链表遍历
- 销毁链表
3. 节点设计
双向链表的节点只是比单向链表多了一个前向指针。示例代码如下所示:
typedef struct node
{
// 以整型数据为例
int data;
// 指向相邻的节点的双向指针
struct node * prev;
struct node * next;
}node;
3. 初始化
所谓初始化,就是构建一条不含有效节点的空链表。
以带头结点的双向循环链表为例,初始化后,其状态如下图所示:
在初始空链表的情况下,链表只有一个头结点,下面是初始化示例代码:
node * initList()
{
// 申请头结点
node * head = malloc(sizeof(node));
// 让首尾互相指向
// 不需要对头结点的 data 做任何操作
if(head != NULL)
{
head->prev = head;
head->next = head;
}
return head;
}
4. 插入节点
与单链表类似,也可以对双链表中的任意节点进行增删操作,常见的有所谓的头插法、尾插法等,即:将新节点插入到链表的首部或者尾部,示例代码是:
- 头插法:将新节点插入到链表的头部
// 将新节点new,插入到链表的首部
void insertHead(node *head, node *new)
{
new->prev = head;
new->next = head->next;
head->next->prev = new;
head->next = new;
}
- 尾插法:将新节点插入到链表的尾部
// 将新节点new,插入到链表的尾部
void insertTail(node *head, node *new)
{
new->prev = head->prev;
new->next = head;
head->prev->next = new;
head->prev= new;
}
5. 剔除节点
注意,从链表中将一个节点剔除出去,并不意味着要释放节点的内容。当然,我们经常在剔除了一个节点之后,紧接着的动作往往是释放它,但是将“剔除”与“释放”两个动作分开,是最基本的函数封装的原则,因为它们虽然常常连在一起使用,但它们之间并无必然联系,例如:当我们要移动一个节点的时候,实质上就是将“剔除”和“插入”的动作连起来,此时就不能释放该节点了。
在双向链表中剔除指定节点的示例代码如下:
// 将指定节点p从链表中剔除
void remove(node *head, node *p)
{
p->prev->next = p->next;
p->next->prev = p->prev;
p->prev = NULL;
p->next = NULL;
}
6. 链表的遍历
对于双向循环链表,路径可以是向后遍历,也可以向前遍历。
下面是向前遍历、向后遍历的示例代码,假设遍历每个节点并将其整数数据输出:
// 向前遍历
void listForEachPrev(node * head)
{
if(isEmpty(head))
return;
for(node * tmp=head->prev; tmp!=head; tmp=tmp->prev)
{
printf("%d\n", tmp->data);
}
}
// 向后遍历
void listForEach(node * head)
{
if(isEmpty(head))
return;
for(node * tmp=head->next; tmp!=head; tmp=tmp->next)
{
printf("%d\n", tmp->data);
}
}
7. 销毁链表
由于链表中的各个节点被离散地分布在各个随机的内存空间,因此销毁链表必须遍历每一个节点,释放每一个节点。
注意:
销毁链表时,遍历节点要注意不能弄丢相邻节点的指针
示例代码如下:
void destroy(node * head)
{
if(isEmpty(head))
return;
node *n;
for(node *tmp = head->next; tmp!=NULL; tmp=n)
{
n = tmp->next;
free(tmp);
}
}
8. 适用场合
经过单链表、双链表的学习,可以总结链表的适用场合:
- 适合用于节点数目不固定,动态变化较大的场合
- 适合用于节点需要频繁插入、删除的场合
- 适合用于对节点查找效率不十分敏感的场合