目录

题目描述

694. 不同岛屿的数量

题意分析

网格里 1 是陆地、0 是水,四连通的陆地组成一个岛屿。题目不是问有几个岛,而是问有几种互不相同的形状

「相同」的定义写得很克制:两个岛如果能通过平移互相重合,就算同一种形状。注意题面只允许平移,不允许旋转,也不允许翻转。这是本题最重要的约束信号——它意味着形状的判定要保留方向信息,一个「向右伸出去的钩子」和一个「向下伸出去的钩子」是两种形状。

由此可以推出问题的本质:要为每个岛屿造一个平移不变的指纹,让形状相同的岛拿到相同的指纹、形状不同的岛拿到不同的指纹,然后数一数有几种指纹。

边界上要考虑:网格里没有任何陆地(答案是 0)、整张网格全是陆地(只有一种形状)、多个只有一格的岛(都算同一种形状)、以及岛屿贴着网格四边导致某些方向越界。

解法:DFS 路径签名

核心思路

最朴素的做法是把每个岛屿的格子坐标集合存下来,再两两比较是否能通过平移重合。比较两个岛需要先做归一化(比如都减去各自左上角坐标)再逐点对照,岛屿多的时候是 $O(k^2)$ 次集合比较,写起来也繁琐。

瓶颈在于「先收集再两两比对」这条路径。观察一下:既然要判等,不如直接为每个岛算出一个可以哈希的字符串指纹,把两两比对换成往集合里丢。

那么指纹怎么造才既平移不变、又能区分形状?关键观察是:从岛屿的某个固定起点出发、按固定顺序做 DFS,走过的方向序列就完全刻画了岛屿的结构,并且和绝对坐标无关。只要两个岛形状相同,DFS 从各自的起点出发就会走出一模一样的方向序列。

起点必须选得一致。外层是按行优先扫描网格的,第一个碰到的陆地格必然是这个岛里「行号最小、同行中列号最小」的那一格,这个位置只由形状决定、不受平移影响,天然满足要求。

只记录进入方向还不够。方向序列相同但分叉位置不同的两个岛会撞签名,因为字符串没法表达「什么时候回到上一层」。补救办法是每次递归返回时追加一个回退标记 'B',这样字符串就变成了对 DFS 树的完整括号化编码,树形结构被唯一确定。

显式的不变量是:对任意一个岛屿,从它的行优先最小格出发、按「上、下、左、右」这个固定顺序递归、并在每层返回时追加 'B',得到的字符串与岛屿在网格中的位置无关,且与岛屿形状一一对应。把所有岛的签名丢进哈希集合,集合大小就是答案。

解题步骤

  • 准备一个字符串集合 shapes 用来去重。选集合而不是计数器,是因为要数的是「形状种类」而不是「岛屿个数」。
  • 按行优先双层循环扫描整个网格,遇到 grid[row][col] == 1 就说明发现了一个尚未处理的新岛屿,且当前格就是这个岛的行优先最小格。
  • 为这个岛新建一个空的路径缓冲,调用 DFS,起始方向字符用 'S'。每个岛必须用独立的缓冲,共用一个会让后面的岛签名带上前面岛的尾巴。
  • DFS 里先做统一的边界与水域判断:行列越界或者当前格是 0 就直接返回,且不追加任何字符。把四种越界情况合并成一个判断,比在四个递归调用前各写一遍可靠得多。
  • 通过判断后立刻把 grid[row][col] 置为 0。这一步必须在递归之前做,否则相邻两格会互相递归成死循环。置 0 同时充当了访问标记,省掉一个 visited 数组。
  • 追加当前的方向字符,然后按「上、下、左、右」的固定顺序递归四个邻居,分别传入 'U''D''L''R'。顺序必须在所有岛之间保持一致,否则同一形状会产生不同签名。
  • 四个方向全部递归完后追加一个 'B'。这个回退标记是区分分叉结构的关键,缺了它不同形状会撞签名。
  • DFS 返回后把路径字符串加进集合。全部扫描结束,返回集合的大小。

grid = [[1,1,0,0,0],[1,1,0,0,0],[0,0,0,1,1],[0,0,0,1,1]] 走一遍:这是两个 2×2 的方块岛,一个在左上角,一个在右下角,形状显然相同,期望答案是 1

