【问题标题】:Recursively free a circular singly linked list?递归释放循环单链表?
【发布时间】:2021-09-01 15:10:28
【问题描述】:

我很好奇什么基本情况可用于递归释放循环链表,将链表的头部作为唯一参数传递。我最初认为基本情况可能是 if (head->next == head) { return NULL; } 足以防止 head->next 指向自身,但情况似乎并非如此(从字面上和比喻上)。最后一个节点free(Head)在这里递归调用后没有被释放。

typedef struct node
{
    int data;
    struct node *next;
} node;

    // temp stores the original head of the list
    node *recursive_destroyer(node *head, node *temp)
    {
        if (head->next == temp) 
            return NULL;
    
        recursive_destroyer(head->next, temp);
    
        free(head);
        head = NULL;
    
        return NULL;
    }

【问题讨论】:

  • 这完全取决于您的实现。你所描述的应该可以正常工作,没有理由不能。
  • @Qix-MONICAWASMISTREATED 将在帖子中发布该功能。我以为我让它工作了,但似乎在递归调用后头部没有释放
  • 如何表示一个空列表?
  • @rici 我有一个 freeLinkedList();处理实际列表的函数(也有一个 LinkedList 结构),它在调用此函数后释放实际列表。我无法在这里释放最后一个节点,以释放整个列表
  • @user12787203:从调用者从不使用该值的函数返回一个值并不能确保任何事情。一个好的经验法则是,一个返回 1 的函数的返回值永远不应被忽略,其返回值将被忽略的函数不应返回 1(即应声明为 void)。其他的只能迷惑人,先从作者开始。 (顺便说一句,您可以要求 GCC 验证返回值永远不会被忽略。)

标签: c recursion free circular-list


【解决方案1】:

我没有将头部设置为等于释放整个列表的另一个函数中的 recursive_destroyer 函数。这是那个函数:

LinkedList *destroy_list(LinkedList *list)
{
    node *temp;

    if (list == NULL)
        return NULL;

    if (list->head == NULL)
        return NULL;

    temp = list->head;
    
    // was not setting list->head equal to this function.
    // causing the original head to never become NULL
    list->head = recursive_destroyer(list->head, temp);

    if (list->head != NULL)
        printf("Error, head not freed.. \n");

    free(list);

    return NULL;
}

还可以传递一个指向 list->head 的指针以避免将 list->head 设置为等于函数。

