Что такое метод указателя на указатель для более простого обхода связанных списков?

Десять лет назад мне показали метод прохождения связанного списка: вместо использования одного указателя вы использовали двойной указатель (указатель на указатель).

Этот метод дал более компактный, более элегантный код, устраняя необходимость проверки определенных случаев границы/края.

Кто-нибудь знает, что такое эта техника на самом деле?

Ответы

Ответ 1

Я думаю, вы имеете в виду двойной указатель, как в "указателе на указатель", который очень эффективен для вставки в конце одиночного списка или древовидной структуры. Идея состоит в том, что вам не нужен специальный случай или "трейлинг-указатель", чтобы следовать указателю обхода, как только вы найдете конец (указатель NULL). Поскольку вы можете просто разыменовать указатель на указатель (он указывает на последний node следующий указатель!) Для вставки. Что-то вроде этого:

T **p = &list_start;
while (*p) {
   p = &(*p)->next;
}
*p = new T;

вместо следующего:

T *p = list_start;
if (p == NULL) {
    list_start = new T;
} else {
    while (p->next) {
        p = p->next;
    }
    p->next = new T;
}

ПРИМЕЧАНИЕ.. Это также полезно для создания эффективного кода удаления для отдельного списка. В любой момент, когда *p = (*p)->next удалит node, вы "смотрите" (конечно, вам все равно нужно очистить хранилище node).

Ответ 2

Под "двойным указателем", я думаю, вы имеете в виду "указатель на указатель". Это полезно, поскольку позволяет исключить специальные случаи для указателей на голову или хвост. Например, учитывая этот список:

struct node {
    struct node *next;
    int key;
    /* ... */
};

struct node *head;

Если вы хотите найти node и удалить его из списка, метод с одним указателем будет выглядеть так:

if (head->key == search_key)
{
    removed = head;
    head = head->next;
}
else
{
    struct node *cur;

    for (cur = head; cur->next != NULL; cur = cur->next)
    {
        if (cur->next->key == search_key)
        {
            removed = cur->next;
            cur->next = cur->next->next;
            break;
         }
    }
}

В то время как метод указателя на указатель намного проще:

struct node **cur;

for (cur = &head; *cur != NULL; cur = &(*cur)->next)
{
    if ((*cur)->key == search_key)
    {
        removed = *cur;
        *cur = (*cur)->next;
        break;
    }
}

Ответ 3

Я думаю, вы имеете в виду дважды связанные списки, где node выглядит примерно так:

struct Node {
(..) data // The data being stored in the node, it can be of any data type
Node *next; // A pointer to the next node; null for last node
Node *prev; // A pointer to the previous node; null for first node
}

Ответ 4

Я согласен с комментариями об использовании контейнеров STL для обработки грязной работы в списке. Однако, это переполнение стека, мы все здесь, чтобы что-то узнать.

Вот как вы обычно вставляете в список:

typedef struct _Node {
    void * data;
    Node * next;
} Node;

Node * insert( Node * root, void * data ) {
    Node * list = root;
    Node * listSave = root;

    while ( list != null ) {
        if ( data < list->data ) {
            break;
        }

        listSave = list;
        list = list->next;
    }

    Node * newNode = (Node*)malloc( sizeof(Node) );
    newNode->data = data;
    /* Insert at the beginning of the list */
    if ( listSave == list ) {
        newNode->next = list;
        list = newNode;
    }
    /* Insert at the end of the list */
    else if ( list == null ) {
        listSave->next = newNode;
        newNode->next = null;
        list = root;
    }
    /* Insert at the middle of the list */
    else {
        listSave->next = newNode;
        newNode->next = list;
        list = root;
    }

    return list;
}

Обратите внимание на всю дополнительную проверку, которую вы должны выполнить, в зависимости от того, происходит ли вставка в начале, конце или середине списка. Сравните это с методом двойной стрелки:

void insert( Node ** proot, void * data ) {
    Node ** plist = proot;

    while ( *plist != null ) {
        if ( data < (*plist)->data ) {
            break;
        }

        plist = &(*plist)->next;
    }

    Node * newNode = (Node *)malloc( sizeof(Node) );
    newNode->data = data;
    newNode->next = *plist;
    *plist = newNode;
}

Как показал Эван Теран, это хорошо работает для одиночно связанных списков, но когда он вдвойне связан, вы в конечном итоге преодолеваете столько же, сколько не манипуляций, как случай с одним указателем. Другая обратная сторона заключается в том, что вы проходите две разметки указателя для каждого обхода. Хотя код выглядит более чистым, он, вероятно, работает не так быстро, как один код указателя.

Ответ 5

Вероятно, вы имеете в виду список с двойной связью, причем один из указателей идет вперед, а другой - назад. Это позволяет вам перейти к следующему и предыдущему узлам для данного node, не запомнив последний один или два найденных узла (как в односвязном списке).

Но одна вещь, которую я обнаружил, которая сделала код еще более элегантным, всегда состояла из двух фиктивных элементов в списке всегда, первого и последнего. Это избавляет от краев случаев для вставки и удаления, так как вы всегда действуете на node в середине списка.

Например, создается пустой список:

first = new node
last = new node
first.next = last
first.prev = null
last.next = null
last.prev = first
// null <- first <-> last -> null

Очевидно, что перемещение списка слегка изменено (только для версии с прямой версией):

curr = first.next
while curr <> last:
    do something with curr
    curr = curr.next

Вставки намного проще, поскольку вам не нужно беспокоиться о том, вставляете ли вы начало или конец списка. Вставить перед текущей точкой:

if curr = first:
    raise error
add = new node
add.next = curr
add.prev = curr.prev
curr.prev.next = add
curr.prev = add

Удаления также проще, избегая случаев краев:

if curr = first or curr = last:
    raise error
curr.prev.next = curr.next
curr.next.prev = curr.prev
delete curr

Весь очень чистый код и за счет того, что он должен поддерживать только два дополнительных узла в списке, а не большую нагрузку на сегодняшние огромные пространства памяти.

Предостережение 1: Если вы выполняете встроенное программирование, где пространство по-прежнему может иметь значение, это может оказаться не жизнеспособным решением (хотя в наши дни некоторые встроенные среды также довольно хмурые).

Caveat 2: Если вы используете язык, который уже предоставляет возможности связанного списка, вероятно, лучше сделать это, а не сворачивать свои собственные (кроме особых обстоятельств).