예전에 '온라인저지 구축기' 라는 글을 올린 적이 있었는데, HUSTOJ가 버전업이 계속 되었고, 설치방법에 대한 문의가 있어 다시 적어봅니다.


설치 OS : Ubuntu 16.04.2 LTS

HUSTOJ repo : https://github.com/zhblue/hustoj (2018년 2월 28일 버전)



1. git을 설치한다.

sudo apt-get install git 



2. git clone으로 저장소에서 파일을 복사해온다.

아래 명령어는 항상 최신 버전을 받아오기 때문에, 이전 버전이 필요하다면 github에서 이전 커밋을 찾아보아야 한다.

git clone https://github.com/zhblue/hustoj



3. 받은 파일의 trunk/install 경로에서 install-ubuntu16+.sh 파일을 실행한다.

여기서는 문제 발생을 위해 기본 설정을 따를 것이므로, mysql이나 nginx가 이미 설정되어 있는 환경에서는 충돌이 발생할 수도 있다. (위의 repo에서 Docker 이미지도 제공하고 있으므로, 도커 사용에 익숙하다면 해당 이미지를 이용하여 쉽게 설치할 수 있다.)

sudo bash ./install-ubuntu16+.sh



4. 설치 도중에 mysql을 설치하고, 비밀번호를 설정하는 창이 나온다. 비밀번호를 입력하여 설치를 계속한다.


5. 수 분을 기다리면 설치가 완료된다. 경우에 따라 경고 메시지가 뜰 수 있는데, 심각한 내용이 아니면 일단 넘어간다. judge 라는 리눅스 계정이 생성되고, /home/judge 라는 홈 디렉터리가 만들어진다. 이 경로에 들어가서 파일이 제대로 설치되있는지 확인한다.



6. ps -ef | grep judged 를 실행하여 judged 데몬이 제대로 실행되고 있는지 확인한다.



7. 브라우저를 열고 127.0.0.1 (원격 접속중이라면, 해당 서버의 IP) 으로 접속하여 페이지가 잘 나오는지 확인한다.



8. /home/judge/src/web/include/db_info.inc.php 을 수퍼유저 권한으로 열어서(sudo) 언어를 수정한다. 언어 설정과 관련되어 있는 'cn'(중국어)이 2개 있다 이것을 en으로 바꿔준다.

zh-CN 라고 되어있는 것은 en-US로 수정한다.



9. 이제 관리자 계정을 생성해야 하는데, 기본 관리자 계정이 없어서 아이디를 만들고 DB를 수정해야 한다. 상단의 Login 메뉴에서 회원가입을 한다.



10. mysql -u root -p로 mysql 클라이언트를 열고 4번에서 지정한 비밀번호를 입력한다.



11. use jol; 을 입력하여 데이터베이스를 선택한다.



12. INSERT INTO privilege(user_id, rightstr) VALUES('생성한 아이디', 'administrator'); 쿼리를 입력하여 유저권한을 지정한다.



13. exit를 쳐서 DB에서 빠져나온다.



14. 9번에서 만든 관리자 ID로 로그인한다. 문제와 테스트케이스를 만들고 저장한다. 추가적인 테스트케이스를 입력하라는 버튼이 나오는데, 이 페이지는 다음에 다시 들어갈 수 있다. 관리자 페이지와 문제 추가에 대한 설명은 생략한다.



참고) 테스트용 문제를 추가할 때, 입력 테스트케이스에 아무 숫자나 입력하고, 출력 테스트케이스에는 Hello 등의 간단한 문자열을 지정한다. 그러면 테스트용 코드를 작성할 때 간단하다. 테스트 케이스를 입력하지 않으면 채점이 되지 않을 수도 있다.



15. 관리자 페이지에서 빠져나와, 문제를 풀고 평가가 제대로 되는지 확인한다. Pending이 지속되지 않고, Accepted 혹은 Wrong Answer, Compile Error 등의 결과가 나오면 성공!


16. 관리 페이지의 문제 리스트에서 Reserved 되어 있는 문제의 status를 클릭하여 Available 상태로 만든다. 이 상태가 되면 관리자가 아닌 일반 유저도 문제를 볼 수 있게 된다.




