在Sentinel Node wikipedia page上,它表示Sentinel节点优于NULL的好处是:
我真的不明白对前哨节点的检查会更快(或者如何在链表或树中正确实现它们),所以我想这更像是一个两部分问题:
答案 0 :(得分:60)
我认为一个小代码示例比理论讨论更好。
以下是双向链接节点列表中节点删除的代码,其中NULL
用于标记列表的末尾,其中两个指针first
和last
是用于保存第一个和最后一个节点的地址:
// 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 == NULL
或x == &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;
正如您所看到的,对于双向链接列表,删除了所有特殊情况和所有条件的虚拟节点方法。
下图显示了内存中同一列表的两种方法......
答案 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即可。实际上,这个例子非常简洁有趣。有关如何在开罗寻找大象和大象的讨论可能会让您感兴趣。当然,我并不是在谈论真正的狩猎大象。