Chats dans des boßtes ou structures de données compactes

image


Que dois-je faire si l'arborescence de recherche a atteint toute la mémoire RAM et est sur le point de rooter les racks voisins dans la salle des serveurs? Que faire d'un index inversé gourmand en ressources? Dois-je me connecter au développement Android si l'utilisateur reçoit «Mémoire du téléphone pleine» et que l'application ne représente que la moitié de la charge d'un conteneur important?


En général, est-il possible de compresser la structure de données afin qu'elle occupe beaucoup moins d'espace, mais ne perd pas ses avantages inhérents? Pour que l'accÚs à la table de hachage reste rapide et que l'arbre équilibré conserve ses propriétés. Oui tu peux! Pour cela, la direction de l'informatique «Structures de données succinctes» est apparue, explorant la représentation compacte des structures de données. Il se développe depuis la fin des années 80 et est actuellement à son apogée dans la gloire du big data et de la surcharge.


En attendant, y aura-t-il un héros sur Habr qui pourra re-parler trois fois de suite
[səkˈsÉȘƋkt]?


Porte vers le monde de la compacité


Ainsi, une structure de données est considérée comme compacte (succincte) si elle:


  • Il occupe un certain nombre de bits proches de la borne infĂ©rieure thĂ©orique de l'information.
  • Il ne nĂ©cessite pas de dĂ©ballage prĂ©liminaire pour une utilisation complĂšte.

Cela signifie que les algorithmes de compression sans perte n'ont rien à voir avec des structures de données compactes. AprÚs tout, ils impliquent la restauration de données à partir d'un état compressé pour le traitement.


Les implĂ©mentations familiĂšres et «traditionnelles» des graphiques, des tables de hachage et d'autres choses ne sont pas non plus bonnes. Prenez au moins des pointeurs vers des Ă©lĂ©ments enfants dans l'arborescence de recherche. Ils mangent des endroits dĂ©cents: O ( M N ) oĂč M - la longueur du pointeur, et N - le nombre de nƓuds dans l'arbre. Mais l'implĂ©mentation de l'arbre succinct nous permet d'amĂ©liorer le comportement asymptotique 2 N + o ( N ) qui est proche de la borne infĂ©rieure thĂ©orique 2 N - Θ ( l o g N ) pour le bois de N nƓuds. Avec la longueur du pointeur M = 8 octet cela signifie passer de O ( 8 N ) Ă  un ordre d'asymptotique complĂštement diffĂ©rent - juste 2 N , considĂ©rant que o ( N ) - valeur nĂ©gligeable par rapport Ă  N .


Les structures de données compactes (succinctes) sont des représentations compressées pour des vecteurs de bits, des ensembles multiples, des graphiques planaires et d'autres structures classiques préférées. Souvent, ils sont statiques, construits une seule fois et ne changent pas pendant l'utilisation. Il existe des exceptions - structures succinctes avec des opérations rapides d'ajout et de suppression d'éléments.


La plupart des structures compactes sont basĂ©es sur le concept du soi-disant dictionnaire indexable compact. C'est un cas particulier de bitmap (bitmap, bitset). Le bitmap lui-mĂȘme est idĂ©al pour vĂ©rifier si les Ă©lĂ©ments sont dans un certain ensemble. Si un Ă©lĂ©ment est inclus dans un ensemble, la valeur de bit Ă  un index donnĂ© est dĂ©finie sur 1, sinon, il est rĂ©initialisĂ© Ă  0. Un exemple essentiel est l'inode-bitmap ext4, UFS et d'autres systĂšmes de fichiers Unix. Il stocke des donnĂ©es sur les entrĂ©es de la table inode qui sont occupĂ©es et celles qui sont libres.


Un dictionnaire indexable compact est le mĂȘme bitmap, mais complĂ©tĂ© par deux opĂ©rations: classer et sĂ©lectionner. Ces opĂ©rations sont les Ă©lĂ©phants sur lesquels repose le monde succinct. En gros, le rang est un dĂ©compte du nombre d'Ă©lĂ©ments, et select est une recherche d'un Ă©lĂ©ment:


  • r a n k x ( i ) renvoie le nombre de bits Ă©gal Ă  x dont les indices se situent sur un segment [ 0 ; i ] . Depuis x - la valeur du bit, il peut alors ĂȘtre Ă©gal exclusivement Ă  0 ou 1.
  • s e l e c t x ( j ) indice de retour j peu Ă©gal Ă  x . Le bon sens dit qu'il n'y a pas d'occurrence nulle, il n'y a que la premiĂšre. Par consĂ©quent $ en ligne $ j> 0 $ en ligne $ : le calcul est effectuĂ© Ă  partir d'un. Aussi j ne peut pas dĂ©passer le nombre total de bits dans le dictionnaire Ă©gal Ă  x .

