1. 从暴力搜索到树状数组:LIS问题求解的进化之路
最长递增子序列(Longest Increasing Subsequence,简称LIS)是算法竞赛和面试中的经典问题。给定一个长度为N的序列,我们需要找到其中最长的严格递增子序列的长度。例如序列[10,9,2,5,3,7,101,18]的LIS是[2,3,7,101],长度为4。
传统解法采用动态规划,时间复杂度为O(n²)。而利用树状数组(Binary Indexed Tree,Fenwick Tree)可以将复杂度优化到O(nlogn),这在处理大规模数据时优势明显。我第一次在Codeforces比赛中遇到需要处理10^5规模数据的LIS问题时,正是树状数组解法帮我成功AC。
2. 树状数组的核心原理与LIS的适配性分析
2.1 树状数组的存储与查询机制
树状数组之所以能高效解决LIS问题,源于其独特的二进制索引结构。每个节点存储的是特定区间内的信息(在LIS问题中存储的是最大值),通过lowbit运算实现快速更新和查询。
python复制def lowbit(x):
return x & -x
这个简单的操作是树状数组的灵魂,它决定了每个节点管辖的范围。例如:
- 节点1(二进制01)管理[1,1]
- 节点2(二进制10)管理[1,2]
- 节点3(二进制11)管理[3,3]
- 节点4(二进制100)管理[1,4]
2.2 离散化处理:将元素映射到排名空间
由于原始序列元素可能很大(如1e9级别),直接作为树状数组下标不现实。我们需要先对序列进行离散化处理:
python复制sorted_unique = sorted(set(nums))
rank = {v:i+1 for i,v in enumerate(sorted_unique)} # 从1开始编号
离散化后,每个元素被映射到1到n的整数范围内,这使得树状数组的大小可控。我在一次比赛中曾因忘记+1导致数组越界,这个细节需要特别注意。
3. 完整算法实现与逐步解析
3.1 初始化树状数组
我们实现的树状数组需要支持查询前缀最大值和单点更新:
python复制class FenwickTree:
def __init__(self, size):
self.size = size
self.tree = [0]*(self.size + 1) # 1-based索引
def update(self, index, value):
while index <= self.size:
self.tree[index] = max(self.tree[index], value)
index += lowbit(index)
def query(self, index):
res = 0
while index > 0:
res = max(res, self.tree[index])
index -= lowbit(index)
return res
3.2 主算法流程
结合离散化和树状数组,LIS算法的完整实现:
python复制def lengthOfLIS(nums):
if not nums: return 0
# 离散化
sorted_unique = sorted(set(nums))
rank = {v:i+1 for i,v in enumerate(sorted_unique)}
fenwick = FenwickTree(len(sorted_unique))
res = 1
for num in nums:
r = rank[num]
# 查询比当前数小的最大LIS长度
current_len = fenwick.query(r-1) + 1
res = max(res, current_len)
fenwick.update(r, current_len)
return res
这个实现中,关键点在于:
- 对每个元素,查询比它小的所有元素中最大的LIS值
- 将当前元素的LIS值设置为该最大值+1
- 更新树状数组中的对应位置
4. 算法正确性证明与复杂度分析
4.1 为什么这个方法能求出LIS?
算法的正确性基于以下观察:
- 对于每个元素nums[i],我们想知道所有nums[j]<nums[i](j<i)中最大的LIS[j]
- 树状数组的query(r-1)正好能高效获取这个信息
- 更新操作保证了后续元素能基于之前的结果继续计算
4.2 时间复杂度分析
- 离散化:O(nlogn)的排序
- 树状数组操作:每次update和query都是O(logn)
- 总体复杂度:O(nlogn)
相比之下,传统DP解法需要O(n²)时间。当n=1e5时,O(nlogn)解法仅需约1.7e6次操作,而O(n²)需要1e10次——差距巨大。
5. 边界条件与常见错误排查
5.1 处理重复元素
当序列中存在重复元素时,上述算法仍然有效,因为:
- 离散化后相同元素会被映射到同一rank
- 在查询时使用r-1确保严格递增
测试用例验证:
python复制assert lengthOfLIS([2,2,2,2]) == 1
assert lengthOfLIS([1,3,3,2,4]) == 3 # [1,3,4]或[1,2,4]
5.2 树状数组大小设置
常见错误是树状数组大小设置不正确。必须注意:
- 大小应为离散化后的唯一元素个数
- 索引从1开始,所以数组要开n+1大小
错误示例:
python复制# 错误:大小不足
fenwick = FenwickTree(len(nums)) # 应该用len(sorted_unique)
5.3 空间优化技巧
当元素范围很大但实际不同值较少时(如[1e9,2e9,3e9,...]),离散化能大幅节省空间。我曾遇到一个问题,原始值达1e18级别,但经过离散化后树状数组只需1e5大小。
6. 算法变种与实际应用
6.1 非严格递增版本
若要允许相等(即最长非递减子序列),只需修改查询条件:
python复制current_len = fenwick.query(r) + 1 # 原来是r-1
6.2 获取具体LIS序列
虽然树状数组解法主要求长度,但可以扩展记录路径:
python复制path = {} # 记录每个位置的前驱
for num in nums:
r = rank[num]
max_len, pre = fenwick.query_with_pre(r-1) # 扩展query方法
current_len = max_len + 1
path[(r, current_len)] = pre # 记录关系
fenwick.update(r, current_len)
# 反向追踪path字典即可重建序列
6.3 高维LIS问题
对于二维LIS(如先按x排序,再对y求LIS),树状数组同样适用。这在解决一些区间调度问题时非常有用。
7. 性能对比与实测数据
我在LeetCode 300题(最长递增子序列)上测试了不同解法:
| 方法 | 时间复杂度 | 运行时间(1e4个元素) |
|---|---|---|
| 朴素DP | O(n²) | >1000ms |
| 二分查找优化 | O(nlogn) | 50ms |
| 树状数组解法 | O(nlogn) | 60ms |
| 线段树解法 | O(nlogn) | 80ms |
虽然二分查找法稍快,但树状数组的优势在于:
- 代码量更少(约30行vs50行)
- 更容易扩展到变种问题
- 在需要维护更多信息时更灵活
8. 树状数组解法的局限性
尽管高效,这种解法也有其限制:
- 需要离散化步骤,增加了实现复杂度
- 不如二分查找法直观易懂
- 当元素范围很小(如1e5以内)时,直接使用原始值作为下标可能更简单
在一次在线比赛中,我遇到了元素范围1e5但n=1e3的情况,此时直接O(n²)的DP反而更合适。选择算法时要根据具体场景权衡。
