[0]zero-index clytie0x/zero-index

15.3 อ่านเขียนให้ทันเวลา

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

cin แบบปกติช้าแค่ไหน

โปรแกรมนี้อ่านจำนวนเต็ม N ตัวแล้วหาผลรวม เป็นงานที่ง่ายที่สุดเท่าที่จะนึกได้

#include <bits/stdc++.h>
using namespace std;

int main()
{
    int n;
    cin >> n;
    long long sum = 0;
    for (int i = 0; i < n; i++) {
        int x;
        cin >> x;
        sum += x;
    }
    cout << sum << '\n';
    return 0;
}
5
10 20 30 40 50
150

ลองเพิ่มสองบรรทัดนี้ไว้ต้น main แล้วไม่แก้อย่างอื่นเลย

#include <bits/stdc++.h>
using namespace std;

int main()
{
    ios::sync_with_stdio(false); // (1)!
    cin.tie(nullptr); // (2)!
    int n;
    cin >> n;
    long long sum = 0;
    for (int i = 0; i < n; i++) {
        int x;
        cin >> x;
        sum += x;
    }
    cout << sum << '\n';
    return 0;
}
  1. เลิกประสานงานกับ scanf printf อธิบายในหัวข้อทำไมสองบรรทัดนี้ถึงเร็ว
  2. เลิกสั่งให้ cout ส่งของใน buffer ออกไปทุกครั้งก่อน cin อ่าน อธิบายในหัวข้อเดียวกัน
5
10 20 30 40 50
150

ผลเหมือนกัน ความต่างอยู่ที่เวลา ทดลองกับไฟล์ข้อมูลเข้าที่มี N = 106 และตัวเลขสุ่ม 1 ถึง 109 (ไฟล์ขนาด 9.9 MB) เทียบกับโปรแกรมเดียวกันที่อ่านด้วย scanf และพิมพ์ด้วย printf

อ่าน 106 ตัวแล้วหาผลรวมแบบ Code::Blocksแบบ -O2
scanf0.34 วินาที0.34 วินาที
cin แบบปกติ0.96 วินาที0.97 วินาที
cin เพิ่มสองบรรทัด0.08 วินาที0.08 วินาที

แค่อ่านอย่างเดียว cin แบบปกติก็ใช้เกือบ 1 วินาทีแล้ว ช้ากว่า scanf เกือบสามเท่า พอเพิ่มสองบรรทัด กลับเร็วกว่า scanf อีกสี่เท่า

อ่านด้วย พิมพ์ด้วย

งานที่หนักกว่าคือพิมพ์ออกมาทีละบรรทัดด้วย เช่นยอดเงินสะสมหลังแต่ละวัน ข้อมูลเข้าแบบเดียวกัน แต่คราวนี้พิมพ์ผลรวมสะสมทุกครั้งที่อ่าน รวม 106 บรรทัด

#include <bits/stdc++.h>
using namespace std;

int main()
{
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    int n;
    cin >> n;
    long long sum = 0;
    for (int i = 0; i < n; i++) {
        int x;
        cin >> x;
        sum += x;
        cout << sum << '\n';
    }
    return 0;
}
5
10 20 30 40 50
10
30
60
100
150

เทียบหกแบบ ทุกแบบต่างจากโปรแกรมข้างบนแค่ตรงที่บอกไว้ในตาราง

อ่าน 106 ตัว พิมพ์ 106 บรรทัดแบบ Code::Blocksแบบ -O2
scanf กับ printf0.61 วินาที0.62 วินาที
cin cout แบบปกติ3.1 วินาที3.5 วินาที
cin cout แบบปกติ ใช้ endl3.8 วินาที3.9 วินาที
มีสองบรรทัด ใช้ '\n'0.18 วินาที0.18 วินาที
มีสองบรรทัด แต่ใช้ endl2.4 วินาที2.6 วินาที
มีแค่ sync_with_stdio(false) ไม่มี cin.tie(nullptr)2.3 วินาที2.2 วินาที

วัดบนเครื่องที่เขียนเอกสารนี้ โน้ตบุ๊ก AMD Ryzen AI 9 HX 370 แรม 32 GB ระบบ Windows 11 เสียบสายชาร์จ โหมดพลังงาน Balanced คอมไพล์ด้วย GCC 14.2 ของ Code::Blocks ทั้งแบบ -Wall อย่างเดียวที่ Code::Blocks ใช้ และแบบเพิ่ม -O2 ที่ grader มักใช้ ป้อนข้อมูลจากไฟล์และให้ผลลัพธ์ลงไฟล์แบบเดียวกับ grader รันแบบละ 6 ครั้ง ทิ้งครั้งแรก แล้วใช้ค่ากลางของ 5 ครั้งที่เหลือ แต่ละครั้งได้ไม่เท่ากัน บางแบบต่างกันเกือบหนึ่งในสาม ตัวเลขจึงใช้ดูว่าเร็วช้ากี่เท่า ไม่ต้องจำทศนิยม

