array indexing

array indexing

「索引」是電腦的絕技!一個元素存放到陣列之後,不論是在陣列的哪個地方,只要利用索引值(index),就能一瞬間找到元素。大多數的演算法都運用了「索引」的技巧,讓程式執行時間更短。

以下介紹索引的常見運用方式。

一、定位

將物件放入陣列中,array[位置] = 物件。

當元素很多時,我們可以放到陣列裡。我們只要記錄索引值,依舊可以常數時間得到元素。

範例:求最大值

將元素連續地放入陣列,若想記錄一元素,僅需一索引值。

範例:求子字串

將元素連續地放入陣列,若想記錄一區間,僅需頭尾的索引值。

範例:連續數字和

將元素連續地放入陣列,利用問題本身的數學性質以及索引值,快速得到答案。

範例:求中位數

將元素依照大小順序並連續地放入陣列,利用索引值得到位於中間的元素。

範例:二元搜尋法(binary search)

將元素依照大小順序並連續地放入陣列,然後夾擠索引值。

如果元素放入串列,則沒有索引值,無法使用二元搜尋法。

範例:二元樹(binary tree)

元素的索引值對應到樹的結構,是一種特殊的定位。

範例:堆疊(stack)、佇列(queue)

元素連續地放入陣列,然後以改變索引值的方式,來動態增減堆疊及佇列的元素。

二、歸類並標記

物件直接作為陣列的索引值,array[物件] = 物件的屬性。

範例:正整數集合

物件:正整數。物件的屬性:是否在集合裡頭出現。

範例:統計英文字母出現次數

物件:英文字母。物件的屬性:英文字母的出現次數。

範例:計數排序法(counting sort)

索引值的大小順序,恰是元素的大小順序,亦可用於排序。

範例:雜湊表(hash table)

元素的索引值由特殊方法決定,是一種特殊的歸類。

三、轉換

array[物件] = 另一個物件。概念等同數學術語「函數」。

範例:取代(substitution)、移位(transposition)

密碼學的基礎概念。取代是文字的轉換,移位是位置的轉換。

定址的時間複雜度

當索引值大小為N,有人認為定址的時間複雜度是O(log₂N),也有人認為是O(1)。兩種說法都有依據。

數學的觀點:N總共有log₂N個位元,運用二元樹的觀念,依照各個位元的數值是0或是1進行分枝,分枝到底後就完成定址了。所以定址的時間複雜度是O(log₂N)。

電路的觀點:一顆中央處理器可以平行處理32位元(現在已有64位元),只要是介於0到2³²-1的索引值,都可以在1單位時間完成定址,而不必用32單位時間來完成定址。所以定址的時間複雜度是O(1)。

大家傾向假設定址的時間複雜度是O(1)。

定址的範圍

方才提到一顆中央處理器可以平行處理32位元,理論上可以定址到2³²以內的位址。一個位址一般擁有1byte的記憶體大小,所以可以運用的記憶體就有2³²byte = 4GB 這麼多。

但是作業系統會保留一些位址、預留一些記憶體空間以維持系統運作,所以使用者實際可以運用的記憶體其實不到4GB。

當記憶體沒有插到4GB的時候,作業系統利用一種叫做virtual memory的技術,以硬碟空間補足記憶體不足4GB的部份。

位址是連續不斷的,我們寫程式也都直接假設位址對應到的記憶體空間是連續不斷的,然而實際上並不是連續的。作業系統運用一種叫做paging的技術,藉由page table,讓記憶體看起來是連續的。

recursion

recursion

在函式內部呼叫原本的函式,叫做「遞迴」。

遞迴與迴圈一樣,將一件事情重複很多次,每次都改變一點點。遞迴與迴圈一樣,必須設定起始條件、結束條件、改變量,以避免無窮遞迴。

範例:階乘

1乘以2乘以3……乘以n。

範例:兔子數列

0 1 1 2 3 5 8……,求第n項。公式f(n) = f(n-1) + f(n-2)。

迴圈輔以堆疊才能樹狀分枝。遞迴可以輕易地樹狀分枝。

UVa 110 177 183 839

作業系統的記憶體管理

作業系統執行一支程式,首先會將程式指令載入到記憶體空間,另外還會預留固定的記憶體空間以便執行程式,稱作call stack。

其內容包含:一、呼叫函式所傳入的參數。二、執行函式所宣告的變數。三、結束函式所回去的位址(某一行程式指令的位址)。

call stack的行為,宛如資料結構stack。每當開始使用一個函式,就把對應資料放入call stack。每當一個函式結束使用,就從call stack移除對應資料。函式層層呼叫,對應資料層層疊起。

作業系統規定了一支程式的call stack記憶體空間大小。例如Windows是1MB,Linux是8MB(不超過page table一個欄位的空間大小)。超過限制,稱作stack overflow。此時作業系統將立即中止程式,以策安全,稱作crash。

遞迴容易引發stack overflow

每當使用函式,就會使用call stack。

人類設計程式,在函式內部呼叫函式,深度大約十層、二十層。call stack足以承擔。

至於遞迴,不斷地在函式內部呼叫原本的函式,輕輕鬆鬆達到成千上萬層。call stack極易耗盡,引發stack overflow。

call stack的原始用途不包括成千上萬層的遞迴。這是個怪招,平常沒人這樣做,大家當作娛樂看看就好。儘管在C++裡面是怪招,但是在Haskell裡面卻是基本功。

recursion = loop + stack

遞迴的運作原理,其實是迴圈和堆疊。凡是遞迴能做到的,迴圈和堆疊也能做到。原理就是作業系統的call stack。

如果遞迴深度只有幾層,那麼大家習慣以遞迴代替迴圈和堆疊。事情交給作業系統的既有機制,不必額外建立迴圈和堆疊。

