浅聊哨兵思想及其在算法问题中的应用

1,998 阅读10分钟

7b6b0ea883f898094d7f5395723e8a0.jpg

前言

前几日有个学妹来问一道题(回文链表),我给了一段示例代码,介绍了一下哨兵思想,但学妹似乎还有疑问。谷歌找了相关资料,但资料相对比较零散,索性自己整理了相关的资料,聊聊哨兵思想及其应用。

image.png

哨兵思想

起源

哨兵思想的起源已经无从得知,但它最早可以追溯到上世纪 60 年代。在那时候,人们已经开始使用哨兵(sentinel)来简化链表的实现。哨兵是指在链表中插入一个节点,用于标记链表的开头或结尾。

定义

哨兵思想是指在算法中使用一个特殊值来检测或标记某些条件的发生,它的目的是为了简化代码,并使其更容易理解,常常用于在循环中优化边界条件的判断。

假设你是一名守卫,你的任务是在一个城市的城门处保卫城市,防止有人进入或离开城市。你的城市有很多城门,你需要站在城门处,监视每个人是否进入或离开城市。

a161529bb2293398ce7aec68f0b0814.jpg 为了方便起见,你决定设置一个哨兵,来帮助你监视所有城门。哨兵可以在城门处站岗,当有人进入或离开城市时,哨兵会立即告诉你。这样,你就不必在每个城门处都站岗,而只需要在哨兵处站岗即可。

在这个例子中,哨兵就是在链表中的哨兵,它的作用是帮助监视所有城门(也就是链表中的所有节点)。通过使用哨兵,你(也就是程序)可以更方便地监视所有城门,而不必在每个城门处都站岗(也就是在每个节点处进行特殊处理)。 例如,在某些数据结构中,可能需要使用哨兵值来标识链表的结尾或空值。在这种情况下,哨兵值可以用来避免在每次操作中进行空值检查。这样可以提高程序的效率,同时也可以简化代码的实现。

哨兵思想也可以用于其他数据结构中,例如数组和树结构。在这些情况下,哨兵值可以用来标识数组边界或树中的特殊节点。

以下是利用哨兵思想解决问题的一般思路

graph TD

哨兵 -->|在循环中判断边界条件| 循环

循环 -->|使用哨兵优化| 算法

算法 -->|解决算法问题| 题解

举几个例子

例一:数组

在查找某个值在数组中的位置时,我们常常需要特殊判断数组的第一个元素。如果我们使用哨兵思想,就可以把这些特殊判断都省略掉.

在使用哨兵思想时,通常需要将数组的第一个元素设置为哨兵值。然后在遍历数组时,逐个与哨兵值进行比较,找到了就结束循环。这样,就可以省略掉对数组第一个元素的特殊判断。

示例代码

def search(arr, x):
    temp = arr[0]  # 保存数组的第一个元素
    arr[0] = x  # 将数组的第一个元素作为哨兵值,设置为要查找的值
    i = len(arr) - 1
    while arr[i] != x:
        i -= 1
    arr[0] = temp  # 恢复数组的第一个元素
    return i if arr[i] == x else -1  # 返回哨兵值的下标,如果找不到则返回

复杂度分析

假设我们要查找的值在数组的最后一个位置,那么算法需要遍历整个数组才能找到这个值。因此,在最坏情况下,算法的时间复杂度为 O(n),其中 n 为数组的长度。

因为算法只使用了常数级别的额外空间,所以空间复杂度为 O(1)。

这个算法的时间复杂度是比较优秀的,但如果要对数组进行多次查找操作,那么就有可能需要使用其他算法(如哈希表等)来提升效率。为避免文章冗长,这里笔者不再展示代码,相关实现

解题思维导图

graph TD

Start("开始") --> Step1("保存数组的第一个元素,将数组的第一个元素作为哨兵值,设置为要查找的值")
Step1 --> Step2("从数组末尾向前遍历")
Step2 --> Step3("找到了哨兵值则结束循环")
Step2 --> Step6("未找到哨兵值则继续循环")
Step6 --> Step2
Step3 --> Step4("将哨兵值复位")
Step4 --> Step5("如果哨兵值未被修改,则表示查找失败")
Step4 --> Step7("哨兵值被修改,返回哨兵值的下标")
Step5 --> End("结束")
Step7 --> End("结束")

例二:链表

在链表中,哨兵是一个空的节点,它的作用是方便对链表的头部或尾部进行操作。

举个例子,使用哨兵可以避免在头部插入节点时,需要特判链表是否为空的情况。可以将哨兵节点设为链表的第一个节点,并让它的 next 指针指向真正的第一个节点。然后,在插入节点时,只需要在哨兵节点之后插入即可。这样就可以避免判断链表是否为空的情况。

