1. 最长上升子序列问题的经典解法
最长上升子序列(Longest Increasing Subsequence, LIS)问题是动态规划领域的经典案例。给定一个无序的整数序列,我们需要找到其中最长的严格递增子序列的长度。例如对于序列[10, 9, 2, 5, 3, 7, 101, 18],最长上升子序列是[2, 3, 7, 101],长度为4。
1.1 基本动态规划解法
最直观的解法是使用O(n²)时间复杂度的动态规划:
- 定义dp数组,dp[i]表示以第i个数字结尾的最长上升子序列长度
- 初始化dp数组所有值为1,因为每个元素本身就是一个长度为1的子序列
- 对于每个i从1到n-1,遍历j从0到i-1:
- 如果nums[j] < nums[i],则dp[i] = max(dp[i], dp[j]+1)
- 最终结果是dp数组中的最大值
python复制def lengthOfLIS(nums):
if not nums:
return 0
dp = [1] * len(nums)
for i in range(1, len(nums)):
for j in range(i):
if nums[j] < nums[i]:
dp[i] = max(dp[i], dp[j] + 1)
return max(dp)
1.2 优化解法:二分查找+贪心
更高效的O(nlogn)解法结合了贪心算法和二分查找:
- 维护一个tails数组,其中tails[i]表示长度为i+1的所有上升子序列的最小末尾元素
- 遍历nums中的每个数x:
- 如果x大于tails中所有元素,则将其追加到tails末尾
- 否则,用x替换tails中第一个大于等于x的元素
- 最终tails的长度就是LIS的长度
python复制import bisect
def lengthOfLIS(nums):
tails = []
for num in nums:
idx = bisect.bisect_left(tails, num)
if idx == len(tails):
tails.append(num)
else:
tails[idx] = num
return len(tails)
提示:二分查找解法虽然时间复杂度更优,但只能得到LIS的长度,无法直接得到具体的子序列。如果需要具体序列,还需要额外的处理步骤。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 最长上升子序列的常见变种
2.1 非严格递增版本
当题目允许子序列中相邻元素相等时,只需将比较条件从<改为<=即可:
python复制def lengthOfNonStrictLIS(nums):
if not nums:
return 0
dp = [1] * len(nums)
for i in range(1, len(nums)):
for j in range(i):
if nums[j] <= nums[i]: # 仅修改了这一行
dp[i] = max(dp[i], dp[j] + 1)
return max(dp)
2.2 二维LIS问题
当元素是二维结构(如信封的宽度和高度)时,通常需要先对一维排序,再在另一维上求LIS:
python复制def maxEnvelopes(envelopes):
# 按宽度升序,同宽度按高度降序排列
envelopes.sort(key=lambda x: (x[0], -x[1]))
# 在高度上求LIS
heights = [e[1] for e in envelopes]
return lengthOfLIS(heights)
2.3 带权值的LIS
每个元素有对应的权值,要求权值和最大的上升子序列:
python复制def maxWeightLIS(nums, weights):
n = len(nums)
dp = weights.copy() # dp[i]表示以i结尾的最大权值和
for i in range(1, n):
for j in range(i):
if nums[j] < nums[i]:
dp[i] = max(dp[i], dp[j] + weights[i])
return max(dp)
3. 动态规划与贪心算法的选择
3.1 何时使用动态规划
动态规划适用于:
- 需要知道具体子序列而不仅仅是长度
- 问题有变种需求(如带权值)
- 输入规模较小(n ≤ 1000)
3.2 何时使用贪心+二分
贪心+二分适用于:
- 只需要知道子序列长度
- 输入规模较大(n ≥ 10^4)
- 对时间复杂度要求严格
注意:贪心解法虽然高效,但理解起来比动态规划更困难。建议先掌握动态规划解法,再学习贪心优化。
4. 实际应用中的常见陷阱
4.1 初始化错误
新手常犯的错误是忘记初始化dp数组,或者错误地初始化为0:
python复制# 错误示例
dp = [0] * len(nums) # 这样会导致结果比实际小1
4.2 边界条件处理
空数组和单元素数组需要特殊处理:
python复制if not nums:
return 0
if len(nums) == 1:
return 1
4.3 序列相等时的处理
在非严格递增版本中,相等的元素是否视为"上升"需要明确:
python复制# 严格递增
if nums[j] < nums[i]
# 非严格递增
if nums[j] <= nums[i]
5. 性能优化技巧
5.1 提前终止
当剩余元素数量不足以超过当前最大长度时,可以提前终止:
python复制max_len = 1
for i in range(1, len(nums)):
if len(nums) - i + max_len <= result:
break
# 正常处理...
5.2 空间优化
对于动态规划解法,可以只维护一个一维数组:
python复制dp = [1] * n # 只需要O(n)空间
5.3 并行计算
对于大规模数据,可以将内层循环并行化:
python复制from concurrent.futures import ThreadPoolExecutor
def process_j(args):
j, i, nums, dp = args
if nums[j] < nums[i]:
dp[i] = max(dp[i], dp[j] + 1)
with ThreadPoolExecutor() as executor:
for i in range(1, len(nums)):
executor.map(process_j, [(j, i, nums, dp) for j in range(i)])
6. 与其他动态规划问题的关联
6.1 与最长公共子序列(LCS)的关系
LIS可以转化为LCS问题:将原序列排序去重后,求与原序列的LCS。
6.2 与背包问题的关系
带权值的LIS问题可以看作是一种特殊的背包问题,其中"容量"是序列的顺序约束。
6.3 与编辑距离的关系
两者都体现了动态规划中"状态转移"的思想,但状态定义和转移方程不同。
7. 实战案例分析
7.1 LeetCode 300. 最长上升子序列
这是最基础的LIS问题,可以直接套用标准解法:
python复制class Solution:
def lengthOfLIS(self, nums: List[int]) -> int:
tails = []
for num in nums:
idx = bisect.bisect_left(tails, num)
if idx == len(tails):
tails.append(num)
else:
tails[idx] = num
return len(tails)
7.2 LeetCode 354. 俄罗斯套娃信封问题
这是二维LIS的典型应用:
python复制class Solution:
def maxEnvelopes(self, envelopes: List[List[int]]) -> int:
envelopes.sort(key=lambda x: (x[0], -x[1]))
heights = [e[1] for e in envelopes]
return self.lengthOfLIS(heights)
def lengthOfLIS(self, nums):
tails = []
for num in nums:
idx = bisect.bisect_left(tails, num)
if idx == len(tails):
tails.append(num)
else:
tails[idx] = num
return len(tails)
7.3 自定义变种:带删除成本的最长上升子序列
假设删除元素有成本,求在预算内能得到的最长上升子序列:
python复制def maxLISWithCost(nums, costs, budget):
n = len(nums)
# dp[i][j]表示前i个元素,花费j预算能得到的最长LIS
dp = [[0]*(budget+1) for _ in range(n+1)]
for i in range(1, n+1):
for j in range(budget+1):
# 不选第i个元素
dp[i][j] = dp[i-1][j]
# 选第i个元素
if j >= costs[i-1]:
max_prev = 0
for k in range(i-1):
if nums[k] < nums[i-1]:
max_prev = max(max_prev, dp[k][j-costs[i-1]])
dp[i][j] = max(dp[i][j], max_prev + 1)
return max(max(row) for row in dp)
8. 进阶思考与扩展
8.1 如何输出所有最长上升子序列
不仅要求长度,还要输出所有可能的LIS:
python复制def allLIS(nums):
n = len(nums)
dp = [1]*n
prev = [[] for _ in range(n)]
max_len = 1
for i in range(1, n):
for j in range(i):
if nums[j] < nums[i]:
if dp[j]+1 > dp[i]:
dp[i] = dp[j]+1
prev[i] = [j]
elif dp[j]+1 == dp[i]:
prev[i].append(j)
max_len = max(max_len, dp[i])
# 回溯所有路径
result = []
for i in range(n):
if dp[i] == max_len:
result.extend(backtrack(i, prev, nums))
return result
def backtrack(i, prev, nums):
if not prev[i]:
return [[nums[i]]]
res = []
for p in prev[i]:
for seq in backtrack(p, prev, nums):
res.append(seq + [nums[i]])
return res
8.2 如何求最长上升子序列的数量
计算有多少个不同的LIS:
python复制def numberOfLIS(nums):
n = len(nums)
length = [1]*n # length[i]:以nums[i]结尾的LIS长度
count = [1]*n # count[i]:以nums[i]结尾的LIS数量
for i in range(1, n):
for j in range(i):
if nums[j] < nums[i]:
if length[j]+1 > length[i]:
length[i] = length[j]+1
count[i] = count[j]
elif length[j]+1 == length[i]:
count[i] += count[j]
max_len = max(length) if length else 0
return sum(c for l, c in zip(length, count) if l == max_len)
8.3 如何求字典序最小的最长上升子序列
当有多个LIS时,返回字典序最小的那个:
python复制def minLexicographicalLIS(nums):
n = len(nums)
dp = [1]*n
prev = [-1]*n
for i in range(1, n):
for j in range(i):
if nums[j] < nums[i] and dp[j]+1 > dp[i]:
dp[i] = dp[j]+1
prev[i] = j
max_len = max(dp)
# 找到所有可能的终点
candidates = [i for i in range(n) if dp[i] == max_len]
# 选择字典序最小的终点
min_idx = min(candidates, key=lambda x: nums[x])
# 回溯重建序列
res = []
while min_idx != -1:
res.append(nums[min_idx])
min_idx = prev[min_idx]
return res[::-1]
9. 实际工程中的应用场景
9.1 版本控制系统
在Git等版本控制系统中,LIS算法可用于确定文件修改历史中最长的连续演进路径。
9.2 股票分析
分析股价历史数据时,LIS可以帮助识别最长的连续上涨周期。
9.3 生物信息学
在DNA序列比对中,寻找最长共同子序列是基因组分析的基础操作之一。
9.4 推荐系统
根据用户行为序列,使用LIS变种可以识别用户兴趣的长期演变趋势。
10. 个人实战经验分享
在实际编码面试和算法竞赛中,LIS问题有几个常见陷阱值得注意:
-
边界条件:空数组和全递减数组是常见的边界情况,务必测试这些case。
-
空间优化:虽然O(n²)的DP解法直观,但在处理大规模数据时,务必考虑使用O(nlogn)的优化解法。
-
变种识别:很多问题表面上看不是LIS,但经过适当转换后可以归约为LIS问题,如俄罗斯套娃信封问题。
-
输出要求:明确题目是要求长度、具体序列、序列数量还是字典序最小的序列,不同要求解法差异很大。
-
性能调优:在竞赛中,对于n=10^5规模的数据,O(n²)的解法肯定会超时,必须使用贪心+二分的优化版本。
我在实际工作中还发现,LIS思想可以灵活应用到许多看似无关的问题上。例如在分析用户行为序列时,可以用LIS变种来识别核心行为路径;在处理时间序列数据时,可以用LIS来识别主要趋势。掌握LIS问题的本质和变种,对提升算法能力大有裨益。
