力扣热题——矩阵

270 阅读9分钟

一、概述

概念

矩阵是一个二维的数学结构,由行和列组成。每个元素可以用两个索引(行和列)来定位。矩阵在计算机科学和数学中有广泛的应用,例如图形处理、线性代数、图算法等。对于算法问题,通常需要设计和实现在矩阵上操作的算法。常见的矩阵操作包括矩阵相加、相乘、转置、求逆等。在算法问题中,有些问题可以通过将问题建模为矩阵,并在矩阵上进行操作来解决。

适用场景

  1. 图形处理: 在图形学和计算机图形处理中,矩阵常用于表示图形变换、旋转、缩放等操作。例如,通过矩阵变换可以实现图像的平移、旋转和放大缩小等。
  2. 图算法: 图算法中,邻接矩阵和邻接表是两种常见的表示图的方式。矩阵的运算可以用于解决图的遍历、最短路径、最小生成树等问题。
  3. 线性代数: 线性代数中的向量和矩阵运算广泛应用于科学计算、统计学、机器学习等领域。例如,矩阵乘法、矩阵的特征值分解等操作在数据分析和机器学习中经常用到。
  4. 动态规划: 有些动态规划问题可以使用矩阵来进行状态转移,将复杂的状态关系通过矩阵的形式简化处理。
  5. 数学建模: 在一些实际问题中,可以将问题抽象为矩阵运算,通过矩阵的性质和运算规律来解决问题。

优点

  1. 表达能力强: 矩阵能够直观而紧凑地表示多维数据,特别适合用于表达图形、图像、向量等复杂结构。
  2. 模块化设计: 矩阵算法常常能够将复杂问题拆解为矩阵的基本运算,使得问题模块化,易于理解和实现。
  3. 高效的线性代数运算: 矩阵运算有很多高效的线性代数算法,如矩阵乘法的 Strassen 算法等,使得处理大规模数据时效率更高。
  4. 图算法表达简单: 邻接矩阵和邻接表是图算法中常见的两种数据结构,矩阵的运算可以方便地应用于图的遍历、路径查找等问题。
  5. 广泛应用于科学计算和工程领域: 线性代数和矩阵运算是科学计算和工程领域的基础,矩阵算法在这些领域有着广泛的应用。
  6. 适用于并行计算: 一些矩阵运算能够很容易地并行化,适合在多核和分布式系统上高效运行。

二、刷题

矩阵置零

image.png
思路: 核心思想就是用数组地方式解决,采用两数组标记表征每行、每列地该位置是否需要置零,然后再遍历操作一下数组即可。
时间复杂度: 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
};

螺旋矩阵

image.png
思路: 定义四个变量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
};

旋转图像

image.png
思路: 矩阵转置(遍历矩阵交换矩阵的行和列,将矩阵沿着从左上到右下的对角线进行镜像交换)后再反转每一行(遍历矩阵的每一行,将每一行的元素按中心水平线翻转)。
时间复杂度: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

image.png

image.png
思路: 直接采用暴力做法可以得出,但并不符合题目要求,因为每行、每列都是按照升序排列好的,所以我们可以采用二分的方式,以右上点为起点(选左下点也类似),当当前值比目标值小时则往它的下面找,二比目标值大时则往左边找,直到相等或者越界。
时间复杂度: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
};

有效的数独

image.png

image.png
思路: 使用三个哈希数组集合来表示每行、每列与每个九宫格的情况,因为是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
};

生命游戏

image.png

image.png

思路:

  1. 计算邻居的函数:countLiveNeighbors 函数用于计算给定细胞坐标 (x, y) 周围的活细胞数量。通过遍历所有可能的相邻坐标,判断是否越界,并检查相邻细胞的状态。这个函数在后续的规则判断中会被使用。
  2. 第一次遍历:更新下一个状态: 在第一次遍历中,对每个细胞应用生存规则,并更新其状态。
    • 如果当前细胞是活细胞(状态为1):
      • 如果周围活细胞的数量小于 2 或大于 3,那么当前细胞死亡,状态改为2。
    • 如果当前细胞是死细胞(状态为0):
      • 如果周围活细胞的数量等于 3,那么当前细胞复活,状态改为3。
  3. 第二次遍历:根据下一个状态更新当前状态: 在第二次遍历中,对所有细胞根据其状态进行最终的更新。具体的规则如下:
    • 如果细胞状态为2,表示活细胞变为死细胞,将其状态改为0。
    • 如果细胞状态为3,表示死细胞变为活细胞,将其状态改为1。

注:directions 是一个用于表示八个方向的数组。每个元素是一个包含两个数字的数组 [dx, dy],表示在横向和纵向上的偏移。通过在当前细胞的坐标上加上这个偏移,就可以得到当前细胞的八个相邻位置的坐标。
时间复杂度:

  1. 计算邻居的函数 countLiveNeighbors: 对于每个细胞,都需要检查其周围的八个方向,因此该函数的时间复杂度为 O(1)。
  2. 第一次遍历:更新下一个状态: 对于每个细胞,都需要调用 countLiveNeighbors 函数,因此总体的时间复杂度为 O(m * n)。
  3. 第二次遍历:根据下一个状态更新当前状态: 这是一个简单的遍历操作,时间复杂度为 O(m * n)。
  4. 综合起来,整个算法的时间复杂度为 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; // 将死细胞变为活细胞
            }
        }
    }
};