Gtx CS1332x I Notes
==
检测Object
类的 equals()
方法来判断。上面两种比较的主要应用发生在 Primitives type 与 Object type 之间。
==
来判断:
int one1 = 1;
int one2 = 1;
int two1 = 2;
one1 == one2; // => true
one1 == two1; // => false
String literals:String literal 会以常量的方式存储在 String pool 中。通过赋值创建的 String Literal 也没有值和引用等价的区分:
String literal = "This is a string.";
literal == "This is a string."; // => true
但如果以对象的方式创建 String Literal (或者说是以 String Literal 为初始值创建对象),那么值等价和引用等价是会区分开的。
//ref equality
String object = new String("This is a string.");
object == "This is a string.";
//value equality
object.equals("This is a string."); // => true
null
使用 ==
来确定(null & 非 null 对象均可使用)
String nullObject = null;
nullObject == null; // => true
null
,调用 equal()
会导致 NPE (NullPointerException )异常null
对象可以调用 equal()
与 null / null 对象比较
String normalObject = "normal";
normalObject.equals(null); // => false
nullObject.equals(normalObject); // causes NullPointerException
Wrapper Objects的比较 与 String Literal 类似,也分值等价和引用等价两种情况:
Integer primitive = 1;
Integer object = new Integer(1);
primitive == 1; // => true
object == 1; // => true
primitive == object; // => false
primitive.equals(object); // => true
object.equals(primitive); // => true
指在运行期,对象类型与生命类型不匹配。解决方式:使用带 type parameter 的特化版本:
public class Container<T> {
private T t;
public void set(T t) {
this.t = t;
}
public T get() {
return t;
}
}
特化版本会在编译器确定类型是否匹配。
//def
class ArrayList<T> { ... }
//declar
ArrayList<Integer> listOne = new ArrayList<>();
//declar
T[] backingArray = (T[]) new Object[10];
class HashMap<K, V> { ... }
HashMap<String, Integer> map = new HashMap<>();
ArrayList<BSTNode<T>> nodeList = new ArrayList<>();
Java 提供了 Iterable 接口;任何实现该接口的类都可以使用 for-each loop。使用需要导入 java.util.iterator
实现上:
iterator()
方法,该方法会返回一个 iterator 对象。Iterator 也可以单独使用。需要重写的方法:
next()
方法:返回当前 iterator 指向的对象,然后将 iterator 移动至下一个对象hasNext()
方法:当 iterator 指向的对象值不为 null
时,返回 true
remove()
方法:移除 next()
返回的对象两种简单的示例如下:
import java.util.Iterator;
public class BookList<Book> implements Iterator<Book> {
//override
public void next() {}
public boolean hasNext() {}
public static void main(String[] args) {
//usage suppose bookList is an object with type BookList<Book>
while(bookList.hasNext())
{
System.out.println(BookList.next());
}
}
}
public class BookList<Book> implements Iterable<Book> {
//In this case, Iterator function needs to be override
public Iterator<Book> Iterator() {....}
public static void main(String[] args) {
}
for(Book book : bookList) {
System.out.println(book);
}
}
可见 Iterable 是基于 Iterator 实现的。
Comparable
允许直接将自身与目标进行比较(使用 comareTo
方法,单参数)
//public int compareTo(T y) ;
x.compareTo(y);
Comparator
并不比较自身与目标,而是比较两个参数(使用 compare()
方法,双参数)
public int compare(T x, T y);
comp.compare(x, y);
两者的返回值意义相同:
实现上:
Comparable
的实现需要实现 Comparable
接口,并重写 compareTo
方法Comparable
已经包含在 java.uitl
中,因此不需要任何导入
public class HDTV implements Comparable<HDTV>{
....
public int compareTo(HDTV tv) {
....
}
}
Comparator
的实现需要导入 java.uitl.Comarator
Comparator
需要实现的接口是 Comparator
,需要重写 compare()
,接收的是两个参数
class SizeComparator implements Comparator<HDTV> {
public int compare(HDTV tv1, HDTV tv2) {
//....
}
}
compareTo
,而不是关系运算符compareTo()
的返回值通常是正 / 负 / 零,而不是 [-1,0,1]算法在高级层面上通过如下的基础操作(primitive instruactions)进行衡量:
这些基础操作的共同点是,都能在常数时间内完成。因此,统计他们的总次数,就能直观的反应算法的复杂度。
常数因子 & low order term 在很多特定的条件下是需要考虑的(比如限定了输入次数的场景)。
为什么访问 array 元素的复杂度是 $O(1)$?
因此,只需要三次次算术运算 (new_address = start_address+i * data_size) 即可获取当前访问元素的地址;所以复杂度为 $O(1)$
resize
,空间增长会申请全新的空间,并将旧元素拷贝过来10
java.util
ArrayList<String> list = new ArrayList<>();
null
是不行的)由于 resize 不是经常发生的,因此 add to the back 的复杂度可以按照 amortized analysis 进行分析;也就是将 resize 的 $O(n)$ 视作多次 $O(1)$ 的操作。因此,可以说 add to the back 的效率为 $\texttt{amortized}(O(1))$ 。
Amortized Analysis 是一种关注于平均计算成本的分析方法。通常的方法是以操作为单位来衡量计算成本,但有的时候某些高成本的操作出现的几率并不是很大,因此这种情况下使用此类操作来作为 worst case 讨论该算法的意义并不是很大。
以之前的 addToTheBack 为例,通常操作的复杂度为 $O(1)$,而 resize 时的复杂度为 $O(n)$;由于 resize 并不是很常见,那么以 $O(n)$ 来衡量 addTOTheBack 的复杂度显然不太精确。这种情况下,我们可以采用 Amortized Analysis 的方法来进行分析
</WRAP>
null
作为代替除非特别必要,本课程中均为 hard removeal。
函数调用自身的方式被称为 Recursion。
if (baseCase) {
//termination
}
else {
// recursive call logic
}
递归的一大重点就是将问题分解为子问题。一些策略:
使用递归形式的辗转相除法(Euclidean algorithm):
x mod y== 0
gcd(x,y) = gcd(y, x mod y)
head
:指向链表的头元素current
:在遍历中,指向当前元素tail
:指向链表的尾部元素size
:记录链表的长度size
加一size
减一T data
,指向下个节点的指针 Node<T> next
链表的遍历基于 head
和 current
:
current = head
current == null
while (current != null) {
current = current.next;
}
算法步骤:
next
指向 head
指向的内容head
原有的指向变更为被创建的节点public void addingToFront(T data)
{
Node<T> newNode = new Node<>(data);
newNode.next = head;
head = newNode;
}
算法步骤:
while(current.next != null)
)next
指向新创建的节点边界条件:
head
指向新创建的节点即可。public void addingToBack(T data)
{
if(head == null) {
head = new Node<T> (data);
}
else {
Node<T> current = head;
while(current.next != null) {
current = current.next;
}
current.next = new Node<T> (data);
}
}
注意循环条件是 while(current.next != null)
而不是 while(current == null)
:
current
在循环之后还是有效的,可以被操作的节点。循环完毕后,current
指向尾节点。current
在循环中是有效的,可以被操作的节点。循环完毕后,current
不指向任何有效的节点。
因为 addingToBack() 的添加元素实现要求 current
在循环后有效,因此使用 current.next
作为判定条件。
算法步骤:
null
head
重新指向第二个节点即可边界条件:
null
public void removingFromFront()
{
if(head == null) {
return;
}
else {
head = head.next;
}
}
返回 null
使用 return;
语句
算法步骤:
null
head = null
next
指向设置为 null
边界条件:
null
head = null
public void removingFromBack()
{
if(head == null) {
return;
}
else if (head.next == null) {
head = null;
}
else {
Node<T> current = head;
while(current.next.next != null) {
current = current.next;
}
current.next = null;
}
}
while(current.next.next != null)
确保循环结束后,current
指向倒数第二个节点。因为删除最后一个节点需要对倒数第二个节点进行操作。
tail
指针指向链表中最后一个节点。维护该指针可以在使用 addingToBack() 的时候避免重复遍历链表($O(n)$ to $O(1)$)tail
指针对 removeFromBack() 没有任何优化。该方法需要访问倒数第二个节点,而不是最后一个节点。tail
指针使用的边界条件:head
和 tail
都应该为 null
head
和 tail
都指向该节点。如果试图删掉该节点,那么 head
和 tail
都应该为 null
Iterable
的类Iterator
的类,用于支持 Iterable
中的 Iterator()
方法的重写Iterator
的时候需要重写:next()
hasNext()
问题描述:
给定有序链表,以递归的方式移除列表中所有重复的元素。
相关思考:
递归的关键点就是将大问题分解为性质相同但规模更小的子问题。这个问题中,判断是否重复的行为最终都会转变为两个对象之间的比较。那么在递归的设计上,主要需要实现两点:
实现思路:
首先应该对递归的状态进行建模。具体的问题就是:
对于 list 的来说,子问题通常来说就是其 sublist。本例中的数据结构是链表,那么不难想到的是:
head
相同,但长度不同的子链表根据这个思路,再来考虑一下递归的输入和输出。sublist 是通过移除重复的对象来进行缩减的,而重复的判定是通过相邻的两个对象比较来进行的。如果希望递归完成这个过程,那么递归的参数和返回值都应该是进行比较的对象:
按照这个思路往下看,base case 也可以明确了。当没有东西可以比的时候,也就是参数值为 null
时,就是我们的递归最终开始返回的时候。联系到之前,我们讨论过缩减规模的方向是从链表的尾部开始,因此有两个状态需要讨论:
null
,也就是当前比较位置已经越过链表最后一位时,此时没有办法比较,直接返回 null
null
,但 null
依然没法与当前元素比较,因此只能返回当前元素 curr
当这一层返回本层对象时,由于下一层的栈中已经有其对应对象在等待了,因此可以进行正常的比较了。因此,我们的比较逻辑可以确认为:
null
,则返回 null
null
,则返回当前对象
实现:
public void removeDups() {
head = rRemove(head);
}
private Node<T> rRemove(Node<T> curr) {
if(curr == null) {
return null;
}
else {
curr.next = rRemove(curr.next);
if(curr.next != null && curr.data.equals(curr.next.data)) {
return curr.next;
}
return curr;
}
}
以一个具体的链表 [233]
来进行栈跟踪:
head = rRemove(head)
开始调用。此时的 curr
在 head
处,值是 2
curr.next = rRemove(curr.next)
;由于 curr
不为 null
,因此会进行下一次的 rRemove
调用。curr
,也就是上一层的 curr.next
,为 3()
,依然不为 null
,因此继续展开curr
为 3
,依然不是 null
,因此展开curr
的值已经为 null
(3
已经是链表的最后一位)。至此整个递归调用拿到了第一个返回值,开始向下返回。curr
是之前的 3
(1st, 逆序)。因为 3(1st)
是链表最后一位,因此 curr.next == null
,因此返回值为本层的 curr
,也就是 3(1st)
。curr
是 3(2rd)
。将其与返回的 3(1st)
比较,相等,因此返回 3(2rd.)
的 curr.next
,也就是 3(1st)
。curr
是 2
。将其与返回的 3(1st)
比较,不等,因此返回 2
。至此栈清空,递归结束。
值得注意的是,在比较的过程中,我们通过 curr.next = return value
将符合条件的返回值串联起来的方式生成了结果链表。这种使用返回值生成结果链表的方法被称为 Pointer reinforcement。其主要的特点:
Doubly-Linked Lists(双链表)在单链表的基础上添加了一个 previous
指针,用于指向之前的节点。双链表要求同时维护 head
与 tail
。
算法步骤:
next
指针指向 head
head
所在节点的 previous
指针指向新节点head
:将 head
指向新节点边界条件:
head
与 tail
都需要指向新创建的节点算法步骤:
previous
指针指向 tail
所在节点tail
所在节点的 next
指向新节点tail
:将 tail
指向新节点边界条件:
head
与 tail
都需要指向新创建的节点算法步骤:
head
:将 head
指向 head.next
指向的节点(第二个节点)head
的前置信息清空:将 head.prev
指向设置为 null
(第二个节点的 prev
)边界条件:
1
时,移除节点需要 head
与 tail
同时被设置为 null
算法步骤:
tail
:将 tail
指向 tail.previous
指向的节点(倒数第二个节点)next
指向设置为 null
(也就是当前的 tail.next
)边界条件:
1
时,移除节点需要 head
与 tail
同时被设置为 null
该算法基于 index 移除内容。
算法步骤:
0
,或者 size-1
)0
,删除节点为首节点,调用 removeToFront
size-1
,删除节点为尾节点,调用 removesToBack
index < size / 2
),那么从链表前端,也就是 head
开始遍历。遍历结束的位置为被移除节点的前一个节点tail
开始遍历,遍历结束的位置为被移除节点的后一个节点index
作为循环条件:$[0, index)$ 和 $[size-1, index)$next
指向其后节点previous
指向其前节点size
,返回被移除的数据
Circularly-Linked Lists (CLL) (环链表) 指尾部节点的 next
指向 head
的链表。
tail
curr.next.equals(head)
算法步骤:
next
指向 head
所在节点head
:将 head
指向新节点head
的尾节点:需要再进行一次遍历。更新尾部节点的 next
,使其指向更新后的 head
($O(n)$)算法步骤:
head
之后(index 1)next
指向原来的第二个节点head
的 next
重定向到新节点head
内的内容拷贝到新节点中head
对应的节点中算法步骤:
head
之后(index 1)head
内的内容拷贝到新节点中head
对应的节点中head
:将新节点作为新的 head
,这样一来原有的 head
就成为了尾部节点。与单链表不同,环链表中的移除的主要思路是从内容上替代节点。整体的逻辑可以描述为:
算法步骤:
head
节点中。此处不需要考虑数据覆盖的问题,因为 head
节点需要被移除。head
的 next
指向从第二个节点改变为第三个节点。边界条件:
head
需要设置为 null
head.next
需要指向其自身算法步骤:
next
指向 head
算法步骤:
next
指向被移除节点的 next
Stack(栈) 是 ADT 一种,通常使用 Array 或者 SLL 实现。名如其意, Stack 就是一种看上去是堆起来的数据结构(比如薯片筒就是一种 stack)。因此,Stack 通常都包含了两种基本操作:
值得注意的是,根据 Stack 的概念,无论是添加还是移除,我们处理的对象都只能是栈顶的对象(最上面的薯片),这导致了一下的几个事实:
void push(x)
:添加对象T pop()
:移除栈顶元素并返回T peek
or T top
:返回栈顶元素(只读)boolean isEmpty()
:通过 size,或是 top
是否为 null
来判断栈是否为空。clear()
:设置 top
为 null
并清空栈。链表是实现 Stack 最高效的手段,所有的操作复杂度都是 $O(1)$。一些实现细节:
head
用于模拟 Stack 的 top
head
为 null
tail
使用链表实现时,head
总是代表 top。因此每 push 一个对象,head
指向的节点就会更新到最新推送的对象。
算法步骤:
next
指向原来的 head
所代表的节点head
,将其指向新的节点算法步骤:
head
指向下一个节点即可。边界条件:
head
设置为 null
实现细节:
size
是否为 0
来确认。size - 1
),避免了每次 push / pop 都要做 $o(n)$ 的移动操作clear
的方式:size
归零0
), $O(n)$ 复杂度Queues(队列) 的结构类似于一个两边都开口的薯片桶;一边是入口,另一边是出口。Queues 遵循 FIFO (First in First Out 先进先出) 原则。与 Stack 类似,Queues 不支持任何的随机访问和操作。
void enqueue(x)
:添加 x
到队列的最后x dequeue()
:从队列中移除最先进入的元素x peak() & x top()
:返回队列的最新 dequeue 的元素boolean isEmpty()
:检查 size 是否为 0 或者 front 是否为 Nullvoid clear()
:将 front 设置为 nullhead
和 tail
两个指针算法步骤:
算法步骤:
与链表数列相似,Queue 的数组实现主要依靠的是一前以后的两个指针 front
& back
来体现出“队列”的概念。Queue 可以被视作存储于数组中的,以 Back 开头,以 Front 结尾的队列。需要注意的是,对效率的考虑给数组数列的组成方式带来了很大的影响:
由于 Array 的 AddToFront() 操作必须要求 Shifting 移位,因此数组队列的 enqueue 是在数组中对象序列的后方,而 dequeue 是在对象序列的前方。那么通常 back
会被视作是队列的入口,而 front
会被视作是队列的出口。
我们之前的设想是将队列视作存储在数组中的,有头有尾的一个对象序列。这意味着其占用数组的位置只与数组的 capcity 有关系,而与数组的 Index 没有关系。因此,我们很可能会遭遇如下的存储分布:
无论是哪种,我们都没有办法用 ArrayList 来实现。因为 ArrayList 要求首元素不能为空,且必须连续存储。因此,数组队列只能使用 Array 来实现。
再观察一下第二种情况。以图为例,这种例子出现的情况是:数列 [6,3,11]
存储在数组的最后三位。假设我们此时希望对该数列进行 enqueue 的操作;此时 capcity 为 7,数列长度为 3,即便添加一个元素也没有超过数组的容量。但由于存储的位置处于数组的末端,我们没有办法再往里插入新的元素了。
为了解决这个问题,数组数列的实现引入了循环(WrapAround)的机制。如果 enqueue 的位置超出了数组的上限,但数组本身还有空可以做 enqueue 的话,我们就将 enqueue 的位置(back 的位置)往数组前方的空闲位置插入。具体的计算方式为:
$$
\texttt{back = Index mod Capacity}
$$
以上图为例,我们原来希望插入的位置是 index = 7(当然没有 7),通过上述转换之后就是在 index = 0 处 enqueue 了。
back
(起点) 并不是必须的。知道 front
(终点)和 size
长度,就可以计算出起点。
Priority queues 是另外一种线性的 ADT。其特点是,当前移除的对象是优先级最高的对象 。这种 ADT 通常用于搜寻最大最小值。 由于有大小的存在,Priority queues 中的对象必须是 Comparable 的。
Priority queues 会分为两个区:一个 Priority 区和一个备选区。Priority 区中存放优先级最高的数据用于操作。当 Priority 区中的数据移除后,备选区需要通过另一轮筛选选出下一个优先级最高的数据推送到 Priority 区中,以便接下来的操作。
可以注意到,每处理一轮 Priority 区中的数据,备选区中的数据就需要排序一次,为了更好的效率,具体实现是基于 heap 的。
Priority queues 是线性类型的 ADT,但使用线性数据结构实现确十分低效。不论使用数组还是链表,我们都需要Priority queue 是一个有序数列。这意味着,每次 enqueue 需要进行一次排序(链表需要遍历到插入新元素的位置),也就是每次 enqueue 的复杂度都在 $O(n)$。这样是非常不效率的。
Deques,指 Double-ended queue。Deque 允许在队列的两端添加和删除元素。Deque 不支持随机访问(添加删除)以及搜寻操作。
void addFirst(x)
:在 Deque 队首添加元素void addLast(x)
:在 Deque 队尾添加元素x removeFirst()
:在 Deque 队首删除元素x removeLast()
:在 Deque 队尾删除元素boolean isEmpty()
int size()
Deque 的数组实现同样需要使用到循环的方式。
back % capacity
维护插入位置front % capacity
维护删除位置
这两个方法也需要通过循环的方式进行位置的维护。但由于其进出方向与之前的两个方法完全相反,如果使用同样的 back
& front
指针,那么指针移动的操作是减而不是加。这意味着指针指向的 Index 很可能会变为负数,也就是会出现向前循环的情况。这种情况下,我们需要进行手动判断并做出以下处理:
//this applied to back as well
if (front < 0) {
front = capacity - front;
}
所有的操作(除开 resize)都是 $O(1)$,总的说来是 $O(1)^*$
通常情况下,末除是一个分类的过程。所有余数相同的数字都会被放入到一个类中。在这个类中,最小的,非负的成员会成为余数。这导致了负数做末除时存在着不同。比如 $9 % 5 = 4$;但进行 $-9 % 5$ 时,因为要保证余数非负,取余的方向实际上需要更往负数方向上进行。比如这里,如果之间按正数的取余方式 $-9$ 去除 $(-1)\times5$ 还有 $-4$,但由于需要余数为正,因此需要再去除一个 $(-1)\times5$。因此余数是 $1$。
但在 Java 中,余数的结果并不要求为正,而是最接近 0 的数。这种情况是我们之前讨论的第一种情况:9 % -5 = -4
。
Deque 的实现需要使用双链表(DLL):
由于使用双链表,所有的操作都是 ($O(1)$)