程式設計師程式設計藝術-----第十一 ~ 十四章-----海量整數處理、蓄水池抽樣、迴文
程式設計師程式設計藝術第十二~十五章:中籤概率,IP訪問次數,迴文等問題(初稿)
作者:上善若水.qinyu,BigPotato,luuillu,well,July。程式設計藝術室出品。
前言
本文的全部稿件是由我們程式設計藝術室的部分成員:上善若水.qinyu,BigPotato,luuillu,well,July共同完成,共分4個部分,即4道題:
- 第一部分、從一道題,漫談資料結構、以及壓縮、點陣圖演算法,由上善若水.qinyu完成,
- 第二部分、遍歷n個元素取出等概率隨機取出其中之一元素,由BigPotato完成,
- 第三部分、提取出某日訪問百度次數最多的那個IP,由luuillu完成,
- 第四部分、迴文判斷,由well完成。全文由July統稿完成。
由於本人在這周時間上實在是過於倉促,來不及過多整理,所以我儘量保持上述我的幾位夥伴的原話原文,基本沒做多少改動。因此,標明為初稿,以後會更加詳盡細緻的進行修補完善。
前十一章請見這:程式設計師程式設計藝術第一~十章集錦與總結。吾以咱們程式設計藝術室的朋友為傲,以能識盡天下各路朋友為傲,以諸君為傲。
第一部分、從一道題,漫談資料結構、以及壓縮、點陣圖演算法
海量資料處理往往會很有趣,有趣在什麼地方呢?
- 空間,available的記憶體不夠,需要反覆交換記憶體
- 時間,速度太慢不行,畢竟那是海量資料
- 處理,資料是一次呼叫還是反覆呼叫,因為針對時間和空間,通常來說,多次呼叫的話,勢必會增加預處理以減少每次呼叫的時候的時間代價。
題目如下
7、騰訊面試題:給40億個不重複的unsignedint的整數,沒排過序的,然後再給一個數,如何快速判斷這個數是否在那40億個數當中?
分析:1個unsigned int佔用4位元組,40億大約是4G個數不到,那麼一共大約要用16G的記憶體空間,如果記憶體不夠大,反覆和硬碟交換資料的話,後果不堪設想。
那麼怎麼儲存這麼多的資料呢?還記得伴隨陣列麼?還是那種思想,利用記憶體地址代替下標。
先舉例,在記憶體中應該是1個byte=8bit,那麼明顯有
0 = 0000 0000
255 = 1111 1111
69 = 0100 0101
那麼69可以表示0.2.6三個數存在,其餘的7以下的數不存在,0表示0-7都不存在,255表示0-7都存在,這就是點陣圖演算法:通過全部置0,存在置1,這樣一種模式來通過連續的地址存貯資料,和檢驗資料的方法。
那麼1個 unsigned int代表多少個數呢?1個unsigned int 是一個2^32以內的數,那麼也就是這樣的1個數,可以表示32個數是否存在。同理申請一個unsigned int的陣列a[n]則可以表示連續的(n+1)*32的數。也就是a[0]表示0-31的數是否存在,a[1]表示32-63的數是否存在,依次類推。
這時候需要用多大的記憶體呢?
16g/32=512M
512M和16G之間的區別,卻是是否一個32位定址的CPU能否辦得到的事兒了,眾所周知,32位CPU最大定址不超過4G,固然,你會說,現在都是64位的CPU之類的云云,但是,對於底層的設計者來說,定址範圍越小越好操控的事實是不爭的。
問題到這裡,其實基本上已經完事了,判斷本身,在點陣圖演算法這裡就是找到對應的記憶體位置是否為1就可以了。
當資料超出可接受範圍之後…
當然,下面就要開始說一說,當資料超出了可以接受的範圍之後的事情了。比如, 2^66範圍的資料檢索,也會是一個問題
4倍於64位CPU定址範圍,如果加上CPU本身的偏移暫存器佔用的資源,可能應該是6-8個64位U的定址範圍,如果反覆從記憶體到硬碟的讀寫,過程本身就是可怕的。
演算法,更多的是用來解決瓶頸的,就想現在,根本不用考慮記憶體超出8M的問題,但是20年前,8086的年代,記憶體4M,或者記憶體8M,你怎麼處理?固然做軟體的不需要完全考慮摩爾定律,但是摩爾定律絕對是影響軟體和演算法編寫者得想法的。
再比如,烏克蘭俄羅斯的一批壓縮高手,比如國內有名的R大,為什麼壓縮會出現?就是因為,要麼存不下,要麼傳輸時間過長。網路再好,64G的高清怎麼的也得下載個一段時間吧。海量資料處理,永遠是考慮超過了當前硬體條件的時候,該怎麼辦?!
那麼我們可以發現一個更加有趣的問題,如果存不下,但是還要存,怎麼辦!
壓縮!這裡簡單的說一嘴,無失真壓縮常見的為Huffman演算法和LZW(Lenpel-Ziv &Welch)壓縮演算法,前者研究不多,後者卻經常使用。
因為上面提到了點陣圖演算法,我就用常見的點陣圖類的資料舉例:
以下引自我的摘抄出處忘記了,請作者見諒:
“對原始資料ABCCAABCDDAACCDB進行LZW壓縮
原始資料中,只包括4個字元(Character),A,B,C,D,四個字元可以用一個2bit的數表示,0-A,1-B,2-C,3-D,從最直觀的角度看,原始字串存在重複字元:ABCCAABCDDAACCDB,用4代表AB,5代表CC,上面的字串可以替代表示為:45A4CDDAA5DB,這樣是不是就比原資料短了一些呢!
LZW演算法的適用範圍
為了區別代表串的值(Code)和原來的單個的資料值(String),需要使它們的數值域不重合,上面用0-3來代表A-D,那麼AB就必須用大於3的數值來代替,再舉另外一個例子,原來的數值範圍可以用8bit來表示,那麼就認為原始的數的範圍是0~255,壓縮程式生成的標號的範圍就不能為0~255(如果是0-255,就重複了)。只能從256開始,但是這樣一來就超過了8位的表示範圍了,所以必須要擴充套件資料的位數,至少擴充套件一位,但是這樣不是增加了1個字元佔用的空間了麼?但是卻可以用一個字元代表幾個字元,比如原來255是8bit,但是現在用256來表示254,255兩個數,還是划得來的。從這個原理可以看出LZW演算法的適用範圍是原始資料串最好是有大量的子串多次重複出現,重複的越多,壓縮效果越好。反之則越差,可能真的不減反增了。
虛擬碼如下
1 STRING = get input character
2 WHILE there are still input characters DO
3 CHARACTER = get input character
4 IF STRING+CHARACTER is in the string table then
5 STRING = STRING+character
6 ELSE
7 output the code for STRING
8 add STRING+CHARACTER to the string table
9 STRING = CHARACTER
10 END of IF
11 END of WHILE
12 output the code for STRING
看過上面的適用範圍在聯想本題,資料有多少種,根據同餘模的原理,可以驚人的發現,其實真的非常適合壓縮,但是壓縮之後,儘管存下了,在查詢的時候,勢必又需要解碼,那麼又回到了我們當初學習演算法時候,的那句經典話,演算法本身,就是為了解決時間和空間的均衡問題,要麼時間換空間,要麼空間換時間。
更多的,請讀者自行思考,因為,壓縮本身只是想引起讀者思考,已經是題外話了~本部分完--上善若水.qinyu。
第二部分、遍歷n個元素取出等概率隨機取出其中之一元素
問題描述
1.一個檔案中含有n個元素,只能遍歷一遍,要求等概率隨機取出其中之一。
先講一個例子,5個人抽5個籤,只有一個籤意味著“中籤”,輪流抽籤,那麼這種情況,估計這5個人都不會有異議,都覺得這種方法是公平的,這確實也是公平的,“抓鬮”的方法已經有很長的歷史了,要是不公平的話老祖先們就不幹了。
或許有人覺得先抓的人中籤的概率會大一些,因為要是前面的人中了,後面的中籤概率就是0了,也可能有人會覺得後面抓的人更有優勢,因為前面拿去了不中的籤,後面中籤的概率就大,那麼我們就計算一下吧。
問題分析
第一個人中籤的概率是1/5,
第二個人中籤的情況只能在第一個人未中時才有可能,所以他中的概率是4/5 X 1/4 = 1/5(4/5表示第一個人未中,1/4表示在剩下的4個籤裡中籤的概率),所以,第二個人最終的中籤概率也是1/5,
同理,第三個人中籤的概率為:第一個人未中的概率X 第二個人未中的概率X第三個人中的概率,即為:4/5 X 3/4 X 1/3 = 1/5,
一樣的可以求出第四和第五個人的概率都為1/5,也就是說先後順序不影響公平性。
說這個問題是要說明這種前後有關聯的事件的概率計算的方式,我們回到第1個問題。前幾天我的一個同學電面百度是被問到這個問題,他想了想回答說,依次遍歷,遇到每一個元素都生成一個隨機數作為標記,如果當前生成的隨機數大於為之前保留的元素生成的隨機數就替換,這樣操作直到檔案結束。
但面試官問到:如果生成的隨機數和之前保留的元素的隨機數一樣大的話,要不要替換呢?
你也許會想,一個double的範圍可以是-1.79E+308 ~ +1.79E+308,要讓兩個隨機生成的double相等的概率不是一般的微乎其微啊!但計算機世界裡有條很讓人傷心的“真理”:可能發生的事件,總會發生!
那我們遇到這種情況,是換還是不換?To be or not to be, that’s a question!
就好比,兩個人百米賽跑,測出來的時間一樣,如果只能有一個人得冠軍的話,對於另一個人始終是不公平的,那麼只能再跑一次,一決雌雄了!
我的策略
下面,說一個個人認為比較滿足要求的選取策略:
- 順序遍歷,當前遍歷的元素為第L個元素,變數e表示之前選取了的某一個元素,此時生成一個隨機數r,如果r%L == 0(當然0也可以是0~L-1中的任何一個,概率都是一樣的), 我們將e的值替換為當前值,否則掃描下一個元素直到檔案結束。
你要是給面試官說明了這樣一個策略後,面試官百分之一千會問你這樣做是等概率嗎?那我們來證明一下。
證明
在遍歷到第1個元素的時候,即L為1,那麼r%L必然為0,所以e為第一個元素,p=100%,
遍歷到第2個元素時,L為2,r%L==0的概率為1/2, 這個時候,第1個元素不被替換的概率為1X(1-1/2)=1/2,
第1個元素被替換,也就是第2個元素被選中的概率為1/2 = 1/2,你可以看到,只有2時,這兩個元素是等概率的機會被選中的。
繼續,遍歷到第3個元素的時候,r%L==0的概率為1/3,前面被選中的元素不被替換的概率為1/2 X (1-1/3)=1/3,前面被選中的元素被替換的概率,即第3個元素被選中的概率為1/3
歸納法證明,這樣走到第L個元素時,這L個元素中任一被選中的概率都是1/L,那麼走到L+1時,第L+1個元素選中的概率為1/(L+1), 之前選中的元素不被替換,即繼續被選中的概率為1/L X ( 1-1/(L+1) ) = 1/(L+1)。證畢。
也就是說,走到檔案最後,每一個元素最終被選出的概率為1/n, n為檔案中元素的總數。好歹我們是一個技術部落格,看不到一丁點程式碼多少有點遺憾,給出一個選取策略的虛擬碼,如下:
虛擬碼
Element RandomPick(file):
Int length = 1;
While( length <= file.size )
If( rand() % length == 0)
Picked = File[length];
Length++;
Return picked
近日,看見我的一些同學在他們的面經裡面常推薦結構之法演算法之道這個部落格,感謝東南大學計算機學院即將找工作的同學們對本博的關注,歡迎批評指正!--BigPotato。
第三部分、提取出某日訪問百度次數最多的那個IP
問題描述:海量日誌資料,提取出某日訪問百度次數最多的那個IP。
方法: 計數法
假設一天之內某個IP訪問百度的次數不超過40億次,則訪問次數可以用unsigned表示.用陣列統計出每個IP地址出現的次數, 即可得到訪問次數最大的IP地址.
IP地址是32位的二進位制數,所以共有N=2^32=4G個不同的IP地址, 建立一個unsigned count[N];的陣列,即可統計出每個IP的訪問次數,而sizeof(count) == 4G*4=16G, 遠遠超過了32位計算機所支援的記憶體大小,因此不能直接建立這個陣列.下面採用劃分法解決這個問題.
假設允許使用的記憶體是512M, 512M/4=128M 即512M記憶體可以統計128M個不同的IP地址的訪問次數.而N/128M =4G/128M = 32 ,所以只要把IP地址劃分成32個不同的區間,分別統計出每個區間中訪問次數最大的IP, 然後就可以計算出所有IP地址中訪問次數最大的IP了.
因為2^5=32, 所以可以把IP地址的最高5位作為區間編號, 剩下的27為作為區間內的值,建立32個臨時檔案,代表32個區間,把相同區間的IP地址儲存到同一的臨時檔案中.
例如:
ip1=0x1f4e2342
ip1的高5位是id1 = ip1 >>27 = 0x11 = 3
ip1的其餘27位是value1 = ip1 &0x07ffffff = 0x074e2342
所以把 value1 儲存在tmp3檔案中.
由id1和value1可以還原成ip1, 即 ip1 =(id1<<27)|value1
按照上面的方法可以得到32個臨時檔案,每個臨時檔案中的IP地址的取值範圍屬於[0-128M),因此可以統計出每個IP地址的訪問次數.從而找到訪問次數最大的IP地址
程式原始碼:
test.cpp是c++原始碼.
- #include <fstream>
- #include <iostream>
- #include <ctime>
- using namespace std;
- #define N 32 //臨時檔案數
- #define ID(x) (x>>27) //x對應的檔案編號
- #define VALUE(x) (x&0x07ffffff) //x在檔案中儲存的值
- #define MAKE_IP(x,y) ((x<<27)|y) //由檔案編號和值得到IP地址.
- #define MEM_SIZE 128*1024*1024 //需分配記憶體的大小為 MEM_SIZE*sizeof(unsigned)
- char* data_path="D:/test/ip.dat"; //ip資料
- //產生n個隨機IP地址
- void make_data(const int& n)
- {
- ofstream out(data_path,ios::out|ios::binary);
- srand((unsigned)(time(NULL)));
- if (out)
- {
- for (int i=0; i<n; ++i)
- {
- unsigned val=unsigned(rand());
- val = (val<<24)|val; //產生unsigned型別的隨機數
- out.write((char *)&val,sizeof (unsigned));
- }
- }
- }
- //找到訪問次數最大的ip地址
- int main()
- {
- //make_data(100); //
- make_data(100000000); //產生測試用的IP資料
- fstream arr[N];
- for (int i=0; i<N; ++i) //建立N個臨時檔案
- {
- char tmp_path[128]; //臨時檔案路徑
- sprintf(tmp_path,"D:/test/tmp%d.dat",i);
- arr[i].open(tmp_path, ios::trunc|ios::in|ios::out|ios::binary); //開啟第i個檔案
- if( !arr[i])
- {
- cout<<"open file"<<i<<"error"<<endl;
- }
- }
- ifstream infile(data_path,ios::in|ios::binary); //讀入測試用的IP資料
- unsigned data;
- while(infile.read((char*)(&data), sizeof(data)))
- {
- unsigned val=VALUE(data);
- int key=ID(data);
- arr[ID(data)].write((char*)(&val), sizeof(val)); //儲存到臨時檔案件中
- }
- for(unsigned i=0; i<N; ++i)
- {
- arr[i].seekg(0);
- }
- unsigned max_ip = 0; //出現次數最多的ip地址
- unsigned max_times = 0; //最大隻出現的次數
- //分配512M記憶體,用於統計每個數出現的次數
- unsigned *count = new unsigned[MEM_SIZE];
- for (unsigned i=0; i<N; ++i)
- {
- memset(count, 0, sizeof(unsigned)*MEM_SIZE);
- //統計每個臨時檔案件中不同數字出現的次數
- unsigned data;
- while(arr[i].read((char*)(&data), sizeof(unsigned)))
- {
- ++count[data];
- }
- //找出出現次數最多的IP地址
- for(unsigned j=0; j<MEM_SIZE; ++j)
- {
- if(max_times<count[j])
- {
- max_times = count[j];
- max_ip = MAKE_IP(i,j); // 恢復成原ip地址.
- }
- }
- }
- delete[] count;
- unsigned char *result=(unsigned char *)(&max_ip);
- printf("出現次數最多的IP為:%d.%d.%d.%d,共出現%d次",
- result[0], result[1], result[2], result[3], max_times);
- }
執行結果.
--luuillu。
第四部分、迴文判斷
(初稿,寫的比較急,請審閱、增補、修改)
迴文判斷是一類典型的問題,尤其是與字串結合後呈現出多姿多彩,在實際中使用也比較廣泛,而且也是面試題中的常客,所以本文就結合幾個典型的例子來體味下回文之趣。
迴文,英文palindrome,指一個順著讀和反過來讀都一樣的字串,比如 madam、我愛我,這樣的短句在智力性、趣味性和藝術性上都頗有特色,中國歷史上還有很多有趣的迴文詩呢 :)
一、迴文判斷
那麼,我們的第一個問題就是:判斷一個字串是否是迴文
通過對迴文字串的考察,最直接的方法顯然是將字串逆轉,存入另外一個字串,然後比較原字串和逆轉後的字串是否一樣,一樣就是迴文,這個方法的時空複雜度都是 O(n)。
我們還很容易想到只要從兩頭開始同時向中間掃描字串,如果直到相遇兩端的字元都一樣,那麼這個字串就是一個迴文。我們只需要維護頭部和尾部兩個掃描指標即可,程式碼如下:
- /**
- *check weather s is a palindrome, n is the length of string s
- *Copyright(C) fairywell 2011
- */
- bool IsPalindrome(const char *s, int n)
- {
- if (s == 0 || n < 1) return false; // invalid string
- char *front, *back;
- front = s; back = s + n - 1; // set front and back to the begin and endof the string
- while (front < back) {
- if (*front != *back) return false; // not a palindrome
- ++front; --back;
- }
- return true; // check over, it's a palindrome
- }
這是一個直白且效率不錯的實現,只需要附加 2 個額外的指標,在 O(n) 時間內我們可以判斷出字串是否是迴文。
是否還有其他方法?呵呵,聰明的讀者因該想到了不少變種吧,不過時空複雜度因為不會有顯著提升了(為什麼?),下面再介紹一種迴文判斷方法,先上程式碼:
- /**
- *check weather s is a palindrome, n is the length of string s
- *Copyright(C) fairywell 2011
- */
- bool IsPalindrome2(const char *s, int n)
- {
- if (s == 0 || n < 1) return false; // invalid string
- char *first, *second;
- int m = ((n>>1) - 1) >= 0 ? (n>>1) - 1 : 0; // m is themiddle point of s
- first = s + m; second = s + n - 1 - m;
- while (first >= s)
- if (s[first--] !=s[second++]) return false; // not equal, so it's not apalindrome
- return true; // check over, it's a palindrome
- }
程式碼略有些小技巧,不過相信我們聰明的讀者已經看清了意思,這裡就是從中間開始、向兩邊擴充套件檢視字元是否相等的一種方法,時空複雜度和上一個方法是一模一樣的,既然一樣,那麼我們為什麼還需要這種方法呢?首先,世界的美存在於它的多樣性;其次,我們很快會看到,在某些迴文問題裡面,這個方法有著自己的獨到之處,可以方便的解決一類問題。
那麼除了直接用陣列,我們還可以採用其他的資料結構來判斷迴文嗎呢?請讀者朋友稍作休息想想看。相信我們聰明的讀者肯定想到了不少好方法吧,也一定想到了經典的單鏈表和棧這兩種方法吧,這也是面試中常常出現的兩種迴文資料結構型別。
對於單鏈表結構,處理的思想不難想到:用兩個指標從兩端或者中間遍歷並判斷對應字元是否相等。所以這裡的關鍵就是如何朝兩個方向遍歷。單鏈表是單向的,所以要向兩個方向遍歷不太容易。一個簡單的方法是,用經典的快慢指標的方法,定位到連結串列的中間位置,將連結串列的後半逆置,然後用兩個指標同時從連結串列頭部和中間開始同時遍歷並比較即可。
對於棧就簡單些,只需要將字串全部壓入棧,然後依次將各字元出棧,這樣得到的就是原字串的逆置串,分別和原字串各個字元比較,就可以判斷了。
二、迴文的應用
我們已經瞭解了迴文的判斷方法,接下來可以來嘗試迴文的其他應用了。迴文不是很簡單的東西嗎,還有其他應用?是的,比如:查詢一個字串中的最長迴文字串
Hum,還是請讀者朋友們先自己想想看看。嗯,有什麼好方法了嗎?列舉所有的子串,分別判斷其是否為迴文?這個思路是正確的,但卻做了很多無用功,如果一個長的子串包含另一個短一些的子串,那麼對子串的迴文判斷其實是不需要的。
那麼如何高效的進行判斷呢?既然對短的子串的判斷和包含它的長的子串的判斷重複了,我們何不復用下短的子串的判斷呢(哈,演算法裡也跑出軟體工程了),讓短的子串的判斷成為長的子串的判斷的一個部分!想到怎麼做了嗎?Aha,沒錯,擴充套件法。從一個字元開始,向兩邊擴充套件,看看最多能到多長,使其保持為迴文。這也就是為什麼我們在上一節裡面要提出 IsPalindrome2 的原因。
具體而言,我們可以列舉中心位置,然後再在該位置上用擴充套件法,記錄並更新得到的最長的迴文長度,即為所求。程式碼如下:
- /**
- *find the longest palindrome in a string, n is the length of string s
- *Copyright(C) fairywell 2011
- */
- int LongestPalindrome(const char *s, int n)
- {
- int i, j, max;
- if (s == 0 || n < 1) return 0;
- max = 0;
- for (i = 0; i < n; ++i) { // i is the middle point of the palindrome
- for (j = 0; (i-j >= 0) && (i+j < n); ++j) // if the lengthof the palindrome is odd
- if (s[i-j] != s[i+j]) break;
- if (j*2+1 > max) max = j * 2 + 1;
- for (j = 0; (i-j >= 0) && (i+j+1 < n); ++j) // for theeven case
- if (s[i-j] != s[i+j+1]) break;
- if (j*2+2 > max) max = j * 2 + 2;
- }
- return max;
- }
程式碼稍微難懂一點的地方就是內層的兩個 for 迴圈,它們分別對於以 i 為中心的,長度為奇數和偶數的兩種情況,整個程式碼遍歷中心位置 i 並以之擴充套件,找出最長的迴文。
當然,還有更先進但也更復雜的方法,比如用 s 和逆置 s' 的組合 s$s' 來建立字尾樹的方法也能找到最長迴文,但構建的過程比較複雜,所以在實踐中用的比較少,感興趣的朋友可以參考相應資料。
迴文的內容還有不少,但主要的部分通過上面的內容相信大家已經掌握,希望大家能抓住實質,在實踐中靈活運用,迴文的內容我就暫時介紹到這裡了,謝謝大家--well。
附註:
- 如果讀者對本文的內容或形式,語言和表達不甚滿意。完全理解。我之前也跟程式設計藝術室內的朋友們開玩笑的說:我暫不做任何修改,題目會標明為初稿。這樣的話,你們才會相信或者知曉,你們的才情只有通過我的語言和表達才能最大限度的發揮出來,被最廣泛的人輕而易舉的所認同和接受(不過,以上4位兄弟的思維靈活度都在本人之上)。呵呵,開個玩笑。
- 本文日後會抽取時間再做修補和完善。若有任何問題,歡迎隨時不吝指正。謝謝。
完。