勇者抽刃向强者的博客

梦是现实的延续,现实是梦的终结

买卖股票的最佳时机II

1.题目内容

给你一个整数数组 prices ,其中 prices[i] 表示某支股票第 i 天的价格。

在每一天,你可以决定是否购买和/或出售股票。你在任何时候 最多 只能持有 一股 股票。你也可以先购买,然后在 同一天 出售。

返回 你能获得的 最大 利润

示例 1:

1
2
3
4
5
输入:prices = [7,1,5,3,6,4]
输出:7
解释:在第 2 天(股票价格 = 1)的时候买入,在第 3 天(股票价格 = 5)的时候卖出, 这笔交易所能获得利润 = 5 - 1 = 4 。
随后,在第 4 天(股票价格 = 3)的时候买入,在第 5 天(股票价格 = 6)的时候卖出, 这笔交易所能获得利润 = 6 - 3 = 3 。
总利润为 4 + 3 = 7 。

示例 2:

1
2
3
4
输入:prices = [1,2,3,4,5]
输出:4
解释:在第 1 天(股票价格 = 1)的时候买入,在第 5 天 (股票价格 = 5)的时候卖出, 这笔交易所能获得利润 = 5 - 1 = 4 。
总利润为 4 。

示例 3:

1
2
3
输入:prices = [7,6,4,3,1]
输出:0
解释:在这种情况下, 交易无法获得正利润,所以不参与交易可以获得最大利润,最大利润为 0 。

提示:

  • 1 <= prices.length <= 3 * 104
  • 0 <= prices[i] <= 104

2.解法

(1)动态规划

思路及算法

考虑到「不能同时参与多笔交易」,因此每天交易结束后只可能存在手里有一支股票或者没有股票的状态。

定义状态 dp[i] [0]表示第 i 天交易完后手里没有股票的最大利润,dp[i] [1] 表示第 i 天交易完后手里持有一支股票的最大利润(i 从 000 开始)。

考虑 dp[i] [0] 的转移方程,如果这一天交易完后手里没有股票,那么可能的转移状态为前一天已经没有股票,即 dp[i−1] [0],或者前一天结束的时候手里持有一支股票,即 dp[i−1] [1],这时候我们要将其卖出,并获得 prices[i] 的收益。因此为了收益最大化,我们列出如下的转移方程:

dp[i] [0]=max⁡{dp[i−1] [0],dp[i−1] [1]+prices[i]}
再来考虑 dp[i] [1],按照同样的方式考虑转移状态,那么可能的转移状态为前一天已经持有一支股票,即 dp[i−1] [1],或者前一天结束时还没有股票,即 dp[i−1] [0],这时候我们要将其买入,并减少 prices[i] 的收益。可以列出如下的转移方程:

dp[i] [1]=max⁡{dp[i−1] [1],dp[i−1] [0]−prices[i]}
对于初始状态,根据状态定义我们可以知道第 0 天交易结束的时候 dp[0] [0]dp[0] [1]=−prices[0]

因此,我们只要从前往后依次计算状态即可。由于全部交易结束后,持有股票的收益一定低于不持有股票的收益,因此这时候 dp[n−1] [0] 的收益必然是大于 dp[n−1] [1] 的,最后的答案即为 dp[n−1] [0]

代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//C++
class Solution {
public:
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];
}
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
//Java
class Solution {
public int maxProfit(int[] prices) {
int n = prices.length;
int dp0 = 0, dp1 = -prices[0];
for (int i = 1; i < n; ++i) {
int newDp0 = Math.max(dp0, dp1 + prices[i]);
int newDp1 = Math.max(dp1, dp0 - prices[i]);
dp0 = newDp0;
dp1 = newDp1;
}
return dp0;
}
}

注意到上面的状态转移方程中,每一天的状态只与前一天的状态有关,而与更早的状态都无关,因此我们不必存储这些无关的状态,只需要将

dp[i−1] [0]dp[i−1] [1] 存放在两个变量中,通过它们计算出 dp[i] [0]dp[i] [1]并存回对应的变量,以便于第 i+1 天的状态转移即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//C++
class Solution {
public:
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;
}
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
//Java
class Solution {
public int maxProfit(int[] prices) {
int n = prices.length;
int dp0 = 0, dp1 = -prices[0];
for (int i = 1; i < n; ++i) {
int newDp0 = Math.max(dp0, dp1 + prices[i]);
int newDp1 = Math.max(dp1, dp0 - prices[i]);
dp0 = newDp0;
dp1 = newDp1;
}
return dp0;
}
}

复杂度分析

  • 时间复杂度:O(n),其中 n 为数组的长度。一共有 2n 个状态,每次状态转移的时间复杂度为 O(1),因此时间复杂度为 O(2n)=O(n)。

  • 空间复杂度:O(n)。我们需要开辟 O(n) 空间存储动态规划中的所有状态。如果使用空间优化,空间复杂度可以优化至 O(1)。

(2)贪心

思路及算法

由于股票的购买没有限制,因此整个问题等价于寻找 x 个不相交的区间 (li,ri] 使得如下的等式最大化

$\sum\limits_{i=1}^xa[ri]-a[li]$

其中 li 表示在第 li 天买入,ri 表示在第 ri 天卖出。同时我们注意到对于 (li,ri] 这一个区间贡献的价值 a[ri]−a[li],其实等价于 (li,li+1],(li+1,li+2],…,(ri−1,ri](l_i,l_i+1],这若干个区间长度为 1 的区间的价值和,即

a[ri]-a[li]=(a[ri]-a[ri-1])+(a[ri-1]-a[ri-2])+…+(a[li+1]-a[li])

因此问题可以简化为找 x 个长度为 111 的区间 (li,li+1] 使得 $\sum\limits_{i=1}^xa[li+1]-a[li]$ 价值最大化。贪心的角度考虑我们每次选择贡献大于 0 的区间即能使得答案最大化,因此最后答案为

ans=$\sum\limits_{i=1}^na[i]-a[i-1]$=4

代码

1
2
3
4
5
6
7
8
9
10
11
12
//C++
class Solution {
public:
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;
}
};
1
2
3
4
5
6
7
8
9
10
11
//Java
class Solution {
public int maxProfit(int[] prices) {
int ans = 0;
int n = prices.length;
for (int i = 1; i < n; ++i) {
ans += Math.max(0, prices[i] - prices[i - 1]);
}
return ans;
}
}

复杂度分析

  • 时间复杂度:O(n),其中 n 为数组的长度。我们只需要遍历一次数组即可。

  • 空间复杂度:O(1)。只需要常数空间存放若干变量。

买卖股票的最佳时机I

1.题目内容

给定一个数组 prices ,它的第 i 个元素 prices[i] 表示一支给定股票第 i 天的价格。

你只能选择 某一天 买入这只股票,并选择在 未来的某一个不同的日子 卖出该股票。设计一个算法来计算你所能获取的最大利润。

