算法导论小结(一)

用了几个月磕磕绊绊的总算把《算法导论》一书看完了,在此写篇博客总结一下学习到的知识。
首先先放上《算法导论》的思维导图:
算法导论思维导图
由于本人的理解能力有限,故部分较难懂的内容没有加入到该思维导图中。

排序

排序问题是我们日常生活中经常遇到的一个问题,因此算法导论也把排序作为整个算法介绍的入门篇。在这么多排序算法里面,目前经典的排序算法有以下几种:

插入排序

对于少量元素的排序,插入排序是一个有效的算法。它的工作方式就像我们排序扑克牌一样,每次把一张新的扑克牌插入到已经排好序的序列中。假设排序序列的长度为n,由于插入排序每次加入一个新的元素都需要遍历几乎整个排好序的序列,故他的时间复杂度为$O(n^2)$。
以下为插入排序的Java代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class Algorithm {  

public static void main(String[] args) {
int A[] = {5, 2, 4, 6, 1, 3};

InsertionSort(A);

for(int num : A)
System.out.println(num);
}

// 插入排序
public static void InsertionSort(int A[]) {
for (int i = 1; i < A.length; i++) {
int key = A[i];
int j = i-1;
while(j >= 0 && A[j] > key) {
A[j+1] = A[j];
j--;
}
A[j+1] = key;
}
}

}

冒泡排序

冒泡排序与插入排序类似,也是一种较为简单的排序算法。冒泡排序会重复地走访过要排序的数列,一次比较两个元素,如果他们的顺序错误就把他们交换过来,因此越大的元素会经由交换慢慢“浮”到数列的顶端,这就是这种排序方法命名的原因。由于需要两层循环遍历数组,所以冒泡排序的时间复杂度为$O(n^2)$。
以下为冒泡排序的Java代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class Algorithm {  

public static void main(String[] args) {
int A[] = {5, 2, 4, 6, 1, 3};

BubbleSort(A);

for(int num : A)
System.out.println(num);
}

// 冒泡排序
public static void BubbleSort(int A[]) {
for (int i = 0; i < A.length; i++) {
for (int j = 1; j < A.length - i; j++) {
// 如果前面的元素比后面的大,则发生交换
if (A[j-1] > A[j]) {
int temp = A[j];
A[j] = A[j-1];
A[j-1] = temp;
}
}
}
}
}

实际上面的冒泡排序代码还可以进行一点小优化,要是循环中没有发生交换则可直接退出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 冒泡排序(优化)  
public static void BubbleSortII(int A[]) {
boolean swap = false; // 是否发生过交换
for (int i = 0; i < A.length; i++) {
swap = false;
for (int j = 1; j < A.length - i; j++) {
// 如果前面的元素比后面的大,则发生交换
if (A[j-1] > A[j]) {
swap = true;
int temp = A[j];
A[j] = A[j-1];
A[j-1] = temp;
}
}
// 没有发生过交换,已经排序完毕,可跳出循环
if (swap == false)
break;
}
}

归并排序

要提到归并排序就不得不讲到分治法(Divide and Conquer),分治法的思想是:把原问题分解成为几个规模较小,但类似于原问题的子问题,递归的去求解这些子问题,然后再合并这些子问题的解来建立原问题的解。归并排序就是是采用分治法的一个非常典型的应用。它是建立在归并操作上的一种有效的排序算法,该算法将已有序的子序列合并,得到完全有序的序列。用扑克牌来举例:在排序一副扑克牌的时候,我们现将其分成两叠大小相近的扑克牌,分别排序,然后我们再把这两叠已经排好序的扑克牌合并成一副更大的排好序的扑克牌,此时只需要每次比较两叠扑克牌的第一张牌即可。归并排序的图解如下:
归并排序图解
设序列中有N个元素需要排序,则由上图易得可以把排序过程分为logN(以2为低的对数)次处理,每次循环N次,故归并排序的时间复杂度为$O(n*logn)$。
归并排序的Java代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
public class Algorithm {  

public static void main(String[] args) {
int A[] = {5, 2, 4, 6, 1, 3};

MergeSort(A, 0, A.length-1);

for(int num : A)
System.out.println(num);
}

public static void MergeSort(int A[], int start, int end) {
if (start < end) {
// 中点
int mid = (start+end)/2;
// 子序列分别排序
MergeSort(A, start, mid);
MergeSort(A, mid+1, end);
// 合并
// 把子序列存到新数组中
int leftLen = mid-start+1, rightLen = end-mid;
int leftCounter = 0, rightCounter = 0, numCounter = start;
int L[] = new int[leftLen], R[] = new int[rightLen];
for (int i = 0; i < leftLen; i++)
L[i] = A[start+i];
for (int i = 0; i < rightLen; i++)
R[i] = A[mid+1+i];
// 比较子序列第一项元素
while(leftCounter < leftLen && rightCounter < rightLen) {
if(L[leftCounter] < R[rightCounter])
A[numCounter++] = L[leftCounter++];
else
A[numCounter++] = R[rightCounter++];
}
// 把剩余的子序列加到后面
while(leftCounter < leftLen)
A[numCounter++] = L[leftCounter++];
while(rightCounter < rightLen)
A[numCounter++] = R[rightCounter++];
}
}
}

