LeetCode 岛屿系列全解析 200. 463. 1905. 1254. 695. 827. 694. 711

359 阅读14分钟

LeetCode. 200 岛屿数量

给你一个由 '1'(陆地)和 '0'(水)组成的的二维网格,请你计算网格中岛屿的数量。

岛屿总是被水包围,并且每座岛屿只能由水平方向和/或竖直方向上相邻的陆地连接形成。

DFS

其实就是求连通块的个数。这道题可以通过DFS,BFS,并查集,三种方法来做。

先说比较简单的DFS:

两层循环,对每个为1的位置,进行DFS,把跟这个点相连的所有点都访问一遍,并且访问后将该位置置为0,以便后续不会重复访问。

class Solution {
    // 4个方向
    int[] dx = {1, -1, 0, 0};
    int[] dy = {0, 0, 1, -1};
    public int numIslands(char[][] grid) {
        int n = grid.length, m = grid[0].length;
        int sum = 0;
        for (int i = 0; i < n; i++) {
            for (int j = 0; j < m; j++) {
                if (grid[i][j] == '1') {
                    dfs(grid, i, j);
                    sum++;
                }
            }
        }
        return sum;
    }

    private void dfs(char[][] grid, int i, int j) {
        int n = grid.length, m = grid[0].length;
        if (i < 0 || i >= n || j < 0 || j >= m || grid[i][j] == '0') return;
        grid[i][j] = '0';
        for (int k = 0; k < 4; k++) dfs(grid, i + dx[k], j + dy[k]);
    }
}

BFS

上面DFS只是针对某一个点,把这个点所在的整个岛屿给扩展开来了。那么用BFS也能达到同样的效果。

但是BFS有个要注意的点,准备把一个点加入队列时,要先把这个点置为0,再加入队列。

我最开始的写法是:从队列中取出一个点后,再把这个点置为0,提交发现会报超时。这是因为,如果从队列中取出一个点后才把它置为0,则这个点可能由于是其他点的邻接点,而被重复的加入了队列。

class Solution {
    int[] dx = {1, -1, 0, 0};
    int[] dy = {0, 0, 1, -1};
    public int numIslands(char[][] grid) {
        int n = grid.length, m = grid[0].length;
        int sum = 0;
        for (int i = 0; i < n; i++) {
            for (int j = 0; j < m; j++) {
                if (grid[i][j] == '1') {
                    bfs(grid, i, j);
                    sum++;
                }
            }
        }
        return sum;
    }

    private void bfs(char[][] grid, int i, int j) {
        int n = grid.length, m = grid[0].length;
        Queue<Integer> q = new LinkedList<>();
        q.offer(i * m + j);
        grid[i][j] = '0'; // 加入队列的同时, 置为0
        while (!q.isEmpty()) {
            int pos = q.poll();
            int x = pos / m;
            int y = pos % m;
            // grid[x][y] = '0'; // 一开始我是在一个点从队列取出后, 才把它置为0, 这种方式会导致相同的点被重复的加入队列
            for (int k = 0; k < 4; k++) {
                int nx = x + dx[k];
                int ny = y + dy[k];
                if (nx < 0 || nx >= n || ny < 0 || ny >= m || grid[nx][ny] == '0') continue;
                q.offer(nx * m + ny);
                grid[nx][ny] = '0'; // 加入队列的同时置为0
            }
        }
    }
}

并查集

由于遍历的顺序是从左到右,从上到下,所以每次合并时,只需要合并当前节点和其右侧,下侧的节点即可。

