[前端漫談] 做一個四則計算器

0x000 概述

近期重新開始學習計算機基礎方面的東西,比如計算機組成原理網絡原理編譯原理之類的東西,目前正好在學習編譯原理,開始對這一塊的東西感興趣,但是理論的學習有點枯燥無味,決定換種方式,那就是先實踐、遇到問題嘗試解決,用實踐推動理論。原本打算寫個中文JS解析的,但是好像有點難,先就找個簡單的來做吧,那就是解析一下四則運算,就有了這個項目,聲明:這是一個很簡單的項目,這是一個很簡單的項目,這是一個很簡單的項目。其中用到的詞法分析、語法分析、自動機都是用簡單的方式實現,畢竟比較菜。

0x001 效果

  • 源碼地址:github

  • 實現功能:

    • 任意順序的四則+-*/正整數運算
    • 支持()
    • 前端后端通用
    • 提供直接計算函數
    • 提供四則運算表達式轉逆波蘭AST函數
    • 提供語法分析函數(暫時只支持上下兩個字符判定)
  • 效果演示:

0x002 實現

既然說很簡單,那不管用到的理論和實現的方式都一定要都很簡單,實現這個效果一共需要克服三個問題:

  1. 如何實現優先級計算,比如*/()的優先級大于+-
  2. 如何分割字符串,比如如何識別數字、符號和錯誤字符,也就是詞素化。
  3. 如何實現語法檢測,也就是讓表達式的規則滿足要求,比如+后面比如跟隨數字或者((這里將-當作操作,而不是符號)。

0x003 解決問題1: 如何實現優先級運算

1. 暫時忽略優先級

如果沒有優先級問題,那實現一個計算十分的簡單,比如下面的代碼可以實現一個簡單的加減或者乘除計算(10以內,超過一位數會遇到問題2,這里先簡單一點,避過問題2):

        let calc = (input) => {let calMap = {'+': (num1, num2) => num1 + num2,'-': (num1, num2) => num1 - num2,'*': (num1, num2) => num1 * num2,'/': (num1, num2) => num1 / num2,}input = [...input].reverse()while (input.length >= 2) {let num1 = +input.pop()let op = input.pop()let num2 = +input.pop()input.push(calMap[op](num1, num2))}return input[0]}expect(calc('1+2+3+4+5-1')).toEqual(14)expect(calc('1*2*3/3')).toEqual(2)
復制代碼

算法步驟:

  • 將輸入打散成一個棧,因為是10以內的,所以每個數只有一位:
    input = [...input].reverse()
    復制代碼
  • 每次取出三位,如果是正確的輸入,則取出的三位,第一位是數字,第二位是操作符,第三位是數字:
    let num1 = +input.pop()
    let op = input.pop()
    let num2 = +input.pop()
    復制代碼
  • 根據操作符做運算后將結果推回棧中,又形成了這么一個流程,一直到最后棧中只剩下一個數,或者說每次都要取出3個數,所以如果棧深度<=2,那就是最后的結果了:
    while (input.length >= 2) {// ......input.push(calMap[op](num1, num2))
    }
    復制代碼

動畫演示:

2. 考慮優先級

但是現在需要考慮優先級,比如*/的優先級大于+-()的運算符最高,那如何解決呢,其實都已經有解決方案了,我用的是后綴表達式,也叫逆波蘭式

  • 后綴表達式: 所謂的后綴表達式,就是將操作符放在表達式的最后邊,比如1+1表示成11+
  • 中綴表達式: 所謂的中綴表達式,其實就是我們平常使用的寫法了,這里不做深入。
  • 前綴表達式 所謂的后綴表達式,就是將操作符放在表達式的最前邊,比如1+1表示成+11,這里不做深入

逆波蘭式可以參考下列文章

  • Wiki-逆波蘭表示法
  • 知乎-什么是逆波蘭表達式

3. 逆波蘭式解決優先級問題

在逆波蘭式子中,1+1*2可以轉化為112*+ 代碼演示:

 let calc = (input) => {let calMap = {'+': (num1, num2) => num1 + num2,'-': (num1, num2) => num1 - num2,'*': (num1, num2) => num1 * num2,'/': (num1, num2) => num1 / num2,}input = [...input].reverse()let resultStack = []while (input.length) {let token = input.pop()if (/[0-9]/.test(token)) {resultStack.push(token)continue}if (/[+\-*/]/.test(token)) {let num1 = +resultStack.pop()let num2 = +resultStack.pop()resultStack.push(calMap[token](num1, num2))continue}}return resultStack[0]
}
expect(calc('123*+')).toEqual(7)
復制代碼

轉化之后計算步驟如下:

  1. 初始化一個棧
        let resultStack = []
    復制代碼
  2. 每次從表達式中取出一位
    let token = input.pop()
    復制代碼
  3. 如果是數字,則推入棧中
    if (/[0-9]/.test(token)) {resultStack.push(token)continue
    }
    復制代碼
  4. 如果是操作符,則從棧中取出兩個數,做相應的運算,再將結果推入棧中
    if (/[+\-*/]/.test(token)) {let num1 = +resultStack.pop()let num2 = +resultStack.pop()resultStack.push(calMap[token](num1, num2))continue
    }
    復制代碼
  5. 如果表達式不為空,進入步驟2,如果表達式空了,棧中的數就是最后的結果,計算完成
    while (input.length) {// ...
    }
    return resultStack[0]
    復制代碼

動畫演示:

轉化成逆波蘭式之后有兩個優點:

  • 不關心運算符優先級
  • 去除括號,比如(1+2)*(3+4),可以轉化為12+34+*,按照逆波蘭式運算方法即可完成運算

4. 中綴轉后綴

這是問題1的最后一個小問題了,這個問題的實現過程如下:

let parse = (input) => {input = [...input].reverse()let resultStack = [], opStack = []while (input.length) {let token = input.pop()if (/[0-9]/.test(token)) {resultStack.push(token)continue}if (/[+\-*/]/.test(token)) {opStack.push(token)continue}}return [...resultStack, ...opStack.reverse()].join('')}expect(parse(`1+2-3+4-5`)).toEqual('12+3-4+5-')
復制代碼

準備兩個棧,一個棧存放結果,一個棧存放操作符,最后將兩個棧拼接起來上面的實現可以將1+2-3+4-5轉化為12+3-4+5-,但是如果涉及到優先級,就無能為力了,例如

        expect(parse(`1+2*3`)).toEqual('123*+')
復制代碼

1+2*3的轉化結果應該是123*+,但其實轉化的結果卻是123+**/的優先級高于+,所以,應該做如下修改

 let parse = (input) => {input = [...input].reverse()let resultStack = [], opStack = []while (input.length) {let token = input.pop()if (/[0-9]/.test(token)) {resultStack.push(token)continue}
//                if (/[+\-*/]/.test(token)) {
//                    opStack.push(token)
//                    continue
//                }if (/[*/]/.test(token)) {while (opStack.length) {let preOp = opStack.pop()if (/[+\-]/.test(preOp)) {opStack.push(preOp)opStack.push(token)token = nullbreak} else {resultStack.push(preOp)continue}}token && opStack.push(token)continue}if (/[+\-]/.test(token)) {while (opStack.length) {resultStack.push(opStack.pop())}opStack.push(token)continue}}return [...resultStack, ...opStack.reverse()].join('')}expect(parse(`1+2`)).toEqual('12+')expect(parse(`1+2*3`)).toEqual('123*+')
復制代碼
  1. 當操作符為*/的時候,取出棧頂元素,判斷棧中的元素的優先級低是否低于*/,如果是就直接將操作符推入opStack,然后退出,否則一直將棧中取出的元素推入resultStack
if (/[+\-]/.test(preOp)) {opStack.push(preOp)// 這里用了棧來做判斷,所以判斷完還得還回去...opStack.push(token)token = nullbreak
}else {resultStack.push(preOp)continue
}
復制代碼
  1. 還要注意棧空掉的情況,需要將操作符直接入棧。
    token && opStack.push(token)continue
復制代碼
  1. 當操作符為+-的時候,因為已經是最低的優先級了,所以直接將所有的操作符出棧就行了
if (/[+\-]/.test(token)) {while (opStack.length) {resultStack.push(opStack.pop())}opStack.push(token)continue
}
復制代碼

到這里已經解決了+-*/的優先級問題,只剩下()的優先級問題了,他的優先級是最高的,所以這里做如下修改即可:

if (/[+\-]/.test(token)) {while (opStack.length) {let op=opStack.pop()if (/\(/.test(op)){opStack.push(op)break}resultStack.push(op)}opStack.push(token)continue
}
if (/\(/.test(token)) {opStack.push(token)continue
}
if (/\)/.test(token)) {let preOp = opStack.pop()while (preOp !== '('&&opStack.length) {resultStack.push(preOp)preOp = opStack.pop()}continue
}
復制代碼
  1. 當操作符是+-的時候,不再無腦彈出,如果是(就不彈出了
while (opStack.length) {let op=opStack.pop()if (/\(/.test(op)){opStack.push(op)break}resultStack.push(op)}opStack.push(token)
復制代碼
  1. 當操作符是(的時候,就推入opStack
if (/\(/.test(token)) {opStack.push(token)continue
}
復制代碼
  1. 當操作符是)的時候,就持續彈出opStackresultStack,直到遇到((不推入resultStack
if (/\)/.test(token)) {let preOp = opStack.pop()while (preOp !== '('&&opStack.length) {resultStack.push(preOp)preOp = opStack.pop()}continue
}
復制代碼

動畫示例:

如此,就完成了中綴轉后綴了,那么整個問題1就已經被解決了,通過calc(parse(input))就能完成中綴=>后綴=>計算的整個流程了。

0x004 解決問題2:分割字符串

雖然上面已經解決了中綴=>后綴=>計算的大問題,但是最基礎的問題還沒解決,那就是輸入問題,在上面問題1的解決過程中,輸入不過是簡單的切割,而且還局限在10以內。而接下來,要解決的就是這個輸入的問題,如何分割輸入,達到要求?

  • 解決方式1:正則,雖然正則可以做到如下,做個簡單的demo還是可以的,但是對于之后的語法檢測之類的東西不太有利,所以不太好,我放棄了這種方法
    (1+22)*(333+4444)`.match(/([0-9]+)|([+\-*/])|(\()|(\))/g)
    // 輸出
    // (11)["(", "1", "+", "22", ")", "*", "(", "333", "+", "4444", ")"]
    復制代碼
  • 解決方法2:逐個字符分析,其大概的流程是
    while(input.length){let token = input.pop()if(/[0-9]/.test(token)) // 進入數字分析if(/[+\-*/\(\)]/.test(token))// 進入符號分析
    }
    復制代碼

接下來試用解決方案2來解決這個問題:

1 定義節點結構

當我們分割的時候,并不單純保存值,而是將每個節點保存成一個相似的結構,這個結構可以使用對象表示:

{type:'',value:''
}
復制代碼

其中,type是節點類型,可以將四則運算中所有可能出現的類型歸納出來,我的歸納如下:

    TYPE_NUMBER: 'TYPE_NUMBER', // 數字TYPE_LEFT_BRACKET: 'TYPE_LEFT_BRACKET', // (TYPE_RIGHT_BRACKET: 'TYPE_RIGHT_BRACKET', // )TYPE_OPERATION_ADD: 'TYPE_OPERATION_ADD', // +TYPE_OPERATION_SUB: 'TYPE_OPERATION_SUB', // -TYPE_OPERATION_MUL: 'TYPE_OPERATION_MUL', // *TYPE_OPERATION_DIV: 'TYPE_OPERATION_DIV', // /
復制代碼

value則是對應的真實值,比如123+-*/

2 數字處理

如果是數字,則繼續往下讀,直到不是數字為止,將這過程中所有的讀取結果放到value中,最后入隊。

if (token.match(/[0-9]/)) {let next = tokens.pop()while (next !== undefined) {if (!next.match(/[0-9]/)) breaktoken += nextnext = tokens.pop()}result.push({type: type.TYPE_NUMBER,value: +token})token = next
}
復制代碼

3 符號處理

先定義一個符號和類型對照表,如果不在表中,說明是異常輸入,拋出異常,如果取到了,說明是正常輸入,入隊即可。

const opMap = {'(': type.TYPE_LEFT_BRACKET,')': type.TYPE_RIGHT_BRACKET,'+': type.TYPE_OPERATION_ADD,'-': type.TYPE_OPERATION_SUB,'*': type.TYPE_OPERATION_MUL,'/': type.TYPE_OPERATION_DIV
}
let type = opMap[token]
if (!type) throw `error input: ${token}`
result.push({type,value: token,
})
復制代碼

4 總結

這樣就完成了輸入的處理,這時候,其他的函數也需要處理一下,應為輸入已經從字符串變成了tokenize之后的序列了,修改完成之后就是可以calc(parse(tokenize()))完成一整套騷操作了。

0x005 解決問題3:語法檢測

語法檢測要解決的問題其實就是判斷輸入的正確性,是否滿足四則運算的規則,這里用了類似狀機的思想,不過簡單到爆炸,并且只能做單步判定~~ 定義一個語法表,該表定義了一個節點后面可以出現的節點類型,比如,+后面只能出現數字或者(之類。

let syntax = {[type.TYPE_NUMBER]: [type.TYPE_OPERATION_ADD,type.TYPE_OPERATION_SUB,type.TYPE_OPERATION_MUL,type.TYPE_OPERATION_DIV,type.TYPE_RIGHT_BRACKET],[type.TYPE_OPERATION_ADD]: [type.TYPE_NUMBER,type.TYPE_LEFT_BRACKET],//...
}
復制代碼

這樣我們就可以簡單的使用下面的語法判定方法了:

 while (tokens.length) {// ...let next = tokens.pop()if (!syntax[token.type].includes(next.type)) throw `syntax error: ${token.value} -> ${next.value}`// ...}
復制代碼

對于(),這里使用的是引用計數,如果是(,則計數+1,如果是),則計數-1,檢測到最后的時候判定一下計數就好了:

    // ...if (token.type === type.TYPE_LEFT_BRACKET) {bracketCount++}// ...if (next.type === type.TYPE_RIGHT_BRACKET) {bracketCount--}// ...if (bracketCount < 0) {throw `syntax error: toooooo much ) -> )`}// ...
復制代碼

0x006 總結

  • 該文章存在一些問題:
    1. 我推導不出為啥要用逆波蘭式,只是知道有這么一個解決方案,拿過來用而已,而不是由問題推導出解決方案。
    2. 文字功底不夠,講的不夠 cool。
  • 該實現也存在一些問題:
    1. 并非完全用編譯原理的思想去實現,而是自己摸解決方案,先實踐,后了解問題。
    2. 并沒有參考太多別人的實現,有點閉門造車的感覺。
  • 思考:
    1. 對于()的處理或許可以使用遞歸的方式,進入()之后重新開始一個新的表達式解析
    2. 思考不夠全,單元測試覆蓋不夠,許多坑還不知道在哪兒

總之:文章到此為止,有很多不夠詳細的地方還請見諒,多多交流,共同成長。

0x007 資源

  • 編譯原理課程
  • 源碼
  • 動畫制作軟件Principle

轉載于:https://juejin.im/post/5c2c7ca56fb9a04a0d56f60b

本文來自互聯網用戶投稿,該文觀點僅代表作者本人,不代表本站立場。本站僅提供信息存儲空間服務,不擁有所有權,不承擔相關法律責任。
如若轉載,請注明出處:http://www.pswp.cn/news/537226.shtml
繁體地址,請注明出處:http://hk.pswp.cn/news/537226.shtml
英文地址,請注明出處:http://en.pswp.cn/news/537226.shtml

如若內容造成侵權/違法違規/事實不符,請聯系多彩編程網進行投訴反饋email:809451989@qq.com,一經查實,立即刪除!

相關文章

程序員筆試面試后上機_hcie面試有哪些要注意的事項?

大家都知道&#xff0c;華為認證hcie考試分為三個部分&#xff0c;分別是筆試、lab實驗和面試。其中&#xff0c;考生討論得最多的就是面試部分&#xff0c;因為面試不同于筆試和lab實驗&#xff0c;自己埋頭答題和操作就行&#xff0c;面試要面對考官&#xff0c;考核的東西非…

【Infragistics教程】在javascript構造函數中創建基本繼承

2019獨角獸企業重金招聘Python工程師標準>>> 【下載Infragistics Ultimate最新版本】 用javascript創建對象有四種方法。具體如下&#xff1a; 對象作為文本構造函數調用模式創建&#xff08;&#xff09;方法在ES6之后使用類繼承的實現因對象創建方法而異。本文將解…

python爬蟲ssl錯誤_Python爬蟲:Requests的SSLError:certificate verify failed問題解決方案6條...

問題&#xff1a;腳本是用Python寫的&#xff0c;用到開源庫play-scraper&#xff0c;調用其collectionAPI來獲取Google Play的Top App列表。該庫使用了requests作為客戶端來對Google Play進行操作。當腳本執行時&#xff0c;會報如下錯誤&#xff1a;certificate verify faile…

2019年1月3日

數組 字面量創建數組 1. var arr[]; []里邊可以放數字&#xff0c;字符串&#xff0c;true&#xff0c;false&#xff0c;null&#xff0c;undefined&#xff0c;數組&#xff08;[1,2,3]&#xff09;&#xff0c;對象{x&#xff1a;1&#xff0c;y&#xff1a;2} var arr[1,2…

vertex 3.0 與SpringBoot混合開發之初探

SpringBoot是最近幾年比較流行的web應用開發框架&#xff0c;它是微服務的一個開發框架。它的Web服務器內核為Tomcat或Jetty&#xff0c;它們作為Servlet容量來對客戶端的http/https請求進行解析。最近&#xff0c;spring.io又出推出一套新的服務器內核框架&#xff0c;它就是W…

switch芯片和phy芯片的區別_感應式芯片卡CPU卡的FM1208-9和FM1208-10有什么區別,你知道嗎?...

感應式CPU卡是目前芯片卡中安全系統較高的芯片&#xff0c;使用范圍也較為廣泛&#xff0c;但是這款CPU分為FM1208-9和FM1208-10&#xff0c;那你們知道分別代表什么意思呢&#xff1f;他們之間有什么不同呢&#xff1f;CPU白卡FM是什么&#xff1f;首先&#xff0c;我們來說下…

每次登陸都要滑動驗證_湖人隊冠軍成員卡魯索很吃香:每次談判都有N支球隊點名要他...

10月24日NBA直播臺訊&#xff1a;洛杉磯湖人隊助理教練邁克-彭伯西在接受媒體采訪時透露&#xff0c;湖人隊替補控球后衛卡魯索目前在聯盟中很吃香。湖人隊每次進行交易談判時&#xff0c;對方球隊都點名想要卡魯索。彭伯西表示&#xff1a;“每一次我們在休賽期或者交易截止日…

[HAOI2015]按位或

樸素的 f[S]表示S到(1<<n)的期望次數 發現1的個數只增加不減少 所以可以類似拓撲序的圖&#xff0c;然后枚舉子集O(3^n)轉移 沒有優化的余地 另辟蹊徑&#xff1a; 拆開每一位來看 t[i]表示第i位變成1的次數 ansE(max(t[i])) 根據min-max容斥 得到&#xff1a;ans∑E(t[i…

MySQL在DOS指令里面的使用以及增刪改查的使用

本人的第一條博客&#xff0c;選中我的電腦單機右鍵&#xff0c;點開管理&#xff0c;選中服務找到MySQL57.啟動該服務。回退至桌面&#xff0c;按住winR 輸入cmd打開DOS指令的窗口。 在窗口輸入: mysql -h localhost -u root -p 顯示password輸入提示&#xff1a;表示已經…

node+socket.io 實現一個聊天室

我們只做簡單的實現&#xff0c;不接入數據庫&#xff0c;nodejs也不使用express和koa等框架 因此依賴只有兩個&#xff1a; 1、socket.io 2、mime&#xff08;用于獲取靜態資源時獲取文件的mime類型&#xff09; 安裝命令&#xff1a; npm install socket.io mime --save 其他…

安卓應用用戶數據_用戶指標數據應用

一、如何理解數據用戶數據&#xff1a;gender:性別、 birthday:出生日期行為數據&#xff1a;user_id:用戶id、auction_id:購買行為編號、buy_mount:購買數量、day:購買時間商品數據&#xff1a;cat_id:商品種類ID、cat1:商品類別、property:商品屬性二、用戶數據指標1.用戶數據…

三大數據庫數據庫端口號及連接jdbc驅動下載

Jdbc連接三大數據庫&#xff08;mysql sqlserver oracle&#xff09; Mysql:端口號為&#xff1a;3306&#xff08;默認&#xff09; 用java連接mysql數據庫 Try{Class.forName(“com.mysql.jdbc.Driver”); //DatabaseName:需要連接的數據庫名稱 String url”jdbc:mysql://12…

webgis從基礎到開發實踐_開源WebGIS教程系列——11.1 GISLite 的開發背景與設計

地理信息門戶可以幫助人們更容易地發現、訪問和使用地理空間信息&#xff0c; 是地理信息發布、服務和共享的重要環節。許多國家都很重視地理信息門戶的 建設&#xff0c;把它作為國家空間數據基礎設施(spatial data infrastructure&#xff0c;SDI)的重要組成部分。GISLite 是…

Oracle數據庫及在DOS命令下面的簡單操作

在Oracle數據庫注釋用--表明為注釋&#xff0c;但以下用//或--代表解釋;數據庫不怎么區分大小寫&#xff1b; 先說說一些簡單Oracle數據庫操作的語句&#xff1a; 使用語句創建普通用戶&#xff1a; Create user username identified by password; //創建普通用戶 Grant reso…

CSS屬性(display)

1.display屬性 <!DOCTYPE html> <html lang"zh-CN"> <head><meta charset"UTF-8"><title>08display屬性</title><style>.c1 {background-color: red;/*display: none; !* 讓其在頁面上不顯示 *!*//*display: i…

產品發布系統_【產品發布】第3期|閥門遙控系統

更多精彩&#xff0c;請點擊上方藍字關注我們&#xff01;常熟瑞特電氣股份有限公司的閥門遙控系統是一款經典的產品線&#xff0c;包括了全系列的液壓執行器&#xff0c;電液執行器&#xff0c;微型動力單元&#xff0c;液壓動力泵站&#xff0c;液壓電磁閥箱等產品。閥門遙控…

大數據就業前景,分析的太到位了

大數據廣泛應用于電網運行、經營管理及優質服務等各大領域&#xff0c;并正在改變著各行各業&#xff0c;也引領了大數據人才的變革。大數據就業前景怎么樣&#xff1f;這對于在就業迷途中的我們是一個很重要的信息。 隨著大數據時代的到來【這次國家教育部也改革動真格了】&am…

常用集合(List,Set,Map)的基本定義和操作

集合類存放于java.util包中。 集合類存放的都是對象的引用&#xff0c;而非對象本身&#xff0c;出于表達上的便利&#xff0c;我們稱集合中的對象就是指集合中對象的引用&#xff08;reference)。 常用的集合類型主要有3種&#xff1a;set(集&#xff09;、list(列表&#x…

多麥克風做拾音的波束_麥克風丨人聲應該用動圈話筒還是電容話筒?

無論是在您最喜歡的樂隊的紀錄片中&#xff0c;還是在電影中那些有關錄音棚里的場景中&#xff0c;似乎都存在著一個共同的主題&#xff0c;那就是&#xff1a;歌手們都在使用大振膜的電容麥克風進行錄音。我知道人們應該從別人的經驗中汲取精華&#xff0c;事半功倍。但是我并…

MYSQL安裝與庫的基本操作

mysql數據庫 什么是數據庫 # 用來存儲數據的倉庫 # 數據庫可以在硬盤及內存中存儲數據 數據庫與文件存儲數據區別 數據庫本質也是通過文件來存儲數據, 數據庫的概念就是系統的管理存儲數據的文件 數據庫介紹 數據庫服務器端: 存放數據的主機集群數據庫端: 可以連接數據庫的任意…