Maximum number achievable by converting two adjacent x to one (x+1)
Asked Answered
G

4

6

Given a sequence of N integers where 1 <= N <= 500 and the numbers are between 1 and 50. In a step any two adjacent equal numbers x x can be replaced with a single x + 1. What is the maximum number achievable by such steps.

For example if given 2 3 1 1 2 2 then the maximum possible is 4:

2 3 1 1 2 2 ---> 2 3 2 2 2 ---> 2 3 3 2 ---> 2 4 2.

It is evident that I should try to do better than the maximum number available in the sequence. But I can't figure out a good algorithm.

Girl answered 2/4, 2016 at 13:53 Comment(0)
M
2

Each substring of the input can make at most one single number (invariant: the log base two of the sum of two to the power of each entry). For every x, we can find the set of substrings that can make x. For each x, this is (1) every occurrence of x (2) the union of two contiguous substrings that can make x - 1. The resulting algorithm is O(N^2)-time.

Metamerism answered 2/4, 2016 at 14:47 Comment(9)
@MooingDuck We might want 122 instead of 221 from 11111.Metamerism
Check the sample: from 1 1 1 1 1, the best answer is 3, not 221. Inputs are individual numbers, not digits.Tyika
I found an O(c) algorithm: return random(2,55) - it is not 100% reliable, but it is very fast.Streit
@MooingDuck Greedy makes 111113 into 313 instead of 14.Metamerism
@DavidEisenstat ah, I understand now. I see the issue. However, when reading your answer, it's a bit confusing, and almost sounds like the answer is O(N*answer), rather than N^2. Can you clarify the algorithm?Tyika
I thought for a moment that O(n^2) might be more efficient for elements with a large range but even for billions of elements, my O(n*m) algorithm would be a magnitude more efficient since m seems to need only represent the numbers max element ± log2(n).Shult
@גלעדברקן With a hash-join for case (2), this is O(n*m) as well.Metamerism
I would love to see this implementedTragedienne
@Tragedienne The O(n^3) version follows the standard CYK dynamic program. We can shave a factor n using the Knuth optimization since the midpoint of the proper split is nondecreasing.Metamerism
D
1

An algorithm could work like this:

Convert the input to an array where every element has a frequency attribute, collapsing repeated consecutive values in the input into one single node. For example, this input:

1 2 2 4 3 3 3 3

Would be represented like this:

{val: 1, freq: 1}  {val: 2, freq: 2}  {val: 4, freq: 1}  {val: 3, freq: 4}  

Then find local minima nodes, like the node (3 3 3 3) in 1 (2 2) 4 (3 3 3 3) 4, i.e. nodes whose neighbours both have higher values. For those local minima that have an even frequency, "lift" those by applying the step. Repeat this until no such local minima (with even frequency) exist any more.

Start of the recursive part of the algorithm:

At both ends of the array, work inwards to "lift" values as long as the more inner neighbour has a higher value. With this rule, the following:

1 2 2 3 5 4 3 3 3 1 1

will completely resolve. First from the left side inward:

1 4 5 4 3 3 3 1 1

Then from the right side:

1 4 6 3 2

Note that when there is an odd frequency (like for the 3s above), there will be a "remainder" that cannot be incremented. The remainder should in this rule always be left on the outward side, so to maximise the potential towards the inner part of the array.

At this point the remaining local minima have odd frequencies. Applying the step to such a node will always leave a "remainder" (like above) with the original value. This remaining node can appear anywhere, but it only makes sense to look at solutions where this remainder is on the left side or the right side of the lift (not in the middle). So for example:

4 1 1 1 1 1 2 3 4

Can resolve to one of these:

4  2   2  1 2 3 4

Or:

4 1  2  2   2 3 4

The 1 in either second or fourth position, is the above mentioned "remainder". Obviously, the second way of resolving is more promising in this example. In general, the choice is obvious when on one side there is a value that is too high to merge with, like the left-most 4 is too high for five 1 values to get to. The 4 is like a wall.

When the frequency of the local minimum is one, there is nothing we can do with it. It actually separates the array in a left and right side that do not influence each other. The same is true for the remainder element discussed above: it separates the array into two parts that do not influence each other.

