понедельник, 31 марта 2008 г.

Разбор решений для задачи про календарные промежутки

Время для разбора решений для задачи про календарные промежутки =)

Примечание: если в приведенном коде встретился незнакомы метод или класс, то найти документацию можно из терминала с помощь утилиты ri или на сайтах http://gotapi.com, http://noobkit.com. Также можно использовать http://ru.wikibooks.org/wiki/Rubyдля русскоязычной документации по часто-употребительным методам встроенных классов. 

Само задание не представляет ничего сложного. У нас есть массив со строками, представляющими разные даты. Даты могут повторяться и массив не упорядочен. Требуется выделить из этих дат непрерывные временные диапазоны (то есть найти все последовательности дат, которые идут по календарю подряд)).

Сама постановка задачи предлагает решение - убрать все повторяющиеся даты, отсортировать их и выделить в отдельные группы даты, идущие друг за другом.  

Удаление дубликатов и сортировка делаются в одну строчку: 
dates.sort!.uniq!

Теперь остается реализовать метод для разделения идущие подряд даты на группы.

Посмотрим, как реализовали этот метод участники в своих решениях:


Версия Кирилла:
date_dzn_start=[]
date_dzn_end=[]
i=0
dates.length.times do
a=dates[i].split("-")
d=Date.new(a[0].to_i ,a[1].to_i,a[2].to_i)
if (d - 1).to_s != dates[i-1] then
date_dzn_start.push(d)
end
if (d + 1).to_s != dates[i+1] then
date_dzn_end.push(d)
end
i+=1
end
i=0

date_dzn_end.length.times do
if(date_dzn_start[i]!=date_dzn_end[i]) then
puts date_dzn_start[i]..date_dzn_end[i]
else
puts date_dzn_start[i]
end
i+=1
end

Каждый элемент массива сравнивается с соседними элементами, причем если текущий элемент не является следующей датой в календаре для левого элемента, или предыдущей датой - для правого, тогда мы его добавляем в массив с начальными датами диапазонов или конечными соответственно. 

Но при таком алгоритме мы делаем в два раза больше проверок, чем необходимо (предположим, что n - текущий индекс элемента массива, тогда мы должны сравнить n-элемент массива с (n-1)-элементом и (n+1)- 
элементом, но мы уже сравнивали n-элемент и (n-1)-элемент, когда (n-1) было текущим индексом).

Конечно, сам код можно сделать более рубиновым, к примеру, если не вручную поддерживать счетчик, а использовать автоматический предоставляемый методом times, и заменить условные конструкции их сокращенными формами. Вот, что получится, если учесть сказанное и заменить times более подходящими в данном случае методами each и each_with_index :

dates.each_with_index do |date, index|
date = Date.parse(date)
start_dates.push date if (date-1).to_s != dates[index-1]
end_dates.push date if (date+1).to_s != dates[index+1]
end

start_dates.zip(end_dates) do |start_date, end_date|
puts start_date == end_date ? start_date : start_date..end_date
end



Теперь перейдем к версии kaineer'a:
SEC_PER_HOUR = 3600
DAY_AND_HALF = ( 24 + 12 ) * SEC_PER_HOUR

def more_than_day( s1, s2 )
d1 = Time.mktime( *s1.scan( /\d+/ ) )
d2 = Time.mktime( *s2.scan( /\d+/ ) )

( d2 - d1 ) > DAY_AND_HALF
end

result = dates.inject( [] ) { |arr, str|
if arr.empty? || more_than_day( arr.last.last, str )
arr << [ str, str ]
else
arr.last[ 1 ] = str
end

arr
}.map{|arr|( arr.first )..( arr.last )}

puts result.inspect

здесь выбран правильный подход, когда каждый элемент сравнивается только с предыдущим элементом, и в зависимости от результатов проверки добавляется либо в последний временной диапазон, либо в новый. Но несмотря на это в решении все равно используется в два раза больше проверок, чем требуется, и все из-за лишних проверок на начальное состояние.

Еще пара мелких замечаний: нам не нужно при создании нового временного диапазона дважды добавлять текущий элемент и для выражения "puts object.inspect" есть альтернативный лаконичный синтаксис "p object". Ну, и опять же в этом решении не использовался класс Date, его метод parse и метод next объектов класса Date, что придало объема программе.

Устранив лишние сравнения и использовав Date.parse, мы получили довольно симпатичный вариант, хоть и не конечный:
result = dates.inject([[ Date.parse(dates.shift) ]]) do |ranges, date|
if (date = Date.parse(date)) == ranges.last.last.next
ranges.last[1] = date
ranges
else
ranges << [date]
end
end

p result.map {|it| "#{it.first}..#{it.last}" }


Нужно всегда выделять более универсальные алгоритмы, с целью их повторного использования. Поэтому выделим в отдельный метод Array#group_by_range разбивку на группы идущих подряд элементов:

class Array
def group_by_range
inject([[shift]]) do |result, it|
result << [] unless it == result.last.last.next
result.last << it
result
end
end
end

puts dates.group_by_range.map {|it| "#{it.first}..#{it.last}"}


Этот метод потом можно будет применить к любому массиву, главное чтобы его элементы поддерживали метод next. То есть в случае с массивом dates нужно всего лишь привести его элементы к классу Date:
dates.map! {|it| Date.parse it }


Ну и напоследок пользуясь случаем приведу пример (хоть и немного притянутый за уши) использования метода Object#tap:
class Object
def tap
yield self; self
end
end

Для этого мы незначительно модифицируем предыдущую версию и получим:
class Array
def group_by_range
inject([[shift]]) do |result, it|
result << [] unless it == result.last.last.next
result.tap {|array| array.last << it }
end
end
end

puts dates.group_by_range.map {|it| "#{it.first}..#{it.last}"}

Как видно с помощью Object#tap можно сделать несколько модификаций перед возвращением объекта из метода и тем самым немного улучшить его вид.

P.S.
Вместо последовательного использование методов uniq и sort было бы конечно лучше использовать что-то вроде sort_uniq (когда сортировка проводится с одновременным отбросом повторяющихся значений), потому что это было бы эффективнее, чем два прохода сначала для отсева повторяющихся значений, а потом сортировки. Главное то, что руби со своим набором готовых алгоритмических и дизайнерских решений позволяет быстро создать рабочий прототип
программы и учит главному правилу программирования - "откладывай оптимизацию на потом". Это позволяет проводить оптимизацию только действительно узких мест, а не всего подряд, что дает значительный прирост продуктивности разработчиков.

P.P.S.
В методе group_by_range не все в порядке, есть маленькая деталька, которую можно улучшить - жду кто первым заметит =)