1. LeetCode 1582题解:二进制矩阵中的特殊位置
今天我们来深入探讨LeetCode第1582题"二进制矩阵中的特殊位置"。这是一道关于矩阵操作的经典题目,考察我们对二维数组的处理能力以及优化思维。在实际面试中,这类矩阵题目经常出现,因为它们能很好地测试候选人对基础数据结构的掌握程度和算法优化能力。
1.1 题目理解与定义
特殊位置的定义非常明确:在一个二进制矩阵中,某个位置(i,j)被称为特殊位置,当且仅当:
- mat[i][j] == 1
- 第i行所有其他元素都是0
- 第j列所有其他元素都是0
换句话说,这个位置上的1必须是它所在行和所在列中唯一的1。这个定义看似简单,但实现起来需要考虑如何高效地验证这些条件。
1.2 直观解法分析
最直观的解法是:对于矩阵中的每一个1,都扫描它所在的行和列,检查是否满足条件。这种方法的时间复杂度是O(m×n×(m+n)),因为对于每个元素(m×n个),最坏情况下需要扫描整行(n个元素)和整列(m个元素)。
这种暴力解法在小矩阵上尚可接受,但对于较大的矩阵(比如1000×1000),时间复杂度会变得非常高(约10^9次操作),显然不够高效。
2. 优化思路与算法设计
2.1 预处理行和列的统计信息
更聪明的做法是预先统计每一行和每一列中1的个数。这样我们可以:
- 第一次遍历矩阵,统计每行有多少个1(存入row数组)
- 第二次遍历矩阵,统计每列有多少个1(存入col数组)
- 第三次遍历矩阵,对于每个1,只需检查row[i]和col[j]是否都为1
这种方法将时间复杂度降低到了O(m×n),因为我们只需要三次完整的矩阵遍历,每个元素被访问常数次。
2.2 算法实现细节
让我们仔细看看代码实现中的几个关键点:
-
矩阵尺寸获取:使用mat.size()获取行数,mat[0].size()获取列数。这里假设矩阵至少有一行一列,题目中应该已经保证了这一点。
-
统计行中1的个数:
cpp复制for (int i = 0; i < m; ++i) {
for (int j = 0; j < n; ++j) {
row[i] += mat[i][j];
}
}
这里利用了C++中bool/int的隐式转换,mat[i][j]的值(0或1)直接累加到row[i]中。
- 统计列中1的个数:
cpp复制for (int j = 0; j < n; ++j) {
for (int i = 0; i < m; ++i) {
col[j] += mat[i][j];
}
}
注意这里循环的顺序是先列后行,这对缓存局部性不太友好,但在这个问题中影响不大。
- 特殊位置判断:
cpp复制if (mat[i][j] == 1 && row[i] == 1 && col[j] == 1) {
++ans;
}
这个条件简洁地表达了题目要求的三个条件。
2.3 复杂度分析
- 时间复杂度:O(m×n),因为我们对矩阵进行了三次完整遍历,每个元素被访问三次。
- 空间复杂度:O(m+n),用于存储行和列的统计信息。
3. 代码实现与优化技巧
3.1 完整代码实现
让我们再看一遍完整的C++实现:
cpp复制class Solution {
public:
int numSpecial(vector<vector<int>>& mat) {
int m = mat.size(); // 行数
int n = mat[0].size(); // 列数
vector<int> row(m, 0); // 每行1的个数
vector<int> col(n, 0); // 每列1的个数
// 统计每行的1的个数
for (int i = 0; i < m; ++i) {
for (int j = 0; j < n; ++j) {
row[i] += mat[i][j];
}
}
// 统计每列的1的个数
for (int j = 0; j < n; ++j) {
for (int i = 0; i < m; ++i) {
col[j] += mat[i][j];
}
}
int ans = 0;
// 查找特殊位置
for (int i = 0; i < m; ++i) {
for (int j = 0; j < n; ++j) {
if (mat[i][j] == 1 && row[i] == 1 && col[j] == 1) {
++ans;
}
}
}
return ans;
}
};
3.2 优化技巧与注意事项
-
循环顺序的选择:
- 统计行和时,外层循环是行,内层是列,这样访问是连续的,对缓存友好。
- 统计列和时,外层是列,内层是行,访问是不连续的。对于大矩阵,这可能影响性能。可以考虑转置矩阵后再统计,但这需要额外空间。
-
空间优化:
- 如果允许修改原矩阵,可以用第一行和第一列来存储统计信息,将空间复杂度降为O(1)。但这样会破坏原始数据,且实现更复杂。
-
边界条件处理:
- 空矩阵的情况需要处理,但题目通常保证矩阵至少有一个元素。
- 所有元素为0的矩阵,结果显然是0。
-
并行化可能性:
- 行统计和列统计可以并行进行,因为它们互不依赖。
- 在支持并行化的环境中,这可以进一步提高性能。
4. 测试用例与验证
4.1 示例测试
让我们验证题目给出的示例:
cpp复制mat = [
[1,0,0],
[0,0,1],
[1,0,0]
]
统计行和:
- row[0] = 1 (第一行:1+0+0)
- row[1] = 1 (第二行:0+0+1)
- row[2] = 1 (第三行:1+0+0)
统计列和:
- col[0] = 2 (第一列:1+0+1)
- col[1] = 0 (第二列:0+0+0)
- col[2] = 1 (第三列:0+1+0)
查找特殊位置:
- (0,0): mat[0][0]=1, row[0]=1, col[0]=2 → 不满足
- (1,2): mat[1][2]=1, row[1]=1, col[2]=1 → 满足
- (2,0): mat[2][0]=1, row[2]=1, col[0]=2 → 不满足
结果确实为1,与示例一致。
4.2 边界测试
- 全0矩阵:
cpp复制[
[0,0],
[0,0]
]
预期输出:0
- 单元素矩阵:
cpp复制[[1]]
预期输出:1(因为它所在的行和列都只有它自己)
- 每行每列恰好一个1(排列矩阵):
cpp复制[
[0,1,0],
[1,0,0],
[0,0,1]
]
预期输出:3
5. 算法扩展与变种
5.1 类似题目
这种预处理行和列信息的技巧可以应用于许多矩阵问题:
- LeetCode 36. 有效的数独:检查每行、每列、每个3x3子网格是否有重复数字。
- LeetCode 73. 矩阵置零:当某个元素为0时,将其所在行和列都置为0。
- LeetCode 566. 重塑矩阵:改变矩阵的行列数,保持元素顺序。
5.2 变种问题思考
-
如果要求找出所有特殊位置的坐标而不仅仅是计数,如何修改代码?
- 只需将ans改为vector<pair<int,int>>,在满足条件时保存(i,j)即可。
-
如果矩阵很大但稀疏(大部分是0),如何优化?
- 可以只记录非零元素的位置,然后统计每行每列的非零元素数。
-
如果允许特殊位置所在行或列有多个1,但要求这些1都在同一行同一列(形成十字形),如何解决?
- 这将需要更复杂的条件判断,可能需要结合并查集等数据结构。
6. 实际应用场景
虽然这个问题看起来是纯理论的,但类似的思路在实际中有广泛应用:
- 数据库索引:快速定位满足多个条件的记录(类似行和列的双重条件)。
- 图像处理:识别图像中的特殊点(如角点检测)。
- 推荐系统:找出同时满足用户偏好和物品特性的特殊匹配。
- 社交网络分析:识别在多个维度上都独特的节点。
7. 总结与个人体会
这道题很好地展示了算法设计中"空间换时间"的思想。通过使用额外的O(m+n)空间来存储行和列的统计信息,我们将时间复杂度从O(m×n×(m+n))降低到了O(m×n)。这种预处理技巧在解决矩阵问题时非常实用。
在实际编码时,有几点值得注意:
- 确保统计行和列时循环的顺序正确,避免混淆行和列。
- 注意矩阵边界条件的处理,特别是单行或单列矩阵。
- 考虑是否可以进一步优化空间复杂度,特别是在内存受限的环境中。
我在最初解决这个问题时,首先想到的是暴力解法,然后才意识到可以通过预处理来优化。这提醒我们,在解决问题时,先想出一个可行的解法,然后再考虑如何优化,往往比一开始就追求最优解更有效。