做業描述 | 連接 |
---|---|
這個做業屬於哪一個課程 | https://edu.cnblogs.com/campus/fzu/SoftwareEngineering1916W |
這個做業要求在哪裏 | https://edu.cnblogs.com/campus/fzu/SoftwareEngineering1916W/homework/2688 |
結對學號 | 22160013一、221600439 |
做業目標 | 實現一個可以對文本文件中的單詞的詞頻進行統計的控制檯程序。 |
基礎需求:https://github.com/temporaryforfzuse/PairProject1-C
進階需求:https://github.com/temporaryforfzuse/PairProject2-Cjavascript
221600131:WordCount基礎、測試數據構造、爬蟲、附加題
221600439:WordCount主體
html
PSP2.1 | Personal Software Process Stages | 預估耗時(分鐘) | 實際耗時(分鐘) |
---|---|---|---|
Planning | 計劃 | ||
- Estimate | 估計這個任務須要多少時間 | 5 | 5 |
Development | 開發 | ||
- Analysis | 需求分析 (包括學習新技術) | 30(學習新技術被計入具體編碼部分) | 240 |
- Design Spec | 生成設計文檔 | 10 | 10 |
- Design Review | 設計複審 | 10 | 10 |
- Coding Standard | 代碼規範 (爲目前的開發制定合適的規範) | 0 | |
- Design | 具體設計 | 10 | |
- Coding | 具體編碼 | 240 | 660 |
- Code Review | 代碼複審 | 貫穿代碼開發過程,不做爲單獨流程 | 0 |
- Test | 測試(自我測試,修改代碼,提交修改) | 貫穿代碼開發過程,不做爲單獨流程 | 0 |
Reporting | 報告 | 30 | 30 |
- Test Report | 測試報告 | 30 | 30 |
- Size Measurement | 計算工做量 | 5 | 5 |
- Postmortem & Process Improvement Plan | 過後總結, 並提出過程改進計劃 | 30 | 30 |
合計 | 370 | 1020 |
如截圖。因不明緣由助教只使用Windows評測C++,必須使用遠程桌面開發。此處即爲計時。能夠注意到,由於需求嚴重不明確,自己週末就搞定了的項目,不得不在工做日進行大量修改。前端
「時間總能擠在重寫上的。」java
結對自己不存在困難,合做很是愉快。
221600131 被評價爲:思惟活躍、創新能力強,學習熱情高,很是認真。熟練掌握 Python 語言,擅長數據挖掘。
221600439 被評價爲:代碼能力強,工程能力強,有較強的Bug查找能力。python
需求極度不明確。git
硬寫啊。github
劃分一個DLL和一個MainProject。考慮到從此可能會被其餘語言調用,暴露出的接口必須爲C式,那麼就不能以C++ STL結構做爲輸入或輸出,必須本身構造struct。同時須要考慮內存回收,誰初始化的內存,誰負責清理。正則表達式
考慮做業需求,只須要讀一次就夠了,具體行爲由DLL內部自行處理,返回數據的處理讓外部調用者來作。所以,DLL內暴露2個API:算法
extern "C" __declspec(dllexport) WordCountResult CalculateWordCount(const char * fileName); extern "C" __declspec(dllexport) void ClearWordAppear(WordCountResult * resultStruct);
沒什麼好思考的,一個大循環就寫完了……僅需一個函數,一百行不到的算法就能解決的事情,硬要把它拆成三四個部分徹底是over design。數組
本算法時間複雜度確定是O(n)(n爲字符數量)的,其中使用的HashMap讀取的時間複雜度爲O(1),排序算法時間複雜度爲O(nlgn)(n爲單詞數量)。慢應當慢在I/O上和STL上。
初步實現是直接逐字節讀文件。如下爲對4400萬的規律數據進行測試,用時4.296秒。
這種作法可能較慢,由於I/O次數較大。性能優化方法是把文件讀到內存Buffer裏,再從Buffer裏逐字節取出。將其改成stringstream
後,優化至3秒。
觀察性能得知,最慢的代碼在此處。相信只要棄用stringstream
而直接從內存數組取數據,性能就能更高。同時因std::map
使用紅黑樹而非HashMap,更換爲HashMap還能夠更加優化性能。
換成C式讀寫,並將std::map
換成std::unordered_map
後,僅需0.3秒。此處性能低在:單詞出現次數排序上、HashMap的增查、內存比較,基本可認爲無繼續優化的必要。
接下來還須要優化的話,重點在於內存佔用上。目前因文件是一次讀入,且須要在內存內記錄全部單詞,致使可能須要3倍於文件大小的內存,所以大文件也須要編譯爲64位纔可處理。對此可增長I/O,例如一次只讀入100M文件。至於內存中的單詞計數,暫時尚未比較好的解決方案。
當前對一個大約760M的文本文件進行了測試,4字節長的單詞約有70萬個,耗時37秒。
測試數據以及腳本:https://files.cnblogs.com/files/aaaaaaaaaaaaaa/%E7%BB%93%E5%AF%B92%E6%B5%8B%E8%AF%95%E6%95%B0%E6%8D%AE.rar
我認爲,一個合理的測試方式是:
.travis.yml
或appveyor.yml
,自動構建並自動測試。但這次做業徹底沒有提到CI的重要性,且不容許一併提交測試數據。 我直接使用腳原本處理而非而非使用VS的單元測試工程,緣由即在於不容許提交測試工程。另外,我我的認爲,一個好的測試,如非必要,不該與外界環境耦合。這一點個人測試並不佳。與其說它是單元測試,更應該說它是迴歸測試 。
代碼覆蓋率測試僅 Visual Studio Enterprise 有,免費的 Community 無。因爲我平常並不進行Windows開發,再也不花費時間找各種工具/破解版。
const testCaseDir = 'cases-1/' const testCaseCount = 11 const cp = require('child_process') const fs = require('fs') const path = require('path') for (let i = 1; i <= testCaseCount; i++) { const randomFileName = (Math.random() * 10000000000 + new Date().getTime()).toString(16) const inFileName = path.resolve(__dirname, `${testCaseDir}input${i}.txt`) const outFileName = path.resolve(__dirname, `${testCaseDir}result${i}.txt`) const stdout = cp.execSync(`"D:\\Projects\\Homework\\fzuse-hw3\\221600131\&221600439\\src\\x64\\Release\\WordCount.exe" ${inFileName}`).toString('utf-8') if (fs.existsSync(outFileName)) { const fout = fs.readFileSync(outFileName, 'utf-8') if (stdout.trim() !== fout.trim()) { console.log(`Failed at ${i}`) console.log(`=== Excepted ====`) console.log(fout) console.log(`=== Actual ====`) console.log(stdout) } else { console.log(`OK at ${i}`) } } else { fs.writeFileSync(outFileName, stdout, 'utf-8') } }
aaaa
是,0aaaa
不是,a0aa
不是,a|aa
不是,aaaa0
是。基本思想就是在各處if周邊試探,寫各類可能讓if出錯的edge case。把這些處理清楚,測試數據就寫完了。部分測試數據如圖。
配合註釋,基本作到代碼自解釋。
EXTERN WordCountResult CalculateWordCount(const char * fileName) { auto ret = WordCountResult(); bool runStateMachine = true; char c = 0; std::ifstream file(fileName); std::string word = ""; // 不想作動態分配內存,std::string省事 size_t wordLength = 0; // <= wordAtLeastCharacterCount,超過則再也不計數 bool isValidWordStart = true; bool hasNotBlankCharacter = false; auto map = std::unordered_map<std::string, size_t>(); FILE* f; if (fopen_s(&f, fileName, "rb") != 0) { ret.errorCode = WORDCOUNTRESULT_OPEN_FILE_FAILED; return ret; } fseek(f, 0, SEEK_END); long fileLength = ftell(f); fseek(f, 0, SEEK_SET); char * string = (char*)malloc(fileLength + 1); fread(string, fileLength, 1, f); fclose(f); string[fileLength] = 0; size_t currentPosition = 0; while (runStateMachine) { c = string[currentPosition]; if (currentPosition == fileLength) { runStateMachine = false; // 文件讀取結束,不當即退出,處理一下以前未整理乾淨的狀態 c = 0; } else { currentPosition++; if (c == '\r') continue; // Thanks God ret.characters++; } if (c >= 'A' && c <= 'Z') { c = c - 'A' + 'a'; } if (!isEmptyChar(c)) { hasNotBlankCharacter = true; } if (isCharacter(c)) { if (isLetter(c)) { // 判斷一下首幾個字母是否是字母,不是的話就不是單詞 if ((wordLength > 0 && wordLength < wordAtLeastCharacterCount) || (wordLength == 0 && isValidWordStart)) { if (isAlphabet(c)) { word += c; wordLength++; } else { isValidWordStart = false; word = ""; wordLength = 0; } } else { word += c; } } } if (!isLetter(c)) { if (wordLength >= wordAtLeastCharacterCount) { // 不是數字字母了,就多是個單詞的結束 if (map.find(word) == map.end()) { map[word] = 0; ret.uniqueWords++; } map[word]++; ret.words++; } word = ""; wordLength = 0; if (isSeparator(c)) { // 只有有分隔符分割的,纔是一個單詞的開始 isValidWordStart = true; } } if (isLf(c) || !runStateMachine) { if (hasNotBlankCharacter) { // 任何包含非空白字符的行,都須要統計。 ret.lines++; } hasNotBlankCharacter = false; } } auto sortedMap = std::vector<WordCountPair>(map.begin(), map.end()); std::sort(sortedMap.begin(), sortedMap.end(), [](const WordCountPair& lhs, const WordCountPair& rhs) noexcept { if (lhs.second == rhs.second) { return lhs.first < rhs.first; } return lhs.second > rhs.second; }); ret.wordAppears = new WordCountWordAppear[ret.uniqueWords]; size_t i = 0; for (auto &it : sortedMap) { ret.wordAppears[i].word = new char[it.first.length() + 1]; strcpy_s(ret.wordAppears[i].word, it.first.length() + 1, it.first.c_str()); ret.wordAppears[i].count = it.second; i++; } free(string); return ret; }
爬蟲選用的是python語言,由於請求庫和解析庫有不少並且方便。我這裏主要用的是Request請求庫和BeautifulSoup + lxml解析庫。因爲這部分只要求爬取title和abstract部分,因此首先分析前端html發現這兩個部分的div都有很明顯的id標誌,因此直接經過xpath定位到這兩個div取出text便可。第一遍常規套路爬一遍耗時超過十分鐘。
由於總共將近一千篇論文爬一遍耗時過久了,因此我使用多進程爬蟲以使性能獲得提高。咱們知道在python下多進程更好,由於每一個進程有獨立的GIL,互不干擾,能夠真正意義上實現並行執行。而python多線程下,每一個線程執行方式是獲取GIL,執行代碼直到sleep或是虛擬機將其掛起,最後釋放GIL。而每次釋放GIL後線程都會進行鎖的競爭,切換線程,從而形成資源的消耗。因此我這裏選擇用多進程爬蟲。
修改代碼後開到32進程再次測試,爬取一遍不用20秒。
先是命令行處理。這一點,直接用庫便可。我選用CLI11,避免重複造輪子。
這題更好的解法是正則表達式。應當用正則的理由以下:
我不用正則表達式的緣由以下:
既然不用正則表達式,那就直接一個狀態機解決了。詞法分析、語法分析、語義分析所有忽略,直接使用最簡單的狀態轉換算法,連詞法帶語義一塊兒處理。
至於找資料..找啥?
核心僅一個函數,畫類圖有點強人所難。狀態轉換圖以下:
和基礎相似,再也不贅述。
分 C++ 內部測試與 Nodejs 外部測試兩個部分。使用Nodejs測試的緣由是,不方便將測試數據進行PR,更不方便把它丟到 C++ 代碼內部。
C++部分的部分測試:
TEST_METHOD(TestPharse) { auto config = WordCountConfig(); config.statByPharse = true; config.pharseSize = 3; config.useDifferentWeight = false; auto out = doTest("0\nTitle: Monday Tuesday Wednesday Thursday\nAbstract: Friday", &config); Assert::AreEqual(out.characters, (size_t)40); Assert::AreEqual(out.words, (size_t)5); Assert::AreEqual(out.lines, (size_t)2); Assert::AreEqual(out.uniqueWordsOrPharses, (size_t)2); Assert::AreEqual(out.wordAppears[0].word, "monday tuesday wednesday"); Assert::AreEqual(out.wordAppears[0].count, (size_t)1); Assert::AreEqual(out.wordAppears[1].word, "tuesday wednesday thursday"); Assert::AreEqual(out.wordAppears[1].count, (size_t)1); ClearWordAppear(&out); }
Nodejs部分的測試:
const testCaseDir = 'cases-2/' const testCaseCount = 7 const cp = require('child_process') const fs = require('fs') const path = require('path') for (let i = 1; i <= testCaseCount; i++) { const randomFileName = (Math.random() * 10000000000 + new Date().getTime()).toString(16) + '.txt' const inFileName = path.resolve(__dirname, `${testCaseDir}input${i}.txt`) const argFileName = path.resolve(__dirname, `${testCaseDir}arg${i}.txt`) const outFileName = path.resolve(__dirname, `${testCaseDir}result${i}.txt`) const arg = fs.readFileSync(argFileName, 'utf-8') cp.execSync(`"D:\\Projects\\Homework\\fzuse-hw3-2\\221600131\&221600439\\src\\Debug\\WordCount.exe" -i ${inFileName} -o ${randomFileName} ${arg}`) const stdout = fs.readFileSync(randomFileName, 'utf-8') if (fs.existsSync(outFileName)) { const fout = fs.readFileSync(outFileName, 'utf-8') if (stdout.trim() !== fout.trim()) { console.log(`Failed at ${i}`) console.log(`=== Excepted ====`) console.log(fout) console.log(`=== Actual ====`) console.log(stdout) } else { console.log(`OK at ${i}`) } } else { fs.writeFileSync(outFileName, stdout, 'utf-8') } fs.unlinkSync(randomFileName) }
部分測試如圖:
須要搭配狀態轉換圖查看,註釋數量尚可。
EXTERN WordCountResult CalculateWordCount(struct WordCountConfig config) { auto ret = WordCountResult(); bool runStateMachine = true; char prev = 0, c = 0; std::string word = ""; // 不想作動態分配內存,std::string省事 std::string separator = ""; std::string token = ""; size_t wordLength = 0; // <= wordAtLeastCharacterCount,超過則再也不計數 auto map = std::unordered_map<std::string, size_t>(); bool isValidWordStart = false; FILE* f; if (fopen_s(&f, config.in, "rb") != 0) { ret.errorCode = WORDCOUNTRESULT_OPEN_FILE_FAILED; return ret; } fseek(f, 0, SEEK_END); long fileLength = ftell(f); fseek(f, 0, SEEK_SET); char * string = (char*)malloc(fileLength + 1); fread(string, fileLength, 1, f); fclose(f); string[fileLength] = 0; ReadingStatus currentStatus = ALREADY; WordStatus wordStatus = NONE; std::list<WordInPharse> pharse; size_t currentPosition = 0; while (runStateMachine) { prev = c; c = string[currentPosition]; if (currentPosition == fileLength) { runStateMachine = false; // 文件讀取結束,不當即退出,處理一下以前未整理乾淨的狀態 c = 0; } else { currentPosition++; } if (c >= 'A' && c <= 'Z') { c = c - 'A' + 'a'; } bool switchStatusInCurrentToken = true; // 直接把read token和parse作在一塊兒,就不拆開了 while (switchStatusInCurrentToken) { switchStatusInCurrentToken = false; // 避免這個大switch的方法是把這個狀態轉換寫成一個類 // 不過沒啥必要,不考慮後續維護 switch (currentStatus) { case ALREADY: if (isNumber(c)) { currentStatus = READING_PAPER_INDEX; switchStatusInCurrentToken = true; continue; } // else if (isEmptyChar(c)) { // 正常, do nothing // } else { // @TODO: 此處要拋錯 } break; case READING_PAPER_INDEX: if (isNumber(c)) { token += c; } else if (isEmptyChar(c)) { // 編號讀完,狀態轉換開始 token = ""; // 這個編號數據沒啥用,我也不知道讀了幹啥 currentStatus = WAITING_FOR_TITLE; } else { // @TODO: 此處要拋錯 } break; case WAITING_FOR_TITLE: if (isEmptyChar(c) && c != ':') { // 多是還沒讀完Title,也多是已經讀完了 if (token == "title:") { // 讀完了 isValidWordStart = true; currentStatus = FINDING_WORD_START; wordStatus = TITLE; token = ""; } else { // @TODO: 此處要拋錯 } } else { token += c; // 暫不判斷title:是否徹底正確,假設其規範;以後加入錯誤提示 } break; case WAITING_FOR_ABSTRACT: if (isEmptyChar(c) && c != ':') { // 同title if (token == "abstract:") { // 讀完了 isValidWordStart = true; currentStatus = FINDING_WORD_START; wordStatus = ABSTRACT; token = ""; } else { // @TODO: 此處要拋錯 } } else { token += c; } break; case FINDING_WORD_START: if (isLetter(c)) { if (wordLength == 0) { separator = token; token = ""; } // 後半部分判斷是爲了處理01abcdefg這種狀況 if ((wordLength > 0 && wordLength < wordAtLeastCharacterCount) || (wordLength == 0 && isValidWordStart)) { if (isAlphabet(c)) { wordLength++; if (wordLength == wordAtLeastCharacterCount) { currentStatus = READ_WORD; switchStatusInCurrentToken = true; } else { ret.characters++; word += c; } continue; } } } if (config.statByPharse) {// 單詞長度不達標則清空詞組 if (wordLength > 0) { pharse.clear(); } } isValidWordStart = false; word = ""; wordLength = 0; currentStatus = READ_WORD_END; switchStatusInCurrentToken = true; continue; break; case READ_WORD: // 肯定已是單詞了,繼續搞 if (isLetter(c)) { // 仍然是字母的狀況下,繼續讀 word += c; ret.characters++; // 非單詞的狀況下字符統計交給READ_WORD_END } else { // 不是字母了,開始處理剩下的了 currentStatus = READ_WORD_END; switchStatusInCurrentToken = true; continue; } break; case READ_WORD_END: if (word != "") { ret.words++; // 這個時候就能肯定讀到了一個完整的單詞了 if (config.statByPharse) { pharse.push_back(WordInPharse{ word = word, separator = separator }); if (pharse.size() == config.pharseSize) { auto pharseString = getPharse(pharse); if (map.find(pharseString) == map.end()) { map[pharseString] = 0; ret.uniqueWordsOrPharses++; } if (config.useDifferentWeight) { if (wordStatus == TITLE) { map[pharseString] += titleWeight; } else { map[pharseString] += 1; } } else { map[pharseString]++; } pharse.pop_front(); } } else { // 略微重複代碼,建議抽象成宏 if (map.find(word) == map.end()) { map[word] = 0; ret.uniqueWordsOrPharses++; } if (config.useDifferentWeight) { if (wordStatus == TITLE) { map[word] += titleWeight; } else { map[word] += 1; } } else { map[word]++; } } isValidWordStart = false; } word = ""; wordLength = 0; if (isLf(c) || !runStateMachine) { // 若是是個換行符,就能夠切換狀態是讀TITLE仍是讀ABSTRACT了 ret.lines++; pharse.clear(); token = ""; if (wordStatus == TITLE) { currentStatus = WAITING_FOR_ABSTRACT; } else { currentStatus = ALREADY; } if (isLf(c)) { ret.characters++; } } else { // 單詞處理完成了,該等新的單詞了。 if (!isValidWordStart) { if (isSeparator(c)) { isValidWordStart = true; token += c; } } if (isCharacter(c)) { ret.characters++; } currentStatus = FINDING_WORD_START; } break; } } } auto sortedMap = std::vector<WordCountPair>(map.begin(), map.end()); std::sort(sortedMap.begin(), sortedMap.end(), [](const WordCountPair& lhs, const WordCountPair& rhs) { if (lhs.second == rhs.second) { return lhs.first < rhs.first; } return lhs.second > rhs.second; }); ret.wordAppears = new WordCountWordAppear[ret.uniqueWordsOrPharses]; size_t i = 0; for (auto &it : sortedMap) { ret.wordAppears[i].word = new char[it.first.length() + 1]; strcpy_s(ret.wordAppears[i].word, it.first.length() + 1, it.first.c_str()); ret.wordAppears[i].count = it.second; i++; } return ret; }
要進行數據分析首先得有足夠的數據集。因此我將前面的爬蟲程序進行改進,將CVPR官網上有用的信息都爬取下來。我這裏是經過Request獲取前端代碼分析時發現底部有個神奇的bibref類,裏面存放了不少信息,甚至還有沒展現的屬性,好比月份。經過觀察這些信息的結構都同樣。
因此直接編寫正則一次性將所需信息取出。結果以下
可是就這些數據種類可玩性仍是過低了,一開始個人想法是能根據論文的研究方向作一個聚類,或者是經過論文使用的測試數據集來畫一個研究進展的趨勢圖(也能夠經過時間序列進行將來預測),又或者是根據做者所屬國家畫一個區域熱力圖。但惋惜的是這些數據都沒有,去GitHub上找別人整理的信息也無非是多了一個論文屬性,並非我想要的。雖然有一種操做是利用已有的做者名或者論文名再去其它地方爬相關信息,之後有時間再嘗試。
因此最後就只能在做者這個屬性上作點文章了。個人目的是繪製一個做者關係圖,用圓來表明做者,一塊兒發過論文的做者用線相互鏈接。發論文量越多的做者圓越大。代碼過程是經過pandas將做者屬性提取,以後將全部做者放入list裏進行遍歷計數,先計算全部做者的發文數,以後進行兩重循環計算做者之間的關聯。最後可視化使用的是基於百度echarts上的pyecharts,能夠在jupyter上處理完數據後直接導入作可視化,也能夠導出像echarts的Web,而沒必要另寫js代碼。
當鼠標放到某個做者圓圈上時,其它圓圈變暗,與其一塊兒發表過論文的做者圓圈和連線高亮。放大效果以下:
有興趣可點連接下載,便可打開Web。
跟以前同樣使用多進程爬蟲。32進程時用時20秒左右,與以前差很少,這裏再也不贅述。