เวลาเราต่อคิวซื้อของหรือซ้อนจานที่บ้าน เรากำลังใช้โครงสร้างข้อมูลสองอย่างโดยไม่รู้ตัว นั่นก็คือ คิว (Queue) และ สแต็ก (Stack)

สองโครงสร้างข้อมูลนี้เป็นพื้นฐานที่สำคัญมากในการเขียนโปรแกรม ตั้งแต่การจัดการงานในระบบปฏิบัติการ ไปจนถึงการทำงานของเบราว์เซอร์ บทความนี้จะพาไปดูว่า queue และ stack คืออะไร ทำงานอย่างไร และมีตัวอย่างการใช้งานในโลกจริงพร้อมโค้ดภาษา Go

TL;DR

  • Queue: โครงสร้างข้อมูลแบบ FIFO (First In, First Out) ตัวแรกที่เข้า คือตัวแรกที่ออก
  • Stack: โครงสร้างข้อมูลแบบ LIFO (Last In, First Out) ตัวสุดท้ายที่เข้า คือตัวแรกที่ออก
  • Queue เหมาะกับงานที่ต้องประมวลผลตามลำดับคอย เช่น print spooler, task scheduler
  • Stack เหมาะกับงานที่ต้องย้อนกลับหรือ undo เช่น browser history, function call stack
  • Go ไม่มี queue/stack ใน standard library โดยตรง แต่สามารถสร้างเองได้ตามหลักการ

Queue: โครงสร้างข้อมูลแบบ FIFO

Queue หรือ คิว คือโครงสร้างข้อมูลที่ทำงานแบบ FIFO (First In, First Out) หมายความว่า ข้อมูลที่เข้ามาก่อนจะถูกนำออกก่อน เหมือนกับการต่อคิวซื้อของตามร้านสะดวกซื้อ คนที่มายืนต่อคิวก่อนก็จะได้ซื้อของก่อน

    In → [ rear ] [ ... ] [ ... ] [ front ] → Out
         (enqueue)                (dequeue)

การทำงานพื้นฐานของ Queue

Queue มี operation หลัก ๆ ดังนี้:

Operation คำอธิบาย Time Complexity
enqueue(x) เพิ่มข้อมูลต่อท้ายคิว O(1)
dequeue() นำข้อมูลหน้าคิวออก O(1)
front() / peek() ดูข้อมูลหน้าคิวโดยไม่นำออก O(1)
isEmpty() ตรวจสอบว่าคิวว่างหรือไม่ O(1)
size() ดูจำนวนสมาชิกในคิว O(1)

ตัวอย่าง Queue ในชีวิตจริง

  • การต่อคิวในชีวิตประจำวัน: คนที่มายืนต่อคิวก่อนได้บริการก่อน
  • Print spooler: งานพิมพ์ที่ส่งมาก่อนจะถูกพิมพ์ก่อน
  • Task scheduler: งานที่ถูกส่งเข้ามาก่อนจะถูกประมวลผลก่อน
  • Message queue: RabbitMQ, Kafka ใช้ queue เพื่อส่งข้อความระหว่าง service
  • Breadth-First Search (BFS): ใช้วนลูปเพื่อค้นหาข้อมูลในกราฟแบบกว้าง

Stack: โครงสร้างข้อมูลแบบ LIFO

Stack หรือ สแต็ก คือโครงสร้างข้อมูลที่ทำงานแบบ LIFO (Last In, First Out) หมายความว่า ข้อมูลที่เข้ามาทีหลังจะถูกนำออกก่อน เหมือนกับการซ้อนจานหรือซ้อนหนังสือ จานที่วางซ้อนทีหลังจะอยู่บนสุดและถูกหยิบออกก่อน

      Out ← [ top ]
            [ ... ]
            [ ... ]
            [ ... ]
    In → [ bottom ]

การทำงานพื้นฐานของ Stack

Stack มี operation หลัก ๆ ดังนี้:

Operation คำอธิบาย Time Complexity
push(x) เพิ่มข้อมูลลงบนสุดของสแต็ก O(1)
pop() นำข้อมูลบนสุดออก O(1)
top() / peek() ดูข้อมูลบนสุดโดยไม่นำออก O(1)
isEmpty() ตรวจสอบว่าสแต็กว่างหรือไม่ O(1)
size() ดูจำนวนสมาชิกในสแต็ก O(1)

