การเรียกซ้ำแบบหางคืออะไร


1694

ขณะที่เริ่มต้นที่จะเรียนรู้เสียงกระเพื่อมผมเคยเจอในระยะหาง recursive มันหมายความว่าอะไรกันแน่?


154
สำหรับความอยากรู้อยากเห็น: ทั้งในขณะที่และในขณะที่ได้รับในภาษาเป็นเวลานานมาก ขณะที่ใช้งานในภาษาอังกฤษโบราณ ในขณะที่การพัฒนาของอังกฤษในขณะที่ ในฐานะที่เป็นคำสันธานพวกเขาสามารถใช้แทนกันได้ในความหมาย แต่ในขณะที่ยังไม่รอดชีวิตในภาษาอังกฤษแบบอเมริกันมาตรฐาน
Filip Bartuzi

14
อาจจะสาย แต่บทความนี้เป็นบทความที่ดีเกี่ยวกับ tail recursive: programmerinterview.com/index.php/recursion/tail-recursion
Sam003

5
ข้อดีอย่างหนึ่งของการระบุฟังก์ชั่น tail-recursive ก็คือมันสามารถแปลงเป็นรูปแบบวนซ้ำได้ดังนั้นจึงสามารถนำอัลกอริทึมกลับมาใช้ใหม่ได้จากเมธอดสแต็ก - โอเวอร์เฮด อาจต้องการดูการตอบสนองจาก @Kyle Cronin และคนอื่น ๆ ด้านล่าง
KGhatak

ลิงค์นี้จาก @yesudeep เป็นคำอธิบายที่ดีที่สุดและละเอียดที่สุดที่ฉันเคยพบ - lua.org/pil/6.3.html
Jeff Fischer

1
ใครบางคนสามารถบอกฉันว่าผสานและเรียงลำดับอย่างรวดเร็วใช้ tail recursion (TRO) หรือไม่
majurageerthan

คำตอบ:


1719

พิจารณาฟังก์ชั่นง่าย ๆ ที่เพิ่มตัวเลขธรรมชาติ N ตัวแรก (เช่นsum(5) = 1 + 2 + 3 + 4 + 5 = 15)

นี่คือการใช้ JavaScript อย่างง่ายที่ใช้การเรียกซ้ำ:

function recsum(x) {
    if (x === 1) {
        return x;
    } else {
        return x + recsum(x - 1);
    }
}

หากคุณโทรrecsum(5)มานี่คือสิ่งที่ล่าม JavaScript จะประเมิน:

recsum(5)
5 + recsum(4)
5 + (4 + recsum(3))
5 + (4 + (3 + recsum(2)))
5 + (4 + (3 + (2 + recsum(1))))
5 + (4 + (3 + (2 + 1)))
15

โปรดทราบว่าการเรียกซ้ำแบบเรียกซ้ำจะต้องดำเนินการให้เสร็จสิ้นก่อนที่ล่าม JavaScript จะเริ่มต้นทำงานจริงในการคำนวณผลรวม

นี่คือฟังก์ชั่นรุ่นหลังหางที่เรียกซ้ำได้:

function tailrecsum(x, running_total = 0) {
    if (x === 0) {
        return running_total;
    } else {
        return tailrecsum(x - 1, running_total + x);
    }
}

นี่คือลำดับเหตุการณ์ที่จะเกิดขึ้นหากคุณโทรหาtailrecsum(5)(ซึ่งจะเป็นอย่างมีประสิทธิภาพtailrecsum(5, 0)เนื่องจากอาร์กิวเมนต์ที่สองที่เป็นค่าเริ่มต้น)

tailrecsum(5, 0)
tailrecsum(4, 5)
tailrecsum(3, 9)
tailrecsum(2, 12)
tailrecsum(1, 14)
tailrecsum(0, 15)
15

ในกรณีหางแบบเรียกซ้ำด้วยการประเมินการเรียกซ้ำแบบเรียกซ้ำแต่ละครั้งrunning_totalจะมีการอัพเดต

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


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

2
ต่อไปนี้เป็นข้อมูลเพิ่มเติมที่แสดงตัวอย่างบางส่วนใน Lua: lua.org/pil/6.3.html อาจเป็นประโยชน์ในการทำเช่นนั้น! :)
yesudeep

2
ใครช่วยกรุณาตอบคำถามของ chrisapotek ได้ไหม? ฉันสับสนว่าtail recursionจะประสบความสำเร็จในภาษาที่ไม่ได้เพิ่มประสิทธิภาพการโทรหางได้อย่างไร
เควินเมเรดิ ธ

