Leetcode-动态规划

动态规划

647-回文子串

给你一个字符串 s ,请你统计并返回这个字符串中 回文子串 的数目。
回文字符串 是正着读和倒过来读一样的字符串。
子字符串 是字符串中的由连续字符组成的一个序列。
具有不同开始位置或结束位置的子串,即使是由相同的字符组成,也会被视作不同的子串。

在这里插入图片描述

方法一:中心拓展

思路与算法

计算有多少个回文子串的最朴素方法就是枚举出所有的回文子串,而枚举出所有的回文字串又有两种思路,分别是:

枚举出所有的子串,然后再判断这些子串是否是回文;
枚举每一个可能的回文中心,然后用两个指针分别向左右两边拓展,当两个指针指向的元素相同的时候就拓展,否则停止拓展。

假设字符串的长度为 n。我们可以看出前者会用 O(n^2) 的时间枚举出所有的子串s[li ⋯ri],然后再用O(ri−li+1) 的时间检测当前的子串是否是回文,整个算法的时间复杂度是 O(n^3)。而后者枚举回文中心的是 O(n) 的,对于每个回文中心拓展的次数也是O(n) 的,所以时间复杂度是 O(n^2)
。所以我们选择第二种方法来枚举所有的回文子串。

在实现的时候,我们需要处理一个问题,即如何有序地枚举所有可能的回文中心,我们需要考虑回文长度是奇数和回文长度是偶数的两种情况。如果回文长度是奇数,那么回文中心是一个字符;如果回文长度是偶数,那么中心是两个字符。当然你可以做两次循环来分别枚举奇数长度和偶数长度的回文,但是我们也可以用一个循环搞定。我们不妨写一组出来观察观察,假设n=4,我们可以把可能的回文中心列出来:
在这里插入图片描述
由此我们可以看出长度为 n 的字符串会生成 2n−1 组回文中心[li,ri],其中li=i/2,ri=li+(i mod 2) 。这样我们只要从 0 到 2n−2 遍历 i,就可以得到所有可能的回文中心,这样就把奇数长度和偶数长度两种情况统一起来了。

代码如下。

class Solution {
public:
    int countSubstrings(string s) {
        int n = s.size(), ans = 0;
        for (int i = 0; i < 2 * n - 1; ++i) {
            int l = i / 2, r = i / 2 + i % 2;
            while (l >= 0 && r < n && s[l] == s[r]) {
                --l;
                ++r;
                ++ans;
            }
        }
        return ans;
    }
};
  1. 时间复杂度:O(n^2)
  2. 空间复杂度:O(1)

5-最长回文子串

给你一个字符串 s,找到 s 中最长的回文子串。
在这里插入图片描述

解题思路

动态规划:中心拓展算法
首先给出状态转移方程:
在这里插入图片描述
找出其中的状态转移链
在这里插入图片描述
可以发现,所有的状态在转移的时候的可能性都是唯一的。也就是说,我们可以从每一种边界情况开始「扩展」,也可以得出所有的状态对应的答案。

边界情况即为子串长度为 1 或 2 的情况。我们枚举每一种边界情况,并从对应的子串开始不断地向两边扩展。如果两边的字母相同,我们就可以继续扩展,例如从P(i+1,j−1) 扩展到P(i,j);如果两边的字母不同,我们就可以停止扩展,因为在这之后的子串都不能是回文串了。

「边界情况」对应的子串实际上就是我们「扩展」出的回文串的「回文中心」。本质即为:我们枚举所有的「回文中心」并尝试「扩展」,直到无法扩展为止,此时的回文串长度即为此「回文中心」下的最长回文串长度。我们对所有的长度求出最大值,即可得到最终的答案。

C++实现

class Solution {
public:
	pair<int, int> expandAroundCenter(const string& s, int left, int right) {
		while (left >= 0 && right < s.size() && s[left] == s[right]) {
			--left;
			++right;
		}
		//return pair<int,int>(left + 1, right - 1);//2012版本不支持直接返回两个参数
		return {left + 1, right - 1}; //C++11版本
	}
	 
	string longestPalindrome(string s) {
		int start = 0, end = 0;
		for (int i = 0; i < s.size(); ++i) {
			/*	pair <int,int>res = expandAroundCenter(s, i, i);
			pair <int,int>res1 = expandAroundCenter(s, i, i+1);
			int left1=res.first,left2=res1.first;
			int right1=res.second,right2=res1.second;*/
			 auto [left1, right1] = expandAroundCenter(s, i, i);//奇数个数
			auto [left2, right2] = expandAroundCenter(s, i, i + 1);//偶数个数
			if (right1 - left1 > end - start) {
				start = left1;
				end = right1;
			}
			if (right2 - left2 > end - start) {
				start = left2;
				end = right2;
			}
		}
		return s.substr(start, end - start + 1);
	}
};
  1. 时间复杂度:O(n^2),其中 n 是字符串的长度。动态规划的状态总数为 O(n^2),对于每个状态,我们需要转移的时间为 O(1)。
  2. 空间复杂度:O(n^2),即存储动态规划状态需要的空间。

53-最大数组和

给你一个整数数组 nums ,请你找出一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。
子数组 是数组中的一个连续部分。

在这里插入图片描述

解题思路

方法一:动态规划

