Por que std :: list :: reverse tem O (n) complexidade?

192

Por que a função reversa para a std::listclasse na biblioteca padrão C ++ possui tempo de execução linear? Eu pensaria que, para listas duplamente vinculadas, a função reversa deveria ter sido O (1).

A reversão de uma lista duplamente vinculada deve envolver apenas a troca da cabeça e dos ponteiros de cauda.

Curioso
fonte
18
Não entendo por que as pessoas estão votando contra esta questão. É uma pergunta perfeitamente razoável de se fazer. A reversão de uma lista duplamente vinculada deve levar tempo O (1).
Curioso
46
infelizmente, algumas pessoas confundem os conceitos de "a pergunta é boa" com "a pergunta tem uma boa idéia". Adoro perguntas como essa, nas quais basicamente "meu entendimento parece diferente de uma prática comumente aceita, por favor, ajude a resolver esse conflito", porque expandir a maneira como você pensa ajuda a resolver muito mais problemas no caminho! Parece que outros adotam a abordagem de "isso é um desperdício de processamento em 99,9999% dos casos, nem pense nisso". Se é algum consolo, tenho sido votado por muito, muito menos!
22416 CorsiKa
4
Sim, esta pergunta recebeu uma quantidade excessiva de votos negativos por sua qualidade. Provavelmente é o mesmo que aprovou a resposta de Blindy. Para ser justo, "reverter uma lista duplamente vinculada deve envolver apenas a troca de indicadores de cabeça e cauda" geralmente não é verdadeiro para a lista vinculada padrão que todos aprendem no ensino médio ou para muitas implementações que as pessoas usam. Muitas vezes, a reação imediata do pessoal da SO à pergunta ou resposta leva a uma decisão positiva / negativa. Se você tivesse sido mais claro nessa frase ou a tivesse omitido, acho que teria recebido menos votos negativos.
25716 Chris Beck
3
Ou deixe-me colocar o ônus da prova com você, @Curious: criei uma implementação de lista duplamente vinculada aqui: ideone.com/c1HebO . Você pode indicar como você esperaria que a Reversefunção fosse implementada em O (1)?
CompuChip
2
@ CompuChip: Na verdade, dependendo da implementação, talvez não. Você não precisa de um booleano extra para saber qual ponteiro usar: basta usar o que não está apontando para você ... o que poderia ser automático com uma lista vinculada ao XOR a propósito. Portanto, sim, depende de como a lista é implementada e a declaração do OP pode ser esclarecida.
27516 Matthieu M.

Respostas:

187

Hipoteticamente, reversepoderia ter sido O (1) . Poderia haver (novamente hipoteticamente) um membro da lista booleano indicando se a direção da lista vinculada atualmente é a mesma ou oposta à original em que a lista foi criada.

Infelizmente, isso reduziria o desempenho de basicamente qualquer outra operação (embora sem alterar o tempo de execução assintótico). Em cada operação, um booleano precisaria ser consultado para considerar se um ponteiro "próximo" ou "anterior" deve ser seguido.

Como presumivelmente isso era considerado uma operação relativamente pouco frequente, o padrão (que não determina implementações, apenas complexidade), especificou que a complexidade poderia ser linear. Isso permite que os ponteiros "próximos" sempre sigam a mesma direção sem ambiguidade, acelerando as operações de casos comuns.

