Notas de estudio de C ++ (tres) -árbol

1. Árbol

Para una gran cantidad de datos de entrada, el tiempo de acceso lineal de la lista enlazada es demasiado largo para ser utilizado. Esta sección presenta una estructura de datos simple cuyo tiempo de ejecución para la mayoría de las operaciones es O (logN) en promedio.

La estructura de datos en la que estamos involucrados se llama árbol de búsqueda binaria. El árbol de búsqueda binaria es la base de implementación de dos conjuntos de clases de colección de bibliotecas y mapas que se utilizan en muchas aplicaciones. Los árboles son un concepto abstracto muy útil en informática.

1.1 Conocimientos preliminares

Los árboles se pueden definir de varias formas. Una forma natural de definir árboles es el método recursivo. Un árbol es una colección de nodos. Este conjunto puede ser un conjunto vacío; si no es un conjunto vacío, el árbol consta de un nodo r llamado raíz y cero o más (sub) árboles no vacíos T_ {1}, T_ {2}, \ cdots, T_ {k}, cada uno de los cuales tiene una raíz Conectado por un borde dirigido desde la raíz r.

La raíz de cada subárbol se denomina hijo de la raíz r, y r es el padre de la raíz de cada subárbol. La siguiente figura muestra un árbol típico definido por recursividad.

Se puede encontrar a partir de la definición recursiva que un árbol es una colección de N nodos y N-1 bordes, y uno de los nodos se llama raíz. La conclusión de que hay N-1 aristas se deriva del hecho de que cada arista conecta un nodo con su padre, y todos los nodos, excepto el nodo raíz, tienen un padre (consulte la figura siguiente) ).

En el árbol de la figura anterior, el nodo A es la raíz. El nodo F tiene un padre A e hijos K, L y M. Cada nodo puede tener cualquier número de hijos o ningún hijo. Un nodo sin un hijo se llama nodo hoja. Los nodos de las hojas (hojas) en la figura anterior son B, C, H, I, P, Q, K, L, M y N. Los nodos con el mismo padre son nodos hermanos; por lo tanto, K, L y M son todos hermanos. Se puede utilizar un método similar para definir la relación entre abuelos y nietos.

La ruta de un nodo n_ {1}a n_ {k}un nodo se define como n_ {1}, n_ {2}, \ cdots, n_ {k}una secuencia de nodos de modo que, para 1 \ leq i <k, el nodo n_ {i}es n_ {i + 1}el padre. La longitud de la ruta es el número de aristas en la ruta, es decir, k-1. Hay una ruta de longitud 0 desde cada nodo hasta sí mismo. Tenga en cuenta que hay exactamente una ruta desde la raíz hasta cada nodo en un árbol.

Para cualquier nodo n_ {i}, n_ {i}la profundidad es n_ {i}la longitud de la ruta única desde la raíz . Por tanto, la profundidad de la raíz es cero. n_ {i}La altura es n_ {i}la longitud del camino más largo desde una hoja. Por lo tanto, la altura de todas las hojas es 0. La altura de un árbol es igual a la altura de su raíz. Para el árbol de la figura anterior, la profundidad de E es 1 y la altura es 2; la profundidad de F es 1 y la altura también es 1; la altura del árbol es 3. La profundidad de un árbol es igual a la profundidad de sus hojas más profundas; la profundidad es siempre igual a la altura del árbol.

Si hay de n_ {1}a n_ {2}un camino, n_ {1}es n_ {2}de un ancestro (ancestro) y n_ {2}es n_ {1}un descendiente (descendiente). Si n_ {1} \ neq n_ {2}, entonces  n_ {1}es n_ {2}el único antepasado verdadero (antepasado propio) y n_ {2}es n_ {1}un verdadero descendiente (descendiente apropiado).

1.1.1 Implementación del árbol

Una forma de implementar un árbol es tener algunas cadenas además de los datos en cada nodo para apuntar a cada hijo del nodo. Sin embargo, dado que el número de hijos de cada nodo puede variar mucho y no se conoce de antemano, no es factible establecer un enlace directo a cada nodo hijo en la estructura de datos porque generará demasiado espacio desperdiciado. De hecho, la solución es muy simple, coloque todos los hijos de cada nodo en la lista enlazada del nodo del árbol. El siguiente código es una declaración muy típica.

struct TreeNode
{
    Object  element;
    TreeNode  *firstChild;
    TreeNode  *nextSibling;
}

El código anterior muestra cómo se representa un árbol mediante este método de implementación. La flecha hacia abajo en la figura es la cadena que apunta a firstChild. La flecha de izquierda a derecha es la cadena que apunta a aingnextSibling. Debido a que hay demasiadas cadenas vacías, no se dibujan.