※ Troubleshootings

- /home/judge/ 디렉터리 내에서 hustoj 관련 파일을 찾을 수 없을 때

install-ubuntu16+.sh 파일 실행 중에 실패한 것이다. 슈퍼유저 권한을 주지 않고 실행했거나, 기존에 설치되어 있던 mysql 또는 nginx 설정과 충돌했을 가능성이 있다. install 파일을 실행할 때 발생한 출력 메시지를 확인하여 에러를 수정한다.


- 웹 사이트가 뜨지 않을 때

에러 메시지에 따라 nginx나 mysql 설정을 확인한다. (구글링..)


- 코드를 제출했는데 pending 상태에서 무한히 대기할 때

ps -ef | grep judged 명령으로 judged 데몬이 실행중인지 확인한다. 실행중이라면 프로세스를 (kill -9 등으로) 죽이고 재실행해 본다. 실행중이 아니라면, 실행한다.(sudo service judged start)

문제가 해결되지 않으면 /home/judge/src/core/judged 경로로 들어가 해당 프로세스를 포그라운드로 실행하여 에러 메시지를 확인 후 조치한다. 파일 접근 권한 문제이거나 DB 접속 문제라면 관련 에러가 뜰 것이다.

만약 아무 에러도 뜨지 않는다면, 관리 페이지에서 테스트케이스 데이터를 확인해 본다.


- 입력 테스트케이스의 문자열 길이가 잘못 측정될 때

테스트케이스 추가페이지에서는 줄바꿈에 \r\n을 사용한다. 이 부분은 관리페이지의 내부 php 코드를 수정하여 캐리지리턴(\r)을 빼고 라인피드(\n)만 저장되도록 수정해야 한다. (이런 잔문제들이 많다..)




HUSTOJ를 사용하여 https://judge.zetagate.com을 운영하고 있었으나, 여러가지 문제가 많고, 확장성이 없다고 판단하여 새로 제작 중이다. https://github.com/ice-judge 아직 Github 저장소만 만들어 놓은 시작 단계다...





 기수정렬(Least Significant Digit 방식) 관련 글들을 찾아보면 개념을 쉽게 설명하기 위해 십진수 단위를(base 10) 사용한 것들이 많은데 실제로 이것을 구현하면 생각보다 느린 것을 볼 수 있다. 나눗셈 연산과 나머지연산이 수행되기 때문이다.  예를 들어 100의 자리에서 기수정렬할 단계를 보면, (현 상태에서 m은 100이라고 가정한다.)


arr[i]/m%10    


위와 같이 / 연산과 % 연산이 사용된다.



 이러한 문제를 개선하기 위해 버킷의 개수를 2의 거듭제곱으로 하면 속도가 빠른 비트연산을 사용할 수 있다. 버킷의 개수를 8로 하면 위 연산식이 아래와 같이 된다. (현 상태에서 m은 64라고 가정)


(arr[i]>>m)&7


2의 거듭제곱으로 나누었을 때의 나머지는, 굳이 나누어서 계산해볼 필요 없이 해당 위치의 비트만 AND연산으로 추출하면 되는 것이다.




구현


 아래 코드는 0부터 99,999,999까지의 정수 데이터들을 정렬할 수 있는 기수정렬(base 8) 알고리즘을 코드로 구현한 것이다. 

     for(int i=0; i<9; ++i) {


           idx = 0;

for(int j=0; j<8; ++j) { counts[j] = 0; } for(int j=0; j<n; ++j) { digit = (arr[j]>>i*3)&7; bucket[digit][counts[digit]++] = arr[j]; } for(int j=0; j<8; ++j) { for(int k=0; k<counts[j]; ++k) { arr[idx++] = bucket[j][k]; } } }


비트시프트 연산을 보면, i는 데이터를 8진수로 나타냈을 때 특정 자리의 숫자를 얻을 때 사용된다. i를 8이 될때까지만 반복시키는 이유는  으로, 99,999,999를 8진수로 나타낼 수 있는 가장 큰 자릿수가 8이기 때문이다.




