当前位置: 首页 > backend >正文

【动归解题套路框架】【带备忘录的递归】【最优子结构】【自下而上DP table】

动归解题套路框架

前情提要:树的遍历框架

二叉树、多叉树的遍历是递归与动态规划的基础,二者的遍历框架存在密切关联,以下先明确核心遍历逻辑。

1. 树的结构定义

二叉树节点
class TreeNode {
public:int val;TreeNode* left;  // 左子节点TreeNode* right; // 右子节点TreeNode(int v) : val(v), left(nullptr), right(nullptr) {}
};
多叉树节点
class Node {
public:int val;std::vector<Node*> children;  // 子节点列表
};
森林

森林是多棵多叉树的集合,可用根节点列表表示:

std::vector<TreeNode*> forest;  // 森林由多棵树的根节点组成

遍历森林只需对每个根节点分别执行DFS/BFS即可。

2. 深度优先遍历(DFS)框架

二叉树的DFS遍历
void traverse(TreeNode* root) {if (root == nullptr) {return;}// 前序位置:刚进入当前节点时执行traverse(root->left);  // 遍历左子树// 中序位置:左子树遍历完毕后执行(仅二叉树有)traverse(root->right); // 遍历右子树// 后序位置:右子树遍历完毕后执行
}
多叉树的DFS遍历

多叉树无中序位置(子节点数量不固定),仅保留前序和后序:

void traverse(Node* root) {if (root == nullptr) {return;}// 前序位置:刚进入当前节点时执行for (Node* child : root->children) {  // 遍历所有子节点traverse(child);}// 后序位置:所有子节点遍历完毕后执行
}

3. 广度优先遍历(层序遍历)框架

层序遍历通过队列实现,核心是按层级依次访问节点。以下分别给出二叉树和多叉树的三种层序遍历写法。

二叉树的层序遍历
写法一:基础版(无法记录节点深度)
void levelOrderTraverse(TreeNode* root) {if (root == nullptr) {return;}std::queue<TreeNode*> q;q.push(root);  // 根节点入队while (!q.empty()) {TreeNode* cur = q.front();q.pop();// 访问当前节点std::cout << cur->val << std::endl;// 左、右子节点入队if (cur->left != nullptr) q.push(cur->left);if (cur->right != nullptr) q.push(cur->right);}
}
写法二:记录节点深度(通过层级循环控制)
void levelOrderTraverse(TreeNode* root) {if (root == nullptr) {return;}std::queue<TreeNode*> q;q.push(root);int depth = 1;  // 根节点视为第1层while (!q.empty()) {int sz = q.size();  // 当前层节点数量// 遍历当前层所有节点for (int i = 0; i < sz; ++i) {TreeNode* cur = q.front();q.pop();// 访问当前节点,附带深度信息std::cout << "depth = " << depth << ", val = " << cur->val << std::endl;// 左、右子节点入队if (cur->left != nullptr) q.push(cur->left);if (cur->right != nullptr) q.push(cur->right);}depth++;  // 当前层遍历完毕,深度+1}
}
写法三:通过状态类记录深度(适配复杂场景)
// 状态类:记录节点及对应深度
class State {
public:TreeNode* node;int depth;State(TreeNode* n, int d) : node(n), depth(d) {}
};void levelOrderTraverse(TreeNode* root) {if (root == nullptr) {return;}std::queue<State> q;q.push(State(root, 1));  // 根节点深度为1while (!q.empty()) {State state = q.front();q.pop();TreeNode* cur = state.node;int depth = state.depth;// 访问当前节点,附带深度信息std::cout << "depth = " << depth << ", val = " << cur->val << std::endl;// 左、右子节点入队,深度+1if (cur->left != nullptr) q.push(State(cur->left, depth + 1));if (cur->right != nullptr) q.push(State(cur->right, depth + 1));}
}
多叉树的层序遍历

多叉树的层序遍历与二叉树逻辑一致,仅将“左右子节点”替换为“所有子节点”。