class Solution {
    int[] p;
    int num;
    public int numIslands(char[][] grid) {
        // 并查集解法
        int n = grid.length, m = grid[0].length;
        // init 
        p = new int[n * m];
        for (int i = 0; i < n; i++) {
            for (int j = 0; j < m; j++) {
                if (grid[i][j] == '1') {
                    int idx = i * m + j;
                    p[idx] = idx;
                    num++;
                }
            }
        }

        for (int i = 0; i < n; i++) {
            for (int j = 0; j < m; j++) {
                if (grid[i][j] == '1') {
                    if (i + 1 < n && grid[i + 1][j] == '1') union(i * m + j, (i + 1) * m + j);
                    if (j + 1 < m && grid[i][j + 1] == '1') union(i * m + j, i * m + j + 1);   
                }
            }
        }
        return num;
    }

    void union(int x, int y) {
        if (find(x) != find(y)) {
            p[find(x)] = find(y);
            num--; // 合并, 集合减1
        }
    }

    int find(int x) {
        if (p[x] != x) p[x] = find(p[x]);
        return p[x];
    }
}

LeetCode 岛屿系列问题 463.200.695.827

LeetCode 463. 岛屿周长

这道题是求岛屿周长,标注是Easy,但我觉得可能并不Easy。

同样,我们可以用DFS或BFS来遍历岛屿上的每一块陆地。按照常规的思维,直接计算岛屿周长,可能无从下手。我们可以试着将问题拆分,把大的问题拆分成小的问题。由于一个岛屿是由很多块陆地构成,我们可以考虑对每块陆地进行某种计算,然后组合起来得到整个岛屿的周长。

观察发现,每块陆地都会对整个岛屿的周长有所贡献,每块陆地可能在上下左右4个方向对岛屿周长做出贡献。当一块陆地有x个方向上邻接的都是陆地,那么这块陆地对整个岛屿周长的贡献就是4-x。换个说法,对于一块陆地,其对整个岛屿周长的贡献,是其上下左右4个方向上邻接的是水域边界的数量。

如此以来,思路就比较清晰了。我们通过DFS搜索这个岛屿的全部陆地,每访问一块陆地,就看一下它有几条边是和水域边界相连的,累加每块陆地的贡献,最终得到整个岛屿的周长。

特别的,由于这道题目只有1个岛屿,我们可以不用DFS,而只是简单地遍历每一个小格子,对所有为陆地的格子,计算一下其对周长的贡献即可。

class Solution {
    int[] dx = {1, -1, 0, 0};
    int[] dy = {0, 0, 1, -1};
    public int islandPerimeter(int[][] grid) {
        int n = grid.length, m = grid[0].length;
        int ans = 0;
        for (int i = 0; i < n; i++) {
            for (int j = 0; j < m; j++) {
                if (grid[i][j] == 1) {
                    for (int k = 0; k < 4; k++) {
                        int ni = i + dx[k];
                        int nj = j + dy[k];
                        // 相邻的是边界, 或水域, 贡献+1
                        if (ni < 0 || ni >= n || nj < 0 || nj >= m || grid[ni][nj] == 0) ans++;
                    }
                }
            }
        }
        return ans;
    }
}

额外贴一下DFS的版本,需要注意,DFS过程中,为了避免重复访问某块陆地,我们可能会将已访问过的陆地设为0(变成水域),这会对计算其他陆地对周长的贡献有所影响(因为水域变多了)。可以考虑加一个visited数组来记录已访问过的陆地。或者将已访问过的陆地置为2。这样,用0表示水域,1表示还未访问过的陆地,2表示已经访问过的陆地。

class Solution {
    
    int[] dx = {1, -1, 0, 0};
    int[] dy = {0, 0, 1, -1};
    int ans = 0;

    public int islandPerimeter(int[][] grid) {
        int n = grid.length, m = grid[0].length;
        out:
        for (int i = 0; i < n; i++) {
            for (int j = 0; j < m; j++) {
                if (grid[i][j] == 1) {
                    dfs(grid, i, j);
                    break out; // 因为只有1个岛屿, 提前退出
                }
            }
        }
        return ans;
    }

