每日算法-250330
记录今天的算法学习与复习。
1234. 替换子串得到平衡字符串
题目
思路
核心思路: 滑动窗口
解题过程
根据题意,一个字符串 s
被认为是平衡的,当且仅当 ‘Q’, ‘W’, ‘E’, ‘R’ 四种字符在 s
中出现的次数都相等(即 n/4
)。我们需要找到一个最短的子串,替换它之后能让整个字符串变平衡。
反向思考:如果我们要替换子串 s[l..r]
,那么这个子串 之外 的字符 s[0...l-1]
和 s[r+1...n-1]
是保持不变的。为了使 整个 字符串最终平衡,这些 不变 的字符中,每种字符 (‘Q’, ‘W’, ‘E’, ‘R’) 的数量都不能超过 n/4
。如果某个字符在子串外部的数量已经超过 n/4
,那么无论我们如何修改子串 s[l..r]
,都无法使该字符的总数降到 n/4
,也就无法使整个字符串平衡。
因此,我们的目标是找到一个最短的窗口 [left, right]
(代表要替换的子串),使得窗口 之外 的所有字符计数 (total_count[char] - window_count[char]
) 均 <= n/4
。
我们可以使用滑动窗口来解决这个问题:
- 先统计整个字符串
s
中各字符的出现次数hash[c]
。 - 如果初始状态已经是平衡的,直接返回 0。
- 初始化
left = 0
,right = 0
。扩展右边界right
,并将s[right]
的计数从hash
中减去(表示这个字符现在在窗口内,不属于 “窗口外” 的部分了)。 - 检查当前窗口是否满足条件:即窗口外的所有字符计数 (
hash['Q']
,hash['W']
,hash['E']
,hash['R']
) 是否都<= n/4
。 - 如果满足条件,说明当前窗口
[left, right]
是一个潜在的可替换子串。我们尝试缩小窗口以找到最短长度:记录当前窗口长度right - left + 1
并更新最小值ret
;然后将s[left]
的计数加回hash
(表示s[left]
移出窗口,回到 “窗口外” 的部分),并将left
右移。重复此步骤直到条件不再满足。 - 继续扩展右边界
right
,重复步骤 3-5,直到right
到达字符串末尾。 - 最终
ret
中存储的就是最短的可替换子串长度。
复杂度
- 时间复杂度:
O
(
n
)
O(n)
O(n),其中 n 是字符串的长度。每个字符最多被
left
和right
指针访问两次。 - 空间复杂度: O ( C ) O(C) O(C) 或 O ( 1 ) O(1) O(1),其中 C 是字符集的大小 (QWER,为常数)。我们使用了一个固定大小的哈希数组来存储字符计数。
Code
import java.util.Arrays;
class Solution {
public int balancedString(String ss) {
char[] s = ss.toCharArray();
int n = s.length;
// m 是每个字符的目标数量
int m = n / 4;
// ret 初始化为一个不可能的最大值
int ret = n;
// 统计整个字符串的字符计数
int[] hash = new int[128];
for (char c : s) {
hash[c]++;
}
// 检查初始字符串是否已经平衡
if (hash['Q'] == m && hash['W'] == m && hash['E'] == m && hash['R'] == m) {
return 0;
}
// 滑动窗口
for (int left = 0, right = 0; right < n; right++) {
// 字符 s[right] 进入窗口,将其从 "窗口外计数" 中移除
hash[s[right]]--;
// 检查窗口外的字符计数是否都满足 <= m
// 如果满足,尝试缩小窗口
while (left <= right && hash['Q'] <= m && hash['W'] <= m && hash['E'] <= m && hash['R'] <= m) {
// 更新最短窗口长度
ret = Math.min(ret, right - left + 1);
// 字符 s[left] 离开窗口,将其加回 "窗口外计数"
hash[s[left]]++;
// 缩小窗口左边界
left++;
}
}
// 最终结果
return ret;
}
}
682. 棒球比赛
题目
思路
核心思路: 模拟 / 栈 (可以用数组模拟)
解题过程
题目描述了一个计分系统,包含四种操作。我们可以使用一个列表或数组来模拟记录有效得分的过程,其行为类似于栈:
- 遍历操作数组
ops
。 - 维护一个记录有效分数的列表(或数组和指针)。
- 根据当前操作
ops[i]
执行相应逻辑:- 如果是整数
x
:将其转换为数字,添加到分数列表末尾。 - 如果是
"+"
:取列表末尾的两个分数,求和,并将和添加到列表末尾。 - 如果是
"D"
:取列表末尾的分数,乘以 2,并将结果添加到列表末尾。 - 如果是
"C"
:移除列表末尾的分数(使其无效)。
- 如果是整数
- 遍历结束后,计算分数列表中所有分数的总和。
在提供的代码中,使用了数组 nums
和指针 index
来模拟这个过程,index
指向下一个可以插入元素的位置,同时也表示当前有效分数的数量。
复杂度
- 时间复杂度:
O
(
n
)
O(n)
O(n),其中 n 是操作数组
ops
的长度。需要遍历一次数组。 - 空间复杂度: O ( n ) O(n) O(n),在最坏的情况下(例如所有操作都是数字),需要存储 n 个分数。
Code
class Solution {
public int calPoints(String[] ops) {
int n = ops.length;
// 使用数组模拟栈来存储有效分数
int[] nums = new int[n];
// index 指向下一个可插入位置,也代表当前有效分数数量
int index = 0;
for (int i = 0; i < n; i++) {
switch (ops[i]) {
case "C":
// 使上一个分数无效,index 后退
// 注意:这里直接将值设为 0 也可以,但 index-- 更符合栈的移除操作
// 后面求和时,无效位置的值不应被计算,但因为 index 减少了,循环求和时自然会跳过
if (index > 0) { // 防止对空栈执行 C 操作
index--;
// 可选:nums[index] = 0; 清零非必需,因为求和只到 index
}
break;
case "D":
// 将上一个分数翻倍并压入
if (index > 0) { // 确保有上一个分数
nums[index] = nums[index - 1] * 2;
index++;
}
break;
case "+":
// 将最后两个分数的和压入
if (index >= 2) { // 确保有最后两个分数
nums[index] = nums[index - 1] + nums[index - 2];
index++;
}
break;
default:
// 是数字,直接解析并压入
int val = Integer.parseInt(ops[i]);
nums[index++] = val;
break;
}
}
// 计算所有有效分数的总和
int sum = 0;
for (int i = 0; i < index; i++) { // 只对 index 之前的有效分数求和
sum += nums[i];
}
return sum;
}
}
53. 最大子数组和(复习)
题目
复习笔记
这次重写依然采用动态规划(或者说是在线处理)的思路。核心思想是:对于数组中的每一个元素 nums[i]
,以它结尾的最大子数组和要么是 nums[i]
本身(即从 nums[i]
开始一个新的子数组),要么是 nums[i]
加上以 nums[i-1]
结尾的最大子数组和(即将 nums[i]
加入到前面的子数组中)。
我们用 curSum
记录以当前元素结尾的最大子数组和。状态转移方程为:
curSum = Math.max(nums[i], curSum + nums[i])
同时,我们需要一个变量 maxSum
来记录遍历过程中遇到的所有 curSum
的最大值,这就是全局的最大子数组和。
这次复习过程中,对这个状态转移的理解和应用还算顺利,但需要确保完全内化,做到不假思索地写出。
具体解题思路回顾请看 3月23号的算法题。
复杂度
- 时间复杂度: O ( n ) O(n) O(n),只需要一次遍历。
- 空间复杂度: O ( 1 ) O(1) O(1),只使用了常数个额外变量。
Code
class Solution {
public int maxSubArray(int[] nums) {
// 初始化 curSum 和 maxSum 为第一个元素的值
int curSum = nums[0];
int maxSum = nums[0];
// 从第二个元素开始遍历
for (int i = 1; i < nums.length; i++) {
// 决定是开启新子数组还是加入原子数组
curSum = Math.max(nums[i], curSum + nums[i]);
// 更新全局最大和
maxSum = Math.max(curSum, maxSum);
}
return maxSum;
}
}
1652. 拆炸弹(复习)
题目
复习笔记
解题方法依然是滑动窗口。关键在于处理 k
的三种情况:
k == 0
: 结果数组所有元素都是 0。k > 0
: 对于code[i]
,需要求和code[i+1]
到code[i+k]
(注意数组循环)。k < 0
: 对于code[i]
,需要求和code[i-1]
到code[i+k]
(即code[i-|k|]
到code[i-1]
, 注意数组循环)。
为了统一处理 k > 0
和 k < 0
的情况,代码中使用了一个技巧:
- 当
k < 0
时,先将原数组code
反转,然后令a = |k|
。这样问题就转换成了求解反转后数组的 “向后”a
个元素的和。 - 计算完结果数组
ret
后,如果k < 0
,再将ret
反转回来,得到最终答案。
这样,主要的计算逻辑就只需要处理 k > 0
(或等价的 a > 0
)的情况,即计算每个位置之后 a
个元素的和。这可以通过滑动窗口实现:
- 先计算出
ret[0]
的值,即code[1]
到code[a]
的和 (注意处理循环下标)。 - 然后窗口向右滑动,对于
ret[i]
(当i > 0
),其和可以通过ret[i-1]
的和减去code[i]
(移出窗口的元素)并加上code[i+a]
(移入窗口的元素)得到 (注意处理循环下标)。
本次复习遇到的问题:
- 忘记在处理
k < 0
之前,无论k
正负,都应该先用a
保存k
的绝对值或原始值(根据后续逻辑决定)。代码中是在a < 0
时才赋值,应在开始就处理a = Math.abs(k)
或类似逻辑。(修正后的代码在判断k<0
后设置a = -k
,并在反转数组后使用a
作为正数窗口大小,是正确的处理方式) - 滑动窗口更新
sum
和ret[left]
的逻辑细节:sum
的更新应该是sum = sum - code[left] + code[right]
,并且ret[left]
应该在sum
更新 之后 赋值(或者在更新前赋值ret[left-1]
的结果)。代码中的sum += (code[right] - code[left])
和ret[left] = sum; left++;
逻辑是正确的,它计算的是下一个位置left
的结果。
具体解法回顾请看 3月23号的算法题。
复杂度
- 时间复杂度:
O
(
n
)
O(n)
O(n),滑动窗口遍历一次。如果
k < 0
,两次数组反转也是 O ( n ) O(n) O(n)。 - 空间复杂度:
O
(
n
)
O(n)
O(n),需要一个额外的数组
ret
来存储结果。如果允许原地修改(题目通常不允许),可以做到 O ( 1 ) O(1) O(1)。
Code
class Solution {
public int[] decrypt(int[] code, int k) {
int n = code.length;
int[] ret = new int[n];
// k == 0 的情况
if (k == 0) {
// 结果数组默认为 0,无需操作
return ret;
}
// 为了统一处理 k > 0 和 k < 0,我们用 a 代表窗口大小 (绝对值)
int a = Math.abs(k);
// 如果 k < 0,先反转数组,问题变为向后求和
if (k < 0) {
reverse(code);
}
// 计算初始窗口 [1, a] 的和 (注意下标循环)
int sum = 0;
for (int i = 1; i <= a; i++) {
// 使用模运算处理循环下标
sum += code[i % n];
}
// 计算 ret[0]
ret[0] = sum;
for (int i = 1; i < n; i++) {
sum = sum - code[i % n] + code[(i + a) % n];
ret[i] = sum;
}
if (k < 0) {
reverse(ret);
}
return ret;
}
// 辅助函数:反转数组
private void reverse(int[] arr) {
int left = 0, right = arr.length - 1;
while (left < right) {
int tmp = arr[left];
arr[left] = arr[right];
arr[right] = tmp;
left++;
right--;
}
}
}