假设需要查找链表中的某个值,那么可以尝试使用哨兵思想 以下我用 Python 实现的单链表的节点的值的查询

示例代码

class Node:
    def __init__(self, value, next=None):
        self.value = value
        self.next = next

class LinkedList:
    def __init__(self, values=None):
        self.dummy_head = Node(None)  # 哨兵节点
        self.size = 0
        if values:
            self.add_all(values)
            
    # 向链表中添加一个值序列
    def add_all(self, values):
        for value in values:
            self.add_last(value)
    
    # 向链表的最后添加一个节点
    def add_last(self, value):
        self.add_after(self.tail, value)
    
    # 在指定节点之后添加一个新节点
    def add_after(self, node, value):
        new_node = Node(value)
        new_node.next = node.next
        node.next = new_node
        self.size += 1

    @property
    # 返回链表的最后一个节点
    def tail(self):
        curr = self.dummy_head
        while curr.next:
            curr = curr.next
        return curr
    # 查找链表中是否存在一个值为给定值的节点
    def find(self, value):
        curr = self.dummy_head.next
        while curr and curr.value != value:
            curr = curr.next
        return curr


# 使用示例
linked_list = LinkedList([1, 2, 3, 4, 5])
node = linked_list.find(6)
if node:
    print(f'发现值: {node.value}')
else:
    print('未找到')

复杂度分析

时间复杂度:O(n)

在最坏的情况下,我们需要遍历整个链表来找到目标值。因此,时间复杂度为O(n)。

空间复杂度:O(1)

我们只使用了常数个变量来存储信息,因此空间复杂度为O(1)。

解题思维导图

graph LR
A[哨兵节点] -- 初始化 --> B[dummy_head]
A -- 查找 --> C[curr = self.dummy_head.next]
C -- 循环 --> D[curr and curr.value != value]
D -- 赋值 --> C
C -- 返回 --> E[curr]

代码流程图:

graph LR
A[LinkedList] -- 初始化 --> B[dummy_head]
A -- 批量添加 --> C[for value in values]
C -- 添加最后 --> D["self.add_last(value)"]
A -- 添加到最后 --> E["self.add_after(self.tail, value)"]
A -- 尾节点 --> F[curr = self.dummy_head]
F -- 循环 --> G[curr.next]
G -- 赋值 --> F
F -- 返回 --> H[curr]
A -- 查找 --> I[curr = self.dummy_head.next]
I -- 循环 --> J[curr and curr.value != value]
J -- 赋值 --> I
I -- 返回 --> K[curr]

其他

除了数组和链表,哨兵思想可以用于多种数据结构,例如栈、队列、哈希表、二叉树、图等。这里就不展示代码了,感兴趣的读者可以自行尝试编程。

在栈中,哨兵可以用于指示栈的底部。这样,当我们执行 push 操作时,就不需要先判断栈是否为空了。同样,当我们执行 pop 操作时,也不需要再判断栈是否为空了。

思路流程图
sequenceDiagram
    定义 Node 类 ->> 定义 Stack 类: 定义 sentinel 节点
    定义 push 操作 ->> 定义 Stack 类: 新建节点,并插入到 sentinel 后面
    定义 pop 操作 ->> 定义 Stack 类: 弹出 sentinel 的后继节点

队列

在队列中,哨兵可以用于指示队列的首尾。这样,当我们执行入列操作时,就不需要先判断队列是否已满了。同样,当我们执行出列操作时,也不需要再判断队列是否为空了。

思路流程图
graph LR
A[哨兵节点]
B[队头指针]
C[队尾指针]
A --> B
A --> C
B --> C
B -- 出队 --> C
C -- 入队 --> B

流程图解释:

  • 在队列中,哨兵节点用来指示队列的首尾。
  • 队头指针指向队列的第一个有效节点,队尾指针指向队列的最后一个有效节点。
  • 当我们执行出队操作时,会将队头指针向后移动一位;当我们执行入队操作时,会将队尾指针向后移动一位。

哈希表

在哈希表中,哨兵可以用于指示哈希表的桶。这样,当我们执行插入操作时,就不需要再判断桶是否已满了。同样,当我们执行查询操作时,也不需要再判断桶是否为空了。

可以参考如下流程我们需要按照如下流程:

  1. 创建哈希表。
  2. 创建一个数组来表示哈希表的桶,并在每个桶中插入一个哨兵节点。
  3. 对于每一个要插入哈希表的元素,先计算出它在哈希表中对应的桶的下标,然后将其插入到该桶的末尾。
  4. 对于每一个要查询的元素,先计算出它在哈希表中对应的桶的下标,然后在该桶中查找该元素。如果找到了,则返回查询结果;如果没有找到,则返回 null。
