记力扣周赛348中一道有趣的思维题

作者在力扣周赛中遇到一道有趣的思维题,题目要求对N*N矩阵进行多次查询,每次修改整行或整列的值,最终求矩阵元素和。作者首先尝试模拟法,但因内存限制失败;随后采用状态压缩,但仍超时;最终通过逆向思维,从后往前遍历查询,利用哈希表记录已处理的行列,成功优化至O(M)时间复杂度,解决了问题。

算法#逆向思维
文章目录

我平时周末有做力扣周赛的习惯,4道题目大部分时候只能做出3题,第四题大多数要么是较为复杂的数论/图论题,要么是较为困难的DP题,这两个类别都不是我的强项,所以想要拿一个好的名次只能从速度上下功夫,一般来说能在40分钟内做出前三题就勉强达到预期了。

而这周的周赛第三题是一道挺有趣的思维题,不仅题目本身有意思,数据规模和测试用例也给得恰到好处(咬牙切齿),最终我花了一小时,换了两次思路才将其做出来,记录一下做题思路。

题目链接

6472. 查询后矩阵的和 - 力扣(Leetcode)

这里就不赘述一遍题目了,只给出数据规模和下面时空复杂度所需的定义:

  • NN为矩阵边长且1N1041 \leq N \leq 10^4
  • MM为查询数组queries的长度且1M51041 \leq M \leq 5 * 10^4

思路一:模拟

一道题一开始没什么思路时,我倾向于先写出暴力算法的代码。题目要求我们对N * N的矩阵进行多次查询,每次会将对应行或列的所有元素修改成指定值,最后求矩阵所有元素的和,那么我们按照要求进行模拟:

class Solution {
public long matrixSumQueries(int n, int[][] queries) {
int[][] mat = new int[n][n]; // n*n矩阵
long ans = 0;
for (int[] query : queries) {
int index = query[1], val = query[2];
if (query[0] == 0) {
for (int i = 0; i < n; i++) {
ans += val - mat[i][index]; // 加上修改后增加的差值
mat[i][index] = val;
}
} else {
for (int i = 0; i < n; i++) {
ans += val - mat[index][i];
mat[index][i] = val;
}
}
}
return ans;
}
}

以上代码时间复杂度为O(NM)O(N * M)

空间复杂度为O(N2)O(N^2)

提交之后MLE了,这倒是始料未及的,那么我们就必须想出一种方法避免构建完整的矩阵。

思路二:状态压缩

观察样例,我们可以看到每个元素的值只跟最后一次修改了对应行/列的查询有关,并且一次会将整列/整行的值的修改掉,所以我们不用构建完整的矩阵,只要用两个数组保存每一行和每一列的值就好了。

但是我们需要知道最后修改元素的查询是行还是列,所以我们需要额外的两个数组,保存每一行和每一列最后修改的时间。

所有查询完成之后,我们遍历矩阵中每一个坐标,判断值并求总和即可。

class Solution {
public long matrixSumQueries(int n, int[][] queries) {
int[] x_val = new int[n], y_val = new int[n]; // 储存行和列的值
int[] x_mod = new int[n], y_mod = new int[n]; // 储存行和列的最后修改时间
long ans = 0;
int mod = 1; // 修改时间,每完成一次查询后自增一次
for (int[] query : queries) {
int index = query[1], val = query[2];
if (query[0] == 0) {
y_mod[index] = mod;
y_val[index] = val;
} else {
x_mod[index] = mod;
x_val[index] = val;
}
mod++;
}
// 遍历每一个坐标、判断值并加入总和中
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
ans += y_mod[j] > x_mod[i] ? y_val[j] : x_val[i];
}
}
return ans;
}
}

以上代码时间复杂度为O(N2+M)O(N^2 + M)

空间复杂度为O(N)O(N)

尝试提交,TLE了,卡在了最后一个测试用例。。。那我们就不能完整地遍历整个矩阵了。

思路三:逆向思维

参考思路二,每个元素的值只跟最后一次修改了对应行/列的查询有关,整个查询的过程就类似于往空白的墙上贴胶带,有横有竖,一道盖着一道,我们想要撕掉这些胶带,就得从最后一道开始往前撕。

求矩阵的元素和也类似,假设最后一次查询所修改的是第一行,该行的值已经确定,不会再更改了,所以可以直接求出该行的总和。

继续往前遍历,倒数第二次查询修改了第三列,除了与第一行的相交点(已经计算过)外,剩下的元素也可以直接求和。

还有一种情况,假设倒数第三次查询依旧修改了第三列,那么由于该次查询的值被后一次覆盖了并且已经求过和了,所以可以直接跳过。

至此思路就很清晰了,我们可以用两个哈希表来储存查询过的行和列,然后从后往前遍历查询数组,具体代码如下:

class Solution {
public long matrixSumQueries(int n, int[][] queries) {
long ans = 0;
Set<Integer> x_vis = new HashSet<>();
Set<Integer> y_vis = new HashSet<>();
// 从后往前遍历
for (int i = queries.length - 1; i >= 0; i--) {
int query = queries[i][0], index = queries[i][1], val = queries[i][2];
if (query == 0) {
// 若该行已经在后面被计算过,则跳过
if (x_vis.contains(index))
continue;
// 除去和计算过的列的交点后求和
ans += val * (n - y_vis.size());
x_vis.add(index);
} else {
// 同上
if (y_vis.contains(index))
continue;
ans += val * (n - x_vis.size());
y_vis.add(index);
}
}
return ans;
}
}

以上代码时间复杂度为O(M)O(M)

空间复杂度为O(N)O(N)

至此,本题完成,附上一份简洁一点的写法。

class Solution {
public long matrixSumQueries(int n, int[][] queries) {
long ans = 0;
Set<Integer>[] vis = new Set[]{new HashSet<>(), new HashSet<>()};
for (int i = queries.length - 1; i >= 0; i--) {
int query = queries[i][0], index = queries[i][1];
long val = queries[i][2];
if (vis[query].contains(index))
continue;
ans += val * (n - vis[query ^ 1].size());
vis[query].add(index);
}
return ans;
}
}

后记

花了这么长时间才做出这道题,中途还差点想摆烂,这种思维题可以说也是我的弱势之一,力扣后续应该多出点这种类型的题,确实挺有意思的。不过这次周赛应该是掉大分了,最后纪念一下竞赛分top5%吧。

竞赛分Top5%