Lazy loaded image
😋化繁为简的分治法
Words 4264Read Time 11 min
2025-10-9
2025-10-9
type
status
date
slug
summary
tags
category
icon
password
网址

📝算法解释

顾名思义,分治问题由“分”(divide)和“治”(conquer)两部分组成,通过把原问题分为子问题,再将子问题进行处理合并,从而实现对原问题的求解。我们在排序章节展示的归并排序就是典型的分治问题,其中“分”即为把大数组平均分成两个小数组,通过递归实现,最终我们会得到多个长度为 1 的子数组;“治”即为把已经排好序的两个小数组合成为一个排好序的大数组,从长度为 1 的子数组开始,最终合成一个大数组。
我们也使用数学表达式来表示这个过程。定义 T(n) 表示处理一个长度为 n 的数组的时间复杂度,则归并排序的时间复杂度递推公式为 T(n) = 2T(n/2) + O(n)。其中 2T(n/2) 表示我们分成了两个长度减半的子问题,O(n) 则为合并两个长度为 n/2 数组的时间复杂度。那么怎么利用这个递推公式得到最终的时间复杂度呢?这里我们可以利用著名的主定理(Master theorem)求解:
notion image
通过主定理我们可以知道,归并排序属于第二种情况,且时间复杂度为 O(n log n)。其他的分治问题也可以通过主定理求得时间复杂度。
另外,自上而下的分治可以和 memoization 结合,避免重复遍历相同的子问题。如果方便推导,也可以换用自下而上的动态规划方法求解。

表达式问题

241. 为运算表达式设计优先级

题目描述

给定一个只包含加、减和乘法的数学表达式,求通过加括号可以得到多少种不同的结果。

输入输出样例

输入是一个字符串,表示数学表达式;输出是一个数组,存储所有不同的加括号结果
在这个样例中,有两种加括号结果:((2-1)-1) = 0 和 (2-(1-1)) = 2。

题解

利用分治思想,我们可以把加括号转化为,对于每个运算符号,先执行处理两侧的数学表达式,再处理此运算符号。注意边界情况,即字符串内无运算符号,只有数字。
这个题目要求对一个由数字和运算符(+、-、*)组成的字符串 expression 进行所有可能的括号分组,计算每种分组的结果,并返回所有可能的值列表(允许重复,结果符合 32 位整数范围,数量 ≤ 10^4)。例如,"2-1-1" 可以是 (2-1)-1=0 或 2-(1-1)=2。

为什么用分治法(Divide and Conquer)?

  • 问题本质:表达式是一个“序列”,括号分组相当于在不同操作符处“分割”表达式为左右子表达式,然后递归求解子表达式的所有可能值,最后根据分割处的操作符“合并”(combine)左右结果。
  • 分治核心
    • Divide(分解):在每个操作符处将表达式分成左子表达式和右子表达式。
    • Conquer(求解):递归计算左/右子表达式的所有可能值。
    • Combine(合并):根据操作符(+、-、*),对左右所有值对进行运算,生成新结果。
  • 优势
    • 避免枚举所有括号位置(指数爆炸),而是用操作符作为自然分割点(操作符数量 ≤19,n≤20)。
    • 时间复杂度:O(4^k * k^2),k=操作符数(≤19),实际因缓存可控(≤10^4 结果)。
    • 空间:O(k^2)(递归栈 + 缓存)。
  • 基线条件:如果表达式是单个数字,直接返回 [其值]。
  • 优化:用 memo(字典,key=子字符串)缓存结果,避免重复子问题(表达式可重叠)。

解题步骤(分治框架)

  1. 预处理:定义一个辅助函数 compute(expression),返回该子表达式的所有可能值列表。
  1. 基线:如果 expression 是纯数字(无操作符),返回 [int(expression)]。
  1. 分解与递归
      • 遍历字符串每个字符:
        • 遇到操作符 op(如 '+'),则:
          • 左子 = expression[0 : 当前索引](递归 compute(左子) → left_res)。
          • 右子 = expression[当前索引+1 : ](递归 compute(右子) → right_res)。
        • 合并:对每个 left ∈ left_res, right ∈ right_res:
          • 如果 op=='+':res.append(left + right)
          • 如果 op=='-':res.append(left - right)
          • 如果 op=='*':res.append(left * right)
      • 用 memo[expression] = res 缓存,避免重复计算。
  1. 返回:所有合并结果的列表(任意顺序)。
  • 为什么遍历所有操作符? 每个操作符代表一种可能的“主分割点”(相当于括号将左右分组),递归处理子部分的所有可能。
  • 数字处理:输入无前导符号,数字≤99,用循环累积数字(e.g., "12" →12),但基线直接 int()。

