Week 2
栈和队列 Stack and Queue
栈 Stacks
- 根据取出元素的选择方式不同,有两类用于维护多个元素的数据类型:
- 栈,检查最新添加的元素(后进先出)(push和pop)
- 队列,检查最早添加的元素(先进先出)(enqueue和dequeue)
- 模块化编程:
- 接口与实现完全分离
- 客户端不清楚实现细节,意味着客户端可以选择多种实现
- 实现者不能知道客户需要什么,意味着实现可以被多次复用
- 实现者更加专注于效率
- 栈的基础API如下:
public class StackOfStrings {
StackOfStrings(); // create an empty stack
void push(String item); // insert a new string onto stack
String pop(); // remove and return the string most recently added
boolean isEmpty(); // is the stack empty?
int size(); // number of strings on the stack
}
-
栈的实现:链表
- 链表中的一项为栈中的一个元素
- 入栈,向链表头插入一个新的节点
- 出栈,取出链表头的第一个节点
- 代码实现:
public class LinkedStackOfStrings { private Node first = null; // inner class private class Node { String item; Node next; } public boolean isEmpty() { return first == null; } public void push(String item) { Node oldfirst = first; first = new Node(); first.item = item; first.next = oldfirst; } public String pop() { String item = first.item; first = first.next; return item; } }
- 性能上,最坏情况下只需要常数时间, N N N个元素的栈大约占用 40 N 40N 40N个字节
-
栈的实现:数组
- 将栈中元素存到数组中,对
N
N
N元素的栈,数组中的
s[N]
对应栈顶的位置 - 入栈,将该元素加入
s[N]
,N
加一 - 出栈,移除
s[N-1]
处的元素,N
减一 - 根本性的缺点:数组需要提前声明,因此使用前需要直到栈的大小
- 代码实现:
public class FixedCapacityStackOfStrings { private String[] s; private int N = 0; public FixedCapacityStackOfStrings(int capacity) { s = new String[capacity]; } public boolean isEmpty() { return N == 0; } public void push(String item) { s[N++] = item; } public String pop() { return s[--N]; } }
-
存在的问题和解决
- 下溢(Underflow):对空栈指定出栈操作应抛出异常
- 上溢(Overflow):调整数组的大小
- 空项目:Java实现允许插入
null
项目 - 对象游离(Loitering):出栈操作,我们仍然保持了一个本应不再可用的对象引用
public String pop() { String item = s[--N]; s[N] = null; return item; }
- 将栈中元素存到数组中,对
N
N
N元素的栈,数组中的
-
栈的实现:可调整数组大小的实现
-
调整数组大小意味着对原数组内容的复制
-
基本原则:我们允许调整大小,但是我们需要确保其不会经常发生
-
入栈,反复倍增(repeated doubling):当数组满了,就创建一个两倍大小的新数组,然后复制原有的项目
- 代码实现如下:
public ResizingArrayStackOfStrings() { s = new String[1]; } public void push(String item) { if (N == s.length) resize(2 * s.length); s[N++] = item; } private void resize(int capacity) { String[] copy = new String[capacity]; for (int i = 0; i < N; i++) copy[i] = s[i]; s = copy; }
- 插入最开始的N的项目的耗时正比于 N N N
- 整体的时间复杂度在 N + ( 2 + 4 + … + N ) ∼ 3 N N+(2+4+\ldots+N) \sim 3N N+(2+4+…+N)∼3N左右
-
出栈,当数组达到 1 4 \frac14 41满时,将尺寸收缩 1 2 \frac12 21
- 代码实现如下:
public String pop() { String item = s[--N]; s[N] = null; if (N > 0 && N == s.length/4) resize(s.length/2); return item; }
-
确保使用的内存总量是实际元素个数的整数倍
-
在最坏序列下(频繁出入栈交叉),平均运行时间与常数成正比,当栈容量翻倍时,时间正比于 N N N
-
总内存占用在大约 8 N 8N 8N(100%满)到大约 32 N 32N 32N(25%满)之间
-
-
链表实现和数组实现的比较:
- 对链表,在最坏情况下的操作是常数时间,但是需要额外的时间和空间去处理链接
- 对数组,能够更好的对每个操作分摊时间,同时有更少的浪费空间
队列 Queue
- 队列的基础API:
public class QueueOfStrings {
QueueOfStrings(); // create an empty queue
void enqueue(String item); // insert a new string onto queue
String dequeue(); // remove and return the string least recently added
boolean isEmpty(); // is the queue empty?
int size(); // number of strings on the queue
}
-
队列的实现:链表
- 使用两个指针分别维护链表的第一个和最后一个节点
- 入队时,从链表末尾添加元素
- 出队时,从链表头部取出元素
- 代码实现为:
public class LinkedQueueOfStrings { private Node first, last; private class Node { /* same as in StackOfStrings */ } public boolean isEmpty() { return first == null; } public void enqueue(String item) { Node oldlast = last; last = new Node(); last.item = item; last.next = null; if (isEmpty()) first = last; else oldlast.next = last; } public String dequeue() { String item = first.item; first = first.next; if (isEmpty()) last = null; return item; } }
-
队列的实现:可更换尺寸的数组
- 双指针,维护队列的头和尾
- 入队操作,向
q[tail]
添加元素 - 出队操作,从
q[head]
取出元素 - 当
head
或者tail
到达数组尾部时,要重置为0 - 添加调整大小的方法
泛型 Generic
- 编程原则:我们应换应编译时错误,避免运行时错误
- 使用泛型时,可以更加通用化,避免类型冗余
- 栈的单链表泛型实现(Item即泛型统配符)
public class Stack<Item>
{
private Node first = null;
private class Node
{
Item item;
Node next;
}
public boolean isEmpty()
{ return first == null; }
public void push(Item item)
{
Node oldfirst = first;
first = new Node();
first.item = item;
first.next = oldfirst;
}
public Item pop()
{
Item item = first.item;
first = first.next;
return item;
}
}
- 由于Java本身的限制,数组实现并不能直接使用泛型数组,需要进行强制转型:
public class FixedCapacityStack<Item>
{
private Item[] s;
private int N = 0;
public FixedCapacityStack(int capacity)
{ s = (Item[]) new Object[capacity]; }
public boolean isEmpty()
{ return N == 0; }
public void push(Item item)
{ s[N++] = item; }
public Item pop()
{ return s[--N]; }
}
- 由于泛型是这对
Object
及其子类的,对于基本数据类型,可以直接使用Java的自动装箱机制,实现基本数据类型和对应子类的类型转换
迭代器 Iterator
- Java提供了迭代器解决方案,可以使我们的数据类型满足用户逐个遍历元素的需求而不必关注内部实现
- 实现
Iterable
接口,就可以实现迭代功能 - 可迭代类,具有
hasNext()
和next()
两个方法的接口类,借此可以使用foreach等利用迭代器类的语法 - 基本的使用方法为(栈的单链表泛型实现):
import java.util.Iterator;
public class Stack<Item> implements Iterable<Item>
{
...
public Iterator<Item> iterator() { return new ListIterator(); }
private class ListIterator implements Iterator<Item>
{
private Node current = first;
public boolean hasNext() { return current != null; }
public void remove() { /* not supported */ }
public Item next()
{
Item item = current.item;
current = current.next;
return item;
}
}
}
- 另外一种实现方式(栈的数组泛型实现):
import java.util.Iterator;
public class Stack<Item> implements Iterable<Item>
{
…
public Iterator<Item> iterator()
{ return new ReverseArrayIterator(); }
private class ReverseArrayIterator implements Iterator<Item>
{
private int i = N;
public boolean hasNext() { return i > 0; }
public void remove() { /* not supported */ }
public Item next() { return s[--i]; }
}
}
背包 Bag
- 一种数据结构,不关心返回元素的顺序,直接向集合中插入元素,接下来遍历已有的元素
- 背包的基本API如下:
public class Bag<Item> implements Iterable<Item> {
Bag(); // create an empty bag
void add(Item x); // insert a new item onto bag
int size(); // number of items in bag
Iterable<Item> iterator(); // iterator for all items in bag
}
栈和队列的应用
- 方法调用
- 调用方法时,将本地环境和返回地址入栈
- 返回时,弹出返回地址和本地环境
- 递归就是通过栈实现的
- 我们可以显式地使用栈以避免递归
- 四则运算(Arithmetic Expression)评估
- 使用两个栈分别管理数和运算符
- 处理运算符之间的优先级
- 遇到数字,入栈
- 遇到运算符,入栈
- 遇到左括号,忽略
- 遇到右括号,出栈两个数字和一个运算符,完成对应运算后将结果入栈
- 最终结果在栈中
排序 Sort
- 排序:将数组中的N个项目重新排列成升序
- 回调:对可执行代码的引用(Java中的compareTo)
- Java的Comparable接口可以实现对对象数组的排序过程
public interface Comparable<Item>
{
public int compareTo(Item that);
}
- 返回值为整数,当前对象对比传入对象,-1为小于,0为相等,1为大于
- 通用sort方法的实现
public static void sort(Comparable[] a)
{
int N = a.length;
for (int i = 0; i < N; i++)
for (int j = i; j > 0; j--)
if (a[j].compareTo(a[j-1]) < 0)
exch(a, j, j-1);
else break;
}
- compareTo的实现:全序关系(Total Order)
- 元素在排序中能够按照特定顺序排列
- 反对称性(Antisymmetry):如果 v ≤ w v \le w v≤w且 w ≤ v w \le v w≤v,那么 v = w v = w v=w
- 传递性(Transitivity):如果 v ≤ w v \le w v≤w且 w ≤ x w \le x w≤x,那么 v ≤ x v \le x v≤x
- 完全性(Totality):要么 v ≤ w v \le w v≤w,要么 w ≤ v w \le v w≤v,要么两者相等(同时成立)
- 注意在不兼容的类型或者空指针时,抛出异常
- 排序的两个基本操作:比较和交换
private static boolean less(Comparable v, Comparable w)
{ return v.compareTo(w) < 0; }
private static void exch(Comparable[] a, int i, int j)
{
Comparable swap = a[i];
a[i] = a[j];
a[j] = swap;
}
选择排序 Selection Sort
- 从未排序的数组开始,在第
i
次迭代中,选出剩下的项中最小的一个,并交换a[i]
和a[min]
- 工作方式:不变性(Invariant),即迭代过后,第i项和左侧的元素都不再变化
public class Selection
{
public static void sort(Comparable[] a)
{
int N = a.length;
for (int i = 0; i < N; i++)
{
int min = i;
for (int j = i+1; j < N; j++)
if (less(a[j], a[min]))
min = j;
exch(a, i, min);
}
}
private static boolean less(Comparable v, Comparable w)
{ /* as before */ }
private static void exch(Comparable[] a, int i, int j)
{ /* as before */ }
}
- 选择排序需要大约 ( N − 1 ) + ( N − 2 ) + … + 1 + 0 ∼ N 2 2 (N-1)+(N-2)+\ldots+1+0 \sim \frac{N^2}2 (N−1)+(N−2)+…+1+0∼2N2次比较和 N N N次交换
- 选择排序的开销(平方时间)与序列本身的顺序无关,因为其每次都要寻找最小项
插入排序 Insertion Sort
- 第
i
次迭代中,将a[i]
与更大的元素交换,移动到其左侧的位置 - 不变性:判定位置左侧的元素必然升序,右侧还未被查看
public class Insertion
{
public static void sort(Comparable[] a)
{
int N = a.length;
for (int i = 0; i < N; i++)
for (int j = i; j > 0; j--)
if (less(a[j], a[j-1]))
exch(a, j, j-1);
else break;
}
private static boolean less(Comparable v, Comparable w)
{ /* as before */ }
private static void exch(Comparable[] a, int i, int j)
{ /* as before */ }
}
- 对一个随机顺序的序列,插入排序平均会用到 ∼ 1 4 N 2 \sim \frac14N^2 ∼41N2次比较和 ∼ 1 4 N 2 \sim \frac14N^2 ∼41N2次交换
- 最佳情况:如果数组本身已经排好序,那么只需要 N − 1 N-1 N−1次比较和 0 0 0次交换
- 最坏情况:如果数组本身是完全逆序,那么需要 ∼ 1 2 N 2 \sim \frac12N^2 ∼21N2次比较和 ∼ 1 2 N 2 \sim \frac12N^2 ∼21N2次交换
- 针对部分有序的数组,进行定量考虑
- 逆序对,数组中乱序的关键值对
- 一个数组是部分有序的,如果其逆序对数量是线性的 ≤ c N \le cN ≤cN
- 对部分有序的数组,插入排序的时间是线性的(有多少逆序对就交换多少次)
希尔排序 Shell Sort
- 每次将元素移动若干位置,成为对数组的h-排序
- h,即包含h个交叉的不同有序子序列
- 排序过程是h逐渐减小
- 基本思想是,每次排序的实现基于前面进行过的排序
- h-排序,可以使用插入排序,但步长为h
- 大步长意味着有更小的子序列(节省时间)
- 小步长意味着接近有序(基于之前的排序结果)
- 如果一个数组是g-有序的,那么经过h-排序后,其仍满足g-有序
- 间隔的选择(比较出名的是 3 N + 1 3N+1 3N+1)有待研究 1 , 5 , 19 , 41 , 109 , 209 , 505 , 929 , 2161 , 3905 , … 1, 5, 19, 41, 109, 209, 505, 929, 2161, 3905, … 1,5,19,41,109,209,505,929,2161,3905,…
public class Shell
{
public static void sort(Comparable[] a)
{
int N = a.length;
int h = 1;
while (h < N/3) h = 3*h + 1; // 1, 4, 13, 40, 121, 364, ...
while (h >= 1)
{ // h-sort the array.
for (int i = h; i < N; i++)
{
for (int j = i; j >= h && less(a[j], a[j-h]); j -= h)
exch(a, j, j-h);
}
h = h/3;
}
}
private static boolean less(Comparable v, Comparable w)
{ /* as before */ }
private static void exch(Comparable[] a, int i, int j)
{ /* as before */ }
}
- 使用 3 N + 1 3N+1 3N+1的增量,最坏的情况下,需要比较的次数是 O ( N 3 2 ) \Omicron(N^{\frac32}) O(N23)
- 实践中的优点:
- 在数组不大的情况下,速度比较快
- 代码量很小(基本固定),可用于嵌入式系统或者是硬件排序
排序的应用:混淆 Shuffling
- 混淆(洗牌)排序:对于每一个数组项目(牌),随机生成一个实数,并对这个数组排序,排序的结果就是随机混淆的结果
- 在输入没有重复值的前提下,上述这种混淆方法能够产生一个均匀随机排列
- Knuth混淆:实现上述过程的线性算法
- 在第
i
次迭代,从0
到i
均匀随机选择一个整数r
- 交换
a[i]
和a[r]
- 在第
public class StdRandom
{
...
public static void shuffle(Object[] a)
{
int N = a.length;
for (int i = 0; i < N; i++)
{
int r = StdRandom.uniform(i + 1);
exch(a, i, r);
}
}
}
凸包 Convex Hull
-
凸包:能包含所有点的最小凸多边形
-
程序计算得到凸包,应当输出够成凸包的顶点序列,若集合中某些点位于凸包的边上但不是顶点,这些点就不应在输出序列中
-
顶点序列按照逆时针顺序输出
-
两个重要的几何性质
- 能够只做逆时针旋转的情况下遍历整个凸包
- 去y轴最低点,每个点相对于该点的极角呈增长顺序
-
葛立恒(Graham)扫描法:
- 选择一个y轴最低点
- 按照各点极角进行排序
- 按顺序考察每一个点,除非够成逆时针,否则忽略这些点
-
寻找最低点、按照极角排序:排序方法’
-
判断逆时针旋转点:几何学计算
- 基本:左转不右转
- 对给定点 a , b , c a,b,c a,b,c,计算行列式,得到两倍区域面基 ( b − a ) × ( c − a ) = ( b x − a x ) ( c y − a y ) − ( b y − a y ) ( c x − a x ) (b-a)\times(c-a)=(b_x-a_x)(c_y-a_y)-(b_y-a_y)(c_x-a_x) (b−a)×(c−a)=(bx−ax)(cy−ay)−(by−ay)(cx−ax)
- 如果面积值大于零,则a到b到c是逆时针
- 如果面积值小于零,则是顺时针
- 如果等于零,三点共线
public class Point2D { private final double x; private final double y; public Point2D(double x, double y) { this.x = x; this.y = y; } ... public static int ccw(Point2D a, Point2D b, Point2D c) { double area2 = (b.x-a.x)*(c.y-a.y) - (b.y-a.y)*(c.x-a.x); if (area2 < 0) return -1; // clockwise else if (area2 > 0) return +1; // counter-clockwise else return 0; // collinear } }
-
高效排序:归并排序, N log N N \log N NlogN