本文是從初步解決到最終解決的思路,文章篇幅較長
雖然是一篇從0開始的文章,中間的思惟跳躍可能比較大
代碼的解析都在文章的思路分析和註釋裏,全文會幫助理解的幾個關鍵詞javascript
- Number.MAX_SAFE_INTEGER 和 Number.MIN_SAFE_INTEGER
- 15長度的字符串
- padStart 和 padEnd
相信不少前端都知道這段神奇的代碼吧前端
console.log(0.1 + 0.2 === 0.3) // false
console.log(0.3 - 0.2 === 0.1) // false
複製代碼
網絡上有不少文章解釋,這裏就不剖析了。
至少咱們能夠知道,小數加減是存在問題的!
那怎麼解決小數的加減呢?有一個思路:java
既然小數加減存在問題,那麼避開這個問題。
直接把小數轉換成整數後加減計算,這總能夠吧。
複製代碼
小數的坑如今轉到了整數,再看看整數加減的坑...git
const max = Number.MAX_SAFE_INTEGER
console.log(max) // 9007199254740991
console.log(max + 2) // 9007199254740992
const min = Number.MIN_SAFE_INTEGER
console.log(min) // -9007199254740991
console.log(min - 2) // -9007199254740992
複製代碼
Number.MAX_SAFE_INTEGER
是何物?
根據 MDN 裏面的定義github
常量表示在 JavaScript 中最大的安全整數
複製代碼
同理可知,Number.MIN_SAFE_INTEGER
也就是最小的安全整數
整數的加減在最大安全整數和最小安全整數之內的計算纔是穩穩的
計算結果安全了麼?emmm好像還有一個問題...數組
console.log(10 ** 21) // 1e+21
console.log(999999999999999999999) // 1e+21
複製代碼
從上面的結果能夠看到,不可能忍受的是安全
1.最後的輸出結果顯示的是科學計數法
2.科學計數法表示的數並不能準確知道真實的數是多少
複製代碼
既然數字的顯示存在這樣的問題,把輸入結果和輸出結果都用字符串表示網絡
console.log(`${10 ** 21}`) // '1e+21'
console.log('' + 10 ** 21) // '1e+21'
console.log((10 ** 21).toString()) // '1e+21'
複製代碼
咱們發現即便直接就轉換成字符串仍然會顯示爲科學計數法,那麼能夠直接輸入字符串了,跳過轉成字符串的過程函數
在這裏先試着解決整數加法的問題
這裏有幾個可能性測試
1.輸入的數字都在安全整數之內相加以後,且計算的結果也在安全整數以內,則直接輸出結果
2.若是不知足上面條件的...(等下再說)
複製代碼
const MAX = Number.MAX_SAFE_INTEGER
const MIN = Number.MIN_SAFE_INTEGER
/** * @param { number } num 須要檢查的整數 * @return { boolean } 返回數字是否爲安全的整數 */
function isSafeNumber(num) {
// 即便 num 成了科學計數法也能正確的和 MAX, MIN 比較大小
return MIN <= num && num <= MAX
}
/** * @param { string } a 相加的第一個整數字符串 * @param { string } b 相加的第二個整數字符串 * @return { string } 返回相加的結果 */
function IntAdd(a = '', b = '') {
let resulte = '0'
const intA = Number(a), intB = Number(b)
if (intA === 0) return b
if (intB === 0) return a
if (isSafeNumber(intA) && isSafeNumber(intB) && isSafeNumber(intA + intB)) {
resulte = intA + intB
} else {
resulte = IntCalc(a, b)
}
return resulte
}
function IntCalc(a, b) {
// TODO
}
複製代碼
若是不知足上面條件的呢?
筆者的思路是
獲取數字轉成字符串拆分紅多個部分(數組),每個部分的長度爲 Number.MAX_SAFE_INTEGER 轉成字符串後的長度減一(15),長度不足15的用字符‘0’填充首部,再計算每一個部分的結果後拼接在一塊兒
同時考慮到正負號的問題,拆分後的計算須要帶上符號
複製代碼
長度減一的緣由是接下來每部分的全部計算都是安全的,不須要在考慮是數字計算結果爲安全的整數
同時每部分計算後的結果存在問題以及筆者的解決方案
注意:下面會使用15這個數字,15上面說過了,是Number.MAX_SAFE_INTEGER的長度減一
1.計算結果爲0
那麼這個部分賦值15個字符‘0’組成的字符串,即‘000000000000000’
2.計算結果爲負數
那麼向上一級數組借10的15次方,同時高位(下一級數組)減一,低位用10的15次方再加上這個負數,作爲這個部分的結果
3.計算結果爲正數,判斷長度:
若是長度超過15,那麼去掉結果的第一位字符(由於進位,第一個字符必定是‘1’),同時高位(下一級數組)加一
若是長度沒有超過15,向首部補充0直到長度足夠15
若是長度等於15,直接添加到結果中
複製代碼
直接上代碼吧,裏面會有詳細的註釋
const MAX = Number.MAX_SAFE_INTEGER
const MIN = Number.MIN_SAFE_INTEGER
const intLen = `${MAX}`.length - 1 // 下面會頻繁用到的長度 15
function isSafeNumber(num) {
// 即便 num 成了科學計數法也能正確的和 MAX, MIN 比較大小
return MIN <= num && num <= MAX
}
// 整數加法函數入口
function intAdd(a = '0', b = '0') {
const statusObj = checkNumber(a, b)
if (!statusObj.status) {
return statusObj.data
} else {
const tagA = Number(a) < 0, tagB = Number(b) < 0
const strA = `${a}`, strB = `${b}`
const lenA = tagA ? strA.length - 1 : strA.length
const lenB = tagB ? strB.length - 1 : strB.length
const maxLen = Math.max(lenA, lenB)
const padLen = Math.ceil(maxLen / intLen) * intLen // 即爲會用到的整個數組長度
const newA = tagA ? `-${strA.slice(1).padStart(padLen, '0')}` : strA.padStart(padLen, '0')
const newB = tagB ? `-${strB.slice(1).padStart(padLen, '0')}` : strB.padStart(padLen, '0')
let result = intCalc(newA, newB)
// 去掉正負數前面無心義的字符 ‘0’
const numberResult = Number(result)
if (numberResult > 0) {
while (result[0] === '0') {
result = result.slice(1)
}
} else if (numberResult < 0) {
while (result[1] === '0') {
result = '-' + result.slice(2)
}
} else {
result = '0'
}
console.log(result)
return result
}
}
/** * @param { string } a 相加的第一個整數字符串 * @param { string } b 相加的第二個整數字符串 * @return { string } 返回相加的結果 */
function intCalc(a, b) {
let result = '0'
const intA = Number(a), intB = Number(b)
// 判斷是否爲安全數,不爲安全數的操做進入複雜計算模式
if (isSafeNumber(intA) && isSafeNumber(intB) && isSafeNumber(intA + intB)) {
result = `${intA + intB}`
} else {
const sliceA = a.slice(1), sliceB = b.slice(1)
if(a[0] === '-' && b[0] === '-') {
// 兩個數都爲負數,取反後計算,結果再取反
result = '-' + calc(sliceA, sliceB, true)
} else if (a[0] === '-') {
// 第一個數爲負數,第二個數爲正數的狀況
const newV = compareNumber(sliceA, b)
if (newV === 1) {
// 因爲 a 的絕對值比 b 大,爲了確保返回結果爲正數,a的絕對值做爲第一個參數
result = '-' + calc(sliceA, b, false)
} else if (newV === -1) {
// 道理同上
result = calc(b, sliceA, false)
}
} else if (b[0] === '-') {
// 第一個數爲正數,第二個數爲負數的狀況
const newV = compareNumber(sliceB, a)
if (newV === 1) {
// 因爲 b 的絕對值比 a 大,爲了確保返回結果爲正數,b的絕對值做爲第一個參數
result = '-' + calc(sliceB, a, false)
} else if (newV === -1) {
// 道理同上
result = calc(a, sliceB, false)
}
} else {
// 兩個數都爲正數,直接計算
result = calc(a, b, true)
}
}
return result
}
/** * @param { string } a 比較的第一個整數字符串 * @param { string } b 比較的第二個整數字符串 * @return { object } 返回是否要退出函數的狀態和退出函數返回的數據 */
function checkNumber(a, b) {
const obj = {
status: true,
data: null
}
const typeA = typeof(a), typeB = typeof(b)
const allowTypes = ['number', 'string']
if (!allowTypes.includes(typeA) || !allowTypes.includes(typeB)) {
console.error('參數中存在非法的數據,數據類型只支持 number 和 string')
obj.status = false
obj.data = false
}
if (Number.isNaN(a) || Number.isNaN(b)) {
console.error('參數中不該該存在 NaN')
obj.status = false
obj.data = false
}
const intA = Number(a), intB = Number(b)
if (intA === 0) {
obj.status = false
obj.data = b
}
if (intB === 0) {
obj.status = false
obj.data = a
}
const inf = [Infinity, -Infinity]
if (inf.includes(intA) || inf.includes(intB)) {
console.error('參數中存在Infinity或-Infinity')
obj.status = false
obj.data = false
}
return obj
}
/** * @param { string } a 比較的第一個整數字符串 * @param { string } b 比較的第二個整數字符串 * @return { boolean } 返回第一個參數與第二個參數的比較 */
function compareNumber(a, b) {
if (a === b) return 0
if (a.length > b.length) {
return 1
} else if (a.length < b.length) {
return -1
} else {
for (let i=0; i<a.length; i++) {
if (a[i] > b[i]) {
return 1
} else if (a[i] < b[i]) {
return -1
}
}
}
}
/** * @param { string } a 相加的第一個整數字符串 * @param { string } b 相加的第二個整數字符串 * @param { string } type 兩個參數是 相加(true) 仍是相減(false) * @return { string } 返回相加的結果 */
function calc(a, b, type = true) {
const arr = [] // 保存每一個部分計算結果的數組
for (let i=0; i<a.length; i+=intLen) {
// 每部分長度 15 的裁取字符串
const strA = a.slice(i, i + intLen)
const strB = b.slice(i, i + intLen)
const newV = Number(strA) + Number(strB) * (type ? 1 : -1) // 每部分的計算結果,暫時不處理
arr.push(`${newV}`)
}
let num = '' // 鏈接每一個部分的字符串
for (let i=arr.length-1; i>=0; i--) {
if (arr[i] > 0) {
// 每部分結果大於 0 的處理方案
const str = `${arr[i]}`
if (str.length < intLen) {
// 長度不足 15 的首部補充字符‘0’
num = str.padStart(intLen, '0') + num
} else if (str.length > intLen) {
// 長度超過 15 的扔掉第一位,下一部分進位加一
num = str.slice(1) + num
if (i >= 1 && str[0] !== '0') arr[i-1]++
else num = '1' + num
} else {
// 長度等於 15 的直接計算
num = str + num
}
} else if(arr[i] < 0) {
// 每部分結果小於 0 的處理方案,借位 10的15次方計算,結果恆爲正數,首部填充字符‘0’到15位
const newV = `${10 ** intLen + Number(arr[i])}`
num = newV.padStart(intLen, '0') + num
if (i >= 1) arr[i-1]--
} else {
// 每部分結果等於 0 的處理方案,連續15個字符‘0’
num = '0'.padStart(intLen, '0') + num
}
}
return num
}
複製代碼
測試結果
這一部分的代碼請看 這裏
console.log(MAX) // 9007199254740991
intAdd(MAX, '2') // '9007199254740993'
intAdd(MAX, '10000000000000000') // '19007199254740991'
// 下面測試10的二十一次方的數據 1000000000000000000000
intAdd(MAX, '1000000000000000000000') // '1000009007199254740991'
intAdd(MAX, `-${10 ** 16}`) // '-992800745259009'
// 仍然存在一個問題,就是不要使用計算中的字符串,以下
intAdd(MAX, `${10 ** 21}`) // '10.0000000071992548e+21'
intAdd(MAX, `-${10 ** 21}`) // '0'
複製代碼
固然考慮到因爲通常計算不會使用大數,書寫字符串相加確實感受怪怪的,能夠在函數內加入判斷,是科學計數法的提示並轉換爲10進制數,進行代碼改進
// 整數加法函數入口
function intAdd(a = '0', b = '0') {
const statusObj = checkNumber(a, b)
if (!statusObj.status) {
return statusObj.data
} else {
let newA, newB, maxLen
const tagA = Number(a) < 0, tagB = Number(b) < 0
const strA = `${a}`, strB = `${b}`
const reg = /^\-?(\d+)(\.\d+)?e\+(\d+)$/
if(reg.test(a) || reg.test(b)) {
console.warn('因爲存在科學計數法,計算結果不必定準確,請轉化成字符串後計算')
a = strA.replace(reg, function(...rest){
const str = rest[2] ? rest[1] + rest[2].slice(1) : rest[1]
return str.padEnd(Number(rest[3]) + 1, '0')
})
b = strB.replace(reg, function(...rest){
const str = rest[2] ? rest[1] + rest[2].slice(1) : rest[1]
return str.padEnd(Number(rest[3]) + 1, '0')
})
maxLen = Math.max(a.length, b.length)
} else {
const lenA = tagA ? strA.length - 1 : strA.length
const lenB = tagB ? strB.length - 1 : strB.length
maxLen = Math.max(lenA, lenB)
}
const padLen = Math.ceil(maxLen / intLen) * intLen // 即爲會用到的整個數組長度
newA = tagA ? `-${strA.slice(1).padStart(padLen, '0')}` : strA.padStart(padLen, '0')
newB = tagB ? `-${strB.slice(1).padStart(padLen, '0')}` : strB.padStart(padLen, '0')
let result = intCalc(newA, newB)
// 去掉正負數前面無心義的字符 ‘0’
const numberResult = Number(result)
if (numberResult > 0) {
while (result[0] === '0') {
result = result.slice(1)
}
} else if (numberResult < 0) {
while (result[1] === '0') {
result = '-' + result.slice(2)
}
} else {
result = '0'
}
console.log(result)
return result
}
}
複製代碼
繼續測試代碼
這一部分的代碼請看 這裏
// 警告:因爲存在科學計數法,計算結果不必定準確,請轉化成字符串後計算
intAdd(MAX, 10 ** 21) // '1000009007199254740991'
// 警告:因爲存在科學計數法,計算結果不必定準確,請轉化成字符串後計算
intAdd(MAX, 10 ** 21 + 2) // '1000009007199254740991'
intAdd(MAX, NaN) // 報錯:參數中不該該存在 NaN
intAdd(MAX, {}) // 報錯:參數中存在非法的數據,數據類型只支持 number 和 string
// 大數計算
intAdd('9037499254750994', '-9007299251310995') // '30200003439999'
intAdd('8107499231750996', '-9007299254310995') // '-899800022559999'
intAdd('-9907492547350994', '9007399254750995') // '-900093292599999'
intAdd('9997492547350994', '9997399254750995') // '19994891802101989'
intAdd('-9997492547350994', '-9997399254750995') // '-19994891802101989'
intAdd('-4707494254750996000004254750996', '9707494254750996007299232150995') // '5000000000000000007294977399999'
intAdd('-4707494254750996900004254750996', '9707494254750996007299232150995') // '4999999999999999107294977399999'
複製代碼
加法和減法同理,只須要把第二個參數取反後利用加法運算就能夠了,因爲以前已經提取了模板,能夠直接定義減法函數
// 整數減法函數入口
function intSub(a = '0', b = '0') {
const newA = `${a}`
const newB = Number(b) > 0 ? `-${b}`: `${b}`.slice(1)
const statusObj = checkNumber(newA, newB)
if (!statusObj.status) {
return statusObj.data
} else {
const result = IntAdd(newA, newB)
return result
}
}
複製代碼
測試結果
IntSub('9037499254750994', '-9007299251310995') // '18044798506061989'
IntSub('8107499231750996', '-9007299254310995') // '17114798486061991'
IntSub('-9907492547350994', '9007399254750995') // '-18914891802101989'
IntSub('9997492547350994', '9997399254750995') // '93292599999'
IntSub('-4707494254750996000004254750996', '9707494254750996007299232150995') // '-14414988509501992007303486901991'
IntSub('-4707494254750996900004254750996', '9707494254750996007299232150995') // '-14414988509501992907303486901991'
複製代碼
JavaScript中小數加減的坑是因爲浮點精度的計算問題,網上能查到不少相關的文章,可是筆者不打算從浮點計算入手。
既然以前已經解決了整數加減的問題,一樣能夠利用整數的加減原理來實現小數的計算。
整數加法代碼中常常出現 `padStart` 這個向前補齊的函數,由於在整數前加字符‘0’的對自己沒有影響。
小數也有這個原理,往尾部補‘0’一樣對小數沒有影響,而後再補齊後的數經過整數加減來計算。
複製代碼
基於整數加法的思想實現
// 小數加法函數入口
function floatAdd(a = '0', b = '0') {
const statusObj = checkNumber(a, b)
if (!statusObj.status) {
return statusObj.data
} else {
const strA = `${a}`.split('.'), strB = `${b}`.split('.')
let newA = strA[1], newB = strB[1]
const maxLen = Math.max(newA.length, newB.length)
const floatLen = Math.ceil(maxLen / intLen) * intLen
newA = newA.padEnd(floatLen, '0')
newB = newB.padEnd(floatLen, '0')
newA = strA[0][0] === '-' ? `-${newA}` : newA
newB = strB[0][0] === '-' ? `-${newB}` : newB
let result = intCalc(newA, newB)
let tag = true, numResult = Number(result)
// 去掉正負數後面無心義的字符 ‘0’
if (numResult !== 0) {
if (numResult < 0) {
result = result.slice(1)
tag = false
}
result = result.length === floatLen ? `0.${result}` : `1.${result.slice(1)}`
result = tag ? result : `-${result}`
let index = result.length - 1
while (result[index] === '0') {
result = result.slice(0, -1)
index--
}
} else {
result = '0'
}
console.log(result)
return result
}
}
複製代碼
測試結果
這一部分的代碼請看 這裏
floatAdd('0.9037499254750994', '-0.9007299251310995') // '0.0030200003439999'
floatAdd('0.8107499231750996', '-0.9007299254310995') // '-0.0899800022559999'
floatAdd('-0.9907492547350994', '0.9007399254750995') // '-0.0900093292599999'
floatAdd('0.9997492547350994', '0.9997399254750995') // '1.9994891802101989'
floatAdd('-0.9997492547350994', '-0.9997399254750995') // '-1.9994891802101989'
floatAdd('-0.4707494254750996000004254750996', '0.9707494254750996007299232150995') // '0.5000000000000000007294977399999'
floatAdd('-0.4707494254750996900004254750996', '0.9707494254750996007299232150995') // '0.4999999999999999107294977399999'
複製代碼
與整數減法的原理相同,能夠直接定義減法函數
// 小數減法函數入口
function floatSub(a = '0', b = '0') {
const newA = `${a}`
const newB = Number(b) > 0 ? `-${b}`: `${b.slice(1)}`
const statusObj = checkNumber(newA, newB)
if (!statusObj.status) {
return statusObj.data
} else {
const result = floatAdd(newA, newB)
return result
}
}
複製代碼
測試結果
以上部分的代碼請看 這裏
floatSub('0.9037499254750994', '-0.9007299251310995') // '1.8044798506061989'
floatSub('0.8107499231750996', '-0.9007299254310995') // '1.7114798486061991'
floatSub('-0.9907492547350994', '0.9007399254750995') // '-1.8914891802101989'
floatSub('0.9997492547350994', '0.9997399254750995') // '0.0000093292599999'
floatSub('-0.9997492547350994', '-0.9997399254750995') // '-0.0000093292599999'
floatSub('-0.4707494254750996000004254750996', '0.9707494254750996007299232150995') // '-1.4414988509501992007303486901991'
floatSub('-0.4707494254750996900004254750996', '0.9707494254750996007299232150995') // '-1.4414988509501992907303486901991'
複製代碼
因爲在實際中遇到的數字不少狀況是整數加小數的,下面開始分析
這裏的解決思路仍然是往前補0和日後補0
把整數和小數都補充完整後,合在一塊兒進行整數相加
最後根據以前保存的整數的長度,插入小數點
剩下的就是把無心義的0排除掉,輸出結果
複製代碼
這裏在遇到一方沒有小數的時候
// 任意數加法函數入口
function allAdd(a = '0', b = '0') {
const statusObj = checkNumber(a, b)
if (!statusObj.status) {
return statusObj.data
} else {
const strA = `${a}`.split('.'), strB = `${b}`.split('.')
let intAs = strA[0], floatA = strA.length === 1 ? '0' : strA[1]
let intBs = strB[0], floatB = strB.length === 1 ? '0' : strB[1]
const tagA = intAs > 0, tagB = intBs > 0
const maxIntLen = Math.max(intAs.length, intBs.length)
const arrIntLen = Math.ceil(maxIntLen / intLen) * intLen
const maxFloatLen = Math.max(floatA.length, floatB.length)
const arrFloatLen = Math.ceil(maxFloatLen / intLen) * intLen
intAs = tagA ? intAs.padStart(arrIntLen, '0') : intAs.slice(1).padStart(arrIntLen, '0')
intBs = tagB ? intBs.padStart(arrIntLen, '0') : intBs.slice(1).padStart(arrIntLen, '0')
let newA = floatA === '0' ? intAs + '0'.padEnd(arrFloatLen, '0') : intAs + floatA.padEnd(arrFloatLen, '0')
let newB = floatB === '0' ? intBs + '0'.padEnd(arrFloatLen, '0') : intBs + floatB.padEnd(arrFloatLen, '0')
newA = tagA ? newA : `-${newA}`
newB = tagB ? newB : `-${newB}`
let result = intCalc(newA, newB)
const numResult = Number(result)
if (result.length > arrIntLen) {
result = result.slice(0, -arrFloatLen) + '.' + result.slice(-arrFloatLen)
}
// 去掉正負數前面後面無心義的字符 ‘0’
if (numResult !== 0) {
if (numResult > 0) {
while (result[0] === '0') {
result = result.slice(1)
}
} else if (numResult < 0) {
while (result[1] === '0') {
result = '-' + result.slice(2)
}
result = result.slice(1)
tag = false
}
let index = result.length - 1
while (result[index] === '0') {
result = result.slice(0, -1)
index--
}
} else {
result = '0'
}
if (result[result.length - 1] === '.') {
result = result.slice(0, -1)
}
if (result[0] === '.') {
result = '0' + result
}
console.log(result)
return result
}
}
// 任意數減法函數入口
function allSub(a = '0', b = '0') {
const newA = `${a}`
const newB = Number(b) > 0 ? `-${b}`: `${b}`.slice(1)
const statusObj = checkNumber(newA, newB)
if (!statusObj.status) {
return statusObj.data
} else {
const result = allAdd(newA, newB)
return result
}
}
複製代碼
測試結果
以上部分的代碼請看 這裏
// 30200003439999.0030200003439999
allAdd('9037499254750994.9037499254750994', '-9007299251310995.9007299251310995')
// 5000000000000000007294977399998.9100199977440001
allAdd('9707494254750996007299232150995.8107499231750996', '-4707494254750996000004254750996.9007299254310995')
// 19994891802101990.9994891802101989
allAdd('9997492547350994.9997492547350994', '9997399254750995.9997399254750995')
// 30200003439999.0030200003439999
allSub('9037499254750994.9037499254750994', '9007299251310995.9007299251310995')
// 18044798506061990.8044798506061989
allSub('9037499254750994.9037499254750994', '-9007299251310995.9007299251310995')
// 17144998486501991.714499848650199
allSub('8107499231750996.8107499231750996', '-9037499254750994.9037499254750994')
複製代碼
本文篇幅太長,因此代碼部分沒有細說(全在註釋)
主要分析瞭解決問題的整個思路,抓住幾個重點理解
- 1.Number.MAX_SAFE_INTEGER 和 Number.MIN_SAFE_INTEGER 之間的計算纔是可信任的
- 2.小數加減的浮點精度問題轉移到整數來解決
- 3.超大的數加減的時候,分區計算(理由是第1點)
- 4.拆分紅每部分15長度的字符串(理由是Number.MAX_SAFE_INTEGER的長度爲16,不管如何加減都是知足第一點的,這樣就不須要去注意加減的安全性問題了)
- 5.科學計數法的問題,匹配是否爲科學計數法的數,而後轉換成十進制,同時提出警告,由於科學計數法的數存在偏差,計算會存在不許確性
代碼有不少地方能夠優化,完成的比較潦草(輕噴)
各位大佬有修改意見的 歡迎提出
感謝觀看