Chapter 2
插入排序
線性查找
選擇算法
歸并排序算法
二分查找算法
冒泡排序
插入排序
INSERTION-SORT(A)
for j=2 to A.length
key = A[j]
//insert A[j] into the sortes sequence A[1...j-1]
i = j-1
while i>0 and A[i]>key
A[i+1] = A[i]
i = i-1
A[i+1] = key
function insertion_sort(arr) {
var key = 0;
var i = 0;
for(var j = 1; j < arr.length; j++) {
key = arr[j];
i = j - 1;
while (i >= 0 && arr[i] > key) {
arr[i+1] = arr[i];
i = i - 1;
}
arr[i+1] = key;
}
return arr;
}
循環不變式
初始化:循環第一次迭代之前,它為真
保持:如果循環的某次迭代之前它為真,那么下次迭代之前它仍為真
終止:在循環終止時,不變式為我們提供一個有用的性質,該性質有助于證明算法是正確的
算法分析
INSERTION-SORT(A) | 代價 | 次數 |
---|---|---|
for j=2 to A.length | c1 | n |
key = A[j] | c2 | n-1 |
//insert A[j] into the sortes sequence A[1...j-1] | 0 | n-1 |
i = j-1 | c4 | n-1 |
while i>0 and A[i]>key | c5 | ∑tj (2<=j<=n) |
A[i+1] = A[i] | c6 | ∑(tj - 1) (2<=j<=n) |
i = i-1 | c7 | ∑(tj - 1) (2<=j<=n) |
A[i+1] = key | c8 | n-1 |
tj 表示對那個值j第5行執行while循環測試的次數
所以,T(n) = c1n + c2(n-1) + c4(n-1) + c5∑tj (2<=j<=n) + c6∑(tj - 1) (2<=j<=n) + c7∑(tj - 1) (2<=j<=n) + c8(n-1)
最佳情況:對j=2, 3, ..., n,有tj = 1,所以 T(n) = (c1 + c2 + c4 + c5 +c8)n - (c2 + c4 + c5 + c8) = Θ(n)
最壞情況:對j=2, 3, ..., n,有tj = j,所以 ∑tj (2<=j<=n) = ∑j (2<=j<=n) = n(n+1)/2 - 1, ∑(tj - 1) (2<=j<=n) = n(n-1)/2, T(n) = (c5 + c6 + c7)n^2/2 + (c1 + c2 + c4 + c5/2 - c6/2 - c7/2 + c8)n - (c2 + c4 + c5 + c8) = Θ(n^2)
分治模式
分治模式在每層遞歸時都有三個步驟:
分解原問題為若干個子問題,這些字問題是原問題的規模較小的實例。
解決這些子問題,遞歸地求解各子問題。然而,若子問題的規模足夠小,則直接求解。
合并這些子問題的解成原問題的解。
歸并排序算法
歸并排序算法完全遵循分治模式。直觀上其操作如下:
分解:分解待排序的n個元素的序列成各具n/2個元素的兩個子序列。
解決:使用歸并排序遞歸地排序兩個子序列。
合并:合并兩個已排序的子序列以產生已排序的答案。
通過調用一個輔助過程MERGE(A, p, q, r)來完成兩個已排序序列的合并,其中A是一個數組,p、q和r是數組下標,滿足 p<=q<r。該過程假設子數組A[p..q]和A[q+1..r]都已排好序。它合并兩個子數組形成單一的已排好序的子數組并代替當前的子數組A[p..r]。
MERGE(A, p, q, r)
n1 = q - p + 1
n2 = r - q
Let L[1..n1+1] and R[1..n2+1] be new arrays
for i = 1 to n1
L[i] = A[p + i - 1]
for j = 1 to n2
R[j] = A[q+j]
//插入哨兵牌
L[n1 + 1] = ∞
R[n2 + 1] = ∞
i = 1
j = 1
for k = p to r
if L[i] <= R[j]
A[k] = L[i]
i = i + 1
else A[k] = R[j]
j = j + 1
MERGE-SORT(A, p, r)
if p < r
q = └(p + r)/2┘
MERGE-SORT(A, p, q)
MERGE-SORT(A, q + 1, r)?
MERGE(A, p, q, r)
分析分治算法
分治算法運行時間的遞歸式來自基本模式的單個步驟。假設T(n)是規模為n的一個問題的運行時間。若問題規模足夠小,如對某個常量c,n<=c,則直接求解需要常量時間,將其記作Θ(1)。假設把原問題分解成a個子問題,每個子問題的規模是原問題的1/b。為了求解一個規模為n/b的子問題,需要T(n/b)的時間,所以,需要aT(n/b)的時間來求解a個子問題,如果分解問題成子問題需要時間D(n),合并子問題的解成原問題的解需要C(n),那么得到遞歸式:
若n<=c,T(n) = Θ(1);其他,T(n) = aT(n/b) + D(n) + C(n)
所以,歸并排序的最壞情況運行時間T(n)的遞歸式:
若n=1,T(n) = Θ(1);若n>1,T(n) = 2T(n/2) + Θ(n)
等價于:若n=1,T(n) = c;若n>1,T(n) = 2T(n/2) + cn
對遞歸式T(n) = 2T(n/2) + cn構造遞歸樹,如下:

所以,T(n) = Θ(nlgn)
練習
2.1-2 重寫INSERTION-SORT,使之按非升序排列
INSERTION-SORT2(A)
for j=2 to A.length
key = A[j]
i = j-1
while i > 0 and A[i] < key
A[i+1] = A[i]
i = i-1
A[i+1] = key
function insertion_sort2(arr) {
var key = 0;
var i = 0;
for(var j = 1; j < arr.length; j++) {
key = arr[j];
i = j - 1;
while (i >= 0 && arr[i] < key) {
arr[i+1] = arr[i];
i = i - 1;
}
arr[i+1] = key;
}
return arr;
}
2.1-3 考慮一下查找問題:
輸入:n個數的一個序列 A=<a1, a2, ..., an> 和一個值v
輸出:下標i是的 v=A[i] 或者當v不在A中出現時,v為特殊值NIL
寫出線性查找的偽代碼,它掃描整個序列來查找v。使用一個循環不變式來證明你的算法是正確的。確保你的循環不變式滿足三條必要的性質。
LINEAR-SEARCH(A, v)
for i=1 to A.length
if A[i] equals v
return i
return NIL
function linear_search(arr, v) {
for(var i = 0; i < arr.length; i++) {
if (arr[i] === v) {
return i+1;
}
}
return null;
}
2.1-4 考慮把兩個n位二進制整數加起來的問題,這兩個整數分別存儲在兩個n元數組A和B中。這兩個整數的和應按二進制形式存儲在一個(n+1)元數組C中。請給出該問題的形式化描述,并寫出偽代碼。
輸入:兩個n元數組A和B
輸出:一個(n+1)元數組C
BINARY-ADD(A, B)
for i=A.length to 1
tmp = A[i] + B[i] + tmp;
if tmp > 1
C[i+1] = tmp - 2
tmp = 1
else C[i+1] = tmp
tmp = 0
C[1] = tmp
function binary_add(arr1, arr2) {
var arr3 = [];
var tmp = 0;
for(var i = arr1.length-1; i >= 0; i--) {
tmp = arr1[i] + arr2[i] + tmp;
if (tmp > 1) {
arr3[i+1] = tmp - 2;
tmp = 1;
}
else {
arr3[i+1] = tmp;
tmp = 0;
}
}
arr3[0] = tmp;
return arr3;
}
2.2-1 用Θ記號表示函數n3/1000-100n2-100n+3
Θ(n^3)
2.2-2 考慮排序存儲在數組A中的n個數:首先找出A中的最小元素并將其與A[1]中的元素進行交換。接著,找出A中的次最小元素并將其與A[2]中的元素進行交換。對A中前n-1個元素按該方式繼續。這算法稱為選擇算法,寫出其偽代碼。該算法維持的循環不變式是什么?為什么它只需要對前n-1個元素,而不是對所有n個元素運行?用Θ記號給出選擇排序的最好情況與最壞情況運行時間
SELECTION(A)
for j=1 to A.length-1
min = A[j]
pointer = j
for i=j to A.length
if A[i] < min
min = A[i]
pointer = i
A[pointer] = A[j]
A[j] = min
function selection(arr) {
var min = 0;
var pointer = 0;
for(var j = 0; j < arr.length - 1; j++) {
min = arr[j];
pointer = j;
for(var i = j; i <arr.length; i++) {
if (arr[i] < min) {
min = arr[i];
pointer = i;
}
}
arr[pointer] = arr[j];
arr[j] = min;
}
return arr;
}
因為第n個元素在進過前n-1個循環后一定是最大的,所以不需要對它在進行操作。
Θbest = Θ(n^2)
Θworest = Θ(n^3) Θ(n^2)
2.2-3 再次考慮線性查找問題(參見練習2.1-3)。假定要查找的元素等可能地為數組中的任意元素,平均需要檢查的輸入序列的多少元素?最壞情況又如何?用Θ記號給出線性查找的平均情況和最壞情況運行時間。證明你的答案
平均情況 T(n) = 1/(n+1) + 2/(n+1) + 3/(n+1) + ... + n/(n+1) + n/(n+1) = (n^2 + 3n)/2(n+1) = Θ(n)
最壞情況 T(n) = Θ(n)
2.3-2 重寫過程MERGE,使之不使用哨兵,而是一旦數組L或R的所有元素均被復制回A就立刻停止,然后把兩一個數組的剩余部分復制回A
MERGE2(A, p, q, r)
n1 = q - p + 1
n2 = r - q
let L[1..n1] and R[1.. n2] be new arrays
for i = 1 to n1
L[i] = A[p + i - 1]
for j = 1 to n2
R[j] = A[q + j]
i = 1
j = 1
for k = p to r
if (L[i] <= R[j] or j > n2)
A[k] = L[i]
i = i + 1
else if (L[i] > R[j] or i > n1)
A[k] = R[j]
j = j + 1
var arr = [10,7,3,9,2,9,4,5,6,11];
function merge2(p, q, r) {
var n1 = q - p + 1;
var n2 = r - q;
var arrL = [];
var arrR = [];
var i = 0, j = 0;
for (i = 0; i < n1; i++) {
arrL[i] = arr[p + i];
}
for (j = 0; j < n2; j++) {
arrR[j] = arr[q + j + 1];
}
i = 0;
j = 0;
var k = 0;
for (k = p; k < r + 1; k++) {
if (arrL[i] <= arrR[j] || j >= n2) {
arr[k] = arrL[i];
i = i + 1;
} else if (arrL[i] > arrR[j] || i >= n1) {
arr[k] = arrR[j];
j = j + 1;
}
}
}
function merge_sort(p, r) {
var mid = 0;
if (p < r) {
mid = Math.floor((p + r)/2);
merge_sort(p, mid);
merge_sort(mid+1, r);
merge2(p, mid, r);
}
}
merge_sort(0, arr.length-1);
console.log(arr);
2.3-4 我們可以把插入排序表示為如下的一個遞歸過程。為了排序A[1..n],我們遞歸地排序A[1..n-1],然后把A[n]插入到已排序的數組A[1..n-1]。為插入排序的這個遞歸版本的最壞情況運行時間寫一個遞歸式
如果n = 1,T(1) = 1;
如果n > 1,將A[n]插入A[1..n-1]需要比進行n-1次比較,所以 T(n) = T(n-1) + c(n-1)
2.3-5 回顧線性查找問題,注意到,如果序列A已排好序,就可以將該序列的中點與v進行比較。根據比較的結果,原序列中有一半就可以不用再做進一步的考慮了。二分查找算法重復這個過程,每次都將序列剩余部分的規模減半。為二分查找寫出迭代或遞歸的偽代碼。證明:二分查找的最壞運行時間為Θ(lgn)
BINARY-SEARCH(A, key)
start = 1
end = A.length
while (start <= end)
i = └(start + end) / 2┘
if (A[i] == key)
return i
else if (A[i] > key)
end = i - 1
else if (A[i] < key)
start = i + 1
return "NF"
function binary_search(array, key) {
var start = 0, end = array.length-1;
var i = 0;
while (start <= end) {
i = Math.floor((start + end)/2);
if (array[i] === key) {
return i;
} else if (array[i] > key) {
end = i - 1;
} else if (array[i] < key) {
start = i + 1;
}
}
return "NF";
}
如果n = 1,T(1) = 1;
如果n > 1, 最差的情況就是比較到最后一位,得出結論,所以T(n)=lgn
2.3-6 注意插入排序的第5~7行的while循環采用一種線性查找來(反向)掃描已排好序的子數組A[1..j-1]。我們可以使用二分查找來把插入排序的最壞情況運行總時間改進到Θ(nlgn)嗎?
INSERTION-SORT2(A)
for i=2 to A.length
newA = A.slice(0,1)
key = A[i]
answer = BINARY-SEARCH(newA, key)
for j=i down to answer
A[j] = A[j-1]
A[answer] = key
function binary_search(array, key) {
var start = 0, end = array.length-1;
var i = 0;
while (start <= end) {
i = Math.floor((start + end)/2);
if (array[i] === key) {
return i;
} else if (array[i] > key) {
end = i - 1;
} else if (array[i] < key) {
start = i + 1;
}
}
return (start < end)? end: start;
}
function insertion_sort2(array) {
for (var i = 1; i < array.length; i++) {
var new_array = array.slice(0, i);
var key = array[i];
var answer = binary_search(new_array, key);
for(var j = i; j > answer; j--) {
array[j] = array[j-1];
}
array[answer] = key;
}
return array;
}
2.3-7 描述一個運行時間為Θ(nlgn)的算法,給定n個整數的集合S和另一個整數x,該算法能確定S中是否存在兩個其和剛好為x的元素。
function determineSumX(array, x) {
var filtered = array.filter(function(e) {
return e<x;
});
var sorted = insertion_sort2(filtered);
var start = 0, end = sorted.length-1;
while(start < end) {
if (sorted[start] + sorted[end] === x) {
return true;
} else if (sorted[start] + sorted[end] < x) {
start = start + 1;
} else if (sorted[start] + sorted[end] > x) {
end = end - 1;
}
}
return false;
}
function binary_search(array, key) {
var start = 0, end = array.length-1;
var i = 0;
while (start <= end) {
i = Math.floor((start + end)/2);
if (array[i] === key) {
return i;
} else if (array[i] > key) {
end = i - 1;
} else if (array[i] < key) {
start = i + 1;
}
}
return (start < end)? end: start;
}
function insertion_sort2(array) {
for (var i = 1; i < array.length; i++) {
var new_array = array.slice(0, i);
var key = array[i];
var answer = binary_search(new_array, key);
for(var j = i; j > answer; j--) {
array[j] = array[j-1];
}
array[answer] = key;
}
return array;
}
2.1 (在歸并排序中對小數組采用插入排序)雖然歸并排序的最壞情況運行時間為Θ(nlgn),而插入排序的最壞運行時間為Θ(n^2),但是插入牌組中的常量因子可能使得它在n較小時,在許多機器上實際運行得更快。因此,在歸并排序中,當子問題變得足夠小時,采用插入排序來使遞歸的葉變粗是有意義的。考慮對歸并排序的一種修改,其中使用插入排序來排序長度為k的n/k個子表,然后使用標準的合并機制來合并這些子表,這里k是一個待定的值。
a. 證明:插入排序最壞的情況可以在Θ(nk)時間內排序每個長度為k的n/k個子表。
b. 表明在最壞的情況下如何在Θ(nlg(n/k))時間內合并這些子表。
c. 假定修改后的算法的最壞情況運行時間為Θ(nk+nlg(n/k)),要使修改后的算法與標準的歸并排序具有相同的運行時間,作為n的一個函數,借助Θ記號,k的最大值是什么?
d. 在實踐中,我們應該如何選擇k?
a. 因為插入排序的最壞運行時間為Θ(n2),所以對于長度為k的1個子表,最壞運行時間為Θ(k2);
又因為一共有n/k個子表,所以,最壞運行時間為(n/k)*Θ(k^2) = Θ(nk)。
b. 由結果遞歸樹可得,最小的葉子節點為cn/k,共有k個葉子節點,所以樹的高度為lg(n/k),又每層將貢獻總代價cn,所以,總代價為cn(lg(n/k)+1),也就是Θ(nlog(n/k))。
c. Θ(nk + nlg(n/k)) = Θ(nlgn),=>Θ(k+lg(n/k)) = Θ(lgn),所以k<lgn,k的最大值應該是lgn。
d. 這是個實驗問題,應該在k的合法范圍內測試可能的k,用T-INSERTION-SORT(k)表示k個元素的插入排序時間,T-MERGE-SORT(k)表示k個元素的合并排序時間。該問題等價于測試求解T-INSERTION-SORT(k)/T-MERGE-SORT(k)比值最小的k值。
2.2 (冒泡排序算法的正確性)冒泡排序算法是一種流行但低效的排序算法,它的作用是反復交換相鄰的未按次序排列的元素。
BUBBLESORT(A)
1 for i = 1 to A.length - 1
2 for j = A.length downto i + 1
3 if A[j] < A[j-1]
4 exchange A[j] with A[j-1]
a. 假設A′表示BUBBLESORT(A)的輸出。為了證明BUBBLESORT正確,我們必須證明它將終止并且有:
A'[1] ≤ A'[2] ≤ ... ≤ A'[n] (2.3)
其中 n=A.length。為了證明BUBBLESORT確實完成了排序我們還需要證明什么?
下面兩個部分將證明不等式(2.3)。
b. 為第2~4行的for循環精確地說明一個循環不變式,并證明該循環不變式成立。你的證明應該使用本章中給出的循環不變式證明的結構。
c. 利用在(b)部分證明的循環不變式的終止條件,為第1~4行的for循環說明一個循環不變式,它可以用來證明不等式(2.3)。你的證明應采用本章中給出的循環不變式的證明結構。
d. 冒泡排序算法的最壞情況運行時間是多少?與插入排序的運行時間相比,其性能如何?
a. 當n=1時,能夠正確排序。
b. 對第2~4行的for循環,循環不變式是A[j]是子數組A[j…n]中的最小值,且子數組中的元素并未改變。約定:n=A.length。
初始化:開始時,j=n,子數組中只包含A[n],故循環不變式成立
保持:假設對于任意的一個j,使得A[j]是子數組A[j…n]中的最小值,在下一輪循環中,若A[j] < A[j-1],則A[j]和A[j-1]交換。使得A[j-1]是子數組A[j-1…n]中的最小值,循環不變式依然成立
終止:循環結束時j=i,A[j]是子數組A[j…n]中的最小值,且子數組中的元素并未改變。
c. 對于1~4行的for循環,循環不變式是每次循環前,A[1…i-1]中包含了整個數組中前i-1小的排好序的元素,而A[i…n]中包含剩下的元素。
初始化:第一次循環前i=1,子數組為空,循環不變式成立
保持:假設對于任意一個i,使得A[1…i-1]中包含了整個數組中前i-1小的排好序的元素,而A[i…n]中包含剩下的元素,則內層循環保證了A[i]是子數組A[i…n]中的最小元素,則A[1…i]中包含了整個數組中前i小的排好序的元素,而A[i+1…n]中包含剩下的元素。循環不變式成立
終止:循環結束時i=n+1,則A[1…n]中包含了整個數組中前n小的排好序的元素,即數組有序。
d. 冒泡排序最壞和最好運行時間均為Θ(n^2)
插入排序的最壞運行時間為Θ(n^2),但是最好運行時間為Θ(n)
排序前A所有元素已經有序時,插入排序達到最好運行時間。
2.3 (霍納(Horner)規則的正確性)給定系數a0, a1, …, an和x的值,代碼片段
1 y = 0
2 for i = n downto 0
3 y = ai + x·y
實現了用于求值多項式
的霍納規則。
a. 借助Θ記號,實現霍納規則的以上代碼片段的運行時間是多少?
b. 編寫偽代碼來實現樸素的多項式求值算法,該算法從頭開始計算多項式的每個項。該算法的運行時間是多少?與霍納規則相比,其性能如何?
c. 考慮以下循環不變式:
在第2~3行for循環每次迭代的開始有
把沒有項的和式解釋為等于0。遵照本章中給出的循環不變式證明的結構,使用該循環不變式來證明終止時有
d. 最后證明上面給出的代碼片段將正確地求由系數a0, a1, …, an刻畫的多項式的值。
a. Θ(n)
b. 偽代碼如下:
y = 0
tmp = 1
for k = 0 to n
y = y + ak * tmp
tmp = tmp * x
該算法的時間復雜度為:Θ(n^2)
c. 證明如下:
初始化:i=n時,y=0
保持:對于任意 0 ≤ i ≤ n,迭代的開始有y=Σ a[k+i+1] * x^k =a[k+i+1] + a[k+i+2] * x + ... + an * x^(n-(i+1)),循環后有:y[i] = a[i] + y[i+1] * x = a[i] + (a[i+1] * x + a[i+2] * x + ... + a[n] * x^(n-(i+1))) * x = a[i] + a[i+1] * x + a[i+2] * x^2 + ... + a[n] * x^(n-i)
終止:i=0時,循環終止,i=0開始前有y=Σ a[k+1] * x^k = a[1] + a[2] * x + a[n] * x^(n-1),執行循環,y=a0 + x * [a[1] + a[2] * x + a[n] * x^(n-1)] = a0 + a[1]x + a[2]x^2 + ... + a[n]*x^n。
d. <我并沒有看懂>
2.4 (逆序對)假設A[1..n]是一個有n個不同數的數組。若i < j且A[i] > A[j],則對偶(i, j)稱為A的一個逆序對(inversion)。
a. 列出數組 <2, 3, 8, 6, 1> 的5個逆序對。
b. 由集合 {1, 2,…,n} 中的元素構成的什么數組具有最多的逆序對?它有多少逆序對?
c. 插入排序的運行時間與輸入數組中逆序對的數量之間是什么關系?證明你的回答。
d. 給出一個確定在n個元素的任何排列中逆序對數量的算法,最壞情況需要 Θ(nlgn) 時間。(提示:修改歸并排序。)
a. (2, 1), (3, 1), (8, 6), (8, 1), (6, 1)
b. <n, n-1, n-2, ..., 1>擁有最多的逆序對,共有 n*(n-1)/2
c. 逆序對越多,插入排序的復雜度越大
排序n個數,如果不存在逆序對,則插入排序的復雜度為Θ(n),每多一個,復雜度加1
d. 如下:
MERGE(A, p, q, r)
n1 = q - p + 1
n2 = r - q
Let L[1..n1+1] and R[1..n2+1] be new arrays
for i = 1 to n1
L[i] = A[p + i - 1]
for j = 1 to n2
R[j] = A[q+j]
//插入哨兵牌
L[n1 + 1] = ∞
R[n2 + 1] = ∞
i = 1
j = 1
count = 0
for k = p to r
if L[i] <= R[j]
A[k] = L[i]
i = i + 1
else A[k] = R[j]
j = j + 1
count = count + 1