3
@KevinMeredith "tail recursion" หมายถึงคำสั่งสุดท้ายในฟังก์ชั่นเป็นการเรียกซ้ำไปยังฟังก์ชันเดียวกัน คุณถูกต้องว่าไม่มีประเด็นในการทำเช่นนี้ในภาษาที่ไม่ได้ปรับการเรียกซ้ำให้เหมาะสม อย่างไรก็ตามคำตอบนี้แสดงแนวคิด (เกือบ) อย่างถูกต้อง มันน่าจะเป็นการเรียกหางที่ชัดเจนยิ่งขึ้นหากละเว้น "else:" จะไม่เปลี่ยนพฤติกรรม แต่จะวางโทรหางเป็นคำสั่งที่เป็นอิสระ ฉันจะส่งสิ่งนั้นเป็นการแก้ไข
ToolmakerSteve

2
ดังนั้นในไพ ธ อนจึงไม่มีประโยชน์เพราะทุกการเรียกไปยังฟังก์ชัน tailrecsum จะสร้างเฟรมสแต็กใหม่ใช่ไหม?
Quazi Irfan

707

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

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

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


17
"ฉันค่อนข้างแน่ใจว่า Lisp ทำสิ่งนี้" - Scheme ทำ แต่ Common LISP ไม่ได้เสมอ
แอรอน

2
@Daniel "โดยทั่วไปค่าส่งคืนของขั้นตอนแบบเรียกซ้ำใด ๆ จะเหมือนกับค่าส่งคืนของการเรียกแบบเรียกซ้ำครั้งถัดไป" - ฉันไม่เห็นอาร์กิวเมนต์นี้ถือเป็นจริงสำหรับข้อมูลโค้ดที่โพสต์โดย Lorin Hochstein คุณช่วยอธิบายรายละเอียดได้ไหม?
Geek

8
@Geek นี่เป็นการตอบรับที่ล่าช้า แต่จริง ๆ แล้วเป็นจริงในตัวอย่างของ Lorin Hochstein การคำนวณสำหรับแต่ละขั้นตอนเสร็จก่อนการเรียกซ้ำ เป็นผลให้การหยุดแต่ละครั้งเพียงแค่ส่งกลับค่าโดยตรงจากขั้นตอนก่อนหน้า การเรียกซ้ำแบบเรียกซ้ำครั้งสุดท้ายเสร็จสิ้นการคำนวณและจากนั้นส่งกลับผลลัพธ์สุดท้ายโดยไม่มีการแก้ไขตลอดจนสำรองการโทรสแต็ก
reirab

3
สกาล่าทำ แต่คุณต้องการ @tailrec ที่ระบุเพื่อบังคับใช้
SilentDirge

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

206

จุดสำคัญคือการเรียกซ้ำหางนั้นเทียบเท่ากับการวนซ้ำ มันไม่ได้เป็นเพียงแค่การเพิ่มประสิทธิภาพของคอมไพเลอร์ แต่เป็นข้อเท็จจริงพื้นฐานเกี่ยวกับการแสดงออก สิ่งนี้ไปได้ทั้งสองวิธี: คุณสามารถวนซ้ำของแบบฟอร์มได้

while(E) { S }; return Q

โดยที่EและQเป็นนิพจน์และSเป็นลำดับของข้อความสั่งและเปลี่ยนเป็นฟังก์ชันเรียกซ้ำแบบหาง

f() = if E then { S; return f() } else { return Q }

แน่นอนE, SและQจะต้องมีการกำหนดไว้ในการคำนวณมูลค่าที่น่าสนใจบางตัวแปรบาง ตัวอย่างเช่นฟังก์ชั่นวนรอบ

sum(n) {
  int i = 1, k = 0;
  while( i <= n ) {
    k += i;
    ++i;
  }
  return k;
}

เทียบเท่ากับฟังก์ชั่น tail-recursive

sum_aux(n,i,k) {
  if( i <= n ) {
    return sum_aux(n,i+1,k+i);
  } else {
    return k;
  }
}

sum(n) {
  return sum_aux(n,1,0);
}

(นี่คือ "การห่อ" ของฟังก์ชั่น tail-recursive พร้อมฟังก์ชั่นที่มีพารามิเตอร์น้อยลงเป็นสำนวนการทำงานทั่วไป)


ในคำตอบโดย @LorinHochstein ฉันเข้าใจตามคำอธิบายของเขาแล้วการวนซ้ำแบบหางนั้นจะเป็นเมื่อส่วนวนซ้ำดังนี้ "Return" อย่างไรก็ตามในตัวคุณหางแบบเรียกซ้ำนั้นไม่ใช่ คุณแน่ใจหรือไม่ว่าตัวอย่างของคุณได้รับการพิจารณาว่าถูกเรียกซ้ำหาง
CodyBugstein

1
@Imray ส่วนหางแบบเรียกซ้ำคือคำสั่ง "return sum_aux" ภายใน sum_aux
Chris Conway

