Heeveloper

단어 제한 끝말잇기


1. Problem

screenshot


2. My Approach

  • 입력 받은 단어들간의 시작단어와 끝단어를 비교하여 그래프를 만든다.

    words[i]의 시작단어와 words[j]의 끝단어가 일치하면 인접행렬 adj[i][j] 에 체크한다.

  • 임의의 단어를 시작으로 설정하여 연결되는 모든 경우로 dfs한다.
  • dfs
    1. 연결되는 다음 단어가 존재하여 해당 단어로 이동하기 위해 dfs할 때마다 방문했다는 의미의 변수를 업데이트하고, 깊이를 의미하는 변수를 하나씩 증가해준다.
    2. 해당 변수가 총 단어의 갯수에 대응하는 값이 되면 모든 단어가 이어진 것이므로 특별한 값을 반환해준다.
    3. 위의 반환값으로 인해 backtracking 과정에서 해당 경로의 단어들을 처리한다.

      a) 만일 끝까지 이어졌다면 1(true)이 반환되며 현재 단어를 기록한다. b) 잘못 연결된 경우는 0(false)이 반환되고 해당 경로의 단어의 방문을 다시 0으로 되돌린다.

    4. 접근 방법은 옳았지만 최악의 경우 만들어지는 모든 경우의 수를 계산해야 하므로 시간초과가 발생한다.

3. Implementation

//
//  main.cpp
//  Wordchain
//
//  Created by  Heejun Lee on 7/21/17.
//  Copyright © 2017  Heejun Lee. All rights reserved.
//


#include <iostream>
#include <vector>
#include <algorithm>

using namespace std;

int T,N;
vector<string> words;
vector<vector<int>> adj;
vector<int> checked;
vector<int> ordered;

void makeGraph(){
    adj = vector<vector<int>>(N,vector<int>(N,0));
    for(int i=0;i<N;i++)
        for(int j=0;j<N;j++){
            if(i==j) adj[i][j] = 0;
            else if(words[i].back() == words[j].front()) adj[i][j] = 1;
        }
}

int dfs(int here, int count){
    checked[here] = 1;
    int ret=0;
    if(count == N-1){
        ordered.push_back(here);
        return 1;
    }
    for(int there=0;there<N;there++)
        if(here != there && adj[here][there] && !checked[there]){
            ret = dfs(there, count+1);
            if(ret)
                ordered.push_back(here);
            else{
                checked[there]=0;
            }
        }
    return ret;
}

vector<int> topologySort(){
    for(int start=0;start<N;start++){
        checked.clear();
        checked = vector<int>(N,0);
        ordered.clear();
        if(dfs(start, 0) != 0) break;
    }
    if(ordered.empty()) return vector<int>();
    else return ordered;
}

int main() {
    cin >> T;
    while(T--){
        cin >> N;
        words = vector<string>(N);
        for(int i=0;i<N;i++)
            cin >> words[i];
        makeGraph();
        vector<int> ret = topologySort();
        reverse(ret.begin(), ret.end());
        if(ret.empty())
            cout << "IMPOSSIBLE" << endl;
        else{
            for(int i=0;i<ret.size();i++)
                cout << words[ret[i]] + " ";
            cout<<endl;
        }
    }
    return 0;
}

