Facebook Pixel

3598. Longest Common Prefix Between Adjacent Strings After Removals

MediumArrayString
Leetcode Link

Problem Description

You are given an array of strings called words. Your task is to process each position in the array and determine what happens to adjacent pairs when you temporarily remove each element.

For each index i from 0 to words.length - 1, you need to:

  1. Remove the string at index i from the array (temporarily, just for this calculation)
  2. In the modified array (with one element removed), look at all adjacent pairs of strings
  3. For each adjacent pair, calculate the longest common prefix - this is the longest sequence of characters that match from the beginning of both strings
  4. Find the maximum length among all these common prefixes

The longest common prefix between two strings is found by comparing characters from the start until they differ. For example:

  • "flower" and "flow" have a common prefix of "flow" (length 4)
  • "dog" and "cat" have no common prefix (length 0)
  • "abc" and "abd" have a common prefix of "ab" (length 2)

You need to return an array answer where answer[i] represents the length of the longest common prefix found among all adjacent pairs after removing the element at index i.

Special cases:

  • If removing an element leaves fewer than 2 strings in the array, there are no adjacent pairs, so answer[i] = 0
  • If no adjacent pairs share any common prefix, answer[i] = 0

Example scenario: If words = ["abc", "abd", "xyz", "abf"] and you remove index 2 ("xyz"), the remaining array is ["abc", "abd", "abf"]. The adjacent pairs are:

  • "abc" and "abd" with common prefix "ab" (length 2)
  • "abd" and "abf" with common prefix "ab" (length 2)

So the maximum common prefix length would be 2.

Quick Interview Experience
Help others by sharing your interview experience
Have you seen this problem before?

Intuition

The naive approach would be to actually remove each element, rebuild the array, calculate all common prefixes for adjacent pairs, and find the maximum. However, this involves a lot of redundant computation since we're recalculating many of the same prefix lengths repeatedly.

The key insight is that when we remove an element at index i, most of the adjacent pairs remain unchanged. Specifically:

  • All pairs before index i-1 stay the same
  • All pairs after index i+1 stay the same
  • Only the pairs involving the removed element are affected

When we remove element at index i:

  • We lose the pair (words[i-1], words[i])
  • We lose the pair (words[i], words[i+1])
  • We gain a new pair (words[i-1], words[i+1]) since these elements become adjacent after removal

This means instead of recalculating everything, we can:

  1. Maintain a data structure with all current prefix lengths
  2. For each removal, only update the affected pairs (remove 2, add 1)
  3. Query the maximum value
  4. Restore the original state before moving to the next index

We use an ordered set (SortedList) because:

  • We need to efficiently add and remove specific prefix length values
  • We need to quickly find the maximum value (last element in sorted order)
  • The set automatically maintains sorted order, making the max query O(1)

The calc function is cached with @cache decorator to avoid recalculating the same prefix lengths multiple times, since the same pair of strings might be compared at different removal indices.

This approach reduces the time complexity significantly by avoiding redundant calculations and leveraging the fact that each removal only affects a small, localized portion of the adjacent pairs.

Solution Approach

The solution uses an ordered set to efficiently track and update the longest common prefix lengths as we simulate removing each element.

Step 1: Calculate Common Prefix Function

The calc(s, t) function computes the longest common prefix between two strings:

@cache
def calc(s: str, t: str) -> int:
    k = 0
    for a, b in zip(s, t):
        if a != b:
            break
        k += 1
    return k

The @cache decorator memoizes results to avoid recalculating the same string pairs.

Step 2: Helper Functions for Set Management

Two helper functions manage the ordered set:

  • add(i, j): Adds the prefix length of words[i] and words[j] to the set (with bounds checking)
  • remove(i, j): Removes the prefix length of words[i] and words[j] from the set (with bounds checking)

Step 3: Initialize the Ordered Set

sl = SortedList(calc(a, b) for a, b in pairwise(words))

This creates a sorted list containing all initial prefix lengths between adjacent pairs. The pairwise function generates all consecutive pairs (words[0], words[1]), (words[1], words[2]), etc.

Step 4: Process Each Removal

