Skip to main content

Live Coding — โจทย์ตัวอย่าง + วิธีคิด

เป้าหมายของหน้านี้ไม่ใช่แค่ "เฉลย" แต่คือ กระบวนการคิดและพูด ระหว่างทำโจทย์สด ซึ่งคือสิ่งที่ interviewer ประเมินจริงๆ มากกว่าคำตอบที่ถูก 100% — ทุกโจทย์มีปุ่ม Run ให้กดคอมไพล์/รันจริง ได้เลยผ่าน Go Playground อย่างเป็นทางการ (ไม่ต้องติดตั้งอะไร)

กลยุทธ์ทั่วไปตอน Live Coding​

  1. พูดทวนโจทย์ด้วยคำพูดตัวเอง ก่อนเริ่มเขียน — เช็คว่าเข้าใจตรงกับผู้สัมภาษณ์
  2. ถาม edge case ก่อนเขียนโค้ด: input ว่างได้ไหม? ค่าติดลบ? concurrent access ไหม?
  3. เขียน signature ฟังก์ชันก่อน แล้วค่อย implement — แสดงว่าคิดเรื่อง interface ก่อนเรื่อง detail
  4. บอก time/space complexity ของ solution แม้จะยังไม่ optimal ที่สุด
  5. ถ้าติด ให้พูดสิ่งที่กำลังคิดออกมาดังๆ ("ตอนนี้ผมคิดว่าจะใช้ map เพื่อ O(1) lookup แต่...") — เงียบคือสิ่งที่แย่ที่สุด
🧠 จำง่ายๆ

ย่อเป็น "ทวน-ถาม-ร่าง-พูด-บอก": ทวนโจทย์ → ถาม edge case → ร่าง signature ก่อน implement → พูดความคิดตลอดเวลาที่ติด → บอก complexity ปิดท้าย ท่องลำดับนี้ไว้เป็น checklist ก่อนเข้าห้อง

โจทย์ 1: Concurrent Rate Limiter​

คำถาม: ออกแบบ rate limiter ที่จำกัดให้ผ่านได้ไม่เกิน N requests ต่อ time window (token bucket แบบง่าย) และต้อง thread-safe

แนวคิดที่ต้องพูด: lazy refill (คำนวณตอน Allow() ถูกเรียก ไม่ต้องมี background goroutine คอย tick) vs active refill (goroutine ticker แยก) — lazy ง่ายกว่า ไม่มี goroutine ให้ leak แต่ active เหมาะกับเคสที่ต้อง broadcast สถานะ token ให้หลาย consumer

ตามด้วยคำถาม follow-up ที่พบบ่อย: "ถ้าต้องรองรับหลาย instance (distributed) จะทำยังไง?" → ตอบด้วยแนวคิด Redis + Lua script แบบ atomic หรือ sliding window algorithm

ลองรันจริง​

package main

import (
"fmt"
"sync"
"time"
)

type RateLimiter struct {
mu sync.Mutex
tokens int
maxTokens int
lastRefill time.Time
refillRate time.Duration // เติม 1 token ทุกๆ refillRate
}

func NewRateLimiter(maxTokens int, refillRate time.Duration) *RateLimiter {
return &RateLimiter{
tokens: maxTokens,
maxTokens: maxTokens,
lastRefill: time.Now(),
refillRate: refillRate,
}
}

func (r *RateLimiter) Allow() bool {
r.mu.Lock()
defer r.mu.Unlock()

r.refill()
if r.tokens > 0 {
r.tokens--
return true
}
return false
}

func (r *RateLimiter) refill() {
elapsed := time.Since(r.lastRefill)
newTokens := int(elapsed / r.refillRate)
if newTokens > 0 {
r.tokens += newTokens
if r.tokens > r.maxTokens {
r.tokens = r.maxTokens
}
r.lastRefill = r.lastRefill.Add(time.Duration(newTokens) * r.refillRate)
}
}

func main() {
rl := NewRateLimiter(3, 100*time.Millisecond)

for i := 1; i <= 5; i++ {
fmt.Printf("request %d: allowed = %v\n", i, rl.Allow())
}

fmt.Println("waiting for refill...")
time.Sleep(250 * time.Millisecond)
fmt.Printf("request 6: allowed = %v\n", rl.Allow())
}
วาง (Ctrl/Cmd+V) ในแท็บที่เปิดขึ้นมา แล้วกด Run — รันจริงบนเซิร์ฟเวอร์ ของทีม Go โดยตรง
🧠 จำง่ายๆ

"Lazy refill = เติมตังค์ตอนรูดบัตร ไม่ใช่เติมทุกนาที" — คำนวณ token ที่ควรมี ณ ตอนที่ถูกเรียก ใช้จริง (Allow()) แทนที่จะมี goroutine คอย tick เติม background ตลอดเวลา ประหยัดทั้ง goroutine และแม่นยำกว่าด้วยซ้ำ (ไม่มี drift จาก ticker)

