2313 words
12 minutes
[LeetCode] Find All Possible Recipes from Given Supplies

문제 소개#

문제 링크 (2025년 3월 21일 Daily Problem)

https://leetcode.com/problems/find-all-possible-recipes-from-given-supplies/description/?envType=daily-question&envId=2025-03-21

문제 설명 및 요구사항#

given

  • String 형태의 배열 recipes 가 주어질 때, 이에 대응하는 2D String 배열 ingredients 가 주어진다.
    • 따라서 recipes[i]ingredientingreditens[i] 를 의미한다.
    • 어떤 recipes[i] 를 만들기 위해서는 ingredients[i] 에 있는 모든 원소를 가지고 있어야 한다.
    • ingredients 에 또 다른 recipe 가 포함될 수도 있다.
  • 처음으로 가지고 있는 ingredients 는 supplies 배열로 표현된다.

todo

  • 각 정보를 통해 만들 수 있는 recipes 는 어떤게 있는가?

입/출력 예시 분석#

예제 1번
  • Input: recipes = ["bread"], ingredients = [["yeast","flour"]], supplies = ["yeast","flour","corn"]
  • Output: ["bread"]

처음 가지고 있는 supplies 를 이용해서 bread 를 만들 수 있다.

예제 2번
  • Input: recipes = ["bread","sandwich"], ingredients = [["yeast","flour"],["bread","meat"]], supplies = ["yeast","flour","meat"]
  • Output: ["bread","sandwich"]

먼저, 처음 가지고 있는 supplies 를 이용해서 recipes[0]bread 를 만들 수 있다. 이 후에는 recipes[0]supplies 를 이용해서 recipes[1]sandwich 를 만들 수 있다.

예제 3번
  • Input: recipes = ["bread","sandwich","burger"], ingredients = [["yeast","flour"],["bread","meat"],["sandwich","meat","bread"]], supplies = ["yeast","flour","meat"]
  • Output: ["bread","sandwich","burger"]

먼저, 처음 가지고 있는 supplies 를 이용해서 bread (recipes[0]) 를 만들 수 있다. 이 후, bread (recipes[0])supplies 를 이용해서 sandwich (recipes[1]) 를 만들 수 있다. 이 후, bread (recipes[0])sandwich (recipes[1]), supplies 를 이용해서 burger (recipes[2]) 을 만들 수 있다.

문제의 제약 조건#

  • n == recipes.length == ingredients.length
  • 1 <= n <= 100
  • 1 <= ingredients[i].length, supplies.length <= 100
  • 1 <= recipes[i].length, ingredients[i][j].length, supplies[k].length <= 10
  • recipes[i], ingredients[i][j], and supplies[k] consist only of lowercase English letters.
  • All the values of recipes and supplies combined are unique.
  • Each ingredients[i] does not contain any duplicate values.

제약조건 자체는 매우 널널하므로 어떻게든 탐색하면 풀리긴한다! 나의 첫 접근 역시, 더 이상 만들 수 있는 레시피가 존재하지 않을 때 까지 while 문을 돌리는 방식으로 문제를 풀었었다. 하지만 Editorial 에 조금 더 효율적으로 푸는 방법이 있어 이를 기록하려고한다.

문제 이해하기#

각 레시피를 만들 수 있는 조건은 다음과 같다.

  • 현재 가지고 있는 supplies 를 통해 레시피를 만드는 경우
  • 현재 가지고 있는 supplies 를 통해, 해당 레시피를 만들기 위한 하위 레시피를 만들 수 있는 경우

따라서 만약 brute force 를 이용해서 문제를 푼다면, 다음과 같은 형식으로 탐색하게 될 것이다.

  1. 초기 supplies 를 통해 만들 수 있는 레시피들을 찾는다.
  2. 1.에서 만들어진 레시피와 supplies 를 통해 만들어지는 다른 레시피들을 찾는다.
  3. 더 이상 새로운 레시피가 만들어지지 않을 때까지 반복한다.

앞서 언급했듯이, 제약 사항이 널널하기 때문에 brute force 로도 문제가 풀릴 수 있다. 하지만 Editorial 에 소개된 위상 정렬을 통해 문제를 푸는 방식 에 대해서 생각해보자.

