Алгоритм N-шляхового злиття


79

Двостороннє злиття широко вивчається як частина алгоритму Mergesort. Але мені цікаво дізнатись, як найкраще можна здійснити N-way злиття?

Скажімо, у мене є Nфайли, які відсортували 1 мільйон цілих чисел кожен. Я повинен об’єднати їх в один єдиний файл, який матиме ці 100 мільйонів відсортованих цілих чисел.

Будь ласка, майте на увазі, що випадком використання цієї проблеми є фактично зовнішнє сортування, яке базується на диску. Тому в реальних сценаріях також існують обмеження пам'яті. Тож наївний підхід об’єднання 2 файлів одночасно (99 разів) не спрацює. Скажімо, у нас є лише невелике розсувне вікно пам’яті, доступне для кожного масиву.

Я не впевнений, що вже існує стандартизоване рішення для цього N-стороннього злиття. (Гугління мені мало що сказав) .

Але якщо ви знаєте, чи хороший алгоритм злиття з n-шляхів, надішліть algo / link.

Складність у часі: якщо ми значно збільшимо кількість файлів ( N), що об’єднуються, як це вплине на часову складність вашого алгоритму?

Дякую за відповіді.

Мене цього ніде не задавали, але я відчув, що це може бути цікавим питанням для інтерв’ю. Тому позначений.


Чому здвоєне злиття файлів не працює?

8
Для запису це відоме як балансова лінія або злиття в сторону k. Алгоритми балансової лінії, як правило, мають O (kn) часову складність, де k - кількість файлів, а n - загальна кількість елементів, тоді як кучеві k-way злиття, як правило, O (n log k). Обидва алгоритми мають складність простору O (k).

@paul, добре, я повинен визнати, що я помилився, сказавши, що об'єднання 2 файлів одночасно не буде працювати з міркувань щодо простору. Але, на мою думку, це був би дуже повільний альго, тому він не буде працювати.
біти

@Bavarious, чи можете ви сказати, чому ви думаєте, що подібне злиття є O (kn)? Мені здається, це O (n log k) (оскільки злиття кожної пари - O (n), і це потрібно робити O (log k) разів, щоб зменшити до одного файлу). Він також використовує лише пробіл O (1).

Рядок балансу @PaulHankin зберігає невідсортований масив (замість купи) з останнім ключем, прочитаним з кожного вхідного файлу, отже, k як в O (kn), так і в O (k). Це досить добре для малих k.

Відповіді:


79

Як щодо наступної ідеї:

  1. Створити пріоритетну чергу

  2. Перебирайте кожен файл f
    1. поставити пару в чергу (nextNumberIn (f), f), використовуючи перше значення як ключ пріоритету

  3. Поки черга не порожня
    1. вивільнення голови (m, f) черги
    2. вихід m
    3. якщо f не виснажується
      1. Enqueue (nextNumberIn (е), е)

Оскільки додавання елементів до черги пріоритетів може здійснюватися за логарифмічним часом, елемент 2 - O (N × log N) . Оскільки (майже всі) ітерації циклу while додають елемент, то весь цикл while є O (M × log N), де M - загальна кількість чисел для сортування.

Якщо припустити, що всі файли мають непусту послідовність чисел, ми маємо M> N і, отже, весь алгоритм повинен мати значення O (M × log N) .


Дуже акуратно. Я думав про подібні рядки, за винятком, що поєднання числа з файлом мені не спало на думку. Дякую. Приймаючи.
біти

1
@aioobe: Ваше рішення є акуратним, але ви робите багато дзвінків для читання, що може зашкодити ефективності. Наприклад, у пункті 3 під час кожної ітерації ви робите виклик читання для наступного елемента в f. Я думаю, буде краще, якщо ви зміните умову if на наступне: якщо f не присутній у черзі та f не вичерпано. Таким чином ви зробите читання лише у тому випадку, якщо в черзі немає елемента f. Більше того, коли ви робите це читання, ви можете відразу прочитати шматок цифр у f.
Програміст

