Skip to content

Latest commit

 

History

History
666 lines (596 loc) · 22 KB

T9-子序列问题.md

File metadata and controls

666 lines (596 loc) · 22 KB

子序列问题

  • 数组或者字符串的子序列问题,是一类常见的中等难度的题目
  • 区别与连续子串,子序列元素仅保留了数组本来元素的序列关系,不一定存在连续特性
  • 下面汇总的题目总体上都可以通过动态规划进行求解,且求解方法基本一致
  • 从题目描述可以看到求解目标基本为:
    • 最长XXX子序列, 计算序列长度
    • 子序列数量, 计算满足条件的序列数量
  • 从动态规划定义上将,基本可以用以下两种定义来实现:
    • dp[i][j] 表示s[:i] t[:j]的目标情况
      • dp遍历往往内外循环正向即可
    • dp[i][j] 表示 s[i:] t[:j]两串的目标情况或者i~j之间的目标情况
      • dp遍历往往采用外反内正的循环逻辑
      • 如: [LC516.最长回文子序列]

392. 判断子序列

给定字符串 s 和 t ,判断 s 是否为 t 的子序列。 字符串的一个子序列是原始字符串删除一些(也可以不删除)字符而不改变剩余字符相对位置形成的新字符串。(例如,"ace"是"abcde"的一个子序列,而"aec"不是)。

class Solution {
public:
    bool isSubsequence(string s, string t) {
        int n = s.size();
        int m = t.size();
        if (n > m) return false;
        int j = 0;
        for (int i = 0; i < m; i++) {
            if (t[i] == s[j]) {
                j++;
              
            }
        }
        return j == n;
    }
};
  • 进一步思考: 当子串s特别长时,如何进行快速匹配
  • 可以考虑在线性遍历的基础上,类似于KMP算法进行下一个位置记录,提高速度
    • 使用伪链表方式,记录每个位置下一个字符的位置(n*26的形式)
    • 为了保证结果可靠,在模
class Solution {
public:
	bool isSubsequence(string s, string t) {
		t.insert(t.begin(), ' '); // 初始化操作,使得后续可以计算起来 
		int len1 = s.size(), len2 = t.size();
		
		vector<vector<int> > dp(len2 , vector<int>(26, 0));

		for (char c = 'a'; c <= 'z'; c++) {
			int nextPos = -1; //表示接下来再不会出现该字符

			for (int i = len2 - 1; i>= 0; i--) {  //为了获得下一个字符的位置,要从后往前
				dp[i][c - 'a'] = nextPos;
				if (t[i] == c)
					nextPos = i; // 更新当前字符的最新位置
			}
		}

		int index = 0;
		for (char c : s) {
			index = dp[index][c - 'a'];
			if (index == -1)
				return false;
		}
		return true;

	}
};

115. 不同的子序列

给定一个字符串 s 和一个字符串 t ,计算在 s 的子序列中 t 出现的个数 字符串的一个 子序列 是指,通过删除一些(也可以不删除)字符且不干扰剩余字符相对位置所组成的新字符串。

输入:s = "rabbbit", t = "rabbit"
输出:3
  • 子序列为非连续的字符子串,可以通过动态规划的方式来进行求解:
    • 定义dp[i][j] s串以i-1结尾的子序列和t串以j-1结尾的子序列匹配(子串匹配)的数量
    • 如果s[i-1] == t[j-1]dp[i][j] = dp[i-1][j-1] + dp[i-1][j]
      • dp[i-1][j]即以子串s[i-1]之前的串与t串进行匹配
    • 如果s[i-1] != t[j-1]dp[i][j] = dp[i-1][j]
  • 初始化: dp[i][0] = 1 dp[0][0] = 1
  • 关键点: 动态规划分析
class Solution {
public:
    int numDistinct(string s, string t) {
        int n = s.size();
        int m = t.size();
        int end = n;
        vector<vector<long long >> dp(n + 1, vector<long long>(m+1, 0));

        for (int i = 0; i <= n; i++) dp[i][0] = 1;
        
        for (int i = 1; i <= n; i++) {
            for (int j = 1; j <= m; j++) {
                if (s[i-1] == t[j-1]) {  
                    dp[i][j] = (dp[i-1][j] + dp[i-1][j-1]) % INT_MAX;
                }
                else {
                    dp[i][j] = dp[i-1][j] % INT_MAX;
                }
            }
        }
        return dp[n][m];
    }
};
  • 基于dfs的记忆化搜索,通过所有案例,但超时了。。。
  • 从本质上理解: 动态规划是不会重复计算的递归搜索
