สวิตช์เวลา (timer switch)

toi13_timerswitch

สำหรับข้อนี้ วิธีการตรงๆที่สามารถคิดได้ไม่ยาก คือดังนี้

เริ่มด้วยบิตสตริง SS (สำหรับข้อนี้ จะถือว่าเริ่มต้นด้วย index 00 จนจบที่ N1N-1)

เราจะสร้างฟังก์ชัน ff นิยามโดย

void f(string &S) {
  S.push_back(S.front());
  S.erase(S.begin());
}

แล้วสามารถไล่ว่าทำฟังก์ชัน ff ซ้ำกี่ครั้งจึงจะได้ SS เหมือนเดิม ดังนี้

string S;
string now = S;
for (int rep = 1; rep <= N; rep++) {
  f(now);
  if (now == S) {
    // Answer is rep.
    break;
  }
}

ซึ่งกระบวนการดังกล่าวใช้เวลา O(N2)\mathcal{O}(N^2) เพราะฟังก์ชัน ff และการเทียบสตริงจะใช้เวลา O(N)\mathcal{O}(N) และมีการทำซ้ำทั้งหมด NN รอบ

อีกหนึ่งวิธีในการ optimize เบื้องต้น คือการเปลี่ยนการเก็บบิตสตริงจาก string เป็น bitset ซึ่งจะทำให้เร็วขึ้น 8 เท่า แต่ยังคง O(N2)\mathcal{O}(N^2) อยู่ จึงไม่ช่วยอะไรมาก

ต่อมาจะเป็นวิธีการ optimize เพื่อทำเวลาให้ดีขึ้นอย่างมีนัยสำคัญ โดยวิธีหลักๆมีอยู่ 2 วิธี

  1. ใช้ข้อสังเกตบางอย่างทำให้เราไม่จำเป็นต้องเทียบสตริงทุก rep
  2. ใช้ความรู้เรื่อง rolling hash เพื่อลดเวลาการทำ ff และ การเทียบสตริง

วิธีที่ 1

สังเกตว่าคำตอบที่ออกมา จะเป็นตัวประกอบ (factor) ของ NN เท่านั้น วิธีการหนึ่งที่เป็นไปได้คือการหาตัวประกอบเตรียมรอไว้ก่อน แล้วเปลี่ยนนิยามฟังก์ชัน ff เพื่อให้ทำงานได้เร็วขึ้น

เราสร้าง SSSS นิยามโดยนำสตริง SS มาต่อกันสองครั้ง (เช่น S = "101110" จะได้ SS = "101110101110")

สังเกตว่าหากต้องการทำฟังก์ชัน ff ไป KK รอบ จะได้ผลลัพธ์เป็นส่วนตัดของสตริง SSSS ในช่วง KK ถึง K+N1K+N-1

สมมติเราเก็บตัวประกอบทุกตัวของ NN ไว้ในเซต (จริงๆคือเวกเตอร์) DD แล้ว (ตัวอย่างโค้ดด้านล่าง)

int N;
vector<int> D;
for (int i = 1; i <= N; i++) {
  if (N % i == 0)
    D.push_back(i);
}

ต่อมาเราจะต้องไล่เช็คทุกส่วนตัดบนตัวประกอบแต่ละตัวว่าเหมือนกับ S หรือไม่ หากเหมือนกันให้นำตัวประกอบน้อยสุดมาคิด

string S;
string SS = S + S;
for (int factor : D) {
  bool ok = true;
  for (int i = 0; i < N; i++) {
    if (SS[factor + i] == S[i]) {
      // Everything is OK
    } else {
      // Mismatch!
      ok = false;
      break;
    }
  }
  if (ok) {
    // Answer is factor.
    break;
  }
}

จากการกระทำข้างต้น เราจะได้ Time Complexity เป็น O(ND)\mathcal{O}(ND) เมื่อ DD แทนจำนวนตัวประกอบของ NN (ซึ่งโดยทั่วไปแล้วมีค่าน้อย สามารถพิสูจน์ได้โดยง่ายว่า D2ND \leq 2\sqrt{N} จากการสังเกตว่าหาก DND \vert N แล้ว NDN\frac{N}{D} \vert N ด้วย)

วิธีที่ 2