    private void dfs(int[][] grid, int i, int j) {
        int n = grid.length, m = grid[0].length;
        grid[i][j] = 2; // visited
        for (int k = 0; k < 4; k++) {
            int ni = i + dx[k];
            int nj = j + dy[k];
            if (ni < 0 || ni >= n || nj < 0 || nj >= m || grid[ni][nj] == 0) {
                ans++;
                continue;
            }
            if (grid[ni][nj] == 2) continue;
            dfs(grid, ni, nj);
        }
    }
}

当然,这道题并查集也能做,但是没必要,这里就不赘述了。

LeetCode 1905. 统计子岛屿数量

给两个长宽都相等的矩阵grid1和grid2,如果 grid2 的一个岛屿,被 grid1 的一个岛屿 完全 包含,也就是说 grid2 中该岛屿的每一个格子都被 grid1 中同一个岛屿完全包含,那么我们称 grid2 中的这个岛屿为 子岛屿 。 请你返回 grid2 中 子岛屿 的 数目 。

其实就是看grid2中的岛屿。是否被grid1中的完全覆盖。

初步想法是:对grid2进行dfs,找出所有岛屿,并且在dfs遍历的过程中,检查grid1中对应坐标是否也是陆地,即可。

class Solution {
    int[] dx = {1, -1, 0, 0};
    int[] dy = {0, 0, 1, -1};
    public int countSubIslands(int[][] grid1, int[][] grid2) {
        int n = grid1.length, m = grid1[0].length;
        int num = 0;
        for (int i = 0; i < n; i++) {
            for (int j = 0; j < m; j++) {
                if (grid2[i][j] == 1) {
                    if (dfs(grid1, grid2, i, j)) num++;
                }
            }
        }
        return num;
    }

    // 不用对grid1进行dfs
    // grid2需要深搜完该岛的全部节点
    private boolean dfs(int[][] grid1, int [][] grid2, int i, int j) {
        int n = grid1.length, m = grid1[0].length;
        boolean res = grid1[i][j] == 0 ? false : true; // 这个点在grid1中是否存在
        grid2[i][j] = 2; // grid2 中标记这个点为已访问过
        for (int k = 0; k < 4; k++) {
            int ni = i + dx[k];
            int nj = j + dy[k];
            if (ni < 0 || ni >= n || nj < 0 || nj >= m || grid2[ni][nj] != 1) continue;
            res = dfs(grid1, grid2, ni, nj) && res;
        }
        return res;
    }
}

有个地方需要注意,就是这一行

res = dfs(grid1, grid2, ni, nj) && res;

我一开始写成了

res = res && dfs(grid1, grid2, ni, nj);

这样是错误的。因为无论当前这个位置在grid1中是否为陆地,我们都需要将grid2中的这个岛屿搜索完毕(如果不搜索完毕的话,剩余部分在后续的循环中会被当做一个新的岛屿进行搜索)。而当grid1中这个位置不是陆地的话,res会是false,而由于&& 运算符的短路原则,把res写在前面,会导致后面的dfs操作无法被执行。

这里要特别注意,我debug了半天才发现。

另一种思路:图覆盖。如果grid2中的某个岛,是grid1的子岛,那么这个岛的每块陆地,都会在grid2中出现一次,在grid1中出现一次。

那么,我们遍历grid2,对于grid2中的每一块陆地,找到grid1中对应位置的格子,累加到grid2上。这样,如果grid2的某个岛是子岛,那么这个岛的每块陆地都应该是2。累加完成后,我们只需要对grid2做一次dfs,当一个岛的全部陆地都是2,说明这个岛是一个子岛。

这里可以扩展想一下:不是把grid1全部加到grid2上。全部加过来的话,无法利用岛上的陆地全是2,这个条件来判断子岛。只是对grid2中为1的位置,从grid1中加过来。

也不是把grid2加到grid1,道理同上,无法判断子岛。