수행시간 분석


 base 8 기수정렬에서 전체 데이터의 양을 N개라고 했을 때 버킷의 크기는 bucket[8][N]이다. base 8 대신 base 4나 base 2를 사용하면 메모리 사용량은 각각 2배, 4배 줄어들게 된다. 반면, 기수정렬의 시간복잡도가 O(dN)이므로, 수행시간은 더 늘어난다. (d는 해당 base에서 데이터의 최대 자릿수이다.) 표 1은 내림차순으로 정렬된 100만개 ~ 500만개 데이터를 오름차순으로 정렬했을 때 각 알고리즘의 수행 시간(ms)이다. 표 2는 랜덤함수로 생성한 무작위 데이터를 오름차순으로 정렬했을때의 결과이다.


표 1


표 2




 표 1의 결과를 그래프로 나타내 보면 다음과 같다.




 base 8 대신 16이나 32를 사용하면 메모리 사용량이 늘어나고, 속도가 약간 줄어들겠지만, 값 복사시간이 기본적으로 존재하기 때문에 속도가 줄어드는 데에는 한계가 있다. 수행시간이 카운팅 정렬을 사용했을때 이하로 떨어지지는 않을 것이다.





2차원 평면상에서 선분 AB와 CD의 정보가 주어질 때, 두 선분의 교차 여부를 빠르게 판단할 수 있는 방법이 있다. 벡터의 외적(벡터곱)을 이용하는 방법이다.


그림 1과 같이 두 선분이 교차하는 경우를 생각해 보자. 벡터 BA를 기준으로 점 C와 점 D가 서로 다른 회전방향에 있으므로, 아래 두 경우는 벡터곱 연산의 결과로 나온 z값의 부호가 다르다.

- 벡터 BA와 벡터 BC를 외적한 결과

- 벡터 BA와 벡터 BD를 외적한 결과


벡터 DC에 대해서도 역시 부호가 다르게 된다.

- 벡터 DC와 벡터 DA를 외적한 결과

- 벡터 DC와 벡터 DB를 외적한 결과 


     

그림 1. 교차하는 두 선분                                  


즉, 벡터곱 연산을 이용하여 한 선분의 벡터를 기준으로, 다른 선분의 두 점들이 같은 회전뱡향에 있는지, 다른 회전방향에 있는지 검사하면 교차 여부를 판단할 수 있는 것이다.
두 선분에 대해 이러한 판단을 수행하여 모두 만족하면 교차하는 것으로 판정한다.


그림 2에서는 벡터 CD에 대해 다른 선분의 두 점(A, B)는 서로 다른 회전방향이지만, 벡터 AB에 대해서는 두 점(C ,D)가 같은 회전방향에 있기 때문에 교차하지 않는 것으로 판정한다.


 

그림 2. 교차하지 않는 두 선분



Javascript로 표현하면 아래와 같이 쓸 수 있다.

    let p1 = line1.p1;

    let p2 = line1.p2;

    let p3 = line2.p1;

    let p4 = line2.p2;


    let sign1 = (p2.x-p1.x)*(p3.y-p1.y) - (p3.x-p1.x)*(p2.y-p1.y);

    let sign2 = (p2.x-p1.x)*(p4.y-p1.y) - (p4.x-p1.x)*(p2.y-p1.y);


    let sign3 = (p4.x-p3.x)*(p1.y-p3.y) - (p1.x-p3.x)*(p4.y-p3.y);

    let sign4 = (p4.x-p3.x)*(p2.y-p3.y) - (p2.x-p3.x)*(p4.y-p3.y);


    return sign1*sign2<0 && sign3*sign4<0;




그러나 위 코드에는 문제점이 있는데, 그림 3과 같이 두 선분의 기울기가 같을 때는 교차하지 않는 것으로 판단한다는 것이다. (이런 결과를 의도했다면 문제가 되지 않겠지만..)


그림 3. 두 선분의 기울기가 같고, 교차점이 무수히 많은 경우



그림 3과 같은 경우에도 두 선분이 교차하는 것으로 판정하기 위해, 비교문을 아래와 같이 고치면 또다른 문제가 발생한다. 그림 4와 같이 기울기가 같고 서로 떨어져 있을 때 잘못 판정한다는 것이다.

