การเรียกซ้ำเป็นเร็วกว่าการวนซ้ำหรือไม่


286

ฉันรู้ว่าการเรียกซ้ำเป็นบางครั้งมากกว่าการวนซ้ำและฉันไม่ได้ถามอะไรเมื่อฉันควรใช้การเรียกซ้ำผ่านการทำซ้ำฉันรู้ว่ามีคำถามมากมายเกี่ยวกับเรื่องนั้นแล้ว

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

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


3
บางครั้งขั้นตอนการทำซ้ำหรือสูตรปิดสำหรับการเกิดซ้ำบางครั้งใช้เวลาหลายศตวรรษกว่าจะเกิดขึ้น ฉันคิดว่าในเวลานั้นการเรียกซ้ำเร็วกว่าเท่านั้น :) lol
Pratik Deoghare

24
สำหรับตัวฉันเองฉันชอบการทำซ้ำมาก ;-)
Iterator

เป็นไปได้ซ้ำของการเรียกซ้ำหรือซ้ำ?
nawfal


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

คำตอบ:


358

ขึ้นอยู่กับภาษาที่ใช้ คุณเขียน 'ผู้ไม่เชื่อเรื่องพระเจ้า' ดังนั้นฉันจะยกตัวอย่าง

ใน Java, C และ Python การเรียกซ้ำค่อนข้างแพงเมื่อเทียบกับการทำซ้ำ (โดยทั่วไป) เนื่องจากต้องการการจัดสรรเฟรมสแต็กใหม่ ในคอมไพเลอร์ C บางตัวเราสามารถใช้คอมไพเลอร์แฟล็กเพื่อกำจัดค่าใช้จ่ายนี้ซึ่งแปลงการเรียกซ้ำบางประเภท (อันที่จริงแล้วการเรียกหางบางประเภท) ไปสู่การกระโดดแทนที่จะเป็นการเรียกฟังก์ชั่น

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

ฉันรู้ว่าในการใช้งาน Scheme บางอย่างการเรียกซ้ำโดยทั่วไปจะเร็วกว่าการวนซ้ำ

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

ภาคผนวก:ในบางสภาพแวดล้อมทางเลือกที่ดีที่สุดไม่ใช่การเรียกซ้ำหรือทำซ้ำ สิ่งเหล่านี้รวมถึง "map", "filter" และ "ลด" (ซึ่งเรียกว่า "fold") สไตล์เหล่านี้ไม่เพียง แต่ต้องการความสะอาดเท่านั้น แต่ในบางสภาพแวดล้อมฟังก์ชั่นเหล่านี้เป็นฟังก์ชั่นแรก (หรือเท่านั้น) ที่จะได้รับการเพิ่มประสิทธิภาพจากการขนานอัตโนมัติ - ดังนั้นจึงสามารถทำได้เร็วกว่าการทำซ้ำหรือเรียกซ้ำ Data Parallel Haskell เป็นตัวอย่างของสภาพแวดล้อมดังกล่าว

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


48
ฉัน +1 และต้องการแสดงความคิดเห็นว่า "การเรียกซ้ำ" และ "ลูป" เป็นเพียงสิ่งที่มนุษย์ตั้งชื่อรหัสของพวกเขา สิ่งที่มีความสำคัญต่อการปฏิบัติงานไม่ใช่วิธีที่คุณตั้งชื่อสิ่งต่าง ๆ แต่เป็นวิธีรวบรวม / ตีความ การเรียกซ้ำโดยนิยามเป็นแนวคิดทางคณิตศาสตร์และมีส่วนเกี่ยวข้องกับสแต็กเฟรมและชุดประกอบ
P Shved

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

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

สิ่งที่สำคัญที่สุดคือการดำเนินการไม่ได้ดำเนินการ ยิ่งคุณ "IO" มากเท่าไหร่คุณก็ยิ่งต้องดำเนินการมากขึ้นเท่านั้น ข้อมูล Un-IOing (การจัดทำดัชนี aka) เป็นการเพิ่มประสิทธิภาพที่ยิ่งใหญ่ที่สุดสำหรับระบบใด ๆ เสมอเพราะคุณไม่จำเป็นต้องประมวลผลในตอนแรก
Jeff Fischer

53