class Solution {
public:
    vector<vector<int>> dp;
    int backTrack(string s, int beg, int end, string target, int index) {
        //cout << "beg" << beg << "end:" << end << "index " << index << endl;
        if (index == target.size()) {
            return 1;
        }
        if (beg > end || index > target.size()) {
            return 0;
        }
        if (dp[beg][index] != -1) {
            return dp[beg][index];
        }
        int ans = 0;
        for (int k = beg; k <= end; k++) {
            if (s[k] != target[index]) {
                continue;
            }
            else {
                if (dp[k + 1][index + 1] == -1)
                    dp[k+1][index+1] = backTrack(s, k + 1, end, target, index + 1);
                ans += dp[k+1][index+1];
            }
        }
        return ans;
    }
    int numDistinct(string s, string t) {
        int n = s.size();
        int m = t.size();
        int end = n;
        dp = vector<vector<int>>(n + 1, vector<int>(m+1, -1));
        for (int i = n - 1; i >= 0; i--) {
            if (s[i] == t.back()) {
                end = n - 1;
            }
        }
        if (end == n) {
            return 0;
        }
        int ans = 0;
        for (int i = 0; i < n; i++) {
            if (s[i] == t[0]) {
                ans+=backTrack(s, i, end, t, 0);
                break;
            }
        }
        return ans;
    }
};

583. 两个字符串的删除操作

给定两个单词 word1 和 word2,找到使得 word1 和 word2 相同所需的最小步数,每步可以删除任意一个字符串中的一个字符

输入: "sea", "eat"
输出: 2
解释: 第一步将"sea"变为"ea",第二步将"eat"变为"ea"
  • 分析: 仍然可以采用动态规划进行求解:
    • 与前面的题目中dp定义相似
    • dp[i][j]表示 word1前i个元素和word2前j个元素相同所需的最小步数
      • word1[i-1]==word2[j-1] 当word1第i个元素和word2第j个元素相同时,所需的最小步数为 dp[i][j] = dp[i-1][j-1]
      • word1[i-1]!=word2[j-1] 当word1第i个元素和word2第j个元素相同时,所需的最小步数 dp[i][j] = min(dp[i-1][j-1] + 2, dp[i-1][j] + 1, dp[i][j-1] + 1)
    • 初始化:
class Solution {
public:
    int minDistance(string word1, string word2) {
        int n = word1.size();
        int m = word2.size();
        vector<vector<int>> dp(n + 1 ,vector<int>(m + 1));
        // init dp[i][0] = i
        for (int i = 0; i <= n; i++) {
            dp[i][0] = i;
        }
        for (int i = 0; i <= m; i++) {
            dp[0][i] = i;
        }
        for (int i = 1; i <= n; i++) {
            for (int j = 1; j <= m; j++) {
                if (word1[i-1] == word2[j-1]) {
                    dp[i][j] = dp[i-1][j-1];
                }
                else {
                    dp[i][j] = min(dp[i-1][j-1] + 2, min(dp[i-1][j] + 1, dp[i][j - 1] + 1));
                }
            }
        }
        return dp[n][m];
    }
};
  • 空间优化,使用滚动数组进行结果存储,这里使用辅助数组进行当前结果存储,使得代码可读性更好
class Solution {
public:
    int minDistance(string word1, string word2) {
        int n = word1.size();
        int m = word2.size();
        vector<int> dp(m + 1);
        for (int i = 0; i <= m; i++) {
            dp[i] = i;
        }
        for (int i = 1; i <= n; i++) {
            
            vector<int> temp(m + 1);
            temp[0] = i;
            for (int j = 1; j <= m; j++) {
                if (word1[i-1] == word2[j-1]) {
                    temp[j] = dp[j-1];
                }
                else {
                    temp[j] = min(dp[j-1] + 2, min(dp[j] + 1, temp[j - 1] + 1));
                }
            }
            dp = temp; // extra array for temp
        }
        return dp[m];
    }
};

300. 最长上升子序列 (LIS)

  • 基础DP思路
    • 状态转移公式 dp[i]=max(dp[j])+1,其中0≤j<i且num[j]<num[i]
    • 初始化:dp[0]=0
    • 最后结果需要从dp中选取最大的值
    • 时间复杂度O(n^2), 空间复杂度 O(N)
