4.11

5. 最长回文子串 - 力扣(LeetCode)

前置题:M:647. 回文子串

1.确定dp数组(dp table)以及下标的含义

如果大家做了很多这种子序列相关的题目,在定义dp数组的时候 很自然就会想题目求什么,我们就如何定义dp数组。绝大多数题目确实是这样,不过本题如果我们定义,dp[i] 为 下标i结尾的字符串有 dp[i]个回文串的话,我们会发现很难找到递归关系。

dp[i] dp[i-1]dp[i + 1] 看上去都没啥关系。

所以我们要看回文串的性质。 如图:

image-20250326145350219

我们在判断字符串S是否是回文,那么如果我们知道 s[1],s[2],s[3] 这个子串是回文的,那么只需要比较 s[0]和s[4]这两个元素是否相同,如果相同的话,这个字符串s 就是回文串。

那么此时我们是不是能找到一种递归关系,也就是判断一个子字符串(字符串下标范围[i,j])是否回文依赖于,子字符串(下标范围[i + 1, j - 1])) 是否是回文。

所以为了明确这种递归关系,我们的dp数组是要定义成一位二维dp数组。

布尔类型的dp[i][j]表示区间范围[i,j] (注意是左闭右闭)的子串是否是回文子串,如果是dp[i][j]为true,否则为false。

2.确定递推公式

  1. 当s[i]与s[j]不相等,dp[i][j]一定是false。

  2. 当s[i]与s[j]相等时,这就复杂一些了,有如下三种情况

  • 情况一:下标i 与 j相同,同一个字符例如a,当然是回文子串
  • 情况二:下标i 与 j相差为1,例如aa,也是回文子串
  • 情况三:下标:i 与 j 相差大于1的时候,例如cabac,此时s[i]与s[j]已经相同了,我们看i到j区间是不是回文子串,就看aba是不是回文就可以了,那么aba的区间就是i+1 与 j-1区间,这个区间是不是回文就看dp[i + 1][j - 1]是否为true。

以上三种情况分析完了,那么递归公式如下:

if(s[i] == s[j]) {
 	if(j - i <= 1){ //情况1,2
    res ++;
    dp[i][j] = true;
  }else if(dp[i + 1][j - 1]){//情况3
    res ++;
    dp[i][j] == true;
  }
}

res为返回结果,统计回文子串的数量。

3.dp数组初始化为false,因此s[i]!=s[j]的情况不用考虑

4.遍历顺序

首先从递推公式中可以看出,情况三是根据dp[i + 1][j - 1]是否为true,在对dp[i][j]进行赋值true的。

dp[i + 1][j - 1] dp[i][j]的左下角,如图:

647.回文子串

如果这矩阵是从上到下,从左到右遍历,那么会用到没有计算过的dp[i + 1][j - 1],也就是根据不确定是不是回文的区间[i+1,j-1],来判断了[i,j]是不是回文,那结果一定是不对的。

所以一定要从下到上,从左到右遍历,这样保证dp[i + 1][j - 1]都是经过计算的

有的代码实现是优先遍历列,然后遍历行,其实也是一个道理,都是为了保证dp[i + 1][j - 1]都是经过计算的。

5.举例推导dp数组

举例,输入:"aaa",dp[i][j]状态如下:

647.回文子串1

图中有6个true,所以就是有6个回文子串。

注意因为dp[i][j]的定义,所以j一定是大于等于i的,那么在填充dp[i][j]的时候一定是只填充右上半部分

class Solution {
  public:
      int countSubstrings(string s) {
          int n = s.size();
        vector<vector<int>> dp(n , vector<int>(n , 0));
        int res = 0;

        for (int i = n - 1; i >= 0; i--) {//注意遍历顺序从下到上,j从i开始加保证j >= i
            for (int j = i; j < n; j++) {
              if (s[i] == s[j] && (j - i <= 1 || dp[i + 1][j - 1])) {
                res ++;
                dp[i][j] = true;
             }
            }
        }
        return res;
      }
  };

回到本题:5. 最长回文子串 - 力扣(LeetCode)

法一:DP

一样的dp定义,如果最后返回长度,只需要记录下过程中的max(res = j - i + 1)即可。

