为啥链表使用指针而不是在节点内存储节点

Posted

技术标签:

【中文标题】为啥链表使用指针而不是在节点内存储节点【英文标题】:Why do linked lists use pointers instead of storing nodes inside of nodes为什么链表使用指针而不是在节点内存储节点 【发布时间】:2015-06-15 03:08:06 【问题描述】:

我之前在 Java 中广泛使用过链表,但我对 C++ 很陌生。我正在使用在项目中给我的这个节点类就好了

class Node

  public:
   Node(int data);

   int m_data;
   Node *m_next;
;

但我有一个问题没有得到很好的回答。为什么需要使用

Node *m_next;

指向列表中的下一个节点而不是

Node m_next;

我知道最好使用指针版本;我不会争论事实,但我不知道为什么它会更好。关于指针如何更好地分配内存,我得到了一个不太明确的答案,我想知道这里是否有人可以帮助我更好地理解这一点。

【问题讨论】:

@self 请原谅我?为什么所有都是指针的语言没有链表? 重要的是要注意 C 和 C++ 在对象指针和引用方面与 Java 的不同之处。 Node m_next 不是对节点的引用,它是整个 Node 本身的存储。 @self Java 确实有指针,您只是没有明确使用它们。 Turtles all the way down 是不是选项。疯狂必须在某个地方结束。 请忘记您对 Java 的了解一切。 C++ 和 Java 以完全不同的方式处理内存。去看看this question for book recommendations 选择一个,然后阅读它。你会帮我们大家一个大忙。 【参考方案1】:

为什么链表使用指针而不是在内部存储节点 节点?

当然有一个微不足道的答案。

如果它们没有通过指针链接一个节点到下一个节点,它们就不是链接列表

链表作为一个东西存在是因为我们希望能够将对象链接在一起。例如:我们已经有一个来自某个地方的对象。例如,我们现在想要将该实际对象(而不是副本)放在队列的末尾。这是通过从队列中的最后一个元素添加一个 link 到我们正在添加的条目来实现的。用机器术语来说,就是用下一个元素的地址填充一个单词。

【讨论】:

【参考方案2】:

概述

在 C++ 中引用和分配对象有两种方式,而在 Java 中只有一种方式。

为了解释这一点,下图显示了对象是如何存储在内存中的。

1.1 没有指针的 C++ 项

class AddressClass

  public:
    int      Code;
    char[50] Street;
    char[10] Number;
    char[50] POBox;
    char[50] City;
    char[50] State;
    char[50] Country;
;

class CustomerClass

  public:
    int          Code;
    char[50]     FirstName;
    char[50]     LastName;
    // "Address" IS NOT A pointer !!!
    AddressClass Address;
;

int main(...)

   CustomerClass MyCustomer();
     MyCustomer.Code = 1;
     strcpy(MyCustomer.FirstName, "John");
     strcpy(MyCustomer.LastName, "Doe");
     MyCustomer.Address.Code = 2;
     strcpy(MyCustomer.Address.Street, "Blue River");
     strcpy(MyCustomer.Address.Number, "2231 A");

   return 0;
 // int main (...)

.......................................
..+---------------------------------+..
..|          AddressClass           |..
..+---------------------------------+..
..| [+] int:      Code              |..
..| [+] char[50]: Street            |..
..| [+] char[10]: Number            |..
..| [+] char[50]: POBox             |..
..| [+] char[50]: City              |..
..| [+] char[50]: State             |..
..| [+] char[50]: Country           |..
..+---------------------------------+..
.......................................
..+---------------------------------+..
..|          CustomerClass          |..
..+---------------------------------+..
..| [+] int:      Code              |..
..| [+] char[50]: FirstName         |..
..| [+] char[50]: LastName          |..
..+---------------------------------+..
..| [+] AddressClass: Address       |..
..| +-----------------------------+ |..
..| | [+] int:      Code          | |..
..| | [+] char[50]: Street        | |..
..| | [+] char[10]: Number        | |..
..| | [+] char[50]: POBox         | |..
..| | [+] char[50]: City          | |..
..| | [+] char[50]: State         | |..
..| | [+] char[50]: Country       | |..
..| +-----------------------------+ |..
..+---------------------------------+..
.......................................

