อินสแตนซ์บางตัว (ค่อนข้าง) ขั้นพื้นฐาน (คิดว่านักเรียนระดับ CS ปีแรกของวิทยาลัย) เมื่อไรที่เราจะใช้การเรียกซ้ำแทนการวนซ้ำ?
อินสแตนซ์บางตัว (ค่อนข้าง) ขั้นพื้นฐาน (คิดว่านักเรียนระดับ CS ปีแรกของวิทยาลัย) เมื่อไรที่เราจะใช้การเรียกซ้ำแทนการวนซ้ำ?
คำตอบ:
ฉันสอน C ++ ให้กับนักศึกษาปริญญาตรีเป็นเวลาประมาณสองปีและครอบคลุมการสอบถามซ้ำ จากประสบการณ์ของฉันคำถามและความรู้สึกของคุณเป็นเรื่องธรรมดามาก อย่างมากนักเรียนบางคนมองว่าการเรียกซ้ำเป็นเรื่องยากที่จะเข้าใจในขณะที่คนอื่นต้องการใช้มันเพื่อทุกสิ่ง
ฉันคิดว่าเดฟสรุปได้ดี: ใช้ในที่ที่เหมาะสม นั่นคือใช้เมื่อรู้สึกเป็นธรรมชาติ เมื่อคุณเผชิญกับปัญหาที่มันเข้ากันได้ดีคุณมักจะจำมันได้: มันจะดูเหมือนว่าคุณไม่สามารถคิดหาวิธีแก้ซ้ำได้ นอกจากนี้ความชัดเจนเป็นสิ่งสำคัญของการเขียนโปรแกรม คนอื่น ๆ (และคุณด้วย!) ควรจะสามารถอ่านและเข้าใจรหัสที่คุณสร้างขึ้นได้ ฉันคิดว่ามันปลอดภัยที่จะบอกว่าลูปวนซ้ำนั้นง่ายต่อการเข้าใจตั้งแต่แรกพบมากกว่าการเรียกซ้ำ
ฉันไม่รู้ว่าคุณรู้จักการเขียนโปรแกรมหรือวิทยาศาสตร์คอมพิวเตอร์เป็นอย่างไร แต่ฉันรู้สึกว่ามันไม่สมเหตุสมผลเลยที่จะพูดถึงฟังก์ชั่นเสมือนการสืบทอดหรือแนวคิดขั้นสูงอื่น ๆ ที่นี่ ฉันมักจะเริ่มต้นด้วยตัวอย่างคลาสสิกของการคำนวณตัวเลขฟีโบนักชี มันพอดีกับที่นี่เป็นอย่างดีเนื่องจากตัวเลข Fibonacci มีการกำหนดซ้ำ นี้เป็นเรื่องง่ายที่จะเข้าใจและไม่จำเป็นต้องใด ๆแฟนซีมีของภาษา หลังจากนักเรียนได้รับความเข้าใจพื้นฐานเกี่ยวกับการเรียกซ้ำเราได้ดูอีกฟังก์ชั่นง่ายๆที่เราสร้างขึ้นก่อนหน้านี้ นี่คือตัวอย่าง:
สตริงมีอักขระหรือไม่?
นี่คือวิธีที่เราทำมันก่อน: สำทับสตริงและดูว่าดัชนีมีx
bool find(const std::string& s, char x)
{
for(int i = 0; i < s.size(); ++i)
{
if(s[i] == x)
return true;
}
return false;
}
คำถามก็คือเราสามารถทำซ้ำได้หรือไม่? แน่นอนว่าเราสามารถทำได้ด้วยวิธีนี้:
bool find(const std::string& s, int idx, char x)
{
if(idx == s.size())
return false;
return s[idx] == x || find(s, ++idx);
}
คำถามธรรมชาติต่อไปคือเราควรทำเช่นนี้หรือไม่ อาจจะไม่. ทำไม? มันยากที่จะเข้าใจและยากที่จะเกิดขึ้น ดังนั้นจึงมีแนวโน้มที่จะเกิดข้อผิดพลาดได้เช่นกัน
วิธีการแก้ปัญหาบางอย่างเป็นธรรมชาติมากขึ้นโดยใช้การเรียกซ้ำ
ตัวอย่างเช่นสมมติว่าคุณมีโครงสร้างข้อมูลแบบต้นไม้ที่มีโหนดสองชนิดคือใบไม้ซึ่งเก็บค่าจำนวนเต็ม และสาขาซึ่งมีต้นไม้ย่อยซ้ายและขวาในสาขาของพวกเขา สมมติว่ามีการจัดเรียงใบเพื่อให้ค่าต่ำสุดอยู่ในใบซ้ายสุด
สมมติว่างานคือการพิมพ์ค่าของต้นไม้ตามลำดับ อัลกอริทึมแบบเรียกซ้ำสำหรับการทำเช่นนี้ค่อนข้างเป็นธรรมชาติ:
class Node { abstract void traverse(); }
class Leaf extends Node {
int val;
void traverse() { print(val); }
}
class Branch extends Node {
Node left, right;
void traverse() { left.traverse(); right.traverse(); }
}
การเขียนโค้ดที่เทียบเท่าโดยไม่ต้องเรียกซ้ำจะยากกว่ามาก ลองมัน!
โดยทั่วไปการเรียกใช้ซ้ำจะทำงานได้ดีสำหรับอัลกอริทึมในโครงสร้างข้อมูลแบบเรียกซ้ำเช่นต้นไม้หรือสำหรับปัญหาที่สามารถแบ่งย่อยเป็นปัญหาย่อย ตรวจสอบเช่นแบ่งและพิชิตอัลกอริทึม
หากคุณต้องการเห็นการเรียกซ้ำในสภาพแวดล้อมที่เป็นธรรมชาติที่สุดคุณควรดูภาษาการเขียนโปรแกรมที่ใช้งานได้เช่น Haskell ในภาษาดังกล่าวไม่มีโครงสร้างวนซ้ำดังนั้นทุกอย่างแสดงออกโดยใช้การเรียกซ้ำ (หรือฟังก์ชันที่มีลำดับสูงกว่า แต่นั่นเป็นอีกเรื่องหนึ่ง
โปรดทราบว่าภาษาโปรแกรมที่ใช้งานได้ดำเนินการเรียกซ้ำหางที่ปรับให้เหมาะสม ซึ่งหมายความว่าพวกเขาไม่ได้วางเฟรมสแต็กเว้นแต่ว่าพวกเขาไม่ต้องการ --- โดยพื้นฐานแล้วการเรียกซ้ำสามารถถูกแปลงเป็นลูป จากมุมมองที่ใช้งานได้จริงคุณสามารถเขียนโค้ดแบบเป็นธรรมชาติ แต่รับประสิทธิภาพของรหัสซ้ำ สำหรับเร็กคอร์ดดูเหมือนว่าคอมไพเลอร์ C ++ ยังปรับการเรียกหางให้เหมาะสมดังนั้นจึงไม่มีค่าใช้จ่ายเพิ่มเติมในการใช้การเรียกซ้ำใน C ++
จากคนที่มีชีวิตอยู่ในการเรียกซ้ำฉันจะพยายามทำให้กระจ่างในเรื่องนี้
เมื่อได้รับการแนะนำให้รู้จักกับการเรียกซ้ำครั้งแรกคุณจะได้เรียนรู้ว่ามันเป็นฟังก์ชั่นที่เรียกตัวเองและแสดงให้เห็นโดยทั่วไปด้วยอัลกอริทึมเช่นการแวะผ่านต้นไม้ หลังจากนั้นคุณจะพบว่ามันถูกใช้อย่างมากในการเขียนโปรแกรมฟังก์ชั่นสำหรับภาษาเช่น LISP และ F # ด้วย F # ฉันเขียนสิ่งที่ฉันเขียนส่วนใหญ่คือการเรียกซ้ำและจับคู่รูปแบบ
หากคุณเรียนรู้เพิ่มเติมเกี่ยวกับการเขียนโปรแกรมที่ใช้งานได้เช่น F # คุณจะได้เรียนรู้รายการ F #ที่นำมาใช้เป็นรายการที่เชื่อมโยงเดี่ยวซึ่งหมายความว่าการดำเนินการที่เข้าถึงเฉพาะส่วนหัวของรายการคือ O (1) และการเข้าถึงองค์ประกอบคือ O (n) เมื่อคุณเรียนรู้สิ่งนี้คุณมักจะสำรวจข้อมูลเป็นรายการสร้างรายชื่อใหม่ในลำดับย้อนกลับแล้วกลับรายการก่อนกลับจากฟังก์ชั่นที่มีประสิทธิภาพมาก
ทีนี้ถ้าคุณเริ่มคิดถึงเรื่องนี้ในไม่ช้าคุณก็จะรู้ว่าฟังก์ชั่นวนซ้ำจะผลักเฟรมสแต็กทุกครั้งที่มีการเรียกใช้ฟังก์ชันและอาจทำให้สแต็กล้น อย่างไรก็ตามหากคุณสร้างฟังก์ชั่นวนซ้ำเพื่อให้สามารถเรียก tailและคอมไพเลอร์สนับสนุนความสามารถในการเพิ่มประสิทธิภาพรหัสสำหรับการโทรหาง เช่น. NET OpCodes.Tailcall Fieldคุณจะไม่ทำให้เกิดการล้นสแต็ค ณ จุดนี้คุณเริ่มเขียนการวนซ้ำเป็นฟังก์ชันแบบเรียกซ้ำและการตัดสินใจใด ๆ เป็นการจับคู่ วันif
และwhile
ตอนนี้เป็นประวัติศาสตร์
เมื่อคุณย้ายไปที่ AI โดยใช้การย้อนรอยในภาษาเช่น PROLOG ทุกอย่างจะเกิดซ้ำ ถึงแม้ว่าสิ่งนี้ต้องการการคิดในลักษณะที่ค่อนข้างแตกต่างจากรหัสที่จำเป็นถ้า PROLOG เป็นเครื่องมือที่เหมาะสมสำหรับปัญหามันทำให้คุณปราศจากภาระในการเขียนบรรทัดของโค้ดจำนวนมากและสามารถลดจำนวนข้อผิดพลาดได้อย่างมาก ดู: ลูกค้า Amzi eoTek
หากต้องการกลับไปที่คำถามของคุณว่าควรใช้การเรียกซ้ำเมื่อใด วิธีหนึ่งที่ฉันดูการเขียนโปรแกรมคือฮาร์ดแวร์ที่ปลายด้านหนึ่งและแนวคิดนามธรรมที่ปลายอีกด้านหนึ่ง ยิ่งฉันเข้าใกล้ฮาร์ดแวร์มากเท่าไหร่ฉันยิ่งมีความจำเป็นต้องใช้ภาษาที่จำเป็นif
และwhile
ยิ่งปัญหาเป็นนามธรรมมากเท่าไหร่ฉันก็ยิ่งคิดถึงภาษาในระดับสูงมากขึ้นเท่านั้น อย่างไรก็ตามหากคุณเริ่มเขียนรหัสระบบในระดับต่ำและเช่นนั้นและคุณต้องการตรวจสอบว่าถูกต้องแล้วคุณจะพบวิธีแก้ปัญหาเช่นผู้พิสูจน์ทฤษฎีบทมีประโยชน์ซึ่งต้องพึ่งพาการเรียกซ้ำ
หากคุณดูที่Jane Streetคุณจะเห็นว่าพวกเขาใช้ภาษาOCaml ที่ใช้งานได้ ในขณะที่ฉันไม่ได้เห็นรหัสใด ๆ ของพวกเขาจากการอ่านเกี่ยวกับสิ่งที่พวกเขาพูดถึงเกี่ยวกับรหัสของพวกเขาพวกเขากำลังคิดอย่างซ้ำซาก
แก้ไข
เนื่องจากคุณกำลังมองหารายการใช้ฉันจะให้แนวคิดพื้นฐานของสิ่งที่มองหาในรหัสและรายการของการใช้งานพื้นฐานซึ่งส่วนใหญ่จะขึ้นอยู่กับแนวคิดของCatamorphismซึ่งเกินพื้นฐาน
สำหรับ C ++: หากคุณกำหนดโครงสร้างหรือคลาสที่มีตัวชี้ไปยังโครงสร้างหรือคลาสเดียวกันการเรียกซ้ำควรพิจารณาสำหรับวิธีการสำรวจเส้นทางที่ใช้พอยน์เตอร์
กรณีง่าย ๆ คือรายการที่เชื่อมโยงทางเดียว คุณจะประมวลผลรายการที่เริ่มต้นที่หัวหรือส่วนท้ายจากนั้นวนซ้ำรายการโดยใช้พอยน์เตอร์
ต้นไม้เป็นอีกกรณีหนึ่งที่มักใช้การเรียกซ้ำ มากจนถ้าคุณเห็นการแวะผ่านต้นไม้โดยไม่มีการเรียกซ้ำคุณควรเริ่มถามว่าทำไม? มันไม่ผิด แต่เป็นสิ่งที่ควรสังเกตในความคิดเห็น
การใช้การสอบถามซ้ำทั่วไปคือ:
เพื่อให้กรณีใช้งานที่มีความลับน้อยกว่าคำตอบอื่น ๆ : การเรียกซ้ำผสมกันได้ดีกับโครงสร้างคลาสเหมือนต้นไม้ (Object Oriented) ที่ได้มาจากแหล่งข้อมูลทั่วไป ตัวอย่าง C ++:
class Expression {
public:
// The "= 0" means 'I don't implement this, I let my subclasses do that'
virtual int ComputeValue() = 0;
}
class Plus : public Expression {
private:
Expression* left
Expression* right;
public:
virtual int ComputeValue() { return left->ComputeValue() + right->ComputeValue(); }
}
class Times : public Expression {
private:
Expression* left
Expression* right;
public:
virtual int ComputeValue() { return left->ComputeValue() * right->ComputeValue(); }
}
class Negate : public Expression {
private:
Expression* expr;
public:
virtual int ComputeValue() { return -(expr->ComputeValue()); }
}
class Constant : public Expression {
private:
int value;
public:
virtual int ComputeValue() { return value; }
}
ตัวอย่างข้างต้นใช้การเรียกซ้ำ: ComputeValue ถูกนำไปใช้ซ้ำ ในการทำให้ตัวอย่างทำงานคุณใช้ฟังก์ชันเสมือนและการสืบทอด คุณไม่รู้ว่าชิ้นส่วนด้านซ้ายและขวาของชั้น Plus เป็นอย่างไร แต่คุณไม่สนใจ: มันเป็นสิ่งที่สามารถคำนวณคุณค่าของตัวเองซึ่งเป็นสิ่งที่คุณต้องรู้
ข้อได้เปรียบที่สำคัญของวิธีการดังกล่าวคือทุกระดับจะดูแลการคำนวณของตัวเอง คุณแยกการใช้งานที่แตกต่างกันของนิพจน์ย่อยที่เป็นไปได้ทั้งหมด: พวกมันไม่มีความรู้ในการทำงานของกันและกัน สิ่งนี้ทำให้การให้เหตุผลเกี่ยวกับโปรแกรมง่ายขึ้นและทำให้โปรแกรมง่ายต่อการเข้าใจบำรุงรักษาและขยายเพิ่มเติม
ตัวอย่างแรกที่ใช้ในการสอนการเรียกซ้ำในคลาสการเขียนโปรแกรมเริ่มต้นของฉันคือฟังก์ชั่นเพื่อแสดงรายการตัวเลขทั้งหมดในตัวเลขแยกกันในลำดับที่กลับกัน
void listDigits(int x){
if (x <= 0)
return;
print x % 10;
listDigits(x/10);
}
หรืออะไรทำนองนั้น (ฉันไปจากความทรงจำที่นี่และไม่ทดสอบ) นอกจากนี้เมื่อคุณเข้าเรียนในระดับที่สูงขึ้นคุณจะใช้การเรียกซ้ำจำนวนมากโดยเฉพาะในขั้นตอนวิธีการค้นหาการเรียงลำดับอัลกอริทึม ฯลฯ
ดังนั้นมันอาจดูเหมือนว่าฟังก์ชั่นไร้ประโยชน์ในภาษานี้ แต่มันมีประโยชน์มากในระยะยาว