Что такое воспоминания, и действительно ли это полезно?

Существует несколько автоматических библиотек memoization, доступных в Интернете для разных языков; но не зная, для чего они предназначены, где их использовать и как они работают, может быть трудно увидеть их ценность. Каковы некоторые убедительные аргументы в пользу использования memoization и в какой проблемной области особенно важна memoization? Информация для неосведомленных была бы особенно оценена здесь.

Ответ 1

Популярный факторный ответ здесь - это что-то вроде игрушечного ответа. Да, memoization полезен для повторных вызовов этой функции, но отношение тривиально - в случае "print factorial (N) для 0..M" вы просто повторно используете последнее значение.

Многие из других примеров здесь просто "кэширование". Это полезно, но оно игнорирует удивительные алгоритмические последствия, которые несет в себе слово memoization.

Более интересными являются случаи, когда разные ветки единичного вызова рекурсивной функции попадают в идентичные подзадачи, но в нетривиальном шаблоне, так что фактически индексирование в некоторый кеш действительно полезно.

Например, рассмотрим n мерных массивов целых чисел, абсолютные значения которых суммируются с k. Например. для n = 3, k = 5 [1, -4,0], [3, -1,1], [5,0,0], [0,5,0] будут некоторые примеры. Пусть V (n, k) - число возможных уникальных массивов для данного n, k. Его определение:

V(n,0)=1; V(0,k)=0; V(n,k) = V(n-1,k) + V(n,k-1) + V(n-1,k-1);

Эта функция дает 102 для n = 3, k = 5.

Без воспоминаний это быстро становится очень медленным для вычисления даже для довольно скромных чисел. Если вы визуализируете обработку как дерево, каждый node вызов V(), расширяющийся до трех детей, у вас будет 186,268,135,991,213,676,920,832 V (n, 0) = 1 при вычислении V (32,32)... Реализованная наивно эта функция быстро становится доступной на доступном оборудовании.

Но многие дочерние ветки в дереве являются точными дубликатами друг друга, но не каким-то тривиальным способом, который можно легко устранить, как факторная функция. С помощью memoization мы можем объединить все эти повторяющиеся ветки. Фактически, с memoization V (32,32) выполняется только V() 1024 (n * m) раз, что является ускорением в 10 ~ 21 (что становится больше, когда n, k растет, очевидно) или так в обмене для довольно небольшого объема памяти.:) Я нахожу такое фундаментальное изменение сложности алгоритма, гораздо более захватывающего, чем простое кэширование. Это может сделать трудноразрешимые проблемы.

Поскольку числа python являются естественными bignums, вы можете реализовать эту формулу в python с помощью memoization, используя ключи словаря и кортежа всего в 9 строках. Сделайте снимок и попробуйте его без воспоминаний.

Ответ 2

По моему мнению, Фибоначчи и факториальные вычисления на самом деле не лучшие примеры. Воспоминание действительно приходит в себя, когда у вас есть:

  • Огромный диапазон потенциальных входов в расчет, о котором идет речь, но диапазон все еще явно ограничен и известен
  • Вы знаете заранее, что любое фактическое использование программы будет использовать только небольшое подмножество возможных входов для вашего расчета (Fibonacci и factorial fail this)
  • Вы не знаете, какие конкретные входы будут использоваться во время выполнения, и поэтому какие конкретные результаты нужно будет запомнить (Fibonacci и factorial тоже не могут это сделать, вплоть до точки).

Очевидно, что если вы делаем знаете все возможные входы, а пространство позволяет, вы можете рассмотреть возможность замены функции поиском (что я бы сделал для, скажем, встроенной реализации CRC32 с известным генератор).

... даже лучше, чем # 2, если для любого конкретного запуска программы вы можете сразу сказать: "диапазон потенциальных входов будет ограничен подмножеством, удовлетворяющим этим условиям...".

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


Вот пример, и я надеюсь, что он не слишком запутан (обобщен), чтобы быть информативным.

В некоторых прошивках, которые я написал, одна часть программы берет чтение из АЦП, которое может быть любым числом от 0x000 до 0xFFF и вычисляет вывод для какой-либо другой части программы. Этот расчет также принимает набор настраиваемых пользователем номеров, но они читаются только при запуске программы. Этот расчет довольно ударил при первом запуске.