class Solution {
    int[] dx = {1, -1, 0, 0};
    int[] dy = {0, 0, 1, -1};
    public int countSubIslands(int[][] grid1, int[][] grid2) {
        int n = grid1.length, m = grid1[0].length;
        for (int i = 0; i < n; i++) {
            for (int j = 0; j < m; j++) {
                if (grid2[i][j] == 1) grid2[i][j] += grid1[i][j];
            }
        }

        int num = 0;
        for (int i = 0; i < n; i++) {
            for (int j = 0; j < m; j++) {
                if (grid2[i][j] == 2) {
                    if (dfs(grid2, i, j)) num++;
                }
            }
        }
        return num;
    }

    // 如果岛屿中全是2, 则返回true
    private boolean dfs(int[][] grid, int i, int j) {
        int n = grid.length, m = grid[0].length;
        boolean res = grid[i][j] == 2 ? true : false;
        grid[i][j] = 3; // visited
        for (int k = 0; k < 4; k++) {
            int ni = i + dx[k];
            int nj = j + dy[k];
            if (ni < 0 || ni >= n || nj < 0 || nj >= m) continue;
            if (grid[ni][nj] == 0 || grid[ni][nj] == 3) continue; // 水域或者已访问过
            res = dfs(grid, ni, nj) && res;
        }
        return res;
    }
}

LeetCode 1254. 封闭岛屿数量

封闭岛屿的含义是,岛屿四周全是水。

解法同样是DFS,遍历过程中看是否碰到边界即可。岛屿题已经做太多了。这里直接贴代码了。

class Solution {
    int[] dx = {1, -1, 0, 0};
    int[] dy = {0, 0, 1, -1};
    public int closedIsland(int[][] grid) {
        int n = grid.length, m = grid[0].length;
        int ans = 0;
        for (int i = 0; i < n; i++) {
            for (int j = 0; j < m; j++) {
                if (grid[i][j] == 0 && dfs(grid, i, j)) ans++; 
            }
        }
        return ans;
    }

    private boolean dfs(int[][] grid, int i, int j) {
        int n = grid.length, m = grid[0].length;
        grid[i][j] = 1; // 置为水域
        boolean res = true; // 是否全被水域包裹
        for (int k = 0; k < 4; k++) {
            int ni = i + dx[k];
            int nj = j + dy[k];
            if (ni < 0 || ni >= n || nj < 0 || nj >= m) {
                res = false; // 触到边界, 没有被水包裹
                continue;
            }
            if (grid[ni][nj] == 1) continue; // 周围是水
            res = dfs(grid, ni, nj) && res;
        }
        return res;
    }
}

LeetCode 695. 最大岛屿

这道题求解的是最大的岛屿的面积。同样的,我们还是可以用DFS/BFS/并查集,因为无论对岛屿做什么计算,都需要遍历岛屿的每块陆地,或者将相邻的陆地进行合并。下面考虑用DFS来做,将访问过的陆地,置为2。

class Solution {
    int[] dx = {1, -1, 0, 0};
    int[] dy = {0, 0, 1, -1};
    int ans = 0;
    public int maxAreaOfIsland(int[][] grid) {
        int n = grid.length, m = grid[0].length;
        for (int i = 0; i < n; i++) {
            for (int j = 0; j < m; j++) {
                if (grid[i][j] == 1) {
                    // 计算这块岛屿的面积, 并更新最大值
                    ans = Math.max(ans, dfs(grid, i, j));
                }
            }
        }
        return ans;
    }

    private int dfs(int[][] grid, int i, int j) {
        int n = grid.length, m = grid[0].length;
        int res = 1; // 当前这块陆地的面积
        grid[i][j] = 2; // visited
        for (int k = 0; k < 4; k++) {
            int ni = i + dx[k];
            int nj = j + dy[k];
            if (ni < 0 || ni >= n || nj < 0 || nj >= m) continue;
            if (grid[ni][nj] == 0 || grid[ni][nj] == 2) continue;
            res += dfs(grid, ni, nj);
        }
        return res;
    }
}

