การเรียกซ้ำหางทำงานอย่างไร?


121

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

// tail recursion
int fac_times (int n, int acc) {
    if (n == 0) return acc;
    else return fac_times(n - 1, acc * n);
}

int factorial (int n) {
    return fac_times (n, 1);
}

// normal recursion
int factorial (int n) {
    if (n == 0) return 1;
    else return n * factorial(n - 1);
}

ไม่มีอะไรต้องทำหลังจากเรียกใช้ฟังก์ชันในฟังก์ชันการเรียกซ้ำหาง แต่มันไม่สมเหตุสมผลสำหรับฉัน


16
การเรียกซ้ำหางเป็นการเรียกซ้ำแบบ"ปกติ" หมายความว่าการเรียกซ้ำเกิดขึ้นที่ส่วนท้ายของฟังก์ชันเท่านั้น
Pete Becker

7
... แต่สามารถใช้งานได้ด้วยวิธีที่แตกต่างกันที่ระดับ IL มากกว่าการเรียกซ้ำแบบปกติลดความลึกของสแต็ก
KeithS

2
BTW, gcc สามารถกำจัดหางซ้ำในตัวอย่าง "ปกติ" ได้ที่นี่
dmckee --- อดีตผู้ดูแลลูกแมว

1
@Geek - ฉันเป็นนักพัฒนา C # ดังนั้น "ภาษาแอสเซมบลี" ของฉันจึงเป็น MSIL หรือแค่ IL สำหรับ C / C ++ ให้แทนที่ IL ด้วย ASM
KeithS

1
@ShannonSeverance ผมพบว่า GCC -O3ที่จะทำมันโดยสมควรง่ายตรวจสอบรหัสการชุมนุมที่ปล่อยออกมาได้โดยไม่ต้องมี ลิงก์นี้มีไว้สำหรับการสนทนาก่อนหน้านี้ซึ่งครอบคลุมพื้นฐานที่คล้ายกันมากและกล่าวถึงสิ่งที่จำเป็นในการปรับใช้การเพิ่มประสิทธิภาพนี้
dmckee --- อดีตผู้ดูแลลูกแมว

คำตอบ:


169

คอมไพเลอร์สามารถแปลงสิ่งนี้ได้

int fac_times (int n, int acc) {
    if (n == 0) return acc;
    else return fac_times(n - 1, acc * n);
}

ในสิ่งนี้:

int fac_times (int n, int acc) {
label:
    if (n == 0) return acc;
    acc *= n--;
    goto label;
}

2
@ Mr.32 ฉันไม่เข้าใจคำถามของคุณ ฉันแปลงฟังก์ชันเป็นฟังก์ชันที่เทียบเท่า แต่ไม่มีการเรียกซ้ำอย่างชัดเจน (นั่นคือไม่มีการเรียกใช้ฟังก์ชันอย่างชัดเจน) หากคุณเปลี่ยนตรรกะเป็นสิ่งที่ไม่เทียบเท่าคุณอาจทำให้ฟังก์ชันวนซ้ำตลอดไปในบางกรณีหรือทุกกรณี
Alexey Frunze

18
ดังนั้นการเรียกซ้ำของหางจึงมีผลเพียงเพราะคอมไพเลอร์ปรับให้เหมาะสม? และมิฉะนั้นจะเหมือนกับการเรียกซ้ำตามปกติในแง่ของหน่วยความจำสแต็กที่ชาญฉลาด?
Alan Coromano

34
อ๋อ หากคอมไพเลอร์ไม่สามารถลดการเรียกซ้ำเป็นลูปได้แสดงว่าคุณติดอยู่กับการเรียกซ้ำ ทั้งหมดหรือไม่มีอะไร.
Alexey Frunze