ตัวอย่าง Stack ในชีวิตจริง

  • การซ้อนจาน: จานที่วางซ้อนทีหลังอยู่บนสุดและถูกหยิบออกก่อน
  • Undo / Redo: การย้อนกลับการทำงานในโปรแกรมแก้ไขเอกสาร
  • Browser history: ปุ่ม Back จะพาเรากลับไปยังหน้าที่เพิ่งดูมาก่อน
  • Function call stack: ภาษาโปรแกรมทุกภาษาใช้ stack ในการเรียกฟังก์ชัน
  • Depth-First Search (DFS): ใช้วนลูปเพื่อค้นหาข้อมูลในกราฟแบบลึก
  • Expression evaluation: ใช้ stack ในการคำนวณนิพจน์ทางคณิตศาสตร์

Queue และ Stack ในภาษา Go

ภาษา Go ไม่ได้มี queue หรือ stack เป็นโครงสร้างข้อมูลในตัว (built-in) แต่เราสามารถ implement ได้ง่าย ๆ โดยใช้ slice หรือ container/list

Queue ด้วย Slice

type Queue[T any] struct {
    items []T
}

func (q *Queue[T]) Enqueue(item T) {
    q.items = append(q.items, item)
}

func (q *Queue[T]) Dequeue() (T, bool) {
    if q.IsEmpty() {
        var zero T
        return zero, false
    }
    item := q.items[0]
    q.items = q.items[1:]
    return item, true
}

func (q *Queue[T]) Front() (T, bool) {
    if q.IsEmpty() {
        var zero T
        return zero, false
    }
    return q.items[0], true
}

func (q *Queue[T]) IsEmpty() bool {
    return len(q.items) == 0
}

func (q *Queue[T]) Size() int {
    return len(q.items)
}

ข้อควรระวัง: การ Dequeue โดยใช้ q.items = q.items[1:] จะไม่คืนหน่วยความจำส่วนที่ถูกตัดทิ้งไป ถ้าต้องการประสิทธิภาพสูง อาจใช้ circular queue หรือ linked list แทน

Stack ด้วย Slice

type Stack[T any] struct {
    items []T
}

func (s *Stack[T]) Push(item T) {
    s.items = append(s.items, item)
}

func (s *Stack[T]) Pop() (T, bool) {
    if s.IsEmpty() {
        var zero T
        return zero, false
    }
    item := s.items[len(s.items)-1]
    s.items = s.items[:len(s.items)-1]
    return item, true
}

func (s *Stack[T]) Top() (T, bool) {
    if s.IsEmpty() {
        var zero T
        return zero, false
    }
    return s.items[len(s.items)-1], true
}

func (s *Stack[T]) IsEmpty() bool {
    return len(s.items) == 0
}

func (s *Stack[T]) Size() int {
    return len(s.items)
}

Queue ด้วย container/list (Linked List)

Go มี container/list ซึ่งเป็น doubly linked list ที่สามารถใช้ implement queue ได้:

import "container/list"

type QueueList[T any] struct {
    items *list.List
}

func NewQueueList[T any]() *QueueList[T] {
    return &QueueList[T]{items: list.New()}
}

func (q *QueueList[T]) Enqueue(item T) {
    q.items.PushBack(item)
}

func (q *QueueList[T]) Dequeue() (T, bool) {
    if q.IsEmpty() {
        var zero T
        return zero, false
    }
    front := q.items.Front()
    q.items.Remove(front)
    return front.Value.(T), true
}

func (q *QueueList[T]) IsEmpty() bool {
    return q.items.Len() == 0
}

Stack ด้วย container/list

import "container/list"

type StackList[T any] struct {
    items *list.List
}

func NewStackList[T any]() *StackList[T] {
    return &StackList[T]{items: list.New()}
}

func (s *StackList[T]) Push(item T) {
    s.items.PushFront(item)
}

func (s *StackList[T]) Pop() (T, bool) {
    if s.IsEmpty() {
        var zero T
        return zero, false
    }
    front := s.items.Front()
    s.items.Remove(front)
    return front.Value.(T), true
}

func (s *StackList[T]) IsEmpty() bool {
    return s.items.Len() == 0
}

ตัวอย่างการใช้งานจริง

ตัวอย่างที่ 1: Print Spooler (Queue)

จำลองระบบส่งงานพิมพ์ที่ต้องพิมพ์ตามลำดับที่ส่งเข้ามา:

package main

import (
    "fmt"
    "time"
)

type PrintJob struct {
    ID       int
    Filename string
}

type PrintSpooler struct {
    queue Queue[PrintJob]
}

func (ps *PrintSpooler) Submit(job PrintJob) {
    ps.queue.Enqueue(job)
    fmt.Printf("Job #%d (%s) submitted\n", job.ID, job.Filename)
}