Создание справочной таблицы досрочно смешно. Входной домен является декартовым произведением [ 0x000,..., 0xFFF] и (большой диапазон значений с плавающей запятой) и (еще один большой диапазон...) и... Нет благодарности.

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

Учитывая определение "медленно меняющихся условий", которое ожидает обычный пользователь, значение ADC переходит к определенному значению и остается в пределах примерно 0x010 от его заданного значения. Какое значение зависит от условий.

Таким образом, результат расчета может быть запомнен для этих 16 потенциальных входов. Если условия окружающей среды меняются быстрее, чем ожидалось, "самый дальний" АЦП, считанный с самого последнего, отбрасывается (например, если я кэшировал 0x210 до 0x21F, а затем читал 0x222, я теряю результат 0x210).

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

Ответ 3

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

Это часто важный метод решения проблем с использованием Динамическое программирование.

Представьте себе перечисление всех путей из верхнего левого угла сетки в нижний правый угол сетки. Многие пути перекрывают друг друга. Вы можете memoize решения, рассчитанные для каждой точки на сетке, построив из нижнего правого, обратно вверх влево-вверх. Это сокращает время вычислений от "смешного" до "сговорного".

Другое использование: Перечислите факториалы числа от 0 до 100. Вы не хотите вычислять 100! используя 100 * 99 * ... * 1. Вы уже рассчитали 99!, так что повторно используйте этот ответ и просто умножьте 100 раз ответ на 99!. Вы можете memoize ответ на каждом из этих шагов (от 1 до 100), чтобы сэкономить значительные суммы вычислений.

Для точки данных для моей задачи решения сетки выше (проблема связана с задачей программирования):

Memoized:

real  0m3.128s
user  0m1.120s
sys   0m0.064s

Не memoized (который я убил, потому что я устал ждать... так что это неполно)

real 24m6.513s
user 23m52.478s
sys   0m6.040s

Ответ 4

Воспоминание освещает проблемы, когда решения для подзадач могут быть повторно использованы. Говоря просто, это форма кэширования. Рассмотрим пример факториала.

3! является проблемой на ее собственной, но это также подзадача для n! где n > 3, такое как 4! = 4 * 3! Функция, которая вычисляет факториалы, может значительно улучшить с помощью memoization, потому что она будет вычислять только 3! один раз и сохранить результат внутри хэш-таблицы. Всякий раз, когда это встречается 3! снова будет выглядеть значение в таблице, а не пересчитывать его.

Любая проблема, при которой решения подзадач могут быть повторно использованы (чем чаще, тем лучше) является кандидатом на использование memoization.

Ответ 5

Воспоминание меняет время на пробел.

Воспоминание может превращать экспоненциальное время (или хуже) в линейное время (или лучше) при применении к проблемам, которые являются многорекурсивными по своей природе. Стоимость, как правило, O (n) пространства.

Классический пример - вычисление последовательности Фибоначчи. Определение учебника - это рекуррентное отношение:

F (n) = F (n-1) + F (n-2)

Реализован наивно, он выглядит так:

int fib(int n) {
  if (n == 0) {
    return 0;
  }
  else if (n == 1) {
    return 1;
  }
  else {
    return fib(n-1) + fib(n-2);
  }
}

Вы можете видеть, что время выполнения растет экспоненциально с n, потому что каждая из частичных сумм вычисляется несколько раз.

Реализованный с помощью memoization, он выглядит так (неуклюжий, но функциональный):

int fib(int n) {
  static bool initialized = false;
  static std::vector<int> memo;

  if (!initialized) {
    memo.push_back(0);
    memo.push_back(1);
    initialized = true;
  }

  if (memo.size() > n) {
    return memo[n];
  }
  else {
    const int val = fib(n-1) + fib(n-2);
    memo.push_back(val);
    return val;
  }
}