如果遞迴深度達到成千上萬層,那麼大家習慣以迴圈和堆疊代替遞迴。如此一來,可以節省許多記憶體空間:呼叫函式所傳入的參數(視 情況可以重複使用)、結束函式所回去的位址(完全不需要)。另外也可以節省一些執行時間。

interval

array indexing

數10個數字,一般而言,從1開始,到10結束。

數10格陣列,有些不同,從0開始,到9結束。

陣列索引值,從0開始。原因可能是指標。存取陣列首格,不需要將記憶體位址+1。

loop

迴圈索引值,從0開始,到n-1結束。原因是陣列。

小技巧:為了減少計算步驟-1,於是<= n-1改成< n。

結束條件是等於,明確合理,「這裡就是終點」。

結束條件是小於,不夠直覺,「似乎還沒到終點」。

為了減少計算步驟-1,只好採用不夠直覺的方式。

interval

數字0到9:左閉右閉區間[0,9],左閉右開區間[0,10)。

正統的做法是左閉右閉[0,9]。

為了使用迴圈小技巧,大家習慣採用左閉右開[0,10)。

左閉右開的優點:iterator

有些資料結構,例如串列list,只能單向移動,不能雙向移動。只能+1,不能-1。這種情況只能採用左閉右開,避免-1。

例如C++標準函式庫,循序存取資料結構的功能iterator。當遇到單向移動的資料結構,只能++,不能--。導致iterator的成員begin和end,採用左閉右開。導致內建函式的參數,採用左閉右開。

左閉右開的優點:減少計算步驟-1

許多經典演算法,可以使用左閉右開,減少計算步驟-1。

左閉右開的優點:計算區間大小不必-1

計算區間的數字數量,可以使用左閉右開,減少計算步驟-1。

左閉右開的優點:空區間不會遭遇負數

[0,0]是一個數字0,[0,-1]是沒有數字。

[0,1)是一個數字0,[0,0)是沒有數字。

例如unsigned int,採用左閉右開可以避免向下溢位。

左閉右開的缺點:最大區間不會涵蓋整數最大值

左閉右開也有缺點。

例如涵蓋unsigned int最大值,區間右值會向上溢位。此時只能使用左閉右閉。

例如C++標準函式庫,隨機整數生成器uniform_int_distribution採用左閉右閉,隨機浮點數生成器uniform_real_distribution採用左閉右開,規則無法統一。

metaprogramming

metaprogramming

設計一支程式來製造程式碼,令該程式碼充分運用程式語言自身擁有的能力,輕鬆地、更有效率地完成更多事情。

範例:四則運算式

5 + 8 × (2 - 3) + 7 × -6 / (2 - 1) + 1

身經百戰的演算法設計高手,必然不假思索說出:stack,把所有符號依序放入stack,依照運算符號的優先次序push和pop。聽來簡單,實作起來還是頗麻煩。

這裡要介紹的是更輕鬆、更強悍的方法:寫程式製造一支會進行四則運算的程式。大家都知道C/C++的語法當中,就有四則運算的語法了。現在來設計一支程式,製作出四則運算的程式碼吧!

如果輸入方才的四則運算式,就會產生如下程式碼,檔名為temp.cpp。

然後編譯temp.cpp、執行一下,就有答案了。甚至可以把編譯、執行的指令,統統寫進程式碼當中:

範例:quine

一支程式,其功能是輸出本身程式碼。純娛樂。

template metaprogramming

C有個功能叫macro,可以代換程式碼。C++有個更厲害的功能叫template,可以遞迴代換程式碼。

運用template,編譯時期即可計算答案,令答案變成程式碼的一部分;執行時期不必花時間計算,直接印出答案!

不過這是個怪招,平常沒人這樣做,大家當作娛樂看看就好。儘管在C++裡面是怪招,但是在Haskell裡面卻是基本功。

範例:階乘

task🚧

process management

Java memory model

行程管理問題、行程管理策略:

race condition
concurrency control

memory management

想要了解作業系統,也許從記憶體管理開始講起。

作業系統/虛擬機器,記憶體空間劃分為多種區塊,作為不同用途。每種作業系統/虛擬機器的劃分方式大同小異,跟程式有關的記憶體區塊大致相同:heap和stack。

heap事先不知道記憶體用量(動態)。stack事先知道記憶體用量(靜態)。

注意到,作業系統heap和stack、資料結構heap和stack,兩者不是相同事物。

幾個比較有名的作業系統/虛擬機器的記憶體劃分方式:

Linux分為kernel virtual memory、user stack、shared library、runtime heap、……。

user stack大致就是先前提到的call stack,差別在於user stack額外包含使用者編號、程序編號、執行時刻、使用權限、……。不過目前大家傾向將兩者視作相同事物,方便解釋。

https://hemantra.medium.com/linux-memory-management-all-you-need-to-know-d1dbdda8b386

Java的JVM額外劃分出metaspace和code。網路上每個人畫出來的示意圖都不一樣,非常奇葩。

https://techvidvan.com/tutorials/java-virtual-machine/

JavaScript的V8則是把code放在heap。

每種程式語言各有一套記憶體分類方式。有趣的是,登場場合不一致。顯然大家尚未取得共識。有些是語法關鍵字,有些是標準函式庫,有些是編譯器。

幾個比較有名的程式語言的記憶體分類方式:

C的memory layout有四種:text、data、heap、stack。Linux記憶體管理方式正是依循這個分類方式。Java和JavaScript以及其他諸多程式語言也跟進這個分類方式。【尚待確認】

C++的storage duration有四種。有些對應到heap,有些對應到stack。【待補表格】

記憶體管理問題、記憶體管理策略:

memory leak
garbage collection

memory fragmentation
storage allocation