En el árbol que se muestra en la figura siguiente, el nodo E tiene una cadena que apunta al hermano (F) y la otra cadena apunta al hijo (I), y algunos nodos no tienen ambas cadenas.

1.1.2 Recorrido y aplicación del árbol

Hay muchas aplicaciones para árboles. Uno de los usos populares es para la estructura de directorios en muchos sistemas operativos comunes, incluidos UNIX y DOS. La siguiente figura es un directorio típico en el sistema de archivos UNIX.

La raíz de este directorio es / usr (el asterisco después del nombre indica que / usr es en sí mismo un directorio). / usr tiene tres hijos: mark, alex y bill, todos ellos directorios. Por tanto, / usr contiene tres directorios y ningún archivo normal. El nombre de archivo /usr/mark/book/ch1.r se obtiene a través del nodo hijo situado más a la izquierda tres veces seguidas. Cada "/" después del primer "/" representa un borde; el resultado es una ruta completa (nombre de ruta). Este sistema de archivos jerárquico es muy popular porque permite a los usuarios organizar los datos de forma lógica. No solo eso, dos archivos en diferentes directorios también pueden tener el mismo nombre, porque deben tener diferentes rutas de la raíz y, por lo tanto, diferentes nombres de ruta. Un directorio en el sistema de archivos UNIX es un archivo que contiene todos sus hijos, por lo que estos directorios se construyen casi exactamente de acuerdo con la declaración de tipo anterior. De hecho, según algunas versiones de UNIX, si el comando estándar para imprimir un archivo se aplica a un directorio, el nombre del archivo en el directorio se puede ver en la salida (junto con otra información no ASCII).

Suponga que queremos listar los nombres de todos los archivos en el directorio. El formato de salida es: d_ {i}un archivo con una profundidad de 1 se d_ {i}sangrará con una pestaña y se imprimirá su nombre. El algoritmo se da en el siguiente pseudocódigo:

void FileSystem::listAll(int depth = 0) const
{
printName( depth );  //Print the name of the object
if( isDirectory() )
     for each file c in this directory (for each child)
        c.listAll( depth + 1 );

}

Para mostrar la raíz sin sangría, la función recursiva listAll debe comenzar en la profundidad 0. La profundidad aquí es una variable de contabilidad interna, no el tipo de parámetros que la rutina de llamada puede esperar conocer. Por lo tanto, debe proporcionar un valor predeterminado de 0 para la profundidad.

La lógica del algoritmo es simple y fácil de entender. El nombre del objeto de archivo se imprime con un número apropiado de pestañas. Si es un directorio, entonces procesamos recursivamente todos sus hijos uno por uno. Estos hijos están a la misma profundidad, por lo que es necesario sangrarlos un espacio adicional. Todo el resultado es el siguiente:

/usr
   mark
      book
         ch1.r
         ch2.r
         ch3.r
      course 
         cop3530
             fall05
                 sy1.r
             spr06
                 sy1.r
             sum06
                 sy1.r
      junk
   alex
      junk
   bill 
      work
      course
          cop3212
              fall05
                  grades
                  prog1.r
                  prog2.r
              fall06
                  prog2.r
                  prog1.r
                  grades

Esta estrategia de recorrido se denomina recorrido de preorden (recorrido de preorden). En el recorrido de preorden, el procesamiento del nodo se realiza antes de que se procesen sus nodos secundarios. Cuando se ejecuta el programa, obviamente la primera línea se ejecuta exactamente una vez para cada nodo, porque cada nombre se genera una sola vez. Dado que la primera línea se ejecuta como máximo una vez para cada nodo, la segunda línea también debe ejecutarse una vez para cada nodo. No solo eso, la cuarta fila de cada nodo hijo de cada nodo solo se puede ejecutar una vez como máximo. Sin embargo, el número de hijos es exactamente uno menos que el número de nodos. Después de eso, cada vez que se ejecuta la cuarta línea, el ciclo for se repetirá una vez y se agregará cada vez que finalice el ciclo. Por tanto, la carga de trabajo total de cada nodo es constante. Si hay N nombres de archivo para generar, el tiempo de ejecución es O (N).

Otro método común es atravesar el postorder del árbol (recorrido del postorder). En el recorrido posterior al pedido, el trabajo en un nodo se realiza después de calcular sus nodos secundarios. Por ejemplo, la siguiente figura muestra la misma estructura de directorios que antes, donde el número entre paréntesis representa el número de bloques de disco ocupados por cada archivo.

