What is the Entropy of Android's Dot Password System?

This is only a partial answer. The only relevant starting dots are 1, 2, and 5. For dots 1 and 2, you can generate a pattern equivalent to starting at any of the other dots (other than 5) with a simple rotation transformation. This means if you can enumerate all the patterns starting at 1 and 2 you can count all the patterns starting at any number other than 5 by multiplying by 4.

The paths starting at 5 are a different case. You can count all of them by counting all the paths that start with 5->1 and 5->2 and multiplying by 4 since you again can generate all the other possible paths with a simple rotation transformation.

So, a way to enumerate the paths would be...

(All paths starting at 1 + all paths starting at 2 + all paths starting with 5->1 + all paths starting with 5->2) * 4

Again, the numbering system, for easy reference:

1  2  3
4  5  6
7  8  9

For this first move:

From 1 -> 2, 4, 5, 6 and 8 are accessible.

From 2 -> 1, 3, 4, 5, 6, 7, and 9 are accessible (basically just not 8 or itself).

From 5 -> 1, 2, 3, 4, 6, 7, 8, and 9 are accessible.

For moves after that it gets really interesting.

For example, 1537 is a valid password. 1539 is not.

Here succinctly, are the rules:

No dot may be part of the path twice. If a dot is not part of the path and it is exactly crossed over by a transition, it becomes part of the path between the source dot and destination dot.

Here are some sample paths:

  • 2->3->1->8 is allowed.
  • 1->3->2->5 is not allowed because 2 is not part of the path when 1->3 goes exactly over 2, so the path becomes 1->2->3->5 whether you want it to or not.
  • 1->2->3->7 is not allowed because 3->7 crosses over 5 and 5 is not already part of the path.
  • 1->5->3->7 is allowed. 5 is ignored in 3->7 because it is already part of the path.

This program:

class LockPattern(object):
    def __init__(self, *args):
        if (len(args) == 1) and hasattr(args[0], '__iter__'):
            args = tuple(args[0])
        if len(args) > 9:
            raise TypeError("A LockPattern may have at most 9 elements.")
        self._pattern = ()
        for move in args:
            if not self.isValidNextStep(move):
                raise TypeError("%r is not a valid lock sequence." % (args,))
            else:
                self._pattern = self._pattern + (move,)
    def __len__(self):
        return len(self._pattern)
    def __iter__(self):
        return iter(self._pattern)
    def isValidNextStep(self, nextdot):
        nextdot = int(nextdot)
        if (nextdot < 1) or (nextdot > 9):
            raise ValueError("A lock sequence may only contain values from 1 "
                             "to 9 and %d isn't." % (nextdot,))
        if len(self._pattern) <= 0:
            return True # Any dot is valid for the first dot
        if len(self._pattern) >= 9:
            return False
        if nextdot in self._pattern:
            return False # No dot may be visited twice
        prevdot = self._pattern[-1]
        dotpair = tuple(sorted((prevdot, nextdot)))
        if dotpair == (1,3):
            return 2 in self._pattern
        if dotpair == (1,7):
            return 4 in self._pattern
        if dotpair in ((1,9),(2,8),(3,7),(4,6)):
            return 5 in self._pattern
        if dotpair == (3,9):
            return 6 in self._pattern
        if dotpair == (7,9):
            return 8 in self._pattern
        return True
    def isValidLockSequence(self):
        return 4 <= len(self)
    def newSequenceAddDot(self, nextdot):
        if not self.isValidNextStep(nextdot):
            raise ValueError("%d is not a valid next dot for the sequence." % (nextdot,))
        newseq = LockPattern()
        newseq._pattern = self._pattern + (nextdot,)
        return newseq

def genAllPatterns(starting = LockPattern()):
    if starting.isValidLockSequence():
        yield starting
    for dot in xrange(1,10):
        if starting.isValidNextStep(dot):
            for result in genAllPatterns(starting.newSequenceAddDot(dot)):
                yield result

print reduce(lambda x, p: x+1, genAllPatterns(), 0)

Generates an answer of 389112.

It also validates my previous intuitions:

lsts = tuple(((p, list(genAllPatterns(LockPattern(p)))) for p in ((1,), (2,), (5,1), (5,2))))
[(x[0], len(x[1])) for x in lsts]
-> [((1,), 38042), ((2,), 43176), ((5, 1), 7352), ((5, 2), 8708)]
sum((len(x[1]) for x in lsts)
-> 97278
97278 * 4
-> 389112

The key is to notice that once you have visited any two dots, you can reach any other dot that you desire without moving through any dot that you have not already visited. So once you pick two starting dots (in order), then you can choose the rest of your password at will, in any order you wish. (This is because "knight's moves" are allowed, at least according to the page you linked)

So, excluding the 'starting two' dots, the number of remaining 2-7 digit tails are 7P2 + 7P3 + 7P4 + 7P5 + 7P6 + 7P7. There are 56 potential starting two-digit heads because there are 5 moves you can make from any corner dot, 7 from any edge dot, and 8 from the center dot. So the total number of unlocking patterns would be 56 * (7P2 + 7P3 + 7P4 + 7P5 + 7P6 + 7P7) = 766 752.

If this is wrong, it's probably because I am missing some knowledge of the rules of this system.


Okay, so first off let me start by saying that omnifarious appears to be correct. What can we do with math. I would agree with him when he says that there are indeed only 3 cases to be concerned with. 1,2 and 5.

The OP wants some sort of elegant counting formula, which I'm doubtful exists for a problem like this. When you use all 9 dots you're finding Hamiltonian paths, which if this was a complete graph would be very easy to count (it's not). Because each of the three cases is unique, you're going to enumerate through all of them in order to find the total number of paths.

Let's take a look at the first case, beginning in a corner. You have four choices for a corner, then you have 5 choices for your next square. Now you'll quickly see that our formula expands rather quickly, because we have 5 choices and we have to split those 5 choices into 2 sets.

Moving to an middle side square, will have the same choices regardless of which one you move to. As opposed to moving to 5 which will give us many more options, already our formula is:

4*(4*(6)+1*(7))

Then we have to break the 6 and 7 options into further groups. If we move to a side square, then we can now move to two side squares, three corners and one middle square. Our formula then becomes:

4*(4*(2*(5)+3*(3)+1*(7))+1*(7))

Then we have to start solving for if we had moved to the middle square and I'm going to stop here. Finding Hamiltonian paths is NP-complete, albeit we're just counting them. But there aren't any properties here that we can take advantage of for an elegant solution. It's a graph theory problem and it's one that involves brute forcing the solution, as has been previously done by Omnifarious.

I'll try to explain why you're intuition is wrong, you say that:

"I know for a fact the solution to this lies in Discrete Math, specifically Permutations Without Repetition, If your answer doesn't use permutations or combinations you are incorrect."

Unfortunately you don't know this for a fact, because you don't know of a formula (unless you can show me a rigorous non-constructive proof). The fact is that a permutation or a combination means that when you have n items, you can pick any item at any iteration. Now you can put restrictions on the number of items you can pick and at what items you can pick at certain iterations. But what you can't do and expect an elegant solution is make picking certain items affect which items you can pick next. Which is exactly what this question is doing and why there isn't some nice formula using combinations and permutations.

In short the formula you're looking for doesn't exist, but Omnifarious did find the correct answer.