Упражнения: Proxy, Stack и Queue
Напредък
💡 Напредъкът се записва локално в браузъра
Практически задачи за затвърдяване на знанията по:
- Proxy Design Pattern и неговите вариации
- Имплементация на Stack и Queue с различни подходи
- Adapter pattern в C++ STL
- Управление на паметта и предотвратяване на memory leaks
Лесни Упражнения (Основни Концепции)
Задача 1: Основна цел на Proxy Pattern
Каква е основната цел на Proxy дизайн шаблона?
a) Да създаде множество инстанции на обект b) Да предостави заместител или placeholder за друг обект, за да контролира достъпа до него c) Да сортира данни ефективно d) Да комбинира множество обекти в един
Proxy pattern предоставя заместител или placeholder за друг обект, за да контролира достъпа до него. Добавя ниво на индиректност между клиента и реалния обект, позволявайки допълнителна логика (security, logging, lazy loading) без да променя основната функционалност.
Задача 2: LIFO vs FIFO Принципи
Свържете следните операции с правилния принцип на структура от данни:
- Последният добавен елемент е първият премахнат: ______
- Първият добавен елемент е първият премахнат: ______
- Последният добавен елемент е първият премахнат: Stack (LIFO - Last-In, First-Out)
- Първият добавен елемент е първият премахнат: Queue (FIFO - First-In, First-Out)
Аналогии:
- Stack: Стек от чинии - можете да взимате само от върха
- Queue: Опашка в магазин - първият в опашката е първият обслужен
Задача 3: Основни Stack Операции
Избройте трите основни операции на стек и опишете накратко какво прави всяка.
-
push(element): Добавя елемент на върха на стека
stack.push(42); -
pop(): Премахва и връща елемента от върха на стека
int value = stack.pop(); -
top() / peek(): Връща елемента от върха без да го премахва
int value = stack.top();
Допълнителни помощни операции:
isEmpty(): Проверява дали стекът е празенsize(): Връща броя елементи в стека
Всички основни операции имат O(1) време за изпълнение!
Задача 4: Изтичане на памет (Memory Leak)
Какво е изтичане на памет (memory leak)? Опишете с едно изречение.
Отговор: Изтичане на памет е ситуация, при която програмата заделя динамична памет, но не я освобождава след използването ѝ, което води до натрупване на неизползвана памет.
Задача 5: Структура на възел в двусвързан списък
Какво съдържа всеки възел в двусвързан списък?
Отговор: Всеки възел съдържа:
- Данни (стойността, която се съхранява)
- Указател
prevкъм предишния възел - Указател
nextкъм следващия възел
Задача 6: Добра практика след delete
Какво винаги трябва да правите след извикване на delete на указател като добра практика?
int* ptr = new int(10);
delete ptr;
// Какво трябва да следва?
Отговор:
ptr = nullptr;
След delete винаги задавайте указателя на nullptr. Това предотвратява използването на "висящ" указател (dangling pointer) и прави грешките по-лесни за откриване.
Задача 7: Времева сложност за добавяне в края
Каква е времевата сложност O() за добавяне на елемент в края на двусвързан списък, когато имате указател към tail?
Отговор: O(1) - константно време.
Когато имаме указател към tail, можем директно да добавим новия елемент и да актуализираме само няколко указателя, независимо от размера на списъка.
Задача 8: Причини за изтичане на памет
Кое от следните ще причини изтичане на памет?
// Опция A:
int* ptr = new int(5);
delete ptr;
// Опция B:
int* ptr = new int(5);
ptr = new int(10);
delete ptr;
// Опция C:
int* ptr = new int(5);
ptr = nullptr;
Отговор: Опция B и Опция C причиняват изтичане на памет.
- Опция A: Правилна - паметта се освобождава.
- Опция B: Грешна - първата заделена памет (5) изтича, защото указателят се презаписва преди да бъде освободена.
- Опция C: Грешна - заделената памет изтича, защото губим указателя без да освободим паметта.
Задача 9: Роля на итератор в C++
Каква е основната роля на итератор в C++?
Отговор: Итераторът предоставя унифициран начин за обхождане на елементите на различни контейнери, скривайки детайлите на вътрешната им структура. Това позволява писането на общ код, който работи с масиви, списъци, вектори и др., без да се налага промяна при смяна на типа контейнер.
Средни Упражнения
Задача 10: Освобождаване на масив
Допълнете следния код за правилно освобождаване на масив:
int* arr = new int[10];
// Използвайте масива...
// Напишете правилния код за освобождаване тук:
Отговор:
delete[] arr;
arr = nullptr;
Важно е да използвате delete[] (не просто delete) за масиви, защото той освобождава цялата заделена памет за всички елементи.
Задача 11: Анализ на изтичане на памет
Какво ще се случи в следния код?
struct Node {
int data;
Node* next;
Node* prev;
};
Node* n = new Node();
n->data = 42;
// Програмата приключва без delete
Отговор: Ще има изтичане на памет. Заделената памет за възела не се освобождава преди приключване на програмата. Правилният код трябва да включва:
delete n;
n = nullptr;
Задача 12: Грешка при премахване на възел
Намерете грешката в тази функция за премахване на възел от двусвързан списък:
void removeNode(Node* node) {
node->prev->next = node->next;
node->next->prev = node->prev;
// Какво липсва?
}
Отговор: Липсва delete node;
След актуализиране на указателите, трябва да освободим паметта за премахнатия възел:
void removeNode(Node* node) {
node->prev->next = node->next;
node->next->prev = node->prev;
delete node; // Освобождаване на паметта
}
Допълнително: Функцията не проверява дали prev или next са nullptr, което може да доведе до грешка при премахване на първия или последния елемент.
Задача 13: Добавяне в началото на списък
Имплементирайте метод за добавяне на елемент в началото на двусвързан списък:
class DoublyLinkedList {
private:
Node* head;
Node* tail;
public:
void addFront(int value) {
// Вашата имплементация тук
}
};
Отговор:
void addFront(int value) {
Node* newNode = new Node(value);
if (head) { // Ако списъкът не е празен
newNode->next = head;
head->prev = newNode;
head = newNode;
} else { // Ако списъкът е празен
head = newNode;
tail = newNode;
}
}
Задача 14: RAII и предотвратяване на Memory Leaks
Какво е RAII и как помага за предотвратяване на изтичане на памет? Дайте пример.
Отговор: RAII (Resource Acquisition Is Initialization) е идиом в C++, при който ресурсите (като памет) се придобиват в конструктора на обекта и автоматично се освобождават в деструктора му.
Пример:
class SafeArray {
private:
int* data;
size_t size;
public:
SafeArray(size_t n) : size(n) {
data = new int[n]; // Заделяне в конструктора
}
~SafeArray() {
delete[] data; // Автоматично освобождаване в деструктора
}
// ... други методи ...
};
Когато SafeArray излезе от обхват, деструкторът автоматично освобождава паметта.
Задача 15: Имплементация на Array Iterator
Допълнете класа итератор за прост масив:
template<typename T>
class ArrayIterator {
private:
T* current;
public:
ArrayIterator(T* ptr) : current(ptr) {}
T& operator*() const {
// Вашият код тук
}
ArrayIterator& operator++() {
// Вашият код тук
}
bool operator!=(const ArrayIterator& other) const {
// Вашият код тук
}
};
Отговор:
T& operator*() const {
return *current; // Връща референция към текущия елемент
}
ArrayIterator& operator++() {
++current; // Премества указателя напред
return *this;
}
bool operator!=(const ArrayIterator& other) const {
return current != other.current; // Сравнява указателите
}
Задача 16: Ранно връщане и изтичане на памет
Обяснете защо този код има изтичане на памет и го поправете:
void processData() {
int* data = new int[100];
for (int i = 0; i < 100; i++) {
data[i] = i * 2;
}
if (data[50] > 100) {
return; // Ранно връщане
}
delete[] data;
}
Проблем: При ранно връщане (return), delete[] data никога не се изпълнява, което води до изтичане на памет.
Решение 1 - добавяне на delete преди return:
void processData() {
int* data = new int[100];
for (int i = 0; i < 100; i++) {
data[i] = i * 2;
}
if (data[50] > 100) {
delete[] data; // Освобождаване преди връщане
return;
}
delete[] data;
}
Решение 2 - използване на RAII (по-добро):
void processData() {
std::unique_ptr<int[]> data = std::make_unique<int[]>(100);
for (int i = 0; i < 100; i++) {
data[i] = i * 2;
}
if (data[50] > 100) {
return; // Паметта се освобождава автоматично
}
// Паметта се освобождава автоматично и тук
}
Задача 17: Типове Smart Pointers в C++
Какви са трите основни типа smart pointers в C++ и кога бихте използвали всеки?
Отговор:
std::unique_ptr: Ексклузивна собственост. Използвайте когато само един обект трябва да притежава ресурса. Не може да се копира, само да се премести сstd::move.std::shared_ptr: Споделена собственост чрез reference counting. Използвайте когато множество обекти трябва да споделят ресурса. Паметта се освобождава, когато последниятshared_ptrбъде унищожен.std::weak_ptr: "Слаба" референция къмshared_ptr, която не увеличава reference count. Използвайте за прекъсване на циклични зависимости междуshared_ptrобекти.
Задача 18: Броене на елементи в двусвързан списък
Имплементирайте функция за броене на елементите в двусвързан списък:
int countElements(Node* head) {
// Вашата имплементация тук
}
Отговор:
int countElements(Node* head) {
int count = 0;
Node* current = head;
while (current != nullptr) {
count++;
current = current->next;
}
return count;
}
Времева сложност: O(n), където n е броят елементи в списъка.
Задача 19: Деструктор за двусвързан списък
Напишете деструктор за двусвързан списък, който правилно освобождава всички възли:
class DoublyLinkedList {
private:
Node* head;
Node* tail;
public:
~DoublyLinkedList() {
// Вашата имплементация тук
}
};
Отговор:
~DoublyLinkedList() {
Node* current = head;
while (current != nullptr) {
Node* next_node = current->next;
delete current;
current = next_node;
}
head = nullptr;
tail = nullptr;
}
Обхождаме целия списък и изтриваме всеки възел. Важно е да запазим next указателя преди да изтрием текущия възел.
Трудни Упражнения
Задача 20: Вмъкване след итераторна позиция
Имплементирайте метод insert, който добавя нов елемент след дадена итераторна позиция:
template<typename T>
class DoublyLinkedList {
public:
class Iterator {
Node* current;
// ... iterator methods ...
};
void insertAfter(Iterator position, T value) {
// Вашата имплементация тук
}
};
Отговор:
void insertAfter(Iterator position, T value) {
if (!position.current) return; // Проверка за валидност
Node* newNode = new Node(value);
Node* next = position.current->next;
// Свързване на новия възел
newNode->prev = position.current;
newNode->next = next;
// Актуализиране на съседните възли
position.current->next = newNode;
if (next) {
next->prev = newNode;
} else {
// Ако вмъкваме след последния елемент
tail = newNode;
}
}
Забележка: Необходим е достъп до current от итератора, което изисква или friend декларация, или публичен getter.
Задача 21: Анализ на проблеми с паметта в DataManager
Анализирайте следния код и идентифицирайте всички проблеми, свързани с паметта:
class DataManager {
int* buffer;
int size;
public:
DataManager(int n) {
size = n;
buffer = new int[size];
}
void resize(int newSize) {
buffer = new int[newSize];
size = newSize;
}
DataManager(const DataManager& other) {
size = other.size;
buffer = other.buffer;
}
};
Проблеми:
- Липсва деструктор: Няма
~DataManager()за освобождаване наbuffer. - Memory leak в
resize(): Старата памет не се освобождава преди заделянето на нова. - Shallow copy в copy constructor:
buffer = other.bufferпросто копира указателя, не прави deep copy на данните. - Липсва copy assignment operator: Няма
operator=за правилно копиране. - Липсва move constructor и move assignment: За ефективно преместване на ресурси.
Правилна имплементация:
class DataManager {
int* buffer;
int size;
public:
DataManager(int n) : size(n), buffer(new int[n]) {}
~DataManager() { delete[] buffer; }
void resize(int newSize) {
int* newBuffer = new int[newSize];
int copySize = std::min(size, newSize);
for (int i = 0; i < copySize; i++) {
newBuffer[i] = buffer[i];
}
delete[] buffer;
buffer = newBuffer;
size = newSize;
}
DataManager(const DataManager& other) : size(other.size) {
buffer = new int[size];
for (int i = 0; i < size; i++) {
buffer[i] = other.buffer[i];
}
}
DataManager& operator=(const DataManager& other) {
if (this != &other) {
delete[] buffer;
size = other.size;
buffer = new int[size];
for (int i = 0; i < size; i++) {
buffer[i] = other.buffer[i];
}
}
return *this;
}
};
Задача 22: Двупосочен итератор за двусвързан списък
Имплементирайте двупосочен итератор за двусвързан списък с operator++ и operator--:
template<typename T>
class DoublyLinkedList {
struct Node {
T data;
Node* prev;
Node* next;
};
class Iterator {
private:
Node* current;
public:
// Имплементирайте всички необходими оператори
};
};
Отговор:
class Iterator {
private:
Node* current;
public:
Iterator(Node* node) : current(node) {}
// Достъп до стойността
T& operator*() const {
return current->data;
}
// Преместване напред (префикс)
Iterator& operator++() {
if (current) current = current->next;
return *this;
}
// Преместване назад (префикс)
Iterator& operator--() {
if (current) current = current->prev;
return *this;
}
// Преместване напред (постфикс)
Iterator operator++(int) {
Iterator temp = *this;
++(*this);
return temp;
}
// Преместване назад (постфикс)
Iterator operator--(int) {
Iterator temp = *this;
--(*this);
return temp;
}
// Сравнение
bool operator==(const Iterator& other) const {
return current == other.current;
}
bool operator!=(const Iterator& other) const {
return current != other.current;
}
};
Задача 23: Exception-safe вмъкване с RAII
Имплементирайте пълен двусвързан списък с exception-safe вмъкване. Гарантирайте, че няма изтичане на памет дори при хвърляне на изключения.
Отговор: Ключът е да използваме RAII и да заделим паметта така, че ако възникне изключение, всичко да се освободи автоматично:
template<typename T>
class SafeDoublyLinkedList {
private:
struct Node {
T data;
std::unique_ptr<Node> next; // Автоматично управление
Node* prev; // Raw pointer назад (не притежава)
Node(const T& val) : data(val), next(nullptr), prev(nullptr) {}
};
std::unique_ptr<Node> head;
Node* tail;
public:
SafeDoublyLinkedList() : head(nullptr), tail(nullptr) {}
void add(const T& value) {
auto newNode = std::make_unique<Node>(value);
if (tail) {
newNode->prev = tail;
tail->next = std::move(newNode);
tail = tail->next.get();
} else {
tail = newNode.get();
head = std::move(newNode);
}
}
};
Използването на unique_ptr гарантира автоматично освобождаване при изключения.
Задача 24: Циклични референции с shared_ptr и weak_ptr
Разгледайте сценарий с циклични референции използвайки std::shared_ptr. Обяснете проблема и предоставете решение с std::weak_ptr:
class Node {
public:
std::shared_ptr<Node> next;
std::shared_ptr<Node> prev;
int data;
};
Проблем: Ако два възела се сочат взаимно чрез shared_ptr, reference count никога не достига нула и паметта никога не се освобождава.
Решение: Използвайте weak_ptr за едната връзка:
class Node {
public:
std::shared_ptr<Node> next; // Силна референция напред
std::weak_ptr<Node> prev; // Слаба референция назад
int data;
Node(int val) : data(val) {}
};
Така next притежава следващия възел, но prev само наблюдава предишния, без да го притежава. Това прекъсва цикличната зависимост.
Задача 25: Valgrind анализ на memory leaks
Използвайки Valgrind концептуално, анализирайте тази програма:
void complexFunction() {
int* arr1 = new int[50];
int* arr2 = new int[100];
for (int i = 0; i < 50; i++) {
arr1[i] = i;
}
arr1 = arr2; // Презаписване
delete[] arr2;
}
int main() {
for (int i = 0; i < 1000; i++) {
complexFunction();
}
return 0;
}
Valgrind би докладвал:
- Memory leak: 50 * sizeof(int) * 1000 = 200,000 байта изтичат, защото
arr1се презаписва без да се освободи първата заделена памет. - Invalid free/delete:
arr2се освобождава два пъти - веднъж чрезdelete[] arr2и втори път когатоarr1(който вече сочи към същата памет) би трябвало да се освободи (но това не се случва в кода).
Поправка:
void fixedFunction() {
int* arr1 = new int[50];
int* arr2 = new int[100];
for (int i = 0; i < 50; i++) {
arr1[i] = i;
}
delete[] arr1; // Освобождаване преди презаписване
delete[] arr2;
}
Задача 26: Двусвързан списък със Sentinel Nodes
Проектирайте memory-efficient двусвързан списък, който използва sentinel nodes (фиктивни head и tail) за опростяване на граничните условия.
Отговор:
template<typename T>
class SentinelDoublyLinkedList {
private:
struct Node {
T data;
Node* prev;
Node* next;
Node() : prev(nullptr), next(nullptr) {} // Sentinel конструктор
Node(const T& val) : data(val), prev(nullptr), next(nullptr) {}
};
Node* sentinel_head; // Фиктивен head (не съдържа данни)
Node* sentinel_tail; // Фиктивен tail (не съдържа данни)
public:
SentinelDoublyLinkedList() {
sentinel_head = new Node();
sentinel_tail = new Node();
sentinel_head->next = sentinel_tail;
sentinel_tail->prev = sentinel_head;
}
~SentinelDoublyLinkedList() {
Node* current = sentinel_head->next;
while (current != sentinel_tail) {
Node* next = current->next;
delete current;
current = next;
}
delete sentinel_head;
delete sentinel_tail;
}
void add(const T& value) {
Node* newNode = new Node(value);
Node* last = sentinel_tail->prev;
// Вмъкване преди sentinel_tail
newNode->prev = last;
newNode->next = sentinel_tail;
last->next = newNode;
sentinel_tail->prev = newNode;
}
// Предимство: не се налагат проверки за nullptr при add/remove
};
Sentinel nodes опростяват логиката, защото винаги има "предишен" и "следващ" елемент.
Задача 27: Filtering Iterator с предикат
Създайте custom iterator, който автоматично прескача елементи, удовлетворяващи даден предикат.
Отговор:
template<typename T, typename Predicate>
class FilteringIterator {
private:
Node* current;
Node* end;
Predicate pred;
void advance() {
while (current != end && pred(current->data)) {
current = current->next;
}
}
public:
FilteringIterator(Node* start, Node* end_node, Predicate p)
: current(start), end(end_node), pred(p) {
advance(); // Прескачаме нежеланите елементи в началото
}
T& operator*() const {
return current->data;
}
FilteringIterator& operator++() {
if (current != end) {
current = current->next;
advance(); // Прескачаме нежелани елементи
}
return *this;
}
bool operator!=(const FilteringIterator& other) const {
return current != other.current;
}
};
// Пример: прескачане на отрицателни числа
auto it = FilteringIterator<int, auto>(
list.begin(), list.end(),
[](int x) { return x < 0; }
);
Задача 28: Move Constructor и Move Assignment
Имплементирайте move constructor и move assignment operator за двусвързан списък:
template<typename T>
class DoublyLinkedList {
public:
DoublyLinkedList(DoublyLinkedList&& other) noexcept {
// Имплементирайте move constructor
}
DoublyLinkedList& operator=(DoublyLinkedList&& other) noexcept {
// Имплементирайте move assignment
}
};
Отговор:
// Move constructor
DoublyLinkedList(DoublyLinkedList&& other) noexcept
: head(other.head), tail(other.tail) {
// "Крадем" ресурсите от other
other.head = nullptr;
other.tail = nullptr;
}
// Move assignment
DoublyLinkedList& operator=(DoublyLinkedList&& other) noexcept {
if (this != &other) {
// Освобождаваме собствените ресурси
Node* current = head;
while (current) {
Node* next = current->next;
delete current;
current = next;
}
// "Крадем" ресурсите от other
head = other.head;
tail = other.tail;
other.head = nullptr;
other.tail = nullptr;
}
return *this;
}
Move семантиката позволява ефективно трансфериране на ресурси без копиране.
Задача 29: Memory Pool Allocator за възли
Проектирайте memory pool allocator за възли на двусвързан списък за намаляване на overhead и фрагментация.
Отговор:
template<typename T>
class NodePool {
private:
struct Node {
T data;
Node* prev;
Node* next;
};
struct Chunk {
Node nodes[64]; // Блок от 64 възела
bool used[64];
Chunk* next;
Chunk() : next(nullptr) {
for (int i = 0; i < 64; i++) used[i] = false;
}
};
Chunk* chunks;
public:
NodePool() : chunks(new Chunk()) {}
~NodePool() {
while (chunks) {
Chunk* next = chunks->next;
delete chunks;
chunks = next;
}
}
Node* allocate() {
Chunk* current = chunks;
while (current) {
for (int i = 0; i < 64; i++) {
if (!current->used[i]) {
current->used[i] = true;
return ¤t->nodes[i];
}
}
if (!current->next) {
current->next = new Chunk();
}
current = current->next;
}
return nullptr;
}
void deallocate(Node* node) {
Chunk* current = chunks;
while (current) {
if (node >= current->nodes &&
node < current->nodes + 64) {
int index = node - current->nodes;
current->used[index] = false;
return;
}
current = current->next;
}
}
};
Memory pool намалява броя системни алокации и подобрява cache locality.
Забележка: Тези упражнения преминават от основно разбиране на концепциите до напреднали имплементации, покривайки всички ключови теми от лекцията: двусвързани списъци, итератори, управление на паметта, smart pointers и RAII принципи.