%% # 纲要 > 主干纲要、Hint/线索/路标 # Q&A #### 已明确 #### 待明确 > 当下仍存有的疑惑 **❓<font color="#c0504d"> 有什么问题?</font>** # Buffer ## 闪念 > sudden idea ## 候选资料 > Read it later %% # 分治法 分治法:将原问题划分成若干个**规模较小**而**结构、形式与原问题相同或相似的子问题**(**==分==**),通过**递归**求解这些子问题(**治**),而后**合并子问题的解,得到原问题的解**(**==合==**)。 根据分解出的子问题的规模,分治法可细分为: - **==减治==**(Decrease and Counquer):拆分出的其中一个子问题**规模为 1**,其余子问题**规模缩减** - **==分治==**(Divide and Conquer):拆分出多个**规模相当**的子问题 <br> ### 分治法的应用前提 分治(包括**减而治之**和**分而治之**)是一种分析、求解问题的思路,但并非所有问题都能通过该思路解决。 - 有的问题**只能采用其中一种方式**(例如**反转数组**,只能**减治**) - 有的问题既可以采用 **减治**,也可以采用**分治**(例如数组求和) - 有的问题在递归过程中根据具体情况的变化,时而为"**减治**",时而为"**分治**"(例如最长公共子序列) 能采用分治的前提是:**原问题可以拆分为规模更小的、与原问题形式相同的子问题求解,在得到子问题的答案后即可确定原问题的答案**。 如果不满足这一点,就不能应用分治求解。 > [!example] 例如,**括号匹配问题,不能用分治解决**。 > > 如上图所示,**原问题中的括号表达式是合法的**,但将原问题拆为规模更小的子问题后,**子问题的括号表达式非法**。 > **故无法由子问题推出原问题的解**,所以不能应用分治。 > > ![image-20231123135350090|622](_attachment/00-数据结构与算法笔记/专题总结/分治与递归.assets/IMG-分治与递归-7DB2321FDB5B1ADB08AADBA6541776D5.png) > <br><br> ## 减而治之 Decrease and Conquer **减而治之**:大规模问题拆分为**两个子问题**: - 其一是退化的**平凡问题**,即**规模为 1 的情况**,能够以较优的时间复杂度直接求解 - 其二是**规模缩减的问题**,其与原问题形式几乎一样。 **递归**地求解出问题的解,再不断地将**问题的解合并起来,得到原始问题的解**。 ![image-20231112170747683|630](_attachment/00-数据结构与算法笔记/专题总结/分治与递归.assets/IMG-分治与递归-FF5F9B8B98BA2881B6561A2430894766.png) ### 递归形式 减而治之对应的递归形式:「**==线性递归==**」,每次缩小一个单位。**递归深度通常近似为 $n$**,即原问题的规模数。 - 如果求解规模为 1 的子问题只需要 $O(1)$,则求解原问题的复杂度即为 $O(n)$。 - 如果求解规模为 1 的子问题需要 $O(\log n)$,则求解原问题的复杂度为 $O(n\log n)$。 > [!NOTE] **线性递归**的过程示意: > > ![image-20231113164759111|635](_attachment/00-数据结构与算法笔记/专题总结/分治与递归.assets/IMG-分治与递归-97A1D06B2D76BFF075377C8C89C44E8D.png) > > ### 示例场景 可通过 "减而治之" 的思路递归求解的题目: - 反转链表、翻转数组、数组求和 - **插入排序** - 通常是以迭代实现,但也可以从"减而治之"的角度去看待,以递归的方式实现: 将前面段排序好,再将末尾元素插入到其中正确的位置。递归深度是 n,每次找到正确位置并插入的时间复杂度也是 n,所以最终时间复杂度为 `O(n^2)`。 <br><br> ## 分而治之 Divide and Conquer 分而治之,将大规模问题拆分为**若干个子问题**(通常两个),**各个子问题规模大体相当**,且子问题与原问题形式几乎一样。 **递归求解子问题**,由子问题的解合并得到原问题的解。 ![image-20231113162203899|664](_attachment/00-数据结构与算法笔记/专题总结/分治与递归.assets/IMG-分治与递归-C4DD492C398BAAACA93959AA7920A3E9.png) ### 递归形式 分治思路对应的递归形式:「**==树形递归==**」,每次**拆分为两个或多个规模相当的子问题**(即分裂为两个或多个子调用) - 拆分出来的**两个子问题的总规模之和可能大于原问题**,例如约为原问题的两倍 (例如最长公共子序列) - **每层递归调用**的次数**呈"==几何数列=="的形式增长**,时间复杂度取决于**递归深度**; - 假设每次拆分为两个规模相当的子问题,且**合并子问题的开销**是 $O(1)$ : - 若递归深度为 $\log n$,则时间复杂度为 $O(n)$; ($2^{\large \log n}=n$) - 若递归深度为 $n$,则总时间复杂度为 $O(2^n)$ 。 > [!note] **树形递归**的递归过程示意: > > ![image-20231113164920578|623](_attachment/00-数据结构与算法笔记/专题总结/分治与递归.assets/IMG-分治与递归-0CA11476001D4BA1987765B474216C9D.png) > ### 示例场景 可通过"分而治之"的思路递归求解的题目: - 数组求和 - 斐波拉契数列 - 最长公共子序列 --- <br><br> # 递归 递归:指**函数内部调用自身**的编程技巧。 > [!NOTE] **减而治之、分而治之**是"思想",是分析与解决问题的思路,而**递归是对二者的==实现方式==**。 递归的核心概念: - "**==递归基==**(Base Case 或 Base Condition),也称**递归边界**、基准条件、递归出口 - **递归的终止条件**,防止无限递归。 - "**==递归式==**"(Recursive Case),也称递归关系 - 将原问题拆分为若干子问题的过程,如 `F(n)=F(n-1)*n`, ## 递归的优化思路 "减而治之","分而治之"都是从 "**自顶向下**" 的角度去求解, **如果**在递归的过程中可能存在 **大量重复的递归实例**(重复计算),两种优化思路是: - **==记忆化搜索==**(备忘录)/ tabulation / memoization: - 仍然从原问题出发自顶向下,每当遇到一个子问题就**查表看是否已经计算过**,每个子问题首次计算后都将结果记入表中。 - **动态规划** / Dynamic Programming: - 从**递归基出发**,**自底向上**地**递推**得到最终答案的解。 ## 尾递归 尾递归(Tail Recursion)是一种特殊形式的递归。 在这种递归中,**==递归调用==是函数的最后一个动作**,即**函数返回的是直接结果 or 对自己的递归调用**。 这一特性使得 **尾递归可以被编译器优化**: - **栈帧重用**: - 由于当前函数返回的结果就是**递归调用的结果**,故**当前函数的执行上下文(比如局部变量、参数等)不再需要保留**,于是编译器可以 **==重用相同的栈帧来执行递归调用==**,而不是在每次递归时创建新的栈帧。这就意味着无论递归多深,**整个递归过程==只占用一个栈帧的空间==**,可以显著减少内存的使用,甚至**避免栈溢出**。 - **转换为迭代**: - 基于栈帧重用,尾递归的性质使得编译器**可以将其转换为迭代的形式**。在这种转换中,**原来的递归调用被替换为一个循环**,而原来的**函数参数则通过循环的迭代来更新**。 > [!NOTE] **尾递归形式的代码在==逻辑上==一定能够被改写成 ==迭代循环形式== 的代码** > [!caution] 注:并非所有编程语言的编译器都自动进行尾递归优化。 > > - C/C++ :**尾递归优化的发生依赖于编译器的具体实现和优化设置**。 > - Java:Java 虚拟机(JVM)通常不进行尾递归优化, > - Haskel、Scheme 等函数式编程语言:将尾递归优化作为语言规范的一部分。 > <br> ### 尾递归与非尾递归的区别 尾递归与非尾递归的**主要区别在于==递归调用后==是否还有其他操作**。 如果函数在递归调用后不执行任何操作(即直接返回递归调用的结果),则该递归是尾递归。 示例一:计算 n 的阶乘 ```cpp // 计算阶乘 // 非尾递归版本 int factorial(int n) { if (n == 0) return 1; return n * fractorial(n - 1); // 非尾递归, 递归调用后还有乘法操作. } // 尾递归版本 int factorial(int n, int accumulator = 1) { if (n == 0) return accumulator; return factorial(n - 1, accumulator * n); // 尾递归, 递归调用是最后的操作 } ``` 示例二:计算斐波那契数列的第 n 项(`F(0)==0`, `F(1)==1`)。 ```cpp // 非尾递归版本 int fibonacci(int n) { if (n <= 1) return n; return fibonacci(n - 1) + fibonacci(n - 2); // 非尾递归, 两路递归调用, 而且还要相加 } // 尾递归版本 // 初始时a==0即F(0), b==1即F(1). // 每次递归时b更新为前两个斐波拉契数a+b的和, 而a更新为b. // 从2开始, 要得到第n个斐波拉契数, 需要再迭代n-1次, 因此递归调用为`n-1`. // 递归到第n-1次时, n为1, 此时返回的b即为结果. int fibonacci(int n, int a = 0, int b = 1) { if (n == 0) return a; if (n == 1) return b; return fibonacci(n - 1, b, a + b); // 尾递归; } // 迭代版本(尾递归的逻辑即对应着迭代的逻辑) int fibonacci(int n) { if (n < 2) return n; int a = 0, b = 1; for (int i = 2; i <= n; ++i) { int tmp = a + b; a = b; b = tmp; } return b; } // 等价的迭代 int fibonacci(int n) { if (n < 2) return n; int a = 0, b = 1; --n; while (n--) { b += a; a = b - a; } return b; } ``` <br><br> # 记忆化搜索(自顶向下) **记忆化搜索**(也称 "**记忆化递归**" 或 "**备忘录法**")。 「记忆化搜索」是从**顶层问题**开始,**自顶向下地通过==递归==求解子问题**,并在**每次递归调用时检查和存储子问题的中间结果**。 > [!note] 「记忆化搜索」本质上即 "**==分治==(递归实现) + ==备忘录==**" ^zoo8sz ##### 主要思想 1. 将递归过程中遇到的**子问题的解**进行记录(例如**记录在数组或哈希表**中) 2. 每当**递归调用**需要解决一个子问题时,首先**检查这个子问题是否已经解决过**: - 如果**已解决过**,则**直接使用存储的结果**; - 如果未解决过,则**计算该子问题的解**,并将其 **==存储==起来**以备后用 <br><br> # 参考资料 # Footnotes