Почему этот код создает экспоненциальный цикл? .Net, Расстояние Левенштайна

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

public static int LevenshteinDistance(this string source, string target)
    {
        Console.WriteLine("Start of method");
        if (source.Length == 0) { return target.Length; }
        if (target.Length == 0) { return source.Length; }

        int distance = 0;

        Console.WriteLine("Distance creation");
        if (source[source.Length - 1] == target[target.Length - 1]) { distance = 0; }
        else { distance = 1; }

        Console.WriteLine("Recursive MethodCall");
        return Math.Min(Math.Min(LevenshteinDistance(source.Substring(0, source.Length - 1), target) + 1,
                                 LevenshteinDistance(source, target.Substring(0, target.Length - 1))) + 1,
                                 LevenshteinDistance(source.Substring(0, source.Length - 1), target.Substring(0, target.Length - 1)) + distance);
    }

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

return Math.Min(Math.Min(LevenshteinDistance(source.Substring(0, source.Length - 1), target) + 1,
                                 LevenshteinDistance(source, target.Substring(0, target.Length - 1))) + 1,
                                 LevenshteinDistance(source.Substring(0, source.Length - 1), target.Substring(0, target.Length - 1)) + distance);
    }

И я могу четко определить, что происходит для этих частей

return Math.Min(***Math.Min(LevenshteinDistance(source.Substring(0, source.Length - 1), target) + 1,
                                 LevenshteinDistance(source, target.Substring(0, target.Length - 1))) + 1,***
                                 LevenshteinDistance(source.Substring(0, source.Length - 1), target.Substring(0, target.Length - 1)) + distance);
    }

Я попытался выделить жирным шрифтом и курсивом ту часть, которую я имею в виду, ту часть, вокруг которой стоит «***». Приступая к этой части, я понимаю, но затем в следующей части, третьем вызове LevenshteinDistance и его первой итерации, я начинаю терять фокус на рекурсии, как она работает и где начинается мое замешательство. Эта часть

LevenshteinDistance(source.Substring(0, source.Length - 1), target.Substring(0, target.Length - 1)) + distance);
    }

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

Примечание. Время может не быть экспоненциальным в буквальном смысле, время для выполнения сравнения коротких строк составляло доли секунды, и как только строки стали немного длиннее, перемычка с доли секунды -> ~ 15 секунд -> 2:30 мин. -> 35 минут

2-е примечание: помечен как бесконечный цикл, поскольку экспоненциальный цикл не существует, и это несколько близко к нему.


person Dexter Whelan    schedule 06.04.2016    source источник


Ответы (4)


Поскольку это рекурсия, а не просто одиночная рекурсия (как вы использовали бы для просмотра дерева), этот щенок передает возвращаемый результат самого себя 3 рекурсивных вызова!

Вот почему вы видите экспоненциальное время увеличения с более длинными строками.

person Jeremy Thompson    schedule 06.04.2016
comment
ошибка не является самой рекурсией, некоторые из наиболее эффективных кодов - это рекурсивный метод, но это тип рекурсии, которую вы создали, она рекурсивна больше времени, чем длиннее входная строка. - person Othello .net dev; 06.04.2016
comment
В конце концов вы столкнетесь с переполнением стека;) - person Jeremy Thompson; 06.04.2016
comment
спасибо, Джереми, где я могу найти место, где можно научиться рисовать «дерево» программы? Может быть, я мог бы попробовать использовать эту тактику в следующий раз, когда столкнусь с подобной проблемой. - person Dexter Whelan; 12.04.2016
comment
Вы просто используете стек вызовов, Ctrl + Alt + C, и вы можете остановить программу, используя условные точки останова, чтобы увидеть глубину стеков вызовов в заданное время во время операции. Как правило, если стек вызовов содержит > 150 вызовов одной и той же (рекурсивной) функции, вам необходимо это проверить. - person Jeremy Thompson; 12.04.2016

Для пары строк (источник, цель) размера n и m вы выполняете 3 рекурсивных вызова функции.