1
@lmray: รหัสของคริสเทียบเท่ากันเป็นหลัก ลำดับของ if / then และรูปแบบของการทดสอบที่ จำกัด ... ถ้า x == 0 กับ if (i <= n) ... ไม่ใช่สิ่งที่จะวางสาย ประเด็นก็คือว่าการวนซ้ำแต่ละครั้งส่งผ่านผลลัพธ์ไปยังจุดถัดไป
เทย์เลอร์

else { return k; }สามารถเปลี่ยนเป็นreturn k;
c0der

144

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

สายหาง [recursion หาง] เป็นชนิดของโกโตะแต่งตัวเป็นโทร การเรียกหางเกิดขึ้นเมื่อฟังก์ชันเรียกอีกอย่างว่าเป็นการกระทำครั้งสุดท้ายดังนั้นจึงไม่มีอะไรให้ทำ ตัวอย่างเช่นในรหัสต่อไปนี้การเรียกเพื่อgเป็นการโทรแบบหาง:

function f (x)
  return g(x)
end

หลังจากfโทรgแล้วไม่มีอะไรทำ ในสถานการณ์เช่นนี้โปรแกรมไม่จำเป็นต้องกลับไปที่ฟังก์ชั่นการโทรเมื่อฟังก์ชั่นที่เรียกว่าจบลง ดังนั้นหลังจากการเรียกแบบหางโปรแกรมไม่จำเป็นต้องเก็บข้อมูลใด ๆ เกี่ยวกับฟังก์ชันการเรียกในสแต็ก ...

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

function foo (n)
  if n > 0 then return foo(n - 1) end
end

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

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

function room1 ()
  local move = io.read()
  if move == "south" then return room3()
  elseif move == "east" then return room2()
  else print("invalid move")
       return room1()   -- stay in the same room
  end
end

function room2 ()
  local move = io.read()
  if move == "south" then return room4()
  elseif move == "west" then return room1()
  else print("invalid move")
       return room2()
  end
end

function room3 ()
  local move = io.read()
  if move == "north" then return room1()
  elseif move == "east" then return room4()
  else print("invalid move")
       return room3()
  end
end

function room4 ()
  print("congratulations!")
end

ดังนั้นคุณจะเห็นเมื่อคุณโทรซ้ำแบบ:

function x(n)
  if n==0 then return 0
  n= n-2
  return x(n) + 1
end

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


9
นี่เป็นคำตอบที่ดีเพราะมันอธิบายความหมายของการเรียกหางตามขนาดสแต็ค
Andrew Swan

@AndrewSwan แน่นอนแม้ว่าฉันเชื่อว่าผู้ถามดั้งเดิมและผู้อ่านเป็นครั้งคราวที่อาจสะดุดกับคำถามนี้อาจตอบสนองได้ดีกว่าด้วยคำตอบที่ยอมรับ (เนื่องจากเขาอาจไม่ทราบว่าสแต็คคืออะไร) ตามวิธีที่ฉันใช้จิรา แฟน
Hoffmann

1
คำตอบที่ฉันชอบเช่นกันเนื่องจากรวมถึงความหมายสำหรับขนาดกองซ้อน
njk2015

80

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

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

โดยทั่วไปการเรียกซ้ำแบบหางสามารถปรับให้เหมาะกับการวนซ้ำ


1
"คิวรีแบบเรียกซ้ำขนาดใหญ่จริง ๆ แล้วอาจทำให้เกิดการล้นสแต็ก" ควรอยู่ในย่อหน้าที่ 1 ไม่ใช่ในส่วนที่ 2 (เรียกซ้ำแบบหาง) ข้อได้เปรียบที่สำคัญของการเรียกซ้ำแบบหางคือสามารถปรับให้เหมาะสม (เช่น: Scheme) ในลักษณะที่จะไม่ "สะสม" การโทรในสแต็กดังนั้นส่วนใหญ่จะหลีกเลี่ยงการล้นสแต็ก!
Olivier Dulac

70

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

เรียกซ้ำหาง / n./

หากคุณไม่ได้เบื่อมันให้ดูการเรียกซ้ำหาง


68

แทนที่จะอธิบายด้วยคำพูดนี่เป็นตัวอย่าง นี่คือ Scheme ของฟังก์ชันแฟกทอเรียล:

(define (factorial x)
  (if (= x 0) 1
      (* x (factorial (- x 1)))))

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

(define factorial
  (letrec ((fact (lambda (x accum)
                   (if (= x 0) accum
                       (fact (- x 1) (* accum x))))))
    (lambda (x)
      (fact x 1))))

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


4
+1 สำหรับการกล่าวถึงแง่มุมที่สำคัญที่สุดของการเรียกซ้ำแบบหางซึ่งพวกเขาสามารถแปลงเป็นรูปแบบวนซ้ำและทำให้มันกลายเป็นรูปแบบความซับซ้อนของหน่วยความจำ O (1)
KGhatak

