引子 :动态规划硬核之路所有个人源码分享+解题过程共享(0)
最近搞完了dp动态规划问题+贪心算法问题,这里是所有题型的全览:
以下是值得思考、值得反复练习的经典动态规划算法问题!!!
一共10道题
1 最长公共子序列:
2 最长公共子串:
#3刷 2025/7/15
这里变成了:dp[i][j] = dp[i-1][j-1]+1就是回越界
/**
* 代码中的类名、方法名、参数名已经指定,请勿修改,直接返回方法规定的值即可
*
* longest common substring
* @param str1 string字符串 the string
* @param str2 string字符串 the string
* @return string字符串
*/
#include <stdio.h>
char *LCS(char *str1, char *str2)
{
// write code here
// int l1 =strlen(str1);
// int l2 = strlen(str2);
// if(l1==0 || l2 ==0 ){
// return NULL;
// }
// int Max = 0,end = 0;
// int dp[5001][5001] = {0};
// for(int i =1 ;i<=l1;i++){
// for(int j=1;j<=l2;j++){
// if(str1[i-1]==str2[j-1]){
// dp[i][j]=dp[i-1][j-1]+1;
// if(dp[i][j]>Max){
// end = i-1;
// Max = dp[i][j] ;
// }
// }
// else{
// dp[i][j]=0;
// }
// }
// }
// //存到了产犊Max end结束的位置
// char* res = (char* )malloc(sizeof(char)*3000);
// for(int i = 0;i<Max;i++){
// res[i]= str1[end-Max+1+i];
// }
// res[Max]='\0';
// return res;
// #2刷
// 最长公共子串,两个位置的必须相同,不然直接跳过为0
// int len1 = strlen(str1) , len2 = strlen(str2);
// int dp[len1+1][len2+1] ;
// dp[0][0] = 0 ;
// int maxLen = 0;
// int endPos = 0;
// for(int i=1;i<=len1;i++){
// for(int j =1;j<=len2;j++){
// if(str1[i-1]==str2[j-1]){
// dp[i][j] = dp[i-1][j-1]+1;
// if(dp[i][j]>maxLen){
// endPos = i;
// maxLen = dp[i][j];
// }
// }
// else{
// dp[i][j]=0;
// }
// }
// }
// endPos-=1;
// char* res = (char*)malloc(5001*sizeof(char));
// for(int i = 0;i<maxLen;i++){
// res[i] = str1[endPos-maxLen+i];
// }
// res[maxLen] = '\0';
// return res;
// #3刷
int len1 = strlen(str1);
int len2 = strlen(str2);
int **dp = (int **)malloc((len1 + 1) * sizeof(int *));
for (int i = 0; i <= len1; i++)
{
dp[i] = (int *)calloc((len2 + 1), sizeof(int));
}
int maxLen = 0;
int end = 0;
for (int i = 1; i <= len1; i++)
{
for (int j = 1; j <= len2; j++)
{
if (str1[i - 1] == str2[j - 1])
{
dp[i][j] = dp[i - 1][j - 1] + 1;
if (dp[i][j] > maxLen)
{
maxLen = dp[i][j];
// #vip 容易错
end = i - 1;
}
}
else
{
dp[i][j] = 0;
}
}
}
// 处理这个start ->end 的序列:
int start = end - maxLen + 1;
char *res = (char *)malloc(5000 * sizeof(char));
for (int i = 0; i < maxLen; i++)
{
res[i] = str1[start + i];
}
res[maxLen] = '\0';
return res;
}
3 数字翻译成字符串?
/**
* 代码中的类名、方法名、参数名已经指定,请勿修改,直接返回方法规定的值即可
*
* 解码
* @param nums string字符串 数字串
* @return int整型
*/
int solve(char *nums)
{
// write code here
// int len = strlen(nums);
// #1刷
// 两种情况:
// 单独译码:i!=0,dp[i] =dp[i]+dp[i-1];
// 合并译码:<i-1,i> <- (10,26) => dp[i] = dp[i-2]+dp[i];
int dp[100] = {0};
dp[0] = 1;
// dp[1] = 1;
if (nums[0] == '0')
{
dp[1] = 0;
}
else
{
dp[1] = 1;
}
int len = strlen(nums);
for (int i = 2; i <= len; i++)
{
dp[i] = 0;
if (nums[i - 1] != '0')
{
dp[i] += dp[i - 1];
}
int temp = 0;
temp = 10 * (nums[i - 2] - '0') + (nums[i - 1] - '0');
if (temp >= 10 && temp <= 26)
{
dp[i] += dp[i - 2];
}
}
return dp[len];
// int dp[100];
// int len = strlen(nums);
// dp[0] = 0;
// for (int i = 1; i <= len; i++)
// {
// dp[i] = len + 1;
// }
// for (int i = 0; i < len; i++)
// {
// int tempCoin = nums[i];
// for (int j = tempCoin; j <= len; j++)
// if (dp[j -tempCoin] != len + 1)
// {
// dp[j] = min(dp[j], dp[j - tempCoin] + 1);
// }
// }
// return dp[len];
}
关键:
(1)第一种情况:a[i]!=0
(2) 第二章情况:a[i]<(10,26)
4 兑换零钱?
遍历所有的1->len长度的序列的每一个硬币面值:
如果dp[j-coin]!=aim+1 那么更新整个dp[j]的值
比如【5,2,3】 20
第一轮,从5>>>20,更新的有:dp[5] dp[10] dp[15] dp[20]
/**
* 代码中的类名、方法名、参数名已经指定,请勿修改,直接返回方法规定的值即可
*
* 最少货币数
* @param arr int整型一维数组 the array
* @param arrLen int arr数组长度
* @param aim int整型 the target
* @return int整型
*/
int min(int a, int b)
{
return a < b ? a : b;
}
int minMoney(int *arr, int arrLen, int aim)
{
// // write code here
// int maxLen = aim + 1;
// int dp[aim + 1];
// dp[0] = 0;
// for (int i = 1; i <= aim; i++)
// {
// dp[i] = aim + 1;
// }
// // 随你怎么变 总会比aim+1小
// // for (int i = 1; i <= aim; i++)
// // {
// // for (int j = 0; j < arrLen; j++)
// // {
// // if (i >= arr[j])
// // {
// // dp[i] = min(dp[i], dp[i - arr[j]] + 1);
// // }
// // }
// // }
// for (int i = 1; i <= aim; i++)
// {
// for (int j = 0; j < arrLen; j++)
// {
// if (i >= arr[j])
// {
// dp[i] = min(dp[i], dp[i - arr[j]] + 1);
// }
// }
// }
// if (dp[aim] == aim + 1)
// {
// return -1;
// }
// return dp[aim];
// 2刷 /
int dp[aim + 1];
int len = arrLen;
dp[0] = 0;
for (int i = 1; i <= aim; i++)
{
dp[i] = aim + 1;
}
for (int i = 0; i < len; i++)
{
int tempCoin = arr[i];
for (int j = tempCoin; j <= aim; j++)
if (dp[j - tempCoin] != aim + 1)
{
dp[j] = min(dp[j], dp[j - tempCoin] + 1);
}
}
if (dp[aim] != aim + 1)
{
return dp[aim];
}
else
{
return -1;
}
}
5 最长回文?
#include <stdio.h>
#include <math.h>
#include <string.h>
// int doFunc(char *A, int left, int right)
// {
// // 从left和right开始一个个往两边拓展,用一个cnt计数
// // int len = strlen(A);
// // int cnt = 0, flag = 0;
// // if (left == right)
// // {
// // cnt = 1;
// // left--;
// // right++;
// // }
// // while (A[left] == A[right] && left >= 0 && right < len)
// // {
// // cnt += 2;
// // left--;
// // right++;
// // }
// // return cnt;
// //最开始一个都没有,绝对是0
// int res =0;
// //中间相等bab 、 不相等baab
// if(left==right){
// res =1;
// left-=1;
// right+=1;
// }
// // int len = strLen(A);
// int len = strlen(A);
// while(A[left]==A[right] && left>=0 && right<len ){
// res+=2;
// left-=1;right+=1;
// }
// return res;
// }
// int max(int a, int b)
// {
// return a > b ? a : b;
// }
// int getLongestPalindrome(char *A)
// {
// // write code here
// // dp方法:初始状态 - 装填转移 - 边界条件
// // 干活的函数: s , left , right
// // mid 开始,left -1 ,right+1 向两边拓展
// // 主函数,从0到右边,一个个找,
// // #self 从左边开始一个个找
// // int maxLen = 1;
// // int len = strlen(A);
// // if (len == 0)
// // {
// // return 0;
// // }
// // int r1, r2;
// // for (int i = 0; i < len; i++)
// // {
// // r1 = doFunc(A, i, i);
// // r2 = doFunc(A, i, i + 1);
// // int res = max(r1, r2);
// // if (res > maxLen)
// // {
// // maxLen = res;
// // }
// // }
// // return maxLen;
// int maxLen = 1;
// int len = strlen(A);
// for(int i =0;i<len;i++){
// int res1= doFunc(A,i,i);
// int res2 = doFunc(A,i,i+1);
// int res = max(res1,res2);
// if(res>maxLen) maxLen =res;
// }
// return maxLen;
}
// 3刷
/**
* 代码中的类名、方法名、参数名已经指定,请勿修改,直接返回方法规定的值即可
*
*
* @param A string字符串
* @return int整型
*/
int max(int a, int b)
{
return a > b ? a : b;
}
int doFunc(char *a, int len, int left, int right)
{
while (left >= 0 && right < len && a[left] == a[right])
{
left--;
right++;
}
return right - left - 1;
}
int getLongestPalindrome(char *A)
{
// write code here
if (!A)
return -1 + 1;
int len = strlen(A);
if (len == 1)
{
return 1;
}
int maxLen = 1;
for (int i = 0; i < len; i++)
{
int len1 = doFunc(A, len, i, i);
int len2 = doFunc(A, len, i, i + 1);
int tempM = max(len1, len2);
if (tempM > maxLen)
{
maxLen = tempM;
}
}
return maxLen;
}
6 最长上升子序列?
// #include <stdio.h>
// int main()
// {
// int arr[] = {47, 89, 23, 76, 12, 55, 34, 91, 62, 7, 38, 81, 44, 50, 99};
// printf("最长上升子序列是:%d\n\n", LTS(arr, 15));
// printf("最长上升子序列是\n");
// return 0;
// }
// int LTS(int *arr, int arrLen)
// {
// int dp[arrLen];
// if (arrLen == 0)
// {
// return 0;
// }
// for (int i = 0; i < arrLen; i++)
// {
// dp[i] = 1;
// }
// dp[0] = 1;
// int maxLen = 1;
// // for (int i = 1; i < arrLen; i++)
// // {
// // for (int j = 0; j < i; j++)
// // {
// // if (arr[i] > arr[j])
// // {
// // if (dp[j] + 1 > dp[i])
// // {
// // dp[i] = dp[j] + 1;
// // }
// // }
// // }
// // if (dp[i] > maxLen)
// // {
// // maxLen = dp[i];
// // }
// // }
// for(int i =1;i<arrLen;i++){
// for(int j = 0 ;j<i;j++){
// if(arr[i]>arr[j]){
// if(dp[j]+1>dp[i]){
// dp[i] = dp[j]+1;
// }
// }
// }
// if(dp[i]>maxLen ){
// maxLen = dp[i];
// }
// }
// return maxLen;
// }
# 2刷
/**
* 代码中的类名、方法名、参数名已经指定,请勿修改,直接返回方法规定的值即可
*
* 给定数组的最长严格上升子序列的长度。
* @param arr int整型一维数组 给定的数组
* @param arrLen int arr数组长度
* @return int整型
*/
int LIS(int *arr, int arrLen)
{
// write code here
if (!arr)
{
return 0;
}
int dp[arrLen];
for (int i = 0; i < arrLen; i++)
{
dp[i] = 1;
}
int maxLen = 1;
for (int i = 0; i < arrLen; i++)
{
for (int j = 0; j < i; j++)
{
if (arr[j] < arr[i])
{
dp[i] = max(dp[i], dp[j] + 1);
maxLen = max(maxLen, dp[i]);
}
else
{
// dp[i] = dp[j];
;
}
}
}
// return dp[arrLen - 1];
return maxLen;
}
dp[i] dp[j] 的动态规划:
0-》arrlen
0->i
如果大,
dp[i] = 挑一个大的
记录一下
7 字符串转化ip地址?
8 编辑距离?
9 打家劫舍(1)+(2)?
20257.20三刷!
/**
* 代码中的类名、方法名、参数名已经指定,请勿修改,直接返回方法规定的值即可
*
*
* @param nums int整型一维数组
* @param numsLen int nums数组长度
* @return int整型
*/
#define MaxV 15000
typedef struct Person
{
char name[];
int age;
} chinese;
#include <stdio.h>
int max(int a, int b)
{
return a > b ? a : b;
}
int rob(int *nums, int numsLen)
{
// write code here
// 打家劫舍问题:要么搞nums[i]+dp[i-2] ,要么dp[i-1]
// int dp[numsLen] ;
// dp[1] =max(nums[0],nums[1]);
// dp[0] = nums[0];
// for(int i=2;i<numsLen;i++){
// dp[i] = max(dp[i-1],nums[i]+dp[i-2]);
// }
// return dp[numsLen-1];
// 2刷
char* a[];
int b[100];
int* ptr_Int = b;
if (!nums || numsLen == 0)
{
return 0;
}
if (numsLen == 1)
{
return nums[0];
}
if (numsLen == 2)
{
return max(nums[0], nums[1]);
}
int dp[numsLen];
for (int i = 2; i < numsLen; i++)
{
dp[i] = 0;
}
for (int i = 2; i < numsLen; i++)
{
dp[i] = max(dp[i - 1], dp[i - 2] + nums[i]);
}
return dp[numsLen - 1];
}
// int main(){
// int a[] = {12, 9,6,6,7,7,4,5,3,7,3,3,7,5,23,6,2,36,26,4,23,6,2,36,24,36,3,68,8 ,12};
// int len = sizeof(a)/sizeof(a[0]);
// printf("结果是:%d \n",rob(a,len));
// }
环形:
int max(int a, int b)
{
return a > b ? a : b;
}
int rob(int *nums, int numsLen)
{
int dp1[numsLen];
int dp2[numsLen];
int m1 = 0;
int m2 = 0;
// 第一家偷,最后一个不能偷!
// 1st不,last one 可以!
dp1[0] = nums[0];
dp1[1] = dp1[0];
for (int i = 2; i < numsLen - 1; i++)
{
dp1[i] = max(dp1[i - 2] + nums[i], dp1[i - 1]);
}
m1 = dp1[numsLen - 2];
dp2[0] = 0;
dp2[1] = nums[1];
for (int i = 2; i < numsLen; i++)
{
dp2[i] = max(dp2[i - 2] + nums[i], dp2[i - 1]);
}
m2 = dp2[numsLen - 1];
return max(m1, m2);
}
以上是笔记本手写的思维分析+总结
---------------------------------------------------------------------------------------------------------最近更新于2025.7.21 晚上1:00
1 x做过了就会,你不看试试?
2 多回顾题目!
3 脑子过 反复
---------------------------------------------------------------------------------------------------------------------重制版>>>>>更新于2025.7.22晚上10:00
动态规划硬核之路:从最长公共子串到数字翻译(第一部分)
前言:DP,嵌入式面试的“拦路虎”还是“敲门砖”?
兄弟们,咱们搞嵌入式的,C语言是基本功,但算法,特别是动态规划,那绝对是面试大厂的“分水岭”。很多人觉得DP难,无从下手,但我告诉你,DP不是玄学,它有套路,有思想。一旦你掌握了它,它就会从“拦路虎”变成你进入大厂的“敲门砖”!
我大学四年学嵌入式,研究生搞了几年区块链,现在又回来刷C语言准备嵌入式工作,深知这种“半路出家”的痛苦。但正是这种经历,让我对知识的理解更深了一层:基础,永远是王道! 而DP,正是检验你基础功底和逻辑思维能力的绝佳利器。
这篇博客,我将结合我在牛客、力扣上刷题的真实代码,手把手带你剖析DP问题的核心,从最基本的概念讲起,深入浅出,力求让每一个看过100道热题榜的C程序员都能看懂,都能有所收获。
1 动态规划初探:核心思想与基本要素
在深入具体题目之前,咱们先来聊聊动态规划到底是个啥。
动态规划 (Dynamic Programming, DP),简单来说,就是把一个复杂的大问题,拆分成若干个相互关联的子问题,通过解决这些子问题,并把子问题的解存储起来(避免重复计算),最终得到大问题的解。
它的核心思想就俩字:“重叠子问题” 和 “最优子结构”。
-
重叠子问题 (Overlapping Subproblems):解决大问题时,会反复遇到相同的子问题。DP通过记忆化(Memoization)或填表(Tabulation)来存储子问题的解,避免重复计算,大大提高效率。
-
最优子结构 (Optimal Substructure):大问题的最优解可以通过子问题的最优解来构造。这意味着,如果你能找到子问题的最优解,那么把它们组合起来就能得到整个问题的最优解。
构建一个DP解决方案,通常需要明确以下几个要素:
-
DP 状态定义:
dp[i]
或dp[i][j]
代表什么?这是最关键的一步,定义好了,后面就水到渠成了。 -
DP 状态转移方程:
dp[i]
或dp[i][j]
如何从之前的状态推导出来?这是DP的核心逻辑。 -
初始化:DP 数组的初始值是多少?通常是边界条件,或者一些特殊情况。
-
边界条件:最小的子问题是什么?它们的解是什么?
-
计算顺序:是自底向上(填表法)还是自顶向下(记忆化搜索)?通常填表法更常见。
我们可以用一张思维导图来概括DP的核心要素:
DP核心要素 |
描述 |
常见表现形式 |
---|---|---|
状态定义 |
|
数组、二维数组 |
转移方程 |
如何从已知状态推导出当前状态? |
|
初始化 |
最小子问题的解,或数组的起始值。 |
|
边界条件 |
问题的最小规模,或特殊情况的处理。 |
|
计算顺序 |
遍历方向,确保计算当前状态时所需的前置状态已计算。 |
通常是嵌套循环,从小到大遍历 |
2 经典问题剖析:最长公共子串 (Longest Common Substring)
好了,理论说完了,咱们直接上硬菜!第一个要讲的,就是最长公共子串。用户在代码里把它写成了 LCS
,但从实现来看,它确实是最长公共子串,而不是最长公共子序列。这里咱们先明确一下概念:
-
最长公共子串 (Longest Common Substring):两个字符串中,最长的连续的公共部分。例如 "ABCDE" 和 "ABFCE",最长公共子串是 "AB"。
-
最长公共子序列 (Longest Common Subsequence):两个字符串中,最长的非连续的公共部分(保持相对顺序)。例如 "ABCDE" 和 "ABFCE",最长公共子序列是 "ABCE"。
这两个问题虽然名字相似,但DP状态转移方程有本质区别。咱们先聚焦最长公共子串。
问题描述
给定两个字符串 str1
和 str2
,找出它们的最长公共子串。
用户代码分析与思考
我们先来看看用户提供的LCS
函数(实际是最长公共子串)的代码:
/**
* 代码中的类名、方法名、参数名已经指定,请勿修改,直接返回方法规定的值即可
*
* longest common substring
* @param str1 string字符串 the string
* @param str2 string字符串 the string
* @return string字符串
*/
#include <stdio.h>
#include <string.h> // 补充头文件
#include <stdlib.h> // 补充头文件
char *LCS(char *str1, char *str2){
// write code here
// #3刷
int len1 = strlen(str1);
int len2 = strlen(str2);
// 动态分配二维数组,避免栈溢出,尤其是在大字符串时
int **dp = (int **)malloc((len1 + 1) * sizeof(int *));
for (int i = 0; i <= len1; i++)
{
// calloc 会自动将内存初始化为0,非常适合DP数组
dp[i] = (int *)calloc((len2 + 1), sizeof(int));
}
int maxLen = 0; // 记录最长公共子串的长度
int end = 0; // 记录最长公共子串在str1中的结束位置(索引)
for (int i = 1; i <= len1; i++)
{
for (int j = 1; j <= len2; j++)
{
// 如果当前字符匹配
if (str1[i - 1] == str2[j - 1])
{
// 状态转移:当前公共子串长度等于前一个字符匹配时的长度加1
dp[i][j] = dp[i - 1][j - 1] + 1;
// 更新最大长度和结束位置
if (dp[i][j] > maxLen)
{
maxLen = dp[i][j];
// #vip 容易错:end记录的是str1中子串的结束索引
// 因为i是基于1的索引,str1[i-1]是当前字符,所以i-1就是当前字符的索引
end = i - 1;
}
}
else
{
// 如果当前字符不匹配,则公共子串断开,长度重置为0
dp[i][j] = 0;
}
}
}
// 处理这个start ->end 的序列:
// 根据最长子串的长度和结束位置,计算起始位置
int start = end - maxLen + 1;
// 分配结果字符串内存,maxLen + 1 用于存放 '\0'
char *res = (char *)malloc((maxLen + 1) * sizeof(char));
if (res == NULL) { // 内存分配失败检查
// 释放之前分配的dp内存
for (int i = 0; i <= len1; i++) {
free(dp[i]);
}
free(dp);
return NULL; // 返回错误
}
// 复制子串到结果字符串
for (int i = 0; i < maxLen; i++)
{
res[i] = str1[start + i];
}
res[maxLen] = '\0'; // 添加字符串结束符
// 释放动态分配的dp内存,避免内存泄漏
for (int i = 0; i <= len1; i++)
{
free(dp[i]);
}
free(dp);
return res;
}
我的分析和思考:
用户这份代码已经是第三次刷了,进步非常明显!从注释的 #3刷于7/15
和 #vip 容易错
都能看出,用户在思考和解决问题过程中遇到的困惑和进步。
优点:
-
DP 状态定义清晰:
dp[i][j]
表示以str1[i-1]
和str2[j-1]
结尾的最长公共子串的长度。这个定义非常关键,因为它直接决定了状态转移方程。 -
状态转移方程正确:
if (str1[i-1] == str2[j-1]) { dp[i][j] = dp[i-1][j-1] + 1; } else { dp[i][j] = 0; }
完美体现了子串连续性的要求。 -
动态分配
dp
数组:用户从之前的固定大小数组int dp[5001][5001]
改为malloc
动态分配,这是一个巨大的进步!避免了栈溢出,使得代码能处理更长的字符串。 -
calloc
初始化:使用calloc
自动初始化为0,省去了手动清零的步骤,代码更简洁。 -
记录
maxLen
和end
:能够正确地记录最长公共子串的长度和其在str1
中的结束位置,这是构建结果字符串的关键。
可以优化和改进的地方:
-
头文件缺失:
strlen
,malloc
,free
都需要<string.h>
和<stdlib.h>
。虽然在某些IDE或编译器下可能隐式包含,但为了代码的健壮性和可移植性,必须显式包含。 -
malloc
大小:在char* res = (char* )malloc(sizeof(char)*3000);
或char* res = (char*)malloc(5001*sizeof(char));
处,虽然用户在第三次刷的时候改成了5000 * sizeof(char)
,但更精确和安全的做法是根据maxLen
来分配,即(maxLen + 1) * sizeof(char)
,因为maxLen
可能远小于5000,造成内存浪费;也可能在极端情况下maxLen
超过5000,导致内存不足。 -
内存泄漏:在返回
res
之前,需要free
掉之前malloc
出来的dp
数组。用户在第三次刷的时候已经考虑到了这一点,并在返回前释放了dp
数组,非常棒! -
边界条件处理:虽然代码中
if(len1==0 || len2 ==0 )
的注释部分被删除了,但对于空字符串的输入,最好在函数开头进行显式检查,避免后续操作出现空指针解引用。
深入浅出:最长公共子串的DP原理与实现
咱们来彻底捋一遍最长公共子串的DP思路。
1. DP 状态定义:
dp[i][j]
表示以 str1[i-1]
结尾且以 str2[j-1]
结尾的最长公共子串的长度。 注意这里的 i
和 j
是为了方便DP数组的索引,它们分别对应 str1
和 str2
的前 i
个和前 j
个字符。所以实际字符索引是 i-1
和 j-1
。
2. DP 状态转移方程:
-
如果
str1[i-1] == str2[j-1]
(当前字符匹配):dp[i][j] = dp[i-1][j-1] + 1
这意味着,如果当前字符匹配,那么最长公共子串的长度就是它们前一个字符匹配时的长度加1。 -
如果
str1[i-1] != str2[j-1]
(当前字符不匹配):dp[i][j] = 0
因为是“子串”,要求连续,一旦不匹配,公共子串就断了,长度自然归零。
3. 初始化:
-
dp[0][j] = 0
(当str1
为空时,没有公共子串) -
dp[i][0] = 0
(当str2
为空时,没有公共子串) -
由于我们使用
calloc
动态分配并初始化为0,这些边界条件自然满足。
4. 计算顺序:
从 i = 1
到 len1
,从 j = 1
到 len2
,双重循环遍历。
DP 状态表(以 str1 = "ABCDE"
, str2 = "ABFCE"
为例):
"" |
A=0 |
B=1 |
F=2 |
C=3 |
E=4 | |
---|---|---|---|---|---|---|
"" |
0 |
0 |
0 |
0 |
0 |
0 |
A=0 |
0 |
1 |
0 |
0 |
0 |
0 |
B=1 |
0 |
0 |
2 |
0 |
0 |
0 |
C=2 |
0 |
0 |
0 |
0 |
1 |
0 |
D=3 |
0 |
0 |
0 |
0 |
0 |
0 |
E=4 |
0 |
0 |
0 |
0 |
0 |
1 |
解释:
-
dp[2][2]
(对应str1[1]='B'
,str2[1]='B'
):str1[1]=='B'
且str2[1]=='B'
,所以dp[2][2] = dp[1][1] + 1 = 1 + 1 = 2
。此时maxLen = 2
,end = 1
。 -
dp[3][4]
(对应str1[2]='C'
,str2[3]='C'
):str1[2]=='C'
且str2[3]=='C'
,所以dp[3][4] = dp[2][3] + 1 = 0 + 1 = 1
。
最终,maxLen
会记录最大值,end
会记录这个最大值对应的 str1
结束索引。
优化与最终代码 (C语言)
结合上述分析,我将用户的代码进行优化,并添加更详细的注释,使其更符合生产级代码的要求,也更便于理解。
#include <stdio.h> // 标准输入输出,用于printf等
#include <string.h> // 字符串操作函数,用于strlen
#include <stdlib.h> // 内存分配函数,用于malloc, free
/**
* @brief 查找两个字符串的最长公共子串
*
* 本函数使用动态规划(Dynamic Programming, DP)的方法来解决最长公共子串问题。
* DP数组 dp[i][j] 存储以 str1[i-1] 结尾且以 str2[j-1] 结尾的最长公共子串的长度。
*
* @param str1 第一个输入字符串
* @param str2 第二个输入字符串
* @return char* 指向最长公共子串的指针。如果无公共子串或内存分配失败,返回NULL。
* 调用者负责释放返回的内存。
*/
char *findLongestCommonSubstring(char *str1, char *str2) {
// 1. 输入校验:处理空字符串或空指针的情况
if (str1 == NULL || str2 == NULL) {
return NULL;
}
int len1 = strlen(str1);
int len2 = strlen(str2);
if (len1 == 0 || len2 == 0) {
// 如果任一字符串为空,则没有公共子串,返回空字符串
char *res = (char *)malloc(sizeof(char)); // 分配1字节用于存储'\0'
if (res == NULL) {
return NULL; // 内存分配失败
}
res[0] = '\0';
return res;
}
// 2. DP 数组定义与初始化
// dp[i][j] 表示以 str1[i-1] 和 str2[j-1] 结尾的最长公共子串的长度
// 数组大小为 (len1 + 1) x (len2 + 1),因为我们需要处理空前缀的情况 (i=0 或 j=0)
int **dp = (int **)malloc((len1 + 1) * sizeof(int *));
if (dp == NULL) {
return NULL; // 内存分配失败
}
for (int i = 0; i <= len1; i++) {
// calloc 会自动将分配的内存初始化为零,这对于DP数组的初始化非常方便
// dp[i][0] 和 dp[0][j] 自然为0,符合边界条件
dp[i] = (int *)calloc((len2 + 1), sizeof(int));
if (dp[i] == NULL) {
// 如果某一行内存分配失败,需要释放之前已分配的内存
for (int k = 0; k < i; k++) {
free(dp[k]);
}
free(dp);
return NULL;
}
}
// 3. 遍历填充 DP 数组
int maxLen = 0; // 记录迄今为止发现的最长公共子串的长度
int endIdx1 = 0; // 记录最长公共子串在 str1 中的结束索引(实际字符索引)
for (int i = 1; i <= len1; i++) {
for (int j = 1; j <= len2; j++) {
// 比较当前字符 str1[i-1] 和 str2[j-1]
if (str1[i - 1] == str2[j - 1]) {
// 如果字符匹配,则当前公共子串的长度是前一个字符匹配时的长度加1
dp[i][j] = dp[i - 1][j - 1] + 1;
// 检查是否找到了更长的公共子串
if (dp[i][j] > maxLen) {
maxLen = dp[i][j];
// 更新最长公共子串在 str1 中的结束索引
// i-1 是当前匹配字符在 str1 中的实际索引
endIdx1 = i - 1;
}
} else {
// 如果字符不匹配,则公共子串断开,长度重置为0
// 这是“子串”与“子序列”的关键区别:子串要求连续
dp[i][j] = 0;
}
}
}
// 4. 构造结果字符串
char *resultSubstring;
if (maxLen == 0) {
// 如果没有找到任何公共子串(maxLen为0),返回一个空字符串
resultSubstring = (char *)malloc(sizeof(char));
if (resultSubstring == NULL) {
// 内存分配失败,需要释放dp数组
for (int i = 0; i <= len1; i++) {
free(dp[i]);
}
free(dp);
return NULL;
}
resultSubstring[0] = '\0';
} else {
// 根据 maxLen 和 endIdx1 计算子串的起始索引
int startIdx1 = endIdx1 - maxLen + 1;
// 分配内存给结果字符串,需要 maxLen + 1 字节来存储字符串结束符 '\0'
resultSubstring = (char *)malloc((maxLen + 1) * sizeof(char));
if (resultSubstring == NULL) {
// 内存分配失败,需要释放dp数组
for (int i = 0; i <= len1; i++) {
free(dp[i]);
}
free(dp);
return NULL;
}
// 从 str1 中复制最长公共子串到结果字符串
strncpy(resultSubstring, str1 + startIdx1, maxLen);
resultSubstring[maxLen] = '\0'; // 确保字符串以空字符结尾
}
// 5. 释放 DP 数组内存,避免内存泄漏
for (int i = 0; i <= len1; i++) {
free(dp[i]);
}
free(dp);
return resultSubstring;
}
/*
// 示例用法 (可以放在main函数中测试)
int main() {
char *s1 = "ABCDE";
char *s2 = "ABFCE";
char *lcs = findLongestCommonSubstring(s1, s2);
if (lcs != NULL) {
printf("最长公共子串: \"%s\"\n", lcs); // 预期输出: "AB"
free(lcs); // 释放结果字符串内存
} else {
printf("未找到公共子串或内存分配失败。\n");
}
s1 = "HelloWorld";
s2 = "LowWorld";
lcs = findLongestCommonSubstring(s1, s2);
if (lcs != NULL) {
printf("最长公共子串: \"%s\"\n", lcs); // 预期输出: "World"
free(lcs);
} else {
printf("未找到公共子串或内存分配失败。\n");
}
s1 = "abc";
s2 = "def";
lcs = findLongestCommonSubstring(s1, s2);
if (lcs != NULL) {
printf("最长公共子串: \"%s\"\n", lcs); // 预期输出: ""
free(lcs);
} else {
printf("未找到公共子串或内存分配失败。\n");
}
s1 = "";
s2 = "test";
lcs = findLongestCommonSubstring(s1, s2);
if (lcs != NULL) {
printf("最长公共子串: \"%s\"\n", lcs); // 预期输出: ""
free(lcs);
} else {
printf("未找到公共子串或内存分配失败。\n");
}
return 0;
}
*/
代码改进总结:
改进点 |
原因/目的 |
效果 |
---|---|---|
头文件补全 |
确保代码的完整性、健壮性和可移植性。 |
避免编译警告和潜在的运行时错误。 |
空字符串/空指针处理 |
提高代码鲁棒性,避免非法输入导致程序崩溃。 |
函数更安全,能够优雅地处理边缘情况。 |
|
根据 |
内存使用更高效、更安全。 |
内存泄漏处理 |
确保所有 |
避免长时间运行的程序内存占用不断增长,尤其在嵌入式系统资源有限的情况下至关重要。 |
详细注释 |
解释每一步的逻辑、变量含义和设计思路。 |
提高代码可读性、可维护性,便于他人理解和自己回顾。 |
错误处理 |
对 |
使函数在内存不足时能给出明确的错误指示,而不是直接崩溃。 |
3 经典问题剖析:数字翻译成字符串 (Decode Ways)
接下来,咱们看看第二个经典DP问题——“数字翻译成字符串”,也就是力扣上的“解码方法”。用户也提供了代码,咱们继续分析。
问题描述
一条包含字母 A-Z
的消息通过以下方式进行了编码:
'A' -> 1
'B' -> 2
...
'Z' -> 26
给定一个只包含数字的非空字符串 nums
,请计算解码方法的总数。
用户代码分析与思考
/**
* 代码中的类名、方法名、参数名已经指定,请勿修改,直接返回方法规定的值即可
*
* 解码
* @param nums string字符串 数字串
* @return int整型
*/
#include <string.h> // 补充头文件
int solve(char *nums){
// write code here
// int len = strlen(nums);
// #1刷
// 两种情况:
// 单独译码:i!=0,dp[i] =dp[i]+dp[i-1];
// 合并译码:<i-1,i> <- (10,26) => dp[i] = dp[i-2]+dp[i];
int dp[100] = {0}; // 固定大小数组,可能存在栈溢出或不够大的问题
dp[0] = 1; // dp[0] 表示空字符串有一种解码方式(作为基准)
// dp[1] 的处理:
if (nums[0] == '0')
{
dp[1] = 0; // 如果第一个字符是'0',无法解码
}
else
{
dp[1] = 1; // 如果第一个字符不是'0',有一种解码方式
}
int len = strlen(nums);
for (int i = 2; i <= len; i++)
{
dp[i] = 0; // 初始化当前dp[i]为0
// 情况1:单独解码 nums[i-1] (当前字符)
if (nums[i - 1] != '0')
{
dp[i] += dp[i - 1]; // 如果当前字符不是'0',则可以单独解码,加上dp[i-1]的解码方法数
}
// 情况2:合并解码 nums[i-2] 和 nums[i-1] (前两个字符)
int temp = 0;
temp = 10 * (nums[i - 2] - '0') + (nums[i - 1] - '0'); // 组合成两位数
if (temp >= 10 && temp <= 26) // 如果组合的两位数在10到26之间
{
dp[i] += dp[i - 2]; // 则可以合并解码,加上dp[i-2]的解码方法数
}
}
return dp[len]; // 返回整个字符串的解码方法数
}
我的分析和思考:
用户这份代码对“数字翻译成字符串”问题的理解非常到位,核心逻辑完全正确!
优点:
-
DP 状态定义正确:
dp[i]
表示字符串nums
的前i
个字符的解码方法总数。 -
状态转移方程清晰:
-
nums[i-1]
单独解码:如果nums[i-1]
不是'0'
,则dp[i]
加上dp[i-1]
。 -
nums[i-2]
和nums[i-1]
合并解码:如果nums[i-2]nums[i-1]
组成的两位数在10
到26
之间,则dp[i]
加上dp[i-2]
。 -
这两种情况考虑得非常全面和准确。
-
-
初始化
dp[0]=1
和dp[1]
的处理:dp[0]=1
是一个巧妙的基准,表示空字符串有一种解码方式(为了方便后续dp[i-2]
的计算)。dp[1]
根据nums[0]
是否为'0'
进行初始化,非常正确。
可以优化和改进的地方:
-
dp
数组大小:int dp[100]
是一个固定大小的数组。如果输入的nums
字符串长度超过99,就会发生越界。更好的做法是根据strlen(nums)
动态分配内存,或者使用变长数组(C99标准支持,但并非所有编译器都默认开启)。对于嵌入式开发,通常会避免动态内存分配,但如果字符串长度可变且较大,则需要考虑。对于本题,如果字符串长度限制在100以内,固定数组是可行的。 -
头文件缺失:
strlen
需要<string.h>
。
深入浅出:数字翻译成字符串的DP原理与实现
咱们来彻底捋一遍数字翻译成字符串的DP思路。
1. DP 状态定义:
dp[i]
表示字符串 nums
的前 i
个字符(即 nums[0...i-1]
)的解码方法总数。
2. DP 状态转移方程:
考虑 dp[i]
,它取决于 dp[i-1]
和 dp[i-2]
:
-
单独解码
nums[i-1]
: 如果nums[i-1]
这个字符不为'0'
(因为'0'
无法单独解码),那么它可以单独作为一个数字解码。此时,解码方法数等于dp[i-1]
。dp[i] += dp[i-1]
(当nums[i-1] != '0'
) -
合并解码
nums[i-2]
和nums[i-1]
: 考虑nums[i-2]
和nums[i-1]
组成的两位数val = (nums[i-2] - '0') * 10 + (nums[i-1] - '0')
。 如果val
在10
到26
之间(即10 <= val <= 26
),那么这两个字符可以作为一个整体进行解码。此时,解码方法数等于dp[i-2]
。dp[i] += dp[i-2]
(当10 <= val <= 26
)
3. 初始化:
-
dp[0] = 1
:表示空字符串有一种解码方式。这听起来有点反直觉,但它是为了方便计算dp[2]
时,dp[0]
作为dp[i-2]
的基准值。例如,对于 "12",dp[2]
需要用到dp[0]
。 -
dp[1]
:-
如果
nums[0] == '0'
,则dp[1] = 0
(第一个字符是0,无法解码)。 -
如果
nums[0] != '0'
,则dp[1] = 1
(第一个字符可以单独解码)。
-
4. 边界条件:
-
字符串以
'0'
开头:如果nums[0] == '0'
,则无法解码,直接返回0。 -
'0'
的处理:如果'0'
前面没有1
或2
,例如 "30",那么'0'
无法单独解码,也无法与前面的3
组成有效数字(30 > 26),因此解码方法数为0。
DP 状态表(以 nums = "123"
为例):
索引 |
字符 |
两位数 |
|
|
|
|
---|---|---|---|---|---|---|
0 |
(空) |
(空) |
- |
- |
(初始化) |
1 |
1 |
'1' |
- |
|
- |
|
1 |
2 |
'2' |
"12" |
|
|
|
2 |
3 |
'3' |
"23" |
|
|
|
3 |
解释:
-
"1":一种解码方式 ("A")
-
"12":两种解码方式 ("AB", "L")
-
"123":三种解码方式 ("ABC", "LC", "AW")
优化与最终代码 (C语言)
对用户代码进行少量优化,主要集中在注释和对固定数组大小的说明。
#include <stdio.h> // 标准输入输出,用于printf等
#include <string.h> // 字符串操作函数,用于strlen
/**
* @brief 计算数字字符串的解码方法总数
*
* 本函数使用动态规划(Dynamic Programming, DP)来解决数字字符串解码问题。
* dp[i] 表示字符串 nums 的前 i 个字符 (nums[0...i-1]) 的解码方法总数。
*
* @param nums 只包含数字的非空字符串
* @return int 解码方法的总数。如果无法解码(如"0"开头),返回0。
*/
int decodeWays(char *nums) {
// 1. 输入校验:处理空指针或空字符串
if (nums == NULL || strlen(nums) == 0) {
return 0; // 空字符串没有解码方法,或者题目要求非空,这里返回0
}
int len = strlen(nums);
// 2. 特殊情况处理:如果字符串以 '0' 开头,则无法解码
if (nums[0] == '0') {
return 0;
}
// 3. DP 数组定义与初始化
// dp[i] 存储 nums 的前 i 个字符的解码方法数
// 数组大小 len + 1,dp[0] 用于基准情况,dp[len] 存储最终结果
// 注意:这里使用固定大小数组101,如果输入字符串长度可能超过100,
// 建议使用动态内存分配 int *dp = (int *)malloc((len + 1) * sizeof(int));
// 或根据实际情况调整数组大小。
int dp[101]; // 假设字符串最大长度为100
// 初始化 dp[0] 为 1,表示空字符串有一种解码方式(作为递归的基准)
dp[0] = 1;
// 初始化 dp[1]
// 如果 nums[0] 是 '0',则 dp[1] 为 0 (已在函数开头处理)
// 否则,dp[1] 为 1 (第一个字符可以单独解码)
dp[1] = 1;
// 4. 遍历填充 DP 数组
for (int i = 2; i <= len; i++) {
dp[i] = 0; // 默认当前 dp[i] 为 0
// 情况1:单独解码 nums[i-1] (当前字符)
// 如果当前字符不是 '0',则可以单独解码
if (nums[i - 1] != '0') {
dp[i] += dp[i - 1];
}
// 情况2:合并解码 nums[i-2] 和 nums[i-1] (前两个字符)
// 提取前两个字符组成的数字
int two_digit_num = (nums[i - 2] - '0') * 10 + (nums[i - 1] - '0');
// 如果这个两位数在 10 到 26 之间,则可以合并解码
if (two_digit_num >= 10 && two_digit_num <= 26) {
dp[i] += dp[i - 2];
}
}
// 5. 返回最终结果
return dp[len];
}
/*
// 示例用法 (可以放在main函数中测试)
int main() {
char *s1 = "12";
printf("'%s' 的解码方法数: %d\n", s1, decodeWays(s1)); // 预期输出: 2 ("AB", "L")
char *s2 = "226";
printf("'%s' 的解码方法数: %d\n", s2, decodeWays(s2)); // 预期输出: 3 ("BBF", "VF", "BZ")
char *s3 = "0";
printf("'%s' 的解码方法数: %d\n", s3, decodeWays(s3)); // 预期输出: 0
char *s4 = "06";
printf("'%s' 的解码方法数: %d\n", s4, decodeWays(s4)); // 预期输出: 0
char *s5 = "10";
printf("'%s' 的解码方法数: %d\n", s5, decodeWays(s5)); // 预期输出: 1 ("J")
char *s6 = "30";
printf("'%s' 的解码方法数: %d\n", s6, decodeWays(s6)); // 预期输出: 0
char *s7 = "1";
printf("'%s' 的解码方法数: %d\n", s7, decodeWays(s7)); // 预期输出: 1
char *s8 = "";
printf("'%s' 的解码方法数: %d\n", s8, decodeWays(s8)); // 预期输出: 0
return 0;
}
*/
代码改进总结:
改进点 |
原因/目的 |
效果 |
---|---|---|
头文件补全 |
确保代码的完整性。 |
避免编译警告。 |
空字符串/空指针处理 |
提高代码鲁棒性。 |
确保函数在接收无效输入时能正确处理。 |
|
字符串以 |
代码更清晰,避免不必要的循环。 |
|
明确指出了固定大小数组的限制,并建议了动态分配的替代方案。 |
提醒读者注意潜在的越界问题,并提供解决方案,这在嵌入式开发中尤其重要(资源限制)。 |
详细注释 |
解释了DP状态定义、转移逻辑、初始化等关键点。 |
提高代码可读性,方便理解和学习。 |
总结与展望
第一部分,咱们深入探讨了动态规划的核心思想,并通过“最长公共子串”和“数字翻译成字符串”这两个经典问题,详细分析了DP状态定义、转移方程、初始化和边界条件。从用户的代码中,我们看到了学习和进步的轨迹,也一起对代码进行了优化,使其更健壮、更易读。
记住,DP的精髓在于找到正确的状态定义和状态转移方程。多画图、多举例、多思考,你一定能攻克它!
在下一部分,我们将继续挑战更多DP难题,比如“兑换零钱”和“最长回文子串”,它们将带你领略DP在不同场景下的应用。敬请期待!
动态规划硬核之路:从兑换零钱到最长回文(第二部分)
前言:DP,不止是理论,更是实战!
上一篇咱们聊了DP的基本概念和两个字符串问题,相信大家对DP已经有了初步的认识。但DP这东西,光看理论是没用的,必须得撸起袖子,一行一行地敲代码,一次一次地调试,才能真正把它吃透。
今天,咱们继续实战,挑战两个在面试中出现频率极高的DP问题:“兑换零钱”和“最长回文子串”。这两个问题虽然看似简单,但背后蕴含的DP思想却非常值得我们反复琢磨。
4 经典问题剖析:兑换零钱 (Coin Change)
“兑换零钱”问题,也叫“硬币找零”,是背包问题的一个变种,也是DP入门的经典题目。它考察的是你如何用最少的硬币凑出目标金额。
问题描述
给定不同面额的硬币 arr
(数组),和一个总金额 aim
。编写一个函数来计算可以凑成总金额所需的最少的硬币个数。如果没有任何一种硬币组合能组成总金额,返回 -1。
用户代码分析与思考
我们先来看看用户提供的 minMoney
函数:
/**
* 代码中的类名、方法名、参数名已经指定,请勿修改,直接返回方法规定的值即可
*
* 最少货币数
* @param arr int整型一维数组 the array
* @param arrLen int arr数组长度
* @param aim int整型 the target
* @return int整型
*/
#include <stdio.h> // 补充头文件
#include <limits.h> // 补充头文件,用于INT_MAX
#include <stdlib.h> // 补充头文件,用于malloc (如果动态分配dp数组)
int min(int a, int b){
return a < b ? a : b;
}
int minMoney(int *arr, int arrLen, int aim){
// 1. 输入校验:处理空数组或目标金额为负的情况
if (arr == NULL || arrLen == 0 || aim < 0) {
return -1; // 无效输入
}
if (aim == 0) {
return 0; // 目标金额为0,不需要任何硬币
}
// 2. DP 数组定义与初始化
// dp[i] 表示凑成金额 i 所需的最少硬币数
// 我们需要一个比任何可能硬币数都大的值来表示“不可达”状态
// aim + 1 是一个很好的选择,因为它肯定大于凑成 aim 所需的最大硬币数 (即全部用1元硬币)
int dp[aim + 1]; // 固定大小数组,如果aim很大,可能栈溢出,建议动态分配或堆分配
dp[0] = 0; // 凑成金额0需要0个硬币
// 将所有其他金额的初始值设为“不可达”
for (int i = 1; i <= aim; i++) {
dp[i] = aim + 1; // 初始化为“无穷大”
}
// 3. 遍历填充 DP 数组
// 外层循环遍历硬币面额:每个硬币都可能被用来凑成目标金额
for (int i = 0; i < arrLen; i++) {
int currentCoin = arr[i]; // 当前考虑的硬币面额
// 内层循环遍历目标金额:从当前硬币面额开始,到目标金额aim
// 为什么从 currentCoin 开始?因为凑成小于 currentCoin 的金额用不到这个硬币
for (int j = currentCoin; j <= aim; j++) {
// 状态转移方程:
// dp[j] 可以由两种方式得到:
// a) 不使用当前硬币 currentCoin,那么 dp[j] 保持原值
// b) 使用当前硬币 currentCoin,那么 dp[j] = dp[j - currentCoin] + 1
// 前提是 dp[j - currentCoin] 必须是可达的 (即不等于 aim + 1)
if (dp[j - currentCoin] != aim + 1) {
dp[j] = min(dp[j], dp[j - currentCoin] + 1);
}
}
}
// 4. 返回最终结果
// 如果 dp[aim] 仍然是 aim + 1,说明目标金额不可达
if (dp[aim] == aim + 1) {
return -1;
} else {
return dp[aim];
}
}
我的分析和思考:
用户这份代码是“兑换零钱”问题的标准解法之一,而且是经过“2刷”后的版本,逻辑非常清晰和正确!
优点:
-
DP 状态定义正确:
dp[i]
表示凑成金额i
所需的最少硬币数。 -
状态转移方程正确:
dp[j] = min(dp[j], dp[j - currentCoin] + 1)
完美体现了选择当前硬币或不选择当前硬币的决策。 -
初始化巧妙:
dp[0] = 0
和dp[i] = aim + 1
(表示“无穷大”或“不可达”) 是解决这类问题的经典技巧。aim + 1
作为一个不可能达到的硬币数量,非常适合作为“无穷大”的替代。 -
循环顺序正确:外层遍历硬币,内层遍历金额,这种顺序保证了每个金额
j
在计算时,都考虑了所有可用的硬币面额。 -
可达性判断:
if (dp[j - tempCoin] != aim + 1)
确保了只有当j - tempCoin
这个子金额是可达的时候,才进行状态转移,避免了无效的计算。
可以优化和改进的地方:
-
头文件缺失:
min
函数通常不需要额外头文件,但INT_MAX
需要<limits.h>
。虽然aim + 1
足够作为“无穷大”,但使用INT_MAX
会更通用。 -
数组大小:
int dp[aim + 1]
是一个栈上的固定大小数组。如果aim
非常大(例如几万、几十万),可能会导致栈溢出。在嵌入式环境中,栈空间通常有限,这种情况下应该考虑使用堆内存malloc
来动态分配dp
数组,并在函数结束时free
掉。 -
输入校验:对于
arr
为NULL
或arrLen
为0
的情况,以及aim
为负数的情况,可以提前返回-1
。
深入浅出:兑换零钱的DP原理与实现
咱们来彻底捋一遍兑换零钱的DP思路。
1. DP 状态定义:
dp[i]
:表示凑成金额 i
所需的最少硬币数。
2. DP 状态转移方程:
对于每一个目标金额 j
,我们遍历所有可用的硬币面额 coin
。如果 j >= coin
,那么我们就可以考虑使用这个 coin
来凑成 j
。 此时,dp[j]
的值应该更新为: dp[j] = min(dp[j], dp[j - coin] + 1)
这个方程的含义是:凑成金额 j
的最少硬币数,要么是之前已经计算出来的 dp[j]
(即不使用当前 coin
),要么是凑成 j - coin
的最少硬币数再加上当前这一个 coin
。我们取这两种情况的最小值。
3. 初始化:
-
dp[0] = 0
:凑成金额 0 需要 0 个硬币。这是我们DP的基准。 -
dp[i] = aim + 1
(对于i > 0
):将所有其他金额的初始值设置为一个“不可能达到”的值,例如aim + 1
。这样,如果某个金额最终仍是这个值,就说明它无法被硬币组合凑成。
4. 计算顺序:
外层循环遍历硬币面额 coin
,内层循环遍历目标金额 j
(从 coin
到 aim
)。这种顺序确保了在计算 dp[j]
时,dp[j - coin]
已经被正确计算过,并且考虑了所有 coin
的组合。
DP 状态表(以 arr = {1, 2, 5}
, aim = 11
为例):
假设 aim + 1 = 12
为“不可达”值。
金额 |
|
|
|
|
最终 |
---|---|---|---|---|---|
0 |
0 |
0 |
0 |
0 |
0 |
1 |
12 |
|
1 |
1 |
1 |
2 |
12 |
|
|
1 |
1 |
3 |
12 |
|
|
2 |
2 |
4 |
12 |
|
|
2 |
2 |
5 |
12 |
|
|
|
1 |
6 |
12 |
|
|
|
2 |
7 |
12 |
|
|
|
2 |
8 |
12 |
|
|
|
3 |
9 |
12 |
|
|
|
3 |
10 |
12 |
|
|
|
2 |
11 |
12 |
|
|
|
3 |
最终 dp[11]
为 3,表示凑成 11 最少需要 3 个硬币 (例如 5 + 5 + 1)。
优化与最终代码 (C语言)
结合上述分析,我将用户的代码进行优化,并添加更详细的注释,使其更符合生产级代码的要求,也更便于理解。
#include <stdio.h> // 标准输入输出,用于printf等
#include <string.h> // 字符串操作函数,用于strlen (虽然这里没用,但习惯性引入)
#include <stdlib.h> // 内存分配函数,用于malloc, free
#include <limits.h> // 用于 INT_MAX,表示“无穷大”
/**
* @brief 辅助函数:返回两个整数中的较小值
* @param a 整数1
* @param b 整数2
* @return int 较小值
*/
int getMin(int a, int b) {
return a < b ? a : b;
}
/**
* @brief 计算凑成目标金额所需的最少硬币数
*
* 本函数使用动态规划(Dynamic Programming, DP)来解决硬币找零问题。
* dp[i] 存储凑成金额 i 所需的最少硬币数。
*
* @param arr 硬币面额数组
* @param arrLen 数组长度
* @param aim 目标金额
* @return int 最少硬币数。如果无法凑成,返回 -1。
*/
int minMoneyOptimized(int *arr, int arrLen, int aim) {
// 1. 输入校验:处理空数组、无效长度或目标金额为负的情况
if (arr == NULL || arrLen <= 0 || aim < 0) {
return -1; // 无效输入
}
if (aim == 0) {
return 0; // 目标金额为0,不需要任何硬币
}
// 2. DP 数组定义与初始化
// dp[i] 表示凑成金额 i 所需的最少硬币数
// 使用动态内存分配,避免栈溢出问题,尤其在 aim 较大时
int *dp = (int *)malloc((aim + 1) * sizeof(int));
if (dp == NULL) {
return -1; // 内存分配失败
}
// 初始化 dp[0] 为 0,表示凑成金额 0 需要 0 个硬币
dp[0] = 0;
// 将所有其他金额的初始值设为“不可达” (一个比任何可能硬币数都大的值)
// INT_MAX 是一个很好的选择,因为它代表了整数的最大值,确保了任何有效硬币数都会比它小
for (int i = 1; i <= aim; i++) {
dp[i] = INT_MAX; // 初始化为“无穷大”
}
// 3. 遍历填充 DP 数组
// 外层循环遍历所有可用的硬币面额
for (int i = 0; i < arrLen; i++) {
int currentCoin = arr[i]; // 当前正在考虑的硬币面额
// 内层循环遍历所有可能的金额,从当前硬币面额开始
// 这样可以确保每个金额 j 都考虑了使用 currentCoin 的情况
// 并且 dp[j - currentCoin] 在计算 dp[j] 时已经被计算过了
for (int j = currentCoin; j <= aim; j++) {
// 只有当 dp[j - currentCoin] 是一个可达的值 (不是 INT_MAX) 时,
// 才能通过加上 currentCoin 来更新 dp[j]
if (dp[j - currentCoin] != INT_MAX) {
// 状态转移方程:
// dp[j] = min(不使用 currentCoin 的情况, 使用 currentCoin 的情况)
// 不使用 currentCoin 的情况就是 dp[j] 的当前值
// 使用 currentCoin 的情况就是 dp[j - currentCoin] + 1
dp[j] = getMin(dp[j], dp[j - currentCoin] + 1);
}
}
}
// 4. 返回最终结果
// 如果 dp[aim] 仍然是 INT_MAX,说明目标金额无法被给定硬币凑成
if (dp[aim] == INT_MAX) {
free(dp); // 释放内存
return -1;
} else {
int result = dp[aim];
free(dp); // 释放内存
return result;
}
}
/*
// 示例用法 (可以放在main函数中测试)
int main() {
int coins1[] = {1, 2, 5};
int len1 = sizeof(coins1) / sizeof(coins1[0]);
int aim1 = 11;
printf("金额 %d, 硬币 {%d, %d, %d} 的最少硬币数: %d\n", aim1, coins1[0], coins1[1], coins1[2], minMoneyOptimized(coins1, len1, aim1)); // 预期输出: 3
int coins2[] = {2};
int len2 = sizeof(coins2) / sizeof(coins2[0]);
int aim2 = 3;
printf("金额 %d, 硬币 {%d} 的最少硬币数: %d\n", aim2, coins2[0], minMoneyOptimized(coins2, len2, aim2)); // 预期输出: -1
int coins3[] = {1};
int len3 = sizeof(coins3) / sizeof(coins3[0]);
int aim3 = 0;
printf("金额 %d, 硬币 {%d} 的最少硬币数: %d\n", aim3, coins3[0], minMoneyOptimized(coins3, len3, aim3)); // 预期输出: 0
int coins4[] = {186, 419, 83, 408};
int len4 = sizeof(coins4) / sizeof(coins4[0]);
int aim4 = 6249;
printf("金额 %d, 硬币 {..., %d} 的最少硬币数: %d\n", aim4, coins4[len4-1], minMoneyOptimized(coins4, len4, aim4)); // 预期输出: 20
int coins5[] = {5, 2, 3};
int len5 = sizeof(coins5) / sizeof(coins5[0]);
int aim5 = 20;
printf("金额 %d, 硬币 {%d, %d, %d} 的最少硬币数: %d\n", aim5, coins5[0], coins5[1], coins5[2], minMoneyOptimized(coins5, len5, aim5)); // 预期输出: 4 (5+5+5+5 或 5+5+2+2+2+2+2)
return 0;
}
*/
代码改进总结:
改进点 |
原因/目的 |
效果 |
---|---|---|
头文件补全 |
确保代码的完整性、健壮性。 |
避免编译警告和潜在的运行时错误。 |
|
解决 |
提高代码的鲁棒性和可伸缩性,能够处理更大规模的问题。 |
内存泄漏处理 |
确保 |
避免内存泄漏,这在嵌入式系统资源有限且需要长时间运行的场景下尤其重要。 |
|
使用标准库提供的最大整数值,更通用、更清晰地表示“不可达”状态。 |
提高代码的可读性和通用性。 |
详细注释 |
解释每一步的逻辑、变量含义和设计思路,特别是DP状态转移的推导过程。 |
极大地提高了代码的可读性、可维护性,方便他人理解和自己回顾。 |
输入校验 |
对 |
提高代码的健壮性,防止非法输入导致程序崩溃。 |
5 经典问题剖析:最长回文子串 (Longest Palindromic Substring)
“最长回文子串”是另一个非常经典的字符串问题。回文串是指正读反读都一样的字符串,比如“aba”、“level”。这个问题有两种常见的DP解法,用户采用的是“中心扩展法”,这是一种非常高效且直观的方法。
问题描述
给定一个字符串 A
,找出它的最长回文子串的长度。
用户代码分析与思考
我们先来看看用户提供的 getLongestPalindrome
函数:
#include <stdio.h>
#include <string.h> // 补充头文件
// #include <math.h> // 这个头文件在这里是不需要的,可以移除
// 辅助函数:从中心向两边扩展,计算回文串的长度
// @param a 原始字符串
// @param len 字符串长度
// @param left 中心左侧或中心点
// @param right 中心右侧或中心点
// @return int 回文子串的长度
int doFunc(char *a, int len, int left, int right){
// 当 left >= 0 且 right < len 且 a[left] == a[right] 时,
// 说明当前字符匹配,继续向两边扩展
while (left >= 0 && right < len && a[left] == a[right])
{
left--; // 左指针向左移动
right++; // 右指针向右移动
}
// 循环结束后,left 和 right 已经越过了回文串的边界
// 回文串的长度 = right - left - 1
// 例如,对于 "aba",初始 left=1, right=1
// 第一次循环:a[1]=='a',left=0, right=2
// 第二次循环:a[0]=='a', a[2]=='a',left=-1, right=3
// 循环结束。长度 = 3 - (-1) - 1 = 3
// 例如,对于 "abba",初始 left=1, right=2
// 第一次循环:a[1]=='b', a[2]=='b',left=0, right=3
// 第二次循环:a[0]=='a', a[3]=='a',left=-1, right=4
// 循环结束。长度 = 4 - (-1) - 1 = 4
return right - left - 1;
}
// 辅助函数:返回两个整数中的较大值
int max(int a, int b){
return a > b ? a : b;
}
/**
* @brief 查找字符串的最长回文子串的长度
*
* 本函数使用“中心扩展法”来查找最长回文子串的长度。
* 遍历字符串中的每一个字符作为回文串的中心(奇数长度),
* 以及每两个相邻字符的间隙作为回文串的中心(偶数长度),
* 然后从中心向两边扩展,计算最长回文串的长度。
*
* @param A 输入字符串
* @return int 最长回文子串的长度。如果字符串为空或NULL,返回0。
*/
int getLongestPalindrome(char *A){
// 1. 输入校验:处理空指针或空字符串
if (A == NULL || strlen(A) == 0) {
return 0; // 空字符串没有回文子串,长度为0
}
int len = strlen(A);
// 2. 特殊情况处理:单个字符的字符串,最长回文长度为1
if (len == 1) {
return 1;
}
int maxLen = 1; // 最小的回文子串是单个字符,所以初始maxLen为1
// 3. 遍历所有可能的中心点
// 每个字符都可以是奇数长度回文串的中心 (i, i)
// 每两个相邻字符的间隙可以是偶数长度回文串的中心 (i, i+1)
for (int i = 0; i < len; i++) {
// 扩展奇数长度的回文串,以 A[i] 为中心
int len1 = doFunc(A, len, i, i);
// 扩展偶数长度的回文串,以 A[i] 和 A[i+1] 之间为中心
int len2 = doFunc(A, len, i, i + 1);
// 取两种情况中较大的长度
int tempM = max(len1, len2);
// 更新全局最大长度
if (tempM > maxLen) {
maxLen = tempM;
}
}
return maxLen;
}
我的分析和思考:
用户这份代码是“最长回文子串长度”问题的经典“中心扩展法”实现,逻辑非常正确,而且 doFunc
中长度的计算 right - left - 1
也非常巧妙和准确。
优点:
-
“中心扩展法”的应用:用户采用了高效且易于理解的中心扩展法,通过遍历所有可能的中心点来寻找回文串。
-
doFunc
辅助函数:将从中心向外扩展的逻辑封装成一个单独的函数,提高了代码的模块化和可读性。 -
奇偶长度回文串的处理:通过
doFunc(A, len, i, i)
处理奇数长度回文串,doFunc(A, len, i, i + 1)
处理偶数长度回文串,考虑到了所有情况。 -
maxLen
的正确更新:能够正确地记录并更新最长回文子串的长度。 -
right - left - 1
计算长度:这是中心扩展法中计算回文长度的精髓,非常正确。
可以优化和改进的地方:
-
头文件移除:
#include <math.h>
在这里是完全不需要的,可以移除。 -
空字符串/空指针处理:
if (!A) return -1 + 1;
这里的-1 + 1
应该直接是return 0;
,更清晰。用户在if (A == NULL || strlen(A) == 0)
已经做了很好的处理。 -
注释的完善:虽然用户已经有注释,但可以进一步细化
doFunc
的作用和right - left - 1
的原理,以及getLongestPalindrome
的整体思路。
深入浅出:最长回文子串的DP原理与实现(中心扩展法与DP表法)
最长回文子串问题有两种主流的DP解法:
1. 中心扩展法 (用户采用的,更适合求长度)
-
核心思想:回文串都是围绕一个中心(一个字符或两个字符的间隙)对称的。我们遍历字符串中的每一个字符(作为奇数长度回文的中心)和每两个相邻字符的间隙(作为偶数长度回文的中心),然后从这些中心点向两边扩展,直到遇到不匹配的字符或到达字符串边界。在扩展过程中,记录并更新找到的最长回文串的长度。
-
DP 状态:严格来说,中心扩展法不是典型的DP填表,它更像是一种枚举和扩展的策略。但其“子问题”是判断以某个中心点扩展的回文串长度,并利用了“如果
s[i...j]
是回文,那么s[i+1...j-1]
也是回文”的性质。 -
优势:实现相对简单,时间复杂度通常为 O(N2) (N为字符串长度),空间复杂度为 O(1) (不考虑结果字符串存储)。
2. 动态规划表法 (更适合求子串本身)
-
DP 状态定义:
dp[i][j]
:表示子串A[i...j]
是否为回文串。-
dp[i][j] = true
如果A[i...j]
是回文串。 -
dp[i][j] = false
如果A[i...j]
不是回文串。
-
-
DP 状态转移方程:
-
基准情况 (长度为1或2的子串):
-
如果
i == j
(长度为1):dp[i][i] = true
(单个字符总是回文)。 -
如果
j - i == 1
(长度为2):dp[i][i+1] = (A[i] == A[i+1])
。
-
-
一般情况 (长度大于2的子串):
-
dp[i][j] = (A[i] == A[j] && dp[i+1][j-1])
-
这意味着,如果
A[i]
和A[j]
匹配,并且它们内部的子串A[i+1...j-1]
也是回文,那么A[i...j]
就是回文。
-
-
-
计算顺序: 通常是按照子串的长度
L
从 1 到len
遍历,然后遍历起始索引i
,计算结束索引j = i + L - 1
。这样可以确保在计算dp[i][j]
时,dp[i+1][j-1]
已经被计算过了。 -
优势:可以方便地记录最长回文子串的起始和结束位置,直接返回子串本身。时间复杂度 O(N2),空间复杂度 O(N2)。
DP 状态表(DP表法以 A = "babad"
为例):
dp[i][j]
表示 A[i...j]
是否为回文。
b(0) |
a(1) |
b(2) |
a(3) |
d(4) | |
---|---|---|---|---|---|
b(0) |
T |
F |
T |
F |
F |
a(1) |
T |
F |
T |
F | |
b(2) |
T |
F |
F | ||
a(3) |
T |
F | |||
d(4) |
T |
解释:
-
dp[0][0]
(b): T -
dp[1][1]
(a): T -
dp[0][1]
(ba): F (b!=a) -
dp[0][2]
(bab):A[0]==A[2]
(b==b) 且dp[1][1]
(a) 为 T,所以dp[0][2]
为 T。 -
dp[1][3]
(aba):A[1]==A[3]
(a==a) 且dp[2][2]
(b) 为 T,所以dp[1][3]
为 T。
通过填充这张表,我们就能找到 true
值为最长的 (j-i+1)
的 dp[i][j]
,从而确定最长回文子串。
为什么用户代码选择了中心扩展法?
因为题目只要求返回最长回文子串的长度。中心扩展法在求长度时,空间复杂度更优 (O(1) vs O(N2)),且实现逻辑也相对直观。对于嵌入式开发,资源受限,更低的内存占用通常是优先考虑的。
优化与最终代码 (C语言)
对用户代码进行优化,主要是完善注释和移除不需要的头文件。
#include <stdio.h> // 标准输入输出,用于printf等
#include <string.h> // 字符串操作函数,用于strlen
// 辅助函数:返回两个整数中的较大值
int getMax(int a, int b) {
return a > b ? a : b;
}
/**
* @brief 辅助函数:从指定中心点向两边扩展,计算回文子串的长度。
*
* 该函数接收字符串、其长度以及回文串的中心点(由left和right定义)。
* left和right可以指向同一个字符(奇数长度回文),也可以指向相邻的两个字符(偶数长度回文)。
* 函数会不断向外扩展,直到遇到不匹配的字符或到达字符串边界。
*
* @param s 原始字符串
* @param len 字符串长度
* @param left 回文中心点的左边界索引
* @param right 回文中心点的右边界索引
* @return int 扩展出的回文子串的长度
*/
int expandAroundCenter(char *s, int len, int left, int right) {
// 循环条件:左右指针均在有效范围内,且左右指针指向的字符相同
while (left >= 0 && right < len && s[left] == s[right]) {
left--; // 左指针向左移动
right++; // 右指针向右移动
}
// 循环结束后,left 和 right 已经越过了回文串的实际边界
// 回文串的长度 = (right - 1) - (left + 1) + 1 = right - left - 1
return right - left - 1;
}
/**
* @brief 查找字符串的最长回文子串的长度
*
* 本函数使用“中心扩展法”来查找给定字符串的最长回文子串的长度。
* 它遍历字符串中的每一个字符作为奇数长度回文串的中心,
* 以及每两个相邻字符的间隙作为偶数长度回文串的中心。
* 对于每个中心点,调用 expandAroundCenter 函数向两边扩展,
* 并记录找到的最长回文串的长度。
*
* @param A 输入字符串
* @return int 最长回文子串的长度。如果字符串为空或NULL,返回0。
*/
int getLongestPalindromeOptimized(char *A) {
// 1. 输入校验:处理空指针或空字符串的情况
if (A == NULL || strlen(A) == 0) {
return 0; // 空字符串没有回文子串,长度为0
}
int len = strlen(A);
// 2. 初始最长回文长度为1(单个字符总是回文)
int maxLen = 1;
// 3. 遍历所有可能的中心点
// 对于每个字符 A[i],它可能是:
// a) 奇数长度回文串的中心 (例如 "aba" 中的 'b')
// b) 偶数长度回文串的左中心 (例如 "abba" 中左边的 'b')
for (int i = 0; i < len; i++) {
// 扩展奇数长度的回文串:以 A[i] 为中心
int len1 = expandAroundCenter(A, len, i, i);
// 扩展偶数长度的回文串:以 A[i] 和 A[i+1] 之间的空隙为中心
// 注意:如果 i+1 越界,expandAroundCenter 会在循环开始时处理
int len2 = expandAroundCenter(A, len, i, i + 1);
// 取两种情况中较大的长度,并与当前的 maxLen 比较
maxLen = getMax(maxLen, getMax(len1, len2));
}
return maxLen;
}
/*
// 示例用法 (可以放在main函数中测试)
int main() {
char *s1 = "babad";
printf("'%s' 的最长回文子串长度: %d\n", s1, getLongestPalindromeOptimized(s1)); // 预期输出: 3 ("bab" 或 "aba")
char *s2 = "cbbd";
printf("'%s' 的最长回文子串长度: %d\n", s2, getLongestPalindromeOptimized(s2)); // 预期输出: 2 ("bb")
char *s3 = "a";
printf("'%s' 的最长回文子串长度: %d\n", s3, getLongestPalindromeOptimized(s3)); // 预期输出: 1
char *s4 = "ac";
printf("'%s' 的最长回文子串长度: %d\n", s4, getLongestPalindromeOptimized(s4)); // 预期输出: 1
char *s5 = "";
printf("'%s' 的最长回文子串长度: %d\n", s5, getLongestPalindromeOptimized(s5)); // 预期输出: 0
char *s6 = "racecar";
printf("'%s' 的最长回文子串长度: %d\n", s6, getLongestPalindromeOptimized(s6)); // 预期输出: 7
char *s7 = "aaaa";
printf("'%s' 的最长回文子串长度: %d\n", s7, getLongestPalindromeOptimized(s7)); // 预期输出: 4
return 0;
}
*/
代码改进总结:
改进点 |
原因/目的 |
效果 |
---|---|---|
移除不必要头文件 |
保持代码简洁,避免引入不必要的依赖。 |
提高编译效率,减少最终可执行文件大小(微乎其微,但好习惯)。 |
|
函数名更具描述性,清晰表达了其功能。 |
提高代码可读性。 |
详细注释 |
深入解释了“中心扩展法”的原理, |
极大地提高了代码的可读性、可维护性,便于他人理解和自己回顾。 |
输入校验 |
对 |
提高代码的健壮性,防止非法输入导致程序崩溃。 |
|
明确 |
逻辑更严谨,覆盖了所有情况。 |
总结与展望
第二部分,我们继续深化动态规划的学习,攻克了“兑换零钱”和“最长回文子串”这两个硬核问题。我们不仅分析了用户代码的优点和可改进之处,还深入探讨了每种算法的DP原理、状态定义、转移方程和初始化。
通过这两个问题,我们看到了DP在处理不同类型问题时的灵活性。对于“兑换零钱”,我们用DP数组记录了凑成每个金额的最少硬币数;对于“最长回文子串”,我们则采用了高效的中心扩展法。
DP的世界远不止于此,还有更多精彩的题目等待我们去探索。在接下来的部分,我们将继续挑战“最长上升子序列”、“字符串转化IP地址”、“编辑距离”以及“打家劫舍”系列问题。
动态规划硬核之路:最长上升子序列与打家劫舍(第三部分)
前言:DP,不止是套路,更是思想!
经过前两部分的洗礼,相信大家对动态规划已经不再陌生。DP不仅仅是一堆公式和套路,它更是一种解决问题的思想:把大问题拆解成小问题,利用小问题的解来构建大问题的解。 这种思想在嵌入式开发中,尤其是在资源受限、需要高效算法的场景下,显得尤为重要。
今天,我们将继续深入,探讨两个在数组和序列问题中非常经典的DP应用:最长上升子序列 (Longest Increasing Subsequence, LIS) 和 打家劫舍 (House Robber) 系列。它们将帮助我们巩固对DP的理解,并学习如何处理更复杂的约束条件。
6 经典问题剖析:最长上升子序列 (LIS)
“最长上升子序列”是序列DP问题的典型代表。它要求我们从一个无序的序列中,找出最长的一个子序列,使得这个子序列是严格递增的。
问题描述
给定一个整数数组 arr
,找出其中最长严格上升子序列的长度。
用户代码分析与思考
我们先来看看用户提供的 LIS
函数:
#include <stdio.h> // 补充头文件
#include <string.h> // 补充头文件,虽然这里没用,但习惯性引入
// 辅助函数:返回两个整数中的较大值
int getMax(int a, int b){
return a > b ? a : b;
}
/**
* 代码中的类名、方法名、参数名已经指定,请勿修改,直接返回方法规定的值即可
*
* 给定数组的最长严格上升子序列的长度。
* @param arr int整型一维数组 给定的数组
* @param arrLen int arr数组长度
* @return int整型
*/
int LIS(int *arr, int arrLen){
// write code here
// 1. 输入校验:处理空指针或空数组
if (arr == NULL || arrLen == 0) {
return 0; // 空数组的最长上升子序列长度为0
}
// 2. DP 数组定义与初始化
// dp[i] 表示以 arr[i] 结尾的最长上升子序列的长度
// 任何单个元素本身就是一个长度为1的上升子序列
int dp[arrLen]; // 固定大小数组,如果arrLen很大,可能栈溢出,建议动态分配
for (int i = 0; i < arrLen; i++) {
dp[i] = 1; // 初始化:每个元素自身构成一个长度为1的上升子序列
}
int maxLen = 1; // 记录整个数组中的最长上升子序列长度,至少为1 (当数组非空时)
// 3. 遍历填充 DP 数组
// 外层循环:遍历数组中的每一个元素 arr[i],作为当前子序列的“结尾”
for (int i = 0; i < arrLen; i++) {
// 内层循环:遍历 arr[i] 之前的所有元素 arr[j]
for (int j = 0; j < i; j++) {
// 状态转移条件:如果 arr[j] 小于 arr[i]
// 意味着 arr[i] 可以接在以 arr[j] 结尾的上升子序列后面
if (arr[j] < arr[i]) {
// 状态转移方程:
// dp[i] = max(dp[i], dp[j] + 1)
// 比较:
// a) 保持当前以 arr[i] 结尾的子序列长度 (dp[i] 的当前值)
// b) 将 arr[i] 接在以 arr[j] 结尾的子序列后面,形成的新长度 (dp[j] + 1)
dp[i] = getMax(dp[i], dp[j] + 1);
}
// else {
// // 如果 arr[j] 不小于 arr[i],则 arr[i] 不能接在以 arr[j] 结尾的子序列后面
// // 此时 dp[i] 不变,或者说不考虑从 dp[j] 转移过来
// ; // 用户原始代码中的空语句,可以省略
// }
}
// 每次计算完 dp[i] 后,更新全局的最大长度
maxLen = getMax(maxLen, dp[i]);
}
// 4. 返回最终结果
// return dp[arrLen - 1]; // 错误:dp[arrLen-1] 只是以最后一个元素结尾的LIS长度
return maxLen; // 正确:返回全局最大长度
}
我的分析和思考:
用户这份代码对于最长上升子序列的DP解法理解得非常透彻,核心逻辑和状态转移方程都是正确的。特别是 dp[i] = getMax(dp[i], dp[j] + 1)
这一步,完美体现了LIS的DP思想。
优点:
-
DP 状态定义清晰:
dp[i]
表示以arr[i]
结尾的最长上升子序列的长度。这个定义非常关键,是解决LIS问题的核心。 -
状态转移方程正确:
dp[i] = max(dp[i], dp[j] + 1)
准确地捕获了如果arr[i]
大于arr[j]
,那么arr[i]
就可以接在以arr[j]
结尾的LIS后面,形成一个更长的LIS。 -
初始化正确:
dp[i] = 1
,每个元素自身都可以构成一个长度为1的上升子序列,这是正确的初始状态。 -
全局
maxLen
记录:用户正确地认识到最终结果是dp
数组中的最大值,而不是dp[arrLen - 1]
,并用maxLen
变量来记录,这非常重要。
可以优化和改进的地方:
-
数组大小:
int dp[arrLen]
是一个栈上的固定大小数组(C99支持变长数组,但并非所有编译器都默认开启或推荐在嵌入式中使用)。如果arrLen
非常大,可能会导致栈溢出。在嵌入式环境中,栈空间通常有限,这种情况下应该考虑使用堆内存malloc
来动态分配dp
数组,并在函数结束时free
掉。 -
头文件缺失:
strlen
虽然在注释中提到了,但实际代码中并未用到,可以移除string.h
。getMax
辅助函数不需要额外头文件。 -
注释的完善:虽然用户已经有注释,但可以进一步细化
dp[i]
的含义,以及为什么需要maxLen
来记录全局最大值。
深入浅出:最长上升子序列的DP原理与实现
咱们来彻底捋一遍最长上升子序列的DP思路。
1. DP 状态定义:
dp[i]
:表示以 arr[i]
这个元素结尾的最长上升子序列的长度。
2. DP 状态转移方程:
要计算 dp[i]
,我们需要向前看,遍历所有 j < i
的元素 arr[j]
。 如果 arr[j] < arr[i]
,这意味着 arr[i]
可以接在以 arr[j]
结尾的任何上升子序列后面,形成一个新的上升子序列。 那么,以 arr[i]
结尾的上升子序列的长度,就可以是 dp[j] + 1
。 由于我们希望找到最长的,所以 dp[i]
应该取所有符合条件的 dp[j] + 1
中的最大值。 dp[i] = max(dp[i], dp[j] + 1)
(其中 arr[j] < arr[i]
)
3. 初始化:
-
dp[i] = 1
:对于数组中的每一个元素arr[i]
,它自身至少可以构成一个长度为1的上升子序列。这是所有dp[i]
的初始值。
4. 计算顺序:
外层循环从 i = 0
到 arrLen - 1
遍历数组,内层循环从 j = 0
到 i - 1
遍历 i
之前的元素。这种顺序确保了在计算 dp[i]
时,所有 dp[j]
(其中 j < i
) 都已经被计算过了。
DP 状态表(以 arr = {10, 9, 2, 5, 3, 7, 101, 18}
为例):
索引 |
|
|
|
|
|
|
|
|
|
最终 |
|
---|---|---|---|---|---|---|---|---|---|---|---|
0 |
10 |
1 |
- |
- |
- |
- |
- |
- |
- |
1 |
1 |
1 |
9 |
1 |
|
- |
- |
- |
- |
- |
- |
1 |
1 |
2 |
2 |
1 |
|
|
- |
- |
- |
- |
- |
1 |
1 |
3 |
5 |
1 |
|
|
|
- |
- |
- |
- |
2 |
2 |
4 |
3 |
1 |
|
|
|
|
- |
- |
- |
2 |
2 |
5 |
7 |
1 |
|
|
|
|
|
- |
- |
3 |
3 |
6 |
101 |
1 |
|
- |
- |
- |
- |
- |
- |
4 |
4 |
7 |
18 |
1 |
|
|
|
|
|
|
|
4 |
4 |
最终 maxLen
为 4。对应的LIS可以是 {2, 3, 7, 18}
或 {2, 5, 7, 18}
。
优化与最终代码 (C语言)
结合上述分析,我将用户的代码进行优化,主要是使用动态内存分配 dp
数组,并添加更详细的注释。
#include <stdio.h> // 标准输入输出,用于printf等
#include <stdlib.h> // 内存分配函数,用于malloc, free
/**
* @brief 辅助函数:返回两个整数中的较大值
* @param a 整数1
* @param b 整数2
* @return int 较小值
*/
int getMax(int a, int b) {
return a > b ? a : b;
}
/**
* @brief 计算给定数组的最长严格上升子序列的长度。
*
* 本函数使用动态规划(Dynamic Programming, DP)来解决最长上升子序列问题。
* dp[i] 存储以 arr[i] 结尾的最长上升子序列的长度。
*
* @param arr int整型一维数组 给定的数组
* @param arrLen int arr数组长度
* @return int整型 最长严格上升子序列的长度。如果数组为空,返回0。
*/
int longestIncreasingSubsequence(int *arr, int arrLen) {
// 1. 输入校验:处理空指针或空数组的情况
if (arr == NULL || arrLen == 0) {
return 0; // 空数组的最长上升子序列长度为0
}
// 2. DP 数组定义与初始化
// dp[i] 表示以 arr[i] 结尾的最长上升子序列的长度
// 使用动态内存分配,避免栈溢出问题,尤其在 arrLen 较大时
int *dp = (int *)malloc(arrLen * sizeof(int));
if (dp == NULL) {
return 0; // 内存分配失败,返回0或-1表示错误
}
// 初始化:每个元素自身构成一个长度为1的上升子序列
for (int i = 0; i < arrLen; i++) {
dp[i] = 1;
}
// 记录整个数组中的最长上升子序列长度,初始至少为1(当数组非空时)
int maxOverallLen = 1;
// 3. 遍历填充 DP 数组
// 外层循环:遍历数组中的每一个元素 arr[i],将其作为当前考虑的上升子序列的“结尾”
for (int i = 0; i < arrLen; i++) {
// 内层循环:遍历 arr[i] 之前的所有元素 arr[j]
// 尝试将 arr[i] 连接到以 arr[j] 结尾的上升子序列后面
for (int j = 0; j < i; j++) {
// 状态转移条件:如果 arr[j] 小于 arr[i]
// 意味着 arr[i] 可以接在以 arr[j] 结尾的上升子序列后面
if (arr[j] < arr[i]) {
// 状态转移方程:
// dp[i] = max(dp[i], dp[j] + 1)
// 比较两种情况:
// a) 保持当前以 arr[i] 结尾的子序列长度(即 arr[i] 自身构成子序列)
// b) 将 arr[i] 接在以 arr[j] 结尾的子序列后面,形成的新长度 (dp[j] + 1)
dp[i] = getMax(dp[i], dp[j] + 1);
}
}
// 每次计算完 dp[i] 后,更新全局的最大长度
maxOverallLen = getMax(maxOverallLen, dp[i]);
}
// 4. 释放动态分配的内存
free(dp);
// 5. 返回最终结果
return maxOverallLen;
}
/*
// 示例用法 (可以放在main函数中测试)
int main() {
int arr1[] = {10, 9, 2, 5, 3, 7, 101, 18};
int len1 = sizeof(arr1) / sizeof(arr1[0]);
printf("数组 {10, 9, 2, 5, 3, 7, 101, 18} 的最长上升子序列长度: %d\n", longestIncreasingSubsequence(arr1, len1)); // 预期输出: 4
int arr2[] = {0, 1, 0, 3, 2, 3};
int len2 = sizeof(arr2) / sizeof(arr2[0]);
printf("数组 {0, 1, 0, 3, 2, 3} 的最长上升子序列长度: %d\n", longestIncreasingSubsequence(arr2, len2)); // 预期输出: 4
int arr3[] = {7, 7, 7, 7, 7, 7, 7};
int len3 = sizeof(arr3) / sizeof(arr3[0]);
printf("数组 {7, 7, 7, 7, 7, 7, 7} 的最长上升子序列长度: %d\n", longestIncreasingSubsequence(arr3, len3)); // 预期输出: 1
int arr4[] = {};
int len4 = sizeof(arr4) / sizeof(arr4[0]);
printf("空数组的最长上升子序列长度: %d\n", longestIncreasingSubsequence(arr4, len4)); // 预期输出: 0
int arr5[] = {1};
int len5 = sizeof(arr5) / sizeof(arr5[0]);
printf("数组 {1} 的最长上升子序列长度: %d\n", longestIncreasingSubsequence(arr5, len5)); // 预期输出: 1
int arr6[] = {47, 89, 23, 76, 12, 55, 34, 91, 62, 7, 38, 81, 44, 50, 99};
int len6 = sizeof(arr6) / sizeof(arr6[0]);
printf("数组 {47, 89, ..., 99} 的最长上升子序列长度: %d\n", longestIncreasingSubsequence(arr6, len6)); // 预期输出: 6 (例如 23, 34, 38, 44, 50, 99)
return 0;
}
*/
代码改进总结:
改进点 |
原因/目的 |
效果 |
---|---|---|
|
解决 |
提高代码的鲁棒性和可伸缩性,能够处理更大规模的问题。 |
内存泄漏处理 |
确保 |
避免内存泄漏,这在嵌入式系统资源有限且需要长时间运行的场景下尤其重要。 |
输入校验 |
对 |
提高代码的健壮性,防止非法输入导致程序崩溃。 |
详细注释 |
解释每一步的逻辑、变量含义和设计思路,特别是DP状态转移的推导过程。 |
极大地提高了代码的可读性、可维护性,方便他人理解和自己回顾。 |
变量名优化 |
|
提高代码可读性。 |
7 经典问题剖析:打家劫舍 (House Robber)
“打家劫舍”问题是DP中非常经典的线性DP问题。它考察的是如何在有约束条件(不能偷相邻的房屋)的情况下,最大化收益。
问题描述 (一:线性排列)
你是一个专业的窃贼,计划偷窃沿街的房屋。每间房屋都存放着特定金额的现金。现在,你面临一个特殊挑战:相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被偷,系统会自动报警。
给定一个非负整数数组 nums
,表示每间房屋的金额,计算你在不触发警报的情况下,能够偷窃到的最高总金额。
用户代码分析与思考
我们先来看看用户提供的 rob
函数(线性排列版本):
#include <stdio.h> // 补充头文件
#include <stdlib.h> // 补充头文件,如果需要动态分配
// 辅助函数:返回两个整数中的较大值
int getMax(int a, int b){
return a > b ? a : b;
}
/**
* 代码中的类名、方法名、参数名已经指定,请勿修改,直接返回方法规定的值即可
*
* 计算在不触发警报的情况下,能够偷窃到的最高总金额(线性排列房屋)。
* @param nums int整型一维数组 表示每间房屋的金额
* @param numsLen int nums数组长度
* @return int整型 能够偷窃到的最高总金额
*/
int robLinear(int *nums, int numsLen){
// write code here
// 打家劫舍问题:要么搞nums[i]+dp[i-2] ,要么dp[i-1]
// 1. 输入校验:处理空指针或空数组
if (nums == NULL || numsLen == 0) {
return 0; // 没有房屋可偷,总金额为0
}
// 2. 特殊情况处理:房屋数量较少的情况
if (numsLen == 1) {
return nums[0]; // 只有一间房屋,直接偷
}
if (numsLen == 2) {
return getMax(nums[0], nums[1]); // 两间房屋,偷金额较大的那间
}
// 3. DP 数组定义与初始化
// dp[i] 表示偷窃到第 i 间房屋(索引为 i-1)时,能够偷窃到的最高总金额
// 或者说,dp[i] 表示考虑前 i 间房屋时(索引 0 到 i-1),能偷到的最大金额
// 这里使用固定大小数组,如果 numsLen 很大,可能栈溢出,建议动态分配
int dp[numsLen];
// 初始化 dp[0] 和 dp[1]
// dp[0] 对应考虑第0间房屋(即nums[0]),最大金额就是nums[0]
dp[0] = nums[0];
// dp[1] 对应考虑第0和第1间房屋,最大金额是两者中较大的
dp[1] = getMax(nums[0], nums[1]);
// 4. 遍历填充 DP 数组
// 从第三间房屋开始(索引为2,对应dp[2])
for (int i = 2; i < numsLen; i++) {
// 状态转移方程:
// 偷窃第 i 间房屋 (nums[i]):那么就不能偷第 i-1 间房屋,总金额为 nums[i] + dp[i-2]
// 不偷窃第 i 间房屋 (nums[i]):那么总金额就是偷窃到第 i-1 间房屋时的最大金额,即 dp[i-1]
// 我们取这两种情况中的较大值
dp[i] = getMax(dp[i - 1], nums[i] + dp[i - 2]);
}
// 5. 返回最终结果
// dp[numsLen - 1] 存储的就是考虑所有房屋后,能够偷窃到的最高总金额
return dp[numsLen - 1];
}
我的分析和思考:
用户这份代码是“打家劫舍”线性排列版本的标准DP解法,非常简洁和高效。
优点:
-
DP 状态定义正确:
dp[i]
表示考虑前i+1
间房屋(即索引从0
到i
)时,能够偷窃到的最高总金额。 -
状态转移方程正确:
dp[i] = max(dp[i-1], nums[i] + dp[i-2])
精准地捕捉了“偷当前房屋”和“不偷当前房屋”两种决策。 -
初始化和边界条件处理:对于
numsLen = 0, 1, 2
的特殊情况处理得当,确保了DP数组的正确初始化。
可以优化和改进的地方:
-
数组大小:
int dp[numsLen]
同样存在栈溢出的风险。建议使用动态内存分配。 -
空间优化:注意到
dp[i]
只依赖于dp[i-1]
和dp[i-2]
,因此可以进行空间优化,将 O(N) 空间复杂度降到 O(1)。
深入浅出:打家劫舍的DP原理与实现 (线性排列)
咱们来彻底捋一遍打家劫舍(线性排列)的DP思路。
1. DP 状态定义:
dp[i]
:表示考虑前 i
间房屋(即 nums[0...i-1]
)时,能够偷窃到的最高总金额。
2. DP 状态转移方程:
对于第 i
间房屋(对应 nums[i-1]
):
-
选择偷窃
nums[i-1]
:那么就不能偷窃第i-2
间房屋(nums[i-2]
)。此时,总金额是nums[i-1]
加上考虑前i-2
间房屋时的最大金额,即nums[i-1] + dp[i-2]
。 -
选择不偷窃
nums[i-1]
:那么总金额就是考虑前i-1
间房屋时的最大金额,即dp[i-1]
。
因此,dp[i] = max(dp[i-1], nums[i-1] + dp[i-2])
。 (注意:这里 dp
数组的索引 i
对应 nums
数组的 i-1
索引,这是一种常见的DP数组定义方式,方便处理边界。)
3. 初始化:
-
dp[0] = 0
:考虑0间房屋,最大金额为0。 -
dp[1] = nums[0]
:考虑1间房屋(nums[0]
),最大金额为nums[0]
。
4. 计算顺序:
从 i = 2
到 numsLen
遍历,确保 dp[i-1]
和 dp[i-2]
已经计算过。
DP 状态表(以 nums = {1, 2, 3, 1}
为例):
索引 |
对应 |
|
|
|
|
|
---|---|---|---|---|---|---|
0 |
- |
- |
- |
- |
(初始化) |
0 |
1 |
0 |
1 |
|
- |
|
1 |
2 |
1 |
2 |
|
|
|
2 |
3 |
2 |
3 |
|
|
|
4 |
4 |
3 |
1 |
|
|
|
4 |
最终 dp[4]
为 4。
优化与最终代码 (C语言)
对用户代码进行优化,主要集中在动态内存分配和空间优化。
#include <stdio.h> // 标准输入输出,用于printf等
#include <stdlib.h> // 内存分配函数,用于malloc, free
/**
* @brief 辅助函数:返回两个整数中的较大值
* @param a 整数1
* @param b 整数2
* @return int 较小值
*/
int getMax(int a, int b) {
return a > b ? a : b;
}
/**
* @brief 计算在不触发警报的情况下,能够偷窃到的最高总金额(线性排列房屋)。
*
* 本函数使用动态规划(Dynamic Programming, DP)解决打家劫舍问题(线性排列)。
* dp[i] 表示考虑前 i 间房屋时,能够偷窃到的最高总金额。
*
* @param nums int整型一维数组 表示每间房屋的金额
* @param numsLen int nums数组长度
* @return int整型 能够偷窃到的最高总金额
*/
int robLinearOptimized(int *nums, int numsLen) {
// 1. 输入校验:处理空指针或空数组
if (nums == NULL || numsLen == 0) {
return 0; // 没有房屋可偷,总金额为0
}
// 2. 特殊情况处理:房屋数量较少的情况
if (numsLen == 1) {
return nums[0]; // 只有一间房屋,直接偷
}
if (numsLen == 2) {
return getMax(nums[0], nums[1]); // 两间房屋,偷金额较大的那间
}
// 3. DP 数组定义与初始化 (空间优化版本)
// 由于 dp[i] 只依赖于 dp[i-1] 和 dp[i-2],我们可以使用两个变量来代替整个 dp 数组
// first_prev: 相当于 dp[i-2] (考虑前 i-2 间房屋的最大金额)
// second_prev: 相当于 dp[i-1] (考虑前 i-1 间房屋的最大金额)
int first_prev = nums[0]; // 对应 dp[0]
int second_prev = getMax(nums[0], nums[1]); // 对应 dp[1]
int current_max = 0; // 存储当前计算的 dp[i]
// 4. 遍历计算最大金额
// 从第三间房屋开始(索引为2)
for (int i = 2; i < numsLen; i++) {
// 状态转移方程:
// current_max = max(不偷当前房屋, 偷当前房屋)
// 不偷当前房屋:沿用 second_prev (即 dp[i-1])
// 偷当前房屋:nums[i] + first_prev (即 nums[i] + dp[i-2])
current_max = getMax(second_prev, nums[i] + first_prev);
// 更新 first_prev 和 second_prev,为下一次循环做准备
first_prev = second_prev;
second_prev = current_max;
}
// 5. 返回最终结果
// second_prev 存储的就是考虑所有房屋后,能够偷窃到的最高总金额
return second_prev;
}
/*
// 示例用法 (可以放在main函数中测试)
int main() {
int nums1[] = {1, 2, 3, 1};
int len1 = sizeof(nums1) / sizeof(nums1[0]);
printf("房屋 {1, 2, 3, 1} 能偷窃的最高金额: %d\n", robLinearOptimized(nums1, len1)); // 预期输出: 4 (偷 1 和 3)
int nums2[] = {2, 7, 9, 3, 1};
int len2 = sizeof(nums2) / sizeof(nums2[0]);
printf("房屋 {2, 7, 9, 3, 1} 能偷窃的最高金额: %d\n", robLinearOptimized(nums2, len2)); // 预期输出: 12 (偷 2, 9, 1)
int nums3[] = {0};
int len3 = sizeof(nums3) / sizeof(nums3[0]);
printf("房屋 {0} 能偷窃的最高金额: %d\n", robLinearOptimized(nums3, len3)); // 预期输出: 0
int nums4[] = {1};
int len4 = sizeof(nums4) / sizeof(nums4[0]);
printf("房屋 {1} 能偷窃的最高金额: %d\n", robLinearOptimized(nums4, len4)); // 预期输出: 1
int nums5[] = {1, 2};
int len5 = sizeof(nums5) / sizeof(nums5[0]);
printf("房屋 {1, 2} 能偷窃的最高金额: %d\n", robLinearOptimized(nums5, len5)); // 预期输出: 2
int nums6[] = {12, 9, 6, 6, 7, 7, 4, 5, 3, 7, 3, 3, 7, 5, 23, 6, 2, 36, 26, 4, 23, 6, 2, 36, 24, 36, 3, 68, 8, 12};
int len6 = sizeof(nums6) / sizeof(nums6[0]);
printf("房屋 {12, 9, ..., 12} 能偷窃的最高金额: %d\n", robLinearOptimized(nums6, len6)); // 预期输出: 201
return 0;
}
*/
代码改进总结:
改进点 |
原因/目的 |
效果 |
---|---|---|
空间优化 |
将 O(N) 的 |
降低内存占用,在嵌入式系统等内存受限环境下尤其重要。 |
输入校验 |
对 |
提高代码的健壮性,防止非法输入导致程序崩溃。 |
详细注释 |
解释了空间优化的原理和变量的含义。 |
提高代码可读性,方便理解和学习。 |
8 经典问题剖析:打家劫舍 II (House Robber II)
“打家劫舍 II”是“打家劫舍”的进阶版本,增加了环形的约束,这让问题变得稍微复杂一些。
问题描述 (二:环形排列)
现在房屋不是线性排列,而是围成一个圈。这意味着第一间房屋和最后一间房屋是相邻的。其他规则不变:相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被偷,系统会自动报警。
给定一个非负整数数组 nums
,表示每间房屋的金额,计算你在不触发警报的情况下,能够偷窃到的最高总金额。
用户代码分析与思考
我们先来看看用户提供的 rob
函数(环形排列版本):
#include <stdio.h> // 标准输入输出,用于printf等
#include <stdlib.h> // 内存分配函数,用于malloc, free
// 辅助函数:返回两个整数中的较大值
int getMax(int a, int b){
return a > b ? a : b;
}
/**
* 代码中的类名、方法名、参数名已经指定,请勿修改,直接返回方法规定的值即可
*
* 计算在不触发警报的情况下,能够偷窃到的最高总金额(环形排列房屋)。
* @param nums int整型一维数组 表示每间房屋的金额
* @param numsLen int nums数组长度
* @return int整型 能够偷窃到的最高总金额
*/
int robCircular(int *nums, int numsLen){
// 1. 输入校验和特殊情况处理
if (nums == NULL || numsLen == 0) {
return 0; // 没有房屋可偷,总金额为0
}
if (numsLen == 1) {
return nums[0]; // 只有一间房屋,直接偷
}
// 2. 将环形问题分解为两个线性问题
// 关键思想:由于首尾房屋不能同时偷窃,所以最大金额只可能出现在以下两种情况:
// 情况 A: 偷窃第一间房屋,则不能偷窃最后一间房屋。
// 问题转化为:在 [0, numsLen-2] 范围内的线性打家劫舍问题。
// 情况 B: 不偷窃第一间房屋,则最后一间房屋可以偷窃。
// 问题转化为:在 [1, numsLen-1] 范围内的线性打家劫舍问题。
// 最终结果是这两种情况中的最大值。
// 2.1 情况 A: 偷窃第一间房屋 (nums[0]),不偷窃最后一间房屋 (nums[numsLen-1])
// 相当于对子数组 nums[0] 到 nums[numsLen-2] (共 numsLen-1 个元素) 进行线性打家劫舍
int *subArr1 = (int *)malloc((numsLen - 1) * sizeof(int));
if (subArr1 == NULL) return -1; // 内存分配失败
for (int i = 0; i < numsLen - 1; i++) {
subArr1[i] = nums[i];
}
int maxAmount1 = robLinearOptimized(subArr1, numsLen - 1); // 调用之前实现的线性打家劫舍函数
free(subArr1); // 释放内存
// 2.2 情况 B: 不偷窃第一间房屋 (nums[0]),偷窃最后一间房屋 (nums[numsLen-1])
// 相当于对子数组 nums[1] 到 nums[numsLen-1] (共 numsLen-1 个元素) 进行线性打家劫舍
int *subArr2 = (int *)malloc((numsLen - 1) * sizeof(int));
if (subArr2 == NULL) return -1; // 内存分配失败
for (int i = 0; i < numsLen - 1; i++) {
subArr2[i] = nums[i+1]; // 从 nums[1] 开始复制
}
int maxAmount2 = robLinearOptimized(subArr2, numsLen - 1); // 调用之前实现的线性打家劫舍函数
free(subArr2); // 释放内存
// 3. 返回两种情况中的最大值
return getMax(maxAmount1, maxAmount2);
}
/*
// 示例用法 (可以放在main函数中测试)
int main() {
// 假设 robLinearOptimized 函数已在前面定义并可用
// int robLinearOptimized(int *nums, int numsLen);
int nums1[] = {2, 3, 2};
int len1 = sizeof(nums1) / sizeof(nums1[0]);
printf("环形房屋 {2, 3, 2} 能偷窃的最高金额: %d\n", robCircular(nums1, len1)); // 预期输出: 3 (偷 3)
int nums2[] = {1, 2, 3, 1};
int len2 = sizeof(nums2) / sizeof(nums2[0]);
printf("环形房屋 {1, 2, 3, 1} 能偷窃的最高金额: %d\n", robCircular(nums2, len2)); // 预期输出: 4 (偷 2 和 3)
int nums3[] = {0};
int len3 = sizeof(nums3) / sizeof(nums3[0]);
printf("环形房屋 {0} 能偷窃的最高金额: %d\n", robCircular(nums3, len3)); // 预期输出: 0
int nums4[] = {1};
int len4 = sizeof(nums4) / sizeof(nums4[0]);
printf("环形房屋 {1} 能偷窃的最高金额: %d\n", robCircular(nums4, len4)); // 预期输出: 1
int nums5[] = {1, 2};
int len5 = sizeof(nums5) / sizeof(nums5[0]);
printf("环形房屋 {1, 2} 能偷窃的最高金额: %d\n", robCircular(nums5, len5)); // 预期输出: 2 (偷 2)
int nums6[] = {1, 2, 3};
int len6 = sizeof(nums6) / sizeof(nums6[0]);
printf("环形房屋 {1, 2, 3} 能偷窃的最高金额: %d\n", robCircular(nums6, len6)); // 预期输出: 3 (偷 3)
return 0;
}
*/
我的分析和思考:
用户这份代码对于“打家劫舍 II”的解题思路非常正确,即将其分解为两个线性打家劫舍问题。这是解决环形DP问题的常用技巧。
优点:
-
问题分解正确:将环形问题巧妙地分解为“偷第一家,不偷最后一家”和“不偷第一家,偷最后一家”两个独立的线性问题,并取两者的最大值。
-
复用线性解法:通过调用
robLinearOptimized
函数,实现了代码的复用,体现了模块化思想。 -
内存管理:对
malloc
分配的子数组进行了free
操作,避免了内存泄漏。
可以优化和改进的地方:
-
内存分配和复制:虽然
malloc
和free
确保了内存管理,但在每次调用robCircular
时都进行两次子数组的malloc
和memcpy
(隐式在for
循环中),会带来额外的开销。对于嵌入式系统,频繁的堆内存操作可能不是最优选择。-
优化思路:可以在
robLinearOptimized
函数中增加start
和end
参数,直接操作原始数组的子区间,避免额外的内存分配和复制。
-
深入浅出:打家劫舍 II 的DP原理与实现 (环形排列)
环形打家劫舍问题的核心在于首尾相连的约束。由于第一间房屋和最后一间房屋是相邻的,我们不能同时偷窃它们。这导致了两种互斥的可能性:
-
偷窃第一间房屋:如果偷窃了第一间房屋,那么就不能偷窃最后一间房屋。此时,问题就变成了在
[0, numsLen-2]
范围内的线性打家劫舍问题。 -
不偷窃第一间房屋:如果选择不偷窃第一间房屋,那么最后一间房屋就可以偷窃(也可以不偷)。此时,问题就变成了在
[1, numsLen-1]
范围内的线性打家劫舍问题。
这两种情况涵盖了所有可能性,并且它们是互斥的。因此,最终的最高总金额就是这两种情况中的最大值。
DP 状态定义和转移方程:与线性打家劫舍完全相同,只是应用于不同的子区间。
DP 状态表:由于是分解为线性问题,所以没有新的DP表形式,只是对原数组的不同切片进行线性DP计算。
优化与最终代码 (C语言)
为了避免在 robCircular
中频繁的 malloc
和 free
,我们可以修改 robLinearOptimized
函数,使其能够处理数组的任意子区间。
#include <stdio.h> // 标准输入输出,用于printf等
#include <stdlib.h> // 内存分配函数,用于malloc, free
/**
* @brief 辅助函数:返回两个整数中的较大值
* @param a 整数1
* @param b 整数2
* @return int 较小值
*/
int getMax(int a, int b) {
return a > b ? a : b;
}
/**
* @brief 计算在不触发警报的情况下,能够偷窃到的最高总金额(线性排列房屋)。
* 此版本支持对数组的任意子区间进行计算,以优化环形打家劫舍问题。
*
* 本函数使用动态规划(Dynamic Programming, DP)解决打家劫舍问题(线性排列)。
* 空间复杂度优化到 O(1)。
*
* @param nums int整型一维数组 表示每间房屋的金额
* @param start int 子区间的起始索引
* @param end int 子区间的结束索引(包含)
* @return int整型 能够偷窃到的最高总金额
*/
int robLinearOptimizedWithRange(int *nums, int start, int end) {
// 1. 输入校验:处理空区间或无效索引
if (start > end) {
return 0; // 无效区间,没有房屋可偷
}
int currentLen = end - start + 1; // 当前子区间的长度
// 2. 特殊情况处理:子区间房屋数量较少的情况
if (currentLen == 0) {
return 0;
}
if (currentLen == 1) {
return nums[start]; // 只有一间房屋,直接偷
}
if (currentLen == 2) {
return getMax(nums[start], nums[start + 1]); // 两间房屋,偷金额较大的那间
}
// 3. DP 状态变量初始化 (空间优化)
// first_prev: 相当于 dp[i-2] (考虑前 i-2 间房屋的最大金额)
// second_prev: 相当于 dp[i-1] (考虑前 i-1 间房屋的最大金额)
// 注意:这里的索引是相对于子区间的,即 nums[start], nums[start+1] 等
int first_prev = nums[start]; // 对应子区间的第一间房屋
int second_prev = getMax(nums[start], nums[start + 1]); // 对应子区间的第二间房屋
// 4. 遍历计算最大金额
// 从子区间的第三间房屋开始(对应索引 start + 2)
for (int i = start + 2; i <= end; i++) {
int current_max = getMax(second_prev, nums[i] + first_prev);
first_prev = second_prev;
second_prev = current_max;
}
// 5. 返回最终结果
return second_prev;
}
/**
* @brief 计算在不触发警报的情况下,能够偷窃到的最高总金额(环形排列房屋)。
* 此版本通过调用带范围的线性打家劫舍函数,避免了额外的内存分配。
*
* @param nums int整型一维数组 表示每间房屋的金额
* @param numsLen int nums数组长度
* @return int整型 能够偷窃到的最高总金额
*/
int robCircularOptimized(int *nums, int numsLen){
// 1. 输入校验和特殊情况处理
if (nums == NULL || numsLen == 0) {
return 0; // 没有房屋可偷,总金额为0
}
if (numsLen == 1) {
return nums[0]; // 只有一间房屋,直接偷
}
// 2. 将环形问题分解为两个线性问题,并复用带范围的线性打家劫舍函数
// 情况 A: 偷窃第一间房屋,则不能偷窃最后一间房屋。
// 问题转化为:在 [0, numsLen-2] 范围内的线性打家劫舍问题。
int maxAmount1 = robLinearOptimizedWithRange(nums, 0, numsLen - 2);
// 情况 B: 不偷窃第一间房屋,则最后一间房屋可以偷窃。
// 问题转化为:在 [1, numsLen-1] 范围内的线性打家劫舍问题。
int maxAmount2 = robLinearOptimizedWithRange(nums, 1, numsLen - 1);
// 3. 返回两种情况中的最大值
return getMax(maxAmount1, maxAmount2);
}
/*
// 示例用法 (可以放在main函数中测试)
int main() {
int nums1[] = {2, 3, 2};
int len1 = sizeof(nums1) / sizeof(nums1[0]);
printf("环形房屋 {2, 3, 2} 能偷窃的最高金额: %d\n", robCircularOptimized(nums1, len1)); // 预期输出: 3
int nums2[] = {1, 2, 3, 1};
int len2 = sizeof(nums2) / sizeof(nums2[0]);
printf("环形房屋 {1, 2, 3, 1} 能偷窃的最高金额: %d\n", robCircularOptimized(nums2, len2)); // 预期输出: 4
int nums3[] = {0};
int len3 = sizeof(nums3) / sizeof(nums3[0]);
printf("环形房屋 {0} 能偷窃的最高金额: %d\n", robCircularOptimized(nums3, len3)); // 预期输出: 0
int nums4[] = {1};
int len4 = sizeof(nums4) / sizeof(nums4[0]);
printf("环形房屋 {1} 能偷窃的最高金额: %d\n", robCircularOptimized(nums4, len4)); // 预期输出: 1
int nums5[] = {1, 2};
int len5 = sizeof(nums5) / sizeof(nums5[0]);
printf("环形房屋 {1, 2} 能偷窃的最高金额: %d\n", robCircularOptimized(nums5, len5)); // 预期输出: 2
int nums6[] = {1, 2, 3};
int len6 = sizeof(nums6) / sizeof(nums6[0]);
printf("环形房屋 {1, 2, 3} 能偷窃的最高金额: %d\n", robCircularOptimized(nums6, len6)); // 预期输出: 3
return 0;
}
*/
代码改进总结:
改进点 |
原因/目的 |
效果 |
---|---|---|
|
引入了带 |
避免了在 |
|
直接调用 |
提高了环形打家劫舍函数的性能和效率,尤其在嵌入式环境中意义重大。 |
详细注释 |
解释了函数参数、优化原理和调用关系。 |
极大地提高了代码可读性、可维护性,方便他人理解和自己回顾。 |
9 总结与展望
第三部分,我们深入学习了“最长上升子序列”和“打家劫舍”系列问题。
对于最长上升子序列,我们掌握了以 arr[i]
结尾的LIS长度的DP状态定义,并通过双重循环实现了状态转移。我们还强调了动态内存分配在处理大规模数据时的重要性。
对于打家劫舍,我们先解决了线性排列的房屋问题,学习了经典的“偷或不偷”决策,并将其空间复杂度优化到 O(1)。接着,我们将线性解法推广到环形排列的房屋,通过将一个环形问题分解为两个线性问题来巧妙解决。
通过这些问题,我们进一步巩固了DP的核心思想:定义状态,找出转移方程,确定初始化和计算顺序。 并且,我们开始思考如何进行空间优化,这在嵌入式开发中是至关重要的技能。
DP的旅程还在继续!在下一部分,我们将挑战更多有趣的DP问题,例如“字符串转化IP地址”和“编辑距离”。这些问题将带你领略DP在字符串处理和序列比对中的强大能力。
继续加油,兄弟们!胜利就在前方!
动态规划硬核之路:字符串转化IP与编辑距离(第四部分)
前言:DP,字符串处理的“瑞士军刀”!
兄弟们,DP的魅力远不止在数组和序列问题上。在字符串处理领域,DP同样是一把“瑞士军刀”,能够高效地解决各种复杂问题。今天,我们将聚焦两个典型的字符串DP问题:字符串转化IP地址 (Restore IP Addresses) 和 编辑距离 (Edit Distance)。
这两个问题,一个考验你对字符串分割和合法性判断的细致考量,另一个则展示了DP在序列比对和最小操作数计算上的强大能力。它们都是大厂面试中常见的“压轴题”,能搞定它们,你的DP水平绝对能上一个台阶!
10 经典问题剖析:字符串转化IP地址 (Restore IP Addresses)
“字符串转化IP地址”是一个典型的回溯(Backtracking)或DP问题。虽然回溯法更直观,但我们也可以用DP的思想来理解和解决它,尤其是在考虑子问题的重叠性时。这里,我们主要以回溯加剪枝的思路来讲解,因为它更符合解决这类字符串分割问题的直觉,并且可以看作是DP思想的递归实现。
问题描述
给定一个只包含数字的字符串 s
,将其分成四段,使得每段都是一个有效的IP地址段(0-255),并返回所有可能的IP地址组合。
一个有效的IP地址由四个整数组成,每个整数位于0到255之间,并由点分隔。例如 0.0.0.0
和 255.255.255.255
是有效的IP地址。 无效的IP地址示例如下:"0.0.0.00"
(包含前导零)、"0.0.0.256"
(超出范围)。
解题思路与原理分析
这个问题可以看作是:从字符串 s
中,切割出4个有效的数字段。
核心思想:回溯 + 剪枝
我们可以用递归的方式来尝试所有可能的切割方案。在每一步递归中,我们尝试从当前位置开始,切割出1位、2位或3位的数字,然后判断这个数字是否有效。如果有效,就继续递归处理剩余的字符串;如果无效,就剪枝,不再继续。
DP的视角:
虽然我们用回溯实现,但其本质上可以看作是DP的自顶向下(记忆化搜索)思想。每个子问题是“从字符串的某个位置开始,还需要切割 k
段,能有多少种有效方案”。只是这里我们不是求数量,而是求所有具体的方案。
关键步骤:
-
递归函数定义:
void backtrack(char *s, int start, int dots, char *current_ip, int *count, char ***result)
-
s
: 原始字符串。 -
start
: 当前处理的起始索引。 -
dots
: 已经添加的点号数量(即已经分割出的段数)。 -
current_ip
: 当前构建的IP地址字符串。 -
count
: 结果数组中的IP地址数量。 -
result
: 存储所有有效IP地址的二维字符数组。
-
-
递归终止条件:
-
如果
dots == 4
:表示已经分割出了4段。此时,如果start
也恰好到达了字符串的末尾(start == strlen(s)
),说明所有字符都被用完且分割正确,则将current_ip
添加到结果集中。 -
如果
dots == 4
但start != strlen(s)
:说明分割了4段,但原始字符串还有剩余,无效。 -
如果
start == strlen(s)
但dots != 4
:说明原始字符串用完了,但还没分割出4段,无效。
-
-
循环与选择: 在每个递归层级,我们尝试从
start
位置开始,切割出长度为 1、2、3 的子串。-
i
从start
到start + 2
遍历(最多取3位)。 -
截取子串
sub = s[start...i]
。 -
合法性判断:
-
长度判断:子串长度不能超过3。
-
前导零判断:如果子串长度大于1,且第一个字符是
'0'
,则无效(例如 "01")。 -
数值范围判断:将子串转换为整数,判断是否在
0
到255
之间。
-
-
-
递归调用: 如果当前切割的子串合法,则:
-
将子串添加到
current_ip
中,并根据dots
的数量添加点号。 -
递归调用
backtrack(s, i + 1, dots + 1, ...)
。 -
回溯:递归返回后,撤销当前的选择(移除
current_ip
中添加的部分),以便尝试其他可能性。
-
优化与最终代码 (C语言)
由于C语言中字符串操作和动态内存管理相对复杂,这里我们将提供一个详细的C语言实现,并附带详细注释。
#include <stdio.h>
#include <string.h>
#include <stdlib.h> // 用于 malloc, free, atoi
// 定义一个结构体来存储结果,方便管理
typedef struct {
char **ips; // 存储IP地址字符串的数组
int count; // IP地址的数量
int capacity; // 数组的容量
} IPAddressesResult;
// 辅助函数:判断一个子串是否是有效的IP地址段 (0-255)
// @param s 原始字符串
// @param start 子串的起始索引
// @param length 子串的长度
// @return int 如果有效返回1,否则返回0
int isValidPart(char *s, int start, int length) {
// 1. 长度校验:IP段长度必须在1到3之间
if (length <= 0 || length > 3) {
return 0;
}
// 2. 前导零校验:如果长度大于1且第一个字符是'0',则无效(如"01", "00")
if (length > 1 && s[start] == '0') {
return 0;
}
// 3. 数值范围校验:将子串转换为整数,判断是否在0-255之间
int num = 0;
for (int i = 0; i < length; i++) {
// 字符必须是数字
if (s[start + i] < '0' || s[start + i] > '9') {
return 0;
}
num = num * 10 + (s[start + i] - '0');
}
// 检查数值范围
return num >= 0 && num <= 255;
}
// 回溯函数:递归地生成所有可能的IP地址
// @param s 原始数字字符串
// @param start 当前处理的起始索引
// @param dots 已经添加的点号数量(即已经分割出的段数)
// @param current_ip_buf 当前构建的IP地址缓冲区
// @param current_ip_len 当前IP地址缓冲区的长度
// @param result 存储所有有效IP地址的结果结构体指针
// @param s_len 原始字符串的长度
void backtrackIP(char *s, int start, int dots, char *current_ip_buf, int current_ip_len, IPAddressesResult *result, int s_len) {
// 递归终止条件
if (dots == 4) { // 已经分割出4段
// 如果所有字符都已用完,则找到一个有效IP地址
if (start == s_len) {
// 确保缓冲区以空字符结尾,并添加点号
current_ip_buf[current_ip_len - 1] = '\0'; // 移除最后一个多余的点
// 动态分配内存存储找到的IP地址
if (result->count == result->capacity) {
// 扩容
result->capacity *= 2;
result->ips = (char **)realloc(result->ips, result->capacity * sizeof(char *));
if (result->ips == NULL) {
// 内存分配失败处理
fprintf(stderr, "Memory realloc failed in backtrackIP (result->ips)\n");
exit(EXIT_FAILURE);
}
}
result->ips[result->count] = (char *)malloc(current_ip_len * sizeof(char));
if (result->ips[result->count] == NULL) {
// 内存分配失败处理
fprintf(stderr, "Memory malloc failed in backtrackIP (result->ips[count])\n");
exit(EXIT_FAILURE);
}
strcpy(result->ips[result->count], current_ip_buf);
result->count++;
}
return; // 无论是否有效,都返回,不再继续深层递归
}
// 剪枝条件:剩余字符不足以构成剩余的段,或者剩余字符过多
// 剩余字符:s_len - start
// 剩余段数:4 - dots
// 最小所需字符:(4 - dots) * 1
// 最大所需字符:(4 - dots) * 3
if (s_len - start < (4 - dots) || s_len - start > (4 - dots) * 3) {
return;
}
// 尝试切割出1位、2位或3位的数字作为当前段
for (int length = 1; length <= 3; length++) {
// 确保子串不会越界
if (start + length > s_len) {
break; // 超出字符串范围
}
// 判断当前切割的子串是否是有效的IP地址段
if (isValidPart(s, start, length)) {
// 记录当前IP地址缓冲区的使用长度,以便回溯
int prev_current_ip_len = current_ip_len;
// 将当前段复制到缓冲区
strncpy(current_ip_buf + current_ip_len, s + start, length);
current_ip_len += length;
// 如果不是最后一段,添加点号
if (dots < 3) {
current_ip_buf[current_ip_len] = '.';
current_ip_len++;
}
// 递归调用,处理下一段
backtrackIP(s, start + length, dots + 1, current_ip_buf, current_ip_len, result, s_len);
// 回溯:恢复缓冲区到之前的状态,以便尝试其他切割方案
current_ip_len = prev_current_ip_len;
// 不需要清空缓冲区,因为下次写入会覆盖
}
}
}
/**
* @brief 将数字字符串转化为所有可能的有效IP地址。
*
* 本函数使用回溯(Backtracking)算法来生成所有有效的IP地址组合。
*
* @param s 只包含数字的输入字符串
* @param returnSize 指向一个整数的指针,用于返回结果数组的大小
* @return char** 指向一个字符串数组的指针,每个字符串是一个有效的IP地址。
* 调用者负责释放返回的内存。
*/
char **restoreIpAddresses(char *s, int *returnSize) {
// 1. 输入校验
if (s == NULL) {
*returnSize = 0;
return NULL;
}
int s_len = strlen(s);
// IP地址的最小长度是4(1.1.1.1),最大长度是12(255.255.255.255)
if (s_len < 4 || s_len > 12) {
*returnSize = 0;
return NULL;
}
// 2. 初始化结果结构体
IPAddressesResult result;
result.count = 0;
result.capacity = 10; // 初始容量,会根据需要扩容
result.ips = (char **)malloc(result.capacity * sizeof(char *));
if (result.ips == NULL) {
*returnSize = 0;
return NULL; // 内存分配失败
}
// 3. 初始化IP地址缓冲区
// IP地址最长为 3*4 + 3 (点号) + 1 (空字符) = 16
char current_ip_buf[17]; // 足够大的缓冲区
current_ip_buf[0] = '\0'; // 确保初始化为空字符串
// 4. 开始回溯
backtrackIP(s, 0, 0, current_ip_buf, 0, &result, s_len);
// 5. 设置返回大小并返回结果
*returnSize = result.count;
// 如果没有找到任何IP地址,或者分配的内存多余,可以realloc缩小
if (result.count == 0) {
free(result.ips);
return NULL; // 没有找到IP地址
}
// 缩小到实际大小,避免内存浪费
result.ips = (char **)realloc(result.ips, result.count * sizeof(char *));
if (result.ips == NULL) {
// realloc 失败,但之前的部分可能仍然有效,这里简单处理为返回NULL
// 实际应用中可能需要更复杂的错误处理
*returnSize = 0;
return NULL;
}
return result.ips;
}
/*
// 示例用法 (可以放在main函数中测试)
int main() {
char *s1 = "25525511135";
int returnSize1;
char **ips1 = restoreIpAddresses(s1, &returnSize1);
printf("字符串 \"%s\" 的有效IP地址:\n", s1);
for (int i = 0; i < returnSize1; i++) {
printf(" %s\n", ips1[i]);
free(ips1[i]); // 释放每个IP字符串的内存
}
if (ips1 != NULL) {
free(ips1); // 释放存储IP字符串指针的数组的内存
}
printf("--------------------\n");
char *s2 = "010010";
int returnSize2;
char **ips2 = restoreIpAddresses(s2, &returnSize2);
printf("字符串 \"%s\" 的有效IP地址:\n", s2);
for (int i = 0; i < returnSize2; i++) {
printf(" %s\n", ips2[i]);
free(ips2[i]);
}
if (ips2 != NULL) {
free(ips2);
}
printf("--------------------\n");
char *s3 = "101023";
int returnSize3;
char **ips3 = restoreIpAddresses(s3, &returnSize3);
printf("字符串 \"%s\" 的有效IP地址:\n", s3);
for (int i = 0; i < returnSize3; i++) {
printf(" %s\n", ips3[i]);
free(ips3[i]);
}
if (ips3 != NULL) {
free(ips3);
}
printf("--------------------\n");
char *s4 = "1";
int returnSize4;
char **ips4 = restoreIpAddresses(s4, &returnSize4);
printf("字符串 \"%s\" 的有效IP地址:\n", s4);
if (ips4 == NULL) {
printf(" 无\n");
} else {
for (int i = 0; i < returnSize4; i++) {
printf(" %s\n", ips4[i]);
free(ips4[i]);
}
free(ips4);
}
printf("--------------------\n");
char *s5 = "19216811";
int returnSize5;
char **ips5 = restoreIpAddresses(s5, &returnSize5);
printf("字符串 \"%s\" 的有效IP地址:\n", s5);
for (int i = 0; i < returnSize5; i++) {
printf(" %s\n", ips5[i]);
free(ips5[i]);
}
if (ips5 != NULL) {
free(ips5);
}
printf("--------------------\n");
return 0;
}
*/
代码分析与总结:
特性 |
描述 |
优点 |
注意事项/改进空间 |
---|---|---|---|
回溯算法 |
采用递归加回溯的方式遍历所有可能的IP地址分割方案。 |
能够找到所有满足条件的解,逻辑清晰。 |
递归深度可能较大,需要注意栈空间。 |
|
封装了IP地址段的合法性判断逻辑(长度、前导零、数值范围)。 |
提高了代码模块化和可读性,避免重复代码。 |
无。 |
剪枝优化 |
在递归过程中,通过判断剩余字符数量与所需段数的关系进行剪枝。 |
大幅减少了不必要的递归调用,提高了算法效率。 |
剪枝条件需要仔细推敲,确保正确性。 |
动态内存管理 |
使用 |
能够处理不同数量的IP地址结果,避免固定大小数组的限制。 |
C语言中内存管理复杂,容易出错,需要非常小心地处理内存分配和释放。 |
|
封装了结果数组、数量和容量,方便统一管理。 |
提高了代码的组织性和可维护性。 |
无。 |
错误处理 |
对 |
提高了程序的健壮性。 |
错误处理可以更精细,例如在内存分配失败时,可以尝试释放已分配的部分。 |
嵌入式开发中的考量:
在嵌入式开发中,由于资源(内存、栈空间)通常比较有限,对于这种可能涉及大量动态内存分配和深层递归的算法,需要特别谨慎:
-
栈溢出:如果输入字符串很长,递归深度可能很大,导致栈溢出。可以考虑将递归改为迭代,或者使用显式栈来模拟递归。
-
内存碎片:频繁的
malloc
和free
可能导致内存碎片,尤其是在长时间运行的系统中。如果IP地址的数量可以预估,可以预先分配一个足够大的内存块,然后手动管理。 -
性能:字符串操作(
strncpy
、strlen
、atoi
的内部实现)可能带来一定的性能开销。在对性能要求极高的场景,可能需要更底层的位操作或自定义解析函数来优化。
11 经典问题剖析:编辑距离 (Edit Distance)
“编辑距离”问题是另一个经典的二维DP问题,它在自然语言处理、生物信息学(DNA序列比对)等领域有广泛应用。它考察的是将一个字符串转换成另一个字符串所需的最少操作次数(插入、删除、替换)。
问题描述
给定两个单词 word1
和 word2
,计算将 word1
转换成 word2
所使用的最少操作数。 你可以对一个单词进行如下三种操作:
-
插入一个字符
-
删除一个字符
-
替换一个字符
解题思路与原理分析
核心思想:二维动态规划
我们可以定义一个二维 dp
数组来存储子问题的解。
1. DP 状态定义:
dp[i][j]
:表示将 word1
的前 i
个字符(word1[0...i-1]
)转换成 word2
的前 j
个字符(word2[0...j-1]
)所需的最少操作数。
2. DP 状态转移方程:
考虑 dp[i][j]
,它取决于以下三种可能的操作:
-
插入操作: 如果我们将
word1
的前i
个字符转换成word2
的前j-1
个字符,然后对word2
的第j
个字符进行插入操作,使其与word2[j-1]
匹配。 所需操作数:dp[i][j-1] + 1
-
删除操作: 如果我们将
word1
的前i-1
个字符转换成word2
的前j
个字符,然后对word1
的第i
个字符进行删除操作。 所需操作数:dp[i-1][j] + 1
-
替换操作: 如果
word1[i-1]
和word2[j-1]
不相等,我们需要将word1[i-1]
替换为word2[j-1]
。 所需操作数:dp[i-1][j-1] + 1
如果word1[i-1]
和word2[j-1]
相等,则不需要替换,直接继承dp[i-1][j-1]
的值。 所需操作数:dp[i-1][j-1]
综合以上,状态转移方程为:
-
如果
word1[i-1] == word2[j-1]
:dp[i][j] = min(dp[i-1][j-1], dp[i-1][j] + 1, dp[i][j-1] + 1)
这里dp[i-1][j-1]
意味着当前字符匹配,不需要额外操作,直接继承前一个状态。 -
如果
word1[i-1] != word2[j-1]
:dp[i][j] = min(dp[i-1][j-1] + 1, dp[i-1][j] + 1, dp[i][j-1] + 1)
这里dp[i-1][j-1] + 1
意味着当前字符不匹配,需要替换操作。
3. 初始化:
-
dp[0][0] = 0
:空字符串转换成空字符串需要0次操作。 -
dp[i][0] = i
:将word1
的前i
个字符转换成空字符串,需要i
次删除操作。 -
dp[0][j] = j
:将空字符串转换成word2
的前j
个字符,需要j
次插入操作。
4. 计算顺序:
从 i = 0
到 len1
,从 j = 0
到 len2
,双重循环遍历。
DP 状态表(以 word1 = "horse"
, word2 = "ros"
为例):
""(0) |
r(1) |
o(2) |
s(3) | |
---|---|---|---|---|
""(0) |
0 |
1 |
2 |
3 |
h(1) |
1 |
1 |
2 |
3 |
o(2) |
2 |
2 |
1 |
2 |
r(3) |
3 |
2 |
2 |
2 |
s(4) |
4 |
3 |
3 |
2 |
e(5) |
5 |
4 |
4 |
3 |
解释:
-
dp[1][1]
(h -> r):min(dp[0][0]+1, dp[0][1]+1, dp[1][0]+1) = min(0+1, 1+1, 1+1) = 1
-
dp[2][2]
(ho -> ro):word1[1]='o'
,word2[1]='o'
匹配。min(dp[1][1], dp[1][2]+1, dp[2][1]+1) = min(1, 2+1, 2+1) = 1
(将 "h" 替换为 "r" 1次,然后 "o" 匹配,所以 "ho" -> "ro" 也是1次操作) -
最终
dp[5][3]
为 3。
优化与最终代码 (C语言)
#include <stdio.h>
#include <string.h>
#include <stdlib.h> // 用于 malloc, free
// 辅助函数:返回三个整数中的最小值
int getMinOfThree(int a, int b, int c) {
int temp = (a < b) ? a : b;
return (temp < c) ? temp : c;
}
/**
* @brief 计算将 word1 转换成 word2 所使用的最少操作数(编辑距离)。
*
* 本函数使用二维动态规划(Dynamic Programming, DP)来解决编辑距离问题。
* dp[i][j] 表示将 word1 的前 i 个字符转换成 word2 的前 j 个字符所需的最少操作数。
*
* @param word1 第一个输入字符串
* @param word2 第二个输入字符串
* @return int 最少操作数。如果输入为NULL,返回-1表示错误。
*/
int minDistance(char *word1, char *word2) {
// 1. 输入校验
if (word1 == NULL || word2 == NULL) {
return -1; // 无效输入
}
int len1 = strlen(word1);
int len2 = strlen(word2);
// 2. DP 数组定义与初始化
// dp[i][j] 表示 word1 的前 i 个字符到 word2 的前 j 个字符的编辑距离
// 数组大小为 (len1 + 1) x (len2 + 1)
int **dp = (int **)malloc((len1 + 1) * sizeof(int *));
if (dp == NULL) {
return -1; // 内存分配失败
}
for (int i = 0; i <= len1; i++) {
dp[i] = (int *)malloc((len2 + 1) * sizeof(int));
if (dp[i] == NULL) {
// 内存分配失败,释放之前已分配的内存
for (int k = 0; k < i; k++) {
free(dp[k]);
}
free(dp);
return -1;
}
}
// 初始化边界条件
// dp[i][0]:word1 的前 i 个字符转换成空字符串,需要 i 次删除操作
for (int i = 0; i <= len1; i++) {
dp[i][0] = i;
}
// dp[0][j]:空字符串转换成 word2 的前 j 个字符,需要 j 次插入操作
for (int j = 0; j <= len2; j++) {
dp[0][j] = j;
}
// 3. 遍历填充 DP 数组
for (int i = 1; i <= len1; i++) {
for (int j = 1; j <= len2; j++) {
// 如果当前字符匹配
if (word1[i - 1] == word2[j - 1]) {
// dp[i-1][j-1]:直接继承前一个状态,因为字符匹配,无需操作
// dp[i-1][j] + 1:删除 word1[i-1]
// dp[i][j-1] + 1:插入 word2[j-1]
dp[i][j] = getMinOfThree(dp[i - 1][j - 1], dp[i - 1][j] + 1, dp[i][j - 1] + 1);
} else {
// 如果当前字符不匹配
// dp[i-1][j-1] + 1:替换 word1[i-1] 为 word2[j-1]
// dp[i-1][j] + 1:删除 word1[i-1]
// dp[i][j-1] + 1:插入 word2[j-1]
dp[i][j] = getMinOfThree(dp[i - 1][j - 1] + 1, dp[i - 1][j] + 1, dp[i][j - 1] + 1);
}
}
}
// 4. 获取最终结果并释放内存
int result = dp[len1][len2];
for (int i = 0; i <= len1; i++) {
free(dp[i]);
}
free(dp);
return result;
}
/*
// 示例用法 (可以放在main函数中测试)
int main() {
char *w1_1 = "horse";
char *w2_1 = "ros";
printf("编辑距离 (\"%s\", \"%s\"): %d\n", w1_1, w2_1, minDistance(w1_1, w2_1)); // 预期输出: 3
char *w1_2 = "intention";
char *w2_2 = "execution";
printf("编辑距离 (\"%s\", \"%s\"): %d\n", w1_2, w2_2, minDistance(w1_2, w2_2)); // 预期输出: 5
char *w1_3 = "";
char *w2_3 = "abc";
printf("编辑距离 (\"%s\", \"%s\"): %d\n", w1_3, w2_3, minDistance(w1_3, w2_3)); // 预期输出: 3
char *w1_4 = "abc";
char *w2_4 = "";
printf("编辑距离 (\"%s\", \"%s\"): %d\n", w1_4, w2_4, minDistance(w1_4, w2_4)); // 预期输出: 3
char *w1_5 = "a";
char *w2_5 = "b";
printf("编辑距离 (\"%s\", \"%s\"): %d\n", w1_5, w2_5, minDistance(w1_5, w2_5)); // 预期输出: 1
char *w1_6 = "ab";
char *w2_6 = "ab";
printf("编辑距离 (\"%s\", \"%s\"): %d\n", w1_6, w2_6, minDistance(w1_6, w2_6)); // 预期输出: 0
return 0;
}
*/
代码分析与总结:
特性 |
描述 |
优点 |
注意事项/改进空间 |
---|---|---|---|
二维DP数组 |
|
能够系统地解决问题,避免重复计算。 |
空间复杂度为 O(MtimesN),对于长字符串可能占用大量内存。 |
状态转移方程 |
准确地考虑了插入、删除、替换三种操作,并根据字符是否匹配进行区分。 |
逻辑严谨,保证了结果的正确性。 |
理解起来可能需要一些时间,特别是三种操作的含义。 |
初始化边界 |
正确地初始化了 |
确保了DP计算的起点正确。 |
无。 |
动态内存管理 |
使用 |
能够处理任意长度的字符串,避免栈溢出。 |
C语言中内存管理复杂,容易出错,需要非常小心地处理内存分配和释放。 |
|
封装了求三个数最小值的逻辑。 |
提高了代码的可读性。 |
无。 |
嵌入式开发中的考量:
-
内存占用:编辑距离的DP表需要 O(MtimesN) 的空间,其中 M 和 N 是两个字符串的长度。对于长字符串,这可能会消耗大量内存。
-
优化思路:如果只关心编辑距离的最终值,可以考虑空间优化,将二维DP数组降为 O(min(M,N)) 甚至 O(1)。例如,
dp[i][j]
只依赖于dp[i-1][j-1]
,dp[i-1][j]
,dp[i][j-1]
,这意味着我们只需要保留前一行和当前行的信息。
-
-
计算时间:时间复杂度为 O(MtimesN)。对于非常长的字符串,计算时间可能会很长。在实时性要求高的嵌入式系统中,可能需要考虑更快的算法(例如,如果允许近似解,可以考虑启发式算法)。
12 总结与展望:DP,嵌入式工程师的“内功”!
兄弟们,经过这四部分的“DP硬核之路”,我们共同学习了动态规划的核心思想,并通过8个经典的DP问题,从字符串、数组、序列等不同维度,深入剖析了DP的状态定义、状态转移方程、初始化、边界条件以及优化技巧。
DP问题 |
核心思想 |
DP状态定义 |
状态转移方程 |
关键优化/技巧 |
---|---|---|---|---|
最长公共子串 |
连续子序列 |
|
|
记录 |
数字翻译成字符串 |
组合计数 |
|
|
处理 '0' 和两位数范围 |
兑换零钱 |
最优解 |
|
|
初始化为“无穷大”,外层硬币,内层金额 |
最长回文子串 |
中心对称 |
(中心扩展法) |
从中心向两边扩展 |
奇偶长度回文处理, |
最长上升子序列 |
子序列最优解 |
|
|
遍历 |
打家劫舍 (线性) |
决策选择 |
|
|
空间优化到 O(1) |
打家劫舍 (环形) |
问题分解 |
(复用线性DP) |
分解为两个线性问题: |
复用带范围的线性DP函数,避免内存复制 |
字符串转化IP地址 |
递归分割 |
(回溯树的节点状态) |
尝试分割1、2、3位,判断合法性,递归 |
剪枝,动态内存管理,C语言字符串操作细节 |
编辑距离 |
序列比对 |
|
|
二维DP表,初始化边界,考虑空间优化 |
为什么嵌入式工程师要学好DP?
-
资源受限:嵌入式系统往往内存、CPU资源有限。DP通过避免重复计算,能够将指数级时间复杂度的问题降到多项式级别,这对于有限资源的系统至关重要。
-
效率优化:DP算法通常比暴力搜索更高效,能够提供更快的响应时间和更低的功耗。
-
系统设计:很多嵌入式应用,如路径规划、任务调度、数据压缩等,其底层算法都可能涉及到DP思想。
-
面试硬核:DP是衡量一个程序员算法功底的重要标准,掌握它能让你在面试中脱颖而出。
我的DP学习心得和建议:
-
多刷多想:DP没有捷径,就是多刷题,多总结。每次做完一道题,不要急着看答案,先自己思考状态定义和转移方程。
-
画图辅助:对于二维DP,画出DP表,一步步推导,能帮助你更好地理解状态转移。
-
理解本质:不要死记硬背公式,要理解每个DP问题的核心思想:重叠子问题和最优子结构。
-
关注边界:DP的初始化和边界条件往往是容易出错的地方,务必仔细推敲。
-
空间优化:在嵌入式场景下,时刻思考如何将DP的空间复杂度降低。
-
C语言实践:用C语言实现DP,能让你更深入地理解内存、指针和数据结构,这对于嵌入式开发是宝贵的经验。
兄弟们,DP之路漫漫,但只要你坚持不懈,勤于思考勇于实践,就一定能掌握这门“内功”,在嵌入式开发的道路上越走越远,拿到心仪的Offer!
这篇博客,是我个人学习DP、备战嵌入式求职的真实记录。如果它能给你带来一点点启发,哪怕只是让你对DP不再那么恐惧,我就心满意足了!
如果你喜欢这篇文章,觉得对你有帮助,请务必点赞、收藏、转发!
感谢相伴 我们江湖再见!
----------------------review in 2025.7.28