假设 nums 数组的长度是 n,下标从 0 到 n−1。

我们用 f(i) 代表以第 i个数结尾的「连续子数组的最大和」,那么很显然我们要求的答案就是:

在这里插入图片描述

因此我们只需要求出每个位置的 f(i),然后返回 f 数组中的最大值即可。那么我们如何求 f(i) 呢?我们可以考虑 nums[i] 单独成为一段还是加入 f(i−1) 对应的那一段,这取决于nums[i] 和 f(i−1)+nums[i] 的大小,我们希望获得一个比较大的,于是可以写出这样的动态规划转移方程:

f(i)=max{f(i−1)+nums[i],nums[i]}

不难给出一个时间复杂度 O(n)、空间复杂度O(n) 的实现,即用一个 f 数组来保存 f(i) 的值,用一个循环求出所有 f(i)。考虑到 f(i) 只和 f(i−1) 相关,于是我们可以只用一个变量 pre 来维护对于当前f(i) 的 f(i−1) 的值是多少,从而让空间复杂度降低到O(1),这有点类似「滚动数组」的思想。

C++实现

class Solution {
public:
    int maxSubArray(vector<int>& nums) {
        int pre=0,maxAns=nums[0];
        for(const auto & x:nums) //作用就是迭代容器中所有的元素,每一个元素的临时名字就是x,等同于下边代码for (vector<int>::iterator iter = nums.begin(); iter != nums.end(); iter++)
        {
            pre=max(pre+x,x);
            maxAns=max(maxAns,pre);//以不同数结尾的最大子数组和的最大值
        }
        return maxAns;
}
};
  1. 时间复杂度:O(n),其中 n 为 nums 数组的长度。我们只需要遍历一遍数组即可求得答案。
  2. 空间复杂度:O(1)。我们只需要常数空间存放若干变量。

方法二:分治

这个分治方法类似于「线段树求解最长公共上升子序列问题」的 pushUp 操作。

我们定义一个操作 get(a, l, r) 表示查询 a 序列 [l,r] 区间内的最大子段和,那么最终我们要求的答案就是 get(nums, 0, nums.size() - 1)。如何分治实现这个操作呢?对于一个区间 [l,r],我们取m= 2/l+r,对区间 [l,m] 和 [m+1,r] 分治求解。当递归逐层深入直到区间长度缩小为 1 的时候,递归「开始回升」。

C++实现

class Solution {
public:
    struct Status {
        int lSum, rSum, mSum, iSum;
    };

    Status pushUp(Status l, Status r) {
        int iSum = l.iSum + r.iSum;
        int lSum = max(l.lSum, l.iSum + r.lSum);
        int rSum = max(r.rSum, r.iSum + l.rSum);
        int mSum = max(max(l.mSum, r.mSum), l.rSum + r.lSum);
        return (Status) {lSum, rSum, mSum, iSum};
    };

    Status get(vector<int> &a, int l, int r) {
        if (l == r) {
            return (Status) {a[l], a[l], a[l], a[l]};
        }
        int m = (l + r) >> 1;
        Status lSub = get(a, l, m);
        Status rSub = get(a, m + 1, r);
        return pushUp(lSub, rSub);
    }

    int maxSubArray(vector<int>& nums) {
        return get(nums, 0, nums.size() - 1).mSum;
    }
};
  1. 假设序列 a 的长度为 n。时间复杂度:假设我们把递归的过程看作是一颗二叉树的先序遍历,那么这颗二叉树的深度的渐进上界为 O(logn),这里的总时间相当于遍历这颗二叉树的所有节点,故总时间的渐进上界是 O(n),故渐进时间复杂度为 O(n)。
  2. 空间复杂度:递归会使用 O(logn) 的栈空间,故渐进空间复杂度为 O(logn)。

152-乘积最大子数组

给你一个整数数组 nums ,请你找出数组中乘积最大的连续子数组(该子数组中至少包含一个数字),并返回该子数组所对应的乘积。
在这里插入图片描述

解题思路

如果我们用 fmax (i) 来表示以第 i 个元素结尾的乘积最大子数组的乘积,a 表示输入参数 nums,那么根据「53. 最大子序和」的经验,我们很容易推导出这样的状态转移方程:
在这里插入图片描述
它表示以第 i个元素结尾的乘积最大子数组的乘积可以考虑 ai 加入前面的fmax(i−1) 对应的一段,或者单独成为一段,这里两种情况下取最大值。求出所有的 fmax(i) 之后选取最大的一个作为答案。

可是在这里,这样做是错误的。为什么呢?

因为这里的定义并不满足「最优子结构」。具体地讲,如果 a={5,6,−3,4,−3},那么此时 fmax对应的序列是{5,30,−3,4,−3},按照前面的算法我们可以得到答案为 30,即前两个数的乘积,而实际上答案应该是全体数字的乘积。我们来想一想问题出在哪里呢?问题出在最后一个 −3 所对应的fmax的值既不是 −3,也不是4×−3,而是 5×30×(−3)×4×(−3)。所以我们得到了一个结论:当前位置的最优解未必是由前一个位置的最优解转移得到的。

我们可以根据正负性进行分类讨论。