LevenshteinDistance(source[0..n - 1], target)
LevenshteinDistance(source, target[0..m - 1])
LevenshteinDistance(source[0..n - 1], target[0..m - 1])

Итак, вы создаете дерево с 3 дочерними элементами для каждого узла и с минимальной глубиной min(n,m) и максимальной глубиной max(m,n).

Итак, на каждом уровне этого дерева узлов в 3 раза больше, чем на предыдущем уровне:

0
|- 1
    |- 2
    |- 2
    |- 2
|- 1
    |- 2
    |- 2
    |- 2
|- 1
    |- 2
    |- 2
    |- 2

И так далее.

Итак, для каждого уровня k в вашем дереве у вас есть 3k узлов. Таким образом, сложность вашего алгоритма составляет O(3max(n,m)), что является экспоненциальным.

person Edgar Hernandez    schedule 06.04.2016

Стандартный рекурсивный алгоритм вычисляет значения несколько раз.

Вот небольшой пример двух строк размера 3, последовательность вычислений такова

D[2, 2] = min(recurse(1, 2), recurse(2, 1), recurse(1, 1) + eq(1, 1))

после 3 вызовов рекурсии, которые вы получаете

//first recursive call
D[1, 2] = min(recurse(0, 2), recurse(1, 1), recurse(0, 1))

//second recursive call
D[2, 1] = min(recurse(1, 1), recurse(2, 0), recurse(1, 0))

//third recursive call
D[1, 1] = min(recurse(0, 1), recurse(1, 0), recurse(0, 0))

Уже здесь вы видите, что у нас есть несколько вычислений одного и того же значения.

Сложность становится, как вы уже поняли, экспоненциальной. Точнее Θ(3^min(m, n)). Вот хороший ответ, который объясняет и вычисляет сложность.

Однако это можно преодолеть, используя кеш для вычисленных значений и проверяя кеш, если значение уже было вычислено. Этот метод также называется Memoization, и тогда его сложность становится равной Θ(nm).

person gustf    schedule 06.04.2016

Обратите внимание, что вы делаете 3 рекурсивных вызова для каждого вызова. Моя математика немного неверна, но примерно вы делаете 3 вызова для каждого уровня (в рекурсивном дереве вызовов). Уровень соответствует минимальному количеству символов между 2 входными строками.

Для вызова LevenshteinDistance("a", "x") вы в конечном итоге сделаете 4 вызова (первый вызов + 3 рекурсивных вызова)

Для вызова LevenshteinDistance("ab", "xy") вы в конечном итоге сделаете 19 вызовов (первый вызов + 3 рекурсивных вызова, каждый рекурсивный вызов приводит к еще 3 вызовам, 2 из этих вызовов будут рекурсивно повторяться в последний раз)

(ab, xy)
        (a, xy)
                (<empty>, xy)
                (a, x)
                        (<empty>, x)
                        (a, <empty>)
                        (<empty>, <empty>)
                (<empty>, x)
        (ab, x)
                (a, x)
                        (<empty>, x)
                        (a, <empty>)
                        (<empty>, <empty>)
                (ab, <empty>)
                (a, <empty>)
        (a, x)
                (<empty>, x)
                (a, <empty>)
                (<empty>, <empty>)

Обратите внимание, что каждое приличное (обработка последнего символа из строки) в дереве вызовов не уменьшает n на 1, в результате чего общее значение находится в диапазоне от (3^(n+1)-1)/2 до (3^(n +2)-1)/2 звонка

Надеюсь, это проливает достаточно света на производительность кода.

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

  1. Используйте массив символов вместо строк в этом случае в качестве параметров. И передать указатель на последний символ рассматриваемого массива. Это не только уменьшит количество нежелательных распределений, но и удалит все вызовы подстроки.
  2. Используйте динамическое программирование, т.е. сохраняйте результаты вашего вызова и просматривайте их, прежде чем запускать рекурсивные вызовы, поскольку одни и те же рекурсивные вызовы выполняются много раз.
person Vikhram    schedule 06.04.2016