호우동의 개발일지

Today :

Published 2023. 2. 12. 18:47
[C++] 백준 22859 - HTML 파싱 Algorithm/BOJ

문제 이해 단계


 

 

https://www.acmicpc.net/problem/22859

 

상당히 문제가 길고 조건이 많은 문제라
필요한 조건만 밑에 적어보자.


HTML 문법 형식으로
줄 바꿈 없이 한 줄로 입력이 들어온다.


핵심이 되는 태그는 <main> <div> <p>이다.

1. 모든 태그와 문자열은 <main>  ~ </main> 사이에서 벌어져야 한다.


2. <div title = "(A)" 형식으로 무조건 title = "(A)"이 입력으로 들어오고
여기서 A는 여러 문단 뭉텅이의 제목이 된다.


3. <p>~</p> 사이에 있는 문자열은 좌우의 공백은 없애고
중간에 두 번 이상 띄어쓰기 되어있으면 한 번으로 줄인다.

그리고 출력 형식은 예제와 같다.
더 중요한 것은 입력이 이런 식으로 무조건 주어진다고 보장한다는 것인데,


1. 제목의 시작과 끝 부분에는 공백이 없다.


2. <p> ~ </p> 태그 사이에는
다른 태그도 존재 가능


3. 태그를 표현하는 <> 제외하고는
알파벳과 공백, ' / ' (닫는 태그에만)만 쓰인다.


4. <p> ~ </p> 안에 있는 태그 제외하고서는
나머지 < ~ > 안에 공백은 존재하지 않는다.


그러니까 <d  i   v> 이런 식으로 주어지진 않는다는 소리이다.

해당 조건이 주어질 때, 해
당 출력 형식으로 정확히 출력하는 문제이다.

 

 

 

 

 

문제 접근 단계


이 문제는 딱 봐도 구현문제다.  
그냥 대놓고 구현해 볼 테 면 해봐라 이런 식인 것 같다.
구현해야 할 부분이 많을 것 같아서 함수를 잘 나눠서 설정해둬야 할 것 같다.


일단 필요한 기능을 살펴보자.


최우선적으로
(1)
들어온 문자열을 일정한 단위로 잘라
태그와 일반 문자열을 구분해야 한다.


<p> adfd </p>이면 <p>와
adfd와 </p>는 서로 다르기 때문에
구분돼야 하듯이 말이다.


그렇게 일정한 단위로 잘랐다면 이게


(2)
시작 태그인지, 종료 태그인지,
일반 문자열인지 파악해야 한다.


태그 중 <div> 태그에는 title이 있는데 이것도 출력해줘야 한다.
 출력해줘야 하는 것은 " " 안에 있는 것,


즉, 
(3)
<div> 태그에 있는 title도 추출해야 한다.

근데 이걸 또 그대로 출력해도 되나? 그렇지 않다.


(4)
좌우 공백은 제거해 주고 중간에 띄어쓰기가
2번 이상은 것은 1번으로 바꿔줘야 한다.

그리고 출력할 때의 사항을 보면


(5)
div 별로 제목이 나와야 하고
<p> 별로 줄 바꿈 하면서 출력해야 한다.


또한
(6)
<p>~</p> 태그 안에 있는 나머지 태그는 출력하지 않는다.


이건 추가적인 사항일 수도 있지만 출력할 때
문자들의 순서가 바뀌지 않도록 주의해야 한다.


복잡한 구현문제의 경우 구현해야 할 것을
미리 정리해 놓고 문제 풀이에 들어가는 게 나은 것 같다.


구현문제인 만큼 문제 구현 단계에서
코드를 풀이하면서 정리하겠다.

 

 

 

 

문제 구현 단계


 

1. 기본 로직 설명

stack<char> s; // 한 문자씩 담을 스택
stack<string> codes; // 완성된 단어를 담을 스택

int main(){
    cin.tie(0); cout.tie(0); ios::sync_with_stdio(false);
    string html;
    getline(cin,html);
	
    // 한 문자씩 탐색
    for(int i = 0; i < html.length(); i++){
        string str = "";
        
        switch(html[i]){
            case '<' :
            if(!s.empty()) {
                str = makeWord(); // 단어 만들기
                classifyWord(str); // 단어 분류
                str.clear(); // 초기화
            }
            s.push(html[i]); // 문자 넣기
            break;
            
            case '>' :
            // 문자 넣고 단어를 만듦
            s.push(html[i]);
            str = makeWord();
            classifyWord(str);
            str.clear();
            break;
			
            // 알파벳이나 공백일 경우 그냥 넣음
            default:
            s.push(html[i]);
        }
    }
}