非递归版本的归并排序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
public class Algorithm {  

public static void main(String[] args) {
int A[] = { 5, 2, 4, 1, 3, 6};

MergeSort(A);

for (int num : A)
System.out.println(num);
}

public static void MergeSort(int A[]) {
int len = A.length;
int temp[] = new int[len];
int leftMin, leftMax, rightMin, rightMax; // leftMin ~ leftMax, rightMin
// ~ rightMax
for (int i = 1; i < len; i *= 2) {
leftMin = leftMax = rightMin = rightMax = 0;
while (leftMin < len) {
rightMin = leftMax = leftMin + i;
rightMax = rightMin + i;
if (rightMax > len)
rightMax = len;
if (rightMin > rightMax)
leftMax = rightMin = rightMax;
int counter = 0;
while (leftMin < leftMax && rightMin < rightMax)
temp[counter++] = A[leftMin] > A[rightMin] ? A[rightMin++]
: A[leftMin++];

while (leftMin < leftMax)
A[--rightMin] = A[--leftMax];

while (counter > 0)
A[--rightMin] = temp[--counter];
leftMin = rightMax;
}
}
}
}

堆排序

首先我们来看看什么是堆:
堆图解
如上图所示(二叉)堆是一个数组,它可以被看成一个近似的完全二叉树,树上每一个结点对应数组中的一个元素。除了最底层外,该树是完全充满的,而且是从左到右填充。在堆中,给定一个结点下标i(对于起始下标为1而言),则它的父节点为i/2,它的左孩子下标为i2,右孩子下标为i2+1。堆中结点的高度被定义为该结点到叶结点的最长简单路径,由数学公式可得含N个元素的堆高度为logN。
二叉堆可以分为两种形式:最大堆和最小堆,他们除了满足堆的基本性质外,最大堆满足:除了根结点外,所有结点的值小于等于父节点,最小堆反之。在堆排序算法中,我们使用最大堆,最小堆通常用于构造优先队列。
以下为Java的堆排序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
public class Algorithm {  

public static void main(String[] args) {
int A[] = { 5, 2, 4, 6, 1, 3 };

HeapSort(A);

for (int num : A)
System.out.println(num);
}

public static void HeapSort(int A[]) {
BuildMaxHeap(A);
for (int i = A.length - 1; i > 0; i--) {
// 把根结点和最后的结点对调
int temp = A[i];
A[i] = A[0];
A[0] = temp;
// 对根结点进行最大堆性质维护
MaxHeapify(A, i, 0);
}
}

/**
* 建立最大堆
*
* @param A
* 数组
*/
private static void BuildMaxHeap(int A[]) {
int heapSize = A.length;
// heapSize/2 ~ heapSize-1 均为叶结点,对非叶结点调用维护最大堆性质方法即可
for (int i = heapSize / 2 - 1; i >= 0; i--)
MaxHeapify(A, heapSize, i);
}

/**
* 维护最大堆的性质,调整下标为i的结点位置
* @param A 数组
* @param heapSize 堆大小
* @param index 结点下标
*/
private static void MaxHeapify(int A[], int heapSize, int index) {
int left = index*2+1, right = index*2+2, largest = index;
// 选取父结点,左结点,右结点中值最大的当父结点
if (left < heapSize && A[left] > A[index])
largest = left;
if (right < heapSize && A[right] > A[largest])
largest = right;
// 若子结点充当了父结点,对子结点递归调用方法维护最大堆性质
if (largest != index) {
int temp = A[largest];
A[largest] = A[index];
A[index] = temp;
MaxHeapify(A, heapSize, largest);
}
}
}

首先来看看MaxHeapify方法,该方法是用于维护最大堆性质的方法。若方法调整的结点发生了交换,则对其子结点递归的调用该方法继续维护最大堆性质,故该方法的调用次数与堆的高度有关,时间复杂度为$O(h) = O(logN)$。

再来看看BuildMaxHeap方法,该方法用于把一个无序的数组构造成一个最大堆。该方法自底向上对非叶结点调用MaxHeapify,咋看其时间复杂度为$O(N*logN)$,但由数学推导可得其紧确时间复杂度为线性时间,此处不给出证明。

最后再来看HeapSort方法,堆排序首先把一个数组构造成最大堆,然后每次让堆的根结点(堆最大的元素)和堆最后的结点交换,并减少堆的大小,然后再对根结点调用MaxHeapify方法调整其位置。堆排序总共调用了N次MaxHeapify方法,故其时间复杂度为$O(N*logN)$

快速排序

快速排序也被称为霍尔排序,虽然快速排序的最坏时间复杂度为$O(N^2)$,但是快速排序通常是实际排序应用中最好的选择,因为他的平均性能很好,期望时间复杂度为$O(N*lgN)$。快速排序与归并排序类似,都使用了分治思想。快速排序每次从数组中选择一个元素作为主元,把比主元小的元素放在其前面,把比主元大的元素方法主元的后面,然后再对其前后两个子数组进行相同的操作。
快速排序的Java代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public class Algorithm {  

public static void main(String[] args) {
int A[] = {5, 2, 4, 6, 1, 3};

QuickSort(A, 0, A.length-1);

for(int num : A)
System.out.println(num);
}

public static void QuickSort(int A[], int start, int end) {
if (start < end) {
// 主元
int key = A[end];
int i = start-1;
for (int j = start; j < end; j++) {
// 比key小的数放在前面
if (A[j] < key) {
i++;
int temp = A[j];
A[j] = A[i];
A[i] = temp;
}
}
i++;
A[end] = A[i];
A[i] = key;
// 对子数组进行同样的操作
QuickSort(A, start, i-1);
QuickSort(A, i+1, end);
}
}
}

上面的代码固定选取当前数组的最后一个元素作为主元,如果想要快速排序的平均性能更好,可以随机选取数组中的元素作为主元来减少出现最坏情况的概率。