十 一. 算法
算法
一. 回溯法
回溯法是一种选优搜索法,按选优条件向前搜索,以达到目标。但当探索到某一步时,发现原先选择并不优或达不到目标,就退回一步重新选择,这种走不通就退回再走的技术为回溯法,而满足回溯条件的某个状态的点称为 “回溯点”。
回溯法通常用于解决组合优化问题、排列问题、子集问题等,其核心思想是深度优先搜索(DFS),通过递归的方式遍历解空间树,在遍历过程中根据约束条件和目标函数进行剪枝,避免无效的搜索,从而提高搜索效率。
算法步骤
- 定义解空间:明确问题的所有可能解的集合,通常可以用树结构来表示解空间,称为解空间树。
- 深度优先搜索:从解空间树的根节点开始,按照深度优先的策略进行搜索。
- 判断约束条件:在搜索过程中,每到达一个节点,检查该节点是否满足问题的约束条件。如果不满足,则停止对该节点子树的搜索,回溯到上一层节点。
- 判断目标函数:若节点满足约束条件,进一步检查该节点是否满足目标函数的要求。如果满足,并且已经到达叶节点(表示找到了一个可行解),则记录该解。
- 回溯:当在某一节点处已经探索完它的所有子节点后,或者发现从该节点继续探索下去不可能得到更优解时,就回溯到该节点的父节点,继续对其他未探索的子节点进行搜索。
全排列问题
import java.util.ArrayList; import java.util.List; public class Permutations { public List<List<Integer>> permute(int[] nums) { List<List<Integer>> result = new ArrayList<>(); if (nums == null || nums.length == 0) { return result; } boolean[] used = new boolean[nums.length]; backtrack(nums, used, new ArrayList<>(), result); return result; } private void backtrack(int[] nums, boolean[] used, List<Integer> current, List<List<Integer>> result) { // 如果当前排列的长度等于数组的长度,说明找到了一个全排列 if (current.size() == nums.length) { result.add(new ArrayList<>(current)); return; } for (int i = 0; i < nums.length; i++) { // 如果该数字已经在当前排列中,跳过 if (used[i]) { continue; } // 选择当前数字 used[i] = true; current.add(nums[i]); // 递归进入下一层 backtrack(nums, used, current, result); // 回溯,撤销选择 used[i] = false; current.remove(current.size() - 1); } } public static void main(String[] args) { Permutations solution = new Permutations(); int[] nums = {1, 2, 3}; List<List<Integer>> permutations = solution.permute(nums); for (List<Integer> permutation : permutations) { System.out.println(permutation); } } }
复杂度
- 时间复杂度:
,其中 是数组的长度。因为全排列的总数为 ,每个排列都需要 的时间来复制到结果列表中。 - 空间复杂度:
,主要用于递归调用栈和标记数组used
的空间开销。
二. 分治法
分治法的核心思想是将一个规模较大、难以直接解决的问题,分解成若干个规模较小、相互独立且与原问题形式相同的子问题,然后递归地解决这些子问题,最后将子问题的解合并起来,得到原问题的解。简单来说,分治法主要包括三个步骤:分解(Divide)、解决(Conquer)、合并(Combine)。
算法步骤
- 分解(Divide):将原问题分解为若干个规模较小、相互独立、与原问题形式相同的子问题。分解的原则是尽量使子问题的规模大致相同,这样可以保证算法的效率。
- 解决(Conquer):若子问题规模较小而容易被解决则直接求解,否则递归地解各个子问题。当子问题的规模足够小时,通常可以直接得到其解,无需再进行分解。
- 合并(Combine):将各个子问题的解合并为原问题的解。合并的过程需要根据具体问题的特点进行设计,确保合并后的解是原问题的正确解。
归并排序
def merge_sort(arr): # 分解步骤:如果数组长度小于等于 1,直接返回数组 if len(arr) <= 1: return arr # 找到数组的中间位置 mid = len(arr) // 2 # 递归地对左半部分和右半部分进行排序 left_half = merge_sort(arr[:mid]) right_half = merge_sort(arr[mid:]) # 合并步骤:将排好序的左半部分和右半部分合并 return merge(left_half, right_half) def merge(left, right): result = [] i = j = 0 # 比较左右子数组的元素,将较小的元素依次添加到结果数组中 while i < len(left) and j < len(right): if left[i] < right[j]: result.append(left[i]) i += 1 else: result.append(right[j]) j += 1 # 将左子数组中剩余的元素添加到结果数组中 while i < len(left): result.append(left[i]) i += 1 # 将右子数组中剩余的元素添加到结果数组中 while j < len(right): result.append(right[j]) j += 1 return result # 测试代码 arr = [38, 27, 43, 3, 9, 82, 10] sorted_arr = merge_sort(arr) print(sorted_arr)
复杂度分析
- 时间复杂度:对于归并排序,每次分解将数组分成两个大致相等的子数组,递归深度为
,每次合并操作的时间复杂度为 ,因此归并排序的时间复杂度为 。 - 空间复杂度:归并排序需要额外的空间来存储合并过程中的结果数组,因此空间复杂度为
。
三. 动态规划法
动态规划(Dynamic Programming,简称 DP)是一种通过把原问题分解为相对简单的子问题,并保存子问题的解来避免重复计算,从而解决复杂问题的算法策略。它通常用于求解具有最优子结构和重叠子问题性质的问题。
- 最优子结构:问题的最优解包含其子问题的最优解。也就是说,可以通过子问题的最优解推导出原问题的最优解。例如,在求解最短路径问题时,从起点到终点的最短路径必然包含从起点到中间某个节点的最短路径。
- 重叠子问题:在求解过程中,很多子问题会被重复计算。动态规划通过记录子问题的解,避免了这种重复计算,从而提高了算法的效率。
算法步骤
动态规划算法一般可以按照以下步骤进行:
- 定义状态:明确问题的状态表示,通常用一个或多个变量来描述子问题。状态的定义要能够准确地刻画问题的特征,并且满足最优子结构性质。
- 确定状态转移方程:根据问题的最优子结构性质,找出状态之间的递推关系。状态转移方程描述了如何从已知的子问题的解推导出当前问题的解。
- 初始化边界条件:确定问题的边界情况,即最简单的子问题的解。边界条件是状态转移方程的起点,是递推的基础。
- 计算顺序:根据状态转移方程,确定计算状态的顺序。一般有两种方式:自顶向下(递归 + 记忆化搜索)和自底向上(迭代)。
- 求解最终结果:根据状态转移方程和计算顺序,逐步计算出所有状态的值,最终得到原问题的解。
应用案例:斐波那契数列
斐波那契数列是一个经典的动态规划问题,其定义为:
代码示例(Python)
# 自底向上的动态规划实现 def fibonacci(n): if n == 0: return 0 if n == 1: return 1 # 初始化前两个状态 dp = [0] * (n + 1) dp[0] = 0 dp[1] = 1 # 从 2 开始递推计算状态 for i in range(2, n + 1): dp[i] = dp[i - 1] + dp[i - 2] return dp[n] # 测试代码 n = 10 print(f"斐波那契数列的第 {n} 项是: {fibonacci(n)}")
代码解释
- 定义状态:用
dp[i]
表示斐波那契数列的第i
项。 - 确定状态转移方程:根据斐波那契数列的定义,状态转移方程为
dp[i] = dp[i - 1] + dp[i - 2]
( )。 - 初始化边界条件:
dp[0] = 0
,dp[1] = 1
。 - 计算顺序:采用自底向上的迭代方式,从
i = 2
开始,依次计算dp[i]
的值。 - 求解最终结果:最终
dp[n]
即为斐波那契数列的第n
项。
复杂度分析
- 时间复杂度:在上述斐波那契数列的动态规划实现中,只需要遍历一次数组,因此时间复杂度为
。 - 空间复杂度:使用了一个长度为
n + 1
的数组来保存状态,因此空间复杂度为 。可以通过优化,只保存前两个状态,将空间复杂度降低到 。
优缺点
- 优点:
- 避免了重复计算,提高了算法的效率,尤其是对于具有大量重叠子问题的问题。
- 可以求解最优子结构问题,得到问题的最优解。
- 缺点:
- 需要额外的空间来保存子问题的解,空间复杂度可能较高。
- 状态定义和状态转移方程的推导需要一定的技巧和经验,对于复杂问题可能比较困难。
动态规划是一种非常实用的算法策略,在许多领域都有广泛的应用,如背包问题、最长公共子序列问题、最短路径问题等。通过合理地定义状态和状态转移方程,可以有效地解决这些复杂问题。
四. 贪心法
贪心法(Greedy Algorithm)是一种在每一步选择中都采取在当前状态下最优(即最有利)的选择,从而希望导致结果是全局最优的算法策略。虽然它并不总是能得到全局最优解,但在一些问题上,贪心算法可以高效地求出近似最优解或全局最优解。下面从多个方面对贪心法进行详细介绍:
基本概念
贪心法的核心思想是,在对问题求解时,总是做出在当前看来是最好的选择。也就是说,不从整体最优上加以考虑,它所做出的仅是在某种意义上的局部最优解。这种算法策略简单直接,在每一步决策时,只考虑当前的利益最大化,而不考虑后续可能产生的影响。
算法步骤
- 确定问题的解空间:明确问题所有可能的解构成的集合,这是算法搜索的范围。
- 定义贪心选择策略:这是贪心法的关键步骤,需要确定在每一步选择中如何做出局部最优的决策。选择策略要根据具体问题的特点来设计,例如在背包问题中,可能选择单位重量价值最大的物品作为贪心选择策略。
- 初始化解:创建一个空的解或者根据问题的初始条件设置一个初始解。
- 迭代选择:按照贪心选择策略,在每一步从剩余的选择中选取一个局部最优的元素,将其加入到解中。
- 检查约束条件:每加入一个元素后,检查当前解是否满足问题的约束条件。如果不满足,则停止添加元素,并判断当前解是否为可行解。
- 得到最终解:当无法再按照贪心策略选择元素或者已经遍历完所有可能的选择时,得到的解即为贪心算法的最终结果。
应用案例:活动安排问题
活动安排问题是指有一系列活动,每个活动都有开始时间和结束时间,在同一时间内只能进行一个活动,目标是选择尽可能多的活动来进行。
代码示例(Python)
def activity_selection(activities): # 按照活动的结束时间对活动进行排序 activities.sort(key=lambda x: x[1]) selected_activities = [] # 选择第一个活动 selected_activities.append(activities[0]) last_end_time = activities[0][1] for activity in activities[1:]: start_time, end_time = activity # 如果当前活动的开始时间大于等于上一个活动的结束时间,则选择该活动 if start_time >= last_end_time: selected_activities.append(activity) last_end_time = end_time return selected_activities # 测试数据,每个元组表示一个活动的开始时间和结束时间 activities = [(1, 4), (3, 5), (0, 6), (5, 7), (3, 8), (5, 9), (6, 10), (8, 11), (8, 12), (2, 13), (12, 14)] selected = activity_selection(activities) print("选择的活动:", selected)
代码解释
- 定义贪心选择策略:按照活动的结束时间对活动进行排序,每次选择结束时间最早的活动,这样可以为后续的活动留出更多的时间,从而尽可能地选择更多的活动。
- 初始化解:将第一个活动加入到已选择的活动列表
selected_activities
中,并记录其结束时间last_end_time
。 - 迭代选择:遍历剩余的活动,对于每个活动,如果其开始时间大于等于上一个活动的结束时间,就将其加入到已选择的活动列表中,并更新
last_end_time
。 - 得到最终解:遍历结束后,
selected_activities
列表中存储的就是选择的活动集合。
复杂度分析
- 时间复杂度:在活动安排问题中,对活动进行排序的时间复杂度为
( 为活动的数量),遍历活动的时间复杂度为 ,所以总的时间复杂度为 。 - 空间复杂度:除了输入的活动列表外,只使用了一些额外的变量和一个存储选择活动的列表,空间复杂度为
(最坏情况下,所有活动都被选择)。
优缺点
- 优点:
- 算法简单直观,实现起来相对容易。
- 时间复杂度通常较低,因为它不需要进行复杂的搜索和回溯,只需要按照贪心策略进行选择。
- 缺点:
- 不一定能得到全局最优解,因为它只考虑当前的最优选择,而忽略了整体的情况。只有在满足贪心选择性质(即一个全局最优解可以通过一系列局部最优选择得到)的问题中,贪心算法才能得到全局最优解。
- 对于一些问题,很难找到合适的贪心选择策略,需要进行深入的分析和尝试。
贪心法是一种实用的算法策略,在很多实际问题中有着广泛的应用,如最小生成树问题、哈夫曼编码问题等。在使用贪心法时,需要仔细分析问题是否满足贪心选择性质,以确保能够得到满意的解。
五. 总结
- 回溯法:
- 核心思想:深度优先搜索解空间树,按选优条件向前,遇不优或不达目标则退回重选。
- 适用场景:适用于求解组合优化、排列、子集等问题,如八皇后问题、全排列问题。
- 优点:能系统搜索解空间,保证找到所有可行或最优解,实现相对简单。
- 缺点:时间和空间复杂度可能高,最坏情况需遍历整个解空间树。
- 关键步骤:定义解空间、深度优先搜索、判断约束和目标函数、回溯。
- 分治法:
- 核心思想:将大问题分解为规模较小、相互独立且形式相同的子问题,递归求解子问题,再合并解。
- 适用场景:适用于可分解为相似子问题的问题,如排序(归并排序、快速排序)、计算幂次方、矩阵乘法等。
- 优点:降低问题求解难度,算法易理解实现,时间复杂度通常较低。
- 缺点:空间复杂度可能高,需额外空间存子问题解;递归调用有开销,小规模问题可能效率低。
- 关键步骤:分解、解决、合并。
- 动态规划法:
- 核心思想:分解原问题为简单子问题,保存子问题解避免重复计算,利用最优子结构和重叠子问题性质求解。
- 适用场景:适用于具有最优子结构和重叠子问题的问题,如斐波那契数列、背包问题、最长公共子序列问题。
- 优点:避免重复计算,提高效率,能求最优子结构问题的最优解。
- 缺点:需额外空间保存子问题解,空间复杂度可能高;状态和转移方程推导有难度。
- 关键步骤:定义状态、确定状态转移方程、初始化边界条件、选择计算顺序、求解最终结果。
- 贪心法:
- 核心思想:每一步选择当前状态下最优解,期望达全局最优,仅考虑局部最优。
- 适用场景:适用于满足贪心选择性质的问题,如活动安排问题、最小生成树问题(Prim 算法、Kruskal 算法)、哈夫曼编码问题。
- 优点:算法简单直观,实现容易,时间复杂度通常较低。
- 缺点:不一定能得全局最优解,需问题满足贪心选择性质;合适贪心策略有时难确定。
- 关键步骤:确定解空间、定义贪心选择策略、初始化解、迭代选择、检查约束条件、得到最终解。
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 微软正式发布.NET 10 Preview 1:开启下一代开发框架新篇章
· 没有源码,如何修改代码逻辑?
· PowerShell开发游戏 · 打蜜蜂
· 在鹅厂做java开发是什么体验
· WPF到Web的无缝过渡:英雄联盟客户端的OpenSilver迁移实战