警告:本示例中使用的 C++ 语法类似于 Java 中的语法。但是,内存分配不同。

1.2 使用指针的 C++ 项

class AddressClass

  public:
    int      Code;
    char[50] Street;
    char[10] Number;
    char[50] POBox;
    char[50] City;
    char[50] State;
    char[50] Country;
;

class CustomerClass

  public:
    int           Code;
    char[50]      FirstName;
    char[50]      LastName;
    // "Address" IS A pointer !!!
    AddressClass* Address;
;

.......................................
..+-----------------------------+......
..|        AddressClass         +<--+..
..+-----------------------------+...|..
..| [+] int:      Code          |...|..
..| [+] char[50]: Street        |...|..
..| [+] char[10]: Number        |...|..
..| [+] char[50]: POBox         |...|..
..| [+] char[50]: City          |...|..
..| [+] char[50]: State         |...|..
..| [+] char[50]: Country       |...|..
..+-----------------------------+...|..
....................................|..
..+-----------------------------+...|..
..|         CustomerClass       |...|..
..+-----------------------------+...|..
..| [+] int:      Code          |...|..
..| [+] char[50]: FirstName     |...|..
..| [+] char[50]: LastName      |...|..
..| [+] AddressClass*: Address  +---+..
..+-----------------------------+......
.......................................

int main(...)

   CustomerClass* MyCustomer = new CustomerClass();
     MyCustomer->Code = 1;
     strcpy(MyCustomer->FirstName, "John");
     strcpy(MyCustomer->LastName, "Doe");

     AddressClass* MyCustomer->Address = new AddressClass();
     MyCustomer->Address->Code = 2;
     strcpy(MyCustomer->Address->Street, "Blue River");
     strcpy(MyCustomer->Address->Number, "2231 A");

     free MyCustomer->Address();
     free MyCustomer();

   return 0;
 // int main (...)

如果您检查两种方式之间的区别,您会发现, 在第一种技术中,地址项在客户内部分配,而第二种方式,您必须显式创建每个地址。​​

警告:Java 像第二种技术一样在内存中分配对象,但是,语法与第一种方式相似,这可能会让“C++”的新手感到困惑。

实施

所以您的列表示例可能类似于以下示例。

class Node

  public:
   Node(int data);

   int m_data;
   Node *m_next;
;

.......................................
..+-----------------------------+......
..|            Node             |......
..+-----------------------------+......
..| [+] int:           m_data   |......
..| [+] Node*:         m_next   +---+..
..+-----------------------------+...|..
....................................|..
..+-----------------------------+...|..
..|            Node             +<--+..
..+-----------------------------+......
..| [+] int:           m_data   |......
..| [+] Node*:         m_next   +---+..
..+-----------------------------+...|..
....................................|..
..+-----------------------------+...|..
..|            Node             +<--+..
..+-----------------------------+......
..| [+] int:           m_data   |......
..| [+] Node*:         m_next   +---+..
..+-----------------------------+...|..
....................................v..
...................................[X].
.......................................

总结

由于链接列表具有可变数量的项目,因此根据需要分配内存,并且在可用时分配内存。

更新:

还值得一提,正如@hacks 在他的帖子中所评论的那样。

有时,引用或对象指针表示嵌套项(也称为“U.M.L. 组合”)。

有时,引用或对象指针表示外部项(也称为“U.M.L. 聚合”)。

但是,同一类的嵌套项,不能使用“无指针”技术。

【讨论】:

【参考方案3】:

为什么在链表中使用指针更好?

原因是当您创建Node 对象时,编译器必须为该对象分配内存并计算对象的大小。编译器知道指向任何类型的指针的大小 因此可以计算对象的自引用指针大小。

如果改用Node m_node,那么编译器不知道Node 的大小,它会陷入计算sizeof(Node)无限递归。永远记住:a class cannot contain a member of its own type

【讨论】:

【参考方案4】:

你使用指针,否则你的代码:

class Node

   //etc
   Node m_next; //non-pointer
;

...不会编译,因为编译器无法计算Node 的大小。这是因为它依赖于自身——这意味着编译器无法决定它会消耗多少内存。

【讨论】:

更糟糕的是,不存在有效的大小:如果 k == sizeof(Node) 持有并且您的类型有数据,那么它还必须持有 sizeof(Node) = k + sizeof(Data) = sizeof(Node) + sizeof(Data),然后是 sizeof(Node) &gt; sizeof(Node) @bitmask 不存在有效大小在实数中。如果您允许超无限,aleph_0 有效。 (只是过于迂腐:-)) @k_g 好吧,C/C++ 标准要求sizeof 的返回值是无符号整数类型,因此希望得到超限甚至实际大小。 (更加迂腐!:p) @Thomas:甚至有人可能会指出,自然数也是如此。 (越过-pedantic top :p) 其实Node在这个sn-p结束之前甚至都没有定义,所以你不能真正在里面使用它。允许隐式前向声明指向尚未声明的类的指针是一种语言允许的小技巧,以便使此类结构成为可能,而无需一直显式转换指针。【参考方案5】:

因为在 C++

int main (..)

    MyClass myObject;

    // or

    MyClass * myObjectPointer = new MyClass();

    ..

等价于 Java

public static void main (..)

    MyClass myObjectReference = new MyClass();

他们都使用默认构造函数创建MyClass 的新对象。

【讨论】:

【参考方案6】:

在 Java 中

Node m_node

存储指向另一个节点的指针。你别无选择。在 C++ 中

Node *m_node

意思是一样的。不同之处在于,在 C++ 中,您实际上可以存储对象而不是指向它的指针。这就是为什么你必须说你想要一个指针。在 C++ 中:

Node m_node

意味着将节点存储在此处(这显然不适用于列表 - 您最终会得到一个递归定义的结构)。

【讨论】:

@SalmanA 我已经知道了。我只是想知道为什么没有指针就无法工作,这是公认的答案解释得更好。 @AR7 他们都给出了相同的解释,只是在两种不同的方法下。如果您将其声明为“常规”变量,那么第一次调用构造函数时,它会将其实例化为一个新实例。但是在它完成实例化之前——在第一个构造函数完成之前——成员Node自己的构造函数将被调用,这将实例化另一个新实例……你会得到无穷无尽的伪递归。这不是真正完全严格和字面意义上的大小问题,而是性能问题。 但是您真正想要的只是一种指向列表中下一个的方法,而不是实际上在第一个 Node 中的 Node。因此,您创建了一个指针,它本质上是 Java 处理对象的方式,而不是原语。当您调用方法或创建变量时,Java 不存储对象的副本甚至对象本身;它存储对对象的引用,该对象本质上是一个指针,周围有一点小手套。这就是两个答案的本质。 它不是大小或速度问题 - 这是一个不可能的问题。包含的 Node 对象将包含一个 Node 对象,该对象将包含一个 Node 对象...实际上不可能编译它 @Panzercrisis 我知道他们都给出了相同的解释。然而,这种方法对我没有多大帮助,因为它专注于我已经了解的内容:指针在 C++ 中的工作方式以及指针在 Java 中的处理方式。接受的答案特别解决了为什么不使用指针是不可能的,因为无法计算大小。另一方面,这个更模糊地留下了“一个递归定义的结构”。 PS你刚刚写的解释比两者都解释得更好:D。【参考方案7】:

您描述的方法不仅与 C++ 兼容,而且与它的(mostly) subset language C 兼容。学习开发 C 风格的链表是向自己介绍低级编程技术(例如手动内存管理)的好方法,但它通常不是现代 C++ 的最佳实践发展。

下面,我实现了关于如何在 C++ 中管理项目列表的四种变体。

    raw_pointer_demo 使用与您相同的方法——使用原始指针需要手动内存管理。此处使用 C++ 仅适用于syntactic-sugar,所使用的方法在其他方面与 C 语言兼容。 在shared_pointer_demo 中,列表管理仍然是手动完成的,但内存管理是automatic(不使用原始指针)。这与您可能在 Java 中所经历的非常相似。 std_list_demo 使用标准库 list 容器。这表明,如果您依赖现有库而不是自行开发库,事情会变得多么容易。 std_vector_demo 使用标准库 vector 容器。这在单个连续内存分配中管理列表存储。换句话说,没有指向单个元素的指针。对于某些相当极端的情况,这可能会变得非常低效。但是,对于典型情况,this is the recommended best practice for list management in C++。