写法一:基础版(无深度记录)
void levelOrderTraverse(Node* root) {if (root == nullptr) {return;}std::queue<Node*> q;q.push(root);while (!q.empty()) {Node* cur = q.front();q.pop();// 访问当前节点std::cout << cur->val << std::endl;// 所有子节点入队for (Node* child : cur->children) {q.push(child);}}
}
写法二:记录节点深度(层级循环控制)
#include <iostream>
#include <queue>
#include <vector>void levelOrderTraverse(Node* root) {if (root == nullptr) {return;}std::queue<Node*> q;q.push(root);int depth = 1;  // 根节点视为第1层while (!q.empty()) {int sz = q.size();  // 当前层节点数量for (int i = 0; i < sz; ++i) {Node* cur = q.front();q.pop();// 访问当前节点,附带深度信息std::cout << "depth = " << depth << ", val = " << cur->val << std::endl;// 所有子节点入队for (Node* child : cur->children) {q.push(child);}}depth++;  // 当前层遍历完毕,深度+1}
}
写法三:状态类记录深度(适配复杂权重场景)

作用:当节点间的“边权重”非1时(如深度递增非固定值),通过状态类可灵活记录节点深度,适用性更强。

// 状态类:记录节点及对应深度
class State {
public:Node* node;int depth;State(Node* n, int d) : node(n), depth(d) {}
};void levelOrderTraverse(Node* root) {if (root == nullptr) {return;}std::queue<State> q;q.push(State(root, 1));  // 根节点深度为1while (!q.empty()) {State state = q.front();q.pop();Node* cur = state.node;int depth = state.depth;// 访问当前节点,附带深度信息std::cout << "depth = " << depth << ", val = " << cur->val << std::endl;// 所有子节点入队,深度+1(若权重非1可调整增量)for (Node* child : cur->children) {q.push(State(child, depth + 1));}}
}

正题:动态规划(Dynamic Programming)核心框架

概要:动态规划的本质与核心问题

动态规划(DP)是求最值的优化算法,核心逻辑可总结为:

  1. 本质:穷举所有可能解,从中筛选最值;
  2. 核心三要素
    • 重叠子问题:存在重复计算的子问题,导致暴力解法效率低下;
    • 最优子结构:原问题的最值可通过子问题的最值推导得出;
    • 状态转移方程:描述原问题与子问题的关系,是穷举的数学表达(核心难点);
  3. 解题步骤:明确「状态」→ 定义「选择」→ 推导「状态转移方程」→ 用备忘录/DP table优化重叠子问题。

动态规划三要素详解与案例:斐波那契数列

斐波那契数列虽非严格DP问题(无最值),但清晰展示了重叠子问题及优化过程,是理解DP的基础。

1. 暴力递归:暴露重叠子问题

