1. 问题分析与解题思路
这道题目描述的是从卡牌游戏中获取最大点数的问题。给定一个整数数组cardPoints表示卡牌的点数,每次只能从数组的最左侧或最右侧取一张卡牌,共取k次,求能获得的最大总点数。
1.1 问题重述与理解
想象你面前有一排卡牌,每张卡牌上都有一个点数。游戏规则是:
- 每次只能从最左边或最右边取一张牌
- 总共可以取k次
- 目标是使取到的牌的总点数最大
这实际上是一个典型的滑动窗口问题,我们需要找到k张牌的最佳组合方式。
1.2 关键观察点
通过分析题目,我们可以得出几个关键观察:
- 取牌的总次数固定为k次
- 每次取牌只能从两端取
- 最终取到的牌必然是左侧连续取i张,右侧连续取k-i张的组合(其中0≤i≤k)
这个观察是解题的核心,它让我们可以将问题转化为寻找最优的i值,使得左侧i张和右侧k-i张的总和最大。
2. 前缀和解法详解
2.1 前缀和概念
前缀和是一种预处理技术,它可以帮助我们快速计算数组中任意区间的和。对于数组arr,其前缀和数组prefix定义为:
- prefix[0] = 0
- prefix[i] = arr[0] + arr[1] + ... + arr[i-1]
这样,arr中从l到r的和可以快速计算为prefix[r+1] - prefix[l]。
2.2 前缀和的应用
在本问题中,我们使用前缀和来:
- 快速计算左侧取i张牌的总和(prefix[i])
- 快速计算右侧取k-i张牌的总和(prefix[n] - prefix[n-(k-i)])
这样,我们就能在O(1)时间内计算出任意i对应的总和,而不需要每次都重新计算。
2.3 算法步骤解析
让我们详细解析代码中的每个步骤:
- 初始化前缀和数组:
cpp复制vector<int> prefix{0};
for(int i = 0; i < n; i++) {
prefix.push_back(prefix.back() + cardPoints[i]);
}
这里我们构建了前缀和数组prefix,其中prefix[i]表示前i张牌的总和。
- 遍历所有可能的i值:
cpp复制for(int i = 0; i <= k; i++) {
mx = max(mx, prefix[i] + prefix[n] - prefix[n-(k-i)]);
}
这个循环尝试所有可能的组合方式:
- i表示从左侧取的牌数
- k-i表示从右侧取的牌数
- prefix[i]是左侧i张牌的总和
- prefix[n] - prefix[n-(k-i)]是右侧k-i张牌的总和
- 返回最大值:
cpp复制return mx;
2.4 时间复杂度分析
- 构建前缀和数组:O(n)
- 遍历所有i值:O(k)
- 总时间复杂度:O(n + k) = O(n)(因为k ≤ n)
空间复杂度:O(n)(用于存储前缀和数组)
3. 代码实现与优化
3.1 完整代码实现
cpp复制class Solution {
public:
int maxScore(vector<int>& cardPoints, int k) {
int n = cardPoints.size();
vector<int> prefix{0};
// 构建前缀和数组
for(int i = 0; i < n; i++) {
prefix.push_back(prefix.back() + cardPoints[i]);
}
int mx = 0;
// 尝试所有可能的左右组合
for(int i = 0; i <= k; i++) {
int current = prefix[i] + (prefix[n] - prefix[n-(k-i)]);
mx = max(mx, current);
}
return mx;
}
};
3.2 代码优化思路
虽然上述代码已经很高效,但我们还可以考虑以下优化:
- 空间优化:可以只计算必要的前缀和部分,减少空间使用
- 滑动窗口法:可以用滑动窗口直接计算所有可能的窗口和,避免显式计算前缀和
3.3 滑动窗口解法
滑动窗口解法更加直观,思路如下:
- 先计算前k张牌的总和作为初始最大值
- 然后依次从右侧取一张牌,同时从左侧减少一张牌
- 比较每次调整后的总和,保留最大值
实现代码:
cpp复制class Solution {
public:
int maxScore(vector<int>& cardPoints, int k) {
int n = cardPoints.size();
int windowSum = accumulate(cardPoints.begin(), cardPoints.begin() + k, 0);
int maxSum = windowSum;
for(int i = 1; i <= k; i++) {
windowSum = windowSum - cardPoints[k-i] + cardPoints[n-i];
maxSum = max(maxSum, windowSum);
}
return maxSum;
}
};
这种解法的时间复杂度为O(k),空间复杂度为O(1),更加高效。
4. 边界条件与测试用例
4.1 常见边界情况
- k等于数组长度:此时必须取所有牌
- k等于1:只需比较第一张和最后一张牌
- 所有牌点数相同:任何取法结果相同
- 数组长度为1:只能取这张牌
4.2 测试用例设计
好的测试用例应该包括:
- 常规情况
- 边界情况
- 极端情况
示例测试用例:
cpp复制// 测试用例1:常规情况
vector<int> cards1 = {1,2,3,4,5,6,1};
int k1 = 3;
// 预期输出:12(取最右边的3张牌:6,5,1)
// 测试用例2:k等于数组长度
vector<int> cards2 = {9,7,7,9,7,7,9};
int k2 = 7;
// 预期输出:55(取所有牌)
// 测试用例3:k等于1
vector<int> cards3 = {1,1000,1};
int k3 = 1;
// 预期输出:1(比较第一张和最后一张)
// 测试用例4:所有牌点数相同
vector<int> cards4 = {2,2,2,2,2,2,2};
int k4 = 3;
// 预期输出:6(任何取法结果相同)
5. 常见问题与解决技巧
5.1 为什么使用前缀和?
前缀和可以让我们在O(1)时间内计算任意区间的和,这在需要频繁计算区间和的问题中非常高效。对于本题,我们需要计算多种不同的左右组合,前缀和能显著提高效率。
5.2 如何避免重复计算?
如果不使用前缀和,直接计算每种组合的和会导致O(k^2)的时间复杂度。前缀和通过预处理将每次计算的时间降为O(1),整体时间复杂度降为O(n)。
5.3 滑动窗口与前缀和的选择
两种方法各有优劣:
- 前缀和:思路直观,实现简单,但需要额外空间
- 滑动窗口:空间效率高,但需要更巧妙的窗口调整逻辑
在面试中,可以先实现前缀和解法,然后讨论是否可以优化为滑动窗口解法。
5.4 调试技巧
在实现这类算法时,可以:
- 打印中间结果(如前缀和数组)
- 对小的测试用例手动计算预期结果
- 特别注意循环的边界条件(如i的起始和结束值)
6. 实际应用与扩展
6.1 类似问题
这类滑动窗口/前缀和的问题在算法中很常见,类似的问题包括:
- 最大子数组和
- 固定大小的滑动窗口最大值
- 满足条件的子数组数目
6.2 实际应用场景
这种技术可以应用于:
- 金融分析中的移动平均计算
- 信号处理中的滑动窗口滤波
- 游戏中的连续奖励计算
6.3 算法扩展
可以尝试解决以下扩展问题:
- 如果允许从任意位置取牌,如何解决?
- 如果每次可以从两端各取一张牌,如何解决?
- 如果牌排成一个环,如何解决?
7. 个人经验分享
在实际解决这类问题时,我发现以下几点特别重要:
- 先理解问题本质:不要急于编码,先彻底理解题目要求和限制条件
- 画图辅助:对于数组操作问题,画出示意图能帮助理清思路
- 考虑边界条件:特别是当k等于0、1或数组长度时的情况
- 从暴力法开始:先想出一个可行的解法,再考虑优化
在实现前缀和解法时,最容易出错的是前缀和数组的索引。记住prefix[i]表示前i个元素的和(不包括第i个元素),这一点需要特别注意。
对于性能优化,滑动窗口法通常更优,但实现起来需要更仔细地处理窗口的移动。我建议先掌握前缀和这种更通用的方法,再学习滑动窗口这种优化技巧。