''' ДОДЕЛАТЬ:
1.-
2.-
3.-
4.-
5.-
6.-
7.+ image done
8.-
9.-
10.-? done
11.+! theory done
12.-
13.+ images done
14.-
15.+ image done
16.-
17.-
18.+ images done
19.-
20.-?practice done
21.-
22.-? practice? done
23.-? practice? done
24.-
25.-
26.-? practice? done
27.-? practice? done
28.-? practice? 
29.-
'''

import pyperclip as pc

def info():
    '''
    Добавляет в буфер обмена список тем, по которым потом обращаться при помощи функции get(n, m), где n - номер темы, m = 0 => теория, m = 1 => практика
    '''
    pc.copy('''
1.  Наивное умножение матрицы на вектор и умножение матриц.
2. Ииерархия памяти, план кеша и LRU, промахи в обращении к кешу.
3.  Алгоритм Штрассена.
4.  Собственные векторы, собственные значения (важность, Google PageRank).
5.  Разложение Шура и QR-алгоритм.
6.  Степенной метод.
7. Круги Гершгорина.
8. Разложение Шура, теорема Шура.
9.  Нормальные матрицы, эрмитовы матрицы, унитарно диагонализуемые матрицы, верхне-гессенбергова форма матриц.
10.  Спектр и псевдоспектр.
11.  Неявный QR алгоритм (со сдвигами).
12.  Алгоритм на основе стратегии "разделяй и властвуй".
13.  Разреженные матрицы, форматы хранения разреженных матриц, прямые методы для решения больших разреженных систем.
14.  Обыкновенные дифференциальные уравнения, задача Коши.
15.  Локальная, глобальная ошибки.
16.  Метод центральной разности.
17.  Метод Эйлера.
18.  Метод предиктора-корректора.
19.  Метод Рунге-Кутты 1-4 порядков.
20.  Методы Адамса-Мултона, методы Адамса-Бэшфорта.
21.  Метод Милна.
22.  Согласованность, устойчивость, сходимость, условия устойчивости.
23.  Моделирование волны с использованием математических инструментов (амплитуда, период, длина волны, частота, Герц, дискретизация, частота дискретизации, фаза, угловая частота).
24.  Дискретное преобразование Фурье, обратное дискретное преобразование Фурье их ограничения, симметрии в дискретном преобразовании Фурье.
25.  Быстрое преобразование Фурье, его принципы, фильтрация сигнала с использованием быстрого преобразования Фурье.
26.  Операции свёртки, связь с быстрым преобразованием Фурье, операции дискретной свёртки.
27.  Дискретная свёртка и Тёплицевы матрицы (Ганкелевы матрицы).
28.  Циркулянтные матрицы. Матрицы Фурье.
29.  Быстрый матвек с циркулянтом
            ''')