1
@KGhatak ไม่แน่นอน; คำตอบที่ถูกต้องพูดเกี่ยวกับ "พื้นที่สแต็คคงที่" ไม่ใช่หน่วยความจำโดยทั่วไป ที่จะไม่เป็น nitpicking เพียงเพื่อให้แน่ใจว่าไม่มีความเข้าใจผิด เช่น tail-recursive list-tail-mutating list-reverseprocedure จะทำงานในพื้นที่สแต็คคงที่ แต่จะสร้างและขยายโครงสร้างข้อมูลบน heap ทราเวิร์สทรีสามารถใช้สแต็กจำลองในอาร์กิวเมนต์เพิ่มเติม ฯลฯ
Will Ness

45

การเรียกซ้ำแบบหางหมายถึงการเรียกแบบเรียกซ้ำเป็นครั้งสุดท้ายในการสอนตรรกะครั้งสุดท้ายในอัลกอริทึมแบบเรียกซ้ำ

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

factorial(x, fac=1) {
  if (x == 1)
     return fac;
   else
     return factorial(x-1, x*fac);
}

การเรียกแฟกทอเรียลเริ่มต้นจะเป็นfactorial(n)ที่fac=1(ค่าเริ่มต้น) และ n คือหมายเลขที่จะคำนวณแฟคทอเรียล


ฉันพบว่าคำอธิบายของคุณง่ายที่สุดที่จะเข้าใจ แต่ถ้าเป็นอะไรไปแล้ว tail recursion ก็มีประโยชน์สำหรับฟังก์ชั่นที่มีเคสฐานเดียว พิจารณาวิธีการเช่นpostimg.cc/5Yg3Cdjnนี้ หมายเหตุ: ด้านนอกelseเป็นขั้นตอนที่คุณอาจเรียกว่า "ตัวพิมพ์ใหญ่" แต่ครอบคลุมหลายบรรทัด ฉันเข้าใจคุณผิดหรือสมมติฐานถูกต้องหรือไม่? การเรียกคืนแบบหางทำได้ดีสำหรับเรือเดินสมุทรหนึ่งเส้นเท่านั้น
ฉันต้องการคำตอบ

2
@IWantAnswers - ไม่เนื้อหาของฟังก์ชันอาจมีขนาดใหญ่โดยพลการ สิ่งที่ต้องใช้สำหรับการโทรแบบหางคือสาขาที่อยู่ในการเรียกฟังก์ชันเป็นสิ่งสุดท้ายที่ทำและส่งกลับผลลัพธ์ของการเรียกฟังก์ชัน factorialตัวอย่างเป็นเพียงตัวอย่างง่ายๆคลาสสิกที่ทั้งหมด
TJ Crowder

28

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

ฉันเขียนโพสต์บล็อกในหัวเรื่องซึ่งมีตัวอย่างกราฟิกของลักษณะกรอบสแต็ก


21

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

ง่ายมากและเข้าใจง่าย

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

อีกวิธีคือการบอกว่าการเรียกซ้ำนั้นไม่มีการเพิ่มการคำนวณการดัดแปลง ฯลฯ ... ความหมายไม่มีอะไรเลยนอกจากการเรียกซ้ำ

public static int factorial(int mynumber) {
    if (mynumber == 1) {
        return 1;
    } else {            
        return mynumber * factorial(--mynumber);
    }
}

public static int tail_factorial(int mynumber, int sofar) {
    if (mynumber == 1) {
        return sofar;
    } else {
        return tail_factorial(--mynumber, sofar * mynumber);
    }
}

3
0! คือ 1. ดังนั้น "mynumber == 1" ควรเป็น "mynumber == 0"
polerto

19

วิธีที่ดีที่สุดสำหรับฉันที่จะเข้าใจtail call recursionคือกรณีพิเศษของการเรียกซ้ำที่การโทรครั้งสุดท้าย (หรือการโทรหาง) เป็นฟังก์ชั่นของตัวเอง

เปรียบเทียบตัวอย่างที่มีให้ใน Python:

def recsum(x):
 if x == 1:
  return x
 else:
  return x + recsum(x - 1)

^ recursion

def tailrecsum(x, running_total=0):
  if x == 0:
    return running_total
  else:
    return tailrecsum(x - 1, running_total + x)

^ RECILION TAIL

ที่คุณสามารถดูในรุ่น recursive x + recsum(x - 1)ทั่วไปสายสุดท้ายในการป้องกันรหัสคือ ดังนั้นหลังจากการเรียกวิธีการนี้ยังมีการดำเนินการอื่นซึ่งเป็นrecsumx + ..

