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

时间:2011-03-21 22:12:07

标签: c++ algorithm data-structures

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

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

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

  1. 是什么导致Sentinel节点比NULL更好的设计?
  2. 如何在(例如)列表中实现一个标记节点?

5 个答案:

答案 0 :(得分: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之前插入节点xx == 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;

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

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

NULL/dummy node alternatives for a doubly-linked list

答案 1 :(得分: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,您将自动返回std::sort你的“有效”元素。

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

答案 2 :(得分:8)

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

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

答案 3 :(得分: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; 
}

答案 4 :(得分: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;

由于混乱的(分组的)循环终止条件,在通过循环主体推理正确性时,无需记住所有循环终止条件,就可以轻松地看到循环终止条件,并减少键入。不过请注意此处的布尔回路。

重点是,这里使用的哨兵并不是为了降低代码复杂度,而是有助于我们减少每个循环中的索引检查。对于线性搜索,我们首先检查索引是否在有效范围内,如果在有效范围内,则在不使用哨兵的情况下检查该值是否是我们想要的值。但是,通过将哨兵放在期望值的末尾,我们可以省去索引边界检查,而仅检查值,因为可以保证循环终止。这属于哨兵控制的循环:重复直到看到所需的值。

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