考虑当前位置如果是一个负数的话,那么我们希望以它前一个位置结尾的某个段的积也是个负数,这样就可以负负得正,并且我们希望这个积尽可能「负得更多」,即尽可能小。如果当前位置是一个正数的话,我们更希望以它前一个位置结尾的某个段的积也是个正数,并且希望它尽可能地大。于是这里我们可以再维护一个fmin(i),它表示以第 i 个元素结尾的乘积最小子数组的乘积,那么我们可以得到这样的动态规划转移方程:
在这里插入图片描述
它代表第 i 个元素结尾的乘积最大子数组的乘积fmax(i),可以考虑把 ai加入第 i−1 个元素结尾的乘积最大或最小的子数组的乘积中,二者加上 ai ,三者取大,就是第 i个元素结尾的乘积最大子数组的乘积。第 i 个元素结尾的乘积最小子数组的乘积f min(i) 同理。

C++实现

class Solution {
public:
    int maxProduct(vector<int>& nums) {
        int preMax=1,preMin=1,maxAns=nums[0];
        for(const auto & x:nums) 
        {
            int temp=preMin;
            preMin=min(min(preMin*x,preMax*x),x);
            preMax=max(max(temp*x,preMax*x),x);
            maxAns=max(maxAns,preMax);//以不同数结尾的最大子数组和的最大值
        }
        return maxAns;
    }
};
  1. 时间复杂度:O(n)
  2. 空间复杂度:O(1)

322-零钱兑换

给你一个整数数组 coins ,表示不同面额的硬币;以及一个整数 amount ,表示总金额。
计算并返回可以凑成总金额所需的 最少的硬币个数 。如果没有任何一种硬币组合能组成总金额,返回 -1 。
你可以认为每种硬币的数量是无限的。

在这里插入图片描述

解题思路

方法一:不带备忘录的递归暴力解

class Solution {
public:
	int coinChange(vector<int>& coins, int amount) {		
		return dp(coins,amount);
	}
	int dp(vector<int>& coins,int amount)
	{
		if(amount==0) return 0;
		if(amount<0) return -1;

		int res=amount+1;
		for(auto & coin:coins)
		{
			int subProblem=dp(coins,amount-coin);
			if(subProblem==-1)continue;
			res=min(res,subProblem+1);// 子问题(res)和现问题的最小值(subProblem+1)
		}	
		return res==amount+1?-1:res;
	}
};

递归算法的时间复杂度分析:子问题总数 x 每个子问题的时间。

子问题总数为递归树节点个数,这个比较难看出来,是 O(n^k),总之是指数级别的。每个子问题中含有一个 for 循环,复杂度为 O(k)。所以总时间复杂度为 O(k * n^k),指数级别,n为coins的容量,k为amount。

方法二:带备忘录的递归解法

class Solution {
public:
	int coinChange(vector<int>& coins, int amount) {
		vector<int> table(amount+1,111);//amount+1包含table[0]..table[amount]
		return dp(coins,table,amount);
	}
	int dp(vector<int>& coins, vector<int> table,int amount)
	{
		if(amount==0) return 0;
		if(amount<0) return -1;
		if(table[amount]!=111)
			return table[amount];
		int res=amount+1;
		for(auto & coin:coins)
		{
			int subProblem=dp(coins,table,amount-coin);
			if(subProblem==-1)continue;
			res=min(res,subProblem+1);
		}
		table[amount]=(res==amount+1)?-1:res;
		return table[amount];
	}
};
  1. 时间复杂度:O(Sn),其中 S 是金额,n 是面额数。我们一共需要计算 S 个状态的答案,且每个状态 F(S) 由于上面的备忘录的措施只计算了一次,而计算一个状态的答案需要枚举 n 个面额值,所以一共需要 O(Sn) 的时间复杂度。
  2. 空间复杂度:O(S),我们需要额外开一个长为 S 的数组来存储计算出来的答案 F(S) 。

方法三:从下至上的迭代解法

dp 数组的定义:当目标金额为 i 时,至少需要 dp[i] 枚硬币凑出。

class Solution {
public:
    int coinChange(vector<int>& coins, int amount) {
    	// 数组大小为 amount + 1,初始值也为 amount + 1
        vector<int> dp(amount+1,amount+1);
        dp[0]=0;
         // 外层 for 循环在遍历所有状态的所有取值
        for(int i=0;i<dp.size();i++)
        {
        	// 内层 for 循环在求所有选择的最小值
            for(auto & coin:coins)
                {
                	 // 子问题无解,跳过
                    if(i-coin<0)
                        continue;
                    dp[i]=min(dp[i],1+dp[i-coin]);
                }
        }
        return dp[amount]==amount+1?-1:dp[amount];
    }
};
  1. 时间复杂度:O(Sn),其中 S 是金额,n 是面额数。我们一共需要计算 O(S) 个状态,S 为题目所给的总金额。对于每个状态,每次需要枚举 n 个面额来转移状态,所以一共需要 O(Sn) 的时间复杂度。
  2. 空间复杂度:O(S)。数组 dp 需要开长度为总金额 S 的空间。

306 最长递增子序列

给你一个整数数组 nums ,找到其中最长严格递增子序列的长度。
子序列是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,[3,6,2,7] 是数组 [0,3,1,6,2,2,7] 的子序列。

在这里插入图片描述

解题思路

方法一:动态规划