class Solution {
public:
    int lengthOfLIS(vector<int>& nums) {
        vector<int> dp(nums.size(), 0);
        dp[0] = 1;
        int ans = 1;
        for (int i = 1; i < nums.size(); i++) {
            dp[i] = 1;
            for (int j = 0; j< i; j++) {
                if (nums[j] < nums[i]) {
                    dp[i] = max(dp[i], dp[j] + 1);
                }
            }
            ans = max(dp[i], ans);
        } 
        return ans;
    }
};
  • 贪心+二分搜索
    • 要使上升子序列尽可能的长,则我们需要让序列上升得尽可能慢,因此我们希望每次在上升子序列最后加上的那个数尽可能的小。
    • 基于上面的贪心思路,维护一个数组 d[i],表示长度为 ii 的最长上升子序列的末尾元素的最小值,用 \textit{len}len 记录目前最长上升子序列的长度,起始时 lenlen 为 11,d[1] = \textit{nums}[0]d[1]=nums[0]
class Solution {
public:
    int lengthOfLIS(vector<int>& nums) {
        vector<int> tail; // 定义不同长度的子序列的最大尾值
        int ans;
        tail.push_back(nums[0]);
        for (int i = 1; i < nums.size(); i++) {
            if (nums[i] > tail.back()) {
                tail.push_back(nums[i]);
            }
            else {
                int left = 0;
                int right = tail.size() - 1;
                //cout << 'l' << left << 'r' << right << " "<< nums[i] <<endl;
                while(left < right) {
                    // int mid = left + (right - left)/2;
                    int mid = (left + right) / 2;
                    if (tail[mid] < nums[i]) {
                        left = mid + 1;
                    } else {
                        right = mid;
                    }
                }
               tail[left] = nums[i];
            }
        }
        return tail.size();
    }
};

674. 最长连续递增序列

未经排序的整数数组,找到最长且 连续递增的子序列,并返回该序列的长度

  • 连续序列比非连续的子序列要更容易处理, 直接遍历计数即可
    • 注意对边界问题的处理, 每次上升即进行更新,而非出现非增再更新结果,以避免序列本身就单增或者结尾单增的情况
class Solution {
public:
    int findLengthOfLCIS(vector<int>& nums) {
        int n = nums.size();
        int beg = 1;
        int ans = 1;
        for (int i = 1; i < n; i++) {
            if (nums[i] > nums[i - 1]) {
                beg++;
                ans = max(ans, beg);
            }
            else {
                beg = 1;
                //ans = max(ans, beg);
            }
        }
        return  ans;
    }
};

1143. 最长公共子序列 *

  • 教科书式经典dp问题

给定两个字符串 text1 和 text2,返回这两个字符串的最长 公共子序列 的长度。如果不存在 公共子序列 ,返回 0 。 一个字符串的 子序列 是指这样一个新的字符串:它是由原字符串在不改变字符的相对顺序的情况下删除某些字符(也可以不删除任何字符)后组成的新字符串

  • 定义dp[i][j]表示子串text1[0:i)和text2[0:j)的公共子序列的最长长度
    • 状态转移公式: dp[i][j] = dp[i-1][j-1]+ 1 当text1[i] == text2[j]
      • dp[i][j] = max(dp[i-1][j], dp[i][j-1])
class Solution {
public:
    int longestCommonSubsequence(string text1, string text2) {
        int n1 = text1.size();
        int n2 = text2.size();
        vector<vector<int> > dp(n1 + 1, vector<int>(n2 + 1));
        for (int i = 1; i <= n1; i++) {
            for (int j = 1; j <= n2; j++) {
                if (text1[i - 1] == text2[j - 1]) {
                    dp[i][j] = dp[i - 1][j - 1] + 1;
                }
                else {
                    dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]);
                }
            }
        }
        return dp[n1][n2];
    }
};

1035. 不相交的线

两条独立的水平线上按给定的顺序写下 nums1 和 nums2 中的整数。 现在,可以绘制一些连接两个数字 nums1[i] 和 nums2[j] 的直线,这些直线需要同时满足满足:nums1[i] == nums2[j]且绘制的直线不与任何其他连线(非水平线)相交。 返回可以绘制的最大连线数

输入:nums1 = [1,4,2], nums2 = [1,2,4]
输出:2
  • 问题分析: 最大连线数即需要得到两个数组的最长公共子序列,即两侧的元素都相等(满足不相交定义)
  • 那么问题即为[LC1143. 最长公共子序列]问题
  • 直接进行dp求解即可
class Solution {
public:
    int maxUncrossedLines(vector<int>& nums1, vector<int>& nums2) {
        int n = nums1.size();
        int m = nums2.size();
        vector<vector<int>> dp(n + 1, vector<int>(m + 1));
        for (int i = 1; i <= n; i++) {
            for (int j = 1; j <= m; j++) {
                if (nums1[i - 1] == nums2[j - 1]) {
                    dp[i][j] = dp[i - 1][j - 1] + 1;
                }
                else {
                    dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]);
                }
            }
        }
        return dp[n][m];
    }
};

