算法学习方法
- 坚持、刻意练习
- 练习缺陷、弱点地方
- 不舒服、枯燥是正常的
- LeetCode做题要考虑时间复杂度,尽量做到最优解
- 经常反馈,LeetCode每道题后面的solution和discuss都会有别人的解法,可以学习别人的优秀方法。
常用的数据结构
- 数组
- 堆栈/队列
- 优先队列
- 链表(单链表/双链表)
- 哈希表
- 树/二叉树
- 二叉搜索树
- 图
时间及空间复杂度
主定理
数组和链表
数组是内存中一段连续的存储区域 ,通过下标可以随机访问数组中的任意元素,所以查询较快。而插入元素的话就需要先将插入位置后面的所有元素向后移动一位,然后再插入新元素,所以数组增删慢。
链表在内存中不是连续的,每个元素除了自己的值之外还有一个指向下一个元素的指针。插入元素只需要移动指针的指向即可,但是查询元素的话需要从头开始移动指针,直到找到要查找的元素。
链表包括单链表和双向链表。单链表,只有 next. 双链表, 不仅有 next, 还有 previous.
数组随机访问某个元素的时间复杂度是O(1)。查找的话,如果无序数组就是o(n),如果有序就可以用二分查找时间复杂度是 O(logn) 。增删的时间复杂度是O(n) 。链表查询的时间复杂度是O(n) ,增删的时间复杂度是O(1)
1. 查找旋转数组的最小数字
例如:把一个数组最开始的若干个元素搬到数组的末尾,我们称之为数组的旋转。输入一个递增排序的数组的一个旋转,输出旋转数组的最小元素。例如数组 {3,4,5,1,2} 为数组 {1,2,3,4,5} 的一个旋转,该数组的最小值为 1
方法一:遍历一遍数组,找到最小值后退出
public static int getTheMin(int nums[]) {
if (nums == null || nums.length == 0) {
throw new RuntimeException("input error!");
}
int result = nums[0];
for (int i = 0; i < nums.length - 1; i++) {
if (nums[i + 1] < nums[i]) {
result = nums[i + 1];
break;
}
}
return result;
}
方法二:数组已经是有序的,只是做了一个旋转,所以我们可以考虑二分查找。
可以设定两个下标 low 和 high,并设定 mid = (low + high)/2,我们自然就可以找到数组中间的元素 array[mid],如果中间的元素位于前面的递增数组,那么它应该大于或者等于 low 下标对应的元素,此时数组中最小的元素应该位于该元素的后面,我们可以把 low 下标指向该中间元素,这样可以缩小查找的范围。同样,如果中间元素位于后面的递增子数组,那么它应该小于或者等于 high 下标对应的元素。此时该数组中最小的元素应该位于该中间元素的前面。我们就可以把 high 下标更新到中位数的下标,移动之后的 high 下标对应的元素仍然在后面的递增子数组中。不管是更新 low 还是 high,我们的查找范围都会缩小为原来的一半,接下来我们再用更新的下标去重复新一轮的查找。直到最后两个下标相邻,即high - low = 1,也就是我们的循环结束条件。
public static int getTheMin(int nums[]) {
if (nums == null || nums.length == 0) {
throw new RuntimeException("input error!");
}
// 如果只有一个元素,直接返回
if (nums.length == 1)
return nums[0];
int result = nums[0];
int low = 0, high = nums.length - 1;
int mid;
// 确保 low 下标对应的值在左边的递增子数组,high 对应的值在右边递增子数组
while (nums[low] >= nums[high]) {
// 确保循环结束条件
if (high - low == 1) {
return nums[high];
}
// 取中间位置
mid = (low + high) / 2;
// 代表中间元素在左边递增子数组
if (nums[mid] >= nums[low]) {
low = mid;
} else {
high = mid;
}
}
return result;
}
2.调整数组顺序使奇数位于偶数前面
例如:输入一个整型数组,实现一个函数来调整该数组中的数字的顺序,使得所有奇数位于数组的前半部分,所有偶数位于数组的后半部分,希望时间复杂度尽量小
方法一:从头到尾扫描一遍数组,然后遇到奇数就移动到最前面
private static int[] orderArray(int[] arr) {
for (int i = 0; i < arr.length; i++) {
// 遇到奇数就放到最前面
if (Math.abs(arr[i]) % 2 == 1) {
int temp = arr[i];
// 先把 i 前面的都向后移动一个位置
for (int j = i; j > 0; j--) {
arr[j] = arr[j - 1];
}
arr[0] = temp;
}
}
return arr;
}
方法二:我们只需要维护两个下标值,让一个下标值从前往后遍历,另外一个下标值从后往前遍历,当发现第一个下标值对应到偶数,第二个下标值对应到奇数的时候,我们就直接对调两个值。直到第一个下标到了第二个下标的后面的时候退出循环。
private static int[] orderArray(int[] arr) {
int odd = 0, even = arr.length - 1;
// 循环结束条件为 odd >= even
while (odd < even) {
// 第一个下标为偶数的时候停止
while (odd < even && Math.abs(arr[odd]) % 2 != 0) {
odd++;
}
// 第二个下标为奇数的时候停止
while (odd < even && Math.abs(arr[even]) % 2 == 0) {
even--;
}
// 找到后对调两个值
int temp = arr[odd];
arr[odd] = arr[even];
arr[even] = temp;
}
return arr;
}
3.反转一个单链表(LeetCode - 206)
例如:
input: 1 --> 2 --> 3 --> 4 output: 4 --> 3 --> 2 --> 1
方法一:迭代
在遍历列表时,将当前节点的 next 指针改为指向前一个元素。由于节点没有引用其上一个节点,因此必须事先存储其前一个元素。在更改引用之前,还需要另一个指针来存储下一个节点。最后返回新的头引用。
public ListNode reverseList(ListNode head) {
ListNode prev = null;
ListNode curr = head;
while (curr != null) {
ListNode nextTemp = curr.next;
curr.next = prev;
prev = curr;
curr = nextTemp;
}
return prev;
}
- 时间复杂度:O(n)
- 空间复杂度:O(1)
方法二:递归
递归版本关键在于反向工作。假设列表的其余部分已经被反转,现在该如何反转它前面的部分?
若从节点 k+1到 m 已经被反转,而我们正处于k。我们希望 k+1的下一个节点next指向k 。所以,
k.next.next = k。要注意的是第一个节点头结点head的下一个必须指向 Ø 。如果忽略了这一点,你的链表中可能会产生循环。
public ListNode reverseList(ListNode head) {
if(head == null || head.next == null) return head;
ListNode p = reverseList(head.next);
head.next.next = head;
head.next = null;
return p;
}
- 假设 n 是列表的长度,那么时间复杂度为 O(n)
- 空间复杂度:O(n) 由于使用递归,将会使用隐式栈空间。递归深度可能会达到 n 层。
4.交换链表相邻元素(LeetCode - 24)
例如:对于给定链表中的元素,每相邻的两个元素互相加换。如果元素个数是奇数个,则最后一个元素就不用交换了。
input: 1 --> 2 --> 3 --> 4 output: 2 --> 1 --> 4 --> 3
input: 1 --> 2 --> 3 --> 4 --> 5 output: 2 --> 1 --> 4 --> 3 --> 5
递归解法:
public ListNode swapPairs(ListNode head) {
if(head == null || head.next == null){
return head;
}
ListNode next = head.next;
head.next = swapPairs(next.next);
next.next = head;
return next;
}
非递归解法:
public ListNode swapPairs(ListNode head) {
ListNode pre = new ListNode(0);
pre.next = head;
ListNode temp = pre;
while(temp.next != null && temp.next.next != null) {
ListNode start = temp.next;
ListNode end = temp.next.next;
temp.next = end;
start.next = end.next;
end.next = start;
temp = start;
}
return pre.next;
}
5.判断一个链表是否有环(LeetCode -141)
方法一:哈希表
思路:可以通过检查一个结点此前是否被访问过来判断链表是否为环形链表。我们遍历所有结点并在哈希表中存储每个结点的引用(或内存地址)。如果当前结点为空结点 null(即已检测到链表尾部的下一个结点),那么我们已经遍历完整个链表,并且该链表不是环形链表。如果当前结点的引用已经存在于哈希表中,那么返回 true,表示该链表为环形链表。
public boolean hasCycle(ListNode head) {
Set nodesSeen = new HashSet<>();
while (head != null) {
if (nodesSeen.contains(head)) {
return true;
} else {
nodesSeen.add(head);
}
head = head.next;
}
return false;
}
- 时间复杂度为 O(n)
- 空间复杂度为 O(n)
方法二:龟兔赛跑法
思路:如果链表有环的话,那么和两个运动员在环形跑道上赛跑是一个道理。如果两人速度不同,那么他们最终会相遇。所以,我们可以使用具有 不同速度 的快、慢两个指针遍历链表,空间复杂度可以被降低至 O(1)。慢指针每次移动一步,而快指针每次移动两步。如果列表中不存在环,最终快指针将会最先到达尾部,此时我们可以返回 false
public boolean hasCycle(ListNode head) {
if (head == null || head.next == null) {
return false;
}
ListNode slow = head;
ListNode quick = head.next;
while (slow != quick) {
if (quick == null || quick.next == null) {
return false;
}
slow = slow.next;
quick = quick.next.next;
}
return true;
}
- 时间复杂度为 O(n)
- 空间复杂度为 O(1)