National Central University
Uedu Main Site
Explore Uedu
Student Console
Register as Member/Login
Research Informed Consent Center
Survey Center
Educational Big Data Challenge
Claim or create a course
Try an AI Scenario Task
Support & Messages
Uptime Data

UeduGPTs

--

Jupyters

3

Local AI

--

Uedu Code

--

中央大學 AQI 80 24°C PM2.5 21
AI Reply Desktop Notifications

Show a desktop notification when the AI TA finishes replying

Chat Message Notifications

Notify me when classmates post messages in the forum

Sound notification

Play an alert sound whenever there is a new notification

Unit 04

環狀佇列與雙端佇列

用固定大小的陣列繞圈存放:front、rear 與取餘數
Interaction實作 25 minute(s)

Learning Objectives

  • 說明用一般陣列做佇列時,front 一路往後走會讓前面的空位再也用不到
  • 用取餘數 % cap 讓 front 與 rear 走到最後一格之後繞回 0,畫出環狀佇列在陣列裡的樣子
  • 比較「front+count」與「front+rear,留一格」兩種判斷空與滿的寫法
  • 說明 (front - 1) % cap 在 C++、Java 會得到負數,改寫成 (front - 1 + cap) % cap
  • 用同一個環狀陣列做雙端佇列(deque)的兩頭進出,並使用 Python、C++、Java 內建的雙端佇列

佇列要怎麼放進一塊固定大小的記憶體

印表機的列印工作、客服的等候電話、鍵盤按下但程式還沒處理的按鍵,都是佇列(queue):先來的先處理,新來的排在最後面。

佇列只有兩個主要操作:

操作 意思
enqueue 從後端(rear)放入一個元素
dequeue 從前端(front)取出一個元素

演算法軌第 10 課談過佇列怎麼用。這一課看的是它怎麼做出來:很多系統(作業系統的鍵盤緩衝區、網路卡收封包的緩衝區、音訊播放的緩衝區)只有一塊固定大小的陣列可以用,不能隨時要新的記憶體。在這種限制下,要怎麼讓前端一直取出、後端一直放入,又不浪費空間?

這一課的每個範例都有 Python、C++、Java 三種寫法,可以用動畫上方的按鈕切換;三種寫法的輸出完全相同。

最直覺的做法:兩個只會往前走的索引

用一個容量 cap 的陣列 data,再記兩個索引:

  • front:下一個要取出的位置
  • rear:下一個要放入的位置

放入時寫到 data[rear],然後 rear += 1;取出時讀 data[front],然後 front += 1。兩個操作都只改一個索引,是 O(1)。

問題在於兩個索引只會往前走。取出之後,front 前面的格子已經沒人用了,可是 rear 走到 cap 之後就判斷「放不下」。下面的範例容量 5,放 4 個、取 2 個之後,佇列裡只剩 2 個元素,卻放不進第 4 個以後的東西。

只往前走的佇列:前面空出來的格子用不到

看兩個 ▲:front 和 rear 都只往右走。取出 10、20 之後,索引 0、1 變成虛線空位;rear 走到 5(超出陣列,標在格子下方)之後,50、60 都放不進去,可是陣列裡明明還有 2 格空位。這種「明明有空位卻判斷為滿」的情況叫做假溢位。

把陣列頭尾接起來:取餘數

解法之一是每次取出就把後面的元素全部往前搬一格,但這樣 dequeue 變成 O(n)。更好的做法是把陣列想成一個圓環:最後一格的下一格是第 0 格。

讓索引「走到尾端就回到 0」只要一個運算:取餘數。

i = (i + 1) % cap      # cap = 5 時:0 → 1 → 2 → 3 → 4 → 0 → 1 …

這樣的佇列叫環狀佇列(circular queue),也叫環形緩衝區(ring buffer)。這一節用兩個變數記錄它的狀態:

  • front:第一個元素的位置
  • count:目前有幾個元素