Ami Tavory
fonte
29
@MooseBoys: Eu não concordo com a sua analogia. A diferença é que, no caso de uma lista, a implementação poderia fornecer reversecom O(1)complexidade sem afetar o big-o de qualquer outra operação , usando este truque sinalizador booleano. Mas, na prática, uma ramificação extra em cada operação é cara, mesmo que seja tecnicamente O (1). Por outro lado, não é possível criar uma estrutura de lista na qual sorté O (1) e todas as outras operações têm o mesmo custo. O ponto principal da pergunta é que, aparentemente, você pode O(1)reverter de graça se você se importa apenas com o O grande, então por que eles não fizeram isso?
22716 Chris Beck
9
Se você usasse uma lista vinculada ao XOR, a reversão se tornaria tempo constante. Um iterador seria maior, e incrementá-lo / decrementá-lo seria um pouco mais computacionalmente caro. Isso pode ser diminuído pelos inevitáveis ​​acessos à memória para qualquer tipo de lista vinculada.
Deduplicator
3
@IlyaPopov: Todo nó realmente precisa disso? O usuário nunca faz perguntas do próprio nó da lista, apenas do corpo da lista principal. Portanto, acessar o booleano é fácil para qualquer método que o usuário chama. Você pode estabelecer a regra de que os iteradores são invalidados se a lista for revertida e / ou armazenar uma cópia do booleano com o iterador, por exemplo. Então eu acho que não afetaria o grande O, necessariamente. Admito, não fui linha por linha através das especificações. :)
Chris Beck
4
@Kevin: Hm, o que? Você não pode xor dois ponteiros diretamente de qualquer maneira, é necessário convertê-los em números inteiros primeiro (obviamente do tipo std::uintptr_t. Então você pode xor-los.
Deduplicator
3
@ Kevin, você definitivamente poderia fazer uma lista vinculada ao XOR em C ++, é praticamente a linguagem poster-child para esse tipo de coisa. Nada diz que você precisa usar std::uintptr_t, você pode converter para uma charmatriz e depois XOR os componentes. Seria mais lento, mas 100% portátil. É provável que você possa selecioná-lo entre essas duas implementações e usar apenas o segundo como substituto se uintptr_testiver ausente. Alguns se ele é descrito nesta resposta: stackoverflow.com/questions/14243971/...
Chris Beck
61

Ele poderia ser O (1) se a lista seria armazenar uma bandeira que permite trocar o significado dos “ prev” e “ next” ponteiros Cada nó tem. Se a reversão da lista for uma operação frequente, essa adição pode ser realmente útil e não conheço nenhum motivo para implementá-la seria proibida pelo padrão atual. No entanto, ter essa bandeira tornaria a travessia comum da lista mais cara (mesmo que apenas por um fator constante), porque em vez de

current = current->next;

no operator++iterador da lista, você obteria

if (reversed)
  current = current->prev;
else
  current = current->next;

o que não é algo que você decida adicionar facilmente. Dado que as listas geralmente são percorridas com muito mais frequência do que são revertidas, seria muito imprudente o padrão exigir essa técnica. Portanto, a operação reversa pode ter complexidade linear. Observe, no entanto, que t O (1) ⇒ tO ( n ), portanto, como mencionado anteriormente, implementar tecnicamente sua “otimização” seria permitido.

Se você tem um plano de fundo Java ou similar, pode se perguntar por que o iterador deve verificar o sinalizador a cada vez. Em vez disso, não poderíamos ter dois tipos de iteradores distintos, ambos derivados de um tipo de base comum std::list::begine std::list::rbegindevolver e polimorficamente o iterador apropriado? Embora possível, isso tornaria tudo ainda pior, porque o avanço do iterador seria uma chamada de função indireta (difícil de incorporar) agora. Em Java, você está pagando esse preço rotineiramente de qualquer maneira, mas, novamente, esse é um dos motivos pelos quais muitas pessoas acessam o C ++ quando o desempenho é crítico.

Como apontado por Benjamin Lindley nos comentários, desdereverse não é permitido invalidar iteradores, a única abordagem permitida pelo padrão parece ser armazenar um ponteiro de volta à lista dentro do iterador, o que causa um acesso à memória indireto duplo.

5gon12eder
fonte
7
@ galinette: std::list::reversenão invalida os iteradores.
Benjamin Lindley
1
@ galinette Desculpe, li mal o seu comentário anterior como "sinalizador por iterador ", em vez de "sinalizador por ", como você o escreveu. Obviamente, um sinalizador por seria contraproducente, pois você teria que fazer uma travessia linear para inverter todos eles.
Feb12
2
@ 5gon12eder: Você pode eliminar a ramificação a um custo muito perdido: armazene os ponteiros nexte prevem uma matriz e armazene a direção como 0ou 1. Para iterar para a frente, você deve seguir pointers[direction]e iterar no sentido inverso pointers[1-direction](ou vice-versa). Isso ainda adicionaria um pouco de sobrecarga, mas provavelmente menos que um ramo.
Jerry Coffin
4
Você provavelmente não pode armazenar um ponteiro para a lista dentro dos iteradores. swap()é especificado como um tempo constante e não invalida nenhum iterador.
Tavian Barnes
1
@TavianBarnes Damn it! Bem, indireto triplo então ... (quero dizer, não realmente triple Você teria que armazenar a bandeira em um objeto alocada dinamicamente mas o ponteiro no iterador lata de ponto claro diretamente a esse objeto em vez de indirecting sobre a lista..)
5gon12eder
37

Certamente, como todos os contêineres que suportam iteradores bidirecionais têm o conceito de rbegin () e rend (), essa pergunta é discutível?

É trivial criar um proxy que inverta os iteradores e acesse o contêiner por meio dele.

Essa não operação é de fato O (1).

tal como:

#include <iostream>
#include <list>
#include <string>
#include <iterator>

template<class Container>
struct reverse_proxy
{
    reverse_proxy(Container& c)
    : _c(c)
    {}

    auto begin() { return std::make_reverse_iterator(std::end(_c)); }
    auto end() { return std::make_reverse_iterator(std::begin(_c)); }

    auto begin() const { return std::make_reverse_iterator(std::end(_c)); }
    auto end() const { return std::make_reverse_iterator(std::begin(_c)); }

    Container& _c;
};

template<class Container>
auto reversed(Container& c)
{
    return reverse_proxy<Container>(c);
}

int main()
{
    using namespace std;
    list<string> l { "the", "cat", "sat", "on", "the", "mat" };

    auto r = reversed(l);
    copy(begin(r), end(r), ostream_iterator<string>(cout, "\n"));

    return 0;
}

resultado esperado:

mat
the
on
sat
cat
the

Diante disso, parece-me que o comitê de padrões não levou tempo para ordenar a ordem inversa O (1) do contêiner porque não é necessário, e a biblioteca padrão é amplamente construída com base no princípio de exigir apenas o estritamente necessário enquanto evitando duplicação.

Apenas meu 2c.

Richard Hodges
fonte
18

Porque ele precisa percorrer todos os nós ( ntotal) e atualizar seus dados (a etapa de atualização é realmente O(1)). Isso faz toda a operação O(n*1) = O(n).

Blindy
fonte
27
Porque você também precisa atualizar os links entre todos os itens. Retire um pedaço de papel e desenhe-o em vez de reduzir a votação.
Blindy
11
Por que perguntar se você tem tanta certeza então? Você está desperdiçando nosso tempo.
Blindy
28
Os nós da lista duplamente vinculada têm um senso de direção. Razão de lá.
Shoe
11
@ Blindy Uma boa resposta deve estar completa. "Pegue um pedaço de papel e tire-o", portanto, não deve ser um componente necessário para uma boa resposta. Respostas que não são boas respostas estão sujeitas a votos negativos.
RM
7
@Shoe: Eles precisam? Por favor, pesquise lista vinculada ao XOR e similares.
Deduplicator
2

Também troca o ponteiro anterior e o próximo para cada nó. É por isso que é preciso Linear. Embora isso possa ser feito em O (1), se a função que usa este LL também recebe informações sobre LL como entrada, como se estivesse acessando normalmente ou ao contrário.

techcomp
fonte
1

Apenas uma explicação do algoritmo. Imagine que você tem uma matriz com elementos e precisa invertê-la. A idéia básica é iterar em cada elemento, alterando o elemento da primeira posição para a última posição, o elemento da segunda posição para penúltima posição e assim por diante. Quando você alcança o meio da matriz, todos os elementos são alterados, assim nas iterações (n / 2), que são consideradas O (n).

danilobraga
fonte
1

É O (n) simplesmente porque precisa copiar a lista na ordem inversa. Cada operação de item individual é O (1), mas existem n na lista inteira.

É claro que existem algumas operações de tempo constante envolvidas na configuração do espaço para a nova lista e na mudança de ponteiros posteriormente, etc. A notação O não considera constantes individuais quando você inclui um fator n de primeira ordem.

SDsolar
fonte