현재 brute force 방식의 문제점은 더 이상 새로운 레시피가 만들어지지 않을 때 까지 레시피 배열을 돌면서 반복하는 것이다. 따라서 우리의 목표는 위상 정렬을 통해, 효율적인 순서로 탐색하는 것이다.

각 레시피가 만들어지는 조건을 생각해보면 다음과 같이 생각할 수 있다.

한 레시피가 다른 레시피에 종속될 수 있다.

먼저 다른 레시피가 만들어지고 나서야 그 레시피를 이용해서 만들어질 수 있는 레시피가 존재한다.

따라서 레시피 간의 종속 관계를 방향 그래프(directed graph)로 표현함으로써 복잡한 의존성을 명확하게 처리할 수 있다. 레시피를 노드(vertex)로, 의존 관계를 간선(edge)으로 모델링하면 문제는 자연스럽게 위상 정렬 문제로 변환된다.

예를 들어, 예시 3번을 생각해본다면

어떤 recipe 를 만들기 위해서, 다른 recipe 와 종속 관계를 가진다는 것을 나타낼 수 있다. 그렇다면 위상 정렬을 위해서 그래프를 어떻게 구성할 수 있을까?

목표는 recipes 배열을 순회하면서 어떤 recipe 가 다른 recipe 에 종속되는지의 관계를 나타내는 것이다. 따라서 초기 supplies 가 현재 recipe 의 ingredents 에 포함되어 있지 않다는 것은 다른 recipe 가 필요하다는 의미로 해석할 수 있다. 따라서, 그래프 adj[recipeIdx] 의 정의는 recipeIdx 의 레시피가 영향을 주게 되는 다른 recipe 들로 정의할 수 있고, 위의 그림에서 반대로 edge 를 구성하면 된다.

또한 “초기 supplies 가 현재 recipe 의 ingredents 에 포함되어 있지 않다는 것” 을 판단하기 위해서, 어떤 문자열을 내가 소유하고 있는지 아닌지 판단해야하는데, 이를 빠르게 하기 위해 HashSet 을 이용할 수 있다.

그래프 구성하기#

그래프를 구성할 때, adj[recipeIdx]는 ‘recipeIdx 레시피가 만들어지면 이를 재료로 사용하는 다른 레시피들의 리스트’를 의미한다. 이는 실제 레시피 제작 과정에서의 의존성 흐름과 일치한다. 예를 들어 bread이 만들어지면 sandwich를 만들 수 있게 되는 것이다.

// 의존성 그래프 구성
for (int recipesIdx = 0; recipesIdx < recipes.length; recipesIdx++) {
    for (String ingredient: ingredients.get(recipesIdx)) {
        // 재료가 기본 supplies에 없고 다른 레시피인 경우
        if (!availableSupplies.contains(ingredient)) {
            if (recipesToIdx.containsKey(ingredient) && recipesToIdx.get(ingredient) != recipesIdx) {
                // 재료 레시피가 현재 레시피에 영향을 준다는 의미로 간선 추가
                adj[recipesToIdx.get(ingredient)].add(recipesIdx);
                inDegree[recipesIdx]++;
            }
        }
    }
}

이 코드에서 adj[recipesToIdx.get(ingredient)].add(recipesIdx)는 “재료 레시피가 영향을 주는 다른 레시피”를 추가하는 것이다. 이는 위상 정렬의 방향과 일치하며, “재료 → 완성품” 방향으로 간선을 구성한다.

위상 정렬#

이제 위상정렬을 하는 과정을 생각해보자. 위상정렬의 칸 알고리즘에 따르면 inDegree 배열이 필요하다. 위에서 그래프를 구성하는 과정을 생각해보면 inDegree[i]i번째 레시피를 만들기 위해 더 필요한 재료 수를 의미한다.

따라서 inDegree 값을 다음과 같이 해석할 수 있다.

  • inDegree[i] = 0: 모든 필요 재료가 확보되어 레시피를 즉시 만들 수 있는 상태
  • inDegree[i] > 0: 아직 확보되지 않은 재료가 있어 레시피를 만들 수 없는 상태

위상 정렬 과정은 다음과 같이 구현할 수 있다.