Dado que los directorios son archivos en sí mismos, también tienen tamaños. Supongamos que queremos calcular el número total de bloques de disco ocupados por todos los archivos del árbol. El método más común es averiguar el número de bloques contenidos en los subdirectorios / usr / mark (30), / usr / alex (9) y / usr / bill (32). Por lo tanto, el número total de bloques de disco es el número total de bloques en el subdirectorio (71) más un bloque usado por / usr, para un total de 72 bloques. El siguiente tamaño de método de pseudocódigo implementa esta estrategia transversal.

int FileSystem::size ( ) const
{
    int totalSize = sizeOfThisFile( );
    
    if( isDirectory( ) )
       for each file c in this directory (for each child)
           totalSize += c.size( )

    return totalSize;
}

Si el objeto actual no es un directorio, el tamaño solo devuelve el número de bloques que ocupa. De lo contrario, la cantidad de bloques ocupados por el directorio se agregará a la cantidad de bloques encontrados por todos sus nodos secundarios (recursivamente). Para distinguir entre la estrategia de recorrido posterior al pedido y la estrategia de recorrido previo al pedido, el siguiente código muestra cómo este algoritmo genera el tamaño de cada directorio o archivo.

             ch1.r
             ch2.r
             ch3.r
          book
                     sy1.r
                  fall05
                     sy1.r
                  spr06
                     sy1.r
                  sum06
             cop3530
          course
          junk
       mark
          junk
       alex
          work
                     grades
                     prog1.r
                     prog2.r
                  fall05
                     prog2.r
                     prog1.r
                     grades
                  fall06
              cop3212
         course
       bill
/usr

1.2 establecer y mapear en la biblioteca estándar

En el Capítulo 3, se discuten el vector contenedor y la lista en STL, los cuales no son suficientes para la búsqueda. En consecuencia, STL proporciona dos contenedores adicionales, set y map, que garantizan la sobrecarga de tiempo logarítmica de las operaciones básicas (como inserción, eliminación y búsqueda).

1.2.1 conjunto

Un conjunto es un contenedor ordenado, que no permite la duplicación. Muchas rutinas para acceder a elementos en vectores y listas también se aplican a conjuntos. En particular, los tipos iterador y const_iterator están anidados en el conjunto, lo que permite atravesarlo. Varios métodos de vector y lista tienen exactamente el mismo nombre en el conjunto, incluido el comienzo, el final, el tamaño y el vacío.

Las operaciones específicas del conjunto son la inserción, eliminación y búsqueda básica eficientes.

La rutina de inserción se denomina apropiadamente insertar. Sin embargo, debido a que el conjunto no permite la duplicación, para la inserción, puede haber fallas de inserción. Por lo tanto, queremos que el tipo de retorno sea una variable booleana que pueda indicar esta situación. Sin embargo, insert devuelve un tipo mucho más complicado que el tipo bool. Esto se debe a que insert también devuelve un iterador para dar la posición de x cuando insert regresa. Este iterador apunta al elemento recién insertado o apunta a un elemento existente que provocó que la inserción fallara. Este iterador es muy útil, porque si conoce la ubicación del elemento, puede eliminarlo rápidamente. El nodo que contiene el artículo se puede obtener directamente, evitando así la operación de búsqueda.

STL define una plantilla de clase denominada par, que tiene dos miembros más primero y segundo que la estructura para acceder a los dos elementos del par. Aquí hay dos rutinas de inserción diferentes:

pair<iterator,bool>insert( const Object & x);
pair<iterator,bool>insert( iterator hint, const Object & x);

La ejecución de la inserción de un solo parámetro se muestra arriba. La inserción de dos parámetros permite una descripción clave de dónde se insertará x. Si la pista es precisa, entonces la inserción es rápida, generalmente O (1). Si no es preciso, debe utilizar el algoritmo de inserción convencional para completar, la ejecución en este momento es la misma que la inserción de un solo parámetro. Por ejemplo, usar la inserción de dos parámetros en el siguiente código es mucho más rápido que usar la inserción de un solo parámetro:

set<int>s;
for ( int i=0; i<1000000; i++)
    s.insert(s.end(),i);

Hay varias versiones de borrar:

int erase( const Object & x);
iterator erase( iterator itr);
iterator erase( iteratorstart, iteartor end);

El primer borrado de un solo parámetro borra x (si se encuentra) y luego devuelve el número de elementos borrados. Obviamente, el valor de retorno es 0 o 1. La ejecución del segundo borrado de un solo parámetro es exactamente la misma que en vector y lista. Elimina el objeto en la posición especificada por el iterador, el iterador devuelto apunta al elemento en la siguiente posición del itr inmediatamente antes de llamar a borrar, y luego invalida el itr, porque el itr en este momento ya no es útil. La ejecución del borrado de dos parámetros es la misma que en vector o lista. Elimina todos los elementos de principio a fin (sin incluir el final).