动态规划的核心设计思想是数学归纳法。

相信大家对数学归纳法都不陌生,高中就学过,而且思路很简单。比如我们想证明一个数学结论,那么我们先假设这个结论在 k<n 时成立,然后根据这个假设,想办法推导证明出 k=n 的时候此结论也成立。如果能够证明出来,那么就说明这个结论对于 k 等于任何数都成立。

类似的,我们设计动态规划算法,不是需要一个 dp 数组吗?我们可以假设 dp[0…i-1] 都已经被算出来了,然后问自己:怎么通过这些结果算出 dp[i]?

直接拿最长递增子序列这个问题举例你就明白了。不过,首先要定义清楚 dp 数组的含义,即 dp[i] 的值到底代表着什么?

我们的定义是这样的:dp[i] 表示以 nums[i] 这个数结尾的最长递增子序列的长度。

根据这个定义,我们就可以推出 base case:dp[i] 初始值为 1,因为以 nums[i] 结尾的最长递增子序列起码要包含它自己。

举两个例子:

在这里插入图片描述
在这里插入图片描述
根据这个定义,我们的最终结果(子序列的最大长度)应该是 dp 数组中的最大值。

假设我们已经知道了 dp[0…4] 的所有结果,我们如何通过这些已知结果推出 dp[5] 呢?
在这里插入图片描述
根据刚才我们对 dp 数组的定义,现在想求 dp[5] 的值,也就是想求以 nums[5] 为结尾的最长递增子序列。

nums[5] = 3,既然是递增子序列,我们只要找到前面那些结尾比 3 小的子序列,然后把 3 接到最后,就可以形成一个新的递增子序列,而且这个新的子序列长度加一。

显然,可能形成很多种新的子序列,但是我们只选择最长的那一个,把最长子序列的长度作为 dp[5] 的值即可。利用数学归纳法,把5换成n就可以实现最长递增子序列的查找。

class Solution {
public:
    int lengthOfLIS(vector<int>& nums) {
        int n=nums.size();
        vector<int>dp(n,1);
        for(int i=0;i<n;++i)
        {
            for(int j=0;j<i;++j)
            {
                if(nums[i]>nums[j])
                    dp[i]=max(dp[i],dp[j]+1);
            }
        }
        int res=0;
        for(int i=0;i<dp.size();++i)
            res=max(res,dp[i]);
        return res;
    }
};
  1. 时间复杂度:O(n^2)

  2. 空间复杂度:O(n)
    总结一下如何找到动态规划的状态转移关系:

    1、明确 dp 数组的定义。这一步对于任何动态规划问题都很重要,如果不得当或者不够清晰,会阻碍之后的步骤。

    2、根据 dp 数组的定义,运用数学归纳法的思想,假设 dp[0…i-1] 都已知,想办法求出 dp[i],一旦这一步完成,整个题目基本就解决了。

    但如果无法完成这一步,很可能就是 dp 数组的定义不够恰当,需要重新定义 dp 数组的含义;或者可能是 dp 数组存储的信息还不够,不足以推出下一步的答案,需要把 dp 数组扩大成二维数组甚至三维数组。

方法二:二分查找

其实最长递增子序列和一种叫做 patience game 的纸牌游戏有关,甚至有一种排序方法就叫做 patience sorting(耐心排序)。

class Solution
{
public:
	 int lengthOfLIS(vector<int> nums) {
		 int n = nums.size();
		vector<int>top(n,0);
		// 牌堆数初始化为 0
		int piles = 0;
		for (int i = 0; i < n; i++) {
			// 要处理的扑克牌
			int poker = nums[i];
			/***** 搜索左侧边界的二分查找 *****/
			int left = 0, right = piles;
			while (left < right) {
				int mid = (left + right) / 2;
				if (top[mid] > poker) {
					right = mid;
				}
				else if (top[mid] < poker) {
					left = mid + 1;
				}
				else {
					right = mid;
				}
			}
			/*********************************/
			// 没找到合适的牌堆,新建一堆
			if (left == piles) piles++;
			// 把这张牌放到牌堆顶
			top[left] = poker;
		}
		// 牌堆数就是 LIS 长度
		return piles;
	}
};

  1. 时间复杂度:O(nlogn)
  2. 空间复杂度:O(n)

931-下降路径最小和

给你一个 n x n 的 方形 整数数组 matrix ,请你找出并返回通过 matrix 的下降路径 的 最小和 。
下降路径 可以从第一行中的任何元素开始,并从每一行中选择一个元素。在下一行选择的元素和当前行所选元素最多相隔一列(即位于正下方或者沿对角线向左或者向右的第一个元素)。具体来说,位置 (row, col) 的下一个元素应当是 (row + 1, col - 1)、(row + 1, col) 或者 (row + 1, col + 1) 。

在这里插入图片描述

C++实现

