Приведён фрагмент кода на Python: def fib(n): return fib(n-1)+fib(n-2) if n>1 else n. Проанализируйте сложность этого алгоритма, предложите не менее трёх оптимизаций (итеративные, мемоизация, матричный метод) с оценкой по времени и памяти и укажите сценарии, в которых каждая оптимизация предпочтительна
Анализ исходного кода - Рекуррент: T(n)=T(n−1)+T(n−2)+O(1)T(n)=T(n-1)+T(n-2)+O(1)T(n)=T(n−1)+T(n−2)+O(1). => время экспоненциально: T(n)=Θ(φn)T(n)=\Theta(\varphi^n)T(n)=Θ(φn), где φ=1+52\varphi=\tfrac{1+\sqrt5}{2}φ=21+5. - Количество вызовов примерно пропорционально Fn+1∼φn/5F_{n+1}\sim\varphi^n/\sqrt5Fn+1∼φn/5. - Память (стек) по глубине рекурсии: S(n)=Θ(n)S(n)=\Theta(n)S(n)=Θ(n). Предложения по оптимизации (с оценками и ситуациями применения) 1) Итеративный алгоритм (нижняя динамика, 2 переменные) - Идея: вычислять последовательно F0,F1,…,FnF_0,F_1,\dots,F_nF0,F1,…,Fn с двумя переменными. - Время (арифметические операции): T(n)=Θ(n)T(n)=\Theta(n)T(n)=Θ(n). Память: S(n)=Θ(1)S(n)=\Theta(1)S(n)=Θ(1). - Если учитывать размер чисел (битовую сложность), то при больших nnn число FnF_nFn имеет Θ(n)\Theta(n)Θ(n) бит, и суммарная битовая сложность примитивных сложений будет примерно O(n2)O(n^2)O(n2). - Когда применять: простые случаи, ограниченная память, маленькие/умеренные nnn, частые последовательные вычисления (выдача всех FkF_kFk до nnn). 2) Мемоизация / динамика с массивом - Идея: кешировать результаты рекурсивных вызовов (top-down) или хранить массив (bottom-up). - Время (число вычислений): T(n)=Θ(n)T(n)=\Theta(n)T(n)=Θ(n) (каждое значение вычисляется один раз). Память: S(n)=Θ(n)S(n)=\Theta(n)S(n)=Θ(n) для кеша (стек при рекурсивной мемоизации может дать ещё O(n)O(n)O(n) глубины). - Преимущества: удобна, если вам нужно многократно запрашивать разные FkF_kFk с перекрывающимися подзадачами; позволяет восстановить всю последовательность. - Когда применять: множество запросов на разные kkk в одном запуске, нужно быстро отвечать на повторные запросы, или требуется доступ к всем предыдущим значениям. 3) Матричный метод / быстрый алгоритм по возведению в степень (включая fast-doubling) - Идея: (1110)n=(Fn+1FnFnFn−1) \begin{pmatrix}1&1\\1&0\end{pmatrix}^n = \begin{pmatrix}F_{n+1}&F_n\\F_n&F_{n-1}\end{pmatrix}(1110)n=(Fn+1FnFnFn−1). Использовать возведение в степень за логарифм шагов (деление экспоненты пополам) или формулы fast-doubling: - fast-doubling: из Fk,Fk+1F_k,F_{k+1}Fk,Fk+1 получить F2kF_{2k}F2k и F2k+1F_{2k+1}F2k+1 за константное число умножений/сложений. - Число матричных умножений или дублей: O(logn)O(\log n)O(logn). Значит арифметических операций T(n)=Θ(logn)T(n)=\Theta(\log n)T(n)=Θ(logn). Память: S(n)=Θ(1)S(n)=\Theta(1)S(n)=Θ(1) (или O(logn)O(\log n)O(logn) стек при рекурсивной реализации). - Битовоя сложность с учётом увеличивающегося размера чисел: примерно O(M(b)logn)O(M(b)\log n)O(M(b)logn), где b=Θ(n)b=\Theta(n)b=Θ(n) — число бит в FnF_nFn, и M(b)M(b)M(b) — сложность умножения больших чисел (для школьного умножения M(b)=O(b2)M(b)=O(b^2)M(b)=O(b2), для FFT-умножения M(b)=O(blogb)M(b)=O(b\log b)M(b)=O(blogb)). - Когда применять: очень большие nnn (например, nnn до 101210^{12}1012 и более), когда нужно получить одно FnF_nFn быстро, или при вычислениях по модулю (где числа остаются малыми) — матричный/fast-doubling самый эффективный. Краткие рекомендации по выбору - Низкие/умеренные nnn, ограниченная память: итеративный (две переменные). - Много запросов/нужна вся последовательность: мемоизация или табличный DP. - Очень большие nnn или требование к логарифмическому времени (особенно для одного FnF_nFn или при работе по модулю): матричный метод / fast-doubling. Дополнительно - Неиспользовать рекурсивную экспоненциальную версию в реальных задачах — она годится только для иллюстрации. - Для приближённых вычислений можно использовать формулу Бине, но она неточна для больших nnn из-за потери точности в плавающей арифметике.
- Рекуррент: T(n)=T(n−1)+T(n−2)+O(1)T(n)=T(n-1)+T(n-2)+O(1)T(n)=T(n−1)+T(n−2)+O(1). => время экспоненциально: T(n)=Θ(φn)T(n)=\Theta(\varphi^n)T(n)=Θ(φn), где φ=1+52\varphi=\tfrac{1+\sqrt5}{2}φ=21+5 .
- Количество вызовов примерно пропорционально Fn+1∼φn/5F_{n+1}\sim\varphi^n/\sqrt5Fn+1 ∼φn/5 .
- Память (стек) по глубине рекурсии: S(n)=Θ(n)S(n)=\Theta(n)S(n)=Θ(n).
Предложения по оптимизации (с оценками и ситуациями применения)
1) Итеративный алгоритм (нижняя динамика, 2 переменные)
- Идея: вычислять последовательно F0,F1,…,FnF_0,F_1,\dots,F_nF0 ,F1 ,…,Fn с двумя переменными.
- Время (арифметические операции): T(n)=Θ(n)T(n)=\Theta(n)T(n)=Θ(n). Память: S(n)=Θ(1)S(n)=\Theta(1)S(n)=Θ(1).
- Если учитывать размер чисел (битовую сложность), то при больших nnn число FnF_nFn имеет Θ(n)\Theta(n)Θ(n) бит, и суммарная битовая сложность примитивных сложений будет примерно O(n2)O(n^2)O(n2).
- Когда применять: простые случаи, ограниченная память, маленькие/умеренные nnn, частые последовательные вычисления (выдача всех FkF_kFk до nnn).
2) Мемоизация / динамика с массивом
- Идея: кешировать результаты рекурсивных вызовов (top-down) или хранить массив (bottom-up).
- Время (число вычислений): T(n)=Θ(n)T(n)=\Theta(n)T(n)=Θ(n) (каждое значение вычисляется один раз). Память: S(n)=Θ(n)S(n)=\Theta(n)S(n)=Θ(n) для кеша (стек при рекурсивной мемоизации может дать ещё O(n)O(n)O(n) глубины).
- Преимущества: удобна, если вам нужно многократно запрашивать разные FkF_kFk с перекрывающимися подзадачами; позволяет восстановить всю последовательность.
- Когда применять: множество запросов на разные kkk в одном запуске, нужно быстро отвечать на повторные запросы, или требуется доступ к всем предыдущим значениям.
3) Матричный метод / быстрый алгоритм по возведению в степень (включая fast-doubling)
- Идея: (1110)n=(Fn+1FnFnFn−1) \begin{pmatrix}1&1\\1&0\end{pmatrix}^n = \begin{pmatrix}F_{n+1}&F_n\\F_n&F_{n-1}\end{pmatrix}(11 10 )n=(Fn+1 Fn Fn Fn−1 ). Использовать возведение в степень за логарифм шагов (деление экспоненты пополам) или формулы fast-doubling:
- fast-doubling: из Fk,Fk+1F_k,F_{k+1}Fk ,Fk+1 получить F2kF_{2k}F2k и F2k+1F_{2k+1}F2k+1 за константное число умножений/сложений.
- Число матричных умножений или дублей: O(logn)O(\log n)O(logn). Значит арифметических операций T(n)=Θ(logn)T(n)=\Theta(\log n)T(n)=Θ(logn). Память: S(n)=Θ(1)S(n)=\Theta(1)S(n)=Θ(1) (или O(logn)O(\log n)O(logn) стек при рекурсивной реализации).
- Битовоя сложность с учётом увеличивающегося размера чисел: примерно O(M(b)logn)O(M(b)\log n)O(M(b)logn), где b=Θ(n)b=\Theta(n)b=Θ(n) — число бит в FnF_nFn , и M(b)M(b)M(b) — сложность умножения больших чисел (для школьного умножения M(b)=O(b2)M(b)=O(b^2)M(b)=O(b2), для FFT-умножения M(b)=O(blogb)M(b)=O(b\log b)M(b)=O(blogb)).
- Когда применять: очень большие nnn (например, nnn до 101210^{12}1012 и более), когда нужно получить одно FnF_nFn быстро, или при вычислениях по модулю (где числа остаются малыми) — матричный/fast-doubling самый эффективный.
Краткие рекомендации по выбору
- Низкие/умеренные nnn, ограниченная память: итеративный (две переменные).
- Много запросов/нужна вся последовательность: мемоизация или табличный DP.
- Очень большие nnn или требование к логарифмическому времени (особенно для одного FnF_nFn или при работе по модулю): матричный метод / fast-doubling.
Дополнительно
- Неиспользовать рекурсивную экспоненциальную версию в реальных задачах — она годится только для иллюстрации.
- Для приближённых вычислений можно использовать формулу Бине, но она неточна для больших nnn из-за потери точности в плавающей арифметике.