return sign1*sign2<=0 && sign3*sign4<=0;



그림 4. 교차 여부를 잘못 판정한 경우




두 선분이 완전히 떨어져 있을 경우(더 정확히 말하면, 각각의 선분이 이루는 직사각형 영역이 서로 겹치지 않는 경우)에 false를 리턴하면 이 문제를 해결할 수 있다. 외적 연산에 앞서 사전 영역 검사를 하는 것이다.

아래 코드에서 모든 부호가 0일 때 true를 반환하는 부분을 추가한 이유는, 그림 3과 같이 교차점이 무수히 많은 경우도 교차하는 것으로 판단하기 위해서이다.


    let p1 = line1.p1;

    let p2 = line1.p2;

    let p3 = line2.p1;

    let p4 = line2.p2;


    if(Math.max(p1.x, p2.x) < Math.min(p3.x, p4.x)) return false;

    if(Math.min(p1.x, p2.x) > Math.max(p3.x, p4.x)) return false;

    if(Math.max(p1.y, p2.y) < Math.min(p3.y, p4.y)) return false;

    if(Math.min(p1.y, p2.y) > Math.max(p3.y, p4.y)) return false;


    let sign1 = (p2.x-p1.x)*(p3.y-p1.y) - (p3.x-p1.x)*(p2.y-p1.y);

    let sign2 = (p2.x-p1.x)*(p4.y-p1.y) - (p4.x-p1.x)*(p2.y-p1.y);


    let sign3 = (p4.x-p3.x)*(p1.y-p3.y) - (p1.x-p3.x)*(p4.y-p3.y);

    let sign4 = (p4.x-p3.x)*(p2.y-p3.y) - (p2.x-p3.x)*(p4.y-p3.y);


    if(sign1==0 && sign2==0 && sign3==0 && sign4==0) return true;


    return sign1*sign2<=0 && sign3*sign4<=0;



그림 5. 교차 여부를 정상적으로 판정한 경우




경우에 따라서 그림 6과 같이 선분의 끝점이 교차할 때는 교차하지 않는 것으로 판단하는 것이 필요할 때가 있다.

 

그림 6, 7. 두 선분이 교차하는 것으로 판단하는 경우



아래 코드에서와 같이 사전 영역 검사에 동등 조건을 넣어  좀더 엄격하게 바꾸고(그림 6과 관련), 마지막의 리턴 코드에서 동등 조건을 제거한다.(그림 7과 관련)


    let p1 = line1.p1;

    let p2 = line1.p2;

    let p3 = line2.p1;

    let p4 = line2.p2;


    if(Math.max(p1.x, p2.x) <= Math.min(p3.x, p4.x)) return false;

    if(Math.min(p1.x, p2.x) >= Math.max(p3.x, p4.x)) return false;

    if(Math.max(p1.y, p2.y) <= Math.min(p3.y, p4.y)) return false;

    if(Math.min(p1.y, p2.y) >= Math.max(p3.y, p4.y)) return false;


    let sign1 = (p2.x-p1.x)*(p3.y-p1.y) - (p3.x-p1.x)*(p2.y-p1.y);

    let sign2 = (p2.x-p1.x)*(p4.y-p1.y) - (p4.x-p1.x)*(p2.y-p1.y);


    let sign3 = (p4.x-p3.x)*(p1.y-p3.y) - (p1.x-p3.x)*(p4.y-p3.y);

    let sign4 = (p4.x-p3.x)*(p2.y-p3.y) - (p2.x-p3.x)*(p4.y-p3.y);


    if(sign1==0 && sign2==0 && sign3==0 && sign4==0) return true;


    return sign1*sign2<0 && sign3*sign4<0; 





결론


만들고자 하는 프로그램에 따라 선분 끝점을 어떻게 판단해야할지가 달라진다. 위 코드를 활용하여 비교문들을 조정하면 선분의 교차 판단을 목적에 맞게 할 수 있을 것이다.



 

소스코드


동작하는 전체 코드는(Javascript)는 아래 github에서 다운로드할 수 있습니다.

