Facebook Pixel

2911. Minimum Changes to Make K Semi-palindromes

Problem Description

You are given a string s and an integer k. Your task is to partition the string into exactly k non-empty substrings and minimize the total number of character changes needed to make each substring a semi-palindrome.

A semi-palindrome is a special type of string that can be verified using the following process:

  1. Choose a divisor d of the string's length (where 1 ≤ d < length). For strings of length 1, no valid divisor exists, so they cannot be semi-palindromes by this definition.

  2. Group the characters based on the divisor d:

    • Group 1: characters at positions 1, 1+d, 1+2d, ...
    • Group 2: characters at positions 2, 2+d, 2+2d, ...
    • Group 3: characters at positions 3, 3+d, 3+2d, ...
    • And so on...
  3. The string is a semi-palindrome if all groups form palindromes for at least one valid divisor d.

For example, "abcabc" (length 6) has divisors 1, 2, and 3:

  • With d=1: The entire string forms one group "abcabc" - not a palindrome
  • With d=2: Groups are "acb" (positions 1,3,5) and "bac" (positions 2,4,6) - neither is a palindrome
  • With d=3: Groups are "aa" (positions 1,4), "bb" (positions 2,5), "cc" (positions 3,6) - all are palindromes ✓

Therefore "abcabc" is a semi-palindrome.

The algorithm uses dynamic programming with two key components:

  1. Preprocessing step (g[i][j]): Calculate the minimum changes needed to convert any substring s[i-1:j] into a semi-palindrome. For each substring and each valid divisor d, it counts how many character pairs need to be changed to make all groups palindromic.

  2. DP step (f[i][j]): Find the minimum changes to partition the first i characters into j substrings where each is a semi-palindrome. The recurrence relation considers all possible positions for the last partition.

The final answer is f[n][k], representing the minimum changes needed to partition all n characters into k semi-palindromic substrings.

Quick Interview Experience
Help others by sharing your interview experience
Have you seen this problem before?

Intuition

The key insight is that this problem can be broken down into two independent subproblems:

  1. How many changes does it take to make any given substring a semi-palindrome?
  2. How do we optimally partition the string into k parts to minimize total changes?

For the first subproblem, we need to understand what makes a string a semi-palindrome. When we choose a divisor d, we're essentially "folding" the string into d groups. Each group must be a palindrome. To convert a group into a palindrome, we compare characters from both ends and count mismatches. The minimum changes for a substring is the minimum across all valid divisors.

Why precompute all possible substrings? Because when we're deciding how to partition the string, we'll need to quickly know the cost of making any substring a semi-palindrome. Computing this on-the-fly would be inefficient.

For the second subproblem, this is a classic dynamic programming pattern: "partition into k parts with minimum cost." We build up solutions incrementally:

  • To partition the first i characters into j parts
  • We try all possible positions for the (j-1)-th partition point
  • The cost is: (cost of first j-1 parts) + (cost of making the last part a semi-palindrome)

The formula f[i][j] = min(f[h][j-1] + g[h+1][i]) captures this idea:

  • f[h][j-1]: minimum cost to partition first h characters into j-1 parts
  • g[h+1][i]: cost to make substring from position h+1 to i a semi-palindrome
  • We try all valid h values and take the minimum

This two-phase approach (precomputation + DP) is common when we have:

  • A complex cost function for individual segments (semi-palindrome conversion)
  • An optimization problem over all possible ways to segment (minimum total cost over k partitions)

By separating these concerns, we get a clean, efficient solution that avoids redundant calculations.

Learn more about Two Pointers and Dynamic Programming patterns.

Solution Approach

The solution implements a two-phase dynamic programming approach:

Phase 1: Precompute Semi-Palindrome Conversion Costs

We create a 2D array g[i][j] where g[i][j] represents the minimum number of changes needed to convert substring s[i-1:j] into a semi-palindrome.

g = [[inf] * (n + 1) for _ in range(n + 1)]

For each possible substring from position i to j:

  1. Calculate the substring length: m = j - i + 1
  2. Try each valid divisor d (from 1 to m-1)
  3. Only check divisors that evenly divide the length: if m % d == 0

For each valid divisor d, we count the changes needed to make all groups palindromic:

for l in range(m):
    r = (m // d - 1 - l // d) * d + l % d
    if l >= r:
        break
    if s[i - 1 + l] != s[i - 1 + r]:
        cnt += 1

The formula r = (m // d - 1 - l // d) * d + l % d calculates the mirror position:

  • l // d: which occurrence of this group position we're at
  • l % d: which group (0 to d-1) we're in
  • m // d - 1 - l // d: the mirror occurrence from the end
  • The formula gives us the position to compare with for palindrome checking

Phase 2: Dynamic Programming for Optimal Partitioning

We create a 2D DP array f[i][j] where f[i][j] represents the minimum changes needed to partition the first i characters into j semi-palindromic substrings.

f = [[inf] * (k + 1) for _ in range(n + 1)]
f[0][0] = 0  # Base case: 0 characters, 0 partitions = 0 changes

The recurrence relation:

for i in range(1, n + 1):
    for j in range(1, k + 1):
        for h in range(i - 1):
            f[i][j] = min(f[i][j], f[h][j - 1] + g[h + 1][i])

This tries all possible positions h for the (j-1)-th partition point:

  • f[h][j-1]: cost of partitioning first h characters into j-1 parts
  • g[h+1][i]: cost of making characters from h+1 to i a semi-palindrome
  • We take the minimum over all valid partition points

Time Complexity

  • Precomputation: O(n³) - for each substring O(n²), we check divisors and calculate changes O(n)
  • DP: O(n²k) - for each state (i,j), we try O(n) partition points
  • Overall: O(n³ + n²k)

Space Complexity

  • O(n²) for the preprocessing array g
  • O(nk) for the DP array f
  • Overall: O(n²)

The final answer f[n][k] gives the minimum number of changes needed to partition the entire string into k semi-palindromic substrings.

Ready to land your dream job?

Unlock your dream job with a 3-minute evaluator for a personalized learning plan!

Start Evaluator

Example Walkthrough

Let's walk through the solution with s = "abcac" and k = 2.

Phase 1: Precompute Semi-Palindrome Costs

We'll calculate g[i][j] for all substrings. Let's focus on a few key ones:

For substring "abc" (positions 1-3, length 3):

  • Valid divisors: only d = 1 (since 3 is prime)
  • With d = 1: Groups are just "abc" itself
    • Compare positions: 0↔2 ('a' vs 'c') → 1 change needed
    • Total: 1 change
  • g[1][3] = 1

For substring "abca" (positions 1-4, length 4):

  • Valid divisors: d = 1, 2
  • With d = 1: Groups are "abca"
    • Compare: 0↔3 ('a' vs 'a') ✓, 1↔2 ('b' vs 'c') → 1 change
    • Total: 1 change
  • With d = 2: Groups are "ac" (positions 0,2) and "ba" (positions 1,3)
    • Group 1: 'a' vs 'c' → 1 change
    • Group 2: 'b' vs 'a' → 1 change
    • Total: 2 changes
  • g[1][4] = min(1, 2) = 1

For substring "cac" (positions 3-5, length 3):

  • Valid divisors: only d = 1
  • With d = 1: Groups are "cac"
    • Compare: 0↔2 ('c' vs 'c') ✓
    • Already a palindrome! Total: 0 changes
  • g[3][5] = 0

Complete g array (showing key values):

g[1][3] = 1  ("abc"1 change)
g[1][4] = 1  ("abca"1 change)
g[1][5] = 2  ("abcac"2 changes)
g[2][5] = 1  ("bcac"1 change)
g[3][5] = 0  ("cac"0 changes, already palindrome)
g[4][5] = 1  ("ac"1 change)

Phase 2: Dynamic Programming for Partitioning

We need to partition into k = 2 parts. Initialize:

f[0][0] = 0
f[i][j] = inf for all other values

Building up the DP table:

For f[3][1] (first 3 chars in 1 partition):

  • Only option: entire substring "abc"
  • f[3][1] = f[0][0] + g[1][3] = 0 + 1 = 1

For f[4][1] (first 4 chars in 1 partition):

  • Only option: entire substring "abca"
  • f[4][1] = f[0][0] + g[1][4] = 0 + 1 = 1

For f[5][2] (all 5 chars in 2 partitions):

  • Option 1: Split at position 1 → "a" | "bcac"
    • Note: "a" has length 1, cannot be semi-palindrome → invalid
  • Option 2: Split at position 2 → "ab" | "cac"
    • f[2][1] + g[3][5] = need f[2][1] first
    • f[2][1] = f[0][0] + g[1][2] = 0 + 1 = 1
    • So: 1 + 0 = 1
  • Option 3: Split at position 3 → "abc" | "ac"
    • f[3][1] + g[4][5] = 1 + 1 = 2
  • Option 4: Split at position 4 → "abca" | "c"
    • "c" has length 1, cannot be semi-palindrome → invalid

f[5][2] = min(1, 2) = 1

Final Answer: 1

The optimal partition is "ab" | "cac":

  • "ab" needs 1 change to become "aa" or "bb" (a semi-palindrome)
  • "cac" is already a palindrome (0 changes)
  • Total: 1 change

Solution Implementation

1class Solution:
2    def minimumChanges(self, s: str, k: int) -> int:
3        n = len(s)
4      
5        # min_changes[i][j] stores the minimum changes needed to make substring s[i-1:j] 
6        # into a semi-palindrome (a string that becomes palindrome after grouping by some divisor)
7        min_changes = [[float('inf')] * (n + 1) for _ in range(n + 1)]
8      
9        # Calculate minimum changes for each substring
10        for start in range(1, n + 1):
11            for end in range(start, n + 1):
12                substring_length = end - start + 1
13              
14                # Try each possible divisor of the substring length
15                for divisor in range(1, substring_length):
16                    if substring_length % divisor == 0:
17                        changes_needed = 0
18                      
19                        # Check pairs of characters that should match in the semi-palindrome
20                        for left_idx in range(substring_length):
21                            # Calculate the corresponding right index for semi-palindrome check
22                            # This pairs characters based on the divisor pattern
23                            groups_count = substring_length // divisor
24                            group_pos = left_idx // divisor
25                            offset = left_idx % divisor
26                            right_idx = (groups_count - 1 - group_pos) * divisor + offset
27                          
28                            # Only check each pair once (avoid double counting)
29                            if left_idx >= right_idx:
30                                break
31                          
32                            # Count mismatches that need to be changed
33                            if s[start - 1 + left_idx] != s[start - 1 + right_idx]:
34                                changes_needed += 1
35                      
36                        # Update minimum changes for this substring
37                        min_changes[start][end] = min(min_changes[start][end], changes_needed)
38      
39        # dp[i][j] stores the minimum changes to partition s[0:i] into j parts
40        dp = [[float('inf')] * (k + 1) for _ in range(n + 1)]
41        dp[0][0] = 0  # Base case: empty string with 0 partitions needs 0 changes
42      
43        # Fill the DP table
44        for length in range(1, n + 1):
45            for partitions in range(1, k + 1):
46                # Try all possible positions for the last partition
47                for prev_length in range(length - 1):
48                    # Cost = previous partitions cost + cost of current partition
49                    dp[length][partitions] = min(
50                        dp[length][partitions], 
51                        dp[prev_length][partitions - 1] + min_changes[prev_length + 1][length]
52                    )
53      
54        return dp[n][k]
55
1class Solution {
2    public int minimumChanges(String s, int k) {
3        int stringLength = s.length();
4      
5        // minChanges[i][j] = minimum changes needed to make substring from index i to j semi-palindromic
6        int[][] minChanges = new int[stringLength + 1][stringLength + 1];
7      
8        // dp[i][j] = minimum changes needed to partition first i characters into j semi-palindromic parts
9        int[][] dp = new int[stringLength + 1][k + 1];
10      
11        final int INFINITY = 1 << 30;
12      
13        // Initialize arrays with infinity (representing impossible states)
14        for (int i = 0; i <= stringLength; ++i) {
15            Arrays.fill(minChanges[i], INFINITY);
16            Arrays.fill(dp[i], INFINITY);
17        }
18      
19        // Precompute minimum changes for all substrings to become semi-palindromic
20        for (int startIdx = 1; startIdx <= stringLength; ++startIdx) {
21            for (int endIdx = startIdx; endIdx <= stringLength; ++endIdx) {
22                int substringLength = endIdx - startIdx + 1;
23              
24                // Try all possible divisors of substring length (for semi-palindrome patterns)
25                for (int divisor = 1; divisor < substringLength; ++divisor) {
26                    if (substringLength % divisor == 0) {
27                        int changesNeeded = 0;
28                      
29                        // Check pairs of characters that should match in semi-palindrome pattern
30                        for (int leftPos = 0; leftPos < substringLength; ++leftPos) {
31                            // Calculate corresponding right position based on divisor pattern
32                            int rightPos = (substringLength / divisor - 1 - leftPos / divisor) * divisor + leftPos % divisor;
33                          
34                            // Only check each pair once (avoid double counting)
35                            if (leftPos >= rightPos) {
36                                break;
37                            }
38                          
39                            // Count mismatches that need to be changed
40                            if (s.charAt(startIdx - 1 + leftPos) != s.charAt(startIdx - 1 + rightPos)) {
41                                ++changesNeeded;
42                            }
43                        }
44                      
45                        // Update minimum changes for this substring
46                        minChanges[startIdx][endIdx] = Math.min(minChanges[startIdx][endIdx], changesNeeded);
47                    }
48                }
49            }
50        }
51      
52        // Base case: 0 characters partitioned into 0 parts requires 0 changes
53        dp[0][0] = 0;
54      
55        // Dynamic programming to find minimum changes for partitioning
56        for (int charCount = 1; charCount <= stringLength; ++charCount) {
57            for (int partCount = 1; partCount <= k; ++partCount) {
58                // Try all possible positions for the last partition
59                for (int prevPartitionEnd = 0; prevPartitionEnd < charCount - 1; ++prevPartitionEnd) {
60                    // Current partition: from prevPartitionEnd+1 to charCount
61                    dp[charCount][partCount] = Math.min(
62                        dp[charCount][partCount], 
63                        dp[prevPartitionEnd][partCount - 1] + minChanges[prevPartitionEnd + 1][charCount]
64                    );
65                }
66            }
67        }
68      
69        // Return minimum changes to partition entire string into k parts
70        return dp[stringLength][k];
71    }
72}
73
1class Solution {
2public:
3    int minimumChanges(string s, int k) {
4        int n = s.size();
5      
6        // minChanges[i][j] = minimum changes to make substring s[i-1...j-1] a semi-palindrome
7        // A semi-palindrome is a string that becomes palindromic when read with period d
8        int minChanges[n + 1][n + 1];
9      
10        // dp[i][j] = minimum changes to partition s[0...i-1] into j semi-palindromes
11        int dp[n + 1][k + 1];
12      
13        // Initialize with large values (infinity)
14        memset(minChanges, 0x3f, sizeof(minChanges));
15        memset(dp, 0x3f, sizeof(dp));
16      
17        // Base case: empty string with 0 partitions needs 0 changes
18        dp[0][0] = 0;
19      
20        // Precompute minimum changes for all substrings to become semi-palindromes
21        for (int start = 1; start <= n; ++start) {
22            for (int end = start; end <= n; ++end) {
23                int length = end - start + 1;
24              
25                // Try all possible divisors of length as the period
26                for (int period = 1; period < length; ++period) {
27                    if (length % period == 0) {
28                        int changesNeeded = 0;
29                      
30                        // Check pairs of characters that should be equal in a semi-palindrome
31                        // with the given period
32                        for (int leftIdx = 0; leftIdx < length; ++leftIdx) {
33                            // Calculate corresponding right index for semi-palindrome check
34                            int rightIdx = (length / period - 1 - leftIdx / period) * period + leftIdx % period;
35                          
36                            // Only check each pair once (avoid double counting)
37                            if (leftIdx >= rightIdx) {
38                                break;
39                            }
40                          
41                            // Count mismatches that need to be changed
42                            if (s[start - 1 + leftIdx] != s[start - 1 + rightIdx]) {
43                                ++changesNeeded;
44                            }
45                        }
46                      
47                        // Update minimum changes for this substring
48                        minChanges[start][end] = min(minChanges[start][end], changesNeeded);
49                    }
50                }
51            }
52        }
53      
54        // Dynamic programming to find minimum changes for k partitions
55        for (int i = 1; i <= n; ++i) {
56            for (int j = 1; j <= k; ++j) {
57                // Try all possible positions for the (j-1)th partition endpoint
58                for (int prevEnd = 0; prevEnd < i - 1; ++prevEnd) {
59                    // dp[i][j] = min(previous j-1 partitions + cost of jth partition)
60                    dp[i][j] = min(dp[i][j], dp[prevEnd][j - 1] + minChanges[prevEnd + 1][i]);
61                }
62            }
63        }
64      
65        // Return minimum changes to partition entire string into k semi-palindromes
66        return dp[n][k];
67    }
68};
69
1/**
2 * Finds the minimum number of character changes needed to partition string s into k semi-palindromes
3 * @param s - The input string to be partitioned
4 * @param k - The number of partitions required
5 * @returns The minimum number of changes needed
6 */
7function minimumChanges(s: string, k: number): number {
8    const stringLength: number = s.length;
9  
10    // dp[i][j] stores minimum changes to make substring from index i to j a semi-palindrome
11    const minChangesForSubstring: number[][] = Array.from(
12        { length: stringLength + 1 }, 
13        () => Array.from({ length: stringLength + 1 }, () => Infinity)
14    );
15  
16    // dp[i][j] stores minimum changes to partition first i characters into j groups
17    const minChangesForPartition: number[][] = Array.from(
18        { length: stringLength + 1 }, 
19        () => Array.from({ length: k + 1 }, () => Infinity)
20    );
21  
22    // Base case: 0 characters with 0 partitions requires 0 changes
23    minChangesForPartition[0][0] = 0;
24  
25    // Calculate minimum changes for each substring to become a semi-palindrome
26    for (let startIdx: number = 1; startIdx <= stringLength; ++startIdx) {
27        for (let endIdx: number = 1; endIdx <= stringLength; ++endIdx) {
28            const substringLength: number = endIdx - startIdx + 1;
29          
30            // Try all possible divisors of substring length
31            for (let divisor: number = 1; divisor < substringLength; ++divisor) {
32                if (substringLength % divisor === 0) {
33                    let changesNeeded: number = 0;
34                  
35                    // Check pairs of characters that should match in semi-palindrome pattern
36                    for (let leftPos: number = 0; leftPos < substringLength; ++leftPos) {
37                        // Calculate corresponding right position based on semi-palindrome structure
38                        const groupsCount: number = Math.floor(substringLength / divisor);
39                        const rightPos: number = (groupsCount - 1 - Math.floor(leftPos / divisor)) * divisor + (leftPos % divisor);
40                      
41                        // Only check each pair once (avoid double counting)
42                        if (leftPos >= rightPos) {
43                            break;
44                        }
45                      
46                        // Count mismatched characters that need changing
47                        if (s[startIdx - 1 + leftPos] !== s[startIdx - 1 + rightPos]) {
48                            ++changesNeeded;
49                        }
50                    }
51                  
52                    // Update minimum changes for this substring
53                    minChangesForSubstring[startIdx][endIdx] = Math.min(
54                        minChangesForSubstring[startIdx][endIdx], 
55                        changesNeeded
56                    );
57                }
58            }
59        }
60    }
61  
62    // Calculate minimum changes for partitioning using dynamic programming
63    for (let currentPos: number = 1; currentPos <= stringLength; ++currentPos) {
64        for (let partitionCount: number = 1; partitionCount <= k; ++partitionCount) {
65            // Try all possible positions for the previous partition
66            for (let prevPartitionEnd: number = 0; prevPartitionEnd < currentPos - 1; ++prevPartitionEnd) {
67                // Combine changes from previous partitions with current substring
68                minChangesForPartition[currentPos][partitionCount] = Math.min(
69                    minChangesForPartition[currentPos][partitionCount],
70                    minChangesForPartition[prevPartitionEnd][partitionCount - 1] + 
71                    minChangesForSubstring[prevPartitionEnd + 1][currentPos]
72                );
73            }
74        }
75    }
76  
77    // Return minimum changes to partition entire string into k groups
78    return minChangesForPartition[stringLength][k];
79}
80

Time and Space Complexity

Time Complexity: O(n³ + n²k)

The time complexity consists of two main parts:

  1. Preprocessing phase (computing matrix g): O(n³)

    • The outer two loops iterate through all pairs (i, j) where 1 ≤ i ≤ j ≤ n, giving O(n²) pairs
    • For each pair, we iterate through divisors d of length m = j - i + 1, which can be at most O(m) divisors
    • For each divisor, we check pairs of characters in O(m) time
    • Overall: O(n²) × O(n) = O(n³)
  2. Dynamic programming phase (computing matrix f): O(n²k)

    • We iterate through i from 1 to n: O(n)
    • For each i, we iterate through j from 1 to k: O(k)
    • For each (i, j), we iterate through h from 0 to i-1: O(n)
    • Overall: O(n) × O(k) × O(n) = O(n²k)

The total time complexity is O(n³ + n²k). Since typically k ≤ n, the dominant term is O(n³).

Space Complexity: O(n² + nk)

The space complexity comes from:

  • Matrix g of size (n+1) × (n+1): O(n²)
  • Matrix f of size (n+1) × (k+1): O(nk)

The total space complexity is O(n² + nk). Since typically k ≤ n, this simplifies to O(n²).

Learn more about how to find time and space complexity quickly.

Common Pitfalls

1. Incorrect Mirror Position Calculation in Semi-Palindrome Check

One of the most common pitfalls is incorrectly calculating the mirror position when checking if groups form palindromes. The formula for finding the corresponding position to compare with is complex and error-prone.

Incorrect approach:

# Wrong: Simple mirroring without considering group structure
right_idx = substring_length - 1 - left_idx

Correct approach:

# Correct: Consider the group structure based on divisor
groups_count = substring_length // divisor
group_pos = left_idx // divisor
offset = left_idx % divisor
right_idx = (groups_count - 1 - group_pos) * divisor + offset

2. Off-by-One Errors in Index Management

The solution mixes 0-based indexing (for string s) and 1-based indexing (for DP arrays). This can lead to confusion and bugs.

Common mistake:

# Forgetting to adjust indices when accessing the string
if s[start + left_idx] != s[start + right_idx]:  # Wrong!

Correct:

# Properly adjust for 0-based string indexing
if s[start - 1 + left_idx] != s[start - 1 + right_idx]:  # Correct

3. Missing Edge Case: Single Character Substrings

According to the problem definition, single-character strings cannot be semi-palindromes because they have no valid divisors. However, the code might not handle this explicitly.

Solution:

# Add explicit handling for single characters
if substring_length == 1:
    min_changes[start][end] = float('inf')  # Cannot be a semi-palindrome
    continue

4. Inefficient Divisor Iteration

Iterating through all numbers from 1 to substring_length - 1 and checking divisibility is inefficient.

Better approach:

# Only iterate through actual divisors
divisors = []
for d in range(1, int(substring_length**0.5) + 1):
    if substring_length % d == 0:
        divisors.append(d)
        if d != substring_length // d and d != 1:
            divisors.append(substring_length // d)

for divisor in divisors:
    if divisor < substring_length:  # Exclude the length itself
        # Process divisor...

5. Not Breaking Early in Pair Comparisons

When checking character pairs, we should avoid double-counting by only checking each pair once.

Issue:

# Without the break, we might count pairs twice
for left_idx in range(substring_length):
    # ... calculate right_idx ...
    if s[start - 1 + left_idx] != s[start - 1 + right_idx]:
        changes_needed += 1

Fix:

for left_idx in range(substring_length):
    # ... calculate right_idx ...
    if left_idx >= right_idx:  # Critical: avoid double counting
        break
    if s[start - 1 + left_idx] != s[start - 1 + right_idx]:
        changes_needed += 1

6. Initialization Issues with DP Base Cases

Forgetting to properly initialize the DP arrays or setting incorrect base cases can lead to wrong results.

Common mistake:

dp = [[float('inf')] * (k + 1) for _ in range(n + 1)]
# Forgetting base case or setting it incorrectly

Correct:

dp = [[float('inf')] * (k + 1) for _ in range(n + 1)]
dp[0][0] = 0  # Essential: 0 characters with 0 partitions = 0 changes
# Also consider: dp[i][1] might need special handling for valid single partitions

7. Range Boundary Errors in DP Transition

When iterating through possible partition points, ensure the ranges are correct.

Potential issue:

# May miss valid partitions or include invalid ones
for prev_length in range(length):  # Should this include length?

Correct:

# Ensure we're considering all valid previous partition points
for prev_length in range(length - 1):  # Correct: from 0 to length-2
    # This ensures the current partition has at least 1 character
Discover Your Strengths and Weaknesses: Take Our 3-Minute Quiz to Tailor Your Study Plan:

Which of the following problems can be solved with backtracking (select multiple)


Recommended Readings

Want a Structured Path to Master System Design Too? Don’t Miss This!

Load More