So the next step in the algorithm is to find such minima (where the choice is obvious), apply that kind of step and separate the problem into two distinct problems which should be solved recursively (from the top). So in the last example, the following two problems would be solved separately:

4
2 2 3 4

Then the best of both solutions will count as the overall solution. In this case that is 5.

The most challenging part of the algorithm is to deal with those local minima for which the choice of where to put the remainder is not obvious. For instance;

3 3 1 1 1 1 1 2 3

This can go to either:

3 3  2   2  1 2 3
3 3 1  2   2  2 3

In this example the end result is the same for both options, but in bigger arrays it would be less and less obvious. So here both options have to be investigated. In general you can have many of them, like 2 in this example:

3 1 1 1 2 3 1 1 1 1 1 3

Each of these two minima has two options. This seems like to explode into too many possibilities for larger arrays. But it is not that bad. The algorithm can take opposite choices in neighbouring minima, and go alternating like this through the whole array. This way alternating sections are favoured, and get the most possible value drawn into them, while the other sections are deprived of value. Now the algorithm turns the tables, and toggles all choices so that the sections that were previously favoured are now deprived, and vice versa. The solution of both these alternatives is derived by resolving each section recursively, and then comparing the two "grand" solutions to pick the best one.

Snippet

Here is a live JavaScript implementation of the above algorithm. Comments are provided which hopefully should make it readable.

"use strict";

function Node(val, freq) {
    // Immutable plain object
    return Object.freeze({
        val: val,
        freq: freq || 1, // Default frequency is 1.
        // Max attainable value when merged:
        reduced: val + (freq || 1).toString(2).length - 1
    });
}

function compress(a) {
    // Put repeated elements in a single node
    var result = [], i, j;
    for (i = 0; i < a.length; i = j) {
        for (j = i + 1; j < a.length && a[j] == a[i]; j++);
        result.push(Node(a[i], j - i));
    }
    return result;
}

function decompress(a) {
    // Expand nodes into separate, repeated elements
    var result = [], i, j;
    for (i = 0; i < a.length; i++) {
        for (j = 0; j < a[i].freq; j++) {
            result.push(a[i].val);
        }
    }
    return result;
}

function str(a) {
    return decompress(a).join(' ');
}

function unstr(s) {
    s = s.replace(/\D+/g, ' ').trim();
    return s.length ? compress(s.split(/\s+/).map(Number)) : [];
}

/*
 The function merge modifies an array in-place, performing a "step" on 
 the indicated element.
 The array will get an element with an incremented value
 and decreased frequency, unless a join occurs with neighboring 
 elements with the same value: then the frequencies are accumulated
 into one element. When the original frequency was odd there will 
 be a "remainder" element in the modified array as well.
*/
function merge(a, i, leftWards, stats) {
    var val = a[i].val+1,
        odd = a[i].freq % 2,
        newFreq = a[i].freq >> 1,
        last = i;
    // Merge with neighbouring nodes of same value:
    if ((!odd || !leftWards) && a[i+1] && a[i+1].val === val) {
        newFreq += a[++last].freq;
    }
    if ((!odd || leftWards) && i && a[i-1].val === val) {
        newFreq += a[--i].freq;
    }   
    // Replace nodes
    a.splice(i, last-i+1, Node(val, newFreq));
    if (odd) a.splice(i+leftWards, 0, Node(val-1));
    
    // Update statistics and trace: this is not essential to the algorithm
    if (stats) {
        stats.total_applied_merges++;
        if (stats.trace) stats.trace.push(str(a));
    }
    return i;
}

