Lista de exercícios
Lista prática de revisão para a Prova 1
24 exercícios com soluções para revisar os conteúdos das aulas práticas 03 a 07.
Resolva um exercício por vez e consulte sua solução depois. Resposta esperada é uma referência para comparação e autocorreção. Os exercícios são independentes. Quando houver uma sequência dentro de um exercício, o enunciado informa sua ordem e seu caráter cumulativo.
As estruturas e as funções auxiliares apresentadas nos enunciados estão fornecidas. Nos exercícios de programação, escreva somente a parte solicitada, em Linguagem C.
Exercício 1: um ponteiro nulo devolve memória?
Avalie a proposta de encerramento de um módulo. Dois nós foram criados com malloc, e head é a única referência externa para a cadeia:
head -> A -> B -> NULL
O módulo executa somente:
head = NULL;
Explique se os blocos de A e B foram liberados e se continuam acessíveis pelo programa, considerando que não existe outra referência para eles.
Solução
Explicação
A atribuição altera apenas o valor de head. Ela não executa free sobre nenhum nó. Como a única referência externa foi apagada, o programa também perde o caminho de acesso a A e, por consequência, a B.
Objetivo de aprendizagem: distinguir a mudança do valor de um ponteiro da liberação efetiva de memória.
Resposta esperada
Os dois blocos continuam alocados, mas ficam inacessíveis ao programa. Isso produz um vazamento de memória. Atribuir NULL ao ponteiro não libera os nós.
Exercício 2: modelar uma lista de alunos com listas de notas
Defina somente três structs em linguagem C para uma lista de alunos, cada um com sua própria lista de notas. As listas são simplesmente encadeadas:
Aluno: registro acadêmico (RA) inteiro positivo, nome (vetor de 100 caracteres, incluindo o terminador) e endereço da primeira nota.nodo_principal: umAlunoarmazenado por valor e ligação para o próximo nó de alunos.nodo_nota: nota inteira de 0 a 10 e ligação para o próximo nó de notas.
Use apenas os campos descritos. Não implemente funções.
Solução
Explicação
São dois encadeamentos diferentes. Cada nó principal contém os dados de um aluno por valor e um ponteiro para o próximo nó principal. Dentro de Aluno, um ponteiro dá acesso à lista de notas. Cada nó dessa lista contém uma nota e um ponteiro para o próximo nó de notas.
Objetivo de aprendizagem: modelar uma lista de alunos com listas internas de notas, distinguindo dados armazenados por valor de ligações representadas por ponteiros.
Resposta esperada
typedef struct nodo_nota {
int nota;
struct nodo_nota *proximo;
} nodo_nota;
typedef struct Aluno {
int ra;
char nome[100];
nodo_nota *notas;
} Aluno;
typedef struct nodo_principal {
Aluno aluno;
struct nodo_principal *proximo;
} nodo_principal;
aluno contém os dados do aluno dentro do nó principal. notas aponta para o primeiro nó da lista de notas desse aluno. Cada campo proximo aponta para um nó do mesmo tipo da struct que o contém. O vetor nome possui 100 posições, incluindo o espaço para o terminador '\0'.
Os nomes dos campos e o uso de typedef podem variar. A resposta deve preservar os dados, a inclusão de Aluno por valor e os tipos dos ponteiros de cada lista. As faixas de RA e nota descrevem os dados considerados no exercício e não exigem código de validação.
Exercício 3: reparar uma remoção intermediária
Corrija o trecho que deveria transformar P -> Q -> R -> NULL em P -> R -> NULL. previous aponta para P, previous->next aponta para Q e Q foi alocado com malloc. Cada nó possui o campo next.
node *removed = previous->next;
free(removed);
node *successor = removed->next;
previous->next = successor;
Identifique o acesso inválido e reescreva o trecho, mantendo os outros nós alcançáveis e liberando Q exatamente uma vez.
Solução
Explicação
Depois de free(removed), Q deixa de ser um objeto válido. Ler seu campo next nesse momento produz uso após liberação. O sucessor precisa ser recuperado e a cadeia precisa ser religada enquanto Q ainda existe.
Objetivo de aprendizagem: corrigir a ordem de uma remoção para evitar acesso após liberação.
Resposta esperada
node *removed = previous->next;
node *successor = removed->next;
previous->next = successor;
free(removed);
O acesso inválido era removed->next depois de free(removed). O trecho corrigido preserva P e R e libera somente Q.
Exercício 4: condição de parada em uma lista circular
Implemente deve_parar para indicar se o nó atual (cursor) é o último de uma lista circular. first aponta para o primeiro nó.
typedef struct nodo {
struct nodo *proximo;
} nodo;
int deve_parar(const nodo *first, const nodo *cursor);
Retorne 1 se o nó atual for o último e 0 caso contrário. Os ponteiros são válidos e pertencem à mesma lista circular não vazia. Escreva somente a função.
Solução
Explicação
O último nó aponta para o primeiro. Por isso, a função compara o próximo nó de cursor com first. Em uma lista com um único nó, esse nó aponta para si e também é o último.
Objetivo de aprendizagem: implementar a condição de parada de um percurso circular, identificando o último nó pela ligação com o primeiro.
Resposta esperada
int deve_parar(const nodo *first, const nodo *cursor) {
return cursor->proximo == first;
}
A função retorna 1 no último nó, inclusive na lista com um único nó, e 0 nos demais. A condição deve ser consultada após processar o nó atual e antes de avançar para o próximo.
Exercício 5: escolher uma estrutura para um sistema de pedidos
Um sistema mantém entre 20 e 20.000 pedidos ativos, na ordem de chegada. Ele recebe novos pedidos, cancela pedidos pelo código e percorre todos para gerar relatórios. Memória e tempo de resposta são limitados.
Escolha lista estática (vetor com capacidade fixa) ou lista dinâmica encadeada. Justifique considerando memória e custo das operações, explicando o que você priorizou.
Indique uma desvantagem da sua escolha e uma mudança nos requisitos que faria você preferir a outra estrutura. Não implemente código.
Solução
Explicação
A quantidade variável não determina sozinha a estrutura. Reservar espaço para o volume máximo, guardar ligações adicionais, localizar pedidos e preservar a ordem após cancelamentos produzem custos diferentes. A decisão deve relacionar esses custos à prioridade adotada.
Objetivo de aprendizagem: justificar uma escolha de representação por requisitos de memória e operações, reconhecendo custos e condições que mudariam a decisão.
Resposta esperada
São aceitáveis as duas escolhas, desde que a justificativa seja coerente. Exemplos:
- Lista dinâmica encadeada: prioriza não reservar espaço para 20.000 pedidos quando existem poucos ativos. Após localizar um pedido e seu predecessor, sua remoção preserva a ordem sem deslocar os demais. Desvantagem: cada nó ocupa espaço adicional para a ligação, e localizar um código pode exigir percorrer toda a lista. Mudança: se o volume permanecesse próximo de 20.000, com poucos cancelamentos e muitos relatórios, um vetor poderia ser preferível.
- Lista estática: prioriza percorrer pedidos armazenados em sequência e evitar o espaço adicional de uma ligação por pedido. A capacidade de 20.000 acomoda o máximo informado. Desvantagem: reserva essa capacidade mesmo com poucos pedidos, e uma remoção que compacte o vetor preservando a ordem desloca os elementos seguintes. Mudança: se o volume passasse a ficar próximo de 20 na maior parte do tempo e a memória reservada fosse o principal limite, uma lista encadeada poderia ser preferível.
Sem uma estrutura auxiliar de busca, localizar um pedido pelo código pode exigir examinar todos os pedidos em ambas as representações. Apenas “a quantidade varia” não justifica a escolha. O cancelamento completo na lista encadeada não tem custo constante quando é necessário buscar o pedido.
Exercício 6: fila e pilha no mesmo aplicativo
Explique a diferença conceitual entre fila e pilha a partir de duas funções de um aplicativo:
- Os comandos de impressão devem ser executados na ordem em que chegam.
- O comando “desfazer” deve reverter primeiro a alteração mais recente ainda não desfeita.
Indique a estrutura de cada função, explique sua ordem de saída e apresente um outro uso prático para cada estrutura, com o motivo da escolha.
Solução
Explicação
A diferença central está na ordem de acesso. Na fila, a saída respeita a chegada. Na pilha, a saída começa pela entrada mais recente. O mesmo conjunto de itens pode produzir ordens de remoção diferentes nas duas estruturas.
Objetivo de aprendizagem: explicar as políticas de fila e pilha e relacioná-las a usos práticos.
Resposta esperada
A impressão usa fila, com política FIFO, primeiro a entrar, primeiro a sair. “Desfazer” usa pilha, com política LIFO, último a entrar, primeiro a sair.
Outro uso de fila é atender requisições na ordem de chegada. Outro uso de pilha é conferir delimitadores aninhados, pois a abertura mais recente precisa ser fechada primeiro. Outros exemplos corretos com justificativa equivalente também atendem ao exercício.
Exercício 7: implementar o conferidor de delimitadores
Implemente expressao_balanceada, usando somente as operações fornecidas. A entrada é uma string válida, terminada por \0. Verifique (), [] e {}. Ignore os outros caracteres.
Exemplos, considerando que a alocação de memória tenha sucesso:
| Entrada | Retorno | Motivo |
|---|---|---|
"" (string vazia) | 1 | Não há delimitadores. |
"x+y" | 1 | Os caracteres são ignorados. |
"()" | 1 | O par está completo. |
"()[]{}" | 1 | Os três pares consecutivos estão completos. |
"((()))" | 1 | Os pares aninhados fecham na ordem correta. |
"x+{y*[z-(w)]}+1" | 1 | Os pares fecham corretamente. Os outros caracteres são ignorados. |
"]" | 0 | Há fechamento sem abertura. |
"())" | 0 | Há um ) extra após um par completo. |
"(" | 0 | Falta fechar (. |
"()(" | 0 | O último ( não foi fechado. |
"x+{y*[z-(w)]" | 0 | Falta fechar {. |
"x+{y*(z]}" | 0 | ] não fecha (. |
"([)]" | 0 | Os pares estão cruzados. |
")(" | 0 | O fechamento aparece antes da abertura. |
As funções abaixo já estão implementadas. O tipo pilha_caracteres representa uma pilha de caracteres:
#include <stddef.h>
typedef struct pilha_caracteres pilha_caracteres;
pilha_caracteres *cria_pilha(void);
int empilha(pilha_caracteres *pilha, char valor);
char pega_topo(pilha_caracteres *pilha);
int desempilha(pilha_caracteres *pilha);
int pilha_vazia(pilha_caracteres *pilha);
void destroi_pilha(pilha_caracteres *pilha);
int eh_abertura(char valor);
int eh_fechamento(char valor);
int par_correspondente(char abertura, char fechamento);
int expressao_balanceada(char *expressao);
Contratos das funções fornecidas:
cria_pilha: cria uma pilha vazia ou retornaNULLse a alocação falhar.empilha: insere e retorna 1. Se a alocação falhar, retorna 0 e preserva a pilha.pega_topo: retorna o caractere do topo sem removê-lo. Exige pilha não vazia.desempilha: remove o topo. Retorna 1 se a remoção ocorrer e 0 caso contrário.pilha_vazia: retorna 1 para pilha vazia e 0 caso contrário.destroi_pilha: libera a pilha e seus nós restantes.eh_aberturaeeh_fechamento: retornam 1 se o caractere pertence ao respectivo grupo e 0 caso contrário.par_correspondente: retorna 1 para abertura e fechamento do mesmo tipo e 0 caso contrário.
expressao_balanceada deve retornar 1 para expressão balanceada e 0 para expressão inválida ou falha de alocação. Libere a pilha criada antes de retornar. Escreva somente a função solicitada.
Solução
Explicação
A pilha conserva as aberturas ainda pendentes. Para cada fechamento, verifique se a pilha está vazia, consulte a abertura mais recente com pega_topo e confira o par. Depois, remova a abertura com desempilha. A ausência dessa abertura, um tipo incompatível ou uma abertura restante ao final tornam a expressão inválida. Uma falha de alocação também retorna 0.
Objetivo de aprendizagem: implementar o balanceamento pela interface de uma pilha e de classificadores fornecidos, com liberação da memória.
Resposta esperada
int expressao_balanceada(char *expressao) {
pilha_caracteres *pilha = cria_pilha();
if (pilha == NULL) {
return 0;
}
int resultado = 1;
for (size_t indice = 0; expressao[indice] != '\0'; ++indice) {
char simbolo = expressao[indice];
if (eh_abertura(simbolo)) {
if (!empilha(pilha, simbolo)) {
resultado = 0;
break;
}
continue;
}
if (eh_fechamento(simbolo)) {
if (pilha_vazia(pilha)
|| !par_correspondente(pega_topo(pilha), simbolo)) {
resultado = 0;
break;
}
desempilha(pilha);
}
}
if (resultado == 1 && !pilha_vazia(pilha)) {
resultado = 0;
}
destroi_pilha(pilha);
return resultado;
}
Exercício 8: implementar o processamento Round Robin com uma fila
Implemente processa_round_robin, usando somente as operações fornecidas. Round Robin divide o processamento em turnos. O parâmetro limite_turno_ms informa quantos milissegundos (ms) uma tarefa pode executar antes de dar a vez à próxima.
- Consulte e retire a tarefa do início. Execute por
limite_turno_msou, se faltar menos trabalho, somente pelo tempo restante. - Atualize o relógio e o tempo restante da tarefa. Se ela não terminou, a insira novamente no final da fila com os dados atualizados.
- Se terminou, imprima sua identificação e o instante de conclusão, sem reinseri-la. Continue até a fila ficar vazia.
Todas as tarefas estão disponíveis no instante 0 ms, na ordem inicial da fila. Não chegam novas tarefas. Retirar, reinserir e imprimir não consomem tempo. O relógio avança somente pelo trabalho executado, sem reservar o restante de um turno quando uma tarefa termina antes do limite.
Exemplos: a fila está escrita do início para o final. Em A(5), A ainda precisa de 5 ms. Cada resultado indicado na coluna de saída ocupa uma linha, na ordem apresentada.
| Fila inicial | limite_turno_ms | Saída | Motivo |
|---|---|---|---|
| Vazia | 3 | Nenhuma linha. | Não há tarefas para processar. |
A(2) | 3 | A 2 | A termina antes do limite do turno. |
A(3) | 3 | A 3 | A termina exatamente no limite. |
A(4) | 3 | A 4 | A retorna à fila e termina no segundo turno. |
A(2), B(1) | 3 | A 2, B 3 | As duas tarefas terminam em seus primeiros turnos. |
A(5), B(2), C(4) | 3 | B 5, A 10, C 11 | As tarefas incompletas retornam ao final. |
A(5), B(2), C(4) | 1 | B 5, C 10, A 11 | Outro limite de turno altera os instantes e a ordem de conclusão. |
As funções abaixo já estão implementadas. O tipo fila_tarefas representa uma fila de tarefas. Cada tarefa armazena id_tarefa, uma letra que identifica a tarefa, e tempo_restante_ms, seu trabalho ainda pendente em milissegundos:
typedef struct {
char id_tarefa;
int tempo_restante_ms;
} tarefa;
typedef struct fila_tarefas fila_tarefas;
int fila_esta_vazia(fila_tarefas *fila);
tarefa pega_primeira_tarefa(fila_tarefas *fila);
int desenfila(fila_tarefas *fila);
int enfila(fila_tarefas *fila, tarefa tarefa_recebida);
void imprime_conclusao_tarefa(char id_tarefa, int instante_conclusao_ms);
void processa_round_robin(fila_tarefas *fila, int limite_turno_ms);
Contratos das funções fornecidas:
fila_esta_vazia: retorna 1 para fila vazia e 0 caso contrário.pega_primeira_tarefa: retorna uma cópia da tarefa do início, sem removê-la. Exige fila não vazia.desenfila: remove a tarefa do início. Retorna 1 se a remoção ocorrer e 0 se a fila estiver vazia.enfila: insere uma cópia detarefa_recebidano final e retorna 1. Se a fila estiver cheia, retorna 0 e preserva a fila. Não aloca memória.imprime_conclusao_tarefa: imprime uma linha comid_tarefa, um espaço einstante_conclusao_ms, o tempo decorrido desde o início do processamento até a conclusão dessa tarefa. Por exemplo,B 5indica que B terminou no instante 5 ms. Não modifica a fila.
Considere fila válida, de 0 a 26 tarefas, identificações distintas de A a Z, tempos iniciais de 1 a 1.000 ms e 1 <= limite_turno_ms <= 1000. A fila tem capacidade para todas as tarefas iniciais. Retirar antes de reinserir garante espaço, e o tempo total cabe em int. Não valide essas premissas nem implemente as funções auxiliares.
processa_round_robin deve imprimir somente as conclusões, na ordem em que ocorrerem, e deixar a fila vazia. Não destrua a fila recebida. Não use pilha, ordenação nem acesso à representação interna da fila. Escreva somente a função solicitada.
Solução
Explicação
O laço processa uma tarefa por turno. Consulte a tarefa com pega_primeira_tarefa e a retire com desenfila. O tempo executado é o menor entre o limite do turno e o trabalho restante. Esse valor aumenta o relógio e reduz o trabalho da cópia local. Se a tarefa continuar pendente, enfila recebe essa cópia atualizada.
Uma tarefa concluída gera uma única linha e não retorna à fila. Uma tarefa incompleta entra depois de todas as que já aguardavam, preservando a ordem FIFO das retiradas. Mesmo com uma única tarefa, a mesma regra funciona: ela retorna ao final até concluir. A fila vazia encerra o laço sem imprimir.
Objetivo de aprendizagem: implementar Round Robin pela interface de uma fila, reinserindo tarefas incompletas e registrando seus instantes de conclusão.
Resposta esperada
void processa_round_robin(fila_tarefas *fila, int limite_turno_ms) {
int tempo_decorrido_ms = 0;
// Atenda uma tarefa por turno enquanto houver trabalho pendente.
while (!fila_esta_vazia(fila)) {
tarefa tarefa_atual = pega_primeira_tarefa(fila);
desenfila(fila);
int tempo_executado_ms = limite_turno_ms;
// Evite consumir o turno inteiro quando a tarefa termina antes.
if (tarefa_atual.tempo_restante_ms < tempo_executado_ms) {
tempo_executado_ms = tarefa_atual.tempo_restante_ms;
}
tempo_decorrido_ms += tempo_executado_ms;
tarefa_atual.tempo_restante_ms -= tempo_executado_ms;
// Registre a conclusão uma única vez, sem reinserir a tarefa.
if (tarefa_atual.tempo_restante_ms == 0) {
imprime_conclusao_tarefa(tarefa_atual.id_tarefa,
tempo_decorrido_ms);
}
// Coloque o trabalho restante depois das tarefas que aguardam.
else {
enfila(fila, tarefa_atual);
}
}
}
Para A(5), B(2), C(4) e limite_turno_ms = 3, a saída é B 5, A 10 e C 11, uma linha por tarefa concluída. O último instante é 5 + 2 + 4 = 11 ms, igual à soma do trabalho inicial. A função termina com a fila vazia e mantém seu descritor válido.
Exercício 9: princípio e garantia dos algoritmos gulosos
Qual é o princípio de um algoritmo guloso e o que precisa ser demonstrado para que as decisões tomadas a cada passo garantam a melhor solução final?
Solução
Explicação
A regra de escolha define o que significa “melhor agora”. Manter essa escolha pode impedir um resultado final melhor, então a garantia depende de uma prova de que a decisão não impede obter uma solução ótima. Um contraexemplo refuta a garantia universal, mas não encontrar contraexemplos nos casos testados não a comprova.
Um argumento de troca é uma forma de prova: mostrar que uma solução ótima pode aceitar a escolha gulosa sem perder viabilidade nem piorar seu valor. Também é necessário justificar que resolver otimamente o restante completa uma solução ótima e que esse raciocínio vale para todas as decisões seguintes.
Objetivo de aprendizagem: distinguir evidência de testes de prova de otimalidade e explicar como uma troca segura, combinada à solução ótima do restante, justifica escolhas gulosas.
Resposta esperada
Um algoritmo guloso escolhe, a cada passo, a melhor opção viável segundo uma regra definida e mantém essa decisão, sem voltar atrás para testar outras escolhas. Isso, por si só, não garante a melhor solução final.
É necessário demonstrar que cada escolha preserva pelo menos uma solução ótima e que uma solução ótima do problema restante, combinada com essa escolha, produz um ótimo do problema original. Um argumento de troca pode justificar a primeira propriedade: substituir uma escolha de uma solução ótima pela escolha gulosa sem violar restrições nem piorar o objetivo. A prova precisa valer para qualquer entrada permitida e sustentar todas as decisões até o término. Acertar os exemplos testados não substitui essa demonstração.
Exercício 10: preencher uma mochila fracionária
Proponha a solução gulosa ótima para uma mochila de capacidade 17 unidades de peso. Cada material pode ser usado parcialmente, e seu benefício é proporcional à quantidade usada. Os sete materiais estão disponíveis nas quantidades indicadas.
A tabela informa o peso total disponível, seu benefício total e a razão benefício/peso já calculada:
| Material | Peso disponível | Benefício total | Benefício por unidade de peso |
|---|---|---|---|
| A | 7 | 28 | 4 |
| B | 2 | 20 | 10 |
| C | 6 | 42 | 7 |
| D | 8 | 40 | 5 |
| E | 5 | 45 | 9 |
| F | 4 | 24 | 6 |
| G | 3 | 24 | 8 |
Descreva a regra de escolha, indique a fração usada de cada material e calcule o benefício final. Justifique por que a regra garante ótimo neste problema fracionário.
Solução
Explicação
Cada unidade de peso deve receber o maior benefício ainda disponível. Se uma solução usa material de razão menor enquanto sobra material de razão maior, substituir a mesma quantidade de peso aumenta o benefício. A possibilidade de usar frações permite realizar essa troca até a capacidade ou a disponibilidade se esgotar.
B, E, G e C somam 16 unidades de peso. A unidade restante recebe F. As razões são todas diferentes, então não existe empate que permita outra distribuição ótima de quantidades.
Objetivo de aprendizagem: aplicar e justificar a escolha por benefício/peso na mochila fracionária, obtendo a distribuição ótima.
Resposta esperada
Ordene por razão decrescente: B, E, G, C, F, D, A. Use cada material até esgotar sua disponibilidade ou a capacidade restante.
- B, E, G e C: 100% de cada um.
- F: 25%, equivalente a uma unidade de peso.
- D e A: 0%.
Peso: 2 + 5 + 3 + 6 + 1 = 17. Benefício: 20 + 45 + 24 + 42 + 6 = 137.
A distribuição é ótima pelo argumento de troca entre unidades de peso. As razões estritamente diferentes tornam única a distribuição ótima das quantidades, embora a ordem de apresentar os materiais possa variar.
Exercício 11: o menor término maximiza o tempo ocupado?
Precisamos maximizar o tempo total ocupado de um auditório. Cada atividade possui um horário de início e de término. O auditório recebe somente uma atividade por vez, mas uma atividade pode começar exatamente quando outra termina. O tempo ocupado é a soma das durações das atividades agendadas.
Uma proposta de agendamento é:
“Ordenar as atividades por término crescente e aceitar cada uma que não conflita com a última escolhida sempre maximiza o tempo total ocupado.”
Construa um contraexemplo para refutar essa proposta.
Solução
Explicação
Contar atividades e somar suas durações são objetivos diferentes. Um término cedo pode favorecer a quantidade de atividades, mas também pode bloquear uma atividade longa que ocuparia mais tempo.
Objetivo de aprendizagem: refutar uma regra gulosa quando o objetivo muda de quantidade de atividades para tempo total ocupado.
Resposta esperada
Um contraexemplo possível é:
A = [0, 2)
B = [2, 4)
C = [0, 9)
A regra escolhe A e B e rejeita C. Seu tempo ocupado é 4. A seleção {C} é viável e ocupa 9. Como 9 > 4, a regra não garante o objetivo proposto. Outros contraexemplos que respeitem as condições também são válidos.
Exercício 12: guardar resultados sempre torna um algoritmo eficiente?
Guardar os resultados de uma função recursiva é suficiente para encontrar a melhor solução de qualquer problema de forma eficiente? Justifique.
Solução
Explicação
A programação dinâmica usa respostas de problemas menores para resolver o problema completo. Para encontrar a melhor solução, é preciso que as melhores respostas desses problemas menores possam ser combinadas para chegar à melhor resposta do problema completo. Essa propriedade é chamada de subestrutura ótima.
Guardar uma resposta ajuda quando o mesmo cálculo aparece novamente: basta consultar o resultado guardado. Porém, isso não diminui a quantidade de problemas menores diferentes que precisam ser resolvidos nem o trabalho necessário para resolver cada um.
No problema do troco, por exemplo, uma tabela pode ter uma posição para cada valor até o valor pedido. Um valor escrito com poucos dígitos pode exigir uma tabela com milhões de posições. Por isso, guardar resultados não garante, por si só, tempo polinomial em relação ao tamanho da entrada.
Objetivo de aprendizagem: reconhecer quando a programação dinâmica pode encontrar a melhor solução e por que evitar cálculos repetidos não garante um algoritmo eficiente.
Resposta esperada
Não. Guardar resultados evita refazer cálculos, mas não corrige uma regra que encontra a solução errada. É preciso que as respostas dos problemas menores permitam construir a melhor solução do problema completo e que o algoritmo faça isso corretamente.
Mesmo sem repetir cálculos, o algoritmo pode demorar muito se houver muitos problemas menores diferentes ou se cada um exigir muito trabalho. Portanto, guardar resultados não garante que qualquer problema seja resolvido de forma eficiente.
Exercício 13: calcular todas as respostas ou só as necessárias?
Dois serviços precisam encontrar a menor quantidade de moedas para formar um troco exato. Ambos usam moedas de 12, 18 e 30, em quantidade ilimitada.
- Serviço A: precisa deixar prontas as respostas de todos os valores de 0 a 240 antes de começar o atendimento. Essas respostas serão consultadas várias vezes.
- Serviço B: precisa responder apenas para o valor 120. Não tem respostas guardadas e encerra o programa após essa consulta.
Explique como a tabulação ajuda cada serviço e quando seria interessante usar memoização no serviço B.
Solução
Explicação
No serviço A, todas as respostas precisam estar prontas, e a tabela pode ser consultada em cada atendimento. No serviço B, mesmo uma única consulta pode repetir cálculos: combinações diferentes de moedas podem deixar o mesmo valor restante.
A memoização começa pelo valor pedido e calcula as respostas conforme precisa delas. Partindo de 120 e retirando essas moedas, os valores restantes são sempre múltiplos de 6. Uma tabulação de 0 a 120 também preencheria os outros valores. Se a memoização usar um vetor com uma posição para cada valor, ela ainda reserva esse espaço, mesmo sem calcular todas as posições.
Objetivo de aprendizagem: comparar tabulação e memoização considerando quais respostas precisam ser calculadas e quando serão reutilizadas.
Resposta esperada
A: a tabulação é adequada porque todas as respostas são necessárias. A tabela é preenchida uma vez e consultada nos atendimentos seguintes.
B: a tabulação evita repetir cálculos durante a consulta, mas pode preencher posições que não serão usadas. A memoização é interessante quando só parte das respostas é necessária, como neste caso. Se quase todas forem necessárias, a tabulação pode ser mais simples e evitar chamadas recursivas. Ter apenas uma consulta não significa que nenhum cálculo será repetido.
Exercício 14: descobrir as moedas de um troco
As moedas disponíveis valem 4, 9, 17 e 29, em quantidade ilimitada. O vetor abaixo foi preenchido com escolhas que usam a menor quantidade de moedas para cada valor de 0 a 80.
last_coin[value] guarda quanto vale a última moeda escolhida para formar value. Não guarda a posição da moeda em outro vetor. Na posição zero, o valor 0 indica que nenhuma moeda é necessária. O valor -1 indica que não é possível formar aquele troco.
const int last_coin[81] = {
/* 00..09 */ 0, -1, -1, -1, 4, -1, -1, -1, 4, 9,
/* 10..19 */ -1, -1, 4, 9, -1, -1, 4, 17, 9, -1,
/* 20..29 */ 4, 4, 9, -1, 4, 4, 9, 9, 4, 29,
/* 30..39 */ 9, 9, 4, 29, 17, 9, 9, 29, 9, 9,
/* 40..49 */ 9, 29, 9, 9, 9, 9, 29, 9, 9, 9,
/* 50..59 */ 29, 17, 9, 9, 29, 9, 9, 9, 29, 9,
/* 60..69 */ 9, 9, 29, 29, 9, 9, 29, 9, 17, 9,
/* 70..79 */ 29, 9, 9, 9, 9, 29, 9, 9, 9, 29,
/* 80 */ 29
};
Use o vetor para descobrir as moedas do troco de 59 unidades. Mostre o valor restante antes e depois de cada moeda escolhida. Ao final, informe as moedas, a soma e a quantidade usada. Use a tabela fornecida, sem calcular outra.
Solução
Explicação
Para um restante de 59, a consulta é last_coin[59]. O valor guardado nessa posição indica quanto retirar do restante. Após a subtração, o novo restante indica qual posição consultar em seguida.
Objetivo de aprendizagem: usar o vetor da última moeda para recuperar um troco com a menor quantidade de moedas e conferir o resultado.
Resposta esperada
Restante 59: moeda 9, novo restante 50
Restante 50: moeda 29, novo restante 21
Restante 21: moeda 4, novo restante 17
Restante 17: moeda 17, novo restante 0
Moedas: 9, 29, 4 e 17. Soma: 59. Quantidade: 4. Apresentar essas moedas em outra ordem não muda o troco, mas as consultas ao vetor seguem a sequência mostrada.
Exercício 15: preencher a tabela do troco
Preencha os dois vetores do troco para os valores de 0 a 5. As moedas valem 2, 3 e 4, em quantidade ilimitada. Para cada valor, teste as moedas nessa ordem.
min_coins[value]guarda a menor quantidade de moedas para formar exatamentevalue.last_coin[value]guarda quanto vale a última moeda escolhida.- Para o valor zero, guarde 0 nos dois vetores.
- Quando não for possível formar o valor, guarde
∞emmin_coinse -1 emlast_coin. - Se duas moedas levarem à mesma quantidade mínima, mantenha a que foi testada primeiro.
Calcule os valores em ordem crescente, aproveitando as respostas anteriores. Apresente os dois vetores completos e mostre o resultado ao testar cada moeda para o valor 5.
Solução
Explicação
Ao testar uma moeda, ela é retirada do valor pedido. Se o restante puder ser formado, a quantidade necessária é a resposta já calculada para esse restante mais uma moeda. Se essa quantidade for menor que a melhor encontrada, os dois vetores são atualizados. Em um empate, a escolha anterior é mantida.
Para formar 4, duas moedas de 2 funcionam, mas uma moeda de 4 usa menos moedas. Para formar 5, usar a moeda 2 deixa 3, e usar a moeda 3 deixa 2. As duas opções usam duas moedas no total. Como a moeda 2 foi testada primeiro, ela é mantida. A moeda 4 deixa 1, que não pode ser formado.
Objetivo de aprendizagem: preencher a tabela do troco com a menor quantidade de moedas, registrando a última moeda e respeitando a regra de empate.
Resposta esperada
Posições: 0, 1, 2, 3, 4, 5
min_coins: 0, ∞, 1, 1, 1, 2
last_coin: 0, -1, 2, 3, 4, 2
Para o valor 5, testar as moedas 2 e 3 resulta em duas moedas nos dois casos. A moeda 4 não permite completar o troco, pois deixa um restante de 1. Portanto, min_coins[5] = 2 e last_coin[5] = 2.
Exercício 16: montar a tabela de Fibonacci para consultas
Implemente uma função que preencha um vetor com os valores de Fibonacci. A definição é F(0) = 0, F(1) = 1 e, para n >= 2, F(n) = F(n - 1) + F(n - 2).
Use a assinatura:
#include <stdint.h>
void build_fibonacci_table(uint64_t table[], unsigned int table_size);
O vetor já está alocado por quem chama a função. table_size informa sua quantidade de posições e estará entre 1 e 94. A função deve preencher todas elas, deixando table[i] = F(i), sem depender dos valores que estavam no vetor. Ela altera o vetor recebido e não retorna um valor.
O tipo de dado uint64_t é um inteiro sem sinal de 64 bits e comporta os valores de F(0) até F(93). Escreva somente a função build_fibonacci_table.
Solução
Explicação
A tabulação começa com F(0) e, se houver uma segunda posição, F(1). Cada valor seguinte usa as duas respostas anteriores, já guardadas no vetor.
Como o vetor pertence a quem chama a função, as respostas continuam disponíveis após seu término. A tabela pode ser montada uma vez e usada em várias consultas.
Objetivo de aprendizagem: montar uma tabela de Fibonacci em um vetor recebido, permitindo consultar os resultados sem refazer os cálculos.
Resposta esperada
void build_fibonacci_table(uint64_t table[], unsigned int table_size) {
if (table_size > 0) table[0] = 0;
if (table_size > 1) table[1] = 1;
// Reuse the previous answers to fill the remaining table positions.
for (unsigned int index = 2; index < table_size; index++) {
table[index] = table[index - 1] + table[index - 2];
}
}
Exercício 17: reconhecer três formas de fazer uma busca
Três algoritmos, P, Q e R, seguem um ramo da busca antes de voltar para testar outras alternativas.
- P: visita todos os estados da árvore, sem descartar possibilidades antes de explorá-las.
- Q: monta uma solução aos poucos e abandona uma tentativa quando mostra que ela não poderá cumprir as regras do problema.
- R: procura a maior pontuação. Além de descartar tentativas que não podem cumprir as regras, descarta aquelas que comprovadamente não podem superar a melhor solução completa já encontrada.
Classifique P, Q e R como busca em profundidade sem poda, backtracking ou branch-and-bound. Justifique cada resposta.
Solução
Explicação
Os três seguem a ordem de busca em profundidade. A diferença está no motivo para abandonar uma tentativa: P não abandona nenhuma antecipadamente, Q descarta as que não podem cumprir as regras, e R também descarta as que não podem melhorar a resposta encontrada.
Objetivo de aprendizagem: distinguir a ordem em que a busca visita os estados dos motivos usados para descartar tentativas.
Resposta esperada
- P: busca em profundidade sem poda. Visita toda a árvore, sem descartar tentativas antecipadamente.
- Q: backtracking. Abandona tentativas que não podem cumprir as regras e volta para testar outras alternativas.
- R: branch-and-bound. Também usa um limite de pontuação para descartar tentativas que não podem superar a melhor solução encontrada.
Exercício 18: o que muda quando o personagem atravessa uma porta?
Em um jogo de exploração, uma masmorra tem três salas: entrada, corredor e sala do tesouro. Uma porta liga a entrada ao corredor, e outra liga o corredor à sala do tesouro. Atravessar uma porta leva o personagem à sala do outro lado e gasta uma unidade de energia. Sem energia, ele não pode atravessar portas.
O personagem começa na entrada, com 2 unidades de energia.
Responda com base nas regras do problema:
- Quais informações precisam fazer parte do estado do personagem?
- O que representa uma ação nesse problema?
- Como fica o estado antes e depois de atravessar a porta da entrada para o corredor? Explique por que essa mudança é uma transição entre estados.
Solução
Explicação
O estado descreve a situação atual do personagem. Saber em qual sala ele está permite identificar as portas disponíveis. A energia restante determina se ele pode atravessá-las. Atravessar uma porta muda essas duas informações.
Objetivo de aprendizagem: explicar estado, ação e transição usando a mudança de sala e o gasto de energia de um personagem.
Resposta esperada
Estado: a sala em que o personagem está e sua energia restante. Ação: atravessar uma porta disponível, desde que ele tenha pelo menos uma unidade de energia.
Antes de atravessar: (entrada, energia 2). Depois: (corredor, energia 1). Essa mudança é uma transição porque a ação leva o personagem de um estado a outro, alterando a sala e a energia restante.
Exercício 19: analisar as jogadas do jogo da velha
No jogo da velha, os jogadores alternam X e O. Cada jogada ocupa uma casa vazia. Vence quem completa três símbolos iguais em uma linha, coluna ou diagonal. Se o tabuleiro ficar cheio sem vencedor, há empate.
Explique como uma inteligência artificial (IA) poderia usar uma busca para escolher suas jogadas. Para isso, responda:
- Descreva as informações mínimas que devem ser guardadas para representar um estado.
- Uma ação válida pode criar um novo estado. Quais são as ações válidas nesse jogo?
- Um estado parcial cujos descendentes ainda não foram explorados até o fim da partida é suficiente para indicar a melhor jogada imediata da IA? Justifique.
- Como os resultados de vitória, derrota e empate nos nós folha ajudam a comparar as jogadas dos filhos da raiz atual?
- Podemos usar qualquer caminho que leva à vitória como indicação de que a jogada imediata é a melhor a fazer agora? Justifique.
Solução
Explicação
Cada estado descreve o tabuleiro e de quem é a vez. Uma jogada coloca o símbolo desse jogador em uma casa vazia e cria outro estado. A raiz representa o tabuleiro atual, e cada filho dela representa uma das jogadas que a IA pode fazer agora.
Ao chegar ao fim de uma partida, a busca identifica vitória, derrota ou empate do ponto de vista da IA. Esses resultados ajudam a avaliar as jogadas anteriores. Porém, é preciso considerar de quem é a vez em cada etapa: a IA escolhe o que a favorece, e o adversário pode escolher o que a prejudica.
Um tabuleiro ainda incompleto pode dar pistas, como uma ameaça de vitória, mas essas pistas não garantem que uma jogada seja a melhor. Da mesma forma, encontrar um caminho vitorioso não basta: ele pode depender de um erro do adversário. A escolha precisa considerar também as outras respostas que o adversário pode dar.
Objetivo de aprendizagem: explicar como uma busca representa as jogadas e as possíveis respostas do adversário.
Resposta esperada
- O tabuleiro e de quem é a vez. É preciso saber quais casas estão vazias ou ocupadas por X e O e qual jogador fará a próxima jogada.
- Colocar o símbolo do jogador da vez em uma casa vazia, desde que a partida ainda não tenha terminado. Depois, a vez passa ao outro jogador.
- Em geral, não. O estado parcial pode mostrar ameaças e oportunidades, mas, sem analisar as próximas jogadas ou ter outra justificativa, não basta para afirmar qual jogada é a melhor. A resposta do adversário pode mudar o resultado.
- Os resultados finais permitem avaliar a quais consequências cada jogada da raiz pode levar. A IA prefere vitória a empate e empate a derrota. Para comparar os filhos da raiz, precisa considerar as escolhas dos dois jogadores ao longo de cada ramo, identificando o melhor resultado que consegue garantir contra as respostas do adversário.
- Não. Um caminho que termina em vitória pode depender de o adversário jogar mal. Ele pode escolher outra resposta e impedir essa vitória. Portanto, a existência desse caminho não prova que a primeira jogada dele seja a melhor.
Exercício 20: uma folha sempre representa um estado completo?
A busca visitou os nós mostrados nesta árvore:
S
├── U
│ ├── W
│ └── Z
└── V
W é uma tentativa completa que cumpre as regras do problema. Z também está completa, mas não cumpre as regras. Em V, ainda faltavam escolhas, mas a busca mostrou que não seria possível completar uma solução válida e podou esse ramo antes de gerar filhos. Em S e U, também faltavam escolhas.
Explique o que são raiz, nó intermediário, subárvore, ramo e folha, usando exemplos do desenho. Nós folha representam apenas estados completos? Justifique.
Solução
Explicação
Um nó é folha quando não tem filhos na árvore mostrada. W e Z não têm filhos porque as tentativas já estavam completas. V não tem filhos porque a busca descartou suas próximas escolhas. Portanto, um nó pode ser folha mesmo que ainda faltassem escolhas para completar o estado.
Objetivo de aprendizagem: reconhecer as partes de uma árvore de busca e distinguir uma tentativa completa de uma tentativa interrompida por poda.
Resposta esperada
- Raiz: nó inicial, sem pai. É S.
- Nó intermediário: possui pai e filhos. É U.
- Subárvore: um nó com seus descendentes. A subárvore de U contém U, W e Z.
- Ramo: um caminho de escolhas, como
S -> U -> W. - Folha: nó sem filhos na árvore representada. Aqui, W, Z e V são folhas.
Não. W e Z representam estados completos, mas V ainda era um estado incompleto quando foi podado. Como a busca não gerou filhos para V, ele também é uma folha na árvore mostrada. Ser folha depende de não ter filhos nessa árvore, e não de representar um estado completo.
Exercício 21: ainda é possível completar o pedido?
Um pedido precisa de pelo menos 12 unidades. Os lotes já escolhidos fornecem 7. Restam apenas um lote de 2 unidades e outro de 1 unidade. É possível acrescentar qualquer um deles, ambos ou nenhum, mas cada lote só pode ser usado uma vez. Não há outros lotes disponíveis, e os lotes já escolhidos não podem ser trocados neste ramo.
A busca pode podar esse ramo por não conseguir completar o pedido? Justifique. É necessário testar todas as combinações restantes para chegar a essa conclusão?
Solução
Explicação
Mesmo acrescentando os dois lotes restantes, o total chega apenas a 7 + 2 + 1 = 10. Deixar algum deles de fora só diminui esse total. Portanto, nenhuma combinação consegue atender ao pedido.
Objetivo de aprendizagem: justificar uma poda mostrando que nenhuma das escolhas restantes consegue cumprir uma exigência do problema.
Resposta esperada
Sim, pode podar. Mesmo usando tudo o que resta, o total é 10, abaixo das 12 unidades necessárias. Não é preciso testar cada combinação: se nem os dois lotes juntos completam o pedido, escolher menos lotes também não vai completar.
Exercício 22: distinguir pontos obtidos de um limite calculado
Uma busca procura a solução com a maior pontuação. Durante a execução:
- A melhor solução completa que cumpre as regras e já foi encontrada até o momento tem 22 pontos.
- A tentativa atual, ainda incompleta, soma 17 pontos, ou seja, essa é a pontuação do nó atual, sem considerar seus descendentes.
- Um cálculo mostra que, ao completar essa tentativa, a pontuação não poderá passar de 28 pontos, ou seja, esse é o teto de pontuação para a subárvore do nó atual.
Com essas informações, devemos continuar explorando os descendentes do estado atual ou podar esse ramo e buscar uma solução melhor a partir de outros estados? Justifique.
Solução
Explicação
Os 17 pontos correspondem apenas às escolhas já feitas. Como a tentativa ainda está incompleta, essa pontuação não basta para decidir pela poda: as próximas escolhas podem acrescentar pontos.
Para podar por pontuação, seria preciso mostrar que o ramo não pode superar os 22 pontos já encontrados. O teto de 28 pontos não permite essa conclusão, pois é maior que 22. Isso também não garante encontrar uma solução de 28 pontos, nem mesmo uma solução melhor que 22; apenas significa que esse limite não descarta a possibilidade de melhora.
Objetivo de aprendizagem: distinguir os pontos de uma tentativa incompleta, seu limite de pontuação e a melhor pontuação realmente obtida.
Resposta esperada
Continuar explorando os descendentes do estado atual. O teto de 28 pontos supera a melhor pontuação encontrada, de 22 pontos, então não há motivo para podar por esse limite. Os 17 pontos atuais não justificam a poda porque a tentativa ainda está incompleta. Continuar a busca não garante encontrar uma solução melhor, porém é cedo demais para desistir.
Exercício 23: continuar a tentativa ou podar?
A busca procura a maior pontuação e já encontrou uma solução completa válida de 18 pontos. Basta encontrar uma das melhores soluções, sem listar todas as que empatam.
As tentativas da tabela estão incompletas e ainda podem cumprir as regras do problema. O limite superior de cada uma foi calculado corretamente: nenhuma solução que complete aquela tentativa pode ultrapassá-lo. Analise cada linha separadamente, considerando sempre 18 como a melhor pontuação encontrada:
| Tentativa | Pontuação atual | Limite superior |
|---|---|---|
| A | 8 | 21 |
| B | 15 | 17 |
| C | 10 | 18 |
Decida se a busca deve continuar ou podar cada tentativa. Justifique. Continuar A garante encontrar uma solução de 21 pontos?
Solução
Explicação
Para valer a pena continuar, a tentativa precisa ter chance de superar 18 pontos. Poder cumprir as regras não basta se ela não puder melhorar a resposta. Um limite acima de 18 ainda permite essa chance, mas não garante que exista uma solução melhor.
Objetivo de aprendizagem: comparar o limite de uma tentativa com a melhor pontuação encontrada, sem confundir um limite calculado com um resultado obtido.
Resposta esperada
- A: continuar, pois o limite 21 é maior que 18. Isso não garante chegar a 21 nem encontrar uma solução melhor.
- B: podar, pois a tentativa não pode passar de 17, abaixo dos 18 já encontrados.
- C: podar, pois a tentativa pode, no máximo, empatar com 18. Não é necessário listar os empates.
Exercício 24: quando usar backtracking ou branch-and-bound?
Escolha backtracking ou branch-and-bound para cada problema. Justifique. Os dois problemas são independentes.
- Montar uma escala de trabalho em que ninguém tenha dois turnos no mesmo horário. Qualquer escala que cumpra essa regra serve. Ao continuar uma tentativa, os turnos já atribuídos não podem ser trocados.
- Escolher serviços para obter a maior pontuação sem passar do orçamento. Cada serviço pode ser escolhido uma vez ou deixado de fora. Nenhum custo nem pontuação é negativo. Para cada tentativa incompleta, há um cálculo que fornece um limite que sua pontuação final não pode ultrapassar.
Para cada problema, indique em que situação um ramo pode ser descartado e quando a busca pode terminar.
Solução
Explicação
No primeiro problema, encontrar uma escala válida já é suficiente. No segundo, uma escolha de serviços pode caber no orçamento e ainda ter menos pontos que outra. O limite de pontuação permite descartar tentativas que não podem melhorar a resposta, mesmo que possam cumprir o orçamento.
Objetivo de aprendizagem: escolher a técnica considerando se basta encontrar uma solução válida ou se é preciso encontrar a melhor, e quais ramos podem ser descartados.
Resposta esperada
- Backtracking: pode podar uma tentativa que já colocou a mesma pessoa em dois turnos no mesmo horário. Acrescentar outros turnos não corrige esse conflito. A busca pode parar quando encontrar a primeira escala completa válida ou quando verificar que nenhuma é possível.
- Branch-and-bound: pode podar uma tentativa que já passou do orçamento, pois acrescentar serviços não reduz o custo. Também pode podar quando o limite de pontuação não supera a melhor solução completa já encontrada. A busca termina quando todos os ramos que poderiam melhorar a resposta tiverem sido explorados ou descartados com uma justificativa válida.
Testar todas as combinações válidas também permitiria encontrar a maior pontuação no segundo problema. O branch-and-bound usa o limite fornecido para evitar explorar tentativas que não podem melhorar a resposta.