7
" якщо f не присутній у черзі ", він ніколи не присутній після зняття з черги, оскільки в черзі завжди є щонайбільше одне значення з кожного файлу. Щодо Вашого другого коментаря: Ваша пропозиція не покращує складності (за винятком того, що робить відповідь більш складною для читання!) Крім того, майте на увазі, що це псевдокод. Це цілком може бути реалізовано за допомогою буферизованого зчитувача, який читає більші фрагменти та кешує їх.
aioobe

2
@Programmer, я думаю, у вас є хороша думка щодо кількості читань, але вам насправді не потрібно реалізовувати "якщо f не присутній у черзі"; Ви можете просто буферизувати f і використовувати алгоритм aioobe як є, зчитуючи значення f через буфер.
enobayram

1
@ RestlessC0bra, ні, крок другий вставляє перше число у кожен файл. На кроці 3 (цикл while) значення виводиться з черги, а наступне значення з цього файлу ставиться в чергу (якщо цей файл не вичерпано). Все ще незрозуміло?
aioobe

12

Шукайте "Поліфазне злиття", перегляньте класику - Дональд Кнут та EHFriend.

Також, можливо, ви захочете поглянути на запропоноване Інтелектуальне об’єднання блоків Seyedafsari & Hasanzadeh, яке, подібно до попередніх пропозицій, використовує черги пріоритетів.

Інші цікаві міркування - це « Алгоритм злиття на місці» Кім і Куцнер.

Я також рекомендую цю статтю Віттера: Алгоритми зовнішньої пам'яті та структури даних: робота з масивними даними .


2
Посилання Smart Block Merging є неправильним. Це якась стаття про ланцюги поставок в Естонії.
Джеремі Сальвен,

Джеремі, дякую, що вказав на це. У 2012 році, схоже, хост waset.org перевизначив ці файли (спочатку опубліковані в 2010 році) новими матеріалами конференцій. Я не можу знайти стару статтю. Якщо у когось є, будь ласка, опублікуйте / посилання.
Григорій Мельник

1
Це нове посилання: waset.org/publications/9638/…
Хенгаме

6

Одною простою ідеєю є збереження черги пріоритетів діапазонів для злиття, збережених таким чином, що діапазон з найменшим першим елементом видаляється першим із черги. Потім ви можете зробити N-way злиття наступним чином:

  1. Вставте всі діапазони в чергу пріоритетів, за винятком порожніх діапазонів.
  2. Поки черга пріоритетів не порожня:
    1. Зняти з черги найменший елемент.
    2. Додайте перший елемент цього діапазону до вихідної послідовності.
    3. Якщо він не пустий, вставте решту послідовності назад у чергу пріоритетів.

Правильність цього алгоритму є по суті узагальненням доказу того, що двостороннє злиття працює правильно - якщо ви завжди додаєте найменший елемент із будь-якого діапазону, і всі діапазони відсортовані, ви отримуєте послідовність у цілому відсортовану.

Складність виконання цього алгоритму можна знайти наступним чином. Нехай M - загальна кількість елементів у всіх послідовностях. Якщо ми використовуємо двійкову купу, то робимо не більше O (M) вставок та O (M) видалень з черги пріоритетів, оскільки для кожного елемента, записаного у вихідну послідовність, існує чергу, щоб витягнути найменшу послідовність, за якою слід enqueue, щоб повернути решту послідовності назад у чергу. Кожен із цих кроків виконує операції O (lg N), оскільки для вставки або видалення з двійкової купи з N елементами потрібен час O (lg N). Це дає чистий час роботи O (M lg N), який зростає менше, ніж лінійно, із кількістю вхідних послідовностей.

Можливо, є спосіб досягти цього ще швидше, але це здається досить хорошим рішенням. Використання пам’яті - O (N), оскільки нам потрібні накладні витрати O (N) для двійкової купи. Якщо ми реалізуємо двійкову купу, зберігаючи вказівники на послідовності, а не на самі послідовності, це не повинно бути великою проблемою, якщо у вас немає справді смішної кількості послідовностей для об'єднання. У такому випадку просто об’єднайте їх у групи, які вписуються в пам’ять, а потім об’єднайте всі результати.

Сподіваюся, це допомагає!


2