อย่างไรก็ตามในเวอร์ชันแบบเรียกซ้ำหางการโทรครั้งสุดท้าย (หรือการโทรแบบหาง) ในบล็อครหัสคือtailrecsum(x - 1, running_total + x)หมายความว่าการโทรครั้งสุดท้ายจะทำกับวิธีการของตัวเองและไม่มีการดำเนินการใด ๆ หลังจากนั้น

จุดนี้มีความสำคัญเนื่องจากการเรียกซ้ำแบบหางตามที่เห็นในที่นี้ไม่ทำให้หน่วยความจำเพิ่มขึ้นเนื่องจากเมื่อ VM พื้นฐานเห็นฟังก์ชันที่เรียกตัวเองว่าอยู่ในตำแหน่งหาง (นิพจน์สุดท้ายที่ประเมินในฟังก์ชัน) จะกำจัดเฟรมสแต็กปัจจุบัน เรียกว่า Tail Call Optimization (TCO)

แก้ไข

NB โปรดจำไว้ว่าตัวอย่างข้างต้นเขียนใน Python ซึ่งรันไทม์ไม่รองรับ TCO นี่เป็นเพียงตัวอย่างเพื่ออธิบายประเด็น TCO รองรับภาษาเช่น Scheme, Haskell และอื่น ๆ


12

ใน Java ต่อไปนี้เป็นไปได้ของการใช้งานฟังก์ชัน Fibonacci แบบวนซ้ำ:

public int tailRecursive(final int n) {
    if (n <= 2)
        return 1;
    return tailRecursiveAux(n, 1, 1);
}

private int tailRecursiveAux(int n, int iter, int acc) {
    if (iter == n)
        return acc;
    return tailRecursiveAux(n, ++iter, acc + iter);
}

เปรียบเทียบสิ่งนี้กับการใช้แบบเรียกซ้ำมาตรฐาน:

public int recursive(final int n) {
    if (n <= 2)
        return 1;
    return recursive(n - 1) + recursive(n - 2);
}

1
นี่คือผลลัพธ์ที่ไม่ถูกต้องสำหรับฉันสำหรับอินพุต 8 ฉันได้ 36 มันต้องเป็น 21. ฉันขาดอะไรไปหรือเปล่า? ฉันใช้จาวาและคัดลอกแล้ว
Alberto Zaccagni

1
ส่งคืน SUM (i) สำหรับ i ใน [1, n] ไม่มีอะไรเกี่ยวข้องกับ Fibbonacci สำหรับ Fibbo คุณต้องทำการทดสอบที่iterจะยกเลิกaccเมื่อiter < (n-1)ใด
Askolein

10

ฉันไม่ใช่โปรแกรมเมอร์ Lisp แต่ฉันคิดว่านี่จะช่วยได้

โดยทั่วไปแล้วมันเป็นรูปแบบของการเขียนโปรแกรมที่การเรียกซ้ำเป็นสิ่งสุดท้ายที่คุณทำ


10

นี่คือตัวอย่าง Common LISP ที่ใช้แฟคทอเรียลโดยใช้การเรียกซ้ำแบบหาง เนื่องจากลักษณะของกองซ้อนที่น้อยกว่าเราสามารถทำการคำนวณแฟกทอเรียลที่มีขนาดใหญ่อย่างบ้าคลั่ง ...

(defun ! (n &optional (product 1))
    (if (zerop n) product
        (! (1- n) (* product n))))

และเพื่อความสนุกคุณสามารถลอง (format nil "~R" (! 25))


9

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

ดังนั้นนี่คือการเรียกซ้ำแบบหางเช่น N (x - 1, p * x) เป็นคำสั่งสุดท้ายในฟังก์ชั่นที่คอมไพเลอร์ฉลาดที่จะคิดออกว่ามันสามารถปรับให้เหมาะสำหรับวง (แฟคทอเรียล) p พารามิเตอร์ที่สองดำเนินการมูลค่าผลิตภัณฑ์ระดับกลาง

function N(x, p) {
   return x == 1 ? p : N(x - 1, p * x);
}

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

function N(x) {
   return x == 1 ? 1 : x * N(x - 1);
}

แต่นี่ไม่ใช่:

function F(x) {
  if (x == 1) return 0;
  if (x == 2) return 1;
  return F(x - 1) + F(x - 2);
}

ฉันเขียนโพสต์ยาวที่ชื่อว่า " ทำความเข้าใจการเรียกซ้ำหาง - Visual Studio C ++ - มุมมองการชุมนุม "

ป้อนคำอธิบายรูปภาพที่นี่


1
ฟังก์ชั่นของคุณเป็นแบบไม่มีหางซ้ำหรือไม่?
Fabian Pijcke

N (x-1) เป็นคำสั่งสุดท้ายในฟังก์ชั่นที่คอมไพเลอร์ฉลาดที่จะเข้าใจว่ามันสามารถปรับให้เหมาะกับการวนรอบ (แฟคทอเรียล)
doctorlai