返回你可以从这笔交易中获取的最大利润。如果你不能获取任何利润,返回 0

示例 1:

1
2
3
4
输入:[7,1,5,3,6,4]
输出:5
解释:在第 2 天(股票价格 = 1)的时候买入,在第 5 天(股票价格 = 6)的时候卖出,最大利润 = 6-1 = 5 。
注意利润不能是 7-1 = 6, 因为卖出价格需要大于买入价格;同时,你不能在买入前卖出股票。

示例 2:

1
2
3
输入:prices = [7,6,4,3,1]
输出:0
解释:在这种情况下, 没有交易完成, 所以最大利润为 0。

提示:

  • 1 <= prices.length <= 105
  • 0 <= prices[i] <= 104

2.解法

解决方案

我们需要找出给定数组中两个数字之间的最大差值(即,最大利润)。此外,第二个数字(卖出价格)必须大于第一个数字(买入价格)。

形式上,对于每组 ij(其中 j>i)我们需要找出 max⁡(prices[j]−prices[i])

(1)暴力法【超时】

代码

1
2
3
4
5
6
7
8
9
10
11
12
13
//C++
class Solution {
public:
int maxProfit(vector<int>& prices) {
int n = (int)prices.size(), ans = 0;
for (int i = 0; i < n; ++i){
for (int j = i + 1; j < n; ++j) {
ans = max(ans, prices[j] - prices[i]);
}
}
return ans;
}
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//Java
public class Solution {
public int maxProfit(int[] prices) {
int maxprofit = 0;
for (int i = 0; i < prices.length - 1; i++) {
for (int j = i + 1; j < prices.length; j++) {
int profit = prices[j] - prices[i];
if (profit > maxprofit) {
maxprofit = profit;
}
}
}
return maxprofit;
}
}

复杂度分析

  • 时间复杂度:O(n^2^)。循环运行 $\dfrac{n (n-1)}{2} $次。
  • 空间复杂度:O(1)。只使用了常数个变量。

(2)一次遍历

思路及算法

假设给定的数组为:[7, 1, 5, 3, 6, 4]

如果我们在图表上绘制给定数组中的数字,我们将会得到:

Profit Graph

我们来假设自己来购买股票。随着时间的推移,每天我们都可以选择出售股票与否。那么,假设在第 i 天,如果我们要在今天卖股票,那么我们能赚多少钱呢?

显然,如果我们真的在买卖股票,我们肯定会想:如果我是在历史最低点买的股票就好了!太好了,在题目中,我们只要用一个变量记录一个历史最低价格 minprice,我们就可以假设自己的股票是在那天买的。那么我们在第 i 天卖出股票能得到的利润就是 prices[i] - minprice

因此,我们只需要遍历价格数组一遍,记录历史最低点,然后在每一天考虑这么一个问题:如果我是在历史最低点买进的,那么我今天卖出能赚多少钱?当考虑完所有天数之时,我们就得到了最好的答案。

代码

1
2
3
4
5
6
7
8
9
10
11
12
13
//C++
class Solution {
public:
int maxProfit(vector<int>& prices) {
int inf = 1e9;
int minprice = inf, maxprofit = 0;
for (int price: prices) {
maxprofit = max(maxprofit, price - minprice);
minprice = min(price, minprice);
}
return maxprofit;
}
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//Java
public class Solution {
public int maxProfit(int prices[]) {
int minprice = Integer.MAX_VALUE;
int maxprofit = 0;
for (int i = 0; i < prices.length; i++) {
if (prices[i] < minprice) {
minprice = prices[i];
} else if (prices[i] - minprice > maxprofit) {
maxprofit = prices[i] - minprice;
}
}
return maxprofit;
}
}

复杂度分析

  • 时间复杂度:O(n),只需要遍历一次。
  • 空间复杂度:O(1),只使用了常数个变量。

删除有序数组的重复项II

1.题目内容

给你一个有序数组 nums ,请你 原地 删除重复出现的元素,使得出现次数超过两次的元素只出现两次 ,返回删除后数组的新长度。

不要使用额外的数组空间,你必须在 原地 修改输入数组 并在使用 O(1) 额外空间的条件下完成。

说明:

为什么返回数值是整数,但输出的答案是数组呢?

请注意,输入数组是以「引用」方式传递的,这意味着在函数里修改输入数组对于调用者是可见的。

你可以想象内部操作如下:

1
2
3
4
5
6
7
8
// nums 是以“引用”方式传递的。也就是说,不对实参做任何拷贝
int len = removeDuplicates(nums);

// 在函数里修改输入数组对于调用者是可见的。
// 根据你的函数返回的长度, 它会打印出数组中 该长度范围内 的所有元素。
for (int i = 0; i < len; i++) {
print(nums[i]);
}

示例 1:

1
2
3
输入:nums = [1,1,1,2,2,3]
输出:5, nums = [1,1,2,2,3]
解释:函数应返回新长度 length = 5, 并且原数组的前五个元素被修改为 1, 1, 2, 2, 3。 不需要考虑数组中超出新长度后面的元素。

示例 2:

1
2
3
输入:nums = [0,0,1,1,1,1,2,3,3]
输出:7, nums = [0,0,1,1,2,3,3]
解释:函数应返回新长度 length = 7, 并且原数组的前七个元素被修改为 0, 0, 1, 1, 2, 3, 3。不需要考虑数组中超出新长度后面的元素。

提示:

  • 1 <= nums.length <= 3 * 104
  • -104 <= nums[i] <= 104
  • nums 已按升序排列

2.解法

双指针

思路及算法

因为给定数组是有序的,所以相同元素必然连续。我们可以使用双指针解决本题,遍历数组检查每一个元素是否应该被保留,如果应该被保留,就将其移动到指定位置。具体地,我们定义两个指针 slow 和 fast 分别为慢指针和快指针,其中慢指针表示处理出的数组的长度,快指针表示已经检查过的数组的长度,nums[fast] 表示待检查的第一个元素,nums[slow−1] 为上一个应该被保留的元素所移动到的指定位置。

因为本题要求相同元素最多出现两次而非一次,所以我们需要检查上上个应该被保留的元素 nums[slow−2] 是否和当前待检查元素 nums[fast] 相同。当且仅当 nums[slow−2]=nums[fast] 时,当前待检查元素 nums[fast] 不应该被保留(因为此时必然有 nums[slow−2]=nums[slow−1]=nums[fast])。最后,slow 即为处理好的数组的长度。

特别地,数组的前两个数必然可以被保留,因此对于长度不超过 2 的数组,我们无需进行任何处理,对于长度超过 222 的数组,我们直接将双指针的初始值设为 2 即可。

代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//C++
class Solution {
public:
int removeDuplicates(vector<int>& nums) {
int n = nums.size();
if (n <= 2) {
return n;
}
int slow = 2, fast = 2;
while (fast < n) {
if (nums[slow - 2] != nums[fast]) {
nums[slow] = nums[fast];
++slow;
}
++fast;
}
return slow;
}
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//Java
class Solution {
public int removeDuplicates(int[] nums) {
int n = nums.length;
if (n <= 2) {
return n;
}
int slow = 2, fast = 2;
while (fast < n) {
if (nums[slow - 2] != nums[fast]) {
nums[slow] = nums[fast];
++slow;
}
++fast;
}
return slow;
}
}

复杂度分析

