遞歸優化

es6出來已經很長時間了,平時工作中也會用到很多es6的新特性,自以為很多東西已經了解清楚了。

周末有空從頭開始把軟大神寫的es6從頭開始又看了一遍,發現好多東西都是囫圇吞棗,很多概念都沒理解清楚。特別是在函數的擴展模塊,以前壓根就沒看到最后,關于遞歸尾調用這里,概念都不清楚。

之前寫的權限框架涉及很多tree的遞歸遍歷,遞歸消耗性能,遞歸嵌套層級過多容易導致棧溢出,這個問題眾所周知,所以怎么處理遞歸調用就很重要,這個尾調用優化正好就是解決這個問題。

關于尾調用的概念,直接引用阮一峰的文檔,也可以直接查看ES6文檔http://es6.ruanyifeng.com/#docs/function

一、階乘

1.遞歸

       const { log } = console
        //獲取階乘
        //方式一:遞歸 復雜度O(n) 
        function getJc(n){
            if(n===1) return 1
            return n * getJc(n-1)
        }
        log('遞歸',getJc(4))

2.遞歸優化

        //方式二:遞歸優化 復雜度O(1)
        function getJc1(n,x){
            if(n===1) return x
            return  getJc1(n-1, x * n)
        }
        log('遞歸優化',getJc1(6,1))

3.循環

        //方式三:循環 效率高,缺點是定義變量多,變量改變頻繁
        function getJc2(n){
            if( n <= 2 ) return n
            let cj = 1
            let n1 = 1
            for( let i = 1; i <= n; i++ ){
                n1 = cj
                cj = n1 * i
            }
            return cj
        }
        log('循環',getJc2(6))        

4.柯里化

//這種方式只是將傳參方式改變了下,本質還是尾調用優化處理
function currying(fn, n) {
  return function (m) {
    return fn.call(this, m, n);
  };
}

function tailFactorial(n, total) {
  if (n === 1) return total;
  return tailFactorial(n - 1, n * total);
}

const factorial = currying(tailFactorial, 1);

factorial(5) // 120

二、斐波那契數列

1.遞歸

// 廣度優先遍歷
let tempArr = []
function f(tree){
    tempArr = []
    tree.forEach(item=>{
        console.log(item.id)
        if(item.child){
            tempArr = tempArr.concat(item.child)
        }
    })
    tempArr.length > 0 && f(tempArr)
}
const tree = [{id:1,child:
[{id:2,child:[{id:4,child:[{id:8,id:9}]}]}]}]
f(tree) //1 2 4 9

// 深度優先遍歷
let temArr = []
function f(tree){
    tree.forEach(item=>{
        console.log('item.id',item.id)
        if(item.child){
            f(item.child)
        }
    })
}
      const { log } = console
        
      //斐波那契數列
        //方式一:遞歸
        function Fibonacci (n) {
            if ( n <= 1 ) {return 1};
            return Fibonacci(n - 1) + Fibonacci(n - 2);
        }
        log('遞歸',Fibonacci(9))

2.遞歸尾調用優化

        //方式二:尾遞歸調用優化 優點:減少重復計算,性能高,代碼優雅 
       // 缺點:代碼理解難度大
        function Fibonacci1 (n,x=1,y=1) {
            if ( n <= 1 ) {return y};
            return Fibonacci1(n - 1,y,x+y);
        }
      log('尾調用優化',Fibonacci1(9))

function curr1(fn,x,y){
    return function(n){
        return fn.call(this,n,x,y)
    }
}
const n1 = curr1(Fibonacci1,1,1)
n1(8) // 21
n1(6) // 8

3.循環

        //方式三:循環 優點:無重復計算,速度快;缺點:變量多,改變頻繁
        function Fibonacci2 (n) {
            if ( n <= 2 ) {return n};
            let n1 = 1,n2 = 1, sum = 0
            for(let i = 2; i <= n; i++){
                sum = n1 + n2
                n1 = n2
                n2 = sum
            }
            return sum;
        }
        log('循環',Fibonacci2(80))