下一個空位不必另外記,從 front 往後數 count 格就是:rear = (front + count) % cap。第 k 個元素(從 0 算起)在 (front + k) % cap。

判斷空與滿也很直接:count == 0 是空,count == cap 是滿,容量的每一格都能用。

環狀佇列:front 與 count

容量 5。先放 10、20、30,再取出 10、20;接下來放 40、50 之後已經寫到索引 4,60 會繞回索引 0、70 放在索引 1。這時 count = 5 = cap,80 就放不進去了。注意佇列裡的元素在陣列中是 3、4、0、1 這幾格,前後不連續,但從 front 繞一圈讀過去順序是對的。

另一種記法:front 與 rear,留一格不用

很多教科書與函式庫不用 count,而是直接記 front 和 rear(下一個空位)。放入時寫 data[rear]、rear = (rear + 1) % cap;取出時讀 data[front]、front = (front + 1) % cap。

麻煩在判斷空與滿:

  • 空的時候:front 追上了 rear,front == rear
  • 如果把 cap 格全部放滿:rear 繞了一圈也追上 front,還是 front == rear

兩種情況長得一模一樣,程式分不出來。常見的解法是故意留一格不放:rear 的下一格就是 front 時就算滿了。

判斷 條件
空 front == rear
滿 (rear + 1) % cap == front
元素個數 (rear - front + cap) % cap

代價是容量 cap 的陣列只能放 cap - 1 個元素。元素個數的公式先加 cap 再取餘數,是因為 rear 繞回來之後可能比 front 小,rear - front 會是負數(下一節會說明負數取餘數的問題)。

兩種記法都對,差別只是「多存一個 count」或「少用一格」。

front 與 rear:容量 4 只能放 3 個

放 10、20、30 之後 rear = 3,再放 40 的話 rear 會變成 0、等於 front,所以 40 被擋下來。取出 10 之後 50 可以放進索引 3,rear 繞回 0。接著連續取出,front 追上 rear 時就是空的;最後放入的 60 寫在索引 0。

動手做:環狀佇列實驗

左邊是圓環,右邊是同一份資料在陣列裡的樣子,下方的判斷式會代入目前的數字。可以切換兩種記法、改容量;「前端放入」「後端取出」是下一節雙端佇列的操作。

想一想:環狀佇列的索引

1.

環狀佇列的容量 cap = 8,目前 front = 6、count = 3。佇列裡的三個元素在陣列的哪三格?

2.

用 front 和 rear(留一格不用)的寫法,容量 cap = 6 的陣列最多能放幾個元素?

3.

下面的程式會印出什麼?

第 1 次作答

兩頭都能進出:雙端佇列

雙端佇列(double-ended queue,簡稱 deque,念作 deck)兩端都可以放入、取出:

操作 做法(front+count 記法)
push_back 寫到 (front + count) % cap,count + 1(和環狀佇列的 enqueue 一樣)
pop_front 讀 data[front],front 往後一格,count - 1(和 dequeue 一樣)
push_front front 往前退一格,寫到新的 data[front],count + 1
pop_back 讀最後一個 data[(front + count - 1) % cap],count - 1

四個操作都是 O(1)。只用 push_back 和 pop_front,它就是佇列;只用 push_back 和 pop_back,它就是堆疊。

往前退一格要特別小心。直覺會寫 (front - 1) % cap,但 front = 0 時:

語言 (0 - 1) % 5
Python 4(餘數的正負號跟除數相同)
C++、Java -1(餘數的正負號跟被除數相同)

C++、Java 拿到 -1 當索引,會讀寫到陣列外面(Java 丟出例外,C++ 是未定義行為)。三種語言都通用的寫法是先加 cap:(front - 1 + cap) % cap,括號裡一定不是負數。

用環狀陣列做雙端佇列