  • 时间复杂度:O(n),其中 n 是数组的长度。我们最多遍历该数组一次。

  • 空间复杂度:O(1)。我们只需要常数的空间存储若干变量。

删除有序数组中的重复项I

1.题目内容

给你一个 非严格递增排列 的数组 nums ,请你原地删除重复出现的元素,使每个元素 只出现一次 ,返回删除后数组的新长度。元素的 相对顺序 应该保持 一致 。然后返回 nums 中唯一元素的个数。

考虑 nums 的唯一元素的数量为 k ,你需要做以下事情确保你的题解可以被通过:

  • 更改数组 nums ,使 nums 的前 k 个元素包含唯一元素,并按照它们最初在 nums 中出现的顺序排列。nums 的其余元素与 nums 的大小不重要。
  • 返回 k

判题标准:

系统会用下面的代码来测试你的题解:

1
2
3
4
5
6
7
8
9
int[] nums = [...]; // 输入数组
int[] expectedNums = [...]; // 长度正确的期望答案

int k = removeDuplicates(nums); // 调用

assert k == expectedNums.length;
for (int i = 0; i < k; i++) {
assert nums[i] == expectedNums[i];
}

如果所有断言都通过,那么您的题解将被 通过

示例 1:

1
2
3
输入:nums = [1,1,2]
输出:2, nums = [1,2,_]
解释:函数应该返回新的长度 2 ,并且原数组 nums 的前两个元素被修改为 1, 2 。不需要考虑数组中超出新长度后面的元素。

示例 2:

1
2
3
输入:nums = [0,0,1,1,1,2,2,3,3,4]
输出:5, nums = [0,1,2,3,4]
解释:函数应该返回新的长度 5 , 并且原数组 nums 的前五个元素被修改为 0, 1, 2, 3, 4 。不需要考虑数组中超出新长度后面的元素。

提示:

  • 1 <= nums.length <= 3 * 104
  • -104 <= nums[i] <= 104
  • nums 已按 非严格递增 排列

2.解法

双指针

思路及算法

这道题目的要求是:对给定的有序数组 nums删除重复元素,在删除重复元素之后,每个元素只出现一次,并返回新的长度,上述操作必须通过原地修改数组的方法,使用 O(1)的空间复杂度完成。

由于给定的数组 nums 是有序的,因此对于任意 i<j,如果 nums[i]=nums[j],则对任意 i≤k≤j,必有 nums[i]=nums[k]=nums[j],即相等的元素在数组中的下标一定是连续的。利用数组有序的特点,可以通过双指针的方法删除重复元素。

如果数组 nums 的长度为 0,则数组不包含任何元素,因此返回 0。

当数组 nums 的长度大于 0 时,数组中至少包含一个元素,在删除重复元素之后也至少剩下一个元素,因此 nums[0] 保持原状即可,从下标 1 开始删除重复元素。

定义两个指针 fast 和 slow 分别为快指针和慢指针,快指针表示遍历数组到达的下标位置,慢指针表示下一个不同元素要填入的下标位置,初始时两个指针都指向下标 1。

假设数组 nums的长度为 n。将快指针 fast 依次遍历从 1 到 n−1 的每个位置,对于每个位置,如果 nums[fast]≠nums[fast−1],说明 nums[fast] 和之前的元素都不同,因此将 nums[fast] 的值复制到 nums[slow],然后将 slow 的值加 1,即指向下一个位置。

遍历结束之后,从 nums[0] 到 nums[slow−1] 的每个元素都不相同且包含原数组中的每个不同的元素,因此新的长度即为 slow,返回 slow 即可。

代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//C++
class Solution {
public:
int removeDuplicates(vector<int>& nums) {
int n = nums.size();
if (n == 0) {
return 0;
}
int fast = 1, slow = 1;
while (fast < n) {
if (nums[fast] != nums[fast - 1]) {
nums[slow] = nums[fast];
++slow;
}
++fast;
}
return slow;
}
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//Java
class Solution {
public int removeDuplicates(int[] nums) {
int n = nums.length;
if (n == 0) {
return 0;
}
int fast = 1, slow = 1;
while (fast < n) {
if (nums[fast] != nums[fast - 1]) {
nums[slow] = nums[fast];
++slow;
}
++fast;
}
return slow;
}
}

复杂度分析

  • 时间复杂度:O(n),其中 n 是数组的长度。快指针和慢指针最多各移动 n 次。

  • 空间复杂度:O(1)。只需要使用常数的额外空间。

多数元素

1.题目描述

给定一个大小为 n 的数组 nums ,返回其中的多数元素。多数元素是指在数组中出现次数 大于 ⌊ n/2 ⌋ 的元素。

你可以假设数组是非空的,并且给定的数组总是存在多数元素。

示例 1:

1
2
输入:nums = [3,2,3]
输出:3

示例 2:

1
2
输入:nums = [2,2,1,1,1,2,2]
输出:2

提示:

  • n == nums.length
  • 1 <= n <= 5 * 104
  • -109 <= nums[i] <= 109

2.解法

说明

最简单的暴力方法是,枚举数组中的每个元素,再遍历一遍数组统计其出现次数。该方法的时间复杂度是 O(n^2^),会超出时间限制,因此我们需要找出时间复杂度小于 O(n^2^)的优秀做法。

(1)哈希表

思路及算法

我们知道出现次数最多的元素大于 $\dfrac{n}{2}$次,所以可以用哈希表来快速统计每个元素出现的次数。

我们使用哈希映射(HashMap)来存储每个元素以及出现的次数。对于哈希映射中的每个键值对,键表示一个元素,值表示该元素出现的次数。

我们用一个循环遍历数组 nums 并将数组中的每个元素加入哈希映射中。在这之后,我们遍历哈希映射中的所有键值对,返回值最大的键。我们同样也可以在遍历数组 nums 时候使用打擂台的方法,维护最大的值,这样省去了最后对哈希映射的遍历。

代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//C++
class Solution {
public:
int majorityElement(vector<int>& nums) {
unordered_map<int, int> counts;
int majority = 0, cnt = 0;
for (int num: nums) {
++counts[num];
if (counts[num] > cnt) {
majority = num;
cnt = counts[num];
}
}
return majority;
}
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
//Java
class Solution {
private Map<Integer, Integer> countNums(int[] nums) {
Map<Integer, Integer> counts = new HashMap<Integer, Integer>();
for (int num : nums) {
if (!counts.containsKey(num)) {
counts.put(num, 1);
} else {
counts.put(num, counts.get(num) + 1);
}
}
return counts;
}

public int majorityElement(int[] nums) {
Map<Integer, Integer> counts = countNums(nums);

Map.Entry<Integer, Integer> majorityEntry = null;
for (Map.Entry<Integer, Integer> entry : counts.entrySet()) {
if (majorityEntry == null || entry.getValue() > majorityEntry.getValue()) {
majorityEntry = entry;
}
}

return majorityEntry.getKey();
}
}

复杂度分析