การเรียกซ้ำเร็วกว่าลูปหรือไม่

ไม่การวนซ้ำจะเร็วกว่าการเรียกซ้ำ (ในสถาปัตยกรรม Von Neumann)

คำอธิบาย:

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

การสร้างเครื่องคำนวณหลอกโดยเริ่มต้น:

ตั้งคำถามกับตัวเอง : คุณต้องการคำนวณค่าอะไรบ้างเช่นทำตามอัลกอริธึมและไปถึงผลลัพธ์

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

  1. แนวคิดแรก: เซลล์หน่วยความจำ, การจัดเก็บรัฐ หากต้องการทำสิ่งที่คุณต้องการให้สถานที่จัดเก็บค่าผลลัพธ์สุดท้ายและระดับกลาง สมมติว่าเรามีอาร์เรย์จำนวนเต็มของเซลล์ "จำนวนเต็ม" ที่เรียกว่าMemory , M [0..Infinite]

  2. คำแนะนำ:ทำอะไรสักอย่าง - เปลี่ยนเซลล์เปลี่ยนค่า รัฐแก้ไข ทุกคำสั่งที่น่าสนใจทำการเปลี่ยนแปลง คำแนะนำพื้นฐานคือ:

    a) ตั้งค่าและย้ายเซลล์หน่วยความจำ

    • เก็บค่าไว้ในหน่วยความจำเช่น: เก็บ 5 m [4]
    • คัดลอกค่าไปยังตำแหน่งอื่น: เช่น: store m [4] m [8]

    b) ลอจิกและเลขคณิต

    • และหรือหรือ xor ไม่
    • เพิ่ม, ย่อย, mul, div เช่นเพิ่ม m [7] m [8]
  3. ตัวแทนดำเนินการ : แกนกลางใน CPU ที่ทันสมัย "ตัวแทน" เป็นสิ่งที่สามารถดำเนินการตามคำสั่งได้ ตัวแทนยังสามารถที่จะเป็นคนต่อไปนี้ขั้นตอนวิธีบนกระดาษ

  4. ลำดับขั้นตอน: ลำดับของคำสั่ง : เช่น: ทำสิ่งนี้ก่อนทำสิ่งนี้หลังจาก ฯลฯ ลำดับของคำสั่งที่จำเป็น แม้แต่นิพจน์บรรทัดเดียวก็คือ "ลำดับของคำสั่งที่จำเป็น" หากคุณมีการแสดงออกที่มีเฉพาะ "คำสั่งของการประเมินผล" แล้วคุณมีขั้นตอน มันหมายถึงว่าแม้กระทั่งนิพจน์ที่สงบเพียงครั้งเดียวก็มี "ขั้นตอน" โดยนัยและยังมีตัวแปรโลคัลโดยปริยาย (ลองเรียกมันว่า "ผลลัพธ์") เช่น:

    4 + 3 * 2 - 5
    (- (+ (* 3 2) 4 ) 5)
    (sub (add (mul 3 2) 4 ) 5)  
    

    การแสดงออกข้างต้นหมายถึง 3 ขั้นตอนด้วยตัวแปร "ผลลัพธ์" โดยนัย

    // pseudocode
    
           1. result = (mul 3 2)
           2. result = (add 4 result)
           3. result = (sub result 5)
    

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

  5. Instruction Pointer : หากคุณมีลำดับขั้นตอนคุณจะต้องมี "พอยน์เตอร์คำสั่ง" โดยปริยาย ตัวชี้คำสั่งทำเครื่องหมายคำสั่งต่อไปและความก้าวหน้าหลังจากอ่านคำแนะนำ แต่ก่อนที่คำสั่งจะถูกดำเนินการ

    ในการนี้หลอกคอมพิวเตอร์เครื่องชี้การเรียนการสอนเป็นส่วนหนึ่งของหน่วยความจำ (หมายเหตุ: โดยปกติInstruction Pointerจะเป็น "register พิเศษ" ใน CPU core แต่ที่นี่เราจะทำให้แนวคิดง่ายขึ้นและสมมติว่าข้อมูลทั้งหมด (รวมถึงรีจิสเตอร์) เป็นส่วนหนึ่งของ "Memory")

  6. กระโดด - เมื่อคุณมีจำนวนขั้นตอนที่สั่งซื้อและตัวชี้คำแนะนำคุณสามารถใช้คำสั่ง " ร้านค้า " เพื่อเปลี่ยนค่าของตัวชี้คำแนะนำ เราจะเรียกใช้งานนี้โดยเฉพาะของการเรียนการสอนการจัดเก็บด้วยชื่อใหม่: กระโดด เราใช้ชื่อใหม่เพราะง่ายกว่าที่จะคิดว่ามันเป็นแนวคิดใหม่ โดยการเปลี่ยนตัวชี้คำสั่งเราจะแนะนำให้ตัวแทน "ไปที่ขั้นตอนที่ x"

  7. การวนซ้ำไม่สิ้นสุด : ด้วยการกระโดดกลับมาตอนนี้คุณสามารถทำให้เอเจนต์ "ทำซ้ำ" ได้หลายขั้นตอน ณ จุดนี้เรามีการวนซ้ำไม่สิ้นสุด

                       1. mov 1000 m[30]
                       2. sub m[30] 1
                       3. jmp-to 2  // infinite loop
    
  8. เงื่อนไข - การดำเนินการตามคำสั่งอย่างมีเงื่อนไข ด้วยประโยค "แบบมีเงื่อนไข" คุณสามารถเรียกใช้งานหนึ่งในหลาย ๆ คำสั่งตามเงื่อนไขปัจจุบัน (ซึ่งสามารถตั้งค่าด้วยคำสั่งก่อนหน้า)

  9. การวนซ้ำที่เหมาะสม : ขณะนี้มีประโยคเงื่อนไขเราสามารถหลบหนีวนไม่สิ้นสุดของคำสั่งกระโดดกลับ ขณะนี้เรามีการวนซ้ำแบบมีเงื่อนไขแล้ววนซ้ำที่เหมาะสม

    1. mov 1000 m[30]
    2. sub m[30] 1
    3. (if not-zero) jump 2  // jump only if the previous 
                            // sub instruction did not result in 0
    
    // this loop will be repeated 1000 times
    // here we have proper ***iteration***, a conditional loop.
    
  10. การตั้งชื่อ : ชื่อให้ข้อมูลการถือครองที่เฉพาะเจาะจงสถานที่ตั้งของหน่วยความจำหรือถือขั้นตอน นี่เป็นเพียง "ความสะดวกสบาย" ที่มี เราไม่ได้เพิ่มคำแนะนำใหม่โดยมีความสามารถในการกำหนด "ชื่อ" สำหรับตำแหน่งหน่วยความจำ “ การตั้งชื่อ” ไม่ใช่คำสั่งสำหรับตัวแทนมันเป็นเพียงความสะดวกสบายสำหรับเรา การตั้งชื่อทำให้รหัส (ณ จุดนี้) ง่ายต่อการอ่านและง่ายต่อการเปลี่ยนแปลง

       #define counter m[30]   // name a memory location
       mov 1000 counter
    loop:                      // name a instruction pointer location
        sub counter 1
        (if not-zero) jmp-to loop  
    
  11. รูทีนย่อยหนึ่งระดับ : สมมติว่ามีชุดของขั้นตอนที่คุณต้องดำเนินการบ่อยครั้ง คุณสามารถจัดเก็บขั้นตอนในตำแหน่งที่ระบุชื่อไว้ในหน่วยความจำแล้วข้ามไปยังตำแหน่งนั้นเมื่อคุณต้องดำเนินการ (การโทร) ในตอนท้ายของลำดับคุณจะต้องกลับไปยังจุดที่เรียกเพื่อดำเนินการต่อ ด้วยกลไกนี้คุณกำลังสร้างคำสั่งใหม่ (รูทีนย่อย) โดยการเขียนคำสั่งหลัก

    การใช้งาน: (ไม่จำเป็นต้องมีแนวคิดใหม่)

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

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

    ในการมีการนำไปปฏิบัติที่ดีกว่าสำหรับรูทีนย่อย: คุณต้องมีสแต็ค

  12. สแต็ค : คุณกำหนดพื้นที่หน่วยความจำให้ทำงานเป็น "สแต็ค" คุณสามารถ "ดัน" ค่าในสแต็กและ "ป๊อป" ค่า "ผลักดัน" ล่าสุด ในการติดตั้งสแต็กคุณจะต้องใช้Stack Pointer (คล้ายกับ Instruction Pointer) ซึ่งชี้ไปที่ "หัว" จริงของสแต็ก เมื่อคุณ“ ผลักดัน” ค่าตัวชี้กองซ้อนจะลดลงและคุณเก็บค่า เมื่อคุณ“ ป๊อป” คุณจะได้รับค่าที่กองการตัวชี้ที่แท้จริงแล้วกองตัวชี้จะเพิ่มขึ้น

  13. ซับรูทีนตอนนี้ที่เรามีสแต็คที่เราสามารถใช้โปรแกรมย่อยที่เหมาะสมช่วยให้สายซ้อนกัน การนำไปใช้นั้นคล้ายกัน แต่แทนที่จะเก็บ Instruction Pointer ไว้ในตำแหน่งหน่วยความจำที่กำหนดไว้ล่วงหน้าเรา "ผลักดัน" ค่าของ IP ในสแต็ก ในตอนท้ายของงานย่อยที่เราเพียงแค่“ป๊อป” ค่าจากสแต็คได้อย่างมีประสิทธิภาพกระโดดกลับไปที่การเรียนการสอนหลังเดิมโทร การนำไปใช้งานนี้การมี“ สแต็ก” อนุญาตให้เรียกรูทีนย่อยจากรูทีนย่อยอื่น ด้วยการใช้งานนี้เราสามารถสร้างนามธรรมได้หลายระดับเมื่อกำหนดคำสั่งใหม่เป็นรูทีนย่อยโดยใช้คำสั่งหลักหรือรูทีนย่อยอื่น ๆ เป็นแบบเอกสารสำเร็จรูป

  14. การเรียกซ้ำ : เกิดอะไรขึ้นเมื่อรูทีนย่อยเรียกตัวเองว่า สิ่งนี้เรียกว่า "การเรียกซ้ำ"

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

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

