Статьи

Алгоритмическая забава с хэшами

Футуристические Технологии

Все программисты их используют. Вы можете называть их «Карты», «Словари», «Хэш-карты» или «Ассоциативные массивы». В мире Ruby мы называем их Hashes, и они довольно крутые. Проще говоря, Hash — это набор пар ключ-значение. Он похож на массив, за исключением того, что он индексируется с помощью произвольных ключей любого типа объекта вместо инкрементного целого числа. Если вы посмотрите на Hash API , вы увидите, что он предлагает впечатляющий набор функций. В этой статье мы не будем рассматривать Hash API как таковой, а будем использовать хеши для моделирования, абстрагирования и решения общих логических задач.

Таблицы правды и алгоритм захвата

Многие люди используют хеши исключительно как простые таблицы поиска данных или как именованные параметры для методов. Это все хорошо, но мы можем сделать гораздо больше, используя присущие Hash гибкость и мощные функции для реализации логики и сложных алгоритмов. Давайте посмотрим на фаворита интервьюера, тест FizzBuzz. Это выглядит так:

«Напишите программу, которая печатает числа от 1 до 100. Для кратных 3 выведите« Fizz »вместо числа, а для кратных 5 -« Buzz ». Для чисел, кратных 3 и 5, выведите «FizzBuzz». »

У нас есть два условия, которые влияют на результат: ( n % 3 == 0 ) и ( n % 5 == 0 ). Как только мы это сделаем, решение проблемы будет простым:

 def conditional(n) if (n % 3 == 0) && (n % 5 != 0) 'Fizz' elsif (n % 3 != 0) && (n % 5 == 0) 'Buzz' elsif (n % 3 == 0) && (n % 5 == 0) 'FizzBuzz' else n end end 

Чтобы запустить FizzBuzz для последовательности от 1 до 100, мы делаем:

 (1..100).each {|n| puts conditional(n)} 

Это был кусок пирога! Но где Хэши входят в это? Ну, мы можем зафиксировать эту условную логику как хэш. Чтобы понять, как это сделать, запишите таблицу истинности для FizzBuzz:

(n% 3 == 0) (n% 5 == 0) исход
правда ложный ‘Fizz’
ложный правда ‘Buzz’
правда правда ‘FizzBuzz’
ложный ложный число

Обратите внимание, что столбец результатов состоит из дискретных, не равных нулю значений для любого заданного входного значения. Это было бы хорошим ключом для нашего хэша. Давайте перепишем нашу таблицу истинности, чтобы она соответствовала Hash:

ключ Значение
‘Fizz’ (n% 3 == 0) && (n% 5! = 0)
‘Buzz’ (n% 3! = 0) && (n% 5 == 0)
‘FizzBuzz’ (n% 3 == 0) && (n% 5 == 0)
число (n% 3! = 0) && (n% 5! = 0)

Смотри Ма, Нет, если заявления!

Теперь легко смоделировать наш хэш на таблице истинности:

 def declarative(n) h = { 'Fizz' => (n % 3 == 0) && (n % 5 != 0), 'Buzz' => (n % 3 != 0) && (n % 5 == 0), 'FizzBuzz' => (n % 3 == 0) && (n % 5 == 0), n => (n % 3 != 0) && (n % 5 != 0) } h.key(true) end 

Здесь мы используем Hash для реализации некоторого декларативного программирования. Мы не говорим нашему приложению, как рассчитать результат для каждого числа, мы просто объявляем, какие условия определяют ключи, и позволяем структуре данных выполнять подъем. Вот что приятно: поскольку два условия, используемые для определения ключей, являются взаимоисключающими, в хэше будет только одно истинное значение для ввода любого числа. Это означает, что правильный ключ для ввода будет иметь значение true а все остальные будут false , поэтому метод возвращает единственный ключ со значением true для данного ввода.

 puts declarative(2) #=> 2 puts declarative(3) #=> Fizz puts declarative(5) #=> Buzz puts declarative(7) #=> 7 puts declarative(15)#=> FizzBuzz 

Чтобы запустить FizzBuzz декларативно для последовательности от 1 до 100:

 (1..100).each {|n| puts declarative(n)} 

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

Можем ли мы достичь счастливого компромисса между элегантностью и производительностью? Да, мы можем!

мемоизации

Мемоизация — это просто метод хранения вычисленных или извлеченных данных для будущего использования. Также известен как кэширование для вас и меня. Хэши очень хороши для хранения вещей. Давайте еще раз пойдем в FizzBuzz, в стиле памятки:

 memoization ||= Hash.new do |hash,key| hash[key] = case when (key % 3 == 0) && (key % 5 != 0) 'Fizz' when (key % 3 != 0) && (key % 5 == 0) 'Buzz' when (key % 3 == 0) && (key % 5 == 0) 'FizzBuzz' else key end end 

