在阅读有关C数据结构的书时,我遇到了“内存高效的双重链接列表”这个术语。它只有一行说一个内存有效的双向链表使用比普通双链表更少的内存,但做同样的工作。没有更多的解释,也没有给出任何例子。只是有人认为这是从一本期刊中获取的,而在“Brackets”中则是“Sinha”。
在Google上搜索后,我最接近的是this。但是,我什么都听不懂。
有人可以解释一下C中的内存高效双链表是什么?它与普通的双向链接列表有什么不同?
编辑:好的,我犯了一个严重的错误。看到我上面发布的链接,是文章的第二页。我没有看到有第一页,并认为给出的链接是第一页。文章的第一页实际上给出了解释,但我不认为它是完美的。它只讨论了内存高效链接列表或XOR链接列表的基本概念。
答案 0 :(得分:56)
我知道这是我的第二个答案,但我认为我在这里提供的解释可能比最后一个答案更好。但请注意,即使这个答案也是正确的。
内存高效链接列表通常被称为 XOR链接列表,因为它完全依赖于 XOR 逻辑门及其属性。
是,确实如此。它实际上与双重链接列表几乎完成相同的工作,但它是不同的。
双向链接列表存储两个指针,指向下一个节点和前一个节点。基本上,如果你想返回,你会转到back
指针指向的地址。如果要继续,请转到next
指针指向的地址。它就像:
内存高效链接列表,或 XOR链接列表只有一个指针而不是两个。这将存储先前的地址(addr(prev)) XOR (^)下一个地址(addr(next))。如果要移动到下一个节点,可以执行某些计算,并查找下一个节点的地址。这与前一个节点相同。就像:
XOR链接列表,正如您可以从其名称中得出的,高度依赖于逻辑门 XOR (^)及其属性。
它的属性是:
|-------------|------------|------------|
| Name | Formula | Result |
|-------------|------------|------------|
| Commutative | A ^ B | B ^ A |
|-------------|------------|------------|
| Associative | A ^ (B ^ C)| (A ^ B) ^ C|
|-------------|------------|------------|
| None (1) | A ^ 0 | A |
|-------------|------------|------------|
| None (2) | A ^ A | 0 |
|-------------|------------|------------|
| None (3) | (A ^ B) ^ A| B |
|-------------|------------|------------|
现在让我们把它放在一边,看看每个节点存储的内容:
第一个节点或 head 存储0 ^ addr (next)
,因为没有先前的节点或地址。它看起来像:
然后第二个节点存储addr (prev) ^ addr (next)
。它看起来像:
上图显示了节点B或第二个节点。 A和C是第三和第一节点的地址。除了头部和尾部之外的所有节点都与上面的节点类似。
列表的尾部,没有任何下一个节点,因此它存储addr (prev) ^ 0
。它看起来像:
在看到我们如何移动之前,让我们再次看到XOR链表的表示:
当你看到
它显然意味着有一个链接字段,您可以使用它前后移动。
另外,要注意当使用XOR链接列表时,您需要一个临时变量(不在节点中),它存储您之前所在节点的地址。当您移动到下一个节点时,您将丢弃旧值,并存储您之前所在节点的地址。
从头部移动到下一个节点
假设您现在位于第一个节点或节点A处。现在您想要移动到节点B.这是执行此操作的公式:
Address of Next Node = Address of Previous Node ^ pointer in the current Node
所以这将是:
addr (next) = addr (prev) ^ (0 ^ addr (next))
由于这是头部,之前的地址只是0,所以:
addr (next) = 0 ^ (0 ^ addr (next))
我们可以删除括号:
addr (next) = 0 ^ 0 addr (next)
使用none (2)
属性,我们可以说0 ^ 0
将始终为0:
addr (next) = 0 ^ addr (next)
使用none (1)
属性,我们可以将其简化为:
addr (next) = addr (next)
你得到了下一个节点的地址!
从节点移动到下一个节点
现在让我们说我们在一个中间节点,它有一个上一个和下一个节点。
让我们应用公式:
Address of Next Node = Address of Previous Node ^ pointer in the current Node
现在替换值:
addr (next) = addr (prev) ^ (addr (prev) ^ addr (next))
删除括号:
addr (next) = addr (prev) ^ addr (prev) ^ addr (next)
使用none (2)
属性,我们可以简化:
addr (next) = 0 ^ addr (next)
使用none (1)
属性,我们可以简化:
addr (next) = addr (next)
你明白了!
从节点移动到您之前的节点
如果你不理解标题,那基本上就意味着如果你在节点X,现在已经转移到节点Y,你想要回到之前访问过的节点,或者基本上是节点X. / p>
这不是一项繁琐的工作。请记住,我上面提到过,您将所在地址存储在临时变量中。因此,您要访问的节点的地址位于变量中:
addr (prev) = temp_addr
从节点移至上一个节点
这与上面提到的相同。我的意思是说,你在节点Z,现在你在节点Y,并且想要去节点X.
这与从节点移动到下一个节点几乎相同。只是这就是反之亦然。编写程序时,您将使用我在从一个节点移动到下一个节点时提到的相同步骤,只是您在列表中找到了比查找下一个元素更早的元素。
我认为我不需要解释这一点。
这使用的内存少于双向链接列表。
它只使用一个指针。这简化了节点的结构。
正如doynax所说,XOR的一个子部分可以在固定的时间内反转。
实施起来有点棘手。它有更高的失败机会,调试非常困难。
所有转化(如果是int)必须与uintptr_t
进行转换
您不仅可以获取节点的地址,还可以从那里开始遍历(或其他)。你必须始终从头部或尾部开始。
您不能跳跃或跳过节点。你必须逐一去。
搬家需要更多操作。
调试使用XOR链接列表的程序很困难。调试双向链接列表要容易得多。
答案 1 :(得分:17)
这是一个旧的编程技巧,可以节省内存。我不认为它已经用得太多了,因为记忆不再像过去那样紧张。
基本思想是这样的:在传统的双向链表中,你有两个指向相邻列表元素的指针,一个指向下一个元素的“下一个”指针,以及一个指向前一个元素的“prev”指针。因此,您可以使用适当的指针向前或向后遍历列表。
在内存减少的实现中,将“next”和“prev”替换为单个值,即“next”和“prev”的按位异或(bitwise-XOR)。因此,您将相邻元素指针的存储空间减少了一半。
使用这种技术,仍然可以在任一方向上遍历列表,但您需要知道前一个(或下一个)元素的地址。例如,如果您正在向前遍历列表,并且您具有“prev”的地址,那么您可以通过使用当前组合指针值的“prev”的按位-XOR来获得“下一个”,这是“prev”XOR“下一步”。结果是“prev”XOR“prev”XOR“next”,这只是“下一步”。同样可以在相反的方向上完成。
缺点是你不能在没有知道“prev”或“next”元素的地址的情况下删除元素,给出指向该元素的指针,因为你没有要解码的上下文组合指针值。
另一个缺点是这种指针技巧绕过了编译器可能期望的普通数据类型检查机制。
这是一个聪明的伎俩,但说实话,我觉得这些天使用它的理由很少。
答案 2 :(得分:15)
我建议看看我的second answer这个问题,因为它更加清晰。但我不是说这个答案是错的。这也是正确的。
内存高效链接列表也称为 XOR链接列表。
XOR (^)链接列表是一个链接列表,其中不是存储next
和back
指针,而是使用一个指针执行next
和back
指针的工作。让我们首先看一下XOR逻辑门属性:
|-------------|------------|------------|
| Name | Formula | Result |
|-------------|------------|------------|
| Commutative | A ^ B | B ^ A |
|-------------|------------|------------|
| Associative | A ^ (B ^ C)| (A ^ B) ^ C|
|-------------|------------|------------|
| None (1) | A ^ 0 | A |
|-------------|------------|------------|
| None (2) | A ^ A | 0 |
|-------------|------------|------------|
| None (3) | (A ^ B) ^ A| B |
|-------------|------------|------------|
现在让我们举一个例子。我们有一个带有四个节点的双向链表: A,B,C,D 。这是它的外观:
如果你看到,每个节点有两个指针,1个变量用于存储数据。所以我们使用了三个变量。
现在,如果您拥有一个包含大量节点的双向链接列表,那么它将使用的内存将会过多。为了提高效率,我们使用内存高效的双向链接列表。
内存高效的双向链接列表是一个链接列表,我们只使用一个指针来使用XOR及其属性来回移动。
这是一个图画表示:
你如何来回移动?
您有一个临时变量(只有一个,不在节点中)。我们假设您从左到右遍历节点。所以从节点A开始。在节点A的指针中,存储节点B的地址。然后移动到节点B.当移动到节点B时,在临时变量中存储节点A的地址。
节点B的链接(指针)变量的地址为A ^ C
。您将获取前一个节点的地址(即A)并使用当前链接字段对其进行异或,为您提供地址C.逻辑上,这看起来像:
A ^ (A ^ C)
现在让我们简化这个等式。我们可以删除括号并重写它,因为关联属性如:
A ^ A ^ C
我们可以进一步简化为
0 ^ C
因为第二个("无(2)"如表中所述)属性。
由于第一个("无(1)"如表中所述)属性,这基本上是
C
如果您无法理解这一切,只需查看第三个属性("无(3)"如表中所述)。
现在,您获得了节点C的地址。这将是返回的相同过程。
让我们说你是从节点C到节点B.你会将节点C的地址存储在临时变量中,再做上面给出的过程。
注意: A
,B
,C
等所有内容都是地址。感谢Bathsheba告诉我说清楚。
XOR链接列表的缺点
正如Lundin所说,所有转换都必须从/ uintptr_t
完成。
正如Sami Kuhmonen所提到的,你必须从明确定义的起点开始,而不仅仅是一个随机节点。
您不能只跳一个节点。你必须按顺序进行。
另请注意,在大多数情况下,XOR链接列表不比更好。
<强>参考强>
答案 3 :(得分:6)
好的,所以你已经看到了XOR链接列表,它为每个项目节省了一个指针......但这是一个丑陋,丑陋的数据结构,远远不是你能做的最好的。
如果您担心内存,那么使用每个节点有超过1个元素的双向链表几乎总是更好,就像链接的数组列表一样。
例如,虽然XOR链接列表每个项目需要1个指针,加上项目本身,但每个节点有16个项目的双向链接列表每16个项目需要3个指针,或者每个项目需要3/16个指针。 (额外指针是记录节点中有多少项的整数的成本),小于1。
除了节省内存之外,您还可以在本地获得优势,因为节点中的所有16个项目在内存中彼此相邻。迭代列表的算法会更快。
请注意,每次添加或删除节点时,XOR链接列表还要求您分配或释放内存,这是一项昂贵的操作。使用阵列链接列表,您可以通过允许节点小于完全填充来做得更好。例如,如果您允许5个空项目插槽,则每次第3次插入时只分配或释放内存,或者最差时删除。
有许多可能的策略来确定如何以及何时分配或释放节点。
答案 4 :(得分:2)
您已经对XOR链表进行了详尽的解释,我将就内存优化提出更多想法。
指针通常在64位计算机上占用8个字节。有必要解决RAM中任何超过4GB的点,使用32位指针进行寻址。
内存管理器通常处理固定大小的块,而不是字节。例如。 C malloc通常在16字节的粒度内分配。
这两件事意味着如果你的数据是1个字节,相应的双向链表元素将占用32个字节(8 + 8 + 1,向上舍入到最接近的16个倍数)。使用XOR技巧,你可以将它降低到16。
但是,为了进一步优化,您可以考虑使用自己的内存管理器: (a)处理较低粒度的块,例如1个字节或甚至可以进入比特, (b)对整体规模有更严格的限制。例如,如果您知道列表将始终适合100 MB的连续块,则只需要27位来寻址该块中的任何字节。不是32位或64位。
如果您没有开发通用列表类,但是您知道应用程序的特定使用模式,那么在许多情况下实现这样的内存管理器可能是一件容易的事。例如,如果您知道永远不会分配超过1000个元素,并且每个元素占用5个字节,则可以将内存管理器实现为5000字节数组,其中包含保存第一个空闲字节索引的变量,并且在分配额外元素时,你只需要获取该索引并按分配的大小向前移动它。在这种情况下,您的指针将不是真正的指针(如int *),但只是该5000字节数组内的索引。