Для чего нужны воспоминания и действительно ли они так полезны? - PullRequest
28 голосов
/ 14 июля 2010

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

Ответы [ 12 ]

22 голосов
/ 14 июля 2010

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

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

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

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

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


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

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

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

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

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

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

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

19 голосов
/ 14 июля 2010

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

Многие из приведенных здесь примеров являются просто «кешированием». Это полезно, но игнорирует удивительные алгоритмические значения, которые несет для меня слово «мемоизация».

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

Например, рассмотрим 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.

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

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

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

13 голосов
/ 14 июля 2010

Заметка - это метод хранения ответов на подзадачи, так что программе не нужно повторно решать те же подзадачи позже.

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

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

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

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

Memoized:

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

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

real 24m6.513s
user 23m52.478s
sys   0m6.040s
11 голосов
/ 14 июля 2010

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

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

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

6 голосов
/ 14 июля 2010

Заметка меняет время на пространство.

Мемоизация может превратить экспоненциальное время (или хуже) в линейное время (или лучше) применительно к задачам, которые являются многорекурсивными по своей природе. Стоимость, как правило, 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, потому что каждая из частичных сумм вычисляется несколько раз.

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

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-разрядное целое число).

4 голосов
/ 14 июля 2010

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

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

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

4 голосов
/ 14 июля 2010

Запоминание может радикально ускорить алгоритмы. Классическим примером является ряд Фибоноччи, где рекурсивный алгоритм невероятно медленный, но запоминание автоматически делает его таким же быстрым, как итерационная версия.

2 голосов
/ 14 июля 2010

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

1 голос
/ 18 июля 2010

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


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)

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

1 голос
/ 14 июля 2010

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

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

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

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

...