Tipos abstratos e escolha da representação

Separar operações observáveis, invariantes da representação e custos concretos.

TLDR

Perguntar sobre esta página

ChatGPTClaudePerplexityGrokGeminiCopiar e abrirDeepSeekCopiar e abrir

Uma pilha que recebe 4, 7 e 2 tem de retirar 2 primeiro. Este comportamento LIFO, último a entrar, primeiro a sair, é o mesmo se os valores estiverem num vetor ou em nós ligados. A representação muda os custos e as regras de memória, sem mudar a ordem das saídas.

Um tipo abstrato de dados, TAD, especifica os valores e as operações observáveis. O cliente usa esse contrato sem depender dos campos que a implementação guarda. As lições anteriores fixaram contratos e contagens; esta página usa-os para escolher a representação.

Interface, contrato e representação

Para uma lista de valores, oferece size, empty, consulta por posição, inserção e remoção. Cada operação precisa de condições, ou seja, de um contrato. Consultar a posição i exige 0<=i<size. Retirar o primeiro elemento exige uma lista não vazia, ou uma interface que devolva explicitamente uma ausência.

O invariante da representação liga os campos privados ao valor abstrato. Numa lista simplesmente ligada com primeiro e tamanho:

  • primeiro é nulo exatamente quando o tamanho é zero.
  • A cadeia termina num apontador nulo, não tem ciclos e contém exatamente tamanho nós.
  • Cada nó pertence à lista; não o libertes enquanto continuar ligado.

O construtor estabelece o invariante. Cada método público assume-o à entrada e repõe-o à saída. O destrutor liberta os recursos possuídos. Se os campos fossem públicos, o utilizador criaria uma cadeia cíclica ou um tamanho falso sem passar pelos métodos.

Distingue o valor abstrato de um detalhe interno: uma fila vazia não precisa de ter um índice interno igual a zero, e um cliente que só usa size, empty, consulta por posição, inserção e remoção não distingue se por baixo está um vetor ou nós ligados.

Com um milhão de consultas por índice e poucas inserções no fim, o vetor calcula o endereço em tempo constante e a lista percorre i+1 nós por consulta, por isso a escolha segue a operação dominante, não o nome da estrutura. Uma representação só pode substituir a outra se ambas respeitarem os mesmos resultados e pré-condições.

Vetor ou nós ligados

OperaçãoVetor contíguoLista simplesmente ligada
acesso à posição iO(1)O(1)O(i+1)O(i+1)
inserir/remover à frenteO(n)O(n)O(1)O(1)
acrescentar no fimO(1)O(1) amortizado com crescimentoO(1)O(1) com apontador para último; O(n)O(n) sem ele
remover o últimoO(1)O(1)O(n)O(n) para encontrar o anterior
inserir depois de um nó conhecidodeslocações O(n)O(n) no caso geralO(1)O(1)
memória por elementovalor e eventual capacidade livrevalor e um ou mais apontadores

«Inserir numa lista é constante» só vale se já tens a ligação ou o nó necessário, porque encontrar a posição pode dominar o custo. Num vetor, a memória contígua favorece acessos sequenciais. A lista evita algumas deslocações, mas paga alocações e segue apontadores.

Uma lista duplamente ligada conhece o anterior de cada nó, por isso retira um nó conhecido em O(1)O(1). Mas continua sem acesso direto por índice, ou seja, não faz pesquisa binária eficiente por sucessivos meios. Um vetor ordenado consulta em binário depressa, mas inserir mantendo a ordem exige deslocações.

Memória, cópia e iteradores

Uma estrutura que possui nós dinâmicos precisa de uma política de cópia: copiar profundamente, transferir posse ou impedir a cópia. Copiar só o apontador inicial cria dois proprietários dos mesmos nós. Os exemplos de nós desta cadeira impedem a cópia e libertam os nós no destrutor. Os containers da biblioteca tratam essa posse por ti.

Um iterador identifica uma posição segundo as regras do container. Num vector, realocar invalida todos os iteradores, referências e apontadores para elementos, e mesmo sem realocação uma inserção pode invalidar os que apontam para a posição de inserção ou depois dela.

Num list, inserir preserva iteradores existentes e apagar invalida só os do elemento apagado, por isso consulta a garantia da operação concreta antes de guardares um iterador entre alterações.

Os templates exprimem uma estrutura para tipos diferentes. Mas generalizar o nome do tipo não prova as propriedades usadas: ordenar exige comparação válida, copiar exige que o tipo suporte essa cópia e acumular exige operações aritméticas adequadas.

Biblioteca e composição de custos

vector, list, deque, stack, queue, set, map, unordered_set, unordered_map e priority_queue oferecem interfaces diferentes. stack e queue são adaptadores, ou seja, limitam as operações de um container subjacente. Uma fila de prioridade escolhe por prioridade, não por ordem de chegada.

Ao analisar um algoritmo, substitui cada chamada pelo seu custo real. Fazer n inserções num set custa O(nlog⁡n)O(n\log n); percorrê-lo inteiro custa O(n)O(n). Fazer n consultas esperadas constantes numa tabela hash dá custo esperado linear sob as hipóteses de hashing, sem garantia linear de pior caso.

Copiar uma estrutura por valor ao chamar uma função pode custar mais que a função em si, por isso usa referência constante se só precisas de ler.

Implementar e testar um contrato

Antes de programares, escreve a entrada, a saída, os limites e a política de casos especiais. Se usas índices em [0,n), conserva essa convenção em todas as funções. Se os enunciados usam [1,n], converte uma vez na fronteira de entrada.

