Listas, deques, pilhas e filas

Ligações seguras, operações nas extremidades e análise amortizada de uma fila com duas pilhas.

TLDR

Perguntar sobre esta página

ChatGPTClaudePerplexityGrokGeminiCopiar e abrirDeepSeekCopiar e abrir

Com os valores [4,7,2], uma pilha retira 2 primeiro e uma fila retira 4. Uma lista permite ainda procurar e alterar posições interiores. A escolha depende dessas operações, não apenas de conseguir guardar os três valores. Esta página mostra como manter as ligações sem perder nós e como analisar a fila de duas pilhas que o programa demonstra.

Lista simplesmente ligada

Cada nó guarda um valor e a ligação para o seguinte. Para inserires à frente, cria o novo nó com a ligação antiga e só depois altera primeiro. Para retirares a frente, guarda o nó antigo, avança primeiro e liberta o nó guardado. Estas operações custam O(1)O(1).

Para removeres um nó, altera a ligação que chega a ele antes de o libertares. Em [5,3,5,8], remover todos os 5 começa pela cabeça: primeiro passa para o nó 3. A próxima remoção muda 3.seguinte para o nó 8. O resultado é [3,8].

No programa, ligacao é um apontador para essa ligação de entrada, seja primeiro ou o campo seguinte de um nó. Depois de apagar, volta a examinar a mesma ligação. Em [5,5,3], a primeira remoção deixa outro 5 na cabeça; avançar de imediato saltaria esse segundo nó. Só avança quando o nó atual é conservado.

Para remover a posição k, começa em &primeiro e avança k ligações, com 0<=k<tamanho. A posição zero altera a cabeça; a última exige chegar ao penúltimo nó. Ter um apontador para o fim não torna essa procura constante numa lista simples.

Remover da lista e retirar da fila
#include <iostream>
#include <vector>
class Lista {
    struct No { int valor; No* seguinte; };
    No* primeiro = nullptr;
public:
    Lista() = default;
    Lista(const Lista&) = delete;
    Lista& operator=(const Lista&) = delete;
    ~Lista() { while (primeiro) { No* p = primeiro; primeiro = p->seguinte; delete p; } }
    void inserirFrente(int x) { primeiro = new No{x, primeiro}; }
    void remover(int x) {
        No** ligacao = &primeiro;
        while (*ligacao) {
            No* p = *ligacao;
            if (p->valor == x) { *ligacao = p->seguinte; delete p; }
            else ligacao = &p->seguinte;
        }
    }
    void mostrar() const {
        for (No* p = primeiro; p; p = p->seguinte)
            std::cout << p->valor << (p->seguinte ? " " : "\n");
        if (!primeiro) std::cout << "vazia\n";
    }
};
class Fila {
    std::vector<int> entrada, saida;
public:
    void inserir(int x) { entrada.push_back(x); }
    bool retirar(int& x) {
        if (saida.empty()) {
            while (!entrada.empty()) {
                saida.push_back(entrada.back()); entrada.pop_back();
            }
        }
        if (saida.empty()) return false;
        x = saida.back(); saida.pop_back(); return true;
    }
};
int main() {
    Lista l;
    for (int x : {8, 5, 3, 5}) l.inserirFrente(x);
    l.remover(5); l.mostrar();
    Fila f; f.inserir(3); f.inserir(5);
    int x;
    if (f.retirar(x)) std::cout << x << '\n';
    f.inserir(8);
    while (f.retirar(x)) std::cout << x << '\n';
    std::cout << "vazia: " << !f.retirar(x) << '\n';
}
Resultado

Executa o código para ver a saída e os gráficos.

A cópia está desativada porque os nós têm um único proprietário, e o destrutor liberta-os de forma iterativa. Os apontadores para nós apagados deixam de ser válidos. Pesquisar e percorrer custam Θ(n)\Theta(n) no pior caso. Retirar o último exige chegar ao penúltimo, mesmo que exista um apontador para o último, ou seja, alterar a ligação custa constante mas encontrar a ligação nem sempre custa constante.