Supposons que nous ayons un dictionnaire indexable dans lequel 4 des 7 bits sont définis. Alors r a n k 1 et select1 prendra les valeurs suivantes:



Un exemple de dictionnaire indexable et son calcul rank1 , select1 .


Un lecteur attentif remarquera que sélectionner est l'inverse du rang. Si rang1(5)=4 alors select1(4)=5 .


Quelqu'un avait déjà vu à la vue de rank1(i) ? Et tout cela parce que cette opération généralise le poids de Hamming - le nombre de caractÚres non nuls dans la séquence. Pour les séquences binaires, le poids de Hamming est également appelé popcount (population count).


Le rang / sélection s'applique également aux bits rejetés. Voici un exemple de calcul rank0 et select0 pour des bits égaux à 0:



Un exemple de dictionnaire indexable compact et son calcul rank0 , select0 .


Vu un arbre en bitiks


Nous utilisons ces connaissances pour construire un arbre de prĂ©fixes compact! Les arbres de prĂ©fixe sont bons pour trouver des chaĂźnes par prĂ©fixe. Avec leur aide, une liste dĂ©roulante d'indices de recherche (sjest) est souvent implĂ©mentĂ©e. L'approche de la succinctisation de l'arbre des prĂ©fixes est extrĂȘmement gĂ©nĂ©ralisĂ©e et dĂ©montre au maximum tous les raisins secs des structures compactes. Contrairement Ă  un arbre binaire, pour lequel des formules particuliĂšres sont dĂ©rivĂ©es qui interfĂšrent avec l'image globale.


Trois méthodes de représentation compacte des arbres sont les plus populaires:


  • BP (parenthĂšses Ă©quilibrĂ©es) - sĂ©quences de parenthĂšses Ă©quilibrĂ©es.
  • DFUDS (sĂ©quence de degrĂ©s unaires en profondeur d'abord) - une sĂ©quence de nƓuds codĂ©s en unitĂ©s triĂ©s par recherche en profondeur.
  • LOUDS (sĂ©quences de degrĂ©s unaires ordonnĂ©es par niveau) - sĂ©quences de nƓuds codĂ©s en unitĂ©s triĂ©es par niveau.

Quelle est la chaĂźne logique suspecte de la traduction de «degrĂ© unaire» en «nƓud codĂ© unique»? Eh bien. Un degrĂ© unaire dans ces noms signifie un moyen de coder les nƓuds d'arbre avec une sĂ©quence d'unitĂ©s en fonction du nombre de nƓuds enfants, toujours avec un zĂ©ro dans la bande-annonce.


Ces trois mĂ©thodes de reprĂ©sentation des arbres sont unies par la prĂ©sence d'opĂ©rations rapides: trouver un parent; trouver le premier descendant; trouver le dernier descendant; Trouvez les nƓuds adjacents gauche et droit. La possibilitĂ© fondamentale et l'efficacitĂ© d'autres opĂ©rations diffĂšrent d'une mĂ©thode Ă  l'autre.


ArrĂȘtons-nous sur la mĂ©thode LOUDS. AprĂšs l'avoir compris, il ne sera pas difficile de traiter avec les deux autres. De plus, l'annĂ©e derniĂšre, les arbres LOUDS ont cĂ©lĂ©brĂ© leur 30e anniversaire! Des opĂ©rations supplĂ©mentaires utiles pour les arbres LOUDS sont implĂ©mentĂ©es dans O(1) : trouver le nombre de descendants du nƓud; calculer le nombre de descendants du nƓud parmi tous ses descendants (premier descendant, deuxiĂšme, i th, etc.); trouver i progĂ©niture. L'inconvĂ©nient de LOUDS est l'absence d'un algorithme efficace pour compter le nombre de nƓuds de sous-arbre.


L'essence de la mĂ©thode est simple: stocker les clĂ©s des nƓuds d'arbre et toutes les informations prĂ©cieuses dans un tableau rĂ©gulier, et reprĂ©senter la structure arborescente comme une sĂ©quence de bits. Au total, nous avons deux structures statiques. Mais il n'est pas nĂ©cessaire d'allouer de la mĂ©moire pour les pointeurs aux nƓuds d'arbre: les transitions entre eux sont implĂ©mentĂ©es Ă  l'aide de formules avec l'utilisation active de rank / select.


Avertissement, arborescence des préfixes:



Arbre de prĂ©fixe prĂȘt pour la compression Ă  l'aide de la mĂ©thode LOUDS.


Préparez l'arbre pour la présentation sous forme binaire:


  1. Attachez l'arbre Ă  la fausse racine. Il jouera son rĂŽle trĂšs prochainement.
  2. Nous numĂ©rotons tous les nƓuds de l'arbre, niveau par niveau, de gauche Ă  droite, comme dans BFS (recherche en largeur d'abord). La fausse racine est ignorĂ©e et la vraie est indexĂ©e par zĂ©ro.
  3. Encodez les nƓuds. Le nƓud d'arbre est codĂ© par une sĂ©quence d'unitĂ©s correspondant aux descendants directs, plus zĂ©ro. Si le nƓud a quatre enfants, alors il est codĂ© comme 11110, et si aucun - comme 0. La fausse racine est codĂ©e en premier. Il a un seul descendant, donc son code est 10.


