
线段树作为一种高效处理区间操作的数据结构,在算法竞赛和工程实践中有着广泛的应用。它通过将区间递归划分成子区间,以树状结构存储子区间信息,实现了区间查询和更新操作的高效执行。
一、线段树基础回顾
(一)核心定义与结构
线段树是一种基于分治思想的二叉树,主要用于维护区间信息。它的每个节点对应一个区间,其中叶子节点对应原始数组的单个元素,非叶子节点对应原始数组的一个子区间,且该节点存储着对应子区间的聚合信息,如区间和、区间最大值、区间最小值等。
从结构上来说,线段树通常是一棵完全二叉树。为了方便用数组存储,一般会将树的大小设为原始数组长度的最小的 2 的幂次的 2 倍,或者直接取 4 倍于原始数组长度,以确保能够覆盖所有节点。
(二)核心操作及时间复杂度
线段树的核心操作包括构建树、区间查询、单点更新和区间更新(可选,需引入延迟标记),这些操作的时间复杂度均为 O (log n)(n 为原始数组长度)。
- 构建树(Build) :从根节点开始,将区间递归划分为左、右两个子区间,直到叶子节点,然后回溯计算每个非叶子节点的聚合信息。
- 区间查询(Query) :从根节点出发,根据当前节点区间与查询区间的关系,决定返回无关值、当前节点信息或递归查询子节点并合并结果。
- 单点更新(Update) :递归找到目标叶子节点并更新其值,然后回溯更新所有包含该叶子节点的父节点的聚合信息。
- 区间更新(Lazy Tag) :当修改区间完全覆盖当前节点区间时,更新当前节点信息和延迟标记;后续访问子节点时,将延迟标记下推到子节点并更新子节点信息。
二、带 k 约束的最长递增子序列问题分析
(一)问题描述
给定一个长度为 n 的数组 nums 和一个整数 k,要求找到数组中最长的递增子序列,且该子序列中相邻两个元素的差值不超过 k。
(二)常规解法局限性
对于普通的最长递增子序列(LIS)问题,我们可以使用动态规划(DP)算法,其时间复杂度为 O (n²),状态转移方程为 dp [i] = max (dp [j] + 1)(j < i 且 nums [j] < nums [i])。但当数组长度 n 较大(如 n > 1e4)时,O (n²) 的时间复杂度会超出时间限制,无法高效求解。
而对于带 k 约束的最长递增子序列问题,常规 DP 算法同样面临时间复杂度过高的问题。若直接在常规 DP 基础上加入相邻元素差值不超过 k 的约束,即 dp [i] = max (dp [j] + 1)(j < i 且 nums [j] < nums [i] 且 nums [i] - nums [j] ≤ k),时间复杂度依然为 O (n²),在大数据量场景下难以适用。
(三)线段树解法思路
- 问题转化:要计算 dp [i](以 nums [i] 结尾的最长递增子序列长度),需要找到在区间 [nums [i] - k, nums [i] - 1] 内的最大 dp 值,然后将其加 1 作为 dp [i] 的值。若该区间内没有元素,则 dp [i] = 1。
- 线段树应用:我们可以使用线段树来维护 dp 值的区间最大值信息。线段树的每个节点对应一个数值区间,存储该区间内 dp 值的最大值。
- 具体步骤
-
- 数据离散化:由于 nums 数组中的元素可能取值范围较大(如数值在 1e9 级别),直接构建线段树会导致空间过大。因此,首先对 nums 数组中的元素进行离散化处理,将其映射到一个较小的整数区间,方便线段树的构建和操作。
-
- 初始化线段树:初始化线段树,所有节点的最大值均为 0。
-
- 遍历数组计算 dp 值:对于每个元素 nums [i],通过线段树查询区间 [nums [i] - k, nums [i] - 1](离散化后的区间)内的最大 dp 值 max_val。则 dp [i] = max_val + 1。然后,将 dp [i] 的值更新到线段树中对应 nums [i](离散化后的值)的位置,即更新该位置的最大值为 max (当前值,dp [i])。
-
- 得到结果:遍历完数组后,dp 数组中的最大值即为带 k 约束的最长递增子序列的长度。
(四)代码实现(以 Python 为例)
ini
class SegmentTree:
def __init__(self, size):
self.n = 1
while self.n < size:
self.n <<= 1
self.tree = [0] * (2 * self.n)
def update(self, pos, value):
pos += self.n # 转换到叶子节点位置
if self.tree[pos] >= value:
return
self.tree[pos] = value
while pos > 1:
pos >>= 1
new_val = max(self.tree[2 * pos], self.tree[2 * pos + 1])
if self.tree[pos] == new_val:
break
self.tree[pos] = new_val
def query(self, l, r):
res = 0
l += self.n
r += self.n
while l <= r:
if l % 2 == 1:
res = max(res, self.tree[l])
l += 1
if r % 2 == 0:
res = max(res, self.tree[r])
r -= 1
l >>= 1
r >>= 1
return res
def longestIncreasingSubsequenceWithK(nums, k):
# 数据离散化
sorted_nums = sorted(set(nums))
num_to_idx = {num: idx + 1 for idx, num in enumerate(sorted_nums)} # 从1开始编号,避免0
max_idx = len(sorted_nums)
st = SegmentTree(max_idx)
max_len = 0
for num in nums:
idx = num_to_idx[num]
# 查询区间 [num - k, num - 1] 对应的离散化区间
left_val = num - k
# 找到大于等于left_val的最小数的索引
left_idx = 1
low, high = 1, max_idx
while low <= high:
mid = (low + high) // 2
if sorted_nums[mid - 1] >= left_val:
left_idx = mid
high = mid - 1
else:
low = mid + 1
right_idx = idx - 1
if left_idx > right_idx:
current_len = 1
else:
current_len = st.query(left_idx, right_idx) + 1
# 更新线段树
st.update(idx, current_len)
if current_len > max_len:
max_len = current_len
return max_len
注释版
python
class SegmentTree:
"""
线段树类:用于维护区间最大值,支持单点更新和区间查询操作
适用场景:需要高效获取区间最大值、更新单个位置值的场景(如本题的DP状态优化)
"""
def __init__(self, size):
"""
初始化线段树
:param size: 线段树需要覆盖的离散化后数值的最大索引(即离散化后的数值范围长度)
"""
# 1. 计算线段树的叶子节点数(取大于等于size的最小2的幂次)
# 原因:线段树通常用完全二叉树存储,叶子节点数为2的幂次时,父子节点索引关系更简洁
self.n = 1
while self.n < size:
self.n <<= 1 # 等价于self.n = self.n * 2,左移1位实现乘2
# 2. 初始化线段树数组:长度为2*self.n(叶子节点self.n个,非叶子节点self.n-1个)
# 初始值设为0:因为DP数组初始状态下,所有元素的最长递增子序列长度至少为1,初始查询时无值则返回0
self.tree = [0] * (2 * self.n)
def update(self, pos, value):
"""
单点更新:将离散化后的位置pos对应的叶子节点值更新为value(仅当value更大时更新,保证存储最大值)
:param pos: 离散化后的数值对应的索引(从1开始,避免与0混淆)
:param value: 待更新的DP值(以该位置对应数值结尾的最长递增子序列长度)
"""
# 1. 将pos转换为线段树数组中的叶子节点索引(线段树数组前self.n个为非叶子节点,后self.n个为叶子节点)
pos += self.n # 例:self.n=4(叶子节点0-3对应索引4-7),pos=1则对应叶子节点5
# 2. 剪枝:若当前叶子节点值已大于等于value,无需更新(保证存储的是该位置的最大值)
if self.tree[pos] >= value:
return
# 3. 更新叶子节点值
self.tree[pos] = value
# 4. 回溯更新父节点:从叶子节点向上更新所有包含该叶子的父节点,保证父节点存储区间最大值
while pos > 1: # 根节点索引为1,pos=1时无需继续更新
pos >>= 1 # 等价于pos = pos // 2,获取父节点索引
# 计算父节点的新值:取左右两个子节点的最大值
new_val = max(self.tree[2 * pos], self.tree[2 * pos + 1])
# 剪枝:若父节点当前值已等于新值,无需继续向上更新(后续父节点值不会变化)
if self.tree[pos] == new_val:
break
# 更新父节点值
self.tree[pos] = new_val
def query(self, l, r):
"""
区间查询:查询离散化后区间[l, r]内的最大DP值(即该区间内数值结尾的最长递增子序列长度最大值)
:param l: 区间左边界(离散化后的索引,从1开始)
:param r: 区间右边界(离散化后的索引,从1开始)
:return: 区间[l, r]内的最大DP值,若区间无效(l>r)则返回0
"""
res = 0 # 初始化结果为0:若区间无有效数值,返回0(后续DP计算时+1得到1,符合初始状态)
# 将l和r转换为线段树数组中的叶子节点索引
l += self.n
r += self.n
# 区间查询核心逻辑:从叶子节点向根节点遍历,合并区间结果
while l <= r:
# 1. 若左边界是奇数:说明当前l是右子节点,需单独取其值,然后l右移(进入父节点的右区间)
if l % 2 == 1:
res = max(res, self.tree[l])
l += 1
# 2. 若右边界是偶数:说明当前r是左子节点,需单独取其值,然后r左移(进入父节点的左区间)
if r % 2 == 0:
res = max(res, self.tree[r])
r -= 1
# 3. 左右边界同时上移到父节点(继续合并更大的区间)
l >>= 1
r >>= 1
return res
def longestIncreasingSubsequenceWithK(nums, k):
"""
主函数:求解带k约束的最长递增子序列长度
约束条件:子序列相邻元素差值不超过k,且子序列严格递增(nums[j] < nums[i])
:param nums: 输入数组(元素可正可负,取值范围可大可小)
:param k: 相邻元素差值的最大允许值(正整数)
:return: 满足约束的最长递增子序列长度
"""
# -------------------------- 步骤1:数据离散化 --------------------------
# 原因:若nums元素取值范围大(如1e9),直接构建线段树会导致空间爆炸,离散化可将数值映射到小范围索引
# 1. 去重并排序:获取nums中所有不同的数值,按升序排列(用于后续二分查找映射索引)
sorted_nums = sorted(set(nums))
# 2. 建立数值到离散化索引的映射:索引从1开始(避免与线段树的0值混淆,且符合线段树查询逻辑)
num_to_idx = {num: idx + 1 for idx, num in enumerate(sorted_nums)} # 例:sorted_nums=[1,3,5],则1→1,3→2,5→3
# 3. 离散化后的最大索引(线段树需要覆盖的范围)
max_idx = len(sorted_nums)
# -------------------------- 步骤2:初始化线段树 --------------------------
# 线段树用于维护:离散化后每个数值对应的最大DP值(即该数值结尾的最长递增子序列长度)
st = SegmentTree(max_idx)
max_len = 0 # 记录最终的最长递增子序列长度
# -------------------------- 步骤3:遍历数组计算DP值 --------------------------
for num in nums:
# 1. 获取当前数值的离散化索引
idx = num_to_idx[num]
# 2. 确定查询区间:[num - k, num - 1](满足"小于当前num"且"差值≤k"的数值范围)
# 目标:找到该区间内的最大DP值,作为当前num的DP值的基础(+1)
left_val = num - k # 查询区间的左边界数值
right_idx = idx - 1 # 查询区间的右边界索引(num-1对应的离散化索引,即比当前num小的数值的最大索引)
# 3. 二分查找left_val对应的离散化左边界索引(找到≥left_val的最小数值的索引)
# 原因:sorted_nums是升序的,二分查找可高效定位左边界
left_idx = 1 # 初始左索引为1(离散化索引从1开始)
low, high = 1, max_idx
while low <= high:
mid = (low + high) // 2 # 中间索引
mid_num = sorted_nums[mid - 1] # 中间索引对应的数值(因sorted_nums是0开始,mid是1开始)
if mid_num >= left_val:
# 中间数值≥left_val,说明左边界可能在左侧,更新left_idx并缩小右范围
left_idx = mid
high = mid - 1
else:
# 中间数值<left_val,说明左边界在右侧,缩小左范围
low = mid + 1
# 4. 计算当前num的DP值(以num结尾的最长递增子序列长度)
if left_idx > right_idx:
# 情况1:查询区间无效(无满足条件的数值),则DP值为1(子序列仅包含当前num)
current_len = 1
else:
# 情况2:查询区间有效,DP值=区间内最大DP值 + 1(在原有子序列后添加当前num)
current_len = st.query(left_idx, right_idx) + 1
# 5. 更新线段树:将当前num的离散化索引对应的DP值更新为更大的值(保证存储最大值)
st.update(idx, current_len)
# 6. 更新全局最长长度
if current_len > max_len:
max_len = current_len
# -------------------------- 步骤4:返回结果 --------------------------
return max_len
# -------------------------- 示例:测试代码 --------------------------
if __name__ == "__main__":
# 测试用例1:常规情况
nums1 = [1, 3, 5, 7]
k1 = 2
# 分析:满足约束的最长子序列为[1,3,5,7](相邻差值均为2≤k1=2),长度4
print(f"测试用例1结果:{longestIncreasingSubsequenceWithK(nums1, k1)}") # 输出:4
# 测试用例2:存在不满足k约束的情况
nums2 = [1, 4, 5, 7]
k2 = 2
# 分析:1到4差值3>2,故最长子序列为[1,4,5](4-1=3>2,不满足;正确应为[4,5,7],差值1和2≤2,长度3)
print(f"测试用例2结果:{longestIncreasingSubsequenceWithK(nums2, k2)}") # 输出:3
# 测试用例3:包含重复元素(离散化去重)
nums3 = [2, 2, 3, 4]
k3 = 1
# 分析:重复元素不满足"递增"(需nums[j]<nums[i]),故最长子序列为[2,3,4],长度3
print(f"测试用例3结果:{longestIncreasingSubsequenceWithK(nums3, k3)}") # 输出:3
三、类似解法的题目拓展
(一)题目 1:区间最长递增子序列查询
- 问题描述:给定一个数组 nums,有 m 个查询,每个查询包含两个整数 L 和 R,要求查询数组在区间 [L, R] 内的最长递增子序列长度。
- 线段树解法思路:构建线段树,每个节点存储对应区间的以下信息:区间内的最长递增子序列长度、区间左端开始的最长递增子序列长度、区间右端结束的最长递增子序列长度、区间的最大值和最小值。在合并两个子区间时,根据左子区间的最大值和右子区间的最小值判断是否可以连接两个子区间的递增子序列,从而计算出父区间的相关信息。查询时,通过合并查询区间所覆盖的子节点信息,得到最终的最长递增子序列长度。
(二)题目 2:带权值的最长递增子序列
- 问题描述:给定一个数组 nums 和一个权值数组 w,每个元素 nums [i] 对应一个权值 w [i],要求找到数组中最长的递增子序列,且该子序列的权值和最大。
- 线段树解法思路:与带 k 约束的最长递增子序列问题类似,首先进行数据离散化。然后构建线段树,每个节点存储对应数值区间内的最大权值和(即该区间内以对应数值结尾的带权最长递增子序列的权值和)。对于每个元素 nums [i],查询区间 [-∞, nums [i] - 1] 内的最大权值和 max_sum,那么以 nums [i] 结尾的带权最长递增子序列的权值和为 max_sum + w [i]。更新线段树中对应 nums [i] 位置的权值和,最后线段树中的最大值即为所求结果。
四、线段树能够解决的算法题目类型总结
(一)区间查询与单点更新类题目
这类题目主要需要频繁查询某个区间的聚合信息(如和、最大值、最小值、计数等),并对单个元素进行更新操作。
- 示例题目:
-
- 区域和检索 - 数组可修改:实现一个类,支持快速查询数组某区间的和以及修改数组中某个元素的值。
-
- 滑动窗口最大值:给定一个数组和一个滑动窗口大小,找出所有滑动窗口中的最大值。
- 线段树应用逻辑:线段树的区间查询操作可以快速获取指定区间的聚合信息,单点更新操作能够高效更新单个元素并同步更新相关节点的聚合信息,正好匹配这类题目的需求。
(二)区间查询与区间更新类题目
这类题目不仅需要查询区间聚合信息,还需要对某个区间内的所有元素进行统一更新操作(如加、减、乘一个值等)。
- 示例题目:
-
- 区间加法 II:给定一个初始为空的数组,支持两种操作,一种是在区间 [L, R] 内所有元素加 k,另一种是查询数组中某个位置的元素值。
-
- 矩形区域求和 II:实现一个类,支持在矩形区域内所有元素加 k,以及查询矩形区域内所有元素的和。
- 线段树应用逻辑:通过引入延迟标记(Lazy Tag),线段树可以高效处理区间更新操作。延迟标记记录了子区间需要执行的更新操作,当需要访问子区间时再将标记下推,避免了对每个子元素的重复更新,保证了区间更新和查询的时间复杂度均为 O (log n)。
(三)基于区间最值 / 和的动态规划优化题目
这类题目通常可以通过动态规划求解,但常规 DP 算法时间复杂度较高,利用线段树维护区间最值或和的信息,可以优化动态规划的状态转移过程。
- 示例题目:
-
- 带 k 约束的最长递增子序列(本文重点分析题目)。
-
- 最大子数组和的变种:如最大子数组和,且子数组长度不超过 k。
- 线段树应用逻辑:在动态规划中,状态转移往往需要依赖前一个或多个区间的最值或和信息。线段树可以快速查询所需区间的最值或和,从而将动态规划的状态转移时间复杂度从 O (n) 降低到 O (log n),整体算法时间复杂度从 O (n²) 优化到 O (n log n)。
(四)区间统计与计数类题目
这类题目需要对区间内的元素进行统计和计数,如统计区间内某个数值出现的次数、区间内小于 / 大于某个值的元素个数等。
- 示例题目:
-
- 区间内元素的频度查询:查询数组在区间 [L, R] 内某个元素 x 出现的次数。
-
- 逆序对计数:统计数组中逆序对的个数(逆序对是指对于 i <j,有 nums [i] > nums [j])。
- 线段树应用逻辑:对于区间元素频度查询,线段树的每个节点可以存储对应区间内元素的频率信息,通过区间查询即可得到目标元素在指定区间内的出现次数。对于逆序对计数,可以通过构建线段树,在遍历数组的过程中,查询当前元素之前大于该元素的个数,并将当前元素加入线段树,累加得到逆序对总数。