【算法题】LeetCode刷题(三)
数据结构和算法是编程路上永远无法避开的两个核心知识点,本系列【算法题】旨在记录刷题过程中的一些心得体会,将会挑出LeetCode等最具代表性的题目进行解析,题解基本都来自于LeetCode官网(https://leetcode-cn.com/),本文是第三篇。
1.两两交换链表中的节点(原第X题)
给定一个链表,两两交换其中相邻的节点,并返回交换后的链表。
你不能只是单纯的改变节点内部的值,而是需要实际的进行节点交换。
示例:
给定 1->2->3->4, 你应该返回 2->1->4->3.
(1)知识点
链表
(2)解题方法
方法一:递归
使用递归的思想很简单,每次递归判断当前结点是否还有交换firstNode和secondNode,如果链表中还有节点,则继续递归,交换结束后返回secondNode
- 时间复杂度:O(N),其中 N 指的是链表的节点数量。
- 空间复杂度:O(N),递归过程使用的堆栈空间。
方法二:迭代
迭代的方法其实更加容易想到,就是根据奇偶节点一步一步往后走,直到为空。
- 时间复杂度:O(N),其中 N 指的是链表的节点数量。
- 空间复杂度:O(1)。
(3)伪代码
函数头:ListNode swapPairs(ListNode head)
方法一:递归
- 如果head为null或head.next为null,返回 head——递归终止条件
- 定义firstNode=head,secondNode=head.next
- 交换firstNode和secondNode:firstNode.next=swapPairs(secondNode.next),secondNode.next=firstNode(这里调用递归,实则是将second后面的节点先接到firstNode后面,然后把secondNode.next指向firstNode从而实现了节点交换+递归)
- 返回secondNode
方法二:迭代
这个方法最好是自己画一个图,就很清晰了
- 如果head为null或head.next为null,返回 head
- 定义一个哑结点dummy.next=head(用于记录最后返回的节点)
- 定义一个用于交换节点的临时节点prevHead=dummy,它的下一个始终指向head(prevHead的作用相当于一个胶水,它每次循环可以都能把前面已经遍历的节点和当前遍历的节点连起来)
- 第一重循环while(head!=null && head.next!=null):
- 定义firstNode=head,secondNode=head.next
- 交换节点:prevHead.next=secondNode,firstNode.next=secondNode.next,secondNode.next=firstNode
- prevNode=firstNode
- head=firstNode.next
- 返回dummy.next
(4)代码示例
//迭代
public ListNode swapPairs(ListNode head) {
if(head == null || head.next == null) return head;
ListNode dummy = new ListNode(-1);
dummy.next = head;
ListNode prev = dummy;
ListNode first = head;
ListNode second;
while(first != null && first.next != null){
second = first.next;
prev.next = second;
first.next = second.next;
second.next = first;
prev = first;
first = first.next;
}
return dummy.next;
}
//递归
public ListNode swapPairs(ListNode head) {
if(head == null || head.next == null) return head;
ListNode first = head;
ListNode second = first.next;
first.next = swapPairs(second.next);
second.next = first;
return second;
}
2.删除排序数组中的重复项(原第X题)
给定一个排序数组,你需要在 原地 (不能增加额外的空间)删除重复出现的元素,使得每个元素只出现一次,返回移除后数组的新长度。
不要使用额外的数组空间,你必须在 原地 修改输入数组 并在使用 O(1) 额外空间的条件下完成。
示例:
给定 nums = [0,0,1,1,1,2,2,3,3,4],
函数应该返回新的长度 5, 并且原数组 nums 的前五个元素被修改为 0, 1, 2, 3, 4。
你不需要考虑数组中超出新长度后面的元素。
(1)知识点
原地算法+双指针
(2)解题方法
方法:双指针法
定义两个指针,一个慢指针,一个快指针,快指针一直往后移,每次都和慢指针比较一次,如果相等,啥也不操作继续往后移,如果不相等,将快指针的值赋给慢指针,慢指针往后移一格。
这个题看起来简单,但是如果想不到这个方法,那还是很要命的。
- 时间复杂度:O(n),假设数组的长度是 n,那么 i 和 j 分别最多遍历 n 步。
- 空间复杂度:O(1)。
(3)伪代码
函数头:int removeDuplicates(int[] nums)
方法:双指针法
- 定义慢指针slow=1,fast=1
- 第一重循环:(fast:1->len)
- 如果nums[slow]!=nums[fast],nums[slow]=nums[fast],slow++
(4)代码示例
public int removeDuplicates(int[] nums) {
int len = nums.length;
if(len == 0 || len == 1) return len;
int index = 1;
int currNum = nums[0];
for(int i = 1; i < len; ++i){
if(nums[i] == currNum) continue;
currNum = nums[i];
nums[index] = currNum;
++index;
}
return index;
}
3.下一个排列(原第31题)
实现获取下一个排列的函数,算法需要将给定数字序列重新排列成字典序中下一个更大的排列。
如果不存在下一个更大的排列,则将数字重新排列成最小的排列(即升序排列)。
必须原地修改,只允许使用额外常数空间。
示例:
1,2,3 → 1,3,2
3,2,1 → 1,2,3
1,1,5 → 1,5,1
(1)知识点
原地算法+数学思想+双指针反转
(2)解题方法
方法转自:https://leetcode-cn.com/problems/next-permutation/solution/xia-yi-ge-pai-lie-by-leetcode/
方法:一遍扫描
由于题目要求不能有额外的空间消耗,所以必须要用原地算法(交换位置)。另外,这个题目最重要的是一个经验:要找到一个排列的下一个排列,就需要知道他们之间的关系,我们看下面这个排列:
1,2,5,4,3
很显然这个排列的下一个排列是:
1,3,2,4,5
那是怎么找到的呢?
第一步:从右边开始找,直到找到一个比它右边小的数,我们这里找到了'2',我们发现,下一个排列从'2'开始变化,怎么变的呢?
第二步:交换'2'和'3'的位置,为什么?这是因为,要从'2'开始变,那么'2'肯定不能待在原来的位置了,那么就找一个尽可能小(但不能比'2'还小)的来替换它。这里我们得到:1,3,5,4,2
第三步:这里我们其实只得到了前面两位数,接下来就好办了,把'3'后面的数按照增序排列,得到:1,3,2,4,5,完美
再举个栗子自行验证一下:1,2,3,5,4,7,6->1,2,3,5,6,4,7 4,5,3,2,1->5,4,1,2,3
- 时间复杂度:O(n),在最坏的情况下,只需要对整个数组进行两次扫描。
- 空间复杂度:O(1),没有使用额外的空间,原地替换足以做到。
(3)伪代码
函数头:void nextPermutation(int[] nums)
方法:一遍扫描
- 循环1:i:len->0
- 找到第一个比前一个小的数,break
- 循环2:j:len->i
- 找到刚好比第一个循环找到的数,break
- 交换i,j
- 反转第i个位置以后的列表(这个地方用双指针,一个指向低位,一个指向高位,依次交换他们,然后往中间移)
(4)代码示例
public void nextPermutation(int[] nums) {
int len = nums.length;
//从右往左遍历第一个开始下降的点
int index = len - 2;
while(index >= 0 && nums[index + 1] <= nums[index]){
--index;
}
//交换这个数和最后一个数
if(index >= 0){
int j = len - 1;
while (j > 0 && nums[j] <= nums[index]) {
--j;
}
swap(nums, j, index);
}
//将index后面的数按照增序排列
int left = index + 1;
int right = len - 1;
while(left < right){
swap(nums, left, right);
++left;
--right;
}
}
private void swap(int[] nums, int i, int j) {
int temp = nums[i];
nums[i] = nums[j];
nums[j] = temp;
}
4.搜索旋转排序数组(原第33题)
假设按照升序排序的数组在预先未知的某个点上进行了旋转。
( 例如,数组 [0,1,2,4,5,6,7] 可能变为 [4,5,6,7,0,1,2] )。
搜索一个给定的目标值,如果数组中存在这个目标值,则返回它的索引,否则返回 -1 。
你可以假设数组中不存在重复的元素。
你的算法时间复杂度必须是 O(log n) 级别。
示例:
输入: nums = [4,5,6,7,0,1,2], target = 7
输出: 3
(1)知识点
二分查找(logN复杂度的一个很常用的查找算法)
(2)解题方法
方法:二分查找
通常二分查找只能用于排好序的数组,然后通过一分为二,不断缩小对应的区间,复杂度为O(logN)
但是这个地方也可以用,很神奇吧!但是和普通的二分查找还是略有不同,因为,我们一分为二的时候,一定有一边是增序,另外一边是先增后减或者先减后增,那么我们必须先确定哪一边是增序的,简单,只需要将第一个值和mid值比较,如果比mid小,则左边增序,反之,右边增序。
试想,比如这个数组[4,5,6,7,0,1,2],一分为二,必然有一边是增序的,比较'4'和'7',4 < 7,那么可知[4,5,6,7]增序,所以先比较target和4和7,不在这个范围就选另一个范围[0,1,2]。
- 时间复杂度: O(logn),其中 n 为 nums[] 数组的大小。整个算法时间复杂度即为二分搜索的时间复杂度 O(logn)。
- 空间复杂度: O(1) 。我们只需要常数级别的空间存放变量。
(3)伪代码
函数头:int search(vector
方法:二分查找
- 定义left=0,right=len-1,mid=(left+right)/2
- 第一重循环:条件left<=right
- 如果target 和 nums[mid]相等,则返回mid
- 如果nums[left] < nums[mid],则进一步判断:如果(target >= nums[left] && target <= nums[mid]),则right=mid;否则,left=mid;continue
- 如果nums[left] >= nums[mid],则进一步判断:如果(target >= nums[mid+1] && target <= nums[right]),则left=mid;否则,right=mid;continue
(4)代码示例
public int search(int[] nums, int target) {
int left = 0;
int right = nums.length - 1;
int mid = 0;
while(left < right){
mid = (left + right) / 2;
if(nums[left] > nums[mid]){
if(target >= nums[mid] && target <= nums[right]){
left = mid;
}else{
right = mid - 1;
}
}else{
if(target >= nums[left] && target <= nums[mid]){
right = mid;
}else {
left = mid + 1;
}
}
}
mid = (left + right) / 2;
return (target == nums[mid]) ? mid : -1;
}
5.在排序数组中查找元素的第一个和最后一个位置(原第34题)
给定一个按照升序排列的整数数组 nums,和一个目标值 target。找出给定目标值在数组中的开始位置和结束位置。
你的算法时间复杂度必须是 O(log n) 级别。
如果数组中不存在目标值,返回 [-1, -1]。
示例:
输入: nums = [5,7,7,8,8,10], target = 8
输出: [3,4]
(1)知识点
二分查找
(2)解题方法
方法:二分查找
官方题解的第一方法线性方法很容易想到,但是时间复杂度N不满足题目要求,所以这里必然要用二分查找做。
二分查找看似简单,实现起来可不容易,我们以下面这一串序列为例,来找到序列中为5的最低位和最高位:
1,2,3,4,5,5,5,5,7,8,9
第一次二分查找(用于找到最低位):定义两个指针low和high,如果mid>=target,说明最低位在左边,设置high=mid;如果mid<target,说明最低位在右边,设置low=mid+1(这里加一是因为mid不等于target,所以最低位至少在mid的右侧,不可能等于mid),然后继续查找,知道low=high,返回low就是目标值的最低位
第二次二分查找(用于找到最高位):定义两个指针low和high,如果mid>target,说明最高位在在左边,设置high=mid;如果mid<=target,说明最高位在右边,设置low=mid+1(同样地,如果target大于等于mid,那么目标值的最高位肯定在mid的右边,当然可能等于mid,但此时为了更快接近high,所以可以让mid+1),这样到最后low=high的时候,返回的最高位就是low-1了。
所以,综上,我们的目的就是进行两次二分查找,分别找到了最低位和最高位,要注意的是,这里找到的最高位是实际的最高位的右边的那个数。
- 时间复杂度: O(logn) :由于二分查找每次将搜索区间大约划分为两等分,所以至多有logn 次迭代。二分查找的过程被调用了两次,所以总的时间复杂度是对数级别的。
+空间复杂度:O(1):所有工作都是原地进行的,所以总的内存空间是常数级别的。
(3)伪代码
函数头:int[] searchRange(int[] nums, int target)
方法:二分查找
定义函数searchLeft(int[] nums, int target)
- 初始化low=0,high=len-1,mid=(low+high)/2
- 第一重循环(low<high)
- 判断如果mid>=target,high=mid,否则,low=mid+1
- 最后返回low
定义函数searchRight(int[]nums, int target)
- 初始化low=0,high=len-1,mid=(low+high)/2
- 第一重循环(low<high)
- 判断如果mid>target,high=mid,否则,low=mid+1
- 最后返回low-1
(4)代码示例
public int[] searchRange(int[] nums, int target) {
int [] result = new int[]{-1, -1};
int leftIdx = find(nums, target, true);
if(leftIdx == nums.length || nums[leftIdx] != target){
return result;
}
result[0] = leftIdx;
result[1] = find(nums, target, false) - 1;
return result;
}
private int find(int[] nums, int target, boolean isFindLow){
int low = 0;
int high = nums.length;
int mid = 0;
while(low < high){
mid = (low + high) / 2;
if(nums[mid] > target || (isFindLow && target == nums[mid])){
high = mid;
}else{
low = mid + 1;
}
}
return low;
}