[算法/数据结构] 链表和哨兵节点

1. 简介

链表是一种基础的数据结构,但对于一些初学者来说,实现一个链表还是比较困难的,许多操作作用在头部或尾部时需要特殊处理。比如下面这段代码:

template <typename T> void LinkedList<T>::remove(LinkedListNode<T> * node)
{
    if (node -> prev == nullptr)
        head = node -> succ;    //更新链表头节点
    else
        node -> prev -> succ = node -> succ;
    if (node -> succ != nullptr)
        node -> succ -> prev = node -> prev;
}

上述代码进行了两次的空值判断,有可能会更新链表头节点,比较复杂,容易出现遗漏。如果在实现链表的时候借助哨兵节点,则可以有效降低代码量和代码逻辑的复杂程度。

在链表和其它类似数据结构中,数据都保存在节点中。哨兵节点是一种不保存任何数据的节点,用法非常灵活,一般被用来标识数据结构的头尾或没有节点的情况。下面给出一个带哨兵节点的链表实现。

2. 实现

c++代码:

template <typename T> struct LinkedListNode
{
    T val;  //节点值
    LinkedListNode<T> * prev;   //前驱节点
    LinkedListNode<T> * succ;   //后继节点

    LinkedListNode()
    {
        prev = succ = nullptr;
    }

    LinkedListNode(T val, LinkedListNode<T> * prev = nullptr, LinkedListNode<T> * succ = nullptr)
    {
        this -> val = val;
        this -> prev = prev;
        this -> succ = succ;
    }
};

template <typename T> struct LinkedList
{
    int size;   //链表的长度
    LinkedListNode<T> * head;   //头哨兵节点
    LinkedListNode<T> * tail;   //尾哨兵节点

    LinkedList()
    {
        size = 0;
        head = new LinkedListNode<T>();
        tail = new LinkedListNode<T>();
        head -> succ = tail;
        tail -> prev = head;
    }

    LinkedListNode<T> * insert(LinkedListNode<T> * prev, T val) //在prev节点后插入val值
    {
        LinkedListNode<T> * node = new LinkedListNode<T>(val, prev, prev -> succ);
        prev -> succ -> prev = node;
        prev -> succ = node;
        ++size;
        return node;
    }

    LinkedListNode<T> * insert(T val)   //在链表头部插入val值
    {
        return insert(head, val);
    }

    void remove(LinkedListNode<T> * node)   //在链表中删除node节点
    {
        node -> prev -> succ = node -> succ;
        node -> succ -> prev = node -> prev;
        --size;
    }

    LinkedListNode<T> * get(int index)  //返回链表index位置的节点
    {
        LinkedListNode<T> * node = head -> succ;
        while (index--)
            node = node -> succ;
        return node;
    }

    T & operator[](int index)   //重载[]运算符
    {
        return get(index) -> val;
    }
};

在链表中我们使用了两个哨兵节点,分别标识链表的头和尾。哨兵节点在链表创建的时候就存在了,并且对外部是不可见的,因此这两个哨兵节点会始终标识链表的头和尾,不可能有节点出现在头哨兵节点之前,也不可能有节点出现在尾哨兵节点之后。链表中至少会包含两个哨兵节点,不会出现空链表的情况。

可以看出,在使用了哨兵节点之后,代码量和代码逻辑的复杂程度都大大降低。还是以remove方法为例。

不使用哨兵节点:

template <typename T> void LinkedList<T>::remove(LinkedListNode<T> * node)
{
    if (node -> prev == nullptr)
        head = node -> succ;    //更新链表头节点
    else
        node -> prev -> succ = node -> succ;
    if (node -> succ != nullptr)
        node -> succ -> prev = node -> prev;
}

使用哨兵节点

template <typename T> void LinkedList<T>::remove(LinkedListNode<T> * node)
{
    node -> prev -> succ = node -> succ;
    node -> succ -> prev = node -> prev;
}

使用哨兵节点减少了两次对空值的判断。

3. 原因

现在我们已经知道使用哨兵节点会降低代码量和代码逻辑的复杂程度,但原因是什么呢?

从本质上来看,哨兵节点不保存任何数据,仅作为一个标识,一般出现在原本应该为空值的地方。比如说不使用哨兵节点的链表的第一个节点,其前驱节点的值一般为nullptr,使用哨兵节点之后,其前驱节点就是头哨兵节点。用哨兵节点代替空值,这样会带来两个好处:

(1) 省去判断空值。如果不使用哨兵节点,那么就经常需要在使用一个指针之前,判断其是否为空。

(2) 避免出现空数据结构的情况。对于链表和其它类似的数据结构,空数据结构的处理比较麻烦,因为向空数据结构中插入第一个数据的时候,需要给一些属性赋值(对于链表来说这个属性就是头节点,对于某些树来说这个属性则是根),而向非空数据结构中插入数据时则不需要赋值,这就导致了进行插入操作时需要分类讨论,先判断数据结构是否为空。同理,删除操作也存在类似问题,需要判断删除后数据结构是否为空。

4. 弊端

虽然哨兵节点有很多好处,但依旧存在一个弊端。哨兵节点往往替代的是空节点,而空节点不占用空间,因此使用哨兵节点实现数据结构,相比不使用哨兵节点的实现,总是要多占用一些空间。具体对空间效率的影响程度,要看哨兵节点的个数相对于总节点个数的占比,占比越高则影响越大。

5. 总结

在数据结构中使用哨兵节点有助于降低代码量和代码逻辑的复杂程度。
哨兵节点降低代码量和代码逻辑复杂程度的原因在于:省去判断空值和避免出现空数据结构的情况。
使用哨兵节点有可能会影响空间效率。
哨兵节点不止在链表中使用,还有很多数据结构可以使用哨兵节点,比如二叉搜索树。


参考资料:

  1. Sentinel node - Wikipedia    https://en.wikipedia.org/wiki/Sentinel_node

禁止一切商业转载,非商业转载请注明原作者和出处。

你可能感兴趣的:(算法/数据结构)