ใช้การสอบถามซ้ำเมื่อใด


26

อินสแตนซ์บางตัว (ค่อนข้าง) ขั้นพื้นฐาน (คิดว่านักเรียนระดับ CS ปีแรกของวิทยาลัย) เมื่อไรที่เราจะใช้การเรียกซ้ำแทนการวนซ้ำ?


2
คุณสามารถเปลี่ยนการเรียกซ้ำเป็นลูป (ด้วยสแต็ก)
Kaveh

คำตอบ:


18

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

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

ฉันไม่รู้ว่าคุณรู้จักการเขียนโปรแกรมหรือวิทยาศาสตร์คอมพิวเตอร์เป็นอย่างไร แต่ฉันรู้สึกว่ามันไม่สมเหตุสมผลเลยที่จะพูดถึงฟังก์ชั่นเสมือนการสืบทอดหรือแนวคิดขั้นสูงอื่น ๆ ที่นี่ ฉันมักจะเริ่มต้นด้วยตัวอย่างคลาสสิกของการคำนวณตัวเลขฟีโบนักชี มันพอดีกับที่นี่เป็นอย่างดีเนื่องจากตัวเลข Fibonacci มีการกำหนดซ้ำ นี้เป็นเรื่องง่ายที่จะเข้าใจและไม่จำเป็นต้องใด ๆแฟนซีมีของภาษา หลังจากนักเรียนได้รับความเข้าใจพื้นฐานเกี่ยวกับการเรียกซ้ำเราได้ดูอีกฟังก์ชั่นง่ายๆที่เราสร้างขึ้นก่อนหน้านี้ นี่คือตัวอย่าง:

สตริงมีอักขระหรือไม่?x

นี่คือวิธีที่เราทำมันก่อน: สำทับสตริงและดูว่าดัชนีมีxx

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);
}

คำถามธรรมชาติต่อไปคือเราควรทำเช่นนี้หรือไม่ อาจจะไม่. ทำไม? มันยากที่จะเข้าใจและยากที่จะเกิดขึ้น ดังนั้นจึงมีแนวโน้มที่จะเกิดข้อผิดพลาดได้เช่นกัน


2
ย่อหน้าสุดท้ายไม่ผิด เพียงแค่ต้องการพูดถึงว่าบ่อยครั้งที่การใช้เหตุผลแบบเดียวกันซ้ำไปซ้ำมาซ้ำ ๆ เพื่อแก้ปัญหาซ้ำ ๆ (Quicksort!)
กราฟิลส์

1
@ ราฟาเอลเห็นด้วยอย่างแน่นอน บางสิ่งบางอย่างเป็นธรรมชาติมากขึ้นที่จะแสดงซ้ำ ๆ คนอื่น ๆ ซ้ำ นั่นคือจุดที่ฉันพยายามทำ :)
Juho

อืมยกโทษให้ฉันถ้าฉันผิด แต่มันจะดีกว่าไหมถ้าคุณแยกเส้นคืนออกเป็นเงื่อนไข if ในโค้ดตัวอย่างซึ่งส่งกลับค่าจริงถ้าพบ x ส่วนอื่นเป็นส่วนซ้ำ ฉันไม่รู้ว่า 'หรือ' ยังคงทำงานต่อไปแม้ว่าจะพบจริง แต่ถ้าเป็นเช่นนั้นรหัสนี้ไม่มีประสิทธิภาพสูง
MindlessRanger

@MindlessRanger บางทีตัวอย่างที่สมบูรณ์แบบที่รุ่นเรียกซ้ำนั้นยากที่จะเข้าใจและเขียน? :-)
Juho

ใช่และความคิดเห็นก่อนหน้าของฉันผิด 'หรือ' หรือ '|' ไม่ได้ตรวจสอบเงื่อนไขถัดไปหากเงื่อนไขแรกเป็นจริงดังนั้นจึงไม่มีประสิทธิภาพ
MindlessRanger

24

วิธีการแก้ปัญหาบางอย่างเป็นธรรมชาติมากขึ้นโดยใช้การเรียกซ้ำ

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

สมมติว่างานคือการพิมพ์ค่าของต้นไม้ตามลำดับ อัลกอริทึมแบบเรียกซ้ำสำหรับการทำเช่นนี้ค่อนข้างเป็นธรรมชาติ:

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 ++


1
C ++ มีการเรียกซ้ำแบบหางหรือไม่? มันอาจคุ้มค่าที่จะชี้ให้เห็นว่าโดยทั่วไปแล้วภาษาที่ใช้งานได้
Louis

3
ขอบคุณหลุยส์ คอมไพเลอร์ C ++ บางตัวปรับการเรียกหางให้เหมาะสม (การเรียกซ้ำแบบหางเป็นคุณสมบัติของโปรแกรมไม่ใช่ภาษา) ฉันอัปเดตคำตอบของฉัน
Dave Clarke