Separar e intercalar por ligações

Separar uma lista não exige copiar os valores. Em [4A,1B,4C,2D,4E], conservar os primeiros dois nós deixa [4A,1B] na origem e [4C,2D,4E] noutra lista. As letras identificam ocorrências, mesmo quando a chave é igual.

Fixa o contrato de separar(resto,k): resto é uma lista distinta, inicialmente vazia; k<=0 transfere tudo; k>=tamanho conserva tudo na origem. Com 0<k<tamanho, avança até à posição k-1, guarda o seu sucessor como cabeça de resto e corta a ligação escrevendo nullptr. Atualiza os dois tamanhos. Sem o corte, as listas continuariam a possuir uma cadeia comum, e os seus destrutores poderiam libertar o mesmo nó duas vezes.

O caso interior custa Θ(k)\Theta(k) para encontrar o corte e espaço O(1)O(1). Os casos extremos custam O(1)O(1) porque o tamanho já está guardado. Se resto tivesse nós, sobrescrever a sua cabeça perderia esses nós; a pré-condição de vazio impede essa fuga. A mesma lista não pode ser origem e destino.

Para intercalar duas listas já ordenadas, guarda uma frente em cada lista e uma ligação para o fim da saída. Escolhe a menor frente, avança apenas essa frente e liga o nó escolhido à saída. No empate, escolhe a lista esquerda para preservar a ordem inicial entre ocorrências. Quando uma lista acaba, liga o sufixo da outra de uma só vez. A origem direita fica vazia, porque os seus nós passaram a pertencer à lista de saída.

Frentes disponíveisNó escolhidoPrefixo da saída
[1B,4A], [2D,4C,4E]1B[1B]
[4A], [2D,4C,4E]2D[1B,2D]
[4A], [4C,4E]4A, empate[1B,2D,4A]
[], [4C,4E]ligar o sufixo[1B,2D,4A,4C,4E]

O invariante conserva um prefixo ordenado com os menores nós já escolhidos; as duas frentes iniciam as cadeias ainda por consumir. Guardar o sucessor antes de alterar uma ligação permite avançar sem perder os restantes nós. Não se cria nem apaga nenhum nó durante a separação ou a intercalação.

Com comprimentos a e b, a intercalação faz no máximo a+b-1 comparações se ambas forem não vazias. Nesse caso, o pior custo é Θ(a+b)\Theta(a+b), com auxiliar O(1)O(1). Se uma lista estiver vazia, basta ligar a cadeia da outra, em O(1)O(1).

Não é limitado por O(min⁡(a,b))O(\min(a,b)): para a=1 e b=m, põe [m+1] à esquerda e [1,…,m] à direita. Consomes os m nós da direita embora o menor comprimento seja sempre 1. Por exemplo, [100] contra [1,…,99] exige 99 comparações. Ligar o sufixo custa constante, mas uma lista curta pode não acabar cedo.

Separar, ordenar e intercalar sem copiar nós
#include <cassert>
#include <iostream>
#include <vector>