ความกังวลของฉันคือว่าฟังก์ชั่นของคุณ N เป็นฟังก์ชั่น recsum จากคำตอบที่ได้รับการยอมรับของหัวข้อนี้ (ยกเว้นว่ามันเป็นผลรวมและไม่ใช่ผลิตภัณฑ์) และ recsum นั้นบอกว่าจะไม่เรียกซ้ำหาง?
Fabian Pijcke

8

นี่คือรุ่น Perl 5 ของtailrecsumฟังก์ชั่นที่กล่าวถึงก่อนหน้านี้

sub tail_rec_sum($;$){
  my( $x,$running_total ) = (@_,0);

  return $running_total unless $x;

  @_ = ($x-1,$running_total+$x);
  goto &tail_rec_sum; # throw away current stack frame
}

8

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

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

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


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

8

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

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

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

ฉันจะอธิบายทั้งฟังก์ชั่น 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 * ความจริง (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

ในที่สุดผลลัพธ์ของข้อเท็จจริง (4) = 4 * 3 * 2 * 1 = 24

ป้อนคำอธิบายรูปภาพที่นี่

การเรียกซ้ำแบบหางจะเป็น

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

ในที่สุดผลลัพธ์ของข้อเท็จจริง (4,1) = 24

ป้อนคำอธิบายรูปภาพที่นี่


7

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

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


1
ไม่แตกภายใต้การตีความความผิดปกติทางบุคลิกภาพแบบแยกส่วน :) สังคมแห่งจิตใจ; จิตใจในฐานะสังคม :)
Will Ness

ว้าว! ตอนนี้เป็นอีกวิธีที่จะคิดเกี่ยวกับมัน
sutanu dalui

7

tail recursion เป็นฟังก์ชันแบบเรียกซ้ำโดยที่ฟังก์ชันเรียกตัวเองที่ท้าย ("tail") ของฟังก์ชันที่ไม่มีการคำนวณใด ๆ หลังจากการเรียก recursive กลับมา คอมไพเลอร์จำนวนมากปรับให้เหมาะสมเพื่อเปลี่ยนการเรียกแบบเรียกซ้ำเป็นหางแบบเรียกซ้ำหรือการเรียกซ้ำ

พิจารณาปัญหาของการคำนวณแฟคทอเรียลของตัวเลข

แนวทางที่ตรงไปตรงมาคือ:

  factorial(n):

    if n==0 then 1

    else n*factorial(n-1)

สมมติว่าคุณเรียกแฟคทอเรียล (4) ต้นไม้เรียกซ้ำจะเป็น:

       factorial(4)
       /        \
      4      factorial(3)
     /             \
    3          factorial(2)
   /                  \
  2                factorial(1)
 /                       \
1                       factorial(0)
                            \
                             1    

ความลึกสูงสุดของการเรียกซ้ำในกรณีข้างต้นคือ O (n)

อย่างไรก็ตามพิจารณาตัวอย่างต่อไปนี้:

factAux(m,n):
if n==0  then m;
else     factAux(m*n,n-1);

factTail(n):
   return factAux(1,n);

ต้นไม้เรียกซ้ำสำหรับ factTail (4) จะเป็น:

factTail(4)
   |
factAux(1,4)
   |
factAux(4,3)
   |
factAux(12,2)
   |
factAux(24,1)
   |
factAux(24,0)
   |
  24

ความลึกการเรียกซ้ำสูงสุดคือ O (n) แต่ไม่มีการเรียกใดที่เพิ่มตัวแปรพิเศษใด ๆ ลงในสแต็ก ดังนั้นคอมไพเลอร์สามารถทำไปกับกอง


7

Tail Recursion ค่อนข้างเร็วเมื่อเทียบกับการเรียกซ้ำแบบปกติ มันเร็วเพราะเอาท์พุทของการเรียกบรรพบุรุษจะไม่ถูกเขียนในกองเพื่อติดตาม แต่ในการเรียกซ้ำปกติบรรพบุรุษทั้งหมดเรียกเอาท์พุทที่เขียนไว้ในกองเพื่อติดตาม


6

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

def recursiveFunction(some_params):
    # some code here
    return recursiveFunction(some_args)
    # no code after the return statement

คอมไพเลอร์และล่ามที่ใช้การปรับให้เหมาะสมของtail callหรือtail call eliminationสามารถปรับโค้ด recursive ให้เหมาะสมเพื่อป้องกัน stack overflows หากคอมไพเลอร์หรือล่ามของคุณไม่ได้ใช้การเพิ่มประสิทธิภาพการโทรหาง (เช่นล่าม CPython) จะไม่มีประโยชน์เพิ่มเติมในการเขียนรหัสของคุณด้วยวิธีนี้