For each index i to be removed:

  1. Remove affected pairs from the set:

    • remove(i, i + 1): Remove the pair of current element with its right neighbor
    • remove(i - 1, i): Remove the pair of current element with its left neighbor
  2. Add the new adjacent pair:

    • add(i - 1, i + 1): After removing words[i], words[i-1] and words[i+1] become adjacent
  3. Record the maximum prefix length:

    ans.append(sl[-1] if sl and sl[-1] > 0 else 0)

    The last element in the sorted list (sl[-1]) is the maximum. If the set is empty or the maximum is 0, we record 0.

  4. Restore the original state:

    • remove(i - 1, i + 1): Remove the temporarily added pair
    • add(i - 1, i): Restore the left pair
    • add(i, i + 1): Restore the right pair

Time Complexity:

  • Initial setup: O(n * m) where n is the number of words and m is average string length
  • For each removal: O(log n) for set operations
  • Total: O(n * m + n * log n)

Space Complexity:

  • O(n) for the sorted list
  • O(n^2) worst case for the cache if all pairs are computed

This approach efficiently handles the dynamic updates by only modifying the affected pairs rather than recalculating everything from scratch for each removal.

Ready to land your dream job?

Unlock your dream job with a 5-minute evaluator for a personalized learning plan!

Start Evaluator

Example Walkthrough

Let's walk through the solution with words = ["abc", "abd", "xyz", "abf"].

Initial Setup: First, we calculate all adjacent pairs and their common prefix lengths:

  • Pair (0,1): "abc" and "abd" → common prefix "ab" → length 2
  • Pair (1,2): "abd" and "xyz" → common prefix "" → length 0
  • Pair (2,3): "xyz" and "abf" → common prefix "" → length 0

Our sorted list initially contains: [0, 0, 2]

Processing Each Removal:

When i = 0 (removing "abc"):

  1. Remove pairs involving index 0:
    • Remove pair (0,1) with length 2 → sorted list becomes [0, 0]
    • Remove pair (-1,0) → nothing to remove (out of bounds)
  2. Add new adjacent pair:
    • Add pair (-1,1) → nothing to add (out of bounds)
  3. Maximum in sorted list is 0
  4. Restore original state → sorted list back to [0, 0, 2]
  5. Result: answer[0] = 0

When i = 1 (removing "abd"):

  1. Remove pairs involving index 1:
    • Remove pair (1,2) with length 0 → sorted list becomes [0, 2]
    • Remove pair (0,1) with length 2 → sorted list becomes [0]
  2. Add new adjacent pair:
    • Add pair (0,2): "abc" and "xyz" → common prefix "" → length 0
    • sorted list becomes [0, 0]
  3. Maximum in sorted list is 0
  4. Restore original state → sorted list back to [0, 0, 2]
  5. Result: answer[1] = 0

When i = 2 (removing "xyz"):

  1. Remove pairs involving index 2:
    • Remove pair (2,3) with length 0 → sorted list becomes [0, 2]
    • Remove pair (1,2) with length 0 → sorted list becomes [2]
  2. Add new adjacent pair:
    • Add pair (1,3): "abd" and "abf" → common prefix "ab" → length 2
    • sorted list becomes [2, 2]
  3. Maximum in sorted list is 2
  4. Restore original state → sorted list back to [0, 0, 2]
  5. Result: answer[2] = 2

When i = 3 (removing "abf"):

  1. Remove pairs involving index 3:
    • Remove pair (3,4) → nothing to remove (out of bounds)
    • Remove pair (2,3) with length 0 → sorted list becomes [0, 2]
  2. Add new adjacent pair:
    • Add pair (2,4) → nothing to add (out of bounds)
  3. Maximum in sorted list is 2
  4. Restore original state → sorted list back to [0, 0, 2]
  5. Result: answer[3] = 2

Final Answer: [0, 0, 2, 2]

The key efficiency comes from only updating the affected pairs (typically 3 operations per removal) rather than recalculating all pairs from scratch, and the sorted list allows us to get the maximum in O(1) time.

Solution Implementation