Сроки этих двух реализаций на моем ноутбуке, для n = 42, наивная версия занимает 6,5 секунды. Памятная версия занимает 0.005 секунды (все системное время, то есть привязка ввода/вывода). Для n = 50 меморированная версия все равно занимает 0.005 секунды, а наивная версия, наконец, заканчивается через 5 минут и 7 секунд (неважно, что оба из них переполнены 32-разрядным целым числом).

Ответ 6

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

Ответ 7

Одно из применений для формы memoization - в анализе дерева игр. При анализе нетривиальных игровых деревьев (думаю, шахматы, go, bridge) вычисление значения позиции является нетривиальной задачей и может занять значительное время. Наивная реализация просто использует этот результат, а затем отбрасывает его, но все сильные игроки будут его хранить и использовать, если ситуация снова возникнет. Вы можете себе представить, что в шахматах существует бесчисленное множество способов достичь той же позиции.

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

В AI использование такой memoization обычно называется "таблицей транспонирования".

Ответ 8

Запоминание по существу кэширует возвращаемое значение функции для данного ввода. Это полезно, если вы будете многократно повторять вызов функции с одним и тем же входом, и особенно, если функция выполняет некоторое время для выполнения. Конечно, поскольку данные должны быть где-то сохранены, memoization будет использовать больше памяти. Это компромисс между использованием процессора и использованием ОЗУ.

Ответ 9

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

Ответ 10

Я думаю, что в основном все освещали основы воспоминаний, но я дам вам несколько практических примеров, где moization можно использовать, чтобы сделать некоторые довольно удивительные вещи (imho):

  • В С# вы можете отобразить функцию и создать для нее делегат, тогда вы можете динамически вызывать делегат... но это ДЕЙСТВИТЕЛЬНО медленно! Это примерно в 30 раз медленнее, чем вызов метода напрямую. Если вы memoize вызов метода, то вы можете сделать вызов почти так же быстро, как вызов метода напрямую.
  • В генетическом программировании это может уменьшить накладные расходы, вызвав неоднократные вызовы той же функции с аналогичными входными параметрами для сотен и тысяч экземпляров в популяции.
  • При выполнении деревьев выражений вам не нужно продолжать переоценивать дерево выражений, если вы уже записали его в память...

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

В мой блог Я обсуждаю memoization и reflection отдельно, но я собираюсь опубликовать еще одну статью об использовании memoization для отраженных методов...

Ответ 11

В качестве примера того, как использовать memoization для повышения производительности алгоритма, следующее выполняется примерно на 300 раз быстрее для этого конкретного тестового примера. Раньше потребовалось ~ 200 секунд; 2/3 memoized.


class Slice:

    __slots__ = 'prefix', 'root', 'suffix'

    def __init__(self, prefix, root, suffix):
        self.prefix = prefix
        self.root = root
        self.suffix = suffix

################################################################################

class Match:

    __slots__ = 'a', 'b', 'prefix', 'suffix', 'value'

    def __init__(self, a, b, prefix, suffix, value):
        self.a = a
        self.b = b
        self.prefix = prefix
        self.suffix = suffix
        self.value = value

################################################################################

class Tree:

    __slots__ = 'nodes', 'index', 'value'

    def __init__(self, nodes, index, value):
        self.nodes = nodes
        self.index = index
        self.value = value

################################################################################

def old_search(a, b):
    # Initialize startup variables.
    nodes, index = [], []
    a_size, b_size = len(a), len(b)
    # Begin to slice the sequences.
    for size in range(min(a_size, b_size), 0, -1):
        for a_addr in range(a_size - size + 1):
            # Slice "a" at address and end.
            a_term = a_addr + size
            a_root = a[a_addr:a_term]
            for b_addr in range(b_size - size + 1):
                # Slice "b" at address and end.
                b_term = b_addr + size
                b_root = b[b_addr:b_term]
                # Find out if slices are equal.
                if a_root == b_root:
                    # Create prefix tree to search.
                    a_pref, b_pref = a[:a_addr], b[:b_addr]
                    p_tree = old_search(a_pref, b_pref)
                    # Create suffix tree to search.
                    a_suff, b_suff = a[a_term:], b[b_term:]
                    s_tree = old_search(a_suff, b_suff)
                    # Make completed slice objects.
                    a_slic = Slice(a_pref, a_root, a_suff)
                    b_slic = Slice(b_pref, b_root, b_suff)
                    # Finish the match calculation.
                    value = size + p_tree.value + s_tree.value
                    match = Match(a_slic, b_slic, p_tree, s_tree, value)
                    # Append results to tree lists.
                    nodes.append(match)
                    index.append(value)
        # Return largest matches found.
        if nodes:
            return Tree(nodes, index, max(index))
    # Give caller null tree object.
    return Tree(nodes, index, 0)

