ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

暴力递归到动态规划:有序表与平衡搜索二叉树的进阶套路

暴力递归到动态规划:有序表与平衡搜索二叉树的进阶套路 开篇先问一个问题如果你已经写过了暴力递归版本的题目答案但提交时超时了你会怎么优化大部分人第一反应是“加缓存”然后发现缓存加的位置不对或者改完反而更乱了。其实从暴力递归到动态规划中间有一条非常清晰的主线——先是找到递归里的重复子问题再把重复计算的结果存起来最后才是把“递归展开图”翻译成一张二维表格。等这条路走顺了你再看有序表、AVL树、SB树这些平衡搜索二叉树会发现它们其实是同一个方法论在数据结构上的延伸把暴力查找变成有组织、有平衡性的检索。这篇博客就是按照这个递进关系讲讲暴力递归如何准确转化成动态规划以及有序表为什么值得单独拎出来当“算法5”的主角之一。有面试需求的人、刷LeetCode刷到动态规划卡壳的人、还有想弄懂TreeMap底层平衡机制的人都能在这篇里找到能直接用的套路。1. 内容整体设计与思路拆解1.1 课程定位这一讲在整个算法体系里的位置原本这套算法课程是按阶段推进的前几讲解决的是“怎么遍历数据”和“怎么递归枚举”这一讲终于把两条线拧到了一起——“暴力递归到动态规划”是在教你把枚举的代价砍掉“有序表”是在教你一种底层自带平衡机制、支持有序增删查的搜索树结构。两者表面上互不相干但底层逻辑都在做同一件事用空间换时间用规律代替盲搜。动态规划最核心的动作是“记住子问题的答案避免重复计算”有序表最核心的动作是“通过旋转保持树的高度平衡保证每次查找在O(log n)量级”。前者缓存的是函数的返回结果后者缓存的是元素的相对位置关系。理解了这一点你后面看什么区间DP、树形DP、AVL旋转都不再是背诵套路而是顺着“怎么消灭低效”这条思路自然生长出来的东西。1.2 构思主线为什么“套路”比“解法”更重要我在刷题和带新人的过程中发现大多数人对动态规划的卡点不是“不会写状态转移”而是拿到题目以后不知道怎么从零开始设计尝试。所以这篇博客的主线刻意安排成先把“暴力递归”当成唯一靠谱的起点教你一套通用的尝试模型再从递归树里找出重复分支推导出记忆化搜索最后把记忆化搜索改写成严格表结构得到真正意义上的动态规划。有序表放在后面是因为它需要的思维方式——“保持平衡”——和动态规划需要的“拆解缓存”正好形成互补。AVL、SB树、红黑树虽然在细节上各不相同但接口完全一致插入、删除、查前驱后继、查第K小。学会一种其他都能快速迁移。提示如果你只刷“题解速览”类的总结很难体会到“为什么状态定义成这样”。这篇讲的是从暴力尝试一路不跳步走到动态规划的过程建议手推至少一遍递归展开图再往下读。2. 核心细节解析与实操要点2.1 暴力递归的本质每个递归都是在做“尝试”暴力递归写起来不难难在很多人不知道“递归尝试”这个本质。所谓尝试就是你站在某一步把所有可能的选择都列出来然后递归处理剩下的事。这句话虽然简单但背后藏着两个关键要求每一步的可选项必须完整漏掉一种就漏掉整个答案分支每一步只做一个阶段性的决定剩下的交给下一层递归。举个例子打印一个字符串的全部子序列你站在第一个字符面前选择就是“要”或“不要”然后带着 “当前已经拼好的前缀 还没处理的后缀”继续往下走。把所有分支走完就是所有子序列。这是典型的从左到右尝试模型也是暴力递归里最常用、最容易理解的一种尝试方式。另一个常用模型是“范围尝试”比如求一个数组上的某个区间能达到的最好结果。这时候递归参数就不是“位置i”而是“左边界L、右边界R”。范围尝试在动态规划的面试题里占了很大比例因为它对应的状态表是二维的写状态转移的规律更明显。2.2 什么样的递归值得改成动态规划抓重复子问题不是所有递归都需要改动态规划。很多递归是天然去重的比如快排的分治、二分查找它们的递归树每个节点只访问一次缓存没有任何意义。值得改动态规划的标志只有一个递归展开图里有重复的节点而且重复节点算出来的结果完全相同。判断方法很简单画递归展开图。以“斐波那契数列”为例F(5)会展开出两个F(3)两个F(3)又会各自展开出重复的F(2)重复分支会指数级膨胀。这就是典型的重复子问题。反之如果你画出来的递归图是一棵标准树没有任何交叉那就不需要动态规划。2.3 暴力递归的参数设计状态由“可变参数”决定暴力递归函数能改成动态规划前提是递归函数的所有可变参数都列全了而且它们是最终状态空间的自变量。这里有个工程上的实用经验可变参数的个数决定了状态表的维度。一个可变参数一维数组缓存两个可变参数二维数组或哈希表缓存三个可变参数三维缓存需要慎重数据量一大就容易超空间更多可变参数优先考虑状态压缩否则基本没法直接用表结构。参数设计不要漏掉“当前走到哪”也不要漏掉“还剩什么限制条件”。我见过不少同学写递归时把所有信息全塞进参数结果状态空间爆炸也有同学为了“少写参数”把信息藏进全局变量导致状态不完整缓存命中错误——这两种极端都是设计失败。2.4 有序表平衡搜索二叉树的基础认知回到有序表。常规搜索二叉树有个致命问题如果不做平衡处理反复插入有序数据会让树退化成一条链查找复杂度从O(logn)直降为O(n)。平衡搜索二叉树的思路就是在每次插入、删除之后通过旋转操作让树重新变平衡从结构上保证操作效率。AVL树是最容易理解的平衡实现它要求每个节点的左右子树高度差绝对值不超过1。一旦某个节点失衡就根据失衡类型做左旋或者右旋。SB树Size Balanced Tree则是用“每个叔叔节点的节点数不小于侄子节点数”来约束平衡。红黑树用颜色标记来近似平衡工程上用它做标准库的TreeMap、std::map实现。无论哪种对外表现都是有序表支持有序性查询、插入、删除、找前驱后继。3. 实操过程与核心环节实现3.1 完整案例机器人走路问题从暴力递归到严格表结构先选一个经典的可复现案例给定一个长度为N的线段机器人初始位置在start每次必须移动一步一共走K步问最终停在aim位置的方法有多少种。第一版写暴力递归。核心是尝试模型当前位置是cur还剩rest步如果rest为0能停在aim就返回1否则返回0如果cur在左边界只能往右走在右边界只能往左走中间位置可以左右各尝试一次。代码如下public class Robot { // N: 线段长度, cur: 当前来到的位置, rest: 还剩几步要走 // aim: 目标位置, N: 总长度 public static int process(int N, int cur, int rest, int aim) { if (rest 0) { return cur aim ? 1 : 0; } if (cur 1) { return process(N, cur 1, rest - 1, aim); } if (cur N) { return process(N, cur - 1, rest - 1, aim); } return process(N, cur 1, rest - 1, aim) process(N, cur - 1, rest - 1, aim); } }注意这个递归的可变参数只有cur和restN和aim全程不变所以状态空间是(cur, rest)构成的二维平面。下一步画递归展开图你会发现同一个(cur, rest)组合会从不同路径被重复访问这就是改动态规划的依据。第二版加缓存做记忆化搜索。准备一个二维数组dp[N1][rest1]初始值全部填-1表示没算过递归函数里先查缓存算完再存缓存。这里有个关键细节缓存值必须和“返回结果”一一对应如果递归函数返回的是方法总数缓存数组就存方法总数不能混存别的含义。public static int processCache(int N, int cur, int rest, int aim, int[][] dp) { if (dp[cur][rest] ! -1) { return dp[cur][rest]; } int ans; if (rest 0) { ans cur aim ? 1 : 0; } else if (cur 1) { ans processCache(N, cur 1, rest - 1, aim, dp); } else if (cur N) { ans processCache(N, cur - 1, rest - 1, aim, dp); } else { ans processCache(N, cur 1, rest - 1, aim, dp) processCache(N, cur - 1, rest - 1, aim, dp); } dp[cur][rest] ans; return ans; }第三版改成严格表结构也就是真正的动态规划。观察依赖关系(cur, rest)位置的值依赖cur-1, rest-1和cur1, rest-1也就是“上一层”的两个值。所以按rest从小到大的顺序填表先初始化rest0的那一列再依次填每一层。public static int dpWay(int N, int aim, int K, int start) { // 行: 位置1~N, 列: 剩余步数0~K int[][] dp new int[N 1][K 1]; dp[aim][0] 1; // rest0时只有位置在aim才有一种方法 for (int rest 1; rest K; rest) { for (int cur 1; cur N; cur) { if (cur 1) { dp[cur][rest] dp[cur 1][rest - 1]; } else if (cur N) { dp[cur][rest] dp[cur - 1][rest - 1]; } else { dp[cur][rest] dp[cur - 1][rest - 1] dp[cur 1][rest - 1]; } } } return dp[start][K]; }这个过程完整展示了“尝试→画递归图→找重复→缓存→严格表”五个步骤。每一步都基于上一步做了很小的改动没有一步是跳跃的。这就是我在工程实践中推荐的动态规划推导方式先保证暴力递归是对的再在它的基础上优化而不是上来就空想状态转移方程。3.2 参数分析与复杂度对比暴力递归的时间复杂度可以这样算每个(cur, rest)组合都会重复执行多次整体是P(N,K)级别的指数展开具体到最坏情况分支接近2^K。加缓存之后每个状态最多算一次状态数是N*(K1)单次计算O(1)总复杂度降到O(N\times K)。严格表结构同样如此空间复杂度也是O(N\times K)。在LeetCode这类题目里N10^4, K10^4时暴力递归直接栈溢出外加超时记忆化搜索勉强能过但递归栈深严格表结构最稳。从面试角度建议把三版都写出来面试官问“还能优化吗”你再逐层递进比直接甩出动态规划解法更有说服力。3.3 核心实操有序表的AVL插入与旋转过程有序表的实操重点看AVL插入后怎么维持平衡。插入流程分四步按二叉搜索树的规则做普通插入从插入位置沿着父链回溯更新每个节点的高度检查每个节点是否失衡即左右子树高度差绝对值是否大于1根据失衡类型做对应旋转旋转完再更新相关节点高度。失衡类型共有四种LL型左子树的左子树过深、RR型右子树的右子树过深、LR型左子树的右子树过深、RL型右子树的左子树过深。处理规律很好记LL右旋RR左旋LR先左旋再右旋RL先右旋再左旋。以LL为例假设节点a的左子树比右子树高2且左子树b的左子树比右子树高。这时候对a做右旋把b提上来当根a变成b的右子树b原来的右子树挂到a的左边。旋转后a和b的高度重新计算平衡恢复。代码层面AVL树的旋转就是几个指针的重定向但写的时候非常容易漏掉“中间子树”的挂接建议先在纸上画一棵具体树再动手编码。3.4 有序表接口设计为什么工程上都爱封装实际使用有序表时不建议每次都直接操作旋转逻辑。正确的做法是封装成类对外暴露几个固定接口下的通用语言比如用有序表接口封装后使用方根本不需要知道内部是AVL还是红黑树——这正好对应Java的TreeMap、C的std::map的设计哲学。常见的接口如下put(key, value)插入或更新remove(key)删除get(key)查询firstKey()/lastKey()取最小/最大keyfloorKey(key)/ceilingKey(key)取小于等于/大于等于给定key的最大/最小keykthKey(k)取第k小的keySB树往往比AVL更容易实现这个。面试手撕时大部分场景用AVL或SB树顶替红黑树即可因为核心考点是旋转和平衡思路而不是红黑树那种颜色翻转的复杂细节。4. 常见问题与排查技巧实录4.1 动态规划表格为什么填错依赖方向没搞清我在实战中发现很多同学填动态规划表时容易发生“当前格子依赖还没算出来的格子”根本原因是没先画清楚递归展开图不知道依赖方向。填表顺序必须和依赖关系保持一致填某个格子前它依赖的所有格子必须已经填好。排查技巧先不要急着写代码手动挑两三个格子写出它们的依赖格子再根据依赖关系决定循环顺序是正序还是逆序。比如背包问题里dp[i][j]依赖i-1行的若干列那外层循环就必须是i从小到大。区间DP里dp[L][R]依赖长度更短的区间那外层循环就必须按区间长度枚举。4.2 记忆化搜索的缓存数组初始值为什么不能用0这是新手最常踩的坑如果答案本身可以是0那用0当“未计算”标记就会导致已经算出来的0被当成“没算过”每次重新算一遍甚至缓存错乱。正确做法是选一个答案不可能取到的值当占位符比如-1、Integer.MIN_VALUE。如果答案涉及负数则不能用-1要改用更大的标记数组或者用HashMapNode, Integer做缓存。这里有个工程上的习惯凡是写记忆化搜索先想想返回值的取值范围再定初始值。尤其像“方法总数”“最大分数”这类主题0经常是合法结果非常容易翻车。4.3 AVL旋转实现中的常见错误清单旋转写错通常集中在三个地方漏更新高度旋转完成后子节点和新根的高度必须先更新否则下一次插入判断平衡时用的是旧高度直接失真旋转后忘记更新父引用如果节点有parent指针旋转时不仅子指针要换parent也要同步换否则后续回溯失衡时可能断链把四种旋转类型搞混LR型如果直接右旋树依然失衡必须先对左子树做左旋变成LL型再对当前节点右旋。这里建议不要在代码里硬记而是每次写旋转前先画出失衡子树的形状判断“多余的深度在哪一侧”再决定先旋哪个节点。4.4 有序表与堆、哈希表的选择错误面试题里经常出现“求滑动窗口最大值”“求中位数”“求前驱后继元素”这类需求我观察到一个很普遍的问题不知道该用堆、哈希表还是有序表。这里给出一个简洁的选型经验只关心最大/最小元素用堆需要按key直接增删查不需要有序性用哈希表需要查“有没有一个数紧挨着某个key”“某个range内有几个数”用有序表需要动态维护第k小的数有序表的kth接口或树状数组更合适。比如“求数组每个位置左边最近的小于它的数”如果只想要答案单调栈O(n)搞定但如果面试官要求“支持随时往数组里插数、随时查询”那单调栈就废了得上有序表——每次插入后floorKey查一下单次O(logn)。5. 经典面试题串联两个主题如何在一道题里协同5.1 联动的底层逻辑这道题能看得更深一层动态规划和有序表不是孤立的在真正的大数据量题目里往往先用动态规划算出一个最优值然后需要用有序表的数据结构来加速“取区间极值”“找前驱后继”这类底层操作。换句话说动态规划负责“状态依赖的逻辑”有序表负责“索引结构的效率”结合后很多题可以再降一个log级别。比如“天际线问题”从左到右扫描建筑物边界需要用有序表维护当前活跃建筑的“高度集合”每次取最高值。这本质上不是动态规划但如果你给这个扫描过程加上“dp[i]表示处理到第i个边界时最高高度”的状态设想就能看到有序表在其中扮演的加速角色。很多复杂工程题都是这样DP定方向有序表做索引。5.2 示例带有序表优化的区间覆盖计数设想这样一个场景有N个区间每次新增一个区间询问它被多少个已有区间完全覆盖。暴力做法是每次遍历所有区间比较复杂度O(N^2)。用动态规划思想可以按右端点排序后设计状态但单纯DP仍然慢——每来一个新区间你需要知道“左端点小于等于自己的左端点右端点大于等于自己的右端点”的已存区间数量这一步用有序表可以直接通过两次查询求解。具体来说用两棵有序表一棵按左端点排序一棵按右端点排序。插入新区间时分别查左端点有序表的排名和右端点有序表的排名做差就能得到完全覆盖的时间复杂度。这个解法里有序表的rank能力是关键而AVL和SB树恰好都支持查第k小或排名查询。从这个例子能看出来“暴力→DP→数据结构加速”这条路径才是算法工程化的完整闭环。5.3 从真题看考点分布总结常见的算法题出题规律动态规划与有序表经常这样配对出现动态规划求LIS 有序表二分优化LIS经典O(nlogn)解法实际调的是有序数组的二分不是平衡树但思路同源区间调度问题 TreeMap维护当前最大剩余容量典型任务调度场景第K小的矩阵和 有序表/堆的收缩维护需要动态维护“当前候选最小值集合”最长回文子序列 区间DP虽然不直接依赖有序表但状态设计思想与范围尝试一脉相承。6. 经验总结这条学习路径怎么走最稳6.1 动态规划学习不要跳步我在前面反复强调暴力递归、记忆化搜索、严格表结构三步走这里多说一点这是最符合人类认知规律的路径。大部分人学动态规划失败一上来就背状态转移方程背完不会用但如果你每次都能先写暴力递归再画递归展开图再找重复分支最后翻译成表格那么遇到任何新题都不会恐慌。在实际刷题时建议每道动态规划题都写三轮只写暴力递归版本测小样本保证逻辑正确加缓存写成记忆化搜索验证复杂度够不够改成严格表结构并确认表结构的内存是O(状态数)。三轮都过了这道题才算真正吃透。我也经常跟人说“一道题写三遍胜过三道题各写一遍。”6.2 有序表的练习重点手写AVL但理解所有变体如果你想在面试中稳妥应付有序表相关题目我的建议是手写一遍AVL插入和删除至少写两遍SB树至少看懂旋转调整的差异红黑树了解规则和行业背景即可。为什么强调AVL因为它的平衡条件最简单旋转类型最直观是理解所有平衡树的门槛。AVL写顺了SB树和红黑树只是“平衡条件不同的一棵AVL”。具体写AVL时把旋转函数拆分出来单独测试只插入三四个节点手动模拟每种旋转插入乱序数据后中序遍历结果必须仍是有序序列删除节点后中序遍历结果也必须仍是有序序列。一旦中序遍历有序树的基本性质就保住了再检查高度平衡就说明旋转正确。6.3 单测驱动如何验证自己的解法真的对了最后分享一个实操习惯写完动态规划或有序表的代码后我习惯先用最朴素的暴力解法生成小数据量的标准答案再和进阶解法做随机对拍。这里“朴素暴力”就是最直接、最没优化但逻辑最清晰的版本。随机生成几千组小规模测试数据如果两边答案完全一致基本可以确定高级解法逻辑没错。比如机器人走路问题暴力递归版本本身就是标准答案那对比dpWay和process的结果即可AVL则可以用Java标准库的TreeMap当参照物随机做大量插入、删除、查询两边结果比对。这个习惯能帮你省下大量调试时间也是我强烈推荐给所有做算法题的人的核心方法。
返回列表