Facebook Pixel

2227. Encrypt and Decrypt Strings

HardDesignTrieArrayHash TableString
Leetcode Link

Problem Description

You need to implement a data structure that can encrypt and decrypt strings based on a given cipher system.

You're given three inputs:

  • keys: An array of unique characters
  • values: An array of strings (each of length 2) that correspond to the encrypted form of each character in keys
  • dictionary: An array of valid original strings that can appear after decryption

Encryption Process: To encrypt a string, you replace each character with its corresponding encrypted value:

  1. For each character c in the input string, find its index i where keys[i] == c
  2. Replace c with values[i] (which is a 2-character string)
  3. If any character in the input string doesn't exist in keys, the encryption fails and returns an empty string ""

For example, if keys = ['a', 'b'] and values = ["aa", "bb"], then encrypting "ab" would give "aabb".

Decryption Process: To decrypt a string, you reverse the process by replacing every 2-character substring (at even indices) with its original character:

  1. Take each substring of length 2 at even positions (0, 2, 4, ...)
  2. Find the index i where values[i] equals this substring
  3. Replace the substring with keys[i]
  4. Note: Multiple characters might encrypt to the same value, so one encrypted string could decrypt to multiple possible original strings

The key challenge is that decryption isn't unique - multiple original strings might encrypt to the same result.

Required Methods:

  1. Encrypter(keys, values, dictionary): Initialize the encrypter with the given parameters
  2. encrypt(word1): Encrypt word1 and return the encrypted string
  3. decrypt(word2): Return the count of how many strings in dictionary would encrypt to word2

The clever part of the solution is pre-computing all encrypted forms of dictionary words during initialization, then simply looking up the count when decrypt() is called, rather than trying to decrypt and check each possibility.

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

Intuition

The key insight comes from understanding the asymmetry between encryption and decryption. While encryption is straightforward (each character maps to exactly one encrypted value), decryption is complex because multiple characters might encrypt to the same value, creating ambiguity.

Initially, you might think to implement decrypt() by actually decrypting the string and checking all possible original strings against the dictionary. However, this becomes complicated because:

  1. We'd need to handle multiple possible decryptions due to non-unique mappings
  2. We'd have to generate all possible combinations when multiple characters map to the same encrypted value
  3. Each decrypt call would require significant computation

The breakthrough comes from reversing our perspective: instead of decrypting word2 to find matches in the dictionary, why not encrypt everything in the dictionary first and count how many encrypt to word2?

This approach is brilliant because:

  • Encryption is deterministic - each dictionary word has exactly one encrypted form
  • We can pre-compute all encrypted dictionary words once during initialization
  • The decrypt() function becomes a simple lookup operation

Think of it like this: if you want to know how many English words become "xyz" when encoded, instead of trying to decode "xyz" in all possible ways and checking which are valid English words, you encode all English words first and count how many become "xyz".

By using a Counter to store the frequency of each encrypted dictionary word, we transform a complex decryption problem into a simple counting problem. The decrypt() function just returns cnt[word2] - the pre-computed count of how many dictionary words encrypt to word2.

This pre-computation strategy trades initialization time and space for extremely fast decrypt operations, which is often the right tradeoff when decrypt might be called many times.

Learn more about Trie patterns.

Solution Approach

The solution uses two hash tables to efficiently handle encryption and decryption operations:

  1. Hash Table mp: Maps each character to its encrypted value
  2. Hash Table cnt: Stores the frequency of encrypted forms of all dictionary words

Constructor Implementation:

def __init__(self, keys: List[str], values: List[str], dictionary: List[str]):
    self.mp = dict(zip(keys, values))
    self.cnt = Counter(self.encrypt(v) for v in dictionary)
  • dict(zip(keys, values)) creates a mapping where mp[keys[i]] = values[i]. This gives us O(1) lookup for encryption.
  • We iterate through each word v in the dictionary, encrypt it using our encrypt method, and count the frequency of each encrypted result using Python's Counter.
  • Time complexity: O(n + m Γ— l) where n is the length of keys, m is the dictionary size, and l is the average word length.

Encryption Implementation:

def encrypt(self, word1: str) -> str:
    res = []
    for c in word1:
        if c not in self.mp:
            return ''
        res.append(self.mp[c])
    return ''.join(res)
  • For each character c in word1, we look up its encrypted value in mp.
  • If any character doesn't exist in mp, encryption is impossible, so we return an empty string.
  • Otherwise, we append the 2-character encrypted value to our result list.
  • Finally, we join all encrypted values to form the complete encrypted string.
  • Time complexity: O(k) where k is the length of word1.

Decryption Implementation:

def decrypt(self, word2: str) -> int:
    return self.cnt[word2]
  • Since we pre-computed how many dictionary words encrypt to each possible string, decryption is just a lookup.
  • We return self.cnt[word2], which gives the count of dictionary words that encrypt to word2.
  • If word2 isn't in cnt, Python's Counter returns 0 by default.
  • Time complexity: O(1) for the lookup operation.

Why This Approach Works:

The clever trick is recognizing that we don't need to actually decrypt strings. Instead, we pre-encrypt all valid dictionary words and count their encrypted forms. This transforms the complex problem of "which dictionary words could this decrypt to?" into the simple problem of "how many dictionary words encrypt to this?"

The space complexity is O(n + m Γ— l) for storing the mappings and encrypted dictionary counts, where n is the number of keys, m is the dictionary size, and l is the average length of encrypted words.

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 a concrete example to understand how the solution works.

Given:

  • keys = ['a', 'b', 'c']
  • values = ["ei", "zf", "ei"] (note: 'a' and 'c' both encrypt to "ei")
  • dictionary = ["abcd", "acbd", "adbc", "badc"]

Step 1: Initialization

First, we create the character-to-encryption mapping:

  • mp = {'a': "ei", 'b': "zf", 'c': "ei"}

Next, we encrypt each dictionary word and count frequencies:

  • "abcd" β†’ "ei" + "zf" + "ei" + ? β†’ Can't encrypt 'd' (not in keys) β†’ returns ""
  • "acbd" β†’ "ei" + "ei" + "zf" + ? β†’ Can't encrypt 'd' β†’ returns ""
  • "adbc" β†’ Can't encrypt 'd' β†’ returns ""
  • "badc" β†’ "zf" + "ei" + ? β†’ Can't encrypt 'd' β†’ returns ""

Since 'd' isn't in our keys, none of these words can be encrypted. Let's modify the dictionary to ["abc", "bac", "cab", "cba"]:

  • "abc" β†’ "ei" + "zf" + "ei" = "eizfei"
  • "bac" β†’ "zf" + "ei" + "ei" = "zfeiei"
  • "cab" β†’ "ei" + "ei" + "zf" = "eieizf"
  • "cba" β†’ "ei" + "zf" + "ei" = "eizfei"

Our counter becomes:

  • cnt = {"eizfei": 2, "zfeiei": 1, "eieizf": 1}

Step 2: Encrypt Operation

If we call encrypt("cab"):

  1. 'c' β†’ look up in mp β†’ "ei"
  2. 'a' β†’ look up in mp β†’ "ei"
  3. 'b' β†’ look up in mp β†’ "zf"
  4. Join them: "eieizf"

Step 3: Decrypt Operation

If we call decrypt("eizfei"):

  • Simply look up in cnt["eizfei"] β†’ returns 2

This tells us that 2 dictionary words ("abc" and "cba") encrypt to "eizfei".

Why this is clever: Notice that "eizfei" could theoretically decrypt to multiple strings because both 'a' and 'c' map to "ei". The encrypted string "eizfei" could represent:

  • "abc" (ei=a, zf=b, ei=c)
  • "cba" (ei=c, zf=b, ei=a)
  • "aba" (ei=a, zf=b, ei=a)
  • "cbc" (ei=c, zf=b, ei=c)

But we only care about which ones are in our dictionary! By pre-encrypting the dictionary words, we avoid the complex task of generating all possible decryptions and checking each one. Instead, we just count how many dictionary words produce this encrypted result.

Solution Implementation