扫描到 (0, 0) 时启动第一次 DFS。当前格置 0,路径 "S"。向上越界返回;向下进入 (1, 0),置 0,路径 "SD"(1, 0) 向上回到 (0, 0),此时它已是 0,返回;向下是 (2, 0) 为水,返回;向左越界;向右进入 (1, 1),置 0,路径 "SDR"(1, 1) 向上进入 (0, 1),置 0,路径 "SDRU"(0, 1) 的四个方向要么越界要么是水,追加 'B'"SDRUB";回到 (1, 1),其余三个方向都无效,追加 'B'"SDRUBB";回到 (1, 0),追加 'B'"SDRUBBB";回到 (0, 0),它的左越界、右邻 (0, 1) 已变成 0,追加 'B'"SDRUBBBB"。集合里加入 "SDRUBBBB"

继续扫描,第一行剩下的格子和第二行的格子都已经是 0,一路跳过,直到 (2, 3) 又是 1,启动第二次 DFS。完全同构的过程走一遍:(2,3)0"S",下到 (3,3)"SD",右到 (3,4)"SDR",上到 (2,4)"SDRU",然后四次返回依次追加 'B',最终同样是 "SDRUBBBB"

这个字符串在集合里已经存在,插入无效果。扫描结束时集合只有一个元素,返回 1

代码实现

class Solution {
    public int numDistinctIslands(int[][] grid) {
        Set<String> shapes = new HashSet<>();
        for (int row = 0; row < grid.length; row++) {
            for (int col = 0; col < grid[0].length; col++) {
                if (grid[row][col] == 1) {
                    StringBuilder path = new StringBuilder();
                    dfs(grid, row, col, 'S', path);
                    shapes.add(path.toString());
                }
            }
        }
        return shapes.size();
    }

    private void dfs(int[][] grid, int row, int col, char dir, StringBuilder path) {
        if (row < 0 || row >= grid.length || col < 0 || col >= grid[0].length || grid[row][col] == 0) {
            return;
        }
        grid[row][col] = 0;
        path.append(dir);
        dfs(grid, row - 1, col, 'U', path);
        dfs(grid, row + 1, col, 'D', path);
        dfs(grid, row, col - 1, 'L', path);
        dfs(grid, row, col + 1, 'R', path);
        path.append('B');
    }
}
func numDistinctIslands(grid [][]int) int {
    shapes := make(map[string]bool)
    var dfs func(int, int, byte, *[]byte)
    dfs = func(row int, col int, dir byte, path *[]byte) {
        if row < 0 || row >= len(grid) || col < 0 || col >= len(grid[0]) || grid[row][col] == 0 {
            return
        }
        grid[row][col] = 0
        *path = append(*path, dir)
        dfs(row-1, col, 'U', path)
        dfs(row+1, col, 'D', path)
        dfs(row, col-1, 'L', path)
        dfs(row, col+1, 'R', path)
        *path = append(*path, 'B')
    }

    for row := 0; row < len(grid); row++ {
        for col := 0; col < len(grid[0]); col++ {
            if grid[row][col] == 1 {
                path := []byte{}
                dfs(row, col, 'S', &path)
                shapes[string(path)] = true
            }
        }
    }
    return len(shapes)
}

复杂度分析

  • 时间复杂度:$O(mn)$,mn 是网格的行数与列数。外层扫描访问每格一次,DFS 中每个陆地格进入后立刻被置 0,因此最多被展开一次;每格向四个方向各发起一次调用,总调用数是 $O(mn)$。签名字符串的总长度也是 $O(mn)$ 量级,哈希与比较的开销同阶。
  • 空间复杂度:$O(mn)$,最坏情况下整张网格是一个蛇形岛屿,递归栈深度和单条签名长度都能达到 $O(mn)$;形状集合里所有签名的总长度同样受这个上界约束。原网格被复用作访问标记,没有额外的 visited 数组。