// 위상 정렬 준비: 모든 재료가 갖춰진 레시피들을 큐에 넣음
Queue<Integer> readyToMake = new LinkedList<>();
for (int i = 0; i < inDegree.length; i++) {
    if (inDegree[i] == 0) {
        readyToMake.offer(i); // 즉시 만들 수 있는 레시피
    }
}

List<String> createdRecipes = new ArrayList<>();

// 위상 정렬 실행
while (!readyToMake.isEmpty()) {
    int recipeIndex = readyToMake.poll();
    String created = recipes[recipeIndex];
    createdRecipes.add(created); // 만든 레시피를 결과에 추가
    
    // 이 레시피가 완성됨으로써 영향 받는 다른 레시피들 업데이트
    for (int nextRecipeIdx : adj[recipeIndex]) {
        inDegree[nextRecipeIdx]--; // 필요한 재료가 하나 확보됨
        
        if (inDegree[nextRecipeIdx] == 0) {
            // 모든 재료가 갖춰지면 다음 만들 레시피 큐에 추가
            readyToMake.offer(nextRecipeIdx);
        }
    }
}

위상 정렬 과정에서 어떤 레시피가 만들어지면, 그 레시피를 재료로 사용하는 다른 레시피들의 inDegree를 감소시킨다. 이는 실제로 하나의 재료를 더 확보했음을 의미한다.

위상정렬의 탐색 과정에서 indegree[i] == 0 이 되면, 답에 포함시켜준다.

시간 복잡도#

recipes 배열의 크기가 n이라고 하고, supplies 의 크기가 s , 각 recipeingredient 의 갯수를 m이라고 하자. HashSet 을 구성하고, 그래프를 구성하는 과정에서 O(n + m)O(s) 의 시간 복잡도가 필요하다. 따라서 그래프를 구성하는 과정에서 O(n + m + s) 의 시간 복잡도가 필요한다.

이제 위상 정렬의 시간 복잡도를 생각해보면, 구성한 그래프에서 각 recipe와 그에대한 edge 는 한번만 처리되기 때문에 O(n + m) 의 시간복잡도가 소요된다.

따라서 총 시간 복잡도는 O(n + m + s) 이다.

구현#

class Solution {
    public List<String> findAllRecipes(String[] recipes, List<List<String>> ingredients, String[] supplies) {
        Set<String> availableSupplies = new HashSet<>(Arrays.asList(supplies));
        Map<String, Integer> recipesToIdx = new HashMap<>();
        List<Integer>[] adj = new ArrayList[recipes.length];
        for (int recipesIdx = 0; recipesIdx < recipes.length; recipesIdx++) {
            adj[recipesIdx] = new ArrayList<>();
            recipesToIdx.put(recipes[recipesIdx], recipesIdx);
        }

        int[] inDegree = new int[recipes.length];
        
        // make dependency graph
        for (int recipesIdx = 0; recipesIdx < recipes.length; recipesIdx++) {
            for (String ingredient: ingredients.get(recipesIdx)) {
                // 재료에 다른 레시피가 포함되어 있음
                if (!availableSupplies.contains(ingredient)) {
                    if (recipesToIdx.containsKey(ingredient) && recipesToIdx.get(ingredient) != recipesIdx) {
                        adj[recipesToIdx.get(ingredient)].add(recipesIdx);
                    }
                    inDegree[recipesIdx]++;
                }
            }
        }

        List<String> createdRecipes = new ArrayList<>();
        
        // 위상정렬
        Queue<Integer> q = new LinkedList<>();
        for (int i = 0; i < inDegree.length; i++) {
            if (inDegree[i] == 0) {
                q.offer(i);
            }
        }

        while (!q.isEmpty()) { // inDegree == 0 => 만들어졌음을 의미 
            int recipeIndex = q.poll();
            String created = recipes[recipeIndex];
            createdRecipes.add(created);
            
            // 해당 recipe 가 사용되는 다른 레시피의 inDegree를 줄여주자
            for (int nextIdx: adj[recipeIndex]) {
                inDegree[nextIdx]--;

                if (inDegree[nextIdx] == 0) {
                    q.offer(nextIdx);
                }
            }
        }

        return createdRecipes;
    }
}

참고 자료#

[LeetCode] Find All Possible Recipes from Given Supplies
https://punchdrunkard.github.io/posts/algorithm/leetcode-2116/
Author
42
Published at
2025-03-21