Один стек, дві черги


59

тло

Кілька років тому, коли я був студентом, нам дали домашнє завдання на амортизований аналіз. Я не зміг вирішити одну з проблем. Я запитав це в comp.theory , але задовільний результат не виник. Я пам’ятаю, що курс ТА наполягав на тому, що він не міг довести, і сказав, що забув доказ, і ... [ти знаєш, що].

Сьогодні я згадав про проблему. Мені все ще хотілося знати, тож ось це ...

Питання

Чи можливо реалізувати стек, використовуючи дві черги , щоб операції PUSH та POP виконувались за амортизованим часом O (1) ? Якщо так, ви можете мені сказати, як?

Примітка. Ситуація є досить легкою, якщо ми хочемо реалізувати чергу з двома стеками (з відповідними операціями ENQUEUE & DEQUEUE ). Зверніть увагу на різницю.

PS: Вищезазначена проблема - це не само домашнє завдання. Домашнє завдання не потребувало нижніх меж; просто реалізація та аналіз часу роботи.


2
Я думаю, що ви можете використовувати лише обмежену кількість простору, крім двох черг (O (1) або O (log n)). Мені це здається неможливим, оскільки у нас немає жодного способу змінити порядок довгого потоку введення. Але, звичайно, це не підтвердження, якщо воно не може бути викладене в суворій претензії….
— Tsuyoshi Ito