3
@AlanDert: ถูกต้อง นอกจากนี้คุณยังสามารถพิจารณาการเรียกหางซ้ำเป็นกรณีพิเศษของ "การเพิ่มประสิทธิภาพการโทรหาง" แบบพิเศษได้เนื่องจากการเรียกหางเป็นฟังก์ชันเดียวกัน โดยทั่วไปการเรียกหางใด ๆ (ที่มีข้อกำหนดเดียวกันกับ "ไม่เหลืองานที่ต้องทำ" เช่นเดียวกับที่ใช้กับการเรียกซ้ำหางและที่ที่ค่าส่งคืนของการเรียกหางจะถูกส่งกลับโดยตรง) สามารถปรับให้เหมาะสมได้หากคอมไพลเลอร์สามารถทำการโทรใน วิธีที่ตั้งค่าที่อยู่ส่งกลับของฟังก์ชันที่เรียกให้เป็นที่อยู่ส่งกลับของฟังก์ชันที่ทำการเรียกหางแทนที่จะเป็นที่อยู่ที่ใช้เรียกหาง
Steve Jessop

1
@AlanDert ใน C นี่เป็นเพียงการเพิ่มประสิทธิภาพที่ไม่ได้บังคับใช้โดยมาตรฐานใด ๆ ดังนั้นโค้ดแบบพกพาจึงไม่ควรขึ้นอยู่กับมัน แต่มีหลายภาษา (Scheme เป็นตัวอย่างหนึ่ง) ซึ่งมาตรฐานบังคับใช้การเพิ่มประสิทธิภาพการเรียกซ้ำของหางดังนั้นคุณไม่จำเป็นต้องกังวลว่ามันจะล้นในบางสภาพแวดล้อม
ม.ค. Wrobel

57

คุณถามว่าทำไม "ไม่ต้องใช้สแต็กในการจำที่อยู่ที่ส่งคืน"

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

โดยไม่ต้องเพิ่มประสิทธิภาพการโทรหาง:

f: ...
   CALL g
   RET
g:
   ...
   RET

ในกรณีนี้เมื่อgถูกเรียกสแต็กจะมีลักษณะดังนี้:

   SP ->  Return address of "g"
          Return address of "f"

ในทางกลับกันด้วยการเพิ่มประสิทธิภาพการโทรหาง:

f: ...
   JUMP g
g:
   ...
   RET

ในกรณีนี้เมื่อgถูกเรียกสแต็กจะมีลักษณะดังนี้:

   SP ->  Return address of "f"

เห็นได้ชัดว่าเมื่อgส่งคืนมันจะกลับไปยังตำแหน่งที่fถูกเรียกจาก

แก้ไข : ตัวอย่างด้านบนใช้กรณีที่ฟังก์ชันหนึ่งเรียกใช้ฟังก์ชันอื่น กลไกจะเหมือนกันเมื่อฟังก์ชันเรียกตัวเอง


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

12

การเรียกซ้ำหางสามารถเปลี่ยนเป็นลูปได้โดยคอมไพเลอร์โดยเฉพาะอย่างยิ่งเมื่อใช้ตัวสะสม

// tail recursion
int fac_times (int n, int acc = 1) {
    if (n == 0) return acc;
    else return fac_times(n - 1, acc * n);
}

จะรวบรวมสิ่งที่ต้องการ

// accumulator
int fac_times (int n) {
    int acc = 1;
    while (n > 0) {
        acc *= n;
        n -= 1;
    }
    return acc;
}

3
ไม่ฉลาดเท่าการใช้งานของ Alexey ... และใช่นั่นเป็นคำชม
Matthieu M.

1
อันที่จริงผลลัพธ์ดูง่ายกว่า แต่ฉันคิดว่าโค้ดที่จะใช้การเปลี่ยนแปลงนี้น่าจะ "ฉลาด" มากกว่าทั้ง label / goto หรือแค่ tail call (ดูคำตอบของ Lindydancer)
ภพ

ถ้านี่คือการวนซ้ำทั้งหมดแล้วทำไมคนถึงตื่นเต้นกับมัน? ไม่เห็นมีใครตื่นเต้นในขณะที่ลูป
Buh Buh

@BuhBuh: สิ่งนี้ไม่มี stackoverflow และหลีกเลี่ยงการผลัก / การแตกพารามิเตอร์ สำหรับวงที่แน่นหนาเช่นนี้มันสามารถสร้างความแตกต่างได้ นอกเหนือจากนั้นผู้คนไม่ควรตื่นเต้น
หมูปิ้ง

11