4.遞歸?緩存

        //方式四:遞歸?緩存 
       
        function memozi(fn){
            let obj = {}
            return function(n){
                if(!obj[n]){
                    return obj[n] = fn(n)
                }else{
                    return obj[n]
                }
            }
        }
        const Fibonacci3 =  memozi ( function(n) {
            if ( n <= 2 ) {return n};
            return Fibonacci3(n-1) + Fibonacci3(n-2);
        })
        log('遞歸?緩存',Fibonacci3(80))

以下,引用于阮一峰《ECMAScript 6 入門》
http://es6.ruanyifeng.com/#docs/function

一、什么是尾調用?

尾調用(Tail Call)是函數式編程的一個重要概念,本身非常簡單,一句話就能說清楚,就是指某個函數的最后一步是調用另一個函數。

function f(x){
  return g(x);
}

上面代碼中,函數f的最后一步是調用函數g,這就叫尾調用。

以下三種情況,都不屬于尾調用。

// 情況一
function f(x){
  let y = g(x);
  return y;
}

// 情況二
function f(x){
  return g(x) + 1;
}

// 情況三
function f(x){
  g(x);
}
//情況三等同于
function f(x){
  g(x);
  return undefined;
}

上面代碼中,情況一是調用函數g之后,還有賦值操作,所以不屬于尾調用,即使語義完全一樣。情況二也屬于調用后還有操作,即使寫在一行內。

尾調用不一定出現在函數尾部,只要是最后一步操作即可

function f(x) {
  if (x > 0) {
    return m(x)
  }
  return n(x);
}

上面代碼中,函數m和n都屬于尾調用,因為它們都是函數f的最后一步操作。

二、尾調用優化

尾調用之所以與其他調用不同,就在于它的特殊的調用位置。

我們知道,函數調用會在內存形成一個“調用記錄”,又稱“調用幀”(call frame),保存調用位置和內部變量等信息。如果在函數A的內部調用函數B,那么在A的調用幀上方,還會形成一個B的調用幀。等到B運行結束,將結果返回到A,B的調用幀才會消失。如果函數B內部還調用函數C,那就還有一個C的調用幀,以此類推。所有的調用幀,就形成一個“調用棧”(call stack)。

尾調用由于是函數的最后一步操作,所以不需要保留外層函數的調用幀,因為調用位置、內部變量等信息都不會再用到了,只要直接用內層函數的調用幀,取代外層函數的調用幀就可以了。

function f() {
  let m = 1;
  let n = 2;
  return g(m + n);
}
f();

// 等同于
function f() {
  return g(3);
}
f();

// 等同于
g(3);

上面代碼中,如果函數g不是尾調用,函數f就需要保存內部變量m和n的值、g的調用位置等信息。但由于調用g之后,函數f就結束了,所以執行到最后一步,完全可以刪除f(x)的調用幀,只保留g(3)的調用幀。

這就叫做“尾調用優化”(Tail call optimization),即只保留內層函數的調用幀。如果所有函數都是尾調用,那么完全可以做到每次執行時,調用幀只有一項,這將大大節省內存。這就是“尾調用優化”的意義。

注意,只有不再用到外層函數的內部變量,內層函數的調用幀才會取代外層函數的調用幀,否則就無法進行“尾調用優化”。

function addOne(a){
  var one = 1;
  function inner(b){
    return b + one;
  }
  return inner(a);
}

上面的函數不會進行尾調用優化,因為內層函數inner用到了外層函數addOne的內部變量one。

三、尾遞歸

函數調用自身,稱為遞歸。如果尾調用自身,就稱為尾遞歸。

遞歸非常耗費內存,因為需要同時保存成千上百個調用幀,很容易發生“棧溢出”錯誤(stack overflow)。但對于尾遞歸來說,由于只存在一個調用幀,所以永遠不會發生“棧溢出”錯誤。

