【问题标题】:What is a Memory-Efficient Doubly Linked List in C?什么是 C 中的内存高效双向链表?
【发布时间】:2016-06-20 21:04:30
【问题描述】:

我在阅读一本关于 C 数据结构的书时遇到了“内存效率高的双向链表”一词。它只有一行说内存高效的双向链表比普通的双向链表使用更少的内存,但做同样的工作。没有更多的解释,也没有给出例子。只是给出了这是从一本期刊中摘录的,括号中是“Sinha”。

在谷歌上搜索后,我最接近的是this。但是,我什么都听不懂。

谁能解释一下什么是 C 语言中的内存高效双向链表?它与普通的双向链表有什么不同?

编辑:好的,我犯了一个严重的错误。请参阅我上面发布的链接,是文章的第二页。我没有看到有第一页,并认为给出的链接是第一页。文章的第一页实际上给出了解释,但我认为它并不完美。它只讨论了内存高效链表或异或链表的基本概念。

【问题讨论】:

  • 我真的不明白你为什么要使用它的原因 - 什么是真实世界的应用程序?如果您有一个包含数千个节点的庞大链表,那么问题不在于内存使用,而在于查找给定元素的缓慢迭代。这意味着您一开始就选择了错误的容器类!二叉搜索树或哈希表等会更有意义。
  • @Lundin,我同意。但我从未提到这比双向链表好得多。我只是找不到这个问题的答案,当我找到答案时,我想发布一个问题和答案来解释 XOR 链接列表。这是因为,当我不知道这叫异或链表时,我真的很难搜索,我花了三个小时才知道它到底是什么。
  • 我不是反对者,但你链接到文章的第二页,第一页解释了结构并给出了示例代码
  • @stark,感谢您指出这一点。已编辑。
  • link [link] (geeksforgeeks.org/…) 这些提供了一些信息

标签: c data-structures memory-efficient xor-linkedlist mem.-efficient-linkedlist


【解决方案1】:

我知道这是我的第二个答案,但我认为我在这里提供的解释可能比上一个答案更好。但请注意,即使那个答案也是正确的。



高效内存链表通常被称为 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 是第三个和第一个节点的地址。所有节点,除了头部和尾部,都和上面的一样。

列表的tail,没有下一个节点,所以存储addr (prev) ^ 0。它看起来像:

在看我们如何移动之前,让我们再看一下 XOR Linked List 的表示:

当你看到

这显然意味着有一个链接字段,您可以使用它前后移动。

另外,需要注意的是,当使用异或链表时,你需要有一个临时变量(不在节点中),它存储你之前所在的节点的地址。当您移动到下一个节点时,您会丢弃旧值,并存储您之前所在的节点的地址。

从 Head 移动到下一个节点

假设你现在在第一个节点,或者在节点 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。

这不是一项繁琐的任务。请记住,我在上面提到过,您将所在的地址存储在一个临时变量中。所以你要访问的节点的地址,就在一个变量中:

addr (prev) = temp_addr

从一个节点移动到上一个节点

这和上面提到的不一样。我的意思是说,你在节点Z,现在你在节点Y,想去节点X。

这几乎与从一个节点移动到下一个节点相同。只是这是它的反之亦然。当您编写程序时,您将使用我在从一个节点移动到下一个节点时提到的相同步骤,只是您在列表中查找较早的元素而不是查找下一个元素。

我想我不需要解释这个。


异或链表的优点

  • 这比双向链表使用更少的内存。减少约 33%。

  • 它只使用一个指针。这简化了节点的结构。

  • 正如 doynax 所说,XOR 的一个子部分可以在恒定时间内反转。


异或链表的缺点

  • 这实现起来有点棘手。失败的几率比较大,调试难度很大。

  • 所有转换(在 int 的情况下)都必须发生在 uintptr_t/从uintptr_t

  • 你不能只获取一个节点的地址,然后从那里开始遍历(或其他)。您必须始终从头或尾开始。

  • 你不能去跳跃,也不能跳过节点。你必须一个一个去。

  • 移动需要更多操作。

  • 很难调试使用 XOR 链接列表的程序。调试双向链表要容易得多。


参考文献

【讨论】:

  • 这确实比您之前的答案更清楚。感谢您的精彩帖子!
  • @FabioTurati,不客气。您是否看到了更好的改进方法?
  • 一个优势,也许比节省空间更重要的是激励选择这种数据结构,是 xor 链表的(子部分)可以在恒定时间内反转。跨度>
  • @doynax,好的,我会补充的。您认为还有其他改进吗?
  • "你必须总是从头或尾开始。" - 不一定,你只需要两个相邻的节点。
【解决方案2】:

这是一个古老的编程技巧,可以让您节省内存。我认为它不再使用太多了,因为内存不再像过去那样紧张。

基本思想是这样的:在传统的双向链表中,有两个指向相邻列表元素的指针,一个指向下一个元素的“next”指针,以及一个指向前一个元素的“prev”指针.因此,您可以使用适当的指针向前或向后遍历列表。

在减少内存的实现中,将“next”和“prev”替换为单个值,即“next”和“prev”的按位异或(按位异或)。因此,您将相邻元素指针的存储空间减少了一半。

使用这种技术,仍然可以在任一方向遍历列表,但您需要知道上一个(或下一个)元素的地址才能这样做。例如,如果您正在向前遍历列表,并且您有“prev”的地址,那么您可以通过将“prev”与当前组合指针值进行按位异或得到“next”,即“上一个”异或“下一个”。结果是“prev” XOR “prev” XOR “next”,也就是“next”。反之亦然。

缺点是你不能在不知道“prev”或“next”元素的地址的情况下删除一个元素,给定一个指向该元素的指针,因为你没有解码的上下文组合的指针值。

另一个缺点是这种指针技巧绕过了编译器可能期望的正常数据类型检查机制。

这是一个聪明的技巧,但老实说,这些天我几乎没有理由使用它。

【讨论】:

  • 这是 80 年代的一个众所周知的技巧,可能在 70 年代,甚至可能更早,那时我预计它更有可能被使用(尤其是汇编程序员)。到 2005 年,这已经是非常的老新闻了,到那时它已经基本无关紧要了。所以不,没有错误。早在 Linux 出现之前,它就已广为人知。 2005 年的文章报道了非常古老的新闻。
  • 顺便说一下,虽然按位异或是执行此操作的标准方法,但实际上您可以使用任何函数 f、g、h 使得 g(f(x, y), x) 为 y h(f(x, y), y) 是 x。例如, f(x, y) = x + y 有效(只要附加值以字长为模,即不受溢出影响)。您也可以使用 f(x, y) = x - y。
  • 微控制器是即使在今天也存在内存问题的系统。承认,你很少在这样的系统上看到链表......
【解决方案3】:

对于这个问题,我建议您查看我的second answer,因为它更清晰。但我并不是说这个答案是错误的。这也是正确的。



高效内存链表也称为XOR链表

它是如何工作的

A XOR (^) Linked List 是一个 Link List,其中不存储 nextback 指针,我们只使用 one 指针来做nextback 指针的工作。我们先来看看 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。下面是它的外观:

如你所见,每个节点都有两个指针和一个用于存储数据的变量。所以我们使用了三个变量。

现在,如果您有一个包含大量节点的双向链表,那么它将使用的内存太多了。为了提高效率,我们使用内存高效的双向链表

内存效率高的双向链表是一个链表,我们只使用一个指针来使用 XOR 和它的属性来回移动。

这是一个图示:

你如何来回移动?

您有一个临时变量(只有一个,不在节点中)。假设您正在从左到右遍历节点。因此,您从节点 A 开始。在节点 A 的指针中,您存储节点 B 的地址。然后移动到节点 B。移动到节点 B 时,在临时变量中存储节点 A 的地址。

节点 B 的链接(指针)变量的地址为A ^ C。您将获取前一个节点的地址(即 A)并将其与当前链接字段 XOR,得到 C 的地址。从逻辑上讲,这看起来像:

A ^ (A ^ C)

现在让我们简化方程。由于 Associative 属性,我们可以删除括号并重写它:

A ^ A ^ C

我们可以进一步简化为

0 ^ C

因为第二个(表中所述的“无 (2)”)属性。

由于第一个(“无(1)”如表中所述)属性,这基本上是

C

如果您无法理解这一切,只需查看第三个属性(表中所述的“None (3)”)即可。

现在,你得到了节点 C 的地址。这将是相同的返回过程。

假设您要从节点 C 到节点 B。您将节点 C 的地址存储在临时变量中,再次执行上面给出的过程。

注意:ABC 这样的所有内容都是地址。感谢 Bathsheba 让我说清楚。

异或链表的缺点

  • 正如 Lundin 所提到的,所有转换都必须从/到 uintptr_t 完成。

  • 正如 Sami Kuhmonen 所说,您必须从一个明确定义的起点开始,而不仅仅是一个随机节点。

  • 你不能只跳一个节点。你必须按顺序去。

另请注意,在大多数情况下,异或链表并不优于双向链表。

参考文献

【讨论】:

  • 将 A ^ C 视为 地址 并不是一个好主意。以明确定义的方式实现也很棘手。此外,“会太多”是主观和笨拙的。 (A ^ B) ^ ANone (3) 的括号是多余的:请参阅您的 Associative 属性。
  • 另请注意,使用此实现,您只能从定义明确的起点开始遍历。例如,您不能存储对随机节点的引用并使用它来前进或后退。插入/删除也变得更加棘手。
  • @Bathsheba,我同意,使用 A ^ C 作为地址有点模棱两可,但每次写 addr1 ^ addr2 之类的东西都太长了,因为我在很多地方都使用它地方。我将添加 A 和 C 是地址。
  • @SamiKuhmonen,是的,我同意。 XOR 链表和双链表都有各自的优势。我只是说 XOR.... 使用更少的内存,并不是说它比双向链表更好。
  • 这里主要关注的是定义不明确的行为。所有指针转换都必须与 uintptr_t 进行转换,而不是天真的 int 或类似的东西。
【解决方案4】:

好的,你已经看到了 XOR 链表,它为每个项目节省了一个指针……但这是一个丑陋、丑陋的数据结构,远非你能做的最好的。

如果您担心内存问题,最好使用每个节点包含 1 个以上元素的双向链表,例如数组的链表。

例如,XOR 链表每项花费 1 个指针,加上项本身,但每个节点 16 项的双向链表每 16 项花费 3 个指针,或每项花费 3/16 个指针。 (额外的指针是记录节点中有多少项的整数的成本)小于1。

除了节省内存之外,您还可以获得局部性优势,因为节点中的所有 16 个项目在内存中都彼此相邻。遍历列表的算法会更快。

请注意,异或链表还要求您在每次添加或删除节点时分配或释放内存,这是一项昂贵的操作。使用数组链表,您可以通过允许节点不完全充满来做得更好。例如,如果您允许 5 个空项目插槽,那么您最多只能在每 3 次插入或删除时分配或释放内存。

有许多可能的策略来确定如何以及何时分配或释放节点。

【讨论】:

  • 这些对我来说似乎是正交的概念;每个节点可以有多个条目,与用于“prev”和“next”指针的存储量无关。
  • 与本题无关,但数组链表有一些缺点:它失去了传统链表的属性,即数据元素的地址不因插入/删除而改变,它要求为正在存储的对象提供“移动”操作(某些对象不能按字节复制移动)
  • 它不是完全正交的,它是一个非常有效的点。例如:我在嵌入式工作过,永远不会使用 XOR 链表,因为它的“内存效率”;而且我仍然已经考虑将分割成数组以提高速度(说实话,内存效率甚至没有注册)。无论如何,恕我直言,考虑到这个问题对令人讨厌的技巧的兴趣,我想说一个好的正交推动让作者改变方向无论如何都是好的;P
【解决方案5】:

你已经对异或链表有了相当详尽的解释,我再分享一些关于内存优化的想法。

  1. 在 64 位机器上,指针通常占用 8 个字节。有必要使用 32 位指针来寻址超过 4GB 的 RAM 中的任何点。

  2. 内存管理器通常处理固定大小的块,而不是字节。例如。 C malloc 通常在 16 字节粒度内分配。

这两件事意味着如果你的数据是 1 字节,那么对应的双向链表元素将占用 32 字节(8+8+1,四舍五入到最接近的 16 倍数)。使用 XOR 技巧,您可以将其降至 16。

但是,为了进一步优化,您可以考虑使用自己的内存管理器,即: (a) 处理较低粒度的块,例如 1 字节甚至可能进入位, (b) 对整体尺寸有更严格的限制。例如,如果您知道您的列表将始终适合 100 MB 的连续块,那么您只需要 27 位来寻址该块中的任何字节。不是 32 位或 64 位。

如果您没有开发通用列表类,但您知道应用程序的特定使用模式,那么在许多情况下实现这样的内存管理器可能是一件容易的事。例如,如果你知道你永远不会分配超过 1000 个元素并且每个元素占用 5 个字节,那么你可以将内存管理器实现为 5000 字节数组,其中包含一个保存第一个空闲字节索引的变量,并且当你分配额外的元素时,您只需获取该索引并将其向前移动分配的大小。在这种情况下,您的指针将不是真正的指针(如 int*),而只是该 5000 字节数组中的索引。

【讨论】:

  • 让我们退后一步,仔细看看。您正在谈论一个包含 1 字节有效负载的双向链表。而且,鉴于您使用的是 64 位机器,您想让事情变得更高效;这样做的方法是实现您自己的内存分配器/分配方案并使用指针的异或。现在……你没有看到这张照片有什么可怕的错误吗??
  • @hmijail,不确定你在暗示什么。澄清一下:对于“指针”,我的意思不是本机 8 字节指针,而是我自己的内存分配器的指针,例如,可以是 20 位宽的索引。如果我们退后一步,我们应该看看这种类型的数据结构会解决什么样的问题,坦率地说,我想不出任何问题。这更像是一个理论练习。在现实世界的应用程序中,双向链表通常用于方便,其中一些额外的字节无关紧要,但自定义分配将是一场灾难。对于高效的算法,通常有更好的结构。
  • 我的观点是,整个 shebang 听起来像是一场过度设计的噩梦,以掩盖最初有问题的设计决策。
  • 如果我们从工程角度来看,我同意你的看法。由于我们没有任何应用程序上下文,我假设上下文是理论上的脑筋急转弯,也许是学习计算机科学基础知识。知道你可能永远不应该在生产中使用的算法上有趣的东西也不错。例如,看看冒泡排序的教育价值——巨大的。实际应用 - 无。 Xor list 作为脑筋急转弯并不是一件坏事。实现自己的内存管理器也不错,这样可以更深入地了解分配和指针大小开销。
  • 在某些情况下,例如信息检索(遍历发布列表),在位级别操作的棘手数据结构对性能非常重要。虽然我仍然无法提出一个需要优化 双重 链表(双向遍历)的真正问题。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 2014-04-23
  • 1970-01-01
  • 1970-01-01
  • 2016-08-09
  • 2016-09-02
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多