Concatenated Divisibility

HARD

Description

You are given an array of positive integers nums and a positive integer k.

A permutation of nums is said to form a divisible concatenation if, when you concatenate the decimal representations of the numbers in the order specified by the permutation, the resulting number is divisible by k.

Return the lexicographically smallest permutation (when considered as a list of integers) that forms a divisible concatenation. If no such permutation exists, return an empty list.

 

Example 1:

Input: nums = [3,12,45], k = 5

Output: [3,12,45]

Explanation:

Permutation Concatenated Value Divisible by 5
[3, 12, 45] 31245 Yes
[3, 45, 12] 34512 No
[12, 3, 45] 12345 Yes
[12, 45, 3] 12453 No
[45, 3, 12] 45312 No
[45, 12, 3] 45123 No

The lexicographically smallest permutation that forms a divisible concatenation is [3,12,45].

Example 2:

Input: nums = [10,5], k = 10

Output: [5,10]

Explanation:

Permutation Concatenated Value Divisible by 10
[5, 10] 510 Yes
[10, 5] 105 No

The lexicographically smallest permutation that forms a divisible concatenation is [5,10].

Example 3:

Input: nums = [1,2,3], k = 5

Output: []

Explanation:

Since no permutation of nums forms a valid divisible concatenation, return an empty list.

 

Constraints:

  • 1 <= nums.length <= 13
  • 1 <= nums[i] <= 105
  • 1 <= k <= 100

Approaches

Checkout 2 different approaches to solve Concatenated Divisibility. Click on different approaches to view the approach and algorithm in detail.

Dynamic Programming with Bitmasking

Given the small constraint on N (up to 13), this problem can be solved efficiently using dynamic programming with a bitmask. This approach avoids the factorial time complexity of backtracking by storing and reusing the results of subproblems. The state of our DP captures the subset of numbers used (via a bitmask) and the remainder of the concatenated number formed by a permutation of this subset.

Algorithm

  • Sort the nums array to ensure that when we build the permutation, we can prioritize smaller numbers to find the lexicographically smallest result.
  • Use a top-down dynamic programming approach with memoization. The state of our DP will be (mask, rem), where mask is a bitmask representing the subset of nums used, and rem is the current remainder modulo k.
  • Create a memoization table memo[1 << N][k] to store the results of subproblems (e.g., 1 for possible, -1 for impossible).
  • Create a parent[1 << N][k] table to store the index of the number chosen at each state to allow for path reconstruction.
  • Define a recursive function solve(mask, rem) that returns whether a valid permutation can be formed from the unused numbers.
  • The base case for solve(mask, rem) is when all numbers are used (mask is all 1s). It returns success if rem == 0, and failure otherwise.
  • In the recursive step, iterate through all numbers nums[i]. If nums[i] is not in the current mask, recursively call solve for the new state (mask | (1 << i), new_rem). If the recursive call is successful, it means we found a valid path. We store the chosen index i in parent[mask][rem] and return success.
  • After the initial call solve(0, 0) completes, if a solution exists, reconstruct the permutation by backtracking through the parent table from the initial state.

The core idea is to solve for subproblems and store their results to avoid re-computation. A subproblem can be defined as: "Is it possible to form a valid divisible concatenation using a specific subset of the remaining numbers, given the prefix has already produced a certain remainder?"

We define a function solve(mask, rem) which determines if a valid suffix can be formed from the numbers not yet included in mask, assuming the prefix has a remainder rem. To find the lexicographically smallest permutation, we first sort nums. Then, inside solve, we iterate through available numbers nums[i] in increasing order. The first one that leads to a valid complete permutation will guarantee that we are on the path to the lexicographically smallest solution.

We use two tables:

  1. memo[mask][rem]: Stores whether the state (mask, rem) can lead to a solution (1 for possible, -1 for impossible, 0 for not computed).
  2. parent[mask][rem]: Stores the index i of the number nums[i] that should be chosen next from state (mask, rem) to be on the optimal path.

After the solve function populates these tables, we can reconstruct the final permutation by starting from the initial state (mask=0, rem=0) and following the indices stored in the parent table.

class Solution {
    int[] nums;
    int n;
    int k;
    long[] p10;
    int[][] memo; // 0: not computed, 1: possible, -1: impossible
    int[][] parent; // stores the index of the number chosen

    public int[] concatenatedDivisibility(int[] nums, int k) {
        this.n = nums.length;
        this.nums = nums;
        this.k = k;
        
        Arrays.sort(this.nums);

        this.p10 = new long[n];
        for (int i = 0; i < n; i++) {
            p10[i] = (long) Math.pow(10, String.valueOf(nums[i]).length());
        }

        this.memo = new int[1 << n][k];
        this.parent = new int[1 << n][k];

        if (solve(0, 0) == -1) {
            return new int[0];
        }

        // Reconstruct path
        int[] result = new int[n];
        int currentMask = 0;
        int currentRem = 0;
        for (int i = 0; i < n; i++) {
            int index = parent[currentMask][currentRem];
            result[i] = this.nums[index];
            currentRem = (int) ((currentRem * p10[index] + this.nums[index]) % k);
            currentMask |= (1 << index);
        }
        return result;
    }

    private int solve(int mask, int rem) {
        if (mask == (1 << n) - 1) {
            return rem == 0 ? 1 : -1;
        }
        if (memo[mask][rem] != 0) {
            return memo[mask][rem];
        }

        for (int i = 0; i < n; i++) {
            if ((mask & (1 << i)) == 0) { // If nums[i] is not used
                // Handle duplicates
                if (i > 0 && nums[i] == nums[i-1] && (mask & (1 << (i - 1))) == 0) {
                    continue;
                }
                
                int newRem = (int) ((rem * p10[i] + nums[i]) % k);
                if (solve(mask | (1 << i), newRem) == 1) {
                    parent[mask][rem] = i;
                    return memo[mask][rem] = 1;
                }
            }
        }
        return memo[mask][rem] = -1;
    }
}

Complexity Analysis

Time Complexity: O(2^N * k * N + N log N). The DP state space is `2^N * k`. For each state, we iterate up to `N` times to decide the next number. Sorting the array takes an initial `O(N log N)`.Space Complexity: O(2^N * k), where N is the length of `nums` and k is the divisor. This space is dominated by the memoization and parent tables.

Pros and Cons

Pros:
  • Efficient enough to pass the given constraints.
  • Guaranteed to find the optimal (lexicographically smallest) solution.
Cons:
  • More complex to understand and implement compared to a simple backtracking solution.
  • Requires significant memory for the memoization and parent tables, although it's feasible for the given constraints.

Video Solution

Watch the video walkthrough for Concatenated Divisibility



Patterns:

Dynamic ProgrammingBit ManipulationBitmask

Data Structures:

Array

Subscribe to Scale Engineer newsletter

Learn about System Design, Software Engineering, and interview experiences every week.

No spam, unsubscribe at any time.