Un arbre de prĂ©fixe avec des nƓuds numĂ©rotĂ©s. Les nƓuds sont codĂ©s.


Dans le processus de traversĂ©e d'arbre niveau par niveau, un dictionnaire indexable compact est formĂ© - une sĂ©quence de bits provenant de nƓuds codĂ©s collĂ©s de haut en bas et de gauche Ă  droite. Nous avons une sĂ©quence de 21 bits. Soit dit en passant, il est appelĂ© LBS (chaĂźne de bits LOUDS).



Dictionnaire indexable compact pour l'arborescence des préfixes.


L'arbre de prĂ©fixe LOUDS compact est construit. LBS pour bois avec N nƓuds (le faux ne compte pas) prend 2 N $ + 1 $ peu. La chose la plus intĂ©ressante reste - des formules pour parcourir un arbre transformĂ© en bitmap.


Recherchez le premier enfant . Transition à partir d'un nƓud i à son premier nƓud enfant s'effectue selon la formule:


firstChild(i)=select0(i+1)−i


i - il s'agit du numĂ©ro de nƓud dans la plaque prĂ©cĂ©dente apposĂ©e en violet.


Trouvez le premier descendant du nƓud avec l'index 3 (la lettre "a"):


firsthild(3)=select0(3+1)−3=select0(4)−3=9−3=6


Le premier nƓud enfant est Ă  l'index 6, et c'est la lettre «k». Nous appliquons la formule pour la racine de l'arbre:


firstChild(0)=select0(0+1)−0=select0(1)=1


Nous avons trouvĂ© une feuille avec l'index 1, la lettre «et». Converge! Il est devenu clair pourquoi une fausse racine Ă©tait nĂ©cessaire: pour la magie de l'indexation des nƓuds. Pour Ă©viter d'Ă©tranges erreurs avant de passer aux descendants du nƓud i Ce serait bien de connaĂźtre le nombre de ces descendants. En effet, pour les feuilles de l'arbre, ce qui n'est pas surprenant, la formule donne un rĂ©sultat insuffisant. Pour trouver le descendant suivant aprĂšs le premier, vous devez ajouter 1. À son index, c'est logique, car les descendants d'un nƓud sont toujours Ă  proximitĂ©, sans lacunes. Mais lorsque vous les parcourez, vous devez vous arrĂȘter Ă  temps - dĂ©terminer quel descendant est considĂ©rĂ© comme le dernier.


Rechercher le dernier descendant d'un nƓud i passe en deux temps: dĂ©terminer l'indice de la derniĂšre unitĂ© dans le code du nƓud - c'est lui qui dĂ©signe cet enfant puis dĂ©terminer l'indice de l'enfant lui-mĂȘme:


lastChildPos(i)=select0(i+2)−1