LeetCode 827. 最大人工岛

最多只能把一格0变成1,求问执行此操作后,最大的岛屿面积是多少。

就是填海!将一块水域变成陆地,问能造出的人工岛的最大面积是多少。

最直观的想法当然是,遍历所有的水域,依次看如果将当前水域变成陆地,得到的岛的面积,然后取一个最值。

然而还要考虑一种情况,就是最大的岛是一个自然岛(无需将水域变成陆地),而不是一个人工岛。此种情况只会出现在,所有的格子都是陆地的情况下,此时没有海可以填。(但凡存在水域,就一定能通过将一块水域变成陆地,使得某个最大岛的面积变大)。

那么对于一块水域,如何计算将其变成陆地后,能够形成的岛屿面积呢?将一块水域变成陆地,则其最多能连接上下左右4个方向的岛屿。我们只需要将其4个方向中的陆地所在的岛屿,进行连接即可。

我对这道题的直观感觉是并查集,因为可以通过并查集进行集合合并,并维护连通块的大小(岛屿面积)。

需要注意,若一块水域的2个方向上都是陆地,这2块陆地有可能属于同一个岛,此时不能重复计算其面积。(即,需要去重)

下面贴一个纯并查集的实现

class Solution {
    int[] dx = {1, -1, 0, 0};
    int[] dy = {0, 0, 1, -1};
    int[] p;
    int[] cnt; // 额外维护某个连通块的面积
    int ans = 1;

    public int largestIsland(int[][] grid) {
        int n = grid.length, m = grid[0].length;
        p = new int[n * m];
        cnt = new int[n * m];

        for (int i = 0; i < n; i++) {
            for (int j = 0; j < m; j++) {
                if (grid[i][j] == 1) {
                    int idx = i * m + j;
                    p[idx] = idx;
                    cnt[idx] = 1;
                }
            }
        }

        for (int i = 0; i < n; i++) {
            for (int j = 0; j < m; j++) {
                if (grid[i][j] == 1) {
                    if (i + 1 < n && grid[i + 1][j] == 1) union(i * m + j, (i + 1) * m + j);
                    if (j + 1 < m && grid[i][j + 1] == 1) union(i * m + j, i * m + j + 1);
                }
            }
        }

        for (int i = 0; i < n; i++) {
            for (int j = 0; j < m; j++) {
                if (grid[i][j] == 0) {
                    int temp = 1; // 当前这个位置变成1, 则面积至少为1
                    Set<Integer> set = new HashSet<>(); // 用于去重
                    for (int k = 0; k < 4; k++) {
                        int ni = i + dx[k];
                        int nj = j + dy[k];
                        if (ni < 0 || ni >= n || nj < 0 || nj >= m || grid[ni][nj] == 0) continue;
                        int idx = ni * m + nj;
                        if (set.contains(find(idx))) continue; // 该岛已经计算过, 不重复计算
                        temp += cnt[find(idx)];
                        set.add(find(idx));
                    }
                    ans = Math.max(ans, temp);
                }
            }
        }
        return ans;
    }

    private void union(int x, int y) {
        int px = find(x), py = find(y);
        if (px != py) {
            p[px] = py;
            cnt[py] += cnt[px];
            ans = Math.max(cnt[py], ans); // 在合并过程中, 让ans等于最大的岛的面积
        }
    }

    private int find(int x) {
        if (x != p[x]) p[x] = find(p[x]);
        return p[x];
    }
}

这道题也能用DFS来做,我们可以用DFS来遍历一个岛,并给这个岛进行编号(以便唯一标识一个岛)。并且,我们对岛上所有的陆地块,将其编号置为岛的编号。这样就能方便地进行去重,以及获取岛的面积。