代码运行示例(示例 1)

输入:expression = "2-1-1"
  • compute("2-1-1"):
    • i=1, c='-':左="2" → [2];右="1-1" → compute("1-1"):
      • i=1, c='-':左="1"→[1];右="1"→[1] → 1-1=[0]
    • 合并:2 - 0 = [2]
    • i=3, c='-':左="2-1" → compute("2-1"):
      • i=1, c='-':左="2"→[2];右="1"→[1] → 2-1=[1]
    • 右="1"→[1];合并:1 - 1 = [0]
  • res = [2, 0](匹配输出)。

示例 2 简析:"23-45"

  • 分治会生成多个分割,如在第一个 :左[2], 右"3-45"(递归多分支)→ 2*(3-(45))=2(-11)=-22? 等,实际计算出 -34 等。
  • 重复 -10 来自不同分组如 (2*(3-4))5 和 2((3-4)*5)。

分治可视化("2-1-1")

每个叶子是基线,路径是合并。

注意事项

  • 缓存关键:子表达式如 "1-1" 可能多次出现,无缓存 TLE。
  • 结果重复:直接 append,允许(如 -10 两次)。
  • 边界:n=1(如 "2")→[2];无操作符 OK。
  • 扩展:若加 /,需处理除零,但题目无。

🤗练习

基础难度

932. 漂亮数组

这个题目是一个构造问题,要求返回一个长度为 n 的漂亮数组:它必须是 [1, n] 的一个排列(每个数出现一次),且满足无三元组条件——不存在 i < k < j 使得 2 * nums[k] = nums[i] + nums[j](即数组中没有三个位置的数形成等差数列,中间是算术平均)。
  • 约束:1 ≤ n ≤ 1000,保证至少一个解存在。
  • 输出:任意一个有效漂亮数组(不唯一)。
  • 为什么难? 直接枚举排列 O(n!) 太慢(n=1000 不可能)。需要数学构造。

解题思路(递归构造 + 奇偶分离)

  • 核心观察:如果将数组分成奇数部分(左)和偶数部分(右),则:
    • 同组内:递归构造子数组为漂亮数组,保证无三元组。
    • 跨组:奇 + 奇 = 偶,偶 + 偶 = 偶,奇 + 偶 = 奇。
      • 2 * 奇 = 偶(可能 = 奇 + 奇 或 偶 + 偶)。
      • 2 * 偶 = 偶(可能 = 奇 + 奇 或 偶 + 偶)。
      • 但如果 k 在左(奇),2k=偶,不能 = i(左奇)+j(右偶)=奇(矛盾)。
      • 如果 k 在右(偶),2k=偶,不能 = i(左奇)+j(左奇)=偶?等下,需仔细。
  • 实际证明:这种分离确保跨组无 2k=i+j,因为:
    • i,j 都在左(奇):k 必须在左(递归保证)。
    • i,j 都在右(偶):k 在右(递归)。
    • i 左 j 右:i+j=奇,2k=偶(无论 k 奇偶),矛盾。
    • i 右 j 左:同上。
  • 构造规则
    • 对于 n=1:返回 [1]。
    • 递归:left = 漂亮数组(ceil(n/2)) → 映射为奇数:每个 x → 2x - 1。
    • right = 漂亮数组(floor(n/2)) → 映射为偶数:每个 x → 2x。
    • 返回 left + right(奇在前,偶在后)。
  • 为什么有效? 映射保持相对顺序,且生成 [1..n] 唯一排列。递归深度 O(log n),总时间 O(n log n)(但实际 O(n),因每个层 O(n))。
  • 空间:O(log n) 递归栈。