Мы создаем Hash, который инициализируется правильным значением FizzBuzz для каждого передаваемого ему числового ключа. Запустите его для диапазона до 100:

 (1..100).each { |n| memoization[n] } 

Хэш памятки создан и сопоставил каждому ключу от 1 до 100 значение FizzBuzz. В следующий раз, когда нам понадобится значение FizzBuzz для числа в этом диапазоне, его получит простое Hash#fetch .

Мемоизация имеет несколько очень практичных приложений, помимо решения проблем в стиле FizzBuzz. Мы все используем Интернет, поэтому вот как мы могли бы реализовать некоторое грубое и надежное кэширование ответов, используя Hash:

 require 'net/http' http = Hash.new{|hash,key| hash[key] = Net::HTTP.get_response(URI(key)).body } puts http['http://www.google.com'] # make actual request puts http['http://www.google.com'] # get cached value 

Теперь давайте посмотрим на производительность. Вот тесты (на моей машине — ваши, очевидно, будут отличаться) при запуске каждого из наших решений FizzBuzz дважды для диапазона 1..1 000 000 .

пользователь система общее количество реальный
условный 0.230000 0.000000 0.230000
условный 0.240000 0.000000 0.240000
декларативный 0.990000 0.000000 0.990000
декларативный 0.980000 0.000000 0.980000
мемоизации 1.040000 0.020000 1.060000
мемоизации 0.370000 0.000000 0.370000

При первом запуске кода напоминания он работает очень медленно. Ruby должен создать Hash, а также выполнить условную логику. Но во 2-й раз , и каждый раз после этого, его выполнение значительно быстрее. Это потому, что Hash уже содержит все значения для этого диапазона, поэтому он просто должен искать их всякий раз, когда мы просим об этом, а не вычислять их заново.

Вы можете заметить, что, хотя быстр для последующих запусков, метод if..elsif все же не так быстр, как подход if..elsif . Это потому, что мы не получаем полного преимущества от кэширования, применяя его к простым линейным алгоритмам, таким как FizzBuzz. Чтобы максимально использовать запоминание, нам нужно использовать его в более сложной, полиномиальной или алгоритмической логике экспоненциального порядка.

Рекурсивные хеши

Рекурсия — типичная область для алгоритмов быстрого роста. Давайте посмотрим на классический пример рекурсии: факториальная проблема. Обычная рекурсивная реализация выглядит так:

 def factorial(x) x == 1 ? 1 : x * factorial(x-1) end 

Мы можем запомнить это с помощью хэша:

 factorial_hash ||= Hash.new do |hash,key| hash[key] = key * hash[key-1] end # initialize special cases fib_hash[1] = 1; fib_hash[2] = 2 

Здесь мы инициализируем Hash таким образом, чтобы заставить код инициализации быть выполненным n раз для ввода n . Когда мы в первый раз запустим, скажем, factorial_hash[5] код будет выполнен 5 раз, создав хэш для ключей 2-5. Затем каждый раз, когда нам нужно получить факториальное значение числа до 5, Hash просто сделает простой поиск и выберет правильное значение.

Выполните каждую технику дважды, чтобы получить факториал 3000:

 factorial(3000) factorial(3000) factorial_hash[3000] factorial_hash[3000] 

Вот время:

пользователь реальный
факториал 0.005829
факториал 0.006082
factorial_hash 0.011202
factorial_hash 0.000002

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

Давайте попробуем что-то еще более сложное: последовательность Фибоначчи . Вот стандартный подход:

 def fibonacci(n) n <= 2 ? 1 : fibonacci(n - 1) + fibonacci(n - 2) end 

и вот наш подход кеширования Hash:

 fib_hash ||= Hash.new do |hash,key| hash[key] = hash[key-1] + hash[key-2] end # initialize special cases fib_hash[1] = 1; fib_hash[2] = 1 

Запуск каждого подхода дважды, чтобы получить значение Фибоначчи в 35, дает следующие моменты времени :

 fibonacci(35) fibonacci(35) fib_hash[35] fib_hash[35] 
пользователь реальный
Фибоначи 0.987518
Фибоначи 0.981804
fibonacci_hash 0.000041
fibonacci_hash 0.000002

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

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

Вывод

Хэши — это не просто способ передачи параметров в методы. При правильном использовании они могут помочь сделать наш код более элегантным, быстрым или иногда одновременно. Однако нам необходимо знать об их недостатках и знать, как и когда их эффективно использовать.

Код, представленный в этой статье, можно найти в следующих разделах: