Magia com XOR! Achando o elemento faltante de um array
Estava eu distraidamente assistindo este vídeo do Primeagen sobre magias de XOR, quando ele apresenta um problema (sem apresentar a solução):
Dado um vetor de tamanho
n-1
, preenchido com inteiros distintos do intervalo[1, n]
, ache o elemento faltante.
Pois bem, como fazer isso? São dois passos apenas:
function elementoFaltante(v: number[], n: number): number {
let xn = 0;
for (let i = 1; i <= n; i++) {
xn ^= i;
}
return v.reduce((a, b) => a ^ b) ^ xn;
}
E… voi là, resolvido.
Dá para ser mais ótimo ainda por sinal!
Tá, mas por quê?
Para isso ser a resposta, precisamos conhecer 4 propriedades relevantes do XOR.
A primeira é que todo elemento é seu oposto. Isto é, a ^ a = 0
sempre.
Não há casos especiais. Isso está na definição do ou exclusivo: um bit só é
verdade se ambos os bits operados tenham valores distintos. Caso tenham o mesmo
valor, seja ele verdadeiro ou falso, o retorno é falso. Operações bitwise são
aplicadas a cada um dos bits individualmente, logo todos os bits de a
, ao
aplicar a operação sobre si mesma, são zerados nesse processo.
A segunda propriedade interessante é que o 0 é o elemento neutro. Portanto,
para todo a
, a ^ 0 = a
. Inclusive 0.
Outra propriedade é que o XOR é comutativo. Isto é, a ^ b
é igual a b ^ a
.
De novo, não há exceções para isso. O XOR é uma operação binária que,
diferentemente do IMPLY a ordem não importa: se ambos forem iguais, não importa
alterar a ordem, e se ambos forem diferentes dá sempre verdade, logo é
comutativo.
E por fim, XOR é associativo. Isso quer dizer que (a ^ b) ^ c
tem o mesmo
valor do que a ^ (b ^ c)
. Isso nos dá 8 possibilidades de valores para as 3
variáveis:
a | b | c | a^b^c | a^b | b^c |
---|---|---|---|---|---|
0 | 0 | 0 | 0 | 0 | 0 |
1 | 0 | 0 | 1 | 1 | 0 |
0 | 1 | 0 | 1 | 0 | 1 |
1 | 1 | 0 | 0 | 0 | 1 |
0 | 0 | 1 | 1 | 0 | 1 |
1 | 0 | 1 | 0 | 1 | 1 |
0 | 1 | 1 | 0 | 1 | 0 |
1 | 1 | 1 | 1 | 0 | 0 |
O que nós podemos ver que a ordem em si em que são aplicadas as operações não importa.
Outro jeito de pensar é: se tiver uma quantidade par de bits ligados, vai ser falso, caso seja ímpar, dá verdadeiro. Contar é feito independente da ordem em que os elementos são agrupados na operação (e também garante a comutatividade), logo, como é equivalente à contagem e a contagem inerentemente vai ser associativa, então a operação é associativa.
Como temos aqui que as operações são associativas, comutativas e que
a ^ a = 0
, por que fizemos esses dois laços? Vamos lá.
Tudo é uma grande operação de XOR. O primeiro conjunto de XOR é de 1 até n.
Então temos aqui 1^2^...^(n-1)^n
. Depois, passamos a fazer o XOR com os
elementos do vetor. Sabemos que todos os elementos do vetor são distintos.
Vamos supor que esteja faltando o valor (n-1)
no vetor. Aqui também teremos
um grande XOR. Colocando os dois valores um embaixo do outro:
1..n : 1 ^ 2 ^ 3 ^ ... ^ (n-2) ^ (n-1) ^ n
vetor : 1 ^ 2 ^ 3 ^ ... ^ (n-2) ^ n
Vamos agora só por um espaço visual para que todos os elementos fiquem um debaixo do outro:
1..n : 1 ^ 2 ^ 3 ^ ... ^ (n-2) ^ (n-1) ^ n
vetor : 1 ^ 2 ^ 3 ^ ... ^ (n-2) ^ n
Como sabemos que é uma operação comutativa e associativa, podemos agrupar os elementos com os pares na vertical:
(1^1) ^ (2^2) ^ (3^3) ^ ... ^ ((n-2) ^ (n-2)) ^ ( (n-1) ) ^ (n^n)
Notou que todo mundo ficou com par, exceto o n-1
? Sabe o que acontece com XOR
de dois elementos iguais? Exatamente, eles se anulam, ficando assim:
(0) ^ (0) ^ (0) ^ ... ^ (0) ^ ( (n-1) ) ^ (0)
E o 0 é elemento neutro, portanto ficou apenas o n-1
no final. Isso da
eliminação dos repetidos iria acontecer com todos os números, independente de
qual número fosse o elemento faltante. E essa recombinação só foi possível por
conta da comutatividade e da associatividade da operação XOR.
Então, resumindo:
- pelas propriedades de comutatividade e associatividade, podemos agrupar os pares de elementos idênticos
- pela propriedade de que o elemento é o seu próprio inverso, todos os pares se aniquilam
- pela propriedade que o 0 é o elemento neutro, só resta o elemento que não se aniquilou
Assim demonstramos que, ao fazer o XOR de todos os elementos de [1..n]
e
depois de todos os elementos do vetor, obtemos o número faltante.
Otimizando ainda mais
Sabe uma coisa interessante sobre os XORs de [1..n]
? São duas coisas:
- como o 0 é elemento netro, dá no mesmo resultado fazer
[0..n]
- o XOR no intervalo
[0..4k-1]
, para umk
inteiro, é 0
O primeiro item é trivial, mas como provar o segundo item?
Para chegar lá, vou provar uma versão mais fraca disso antes: o XOR no
intervalo [4k..4k+3]
é 0 para um k
inteiro. Mas, se isso for verdade, isso
significa imediatamente que o item 2 é verdade. Então, vamos supor que isso
seja verdade.
O intervalo [4k..4k+3]
pode ser reescrito assim, para m=k+1
:
[4m-4..4m-1]
. Para m = 1
(equivalente a k = 0
), isso dá o intervalo
[0..3]
. Para m = 2
, temos o intervalo [4..7]
. Então, como a união de
[0..3] U [4..7] = [0..7]
, temos que o XOR nos intervalos menores dá 0
(afinal, estamos assumindo isso), e portanto o XOR dos resultados dos
intervalos também dá 0. Logo, se o XOR nos elementos de [4k..4k+3]
realmente
der 0, então temos que é verdade que o XOR de [0..4k]
é 0.
Ok, vamos agora demonstrar que aplicar o XOR no intervalo [4k..4k+3]
dá 0.
Os elementos que estamos fazendo XOR são:
4k
4k + 1
4k + 2
4k + 3
Como k
é um inteiro, 4k
vai ter necessariamente os dois bits menos
significativos zerados. Como os números de 0 a 3 interferem apenas nos dois
bits menos significativos, isso significa que podemos considerar esses
elementos de modo independentes.
Assim, temos o seguinte grande XOR:
4k ^ (4k + 1) ^ (4k + 2) ^ (4k + 3)
Devido a questão dos bits, podemos tratar 4k + 1
como 4k ^ 1
, e o mesmo
pensamento para 2 e 3:
4k ^ (4k ^ 1) ^ (4k ^ 2) ^ (4k ^ 3)
Reordenando, consigo fazer dois pares de 4k
:
(4k ^ 4k) ^ (4k ^ 4k) ^ 1 ^ 2 ^ 3
Logo, anulando os elementos idênticos:
1 ^ 2 ^ 3
Lembrando que, por 1 e 2 não compartilharem nenhum bit ligado, a operação de
soma e o XOR se tornam a mesma coisa, portanto 1 + 2 = 1 ^ 2 = 3
, daí:
3 ^ 3
E… eliminamos os repetidos. Portanto, aplicar XOR nos elementos de
[4k..4k+3]
sempre resulta em 0.
Utilizando essa otimização na prática
Basicamente, do intervalo [1..n]
, podemos eliminar todos os intervalos todos
os subintervalor no formato [4k..4k+3]
. Logo, para o maior k
possível tal
que 4k+3 < n
, ficamos apenas com o XOR no intervalo [4k+4..n]
.
Agora, precisamos achar esse k
? Na real, não. Se n % 4 = 3
, isso significa
que aplicar o XOR no intervalo restante, [4k+4..n]
, que pode ser escrito da
forma [4(k+1)..4x+3]
vai resultar em 0.
Agora, e se n % 4 = 0
? Bem, aqui n
vai ser o único número (pois todo o
intervalo [1..n-1]
vai virar 0 ao aplicar o XOR).
Para n % 4 = 1
, vamos ter que n = 4x + 1
, portanto os dois elementos
restantes são 4x
e 4x + 1
. Aplicar o XOR nesses dois resulta em 1
.
Agora, para n % 4 = 2
? Bem, podemos aproveitar o resultado anterior e fazer
um XOR a mais com o novo n
, que vai dar n + 1
.
Portanto, de acordo com n % 4
:
n % 4 | valor do XOR [0..n] |
---|---|
0 | n |
1 | 1 |
2 | n + 1 |
3 | 0 |
Logo, posso reescrever aquela função assim:
function elementoFaltante(v: number[], n: number): number {
let xn = 0;
switch (n % 4) {
case 0:
xn = n;
break;
case 1:
xn = 1;
break;
case 2:
xn = n + 1;
break;
case 3:
xn = 0;
break;
}
return v.reduce((a, b) => a ^ b) ^ xn;
}