Простий підхід до об’єднання k відсортованих масивів (кожен довжиною n) вимагає часу O (nk ^ 2), а не часу O (nk). Оскільки, коли ви об'єднуєте перші 2 масиви, це займає 2n часу, тоді, коли ви об'єднуєте третій з результатом, це займає 3n час, оскільки зараз ми об'єднуємо два масиви довжиною 2n і n. Тепер, коли ми об'єднуємо цей вихід з четвертим, для цього злиття потрібно 4n час, отже останнє злиття (коли ми додаємо k-й масив до нашого вже відсортованого масиву) вимагає k * n часу. + ... k * n, що дорівнює O (nk ^ 2).

Схоже, ми можемо зробити це за час O (kn), але це не так, оскільки з кожним разом наш масив, який ми об’єднуємо, збільшується в розмірі.
Хоча ми можемо досягти кращого поєднання, використовуючи розділи і завоюй. Я все ще працюю над цим і публікую рішення, якщо знайду.


1

Див. Http://en.wikipedia.org/wiki/External_sorting . Ось мій погляд на куп-злиття на основі купи, використовуючи буферизоване зчитування з джерел для емуляції зменшення вводу-виводу:

public class KWayMerger<T>
{
    private readonly IList<T[]> _sources;
    private readonly int _bufferSize;
    private readonly MinHeap<MergeValue<T>> _mergeHeap;
    private readonly int[] _indices;

    public KWayMerger(IList<T[]> sources, int bufferSize, Comparer<T> comparer = null)
    {
        if (sources == null) throw new ArgumentNullException("sources");

        _sources = sources;
        _bufferSize = bufferSize;

        _mergeHeap = new MinHeap<MergeValue<T>>(
                      new MergeComparer<T>(comparer ?? Comparer<T>.Default));
        _indices = new int[sources.Count];
    }

    public T[] Merge()
    {
        for (int i = 0; i <= _sources.Count - 1; i++)
            AddToMergeHeap(i);

        var merged = new T[_sources.Sum(s => s.Length)];
        int mergeIndex = 0;

        while (_mergeHeap.Count > 0)
        {
            var min = _mergeHeap.ExtractDominating();
            merged[mergeIndex++] = min.Value;
            if (min.Source != -1) //the last item of the source was extracted
                AddToMergeHeap(min.Source);
        }

        return merged;
    }

    private void AddToMergeHeap(int sourceIndex)
    {
        var source = _sources[sourceIndex];
        var start = _indices[sourceIndex];
        var end = Math.Min(start + _bufferSize - 1, source.Length - 1);

        if (start > source.Length - 1)
            return; //we're done with this source

        for (int i = start; i <= end - 1; i++)
            _mergeHeap.Add(new MergeValue<T>(-1, source[i]));   

        //only the last item should trigger the next buffered read
        _mergeHeap.Add(new MergeValue<T>(sourceIndex, source[end]));

        _indices[sourceIndex] += _bufferSize; //we may have added less items, 
        //but if we did we've reached the end of the source so it doesn't matter
    } 
}

internal class MergeValue<T>
{
    public int Source { get; private set; }
    public T Value { get; private set; }

    public MergeValue(int source, T value)
    {
        Value = value;
        Source = source;
    }
}

internal class MergeComparer<T> : IComparer<MergeValue<T>>
{
    public Comparer<T> Comparer { get; private set; }

    public MergeComparer(Comparer<T> comparer)
    {
        if (comparer == null) throw new ArgumentNullException("comparer");
        Comparer = comparer;
    }

    public int Compare(MergeValue<T> x, MergeValue<T> y)
    {
        Debug.Assert(x != null && y != null);
        return Comparer.Compare(x.Value, y.Value);
    }
}

Ось одна з можливих реалізаційMinHeap<T> . Деякі тести:

[TestMethod]
public void TestKWaySort()
{
    var rand = new Random();
    for (int i = 0; i < 10; i++)
        AssertKwayMerge(rand);
}

private static void AssertKwayMerge(Random rand)
{
    var sources = new[]
        {
            GenerateRandomCollection(rand, 10, 30, 0, 30).OrderBy(i => i).ToArray(),
            GenerateRandomCollection(rand, 10, 30, 0, 30).OrderBy(i => i).ToArray(),
            GenerateRandomCollection(rand, 10, 30, 0, 30).OrderBy(i => i).ToArray(),
            GenerateRandomCollection(rand, 10, 30, 0, 30).OrderBy(i => i).ToArray(),
        };
    Assert.IsTrue(new KWayMerger<int>(sources, 20).Merge().SequenceEqual(sources.SelectMany(s => s).OrderBy(i => i)));
}