################################################################################

def search(memo, a, b):
    # Initialize startup variables.
    nodes, index = [], []
    a_size, b_size = len(a), len(b)
    # Begin to slice the sequences.
    for size in range(min(a_size, b_size), 0, -1):
        for a_addr in range(a_size - size + 1):
            # Slice "a" at address and end.
            a_term = a_addr + size
            a_root = a[a_addr:a_term]
            for b_addr in range(b_size - size + 1):
                # Slice "b" at address and end.
                b_term = b_addr + size
                b_root = b[b_addr:b_term]
                # Find out if slices are equal.
                if a_root == b_root:
                    # Create prefix tree to search.
                    key = a_pref, b_pref = a[:a_addr], b[:b_addr]
                    if key not in memo:
                        memo[key] = search(memo, a_pref, b_pref)
                    p_tree = memo[key]
                    # Create suffix tree to search.
                    key = a_suff, b_suff = a[a_term:], b[b_term:]
                    if key not in memo:
                        memo[key] = search(memo, a_suff, b_suff)
                    s_tree = memo[key]
                    # Make completed slice objects.
                    a_slic = Slice(a_pref, a_root, a_suff)
                    b_slic = Slice(b_pref, b_root, b_suff)
                    # Finish the match calculation.
                    value = size + p_tree.value + s_tree.value
                    match = Match(a_slic, b_slic, p_tree, s_tree, value)
                    # Append results to tree lists.
                    nodes.append(match)
                    index.append(value)
        # Return largest matches found.
        if nodes:
            return Tree(nodes, index, max(index))
    # Give caller null tree object.
    return Tree(nodes, index, 0)

################################################################################

import time
a = tuple(range(50))
b = (48, 11, 5, 22, 28, 31, 14, 18, 7, 29, 49, 44, 47, 36, 25, 27,
     34, 10, 38, 15, 21, 16, 35, 20, 45, 2, 37, 33, 6, 30, 0, 8, 13,
     43, 32, 1, 40, 26, 24, 42, 39, 9, 12, 17, 46, 4, 23, 3, 19, 41)

start = time.clock()
old_search(a, b)
stop = time.clock()

print('old_search() =', stop - start)

start = time.clock()
search({}, a, b)
stop = time.clock()

print('search() =', stop - start)

Ссылка: Как можно применить memoization к этому алгоритму?

Ответ 12

Запоминание - просто причудливое слово для кеширования. Если вы вычисляете дороже, чем вынимаете информацию из кеша, тогда это хорошо. Проблема в том, что процессоры быстрые, а память медленная. Поэтому я обнаружил, что использование memoization обычно намного медленнее, чем просто переработка вычисления.

Конечно, есть и другие доступные методы, которые действительно дают вам значительное улучшение. Если я знаю, что мне нужно f (10) для каждой итерации цикла, я сохраню это в переменной. Поскольку просмотр кеша отсутствует, обычно это выигрыш.

EDIT

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

Если вы знаете свой диапазон значений во время компиляции, скажите, потому что вы используете n! и n - 32-битный int, тогда вам будет лучше использовать статический массив.

Если ваш диапазон значений велико, скажем, любой двойной, то ваша хеш-таблица может расти настолько, что это становится серьезной проблемой.

Если один и тот же результат используется снова и снова в сочетании с данным объектом, тогда имеет смысл сохранить это значение с объектом.

В моем случае я обнаружил, что более 90% времени входы для любой заданной итерации были такими же, как и последняя итерация. Это означает, что мне просто нужно было сохранить последний вход и последний результат, и только recalc, если вход изменился. Это было на порядок быстрее, чем использование memoization для этого алгоритма.