4. Textbook Approach

  • 해밀토니안 경로(Hamiltonian path)와 오일러 트레일
    • 해밀토니안 경로 : 그래프의 모든 정점을 정확히 한 번씩 지나가는 경로

      해밀토니안 경로를 찾는 유일한 방법은 조합탐색으로, 모든 정점의 배열을 하나하나 시도하며 이들이 경로가 되는지를 확인하는 것이다. 이 방법으로 문제를 풀기 위해서는 최악의 경우 N!개의 후보를 만들어야 하는데, 이 문제에선 단어의 수가 최대 100이기 때문에 이렇게는 절대로 답을 찾을 수 없다.

    • 오일러 트레일 : 오일러 서킷처럼 모든 간선을 정확히 한 번씩 지나가지만, 시작점과 끝점이 다른 경로

      시작점과 끝점을 잇는 간선을 하나 추가한 뒤 모든 점이 짝수점이 되려면, 시작점과 끝점을 제외한 모든 점은 짝수점이고 시작점과 끝점은 홀수점이 되어야 한다.

    • 뒤의 Hamiltonian path vs. Euler circuit/trail 참고
  • 따라서 본 문제는 방향 그래프에서 오일러 서킷을 찾는다.
    • 각 정점에 들어오는 간선의 수와 나가는 간선의 수가 같아야 한다.
  • 오일러 서킷을 이용해 오일러 트레일을 찾는다.
    • a에서 시작하고 b에서 끝나는 오일러 트레일을 찾기 위해선 간선 (b,a)를 그래프에 추가한 뒤 오일러 서킷을 찾아야 한다.
    • 이 떄 오일러 서킷의 존재 조건이 만족되려면 a에서는 나가는 간선이 들어오는 간선보다 하나 많고, b는 들어오는 간선이 나가는 간선보다 하나 많고, 다른 모든 정점에서는 나가는 간선과 들어오는 간선의 수가 같아야 한다.
  • 본 문제의 답은 오일러 서킷일 수도 있고 오일러 트레일일 수도 있다.

    각 정점의 차수를 확인하여 오일러 서킷과 트레일을 구분한다.


5. Implementation

#include <stdio.h>
#include <string.h>
#include <iostream>
#include <vector>
#include <algorithm>

using namespace std;
vector<vector<int>> adj;
vector<string> graph[26][26];
vector<int> indegree, outdegree;

void getEulerCircuit(int u, vector<int>& circuit){
    for (int v = 0; v < adj.size(); ++v){
        while (adj[u][v]>0){
            adj[u][v]--;
            getEulerCircuit(v, circuit);
        }
    }
    circuit.push_back(u);
}

vector<int> getEulerTrailOrCircuit(){
    vector<int> circuit;
    for (int i = 0; i < 26; ++i){
        if (outdegree[i] == indegree[i] + 1){ //    무조건 시작지점인 문자 있는 지 체크 후 그걸로 오일러트레일러 생성
            getEulerCircuit(i, circuit);
            return circuit;
        }
    }
    for (int i = 0; i < 26; ++i){ //    오일러서킷이므로 간선 있는 경우부터 시작
        if (outdegree[i]){
            getEulerCircuit(i,circuit);
            return circuit;
        }
    }
    return circuit;
}


void makeGraph(const vector<string>& words){
    for (int i = 0; i < 26; ++i){
        for (int j = 0; j < 26; ++j){
            graph[i][j].clear();
        }
    }
    adj = vector<vector<int>>(26,vector<int>(26,0));
    indegree = outdegree = vector<int>(26, 0);

    for (int i = 0; i < words.size(); ++i){
        int a = words[i][0] - 'a';
        int b = words[i][words[i].size() - 1] - 'a';
        graph[a][b].push_back(words[i]);
        adj[a][b]++;
        outdegree[a]++;
        indegree[b]++;
    }
}

bool checkEuler(){
    int plus1 = 0, minus1 = 0;
    for (int i = 0; i < 26; ++i){
        int delta = outdegree[i] - indegree[i];
        if (delta < -1 || delta > 1) return false;
        if (delta == 1) plus1++;
        if (delta == -1) minus1++;
    }
    //Euler Trail || Euler Circuit
    return (plus1 == 1 && minus1 == 1) || (plus1==0 && minus1==0);
}

string solve(const vector<string>& words){
    makeGraph(words);
    if (!checkEuler()) return "IMPOSSIBLE";

    vector<int> circuit = getEulerTrailOrCircuit();
    if (circuit.size() != words.size() + 1) return "IMPOSSIBLE";

    reverse(circuit.begin(), circuit.end());
    string ret;
    for (int i = 1; i < circuit.size(); ++i){
        int a = circuit[i - 1], b = circuit[i];
        if (ret.size()) ret += " ";
        ret += graph[a][b].back();
        graph[a][b].pop_back();
    }
    return ret;
}