1from typing import List
2from functools import cache
3from sortedcontainers import SortedList
4from itertools import pairwise
5
6class Solution:
7    def longestCommonPrefix(self, words: List[str]) -> List[int]:
8        @cache
9        def calculate_common_prefix_length(string1: str, string2: str) -> int:
10            """
11            Calculate the length of the common prefix between two strings.
12          
13            Args:
14                string1: First string to compare
15                string2: Second string to compare
16          
17            Returns:
18                Length of the common prefix
19            """
20            prefix_length = 0
21            for char1, char2 in zip(string1, string2):
22                if char1 != char2:
23                    break
24                prefix_length += 1
25            return prefix_length
26      
27        def add_prefix_length(index1: int, index2: int) -> None:
28            """
29            Add the common prefix length between words at given indices to the sorted list.
30          
31            Args:
32                index1: Index of the first word
33                index2: Index of the second word
34            """
35            if 0 <= index1 < num_words and 0 <= index2 < num_words:
36                sorted_prefix_lengths.add(
37                    calculate_common_prefix_length(words[index1], words[index2])
38                )
39      
40        def remove_prefix_length(index1: int, index2: int) -> None:
41            """
42            Remove the common prefix length between words at given indices from the sorted list.
43          
44            Args:
45                index1: Index of the first word
46                index2: Index of the second word
47            """
48            if 0 <= index1 < num_words and 0 <= index2 < num_words:
49                sorted_prefix_lengths.remove(
50                    calculate_common_prefix_length(words[index1], words[index2])
51                )
52      
53        # Initialize variables
54        num_words = len(words)
55      
56        # Build initial sorted list of common prefix lengths between consecutive words
57        sorted_prefix_lengths = SortedList(
58            calculate_common_prefix_length(word1, word2) 
59            for word1, word2 in pairwise(words)
60        )
61      
62        result = []
63      
64        # Process each word removal
65        for current_index in range(num_words):
66            # Remove prefix lengths involving the current word
67            remove_prefix_length(current_index, current_index + 1)  # Current to next
68            remove_prefix_length(current_index - 1, current_index)  # Previous to current
69          
70            # Add new prefix length between previous and next word (skipping current)
71            add_prefix_length(current_index - 1, current_index + 1)
72          
73            # Get the maximum prefix length after removal (or 0 if none exists or all are 0)
74            if sorted_prefix_lengths and sorted_prefix_lengths[-1] > 0:
75                result.append(sorted_prefix_lengths[-1])
76            else:
77                result.append(0)
78          
79            # Restore the original state for next iteration
80            remove_prefix_length(current_index - 1, current_index + 1)
81            add_prefix_length(current_index - 1, current_index)
82            add_prefix_length(current_index, current_index + 1)
83      
84        return result
85
1class Solution {
2    // TreeMap to store frequency of common prefix lengths between adjacent words
3    private final TreeMap<Integer, Integer> prefixLengthFrequency = new TreeMap<>();
4    private String[] words;
5    private int arrayLength;
6
7    public int[] longestCommonPrefix(String[] words) {
8        arrayLength = words.length;
9        this.words = words;
10      
11        // Initialize TreeMap with common prefix lengths of all adjacent pairs
12        for (int i = 0; i + 1 < arrayLength; ++i) {
13            int prefixLength = calculateCommonPrefixLength(words[i], words[i + 1]);
14            prefixLengthFrequency.merge(prefixLength, 1, Integer::sum);
15        }
16      
17        int[] result = new int[arrayLength];
18      
19        // For each word, find the maximum common prefix after removing it
20        for (int i = 0; i < arrayLength; ++i) {
21            // Remove the pairs that include word at index i
22            removePair(i, i + 1);  // Remove pair (i, i+1)
23            removePair(i - 1, i);  // Remove pair (i-1, i)
24          
25            // Add the new pair formed after removing word at index i
26            addPair(i - 1, i + 1);  // Add pair (i-1, i+1)
27          
28            // Get the maximum common prefix length from remaining pairs
29            result[i] = !prefixLengthFrequency.isEmpty() && prefixLengthFrequency.lastKey() > 0 
30                        ? prefixLengthFrequency.lastKey() 
31                        : 0;
32          
33            // Restore the original state for next iteration
34            removePair(i - 1, i + 1);  // Remove the temporarily added pair
35            addPair(i - 1, i);         // Restore pair (i-1, i)
36            addPair(i, i + 1);         // Restore pair (i, i+1)
37        }
38      
39        return result;
40    }
41
42    /**
43     * Adds a pair's common prefix length to the frequency map
44     * @param leftIndex index of the left word in the pair
45     * @param rightIndex index of the right word in the pair
46     */
47    private void addPair(int leftIndex, int rightIndex) {
48        // Check if both indices are valid
49        if (leftIndex >= 0 && leftIndex < arrayLength && 
50            rightIndex >= 0 && rightIndex < arrayLength) {
51            int prefixLength = calculateCommonPrefixLength(words[leftIndex], words[rightIndex]);
52            prefixLengthFrequency.merge(prefixLength, 1, Integer::sum);
53        }
54    }
55
56    /**
57     * Removes a pair's common prefix length from the frequency map
58     * @param leftIndex index of the left word in the pair
59     * @param rightIndex index of the right word in the pair
60     */
61    private void removePair(int leftIndex, int rightIndex) {
62        // Check if both indices are valid
63        if (leftIndex >= 0 && leftIndex < arrayLength && 
64            rightIndex >= 0 && rightIndex < arrayLength) {
65            int prefixLength = calculateCommonPrefixLength(words[leftIndex], words[rightIndex]);
66            // Decrement frequency and remove entry if frequency becomes 0
67            if (prefixLengthFrequency.merge(prefixLength, -1, Integer::sum) == 0) {
68                prefixLengthFrequency.remove(prefixLength);
69            }
70        }
71    }
72
73    /**
74     * Calculates the length of common prefix between two strings
75     * @param firstString first string to compare
76     * @param secondString second string to compare
77     * @return length of the common prefix
78     */
79    private int calculateCommonPrefixLength(String firstString, String secondString) {
80        int minLength = Math.min(firstString.length(), secondString.length());
81      
82        // Compare characters until a mismatch is found
83        for (int charIndex = 0; charIndex < minLength; ++charIndex) {
84            if (firstString.charAt(charIndex) != secondString.charAt(charIndex)) {
85                return charIndex;
86            }
87        }
88      
89        // All characters matched up to the minimum length
90        return minLength;
91    }
92}
93
1class Solution {
2public:
3    vector<int> longestCommonPrefix(vector<string>& words) {
4        // Multiset to maintain all common prefix lengths between adjacent words
5        multiset<int> prefixLengths;
6        int numWords = words.size();
7      
8        // Lambda function to calculate the length of common prefix between two strings
9        auto calculateCommonPrefixLength = [&](const string& str1, const string& str2) {
10            int minLength = min(str1.size(), str2.size());
11            for (int idx = 0; idx < minLength; ++idx) {
12                if (str1[idx] != str2[idx]) {
13                    return idx;  // Return the index where characters differ
14                }
15            }
16            return minLength;  // All characters match up to the shorter string's length
17        };
18      
19        // Initialize multiset with common prefix lengths of all adjacent pairs
20        for (int i = 0; i + 1 < numWords; ++i) {
21            prefixLengths.insert(calculateCommonPrefixLength(words[i], words[i + 1]));
22        }
23      
24        vector<int> result(numWords);
25      
26        // Lambda to add common prefix length between two words at given indices
27        auto addPrefixLength = [&](int index1, int index2) {
28            if (index1 >= 0 && index1 < numWords && index2 >= 0 && index2 < numWords) {
29                prefixLengths.insert(calculateCommonPrefixLength(words[index1], words[index2]));
30            }
31        };
32      
33        // Lambda to remove common prefix length between two words at given indices
34        auto removePrefixLength = [&](int index1, int index2) {
35            if (index1 >= 0 && index1 < numWords && index2 >= 0 && index2 < numWords) {
36                int prefixLen = calculateCommonPrefixLength(words[index1], words[index2]);
37                auto iterator = prefixLengths.find(prefixLen);
38                if (iterator != prefixLengths.end()) {
39                    prefixLengths.erase(iterator);
40                }
41            }
42        };
43      
44        // Process each word removal and find the maximum common prefix length
45        for (int currentIndex = 0; currentIndex < numWords; ++currentIndex) {
46            // Remove the common prefix lengths involving the current word
47            removePrefixLength(currentIndex, currentIndex + 1);      // Remove prefix with next word
48            removePrefixLength(currentIndex - 1, currentIndex);      // Remove prefix with previous word
49          
50            // Add the new adjacent pair formed after removing current word
51            addPrefixLength(currentIndex - 1, currentIndex + 1);
52          
53            // Get the maximum prefix length (last element in multiset)
54            result[currentIndex] = prefixLengths.empty() ? 0 : *prefixLengths.rbegin();
55          
56            // Restore the original state for next iteration
57            removePrefixLength(currentIndex - 1, currentIndex + 1);  // Remove the temporarily added pair
58            addPrefixLength(currentIndex - 1, currentIndex);         // Restore prefix with previous word
59            addPrefixLength(currentIndex, currentIndex + 1);         // Restore prefix with next word
60        }
61      
62        return result;
63    }
64};
65
1function longestCommonPrefix(words: string[]): number[] {
2    // Multiset to maintain all common prefix lengths between adjacent words
3    // Using Map to simulate multiset (value counts occurrences)
4    const prefixLengths = new Map<number, number>();
5    const numWords = words.length;
6  
7    // Calculate the length of common prefix between two strings
8    const calculateCommonPrefixLength = (str1: string, str2: string): number => {
9        const minLength = Math.min(str1.length, str2.length);
10        for (let idx = 0; idx < minLength; idx++) {
11            if (str1[idx] !== str2[idx]) {
12                return idx; // Return the index where characters differ
13            }
14        }
15        return minLength; // All characters match up to the shorter string's length
16    };
17  
18    // Add a value to the multiset
19    const addToMultiset = (value: number): void => {
20        prefixLengths.set(value, (prefixLengths.get(value) || 0) + 1);
21    };
22  
23    // Remove a value from the multiset
24    const removeFromMultiset = (value: number): void => {
25        const count = prefixLengths.get(value);
26        if (count !== undefined) {
27            if (count > 1) {
28                prefixLengths.set(value, count - 1);
29            } else {
30                prefixLengths.delete(value);
31            }
32        }
33    };
34  
35    // Get maximum value from the multiset
36    const getMaxFromMultiset = (): number => {
37        if (prefixLengths.size === 0) return 0;
38        return Math.max(...prefixLengths.keys());
39    };
40  
41    // Initialize multiset with common prefix lengths of all adjacent pairs
42    for (let i = 0; i + 1 < numWords; i++) {
43        addToMultiset(calculateCommonPrefixLength(words[i], words[i + 1]));
44    }
45  
46    const result: number[] = new Array(numWords);
47  
48    // Add common prefix length between two words at given indices
49    const addPrefixLength = (index1: number, index2: number): void => {
50        if (index1 >= 0 && index1 < numWords && index2 >= 0 && index2 < numWords) {
51            addToMultiset(calculateCommonPrefixLength(words[index1], words[index2]));
52        }
53    };
54  
55    // Remove common prefix length between two words at given indices
56    const removePrefixLength = (index1: number, index2: number): void => {
57        if (index1 >= 0 && index1 < numWords && index2 >= 0 && index2 < numWords) {
58            const prefixLen = calculateCommonPrefixLength(words[index1], words[index2]);
59            removeFromMultiset(prefixLen);
60        }
61    };
62  
63    // Process each word removal and find the maximum common prefix length
64    for (let currentIndex = 0; currentIndex < numWords; currentIndex++) {
65        // Remove the common prefix lengths involving the current word
66        removePrefixLength(currentIndex, currentIndex + 1);      // Remove prefix with next word
67        removePrefixLength(currentIndex - 1, currentIndex);      // Remove prefix with previous word
68      
69        // Add the new adjacent pair formed after removing current word
70        addPrefixLength(currentIndex - 1, currentIndex + 1);
71      
72        // Get the maximum prefix length from the multiset
73        result[currentIndex] = getMaxFromMultiset();
74      
75        // Restore the original state for next iteration
76        removePrefixLength(currentIndex - 1, currentIndex + 1);  // Remove the temporarily added pair
77        addPrefixLength(currentIndex - 1, currentIndex);         // Restore prefix with previous word
78        addPrefixLength(currentIndex, currentIndex + 1);         // Restore prefix with next word
79    }
80  
81    return result;
82}
83