อ่านตารางได้สามเรื่อง

  1. cin cout แบบปกติไม่ทัน 1 วินาที ช้ากว่า scanf printf ราวห้าเท่า
  2. ต้องมีครบทั้งสองบรรทัด ขาด cin.tie(nullptr) บรรทัดเดียวก็ช้ากว่าแบบครบสิบกว่าเท่า
  3. endl ทำลายทุกอย่าง แม้มีสองบรรทัดครบ ถ้าขึ้นบรรทัดด้วย endl ก็ช้าลงสิบกว่าเท่า

ส่วน -O2 แทบไม่ช่วยเลย เพราะเวลาไม่ได้หมดไปกับโค้ดของเรา แต่หมดไปกับการรับส่งข้อมูล ซึ่ง compiler ปรับให้ไม่ได้

ทำไมสองบรรทัดนี้ถึงเร็ว

ios::sync_with_stdio(false)

C++ ยอมให้ใช้ scanf printf ปนกับ cin cout ในโปรแกรมเดียวได้โดยลำดับไม่เพี้ยน เพราะตั้งต้นไว้ให้สองฝั่งประสานงานกัน (synchronize) ทุกครั้งที่อ่านหรือพิมพ์ ฝั่ง cin cout จึงไม่มี buffer เป็นของตัวเอง ต้องส่งต่อไปให้ฝั่งภาษา C ทีละน้อย ซึ่งเสียเวลามาก

ios::sync_with_stdio(false) คือบอกว่า "ไม่ต้องประสานงานแล้ว" cin กับ cout ได้ buffer ของตัวเอง อ่านเขียนทีละก้อนใหญ่ได้เต็มที่ ราคาที่ต้องจ่ายคือห้ามใช้ scanf printf ในโปรแกรมนั้นอีก ดูในส่วนลองทำให้พังว่าถ้าฝืนจะเป็นอย่างไร

cin.tie(nullptr)

ถ้าโปรแกรมพิมพ์คำถามว่า Enter your age: ด้วย cout แล้วรอรับด้วย cin คำถามต้องขึ้นจอก่อน ไม่อย่างนั้นคนใช้จะเห็นจอว่างๆ โดยไม่รู้ว่าต้องพิมพ์อะไร C++ จึงตั้งต้นให้ cin ผูก (tie) อยู่กับ cout ก่อน cin อ่านทุกครั้ง จะสั่ง flush ให้ cout ส่งของใน buffer ออกไปก่อน

โปรแกรมที่อ่านหนึ่งตัวแล้วพิมพ์หนึ่งบรรทัดสลับกันเป็นล้านครั้ง จึงโดน flush เป็นล้านครั้ง ผลเท่ากับใช้ endl ทุกบรรทัด ซึ่งตรงกับตัวเลขในตาราง cin.tie(nullptr) คือแก้ให้ cin ไม่ผูกกับอะไรเลย (nullptr คือ "ไม่มีอะไร" แบบเดียวกับ NULL ในบทที่ 10) โปรแกรมแข่งขันไม่ได้พิมพ์คำถามรอใครอยู่แล้ว จึงไม่เสียอะไร บางคนเขียน cin.tie(0) หรือ cin.tie(NULL) ซึ่งได้ผลเหมือนกัน

โครงที่ใช้ทุกโปรแกรม

รวมทุกอย่างในหน้า 15.1 ถึง 15.3 โปรแกรมแข่งขันจะเริ่มแบบนี้ ตั้งแต่ตรงนี้ไป ทุกโปรแกรมในเอกสารนี้ที่อ่านข้อมูลเยอะ หรือไม่รู้ว่าจะอ่านกี่ตัว จะมีสองบรรทัดนี้ โปรแกรมสั้นๆ ที่อ่านแค่ไม่กี่ค่าอาจไม่ใส่ เพื่อให้ตัวอย่างสั้น แต่เวลาส่งโจทย์จริง ใส่ไว้ทุกครั้งไม่เสียอะไร

#include <bits/stdc++.h>
using namespace std;

int main()
{
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    return 0;
}

โครงนี้คอมไพล์และรันได้เลย แค่ยังไม่ได้ทำอะไร เขียนโค้ดของข้อนั้นลงในบรรทัดว่างตรงกลาง

กฎสามข้อของการอ่านเขียนให้เร็ว

