前哨节点如何提供优于NULL的优势?

问题描述 投票:47回答:5

Sentinel Node wikipedia page上,它表示Sentinel节点优于NULL的好处是:

  • 提高了运营速度
  • 减少算法代码大小
  • 提高数据结构的稳健性(可以说)。

我真的不明白对Sentinel节点的检查会更快(或者如何在链表或树中正确实现它们),所以我想这更像是一个两部分问题:

  1. 是什么导致Sentinel节点比NULL更好的设计?
  2. 你如何在(例如)列表中实现一个标记节点?
c++ algorithm data-structures
5个回答
28
投票

如果您只是进行简单的迭代而不是查看元素中的数据,那么哨兵没有优势。

但是,将它用于“查找”类型算法时会有一些实际的好处。例如,想象一下链接列表std::list,您要在其中找到特定值x

没有哨兵你会做的是:

for (iterator i=list.begin(); i!=list.end(); ++i) // first branch here
{
  if (*i == x) // second branch here
    return i;
}
return list.end();

但是对于哨兵(当然,结尾实际上必须是一个真正的节点......):

iterator i=list.begin();
*list.end() = x;

while (*i != x) // just this branch!
  ++i;

return i;

您看到没有必要使用额外的分支来测试列表的末尾 - 值始终保证在那里,因此如果在“有效”元素中找不到end(),则会自动返回x

对于另一个很酷且实际上有用的哨兵应用,请参阅“intro-sort”,这是在大多数std::sort实现中使用的排序算法。它有一个很酷的分区算法变体,它使用标记来删除一些分支。


60
投票

我认为一个小代码示例比理论讨论更好。

以下是双链接节点列表中节点删除的代码,其中NULL用于标记列表的末尾,其中两个指针firstlast用于保存第一个和最后一个节点的地址:

// Using NULL and pointers for first and last
if (n->prev) n->prev->next = n->next;
        else first = n->next;
if (n->next) n->next->prev = n->prev;
        else last = n->prev;

这是相同的代码,而是有一个特殊的虚拟节点来标记列表的末尾,列表中第一个节点的地址存储在特殊节点的next字段中,列表中的最后一个节点是存储在特殊虚拟节点的prev字段中:

// Using the dummy node
n->prev->next = n->next;
n->next->prev = n->prev;

节点插入也存在同样的简化;例如,在节点n之前插入节点x(有x == NULLx == &dummy意味着插入最后位置)代码将是:

// Using NULL and pointers for first and last
n->next = x;
n->prev = x ? x->prev : last;
if (n->prev) n->prev->next = n;
        else first = n;
if (n->next) n->next->prev = n;
        else last = n;

// Using the dummy node
n->next = x;
n->prev = x->prev;
n->next->prev = n;
n->prev->next = n;

正如您所看到的,对于双向链接列表,所有特殊情况和所有条件都删除了虚节点方法。

下图显示了内存中同一列表的两种方法......


8
投票

您的问题(1)的答案在链接的维基百科条目的最后一句中:“由于通常链接到NULL的节点现在链接到”nil“(包括nil本身),它消除了对昂贵的分支操作的需要检查是否为NULL。“

通常,您需要在访问节点之前测试其为NULL。相反,如果您有一个有效的nil节点,那么您不需要进行第一次测试,保存比较和条件分支,否则当分支被错误预测时,现在的超标量CPU可能会很昂贵。


0
投票

我将尝试在标准模板库的上下文中回答:

1)在对“next()”的调用中,NULL不一定表示列表的结尾。如果发生内存错误怎么办?返回一个sentinel节点,是一种明确的方式来表明列表末尾已经发生,而不是其他一些结果。换句话说,NULL可以指示各种事物,而不仅仅是列表末尾。

2)这只是一种可能的方法:当您创建列表时,创建一个不在类外部共享的私有节点(例如,称为“lastNode”)。在检测到您已迭代到列表末尾时,让“next()”返回对“lastNode”的引用。还有一个名为“end()”的方法返回对“lastNode”的引用。最后,根据您实现类的方式,您可能需要覆盖比较运算符才能使其正常工作。

例:

class MyNode{

};

class MyList{

public:
    MyList () : lastNode();

    MyNode * next(){

       if (isLastNode) return &lastNode;
       else return //whatever comes next
    }

    MyNode * end() {

       return &lastNode;
    }

    //comparison operator
    friend bool operator == (MyNode &n1, MyNode &n2){

        return (&n1 == &n2); //check that both operands point to same memory
    }


private:
    MyNode lastNode;
};


int main(){

  MyList list;
  MyNode * node = list.next();

  while ( node != list.end() ){ 

     //do stuff!

     node = list.next();
  }

  return 0; 
}

0
投票

让我们先放下哨兵。在代码复杂性方面,对于ltjax中的答案,他为我们提供了代码

for (iterator i=list.begin(); i!=list.end(); ++i) // first branch here
{
  if (*i == x) // second branch here
    return i;
}
return list.end();

代码可以更好地形成为:

auto iter = list.begin();
while(iter != list.end() && *iter != x)
    ++iter;
return iter;

由于杂乱(分组)的循环终止条件,人们可以很容易地看到循环终止条件,而不会在通过循环体时记住所有循环终止条件以推断正确性,并且键入较少。但请注意这里的bool电路。

关键是这里使用的标记不是为了降低代码复杂度,但它有助于我们减少每个循环中的索引检查。对于线性搜索,我们首先检查索引是否具有有效范围,如果是,则检查该值是否是我们想要的值,而不使用sentinel。但是对于最终放置了期望值的sentinel,我们可以省去索引边界检查,但只检查值,因为保证循环终止。这属于哨兵控制循环:重复直到看到所需的值。

推荐阅读:算法简介,第三版,如果你有pdf格式,只需搜索关键字sentinel即可拥有。事实上,这个例子是如此简洁和有趣。关于如何在开罗寻找大象和大象的讨论可能会让您感兴趣。当然,我不是在谈论真正捕猎大象。

© www.soinside.com 2019 - 2024. All rights reserved.