注意:在所有这些中,只有raw_pointer_demo 实际上要求显式销毁列表以避免“泄漏”内存。当容器超出范围时(在函数结束时),其他三种方法会自动销毁列表及其内容。重点是:在这方面,C++ 能够非常“类似于 Java”——但前提是您选择使用您可以使用的高级工具来开发您的程序。


/*BINFMTCXX: -Wall -Werror -std=c++11
*/

#include <iostream>
#include <algorithm>
#include <string>
#include <list>
#include <vector>
#include <memory>
using std::cerr;

/** Brief   Create a list, show it, then destroy it */
void raw_pointer_demo()

    cerr << "\n" << "raw_pointer_demo()..." << "\n";

    struct Node
    
        Node(int data, Node *next) : data(data), next(next) 
        int data;
        Node *next;
    ;

    Node * items = 0;
    items = new Node(1,items);
    items = new Node(7,items);
    items = new Node(3,items);
    items = new Node(9,items);

    for (Node *i = items; i != 0; i = i->next)
        cerr << (i==items?"":", ") << i->data;
    cerr << "\n";

    // Erase the entire list
    while (items) 
        Node *temp = items;
        items = items->next;
        delete temp;
    


raw_pointer_demo()...
9, 3, 7, 1

/** Brief   Create a list, show it, then destroy it */
void shared_pointer_demo()

    cerr << "\n" << "shared_pointer_demo()..." << "\n";

    struct Node; // Forward declaration of 'Node' required for typedef
    typedef std::shared_ptr<Node> Node_reference;

    struct Node
    
        Node(int data, std::shared_ptr<Node> next ) : data(data), next(next) 
        int data;
        Node_reference next;
    ;

    Node_reference items = 0;
    items.reset( new Node(1,items) );
    items.reset( new Node(7,items) );
    items.reset( new Node(3,items) );
    items.reset( new Node(9,items) );

    for (Node_reference i = items; i != 0; i = i->next)
        cerr << (i==items?"":", ") << i->data;
    cerr<<"\n";

    // Erase the entire list
    while (items)
        items = items->next;


shared_pointer_demo()...
9, 3, 7, 1

/** Brief   Show the contents of a standard container */
template< typename C >
void show(std::string const & msg, C const & container)

    cerr << msg;
    bool first = true;
    for ( int i : container )
        cerr << (first?" ":", ") << i, first = false;
    cerr<<"\n";


/** Brief  Create a list, manipulate it, then destroy it */
void std_list_demo()

    cerr << "\n" << "std_list_demo()..." << "\n";

    // Initial list of integers
    std::list<int> items =  9, 3, 7, 1 ;
    show( "A: ", items );

    // Insert '8' before '3'
    items.insert(std::find( items.begin(), items.end(), 3), 8);
    show("B: ", items);

    // Sort the list
    items.sort();
    show( "C: ", items);

    // Erase '7'
    items.erase(std::find(items.begin(), items.end(), 7));
    show("D: ", items);

    // Erase the entire list
    items.clear();
    show("E: ", items);


std_list_demo()...
A:  9, 3, 7, 1
B:  9, 8, 3, 7, 1
C:  1, 3, 7, 8, 9
D:  1, 3, 8, 9
E:

/** brief  Create a list, manipulate it, then destroy it */
void std_vector_demo()

    cerr << "\n" << "std_vector_demo()..." << "\n";

    // Initial list of integers
    std::vector<int> items =  9, 3, 7, 1 ;
    show( "A: ", items );

    // Insert '8' before '3'
    items.insert(std::find(items.begin(), items.end(), 3), 8);
    show( "B: ", items );

    // Sort the list
    sort(items.begin(), items.end());
    show("C: ", items);

    // Erase '7'
    items.erase( std::find( items.begin(), items.end(), 7 ) );
    show("D: ", items);

    // Erase the entire list
    items.clear();
    show("E: ", items);


std_vector_demo()...
A:  9, 3, 7, 1
B:  9, 8, 3, 7, 1
C:  1, 3, 7, 8, 9
D:  1, 3, 8, 9
E:

int main()

    raw_pointer_demo();
    shared_pointer_demo();
    std_list_demo();
    std_vector_demo();

【讨论】:

上面的Node_reference 声明解决了Java 和C++ 之间最有趣的语言级别差异之一。在 Java 中,声明 Node 类型的对象将隐式使用对单独分配的对象的引用。在 C++ 中,您可以选择引用(指针)与直接(堆栈)分配——因此您必须明确处理区别。在大多数情况下,您会使用直接分配,但不适用于列表元素。 不知道为什么我也不推荐std::deque的可能性。【参考方案8】:

这不仅更好,而且是唯一可能的方法。

如果你在自身内部存储了一个Node 对象sizeof(Node) 会是什么?它将是sizeof(int) + sizeof(Node),这将等于sizeof(int) + (sizeof(int) + sizeof(Node)),这将等于sizeof(int) + (sizeof(int) + (sizeof(int) + sizeof(Node))),等等,直到无穷大。

这样的对象不可能存在。这是不可能

【讨论】:

*除非它被懒惰地评估。无限列表是可能的,只是没有严格的评估。 @Carcigenicate 这不是关于在 Node 对象上评估/执行某些函数 - 它是关于 Node 的每个实例的内存布局,必须在编译时确定,然后才能进行任何评估。跨度> @DavidK 这在逻辑上是不可能的。你在这里需要一个指针(实际上是一个间接的) - 确保语言可以隐藏它,但最终,没有办法解决这个问题。 @David 我很困惑。首先你同意这在逻辑上是不可能的,但你想考虑一下吗?删除 C 或 C++ 的任何内容 - 在我所见的 any 语言中,你永远无法想象。根据定义,该结构是无限递归,如果没有某种程度的间接性,我们无法打破它。 @benjamin 我实际上指出(因为我知道否则有人会提出这个问题 - 好吧没有帮助)Haskell 在创建时分配了 thunk,因此这是有效的,因为这些 thunk 给了我们间接性我们需要。这只不过是一个带有额外数据变相的指针......【参考方案9】:

附带说明,如果类或结构的第一个成员是 next 指针(因此没有虚函数或类的任何其他特性意味着 next 不是类或结构的第一个成员) ,然后您可以使用仅带有 next 指针的“基”类或结构,并使用通用代码进行基本的链表操作,例如追加、插入之前、从前面检索,...。这是因为 C/C++ 保证类或结构的第一个成员的地址与类或结构的地址相同。基节点类或​​结构将只有一个 next 指针供基本链表函数使用,然后根据需要使用类型转换在基节点类型和“派生”节点类型之间进行转换。旁注 - 在 C++ 中,如果基节点类只有一个 next 指针,那么我假设派生类不能有虚函数。

【讨论】:

【参考方案10】:

C++ 不是 Java。当你写

Node m_next;

在Java中,和写法一样

Node* m_next;

在 C++ 中。在 Java 中,指针是隐式的,在 C++ 中是显式的。如果你写

Node m_next;

在 C++ 中,您将 Node 的实例放在您正在定义的对象中。它始终存在,不能省略,不能用new 分配,也不能删除。这种效果在Java中是不可能实现的,和Java用同样的语法做的完全不一样。

【讨论】:

如果 SuperNode 扩展 Node,在 Java 中获得类似的东西可能是“扩展”,SuperNodes 包含 Node 的所有属性并且必须保留所有额外的空间。所以在Java中你不能做“节点扩展节点” @Falco 没错,继承是就地包含基类的一种形式。但是,由于 Java 不允许多重继承(与 C++ 不同),因此您只能通过继承引入单个其他预先存在的类的实例。这就是为什么我不认为继承可以替代就地成员包含。【参考方案11】:

后者 (Node m_next) 必须包含该节点。它不会指向它。这样就不会有元素的链接了。

【讨论】:

更糟糕的是,一个对象在逻辑上不可能包含相同类型的东西。 在技术上是否仍然存在链接,因为它将是一个包含一个包含一个节点的节点等等的节点? @AR7:不,收容意味着它实际上是在对象内部,而不是与其链接。

以上是关于为啥链表使用指针而不是在节点内存储节点的主要内容,如果未能解决你的问题,请参考以下文章

我找不到我的链表的错误(为啥我的头指针在移动?)

将第一个节点添加到hashmap中的链表时,为啥必须将新节点直接分配给索引指针?

Java-链表(单向链表双向链表)

2021.5.29 链表

Linux内核数据结构之链表

Linked List