在Sentinel Node wikipedia page上,它表示Sentinel节点优于NULL的好处是:
我真的不明白对Sentinel节点的检查会更快(或者如何在链表或树中正确实现它们),所以我想这更像是一个两部分问题:
如果您只是进行简单的迭代而不是查看元素中的数据,那么哨兵没有优势。
但是,将它用于“查找”类型算法时会有一些实际的好处。例如,想象一下链接列表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
实现中使用的排序算法。它有一个很酷的分区算法变体,它使用标记来删除一些分支。
我认为一个小代码示例比理论讨论更好。
以下是双链接节点列表中节点删除的代码,其中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)的答案在链接的维基百科条目的最后一句中:“由于通常链接到NULL的节点现在链接到”nil“(包括nil本身),它消除了对昂贵的分支操作的需要检查是否为NULL。“
通常,您需要在访问节点之前测试其为NULL。相反,如果您有一个有效的nil节点,那么您不需要进行第一次测试,保存比较和条件分支,否则当分支被错误预测时,现在的超标量CPU可能会很昂贵。
我将尝试在标准模板库的上下文中回答:
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;
}
让我们先放下哨兵。在代码复杂性方面,对于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即可拥有。事实上,这个例子是如此简洁和有趣。关于如何在开罗寻找大象和大象的讨论可能会让您感兴趣。当然,我不是在谈论真正捕猎大象。