1from typing import List
2from collections import Counter
3
4
5class Encrypter:
6    def __init__(self, keys: List[str], values: List[str], dictionary: List[str]):
7        """
8        Initialize the Encrypter with encryption mappings and a dictionary.
9      
10        Args:
11            keys: List of characters to be encrypted
12            values: List of corresponding encrypted strings for each key
13            dictionary: List of valid words to be used for decryption counting
14        """
15        # Create a mapping from each key character to its encrypted value
16        self.encryption_map = dict(zip(keys, values))
17      
18        # Pre-compute encrypted versions of all dictionary words
19        # and count how many dictionary words encrypt to each result
20        self.encrypted_word_count = Counter(
21            self.encrypt(word) for word in dictionary
22        )
23
24    def encrypt(self, word1: str) -> str:
25        """
26        Encrypt a word by replacing each character with its mapped value.
27      
28        Args:
29            word1: The word to encrypt
30          
31        Returns:
32            The encrypted string, or empty string if any character cannot be encrypted
33        """
34        encrypted_parts = []
35      
36        # Process each character in the word
37        for char in word1:
38            # If character has no encryption mapping, return empty string
39            if char not in self.encryption_map:
40                return ''
41            # Add the encrypted value for this character
42            encrypted_parts.append(self.encryption_map[char])
43      
44        # Join all encrypted parts into final encrypted word
45        return ''.join(encrypted_parts)
46
47    def decrypt(self, word2: str) -> int:
48        """
49        Return the count of dictionary words that could encrypt to the given string.
50      
51        Args:
52            word2: The encrypted string to decrypt
53          
54        Returns:
55            Number of possible original words from the dictionary
56        """
57        # Return pre-computed count of dictionary words that encrypt to word2
58        return self.encrypted_word_count[word2]
59
60
61# Your Encrypter object will be instantiated and called as such:
62# obj = Encrypter(keys, values, dictionary)
63# param_1 = obj.encrypt(word1)
64# param_2 = obj.decrypt(word2)
65
1class Encrypter {
2    // Map to store character to string encryption mappings
3    private Map<Character, String> encryptionMap = new HashMap<>();
4    // Map to store encrypted strings and their count from dictionary
5    private Map<String, Integer> encryptedWordCount = new HashMap<>();
6
7    /**
8     * Constructor to initialize the Encrypter with keys, values, and dictionary
9     * @param keys Array of characters to be encrypted
10     * @param values Array of corresponding encrypted strings for each key
11     * @param dictionary Array of valid words to pre-process for decryption
12     */
13    public Encrypter(char[] keys, String[] values, String[] dictionary) {
14        // Build the encryption mapping from keys to values
15        for (int i = 0; i < keys.length; i++) {
16            encryptionMap.put(keys[i], values[i]);
17        }
18      
19        // Pre-compute encrypted versions of all dictionary words
20        // and count how many words encrypt to the same result
21        for (String word : dictionary) {
22            String encryptedWord = encrypt(word);
23            encryptedWordCount.merge(encryptedWord, 1, Integer::sum);
24        }
25    }
26
27    /**
28     * Encrypts the given word using the encryption mapping
29     * @param word1 The word to be encrypted
30     * @return The encrypted string, or empty string if any character cannot be encrypted
31     */
32    public String encrypt(String word1) {
33        StringBuilder encryptedResult = new StringBuilder();
34      
35        // Process each character in the word
36        for (char character : word1.toCharArray()) {
37            // Check if the character can be encrypted
38            if (!encryptionMap.containsKey(character)) {
39                return "";  // Return empty string if character has no mapping
40            }
41            // Append the encrypted value for this character
42            encryptedResult.append(encryptionMap.get(character));
43        }
44      
45        return encryptedResult.toString();
46    }
47
48    /**
49     * Returns the count of possible original words from dictionary that could
50     * have been encrypted to produce the given encrypted string
51     * @param word2 The encrypted string to decrypt
52     * @return The number of possible original words
53     */
54    public int decrypt(String word2) {
55        // Return the pre-computed count, or 0 if not found
56        return encryptedWordCount.getOrDefault(word2, 0);
57    }
58}
59
60/**
61 * Your Encrypter object will be instantiated and called as such:
62 * Encrypter obj = new Encrypter(keys, values, dictionary);
63 * String param_1 = obj.encrypt(word1);
64 * int param_2 = obj.decrypt(word2);
65 */
66
1class Encrypter {
2public:
3    // Map to store count of encrypted strings from dictionary
4    unordered_map<string, int> encryptedCount;
5    // Map to store character to string value mapping for encryption
6    unordered_map<char, string> charToValueMap;
7
8    /**
9     * Constructor: Initializes the encrypter with keys, values, and dictionary
10     * @param keys: List of characters that can be encrypted
11     * @param values: Corresponding encrypted values for each key
12     * @param dictionary: List of valid words to pre-process for decryption
13     */
14    Encrypter(vector<char>& keys, vector<string>& values, vector<string>& dictionary) {
15        // Build the character to value mapping for encryption
16        for (int i = 0; i < keys.size(); ++i) {
17            charToValueMap[keys[i]] = values[i];
18        }
19      
20        // Pre-compute encrypted versions of all dictionary words
21        // and count occurrences of each encrypted string
22        for (const auto& word : dictionary) {
23            string encryptedWord = encrypt(word);
24            if (!encryptedWord.empty()) {
25                encryptedCount[encryptedWord]++;
26            }
27        }
28    }
29
30    /**
31     * Encrypts a given word by replacing each character with its mapped value
32     * @param word1: The word to encrypt
33     * @return: Encrypted string, or empty string if any character cannot be encrypted
34     */
35    string encrypt(string word1) {
36        string encryptedResult = "";
37      
38        // Process each character in the input word
39        for (char currentChar : word1) {
40            // Check if the character exists in our mapping
41            if (charToValueMap.find(currentChar) == charToValueMap.end()) {
42                // Character cannot be encrypted, return empty string
43                return "";
44            }
45            // Append the encrypted value to result
46            encryptedResult += charToValueMap[currentChar];
47        }
48      
49        return encryptedResult;
50    }
51
52    /**
53     * Returns the count of dictionary words that encrypt to the given string
54     * @param word2: The encrypted string to decrypt
55     * @return: Number of possible original words from dictionary
56     */
57    int decrypt(string word2) {
58        // Return the pre-computed count for this encrypted string
59        // Returns 0 if the encrypted string doesn't exist in the map
60        return encryptedCount.count(word2) ? encryptedCount[word2] : 0;
61    }
62};
63
64/**
65 * Your Encrypter object will be instantiated and called as such:
66 * Encrypter* obj = new Encrypter(keys, values, dictionary);
67 * string param_1 = obj->encrypt(word1);
68 * int param_2 = obj->decrypt(word2);
69 */
70
1// Map to store character to encryption value mappings
2const encryptionMap: Map<string, string> = new Map();
3
4// Map to store encrypted word frequency count
5const encryptedWordCount: Map<string, number> = new Map();
6
7/**
8 * Initializes the encrypter with keys, values, and dictionary
9 * @param keys - Array of characters to be encrypted
10 * @param values - Array of corresponding encryption values for each key
11 * @param dictionary - Array of valid words that can be formed after decryption
12 */
13function initializeEncrypter(keys: string[], values: string[], dictionary: string[]): void {
14    // Clear existing maps
15    encryptionMap.clear();
16    encryptedWordCount.clear();
17  
18    // Build the encryption mapping from keys to values
19    for (let i = 0; i < keys.length; i++) {
20        encryptionMap.set(keys[i], values[i]);
21    }
22  
23    // Pre-compute encrypted versions of all dictionary words
24    // and count how many words encrypt to the same result
25    for (const word of dictionary) {
26        const encryptedWord = encrypt(word);
27      
28        // Only count valid encryptions (non-empty results)
29        if (encryptedWord !== '') {
30            const currentCount = encryptedWordCount.get(encryptedWord) || 0;
31            encryptedWordCount.set(encryptedWord, currentCount + 1);
32        }
33    }
34}
35
36/**
37 * Encrypts a word by replacing each character with its mapped value
38 * @param word - The word to encrypt
39 * @returns The encrypted string, or empty string if any character cannot be encrypted
40 */
41function encrypt(word: string): string {
42    let encryptedResult = '';
43  
44    // Process each character in the word
45    for (const character of word) {
46        // Check if the character has an encryption mapping
47        if (!encryptionMap.has(character)) {
48            // Return empty string if any character cannot be encrypted
49            return '';
50        }
51      
52        // Append the encrypted value to the result
53        encryptedResult += encryptionMap.get(character);
54    }
55  
56    return encryptedResult;
57}
58
59/**
60 * Returns the number of possible decryptions for an encrypted word
61 * @param word - The encrypted word to decrypt
62 * @returns The count of valid dictionary words that could produce this encryption
63 */
64function decrypt(word: string): number {
65    // Return the pre-computed count, or 0 if not found
66    return encryptedWordCount.get(word) || 0;
67}
68