https://github.com/tibyte/algorithms/tree/master/line-intersection






출처 

Koreatech Online Judge(링크)


문제이해 

양의 정수를 1부터 일렬로 나열했을 때 (ex 123456789101112...) P번째에 오는 숫자를 구하는 문제이다.


문제접근 

자리수가 1개인 수(1~9), 2개인 수(10~99), 3개인 수(100~999), ... 를 구분하여 계산한다


구현 

아래와 같은 규칙이 있다.

- 자리수가 1개인 수를 모두 썼을 때는 길이가 9인 문자열이 생성된다.

- 자리수가 2개인 수를 모두 썼을 때는 길이가 90*2 = 180 인 문자열이 생성된다.

- 자리수가 3개인 수를 모두 썼을 때는 길이가 900*3 = 2700인 문자열이 생성된다.

만약 P 값으로 2889가 입력된다면, 그 수는 9+180+2700 이므로 세 자리 숫자 중 가장 마지막에 오는 숫자(999)일 것이다.


같은 방법으로, 임의의 P가 있을 때 이 수가 몇 자리 수로 이루어진 정수까지 썼을 때 나온 길이인지 계산할 수 있으며, 해당 자리 수를 가진 수 중 가장 작은 수까지 썼을 때 문자열의 길이도 구할 수 있다.


1부터 n까지 수를 일렬로 나열했을 때 문자열의 길이를 f(n)이라 한다면,

예를 들어, f(1000)과 f(10000)을 미리 계산할 수 있으므로, P가 이 사이의 값이라면 P는 네 자리 수까지 썼을 때의 값이다. 이 P값에서 f(1000)을 뺀 후 4로 나누고, 1000을 더하면, n값을 알 수 있는 것이다.  


실행결과 


코드
https://github.com/tibyte/algorithm-problems/blob/master/koreatech/1068/1068.cpp

출처

Koreatech Onlinejudge(링크)



문제이해

-50000이상 50000이하 정수로 이루어진 배열이 입력된다. 두 개의 수를 제외한 나머지 수는 모두 2개씩 존재한다. 단 하나씩만 존재하는 두 수를 크기가 작은 것부터 출력하는 문제이다.



문제접근

유일한 수를 찾는 문제에서는(링크) 모든 배열원소들을 XOR연산으로 누적하여 쉽게 구할 수 있었다. 그러나 이 문제에서는 2개를 찾아야 하므로 단순히 XOR로는 구할 수 없다. 


1. 배열 이용

입력되는 수의 범위가 -50000부터 50000까지 100001크기의 범위이므로 크기 100001짜리 배열을 만들고, 모든 인자를 0으로 초기화한 후, 입력배열을 순차탐색하면서 해당 입력값을 인덱스로 하는 배열위치에 1을 더한다. 이 과정이 끝났을 때 1이 기록되어 있는 위치를 출력하면 된다. 이 방법의 한계는 입력되는 수의 범위가 더 클 경우 적용할 수 없다는 것이다.


2. std::map 이용

입력되는 수를 std::find()로 찾아서 값이 있으면 std::erase()로 지우고 없으면 추가한다.

최종적으로 남은 요소를 출력하면 된다

std::map은 rbtree로 되어있기 때문에 각 연산이 O(logn)이며 입력의 수만큼 반복하게 되므로 복잡도는 O(nlogn)이 된다.


3. 비트연산(XOR) 이용