  • 时间复杂度:O(n),其中 n 是数组 nums 的长度。我们遍历数组 nums 一次,对于 nums 中的每一个元素,将其插入哈希表都只需要常数时间。如果在遍历时没有维护最大值,在遍历结束后还需要对哈希表进行遍历,因为哈希表中占用的空间为 O(n)(可参考下文的空间复杂度分析),那么遍历的时间不会超过 O(n)。因此总时间复杂度为 O(n)。

  • 空间复杂度:O(n)。哈希表最多包含 n−$\dfrac{n}{2}$ 个键值对,所以占用的空间为 O(n)。这是因为任意一个长度为 n 的数组最多只能包含 n 个不同的值,但题中保证 nums 一定有一个众数,会占用(最少) $\dfrac{n}{2}$ +1个数字。因此最多有n-($\dfrac{n}{2}$ +1 ) 个不同的其他数字,所以最多有 n−$\dfrac{n}{2}$ 不同的元素。

(2)排序

思路及算法

如果将数组 nums 中的所有元素按照单调递增或单调递减的顺序排序,那么下标为 $\dfrac{n}{2}$的元素(下标从 0 开始)一定是众数。

对于这种算法,我们先将 nums 数组排序,然后返回上文所说的下标对应的元素。下面的图中解释了为什么这种策略是有效的。在下图中,第一个例子是 n 为奇数的情况,第二个例子是 n 为偶数的情况。

image.png

对于每种情况,数组上面的线表示如果众数是数组中的最小值时覆盖的下标,数组下面的线表示如果众数是数组中的最大值时覆盖的下标。对于其他的情况,这条线会在这两种极端情况的中间。对于这两种极端情况,它们会在下标为 $\dfrac{n}{2}$ 的地方有重叠。因此,无论众数是多少,返回$\dfrac{n}{2}$下标对应的值都是正确的。

代码

1
2
3
4
5
6
7
8
//C++
class Solution {
public:
int majorityElement(vector<int>& nums) {
sort(nums.begin(), nums.end());
return nums[nums.size() / 2];
}
};
1
2
3
4
5
6
7
//Java
class Solution {
public int majorityElement(int[] nums) {
Arrays.sort(nums);
return nums[nums.length / 2];
}
}

复杂度分析

  • 时间复杂度:O(nlog⁡n)。将数组排序的时间复杂度为 O(nlog⁡n)。

  • 空间复杂度:O(log⁡n)。如果使用语言自带的排序算法,需要使用 O(log⁡n) 的栈空间。如果自己编写堆排序,则只需要使用 O(1)的额外空间。

(3)分治

思路及算法

如果数 a 是数组 nums 的众数,如果我们将 nums 分成两部分,那么 a 必定是至少一部分的众数。

我们可以使用反证法来证明这个结论。假设 a 既不是左半部分的众数,也不是右半部分的众数,那么 a 出现的次数少于 l / 2 + r / 2 次,其中 l 和 r 分别是左半部分和右半部分的长度。由于 l / 2 + r / 2 <= (l + r) / 2,说明 a 也不是数组 nums 的众数,因此出现了矛盾。所以这个结论是正确的。

这样以来,我们就可以使用分治法解决这个问题:将数组分成左右两部分,分别求出左半部分的众数 a1 以及右半部分的众数 a2,随后在 a1 和 a2 中选出正确的众数。

我们使用经典的分治算法递归求解,直到所有的子问题都是长度为 1 的数组。长度为 1 的子数组中唯一的数显然是众数,直接返回即可。如果回溯后某区间的长度大于 1,我们必须将左右子区间的值合并。如果它们的众数相同,那么显然这一段区间的众数是它们相同的值。否则,我们需要比较两个众数在整个区间内出现的次数来决定该区间的众数。

代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
//C++
class Solution {
int count_in_range(vector<int>& nums, int target, int lo, int hi) {
int count = 0;
for (int i = lo; i <= hi; ++i)
if (nums[i] == target)
++count;
return count;
}
int majority_element_rec(vector<int>& nums, int lo, int hi) {
if (lo == hi)
return nums[lo];
int mid = (lo + hi) / 2;
int left_majority = majority_element_rec(nums, lo, mid);
int right_majority = majority_element_rec(nums, mid + 1, hi);
if (count_in_range(nums, left_majority, lo, hi) > (hi - lo + 1) / 2)
return left_majority;
if (count_in_range(nums, right_majority, lo, hi) > (hi - lo + 1) / 2)
return right_majority;
return -1;
}
public:
int majorityElement(vector<int>& nums) {
return majority_element_rec(nums, 0, nums.size() - 1);
}
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
//Java
class Solution {
private int countInRange(int[] nums, int num, int lo, int hi) {
int count = 0;
for (int i = lo; i <= hi; i++) {
if (nums[i] == num) {
count++;
}
}
return count;
}

private int majorityElementRec(int[] nums, int lo, int hi) {
// base case; the only element in an array of size 1 is the majority
// element.
if (lo == hi) {
return nums[lo];
}

// recurse on left and right halves of this slice.
int mid = (hi - lo) / 2 + lo;
int left = majorityElementRec(nums, lo, mid);
int right = majorityElementRec(nums, mid + 1, hi);

// if the two halves agree on the majority element, return it.
if (left == right) {
return left;
}

// otherwise, count each element and return the "winner".
int leftCount = countInRange(nums, left, lo, hi);
int rightCount = countInRange(nums, right, lo, hi);

return leftCount > rightCount ? left : right;
}

public int majorityElement(int[] nums) {
return majorityElementRec(nums, 0, nums.length - 1);
}
}

复杂度分析

  • 时间复杂度:O(nlog⁡n)。函数 majority_element_rec() 会求解 2 个长度为 n/2 的子问题,并做两遍长度为 n 的线性扫描。因此,分治算法的时间复杂度可以表示为 O(nlog⁡n).

  • 空间复杂度:O(log⁡n)。尽管分治算法没有直接分配额外的数组空间,但在递归的过程中使用了额外的栈空间。算法每次将数组从中间分成两部分,所以数组长度变为 1 之前需要进行 O(log⁡n) 次递归,即空间复杂度为 O(log⁡n)。

缺失的第一个正数

1.题目内容

给你一个未排序的整数数组 nums ,请你找出其中没有出现的最小的正整数。

请你实现时间复杂度为 O(n) 并且只使用常数级别额外空间的解决方案。

示例 1:

1
2
输入:nums = [1,2,0]
输出:3

示例 2:

1
2
输入:nums = [3,4,-1,1]
输出:2

示例 3:

1
2
输入:nums = [7,8,9,11,12]
输出:1

提示:

  • 1 <= nums.length <= 5 * 105
  • -231 <= nums[i] <= 231 - 1

2.解法

(1)哈希表

思路及算法

我们可以将数组所有的数放入哈希表,随后从 1 开始依次枚举正整数,并判断其是否在哈希表中。

仔细想一想,我们为什么要使用哈希表?这是因为哈希表是一个可以支持快速查找的数据结构:给定一个元素,我们可以在 O(1) 的时间查找该元素是否在哈希表中。因此,我们可以考虑将给定的数组设计成哈希表的「替代产品」。

实际上,对于一个长度为 N 的数组,其中没有出现的最小正整数只能在 [1,N+1] 中。这是因为如果 [1,N] 都出现了,那么答案是 N+1,否则答案是 [1,N] 中没有出现的最小正整数。这样一来,我们将所有在 [1,N] 范围内的数放入哈希表,也可以得到最终的答案。而给定的数组恰好长度为 N,这让我们有了一种将数组设计成哈希表的思路:

我们对数组进行遍历,对于遍历到的数 x,如果它在 [1,N] 的范围内,那么就将数组中的第 x−1个位置(注意:数组下标从 000 开始)打上「标记」。在遍历结束之后,如果所有的位置都被打上了标记,那么答案是 N+1,否则答案是最小的没有打上标记的位置加 1。

那么如何设计这个「标记」呢?由于数组中的数没有任何限制,因此这并不是一件容易的事情。但我们可以继续利用上面的提到的性质:由于我们只在意 [1,N][1, N][1,N] 中的数,因此我们可以先对数组进行遍历,把不在 [1,N] 范围内的数修改成任意一个大于 N 的数(例如 N+1)。这样一来,数组中的所有数就都是正数了,因此我们就可以将「标记」表示为「负号」。算法的流程如下:

我们将数组中所有小于等于 0 的数修改为 N+1;

我们遍历数组中的每一个数 x,它可能已经被打了标记,因此原本对应的数为 ∣x∣,其中 ∣ ∣ 为绝对值符号。如果 ∣x∣∈[1,N],那么我们给数组中的第 ∣x∣−1 个位置的数添加一个负号。注意如果它已经有负号,不需要重复添加;

在遍历完成之后,如果数组中的每一个数都是负数,那么答案是 N+1,否则答案是第一个正数的位置加 1。

代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
//C++
class Solution {
public:
int firstMissingPositive(vector<int>& nums) {
int n = nums.size();
for (int& num: nums) {
if (num <= 0) {
num = n + 1;
}
}
for (int i = 0; i < n; ++i) {
int num = abs(nums[i]);
if (num <= n) {
nums[num - 1] = -abs(nums[num - 1]);
}
}
for (int i = 0; i < n; ++i) {
if (nums[i] > 0) {
return i + 1;
}
}
return n + 1;
}
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//Java
class Solution {
public int firstMissingPositive(int[] nums) {
int n = nums.length;
for (int i = 0; i < n; ++i) {
if (nums[i] <= 0) {
nums[i] = n + 1;
}
}
for (int i = 0; i < n; ++i) {
int num = Math.abs(nums[i]);
if (num <= n) {
nums[num - 1] = -Math.abs(nums[num - 1]);
}
}
for (int i = 0; i < n; ++i) {
if (nums[i] > 0) {
return i + 1;
}
}
return n + 1;
}
}

复杂度分析

  • 时间复杂度:O(N),其中 N 是数组的长度。
  • 空间复杂度:O(1)。

(2)置换

思路及算法

除了打标记以外,我们还可以使用置换的方法,将给定的数组「恢复」成下面的形式:

如果数组中包含 x∈[1,N],那么恢复后,数组的第 x−1个元素为 x。

在恢复后,数组应当有 [1, 2, …, N] 的形式,但其中有若干个位置上的数是错误的,每一个错误的位置就代表了一个缺失的正数。以题目中的示例二 [3, 4, -1, 1] 为例,恢复后的数组应当为 [1, -1, 3, 4],我们就可以知道缺失的数为 2。

那么我们如何将数组进行恢复呢?我们可以对数组进行一次遍历,对于遍历到的数 x=nums[i],如果 x∈[1,N],我们就知道 x 应当出现在数组中的 x−1 的位置,因此交换 nums[i] 和 nums[x-1],这样 x 就出现在了正确的位置。在完成交换后,新的 nums[i] 可能还在 [1,N] 的范围内,我们需要继续进行交换操作,直到 x∉[1,N]。

注意到上面的方法可能会陷入死循环。如果 nums[i] 恰好与 nums[x−1] 相等,那么就会无限交换下去。此时我们有 nums[i]=x=nums[x−1],说明 x 已经出现在了正确的位置。因此我们可以跳出循环,开始遍历下一个数。

由于每次的交换操作都会使得某一个数交换到正确的位置,因此交换的次数最多为 N,整个方法的时间复杂度为 O(N)。

代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//C++
class Solution {
public:
int firstMissingPositive(vector<int>& nums) {
int n = nums.size();
for (int i = 0; i < n; ++i) {
while (nums[i] > 0 && nums[i] <= n && nums[nums[i] - 1] != nums[i]) {
swap(nums[nums[i] - 1], nums[i]);
}
}
for (int i = 0; i < n; ++i) {
if (nums[i] != i + 1) {
return i + 1;
}
}
return n + 1;
}
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//Java
class Solution {
public int firstMissingPositive(int[] nums) {
int n = nums.length;
for (int i = 0; i < n; ++i) {
while (nums[i] > 0 && nums[i] <= n && nums[nums[i] - 1] != nums[i]) {
int temp = nums[nums[i] - 1];
nums[nums[i] - 1] = nums[i];
nums[i] = temp;
}
}
for (int i = 0; i < n; ++i) {
if (nums[i] != i + 1) {
return i + 1;
}
}
return n + 1;
}
}

复杂度分析

  • 时间复杂度:O(N),其中 N 是数组的长度。
  • 空间复杂度:O(1) 。

除自身以外数组的乘积

1.题目描述

给你一个整数数组 nums,返回 数组 answer ,其中 answer[i] 等于 nums 中除 nums[i] 之外其余各元素的乘积

题目数据 保证 数组 nums之中任意元素的全部前缀元素和后缀的乘积都在 32 位 整数范围内。

不要使用除法,且在 O(*n*) 时间复杂度内完成此题。

示例 1:

1
2
输入: nums = [1,2,3,4]
输出: [24,12,8,6]

示例 2:

1
2
输入: nums = [-1,1,0,-3,3]
输出: [0,0,9,0,0]

提示:

  • 2 <= nums.length <= 105
  • -30 <= nums[i] <= 30
  • 保证 数组 nums之中任意元素的全部前缀元素和后缀的乘积都在 32 位 整数范围内

2.解法

左右乘积列表

思路及算法

我们不必将所有数字的乘积除以给定索引处的数字得到相应的答案,而是利用索引左侧所有数字的乘积和右侧所有数字的乘积(即前缀与后缀)相乘得到答案。

对于给定索引 iii,我们将使用它左边所有数字的乘积乘以右边所有数字的乘积。下面让我们更加具体的描述这个算法。

1.初始化两个空数组 L 和 R。对于给定索引 i,L[i] 代表的是 i 左侧所有数字的乘积,R[i] 代表的是 i 右侧所有数字的乘积。
2.我们需要用两个循环来填充 L 和 R 数组的值。对于数组 L,L[0] 应该是 1,因为第一个元素的左边没有元素。对于其他元素:L[i] = L[i-1] * nums[i-1]。
3.同理,对于数组 R,R[length-1] 应为 1。length 指的是输入数组的大小。其他元素:R[i] = R[i+1] * nums[i+1]。
4.当 R 和 L 数组填充完成,我们只需要在输入数组上迭代,且索引 i 处的值为:L[i] * R[i]。

代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
//C++
class Solution {
public:
vector<int> productExceptSelf(vector<int>& nums) {
int length = nums.size();

// L 和 R 分别表示左右两侧的乘积列表
vector<int> L(length, 0), R(length, 0);

vector<int> answer(length);

// L[i] 为索引 i 左侧所有元素的乘积
// 对于索引为 '0' 的元素,因为左侧没有元素,所以 L[0] = 1
L[0] = 1;
for (int i = 1; i < length; i++) {
L[i] = nums[i - 1] * L[i - 1];
}

// R[i] 为索引 i 右侧所有元素的乘积
// 对于索引为 'length-1' 的元素,因为右侧没有元素,所以 R[length-1] = 1
R[length - 1] = 1;
for (int i = length - 2; i >= 0; i--) {
R[i] = nums[i + 1] * R[i + 1];
}

// 对于索引 i,除 nums[i] 之外其余各元素的乘积就是左侧所有元素的乘积乘以右侧所有元素的乘积
for (int i = 0; i < length; i++) {
answer[i] = L[i] * R[i];
}

return answer;
}
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
//Java
class Solution {
public int[] productExceptSelf(int[] nums) {
int length = nums.length;

// L 和 R 分别表示左右两侧的乘积列表
int[] L = new int[length];
int[] R = new int[length];

int[] answer = new int[length];

// L[i] 为索引 i 左侧所有元素的乘积
// 对于索引为 '0' 的元素,因为左侧没有元素,所以 L[0] = 1
L[0] = 1;
for (int i = 1; i < length; i++) {
L[i] = nums[i - 1] * L[i - 1];
}

// R[i] 为索引 i 右侧所有元素的乘积
// 对于索引为 'length-1' 的元素,因为右侧没有元素,所以 R[length-1] = 1
R[length - 1] = 1;
for (int i = length - 2; i >= 0; i--) {
R[i] = nums[i + 1] * R[i + 1];
}

// 对于索引 i,除 nums[i] 之外其余各元素的乘积就是左侧所有元素的乘积乘以右侧所有元素的乘积
for (int i = 0; i < length; i++) {
answer[i] = L[i] * R[i];
}

return answer;
}
}

复杂度分析

  • 时间复杂度:O(N),其中 N 指的是数组 nums 的大小。预处理 L 和 R 数组以及最后的遍历计算都是 O(N) 的时间复杂度。
  • 空间复杂度:O(N),其中 N 指的是数组 nums 的大小。使用了 L 和 R 数组去构造答案,L 和 R 数组的长度为数组 nums 的大小。

轮转数组

1.题目内容

给定一个整数数组 nums,将数组中的元素向右轮转 k 个位置,其中 k 是非负数。

示例 1:

1
2
3
4
5
6
输入: nums = [1,2,3,4,5,6,7], k = 3
输出: [5,6,7,1,2,3,4]
解释:
向右轮转 1 步: [7,1,2,3,4,5,6]
向右轮转 2 步: [6,7,1,2,3,4,5]
向右轮转 3 步: [5,6,7,1,2,3,4]

示例 2:

1
2
3
4
5
输入:nums = [-1,-100,3,99], k = 2
输出:[3,99,-1,-100]
解释:
向右轮转 1 步: [99,-1,-100,3]
向右轮转 2 步: [3,99,-1,-100]

提示:

  • 1 <= nums.length <= 105
  • -231 <= nums[i] <= 231 - 1
  • 0 <= k <= 105

2.解法

(1)使用额外的数组

思路及算法

我们可以使用额外的数组来将每个元素放至正确的位置。用 n 表示数组的长度,我们遍历原数组,将原数组下标为 i 的元素放至新数组下标为 (i+k) mod  n 的位置,最后将新数组拷贝至原数组即可。

代码

1
2
3
4
5
6
7
8
9
10
11
12
//C++
class Solution {
public:
void rotate(vector<int>& nums, int k) {
int n = nums.size();
vector<int> newArr(n);
for (int i = 0; i < n; ++i) {
newArr[(i + k) % n] = nums[i];
}
nums.assign(newArr.begin(), newArr.end());
}
};
1
2
3
4
5
6
7
8
9
10
11
//Java
class Solution {
public void rotate(int[] nums, int k) {
int n = nums.length;
int[] newArr = new int[n];
for (int i = 0; i < n; ++i) {
newArr[(i + k) % n] = nums[i];
}
System.arraycopy(newArr, 0, nums, 0, n);
}
}

复杂度分析