🎯 Beyond: จุดที่ชาวบ้านไม่ค่อยรู้

Token bucket ที่เขียนแบบนี้มี edge case ที่คนมักลืมพูดถึง: ถ้า Allow() ไม่ถูกเรียกเป็นเวลานาน มากๆ (เช่น traffic เงียบไปหลายชั่วโมง) แล้วจู่ๆ มี burst เข้ามา โค้ดนี้จะปล่อยผ่านทันที maxTokens request รวดเดียว (เพราะ refill() คำนวณ token สะสมเต็ม cap) ซึ่งอาจไม่ใช่พฤติกรรม ที่ต้องการเสมอไป ถ้า interviewer ถามเรื่อง "smoothing" ให้พูดถึง leaky bucket algorithm แทน (ปล่อยออกด้วยอัตราคงที่ไม่ว่าจะมี burst เข้ามาแค่ไหน ต่างจาก token bucket ที่ยอมให้ burst ได้ถึง capacity) — การแยกสองคำนี้ออกจากกันได้ชัดคือจุดที่แยก senior จาก mid-level ในคำถามนี้

โจทย์ 2: Worker Pool ประมวลผล Jobs​

คำถาม: มี jobs หลายชิ้น ประมวลผลแบบ concurrent แต่จำกัด worker ไม่เกิน N ตัว รวบรวมผลลัพธ์ ทั้งหมด และต้อง handle error โดยไม่ทำให้ worker อื่น crash

จุดที่ interviewer มักจับผิด: ลืม close(jobCh) ทำให้ worker ค้างใน range jobCh ตลอดไป (deadlock), หรือ error handling ที่ทำให้ worker ตัวหนึ่ง panic แล้วลาก goroutine อื่นล้มไปด้วย (ควรมี recover() ใน worker ถ้าความเสี่ยงสูง)

ลองรันจริง​

package main

import (
"errors"
"fmt"
"sync"
)

type Job struct {
ID string
N int
}

type Result struct {
JobID string
Value int
}

func (j Job) Process() (Result, error) {
if j.N < 0 {
return Result{}, fmt.Errorf("negative input: %d", j.N)
}
return Result{JobID: j.ID, Value: j.N * j.N}, nil
}

func ProcessJobs(jobs []Job, numWorkers int) ([]Result, error) {
jobCh := make(chan Job, len(jobs))
resultCh := make(chan Result, len(jobs))
errCh := make(chan error, len(jobs))

var wg sync.WaitGroup
for w := 0; w < numWorkers; w++ {
wg.Add(1)
go func() {
defer wg.Done()
for job := range jobCh {
res, err := job.Process()
if err != nil {
errCh <- fmt.Errorf("job %s: %w", job.ID, err)
continue
}
resultCh <- res
}
}()
}

for _, j := range jobs {
jobCh <- j
}
close(jobCh) // สำคัญ: ปิดก่อนเพื่อให้ worker หยุด range เมื่อ job หมด

wg.Wait()
close(resultCh)
close(errCh)

var errs []error
for err := range errCh {
errs = append(errs, err)
}

results := make([]Result, 0, len(resultCh))
for r := range resultCh {
results = append(results, r)
}

if len(errs) > 0 {
return results, errors.Join(errs...)
}
return results, nil
}

func main() {
jobs := []Job{
{ID: "a", N: 2},
{ID: "b", N: 3},
{ID: "c", N: -1},
{ID: "d", N: 4},
}

results, err := ProcessJobs(jobs, 2)
fmt.Println("results:", results)
fmt.Println("err:", err)
}
วาง (Ctrl/Cmd+V) ในแท็บที่เปิดขึ้นมา แล้วกด Run — รันจริงบนเซิร์ฟเวอร์ ของทีม Go โดยตรง
🧠 จำง่ายๆ

"close(jobCh) คือป้าย 'หมดแล้ว' ให้ worker เลิกรองาน" — range บน channel จะวนรอรับเรื่อยๆ จนกว่าจะเจอ channel ที่ถูก close (ไม่ใช่แค่ว่าง) ถ้าไม่ปิดเลย worker จะคิดว่า "เดี๋ยวมีงานมาอีก" แล้วรอตลอดกาลแม้ job จะหมดแล้วจริงๆ

🎯 Beyond: จุดที่ชาวบ้านไม่ค่อยรู้

Pattern นี้ทำให้ resultCh/errCh เป็น buffered ขนาด len(jobs) เพื่อไม่ให้ worker block ตอน ส่งผลลัพธ์ — แต่ถ้า jobs มีจำนวนมาก (เช่นล้านชิ้น) การ buffer ขนาดเท่า input ทั้งหมดใช้ memory สูงเกินจำเป็น แนวทางที่ scale ได้ดีกว่าคือใช้ errgroup จาก golang.org/x/sync/errgroup ซึ่งจัดการ cancellation แบบอัตโนมัติ (ถ้า goroutine ไหน error ตัวอื่นจะถูก cancel ผ่าน context ที่ errgroup สร้างให้) ไม่ต้องมานั่งจัดการ error channel เองแบบในตัวอย่าง — เป็น library ที่ senior Go engineer เกือบทุกคนใช้แทนการเขียน worker pool มือเองแบบ raw channel