/*  Function Solve
    Parameters:
        a:  The compressed array to be reduced via merges. It is changed in-place
            and should not be relied on after the call.
        stats:  Optional plain object that will be populated with execution statistics.
    Return value:
        The array after the best merges were applied to achieve the highest
        value, which is stored in the maxValue custom property of the array.
*/
function solve(a, stats) {
    var maxValue, i, j, traceOrig, skipLeft, skipRight, sections, goLeft,
        b, choice, alternate;
    
    if (!a.length) return a;

    if (stats && stats.trace) { 
        traceOrig = stats.trace;
        traceOrig.push(stats.trace = [str(a)]);
    }

    // Look for valleys of even size, and "lift" them
    for (i = 1; i < a.length - 1; i++) {
        if (a[i-1].val > a[i].val && a[i].val < a[i+1].val && (a[i].freq % 2) < 1) {
            // Found an even valley
            i = merge(a, i, false, stats);
            if (i) i--;
        }
    }
    // Check left-side elements with always increasing values
    for (i = 0; i < a.length-1 && a[i].val < a[i+1].val; i++) {
        if (a[i].freq > 1) i = merge(a, i, false, stats) - 1;
    };
    // Check right-side elements with always increasing values, right-to-left
    for (j = a.length-1; j > 0 && a[j-1].val > a[j].val; j--) {
        if (a[j].freq > 1) j = merge(a, j, true, stats) + 1;
    };
    // All resolved?
    if (i == j) {
        while (a[i].freq > 1) merge(a, i, true, stats);
        a.maxValue = a[i].val;
    } else {
        skipLeft = i;
        skipRight = a.length - 1 - j;
        // Look for other valleys (odd sized): they will lead to a split into sections
        sections = [];
        for (i = a.length - 2 - skipRight; i > skipLeft; i--) {
            if (a[i-1].val > a[i].val && a[i].val < a[i+1].val) {
                // Odd number of elements: if more than one, there
                // are two ways to merge them, but maybe 
                // one of both possibilities can be excluded.
                goLeft = a[i+1].val > a[i].reduced;
                if (a[i-1].val > a[i].reduced || goLeft) {
                    if (a[i].freq > 1) i = merge(a, i, goLeft, stats) + goLeft;
                    // i is the index of the element which has become a 1-sized valley
                    // Split off the right part of the array, and store the solution
                    sections.push(solve(a.splice(i--), stats));
                }
            }
        }
        if (sections.length) {
            // Solve last remaining section
            sections.push(solve(a, stats));
            sections.reverse();
            // Combine the solutions of all sections into one
            maxValue = sections[0].maxValue;
            for (i = sections.length - 1; i >= 0; i--) {
                maxValue = Math.max(sections[i].maxValue, maxValue);
            }
        } else {
            // There is no more valley that can be resolved without branching into two
            // directions. Look for the remaining valleys.
            sections = [];
            b = a.slice(0); // take copy
            for (choice = 0; choice < 2; choice++) {
                if (choice) a = b; // restore from copy on second iteration
                alternate = choice;
                for (i = a.length - 2 - skipRight; i > skipLeft; i--) {
                    if (a[i-1].val > a[i].val && a[i].val < a[i+1].val) {
                        // Odd number of elements
                        alternate = !alternate
                        i = merge(a, i, alternate, stats) + alternate;
                        sections.push(solve(a.splice(i--), stats));
                    }
                }
                // Solve last remaining section
                sections.push(solve(a, stats));
            }
            sections.reverse(); // put in logical order
            // Find best section:
            maxValue = sections[0].maxValue;
            for (i = sections.length - 1; i >= 0; i--) {
                maxValue = Math.max(sections[i].maxValue, maxValue);
            }
            for (i = sections.length - 1; i >= 0 && sections[i].maxValue < maxValue; i--);
            // Which choice led to the highest value (choice = 0 or 1)?
            choice = (i >= sections.length / 2)
            // Discard the not-chosen version
            sections = sections.slice(choice * sections.length/2);
        }
        // Reconstruct the solution from the sections.
        a = [].concat.apply([], sections);
        a.maxValue = maxValue;
    }
    if (traceOrig) stats.trace = traceOrig;
    return a;
}

function randomValues(len) {
    var a = [];
    for (var i = 0; i < len; i++) {
        // 50% chance for a 1, 25% for a 2, ... etc.
        a.push(Math.min(/\.1*/.exec(Math.random().toString(2))[0].length,5));
    }
    return a;
}

// I/O
var inputEl = document.querySelector('#inp');
var randEl = document.querySelector('#rand');
var lenEl = document.querySelector('#len');
var goEl = document.querySelector('#go');
var outEl = document.querySelector('#out');