ในวิธีนี้เราจะดัดแปลง Rabin-Karp Algorithm โดยจากเดิมที่เป็นการเทียบหา pattern ยาว MM ใน text ยาว NN ด้วยเวลา O(N+M)\mathcal{O}(N+M) เราจะใช้ Rolling Hash เพื่อเปลี่ยนสถานะสตริงตามการหมุนของสวิทช์เวลา สมมติเรามี Hash function h:{0,1}NNh: \{0,1\}^{N} \rightarrow \mathbb{N} นิยามโดย h(T):=i=0N1TiBN1ih(T) := \sum\limits_{i=0}^{N-1}{T_iB^{N-1-i}} สำหรับบางจำนวนนับ BB เราสามารถเทียบว่าสองสตริงเท่ากันได้หรือไม่โดยการเทียบว่าเมื่อผ่าน h ไปแล้ว จะเท่ากันหรือไม่ เพราะ S=Th(S)=h(T)S = T \leftrightarrow h(S) = h(T) ปัญหาคือ h(T)O(BT)h(T) \in \mathcal{O}(B^{|T|}) ทำให้อาจเกินขอบเขตของ int หรือ long long ได้ โดยจะมีวิธีแก้ง่ายๆเช่น ปล่อยเอาไว้อย่างนั้น เพราะ หากจำนวน overflow ไปเท่าไร มันจะ overflow ไปเท่ากัน หรืออีกวิธีคือนำไป modp\mod p สำหรับบางจำนวนนับ pp (นิยมจำนวนเฉพาะ) แต่จะทำให้ได้ว่า S=Th(S)=h(T)S = T \leftarrow h(S) = h(T) ไม่เป็นสัจนิรันดร์อีกต่อไป (มีกรณีที่อาจซ้ำได้) แต่เราสามารถคาดการณ์ได้ว่ามีโอกาสน้อยมากที่จะซ้ำกันเกิดขึ้น จึงสามารถใช้ต่อได้ อัลกอริทึมดังกล่าวจึงเป็น Monte Carlo Algorithm (หากกังวล สามารถใช้ double hash เพื่อลดโอกาสซ้ำ หรือ brute force ทุกครั้งที่พบว่าเท่ากัน เพื่อแปลงเป็น Las Vegas Algorithm)

ข้อดีของการทำ Rolling Hash คือเมื่อเราต้องการเคลื่อนย้ายตัวหน้าสุดไปไว้หลังสุด เราสามารถนำ h(T)h(T) มาลบออกด้วย T0BN1T_0B^{N-1} แล้วคูณด้วย BB แล้วค่อยบวกกลับด้วย T0T_0 กล่าวคือ h(f(T)):=B(h(T)T0BN1)+T0h(f(T)) := B(h(T)-T_0B^{N-1})+T_0 ได้เลย โดยไม่ต้องคำนวณ h(f(T))h(f(T)) ใหม่ตั้งแต่ต้น

เราทำการคำนวณ h(f(S))h(f(S)), h(f(f(S)))h(f(f(S))), \dots จนกว่าจะพบว่า h(f(f(f(S))))=h(S)h(f(f(\dots f(S)))) = h(S)

การคำนวณ h(T)h(T) แต่ละรอบ ใช้เวลา O(T)\mathcal{O}(T) แต่หากมี h(T)h(T) อยู่แล้ว สามารถหา h(f(T))h(f(T)) ได้ใน O(1)\mathcal{O}(1) ตามวิธีการข้างต้น สุดท้ายจึงใช้เวลารวม O(N)\mathcal{O}(N)

โค้ดตัวอย่าง

const int B = 53; // Select some random rolling hash base
int h(string T) {
  int ret = 0;
  for (int i = 0; i < T.size(); i++) {
    ret *= B;          // Multiply by base
    ret += T[i] - '0'; // Convert char '0','1' to integer 0, 1.
                       // Let ret overflow...
  }
  return ret;
}
string S;
string SS = S + S;
int N = S.size();
const int target = h(S);
int currentHash = target;
int highestMultiplier = 1;
for (int i = 0; i < N - 1; i++)
  highestMultiplier *= B;
for (int i = 0; i < N; i++) {
  currentHash -= (S[i] - '0') * highestMultiplier;
  currentHash *= B;
  currentHash += S[i] - '0';
  if (currentHash == target) {
    // Answer should be i+1
    // Recheck for assurance
    bool correct = true;
    for (int j = 0; j < N; j++) {
      if (SS[i + 1 + j] == S[j]) {
        // Still OK
      } else {
        correct = false;
        break;
      }
    }
    if (correct) {
      // Answer is exactly i+1
      break;
    }
  }
}