Logo

Number of Islands

LeetCode의 200번째 문제인 Number of Islands를 함께 풀어보도록 하겠습니다.

문제

물을 나타내는 문자 "0" 또는 땅을 나타내는 문자 "1"로 채워진 2차원 배열이 주어졌을 때, 섬의 개수를 반환하라. 여기서 섬은 물("0")로 둘러싸여 여러 개의 땅("1")으로 이뤄진 영역을 의미한다. 땅의 수평과 수직 방향으로 연결될 수 있다.

예제

Input: grid = [
  ["1","1","0","0","0"],
  ["1","1","0","0","0"],
  ["0","0","1","0","0"],
  ["0","0","0","1","1"]
]
Output: 3

설명: 좌측 상단에 4개의 "1"로 이뤄진 첫 번째 섬이 있고, 중간(세 번째 줄, 세 번째 열)에 1개의 "1"로 이뤄진 두 번째 섬이 있고, 우측 하단에 2개의 "1"로 이뤄진 섬이 하나가 있음.

풀이 1

우선 섬보다는 쉬운 땅의 개수를 먼저 세어보면 어떨까요?

주어진 2차원 배열을 루프를 돌면서 "1"을 만날 때 마다 섬의 개수를 1씩 증가시키면 될 텐데요. 코드로 짜보면 다음과 같은 형태일 것입니다.

class Solution:
    def numLands(self, grid: List[List[str]]) -> int:
        cnt = 0
        for r in range(len(grid)):
            for c in range(len(grid[r])):
                if grid[r][c] == "1":
                    cnt += 1
        return cnt

모든 섬들이 단 한 개의 땅으로 이루어져있다면 섬의 개수는 땅의 개수와 동일할텐데요. 하지만 문제에서 주어진 예제에서 보았듯이 섬은 2개 이상의 땅으로 이뤄질 수도 있습니다. 그러므로 대부분의 경우에는 섬의 개수는 땅의 개수보다 적을 것입니다.

이 쯤에서 스스로에게 물어볼 중요한 질문은 “루프를 돌다가 "1"을 만났을 때 어떻게 하면 그 땅과 연결되어 있는 모든 땅을 고려할 수 있을까?” 입니다.

아래 배열을 보면 4개의 "1"로 이뤄진 섬이 하나가 보입니다.

["0","0","0"]
["0","1","0"]
["1","1","0"]
["0","1","1"]

이 배열을 grid라고 하고 루프를 돈다면 grid[1][1]에서 이 섬의 일부인 "1"을 처음으로 만나게 됩니다. 이렇게 처음으로 발견된 섬의 일부를 #로 표시해보겠습니다.

["0","0","0"]
["0","#","0"]
["1","1","0"]
["0","1","1"]

여기서 우리는 현재 위치 기준으로 상하좌우에 땅이 없는지를 살펴봐야 합니다. 만약에 땅이 있다면 같은 섬의 일부로 봐야하기 때문입니다. 바로 아래인 grid[2][1]"1"이 하나 있으므로 이 것도 섬의 일부이므로 #로 표시하겠습니다.

["0","0","0"]
["0","#","0"]
["1","#","0"]
["0","1","1"]

grid[2][1]을 기준으로 상하좌우에 땅이 없는지를 살펴보니 이번에는 좌측인 grid[2][0]과 아래인 grid[3][1] 땅이 있습니다. 이 두 땅을 모두 섬의 일부이므로 #로 표시해보겠습니다.

["0","0","0"]
["0","#","0"]
["#","#","0"]
["0","#","1"]

grid[2][0] 주변에는 이미 #로 표시한 grid[2][1]을 제외하고는 더 이상 땅이 없으므로 grid[3][1]을 기준으로 또 상하좌우를 살펴봅시다. 바로 우측인 grid[3][2]에 땅이 하나가 있네요. 마찬가지로 #로 표시해보겠습니다.