72. 编辑距离 [Hard]

给你两个单词 word1 和 word2,请你计算出将 word1 转换成 word2 所使用的最少操作数

  • 动态规划解题
  • 定义dp[i][j]表示把word1的前i个字母替换为word2的前j个字母的最少操作数量
    • 那么动态规划的转移方程为;
    • dp[i][j] = min(dp[i-1][j] + 1, dp[i][j-1] + 1, dp[i-1][j-1] + (w1[i]!=w2[j])
    • 初始化: dp[i][0] = i
    • 根据dp定义,那么我们应该初始化[m+1][n+1]的二维数组,返回dp[m][n]作为最终结果
  • 对于特殊情况,即其中一个word为空时,编辑距离为非空word的长度
  • 尤其要注意索引的设置
    • 取字符串内元素的索引和dp的索引
  • 时间复杂度O(N^2) 空间复杂度O(N^2)
class Solution {
public:
    int minDistance(string word1, string word2) {
        int l1 = word1.size();
        int l2 = word2.size();
        if (word1.empty())
            return l2;
        if (word2.empty())
            return l1;
        vector<vector<int>> dp(l1 + 1, vector<int>(l2 + 1));
        for (int i = 1; i< l1 + 1; i++) {
            dp[i][0] = i;
        }
        for (int i = 1; i< l2 + 1; i++) {
            dp[0][i] = i;
        }
        for (int i = 1; i < l1 + 1; i++) {
            for (int j = 1; j< l2 + 1; j++) {
                int tmp = 1;
                // 对于字符串取值,需要注意索引的差异
                if (word1[i-1] == word2[j-1])
                    tmp = 0;
                dp[i][j] = min(dp[i-1][j] + 1, min(dp[i][j-1] + 1, dp[i-1][j-1] + tmp));
            }
        }
        return dp[l1][l2];
    }
};
  • 进一步优化空间使用,dp问题的常见优化,将二维数组变为一维数组
    • 使用一维数组记录当前行的情况,在遍历的同时进行更新
    • 代码易读性大幅下降
class Solution {
public:
    int minDistance(string word1, string word2) {
        int l1 = word1.size();
        int l2 = word2.size();
        int prev = 0;
        if (word1.empty())
            return l2;
        if (word2.empty())
            return l1;
        vector<int> dp((l2 + 1));
        for (int i = 0; i < l2 + 1; i++) {
            dp[i] = i;// dp[i][j]
        }

        for (int i = 1; i < l1 + 1; i++) {
            prev = dp[0]; // dp[i-1][j-1]
            dp[0] = i; // dp[i-1][0] 更新dp赋值
            for (int j = 1; j < l2 + 1; j++) {
                // dp[i][j] = dp[i-1][j] dp[i][j-1]
                // 提前存储上一行的状态,避免被覆盖
                int tmp = dp[j];
                dp[j] = min(dp[j] + 1, min(dp[j-1] + 1, prev + int(word1[i-1] != word2[j-1])));
                prev = tmp;
            }
        }
        return dp.back();
    }
};

516. 最长回文子序列

给定一个字符串 s ,找到其中最长的回文子序列,并返回该序列的长度。可以假设 s 的最大长度为 1000 。

输入:
"bbbab"
输出:
4  (bbbb)
  • 注意:这里是子序列,而不是子串,子串是元素连续的
  • 无法直接采用[LC5.最长回文子串]中简洁的双指针方法
  • 需要借助动态规划进行解题
    • dp状态定义: dp[i][j] 表示从i元素到j元素之间的最长子序列
    • 状态转移:
      • dp[i][j] = dp[i+1][j-1] + 2, if s[i] == s[j]
      • dp[i][j] = max(dp[i+1][j], dp[i][j-1]), otherwise 对比左右两边的长度
    • 特殊情况: 对于长度为1的字符串的处理, 通过对dp[i][i]进行初始化即可
    • 遍历方向: 需要保证左下右方向的结果提前计算处理,因此要在外层进行反方向遍历,在内循环正常从i + 1开始遍历,从下往上得到最终结果
  • 关键点: dp初始化 遍历方向
class Solution {
public:
    int longestPalindromeSubseq(string s) {
        int n = s.size();
        vector<vector<int>> dp (n, vector<int> (n));
        for (int i = n - 1; i >= 0; i--) { 
            dp[i][i] = 1; //  处理长度为1的特殊情况
            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][j-1], dp[i+1][j]);
                }
            }
        }
        return dp[0][n-1]; // 
    }
};