class Solution {
    int[] dx = {1, -1, 0, 0};
    int[] dy = {0, 0, 1, -1};
    int[] area; // 岛屿面积, 下标是岛屿编号
    int idx = 2; // 岛屿编号, 从2开始, 因为0是水域, 1是陆地
    int ans = 0;
    public int largestIsland(int[][] grid) {
        int n = grid.length, m = grid[0].length;
        area = new int[n * m + 2]; // 对于[1] 这样的输入, 由于岛的编号从2开始, 需要多开2个大小
        for (int i = 0; i < n; i++) {
            for (int j = 0; j < m; j++) {
                if (grid[i][j] == 1) {
                    // 计算这个岛的面积
                    area[idx] = dfs(grid, i, j);
                    // 最大岛屿面积
                    ans = Math.max(ans, area[idx]);
                    // 岛的编号+1
                    idx++;
                }
            }
        }

        // 对所有的水域, 计算可能形成的最大人工岛面积
        Set<Integer> set = new HashSet<>(); // 用于去重, 声明在外面, 避免频繁创建对象
        for (int i = 0; i < n; i++) {
            for (int j = 0; j < m; j++) {
                if (grid[i][j] == 0) {
                    set.clear();
                    int t = 1;
                    for (int k = 0; k < 4; k++) {
                        int ni = i + dx[k];
                        int nj = j + dy[k];
                        if (ni < 0 || ni >= n || nj < 0 || nj >= m || grid[ni][nj] == 0) continue;
                        if (set.contains(grid[ni][nj])) continue; // 用set做去重
                        t += area[grid[ni][nj]];
                        set.add(grid[ni][nj]); // 已经出现过, 记得添加到set
                    }
                    ans = Math.max(ans, t);
                }
            }
        }

        return ans;
    }

    private int dfs(int[][] grid, int i, int j) {
        int n = grid.length, m = grid[0].length;
        grid[i][j] = idx; // 将这块陆地编号变为岛屿编号
        int res = 1; // 面积
        for (int k = 0; k < 4; k++) {
            int ni = i + dx[k];
            int nj = j + dy[k];
            if (ni < 0 || ni >= n || nj < 0 || nj >= m) continue; // 边界
            if (grid[ni][nj] == 0 || grid[ni][nj] > 1) continue; // 水域或者其他
            res += dfs(grid, ni, nj);
        }
        return res;
    }
}

注意2个点:

  • 可能所有岛中,面积最大者,是答案,而不是把某个0变成1后形成的人工岛(这种情况只出现在全部格子都是陆地的时候)
  • 对某个水域,在其上下左右四个方向做连接时,记得去重(可能2个方向上的陆地属于同一个岛,不能重复计算)

小结

岛屿类问题都可以用DFS来解决,需要注意:

  • DFS过程中要标记已访问过的点,防止重复访问(标记方式有很多种,比如访问过的格子置0,置2,开一个visited 数组等)
  • DFS的过程可以计算出岛屿的一些属性(周长,面积等)
  • 可以对岛屿进行编号,以便做唯一标识

当然也可以用并查集,因为将相邻的陆地进行连接,形成岛屿的过程,也可以看作是集合合并。

待办

其他待办的follow up:形状相同的岛有多少个?形状不同的岛有多少个?(猜测对应305. 694. 711.这三道题,但是没钱开会员,就先这样吧 TODO)

LeetCode 694. 不同岛屿数量

在网上搜到了这道题的描述:若两个岛屿经过平移后,能够完全重合,则称这两个岛屿形状相同。

有一个思路很巧妙:如何判断2个岛屿具有相同形状?根据遍历这个岛屿每块陆地的顺序

只要两个岛屿,其遍历的顺序是一致的,说明其形状相同。太妙了!下面给出代码,本地调试了一些test cast,结果正确。但没有会员,无法提交到LeetCode验证是否正确。

