1、斐波那契数列
题目描述:
大家都知道斐波那契数列,现在要求输入一个正整数 n ,请你输出斐波那契数列的第 n 项。斐波那契数列是一个满足:
f
i
b
(
x
)
=
{
x
=
1
x
=
0
,
x
=
2
y
=
f
i
b
(
x
−
1
)
+
f
i
b
(
x
−
2
)
x
>
2
fib(x)=\left\{ \begin{aligned} x & = & 1 && x = 0, x=2 \\ y & = & fib(x-1)+fib(x-2) && x >2\end{aligned} \right.
fib(x)={xy==1fib(x−1)+fib(x−2)x=0,x=2x>2
要求: 1 ≤ n ≤ 40 1≤n≤40 1≤n≤40,空间复杂度 O ( 1 ) O(1) O(1),时间复杂度 O ( n ) O(n) O(n) ,本题也有时间复杂度 O ( l o g n ) O(logn) O(logn) 的解法。
方法一:采用递归算法
题目分析,斐波那契数列公式为: f [ n ] = f [ n − 1 ] + f [ n − 2 ] f[n] = f[n-1] + f[n-2] f[n]=f[n−1]+f[n−2],初始值 f [ 0 ] = 0 f[0]=0 f[0]=0, f [ 1 ] = 1 f[1]=1 f[1]=1,目标求 f [ n ] f[n] f[n] 看到公式很亲切,代码随便写完。
class Solution {
public:
int Fibonacci(int n) {
if (n <= 2) return 1;
return Fibonacci(n - 1) + Fibonacci(n - 2);
}
};
优点,代码简单好写,缺点:慢,会超时
时间复杂度: O ( 2 n ) O(2^n) O(2n), 空间复杂度:递归栈的空间
方法二:记忆化搜索
首先看下:递归思想的解析图,以
F
[
5
]
F[5]
F[5] 为例:
通过图会发现,方法一中,存在很多重复计算,因为为了改进,就把计算过的保存下来。 那么用什么保存呢?一般会想到 map, 但是此处不用牛刀,此处用数组就好了。
class Solution {
public:
int f[40]{0};
int Fibonacci(int n) {
if (n <= 2) return 1;
if (f[n] > 0) return f[n];
return f[n] = (Fibonacci(n - 1) + Fibonacci(n - 2));
}
};
时间复杂度:O(n), 没有重复的计算,空间复杂度:O(n)和递归栈的空间
方法三:动态规划
虽然方法二可以解决此题了,但是如果想让空间继续优化,那就用动态规划,优化掉递归栈空间。 方法二是从上往下递归的然后再从下往上回溯的,最后回溯的时候来合并子树从而求得答案。 那么动态规划不同的是,不用递归的过程,直接从子树求得答案。过程是从下往上。
class Solution {
public:
int dp[40] {0};
int Fibonacci(int n) {
dp[1] = 1, dp[2] = 1;
for (int i = 3; i <= n; i++) {
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[n];
}
};
时间复杂度:O(n) 空间复杂度:O(n)
继续优化:发现计算f[5]的时候只用到了 f [ 4 ] f[4] f[4] 和 f [ 3 ] f[3] f[3], 没有用到 f [ 2 ] . . . f [ 0 ] f[2]...f[0] f[2]...f[0],所以保存 f [ 2 ] . . f [ 0 ] f[2]..f[0] f[2]..f[0] 是浪费了空间。 只需要用 3 个变量即可。
class Solution {
public:
int Fibonacci(int n) {
int a = 1, b = 1, c = 1;
for (int i = 3; i <= n; i++) {
c = a + b; a = b; b = c;
}
return c;
}
};
时间复杂度:O(n) 空间复杂度:O(1)
2、跳台阶问题
题目描述:
一只青蛙一次可以跳上1级台阶,也可以跳上2级。求该青蛙跳上一个 n 级的台阶总共有多少种跳法(先后次序不同算不同的结果)。
数据范围:
1
≤
n
≤
40
1 \leq n \leq 40
1≤n≤40
要求:时间复杂度:O(n),空间复杂度: O(1)
方法一:递归算法
题目分析,假设 f [ i ] f[i] f[i] 表示在第 i i i 个台阶上可能的方法数。逆向思维。如果我从第 n n n 个台阶进行下台阶,下一步有 2 2 2 中可能,一种走到第 n − 1 n-1 n−1 个台阶,一种是走到第 n − 2 n-2 n−2 个台阶。所以 f [ n ] = f [ n − 1 ] + f [ n − 2 ] f[n] = f[n-1] + f[n-2] f[n]=f[n−1]+f[n−2]。那么初始条件了, f [ 0 ] = f [ 1 ] = 1 f[0] = f[1] = 1 f[0]=f[1]=1。 所以就变成了: f [ n ] = f [ n − 1 ] + f [ n − 2 ] f[n] = f[n-1] + f[n-2] f[n]=f[n−1]+f[n−2], 初始值 f [ 0 ] = 1 f[0]=1 f[0]=1, f [ 1 ] = 1 f[1]=1 f[1]=1,目标求 f [ n ] f[n] f[n] 看到公式很亲切,代码瞬间写完。
算法代码展示:
class Solution {
public:
int jumpFloor(int number) {
if (number <= 1) return 1;
return jumpFloor(number - 1) + jumpFloor(number - 2);
}
}
优点,代码简单好写,缺点:慢,会超时
时间复杂度: O ( 2 n ) O(2^n) O(2n) 空间复杂度:递归栈的空间
方法二:记忆化搜索
通过图会发现,方法一中,存在很多重复计算,因为为了改进,就把计算过的保存下来。 那么用什么保存呢?一般会想到 map, 但是此处不用牛刀,此处用数组就好了。
class Solution {
public:
int f[50] {0};
int jumpFloor(int number) {
if (number <= 1) return 1;
if (f[number] > 0) return f[number];
return f[number] = (jumpFloor(number - 1) + jumpFloor(number - 2));
}
};
时间复杂度:O(n), 没有重复的计算 空间复杂度:O(n)和递归栈的空间
方法三:动态规划
虽然方法二可以解决此题了,但是如果想让空间继续优化,那就用动态规划,优化掉递归栈空间。 方法二是从上往下递归的然后再从下往上回溯的,最后回溯的时候来合并子树从而求得答案。 那么动态规划不同的是,不用递归的过程,直接从子树求得答案。过程是从下往上。
class Solution {
public:
int dp[40]{0};
int jumpFloor(int number) {
dp[0] = 1, dp[1] = 1;
for (int i = 2 ; i <= number ; i ++) dp[i] = dp[i - 1] + dp[i - 2];
return dp[number];
}
};
时间复杂度:O(n) 空间复杂度:O(n)
动态规划 优化变量的存储:只需要用 3 个变量即可
class Solution {
public:
int dp[50] {0};
int jumpFloor(int number) {
int a = 1, b = 2, c = 3;
if (number == 1) { return 1; }
if (number == 2) { return 2; }
for (int i = 3 ; i <= number ; i ++) {
c = a + b; a = b; b = c;
}
return c;
}
};
时间复杂度:O(n) 空间复杂度:O(1)
3、矩阵的最小路径和
题目描述:
给定一个 n * m 的矩阵 a,从左上角开始每次只能向右或者向下走,最后到达右下角的位置,路径上所有的数字累加起来就是路径和,输出所有的路径中最小的路径和。
数据范围: 1 ≤ n , m ≤ 500 1 \le n,m\le 500 1≤n,m≤500,矩阵中任意值都满足 0 ≤ a i j ≤ 1000 0 \le a_{ij} \le 1000 0≤aij≤1000
要求:时间复杂度 O ( n m ) O(nm) O(nm)
方法:动态规划(推荐使用)
动态规划算法的基本思想是:将待求解的问题分解成若干个相互联系的子问题,先求解子问题,然后从这些子问题的解得到原问题的解;对于重复出现的子问题,只在第一次遇到的时候对它进行求解,并把答案保存起来,让以后再次遇到时直接引用答案,不必重新求解。动态规划算法将问题的解决方案视为一系列决策的结果
算法思路:
最朴素的解法莫过于枚举所有的路径,然后求和,找出其中最大值。但是像这种有状态值可以转移的问题,我们可以尝试用动态规划。
算法的图示:
算法代码展示:
class Solution {
public:
int minPathSum(vector<vector<int> >& matrix) {
int row = matrix.size();
int col = matrix[0].size();
vector<vector<int> > dp(row + 1, vector<int>(col + 1, 0));
dp[0][0] = matrix[0][0];
//处理第一列
for (int i = 1; i < row; i++)
dp[i][0] = matrix[i][0] + dp[i - 1][0];
//处理第一行
for (int j = 1; j < col; j++)
dp[0][j] = matrix[0][j] + dp[0][j - 1];
for (int i = 1; i < row; i++) {
for (int j = 1; j < col; j++) {
dp[i][j] = (dp[i - 1][j] < dp[i][j - 1] ? dp[i - 1][j] : dp[i][j - 1]) + matrix[i][j];
}
}
return dp[row - 1][col - 1];
}
};
复杂度分析:
时间复杂度:O(mn),单独遍历矩阵的一行一列,然后遍历整个矩阵
空间复杂度:O(mn),辅助矩阵dp为二维数组
解法二的优化:
对于解法二,定义了(M*N)大小的dp数组,可对其进行空间复杂度优化.
在动态规划递推过程中,其方向是「从上至下」、「从左至右」,因此可以直接对原输入数组进行修改,并不会影响后续的递推过程。
class Solution {
public:
int minPathSum(vector<vector<int> >& matrix) {
int row = matrix.size(), col = matrix[0].size();
for (int i = 0; i < row; i ++) {
for (int j = 0; j < col; j ++) {
if (i == 0 && j != 0) { // 数组第一行
matrix[0][j] += matrix[0][j - 1];
} else if (j == 0 && i != 0) { // 数组第一列
matrix[i][0] += matrix[i - 1][0];
} else if (i > 0 && j > 0) {
// 两种方式取最小的
matrix[i][j] += min(matrix[i - 1][j], matrix[i][j - 1]);
}
}
}
return matrix[row - 1][col - 1];
}
};
4、接雨水问题
给定一个整形数组arr,已知其中所有的值都是非负的,将这个数组看作一个柱子高度图,计算按此排列的柱子,下雨之后能接多少雨水。(数组以外的区域高度视为0)
数据范围:数组长度
0
≤
n
≤
2
×
1
0
5
0 \le n \le 2\times10^5
0≤n≤2×105 ,数组中每个值满足
0
<
v
a
l
≤
1
0
9
0 < val \le 10^9
0<val≤109,保证返回结果满足
0
<
v
a
l
≤
1
0
9
0 < val \le 10^9
0<val≤109,要求:时间复杂度 O(n)
输入: [3,1,2,5,2,4]
返回值: 5
说明: 数组 [3,1,2,5,2,4] 表示柱子高度图,在这种情况下,可以接 5个单位的雨水,蓝色的为雨水 ,如题面图。
方法:双指针(推荐使用)
双指针指的是在遍历对象的过程中,不是普通的使用单个指针进行访问,而是使用两个指针(特殊情况甚至可以多个),两个指针或是同方向访问两个链表、或是同方向访问一个链表(快慢指针)、或是相反方向扫描(对撞指针),从而达到我们需要的目的。
思路:
我们都知道水桶的短板问题,控制水桶水量的是最短的一条板子。这道题也是类似,我们可以将整个图看成一个水桶,两边就是水桶的板,中间比较低的部分就是水桶的底,由较短的边控制水桶的最高水量。但是水桶中可能出现更高的边,比如上图第四列,它比水桶边还要高,那这种情况下它是不是将一个水桶分割成了两个水桶,而中间的那条边就是两个水桶的边。
有了这个思想,解决这道题就容易了,因为我们这里的水桶有两个边,因此可以考虑使用对撞双指针往中间靠。
具体做法:
step 1:检查数组是否为空的特殊情况
step 2:准备双指针,分别指向数组首尾元素,代表最初的两个边界
step 3:指针往中间遍历,遇到更低柱子就是底,用较短的边界减去底就是这一列的接水量,遇到更高的柱子就是新的边界,更新边界大小。
算法图示:
算法代码展示:
long long maxWater(int* arr, int arrLen ) {
int count = 0, left = 0, right = arrLen - 1;
int maxLeft = arr[left];
int maxRight = arr[right];
while (left < right) {
maxLeft = maxLeft > arr[left] ? maxLeft : arr[left];
maxRight = maxRight > arr[right] ? maxRight : arr[right];
if (maxLeft >= maxRight) {
count = count + maxRight - arr[right];
right--;
} else {
count = count + maxLeft - arr[left];
left++;
}
}
return count;
}
复杂度分析:
时间复杂度:O(n),两个指针最多共同遍历整个数组
空间复杂度:O(1),常数个变量,没有额外的辅助空间
5、岛屿数量问题
题目描述:
给一个01矩阵,1代表是陆地,0代表海洋, 如果两个1相邻,那么这两个1属于同一个岛。我们只考虑上下左右为相邻。
岛屿:相邻陆地可以组成一个岛屿(相邻:上下左右) 判断岛屿个数。01 矩阵范围<=200*200
例如:输入如下二维数组
[
[1,1,0,0,0],
[0,1,0,1,1],
[0,0,0,1,1],
[0,0,0,0,0],
[0,0,1,1,1]
]
对应的输出为3 (注:存储的01数据其实是字符’0’,‘1’)
方法一:深度优先搜索 dfs(推荐使用)
知识点:深度优先搜索(dfs) 深度优先搜索一般用于树或者图的遍历,其他有分支的(如二维矩阵)也适用。它的原理是从初始点开始,一直沿着同一个分支遍历,直到该分支结束,然后回溯到上一级继续沿着一个分支走到底,如此往复,直到所有的节点都有被访问到。
思路:
矩阵中多处聚集着1,要想统计1聚集的堆数而不重复统计,那我们可以考虑每次找到一堆相邻的1,就将其全部改成0,而将所有相邻的1改成0的步骤又可以使用深度优先搜索(dfs):当我们遇到矩阵的某个元素为1时,首先将其置为了0,然后查看与它相邻的上下左右四个方向,如果这四个方向任意相邻元素为1,则进入该元素,进入该元素之后我们发现又回到了刚刚的子问题,又是把这一片相邻区域的1全部置为0,因此可以用递归实现。
//后续四个方向遍历
if(i - 1 >= 0 && grid[i - 1][j] == '1')
dfs(grid, i - 1, j);
if(i + 1 < n && grid[i + 1][j] == '1')
dfs(grid, i + 1,j);
if(j - 1 >= 0 && grid[i][j - 1] == '1')
dfs(grid, i, j - 1);
if(j + 1 < m && grid[i][j + 1] == '1')
dfs(grid, i, j + 1);
终止条件: 进入某个元素修改其值为0后,遍历四个方向发现周围都没有1,那就不用继续递归,返回即可,或者递归到矩阵边界也同样可以结束。
返回值: 每一级的子问题就是把修改后的矩阵返回,因为其是函数引用,也不用管。
本级任务: 对于每一级任务就是将该位置的元素置为0,然后查询与之相邻的四个方向,看看能不能进入子问题。
具体做法:
- step 1:优先判断空矩阵等情况。
- step 2:从上到下从左到右遍历矩阵每一个位置的元素,如果该元素值为1,统计岛屿数量。
- step 3:接着将该位置的1改为0,然后使用dfs判断四个方向是否为1,分别进入4个分支继续修改。
算法图示:
代码展示:
class Solution {
public:
//深度优先遍历与i,j相邻的所有1
void dfs(vector<vector<char>>& grid, int i, int j) {
int n = grid.size();
int m = grid[0].size();
//置为0
grid[i][j] = '0';
//后续四个方向遍历
if(i - 1 >= 0 && grid[i - 1][j] == '1')
dfs(grid, i - 1, j);
if(i + 1 < n && grid[i + 1][j] == '1')
dfs(grid, i + 1,j);
if(j - 1 >= 0 && grid[i][j - 1] == '1')
dfs(grid, i, j - 1);
if(j + 1 < m && grid[i][j + 1] == '1')
dfs(grid, i, j + 1);
}
int solve(vector<vector<char> >& grid) {
int n = grid.size();
if (n == 0) //空矩阵的情况
return 0;
int m = grid[0].size();
int count = 0; //记录岛屿数
//遍历矩阵
for(int i = 0; i < n; i++){
for(int j = 0; j < m; j++){
if(grid[i][j] == '1'){ //遍历到1的情况
count++; //计数
dfs(grid, i, j); //将与这个1相邻的所有1置为0
}
}
}
return count;
}
};
复杂度分析:
时间复杂度:O(nm),其中m、n为矩阵的长和宽,需要遍历整个矩阵,每次dfs搜索需要经过每个值为1的元素,但是最坏情况下也只是将整个矩阵变成0,因此相当于最坏遍历矩阵2次
空间复杂度:O(nm),最坏情况下整个矩阵都是1,递归栈深度为mn
方法二:广度优先搜索(bfs)
广度优先搜索与深度优先搜索不同,它是将与某个节点直接相连的其它所有节点依次访问一次之后,再往更深处,进入与其他节点直接相连的节点。bfs的时候我们常常会借助队列的先进先出,因为从某个节点出发,我们将与它直接相连的节点都加入队列,它们优先进入,则会优先弹出,在它们弹出的时候再将与它们直接相连的节点加入,由此就可以依次按层访问。
思路:
统计岛屿的方法可以和方法一同样遍历解决,为了去重我们还是要将所有相邻的1一起改成0,这时候同样遍历连通的广度优先搜索(bfs)可以代替dfs。
具体做法:
- step 1:优先判断空矩阵等情况。
- step 2:从上到下从左到右遍历矩阵每一个位置的元素,如果该元素值为1,统计岛屿数量。
- step 3:使用bfs将遍历矩阵遇到的1以及相邻的1全部置为0:利用两个队列辅助(C++可以使用pair),每次队列进入第一个进入的1,然后遍历队列,依次探讨队首的四个方向,是否符合,如果符合则置为0,且位置坐标加入队列,继续遍历,直到队列为空。
算法图例:
代码展示:
class Solution {
public:
int solve(vector<vector<char> >& grid) {
int n = grid.size();
//空矩阵的情况
if(n == 0)
return 0;
int m = grid[0].size();
//记录岛屿数
int count = 0;
//遍历矩阵
for(int i = 0; i < n; i++){
for(int j = 0; j < m; j++){
//遇到1要将这个1及与其相邻的1都置为0
if(grid[i][j] == '1'){
//岛屿数增加
count++;
grid[i][j] = '0';
//记录后续bfs的坐标
queue<pair<int, int>> q;
q.push({i, j});
//bfs
while(!q.empty()){
auto temp = q.front();
q.pop();
int row = temp.first;
int col = temp.second;
//四个方向依次检查:不越界且为1
if(row - 1 >= 0 && grid[row - 1][col] == '1'){
q.push({row - 1, col});
grid[row - 1][col] = '0';
}
if(row + 1 < n && grid[row + 1][col] == '1'){
q.push({row + 1, col});
grid[row + 1][col] = '0';
}
if(col - 1 >= 0 && grid[row][col - 1] == '1'){
q.push({row, col - 1});
grid[row][col - 1] = '0';
}
if(col + 1 < m && grid[row][col + 1] == '1'){
q.push({row, col + 1});
grid[row][col + 1] = '0';
}
}
}
}
}
return count;
}
};
复杂度分析:
时间复杂度:O(nm),其中m、n为矩阵的长和宽,需要遍历整个矩阵,每次bfs搜索需要经过每个值为1的元素,但是最坏情况下也只是将整个矩阵变成0,因此相当于最坏遍历矩阵2次
空间复杂度:(min(n,m)),bfs最坏情况队列大小为长和宽的较小值
6、盛水最多的容器
给定一个数组height,长度为n,每个数代表坐标轴中的一个点的高度,height[i]是在第i点的高度,请问,从中选2个高度与x轴组成的容器最多能容纳多少水?
- 你不能倾斜容器
- 当n小于2时,视为不能形成容器,请返回0
- 数据保证能容纳最多的水不会超过整形范围,即不会超过2^31-1
如输入的height为[1,7,3,2,4,5,8,2,7],那么如下图:
知识点1:双指针
双指针指的是在遍历对象的过程中,不是普通的使用单个指针进行访问,而是使用两个指针(特殊情况甚至可以多个),两个指针或是同方向访问两个链表、或是同方向访问一个链表(快慢指针)、或是相反方向扫描(对撞指针),从而达到我们需要的目的。
知识点2:贪心思想
贪心思想属于动态规划思想中的一种,其基本原理是找出整体当中给的每个局部子结构的最优解,并且最终将所有的这些局部最优解结合起来形成整体上的一个最优解。
算法详细介绍
这道题利用了水桶的短板原理,较短的一边控制最大水量,因此直接用较短边长乘底部两边距离就可以得到当前情况下的容积。但是要怎么找最大值呢?
可以利用贪心思想:我们都知道容积与最短边长和底边长有关,与长的底边一定以首尾为边,但是首尾不一定够高,中间可能会出现更高但是底边更短的情况,因此我们可以使用对撞双指针向中间靠,这样底边长会缩短,因此还想要有更大容积只能是增加最短变长,此时我们每次指针移动就移动较短的一边,因为贪心思想下较长的一边比较短的一边更可能出现更大容积。
具体做法:
step 1:优先排除不能形成容器的特殊情况。
step 2:初始化双指针指向数组首尾,每次利用上述公式计算当前的容积,维护一个最大容积作为返回值。
step 3:对撞双指针向中间靠,但是依据贪心思想,每次指向较短边的指针向中间靠,另一指针不变。
class Solution:
def maxArea(self , height: List[int]) -> int:
# write code here
if len(height) < 2: return 0
leftCur = 0
rightCur = len(height) - 1
leftHeight = height[leftCur]
rightHeight = height[rightCur]
realHeight = leftHeight if leftHeight <= rightHeight else rightHeight
maxArea = realHeight * (rightCur - leftCur)
while leftCur < rightCur:
if leftHeight < rightHeight:
leftCur = leftCur + 1
if leftHeight < height[leftCur]:
leftHeight = height[leftCur]
realHeight = leftHeight if leftHeight <= rightHeight else rightHeight
maxArea = maxArea if maxArea > (rightCur - leftCur) * realHeight else (rightCur - leftCur) * realHeight
else:
rightCur = rightCur - 1
if rightHeight < height[rightCur]:
rightHeight = height[rightCur]
realHeight = leftHeight if leftHeight <= rightHeight else rightHeight
maxArea = maxArea if maxArea > (rightCur - leftCur) * realHeight else (rightCur - leftCur) * realHeight
return maxArea
C ++ 代码展示:
class Solution {
public:
int maxArea(vector<int>& height) {
//排除不能形成容器的情况
if(height.size() < 2)
return 0;
int res = 0;
//双指针左右界
int left = 0;
int right = height.size() - 1;
//共同遍历完所有的数组
while(left < right){
//计算区域水容量
int capacity = min(height[left], height[right]) * (right - left);
//维护最大值
res = max(res, capacity);
//优先舍弃较短的边
if(height[left] < height[right])
left++;
else
right--;
}
return res;
}
};
7、分糖果问题
一群孩子做游戏,现在请你根据游戏得分来发糖果,要求如下:
- 每个孩子不管得分多少,起码分到一个糖果。
- 任意两个相邻的孩子之间,得分较多的孩子必须拿多一些糖果。(若相同则无此限制)
给定一个数组 arrarr 代表得分数组,请返回最少需要多少糖果。
要求: 时间复杂度为 O(n) 空间复杂度为 O(n)
方法:贪心算法(推荐使用)
知识点:贪心思想
贪心思想属于动态规划思想中的一种,其基本原理是找出整体当中给的每个局部子结构的最优解,并且最终将所有的这些局部最优解结合起来形成整体上的一个最优解。
思路:
要想分出最少的糖果,利用贪心思想,肯定是相邻位置没有增加的情况下,大家都分到1,相邻位置有增加的情况下,分到糖果数加1就好。什么情况下会增加糖果,相邻位置有得分差异,可能是递增可能是递减,如果是递增的话,糖果依次加1,如果是递减糖果依次减1?这不符合最小,因为减到最后一个递减的位置可能不是1,必须从1开始加才是最小,那我们可以从最后一个递减的位置往前反向加1.
具体做法:
step 1:使用一个辅助数组记录每个位置的孩子分到的糖果,全部初始化为1.
step 2:从左到右遍历数组,如果右边元素比相邻左边元素大,意味着在递增,糖果数就是前一个加1,否则保持1不变。
step 3:从右到左遍历数组,如果左边元素比相邻右边元素大, 意味着在原数组中是递减部分,如果左边在上一轮中分到的糖果数更小,则更新为右边的糖果数+1,否则保持不变。
step 4:将辅助数组中的元素累加求和。
图例介绍:
算法代码:
class Solution {
public:
/**
* pick candy
* @param arr int整型vector the array
* @return int整型
*/
int candy(vector<int>& arr) {
// write code here
vector<int> nums(arr.size(), 1);
for (int i = 1; i <= arr.size(); i++) {
if (arr[i] > arr[i - 1] ) {
nums[i] = nums[i - 1] + 1;
}
}
int res = nums[arr.size() - 1];
for (int i = arr.size() - 2; i >= 0; i--) {
if (arr[i] > arr[i + 1] && nums[i] <= nums[i + 1]) {
nums[i] = nums[i + 1] + 1;
}
res += nums[i];
}
return res;
}
};
复杂度分析:
时间复杂度:O(n),单独遍历两次
空间复杂度:O(n),记录每个位置糖果数的辅助数组
8、最长的括号子串
给出一个长度为 n 的,仅包含字符 ‘(’ 和 ‘)’ 的字符串,计算最长的格式正确的括号子串的长度。
例1: 对于字符串 "(()" 来说,最长的格式正确的子串是 "()" ,长度为 2 .
例2:对于字符串 ")()())" , 来说, 最长的格式正确的子串是 "()()" ,长度为 4 .
要求时间复杂度 O(n) ,空间复杂度 O(n)
输入: "(()" 返回值: 2
算法思想一:栈
解题思路:
主要通过栈,可以在遍历给定字符串的过程中去判断到目前为止扫描的子串的有效性,同时能得到最长有效括号的长度。
具体做法是始终保持栈底元素为当前已经遍历过的元素中「最后一个没有被匹配的右括号的下标」,这样的做法主要是考虑了边界条件的处理,栈里其他元素维护左括号的下标:
- 对于遇到的每个‘(’ ,将它的下标放入栈中
- 对于遇到的每个 ‘)’ ,先弹出栈顶元素表示匹配了当前右括号:
- 如果栈为空,说明当前的右括号为没有被匹配的右括号,将其下标放入栈中来更新我们之前提到的「最后一个没有被匹配的右括号的下标」
- 如果栈不为空,当前右括号的下标减去栈顶元素即为「以该右括号为结尾的最长有效括号的长度」
- 从前往后遍历字符串并更新答案即可。
需要注意的是,如果一开始栈为空,第一个字符为左括号的时候我们会将其放入栈中,这样就不满足提及的「最后一个没有被匹配的右括号的下标」,为了保持统一,我们在一开始的时候往栈中放入一个值为 -1 的元素。
算法图解:
算法代码:
class Solution:
def longestValidParentheses(self , s ):
# write code here
stack = [-1]
ans = 0
for i, ch in enumerate(s):
if ch == '(':
# 左括号下标入栈
stack.append(i)
else:
if len(stack) > 1:
# 匹配括号
stack.pop()
# 最大括号长度
ans = max(ans, i-stack[-1])
else:
# 将其下标放入栈中
stack[-1] = i
return ans
时间复杂度O(N):N是给定字符串长度,整个过程只需要遍历一次字符串即可
空间复杂度O(N):栈的大小在最坏情况下会达到 N,因此空间复杂度为 O(N)
算法思想二:动态规划
解题思路:
我们定义 dp[i] 表示以下标 i 字符结尾的最长有效括号的长度。我们将 dp 数组全部初始化为 0 。显然有效的子串一定以 ‘)’ 结尾,因此我们可以知道以 ‘(’ 结尾的子串对应的 dp 值必定为 0 ,我们只需要求解 ‘)’ 在 dp 数组中对应位置的值
从前往后遍历字符串求解 dp 值,我们每两个字符检查一次
1、s[i] = ‘)’ 且 s[i-1] = ‘(’,表示字符串形如 ‘…()’,则可推出:
dp[i] = dp[i-2] + 2
以进行这样的转移,是因为结束部分的 “()” 是一个有效子字符串,并且将之前有效子字符串的长度增加了 2
2、s[i] = ‘)’ 且 s[i-1] = ‘)’,表示字符串形如 ‘…))’,则可推出:
如果s[i - dp[i-1] - 1] = '(',则 dp[i] = dp[i-1] + dp[i-dp[i-1]-2] + 2
考虑如果倒数第二个 ‘)’ 是一个有效子字符串的一部分(记作 subs),对于最后一个‘)’ ,如果它是一个更长子字符串的一部分,那么它一定有一个对应的 ‘(’ ,且它的位置在倒数第二个 ‘)’ 所在的有效子字符串的前面(也就是 subs的前面)。
因此,如果子字符串 subs 的前面恰好是 ‘(’ ,那么我们就用 2 加上 subs 的长度(dp[i−1])去更新 dp[i]。同时,也会把有效子串 “(subs)” 之前的有效子串的长度也加上,也就是再加上 dp[i−dp[i−1]−2]。
最后的答案即为 dp 数组中的最大值
算法图示:
算法代码:
import java.util.*;
public class Solution {
/**
*
* @param s string字符串
* @return int整型
*/
public int longestValidParentheses (String s) {
// write code here
int maxans = 0;
int[] dp = new int[s.length()];
for (int i = 1; i < s.length(); i++) {
if (s.charAt(i) == ')') {
if (s.charAt(i - 1) == '(') {
dp[i] = (i >= 2 ? dp[i - 2] : 0) + 2;
} else if (i - dp[i - 1] > 0 && s.charAt(i - dp[i - 1] - 1) == '(') {
dp[i] = dp[i - 1] + ((i - dp[i - 1]) >= 2 ? dp[i - dp[i - 1] - 2] : 0) + 2;
}
maxans = Math.max(maxans, dp[i]);
}
}
return maxans;
}
}
复杂度分析
时间复杂度O(N):N是给定字符串长度,整个过程只需要遍历一次字符串即可
空间复杂度O(N):需要一个大小为 N 的 dp 数组
算法思想三:双指针
解题思路:
利用两个计数器 left 和 right 。首先,从左到右遍历字符串,对于遇到的每个‘(’,我们增加 left 计数器,对于遇到的每个 ‘)’ ,增加 right 计数器。每当 left 计数器与 right 计数器相等时,计算当前有效字符串的长度,并且记录目前为止找到的最长子字符串。当 right 计数器比 left 计数器大时,将 left 和 right 计数器同时变回 0
这样的做法贪心地考虑了以当前字符下标结尾的有效括号长度,每次当右括号数量多于左括号数量的时候之前的字符都扔掉不再考虑,重新从下一个字符开始计算,但这样会漏掉一种情况,就是遍历的时候左括号的数量始终大于右括号的数量,即 (() ,这种时候最长有效括号是求不出来的。
解决的方法也很简单,我们只需要从右往左遍历用类似的方法计算即可,只是这个时候判断条件反了过来
1、当 left 计数器比 right 计数器大时,将 left 和 right 计数器同时变回 0
2、当 left 计数器与 right 计数器相等时,计算当前有效字符串的长度,并且记录目前为止找到的最长子字符串
算法代码:
class Solution {
public:
int longestValidParentheses(string s) {
// write code here
int left = 0, right = 0, maxlength = 0;
for (int i = 0; i < s.length(); i++) {
if (s[i] == '(') {
left++;
} else {
right++;
}
if (left == right) {
maxlength = max(maxlength, 2 * right);
} else if (right > left) {
left = right = 0;
}
}
left = right = 0;
for (int i = (int)s.length() - 1; i >= 0; i--) {
if (s[i] == '(') {
left++;
} else {
right++;
}
if (left == right) {
maxlength = max(maxlength, 2 * left);
} else if (left > right) {
left = right = 0;
}
}
return maxlength;
}
};
复杂度分析
时间复杂度O(N):N是给定字符串长度,整个过程只需要遍历一次字符串即可
空间复杂度O(1):只需要常数空间存放若干变量
9、兑换零钱问题
给定数组arr,arr中所有的值都为正整数且不重复。每个值代表一种面值的货币,每种面值的货币可以使用任意张,再给定一个aim,代表要找的钱数,求组成aim的最少货币数。
如果无解,请返回-1.
方法一:动态规划(推荐使用)
知识点:动态规划
动态规划算法的基本思想是:将待求解的问题分解成若干个相互联系的子问题,先求解子问题,然后从这些子问题的解得到原问题的解;对于重复出现的子问题,只在第一次遇到的时候对它进行求解,并把答案保存起来,让以后再次遇到时直接引用答案,不必重新求解。动态规划算法将问题的解决方案视为一系列决策的结果。
具体步骤:
算法代码:
class Solution {
public:
int minMoney(vector<int>& arr, int aim) {
//小于1的都返回0
if(aim < 1)
return 0;
//dp[i]表示凑齐i元最少需要多少货币数
vector<int> dp(aim + 1, aim + 1);
dp[0] = 0;
//遍历1-aim元
for(int i = 1; i <= aim; i++){
//每种面值的货币都要枚举
for(int j = 0; j < arr.size(); j++){
//如果面值不超过要凑的钱才能用
if(arr[j] <= i)
//维护最小值
dp[i] = min(dp[i], dp[i - arr[j]] + 1);
}
}
//如果最终答案大于aim代表无解
return dp[aim] > aim ? -1 : dp[aim];
}
};
复杂度分析:
时间复杂度:O(n⋅aim),第一层遍历枚举1元到aim元,第二层遍历枚举n种货币面值
空间复杂度:O(aim),辅助数组dp的大小
方法二:空间记忆递归(扩展思路)
知识点:递归
递归是一个过程或函数在其定义或说明中有直接或间接调用自身的一种方法,它通常把一个大型复杂的问题层层转化为一个与原问题相似的规模较小的问题来求解。因此递归过程,最重要的就是查看能不能讲原本的问题分解为更小的子问题,这是使用递归的关键。
思路:
对于需要凑成aim的钱,第一次我们可以选择使用arr[0],则后续需要凑出aim−arr[0]的钱,那后续就是上一个的子问题,可以用递归进行。因为每种面值使用不受限,因此第一次我们可以使用arr数组中每一个,同理后续每次也可以使用arr数组中每一次,因此每次递归都要遍历arr数组,相当于分枝为arr.size()的树型递归。
具体做法:
step 1:递归的时候,一旦剩余需要凑出的钱为0,则找到一种情况,记录下整个的使用货币的数量,维护最小值即可。
step 2:一旦剩余需要凑出的钱为负,则意味着这一分枝无解,返回-1.
step 3:后续每次也可以使用arr数组中一次,进入子问题。
step 4:但是树型递归的复杂度需要O(aim^{n}),重复计算过于多了,如图所示,因此我们可以用一个dp数组记录每次递归上来的结果,避免小分支重复计算,如果dp数组有值直接获取即可,不用再重复计算了。
class Solution {
public:
int recursion(vector<int>& arr, int aim, vector<int>& dp){
//组合超过了,返回-1
if(aim < 0)
return -1;
//组合刚好等于需要的零钱
if(aim == 0)
return 0;
//剩余零钱是否已经被运算过了
if(dp[aim - 1] != 0)
return dp[aim - 1];
int Min = INT_MAX;
//遍历所有面值
for(int i = 0; i < arr.size(); i++){
//递归运算选择当前的面值
int res = recursion(arr, aim - arr[i], dp);
//获取最小值
if(res >= 0 && res < Min)
Min = res + 1;
}
//更新最小值
dp[aim - 1] = Min == INT_MAX ? -1 : Min;
return dp[aim - 1];
}
int minMoney(vector<int>& arr, int aim) {
//小于1的都返回0
if(aim < 1)
return 0;
//记录递归中间的值
vector<int> dp(aim, 0);
return recursion(arr, aim, dp);
}
};
复杂度分析:
时间复杂度:O(n⋅aim),一共需要计算aim个状态的答案,每个状态需要枚举n个面值
空间复杂度:O(aim),递归栈深度及辅助数组的空间
10、打家劫舍问题
你是一个经验丰富的小偷,准备偷沿街的一排房间,每个房间都存有一定的现金,为了防止被发现,你不能偷相邻的两家,即,如果偷了第一家,就不能再偷第二家;如果偷了第二家,那么就不能偷第一家和第三家。
给定一个整数数组nums,数组中的元素表示每个房间存有的现金数额,请你计算在不被发现的前提下最多的偷窃金额。
输入:[1,2,3,4]
返回值:6
说明:最优方案是偷第 2,4 个房间
方法:动态规划(推荐使用)
知识点:动态规划
动态规划算法的基本思想是:将待求解的问题分解成若干个相互联系的子问题,先求解子问题,然后从这些子问题的解得到原问题的解;对于重复出现的子问题,只在第一次遇到的时候对它进行求解,并把答案保存起来,让以后再次遇到时直接引用答案,不必重新求解。动态规划算法将问题的解决方案视为一系列决策的结果
思路:
或许有人认为利用贪心思想,偷取最多人家的钱就可以了,要么偶数家要么奇数家全部的钱,但是有时候会为了偷取更多的钱,或许可能会连续放弃两家不偷,因此这种方案行不通,我们依旧考虑动态规划。
具体做法:
- step 1:用dp[i]表示长度为i的数组,最多能偷取到多少钱,只要每次转移状态逐渐累加就可以得到整个数组能偷取的钱。
- step 2:(初始状态) 如果数组长度为1,只有一家人,肯定是把这家人偷了,收益最大,因此dp[1]=nums[0]。
- step 3:(状态转移) 每次对于一个人家,我们选择偷他或者不偷他,如果我们选择偷那么前一家必定不能偷,因此累加的上上级的最多收益,同理如果选择不偷他,那我们最多可以累加上一级的收益。因此转移方程为dp[i]=max(dp[i−1],nums[i−1]+dp[i−2])。这里的i在dp中为数组长度,在nums中为下标。
算法图示:
代码展示:
class Solution {
public:
int rob(vector<int>& nums) {
//dp[i]表示长度为i的数组,最多能偷取多少钱
vector<int> dp(nums.size() + 1, 0);
//长度为1只能偷第一家
dp[1] = nums[0];
for(int i = 2; i <= nums.size(); i++)
//对于每家可以选择偷或者不偷
dp[i] = max(dp[i - 1], nums[i - 1] + dp[i - 2]);
return dp[nums.size()];
}
};
复杂度分析:
时间复杂度:O(n),其中nnn为数组长度,遍历一次数组
空间复杂度:O(n),动态规划辅助数组的空间
12、把数字翻译成字符串
有一种将字母编码成数字的方式:‘a’->1, ‘b->2’, … , ‘z->26’。现在给一串数字,返回有多少种可能的译码结果。数据范围:字符串长度满足 900<n≤90
输入: "12"
返回值: 2
说明: 2种可能的译码结果(”ab” 或”l”)
方法:动态规划(推荐使用)
知识点:动态规划
动态规划算法的基本思想是:将待求解的问题分解成若干个相互联系的子问题,先求解子问题,然后从这些子问题的解得到原问题的解;对于重复出现的子问题,只在第一次遇到的时候对它进行求解,并把答案保存起来,让以后再次遇到时直接引用答案,不必重新求解。动态规划算法将问题的解决方案视为一系列决策的结果。
思路:
对于普通数组1-9,译码方式只有一种,但是对于11-19,21-26,译码方式有可选择的两种方案,因此我们使用动态规划将两种方案累计。
具体做法:
算法代码:
class Solution {
public:
int solve(string nums) {
//排除0
if(nums == "0")
return 0;
//排除只有一种可能的10 和 20
if(nums == "10" || nums == "20")
return 1;
//当0的前面不是1或2时,无法译码,0种
for(int i = 1; i < nums.length(); i++){
if(nums[i] == '0')
if(nums[i - 1] != '1' && nums[i - 1] != '2')
return 0;
}
//辅助数组初始化为1
vector<int> dp(nums.length() + 1, 1);
for(int i = 2; i <= nums.length(); i++){
//在11-19,21-26之间的情况
if((nums[i - 2] == '1' && nums[i - 1] != '0') || (nums[i - 2] == '2' && nums[i - 1] > '0' && nums[i - 1] < '7'))
dp[i] = dp[i - 1] + dp[i - 2];
else
dp[i] = dp[i - 1];
}
return dp[nums.length()];
}
};
复杂度分析:
时间复杂度:O(n),两次遍历都是单层
空间复杂度:O(n),辅助数组dp