template <class T> class Lista {
    struct No { T valor; No* seguinte; };
    No* primeiro = nullptr;
    int tamanho = 0;
public:
    Lista() = default;
    Lista(const Lista&) = delete;
    Lista& operator=(const Lista&) = delete;
    ~Lista() {
        while (primeiro) {
            No* p = primeiro;
            primeiro = p->seguinte;
            delete p;
        }
    }
    void inserirFrente(const T& x) {
        primeiro = new No{x, primeiro};
        ++tamanho;
    }
    int size() const { return tamanho; }
    std::vector<T> valores() const {
        std::vector<T> r;
        for (No* p = primeiro; p; p = p->seguinte) r.push_back(p->valor);
        return r;
    }
    // resto deve ser outra lista, inicialmente vazia.
    void separar(Lista& resto, int k) {
        assert(&resto != this && resto.tamanho == 0);
        if (k >= tamanho) return;
        if (k <= 0) {
            resto.primeiro = primeiro;
            resto.tamanho = tamanho;
            primeiro = nullptr;
            tamanho = 0;
            return;
        }
        No* ultimo = primeiro;
        for (int i = 1; i < k; ++i) ultimo = ultimo->seguinte;
        resto.primeiro = ultimo->seguinte;
        resto.tamanho = tamanho - k;
        ultimo->seguinte = nullptr;
        tamanho = k;
    }
    // As duas listas devem estar ordenadas e ser distintas.
    void intercalar(Lista& outra) {
        assert(&outra != this);
        No* esquerda = primeiro;
        No* direita = outra.primeiro;
        No** fim = &primeiro;
        while (esquerda && direita) {
            // Escolher esquerda no empate conserva a estabilidade.
            No*& escolhido = direita->valor < esquerda->valor ? direita : esquerda;
            *fim = escolhido;
            escolhido = escolhido->seguinte;
            fim = &(*fim)->seguinte;
        }
        *fim = esquerda ? esquerda : direita;
        tamanho += outra.tamanho;
        outra.primeiro = nullptr;
        outra.tamanho = 0;
    }
    void ordenar() {
        if (tamanho <= 1) return;
        Lista resto;
        separar(resto, tamanho / 2);
        ordenar();
        resto.ordenar();
        intercalar(resto);
    }
};

struct Registo {
    int chave;
    char etiqueta;
    bool operator<(const Registo& r) const { return chave < r.chave; }
};
void mostrar(const Lista<Registo>& lista) {
    for (auto r : lista.valores()) std::cout << r.chave << r.etiqueta << ' ';
    std::cout << "(tamanho " << lista.size() << ")\n";
}
int main() {
    Lista<Registo> lista, resto;
    // Inserir pela frente em ordem inversa cria [4A,1B,4C,2D,4E].
    for (Registo r : {Registo{4,'E'}, {2,'D'}, {4,'C'}, {1,'B'}, {4,'A'}})
        lista.inserirFrente(r);
    lista.separar(resto, 2);
    mostrar(lista); mostrar(resto);
    lista.ordenar(); resto.ordenar();
    lista.intercalar(resto);
    mostrar(lista); mostrar(resto);
    return 0;
}
Resultado

Executa o código para ver a saída e os gráficos.

O programa primeiro mostra [4A,1B] e [4C,2D,4E], depois ordena cada parte e intercala. A saída final é 1B 2D 4A 4C 4E, tamanho 5; a outra lista tem tamanho 0. No*& escolhido é uma referência ao apontador da frente escolhida, permitindo avançar essa frente. No** fim refere a cabeça de saída ou o campo seguinte do último nó escrito. O método ordenar compõe estas operações no mergesort de listas. As comparações do tipo T têm de definir uma ordem válida e não lançar exceções neste exemplo.

Lista dupla e circular

Numa lista duplamente ligada, cada nó guarda anterior e seguinte. Para inserires x entre a e b, ajusta as quatro ligações: a.seguinte=x, x.anterior=a, x.seguinte=b, b.anterior=x. Uma sentinela simplifica as extremidades, mas não representa um valor da lista.

Numa lista circular com sentinela, o vazio tem as duas ligações da sentinela para ela própria. O percurso termina quando regressa à sentinela, não quando encontra um apontador nulo. Para cada ligação entre nós vizinhos, seguir seguinte e depois anterior deve regressar ao nó de partida. Retirar um nó conhecido é constante; obter o nó por índice continua linear.

Deque, pilha e fila

Um deque permite inserir e retirar em ambas as extremidades. Não implica permitir inserções interiores eficientes. Um deque de array circular guarda um índice de início, um tamanho e uma capacidade. A posição lógica i corresponde a (inicio+i)%capacidade. Com capacidade 5, inicio=3 e [a,b,c] nas posições físicas 3,4,0, retirar a frente avança inicio para 4 e inserir d escreve na física 1. O percurso lógico 0,1,2 visita as físicas 4,0,1 após as operações. Reserva capacidade positiva antes de calcular o resto.