class Solution {
    int[] dx = {1, -1, 0, 0};
    int[] dy = {0, 0, 1, -1};
    public int numIslands(int[][] grid) {
        int n = grid.length, m = grid[0].length;
        Set<String> set = new HashSet<>();
        for (int i = 0; i < n; i++) {
            for (int j = 0; j < m; j++) {
                if (grid[i][j] == 1) {
                    // 记录这个岛的形状 (遍历顺序)
                    StringBuilder sb = new StringBuilder();
                    dfs(grid, i, j, sb);
                    set.add(sb.toString());
                }
            }
        }
        return set.size();
    }

    private void dfs(int[][] grid, int i, int j, StringBuilder sb) {
        int n = grid.length, m = grid[0].length;
        grid[i][j] = 0;
        for (int k = 0; k < 4; k++) {
            int ni = i + dx[k];
            int nj = j + dy[k];
            if (ni < 0 || ni >= n || nj < 0 || nj >= m || grid[ni][nj] == 0) continue;
            //10 表示向下移动
            //-10 表示向上移动
            //01 表示向右
            //0-1 表示向左
            sb.append(dx[k]).append(dy[k]);
            dfs(grid, ni, nj, sb);
        }
    }
}

LeetCode 711. 不同岛屿数量II

694的升级版,694对形状相同的岛屿的定义是:经过平移后能够完全重合。

711对形状相同的岛屿的定义是:经过平移,旋转,或镜像翻转后,能够完全重合。

比如下图,左上角的岛屿和右下角的岛屿,在694的定义中就是2个不同的岛屿,但在711的定义中就是相同的岛屿。

现在回过头来思考一下,694中,我们是通过对这个小岛的遍历顺序,来判断形状是否一致的。而711可以对小岛进行旋转,翻转等各种变换。遍历顺序就不管用了。 换一种思路,先针对694,我们除了小岛的遍历顺序,还有其他方式来描述一个小岛的形状吗?有的。

假设对于一个小岛,我们用一个数组shape保存其所有陆地块的坐标(绝对坐标)。然后对每个陆地块的坐标,减去最左上方陆地块的坐标。这样就变成了相对坐标。若2个小岛形状相同,则其shape数组中存储的相对坐标就是相同的。我们可以直接将shape数组按顺序展开,比较如果相同,说明形状相同。

再来看711,这道题对相同形状的定义就比较蛋疼了。可以进行旋转,镜像翻转等操作。我们列举一下。对于一个点[x,y],能进行的变换无非就8种。第一个位置可以是正负x,y,共4种,对应的第二个位置就有2种选择(只有正负,由于第一个位置字母确定了,第二个位置的字母也就确定了)。共8种(其实题目允许的变换就5种,旋转90°,旋转180°,旋转270°,水平翻转,垂直翻转)。而由于我们是用左上角第一个点作为基准,来计算的相对坐标,所以负数什么的不会有影响。只要形状相同,则相对坐标就是相同的。

那么我们对某一个岛,只需要把shape数组中的每一个坐标,都进行一下翻转,然后把翻转后的shape,重新与左上角坐标进行对齐。这样就得到了一个转换后的形状,由于存储的是相对坐标,那么只要形状相同,shape数组中存储的所有坐标就是相同的。

这样,对于一个岛,我们存储其变换后可能达到的形状(存储8个shape数组,每个shape数组中存的是一系列点的坐标(相对坐标))。用这全部的形状,来标识这个岛的形状。 我们可以将所有形状中的点,进行扁平化,也就是将List<List<Pair>>扁平化为List<Pair>,然后对所有点进行一个排序, 再将得到的List转换成一个字符串(序列化)。这个字符串就能够唯一标识某一个形状。

代码如下,列举了几个 test case,结果是正确的。然而没充钱,没法提交到LeetCode去看实际的结果。等充钱了再来更新吧。