public static IEnumerable<int> GenerateRandomCollection(Random rand, int minLength, int maxLength, int min = 0, int max = int.MaxValue)
{
    return Enumerable.Repeat(0, rand.Next(minLength, maxLength)).Select(i => rand.Next(min, max));
}

Яка мова вашого коду? (Вибачте, я новачок у програмуванні; я шукаю рішення Java).
Хенгаме,

1
@Hengameh це C #. Синтаксис дуже схожий на Java, тому перекладати його не повинно бути надто складно.
Охад Шнайдер,

1

Я написав цей фрагмент коду в стилі STL, який поєднує N-шлях, і думав, що розміщу його тут, щоб запобігти іншим з нових винаходів колеса. :)

Попередження: це лише невелика перевірка. Перевірте перед використанням. :)

Ви можете використовувати його так:

#include <vector>

int main()
{
    std::vector<std::vector<int> > v;
    std::vector<std::vector<int>::iterator> vout;
    std::vector<int> v1;
    std::vector<int> v2;
    v1.push_back(1);
    v1.push_back(2);
    v1.push_back(3);
    v2.push_back(0);
    v2.push_back(1);
    v2.push_back(2);
    v.push_back(v1);
    v.push_back(v2);
    multiway_merge(v.begin(), v.end(), std::back_inserter(vout), false);
}

Це також дозволяє використовувати пари ітераторів замість самих контейнерів.

Якщо ви використовуєте Boost.Range, ви можете видалити частину коду шаблону.

Код:

#include <algorithm>
#include <functional>  // std::less
#include <iterator>
#include <queue>  // std::priority_queue
#include <utility>  // std::pair
#include <vector>

template<class OutIt>
struct multiway_merge_value_insert_iterator : public std::iterator<
    std::output_iterator_tag, OutIt, ptrdiff_t
>
{
    OutIt it;
    multiway_merge_value_insert_iterator(OutIt const it = OutIt())
        : it(it) { }

    multiway_merge_value_insert_iterator &operator++(int)
    { return *this; }

    multiway_merge_value_insert_iterator &operator++()
    { return *this; }

    multiway_merge_value_insert_iterator &operator *()
    { return *this; }

    template<class It>
    multiway_merge_value_insert_iterator &operator =(It const i)
    {
        *this->it = *i;
        ++this->it;
        return *this;
    }
};

template<class OutIt>
multiway_merge_value_insert_iterator<OutIt>
    multiway_merge_value_inserter(OutIt const it)
{ return multiway_merge_value_insert_iterator<OutIt>(it); };

template<class Less>
struct multiway_merge_value_less : private Less
{
    multiway_merge_value_less(Less const &less) : Less(less) { }
    template<class It1, class It2>
    bool operator()(
        std::pair<It1, It1> const &b /* inverted */,
        std::pair<It2, It2> const &a) const
    {
        return b.first != b.second && (
            a.first == a.second ||
            this->Less::operator()(*a.first, *b.first));
    }
};

struct multiway_merge_default_less
{
    template<class T>
    bool operator()(T const &a, T const &b) const
    { return std::less<T>()(a, b); }
};

template<class R>
struct multiway_merge_range_iterator
{ typedef typename R::iterator type; };

template<class R>
struct multiway_merge_range_iterator<R const>
{ typedef typename R::const_iterator type; };

template<class It>
struct multiway_merge_range_iterator<std::pair<It, It> >
{ typedef It type; };

template<class R>
typename R::iterator multiway_merge_range_begin(R &r)
{ return r.begin(); }

template<class R>
typename R::iterator multiway_merge_range_end(R &r)
{ return r.end(); }

template<class R>
typename R::const_iterator multiway_merge_range_begin(R const &r)
{ return r.begin(); }

template<class R>
typename R::const_iterator multiway_merge_range_end(R const &r)
{ return r.end(); }

template<class It>
It multiway_merge_range_begin(std::pair<It, It> const &r)
{ return r.first; }

template<class It>
It multiway_merge_range_end(std::pair<It, It> const &r)
{ return r.second; }