2개의 스택을 사용한다.


스택 s는 char형 문자를 담는 것이고,
스택 codes는 문자열을 담는 스택이다.


기본적인 로직은 전체 입력을 처음부터 한 문자씩 탐색한다. 
한문자씩 읽으면서 그 단어를 스택 s에 넣는다.


일정 조건이 만족되면,  
s에 담겨있던 모든 값으로 한 문자열을 만들어
그 문자열을 스택 codes에 push 한다.


만들어진 문자열은 위에서 조건에 의해 여러 함수에 사용된다.
일단 이것이 기본 로직이다.

 

 

2. 구분하여 문자열 만들기

일단 해당 html 문법에서 >가 오면 태그가 닫힘을 의미하므
로 하나의 문자열이 끝남을 의미한다.


<main> , <div ~~~ >, <p> , <br > 등 전부 >로 끝나기 때문.


그리고 <가 오면 일단 <를 넣어주기 전에
이전까지의 값을 문자열을 만들어줘야 한다.


왜냐하면 이전에 계산해 둔 값이 존재할 수 있기 때문이다. (예 : "adsfasdf <")

그럼 어떻게 문자열을 구성할까, 다음 함수를 사용한다.

 

// 단어 만들기
string makeWord(){  
    string tmp = ""; 
    while(!s.empty()){
        tmp.insert(tmp.begin(),s.top());
        s.pop();
    }
    return tmp;
}

 

간단하게 스택 s에 있는 값을 모두 빼서 만들어주면 된다.
여기서 유의 깊게 봐야 하는 것은
빼준 요소 하나하나를 맨 앞에 두는 것이다.


이렇게 하는 이유는 스택에 나온 순서대로 출력하면
반전돼서 나오기 때문에,
늦게 나온 값을 앞으로 놔두는 것으로
올바른 값이 나오게 하는 것이다.


 

3. 중간 중복 띄어쓰기 제거

// 다중 띄어쓰기 제거
string removeEmpty(string word){
    if(word.empty() || word.length() <= 1) return word;

    string str = "";
    int combo = 0;
    for(int i = 0; i < word.length(); i++){
        if(word[i] == ' ') combo++;
        else combo = 0;

        if(combo < 2) {
            str.push_back(word[i]);
        }
    }
    return str;
}

 

문제 조건 중에 <p> ~ </p> 안에 있는 것 중에
띄어쓰기가 2개 이상인 것은 하나로 만들어주라고 했다.


여기서는 combo라는 변수를 이용해서 이를 판단한다.


띄어쓰기가 발견되면 combo + 1,
띄어쓰기가 아니면 combo = 0으로 하여 
띄어쓰기가 연속해서 나와야 2,3,4로 갈 수 있게 해 놨다.


그렇게 combo가 2 미만일 때만
재구성할 문자열에 다시 넣어준다.

// 좌우 공백 제거
string trim(string word){
    if(word.length() <= 1) return word;
    if(word[0] == '<'){
        // 시작+1 공백 제거
        while(word[1] == ' ') word.erase(word.begin()+1);
        // 끝-1 공백 제거
        while(word[word.size()-2] == ' ') 
            word.erase(word.begin()+word.size()-2);
    }
    else{
        // 시작 공백 제거
        while(word[0] == ' ') word.erase(word.begin());
        // 끝 공백 제거
        while(word[word.size()-1] == ' ') 
            word.erase(word.begin()+word.size()-1);
    }
    return word;
}

 

<p> ~ </p> 태그 사이에서 좌우 공백을 제거하는 함수이다.
C++에서는 trim함수가 없기 때문에 직접 구현해줘야 한다.

방법은 매개변수 word가 태그인지 아닌지에 따라 나뉘는데
사실 로직은 같고 인덱스 위치만 다르다.


태그이면 <가 붙기 때문에 문자는 인덱스 1부터 시작하기 때문이다.

문자가 시작하는 인덱스가
공백이 아닐 때까지 그 인덱스를 제거한다.