class Solution {
    int[] dx = {1, -1, 0, 0};
    int[] dy = {0, 0, 1, -1};
    public int numIslands(int[][] grid) {
        int n = grid.length, m = grid[0].length;
        Set<String> set = new HashSet<>(); // 存所有形状, 每一种形状用一个字符串表示
        for (int i = 0; i < n; i++) {
            for (int j = 0; j < m; j++) {
                if (grid[i][j] == 1) {
                    List<Pair> shape = new ArrayList<>(); // 这个岛屿的全部陆地的坐标
                    dfs(grid, i, j, shape);
                    String shapeStr = normalize(shape); // 对这个岛屿的形状进行归一化
                    set.add(shapeStr);
                }
            }
        }
        return set.size();
    }

    private void dfs(int[][] grid, int i, int j, List<Pair> shape) {
        int n = grid.length, m = grid[0].length;
        shape.add(new Pair(i, j));
        grid[i][j] = 2; // visited
        for (int k = 0; k < 4; k++) {
            int ni = i + dx[k];
            int nj = j + dy[k];
            if (ni < 0 || ni >= n || nj < 0 || nj >= m || grid[ni][nj] != 1) continue;
            dfs(grid, ni, nj, shape);
        }
    }

    // 归一化
    private String normalize(List<Pair> shape) {
        // 将8种转换全部加入, 然后进行序列化
        List<Pair>[] allShapes = new List[8];
        allShapes[0] = shape;
        for (int i = 1; i < 8; i++) allShapes[i] = new ArrayList<>();
        for (Pair p : shape) {
            int x = p.i, y = p.j;
            allShapes[1].add(new Pair(-x, y));
            allShapes[2].add(new Pair(-x, -y));
            allShapes[3].add(new Pair(x, -y));
            allShapes[4].add(new Pair(y, x));
            allShapes[5].add(new Pair(y, -x));
            allShapes[6].add(new Pair(-y, x));
            allShapes[7].add(new Pair(-y, -x));
        }
        // 对每个shape进行排序, 确保每个shape中, 左上角的坐标排在第一个位置
        for (List<Pair> s : allShapes) Collections.sort(s);
        // 对每个shape, 以左上角为基准, 进行坐标修正, 计算相对坐标
        for (List<Pair> s : allShapes) {
            // 这里可以直接采用倒序遍历, 确保第一个位置的坐标最后才被更新
            Pair beginPos = s.get(0);
            int beginI = beginPos.i, beginJ = beginPos.j;
            for (Pair p : s) {
                p.i = p.i - beginI;
                p.j = p.j - beginJ;
            }
        }

        // 将每个shape的点全部拿出来, 放在一起, 进行扁平化
        List<Pair> allPair = new ArrayList<>();
        for (List<Pair> s : allShapes) {
            for (Pair p : s) allPair.add(p);
        }

        // 对扁平化后的点, 进行排序
        Collections.sort(allPair);
        StringBuilder sb = new StringBuilder();
        // 序列化成字符串
        for (Pair p : allPair) sb.append(p.toString());
        return sb.toString();
    }

    class Pair implements Comparable<Pair> {

        private int i;

        private int j;

        public Pair(int i, int j) {
            this.i = i;
            this.j = j;
        }

        @Override
        public int compareTo(Pair o) {
            // i越小, 越靠前
            // j越大, 越靠前
            // 结果就是左上角地点是第一个点 (最小的点)
            return this.i != o.i ? this.i - o.i : o.j - this.j;
        }

        @Override
        public String toString() {
            // 序列化一个坐标
            return "[" + i + "," + j + "]";
        }
    }
}

注意,必须采用相对坐标,以岛屿左上角第一个点为[0,0],这样才能保证相同形状的岛,其坐标从数值上来看是一致的。 在进行归一化时,对每个形状的坐标数组进行排序,是为了确保数组第一个元素是左上角的坐标,以方便计算相对坐标。随后会将全部形状的坐标进行扁平化,放在一个数组中,最后还需要对这个扁平化后的数组进行排序。