template<class It, class OutIt, class Less, class PQ>
OutIt multiway_merge(
    It begin, It const end, OutIt out, Less const &less,
    PQ &pq, bool const distinct = false)
{
    while (begin != end)
    {
        pq.push(typename PQ::value_type(
            multiway_merge_range_begin(*begin),
            multiway_merge_range_end(*begin)));
        ++begin;
    }
    while (!pq.empty())
    {
        typename PQ::value_type top = pq.top();
        pq.pop();
        if (top.first != top.second)
        {
            while (!pq.empty() && pq.top().first == pq.top().second)
            { pq.pop(); }
            if (!distinct ||
                pq.empty() ||
                less(*pq.top().first, *top.first) ||
                less(*top.first, *pq.top().first))
            {
                *out = top.first;
                ++out;
            }

            ++top.first;
            pq.push(top);
        }
    }
    return out;
}

template<class It, class OutIt, class Less>
OutIt multiway_merge(
    It const begin, It const end, OutIt out, Less const &less,
    bool const distinct = false)
{
    typedef typename multiway_merge_range_iterator<
        typename std::iterator_traits<It>::value_type
    >::type SubIt;
    if (std::distance(begin, end) < 16)
    {
        typedef std::vector<std::pair<SubIt, SubIt> > Remaining;
        Remaining remaining;
        remaining.reserve(
            static_cast<size_t>(std::distance(begin, end)));
        for (It i = begin; i != end; ++i)
        {
            if (multiway_merge_range_begin(*i) !=
                multiway_merge_range_end(*i))
            {
                remaining.push_back(std::make_pair(
                    multiway_merge_range_begin(*i),
                    multiway_merge_range_end(*i)));
            }
        }
        while (!remaining.empty())
        {
            typename Remaining::iterator smallest =
                remaining.begin();
            for (typename Remaining::iterator
                i = remaining.begin();
                i != remaining.end();
            )
            {
                if (less(*i->first, *smallest->first))
                {
                    smallest = i;
                    ++i;
                }
                else if (distinct && i != smallest &&
                    !less(
                        *smallest->first,
                        *i->first))
                {
                    i = remaining.erase(i);
                }
                else { ++i; }
            }
            *out = smallest->first;
            ++out;
            ++smallest->first;
            if (smallest->first == smallest->second)
            { smallest = remaining.erase(smallest); }
        }
        return out;
    }
    else
    {
        std::priority_queue<
            std::pair<SubIt, SubIt>,
            std::vector<std::pair<SubIt, SubIt> >,
            multiway_merge_value_less<Less>
        > q((multiway_merge_value_less<Less>(less)));
        return multiway_merge(begin, end, out, less, q, distinct);
    }
}

template<class It, class OutIt>
OutIt multiway_merge(
    It const begin, It const end, OutIt const out,
    bool const distinct = false)
{
    return multiway_merge(
        begin, end, out,
        multiway_merge_default_less(), distinct);
}

1
Here is my implementation using MinHeap...

package merging;

import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileReader;
import java.io.FileWriter;
import java.io.IOException;
import java.io.PrintWriter;