class Solution {
public:
    int minFallingPathSum(vector<vector<int>>& matrix) {
    int n = (int)matrix.size();
    int res = INT_MAX;
    // 备忘录里的值初始化为 66666
    vector<vector<int>>memo(n);
    for(int i=0;i<n;++i)
			memo[i].resize(n);
    for(int i=0;i<n;++i)
    {
        for(int j=0;j<n;++j)
            memo[i][j]=10001;
    }
    // 终点可能在 matrix[n-1] 的任意一列
    for (int j = 0; j < n; j++) {
        res = min(res, dp(matrix,memo, n - 1, j));
    }
    return res;
}
int dp(vector<vector<int>>& matrix ,vector<vector<int>>&memo, int i, int j) {
    // 1、索引合法性检查
    if (i < 0 || j < 0 ||i >= (int)matrix.size() || j >=(int) matrix.size()) 
        return 99999;
    // 2、base case
    if (i == 0) {
        return matrix[0][j];
    }
    // 3、查找备忘录,防止重复计算
    if (memo[i][j] != 10001) {
        return memo[i][j];
    }
    // 进行状态转移
    memo[i][j] = matrix[i][j] + Min(
            dp(matrix,memo, i - 1, j), 
            dp(matrix, memo,i - 1, j - 1),
            dp(matrix,memo, i - 1, j + 1)
        );
    return memo[i][j];
}
int Min(int a, int b, int c) {
    return min(a, min(b, c));
}
};

354-俄罗斯套娃信封问题

给你一个二维整数数组 envelopes ,其中 envelopes[i] = [wi, hi] ,表示第 i 个信封的宽度和高度。
当另一个信封的宽度和高度都比这个信封大的时候,这个信封就可以放进另一个信封里,如同俄罗斯套娃一样。
请计算 最多能有多少个 信封能组成一组“俄罗斯套娃”信封(即可以把一个信封放到另一个信封里面)。
注意:不允许旋转信封。

在这里插入图片描述

解题思路

将二维数组排序后,可转化为最长递增子序列问题。

C++实现

class Solution {
public:
    int maxEnvelopes(vector<vector<int>>& envelopes) {
        int n=envelopes.size(),count=0;
        sort(envelopes.begin(),envelopes.end());
        vector<int>dp(n,1);
        for(int i=0;i<n;++i)
        {
            for(int j=0;j<i;++j)
            {
                if(envelopes[i][0]>envelopes[j][0]&&envelopes[i][1]>envelopes[j][1])
                dp[i]=max(dp[i],dp[j]+1);
            }

        }
        for(int i=0;i<n;++i)
            count=max(count,dp[i]);
        return count;
    }
};
  1. 时间复杂度:O(n^2),双层循环
  2. 空间复杂度:O(n),n为数组的大小

1143-最长公共子序列

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

在这里插入图片描述

解题思路

方法一:带备忘录的递归解法

class Solution {
public:
    int longestCommonSubsequence(string text1, string text2) {
   int s1=text1.size(),s2=text2.size();
        vector<vector<int>>memo(s1,vector<int>(s2,-1));
        return dp(text1,0,text2,0,memo);
    }
    int dp(string text1,int i,string text2,int j,vector<vector<int>>memo)
    {
        if(i==text1.size()||j==text2.size())
            return 0;
        if(memo[i][j]!=-1)
            return memo[i][j];
        if(text1[i]==text2[j])
            memo[i][j]= 1+dp(text1,i+1,text2,j+1,memo);
        else
        {
            memo[i][j]= max(dp(text1,i+1,text2,j,memo),dp(text1,i,text2,j+1,memo));
        }
        return memo[i][j];
    }
};

方法二:自底向上的迭代的动态规划思路

class Solution {
public:
    int longestCommonSubsequence(string text1, string text2) {
        int s1=text1.size(),s2=text2.size();
        vector<vector<int>>dp(s1+1,vector<int>(s2+1,0));
        for(int i=1;i<=s1;++i)
        {
            for(int j=1;j<=s2;++j)
            {
                if(text1[i-1]==text2[j-1])
                    dp[i][j]= 1+dp[i-1][j-1];
                else
                    dp[i][j]= max(dp[i-1][j],dp[i][j-1]);
            }
        }
        return dp[s1][s2];
    }
};
  1. 时间复杂度:O(mxn),m为text1的长度,n为text2的长度。
  2. 空间复杂度:O(mxn)

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

给定两个单词 word1 和 word2,找到使得 word1 和 word2 相同所需的最小步数,每步可以删除任意一个字符串中的一个字符。
在这里插入图片描述

解题思路

word1 和word2删除后的字符就是它们的最长公共子序列,所以删除需要最少的操作数就是两个字符的长度-2x公共子序列的长度。

C++实现

class Solution {
public:
    int minDistance(string word1, string word2) {
         int s1=word1.size(),s2=word2.size();
        vector<vector<int>>dp(s1+1,vector<int>(s2+1,0));
        for(int i=1;i<=s1;++i)
        {
            for(int j=1;j<=s2;++j)
            {
                if(word1[i-1]==word2[j-1])
                    dp[i][j]= 1+dp[i-1][j-1];
                else
                    dp[i][j]= max(dp[i-1][j],dp[i][j-1]);
            }
        }
        return s1+s2-2*dp[s1][s2];
    }
};

712-两个字符串的最小ASCII删除和

给定两个字符串s1, s2,找到使两个字符串相等所需删除字符的ASCII值的最小和。
在这里插入图片描述

解题思路

沿用1143最长公共子序列,返回word1和word2所有字符ASCII值的和-最长公共子序列的ASCII值的和。

C++实现