...

เมื่อมาถึงการเรียกซ้ำเราหยุดที่นี่

สรุป:

ในสถาปัตยกรรม Von Neumann ชัดเจนว่า"Iteration"เป็นแนวคิดที่เรียบง่าย / พื้นฐานกว่า"เรียกซ้ำ"เรามีรูปแบบของ"Iteration"ที่ระดับ 7 ในขณะที่"เรียกซ้ำ"อยู่ที่ระดับ 14 ของลำดับชั้นแนวคิด

การวนซ้ำจะเร็วขึ้นในรหัสเครื่องเพราะมันแสดงถึงคำสั่งที่น้อยลงดังนั้นรอบการทำงานของ CPU ที่น้อยลง

อันไหนดีกว่า"?

  • คุณควรใช้ "การวนซ้ำ" เมื่อคุณกำลังประมวลผลโครงสร้างข้อมูลที่เรียบง่ายตามลำดับและทุก ๆ ที่ "ลูปง่าย" จะทำ

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

คำแนะนำ : ใช้เครื่องมือที่ดีที่สุดสำหรับงาน แต่เข้าใจการทำงานด้านในของแต่ละเครื่องมือเพื่อเลือกอย่างชาญฉลาด

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


2
คุณกำลังสมมติว่าความก้าวหน้าของคุณเป็น 1) จำเป็นและ 2) มันหยุดลงเมื่อคุณทำเช่นนั้น แต่ 1) ไม่จำเป็น (ตัวอย่างเช่นการเรียกซ้ำสามารถกลายเป็นการกระโดดได้ตามคำตอบที่ยอมรับดังนั้นไม่จำเป็นต้องมีสแต็ก) และ 2) ไม่จำเป็นต้องหยุดที่นั่น (ตัวอย่างเช่นในที่สุดคุณ จะถึงการประมวลผลที่เกิดขึ้นพร้อมกันซึ่งอาจจำเป็นต้องล็อคถ้าคุณมีสถานะไม่แน่นอนตามที่คุณแนะนำในขั้นตอนที่ 2 ของคุณดังนั้นทุกอย่างจะช้าลงในขณะที่โซลูชันที่ไม่เปลี่ยนรูปแบบเช่นแบบใช้งาน / เรียกซ้ำ .
hmijail mourns ลาออก

2
"การเรียกซ้ำสามารถกลายเป็นการกระโดด" เป็นเท็จ การเรียกซ้ำที่เป็นประโยชน์อย่างแท้จริงไม่สามารถกลายเป็นการกระโดดได้ Tail call "recursion" เป็นกรณีพิเศษที่คุณโค้ด "เป็น recursion" สิ่งที่สามารถทำให้เป็นวงได้ง่ายโดยคอมไพเลอร์ นอกจากนี้คุณกำลังสับสน "ไม่เปลี่ยนรูป" กับ "การเรียกซ้ำ" ซึ่งเป็นแนวคิดมุมฉาก
Lucio M. Tato

"การเรียกซ้ำที่มีประโยชน์อย่างแท้จริงไม่สามารถกลายเป็นการกระโดดได้" -> ดังนั้นการปรับการเรียกหางให้เป็นประโยชน์อย่างใด? นอกจากนี้การไม่เปลี่ยนรูปแบบและการเรียกซ้ำอาจเป็นแบบฉาก แต่คุณเชื่อมโยงวนกับเคาน์เตอร์ที่ไม่แน่นอน - ดูที่ขั้นตอนที่ 9 ของคุณดูเหมือนว่าฉันคิดว่าคุณกำลังคิดว่าการวนซ้ำและการเรียกซ้ำเป็นแนวคิดที่ต่างกันอย่างสิ้นเชิง พวกเขาไม่ได้ stackoverflow.com/questions/2651112/…
hmijail mourns ทำการลาออก

@hmijail ฉันคิดว่าคำที่ดีกว่า "มีประโยชน์" คือ "เป็นจริง" การเรียกซ้ำแบบหางไม่ใช่การสอบถามซ้ำแบบจริงเนื่องจากเป็นเพียงการใช้ฟังก์ชันการเรียกไวยากรณ์เพื่ออำพรางการแตกแขนงแบบไม่มีเงื่อนไขเช่นการทำซ้ำ การเรียกซ้ำที่แท้จริงทำให้เรามี backtracking stack อย่างไรก็ตามการเรียกซ้ำหางยังคงแสดงออกซึ่งทำให้มีประโยชน์ คุณสมบัติของการเรียกซ้ำซึ่งทำให้ง่ายหรือง่ายต่อการวิเคราะห์รหัสเพื่อความถูกต้องจะถูกนำไปใช้กับรหัสซ้ำเมื่อมันแสดงโดยใช้การเรียกหาง แม้ว่าบางครั้งจะถูกชดเชยเล็กน้อยโดยความซับซ้อนเป็นพิเศษในรุ่นหางเช่นพารามิเตอร์พิเศษ
Kaz

34

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

ฉันมีกรณีที่เขียนอัลกอริทึมซ้ำใน Java ทำให้มันช้าลง

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


2
+1 สำหรับ " เขียนครั้งแรกด้วยวิธีที่เป็นธรรมชาติที่สุด " และโดยเฉพาะอย่างยิ่ง " เพิ่มประสิทธิภาพเฉพาะเมื่อการทำโปรไฟล์แสดงให้เห็นว่ามีความสำคัญ "
TripeHound

2
+1 สำหรับการยอมรับว่าสแต็กของฮาร์ดแวร์อาจเร็วกว่าซอฟต์แวร์ที่ติดตั้งด้วยตนเองในกอง แสดงให้เห็นอย่างมีประสิทธิภาพว่าคำตอบ "ไม่" ทั้งหมดไม่ถูกต้อง
sh1

12

การเรียกซ้ำแบบหางนั้นเร็วเท่ากับการวนซ้ำ ภาษาที่ใช้งานได้หลายภาษามีการเรียกใช้หางซ้ำในพวกเขา


35
การเรียกซ้ำแบบหางสามารถทำได้เร็วเท่ากับการวนซ้ำเมื่อมีการปรับการเรียกหาง: c2.com/cgi/wiki?TailCallOptimization
Joachim Sauer

12

พิจารณาสิ่งที่ต้องทำอย่างแน่นอนสำหรับการทำซ้ำแต่ละครั้งและการเรียกซ้ำ

  • การวนซ้ำ: การข้ามไปยังจุดเริ่มต้นของการวนซ้ำ
  • การเรียกซ้ำ: การข้ามไปยังจุดเริ่มต้นของฟังก์ชันที่เรียก

คุณเห็นว่ามีพื้นที่ไม่มากสำหรับความแตกต่างที่นี่

(ฉันถือว่าการเรียกซ้ำเป็นการโทรหางและคอมไพเลอร์โดยตระหนักถึงการเพิ่มประสิทธิภาพนั้น)


9

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

TL; DR อัลกอริทึมแบบเรียกซ้ำมีลักษณะการทำงานแคชที่แย่กว่าแบบวนซ้ำ


6

ส่วนใหญ่คำตอบที่นี่มีความผิด คำตอบที่ถูกต้องคือมันขึ้นอยู่กับ ตัวอย่างเช่นนี่คือฟังก์ชัน C สองฟังก์ชันที่เดินผ่านต้นไม้ ก่อนอื่น recursive:

static
void mm_scan_black(mm_rc *m, ptr p) {
    SET_COL(p, COL_BLACK);
    P_FOR_EACH_CHILD(p, {
        INC_RC(p_child);
        if (GET_COL(p_child) != COL_BLACK) {
            mm_scan_black(m, p_child);
        }
    });
}

และนี่คือฟังก์ชั่นเดียวกับที่ใช้กับการวนซ้ำ:

static
void mm_scan_black(mm_rc *m, ptr p) {
    stack *st = m->black_stack;
    SET_COL(p, COL_BLACK);
    st_push(st, p);
    while (st->used != 0) {
        p = st_pop(st);
        P_FOR_EACH_CHILD(p, {
            INC_RC(p_child);
            if (GET_COL(p_child) != COL_BLACK) {
                SET_COL(p_child, COL_BLACK);
                st_push(st, p_child);
            }
        });
    }
}

มันไม่สำคัญที่จะเข้าใจรายละเอียดของรหัส เพียงแค่นั่นpคือโหนดและนั่นP_FOR_EACH_CHILDจะเป็นการเดิน ในรุ่นที่ซ้ำกันเราต้องการสแต็คอย่างชัดเจนstไปที่โหนดจะถูกผลักแล้วผุดและจัดการ

ฟังก์ชั่นวนซ้ำทำงานเร็วกว่าฟังก์ชั่นวนซ้ำมาก เหตุผลเป็นเพราะในระยะหลังสำหรับแต่ละรายการที่CALLจะฟังก์ชั่นที่จำเป็นและจากนั้นอีกครั้งเพื่อให้st_pushst_pop

ในอดีตคุณมีการเรียกซ้ำCALLสำหรับแต่ละโหนดเท่านั้น

นอกจากนี้การเข้าถึงตัวแปรบน callstack นั้นทำได้รวดเร็วอย่างไม่น่าเชื่อ หมายความว่าคุณกำลังอ่านจากหน่วยความจำซึ่งน่าจะอยู่ในแคชด้านในสุดเสมอ ในทางกลับกันสแต็กที่ชัดเจนต้องได้รับการสนับสนุนโดยmalloc: ed memory จาก heap ซึ่งเข้าถึงได้ช้ากว่ามาก

ด้วยการปรับให้เหมาะสมอย่างระมัดระวังเช่น inlining st_pushและst_popฉันสามารถเข้าถึงความเท่าเทียมกันอย่างคร่าวๆด้วยวิธีเรียกซ้ำ แต่อย่างน้อยในคอมพิวเตอร์ของฉันค่าใช้จ่ายในการเข้าถึงหน่วยความจำฮีปนั้นใหญ่กว่าค่าใช้จ่ายของการโทรซ้ำ

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


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

1
ทำการสำรวจเส้นทางล่วงหน้าของต้นไม้ไบนารี 7 โหนด 10 ^ 8 ครั้ง การเรียกซ้ำ 25ns สแต็กอย่างชัดเจน (ตรวจสอบขอบเขตหรือไม่ - ไม่สร้างความแตกต่างมากนัก) ~ 15ns การเรียกซ้ำจำเป็นต้องทำเพิ่มเติม (ลงทะเบียนการบันทึกและการคืนค่า + (โดยปกติ) การจัดแนวเฟรมที่เข้มงวด) นอกเหนือจากการกดและกระโดด (และจะแย่ลงเมื่อใช้ PLT ใน libs ที่เชื่อมโยงแบบไดนามิก) คุณไม่จำเป็นต้องจัดสรรกองซ้อนอย่างชัดเจน คุณสามารถทำสิ่งกีดขวางที่มีเฟรมแรกอยู่ใน call stack ปกติดังนั้นคุณจะไม่เสียสละตำแหน่งแคชสำหรับกรณีที่พบบ่อยที่สุดที่คุณไม่เกินบล็อกแรก
PSkocik

3

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

คุณตบหัวด้วยเหตุผล การสร้างและทำลายเฟรมสแต็กมีราคาแพงกว่าการกระโดดแบบง่าย ๆ

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

แก้ไข: คำตอบนี้สมมติว่าภาษาไม่ทำงานซึ่งประเภทข้อมูลพื้นฐานส่วนใหญ่ไม่แน่นอน มันใช้ไม่ได้กับภาษาที่ใช้งานได้


นั่นเป็นเหตุผลที่หลายกรณีของการเรียกซ้ำถูกปรับให้เหมาะสมโดยคอมไพเลอร์ในภาษาที่มีการใช้การเรียกซ้ำบ่อยครั้ง ยกตัวอย่างเช่นใน F # นอกเหนือจากการสนับสนุนอย่างเต็มที่เพื่อกำหนดฟังก์ชั่นวนซ้ำด้วยหาง. opcode คุณมักจะเห็นฟังก์ชั่นวนซ้ำที่คอมไพล์เป็นลูป
em70

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

1
โดยทั่วไปไม่ถูกต้อง ในบางสภาพแวดล้อมการกลายพันธุ์ (ซึ่งโต้ตอบกับ GC) มีราคาแพงกว่าการเรียกซ้ำแบบหางซึ่งจะเปลี่ยนเป็นลูปที่ง่ายกว่าในเอาต์พุตที่ไม่ได้ใช้เฟรมสแต็กพิเศษ
Dietrich Epp

2

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


1

ฟังก์ชั่นการเขียนโปรแกรมเป็นมากกว่าเกี่ยวกับ " อะไร " มากกว่า " อย่างไร "

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

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


0

นี่คือการเดา โดยทั่วไปเรียกซ้ำอาจจะไม่ชนะการวนลูปหรือมักจะเคยเกี่ยวกับปัญหาของขนาดที่เหมาะสมถ้าทั้งสองกำลังใช้อัลกอริทึมที่ดีจริงๆ (ไม่นับความยากลำบากในการดำเนินงาน) ก็อาจจะแตกต่างกันถ้าใช้กับภาษา w / หาง recursion โทร (และหาง recursive อัลกอริทึม และด้วยลูปซึ่งเป็นส่วนหนึ่งของภาษาด้วย) - ซึ่งอาจมีลักษณะคล้ายกันมากและอาจต้องการการเรียกซ้ำบางครั้ง


0

ตามทฤษฎีมันเป็นสิ่งเดียวกัน การเรียกซ้ำและวนซ้ำที่มีความซับซ้อน O () เดียวกันจะทำงานด้วยความเร็วทางทฤษฎีที่เหมือนกัน แต่แน่นอนความเร็วที่แท้จริงขึ้นอยู่กับภาษาคอมไพเลอร์และตัวประมวลผล ตัวอย่างที่มีกำลังของตัวเลขสามารถเขียนในลักษณะซ้ำด้วย O (ln (n)):

  int power(int t, int k) {
  int res = 1;
  while (k) {
    if (k & 1) res *= t;
    t *= t;
    k >>= 1;
  }
  return res;
  }

1
Big O คือ“ สัดส่วนกับ” ดังนั้นทั้งคู่O(n)แต่อย่างหนึ่งอาจจะใช้xเวลานานกว่าที่อื่น ๆ nทั้งหมด
ctrl-alt-delor
โดยการใช้ไซต์ของเรา หมายความว่าคุณได้อ่านและทำความเข้าใจนโยบายคุกกี้และนโยบายความเป็นส่วนตัวของเราแล้ว
Licensed under cc by-sa 3.0 with attribution required.