本题要求返回串,那么记下s.substr(l , len)的两个参数:左边界l 与 长度len

class Solution {
  public:
      string longestPalindrome(string s) {
          int n = s.size();
          vector<vector<bool>> dp(n , vector<bool>(n , false));
          int res = 0;
          int l;
          for (int i = n - 1 ; i >= 0 ; i --){
            for(int j = i ; j < n ; j ++){
              if(s[i] == s[j] && ((j - i <= 1) || dp[i + 1][j - 1]))
              {
                dp[i][j] = true;
                if(j - i + 1 > res){
                  l = i;
                  res = j - i + 1;
                }
              }
            }
          }
          return s.substr(l , res);
      }
  };
  • 时间复杂度:O(n^2)
  • 空间复杂度:O(n^2)

法二:双指针(优化空间复杂度)

动态规划的空间复杂度是偏高的。

首先确定回文串,就是找中心然后想两边扩散看是不是对称的就可以了。

在遍历中心点的时候,要注意中心点有两种情况

一个元素可以作为中心点,两个元素也可以作为中心点。

class Solution {
public:
   int l = 0 , r = 0 , maxlength = 0;
   string longestPalindrome(string s) {
       int n = s.size();
       auto extend = [&](int i , int j)->void{
           while(i >= 0 && j < n && s[i] == s[j]){//注意这里是while循环
             if(j - i + 1 > maxlength){
               l = i;
               r = j;
               maxlength = j - i + 1;
             }
             i --;
             j ++;
           }
       };
       for (int i = 0; i < n; i++) {
          extend(i , i);  //以i , i 为中心扩散
          extend(i , i + 1);  //以i , i + 1 为中心扩散              
       }
       return s.substr(l , maxlength);
   }
};
  • 时间复杂度:O(n^2)
  • 空间复杂度:O(1)

M:516.最长回文子序列

思路:

回文子串是要连续的,回文子序列可不是连续的!

1.确定dp数组(dp table)以及下标的含义

dp[i][j]:字符串s在[i, j]范围内最长的回文子序列的长度dp[i][j]

2.确定递推公式

如果s[i]与s[j]相同,那么dp[i][j] = dp[i + 1][j - 1] + 2;

516.最长回文子序列

如果s[i]与s[j]不相同,说明s[i]和s[j]的同时加入 并不能增加[i,j]区间回文子序列的长度,那么分别加入s[i]、s[j]看看哪一个可以组成最长的回文子序列。

因为dp[i][j]由状态dp[i + 1][j - 1]转移而来:

  • 加入s[j]的回文子序列长度为dp[i + 1][j]

  • 加入s[i]的回文子序列长度为dp[i][j - 1]

那么dp[i][j]一定是取最大的,即:dp[i][j] = max(dp[i + 1][j], dp[i][j - 1]);

516.最长回文子序列1

代码如下:

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

3.dp数组如何初始化

首先要考虑当i 和j 相同的情况,从递推公式:dp[i][j] = dp[i + 1][j - 1] + 2; 可以看出 递推公式是计算不到 i 和 j 相同时候的情况。

所以需要手动初始化一下,当i与j相同,那么dp[i][j]一定是等于1的,即:一个字符的回文子序列长度就是1。

其他情况dp[i][j]初始为0就行,这样递推公式:dp[i][j] = max(dp[i + 1][j], dp[i][j - 1]); 中dp[i][j]才不会被初始值覆盖。

vector<vector<int>> dp(s.size(), vector<int>(s.size(), 0));
for (int i = 0; i < s.size(); i++) dp[i][i] = 1;

4.确定遍历顺序

从递归公式中,可以看出,dp[i][j] 依赖于 dp[i + 1][j - 1]dp[i + 1][j] dp[i][j - 1],如图:

img

所以遍历i的时候一定要从下到上遍历,这样才能保证下一行的数据是经过计算的

j的话,可以正常从左向右遍历。

代码如下:

for (int i = n - 1; i >= 0; i--) {
    for (int j = i + 1; j < n; j++) {
        if (s[i] == s[j]) {
            dp[i][j] = dp[i + 1][j - 1] + 2;
        } else {
            dp[i][j] = max(dp[i + 1][j], dp[i][j - 1]);
        }
    }
}