AprĂšs avoir reçu l'index de la derniĂšre unitĂ© dans le code de noeud, il faut vĂ©rifier que le bit Ă  cet index est bien positionnĂ©. Sinon, la conclusion se suggĂšre: c'est un nƓud sans descendance, une feuille. Si le bit est activĂ©, continuez ensuite:


lastChild(i)=rank1(lastChildPos(i)−1)


Trouvez le dernier descendant du nƓud 2 (la lettre "k").


lastChildPos(2)=select0(2+2)−1=select0(4)−1=9−1=8


Le bit à l'index 8 est 1, donc le nƓud 2 n'est pas une feuille, et nous pouvons trouver l'index de son dernier enfant:


lastChild(i)=rang1(8−1)=5


Le nombre de descendants. La façon la plus simple de dĂ©terminer le nombre de descendants est de soustraire l'index de son premier descendant de l'index du dernier descendant du nƓud et d'ajouter 1:


childrenCount(i)=lasthild(i)−firsthild(i)+1


Mais supposons que le nƓud i il y a un nƓud voisin i+1 situĂ© au mĂȘme niveau d'arbre que i . Ensuite, le nombre de descendants i peut ĂȘtre dĂ©fini comme la diffĂ©rence entre les indices des premiers descendants des nƓuds i+1 et i :


childrenCount(i)=firsthild(i+1)−firsthild(i)


Les descendants du nƓud sont Ă©galement numĂ©rotĂ©s sĂ©quentiellement. Si le premier descendant i Est-ce j puis le second - j+1 et ainsi de suite jusqu'au descendant d'un nƓud voisin Ă  ce niveau i+1 (le cas Ă©chĂ©ant).


Le nombre de descendants de la feuille «et» d'index 1 devrait ĂȘtre nul:


childrenCount(1)=(select0(2+1)−2)−(select0(1+1)−1)=3−3=0


Recherche parent d'un nƓud i organisĂ© par la formule:


parent(i)=rank0(select1(i+1)−1)−1


Nous allons l'utiliser pour rechercher le parent du nƓud 6 (la lettre «k»):


parent(6)=rank0(select1(7)−1)−1=rank0(9)−1=3


Il s'agit du nƓud 3, la lettre "a".


Avec la connaissance des formules des indices enfant et parent, il n'est pas difficile de faire le tour de l'arbre entier. L'essentiel est de ne pas oublier le traitement des conditions aux limites pour la racine et les feuilles.


Quelques kopecks sur les mĂ©thodes BP et DFUDS. Les deux mĂ©thodes ont des asymptotiques spatiales - 2N+o(N) peu pour le bois de N nƓuds, et les deux sont similaires dans la reprĂ©sentation d'un nƓud d'arbre sous la forme de crochets ouvrants et fermants.


BP (parenthĂšses Ă©quilibrĂ©es) convertit un arbre en une sĂ©quence de crochets, une paire pour chaque nƓud. Pour ce faire, l'arbre va en profondeur; chaque nƓud est visitĂ© deux fois. Lors de la premiĂšre visite, le crochet d'ouverture est enregistrĂ©, lors de la deuxiĂšme visite - le crochet de fermeture. Entre eux se trouvent des parenthĂšses d'enfants.


Il est pratique de reprĂ©senter la sĂ©quence de crochets sous la forme d'un bitmap, oĂč 1 est le crochet d'ouverture et 0 est le crochet de fermeture. Toutes les formules pour travailler avec BP sont affinĂ©es pour une recherche rapide. Contrairement Ă  LOUDS, BP vous permet de calculer rapidement la taille d'un sous-arbre et de dĂ©terminer l'ancĂȘtre commun le plus proche de deux nƓuds. Mais trouvez i -le descendant est beaucoup plus compliquĂ© que dans LOUDS.


