루비를 사용해보니
Ruby Competitive Programming
2024-11-03
루비를 써봤습니다.
지난 번 글 이후로, NeoVim 으로도 루비 환경을 구축하고 루비로 문제를을 풀어봤습니다.
익숙지 않아서인지 C++ 보다 어쩐지 하기 어려웠지만, 몇 가지 괜찮은 문법이나 느낀 점 들에 있어 소개하고자 합니다.
입출력
알고리즘 문제를 풀다 보면, 문제의 입력을 받는 일은 부지기수입니다. 이 경우, 처리하는 방법들이 언어 따라서 조금씩 달라집니다만, 루비의 경우 대개의 경우 gets
를 쓰면 해결되는 경우가 많았습니다.
gets
는 줄 단위의 입력으로, 개행 문자 \n
가 있다면 거기까지, 아닌 경우 문자열의 끝까지 읽어서 문자열 형태로 반환해주기 떄문에, 대부분 정수 여러 개를 한 줄에 입력받는 경우 이런 처리가 유효했습니다.
N, M = gets.split.map! { |x| x.to_i }
gets
를 split
한 결과물에 map
메소드를 이용해 배열을 만들고, 그 각각에 대새 to_i
연산을 하여 정수로 만들어서 N
과 M
에 각각 넣어줍니다.
한 줄에 들어온다는 것이 보장된다면, 비슷하게 여러 인자를 처리할 수 있습니다.
일단 PS 관점에서는 이 정도로 써먹지만, CLI 같은 데서 파싱을 할 땐 꽤나 유용해 보입니다.
출력의 경우 대표적으로 print
puts
p
등이 있습니다.
print
의 경우 모두가 예상하는 대로 파이썬의 그것과 큰 차이가 없이 동작합니다.
puts
도 대체로 비슷하게 쓰이며, 자동 줄바꿈이 된다는 점이 차이인 듯 합니다.
마지막으로 p
의 경우 뭔가 개발자가 좋아할만하도록 포맷을 맞춰 디버깅하기 좋은 출력을 해줍니다.
따라서 일반적으로 문제를 풀다 출력을 할 일이 있다면 puts
를 사용하게 되었습니다.
print("1")
# 1
print "1"
# 1
puts "1", "2"
# 1
# 2
p "1"
# '1'
출력의 경우에도 속도는 대동소이하다는 결론들을 많이 찾아볼 수 있었습니다.
반복문
루비의 경우에도 일반적인 언어의 for
while
등의 반복문이 기본적으로 있고, 사용법도 크게 다르지 않습니다.
다만 루비스러운 방법들이 몇 존재하는데 다음과 같이 사용할 수 있습니다.
# 일반적으로 순회가 가능한 객체의 경우
items.each do |item|
# do something
end
# 문자열에서 문자 하나하나를 순회하는 경우
str.each_char do |char|
# do something
end
저런 식의 반복문 구조에서 |
사이에 반복자를 이름을 넣고 쓸 수 있습니다.
자바스크립트의 forEach
메소드나 파이썬의 for item in items
등과 비슷한 느낌입니다.
그리고 다음과 같은 범위 메소드들이 있습니다.
# [1, N]
(1..N).each do |x|
# do something
end
# [1, N]
for x in 1..N
# do something
end
# [0, N)
(0...N).each do |x|
# do something
end
# [0, N)
N.times do |x|
# do something
end
이 외에도 여러가지가 존재합니다만, 일단 저것들만 봅시다.
기본적으로 정수형 두개를 ..
로 이으면 닫힌 구간이 되는 모양입니다. 반대로, ...
로 이으면 반열린 구간이 됩니다.
여기서 아까 보았던 each
메소드를 이용해 반복을 할 수 있습니다.
또 하나 신기한 점은, Numeric
클래스에 times
라는 메소드가 있어 비슷한 기능을 하도록 할 수 있다는 것입니다. 꽤나 신기합니다.
실제로 문제를 풀었을 때
그럼 대충 신기한 문법들도 살펴봤으니, 실제로 쓸 때를 생각해봅시다.
우선 기본적으로 스크립팅 언어고, 짧은 코드로 나와주다 보니 코딩하는데 걸리는 시간 자체는 짧은 편입니다.
다만, 느립니다.
파이썬과 비견될 정도로, 혹은 그 이상으로 대부분의 경우에서 느리게 동작하기 때문에, 잘 정의된 일부 내장 메소드들을 활용하거나 재미로 쓰는게 아니라면, PS에서는 얌전히 C++ 나 파이썬을 쓰는게 이득이라는 결론입니다.
대표적인 예시를 들자면, 다음은 BOJ 22343번 괄호의 값 비교 문제를 풀러 작성한 코드입니다.
# 주어지는 문자열은 전부 올바른 괄호 문자열임이 보장된다.
# 따라서 닫는 괄호가 나왔을 때, 정확히 이전에 여는 괄호가 등장했을 때만,
# 그 시점까지 누적된 괄호의 깊이에 해당하는 비트를 켜주는 전처리를 하고,
# 이를 단순 비교하면 되므로, 선형 시간에 해결이 가능하다.
def calc(s)
sum = Array.new(1 << 21) {0}
depth = 0
prv = 0
s.each_char do |ch|
if ch == '('
depth += 1
prv = 1
else
depth -= 1
sum[depth] += prv
prv = 0
end
end
return sum
end
def comp(a, b)
l = a.length - 1
l.downto(0).each do |i|
if a[i] > b[i]
return ">"
elsif a[i] < b[i]
return "<"
end
end
return "="
end
T = gets.to_i
for t in 1..T
A = calc(gets.chomp)
B = calc(gets.chomp)
[A, B].each do |arr|
for i in 0...(arr.length - 1)
arr[i + 1] += arr[i] >> 1
arr[i] &= 1
end
end
puts comp(A, B)
end
주석 부분에 서술한 풀이로, 이 문제는 선형에 충분히 풀릴 만한 제한을 가지고 있습니다.
하지만 저 루비 코드의 경우 어이없게도 시간 초과 였습니다.
혹시나 해서 똑같은 풀이를 C++ 로 작성해 시험해 본 결과, 결론은 루비의 배열 연산이 매우 비싸다 라는 것이었습니다.
문제의 경우 주어지는 모든 N
의 최대 합은 3,000,000
까지였기에, 넉넉하게 200만 정도 크기의 배열을 선언하고 10번의 테스트 케이스가 들어오더라도, 2,000만 정도에 비례한 연산이라면 충분할 것이라 생각했습니다만, 루비의 경우 아니었나 봅니다…
결국 배열의 크기를 동적으로 할당해 필요한 만큼만 사용하고서야 AC를 받을 수 있었습니다. 참고로 파이썬의 경우도 비슷한 상황이 발생하며, 파이파이의 경우엔 여유롭게 통과하는 모습을 보였습니다.
여튼 결론적으로 루비는 느린게 보통입니다. 하지만 사용하기에 따라서, 효율적인 코드를 오래 걸려서 짜는 것보다, 도는 코드를 빠르게 짜는게 중요할 때가 올 수도 있는 법이고, 아는 건 많아서 나쁠 건 없다는 생각입니다.