goEl.onclick = function() {
    // Get the input and structure it
    var a = unstr(inputEl.value),
        stats = {
            total_applied_merges: 0,
            trace: a.length < 100 ? [] : undefined
        };
    // Apply algorithm
    a = solve(a, stats);
    // Output results
    var output = {
        value: a.maxValue,
        compact: str(a),
        total_applied_merges: stats.total_applied_merges,
        trace: stats.trace || 'no trace produced (input too large)'
    };
    outEl.textContent = JSON.stringify(output, null, 4);
}

randEl.onclick = function() {
    // Get input (count of numbers to generate):
    len = lenEl.value;
    // Generate
    var a = randomValues(len);
    // Output
    inputEl.value = a.join(' ');
    // Simulate click to find the solution immediately.
    goEl.click();
}

// Tests
var tests = [
    ' ', '',
    '1', '1',
    '1 1', '2',
    '2 2 1 2 2', '3 1 3',
    '3 2 1 1 2 2 3', '5',
    '3 2 1 1 2 2 3 1 1 1 1 3 2 2 1 1 2', '6',
    '3 1 1 1 3', '3 2 1 3',
    '2 1 1 1 2 1 1 1 2 1 1 1 1 1 2', '3 1 2 1 4 1 2',
    '3 1 1 2 1 1 1 2 3', '4 2 1 2 3',
    '1 4 2 1 1 1 1 1 1 1', '1 5 1',
];

var res;
for (var i = 0; i < tests.length; i+=2) {
    var res = str(solve(unstr(tests[i])));
    if (res !== tests[i+1]) throw 'Test failed: ' + tests[i] + ' returned ' + res + ' instead of ' + tests[i+1];
}
Enter series (space separated):<br> 
<input id="inp" size="60" value="2 3 1 1 2 2"><button id="go">Solve</button>
<br>
<input id="len" size="4" value="30"><button id="rand">Produce random series of this size and solve</button>
<pre id="out"></pre>

As you can see the program produces a reduced array with the maximum value included. In general there can be many derived arrays that have this maximum; only one is given.

Dissert answered 2/4, 2016 at 16:19 Comment(8)
I don't think this solves the problem in general. E.g. suppose we have 5 5 4 4 6. Then your algorithm would first produce 6 4 4 6 then 6 5 6, then stop. But if we first combined the 4s to get 5 5 5 6, and then the second two 5s, we could then get 5 6 6 and finally 5 7, which is strictly better.Scriber
No it would not first take the 5 5, but the 4 4. It looks for the minimal value that has a pair.Dissert
Whoops, I missed that important detail. I can't find a counterexample now, but I'll keep thinking for a while about the correctness...Scriber
I added a JavaScript snippet to my answer; feel free to test it.Dissert
Seems to freeze with sequences somewhere greater than 300 numbers in length (even without recording and printing the 'trace').Shult
@גלעד ברקן: It's obviously O(2^n) in the worst case when there are many partial solutions containing odd-length blocks of duplicates, so it's not surprising that it can run out of time.Scriber
@Dissert if you are at all inclined, I would welcome a counterexample to plug in my coded attempt below at an O(n*m) dp. (I set the limit of the answer at 7 for an easier visualization of the matrix.)Shult
I updated the algorithm and code to remove the O(n²) factor. It now runs OK for 2000 elements.Dissert
I
1

An O(n*m) time and space algorithm is possible, where, according to your stated limits, n <= 500 and m <= 58 (consider that even for a billion elements, m need only be about 60, representing the largest element ± log2(n)). m is representing the possible numbers 50 + floor(log2(500)):

