Pular para o conteúdo
Início » Série Preparação para Entrevistas: #10 – Permutações de Strings

Série Preparação para Entrevistas: #10 – Permutações de Strings

Introdução

Ao se preparar para entrevistas técnicas, um problema que frequentemente surge é o da permutação de strings. Durante minha própria trajetória e nas experiências compartilhadas por amigos da área de TI, nos deparamos diversas vezes com esse desafio em entrevistas.

Neste artigo, iremos explorar o problema de permutação de strings em profundidade. Explicaremos o conceito de permutações, como abordá-las de maneira eficiente e apresentaremos soluções em duas linguagens de programação populares: Java e Python. Além disso, faremos uma análise detalhada da complexidade da solução, equipando você com o conhecimento necessário para enfrentar este desafio em suas próximas entrevistas técnicas.

Se você está buscando aprimorar suas habilidades em algoritmos este artigo é essencial para você. Vamos começar!

Descrição do Problema

Uma permutação é uma reorganização de elementos em uma ordem diferente. No contexto de strings, uma permutação é qualquer reordenação dos caracteres que compõem a string. Por exemplo, as permutações da string “ABC” incluem “BCA”, “CAB”, “BAC”, “ACB” e “CBA”, além da própria “ABC”.

Permutação de Strings

Aqui está a implementação do método que recebe uma string como parâmetro. A função recursiva permuta as partes do sufixo da string, enquanto acumula caracteres no prefixo. 

Implementação em Java

public class PermutacaoStrings {

    public static void permutacao(String str) {
        permutacao("", str);
    }

    private static void permutacao(String prefixo, String sufixo) {
        if (sufixo.isEmpty()) {
            System.out.println(prefixo);
            return;
        }

        for (int i = 0; i < sufixo.length(); i++) {
            String newPrefixo = prefixo + sufixo.charAt(i);
            String newSufixo = sufixo.substring(0, i) + sufixo.substring(i + 1);
            permutacao(newPrefixo, newSufixo);
        }
    }

    public static void main(String[] args) {
        permutacao("ABC");
    }
}

Neste código, a função permutacao é inicialmente chamada com um prefixo vazio e o sufixo sendo a string completa. Conforme a função se chama recursivamente, o prefixo acumula caracteres da string original, e o sufixo é reduzido, removendo um caractere de cada vez. Quando o sufixo está vazio, uma permutação completa foi formada e é impressa.

Implementação em Python

def gerar_permutacao(str):
    permutacao("", str)

def permutacao(prefixo, sufixo):
    if not sufixo:
        print(prefixo)
    else:
        for i in range(len(sufixo)):
            new_prefixo = prefixo + sufixo[i]
            new_sufixo = sufixo[:i] + sufixo[i+1:]
            permutacao(new_prefixo, new_sufixo)

# Exemplo de uso
gerar_permutacao("ABC")

Análise de Complexidade

Complexidade de Tempo

Complexidade de Tempo da Recursão: A função de permutação é recursiva. Para uma string de comprimento N a primeira chamada da função gera N chamadas recursivas, cada uma para uma string de comprimento N−1. Isso continua até chegarmos a uma string de comprimento 1, onde apenas uma chamada recursiva é feita. Portanto, o número total de chamadas recursivas é N!, que é a quantidade de permutações possíveis para uma string de comprimento n.

Custo de Cada Chamada Recursiva: Dentro de cada chamada recursiva, a operação de maior custo é a criação das novas strings newPrefixo e newSufixo. Estas operações são porque, na pior das hipóteses, envolvem copiar quase toda a string original. Portanto, o custo de cada chamada recursiva é proporcional ao comprimento da string, .

Combinando estes fatores, a complexidade de tempo total do algoritmo é .

Complexidade de Espaço

Chamadas Recursivas: Cada chamada recursiva adiciona um novo frame na pilha de chamadas. Como a profundidade máxima de recursão é igual ao comprimento da string N, a complexidade de espaço devido à pilha de chamadas é .

Uso de Memória Adicional: Em cada chamada recursiva, novas strings são criadas para newPrefixo e newSufixo. Essas strings têm um comprimento total que varia de 0 a , e são criadas vezes ao longo de todas as chamadas recursivas. No entanto, essas strings não existem simultaneamente; elas são criadas e descartadas ao longo das chamadas. Assim, o uso de memória adicional em um dado momento é proporcional ao comprimento da string, .

Conclusão da Análise

O algoritmo tem uma complexidade de tempo de , tornando-o intensivo em termos de processamento para strings maiores. A complexidade de espaço é , principalmente devido à pilha de chamadas recursivas e ao armazenamento temporário de strings durante a execução.

Permutação de um Lista de Inteiros

Neste segundo exemplo, adaptamos o conceito de permutação para trabalhar com uma lista de inteiros. Esta abordagem é útil para entender como generalizar o conceito de permutação para além de strings. A implementação se baseia na mesma ideia de permutação recursiva, mas com algumas adaptações para lidar com listas. Aqui está a implementação do método que recebe uma lista de inteiros como parâmetro.

