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


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)


NN

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

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

PUSHNPOPNO(N)

O(N)

6

O(lgn)pushpoppopO(lgn)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)

Θ(logn)


-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.