一、概述
概念
矩阵是一个二维的数学结构,由行和列组成。每个元素可以用两个索引(行和列)来定位。矩阵在计算机科学和数学中有广泛的应用,例如图形处理、线性代数、图算法等。对于算法问题,通常需要设计和实现在矩阵上操作的算法。常见的矩阵操作包括矩阵相加、相乘、转置、求逆等。在算法问题中,有些问题可以通过将问题建模为矩阵,并在矩阵上进行操作来解决。
适用场景
- 图形处理: 在图形学和计算机图形处理中,矩阵常用于表示图形变换、旋转、缩放等操作。例如,通过矩阵变换可以实现图像的平移、旋转和放大缩小等。
- 图算法: 图算法中,邻接矩阵和邻接表是两种常见的表示图的方式。矩阵的运算可以用于解决图的遍历、最短路径、最小生成树等问题。
- 线性代数: 线性代数中的向量和矩阵运算广泛应用于科学计算、统计学、机器学习等领域。例如,矩阵乘法、矩阵的特征值分解等操作在数据分析和机器学习中经常用到。
- 动态规划: 有些动态规划问题可以使用矩阵来进行状态转移,将复杂的状态关系通过矩阵的形式简化处理。
- 数学建模: 在一些实际问题中,可以将问题抽象为矩阵运算,通过矩阵的性质和运算规律来解决问题。
优点
- 表达能力强: 矩阵能够直观而紧凑地表示多维数据,特别适合用于表达图形、图像、向量等复杂结构。
- 模块化设计: 矩阵算法常常能够将复杂问题拆解为矩阵的基本运算,使得问题模块化,易于理解和实现。
- 高效的线性代数运算: 矩阵运算有很多高效的线性代数算法,如矩阵乘法的 Strassen 算法等,使得处理大规模数据时效率更高。
- 图算法表达简单: 邻接矩阵和邻接表是图算法中常见的两种数据结构,矩阵的运算可以方便地应用于图的遍历、路径查找等问题。
- 广泛应用于科学计算和工程领域: 线性代数和矩阵运算是科学计算和工程领域的基础,矩阵算法在这些领域有着广泛的应用。
- 适用于并行计算: 一些矩阵运算能够很容易地并行化,适合在多核和分布式系统上高效运行。
二、刷题
矩阵置零
思路: 核心思想就是用数组地方式解决,采用两数组标记表征每行、每列地该位置是否需要置零,然后再遍历操作一下数组即可。
时间复杂度: O(n*m),其中m和n分别为矩阵的行数和列数
空间复杂度: O(m + n),两个额外的数组用于标记需要置零的行和列
/**
* @param {number[][]} matrix
* @return {void} Do not return anything, modify matrix in-place instead.
*/
var setZeroes = function (matrix) {
const len1 = matrix.length
const len2 = matrix[0].length
// 行标记
const rowFlag = new Array(len1).fill(false)
// 列标记
const colFlag = new Array(len1).fill(false)
for (let i = 0; i < len1; i++) {
for (let j = 0; j < len2; j++) {
if (matrix[i][j] === 0) {
rowFlag[i] = true
colFlag[j] = true
}
}
}
for (let i = 0; i < len1; i++) {
for (let j = 0; j < len2; j++) {
if (rowFlag[i] || colFlag[j]) {
matrix[i][j] = 0
}
}
}
return matrix
};
螺旋矩阵
思路: 定义四个变量top、bottom、left、right,表示当前要遍历的子矩阵的上边界、下边界、左边界和右边界,每次按照顺时针的顺序遍历当前子矩阵的边界,同时更新边界值。注意,在螺旋顺序遍历矩阵时,我们要确保当前子矩阵的上下边界和左右边界都是有效的,即使用if (top <= bottom)等条件避免重复遍历相同的元素。
时间复杂度:O(m * n),其中 m 为矩阵的行数,n 为矩阵的列数。
空间复杂度:除了返回的结果数组之外,我们只使用了常数级的额外空间,因此空间复杂度是 O(1)
/**
* @param {number[][]} matrix
* @return {number[]}
*/
var spiralOrder = function (matrix) {
const len1 = matrix.length
const len2 = matrix[0].length
const res = []
let top = 0
let right = len2 - 1
let bottom = len1 - 1
let left = 0
while (top <= bottom && left <= right) {
// 左 -> 右
for (let i = left; i <= right; i++) {
res.push(matrix[top][i])
}
top++
// 上 -> 下
for (let j = top; j <= bottom; j++) {
res.push(matrix[j][right])
}
right--
// 判断是否还存在有效的行或列
if (top <= bottom) {
// 右 -> 左
for (let i = right; i >= left; i--) {
res.push(matrix[bottom][i])
}
bottom--
}
if (left <= right) {
// 下 -> 上
for (let i = bottom; i >= top; i--) {
res.push(matrix[i][left])
}
left++
}
}
return res
};
旋转图像
思路: 矩阵转置(遍历矩阵交换矩阵的行和列,将矩阵沿着从左上到右下的对角线进行镜像交换)后再反转每一行(遍历矩阵的每一行,将每一行的元素按中心水平线翻转)。
时间复杂度:O(n^2),其中 n 是矩阵的边长。矩阵转置和每一行的翻转都是 O(n^2) 的操作。
空间复杂度:O(1),原地修改矩阵,没有使用额外的空间,因此空间复杂度是常数级别的。
/**
* @param {number[][]} matrix
* @return {void} Do not return anything, modify matrix in-place instead.
*/
var rotate = function(matrix) {
const len = matrix.length
// 先进行矩阵的转置
for(let i = 0; i < len; i++){
for(let j = i; j < len; j++){
[matrix[i][j], matrix[j][i]] = [matrix[j][i], matrix[i][j]]
}
}
// 再翻转每一行
for(let i = 0; i < len; i++){
matrix[i].reverse()
}
};
搜索二维矩阵 II
思路: 直接采用暴力做法可以得出,但并不符合题目要求,因为每行、每列都是按照升序排列好的,所以我们可以采用二分的方式,以右上点为起点(选左下点也类似),当当前值比目标值小时则往它的下面找,二比目标值大时则往左边找,直到相等或者越界。
时间复杂度:O(m + n),在每一步迭代中,我们都会移动到矩阵的下一行或前一列,因此最多进行 m + n 步
空间复杂度:O(1),只使用常数级别的额外空间
/**
* @param {number[][]} matrix
* @param {number} target
* @return {boolean}
*/
var searchMatrix = function(matrix, target) {
// 二分法
const rows = matrix.length
const cols = matrix[0].length
let row = 0
let col = cols - 1
// 从右上角开始找
while(row < rows && col >= 0){
if(matrix[row][col] === target) return true
else if(matrix[row][col] > target){
// 比目标值大则往左找
col--
}else{
// 比目标值小则往下找
row++
}
}
return false
};
有效的数独
思路: 使用三个哈希数组集合来表示每行、每列与每个九宫格的情况,因为是1-9唯一,所以采用Set结构,即如果在当前Set中已经存在直接返回false,否则把这个数添加到Set里,稍微需要注意的是九宫格的表示形式,在这里我们用Math.floor(i/3)+Math.floor(j/3)3来计算九宫格位置索引值,即当 i 在 0 到 2 之间时,结果是 0;当 i 在 3 到 5 之间时,结果是 1;依此类推,因为j表征列,避免冲突赋予3的权重,使得序号能够正常使用。
时间复杂度:O(1)
空间复杂度:O(1)
/**
* @param {character[][]} board
* @return {boolean}
*/
var isValidSudoku = function (board) {
const rows = new Array(9).fill(null).map(() => new Set())
const cols = new Array(9).fill(null).map(() => new Set())
const box = new Array(9).fill(null).map(() => new Set())
for (let i = 0; i < board.length; i++) {
for (let j = 0; j < board[0].length; j++) {
const num = board[i][j]
if (num !== '.') {
if (rows[i].has(num)) return false
rows[i].add(num)
if (cols[j].has(num)) return false
cols[j].add(num)
const boxIndex = Math.floor(i / 3) + Math.floor(j / 3) * 3
if(box[boxIndex].has(num)) return false
box[boxIndex].add(num)
}
}
}
return true
};
生命游戏
思路:
- 计算邻居的函数:countLiveNeighbors 函数用于计算给定细胞坐标 (x, y) 周围的活细胞数量。通过遍历所有可能的相邻坐标,判断是否越界,并检查相邻细胞的状态。这个函数在后续的规则判断中会被使用。
- 第一次遍历:更新下一个状态: 在第一次遍历中,对每个细胞应用生存规则,并更新其状态。
- 如果当前细胞是活细胞(状态为1):
- 如果周围活细胞的数量小于 2 或大于 3,那么当前细胞死亡,状态改为2。
- 如果当前细胞是死细胞(状态为0):
- 如果周围活细胞的数量等于 3,那么当前细胞复活,状态改为3。
- 第二次遍历:根据下一个状态更新当前状态: 在第二次遍历中,对所有细胞根据其状态进行最终的更新。具体的规则如下:
- 如果细胞状态为2,表示活细胞变为死细胞,将其状态改为0。
- 如果细胞状态为3,表示死细胞变为活细胞,将其状态改为1。
注:directions 是一个用于表示八个方向的数组。每个元素是一个包含两个数字的数组 [dx, dy],表示在横向和纵向上的偏移。通过在当前细胞的坐标上加上这个偏移,就可以得到当前细胞的八个相邻位置的坐标。
时间复杂度:
- 计算邻居的函数 countLiveNeighbors: 对于每个细胞,都需要检查其周围的八个方向,因此该函数的时间复杂度为 O(1)。
- 第一次遍历:更新下一个状态: 对于每个细胞,都需要调用 countLiveNeighbors 函数,因此总体的时间复杂度为 O(m * n)。
- 第二次遍历:根据下一个状态更新当前状态: 这是一个简单的遍历操作,时间复杂度为 O(m * n)。
- 综合起来,整个算法的时间复杂度为 O(m * n)
空间复杂度: 由于算法没有使用额外的数据结构,空间复杂度为 O(1),属于原地更新。
/**
* @param {number[][]} board
* @return {void} Do not return anything, modify board in-place instead.
*/
var gameOfLife = function (board) {
const m = board.length;
const n = board[0].length;
const directions = [[-1, -1], [-1, 0], [-1, 1], [0, -1], [0, 1], [1, -1], [1, 0], [1, 1]];
// 计算邻居的函数
const countLiveNeighbors = (x, y) => {
let count = 0;
for (const [dx, dy] of directions) {
const newX = x + dx;
const newY = y + dy;
if (newX >= 0 && newX < m && newY >= 0 && newY < n && (board[newX][newY] === 1 || board[newX][newY] === 2)) {
count++;
}
}
return count;
};
// 第一次遍历:更新下一个状态
for (let i = 0; i < m; i++) {
for (let j = 0; j < n; j++) {
const liveNeighbors = countLiveNeighbors(i, j);
if (board[i][j] === 1) {
// Rule 1 and Rule 3
if (liveNeighbors < 2 || liveNeighbors > 3) {
board[i][j] = 2; // 2 表示一个活细胞变为死细胞
}
} else if (board[i][j] === 0) {
// Rule 4
if (liveNeighbors === 3) {
board[i][j] = 3; // 3 表示一个死细胞变为活细胞
}
}
}
}
// 第二次遍历:根据下一个状态更新当前状态
for (let i = 0; i < m; i++) {
for (let j = 0; j < n; j++) {
if (board[i][j] === 2) {
board[i][j] = 0; // 将活细胞变为死细胞
} else if (board[i][j] === 3) {
board[i][j] = 1; // 将死细胞变为活细胞
}
}
}
};