Skip to content

Latest commit

 

History

History

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
 
 
 
 

README.md

Упражнение 9

Динамично програмиране

Динамично програмиране е алгоритмична техника, при която решаваме сложен проблем като го разделяме на подпроблеми и запазваме резултатите им. Основата идея е всеки подпроблем да бъде решен точно веднъж и неговият резултат да бъде запазен в случай, че се нуждаем от него отново. С тази оптимизация можем да намалим сложността на някои алгоритми от експоненциална на полиномиална.

Техниката за запазване на вече получени резултати се нарича мемоизация. С тази техника печелим бързодействие откъм действие за сметка на памет.

За разлика от задачите "разделяй и владей", в случая на динамичното програмиране подпроблемите се препокриват (поради което запазваме резултатите им).

Една от дилемите на програмирането - Space-time tradeoff

Кога една задача се решава с динамично програмиране?

Има две основни характеристики на проблеми, които предполагат, че задача може да се реши с динамично програмиране:

  • (Optimal Substructure) Когато оптималното решение на задачата, може да се намери чрез оптималните решения на неговите подзадачи, вместо да се опитат всички възможни варианти.
  • (Overlapping Subproblems) Някои подзадачи трябва да бъдат пресметнати повече от веднъж, за да се реши финалният проблем.

Обикновено такива задачи търсят:

  • Максимална/минимална стойност
  • Брой начини да се направи нещо

Компоненти

State

Математическа функция, която съдържа отговора на дадена задача.

По по-прост начин казано, това е функция, която ни дава отговора на дадена задача и запазва този отговора в контейнер. При повторно извикване ни дава директно кешираната стойност.

База

Начални стойности на нашия state.

Рекурентна зависимост

Рекурентна зависимост е уравнение, което изразява state-a, чрез предишни негови стойности.

Подходи

Top-Down approach

Започваме от проблемът, който искаме да решим, разделяме го на подпроблеми, които искаме да решим, и така рекурсивно докато не стигнем базата. Имплементира се рекурсивно. С този подход пресмятаме само подпроблемите, които са ни нужни за финалния.

Bottom-Up approach

Започваме да решаваме от най-малките проблеми и от тях постепенно решаваме по-големи, докато не решим финалния, който искаме. Имплементира се итеративно. С този подход може да пресметнем резултатите на подпроблеми, които не са ни нужни за решаване на финалния. Полезен, когато се нуждаем от резултатите на всички подпроблеми.

Стъпки за решаване на такъв тип задачи

  • Измисляме state
  • Измисляме рекурентната зависимост
  • Пробваме

Примери с основни задачи

Пример с Фибоначи - Memoization

  • State - F(N) - връща N-тото число на Фибоначи
  • База - F(1) = 1, F(2) = 1
  • Рекурентно уравнение - F(N) = F(N - 1) + F(N - 2)

Ето и рекурсивна имплементация на алгоритъма:

C++
size_t fibonacci(size_t n) {
	if (n == 0 || n == 1) { // n == 0 - just to handle the zero edge case
		return n;
	}

	return fibonacci(n - 1) + fibonacci(n - 2);
}
Python
def fibonacci(n):
    if n == 0 or n == 1:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)

В тази имплементация на многократно се извиква fibonacci с един и същ входен параметър по време на изпълнение на функцията. Забележете колко пъти се пресмята fibonacci(3)

image

Затова можем да използваме мемоизация за да запазваме вече пресметнатите стойности и да не се налага да ги пресмятаме втори път.

C++
size_t fibonacci(size_t n) {
	if (n == 0 || n == 1) {
		return n;
	}
	static size_t dp[1000]{};

	if (dp[n] == 0) {
		dp[n] = fibonacci(n - 1) + fibonacci(n - 2);
	}

	return dp[n];
}
Python
memo = {}

def fibonacci(n):
    if n == 0 or n == 1:
        return n

    if n not in memo:
        memo[n] = fibonacci(n - 1) + fibonacci(n - 2)

    return memo[n]

В случая при извикване с n = 5, fibonacci(3) ще бъде пресметнато само първият път, а следващите пъти стойността ще бъде достъпвано директно от cache-a.

Пример с Фибоначи - Tabulation

Същата задача може да бъде и решена като започнем да решаваме по-малките проблеми и надграждайки да стигнем до проблема, който искаме да решим.

C++
long long fibonacci(size_t n) {
	if (n == 0 || n == 1) {
		return n;
	}

	long long prevPrev = 0, prev = 1, curr;
	for (size_t i = 2; i <= n; i++) {
		curr = prevPrev + prev;
		prevPrev = prev;
		prev = curr;
	}
	return curr;
}
Python
def fibonacci(n):
    if n == 0 or n == 1:
        return n

    prev_prev, prev = 0, 1
    for _ in range(2, n + 1):
        curr = prev_prev + prev
        prev_prev, prev = prev, curr

    return curr

Задачи с dp грид (матрица)

Пример - Unique Paths

Задачи, при които state-a представлява матрица. Съответно тези задачи имат сложност от към памет O(n^2). Много често в тези гридове ни трябва само предишния ред спрямо текущия, следователно може да пазим само по два реда и да ги разменяме на всяка стъпка, което сваля паметта до O(n).

C++
// O(n^2) memory
int uniquePaths(int m, int n) {
	vector<vector<int>> dp(m, vector<int>(n, 1));
 
	for(size_t i = 1; i < m; i++) {
		for(size_t j = 1; j < n; j++) {
			dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
		}
	}
        
	return dp[m - 1][n - 1];
}


// O(n) memory
int uniquePaths(int m, int n) {
	vector<int> prev(n, 1);    

	for(int i = 1; i < m; ++i) {
		vector<int> curr(n, 1);
		for(int j = 1; j < n; ++j) {
			curr[j] = prev[j] + curr[j - 1];
		}
		prev = std::move(curr);
	}
    
	return prev[n - 1];
}

// O(n) smarter (works in some cases)
int uniquePaths(int m, int n) {
	vector<int> dp(n, 1);
	for(int i = 1; i < m; ++i) {
		for(int j = 1; j < n; ++j) {
			dp[j] += dp[j-1];
		}
	}

	return dp[n-1];
}
Python
# O(n^2) memory
def uniquePaths(m, n):
    dp = [[1] * n for _ in range(m)]

    for i in range(1, m):
        for j in range(1, n):
            dp[i][j] = dp[i - 1][j] + dp[i][j - 1]

    return dp[m - 1][n - 1]


# O(n) memory
def uniquePaths(m, n):
    prev = [1] * n

    for _ in range(1, m):
        curr = [1] * n
        for j in range(1, n):
            curr[j] = prev[j] + curr[j - 1]
        prev = curr

    return prev[-1]


# O(n) smarter (works in some cases)
def uniquePaths(m, n):
    dp = [1] * n
    for _ in range(1, m):
        for j in range(1, n):
            dp[j] += dp[j - 1]
    return dp[-1]

Още ресурси

Задачи

Easy

Medium

Medium to hard

Бонус