  • 时间复杂度: O(n),其中 n 为数组的长度。
  • 空间复杂度: O(n)。

(2)环状替换

思路及算法

使用额外数组的原因在于如果我们直接将每个数字放至它最后的位置,这样被放置位置的元素会被覆盖从而丢失。因此,从另一个角度,我们可以将被替换的元素保存在变量 temp 中,从而避免了额外数组的开销。

我们从位置 0 开始,最初令temp=nums[0]。根据规则,位置 0 的元素会放至 (0+k) mod  n 的位置,令 x=(0+k) mod  n,此时交换 tempnums[x],完成位置 x 的更新。然后,我们考察位置 x,并交换 temp nums[(x+k) mod  n],从而完成下一个位置的更新。不断进行上述过程,直至回到初始位置 0。

容易发现,当回到初始位置 0 时,有些数字可能还没有遍历到,此时我们应该从下一个数字开始重复的过程,可是这个时候怎么才算遍历结束呢?我们不妨先考虑这样一个问题:从 0 开始不断遍历,最终回到起点 0 的过程中,我们遍历了多少个元素?

由于最终回到了起点,故该过程恰好走了整数数量的圈,不妨设为 a 圈;再设该过程总共遍历了 b 个元素。因此,我们有 an=b,即 a 一定为 n,kn,kn,k 的公倍数。又因为我们在第一次回到起点时就结束,因此 a 要尽可能小,故 an 就是 n,kn,kn,k 的最小公倍数 lcm(n,k),因此 b 就为 lcm(n,k)/k

这说明单次遍历会访问到 lcm(n,k)/k个元素。为了访问到所有的元素,我们需要进行遍历的次数为

$\frac{n}{lcm(n,k)/k}$ =$\frac{nk}{lcm(n,k)}$ =gcd(n,k)

其中 gcd 指的是最大公约数。

我们用下面的例子更具体地说明这个过程:

1
2
nums = [1, 2, 3, 4, 5, 6] 
k = 2

image.png

如果读者对上面的数学推导的理解有一定困难,也可以使用另外一种方式完成代码:使用单独的变量 count 跟踪当前已经访问的元素数量,当 count=n 时,结束遍历过程。

代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//C++
class Solution {
public:
void rotate(vector<int>& nums, int k) {
int n = nums.size();
k = k % n;
int count = gcd(k, n);
for (int start = 0; start < count; ++start) {
int current = start;
int prev = nums[start];
do {
int next = (current + k) % n;
swap(nums[next], prev);
current = next;
} while (start != current);
}
}
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//Java
class Solution {
public void rotate(int[] nums, int k) {
int n = nums.length;
k = k % n;
int count = gcd(k, n);
for (int start = 0; start < count; ++start) {
int current = start;
int prev = nums[start];
do {
int next = (current + k) % n;
int temp = nums[next];
nums[next] = prev;
prev = temp;
current = next;
} while (start != current);
}
}

public int gcd(int x, int y) {
return y > 0 ? gcd(y, x % y) : x;
}
}

复杂度分析

  • 时间复杂度:O(n),其中 n 为数组的长度。每个元素只会被遍历一次。

  • 空间复杂度:O(1)。我们只需常数空间存放若干变量。

(3)数组翻转

思路及算法

该方法基于如下的事实:当我们将数组的元素向右移动 k 次后,尾部 k mod n 个元素会移动至数组头部,其余元素向后移动 k mod n 个位置。

该方法为数组的翻转:我们可以先将所有元素翻转,这样尾部的 k mod n 个元素就被移至数组头部,然后我们再翻转 [0,k mod n−1] 区间的元素和 [k mod n,n−1] 区间的元素即能得到最后的答案。

我们以 n=7,k=3 为例进行如下展示:

操作 结果
原始数组 1 2 3 4 5 6 7
翻转所有元素 7 6 5 4 3 2 1
翻转 [0,k mod n−1] 区间的元素 5 6 7 4 3 2 1
翻转[k mod n,n−1] 区间的元素 5 6 7 1 2 3 4

代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//C++
class Solution {
public:
void reverse(vector<int>& nums, int start, int end) {
while (start < end) {
swap(nums[start], nums[end]);
start += 1;
end -= 1;
}
}

void rotate(vector<int>& nums, int k) {
k %= nums.size();
reverse(nums, 0, nums.size() - 1);
reverse(nums, 0, k - 1);
reverse(nums, k, nums.size() - 1);
}
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//Java
class Solution {
public void rotate(int[] nums, int k) {
k %= nums.length;
reverse(nums, 0, nums.length - 1);
reverse(nums, 0, k - 1);
reverse(nums, k, nums.length - 1);
}

public void reverse(int[] nums, int start, int end) {
while (start < end) {
int temp = nums[start];
nums[start] = nums[end];
nums[end] = temp;
start += 1;
end -= 1;
}
}
}

复杂度分析

  • 时间复杂度:O(n),其中 n 为数组的长度。每个元素被翻转两次,一共 n 个元素,因此总时间复杂度为 O(2n)=O(n)。

  • 空间复杂度:O(1)。

合并区间

1.题目内容

以数组 intervals 表示若干个区间的集合,其中单个区间为 intervals[i] = [starti, endi] 。请你合并所有重叠的区间,并返回 一个不重叠的区间数组,该数组需恰好覆盖输入中的所有区间。

示例 1:

1
2
3
输入:intervals = [[1,3],[2,6],[8,10],[15,18]]
输出:[[1,6],[8,10],[15,18]]
解释:区间 [1,3] 和 [2,6] 重叠, 将它们合并为 [1,6].

示例 2:

1
2
3
输入:intervals = [[1,4],[4,5]]
输出:[[1,5]]
解释:区间 [1,4] 和 [4,5] 可被视为重叠区间。

提示:

  • 1 <= intervals.length <= 104
  • intervals[i].length == 2
  • 0 <= starti <= endi <= 104

2.解法

排序

思路及算法

如果我们按照区间的左端点排序,那么在排完序的列表中,可以合并的区间一定是连续的。如下图所示,标记为蓝色、黄色和绿色的区间分别可以合并成一个大区间,它们在排完序的列表中是连续的:

56-2.png

我们用数组 merged 存储最终的答案。

首先,我们将列表中的区间按照左端点升序排序。然后我们将第一个区间加入 merged 数组中,并按顺序依次考虑之后的每个区间:

  • 如果当前区间的左端点在数组 merged 中最后一个区间的右端点之后,那么它们不会重合,我们可以直接将这个区间加入数组 merged 的末尾;

  • 否则,它们重合,我们需要用当前区间的右端点更新数组 merged 中最后一个区间的右端点,将其置为二者的较大值。

上述算法的正确性可以用反证法来证明:在排完序后的数组中,两个本应合并的区间没能被合并,那么说明存在这样的三元组 (i,j,k) 以及数组中的三个区间 a[i],a[j],a[k] 满足 i<j<k 并且 (a[i],a[k]) 可以合并,但 (a[i],a[j]) 和 (a[j],a[k]) 不能合并。这说明它们满足下面的不等式:
a[i].end<a[j].start(a[i] 和 a[j] 不能合并)
a[j].end<a[k].start(a[j] 和 a[k] 不能合并)
a[i].end≥a[k].start(a[i] 和 a[k] 可以合并)
我们联立这些不等式(注意还有一个显然的不等式 a[j].start≤a[j].end),可以得到:a[i].end<a[j].start≤a[j].end<a[k].start
产生了矛盾!这说明假设是不成立的。因此,所有能够合并的区间都必然是连续的。

代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//C++
class Solution {
public:
vector<vector<int>> merge(vector<vector<int>>& intervals) {
if (intervals.size() == 0) {
return {};
}
sort(intervals.begin(), intervals.end());
vector<vector<int>> merged;
for (int i = 0; i < intervals.size(); ++i) {
int L = intervals[i][0], R = intervals[i][1];
if (!merged.size() || merged.back()[1] < L) {
merged.push_back({L, R});
}
else {
merged.back()[1] = max(merged.back()[1], R);
}
}
return merged;
}
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//Java
class Solution {
public int[][] merge(int[][] intervals) {
if (intervals.length == 0) {
return new int[0][2];
}
Arrays.sort(intervals, new Comparator<int[]>() {
public int compare(int[] interval1, int[] interval2) {
return interval1[0] - interval2[0];
}
});
List<int[]> merged = new ArrayList<int[]>();
for (int i = 0; i < intervals.length; ++i) {
int L = intervals[i][0], R = intervals[i][1];
if (merged.size() == 0 || merged.get(merged.size() - 1)[1] < L) {
merged.add(new int[]{L, R});
} else {
merged.get(merged.size() - 1)[1] = Math.max(merged.get(merged.size() - 1)[1], R);
}
}
return merged.toArray(new int[merged.size()][]);
}
}

复杂度分析