public class N_Way_Merge {

int No_of_files=0;
String[] listString;
int[] listIndex;
PrintWriter pw;
private String fileDir = "D:\\XMLParsing_Files\\Extracted_Data";
private File[] fileList;
private BufferedReader[] readers;

public static void main(String[] args) throws IOException {

    N_Way_Merge nwm=new N_Way_Merge();

    long start= System.currentTimeMillis();

    try {

        nwm.createFileList();

        nwm.createReaders();
        nwm.createMinHeap();
    }
    finally {
        nwm.pw.flush();
        nwm.pw.close();
        for (BufferedReader readers : nwm.readers) {

            readers.close();

        }
    }
    long end = System.currentTimeMillis();
    System.out.println("Files merged into a single file.\nTime taken: "+((end-start)/1000)+"secs");
}

public void createFileList() throws IOException {
    //creates a list of sorted files present in a particular directory
    File folder = new File(fileDir);
    fileList = folder.listFiles();
    No_of_files=fileList.length;
    assign();
    System.out.println("No. of files - "+ No_of_files);

}

public void assign() throws IOException
{
    listString = new String[No_of_files];
    listIndex = new int[No_of_files];
    pw = new PrintWriter(new BufferedWriter(new FileWriter("D:\\XMLParsing_Files\\Final.txt", true)));
}

public void createReaders() throws IOException {
    //creates array of BufferedReaders to read the files
    readers = new BufferedReader[No_of_files];

    for(int i=0;i<No_of_files;++i)
    {
        readers[i]=new BufferedReader(new FileReader(fileList[i]));
    }
}

public void createMinHeap() throws IOException {

    for(int i=0;i<No_of_files;i++)
    {
        listString[i]=readers[i].readLine();
        listIndex[i]=i;
    }

    WriteToFile(listString,listIndex);

}

public void WriteToFile(String[] listString,int[] listIndex) throws IOException{

    BuildHeap_forFirstTime(listString, listIndex);
    while(!(listString[0].equals("zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz")))
    {
        pw.println(listString[0]);
        listString[0]=readers[listIndex[0]].readLine();

        MinHeapify(listString,listIndex,0);
    }

}
public void BuildHeap_forFirstTime(String[] listString,int[] listIndex){

    for(int i=(No_of_files/2)-1;i>=0;--i)
        MinHeapify(listString,listIndex,i);

}

public void MinHeapify(String[] listString,int[] listIndex,int index){

    int left=index*2 + 1;
    int right=left + 1;
    int smallest=index;
    int HeapSize=No_of_files;
    if(left <= HeapSize-1  && listString[left]!=null &&  (listString[left].compareTo(listString[index])) < 0)
        smallest = left;

    if(right <= HeapSize-1 && listString[right]!=null &&  (listString[right].compareTo(listString[smallest])) < 0)
        smallest=right;



    if(smallest!=index)
    {
        String temp=listString[index];
        listString[index]=listString[smallest];
        listString[smallest]=temp;

        listIndex[smallest]^=listIndex[index];
        listIndex[index]^=listIndex[smallest];
        listIndex[smallest]^=listIndex[index];

        MinHeapify(listString,listIndex,smallest);
    }

}

}


0

Реалізація Java алгоритму min heap для об’єднання k відсортованих масивів:

public class MergeKSorted {

/**
 * helper object to store min value of each array in a priority queue, 
 * the kth array and the index into kth array
 *
 */
static class PQNode implements Comparable<PQNode>{
    int value;
    int kth = 0;
    int indexKth = 0;

    public PQNode(int value, int kth, int indexKth) {
        this.value = value;
        this.kth = kth;
        this.indexKth = indexKth;
    }
    @Override
    public int compareTo(PQNode o) {
        if(o != null) {
            return Integer.valueOf(value).compareTo(Integer.valueOf(o.value));
        }
        else return 0;
    }

    @Override
    public String toString() {
        return value+" "+kth+" "+indexKth;
    }
}
public static void mergeKSorted(int[][] sortedArrays) {
    int k = sortedArrays.length;
    int resultCtr = 0;
    int totalSize = 0;
    PriorityQueue<PQNode> pq = new PriorityQueue<>();
    for(int i=0; i<k; i++) {
        int[] kthArray = sortedArrays[i];
        totalSize+=kthArray.length;
        if(kthArray.length > 0) {
            PQNode temp = new PQNode(kthArray[0], i, 0);
            pq.add(temp); 
        }
    }
    int[] result = new int[totalSize];
    while(!pq.isEmpty()) {
        PQNode temp = pq.poll();
        int[] kthArray = sortedArrays[temp.kth];
        result[resultCtr] = temp.value;
        resultCtr++;            
        temp.indexKth++;
        if(temp.indexKth < kthArray.length) {
            temp = new PQNode(kthArray[temp.indexKth], temp.kth, temp.indexKth);
            pq.add(temp);
        }

    }
    print(result);
}

public static void print(int[] a) {
    StringBuilder sb = new StringBuilder();
    for(int v : a) {
        sb.append(v).append(" ");
    }
    System.out.println(sb);
}

public static void main(String[] args) {
     int[][] sortedA = {
         {3,4,6,9},
         {4,6,8,9,12},
         {3,4,9},
         {1,4,9}    
     };
     mergeKSorted(sortedA);
}

}
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.