มีสององค์ประกอบที่ต้องมีอยู่ในฟังก์ชันแบบเรียกซ้ำ:

  1. การโทรซ้ำ
  2. สถานที่สำหรับนับค่าที่ส่งคืน

ฟังก์ชันเรียกซ้ำ "ปกติ" จะเก็บ (2) ไว้ในสแต็กเฟรม

ค่าที่ส่งคืนในฟังก์ชันการเรียกซ้ำปกติประกอบด้วยค่าสองประเภท:

  • ค่าตอบแทนอื่น ๆ
  • ผลลัพธ์ของการคำนวณฟังก์ชันเป็นเจ้าของ

ลองดูตัวอย่างของคุณ:

int factorial (int n) {
    if (n == 0) return 1;
    else return n * factorial(n - 1);
}

เฟรม f (5) "เก็บ" ผลลัพธ์ของการคำนวณของมันเอง (5) และค่าของ f (4) เป็นต้น ถ้าฉันเรียกแฟกทอเรียล (5) ก่อนที่การเรียกสแต็กจะเริ่มยุบฉันมี:

 [Stack_f(5): return 5 * [Stack_f(4): 4 * [Stack_f(3): 3 * ... [1[1]]

สังเกตว่าแต่ละสแต็กจัดเก็บนอกเหนือจากค่าที่ฉันกล่าวถึงขอบเขตทั้งหมดของฟังก์ชัน ดังนั้นการใช้หน่วยความจำสำหรับฟังก์ชันเรียกซ้ำ f คือ O (x) โดยที่ x คือจำนวนการเรียกซ้ำที่ฉันต้องทำ ดังนั้นถ้าฉันต้องการ RAM ขนาด 1kb เพื่อคำนวณแฟกทอเรียล (1) หรือแฟกทอเรียล (2) ฉันต้องการ ~ 100k เพื่อคำนวณแฟกทอเรียล (100) และอื่น ๆ

ฟังก์ชัน Tail Recursive ใส่ (2) ในอาร์กิวเมนต์

ใน Tail Recursion ฉันจะส่งผลลัพธ์ของการคำนวณบางส่วนในแต่ละเฟรมแบบวนซ้ำไปยังเฟรมถัดไปโดยใช้พารามิเตอร์ ลองดูตัวอย่างแฟกทอเรียลของเรา Tail Recursive:

int factorial (int n) {int helper (int num, int สะสม) {if num == 0 return collect else return helper (num - 1, collect * num)} return helper (n, 1)
}

ลองดูที่เฟรมในแฟกทอเรียล (4):

[Stack f(4, 5): Stack f(3, 20): [Stack f(2,60): [Stack f(1, 120): 120]]]]

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

เทมเพลตการเรียกซ้ำ

ฟังก์ชันการเรียกซ้ำปกติมีดังนี้:

type regular(n)
    base_case
    computation
    return (result of computation) combined with (regular(n towards base case))

ในการแปลงร่างในการเรียกซ้ำหางเรา:

  • แนะนำฟังก์ชันตัวช่วยที่มีตัวสะสม
  • เรียกใช้ฟังก์ชันตัวช่วยภายในฟังก์ชันหลักโดยตั้งตัวสะสมไว้ที่เคสฐาน

ดู:

type tail(n):
    type helper(n, accumulator):
        if n == base case
            return accumulator
        computation
        accumulator = computation combined with accumulator
        return helper(n towards base case, accumulator)
    helper(n, base case)

ดูความแตกต่าง?

การเพิ่มประสิทธิภาพการโทรหาง

เนื่องจากไม่มีการจัดเก็บสถานะไว้ในสแต็ก Non-Border-Cases ของ Tail Call จึงไม่สำคัญนัก ภาษา / ล่ามบางภาษาจะแทนที่กองเก่าด้วยภาษาใหม่ ดังนั้นหากไม่มีสแต็กเฟรมที่ จำกัด จำนวนการโทรTail Calls จะทำงานเหมือนกับ for-loopในกรณีเหล่านี้

มันขึ้นอยู่กับคอมไพเลอร์ของคุณที่จะปรับให้เหมาะสมหรือไม่


6

นี่คือตัวอย่างง่ายๆที่แสดงให้เห็นว่าฟังก์ชันเรียกซ้ำทำงานอย่างไร:

long f (long n)
{

    if (n == 0) // have we reached the bottom of the ocean ?
        return 0;

    // code executed in the descendence

    return f(n-1) + 1; // recurrence

    // code executed in the ascendence

}

Tail recursion เป็นฟังก์ชั่นการเรียกซ้ำแบบง่ายซึ่งการทำซ้ำจะเกิดขึ้นในตอนท้ายของฟังก์ชันดังนั้นจึงไม่มีการสร้างโค้ดในการขึ้นลงซึ่งจะช่วยให้คอมไพเลอร์ส่วนใหญ่ของภาษาโปรแกรมระดับสูงสามารถทำสิ่งที่เรียกว่าTail Recursion Optimizationได้เช่นกัน การเพิ่มประสิทธิภาพที่ซับซ้อนมากขึ้นเรียกว่าโมดูโลการเรียกซ้ำหาง


1

ฟังก์ชันเรียกซ้ำเป็นฟังก์ชันที่เรียกด้วยตัวเอง

มันช่วยให้โปรแกรมเมอร์เขียนโปรแกรมที่มีประสิทธิภาพโดยใช้จำนวนน้อยที่สุดของรหัส

ข้อเสียคือว่าพวกเขาสามารถทำให้เกิดลูปไม่มีที่สิ้นสุดและผลที่ไม่คาดคิดอื่น ๆ หากไม่ได้เขียนอย่างถูกต้อง

ฉันจะอธิบายทั้งฟังก์ชัน Simple Recursive และฟังก์ชัน Tail Recursive

เพื่อเขียนฟังก์ชันเรียกซ้ำแบบง่าย

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

จากตัวอย่างที่กำหนด:

public static int fact(int n){
  if(n <=1)
     return 1;
  else 
     return n * fact(n-1);
}

จากตัวอย่างข้างต้น

if(n <=1)
     return 1;

เป็นปัจจัยในการตัดสินใจเมื่อออกจากลูป

else 
     return n * fact(n-1);

คือการประมวลผลจริงที่ต้องทำ

ขอแบ่งงานทีละอย่างเพื่อให้เข้าใจง่าย

ให้เราดูว่าจะเกิดอะไรขึ้นภายในถ้าฉันวิ่ง fact(4)

  1. การแทนที่ n = 4
public static int fact(4){
  if(4 <=1)
     return 1;
  else 
     return 4 * fact(4-1);
}

Ifลูปล้มเหลวดังนั้นมันelseจึงวนซ้ำเพื่อให้มันกลับมา4 * fact(3)

  1. ในหน่วยความจำแบบสแต็กเรามี 4 * fact(3)

    การแทนที่ n = 3

public static int fact(3){
  if(3 <=1)
     return 1;
  else 
     return 3 * fact(3-1);
}

Ifลูปล้มเหลวดังนั้นจึงไปelseลูป

ดังนั้นมันจึงกลับมา 3 * fact(2)

จำไว้ว่าเราเรียกว่า `` 4 * fact (3) ''

ผลลัพธ์สำหรับ fact(3) = 3 * fact(2)

จนถึงตอนนี้สแต็คมี 4 * fact(3) = 4 * 3 * fact(2)

  1. ในหน่วยความจำแบบสแต็กเรามี 4 * 3 * fact(2)

    การแทนที่ n = 2

public static int fact(2){
  if(2 <=1)
     return 1;
  else 
     return 2 * fact(2-1);
}

Ifลูปล้มเหลวดังนั้นจึงไปelseลูป

ดังนั้นมันจึงกลับมา 2 * fact(1)

จำเราโทร 4 * 3 * fact(2)

ผลลัพธ์สำหรับ fact(2) = 2 * fact(1)

จนถึงตอนนี้สแต็คมี 4 * 3 * fact(2) = 4 * 3 * 2 * fact(1)

  1. ในหน่วยความจำแบบสแต็กเรามี 4 * 3 * 2 * fact(1)

    การแทนที่ n = 1

public static int fact(1){
  if(1 <=1)
     return 1;
  else 
     return 1 * fact(1-1);
}

If ห่วงเป็นจริง

ดังนั้นมันจึงกลับมา 1

จำเราโทร 4 * 3 * 2 * fact(1)

ผลลัพธ์สำหรับ fact(1) = 1

จนถึงตอนนี้สแต็คมี 4 * 3 * 2 * fact(1) = 4 * 3 * 2 * 1

สุดท้ายผลลัพธ์ของfact (4) = 4 * 3 * 2 * 1 = 24

ใส่คำอธิบายภาพที่นี่

Recursion หางจะเป็น

public static int fact(x, running_total=1) {
    if (x==1) {
        return running_total;
    } else {
        return fact(x-1, running_total*x);
    }
}
  1. การแทนที่ n = 4
public static int fact(4, running_total=1) {
    if (x==1) {
        return running_total;
    } else {
        return fact(4-1, running_total*4);
    }
}

Ifลูปล้มเหลวดังนั้นมันelseจึงวนซ้ำเพื่อให้มันกลับมาfact(3, 4)

  1. ในหน่วยความจำแบบสแต็กเรามี fact(3, 4)

    การแทนที่ n = 3

public static int fact(3, running_total=4) {
    if (x==1) {
        return running_total;
    } else {
        return fact(3-1, 4*3);
    }
}

Ifลูปล้มเหลวดังนั้นจึงไปelseลูป

ดังนั้นมันจึงกลับมา fact(2, 12)

  1. ในหน่วยความจำแบบสแต็กเรามี fact(2, 12)

    การแทนที่ n = 2

public static int fact(2, running_total=12) {
    if (x==1) {
        return running_total;
    } else {
        return fact(2-1, 12*2);
    }
}

Ifลูปล้มเหลวดังนั้นจึงไปelseลูป

ดังนั้นมันจึงกลับมา fact(1, 24)

  1. ในหน่วยความจำแบบสแต็กเรามี fact(1, 24)

    การแทนที่ n = 1

public static int fact(1, running_total=24) {
    if (x==1) {
        return running_total;
    } else {
        return fact(1-1, 24*1);
    }
}

If ห่วงเป็นจริง

ดังนั้นมันจึงกลับมา running_total

ผลลัพธ์สำหรับ running_total = 24

สุดท้ายผลลัพธ์ของfact (4,1) = 24

ใส่คำอธิบายภาพที่นี่


0

คำตอบของฉันเป็นการคาดเดามากกว่าเนื่องจากการเรียกซ้ำเป็นสิ่งที่เกี่ยวข้องกับการใช้งานภายใน

ในการเรียกซ้ำหางฟังก์ชันเรียกซ้ำจะถูกเรียกที่ส่วนท้ายของฟังก์ชันเดียวกัน คอมไพเลอร์อาจปรับให้เหมาะสมได้ดังนี้:

  1. ปล่อยให้ฟังก์ชันต่อเนื่องหมดลง (เช่นเรียกคืนสแต็กที่ใช้แล้ว)
  2. จัดเก็บตัวแปรที่จะใช้เป็นอาร์กิวเมนต์ของฟังก์ชันในที่จัดเก็บชั่วคราว
  3. หลังจากนี้ให้เรียกใช้ฟังก์ชันอีกครั้งด้วยอาร์กิวเมนต์ที่เก็บไว้ชั่วคราว

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

แต่ฉันเชื่อว่าหากมีการเรียกตัวทำลายภายในฟังก์ชันการเพิ่มประสิทธิภาพนี้อาจใช้ไม่ได้


0

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

void tail(int i) {
    if(i<=0) return;
    else {
     system.out.print(i+"");
     tail(i-1);
    }
   }

หลังจากดำเนินการเพิ่มประสิทธิภาพแล้วโค้ดด้านบนจะถูกแปลงเป็นโค้ดด้านล่าง

void tail(int i) {
    blockToJump:{
    if(i<=0) return;
    else {
     system.out.print(i+"");
     i=i-1;
     continue blockToJump;  //jump to the bolckToJump
    }
    }
   }

นี่คือวิธีที่คอมไพเลอร์ใช้ Tail Recursion Optimization

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