718. 最长重复子数组

给两个整数数组 A 和 B ,返回两个数组中公共的、长度最长的子数组的长度。

输入:
A: [1,2,3,2,1]
B: [3,2,1,4,7]
输出:3
解释:
长度最长的公共子数组是 [3, 2, 1] 。
  • 滑窗方法解题,分别移动A/B,调整两个数组的对齐位置,然后进行同位置元素对比,记录连续重复元素的个数
    • 时间复杂度 O(N+M)*min(n,m)
    • 空间复杂度 O(1)
class Solution {
public:
    int findRepeat(vector<int> A, vector<int> B, int addA, int addB, int len) {
        int res = 0;
        int k = 0;
        // 记录当前遍历下的最优结果
        for (int i = 0; i < len; i++) {
            if (A[addA + i] == B[addB + i])
                k++;
            else {
                k = 0;
            }
            res = max(res, k);
        }
        return res; 

    }
    int findLength(vector<int>& A, vector<int>& B) {
        int m = A.size();
        int n = B.size();
        int ans = 0;
        // 左移B数组
        // 当结果大于/等于当前对齐长度后break
        for (int i = 0; i < n - 1; i++) {
            int len = min(m, n - i);
            if (ans >= len)
                break;
            ans = max(findRepeat(A, B, 0, i, len), ans);
        }
        // 左移A数组
        // 当结果大于/等于当前对齐长度后break
        for (int i = 0; i < m - 1; i++) {
            int len = min(n, m - i);
            if (ans >= len)
                break;
            ans = max(findRepeat(A, B, i, 0, len), ans);
        }
        return ans;
    }
};
  • 动态规划解法
    • 定义dp[i][j] 表示A数组以i开始和B数组以j开始的子串的最大公共长度;
    • dp[i][j] = dp[i+1][j+1] + 1 if dp[i] == dp[j]; otherwise dp[i][j] = 0;
    • 最终结果为dp数组中的最大值
  • 时间复杂度 O(M*N) 空间复杂度 O(M*N)
class Solution {
public:
    int findLength(vector<int>& A, vector<int>& B) {
        int m = A.size();
        int n = B.size();
        int ans = 0;
        // 多增加一维,便于初始化
        vector<vector<int>> dp(m + 1, vector<int> (n + 1));
        //反向遍历 更新dp数组
        for (int i = m - 1; i >= 0; i--) {
            for (int j = n - 1; j >= 0; j--) {
                if (A[i] == B[j]) {
                    dp[i][j] = dp[i + 1][j + 1] + 1;
                }
                else {
                    dp[i][j] = 0;
                }
                ans = max(ans, dp[i][j]);
            }
        }
        return ans;
    }
};

补充: 最小编辑代价 [ByteDance]

在编辑距离上的改动:

给定两个字符串str1和str2,再给定三个整数ic,dc,rc,分别代表插入、删除、替换一个字符的代价,返回将str1编辑成str2的最小代价。

  • 编辑距离问题中没有涉及到不同方式的代价,都是1;
  • 修改状态转移公式: dp[i][j] = min(dp[i][j-1] + ic, dp[i-1][j] + dc, dp[i-1][j-1] / dp[i-1][j-1] + rc)
    • str1[i]!=str[j] 时, 从str1[i] 转移到 str2[j]需要把[i]/[j]进行替换,所以加上的时rc
    • 其他ic / dc都是ß本质上考察对状态转移公式的理解
    • 状态初始化: dp[0][j] = ic*j dp[i][0] = dc*i
      https://www.it610.com/article/1176869420593655808.htm
1.dp[0][0]表示空串编辑成空串,故dp[0][0]=0;

2.求第一行dp[0][j],空串编辑成str2[0….j-1],则dp[0][j]=ic*j;

3.求第一列dp[i][0],str1[0……i-1]编辑成空串,则dp[i][0]=dc*i;

4.求dp[i][j],即str1[0….i-1]编辑成str2[0…..j-1],四种可能的途径:

<1>str1[0….i-1]先编辑成str2[0…..j-2],再由str2[0…..j-2]到str2[0…..j-1],即dp[i][j-1]+ic;

<2>str1[0….i-1]先编辑成str1[0…..i-2],再由str1[0…..i-2]到str2[0…..j-1],即dc+dp[i-1][j];

<3>如果str1[i-1]==str2[j-1],则dp[i][j]=dp[i-1][j-1];