function factorial(n) {
  if (n === 1) return 1;
  return n * factorial(n - 1);
}

factorial(5) // 120

上面代碼是一個階乘函數,計算n的階乘,最多需要保存n個調用記錄,復雜度 O(n) 。

如果改寫成尾遞歸,只保留一個調用記錄,復雜度 O(1) 。

function factorial(n, total) {
  if (n === 1) return total;
  return factorial(n - 1, n * total);
}

factorial(5, 1) // 120

還有一個比較著名的例子,就是計算 Fibonacci 數列,也能充分說明尾遞歸優化的重要性。

非尾遞歸的 Fibonacci 數列實現如下。

function Fibonacci (n) {
  if ( n <= 1 ) {return 1};

  return Fibonacci(n - 1) + Fibonacci(n - 2);
}

Fibonacci(10) // 89
Fibonacci(100) // 超時
Fibonacci(500) // 超時

尾遞歸優化過的 Fibonacci 數列實現如下。

function Fibonacci2 (n , ac1 = 1 , ac2 = 1) {
  if( n <= 1 ) {return ac2};

  return Fibonacci2 (n - 1, ac2, ac1 + ac2);
}

Fibonacci2(100) // 573147844013817200000
Fibonacci2(1000) // 7.0330367711422765e+208
Fibonacci2(10000) // Infinity

由此可見,“尾調用優化”對遞歸操作意義重大,所以一些函數式編程語言將其寫入了語言規格。ES6 亦是如此,第一次明確規定,所有 ECMAScript 的實現,都必須部署“尾調用優化”。這就是說,ES6 中只要使用尾遞歸,就不會發生棧溢出(或者層層遞歸造成的超時),相對節省內存。

四、遞歸函數的改寫

尾遞歸的實現,往往需要改寫遞歸函數,確保最后一步只調用自身。做到這一點的方法,就是把所有用到的內部變量改寫成函數的參數。比如上面的例子,階乘函數 factorial 需要用到一個中間變量total,那就把這個中間變量改寫成函數的參數。這樣做的缺點就是不太直觀,第一眼很難看出來,為什么計算5的階乘,需要傳入兩個參數5和1?

兩個方法可以解決這個問題。方法一是在尾遞歸函數之外,再提供一個正常形式的函數。

function tailFactorial(n, total) {
  if (n === 1) return total;
  return tailFactorial(n - 1, n * total);
}

function factorial(n) {
  return tailFactorial(n, 1);
}

factorial(5) // 120

上面代碼通過一個正常形式的階乘函數factorial,調用尾遞歸函數tailFactorial,看起來就正常多了。

函數式編程有一個概念,叫做柯里化(currying),意思是將多參數的函數轉換成單參數的形式。這里也可以使用柯里化。

function currying(fn, n) {
  return function (m) {
    return fn.call(this, m, n);
  };
}

function tailFactorial(n, total) {
  if (n === 1) return total;
  return tailFactorial(n - 1, n * total);
}

const factorial = currying(tailFactorial, 1);

factorial(5) // 120

上面代碼通過柯里化,將尾遞歸函數tailFactorial變為只接受一個參數的factorial。

第二種方法就簡單多了,就是采用 ES6 的函數默認值。

function factorial(n, total = 1) {
  if (n === 1) return total;
  return factorial(n - 1, n * total);
}

factorial(5) // 120

上面代碼中,參數total有默認值1,所以調用時不用提供這個值。

總結一下,遞歸本質上是一種循環操作。純粹的函數式編程語言沒有循環操作命令,所有的循環都用遞歸實現,這就是為什么尾遞歸對這些語言極其重要。對于其他支持“尾調用優化”的語言(比如 Lua,ES6),只需要知道循環可以用遞歸代替,而一旦使用遞歸,就最好使用尾遞歸。

五、嚴格模式

ES6 的尾調用優化只在嚴格模式下開啟,正常模式是無效的。

