正确的数据结构选择可以提高算法的效率。在计算机程序设计的过程中,选择适当的数据结构是一项重要工作。许多大型系统的编写经验显示,程序设计的困难程度与最终成果的质量与表现,取决于是否选择了最适合的数据结构。
01. 存在重复元素问题
Contains Duplicate
Given an integer array nums, return true if any value appears at least twice in the array, and return false if every element is distinct.
给定一个整数数组编号,如果任何值在数组中至少出现了两次,则返回true,如果每个元素都是不同的,则返回false。
duplicate
v. 重复; 复制,复印 adj. 复制的; 副本的 n. 复制品; 副本
distinct
adj. 不同的; 清楚的,明显的; 确切的
Method one:
在对数字从小到大排序之后,数组的重复元素一定出现在相邻位置中。因此,我们可以扫描已排序的数组,每次判断相邻的两个元素是否相等,如果相等则说明存在重复的元素。
复杂度分析:
->bool
的意思是这个函数需要返回True或者False
def containsDuplicate(self, nums: list[int]) -> bool:
list.sort(nums)
"""
Sort the list in ascending order and return None.
"""
for index in range(len(nums) - 1):
if nums[index] == nums[index + 1]:
return True
return False
range类的具体介绍:range(start, stop[, step])
Method two:
构造set()集合,依次在集合中添加新的元素,进行判断元素是否存在于集合中?
def containsDuplicate(self, nums: list[int]) -> bool:
numSet = set()
for i in nums:
if i in numSet:
return True
else:
numSet.add(i)
return False
根据运行结果测试:结论,在python3中,method one 方法 比 method two 优越
02. 最大子序和问题
最大子序和问题 Maximum Subarray
Given an integer array nums
, find the contiguous subarray (containing at least one number) which has the largest sum and return its sum.
给定一个整数数组 nums ,找到一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。
A subarray is a contiguous part of an array. 子数组是数组的连续部分。
Method one:动态规划法 O(N)
class Solution:
def maxSubArray(self, nums: List[int]) -> int:
for i in range(1, len(nums)):
if nums[i - 1] > 0:
nums[i] += nums[i - 1]
return max(nums)
设sum[i]为以第i个元素结尾且和最大的连续子数组。
假设对于元素i,所有以它前面的元素结尾的子数组的长度都已经求得,那么以第i个元素结尾且和最大的连续子数组实际上,要么是以第i-1个元素结尾且和最大的连续子数组加上这个元素,要么是只包含第i个元素,即sum[i] = max(sum[i-1] + a[i], a[i])。
可以通过判断sum[i-1] + a[i]是否大于a[i]来做选择,而这实际上等价于判断sum[i-1]是否大于0。由于每次运算只需要前一次的结果,因此并不需要像普通的动态规划那样保留之前所有的计算结果,只需要保留上一次的即可,因此算法的时间和空间复杂度都很小。
Method two:贪心算法
时间复杂度是:O(n),空间复杂度:O(1)
class Solution:
def maxSubArray(self, nums: List[int]) -> int:
cur_sum = nums[0] # 指针的和
max_sum = nums[0] # 最大的和
for index in range(1, len(nums)):
cur_sum = max(nums[index], cur_sum + nums[index])
# 放弃原指针和,以当前值为开始,继续
max_sum = max(cur_sum, max_sum)
return max_sum
03. 动态规划算法介绍
动态规划法定义:
动态规划算法是通过拆分问题,定义问题状态和状态之间的关系,使得问题能够以递推(或者说分治)的方式去解决。
动态规划算法的基本思想与分治法类似,也是将待求解的问题分解为若干个子问题(阶段),按顺序求解子阶段,前一子问题的解,为后一子问题的求解提供了有用的信息。
在求解任一子问题时,列出各种可能的局部解,通过决策保留那些有可能达到最优的局部解,丢弃其他局部解。依次解决各子问题,最后一个子问题就是初始问题的解。
基本思想与策略编辑:
由于动态规划解决的问题多数有重叠子问题这个特点,为减少重复计算,对每一个子问题只解一次,将其不同阶段的不同状态保存在一个二维数组中。
04. 经典排序算法介绍
排序算法是《数据结构与算法》中最基本的算法之一。
排序算法可以分为内部排序和外部排序,内部排序是数据记录在内存中进行排序,而外部排序是因排序的数据很大,一次不能容纳全部的排序记录,在排序过程中需要访问外存。常见的内部排序算法有:插入排序、希尔排序、选择排序、冒泡排序、归并排序、快速排序、堆排序、基数排序等。用一张图概括:
点击以下图片查看大图:
冒泡排序算法
冒泡排序(Bubble Sort)也是一种简单直观的排序算法。它重复地走访过要排序的数列,一次比较两个元素,如果他们的顺序错误就把他们交换过来。走访数列的工作是重复地进行直到没有再需要交换,也就是说该数列已经排序完成。这个算法的名字由来是因为越小的元素会经由交换慢慢"浮"到数列的顶端。
算法步骤
-
比较相邻的元素。如果第一个比第二个大,就交换他们两个。
-
对每一对相邻元素作同样的工作,从开始第一对到结尾的最后一对。这步做完后,最后的元素会是最大的数。
-
针对所有的元素重复以上的步骤,除了最后一个。
-
持续每次对越来越少的元素重复上面的步骤,直到没有任何一对数字需要比较。
动画演示
代码实现
:
def bubbleSort(arr):
for i in range(1, len(arr)):
for j in range(0, len(arr)-i):
if arr[j] > arr[j+1]:
arr[j], arr[j + 1] = arr[j + 1], arr[j]
return arr
改进版冒泡排序
冒泡排序第1次遍历后会将最大值放到最右边,这个最大值也是全局最大值。
标准冒泡排序的每一次遍历都会比较全部的元素,虽然最右侧的值已经是最大值了。
改进之后,每次遍历后的最大值,次大值,等等会固定在右侧,避免了重复比较。
def bubbleSort(arr):
for i in range(len(arr) - 1, 0, -1): # 反向遍历
for j in range(0, i):
# 由于最右侧的值已经有序,不再比较,每次都减少遍历次数
if arr[j] > arr[j + 1]:
arr[j], arr[j + 1] = arr[j + 1], arr[j]
return arr
选择排序算法
选择排序是一种简单直观的排序算法,无论什么数据进去都是 O(n²) 的时间复杂度。所以用到它的时候,数据规模越小越好。唯一的好处可能就是不占用额外的内存空间了吧。
算法步骤
-
首先在未排序序列中找到最小(大)元素,存放到排序序列的起始位置。
-
再从剩余未排序元素中继续寻找最小(大)元素,然后放到已排序序列的末尾。
-
重复第二步,直到所有元素均排序完毕。
动图演示
代码实现
def selectionSort(arr):
for i in range(len(arr) - 1):
# 记录最小数的索引
minIndex = i
for j in range(i + 1, len(arr)):
if arr[j] < arr[minIndex]:
minIndex = j
# i 不是最小数时,将 i 和最小数进行交换
if i != minIndex:
arr[i], arr[minIndex] = arr[minIndex], arr[i]
return arr
插入排序算法
插入排序的代码实现虽然没有冒泡排序和选择排序那么简单粗暴,但它的原理应该是最容易理解的了,因为只要打过扑克牌的人都应该能够秒懂。插入排序是一种最简单直观的排序算法,它的工作原理是通过构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入。
插入排序和冒泡排序一样,也有一种优化算法,叫做拆半插入。
算法步骤
-
将第一待排序序列第一个元素看做一个有序序列,把第二个元素到最后一个元素当成是未排序序列。
-
从头到尾依次扫描未排序序列,将扫描到的每个元素插入有序序列的适当位置。(如果待插入的元素与有序序列中的某个元素相等,则将待插入元素插入到相等元素的后面。)
动画演示
代码展示
def insertionSort(arr):
for i in range(len(arr)):
preIndex = i-1
current = arr[i]
while preIndex >= 0 and arr[preIndex] > current:
arr[preIndex+1] = arr[preIndex]
preIndex-=1
arr[preIndex+1] = current
return arr
05. 数字三角形问题
给定一个由n行数字组成的数字三角形如下图所示。试设计一个算法,计算出从三角形的顶至底的一条路径,使该路径经过的数字总和最大。
对于给定的由n行数字组成的数字三角形,计算从三角形的顶至底的路径经过的数字和的最大值。
Input
:输入数据的第1行是数字三角形的行数n,1 ≤ n ≤ 100。接下来n行是数字三角形各行中的数字。所有数字在0…99之间。
Output
:输出数据只有一个整数,表示计算出的最大值。
增加限定条件:只准向下或向右走
缺点:深度遍历每条路径,存在大量重复计算,复杂度为O(2^n),当n=100时,会超时
import random
import numpy as np
lines = int(input("The lines number of triangle : "))
points = np.zeros((lines, lines))
for i in range(lines):
for j in range(i + 1):
points[i][j] = random.randint(1, 9)
"""
Return random integer in range [a, b], including both end points.
"""
print(points)
#
def MaxSum(i, j):
# 从points(row,col)到底边的各条路径中,最佳路径的数字之和。
if (i + 1) == lines:
# 刚好到底了,进行判断,是的就直接返回
return points[i][j]
x = MaxSum(i + 1, j)
y = MaxSum(i + 1, j + 1)
return max(x, y) + points[i][j]
print(MaxSum(0, 0))
解释一下:为什么会出现超时的情况,三角形被深度遍历后每个节点被访问的次数,构成了杨辉三角。
改进方法:增加变量进行存储
每次计算完MaxSum[i][j],都将其保存起来,下次用到,则直接调用,其复杂度为O(n^2)
import random
import numpy as np
lines = int(input("The lines number of triangle : "))
points = np.zeros((lines, lines))
for i in range(lines):
for j in range(i + 1):
points[i][j] = random.randint(1, 9)
"""
Return random integer in range [a, b], including both end points.
"""
print(points)
# 每次计算完MaxSum[i][j],都将其保存起来,下次用到,则直接调用,其复杂度为O(n^2)
def MaxSum(i, j):
global maxsum
maxsum = np.ones((lines, lines)) * (-1)
# 从points(row,col)到底边的各条路径中,最佳路径的数字之和。
if maxsum[i, j] != -1:
return maxsum[i][j]
if (i + 1) == lines:
# 刚好到底了,进行判断,是的就直接返回
maxsum[i][j] = points[i][j]
else:
x = MaxSum(i + 1, j)
y = MaxSum(i + 1, j + 1)
maxsum[i][j] = max(x, y) + points[i][j]
return maxsum[i][j]
print(MaxSum(0, 0))
print(maxsum)
上方解决方案采取递归,其实相当于从后往前递推,上方节点每个都选取下方两个节点的极大值(最优子解),又因为上方节点比下方节点少,所以可以求出最大值(最优解),其中,有很多值如果不被记录则需计算多次(重叠子问题),花费的时间复杂度为O(2 ^ n),而采用记录的方式,则时间复杂度为O(n ^ 2)
06. 买卖股票时机问题
给定一个数组 prices ,其中 prices[i] 表示股票第 i 天的价格。
在每一天,你可能会决定购买和/或出售股票。你在任何时候 最多 只能持有 一股 股票。你也可以购买它,然后在 同一天 出售。
返回 你能获得的 最大 利润 。
输入: prices = [7,1,5,3,6,4] 输出: 7
解释:
在第 2 天(股票价格 = 1)的时候买入,在第 3 天(股票价格 = 5)的时候卖出, 这笔交易所能获得利润 = 5-1 = 4 。
随后,在第 4 天(股票价格 = 3)的时候买入,在第 5 天(股票价格 = 6)的时候卖出, 这笔交易所能获得利润 = 6-3 = 3
下面我随便画了一个股票的曲线图,可以看到如果股票一直上涨,只需要找到股票上涨的最大值和股票开始上涨的最小值,计算他们的差就是这段时间内股票的最大利润。
如果股票下跌就不用计算,最终只需要把所有股票上涨的时间段内的利润累加就是我们所要求的结果:
这题的思路是,只要后一天价格比前一天高,就就把差值累加到利润里。也可以把每天价格想象为价格曲线,最大利润就是曲线中每一个斜向右上的边的差值的累加,股票操作就是斜向上边的起点买入终点卖出。
class Solution:
def maxProfit(self, prices: List[int]) -> int:
return sum([prices[i+1]-prices[i] for i in range(len(prices)-1) if prices[i+1]-prices[i] > 0])
动态规划解决:
定义dp[i][0]表示第i+1天交易完之后手里没有股票的最大利润,dp[i][1]表示第i+1天交易完之后手里持有股票的最大利润。
当天交易完之后手里没有股票可能有两种情况:
- 一种是当天没有进行任何交易,又因为当天手里没有股票,所以当天没有股票的利润只能取前一天手里没有股票的利润。
- 一种是把当天手里的股票给卖了,既然能卖,说明手里是有股票的,所以这个时候当天没有股票的利润要取前一天手里有股票的利润加上当天股票能卖的价格。
这两种情况我们取利润最大的即可,所以可以得到:
dp[i][0]=max(dp[i-1][0],dp[i-1][1]+prices[i]);
当天交易完之后手里持有股票也有两种情况:
- 一种是当天没有任何交易,又因为当天手里持有股票,所以当天手里持有的股票其实前一天就已经持有了。
- 还一种是当天买入了股票,当天能买股票,说明前一天手里肯定是没有股票的。
我们取这两者的最大值,所以可以得到:
dp[i][1]=max(dp[i-1][1],dp[i-1][0]-prices[i]);
代码展示如下所示:
class Solution:
def maxProfit(self, prices: List[int]) -> int:
if prices is None or len(prices) < 2:
return 0
length = len(prices)
profit_hold = [i for i in range(length)]
profit_none = [i for i in range(length)]
profit_hold[0] = - prices[0] # 初始时候有股票的持有利润
profit_none[0] = 0 # 初始时候有股票的持有利润
for index in range(1, length):
profit_none[index] = max(profit_none[index - 1], profit_hold[index - 1] + prices[index])
profit_hold[index] = max(profit_hold[index - 1], profit_none[index - 1] - prices[index])
# 只有最后一天没有股票的时候,利润才是最大的
return profit_none[length - 1]
上面计算的时候我们看到当天的利润只和前一天有关,没必要使用一个二维数组,只需要使用两个变量,一个记录当天交易完之后手里持有股票的最大利润,一个记录当天交易完之后手里没有股票的最大利润,来看下代码:
class Solution:
def maxProfit(self, prices: List[int]) -> int:
if prices is None or len(prices) < 2:
return 0
length = len(prices)
profit_hold = - prices[0] # 初始时候有股票的持有利润
profit_none = 0 # 初始时候有股票的持有利润
for index in range(1, length):
profit_none = max(profit_none, profit_hold + prices[index])
profit_hold = max(profit_hold, profit_none - prices[index])
# 只有最后一天没有股票的时候,利润才是最大的
return profit_none
06. 回文链表问题
给你一个单链表的头节点 head ,请你判断该链表是否为回文链表。如果是,返回 true ;否则,返回 false 。
暴力解法如下:时间复杂度 O(n),空间复杂度 O(n)
# Definition for singly-linked list.
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
class Solution:
def isPalindrome(self, head: ListNode) -> bool:
node_list = []
while True:
node_list.append(head.val)
if head.next is None:
break
else:
head = head.next
for index in range(len(node_list)):
if node_list[index] != node_list[len(node_list) - index - 1]:
return False
return True
回文链表优化介绍:
这题是让判断链表是否是回文链表,所谓的回文链表就是以链表中间为中心点两边对称。我们常见的有判断一个字符串是否是回文字符串,这个比较简单,可以使用两个指针,一个最左边一个最右边,两个指针同时往中间靠,判断所指的字符是否相等。
但这题判断的是链表,因为这里是单向链表,只能从前往后访问,不能从后往前访问,所以使用判断字符串的那种方式是行不通的。但我们可以通过找到链表的中间节点然后把链表后半部分反转(关于链表的反转可以看下剑指 Offer-反转链表的3种方式),最后再用后半部分反转的链表和前半部分一个个比较即可。
快慢指针翻转链表法
其一,find mid node 使用快慢指针找到链表中点。
其二,reverse 逆序后半部分。
其三,check 从头、中点,开始比较是否相同。
# Definition for singly-linked list.
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
class Solution:
def isPalindrome(self, head: ListNode) -> bool:
if not head or not head.next:
return True
# 链表翻转
def reversenode(head):
pre = None # 初始时候指向空
while(head):
temp = head.next
head.next = pre
pre = head
head = temp
return pre
slow, fast = head, head # 快慢指针
while(fast and fast.next):
slow, fast = slow.next, fast.next.next
slow = reversenode(slow)
# 翻转中间节点以后的链表
while(head and slow):
# print(head.val, slow.val)
if head.val != slow.val: # 比对
return False
head = head.next
slow = slow.next
return True
具体介绍下翻转链表的原理:
# 链表翻转 函数
def reversenode(head):
pre = None # 初始时候指向空
while(head):
temp = head.next
head.next = pre
# 头结点重新指向pre节点
pre = head
head = temp
return pre
07. 反转链表常用方法
使用栈解决
链表的反转是老生常谈的一个问题了,同时也是面试中常考的一道题。最简单的一种方式就是使用栈,因为栈是先进后出的。实现原理就是把链表节点一个个入栈,当全部入栈完之后再一个个出栈,出栈的时候在把出栈的结点串成一个新的链表。
首先能想到的当然是通过栈的结构,来进行反转,设置一个栈,先进后出的性质。唯一需要注意的当剩余的元素不足以放到栈里面的时候,就停止反转就好来。
class Solution(object):
def reverseKGroup(self, head, k):
"""
:type head: ListNode
:type k: int
:rtype: ListNode
"""
#用于链接头元素
Phead = ListNode(None)
p = Phead
while True:
count = k
stack = []
tmp = head
while count and tmp:
stack.append(tmp)
tmp = tmp.next
count -= 1
# 跳出上面循环的时候,tmp是第k+1的元素来
# 如果上面循环结束的时候,count还不为0,那就代表不足k个元素
if count :
p.next = head
break
# 对k个元素进行反转
while stack:
p.next = stack.pop()
p = p.next
# 与剩下链表链接起来
p.next = tmp
head = tmp
return Phead.next
双链表求解:尾插法
双链表求解是把原链表的结点一个个摘掉,每次摘掉的链表都让他成为新的链表的头结点,然后更新新链表。下面以链表1→2→3→4为例,画个图来看下:
递归解决
我们再来回顾一下递归的模板,终止条件,递归调用,逻辑处理:
class Solution(object):
def reverseKGroup(self, head, k):
"""
:type head: ListNode
:type k: int
:rtype: ListNode
"""
cur = head
count = 0
while cur and count != k:
cur = cur.next
count += 1
#通过设置cur,把原先的链表切成几段,每一段在一个递归体里面进行处理。
if count == k:
cur = self.reverseKGroup(cur,k)
while count:
tmp = head.next
head.next = cur
cur = head
head = tmp
count -= 1
head = cur
return head
08. 最长子序和问题
给定一个数组 nums 和一个目标值 k,找到和等于 k 的最长连续子数组长度。如果不存在任意一个符合要求的子数组,则返回 0。
输入: nums = [1,-1,5,-2,3], k = 3
输出: 4
解释: 子数组 [1, -1, 5, -2] 和等于 3,且长度最长。
思路和心得:
a. 前缀和的典型应用
sum[i:j) = presum[j] - presum[i] (presum是虚指,即从1开始计数)
因为presum一般用虚指(就是指针指的位置包不包含在内),好计算,与nums数组的实指之间经常混,下面写一写:
sum[2:4) = nums[2] + nums[3] = 第3个数,第4个数的和 = presum[4] - presum[2] = 前4个数的和 - 前2个数的和
b. 记得在计算最长等差数列,或者两数之和、三数之和为target时,就用哈希表字典来尝试匹配。
c. 贪心算法思想:因为是最长,所以只记录一个值第一次出现的位置。这样左右距离就尽可能的长了。
什么题目想到用前缀和?
看到题目中出现 和为k的子数组
这种的字眼就要反应过来。前缀和肯定是要求和的,子数组说明需要前缀和之前相减才能求得子数组的和。
运用递归的思想,根据 “s[i] = s[i-1] + a[i]” 求出前 i 个数的和,因此在求某一区间的和时,可以直接用“s[r] - s[l-1]” ,使得算法复杂度变为O(1)。【注意:这里原数组的下标是从1开始的,令 s[0]=0 有利于统一处理边界问题(少一个 if 特判)】。
from collections import defaultdict
class Solution:
def maxSubArrayLen(self, nums: List[int], k: int) -> int:
n = len(nums)
presum_idx = defaultdict(int)
# 哈希字典,映射前缀和值到第一次出现的下标位置
res = 0 # result value
presum = [0 for i in range(n + 1)] # 前缀和
presum[0] = 0 # 前0项为0
presum_idx[0] = 0 # 前0个,和为0 也决定了必须用虚指
for idx in range(1, n + 1):
presum[idx] = presum[idx - 1] + nums[idx - 1]
# 确保记录的是第一次出现的位置
if presum[idx] not in presum_idx:
presum_idx[presum[idx]] = idx
# 检查一下是否需要更新答案
if (presum[idx] - k) in presum_idx:
res = max(idx - presum_idx[presum[idx] - k], res)
return res
当然在上述的算法代码中,其实presum不需要设置为列表,以单一变量也即可。在遍历中始终使用的前一个值而已。
和差分数组的对比:前缀和用于原始数组不会被修改的情况下,频繁查询某个区间的累加和。如果要修改的话,考虑用差分数组。