算法是逻辑的诗,动态规划则是诗中最精妙的韵脚。
当"单词拆分"的字符链条遇上"点数蟾蜍"的数字棋局,看似无关的题目背后,藏着动态规划(DP)的统一灵魂。今日,我们将拨开迷雾,用专业视角拆解两道经典题目,揭示DP的"状态转移艺术",并绘制思维地图,带您体验算法设计的颅内高潮!
一、问题全景:当字符串遇见数字
-
单词拆分(Word Break)
-
核心挑战:字符串
s
能否被字典wordDict
中的单词无缝拼接?(单词可重复使用) -
关键矛盾:如何避免暴力搜索的指数级爆炸?
-
示例隐喻:
s = "applepenapple"
如一条锁链,字典["apple","pen"]
是两把钥匙——能否用钥匙精准拆解链条?
-
-
蟾蜍并获得点数(Delete and Earn)
-
核心挑战:在数组中取数得分,但选择
x
会抹除x-1
和x+1
,如何最大化得分? -
关键矛盾:数字的"相邻性"引发的连锁反应。
-
示例隐喻:
nums = [2,2,3,3,3,4]
如一片荷塘,取走"3"(蟾蜍)会惊走相邻的"2"和"4"——如何智慧捕捞?
-
题目程序:
#include <stdio.h> // 标准输入输出函数
#include <stdlib.h> // 内存分配和释放函数
#include <string.h> // 字符串处理函数
#include <stdbool.h> // 布尔类型支持
/**
* 判断字符串是否可由字典单词拼接而成
* @param s 目标字符串
* @param wordDict 字典单词数组
* @param wordDictSize 字典单词数量
* @return bool 是否可拼接成功
*/
bool wordBreak(char* s, char** wordDict, int wordDictSize) {
// 获取目标字符串长度
int n = strlen(s);
// 创建动态规划数组 (长度n+1,包含空字符串情况)
bool* dp = (bool*)malloc((n + 1) * sizeof(bool));
// 初始化dp数组为false
for (int i = 0; i <= n; i++) {
dp[i] = false;
}
// 基础情况:空字符串可被拆分
dp[0] = true;
// 遍历字符串所有位置 (1到n)
for (int i = 1; i <= n; i++) {
// 遍历字典中所有单词
for (int j = 0; j < wordDictSize; j++) {
// 获取当前单词及其长度
char* word = wordDict[j];
int len = strlen(word);
// 检查:1.当前长度足够 2.起始位置可拆分
if (i >= len && dp[i - len]) {
// 比较s[i-len]到s[i-1]子串与当前单词
bool match = true;
for (int k = 0; k < len; k++) {
// 逐字符比较子串和单词
if (s[i - len + k] != word[k]) {
match = false;
break; // 发现不匹配立即退出
}
}
// 完全匹配则标记当前位置可拆分
if (match) {
dp[i] = true;
break; // 找到匹配后跳出单词循环
}
}
}
}
// 保存最终结果
bool result = dp[n];
// 释放动态分配的dp数组
free(dp);
// 返回最终判断结果
return result;
}
// 主函数:测试用例验证
int main() {
// 测试用例1
char* s1 = "leetcode";
char* dict1[] = {"leet", "code"};
int dictSize1 = 2;
bool ret1 = wordBreak(s1, dict1, dictSize1);
printf("Test1: %s\n", ret1 ? "true" : "false"); // 应输出true
// 测试用例2
char* s2 = "applepenapple";
char* dict2[] = {"apple", "pen"};
int dictSize2 = 2;
bool ret2 = wordBreak(s2, dict2, dictSize2);
printf("Test2: %s\n", ret2 ? "true" : "false"); // 应输出true
// 测试用例3
char* s3 = "catsandog";
char* dict3[] = {"cats", "dog", "sand", "and", "cat"};
int dictSize3 = 5;
bool ret3 = wordBreak(s3, dict3, dictSize3);
printf("Test3: %s\n", ret3 ? "true" : "false"); // 应输出false
return 0;
}
输出结果:
二、算法拆解:DP的两种面孔
题目1:单词拆分——"链式反应"DP
问题场景
想象你站在一堵由字母组成的巨墙前(字符串 s
),手中有一串钥匙(单词字典 wordDict
)。每把钥匙可重复使用,目标是将巨墙拆解为钥匙能打开的连续段落。
关键示例
-
s = "leetcode"
,wordDict = ["leet","code"]
→ 可拆为"leet"+"code"
-
s = "catsandog"
,wordDict = ["cats","dog"]
→ 无解("sand"
阻断通路)
动态规划核心策略
-
状态定义:
dp[i]
= 字符串前i
个字符能否被字典拆分(布尔值) -
状态转移方程:
-
-
算法流程:
-
初始化
dp[0]=true
(空串视为可拆分) -
对每个位置
i
从1
到n
,遍历所有j<i
,检查子串s[j:i]
的合法性 -
返回
dp[n]
-
思维洞见
该问题本质是 有状态依赖的路径搜索。DP 数组像一盏探照灯,从字符串起点扫描至终点,每一步依赖前序节点的可行性,形成链式推理。
验证示例:
- 示例1:输入
s = "leetcode"
,wordDict = ["leet", "code"]
,输出为true
。s
可以拆分为"leet"
和"code"
。
- 示例2:输入
s = "applepenapple"
,wordDict = ["apple", "pen"]
,输出为true
。s
可以拆分为"apple"
,"pen"
,"apple"
。
- 示例3:输入
s = "catsandog"
,wordDict = ["cats", "dog", "sand", "and", "cat"]
,输出为false
。- 无法找到满足条件的拆分方式。
题目2:蟾蜍并获得点数——"邻居规避"DP
问题场景
你闯入一片数字丛林(数组 nums
),采摘数字 nums[i]
获得点数,但会立即清除所有值为 nums[i]±1
的“毒草”。如何在避免触发连锁清除的前提下最大化收益?
关键示例
-
nums = [3,4,2]
→ 删除4
得4
点(清除3
),再删2
得2
点 → 总6
点 -
nums = [2,2,3,3,3,4]
→ 删除三个3
得9
点(清除所有2
和4
)
动态规划核心策略
-
问题转化:
-
统计每个数字
x
的总点数:value[x] = x * (x的出现次数)
-
将问题转化为 在数值轴上选取互斥集合(选取
x
则禁止选x-1
和x+1
)
-
-
状态定义:
-
按数字大小升序处理,设
dp[x]
= 考虑所有≤x
的数字时的最大点数
-
-
状态转移方程:
算法流程
-
预处理:计算
value[]
数组,按数字大小排序 -
初始化
dp[0]=0
,dp[1]=value[1]
-
遍历
x
从2
到max_num
:-
若
x
存在:dp[x] = max(dp[x-1], dp[x-2] + value[x])
-
若
x
不存在:dp[x] = dp[x-1]
-
-
返回
dp[max_num]
思维洞见
该问题实为 带约束的序列决策,通过数值排序将离散点映射为线性结构,其状态转移类似经典的 "打家劫舍" 模型,凸显动态规划对问题结构的重塑能力。
验证示例:
- 示例1:输入
nums = [3,4,2]
,输出为6。- 删除4,获得4点数,同时删除3。
- 删除2,获得2点数,总点数为6。
- 示例2:输入
nums = [2,2,3,3,3,4]
,输出为9。- 删除3,获得3 × 3 = 9点数,同时删除2和4。
- 总点数为9。
题目程序:
#include <stdio.h> // 标准输入输出函数
#include <stdlib.h> // 内存分配和释放函数
#include <string.h> // 字符串处理函数
// 辅助函数:返回两个整数中的较大值
int max(int a, int b) {
return (a > b) ? a : b;
}
/**
* 计算删除操作可获得的最大点数
* @param nums 整数数组
* @param numsSize 数组长度
* @return int 可获得的最大点数
*/
int deleteAndEarn(int* nums, int numsSize) {
// 如果数组为空,直接返回0
if (numsSize == 0) return 0;
// 步骤1:找出数组中的最大值
int max_val = nums[0];
for (int i = 1; i < numsSize; i++) {
if (nums[i] > max_val) {
max_val = nums[i];
}
}
// 步骤2:创建点数数组并初始化为0
// 数组大小设置为max_val+1,索引0到max_val对应所有可能的值
int* points = (int*)calloc(max_val + 1, sizeof(int));
// 步骤3:填充点数数组
// 对于每个值,计算该值可获得的总点数(值 × 出现次数)
for (int i = 0; i < numsSize; i++) {
points[nums[i]] += nums[i];
}
// 步骤4:动态规划处理
// 特殊情况处理:当最大值为0时
if (max_val == 0) {
free(points);
return 0;
}
// 创建动态规划数组
int* dp = (int*)malloc((max_val + 1) * sizeof(int));
// 初始化dp数组
dp[0] = points[0]; // 只有值为0时的点数
// 处理值为1的情况
if (max_val >= 1) {
dp[1] = max(points[0], points[1]);
}
// 动态规划状态转移
for (int i = 2; i <= max_val; i++) {
// 状态转移方程:
// 选择当前值:dp[i-2] + points[i](因为不能选择相邻值i-1)
// 不选择当前值:dp[i-1]
dp[i] = max(dp[i - 1], dp[i - 2] + points[i]);
}
// 保存最终结果
int result = dp[max_val];
// 释放动态分配的内存
free(points);
free(dp);
// 返回最大点数
return result;
}
// 主函数:测试用例验证
int main() {
// 测试用例1: [3,4,2] -> 6
int nums1[] = {3, 4, 2};
int size1 = sizeof(nums1) / sizeof(nums1[0]);
int ret1 = deleteAndEarn(nums1, size1);
printf("Test1: %d (Expected: 6)\n", ret1);
// 测试用例2: [2,2,3,3,3,4] -> 9
int nums2[] = {2, 2, 3, 3, 3, 4};
int size2 = sizeof(nums2) / sizeof(nums2[0]);
int ret2 = deleteAndEarn(nums2, size2);
printf("Test2: %d (Expected: 9)\n", ret2);
// 测试用例3: 空数组 -> 0
int* nums3 = NULL;
int size3 = 0;
int ret3 = deleteAndEarn(nums3, size3);
printf("Test3: %d (Expected: 0)\n", ret3);
// 测试用例4: 单个元素 [5] -> 5
int nums4[] = {5};
int size4 = sizeof(nums4) / sizeof(nums4[0]);
int ret4 = deleteAndEarn(nums4, size4);
printf("Test4: %d (Expected: 5)\n", ret4);
// 测试用例5: 包含0 [0,0,1,2] -> 2
int nums5[] = {0, 0, 1, 2};
int size5 = sizeof(nums5) / sizeof(nums5[0]);
int ret5 = deleteAndEarn(nums5, size5);
printf("Test5: %d (Expected: 2)\n", ret5);
return 0;
}
输出结果:
三、双题对比:DP的维度战争
维度 | 单词拆分 | 蟾蜍并获得点数 |
---|---|---|
DP类型 | 序列型DP(字符串分段) | 值域型DP(数字选择) |
状态依赖 | 任意前驱位置 j | 固定前驱 x-1 , x-2 |
转移开销 | O(n) 次子串查询 | O(1) 次比较 |
数据结构 | 哈希表加速子串查询 | 桶数组存储值域点数 |
约束特性 | 连续性约束(子串覆盖) | 相邻性约束(删除邻居) |
优化方向 | 字典树剪枝、记忆化DFS | 滚动数组压缩空间 |
对比图示:
text
单词拆分DP: [0] → [j] → [i] │ ▲ └──────┘ (子串 s[j:i] ∈ dict) 蟾蜍DP: ... → [x-2] → [x-1] → [x] │ ▲ ▲ └───────┘ (选x则跳过x-1)
四、深度总结:DP设计的"道"与"术"
-
共性内核:
-
最优子结构:全局最优解依赖子问题最优解(
dp[i]
由dp[j]
决定)。 -
状态压缩:用一维数组存储子问题解,避免重复计算。
-
-
个性差异:
-
单词拆分:
-
状态转移是发散式(需枚举所有切割点)。
-
性能瓶颈在子串查询(哈希表是刚需)。
-
-
点数蟾蜍:
-
状态转移是聚焦式(仅依赖前两个状态)。
-
核心在值域映射(将离散数字转化为连续处理)。
-
-
-
思维跃迁:
-
"分治"视角:单词拆分是"横向分段",点数蟾蜍是"纵向分层"。
-
"规避"哲学:前者规避断裂,后者规避相邻,本质都是约束条件下的组合优化。
-
博客结语
"单词拆分"如庖丁解牛,以DP之刃精准分割字符链;"点数蟾蜍"若弈者谋局,借DP之眼洞见数字杀阵。两道题在动态规划的星河中交相辉映,照亮了算法设计的终极奥义:
"状态定义是骨架,转移方程是血脉,而问题抽象——是赋予算法生命的灵魂。"