  • 时间复杂度:O(nlog⁡n),其中 n 为区间的数量。除去排序的开销,我们只需要一次线性扫描,所以主要的时间开销是排序的 O(nlog⁡n)。

  • 空间复杂度:O(log⁡n),其中 n 为区间的数量。这里计算的是存储答案之外,使用的额外空间。O(log⁡n) 即为排序所需要的空间复杂度。

最大子数组和

1.题目内容

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

子数组 是数组中的一个连续部分。

示例 1:

1
2
3
输入:nums = [-2,1,-3,4,-1,2,1,-5,4]
输出:6
解释:连续子数组 [4,-1,2,1] 的和最大,为 6 。

示例 2:

1
2
输入:nums = [1]
输出:1

示例 3:

1
2
输入:nums = [5,4,-1,7,8]
输出:23

提示:

  • 1 <= nums.length <= 105
  • -104 <= nums[i] <= 104

2.解法

(1)动态规划

思路及算法

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

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

max0≤i≤n−1{f(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),这有点类似「滚动数组」的思想。

代码

1
2
3
4
5
6
7
8
9
10
11
12
//C++
class Solution {
public:
int maxSubArray(vector<int>& nums) {
int pre = 0, maxAns = nums[0];
for (const auto &x: nums) {
pre = max(pre + x, x);
maxAns = max(maxAns, pre);
}
return maxAns;
}
};
1
2
3
4
5
6
7
8
9
10
11
//Java
class Solution {
public int maxSubArray(int[] nums) {
int pre = 0, maxAns = nums[0];
for (int x : nums) {
pre = Math.max(pre + x, x);
maxAns = Math.max(maxAns, pre);
}
return maxAns;
}
}

复杂度分析

  • 时间复杂度:O(n),其中 n 为 nums 数组的长度。我们只需要遍历一遍数组即可求得答案。
  • 空间复杂度:O(1)。我们只需要常数空间存放若干变量。

(2)分治

思路及算法

这个分治方法类似于「线段树求解最长公共上升子序列问题」的 pushUp 操作。 也许读者还没有接触过线段树,没有关系,方法二的内容假设你没有任何线段树的基础。当然,如果读者有兴趣的话,推荐阅读线段树区间合并法解决多次询问的「区间最长连续上升序列问题」和「区间最大子段和问题」,还是非常有趣的。

我们定义一个操作 get(a, l, r) 表示查询 a 序列 [l,r] 区间内的最大子段和,那么最终我们要求的答案就是 get(nums, 0, nums.size() - 1)。如何分治实现这个操作呢?对于一个区间 [l,r],我们取 m=⌊$\frac{l+r}{2}$⌋,对区间 [l,m] 和 [m+1,r] 分治求解。当递归逐层深入直到区间长度缩小为 1 的时候,递归「开始回升」。这个时候我们考虑如何通过 [l,m] 区间的信息和 [m+1,r] 区间的信息合并成区间 [l,r] 的信息。最关键的两个问题是:

  • 我们要维护区间的哪些信息呢?
  • 我们如何合并这些信息呢?

对于一个区间 [l,r],我们可以维护四个量:

  • lSum 表示 [l,r] 内以 l 为左端点的最大子段和
  • rSum 表示 [l,r] 内以 r 为右端点的最大子段和
  • mSum表示 [l,r] 内的最大子段和
  • iSum 表示 [l,r] 的区间和

以下简称 [l,m] 为 [l,r] 的「左子区间」,[m+1,r] 为 [l,r] 的「右子区间」。我们考虑如何维护这些量呢(如何通过左右子区间的信息合并得到 [l,r] 的信息)?对于长度为 1 的区间 [i,i],四个量的值都和 nums[i] 相等。对于长度大于 1 的区间:

  • 首先最好维护的是 iSum,区间 [l,r] 的 iSum 就等于「左子区间」的 iSum 加上「右子区间」的 iSum。
  • 对于 [l,r] 的 lSum,存在两种可能,它要么等于「左子区间」的 lSum,要么等于「左子区间」的 iSum 加上「右子区间」的 lSum,二者取大。
  • 对于 [l,r] 的 rSum,同理,它要么等于「右子区间」的 rSum,要么等于「右子区间」的 iSum 加上「左子区间」的 rSum,二者取大。
  • 当计算好上面的三个量之后,就很好计算 [l,r] 的 mSum 了。我们可以考虑 [l,r] 的 mSum 对应的区间是否跨越 m——它可能不跨越 m,也就是说 [l,r] 的 mSum 可能是「左子区间」的 mSum 和 「右子区间」的 mSum 中的一个;它也可能跨越 m,可能是「左子区间」的 rSum 和 「右子区间」的 lSum 求和。三者取大。

这样问题就得到了解决。

代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
//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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
//Java
class Solution {
public class Status {
public int lSum, rSum, mSum, iSum;

public Status(int lSum, int rSum, int mSum, int iSum) {
this.lSum = lSum;
this.rSum = rSum;
this.mSum = mSum;
this.iSum = iSum;
}
}

public int maxSubArray(int[] nums) {
return getInfo(nums, 0, nums.length - 1).mSum;
}

public Status getInfo(int[] a, int l, int r) {
if (l == r) {
return new Status(a[l], a[l], a[l], a[l]);
}
int m = (l + r) >> 1;
Status lSub = getInfo(a, l, m);
Status rSub = getInfo(a, m + 1, r);
return pushUp(lSub, rSub);
}

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

复杂度分析

假设序列 a 的长度为 n。

  • 时间复杂度:假设我们把递归的过程看作是一颗二叉树的先序遍历,那么这颗二叉树的深度的渐进上界为 O(log⁡n),这里的总时间相当于遍历这颗二叉树的所有节点,故总时间的渐进上界是 O(∑i=1log⁡n2^i−1^)=O(n),故渐进时间复杂度为 O(n)。

  • 空间复杂度:递归会使用 O(log⁡n) 的栈空间,故渐进空间复杂度为 O(log⁡n)。

0%