Time and Space Complexity

Time Complexity:

  • __init__: O(n * k + m) where n is the length of the dictionary, k is the average length of words in the dictionary, and m is the length of the keys array. Creating the mapping takes O(m) time, and encrypting all dictionary words takes O(n * k) time since each word needs to be processed character by character.

  • encrypt: O(k) where k is the length of the input word. Each character lookup in the mapping is O(1), and we process each character once.

  • decrypt: O(1) since it's just a dictionary lookup in the Counter object.

Space Complexity: O(n * k + m)

  • The mapping self.mp stores m key-value pairs: O(m)
  • The Counter self.cnt stores up to n encrypted strings, where each encrypted string can be up to 2k characters long (since each character maps to a 2-character string based on the problem context): O(n * k)
  • Overall space complexity is O(n * k + m), which can be simplified to O(n + m) when considering that k is typically bounded by a constant in most practical scenarios.

The reference answer O(n + m) assumes that the average word length k is constant or bounded, which is a reasonable assumption for most dictionary problems.

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

Common Pitfalls

1. Attempting to Actually Decrypt the String

The Pitfall: A natural instinct is to implement the decrypt method by actually reversing the encryption process - parsing the encrypted string into 2-character chunks, finding matching values, and building all possible original strings. This approach has several problems:

  • One encrypted string can map to multiple original strings (when different characters encrypt to the same value)
  • The decryption tree can grow exponentially with the string length
  • You'd need to check each decrypted possibility against the dictionary

