`
jja1982
  • 浏览: 112325 次
  • 性别: Icon_minigender_1
  • 来自: 杭州
社区版块
存档分类
最新评论

数据结构与算法分析 Java语言描述 读书笔记

阅读更多
第1章 引论
递归的四条基本法则
1. 基准情况。必须总要有某些基准情况,它无需递归就能解出。
2. 不断推进。对于那些需要递归求解的情况,每一次递归调用都必需要使状况朝向一种基准情况推进。
3. 设计法则。假设所有的递归调用都能运行。
4. 合成效益法则。在求解一个问题的同一实例时,切勿在不同的递归调用中做重复性的工作。

Java泛型
class MyClass<AnyType extends Comparable<? super AnyType>>

第2章 算法分析
典型的增长率
c   logN   log2N   N   NlogN   N2   N3   2N

递归过程调用的一般形式是传递输入的数组以及左边界和右边界,它们界定了数组要被处理的部分。单行驱动程序通过传递数组以及边界0和N – 1而将该过程启动。

对数最常出现的规律可概括为下列一般法则:如果一个算法用常数事件(O(1))将问题的大小削减为其一部分(通常是1/2),那么该算法就是O(logN)。另一方面,如果使用常熟事件只是把问题减少一个常数的数量(如将问题减少1),那么这种算法就是O(N)的。

第3章 表、栈和队列
LinkedList的实现数据结构是双向链表。

当直接使用Iterator(而不是通过一个增强的for循环间接使用)时,重要的是要记住一个基本法则:如果对正在被迭代的集合进行结构上的改变(即对该集合使用add、remove或clear方法),那么迭代器就不再合法(并且在其后使用该迭代器时会有ConcurrentModificationException异常被抛出)为避免迭代器准备给出某一项作为下一项(next item)而该项此后或者被删除,或者也许一个新的项正好插入该项的前面这样一些讨厌的情况,有必要记住上述法则。这意味着,只要在需要立即使用一个迭代器的时候,我们才应该获取迭代器。然而,如果迭代器调用了它自己的remove方法,那么这个迭代器就仍然是合法的。

尾递归涉及在最后一行的递归调用。尾递归可以通过将代码放到一个while循环中并用每个方法参数的一次赋值代替递归调用而被手工消除。

递归总能够被彻底去除(编译器是在转变成汇编语言时完成递归去除的),但是这么做是相当冗长乏味的。一般方法是要求使用一个栈,而且仅当你能够把最低限度的最小值放到栈上时这个方法才值得一用。

第4章 树
二叉查找树,其深度的平均值是O(logN)。查找、插入、删除平均时间O(logN)。
二叉查找树的插入
private BinaryNode<AnyType> insert(AnyType x, BinaryNode<AnyType> t) {
		if (t == null) {
			return new BinaryNode<AnyType>(x, null, null);
		}
		int compareResult = x.compareTo(t.element);
		if (compareResult > 0) {
			t.right = insert(x, t.right);
		} else if (compareResult < 0) {
			t.left = insert(x, t.left);
		} else {
			;
		}
		return t;
	}


二叉查找树的删除
一般的删除策略是用其右子树的最小的数据代替该节点的数据并递归地删除那个节点。
private BinaryNode<AnyType> remove(AnyType x, BinaryNode<AnyType> t) {
		if (t == null) {
			return null;
		}
		int compareResult = x.compareTo(t.element);
		if (compareResult > 0) {
			t.right = remove(x, t.right);
		} else if (compareResult < 0) {
			t.left = remove(x, t.left);
		} else if (t.left != null && t.right != null) {
			t.element = findMin(t.right).element;
			t.right = remove(t.element, t.right);
		} else if (t.left != null) {
			t = t.left;
		} else {
			t = t.right;
		}
		return t;
	}


如果删除的次数不多,通常使用的策略是懒惰删除:当一个元素要被删除时,它仍留在书中,而只是被标记为删除。

AVL树是带有平衡条件的二叉查找树。它保证树的深度须是O(logN)。一棵AVL树是其每个节点的左子树和右子数的高度最多差1的二叉查找树。

伸展树,它保证从空树开始连续M次对树的操作最多花费O(MlogN)时间。伸展树基于这样的事实:对于二叉查找树来说,每次操作最坏情况时间O(N)并不坏,只要它相对不常发生就行。伸展树的基本想法是,当一个节点被访问后,它就要经过一系列AVL树的旋转被推到根上。

一个完全二叉树的高度大约为log2N,而一个完全M叉树的高度大约是logMN。
B树有深度O(logM/2N)。

TreeSet和TreeMap使用自顶向下的红黑树实现。

第5章 散列
散列表的大小通常是素数。

装填因子为散列表中的元素个数对该表大小的比。散列表的大小实际上并不重要,而装填因子才是重要的。分离链接散列法的一般法则是使得表的大小与预料的元素个数大致相当。对于不适用分离连接的散列表来说,其装填影子应该低于0.5(探测散列表)。

在探测散列表中标准的删除操作不能执行,因为相应的单元可能已经引起过冲突,元素绕过它存在了别处。因此,探测散列表需要懒惰删除,不过这种情况下实际上并不存在所意味的懒惰。

HashSet和HashMap通常是用分离链接散列实现的。

散列表操作中费时多的部分就是计算hashCode值。该值初始为0,但若hashCode被调用,那么这个值就被记住。因此,如果hashCode对同一个String对象被第2次计算,我们则可以避免昂贵的重新计算。这种技巧叫做闪存散列代码,并且表示另一种经典的时空交换。闪存散列代码之所以有效,只是因为String类是不可改变的:要是String允许变化,那么它就会使hashCode无效,而hashCode就只能重置回0。

第6章 优先队列(堆)
优先队列是允许至少下列两种操作的数据结构:insert(插入),它的作用是显而易见的以及deleteMin(删除最小者),它的工作是找出、返回并删除优先队列中最小的元素。Insert操作等价于enqueue(入队),而deleteMin则是队列运算dequeue(出队)在优先队列中的等价操作。

二插堆是一颗被完全填满的二叉树,有可能的例外是在底层,底层上的元素从左到右填入。对于数组中任一位置i上的元素,其左儿子在位置2i上,右儿子在左儿子的单元(2i+1)上,它的父亲则在位置i/2上。

由于每个insert将花费O(1)的平均时间以及O(logN)的最坏情况时间,因此该算法的中的运行时间是O(N)平均时间而不是O(NlogN)最坏情况时间。

Java中优先队列的实现PriorityQueue。

第7章 排序
插入排序O(N2),而且这个界是精确的,因为以反序的输入可以达到该界。如果输入数据已预先排序,那么运行时间为O(N),因为内层for循环的检测总是立即判定不成立而终止。事实上,如果输入几乎被排序,那么插入排序将运行的很快。插入排序的平均情况O(N2)。

希尔排序O(N2),希尔排序的性能在实践中是完全可以接受的,即使是对于数以万计的N仍是如此。编程的简单特点使它成为对适度的大量的输入数据经常选用的算法。
public static <AnyType extends Comparable<? super AnyType>> void shellSort(AnyType[] a) {
		for(int gap = a.length / 2; gap > 0; gap /= 2) {
			for(int i = gap; i < a.length; i++) {
				int j;
				AnyType temp = a[i];
				for(j = i; j >=gap && a[j].compareTo(a[j - gap]) < 0; j -= gap) {
					a[j] = a[j - gap];
				}
				a[j] = temp;
			}
		}
	}


堆排序O(NlogN)。
private static int leftChild(int i) {
		return 2 * i + 1;
	}

	private static <AnyType extends Comparable<? super AnyType>> void percDown(AnyType[] a, int i, int n) {
		AnyType temp;
		int child;
		for (temp = a[i]; leftChild(i) < n; i = child) {
			child = leftChild(i);
			if (child != n - 1 && a[child].compareTo(a[child + 1]) < 0) {
				child++;
			}
			if (temp.compareTo(a[child]) < 0) {
				a[i] = a[child];
			} else {
				break;
			}
		}
		a[i] = temp;
	}

	private static <AnyType extends Comparable<? super AnyType>> void heapSort(AnyType[] a) {
		for (int i = a.length / 2; i >= 0; i--) {
			percDown(a, i, a.length);
		}
		for (int i = a.length - 1; i > 0; i--) {
			swapReferences(a, 0, i);
			percDown(a, 0, i);
		}
	}

	private static <AnyType extends Comparable<? super AnyType>> void swapReferences(AnyType[] a, int i, int j) {
		AnyType temp = a[i];
		a[i] = a[j];
		a[j] = temp;
	}
	


归并排序以O(NlogN)最坏情况时间运行,而所使用的比较次数几乎是最优的。但是它有一个明显的问题,即合并两个已排序的表用到线性附加内存。
在Java中,当执行一次泛型排序时,进行一次元素比较可能是昂贵的(因为比较可能不容易被内嵌,从而动态调度的开销可能会减慢执行的速度),但是移动元素则是省时的(因为它们是引用的赋值,而不是庞大对象的拷贝)。归并排序使用所有流行的排序算法最少的比较次数,因此是使用Java的通用排序算法中的上好选择。事实上,它就是标准Java类库中泛型排序所使用的算法。
private static <AnyType extends Comparable<? super AnyType>> void mergeSort(AnyType[] a, AnyType[] temp, int left, int right) {
		while (left < right) {
			int center = (left + right) / 2;
			mergeSort(a, temp, left, center);
			mergeSort(a, temp, center + 1, right);
			merge(a, temp, left, center + 1, right);
		}
	}

	private static <AnyType extends Comparable<? super AnyType>> void merge(AnyType[] a, AnyType[] temp, int leftPos, int rightPos, int rightEnd) {
		int leftEnd = rightPos - 1;
		int tempPos = leftPos;
		int numberElement = rightEnd - leftPos + 1;
		while (leftPos <= leftEnd && rightPos <= rightEnd) {
			if (a[leftPos].compareTo(a[rightPos]) <= 0) {
				temp[tempPos++] = a[leftPos++];
			} else {
				temp[tempPos++] = a[rightPos++];
			}
		}
		while (leftPos <= leftPos) {
			temp[tempPos++] = a[leftPos++];
		}
		while (rightPos <= rightPos) {
			temp[tempPos++] = a[rightPos++];
		}
		for (int i = 0; i < numberElement; i++, rightEnd--) {
			a[rightEnd] = temp[rightEnd];
		}
	}


快速排序的平均运行时间是O(NlogN),最坏情况性能是O(N2),当输入是正序或反序时达到最坏情况。

对于很小的数组(N<=20),快速排序不如插入排序。不仅如此,因为快速排序是递归的,所以这样的情况经常发生。通常的解决方法是对于小的数组不适用递归的快速排序,而代之以插入排序这样的对小数组有效地排序算法。
private void quickSort(AnyType[] a, int left, int right) {
		AnyType pio = median3(a, left, right);
		int i = left;
		int j = right - 1;
		for(;;) {
			while(a[++i].compareTo(pio) < 0){}
			while(a[--j].compareTo(pio) > 0){}
			if(i < j) {
				swap(a, i, j);
			} else {
				break;
			}
		}
		swap(a, i, right - 1);
		quickSort(a, left, i - 1);
		quickSort(a, i + 1, right);
	}
	
	private AnyType median3(AnyType[] a, int left, int right) {
		int center = (left + right) /2;
		if(a[center].compareTo(a[left]) < 0) {
			swap(a, left, center);
		}
		if(a[right].compareTo(a[left]) < 0) {
			swap(a, left, right);
		}
		if(a[right].compareTo(a[center]) < 0) {
			swap(a, center, right);
		}
		swap(a, center, right - 1);
		return a[right - 1];
		
	}
	
	private void swap(AnyType[] a, int i, int j) {
		AnyType temp = a[i];
		a[i] = a[j];
		a[j] = temp;
	}


桶式排序。输入数据A1,A2,…,AN,必须只由小于M的正整数组成。使用一个大小为M的称为count的数组,它被初始化为全0。于是,count有M个单元或称桶,这些桶初始化为空。当读Ai时,count[Ai]增1。在所有的输入数据读入后,扫描数组count,打印出排序后的表。O(N),尽管桶式排序看似太一般而用处不大,但是实际上却存在许多其输入只是一些小整数的情况,使用像桶式排序这样的排序方法真的是小题大作了。

第8章 不相交集类
不相交集(等价类)支持union/find操作,M次union和find的运行时间为O(MlogN)。

生成迷宫的一个简单算法是从各处的墙壁开始(除入口和出口之外)。此时,我们不断地随机选择一面墙,如果被该墙分隔的单元彼此不连通,那么我们就把这面墙拆掉。如果我们重复这个过程直到开始单元和终止单元连通,那么我们就得到一个迷宫。实际上不断的拆掉墙壁直到每一个单元都可以从每个其他单元到达就更好(这就会是迷宫产生更多误导的路径)。

第9章 图论算法
图的表示方法:邻接矩阵、邻接表
邻接表是表示图的标准方法。

拓扑排序O(|E| + |V|)
void topsort() throws CycleFoundException {
	Queue<Vertcx> q = new Queue<Vertex>();
	int count = 0;
	for each Vertex v
		if(v.indegree == 0)		
			q.enqueue(v);
	while(!q.isEmpty()) {
		Vertex v = q.dequeue();
		v.topNum = ++counter;
		for each Vertex w adjacent to v
			if(--w.indegree == 0)
				q.enqueue(w);
	}
	if(counter != NUM_VERTICES)
		throw new CycleFoundException();
}

广度优先搜索(breadth-first search)。该方法按层处理顶点:距开始点最近的那些顶点首先被求值,而最远的那些顶点最后被求值。这很像对数的层序遍历。

单源无权最短路径
void unweighted(Vertex s) {
	Queue<Vertcx> q = new Queue<Vertex>();
	for each Vertex v
		v.dist = INFINITY;
	s.dist = 0;
	q.enqueue(s);
	while(!q.isEmpty()) {
		Vertex v = q.dequeue();
		for each Vertex w adjacent to v
			if(w.dist == INFINITY) {
				w.dist = v.dist + 1;
				w.path = v;
				q.enqueue(w);
			}
	}
}

只是用邻接表,这运行时间就是O(|E| + |V|)。

解决单源最短路径的一般方法为Dijkstra算法。是贪婪算法最好的例子。
void dijkstra(Vertex s) {
	for each Verte v {
		v.dist = INFINITY;
		v.known = false;
	}
	s.dist = 0;
	for(;;) {
		Vertex v = smallest unknown distance vertex;
		if(v == NOT_A_VERTEX)
			break;
		v.known = true;
		for each Vertex w adjacent to v
			if(!w.known)
				if(v.dist + cvw < dist) {
				// update w
				decrease(w.dist to v.dist + cvw);
				w.path = v;
			}
	}
}


最小生成树算法
Prim算法O(|E|log|V|)。
算法在每个阶段都可以通过选择边(u,v)使得(u,v)的值是所有u在树上但v不再树上的边的值中的最小者而找出一个新的顶点并把它添加到这棵树上。
Prim算法基本上和Dijkstra算法相通,更新法则为:在每一个顶点v被选取以后,对于每一个与v邻接的未知的w,dw = min(dw,cw,v)。

Kruskal算法O(|E|log|V|)。
连续地选择最小的权选择边,并且当所选的边不产生圈时就把它作为所取定的边。
void kruskal() {
	int edgesAccepted = 0;
	DisjSet ds = new DisjSets(NUM_VERTICES);
	PriorityQueue<Edge> pq = new PriorityQueue<Edge>(getEdges());
	Edge e;
	Vertex u, v;
	while(edgesAccepted < NUM_VERTICES - 1) {
		e = pq.deleteMin(); // Edge e = (u,v)
		SetType uset = ds.find(u);
		SetType vset = ds.find(v);
		if(uset != vset) {
			// Accept the edge
			edgesAccepted++;
			ds.union(uset, vset);
		}
	}
}

深度优先搜索(depth-first search)是对先序遍历的推广。
void dfs(Vertex v) {
	v.visited = true;
	for each Vertex w adjacent to v
		if(!w.visited)
			dfs(w);
}

第10章 算法设计技巧
贪婪算法(greedy algorithm)。贪婪算法分阶段的工作。在每一个阶段,可以认为所做决定是好的,而不考虑将来的后果。通常,这意味着选择的是某个局部最优。这种“眼下能够拿到的就拿”的策略是这类算法名称的来源。当算法中止时,我们希望局部最优等于全局最优。如果是这样的话,那么算法就是正确的;否则,算法得到的是一个次优解。如果不要求绝对最佳答案,那么有时使用简单的贪婪算法生成近似的答案,而不是使用通常产生准确答案所需要的复杂算法。

分治算法(divide and conquer)。分治算法有两部分组成:
分:递归解决较小的问题(当然,基本情况除外)。
治:然后从子问题的解构造原问题的解。
传统上,在正文中智商含有两个递归调用的例程叫做分治算法,而正文中只含有一个递归调用的例程不是分治算法。一般坚持子问题是不相交的(即基本不重叠)。

动态规划(dynamic programming)。
任何数学递推公司都可以直接转换成递归算法,但是基本实现是编译器常常不能正确对待递归算法,结果导致低效的程序。当换衣很可能是这种情况时,我们必须再给编译器提供一些帮助,将递归算法重新写成非递归算法,让后者把那些子问题的答案系统地记录在一个表内。

随机化方法(randomized algorithm)

回溯算法(backtracking)

附录为相应数据结构的Java实现
0
0
分享到:
评论

相关推荐

Global site tag (gtag.js) - Google Analytics