一個信息可視化Demo的設計(三):算法設計
作者:憤怒的小狐狸 撰寫日期:2012-02-22 ~ 2011-02-28
博客鏈接: http://blog.csdn.net/MONKEY_D_MENG
此爲系列博文,前續請看:
第二部分:《一個信息可視化Demo的設計(二):Index & Search》
一、背景和廢話若干
信息可視化一個簡單概括性的說法即是將信息、數據、資源等能夠通過可視化的手段和方式,直觀地呈現給用戶,幫助用戶梳理其中的知識以及內在關聯,必要時提供所見即所得(WYSIWYG)的交互方式。從一個產品設計的角度來考慮的話,好的信息可視化工具必然給用戶帶來一種自然而然的交互體驗,甚至是用戶根本沒意識到某種交互行爲的存在,也即是用戶潛意識裏面一直在念叨:交互行爲不就應該是這樣的嗎?
作爲一個信息可視化的Demo,最終前端的落腳點還是要依託於某種圖形化的展示技術來實現。我們的方案是基於Silverlight,當然並不是說我們不可以選用其他的前端技術。正如之前所述,微軟一般還是會用自己公司的產品,即便是和我搭檔的小夥會用Flex,而我倆都不會Silverlight。插一句題外話,在來實習之前,我還是認爲微軟的大型在線服務系統如Bing會採用UNIX環境部署,後來得知是基於Win Server,這使我大吃一驚,看來Win Server性能應該還是不錯的,雖然我沒用過,見笑了各位~
Silverlight提供了交互能力強大且豐富的可視化控件,因此我們有了圖形顯示、交互的落地技術。然而,同時又有新問題擺在了我們面前,即如何合理地佈局一棵樹,使得樹形結構的渲染簡潔而又不失優雅。其實,早在我們剛剛接觸這個項目的時候,我還對此不屑一顧,我認爲這個項目真是太簡單了,說白了不就是把描述Tree的文件載入,把Tree的結構還原,再用Silverlight顯示出來嘛。後來,真正做的時候卻是問題接踵而來,就比如現在的Tree的佈局問題,猛的一看覺得簡單的不得了,再仔細一想發現很難一下解決,再到後來看了幾個Li給我的老外做的Demo之後,哥徹底清楚過來,如果要想實現的特別NB,還是很難駕馭的。其中,簡單一點的Space Tree的Demo鏈接:http://www.cs.umd.edu/hcil/spacetree/,還有一些比較絢的渲染效果如FishEye Tree、Circular Treemap等,鏈接:http://www.randelshofer.ch/treeviz/。看完這些Demo你會覺得專業和非專業還是有蠻大差別,是我肯定是想不出這麼多、這麼絢的效果,而且交互作爲黑NB~
對自己有了重新的定位後,我們開始選擇最簡單的原型設計,即輸入一個樹,把它完整地在頁面上顯示出來,使得節點與節點之間的排列布局井然有序簡潔優雅而又層次結構關聯邏輯清晰可見(- -|||)。那,這應該如何做到呢?說到底還是算法設計問題。
不搞不知道,一搞嚇一跳,其實這個問題在幾十年前,也就是70年代時候就有NB人物已經解決過了,而且解決的很好。其中比較著名的有Knuth提出的“Optimum binary search trees”,學計算機都知道這人是誰,不用多說。後來,找到一篇論文“Tidy Drawings of Trees”裏面介紹了幾種方法,感覺比較簡單且易於實現,在此做個總結。
二、樸素的樹形結構顯示算法
首先來看一個樸素的樹形結構顯示算法,對於任一節點而言,至多存在一個前驅父節點,而根節點root的父節點爲NULL,同等高度的節點應處於同一水平線層次。因此,一種樸素方法即是按照節點的高度進行平行繪製,高度可以表徵節點的Y軸座標,計算和獲取每個節點高度是首要解決的問題,但在論文中該算法中先假設已經得到了這個值。
算法的基本思路在於將具有相同高度節點的Y軸座標設置爲一樣的,即設爲高度值,使它們處於同一水平線,剩下的只需要調整它們的X座標,使得下一個節點的X座標與上一節點之間存在一定的距離即可,思想是很簡單的。
輸入:定義良好的樹形結構的root、最大高度max_height。
輸出:每個節點的座標(x, y),假設每個節點的高度是已經知道。
算法僞碼描述:
//節點數據結構
type node
data; //節點數據
father: branch; //父節點
children_num: integer; //子節點數目
children: array[1..children_num] of branch; //子節點數組
height: integer; //節點高度
x, y: integer; //X、Y軸座標
status: 0..children_num; //處理子節點數目
end;
//變量定義
var next_x: array[0..max_height] of integer;
current: branch;
i: integer;
begin
//初始化每個高度的X軸首位置爲1
for i = 0 to max_height do
next_x[i] = 1;
end for;
//初始化root處理的子節點數目爲0
root.status = 0;
current = root;
//結束條件爲root的父節點爲NULL
while current != NULL do
//首先處理current節點本身
if current.status == 0 then
//X座標記錄在next_x中,用於使同一層次兩個節點之間保持間隔
current.x = next_x[current.height];
//Y座標直接使用節點高度
current.y = 2 * current.height + 1;
//同一層兩個節點之間間隔爲2
next_x[current.height] = next_x[current.height] + 2;
//初始化所有子節點
for i = 1 to current.children_num do
current.children[i].status = 0;
end for;
//設置current節點已經被處理過
current.status = 1;
//子節點尚未完全處理完
else if 1 <= current.status && current.status <= current.children_num then
current.status = current.status + 1;
current = current.children[current.status - 1];
//子節點處理則回溯到父節點
else current = current.father;
end if;
end while;
end;
其實,我並不明白爲什麼要假設節點的高度是已知的,而事實上我們並不需要提前知道節點的高度,而是可以在計算節點高度的過程中計算節點的(X、Y)座標值,這一點藉助於隊列,利用層次遍歷的思想很容易就實現了,子節點高度=父節點高度+1嘛,那樣給出僞碼比論文中給出的還要簡單些,其僞碼描述如下所示。
輸入:定義良好的樹形結構的root。
輸出:每個節點的座標(x, y)。
算法僞碼描述:
//節點數據結構
type node
data; //節點數據
father: branch; //父節點
children_num: integer; //子節點數目
children: array[1..children_num] of branch; //子節點數組
height: integer; //節點高度
x, y: integer; //X、Y軸座標
end;
//變量定義
var next_x: array[0..N] of integer;
current: branch;
i: integer;
N: integer;
q: Queue;
begin
//初始化每個高度的X軸首位置爲1,N通常取一個較大的數,必須大於樹形結構最大高度
for i = 0 to N do
next_x[i] = 1;
end for;
//初始化root的高度爲0
root.height = 0;
//將root裝入隊列
q.push(root);
//如果隊列不爲空s
while !q.empty() do
//從隊列中彈出節點
current = q.pop();
//設置彈出節點的X、Y軸座標
current.x = next_x[current.height];
current.y = 2 * current.height + 1;
//同一層兩個節點之間間隔爲2
next_x[current.height] = next_x[current.height] + 2;
//對彈出節點的所有子節點進行處理
for i = 0 to children_num do
//子節點的高度爲父節點+1
current.children[i].height = current.height + 1;
//將子節點裝入隊列
q.push(current.children[i]);
end for;
end while;
end;
按照樸素的樹形結構顯示算法得繪製的圖形如下所示,當然,整體而言還算是比較整齊與簡潔,但存在的問題是父子繼承關係的連線不太美觀與優雅,特別是當某一層次上節點過多時,佈局看上去總是不那麼Happy,這也是這種方法使用的侷限所在。
三、二叉樹顯示算法
二叉樹是應用非常廣泛的特殊樹形結構,其嚴格意義上並不屬於“樹”,因爲其有明確的左右孩子節點之分,而對於樹而言其所有的孩子節點均是平等的。Knuth利用中序遍歷的思想,設計了一種二叉樹圖形渲染算法,基本思路:中序遍歷,從而依次確定左孩子、父節點和右孩子節點的座標。
輸入:定義良好的樹形結構的root。
輸出:每個節點的座標(x, y),假設每個節點的高度是已經知道。
算法僞碼描述:
//節點數據結構
type node
data; //節點數據
father: branch; //父節點
left_son, right_son: branch; //左右孩子節點
height: integer; //節點高度
x, y: integer; //X、Y軸座標
status: (first_visit, left_visit, right_visit); //遍歷的訪問模式
end;
//變量定義
var current: branch;
next: integer;
begin
//初始化節點的X座標
next = 1;
current = root;
current = first_visit;
//存在未處理的二叉樹節點
while current != NULL do
//對current的訪問情況進行分析
switch current.status
//初次訪問模式
case first_visit:
//設定下次訪問模式爲左訪問
current.status = left_visit;
//中序遍歷
if current.left_son != NULL then
current = current.left_son;
current.status = first_visit;
end if;
break;
//走到當前子樹的最左端,即可確定該節點的X座標
case left_visit:
current.x = next;
current.y = 2 * current.height + 1;
//計算下一個節點X座標
++next;
//左邊爲空,設置右訪問模式
current.status = right_visit;
if current.right_son != NULL then
current = current.right_son;
current.status = first_visit;
end if;
break;
//表明右孩子爲空,則回溯到父節點
case right_visit:
current = current.father;
break;
end case;
end switch;
end while;
end;
Kunth大神的這個算法很是簡潔,只需一次中序遍歷即確定了所有節點的X座標,他是使用了一種非遞歸的方式書寫了算法,當然遞歸的算法描述更爲簡單:
輸入:定義良好的樹形結構的root。
輸出:每個節點的座標(x, y),假設每個節點的高度是已經知道。
算法僞碼描述:
//變量定義
var current: branch;
next: integer;
//定義中序遍歷函數
function in_order(current)
if current != NULL then
in_order(current.left_son);
//計算該節點X座標
current.x = next++;
current.y = 2 * current.height + 1;
in_order(current.right_son);
end if;
end function;
begin
in_order(root);
end;
這種算法存在一個問題,即節點的佈局過於分散化,因爲所有節點的X座標都是按照中序遍歷的方式疊加上去的,而不存在某些節點具備相同的X座標值,特別是對於節點之間next間隔設置較大的情況,二叉樹的佈局就顯得不那麼美觀,如下圖所示。
四、整潔二叉樹顯示算法
根據上述算法存在的問題,我們對節點的佈局進行了一些調整和修正,優化了上述的算法使得二叉樹節點能夠更爲緊湊的顯示,在節點node的數據節點定義中引入了一個修正器變量,用於美化調整節點渲染布局。
輸入:定義良好的樹形結構的root。
輸出:每個節點的座標(x, y),假設每個節點的高度是已經知道。
算法僞碼描述:
//節點數據結構
type node
data; //節點數據
father: branch; //父節點
left_son, right_son: branch; //左右孩子節點
height: integer; //節點高度
x, y: integer; //X、Y軸座標
status: (first_visit, left_visit, right_visit); //遍歷的訪問模式
modifier: integer; //修正器
end;
//變量定義
var modifier: array[0 .. max_height] of integer;
next_pos: array[0 .. max_height] of integer;
i : integer;
place: integer;
h : integer;
is_leaf: boolean;
modifier_sum: integer;
begin
//初始化節點在每層的起始位置爲1,且無修正值
for i = 0 to max_height do
modifier[i] = 0;
next_pos[i] = 1;
end for;
current = root;
current.status = first_visit;
while current != NULL do
switch current.staus
//向左孩子遍歷
case first_visit:
current.status = left_visit;
if current.left_son != NULL then
current = current.left_son;
current.status = first_visit;
end if
break;
//向右孩子遍歷
case left_visit:
current.status = right_visit;
if current.right_son != NULL then
current = current.right_son;
current.status = first_visit;
end if;
break;
//計算該節點位置以及修正值,每層的第一個節點位置均從1開始計
case right_visit:
h = current.height;
is_leaf = (current.left_son == NULL && current.right_son == NULL);
//place是根據子節點計算父節點位置
if is_left then
place = next_pos[h];
else if current.left_son == NULL then
place = current.right_son.x – 1;
else if current.right_son == NULL then
place = current.left_son.x + 1;
else
place = (current.left_son.x + current.right_son.x) / 2;
end if;
//因爲每層第一個節點的起始位置都是1,所以需要修正
//計算修正值,即該節點應該往右移動多少距離
modifier[h] = max(modifier[h], next_pos[h] - place);
if is_leaf then
current.x = place;
else
current.x = place + modifier[h];
end if;
//該層次下一節點位置比上一節點向右移動2
next_pos[h] = current.x + 2;
//記錄該節點的修正值,代表向右移動的距離
current.modifier = modifier[h];
current = current.father;
break;
end case;
end switch;
end while;
//根據計算得到各節點的X座標和修正值計算最終的節點位置
current = root;
current.status = first_visit;
modifier_sum = 0;
while current != NULL do
switch current.status
case first_visit:
//父節點記錄的修正值,將導致其所有的子節點X座標向右偏移
current.x = current.x + modifier_sum;
modifier_sum += current.modifier;
current.y = 2 * current.height + 1;
current.status = left_visit;
if current.left_son != NULL then
current = current.left_son;
current.status = first_visit;
end if;
break;
case left_visit:
current.status = right_visit;
if current.right_son != NULL then
current = current.right_son;
current.status = first_visit;
end if;
break;
case right_visit:
//當這個節點所有的子樹全部處理完成之後,其修正值則需要復位
//從而不會影響其它子樹的向右的偏向量
modifier_sum -= current.modifier;
current = current.father;
end switch;
end while;
end;
上述算法先是確定了各節點的X座標,同時設定了同一層次當中兩個相鄰節點之間距離爲2,由於每一層第一個節點初始X座標均從1開始,則需要對每個節點設定一個向右移動的修正值,以此形成一棵比較緊湊美觀的二叉樹。此過程當中,只需要父節點記錄整體子樹向右需要的偏移量即可,其所有子節點均會按照父節點的修正值完成對應的位移,從而計算出正確的X座標。下圖展示了該算法的具體執行過程,當然這個算法整體讀起來還是有那麼點繞,不過多看幾遍就能懂了,是不是很巧妙啊~
五、表達式查詢Search算法設計
我們一般所接觸的搜索引擎都是基於關鍵字的,因爲它是一個面向大衆的通用搜索平臺,而在這個Demo當中,我們卻是切合了非常垂直的業務需求,即可以根據任意多個不同的條件短語對信息進行Search,對於表達式的Search操作自然而然提上日程。在博文《一個信息可視化Demo的設計(二):Index & Search》中,已經對Search部分設計進行了簡要介紹,本文只要講解Search表達式的解析,從而能夠形成上文中所述的各種Query對象。爲簡要說明,現在只描述表達式計算算法思想和技巧,參照了數據結構教材裏面的內容。
任何一個表達式都是由操作數、運算符和界限符組成的,其中操作數可以是常數也可以是變量或表達式,運算符可以是算術運算符、關係運算符和邏輯運算符等,基本界限符則可以是左右括弧和表達式結束符等。那麼一個表達式可以形式化表示爲:
Exp(表達式) = D1(操作數) OP(運算符) D2(操作數)則會存在三種表示形式:
OP D1 D2爲表達式的前綴表示法
D1 OP D2爲表達式的中綴表示法
D1 D2 OP爲表達式的後綴表示法
已知表達式Exp = a * b + (c – d / e) * f
前綴表達式:+ * a b * - c / d e f
中綴表達式:a * b + c – d / e * f
後綴表達式:a b * c d e / - f * +
在不同的表示法中,操作數之間的相對次序不變,但運算符之間的相對次序不同。其中,因中綴表達式丟失了原表達式中的括弧信息致使運算的次序不確定,而無法使用,前綴表達式和後綴表達式中都包含了確定的運算順序。前綴表達式的運算規則爲:連續出現的兩個操作數和在它們之前且緊靠它們的運算符構成一個最小表達式;後綴表達式的運算規則爲:每個運算符和在它之前出現且緊靠它的兩個操作數構成一個最小表達式,且運算符在後綴表達式中出現的順序恰爲表達式的運算順序。可見從後綴式很容易求得表達式的值,只要自左至右的順序掃描後綴表達式,在掃描的過程中,凡是遇到運算符即作運算,與它對應的操作數應該是在它之前剛剛掃描到的兩個操作數。爲了識別剛剛掃描過的兩個操作數,自然需要一個棧,以實現操作數後出現先運算的規則。算法中以字符串表示算術表達式,表達式尾添加“#”字符作爲結束標誌。
爲了便於簡單說明,現在限定操作數以單字母字符作爲變量名,自左至右依次識別字符串中的字符,若爲字母,則入棧;否則從棧中依次退出第二操作數和第一操作數,並作相應的運算,operate(d1, op, d2)返回d1和d2進行op運算的結果。算法中opMember(char ch)爲自定義的返回bool型值的函數,若ch是運算符,則返回True,否則返回False。對於後綴式求得表達式值的算法可概括如下:
//本函數返回由後綴式suffix表示的表達式的運算結果
double evaluation(char suffix[])
{
ch = *suffix++;
initStack(S);
while (ch != ‘#’)
{
//非運算符入操作數棧
if (! opMember(ch)) push(S, ch);
else
{
//兩個操作數出棧
pop(S, b); pop(S, a);
//作相應的運算,並將運算結果入棧
push(S, operate(a, ch, b));
}
ch = *suffix++;
}
pop(S, result);
result result;
}
現在所剩下的問題即是如何將表達式轉換成後綴式了,分析原表達式和後綴式中相應運算符所在不同位置可見,原表達式的運算符在後綴式中出現的位置取決於它本身和後一個運算符之間的優先關係。按照算術運算的規則:(1)先乘除、後加減;(2)從左算到右;(3)先括弧內、後括弧外。因此,可爲運算符設置優先數如下:
運算符 # ( + - ) / ^(乘冪)
優先數 -1 0 1 1 2 2 3
當然還有一些比如&&、||等,我們可以自己添加,上述是比較常見的。若當前運算符的優先數小於在它之後的運算符,則暫不送往後綴式,否則它在後綴式中領先於在它後的運算符。換句話說,在後綴式中,優先數高的運算符領先於優先數低的運算符。因此,從原表達式求得後綴式的規則爲:
(1)設立運算符棧;
(2)設表達式的結束符爲#,預設運算符棧的棧底爲#;
(3)若當前字符是操作數,則直接發送給後綴式;
(4)若當前字符爲運算符且優先數大於棧頂運算符,則進棧;否則退出棧頂運算符發送給後綴式;
(5)若當前字符是結束符,則自棧頂至棧底依次將棧中所有運算符發送給後綴式;
(6)“(”對它之前的運算符起隔離作用,則若當前運算符爲“(”時進棧;
(7)“)”可視爲自相應左括弧開始的表達式的結束符,則從棧頂起依次退出棧頂運算符發送給後綴式直至棧頂字符爲“)”爲止。
//從合法的表達式字符串exp求得其相應的後綴式字符串suffix
void transform(char suffix[], char exp[])
{
//預設運算符棧的棧底元素爲#
initStack(S); push(S, ‘#’);
p = exp; ch = *p; k = 0;
while(!stackEmpty(S))
{
//操作數直接發送給後綴式
if (!opMember(ch)) suffix[k++] = ch;
else
{
switch(ch)
{
//左括弧一律入棧
case ‘(’: push(S, ch); break;
case ‘)’: pop(S, c);
while(c != ‘(’)
{ suffix[k++] = c; pop(S, c); }
break;
default:
//precede(a, b)判斷運算符的優先程度,當a的優先數>=b的優先數時,返回1;否則返回0
while(getTop(S, c) && precede(c, ch))
{ suffix[k++] = c; pop(S, c); }
if (ch != ‘#’) push(S, ch);
break;
}
}
if (ch != ‘#’) ch = *++p;
}
suffix[k] = ‘\0’;
}
當然上述代碼對於我們這個Demo還並不是完全適用,我們只是借鑑了這樣的處理後綴式的思想,從而把一個Search的表達式語句轉化爲了Query對象,具體怎樣轉換,只是在算法的處理細節上有所改變和調整,這塊不再描述。
寫到這算是把MSRA/STC實習的Demo做了一個整體回顧,從第一篇博文講架構設計,第二篇博文講Index & Search,再到這篇博文講算法設計,雖然不是每個細節都涉及到了,但也差不多了。本來打算把我們做Sliverlight那個平臺組件MVC框架也用一個篇幅介紹一下的,但是考慮到現在要面臨工作了,可能也很難抽時間去整理了,那咱就有空再說吧~哈哈~