DFUDS (sĂ©quence de degrĂ© unaire en profondeur d'abord) est similaire Ă  la fois BP et LOUDS. Avec BP, il associe une marche d'arbre en profondeur et sa reprĂ©sentation en bracket. Et le principe des parenthĂšses est le mĂȘme que le principe de codage des nƓuds dans LOUDS. Avant de parcourir l'arbre, nous ajoutons une parenthĂšse ouvrante Ă  la sĂ©quence de parenthĂšses Ă  l'avance. Ensuite, lors de la traversĂ©e des nƓuds, nous Ă©crivons des crochets ouverts en fonction du nombre de descendants, plus un fermant. Il s'avĂšre que la localitĂ© de stockage des descendants dans DFUDS est plus Ă©levĂ©e que celle de BP. Le calcul de la taille du sous-arbre et la recherche de l'ancĂȘtre commun le plus proche sont effectuĂ©s pour O(1) . Mais dĂ©terminer la hauteur du sous-arbre et trouver le parent sur j niveau - opĂ©rations lourdes pour DFUDS.


Pour plus de clarté, nous comparons les méthodes LOUDS, BP et DFUDS en utilisant le mini-arbre comme exemple.



Les nƓuds de l'arbre sont numĂ©rotĂ©s en orange comme s'ils marchaient en largeur (pour LOUDS), en bleu - comme en marchant en profondeur (pour BP et DFUDS).



Vues d'arbres LOUDS, BP et DFUDS.


Ne soyez pas surpris si vous voyez des diffĂ©rences dans les formules dans les Ɠuvres en anglais. Parmi les mathĂ©maticiens, il y a des amateurs d'indexation Ă  partir de l'un. Et certains dĂ©veloppeurs considĂšrent les mots rang et gamme consonant, donc ils font le rang la moitiĂ© du temps. [0;i) . En raison de la nĂ©cessitĂ© de maintenir la symĂ©trie de rang / sĂ©lection, ils calculent sĂ©lectionner(i) comment sĂ©lectionner(i+1) . Mais certaines formules sous cette forme semblent plus courtes.


Tableau clairsemé: secouez mais ne mélangez pas


Un tableau clairsemé est une autre structure littéralement créée pour la compression. La taille d'un tel tableau est parfois supérieure de plusieurs ordres de grandeur au nombre d'éléments remplis. Et les éléments vides prennent une valeur par défaut ou sont marqués avec quelque chose comme null. Un réseau clairsemé se profile à l'horizon chaque fois que nécessaire pour stocker de nombreux objets et les relations entre eux. Les graphiques d'amis sur les réseaux sociaux, les algorithmes de classement des moteurs de recherche, les tableaux de type Excel, les simulateurs de circuits électriques avec des milliards de transistors dans une puce viennent immédiatement à l'esprit.


Souvent, ces tableaux sont cyclopéens dans le style Lovecraftian, avec une implémentation naïve, ils ne rentrent pas dans la RAM, restant pratiquement vide. Selon les besoins en mémoire et en vitesse, les tableaux clairsemés se transforment en tables de hachage beaucoup plus compactes, listes de contiguïté, arbres binaires ... ou succinctes.


Disons que nous avons un tableau clairsemé de chaßnes. Attachez-y un dictionnaire indexable compact. Que va-t-il donner?



Tableau clairsemé avec un bitmap.


Maintenant, sans accĂ©der directement au tableau d'origine, il est facile de vĂ©rifier si un Ă©lĂ©ment y est prĂ©sent Ă  l'index d'intĂ©rĂȘt. Rien n'empĂȘche de rĂ©duire le tableau d'origine en jetant tous les Ă©lĂ©ments non remplis:



Un tableau sans éléments vides.


Calcul d'un index dans un tableau compressé. AprÚs avoir vérifié la présence d'un élément, il serait intéressant d'accéder à sa valeur dans le tableau d'origine, c'est-à-dire de mapper l'index i dans l'index du dictionnaire indexé j dans un tableau compressé. Pas étonnant que le rang soit utilisé pour cela:


j=rang1(i)−1


VĂ©rifions comment les choses se passent avec le 8Ăšme Ă©lĂ©ment: bitmap[8]=0 . Donc, dans le tableau d'origine, il n'y a pas un tel Ă©lĂ©ment. Et l'Ă©lĂ©ment 7? bitmap[7]=1 . Obtenez sa valeur: rang1(7)−1=3−1=2 . À l'index 2 est "go".


Calcul de l'index dans le tableau source. Certes, dans le tableau, vous devrez rechercher des Ă©lĂ©ments par valeur! Si les donnĂ©es ne sont pas triĂ©es, la recherche est rĂ©duite Ă  la recherche de O(N) oĂč N - le nombre d'Ă©lĂ©ments non vides. Pour l'Ă©lĂ©ment trouvĂ© j peut avoir besoin d'obtenir son index i comme si le tableau n'Ă©tait pas rĂ©duit. Pour ce faire, utilisez select, l'inverse de rank:


i=select1(j+1)


Par exemple, recherchez la ligne «C ++». Dans un tableau compact, il est situé à l'index 0. Et son index dans le tableau d'origine est select1(0+1)=3 .


Déjà sur un exemple avec des lignes d'économies de mémoire notables. Si le tableau est conçu pour stocker des classes avec de nombreux champs, les économies deviennent beaucoup plus importantes. De plus, la recherche dans une baie compacte est plus rapide que dans une baie large et clairsemée: elle est mieux mise en cache par le processeur. Un dictionnaire indexé sur les bits est plus susceptible de tenir dans une ligne de cache qu'un tableau régulier de nombres, de chaßnes ou de types personnalisés fantaisistes.


Bien sĂ»r pour le stockage 230 les Ă©lĂ©ments dĂ©crits ne conviennent pas. Son applicabilitĂ© se termine lĂ  oĂč les problĂšmes de croissance de l'indice commencent. Mais bien sĂ»r, cette mĂ©thode de compression des tableaux et ses variations a sa propre niche. Un exemple courant est la mise en Ɠuvre du protocole BitTorrent. Le bitmap contient des informations sur les segments de fichiers tĂ©lĂ©chargĂ©s et les pairs Ă©changent les index de leurs segments. Un exemple d'espace est les options de transmission de donnĂ©es segmentĂ©es qui sont utilisĂ©es par les rovers, Voyagers et la station New Horizons, labourant les espaces ouverts trans-Neptune.


Les exemples décrits de succinctisation d'un arbre de préfixes et d'un tableau clairsemé sont construits sur une base commune. Il est basé sur une croyance inébranlable en l'efficacité des opérations de rang / sélection. Sans elle, toute la théorie des structures compactes mais assez rapides éclate aux coutures. La pertinence d'utiliser des structures compactes en dehors des thÚses dépend du rang et de la vitesse de sélection.


En fait, ces opĂ©rations peuvent ĂȘtre mises en Ɠuvre de maniĂšre extrĂȘmement efficace: rang - pour O(1) ; sĂ©lectionner - pour O(log(logN)) , ce qui est Ă©galement presque constant. Et bien sĂ»r, ce n'est pas sans quelques astuces. Et comme il doit y avoir une lĂ©gĂšre sous-estimation dans tout travail avec un complot compliquĂ©, je m'arrĂȘterai ici.


Faits intéressants


Quelle est la limite infĂ©rieure thĂ©orique des ressources occupĂ©es en termes de thĂ©orie de l'information? Disons qu'une structure de donnĂ©es stocke beaucoup de N Ă©lĂ©ments. Pour les identifier sans collision, vous avez besoin d'un certain nombre de bits, pas moins de X=log2N . X et il y a cette borne trĂšs infĂ©rieure dĂ©terminĂ©e par la formule de Hartley. Dans certains cas particuliers, ayant des informations sur la nature des donnĂ©es stockĂ©es, la structure peut ĂȘtre compressĂ©e encore plus efficacement.


La chaßne succincte est-elle une structure de données? Il contient N et se termine par un caractÚre ASCII nul. Il faut donc N+1 lieux, et donc ... elle est succincte, et plus précisément implicite! Ce qui nous amÚne à la question suivante.


Toutes les structures succinctes sont-elles également compactes? Le domaine de recherche succinct définit jusqu'à trois types de structures compactes dont la complexité spatiale diffÚre:


  • compact - O(N) . ComplexitĂ© linĂ©aire de N - le nombre d'articles stockĂ©s. «» . . , : . , . , 0 , . O(2N) ( 2 , ) select .
  • succinct — N+o(N) . — , succinct data structures . : (Pascal string), . N+log(N) .
  • implicit — N+O(1) . , . : (heap) . N+1 .

? , , . succinct- . , -, FM-, RMQ (range minimum queries), LCP (longest common prefix), , succinct'. -.



, , . Et pas seulement ça. , , X, .


succinct — , «- ». Succinct — . , , succinct. , . (IME) Google, . MAPS.ME succinct- .


, . ., 97 % -: . 3 %.


Et ensuite?


, succinct:



, :


Source: https://habr.com/ru/post/fr479822/


All Articles