class Solution {
public:
    int minimumDeleteSum(string s1, string s2) {
           int S1=s1.size(),S2=s2.size();
           int res=0;
        for(int i=0;i<S1;++i)
        {
            res+=s1[i];
        }
        for(int i=0;i<S2;++i)
        {
            res+=s2[i];
        }
        vector<vector<int>>dp(S1+1,vector<int>(S2+1,0));
        for(int i=1;i<=S1;++i)
        {
            for(int j=1;j<=S2;++j)
            {
                if(s1[i-1]==s2[j-1])
                    dp[i][j]= s1[i-1]+dp[i-1][j-1];
                else
                    dp[i][j]= max(dp[i-1][j],dp[i][j-1]);
            }
        }
        return res-2*dp[S1][S2];
    }
};

62-不同路径

一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为 “Start” )。
机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为 “Finish” )。
问总共有多少条不同的路径?

在这里插入图片描述

方法一:动态规划

我们用f(i,j) 表示从左上角走到(i,j) 的路径数量,其中 i 和 j 的范围分别是 [0,m) 和 [0,n)。

由于我们每一步只能从向下或者向右移动一步,因此要想走到 (i, j),如果向下走一步,那么会从(i−1,j) 走过来;如果向右走一步,那么会从 (i,j−1) 走过来。因此我们可以写出动态规划转移方程:
在这里插入图片描述
需要注意的是,如果i=0,那么 f(i−1,j) 并不是一个满足要求的状态,我们需要忽略这一项;同理,如果 j=0,那么 f(i,j−1) 并不是一个满足要求的状态,我们需要忽略这一项。

初始条件为f(0,0)=1,即从左上角走到左上角有一种方法。

最终的答案即为f(m−1,n−1)。

细节

为了方便代码编写,我们可以将所有的f(0,j) 以及 f(i,0) 都设置为边界条件,它们的值均为 1。

class Solution {
public:
    int uniquePaths(int m, int n) {
        vector<vector<int>> f(m, vector<int>(n));
        for (int i = 0; i < m; ++i) {
            f[i][0] = 1;
        }
        for (int j = 0; j < n; ++j) {
            f[0][j] = 1;
        }
        for (int i = 1; i < m; ++i) {
            for (int j = 1; j < n; ++j) {
                f[i][j] = f[i - 1][j] + f[i][j - 1];
            }
        }
        return f[m - 1][n - 1];
    }
};
  1. 时间复杂度:O(n*m)
  2. 空间复杂度:O(n*m)

优化空间后

注意到 f(i,j) 仅与第 i 行和第 i−1 行的状态有关,因此我们可以使用滚动数组代替代码中的二维数组,使空间复杂度降低为O(n)。此外,由于我们交换行列的值并不会对答案产生影响,因此我们总可以通过交换 m 和 n 使得 m≤n,这样空间复杂度降低至O(min(m,n))。

class Solution {
public:
    int uniquePaths(int m, int n) {
        vector<vector<int>> f(n);
        for (int j = 0; j < n; ++j) {
            f[j] = 1;
        }
        for (int i = 1; i < m; ++i) {
            for (int j = 1; j < n; ++j) {
                f[j] += f[j - 1];
            }
        }
        return f[n - 1];
    }
};
  1. 时间复杂度:O(nm)
  2. 空间复杂度:O(n)

方法二:数学公式

从左上角到右下角的过程中,我们需要移动m+n−2 次,其中有 m−1 次向下移动,n−1 次向右移动。因此路径的总数,就等于从m+n−2 次移动中选择 m−1 次向下移动的方案数,即组合数:

在这里插入图片描述

class Solution {
public:
    int uniquePaths(int m, int n) {
        long long ans = 1;
        for (int x = n, y = 1; y < m; ++x, ++y) {
            ans = ans * x / y;
        }
        return ans;
    }
};
  1. 时间复杂度:O(m)。由于我们交换行列的值并不会对答案产生影响,因此我们总可以通过交换 m 和 n 使得m≤n,这样空间复杂度降低至 O(min(m,n))。
  2. 空间复杂度:O(1)。

221-最大正方形

在一个由 ‘0’ 和 ‘1’ 组成的二维矩阵内,找到只包含 ‘1’ 的最大正方形,并返回其面积。
在这里插入图片描述

方法一:暴力法

由于正方形的面积等于边长的平方,因此要找到最大正方形的面积,首先需要找到最大正方形的边长,然后计算最大边长的平方即可。

暴力法是最简单直观的做法,具体做法如下:

遍历矩阵中的每个元素,每次遇到 1,则将该元素作为正方形的左上角;
确定正方形的左上角后,根据左上角所在的行和列计算可能的最大正方形的边长(正方形的范围不能超出矩阵的行数和列数),在该边长范围内寻找只包含 1 的最大正方形;
每次在下方新增一行以及在右方新增一列,判断新增的行和列是否满足所有元素都是 1。

