问题:
如何只用一个指针节点实现双向链表?
英语能力有限,翻译起来不会很准确,因此在这里先做一下这篇文章的核心思想阐述:
使用一个指针,通过异或保存上一个节点的地址以及下一个节点的地址。
比如:
2^3 = 1; // 保存 1,就是本文中 ptrdiff 保存的值
2^3^2 = 3; // 异或 2 之后可以得到 3
假设现在有三个节点 prev, current, next
current->ptrdiff =(Node*) prev ^ next;
这样我们就通通过一个节点保存了另个地址,现在如何拿到前一个节点?显然
Node *tmp = (Node*)current->ptrdiff ^ next;
对于小型设备来说,每一小块内存都弥足珍贵。设备制造商需要考虑如何节省内存的使用。一种办法是,为日常使用的抽象数据结构寻找另一种实现。例如双向指针链表。
在本文中,我会用传统的方法和另一种替代方法来实现双指针链表,该双指针链表会有插入、遍历和删除操作。同时,我会打印出内存和时间使用情况,以供参考。这种替代实现的方法是基于指针距离(pointer distance),因此在本文中,我们称为指针距离实现(the pointer distance implementation)。每一个节点只需要一个指针域,就能实现向前或者向后遍历。在传统的实现中,我们需要一个指针指向前一个节点,以及另一个指针指向后一个节点。传统的节点的内存利用率只有34%,而指针距离实现的节点内存利用率是50%。如果我们使用多维的双向指针链表,比如动态网格,节点内存利用率还会更低。
在这里,我们不会讨论过多的去讨论传统的双向指针链表实现,你随便谷歌一下,会有很多相关文章。
节点定义:
typedef int T;
typedef struct listNode{
T elm;
struct listNode * ptrdiff;
};
ptrdiff
存着当前这个节点和下一个节点的差别(difference)以及当前这个节点和前一个节点的差别(difference)。指针区别是通过 异或 运算得到的。任何这种链表实例,都一个StartNode
和一个EndNode
。StartNode
指向链表的头部,EndNode
指向链表的尾部。我们定义StartNode
指向的前一个节点是 NULL
节点,EndNode
指向的下一个节点是NULL
节点。对于单节点链表,它的前一个节点和下一个节点都是NULL
。 简单来说就是当前节点的 ptrdiff
保存着当前节点的前一个节点和下一个节点的异或结果。
遍历
一个特定节点的插入和删除操作都依赖于遍历。我们很容易就能实现向前或者向后遍历。如果把 StartNode
作为一个参数,并且因为它的前一个节点是 NULL
,那么,很显然,这是一个从左往右遍历的操作。同样的,如果把EndNode
作为一个参数,那么,就是从右往左遍历。在本文中,尚未支持从链表中间遍历,但是要支持这种操作并不难。
NextNode()
定义如下
typedef listNode * plistNode;
plistNode NextNode( plistNode pNode,
plistNode pPrevNode){
return ((plistNode)
((int) pNode->ptrdiff ^ ( int)pPrevNode) );
}
给定一个元素元素(element),用 ptrdiff
来保存这个元素和下一个节点以及前一个节点的异或结果。因此,如何和前一个节点再做一次异或操作,那么指针就会指向下一个节点。
插入
给定一个新的节点和一个已经存在的节点元素,通过遍历找到这个元素所在的节点,然后将新的节点插到后面(看链表1)。将一个新的节点插入到双向链表,需要三个节点:当前节点,当前节点的上一个和下一个节点。如果所给的元素是最后一个节点的元素,就把新节点插到链表的最后面。如果在insertAfter()
轮询中,没有找到给定的元素,则放弃插入新的节点。
Listing 1. Function to Insert a New Node
void insertAfter(plistNode pNew, T theElm)
{
plistNode pPrev, pCurrent, pNext;
pPrev = NULL;
pCurrent = pStart;
while (pCurrent) {
pNext = NextNode(pCurrent, pPrev);
if (pCurrent->elm == theElm) {
/* traversal is done */
if (pNext) {
/* fix the existing next node */
pNext->ptrdiff =
(plistNode) ((int) pNext->ptrdiff
^ (int) pCurrent
^ (int) pNew);
/* fix the current node */
pCurrent->ptrdiff =
(plistNode) ((int) pNew ^ (int) pNext
^ (int) pCurrent->ptrdiff);
/* fix the new node */
pNew->ptrdiff =
(plistNode) ((int) pCurrent
^ (int) pNext);
break;
}
pPrev = pCurrent;
pCurrent = pNext;
}
}
首先通过 nextNode()
和遍历找到给定元素所在的节点,如果找到把新的节点插入到该节点的后面。
因为下一个节点有ptrdiff
,可以通过和已经找到的节点进行异或操作解出地址。
接着,和新节点做异或操作,将新节点作为前一个节点。同样的,当前节点也是一样的逻辑。首先通过和下一个节点做异或操作解出ptrdiff
;接着和新节点再做一次异或操作,这样就可以得到正确的ptrdiff
。 最后,因为新节点处于已经找到的节点和下一个节点中间,通过和它们做异或操作得到了ptrdiff
。
删除
当前的删除操作支持删除整个链表。因为本文旨在阐述这种实现的动态内存分配和执行时间的消耗。因此这里不会实现各种双向链表的操作,但是实现它们不会太难。
因为我们的遍历依赖于指向两个节点的指针,因此没有办法在找到下一个节点的时候就可以直接删除当前节点。因此,我们依赖于下面这种规则,当找到下一个节点的时候,删除前一个节点。如果,当前节点是最后一个节点,当它被释放之后,删除结束。如果 nextNode()
返回 NULL
,当前节点就是最后一个节点。
内存和时间的使用
这里不翻译,只贴上我自己运行的结果。
运行效率几乎相同,但是内存省了一大半。
地址实现方式
Before insert(prt.dist.) Thu Apr 12 19:39:56 2018
After insert(prt.dist.) Thu Apr 12 19:39:59 2018
Total Memory taken by ptr distance structure = 480000 bytes.
Before traverse(pStart) of (prt.dist.) Thu Apr 12 19:39:59 2018
After traverse(pStart) of(prt.dist.) Thu Apr 12 19:39:59 2018
Before traverse(pEnd) of (prt.dist.) Thu Apr 12 19:39:59 2018
After traverse(pEnd) of (prt.dist.) Thu Apr 12 19:39:59 2018
Before delList () of (prt.dist.) Thu Apr 12 19:39:59 2018
Final node being deleted in prt.dist. =29999
After delList () of (prt.dist.) Thu Apr 12 19:39:59 2018
--------------
传统实现方式
Before conventional insert() Thu Apr 12 19:39:59 2018
After conventional insert() Thu Apr 12 19:40:02 2018
Total Memory taken by conventional structure = 720000 bytes.
Before conventioal dtraversefw(pdHead ) Thu Apr 12 19:40:02 2018
After conventioal dtraversefw(pdHead ) Thu Apr 12 19:40:02 2018
Before conventioal dtraversebw(pdEnd) Thu Apr 12 19:40:02 2018
After conventioal dtraversebw(pdEnd) Thu Apr 12 19:40:02 2018
Before conventioal ddelList () Thu Apr 12 19:40:02 2018
After conventioal ddelList () Thu Apr 12 19:40:02 2018
源代码地址 GitHub
A Memory-Efficient Doubly Linked List
还可以阅读 XOR linked list