Why is this DP algo slower than the brute forcea algo?

Viewed 155

I am working on implementing the longest palindromic substring problem and I followed the approach with DP and extra O(N^2) (yes I know there is an even more efficient algorithm but I am not interested in that in this post).
My implementation which basically uses the recurrence:

P(i, j) = P(i + 1, j - 1) ^ s[i] == s[j]

builds the relevant table but the run time is much slower than expected.
It does give the correct output if I run it in my IDE after several seconds (15+) but it is rejected by any online judge as too slow. I am not sure where the issue is since I am using memorization. So there is not recomputation of the same cases.
The strings that are starting to show that the algorithm has a performance issue are over 900 chars long.

Update
I am updating the question to add full source code and test case

Dynamic Programming approach O(N^2) time and O(N^2) space (not accepted and too slow)

public static String longestPalindromeDP(String s) {
        Map<List<Integer>, Boolean> cache = new HashMap<>();
        for(int i = 0; i < s.length(); i++) {
            for(int j = 0; j < s.length(); j++) {
                populateTable(s, i, j, cache);
            }
        }
        int start = 0;
        int end = 0;
        for(int i = 0; i < s.length(); i++) {
            for(int j = 0; j < s.length(); j++) {
                if(cache.get(Arrays.asList(i, j))) {
                    if(Math.abs(start - end) < Math.abs(i - j)) {
                        start = i;
                        end = j;
                    }
                }
            }
        }
        return s.substring(start, end + 1);
    }

private static boolean populateTable(String s, int i, int j, Map<List<Integer>, Boolean> cache) {
        if(i == j) {
            cache.put(Arrays.asList(i, j), true);
            return true;
        }
        if(Math.abs(i - j) == 1) {
            cache.put(Arrays.asList(i, j), s.charAt(i) == s.charAt(j));
            return s.charAt(i) == s.charAt(j);
        }

        if(cache.containsKey(Arrays.asList(i, j))) {
            return cache.get(Arrays.asList(i, j));
        }

        boolean res = populateTable(s, i + 1, j - 1, cache) && s.charAt(i) == s.charAt(j);
        cache.put(Arrays.asList(i, j), res);
        cache.put(Arrays.asList(j, i), res);
        return res;
    }

This is very slow in the populateTable but once it finishes the result is correct.

Brute force O(N^3) time and O(1) space: much faster and accepted

public static String longestPalindromeBruteForce(String s) {
        if(s.length() == 1) {
            return s;
        }
        String result = "";
        for(int i = 0; i < s.length(); i++) {
            for(int j = i + 1; j <= s.length(); j++) {
                String tmp = s.substring(i, j);
                if(isPalindrome(tmp)) {
                    if(tmp.length() > result.length()) {
                        result = tmp;
                        if(result.length() == s.length()) {
                            return result;
                        }
                    }
                }
            }
        }

        return result;
    }

    private static boolean isPalindrome(String s) {
        for(int i = 0, j = s.length() - 1; i < j; i++, j--) {
            if(s.charAt(i) != s.charAt(j)) {
                return false;
            }
        }
        return true;
    }  

Testing and input:

public static void main(String[] args) {
        final String string1 = "civilwartestingwhetherthatnaptionoranynartionsoconceivedandsodedicatedcanlongendureWeareqmetonagreatbattlefiemldoftzhatwarWehavecometodedicpateaportionofthatfieldasafinalrestingplaceforthosewhoheregavetheirlivesthatthatnationmightliveItisaltogetherfangandproperthatweshoulddothisButinalargersensewecannotdedicatewecannotconsecratewecannothallowthisgroundThebravelmenlivinganddeadwhostruggledherehaveconsecrateditfaraboveourpoorponwertoaddordetractTgheworldadswfilllittlenotlenorlongrememberwhatwesayherebutitcanneverforgetwhattheydidhereItisforusthelivingrathertobededicatedheretotheulnfinishedworkwhichtheywhofoughtherehavethusfarsonoblyadvancedItisratherforustobeherededicatedtothegreattdafskremainingbeforeusthatfromthesehonoreddeadwetakeincreaseddevotiontothatcauseforwhichtheygavethelastpfullmeasureofdevotionthatweherehighlyresolvethatthesedeadshallnothavediedinvainthatthisnationunsderGodshallhaveanewbirthoffreedomandthatgovernmentofthepeoplebythepeopleforthepeopleshallnotperishfromtheearth";
        //final String string2 = "ibvjkmpyzsifuxcabqqpahjdeuzaybqsrsmbfplxycsafogotliyvhxjtkrbzqxlyfwujzhkdafhebvsdhkkdbhlhmaoxmbkqiwiusngkbdhlvxdyvnjrzvxmukvdfobzlmvnbnilnsyrgoygfdzjlymhprcpxsnxpcafctikxxybcusgjwmfklkffehbvlhvxfiddznwumxosomfbgxoruoqrhezgsgidgcfzbtdftjxeahriirqgxbhicoxavquhbkaomrroghdnfkknyigsluqebaqrtcwgmlnvmxoagisdmsokeznjsnwpxygjjptvyjjkbmkxvlivinmpnpxgmmorkasebngirckqcawgevljplkkgextudqaodwqmfljljhrujoerycoojwwgtklypicgkyaboqjfivbeqdlonxeidgxsyzugkntoevwfuxovazcyayvwbcqswzhytlmtmrtwpikgacnpkbwgfmpavzyjoxughwhvlsxsgttbcyrlkaarngeoaldsdtjncivhcfsaohmdhgbwkuemcembmlwbwquxfaiukoqvzmgoeppieztdacvwngbkcxknbytvztodbfnjhbtwpjlzuajnlzfmmujhcggpdcwdquutdiubgcvnxvgspmfumeqrofewynizvynavjzkbpkuxxvkjujectdyfwygnfsukvzflcuxxzvxzravzznpxttduajhbsyiywpqunnarabcroljwcbdydagachbobkcvudkoddldaucwruobfylfhyvjuynjrosxczgjwudpxaqwnboxgxybnngxxhibesiaxkicinikzzmonftqkcudlzfzutplbycejmkpxcygsafzkgudy";

        long startTime = System.nanoTime();

        String palindromic = longestPalindromeDP(string1);
        long elapsed = TimeUnit.SECONDS.convert(System.nanoTime() - startTime, TimeUnit.NANOSECONDS);

        System.out.println(elapsed);
        System.out.println(palindromic);

    }