class Solution {
public:
    int maximalSquare(vector<vector<char>>& matrix) {
        if (matrix.size() == 0 || matrix[0].size() == 0) {
            return 0;
        }
        int maxSide = 0;
        int rows = matrix.size(), columns = matrix[0].size();
        for (int i = 0; i < rows; i++) {
            for (int j = 0; j < columns; j++) {
                if (matrix[i][j] == '1') {
                    // 遇到一个 1 作为正方形的左上角
                    maxSide = max(maxSide, 1);
                    // 计算可能的最大正方形边长
                    int currentMaxSide = min(rows - i, columns - j);
                    for (int k = 1; k < currentMaxSide; k++) {
                        // 判断新增的一行一列是否均为 1
                        bool flag = true;
                        if (matrix[i + k][j + k] == '0') {
                            break;
                        }
                        for (int m = 0; m < k; m++) {
                            if (matrix[i + k][j + m] == '0' || matrix[i + m][j + k] == '0') {
                                flag = false;
                                break;
                            }
                        }
                        if (flag) {
                            maxSide = max(maxSide, k + 1);
                        } else {
                            break;
                        }
                    }
                }
            }
        }
        int maxSquare = maxSide * maxSide;
        return maxSquare;
    }
};
  1. 时间复杂度:O(mn min(m,n)^2)
  2. 空间复杂度:O(1)

方法二:动态规划

可以使用动态规划降低时间复杂度。我们用 dp(i,j) 表示以(i,j) 为右下角,且只包含 1 的正方形的边长最大值。如果我们能计算出所有 dp(i,j) 的值,那么其中的最大值即为矩阵中只包含 1 的正方形的边长最大值,其平方即为最大正方形的面积。

那么如何计算dp 中的每个元素值呢?对于每个位置(i,j),检查在矩阵中该位置的值:

1.如果该位置的值是 0,则dp(i,j)=0,因为当前位置不可能在由 1 组成的正方形中;
2.如果该位置的值是 1,则dp(i,j) 的值由其上方、左方和左上方的三个相邻位置的dp 值决定。具体而言,当前位置的元素值等于三个相邻位置的元素中的最小值加 1,状态转移方程如下:

在这里插入图片描述

class Solution {
public:
    int maximalSquare(char **matrix) {
       // cout << typeid(matrix).name() << endl;
        int m = sizeof(matrix), n = sizeof(matrix[0]);//sizeof不能计算指针的容量
        vector<vector<int>>vec(m, vector<int>(n));
        int maxside = 0;
        for (int i = 0; i < m; ++i)
        {
            for (int j = 0; j < n; ++j)
            {
                if (matrix[i][j] == '1')
                {
                    if (i == 0 || j == 0)
                    {
                        vec[i][j] = 1;
                    }
                    else
                    {
                        vec[i][j] = min(vec[i - 1][j], min(vec[i][j - 1], vec[i - 1][j - 1])) + 1;
                        maxside = max(maxside, vec[i][j]);
                    }
                }
            }
        }
        return maxside * maxside;
    }
};

int main()
{
    //二维指针数组,无法计算行数和列数  ,函数传入参数为char **matrix
    char** array = (char**)malloc(sizeof(char*) * 2);

    for (int i = 0; i < 2; i++)
    {
        array[i] = (char*)malloc(sizeof(char) * 2);
      
    }
    //普通二维数组,可用sizeof和strlen计算行数和列数,函数传入参数为char matrix[][2]
    char array[2][2];
    int m = sizeof(array) ,n = sizeof(array[0]) ;
    array[0][0]='0'; 
    array[0][1] = '1';
    array[1][0] = '0';
    array[1][1] = '1';
    Solution s;
    s.maximalSquare(array);
}
  1. 时间复杂度:O(mn),其中 m 和 n 是矩阵的行数和列数。需要遍历原始矩阵中的每个元素计算dp 的值。
  2. 空间复杂度:O(mn),其中 m 和 n 是矩阵的行数和列数。创建了一个和原始矩阵大小相同的矩阵dp。由于状态转移方程中的 dp(i,j) 由其上方、左方和左上方的三个相邻位置的dp 值决定,因此可以使用两个一维数组进行状态转移,空间复杂度优化至 O(n)。

279-完全平方数

给你一个整数 n ,返回 和为 n 的完全平方数的最少数量 。
完全平方数 是一个整数,其值等于另一个整数的平方;换句话说,其值等于一个整数自乘的积。例如,1、4、9 和 16 都是完全平方数,而 3 和 11 不是。

在这里插入图片描述

方法一:动态规划

我们可以依据题目的要求写出状态表达式:f[i] 表示最少需要多少个数的平方来表示整数 i。

这些数必然落在区间[1, √n ]。我们可以枚举这些数,假设当前枚举到 j,那么我们还需要取若干数的平方,构成 i-j^2。此时我们发现该子问题和原问题类似,只是规模变小了。这符合了动态规划的要求,于是我们可以写出状态转移方程。
在这里插入图片描述
其中 f[0]=0 为边界条件,实际上我们无法表示数字 0,只是为了保证状态转移过程中遇到 j恰为sqrt{i} 的情况合法。同时因为计算f[i] 时所需要用到的状态仅有 f[i-j^2],必然小于 i,因此我们只需要从小到大地枚举 i 来计算 f[i] 即可。

class Solution {
public:
    int numSquares(int n) {
        vector<int> f(n + 1);
        for (int i = 1; i <= n; i++) {
            int minn = INT_MAX;
            for (int j = 1; j * j <= i; j++) {
                minn = min(minn, f[i - j * j]);
            }
            f[i] = minn + 1;
        }
        return f[n];
    }
};
  1. 时间复杂度:O(n√n),其中n为给定正整数。状态转移方程的时间复杂度为O(√n),共需要计算n个状态,因此总时间复杂度为O(n√n)。
  2. 空间复杂度:O(n)。我们需要O(n)的空间保存状态。