這是因為在正常模式下,函數內部有兩個變量,可以跟蹤函數的調用棧。

  • func.arguments:返回調用時函數的參數。
  • func.caller:返回調用當前函數的那個函數。

尾調用優化發生時,函數的調用棧會改寫,因此上面兩個變量就會失真。嚴格模式禁用這兩個變量,所以尾調用模式僅在嚴格模式下生效。

function restricted() {
  'use strict';
  restricted.caller;    // 報錯
  restricted.arguments; // 報錯
}
restricted();

六、尾遞歸優化的實現

尾遞歸優化只在嚴格模式下生效,那么正常模式下,或者那些不支持該功能的環境中,有沒有辦法也使用尾遞歸優化呢?回答是可以的,就是自己實現尾遞歸優化。

它的原理非常簡單。尾遞歸之所以需要優化,原因是調用棧太多,造成溢出,那么只要減少調用棧,就不會溢出。怎么做可以減少調用棧呢?就是采用“循環”換掉“遞歸”。

下面是一個正常的遞歸函數。

function sum(x, y) {
  if (y > 0) {
    return sum(x + 1, y - 1);
  } else {
    return x;
  }
}

sum(1, 100000)
// Uncaught RangeError: Maximum call stack size exceeded(…)

上面代碼中,sum是一個遞歸函數,參數x是需要累加的值,參數y控制遞歸次數。一旦指定sum遞歸 100000 次,就會報錯,提示超出調用棧的最大次數。

蹦床函數(trampoline)可以將遞歸執行轉為循環執行。

function trampoline(f) {
  while (f && f instanceof Function) {
    f = f();
  }
  return f;
}

上面就是蹦床函數的一個實現,它接受一個函數f作為參數。只要f執行后返回一個函數,就繼續執行。注意,這里是返回一個函數,然后執行該函數,而不是函數里面調用函數,這樣就避免了遞歸執行,從而就消除了調用棧過大的問題。

然后,要做的就是將原來的遞歸函數,改寫為每一步返回另一個函數。

function sum(x, y) {
  if (y > 0) {
    return sum.bind(null, x + 1, y - 1);
  } else {
    return x;
  }
}

上面代碼中,sum函數的每次執行,都會返回自身的另一個版本。

現在,使用蹦床函數執行sum,就不會發生調用棧溢出。

trampoline(sum(1, 100000))
// 100001

蹦床函數并不是真正的尾遞歸優化,下面的實現才是。

function tco(f) {
  var value;
  var active = false;
  var accumulated = [];

  return function accumulator() {
    accumulated.push(arguments);
    if (!active) {
      active = true;
      while (accumulated.length) {
        value = f.apply(this, accumulated.shift());
      }
      active = false;
      return value;
    }
  };
}

var sum = tco(function(x, y) {
  if (y > 0) {
    return sum(x + 1, y - 1)
  }
  else {
    return x
  }
});

sum(1, 100000)
// 100001

上面代碼中,tco函數是尾遞歸優化的實現,它的奧妙就在于狀態變量active。默認情況下,這個變量是不激活的。一旦進入尾遞歸優化的過程,這個變量就激活了。然后,每一輪遞歸sum返回的都是undefined,所以就避免了遞歸執行;而accumulated數組存放每一輪sum執行的參數,總是有值的,這就保證了accumulator函數內部的while循環總是會執行。這樣就很巧妙地將“遞歸”改成了“循環”,而后一輪的參數會取代前一輪的參數,保證了調用棧只有一層。

以上,引用于阮一峰《ECMAScript 6 入門》
http://es6.ruanyifeng.com/#docs/function

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 230,247評論 6 543
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,520評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,362評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,805評論 1 317
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,541評論 6 412
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,896評論 1 328
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,887評論 3 447
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 43,062評論 0 290
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,608評論 1 336
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,356評論 3 358
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,555評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,077評論 5 364
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,769評論 3 349
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,175評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,489評論 1 295
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,289評論 3 400
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,516評論 2 379