关键点总结

  • 「判断两个结构是否等价」的问题,优先想能不能造一个规范形式(canonical form),把等价判定转成字符串相等。造出规范形式后,两两比对的 $O(k^2)$ 立刻塌缩成一次哈希插入。
  • 规范形式的两个必要条件是「起点唯一」和「遍历顺序固定」。这题的起点由行优先扫描天然给出,顺序则要靠自己约定并全程不变——两者缺一,同形状就会产生不同签名。
  • 括号化编码(进入写方向、返回写 'B')是把树结构无损压成字符串的通用技巧,序列化二叉树、比较子树是否相同等题目用的都是同一套。只记进入不记返回,等于丢掉了层级信息。
  • 复用输入网格当访问标记,前提是允许修改输入且不需要二次遍历。面试时值得主动说一句「这里我把 1 改成了 0,如果面试官要求不修改输入,我会加一个 visited 数组」,显示你知道这是个取舍而不是随手写的。
  • 面试视角:最高频的追问是「如果允许旋转和翻转还算同一种形状呢」(第 711 题)。答案是为每个岛生成 8 种变换(4 个旋转乘镜像)下的相对坐标集合,取字典序最小的那个当规范形式。能指出「本题只允许平移,所以方向序列就够用」,说明你抓住了约束与解法的对应关系。
  • 面试视角:另一个常见追问是「不用字符串还能怎么做指纹」。可以把每个格子相对起点的偏移 (dr, dc) 收集起来排序后拼成串,这种写法不依赖遍历顺序,也不需要回退标记,正确性更容易讲清楚,代价是多一次排序。

易错点总结

  • 错误写法:只把进入方向写进签名,省略递归返回时追加的 'B'。用例 两个岛屿的方向序列相同但分叉深度不同(比如同一条链上,一个岛在第二格向旁边伸出一格、另一个岛在第三格伸出一格)→ 两串字符完全一致,不同形状被合并成一种,答案偏小。
  • 错误写法:DFS 的四个方向顺序在不同岛屿之间不一致,例如按邻居坐标动态排序后再递归。用例 两个位置不同但形状完全相同的岛 → 遍历顺序不同导致签名不同,同一形状被算成两种,答案偏大。
  • 错误写法:DFS 里不把访问过的陆地置 0。用例 [[1, 1]](0,0) 向右递归到 (0,1)(0,1) 又向左递归回 (0,0),两格互相调用直到栈溢出。
  • 错误写法:把「当前格置 0」放在四个方向递归之后。用例 [[1, 1]] → 置 0 永远轮不到执行,效果和完全不标记一样,同样栈溢出。
  • 错误写法:用格子的绝对坐标集合当签名。用例 [[1,1,0,0,0],[1,1,0,0,0],[0,0,0,1,1],[0,0,0,1,1]] → 两个岛的坐标集合分别是 {(0,0),(0,1),(1,0),(1,1)}{(2,3),(2,4),(3,3),(3,4)},被判成两种形状,返回 2 而不是 1
  • 错误写法:所有岛屿共用同一个路径缓冲,不在每次外层调用前清空。用例 [[1,1,0,0,0],[1,1,0,0,0],[0,0,0,1,1],[0,0,0,1,1]] → 第二个岛的签名变成第一个岛的签名再接上自己的部分,与第一个岛不同,返回 2 而不是 1
  • 错误写法:越界检查在四个递归调用点分别手写,漏掉其中一侧。用例 [[1]] → 向上递归时 row - 1-1 没被拦住,访问 grid[-1][0] 直接越界崩溃。
  • 错误写法:把外层遇到 1 的次数直接当答案,忘了往集合里塞签名去重。用例 [[1,1,0,0,0],[1,1,0,0,0],[0,0,0,1,1],[0,0,0,1,1]] → 数出来的是岛屿总数 2,而题目问的是形状种类数 1

相似题目

题目 难度 考察点
200. 岛屿数量 中等 连通块计数的最简形态,不需要为岛屿构造任何指纹
695. 岛屿的最大面积 中等 DFS 带返回值向上累加格子数,练的是聚合而非结构描述
463. 岛屿的周长 简单 逐格统计暴露在外的边,完全不依赖连通性遍历
130. 被围绕的区域 中等 从边界反向出发染色,把「不满足条件」的部分先标出来
1020. 飞地的数量 中等 先消掉贴边的连通块,再统计剩余陆地格数
1254. 统计封闭岛屿的数目 中等 水陆角色互换,判定条件是整块岛是否完全不接触边界
面试题 16.19. 水域大小 中等 连通性定义为八连通,方向数组要从 4 个扩到 8 个
LCR 105. 岛屿的最大面积 中等 同 695 的另一入口,适合用来对照 BFS 与 DFS 两种遍历骨架