ตัวอย่างเช่นนี่เป็นฟังก์ชันแฟกทอเรียลแบบเรียกซ้ำใน Python:

def factorial(number):
    if number == 1:
        # BASE CASE
        return 1
    else:
        # RECURSIVE CASE
        # Note that `number *` happens *after* the recursive call.
        # This means that this is *not* tail call recursion.
        return number * factorial(number - 1)

และนี่คือฟังก์ชั่นแฟกทอเรียลแบบเรียกซ้ำ (tail call):

def factorial(number, accumulator=1):
    if number == 0:
        # BASE CASE
        return accumulator
    else:
        # RECURSIVE CASE
        # There's no code after the recursive call.
        # This is tail call recursion:
        return factorial(number - 1, number * accumulator)
print(factorial(5))

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

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

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

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

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

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

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


"การเรียกซ้ำแบบหาง (เรียกอีกอย่างว่าการเพิ่มประสิทธิภาพการโทรแบบหางหรือการกำจัดการเรียกแบบหาง)" ไม่มี การกำจัด tail-call หรือการเพิ่มประสิทธิภาพ tail-call เป็นสิ่งที่คุณสามารถนำไปใช้กับฟังก์ชั่น tail-recursive แต่มันไม่เหมือนกัน คุณสามารถเขียนฟังก์ชั่น tail-recursive ใน Python (ตามที่คุณแสดง) แต่มันไม่มีประสิทธิภาพมากกว่าฟังก์ชั่น non-tail-recursive เนื่องจาก Python ไม่ได้ทำการปรับให้เหมาะสมแบบ tail-call
chepner

หมายความว่าถ้ามีคนจัดการเพื่อเพิ่มประสิทธิภาพเว็บไซต์และแสดงการเรียกซ้ำแบบเรียกซ้ำเราจะไม่มีไซต์ StackOverflow อีกต่อไป! มันช่างน่ากลัว
Nadjib Mami

5

เพื่อให้เข้าใจถึงความแตกต่างที่สำคัญระหว่างการเรียกแบบ tail-call และการโทรแบบ tail-call-recursion เราสามารถสำรวจการใช้งาน NET ของเทคนิคเหล่านี้

นี่คือบทความที่มีตัวอย่างบางส่วนใน C #, F # และ C ++ \ CLI: การผจญภัยใน Recursion หางใน C #, F # และ C

C # ไม่ปรับการเรียกซ้ำแบบ tail-call ในขณะที่ F # ทำ

ความแตกต่างของหลักการเกี่ยวข้องกับลูปกับแคลคูลัสแลมบ์ดา C # ได้รับการออกแบบโดยคำนึงถึงลูปในขณะที่ F # สร้างขึ้นจากหลักการของแคลคูลัสแลมบ์ดา สำหรับการที่ดีมาก (และฟรี) หนังสือเกี่ยวกับหลักการของแลมบ์ดาแคลคูลัสดูโครงสร้างและการแปลความหมายของโปรแกรมคอมพิวเตอร์โดย Abelson, Sussman และ Sussman

เกี่ยวกับการโทรหาง F # สำหรับบทความแนะนำที่ดีมากดูรายละเอียดเบื้องต้นเกี่ยวกับการโทรหางใน F # สุดท้ายนี่เป็นบทความที่ครอบคลุมความแตกต่างระหว่างการเรียกซ้ำไม่ใช่หาง recursion หางโทร (ใน F #): หาง recursion เทียบกับที่ไม่ใช่หาง recursion ใน F ที่คมชัด

หากคุณต้องการที่จะอ่านเกี่ยวกับบางส่วนของความแตกต่างของการออกแบบ recursion หางโทรระหว่าง C # และ F # ดูฝ่ายผลิต Tail-Call Opcode ใน C # และ F #

หากคุณสนใจพอที่จะต้องการที่จะรู้ว่าสิ่งที่ป้องกันไม่ให้เงื่อนไขคอมไพเลอร์ C # จากการปฏิบัติเพิ่มประสิทธิภาพหางเรียกดูบทความนี้: JIT CLR เงื่อนไขหางโทร


4

การเรียกซ้ำมีสองชนิดพื้นฐาน: การเรียกซ้ำแบบหัวและแบบวนซ้ำ

ในการเรียกใช้เฮดเดอร์ฟังก์ชันจะทำการเรียกซ้ำและทำการคำนวณเพิ่มเติมบางครั้งอาจใช้ผลลัพธ์ของการโทรซ้ำ

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

ที่นำมาจากนี้โพสต์ที่น่ากลัวสุด โปรดพิจารณาการอ่าน


4

การเรียกซ้ำหมายถึงฟังก์ชันที่เรียกตัวเอง ตัวอย่างเช่น:

(define (un-ended name)
  (un-ended 'me)
  (print "How can I get here?"))

Tail-Recursion หมายถึงการเรียกซ้ำที่สรุปฟังก์ชัน:

(define (un-ended name)
  (print "hello")
  (un-ended 'me))

ดูสิ่งสุดท้ายฟังก์ชั่นไม่สิ้นสุด (ขั้นตอนในศัพท์แสง Scheme) ทำคือการเรียกตัวเอง อีกตัวอย่าง (มีประโยชน์มากกว่า) คือ:

(define (map lst op)
  (define (helper done left)
    (if (nil? left)
        done
        (helper (cons (op (car left))
                      done)
                (cdr left))))
  (reverse (helper '() lst)))

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

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


3

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

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

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

กระดาษมีค่าการศึกษาอย่างรอบคอบด้วยเหตุผลหลายประการ:

  • มันให้คำนิยามอุปนัยของการแสดงออกหางและเรียกหางของโปรแกรม (คำจำกัดความดังกล่าวและสาเหตุที่การโทรดังกล่าวมีความสำคัญน่าจะเป็นหัวข้อของคำตอบอื่น ๆ ที่ให้ไว้ที่นี่)

    นี่คือคำจำกัดความเหล่านี้เพียงเพื่อให้ได้รสชาติของข้อความ:

    นิยาม 1 แสดงออกหางของโปรแกรมที่เขียนในหลักโครงการจะมีการกำหนด inductively ดังต่อไปนี้

    1. ร่างกายของการแสดงออกแลมบ์ดาคือการแสดงออกหาง
    2. หาก(if E0 E1 E2)เป็นการแสดงออกหางแล้วทั้งสองE1และE2มีการแสดงออกหาง
    3. ไม่มีอะไรอื่นที่แสดงออกหาง

    คำจำกัดความ 2การเรียกแบบหางเป็นการแสดงออกแบบหางที่เป็นการเรียกขั้นตอน

(การเรียกแบบเรียกซ้ำแบบหางหรือตามที่กระดาษบอกว่า "การโทรแบบหางตัวเอง" เป็นกรณีพิเศษของการโทรแบบหางที่กระบวนการถูกเรียกใช้เอง)

  • มันให้คำจำกัดความอย่างเป็นทางการสำหรับ "เครื่องจักร" หกชนิดที่แตกต่างกันสำหรับการประเมิน Core Scheme โดยที่แต่ละเครื่องมีพฤติกรรมที่สังเกตได้เหมือนกันยกเว้นคลาสที่ซับซ้อนเชิงพื้นที่ที่ไม่มีสัญลักษณ์

    ตัวอย่างเช่นหลังจากให้คำจำกัดความสำหรับเครื่องที่มีลำดับ 1. การจัดการหน่วยความจำแบบกองซ้อน 2. การรวบรวมขยะ แต่ไม่มีการเรียกแบบหาง 3. การรวบรวมขยะและการเรียกแบบหางกระดาษจะยังคงดำเนินต่อไปด้วยกลยุทธ์การจัดการพื้นที่เก็บข้อมูลขั้นสูงยิ่งขึ้นเช่น 4. "evlis tail recursion" ซึ่งไม่จำเป็นต้องรักษาสภาพแวดล้อมไว้ตลอดการประเมินผลการโต้แย้งย่อยนิพจน์สุดท้ายในการเรียกหาง 5. ลดสภาพแวดล้อมของการปิดเป็นเพียงตัวแปรอิสระของการปิดนั้นและ 6. ความหมายที่เรียกว่า "พื้นที่ปลอดภัย" ตามที่Appel และ Shaoกำหนด

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


(อ่านคำตอบของฉันตอนนี้ฉันไม่แน่ใจว่าฉันจัดการเพื่อจับจุดสำคัญของกระดาษ Clingerจริง ๆ แต่อนิจจาฉันไม่สามารถอุทิศเวลามากขึ้นในการพัฒนาคำตอบในตอนนี้)


1

หลายคนอธิบายการเรียกซ้ำแล้วซ้ำอีกที่นี่ ฉันอยากจะพูดถึงความคิดสองสามข้อเกี่ยวกับข้อดีบางประการที่การเรียกซ้ำได้รับจากหนังสือ“ Concurrency in .NET, รูปแบบทันสมัยของการเขียนโปรแกรมพร้อมกันและขนาน” โดย Riccardo Terrell:

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

นี่คือบันทึกย่อที่น่าสนใจจากหนังสือเล่มเดียวกันเกี่ยวกับการเรียกซ้ำหาง:

Tail-call recursion เป็นเทคนิคที่แปลงฟังก์ชั่น recursive ปกติให้เป็นเวอร์ชั่นที่ปรับให้เหมาะสมที่สุดที่สามารถจัดการอินพุตขนาดใหญ่ได้โดยไม่มีความเสี่ยงและผลข้างเคียง

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

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