Time and Space Complexity

Time Complexity: O(L + n × log n)

The time complexity breaks down as follows:

  • The calc function computes the longest common prefix between two strings. With memoization (@cache), each unique pair of strings is computed only once. In the worst case, we compute O(n²) pairs, but due to the algorithm's structure, we only compute O(n) unique pairs (adjacent pairs and their variations when elements are temporarily removed).
  • Each calc call takes O(min(len(s), len(t))) time. Summing over all pairs gives us O(L) where L is the total length of all strings.
  • The initial SortedList construction involves n-1 insertions, each taking O(log n) time, resulting in O(n log n).
  • The main loop runs n times. In each iteration:
    • We perform at most 3 removals and 3 additions to the SortedList
    • Each add/remove operation on SortedList takes O(log n) time
    • Accessing sl[-1] takes O(1) time
    • Total per iteration: O(6 × log n) = O(log n)
    • Total for all iterations: O(n × log n)
  • Overall time complexity: O(L) for computing prefixes + O(n log n) for SortedList operations = O(L + n × log n)

Space Complexity: O(n)

The space complexity consists of:

  • The SortedList stores at most n-1 elements at any time: O(n)
  • The @cache decorator stores memoized results for calc. In the worst case, we compute O(n) unique pairs of adjacent strings (and their variations), each result being an integer: O(n)
  • The ans list stores n integers: O(n)
  • Other variables use constant space: O(1)
  • Overall space complexity: O(n)