อย่างน้อย GCC จะปรับการโทรแบบหางให้เหมาะสม (และแม้กระทั่งการโทรแบบที่ไม่ใช่แบบหางบางอย่าง)
vonbrand

11

จากคนที่มีชีวิตอยู่ในการเรียกซ้ำฉันจะพยายามทำให้กระจ่างในเรื่องนี้

เมื่อได้รับการแนะนำให้รู้จักกับการเรียกซ้ำครั้งแรกคุณจะได้เรียนรู้ว่ามันเป็นฟังก์ชั่นที่เรียกตัวเองและแสดงให้เห็นโดยทั่วไปด้วยอัลกอริทึมเช่นการแวะผ่านต้นไม้ หลังจากนั้นคุณจะพบว่ามันถูกใช้อย่างมากในการเขียนโปรแกรมฟังก์ชั่นสำหรับภาษาเช่น 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 ++: หากคุณกำหนดโครงสร้างหรือคลาสที่มีตัวชี้ไปยังโครงสร้างหรือคลาสเดียวกันการเรียกซ้ำควรพิจารณาสำหรับวิธีการสำรวจเส้นทางที่ใช้พอยน์เตอร์

กรณีง่าย ๆ คือรายการที่เชื่อมโยงทางเดียว คุณจะประมวลผลรายการที่เริ่มต้นที่หัวหรือส่วนท้ายจากนั้นวนซ้ำรายการโดยใช้พอยน์เตอร์

ต้นไม้เป็นอีกกรณีหนึ่งที่มักใช้การเรียกซ้ำ มากจนถ้าคุณเห็นการแวะผ่านต้นไม้โดยไม่มีการเรียกซ้ำคุณควรเริ่มถามว่าทำไม? มันไม่ผิด แต่เป็นสิ่งที่ควรสังเกตในความคิดเห็น

การใช้การสอบถามซ้ำทั่วไปคือ:


2
ฟังดูเหมือนเป็นคำตอบที่ยอดเยี่ยมถึงแม้ว่ามันจะเป็นสิ่งที่เหนือกว่าสิ่งใดก็ตามที่ได้รับการสอนในชั้นเรียนของฉันทุกเวลาเร็ว ๆ นี้ฉันก็เชื่อ
Taylor Huston

1
@TaylorHuston จำไว้ว่าคุณเป็นลูกค้า; ถามครูเกี่ยวกับแนวคิดที่คุณต้องการเข้าใจ เขาอาจจะไม่ตอบคำถามพวกเขาในชั้นเรียน แต่จับเขาในช่วงเวลาทำการและอาจจ่ายเงินปันผลจำนวนมากในอนาคต
Guy Coder

คำตอบที่ดี แต่ดูเหมือนจะสูงเกินไปสำหรับคนที่ไม่รู้จักการทำงานของโปรแกรม :)
แผ่น

2
... นำคำถามที่ไร้เดียงสาเพื่อศึกษาการเขียนโปรแกรมการทำงาน ชนะ!
JeffE

8

เพื่อให้กรณีใช้งานที่มีความลับน้อยกว่าคำตอบอื่น ๆ : การเรียกซ้ำผสมกันได้ดีกับโครงสร้างคลาสเหมือนต้นไม้ (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 เป็นอย่างไร แต่คุณไม่สนใจ: มันเป็นสิ่งที่สามารถคำนวณคุณค่าของตัวเองซึ่งเป็นสิ่งที่คุณต้องรู้

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


1
ฉันไม่แน่ใจว่าตัวอย่าง 'อาร์เคน' ที่คุณอ้างถึงคืออะไร อย่างไรก็ตามการสนทนาที่ดีของการรวมกับ OO
Dave Clarke

3

ตัวอย่างแรกที่ใช้ในการสอนการเรียกซ้ำในคลาสการเขียนโปรแกรมเริ่มต้นของฉันคือฟังก์ชั่นเพื่อแสดงรายการตัวเลขทั้งหมดในตัวเลขแยกกันในลำดับที่กลับกัน

void listDigits(int x){
     if (x <= 0)
        return;
     print x % 10;
     listDigits(x/10);
}

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

ดังนั้นมันอาจดูเหมือนว่าฟังก์ชั่นไร้ประโยชน์ในภาษานี้ แต่มันมีประโยชน์มากในระยะยาว

โดยการใช้ไซต์ของเรา หมายความว่าคุณได้อ่านและทำความเข้าใจนโยบายคุกกี้และนโยบายความเป็นส่วนตัวของเราแล้ว
Licensed under cc by-sa 3.0 with attribution required.