Why It's Wrong:

# INCORRECT approach - trying to actually decrypt
def decrypt(self, word2: str) -> int:
    # This would require complex backtracking to find all possibilities
    possible_words = []
    # Parse word2 into 2-char chunks and try to reverse map...
    # This becomes exponentially complex!

The Solution: Pre-compute all encrypted forms of dictionary words during initialization, then simply look up the count. This transforms an exponential problem into O(1) lookup.

2. Not Handling Empty Strings in Counter

The Pitfall: When encrypting dictionary words in the constructor, some words might fail to encrypt (return empty string). If you don't handle this, the Counter might include empty strings.

# PROBLEMATIC: Empty strings get counted
self.encrypted_word_count = Counter(
    self.encrypt(word) for word in dictionary
)
# If encrypt returns "", Counter will count empty strings

The Solution: Filter out empty strings before counting:

self.encrypted_word_count = Counter(
    encrypted for word in dictionary 
    if (encrypted := self.encrypt(word))
)

3. Assuming One-to-One Mapping

The Pitfall: Incorrectly assuming that each character maps to a unique encrypted value, or that each encrypted value maps back to a unique character. The problem explicitly allows multiple characters to encrypt to the same value.

Why It Matters: If keys = ['a', 'b'] and values = ['xx', 'xx'], both 'a' and 'b' encrypt to 'xx'. So 'xx' could decrypt to either 'a' or 'b', and 'xxxx' could decrypt to 'aa', 'ab', 'ba', or 'bb'.

4. Incorrect String Parsing in Decryption Logic

The Pitfall: If you were to implement actual decryption, you might incorrectly parse the encrypted string:

# WRONG: Assumes we need to decrypt at odd positions or variable lengths
for i in range(0, len(word2), 2):
    chunk = word2[i:i+2]  # What if len(word2) is odd?

The Solution: The problem guarantees that encrypted strings have even length (each character becomes 2 characters), but our approach avoids this parsing entirely by pre-computing encrypted forms.

5. Not Using Efficient Data Structures

The Pitfall: Using lists and linear search instead of hash tables:

# INEFFICIENT: O(n) lookup for each character
def encrypt(self, word1: str) -> str:
    result = []
    for c in word1:
        for i, key in enumerate(self.keys):  # O(n) search
            if key == c:
                result.append(self.values[i])
                break
        else:
            return ''

The Solution: Use a dictionary for O(1) lookup: self.encryption_map = dict(zip(keys, values))

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

Which algorithm is best for finding the shortest distance between two points in an unweighted graph?


Recommended Readings

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

Load More