Leetcode 150 最小路径和 | 最长回文子串

1 题目

64. 最小路径和

给定一个包含非负整数的 m xn 网格 grid ,请找出一条从左上角到右下角的路径,使得路径上的数字总和为最小。

**说明:**每次只能向下或者向右移动一步。

示例 1:

复制代码
输入:grid = [[1,3,1],[1,5,1],[4,2,1]]
输出:7
解释:因为路径 1→3→1→1→1 的总和最小。

示例 2:

复制代码
输入:grid = [[1,2,3],[4,5,6]]
输出:12

提示:

  • m == grid.length
  • n == grid[i].length
  • 1 <= m, n <= 200
  • 0 <= grid[i][j] <= 20

2 代码实现

c++

cpp 复制代码
class Solution {
public:
    int minPathSum(vector<vector<int>>& grid) {
        int m = grid.size();
        int n = grid[0].size();

        vector<vector<int>> dp (m , vector<int>(n));

        dp[0][0] = grid[0][0];

        for (int i = 1 ; i < m ; i ++){
            dp[i][0] = dp[i - 1][0] + grid[i][0];
        }

        for (int j = 1 ; j < n ; j++){
            dp[0][j] = dp[0][j -1 ] + grid[0][j];
        }

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

js

javascript 复制代码
/**
 * @param {number[][]} grid
 * @return {number}
 */
var minPathSum = function(grid) {
    const m = grid.length ;
    const n = grid[0].length ;

    const dp = new Array(m);
    for (let i = 0 ; i < m ; i ++){
        dp[i] = new Array(n);
    }

    dp[0][0] = grid[0][0];

    for (let i = 1 ; i < m  ; i ++){
        dp[i][0] = dp[i - 1][0] + grid[i][0];
    }

    for (let j = 1 ; j < n ; j ++){
        dp[0][j] = dp[0][j - 1] + grid[0][j];
    }

    for (let i = 1 ; i < m ; i++){
        for (let j = 1 ; j < n ; j ++){
            dp[i][j] = Math.min(dp[i -1 ][j] , dp[i][j -1 ]) + grid[i][j];
        }
    }

    return dp[m - 1][n - 1];
};

思考

蛤?这为何多维动态规划,这该怎么做,我以为是DFS呢,不过按照已有的情况,应该是在维护一个dp数组,每次走的时候看看是下还是右比较大,不对,并不是每次都挑最小的走就行了,万一是在99和98里面挑呢,前面的都是1,这该怎么做??!不会啊!

我理解题解了,只不过是图的形式,二维数组我可以的。

题解

我完全懂你的困惑!你一开始想每次选最小的走 (贪心),但这绝对是错的 !贪心只看眼前一步,DP 看的是全局最优,这就是这道题必须用动态规划的原因。

我用最简单、最直白、最容易理解的 DP 数组思路,一步一步带你吃透。


一、先搞懂核心:为什么不能贪心?

看示例 1:

复制代码
1  3  1
1  5  1
4  2  1

如果贪心:起点 1 → 右边 3(小)→ 右边 1 → 下边 1 → 下边 1,总和 7(刚好对)。

换个例子:

复制代码
1  100
99 1

贪心:1 → 右 100(比下 99 大!)→ 下 1,总和 102 ❌正确:1 → 下 99 → 右 1,总和 101 ✅

结论 :不能只看当前一步选最小,必须看走到当前位置的所有路径里,总和最小的那个 → 这就是 DP!


二、动态规划核心三要素(死记硬背都能做)

1. 定义 DP 数组(最关键)

dp[i][j]从左上角 (0,0) 走到 位置 (i,j) 的最小路径和

这就是我们要维护的数组,最终答案就是 dp[m-1][n-1](右下角)。

2. 状态转移方程(怎么算)

题目规定:只能向右 或 向下走 所以,走到 (i,j) 只有 2 种来源:

  1. 上边 (i-1,j) 走下来
  2. 左边 (i,j-1) 走过来

我们要最小和,所以选两个来源里更小的那个,加上当前格子的值:

cpp 复制代码
dp[i][j] = min(dp[i-1][j], dp[i][j-1]) + grid[i][j];

3. 初始化(边界条件)

  • 第一行:只能从左边一直走过来,没有上边
  • 第一列:只能从上边一直走下来,没有左边

三、完整解题步骤(手绘逻辑)

以示例 1:grid = [[1,3,1],[1,5,1],[4,2,1]]

第一步:创建和 grid 一样大的 dp 数组

3 行 3 列,初始全空。

第二步:初始化起点

dp[0][0] = grid[0][0] = 1

第三步:初始化第一行

只能从左来:dp[0][1] = dp[0][0] + 3 = 4``dp[0][2] = dp[0][1] + 1 = 5

第四步:初始化第一列

只能从上来:dp[1][0] = dp[0][0] + 1 = 2``dp[2][0] = dp[1][0] + 4 = 6

第五步:填充剩余所有位置

  • dp[1][1] = min(dp[0][1], dp[1][0]) +5 = min(4,2)+5=7
  • dp[1][2] = min(dp[0][2], dp[1][1]) +1 = min(5,7)+1=6
  • dp[2][1] = min(dp[1][1], dp[2][0]) +2 = min(7,6)+2=8
  • dp[2][2] = min(dp[1][2], dp[2][1]) +1 = min(6,8)+1=7

✅ 最终答案:7,完美匹配示例!


四、C++ 代码实现(最简版,直接抄)

我写了最容易理解的版本,没有任何优化,纯 DP 数组,注释拉满:

cpp 复制代码
#include <iostream>
#include <vector>
#include <algorithm> // 用 min 函数
using namespace std;

class Solution {
public:
    int minPathSum(vector<vector<int>>& grid) {
        // 1. 获取网格的行数和列数
        int m = grid.size();
        int n = grid[0].size();
        
        // 2. 创建 dp 数组,大小和 grid 完全一样
        vector<vector<int>> dp(m, vector<int>(n));
        
        // 3. 初始化起点:左上角
        dp[0][0] = grid[0][0];
        
        // 4. 初始化第一行:只能从左边来
        for (int j = 1; j < n; j++) {
            dp[0][j] = dp[0][j-1] + grid[0][j];
        }
        
        // 5. 初始化第一列:只能从上边来
        for (int i = 1; i < m; i++) {
            dp[i][0] = dp[i-1][0] + grid[i][0];
        }
        
        // 6. 填充剩下的所有位置(核心状态转移)
        for (int i = 1; i < m; i++) {
            for (int j = 1; j < n; j++) {
                // 选 上边 / 左边 最小的 + 当前值
                dp[i][j] = min(dp[i-1][j], dp[i][j-1]) + grid[i][j];
            }
        }
        
        // 7. 返回右下角的值,就是答案
        return dp[m-1][n-1];
    }
};

// 测试代码
int main() {
    vector<vector<int>> grid = {{1,3,1},{1,5,1},{4,2,1}};
    Solution s;
    cout << s.minPathSum(grid) << endl; // 输出 7
    return 0;
}

五、代码逐行解释(小白必看)

  1. 获取行列m 是行数,n 是列数
  2. 创建 dp 数组 :和输入网格一样大,专门存最小路径和
  3. 起点初始化:起点的最小和就是它自己
  4. 第一行初始化:只能向右走,累加左边的值
  5. 第一列初始化:只能向下走,累加上边的值
  6. 核心循环
    • 遍历每一个格子
    • 上边左边哪个和更小
    • 加上当前格子的值,就是当前位置的最小和
  7. 返回结果:右下角就是从起点到终点的最小和

六、这个思路为什么是对的?

  • 我们没有做任何贪心选择,没有只看当前一步
  • 每个位置都存了走到这里的全局最优解
  • 后面的位置只依赖前面已经算好的最优解
  • 时间复杂度:O(m*n)(遍历一遍网格,最快)
  • 空间复杂度:O(m*n)(用了一个 dp 数组,最简单)

总结

  1. DP 数组定义dp[i][j] = 走到 (i,j) 的最小路径和
  2. 转移方程dp[i][j] = min(上边, 左边) + 当前值
  3. 初始化:第一行、第一列只能单向走,直接累加
  4. 结果:dp 数组右下角的值

3 题目

5. 最长回文子串

给你一个字符串 s,找到 s 中最长的 回文 子串。

示例 1:

复制代码
输入:s = "babad"
输出:"bab"
解释:"aba" 同样是符合题意的答案。

示例 2:

复制代码
输入:s = "cbbd"
输出:"bb"

提示:

  • 1 <= s.length <= 1000
  • s 仅由数字和英文字母组成

4 代码实现

c++

cpp 复制代码
class Solution {
public:
    string longestPalindrome(string s) {
        int n = s.size();
        if (n <= 1 ) return s ;

        vector<vector<bool>> dp (n , vector<bool>(n,false));

        int maxLen = 1 ;
        int start = 0 ;

        for (int i = 0 ; i < n ; i++){
            dp[i][i] = true ;
        }

        for (int l = 2 ; l <= n ; l++){
            for (int i = 0 ; i < n ; i++){
                int j = i + l - 1;
                if ( j >= n ) break ;

                if (s[i] == s[j]){
                    if (j == i + 1){
                        dp[i][j] = true ;
                    }else {
                        dp[i][j] = dp[i + 1][j - 1];
                    }
                }else{
                    dp[i][j] = false ;
                }
                if (dp[i][j] && l > maxLen){
                    maxLen = l ;
                    start = i ;
                }
            }
        }
        return s.substr(start,maxLen);
    }
};

js

javascript 复制代码
/**
 * @param {string} s
 * @return {string}
 */
var longestPalindrome = function(s) {
    const len = s.length ;

    if (len <= 1 ) return s ;

    const dp = new Array(len);

    for (let i = 0 ; i < len ; i++){
        dp[i] = new Array(len).fill(false);
    }

    for (let i = 0 ; i < len ; i++){
        dp[i][i] = true ;
    }

    let maxLen = 1 ;
    let start = 0 ;

    for (let l = 2 ; l <= len ; l ++ ){
        for (let i = 0 ; i < len ; i++){
            const j = i + l - 1 ;
            if (j >= len) break ;

            if (s[i] === s[j]){
                if (j - i === 1){
                    dp[i][j] = true ;
                }else {
                    dp[i][j] = dp [i + 1][j -1 ];
                }
            }

            if (dp[i][j] && l > maxLen ){
                maxLen = l ;
                start = i ;
            }
        }
    }
    return s.substring(start , start + maxLen);
};

思考

我尝试冷静下来思考。。。

这应该如何做,我首先想到的是双指针,啊,这是啥意思哦。。。

dp数组,首先,回文的基础上是如何维护的,如何维护又一个,呃,这怎么办,不知道,感觉不是之前很简单判断向哪个方向走就可以了,而且这里要维护长度。

不会做呃没想到多为动规的思路。

题解

一、先搞懂:什么是回文?

回文:正读和反读一模一样 比如:ababbaccacc

核心规律:如果一个字符串首尾两个字符相等 ,并且中间的子串也是回文 ,那它整体就是回文!例:abba

  • 首尾 a == a
  • 中间 bb 是回文→ 所以 abba 是回文

二、DP 动态规划 三要素(死记就能做)

1. DP 数组定义(最关键)

dp[i][j]布尔值 ,表示字符串 s 从下标 i 到 j 的子串,是不是回文

  • true = 是回文
  • false = 不是回文

2. 状态转移方程(核心公式)

  1. 如果 s [i] == s [j](首尾相等)

    • 情况 1:子串长度 ≤2(ij 重合 / 相邻)→ 一定是回文 例:abb
    • 情况 2:子串长度 >2 → 看中间的子串是不是回文 即:dp[i][j] = dp[i+1][j-1]
  2. 如果 s [i] != s [j] → 直接不是回文:dp[i][j] = false

最终公式:

js

复制代码
if (s[i] === s[j]) {
  // 长度<=2 必然是回文;长度>2 看中间
  dp[i][j] = (j - i <= 2) || dp[i+1][j-1];
}

3. 初始化

所有单个字符都是回文:dp[i][i] = true


三、解题思路(超直白)

  1. 建一个二维 DP 数组,记录所有子串是否为回文
  2. 从小到大遍历子串长度,判断每个子串是不是回文
  3. 记录最长的那个回文子串

四、完整 JS 代码(最简 DP 版,直接抄)

javascript

运行

复制代码
/**
 * @param {string} s
 * @return {string}
 */
var longestPalindrome = function(s) {
    const len = s.length;
    // 特殊情况:长度为1直接返回
    if (len <= 1) return s;

    // 1. 创建 dp 二维数组,len x len 大小
    const dp = new Array(len);
    for (let i = 0; i < len; i++) {
        dp[i] = new Array(len).fill(false);
    }

    // 2. 初始化:单个字符一定是回文
    for (let i = 0; i < len; i++) {
        dp[i][i] = true;
    }

    // 记录最长回文的起始下标 和 最大长度
    let maxLen = 1;
    let start = 0;

    // 3. 核心:遍历所有子串(从长度2开始)
    // l 代表子串长度
    for (let l = 2; l <= len; l++) {
        // i 是子串起始下标
        for (let i = 0; i < len; i++) {
            // j 是子串结束下标
            const j = i + l - 1;
            // 越界直接退出
            if (j >= len) break;

            if (s[i] === s[j]) {
                // 长度2:必然是回文
                if (j - i === 1) {
                    dp[i][j] = true;
                } 
                // 长度>2:看中间子串
                else {
                    dp[i][j] = dp[i+1][j-1];
                }
            }

            // 4. 如果当前是回文,更新最长回文
            if (dp[i][j] && l > maxLen) {
                maxLen = l;
                start = i;
            }
        }
    }

    // 5. 截取最长回文并返回
    return s.substring(start, start + maxLen);
};

五、代码逐行解释(小白必看)

  1. 特殊判断:字符串长度为 1,本身就是最长回文
  2. 创建 DP 数组len x len 的布尔数组,存是否为回文
  3. 初始化 :所有单个字符 dp[i][i]=true
  4. 遍历子串
    • l:子串长度(从 2 开始,因为 1 已经初始化)
    • i:子串起点,j:子串终点
  5. 判断回文
    • 首尾相等 + 中间是回文 → 当前子串是回文
  6. 更新最长:发现更长的回文,记录起点和长度
  7. 返回结果:截取最长回文子串

六、测试示例

javascript

运行

复制代码
// 示例1
console.log(longestPalindrome("babad")); // 输出 "bab" 或 "aba"

// 示例2
console.log(longestPalindrome("cbbd"));  // 输出 "bb"

七、和上一题(最小路径和)的区别

  • 最小路径和:dp[i][j]数字(最小和)
  • 最长回文子串:dp[i][j]布尔值(是否回文)
  • 核心逻辑:用已知的小问题,推导未知的大问题
  1. DP 数组定义dp[i][j] = 子串 s [i~j] 是否为回文
  2. 核心公式:首尾相等 + 中间是回文 → 整体是回文
  3. 遍历方式:从短子串到长子串
  4. 结果:记录最长的回文子串

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

dp[i][j] 表示:从 i 到 j 的子串是不是回文

如果 s[i] == s[j] (两头相等)那 中间那一段必须是回文,整个串才是回文!

中间那一段就是:i+1 到 j-1

所以:

cpp 复制代码
dp[i][j] = dp[i+1][j-1]

看字符串:a b c b a下标:  0 1 2 3 4

我们想判断:i=0,j=4 → 子串 a b c b a 是不是回文?

步骤:

  1. 看两头:s[0] = as[4] = a相等
  2. 那中间是什么?中间是 i+1=1j-1=3b c b
  3. 如果 中间是回文,那整个串就是回文!

所以:

cpp 复制代码
dp[0][4] = dp[1][3]

再看一个更简单的:a b b a

下标:0 1 2 3

判断 i=0,j=3

  1. s[0]=as[3]=a → 相等
  2. 中间是 i+1=1j-1=2b b
  3. bb 是回文 → 所以整个 abba 是回文
cpp 复制代码
dp[0][3] = dp[1][2]

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

翻译成人话:

两头一样 → 那我只看中间中间是回文 → 我就是回文中间不是 → 我也不是

因为回文的核心规则 就是:一个字符串是回文 = 两头相等 + 中间也是回文

这就是动态规划的核心:用小问题的答案,推出大问题的答案!

cpp 复制代码
子串:a [ b c b ] a
      i i+1   j-1 j

dp[i][j] = 是不是回文?
= 看 dp[i+1][j-1]
  1. dp[i][j] = i 到 j 是不是回文
  2. 两头一样 → 看中间
  3. 中间是 i+1 ~ j-1 → 所以 dp[i][j] = dp[i+1][j-1]

5 小结

好蠢好蠢,多学多练,主要是细节,还有思路上都是类似的,有点高中数列的味道,靠小的推大的。

加油!!还有二维数组的js写法!!要非常注意!!

相关推荐
进击的荆棘2 小时前
C++起始之路——二叉搜索树
数据结构·c++·stl
cjforever142 小时前
各STL容器的模拟实现
开发语言·数据结构·c++
模拟器连接器曾工2 小时前
AI视觉检测设备参数有哪些?从硬件到算法的全面解析
人工智能·算法·视觉检测·ai视觉·ai视觉检测
量子物理学2 小时前
Open CV 边缘检测算法:Canny、Sobel、Scharr与Laplacian对比解析
人工智能·算法·计算机视觉
.柒宇.2 小时前
力扣hot 100之和为 K 的子数组(Java版)
java·算法·leetcode
Byte不洛2 小时前
LeetCode中经典双指针题(环形链表 + 快乐数 + 移动零)
算法·leetcode·链表·数组·双指针
重庆小透明2 小时前
Redis 九大数据结构:从原理到实战场景
数据结构·数据库·redis
Boop_wu2 小时前
[Java 算法] 快速排序和快速选择排序(※)
数据结构·算法·排序算法
人间打气筒(Ada)2 小时前
「码动四季·开源同行」golang:负载均衡如何提高系统可用性?
算法·golang·开源·go·负载均衡·负载均衡算法