Árbol Sqrt
Dado un arreglo que contiene elementos y la operación que satisface la propiedad asociativa: es cierta para cualquier , , .
Así, operaciones como , , , , , , , etc. satisfacen estas condiciones.
También tenemos algunas consultas . Para cada consulta, hay que calcular .
El Árbol Sqrt (Sqrt Tree) puede procesar tales consultas en tiempo con tiempo de preprocesamiento y memoria .
Descripción
Construir la descomposición por raíz cuadrada
Hagamos una descomposición por raíz cuadrada. Dividimos nuestro arreglo en bloques, cada bloque tiene tamaño . Para cada bloque, calculamos:
- Respuestas a las consultas que yacen en el bloque y empiezan al principio del bloque ()
- Respuestas a las consultas que yacen en el bloque y terminan al final del bloque ()
Y calcularemos un arreglo adicional:
- (para ) — respuesta a la consulta que empieza al inicio del bloque y termina al final del bloque . Nótese que tenemos bloques, así que el tamaño de este arreglo será .
Veamos el ejemplo.
Sea igual a (calculamos la suma en un segmento) y tenemos el siguiente arreglo :
{1, 2, 3, 4, 5, 6, 7, 8, 9}
Se dividirá en tres bloques: {1, 2, 3}, {4, 5, 6} y {7, 8, 9}.
Para el primer bloque es {1, 3, 6} y es {6, 5, 3}.
Para el segundo bloque es {4, 9, 15} y es {15, 11, 6}.
Para el tercer bloque es {7, 15, 24} y es {24, 17, 9}.
El arreglo es:
{
{6, 21, 45},
{0, 15, 39},
{0, 0, 24}
}(asumimos que los elementos inválidos donde se rellenan con ceros)
Es obvio ver que estos arreglos se pueden calcular fácilmente en tiempo y memoria .
Ya podemos responder algunas consultas usando estos arreglos. Si la consulta no cabe en un solo bloque, podemos partirla en tres partes: sufijo de un bloque, luego algún segmento de bloques contiguos y luego prefijo de algún bloque. Podemos responder una consulta partiéndola en tres partes y aplicando nuestra operación a algún valor de , luego algún valor de , luego algún valor de .
Pero si tenemos consultas que caben por completo en un solo bloque, no podemos procesarlas usando estos tres arreglos. Así que hay que hacer algo.
Hacer un árbol
No podemos responder solo las consultas que caben por completo en un bloque. ¿Pero qué si construimos la misma estructura descrita arriba para cada bloque? Sí, podemos hacerlo. Y lo hacemos de forma recursiva, hasta alcanzar el tamaño de bloque de o . Las respuestas para tales bloques se pueden calcular fácilmente en .
Así, obtenemos un árbol. Cada nodo del árbol representa algún segmento del arreglo. Un nodo que representa un segmento del arreglo de tamaño tiene hijos — uno por cada bloque. Además cada nodo contiene los tres arreglos descritos arriba para el segmento que contiene. La raíz del árbol representa todo el arreglo. Los nodos con longitudes de segmento o son hojas.
También es obvio que la altura de este árbol es , porque si algún vértice del árbol representa un arreglo de longitud , entonces sus hijos tienen longitud . , así que disminuye a la mitad en cada capa del árbol y por tanto su altura es . El tiempo de construcción y el uso de memoria serán , porque cada elemento del arreglo aparece exactamente una vez en cada capa del árbol.
Ahora podemos responder las consultas en . Podemos bajar por el árbol hasta encontrar un segmento de longitud o (la respuesta para él se puede calcular en tiempo ) o encontrar el primer segmento en el que nuestra consulta no cabe por completo en un solo bloque. Véase la primera sección sobre cómo responder la consulta en este caso.
Bien, ahora podemos hacer por consulta. ¿Se puede hacer más rápido?
Optimizar la complejidad de la consulta
Una de las optimizaciones más obvias es hacer búsqueda binaria del nodo del árbol que necesitamos. Usando búsqueda binaria, podemos alcanzar la complejidad por consulta. ¿Podemos hacerlo aún más rápido?
La respuesta es sí. Asumamos las siguientes dos cosas:
- Cada tamaño de bloque es una potencia de dos.
- Todos los bloques son iguales en cada capa.
Para lograr esto, podemos agregar algunos elementos cero a nuestro arreglo de modo que su tamaño se vuelva una potencia de dos.
Cuando usamos esto, algunos tamaños de bloque pueden volverse el doble de grandes para ser una potencia de dos, pero seguirán siendo en tamaño y mantenemos complejidad lineal para construir los arreglos en un segmento.
Ahora, podemos comprobar fácilmente si la consulta cabe por completo en un bloque de tamaño . Escribamos los rangos de la consulta, y (usamos indexación desde cero) en forma binaria. Por ejemplo, asumamos . La representación binaria de y es:
Recordemos que una capa contiene segmentos del mismo tamaño, y los bloques en una capa también tienen el mismo tamaño (en nuestro caso, su tamaño es . Los bloques cubren el arreglo por completo, así que el primer bloque cubre los elementos ( en binario), el segundo cubre los elementos ( en binario) y así sucesivamente. Vemos que los índices de las posiciones cubiertas por un bloque pueden diferir solo en los (en nuestro caso, ) últimos bits. En nuestro caso y tienen bits iguales excepto los cuatro más bajos, así que yacen en un mismo bloque.
Así, hay que comprobar si no difieren más que los bits más pequeños (o no excede ).
Usando esta observación, podemos encontrar una capa que sea adecuada para responder la consulta de forma rápida. Cómo hacerlo:
-
Para cada que no exceda el tamaño del arreglo, encontramos el bit más alto que es igual a . Para hacerlo rápido, usamos DP y un arreglo precalculado.
-
Ahora, para cada encontramos el bit más alto de y, usando esta información, es fácil elegir la capa en la que podemos procesar la consulta fácilmente. También podemos usar un arreglo precalculado aquí.
Para más detalles, véase el código de abajo.
Así, usando esto, podemos responder las consultas en cada una. ¡Hurra! :)
Actualizar elementos
También podemos actualizar elementos en el Árbol Sqrt. Se soportan tanto actualizaciones de un solo elemento como actualizaciones sobre un segmento.
Actualizar un solo elemento
Consideremos una consulta que hace la asignación . Hay que realizar esta consulta lo bastante rápido.
Enfoque naive
Primero, veamos qué cambia en el árbol cuando cambia un solo elemento. Consideremos un nodo del árbol de longitud y sus arreglos: , y . Es fácil ver que solo cambian elementos de y (solo dentro del bloque con el elemento cambiado). Se cambian elementos en . Por lo tanto, se actualizan elementos en el nodo del árbol.
Recordamos que cualquier elemento está presente en exactamente un nodo del árbol en cada capa. El nodo raíz (capa ) tiene longitud , los nodos en la capa tienen longitud , los nodos en la capa tienen longitud , etc. Así que la complejidad temporal por actualización es .
Pero es demasiado lento. ¿Se puede hacer más rápido?
Un Árbol Sqrt dentro del Árbol Sqrt
Nótese que el cuello de botella de la actualización es reconstruir del nodo raíz. Para optimizar el árbol, ¡deshagámonos de este arreglo! En lugar del arreglo , guardamos otro Árbol Sqrt para el nodo raíz. Llamémoslo . Juega el mismo papel que : responde las consultas sobre segmentos de bloques. Nótese que el resto de los nodos del árbol no tienen , mantienen sus arreglos .
Un Árbol Sqrt está indexado si su nodo raíz tiene . Un Árbol Sqrt con arreglo en su nodo raíz está no indexado. Nótese que está no indexado él mismo.
Así, tenemos el siguiente algoritmo para actualizar un árbol indexado:
-
Actualizar y en .
-
Actualizar . Tiene longitud y hay que actualizar solo un ítem en él (el que representa el bloque cambiado). Así, la complejidad temporal de este paso es . Podemos usar el algoritmo descrito al principio de esta sección (el “lento”) para hacerlo.
-
Ir al nodo hijo que representa el bloque cambiado y actualizarlo en con el algoritmo “lento”.
Nótese que la complejidad de la consulta sigue siendo : hay que usar en la consulta no más de una vez, y esto tomará tiempo .
Así, la complejidad temporal total para actualizar un solo elemento es . ¡Hurra! :)
Actualizar un segmento
El Árbol Sqrt también puede hacer cosas como asignar un elemento sobre un segmento. significa para todo .
Hay dos enfoques para hacer esto: uno de ellos hace en , manteniendo por consulta. El segundo hace en , pero la complejidad de la consulta se vuelve .
Haremos propagación perezosa de la misma forma que se hace en los Árboles de Segmentos: marcamos algunos nodos como lazy, lo que significa que los empujaremos cuando sea necesario. Pero una cosa es distinta de los Árboles de Segmentos: empujar un nodo es caro, así que no se puede hacer en las consultas. En la capa , empujar un nodo toma tiempo . Así, no empujamos nodos dentro de las consultas, solo miramos si el nodo actual o su padre están lazy, y simplemente lo tenemos en cuenta al realizar las consultas.
Primer enfoque
En el primer enfoque, decimos que solo los nodos de la capa (con longitud ) pueden ser lazy. Al empujar tal nodo, actualiza todo su subárbol incluyéndose a sí mismo en . El proceso se hace de la siguiente forma:
-
Considerar los nodos de la capa y los bloques correspondientes a ellos.
-
Algunos bloques están cubiertos por completo por . Marcarlos como lazy en .
-
Algunos bloques están cubiertos de forma parcial. Nótese que no hay más de dos bloques de este tipo. Reconstruirlos en . Si estaban lazy, tenerlo en cuenta.
-
Actualizar y para los bloques cubiertos de forma parcial en (porque solo hay dos bloques de ese tipo).
-
Reconstruir el en .
Así podemos hacer de forma rápida. ¿Pero cómo afecta la propagación perezosa a las consultas? Tendrán las siguientes modificaciones:
-
Si nuestra consulta yace por completo en un bloque lazy, calcularla y tener en cuenta lazy. .
-
Si nuestra consulta consiste de muchos bloques, algunos de los cuales son lazy, hay que ocuparse de lazy solo en el bloque más a la izquierda y el más a la derecha. El resto de los bloques se calculan usando , que ya conoce la respuesta sobre el bloque lazy (porque se reconstruye después de cada modificación). .
La complejidad de la consulta sigue siendo .
Segundo enfoque
En este enfoque, cada nodo puede ser lazy (excepto la raíz). Incluso los nodos en pueden ser lazy. Así, al procesar una consulta, tenemos que buscar etiquetas lazy en todos los nodos padre, es decir, la complejidad de la consulta será .
Pero se vuelve más rápido. Se ve de la siguiente forma:
-
Algunos bloques están cubiertos por completo con . Así, se les agregan etiquetas lazy. Es .
-
Actualizar y para los bloques cubiertos de forma parcial en (porque solo hay dos bloques de ese tipo).
-
No olvidar actualizar el índice. Es (usamos el mismo algoritmo ).
-
Actualizar el arreglo para los subárboles no indexados.
-
Ir a los nodos que representan los bloques cubiertos de forma parcial y llamar a de forma recursiva.
Nótese que cuando hacemos la llamada recursiva, hacemos de prefijo o de sufijo. Pero para actualizaciones de prefijo y de sufijo no podemos tener más de un hijo cubierto de forma parcial. Así, visitamos un nodo en la capa , dos nodos en la capa y dos nodos en cualquier nivel más profundo. Así, la complejidad temporal es . El enfoque aquí es similar a la actualización masiva del Árbol de Segmentos.
Implementación
La siguiente implementación del Árbol Sqrt puede realizar las siguientes operaciones: construir en , responder consultas en y actualizar un elemento en .
SqrtTreeItem op(const SqrtTreeItem &a, const SqrtTreeItem &b);
inline int log2Up(int n) {
int res = 0;
while ((1 << res) < n) {
res++;
}
return res;
}
class SqrtTree {
private:
int n, lg, indexSz;
vector<SqrtTreeItem> v;
vector<int> clz, layers, onLayer;
vector< vector<SqrtTreeItem> > pref, suf, between;
inline void buildBlock(int layer, int l, int r) {
pref[layer][l] = v[l];
for (int i = l+1; i < r; i++) {
pref[layer][i] = op(pref[layer][i-1], v[i]);
}
suf[layer][r-1] = v[r-1];
for (int i = r-2; i >= l; i--) {
suf[layer][i] = op(v[i], suf[layer][i+1]);
}
}
inline void buildBetween(int layer, int lBound, int rBound, int betweenOffs) {
int bSzLog = (layers[layer]+1) >> 1;
int bCntLog = layers[layer] >> 1;
int bSz = 1 << bSzLog;
int bCnt = (rBound - lBound + bSz - 1) >> bSzLog;
for (int i = 0; i < bCnt; i++) {
SqrtTreeItem ans;
for (int j = i; j < bCnt; j++) {
SqrtTreeItem add = suf[layer][lBound + (j << bSzLog)];
ans = (i == j) ? add : op(ans, add);
between[layer-1][betweenOffs + lBound + (i << bCntLog) + j] = ans;
}
}
}
inline void buildBetweenZero() {
int bSzLog = (lg+1) >> 1;
for (int i = 0; i < indexSz; i++) {
v[n+i] = suf[0][i << bSzLog];
}
build(1, n, n + indexSz, (1 << lg) - n);
}
inline void updateBetweenZero(int bid) {
int bSzLog = (lg+1) >> 1;
v[n+bid] = suf[0][bid << bSzLog];
update(1, n, n + indexSz, (1 << lg) - n, n+bid);
}
void build(int layer, int lBound, int rBound, int betweenOffs) {
if (layer >= (int)layers.size()) {
return;
}
int bSz = 1 << ((layers[layer]+1) >> 1);
for (int l = lBound; l < rBound; l += bSz) {
int r = min(l + bSz, rBound);
buildBlock(layer, l, r);
build(layer+1, l, r, betweenOffs);
}
if (layer == 0) {
buildBetweenZero();
} else {
buildBetween(layer, lBound, rBound, betweenOffs);
}
}
void update(int layer, int lBound, int rBound, int betweenOffs, int x) {
if (layer >= (int)layers.size()) {
return;
}
int bSzLog = (layers[layer]+1) >> 1;
int bSz = 1 << bSzLog;
int blockIdx = (x - lBound) >> bSzLog;
int l = lBound + (blockIdx << bSzLog);
int r = min(l + bSz, rBound);
buildBlock(layer, l, r);
if (layer == 0) {
updateBetweenZero(blockIdx);
} else {
buildBetween(layer, lBound, rBound, betweenOffs);
}
update(layer+1, l, r, betweenOffs, x);
}
inline SqrtTreeItem query(int l, int r, int betweenOffs, int base) {
if (l == r) {
return v[l];
}
if (l + 1 == r) {
return op(v[l], v[r]);
}
int layer = onLayer[clz[(l - base) ^ (r - base)]];
int bSzLog = (layers[layer]+1) >> 1;
int bCntLog = layers[layer] >> 1;
int lBound = (((l - base) >> layers[layer]) << layers[layer]) + base;
int lBlock = ((l - lBound) >> bSzLog) + 1;
int rBlock = ((r - lBound) >> bSzLog) - 1;
SqrtTreeItem ans = suf[layer][l];
if (lBlock <= rBlock) {
SqrtTreeItem add = (layer == 0) ? (
query(n + lBlock, n + rBlock, (1 << lg) - n, n)
) : (
between[layer-1][betweenOffs + lBound + (lBlock << bCntLog) + rBlock]
);
ans = op(ans, add);
}
ans = op(ans, pref[layer][r]);
return ans;
}
public:
inline SqrtTreeItem query(int l, int r) {
return query(l, r, 0, 0);
}
inline void update(int x, const SqrtTreeItem &item) {
v[x] = item;
update(0, 0, n, 0, x);
}
SqrtTree(const vector<SqrtTreeItem>& a)
: n((int)a.size()), lg(log2Up(n)), v(a), clz(1 << lg), onLayer(lg+1) {
clz[0] = 0;
for (int i = 1; i < (int)clz.size(); i++) {
clz[i] = clz[i >> 1] + 1;
}
int tlg = lg;
while (tlg > 1) {
onLayer[tlg] = (int)layers.size();
layers.push_back(tlg);
tlg = (tlg+1) >> 1;
}
for (int i = lg-1; i >= 0; i--) {
onLayer[i] = max(onLayer[i], onLayer[i+1]);
}
int betweenLayers = max(0, (int)layers.size() - 1);
int bSzLog = (lg+1) >> 1;
int bSz = 1 << bSzLog;
indexSz = (n + bSz - 1) >> bSzLog;
v.resize(n + indexSz);
pref.assign(layers.size(), vector<SqrtTreeItem>(n + indexSz));
suf.assign(layers.size(), vector<SqrtTreeItem>(n + indexSz));
between.assign(betweenLayers, vector<SqrtTreeItem>((1 << lg) + bSz));
build(0, 0, n, 0);
}
};