diff --git a/docs/04_string/04_08_trie.md b/docs/04_string/04_08_trie.md index 9ef8e618..910f04a7 100644 --- a/docs/04_string/04_08_trie.md +++ b/docs/04_string/04_08_trie.md @@ -1,106 +1,119 @@ -## 1. 字典树简介 +## 1. 字典树介绍 -> **字典树(Trie)**:又称为前缀树、单词查找树,是一种树形结构。顾名思义,就是一个像字典一样的树。它是字典的一种存储方式。字典中的每个单词在字典树中表现为一条从根节点出发的路径,路径相连的边上的字母连起来就形成对应的字符串。 +> **字典树(Trie)**,又称前缀树,是一种高效存储和查找字符串集合的树形结构。可以把它想象成一本「分层字典」:每个单词从根节点出发,按字母顺序一层层分支,直到单词结尾。具有相同前缀的单词会在树上共用同一条路径,就像家族树中有共同祖先的亲戚一样,这样能大幅提升查找和前缀匹配的效率。 -例如下图就是一棵字典树,其中包含有 `"a"`、`"abc"`、`"acb"`、`"acc"`、`"ach"`、`"b"`、`"chb"` 这 7 个单词。 +下图展示了一棵字典树,包含 `"a"`、`"abc"`、`"acb"`、`"acc"`、`"ach"`、`"b"`、`"chb"` 这 7 个单词。 ![字典树](https://qcdn.itcharge.cn/images/20240511165918.png) -从图中可以发现,这棵字典树用边来表示字母,从根节点到树上某一节点的路径就代表了一个单词。比如 1ドル \rightarrow 2 \rightarrow 6 \rightarrow 10$ 表示的就是单词 `"acc"`。为了清楚地判断某节点路径是否表示一个单词,我们还可以在每个单词对应路径的结束位置增加一个结束标记 $end$(图中红色节点),表示从根节点到这里有一个单词。 +在图中,边表示字符,从根节点到某节点的路径即为一个单词。例如 1ドル \rightarrow 2 \rightarrow 6 \rightarrow 10$ 表示 `"acc"`。每个单词的结尾节点通常会有一个结束标记 $end$(红色节点),用于区分完整单词。 -字典树的结构比较简单,其本质上就是一个用于字符串快速检索的多叉树,树上每个节点都包含多字符指针。将从根节点到某一节点路径上经过的字符连接起来,就是该节点对应的字符串。 +字典树的本质是利用字符串的公共前缀,将相同前缀的单词合并存储,从而加快查询速度,减少重复比较,利用空间换时间。 -**字典树设计的核心思想 **:利用空间换时间,利用字符串的公共前缀来降低查询时间的开销,最大限度的减少无谓的字符串比较,以达到提高效率的目的。 - -下面我们来归纳一下 **字典树的基本性质**: - -- 根节点不包含字符,除根节点外,每个节点都只包含一个字符。 -- 从根节点到某一节点,路径航经过的字符串连接起来,就是该节点对应的字符串。 -- 每个节点的所有子节点包含的字符串都不相同。 +**字典树的基本性质:** +- 根节点不存字符,其他每个节点只存一个字符。 +- 从根到某节点的路径组成该节点对应的字符串。 +- 每个节点的所有子节点字符都不相同。 ## 2. 字典树的基本操作 -字典树的基本操作有 **创建**、**插入**、**查找** 和 **删除**。其中删除操作是最不常用,我们这里主要介绍字典树的创建、插入和查找。 +字典树常见的基本操作包括 **创建**、**插入**、**查找** 和 **删除**。其中,删除操作在实际应用中较少用到,因此本节主要聚焦于字典树的创建、插入和查找。 ### 2.1 字典树的结构 -#### 2.1.1 字典树的节点结构 +#### 2.1.1 字典树节点的定义 -首先我们先来定义一下字典树的节点结构。 +我们先明确字典树节点的结构。 -上面说到字典树是一棵多叉树,这个 **「多叉」** 的意思是一个节点可以有多个子节点。而多叉的实现方式可以使用数组实现,也可以使用哈希表实现。接下来我们来介绍一下这两种节点结构。 +字典树本质上是一棵多叉树,即每个节点可以拥有多个子节点。实现多叉结构时,常见的方式有两种:使用数组或哈希表。下面分别介绍这两种实现方式。 -- 如果字符串所涉及的字符集合只包含小写英文字母的话,我们可以使用一个长度为 26ドル$ 的数组来表示当前节点的多个子节点,如下面代码所示。 +- 当字符串仅包含小写英文字母时,可以用长度为 26ドル$ 的数组来存储每个节点的所有子节点。例如: ```python class Node: # 字符节点 - def __init__(self): # 初始化字符节点 - self.children = [None for _ in range(26)] # 初始化子节点 - self.isEnd = False # isEnd 用于标记单词结束 + def __init__(self): + # 初始化字符节点 + # children 是长度为 26 的数组,分别对应 'a'~'z' 的子节点 + self.children = [None for _ in range(26)] # 初始化所有子节点为 None + self.isEnd = False # isEnd 用于标记该节点是否为某个单词的结尾 ``` -代码中,$self.children$ 使用数组实现,表示该节点的所有子节点。$isEnd$ 则用于标记单词是否结束。 +上述代码中,$self.children$ 采用数组结构,表示该节点的全部子节点;$isEnd$ 用于标记该节点是否为某个单词的结尾。 -这样,如果我们在插入单词时,需要先将单词中的字符转换为数字,再创建对应的字符节点,并将其映射到长度为 26ドル$ 数组中。 +在插入单词时,需要将每个字符转换为对应的数字索引,然后在长度为 26ドル$ 的数组中定位并创建相应的子节点。 -- 如果所涉及的字符集合不仅包含小写字母,还包含大写字母和其他字符,我们可以使用哈希表来表示当前节点的多个子节点,如下面代码所示。 +- 如果字符集不仅包含小写字母,还包括大写字母或其他字符,则可使用哈希表来存储当前节点的所有子节点,具体实现如下: ```python -class Node: # 字符节点 - def __init__(self): # 初始化字符节点 - self.children = dict() # 初始化子节点 - self.isEnd = False # isEnd 用于标记单词结束 +class Node: # 字符节点 + def __init__(self): # 初始化字符节点 + self.children = dict() # 用哈希表存储所有子节点,key 为字符,value 为 Node 实例 + self.isEnd = False # 标记该节点是否为某个单词的结尾 + # 例如:children['a'] 表示以当前节点为父节点,字符为 'a' 的子节点 ``` -代码中,$self.children$ 使用哈希表实现,表示该节点的所有子节点。$isEnd$ 则用于标记单词是否结束。这样,如果我们在插入单词时,直接根据单词中的字符创建对应的字符节点,并将其插入到对应的哈希表中。 +在上述代码中,$self.children$ 采用哈希表结构,用于存储该节点的所有子节点,$isEnd$ 用于标记该节点是否为某个单词的结尾。插入单词时,可以根据单词的每个字符,动态创建对应的字符节点,并将其加入哈希表,便于高效查找和插入。 -下面为了统一代码和编写方便,本文代码全部以哈希表的形式来表示当前节点的多个子节点。 +为统一实现和便于维护,本文后续所有代码均采用哈希表来管理节点的子节点。 #### 2.1.2 字典树的基本结构 -定义完了字典树的字符结构,下面我们定义下字典树的基本结构。在字典树的初始化操作时,定义一个根节点。并且这个根节点不用保存字符。在后续进行插入操作、查找操作都是从字典树的根节点开始的。字典树的基本结构代码如下。 +在明确了字典树节点的结构后,我们进一步定义字典树的整体结构。字典树在初始化时会创建一个根节点,该根节点不存储任何字符。所有的插入和查找操作均从根节点出发。下面是字典树的基本结构代码: ```python -class Trie: # 字典树 - - # 初始化字典树 - def __init__(self): # 初始化字典树 - self.root = Node() # 初始化根节点(根节点不保存字符) +class Trie: # 字典树(前缀树) + def __init__(self): + """ + 初始化字典树,创建一个根节点。 + 根节点不存储任何字符,仅作为所有单词的公共起点。 + """ + self.root = Node() # 初始化根节点(根节点不保存字符) ``` -### 2.2 字典树的创建和插入操作 +### 2.2 字典树的创建与插入操作 -字典树的创建指的是将字符串数组中的所有字符串都插入字典树中。而插入操作指的是将一个字符串插入字典树中。 +字典树的「创建」是指将字符串数组中的所有字符串依次插入到字典树中;而「插入」操作则是将单个字符串加入到字典树的过程。 #### 2.2.1 字典树的插入操作 -在讲解字典树的创建之前,我们先来看一下如何在字典树中插入一个单词。具体步骤如下: +在介绍字典树的批量创建前,先说明单个单词的插入流程: -- 依次遍历单词中的字符 $ch,ドル并从字典树的根节点的子节点位置开始进行插入操作(根节点不包含字符)。 -- 如果当前节点的子节点中,不存在键为 $ch$ 的节点,则建立一个节点,并将其保存到当前节点的子节点中,即 `cur.children[ch] = Node()`,然后令当前节点指向新建立的节点,然后继续处理下一个字符。 -- 如果当前节点的子节点中,存在键为 $ch$ 的节点,则直接令当前节点指向键为 $ch$ 的节点,继续处理下一个字符。 -- 在单词处理完成时,将当前节点标记为单词结束。 +- 从根节点出发,依次遍历单词的每个字符 $ch$(根节点本身不存储字符)。 +- 如果当前节点的子节点中不存在字符 $ch,ドル则新建一个节点 `cur.children[ch] = Node()`,并将当前指针移动到新节点。 +- 如果当前节点的子节点中已存在字符 $ch,ドル则直接将当前指针移动到该子节点。 +- 当所有字符遍历完毕后,将当前节点标记为单词结尾(即 `isEnd = True`)。 ```python # 向字典树中插入一个单词 def insert(self, word: str) -> None: - cur = self.root - for ch in word: # 遍历单词中的字符 - if ch not in cur.children: # 如果当前节点的子节点中,不存在键为 ch 的节点 - cur.children[ch] = Node() # 建立一个节点,并将其保存到当前节点的子节点 - cur = cur.children[ch] # 令当前节点指向新建立的节点,继续处理下一个字符 - cur.isEnd = True # 单词处理完成时,将当前节点标记为单词结束 + """ + 将一个单词插入到字典树中。 + + 参数: + word (str): 需要插入的单词 + """ + cur = self.root # 从根节点开始 + for ch in word: # 遍历单词中的每个字符 + # 如果当前节点的子节点中不存在字符 ch,则新建一个节点 + if ch not in cur.children: + cur.children[ch] = Node() # 创建新节点并加入子节点字典 + # 移动到下一个字符节点,继续插入 + cur = cur.children[ch] + # 单词所有字符插入完成后,将当前节点标记为单词结尾 + cur.isEnd = True ``` #### 2.2.2 字典树的创建操作 -字典树的创建比较简单,具体步骤如下: +字典树的创建过程比较简单,通常包括以下步骤: -- 首先初始化一个字典树,即 `trie = Trie()`。 -- 然后依次遍历字符串中的所有单词,将其一一插入到字典树中。 +- 先实例化一个字典树对象,如 `trie = Trie()`。 +- 遍历单词列表,将每个单词依次插入到字典树中。 ```python +# 创建一个字典树实例 trie = Trie() +# 遍历单词列表,将每个单词插入到字典树中 for word in words: trie.insert(word) ``` @@ -109,91 +122,130 @@ for word in words: #### 2.3.1 字典树的查找单词操作 -在字典树中查找某个单词是否存在,其实和字典树的插入操作差不多。具体操作如下: +在字典树中查找某个单词是否存在的过程与插入操作类似,具体步骤如下: -- 依次遍历单词中的字符,并从字典树的根节点位置开始进行查找操作。 -- 如果当前节点的子节点中,不存在键为 $ch$ 的节点,则说明不存在该单词,直接返回 $False$。 -- 如果当前节点的子节点中,存在键为 $ch$ 的节点,则令当前节点指向新建立的节点,然后继续查找下一个字符。 -- 在单词处理完成时,判断当前节点是否有单词结束标记,如果有,则说明字典树中存在该单词,返回 $True$。否则,则说明字典树中不存在该单词,返回 $False$。 +- 从根节点出发,依次遍历单词的每个字符 $ch$。 +- 如果当前节点的子节点中不存在字符 $ch,ドル则说明该单词不在字典树中,直接返回 $False$。 +- 如果存在字符 $ch,ドル则将当前指针移动到对应的子节点,继续查找下一个字符。 +- 当所有字符遍历完毕后,检查当前节点是否被标记为单词结尾(`isEnd = True`)。如果是,则说明字典树中存在该单词,返回 $True$;否则返回 $False$。 ```python # 查找字典树中是否存在一个单词 def search(self, word: str) -> bool: - cur = self.root - for ch in word: # 遍历单词中的字符 - if ch not in cur.children: # 如果当前节点的子节点中,不存在键为 ch 的节点 - return False # 直接返回 False - cur = cur.children[ch] # 令当前节点指向新建立的节点,然后继续查找下一个字符 - - return cur.isEnd # 判断是否有单词结束标记 + """ + 在字典树中查找指定单词是否存在。 + + 参数: + word (str): 需要查找的单词 + + 返回: + bool: 如果单词存在于字典树中,返回 True;否则返回 False + """ + cur = self.root # 从根节点开始 + for ch in word: # 遍历单词中的每个字符 + if ch not in cur.children: # 如果当前节点的子节点中不存在该字符 + return False # 说明单词不存在,直接返回 False + cur = cur.children[ch] # 移动到对应的子节点,继续查找下一个字符 + return cur.isEnd # 所有字符查找完毕,判断当前节点是否为单词结尾标记 ``` #### 2.3.2 字典树的查找前缀操作 -在字典树中查找某个前缀是否存在,和字典树的查找单词操作一样,不同点在于最后不需要判断是否有单词结束标记。 +在字典树中查找某个前缀是否存在,其过程与查找完整单词类似。不同之处在于,查找前缀时只需依次判断每个字符是否存在于相应的子节点中,无需判断最后节点是否为单词结尾标记。只要前缀的所有字符都能顺利匹配,即可认为该前缀存在于字典树中。 ```python # 查找字典树中是否存在一个前缀 def startsWith(self, prefix: str) -> bool: - cur = self.root - for ch in prefix: # 遍历前缀中的字符 - if ch not in cur.children: # 如果当前节点的子节点中,不存在键为 ch 的节点 - return False # 直接返回 False - cur = cur.children[ch] # 令当前节点指向新建立的节点,然后继续查找下一个字符 - return True # 查找成功 + """ + 在字典树中查找指定前缀是否存在。 + + 参数: + prefix (str): 需要查找的前缀字符串 + + 返回: + bool: 如果前缀存在于字典树中,返回 True;否则返回 False + """ + cur = self.root # 从根节点开始 + for ch in prefix: # 遍历前缀中的每个字符 + if ch not in cur.children: # 如果当前节点的子节点中不存在该字符 + return False # 说明前缀不存在,直接返回 False + cur = cur.children[ch] # 移动到对应的子节点,继续查找下一个字符 + return True # 所有字符查找完毕,前缀存在于字典树中 ``` ## 3. 字典树的实现代码 ```python -class Node: # 字符节点 - def __init__(self): # 初始化字符节点 - self.children = dict() # 初始化子节点 - self.isEnd = False # isEnd 用于标记单词结束 - - -class Trie: # 字典树 - - # 初始化字典树 - def __init__(self): # 初始化字典树 - self.root = Node() # 初始化根节点(根节点不保存字符) - - # 向字典树中插入一个单词 +class Node: # 字符节点(Trie 树的节点) + def __init__(self): + self.children = dict() # 子节点字典,key 为字符,value 为 Node 对象 + self.isEnd = False # 是否为单词结尾标记 + + +class Trie: # 字典树(Trie) + + def __init__(self): + """ + 初始化字典树,创建一个空的根节点(根节点不保存字符) + """ + self.root = Node() + def insert(self, word: str) -> None: - cur = self.root - for ch in word: # 遍历单词中的字符 - if ch not in cur.children: # 如果当前节点的子节点中,不存在键为 ch 的节点 - cur.children[ch] = Node() # 建立一个节点,并将其保存到当前节点的子节点 - cur = cur.children[ch] # 令当前节点指向新建立的节点,继续处理下一个字符 - cur.isEnd = True # 单词处理完成时,将当前节点标记为单词结束 - - # 查找字典树中是否存在一个单词 - def search(self, word: str) -> bool: - cur = self.root - for ch in word: # 遍历单词中的字符 - if ch not in cur.children: # 如果当前节点的子节点中,不存在键为 ch 的节点 - return False # 直接返回 False - cur = cur.children[ch] # 令当前节点指向新建立的节点,然后继续查找下一个字符 + """ + 向字典树中插入一个单词 + + 参数: + word (str): 要插入的单词 + """ + cur = self.root # 从根节点开始 + for ch in word: # 遍历单词中的每个字符 + if ch not in cur.children: # 如果当前节点没有ch这个子节点 + cur.children[ch] = Node() # 新建一个子节点 + cur = cur.children[ch] # 移动到子节点,继续处理下一个字符 + cur.isEnd = True # 单词插入完成,标记结尾 - return cur.isEnd # 判断是否有单词结束标记 + def search(self, word: str) -> bool: + """ + 查找字典树中是否存在一个完整单词 + + 参数: + word (str): 要查找的单词 + + 返回: + bool: 存在返回True,否则返回False + """ + cur = self.root # 从根节点开始 + for ch in word: # 遍历单词中的每个字符 + if ch not in cur.children: # 如果没有对应的子节点 + return False # 单词不存在 + cur = cur.children[ch] # 移动到子节点 + return cur.isEnd # 判断是否为单词结尾 - # 查找字典树中是否存在一个前缀 def startsWith(self, prefix: str) -> bool: - cur = self.root - for ch in prefix: # 遍历前缀中的字符 - if ch not in cur.children: # 如果当前节点的子节点中,不存在键为 ch 的节点 - return False # 直接返回 False - cur = cur.children[ch] # 令当前节点指向新建立的节点,然后继续查找下一个字符 - return True # 查找成功 + """ + 查找字典树中是否存在某个前缀 + + 参数: + prefix (str): 要查找的前缀 + + 返回: + bool: 存在返回True,否则返回False + """ + cur = self.root # 从根节点开始 + for ch in prefix: # 遍历前缀中的每个字符 + if ch not in cur.children: # 如果没有对应的子节点 + return False # 前缀不存在 + cur = cur.children[ch] # 移动到子节点 + return True # 前缀存在 ``` ## 4. 字典树的算法分析 -假设单词的长度为 $n,ドル前缀的长度为 $m,ドル字符集合的维度为 $d,ドル则: - -- **插入一个单词**:时间复杂度为 $O(n)$;如果使用数组,则空间复杂度为 $O(d^n),ドル如果使用哈希表实现,则空间复杂度为 $O(n)$。 -- **查找一个单词**:时间复杂度为 $O(n)$;空间复杂度为 $O(1)$。 -- **查找一个前缀**:时间复杂度为 $O(m)$;空间复杂度为 $O(1)$。 +| 指标 | 复杂度 | 说明 | +|------------------|-------------------------------|--------------------------------------------------------------| +| 插入一个单词 | 时间:$O(n)$
空间:$O(d^n)$(数组实现)
空间:$O(n)$(哈希表实现) | $n$ 为单词长度,$d$ 为字符集大小。数组实现空间消耗大,哈希表实现更节省空间。 | +| 查找一个单词 | 时间:$O(n)$
空间:$O(1)$ | $n$ 为单词长度,仅遍历单词长度,空间为常数。 | +| 查找一个前缀 | 时间:$O(m)$
空间:$O(1)$ | $m$ 为前缀长度,仅遍历前缀长度,空间为常数。 | ## 5. 字典树的应用 @@ -212,6 +264,22 @@ class Trie: # 字典树 - **最长公共前缀问题**:利用字典树求解多个字符串的最长公共前缀问题。将大量字符串都存储到一棵字典树上时, 可以快速得到某些字符串的公共前缀。对所有字符串都建立字典树,两个串的最长公共前缀的长度就是它们所在节点最近公共祖先的长度,于是转变为最近公共祖先问题。 - **字符串排序**:利用字典树进行串排序。例如,给定多个互不相同的仅由一个单词构成的英文名,将它们按字典序从小到大输出。采用数组方式创建字典树,字典树中每个节点的所有子节点都是按照其字母大小排序的。然后对字典树进行先序遍历,输出的相应字符串就是按字典序排序的结果。 +## 6. 总结 + +字典树(Trie)是一种高效存储和查找字符串集合的树形数据结构,通过利用字符串的公共前缀来减少重复比较,实现快速的前缀匹配和字符串检索。 + +**优点:** +- **查找效率高**:查找单词和前缀的时间复杂度均为 $O(n),ドル其中 $n$ 为字符串长度,比暴力匹配快很多 +- **前缀匹配优秀**:能够快速判断一个字符串是否为另一个字符串的前缀,这在搜索引擎自动补全等场景中非常有用 +- **空间共享**:具有相同前缀的单词共享路径,相比单独存储每个单词,能节省大量空间 +- **支持动态操作**:可以动态插入、删除字符串,适合需要频繁更新的字符串集合 + +**缺点:** +- **空间消耗较大**:每个节点都需要存储子节点信息,对于稀疏的字符串集合,空间利用率不高 +- **实现复杂度**:相比简单的哈希表或数组,字典树的实现和维护更加复杂 +- **字符集限制**:使用数组实现时,字符集大小会影响空间复杂度,大字符集会显著增加内存消耗 +- **缓存不友好**:树形结构在内存中的分布可能不够连续,对 CPU 缓存不够友好 + ## 练习题目 - [0208. 实现 Trie (前缀树)](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/0200-0299/implement-trie-prefix-tree.md) diff --git a/docs/05_tree/05_01_tree_basic.md b/docs/05_tree/05_01_tree_basic.md index 25235fc8..a32439ed 100644 --- a/docs/05_tree/05_01_tree_basic.md +++ b/docs/05_tree/05_01_tree_basic.md @@ -1,200 +1,235 @@ -## 1. 树简介 +## 1. 树 ### 1.1 树的定义 -> **树(Tree)**:由 $n \ge 0$ 个节点与节点之间的关系组成的有限集合。当 $n = 0$ 时称为空树,当 $n> 0$ 时称为非空树。 +> **树(Tree)**:由 $n \ge 0$ 个节点及其相互之间的关系组成的有限集合。当 $n = 0$ 时称为空树,$n> 0$ 时称为非空树。 -之所以把这种数据结构称为「树」是因为这种数据结构看起来就像是一棵倒挂的树,也就是说数据结构中的「树」是根朝上,而叶朝下的。如下图所示。 +之所以称为「树」,是因为这种数据结构的形态类似于一棵倒挂的树:根节点在上,叶子节点在下。如下图所示: ![树](https://qcdn.itcharge.cn/images/20240511171215.png) -「树」具有以下的特点: +树结构具备以下基本特性: -- 有且仅有一个节点没有前驱节点,该节点被称为树的 **「根节点(Root)」** 。 -- 除了根节点以外,每个节点有且仅有一个直接前驱节点。 -- 包括根节点在内,每个节点可以有多个后继节点。 -- 当 $n> 1$ 时,除了根节点之外的其他节点,可分为 $m(m> 0)$ 个互不相交的有限集合 $T_1, T_2, ..., T_m,ドル其中每一个集合本身又是一棵树,并且被称为根的 **「子树(SubTree)」**。 +- 仅有一个没有前驱的节点,称为 **根节点(Root)**。 +- 除根节点外,其余每个节点都且仅有一个直接前驱节点。 +- 每个节点(包括根节点)可以有零个或多个后继节点。 +- 当 $n> 1$ 时,除根节点外的其余节点可分为 $m\ (m> 0)$ 个互不相交的有限集合 $T_1, T_2, ..., T_m,ドル每个集合本身又是一棵树,称为根的 **子树(SubTree)**。 -如下图所示,红色节点 $A$ 是根节点,除了根节点之外,还有 3ドル$ 棵互不相交的子树 $T_1(B, E, H, I, G)$、$T_2(C)$、$T_3(D, F, G, K)$。 +如下图所示,红色节点 $A$ 是根节点,除根节点外,存在 3ドル$ 棵互不相交的子树:$T_1(B, E, H, I, G)$、$T_2(C)$、$T_3(D, F, G, K)$。 ![树与子树](https://qcdn.itcharge.cn/images/20240511171233.png) ### 1.2 树的相关术语 -下面我们来介绍一下树结构中的一些基本术语。 +下面介绍树结构中的常用基本术语。 #### 1.2.1 节点分类 -**「树的节点」** 由一个数据元素和若干个指向其子树的树的分支组成。而节点所含有的子树个数称为 **「节点的度」**。度为 0ドル$ 的节点称为 **「叶子节点」** 或者 **「终端节点」**,度不为 0ドル$ 的节点称为 **「分支节点」** 或者 **「非终端节点」**。树中各节点的最大度数称为 **「树的度」**。 +- **树的节点**:由一个数据元素和若干指向其子树的分支组成。节点拥有的子树数量称为 **节点的度**。 +- **叶子节点(终端节点)**:度为 0ドル$ 的节点。例如图中 $C$、$H$、$I$、$G$、$F$、$K$。 +- **分支节点(非终端节点)**:度大于 0ドル$ 的节点。例如图中 $A$、$B$、$D$、$E$、$G$。 +- **树的度**:树中所有节点的最大度数。例如图中树的度为 3ドル$。 ![节点分类](https://qcdn.itcharge.cn/images/20240511171300.png) -- **树的节点**:由一个数据元素和若干个指向其子树的树的分支组成。 -- **节点的度**:一个节点所含有的子树个数。 -- **叶子节点(终端节点)**:度为 0ドル$ 的节点。例如图中叶子节点为 $C$、$H$、$I$、$G$、$F$、$K$。 -- **分支节点(非终端节点)**:度不为 0ドル$ 的节点。例如图中分支节点为 $A$、$B$、$D$、$E$、$G$。 -- **树的度**:树中节点的最大度数。例如图中树的度为 3ドル$。 - #### 1.2.2 节点间关系 -一个节点的子树的根节点称为该节点的 **「孩子节点」**,相应的,该节点称为孩子的 **「父亲节点」**。同一个父亲节点的孩子节点之间互称为 **「兄弟节点」**。 +- **孩子节点(子节点)**:某节点的子树的根节点。例如图中 $B$ 是 $A$ 的孩子节点。 +- **父节点**:拥有子节点的节点称为其子节点的父节点。例如图中 $B$ 是 $E$ 的父节点。 +- **兄弟节点**:同一父节点的不同子节点互为兄弟。例如图中 $F$、$G$ 互为兄弟节点。 ![节点间关系](https://qcdn.itcharge.cn/images/20240511171311.png) -- **孩子节点(子节点)**:一个节点含有的子树的根节点称为该节点的子节点。例如图中 $B$ 是 $A$ 的孩子节点。 -- **父亲节点(父节点)**:如果一个节点含有子节点,则这个节点称为其子节点的父节点。例如图中 $B$ 是 $E$ 的父亲节点。 -- **兄弟节点**:具有相同父节点的节点互称为兄弟节点。例如图中 $F$、$G$ 互为兄弟节点。 - -#### 1.2.3 树的其他术语 +#### 1.2.3 其他常用术语 -**「节点的层次」** 是从根节点开始定义,将根节点作为第 1 层,根的孩子节点作为第 2 层,以此类推,如果某个节点在第 $i$ 层,则其孩子节点在第 $i + 1$ 层。而父亲节点在同一层的节点互为 **「堂兄弟节点」**。树中所有节点最大的层数称为 **「树的深度」** 或 **「树的高度」**。树中,两个节点之间所经过节点序列称为 **「路径」**,两个节点之间路径上经过的边数称为 **「路径长度」**。 +- **节点的层次**:从根节点开始,根为第 1ドル$ 层,根的子节点为第 2ドル$ 层,依此类推。 +- **树的深度(高度)**:树中节点的最大层数。例如图中树的深度为 4ドル$。 +- **堂兄弟节点**:父节点在同一层的节点互为堂兄弟。例如图中 $J$、$K$ 互为堂兄弟节点。 +- **路径**:树中两个节点之间经过的节点序列。例如 $E$ 到 $G$ 的路径为 $E - B - A - D - G$。 +- **路径长度**:路径上经过的边数。例如 $E$ 到 $G$ 的路径长度为 4ドル$。 +- **节点的祖先**:从该节点到根节点路径上所有节点。例如 $H$ 的祖先为 $E$、$B$、$A$。 +- **节点的子孙**:以该节点为根的子树中所有节点。例如 $D$ 的子孙为 $F$、$G$、$K$。 ![树的其他术语](https://qcdn.itcharge.cn/images/20240511171325.png) -- **节点的层次**:从根节点开始定义,根为第 1ドル$ 层,根的子节点为第 2ドル$ 层,以此类推。 -- **树的深度(高度)**:所有节点中最大的层数。例如图中树的深度为 4ドル$。 -- **堂兄弟节点**:父节点在同一层的节点互为堂兄弟。例如图中 $J$、$K$ 互为堂兄弟节点。 -- **路径**:树中两个节点之间所经过的节点序列。例如图中 $E$ 到 $G$ 的路径为 $E - B - A - D - G$。 -- **路径长度**:两个节点之间路径上经过的边数。例如图中 $E$ 到 $G$ 的路径长度为 4ドル$。 -- **节点的祖先**:从该节点到根节点所经过的所有节点,被称为该节点的祖先。例如图中 $H$ 的祖先为 $E$、$B$、$A$。 -- **节点的子孙**:节点的子树中所有节点被称为该节点的子孙。例如图中 $D$ 的子孙为 $F$、$G$、$K$。 - ### 1.3 树的分类 -根据节点的子树是否可以互换位置,我们可以将树分为两种类型:**「有序树」** 和 **「无序树」**。 +树按照节点子树之间是否可以交换位置,可以分为两大类:**有序树** 和 **无序树**。 -如果将树中节点的各个子树看做是从左到右是依次有序的(即不能互换),则称该树为 **「有序树」**。反之,如果节点的各个子树可以互换位置,则成该树为 **「无序树」**。 +- **有序树**:每个节点的子树有严格的左右次序,子树之间的位置不可随意交换。例如二叉树就是典型的有序树。 +- **无序树**:每个节点的子树之间没有顺序要求,子树可以任意交换位置。 -- **有序树**:节点的各个子树从左至右有序, 不能互换位置。 -- **无序树**:节点的各个子树可互换位置。 +简而言之,有序树强调子树的排列顺序,结构唯一;无序树则只关注连接关系,不关心子树的排列顺序。 -## 2. 二叉树简介 +## 2. 二叉树 ### 2.1 二叉树的定义 -> **二叉树(Binary Tree)**:树中各个节点的度不大于 2ドル$ 个的有序树,称为二叉树。通常树中的分支节点被称为 **「左子树」** 或 **「右子树」**。二叉树的分支具有左右次序,不能随意互换位置。 +> **二叉树(Binary Tree)**:是一种有序树,其中每个节点的度最多为 2ドル$。每个节点的两个分支分别称为 **左子树** 和 **右子树**,且左右子树的顺序不可交换。 -下图就是一棵二叉树。 +如下图所示是一棵典型的二叉树: ![二叉树](https://qcdn.itcharge.cn/images/20240511171342.png) -二叉树也可以使用递归方式来定义,即二叉树满足以下两个要求之一: +二叉树还可以递归地定义为: -- **空树**:二叉树是一棵空树。 -- **非空树**:二叉树是由一个根节点和两棵互不相交的子树 $T_1$、$T_2,ドル分别称为根节点的左子树、右子树组成的非空树;并且 $T_1$、$T_2$ 本身都是二叉树。 +- **空树**:即不包含任何节点的树。 +- **非空树**:由一个根节点和两棵互不相交的子树 $T_1$、$T_2$ 组成,$T_1$ 称为左子树,$T_2$ 称为右子树,且 $T_1$、$T_2$ 本身也都是二叉树。 -二叉树是种特殊的树,它最多有两个子树,分别为左子树和右子树,并且两个子树是有序的,不可以互换。也就是说,在二叉树中不存在度大于 2ドル$ 的节点。 +简而言之,二叉树是一种每个节点最多有两个子树(左子树和右子树)的有序树,且左右子树的位置不可交换。换句话说,二叉树中每个节点的度都不超过 2。 -二叉树在逻辑上可以分为 5ドル$ 种基本形态,如下图所示。 +二叉树在结构上可以分为以下 5ドル$ 种基本形态,如下图所示: ![二叉树的形态](https://qcdn.itcharge.cn/images/20220218164839.png) -### 2.2 特殊的二叉树 +### 2.2 二叉树的基本性质 + +二叉树作为最常用的树形结构之一,具有以下基本性质: + +1. **第 $i$ 层的最大节点数**:在二叉树中,第 $i$ 层最多有 2ドル^{i-1}$ 个节点($i \geq 1$)。 +2. **深度为 $k$ 的二叉树的最大节点数**:2ドル^k - 1$。 +3. **任意一棵非空二叉树的第 $k$ 层至多有 2ドル^{k-1}$ 个节点**。 +4. **节点数为 $n$ 的二叉树的最小深度**:$\lceil \log_2(n+1) \rceil$。 +5. **叶子节点数与度为 2ドル$ 的节点数关系**:二叉树中叶子节点数 $n_0$ 恰好比度为 2ドル$ 的节点数 $n_2$ 多 1ドル,ドル即 $n_0 = n_2 + 1$。 +6. **二叉树的边数**:$n - 1,ドル其中 $n$ 为节点数。 + +这些性质对于分析二叉树的结构、空间复杂度和算法效率具有重要意义。 + +### 2.3 特殊的二叉树 -下面我们来介绍一些特殊的二叉树。 +下面介绍几类常见的特殊二叉树。 -#### 2.2.1 满二叉树 +#### 2.3.1 满二叉树 -> **满二叉树(Full Binary Tree)**:如果所有分支节点都存在左子树和右子树,并且所有叶子节点都在同一层上,则称该二叉树为满二叉树。 +> **满二叉树(Full Binary Tree)**:指所有非叶子节点均有左右两个子节点,且所有叶子节点都集中在同一层的二叉树。 -满二叉树满足以下特点: +满二叉树具有以下特征: -- 叶子节点只出现在最下面一层。 -- 非叶子节点的度一定为 2ドル$。 -- 在同等深度的二叉树中,满二叉树的节点个数最多,叶子节点个数最多。 +- 叶子节点全部位于最底层。 +- 所有非叶子节点的度均为 2ドル$。 +- 在相同深度的二叉树中,满二叉树的节点数和叶子节点数均为最大。 -如果我们对满二叉树的节点进行编号,根节点编号为 1ドル,ドル然后按照层次依次向下,每一层从左至右的顺序进行编号。则深度为 $k$ 的满二叉树最后一个节点的编号为 2ドル^k - 1$。 +如果对满二叉树的节点自上而下、从左到右依次编号,根节点编号为 1ドル,ドル则深度为 $k$ 的满二叉树最后一个节点的编号为 2ドル^k - 1$。 -我们可以来看几个例子。 +如下图所示,展示了满二叉树与非满二叉树的示例: ![满二叉树与非满二叉树](https://qcdn.itcharge.cn/images/20220218173007.png) -#### 2.2.2 完全二叉树 +#### 2.3.2 完全二叉树 -> **完全二叉树(Complete Binary Tree)**:如果叶子节点只能出现在最下面两层,并且最下层的叶子节点都依次排列在该层最左边的位置上,具有这种特点的二叉树称为完全二叉树。 +> **完全二叉树(Complete Binary Tree)**:一种特殊的二叉树,要求除了最后一层外,每一层的节点数都达到最大,且最后一层的所有节点都连续排列在最左侧。 -完全二叉树满足以下特点: +完全二叉树的主要特征如下: -- 叶子节点只能出现在最下面两层。 -- 最下层的叶子节点一定集中在该层最左边的位置上。 -- 倒数第二层如果有叶子节点,则该层的叶子节点一定集中在右边的位置上。 -- 如果节点的度为 1ドル,ドル则该节点只有左孩子节点,即不存在只有右孩子节点的情况。 -- 同等节点数的二叉树中,完全二叉树的深度最小。 +- 叶子节点只可能出现在最后两层。 +- 最底层的叶子节点必须依次排列在最左侧。 +- 倒数第二层如果有叶子节点,则这些节点必须集中在右侧。 +- 如果某节点的度为 1ドル,ドル则该节点只能有左孩子,不存在只有右孩子的情况。 +- 在节点数相同的二叉树中,完全二叉树的深度最小。 -完全二叉树也可以使用类似满二叉树的节点编号的方式来定义。即从根节点编号为 1ドル$ 开始,按照层次从上至下,每一层从左至右进行编号。对于深度为 $i$ 且有 $n$ 个节点的二叉树,当且仅当每一个节点都与深度为 $k$ 的满二叉树中编号从 1ドル$ 至 $n$ 的节点意义对应时,该二叉树为完全二叉树。 +完全二叉树也可以通过节点编号来定义:从根节点开始,按层次自上而下、从左到右依次编号(根为 1ドル$)。若一棵深度为 $k$、节点数为 $n$ 的二叉树,其每个节点与深度为 $k$ 的满二叉树中编号 1ドル$ 到 $n$ 的节点一一对应,则该树为完全二叉树。 -我们可以来看几个例子。 +下面通过示例图进行说明: ![完全二叉树与非完全二叉树](https://qcdn.itcharge.cn/images/20220218174000.png) -#### 2.2.3 二叉搜索树 +#### 2.3.3 二叉搜索树 -> **二叉搜索树(Binary Search Tree)**:也叫做二叉查找树、有序二叉树或者排序二叉树。是指一棵空树或者具有下列性质的二叉树: +> **二叉搜索树(Binary Search Tree, BST)**,又称二叉查找树、有序二叉树或排序二叉树,是一种特殊的二叉树结构。其定义如下: > -> - 如果任意节点的左子树不为空,则左子树上所有节点的值均小于它的根节点的值。 -> - 如果任意节点的右子树不为空,则右子树上所有节点的值均大于它的根节点的值。 -> - 任意节点的左子树、右子树均为二叉搜索树。 +> - 对于任意一个节点,如果其左子树非空,则左子树所有节点的值均小于该节点的值; +> - 对于任意一个节点,如果其右子树非空,则右子树所有节点的值均大于该节点的值; +> - 左右子树本身也都是二叉搜索树; +> - 空树也被视为二叉搜索树。 -如图所示,这 3ドル$ 棵树都是二叉搜索树。 +二叉搜索树的结构保证了对任意节点的中序遍历结果是递增有序的。下图展示了三棵典型的二叉搜索树: ![二叉搜索树](https://qcdn.itcharge.cn/images/20240511171406.png) -#### 2.2.4 平衡二叉搜索树 +#### 2.3.4 平衡二叉搜索树 -> **平衡二叉搜索树(Balanced Binary Tree)**:一种结构平衡的二叉搜索树。即叶节点高度差的绝对值不超过 1ドル,ドル并且左右两个子树都是一棵平衡二叉搜索树。平衡二叉树可以在 $O(logn)$ 内完成插入、查找和删除操作。最早被发明的平衡二叉搜索树为 **「AVL 树(Adelson-Velsky and Landis Tree))」**。 -> -> AVL 树满足以下性质: -> -> - 空二叉树是一棵 AVL 树。 -> - 如果 T 是一棵 AVL 树,那么其左右子树也是 AVL 树,并且 $|h(ls) - h(rs)| \le 1,ドル$h(ls)$ 是左子树的高度,$h(rs)$ 是右子树的高度。 -> - AVL 树的高度为 $O(log n)$。 +> **平衡二叉搜索树(Balanced Binary Search Tree, BBST)**:是一类结构上保持平衡的二叉搜索树。其核心特性是任意节点的左右子树高度差的绝对值不超过 1ドル,ドル且左右子树本身也都是平衡二叉搜索树。通过这种结构,平衡二叉搜索树能够保证插入、查找和删除操作的时间复杂度均为 $O(\log n)$。最早提出的平衡二叉搜索树是 **AVL 树(Adelson-Velsky and Landis Tree)**。 + +AVL 树的主要性质如下: + +- 空树是一棵 AVL 树。 +- 如果二叉树 $T$ 是 AVL 树,则 $T$ 的左右子树也都是 AVL 树,且满足 $|h(\text{left}) - h(\text{right})| \le 1,ドル其中 $h(\text{left})$ 和 $h(\text{right})$ 分别为左、右子树的高度。 +- AVL 树的高度始终保持在 $O(\log n)$。 -如图所示,前 2ドル$ 棵树是平衡二叉搜索树,最后一棵树不是平衡二叉搜索树,因为这棵树的左右子树的高度差的绝对值超过了 1ドル$。 +下图中,前两棵树为平衡二叉搜索树,最后一棵树不是平衡二叉搜索树,因为其某节点的左右子树高度差超过 1ドル$。 ![平衡二叉树与非平衡二叉树](https://qcdn.itcharge.cn/images/20220221103552.png) -### 2.3 二叉树的存储结构 +### 2.4 二叉树的存储结构 -二叉树的存储结构分为两种:「顺序存储结构」和「链式存储结构」,下面进行一一讲解。 +二叉树常见的存储方式有两种:**「顺序存储结构」**和**「链式存储结构」**。下面分别介绍这两种方式。 -#### 2.3.1 二叉树的顺序存储结构 +#### 2.4.1 二叉树的顺序存储结构 -其实,堆排序、优先队列中的二叉堆结构,采用的就是二叉树的顺序存储结构。 +顺序存储结构通常使用一维数组来保存二叉树的所有节点。节点在数组中的位置按照完全二叉树的层序编号排列:自上而下、从左到右依次存放。如果某个节点不存在,则在数组对应位置填充「空节点」。 -二叉树的顺序存储结构使用一维数组来存储二叉树中的节点,节点存储位置则采用完全二叉树的节点层次编号,按照层次从上至下,每一层从左至右的顺序依次存放二叉树的数据元素。在进行顺序存储时,如果对应的二叉树节点不存在,则设置为「空节点」。 +例如,堆排序和优先队列中的二叉堆结构就是采用顺序存储结构实现的。 + +以节点值为 $[1, 2, 3, 4, 5, 6, 7]$ 的完全二叉树为例,其顺序存储结构如下图所示。 -下图为二叉树的顺序存储结构。 ![二叉树的顺序存储结构](https://qcdn.itcharge.cn/images/20240511171423.png) -从图中我们也可以看出节点之间的逻辑关系。 +通过顺序存储结构,节点之间的关系可以通过下标直接计算得到: -- 如果某二叉树节点(非叶子节点)的下标为 $i,ドル那么其左孩子节点下标为 2ドル * i + 1,ドル右孩子节点下标为 2ドル * i + 2$。 -- 如果某二叉树节点(非根节点)的下标为 $i,ドル那么其根节点下标为 $(i - 1) // 2$。$//$ 表示整除。 +- 如果某节点(非叶子节点)下标为 $i,ドル则其左孩子下标为 2ドル \times i + 1,ドル右孩子下标为 2ドル \times i + 2$。 +- 如果某节点(非根节点)下标为 $i,ドル则其父节点下标为 $(i - 1) // 2$($//$ 表示整除)。 -对于完全二叉树(尤其是满二叉树)来说,采用顺序存储结构比较合适,它能充分利用存储空间;而对于一般二叉树,如果需要设置很多的「空节点」,则采用顺序存储结构就会浪费很多存储空间。并且,由于顺序存储结构固有的一些缺陷,会使得二叉树的插入、删除等操作不方便,效率也比较低。对于二叉树来说,当树的形态和大小经常发生动态变化时,更适合采用链式存储结构。 +顺序存储结构非常适合 **完全二叉树**(尤其是满二叉树),因为可以充分利用数组空间,节点排列紧凑。但对于一般二叉树,若存在大量空节点,则会造成空间浪费。此外,顺序存储结构不利于二叉树的插入和删除操作,灵活性较差。当二叉树结构和规模经常变化时,更推荐使用链式存储结构。 -#### 2.3.2 二叉树的链式存储结构 +#### 2.4.2 二叉树的链式存储结构 -二叉树采用链式存储结构时,每个链节点包含一个用于数据域 $val,ドル存储节点信息;还包含两个指针域 $left$ 和 $right,ドル分别指向左右两个孩子节点,当左孩子或者右孩子不存在时,相应指针域值为空。二叉链节点结构如下图所示。 +在链式存储结构中,二叉树的每个节点通常包含三个部分:一个数据域 $val$ 用于存放节点的值,两个指针域 $left$ 和 $right$ 分别指向左、右子节点。当某个子节点不存在时,相应的指针为 None(或 null)。这种结构如下图所示: ![二叉链节点](https://qcdn.itcharge.cn/images/20240511171434.png) -二叉链节点结构的对应代码为: +其对应的代码实现如下: ```python class TreeNode: + """ + 二叉树节点定义(链式存储结构) + + 属性: + val: 节点存储的值 + left: 指向左子节点的指针(无左子节点时为 None) + right: 指向右子节点的指针(无右子节点时为 None) + """ def __init__(self, val=0, left=None, right=None): - self.val = val - self.left = left - self.right = right + self.val = val # 节点的值 + self.left = left # 左子节点指针 + self.right = right # 右子节点指针 ``` -下面我们将值为 $[1, 2, 3, 4, 5, 6, 7]$ 的二叉树使用链式存储结构进行存储,即为下图所示。 +以节点值为 $[1, 2, 3, 4, 5, 6, 7]$ 的完全二叉树为例,其链式存储结构如下图所示。 ![二叉树的链式存储结构](https://qcdn.itcharge.cn/images/20240511171446.png) -二叉树的链表存储结构具有灵活、方便的特点。节点的最大数目只受系统最大可存储空间的限制。一般情况下,二叉树的链表存储结构比顺序存储结构更省空间(用于存储指针域的空间开销只是二叉树中节点数的线性函数),而且对于二叉树实施相关操作也很方便,因此,一般我们使用链式存储结构来存储二叉树。 +链式存储结构具有高度的灵活性和便利性。其节点数量仅受限于系统可用内存,且通常比顺序存储结构更节省空间(指针域的空间开销与节点数成线性关系)。此外,链式结构便于进行插入、删除等操作,因此在实际应用中,二叉树大多采用链式存储结构。 + +## 3. 总结 + +树是一种层次化的非线性数据结构,由节点和边组成,具有一个根节点和若干子树。二叉树是树的一种特殊形式,每个节点最多有两个子节点(左子树和右子树)。 + +**二叉树的核心特性:** +- **层次性**:二叉树以根节点为起点,节点自上而下、由左至右分层排列,形成清晰的层级结构。 +- **递归性**:每个节点的左右子树本身也是二叉树,天然适合递归定义与处理。 +- **有序性**:每个节点的左、右子树位置固定,不能随意交换,保证结构的唯一性和有序性。 + +**二叉树常见类型:** +- **满二叉树**:除叶子节点外,每个节点都有两个子节点 +- **完全二叉树**:除最后一层外,其他层都被填满,最后一层从左到右填充 +- **二叉搜索树**:左子树所有节点值小于根节点,右子树所有节点值大于根节点 +- **平衡二叉树**:左右子树高度差不超过1,保证操作效率 + +**二叉树存储方式:** +- **顺序存储**:用数组存储,适合完全二叉树,空间利用率高 +- **链式存储**:用指针连接,灵活性好,便于动态操作 ## 参考链接 diff --git a/docs/05_tree/05_02_binary_tree_traverse.md b/docs/05_tree/05_02_binary_tree_traverse.md index b22704a2..fff972a8 100644 --- a/docs/05_tree/05_02_binary_tree_traverse.md +++ b/docs/05_tree/05_02_binary_tree_traverse.md @@ -1,312 +1,448 @@ ## 1. 二叉树的遍历简介 -> **二叉树的遍历**:指的是从根节点出发,按照某种次序依次访问二叉树中所有节点,使得每个节点被访问一次且仅被访问一次。 +> **二叉树的遍历**:是指从根节点出发,按照特定顺序依次访问二叉树中的所有节点,确保每个节点被且仅被访问一次。 -在二叉树的一些实际问题中,经常需要按照一定顺序对二叉树中每个节点逐个进行访问一次,用以查找具有某一特点的节点或者全部节点,然后对这些满足要求的节点进行处理。这里所说的「访问」就是指对该节点进行某种操作,例如:依次输出节点的数据信息、统计满足某条件的节点总数等等。 +在实际应用中,常常需要按照一定的顺序访问二叉树的每个节点,以便查找特定节点或处理全部节点。例如,可以依次输出节点的值、统计满足某条件的节点数量等。这里的「访问」通常指对节点执行某种操作。 -回顾二叉树的递归定义可以知道,二叉树是由根节点和左子树、右子树构成的。因此,如果能依次遍历这 3ドル$ 个部分,就可以遍历整个二叉树。 +根据二叉树的递归结构是由根节点、左子树和右子树组成的,只要依次遍历这三部分,就能遍历整棵二叉树。 -如果利用深度优先搜索的方式,并且根据访问顺序次序的不同,我们可以分为 6ドル$ 种遍历方式,而如果限制先左子树后右子树的遍历顺序,则总共有 3ドル$ 种遍历方式:分别为 **「二叉树的前序遍历」**、**「二叉树的中序遍历」** 和 **「二叉树的后续遍历」**。 +按照遍历顺序的不同,二叉树的遍历方式主要分为两大类: -而如果使用广度优先搜索的方式,则可以按照层序方式(按照层次从上至下,每一层从左至右)对二叉树进行遍历,这种方式叫做 **「二叉树的层序遍历」**。 +- **深度优先遍历(DFS)**:根据节点访问顺序的不同,理论上有 6ドル$ 种遍历方式。若约定先遍历左子树再遍历右子树,常用的有 3ドル$ 种:**前序遍历**、**中序遍历**、**后序遍历**。 +- **广度优先遍历(BFS)**:按照层次自上而下、每层从左到右依次访问所有节点,称为 **层序遍历**。 -## 2. 二叉树的前序遍历 +这些遍历方式为二叉树的各种操作和算法奠定了基础。 -> 二叉树的前序遍历规则为: -> -> - 如果二叉树为空,则返回。 -> - 如果二叉树不为空,则: -> 1. 访问根节点。 -> 2. 以前序遍历的方式遍历根节点的左子树。 -> 3. 以前序遍历的方式遍历根节点的右子树。 +## 2. 二叉树前序遍历 + +> **二叉树前序遍历(Preorder Traversal)**:是指按照「根节点 → 左子树 → 右子树」的顺序依次访问二叉树的所有节点。 + +具体规则如下: -从二叉树的前序遍历规则可以看出:前序遍历过程是一个递归过程。在遍历任何一棵子树时仍然是按照先访问根节点,然后遍历子树根节点的左子树,最后再遍历子树根节点的右子树的顺序进行遍历。 +- 如果二叉树为空,直接返回; +- 如果二叉树非空,则: +> 1. 访问根节点; +> 2. 递归前序遍历左子树; +> 3. 递归前序遍历右子树。 -如下图所示,该二叉树的前序遍历顺序为:$A - B - D - H - I - E - C - F - J - G - K$。 +前序遍历本质上是一个递归过程。无论遍历哪一棵子树,始终遵循「先访问根节点,再遍历左子树,最后遍历右子树」的顺序。 + +如下图所示,该二叉树的前序遍历结果为:$A - B - D - H - I - E - C - F - J - G - K$。 ![二叉树的前序遍历](https://qcdn.itcharge.cn/images/20240511171628.png) -### 2.1 二叉树的前序遍历递归实现 +### 2.1 二叉树前序遍历的递归实现 -二叉树的前序遍历递归实现步骤为: +二叉树前序遍历递归实现的基本步骤: -1. 判断二叉树是否为空,为空则直接返回。 -2. 先访问根节点。 -3. 然后递归遍历左子树。 -4. 最后递归遍历右子树。 +1. 如果当前节点为空,直接返回; +2. 访问当前节点(根节点); +3. 递归遍历左子树; +4. 递归遍历右子树。 -二叉树的前序遍历递归实现代码如下: +前序遍历的递归实现代码如下: ```python class Solution: def preorderTraversal(self, root: TreeNode) -> List[int]: - res = [] - - def preorder(root): - if not root: - return - res.append(root.val) - preorder(root.left) - preorder(root.right) - - preorder(root) + """ + 二叉树的前序遍历(递归实现) + 参数: + root: TreeNode,二叉树的根节点 + 返回: + List[int],前序遍历的节点值列表 + """ + res = [] # 用于存储遍历结果 + + def preorder(node): + if not node: + return # 递归终止条件:节点为空 + res.append(node.val) # 1. 访问根节点 + preorder(node.left) # 2. 递归遍历左子树 + preorder(node.right) # 3. 递归遍历右子树 + + preorder(root) # 从根节点开始递归 return res ``` -### 2.2 二叉树的前序遍历显式栈实现 +### 2.2 二叉树前序遍历的非递归实现 -二叉树的前序遍历递归实现的过程,实际上就是调用系统栈的过程。我们也可以使用一个显式栈 $stack$ 来模拟递归的过程。 +递归实现前序遍历时,实际上是借助系统调用栈来完成的。我们同样可以用一个显式栈 $stack$ 来手动模拟递归过程,实现前序遍历。 -前序遍历的顺序为:根节点 - 左子树 - 右子树,而根据栈的「先入后出」特点,所以入栈的顺序应该为:先放入右子树,再放入左子树。这样可以保证最终遍历顺序为前序遍历顺序。 +前序遍历的访问顺序为:根节点 → 左子树 → 右子树。由于栈具有「后进先出」的特性,为了保证遍历顺序正确,入栈时应先将右子节点压入,再将左子节点压入,这样弹出时会先访问左子树,再访问右子树。 -二叉树的前序遍历显式栈实现步骤如下: +具体实现步骤如下: -1. 判断二叉树是否为空,为空则直接返回。 -2. 初始化维护一个栈,将根节点入栈。 -3. 当栈不为空时: - 1. 弹出栈顶元素 $node,ドル并访问该元素。 - 2. 如果 $node$ 的右子树不为空,则将 $node$ 的右子树入栈。 - 3. 如果 $node$ 的左子树不为空,则将 $node$ 的左子树入栈。 +1. 如果二叉树为空,直接返回。 +2. 初始化一个栈,将根节点压入栈中。 +3. 当栈不为空时,重复以下操作: + 1. 弹出栈顶节点 $node,ドル访问该节点。 + 2. 如果 $node$ 的右子节点存在,则将其压入栈中。 + 3. 如果 $node$ 的左子节点存在,则将其压入栈中。 - 二叉树的前序遍历显式栈实现代码如下: +这样即可实现前序遍历的非递归(显式栈)写法。 ```python class Solution: def preorderTraversal(self, root: Optional[TreeNode]) -> List[int]: - if not root: # 二叉树为空直接返回 + """ + 二叉树的前序遍历(非递归/显式栈实现) + 参数: + root: Optional[TreeNode],二叉树的根节点 + 返回: + List[int],前序遍历的节点值列表 + """ + if not root: # 特判:二叉树为空,直接返回空列表 return [] - - res = [] - stack = [root] - - while stack: # 栈不为空 - node = stack.pop() # 弹出根节点 - res.append(node.val) # 访问根节点 - if node.right: - stack.append(node.right) # 右子树入栈 - if node.left: - stack.append(node.left) # 左子树入栈 - return res + res = [] # 用于存储遍历结果 + stack = [root] # 初始化栈,根节点先入栈 + + while stack: # 当栈不为空时循环 + node = stack.pop() # 弹出栈顶节点 + res.append(node.val) # 访问当前节点(根节点) + # 注意:先右后左,保证左子树先被遍历 + if node.right: # 如果右子节点存在,先将其入栈 + stack.append(node.right) + if node.left: # 如果左子节点存在,再将其入栈 + stack.append(node.left) + + return res # 返回前序遍历结果 ``` -## 3. 二叉树的中序遍历 +## 3. 二叉树中序遍历 -> 二叉树的中序遍历规则为: +> **二叉树中序遍历(Inorder Traversal)** 的基本规则如下: > -> - 如果二叉树为空,则返回。 -> - 如果二叉树不为空,则: -> 1. 以中序遍历的方式遍历根节点的左子树。 -> 2. 访问根节点。 -> 3. 以中序遍历的方式遍历根节点的右子树。 +> - 如果二叉树为空,直接返回。 +> - 如果二叉树非空,则依次执行: +> 1. 递归遍历左子树(中序方式); +> 2. 访问当前根节点; +> 3. 递归遍历右子树(中序方式)。 -从二叉树的中序遍历规则可以看出:中序遍历过程也是一个递归过程。在遍历任何一棵子树时仍然是按照先遍历子树根节点的左子树,然后访问根节点,最后再遍历子树根节点的右子树的顺序进行遍历。 +中序遍历本质上是一个递归过程。无论遍历哪一棵子树,始终遵循「先左子树,后根节点,最后右子树」的顺序。每到一个节点,先深入其左子树,左子树遍历完毕后访问该节点本身,最后再遍历其右子树。 -如下图所示,该二叉树的中序遍历顺序为:$H - D - I - B - E - A - F - J - C - K - G$。 +如下图所示,该二叉树的中序遍历结果为:$H - D - I - B - E - A - F - J - C - K - G$。 ![二叉树的中序遍历](https://qcdn.itcharge.cn/images/20240511171643.png) -### 3.1 二叉树的中序遍历递归实现 +### 3.1 二叉树中序遍历的递归实现 -二叉树的中序遍历递归实现步骤为: +二叉树的序遍历递归实现的基本步骤: -1. 判断二叉树是否为空,为空则直接返回。 -2. 先递归遍历左子树。 -3. 然后访问根节点。 -4. 最后递归遍历右子树。 +1. 如果当前节点为空,直接返回。 +2. 递归遍历左子树。 +3. 访问当前节点。 +4. 递归遍历右子树。 -二叉树的中序遍历递归实现代码如下: +对应的递归实现代码如下: ```python class Solution: def inorderTraversal(self, root: TreeNode) -> List[int]: - res = [] - def inorder(root): - if not root: - return - inorder(root.left) - res.append(root.val) - inorder(root.right) - - inorder(root) - return res + """ + 二叉树中序遍历(递归实现) + + 参数: + root: TreeNode,二叉树的根节点 + 返回: + List[int],中序遍历的节点值列表 + """ + res = [] # 用于存储遍历结果 + + def inorder(node): + if not node: + return # 递归终止条件:节点为空 + inorder(node.left) # 递归遍历左子树 + res.append(node.val) # 访问当前节点 + inorder(node.right) # 递归遍历右子树 + + inorder(root) # 从根节点开始递归 + return res # 返回中序遍历结果 ``` -### 3.2 二叉树的中序遍历显式栈实现 - -我们可以使用一个显式栈 $stack$ 来模拟二叉树的中序遍历递归的过程。 +### 3.2 二叉树中序遍历的非递归实现 -与前序遍历不同,访问根节点要放在左子树遍历完之后。因此我们需要保证:**在左子树访问之前,当前节点不能提前出栈**。 +我们可以通过显式维护一个栈 $stack,ドル来模拟递归实现的中序遍历过程。 -我们应该从根节点开始,循环遍历左子树,不断将当前子树的根节点放入栈中,直到当前节点无左子树时,从栈中弹出该节点并进行处理。 +与前序遍历不同,中序遍历要求在访问根节点前,必须先遍历完其左子树。因此,**只有在左子树全部入栈后,当前节点才能出栈并被访问**。 -然后再访问该元素的右子树,并进行上述循环遍历左子树的操作。这样可以保证最终遍历顺序为中序遍历顺序。 +具体做法是:从根节点出发,不断将当前节点压入栈中,并向左移动,直到没有左子节点为止。此时弹出栈顶节点,访问该节点,然后转向其右子树,重复上述过程。这样可以确保遍历顺序严格按照「左-根-右」进行。 -二叉树的中序遍历显式栈实现步骤如下: +中序遍历的非递归(显式栈)实现步骤如下: -1. 判断二叉树是否为空,为空则直接返回。 -2. 初始化维护一个空栈。 -3. 当根节点或者栈不为空时: - 1. 如果当前节点不为空,则循环遍历左子树,并不断将当前子树的根节点入栈。 - 1. 如果当前节点为空,说明当前节点无左子树,则弹出栈顶元素 $node,ドル并访问该元素,然后尝试访问该节点的右子树。 +1. 如果二叉树为空,直接返回。 +2. 初始化一个空栈。 +3. 当当前节点不为空或栈不为空时,重复以下操作: + 1. 如果当前节点不为空,不断将其压入栈,并向左移动,直到左子节点为空。 + 2. 如果当前节点为空,说明已到达最左侧,弹出栈顶节点 $node,ドル访问该节点,然后将当前节点指向 $node$ 的右子节点,继续上述循环。 - 二叉树的中序遍历显式栈实现代码如下: +二叉树中序遍历的非递归实现代码如下: ```python class Solution: def inorderTraversal(self, root: Optional[TreeNode]) -> List[int]: - if not root: # 二叉树为空直接返回 - return [] - - res = [] - stack = [] - - while root or stack: # 根节点或栈不为空 - while root: - stack.append(root) # 将当前树的根节点入栈 - root = root.left # 找到最左侧节点 - - node = stack.pop() # 遍历到最左侧,当前节点无左子树时,将最左侧节点弹出 - res.append(node.val) # 访问该节点 - root = node.right # 尝试访问该节点的右子树 + """ + 二叉树中序遍历(非递归/显式栈实现) + + 参数: + root: Optional[TreeNode],二叉树的根节点 + 返回: + List[int],中序遍历的节点值列表 + """ + res = [] # 用于存储遍历结果 + stack = [] # 显式栈,用于模拟递归过程 + cur = root # 当前遍历的节点指针 + + while cur or stack: # 只要当前节点不为空或栈不为空就继续 + # 不断向左子树深入,将沿途节点全部入栈 + while cur: + stack.append(cur) # 当前节点入栈 + cur = cur.left # 继续遍历左子树 + + # 此时已到达最左侧,弹出栈顶节点 + node = stack.pop() # 弹出最左侧节点 + res.append(node.val) # 访问该节点(中序遍历的"根") + cur = node.right # 转向右子树,继续上述过程 + return res ``` -## 4. 二叉树的后序遍历 +## 4. 二叉树后序遍历 -> 二叉树的后序遍历规则为: +> **二叉树后序遍历(Postorder Traversal)** 的基本规则如下: > -> - 如果二叉树为空,则返回。 -> - 如果二叉树不为空,则: -> 1. 以后序遍历的方式遍历根节点的左子树。 -> 2. 以后序遍历的方式遍历根节点的右子树。 +> - 如果二叉树为空,直接返回。 +> - 如果二叉树非空,则依次执行: +> 1. 递归遍历左子树(后序方式)。 +> 2. 递归遍历右子树(后序方式)。 > 3. 访问根节点。 -从二叉树的后序遍历规则可以看出:后序遍历过程也是一个递归过程。在遍历任何一棵子树时仍然是按照先遍历子树根节点的左子树,然后遍历子树根节点的右子树,最后再访问根节点的顺序进行遍历。 +后序遍历的本质是递归地先处理左子树,再处理右子树,最后处理根节点。无论遍历到哪一棵子树,始终遵循「左-右-根」的顺序。 -如下图所示,该二叉树的后序遍历顺序为:$H - I - D - E - B - J - F - K - G - C - A$。 +如下图所示,该二叉树的后序遍历结果为:$H - I - D - E - B - J - F - K - G - C - A$。 ![二叉树的后序遍历](https://qcdn.itcharge.cn/images/20240511171658.png) -### 4.1 二叉树的后序遍历递归实现 +### 4.1 二叉树后序遍历的递归实现 -二叉树的后序遍历递归实现步骤为: +后序遍历递归实现的核心思想是:对于每个节点,先处理其左子树,再处理右子树,最后访问节点本身。具体步骤如下: -1. 判断二叉树是否为空,为空则直接返回。 -2. 先递归遍历左子树。 -3. 然后递归遍历右子树。 -4. 最后访问根节点。 +1. 如果当前节点为空,直接返回。 +2. 递归遍历左子树。 +3. 递归遍历右子树。 +4. 访问当前节点(即处理节点值)。 -二叉树的后序遍历递归实现代码如下: +下面是二叉树后序遍历的递归实现代码: ```python class Solution: def postorderTraversal(self, root: TreeNode) -> List[int]: - res = [] - def postorder(root): - if not root: + """ + 二叉树后序遍历(递归实现) + 参数: + root: TreeNode,二叉树的根节点 + 返回: + List[int],后序遍历的节点值列表 + """ + res = [] # 用于存储遍历结果 + + def postorder(node): + if not node: return - postorder(root.left) - postorder(root.right) - res.append(root.val) + # 递归遍历左子树 + postorder(node.left) + # 递归遍历右子树 + postorder(node.right) + # 访问当前节点 + res.append(node.val) postorder(root) return res ``` -### 4.2 二叉树的后序遍历显式栈实现 +### 4.2 二叉树后序遍历的非递归实现 -我们可以使用一个显式栈 $stack$ 来模拟二叉树的后序遍历递归的过程。 +后序遍历可以通过显式栈 $stack$ 来模拟递归过程。与前序和中序遍历不同,后序遍历要求在左右子树都访问完成后,才能访问根节点。因此,必须确保:**当前节点在其左右孩子节点都访问完毕之前不能出栈**。 -与前序、中序遍历不同,在后序遍历中,根节点的访问要放在左右子树访问之后。因此,我们要保证:**在左右孩子节点访问结束之前,当前节点不能提前出栈**。 +后序遍历的非递归实现可以通过如下方式优化理解: -我们应该从根节点开始,先将根节点放入栈中,然后依次遍历左子树,不断将当前子树的根节点放入栈中,直到遍历到左子树最左侧的那个节点,从栈中弹出该元素,并判断该元素的右子树是否已经访问完毕,如果访问完毕,则访问该元素。如果未访问完毕,则访问该元素的右子树。 +- 从根节点出发,将其依次压入栈中,并不断向左深入,直到到达最左侧节点。 +- 每次弹出栈顶节点,判断其右子树是否已被访问: + - 如果已访问,则访问该节点; + - 如果未访问,则将该节点重新压入栈,并转而遍历其右子树。 + +具体步骤如下: -二叉树的后序遍历显式栈实现步骤如下: +1. 如果二叉树为空,直接返回。 +2. 初始化一个空栈 $stack,ドル并用 $prev$ 记录上一个访问的节点。 +3. 当当前节点不为空或栈不为空时,循环执行: + 1. 不断将当前节点压入栈,并向左移动,直到最左侧节点。 + 2. 弹出栈顶节点 $node$。 + 3. 若 $node$ 没有右子树,或右子树已被访问,则访问 $node,ドル更新 $prev,ドル并将当前节点设为空。 + 4. 否则,将 $node$ 重新压回栈,转而遍历其右子树。 -1. 判断二叉树是否为空,为空则直接返回。 -2. 初始化维护一个空栈,使用 $prev$ 保存前一个访问的节点,用于确定当前节点的右子树是否访问完毕。 -3. 当根节点或者栈不为空时,从当前节点开始: - 1. 如果当前节点有左子树,则不断遍历左子树,并将当前根节点压入栈中。 - 2. 如果当前节点无左子树,则弹出栈顶元素 $node$。 - 2. 如果栈顶元素 $node$ 无右子树(即 `not node.right`)或者右子树已经访问完毕(即 `node.right == prev`),则访问该元素,然后记录前一节点,并将当前节点标记为空节点。 - 2. 如果栈顶元素有右子树,则将栈顶元素重新压入栈中,继续访问栈顶元素的右子树。 +这样即可实现二叉树的后序遍历非递归写法。 ```python class Solution: def postorderTraversal(self, root: Optional[TreeNode]) -> List[int]: - res = [] - stack = [] - prev = None # 保存前一个访问的节点,用于确定当前节点的右子树是否访问完毕 - - while root or stack: # 根节点或栈不为空 + """ + 二叉树后序遍历(非递归/显式栈实现) + 参数: + root: Optional[TreeNode],二叉树的根节点 + 返回: + List[int],后序遍历的节点值列表 + """ + res = [] # 用于存储遍历结果 + stack = [] # 显式栈,用于模拟递归过程 + prev = None # 记录上一个访问的节点,用于判断右子树是否已访问 + + while root or stack: # 只要当前节点不为空或栈不为空就继续遍历 + # 一直向左走,将所有左子节点入栈 while root: - stack.append(root) # 将当前树的根节点入栈 - root = root.left # 继续访问左子树,找到最左侧节点 + stack.append(root) # 当前节点入栈 + root = root.left # 继续遍历左子树 - node = stack.pop() # 遍历到最左侧,当前节点无左子树时,将最左侧节点弹出 + node = stack.pop() # 弹出栈顶节点,准备访问或遍历其右子树 - # 如果当前节点无右子树或者右子树访问完毕 + # 判断是否可以访问当前节点 + # 1. 没有右子树 + # 2. 右子树已经访问过(即上一次访问的节点是当前节点的右子节点) if not node.right or node.right == prev: - res.append(node.val)# 访问该节点 - prev = node # 记录前一节点 - root = None # 将当前根节点标记为空 + res.append(node.val) # 访问当前节点 + prev = node # 更新上一次访问的节点 + root = None # 当前节点已访问,重置root,防止重复入栈 else: - stack.append(node) # 右子树尚未访问完毕,将当前节点重新压回栈中 - root = node.right # 继续访问右子树 - + # 右子树还未访问,当前节点重新入栈,转而遍历右子树 + stack.append(node) + root = node.right + return res ``` -## 5. 二叉树的层序遍历 +## 5. 二叉树层序遍历 -> 二叉树的层序遍历规则为: +> **二叉树层序遍历**(Level Order Traversal)的基本规则为:指按照从上到下、从左到右的顺序,逐层依次访问二叉树的所有节点。 > -> - 如果二叉树为空,则返回。 -> - 如果二叉树不为空,则: -> 1. 先依次访问二叉树第 1ドル$ 层的节点。 -> 2. 然后依次访问二叉树第 2ドル$ 层的节点。 -> 3. ...... -> 4. 依次下去,最后依次访问二叉树最下面一层的节点。 +> - 如果二叉树为空,直接返回。 +> - 如果二叉树非空,则: +> 1. 先访问第 1ドル$ 层(根节点); +> 2. 再访问第 2ドル$ 层的所有节点; +> 3. 依次类推,直到访问到最底层的所有节点。 -从二叉树的层序遍历规则可以看出:遍历过程是一个广度优先搜索过程。在遍历的时候是按照第 1ドル$ 层、第 2ドル$ 层、...... 最后一层依次遍历的,而同一层节点则是按照从左至右的顺序依次访问的。 +层序遍历本质上是一种广度优先搜索(BFS)过程。遍历时,先访问每一层的所有节点,再进入下一层,并且同一层的节点总是从左到右依次访问。 -如下图所示,该二叉树的后序遍历顺序为:$A - B - C - D - E - F - G - H - I - J - K$。 +如下图所示,该二叉树的层序遍历结果为:$A - B - C - D - E - F - G - H - I - J - K$。 ![二叉树的层序遍历](https://qcdn.itcharge.cn/images/20240511175431.png) -二叉树的层序遍历是通过队列来实现的。具体步骤如下: +层序遍历通常借助队列(Queue)来实现。具体流程如下: -1. 判断二叉树是否为空,为空则直接返回。 -2. 令根节点入队。 -3. 当队列不为空时,求出当前队列长度 $s_i$。 -4. 依次从队列中取出这 $s_i$ 个元素,并对这 $s_i$ 个元素依次进行访问。然后将其左右孩子节点入队,然后继续遍历下一层节点。 -5. 当队列为空时,结束遍历。 +1. 如果二叉树为空,直接返回。 +2. 将根节点加入队列。 +3. 当队列不为空时,重复以下操作: + 1. 记录当前队列长度 $s_i$(即当前层的节点数)。 + 2. 依次从队列中取出这 $s_i$ 个节点,访问它们,并将它们的左右子节点(如存在)加入队列。 +4. 队列为空时,遍历结束。 -二叉树的层序遍历代码实现如下: +二叉树层序遍历的代码实现如下: ```python class Solution: def levelOrder(self, root: TreeNode) -> List[List[int]]: + """ + 二叉树层序遍历(广度优先搜索,BFS) + 返回每一层的节点值组成的二维列表 + """ if not root: - return [] - queue = [root] - order = [] + return [] # 空树直接返回空列表 + + from collections import deque # 推荐使用 deque 提高队列效率 + queue = deque([root]) # 初始化队列,根节点入队 + order = [] # 用于存储最终结果 + while queue: - level = [] - size = len(queue) + level = [] # 存储当前层的节点值 + size = len(queue) # 当前层的节点数量 for _ in range(size): - curr = queue.pop(0) - level.append(curr.val) + curr = queue.popleft() # 弹出队首节点 + level.append(curr.val) # 访问当前节点 if curr.left: - queue.append(curr.left) + queue.append(curr.left) # 左子节点入队 if curr.right: - queue.append(curr.right) + queue.append(curr.right) # 右子节点入队 if level: - order.append(level) + order.append(level) # 当前层结果加入总结果 + return order ``` +## 6. 总结 + +### 6.1 算法特点对比 + +| 遍历方式 | 访问顺序 | 递归实现 | 非递归实现 | 空间复杂度 | 时间复杂度 | +|---------|---------|---------|-----------|-----------|-----------| +| **前序遍历** | 根 → 左 → 右 | 简单直观 | 使用栈,先右后左入栈 | O(h) | O(n) | +| **中序遍历** | 左 → 根 → 右 | 简单直观 | 使用栈,先左后右 | O(h) | O(n) | +| **后序遍历** | 左 → 右 → 根 | 简单直观 | 使用栈,需要标记访问状态 | O(h) | O(n) | +| **层序遍历** | 按层从左到右 | 不适用 | 使用队列,BFS思想 | O(w) | O(n) | + +> 注:h 为树的高度,w 为树的最大宽度,n 为节点总数 + +### 6.2 优缺点分析 + +#### 前序遍历 +- **优点**: + - 递归实现简单直观,易于理解 + - 适合需要先处理根节点再处理子节点的场景 + - 常用于树的复制、序列化等操作 +- **缺点**: + - 非递归实现需要特别注意入栈顺序 + - 对于深度很大的树,递归可能导致栈溢出 + +#### 中序遍历 +- **优点**: + - 对于二叉搜索树,中序遍历得到有序序列 + - 递归实现逻辑清晰 + - 适合需要按顺序处理节点的场景 +- **缺点**: + - 非递归实现相对复杂 + - 需要理解"左-根-右"的访问时机 + +#### 后序遍历 +- **优点**: + - 适合需要先处理子节点再处理父节点的场景 + - 常用于树的删除、后序表达式计算等 + - 递归实现简单 +- **缺点**: + - 非递归实现最复杂,需要额外的访问状态标记 + - 理解难度较高 + +#### 层序遍历 +- **优点**: + - 直观反映树的层次结构 + - 适合需要按层处理节点的场景 + - 非递归实现相对简单 +- **缺点**: + - 不适用于递归实现 + - 空间复杂度可能较高(对于宽树) + +### 6.3 适用场景 + +- **前序遍历**:树的复制、序列化、前缀表达式计算 +- **中序遍历**:二叉搜索树的有序遍历、中缀表达式计算 +- **后序遍历**:树的删除、后缀表达式计算、计算树的高度 +- **层序遍历**:按层打印树、计算树的宽度、BFS相关算法 + +### 6.4 实现建议 + +1. **递归实现**:代码简洁,易于理解,适合面试和教学 +2. **非递归实现**:性能更好,避免栈溢出,适合生产环境 +3. **选择原则**:根据具体需求选择合适的遍历方式,考虑时间复杂度和空间复杂度 +4. **优化技巧**:使用双端队列提高层序遍历效率,合理使用栈和队列数据结构 + ## 练习题目 - [0144. 二叉树的前序遍历](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/0100-0199/binary-tree-preorder-traversal.md) diff --git a/docs/05_tree/05_03_binary_tree_reduction.md b/docs/05_tree/05_03_binary_tree_reduction.md index 4803395d..25f4d113 100644 --- a/docs/05_tree/05_03_binary_tree_reduction.md +++ b/docs/05_tree/05_03_binary_tree_reduction.md @@ -1,164 +1,247 @@ ## 1. 二叉树的还原简介 -> **二叉树的还原**:指的是通过二叉树的遍历序列,还原出对应的二叉树。 +> **二叉树的还原**:指通过已知的二叉树遍历序列,重建出原始的二叉树结构。 -从二叉树的遍历过程可以看出,给定一棵非空二叉树,它的前序、中序、后续遍历所得到的遍历序列都是唯一的。那么反过来,如果已知节点的某种遍历序列,能否确定这棵二叉树呢?并且确定的二叉树是否是唯一的呢? +我们知道,对于一棵非空二叉树,其前序、中序、后序遍历序列都是唯一的。但反过来,如果只给出某一种遍历序列,是否能唯一确定这棵二叉树呢?答案是否定的。 -我们先来回顾一下二叉树的前序遍历、中序遍历、后序遍历规则。 +### 1.1 单一遍历序列的还原能力 -- 非空二叉树的前序遍历规则: - 1. 访问根节点。 - 2. 以前序遍历的方式遍历根节点的左子树。 - 3. 以前序遍历的方式遍历根节点的右子树。 -- 非空二叉树的中序遍历规则: - 1. 以中序遍历的方式遍历根节点的左子树。 - 2. 访问根节点。 - 3. 以中序遍历的方式遍历根节点的右子树。 -- 非空二叉树的后序遍历规则: - 1. 以后序遍历的方式遍历根节点的左子树。 - 2. 以后序遍历的方式遍历根节点的右子树。 - 3. 访问根节点。 +- **前序遍历**:第一个节点必为根节点,但无法区分后续节点属于左子树还是右子树,因此仅凭前序序列无法还原二叉树。 +- **中序遍历**:虽然根节点能将中序序列分为左右子树,但无法确定根节点是谁,因此仅凭中序序列也无法还原二叉树。 +- **后序遍历**:最后一个节点必为根节点,但同样无法判断其他节点的归属,仅凭后序序列也无法还原二叉树。 -先来看二叉树的前序遍历,前序遍历过程中首先访问的是根节点,所以通过前序遍历序列,我们可以确定序列的第 1ドル$ 个节点肯定是根节点。但是从第 2ドル$ 个节点开始就不确定它是根节点的左子树还是根节点的右子树了。所以单凭前序遍历序列是无法恢复一棵二叉树的。 +### 1.2 两种遍历序列的组合情况 -再来看二叉树的后序遍历,后序遍历也是只能确定序列的最后一个节点为根节点,而无法确定其他节点在二叉树中的位置。所以单凭后序遍历序列也是无法恢复一棵二叉树的。 +- **前序 + 中序**:前序序列确定根节点,中序序列确定左右子树的范围。递归分割子序列,可以唯一还原原二叉树。 +- **中序 + 后序**:后序序列确定根节点,中序序列确定左右子树的范围,方法与前序+中序类似,也能唯一还原二叉树。 +- **中序 + 层序**:通过层序遍历确定每个子树的根节点,再结合中序遍历分割左右子树,也可以唯一还原二叉树。 -最后我们来看二叉树的中序遍历,中序遍历是先遍历根节点的左子树,然后访问根节点,最后遍历根节点的右子树。这样,根节点在中序遍历序列中必然将中序序列分割成前后两个子序列,其中前一个子序列是根节点的左子树的中序遍历序列,后一个子序列是根节点的右子树的中序遍历序列。当然单凭中序遍历序列也是无法恢复一棵二叉树的。 +### 1.3 不能唯一还原的特殊情况 -但是如果我们可以将「前序遍历序列」和「中序遍历序列」相结合,那么我们就可以通过上面中序遍历序列中的两个子序列,在前序遍历序列中找到对应的左子序列和右子序列。在前序遍历序列中,左子序列的第 1ドル$ 个节点是左子树的根节点,右子序列的第 1ドル$ 个节点是右子树的根节点。这样,就确定了二叉树的 3ドル$ 个节点。 +- **前序 + 后序**:仅有前序和后序遍历序列时,无法唯一确定二叉树结构。因为缺少中序信息,无法区分左右子树的分界。例如,如果存在度为 1ドル$ 的节点,无法判断该节点是左子树还是右子树。 +- **特殊说明**:只有当二叉树中每个节点的度均为 2ドル$ 或 0ドル$(即满二叉树)时,前序和后序遍历序列才能唯一确定二叉树。若存在度为 1ドル$ 的节点,则无法唯一还原。 -同时,左子树和右子树的根节点在中序遍历序列中又可以将左子序列和右子序列分别划分成两个子序列。如此递归下去,当确定了前序遍历序列中的所有节点时,我们就得到了一棵二叉树。 +**结论**: +- 已知「前序+中序」、「中序+后序」、「中序+层序」任意一组遍历序列,可以唯一还原一棵二叉树。 +- 仅有「前序+后序」遍历序列,通常无法唯一还原二叉树,除非二叉树为满二叉树。 -还有一个问题,通过前序序列和中序序列还原的二叉树是唯一的吗? +## 2. 利用前序与中序遍历序列重建二叉树 -这个唯一性可以利用归纳法加以证明。感兴趣的读者可以试试自己证明或者参考有关资料。 +- **描述**:给定一棵二叉树的前序遍历序列和中序遍历序列。 +- **目标**:重建出原始的二叉树结构。 +- **说明**:树中所有节点值均不重复。 -通过上述过程说明:**如果已知一棵二叉树的前序序列和中序序列,可以唯一地确定这棵二叉树。** +### 2.1 实现思路与步骤 -同理,**如果已知一棵二叉树的中序序列和后序序列,也可以唯一地确定这棵二叉树。** 方法和通过二叉树的前序序列和中序序列构造二叉树类似,唯一不同点在于二叉树的根节点是根据后序遍历序列的最后一个元素确定的。 +前序遍历顺序为:根节点 → 左子树 → 右子树; +中序遍历顺序为:左子树 → 根节点 → 右子树。 -类似的,**已知二叉树的「中序遍历序列」和「层序遍历序列」,也可以唯一地确定一棵二叉树。** +基于上述规律,可以通过以下方式递归重建二叉树: -需要注意的是:**如果已知二叉树的「前序遍历序列」和「后序遍历序列」,是不能唯一地确定一棵二叉树的。** 这是因为没有中序遍历序列无法确定左右部分,也就无法进行子序列的分割。 +1. 前序遍历序列的第一个元素即为当前子树的根节点。 +2. 在中序遍历序列中查找该根节点的位置 $inorder[k],ドル据此将中序序列分为左、右子树两部分,并确定左右子树的节点数量。 +3. 利用左右子树节点数量,将前序遍历序列切分为左、右子树对应的部分。 +4. 递归构建当前根节点的左、右子树,直到子树为空(序列长度为0)为止。 -只有二叉树中每个节点度为 2ドル$ 或者 0ドル$ 的时候,已知前序遍历序列和后序遍历序列,才能唯一地确定一颗二叉树,如果二叉树中存在度为 1ドル$ 的节点时是无法唯一地确定一棵二叉树的,这是因为我们无法判断该节点是左子树还是右子树。 +简要流程如下: -## 2. 从前序与中序遍历序列构造二叉树 +- 取前序序列首元素作为根节点。 +- 在中序序列中定位根节点,分割出左、右子树的中序区间。 +- 根据左子树节点数,切分前序序列为左、右子树区间。 +- 递归处理左右子树,直至区间为空。 -- **描述**:已知一棵二叉树的前序遍历序列和中序遍历序列。 -- **要求**:构造出该二叉树。 -- **注意**:假设树中没有重复的元素。 - -### 2.1 从前序与中序遍历序列构造二叉树实现过程 - -前序遍历的顺序是:根节点 - 左子树 - 右子树。中序遍历的顺序是:左子树 - 根节点 - 右子树。 - -根据前序遍历的顺序,可以找到根节点位置。然后在中序遍历的结果中可以找到对应的根节点位置,就可以从根节点位置将二叉树分割成左子树、右子树。同时能得到左右子树的节点个数。 - -此时构建当前节点,并递归建立左右子树,在左右子树对应位置继续递归遍历进行上述步骤,直到节点为空,具体操作步骤如下: - -1. 从前序遍历顺序中得到当前根节点的位置在 $postorder[0]$。 -2. 通过在中序遍历中查找上一步根节点对应的位置 $inorder[k],ドル从而将二叉树的左右子树分隔开,并得到左右子树节点的个数。 -3. 从上一步得到的左右子树个数将前序遍历结果中的左右子树分开。 -4. 构建当前节点,并递归建立左右子树,在左右子树对应位置继续递归遍历并执行上述三步,直到节点为空。 - -### 2.2 从前序与中序遍历序列构造二叉树实现代码 +### 2.2 代码实现 ```python class Solution: def buildTree(self, preorder: List[int], inorder: List[int]) -> TreeNode: + """ + 根据前序遍历和中序遍历序列重建二叉树 + + 参数: + preorder: List[int],二叉树的前序遍历序列 + inorder: List[int],二叉树的中序遍历序列 + 返回: + TreeNode,重建后的二叉树根节点 + """ def createTree(preorder, inorder, n): + """ + 递归构建二叉树 + + 参数: + preorder: 当前子树的前序遍历序列 + inorder: 当前子树的中序遍历序列 + n: 当前子树的节点数 + 返回: + TreeNode,当前子树的根节点 + """ if n == 0: - return None + return None # 递归终止条件:子树节点数为 0 + # 在中序遍历中查找根节点位置 k = 0 while preorder[0] != inorder[k]: k += 1 + # 创建根节点 node = TreeNode(inorder[k]) + # 递归构建左子树 node.left = createTree(preorder[1: k + 1], inorder[0: k], k) + # 递归构建右子树 node.right = createTree(preorder[k + 1:], inorder[k + 1:], n - k - 1) return node + + # 从整棵树的前序和中序序列开始递归构建 return createTree(preorder, inorder, len(inorder)) ``` -## 3. 从中序与后序遍历序列构造二叉树 +## 3. 利用中序与后序遍历序列重建二叉树 -- **描述**:已知一棵二叉树的中序遍历序列和后序遍历序列。 -- **要求**:构造出该二叉树。 -- **注意**:假设树中没有重复的元素。 +- **描述**:给定一棵二叉树的中序遍历序列和后序遍历序列。 +- **目标**:重建出原始的二叉树结构。 +- **说明**:树中所有节点值均不重复。 -### 3.1 从中序与后序遍历序列构造二叉树实现过程 +### 3.1 实现思路与步骤 -中序遍历的顺序是:左子树 - 根节点 - 右子树。后序遍历的顺序是:左子树 - 右子树 - 根节点。 +- 中序遍历顺序:左子树 → 根节点 → 右子树 +- 后序遍历顺序:左子树 → 右子树 → 根节点 -根据后序遍历的顺序,可以找到根节点位置。然后在中序遍历的结果中可以找到对应的根节点位置,就可以从根节点位置将二叉树分割成左子树、右子树。同时能得到左右子树的节点个数。 +利用后序遍历的最后一个元素可以确定当前子树的根节点。再在中序遍历序列中定位该根节点,从而划分出左、右子树的中序区间,并据此确定左右子树的节点数量。递归地对左右子树重复上述过程,直到区间为空。 -此时构建当前节点,并递归建立左右子树,在左右子树对应位置继续递归遍历进行上述步骤,直到节点为空,具体操作步骤如下: +具体步骤如下: -1. 从后序遍历顺序中当前根节点的位置在 $postorder[n-1]$。 -2. 通过在中序遍历中查找上一步根节点对应的位置 $inorder[k],ドル从而将二叉树的左右子树分隔开,并得到左右子树节点的个数。 -3. 从上一步得到的左右子树个数将后序遍历结果中的左右子树分开。 -4. 构建当前节点,并递归建立左右子树,在左右子树对应位置继续递归遍历并执行上述三步,直到节点为空。 +1. 后序遍历序列的最后一个元素 $postorder[n-1]$ 为当前子树的根节点。 +2. 在中序遍历序列中查找该根节点的位置 $inorder[k],ドル据此将中序序列分为左、右子树区间,并确定左右子树的节点数。 +3. 利用左右子树的节点数,将后序遍历序列划分为左、右子树对应的区间。 +4. 构建当前根节点,并递归构建其左、右子树,直到区间为空为止。 -### 3.2 从中序与后序遍历序列构造二叉树实现代码 +### 3.2 代码实现 ```python class Solution: def buildTree(self, inorder: List[int], postorder: List[int]) -> TreeNode: + """ + 根据中序遍历和后序遍历序列重建二叉树 + + 参数: + inorder: List[int],二叉树的中序遍历序列 + postorder: List[int],二叉树的后序遍历序列 + 返回: + TreeNode,重建后的二叉树根节点 + """ def createTree(inorder, postorder, n): + """ + 递归构建二叉树 + + 参数: + inorder: 当前子树的中序遍历序列 + postorder: 当前子树的后序遍历序列 + n: 当前子树的节点数 + 返回: + TreeNode,当前子树的根节点 + """ if n == 0: - return None + return None # 递归终止条件:子树节点数为0,返回空节点 + + # 后序遍历的最后一个元素为当前子树的根节点 + root_val = postorder[n - 1] + # 在中序遍历中查找根节点的位置 k = 0 - while postorder[n - 1] != inorder[k]: + while inorder[k] != root_val: k += 1 - node = TreeNode(inorder[k]) - node.right = createTree(inorder[k + 1: n], postorder[k: n - 1], n - k - 1) - node.left = createTree(inorder[0: k], postorder[0: k], k) + + # 创建根节点 + node = TreeNode(root_val) + # 递归构建左子树 + # 左子树的中序区间:inorder[0:k] + # 左子树的后序区间:postorder[0:k] + node.left = createTree(inorder[0:k], postorder[0:k], k) + # 递归构建右子树 + # 右子树的中序区间:inorder[k+1:n] + # 右子树的后序区间:postorder[k:n-1] + node.right = createTree(inorder[k+1:n], postorder[k:n-1], n - k - 1) return node + + # 从整棵树的中序和后序序列开始递归构建 return createTree(inorder, postorder, len(postorder)) ``` -## 4. 从前序与后序遍历序列构造二叉树 - -前边我们说过:**已知二叉树的前序遍历序列和后序遍历序列,是不能唯一地确定一棵二叉树的。** 而如果不要求构造的二叉树是唯一的,只要求构造出一棵二叉树,还是可以进行构造的。 +## 4. 利用前序与后序遍历序列构造二叉树 -- **描述**:已知一棵二叉树的前序遍历序列和后序遍历序列。 +如前所述,**仅通过二叉树的前序和后序遍历序列,无法唯一确定一棵二叉树。** 但如果不要求唯一性,只需构造出任意一棵符合条件的二叉树,是可以实现的。 -- **要求**:重构并返回该二叉树。 +- **描述**:给定一棵二叉树的前序遍历和后序遍历序列。 +- **目标**:重建并返回该二叉树。 +- **说明**:假设树中节点值各不相同。如果存在多个可行答案,返回其中任意一个即可。 -- **注意**:假设树中没有重复的元素。如果存在多个答案,则可以返回其中任意一个。 +### 4.1 实现思路与步骤 -### 4.1 从前序与后序遍历序列构造二叉树实现过程 +我们可以假定前序遍历序列的第二个元素为左子树的根节点,进而递归划分左右子树。具体步骤如下: -我们可以默认指定前序遍历序列的第 2ドル$ 个值为左子树的根节点,由此递归划分左右子序列。具体操作步骤如下: +1. 前序遍历的第一个元素 $preorder[0]$ 是当前子树的根节点。 +2. 前序遍历的第二个元素 $preorder[1]$ 是左子树的根节点。我们在后序遍历中查找该节点的位置 $postorder[k],ドル该位置左侧为左子树,右侧为右子树。 +3. 由 $k$ 可确定左子树的节点数量,从而划分前序和后序序列的左右子树部分。 +4. 递归构建当前节点的左、右子树,直到子树为空。 -1. 从前序遍历序列中可知当前根节点的位置在 $preorder[0]$。 - -2. 前序遍历序列的第 2ドル$ 个值为左子树的根节点,即 $preorder[1]$。通过在后序遍历中查找上一步根节点对应的位置 $postorder[k]$(该节点右侧为右子树序列),从而将二叉树的左右子树分隔开,并得到左右子树节点的个数。 - -3. 从上一步得到的左右子树个数将后序遍历结果中的左右子树分开。 - -4. 构建当前节点,并递归建立左右子树,在左右子树对应位置继续递归遍历并执行上述三步,直到节点为空。 - -### 4.2 从前序与后序遍历序列构造二叉树实现代码 +### 4.2 代码实现 ```python class Solution: def constructFromPrePost(self, preorder: List[int], postorder: List[int]) -> TreeNode: + """ + 根据前序和后序遍历序列构造二叉树(不唯一) + 参数: + preorder: List[int],二叉树的前序遍历序列 + postorder: List[int],二叉树的后序遍历序列 + 返回: + TreeNode,重建后的二叉树根节点 + """ def createTree(preorder, postorder, n): if n == 0: - return None - node = TreeNode(preorder[0]) + return None # 递归终止条件:子树节点数为0,返回空节点 + # 前序遍历的第一个元素为当前子树的根节点 + root_val = preorder[0] + node = TreeNode(root_val) if n == 1: - return node + return node # 只有一个节点,直接返回 + # 前序遍历的第二个元素为左子树的根节点 + left_root_val = preorder[1] + # 在后序遍历中查找左子树根节点的位置 k = 0 - while postorder[k] != preorder[1]: + while postorder[k] != left_root_val: k += 1 - node.left = createTree(preorder[1: k + 2], postorder[: k + 1], k + 1) - node.right = createTree(preorder[k + 2: ], postorder[k + 1: -1], n - k - 2) + # k 为左子树在 postorder 中的结尾索引,左子树节点数为 k+1 + # 划分左右子树的前序和后序区间 + # 左子树:preorder[1:k+2], postorder[0:k+1] + # 右子树:preorder[k+2:], postorder[k+1:n-1] + node.left = createTree(preorder[1:k+2], postorder[0:k+1], k+1) + node.right = createTree(preorder[k+2:], postorder[k+1:n-1], n-k-1) return node + # 从整棵树的前序和后序序列开始递归构建 return createTree(preorder, postorder, len(preorder)) ``` +## 5. 总结 + +### 5.1 核心要点 + +二叉树的还原是数据结构中的重要问题,其核心在于 **利用遍历序列的特性来重建树结构**。 + +**关键规律**: +- **前序遍历**:根节点 → 左子树 → 右子树 +- **中序遍历**:左子树 → 根节点 → 右子树 +- **后序遍历**:左子树 → 右子树 → 根节点 + +### 5.2 还原能力对比 + +| 遍历序列组合 | 能否唯一还原 | 说明 | +|-------------|-------------|------| +| 前序 + 中序 | ✅ 可以 | 前序确定根,中序确定左右子树范围 | +| 中序 + 后序 | ✅ 可以 | 后序确定根,中序确定左右子树范围 | +| 中序 + 层序 | ✅ 可以 | 层序确定根,中序确定左右子树范围 | +| 前序 + 后序 | ❌ 不能 | 缺少中序信息,无法区分左右子树 | + + +二叉树的还原是理解树结构遍历特性的重要应用,掌握「前序+中序」和「中序+后序」的还原方法,就能解决大部分二叉树构造问题。 + ## 练习题目 - [0105. 从前序与中序遍历序列构造二叉树](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/0100-0199/construct-binary-tree-from-preorder-and-inorder-traversal.md) diff --git a/docs/05_tree/05_04_binary_search_tree.md b/docs/05_tree/05_04_binary_search_tree.md index 8e97b8fa..b84f4408 100644 --- a/docs/05_tree/05_04_binary_search_tree.md +++ b/docs/05_tree/05_04_binary_search_tree.md @@ -1,160 +1,207 @@ ## 1. 二叉搜索树简介 -> **二叉搜索树(Binary Search Tree)**:也叫做二叉查找树、有序二叉树或者排序二叉树。是指一棵空树或者具有下列性质的二叉树: +> **二叉搜索树(Binary Search Tree, BST)**,又称二叉查找树、有序二叉树或排序二叉树,是一种特殊的二叉树结构,满足以下性质: > -> - 如果任意节点的左子树不为空,则左子树上所有节点的值均小于它的根节点的值。 -> - 如果任意节点的右子树不为空,则右子树上所有节点的值均大于它的根节点的值。 -> - 任意节点的左子树、右子树均为二叉搜索树。 +> - 对于任意节点,如果其左子树非空,则左子树所有节点的值均 **小于** 该节点的值; +> - 对于任意节点,如果其右子树非空,则右子树所有节点的值均 **大于** 该节点的值; +> - 任意节点的左右子树也都分别是二叉搜索树(递归定义)。 -如图所示,这 3ドル$ 棵树都是二叉搜索树。 +下图展示了三棵典型的二叉搜索树: ![二叉搜索树](https://qcdn.itcharge.cn/images/20240511171406.png) -二叉树具有一个特性,即:**左子树的节点值 < 根节点值 < 右子树的节点值**。 +二叉搜索树的核心特性是:**左子树所有节点值 < 根节点值 < 右子树所有节点值**。 -根据这个特性,如果我们以中序遍历的方式遍历整个二叉搜索树时,会得到一个递增序列。例如,一棵二叉搜索树的中序遍历序列如下图所示。 +基于这一特性,若对二叉搜索树进行中序遍历,得到的节点值序列一定是递增的。例如,某棵二叉搜索树的中序遍历结果如下图所示。 ## 2. 二叉搜索树的查找 -> **二叉搜索树的查找**:在二叉搜索树中查找值为 $val$ 的节点。 +> **二叉搜索树查找**:即在二叉搜索树中定位值为 $val$ 的节点。 -### 2.1 二叉搜索树的查找算法步骤 +### 2.1 查找算法思路 -按照二叉搜索树的定义,在进行元素查找时,我们只需要根据情况判断需要往左还是往右走。这样,每次根据情况判断都会缩小查找范围,从而提高查找效率。二叉树的查找步骤如下: +基于二叉搜索树的性质,查找过程可以高效地缩小范围。每次比较后,只需决定向左子树还是右子树继续查找,从而大大提升查找效率。具体步骤如下: -1. 如果二叉搜索树为空,则查找失败,结束查找,并返回空指针节点 $None$。 -2. 如果二叉搜索树不为空,则将要查找的值 $val$ 与二叉搜索树根节点的值 $root.val$ 进行比较: - 1. 如果 $val == root.val,ドル则查找成功,结束查找,返回被查找到的节点。 - 2. 如果 $val < root.val,ドル则递归查找左子树。 - 3. 如果 $val> root.val,ドル则递归查找右子树。 +1. 如果当前二叉搜索树为空,查找失败,返回空指针 $None$。 +2. 如果当前节点不为空,将待查找值 $val$ 与当前节点值 $root.val$ 比较: + - 如果 $val == root.val,ドル查找成功,返回该节点。 + - 如果 $val < root.val,ドル递归查找左子树。 + - 如果 $val> root.val,ドル递归查找右子树。 -### 2.2 二叉搜索树的查找代码实现 +### 2.2 查找算法代码实现 ```python class TreeNode: def __init__(self, val=0, left=None, right=None): - self.val = val - self.left = left - self.right = right + self.val = val # 节点值 + self.left = left # 左子节点 + self.right = right # 右子节点 class Solution: def searchBST(self, root: TreeNode, val: int) -> TreeNode: + """ + 在二叉搜索树中查找值为 val 的节点 + + 参数: + root: TreeNode,二叉搜索树的根节点 + val: int,待查找的目标值 + 返回: + TreeNode,值为 val 的节点,若未找到则返回 None + """ if not root: - return None - + return None # 空树或查找失败,返回 None + if val == root.val: - return root + return root # 找到目标节点,返回 elif val < root.val: + # 目标值小于当前节点值,递归查找左子树 return self.searchBST(root.left, val) else: + # 目标值大于当前节点值,递归查找右子树 return self.searchBST(root.right, val) ``` ### 2.3 二叉搜索树的查找算法分析 -- 二叉搜索树的查找时间复杂度和树的形态有关。 -- 在最好情况下,二叉搜索树的形态与二分查找的判定树相似。每次查找都可以所辖一半搜索范围。查找路径最多从根节点到叶子节点,比较次数最多为树的高度 $\log_2 n$。在最好情况下查找的时间复杂度为 $O(\log_2 n)$。 -- 在最坏情况下,二叉搜索树的形态为单支树,即只有左子树或者只有右子树。每次查找的搜索范围都缩小为 $n - 1,ドル退化为顺序查找,在最坏情况下时间复杂度为 $O(n)$。 -- 在平均情况下,二叉搜索树的平均查找长度为 $ASL = [(n + 1) / n] * /log_2(n+1) - 1$。所以二分搜索树的查找平均时间复杂度为 $O(log_2 n)$。 +| 指标 | 复杂度 | 说明 | +|--------------|------------------|--------------------------------------------------------------| +| 最优时间 | $O(\log_2 n)$ | 树接近完全平衡,高度为 $h = \log_2 n,ドル每次查找缩小一半范围 | +| 最坏时间 | $O(n)$ | 树退化为单链表,需遍历所有节点 | +| 平均时间 | $O(\log_2 n)$ | 随机插入情况下,平均查找长度约为 $\log_2 n$ | +| 空间复杂度 | $O(1)$ | 递归实现时为 $O(h),ドル迭代实现为 $O(1),ドル$h$ 为树高 | ## 3. 二叉搜索树的插入 -> **二叉搜索树的插入**:在二叉搜索树中插入一个值为 $val$ 的节点(假设当前二叉搜索树中不存在值为 $val$ 的节点)。 +> **二叉搜索树的插入**:在二叉搜索树中插入一个值为 $val$ 的节点(假设当前树中不存在 $val$)。 -### 3.1 二叉搜索树的插入算法步骤 +### 3.1 插入算法步骤 -二叉搜索树的插入操作与二叉树的查找操作过程类似,具体步骤如下: +二叉搜索树的插入过程与查找类似,具体如下: -1. 如果二叉搜索树为空,则创建一个值为 $val$ 的节点,并将其作为二叉搜索树的根节点。 -2. 如果二叉搜索树不为空,则将待插入的值 $val$ 与二叉搜索树根节点的值 $root.val$ 进行比较: - 1. 如果 $val < root.val,ドル则递归将值为 $val$ 的节点插入到左子树中。 - 2. 如果 $val> root.val,ドル则递归将值为 $val$ 的节点插入到右子树中。 +1. 如果当前树为空,直接创建值为 $val$ 的节点,作为根节点返回。 +2. 如果当前树非空,将 $val$ 与当前节点 $root.val$ 比较: + - 如果 $val < root.val,ドル递归插入到左子树。 + - 如果 $val> root.val,ドル递归插入到右子树。 -> **注意**:二叉搜索树不允许存在重复节点,否则将违反其定义。因此,如果带插入节点在树中已存在,则不执行插入操作,直接返回。 +> **注意**:二叉搜索树不允许重复节点。如果 $val$ 已存在于树中,则不插入,直接返回原树。 -### 3.2 二叉搜索树的插入代码实现 +### 3.2 插入算法代码实现 ```python class TreeNode: def __init__(self, val=0, left=None, right=None): - self.val = val - self.left = left - self.right = right + self.val = val # 节点值 + self.left = left # 左子节点 + self.right = right # 右子节点 class Solution: def insertIntoBST(self, root: TreeNode, val: int) -> TreeNode: - if root == None: + """ + 在二叉搜索树中插入一个值为 val 的节点 + + 参数: + root: TreeNode,二叉搜索树的根节点 + val: int,待插入的节点值 + 返回: + TreeNode,插入后的二叉搜索树根节点 + """ + if root is None: + # 当前子树为空,直接创建新节点并返回 return TreeNode(val) if val < root.val: + # 待插入值小于当前节点值,递归插入到左子树 root.left = self.insertIntoBST(root.left, val) - if val> root.val: + elif val> root.val: + # 待插入值大于当前节点值,递归插入到右子树 root.right = self.insertIntoBST(root.right, val) + # 如果 val == root.val,不插入(不允许重复),直接返回原树 return root ``` ## 4. 二叉搜索树的创建 -> **二叉搜索树的创建**:根据数组序列中的元素值,建立一棵二叉搜索树。 +> **二叉搜索树的创建**:根据给定数组中的元素,依次插入,构建出一棵二叉搜索树。 -### 4.1 二叉搜索树的创建算法步骤 +### 4.1 创建算法步骤 -二叉搜索树的创建操作是从空树开始,按照给定数组元素的值,依次进行二叉搜索树的插入操作,最终得到一棵二叉搜索树。具体算法步骤如下: +二叉搜索树的创建通常从一棵空树开始,依次将数组中的每个元素插入到树中,最终形成完整的二叉搜索树。具体步骤如下: -1. 初始化二叉搜索树为空树。 -2. 遍历数组元素,将数组元素值 $nums[i]$ 依次插入到二叉搜索树中。 -3. 将数组中全部元素值插入到二叉搜索树中之后,返回二叉搜索树的根节点。 +1. 初始化根节点为空。 +2. 遍历数组,将每个元素 $nums[i]$ 依次插入到当前的二叉搜索树中。 +3. 所有元素插入完成后,返回二叉搜索树的根节点。 -### 4.2 二叉搜索树的创建代码实现 +### 4.2 创建代码实现 ```python class TreeNode: def __init__(self, val=0, left=None, right=None): - self.val = val - self.left = left - self.right = right + self.val = val # 节点值 + self.left = left # 左子节点 + self.right = right # 右子节点 class Solution: def insertIntoBST(self, root: TreeNode, val: int) -> TreeNode: - if root == None: + """ + 在二叉搜索树中插入一个值为 val 的节点 + + 参数: + root: TreeNode,二叉搜索树的根节点 + val: int,待插入的节点值 + 返回: + TreeNode,插入后的二叉搜索树根节点 + """ + if root is None: + # 当前子树为空,直接创建新节点并返回 return TreeNode(val) - if val < root.val: + # 待插入值小于当前节点值,递归插入到左子树 root.left = self.insertIntoBST(root.left, val) - if val> root.val: + elif val> root.val: + # 待插入值大于当前节点值,递归插入到右子树 root.right = self.insertIntoBST(root.right, val) + # 如果 val == root.val,不插入(不允许重复),直接返回原树 return root + def buildBST(self, nums) -> TreeNode: - root = TreeNode(val) + """ + 根据给定数组 nums 创建一棵二叉搜索树 + + 参数: + nums: List[int],待插入的节点值数组 + 返回: + TreeNode,构建好的二叉搜索树根节点 + """ + root = None # 初始化根节点为空 for num in nums: - self.insertIntoBST(root, num) + root = self.insertIntoBST(root, num) # 依次插入每个元素 return root ``` ## 5. 二叉搜索树的删除 -> **二叉搜索树的删除**:在二叉搜索树中删除值为 $val$ 的节点。 +> **二叉搜索树的删除**:即在二叉搜索树中删除值为 $val$ 的节点。 -### 5.1 二叉搜索树的删除算法步骤 +### 5.1 删除操作算法步骤 -在二叉搜索树中删除元素,首先要找到待删除节点,然后执行删除操作。根据待删除节点所在位置的不同,可以分为 3ドル$ 种情况: +在二叉搜索树中删除节点时,首先需要定位到目标节点,然后根据其子树情况分为三种情形: -1. 被删除节点的左子树为空。则令其右子树代替被删除节点的位置。 -2. 被删除节点的右子树为空。则令其左子树代替被删除节点的位置。 -3. 被删除节点的左右子树均不为空,则根据二叉搜索树的中序遍历有序性,删除该节点时,可以使用其直接前驱(或直接后继)代替被删除节点的位置。 +1. **左子树为空**:用其右子树替代被删除节点的位置。 +2. **右子树为空**:用其左子树替代被删除节点的位置。 +3. **左右子树均不为空**:利用二叉搜索树的有序性,可用「直接前驱」或「直接后继」节点的值替换当前节点,然后递归删除前驱或后继节点。 -- **直接前驱**:在中序遍历中,节点 $p$ 的直接前驱为其左子树的最右侧的叶子节点。 -- **直接后继**:在中序遍历中,节点 $p$ 的直接后继为其右子树的最左侧的叶子节点。 +- **直接前驱**:即左子树中值最大的节点(左子树最右侧节点)。 +- **直接后继**:即右子树中值最小的节点(右子树最左侧节点)。 -二叉搜索树的删除算法步骤如下: +具体删除步骤如下: -1. 如果当前节点为空,则返回当前节点。 -2. 如果当前节点值大于 $val,ドル则递归去左子树中搜索并删除,此时 $root.left$ 也要跟着递归更新。 -3. 如果当前节点值小于 $val,ドル则递归去右子树中搜索并删除,此时 $root.right$ 也要跟着递归更新。 -4. 如果当前节点值等于 $val,ドル则该节点就是待删除节点。 - 1. 如果当前节点的左子树为空,则删除该节点之后,则右子树代替当前节点位置,返回右子树。 - 2. 如果当前节点的右子树为空,则删除该节点之后,则左子树代替当前节点位置,返回左子树。 - 3. 如果当前节点的左右子树都有,则将左子树转移到右子树最左侧的叶子节点位置上,然后右子树代替当前节点位置。 +1. 如果当前节点为空,直接返回。 +2. 如果当前节点值大于 $val,ドル递归在左子树中查找并删除,更新 $root.left$。 +3. 如果当前节点值小于 $val,ドル递归在右子树中查找并删除,更新 $root.right$。 +4. 如果当前节点值等于 $val,ドル即找到目标节点,分三种情况处理: + 1. 如果左子树为空,返回右子树(右子树顶替当前节点)。 + 2. 如果右子树为空,返回左子树(左子树顶替当前节点)。 + 3. 如果左右子树均不为空,将左子树整体接到右子树的最左侧节点下,然后返回右子树作为新的子树根节点。 ### 5.2 二叉搜索树的删除代码实现 @@ -167,28 +214,79 @@ class TreeNode: class Solution: def deleteNode(self, root: TreeNode, val: int) -> TreeNode: + """ + 在二叉搜索树中删除值为 val 的节点,并返回新的根节点 + + 参数: + root: TreeNode,当前子树的根节点 + val: int,待删除的节点值 + 返回: + TreeNode,删除节点后的新根节点 + """ if not root: - return root + # 递归终止条件:未找到目标节点,直接返回 + return None - if root.val> val: + if val < root.val: + # 待删除值小于当前节点,递归去左子树删除 root.left = self.deleteNode(root.left, val) return root - elif root.val < val: + elif val> root.val: + # 待删除值大于当前节点,递归去右子树删除 root.right = self.deleteNode(root.right, val) return root else: + # 找到目标节点,分三种情况处理 if not root.left: + # 情况 1:左子树为空,直接返回右子树 return root.right elif not root.right: + # 情况 2:右子树为空,直接返回左子树 return root.left else: - curr = root.right - while curr.left: - curr = curr.left - curr.left = root.left - return root.right + # 情况 3:左右子树均不为空 + # 找到右子树的最左节点(即后继节点) + successor = root.right + while successor.left: + successor = successor.left + # 用后继节点的值替换当前节点 + root.val = successor.val + # 在右子树中递归删除后继节点 + root.right = self.deleteNode(root.right, successor.val) + return root ``` +## 6. 总结 + +### 6.1 核心特性 + +二叉搜索树(BST)是一种 **有序的二叉树结构**,其核心特性是: +- **左子树所有节点值 < 根节点值 < 右子树所有节点值** +- **中序遍历结果是有序的**(递增序列) +- **每个节点的左右子树也都是二叉搜索树** + +### 6.2 基本操作算法分析 + +| 操作 | 最优时间 | 最坏时间 | 平均时间 | 空间复杂度 | +|------|----------|----------|----------|------------| +| 查找 | O(log n) | O(n) | O(log n) | O(1) | +| 插入 | O(log n) | O(n) | O(log n) | O(1) | +| 删除 | O(log n) | O(n) | O(log n) | O(1) | + +**说明**:最优情况是树接近完全平衡,最坏情况是树退化为单链表。 + +### 6.3 算法特点 + +**优点**: +- 查找、插入、删除效率高(平均 O(log n)) +- 支持范围查询和有序遍历 +- 实现相对简单,易于理解 + +**缺点**: +- 插入顺序影响树的高度和性能 +- 不平衡时可能退化为链表($O(n)$ 复杂度) +- 需要额外的平衡机制(如 AVL 树、红黑树) + ## 练习题目 - [0700. 二叉搜索树中的搜索](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/0700-0799/search-in-a-binary-search-tree.md) diff --git a/docs/05_tree/05_05_segment_tree_01.md b/docs/05_tree/05_05_segment_tree_01.md index 09789901..0b639936 100644 --- a/docs/05_tree/05_05_segment_tree_01.md +++ b/docs/05_tree/05_05_segment_tree_01.md @@ -2,51 +2,49 @@ ### 1.1 线段树的定义 -> **线段树(Segment Tree)**:一种基于分治思想的二叉树,用于在区间上进行信息统计。它的每一个节点都对应一个区间 $[left, right]$ ,$left$、$right$ 通常是整数。每一个叶子节点表示了一个单位区间(长度为 1ドル$),叶子节点对应区间上 $left == right$。每一个非叶子节点 $[left, right]$ 的左子节点表示的区间都为 $[left, (left + right) / 2],ドル右子节点表示的的区间都为 $[(left + right) / 2 + 1, right]$。 +> **线段树(Segment Tree)**:一种用于高效处理区间查询和区间修改的二叉树结构。它将一个区间不断二分,每个节点管理一个区间,叶子节点对应单个元素,内部节点则代表其子区间的合并结果。这样可以在 $O(\log n)$ 时间内完成区间相关操作。 -线段树是一棵平衡二叉树,树上的每个节点维护一个区间。根节点维护的是整个区间,每个节点维护的是父亲节点的区间二等分之后的其中一个子区间。当有 $n$ 个元素时,对区间的操作(单点更新、区间更新、区间查询等)可以在 $O(\log_2n)$ 的时间复杂度内完成。 +线段树就像「区间的管家」:每个节点专管一个区间 $[left, right],ドル叶子节点只负责一个元素($left = right$),非叶子节点则把区间一分为二,左孩子管 $[left, mid],ドル右孩子管 $[mid+1, right],ドル其中 $mid = (left + right) // 2$。整棵树自顶向下分工明确,根节点总揽全局。无论是单点修改、区间修改还是区间查询,都能在 $O(\log n)$ 时间内完成,非常适合处理大规模区间数据。 -如下图所示,这是一棵区间为 $[0, 7]$ 的线段树。 +下图展示了区间 $[0, 7]$ 的线段树结构: ![区间 [0, 7] 对应的线段树](https://qcdn.itcharge.cn/images/20240511173328.png) ### 1.2 线段树的特点 -根据上述描述,我们可以总结一下线段树的特点: +线段树的核心特点如下: -1. 线段树的每个节点都代表一个区间。 -2. 线段树具有唯一的根节点,代表的区间是整个统计范围,比如 $[1, n]$。 -3. 线段树的每个叶子节点都代表一个长度为 1ドル$ 的单位区间 $[x, x]$。 -4. 对于每个内部节点 $[left, right],ドル它的左子节点是 $[left, mid],ドル右子节点是 $[mid + 1, right]$。其中 $mid = (left + right) / 2$(向下取整)。 +1. 每个节点对应一个区间。 +2. 根节点管理整个区间(如 $[1, n]$)。 +3. 叶子节点对应单个元素区间($[x, x]$)。 +4. 每个内部节点 $[left, right]$ 的左子节点为 $[left, mid],ドル右子节点为 $[mid + 1, right],ドル其中 $mid = (left + right) // 2$(向下取整)。 ## 2. 线段树的构建 ### 2.1 线段树的存储结构 -之前我们学习过二叉树的两种存储结构,一种是「链式存储结构」,另一种是「顺序存储结构」。线段树也可以使用这两种存储结构来实现。 +在二叉树中,我们常见的存储方式有「链式存储」和「顺序存储」。线段树同样可以采用这两种方式实现,但由于其结构接近完全二叉树,使用「顺序存储结构」(即数组)更加高效和简洁。 -由于线段树近乎是完全二叉树,所以很适合用「顺序存储结构」来实现。 +线段树的数组存储编号规则如下: -我们可以采用与完全二叉树类似的编号方法来对线段树进行编号,方法如下: +- 根节点编号为 0ドル$。 +- 如果某节点编号为 $i,ドル则其左孩子编号为 2ドル \tiems i + 1,ドル右孩子编号为 2ドル \tiems i + 2$。 +- 如果某节点编号为 $i$(且 $i> 0$),其父节点编号为 $(i - 1) // 2$。 -- 根节点的编号为 0ドル$。 -- 如果某二叉树节点(非叶子节点)的下标为 $i,ドル那么其左孩子节点下标为 2ドル \times i + 1,ドル右孩子节点下标为 2ドル \times i + 2$。 -- 如果某二叉树节点(非根节点)的下标为 $i,ドル那么其父节点下标为 $(i - 1) // 2,ドル$//$ 表示整除。 +这样,我们可以用一个数组来存储整棵线段树。那么数组的大小如何确定呢? -这样我们就能使用一个数组来保存线段树。那么这个数组的大小应该设置为多少才合适? - -- 在理想情况下,$n$ 个单位区间构成的线段树是一棵满二叉树,节点数为 $n + n/2 + n/4 + ... + 2 + 1 = 2 \times n - 1$ 个。 因为 2ドル \times n - 1 < 2 \times n,ドル所以在理想情况下,只需要使用一个大小为 2ドル \times n$ 的数组来存储线段树就足够了。 -- 但是在一般情况下,有些区间元素需要开辟新的一层来存储元素。线段树的深度为 $\lceil \log_2n \rceil,ドル最坏情况下叶子节点(包括无用的节点)的数量为 2ドル^{\lceil \log_2n \rceil}$ 个,总节点数为 2ドル^{\lceil \log_2n \rceil + 1} - 1$ 个,可以近似看做是 4ドル * n,ドル所以我们可以使用一个大小为 4ドル \times n$ 的数组来存储线段树。 +- 理想情况下,$n$ 个叶子节点构成的线段树是一棵满二叉树,总节点数为 2ドル \tiems n - 1$。因此,数组大小取 2ドル \tiems n$ 足够。 +- 但实际上,为了适配任意长度的区间,线段树的深度为 $\lceil \log_2 n \rceil,ドル最坏情况下节点总数约为 2ドル^{\lceil \log_2 n \rceil + 1} - 1,ドル可近似为 4ドル \tiems n$。因此,通常分配 4ドル \tiems n$ 大小的数组即可保证安全。 ### 2.2 线段树的构建方法 ![线段树父子节点下标关系](https://qcdn.itcharge.cn/images/20240511173417.png) -通过上图可知:下标为 $i$ 的节点的孩子节点下标为 2ドル \times i + 1$ 和 2ドル \times i + 2$。所以线段树十分适合采用递归的方法来创建。具体步骤如下: +如上图所示,编号为 $i$ 的节点,其左右孩子编号分别为 2ドル \tiems i + 1$ 和 2ドル \tiems i + 2$。因此,线段树的构建非常适合递归实现。具体步骤如下: -1. 如果是叶子节点($left == right$),则节点的值就是对应位置的元素值。 -2. 如果是非叶子节点,则递归创建左子树和右子树。 -3. 节点的区间值(区间和、区间最大值、区间最小值)等于该节点左右子节点元素值的对应计算结果。 +1. 如果当前区间为叶子节点($left == right$),则节点值为对应元素值。 +2. 如果为非叶子节点,递归构建左、右子树。 +3. 当前节点的区间值(如区间和、最大值、最小值等)由左右子节点的值合并得到。 线段树的构建实现代码如下: @@ -54,277 +52,387 @@ # 线段树的节点类 class TreeNode: def __init__(self, val=0): - self.left = -1 # 区间左边界 - self.right = -1 # 区间右边界 - self.val = val # 节点值(区间值) - self.lazy_tag = None # 区间和问题的延迟更新标记 - - + self.left = -1 # 区间左边界 + self.right = -1 # 区间右边界 + self.val = val # 节点值(区间值,如区间和、区间最大值等) + self.lazy_tag = None # 区间延迟更新标记(如区间加法、区间赋值等懒惰标记) + # 线段树类 class SegmentTree: def __init__(self, nums, function): + """ + :param nums: 原始数据数组 + :param function: 区间聚合函数(如 sum, max, min 等) + """ self.size = len(nums) - self.tree = [TreeNode() for _ in range(4 * self.size)] # 维护 TreeNode 数组 - self.nums = nums # 原始数据 - self.function = function # function 是一个函数,左右区间的聚合方法 + # 线段树最多需要 4 * n 个节点,使用数组存储 + self.tree = [TreeNode() for _ in range(4 * self.size)] + self.nums = nums + self.function = function if self.size> 0: self.__build(0, 0, self.size - 1) - - # 构建线段树,节点的存储下标为 index,节点的区间为 [left, right] + def __build(self, index, left, right): + """ + 递归构建线段树 + :param index: 当前节点在数组中的下标 + :param left: 当前节点管理的区间左端点 + :param right: 当前节点管理的区间右端点 + """ self.tree[index].left = left self.tree[index].right = right - if left == right: # 叶子节点,节点值为对应位置的元素值 + if left == right: + # 叶子节点,直接赋值为原数组对应元素 self.tree[index].val = self.nums[left] return - - mid = left + (right - left) // 2 # 左右节点划分点 - left_index = index * 2 + 1 # 左子节点的存储下标 - right_index = index * 2 + 2 # 右子节点的存储下标 - self.__build(left_index, left, mid) # 递归创建左子树 - self.__build(right_index, mid + 1, right) # 递归创建右子树 - self.__pushup(index) # 向上更新节点的区间值 - - # 向上更新下标为 index 的节点区间值,节点的区间值等于该节点左右子节点元素值的聚合计算结果 + + mid = left + (right - left) // 2 + left_index = index * 2 + 1 # 左子节点下标 + right_index = index * 2 + 2 # 右子节点下标 + self.__build(left_index, left, mid) # 构建左子树 + self.__build(right_index, mid + 1, right) # 构建右子树 + self.__pushup(index) # 更新当前节点的区间值 + def __pushup(self, index): - left_index = index * 2 + 1 # 左子节点的存储下标 - right_index = index * 2 + 2 # 右子节点的存储下标 - self.tree[index].val = self.function(self.tree[left_index].val, self.tree[right_index].val) + """ + 向上更新当前节点的区间值 + :param index: 当前节点在数组中的下标 + """ + left_index = index * 2 + 1 # 左子节点下标 + right_index = index * 2 + 2 # 右子节点下标 + # 当前节点的区间值由左右子节点的区间值聚合得到 + self.tree[index].val = self.function( + self.tree[left_index].val, + self.tree[right_index].val + ) ``` -这里的 `function` 指的是线段树区间合并的聚合方法。可以根据题意进行变化,常见的操作有求和、取最大值、取最小值等等。 +这里的 `function` 参数用于指定线段树在区间合并时所采用的聚合函数。根据具体题目需求,可以灵活传入如求和(sum)、取最大值(max)、取最小值(min)等常见操作,实现不同类型的区间查询。 ## 3. 线段树的基本操作 -线段树的基本操作主要涉及到单点更新、区间查询和区间更新操作。下面我们来进行一一讲解。 +线段树的基本操作包括:单点更新、区间查询和区间更新。下面依次介绍。 -### 3.1 线段树的单点更新 +### 3.1 单点更新 -> **线段树的单点更新**:修改一个元素的值,例如将 $nums[i]$ 修改为 $val$。 +> **单点更新**:将 $nums[i]$ 修改为 $val$。 -我们可以采用递归的方式进行单点更新,具体步骤如下: +递归实现思路如下: -1. 如果是叶子节点,满足 $left == right,ドル则更新该节点的值。 -2. 如果是非叶子节点,则判断应该在左子树中更新,还是应该在右子树中更新。 -3. 在对应的左子树或右子树中更新节点值。 -4. 左右子树更新返回之后,向上更新节点的区间值(区间和、区间最大值、区间最小值等),区间值等于该节点左右子节点元素值的聚合计算结果。 +1. 如果当前节点为叶子节点($left == right$),直接更新其值。 +2. 否则,判断 $i$ 属于左子树还是右子树,递归更新对应子树。 +3. 更新完后,向上合并,重新计算当前节点的区间值。 -线段树的单点更新实现代码如下: +单点更新的代码如下: ```python - # 单点更新,将 nums[i] 更改为 val - def update_point(self, i, val): - self.nums[i] = val - self.__update_point(i, val, 0, 0, self.size - 1) - - # 单点更新,将 nums[i] 更改为 val。节点的存储下标为 index,节点的区间为 [left, right] - def __update_point(self, i, val, index, left, right): - if self.tree[index].left == self.tree[index].right: - self.tree[index].val = val # 叶子节点,节点值修改为 val - return - - mid = left + (right - left) // 2 # 左右节点划分点 - left_index = index * 2 + 1 # 左子节点的存储下标 - right_index = index * 2 + 2 # 右子节点的存储下标 - if i <= mid: # 在左子树中更新节点值 - self.__update_point(i, val, left_index, left, mid) - else: # 在右子树中更新节点值 - self.__update_point(i, val, right_index, mid + 1, right) - self.__pushup(index) # 向上更新节点的区间值 +def update_point(self, i, val): + """ + 单点更新:将原数组 nums[i] 的值修改为 val,并同步更新线段树 + :param i: 需要更新的元素下标 + :param val: 新的值 + """ + self.nums[i] = val # 更新原数组 + self.__update_point(i, val, 0, 0, self.size - 1) # 从根节点递归更新线段树 + +def __update_point(self, i, val, index, left, right): + """ + 递归实现单点更新 + :param i: 需要更新的元素下标 + :param val: 新的值 + :param index: 当前节点在线段树数组中的下标 + :param left: 当前节点管理的区间左端点 + :param right: 当前节点管理的区间右端点 + """ + # 如果到达叶子节点,直接更新节点值 + if self.tree[index].left == self.tree[index].right: + self.tree[index].val = val # 叶子节点,节点值修改为 val + return + + mid = left + (right - left) // 2 # 计算区间中点 + left_index = index * 2 + 1 # 左子节点的下标 + right_index = index * 2 + 2 # 右子节点的下标 + + # 判断 i 属于左子树还是右子树,递归更新 + if i <= mid: + self.__update_point(i, val, left_index, left, mid) # 在左子树中更新 + else: + self.__update_point(i, val, right_index, mid + 1, right) # 在右子树中更新 + + self.__pushup(index) # 向上更新当前节点的区间值 ``` -### 3.2 线段树的区间查询 +### 3.2 区间查询 -> **线段树的区间查询**:查询一个区间为 $[q\underline{\hspace{0.5em}}left, q\underline{\hspace{0.5em}}right]$ 的区间值。 +> **区间查询**:即查询区间 $[q\_left, q\_right]$ 上的区间聚合值(如区间和、区间最值等)。 -我们可以采用递归的方式进行区间查询,具体步骤如下: +区间查询通常采用递归方式实现,具体流程如下: -1. 如果区间 $[q\underline{\hspace{0.5em}}left, q\underline{\hspace{0.5em}}right]$ 完全覆盖了当前节点所在区间 $[left, right]$ ,即 $left \ge q\underline{\hspace{0.5em}}left$ 并且 $right \le q\underline{\hspace{0.5em}}right,ドル则返回该节点的区间值。 -2. 如果区间 $[q\underline{\hspace{0.5em}}left, q\underline{\hspace{0.5em}}right]$ 与当前节点所在区间 $[left, right]$ 毫无关系,即 $right < q\underline{\hspace{0.5em}}left$ 或者 $left> q\underline{\hspace{0.5em}}right,ドル则返回 0ドル$。 -3. 如果区间 $[q\underline{\hspace{0.5em}}left, q\underline{\hspace{0.5em}}right]$ 与当前节点所在区间有交集,则: - 1. 如果区间 $[q\underline{\hspace{0.5em}}left, q\underline{\hspace{0.5em}}right]$ 与左子节点所在区间 $[left, mid]$ 有交集,即 $q\underline{\hspace{0.5em}}left \le mid,ドル则在当前节点的左子树中进行查询并保存查询结果 $res\underline{\hspace{0.5em}}left$。 - 2. 如果区间 $[q\underline{\hspace{0.5em}}left, q\underline{\hspace{0.5em}}right]$ 与右子节点所在区间 $[mid + 1, right]$ 有交集,即 $q\underline{\hspace{0.5em}}right> mid,ドル则在当前节点的右子树中进行查询并保存查询结果 $res\underline{\hspace{0.5em}}right$。 - 3. 最后返回左右子树元素区间值的聚合计算结果。 +1. 如果查询区间 $[q\_left, q\_right]$ 完全覆盖当前节点区间 $[left, right]$(即 $left \ge q\_left$ 且 $right \le q\_right$),直接返回该节点的区间值。 +2. 如果查询区间 $[q\_left, q\_right]$ 与当前节点区间 $[left, right]$ 无交集(即 $right < q\_left$ 或 $left> q\_right$),返回 0ドル$(或聚合运算的单位元)。 +3. 如果两区间有交集,则递归查询左右子区间,并将结果合并: + - 如果 $q\_left \le mid,ドル递归查询左子区间 $[left, mid],ドル记为 $res\_left$。 + - 如果 $q\_right> mid,ドル递归查询右子区间 $[mid+1, right],ドル记为 $res\_right$。 + - 最终返回 $res\_left$ 与 $res\_right$ 的聚合结果。 -线段树的区间查询代码如下: +线段树区间查询的代码如下: ```python - # 区间查询,查询区间为 [q_left, q_right] 的区间值 - def query_interval(self, q_left, q_right): - return self.__query_interval(q_left, q_right, 0, 0, self.size - 1) - - # 区间查询,在线段树的 [left, right] 区间范围中搜索区间为 [q_left, q_right] 的区间值 - def __query_interval(self, q_left, q_right, index, left, right): - if left>= q_left and right <= q_right: # 节点所在区间被 [q_left, q_right] 所覆盖 - return self.tree[index].val # 直接返回节点值 - if right < q_left or left> q_right: # 节点所在区间与 [q_left, q_right] 无关 - return 0 - - self.__pushdown(index) - - mid = left + (right - left) // 2 # 左右节点划分点 - left_index = index * 2 + 1 # 左子节点的存储下标 - right_index = index * 2 + 2 # 右子节点的存储下标 - res_left = 0 # 左子树查询结果 - res_right = 0 # 右子树查询结果 - if q_left <= mid: # 在左子树中查询 - res_left = self.__query_interval(q_left, q_right, left_index, left, mid) - if q_right> mid: # 在右子树中查询 - res_right = self.__query_interval(q_left, q_right, right_index, mid + 1, right) - return self.function(res_left, res_right) # 返回左右子树元素值的聚合计算结果 +# 区间查询,查询区间 [q_left, q_right] 的区间聚合值 +def query_interval(self, q_left, q_right): + """ + 查询区间 [q_left, q_right] 的区间聚合值(如区间和、区间最值等) + + :param q_left: 查询区间左端点 + :param q_right: 查询区间右端点 + :return: 区间 [q_left, q_right] 的聚合值 + """ + return self.__query_interval(q_left, q_right, 0, 0, self.size - 1) + +# 区间查询的递归实现 +def __query_interval(self, q_left, q_right, index, left, right): + """ + 递归查询线段树节点 [left, right] 区间与查询区间 [q_left, q_right] 的交集部分的聚合值 + + :param q_left: 查询区间左端点 + :param q_right: 查询区间右端点 + :param index: 当前节点在线段树数组中的下标 + :param left: 当前节点管理的区间左端点 + :param right: 当前节点管理的区间右端点 + :return: 区间 [q_left, q_right] 与 [left, right] 的交集部分的聚合值 + """ + # 情况 1:当前节点区间被查询区间完全覆盖,直接返回节点值 + if left>= q_left and right <= q_right: + return self.tree[index].val + # 情况 2:当前节点区间与查询区间无交集,返回单位元(如区间和为 0,区间最小值为正无穷等) + if right < q_left or left> q_right: + return 0 + + # 情况 3:当前节点区间与查询区间有部分重叠,递归查询左右子区间 + self.__pushdown(index) # 下推懒惰标记,保证子节点信息正确 + + mid = left + (right - left) // 2 # 计算区间中点 + left_index = index * 2 + 1 # 左子节点下标 + right_index = index * 2 + 2 # 右子节点下标 + res_left = 0 # 左子树查询结果初始化 + res_right = 0 # 右子树查询结果初始化 + if q_left <= mid: # 查询区间与左子区间有交集 + res_left = self.__query_interval(q_left, q_right, left_index, left, mid) + if q_right> mid: # 查询区间与右子区间有交集 + res_right = self.__query_interval(q_left, q_right, right_index, mid + 1, right) + return self.function(res_left, res_right) # 合并左右子树结果并返回 ``` -### 3.3 线段树的区间更新 +### 3.3 区间更新 -> **线段树的区间更新**:对 $[q\underline{\hspace{0.5em}}left, q\underline{\hspace{0.5em}}right]$ 区间进行更新,例如将 $[q\underline{\hspace{0.5em}}left, q\underline{\hspace{0.5em}}right]$ 区间内所有元素都更新为 $val$。 +> **区间更新**:即将区间 $[q\underline{\hspace{0.5em}}left, q\underline{\hspace{0.5em}}right]$ 内所有元素批量修改为 $val$。 -#### 3.3.1 延迟标记 +#### 3.3.1 延迟标记(懒惰标记) -线段树在进行单点更新、区间查询时,区间 $[q\underline{\hspace{0.5em}}left, q\underline{\hspace{0.5em}}right]$ 在线段树上会被分成 $O(\log_2n)$ 个小区间(节点),从而在 $O(\log_2n)$ 的时间复杂度内完成操作。 +线段树的区间更新如果每次都递归到所有被覆盖的叶子节点,复杂度会退化为 $O(n)$。为避免无用的重复更新,线段树引入了 **延迟标记**(懒惰标记):当某个节点区间 $[left, right]$ 被更新区间 $[q\underline{\hspace{0.5em}}left, q\underline{\hspace{0.5em}}right]$ 完全覆盖时,只需直接更新该节点的值,并打上延迟标记,表示其子节点尚未被真正更新。只有在后续递归访问到子节点时,才将更新操作「下推」到子节点。 -而在「区间更新」操作中,如果某个节点区间 $[left, right]$ 被修改区间 $[q\underline{\hspace{0.5em}}left, q\underline{\hspace{0.5em}}right]$ 完全覆盖,则以该节点为根的整棵子树中所有节点的区间值都要发生变化,如果逐一进行更新的话,将使得一次区间更新操作的时间复杂度增加到 $O(n)$。 +这样,区间更新和区间查询的时间复杂度都能保持 $O(\log_2 n)$。 -设想这一种情况:如果我们在一次执行更新操作时,发现当前节点区间 $[left, right]$ 被修改区间 $[q\underline{\hspace{0.5em}}left, q\underline{\hspace{0.5em}}right]$ 完全覆盖,然后逐一更新了区间 $[left, right]$ 对应子树中的所有节点,但是在后续的区间查询操作中却根本没有用到 $[left, right]$ 作为候选答案,则更新 $[left, right]$ 对应子树的工作就是徒劳的。 +区间更新的主要步骤如下: -如果我们减少更新的次数和时间复杂度,应该怎么办? +1. 如果 $[q\underline{\hspace{0.5em}}left, q\underline{\hspace{0.5em}}right]$ 完全覆盖当前节点区间 $[left, right],ドル则直接更新当前节点的值,并设置延迟标记。 +2. 如果 $[q\underline{\hspace{0.5em}}left, q\underline{\hspace{0.5em}}right]$ 与 $[left, right]$ 无交集,直接返回。 +3. 若有部分重叠,先将当前节点的延迟标记下推到子节点(如果有),然后递归更新左右子区间,最后更新当前节点的值。 -我们可以向线段树的节点类中增加一个 **「延迟标记」**,标识为 **「该区间曾经被修改为 $val,ドル但其子节点区间值尚未更新」**。也就是说除了在进行区间更新时,将区间子节点的更新操作延迟到 **「在后续操作中递归进入子节点时」** 再执行。这样一来,每次区间更新和区间查询的时间复杂度都降低到了 $O(\log_2n)$。 +#### 3.3.2 下推延迟标记 -使用「延迟标记」的区间更新步骤为: +当节点有延迟标记时,需要将该标记下推到左右子节点,具体做法: -1. 如果区间 $[q\underline{\hspace{0.5em}}left, q\underline{\hspace{0.5em}}right]$ 完全覆盖了当前节点所在区间 $[left, right]$ ,即 $left \ge q\underline{\hspace{0.5em}}left$ 并且 $right \le q\underline{\hspace{0.5em}}right,ドル则更新当前节点所在区间的值,并将当前节点的延迟标记为区间值。 -2. 如果区间 $[q\underline{\hspace{0.5em}}left, q\underline{\hspace{0.5em}}right]$ 与当前节点所在区间 $[left, right]$ 毫无关系,即 $right < q\underline{\hspace{0.5em}}left$ 或者 $left> q\underline{\hspace{0.5em}}right,ドル则直接返回。 -3. 如果区间 $[q\underline{\hspace{0.5em}}left, q\underline{\hspace{0.5em}}right]$ 与当前节点所在区间有交集,则: - 1. 如果当前节点使用了「延迟标记」,即延迟标记不为 $None,ドル则将当前区间的更新操作应用到该节点的子节点上(即向下更新)。 - 2. 如果区间 $[q\underline{\hspace{0.5em}}left, q\underline{\hspace{0.5em}}right]$ 与左子节点所在区间 $[left, mid]$ 有交集,即 $q\underline{\hspace{0.5em}}left \le mid,ドル则在当前节点的左子树中更新区间值。 - 3. 如果区间 $[q\underline{\hspace{0.5em}}left, q\underline{\hspace{0.5em}}right]$ 与右子节点所在区间 $[mid + 1, right]$ 有交集,即 $q\underline{\hspace{0.5em}}right> mid,ドル则在当前节点的右子树中更新区间值。 - 4. 左右子树更新返回之后,向上更新节点的区间值(区间和、区间最大值、区间最小值),区间值等于该节点左右子节点元素值的对应计算结果。 +1. 将左子节点的值和懒惰标记更新为 $val$。 +2. 将右子节点的值和懒惰标记更新为 $val$。 +3. 清除当前节点的懒惰标记。 -#### 3.3.2 向下更新 +这样可以保证每个节点的更新操作只在必要时才真正执行,极大提升效率。 -上面提到了如果当前节点使用了「延迟标记」,即延迟标记不为 $None,ドル则将当前区间的更新操作应用到该节点的子节点上(即向下更新)。这里描述一下向下更新的具体步骤: +#### 3.3.3 区间赋值操作(延迟标记) -1. 更新左子节点值和左子节点懒惰标记为 $val$。 -2. 更新右子节点值和右子节点懒惰标记为 $val$。 -3. 将当前节点的懒惰标记更新为 $None$。 - -使用「延迟标记」的区间更新实现代码如下: +使用延迟标记实现区间赋值的代码如下: ```python - # 区间更新,将区间为 [q_left, q_right] 上的元素值修改为 val - def update_interval(self, q_left, q_right, val): - self.__update_interval(q_left, q_right, val, 0, 0, self.size - 1) - - # 区间更新 - def __update_interval(self, q_left, q_right, val, index, left, right): - - if left>= q_left and right <= q_right: # 节点所在区间被 [q_left, q_right] 所覆盖 - interval_size = (right - left + 1) # 当前节点所在区间大小 - self.tree[index].val = interval_size * val # 当前节点所在区间每个元素值改为 val - self.tree[index].lazy_tag = val # 将当前节点的延迟标记为区间值 - return - if right < q_left or left> q_right: # 节点所在区间与 [q_left, q_right] 无关 - return 0 - - self.__pushdown(index) - - mid = left + (right - left) // 2 # 左右节点划分点 - left_index = index * 2 + 1 # 左子节点的存储下标 - right_index = index * 2 + 2 # 右子节点的存储下标 - if q_left <= mid: # 在左子树中更新区间值 - self.__update_interval(q_left, q_right, val, left_index, left, mid) - if q_right> mid: # 在右子树中更新区间值 - self.__update_interval(q_left, q_right, val, right_index, mid + 1, right) - - self.__pushup(index) - - # 向下更新下标为 index 的节点所在区间的左右子节点的值和懒惰标记 - def __pushdown(self, index): - lazy_tag = self.tree[index].lazy_tag - if not lazy_tag: - return - - left_index = index * 2 + 1 # 左子节点的存储下标 - right_index = index * 2 + 2 # 右子节点的存储下标 - - self.tree[left_index].lazy_tag = lazy_tag # 更新左子节点懒惰标记 - left_size = (self.tree[left_index].right - self.tree[left_index].left + 1) - self.tree[left_index].val = lazy_tag * left_size # 更新左子节点值 - - self.tree[right_index].lazy_tag = lazy_tag # 更新右子节点懒惰标记 - right_size = (self.tree[right_index].right - self.tree[right_index].left + 1) - self.tree[right_index].val = lazy_tag * right_size # 更新右子节点值 - - self.tree[index].lazy_tag = None # 更新当前节点的懒惰标记 + +def update_interval(self, q_left, q_right, val): + """ + 对区间 [q_left, q_right] 进行区间赋值操作,将该区间内所有元素修改为 val + """ + self.__update_interval(q_left, q_right, val, 0, 0, self.size - 1) + +def __update_interval(self, q_left, q_right, val, index, left, right): + """ + 递归实现区间赋值更新 + 参数说明: + q_left, q_right: 待更新的目标区间 + val: 赋值的目标值 + index: 当前节点在线段树数组中的下标 + left, right: 当前节点所表示的区间范围 + """ + # 情况 1:当前节点区间被 [q_left, q_right] 完全覆盖,直接更新并打懒惰标记 + if left>= q_left and right <= q_right: + interval_size = (right - left + 1) # 当前区间长度 + self.tree[index].val = interval_size * val # 区间所有元素赋值为 val + self.tree[index].lazy_tag = val # 打上懒惰标记 + return + # 情况 2:当前节点区间与 [q_left, q_right] 无交集,直接返回 + if right < q_left or left> q_right: + return + + # 情况 3:部分重叠,先下推懒惰标记,再递归更新左右子区间 + self.__pushdown(index) + + mid = left + (right - left) // 2 # 区间中点 + left_index = index * 2 + 1 # 左子节点下标 + right_index = index * 2 + 2 # 右子节点下标 + if q_left <= mid: # 左子区间有交集 + self.__update_interval(q_left, q_right, val, left_index, left, mid) + if q_right> mid: # 右子区间有交集 + self.__update_interval(q_left, q_right, val, right_index, mid + 1, right) + + self.__pushup(index) # 回溯时更新当前节点的值 + + +def __pushdown(self, index): + """ + 将当前节点的懒惰标记下推到左右子节点,并更新子节点的值 + """ + lazy_tag = self.tree[index].lazy_tag + if lazy_tag is None: + return + + left_index = index * 2 + 1 # 左子节点下标 + right_index = index * 2 + 2 # 右子节点下标 + + # 更新左子节点的懒惰标记和值 + self.tree[left_index].lazy_tag = lazy_tag + left_size = self.tree[left_index].right - self.tree[left_index].left + 1 + self.tree[left_index].val = lazy_tag * left_size + + # 更新右子节点的懒惰标记和值 + self.tree[right_index].lazy_tag = lazy_tag + right_size = self.tree[right_index].right - self.tree[right_index].left + 1 + self.tree[right_index].val = lazy_tag * right_size + + # 清除当前节点的懒惰标记 + self.tree[index].lazy_tag = None ``` -> **注意**:有些题目中不是将 $[q\underline{\hspace{0.5em}}left, q\underline{\hspace{0.5em}}right]$ 区间更新为 $val,ドル而是将 $[q\underline{\hspace{0.5em}}left, q\underline{\hspace{0.5em}}right]$ 区间中每一个元素值在原值基础增加或减去 $val$。 -> -> 对于这种情况,我们可以更改一下「延迟标记」的定义。改变为: **「该区间曾经变化了 $val,ドル但其子节点区间值尚未更新」**。并更改对应的代码逻辑。 +### 3.3.4 区间加减操作(延迟标记) + +有些题目要求将区间 $[q\_left, q\_right]$ 内每个元素在原有基础上增加或减少 $val,ドル而不是直接赋值为 $val$。 + +针对这种情况,我们需要重新定义「延迟标记」的含义:即表示当前区间整体增加了 $val,ドル但该操作尚未下传到子区间。相应地,代码实现也要相应调整以支持区间加减操作的延迟更新。 -使用「延迟标记」的区间增减更新实现代码如下: +以下是基于延迟标记的区间加减操作代码: ```python - # 区间更新,将区间为 [q_left, q_right] 上的元素值修改为 val - def update_interval(self, q_left, q_right, val): - self.__update_interval(q_left, q_right, val, 0, 0, self.size - 1) - - # 区间更新 - def __update_interval(self, q_left, q_right, val, index, left, right): - - if left>= q_left and right <= q_right: # 节点所在区间被 [q_left, q_right] 所覆盖 -# interval_size = (right - left + 1) # 当前节点所在区间大小 -# self.tree[index].val = interval_size * val # 当前节点所在区间每个元素值改为 val -# self.tree[index].lazy_tag = val # 将当前节点的延迟标记为区间值 - - if self.tree[index].lazy_tag: - self.tree[index].lazy_tag += val # 将当前节点的延迟标记增加 val - else: - self.tree[index].lazy_tag = val # 将当前节点的延迟标记增加 val - interval_size = (right - left + 1) # 当前节点所在区间大小 - self.tree[index].val += val * interval_size # 当前节点所在区间每个元素值增加 val - return - if right < q_left or left> q_right: # 节点所在区间与 [q_left, q_right] 无关 - return 0 - - self.__pushdown(index) - - mid = left + (right - left) // 2 # 左右节点划分点 - left_index = index * 2 + 1 # 左子节点的存储下标 - right_index = index * 2 + 2 # 右子节点的存储下标 - if q_left <= mid: # 在左子树中更新区间值 - self.__update_interval(q_left, q_right, val, left_index, left, mid) - if q_right> mid: # 在右子树中更新区间值 - self.__update_interval(q_left, q_right, val, right_index, mid + 1, right) - - self.__pushup(index) - - # 向下更新下标为 index 的节点所在区间的左右子节点的值和懒惰标记 - def __pushdown(self, index): - lazy_tag = self.tree[index].lazy_tag - if not lazy_tag: - return - - left_index = index * 2 + 1 # 左子节点的存储下标 - right_index = index * 2 + 2 # 右子节点的存储下标 - - if self.tree[left_index].lazy_tag: - self.tree[left_index].lazy_tag += lazy_tag # 更新左子节点懒惰标记 - else: - self.tree[left_index].lazy_tag = lazy_tag - left_size = (self.tree[left_index].right - self.tree[left_index].left + 1) - self.tree[left_index].val += lazy_tag * left_size # 左子节点每个元素值增加 lazy_tag - - if self.tree[right_index].lazy_tag: - self.tree[right_index].lazy_tag += lazy_tag # 更新右子节点懒惰标记 +# 区间更新,将区间 [q_left, q_right] 上的所有元素增加 val +def update_interval(self, q_left, q_right, val): + """ + 对区间 [q_left, q_right] 内的所有元素增加 val + """ + self.__update_interval(q_left, q_right, val, 0, 0, self.size - 1) + +def __update_interval(self, q_left, q_right, val, index, left, right): + """ + 递归实现区间加法更新 + 参数: + q_left, q_right: 待更新的区间范围 + val: 增加的值 + index: 当前节点在线段树数组中的下标 + left, right: 当前节点所表示的区间范围 + """ + # 情况 1:当前节点区间被 [q_left, q_right] 完全覆盖,直接打懒惰标记并更新区间和 + if left>= q_left and right <= q_right: + interval_size = right - left + 1 # 当前节点区间长度 + if self.tree[index].lazy_tag is not None: + self.tree[index].lazy_tag += val # 累加懒惰标记 else: - self.tree[right_index].lazy_tag = lazy_tag - right_size = (self.tree[right_index].right - self.tree[right_index].left + 1) - self.tree[right_index].val += lazy_tag * right_size # 右子节点每个元素值增加 lazy_tag - - self.tree[index].lazy_tag = None # 更新当前节点的懒惰标记 + self.tree[index].lazy_tag = val # 新建懒惰标记 + self.tree[index].val += val * interval_size # 区间和增加 + return + + # 情况2:当前节点区间与 [q_left, q_right] 无交集,直接返回 + if right < q_left or left> q_right: + return + + # 情况3:部分重叠,先下推懒惰标记,再递归更新左右子区间 + self.__pushdown(index) + + mid = left + (right - left) // 2 + left_index = index * 2 + 1 + right_index = index * 2 + 2 + if q_left <= mid: + self.__update_interval(q_left, q_right, val, left_index, left, mid) + if q_right> mid: + self.__update_interval(q_left, q_right, val, right_index, mid + 1, right) + + self.__pushup(index) # 回溯时更新当前节点的区间和 + +def __pushdown(self, index): + """ + 将当前节点的懒惰标记下推到左右子节点,并同步更新子节点的区间和 + """ + lazy_tag = self.tree[index].lazy_tag + if lazy_tag is None: + return + + left_index = index * 2 + 1 + right_index = index * 2 + 2 + + # 处理左子节点 + if self.tree[left_index].lazy_tag is not None: + self.tree[left_index].lazy_tag += lazy_tag + else: + self.tree[left_index].lazy_tag = lazy_tag + left_size = self.tree[left_index].right - self.tree[left_index].left + 1 + self.tree[left_index].val += lazy_tag * left_size + + # 处理右子节点 + if self.tree[right_index].lazy_tag is not None: + self.tree[right_index].lazy_tag += lazy_tag + else: + self.tree[right_index].lazy_tag = lazy_tag + right_size = self.tree[right_index].right - self.tree[right_index].left + 1 + self.tree[right_index].val += lazy_tag * right_size + + # 清除当前节点的懒惰标记 + self.tree[index].lazy_tag = None ``` +## 4. 总结 + +### 4.1 核心要点 + +- 线段树通过对区间反复二分,让每个节点维护一个子区间的聚合信息(如和、最值)。 +- 使用数组顺序存储,容量通常取约 `4 * n`,查询/更新沿树高进行。 +- 引入懒惰标记后,区间更新无需遍历到所有叶子,保持对数级复杂度。 +- 聚合函数可配置(sum/max/min/自定义),需满足可结合性以支持自底向上合并。 + +### 4.2 复杂度分析 + +| 操作 | 最优时间 | 最坏时间 | 平均时间 | 空间复杂度 | 稳定性 | +|------|----------|----------|----------|------------|--------| +| 构建 | O(n) | O(n) | O(n) | O(n) | 不涉及 | +| 单点更新 | O(log n) | O(log n) | O(log n) | O(1)(递归为 O(log n) 栈) | 不涉及 | +| 区间查询 | O(log n) | O(log n) | O(log n) | O(1)(递归为 O(log n) 栈) | 不涉及 | +| 区间更新(含懒标) | O(log n) | O(log n) | O(log n) | O(1)(递归为 O(log n) 栈) | 不涉及 | + +说明:如果不使用懒惰标记,区间更新在最坏情况下会退化为 O(n)。 + +### 4.3 算法特点 + +**优点**: +- 区间查询与区间更新效率高(均为 O(log n))。 +- 适配多种聚合函数,扩展性强。 +- 支持动态数据的在线维护。 + +**缺点**: +- 实现复杂度与常数因子较大,代码易错。 +- 对聚合函数有约束(需可结合),不适合不可结合的运算。 +- 多维线段树实现复杂,内存与常数进一步增大;对于简单前缀和/仅单点更新的场景,树状数组往往更简洁高效。 ## 练习题目 - [线段树题目列表](https://github.com/ITCharge/AlgoNote/tree/main/docs/00_preface/00_06_categories_list.md#%E7%BA%BF%E6%AE%B5%E6%A0%91%E9%A2%98%E7%9B%AE) diff --git a/docs/05_tree/05_06_segment_tree_02.md b/docs/05_tree/05_06_segment_tree_02.md index 7851b63f..7fd84e73 100644 --- a/docs/05_tree/05_06_segment_tree_02.md +++ b/docs/05_tree/05_06_segment_tree_02.md @@ -1,190 +1,222 @@ -## 1. 线段树的常见题型 +## 1. 线段树常见题型 -### 1.1 RMQ 问题 +线段树是一种高效的数据结构,常用于处理区间相关的查询与修改。以下是线段树常见的几类题型及其简要说明: -> **RMQ 问题**:Range Maximum / Minimum Query 的缩写,指的是对于长度为 $n$ 的数组序列 $nums,ドル回答若干个询问问题 `RMQ(nums, q_left, q_right)`,要求返回数组序列 $nums$ 在区间 $[q\underline{\hspace{0.5em}}left, q\underline{\hspace{0.5em}}right]$ 中的最大(最小)值。也就是求区间最大(最小)值问题。 +### 1.1 区间最大 / 最小值查询(RMQ) -假设查询次数为 $q,ドル则使用朴素算法解决 RMQ 问题的时间复杂度为 $O(q \times n)$。而使用线段树解决 RMQ 问题的时间复杂度为 $O(q \times n) \sim Q(q \times \log_2n)$ 之间。 +> **RMQ(Range Maximum / Minimum Query)问题**:给定长度为 $n$ 的数组 $nums,ドル多次询问区间 $[q_{left}, q_{right}]$ 内的最大值或最小值。 -### 1.2 单点更新,区间查询问题 +假设有 $q$ 次查询,朴素算法每次需遍历区间,整体时间复杂度为 $O(q \times n)$;而采用线段树后,每次查询仅需 $O(\log n),ドル总复杂度降为 $O(q \times \log n),ドル大大提升了效率。 -> **单点更新,区间查询问题**: +### 1.2 单点更新与区间查询 + +> **单点更新与区间查询问题**: > -> 1. 修改某一个元素的值。 -> 2. 查询区间为 $[q\underline{\hspace{0.5em}}left, q\underline{\hspace{0.5em}}right]$ 的区间值。 +> 1. 支持对数组中某一元素进行修改(单点更新)。 +> 2. 支持查询任意区间 $[q_{left}, q_{right}]$ 的聚合值(如区间和、最大/最小值等)。 -这类问题直接使用「3.1 线段树的单点更新」和「3.2 线段树的区间查询」即可解决。 +这类问题直接使用「5.5 线段树(一)」中的「3.1 单点更新」和「3.2 区间查询」即可解决。 -### 1.3 区间更新,区间查询问题 +### 1.3 区间更新与区间查询 -> **区间更新,区间查询问题**: +> **区间更新与区间查询问题**: > -> 1. 修改某一个区间的值。 -> 2. 查询区间为 $[q\underline{\hspace{0.5em}}left, q\underline{\hspace{0.5em}}right]$ 的区间值。 +> 1. 支持对某一连续区间的所有元素进行批量修改(区间更新)。 +> 2. 支持查询任意区间 $[q_{left}, q_{right}]$ 的聚合值(如区间和、最大/最小值等)。 -这类问题直接使用「3.3 线段树的区间更新」和「3.2 线段树的区间查询」即可解决。 +此类问题直接使用「5.5 线段树(一)」中的「3.3 区间更新」与「3.2 区间查询」即可解决。 -### 1.4 区间合并问题 +### 1.4 区间合并与区间查询 -> **区间合并,区间查询问题**: +> **区间合并与区间查询问题**: > -> 1. 修改某一个区间的值。 -> 2. 查询区间为 $[q\underline{\hspace{0.5em}}left, q\underline{\hspace{0.5em}}right]$ 中满足条件的连续最长区间值。 +> 1. 支持对某一连续区间的所有元素进行批量修改(区间更新)。 +> 2. 支持查询区间 $[q_{left}, q_{right}]$ 内,满足特定条件的连续最长子区间(如最长连续 1、最长连续递增/递减等)。 -这类问题需要在「3.3 线段树的区间更新」和「3.2 线段树的区间查询」的基础上增加变动,在进行向上更新时需要对左右子节点的区间进行合并。 +这类问题在解决时,需在「5.5 线段树(一)」中「3.3 区间更新」和「3.2 区间查询」的基础上,扩展每个节点维护的信息。例如,节点需额外记录区间内的前缀/后缀/最大连续长度等统计量。在向上合并时,需根据左右子节点的这些信息进行合并计算,从而支持高效的区间合并与查询操作。 ### 1.5 扫描线问题 -> **扫描线问题**:虚拟扫描线或扫描面来解决欧几里德空间中的各种问题,一般被用来解决图形面积,周长等问题。 +> **扫描线问题**:通过模拟一条虚拟的扫描线(通常为垂直或水平线)在平面上移动,动态处理与其相交的几何对象,从而高效解决如图形面积、周长等几何统计问题。 > -> 主要思想为:想象一条线(通常是一条垂直线)在平面上扫过或移动,在某些点停止。几何操作仅限于几何对象,无论何时停止,它们都与扫描线相交或紧邻扫描线,并且一旦线穿过所有对象,就可以获得完整的解。 +> 核心思想是:让扫描线从一端出发,依次经过所有关键事件点(如矩形的边界),每到一个事件点时,更新与扫描线相交的区间集合,并据此统计所需信息。随着扫描线的推进,所有对象都被处理,最终得到完整解答。 -这类问题通常坐标跨度很大,需要先对每条扫描线的坐标进行离散化处理,将 $y$ 坐标映射到 0,ドル 1, 2, ...$ 中。然后将每条竖线的端点作为区间范围,使用线段树存储每条竖线的信息($x$ 坐标、是左竖线还是右竖线等),然后再进行区间合并,并统计相关信息。 +这类问题往往涉及大范围的坐标区间,因此通常需要对坐标进行离散化(如将 $y$ 坐标映射为 0,ドル 1, 2, \ldots$),以便用线段树等数据结构高效维护区间信息。具体做法是:将每条竖线(或水平线)的端点作为区间边界,利用线段树动态维护区间的覆盖情况(如 $x$ 坐标、左/右边界等),在扫描过程中实时合并区间并统计相关量(如总覆盖长度、重叠次数等)。 ## 2. 线段树的拓展 ### 2.1 动态开点线段树 -在有些情况下,线段树需要维护的区间很大(例如 $[1, 10^9]$),在实际中用到的节点却很少。 +在某些场景下,线段树需要维护的区间范围极大(如 $[1, 10^9]$),但实际被访问和修改的节点却非常有限。 -如果使用之前数组形式实现线段树,则需要 4ドル \times n$ 大小的空间,空间消耗有点过大了。 +如果仍采用传统的数组实现方式,则需要分配 4ドル \times n$ 的空间,导致空间浪费严重,效率低下。 -这时候我们就可以使用动态开点的思想来构建线段树。 +为了解决这一问题,可以采用 **动态开点** 的线段树实现思路: -动态开点线段树的算法思想如下: +- 初始时仅创建一个根节点,表示整个区间。 +- 只有在访问或修改到某个子区间时,才动态地为该区间分配节点。 -- 开始时只建立一个根节点,代表整个区间。 -- 当需要访问线段树的某棵子树(某个子区间)时,再建立代表这个子区间的节点。 +这种方式极大地节省了空间,仅为实际需要的区间分配内存,适合处理稀疏访问、超大区间的问题。 -动态开点线段树实现代码如下: +动态开点线段树的基本实现如下: ```python -# 线段树的节点类 +# 动态开点线段树节点类 class TreeNode: def __init__(self, left=-1, right=-1, val=0): self.left = left # 区间左边界 self.right = right # 区间右边界 - self.mid = left + (right - left) // 2 - self.leftNode = None # 区间左节点 - self.rightNode = None # 区间右节点 - self.val = val # 节点值(区间值) - self.lazy_tag = None # 区间问题的延迟更新标记 - - -# 线段树类 + self.mid = left + (right - left) // 2 # 区间中点 + self.leftNode = None # 左子节点 + self.rightNode = None # 右子节点 + self.val = val # 区间聚合值 + self.lazy_tag = None # 懒惰标记(延迟更新) + +# 动态开点线段树 class SegmentTree: def __init__(self, function): - self.tree = TreeNode(0, int(1e9)) - self.function = function # function 是一个函数,左右区间的聚合方法 - - # 向上更新 node 节点区间值,节点的区间值等于该节点左右子节点元素值的聚合计算结果 + self.tree = TreeNode(0, int(1e9)) # 根节点,维护区间 [0, 1e9] + self.function = function # 区间聚合函数(如 sum, max, min) + def __pushup(self, node): + """ + 向上更新当前节点的区间值,由左右子节点聚合得到 + """ leftNode = node.leftNode rightNode = node.rightNode if leftNode and rightNode: node.val = self.function(leftNode.val, rightNode.val) - - # 单点更新,将 nums[i] 更改为 val + elif leftNode: + node.val = leftNode.val + elif rightNode: + node.val = rightNode.val + # 如果左右子节点都不存在,val 保持不变 + def update_point(self, i, val): + """ + 单点更新:将下标 i 的元素修改为 val + """ self.__update_point(i, val, self.tree) - - # 单点更新,将 nums[i] 更改为 val。node 节点的区间为 [node.left, node.right] + def __update_point(self, i, val, node): + """ + 递归实现单点更新 + """ if node.left == node.right: - node.val = val # 叶子节点,节点值修改为 val + node.val = val # 叶子节点,直接赋值 + node.lazy_tag = None # 清除懒惰标记 return - - if i <= node.mid: # 在左子树中更新节点值 + + self.__pushdown(node) # 下推懒惰标记,保证更新正确 + + if i <= node.mid: if not node.leftNode: node.leftNode = TreeNode(node.left, node.mid) self.__update_point(i, val, node.leftNode) - else: # 在右子树中更新节点值 + else: if not node.rightNode: node.rightNode = TreeNode(node.mid + 1, node.right) self.__update_point(i, val, node.rightNode) - self.__pushup(node) # 向上更新节点的区间值 - - # 区间查询,查询区间为 [q_left, q_right] 的区间值 + self.__pushup(node) # 向上更新 + def query_interval(self, q_left, q_right): + """ + 区间查询:[q_left, q_right] 区间的聚合值 + """ return self.__query_interval(q_left, q_right, self.tree) - - # 区间查询,在线段树的 [left, right] 区间范围中搜索区间为 [q_left, q_right] 的区间值 + def __query_interval(self, q_left, q_right, node): - if node.left>= q_left and node.right <= q_right: # 节点所在区间被 [q_left, q_right] 所覆盖 - return node.val # 直接返回节点值 - if node.right < q_left or node.left> q_right: # 节点所在区间与 [q_left, q_right] 无关 + """ + 递归实现区间查询 + """ + if node.left> q_right or node.right < q_left: + # 当前节点区间与查询区间无交集 return 0 - - self.__pushdown(node) # 向下更新节点所在区间的左右子节点的值和懒惰标记 - - res_left = 0 # 左子树查询结果 - res_right = 0 # 右子树查询结果 - if q_left <= node.mid: # 在左子树中查询 + if node.left>= q_left and node.right <= q_right: + # 当前节点区间被查询区间完全覆盖 + return node.val + + self.__pushdown(node) # 下推懒惰标记 + + res_left = 0 + res_right = 0 + if q_left <= node.mid: if not node.leftNode: node.leftNode = TreeNode(node.left, node.mid) res_left = self.__query_interval(q_left, q_right, node.leftNode) - if q_right> node.mid: # 在右子树中查询 + if q_right> node.mid: if not node.rightNode: node.rightNode = TreeNode(node.mid + 1, node.right) res_right = self.__query_interval(q_left, q_right, node.rightNode) return self.function(res_left, res_right) # 返回左右子树元素值的聚合计算结果 - - # 区间更新,将区间为 [q_left, q_right] 上的元素值修改为 val + def update_interval(self, q_left, q_right, val): + """ + 区间更新:将 [q_left, q_right] 区间内所有元素增加 val + """ self.__update_interval(q_left, q_right, val, self.tree) - - # 区间更新 + def __update_interval(self, q_left, q_right, val, node): - if node.left>= q_left and node.right <= q_right: # 节点所在区间被 [q_left, q_right] 所覆盖 - if node.lazy_tag: - node.lazy_tag += val # 将当前节点的延迟标记增加 val + """ + 递归实现区间更新(区间加法) + """ + if node.left> q_right or node.right < q_left: + # 当前节点区间与更新区间无交集 + return + + if node.left>= q_left and node.right <= q_right: + # 当前节点区间被更新区间完全覆盖 + interval_size = node.right - node.left + 1 + if node.lazy_tag is not None: + node.lazy_tag += val else: - node.lazy_tag = val # 将当前节点的延迟标记增加 val - interval_size = (node.right - node.left + 1) # 当前节点所在区间大小 - node.val += val * interval_size # 当前节点所在区间每个元素值增加 val + node.lazy_tag = val + node.val += val * interval_size return - if node.right < q_left or node.left> q_right: # 节点所在区间与 [q_left, q_right] 无关 - return 0 - - self.__pushdown(node) # 向下更新节点所在区间的左右子节点的值和懒惰标记 - - if q_left <= node.mid: # 在左子树中更新区间值 + + self.__pushdown(node) # 下推懒惰标记 + + if q_left <= node.mid: if not node.leftNode: node.leftNode = TreeNode(node.left, node.mid) self.__update_interval(q_left, q_right, val, node.leftNode) - if q_right> node.mid: # 在右子树中更新区间值 + if q_right> node.mid: if not node.rightNode: node.rightNode = TreeNode(node.mid + 1, node.right) self.__update_interval(q_left, q_right, val, node.rightNode) - - self.__pushup(node) - - # 向下更新 node 节点所在区间的左右子节点的值和懒惰标记 + + self.__pushup(node) # 向上更新 + def __pushdown(self, node): - lazy_tag = node.lazy_tag - if not node.lazy_tag: + """ + 懒惰标记下推:将当前节点的延迟更新传递给左右子节点 + """ + if node.lazy_tag is None: return - + + # 动态创建左右子节点 if not node.leftNode: node.leftNode = TreeNode(node.left, node.mid) if not node.rightNode: node.rightNode = TreeNode(node.mid + 1, node.right) - - if node.leftNode.lazy_tag: - node.leftNode.lazy_tag += lazy_tag # 更新左子节点懒惰标记 + + # 更新左子节点 + left_size = node.leftNode.right - node.leftNode.left + 1 + if node.leftNode.lazy_tag is not None: + node.leftNode.lazy_tag += node.lazy_tag else: - node.leftNode.lazy_tag = lazy_tag # 更新左子节点懒惰标记 - left_size = (node.leftNode.right - node.leftNode.left + 1) - node.leftNode.val += lazy_tag * left_size # 左子节点每个元素值增加 lazy_tag - - if node.rightNode.lazy_tag: - node.rightNode.lazy_tag += lazy_tag # 更新右子节点懒惰标记 + node.leftNode.lazy_tag = node.lazy_tag + node.leftNode.val += node.lazy_tag * left_size + + # 更新右子节点 + right_size = node.rightNode.right - node.rightNode.left + 1 + if node.rightNode.lazy_tag is not None: + node.rightNode.lazy_tag += node.lazy_tag else: - node.rightNode.lazy_tag = lazy_tag # 更新右子节点懒惰标记 - right_size = (node.rightNode.right - node.rightNode.left + 1) - node.rightNode.val += lazy_tag * right_size # 右子节点每个元素值增加 lazy_tag - - node.lazy_tag = None # 更新当前节点的懒惰标记 + node.rightNode.lazy_tag = node.lazy_tag + node.rightNode.val += node.lazy_tag * right_size + + node.lazy_tag = None # 清除当前节点的懒惰标记 ``` ## 练习题目 diff --git a/docs/05_tree/05_08_union_find.md b/docs/05_tree/05_08_union_find.md index ce0f3a3b..81459e07 100644 --- a/docs/05_tree/05_08_union_find.md +++ b/docs/05_tree/05_08_union_find.md @@ -2,342 +2,438 @@ ### 1.1 并查集的定义 -> **并查集(Union Find)**:一种树型的数据结构,用于处理一些不交集(Disjoint Sets)的合并及查询问题。不交集指的是一系列没有重复元素的集合。 +> **并查集(Union Find)**:一种高效的数据结构,常用于处理若干不相交集合(Disjoint Sets)的合并与查询操作。不相交集合指的是元素互不重叠的集合族。 > -> 并查集主要支持两种操作: +> 并查集主要支持两类核心操作: > -> - **合并(Union)**:将两个集合合并成一个集合。 -> - **查找(Find)**:确定某个元素属于哪个集合。通常是返回集合内的一个「代表元素」。 +> - **合并(Union)**:将两个不同的集合合并为一个集合。 +> - **查找(Find)**:确定某个元素属于哪个集合,通常返回该集合的「代表元素」。 -简单来说,并查集就是用来处理集合的合并和集合的查询。 +简而言之,并查集用于高效地管理集合的合并与成员归属查询。 -- 并查集中的「集」指的就是我们初中所学的集合概念,在这里指的是不相交的集合,即一系列没有重复元素的集合。 -- 并查集中的「并」指的就是集合的并集操作,将两个集合合并之后就变成一个集合。合并操作如下所示: +- 并查集中的「集」指的是不相交的集合,即元素互不重复、互不重叠的若干集合。 +- 并查集中的「并」指的是集合的并集操作,即将两个不同的集合合并为一个更大的集合。合并操作如下: ```python {1, 3, 5, 7} U {2, 4, 6, 8} = {1, 2, 3, 4, 5, 6, 7, 8} ``` -- 并查集中的「查」是对于集合中存放的元素来说的,通常我们需要查询两个元素是否属于同一个集合。 +- 并查集中的「查」操作,主要用于判断两个元素是否属于同一个集合。 -如果我们只是想知道一个元素是否在集合中,可以通过 Python 或其他语言中的 `set` 集合来解决。而如果我们想知道两个元素是否属于同一个集合,则仅用一个 `set` 集合就很难做到了。这就需要用到我们接下来要讲解的「并查集」结构。 +如果只是判断某个元素是否在集合中,直接用 Python 的 `set` 类型即可。但如果要 **高效判断两个元素是否属于同一集合**,`set` 就不适合了,因为它只能判断单个元素是否存在,无法快速判断两个元素是否在同一个集合里,往往需要遍历所有集合,效率很低。此时,就需要用专门的并查集结构,才能高效地支持集合的合并和连通性查询。 -根据上文描述,我们就可以定义一下「并查集」结构所支持的操作接口: +基于上述需求,并查集通常支持以下核心操作接口: -- **合并 `union(x, y)`**:将集合 $x$ 和集合 $y$ 合并成一个集合。 -- **查找 `find(x)`**:查找元素 $x$ 属于哪个集合。 -- **查找 `is_connected(x, y)`**:查询元素 $x$ 和 $y$ 是否在同一个集合中。 +- **合并 `union(x, y)`**:将包含元素 $x$ 和 $y$ 的两个集合合并为一个集合。 +- **查找 `find(x)`**:查找元素 $x$ 所在集合的代表元素(根节点)。 +- **连通性判断 `is_connected(x, y)`**:判断元素 $x$ 和 $y$ 是否属于同一个集合。 ### 1.2 并查集的两种实现思路 -下面我们来讲解一下并查集的两种实现思路:一种是使用「快速查询」思路、基于数组结构实现的并查集;另一种是使用「快速合并」思路、基于森林实现的并查集。 +并查集常见的两种实现方式分别侧重于不同操作的效率:一种是「快速查询」——基于数组结构,另一种是「快速合并」——基于森林结构。 #### 1.2.1 快速查询:基于数组实现 -如果我们希望并查集的查询效率高一些,那么我们就可以侧重于查询操作。 +当我们更关注查询操作的效率时,可以采用基于数组的实现方式。 -在使用「快速查询」思路实现并查集时,我们可以使用一个「数组结构」来表示集合中的元素。数组元素和集合元素是一一对应的,我们可以将数组的索引值作为每个元素的集合编号,称为 $id$。然后可以对数组进行以下操作来实现并查集: +在这种实现中,使用一个数组来表示每个元素所属的集合。数组的下标代表元素本身,数组的值($id$)表示该元素所在集合的编号。具体操作如下: -- **当初始化时**:将数组下标索引值作为每个元素的集合编号。所有元素的 $id$ 都是唯一的,代表着每个元素单独属于一个集合。 -- **合并操作时**:需要将其中一个集合中的所有元素 $id$ 更改为另一个集合中的 $id,ドル这样能够保证在合并后一个集合中所有元素的 $id$ 均相同。 -- **查找操作时**:如果两个元素的 $id$ 一样,则说明它们属于同一个集合;如果两个元素的 $id$ 不一样,则说明它们不属于同一个集合。 +- **初始化**:将每个元素的集合编号设为其自身的下标,即每个元素自成一个集合。 +- **合并操作**:将一个集合中的所有元素的 $id$ 修改为另一个集合的 $id,ドル从而实现集合的合并。这样,合并后同一集合内所有元素的 $id$ 都相同。 +- **查找操作**:直接比较两个元素的 $id$ 是否相同,若相同则属于同一集合,否则属于不同集合。 -举个例子来说明一下,我们使用数组来表示一系列集合元素 $\left\{ 0 \right\}, \left\{ 1 \right\}, \left\{ 2 \right\}, \left\{ 3 \right\}, \left\{ 4 \right\}, \left\{ 5 \right\}, \left\{ 6 \right\}, \left\{ 7 \right\},ドル初始化时如下图所示。 +举例说明,假设有集合 $\left\{ 0 \right\}, \left\{ 1 \right\}, \left\{ 2 \right\}, \left\{ 3 \right\}, \left\{ 4 \right\}, \left\{ 5 \right\}, \left\{ 6 \right\}, \left\{ 7 \right\},ドル初始化如下: ![基于数组实现:初始化操作](https://qcdn.itcharge.cn/images/20240513150949.png) -从上图中可以看出:数组的每个下标索引值对应一个元素的集合编号,代表着每个元素单独属于一个集合。 +如上图所示,数组下标即为元素编号,初始时每个元素单独成集。 -当我们进行一系列的合并操作后,比如合并后变为 $\left\{ 0 \right\}, \left\{ 1, 2, 3 \right\}, \left\{ 4 \right\}, \left\{5, 6\right\}, \left\{ 7 \right\},ドル合并操作的结果如下图所示。 +经过若干次合并操作后,例如合并成 $\left\{ 0 \right\}, \left\{ 1, 2, 3 \right\}, \left\{ 4 \right\}, \left\{5, 6\right\}, \left\{ 7 \right\},ドル结果如下: ![基于数组实现:合并操作](https://qcdn.itcharge.cn/images/20240513151310.png) -从上图中可以看出,在进行一系列合并操作后,下标为 1ドル$、2ドル$、3ドル$ 的元素集合编号是一致的,说明这 3ドル$ 个元素同属于一个集合。同理下标为 5ドル$ 和 6ドル$ 的元素则同属于另一个集合。 +可以看到,1ドル$、2ドル$、3ドル$ 的 $id$ 相同,说明它们属于同一集合;5ドル$ 和 6ドル$ 也同理。 -在快速查询的实现思路中,单次查询操作的时间复杂度是 $O(1),ドル而单次合并操作的时间复杂度为 $O(n)$(每次合并操作需要遍历数组)。两者的时间复杂度相差得比较大,完全牺牲了合并操作的性能。因此,这种并查集的实现思路并不常用。 +这种实现方式下,查询操作的时间复杂度为 $O(1),ドル但合并操作的时间复杂度为 $O(n)$(每次合并都需遍历整个数组)。因此,虽然查询极快,但合并效率较低,实际应用中较少采用。 -- 使用「快速查询」思路实现并查集代码如下所示: +- 基于「快速查询」思路的并查集代码如下: ```python class UnionFind: - def __init__(self, n): # 初始化:将每个元素的集合编号初始化为数组下标索引 - self.ids = [i for i in range(n)] - - def find(self, x): # 查找元素所属集合编号内部实现方法 + def __init__(self, n): + """ + 初始化并查集,将每个元素的集合编号初始化为其自身下标。 + :param n: 元素总数 + """ + self.ids = [i for i in range(n)] # ids[i] 表示元素 i 所在集合的编号 + + def find(self, x): + """ + 查找元素 x 所在集合的编号。 + :param x: 元素编号 + :return: x 所在集合的编号 + """ return self.ids[x] - def union(self, x, y): # 合并操作:将集合 x 和集合 y 合并成一个集合 + def union(self, x, y): + """ + 合并包含元素 x 和 y 的两个集合。 + :param x: 元素 x + :param y: 元素 y + :return: 如果 x 和 y 原本就在同一集合,返回 False;否则合并并返回 True + """ x_id = self.find(x) y_id = self.find(y) - - if x_id == y_id: # x 和 y 已经同属于一个集合 + + if x_id == y_id: + # x 和 y 已经在同一个集合,无需合并 return False - - for i in range(len(self.ids)): # 将两个集合的集合编号改为一致 + + # 遍历所有元素,将属于 y_id 集合的元素编号改为 x_id,实现合并 + for i in range(len(self.ids)): if self.ids[i] == y_id: self.ids[i] = x_id return True - def is_connected(self, x, y): # 查询操作:判断 x 和 y 是否同属于一个集合 + def is_connected(self, x, y): + """ + 判断元素 x 和 y 是否属于同一个集合。 + :param x: 元素 x + :param y: 元素 y + :return: 如果属于同一集合返回 True,否则返回 False + """ return self.find(x) == self.find(y) ``` #### 1.2.2 快速合并:基于森林实现 -因为快速查询的实现思路中,合并操作的效率比较低。所以我们现在的重点是提高合并操作的效率。 - -在使用「快速合并」思路实现并查集时,我们可以使用「一个森林(若干棵树)」来存储所有集合。每一棵树代表一个集合,树上的每个节点都是一个元素,树根节点为这个集合的代表元素。 +在「快速查询」的实现方式中,合并操作效率较低,因此我们需要优化合并操作的性能。 -> **注意**:与普通的树形结构(父节点指向子节点)不同的是,基于森林实现的并查集中,树中的子节点是指向父节点的。 +为此,可以采用「森林结构」来实现并查集。具体做法是:用若干棵树(即森林)来表示所有集合,每棵树代表一个集合,树中的每个节点对应一个元素,树的根节点即为该集合的代表元素。 -此时,我们仍然可以使用一个数组 $fa$ 来记录这个森林。我们用 $fa[x]$ 来保存 $x$ 的父节点的集合编号,代表着元素节点 $x$ 指向父节点 $fa[x]$。 +> **注意**:与常规树结构(父节点指向子节点)不同,基于森林的并查集中,每个节点都指向其父节点。 -当初始化时,$fa[x]$ 值赋值为下标索引 $x$。在进行合并操作时,只需要将两个元素的树根节点相连接(`fa[root1] = root2`)即可。而在进行查询操作时,只需要查看两个元素的树根节点是否一致,就能知道两个元素是否属于同一个集合。 +我们可以用一个数组 $fa$ 来维护森林结构,其中 $fa[x]$ 表示元素 $x$ 的父节点编号。也就是说,$x$ 通过 $fa[x]$ 指向其父节点。 -总结一下,我们可以对数组 $fa$ 进行以下操作来实现并查集: +- **初始化**:令 $fa[x] = x,ドル即每个元素自成一个集合,自己是自己的根节点。 +- **合并操作**:将两个集合的根节点相连,例如令 $fa[root1] = root2,ドル即把 $root1$ 所在集合合并到 $root2$ 所在集合。 +- **查找操作**:从某个元素出发,沿着 $fa$ 数组不断查找其父节点,直到找到根节点。若两个元素的根节点相同,则它们属于同一集合,否则属于不同集合。 -- **当初始化时**:将数组 $fa$​ 的下标索引作为每个元素的集合编号。所有元素的根节点的集合编号都不一样,代表着每个元素单独属于一个集合。 -- **合并操作时**:需要将两个集合的树根节点相连接。即令其中一个集合的树根节点指向另一个集合的树根节点(`fa[root1] = root2`),这样合并后当前集合中的所有元素的树根节点均为同一个。 -- **查找操作时**:分别从两个元素开始,通过数组 $fa$ 存储的值,不断递归访问元素的父节点,直到到达树根节点。如果两个元素的树根节点一样,则说明它们属于同一个集合;如果两个元素的树根节点不一样,则说明它们不属于同一个集合。 - -举个例子来说明一下,我们使用数组来表示一系列集合元素 $\left\{0\right\}, \left\{ 1 \right\}, \left\{ 2 \right\}, \left\{ 3 \right\}, \left\{ 4 \right\}, \left\{ 5 \right\}, \left\{ 6 \right\}, \left\{ 7 \right\},ドル初始化时如下图所示。 +举例说明,假设有集合 $\left\{0\right\}, \left\{1\right\}, \left\{2\right\}, \left\{3\right\}, \left\{4\right\}, \left\{5\right\}, \left\{6\right\}, \left\{7\right\},ドル初始化时如下图: ![基于森林实现:初始化操作](https://qcdn.itcharge.cn/images/20240513151548.png) 从上图中可以看出:$fa$ 数组的每个下标索引值对应一个元素的集合编号,代表着每个元素属于一个集合。 -当我们进行一系列的合并操作后,比如 `union(4, 5)`、`union(6, 7)`、`union(4, 7)` 操作后变为 $\left\{ 0 \right\}, \left\{ 1 \right\}, \left\{ 2 \right\}, \left\{ 3 \right\}, \left\{ 4, 5, 6, 7 \right\}$​,合并操作的步骤及结果如下图所示。 +接下来,依次执行 `union(4, 5)`、`union(6, 7)`、`union(4, 7)`,最终集合变为 $\left\{0\right\}, \left\{1\right\}, \left\{2\right\}, \left\{3\right\}, \left\{4, 5, 6, 7\right\},ドル具体步骤如下: ::: tabs#union @tab <1> -- 合并 $(4, 5)$:令 4ドル$ 的根节点指向 5ドル,ドル即将 $fa[4]$ 更改为 5ドル$。 +- 合并 $(4, 5)$:将 4ドル$ 的根节点指向 5ドル,ドル即 $fa[4] = 5$。 ![基于森林实现:合并操作 1](https://qcdn.itcharge.cn/images/20240513154015.png) @tab <2> -- 合并 $(6, 7)$:令 6ドル$ 的根节点指向 7ドル,ドル即将 $fa[6]$ 更改为 7ドル$。 +- 合并 $(6, 7)$:将 6ドル$ 的根节点指向 7ドル,ドル即 $fa[6] = 7$。 ![基于森林实现:合并操作 2](https://qcdn.itcharge.cn/images/20240513154022.png) @tab <3> -- 合并 $(4, 7)$:令 4ドル$ 的的根节点指向 7ドル,ドル即将 $fa[fa[4]]$(也就是 $fa[5]$)更改为 7ドル$。 +- 合并 $(4, 7)$:将 4ドル$ 的根节点(即 $fa[4] = 5$)指向 7ドル,ドル即 $fa[fa[4]] = fa[5] = 7$。 ![基于森林实现:合并操作 3](https://qcdn.itcharge.cn/images/20240513154030.png) ::: -从上图中可以看出,在进行一系列合并操作后,`fa[fa[4]] == fa[5] == fa[6] == f[7]`,即 4ドル$、5ドル$、6ドル$、7ドル$ 的元素根节点编号都是 4ドル,ドル说明这 4ドル$ 个元素同属于一个集合。 +可以看到,经过上述合并后,4ドル$、5ドル$、6ドル$、7ドル$ 的根节点编号都为 7ドル,ドル说明它们已经属于同一个集合。 -- 使用「快速合并」思路实现并查集代码如下所示: +- 基于「快速合并」思想的并查集代码如下: ```python class UnionFind: - def __init__(self, n): # 初始化:将每个元素的集合编号初始化为数组 fa 的下标索引 - self.fa = [i for i in range(n)] - - def find(self, x): # 查找元素根节点的集合编号内部实现方法 - while self.fa[x] != x: # 递归查找元素的父节点,直到根节点 + def __init__(self, n): + """ + 初始化并查集,将每个元素的父节点初始化为自身 + :param n: 元素个数 + """ + self.fa = [i for i in range(n)] # fa[x] 表示 x 的父节点,初始时每个节点自成一个集合 + + def find(self, x): + """ + 查找元素 x 所在集合的根节点(代表元) + :param x: 待查找的元素 + :return: x 所在集合的根节点编号 + """ + # 循环查找父节点,直到找到根节点(fa[x] == x) + while self.fa[x] != x: x = self.fa[x] - return x # 返回元素根节点的集合编号 - - def union(self, x, y): # 合并操作:令其中一个集合的树根节点指向另一个集合的树根节点 + return x + + def union(self, x, y): + """ + 合并 x 和 y 所在的两个集合 + :param x: 元素 x + :param y: 元素 y + :return: 如果 x 和 y 原本属于同一集合,返回 False;否则合并并返回 True + """ root_x = self.find(x) root_y = self.find(y) - if root_x == root_y: # x 和 y 的根节点集合编号相同,说明 x 和 y 已经同属于一个集合 + if root_x == root_y: + # x 和 y 已经在同一个集合中,无需合并 return False - self.fa[root_x] = root_y # x 的根节点连接到 y 的根节点上,成为 y 的根节点的子节点 + self.fa[root_x] = root_y # 将 x 的根节点连接到 y 的根节点 return True - def is_connected(self, x, y): # 查询操作:判断 x 和 y 是否同属于一个集合 + def is_connected(self, x, y): + """ + 判断 x 和 y 是否属于同一个集合 + :param x: 元素 x + :param y: 元素 y + :return: 如果属于同一集合返回 True,否则返回 False + """ return self.find(x) == self.find(y) ``` ## 2. 路径压缩 -在集合很大或者树很不平衡时,使用上述「快速合并」思路实现并查集的代码效率很差,最坏情况下,树会退化成一条链,单次查询的时间复杂度高达 $O(n)$。并查集的最坏情况如下图所示。 +当集合规模较大或树结构极度不平衡时,单纯依赖「快速合并」的并查集实现效率较低。在最坏情况下,树会退化为一条链,此时单次查找操作的时间复杂度为 $O(n),ドル如下图所示: ![并查集最坏情况](https://qcdn.itcharge.cn/images/20240513154732.png) -为了避免出现最坏情况,一个常见的优化方式是「路径压缩」。 +为了提升效率、避免上述最坏情况,常用的优化手段是「路径压缩」。 -> **路径压缩(Path Compression)**:在从底向上查找根节点过程中,如果此时访问的节点不是根节点,则我们可以把这个节点尽量向上移动一下,从而减少树的层树。这个过程就叫做路径压缩。 +> **路径压缩(Path Compression)**:在查找根节点的过程中,将路径上经过的所有节点尽量直接挂到根节点下,从而显著降低树的高度,提高后续操作的效率。 -路径压缩有两种方式:一种叫做「隔代压缩」;另一种叫做「完全压缩」。 +路径压缩主要有两种常见实现方式:一种是「隔代压缩」,另一种是「完全压缩」。 ### 2.1 隔代压缩 -> **隔代压缩**:在查询时,两步一压缩,一直循环执行「把当前节点指向它的父亲节点的父亲节点」这样的操作,从而减小树的深度。 +> **隔代压缩**:在查找操作时,每次将当前节点直接连接到其父节点的父节点(即跳过一层),通过不断重复这一过程,有效降低树的高度,从而提升并查集的查找效率。 -下面是一个「隔代压缩」的例子。 +如下图所示,展示了隔代压缩的过程: ![路径压缩:隔代压缩](https://qcdn.itcharge.cn/images/20240513154745.png) -- 隔代压缩的查找代码如下: +隔代压缩的查找代码如下: ```python -def find(self, x): # 查找元素根节点的集合编号内部实现方法 - while self.fa[x] != x: # 递归查找元素的父节点,直到根节点 - self.fa[x] = self.fa[self.fa[x]] # 隔代压缩 - x = self.fa[x] - return x # 返回元素根节点的集合编号 +def find(self, x): + """ + 查找元素 x 所在集合的根节点(带隔代路径压缩) + :param x: 待查找的元素 + :return: x 所在集合的根节点编号 + """ + while self.fa[x] != x: + # 将 x 的父节点直接指向其祖父节点,实现隔代压缩 + self.fa[x] = self.fa[self.fa[x]] + x = self.fa[x] # 继续向上查找 + return x # 返回根节点编号 ``` ### 2.2 完全压缩 -> **完全压缩**:在查询时,把被查询的节点到根节点的路径上的所有节点的父节点设置为根节点,从而减小树的深度。也就是说,在向上查询的同时,把在路径上的每个节点都直接连接到根上,以后查询时就能直接查询到根节点。 +> **完全压缩**:在查找操作时,将从当前节点到根节点路径上的所有节点的父节点都直接指向根节点,从而极大地降低树的高度。这样,后续对这些节点的查找都能一步到达根节点,显著提升效率。 -相比较于「隔代压缩」,「完全压缩」压缩的更加彻底。下面是一个「完全压缩」的例子。 +与「隔代压缩」相比,「完全压缩」能够更彻底地扁平化树结构。如下图所示: ![路径压缩:完全压缩](https://qcdn.itcharge.cn/images/20240513154759.png) -- 完全压缩的查找代码如下: +完全压缩的查找代码如下: ```python -def find(self, x): # 查找元素根节点的集合编号内部实现方法 - if self.fa[x] != x: # 递归查找元素的父节点,直到根节点 - self.fa[x] = self.find(self.fa[x]) # 完全压缩优化 - return self.fa[x] +def find(self, x): + """ + 查找元素 x 所在集合的根节点(带完全路径压缩) + :param x: 待查找的元素 + :return: x 所在集合的根节点编号 + """ + if self.fa[x] != x: # 如果 x 不是根节点,递归查找其父节点 + self.fa[x] = self.find(self.fa[x]) # 路径压缩:将 x 直接连接到根节点 + return self.fa[x] # 返回根节点编号 ``` ## 3. 按秩合并 -因为路径压缩只在查询时进行,并且只压缩一棵树上的路径,所以并查集最终的结构仍然可能是比较复杂的。为了避免这种情况,另一个优化方式是「按秩合并」。 +虽然路径压缩能够有效降低树的高度,但它只在查找操作时生效,且仅影响当前查找路径上的节点。因此,若仅依赖路径压缩,整个并查集的结构仍可能出现较高的树。为进一步优化并查集的结构,常用的另一种方法是「按秩合并」。 -> **按秩合并(Union By Rank)**:指的是在每次合并操作时,都把「秩」较小的树根节点指向「秩」较大的树根节点。 +> **按秩合并(Union By Rank)**:在每次合并操作时,总是将「秩」较小的树的根节点连接到「秩」较大的树的根节点下。 -这里的「秩」有两种定义,一种定义指的是树的深度;另一种定义指的是树的大小(即集合节点个数)。无论采用哪种定义,集合的秩都记录在树的根节点上。 +这里的「秩」可以有两种常见定义:一种是树的深度,另一种是集合的大小(即节点个数)。无论采用哪种定义,秩的信息都只需记录在每棵树的根节点上。 -按秩合并也有两种方式:一种叫做「按深度合并」;另一种叫做「按大小合并」。 +按秩合并主要有两种实现方式:一种是「按深度合并」,另一种是「按大小合并」。 ### 3.1 按深度合并 -> **按深度合并(Unoin By Rank)**:在每次合并操作时,都把「深度」较小的树根节点指向「深度」较大的树根节点。 +> **按深度合并(Union By Rank)**:每次合并时,将「深度」较小的树的根节点指向「深度」较大的树的根节点。 -我们用一个数组 $rank$ 记录每个根节点对应的树的深度(如果不是根节点,其 $rank$ 值相当于以它作为根节点的子树的深度)。 +具体做法是,使用一个数组 $rank$ 记录每个根节点对应的树的深度(非根节点的 $rank$ 值无实际意义,仅根节点有效)。 -初始化时,将所有元素的 $rank$ 值设为 1ドル$。在合并操作时,比较两个根节点,把 $rank$ 值较小的根节点指向 $rank$ 值较大的根节点上合并。 +初始化时,所有元素的 $rank$ 值设为 1ドル$。合并时,比较两个集合根节点的 $rank,ドル将 $rank$ 较小的根节点指向 $rank$ 较大的根节点。如果两棵树深度相同,任选一方作为新根,并将其 $rank$ 加 1ドル$。 -下面是一个「按深度合并」的例子。 +如下图所示为「按深度合并」的示意: ![按秩合并:按深度合并](https://qcdn.itcharge.cn/images/20240513154814.png) -- 按深度合并的实现代码如下: +按深度合并的实现代码如下: ```python class UnionFind: - def __init__(self, n): # 初始化 - self.fa = [i for i in range(n)] # 每个元素的集合编号初始化为数组 fa 的下标索引 - self.rank = [1 for i in range(n)] # 每个元素的深度初始化为 1 - - def find(self, x): # 查找元素根节点的集合编号内部实现方法 - while self.fa[x] != x: # 递归查找元素的父节点,直到根节点 - self.fa[x] = self.fa[self.fa[x]] # 隔代压缩 + def __init__(self, n): + """ + 初始化并查集 + :param n: 元素个数 + """ + self.fa = [i for i in range(n)] # fa[i] 表示元素 i 的父节点,初始时每个元素自成一个集合 + self.rank = [1 for _ in range(n)] # rank[i] 表示以 i 为根的树的深度,初始为 1 + + def find(self, x): + """ + 查找元素 x 所在集合的根节点(带路径压缩,隔代压缩) + :param x: 待查找的元素 + :return: x 所在集合的根节点编号 + """ + while self.fa[x] != x: # 如果 x 不是根节点,继续查找其父节点 + self.fa[x] = self.fa[self.fa[x]]# 路径压缩:将 x 直接连接到祖父节点,实现隔代压缩 x = self.fa[x] - return x # 返回元素根节点的集合编号 - - def union(self, x, y): # 合并操作:令其中一个集合的树根节点指向另一个集合的树根节点 + return x # 返回根节点编号 + + def union(self, x, y): + """ + 合并操作:将 x 和 y 所在的集合合并 + :param x: 元素 x + :param y: 元素 y + :return: 如果合并成功返回 True,若已在同一集合返回 False + """ root_x = self.find(x) root_y = self.find(y) - if root_x == root_y: # x 和 y 的根节点集合编号相同,说明 x 和 y 已经同属于一个集合 + if root_x == root_y: # x 和 y 已经在同一个集合 return False - - if self.rank[root_x] < self.rank[root_y]: # x 的根节点对应的树的深度 小于 y 的根节点对应的树的深度 - self.fa[root_x] = root_y # x 的根节点连接到 y 的根节点上,成为 y 的根节点的子节点 - elif self.rank[root_y]> self.rank[root_y]: # x 的根节点对应的树的深度 大于 y 的根节点对应的树的深度 - self.fa[root_y] = root_x # y 的根节点连接到 x 的根节点上,成为 x 的根节点的子节点 - else: # x 的根节点对应的树的深度 等于 y 的根节点对应的树的深度 - self.fa[root_x] = root_y # 向任意一方合并即可 - self.rank[root_y] += 1 # 因为层数相同,被合并的树必然层数会 +1 + + # 按秩合并:将深度较小的树合并到深度较大的树下 + if self.rank[root_x] < self.rank[root_y]: + self.fa[root_x] = root_y # x 的根节点连接到 y 的根节点 + elif self.rank[root_x]> self.rank[root_y]: + self.fa[root_y] = root_x # y 的根节点连接到 x 的根节点 + else: + self.fa[root_x] = root_y # 深度相同,任选一方作为新根 + self.rank[root_y] += 1 # 新根的深度加 1 return True - def is_connected(self, x, y): # 查询操作:判断 x 和 y 是否同属于一个集合 + def is_connected(self, x, y): + """ + 查询操作:判断 x 和 y 是否属于同一个集合 + :param x: 元素 x + :param y: 元素 y + :return: 如果属于同一集合返回 True,否则返回 False + """ return self.find(x) == self.find(y) ``` ### 3.2 按大小合并 -> **按大小合并(Unoin By Size)**:这里的大小指的是集合节点个数。在每次合并操作时,都把「集合节点个数」较少的树根节点指向「集合节点个数」较大的树根节点。 +> **按大小合并(Union By Size)**:此处的「大小」指的是集合中节点的数量。每次合并时,总是将节点数较少的集合的根节点指向节点数较多的集合的根节点,从而有效控制树的高度。 -我们用一个数组 $size$ 记录每个根节点对应的集合节点个数(如果不是根节点,其 $size$ 值相当于以它作为根节点的子树的集合节点个数)。 +具体做法是,使用一个数组 $size$ 记录每个根节点所代表集合的节点个数(对于非根节点,$size$ 的值无实际意义,仅根节点的 $size$ 有效)。 -初始化时,将所有元素的 $size$ 值设为 1ドル$。在合并操作时,比较两个根节点,把 $size$ 值较小的根节点指向 $size$ 值较大的根节点上合并。 +初始化时,所有元素各自为一个集合,因此 $size$ 均为 1ドル$。合并操作时,先分别找到两个元素的根节点,比较它们的 $size,ドル将较小集合的根节点连接到较大集合的根节点,并更新新根节点的 $size$。 -下面是一个「按大小合并」的例子。 +如下图所示为按大小合并的示意: ![按秩合并:按大小合并](https://qcdn.itcharge.cn/images/20240513154835.png) -- 按大小合并的实现代码如下: +按大小合并的实现代码如下: ```python class UnionFind: - def __init__(self, n): # 初始化 - self.fa = [i for i in range(n)] # 每个元素的集合编号初始化为数组 fa 的下标索引 - self.size = [1 for i in range(n)] # 每个元素的集合个数初始化为 1 - - def find(self, x): # 查找元素根节点的集合编号内部实现方法 - while self.fa[x] != x: # 递归查找元素的父节点,直到根节点 - self.fa[x] = self.fa[self.fa[x]] # 隔代压缩优化 + def __init__(self, n): + """ + 初始化并查集 + :param n: 元素个数 + """ + self.fa = [i for i in range(n)] # fa[i] 表示元素 i 的父节点,初始时每个元素自成一个集合 + self.size = [1 for _ in range(n)] # size[i] 表示以 i 为根的集合的元素个数,初始为 1 + + def find(self, x): + """ + 查找元素 x 所在集合的根节点(带隔代路径压缩) + :param x: 待查找的元素 + :return: x 所在集合的根节点编号 + """ + while self.fa[x] != x: + self.fa[x] = self.fa[self.fa[x]] # 隔代路径压缩,将 x 直接连接到祖父节点 x = self.fa[x] - return x # 返回元素根节点的集合编号 - - def union(self, x, y): # 合并操作:令其中一个集合的树根节点指向另一个集合的树根节点 + return x + + def union(self, x, y): + """ + 合并操作:将 x 和 y 所在的集合合并(按集合大小合并) + :param x: 元素 x + :param y: 元素 y + :return: 如果合并成功返回 True,若已在同一集合返回 False + """ root_x = self.find(x) root_y = self.find(y) - if root_x == root_y: # x 和 y 的根节点集合编号相同,说明 x 和 y 已经同属于一个集合 - return False - - if self.size[root_x] < self.size[root_y]: # x 对应的集合元素个数 小于 y 对应的集合元素个数 - self.fa[root_x] = root_y # x 的根节点连接到 y 的根节点上,成为 y 的根节点的子节点 - self.size[root_y] += self.size[root_x] # y 的根节点对应的集合元素个数 累加上 x 的根节点对应的集合元素个数 - elif self.size[root_x]> self.size[root_y]: # x 对应的集合元素个数 大于 y 对应的集合元素个数 - self.fa[root_y] = root_x # y 的根节点连接到 x 的根节点上,成为 x 的根节点的子节点 - self.size[root_x] += self.size[root_y] # x 的根节点对应的集合元素个数 累加上 y 的根节点对应的集合元素个数 - else: # x 对应的集合元素个数 小于 y 对应的集合元素个数 - self.fa[root_x] = root_y # 向任意一方合并即可 + if root_x == root_y: + return False # x 和 y 已经在同一个集合,无需合并 + + # 按集合大小合并:小集合合并到大集合 + if self.size[root_x] < self.size[root_y]: + self.fa[root_x] = root_y + self.size[root_y] += self.size[root_x] + elif self.size[root_x]> self.size[root_y]: + self.fa[root_y] = root_x + self.size[root_x] += self.size[root_y] + else: + # 集合大小相等,任选一方作为新根 + self.fa[root_x] = root_y self.size[root_y] += self.size[root_x] - + return True - def is_connected(self, x, y): # 查询操作:判断 x 和 y 是否同属于一个集合 + def is_connected(self, x, y): + """ + 查询操作:判断 x 和 y 是否属于同一个集合 + :param x: 元素 x + :param y: 元素 y + :return: 如果属于同一集合返回 True,否则返回 False + """ return self.find(x) == self.find(y) ``` ### 3.3 按秩合并的注意点 -看过「按深度合并」和「按大小合并」的实现代码后,大家可能会产生一个疑问:为什么在路径压缩的过程中不用更新 $rank$ 值或者 $size$ 值呢? - -其实,代码中的 $rank$ 值或者 $size$ 值并不完全是树中真实的深度或者集合元素个数。 +很多同学会疑惑:再路径压缩时,为什么不用更新 $rank$ 或 $size$? -这是因为当我们在代码中引入路径压缩之后,维护真实的深度或者集合元素个数就会变得比较难。此时我们使用的 $rank$ 值或者 $size$ 值更像是用于当前节点排名的一个标志数字,只在合并操作的过程中,用于比较两棵树的权值大小。 +其实,路径压缩后,$rank$ 和 $size$ 已经不再代表真实的树高或集合大小。它们只是合并时用来比较「谁大谁小」的辅助标记,只在合并操作时起作用。 -换句话说,我们完全可以不知道每个节点的具体深度或者集合元素个数,只要能够保证每两个节点之间的深度或者集合元素个数关系可以通过 $rank$ 值或者 $size$ 值正确的表达即可。 +换句话说,我们不需要关心每个节点的真实深度或集合元素个数,只要 $rank$ 或 $size$ 能正确反映两个集合的相对大小即可。 -而根据路径压缩的过程,$rank$ 值或者 $size$ 值只会不断的升高,而不可能降低到比原先深度更小的节点或者集合元素个数更少的节点还要小。所以,$rank$ 值或者 $size$ 值足够用于比较两个节点的权值,进而选择合适的方式进行合并操作。 +此外,路径压缩只会让树变矮,$rank$ 或 $size$ 只会增加,不会减少。因此,它们足以作为合并时的比较依据,无需在路径压缩时维护真实值。 ## 4. 并查集的算法分析 -首先我们来分析一下并查集的空间复杂度。在代码中,我们主要使用了数组 $fa$ 来存储集合中的元素。如果使用了「按秩合并」的优化方式,还会使用数组 $rank$ 或者数组 $size$ 来存放权值。因为空间复杂度取决于元素个数,不难得出空间复杂度为 $O(n)$。 -在同时使用了「路径压缩」和「按秩合并」的情况下,并查集的合并操作和查找操作的时间复杂度可以接近于 $O(1)$。最坏情况下的时间复杂度是 $O(m \times \alpha(n))$。这里的 $m$ 是合并操作和查找操作的次数,$\alpha(n)$ 是 Ackerman 函数的某个反函数,其增长极其缓慢,也就是说其单次操作的平均运行时间可以认为是一个很小的常数。 +- **时间复杂度**:在同时使用「路径压缩」和「按秩合并」优化后,合并(union)和查找(find)操作的均摊时间复杂度非常接近 $O(1)$。更精确地说,$m$ 次操作的总时间复杂度为 $O(m \times \alpha(n)),ドル其中 $\alpha(n)$ 是阿克曼函数的反函数,增长极其缓慢,实际应用中可视为常数。 +- **空间复杂度**:主要由数组 $fa$(父节点数组)构成,若采用「按秩合并」优化,还需额外的 $rank$ 或 $size$ 数组。整体空间复杂度为 $O(n),ドル其中 $n$ 为元素个数。 -总结一下: +## 5. 并查集的推荐实现方式 -- 并查集的空间复杂度:$O(n)$。 -- 并查集的时间复杂度:$O(m \times \alpha(n))$。 +结合实际刷题和主流经验,推荐并查集的实现策略如下:优先采用「隔代压缩」优化,一般情况下无需引入「按秩合并」。 -## 5. 并查集的最终实现代码 +这种做法的优势在于代码简洁、易于实现,同时性能表现也非常优秀。只有在遇到性能瓶颈时,再考虑引入「按秩合并」进一步优化。 -根据我自己的做题经验和网上大佬的经验,我使用并查集的策略(仅供参考)是这样:使用「隔代压缩」,一般不使用「按秩合并」。 +此外,如果题目需要支持查询集合数量或集合内元素个数等功能,可根据具体需求对实现进行适当扩展。 -这样选择的原因是既能保证代码简单易写,又能得到不错的性能。如果这样写的性能还不够好的话,再考虑使用「按秩合并」。 -在有些题目中,还会遇到需要查询集合的个数或者集合中元素个数的情况,可以根据题目具体要求再做相应的更改。 +::: tabs#bubble + +@tab <1> -- 使用「隔代压缩」,不使用「按秩合并」的并查集最终实现代码: +采用「隔代压缩」且不使用「按秩合并」的并查集实现代码: ```python class UnionFind: @@ -363,7 +459,9 @@ class UnionFind: return self.find(x) == self.find(y) ``` -- 使用「隔代压缩」,使用「按秩合并」的并查集最终实现代码: +@tab <2> + +使用「隔代压缩」,使用「按秩合并」的并查集最终实现代码: ```python class UnionFind: @@ -396,6 +494,8 @@ class UnionFind: return self.find(x) == self.find(y) ``` +::: + ## 6. 并查集的应用 并查集通常用来求解不同元素之间的关系问题,比如判断两个人是否是亲戚关系、两个点之间时候存在至少一条路径连接。或者用来求解集合的个数、集合中元素的个数等等。 @@ -430,12 +530,12 @@ class UnionFind: #### 6.1.3 解题思路 -字符串方程只有 `==` 或者 `!=`,可以考虑将相等的遍历划分到相同集合中,然后再遍历所有不等式方程,看方程的两个变量是否在之前划分的相同集合中,如果在则说明不满足。 +由于字符串方程仅包含 `==` 或 `!=` 两种形式,我们可以将所有等式(`==`)的变量归为同一个集合,然后再检查所有不等式(`!=`)的变量是否被错误地划分到了同一集合中。如果出现这种情况,则说明方程组无法同时满足。 -这就需要用到并查集,具体操作如下: +具体步骤如下: -- 遍历所有等式方程,将等式两边的单字母变量顶点进行合并。 -- 遍历所有不等式方程,检查不等式两边的单字母遍历是不是在一个连通分量中,如果在则返回 $False,ドル否则继续扫描。如果所有不等式检查都没有矛盾,则返回 $True$。 +- 首先遍历所有等式方程,将等式两侧的变量通过并查集合并到同一个集合中。 +- 然后遍历所有不等式方程,判断不等式两侧的变量是否已经在同一个集合中。如果在同一集合,则说明存在矛盾,返回 $False$;如果所有不等式都检查无冲突,则返回 $True$。 #### 6.1.4 代码 @@ -480,87 +580,6 @@ class Solution: return True ``` -### 6.2 省份数量 - -#### 6.2.1 题目链接 - -- [547. 省份数量 - 力扣(LeetCode)](https://leetcode.cn/problems/number-of-provinces/) - -#### 6.2.2 题目大意 - -**描述**:有 $n$ 个城市,其中一些彼此相连,另一些没有相连。如果城市 $a$ 与城市 $b$ 直接相连,且城市 $b$ 与城市 $c$ 直接相连,那么城市 $a$ 与城市 $c$ 间接相连。 - -「省份」是由一组直接或间接链接的城市组成,组内不含有其他没有相连的城市。 - -现在给定一个 $n \times n$ 的矩阵 $isConnected$ 表示城市的链接关系。其中 `isConnected[i][j] = 1` 表示第 $i$ 个城市和第 $j$ 个城市直接相连,`isConnected[i][j] = 0` 表示第 $i$ 个城市和第 $j$ 个城市没有相连。 - -**要求**:根据给定的城市关系,返回「省份」的数量。 - -**说明**: - -- 1ドル \le n \le 200$。 -- $n == isConnected.length$。 -- $n == isConnected[i].length$。 -- $isConnected[i][j]$ 为 1ドル$ 或 0ドル$。 -- $isConnected[i][i] == 1$。 -- $isConnected[i][j] == isConnected[j][i]$。 - -**示例**: - -- 如图所示: - -![](https://assets.leetcode.com/uploads/2020/12/24/graph1.jpg) - -```python -输入 isConnected = [[1,1,0],[1,1,0],[0,0,1]] -输出 2 -``` - -#### 6.2.3 解题思路 - -具体做法如下: -- 遍历矩阵 $isConnected$。如果 `isConnected[i][j] = 1`,将 $i$ 节点和 $j$ 节点相连。 -- 然后判断每个城市节点的根节点,然后统计不重复的根节点有多少个,也就是集合个数,即为「省份」的数量。 - -#### 6.2.4 代码 - -```python -class UnionFind: - def __init__(self, n): # 初始化 - self.fa = [i for i in range(n)] # 每个元素的集合编号初始化为数组 fa 的下标索引 - - def find(self, x): # 查找元素根节点的集合编号内部实现方法 - while self.fa[x] != x: # 递归查找元素的父节点,直到根节点 - self.fa[x] = self.fa[self.fa[x]] # 隔代压缩优化 - x = self.fa[x] - return x # 返回元素根节点的集合编号 - - def union(self, x, y): # 合并操作:令其中一个集合的树根节点指向另一个集合的树根节点 - root_x = self.find(x) - root_y = self.find(y) - if root_x == root_y: # x 和 y 的根节点集合编号相同,说明 x 和 y 已经同属于一个集合 - return False - self.fa[root_x] = root_y # x 的根节点连接到 y 的根节点上,成为 y 的根节点的子节点 - return True - - def is_connected(self, x, y): # 查询操作:判断 x 和 y 是否同属于一个集合 - return self.find(x) == self.find(y) - -class Solution: - def findCircleNum(self, isConnected: List[List[int]]) -> int: - size = len(isConnected) - union_find = UnionFind(size) - for i in range(size): - for j in range(i + 1, size): - if isConnected[i][j] == 1: - union_find.union(i, j) - - res = set() - for i in range(size): - res.add(union_find.find(i)) - return len(res) -``` - ## 练习题目 - [0990. 等式方程的可满足性](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/0900-0999/satisfiability-of-equality-equations.md)

AltStyle によって変換されたページ (->オリジナル) /