Learn more about how to find time and space complexity quickly.

Common Pitfalls

1. Incorrect Handling of Boundary Elements

When removing elements at index 0 or index n-1, there's only one adjacent pair to consider, not two. The code attempts to handle non-existent pairs which could lead to incorrect results.

Problem Example:

  • When removing index 0, there's no "previous to current" pair to remove
  • When removing the last index, there's no "current to next" pair to remove

Solution: The current implementation already handles this correctly through bounds checking in the add_prefix_length and remove_prefix_length functions. The condition if 0 <= index1 < num_words and 0 <= index2 < num_words: ensures that operations on non-existent pairs are safely ignored.

2. Forgetting to Restore State After Each Removal

A critical pitfall is modifying the sorted list for one removal but forgetting to restore it before processing the next removal. This would cause cumulative errors where each subsequent calculation works with an increasingly incorrect state.

Problem Example: If you don't restore the pairs after processing index i, when you process index i+1, the sorted list will still be missing the pairs from the previous removal.

Solution: Always restore the state after recording the result:

# After getting the result
result.append(sorted_prefix_lengths[-1] if sorted_prefix_lengths and sorted_prefix_lengths[-1] > 0 else 0)

# Must restore the state immediately
remove_prefix_length(current_index - 1, current_index + 1)  # Remove temporary pair
add_prefix_length(current_index - 1, current_index)         # Restore left pair
add_prefix_length(current_index, current_index + 1)         # Restore right pair