Uma pilha é LIFO: o último a entrar é o primeiro a sair. Num vetor, push_back, back e pop_back servem para a pilha. Uma fila é FIFO: entra no fim e sai pela frente. Num deque, push_back, front e pop_front evitam deslocar todos os elementos. top, front e pop exigem não vazio na biblioteca.

Entrada de 4, 7, 2Próximo a sairOrdem completa
pilha22, 7, 4
fila44, 7, 2

A pilha modela tarefas suspensas, desfazer operações e percursos em profundidade. A fila modela pedidos por ordem de chegada e percursos por camadas. Uma fila de prioridade escolhe uma prioridade, sem garantir FIFO nos empates.

Fila com duas pilhas

A pilha entrada recebe as inserções. A pilha saida tem no topo o elemento mais antigo. A classe Fila do programa implementa estas duas pilhas com vetores. Se saida estiver vazia, transfere todos os elementos de entrada para ela: a inversão põe o mais antigo no topo. Se ainda estiver vazia, a fila está vazia.

  1. Inserir 3 e 5
    entrada: 3, 5saída: vazia
    Para retirar, transfere 5 e depois 3 para saída.
  2. Retirar 3 e inserir 8
    entrada: 8saída: 5
    Conserva 8 na entrada. A retirada seguinte devolve 5 sem transferência.
  3. Retirar 8
    entrada: vaziasaída: vazia
    Só agora transfere 8, porque saída ficou vazia. Saídas: 3, 5, 8.
Os valores de cada pilha estão escritos do fundo para o topo. O topo de saída é o próximo a retirar.

Uma retirada isolada pode mover n elementos e custar Θ(n)\Theta(n). Ao longo de m operações, cada elemento entra em entrada, sai dela, entra em saida e sai dela no máximo uma vez por cada etapa. Assim, o número total de operações nas pilhas é O(m)O(m), e o custo amortizado por operação da fila é O(1)O(1). Se as pilhas são vetores dinâmicos, inclui também o seu custo amortizado de crescimento.

Aplicações de pilhas

