Processing math: 100%
본문으로 바로가기

전화번호 목록 (Level 2)

문제

전체 문제 보기

 

코딩테스트 연습 - 전화번호 목록

전화번호부에 적힌 전화번호 중, 한 번호가 다른 번호의 접두어인 경우가 있는지 확인하려 합니다. 전화번호가 다음과 같을 경우, 구조대 전화번호는 영석이의 전화번호의 접두사입니다. 구조

programmers.co.kr

접근법

이 문제는 해시 테이블만 사용하여 풀이한 적이 있다. (해시 테이블만 활용한 기존 풀이 보기) 트라이를 배우고 이 문제를 다시 보니 해시 테이블만 사용했을 때 보다 트라이를 활용했을 때 더 좋은 성능을 낼 수 있을 듯하여 다시 풀었다. 트라이는 여러 문자열들을 입력받아 동일한 접두사를 효율적으로 찾아낼 수 있다는 특징이 있다. 그래서 접두사를 찾아내야 하는 이 문제에 적합한 자료구조가 된다. 트라이 자료구조에 대한 더 자세한 설명은 이 글을 참고하자.

자료구조 정의

먼저 이 문제의 문자열은 숫자로만 이루어져 있다. 그래서 자식 노드는 최대 10개이며, 인덱싱 함수가 간단하기 때문에 배열을 활용하여 자식 노드를 구성해 보려한다. 또한 한 전화번호가 다른 전화번호의 접두사인지 아닌지 파악하기 위해서는 전화번호의 끝을 나타낼 수 있는 isEnd 변수가 필요해 보인다. 따라서 다음과 같이 Node 구조체를 정의한다.

struct Node{
    Node() : isEnd (false), links{} 
    {}

    bool isEnd;
    array<Node*, 10> links;
    static size_t GetIndex(char ch) {return ch - '0';}
};

트라이 자료구조를 사용할 때 가장큰 장점은 모든 문자열을 다 삽입하지 않더라도, 문자열을 삽입하는 중간에 접두사인지 아닌지 바로 파악할 수 있다. (기준 해시 테이블만 사용한 풀이에서는 모든 전화번호를 해시 테이블에 삽입 후 하나씩 확인했었다.) 이를 위해서 우리는 문자열 삽입 과정에서 bool 값을 반환할 Insert 함수를 가진 Trie 클래스를 아래와 같이 정의할 수 있다.

class Trie{
public:
    Trie() { root = new Node(); }
    ~Trie() { DeleteAll(root); }
    bool Insert (const string& str );	// 삽입 과정에서 유효한 번호인지 판단
    
private:
    Node* root;
    void DeleteAll(Node* node);	// 모든 노드를 삭제하기 위한 함수. 재귀적으로 구현
};

이렇게 구현된 Trie 클래스는 다음과 같이 사용할 예정이다.

bool solution(vector<string> phone_book) {
    bool answer = true;
    
    Trie trie;
    
    for(const string& number : phone_book )
    {
        // 유효하지 않은 전화번호가 삽입되면 false를 반환하고 종료.
        if(trie.Insert(number) == false)
            return false;
    }
    // 모든 전화번호가 정상적으로 삽입되었다면 유효한 phone_book 이다.
    
    // tire의 소멸자가 자동으로 호출되면서 동적으로 할당된 노드가 삭제됨.
    return true;
}

구현

이 문제의 핵심인 Insert 함수를 구현해보자. 아래와 같이 유효한 전화번호부가 있다고 가정하자.

▲ [그림 1]

[그림 1]은 한 번호가 다른 번호의 접두사인 경우가 없는 유효한 전화번호부 이다. 여기에 아래와 같이 새로운 번호 "3345"를 삽입해보자.

▲ [그림 2]

새로운 번호 "3345"는 "334"를 접두사로 가지는 번호이다. "3345"를 삽입하기 위해서는 노드 3→3→4를 지나와야 한다. 그런데 노드 4는 이미 isEndtrue로 설정되어 있다. 이번 예시에서 true로 설정된 노드 이후에 새로운 자식 노드가 들어온다면 해당 전화번호가 유효하지 않다는 사실을 알 수 있다. 다른 예시도 살펴보자.

▲ [그림 3]

이 번에는[그림 1] 트라이에 전화번호 "12"를 삽입해보자. "12"는 "123"의 접두사이기 때문에 유효할 수 없다. 우리는 "12"를 삽입할 때 노드 "2"의 자식 노드가 있는지 확인할 수 있다. "2"에 자식 노드가 있다면 "12"는 어떠한 번호의 접두사 전화번호가 되기 때문에 이러한 삽입은 유효하지 않다는 것을 알 수 있다. 따라서 일반적인 트라이 노드 삽입 함수에 [그림 2], [그림 3]과 같은 상황에는 false를 반환도록 Insert 함수를 구현할 수 있을 것이다.

