Search code examples
algorithmtheory

Using min/maximum values of a type when finding the min/maximum values in a set


Originally, I had basically written an essay with a question at the end, so I'm going to cram it down to this: which is better (being really nit-picky here)?

A)

int min = someArray[0][0];
for (int i = 0; i < someArray.length; i++)
    for (int j = 0; j < someArray[i].length; j++)
        min = Math.min(min, someArray[i][j]);

-or-

B)

int min = int.MAX_VALUE;
for (int i = 0; i < someArray.length; i++)
     for (int j = 0; j < someArray[i].length; j++)
         min = Math.min(min, someArray[i][j]);

I reckon b is faster, saving an instruction or two by initializing min to a constant value instead of using the indexer. It also feels less redundant - no comparing someArray[0][0] to itself...

As an algorithm, which is better/valid-er.

EDIT: Assume that the array is not null and not empty.
EDIT2: Fixed a couple of careless errors.


Solution

  • Both of these algorithms are correct (assuming, of course, the array is nonempty). I think that version A works more generally, since for some types (strings, in particular) there may not be a well-defined maximum value.

    The reason that these algorithms are equivalent has to do with a cool mathematical object called a semilattice. To motivate semilattices, there are few cool properties of max that happen to hold true:

    1. max is idempotent, so applying it to the same value twice gives back that original value: max(x, x) = x
    2. max is commutative, so it doesn't matter what order you apply it to its arguments: max(x, y) = max(y, x)
    3. max is associative, so when taking the maximum of three or more values it doesn't matter how you group the elements: max(max(x, y), z) = max(x, max(y, z))

    These laws also hold for the minimum as well, as well as many other structures. For example, if you have a tree structure, the "least upper bound" operator also satisfies these constraints. Similarly, if you have a collection of sets and set union or intersection, you'd find that these constraints hold as well.

    If you have a set of elements (for example, integers, strings, etc.) and some binary operator defined over them with the above three properties (idempotency, commutativity, and associativity), then you have found a structure called a semilattice. The binary operator is then called a meet operator (or sometimes a join operator depending on the context).

    The reason that semilattices are useful is that if you have a (finite) collection of elements drawn from a semilattice and want to compute their meet, you can do so by using a loop like this:

    Element e = data[0];
    for (i in data[1 .. n])
        e = meet(e, data[i])
    

    The reason that this works is that because the meet operator is commutative and associative, we can apply the meet across the elements in any order that we want. Applying it one element at a time as we walk across the elements of the array in order thus produces the same value than if we had shuffled the array elements first, or iterated in reverse order, etc. In your case, the meet operator was "max" or "min," and since they satisfy the laws for meet operators described above the above code will correctly compute the max or min.

    To address your initial question, we need a bit more terminology. You were curious about whether or not it was better or safer to initialize your initial guess of the minimum value to be the maximum possible integer. The reason this works is that we have the cool property that

    min(int.MAX_VALUE, x) = min(x, int.MAX_VALUE) = x
    

    In other words, if you compute the meet of int.MAX_VALUE and any other value, you get the second value back. In mathematical terms, this is because int.MAX_VALUE is the top element of the meet semilattice. More formally, a top element for a meet semilattice is an element (denoted ⊤) satisfying

    meet(⊤, x) = meet(x, ⊤) = x

    If you use max instead of min, then the top element would be int.MIN_VALUE, since

    max(int.MIN_VALUE, x) = max(x, int.MIN_VALUE) = x
    

    Because applying the meet operator to ⊤ and any other element produces that other element, if you have a meet semilattice with a well-defined top element, you can rewrite the above code to compute the meet of all the elements as

    Element e = Element.TOP;
    for (i in data[0 .. n])
        e = meet(e, data[i])
    

    This works because after the first iteration, e is set to meet(e, data[0]) = meet(Element.TOP, data[0]) = data[0] and the iteration proceeds as usual. Consequently, in your original question, it doesn't matter which of the two loops you use; as long as there is at least one element defined, they produce the same value.

    That said, not all semilattices have a top element. Consider, for example, the set of all strings where the meet operator is defined as

    meet(x, y) = x if x lexicographically precedes y
               = y otherwise
    

    For example, meet("a", "ab") = "a", meet("dog, "cat") = "cat", etc. In this case, there is no string s that satisfies the property meet(s, x) = meet(x, s) = x, and so the semilattice has no top element. In that case, you cannot possibly use the second version of the code, because there is no top element that you can initialize the initial value to.

    However, there is a very cute technique you can use to fake this, which actually does end up getting used a bit in practice. Given a semilattice with no top element, you can create a new semilattice that does have a top element by introducing a new element ⊤ and arbitrarily defining that meet(⊤, x) = meet(x, ⊤) = x. In other words, this element is specially crafted to be a top element and has no significance otherwise.

    In code, you can introduce an element like this implicitly by writing

    bool found = false;
    Element e;
    
    for (i in data[0 .. n]) {
        if (!found) {
            found = true;
            e = i;
        } else {
            e = meet(e, i);
        }
    }
    

    This code works by having an external boolean found keep track of whether or not we have seen the first element yet. If we haven't, then we pretend that the element e is this new top element. Computing the meet of this top element and the array element produces the array element, and so we can just set the element e to be equal to that array element.

    Hope this helps! Sorry if this is too theoretical... I just happen to like math. :-)