算法笔记

内容来源于书本与网络,仅供个人学习交流使用 笔者@StuBoo
未完待续

1.回溯法 (Backtracking)

应用:组合、排列、子集等组合型问题,0/1背包问题、图的着色问题等。

时空复杂度:时空复杂度较高,指数级别。时间复杂度:O(2^n) 或更高,其中 n 是问题规模。空间复杂度:O(n) 或更高,取决于递归深度。

特性:

  • 通过深度优先搜索遍历解空间。
  • 需要撤销选择,回溯到上一步,尝试其他选择。
  • 通常用于解决组合型问题。

1.1 总述

回溯是一种经典的搜索算法,通常用于解决组合、排列、子集等问题。

回溯法既不属于动态规划也不属于贪心算法。回溯法是一种搜索算法,它通过不断尝试各种可能的选择,然后回溯(撤销选择)来找到问题的解。回溯算法通常用于求解组合、排列、子集等问题。回溯法是一种通用的搜索算法,适用于一些组合、排列、子集等问题,而不限于最优化问题。

虽然回溯法与动态规划和贪心算法都属于求解最优化问题的算法范畴,但它们有很大的区别:

动态规划:动态规划通常通过保存子问题的解来避免重复计算,具有最优子结构。典型的动态规划问题有斐波那契数列、背包问题等。

贪心算法:贪心算法则通过每一步的局部最优选择来期望达到全局最优解,不进行回溯。典型的贪心算法问题有霍夫曼编码、最小生成树算法等。

回溯法更注重搜索整个解空间,通过深度优先搜索的方式,逐步尝试各种选择,遇到无效选择时回溯撤销选择,直到找到问题的解或遍历完整个解空间。

回溯算法基本思想:

  • 递归: 使用递归实现对解空间的深度优先搜索。
  • 选择: 在每一步根据问题的要求做出选择,尝试不同的可能性。
  • 撤销选择: 在递归完成后,撤销当前选择,进行回溯,继续尝试其他可能性。

关键点和优化:

  1. 剪枝: 在递归的过程中,通过一些条件判断提前终止不符合条件的搜索路径,减少搜索空间,提高效率。
  2. 状态重置: 在递归完成后,需要将当前选择撤销,进行回溯,保持状态的一致性。
  3. 选择列表: 在每一步的递归中,需要考虑当前可以做的选择,通常使用循环遍历选择列表。
  4. 记录路径: 如果需要记录路径或结果,需要使用合适的数据结构进行记录。

典型问题类型:

组合问题: 如组合总和、子集、电话号码的字母组合等。
排列问题: 如全排列、字符串的全排列等。
N 皇后问题: 在 n×n 棋盘上放置 n 个皇后,使其不能相互攻击。

总体思路:

  1. 确定问题的解空间和选择列表。
  2. 编写回溯函数,实现对解空间的深度优先搜索。
  3. 在递归中做出选择、递归到下一层、撤销选择,实现回溯。

1.2 LeetCode实战

全排列

给定一个不含重复数字的数组 nums ,返回其 所有可能的全排列 。你可以 按任意顺序 返回答案。

#include <vector>
class Solution {
public:
	std::vector<std::vector<int>> permute(std::vector<int>& nums) {
		std::vector<std::vector<int>> result;
		if (nums.empty()) {
			return result;
		}

		std::vector<int> current;  // 用于存储当前排列
		std::vector<bool> used(nums.size(), false);  // 记录数字是否被使用过
		backtrack(nums, current, used, result);

		return result;
	}

private:
	void backtrack(const std::vector<int>& nums, std::vector<int>& current,
					std::vector<bool>& used, std::vector<std::vector<int>>& result) {
		// 终止条件:当前排列长度达到数组长度
		if (current.size() == nums.size()) {
			result.push_back(current);  // 将当前排列加入结果集
			return;
		}

		for (int i = 0; i < nums.size(); ++i) {
			if (!used[i]) {
				// 选择当前数字,递归到下一层
				current.push_back(nums[i]);
				used[i] = true;
				backtrack(nums, current, used, result);
				// 撤销选择,进行回溯
				current.pop_back();
				used[i] = false;
			}
		}
	}
};

在这个实现中,used 数组用于记录数字是否被使用过,防止重复选择。回溯函数 backtrack 中,在每一层递归中选择当前数字,递归到下一层,然后撤销选择进行回溯。

电话号码的字母组合

给定一个仅包含数字 2-9 的字符串,返回所有它能表示的字母组合。答案可以按 任意顺序 返回。
给出数字到字母的映射如下(与电话按键相同)。注意 1 不对应任何字母。

#include <vector>
#include <string>

