前端筆試&面試爬坑系列---演算法
排序
JS本身陣列的sort方法,可以滿足日常業務操作中很多的場景了,所以我認為這也是為什麼基本面試會直接讓寫一個快速排序,因為好像其他排序方法在JS中似乎沒什麼意義了。
但是在拼多多的面試中,面試官還是讓我手寫選擇排序 氣泡排序 和快速排序 的虛擬碼。 既然有機會總結,乾脆就全部寫一遍好了,從基本排序到高階排序來說。
基本排序演算法
基本排序的基本思想非常類似,重排列時用的技術基本都是一組巢狀的for迴圈: 外迴圈遍歷陣列的每一項,內迴圈則用於比較元素。
氣泡排序
最笨最基本最經典點的方法,不管學什麼語言,說到排序,第一個接觸的就是它了吧。基本思想什麼的太經典了,就不復數了,直接用例子說明過程吧:
E A D B H
經過一次排列後,變成
A E D B H
前兩個元素互換了,接下來變成:
A D E B H
第二個和第三個互換,繼續:
A D B E H
第三個和第四個互換,最後,第二個和第三個元素還會互換一次,得到最終的順序為:
A B D E H
好了,其實基本思想就是逐個的比較,下面就實現一下:
function bubleSort(arr) { var len = arr。length; for (let outer = len ; outer >= 2; outer——) { for(let inner = 0; inner <=outer - 1; inner++) { if(arr[inner] > arr[inner + 1]) { let temp = arr[inner]; arr[inner] = arr[inner + 1]; arr[inner + 1] = temp; } } } return arr; }
這裡有兩點需要注意:
外層迴圈,從最大值開始遞減,因為內層是兩兩比較,因此最外層當>=2時即可停止;
內層是兩兩比較,從0開始,比較inner與inner+1,因此,臨界條件是inner 在比較交換的時候,就是計算機中最經典的交換策略,用臨時變數temp儲存值,但是面試官問過我, ES6有沒有簡單的方法實現? 有的,如下: arr2 = [1,2,3,4]; [arr2[0],arr2[1]] = [arr2[1],arr2[0]] //ES6解構賦值 console。log(arr2) // [2, 1, 3, 4] 所以,剛才的冒牌排序可以最佳化如下: function bubleSort(arr) { var len = arr。length; for (let outer = len ; outer >= 2; outer——) { for(let inner = 0; inner <=outer - 1; inner++) { if(arr[inner] > arr[inner + 1]) { [arr[inner],arr[inner+1]] = [arr[inner+1],arr[inner]] } } } return arr; } 選擇排序 選擇排序是從陣列的開頭開始,將第一個元素和其他元素作比較,檢查完所有的元素後,最小的放在第一個位置,接下來再開始從第二個元素開始,重複以上一直到最後。 有了剛才的鋪墊,我覺得不用再演示了,很簡單嘛: 外層迴圈從0開始到length-1, 然後內層比較,最小的放開頭,走你: function selectSort (arr) { var len = arr。length; for(let i = 0 ;i < len - 1; i++) { for(let j = i ; j 簡單說兩句: 外層迴圈的i表示第幾輪,arr[i]就表示當前輪次最靠前(小)的位置; 內層從i開始,依次往後數,找到比開頭小的,互換位置即可 結束,收工!! 插入排序 插入排序核心——撲克牌思想: 就想著自己在打撲克牌,接起來一張,放哪裡無所謂,再接起來一張,比第一張小,放左邊,繼續接,可能是中間數,就插在中間....依次 其實每種演算法,主要是理解其原理,至於寫程式碼,都是在原理之上順理成章的事情: 首先將待排序的第一個記錄作為一個有序段 從第二個開始,到最後一個,依次和前面的有序段進行比較,確定插入位置 function insertSort (arr) { for(let i = 1; i < arr。length; i++) { //外迴圈從1開始,預設arr[0]是有序段 for(let j = i; j > 0; j——) { //j = i,將arr[j]依次插入有序段中 if(arr[j] < arr[j-1]) { [arr[j],arr[j-1]] = [arr[j-1],arr[j]]; } else { break; } } } return arr; } 複製程式碼 分析: 注意這裡兩次迴圈中,i和j的含義: i是外迴圈,依次把後面的數插入前面的有序序列中,預設arr[0]為有序的,i就從1開始 j進來後,依次與前面佇列的數進行比較,因為前面的序列是有序的,因此只需要迴圈比較、交換即可 注意這裡的break,因為前面是都是有序的序列,所以如果當前要插入的值arr[j]大於或等於arr[j-1],則無需繼續比較,直接下一次迴圈就可以了。 時間複雜度 乍一看,好像插入排序速度還不慢,但是要知道: 當序列正好逆序的時候,每次插入都要一次次交換,這個速度和氣泡排序是一樣的,時間複雜度O(n²); 當然運氣好,前面是有序的,那時間複雜度就只有O(n)了,直接插入即可。 排序演算法 平均時間複雜度 最壞時間複雜度 空間複雜度 是否穩定 氣泡排序 O(n²) O(n²) O(1) 是 選擇排序 O(n²) O(n²) O(1) 不是 直接插入排序 O(n²) O(n²) O(1) 是 好了,這張表如何快速記憶呢? 方法就是一開始寫的 基本排序演算法 。 一開始就說到,基本思想就是兩層迴圈巢狀,第一遍找元素O(n),第二遍找位置O(n),所以這幾種方法,時間複雜度就可以這麼簡便記憶啦! 高階排序演算法 如果所有排序都像上面的基本方法一樣,那麼對於大量資料的處理,將是災難性的,老哥,只是讓你排個序,你都用了O(n²)。 好吧,所以接下來這些高階排序演算法,在大資料上,可以大大的減少複雜度。 快速排序 快速排序可以說是對於前端最最最最重要的排序演算法,沒有之一了,面試官問到排序演算法,快排的機率能有80%以上(我瞎統計的。。。信不信由你)。 所以快排是什麼呢? 快排是處理大資料最快的排序演算法之一。它是一種分而治之的演算法,透過遞迴的方式將資料依次分解為包含較小元素和較大元素的不同子序列。該演算法不斷重複這個步驟直至所有資料都是有序的。 簡單說: 找到一個數作為參考,比這個數字大的放在數字左邊,比它小的放在右邊; 然後分別再對左邊和右變的序列做相同的操作: 選擇一個基準元素,將列表分割成兩個子序列; 對列表重新排序,將所有小於基準值的元素放在基準值前面,所有大於基準值的元素放在基準值的後面; 分別對較小元素的子序列和較大元素的子序列重複步驟1和2 function quickSort(arr) { if(arr。length <= 1) { return arr; //遞迴出口 } var left = [], right = [], current = arr。splice(0,1); //注意splice後,陣列長度少了一個 for(let i = 0; i < arr。length; i++) { if(arr[i] < current) { left。push(arr[i]) //放在左邊 } else { right。push(arr[i]) //放在右邊 } } return quickSort(left)。concat(current,quickSort(right)); //遞迴 } 希爾排序 希爾排序是插入排序的改良演算法,但是核心理念與插入演算法又不同,它會先比較距離較遠的元素,而非相鄰的元素。文字太枯燥,還是看下面的動圖吧: 在實現之前,先看下剛才插入排序怎麼寫的: function insertSort (arr) { for(let i = 1; i < arr。length - 1; i++) { //外迴圈從1開始,預設arr[0]是有序段 for(let j = i; j > 0; j——) { //j = i,將arr[j]依次插入有序段中 if(arr[j] < arr[j-1]) { [arr[j],arr[j-1]] = [arr[j-1],arr[j]]; } else { continue; } } } return arr; } 現在,不同之處是在上面的基礎上,讓步長按照3、2、1來進行比較,相當於是三層迴圈和巢狀啦。 insertSort(arr,[3,2,1]); function shellSort (arr,gap) { console。log(arr) //為了方便觀察過程,使用時去除 for(let i = 0; i //最外層迴圈,一次取不同的步長,步長需要預先給出 let n = gap[i]; //步長為n for(let j = i + n; j < arr。length; j++) { //接下類和插入排序一樣,j迴圈依次取後面的數 for(let k = j; k > 0; k-=n) { //k迴圈進行比較,和直接插入的唯一區別是1變為了n if(arr[k] < arr[k-n]) { [arr[k],arr[k-n]] = [arr[k-n],arr[k]]; console。log(`當前序列為[${arr}] \n 交換了${arr[k]}和${arr[k-n]}`) //為了觀察過程 } else { continue; } } } } return arr; } 直接看這個三層迴圈巢狀的內容,會稍顯複雜,這也是為什麼先把插入排序寫在前面做一個對照。 其實三層迴圈的內兩層完全就是一個插入排序,只不過原來插入排序間隔為1,而希爾排序的間隔是變換的n, 如果把n修改為1,就會發現是完全一樣的了。 執行一下看看 var arr = [3, 2, 45, 6, 55, 23, 5, 4, 8, 9, 19, 0]; var gap = [3,2,1]; console。log(shellSort(arr,gap)) 結果如下: (12) [3, 2, 45, 6, 55, 23, 5, 4, 8, 9, 19, 0] //初始值 當前序列為[3,2,23,6,55,45,5,4,8,9,19,0] 交換了45和23 當前序列為[3,2,23,5,55,45,6,4,8,9,19,0] 交換了6和5 當前序列為[3,2,23,5,4,45,6,55,8,9,19,0] 交換了55和4 當前序列為[3,2,23,5,4,8,6,55,45,9,19,0] 交換了45和8 當前序列為[3,2,8,5,4,23,6,55,45,9,19,0] 交換了23和8 當前序列為[3,2,8,5,4,23,6,19,45,9,55,0] 交換了55和19 當前序列為[3,2,8,5,4,23,6,19,0,9,55,45] 交換了45和0 當前序列為[3,2,8,5,4,0,6,19,23,9,55,45] 交換了23和0 當前序列為[3,2,0,5,4,8,6,19,23,9,55,45] 交換了8和0 當前序列為[0,2,3,5,4,8,6,19,23,9,55,45] 交換了3和0 當前序列為[0,2,3,5,4,8,6,9,23,19,55,45] 交換了19和9 當前序列為[0,2,3,4,5,8,6,9,23,19,55,45] 交換了5和4 當前序列為[0,2,3,4,5,6,8,9,23,19,55,45] 交換了8和6 當前序列為[0,2,3,4,5,6,8,9,19,23,55,45] 交換了23和19 當前序列為[0,2,3,4,5,6,8,9,19,23,45,55] 交換了55和45 時間複雜度總結 wait? 不是還有很多排序演算法的嗎?怎麼不繼續了? 是的,其實排序是很深奧的問題,如果研究透各個方法的實現、效能等等,內容恐怕多到爆炸了。。。而且這個也主要是為 前端常見演算法 問題的總結,個人覺得到這裡就差不多了 排序演算法 平均時間複雜度 最壞時間複雜度 空間複雜度 是否穩定 氣泡排序 O(n²) O(n²) O(1) 是 選擇排序 O(n²) O(n²) O(1) 不是 直接插入排序 O(n²) O(n²) O(1) 是 快速排序 O(nlogn) O(n²) O(logn) 不是 希爾排序 O(nlogn) O(n^s) O(1) 不是 是否穩定 如果不考慮穩定性,快排似乎是接近完美的一種方法,但可惜它是不穩定的。 那什麼是穩定性呢? 通俗的講 有兩個相同的數A和B,在排序之前A在B的前面,而經過排序之後,B跑到了A的前面,對於這種情況的發生,我們管他叫做排序的不穩定性 ,而快速排序在對存在相同數進行排序時就有可能發生這種情況。 /* 比如對(5,3A,6,3B ) 進行排序,排序之前相同的數3A與3B,A在B的前面,經過排序之後會變成 (3B,3A,5,6) 所以說快速排序是一個不穩定的排序 /* 穩定性有什麼意義? 個人理解對於前端來說,比如我們熟知框架中的虛擬DOM的比較,我們對一個 輔助記憶 時間複雜度記憶 冒泡、選擇、直接 排序需要兩個for迴圈,每次只關注一個元素,平均時間複雜度為O(n²)(一遍找元素O(n),一遍找位置O(n)) 快速、歸併、希爾、堆基於二分思想,log以2為底,平均時間複雜度為O(nlogn)(一遍找元素O(n),一遍找位置O(logn)) 穩定性記憶-“快希選堆”(快犧牲穩定性) 遞迴 遞迴,其實就是自己呼叫自己。 很多時候我們自己覺得麻煩或者感覺 “想象不過來”,主要是自己和自己較真,因為交給遞迴,它自己會幫你完成需要做的。 遞迴步驟: 尋找出口,遞迴一定有一個出口,鎖定出口,保證不會死迴圈 遞迴條件,符合遞迴條件,自己呼叫自己。 talk is cheap,show me code! 斐波那契數列,每個語言講遞迴都會從這個開始,但是既然搞前端,就搞點不一樣的吧,從物件的深度克隆(deep clone)說起 Deep Clone :實現對一個物件(object)的深度克隆 //所謂深度克隆,就是當物件的某個屬性值為object或array的時候,要獲得一份copy,而不是直接拿到引用值 function deepClone(origin,target) { //origin是被克隆物件,target是我們獲得copy var target = target || {}; //定義target for(var key in origin) { //遍歷原物件 if(origin。hasOwnProperty(key)) { if(Array。isArray(origin[key])) { //如果是陣列 target[key] = []; deepClone(origin[key],target[key]) //遞迴 } else if (typeof origin[key] === ‘object’ && origin[key] !== null) { target[key] = {}; deepClone(origin[key],target[key]) //遞迴 } else { target[key] = origin[key]; } } } return target; } 這個可以說是前端筆試/面試中經常經常遇到的問題了,思路是很清晰的: 出口: 遍歷物件結束後return 遞迴條件: 遇到引用值Array 或 Object 剩下的事情,交給JS自己處理就好了,我們不用考慮內部的層層巢狀,想太多 實戰例題 接下來,列舉一些自己在最近筆試、面試中遇到的,需要使用遞迴實現的問題 Q1:Array陣列的flat方法實現(2018網易雷火&伏羲前端秋招筆試) Array的方法flat很多瀏覽器還未能實現,請寫一個flat方法,實現扁平化巢狀陣列,如: Array var arr1 = [1, 2, [3, 4]]; arr1。flat(); // [1, 2, 3, 4] 這個問題的實現思路和Deep Clone非常相似,這裡實現如下: Array。prototype。flat = function() { var arr = []; this。forEach((item,idx) => { if(Array。isArray(item)) { arr = arr。concat(item。flat()); //遞迴去處理陣列元素 } else { arr。push(item) //非陣列直接push進去 } }) return arr; //遞迴出口 } 好了,可以測試一下: arr = [[2],[[2,3],[2]],3,4] arr。flat() // [2, 2, 3, 2, 3, 4] 神秘力量的新解法 在評論區的一位小夥伴,提出了更好的辦法,很簡潔、方便,只用一句話就可以實現需求哦(不過你這樣去解答一道網易的“程式設計題”,不覺得讓人家有點難堪嘛~哈哈) arr。prototype。flat = function() { this。toString()。split(‘,’)。map(item=> +item ) } 好了,驚歎完之後,大概說下實現吧: toString方法,連線陣列並返回一個字串 ‘2,2,3,2,3,4’ split方法分割字串,變成陣列[‘2’,‘2’,‘3’,‘2’,‘3’,‘4’] map方法,將string對映成為number型別2,2,3,2,3,4 Q2 實現簡易版的co,自動執行generator 這個問題,詳細的解釋可以在我之前的文章(從Co剖析和解釋generator的非同步原理)去看一下,如果對ES6的iterator和generator不太瞭解的,可以跳過這個問題。 比如實現如下的功能: const co = require(‘co’); co(function *() { const url = ‘ http:// jasonplacerholder。typecoder。com /posts/1 ’; const response = yield fetch(url); const post = yield response。json(); const title = post。title; console。log(‘Title: ’,title); }) 剖析: 第一步找出口,執行器返回的iterator如果狀態為done,代表結束,可以出去 遞迴條件: 取到下一個iterator,進行遞迴,自我呼叫 function run (generat) { const iterator = generat(); function autoRun (iteration) { if(iteration。done) {return iteration。value} //出口 const anotherPromise = iteration。value; anoterPromise。then(x => { return autoRun(iterator。next(x)) //遞迴條件 }) } return autoRun(iterator。next()) } Q3。 爬樓梯問題 有一樓梯共M級,剛開始時你在第一級,若每次只能跨上一級或二級,要走上第M級,共有多少種走法? 分析 : 這個問題要倒過來看,要到達n級樓梯,只有兩種方式,從(n-1)級 或 (n-2)級到達的。所以可以用遞推的思想去想這題,假設有一個數組s[n], 那麼s[1] = 1(由於一開始就在第一級,只有一種方法), s[2] = 1(只能從s[1]上去 沒有其他方法)。 那麼就可以推出s[3] ~ s[n]了。 下面繼續模擬一下, s[3] = s[1] + s[2], 因為只能從第一級跨兩步, 或者第二級跨一步。 function cStairs (n) { if(n === 1 || n === 2) { return 1; } else { return cStairs(n-1) + cStairs(n-2) } } 嗯嗯,沒錯呢,其實就是斐波納契數列沒跑了 Q4。二分查詢 二分查詢,是在一個有序的序列裡查詢某一個值,與小時候玩的猜數字遊戲非常相啦: A: 0 ~ 100 猜一個數字 B: 50 A: 大了 B: 25 A: 對頭,就是25 因此,思路也就非常清楚了,這裡有遞迴和非遞迴兩種寫法,先說下遞迴的方法吧: 設定區間,low和high 找出口: 找到target,返回target; 否則尋找,當前次序沒有找到,把區間縮小後遞迴 function binaryFind (arr,target,low = 0,high = arr。length - 1) { const n = Math。floor((low+high) /2); const cur = arr[n]; if(cur === target) { return `找到了${target},在第${n+1}個`; } else if (cur > target) { return binaryFind (arr,target,low, n-1); } else if (cur < target) { return binaryFind (arr,target,n+1,high); } return -1; } 接下來,使用迴圈來做一下二分查詢,其實思路基本一致: function binaryFind(arr, target) { var low = 0, high = arr。length - 1, mid; while (low <= high) { mid = Math。floor((low + high) / 2); if (target === arr[mid]) { return `找到了${target},在第${mid + 1}個` } if (target > arr[mid]) { low = mid + 1; } else if (target < arr[mid]) { high = mid - 1; } } return -1 } 二叉樹和二叉查詢樹 樹的基本概念 這裡對基本概念就不詳細複習了,在各大資料中有更詳盡的介紹,這裡就只簡單介紹基本概念和術語,然後使用JavaScript實現一個二叉樹,並封裝其方法。 如圖所示,一棵樹最上面的幾點稱為根節點,如果一個節點下面連線多個節點,那麼該節點成為父節點,它下面的節點稱為子節點,一個節點可以有0個、1個或更多節點,沒有子節點的節點叫葉子節點。 二叉樹 ,是一種特殊的樹,即子節點最多隻有兩個,這個限制可以使得寫出高效的插入、刪除、和查詢資料。在二叉樹中,子節點分別叫左節點和右節點。 二叉查詢樹 二叉查詢樹是一種特殊的二叉樹,相對較小的值儲存在左節點中,較大的值儲存在右節點中,這一特性使得查詢的效率很高,對於數值型和非數值型資料,比如字母和字串,都是如此。現在透過JS實現一個二叉查詢樹。 節點 二叉樹的最小元素是節點,所以先定義一個節點 function Node(data,left,right) { this。left = left; this。right = right; this。data = data; this。show = () => {return this。data} } 這個就是二叉樹的最小結構單元 二叉樹 function BST () { this。root = null //初始化,root為null } BST初始化時,只有一個根節點,且沒有任何資料。 接下來,我們利用二叉查詢樹的規則,定義一個插入方法,這個方法的基本思想是: 如果BST。root === null ,那麼就將節點作為根節點 如果BST。root !==null ,將插入節點進行一個比較,小於根節點,拿到左邊的節點,否則拿右邊,再次比較、遞迴。 這裡就出現了遞迴了,因為,總是要把較小的放在靠左的分支。換言之 最左變的葉子節點是最小的數,最右的葉子節點是最大的數 function insert(data) { var node = new Node(data,null,null); if(this。root === null) { this。root = node } else { var current = this。root; var parent; while(true) { parent = current; if(data < current。data) { current = current。left; //到左子樹 if(current === null) { //如果左子樹為空,說明可以將node插入在這裡 parent。left = node; break; //跳出while迴圈 } } else { current = current。right; if(current === null) { parent。right = node; break; } } } } } 這裡,是使用了一個迴圈方法,不斷的去向子樹尋找正確的位置。 迴圈和遞迴都有一個核心,就是找到出口,這裡的出口就是當current 為null的時候,代表沒有內容,可以插入。 接下來,將此方法寫入BST即可: function BST () { this。root = null; this。insert = insert; } 這樣子,就可以使用二叉樹這個自建的資料結構了: var bst = new BST (); bst 。 insert (10); bst 。 insert (8); bst 。 insert (2); bst 。 insert (7); bst 。 insert (5); 但是這個時候,想要看樹中的資料,不是那麼清晰,所以接下來,就要用到遍歷了。 二叉樹的遍歷 我們知道,樹的遍歷主要包括 前序遍歷 (根左右) 中序遍歷 (左根右) 後序遍歷 (左右根) 這個只是為了好記憶,我們拿下面的圖做一個遍歷 前序遍歷: 56 22 10 30 81 77 92 中序遍歷: 10 22 30 56 77 81 92 後序遍歷: 10 30 22 77 92 81 56 這裡發現了一些規律: 前序遍歷,因為是根左右,所以最後一個一定是最大的; 第一個一定是root節點; 中序遍歷,在查詢二叉樹中,一定是從小到大的順序; 根節點56左邊(10/22/30)的一定是左子樹的,右邊的(77/81/92)一定是右子樹的。 後序遍歷,根節點一定在最後 中序遍歷的實現 這裡就又用到之前的遞迴了,中序遍歷要求: 左!根!右 function inOrder(node ) { if (node !== null ) { //如果不是null,就一直查詢左變,因此遞迴 inOrder(node。 left ); //遞迴結束,列印當前值 console。log(node。 show ()); //上一次遞迴已經把左邊搞完了,右邊 inOrder(node。 right ); } } //在剛才已有bst的基礎上執行命令 inOrder(bst。root); 透過遞迴,實現了中序遍歷,上面打印出的結果如下: 2 5 7 8 10 前序遍歷&後序遍歷 如果剛才的遞迴過程搞清楚,那這個就再簡單不過了 function preOrder(node ) { if (node !== null ) { //根左右 console。log(node。 show ()); preOrder(node。 left ); preOrder(node。 right ); } } ok,趁熱打鐵,就把後序遍歷的方法也一併寫入,如下: function postOrder(node ) { if (node !== null ) { //左右根 postOrder(node。 left ); postOrder(node。 right ); console。log(node。 show ()) } } 好了,可以去嘗試兩種方法打印出來的結果了: preOrder(bst。root) ; postOrder(bst。root) ; 二叉樹的查詢 在二叉樹這種資料結構中進行資料查詢是最方便的,現在我們就對查詢最小值、最大值和特定值進行一個梳理: 最小值: 最左子樹的葉子節點 最大值: 最右子樹的葉子節點 特定值: target與current進行比較,如果比current大,在current。right進行查詢,反之類似。 清楚思路後,就動手來寫: //最小值 function getMin (bst) { var current = bst。root; while(current。left !== null) { current = current。left; } return current。data; } //最大值 function getMax (bst) { var current = bst。root; while(current。right !== null) { current = current。right; } return current。data; } 最大、最小值都是非常簡單的,下面主要看下如何透過 function find(target,bst) { var current = bst。root; while(current !== null) { if(target === current。data) { return true; } else if(target > current。data) { current = current。right; } else if(target < current。data) { current = current。left; } } return -1; } 其實核心,仍然是透過一個迴圈和判斷,來不斷的向下去尋找,這裡的思想其實和二分查詢是有點類似的。 哇。。。 沒想到今天去整理排序 花了這麼久。。。嗯。。然而這篇文章已經夠長了 接下來我會把之前筆試遇到的題目和一些常用的演算法問題,一一記錄,前端很多演算法都是和陣列、字串處理息息相關的,所以對正則表示式、陣列常用方法的掌握也很重要,簡單總結下知識點: 正則表示式 字串相關方法 str。split() str。replace() str。match() reg。test() reg。exec() 陣列方法 Array。map() 對映,有返回值,不改變陣列本身 Array。forEach() 遍歷,無返回值 Array。filter() 過濾,返回true時返回,false時不返回 Array。splice/slice/join等 for。。。of 遍歷,iterator相關知識點列表進行渲染,當資料改變後需要比較變化時,不穩定排序或操作將會使本身不需要變化的東西變化,導致重新渲染,帶來效能的損耗。