bool Trie::Insert(const string& str)
{
    Node* cur = root;
    // 반복문 시간 복잡도 (O(1))
    for(size_t i = 0; i < str.length(); ++i)
    {
    	// 그림 2의 경우 (cur노드에 새 자식 노드를 추가하는데 해당 노드가 잎 노드인 경우)
        if(cur->isEnd == true)
            return false;
        
        size_t index = Node::GetIndex(str[i]);
        if(cur->links[index] == nullptr)
        {
            cur->links[index] = new Node();
        }
        cur = cur->links[index];
    }
    cur->isEnd = true;
    
    // 그림 3의 경우 (노드 삽입 후 자식 노드가 존재하는지 확인)
    for(Node* child : cur->links)
    {
        if(child != nullptr)
            return false;
    }
    
    return true;
}

Insert 함수의 시간 복잡도를 살펴보자. Insert 함수의 시간 복잡도는 O(1)이다. 비록 반복문이 있지만, 전화번호의 최대 길이는 20이기 때문에 최악의 경우에도 반복문은 20회만 동작하기 때문에 O(1)로 볼 수 있다.

 

성능 평가

가장 시간 복잡도가 높은 함수인 Insert 함수의 시간 복잡도가 무려 O(1)이다. 때문에 모든 노드를 삽입하더라도 최악의 경우에도 O(n)만에 연산을 마칠 수 있다. 기존 해시테이블만 사용한 풀이한 코드의 시간 복잡도 또한 O(n)으로 트라이를 사용한 풀이와 동일하다. 하지만 기존 풀이에서 아래의 부분을 살펴보자.

    for(auto number : phone_book)    // 모든 전화번호에 대해서
        for(int i = 1; i < number.length(); i++) //  모든 경우의 수에 대해서 (자기자신 제외)
            if(ht.find(number.substr(0,i)) != ht.end())    // 중복된 번호가 있다면 false
                return false;

해시 테이블의 find 함수의 시간 복잡도는 O(1)이지만 연산량이 적지 않다. 더욱이 문자열이 길어질수록 성능은 매우 떨어지게 된다. 또한 substr 함수도 문자열 길이만큼의 시간 복잡도를 가진다. 결국 해당 반복문은 O(t2n) 시간 복잡도를 가지게 된 셈이다. 문자열의 최대 길이가 20으로 한정되어 있어 사용할수 없을 만큼 느린것은 아니지만 이번 트라이를 활용한 풀이에 비하면 다소 느리다고 할 수 있다. 이번 트라이 풀이에서 사용한 배열의 GetIndex 함수의 연산은 오직 뺄셈연산 하나로 이루어져 있기 때문이다. 그래서 같은 O(1) 시간 복잡도를 가졌지만 아래 그림 4와 같이 2~5배가량의 성능 차이를 보이게 된다.

▲ 그림 4. (좌)해시 테이블 활용 / (우) 트라이 활용

하지만, 공간 복잡도에서는 두 경우 모두 O(n)이지만 항상 모든 노드에 자식노드 포인터 배열을 10개씩 가져야 하는 트라이 구조보다 필요한 만큼 문자열을 동적으로 가지는 해시 테이블이 조금 더 효율적임을 확인할 수 있다.

 

전체 코드

동적으로 할당한 노드를 모두 해제하는 소멸자 코드는 재귀적으로 구현되어 있음에 주의하자.

// 문제 : 전화번호 목록 (Level 2)
// 출처 : 프로그래머스 (https://programmers.co.kr/learn/courses/30/lessons/42577)
// 풀이 : https://dev-sbee.tistory.com/ 참고

#include <string>
#include <vector>
#include <array>

using namespace std;
struct Node{
    Node() : isEnd (false), links{} 
    {}
    
    bool isEnd;
    array<Node*, 10> links;
    static size_t GetIndex(char ch) {return ch - '0';}
};

class Trie{
public:
    Trie() { root = new Node(); }
    ~Trie() { DeleteAll(root); }
    bool Insert (const string& str );	// 삽입 과정에서 유효한 번호인지 판단
    
private:
    Node* root;
    void DeleteAll(Node* node);	// 모든 노드를 삭제하기 위한 함수. 재귀적으로 구현
};
bool Trie::Insert(const string& str)
{
    Node* cur = root;
    // 반복문 시간 복잡도 (O(1))
    for(size_t i = 0; i < str.length(); ++i)
    {
    	// 그림 2의 경우 (새 노드를 추가해야하는데 해당 노드가 잎 노드인 경우)
        if(cur->isEnd == true)
            return false;
        
        size_t index = Node::GetIndex(str[i]);
        if(cur->links[index] == nullptr)
        {
            cur->links[index] = new Node();
        }
        cur = cur->links[index];
    }
    cur->isEnd = true;
    
    // 그림 2의 경우 (자식 노드가 존재하는지 확인)
    for(Node* child : cur->links)
    {
        if(child != nullptr)
            return false;
    }
    
    return true;
}

void Trie::DeleteAll(Node* node)
{
    for(Node* child : node->links)
    {
        if(child != nullptr)
            DeleteAll(child);
    }
    
    delete node;
}
bool solution(vector<string> phone_book) {
    bool answer = true;
    
    Trie trie;
    
    for(const string& number : phone_book )
    {
        // 유효하지 않은 전화번호가 삽입되면 false를 반환하고 종료.
        if(trie.Insert(number) == false)
            return false;
    }
    // 모든 전화번호가 정상적으로 삽입되었다면 유효한 phone_book 이다.
    
    // tire의 소멸자가 자동으로 호출되면서 동적으로 할당된 노드가 삭제됨.
    return true;
}