Implementação em Java

import java.util.Arrays;
import java.util.List;

public class PermutacaoStrings {

    public static void main(String[] args) {
        List<Integer> numeros = Arrays.asList(1, 2, 3);
        permutacao(numeros);
    }

    private static void permutacao(List<Integer> numeros) {
        permutacao(numeros, 0);
    }

    private static void permutacao(List<Integer> numeros, int prefixo) {
        if (prefixo >= numeros.size()) {
            System.out.println(numeros);
            return;
        }

        for (int i = prefixo; i < numeros.size(); i++) {
            swap(numeros, prefixo, i);
            permutacao(numeros, prefixo + 1);
            swap(numeros, prefixo, i);
        }
    }

    private static void swap(List<Integer> numeros, int prefixo, int i) {
        int temp = numeros.get(prefixo);
        numeros.set(prefixo, numeros.get(i));
        numeros.set(i, temp);
    }
}

Detalhes da Implementação em Java

O método sobrecarregado permutacao, recebe dois parâmetros: a lista de números e a variável prefixo. Inicialmente, prefixo é definido como 0, o que corresponde ao primeiro índice da lista, onde se encontra o número 1. O loop for for é utilizado para gerar permutações, trocando o elemento no índice prefixo com cada elemento nos índices subsequentes. Dentro do loop existe uma chamada recursiva aumentando o prefixo a cada passo. Após a chamada recursiva, realiza uma operação de swap novamente para restaurar a lista à sua forma original antes da próxima iteração.

Implementação em Python

def gerar_permutacao(lista):
    permutacao(lista, 0)

def permutacao(lista, index):
    if index >= len(lista):
        print(lista)
    else:
        for i in range(index, len(lista)):
            lista[index], lista[i] = lista[i], lista[index]  # Swap
            permutacao(lista, index + 1)
            lista[index], lista[i] = lista[i], lista[index]  # Backtrack

# Exemplo de uso
gerar_permutacao([1, 2, 3])

Análise de Complexidade

Complexidade de Tempo

Número de Permutações: Similar ao primeiro algoritmo, o número total de permutações possíveis para uma lista de elementos é N. Isso ocorre porque para cada posição na lista, temos escolhas para o primeiro elemento, N para o segundo, e assim por diante.

Custo de Cada Permutação: Em cada chamada recursiva do algoritmo, a operação principal é a troca de elementos na lista, que é uma operação , ou seja, de tempo constante. No entanto, o número de trocas necessárias para gerar todas as permutações é proporcional ao número de permutações, que é N.

Assim, combinando esses fatores, a complexidade de tempo total do algoritmo é , onde o termo adicional N advém das chamadas recursivas e operações de troca em cada nível de recursão.

Complexidade de Espaço

Chamadas Recursivas: Como no primeiro algoritmo, a profundidade máxima de recursão é , pois a função permutacao é chamada recursivamente até que prefixo seja igual ao tamanho da lista. Portanto, a complexidade de espaço devido à pilha de chamadas é .

Uso de Memória Adicional: Uma das principais vantagens deste algoritmo é seu uso eficiente de memória. Ao contrário do primeiro algoritmo, que criava novas strings a cada chamada recursiva, este algoritmo opera na mesma lista, realizando apenas trocas de elementos. Isso significa que não há custo adicional significativo de memória além da lista original e das variáveis temporárias usadas nas trocas, mantendo a complexidade de espaço adicional como .

Conclusão da Análise

O segundo algoritmo mantém a mesma complexidade de tempo do primeiro, , devido à natureza fatorial das permutações. No entanto, ele é mais eficiente em termos de uso de espaço, com uma complexidade de espaço total de , considerando principalmente a pilha de chamadas recursivas. O uso eficiente da mesma lista para todas as permutações evita a criação de múltiplos objetos temporários.

Conclusão

Neste artigo da nossa série “Preparação para Entrevistas“, discutimos o problema de permutação de strings. Apresentamos soluções em Java e Python, utilizando métodos recursivos. Através da análise de complexidade, destacamos as limitações desses algoritmos em termos de eficiência, particularmente para strings mais longas devido ao número fatorial de permutações.

Uma sugestão para prática adicional é modificar o primeiro algoritmo. Ao invés de gerar novas substrings em cada passo recursivo, você pode experimentar trocar diretamente a posição dos caracteres na string original.

A habilidade de adaptar e otimizar algoritmos é crucial em ciência da computação e extremamente valiosa em entrevistas técnicas. Este tipo de exercício não só reforça seu entendimento do problema, mas também aprimora suas habilidades de programação e pensamento crítico.

Se este artigo foi útil para você, considere compartilhá-lo com seus colegas ou em suas redes sociais. Mantenha-se atento para o próximo artigo desta série, onde abordaremos outro conceito fundamental para sua preparação em entrevistas técnicas.

Referências

Deixe um comentário