思路流程图
sequenceDiagram
哈希表->>数组: 创建哈希表
数组->>哨兵节点: 插入哨兵节点
元素->>哈希表: 要插入的元素
哈希表->>哨兵节点: 插入元素
元素->>哈希表: 要查询的元素
哈希表->>哨兵节点: 查找元素

二叉树

在二叉树中,哨兵可以用于指示二叉树的空节点。这样,我们在遍历二叉树时,就不需要再判断节点是否为空了。

思路流程图
sequenceDiagram
    participant 根节点
    participant 哨兵节点
    participant 左子节点
    participant 右子节点

    根节点->>哨兵节点: 指向哨兵节点
    哨兵节点->>左子节点: 指向左子节点
    哨兵节点->>右子节点: 指向右子节点
    左子节点->>哨兵节点: 指向哨兵节点
    右子节点->>哨兵节点: 指向哨兵节点

流程图解释:在上面的流程图中,我们将哨兵节点作为二叉树的根节点。在遍历二叉树时,我们可以直接跳过哨兵节点,而不需要再判断它是否为空。当我们遍历完一个节点的左右子节点后,可以直接回到哨兵节点,然后再继续遍历其他节点。

在图中,哨兵可以用于指示图的边界。这样,我们在遍历图时,就不需要再判断边界了。

思路流程图
graph LR
A("哨兵节点")
B("节点")
C("节点")
D("节点")
A --> B
A --> C
A --> D

流程图解释:在这个流程图中,我们将哨兵节点看作是图的边界。当我们执行插入操作时,会新建一个节点并将其插入到哨兵节点的后面;当我们执行删除操作时,会将哨兵节点的后继节点删除。

实战:回文链表

Tips此节在力扣做过该题目阅读效果更佳

题目地址:234. 回文链表 - 力扣(LeetCode)

题目:

image.png

思路:

  1. 将哨兵插入链表的开头,并使哨兵的 next 指针指向链表的第一个节点。
  2. 设置两个指针:快指针和慢指针。初始时,快指针指向哨兵,慢指针指向哨兵的 next 指针所指向的节点。
  3. 将快指针向前移动两个节点,将慢指针向前移动一个节点。重复这个过程,直到快指针指向链表的最后一个节点为止。
  4. 在移动快指针的过程中,将慢指针指向的节点的值存储在一个栈中。
  5. 当快指针指向链表的最后一个节点时,将慢指针重新定位到哨兵的 next 指针所指向的节点。
  6. 通过使用栈,依次比较慢指针指向的节点和栈顶元素是否相等。如果慢指针指向的节点和栈顶元素是相等的,则将栈顶元素弹出栈,并将慢指针向前移动一个节点。如果不相等,则说明链表不是回文的。重复这个过程,直到慢指针指向链表的最后一个节点为止。
  7. 如果在比较过程中,栈已经被弹空,说明链表是回文的。否则,链表不是回文的。

这种方法的时间复杂度为 O(n),空间复杂度为 O(n),其中 n 是链表中节点的数量。

示例代码

/**  
 * Definition for singly-linked list.  
 * struct ListNode {  
 *     int val;  
 *     struct ListNode *next;  
 * };  
 */  
bool isPalindrome(struct ListNode* head) {  
    // 如果链表为空或只有一个元素,直接返回 true  
    if (head == NULL || head->next == NULL) {  
        return true;  
    }  
  
    // 找到链表的中点  
    struct ListNode *slow = head;  
    struct ListNode *fast = head;  
    while (fast->next != NULL && fast->next->next != NULL) {  
        slow = slow->next;  
        fast = fast->next->next;  
    }  
  
    // 反转后半部分  
    struct ListNode *prev = NULL;  
    struct ListNode *curr = slow->next;  
    while (curr != NULL) {  
        struct ListNode *next = curr->next;  
        curr->next = prev;  
        prev = curr;  
        curr = next;  
    }  
    slow->next = prev;  
  
    // 比较前半部分和反转后的后半部分是否相等  
    struct ListNode *p1 = head;  
    struct ListNode *p2 = slow->next;  
    while (p2 != NULL) {  
        if (p1->val != p2->val) {  
            return false;  
        }  
        p1 = p1->next;  
        p2 = p2->next;  
    }  
    return true;  
}

总结

本文从介绍哨兵思想的起源出发,讲了哨兵思想的定义和它在数组、链表等各个数据结构中使用的示例,最后给出一道leetcode的题目用于练习。

最后

本文所涉及的内容基于我的个人实践,很多东西基于我个人的理解,所以有望大家多多指教。
如果你发现了本文的错误之处,欢迎你在评论区留言,我会及时的进行修改。如果你有其他的想法,也欢迎在评论区留言,我会在看到评论的第一时间回复。
码字不易,如果你觉得本文对你有帮助,麻烦点个大大的赞,你的支持是我更新的最大动力!