func (ps *PrintSpooler) Process() {
    for !ps.queue.IsEmpty() {
        job, _ := ps.queue.Dequeue()
        fmt.Printf("Printing: #%d (%s)...\n", job.ID, job.Filename)
        time.Sleep(500 * time.Millisecond)
        fmt.Printf("Done: #%d (%s)\n", job.ID, job.Filename)
    }
}

func main() {
    spooler := &PrintSpooler{}

    spooler.Submit(PrintJob{1, "report.pdf"})
    spooler.Submit(PrintJob{2, "invoice.pdf"})
    spooler.Submit(PrintJob{3, "photo.jpg"})

    fmt.Println("\n--- Start Printing ---")
    spooler.Process()
}

ผลลัพธ์:

Job #1 (report.pdf) submitted
Job #2 (invoice.pdf) submitted
Job #3 (photo.jpg) submitted

--- Start Printing ---
Printing: #1 (report.pdf)...
Done: #1 (report.pdf)
Printing: #2 (invoice.pdf)...
Done: #2 (invoice.pdf)
Printing: #3 (photo.jpg)...
Done: #3 (photo.jpg)

จะเห็นว่างานพิมพ์ที่ส่งเข้ามาก่อน (report.pdf) ถูกพิมพ์ก่อนเสมอ ตามหลัก FIFO

ตัวอย่างที่ 2: Browser History (Stack)

จำลองการทำงานของปุ่ม Back ในเบราว์เซอร์:

package main

import "fmt"

type BrowserHistory struct {
    backStack    Stack[string]
    forwardStack Stack[string]
    current      string
}

func NewBrowserHistory(homepage string) *BrowserHistory {
    return &BrowserHistory{
        current: homepage,
    }
}

func (b *BrowserHistory) Visit(url string) {
    b.backStack.Push(b.current)
    b.current = url
    // clear forward history on new visit
    b.forwardStack = Stack[string]{}
    fmt.Printf("Visiting: %s\n", b.current)
}

func (b *BrowserHistory) Back() {
    if page, ok := b.backStack.Pop(); ok {
        b.forwardStack.Push(b.current)
        b.current = page
        fmt.Printf("Back to: %s\n", b.current)
    } else {
        fmt.Println("No back history")
    }
}

func (b *BrowserHistory) Forward() {
    if page, ok := b.forwardStack.Pop(); ok {
        b.backStack.Push(b.current)
        b.current = page
        fmt.Printf("Forward to: %s\n", b.current)
    } else {
        fmt.Println("No forward history")
    }
}

func main() {
    browser := NewBrowserHistory("google.com")

    browser.Visit("github.com")
    browser.Visit("medium.com")
    browser.Visit("youtube.com")

    browser.Back()  // back to medium.com
    browser.Back()  // back to github.com
    browser.Forward() // forward to medium.com
    browser.Visit("x.com") // clear forward history on new visit

    browser.Back() // back to medium.com
}

ผลลัพธ์:

Visiting: github.com
Visiting: medium.com
Visiting: youtube.com
Back to: medium.com
Back to: github.com
Forward to: medium.com
Visiting: x.com
Back to: medium.com

จะเห็นว่า stack ช่วยให้เราสามารถย้อนกลับและไปข้างหน้าได้อย่างอิสระ โดยใช้หลักการ LIFO นั่นเอง


สรุป

Queue และ Stack เป็นโครงสร้างข้อมูลพื้นฐานที่เรียกว่า linear data structure เหมือนกัน แต่แตกต่างกันที่กฎในการเข้า-ออก:

  • Queue: FIFO: มาก่อนออกก่อน เหมาะกับงานที่ต้องรักษาลำดับ
  • Stack: LIFO: มาทีหลังออกก่อน เหมาะกับงานที่ต้องย้อนกลับ

ในภาษา Go เราสามารถ implement ทั้งสองโครงสร้างนี้ได้ง่าย ๆ ด้วย slice หรือ container/list โดย slice จะมีประสิทธิภาพดีกว่าในกรณีทั่วไป แต่ container/list จะเหมาะกับ queue ที่ต้อง enqueue/dequeue บ่อยครั้งเพราะไม่ต้องเลื่อนสมาชิก

การเลือกใช้ queue หรือ stack ขึ้นอยู่กับโจทย์ที่เราเจอ ถ้าต้องการความเป็นธรรมและรักษาลำดับ ใช้ queue ถ้าต้องการย้อนกลับหรือจัดการข้อมูลที่ซ้อนกัน ใช้ stack ครับ