class Solution {
public:
	std::vector<std::string> letterCombinations(std::string digits) {
		std::vector<std::string> result;
		if (digits.empty()) {
			return result;
		}

		std::vector<std::string> mapping = {"", "", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz"};
		std::string current;
		backtrack(digits, 0, current, mapping, result);

		return result;
	}

private:
	void backtrack(const std::string& digits, int index, std::string& current,
					const std::vector<std::string>& mapping, std::vector<std::string>& result) {
		if (index == digits.size()) {
			// 当递归到字符串末尾时,将当前组合加入结果集
			result.push_back(current);
			return;
		}

		int digit = digits[index] - '0';
		const std::string& letters = mapping[digit];

		for (char letter : letters) {
			// 选择当前字母,递归到下一层
			current.push_back(letter);
			backtrack(digits, index + 1, current, mapping, result);
			// 撤销选择,进行回溯
			current.pop_back();
		}
	}
};

实现中,mapping 数组存储了数字和字母的映射关系。回溯函数 backtrack 通过递归地选择当前字母,构建可能的组合,并在递归完成后撤销选择进行回溯。

组合总和

给你一个 无重复元素 的整数数组 candidates 和一个目标整数 target ,找出 candidates 中可以使数字和为目标数 target 的 所有 不同组合 ,并以列表形式返回。你可以按 任意顺序 返回这些组合。
candidates 中的 同一个 数字可以 无限制重复被选取 。如果至少一个数字的被选数量不同,则两种组合是不同的。
对于给定的输入,保证和为 target 的不同组合数少于 150 个。

class Solution {
public:
	std::vector<std::vector<int>> combinationSum(std::vector<int>& candidates, int target) {
		std::vector<std::vector<int>> result;
		std::vector<int> current;
		backtrack(candidates, target, 0, current, result);
		return result;
	}

private:
	void backtrack(const std::vector<int>& candidates, int target, int start,
					std::vector<int>& current, std::vector<std::vector<int>>& result) {
		if (target == 0) {
			// 当目标值为0时,将当前组合加入结果集
			result.push_back(current);
			return;
		}

		for (int i = start; i < candidates.size(); ++i) {
			if (target - candidates[i] >= 0) {
				// 选择当前数字,并递归下一层
				current.push_back(candidates[i]);
				backtrack(candidates, target - candidates[i], i, current, result);
				// 撤销选择,进行回溯
				current.pop_back();
			}
		}
	}
};
N 皇后

按照国际象棋的规则,皇后可以攻击与之处在同一行或同一列或同一斜线上的棋子。
n 皇后问题 研究的是如何将 n 个皇后放置在 n×n 的棋盘上,并且使皇后彼此之间不能相互攻击。
给你一个整数 n ,返回所有不同的 n 皇后问题 的解决方案。
每一种解法包含一个不同的 n 皇后问题 的棋子放置方案,该方案中 'Q' 和 '.' 分别代表了皇后和空位。

#include <vector>
#include <string>

class Solution {
public:
	std::vector<std::vector<std::string>> solveNQueens(int n) {
		std::vector<std::vector<std::string>> result;
		if (n <= 0) {
			return result;
		}

		std::vector<std::string> board(n, std::string(n, '.'));  // 初始化棋盘
		backtrack(n, 0, board, result);

		return result;
	}

private:
	void backtrack(int n, int row, std::vector<std::string>& board,
					std::vector<std::vector<std::string>>& result) {
		// 终止条件:已经放置完所有皇后
		if (row == n) {
			result.push_back(board);  // 将当前棋盘加入结果集
			return;
		}

		for (int col = 0; col < n; ++col) {
			if (isValid(board, row, col, n)) {
				// 在当前位置放置皇后,递归到下一层
				board[row][col] = 'Q';
				backtrack(n, row + 1, board, result);
				// 撤销选择,进行回溯
				board[row][col] = '.';
			}
		}
	}

	bool isValid(const std::vector<std::string>& board, int row, int col, int n) {
		// 检查同一列是否有皇后
		for (int i = 0; i < row; ++i) {
			if (board[i][col] == 'Q') {
				return false;
			}
		}

		// 检查左上到右下斜线是否有皇后
		for (int i = row - 1, j = col - 1; i >= 0 && j >= 0; --i, --j) {
			if (board[i][j] == 'Q') {
				return false;
			}
		}

		// 检查左下到右上斜线是否有皇后
		for (int i = row - 1, j = col + 1; i >= 0 && j < n; --i, ++j) {
			if (board[i][j] == 'Q') {
				return false;
			}
		}

		return true;
	}
};

实现中,使用回溯算法递归地尝试在每一行放置皇后,检查是否满足国际象棋的规则。通过不断递归和回溯,生成所有不同的 N 皇后问题的解决方案。

1.3 模板

基本模板:

class Solution {
public:
	// 主函数,入口点
	void solve(/*其他参数*/) {
		// 初始化结果集等必要的数据结构
		// ...

		// 调用回溯函数
		backtrack(/*参数列表*/);

		// 打印结果或其他操作
		printResult();
	}

private:
	// 回溯函数
	void backtrack(/*参数列表*/) {
		// 终止条件
		if (/*满足条件*/) {
			// 处理当前解
			processSolution();
			return;
		}

		// 递归处理每一步的选择
		for (/*每个选择*/) {
			// 做出选择
			makeChoice();

			// 递归到下一层
			backtrack(/*参数列表*/);

			// 撤销选择,进行回溯
			undoChoice();
		}
	}
}

2.动态规划 (Dynamic Programming)

应用:最优子结构性质的问题,问题可以被分解为重叠的子问题,具有递归关系,子问题的解可以被重复利用,例如最长公共子序列、背包问题等。

时空复杂度:时空复杂度较低,多项式级别。时间复杂度:O(n^2) 或更低,其中 n 是问题规模。空间复杂度:O(n) 或更低,取决于状态表格的大小。

特性:

  • 自底向上或自顶向下的求解过程。
  • 使用状态表格保存子问题的解,避免重复计算。
  • 通常用于求解最优化问题。

2.1 概述

动态规划(Dynamic Programming,简称DP)是一种将问题分解成子问题并仅仅解决一次,将解保存起来的优化技术。DP主要适用于有重叠子问题和最优子结构性质的问题。

通常许多子问题非常相似,为此动态规划法试图仅仅解决每个子问题一次,从而减少计算量:一旦某个给定子问题的解已经算出,则将其记忆化存储,以便下次需要同一个子问题解之时直接查表。这种做法在重复子问题的数目关于输入的规模呈指数增长时特别有用。

适用情况

  1. 最优子结构性质。如果问题的最优解所包含的子问题的解也是最优的,我们就称该问题具有最优子结构性质(即满足最优化原理)。最优子结构性质为动态规划算法解决问题提供了重要线索。
  2. 无后效性。即子问题的解一旦确定,就不再改变,不受在这之后、包含它的更大的问题的求解决策影响。
  3. 子问题重叠性质。子问题重叠性质是指在用递归算法自顶向下对问题进行求解时,每次产生的子问题并不总是新问题,有些子问题会被重复计算多次。动态规划算法正是利用了这种子问题的重叠性质,对每一个子问题只计算一次,然后将其计算结果保存在一个表格中,当再次需要计算已经计算过的子问题时,只是在表格中简单地查看一下结果,从而获得较高的效率,降低了时间复杂度。

动态规划的一般步骤:

  • 定义状态:明确定义问题的状态,找出问题中变化的量,这些变化的量即为状态。
    
  • 找到状态转移方程:确定状态之间的关系,即状态转移方程。这是动态规划的核心,描述了问题的最优子结构。
    
  • 初始化:确定初始状态,即问题中最小规模的子问题的解。
    
  • 计算顺序:按照一定的计算顺序,一步步计算状态的值,直到计算出问题的解。
    
  • 解的表示:根据问题的要求,确定最终的解是哪个状态,即哪些状态是我们最终关心的。
    

举例:

斐波那契数列。斐波那契数列的状态定义为 f(n) 表示第 n 个斐波那契数,状态转移方程为 f(n) = f(n-1) + f(n-2),初始状态为 f(0) = 0 和 f(1) = 1。