int main(void){
    int C, N;
    cin >> C;

    for (int test = 1; test <= C; ++test){
        cin >> N;

        vector<string> words = vector<string>(N);
        char ch[11];
        for (int i = 0; i < N; ++i){
            getchar();
            scanf("%s", ch);
            words[i] = ch;
        }
        string str = solve(words);
        printf("%s\n", str.c_str());
    }
    return 0;
}


6. Analysis

본인의 코드는 최악의 경우 모든 경우의 수를 계산해야 한다.따라서 N개의 단어가 주어졌을 때, N!개의 후보를 만들어봐야 하는데, 본 문제의 단어 개수가 최대 100개 이므로 시간초과가 발생한다.

책의 코드의 시간 복잡도는 오일러 트레일을 찾는 함수에 의해 지배된다.
그래프를 만드는 과정과 답으로 출력할 문자열을 만드는 과정은 모두 단어의 수에 비례하는 시간이 걸리는데, 오일러 트레일을 찾는 함수의 수행 시간은 알파벳의 수 A와 단어의 수 N의 곱에 비례하기 때문이다.
따라서 전체 시간 복잡도는 O(NA)가 된다.


7. Feedback

Hamiltonian path vs. Euler circuit/trail
두 가지 접근 방법에 대해 의미론적인 이해는 다음과 같았다.
모든 정점을 한번씩 방문하는 해밀턴 경로와 모든 간선을 한번씩 방문하는 오일러 경로의 차이는 모두 방문해야 하는 것이 무엇인지에 있다.
이 모두 방문해야 한다는 것을 문제의 상황에 대입해보면 주어진 단어들이라고 할 수 있다. 답을 구하기 위해선 모든 단어들을 한 번씩 사용해야 한다.
따라서 해밀턴의 경우엔 단어에 대응하는 모든 정점을 한번씩 지나쳐야 하고, 오일러의 경우엔 모든 간선을 한번씩 지나쳐야 한다. 그리고 각각의 상황에 맞게 그래프를 그리면 아래와 같다.

screenshot (프로그래밍 대회에서 배우는 알고리즘 문제해결전략)

(a)는 해밀턴 경로의 접근방법을 구현하기 위해, (b)는 오일러 경로의 접근방법을 구현하기 위해 표현한 그래프로 볼 수 있다.
여기서 수행시간에 영향을 미치는 결정적인 요소가 드러나는데, (b)는 오일러 서킷/트레일 조건 만 확인하면 답을 바로 알 수 있는 반면, (a)그래프의 경우 4개의 정점을 지나치기 위해서 직접 모든 경우의 수를 연산하며 비교해봐야 한다.
따라서 오일러 경로가 수행시간을 줄여준다는 단순한 기술적 결과만을 보고 지나갈 것이 아니라, 위와 같이 그 쓰임새의 본질적인 의미를 이해하는 것이 중요하다!

깊이 우선 탐색
깊이 우선 탐색은 그래프의 구조에 관련된 많은 문제를 푸는 데 사용될 수 있다.
깊이 우선 탐색을 수행하면 그 과정에서 그래프의 모든 간선을 한 번씩은 만나게 되는데, 그 중 일부 간선은 처음 발견한 정점으로 연결되어 있어서 우리가 따라갈 테고, 나머지는 무시하게 된다.
그러나 이 간선들을 무시하지 않고 이들에 대한 정보를 수집하면 그래프의 구조에 대해 많은 것을 알 수 있다.
이러한 문제들을 해결하기 위해서 깊이 우선 탐색이 하는 일을 좀 더 자세히 살펴볼 필요성을 느낀다. 시간을 내서 따로 정리를 하도록 해야겠다.



Comments

Content
-->