文章目录
1. 题目描述
给定一个长度为 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 <= 10^5
- 0 <= height[i] <= 10^4
2. 理解题目
这道题可以理解为:
- 有 n 根不同高度的柱子,分别位于 x 轴上的位置 0, 1, 2, …, n-1
- 选择两根柱子,它们构成一个容器
- 容器的宽度是两根柱子之间的距离(坐标差)
- 容器的高度是两根柱子中较短的那根的高度(木桶效应)
- 容器的容量是宽度 × 高度
- 我们要找出哪两根柱子组成的容器,可以盛最多的水
关键点:
- 水的容量受限于较短的柱子
- 容器不能倾斜,因此两根柱子之间的其他柱子对容量没有影响
- 宽度由两根柱子的索引差决定
3. 解法一:暴力法
3.1 思路
最直观的方法是枚举所有可能的柱子对,计算每对柱子能形成的容器容量,然后找出最大值:
- 使用两层循环,遍历所有可能的柱子对
- 对于每一对柱子 (i, j),计算它们构成的容器的容量:
- 宽度 = j - i
- 高度 = min(height[i], height[j])
- 容量 = 宽度 × 高度
- 记录并返回最大容量
3.2 Java代码实现
public class Solution {
public int maxArea(int[] height) {
int maxWater = 0;
int n = height.length;
// 枚举所有可能的柱子对
for (int i = 0; i < n; i++) {
for (int j = i + 1; j < n; j++) {
// 计算容器的宽度
int width = j - i;
// 计算容器的高度(取两柱子的较小值)
int h = Math.min(height[i], height[j]);
// 计算容器的容量
int water = width * h;
// 更新最大容量
maxWater = Math.max(maxWater, water);
}
}
return maxWater;
}
}
3.3 代码详解
- 初始化
maxWater
为0,用于记录最大容量 - 使用两层循环遍历所有可能的柱子对:
- 外层循环
i
从0到 n-1 - 内层循环
j
从 i+1 到 n-1
- 外层循环
- 对于每一对柱子 (i, j):
- 计算容器的宽度:
width = j - i
- 计算容器的高度:
h = Math.min(height[i], height[j])
- 计算容器的容量:
water = width * h
- 计算容器的宽度:
- 如果当前容量大于已知的最大容量,则更新最大容量
- 最后返回最大容量
3.4 复杂度分析
- 时间复杂度:O(n²),其中 n 是数组的长度。需要两层循环来枚举所有的柱子对。
- 空间复杂度:O(1),只需要常数级别的额外空间。
3.5 适用场景
暴力法简单直观,适合小规模数据和教学目的。但对于较大规模的数组(例如题目中提到的 n 可达 10^5),这种方法可能会超时。
4. 解法二:双指针法
4.1 思路
双指针法是解决此问题的最优解,其核心思想是:
- 使用两个指针,初始时分别指向数组的两端
- 计算这两个指针指向的柱子构成的容器容量
- 然后移动指向较短柱子的那个指针(向内移动)
- 重复步骤2和3,直到两个指针相遇
- 在整个过程中记录最大容量
为什么要移动较短的那个指针?因为:
- 容器的容量受限于较短的柱子
- 如果移动较长的柱子,容器的宽度会减小,而高度不会增加(仍然受限于较短的柱子),所以容量只会减小
- 而移动较短的柱子,虽然宽度减小,但高度可能增加,容量可能增大
4.2 Java代码实现
public class Solution {
public int maxArea(int[] height) {
int maxWater = 0;
int left = 0; // 左指针,初始指向数组第一个元素
int right = height.length - 1; // 右指针,初始指向数组最后一个元素
while (left < right) {
// 计算当前两个指针构成的容器容量
int width = right - left;
int h = Math.min(height[left], height[right]);
int water = width * h;
// 更新最大容量
maxWater = Math.max(maxWater, water);
// 移动指向较短柱子的指针
if (height[left] < height[right]) {
left++;
} else {
right--;
}
}
return maxWater;
}
}
4.3 代码详解
- 初始化
maxWater
为0,用于记录最大容量 - 使用两个指针
left
和right
,分别指向数组的首尾 - 当
left < right
时,执行循环:- 计算当前两个指针构成的容器宽度:
width = right - left
- 计算容器的高度:
h = Math.min(height[left], height[right])
- 计算容器的容量:
water = width * h
- 更新最大容量:
maxWater = Math.max(maxWater, water)
- 移动指向较短柱子的指针:
- 如果
height[left] < height[right]
,移动左指针:left++
- 否则,移动右指针:
right--
- 如果
- 计算当前两个指针构成的容器宽度:
- 返回最大容量
4.4 复杂度分析
- 时间复杂度:O(n),其中 n 是数组的长度。我们只需遍历一次数组。
- 空间复杂度:O(1),只需要常数级别的额外空间。
4.5 正确性证明
为什么双指针法是正确的?我们需要证明在移动指针的过程中不会错过最优解。
假设当前左右指针分别为 left
和 right
,高度分别为 height[left]
和 height[right]
,我们假设 height[left] < height[right]
(较短的柱子在左边)。
如果最优解包含 left
指针所指的柱子,那么我们已经计算过了当前的容量。
如果最优解不包含 left
指针所指的柱子,而是某个位置 i
(其中 left < i < right
)与 right
形成的容器,那么:
- 这个容器的宽度为
right - i
,小于当前容器的宽度right - left
- 这个容器的高度为
min(height[i], height[right])
- 由于
height[left] < height[right]
,要使新容器的容量更大,必须height[i] > height[left]
- 但即使
height[i]
远大于height[left]
,容器的高度最多为height[right]
- 所以新容器的容量为
(right - i) * min(height[i], height[right])
- 这个容量不可能超过
(right - left) * height[right]
,而后者大于我们当前计算的容量(right - left) * height[left]
因此,在 height[left] < height[right]
的情况下,移动 left
指针不会错过最优解。同理,在 height[left] >= height[right]
的情况下,移动 right
指针也不会错过最优解。
4.6 适用场景
双指针法是解决此问题的最优解,适用于所有情况,尤其是大型数组。这种方法既高效又简洁。
5. 解法三:双指针法的变体
5.1 思路
我们可以对双指针法做一些优化:
- 当移动指针时,如果发现新指向的柱子高度小于或等于原来的高度,可以继续移动指针,因为这样形成的容器容量只会更小。
- 这样可以跳过一些不必要的计算,进一步提高效率。
5.2 Java代码实现
public class Solution {
public int maxArea(int[] height) {
int maxWater = 0;
int left = 0;
int right = height.length - 1;
while (left < right) {
int width = right - left;
int h = Math.min(height[left], height[right]);
int water = width * h;
maxWater = Math.max(maxWater, water);
// 移动较短的一侧,并跳过不会产生更大容量的柱子
if (height[left] < height[right]) {
int currentLeft = height[left];
while (left < right && height[left] <= currentLeft) {
left++;
}
} else {
int currentRight = height[right];
while (left < right && height[right] <= currentRight) {
right--;
}
}
}
return maxWater;
}
}
5.3 代码详解
- 初始化
maxWater
为0,用于记录最大容量 - 使用两个指针
left
和right
,分别指向数组的首尾 - 当
left < right
时,执行循环:- 计算当前两个指针构成的容器容量
- 更新最大容量
- 根据以下策略移动指针:
- 如果
height[left] < height[right]
:- 记录当前左柱子的高度
currentLeft
- 移动左指针直到找到高度大于
currentLeft
的柱子或指针相遇
- 记录当前左柱子的高度
- 否则:
- 记录当前右柱子的高度
currentRight
- 移动右指针直到找到高度大于
currentRight
的柱子或指针相遇
- 记录当前右柱子的高度
- 如果
- 返回最大容量
5.4 复杂度分析
- 时间复杂度:O(n),虽然有循环嵌套,但每个位置最多只被访问一次,所以总体时间复杂度仍为O(n)。
- 空间复杂度:O(1),只需要常数级别的额外空间。
5.5 优点与缺点
优点:
- 通过跳过不会产生更大容量的柱子,减少了不必要的计算
- 在某些特殊情况下(如数组中有很多相同高度的柱子),性能会比基本的双指针法更好
缺点:
- 代码稍微复杂一些
- 在随机数据下,性能提升可能不明显
6. 特殊情况和边界处理
在实现解决方案时,我们需要考虑以下特殊情况:
-
只有两根柱子:此时只有一种可能,直接计算容量即可。
if (height.length == 2) { return Math.min(height[0], height[1]); }
-
所有柱子高度相同:在这种情况下,最大容量将由最远的两根柱子决定。
- 双指针法会正确地计算这种情况,无需特殊处理
-
单调递增或递减的柱子:例如[1,2,3,4,5]或[5,4,3,2,1]
- 双指针法会从两端开始,逐渐向中间移动,确保不会错过最优解
-
高度为0的柱子:这种柱子不会贡献任何容量,但双指针法会自动跳过它们
7. 优化与改进
7.1 提前结束条件
在某些情况下,我们可以提前终止搜索:
public int maxArea(int[] height) {
int maxWater = 0;
int left = 0;
int right = height.length - 1;
while (left < right) {
int width = right - left;
int h = Math.min(height[left], height[right]);
int water = width * h;
maxWater = Math.max(maxWater, water);
// 提前结束条件:如果剩余宽度 * 最大可能高度都小于当前最大容量,可以提前结束
if ((right - left) * Math.max(height[left], height[right]) <= maxWater) {
break;
}
if (height[left] < height[right]) {
left++;
} else {
right--;
}
}
return maxWater;
}
注意:这个优化在实际中可能并不总是有效,因为额外的检查可能会抵消节省的计算。
7.2 记忆化搜索
对于暴力法,我们可以使用记忆化搜索避免重复计算:
public int maxArea(int[] height) {
// 创建记忆数组,memo[i][j]表示柱子i和j之间的最大容量
int[][] memo = new int[height.length][height.length];
return findMaxArea(height, 0, height.length - 1, memo);
}
private int findMaxArea(int[] height, int left, int right, int[][] memo) {
// 基础情况
if (left >= right) return 0;
// 如果已经计算过,直接返回结果
if (memo[left][right] > 0) return memo[left][right];
// 计算当前柱子对形成的容器容量
int currentArea = (right - left) * Math.min(height[left], height[right]);
// 选择移动左指针或右指针,取决于哪个柱子更短
int nextArea;
if (height[left] < height[right]) {
nextArea = findMaxArea(height, left + 1, right, memo);
} else {
nextArea = findMaxArea(height, left, right - 1, memo);
}
// 记录并返回最大容量
memo[left][right] = Math.max(currentArea, nextArea);
return memo[left][right];
}
注意:这种方法的时间复杂度为O(n²),空间复杂度也为O(n²),在大规模数据下仍不如双指针法高效。
8. 完整的 Java 解决方案
以下是最优解决方案(双指针法)的完整实现:
class Solution {
public int maxArea(int[] height) {
int maxWater = 0;
int left = 0;
int right = height.length - 1;
while (left < right) {
// 计算当前容器容量
int width = right - left;
int h = Math.min(height[left], height[right]);
int water = width * h;
// 更新最大容量
maxWater = Math.max(maxWater, water);
// 移动指向较短柱子的指针
if (height[left] < height[right]) {
left++;
} else {
right--;
}
}
return maxWater;
}
}
9. 实际应用与扩展
9.1 应用场景
盛最多水的容器问题的思想在实际中有多种应用:
- 资源优化:在一些资源分配问题中,可能需要选择两个点来最大化它们之间的某种度量
- 图像处理:在某些图像处理算法中,可能需要找出图像中能含有最多像素的矩形区域
- 物流规划:在物流仓库设计中,可能需要确定最优的货架位置以最大化存储空间
- 网络带宽分配:在网络设计中,可能需要确定两个节点之间的最大可能带宽
9.2 扩展问题
-
三维容器问题:如果扩展到三维空间,如何找到容积最大的容器?
public int maxVolume(int[][] heights) { // 这里heights[i][j]表示在坐标(i,j)处的高度 // 实现三维最大容积的计算 // 这是一个复杂的问题,可能需要更高级的算法 }
-
有漏洞的容器:如果容器中可能有漏洞(某些位置不能存水),如何计算最大容量?
public int maxAreaWithHoles(int[] height, boolean[] hasHole) { int maxWater = 0; int left = 0; int right = height.length - 1; while (left < right) { // 跳过有漏洞的位置 if (hasHole[left]) { left++; continue; } if (hasHole[right]) { right--; continue; } // 计算当前容器容量 int width = right - left; int h = Math.min(height[left], height[right]); int water = width * h; maxWater = Math.max(maxWater, water); if (height[left] < height[right]) { left++; } else { right--; } } return maxWater; }
-
动态变化的容器:如果柱子的高度可能随时间变化,如何高效地重新计算最大容量?
public class DynamicContainer { private int[] heights; private int maxWater; public DynamicContainer(int[] initialHeights) { this.heights = initialHeights.clone(); this.maxWater = calculateMaxWater(); } public void updateHeight(int index, int newHeight) { heights[index] = newHeight; // 重新计算最大容量 maxWater = calculateMaxWater(); } private int calculateMaxWater() { // 使用双指针算法计算最大容量 int maxWater = 0; int left = 0; int right = heights.length - 1; while (left < right) { int width = right - left; int h = Math.min(heights[left], heights[right]); int water = width * h; maxWater = Math.max(maxWater, water); if (heights[left] < heights[right]) { left++; } else { right--; } } return maxWater; } public int getMaxWater() { return maxWater; } }
10. 常见问题与解答
10.1 为什么暴力法会超时?
暴力法需要O(n²)的时间复杂度,当n很大时(如n=105),计算量会达到1010,这在大多数编程语言中都会超出时间限制。
10.2 双指针法为什么能保证找到最优解?
双指针法的核心思想是,每次移动指向较短柱子的指针。这是因为容器的容量受限于较短的柱子,而移动较长的柱子只会减小宽度而不会增加高度,从而容量只会减小。通过这种方式,我们可以保证不会错过最优解。
10.3 如何处理大规模数据?
对于非常大的数组:
- 使用双指针法,时间复杂度为O(n)
- 如果数据分布有特点,可以考虑使用跳过相似高度的优化版本
- 对于特别大的数据集,可以考虑并行处理不同区间,然后合并结果
10.4 如何验证算法的正确性?
可以使用以下测试用例:
- 基本测试:[1,8,6,2,5,4,8,3,7] → 49
- 单调递增:[1,2,3,4,5] → 4 (柱子1和5,宽度为4,高度为1)
- 单调递减:[5,4,3,2,1] → 4 (柱子1和5,宽度为4,高度为1)
- 全部相同:[5,5,5,5,5] → 20 (柱子1和5,宽度为4,高度为5)
- 两个高峰:[1,8,1,1,1,8,1] → 30 (柱子2和6,宽度为4,高度为8)
11. 测试用例
为了验证解决方案的正确性,以下是一些测试用例:
public class ContainerWithMostWaterTest {
public static void main(String[] args) {
Solution solution = new Solution();
// 测试用例1:标准测试
int[] height1 = {1, 8, 6, 2, 5, 4, 8, 3, 7};
testAndPrint(solution, height1, "测试用例1");
// 测试用例2:只有两个柱子
int[] height2 = {1, 2};
testAndPrint(solution, height2, "测试用例2");
// 测试用例3:单调递增
int[] height3 = {1, 2, 3, 4, 5};
testAndPrint(solution, height3, "测试用例3");
// 测试用例4:单调递减
int[] height4 = {5, 4, 3, 2, 1};
testAndPrint(solution, height4, "测试用例4");
// 测试用例5:全部相同高度
int[] height5 = {5, 5, 5, 5, 5};
testAndPrint(solution, height5, "测试用例5");
// 测试用例6:两个高峰
int[] height6 = {1, 8, 1, 1, 1, 8, 1};
testAndPrint(solution, height6, "测试用例6");
// 测试用例7:零高度
int[] height7 = {0, 5, 0, 5, 0};
testAndPrint(solution, height7, "测试用例7");
}
private static void testAndPrint(Solution solution, int[] height, String caseName) {
int result = solution.maxArea(height);
System.out.println(caseName + ":");
System.out.println("输入: " + Arrays.toString(height));
System.out.println("输出: " + result);
System.out.println();
}
}
12. 图解算法
为了更好地理解这个问题,下面是一个图解示例(使用文本表示):
示例数组:[1,8,6,2,5,4,8,3,7]
|
|
8 | | |
| | |
7 | | | |
| | | |
6 | | | | |
| | | | |
5 | | | | | |
| | | | | |
4 | | | | | | |
| | | | | | |
3 | | | | | | | |
| | | | | | | |
2 | | | | | | | | |
| | | | | | | | |
1 | | | | | | | | | | |
----------------------------------------
0 1 2 3 4 5 6 7 8
-
初始状态:left = 0, right = 8
- 容器宽度 = 8 - 0 = 8
- 容器高度 = min(1, 7) = 1
- 容器容量 = 8 * 1 = 8
-
由于 height[left] < height[right],移动左指针:left = 1, right = 8
- 容器宽度 = 8 - 1 = 7
- 容器高度 = min(8, 7) = 7
- 容器容量 = 7 * 7 = 49
-
由于 height[left] > height[right],移动右指针:left = 1, right = 7
- 容器宽度 = 7 - 1 = 6
- 容器高度 = min(8, 3) = 3
- 容器容量 = 6 * 3 = 18
…依此类推,最后找到的最大容量为49。
13. 总结与技巧
13.1 解题要点
- 理解容器容量的计算方式:容量 = 宽度 × 高度,其中高度取决于较短的柱子
- 双指针技巧的应用:从两端开始,逐步向中间移动,每次移动较短的那一端
- 合理剪枝:跳过那些不可能产生更大容量的情况
- 处理边界情况:如只有两根柱子、全部相同高度等
- 优化思路:从暴力法O(n²)到双指针法O(n)的优化过程
13.2 常用技巧
- 双指针法:在处理数组中需要考虑两个元素的问题时,双指针法常常很有效
- 贪心策略:每次移动较短的指针,以期望找到更大的容量
- 状态转移:通过移动指针,从一个状态转移到另一个状态,并在过程中记录最优解
- 分析极限情况:理解在各种特殊情况下算法的行为
- 图形化思考:将问题转换为几何问题,可以帮助更好地理解
13.3 面试技巧
在面试中遇到此类问题时:
- 先讨论暴力解法,说明你理解问题
- 分析暴力解法的时间复杂度,指出其不足
- 引入双指针法,解释为什么它能够正确工作
- 证明双指针法不会错过最优解
- 讨论各种边界情况和优化可能
- 如果有时间,可以讨论问题的扩展和应用场景