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]看上去都没啥关系。所以我们要看回文串的性质。 如图:
我们在判断字符串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.确定递推公式
当s[i]与s[j]不相等,
dp[i][j]一定是false。当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]的左下角,如图:
如果这矩阵是从上到下,从左到右遍历,那么会用到没有计算过的
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]状态如下:
图中有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;
如果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]);
代码如下:
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],如图:
所以遍历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数组状态如图:
红色框即:
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)
代码如下:
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;
}
};
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; // 释放内存
}
}
};
回到本题:

答疑
问:一个双向链表需要几个哨兵节点?
答:一个就够了。一开始哨兵节点 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 的调用次数。
相似题目
本题代码量大,如果您在做本题时遇到一些困难,也可以先做完下面这些题目,再回过头来做本题。








浙公网安备 33010602011771号