마찬가지로 마지막 문자가 있는 인덱스도
공백이 아닐 때까지 인덱스를 제거한다.

 

4. 순서대로 출력하기

// 시작 태그를 찾을 때 까지 pop하면서 출력
void popAndPrint(string endTag){
    vector<string> print;
    // codes 스택의 가장 위가 endTag와 같을 때까지
    while(codes.top().compare(endTag) != 0){
    	// 그 전에 pop한 문자열은 프린트할 vector에 저장
        string str = codes.top();
        codes.pop();
        print.push_back(str);
    }
    if(!codes.empty()) codes.pop(); // 시작 태그 스택에서 제거
    string output = "";
    reverse(print.begin(),print.end()); // 순서를 위해 뒤집음
    for(auto it : print) output.append(it); // 문자열로 붙임
    // 공백 제거
    output = removeEmpty(output);
    output = trim(output);
    cout << output << "\n";
}

 

이 함수는 매개변수는 endTag는
여기에 들어오기 이전에 '/'를 제거하여 시작태그와 똑같이 만들어놨다.


즉 </main>에서 /를 제거해서 <main>으로 만들어놨다는 뜻이다. 
이는 밑에서 시작태그를 찾기 위해서이다.


이렇게 시작 태그를 찾을 때까지 pop을 하면서
그 안에 있는 문자열을 모두 담는다.

그리고 이를 한번 반전시키는데,
이는 아까도 말했듯이 스택에서 빼낸 거라
값이 반전되어 있기 때문이다.


결과 벡터를 모두 한 문자열로 이어 붙여주고
최종적으로 trim과 removeEmpty로
좌우공백과 가운데 공백을 제거해 준다.

 

 

5. 단어 구분 및 전체 제어

 

// 단어를 판단하고, 출력 담당하는 메인
void classifyWord(string str){
    if(str.empty()) return;
    //태그 옵션일 때
    if(str[0] == '<'){
        // 시작 태그일 때
        if(str.find('/') == string::npos){
        	// <main>이나 <p>태그 일때만 codes에 넣음
            if(str.compare("<main>") == 0 || str.compare("<p>") == 0) codes.push(str);
            // div 태그를 찾음 
            else if(str.find("<div title=") != string::npos){
                if(str.find('"') != string::npos){
                    getTitle(str); // 타이틀 출력
                }
            }
        }
        // 종료 태그일 때
        else{
            int idx = str.find('/');
            // '/' 문자 제거 후에 popAndPrint 호출
            str.erase(str.begin()+idx);
            if(str.compare("<main>") == 0 || str.compare("<p>") == 0){
                popAndPrint(str);
            }
        }
    }
    // 그냥 문자열 일 때 
    else codes.push(str);
}

 

마지막으로 들어온 단어가 그냥 문자열인지 태그인지 구분하고,
또 그게 어떤 태그인지 구분하는 함수이다.


이 함수가 핵심함수인 이유는
이 함수에서 다른 모든 함수를 거의 호출하기 때문이다.

위에서부터 설명하면 '<'로 시작한다는 것은 시작태그를 의미하는데,
그 안에서도 <main>과 <p> 일 때만 넣는 이유는
<p>~</p> 안에 있는 다른 태그들은 자연스럽게 출력해주지 않기 위해
애초에 넣어주질 않는 것이다.

그리고 div는 따로 구분하였는데
이는 find를 사용하여 해당 문자열을 포함하고 있는지를 했다.


만약 "<div" 이런 식으로 했다면 "<diving>" 이런 태그도 입력될 수 있기 때문에
최대한 형식대로 자세히 적어줬다.

종료태그일 때는 아까 말했듯이
'/'를 제거해 준 문자열로 popAndPrint를 호출한다.


이게 모든 코드에 대한 설명이다.
이제 아래에 전체 코드에 대해 올리고 설명을 마치겠다.

#include <iostream>
#include <vector>
#include <string>
#include <stack>
#include <algorithm>
using namespace std;

stack<char> s; // 한 문자씩 담을 스택
stack<string> codes; // 완성된 단어를 담을 스택
string cntTitle = "";

// 이중 띄어쓰기 제거
string removeEmpty(string word){
    if(word.empty() || word.length() <= 1) return word;

    string str = "";
    int combo = 0;
    for(int i = 0; i < word.length(); i++){
        if(word[i] == ' ') combo++;
        else combo = 0;

        if(combo < 2) {
            str.push_back(word[i]);
        }
    }
    return str;
}