โจทย์ 3: LRU Cache​

คำถาม: Implement LRU Cache ที่ Get/Put เป็น O(1)

แนวคิด: Doubly linked list (เก็บลำดับการใช้งาน) + hash map (เก็บ key → node pointer เพื่อ lookup O(1)) — Go มี container/list ให้ใช้ได้เลยไม่ต้อง implement linked list เอง

พูดเรื่อง concurrency: โจทย์นี้ไม่ thread-safe ตามที่เขียน — ถ้าถูกถามต่อให้เพิ่ม sync.Mutex ครอบทุก method หรือเสนอ sharded lock ถ้าต้องการ throughput สูง

ลองรันจริง​

package main

import (
"container/list"
"fmt"
)

type LRUCache struct {
capacity int
ll *list.List
items map[int]*list.Element
}

type entry struct {
key, value int
}

func NewLRUCache(capacity int) *LRUCache {
return &LRUCache{capacity: capacity, ll: list.New(), items: make(map[int]*list.Element)}
}

func (c *LRUCache) Get(key int) (int, bool) {
if el, ok := c.items[key]; ok {
c.ll.MoveToFront(el)
return el.Value.(*entry).value, true
}
return 0, false
}

func (c *LRUCache) Put(key, value int) {
if el, ok := c.items[key]; ok {
c.ll.MoveToFront(el)
el.Value.(*entry).value = value
return
}
if c.ll.Len() >= c.capacity {
oldest := c.ll.Back()
c.ll.Remove(oldest)
delete(c.items, oldest.Value.(*entry).key)
}
el := c.ll.PushFront(&entry{key, value})
c.items[key] = el
}

func main() {
cache := NewLRUCache(2)
cache.Put(1, 100)
cache.Put(2, 200)
v, ok := cache.Get(1)
fmt.Println("get 1:", v, ok) // 100 true (1 ถูกเลื่อนไปหน้าสุด)

cache.Put(3, 300) // cache เต็ม (cap=2) → เชือด key ที่เก่าสุด (key 2) ทิ้ง
_, ok = cache.Get(2)
fmt.Println("key 2 present:", ok) // false

v, ok = cache.Get(3)
fmt.Println("get 3:", v, ok) // 300 true
}
วาง (Ctrl/Cmd+V) ในแท็บที่เปิดขึ้นมา แล้วกด Run — รันจริงบนเซิร์ฟเวอร์ ของทีม Go โดยตรง
🧠 จำง่ายๆ

"หน้าแถว = ใหม่สุด, ท้ายแถว = เก่าสุด (จะโดนเชือดก่อน)" — MoveToFront ทุกครั้งที่ touch (Get หรือ Put) คือการ "เบียดแถวไปอยู่หน้าสุด" ส่วนของที่ไม่มีใครแตะมานาน จะค่อยๆ ไหลไปท้ายแถว เองโดยธรรมชาติ แล้วโดนตัดทิ้งตอนแถวเต็ม (c.ll.Back())

🎯 Beyond: จุดที่ชาวบ้านไม่ค่อยรู้

Get ที่เรียก c.ll.MoveToFront(el) เปลี่ยน state ของ cache แม้จะเป็นแค่ "อ่าน" — นี่ คือจุดที่คนมักมองข้ามตอนออกแบบ thread-safety: ถ้าคิดว่า "Get แค่อ่านใช้ RWMutex.RLock() พอ" จะผิดทันที เพราะ Get เขียน (mutate) โครงสร้าง linked list ข้างในด้วย ต้องใช้ Lock() (exclusive) ทั้ง Get และ Put ไม่ใช่แค่ Put เท่านั้น — เป็นกับดักคลาสสิกที่ interviewer มักถามต่อเป็น follow-up เพื่อดูว่าเข้าใจ "อ่านที่จริงๆ แล้วเขียน" ลึกแค่ไหน

English Summary

Three recurring live-coding patterns in Go interviews: (1) a mutex-guarded token-bucket rate limiter, where lazy vs. active refill is the key design discussion, and distinguishing token bucket (allows bursts up to capacity) from leaky bucket (constant-rate smoothing) signals senior- level understanding; (2) a worker pool over a job channel, where forgetting to close(jobCh) is the classic deadlock trap, and errgroup is the production-grade replacement for hand-rolled error channels; (3) an LRU cache built from container/list + a map for O(1) get/put — note that Get mutates the list via MoveToFront, so it needs an exclusive lock, not just a read lock, a common interviewer follow-up trap. Across all three: narrate your thinking out loud, clarify edge cases before coding, state complexity, and expect a "how would this scale/ distribute?" follow-up.