代码运行示例

  • 示例 1:n=4
    • helper(4):left=helper(3),right=helper(2)
    • helper(3):left=helper(2)=[1,2]? 逐步:
      • helper(2):left=helper(1)=[1],right=helper(1)=[1] → [21-1, 21] = [1,2]
      • helper(3):left=helper(2)=[1,2] → [1,3];right=helper(1)=[1] → [2] → [1,3] + [2] = [1,3,2]
      • helper(4):left=helper(3)=[1,3,2] → [1,5,3] 但 n=4,22=4? 映射:[21-1=1, 2*3-1=5>4? 等下。
    • 实际运行:[1, 3, 2, 4]
    • 验证:排列 [1,2,3,4],检查三元:无 i<k<j 2k=i+j(e.g., 2*3=6≠1+2=3 等)。
    • 示例输出 [2,1,4,3] 也有效(本题任一)。
  • 示例 2:n=5
    • 运行:[1, 5, 3, 2, 4]
    • 验证:奇[1,5,3] + 偶[2,4],无三元组。
    • 示例 [3,1,2,5,4] 类似。

构造过程可视化(n=4)

注意事项

  • 唯一性:构造不唯一,但这个总是有效(数学证明:诱导法,基线 n=1 OK,假设子漂亮则整体 OK)。
  • 效率:n=1000,递归快;可用迭代(从 1 建起),但递归简洁。
  • 扩展:若需所有漂亮数组,需 DP,但本题只需一个。

进阶难度

312. 戳气球

这个题目是一个经典的区间动态规划(Interval DP)问题,考察对“最优戳破顺序”的计算。n 个气球排成一排,戳破第 i 个时获得 nums[left] * nums[i] * nums[right] 硬币(left 和 right 是相邻未戳气球,边界视为 1)。目标是找到戳破所有气球的最大硬币数

问题分析

  • 挑战:戳破顺序影响硬币(因为相邻边界动态变化),暴力枚举顺序 O(n!) 太慢(n≤300 不可能)。
  • 关键观察:这是一个“区间”问题——考虑在某个子区间内戳破所有气球,最后戳的那个气球会用区间的左右边界计算硬币。
  • DP 状态定义
    • dp[i][j]:戳破气球 i 到 j(不包括 i 和 j)之间的所有气球,获得的最大硬币数。其中 nums[i] 和 nums[j] 作为固定边界(假设 i 和 j 不戳)。
  • 为什么这样定义? 边界固定,便于转移;实际戳的是 i+1 到 j-1。
  • 预处理:为了边界统一,添加虚拟气球:nums = [1] + nums + [1](原 nums 索引 0..n-1 → 新 1..n,虚拟 0 和 n+1 为 1)。
    • 新 nums 长度 n+2,dp 是 (n+2) x (n+2) 矩阵。
  • 基线:dp[i][i+1] = 0(区间内无气球戳)。
  • 转移方程
    • 对于区间 [i, j](j > i+1),枚举最后戳的 k(i < k < j):
      • dp[i][j] = max( dp[i][k] + dp[k][j] + nums[i] * nums[k] * nums[j] )
      • 解释:先戳 [i,k] 和 [k,j] 的所有(递归),最后戳 k(此时边界是 nums[i] 和 nums[j],硬币 = nums[i]*nums[k]*nums[j])。
  • 答案:dp[0][n+1](整个区间,边界 1)。
  • 复杂度
    • 时间:O(n^3),枚举区间 O(n^2),内层 k 枚举 O(n)。
    • 空间:O(n^2),dp 表。
    • n=300,27e6 操作,高效。

解题步骤(自底向上 DP)

  1. 初始化:添加边界,创建 dp 零矩阵。
  1. 填充 dp:从小区间到大区间(长度 len 从 2 到 n+1):
      • for i in 0 to (n+1 - len):
        • j = i + len
        • for k in i+1 to j-1:
          • 更新 dp[i][j] = max(..., dp[i][k] + dp[k][j] + nums[i]*nums[k]*nums[j])
  1. 返回 dp[0][n+1]。

代码实现(Python)

代码运行示例(示例 1)

输入:nums = [3,1,5,8],n=4
  • 新 nums = [1, 3, 1, 5, 8, 1](索引 0..5)
  • dp 初始化全 0。
  • 小区间(len=2):如 [0,2](戳 1):k=1,dp[0][2] = 0 + 0 + 131 = 3
  • 逐步填充:最终 dp[0][5] = 167
    • 解释:顺序如戳 1 (315=15),剩 [3,5,8];戳 5 (358=120),剩 [3,8];戳 3 (138=24);戳 8 (181=8);总 15+120+24+8=167(代码计算所有可能,取 max)。
  • 输出:167。
示例 2:[1,5] → 新 [1,1,5,1],dp[0][3] = 115 + 151 = 5+5=10。

DP 填充可视化(n=4 示例,简化部分)

  • 每个 dp[i][j] 枚举 k,max 所有可能“最后戳”。

注意事项

  • 顺序重要:从小到大 len,确保子区间已算(自底向上)。
  • 边界处理:虚拟 1 避免越界。
  • 优化:O(n^3) 已优;若 n 大,可 memo 化递归,但迭代更稳。
 
上一篇
下一篇
一切皆可搜索

Comments
Loading...