Para la búsqueda, set proporciona una rutina de búsqueda que es superior a las rutinas contiene que devuelven variables.Esta rutina devuelve un iterador para señalar la posición del elemento (apunte al identificador final si la búsqueda falla). Esto proporciona una cantidad considerable de información adicional sin consumir tiempo de ejecución. La forma de encontrar es la siguiente:

iterator find( const Object & x ) const;

De forma predeterminada, la operación de clasificación se implementa usando el objeto de función less <Objeto>, y el objeto de función se implementa llamando al operador en el Objeto. Otro esquema de clasificación alternativo puede ejemplificarse mediante una plantilla de conjunto con un tipo de objeto de función. Por ejemplo, puede generar un conjunto que almacena objetos de cadena e ignorar el caso de los caracteres mediante el objeto de función CaseInsensitiveCompare. En el siguiente código, el tamaño del conjunto s es 1.

set<string,CaseInsensitiveCompare> s;
s.insert( "hello" );s.insert("HeLLo");
cout<< "The size is: " << s.size() <<endl;

1.2.2 mapa

El mapa se utiliza para almacenar una colección ordenada de elementos que consta de claves y valores. La clave debe ser única, pero varias claves pueden corresponder al mismo valor. Por tanto, no es necesario que el valor sea único. Las claves del mapa mantienen el orden lógico.

La ejecución del mapa es similar al conjunto ejemplificado por par. La función de comparación solo involucra teclas. Por lo tanto, map admite begin, end, size y enmty, pero el iterador básico es un par clave-valor. En otras palabras, para el iterador itr, * itr es del par de tipos <KeyType, ValueType>. map también admite insertar, buscar y borrar. Para insertar, se debe proporcionar un objeto par <KeyType, ValueType>. Aunque encontrar solo requiere una clave, el iterador devuelto aún apunta a un par. Por lo general, no vale la pena usar estas operaciones, ya que conducirán a una carga de sintaxis costosa.

Afortunadamente, map tiene una operación adicional importante para obtener una sintaxis simple. Lo siguiente es una sobrecarga del operador de índice de matriz de map:

ValueType & operator[] ( const KeyType & key );

La sintaxis del operador [] es la siguiente. Si hay una clave en el mapa, se devuelve una referencia al valor correspondiente. Si no hay ninguna clave en el mapa, inserte un valor predeterminado en el mapa y luego devuelva una referencia al valor predeterminado insertado. Este valor predeterminado se obtiene aplicando un constructor de argumento cero, o 0 si es un tipo básico. Estas sintaxis no permiten modificar la versión de la función del operador [], por lo que el operador [] no se puede utilizar para mapas constantes. Por ejemplo, si el mapa se pasa por referencia constante en la rutina, el operador [] no está disponible.

El fragmento de código de la figura siguiente ilustra dos técnicas para acceder a elementos del mapa. Primero observe la tercera línea, el operador [] se llama a la izquierda, así que inserte "Pat" y un doble con un valor de 0 en el mapa. También devuelve una referencia a este doble. Luego asigne el doble en el mapa a 75000. Línea 4 salidas 75000. Desafortunadamente, la quinta línea inserta "Jan" y salario "0.0" en el mapa y lo imprime. Esto puede o no obtener el resultado correcto, según la aplicación. Si es importante distinguir entre los elementos en el mapa y los elementos que no están en el mapa, o si no se insertan en el mapa (porque no se pueden modificar), se puede utilizar un método alternativo que se muestra en las líneas 7-12. Hay una llamada para encontrar. Si no se encuentra la clave, el iterador es el marcador final y se puede probar. Si no se encuentra la clave, podemos acceder al segundo elemento referenciado por el iterador en el par, que es el valor correspondiente a la clave. Si itr es un iterador en lugar de const_iterator, puede asignar itr-> second.

map<string,double>salaries;

salaries[ "Pat" ] = 75000.00;
cout << salsries[ "Pat" ] << endl;
cout << salsries[ "Jan" ] << endl;

map<string,double>::const_iterator itr;
itr = salaries.find( "Chris" );
if( itr == salaries.end( ) )
   cout << "Not an employee of this company!" << endl;
else
   cout << itr->second << endl;

 

Supongo que te gusta

Origin blog.csdn.net/weixin_38452841/article/details/109093176
Recomendado
Clasificación