Para cada operação, testa vazio, um elemento, repetidos, alteração da frente e do fim, ausência de uma chave e várias operações seguidas. Depois de cada alteração, confere o invariante, não só uma impressão final, porque uma lista com tamanho correto e um ciclo continua inválida.

Compara o teu algoritmo com uma solução simples e independente em entradas pequenas: pesquisa com um percurso linear, ordenação com a biblioteca, caminhos de grafos com uma matriz de alcançabilidade. A referência pode ser lenta, porque o seu papel é detetar erros.

Compila em C++17 com avisos, por exemplo g++ -std=c++17 -Wall -Wextra -Wpedantic programa.cpp -o programa. Separa interface e implementação para documentares as pré-condições de cada método. Em submissões automáticas, lê exatamente o formato pedido e imprime apenas a saída exigida, porque uma mensagem de depuração invalida uma resposta correta. Verifica overflow, limites de memória e profundidade da recursão antes de aumentares a dimensão dos testes.

Exercícios

As justificações e os programas em C++ são de autoavaliação. Compara o raciocínio com os critérios da solução.

Escolher pela operação dominante

Um programa faz um milhão de consultas por índice e poucas inserções no fim. Escolhe entre vector e lista simplesmente ligada e compara os custos.

Pista

Identifica a operação repetida mais vezes.

Solução

Escolhe vector, porque o acesso por índice é O(1) e acrescentar no fim é O(1) amortizado. A lista custa O(i+1) por consulta de índice i e pode ter custo total muito maior. Se crescer o vetor, trata a invalidação de referências e iteradores. A escolha não se altera só porque a lista tem inserção constante à frente.

Confere a tua resposta:

  • Escolhi vector pelo acesso constante por índice.
  • Comparei com o percurso O(i+1) da lista.
  • Distingui crescimento amortizado de uma realocação e da invalidação.

Erros frequentes

Não omitas o custo de localizar uma posição nem confundas amortizado com pior caso de uma inserção.

Voltar à explicação
Encontrar uma violação de posse

Uma classe possui uma cadeia de nós e o destrutor apaga todos. A cópia por omissão copia apenas o apontador inicial. O que acontece se duas cópias saírem do âmbito? Dá duas políticas corretas.

Pista

Segue a identidade dos nós, não apenas os valores guardados.

Solução

A primeira destruição liberta a cadeia; a segunda percorre memória já libertada e pode libertá-la de novo. É comportamento indefinido, não uma saída previsível. Podes impedir cópia ou implementar cópia profunda, criando uma cadeia independente. Uma transferência de posse também pode ser definida, deixando a origem vazia e válida.

Confere a tua resposta:

  • Identifiquei a memória libertada que as duas cópias partilham.
  • Expliquei o comportamento indefinido, sem prever uma saída.
  • Dei duas políticas de posse que impedem a dupla libertação.

Erros frequentes

Não proponhas retirar o destrutor e deixar fugas; copiar valores para novos nós exige tratar também falhas de alocação.

Voltar à explicação
Guardar uma posição depois de crescer

Um vetor não vazio tem size()==capacity(). Guardas it=v.begin() e fazes v.push_back(8), que termina com sucesso. Podes consultar *it depois? Como conservar a posição lógica do primeiro elemento sem usar um iterador inválido?

Pista

Sem capacidade livre, o vetor tem de obter outra zona de armazenamento.

Solução

Não podes usar *it, porque aumentar o tamanho para além da capacidade exige realocação e invalida todos os iteradores. Guarda o índice 0 e obtém de novo v.begin() depois de inserir. O primeiro valor mantém-se, mas o seu endereço pode mudar. Para uma posição geral num vetor, guarda it-v.begin() antes da alteração e recompõe o iterador depois, se a operação preservar a posição lógica.

Confere a tua resposta:

  • Identifiquei a realocação como causa da invalidação.
  • Guardei o índice antes da inserção e obtive um iterador novo depois.
  • Expliquei por que consultar o iterador antigo não tem resultado garantido.

Erros frequentes

Guardar um apontador ou referência ao mesmo elemento também não evita a invalidação.

Voltar à explicação

À tua maneira

Aparência

Modo de cor
Tema
Cor de destaque

Leitura

Fonte de leitura

Álgebra, lógica e uma ideia de cada vez.

Código

Fonte do código

Navegação

Cursor de leitura, sem edição. h, j, k, l movem; / pesquisa nesta página. v seleciona caracteres; V seleciona linhas; y copia. Tab permite destacar ou comentar. Espaço ou Enter abre blocos recolhíveis. Nos cartões, h, j, k, l mudam o foco.

CSS personalizado

Ativa uma sugestão ou guarda o teu CSS. Só muda este navegador.

Usa --page, --text e --accent para acompanhar o tema.

Seletores e recuperação

.site-header, .prose, .course-card, [data-reading-history], [data-playground] e .page-actions.

Abre a aparência com a tecla vírgula ou no rodapé. Se o CSS esconder os controlos, abre esta página sem CSS personalizado.

Pesquisar

Escreve para pesquisar em todo o site.

para escolher · Enter para abrir · Esc para fechar

Copiar fórmula

A cópia automática não está disponível. Copia o LaTeX abaixo.

Pesquisar nesta página

Enter procura · n / N repetem · Esc cancela. Ctrl / Cmd K pesquisa todo o site.

Atalhos de teclado

Clica numa tecla para a mudar. Esc cancela. Backspace desativa.

PesquisarCtrl / Cmd K

Os atalhos não interferem enquanto escreves. Tab e Enter funcionam sempre.