5.举例推导dp数组

输入s:"cbbd" 为例,dp数组状态如图:

516.最长回文子序列3

红色框即:dp[0][s.size() - 1]; 为最终结果。

如果是回文子串求最大长度,那么要定义全局变量maxlen,在过程中取最大值,因为dp数组是布尔类型,保存的是ij之间是否回文,而不是长度。如果定义为长度,状态转移及其复杂。

class Solution {
  public:
      int longestPalindromeSubseq(string s) {
          int n = s.length();
          vector<vector<int>> dp(n , vector<int>(n , 0));
          for (int i = 0; i < n; i++)  dp[i][i] = 1;
             
          for (int i = n - 1; i >=  0; i --) {//i可写从n-2开始
              for (int j = i + 1; j < n; j++) {
                 if(s[i] == s[j]) dp[i][j] = dp[i + 1][j - 1] + 2;
                else {
                      dp[i][j] = max(dp[i + 1][j] , dp[i][j - 1]); //注意这里dp[i][j]是由dp[i + 1][j - 1]转移而来,如果单移左右边界就应该是dp[i + 1][j] , dp[i][j - 1]。而不是dp[i - 1][j] , dp[i][j + 1]                 
                 }
              }
          }
          return dp[0][n - 1];
      }
  };

1143. 最长公共子序列 - 力扣(LeetCode)

一般定义dp[i][j] : 长度为[0, i - 1]的字符串text1 与 长度为[0, j - 1]的字符串text2的最长公共子序列为dp[i][j],这样可以避免dp[0][0]的初始化问题。

子序列问题本质上就是考虑 i , j 选与不选,就有以下四种情况

  • text1 选 i , text2 选 j (dp[i] [j])
  • text1 不选 i , text2 选 j (dp[i - 1] [j])
  • text1 选 i , text2 不选 j (dp[i] [j - 1])
  • text1 不选 i , text2 不选 j (dp[i - 1] [j - 1])

那么考虑text1[i]text2[j]是否相等:

  • == dp[i][j] = dp[i - 1][j - 1] + 1
  • != dp[i][j] = max(dp[i - 1][j] , dp[i][j - 1])

(这里从定义知道dp[i - 1][j - 1] <= dp[i - 1][j] , dp[i - 1][j - 1] <= dp[i][j - 1]所以第二个转移式无需考虑dp[i - 1][j - 1])

class Solution {
  public:
      int longestCommonSubsequence(string text1, string text2) {
          int m = text1.size() , n = text2.size();
          vector<vector<int>> dp(m  + 1, vector<int>(n + 1, 0));//由于dp数组的定义,相当于把数组向右平移了一个单位

          for (int i = 1; i <= m; i++) {
             for (int j = 1; j <= n; j++) {
                if(text1[i - 1] == text2[j - 1])  dp[i][j] = dp[i - 1][j - 1] + 1;
//dp[i][j]取决于text1[i - 1]与text2[j - 1]是否相等,而不是text1[i]与text2[j]
                else{
                  dp[i][j] = max(dp[i - 1][j] , dp[i][j - 1]);
                }
             }
          }
          return dp[m][n];
      }
  };

43. 字符串相乘 - 力扣(LeetCode)

前置题:415. 字符串相加 - 力扣(LeetCode)

代码如下:

class Solution {
  public:
      string addStrings(string num1, string num2) {
        int carry = 0;
        int i = num1.size() - 1;
        int j = num2.size() - 1;
        string res;

        while(i >= 0 || j >= 0 || carry){
          int n1 = (i >= 0) ? (num1[i --] - '0') : 0;
          int n2 = (j >= 0) ? (num2[j --] - '0') : 0;
          int sum = n1 + n2 + carry;
          carry = sum / 10;
          res.push_back(sum % 10 + '0');
        }

        reverse(res.begin() , res.end());
        return res;
      }
  };

回到本题:43. 字符串相乘 - 力扣(LeetCode)

