วงแหวนเว็บ

neizod's speculation

insufficient data for meaningful answer

Hacker Cup 2020: น้องเต่าในบ่อน้ำ

โจทย์จากรอบชิงขนะเลิศของ Facebook Hacker Cup ปี 2020 ที่แม้จะไม่ใช่ข้อที่ยากที่สุด แต่ก็มีความน่าสนใจในหลายระดับ (และหากไล่ให้ถูกทางก็จะคิดคำตอบได้ง่ายกว่าที่คิด) ได้แก่ข้อ Pond Precipitation ที่โจทย์ให้แผนที่บ่อน้ำแห่งหนึ่งที่น้องเต่าอาศัยอยู่ แล้วถามหาค่าคาดหวังว่าฝนต้องตกเป็นจำนวนกี่หยดจึงจะทำให้น้องเต่าจมน้ำ โดยคำนึงว่าฝนแต่ละหยดมีโอกาสตกลงยังแต่ละตำแหน่งในบ่อน้ำเท่าเท่ากัน

นั่นก็คือ หากให้ $X$ เป็นตัวแปรสุ่มแทนน้ำฝนหยดที่ทำให้น้องเต่าจมน้ำ โจทย์ให้เราหา

\[E(X) = \sum_{d \in \mathbb{N}} d \cdot P(X=d)\]

แปลงปัญหาให้เหมาะสม

แม้ข้อมูลนำเข้าของโจทย์ข้อนี้ (ความกว้างของบ่อน้ำ) จะเล็กเพียง $n=30$ และโจทย์รับประกันว่าฝนตกไม่เกิน $n^2$ หยดก็จะท่วมน้องเต่า แต่หากเลือกใช้ขั้นตอนวิธีแบบทดลองหยดน้ำฝนทุกรูปแบบ จะเห็นว่าความซับซ้อนของขั้นตอนวิธีพุ่งสูงเป็น $O(n^{n^2})$ นั่นหมายความว่ามันไม่มีทางทำงานทันแน่นอน อย่างไรก็ตาม หากใครคุ้นเคยกับการแข่งขันเขียนโปรแกรมที่เน้นประสิทธิภาพของขั้นตอนวิธี ก็จะเดาได้ทันทีว่าโจทย์ข้อนี้ต้องเลือกใช้ขั้นตอนวิธีที่มีความซับซ้อนไม่เกิน $O(n^6)$ และเทคนิคที่ไม่นึกถึงไม่ได้สำหรับความซับซ้อนตระกูลนี้ ก็คงหนีไม่พ้นการใช้กำหนดการพลวัตนั่นเอง

ถึงกระนั้น เราจะมองโจทย์ดังกล่าวให้สามารถถูกแก้โดยกำหนดการพลวัตได้อย่างไร เพราะแนวคิดพื้นฐานของการใช้เทคนิคนี้ คือ เราจะแบ่งปัญหาตั้งต้นออกเป็นปัญหาชิ้นเล็กๆ ที่เหลื่อมกันเป็นลำดับขั้น แล้วเริ่มแก้ปัญหาชิ้นเล็กที่สุด (ที่ไม่เหลื่อมกับปัญหาชิ้นอื่นใด) ไล่ขึ้นมาเรื่อยๆ โดยเมื่อต้องแก้ปัญหาชิ้นใหญ่ที่เหลื่อมกับปัญหาชิ้นเล็กที่เคยแก้ไว้แล้ว เราก็จะประหยัดเวลาโดยการนำผลลัพธ์ของการแก้ปัญหาเล็กมาช่วยได้เลยโดยไม่ต้องเริ่มแก้ปัญหาทั้งหมดตั้งแต่ต้น

แต่โจทย์ข้อนี้มีกฎว่าหยดน้ำฝนเมื่อตกกระทบพื้นแล้วสามารถไหลไปได้ทั้งทางซ้ายและขวา ทำให้มองเผินๆ เหมือนกับว่าเราจะไม่สามารถใช้เทคนิคกำหนดการพลวัตได้ เพราะปัญหาย่อยนั้นจะเหลื่อมกันไปหมดจนไม่มีปัญหาที่ไม่เหลื่อมกับปัญหาอื่นให้เริ่มต้นแก้

อย่างไรก็ตาม เราสามารถแปลงข้อมูลความสูงต่ำของตำแหน่งต่างๆ บนแผนที่ในโจทย์ ให้กลายเป็นบ่อน้ำย่อยๆ หลายบ่อเรียงกัน ที่แต่ละบ่อถูกนิยามผ่านความจุและความกว้าง พร้อมวางรากฐานกฎใหม่ไว้ว่าเมื่อแต่ละบ่อรองรับน้ำจนเต็มแล้ว น้ำที่ล้นออกมาจะไหลไปยังบ่อทางซ้ายเท่านั้น และถือว่าน้องเต่าจมน้ำเมื่อน้ำล้นออกจากบ่อซ้ายสุด การแปลงนี้ค่อนข้างง่ายและขอฝากไว้เป็นการบ้านสำหรับคนอ่าน 😜

ตัวอย่างการแปลงแผนที่บ่อน้ำให้กลายเป็นบ่อน้ำย่อยๆ ที่เมื่อน้ำล้นจะล้นไปยังบ่อทางซ้ายเท่านั้น

ดังนั้น จากบ่อน้ำตั้งต้นที่มีความกว้าง $n$ หน่วย เราจะแปลงมันเป็นบ่อน้ำย่อยๆ จำนวน $m \le n$ บ่อ โดยบ่อที่ $i$ สามารถจุน้ำฝนได้ $c_i$ หยด และมีความกว้าง $w_i$ หน่วย

สังเกตว่า บ่อซ้ายสุด (เรียกว่าบ่อที่ $1$) อาจจะมีความจุเท่ากับศูนย์ก็ได้ หากทุกตำแหน่งทางขวาในบ่อย่อยนั้นมีความสูงมากกว่าปากบ่อด้านซ้าย และที่บ่อขวาสุด (ซึ่งก็คือบ่อที่ $m$) อาจจะไม่มีขอบบ่อด้านขวาก็ได้ โดยถือว่าขอบขวาของบ่อนี้สูงอย่างน้อยเท่ากับขอบซ้ายได้เลย

กำหนดการพลวัตตรงไปตรงมา

ใจความของการใช้กำหนดการพลวัตมาช่วยแก้ปัญหาข้อนี้ เราจะสนใจว่าเมื่อน้ำฝนหยดมาแล้ว $d$ หยด จะมีกี่วิธีที่แตกต่างกันที่น้ำฝนล้นออกมาท่วมน้องเต่า ซึ่งเมื่อนำไปหารกับวิธีที่น้ำฝนสามารถตกได้ทั้งหมด ก็จะได้ค่า $P(X=d)$ ออกมานั่นเอง

ให้ $S(i,d)$ เป็นจำนวนวิธีที่น้ำฝนจะเริ่มท่วมน้องเต่าเมื่อฝนตกมา $d$ หยดพอดี โดยพิจารณาเฉพาะบ่อน้ำตั้งแต่บ่อซ้ายสุดจนถึงบ่อที่ $i$ สังเกตว่าเราจะมีน้ำฝนที่อิสระในการเลือกตก $d-1$ หยด ส่วนหยดสุดท้ายเป็นหยดที่บังคับตกลงมาแล้วทำให้น้ำล้นจนท่วมน้องเต่า

ดังนั้นจะเห็นว่า หากต้องการเพิ่มบ่อน้ำทางขวาเข้ามา โดยใช้ความรู้ของ $S(i, d)$ ที่คำนวณไว้แล้วก่อนหน้ามาช่วย เราจะเพิ่มน้ำฝนอิสระชุดใหม่เข้าไปสลับตำแหน่งกับน้ำฝนอิสระชุดเดิมที่มีอยู่แล้วโดยที่ไม่ทำให้น้องเต่าจมน้ำและไม่ทำให้ทุกบ่อเต็ม แล้วอาศัยน้ำฝนที่ไม่อิสระหยุดสุดท้ายของชุดเก่าเป็นหยดที่ทำให้น้องเต่าจมน้ำ อย่างไรก็ตาม หากน้ำฝนอิสระทั้งสองชุดตกมาจนเต็มแลทุกบ่อแล้วยังไม่ทำให้น้องเต่าจมน้ำ ก็จะเข้ากรณีพิเศษที่ฝนหยดสุดท้ายจะตกตรงไหนก็ได้ ซึ่งจะเปลี่ยนไปคูณกับวิธีที่น้ำฝนตกมา $d$ หยดแล้วเต็มบ่อพอดีโดยที่ไม่เคยท่วมน้องเต่าแทน

ให้ $H(i,d)$ เป็นจำนวนวิธีที่น้ำฝนจะไม่ท่วมน้องเต่าเมื่อฝนตกมาแล้ว $d$ หยด โดยพิจารณาเฉพาะบ่อน้ำตั้งแต่บ่อซ้ายสุดจนถึงบ่อที่ $i$ เราจะเขียนสมการสำหรับหา $S$ และ $H$ ได้ดังนี้

\[\begin{align} S(i, d+1) &= \begin{cases} \displaystyle \left( \sum_{k = 1}^i w_k \right) \cdot H(i, d) & \displaystyle \text{if } d = \sum_{k=1}^i c_k \\ \displaystyle \sum_{\substack{a, b \in \mathbb{N}_0 \\ a+b = d}} \binom{d}{b} \cdot w_i^b \cdot S(i, a+1) & \displaystyle \text{if } d \lt \sum_{k=1}^i c_k \\ 0 & \text{otherwise} \end{cases} \\ \\ H(i, d) &= \begin{cases} 1 & \text{if } i = 0 \text{ and } d = 0 \\ \displaystyle \sum_{\substack{a, b \in \mathbb{N}_0 \\ a+b = d}} \binom{d}{b} \cdot w_i^b \cdot H(i, a) & \displaystyle \text{if } d \le \sum_{k=1}^i c_k \\ 0 & \text{otherwise} \end{cases} \end{align}\]

และทำให้ได้ว่าคำตอบคือ

\[E(X) = \sum_{d \in \mathbb{N}} d \cdot \frac{S(m, d)}{n^d}\]

ใช้เทคนิคความน่าจะเป็นเข้าช่วย

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

\[\begin{align} E(X) &= \sum_{d \in \mathbb{N}} d \cdot P(X=d) \\ &= 1 \cdot P(X=1) + 2 \cdot P(X=2) + 3 \cdot P(X=3) + \cdots \\ &= \begin{pmatrix} P(X=1) &+& P(X=2) &+& P(X=3) &+& \cdots \\ &+& P(X=2) &+& P(X=3) &+& \cdots \\ & & &+& P(X=3) &+& \cdots \\ & & & & &+& \ddots \end{pmatrix} \\ &= P(X \ge 1) + P(X \ge 2) + P(X \ge 3) + \cdots \\ &= \sum_{d \in \mathbb{N}} P(X \ge d) \end{align}\]

ดังนั้นเราจะมาแปลความหมายของ $P(X \ge d)$ กัน เพราะว่า $X$ เป็นตัวแปรสุ่มแทนน้ำฝนหยดที่ทำให้น้องเต่าจมน้ำ นั่นก็แปลว่า $X \ge d$ จะหมายถึงเหตการณ์ที่น้ำฝนหยดใดก็ได้ตั้งแต่หยดที่ $d$ ขึ้นไป (จนถึง $\infty$) ที่ทำให้น้องเต่าจมน้ำ หรืออีกนัยหนึ่งก็คือ น้ำฝนตั้งแต่หยดที่ $0$ ถึง $d-1$ นั้นไม่ทำให้น้องเต่าจมน้ำนั่นเอง

ให้ $Y$ เป็นตัวแปรสุ่มแทนจำนวนน้ำฝนที่หยดลงไปแล้วยังไม่ทำให้น้องเต่าจมน้ำ เราจะเห็นทันทีว่า

\[P(X \ge d) = P(Y = d-1)\]

เนื่องจาก $P(Y=d)$ สามารถคำนวณได้จาก ${H(m,d)}$ นี่ทำให้เราสามารถลดการคำนวณเหลือเพียงเท่านี้

\[\begin{align} E(X) &= \sum_{d \in \mathbb{N}_0} P(Y=d) \\ &= \sum_{d \in \mathbb{N}_0} \frac{H(m,d)}{n^d} \end{align}\]

เพื่อหาคำตอบได้เช่นกันโดยไม่ต้องง้อการคำนวณตัวแปร $S$ ที่ยุ่งยากอีกต่อไป

โค้ด

from fractions import Fraction
from itertools import count

def composition(n):
    for i in range(n+1):
        yield i, n-i

def choose(n, r, mem={}):
    if (n,r) not in mem:
        if r == 0 or r == n:
            mem[n,r] = 1
        else:
            mem[n,r] = choose(n-1, r) + choose(n-1, r-1)
    return mem[n,r]

def find_intervals(depths):
    k = 0
    intervals = []
    for i, j in zip(count(0), count(1)):
        if j == len(depths):
            intervals += [(k, j)]
            break
        elif depths[i] < depths[k] and depths[i] < depths[j]:
            intervals += [(k, i)]
            k = i
    return intervals

def transform(depths):
    intervals = find_intervals(depths)
    capacities = [0 for _ in intervals]
    for i, (a, b) in enumerate(intervals):
        for c in range(a, b):
            if depths[c] < depths[a]:
                break
            capacities[i] += depths[c] - depths[a]
    sizes = [b-a for a, b in intervals]
    sizes[0] += 1
    sizes[-1] -= 1
    return capacities, sizes

def calculate_expectation(depths):
    capacities, widths = transform(depths)
    n = len(depths)
    m = len(capacities)
    limit = sum(capacities) + 1
    hold = [[0 for _ in range(limit)] for _ in range(m+1)]
    hold[0][0] = 1
    acc_capacity = 0
    for i, (capacity, width) in enumerate(zip(capacities, widths)):
        acc_capacity += capacity
        for d in range(acc_capacity+1):
            for a, b in composition(d):
                hold[i+1][d] += choose(d, b) * width**b * hold[i][a]
    return sum(Fraction(hold[m][d], n**d) for d in range(limit))

def formatting(frac, prime=1_000_000_007):
    return frac.numerator * pow(frac.denominator, prime-2, prime) % prime

for t in range(int(input())):
    _ = input()
    depths = [int(x) for x in input().split()]
    answer = calculate_expectation(depths)
    print(f'Case #{t+1}: {formatting(answer)}')

neizod

author, illustrator

Nonthaphat Wongwattanakij

coauthor, coder