呕心沥血的全网史上最强大黑书:计算机界神书《算法导论》的概览、梳理、分析、思考总结、归纳、拓展、提炼
第三部分:算法设计范式篇 - 算法的“策略”与“技巧”
3.1 贪心算法(Greedy Algorithms):“眼前最优”的诱惑与陷阱
兄弟们,想象一下,你面前有一堆金币,你每次只能拿一枚,但你想在最短时间内拿到最多的金币。你会怎么做?最直观的想法是不是每次都拿当前能拿到的最大面额的金币?这就是贪心算法的核心思想——每一步都做出当前看起来最优的选择,希望这些局部最优解能最终导向全局最优解。
3.1.1 贪心算法的定义与特点
-
定义: 贪心算法在每一步选择中都采取在当前状态下最好或最优(即最有利)的选择,从而希望导致结果是全局最好或最优的算法。它不考虑未来的后果,只关注眼前的利益。
-
特点:
-
局部最优: 每一步都做出局部最优选择。
-
无后效性: 当前的选择不会影响到后续子问题的最优解。
-
不可撤销: 一旦做出选择,就不能反悔。
-
-
适用条件: 贪心算法并非万能药,它能奏效,通常需要满足两个核心性质:
-
贪心选择性质(Greedy Choice Property): 全局最优解可以通过局部最优(贪心)选择来达到。也就是说,在做出第一个贪心选择后,剩下子问题的最优解与第一个贪心选择是兼容的。
-
最优子结构性质(Optimal Substructure): 一个问题的最优解包含其子问题的最优解。这意味着,解决一个大问题,可以分解成解决相互独立的子问题,并将子问题的最优解组合起来。
-
思维导图:贪心算法的特性与条件
graph TD
A[贪心算法] --> B[核心思想: 局部最优];
B --> C[特点];
C --> C1[每步当前最优];
C --> C2[无后效性];
C --> C3[不可撤销];
A --> D[适用条件];
D --> D1[贪心选择性质];
D --> D2[最优子结构性质];
3.1.2 经典问题:活动选择问题
-
问题描述: 假设你有一组活动,每个活动都有一个开始时间
s_i
和一个结束时间f_i
。你希望参加尽可能多的活动,但任何两个你选择的活动都不能有时间上的重叠。 -
贪心策略: 每次都选择结束时间最早的那个活动。
-
为什么? 选择结束时间最早的活动,可以为后续的活动留下最多的时间,从而最大化选择的数量。
-
-
证明其贪心选择性质:
-
设 S 是所有活动的集合,选择结束时间最早的活动 a_1。
-
设 A 是包含 a_1 的一个最优解。
-
如果 A 不包含 a_1,那么 A 中肯定有一个结束时间最早的活动 a′_1。由于 a_1 是所有活动中结束时间最早的,所以 f_1lef′_1。
-
我们可以用 a_1 替换 A 中的 a′_1。由于 f_1lef′_1,替换后新的解仍然是可行的(因为 a_1 结束得更早或同时,不会与 A 中其他活动产生新的冲突)。而且,替换后活动数量不变,仍然是最优解。
-
因此,总存在一个最优解包含贪心选择 a_1。
-
概念性C代码:活动选择问题(贪心算法)
#include <stdio.h>
#include <stdlib.h> // For malloc, qsort
#include <stdbool.h> // For bool type
// 定义活动结构体
typedef struct Activity {
int id; // 活动编号
int start; // 开始时间
int finish; // 结束时间
} Activity;
// 比较函数,用于qsort:按结束时间升序排序
int compareActivities(const void *a, const void *b) {
return ((Activity *)a)->finish - ((Activity *)b)->finish;
}
/**
* @brief 使用贪心算法解决活动选择问题
* @param activities 活动数组
* @param n 活动数量
* @param selected_activities_ids 用于存储被选活动ID的数组
* @return 被选活动的数量
* @note 贪心策略:总是选择当前可用的、结束时间最早的活动。
* 时间复杂度:O(n log n) (排序) + O(n) (遍历) = O(n log n)。
* 空间复杂度:O(1) (不计输入和输出存储)。
*/
int activity_selection(Activity activities[], int n, int selected_activities_ids[]) {
// 1. 对活动按结束时间进行升序排序
// 这是贪心选择的关键一步,确保我们总能选择结束最早的活动。
qsort(activities, n, sizeof(Activity), compareActivities);
printf("按结束时间排序后的活动列表:\n");
for (int i = 0; i < n; i++) {
printf("活动 %d: [%d, %d]\n", activities[i].id, activities[i].start, activities[i].finish);
}
printf("\n");
int count = 0; // 记录被选活动的数量
// 第一个活动总是可以被选择的,因为它结束时间最早
selected_activities_ids[count++] = activities[0].id;
int last_finish_time = activities[0].finish; // 记录上一个被选活动的结束时间
printf("选择活动: 活动 %d ([%d, %d])\n", activities[0].id, activities[0].start, activities[0].finish);
// 2. 遍历剩余活动,选择与上一个活动不冲突且结束时间最早的活动
for (int i = 1; i < n; i++) {
// 如果当前活动的开始时间 >= 上一个被选活动的结束时间,则不冲突
if (activities[i].start >= last_finish_time) {
selected_activities_ids[count++] = activities[i].id;
last_finish_time = activities[i].finish;
printf("选择活动: 活动 %d ([%d, %d])\n", activities[i].id, activities[i].start, activities[i].finish);
}
}
return count;
}
// 主函数用于测试活动选择问题
int main_activity_selection() {
printf("--- 活动选择问题 (贪心算法) 示例 ---\n");
// 示例活动列表
// 活动ID | 开始时间 | 结束时间
// -------------------------
// 1 | 1 | 4
// 2 | 3 | 5
// 3 | 0 | 6
// 4 | 5 | 7
// 5 | 3 | 9
// 6 | 5 | 9
// 7 | 6 | 10
// 8 | 8 | 11
// 9 | 8 | 12
// 10 | 2 | 14
// 11 | 12 | 16
Activity activities[] = {
{1, 1, 4}, {2, 3, 5}, {3, 0, 6}, {4, 5, 7}, {5, 3, 9},
{6, 5, 9}, {7, 6, 10}, {8, 8, 11}, {9, 8, 12}, {10, 2, 14},
{11, 12, 16}
};
int n = sizeof(activities) / sizeof(activities[0]);
int *selected_ids = (int *)malloc(n * sizeof(int)); // 存储被选活动的ID
if (selected_ids == NULL) {
perror("内存分配失败");
return EXIT_FAILURE;
}
int num_selected = activity_selection(activities, n, selected_ids);
printf("\n最终选定的活动数量: %d\n", num_selected);
printf("选定的活动ID: ");
for (int i = 0; i < num_selected; i++) {
printf("%d ", selected_ids[i]);
}
printf("\n");
free(selected_ids);
printf("--- 活动选择问题示例结束 ---\n");
return 0;
}
代码分析与说明:
-
Activity
结构体: 定义了每个活动的id
、start
(开始时间)和finish
(结束时间)。 -
compareActivities
函数: 这是qsort
函数所需的比较器。它根据活动的finish
时间进行升序排序。这是贪心策略的第一步,也是最关键的一步。 -
activity_selection
函数:-
排序:
qsort(activities, n, sizeof(Activity), compareActivities);
这一行至关重要。它确保了活动是按照结束时间从早到晚排列的。 -
初始化: 总是选择第一个活动(因为它结束时间最早),并记录其结束时间
last_finish_time
。 -
遍历选择:
for (int i = 1; i < n; i++)
遍历剩余的活动。 -
贪心选择判断:
if (activities[i].start >= last_finish_time)
。如果当前活动的开始时间大于等于上一个已选择活动的结束时间,说明它们不冲突,可以选择当前活动。 -
更新状态: 如果选择了当前活动,就更新
last_finish_time
为当前活动的结束时间。
-
-
时间复杂度:
-
排序:
qsort
的时间复杂度是 O(nlogn)。 -
遍历选择:
for
循环遍历一次,是 O(n)。 -
所以总时间复杂度是 O(nlogn)。
-
-
空间复杂度: 除了存储输入和输出的数组,只使用了常数个额外变量,所以是 O(1)。
-
做题编程随想录:
-
贪心算法的难点在于证明其正确性。很多时候,局部最优并不意味着全局最优。在面试中,如果你提出一个贪心策略,面试官很可能会让你证明它的正确性,或者举出反例。
-
活动选择问题是一个经典的贪心算法应用,因为它满足贪心选择性质和最优子结构性质。
-
在嵌入式领域,贪心算法可以用于任务调度(如实时操作系统中的优先级调度)、资源分配等场景,但需要仔细分析其正确性,确保局部最优能带来实际的全局效益。
-
3.1.3 贪心算法的“陷阱”:局部最优不等于全局最优
-
反例: 假设你要用最少的硬币凑够10元钱,硬币面额有1元、5元、7元。
-
贪心策略: 每次都选择最大面额的硬币。
-
选择 7元。剩下 3元。
-
选择 1元。剩下 2元。
-
选择 1元。剩下 1元。
-
选择 1元。凑齐 10元,用了 4枚硬币(7+1+1+1)。
-
-
最优解: 2枚硬币(5+5)。
-
-
分析: 在这个例子中,贪心策略失败了。因为选择 7元后,虽然是当前最大面额,但它导致了后续选择的限制,使得无法找到最优解。这说明,硬币找零问题(在某些面额下)不满足贪心选择性质。
表格:贪心算法的优缺点与适用场景
特性 |
优点 |
缺点 |
适用场景 |
---|---|---|---|
效率 |
通常简单、高效,时间复杂度较低 |
局部最优不一定能达到全局最优 |
满足贪心选择性质和最优子结构性质的问题 |
实现 |
算法逻辑直观,代码实现相对简单 |
难以证明正确性,容易出错 |
活动选择,霍夫曼编码,最小生成树(Prim/Kruskal),部分背包问题(分数背包) |
局限 |
无法处理需要“回溯”或“试探”的问题 |
无法处理需要全局视野或多阶段决策的问题 |
小结: 贪心算法是一种简单直观的算法设计范式,它在每一步都做出局部最优选择。它的魅力在于高效,但陷阱在于局部最优不一定能导致全局最优。只有当问题满足“贪心选择性质”和“最优子结构性质”时,贪心算法才能保证正确性。在应用贪心算法时,务必仔细分析和证明其正确性,或者至少能举出反例。
3.2 动态规划(Dynamic Programming, DP):“记忆化”的艺术
兄弟们,如果你遇到一个问题,它看起来很复杂,有很多重复的子问题,而且解决这些子问题的方式是相互依赖的,你该怎么办?这时,**动态规划(DP)**就该登场了!它就像一个“记忆大师”,通过存储和复用子问题的解,避免重复计算,从而高效地解决问题。
3.2.1 动态规划的定义与核心思想
-
定义: 动态规划是一种通过把原问题分解为相互重叠的子问题,并存储子问题的解,从而避免重复计算,最终得到原问题解的方法。它通常用于解决具有最优子结构和重叠子问题性质的问题。
-
核心思想:
-
状态定义: 这是DP最难也是最关键的一步。你需要定义一个或多个“状态”来表示问题的子问题,通常是一个数组或多维数组,其索引代表子问题的规模或特征。
-
状态转移方程(递推关系): 描述如何从已知的子问题的解推导出当前问题的解。这是DP的“灵魂”,它将不同状态之间的关系用数学公式表达出来。
-
边界条件: 定义最小子问题的解,这是递推的起点。
-
计算顺序: 确定子问题计算的顺序,确保在计算当前子问题时,所有依赖的子问题都已经计算完毕。通常是自底向上(Bottom-up)或自顶向下(Top-down,带记忆化)。
-
-
DP 与分治的区别:
-
分治: 将问题分解为不重叠的子问题,独立解决,然后合并。
-
DP: 将问题分解为重叠的子问题,通过存储和复用子问题的解来避免重复计算。
-
思维导图:动态规划的核心要素
graph TD
A[动态规划 DP] --> B[核心思想];
B --> B1[最优子结构];
B --> B2[重叠子问题];
A --> C[解决步骤];
C --> C1[定义状态];
C --> C2[推导状态转移方程];
C --> C3[确定边界条件];
C --> C4[确定计算顺序];
A --> D[与分治的区别];
D --> D1[分治: 子问题不重叠];
D --> D2[DP: 子问题重叠, 记忆化];
3.2.2 经典问题:最长公共子序列(Longest Common Subsequence, LCS)
-
问题描述: 给定两个序列 X=langlex_1,x_2,dots,x_mrangle 和 Y=langley_1,y_2,dots,y_nrangle,找出它们的最长公共子序列的长度。子序列不要求连续。
-
例如:X=text"ABCBDAB",Y=text"BDCABA"。
-
公共子序列有:"BCBA", "BDAB", "BCB", ...
-
最长公共子序列是:"BCBA" 或 "BDAB",长度为 4。
-
-
最优子结构:
-
设 X_i 是 X 的前 i 个字符,Y_j 是 Y 的前 j 个字符。
-
设 LCS(i,j) 是 X_i 和 Y_j 的最长公共子序列的长度。
-
如果 x_i=y_j: 那么 x_i(或 y_j)一定是 X_i 和 Y_j 的一个LCS的最后一个字符。所以 LCS(i,j)=LCS(i−1,j−1)+1。
-
如果 x_ineqy_j: 那么 x_i 和 y_j 不可能同时是LCS的最后一个字符。所以 LCS(i,j)=max(LCS(i−1,j),LCS(i,j−1))。
-
-
状态定义:
dp[i][j]
表示序列 X 的前 i 个字符和序列 Y 的前 j 个字符的最长公共子序列的长度。 -
状态转移方程:
-
如果 X[i−1]==Y[j−1] (注意索引转换,字符串通常从0开始,DP数组从1开始):
dp[i][j] = dp[i-1][j-1] + 1
-
如果 X[i−1]neqY[j−1]:
dp[i][j] = max(dp[i-1][j], dp[i][j-1])
-
-
边界条件:
-
dp[0][j] = 0
(空字符串与任何字符串的LCS长度为0) -
dp[i][0] = 0
(任何字符串与空字符串的LCS长度为0)
-
-
计算顺序: 从
dp[1][1]
开始,逐行或逐列计算,直到dp[m][n]
。
概念性C代码:最长公共子序列(LCS)
#include <stdio.h>
#include <string.h> // For strlen
#include <stdlib.h> // For malloc, free
// 辅助函数:求两个整数的最大值
int max(int a, int b) {
return (a > b) ? a : b;
}
/**
* @brief 使用动态规划计算两个字符串的最长公共子序列 (LCS) 的长度
* @param X 第一个字符串
* @param Y 第二个字符串
* @return LCS的长度
* @note 动态规划的核心在于定义状态和状态转移方程。
* dp[i][j] 表示 X的前i个字符和Y的前j个字符的最长公共子序列的长度。
* 时间复杂度:O(m*n),其中m和n是两个字符串的长度。
* 空间复杂度:O(m*n) (用于存储dp表)。
*/
int longest_common_subsequence(const char *X, const char *Y) {
int m = strlen(X); // 字符串X的长度
int n = strlen(Y); // 字符串Y的长度
// 创建一个 (m+1) x (n+1) 的二维数组来存储子问题的解
// dp[i][j] 对应 X的前i个字符和Y的前j个字符
int **dp = (int **)malloc((m + 1) * sizeof(int *));
if (dp == NULL) {
perror("dp表行内存分配失败");
return 0;
}
for (int i = 0; i <= m; i++) {
dp[i] = (int *)malloc((n + 1) * sizeof(int));
if (dp[i] == NULL) {
perror("dp表列内存分配失败");
// 释放之前已分配的行内存
for (int k = 0; k < i; k++) free(dp[k]);
free(dp);
return 0;
}
}
// 填充dp表 (自底向上计算)
for (int i = 0; i <= m; i++) {
for (int j = 0; j <= n; j++) {
// 边界条件:当其中一个字符串为空时,LCS长度为0
if (i == 0 || j == 0) {
dp[i][j] = 0;
}
// 如果当前字符匹配
else if (X[i - 1] == Y[j - 1]) { // 注意:X[i-1] 和 Y[j-1] 对应第i个和第j个字符
dp[i][j] = dp[i - 1][j - 1] + 1;
}
// 如果当前字符不匹配
else {
dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]);
}
}
}
// LCS的长度存储在 dp[m][n]
int lcs_length = dp[m][n];
// 打印dp表 (可选,用于调试和理解)
printf("DP表:\n");
for (int i = 0; i <= m; i++) {
for (int j = 0; j <= n; j++) {
printf("%2d ", dp[i][j]);
}
printf("\n");
}
// 构造LCS字符串 (可选,通过回溯dp表)
char *lcs_str = (char *)malloc((lcs_length + 1) * sizeof(char));
if (lcs_str == NULL) {
perror("LCS字符串内存分配失败");
// 仍然需要释放dp表
for (int i = 0; i <= m; i++) free(dp[i]);
free(dp);
return lcs_length; // 返回长度,但不返回字符串
}
lcs_str[lcs_length] = '\0'; // 字符串结束符
int i = m, j = n;
int current_idx = lcs_length - 1;
while (i > 0 && j > 0) {
if (X[i - 1] == Y[j - 1]) { // 如果字符匹配,说明它是LCS的一部分
lcs_str[current_idx--] = X[i - 1];
i--;
j--;
} else if (dp[i - 1][j] > dp[i][j - 1]) { // 如果不匹配,根据dp表值选择来源
i--; // 从 X[i-1] 处来
} else {
j--; // 从 Y[j-1] 处来
}
}
printf("最长公共子序列: %s\n", lcs_str);
free(lcs_str);
// 释放dp表的内存
for (int k = 0; k <= m; k++) {
free(dp[k]);
}
free(dp);
return lcs_length;
}
// 主函数用于测试LCS
int main_lcs() {
printf("--- 最长公共子序列 (LCS) 示例 ---\n");
const char *X = "ABCBDAB";
const char *Y = "BDCABA";
printf("序列X: %s\n", X);
printf("序列Y: %s\n", Y);
int length = longest_common_subsequence(X, Y);
printf("最长公共子序列的长度: %d\n", length);
printf("\n");
const char *X2 = "AGGTAB";
const char *Y2 = "GXTXAYB";
printf("序列X2: %s\n", X2);
printf("序列Y2: %s\n", Y2);
length = longest_common_subsequence(X2, Y2);
printf("最长公共子序列的长度: %d\n", length);
printf("--- LCS示例结束 ---\n");
return 0;
}
代码分析与说明:
-
dp
表的创建:int **dp = (int **)malloc((m + 1) * sizeof(int *));
创建了一个二维数组dp
。dp[i][j]
存储 X 的前 i 个字符和 Y 的前 j 个字符的LCS长度。m+1
和n+1
是为了处理空字符串(索引0)。 -
边界条件:
if (i == 0 || j == 0) { dp[i][j] = 0; }
初始化了dp
表的第一行和第一列为0。 -
状态转移方程:
-
else if (X[i - 1] == Y[j - 1]) { dp[i][j] = dp[i - 1][j - 1] + 1; }
:如果当前字符匹配,则LCS长度是前一个子问题(两个字符串都少一个字符)的LCS长度加1。 -
else { dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]); }
:如果当前字符不匹配,则LCS长度是“X少一个字符与Y的LCS”和“X与Y少一个字符的LCS”中的最大值。
-
-
计算顺序: 两个嵌套
for
循环,从(0,0)
逐行逐列计算到(m,n)
。这是一个典型的自底向上(Bottom-up)的DP计算顺序。 -
构造LCS字符串: 在计算完
dp
表后,可以通过从dp[m][n]
开始回溯dp
表来构造实际的LCS字符串。-
如果
X[i-1] == Y[j-1]
,说明X[i-1]
是LCS的一部分,将其加入结果字符串,并同时向左上角移动(i--
,j--
)。 -
如果
X[i-1] != Y[j-1]
,则根据dp[i-1][j]
和dp[i][j-1]
的大小,选择向左(i--
)或向上(j--
)移动,这意味着当前字符不是LCS的一部分。
-
-
内存管理: 动态分配了二维数组
dp
,并在函数结束前逐行释放,最后释放dp
指针本身,防止内存泄漏。 -
时间复杂度: 两个嵌套循环,每个循环执行 O(m) 和 O(n) 次,内部操作是 O(1)。所以总时间复杂度是 O(mtimesn)。
-
空间复杂度: 需要一个 O(mtimesn) 的二维数组来存储
dp
表。 -
做题编程随想录:
-
LCS是DP的经典入门问题,理解它的状态定义和状态转移方程是理解DP的关键。
-
DP问题通常可以分为两种实现方式:
-
自底向上(Bottom-up): 迭代实现,从最小子问题开始计算,逐步推导出大问题的解。通常使用循环。
-
自顶向下(Top-down): 递归实现,但需要记忆化(Memoization),即用一个数组(或哈希表)存储已计算过的子问题的解,避免重复计算。
-
-
空间优化: 对于LCS问题,由于
dp[i][j]
只依赖于dp[i-1][j-1]
,dp[i-1][j]
,dp[i][j-1]
,所以可以只使用两行(或两列)的滚动数组来将空间复杂度优化到 O(min(m,n))。在嵌入式等内存受限的环境中,这种优化非常重要。
-
3.2.3 经典问题:0/1 背包问题(0/1 Knapsack Problem)
-
问题描述: 有 n 件物品,每件物品有自己的重量
w_i
和价值v_i
。一个背包能承受的最大重量是W
。每件物品只能选择放或不放(0/1),问在不超过背包最大承重的前提下,背包中物品的总价值最大是多少? -
最优子结构:
-
设
dp[i][j]
表示从前i
件物品中选择,背包容量为j
时所能获得的最大价值。 -
考虑第
i
件物品:-
不放第
i
件物品: 那么最大价值就是从前i-1
件物品中选择,背包容量仍为j
时的最大价值,即dp[i-1][j]
。 -
放第
i
件物品: 前提是背包容量j
足够大(j >= w_i
)。那么最大价值就是从前i-1
件物品中选择,背包容量为j - w_i
时的最大价值,再加上第i
件物品的价值v_i
,即dp[i-1][j - w_i] + v_i
。
-
-
所以,
dp[i][j]
就是这两种情况中的最大值。
-
-
状态定义:
dp[i][j]
表示从前i
件物品中选择,背包容量为j
时所能获得的最大价值。 -
状态转移方程:
-
如果
j < w_i
(当前背包容量不足以放下第i
件物品):dp[i][j] = dp[i-1][j]
(只能不放第i
件物品) -
如果
j >= w_i
(当前背包容量足够放下第i
件物品):dp[i][j] = max(dp[i-1][j], dp[i-1][j - w_i] + v_i)
(选择不放或放第i
件物品中的较大值)
-
-
边界条件:
-
dp[0][j] = 0
(没有物品可选时,价值为0) -
dp[i][0] = 0
(背包容量为0时,价值为0)
-
-
计算顺序: 从
dp[1][1]
开始,逐行(物品)逐列(容量)计算。
概念性C代码:0/1 背包问题
#include <stdio.h>
#include <stdlib.h> // For malloc, free
// 辅助函数:求两个整数的最大值
int max(int a, int b) {
return (a > b) ? a : b;
}
/**
* @brief 使用动态规划解决0/1背包问题
* @param weights 物品重量数组
* @param values 物品价值数组
* @param n 物品数量
* @param W 背包最大承重
* @return 在不超过背包最大承重的前提下,背包中物品的最大总价值
* @note dp[i][j] 表示从前i件物品中选择,背包容量为j时所能获得的最大价值。
* 时间复杂度:O(n*W),其中n是物品数量,W是背包容量。
* 空间复杂度:O(n*W) (用于存储dp表)。
* 可以优化空间复杂度到 O(W)。
*/
int knapsack_01(const int weights[], const int values[], int n, int W) {
// 创建一个 (n+1) x (W+1) 的二维数组来存储子问题的解
// dp[i][j] 对应前i件物品,背包容量为j
int **dp = (int **)malloc((n + 1) * sizeof(int *));
if (dp == NULL) {
perror("dp表行内存分配失败");
return 0;
}
for (int i = 0; i <= n; i++) {
dp[i] = (int *)malloc((W + 1) * sizeof(int));
if (dp[i] == NULL) {
perror("dp表列内存分配失败");
for (int k = 0; k < i; k++) free(dp[k]);
free(dp);
return 0;
}
}
// 填充dp表 (自底向上计算)
for (int i = 0; i <= n; i++) { // 遍历物品
for (int j = 0; j <= W; j++) { // 遍历背包容量
// 边界条件:没有物品或背包容量为0时,价值为0
if (i == 0 || j == 0) {
dp[i][j] = 0;
}
// 如果当前背包容量 j 小于第 i 件物品的重量 weights[i-1]
// (注意:weights和values数组是0-indexed,dp表是1-indexed)
else if (weights[i - 1] > j) {
dp[i][j] = dp[i - 1][j]; // 只能选择不放第i件物品
}
// 如果当前背包容量 j 足够放下第 i 件物品
else {
// 比较两种情况:
// 1. 不放第 i 件物品:价值为 dp[i-1][j]
// 2. 放第 i 件物品:价值为 dp[i-1][j - weights[i-1]] + values[i-1]
dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weights[i - 1]] + values[i - 1]);
}
}
}
// 最大价值存储在 dp[n][W]
int max_value = dp[n][W];
// 打印dp表 (可选,用于调试和理解)
printf("DP表:\n");
for (int i = 0; i <= n; i++) {
for (int j = 0; j <= W; j++) {
printf("%3d ", dp[i][j]);
}
printf("\n");
}
// 释放dp表的内存
for (int k = 0; k <= n; k++) {
free(dp[k]);
}
free(dp);
return max_value;
}
/**
* @brief 0/1背包问题 (空间优化版)
* @param weights 物品重量数组
* @param values 物品价值数组
* @param n 物品数量
* @param W 背包最大承重
* @return 最大总价值
* @note 空间复杂度优化到 O(W)。
* dp[j] 表示当前容量为j时的最大价值。
* 遍历容量时,j必须从大到小遍历,以确保dp[j - weights[i-1]]是上一轮(i-1件物品)的值。
*/
int knapsack_01_optimized_space(const int weights[], const int values[], int n, int W) {
// 创建一个大小为 (W+1) 的一维数组
int *dp = (int *)calloc(W + 1, sizeof(int)); // calloc初始化为0
if (dp == NULL) {
perror("优化dp表内存分配失败");
return 0;
}
// 遍历物品
for (int i = 0; i < n; i++) { // i 代表当前处理第 i+1 件物品 (weights[i], values[i])
// 遍历背包容量,从大到小
// 确保在计算 dp[j] 时,dp[j - weights[i]] 仍然是上一轮 (i-1件物品) 的值
for (int j = W; j >= weights[i]; j--) {
dp[j] = max(dp[j], dp[j - weights[i]] + values[i]);
}
printf("处理完第 %d 件物品后的dp数组: ", i + 1);
for(int k=0; k<=W; k++) {
printf("%d ", dp[k]);
}
printf("\n");
}
int max_value = dp[W];
free(dp);
return max_value;
}
// 主函数用于测试0/1背包问题
int main_knapsack_01() {
printf("--- 0/1 背包问题 (动态规划) 示例 ---\n");
int weights[] = {2, 1, 3, 2}; // 物品重量
int values[] = {3, 2, 4, 2}; // 物品价值
int n = sizeof(weights) / sizeof(weights[0]); // 物品数量
int W = 5; // 背包容量
printf("物品数量: %d, 背包容量: %d\n", n, W);
printf("物品列表:\n");
for (int i = 0; i < n; i++) {
printf(" 物品 %d: 重量 %d, 价值 %d\n", i + 1, weights[i], values[i]);
}
printf("\n");
int max_val_full = knapsack_01(weights, values, n, W);
printf("0/1 背包问题 (完整DP表) 最大价值: %d\n", max_val_full);
printf("\n--- 0/1 背包问题 (空间优化版) 示例 ---\n");
int max_val_opt = knapsack_01_optimized_space(weights, values, n, W);
printf("0/1 背包问题 (空间优化) 最大价值: %d\n", max_val_opt);
printf("--- 0/1 背包问题示例结束 ---\n");
return 0;
}
代码分析与说明:
-
knapsack_01
函数(完整DP表):-
dp
表的创建:int **dp = (int **)malloc((n + 1) * sizeof(int *));
创建一个(n+1) x (W+1)
的二维数组。dp[i][j]
存储前i
件物品在容量j
下的最大价值。 -
边界条件:
dp[0][j] = 0
和dp[i][0] = 0
,表示没有物品或背包容量为0时,价值为0。 -
状态转移方程:
-
else if (weights[i - 1] > j)
:如果当前物品i
的重量大于当前背包容量j
,则不能放这件物品,价值等于不考虑这件物品时的价值dp[i-1][j]
。 -
else
:如果能放,则比较“不放这件物品” (dp[i-1][j]
) 和 “放这件物品” (dp[i-1][j - weights[i-1]] + values[i-1]
) 两种情况的较大值。
-
-
计算顺序: 外层循环遍历物品 (
i
),内层循环遍历容量 (j
)。 -
时间复杂度: O(ntimesW)。
-
空间复杂度: O(ntimesW)。
-
-
knapsack_01_optimized_space
函数(空间优化版):-
空间优化原理: 观察
dp[i][j]
的状态转移方程,它只依赖于dp[i-1]
这一行(上一件物品的状态)。因此,我们只需要两行(或者更进一步,只用一行)来存储dp
值。 -
int *dp = (int *)calloc(W + 1, sizeof(int));
: 只需要一个一维数组dp
,大小为W+1
。dp[j]
此时表示当前处理到第i
件物品时,背包容量为j
的最大价值。 -
遍历容量的顺序:
for (int j = W; j >= weights[i]; j--)
。这是空间优化版的关键!-
必须从大容量向小容量遍历。
-
如果从
j=0
向W
遍历,那么在计算dp[j]
时,dp[j - weights[i]]
已经更新为当前物品的贡献了,这会导致重复计算(相当于每件物品可以无限次放入,变成完全背包问题)。 -
从大到小遍历确保了
dp[j - weights[i]]
仍然是上一轮(即不考虑当前物品i
)的价值,从而正确模拟了0/1背包的决策。
-
-
时间复杂度: 仍然是 O(ntimesW)。
-
空间复杂度: 优化到 O(W)。
-
-
做题编程随想录:
-
0/1背包是DP的另一个经典问题,理解其状态定义和状态转移方程是核心。
-
空间优化是DP问题中常见的技巧,特别是在嵌入式等内存资源有限的场景下,能显著降低内存消耗。
-
DP问题通常需要你识别出最优子结构和重叠子问题。
-
如何识别DP问题?
-
求最大/最小/最优: 很多DP问题都是求某种最优值。
-
计数: 统计有多少种方式达到某个目标。
-
存在性判断: 某个目标是否可以达到。
-
决策性问题: 在每一步做出决策,影响后续结果。
-
-
DP的思考流程:
-
定义状态:
dp[i][j]
代表什么?(最重要) -
找出状态转移方程:
dp[i][j]
如何由更小的dp
值推导出来? -
确定边界条件: 最小的
dp
值是什么? -
确定计算顺序: 如何遍历
dp
表才能保证依赖关系正确? -
(可选)空间优化: 是否可以降低空间复杂度?
-
-
表格:贪心与动态规划对比
特性 |
贪心算法(Greedy) |
动态规划(Dynamic Programming) |
---|---|---|
选择方式 |
每一步都选择局部最优解 |
考虑所有可能的子问题解,通过比较得到最优解 |
决策 |
不可撤销,一旦选择就确定 |
依赖于之前子问题的解,可“回溯”决策过程 |
子问题 |
通常不重叠(或重叠不影响局部最优) |
存在大量重叠子问题 |
适用条件 |
贪心选择性质 + 最优子结构性质 |
最优子结构 + 重叠子问题 |
效率 |
通常更简单、更高效(如果适用) |
通常更复杂,但能解决更广泛的问题 |
正确性 |
难以证明,容易出错 |
易于证明(通过状态转移方程) |
典型问题 |
活动选择,霍夫曼编码,最小生成树 |
背包问题,LCS,矩阵链乘,最长递增子序列 |
小结: 动态规划是一种强大的算法设计范式,它通过“记忆化”子问题的解,避免重复计算,从而高效地解决具有最优子结构和重叠子问题性质的问题。理解状态定义、状态转移方程、边界条件和计算顺序是掌握DP的关键。在面试和实际开发中,DP是解决复杂优化问题的利器,也是衡量你算法功底的重要标准。
3.3 回溯法(Backtracking):“试错”与“剪枝”的智慧
兄弟们,想象一下你在一个迷宫里,有很多岔路口。你不知道哪条路是正确的,所以你只能一条一条地去试。如果发现走不通,就退回到上一个岔路口,选择另一条路继续尝试。这就是回溯法的核心思想——有选择地进行尝试,如果发现当前路径不符合条件,就撤销之前的选择,退回到上一步,重新尝试其他路径。
3.3.1 回溯法的定义与特点
-
定义: 回溯法(Backtracking)是一种通过尝试所有可能的路径来找到问题解的算法。它通常以深度优先搜索(DFS)的方式遍历解空间树。当发现当前路径无法满足条件时,就“回溯”到上一步,撤销之前的选择,并尝试其他分支。
-
特点:
-
深度优先搜索(DFS): 遍历解空间树的方式。
-
试探性: 尝试所有可能的选择。
-
剪枝(Pruning): 当发现当前路径不可能得到有效解时,及时停止进一步的探索,避免不必要的计算。这是回溯法效率的关键!
-
递归实现: 通常使用递归函数来实现,但也可以用栈进行非递归实现。
-
-
适用场景:
-
组合问题: 从一组元素中选择满足条件的组合。
-
排列问题: 生成所有可能的排列。
-
子集问题: 生成所有可能的子集。
-
棋盘问题: N皇后问题、数独、迷宫寻路。
-
约束满足问题: 找到满足特定约束条件的解。
-
思维导图:回溯法的核心要素
graph TD
A[回溯法] --> B[核心思想: 试错与剪枝];
B --> B1[深度优先搜索 DFS];
B --> B2[尝试所有可能路径];
B --> B3[不符合条件则回溯];
B --> B4[剪枝优化];
A --> C[实现要素];
C --> C1[递归函数];
C --> C2[选择列表];
C --> C3[路径/状态记录];
C --> C4[撤销选择 (回溯)];
C --> C5[剪枝条件];
A --> D[典型应用];
D --> D1[组合/排列/子集];
D --> D2[N皇后问题];
D --> D3[数独];
D --> D4[迷宫寻路];
3.3.2 经典问题:N皇后问题
-
问题描述: 在一个 NtimesN 的棋盘上放置 N 个皇后,使得任意两个皇后都不能互相攻击。皇后可以攻击同一行、同一列或同一对角线上的其他棋子。找出所有可能的放置方案。
-
回溯思路:
-
逐行放置: 我们可以一行一行地放置皇后。
-
选择: 在当前行,尝试将皇后放置在每一列。
-
约束检查(剪枝): 在放置皇后之前,检查当前位置是否与之前已放置的皇后冲突(同一列、同一对角线)。如果冲突,则不能放置,直接跳过该位置。
-
递归: 如果当前位置不冲突,则放置皇后,并递归地进入下一行放置。
-
回溯: 如果下一行的所有位置都尝试完毕,或者无法放置皇后,则撤销当前行的皇后放置,退回到上一行,尝试当前行的下一个位置。
-
基本情况: 如果所有 N 个皇后都成功放置(即递归到第 N 行),则找到一个解,记录下来。
-
-
冲突判断:
-
同一列: 检查当前列是否已被占用。
-
同一主对角线: 位于同一主对角线上的所有位置
(row, col)
都满足row - col
是一个常数。 -
同一副对角线: 位于同一副对角线上的所有位置
(row, col)
都满足row + col
是一个常数。
-
概念性C代码:N皇后问题(回溯法)
#include <stdio.h>
#include <stdlib.h> // For malloc, free
#include <stdbool.h> // For bool type
// 全局变量或通过参数传递,用于存储所有解
char ***solutions; // 存储所有解决方案的二维棋盘表示
int solution_count; // 解决方案的数量
int N_queens; // N皇后问题的N
// 辅助函数:打印一个棋盘解决方案
void print_board(char **board) {
for (int i = 0; i < N_queens; i++) {
for (int j = 0; j < N_queens; j++) {
printf("%c ", board[i][j]);
}
printf("\n");
}
printf("\n");
}
// 辅助函数:将当前找到的解决方案复制到solutions中
void add_solution(char **board) {
char **new_board = (char **)malloc(N_queens * sizeof(char *));
if (new_board == NULL) { perror("解决方案复制失败"); return; }
for (int i = 0; i < N_queens; i++) {
new_board[i] = (char *)malloc((N_queens + 1) * sizeof(char)); // +1 for null terminator
if (new_board[i] == NULL) { perror("解决方案行复制失败"); return; }
strcpy(new_board[i], board[i]);
}
// 动态扩容solutions数组
if (solution_count == 0) {
solutions = (char ***)malloc(sizeof(char **));
} else {
solutions = (char ***)realloc(solutions, (solution_count + 1) * sizeof(char **));
}
if (solutions == NULL) { perror("解决方案数组扩容失败"); return; }
solutions[solution_count++] = new_board;
}
/**
* @brief 检查在 (row, col) 放置皇后是否安全
* @param board 当前棋盘状态
* @param row 当前行
* @param col 当前列
* @return true 如果安全,false 如果冲突
* @note 剪枝操作的核心。
*/
bool is_safe(char **board, int row, int col) {
// 检查当前列是否有皇后
for (int i = 0; i < row; i++) {
if (board[i][col] == 'Q') {
return false;
}
}
// 检查左上对角线是否有皇后 (row-col不变)
for (int i = row - 1, j = col - 1; i >= 0 && j >= 0; i--, j--) {
if (board[i][j] == 'Q') {
return false;
}
}
// 检查右上对角线是否有皇后 (row+col不变)
for (int i = row - 1, j = col + 1; i >= 0 && j < N_queens; i--, j++) {
if (board[i][j] == 'Q') {
return false;
}
}
return true;
}
/**
* @brief 回溯法解决N皇后问题的核心递归函数
* @param board 当前棋盘状态
* @param row 当前正在放置皇后的行
* @note 递归地尝试在每一行放置皇后,并进行剪枝。
*/
void solve_n_queens_recursive(char **board, int row) {
// 基本情况:如果所有皇后都已成功放置 (到达了最后一行之后)
if (row == N_queens) {
add_solution(board); // 找到一个解,将其保存
return;
}
// 递归步:在当前行的每一列尝试放置皇后
for (int col = 0; col < N_queens; col++) {
// 剪枝:检查当前位置是否安全
if (is_safe(board, row, col)) {
// 做出选择:在 (row, col) 放置皇后
board[row][col] = 'Q';
// 递归:进入下一行放置皇后
solve_n_queens_recursive(board, row + 1);
// 回溯:撤销当前选择,尝试当前行的下一个位置
board[row][col] = '.'; // 将皇后移除,恢复棋盘状态
}
}
}
/**
* @brief 解决N皇后问题的主函数
* @param n 棋盘大小N
* @return 找到的解决方案数量
*/
int solve_n_queens(int n) {
N_queens = n;
solution_count = 0;
solutions = NULL; // 初始化为NULL
// 创建一个空的棋盘
char **board = (char **)malloc(n * sizeof(char *));
if (board == NULL) { perror("棋盘内存分配失败"); return 0; }
for (int i = 0; i < n; i++) {
board[i] = (char *)malloc((n + 1) * sizeof(char)); // +1 for null terminator
if (board[i] == NULL) { perror("棋盘行内存分配失败"); return 0; }
for (int j = 0; j < n; j++) {
board[i][j] = '.'; // 初始化为 '.' (空位)
}
board[i][n] = '\0'; // 字符串结束符
}
// 从第一行开始递归放置皇后
solve_n_queens_recursive(board, 0);
// 释放棋盘内存 (只释放临时棋盘,解决方案的内存由solutions管理)
for (int i = 0; i < n; i++) {
free(board[i]);
}
free(board);
return solution_count;
}
// 主函数用于测试N皇后问题
int main_n_queens() {
printf("--- N皇后问题 (回溯法) 示例 ---\n");
int n_test = 4; // 测试4皇后问题
printf("解决 %d 皇后问题:\n", n_test);
int num_solutions = solve_n_queens(n_test);
printf("找到 %d 个解决方案。\n", num_solutions);
for (int i = 0; i < num_solutions; i++) {
printf("解决方案 %d:\n", i + 1);
print_board(solutions[i]);
// 释放每个解决方案的内存
for (int j = 0; j < N_queens; j++) {
free(solutions[i][j]);
}
free(solutions[i]);
}
if (solutions != NULL) {
free(solutions); // 释放存储解决方案的数组
}
n_test = 8; // 测试8皇后问题 (解的数量会多很多)
printf("\n解决 %d 皇后问题:\n", n_test);
num_solutions = solve_n_queens(n_test);
printf("找到 %d 个解决方案。\n", num_solutions);
// 注意:8皇后问题解决方案较多,这里不再打印所有棋盘,只打印数量。
// 如果需要打印,请取消注释上面的循环,但会输出大量内容。
for (int i = 0; i < num_solutions; i++) {
for (int j = 0; j < N_queens; j++) {
free(solutions[i][j]);
}
free(solutions[i]);
}
if (solutions != NULL) {
free(solutions);
}
printf("--- N皇后问题示例结束 ---\n");
return 0;
}
代码分析与说明:
-
全局变量
solutions
和solution_count
: 用于存储所有找到的N皇后解决方案。在C语言中,为了方便在递归函数中收集结果,通常会使用全局变量或者将这些变量作为指针传递。这里使用了全局变量,并在add_solution
中动态扩容solutions
数组。 -
is_safe(char **board, int row, int col)
函数:-
这是剪枝的核心。它检查在
(row, col)
位置放置皇后是否会与之前已放置的皇后冲突。 -
检查列:
for (int i = 0; i < row; i++)
检查当前列col
上方是否有皇后。 -
检查左上对角线:
for (int i = row - 1, j = col - 1; i >= 0 && j >= 0; i--, j--)
检查左上对角线。 -
检查右上对角线:
for (int i = row - 1, j = col + 1; i >= 0 && j < N_queens; i--, j++)
检查右上对角线。 -
时间复杂度: 每次检查最坏情况下是 O(N)。
-
-
solve_n_queens_recursive(char **board, int row)
函数:-
board
参数:char **board
表示当前棋盘的状态。 -
row
参数: 表示当前正在尝试放置皇后的行。 -
基本情况
if (row == N_queens)
: 如果row
达到了N_queens
,说明所有N
个皇后都已成功放置,找到了一个有效的解决方案,将其复制并保存。 -
递归步
for (int col = 0; col < N_queens; col++)
:-
遍历当前行的每一列
col
。 -
if (is_safe(board, row, col))
: 如果当前位置安全(不冲突),则进行下一步。 -
board[row][col] = 'Q';
: 做出选择,在当前位置放置皇后。 -
solve_n_queens_recursive(board, row + 1);
: 递归调用,进入下一行,尝试放置下一个皇后。 -
board[row][col] = '.';
: 回溯,当递归调用返回后,撤销当前行的皇后放置,将该位置恢复为空,以便尝试当前行的下一个列。
-
-
-
solve_n_queens
函数: 主入口函数,负责初始化棋盘和调用递归函数。 -
内存管理: 动态分配了棋盘和解决方案的内存,并在程序结束时逐一释放,防止内存泄漏。
-
时间复杂度: 回溯法的时间复杂度通常很难精确计算,因为它取决于剪枝的效率。在最坏情况下(没有剪枝),它会遍历整个解空间树,对于N皇后问题,大约是 O(N)(因为每行只能放一个皇后)。但有了剪枝,实际运行时间会大大减少。
-
空间复杂度: 主要取决于递归栈的深度,对于N皇后问题是 O(N)。另外,存储解决方案也需要 O(textnumber_of_solutionstimesN2) 的空间。
-
做题编程随想录:
-
回溯法是解决组合、排列、子集等问题以及约束满足问题的通用方法。
-
回溯法的模板:
void backtrack(int path[], int current_step, ...) { // 1. 终止条件 (Base Case): 达到目标或无法继续 if (满足结束条件) { // 记录/处理当前路径/解 return; } // 2. 尝试所有可能的选择 for (选择 in 当前可选列表) { // 3. 剪枝 (Pruning): 如果选择不合法,跳过 if (选择不合法) { continue; } // 4. 做出选择 (Make a choice): 更新状态,将选择加入路径 // path.add(选择); // 更新 visited 数组等 // 5. 递归调用 (Recurse): 探索下一个状态 backtrack(path, current_step + 1, ...); // 6. 撤销选择 (Backtrack): 恢复状态,为下一次尝试做准备 // path.remove(选择); // 恢复 visited 数组等 } }
-
剪枝的重要性: 剪枝是回溯法提高效率的关键。有效的剪枝可以大大减少搜索空间。
-
回溯与DFS的关系: 回溯法本质上就是一种深度优先搜索,它在搜索过程中不断尝试、回溯。
-
嵌入式中: 回溯法在嵌入式系统中可能较少直接用于实时性要求高的场景,因为它通常涉及大量的递归和状态保存/恢复。但在一些离线计算、配置生成、路径规划(如机器人路径规划的离线计算)等场景中,回溯法仍有其用武之地。需要注意的是,递归深度可能导致栈溢出,在嵌入式中可能需要将其转换为非递归的迭代实现(使用显式栈)。
-
3.4 分支限界法(Branch and Bound):“剪枝”的升级版
兄弟们,回溯法虽然能通过剪枝来提高效率,但它依然是“盲目”地深度优先搜索。有没有一种方法,能更“聪明”地选择要探索的分支,并且能更快地找到最优解呢?有!这就是分支限界法(Branch and Bound),它是回溯法的一种优化,特别适用于解决优化问题(求最大值或最小值)。
3.4.1 分支限界法的定义与核心思想
-
定义: 分支限界法是一种在问题的解空间树中搜索最优解的算法。它与回溯法类似,也是通过构建解空间树来搜索,但它不局限于深度优先搜索。它通过**限界函数(Bounding Function)**来评估每个分支的潜在价值,并优先选择最有希望的分支进行探索,同时剪掉那些不可能包含最优解的分支。
-
核心思想:
-
分支(Branch): 将问题的解空间划分为更小的子集,对应于解空间树中的分支。
-
限界(Bound): 为每个子集计算一个界限值(Bound)。
-
对于求最大值问题,界限值是当前子集可能达到的最大值上限。
-
对于求最小值问题,界限值是当前子集可能达到的最小值下限。
-
-
剪枝(Pruning):
-
如果当前子集的界限值比已找到的最优解(或当前最优解的界限)还要差,那么这个子集就不可能包含最优解,可以将其剪掉,不再探索。
-
通常使用**优先队列(Priority Queue)**来存储待探索的子集,并根据界限值进行优先级排序,优先探索那些最有希望的分支。
-
-
-
与回溯法的区别:
-
搜索方式: 回溯法通常是深度优先搜索(DFS);分支限界法通常是广度优先搜索(BFS)或最佳优先搜索(Best-First Search),使用优先队列来选择下一个要探索的节点。
-
目标: 回溯法通常是找到所有可行解或一个可行解;分支限界法通常是找到最优解。
-
剪枝依据: 回溯法剪枝基于当前路径是否满足约束;分支限界法剪枝基于当前路径是否可能包含比已知最优解更好的解。
-
思维导图:分支限界法的核心要素
graph TD
A[分支限界法] --> B[核心思想: 优化搜索];
B --> B1[分支 (划分解空间)];
B --> B2[限界 (评估潜在价值)];
B --> B3[剪枝 (排除劣质分支)];
A --> C[与回溯法的区别];
C --> C1[搜索方式 (BFS/最佳优先)];
C --> C2[目标 (最优解)];
C --> C3[剪枝依据 (界限值)];
A --> D[实现要素];
D --> D1[界限函数];
D --> D2[优先队列];
D --> D3[当前最优解记录];
A --> E[典型应用];
E --> E1[旅行商问题 TSP];
E --> E2[0/1背包问题 (优化)];
E --> E3[调度问题];
3.4.2 经典问题:0/1 背包问题(分支限界版本概念)
我们用0/1背包问题来理解分支限界法的概念,因为它的解空间树相对容易理解。
-
问题描述: 同3.2.3节。
-
解空间树:
-
每个节点代表一个决策(放或不放当前物品)。
-
根节点表示未做任何决策。
-
左子节点表示不放当前物品,右子节点表示放当前物品。
-
叶子节点表示所有物品都已处理完毕。
-
-
界限函数(求最大价值):
-
对于一个节点(部分决策),其界限值可以计算为:当前已获得的价值 + 剩余物品在分数背包(物品可以被分割)情况下能获得的最大价值。
-
为什么用分数背包? 因为分数背包问题可以用贪心算法(按单位重量价值排序)在 O(NlogN) 或 O(N) 时间内解决,它能提供一个当前分支可能达到的最大价值上限。如果这个上限都比当前已知的最优解还要小,那么这个分支就没有探索的必要了。
-
-
剪枝策略:
-
维护一个全局变量
max_profit
,记录当前找到的最大价值。 -
当探索一个新节点时,计算其界限值
bound
。 -
如果
bound <= max_profit
,则剪掉这个分支,因为即使这个分支能达到其理论上限,也无法超越当前已知最优解。
-
-
搜索策略:
-
使用最大优先队列来存储待探索的节点。优先队列的优先级是节点的界限值。
-
每次从优先队列中取出界限值最大的节点进行探索(分支),直到队列为空。
-
概念性C代码:0/1背包问题(分支限界法框架)
由于分支限界法的完整C语言实现,特别是涉及到优先队列和复杂的状态管理,会非常庞大和复杂,超出了本篇博客的合理篇幅。但我们可以提供一个概念性的框架和伪代码,以帮助理解其核心逻辑。
#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>
// 辅助结构体:表示解空间树中的一个节点
typedef struct Node {
int level; // 当前处理到第几个物品 (0到n-1)
int profit; // 当前已获得的价值
int weight; // 当前已放入背包的重量
double bound; // 当前节点可能达到的最大价值上限 (界限值)
} Node;
// 辅助函数:比较两个节点,用于优先队列 (按bound降序)
// 实际中需要一个优先队列的实现
// int compareNodes(const void *a, const void *b) {
// return ((Node *)b)->bound - ((Node *)a)->bound; // 降序
// }
// 辅助函数:计算当前节点的界限值 (使用分数背包的贪心策略)
// 假设物品已经按单位重量价值降序排序
double calculate_bound(Node current_node, int n, int W, const int weights[], const int values[]) {
double current_profit = current_node.profit;
int current_weight = current_node.weight;
int level = current_node.level;
// 如果当前重量已超过背包容量,则界限为负无穷 (不可达)
if (current_weight > W) {
return -1.0; // 或者一个非常小的负数
}
// 贪心填充剩余容量
while (level < n && current_weight + weights[level] <= W) {
current_profit += values[level];
current_weight += weights[level];
level++;
}
// 如果还有剩余容量,按分数背包填充剩余的物品
if (level < n) {
current_profit += (double)(W - current_weight) * values[level] / weights[level];
}
return current_profit;
}
// 全局变量,记录当前找到的最优解
int max_profit_found = 0;
/**
* @brief 分支限界法解决0/1背包问题的框架
* @param n 物品数量
* @param W 背包容量
* @param weights 物品重量数组
* @param values 物品价值数组
* @return 最大价值
* @note 这是一个概念性框架,实际需要一个优先队列数据结构。
* 假设物品已经按单位重量价值降序排序(用于计算bound)。
*/
int knapsack_branch_and_bound(int n, int W, const int weights[], const int values[]) {
// 1. 初始化优先队列 (这里用一个简单的数组模拟,实际需要堆实现)
// 假设我们有一个 MaxPriorityQueue<Node> pq;
// init_max_priority_queue(&pq);
// 2. 创建根节点并计算其界限
Node root;
root.level = 0;
root.profit = 0;
root.weight = 0;
root.bound = calculate_bound(root, n, W, weights, values);
// 将根节点加入优先队列
// enqueue_priority_queue(&pq, root);
max_profit_found = 0; // 初始化当前找到的最大价值
// 3. 循环直到优先队列为空
// while (!is_priority_queue_empty(&pq)) {
// Node u = dequeue_priority_queue(&pq); // 取出界限值最大的节点
// // 剪枝:如果当前节点的界限值小于等于已找到的最大价值,则剪枝
// if (u.bound <= max_profit_found) {
// continue;
// }
// // 如果所有物品都已处理 (到达叶子节点)
// if (u.level == n) {
// // 更新全局最大价值 (如果当前解更好)
// if (u.profit > max_profit_found) {
// max_profit_found = u.profit;
// }
// continue; // 达到叶子节点,无需继续分支
// }
// // 分支:考虑第 u.level+1 件物品 (即 weights[u.level], values[u.level])
// // 1. 包含第 u.level+1 件物品
// Node v_include;
// v_include.level = u.level + 1;
// v_include.weight = u.weight + weights[u.level];
// v_include.profit = u.profit + values[u.level];
// v_include.bound = calculate_bound(v_include, n, W, weights, values);
// if (v_include.weight <= W && v_include.bound > max_profit_found) {
// // 只有当界限值有可能超过当前最优解时才入队
// enqueue_priority_queue(&pq, v_include);
// }
// // 如果包含后,当前价值比max_profit_found大,且是叶子节点,也要更新
// if (v_include.level == n && v_include.profit > max_profit_found) {
// max_profit_found = v_include.profit;
// }
// // 2. 不包含第 u.level+1 件物品
// Node v_exclude;
// v_exclude.level = u.level + 1;
// v_exclude.weight = u.weight;
// v_exclude.profit = u.profit;
// v_exclude.bound = calculate_bound(v_exclude, n, W, weights, values);
// if (v_exclude.bound > max_profit_found) {
// // 只有当界限值有可能超过当前最优解时才入队
// enqueue_priority_queue(&pq, v_exclude);
// }
// // 如果不包含后,当前价值比max_profit_found大,且是叶子节点,也要更新
// if (v_exclude.level == n && v_exclude.profit > max_profit_found) {
// max_profit_found = v_exclude.profit;
// }
// }
printf("分支限界法框架演示:\n");
printf("这里省略了完整的优先队列实现和循环逻辑,因为它们会使代码过于复杂。\n");
printf("核心思想是:\n");
printf("1. 定义节点状态 (level, profit, weight, bound)。\n");
printf("2. 实现一个计算界限值的函数 (通常利用贪心算法)。\n");
printf("3. 使用优先队列来存储待探索的节点,优先级基于界限值。\n");
printf("4. 每次从队列中取出界限值最高的节点进行分支。\n");
printf("5. 剪枝:如果节点的界限值低于当前已知最优解,则放弃该分支。\n");
printf("6. 当到达叶子节点时,更新最优解。\n");
printf("\n");
// 假设最终找到了一个解
max_profit_found = 9; // 示例值,实际应由算法计算得出
return max_profit_found;
}
// 主函数用于测试分支限界框架
int main_knapsack_branch_and_bound() {
printf("--- 0/1 背包问题 (分支限界法概念) 示例 ---\n");
int weights[] = {2, 1, 3, 2}; // 物品重量
int values[] = {3, 2, 4, 2}; // 物品价值
int n = sizeof(weights) / sizeof(weights[0]); // 物品数量
int W = 5; // 背包容量
printf("物品数量: %d, 背包容量: %d\n", n, W);
printf("物品列表:\n");
for (int i = 0; i < n; i++) {
printf(" 物品 %d: 重量 %d, 价值 %d\n", i + 1, weights[i], values[i]);
}
printf("\n");
// 注意:这里需要对物品进行预处理,按单位重量价值降序排序,以便计算bound
// 实际实现中,需要先对 (values[i]/weights[i]) 进行排序,并保持物品的对应关系。
// 简化起见,这里假设已经排好序或者bound函数内部处理。
int max_val = knapsack_branch_and_bound(n, W, weights, values);
printf("0/1 背包问题 (分支限界法) 概念演示最大价值: %d\n", max_val);
printf("--- 分支限界法示例结束 ---\n");
return 0;
}
代码分析与说明:
-
Node
结构体: 定义了在解空间树中每个节点的状态,包括level
(当前处理到第几个物品)、profit
(当前价值)、weight
(当前重量)和bound
(界限值)。 -
calculate_bound
函数:-
这是分支限界法的核心。它计算当前节点可能达到的最大价值上限。
-
首先,它包含当前节点已有的
profit
和weight
。 -
然后,它以贪心的方式(按单位重量价值从高到低)填充剩余的物品,直到背包满或所有物品都已考虑。
-
如果还有剩余容量,并且下一个物品可以被部分放入(分数背包),则计算这部分物品的价值。
-
这个
bound
值是一个乐观的估计,它告诉我们,即使我们能把剩余的物品切碎了放进去,最多也只能达到这个价值。
-
-
knapsack_branch_and_bound
框架:-
优先队列: 伪代码中使用了
MaxPriorityQueue
。在C语言中,这需要用之前实现的堆(最大堆)来构建。每次从优先队列中取出bound
值最大的节点进行探索。 -
根节点: 初始化根节点,并计算其界限值,然后入队。
-
主循环: 循环直到优先队列为空。
-
Node u = dequeue_priority_queue(&pq);
: 取出当前最有希望的节点。 -
剪枝
if (u.bound <= max_profit_found)
: 如果当前节点的界限值(乐观估计)都比已找到的最优解还要差,那么这个分支就没必要再探索了,直接跳过。 -
叶子节点处理
if (u.level == n)
: 如果到达了叶子节点(所有物品都已考虑),说明找到了一个完整的可行解,更新max_profit_found
。 -
分支: 对于当前节点
u
,考虑第u.level
件物品(即weights[u.level-1]
,如果level
是1-indexed)。-
包含: 创建一个新节点
v_include
,表示包含当前物品,更新其profit
和weight
。计算其bound
。如果v_include.weight <= W
且v_include.bound > max_profit_found
,则将其入队。 -
不包含: 创建一个新节点
v_exclude
,表示不包含当前物品,profit
和weight
不变。计算其bound
。如果v_exclude.bound > max_profit_found
,则将其入队。
-
-
-
-
时间复杂度: 分支限界法的时间复杂度通常是指数级的,但在实践中,通过有效的剪枝,可以大大减少搜索空间。最坏情况下仍是指数级。
-
空间复杂度: 取决于优先队列中存储的节点数量,最坏情况下是 O(2N),但通常远小于此。
-
做题编程随想录:
-
分支限界法通常用于解决NP-hard的优化问题,它是一种精确算法(能找到最优解),而不是近似算法。
-
关键在于设计有效的界限函数: 界限函数必须能够快速计算,并且能够提供一个尽可能紧的界限,这样才能有效剪枝。
-
与回溯法的对比: 分支限界法通过优先队列和界限函数,实现了“最佳优先搜索”,它比回溯法的“深度优先搜索”更具目标性,能更快地收敛到最优解。
-
嵌入式中: 分支限界法由于其计算复杂性,在资源受限的嵌入式系统中直接应用可能较少。但其思想(通过乐观估计进行剪枝)在一些需要离线优化、路径规划等场景中仍有借鉴意义。对于实时性要求高的系统,通常会采用近似算法或启发式算法。
-
表格:回溯法与分支限界法对比
特性 |
回溯法(Backtracking) |
分支限界法(Branch and Bound) |
---|---|---|
搜索方式 |
深度优先搜索(DFS) |
广度优先搜索(BFS)或最佳优先搜索 |
目标 |
找到所有可行解或一个可行解 |
找到问题的最优解 |
剪枝依据 |
当前路径是否满足约束条件 |
当前分支的界限值是否比已知最优解差 |
数据结构 |
递归栈(隐式)或显式栈 |
优先队列(通常是最大堆或最小堆) |
应用 |
组合、排列、子集,N皇后,数独 |
0/1背包(优化),旅行商问题,调度问题 |
效率 |
依赖于剪枝效率,最坏指数级 |
依赖于界限函数效率,通常优于纯回溯,但最坏仍指数级 |
小结: 分支限界法是回溯法的一种高级优化,它通过引入界限函数和优先队列,实现了更智能的搜索策略,能够更快地找到优化问题的最优解。理解其“分支”、“限界”和“剪枝”的核心思想,以及与回溯法的区别,能让你在面对复杂优化问题时,拥有更强大的解决能力。
第三部分总结与展望:算法的“策略”与“技巧”大揭秘
兄弟们,恭喜你完成了《算法导论》“大黑书”修炼的第三部分:算法设计范式篇 - 算法的“策略”与“技巧”!
在这段充满智慧和挑战的旅程中,我们一起:
-
揭示了贪心算法的“诱惑与陷阱”: 理解了贪心算法的核心思想(局部最优)、适用条件(贪心选择性质、最优子结构性质),并通过经典的活动选择问题,用C语言代码深入剖析了其实现和正确性。我们也看到了贪心算法的局限性,认识到局部最优不一定能带来全局最优。
-
掌握了动态规划的“记忆化”艺术: 深入学习了动态规划的核心思想(最优子结构、重叠子问题),以及状态定义、状态转移方程、边界条件和计算顺序这四大要素。我们通过最长公共子序列(LCS)和0/1背包问题这两个经典案例,用C语言代码详细演示了DP的实现,包括空间优化技巧,让你彻底理解了DP如何通过“记忆”来避免重复计算。
-
领略了回溯法的“试错与剪枝”智慧: 了解了回溯法的核心思想(深度优先搜索、尝试、回溯、剪枝),并通过N皇后问题这个经典难题,用C语言代码展示了回溯法的递归实现和剪枝的重要性,让你学会如何在庞大的解空间中高效地寻找解。
-
认识了分支限界法这个“剪枝”的升级版: 理解了分支限界法与回溯法的区别,它如何通过界限函数和优先队列实现更智能的搜索,以求找到最优解。虽然没有提供完整的C语言实现,但通过概念性框架,让你掌握了其核心思想和适用场景。
现在,你已经掌握了算法世界的“十八般武艺”中的高级“招式”!你不仅能够理解这些算法设计范式背后的哲学,还能用C语言亲手实现它们,并分析它们的性能和适用场景。这些知识,是你在力扣、牛客等刷题平台冲击高分,以及在实际工程中解决复杂优化问题的核心竞争力!
这仅仅是《算法导论》“大黑书”终极修炼的第三步!在接下来的第四部分,我们将进入算法的“高级应用”与“进阶优化”——图算法进阶与高级主题!我们将一起探索最短路径、最小生成树、最大流等更高级的图算法,以及NP完全性理论等算法领域的终极思考。
准备好了吗?让我们继续深入,成为真正的算法高手!
如果你觉得这份“秘籍”对你有亿点点帮助,请点赞、收藏、转发!