@Tsuyoshi: Ви маєте рацію щодо припущення про обмежений простір. Так, це я сказав на це (впертий) ТА, але він відмовився :(
— MS Dousti

2
@Tsuyoshi: Я не думаю, що вам взагалі не потрібно обмежувати простір, вам потрібно лише припустити, що вам не дозволяється зберігати об'єкти, висунуті та висунуті зі стека в будь-якому місці, крім двох черг (і, ймовірно, постійна кількість змінних).
— Каве

@SadeqDousti На мою думку, єдиний спосіб це було б можливим, якби ви користувались реалізацією черги з пов'язаним списком і використовували деякі вказівники, щоб завжди вказувати на верхню частину "стека"
— Чарльз Аддіс

2
Це здається, що TA, можливо, насправді хотів сказати "Реалізуйте чергу, використовуючи два стеки", що дійсно можливо саме в "а (а) амортизований час".
— Томас Ейл

Відповіді:


45

Я не маю фактичної відповіді, але ось деякі докази того, що проблема відкрита:

  • Це не згадується в Мінг Лі, Люк Лонгпре та Пол М. Б. Вітані, "Сила черги", "Структури 1986", де розглядаються кілька інших тісно пов'язаних моделей

  • Це не згадується в Мартіні Хюне "Про силу кількох черг", Теор. Склад. Наук. 1993, подальший документ.

  • Це не згадується в Хольгері Петерсені "Стек проти Декеса", COCOON 2001.

  • Бертон Розенберг, "Швидке недетерміноване розпізнавання без контекстних мов за допомогою двох черг", Інформ. Зб. Лет. 1998 р. Дає алгоритм двома чергою O (n log n) для розпізнавання будь-яких CFL за допомогою двох черг. Але недетермінований автоматичний вимикач може розпізнавати CFL в лінійний час. Отже, якби було моделювання стека з двома чергами швидше, ніж O (log n) за операцію, Розенберг та його арбітри повинні були знати про це.


4
+1 за відмінні посилання. Однак є деякі технічні особливості: Деякі статті, як і перша, не розглядають проблему моделювання одного стека за допомогою двох черг (наскільки я можу сказати з реферату). Інші вважають аналіз найгіршого випадку, а не амортизованою вартістю.
— МС Дусті

13

Відповідь нижче - «обман», оскільки, хоча він не використовує проміжку між операціями, самі операції можуть використовувати більше, ніж пробіл . Дивіться деінде в цій темі, щоб відповісти, що не має цієї проблеми.O(1)

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

Я представляю два алгоритми, перший - простий алгоритм з часом роботи для Pop, а другий з час роботи для Pop. Я описую перший головним чином через його простоту, так що другий легше зрозуміти.O ( √O(n)O(n)

Щоб детальніше розповісти: перший не використовує додаткового простору, має найгірший випадок (та амортизований) Push та гірший випадок (та амортизований) Pop, але поведінка в гіршому випадку не завжди спрацьовує. Оскільки він не використовує додаткового місця поза двома чергами, це трохи краще, ніж рішення, яке пропонує Росс Снайдер.O ( n )О(1)О(н)

Друге використовує єдине ціле поле (тому додатковий простір), має найгірший випадок (і амортизується) Push та амортизований Pop. Тому час роботи значно кращий, ніж у «простого» підходу, але він використовує додаткове місце.O ( 1 ) O ( √О(1)О(1)О(н)

Перший алгоритм

У нас дві черги: черга та черга s e c o n d . f i r s t буде нашою "чергою наштовхування", тоді як s e c o n d буде чергою, вже в "порядку стеку".firстсеcонгfirстсеcонг

  • Натискання виконується простим додаванням параметра на .firст
  • Вискакування робиться наступним чином. Якщо порожній, ми просто відміняємо s e c o n d і повертаємо результат. В іншому випадку ми повернемо f i r s t , додамо всі s e c o n d до f i r s t і поміняємо f i r s t і s e c o n d . Потім ми Dequeue з е з проfirстсеcонгfirстсеcонгfirстfirстсеcонг і повернути результат dequeue.сеcонг

C # код першого алгоритму

Це може бути досить читабельним, навіть якщо ви ніколи раніше не бачили C #. Якщо ви не знаєте, що таке дженерики, просто замініть всі екземпляри 'T' на 'string' у вашій думці, щоб створити купу рядків.

public class Stack<T> {
    private Queue<T> first = new Queue<T>();
    private Queue<T> second = new Queue<T>();
    public void Push(T value) {
        first.Enqueue(value);
    }
    public T Pop() {
        if (first.Count == 0) {
            if (second.Count > 0)
                return second.Dequeue();
            else
                throw new InvalidOperationException("Empty stack.");
        } else {
            int nrOfItemsInFirst = first.Count;
            T[] reverser = new T[nrOfItemsInFirst];

            // Reverse first
            for (int i = 0; i < nrOfItemsInFirst; i++)
                reverser[i] = first.Dequeue();    
            for (int i = nrOfItemsInFirst - 1; i >= 0; i--)
                first.Enqueue(reverser[i]);

            // Append second to first
            while (second.Count > 0)
                first.Enqueue(second.Dequeue());

            // Swap first and second
            Queue<T> temp = first; first = second; second = temp;

            return second.Dequeue();
        }
    }
}

Аналіз

Очевидно, що Push працює в час. Поп може торкатися всього, що знаходиться всередині f i r s t і s e c o n d постійну кількість разів, тому у нас є O ( n ) в гіршому випадку. Алгоритм демонструє таку поведінку (наприклад), якщо один натискає на стек n елементів, а потім повторно виконує окрему операцію Push і одну операцію Pop послідовно.О(1)firstsecondO(n)n

Другий алгоритм

У нас дві черги: черга та черга s e c o n d . f i r s t буде нашою "чергою наштовхування", тоді як s e c o n d буде чергою, вже в "порядку стеку".firstsecondfirstsecond

Це адаптована версія першого алгоритму, в якій ми не одразу «перетасовуємо» вміст у s e c o n d . Натомість, якщо f i r s t містить достатньо малу кількість елементів порівняно з s e c o n d (а саме квадратний корінь кількості елементів у s e c o n d ), ми реорганізуємо лише f i r s t в порядок стека і не зливайте його зfirstsecondfirstsecondsecondfirst .second

  • Натискання все ще робиться простим додаванням параметра на .first
  • Вискакування робиться наступним чином. Якщо порожній, ми просто відміняємо s e c o n d і повертаємо результат. В іншому випадку ми реорганізуємо вміст f i r s t так, щоб вони були в порядку стеку. Якщо | f i r s t | < √firstsecondfirstми просто відмовляємося відfirstі повертаємо результат. В іншому випадку ми додаємоsecondнаfirst, підміняємоfirstіsecond, відміняємоsecondі повертаємо результат.|first|<|second|firstsecondfirstfirstsecondсеcонг

C # код першого алгоритму

Це може бути досить читабельним, навіть якщо ви ніколи раніше не бачили C #. Якщо ви не знаєте, що таке дженерики, просто замініть всі екземпляри 'T' на 'string' у вашій думці, щоб створити купу рядків.

public class Stack<T> {
    private Queue<T> first = new Queue<T>();
    private Queue<T> second = new Queue<T>();
    int unsortedPart = 0;
    public void Push(T value) {
        unsortedPart++;
        first.Enqueue(value);
    }
    public T Pop() {
        if (first.Count == 0) {
            if (second.Count > 0)
                return second.Dequeue();
            else
                throw new InvalidOperationException("Empty stack.");
        } else {
            int nrOfItemsInFirst = first.Count;
            T[] reverser = new T[nrOfItemsInFirst];

            for (int i = nrOfItemsInFirst - unsortedPart - 1; i >= 0; i--)
                reverser[i] = first.Dequeue();

            for (int i = nrOfItemsInFirst - unsortedPart; i < nrOfItemsInFirst; i++)
                reverser[i] = first.Dequeue();

            for (int i = nrOfItemsInFirst - 1; i >= 0; i--)
                first.Enqueue(reverser[i]);

            unsortedPart = 0;
            if (first.Count * first.Count < second.Count)
                return first.Dequeue();
            else {
                while (second.Count > 0)
                    first.Enqueue(second.Dequeue());

                Queue<T> temp = first; first = second; second = temp;

                return second.Dequeue();
            }
        }
    }
}

Аналіз

Очевидно, що Push працює в час.О(1)

Поп працює в амортизований час. Є два випадки: якщо| first| < √О(н), тоді ми переміщуємоfirstв порядок стека вO(|first|)=O(√|first|<|second|firстчас. Якщо| first| ≥ √O(|first|)=O(n), тоді у нас повинно було бути принаймні√|first|≥|second| дзвінків на Push. Отже, ми можемо вражати цей випадок лише кожен √n дзвінків на Push and Pop. Фактичний час роботи для цього випадку дорівнюєO(n), тому амортизований час -O( n)nO(n).O(nn)=O(n)

Заключна примітка

Можна усунути зайву змінну ціною виготовлення Pop a операція, шляхом того, щоб Pop реорганізувавfirstпід час кожного дзвінка, замість того, щоб Push виконати всю роботу.O(n)first


Я відредагував перші пункти, тому моя відповідь сформульована як фактична відповідь на питання.
— Олексій десять Бринк

6
Ви використовуєте масив (реверсер) для реверсування! Я не думаю, що вам дозволено це робити.
— Каве

Щоправда, я використовую додатковий простір під час виконання методів, але я вважав, що це буде дозволено: якщо ви хочете реалізувати чергу з використанням двох стеків прямо, ви повинні повернути один із стеків в одну точку, і наскільки це можливо Я знаю, що для цього вам потрібен додатковий простір, тому, оскільки це питання схоже, я вважав, що використання додаткового простору під час виконання методу буде дозволено, доки ви не використовуєте додатковий простір між викликами методу.
— Олексій десять Бринк

6
"якщо ви хочете реалізувати чергу, використовуючи два стеки прямо, ви повинні повернути один із стеків в один момент, і, наскільки я знаю, для цього вам потрібен додатковий простір" --- Ви цього не робите. Існує спосіб отримати амортизовану вартість Enqueue 3 і амортизовану вартість Dequeue бути 1 (тобто обидва O (1)) з однією коміркою пам'яті та двома стеками. Важка частина - це справді доказ, а не конструкція алгоритму.
— Аарон Стерлінг

Подумавши про це ще раз, я зрозумів, що насправді обманюю, і мій попередній коментар справді невірний. Я знайшов спосіб її виправити: я придумав два алгоритми з тими ж часом роботи, що і два вище (хоча Push зараз триває операцію, і Pop зараз виконується в постійний час), не використовуючи зайвого простору. Я опублікую нову відповідь, як тільки я все це запишу.
— Олексій десять Бринк

12

Після деяких коментарів до моєї попередньої відповіді мені стало зрозуміло, що я більш-менш обманюю: я використовував додатковий простір ( додатковий простір у другому алгоритмі) під час виконання мого методу Pop.O(n)

Наступний алгоритм не використовує додаткового простору між методами та лише додаткового простору під час виконання Push і Pop. Push має O ( √O(1)амортизований час роботи, а Pop маєO(1)найгірший (та амортизований) час роботи.O(n)O(1)

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

Алгоритм

У нас дві черги: черга та черга s e c o n d . f i r s t буде нашим "кешем", тоді як s e c o n d буде нашим основним "сховищем". Обидві черги завжди будуть у "стеку". f i r s t міститиме елементи у верхній частині стека, а s e c o n d міститиме елементи в нижній частині стека. Розмір f i rfirstsecondfirstsecondfirstsecond завжди буде максимум квадратним коренем s e c o n d .firstsecond

  • Натиснення виконується шляхом "вставлення" параметра на початку черги наступним чином: ми додаємо параметр до , а потім видаляємо і повторно додаємо всі інші елементи у f i r s t . Таким чином, параметр закінчується на початку f i r s t .firstfirstfirst
  • Якщо стає більшим, ніж квадратний корінь s e c o n d , ми вкладаємо всі елементи s e c o n d на f i r s t один за одним, а потім змінюємо f i r s t і s e c o n d . Таким чином, елементи f i r s t (верхівка стека) закінчуються на чолі s efirstsecondsecondfirstfirstsecondfirst .second
  • Поп виконується за допомогою видалення і повернення результату, якщо f i r s t не порожній, інакше шляхом видалення з е c o n d і повернення результату.firstfirstsecond

C # код першого алгоритму

Цей код повинен бути досить читабельним, навіть якщо ви ніколи раніше не бачили C #. Якщо ви не знаєте, що таке дженерики, просто замініть всі екземпляри 'T' на 'string' у вашій думці, щоб створити купу рядків.

public class Stack<T> {
    private Queue<T> first = new Queue<T>();
    private Queue<T> second = new Queue<T>();
    public void Push(T value) {
        // I'll explain what's happening in these comments. Assume we pushed
        // integers onto the stack in increasing order: ie, we pushed 1 first,
        // then 2, then 3 and so on.

        // Suppose our queues look like this:
        // first: in 5 6 out
        // second: in 1 2 3 4 out
        // Note they are both in stack order and first contains the top of
        // the stack.

        // Suppose value == 7:
        first.Enqueue(value);
        // first: in 7 5 6 out
        // second: in 1 2 3 4 out

        // We restore the stack order in first:
        for (int i = 0; i < first.Count - 1; i++)
            first.Enqueue(first.Dequeue());
        // first.Enqueue(first.Dequeue()); is executed twice for this example, the 
        // following happens:
        // first: in 6 7 5 out
        // second: in 1 2 3 4 out
        // first: in 5 6 7 out
        // second: in 1 2 3 4 out

        // first exeeded its capacity, so we merge first and second.
        if (first.Count * first.Count > second.Count) {
            while (second.Count > 0)
                first.Enqueue(second.Dequeue());
            // first: in 4 5 6 7 out
            // second: in 1 2 3 out
            // first: in 3 4 5 6 7 out
            // second: in 1 2 out
            // first: in 2 3 4 5 6 7 out
            // second: in 1 out
            // first: in 1 2 3 4 5 6 7 out
            // second: in out

            Queue<T> temp = first; first = second; second = temp;
            // first: in out
            // second: in 1 2 3 4 5 6 7 out
        }
    }
    public T Pop() {
        if (first.Count == 0) {
            if (second.Count > 0)
                return second.Dequeue();
            else
                throw new InvalidOperationException("Empty stack.");
        } else
            return first.Dequeue();
    }
}

Аналіз

Очевидно, Поп працює в час у гіршому випадку.O(1)

Поштовх працює в амортизований час. Є два випадки: якщо| first| < √O(n)тоді Push приймаєO(√|first|<|second|час. Якщо| first| ≥ √O(n)тоді Push забирає часO(n), але після цієї операціїfirstбуде порожнім. ЗнадобитьсяО(√|first|≥|second|O(n)firstчас, перш ніж ми знову отримаємо цей випадок, тому амортизований час -O( nO(n)час.O(nn)=O(n)


про видалення відповіді перегляньте сторінку meta.cstheory.stackexchange.com/q/386/873 .
— MS Dousti

Я не можу зрозуміти лінію first.Enqueue(first.Dequeue()). Ви щось помилково ввели?
— MS Dousti

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

для мене алгоритм був більш читабельним і простішим для розуміння перед редагуванням.
— Каве

9

Я стверджую, що у нас є амортизована вартість за операцію. Алгоритм Алекса дає верхню межу. Щоб довести нижню межу, я даю найгірший варіант послідовності переміщень PUSH та POP.Θ(N)

NNNNN

PUSHN(PUSHNPOPN)N

NN/2

N

N/2N/2

N/22N

N/22NNNN/23NΩ(N)


N∞N

nQ1N/2Q22nn4:1+2+…+n+n2n

Мабуть, відповідь Петра суперечить цій нижній межі?
— Джо

PUSHNPOPNO(N)

O(N)

6

O(lg⁡n)pushpoppopO(lg⁡n)pop запитується, а черга виводу порожня, ви виконуєте послідовність ідеальних перетасовок, щоб змінити чергу введення та зберігати її у вихідній черзі.

O(1)

Наскільки я знаю, це нова ідея ...



Арг! Я повинен був шукати оновлене або пов’язане з цим питання. Папери, з якими ви посилалися у своїй попередній відповіді, співвідносили між стеками k та k + 1. Чи закінчується цей трюк, ставлячи потужність k черг між k і k + 1 стеками? Якщо так, то це начебто акуратний сиденот. Так чи інакше, дякую, що зв’язали мене з вашою відповіддю, щоб я не витрачав занадто багато часу, щоб написати це на іншому місці.
— Пітер Бут

1

Без використання додаткового простору, можливо, використовуючи пріоритетну чергу і змушуючи кожен новий натиск, щоб надати їй більший пріоритет, ніж попередній? Все-таки не буде O (1).


0

Я не можу отримати черги для реалізації стека в амортизованому постійному часі. Однак я можу придумати спосіб отримати дві черги для реалізації стека в гіршому випадку лінійного часу.

  • AБ
  • Кожен раз, коли відбувається натискання, переверніть біт та вставте елемент у чергу, біт тепер розмежовується. Попустіть усе з іншої черги та натисніть на поточну чергу.
  • Поп-операція знімає передню частину поточної черги і не торкається біт зовнішнього стану.

Звичайно, ми можемо додати ще один біт зовнішнього стану, який говорить нам, чи була остання операція натисканням чи попсом. Ми можемо затримати переміщення всього з однієї черги на іншу, поки не отримаємо дві операції натискання поспіль. Це також робить складну операцію трохи складнішою. Це дає нам O (1) амортизовану складність для поп-операції. На жаль, натиск залишається лінійним.

Все це працює тому, що кожного разу, коли виконується натискання, новий елемент ставиться на чолі порожньої черги, а повна черга додається до хвостового кінця її, ефективно реверсуючи елементи.

Якщо ви хочете отримати амортизовані операції постійного часу, вам, ймовірно, доведеться зробити щось більш розумне.


4
Звичайно, я можу використовувати одну чергу з такою ж гіршою складністю за часом та без ускладнень, по суті трактуючи чергу як круговий список із додатковим елементом черги, що представляє верхню частину стека.
— Дейв Кларк

Схоже, ви можете! Однак, схоже, що для моделювання стека таким чином потрібно більше однієї класичної черги.
— Росс Снайдер

0

Існує тривіальне рішення, якщо ваша черга дозволяє переднє завантаження, що вимагає лише однієї черги (або, точніше, deque.) Можливо, це такий тип черги, який курси TA в оригінальному питанні мали на увазі?

Не допускаючи переднього завантаження, ось ще одне рішення:

Цей алгоритм вимагає двох черг та двох покажчиків, ми називаємо їх Q1, Q2, первинними та вторинними відповідно. Після ініціалізації Q1 і Q2 порожні, первинні точки до Q1 і вторинні точки до Q2.

Операція PUSH тривіальна, вона складається лише з:

*primary.enqueue(value);

Операція POP трохи більше задіяна; для цього потрібна спілінг всіх, крім останнього елемента первинної черги, на вторинну чергу, заміна покажчиків та повернення останнього елемента, що залишився з вихідної черги:

while(*primary.size() > 1)
{
    *secondary.enqueue(*primary.dequeue());
}

swap(primary, secondary);
return(*secondary.dequeue());

Перевірка меж не проводиться, і це не O (1).

Коли я набираю це, я бачу, що це можна зробити за допомогою однієї черги, використовуючи цикл for замість певного циклу, як це робив Алекс. У будь-якому випадку операція PUSH є O (1), а операція POP стає O (n).


Ось ще одне рішення з використанням двох черг та одного вказівника, що називається відповідно Q1, Q2 та queue_p:

Після ініціалізації Q1 і Q2 є порожніми, а черга_p вказує на Q1.

Знову ж таки, операція PUSH є тривіальною, але вимагає одного додаткового кроку вказівки queue_p на іншій черзі:

*queue_p.enqueue(value);
queue_p = (queue_p == &Q1) ? &Q2 : &Q1;

Операція POP аналогічна раніше, але зараз є n / 2 елементів, які потрібно повертати через чергу:

queue_p = (queue_p == &Q1) ? &Q2 : &Q1;
for(i=0, i<(*queue_p.size()-1, i++)
{
    *queue_p.enqueue(*queue_p.dequeue());
}
return(*queue_p.dequeue());

Операція PUSH все ще O (1), але тепер POP-операція O (n / 2).

Особисто для цієї проблеми я віддаю перевагу ідеї реалізації єдиної, подвійної черги (deque) і називаю її стеком, коли ми цього хочемо.


Ваш другий алгоритм корисний для розуміння більш причетного до Алекса.
— hengxin

0

кΘ(н1/к)

к
н
О(1)

iΘ(нi/к)Θ(нi/к)О(1)i+1О(н1/к)i-1Θ(н1/к)

mmΩ(mn1/k)o(n1/k)Ω(n1/k)o(n2/k)ko(n)

Θ(log⁡n)


-3

Стек може бути реалізований за допомогою двох черг, використовуючи другу чергу як ab uffer. Коли елементи висуваються на стек, вони додаються до кінця черги. Кожного разу, коли елемент вискакує, n - 1 елементів першої черги повинні бути переміщені до другої, а решта повертається. реалізація QueueStack публічного класу IStack {private IQueue q1 = нова черга (); приватна IQueue q2 = нова черга (); public void push (E e) {q1.enqueue (e) // O (1)} public E pop (E e) {while (1 <q1.size ()) // O (n) {q2.enqueue ( q1.dequeue ()); } sw apQueues (); повернути q2.dequeue (); } p rivate void swapQueues () {IQueue Q = q2; q2 = q1; q1 = Q; }}


2
Ви пропустили частину запитання про амортизований час O (1)?
— Девід Еппштейн
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.