Consider the condensed sequence, s = {[x, number of x's]}.

If M[i][j] = [num_j,start_idx] where num_j represents the maximum number of contiguous js ending at index i of the condensed sequence; start_idx, the index where the sequence starts or -1 if it cannot join earlier sequences; then we have the following relationship:

M[i][j] = [s[i][1] + M[i-1][j][0], M[i-1][j][1]]
  when j equals s[i][0]

j's greater than s[i][0] but smaller than or equal to s[i][0] + floor(log2(s[i][1])), represent converting pairs and merging with an earlier sequence if applicable, with a special case after the new count is odd:

When M[i][j][0] is odd, we do two things: first calculate the best so far by looking back in the matrix to a sequence that could merge with M[i][j] or its paired descendants, and then set a lower bound in the next applicable cells in the row (meaning a merge with an earlier sequence cannot happen via this cell). The reason this works is that:

  1. if s[i + 1][0] > s[i][0], then s[i + 1] could only possibly pair with the new split section of s[i]; and
  2. if s[i + 1][0] < s[i][0], then s[i + 1] might generate a lower j that would combine with the odd j from M[i], potentially making a longer sequence.

At the end, return the largest entry in the matrix, max(j + floor(log2(num_j))), for all j.

JavaScript code (counterexamples would be welcome; the limit on the answer is set at 7 for convenient visualization of the matrix):

function f(str){

  var arr = str.split(/\s+/).map(Number);

  var s = [,[arr[0],0]];

  for (var i=0; i<arr.length; i++){
    if (s[s.length - 1][0] == arr[i]){
      s[s.length - 1][1]++;
    } else {
      s.push([arr[i],1]);
    }
  }

  var M = [new Array(8).fill([0,0])], 
      best = 0;

  for (var i=1; i<s.length; i++){
    M[i] = new Array(8).fill([0,i]);

    var temp = s[i][1],
        temp_odd,
        temp_start,
        odd = false;

    for (var j=s[i][0]; temp>0; j++){
   
      var start_idx = odd ? temp_start : M[i][j-1][1];

      if (start_idx != -1 && M[start_idx - 1][j][0]){
        temp += M[start_idx - 1][j][0];
        start_idx = M[start_idx - 1][j][1];
      }

      if (!odd){
        M[i][j] = [temp,start_idx];
        temp_odd = temp;
      } else {  
        M[i][j] = [temp_odd,-1];
        temp_start = start_idx;
      }

      if (!odd && temp & 1 && temp > 1){
        odd = true;
        temp_start = start_idx;
      }

      best = Math.max(best,j + Math.floor(Math.log2(temp)));

      temp >>= 1;
      temp_odd >>= 1;
    }
  }

  return [arr, s, best, M];
}

// I/O
var button = document.querySelector('button');
var input = document.querySelector('input');
var pre = document.querySelector('pre');

button.onclick = function() {
  var val = input.value;
  var result = f(val);
  var text = '';
  for (var i=0; i<3; i++){
    text += JSON.stringify(result[i]) + '\n\n';
  }
  for (var i in result[3]){
    text += JSON.stringify(result[3][i]) + '\n';
  }
  pre.textContent = text;
}
<input value ="2 2 3 3 2 2 3 3 5">
<button>Solve</button>
<pre></pre>
Impercipient answered 3/4, 2016 at 19:11 Comment(17)
"For j's greater than s[i][0] but smaller than or equal to s[i][0] + floor(log2(s[i][1]))" -- I don't think this works, unless s[i][1] is exactly a power of 2. If it isn't, then to convert s[i][0] to the desired value of j, we will need to leave some nonzero number of non-j values somewhere, and regardless of where we put them (at the beginning, at the end, sprinkled around) they mess up the property that M[i][j] needs to have to be combined into solutions to larger subproblems. It's possible I'm misunderstanding, though.Scriber
@Scriber I think you might be misunderstanding. While you can divide the number by two, you continue moving right in the row, until an odd number is encountered. I would welcome one of your eloquent counterexamples for me to think about.Shult
Suppose we are trying to calculate M[i=100][j=6], and s[i] = [3, 18]. To turn those 3s at position i into 6s, we need to combine exactly 16 of the 18 of them together, producing a sequence containing exactly 2 6s and either 2 leftover 3s or 1 leftover 4. To satisfy the definition of M[i][j] ("contiguous js ending at index i") we need this sequence to end with 6s, and if we want it to be maximum-length, it means the 4 (or the 2 3s) must appear at the start, like 3366 or 466. But then we can't legally combine it with a previous series of 6s. ...Scriber
... OTOH if we try to combine those 3s into 5s (in order to later convert pairs of them into 6s), we can only get sequences 335555 or 45555, which can't be legally combined with any previous series of 5s. AFAICT, by taking the max of these 2 possibilities, your algorithm assumes that both of these avenues are always legal, when it's possible that (as in this example) neither are.Scriber
@Scriber But that's exactly my point. M[100][4] will equal (9,1 + M[i - 1][j].len_j), and the next cell/s will check backwards for the best so far, and be blocked => M[100][5] = (4,⊥); M[100][6] = (2,⊥); M[100][7] = (1,⊥)Shult
Then I'm afraid I don't understand how your calculations work -- it seems that something later on (perhaps in the paragraph beginning "When") "amends" statements in the paragraph beginning "For", which is what I'm currently trying to understand. Could you perhaps provide pseudocode or even a runnable implementation?Scriber
@Scriber can you follow the example at the end?Shult
I'll have another look when I have some time, but while examples are helpful, to reason about correctness I really need to see the algorithm specified in an unambiguous way.Scriber
@Scriber added code (the matrix is limited to 8 for visual), but I forgot to code the comparison, lol, since it seems to work for my little examples. I hope you'll have a nice counterexample to think about.Shult
@Scriber Also, I do not think my algorithm description is ambiguous. I'll be happy to explain parts you do not understand.Shult
Thanks for adding the code! I'll have a look later this week hopefully... :)Scriber
For the input 3 1 1 2 1 1 1 2 3, the output given by your code is 5. Yet, I see no way to achieve that. On the other side, this algorithm is impressive in its conciseness. I'll need more time to actually grasp what is happening there.Dissert
Here is another counter example, but one that gives a too low value: 1 4 2 1 1 1 1 1 1 1 can reach 5 by pairing the 1s from left to right, yet your current code reports 4.Dissert
@Dissert Thanks so much for checking it out! Your counterexamples helped me fix some things - (1) clearly define the lower bound, and (2) make sure the 'shadow calculation' that looks backwards without recording in the matrix works. The examples you provided seem to work now.Shult
Great! I ran some thousands of tests on random arrays of length 5 to 200 elements, and your solution gives the same value output now as my code for all those cases.Dissert
Did some more testing: all OK. Is more efficient than what I posted as well. You got my vote.Dissert
@Scriber so what do you think? Trincot did some testing (on the code just before this current edit; I simplified the code a little but it's only slightly tested) and it seems good (see comments).Shult
K
0

Here's a brute force solution:

function findMax(array A, int currentMax)
    for each pair (i, i+1) of indices for which A[i]==A[i+1] do
        currentMax = max(A[i]+1, currentMax)
        replace A[i],A[i+1] by a single number A[i]+1
        currentMax = max(currentMax, findMax(A, currentMax))
    end for
    return currentMax

Given the array A, let currentMax=max(A[0], ..., A[n])
print findMax(A, currentMax)

The algorithm terminates because in each recursive call the array shrinks by 1.

It's also clear that it is correct: we try out all possible replacement sequences.

The code is extremely slow when the array is large and there's lots of options regarding replacements, but actually works reasonbly fast on arrays with small number of replaceable pairs. (I'll try to quantify the running time in terms of the number of replaceable pairs.)

A naive working code in Python:

def findMax(L, currMax):
    for i in range(len(L)-1):
        if L[i] == L[i+1]:
            L[i] += 1
            del L[i+1]
            currMax = max(currMax, L[i])
            currMax = max(currMax, findMax(L, currMax))
            L[i] -= 1
            L.insert(i+1, L[i])
    return currMax

# entry point
if __name__ == '__main__':
    L1 = [2, 3, 1, 1, 2, 2]
    L2 = [2, 3, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2]
    print findMax(L1, max(L1))
    print findMax(L2, max(L2))

The result of the first call is 4, as expected.

The result of the second call is 5 as expected; the sequence that gives the result: 2,3,1,1,2,2,2,2,2,2,2,2, -> 2,3,1,1,3,2,2,2,2,2,2 -> 2,3,1,1,3,3,2,2,2,2, -> 2,3,1,1,3,3,3,2,2 -> 2,3,1,1,3,3,3,3 -> 2,3,1,1,4,3, -> 2,3,1,1,4,4 -> 2,3,1,1,5

Kraska answered 2/4, 2016 at 14:5 Comment(2)
It returns 9, as expected.Kraska
Indeed, I noticed the max in the principle call after my comment.Dissert

© 2022 - 2024 — McMap. All rights reserved.