// 좌우 공백 제거
string trim(string word){
    if(word.length() <= 1) return word;
    if(word[0] == '<'){
        // 시작+1 공백 제거
        while(word[1] == ' ') word.erase(word.begin()+1);
        // 끝-1 공백 제거
        while(word[word.size()-2] == ' ') 
            word.erase(word.begin()+word.size()-2);
    }
    else{
        // 시작 공백 제거
        while(word[0] == ' ') word.erase(word.begin());
        // 끝 공백 제거
        while(word[word.size()-1] == ' ') 
            word.erase(word.begin()+word.size()-1);
    }
    return word;
}

// 단어 만들기
string makeWord(){  
    string tmp = ""; 
    while(!s.empty()){
        tmp.insert(tmp.begin(),s.top());
        s.pop();
    }
    return tmp;
}

// <div> 태그에서 " " 안에 있는 이름 추출
void getTitle(string word){
    string title = "";
    int idx = word.find('"');
    while(word[++idx] != '"') title += word[idx];

    cout << "title : " << title << "\n";
}

// 시작 태그를 찾을 때 까지 pop하면서 출력
void popAndPrint(string endTag){
    vector<string> print;
    // codes 스택의 가장 위가 endTag와 같을 때까지
    while(codes.top().compare(endTag) != 0){
    	// 그 전에 pop한 문자열은 프린트할 vector에 저장
        string str = codes.top();
        codes.pop();
        print.push_back(str);
    }
    if(!codes.empty()) codes.pop(); // 시작 태그 스택에서 제거
    string output = "";
    reverse(print.begin(),print.end()); // 순서를 위해 뒤집음
    for(auto it : print) output.append(it); // 문자열로 붙임
    // 공백 제거
    output = removeEmpty(output);
    output = trim(output);
    cout << output << "\n";
}

// 단어를 판단하고, 출력 담당하는 메인
void classifyWord(string str){
    if(str.empty()) return;
    //태그 옵션일 때
    if(str[0] == '<'){
        // 시작 태그일 때
        if(str.find('/') == string::npos){
        	// <main>이나 <p>태그 일때만 codes에 넣음
            if(str.compare("<main>") == 0 || str.compare("<p>") == 0) codes.push(str);
            // div 태그를 찾음 
            else if(str.find("<div title=") != string::npos){
                if(str.find('"') != string::npos){
                    getTitle(str); // 타이틀 출력
                }
            }
        }
        // 종료 태그일 때
        else{
            int idx = str.find('/');
            // '/' 문자 제거 후에 popAndPrint 호출
            str.erase(str.begin()+idx);
            if(str.compare("<main>") == 0 || str.compare("<p>") == 0){
                popAndPrint(str);
            }
        }
    }
    // 그냥 문자열 일 때 
    else codes.push(str);
}


int main(){
    cin.tie(0); cout.tie(0); ios::sync_with_stdio(false);
    string html;
    getline(cin,html);
	
    // 한 문자씩 탐색
    for(int i = 0; i < html.length(); i++){
        string str = "";
        
        switch(html[i]){
            case '<' :
            if(!s.empty()) {
                str = makeWord(); // 단어 만들기
                classifyWord(str); // 단어 분류
                str.clear(); // 초기화
            }
            s.push(html[i]); // 문자 넣기
            break;
            
            case '>' :
            // 문자 넣고 단어를 만듦
            s.push(html[i]);
            str = makeWord();
            classifyWord(str);
            str.clear();
            break;
			
            // 알파벳이나 공백일 경우 그냥 넣음
            default:
            s.push(html[i]);
        }
    }
}

 

시행착오


푸는데 이틀 걸렸다.
포기하려고 했다가 인터넷 찾아봐도 C++로 된 풀이는 없더라.


진짜 구현문제 토 나온다.
문자열 파싱도 그래도 이 정도로 맛봤으면 어느 정도는 할 수 있겠지.

이 문제를 풀면서 덕분에 find와 compare의 차이
, 함수를 체계적으로 짜야겠다는 필요성

trim 함수가 필요하다. 뿌듯함. 여러 가지 많이 느꼈다.


구현 문제 잘 풀고 싶다. 아니 그냥 알고리즘 좀 잘하고 싶다.