(참고 : http://www.geeksforgeeks.org/find-two-non-repeating-elements-in-an-array-of-repeating-elements/)

XOR연산으로도 이 문제를 풀 수 있다. 우선 모든 수를 XOR연산으로 누적하면 하나의 수가 나오는데, 이것은 답으로 출력해야 할 두 수의 XOR연산결과이다. 이 결과에서 rightmost set bit(낮은 비트부터 봤을 때 1이 처음으로 나타나는 위치)을 찾는다.


예를 들어 XOR결과가 이진수로 1101000라고 하자. rightmost set bit을 제외한 나머지 비트들을 0으로 하면 0001000이 된다. 이것은 모든 입력값들 중에서 해당 위치에 1이 쓰여진 원소가 홀수 개라는 뜻이 된다. 즉, 구하고자 하는 답 중에서 하나는 해당 위치에 1이 쓰여진 원소들 중에서 나오고, 다른 하나는 해당 위치에 0이 쓰여진 원소들 중에서 나온다는 것이다.


이제 다시 모든 원소들을 위에서 구한 XOR값(위 예시에서는 0001000)과 비트 AND 연산을 하면서, 결과가 1인 경우와, 결과가 0인 경우로 나누어 각각 XOR연산으로 누적한다. 

 for(int i=0; i<N; ++i) {

if(arr[i]&det) {

xora ^= arr[i];

}

else {

xorb ^= arr[i];

}

}

문제의 조건에 따라, 구한 두 값을 크기순서로 출력하면 된다.



구현

이 문제에서 필요한 rightmost set bit의 형태는 위치값이 아니라, 해당 비트만 1로 세팅된 수이기 때문에 아래와 같은 간단한 연산으로 구할 수 있다.  (1101000을 예시로 하여 직접 계산해 보면 이해할 수 있다.)

num&(~num+1)


이 연산을 잘 보면, 2의보수를 구하고 있는 형태이기 때문에

num&-num 으로도 쓸 수 있다.



코드
https://github.com/tibyte/algorithm-problems/blob/master/koreatech/1074/1074.c

출처

Koreatech OnlineJudge(링크)



문제이해

5000자 이하의 문자가 입력으로 주어지고, 임의의 위치에 문자를 추가하여 펠린드롬으로 만들 때 추가하는 문자의 개수를 최소화하면, 몇 개인지를 구하는 문제이다.



문제접근

동적 프로그래밍 문제이다.

인덱스 a부터 인덱스 b까지의 구역에 대해 펠린드롬을 만들기 위한 최소비용을 D[a][b]라고 하자. D[a][b]를 한 단계 작은 계산결과로 구하기 위해 두 가지 조건을 따져봐야 한다.


1. 구역의 맨 앞과 맨 뒤가 같은 문자인 경우

D[a][b] (구역의 전체)는 D[a+1][b-1] (회색 부분)와 같다.



2. 구역의 맨 앞과 맨 뒤가 다른 문자인 경우

D[a][b] = 1+min(D[a][b-1], D[a+1][b])

a부터 b-1까지의 부분구역과, a+1부터 b까지의 부분구역 중에서 비용이 작은 것을 선택하여 거기에 1을 더한 값을 D[a][b]로 한다.


Top-down 방식으로 점화식을 해결하면 시간복잡도가 지수함수가 되므로, 아래에서부터 누적하여 구해야 한다.


구간의 길이를 1부터 전체까지 늘려 가며 데이터를 얻는 것이다.

전에 포스팅했던 이 문제와 같은 아이디어이다.





구현

다음과 같이 전처리하면 복잡하지 않게 반복문을 짤 수 있다.

- 구간의 길이가 1인 경우 D값은 0으로 한다.

- 구간의 길이가 2인 경우 D값은, 두 문자가 같을 때 0, 다를 때 1로 한다.



코드
https://github.com/tibyte/algorithm-problems/blob/master/koreatech/1060/1060.cpp



출처 

Koreatech OnlineJudge(링크)



문제이해 

입력으로 두 수 A, B가 주어지고 A/B를 순환마디를 표시하여 출력하는 문제이다.

예를 들어 0.123579579579... 를 출력한다고 하면 0.123(579)와 같이 출력하는 것이다.

만약 입력이 1, 4로 계산결과가 0.25가 되어 나누어 떨어진다면 0.25(0)처럼 표현한다.



문제접근

출력해야 할 수가 그림과 같이 0.123579579579... 라면

순환마디 시작점인 a와 끝점인 b를 구해야 한다.


그렇다면 순환마디가 어디까지인지는 어떻게 구해야 할까?

나눗셈을 할 때 매 과정마다 몫과 나머지가 나오고 나머지를 다시 나누는 과정을 반복하는데, 매 과정마다 이 나머지 값을 저장해 두고, 저장됐던 나머지와 같은 값이 나올 때 반복을 종료한다. 이 때 해당 나머지 값이 처음 나온 부분이 순환마디의 시작이며, 다시 나온 부분이 순환마디의 끝이다.



구현

매 과정마다 나머지 값이 이미 저장되어 있는지 검사해야 하므로 STL map을 사용하면 적절하다. STL map은 레드-블랙 트리를 사용하고, map::find()는 O(logn)으로 어떤 원소가 존재하고 있는지 검사할 수 있다.

반복문의 종료조건은 아래와 같이 두 가지이다.

- 나머지가 0이 되었을 때 (유한소수)

- map::find()값이 map::end()와 다를 때 (나머지값이 중복하여 등장했을 때) 


printf()함수를 사용하여 출력할 때

printf("%.*s", length, str);

의 형태로 사용하면 str이라는 char배열을 length길이만큼 출력할 수 있다.



실행결과



코드

코드에서 for(;~scanf.... 와 같이 되어 있는 부분은 문제를 빨리 풀기 위해 작성한 것으로, 데이터를 입력할 때 EOF를 입력해 주어야 프로그램이 제대로 완료된다.

https://github.com/tibyte/algorithm-problems/blob/master/koreatech/1008/1008.cpp




출처

Koreatech OnlineJudge(링크)



문제이해 

전화번호를 누를 때 연속된 각 번호가 상하좌우로 한 칸씩만 떨어져 있는 번호를 '걸기 쉬운 전화번호'라고 한다. 예를 들어 1236987412는 '걸기 쉬운 전화번호'이다.

999와 같이 같은 숫자가 연속하여 나오면 이에 해당하지 않는다.

길이가 N인 '걸기 쉬운 전화번호'를 구하는 문제이다.



문제접근

DP(동적 프로그래밍)로 해결할 수 있는 문제이다.


길이가 n이고, m으로 끝나는 '걸기 쉬운 전화번호'를 f(n, m)으로 정의한다.

그러면 길이가 n인 '걸기 쉬운 전화번호'의 개수는 

이다.


그리고 길이가 n일 때의 경우의 수는 길이가 n-1일 때를 사용하여 구할 수 있다.

예를 들어, f(n, 2)는 f(n-1, 1) + f(n-1, 3) + f(n-1, 5)로 나타낼 수 있는데, 2와 인접해 있는 숫자가 1, 3, 5이기 때문이다.

다시 말하면 끝자리가 1이나 3, 또는 5인 전화번호 다음에만 2가 붙을 수 있다는 것이다.


이런 방법으로 0부터 9까지의 숫자에 대해 점화식을 세워 보면 다음과 같다.


f(n, 0) = f(n-1, 8)

f(n, 1) = f(n-1, 2) + f(n-1, 4)

f(n, 2) = f(n-1, 1) + f(n-1, 3) + f(n-1, 5) 

f(n, 3) = f(n-1, 2) + f(n-1, 6)

f(n, 4) = f(n-1, 1) + f(n-1, 5) + f(n-1, 7)

f(n, 5) = f(n-1, 2) + f(n-1, 4) + f(n-1, 6) + f(n-1, 8)

f(n, 6) = f(n-1, 3) + f(n-1, 5) + f(n-1, 9)

f(n, 7) = f(n-1, 4) + f(n-1, 8)

f(n, 8) = f(n-1, 0) + f(n-1, 5) + f(n-1, 7) + f(n-1, 9)

f(n, 9) = f(n-1, 6) + f(n-1, 8)


bottom-up 방식으로 메모이제이션을 하면 답을 구할 수 있다.

  


구현

각 번호에 대해, 길이가 1일 때의 초기값은 1로 한다.

f(1, 0) = f(1, 1) = f(1, 2) = ... = f(1, 8) = f(1, 9) = 1

이기 때문이다. (이 경우에 답은 10이 된다.)


길이가 2일때 위 0~9에 대해 점화식을 한 번 시행하면 되고,

길이가 3일때는 두 번 시행하면 된다.


주의할 점은 mod 1,000,000,007을 연산해야 한다는 것인데,

점화식에서 최대 4개의 항을 더하므로 32비트 unsigned int이상의 자료형을 사용하면 편하게 구현할 수 있다.



코드

https://github.com/tibyte/algorithm-problems/blob/master/koreatech/1057/1057.c







출처

Koreatech OnlineJudge(링크)



문제이해 

수열에서 수를 3개 뽑아서 합이 0이 되는 경우의 수를 구한다. 중복되는 경우는 계산하지 않는다.



문제접근

단순히 3중 for문으로 반복하여 찾으면 시간복잡도는 O(n^3)으로, 시간초과가 나서 통과하지 못한다.

세 수 i, j, k가 있을 때 i를 처음부터 한 칸씩 증가시키면서 각 i에 대해  j와 k를 찾으면 빠른데,

이 방법을 사용하려면 먼저 수열을 정렬해야 한다.


i+j+k > 0이면

k를 감소시키고,


i+j+k < 0이면,

j를 증가시킨다.


i+j+k = 0이면,

경우의 수를 한 번 카운트하고, k를 감소시키고 j를 증가시킨다.


그리고 반복문의 종료조건은 j와 k가 서로 교차했을 때이므로 아래 그림과 같은 경우까지 반복하게 된다.

시간복잡도는 O(n^2)이다.


이 부분을 코드로 보면 아래와 같다.

prv 변수는 중복되는 순서쌍을 제외하기위해 만든 변수이다.

(전체 코드는 맨 아래에 있음.)

while(j<k) {

if(arr[i]+arr[j]+arr[k] > 0)

--k;

else if(arr[i]+arr[j]+arr[k] < 0)

++j;

else {

if(prv != arr[j]) {

++cnt;

prv = arr[j];

}

--k;

++j;

}




구현 

수열을 정렬할 때 std::sort()를 사용하면 간단하다.

위에서 중복을 제거하는 부분이 있었는데, 바깥쪽 반복문에서 i, j, k 중 i가 중복되는 경우 또한 제거한다.



실행결과 



코드
https://github.com/tibyte/algorithm-problems/blob/master/koreatech/1005/1005.cpp




출처 

Koreatech OnlineJudge(링크)



문제이해 

수열을 각 부분당 2개 이상의 원소를 가지는 구간으로 나눌 때,

각 구간의 최댓값-최솟값 값의 합이 최대가 되도록 하려면

구간을 어떻게 나누어야 하는지 구하는 문제이다.



문제접근 

동적 프로그래밍(DP)으로 풀 수 있는 대표적인 유형이다.

문제 푸는 과정을 보면 다음과 같다.


j부터 j+i까지의 구간 S가 있을 때

이 구간을 j부터 k까지, k+1부터 j+i까지

두 개의 구간 Sa와 Sb로 나누는 것이 더 이득일 수 있다.

플로이드-워셜 알고리즘(Floyd-Warshall Algorithm)을 생각해 보면 이해가 쉽다.


즉, 수열 인덱스 j부터 j+i까지의 최대이득을 D[a][b]라고 하면, 

D[j][j+i] 보다 큰 D[j][k]+D[k+1][j+i]를 찾는 것이다.


큰 크기의 문제(D[j][j+i])를 풀기 위해 작은 크기의 문제(D[j][k]+D[k+1][j+i])

의 값이 필요하므로, 구간 길이가 2일 때부터 계산해야 한다.



수열의 길이가 5라고 가정하여 이 과정을 보면,

아래 그림과 같이 구간 길이가 2일때를 계산한다.



그 다음 구간의 길이가 3인 경우,



4인 경우,



마지막으로 5인 경우를 계산한다.



이 글 맨 마지막에 있는 코드를 보면 이해가 쉬울 것이다.



구현 

문제 조건에서는 구간 길이는 2보다 같거나 크지만, 실제 구현할 때에는 구간 길이가 1인 경우도 계산해야 규칙성 있는 반복문을 작성할 수 있다.

구간 길이가 1일 때의 해는 그 원소 자신의 값이 된다. 



코드
https://github.com/tibyte/algorithm-problems/blob/master/koreatech/1059/1059.c

+ Recent posts