11. 盛最多水的容器
给定一个长度为 n 的整数数组 height 。有 n 条垂线,第 i 条线的两个端点是 (i, 0) 和 (i, height[i]) 。
找出其中的两条线,使得它们与 x 轴共同构成的容器可以容纳最多的水。
返回容器可以储存的最大水量。
说明:你不能倾斜容器。
示例 1:
输入:[1,8,6,2,5,4,8,3,7]
输出:49
解释:图中垂直线代表输入数组 [1,8,6,2,5,4,8,3,7]。在此情况下,容器能够容纳水(表示为蓝色部分)的最大值为 49。
示例 2:
输入:height = [1,1]
输出:1
提示:
n == height.length
2 <= n <= 105
0 <= height[i] <= 104
我的方法:暴力,超时
方法一:双指针
本题是一道经典的面试题,最优的做法是使用「双指针」。如果读者第一次看到这题,不一定能想出双指针的做法。
分析
我们先从题目中的示例开始,一步一步地解释双指针算法的过程。稍后再给出算法正确性的证明。
题目中的示例为:
[1, 8, 6, 2, 5, 4, 8, 3, 7]
^ ^
在初始时,左右指针分别指向数组的左右两端,它们可以容纳的水量为 min ( 1 , 7 ) ∗ 8 = 8 \min(1, 7) * 8 = 8 min(1,7)∗8=8。
此时我们需要移动一个指针。移动哪一个呢?直觉告诉我们,应该移动对应数字较小的那个指针(即此时的左指针)。这是因为,由于容纳的水量是由
两个指针指向的数字中较小值 * 指针之间的距离
两个指针指向的数字中较小值∗指针之间的距离
决定的。如果我们移动数字较大的那个指针,那么前者「两个指针指向的数字中较小值」不会增加,后者「指针之间的距离」会减小,那么这个乘积会减小。因此,我们移动数字较大的那个指针是不合理的。因此,我们移动 数字较小的那个指针。
有读者可能会产生疑问:我们可不可以同时移动两个指针? 先别急,我们先假设 总是移动数字较小的那个指针 的思路是正确的,在走完流程之后,我们再去进行证明。
所以,我们将左指针向右移动:
[1, 8, 6, 2, 5, 4, 8, 3, 7]
^ ^
此时可以容纳的水量为 min ( 8 , 7 ) ∗ 7 = 49 \min(8, 7) * 7 = 49 min(8,7)∗7=49。由于右指针对应的数字较小,我们移动右指针:
[1, 8, 6, 2, 5, 4, 8, 3, 7]
^ ^
此时可以容纳的水量为 min ( 8 , 3 ) ∗ 6 = 18 \min(8, 3) * 6 = 18 min(8,3)∗6=18。由于右指针对应的数字较小,我们移动右指针:
[1, 8, 6, 2, 5, 4, 8, 3, 7]
^ ^
此时可以容纳的水量为 min ( 8 , 8 ) ∗ 5 = 40 \min(8, 8) * 5 = 40 min(8,8)∗5=40。两指针对应的数字相同,我们可以任意移动一个,例如左指针:
[1, 8, 6, 2, 5, 4, 8, 3, 7]
^ ^
此时可以容纳的水量为 min ( 6 , 8 ) ∗ 4 = 24 \min(6, 8) * 4 = 24 min(6,8)∗4=24。由于左指针对应的数字较小,我们移动左指针,并且可以发现,在这之后左指针对应的数字总是较小,因此我们会一直移动左指针,直到两个指针重合。在这期间,对应的可以容纳的水量为: min ( 2 , 8 ) ∗ 3 = 6 \min(2, 8) * 3 = 6 min(2,8)∗3=6, min ( 5 , 8 ) ∗ 2 = 10 \min(5, 8) * 2 = 10 min(5,8)∗2=10, min ( 4 , 8 ) ∗ 1 = 4 \min(4, 8) * 1 = 4 min(4,8)∗1=4。
在我们移动指针的过程中,计算到的最多可以容纳的数量为 4949,即为最终的答案。
证明
为什么双指针的做法是正确的?
双指针代表了什么?
双指针代表的是 可以作为容器边界的所有位置的范围。在一开始,双指针指向数组的左右边界,表示 数组中所有的位置都可以作为容器的边界,因为我们还没有进行过任何尝试。在这之后,我们每次将 对应的数字较小的那个指针 往 另一个指针 的方向移动一个位置,就表示我们认为 这个指针不可能再作为容器的边界了。
为什么对应的数字较小的那个指针不可能再作为容器的边界了?
在上面的分析部分,我们对这个问题有了一点初步的想法。这里我们定量地进行证明。
考虑第一步,假设当前左指针和右指针指向的数分别为 x 和 y ,不失一般性,我们假设
x
≤
y
x \leq y
x≤y。同时,两个指针之间的距离为 t。那么,它们组成的容器的容量为:
min
(
x
,
y
)
∗
t
=
x
∗
t
\min(x, y) * t = x * t
min(x,y)∗t=x∗t
我们可以断定,如果我们保持左指针的位置不变,那么无论右指针在哪里,这个容器的容量都不会超过 x ∗ t x * t x∗t 了。注意这里右指针只能向左移动,因为 我们考虑的是第一步,也就是 指针还指向数组的左右边界的时候。
我们任意向左移动右指针,指向的数为 y 1 y_1 y1,两个指针之间的距离为 t 1 t_1 t1,那么显然有 t 1 < t t_1 < t t1<t,并且 min ( x , y 1 ) ≤ min ( x , y ) \min(x, y_1) \leq \min(x, y) min(x,y1)≤min(x,y):
-
如果 y 1 ≤ y y_1 \leq y y1≤y,那么 min ( x , y 1 ) ≤ min ( x , y ) \min(x, y_1) \leq \min(x, y) min(x,y1)≤min(x,y);
-
如果 y 1 > y y_1 > y y1>y ,那么 min ( x , y 1 ) = x = min ( x , y ) \min(x, y_1) = x = \min(x, y) min(x,y1)=x=min(x,y)。
因此有(无论怎么移动右指针,值都会变小):
min
(
x
,
y
t
)
∗
t
1
<
min
(
x
,
y
)
∗
t
\min(x, y_t) * t_1 < \min(x, y) * t
min(x,yt)∗t1<min(x,y)∗t
即无论我们怎么移动右指针,得到的容器的容量都小于移动前容器的容量。也就是说,这个左指针对应的数不会作为容器的边界了,那么我们就可以丢弃这个位置,将左指针向右移动一个位置,此时新的左指针于原先的右指针之间的左右位置,才可能会作为容器的边界。
这样以来,我们将问题的规模减小了 1 ,被我们丢弃的那个位置就相当于消失了。此时的左右指针,就指向了一个新的、规模减少了的问题的数组的左右边界,因此,我们可以继续像之前 考虑第一步 那样考虑这个问题:
-
求出当前双指针对应的容器的容量;
-
对应数字较小的那个指针以后不可能作为容器的边界了,将其丢弃,并移动对应的指针。
最后的答案是什么?
答案就是我们每次以双指针为左右边界(也就是「数组」的左右边界)计算出的容量中的最大值。
int maxArea(vector<int>& height) {
int ans = 0;
int l = 0, r = height.size() - 1;
while (l < r) {
ans = max(ans, min(height[r], height[l]) * (r - l));
if (height[r] > height[l]) l++;
else r--;
}
return ans;
}
复杂度分析
-
时间复杂度:O(N) ,双指针总计最多遍历整个数组一次。
-
空间复杂度:O(1) ,只需要额外的常数级别的空间。
122. 买卖股票的最佳时机 II
给定一个数组 prices ,其中 prices[i] 表示股票第 i 天的价格。
在每一天,你可能会决定购买和/或出售股票。你在任何时候 最多 只能持有 一股 股票。你也可以购买它,然后在 同一天 出售。
返回 你能获得的 最大 利润 。
示例 1:
输入: prices = [7,1,5,3,6,4]
输出: 7
解释: 在第 2 天(股票价格 = 1)的时候买入,在第 3 天(股票价格 = 5)的时候卖出, 这笔交易所能获得利润 = 5-1 = 4 。
随后,在第 4 天(股票价格 = 3)的时候买入,在第 5 天(股票价格 = 6)的时候卖出, 这笔交易所能获得利润 = 6-3 = 3 。
示例 2:
输入: prices = [1,2,3,4,5]
输出: 4
解释: 在第 1 天(股票价格 = 1)的时候买入,在第 5 天 (股票价格 = 5)的时候卖出, 这笔交易所能获得利润 = 5-1 = 4 。
注意你不能在第 1 天和第 2 天接连购买股票,之后再将它们卖出。因为这样属于同时参与了多笔交易,你必须在再次购买前出售掉之前的股票。
示例 3:
输入: prices = [7,6,4,3,1]
输出: 0
解释: 在这种情况下, 没有交易完成, 所以最大利润为 0。
提示:
1 <= prices.length <= 3 * 104
0 <= prices[i] <= 104
我的解法:遍历+if-else
bool state = false; int n = prices.size();
int ans = 0, buy, sold;
for (int i = 0; i < n; ++i) {
if (!state && i != n-1 && prices[i + 1] > prices[i]) {
state = true;
buy = i;
}
if (state) {
if (i != n - 1 && prices[i + 1] < prices[i]) {
sold = i; state = false;
ans += prices[sold] - prices[buy];
}
}
if (i == n - 1) {
if (state) {
if (prices[i] > prices[buy]) ans+= prices[i] - prices[buy];
}
}
}
return ans;
}
复杂度:
时间:O(n)
空间:O(1)
方法二:贪心
由于股票的购买没有限制,因此整个问题等价于寻找 x 个不相交的区间
(
l
i
,
r
i
]
(l_i,r_i]
(li,ri] 使得如下的等式最大化
∑
i
=
1
x
a
[
r
i
]
−
a
[
l
i
]
\sum_{i=1}^{x} a[r_i]-a[l_i]
i=1∑xa[ri]−a[li]
其中 l i l_i li 表示在第 l i l_i li 天买入, r i r_i ri 表示在第 r i r_i ri 天卖出。
同时我们注意到对于
(
l
i
,
r
i
]
(l_i,r_i]
(li,ri] 这一个区间贡献的价值
a
[
r
i
]
−
a
[
l
i
]
a[r_i]-a[l_i]
a[ri]−a[li],其实等价于
(
l
i
,
l
i
+
1
]
,
(
l
i
+
1
,
l
i
+
2
]
,
…
,
(
r
i
−
1
,
r
i
]
(l_i,l_i+1],(l_i+1,l_i+2],\ldots,(r_i-1,r_i]
(li,li+1],(li+1,li+2],…,(ri−1,ri]这若干个区间长度为 1 的区间的价值和,即
a
[
r
i
]
−
a
[
l
i
]
=
(
a
[
r
i
]
−
a
[
r
i
−
1
]
)
+
(
a
[
r
i
−
1
]
−
a
[
r
i
−
2
]
)
+
…
+
(
a
[
l
i
+
1
]
−
a
[
l
i
]
)
a[r_i]-a[l_i]=(a[r_i]-a[r_i-1])+(a[r_i-1]-a[r_i-2])+\ldots+(a[l_i+1]-a[l_i])
a[ri]−a[li]=(a[ri]−a[ri−1])+(a[ri−1]−a[ri−2])+…+(a[li+1]−a[li])
因此问题可以简化为找 x 个长度为 1 的区间
(
l
i
,
l
i
+
1
]
(l_i,l_i+1]
(li,li+1]使得
∑
i
=
1
x
a
[
l
i
+
1
]
−
a
[
l
i
]
\sum_{i=1}^{x} a[l_i+1]-a[l_i]
∑i=1xa[li+1]−a[li]价值最大化。
贪心的角度考虑我们每次选择贡献大于 0 的区间即能使得答案最大化,因此最后答案为
ans
=
∑
i
=
1
n
−
1
max
{
0
,
a
[
i
]
−
a
[
i
−
1
]
}
\textit{ans}=\sum_{i=1}^{n-1}\max\{0,a[i]-a[i-1]\}
ans=i=1∑n−1max{0,a[i]−a[i−1]}
其中 n 为数组的长度。
需要说明的是,贪心算法只能用于计算最大利润,计算的过程并不是实际的交易过程。
考虑题目中的例子
[
1
,
2
,
3
,
4
,
5
]
[1,2,3,4,5]
[1,2,3,4,5],数组的长度
n
=
5
n=5
n=5,由于对所有的
1
≤
i
<
n
1 \le i < n
1≤i<n都有
a
[
i
]
>
a
[
i
−
1
]
a[i]>a[i-1]
a[i]>a[i−1],因此答案为
ans
=
∑
i
=
1
n
−
1
a
[
i
]
−
a
[
i
−
1
]
=
4
\textit{ans}=\sum_{i=1}^{n-1}a[i]-a[i-1]=4
ans=i=1∑n−1a[i]−a[i−1]=4
但是实际的交易过程并不是进行 4 次买入和 4 次卖出,而是在第 1 天买入,第 5 天卖出。
int maxProfit(vector<int>& prices) {
int ans = 0;
int n = prices.size();
for (int i = 1; i < n; ++i) {
ans += max(0, prices[i] - prices[i - 1]);
}
return ans;
}
复杂度分析
- 时间复杂度:O(n),其中n 为数组的长度。我们只需要遍历一次数组即可。
- 空间复杂度:O(1)。只需要常数空间存放若干变量。
方法三:动态规划
考虑到「不能同时参与多笔交易」,因此每天交易结束后只可能存在手里有一支股票或者没有股票的状态。
定义状态 dp [ i ] [ 0 ] \textit{dp}[i][0] dp[i][0] 表示第 i 天交易完后手里没有股票的最大利润, dp [ i ] [ 1 ] \textit{dp}[i][1] dp[i][1] 表示第 i 天交易完后手里持有一支股票的最大利润(i 从 0 开始)。
考虑
dp
[
i
]
[
0
]
\textit{dp}[i][0]
dp[i][0] 的转移方程,如果这一天交易完后手里没有股票,那么可能的转移状态为前一天已经没有股票,即
dp
[
i
−
1
]
[
0
]
\textit{dp}[i-1][0]
dp[i−1][0],或者前一天结束的时候手里持有一支股票,即
dp
[
i
−
1
]
[
1
]
\textit{dp}[i-1][1]
dp[i−1][1],这时候我们要将其卖出,并获得
prices
[
i
]
\textit{prices}[i]
prices[i] 的收益。因此为了收益最大化,我们列出如下的转移方程:
dp
[
i
]
[
0
]
=
max
{
dp
[
i
−
1
]
[
0
]
,
dp
[
i
−
1
]
[
1
]
+
prices
[
i
]
}
\textit{dp}[i][0]=\max\{\textit{dp}[i-1][0],\textit{dp}[i-1][1]+\textit{prices}[i]\}
dp[i][0]=max{dp[i−1][0],dp[i−1][1]+prices[i]}
再来考虑
dp
[
i
]
[
1
]
\textit{dp}[i][1]
dp[i][1],按照同样的方式考虑转移状态,那么可能的转移状态为前一天已经持有一支股票,即
dp
[
i
−
1
]
[
1
]
\textit{dp}[i-1][1]
dp[i−1][1],或者前一天结束时还没有股票,即
dp
[
i
−
1
]
[
0
]
\textit{dp}[i-1][0]
dp[i−1][0],这时候我们要将其买入,并减少
prices
[
i
]
\textit{prices}[i]
prices[i] 的收益。可以列出如下的转移方程:
dp
[
i
]
[
1
]
=
max
{
dp
[
i
−
1
]
[
1
]
,
dp
[
i
−
1
]
[
0
]
−
prices
[
i
]
}
\textit{dp}[i][1]=\max\{\textit{dp}[i-1][1],\textit{dp}[i-1][0]-\textit{prices}[i]\}
dp[i][1]=max{dp[i−1][1],dp[i−1][0]−prices[i]}
对于初始状态,根据状态定义我们可以知道第 00 天交易结束的时候
dp
[
0
]
[
0
]
=
0
\textit{dp}[0][0]=0
dp[0][0]=0,
dp
[
0
]
[
1
]
=
−
prices
[
0
]
\textit{dp}[0][1]=-\textit{prices}[0]
dp[0][1]=−prices[0]。
因此,我们只要从前往后依次计算状态即可。由于全部交易结束后,持有股票的收益一定低于不持有股票的收益,因此这时候 dp [ n − 1 ] [ 0 ] \textit{dp}[n-1][0] dp[n−1][0] 的收益必然是大于 dp [ n − 1 ] [ 1 ] \textit{dp}[n-1][1] dp[n−1][1] 的,最后的答案即为 dp [ n − 1 ] [ 0 ] \textit{dp}[n-1][0] dp[n−1][0]。
int maxProfit(vector<int>& prices) {
int n = prices.size();
int dp[n][2];
dp[0][0] = 0, dp[0][1] = -prices[0];
for (int i = 1; i < n; ++i) {
dp[i][0] = max(dp[i - 1][0], dp[i - 1][1] + prices[i]);
dp[i][1] = max(dp[i - 1][1], dp[i - 1][0] - prices[i]);
}
return dp[n - 1][0];
}
滚动数组
int maxProfit(vector<int>& prices) {
int n = prices.size();
int dp0 = 0, dp1 = -prices[0];
for (int i = 1; i < n; ++i) {
int newDp0 = max(dp0, dp1 + prices[i]);
int newDp1 = max(dp1, dp0 - prices[i]);
dp0 = newDp0;
dp1 = newDp1;
}
return dp0;
}
31. 下一个排列
整数数组的一个 排列 就是将其所有成员以序列或线性顺序排列。
例如,arr = [1,2,3] ,以下这些都可以视作 arr 的排列:[1,2,3]、[1,3,2]、[3,1,2]、[2,3,1] 。
整数数组的 下一个排列 是指其整数的下一个字典序更大的排列。更正式地,如果数组的所有排列根据其字典顺序从小到大排列在一个容器中,那么数组的 下一个排列 就是在这个有序容器中排在它后面的那个排列。如果不存在下一个更大的排列,那么这个数组必须重排为字典序最小的排列(即,其元素按升序排列)。
例如,arr = [1,2,3] 的下一个排列是 [1,3,2] 。
类似地,arr = [2,3,1] 的下一个排列是 [3,1,2] 。
而 arr = [3,2,1] 的下一个排列是 [1,2,3] ,因为 [3,2,1] 不存在一个字典序更大的排列。
给你一个整数数组 nums ,找出 nums 的下一个排列。
必须 原地 修改,只允许使用额外常数空间。
示例 1:
输入:nums = [1,2,3]
输出:[1,3,2]
示例 2:
输入:nums = [3,2,1]
输出:[1,2,3]
示例 3:
输入:nums = [1,1,5]
输出:[1,5,1]
提示:
1 <= nums.length <= 100
0 <= nums[i] <= 100
方法:贪心,通过顺序对找第一个波峰
刚看这道题时,感觉自己没有思路,此时可以自己模拟一下排列的过程
以数字序列
[
1
,
2
,
3
]
[1,2,3]
[1,2,3] 为例,其排列按照字典序依次为:
[
1
,
2
,
3
]
[
1
,
3
,
2
]
[
2
,
1
,
3
]
[
2
,
3
,
1
]
[
3
,
1
,
2
]
[
3
,
2
,
1
]
\begin{aligned} [1,2,3] \\ [1,3,2]\\ [2,1,3]\\ [2,3,1]\\ [3,1,2]\\ [3,2,1] \end{aligned}
[1,2,3][1,3,2][2,1,3][2,3,1][3,1,2][3,2,1]
从字典序的变换过程可以知道:
- 我们需要将一个左边的「较小数」与一个右边的「较大数」交换,以能够让当前排列变大,从而得到下一个排列。
- 同时我们要让这个「较小数」尽量靠右,而「较大数」尽可能小。当交换完成后,「较大数」右边的数需要按照升序重新排列。这样可以在保证新排列大于原来排列的情况下,使变大的幅度尽可能小。
class Solution:
def nextPermutation(self, nums: List[int]) -> None:
i = len(nums) - 2
while i>=0 and nums[i] >= nums[i+1]:
i -= 1 # 寻找第一个波峰 (i,i+1,i+2)
if i >= 0: # i 为第一个顺序对,说明 [i+1: n) 为递减序列
j = len(nums)-1 # 找到第一个比 nums[i] 小的数 nums[j]
while j >= 0 and nums[i] >= nums[j]:
j -= 1
nums[i], nums[j] = nums[j], nums[i]
l, r = i+1, len(nums)-1
while l<r: # 此时的 nums[i+1:] 为降序排列
nums[l], nums[r] = nums[r], nums[l]
l += 1
r -= 1
复杂度:
时间:
O
(
N
)
O(N)
O(N)
空间:
O
(
1
)
O(1)
O(1)