class Solution {
  public:
string multiply(string num1, string num2) {
    int m = num1.size(), n = num2.size();
    vector<int> pos(m + n, 0); // 初始化结果数组,长度为m+n

    for (int i = m - 1; i >= 0; --i) {//相当于num2 * num1 ,num2在上,乘以num1的每一位
        for (int j = n - 1; j >= 0; --j) {
            int mul = (num1[i] - '0') * (num2[j] - '0');
            int p1 = i + j, p2 = i + j + 1; // p1为高位,p2为低位
            int sum = mul + pos[p2]; //乘积加进位

            pos[p1] += sum / 10; // 处理进位
            pos[p2] = sum % 10;  // 更新当前位
        }
    }

    // 构建结果字符串,跳过前导零
    string result;
    for (int p : pos) {
        if (result.empty() && p == 0) continue;
        result += (p + '0');
    }

    return result.empty() ? "0" : result;// 0*0的情况返回0
	}
};

关键点说明

  • 数组长度:乘积的最大位数为 m + n,因此初始化数组长度为 m + n
  • 进位处理:通过将进位累加到高位的方式,确保每一位的值在计算过程中不会溢出,最终结果正确。
  • 前导零处理:在构建结果字符串时跳过数组中的前导零,若结果全零则返回 "0"。

该算法高效地处理了大数相乘问题,时间复杂度为 O(m*n),空间复杂度为 O(m+n)。


121. 买卖股票的最佳时机 - 力扣(LeetCode)

class Solution {
  public:
      int maxProfit(vector<int>& prices) {
          int min_price = INT_MAX;
          int res = 0;

          for(int price : prices){
            res = max(res , price - min_price);
            min_price = min(price , min_price);
          }
          return res;
      }
  };

322. 零钱兑换 - 力扣(LeetCode)

完全背包问题

dp[j] 表示凑成总金额j所需的最少硬币为dp[j]

dp[j] = dp[j - coins[i]] + 1 (x)

dp[j] = min(dp[j] , d[j - coins[i]] + 1)

只求最终硬币数,循环顺序无所谓。

考虑到递推公式的特性,dp[j]必须初始化为一个最大的数

vector<int> dp(amount + 1 ,INT_MAX)

class Solution {
  public:
      int coinChange(vector<int>& coins, int amount) {      
          vector<unsigned int> dp(amount + 1 , INT_MAX);
          dp[0] = 0;
          
          for(int i : coins){
              for (int j = i; j <= amount; j++) {
                 dp[j] = min(dp[j] ,dp[j - i]  + 1);
              }
          }
          if(dp[amount] == INT_MAX)  return -1;
          else return dp[amount];
      }
  };

460. LFU 缓存 - 力扣(LeetCode)

类似题:146. LRU 缓存 - 力扣(LeetCode)

class Node {
public:
    int key;
    int value;
    Node* prev;
    Node* next;

    Node(int k = 0, int v = 0) : key(k), value(v) {}
};

class LRUCache {
private:
    int capacity;
    Node* dummy; // 哨兵节点
    unordered_map<int, Node*> key_to_node;

    // 删除一个节点(抽出一本书)
    void remove(Node* x) {
        x->prev->next = x->next;
        x->next->prev = x->prev;
    }

    // 在链表头添加一个节点(把一本书放在最上面)
    void push_front(Node* x) {
        x->prev = dummy;
        x->next = dummy->next;
        x->prev->next = x;
        x->next->prev = x;
    }

    // 获取 key 对应的节点,同时把该节点移到链表头部
    Node* get_node(int key) {
        auto it = key_to_node.find(key);
        if (it == key_to_node.end()) { // 没有这本书
            return nullptr;
        }
        Node* node = it->second; // 有这本书
        remove(node); // 把这本书抽出来
        push_front(node); // 放在最上面
        return node;
    }

public:
    LRUCache(int capacity) : capacity(capacity), dummy(new Node()) {//class LRUCache 
        dummy->prev = dummy;
        dummy->next = dummy;
    }

    int get(int key) {
        Node* node = get_node(key);
        return node ? node->value : -1;
    }

    void put(int key, int value) {
        Node* node = get_node(key);
        if (node) { // 有这本书
            node->value = value; // 更新 value
            return;
        }
        key_to_node[key] = node = new Node(key, value); // 新书
        push_front(node); // 放在最上面
        if (key_to_node.size() > capacity) { // 书太多了
            Node* back_node = dummy->prev;
            key_to_node.erase(back_node->key);
            remove(back_node); // 去掉最后一本书
            delete back_node; // 释放内存
        }
    }
};