  1. 定义状态:f(n) 表示第 n 个斐波那契数。
  2. 状态转移方程:f(n) = f(n-1) + f(n-2)。
  3. 初始化:f(0) = 0,f(1) = 1。
  4. 计算顺序:从小到大计算 f(2), f(3), ..., f(n)。
  5. 解的表示:最终解是 f(n)。

2.2 力扣实战

杨辉三角

给定一个非负整数 numRows,生成「杨辉三角」的前 numRows 行。
动态规划实现:

class Solution {
public:
	std::vector<std::vector<int>> generate(int numRows) {
		std::vector<std::vector<int>> triangle;

		for (int i = 0; i < numRows; ++i) {
			std::vector<int> row(i + 1, 1);  // 初始化当前行并将元素都置为1
			for (int j = 1; j < i; ++j) {
				row[j] = triangle[i - 1][j - 1] + triangle[i - 1][j];  // 计算当前元素的值
			}
			triangle.push_back(row);  // 将当前行加入杨辉三角
		}

		return triangle;
	}
};
打家劫舍

你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。

给定一个代表每个房屋存放金额的非负整数数组,计算你 不触动警报装置的情况下 ,一夜之内能够偷窃到的最高金额。
动态规划实现:

动态规划的关键是找到状态转移方程。在这个问题中,我们可以定义一个数组 dp,其中 dp[i] 表示在第 i 个房屋结束时,小偷能够偷窃到的最大金额。状态转移方程为:

dp[i] = max(dp[i-1], dp[i-2] + nums[i])

nums[i] 表示第 i 个房屋内存放的金额。在第 i 个房屋结束时,小偷可以选择偷窃这个房屋或者不偷窃。如果偷窃,那么最大金额为前两个房屋的最大金额加上当前房屋的金额;如果不偷窃,那么最大金额为前一个房屋的最大金额。

class Solution {
public:
	int rob(vector<int>& nums) {
		int n = nums.size();
		if (n == 0) {
			return 0;
		} else if (n == 1) {
			return nums[0];
		}

		vector<int> dp(n, 0);
		dp[0] = nums[0];
		dp[1] = max(nums[0], nums[1]);

		for (int i = 2; i < n; ++i) {
			dp[i] = max(dp[i - 1], dp[i - 2] + nums[i]);
		}

		return dp[n - 1];
	}
};

dp[i] 记录了在第 i 个房屋结束时能够偷窃到的最大金额。通过遍历数组,计算出最后一个房屋的最大金额,即为整个问题的解。

完全平方数

给你一个整数 n ,返回 和为 n 的完全平方数的最少数量 。

完全平方数 是一个整数,其值等于另一个整数的平方;换句话说,其值等于一个整数自乘的积。例如,1、4、9 和 16 都是完全平方数,而 3 和 11 不是。

使用动态规划数组 dp,其中 dp[i] 表示和为 i 的完全平方数的最少数量。

状态转移方程可以定义为:

dp[i]=min⁡(dp[i],dp[i−j^2]+1)

其中 j 的取值范围是 1 到 根号i。

动态规划实现:

class Solution {
public:
	int numSquares(int n) {
		vector<int> dp(n + 1, INT_MAX);
		dp[0] = 0;

		for (int i = 1; i <= n; ++i) {
			for (int j = 1; j * j <= i; ++j) {
				dp[i] = min(dp[i], dp[i - j * j] + 1);
			}
		}

		return dp[n];
	}
};

dp[i] 记录了和为 i 的完全平方数的最少数量。通过双重循环,遍历所有可能的平方数,更新 dp[i]。

零钱兑换

给你一个整数数组 coins ,表示不同面额的硬币;以及一个整数 amount ,表示总金额。

计算并返回可以凑成总金额所需的 最少的硬币个数 。如果没有任何一种硬币组合能组成总金额,返回 -1 。

你可以认为每种硬币的数量是无限的。

动态规划实现:

令 dp[i] 表示凑成金额 i 所需的最少硬币个数。对于每个金额 i,我们可以考虑使用所有硬币中的任何一种硬币,假设选择硬币的面值为 coin,那么状态转移方程可以定义为:

dp[i]=min⁡(dp[i],dp[i−coin]+1)

这表示,凑成金额 i 的最少硬币个数可以通过凑成金额 i - coin 的最少硬币个数加上 1 来得到。我们在所有可能的硬币中选择最小的数量。

class Solution {
public:
	int coinChange(vector<int>& coins, int amount) {
		vector<int> dp(amount + 1, INT_MAX);
		dp[0] = 0;  // 初始状态,凑成金额为 0 的硬币个数为 0

		for (int i = 1; i <= amount; ++i) {
			for (int coin : coins) {
				if (i - coin >= 0 && dp[i - coin] != INT_MAX) {
					dp[i] = min(dp[i], dp[i - coin] + 1);
				}
			}
		}

		return (dp[amount] == INT_MAX) ? -1 : dp[amount];
	}
};

dp[i] 记录了凑成金额 i 所需的最少硬币个数。通过两层循环,遍历金额和硬币,更新 dp[i] 的值。

单词拆分

给你一个字符串 s 和一个字符串列表 wordDict 作为字典。请你判断是否可以利用字典中出现的单词拼接出 s 。

注意:不要求字典中出现的单词全部都使用,并且字典中的单词可以重复使用。

动态规划实现:
令 dp[i] 表示字符串 s 的前 i 个字符是否可以被字典中的单词拼接而成。对于每个位置 i,我们考虑前面的字符,假设 j 是一个位置,0 <= j < i,如果 dp[j] 为 true(表示前 j 个字符可以被拼接),且从 j 到 i 的子串也在字典中,那么 dp[i] 也为 true。即:
dp[i]=dp[j] and s[j:i] in wordDict

其中,s[j:i] 表示字符串 s 从位置 j 到 i 的子串。

class Solution {
public:
	bool wordBreak(string s, vector<string>& wordDict) {
		unordered_set<string> wordSet(wordDict.begin(), wordDict.end());
		int n = s.length();
		vector<bool> dp(n + 1, false);
		dp[0] = true;  // 空字符串可以被拼接

		for (int i = 1; i <= n; ++i) {
			for (int j = 0; j < i; ++j) {
				if (dp[j] && wordSet.count(s.substr(j, i - j))) {
					dp[i] = true;
					break;
				}
			}
		}

		return dp[n];
	}
};

dp[i] 记录了字符串 s 的前 i 个字符是否可以被拼接。通过两层循环,遍历所有的位置 i 和 j,更新 dp[i]。

最长递增子序列

给你一个整数数组 nums ,找到其中最长严格递增子序列的长度。

子序列 是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,[3,6,2,7] 是数组 [0,3,1,6,2,2,7] 的子序列。

动态规划实现:

令 dp[i] 表示以 nums[i] 结尾的最长递增子序列的长度。对于每个位置 i,我们考虑前面的位置 j,0 <= j < i,如果 nums[i] > nums[j],那么 dp[i] 就可以通过 dp[j] 的值加上 1 来更新。

dp[i]=max⁡(dp[i],dp[j]+1)

class Solution {
public:
	int lengthOfLIS(vector<int>& nums) {
		int n = nums.size();
		if (n == 0) {
			return 0;
		}

		vector<int> dp(n, 1);

		for (int i = 1; i < n; ++i) {
			for (int j = 0; j < i; ++j) {
				if (nums[i] > nums[j]) {
					dp[i] = max(dp[i], dp[j] + 1);
				}
			}
		}

		return *max_element(dp.begin(), dp.end());
	}
};

dp[i] 记录了以 nums[i] 结尾的最长递增子序列的长度。通过两层循环,遍历所有的位置 i 和 j,更新 dp[i]。

乘积最大子数组

给你一个整数数组 nums ,请你找出数组中乘积最大的非空连续子数组(该子数组中至少包含一个数字),并返回该子数组所对应的乘积。

测试用例的答案是一个 32-位 整数。

动态规划实现:

令 maxProd[i] 表示以 nums[i] 结尾的最大乘积子数组的乘积,minProd[i] 表示以 nums[i] 结尾的最小乘积子数组的乘积。对于 maxProd[i],可以选择继续乘以 nums[i] 或者重新开始以 nums[i] 为起点。

maxProd[i] = max(nums[i], maxProd[i-1] * nums[i])
minProd[i] = min(nums[i], minProd[i-1] * nums[i])

class Solution {
public:
	int maxProduct(vector<int>& nums) {
		int n = nums.size();
		if (n == 0) {
			return 0;
		}

		int maxProd = nums[0];
		int minProd = nums[0];
		int result = nums[0];

		for (int i = 1; i < n; ++i) {
			int tempMax = maxProd;
			maxProd = max({nums[i], maxProd * nums[i], minProd * nums[i]});
			minProd = min({nums[i], tempMax * nums[i], minProd * nums[i]});
			result = max(result, maxProd);
		}

		return result;
	}
};

maxProd 和 minProd 分别记录了以当前位置 nums[i] 结尾的子数组的最大乘积和最小乘积。通过一重循环,遍历所有的位置 i,更新这两个值,最终得到最大乘积。

背包问题

给定一组物品,每个物品有两个属性:重量 w[i] 和价值 v[i],以及一个背包的容量 C。现在要求从这组物品中选择若干个放入背包中,使得这些物品的总重量不超过背包容量,同时总价值最大。

动态规划实现:

  1. 状态定义:

定义一个二维数组 dp[i][j],表示在前 i 个物品中,背包容量为 j 时的最大总价值。

  1. 状态转移方程:

dp[i][j]=max⁡(dp[i−1][j],dp[i−1][j−w[i]]+v[i])

其中,dp[i-1][j] 表示不选择第 i 个物品时的最大总价值,dp[i-1][j-w[i]] + v[i] 表示选择第 i 个物品时的最大总价值。

C++中的伪代码实现:

int knapsack(int N, int C, vector<int>& w, vector<int>& v) {
	vector<vector<int>> dp(N + 1, vector<int>(C + 1, 0));

	for (int i = 1; i <= N; ++i) {
		for (int j = 1; j <= C; ++j) {
			if (j >= w[i]) {
				dp[i][j] = max(dp[i-1][j], dp[i-1][j-w[i]] + v[i]);
			} else {
				dp[i][j] = dp[i-1][j];
			}
		}
	}

	return dp[N][C];
}
分割等和子集

给你一个 只包含正整数 的 非空 数组 nums 。请你判断是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。

动态规划实现:

令 dp[i][j] 表示前 i 个元素是否可以构成和为 j 的子集。对于每个元素 nums[i],我们有两个选择:
不选择 nums[i],即

dp[i][j] = dp[i-1][j];

选择 nums[i],即

dp[i][j] = dp[i-1][j-nums[i]]。

class Solution {
public:
	bool canPartition(vector<int>& nums) {
		int n = nums.size();
		if (n == 0) {
			return false;
		}

		int sum = 0;
		for (int num : nums) {
			sum += num;
		}

		// 如果总和为奇数,不可能划分成两个和相等的子集
		if (sum % 2 != 0) {
			return false;
		}

		int target = sum / 2;
		vector<vector<bool>> dp(n + 1, vector<bool>(target + 1, false));

		// 初始化
		dp[0][0] = true;

		// 遍历计算
		for (int i = 1; i <= n; ++i) {
			for (int j = 0; j <= target; ++j) {
				dp[i][j] = dp[i-1][j];
				if (j >= nums[i-1]) {
					dp[i][j] = dp[i][j] || dp[i-1][j-nums[i-1]];
				}
			}
		}

		return dp[n][target];
	}
};
最长有效括号

给你一个只包含 '(' 和 ')' 的字符串,找出最长有效(格式正确且连续)括号子串的长度。

动态规划实现:

令 dp[i] 表示以字符串的第 i 个字符结尾的最长有效括号子串的长度。如果字符串第 i 个字符是 (,那么 dp[i] 必定为0,因为以 ( 结尾的子串无法形成有效括号子串。如果字符串第 i 个字符是 ),那么我们需要考虑它前面的字符。
如果 s[i-1] 是 (,那么

dp[i] = dp[i-2] + 2

如果 s[i-1] 是 ) 并且 i - dp[i-1] - 1 大于等于0,并且 s[i - dp[i-1] - 1] 是 (,那么

dp[i] = dp[i-1] + dp[i - dp[i-1] - 2] + 2

tip:
如果 s[i-1] 是 ) 并且 i - dp[i-1] - 1 大于等于0,并且 s[i - dp[i-1] - 1] 是 (,那么我们可以形成一对匹配的括号,即 ...(...)... 的形式。

  1. dp[i-1] 表示以 s[i-1] 结尾的最长有效括号子串的长度。
  2. dp[i - dp[i-1] - 2] 表示在 s[i-1] 之前,与 s[i-1] 形成一对有效括号的前一个字符之前的最长有效括号子串的长度。
  3. +2 表示当前形成的一对有效括号。

实际上是在原有的最长有效括号子串的基础上,加上了新形成的一对括号。这样就确保了正确地计算了以 s[i] 结尾的最长有效括号子串的长度。

class Solution {
public:
	int longestValidParentheses(string s) {
		int n = s.size();
		if (n <= 1) {
			return 0;
		}

		vector<int> dp(n, 0);
		int maxLen = 0;

		for (int i = 1; i < n; ++i) {
			if (s[i] == ')') {
				if (s[i - 1] == '(') {
					dp[i] = (i >= 2 ? dp[i - 2] : 0) + 2;
				} else if (i - dp[i - 1] > 0 && s[i - dp[i - 1] - 1] == '(') {
					dp[i] = dp[i - 1] + ((i - dp[i - 1] >= 2) ? dp[i - dp[i - 1] - 2] : 0) + 2;
				}
				maxLen = max(maxLen, dp[i]);
			}
		}

		return maxLen;
	}
};

2.3 总结

在动态规划中,选择是基于问题的性质而定的。通常情况下:

  1. 选择min: 当问题要求最小值、最小花费、最少次数等时,我们选择min。例如,在找零钱的问题中,我们要求凑成总金额所需的最少硬币个数,因此使用 dp[i] = min(dp[i], dp[i - coin] + 1)。
  2. 选择max: 当问题要求最大值、最大收益、最长长度等时,我们选择max。例如,在打家劫舍的问题中,我们要求一夜之内能够偷窃到的最高金额,因此使用 dp[i] = max(dp[i - 1], dp[i - 2] + nums[i])。
模板
int dynamicProgramming(int n, vector<int>& nums) {
    // 1. 定义状态
    vector<int> dp(n + 1, 0);

    // 2. 初始化
    dp[0] = ...;

    // 3. 状态转移方程
    for (int i = 1; i <= n; ++i) {
        dp[i] = ...; // 根据状态转移方程更新 dp[i]
    }

    // 4. 得到最终结果
    int result = dp[n];
    return result;
}
优化方法
  • 状态压缩: 对于一些问题,可以通过状态压缩将二维动态规划数组优化为一维,减少空间复杂度。

  • 贪心策略: 在一些情况下,可以使用贪心策略对状态进行更新,而不是完全遍历所有可能的状态。

  • 提前终止: 在某些情况下,可以提前终止遍历,减少计算量。例如,在背包问题中,如果已经达到目标值,则可以提前结束循环。

  • 逆向思维: 对于一些问题,可以考虑从终点开始逆向思考,计算得到初始状态。这有时可以简化问题的解决过程。

  • 滚动数组: 在一些问题中,只需要保存当前状态和前一个状态,不需要完整的动态规划数组,可以使用滚动数组进行优化。

  • 剪枝: 对于搜索空间较大的问题,可以使用剪枝策略,提前剪掉一些不必要的计算分支。

  • 前缀和等预处理: 对于一些问题,可以通过预处理得到一些额外的信息,如前缀和、前缀最大值等,以加速动态规划过程。

零钱兑换:给定不同面额的硬币和一个总金额,找到最少的硬币个数凑成该金额。
未优化:

class Solution {
public:
    int coinChange(std::vector<int>& coins, int amount) {
        int n = coins.size();
        std::vector<int> dp(amount + 1, INT_MAX);
        dp[0] = 0;

        for (int i = 1; i <= amount; ++i) {
            for (int coin : coins) {
                if (i - coin >= 0 && dp[i - coin] != INT_MAX) {
                    dp[i] = std::min(dp[i], dp[i - coin] + 1);
                }
            }
        }

        return (dp[amount] == INT_MAX) ? -1 : dp[amount];
    }
};

优化版本:

class Solution {
public:
    int coinChange(std::vector<int>& coins, int amount) {
        int n = coins.size();
        std::vector<int> dp(amount + 1, INT_MAX);
        dp[0] = 0;

        for (int coin : coins) {
            for (int i = coin; i <= amount; ++i) {
                if (dp[i - coin] != INT_MAX) {
                    dp[i] = std::min(dp[i], dp[i - coin] + 1);
                }
            }
        }

        return (dp[amount] == INT_MAX) ? -1 : dp[amount];
    }
};

未优化版本使用二维数组dp,考虑所有金额和硬币的组合。优化版本中,我们只使用一维数组dp,在计算每个金额时,只考虑当前硬币的影响,减少了空间复杂度。
由于我们从前往后遍历,每个dp[i]只与前面的dp[i-coin]有关,所以在内层循环中,我们可以直接使用一维数组dp的前面元素,不需要使用二维数组。
这种优化方法称为「滚动数组」,通过降低空间复杂度提高了算法的效率。

3.贪心算法 (Greedy Algorithm)

应用:每一步的选择不会影响后续步骤,局部最优选择期望导致全局最优解,问题具有贪心选择性质,不需要回溯。

时空复杂度:时空复杂度较低,线性或对数级别。时间复杂度:O(n log n) 或更低,其中 n 是问题规模。空间复杂度:O(1) 或常数级别。

特性:

  • 每一步都做出局部最优选择。
  • 不进行回溯,通常无需保存状态。

概述

贪心算法(Greedy Algorithm),又称贪婪算法,是一种在每一步选择中都采取在当前状态下最好或最优(即最有利)的选择,从而希望导致结果是最好或最优的算法。

贪心算法在有最优子结构的问题中尤为有效。最优子结构的意思是局部最优解能决定全局最优解。简单地说,问题能够分解成子问题来解决,子问题的最优解能递推到最终问题的最优解。

贪心算法与动态规划的不同在于它对每个子问题的解决方案都做出选择,不能回退。动态规划则会保存以前的运算结果,并根据以前的结果对当前进行选择,有回退功能。

贪心法可以解决一些最优化问题,如:求图中的最小生成树、求哈夫曼编码……对于其他问题,贪心法一般不能得到我们所要求的答案。一旦一个问题可以通过贪心法来解决,那么贪心法一般是解决这个问题的最好办法。由于贪心法的高效性以及其所求得的答案比较接近最优结果,贪心法也可以用作辅助算法或者直接解决一些要求结果不特别精确的问题。在不同情况,选择最优的解,可能会导致辛普森悖论(Simpson's Paradox),不一定出现最优的解。

对于大部分的问题,贪心法通常都不能找出最佳解(不过也有例外),因为他们一般没有测试所有可能的解。贪心法容易过早做决定,因而没法达到最佳解。例如,所有对图着色问题。

思路概括:

  • 问题建模:将问题转化为可以用贪心策略求解的形式。
    
  • 制定贪心策略:找到一个合适的贪心策略,即在每一步选择中都选择当前状态下的最优解。
    
  • 证明正确性:证明贪心选择是局部最优的,并且通过局部最优选择能够达到全局最优解。
    

常见应用:

  1. 活动选择问题(Activity Selection Problem): 在一组互不相容的活动中,选择最大数量的活动,使得它们彼此不重叠。
  2. 霍夫曼编码(Huffman Coding): 用不同长度的二进制编码表示不同字符,使得出现频率高的字符的编码长度较短。
  3. 最小生成树(Minimum Spanning Tree): 在一个带权重的无向图中,找到一个树,包含所有的顶点且边的权重之和最小。
  4. 单源最短路径问题(Dijkstra's Algorithm): 在带权重的有向图中,找到一个顶点到其他所有顶点的最短路径。
  5. 零钱找零问题: 找到一组硬币的最小数量,使其总面值等于给定的金额。

力扣实战

买卖股票的最佳时机

给定一个数组 prices ,它的第 i 个元素 prices[i] 表示一支给定股票第 i 天的价格。

你只能选择 某一天 买入这只股票,并选择在 未来的某一个不同的日子 卖出该股票。设计一个算法来计算你所能获取的最大利润。

返回你可以从这笔交易中获取的最大利润。如果你不能获取任何利润,返回 0 。

贪心实现:

class Solution {
public:
	int maxProfit(std::vector<int>& prices) {
		int n = prices.size();
		if (n <= 1) {
			return 0; // 如果数组长度小于等于1,则无法交易,利润为0
		}

		int maxProfit = 0;
		int minPrice = prices[0]; // 初始化最低买入价格为第一天的股票价格

		for (int i = 1; i < n; ++i) {
			// 更新最低买入价格
			minPrice = std::min(minPrice, prices[i]);

			// 计算当前卖出时的利润,并更新最大利润
			maxProfit = std::max(maxProfit, prices[i] - minPrice);
		}

		return maxProfit;
	}
};
跳跃游戏

给你一个非负整数数组 nums ,你最初位于数组的 第一个下标 。数组中的每个元素代表你在该位置可以跳跃的最大长度。

判断你是否能够到达最后一个下标,如果可以,返回 true ;否则,返回 false 。

贪心实现:

class Solution {
public:
	bool canJump(std::vector<int>& nums) {
		int n = nums.size();
		int maxReach = 0; // 记录当前能够到达的最远位置

		for (int i = 0; i < n; ++i) {
			if (i > maxReach) {
				return false; // 如果当前位置超过了能够到达的最远位置,则无法到达最后一个下标
			}

			maxReach = std::max(maxReach, i + nums[i]); // 更新能够到达的最远位置
		}

		return true;
	}
};

实现中,维护一个变量 maxReach 来记录当前能够到达的最远位置。在遍历数组的过程中,不断更新 maxReach,表示在当前位置可以跳跃的最大长度。如果某一步发现当前位置超过了能够到达的最远位置,则说明无法到达最后一个下标,返回 false。否则,最终返回 true。

跳跃游戏 II

给定一个长度为 n 的 0 索引整数数组 nums。初始位置为 nums[0]。

每个元素 nums[i] 表示从索引 i 向前跳转的最大长度。换句话说,如果你在 nums[i] 处,你可以跳转到任意 nums[i + j] 处:

  • 0 <= j <= nums[i]
  • i + j < n

返回到达 nums[n - 1] 的最小跳跃次数。生成的测试用例可以到达 nums[n - 1]。

贪心实现:

class Solution {
public:
	int jump(std::vector<int>& nums) {
		int n = nums.size();
		int jumps = 0; // 记录跳跃次数
		int curEnd = 0; // 当前能够到达的最远位置
		int curFarthest = 0; // 在当前跳跃范围内能够到达的最远位置

		for (int i = 0; i < n - 1; ++i) {
			curFarthest = std::max(curFarthest, i + nums[i]);

			if (i == curEnd) {
				// 当前跳跃范围结束,更新下一次跳跃范围的边界
				curEnd = curFarthest;
				jumps++;
			}
		}

		return jumps;
	}
};

实现中维护了两个变量 curEnd 和 curFarthest,分别表示当前跳跃范围的边界和在当前范围内能够到达的最远位置。在遍历数组的过程中,不断更新这两个变量,当遍历到当前跳跃范围的边界时,表示需要进行下一次跳跃,同时增加跳跃次数。

划分字母区间

给你一个字符串 s 。我们要把这个字符串划分为尽可能多的片段,同一字母最多出现在一个片段中。

注意,划分结果需要满足:将所有划分结果按顺序连接,得到的字符串仍然是 s 。

返回一个表示每个字符串片段的长度的列表。

贪心实现:

class Solution {
public:
	vector<int> partitionLabels(string s) {
		int n = s.size();
		vector<int> ans;
		int start=0,end=0;
		unordered_map<char,int> map;
		for(int i = 0;i<n;++i){
			map[s[i]] = i;
		}
		for(int i = 0;i<n;++i){
			end = max(end,map[s[i]]);
			if(i==end){
				ans.push_back(end-start+1);
				start = end+1;
			}
		}
		return ans;
	}
};

4.分治法 (Divide and Conquer)

应用:问题可以分解为互不相交的子问题,具有递归结构,子问题独立求解,子问题的解合并得到原问题的解。

时空复杂度:时空复杂度较低,多项式级别。时间复杂度:O(n log n) 或更低,其中 n 是问题规模。空间复杂度:O(log n) 或更低,取决于递归深度。

特性:

  • 通过递归地将问题分解为子问题。
  • 子问题独立求解,不涉及状态的回溯。
  • 典型的应用如归并排序、快速排序等。

异同点总结:

  1. 回溯法:主要用于解决组合、排列、子集等组合型问题,时空复杂度高,需要回溯选择。
  2. 动态规划:用于求解最优子结构性质的问题,通过保存子问题的解避免重复计算,时空复杂度较低。
  3. 贪心算法:适用于每一步的选择不会影响后续步骤、且期望通过局部最优选择得到全局最优解的问题。
  4. 分治法:将问题分解为互不相交的子问题,子问题独立求解,通过合并子问题的解得到原问题的解。适用于具有递归结构的问题。

算法思想的使用及举例

是否具有最优子结构性质?

  • 如果问题可以被分解为子问题,且子问题的最优解可以组合成原问题的最优解,可能适合使用动态规划。

是否可以通过贪心选择策略得到全局最优解?

  • 如果每一步的选择都是局部最优的,并且这些选择期望能够得到全局最优解,可能适合使用贪心算法。

是否可以通过深度优先搜索来穷尽所有可能的解空间?

  • 如果问题的解空间是一个树状结构,可以通过深度优先搜索来穷尽所有可能的解空间,可能适合使用回溯法。

是否可以通过分治法将问题分解为互不相交的子问题?

  • 如果问题可以被分解为互不相交的子问题,且通过合并子问题的解得到原问题的解,可能适合使用分治法。

是否需要遍历图的结构?

  • 如果问题可以被建模成图的结构,可能需要使用图论算法,例如深度优先搜索、广度优先搜索等。

是否可以通过状态压缩来优化解空间搜索?

  • 如果问题的状态空间非常大,可以考虑使用位运算等方法进行状态压缩,例如在动态规划或搜索问题中。

举例

  • 组合、排列、子集问题通常涉及在给定集合中选择若干元素,满足某些条件。回溯法通过深度优先搜索的方式遍历解空间,逐步选择元素,撤销选择,寻找问题的解。

  • 最短路径问题通常涉及在图中找到从一个节点到另一个节点的最短路径。图论算法如Dijkstra、Bellman-Ford等天然适用于解决这类问题。

  • 背包问题涉及在给定容量的背包中选择一些物品,使得价值最大或者总重量最小。动态规划适合解决这类问题,因为它可以通过保存子问题的解来避免重复计算。

  • 排序问题通常涉及将一组元素按照一定的规则进行排序。贪心算法适合解决这类问题,因为每一步都可以通过选择当前最优的元素来期望得到全局最优解。

  • 分治法适合解决问题可以分解为互不相交的子问题,子问题独立求解,最后合并得到原问题解的情况。归并排序和快速排序是分治法的经典应用。

  • 动态规划适合解决具有最优子结构性质的问题,其中问题的最优解可以由其子问题的最优解推导得到。例如,最长公共子序列问题。

  • 图的遍历问题,如寻找连通分量、拓扑排序等,通常可以通过深度优先搜索和广度优先搜索解决。

posted @   StuBoo  阅读(47)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· winform 绘制太阳,地球,月球 运作规律
· AI与.NET技术实操系列(五):向量存储与相似性搜索在 .NET 中的实现
· 超详细:普通电脑也行Windows部署deepseek R1训练数据并当服务器共享给他人
· 上周热点回顾(3.3-3.9)
· AI 智能体引爆开源社区「GitHub 热点速览」
点击右上角即可分享
微信分享提示