3. Cache Invalidation with Mutable Input

If the input words array were to be modified during execution (though it shouldn't be in this problem), the @cache decorator would return stale results since it caches based on string values.

Problem Example: If someone modifies words[i] after some calculations, the cached results for any previous calculations involving words[i] would be incorrect.

Solution: Either ensure the input is immutable or clear the cache when needed:

# If needed to clear cache (not required for this problem)
calculate_common_prefix_length.cache_clear()

4. Empty or Single-Element Arrays

The code could fail if the input array has fewer than 2 elements, as there would be no adjacent pairs to process initially.

Problem Example:

  • words = [] would cause pairwise(words) to return an empty iterator
  • words = ["single"] would also have no pairs

Solution: Add an early return for edge cases:

def longestCommonPrefix(self, words: List[str]) -> List[int]:
    if len(words) <= 1:
        return [0] * len(words)
    # ... rest of the code

5. Inefficient String Comparison Without Early Termination

While the current implementation correctly uses zip which stops at the shorter string's length, a common mistake is manually iterating with indices without checking string lengths first.

Incorrect approach:

# This would cause IndexError
for i in range(max(len(string1), len(string2))):
    if string1[i] != string2[i]:
        break

Solution (already implemented correctly):

for char1, char2 in zip(string1, string2):
    if char1 != char2:
        break
    prefix_length += 1

The zip function automatically handles strings of different lengths by stopping at the shorter one.

Discover Your Strengths and Weaknesses: Take Our 5-Minute Quiz to Tailor Your Study Plan:

Which data structure is used in a depth first search?


Recommended Readings

Want a Structured Path to Master System Design Too? Don’t Miss This!

Load More