def get(n, m : int):
    '''
    Добавляет в буфер обмена ответ по теме (n - номер темы; m = 0 => теория, m = 1 => практика)
    '''
    if n == 1 or n == 'numnv':
        if m == 0:
            pc.copy(r'''
При умножении матрицы $A$ размером $n \times m$ на вектор $x$ размером $m$, результат — вектор $y$ размером $n$, вычисляемый по формуле:

$$
y_i = \sum_{j=1}^{m} A_{ij} x_j
$$

Алгоритм выполняет $O(n \cdot m)$ операций.

#### Умножение двух матриц

Для матриц $A$ ($n \times k$) и $B$ ($k \times m$) результирующая матрица $C$ ($n \times m$) вычисляется как:

$$
C_{ij} = \sum_{s=1}^{k} A_{is} B_{sj}
$$

Наивный алгоритм имеет три вложенных цикла и сложность $O(n \cdot k \cdot m)$.
                    ''')
        elif m == 1:
            pc.copy(r'''
# Рукописная реализация перемножения двух матриц
def matmul(a, b):
    n = a.shape[0]
    k = a.shape[1]
    m = b.shape[1]
    c = np.zeros((n, m))
    for i in range(n):
        for j in range(m):
            for s in range(k):
                c[i, j] += a[i, s] * b[s, j]
    return c
                    ''')
        else:
            pc.copy('Неправильный выбор типа задания')

    elif n == 2 or n == 'ippkp':
        if m == 0:
            pc.copy(r'''
1. Регистры процессора. Скорость доступа — порядка 1 такта, объём — несколько сотен или тысяч байт. 5
2. Кэш процессора L1. Скорость доступа — порядка несколько тактов, объём — десятки килобайт. 5
3. Кэш процессора L2. От 2 до 10 раз медленнее L1, объём — от 0,5 МБ. 5
4. Кэш процессора L3. Около сотни тактов, объём — от нескольких мегабайт до сотен. 5
5. ОЗУ. От сотен до тысяч тактов, объём — от нескольких гигабайт до нескольких терабайт. 5
6. Дисковое хранилище. Миллионы тактов, объём — до нескольких сотен терабайт. 5
7. Третичная память. До нескольких секунд или минут, объём практически неограничен.  

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

LRU (Least Recently Used) — алгоритм замещения кэш-строк, при котором новые данные вытесняют самые старые.

Промахи в обращении к кэшу — это случаи, когда в кэше нет запрашиваемых данных. Промахи по чтению задерживают исполнение, поскольку они требуют запроса данных в более медленной основной памяти. Промахи по записи могут не давать задержку, поскольку записываемые данные сразу могут быть сохранены в кэше, а запись их в основную память можно произвести в фоновом режиме.
                    ''')
        elif m == 1:
            pc.copy('К сожалению, к этому вопросу нет кода') 
        else:
            pc.copy('Неправильный выбор типа задания')

    elif n == 3 or n == 'strassen':
        if m == 0:
            pc.copy(r'''
Если при помощи классического наивного подхода мы можем вычислить произведение двух матриц 2х2 используя 8 умножений и 4 сложения, то при помощи алгоритма Штрассена можно его вычислить при помощи 7 умножений и 18 сложений
c11 = f1 + f4  - f5 + f7
c12 = f3 + f5
c21 = f2 + f4
c22 = f1 - f2 + f3 + f6

f1 = (a11 + a22)(b11 + b22)
f2 = (a21 + a22)*b11
f3 = a11*(b12 - b22)
f4 = a22*(b21 - b11)
f5 = (a11+ a12)*b22
f6 = (a21 - a11)*(b11 + b12)
f7 = (a12 - a22)*(b21 + b22)



Схема алгоритма:

Разбиваем исходные матрицы a и b размера n x n на 4 блока n/2 x n/2
Вычисляем произведения по приведенным выше формулам рекурсивно


                    ''')
        elif m == 1:
            pc.copy(r'''
def strassen(A, B):
    n = len(A)

    if n <= 2:
        return np.dot(A, B)

    # Разделим матрицы на подматрицы
    mid = n // 2
    A11 = A[:mid, :mid]
    A12 = A[:mid, mid:]
    A21 = A[mid:, :mid]
    A22 = A[mid:, mid:]
    B11 = B[:mid, :mid]
    B12 = B[:mid, mid:]
    B21 = B[mid:, :mid]
    B22 = B[mid:, mid:]

    # Рекурсивное умножение
    P1 = strassen(A11, B12 - B22)
    P2 = strassen(A11 + A12, B22)
    P3 = strassen(A21 + A22, B11)
    P4 = strassen(A22, B21 - B11)
    P5 = strassen(A11 + A22, B11 + B22)
    P6 = strassen(A12 - A22, B21 + B22)
    P7 = strassen(A11 - A21, B11 + B12)

    # Соединим результаты в матрицу С
    C11 = P5 + P4 - P2 + P6
    C12 = P1 + P2
    C21 = P3 + P4
    C22 = P5 + P1 - P3 - P7

    C = np.vstack((np.hstack((C11, C12)), np.hstack((C21, C22))))

    return C
                    ''')
        else:
            pc.copy('Неправильный выбор типа задания')

    elif n == 4 or n == 'svsz_google':
        if m == 0:
            pc.copy(r'''
Пусть A — действительная числовая квадратная матрица размеров (n × n).
Ненулевой вектор $X = (x_1, ..., x_n)^T$ размеров (n × 1), удовлетворяющий
условию:  
$AX = \lambda X => (A - \lambda E)X = 0$  
Следовательно система имеет ненулевое решение для вектора X при условии $|A - \lambda E| = 0$
Это равенство называется **характерестическим уровнением**

Задача состоит в ранжировании веб-страницы: какие из
них являются важными, а какие нет.
В интернете страницы ссылаются друг на друга.
PageRank определяется рекурсивно. Обозначим за pi
важность i-ой страницы. Тогда определим эту важность
как усреднённую важность всех страниц, которые
ссылаются на данную страницу. Это определение
приводит к следующей линейной системе:  
$p_i = \sum_{j \in N(i)}\frac{p_j}{L(j)}$  
где L(j)– число исходящих ссылок с j-ой страницы, N(i)
– число соседей i-ой страницы. Это может быть
записано следующим образом:  
$p = Gp;  G_{ij} = \frac{1}{L(j)}$

то есть мы уже знаем, что у матрицы G есть
собственное значение равное 1. Заметим, что G–
левостохастичная матрица, то есть сумма в каждом
столбце равна 1.
                    ''')
        elif m == 1:
            pc.copy(r'''

                    ''')
        else:
            pc.copy('Неправильный выбор типа задания')
            
    elif n == 5 or n == 'rshia':
        if m == 0:
            pc.copy(r'''
Если мы приведем матрицу А к верхнетреугольному виду T с помощью унитарной матрицы U: $U^*AU = T$, то мы решим задачу. Умножая слева и справа на $U \, и \, U^*$ получим следующее разложение: $A = UTU^*$ - **Разложение (форма) Шура**

* Использование унитарных матриц приводит к устойчивым алгоритмам, таким образом собственные значения вычисляются очень точно

* Разложение Шура показывает, почему нам нужны матричные разложения: они представляют матрицу в виде произведения трёх матриц подходящей структуры

**Теорема Шура**  

Каждая матрица $A \in C^{n\times n}$ может быть представлена в виде формы Шура.

1. Каждая матрица имеет как минимум один ненулевой собственный вектор (для корня характеристического многочлена матрица $(A - \lambda I)$ вырождена и имеет нетривиальное ядро). Пусть:  
        $Av_1 = \lambda_1 v_1, \,||v_1||_2 = 1$
       
2. Пусть $U_1 = [v_1, v_2, ..., v_n]$, где $v_2, ..., v_n$ любые векторы ортогональные $v_1$. Тогда $U_1^*AU_1 = \begin{bmatrix}
\lambda_1 & * \\
0 & A_2
\end{bmatrix}$, где $A_2$ матрица размера $(n-1)*(n-1)$&. Она же называется блочнотреугольной формой. Теперь мы можем проделать аналогичную процедуру для матрицы $A_2$ и так далее.

* QR-алгоритм *
QR алгоритм использует QR разложение для вычисления разложения Шура

Рассмотрим выражение: $ A = QTQ^* => QT = AQ $ (T - верхнетреугольная)

Запишим следующий итерационный процесс:
$Q_{k+1}R_{k+1} = AQ_k, \\
Q_{k+1}^*A = R_{k+1}Q_k^*, \\
Введем \, новую \, матрицу \\
A_k = Q_k^*AQ_{k} = Q_k^*Q_{k+1}R_{k+1} = \widehat{Q}_kR_{k+1} \\$  

Тогда для аппроксимация для $A_{k+1}$ имеет вид  

$A_{k+1} = Q_{k+1}^*AQ_{k+1} = (Q_{k+1}^*A = R_{k+1}Q_k^*) = R_{k+1}\widehat{Q}_k
$

Финальные формулы обычно записывают в QRRQ - форме
1. Инициализируем $A_0 = A$
2. Вычислим QR разложение матрицы $A_k: A_k = Q_kR_k$.
3. Обновим аппроксимацию $A_{k+1} = R_kQ_k$

Продолжаем итерации пока A_k не станет достаточно треугольной (например, норма подматрицы под главной дианональю не станет достаточно мала)
                    ''')
        elif m == 1:
            pc.copy(r'''
# Отражение Хаусхолдера:
def householder(x):
  e1 = np.zeros_like(x)
  e1[0] = 1

  # вычисление alpha
  alpha = np.sign(x[0])*np.sqrt(np.sum(x**2))

  # матрица отражения u
  u = x - alpha*e1
  # нормализация u
  norm_u = np.sum(u**2)

  if norm_u > 1e-10:
    u = u/np.sqrt(norm_u)
  else:
    u = np.zeros_like(u)


  return u

# QR-разложение
def qr_dec(A):
  n, m = A.shape

  # единичная матрица Q
  Q = np.eye(n)
  R = A.copy()

  # итариция разложения
  for i in range(min(n, m)):
    # подмножество x:
    x = R[i:, i]
    # вычисление u - отражение для x:
    u = householder(x)

    # нахождение матрицы Хаусхолдера:
    H = np.eye(n)
    H[i:, i:] -= 2*np.outer(u, u)

    # обновление R и Q:
    R = H@R
    Q = Q@H.T

  return Q, R

# реализация QR алгоритма:
def qr(A, iter=100):
  for i in range(iter):
    # Разложение:
    Q, R = qr_dec(A)
    # Перемножение матриц:
    A = R@Q

  return A

# реализация неявного QR алгоритма:
def implict_qr(A, iter=100):
  n = A.shape[0]
  for i in range(iter):
    # сдвиг
    shift = A[n-1, n-1]
    Q, R = qr_dec(A - shift*np.eye(n))
    A = R@Q + shift*np.eye(n)

  return A
                    ''')
        else:
            pc.copy('Неправильный выбор типа задания')
            
    elif n == 6 or n == 'stepen':
        if m == 0:
            pc.copy(r'''
Степенной метод (Power method):

Берём некоторый случайный вектор b и начинаем действовать на него оператором А (умножая), при этом нормируя: <br>
$b_{i+1} = A * b_i$ / ||$A$||

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

* Степенной метод дает оценку для максимального по модую собственного числа или спектрального радиуса матрицы
* Одна итерация требует одного умножения матрицы на вектор. Если умножить вектор на матрицу за O(n) (например, она разреженная), тогда степенной метод можно использовать для больших n
* Сходимость может быть медленной
* Для грубой оценки максимального по модулю собственного значения и соответствующего вектора достаточно небольшого числа итераций
* Вектор решения лежит в Крыловском подпространстве $\{x_0, Ax_0, …, A^kx_0\}$ и имеет вид $μA^kx_0$, где μ– нормировочная постоянная.
                    ''')
        elif m == 1:
            pc.copy(r'''
A = np.array([[2, 1], [1, 2]]) #Линейный оператор
x = np.array([[1, 2]]).T #Исходный вектор
tol = 1e-6 #Порог точности
max_iter = 100

lam_prev = 0

for i in range(max_iter):
    x = A @ x / np.linalg.norm(A @ x)

    lam = (x.T @ A @ x) / (x.T @ x)

    if np.abs(lam - lam_prev) < tol:
        break

    lam_prev = lam

print(lam)
print(x)
                    ''')
        else:
            pc.copy('Неправильный выбор типа задания')
            
    elif n == 7 or n == 'krugi':
        if m == 0:
            pc.copy(r'''
Есть теорема, которая часто помогает локализовать собственные значения.
Она называется теоремой Гершгорина.
Теорема утверждает, что все собственные значения λi, i= 1, 2, ..., n находятся внутри объединения кругов Гершгорина $C_i$, где $C_i$–окружность на комплексной плоскости с центром в $a_{ii}$ и радиусом: $\sum_{j≠i} |a_{ij}|$  
Более того, если круги не
пересекаются, то они содержат
по одному собственному
значению внутри каждого
круга.


Сначала покажем, что если матрица $A$ обладает строгим диагональным преобладанием, то есть

$$
|a_{ii}| > \sum_{j \neq i} |a_{ij}|,
$$

тогда такая матрица невырождена.

Разделим диагональную и недиагональную части и получим

$$
A = D + S = D(I + D^{-1}S),
$$

где $\|D^{-1}S\|_1 < 1$. Поэтому, в силу теоремы о ряде Неймана, матрица $I + D^{-1}S$ обратима и, следовательно, $A$ также обратима.

                    ''')
        elif m == 1:
            pc.copy(r'''
# Круги Гершгорина

import numpy as np
import matplotlib.pyplot as plt

n = 3
fig, ax = plt.subplots(1, 1)
a = np.array([[5, 1, 1], [1, 0, 0.5], [2, 0, 10]])

a += 2*np.random.randn(n, n)

xg = np.diag(a).real
yg = np.diag(a).imag
rg = np.zeros(n)
ev = np.linalg.eigvals(a)

for i in range(n):
    rg[i] = np.sum(np.abs(a[i, :])) - np.abs(a[i, i])
    crc = plt.Circle((xg[i], yg[i]), radius = rg[i], fill = False)
    ax.add_patch(crc)
plt.scatter(ev.real, ev.imag, color = 'r', label = "Eigenvalues")
plt.axis('equal')
                    ''')
        else:
            pc.copy('Неправильный выбор типа задания')
            
    elif n == 8 or n == 'rshtsh':
        if m == 0:
            pc.copy(r'''
Если мы приведем матрицу А к верхнетреугольному виду T с помощью унитарной матрицы U: $U^*AU = T$, то мы решим задачу. Умножая слева и справа на $U \, и \, U^*$ получим следующее разложение: $A = UTU^*$ - **Разложение (форма) Шура**

* Использование унитарных матриц приводит к устойчивым алгоритмам, таким образом собственные значения вычисляются очень точно

* Разложение Шура показывает, почему нам нужны матричные разложения: они представляют матрицу в виде произведения трёх матриц подходящей структуры

**Теорема Шура**  

Каждая матрица $A \in C^{n\times n}$ может быть представлена в виде формы Шура.

1. Каждая матрица имеет как минимум один ненулевой собственный вектор (для корня характеристического многочлена матрица $(A - \lambda I)$ вырождена и имеет нетривиальное ядро). Пусть:  
      $Av_1 = \lambda_1 v_1, \,||v_1||_2 = 1$
       
2. Пусть $U_1 = [v_1, v_2, ..., v_n]$, где $v_2, ..., v_n$ любые векторы ортогональные $v_1$. Тогда $U_1^*AU_1 = \begin{bmatrix}
\lambda_1 & * \\
0 & A_2
\end{bmatrix}$, где $A_2$ матрица размера $(n-1)*(n-1)$&. Она же называется блочнотреугольной формой. Теперь мы можем проделать аналогичную процедуру для матрицы $A_2$ и так далее.
                    ''')
        elif m == 1:
            pc.copy(r'''

                    ''')
        else:
            pc.copy('Неправильный выбор типа задания')
            
    elif n == 9 or n == 'nmemu':
        if m == 0:
            pc.copy(r'''
1. Нормальная матрица $$AA^*=A^*A,$$ где A* - сопряжённая матрица

  Эрмитовы и унитарные матрицы являются нормальными


2. Эрмитова (самосопряженная) матрица
$$A^T = \overline{A},$$
$\overline{A}$ - комплексно сопряжённая матрица

  Для Эрмитовых матриц собственные
значения всегда действительны



3. Квадратная матрица A называется унитарно диагонализируемой, если существует унитарная матрица U, такая что $$U^*AU = D,$$
D - диагональная матрица

  Любая нормальная матрица – унитарно диагонализуема

4. Матрица 𝐴 имеет верхне-гессенбергову форму, если $𝑎_{𝑖𝑗}$ = 0, при $𝑖 ≥ 𝑗 + 2$ $$H = \begin{bmatrix}
* & * & * & * & * \\
* & * & * & * & * \\
0 & * & * & * & * \\
0 & 0 & * & * & * \\
0 & 0 & 0 & * & *
\end{bmatrix}$$
С помощью отражений Хаусхолдера можно привести любую матрицу к верхнегессенберговой форме:
$U^*AU=H$

  Если матрица 𝐴 симметричная (эрмитова), то $A=A^*$
,
тогда $H=H^*$ и верхне-гессенбергова форма оказывается
трёхдиагональной матрицей.
\begin{bmatrix}
a_{11} & b_1 & 0 & \cdots & 0 \\
c_2 & a_{22} & b_2 & \ddots & \vdots \\
0 & c_3 & a_{33} & \ddots & 0 \\
\vdots & \ddots & \ddots & \ddots & b_{n-1} \\
0 & \cdots & 0 & c_n & a_{nn}
\end{bmatrix}
                    ''')
        elif m == 1:
            pc.copy(r'''

                    ''')
        else:
            pc.copy('Неправильный выбор типа задания')
            
    elif n == 10 or n == 'spektr':
        if m == 0:
            pc.copy(r'''
Для динамических систем с матрицей A, спектр может много
сообщить о поведении системы (например, о её устойчивости).

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

Для измерения подобных возмущений было разработана концепция
псевдоспектра.

**Теорема**: A – нормальная матрица, тогда и только тогда, когда A =
UΛU*
, где U унитарна и Λ диагональна.
Любая нормальная матрица – унитарно диагонализуема. Это
означает, что она может быть приведена к диагональному виду с
помощью унитарной матрицы U. Другими словами, каждая
нормальная матрица имеет ортогональный базис из собственных
векторов.

Рассмотрим объединение всех возможных собственных значений для
всевозможных возмущений матрицы A.

$Λ_{\epsilon}(A) = \{\lambda \in C: \exists E, x ≠ 0: (A+E)x = \lambda x, ||E||_2 \leq \epsilon\}$

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

### Спектр матрицы

**Спектр матрицы** — это множество собственных значений (или спектральных значений) квадратной матрицы $A$.  

Если $\lambda$ — собственное значение матрицы $A$, то существует ненулевой вектор $v$ (собственный вектор), для которого выполняется уравнение:  

$A v = \lambda v$

Таким образом, спектр матрицы $A$ определяется как множество всех собственных значений:  

$\text{Sp}(A) = \{ \lambda \in \mathbb{C} \mid \det(A - \lambda I) = 0 \}$

где $I$ — единичная матрица той же размерности, что и $A$, а $\det$ — определитель.

---

### Псевдоспектр матрицы

**Псевдоспектр матрицы** $A$ — это обобщение спектра, которое учитывает численную устойчивость и небольшие возмущения в матрице.  

Для комплексного числа $z$, псевдоспектр $\varepsilon$-уровня определяется как множество таких $z$, что:  

$z \in \Lambda_\varepsilon(A) \quad \text{если} \quad \| (A - zI)^{-1} \| \geq \frac{1}{\varepsilon}$

или, эквивалентно,  

$\Lambda_\varepsilon(A) = \{ z \in \mathbb{C} \mid \sigma_{\text{min}}(A - zI) \leq \varepsilon \}$

где $\sigma_{\text{min}}(A - zI)$ — наименьшее сингулярное значение матрицы $A - zI$.

---

### Интерпретация

- **Спектр** — это точки, где оператор $A - zI$ становится вырожденным (необратимым).  
- **Псевдоспектр** включает в себя точки, где оператор близок к необратимому, что важно в приложениях, где матрица подвержена шумам или численным погрешностям.

                    ''')
        elif m == 1:
            pc.copy(r'''
import numpy as np
import matplotlib.pyplot as plt

def compute_spectrum(A):
    """Вычисляет собственные значения (спектр) матрицы A методом степени."""
    n = A.shape[0]
    eigenvalues = []

    for _ in range(n):
        v = np.random.rand(n)
        for _ in range(50):  # Итерации степенного метода
            v = np.dot(A, v)
            v /= np.linalg.norm(v)
        eigenvalue = np.dot(v, np.dot(A, v)) / np.dot(v, v)
        eigenvalues.append(eigenvalue)

        # Дефляция
        A = A - eigenvalue * np.outer(v, v)

    return np.array(eigenvalues)

def compute_pseudospectrum(A, epsilon=1e-3, grid_size=500, extent=5):
    """Вычисляет псевдоспектр матрицы A."""
    x = np.linspace(-extent, extent, grid_size)
    y = np.linspace(-extent, extent, grid_size)
    X, Y = np.meshgrid(x, y)
    Z = X + 1j * Y  # Комплексная сетка
    pseudospectrum = np.zeros_like(Z, dtype=float)

    for i in range(grid_size):
        for j in range(grid_size):
            z = Z[i, j]
            M = A - z * np.eye(A.shape[0])
            try:
                inv_norm = 1 / np.linalg.norm(np.linalg.solve(M, np.eye(A.shape[0])))
            except np.linalg.LinAlgError:
                inv_norm = 0  # Если матрица необратима
            pseudospectrum[i, j] = inv_norm

    return X, Y, pseudospectrum

def plot_spectrum_and_pseudospectrum(A, epsilon=1e-3, grid_size=500, extent=5):
    """Строит график спектра и псевдоспектра матрицы A."""
    eigenvalues = compute_spectrum(A)
    X, Y, pseudospectrum = compute_pseudospectrum(A, epsilon, grid_size, extent)

    # График
    plt.figure(figsize=(10, 8))
    plt.contourf(X, Y, -np.log10(pseudospectrum), levels=50, cmap='viridis')  # Логарифмическая шкала
    plt.colorbar(label='-log10(1 / ||inv(M)||)')
    plt.scatter(eigenvalues.real, eigenvalues.imag, color='red', marker='o', label='Eigenvalues')
    plt.xlabel('Real part')
    plt.ylabel('Imaginary part')
    plt.title('Spectrum and Pseudospectrum')
    plt.legend()
    plt.grid(True)
    plt.show()

# Пример использования
A = np.array([[1, 2], [3, 4]])
plot_spectrum_and_pseudospectrum(A, epsilon=1e-3, grid_size=500, extent=5)
                    ''')
        else:
            pc.copy('Неправильный выбор типа задания')
            
    elif n == 11 or n == 'QRsdvig':
        if m == 0:
            pc.copy(r'''
### Неявный QR алгоритм (со сдвигами)

#### Основная идея
- QR алгоритм используется для вычисления собственных значений и векторов матрицы.
- Ускорение достигается за счёт использования **сдвигов** и сохранения структурных свойств матрицы (например, верхне-гессенберговой формы).

#### Шаги алгоритма
1. Преобразуем матрицу $A$ к **верхне-гессенберговой форме** с помощью отражений Хаусхолдера:
   - Сложность приведения: $O(n^3)$.
   - Для симметричных матриц форма становится **трёхдиагональной**.

2. Итерационный процесс:
   $$
   A_k = Q_k R_k, \quad A_{k+1} = R_k Q_k
   $$
   - Использование сдвига $\lambda$: $A_k - \lambda I$ ускоряет сходимость.

3. Если $A$ симметрична (эрмитова):
   - Верхне-гессенбергова форма превращается в трёхдиагональную.
   - Итерации QR алгоритма сохраняют эту структуру, что сокращает сложность до $O(n)$ на шаг.

4. **Неявный QR-шаг**:
   - Вычисление $A_{k+1}$ напрямую, без явного вычисления матрицы $Q_k$.
   - Основан на теореме: первый столбец матрицы $Q$ определяет все остальные её столбцы.
   - Используем уравнение:
     $$
     A Q = Q H
     $$

#### Преимущества сдвигов
- Ускоряют сходимость, выбирая $\lambda$ близким к собственным значениям (например, элемент в правом нижнем углу $A_k$).
- Итерации становятся более стабильными и эффективными.

                    ''')
        elif m == 1:
            pc.copy(r'''
def householder_reflection(x):
  """
  Функция, позволяющая применить к матрице отражение Хаусхолдера

  x - матрица для вычислений
  """

  # создадим единичный вектор (или матрицу) того же размера, что и x
  e1 = np.zeros_like(x)
  e1[0] = 1

  # вычисляем alpha, используя знак первого элемента x и его норму
  alpha = np.sign(x[0])*np.sqrt(np.sum(x**2))

  # вычисляем вектор(матрицу) отражения u
  u = x - alpha*e1
  # нормализуем u
  u_norm_squared = np.sum(u**2)

  # введём условие на соответствие некой малой величине,
  # чтобы избежать деления на 0
  if u_norm_squared > 1e-20:
    u = u/np.sqrt(u_norm_squared)
  else:
    u = np.zeros_like(u)

  return u

def qr_decomposition(A):
  """
  Функция, выполняющая QR-разложение матрицы

  A - матрица для разложения
  """

  # получаем размерность матрицы A
  n, m = A.shape
  # создаем единичную матрицу Q
  Q = np.eye(n)
  # создаем R как копию A
  R = A.copy()

  # итерируемся по столбцам A
  for i in range(min(n, m)):
    # получаем подстолбец x, начиная с i-ой строки
    x = R[i:, i]
    # вычисляем вектор отражения для x
    u = householder_reflection(x)

    # матрица Хаусхолдера
    H = np.eye(n)
    H[i:, i:] -= 2*np.outer(u, u)

    # обновляем R и Q
    R = H@R
    Q = Q@H.T

  return Q, R

def qr_algorithm(A, iter=100):
  """
  Функция, выполняющая стандартный QR-алгоритм

  A - матрица
  iter - допустимое количество итераций

  """
  # выполняем допустимое количество итераций
  for _ in range(iter):
    # разглагаем A на Q и R
    Q, R = qr_decomposition(A)
    # обновляем A
    A = R@Q

  return A

def implict_qr_algorithm(A, iter=100):
  """
  Функция, выполняющая неявный QR-алгоритм со сдвигами

  A - матрица
  iter - допустимое количество итераций
  """

  n = A.shape[0]

  for _ in range(iter):
    # вычисляем сдвиг (последний элемент диагонали)
    shift = A[n-1, n-1]
    Q, R = qr_decomposition(A - shift*np.eye(n))
    A = R@Q + shift*np.eye(n)

  return A
                    ''')
        else:
            pc.copy('Неправильный выбор типа задания')
            
    elif n == 12 or n == 'razd_vlastv':
        if m == 0:
            pc.copy(r'''
Алгоритм "разделяй и властвуй" является одним из базовых методов по ускорению алгоритмов. Примером тому служит переход от квадратичной сложности пузырьковой сортировки или сортировки вставками к сложности $O (nlogn)$ при сортировке слиянием.
Решение задачи с помощью данного подхода обладает следующими тремя свойствами:
1. Разделить входные данные на меньшие подмножества.
2. Решить подзадачи рекурсивно.
3. Объединить решения подзадач в решение исходной задачи.
                    ''')
        elif m == 1:
            pc.copy(r'''
def merge_sort(arr):
    if len(arr) > 1:
        # Находим середину массива
        mid = len(arr) // 2

        # Разделяем массив на две половины
        left_half = arr[:mid]
        right_half = arr[mid:]

        # Рекурсивно сортируем обе половины
        merge_sort(left_half)
        merge_sort(right_half)

        # Индексы для отслеживания текущих позиций в левой и правой половинах
        i = j = k = 0

        # Сливаем отсортированные половины обратно в исходный массив
        while i < len(left_half) and j < len(right_half):
            if left_half[i] < right_half[j]:
                arr[k] = left_half[i]
                i += 1
            else:
                arr[k] = right_half[j]
                j += 1
            k += 1

        # Проверяем, остались ли элементы в левой половине
        while i < len(left_half):
            arr[k] = left_half[i]
            i += 1
            k += 1

        # Проверяем, остались ли элементы в правой половине
        while j < len(right_half):
            arr[k] = right_half[j]
            j += 1
            k += 1

# Пример использования
array = [38, 27, 43, 3, 9, 82, 10]
print("Исходный массив:", array)
merge_sort(array)
print("Отсортированный массив:", array)
                    ''')
        else:
            pc.copy('Неправильный выбор типа задания')
            
    elif n == 13 or n == 'rmfhr':
        if m == 0:
            pc.copy(r'''
**Разреженная матрица** — матрица с преимущественно нулевыми элементами.
\begin{equation*}
A = \left(
\begin{array}{cccc}
1 & 0 & 0\\
0 & 0 & 0 \\
0 & 9 & 0
\end{array}
\right)
\end{equation*}

Форматы храниения разреженных матриц:
1. COO (Coordinate list) - хранится список из элементов вида (строка, столбец, значение). \
`matrix = [(0, 0, 1), (2, 1, 9)]`
2. LIL (List of Lists) - строится как список строк, где строка — это список узлов вида (столбец, значение). \
`matrix = [[(0, 1)], [], [(1, 9)]]`
3. CSR (compressed sparse row) - представляем исходную матрицу $M^{n \cdot m}$, cодержащую $N_{NZ}$ ненулевых значений в виде трёх массивов:
 - **массив значений** - массив размера , в котором хранятся ненулевые значения, взятые подряд из первой непустой строки, затем идут значения из следующей непустой строки и т. д. \
 - **массив индексов столбцов** - массив размера хранит номера столбцов соответствующих элементов из массива значений. \
 - **массив индексации строк** - массив размера $n + 1$, для индекса $i$ хранит количество ненулевых элементов в строках с первой до $i - 1$ строки включительно, стоит отметить что последний элемент массива индексации строк совпадает с $N_{NZ}$, а первый всегда равен 0.

```
  value_matrix = [1, 9]
  column_index_matrix = [0, 1]
  row_index_matrix = [0, 1, 1, 2]
```

4. CSC - только строки и столбцы меняются ролями — значения храним по столбцам, по второму массиву можем определить строку, после подсчётов с третьим массивом — узнаём столбцы.
5. Блочный вариант BSR. Матрица предварительно разбивается на блоки одинакового размера $n \cdot n$. Также формируется 3 массива:
 - **Значения ненулевых блоков** - cодержит ненулевые блоки, хранящиеся в построчном порядке. \
 - **Индексы начала строк блоков** - указывает, где начинается каждая строка в массиве блоков. \
 - **Индексы столбцов ненулевых блоков** - указывает, в каких столбцах находятся ненулевые блоки.

 ---
 **Методы для решения больших разреженных систем**


1. **LU-разложение**

**Общий вид матриц**:

$
A = \begin{bmatrix}
a_{11} & a_{12} & a_{13} & \cdots & a_{1n} \\
a_{21} & a_{22} & a_{23} & \cdots & a_{2n} \\
a_{31} & a_{32} & a_{33} & \cdots & a_{3n} \\
\vdots & \vdots & \vdots & \ddots & \vdots \\
a_{n1} & a_{n2} & a_{n3} & \cdots & a_{nn}
\end{bmatrix},
\quad
L = \begin{bmatrix}
1 & 0 & 0 & \cdots & 0 \\
l_{21} & 1 & 0 & \cdots & 0 \\
l_{31} & l_{32} & 1 & \cdots & 0 \\
\vdots & \vdots & \vdots & \ddots & 0 \\
l_{n1} & l_{n2} & l_{n3} & \cdots & 1
\end{bmatrix},
\quad
U = \begin{bmatrix}
u_{11} & u_{12} & u_{13} & \cdots & u_{1n} \\
0 & u_{22} & u_{23} & \cdots & u_{2n} \\
0 & 0 & u_{33} & \cdots & u_{3n} \\
\vdots & \vdots & \vdots & \ddots & u_{nn} \\
0 & 0 & 0 & \cdots & u_{nn}
\end{bmatrix}.
$

**Элементы матриц**:

Для $( i \geq j)$ (нижнетреугольные элементы L):
$l_{ij} = \frac{1}{u_{jj}} \left( a_{ij} - \sum_{k=1}^{j-1} l_{ik} u_{kj} \right).$

Для $(i \leq j)$ (верхнетреугольные элементы  U):
$u_{ij} = a_{ij} - \sum_{k=1}^{i-1} l_{ik} u_{kj}.$

**Итеративное вычисление**:

LU-разложение вычисляется по шагам:  
- На k-м шаге элементы $u_{kj}$ для $( j \geq k )$ вычисляются как:
$u_{kj} = a_{kj} - \sum_{m=1}^{k-1} l_{km} u_{mj}.$
- Элементы $l_{ik}$ для $i > k$ вычисляются как:
$l_{ik} = \frac{1}{u_{kk}} \left( a_{ik} - \sum_{m=1}^{k-1} l_{im} u_{mk} \right).$
                    ''')
        elif m == 1:
            pc.copy(r'''
import numpy as np
from scipy.sparse import coo_matrix, lil_matrix, csr_matrix
from scipy.sparse.linalg import splu

dense_matrix = np.array([[1, 0, 0],
                         [0, 0, 0],
                         [0, 9, 0]])

# 1. Формат CSR
csr = csr_matrix(dense_matrix)
print("Матрица в формате CSR:")
print(csr)

# 2. Формат LIL
lil = lil_matrix(dense_matrix)
print("\nМатрица в формате LIL:")
print(lil)

# 3. Формат COO
coo = coo_matrix(dense_matrix)
print("\nМатрица в формате COO:")
print(coo)

def lu_decomposition(A):
    n = A.shape[0]

    L = np.zeros((n, n))
    U = np.zeros((n, n))

    for i in range(n):
        U[i, i:] = A[i, i:] - np.dot(L[i, :i], U[:i, i:])

        L[i:, i] = (A[i:, i] - np.dot(L[i:, :i], U[:i, i])) / U[i, i]

        L[i, i] = 1.0

    return L, U

lu = splu(A)

L = lu.L
U = lu.U
                    ''')
        else:
            pc.copy('Неправильный выбор типа задания')
            
    elif n == 14 or n == 'oduzk':
        if m == 0:
            pc.copy(r'''
Обыкновенные дифференциальные уравнения (ОДУ) - это уравнения, содержащие одну или несколько производных от искомой функции

**F
(х, у, уʹ, уʹʹ, уʹʹʹ, ……, у^(n)) = 0**

где
**x** независимая переменная, **у = у (х)** искомая функция.

Наивысший порядок производной n входящей в предыдущее уравнение, называют порядком дифференциального уравнения

**Определение задачи Коши**

Рассмотрим систему ОДУ первого порядка, записанную в виде
 **уʹ(x) = f(x, y(x))**

Решение: любая функция y(x), которая удовлетворяет уравнению. Решением ОДУ на интервале (a,b) называется функция **у = ф(х)**, которая при ее подстановке в исходное уравнение обращает его в тождество на (a,b)
Решение ОДУ в неявном виде **Ф(х, у) = 0** называется интегралом ОДУ
Существует множество возможных решений Для одного уникального решения
необходимо указать независимые условия (для системы размером 𝑛)
Например, когда 𝑛 условий заданы для одной точки **$у(0) = у_0$** называется задачей Коши

*Постановка задачи:* **Ly = f**
*   **L** - дифференциальный оператор
*   **у** - точное решение
*   **f** - правая часть
                    ''')
        elif m == 1:
            pc.copy(r'''

                    ''')
        else:
            pc.copy('Неправильный выбор типа задания')
            
    elif n == 15 or n == 'lok_glob':
        if m == 0:
            pc.copy(r'''
- *Локальные погрешности* – погрешности, образовавшиеся на каждом шаге,
- *Глобальная (накопленная) погрешность* – погрешность, образовавшаяся за несколько шагов.

Порядок глобальной погрешности относительно шага интегрирования на единицу ниже, чем порядок локальной погрешности. Таким образом, глобальная погрешность метода Эйлера имеет порядок p = 1: $g_1$ = C ∙ h, где C– некоторая постоянная.

Порядок численного метода для решения ОДУ определяется порядком его глобальной погрешности. Он может быть также определен, как количество вычислений значения производной f(x, y) искомой функции на каждом шаге. В соответствии с этим метод Эйлера является методом первого порядка.

Рассмотрим решение обыкновенного дифференциального уравнения первого порядка:

$\frac{dx}{dy}$ = $\frac{y}{(cos(x))^2}$

методом Эйлера на отрезке [0, 1] с шагом h = 0.1.

Начальные условия: $x_0$ = 0; $y_0$ = 2.7183.
Построим таблицу значений переменной $y_i$ при соответствующих значениях переменной $x_i$.

1. $y_1 = y_0 + h \cdot \frac{y_0}{(\cos(x_0))^2} = 2.7183 + 0.1 \cdot \frac{2.7183}{(\cos(0))^2} = 2.9901$

2. $y_2 = y_1 + h \cdot \frac{y_1}{(\cos(x_1))^2} = 2.9901 + 0.1 \cdot \frac{2.9901}{(\cos(0.1))^2} = 3.2922$

3. $y_3 = y_2 + h \cdot \frac{y_2}{(\cos(x_2))^2} = 3.2922 + 0.1 \cdot \frac{3.2922}{(\cos(0.2))^2} = 3.6349$

                    ''')
        elif m == 1:
            pc.copy(r'''
import math
def func(x, y):
  return y / math.cos(x) ** 2

def eiler(func, x0, xf, y0, h):
  count = int((xf - x0) / h) + 1
  y = [y0]
  x = x0
  for i in range (1, count):
    y.append(y[i-1] + h * func(x, y[i-1]))
    x += h
  return y

print(eiler(func, 0, 1, 2.7183, 0.1))
                    ''')
        else:
            pc.copy('Неправильный выбор типа задания')
            
    elif n == 16 or n == 'met_centr':
        if m == 0:
            pc.copy(r'''
Принцип метода центральной разности  
Метод центральной разности — это способ численного приближения производной функции. Идея основана на том, что вместо прямой (продвинутой) или обратной разности для оценки производной берётся средняя точка между двумя соседними значениями функции. Это даёт более высокую точность по сравнению с методами вперёд или назад, так как ошибка аппроксимации имеет порядок $O(h^2)$, где $h$ — шаг сетки.

Рассмотрим функцию $f(x)$. Для оценки первой производной в точке $x_0$ используем формулу:
$f'(x_0) \approx \frac{f(x_0 + h) - f(x_0 - h)}{2h}.$

Для второй производной (при условии достаточной гладкости $f$) формула будет:
$f''(x_0) \approx \frac{f(x_0 + h) - 2f(x_0) + f(x_0 - h)}{h^2}.$

Чем меньше шаг $h$, тем точнее получается результат, однако при этом растёт влияние ошибок округления и вычислительные затраты (если речь идёт о задаче с большим числом узлов).

Пример вычисления первой производной  
Пусть дана функция $f(x) = \sin(x)$. Мы хотим найти значение производной $f'(x)$ при $x_0 = 2$ с шагом $h = 0.01$ методом центральной разности. Аналитическая производная для $\sin(x)$ — это $\cos(x)$. Значит, точное значение в точке $x_0 = 2$ равно $\cos(2)$.
                    ''')
        elif m == 1:
            pc.copy(r'''
import numpy as np
def central_difference_first_derivative(f, x_0, x_end, h, N = 1000):
    """
    f: функция f(x), исходная функция
    x_0: начальное значение x
    x_end: конечное значение x
    N: количество шагов
    """
    x = np.linspace(x_0, x_end, N)
    return (f(x + h) - f(x - h)) / (2 * h)

def f(x):
    return x**3*np.cos(x) # Исходная функция
h = 0.1
x_0 = 0
x_end = 10
N = 100
x = np.linspace(x_0, x_end, N)
y_true = 3*x**2 * np.cos(x) - x**3*np.sin(x) # Производная исходной функции
y_pred = central_difference_first_derivative(f, x_0, x_end, h, N)
                    ''')
        else:
            pc.copy('Неправильный выбор типа задания')
            
    elif n == 17 or n == 'met_euler':
        if m == 0:
            pc.copy(r'''
Метод Эйлера — это численный метод решения задачи Коши для обыкновенных дифференциальных уравнений первого порядка. Его идея заключается в приближенном вычислении значений искомой функции \( y(x) \) в узловых точках \( x_i \), используя разложение функции в ряд Тейлора.

1. **Формула разложения в ряд Тейлора в окрестности точти**

  $$
  f(x) = f(x_0) + f'(x_0)(x - x_0) + \frac{f''(x_0)}{2!}(x - x_0)^2 + \frac{f^{(3)}(x_0)}{3!}(x - x_0)^3 + \dots + \frac{f^{(n)}(x_0)}{n!}(x - x_0)^n + \dots
  $$

  В точке x0 + h при малых значениях h достаточно использовать
  только два слагаемых ряда, получим
  $$ y(x) = y(x_0+h) = y(x_0) + y'(x_0) \Delta x + O(h^2)$$
  где O(h^2) – бесконечно малая величина порядка h^2.

2. **Формула метода**:
  
  После преобразования получим:
   $$y_{n+1} = y_n + hf(x_n, y_n)$$
   где h — шаг интегрирования, f(x, y) — правая часть дифференциального уравнения.

3. **Алгоритм**:
   - Известны начальные условия x_0 и y_0.
   - Значения y вычисляются последовательно для точек x_1 = x_0 + h, x_2 = x_1 + h, используя формулу выше.

4. **Особенности**:
   - **Простота реализации**: метод основывается на линейной аппроксимации.
   - **Погрешность**:
     - Локальная погрешность метода имеет порядок O(h^2).
     - Глобальная погрешность O(h) .
   - **Точность**: Увеличивается с уменьшением шага h, но слишком малый шаг увеличивает вычислительные затраты.
                    ''')
        elif m == 1:
            pc.copy(r'''
import numpy as np
import matplotlib.pyplot as plt

def method_euler(f, x_0, x_end, y_0, N = 1000):
    """
    f: функция f(x, y), задающая производную исходной функции
    x_0: начальное значение x
    x_end: конечное значение x
    y_0: начальное значение y
    N: количество шагов
    """
    h = (x_end - x_0) / N
    x = np.linspace(x_0, x_end, N+1)
    y = np.zeros(N+1)
    y[0] = y_0

    for n in range(N):
        y[n+1] = y[n] + h * f(x[n], y[n])

    return x, y

def f(x, y):
    return  3*x**2 * np.cos(x) - x**3*np.sin(x) # Производная исходной функции
x_0 = 0
x_end = 10
y_0 = 0
N = 100
x, y = method_euler(f, x_0, x_end, y_0, N)
y_true = x**3*np.cos(x) # Изначальная функция
                    ''')
        else:
            pc.copy('Неправильный выбор типа задания')
            
    elif n == 18 or n == 'met_predictor':
        if m == 0:
            pc.copy(r'''
На каждом шаге вводятся два этапа, использующих многошаговые методы:

1. С помощью явного метода (**предиктора**) по известным значениям функции в предыдущих узлах находится начальное приближение:

   $$
   y_{i+1} = y_{i+1}^{(0)}
   $$

   в новом узле.

2. Используя неявный метод (**корректора**), в результате итераций находятся приближения:

   $$
   y_{i+1}^{(1)}, \, y_{i+1}^{(2)}, \, \dots
   $$

Один из вариантов метода прогноза и коррекции может быть получен на основе метода Адамса четвертого порядка:

- **На этапе предиктора**:

  $$
  y_{i+1} = y_i + \frac{h}{24} \left( 55f_i - 59f_{i-1} + 37f_{i-2} - 9f_{i-3} \right)
  $$

- **На этапе корректора**:

  $$
  y_{i+1} = y_i + \frac{h}{24} \left( 9f_{i+1} + 19f_i - 5f_{i-1} + f_{i-2} \right)
  $$

Явная схема используется на каждом шаге один раз, а с помощью неявной схемы строится итерационный процесс вычисления $y_{i+1}$, поскольку это значение входит в правую часть выражения $f_{i+1} = f(x_{i+1}, y_{i+1})$. 

Расчёт по этому методу может быть начат только со значения $y_4$. Необходимые при этом $y_1, y_2, y_3$ находятся по методу Рунге-Кутта, $y_0$ задаётся начальным условием.                    
                    ''')
        elif m == 1:
            pc.copy(r'''
import numpy as np
import matplotlib.pyplot as plt

def predictor_corrector(f, t0, y0, t_end, h):
    """
    Метод предиктора-корректора для решения ODE.

    Параметры:
    f    : функция, задающая правую часть уравнения dy/dt = f(t, y)
    t0   : начальное время
    y0   : начальное значение
    t_end: конечное время
    h    : шаг

    Возвращает:
    t : массив времени
    y : массив решений
    """
    # Инициализация массивов времени и решений
    t = np.arange(t0, t_end + h, h)
    y = np.zeros_like(t)
    y[0] = y0

    # Основной цикл
    for i in range(1, len(t)):
        # Предиктор (явный метод Эйлера)
        y_pred = y[i - 1] + h * f(t[i - 1], y[i - 1])

        # Корректор (метод трапеций)
        y[i] = y[i - 1] + h / 2 * (f(t[i - 1], y[i - 1]) + f(t[i], y_pred))

    return t, y

# Пример использования
def f(t, y):
    return -2 * t * y  # Пример: dy/dt = -2 * t * y

t0 = 0
y0 = 1
t_end = 5
h = 0.1

t, y = predictor_corrector(f, t0, y0, t_end, h)

# График
plt.plot(t, y, label='Predictor-Corrector', marker='o')
plt.xlabel('t')
plt.ylabel('y')
plt.title('Solution of dy/dt = -2 * t * y using Predictor-Corrector')
plt.grid(True)
plt.legend()
plt.show()
                    ''')
        else:
            pc.copy('Неправильный выбор типа задания')
            
    elif n == 19 or n == 'met_runge':
        if m == 0:
            pc.copy(r'''
*референс: слайды 152-173*
\
\
Метод Рунге–Кутты широко используется при интегрировании обыкновенных дифференциальных уравнений (ОДУ) вида:

$$
\begin{cases}
y'(t) = f\bigl(t,\,y(t)\bigr),\\
y(t_0) = y_0.
\end{cases}
$$

По умолчанию, когда говорят «метод Рунге–Кутты», чаще всего имеют в виду метод Рунге–Кутты 4-го порядка точности. Однако существуют и методы 1-го, 2-го и 3-го порядков, имеющие ту же общую идею.
\
\
**Общая идея построения**  
Для построения разностной схемы интегрирования можно воспользоваться разложением искомой функции \(y(x)\) в ряд Тейлора:  
$$
y(x_{k+1}) \;=\; y(x_k) \;+\; y'(x_k)\,h \;+\; \frac{y''(x_k)}{2}\,h^2 \;+\;\dots
$$
где $h = x_{k+1} - x_k$.  

Чтобы заменить вторую производную $y''(x_k)$, мы используем приближение производной правой части $f(x,y)$. Например:  
$$
y''(x_k) \;=\; \bigl(y'(x_k)\bigr)' \;=\; f'(x_k,\,y(x_k))
\;\approx\;
\frac{\,f(\tilde{x},\,\tilde{y}) \;-\; f\bigl(x_k,\,y(x_k)\bigr)\,}{\,\Delta x\,},
$$
где $\tilde{x} = x_k + \Delta x$ и $\tilde{y} = y\bigl(x_k + \Delta x\bigr)$.  
Выбирая $\Delta x$ подходящим образом, получают различные схемы **Рунге–Кутты**
\
\
**Общая формула для метода Рунге–Кутты**  
$$
y_{k+1}
\;=\;
y_{k}
\;+\;
h\,\Bigl[
(1-\alpha)\,f(x_k,y_k)
\;+\;
\alpha\,f\Bigl(
x_k + \tfrac{h}{2\alpha},
\;
y_k + f(x_k,y_k)\,\tfrac{2h}{\alpha}
\Bigr)
\Bigr].
$$

Для разных значений $\alpha$ получаются различные частные случаи:

При $\alpha=0$ получается метод Эйлера (или же метод Рунге-Кутты 1-го порядка):
$$
y_{k+1} \;=\; y_{k} \;+\; h\,f\bigl(x_k,\,y_k\bigr).
$$
\
При $\alpha=0.5$ получается классический метод Рунге–Кутты 2-го порядка (иногда называемый «метод Эйлера с пересчётом»):
$$
y_{k+1}
\;=\;
y_{k}
\;+\;
\frac{h}{2}\,\Bigl[
f\bigl(x_k,\,y_k\bigr)
\;+\;
f\bigl(x_k + h,\,y_k + h\,f(x_k,y_k)\bigr)
\Bigr].
$$
\
\
**Метод Рунге–Кутты 1-го порядка (метод Эйлера)**  
Самый простой вариант, фактически совпадает со схемой Эйлера:  
$$
y_{i} \;=\; y_{i-1} \;+\; h\,f\bigl(x_{i-1},\,y_{i-1}\bigr).
$$
\
\
**Метод Рунге–Кутты 2-го порядка**  
Частным случаем является схема (иногда её называют «Эйлера с пересчётом» или «метод Хойна»):
\
\
$$
\begin{cases}
K_1 = f\bigl(x_{i-1},\,y_{i-1}\bigr),\\[6pt]
K_2 = f\!\Bigl(x_{i-1} + h,\;y_{i-1} + h\,K_1\Bigr),\\[6pt]
y_i = y_{i-1} + \frac{h}{2}\,\bigl(K_1 + K_2\bigr).
\end{cases}
$$
\
**Метод Рунге–Кутты 4-го порядка (классический РК4)**  
На практике чаще всего применяют **метод Рунге–Кутты 4-го порядка**:
:$$
\begin{cases}
K_1 = f\bigl(x_{i-1},\,y_{i-1}\bigr),\\[6pt]
K_2 = f\!\Bigl(x_{i-1} + \tfrac{h}{2},\;y_{i-1} + \tfrac{h}{2}\,K_1\Bigr),\\[6pt]
K_3 = f\!\Bigl(x_{i-1} + \tfrac{h}{2},\;y_{i-1} + \tfrac{h}{2}\,K_2\Bigr),\\[6pt]
K_4 = f\!\Bigl(x_{i-1} + h,\;y_{i-1} + h\,K_3\Bigr),\\[6pt]
y_i = y_{i-1} + \frac{h}{6}\,\bigl(K_1 + 2\,K_2 + 2\,K_3 + K_4\bigr).
\end{cases}
$$
                    ''')
        elif m == 1:
            pc.copy(r'''
import numpy as np
import matplotlib.pyplot as plt

# сама функция Рунге-Кутта 4-го порядка
def runge_kutta_4(f, t0, y0, h, n_steps):
    t_values = np.linspace(t0, t0 + n_steps*h, n_steps + 1)
    y_values = np.zeros(n_steps + 1)

    y_values[0] = y0

    for i in range(n_steps):
        t = t_values[i]
        y = y_values[i]

        K1 = f(t,         y)
        K2 = f(t + h/2.0, y + h/2.0*K1)
        K3 = f(t + h/2.0, y + h/2.0*K2)
        K4 = f(t + h,     y + h*K3)

        y_values[i+1] = y + (h/6.0)*(K1 + 2.0*K2 + 2.0*K3 + K4)

    return t_values, y_values

# пример y'(t) = -2y,  y(0) = 1
def f_example(t, y):
    return -2*y

t0 = 0.0
y0 = 1.0
h = 0.1
n_steps = 50

t_vals, y_vals = runge_kutta_4(f_example, t0, y0, h, n_steps)

y_exact = y0 * np.exp(-2 * t_vals)

plt.plot(t_vals, y_vals, 'o-', label='РК4')
plt.plot(t_vals, y_exact, 'r--', label='Точное решение')
plt.legend()
plt.grid(True)
plt.xlabel('t')
plt.ylabel('y(t)')
plt.show()

                    ''')
        else:
            pc.copy('Неправильный выбор типа задания')
            
    elif n == 20 or n == 'met_adams':
        if m == 0:
            pc.copy(r'''
**Явный метод Адамса:** Использует предыдущие значения $𝑦_𝑛
, 𝑦_{𝑛−1}
, … $ для
аппроксимации следующего значения $𝑦_{𝑛+1}
$.  
Формула для трехшагового метода
Адамса-Башфорта:  
$$y_{n+1} = y_n + \frac{h}{12}(23f(t_n, y_n) - 16f(t_{n-1}, y_{n-1}) + 5f(t_{n-2}, y_{n-2}))$$

**Неявный метод Адамса:** Использует текущие и будущие значения для более
точного результата.  
Формула для трехшагового метода Адамса-Мултона:  
$$y_{n+1} = y_n + \frac{h}{12}(5f(t_{n+1}, y_{n+1}) + 8f(t_{n}, y_{n}) - f(t_{n-1}, y_{n-1}))$$

**Явные методы**  
Преимущества: Простота реализации и вычислительная эффективность.
Недостатки: Ограниченная стабильность, особенно для жестких систем.  
**Неявные методы**  
Преимущества: Более высокая стабильность, подходящие для жестких
систем.  
Недостатки: Более сложная реализация и необходимость решения
нелинейных уравнений на каждом шаге.
                    ''')
        elif m == 1:
            pc.copy(r'''
def f(t, y):
    return -y + np.sin(t)  # Пример функции

# Явный метод Адамса (Адамса-Бэшфорта) для трехшагового метода
def adams_bashforth_3(f, t, y0, h):
    n = len(t)
    y = np.zeros(n)
    y[0] = y0

    # Начальные значения y1, y2 получаем методом Рунге-Кутты 2-го порядка
    y[1] = y[0] + h * f(t[0], y[0])
    y[2] = y[1] + h * f(t[1], y[1])

    for i in range(2, n - 1):
        y[i + 1] = y[i] + (h / 12) * (23 * f(t[i], y[i]) - 16 * f(t[i - 1], y[i - 1]) + 5 * f(t[i - 2], y[i - 2]))

    return y

# Неявный метод Адамса (Адамса-Мултона) для трехшагового метода
def adams_moulton_3(f, t, y0, h, tol=1e-6, max_iter=10):
    n = len(t)
    y = np.zeros(n)
    y[0] = y0

    # Начальные значения y1, y2 получаем методом Рунге-Кутты 2-го порядка
    y[1] = y[0] + h * f(t[0], y[0])
    y[2] = y[1] + h * f(t[1], y[1])

    for i in range(2, n - 1):
        # Предиктор: используем явный метод для начального приближения y[i+1]
        y_pred = y[i] + (h / 12) * (23 * f(t[i], y[i]) - 16 * f(t[i - 1], y[i - 1]) + 5 * f(t[i - 2], y[i - 2]))

        # Корректор: итерационно решаем нелинейное уравнение для y[i+1]
        y_new = y_pred
        for _ in range(max_iter):
            y_next = y[i] + (h / 12) * (5 * f(t[i + 1], y_new) + 8 * f(t[i], y[i]) - f(t[i - 1], y[i - 1]))
            if np.abs(y_next - y_new) < tol:
                break
            y_new = y_next

        y[i + 1] = y_new

    return y

# Пример использования
t = np.arange(0, 2, 0.1)  # Временные шаги
y0 = 1.0  # Начальное условие
h = 0.1   # Шаг
                    ''')
        else:
            pc.copy('Неправильный выбор типа задания')
            
    elif n == 21 or n == 'met_miln':
        if m == 0:
            pc.copy(r'''
Метод Милна относится к многошаговым методам и представляет один из методов
прогноза и коррекции (предиктора-корректора).

Решение в следующей точке находится в два этапа.

На **первом этапе** осуществляется по специальной
формуле **прогноз** значения функции, а
затем на **втором этапе** - **коррекция**
полученного значения.

Если полученное значение у после
коррекции существенно отличается от
спрогнозированного, то проводят еще
один этап коррекции.

------------

Метод Милна имеет следующие вычислительные формулы:\
а) этап предположения (прогноза):\
$y_i^{пред} = y_{i-4} + \frac{4*h}{3}(2f_{i-3} - f_{i-2} + 2f_{i-1})$

где для компактности записи использовано следующее обозначение\
f_i = f(x_i, y_i)


б) этап коррекции:\
$y_i^{корр} = y_{i-2} + \frac{h}{3}(f_{i-2} + 4f_{i-1} + f_i^{пред})$

---------------

Метод требует несколько меньшего количества вычислений (например, достаточно
только два раза вычислить остальные запомнены с предыдущих этапов), но
требует дополнительного "расхода" памяти. Кроме этого, как уже указывалось выше,
невозможно "запустить" метод: для этого необходимо предварительно получить
одношаговыми методами первые три точки.

Первые три точки можно получить с помощью метода Рунге-Кутты

------------------
                    ''')
        elif m == 1:
            pc.copy(r'''
import numpy as np

def milne_method(f, x_0, x_n, y_0, N):
    dx = (x_n - x_0) / N
    x = np.linspace(x_0, x_n, N + 1)
    y = np.zeros((N + 1, len(y_0)))
    fn = np.zeros_like(y)
    y[0, :] = y_0

    # Инициализация методом Рунге-Кутта 4-го порядка для первых 4 точек
    for n in range(3):
        k1 = dx * f(x[n], y[n, :])
        k2 = dx * f(x[n] + dx / 2, y[n, :] + k1 / 2)
        k3 = dx * f(x[n] + dx / 2, y[n, :] + k2 / 2)
        k4 = dx * f(x[n] + dx, y[n, :] + k3)
        y[n + 1, :] = y[n, :] + 1 / 6 * (k1 + 2 * k2 + 2 * k3 + k4)

    # Вычисляем значения функции f(x, y) для первых 4 точек
    for n in range(4):
        fn[n, :] = f(x[n], y[n, :])

    # Основной цикл метода Милна
    for n in range(3, N):
        # Предсказание (Milne predictor)
        y_pred = y[n - 3, :] + 4 * dx / 3 * (2 * fn[n, :] - fn[n - 1, :] + 2 * fn[n - 2, :])

        # Коррекция (Milne corrector)
        fn_pred = f(x[n + 1], y_pred)
        y[n + 1, :] = y[n - 1, :] + dx / 3 * (fn_pred + 4 * fn[n, :] + fn[n - 1, :])
        # Обновляем значение функции f(x, y) для нового шага
        fn[n + 1, :] = f(x[n + 1], y[n + 1, :])

    return x, y

def equations(x, y):
    return np.array([np.arctan(1 / (1 + y[0]**2 + y[1]**2)), np.sin(y[0] * y[1])])

x, y = milne_method(equations, -1.0, 5.0, [-1.0, -1.0], 60000)

print("Последние значения x:", x[-1])
print("Последние значения y:", y[-1])
                    ''')
        else:
            pc.copy('Неправильный выбор типа задания')
            
    elif n == 22 or n == 'sogl_ust_shod':
        if m == 0:
            pc.copy(r'''
*референс слайды 227-229*

При рассмотрении методов численного решения ОДУ вида  
$$
\sum_{i=0}^{k} \alpha_i\,y_{n+i}
\;=\;
h \sum_{i=0}^{n} \beta_i\,f\bigl(x_{n+i},\,y_{n+i}\bigr),
\quad
n = 0,1,2,\dots
$$
важными свойствами метода являются:


1. **Согласованность**  

   Пусть $y(x)$ — точное решение дифференциального уравнения. Подставим $y(x_{n+i})$ вместо $y_{n+i}$ в разностную схему. Тогда невязка
   $$
   \rho_{n+k}
   \;=\;
   \sum_{i=0}^k \alpha_i \,y(x_{n+i})
   \;-\;
   h \sum_{i=0}^n \beta_i \,f\bigl(x_{n+i},\,y(x_{n+i})\bigr)
   $$
   называется погрешностью аппроксимации (или локальной ошибкой метода).  
   - Если $\rho_{n+k} = O\bigl(h^{\,s+1}\bigr)$, то говорят, что порядок аппроксимации (или степень разностного уравнения) равен $s$.  
   - Соответственно, $r_{n+k} = \rho_{n+k}\,/\,h$ называют погрешностью дискретизации.  

   Метод называется **согласованным** с порядком $s$, если при $h \to 0$ невязка $\rho_{n+k}$ убывает как $O\bigl(h^{\,s+1}\bigr)$ для любого точного решения.


2. **Нуль-устойчивость**  

   Рассмотрим характеристический многочлен
   $$
   \rho(\theta)
   \;=\;
   \sum_{i=0}^{k} \alpha_i\,\theta^{\,i},
   $$
   возникающий при анализе однородной части разностной схемы.  
   Метод называют **нуль-устойчивым**, если все корни $\theta_i$ многочлена $\rho(\theta)$ лежат внутри или на единичной окружности в комплексной плоскости, причём корни на окружности являются простыми (не имеют кратности больше 1).   

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


3. **Сходимость**  

   Пусть $y_{n}$ — численное решение (вычисленное методом), а $y(x)$ — точное решение задачи Коши. Говорят, что метод **сходится**, если
   $$
   y_n \;\to\; y\bigl(x_n\bigr)
   \quad
   \text{при }
   h \to 0,\,
   n = \frac{x - x_0}{h}.
   $$
   На практике это означает, что точность возрастает при уменьшении шага h.

   **Основная теорема** (аналог теоремы Лакса в теории разностных схем) утверждает, что:
   - Метод из данного класса **сходится** тогда и только тогда, когда он **согласован** и **нуль-устойчив**.  
   - Порядок сходимости равен порядку согласованности $s$.


4. **Интерпретация**  
   - **Согласованность** определяет «уровень» локальной погрешности (насколько точно разностная схема аппроксимирует само дифференциальное уравнение).  
   - **Нуль-устойчивость** описывает, как «накопленные» ошибки (начальные или вычислительные) будут вести себя по мере продвижения по шагам.  
   - Если метод согласован и нуль-устойчив, то он **сходится**, то есть глобальная погрешность $y_n - y(x_n)$ исчезает при $h \to 0$.
                    ''')
        elif m == 1:
            pc.copy(r'''
сомневаюсь, что тут можно какой-то код придумать
                    ''')
        else:
            pc.copy('Неправильный выбор типа задания')
            
    elif n == 23 or n == 'volna':
        if m == 0:
            pc.copy(r'''
* Амплитуда ($A$): Максимальное отклонение волны от положения равновесия. Определяет "высоту" волны.

* Период ($T$): Время, за которое волна совершает одно полное колебание. Измеряется в секундах.

* Длина волны ($λ$): Пространственное расстояние между двумя соседними точками волны, колеблющимися в одинаковых фазах. Измеряется в метрах.

* Частота ($f$): Количество полных колебаний, совершаемых волной за единицу времени. Измеряется в герцах (Гц). Связана с периодом соотношением: $f = 1/T.$

* Угловая частота ($ω$): Скорость изменения фазы волны. Измеряется в радианах в секунду. Связана с частотой соотношением: $ω = 2πf.$

* Фаза ($φ$): Определяет начальное положение точки волны в момент времени $t=0.$

* Дискретизация: Процесс представления непрерывной функции (например, волны) в виде дискретного набора точек.

* Частота дискретизации: Количество отсчетов, берущихся за единицу времени.

Ряды Фурье

Ряды Фурье позволяют изучать периодические (и непериодические)
функции при помощи представления их функциональными рядами. Комплексная форма ряда Фурье является наиболее употребимой в обработке сигналов.

Рассмотрим произвольную периодическую функцию $f(x)$ с периодом $2l$. Ее разложение в тригонометрический ряд Фурье имеет вид:

\begin{equation}
f(x) = \frac{a_0}{2} + \sum_{n=1}^{\infty} \left[ a_n \cos\left(\frac{n\pi x}{l}\right) + b_n \sin\left(\frac{n\pi x}{l}\right) \right]
\end{equation}

Для более компактной записи ряда Фурье можно использовать следующую формулу:

\begin{equation}
f(x) = \frac{a_0}{2} + \sum_{n=1}^{\infty} A_n \sin\left(\frac{n\pi x}{l} + \phi_n\right)
\end{equation}

где:

\begin{align*}
A_n &= \sqrt{a_n^2 + b_n^2} \\
\phi_n &= \arctan{\frac{b_n}{a_n}}
\end{align*}

Величина $A_n$ называется амплитудой $n$-ой гармоники, а $\phi_n$ – ее фазой. Совокупность всех амплитуд $A_n$ образует амплитудный спектр, а совокупность фаз $\phi_n$ – фазовый спектр сигнала.
                    ''')
        elif m == 1:
            pc.copy(r'''
import numpy as np
import matplotlib.pyplot as plt

# Параметры волны
A = 1.0             # Амплитуда
T = 1.0             # Период
f = 1 / T           # Частота (Гц)
omega = 2 * np.pi * f  # Угловая частота
phi = 0.0           # Фаза (радианы)
sampling_rate = 100  # Частота дискретизации (Гц)
duration = 2.0       # Длительность сигнала (секунды)

# Дискретизация времени
t = np.linspace(0, duration, int(sampling_rate * duration), endpoint=False)

# Моделирование синусоидальной волны
wave = A * np.sin(omega * t + phi)

# Визуализация волны
plt.figure(figsize=(10, 4))
plt.plot(t, wave, label="Синусоидальная волна")
plt.title("Моделирование волны")
plt.xlabel("Время (с)")
plt.ylabel("Амплитуда")
plt.grid(True)
plt.legend()
plt.show()

# Применение ряда Фурье (быстрое преобразование Фурье)
n = len(wave)
frequencies = np.fft.fftfreq(n, d=1/sampling_rate)
fft_values = np.fft.fft(wave)

# Модуль и фаза спектра
amplitude_spectrum = np.abs(fft_values) / n
phase_spectrum = np.angle(fft_values)

# Визуализация амплитудного спектра
plt.figure(figsize=(10, 4))
plt.stem(frequencies[:n // 2], amplitude_spectrum[:n // 2])
plt.title("Амплитудный спектр (ряд Фурье)")
plt.xlabel("Частота (Гц)")
plt.ylabel("Амплитуда")
plt.grid(True)
plt.show()

# Визуализация фазового спектра
plt.figure(figsize=(10, 4))
plt.stem(frequencies[:n // 2], phase_spectrum[:n // 2])
plt.title("Фазовый спектр (ряд Фурье)")
plt.xlabel("Частота (Гц)")
plt.ylabel("Фаза (радианы)")
plt.grid(True)
plt.show()
                    ''')
        else:
            pc.copy('Неправильный выбор типа задания')
            
    elif n == 24 or n == 'dft':
        if m == 0:
            pc.copy(r'''
Дискретное преобразование Фурье: $X(n) = \sum_{k = 0}^{N - 1} x(k) * exp^{\frac{-2j * \pi * k * n} {N}}$ \
Обратное дискретное преобразование Фурье: $x(k) = \frac{1}{N} \sum_{n = 0}^{N - 1} X(n) * exp^{\frac{2j * \pi * k * n} {N}}$ \
**Ограничения**


1. При перемножении сигналов их длины должны быть
одинаковыми (N);

2. Cуммирование элементов произведения должно
производиться по одному периоду (полученный результат
называется круговой сверткой спектров исходных
сигналов).

3. $O(N^{2})$ - время работы алгоритма

Симметрия для действительных сигналов:

Если
$x[n]$ — действительный сигнал, то его спектр
$X[k]$ обладает следующими свойствами:
$X[N−k] = \overline{X[k]}$, где $\overline{X[k]}$ — комплексное сопряжение.
Это означает, что спектр симметричен относительно середины.

                    ''')
        elif m == 1:
            pc.copy(r'''
def dft(signal):
    N = len(signal)
    spectrum = np.zeros(N, dtype = complex)
    for k in range(N):
        X_k = 0
        for n in range(N):
            X_k += signal[n] * np.exp(-2j * np.pi * k * n / N)
        spectrum[k] = X_k
    return spectrum

def idft(spectrum):
    N = len(spectrum)
    signal = np.zeros(N)
    for n in range(N):
        x_n = 0
        for k in range(N):
            x_n += spectrum[k] * np.exp(2j * np.pi * k * n / N)
        x_n = x_n / N
        signal[n] = x_n.real
    return signal
                    ''')
        else:
            pc.copy('Неправильный выбор типа задания')
            
    elif n == 25 or n == 'fft':
        if m == 0:
            pc.copy(r'''
**Принцип алгоритма БФП** \
БПФ можно разделить на две меньшие части. В отличие от прямого ДПФ, который требует  $O(N^{2})$  вычислений, алгоритм БПФ использует свойство симметрии и делит задачу на более мелкие подзадачи. Сначала разделяем исходное преобразование на два: одно для четных, другое для нечетных индексов. Каждый из этих подзадач похож на ДПФ меньшего размера (на $N/2$), и для их вычисления потребуется меньше операций. Этот процесс рекурсивно повторяется, пока размер подзадачи не станет слишком малым, чтобы применять дальнейшее разделение. Каждый шаг уменьшает количество вычислений, и в итоге общая сложность алгоритма становится
$O(N\log{N})$, что значительно быстрее прямого ДПФ.

**Фильтрация сигнала** \
Применить БПФ к сигналу, чтобы преобразовать его в частотную область. Дальше обнуляются или уменьшаются амплитуды на определенных частотах. Применяется обратное преобразование Фурье, чтобы вернуть сигнал в временную область.

                    ''')
        elif m == 1:
            pc.copy(r'''
def fft(signal):
    n = len(signal)
    if n <= 1:
        return signal

    even = fft(signal[::2])
    odd = fft(signal[1::2])

    combined = np.zeros(n, dtype=complex)
    for k in range(n // 2):
        t = np.exp(-2j * np.pi * k / n) * odd[k]
        combined[k] = even[k] + t
        combined[k + n // 2] = even[k] - t

    return combined

def ifft(signal):
    n = len(signal)
    if n <= 1:
        return signal

    even = ifft(signal[::2])
    odd = ifft(signal[1::2])

    combined = np.zeros(n, dtype=complex)
    for k in range(n // 2):
        t = np.exp(2j * np.pi * k / n) * odd[k]
        combined[k] = (even[k] + t) / 2
        combined[k + n // 2] = (even[k] - t) / 2

    return combined
                    ''')
        else:
            pc.copy('Неправильный выбор типа задания')
            
    elif n == 26 or n == 'svertka_fft':
        if m == 0:
            pc.copy(r'''
**Определение**  
Пусть $f,g:\mathbb{R}^n \to \mathbb{R}$ — две функции, на пространстве $\mathbb{R}^n$. Тогда их свёрткой называется функция $f * g:\mathbb{R}^n \to \mathbb{R}$, определённая формулой
:$$
(f * g)(x)\
\stackrel{\mathrm{def}}{=}\ \int \limits_{\mathbb{R}^n} f(y)\, g(x-y)\, dy =
\int \limits_{\mathbb{R}^n} f(x-y)\, g(y)\, dy.
$$
В частности, при $n=1$ формула принимает вид
:$$
(f * g)(x)\
\stackrel{\mathrm{def}}{=}\ \int \limits_{-\infty}^{\infty} f(y)\, g(x-y)\, dy =
\int \limits_{-\infty}^{\infty} f(x-y)\, g(y)\, dy.
$$

**Связь с преобразованием Фурье**  
Одним из наиболее значимых свойств свёртки является её связь с преобразованием Фурье. Свёртка функций в пространстве $R^n$ преобразуется в произведение их преобразований Фурье:

$$F(f∗g)(ξ)=F(f)(ξ)⋅F(g)(ξ),$$

где
$F(f)$ — преобразование Фурье функции
$f$. Это свойство позволяет вычислять свёртку быстрее, особенно в случае высокоразмерных данных, используя быстрое преобразование Фурье (FFT).

**Дискретная свёртка**  
Для цифровой обработки данных используется дискретная свёртка. Пусть
$f[k]$ и $g[k]$ — дискретные последовательности. Тогда их дискретная свёртка определяется как:
$$(f∗g)[n]= \sum^∞ _{k=−∞} f[k]⋅g[n−k].$$

**Преимущества свёртки через FFT**  
Прямая вычислительная свёртка имеет сложность
$O(N
^2
 )$ для сигналов длины N.
Используя FFT, свёртка может быть выполнена с вычислительной сложностью
$O(NlogN)$, что существенно ускоряет обработку.
                    ''')
        elif m == 1:
            pc.copy(r'''
from scipy.signal import convolve

# Пример функций
def f(x):
    return np.exp(-x**2)  # Гауссиана

def g(x):
    return np.sin(2 * np.pi * x)  # Синусоида

# Дискретизация
x = np.linspace(-5, 5, 500)  # Интервал дискретизации
f_discrete = f(x)  # Дискретизация функции f
g_discrete = g(x)  # Дискретизация функции g

# Прямая свёртка
direct_convolution = np.convolve(f_discrete, g_discrete, mode="same")

# Свёртка с использованием FFT
fft_f = np.fft.fft(f_discrete)
fft_g = np.fft.fft(g_discrete)
fft_convolution = np.fft.ifft(fft_f * fft_g).real

# Встроенная дискретная свёртка (Scipy)
scipy_convolution = convolve(f_discrete, g_discrete, mode="same")
                    ''')
        else:
            pc.copy('Неправильный выбор типа задания')
            
    elif n == 27 or n == 'svertka_teplic':
        if m == 0:
            pc.copy(r'''
**Дискретную свёртку** можно представить как умножение матрицы на вектор:

$z_i = \sum_{j=0}^{n-1}x_jy_{i-j}\Leftrightarrow z = Ax$

где элементы матрицы ${A}$ равны ${a_{ij}} = y_{i-j}$, то есть они зависят только от разности между индексами строки и столбца.

------------------------

**Тёплицевы матрицы**\
**Матрица Тёплица** (диагонально-постоянная матрица) — матрица, в которой на всех диагоналях, параллельных главной, стоят равные элементы. То есть выполняется соотношение:

$$a_{ij} = t_{i - j}.$$

- Тёплицева матрица полностью определяется первой строкой и первым столбцом (то есть $2n-1$ параметр).

- Это плотная матрица, однако она имеет структуру, то есть определяется $\mathcal{O}(n)$ параметрами (сравните с разреженными матрицами)

- Основная операция для вычисления дискретной свёртки – это произведение Тёплицевой матрицы на вектор.

Общий вид:

$
T =
\begin{bmatrix}
t_0 & t_{-1} & t_{-2} & \cdots & t_{-n+1} \\
t_1 & t_0 & t_{-1} & \cdots & t_{-n+2} \\
t_2 & t_1 & t_0 & \cdots & t_{-n+3} \\
\vdots & \vdots & \vdots & \ddots & \vdots \\
t_{n-1} & t_{n-2} & t_{n-3} & \cdots & t_0
\end{bmatrix}.
$

------------------

**Ганкелевы матрицы**\
Ганкелева матрица — это матрица, у которой на всех диагоналях, перпендикулярных главной, стоят равные элементы.\
Общий вид:

$H =
\begin{bmatrix}
h_0 & h_1 & h_2 & \cdots & h_{n-1} \\
h_1 & h_2 & h_3 & \cdots & h_n \\
h_2 & h_3 & h_4 & \cdots & h_{n+1} \\
\vdots & \vdots & \vdots & \ddots & \vdots \\
h_{n-1} & h_n & h_{n+1} & \cdots & h_{2n-2}
\end{bmatrix}.
$
                    ''')
        elif m == 1:
            pc.copy(r'''
from scipy.linalg import toeplitz, hankel

# Дискретная свёртка через матричное умножение (Тёплицева матрица)
def discrete_convolution(x, y):
    n = len(x)
    m = len(y)
    # Создаем Тёплицеву матрицу на основе y
    first_col = np.concatenate(([y[0]], np.zeros(n - 1)))
    first_row = np.concatenate((y, np.zeros(n - 1)))
    toeplitz_matrix = toeplitz(first_col, first_row[:n])

    # Умножаем Тёплицеву матрицу на вектор x
    return toeplitz_matrix @ x

# Генерация Тёплицевой матрицы
def generate_toeplitz(first_col, first_row):
    return toeplitz(first_col, first_row)

# Генерация Ганкелевой матрицы
def generate_hankel(first_col, last_row):
    return hankel(first_col, last_row)
                    ''')
        else:
            pc.copy('Неправильный выбор типа задания')
            
    elif n == 28 or n == 'circulant':
        if m == 0:
            pc.copy(r'''
Циркулянтные матрицы = циркулянты

- Матрица $C$ называется циркулянтом, если

$$C_{ij} = c_{i - j \mod n},$$

то есть

$$C = \begin{bmatrix} c_0 & c_1 & c_2 & c_3 \\
 c_3 & c_0 & c_1 & c_2 \\
 c_2 & c_3 & c_0 & c_1 \\
 c_1 & c_2 & c_3 & c_0 \\
 \end{bmatrix}.
 $$

 ### Матрица Фурье

Матрица Фурье определяется как:

$$
F_n =
\begin{pmatrix}
1 & 1 & 1 & \dots & 1 \\
1 & w^{1\cdot 1}_n & w^{1\cdot 2}_n & \dots & w^{1\cdot (n-1)}_n\\
1 & w^{2\cdot 1}_n & w^{2\cdot 2}_n & \dots & w^{2\cdot (n-1)}_n\\
\dots & \dots & \dots &\dots &\dots \\
1 & w^{(n-1)\cdot 1}_n & w^{(n-1)\cdot 2}_n & \dots & w^{(n-1)\cdot (n-1)}_n\\
\end{pmatrix},
$$

или эквивалентно

$$ F_n = \{ w_n^{kl} \}_{k,l=0}^{n-1}, $$

где

$$w_n = e^{-\frac{2\pi i}{n}}.$$

**Свойства:**

* Симметричная (но не эрмитова!)
* Унитарна с точностью до нормализующего множителя: $F_n^* F_n = F_n F_n^* = nI$ (проверьте этот факт!). Поэтому $F_n^{-1} = \frac{1}{n}F^*_n$
* Может быть умножена на вектора (произведение называется дискретным преобразованием Фурье (DFT)) за  <font color='red'>$\mathcal{O}(n \log n)$</font> операций (операция называется быстрым преобразованием Фурье или <font color='red'>FFT</font>)!
* FFT помогает анализировать спектр сигнала и, как мы увидим далее, позволяет быстро умножать некоторый класс матриц на вектор.

### Спектральная теорема для циркулянтов

**Теорема**

Любая циркулянтная матрица может быть представлена в виде

$$C = \frac{1}{n} F^* \Lambda F,$$

где $F$ – **матрица Фурье** с элементами

$$F_{kl} = w_n^{kl}, \quad k, l = 0, \ldots, n-1, \quad w_n = e^{-\frac{2 \pi i}{n}},$$

а матрица $\Lambda = \text{diag}(\lambda)$ диагональная и

$$\lambda = F c, $$

где $c$ – первый столбец циркулянта $C$.
                    ''')
        elif m == 1:
            pc.copy(r'''
from scipy.linalg import circulant, dft

# Создание циркулянтной матрицы
def create_circulant(c):
    """
    Создает циркулянтную матрицу из первого столбца c.
    """
    return circulant(c)

# Создание матрицы Фурье
def create_fourier_matrix(n):
    """
    Создает матрицу Фурье размера n x n.
    """
    w_n = np.exp(-2j * np.pi / n)  # корень из единицы
    F = np.array([[w_n**(k * l) for l in range(n)] for k in range(n)])
    return F

# Проверка спектральной теоремы для циркулянтов
def spectral_theorem_circulant(c):
    """
    Проверяет спектральную теорему для циркулянтной матрицы.
    """
    n = len(c)
    C = create_circulant(c)  # Циркулянтная матрица
    F = create_fourier_matrix(n)  # Матрица Фурье
    F_star = np.conj(F.T)  # Сопряжённо-транспонированная матрица Фурье

    # Собственные значения
    lambdas = F @ c  # Диагональные элементы λ = F @ c
    Lambda = np.diag(lambdas)  # Диагональная матрица

    # Реконструкция циркулянта через спектральную теорему
    C_reconstructed = (1 / n) * F_star @ Lambda @ F

    return C, C_reconstructed, lambdas

# Пример использования
c = np.array([1, 2, 3, 4])  # Первый столбец циркулянтной матрицы

# Циркулянт и его спектральная теорема
C, C_reconstructed, lambdas = spectral_theorem_circulant(c)

# Матрица Фурье
F = create_fourier_matrix(len(c))
                    ''')
        else:
            pc.copy('Неправильный выбор типа задания')
            
    elif n == 29 or n == 'matvec':
        if m == 0:
            pc.copy(r'''
Циркулянт (circulant) — это специальный тип матрицы, в которой каждый ряд является циклическим сдвигом предыдущего ряда. Циркулянты часто встречаются в различных областях науки и инженерии, включая обработку сигналов, теорию управления и численные методы.

Быстрый метод решения систем линейных уравнений с циркулянтными матрицами называется "Быстрый матвек с циркулянтом". Этот метод использует свойства циркулянтных матриц и преобразование Фурье для значительного ускорения вычислений.

Свойства циркулянтных матриц
1. **Диагонализация с помощью преобразования Фурье**: - Циркулянтные матрицы могут быть диагонализированы с помощью матрицы преобразования Фурье. Если $ F $ — матрица преобразования Фурье, то: $$ C = F^* \Lambda F $$ где $ \Lambda $ — диагональная матрица, содержащая собственные значения $ C $.
2. **Собственные значения и векторы**: - Собственные значения циркулянтной матрицы $ C $ являются значениями дискретного преобразования Фурье (DFT) первого столбца $ c $. - Собственные векторы циркулянтной матрицы $ C $ являются столбцами матрицы преобразования Фурье $ F $.

Быстрый матвек с циркулянтом   

Для решения системы линейных уравнений $ Cx = b $, где $ C $ — циркулянтная матрица, можно использовать быстрый метод, основанный на преобразовании Фурье.

#### Алгоритм

1. **Вычисление DFT первого столбца $ c $**: - Вычислите DFT первого столбца $ c $ циркулянтной матрицы $ C $ для получения собственных значений $ \lambda $.

2. **Вычисление DFT правой части $ b $**: - Вычислите DFT правой части $ b $ для получения $ \hat{b} $.

3. **Поэлементное деление**: - Выполните поэлементное деление $ \hat{b} $ на собственные значения $ \lambda $ для получения $ \hat{x} $.

4. **Вычисление обратного DFT**: - Вычислите обратное DFT $ \hat{x} $ для получения решения $ x $.  

ДОДЕЛАТЬ (КАРТИНКА)
                    ''')
        elif m == 1:
            pc.copy(r'''
import numpy as np
import scipy as sp
import scipy.linalg

def circulant_matvec(c, x):
    return np.fft.ifft(np.fft.fft(c) * np.fft.fft(x))

n = 5000
c = np.random.random(n)
C = sp.linalg.circulant(c)
x = np.random.randn(n)


y_full = C.dot(x)
full_mv_time = %timeit -q -o C.dot(x)
print('Full matvec time =', full_mv_time.average)


y_fft = circulant_matvec(c, x)
fft_mv_time = %timeit -q -o circulant_matvec(c, x)
print('FFT time =', fft_mv_time.average)

print('Relative error =', (np.linalg.norm(y_full - y_fft)) / np.linalg.norm(y_full))
                    ''')
        else:
            pc.copy('Неправильный выбор типа задания')
    
    else:
        pc.copy('Неправильный выбор номера задания')