The BruteForce finishes in 0 seconds.
The DynamicProgramming finishes in up to 9 seconds (depending on the machine)

What is the problem here?
I understand that there can be some optimization to improve the performance but how is it possible that the O(N^3) outperforms the O(N^2) since I use memoization?

Update
Update based on the answer of @CahidEnesKeleş

I replaced the List<Integer> as key with a custom object:

class IdxPair {  
   int i;
   int j;  
   IdxPair(int i, int j) {
     this.i = i;
     this.j = j;  
   }  

   @Override  
   public boolean equals(Object o) {  
      if(o == null || !(o instanceof IdxPair)) return false;  
      if(this == o ) return true;  
      IdxPair other = (IdxPair) o;  
      return this.i == other.i && this.j == other.j;  
   }  

   @Override 
   public int hashCode() {  
     int h = 31;  
     h = 31 * i + 37;  
     h = (37 * h) + j;    
     return h;  
   }  
} 

Although a couple of test cases that previously failed, now pass it is still too slow overall and rejected by online judges.

1 Answers

I tried using c-like arrays instead of HashMap, here is the code:

public static String longestPalindromeDP(String s) {
    int[][] cache = new int[s.length()][s.length()];
    for (int i = 0; i < s.length(); i++) {
        for (int j = 0; j < s.length(); j++) {
            cache[i][j] = -1;
        }
    }
    for(int i = 0; i < s.length(); i++) {
        for(int j = 0; j < s.length(); j++) {
            populateTable(s, i, j, cache);
        }
    }
    int start = 0;
    int end = 0;
    for(int i = 0; i < s.length(); i++) {
        for(int j = 0; j < s.length(); j++) {
            if(cache[i][j] == 1) {
                if(Math.abs(start - end) < Math.abs(i - j)) {
                    start = i;
                    end = j;
                }
            }
        }
    }
    return s.substring(start, end + 1);
}

private static boolean populateTable(String s, int i, int j, int[][] cache) {
    if(i == j) {
        cache[i][j] = 1;
        return true;
    }
    if(Math.abs(i - j) == 1) {
        cache[i][j] = s.charAt(i) == s.charAt(j) ? 1 : 0;
        return s.charAt(i) == s.charAt(j);
    }

    if (cache[i][j] != -1) {
        return cache[i][j] == 1;
    }

    boolean res = populateTable(s, i + 1, j - 1, cache) && s.charAt(i) == s.charAt(j);
    cache[i][j] = res ? 1 : 0;
    cache[j][i] = res ? 1 : 0;
    return res;
}

This code works faster than brute force approach. In my computer old dp finishes in ~5000 milliseconds, new dp finishes ~30 milliseconds, and bruteforce finishes in ~100 milliseconds.

Now that we know the reason for slowness, I conducted further experiments and measured the following codes' running time.

for (int i = 0; i < 1000; i++) {
    for (int j = 0; j < 1000; j++) {
        cache.put(Arrays.asList(i, j), true);
    }
}

This code finishes in 2000 milliseconds. I further divided the expression to find exactly what is the source of slowness.

for (int i = 0; i < 1000; i++) {
    for (int j = 0; j < 1000; j++) {
        Arrays.asList(i, j);
    }
}

This code finishes in 37 milliseconds.

Map<Integer, Boolean> cache = new HashMap<>();
for (int i = 0; i < 1000; i++) {
    for (int j = 0; j < 1000; j++) {
        cache.put(i*1000 + j, true);
    }
}

This code finishes in 97 milliseconds.

Not Arrays.asList neither Map.put is slow. Maybe the hash function of the list is slow

for (int i = 0; i < 1000; i++) {
    for (int j = 0; j < 1000; j++) {
        Arrays.asList(i, j).hashCode();
    }
}

This code finishes in 101 milliseconds.

No. This is fast as well. So maybe hash values collide most of the time. To test this, I put all hash codes inside a set and checked its size.

Set<Integer> hashSet = new HashSet<>();
for (int i = 0; i < 1000; i++) {
    for (int j = 0; j < 1000; j++) {
        hashSet.add(Arrays.asList(i, j).hashCode());
    }
}
System.out.println(hashSet.size());

And it gave 31969. 31969 out of 1000000 is about %3,2. I think this is the source of the slowness. 1m item is too much for HashMap. It starts to move away from O(1) as more and more collisions occur.

Related