Para verificares parênteses de vários tipos, empilha cada abertura. Ao leres um fecho, a pilha deve estar não vazia e o topo deve ser a abertura correspondente; depois retira-o. No fim, a pilha tem de estar vazia. ([)] tem o mesmo número de aberturas e fechos, mas é inválido porque ) não corresponde ao topo [. O invariante identifica a pilha com as aberturas ainda não fechadas, pela sua ordem de encaixe. Tempo O(n)O(n) e espaço O(n)O(n) no pior caso.

Uma pilha monótona resolve o próximo valor estritamente maior. Percorre da esquerda para a direita, guardando índices cujos valores ainda não encontraram resposta e estão por ordem não crescente. Quando chega um valor maior, retira todos os índices com valor menor e responde com a posição atual. Cada índice é inserido e retirado no máximo uma vez, logo o total é linear. Para [3,1,4,4,2], as posições seguintes maiores são [2,2,ausente,ausente,ausente]. Um valor igual não responde a «estritamente maior».

A mesma contagem de entradas e saídas explica por que um ciclo while dentro de um ciclo linear não implica, por si só, tempo quadrático.

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.

Retirar nós sem saltar iguais

Remove todas as ocorrências de 4 em 4->4->7->4->9. Descreve o estado de uma ligação de entrada depois de cada remoção.

Pista

Uma ligação de entrada pode ser a cabeça ou um campo seguinte.

Mais uma pista

Depois de apagar um nó, volta a examinar o novo nó que essa ligação referencia.

Solução

A cabeça passa primeiro para o segundo 4, depois para 7. Avança a ligação para o seguinte de 7; apagar o 4 liga 7 a 9. A lista final é 7->9. Cada nó é examinado uma vez, por isso o custo é O(n), e cada nó retirado é libertado uma vez. Não acedas ao nó depois de delete.

Confere a tua resposta:

  • Obtive 7 → 9 após retirar os três nós com 4.
  • Voltei a examinar a mesma ligação depois de apagar cada cabeça.
  • Mantive a cadeia ligada e libertei cada nó retirado uma vez.

Erros frequentes

Avançar após apagar a primeira cabeça saltaria o segundo 4; esquecer a ligação do anterior perde a cadeia.

Voltar à explicação
Traçar a fila amortizada

Numa fila com duas pilhas, insere 2 e 6, retira um, insere 9 e retira mais dois. Indica as saídas, quantos elementos são transferidos de entrada para saída e em quantas fases de transferência.

Pista

Só transfere quando saída estiver vazia.

Mais uma pista

A primeira retirada transfere dois valores; a última transfere um.

Solução

As saídas são 2, 6 e 9. Cada um dos três elementos é transferido uma vez, ou seja, três elementos transferidos em duas fases. Depois de retirar 2, saída ainda guarda 6 e a inserção de 9 fica em entrada. Retirar 6 não transfere nada. A última retirada transfere 9. Uma retirada pode ser linear, mas a sequência é linear no número de operações.

Confere a tua resposta:

  • Obtive saídas 2, 6 e 9 pela ordem FIFO.
  • Distingui duas fases de transferência de três elementos transferidos.
  • Mantive 9 na entrada enquanto a saída ainda continha 6.

Erros frequentes

Transferir 9 para uma saída que ainda contém 6 violaria FIFO; não chames ao custo amortizado uma média probabilística.

Voltar à explicação
Remover por posição com tamanho

Numa lista com [5,3,5,8] e tamanho 4, remove a posição 2 com índices a partir de zero. Mostra anterior, vítima, ligações e tamanho final. Explica como remover a cabeça ou o último nó e que pré-condição impede remover uma posição inexistente.

Pista
Segue a ligação que chega ao nó.
Solução

Anterior é 3, vítima é o segundo 5; a ligação passa a apontar para 8 e o tamanho fica 3, com lista [5,3,8]. Guarda a vítima, liga o anterior ao sucessor e só depois liberta a vítima. Em k=0, não há anterior: atualiza primeiro para o sucessor. Para o último, chega ao penúltimo e escreve uma ligação nula. A pré-condição é 0<=k<tamanho; o vazio não tem posições válidas. Encontrar a ligação custa O(k+1), religar e atualizar o tamanho custam O(1).

Confere a tua resposta:

  • Percorri k ligações a partir de primeiro.
  • Tratei k=0 com atualização de primeiro.
  • Atualizei o tamanho e distingui remoção por posição de remoção por valor.

Erros frequentes

Remover por posição apaga um único nó, mesmo que haja outro com o mesmo valor. Esquecer o tamanho quebra o invariante; ler o sucessor depois de libertar a vítima acede a memória inválida.

Voltar à explicação
Transferir nós e conservar os empates

Uma lista contém [1A,4B,4C,2D,4E,8F]; as letras identificam os nós e a ordenação compara apenas números. Uma segunda lista está vazia. Separa a primeira após os três primeiros nós e intercala as duas metades ordenadas. Mostra as ligações e tamanhos depois de cada operação. Descreve uma implementação sem copiar valores, criar ou apagar nós, incluindo k<=0 e k>=n na separação. A segunda lista é distinta da primeira.

Pista
Uma separação precisa de cortar a cadeia; uma intercalação transfere a sua posse.
Solução

O nó 4C passa a ter seguinte=nullptr; a segunda cabeça aponta para 2D. As duas listas têm tamanho 3. Na junção, escolhes 1A, 2D, 4B e 4C; depois ligas o sufixo [4E,8F]. A saída é [1A,2D,4B,4C,4E,8F], tamanho 6, e a segunda lista fica com cabeça nula e tamanho zero. Na igualdade, consumir a esquerda conserva 4B e 4C antes de 4E.

Para k<=0, transfere cabeça e tamanho para o destino e esvazia a origem; para k>=n, conserva a origem e o destino vazio. No caso interior, percorre até à posição k-1, guarda o sucessor no destino, corta a ligação e reparte o tamanho. Custa Θ(k) no interior e O(1) nos extremos. Intercalar custa O(a+b) no pior caso e espaço O(1): comparar as frentes, avançar a escolhida e ligar o resto quando uma acabar. A cabeça e o tamanho da origem direita têm de ser limpos, evitando posse duplicada.

O programa seguinte implementa estas operações; ordenar() é usado apenas quando as entradas ainda não estão ordenadas.

#include <cassert>
#include <iostream>
#include <vector>

template <class T> class Lista {
    struct No { T valor; No* seguinte; };
    No* primeiro = nullptr;
    int tamanho = 0;
public:
    Lista() = default;
    Lista(const Lista&) = delete;
    Lista& operator=(const Lista&) = delete;
    ~Lista() {
        while (primeiro) {
            No* p = primeiro;
            primeiro = p->seguinte;
            delete p;
        }
    }
    void inserirFrente(const T& x) {
        primeiro = new No{x, primeiro};
        ++tamanho;
    }
    int size() const { return tamanho; }
    std::vector<T> valores() const {
        std::vector<T> r;
        for (No* p = primeiro; p; p = p->seguinte) r.push_back(p->valor);
        return r;
    }
    // resto deve ser outra lista, inicialmente vazia.
    void separar(Lista& resto, int k) {
        assert(&resto != this && resto.tamanho == 0);
        if (k >= tamanho) return;
        if (k <= 0) {
            resto.primeiro = primeiro;
            resto.tamanho = tamanho;
            primeiro = nullptr;
            tamanho = 0;
            return;
        }
        No* ultimo = primeiro;
        for (int i = 1; i < k; ++i) ultimo = ultimo->seguinte;
        resto.primeiro = ultimo->seguinte;
        resto.tamanho = tamanho - k;
        ultimo->seguinte = nullptr;
        tamanho = k;
    }
    // As duas listas devem estar ordenadas e ser distintas.
    void intercalar(Lista& outra) {
        assert(&outra != this);
        No* esquerda = primeiro;
        No* direita = outra.primeiro;
        No** fim = &primeiro;
        while (esquerda && direita) {
            // Escolher esquerda no empate conserva a estabilidade.
            No*& escolhido = direita->valor < esquerda->valor ? direita : esquerda;
            *fim = escolhido;
            escolhido = escolhido->seguinte;
            fim = &(*fim)->seguinte;
        }
        *fim = esquerda ? esquerda : direita;
        tamanho += outra.tamanho;
        outra.primeiro = nullptr;
        outra.tamanho = 0;
    }
    void ordenar() {
        if (tamanho <= 1) return;
        Lista resto;
        separar(resto, tamanho / 2);
        ordenar();
        resto.ordenar();
        intercalar(resto);
    }
};

struct Registo {
    int chave;
    char etiqueta;
    bool operator<(const Registo& r) const { return chave < r.chave; }
};
void mostrar(const Lista<Registo>& lista) {
    for (auto r : lista.valores()) std::cout << r.chave << r.etiqueta << ' ';
    std::cout << "(tamanho " << lista.size() << ")\n";
}
int main() {
    Lista<Registo> lista, resto;
    // Inserir pela frente em ordem inversa cria [4A,1B,4C,2D,4E].
    for (Registo r : {Registo{4,'E'}, {2,'D'}, {4,'C'}, {1,'B'}, {4,'A'}})
        lista.inserirFrente(r);
    lista.separar(resto, 2);
    mostrar(lista); mostrar(resto);
    lista.ordenar(); resto.ordenar();
    lista.intercalar(resto);
    mostrar(lista); mostrar(resto);
    return 0;
}

Confere a tua resposta:

  • Separei após 4C e atualizei as duas cabeças e tamanhos.
  • Obtive 1A,2D,4B,4C,4E,8F sem criar ou apagar nós.
  • Escolhi a esquerda no empate e esvaziei a origem direita.
  • Tratei k<=0, k>=n, destino vazio distinto e custos.

Erros frequentes

Sobrescrever um destino não vazio perde os seus nós. Não compares etiquetas nem deixes as duas listas partilhar o sufixo.
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.