回到本题:

460-2-c.png

答疑

:一个双向链表需要几个哨兵节点?

:一个就够了。一开始哨兵节点 dummy 的 prev 和 next 都指向 dummy。随着节点的插入,dummy 的 next 指向链表的第一个节点(最上面的书),prev 指向链表的最后一个节点(最下面的书)。

:为什么 minFreq 一定对应着最左边的非空的那摞书?

:在添加一本新书的情况下,这本新书一定是放在 freq=1 的那摞书上,此时我们把 minFreq 置为 1。在「抽出一本书且这摞书变成空」的情况下,我们会把这本书放到它右边这摞书的最上面。如果变成空的那摞书是最左边的,我们还会把 minFreq 加一。所以无论如何,minFreq 都会对应着最左边的非空的那摞书。

:有没有一些让代码变快的方法?

:有两处「移除空链表」的逻辑是可以去掉的,代码(可能)会更快。

class Node {
public:
    int key;
    int value;
    int freq = 1; // 新书只读了一次
    Node* prev;
    Node* next;

    Node(int k = 0, int v = 0) : key(k), value(v) {}
};

class LFUCache {
private:
    int min_freq;
    int capacity;
    unordered_map<int, Node*> key_to_node;
    unordered_map<int, Node*> freq_to_dummy;

    Node* get_node(int key) {
        auto it = key_to_node.find(key);
        if (it == key_to_node.end()) { // 没有这本书
            return nullptr;
        }
        Node* node = it->second; // 有这本书
        remove(node); // 把这本书抽出来
        Node* dummy = freq_to_dummy[node->freq];
        if (dummy->prev == dummy) { // 抽出来后,这摞书是空的
            freq_to_dummy.erase(node->freq); // 移除空链表
            delete dummy; // 释放内存
            if (min_freq == node->freq) { // 这摞书是最左边的
                min_freq++;
            }
        }
        push_front(++node->freq, node); // 放在右边这摞书的最上面
        return node;
    }

    // 创建一个新的双向链表
    Node* new_list() {
        Node* dummy = new Node(); // 哨兵节点
        dummy->prev = dummy;
        dummy->next = dummy;
        return dummy;
    }

    // 在链表头添加一个节点(把一本书放在最上面)
    void push_front(int freq, Node* x) {
        auto it = freq_to_dummy.find(freq);
        if (it == freq_to_dummy.end()) { // 这摞书是空的
            it = freq_to_dummy.emplace(freq, new_list()).first;
        }
        Node* dummy = it->second;
        x->prev = dummy;
        x->next = dummy->next;
        x->prev->next = x;
        x->next->prev = x;
    }

    // 删除一个节点(抽出一本书)
    void remove(Node* x) {
        x->prev->next = x->next;
        x->next->prev = x->prev;
    }

public:
    LFUCache(int capacity) : capacity(capacity) {}

    int get(int key) {
        Node* node = get_node(key);
        return node ? node->value : -1;
    }

    void put(int key, int value) {
        Node* node = get_node(key);
        if (node) { // 有这本书
            node->value = value; // 更新 value
            return;
        }
        if (key_to_node.size() == capacity) { // 书太多了
            Node* dummy = freq_to_dummy[min_freq];
            Node* back_node = dummy->prev; // 最左边那摞书的最下面的书
            key_to_node.erase(back_node->key);
            remove(back_node); // 移除
            delete back_node; // 释放内存
            if (dummy->prev == dummy) { // 这摞书是空的
                freq_to_dummy.erase(min_freq); // 移除空链表
                delete dummy; // 释放内存
            }
        }
        key_to_node[key] = node = new Node(key, value); // 新书
        push_front(1, node); // 放在「看过 1 次」的最上面
        min_freq = 1;
    }
};

复杂度分析

  • 时间复杂度:所有操作均为 O(1)。
  • 空间复杂度:O(min(p,capacity)),其中 p 为 put 的调用次数。

相似题目

本题代码量大,如果您在做本题时遇到一些困难,也可以先做完下面这些题目,再回过头来做本题。

posted @ 2025-04-12 02:45  七龙猪  阅读(2)  评论(0)    收藏  举报
-->