หนึ่ง ใส่สองบรรทัดไว้บนสุดของ main ก่อนอ่านหรือพิมพ์อะไรทั้งนั้น สอง ขึ้นบรรทัดใหม่ด้วย '\n' ไม่ใช้ endl สาม ใช้ cin cout อย่างเดียว ห้ามมี scanf printf getchar puts ปน

ลองทำให้พัง

แบบที่ 1 ปิด sync แล้วอ่านปนกัน

น้องย้ายโปรแกรมหาผลรวมจากภาษา C มาเป็น C++ ใส่สองบรรทัดตามกฎแล้ว แต่ลืมแก้ scanf ในลูปไปหนึ่งที่

#include <bits/stdc++.h>
using namespace std;

int main()
{
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    int n;
    cin >> n;
    long long sum = 0;
    for (int i = 0; i < n; i++) {
        int x = 0;
        scanf("%d", &x);
        sum += x;
    }
    cout << sum << '\n';
    return 0;
}

คอมไพล์ผ่านไม่มีคำเตือน บนเครื่องที่เขียนเอกสารนี้ ทดลองป้อนข้อมูลชุดเดิม 5 แล้ว 10 20 30 40 50 ทีละบรรทัดแบบคนพิมพ์ ได้ 150 ถูกต้อง แต่พอป้อนข้อมูลชุดเดียวกันจากไฟล์แบบที่ grader ทำ ได้ 0

สาเหตุคือ cin ที่มี buffer ของตัวเองแล้วจะอ่านข้อมูลเข้ามาทีละก้อนใหญ่ ตอนอ่าน n จากไฟล์ มันดึงข้อมูลก้อนใหญ่ไปเก็บใน buffer ของ cin ในครั้งเดียว ไฟล์เล็กแบบนี้ก็คือทั้งไฟล์ scanf มาถึงทีหลังเลยไม่เหลืออะไรให้อ่าน x จึงเป็น 0 ทุกรอบ ส่วนตอนพิมพ์เอง ข้อมูลมาทีละบรรทัด cin ได้แค่บรรทัดแรกไป บรรทัดที่สองยังรอ scanf อยู่ จึงบังเอิญถูก บั๊กแบบนี้ทดสอบในเครื่องตัวเองผ่าน แต่ไปตกใน grader

แบบที่ 2 ปิด sync แล้วพิมพ์ปนกัน

#include <bits/stdc++.h>
using namespace std;

int main()
{
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    for (int day = 1; day <= 3; day++) {
        cout << "day " << day << '\n';
        printf("score %d\n", day * 10);
    }
    return 0;
}

ตั้งใจให้ day กับ score สลับกันทีละบรรทัด บนเครื่องที่เขียนเอกสารนี้ ให้ผลลัพธ์ลงไฟล์แบบที่ grader ทำ แล้วเปิดไฟล์ดู ได้แบบนี้

day 1
day 2
day 3
score 10
score 20
score 30

cout กับ printf ต่างคนต่างเก็บของไว้ใน buffer ของตัวเอง แล้วส่งออกตอนไหนก็ได้ที่แต่ละฝั่งสะดวก ลำดับจึงเพี้ยน grader ตรวจว่าผิดทั้งที่ตัวเลขถูกทุกตัว

แบบที่ 3 ใส่บรรทัดเดียว หรือใช้ endl

สองแบบนี้คำตอบถูกทุกตัว แต่ช้า จากตารางข้างบน ไม่มี cin.tie(nullptr) ใช้ 2.3 วินาที และมีสองบรรทัดแต่ใช้ endl ใช้ 2.4 วินาที ทั้งที่แบบถูกใช้แค่ 0.18 วินาที ถ้าโจทย์จำกัด 1 วินาที grader จะให้ผลว่า Time Limit Exceeded ทั้งสองแบบ คนที่ไม่รู้เรื่องนี้จะนั่งหาบั๊กในวิธีคิดทั้งที่วิธีคิดไม่ผิด

สรุป

  • cin cout แบบปกติช้ากว่า scanf printf ราวสามถึงห้าเท่า ข้อมูลล้านตัวไม่ทัน 1 วินาที
  • ใส่ ios::sync_with_stdio(false); กับ cin.tie(nullptr); ไว้บนสุดของ main แล้วจะเร็วกว่า scanf printf ด้วยซ้ำ
  • sync_with_stdio(false) เลิกประสานกับฝั่งภาษา C จึงห้ามใช้ scanf printf ปน
  • cin.tie(nullptr) เลิก flush cout ก่อนทุกครั้งที่ cin อ่าน
  • endl ในลูปใหญ่ทำให้ช้าลงสิบกว่าเท่า ใช้ '\n'
  • บั๊กจากการอ่านปนกันอาจไม่โผล่ตอนพิมพ์เอง แต่โผล่ใน grader ที่อ่านจากไฟล์