破解动态规划双璧:从「单词迷宫」到「数字丛林」的思维探险

算法是逻辑的诗,动态规划则是诗中最精妙的韵脚。
当"单词拆分"的字符链条遇上"点数蟾蜍"的数字棋局,看似无关的题目背后,藏着动态规划(DP)的统一灵魂。今日,我们将拨开迷雾,用专业视角拆解两道经典题目,揭示DP的"状态转移艺术",并绘制思维地图,带您体验算法设计的颅内高潮!

一、问题全景:当字符串遇见数字
  1. 单词拆分(Word Break)

    • 核心挑战:字符串s能否被字典wordDict中的单词无缝拼接?(单词可重复使用)

    • 关键矛盾:如何避免暴力搜索的指数级爆炸?

    • 示例隐喻
      s = "applepenapple" 如一条锁链,字典 ["apple","pen"] 是两把钥匙——能否用钥匙精准拆解链条?

  2. 蟾蜍并获得点数(Delete and Earn)

    • 核心挑战:在数组中取数得分,但选择x抹除x-1x+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"阻断通路)

动态规划核心策略

  1. 状态定义
    dp[i] = 字符串前 i 个字符能否被字典拆分(布尔值)

  2. 状态转移方程

  • 算法流程

    • 初始化 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

动态规划核心策略

  1. 问题转化

    • 统计每个数字 x 的总点数:value[x] = x * (x的出现次数)

    • 将问题转化为 在数值轴上选取互斥集合(选取 x 则禁止选 x-1 和 x+1

    <center> | 数字 | 总点数 | 相邻冲突 | |------|--------|----------| | 1 | 0 | 1-2 | | 2 | 4 | 1-2-3 | | 3 | 9 | 2-3-4 | | 4 | 4 | 3-4 | </center>
  2. 状态定义

    • 按数字大小升序处理,设 dp[x] = 考虑所有 ≤x 的数字时的最大点数

  3. 状态转移方程

算法流程

  • 预处理:计算 value[] 数组,按数字大小排序

  • 初始化 dp[0]=0dp[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-1x-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设计的"道"与"术"
  1. 共性内核

    • 最优子结构:全局最优解依赖子问题最优解(dp[i]dp[j]决定)。

    • 状态压缩:用一维数组存储子问题解,避免重复计算。

  2. 个性差异

    • 单词拆分

      • 状态转移是发散式(需枚举所有切割点)。

      • 性能瓶颈在子串查询(哈希表是刚需)。

    • 点数蟾蜍

      • 状态转移是聚焦式(仅依赖前两个状态)。

      • 核心在值域映射(将离散数字转化为连续处理)。

  3. 思维跃迁

    • "分治"视角:单词拆分是"横向分段",点数蟾蜍是"纵向分层"。

    • "规避"哲学:前者规避断裂,后者规避相邻,本质都是约束条件下的组合优化


博客结语

"单词拆分"如庖丁解牛,以DP之刃精准分割字符链;"点数蟾蜍"若弈者谋局,借DP之眼洞见数字杀阵。两道题在动态规划的星河中交相辉映,照亮了算法设计的终极奥义:

"状态定义是骨架,转移方程是血脉,而问题抽象——是赋予算法生命的灵魂。"

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

司铭鸿

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值