push_front 5 時 front 從 0 退到 4(陣列最後一格),push_front 1 再退到 3。此時佇列由前到後是 1、5、10、20,在陣列裡卻是索引 3、4、0、1。pop_back 取出 20,push_front 9 寫到索引 2,pop_front 又把 9 取出。

各語言內建的雙端佇列

實際寫程式時直接用內建的雙端佇列,它們都是 O(1) 的兩端操作,而且會自動長大,不會「滿」:

操作 Python collections.deque C++ std::deque Java ArrayDeque
後端放入 dq.append(x) dq.push_back(x) dq.addLast(x)
前端放入 dq.appendleft(x) dq.push_front(x) dq.addFirst(x)
前端取出 dq.popleft() dq.front() 再 dq.pop_front() dq.pollFirst()
後端取出 dq.pop() dq.back() 再 dq.pop_back() dq.pollLast()

三者內部的做法不同:Java 的 ArrayDeque 就是這一課的環狀陣列,滿了就換一個更大的陣列再搬過去; Python 的 deque 把資料分成固定大小的區塊,區塊之間用雙向鏈結串列串起來;C++ 的 std::deque 通常也是分成區塊,另外用一個表記住各區塊的位置。兩者在兩端不夠用時都是加一個新區塊,不必搬動舊資料。 細節依實作而定,使用上的共同點是兩端進出都很快。

不要用 Python 的 list 當佇列:lst.pop(0) 會把後面每個元素往前搬一格,是 O(n)。

下面用雙端佇列判斷迴文(正著讀和倒著讀一樣的字):每一輪從前端和後端各取一個字元比較,相同就繼續往中間靠。

內建雙端佇列:判斷迴文

level、racecar 每一輪兩端取出的字元都相同,最後剩 0 或 1 個字元就是迴文。abcda 第一輪 a 和 a 相同,第二輪 b 和 d 不同,立刻回傳「不是」。(C++、Java 的函式名稱是 isPalindrome。)

想一想:雙端佇列與負數取餘數

1.

Java 執行 System.out.println((0 - 1) % 5); 會印出什麼?

2.

容量 cap = 6 的環狀雙端佇列,front = 4、count = 3。執行 pop_back 會取出陣列哪一格的元素?

3.

用 Python 的 list 當佇列,每次 lst.pop(0) 取出第一個元素。n 個元素全部取完,總共大約要搬動幾次元素?

第 1 次作答

實作:只保留最近 k 筆資料

感測器、聊天室、「最近瀏覽」常常只要保留最近 k 筆:還沒滿就放在後面,滿了就蓋掉最舊的那一筆。請完成 add:還沒滿時寫到 (front + count) % cap、count 加 1;滿了就把新值寫在 front(最舊的那一格),再讓 front 往後一格。預期輸出在右邊。

你的輸出
預期輸出

重點整理

  • 用陣列做佇列,如果 front、rear 只往前走,取出後空出來的格子再也用不到(假溢位)
  • 環狀佇列用 % cap 讓索引走到最後一格之後回到 0,放入、取出都是 O(1),空位可以重複使用
  • 記法一:front 與 count。空=count == 0,滿=count == cap,下一個空位 (front + count) % cap
  • 記法二:front 與 rear,留一格不用。空=front == rear,滿=(rear + 1) % cap == front,最多放 cap - 1 個
  • C++、Java 的負數取餘數會是負的;往前退一格寫 (front - 1 + cap) % cap
  • 雙端佇列兩端都能進出,同一個環狀陣列多寫 push_front、pop_back 就能做到;內建的是 Python collections.deque、C++ std::deque、Java ArrayDeque

下一課:堆疊的應用。堆疊只在一端進出,看它怎麼檢查括號、把運算式轉成電腦好計算的順序。

這一課完成了!
Next lesson:堆疊的應用
你目前沒有登入,學習進度只存在這個瀏覽器。 Login 後,進度會跟著帳號,換電腦也看得到。