【讨论】:

    【解决方案2】:

    您的代码不起作用。它将保持单个分配不变。

    考虑循环链表[1]。如果你打电话给recursive_destroyer(head, head),它不会释放任何东西。正确的递归代码是

    void destroy_helper(node* const current, node* const original) {
        if (current->next != original) destroy_helper(current->next, original);
        free(current);
    }
    
    void destroy(node* const list) {
        // null-check necessary since otherwise current->next is UB in destroy_helper
        if (list) destroy_helper(list, list);
    }
    

    如果我们想把它变成迭代代码,首先要修改destroy_helper函数为尾递归:

    void destroy_helper(node* const current, node* const original) {
        node* const next = current->next;
        free(current);
        if (next != original) destroy_helper(next, original);
    }
    

    然后我们可以将其重写为循环:

    void destroy(node* const list) {
        if (list) {
            node* current = list;
            do {
                node* next = current->next;
                free(current);
                current = next;
            } while (current != list);
        }
    }
    

    编辑:

    为了证明我的代码实际上释放了一切,我们可以将free替换为以下函数:

    void free_with_print(node* ptr) {
        printf("Freeing node with value %d\n", ptr->data);
        free(ptr);
    }
    

    一个简单的例子:

    int main() {
        node* node1 = malloc(sizeof *node1);
        node1->data = 1;
        node1->next = node1;
        
        node* node2 = malloc(sizeof *node2);
        node2->data = 2;
        node2->next = node1;
        node1->next = node2;
        destroy(node1);
    }
    

    使用迭代版本打印

    Freeing node with value 1
    Freeing node with value 2
    

    正如预期的那样。用你的原始代码打印尝试同样的事情

    Freeing node with value 1
    

    正如预期的那样,您的代码没有释放两个节点之一,而我的代码释放了两个节点。

    【讨论】:

    • 我相信你的递归函数和我的一样,只是你使用的是temp遍历列表,而不是原来的head。我尝试将这些交换为我的基本情况,结果仍然相同,因为最后一个节点仍未被释放。我已经有一个单独的函数来检查头部是否已经为 NULL。
    • 不,我的功能和你的不一样。我的函数总是释放整个列表。您的函数将释放列表中的所有节点,除了node->nextoriginalnode
    • @user12787203 查看我的编辑了解更多详情。
    【解决方案3】:

    对于这样的代码,您可以(并且应该)在脑海中进行“单步调试”以说服自己它应该按预期工作。这是一项非常重要的学习技能。

    让我们尝试 3 种情况:

    a) 假设列表为空(headtemp 为 NULL)。在这种情况下,由于尝试在 head->next 中使用 NULL 指针,它会在 if (head->next == temp) 崩溃。

    b) 假设列表有一项。在这种情况下,if (head->next == temp) 为真,因为它是一个循环链表,所以它从第一次调用中返回而不释放任何东西。

    c) 假设列表有 2 个项目。在这种情况下,if (head->next == temp) 第一次调用为 false,第二次调用为 true;所以第二次调用不会释放任何东西,而第一次调用将释放列表的原始头部。

    我们可以据此推断,列表中的最后一项永远不会被释放(但如果它不是最后一项,则列表原始头部的第一项将被释放)。

    要解决这个问题,您可以随时释放该项目,例如:

        if (head->next == temp) {
            free(head);
            return NULL;
        }
    

    这很混乱,因为您正在复制代码(并且可以反转条件以避免这种情况)。如果head 总是指向原始头部并且temp 是临时的,那么它也会更容易阅读。此外(如 cmets 中所述)完成时返回 NULL 是没有意义的。重构代码会给你类似的东西:

    void recursive_destroyer(node *head, node *temp)
    {
        if (head->next != temp) {
            recursive_destroyer(head, temp->next);
        }
        free(temp);
    }
    

    但是;如果列表最初为空,这仍然会崩溃。为了解决这个问题,我会做一个包装函数,比如:

    void recursive_destroyer(node *head) {
        if(head != NULL) {
            recursive_destroyer_internal(head, head);
        }
    }
    
    static void recursive_destroyer_internal(node *head, node *temp)
    {
    

    最后一个问题是递归很糟糕(由于所有额外的函数调用,往往会变慢,并且当您用完堆栈空间时有崩溃的风险,并且通常会导致人们难以阅读);特别是如果/当编译器本身不能进行“尾调用优化”以将其转换为非递归循环时。要解决这个问题,您不应该使用递归。例如:

    void destroy(node *head) {
        node *original_head = head;
        node *temp;
    
        if(head != NULL) {
            do {
                temp = head;
                head = head->next;
                free(temp);
            } while(head != original_head);
        }
    }
    

    【讨论】:

      【解决方案4】:

      你问的是传入一个参数

      我认为大多数人跳过了你的第一句话,直接跳到了你的代码。你在帖子里问:

      我很好奇什么基本情况可用于递归释放循环链表,将链表的头部作为唯一参数传递。 ...

      你继续解释你尝试的方法:

      我最初认为基本情况可能是 if (head->next == head) { return NULL; } 足以阻止 head->next 指向自身,但事实似乎并非如此......

      您提供了一个代码示例,但它传入了两个参数。

      删除head->next,而不是head

      这个答案解决了你第一句话中的问题。接下来将与您的方法进行简短比较。

      检查head->next 是否指向head 是一个很好的停止情况,但这意味着您的递归函数需要在每次迭代时删除和销毁head->next,然后递归处理相同的列表。

      如果head->nexthead相同,则销毁head,就完成了。

      我看不出从这个函数返回值有什么意义,所以我删除了它。

      void recursive_destroyer(node *head) {
      
          if (head == NULL) return;
      
          if (head->next == head) {
              destroy(head);
              return;
          }
      
          node *tmp = head->next;
          head->next = head->next->next;
          destroy(tmp);
      
          recursive_destroyer(head);
      }
      

      请注意,递归函数不再需要第二个参数。

      与您的方法比较

      您的示例代码中存在一些导致错误行为的问题。还有其他一些答案已经深入解决了这些问题。但是,我确实想指出,您应该尽可能选择 尾递归

      尾递归是兄弟调用的一种特殊情况。同级调用是指一个函数调用另一个函数,然后立即返回。在下面的示例中,function_A() 正在对 function_B() 进行同级调用

      void function_B () { puts(__func__); }
      
      void function_A (bool flag) {
          if (flag) {
              function_B();
              return;
          }
          puts(__func__);
      }
      

      编译器可以优化同级调用,以重用当前函数的堆栈帧来进行同级调用。这是因为在兄弟返回后,调用者的当前函数状态都不需要。

      尾递归调用可以用同样的方式进行优化。因此,优化后的尾递归调用与普通循环具有相同的内存占用。而事实上,如果优化器检测到同级调用是递归调用,而不是对自身执行函数调用,尾递归转换为跳转到函数的开头。大多数 C 编译器都可以执行这种优化。您可以自己手动执行此优化,并轻松将尾递归函数转换为循环。

      如果您正在使用 C 编译器的优化功能,并且它支持尾递归优化,那么没有技术理由更喜欢循环而不是尾递归。如果您的软件团队发现阅读递归代码令人困惑,那么最好使用循环,以使代码更易于理解。

      【讨论】:

        猜你喜欢
        • 2020-12-08
        • 1970-01-01
        • 2012-10-31
        • 1970-01-01
        • 2018-10-24
        • 1970-01-01
        • 2020-09-24
        • 2018-06-27
        • 2017-01-21
        相关资源
        最近更新 更多