["0","0","0"]
["0","#","0"]
["#","#","0"]
["0","#","#"]

grid[3][2] 주변에는 이미 #로 표시한 grid[3][1]을 제외하고는 더 이상 땅이 없습니다. 이상으로 이 섬을 이루고 모든 땅을 #로 표시하였습니다.

자, 이렇게 첫 번째 마추친 땅부터 인접하고 있는 땅을 연쇄적으로 확인해나가면 결국 섬 전체를 파악할 수 있습니다.

여기서 주의할 부분은 한 번 확인한 땅은 더 이상 확인할 필요가 없다는 것인데요. 더 엄밀히 얘기하면 중복해서 확인을 하게되면 재귀 탐색이 영원히 끝나지가 않을 것입니다. 어떻게 하면 효과적으로 땅을 한 번씩만 확인할 수 있을까요?

가장 간단한 방법은 위에서 했던 것처럼 배열에서 해당 위치를 "1"이 아닌 다른 값으로 갱신하는 것입니다. 예를 들어, "0"으로 갱신한다면 물로 인식이 되어 그 위치는 더 이상 땅으로 여겨지지 않을 것입니다. 한 번 방문한 땅은 꺼뜨려서 물로 잠기게 한다고 생각하시면 상상하시기가 더 쉬우실까요?

이 재귀 알고리즘을 코드로 한 번 구현해볼까요?

class Solution:
    def numIslands(self, grid: List[List[str]]) -> int:
        def sink(row, col):
            grid[row][col] = "0"

            for r, c in [
                (row, col - 1),
                (row, col + 1),
                (row - 1, col),
                (row + 1, col),
            ]:
                if 0 <= r < len(grid) and 0 <= c < len(grid[r]):
                    if grid[r][c] == "1":
                        sink(r, c)

        cnt = 0
        for r in range(len(grid)):
            for c in range(len(grid[r])):
                if grid[r][c] == "1":
                    cnt += 1
                    sink(r, c)
        return cnt

같은 알고리즘을 자바스크립트로도 구현해보겠습니다.

function numIslands(grid: string[][]): number {
  const sink = (row, col) => {
    grid[row][col] = "0";
    [
      [row, col - 1],
      [row, col + 1],
      [row - 1, col],
      [row + 1, col],
    ].forEach(([r, c]) => {
      if (0 <= r && r < grid.length && 0 <= c && c < grid[r].length)
        if (grid[r][c] === "1") sink(r, c);
    });
  };

  let cnt = 0;
  for (let r = 0; r < grid.length; r++) {
    for (let c = 0; c < grid[r].length; c++) {
      if (grid[r][c] === "1") {
        cnt++;
        sink(r, c);
      }
    }
  }
  return cnt;
}

sink()는 배열 내의 행과 열 인덱스를 인자로 받아서 그 위치에 인접하고 있는 땅을 재귀적으로 꺼뜨리기 위한 재귀 함수입니다. 배열을 루프 돌다가 땅이 발견되면 일단 개수를 하나 증가시키고, sink() 함수를 호출함으로써 그 땅을 시작으로 해서 수평과 수직 방형으로 연결된 모든 땅들을 꺼드리게 됩니다.

문제에서 주어진 예제를 기준으로 코드를 한 번 실행해보면 다음과 같이 배열과 섬의 개수가 변화하게 될 것입니다.

cnt: 0
["1","1","0","0","0"]
["1","1","0","0","0"]
["0","0","1","0","0"]
["0","0","0","1","1"]
cnt: 1
["0","0","0","0","0"]
["0","0","0","0","0"]
["0","0","1","0","0"]
["0","0","0","1","1"]
cnt: 2
["0","0","0","0","0"]
["0","0","0","0","0"]
["0","0","0","0","0"]
["0","0","0","1","1"]
cnt: 3
["0","0","0","0","0"]
["0","0","0","0","0"]
["0","0","0","0","0"]
["0","0","0","0","0"]