方法二:数学

一个数学定理可以帮助解决本题:「四平方和定理」。

四平方和定理证明了任意一个正整数都可以被表示为至多四个正整数的平方和。这给出了本题的答案的上界。

同时四平方和定理包含了一个更强的结论:当且仅当 n ≠4^k×(8m+7) 时,n 可以被表示为至多三个正整数的平方和。因此,当 n = 4^k×(8m+7) 时,n 只能被表示为四个正整数的平方和。此时我们可以直接返回 4。

当 n ≠4^k×(8m+7) 时,我们需要判断到底多少个完全平方数能够表示 n,我们知道答案只会是1,2,3 中的一个:

答案为 1 时,则必有 n 为完全平方数,这很好判断;

答案为 2 时,则有 n=a^2+b^2 ,我们只需要枚举所有的 a(1≤a≤ √n  ),判断 n-a^2是否为完全平方数即可;

答案为 3 时,我们很难在一个优秀的时间复杂度内解决它,但我们只需要检查答案为1 或 2 的两种情况,即可利用排除法确定答案。
class Solution {
public:
    // 判断是否为完全平方数
    bool isPerfectSquare(int x) {
        int y = sqrt(x);
        return y * y == x;
    }

    // 判断是否能表示为 4^k*(8m+7)
    bool checkAnswer4(int x) {
        while (x % 4 == 0) {
            x /= 4;
        }
        return x % 8 == 7;
    }

    int numSquares(int n) {
        if (isPerfectSquare(n)) {
            return 1;
        }
        if (checkAnswer4(n)) {
            return 4;
        }
        for (int i = 1; i * i <= n; i++) {
            int j = n - i * i;
            if (isPerfectSquare(j)) {
                return 2;
            }
        }
        return 3;
    }
};
  1. 时间复杂度:O(√n ),其中n为给定的正整数。最坏情况下答案为3
    ,我们需要运行所有的判断,而判断答案是否为 1 的时间复杂度为O(1),判断答案是否为 4 的时间复杂度为 O(logn)(不太懂),剩余判断为 O( n ),因此总时间复杂度为 O(logn+ √n )。
  2. 空间复杂度:O(1)。我们只需要常数的空间保存若干变量。

139-单词拆分

给你一个字符串 s 和一个字符串列表 wordDict 作为字典。请你判断是否可以利用字典中出现的单词拼接出 s 。

注意:不要求字典中出现的单词全部都使用,并且字典中的单词可以重复使用。
在这里插入图片描述

动态规划

我们定义dp[i] 表示字符串 s 前 i个字符组成的字符串 s[0…i−1] 是否能被空格拆分成若干个字典中出现的单词。从前往后计算考虑转移方程,每次转移的时候我们需要枚举包含位置 i−1 的最后一个单词,看它是否出现在字典中以及除去这部分的字符串是否合法即可。公式化来说,我们需要枚举 s[0…i−1] 中的分割点 j,看 s[0…j−1] 组成的字符串 s1(默认 j=0 时s1为空串)和s[j…i−1] 组成的字符串 s2是否都合法,如果两个字符串均合法,那么按照定义s1和 s2拼接成的字符串也同样合法。由于计算到dp[i] 时我们已经计算出了dp[0…i−1] 的值,因此字符串 s1是否合法可以直接由 dp[j] 得知,剩下的我们只需要看 s2是否合法即可,因此我们可以得出如下转移方程:
在这里插入图片描述
其中 check(s[j…i−1]) 表示子串 s[j…i−1] 是否出现在字典中。

对于检查一个字符串是否出现在给定的字符串列表里一般可以考虑哈希表来快速判断,同时也可以做一些简单的剪枝,枚举分割点的时候倒着枚举,如果分割点 j 到 i 的长度已经大于字典列表里最长的单词的长度,那么就结束枚举,但是需要注意的是下面的代码给出的是不带剪枝的写法。

对于边界条件,我们定义 dp[0]=true 表示空串且合法。

有能力的读者也可以考虑怎么结合字典树 Trie 来实现,这里不再展开。

class Solution {
public:
    bool wordBreak(string s, vector<string>& wordDict) {
        auto wordDictSet = unordered_set <string> ();
        for (auto word: wordDict) {
            wordDictSet.insert(word);
        }

        auto dp = vector <bool> (s.size() + 1);
        dp[0] = true;
        for (int i = 1; i <= s.size(); ++i) {
            for (int j = 0; j < i; ++j) {
                if (dp[j] && wordDictSet.find(s.substr(j, i - j)) != wordDictSet.end()) {
                    dp[i] = true;
                    break;
                }
            }
        }

        return dp[s.size()];
    }
};
  1. 时间复杂度:O(n^2),其中 n 为字符串 s 的长度。我们一共有 O(n) 个状态需要计算,每次计算需要枚举O(n) 个分割点,哈希表判断一个字符串是否出现在给定的字符串列表需要O(1) 的时间,因此总时间复杂度为 O(n^2)。
  2. 空间复杂度:O(n)
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值