This course is designed to help learners master the core concepts of Data Structures and Algorithms (DSA) using the C programming language. Starting from the basics and advancing to complex topics like graphs, dynamic programming, and memory optimization, this course is ideal for students, job seekers, and aspiring developers. You’ll learn how to structure and manipulate data efficiently, solve real-world coding problems, and prepare for technical interviews at top companies. The content is structured step-by-step, combining theory with hands-on coding examples and practice problems to reinforce understanding. Whether you're preparing for university exams, campus placements, or competitive programming, this course provides a strong foundation in logic building, code efficiency, and problem-solving using C. Key Highlights: Covers all major DSA topics from beginner to advanced level 100+ coding examples with explanations Focus on time and space complexity optimization Designed for coding interviews, competitive exams, and CS fundamentals
In the previous chapter, we covered the fundamental principles of Dynamic Programming: Optimal Substructure and Overlapping Subproblems, along with the Memoization (top-down) and Tabulation (bottom-up) approaches. Now, we'll dive into some classic and more advanced DP problems. These problems will solidify your understanding and equip you with patterns that are applicable to a wide range of challenges, including those on sequences, items with weights, and structured data like trees and grids.
The LIS problem is a fundamental sequence problem solved with DP.
Given an unsorted array of integers, find the length of the longest subsequence such that all elements of the subsequence are sorted in increasing order. A subsequence does not require contiguous elements.
Example: For `[10, 22, 9, 33, 21, 50, 41, 60]`, the LIS is `[10, 22, 33, 41, 60]` or `[10, 22, 33, 50, 60]`, both of length 5.
Define `dp[i]` as the length of the longest increasing subsequence ending at index `i`.
// C++ for LIS (O(N^2) DP)
int lengthOfLIS_N2(const vector<int>& nums) {
if (nums.empty()) return 0;
int n = nums.size();
vector<int> dp(n, 1); // dp[i] = LIS ending at index i
for (int i = 1; i < n; ++i) {
for (int j = 0; j < i; ++j) {
if (nums[i] > nums[j]) {
dp[i] = max(dp[i], 1 + dp[j]);
}
}
}
return *max_element(dp.begin(), dp.end()); // Find max in dp array
}
Time Complexity: $O(N^2)$ due to nested loops.
Space Complexity: $O(N)$ for the `dp` array.
This optimized approach maintains an array, say `tails`, where `tails[k]` stores the smallest ending element of an increasing subsequence of length `k+1`.
// C++ for LIS (O(N log N) DP)
int lengthOfLIS_NlogN(const vector<int>& nums) {
if (nums.empty()) return 0;
vector<int> tails; // stores the smallest ending element of an increasing subsequence of length i+1
for (int num : nums) {
// Find iterator to the first element in tails >= num
auto it = lower_bound(tails.begin(), tails.end(), num);
if (it == tails.end()) { // If num is greater than all elements in tails
tails.push_back(num); // Extend LIS by 1
} else {
*it = num; // Replace existing element to form a smaller-ending LIS of same length
}
}
return tails.size(); // The size of tails is the length of LIS
}
Time Complexity: $O(N \log N)$ due to $N$ iterations, each performing a binary search on `tails` (which can grow up to size $N$).
Space Complexity: $O(N)$ for the `tails` array.
A classic optimization problem where you must decide whether to include an item or not.
Given $N$ items, each with a `weight[i]` and a `value[i]`, and a knapsack with maximum `capacity W`. Determine the maximum total `value` that can be obtained by selecting a subset of items such that their total `weight` does not exceed `W`. Each item can either be entirely included or entirely excluded (hence "0/1").
Define `dp[i][w]` as the maximum value that can be obtained using the first `i` items with a knapsack capacity of `w`.
// C++ for 0/1 Knapsack Problem
int knapsack(int W, const vector<int>& weights, const vector<int>& values, int N) {
// dp[i][w] stores max value using first 'i' items with capacity 'w'
vector<vector<int>> dp(N + 1, vector<int>(W + 1, 0));
for (int i = 1; i <= N; ++i) { // Iterate through items
for (int w = 1; w <= W; ++w) { // Iterate through capacities
int current_weight = weights[i-1]; // weights and values are 0-indexed
int current_value = values[i-1];
if (current_weight > w) {
// Cannot include current item, take value from previous items
dp[i][w] = dp[i-1][w];
} else {
// Option 1: Don't include current item
// Option 2: Include current item + max value from remaining capacity with previous items
dp[i][w] = max(dp[i-1][w], current_value + dp[i-1][w - current_weight]);
}
}
}
return dp[N][W];
}
Time Complexity: $O(N \cdot W)$ due to nested loops.
Space Complexity: $O(N \cdot W)$ for the 2D DP table. This can be optimized to $O(W)$ using only two rows or even one row, as `dp[i][w]` only depends on `dp[i-1]` values.
// C++ for 0/1 Knapsack (O(W) Space Optimized)
int knapsack_optimized(int W, const vector<int>& weights, const vector<int>& values, int N) {
vector<int> dp(W + 1, 0); // dp[w] stores max value for current capacity w
for (int i = 0; i < N; ++i) { // Iterate through items
// Iterate weights from W down to current_weight
// This ensures dp[w - current_weight] is from previous iteration (i-1)
for (int w = W; w >= weights[i]; --w) {
dp[w] = max(dp[w], values[i] + dp[w - weights[i]]);
}
}
return dp[W];
}
Many pathfinding or counting problems on grids can be solved with DP.
A robot is located at the top-left corner of an `m x n` grid. The robot can only move either down or right at any point in time. How many unique paths are there from the top-left corner to the bottom-right corner?
Define `dp[i][j]` as the number of unique paths to reach cell `(i, j)`.
// C++ for Unique Paths on a Grid
int uniquePaths(int m, int n) {
// dp[i][j] stores number of unique paths to reach (i, j)
vector<vector<int>> dp(m, vector<int>(n, 0));
// Base cases: first row and first column have only 1 way to reach
for (int i = 0; i < m; ++i) dp[i][0] = 1;
for (int j = 0; j < n; ++j) dp[0][j] = 1;
// Fill the rest of the table
for (int i = 1; i < m; ++i) {
for (int j = 1; j < n; ++j) {
dp[i][j] = dp[i-1][j] + dp[i][j-1];
}
}
return dp[m-1][n-1];
}
Time Complexity: $O(m \cdot n)$ to fill the grid.
Space Complexity: $O(m \cdot n)$ for the 2D DP table. Can be optimized to $O(min(m, n))$ by only keeping track of the previous row/column.
DP on trees is a common pattern where the state of a node's solution depends on the solutions of its children (or ancestors). These problems are typically solved using a DFS traversal.
These advanced DP patterns are applicable in various complex optimization and data analysis scenarios:
From the algorithmic heavy lifting of Dynamic Programming, we'll shift gears to a more "bit-level" understanding of data. Our next chapter will delve into Bit Manipulation and XOR Tricks, exploring how direct operations on binary representations of numbers can lead to incredibly efficient and elegant solutions for certain problems, often crucial in competitive programming and low-level optimization.