斐波那契数列的数学定义为:
f(n)={0n=01n=1f(n−1)+f(n−2)n>1f(n) = \begin{cases} 0 & n=0 \\ 1 & n=1 \\ f(n-1) + f(n-2) & n>1 \end{cases}f(n)=01f(n1)+f(n2)n=0n=1n>1

暴力递归实现

// 计算第n个斐波那契数
int fib(int n) {// base case:最小子问题if (n == 0 || n == 1) {return n;}// 递归计算子问题return fib(n - 1) + fib(n - 2);
}

问题分析

  • 重叠子问题:递归树中存在大量重复计算。例如,计算f(5)时,f(3)被计算2次,f(2)被计算3次,且子树规模随n指数增长(如图1)。
    斐波那契递归树的重叠子问题
  • 时间复杂度:子问题个数为递归树节点总数(约2n2^n2n),每个子问题计算时间为O(1)O(1)O(1),总复杂度为O(2n)O(2^n)O(2n)(指数级,效率极低)。
2. 带备忘录的递归:消除重叠子问题

思路:用备忘录(数组/哈希表)记录已计算的子问题结果,避免重复计算。

实现代码

int fib(int n) {if (n < 0) return -1;  // 非法输入处理// 备忘录:存储f(0)~f(n)的结果,初始化为-1(未计算)std::vector<int> memo(n + 1, -1);// 带备忘录的递归return dp(memo, n);
}// 辅助函数:用备忘录计算f(n)
int dp(std::vector<int>& memo, int n) {// base caseif (n == 0 || n == 1) {return n;}// 若已计算,直接返回备忘录结果(剪枝)if (memo[n] != -1) {return memo[n];}// 计算子问题并记录到备忘录memo[n] = dp(memo, n - 1) + dp(memo, n - 2);return memo[n];
}

优化效果

  • 递归树被剪枝为线性结构,每个子问题仅计算一次(如图2)。
    带备忘录的斐波那契递归图
  • 时间复杂度:子问题个数为O(n)O(n)O(n),总复杂度优化为O(n)O(n)O(n)
3. 自底向上的DP table:迭代实现

思路:从最小子问题(base case)出发,通过迭代计算更大的子问题,直至得到原问题结果(自底向上)。

实现代码

int fib(int n) {// base caseif (n == 0 || n == 1) {return n;}// DP table:存储子问题结果std::vector<int> dp(n + 1);// 初始化base casedp[0] = 0;dp[1] = 1;// 从子问题逐步推导原问题for (int i = 2; i <= n; ++i) {dp[i] = dp[i - 1] + dp[i - 2];  // 状态转移方程}return dp[n];
}

核心逻辑

  • DP tabledp[i]表示第i个斐波那契数,对应状态转移方程f(i)=f(i−1)+f(i−2)f(i) = f(i-1) + f(i-2)f(i)=f(i1)+f(i2)
  • 自底向上:从i=2开始,利用已计算的dp[i-1]dp[i-2]推导dp[i],直至i=n
4. 空间复杂度优化:滚动数组

观察:状态转移方程中,dp[i]仅依赖dp[i-1]dp[i-2],无需存储完整DP table。

优化实现

int fib(int n) {// base caseif (n == 0 || n == 1) {return n;}// 用变量记录前两个状态int dp_i_1 = 1;  // 对应dp[i-1],初始为f(1)=1int dp_i_2 = 0;  // 对应dp[i-2],初始为f(0)=0for (int i = 2; i <= n; ++i) {// 计算当前状态int dp_i = dp_i_1 + dp_i_2;// 滚动更新前两个状态dp_i_2 = dp_i_1;dp_i_1 = dp_i;}return dp_i_1;  // 最终dp_i_1即为f(n)
}

效果:空间复杂度从O(n)O(n)O(n)降至O(1)O(1)O(1),时间复杂度仍为O(n)O(n)O(n)

核心结论:动态规划的解题纲领

  1. 状态转移方程是核心:所有优化均围绕方程展开,暴力解法直接对应方程,备忘录/DP table仅为优化工具。
  2. 优化步骤固定:暴力递归(暴露问题)→ 备忘录(自顶向下剪枝)→ DP table(自底向上迭代)→ 滚动数组(空间优化)。
  3. 本质不变:自顶向下(递归+备忘录)与自底向上(DP table)本质相同,均通过消除重叠子问题提升效率。

凑零钱问题:动态规划的典型应用

斐波那契数列仅展示了重叠子问题的优化,而凑零钱问题则完整体现动态规划的三要素(重叠子问题、最优子结构、状态转移方程),是理解DP的核心案例。

问题定义与分析

力扣第322题「零钱兑换」
给你 k 种面值的硬币(面值为 c1, c2, ..., ck,每种数量无限)和总金额 amount,求凑出该金额最少需要的硬币数;若无法凑出,返回 -1

示例
输入 coins = [1,2,5], amount = 11,输出 3(最优解:5+5+1)。

为何是动态规划问题?—— 最优子结构的判断

动态规划问题需具备最优子结构:原问题的最值可通过子问题的最值推导,且子问题间互相独立(无相互制约)。

  • 凑零钱问题的最优子结构
    若要凑出金额 amount 的最少硬币数为 dp(amount),则对于每种硬币面值 coindp(amount) 可由 dp(amount - coin) + 1 推导(即先凑出 amount - coin,再加一枚面值 coin 的硬币)。
    子问题 dp(amount - coin) 之间互不干扰(选择一枚硬币后,剩余金额的最优解独立于其他选择),因此符合最优子结构。

动态规划三要素拆解

1. 状态(State)

状态是原问题和子问题中会变化的量。本题中,唯一变化的量是「目标金额」,即从 amount 逐步减少到 0 的过程,因此状态 = 目标金额 n

2. 选择(Choice)

选择是导致状态变化的行为。本题中,选择一枚硬币会减少目标金额,因此选择 = 所有硬币的面值 coins

3. 状态转移方程

定义 dp(n) 为:「凑出目标金额 n 所需的最少硬币数」。根据状态和选择,推导方程:

dp(n)={0if n=0(无需硬币)−1if n<0(金额为负,无解)min⁡{dp(n−coin)+1∣coin∈coins}if n>0(选择一枚硬币后,取子问题最小值+1)dp(n) = \begin{cases} 0 & \text{if } n=0 \text{(无需硬币)} \\ -1 & \text{if } n<0 \text{(金额为负,无解)} \\ \min\{ dp(n - coin) + 1 \mid coin \in coins \} & \text{if } n>0 \text{(选择一枚硬币后,取子问题最小值+1)} \end{cases}dp(n)=01min{dp(ncoin)+1coincoins}if n=0(无需硬币)if n<0(金额为负,无解)if n>0(选择一枚硬币后,取子问题最小值+1

解法一:暴力递归(暴露问题)

暴力递归直接实现状态转移方程,但存在大量重叠子问题,效率极低。

核心逻辑

通过递归遍历所有可能的硬币选择,计算子问题 dp(n - coin) 并取最小值加1。

class Solution {
public:int coinChange(vector<int>& coins, int amount) {return dp(coins, amount); // 最终答案为dp(amount)}private:// dp定义:凑出目标金额n所需的最少硬币数;若无解,返回-1int dp(vector<int>& coins, int n) {// base caseif (n == 0) return 0;    // 金额为0,无需硬币if (n < 0) return -1;    // 金额为负,无解int res = INT_MAX;       // 初始化结果为无穷大(用于后续取最小值)for (int coin : coins) { // 遍历所有选择(硬币面值)int subProblem = dp(coins, n - coin); // 计算子问题:n - coin的最少硬币数if (subProblem == -1) continue;       // 子问题无解,跳过当前硬币res = min(res, subProblem + 1);       // 更新最小值(子问题结果+当前硬币)}// 若res仍为INT_MAX,说明所有子问题无解,返回-1;否则返回resreturn res == INT_MAX ? -1 : res;}
};

问题分析

  • 重叠子问题:递归树中大量子问题重复计算。例如,凑 amount=11 时,dp(9)dp(6) 等子问题会被多次计算(如图1)。
    凑零钱问题的递归树重叠子问题
  • 时间复杂度:最坏情况下为 O(kn)O(k^n)O(kn)kkk 为硬币种类,nnn 为金额),指数级,无法通过大测试用例。

解法二:带备忘录的递归(优化重叠子问题)

通过备忘录记录已计算的子问题结果,消除重复计算,将时间复杂度降至多项式级别。

核心逻辑

用数组 memo 存储 dp(n) 的结果,每次计算前先查备忘录,避免重复递归。

class Solution {
private:vector<int> memo; // 备忘录:memo[n]记录dp(n)的结果public:int coinChange(vector<int>& coins, int amount) {memo.resize(amount + 1, -666); // 初始化备忘录为-666(标记未计算)return dp(coins, amount);}private:int dp(vector<int>& coins, int n) {// base caseif (n == 0) return 0;if (n < 0) return -1;// 查备忘录:若已计算,直接返回结果if (memo[n] != -666) return memo[n];int res = INT_MAX;for (int coin : coins) {int subProblem = dp(coins, n - coin);if (subProblem == -1) continue;res = min(res, subProblem + 1);}// 记录结果到备忘录memo[n] = (res == INT_MAX) ? -1 : res;return memo[n];}
};

优化效果

  • 重叠子问题消除:每个子问题 dp(0)~dp(amount) 仅计算一次,子问题总数为 O(n)O(n)O(n)
  • 时间复杂度O(kn)O(kn)O(kn)kkk 为硬币种类,nnn 为金额),每个子问题需遍历 kkk 种硬币。

解法三:DP数组迭代解法(自底向上)

将递归改为迭代,用DP数组存储子问题结果,进一步优化效率(避免递归栈开销)。

核心逻辑

定义 dp[i] 为凑出金额 i 所需的最少硬币数,从 i=0 开始自底向上计算,直至 i=amount

class Solution {
public:int coinChange(vector<int>& coins, int amount) {// 初始化DP数组:dp[i]表示凑出金额i的最少硬币数vector<int> dp(amount + 1, amount + 1); // 初始化为amount+1(等效于无穷大,避免溢出)dp[0] = 0; // base case:金额0需0枚硬币// 遍历所有状态(金额从1到amount)for (int i = 0; i < dp.size(); ++i) {// 遍历所有选择(硬币面值)for (int coin : coins) {if (i - coin < 0) continue; // 子问题i-coin无解,跳过// 更新dp[i]:取当前值与子问题dp[i-coin]+1的最小值dp[i] = min(dp[i], 1 + dp[i - coin]);}}// 若dp[amount]仍为amount+1,说明无解,返回-1;否则返回dp[amount]return (dp[amount] == amount + 1) ? -1 : dp[amount];}
};

关键优化

  • 初始值选择:用 amount + 1 代替 INT_MAX,避免 dp[i - coin] + 1 时的整型溢出(因最大硬币数不超过 amountamount + 1 等效于无穷大)。
  • 自底向上优势:无需递归栈,空间复杂度为 O(n)O(n)O(n)(DP数组大小),时间复杂度仍为 O(kn)O(kn)O(kn)

三种解法对比

解法核心思路时间复杂度空间复杂度适用场景
暴力递归直接递归实现状态转移方程O(kn)O(k^n)O(kn)O(n)O(n)O(n)理解问题本质,小规模输入
带备忘录递归递归+备忘录剪枝O(kn)O(kn)O(kn)O(n)O(n)O(n)中等规模输入,代码简洁
DP数组迭代自底向上填充DP数组O(kn)O(kn)O(kn)O(n)O(n)O(n)大规模输入,效率更高

三、动态规划总结

动态规划的核心是聪明地穷举:通过定义状态、选择和状态转移方程明确穷举逻辑,再用备忘录或DP数组消除重叠子问题,利用最优子结构推导结果。

解题流程

  1. 判断问题类型:是否存在最值问题,且具备最优子结构(子问题独立,原问题最值可由子问题最值推导)。
  2. 定义三要素
    • 状态:变化的量(如凑零钱问题中的目标金额);
    • 选择:导致状态变化的行为(如硬币面值);
    • 状态转移方程:描述原问题与子问题的关系(核心难点)。
  3. 实现与优化
    • 先写暴力递归(直接实现状态转移方程);
    • 用备忘录(自顶向下)或DP数组(自底向上)优化重叠子问题;
    • 必要时压缩空间(如斐波那契的滚动数组)。

核心结论

  • 动态规划的本质是优化后的穷举,关键在于写出状态转移方程;
  • 重叠子问题是效率瓶颈,备忘录和DP数组是通用优化手段;
  • 最优子结构是问题可解的前提,确保子问题结果能直接用于原问题。

掌握这套框架后,面对多数动态规划问题均可按此流程分析求解,从斐波那契到凑零钱,再到更复杂的问题(如最长递增子序列、编辑距离),核心逻辑始终一致。

http://www.xdnf.cn/news/15561.html

相关文章:

  • FunASR Paraformer-zh:高效中文端到端语音识别方案全解
  • Linux运维新手的修炼手扎之第19天
  • 【从零开始学习大模型】什么是MCP协议
  • PostGres超过最大连接数报错
  • 多级缓存架构与热点探测系统核心技术解析
  • AI时代基础入门
  • 测试学习之——Pytest Day2
  • 深入理解 Kafka 核心:主题、分区与副本的协同机制
  • Scalefusion 与 EasyControl 对比:轻量级方案与全功能 IoT MDM 的深度碰撞
  • spring容器的bean是单例还是多例的?线程安全吗?
  • AI编程神器 Claude Code 安装及使用体验
  • SQLSERVER清理日志
  • 【28】MFC入门到精通——MFC串口 Combobox 控件实现串口号
  • Python面向对象编程(OOP)详解:通俗易懂的全面指南
  • HTTP vs HTTPS
  • Linux驱动基础:阻塞、休眠、poll、异步通知
  • 探究Netty 4.2.x版本
  • 增程式汽车底盘设计cad【9张】三维图+设计说明书
  • 单列集合顶层接口Collection
  • 医疗AI“全栈原生态“系统设计路径分析
  • 【游戏引擎之路】登神长阶(十八):3天制作Galgame引擎《Galplayer》——无敌之道心
  • 用AI做带货视频评论分析进阶提分【Datawhale AI 夏令营】
  • LLM大语言模型不适合统计算数,可以让大模型根据数据自己建表、插入数据、编写查询sql统计
  • 加速度传感器的用途与应用
  • es启动问题解决
  • 【C#】实体类定义的是long和值识别到的是Int64,实体类反射容易出现Object does not match target type
  • 高性能架构模式——高性能NoSQL
  • 【MySQL基础】MySQL事务详解:原理、特性与实战应用
  • 用PyTorch手写透视变换
  • 嵌入式学习-PyTorch(5)-day22