이 풀이의 복잡도는 2차원 배열 내의 원소를 수를 n이라고 했을 때, 시간과 공간 측면에서 모두 O(n)이 되는데요. 최악의 경우로 배열이 모두 "1"로 채워져 있다고 하더라도, O(2n)의 시간이 소모될 것이며, 재귀 호출 스택도 그에 비례해서 깊어질 것이기 때문입니다.

풀이 2

사실 이 문제에서 주어진 2차원 배열은 일종의 그래프라고도 볼 수 있습니다. 배열이 담고 있는 "1"을 그래프의 vertex로 보면, 인접한 "1"과의 관계를 edge로 볼 수가 있을테니까요.

이렇게 이 문제를 그래프의 관점으로 접근하면 위 코드는 DFS(깊이 우선 탐색)을 재귀로 구현한 것이라는 것을 깨닫게 됩니다. 보통 DFS는 스택(stack) 자료구조를 이용해서 반복 알고리즘으로도 구현이 가능하지요?

class Solution:
    def numIslands(self, grid: List[List[str]]) -> int:
        def sink(row, col):
            stack = [(row, col)]
            while stack:
                row, col = stack.pop()
                grid[row][col] = "0"

                for r, c in [
                    (row, col - 1),
                    (row, col + 1),
                    (row - 1, col),
                    (row + 1, col),
                ]:
                    if 0 <= r < len(grid) and 0 <= c < len(grid[r]):
                        if grid[r][c] == "1":
                            stack.append((r, c))

        cnt = 0
        for r in range(len(grid)):
            for c in range(len(grid[r])):
                if grid[r][c] == "1":
                    cnt += 1
                    sink(r, c)
        return cnt

이 반복 알고리즘은 이전의 재귀 알고리즘과 동일한 복잡도를 같게 됩니다. 배열 내의 "1"인 원소의 수가 많아질수록 스택의 크기도 그에 비례해서 커지게 될테니까요.

풀이 3

만약에 면접관이 입력 배열에 변경하는 것을 허락하지 않는다면 어떻게 해야 할까요?

이럴 때는 당황하지 마시고 어떤 자료구조를 사용해야 할지 한 번 생각해보세요. 집합(set) 자료구조에 확인된 땅을 저장해놓으면 그 땅을 중복 확인하는 것을 방지할 수 있겠죠?

확인된 땅을 "0"으로 변경하는 대신에 집합(set) 자료구조를 사용하도록 코드를 살짝 수정해보겠습니다.

class Solution:
    def numIslands(self, grid: List[List[str]]) -> int:
        def sink(row, col):
            visited.add((row, col))

            for r, c in [
                (row, col - 1),
                (row, col + 1),
                (row - 1, col),
                (row + 1, col),
            ]:
                if 0 <= r < len(grid) and 0 <= c < len(grid[r]):
                    if (r, c) not in visited and grid[r][c] == "1":
                        sink(r, c)

        cnt = 0
        visited = set()
        for r in range(len(grid)):
            for c in range(len(grid[r])):
                if (r, c) not in visited and grid[r][c] == "1":
                    cnt += 1
                    sink(r, c)
        return cnt

이 풀이는 얼핏보면 세트가 추가적인 메모리를 소모하므로 공간 복잡도가 저하될 거라고 생각할 수 있는데요. 세트의 크기는 배열의 원소의 수보다는 커질 수 없으므로 공간 복잡도는 O(2n)가 됩니다. 따라서 빅오 계산법 기준으로는 O(2n) = O(n)가 되어 유의미한 차이가 있다고는 보기 어렵겠습니다.

마치면서

이 문제가 너무 어려우셨다면 비슷하지만 좀 더 쉬운 문제인 Number of Provinces도 풀어보시라고 추천드립니다. 코딩 테스트에서 그래프를 어떻게 다루는지에 대해서 더 공부하고 싶으시다면 관련 게시물을 참고 바랄께요.