Hao Yu's blog

The program monkey was eaten by the siege lion.

0%

3.数组中重复的数字

题目描述

在一个长度为n的数组里的所有数字都在0到n-1的范围内。 数组中某些数字是重复的,但不知道有几个数字是重复的。也不知道每个数字重复几次。请找出数组中任意一个重复的数字。 例如,如果输入长度为7的数组{2,3,1,0,2,5,3},那么对应的输出是第一个重复的数字2。

解法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Solution:
# 这里要特别注意~找到任意重复的一个值并赋值到duplication[0]
# 函数返回True/False
def duplicate(self, numbers, duplication):
if not numbers:
return False
for _, v in enumerate(numbers):
if v >= len(numbers) or v < 0:
return False
for i in range(len(numbers)):
while numbers[i] != i:
if numbers[i] == numbers[numbers[i]]:
duplication[0] = numbers[i]
return True
else:
idx = numbers[i]
numbers[i], numbers[idx] = numbers[idx], numbers[i]
return False

使用 O(1) 空间的解法: 但条件一定要明确,存在重复数字。思维类似于寻找链表环的入口。

1
2
3
4
5
6
7
8
9
10
11
class Solution:
def duplicateInArray(self, nums):
f = s = 0
while f == 0 or f != s:
f = nums[nums[f]]
s = nums[s]
f = 0
while f != s:
f = nums[f]
s = nums[s]
return s

回到目录

4.二维数组中的查找

题目描述

在一个二维数组中(每个一维数组的长度相同),每一行都按照从左到右递增的顺序排序,每一列都按照从上到下递增的顺序排序。请完成一个函数,输入这样的一个二维数组和一个整数,判断数组中是否含有该整数。

解法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Solution:
def Find(self, target, array):
if not array:
return False
row = len(array) # 数组的行数
col = len(array[0]) # 数组的列数
i, j = row - 1, 0 # i, j这样规定是从左下开始查找,也可以从右上
while i >= 0 and j < col: # 双指针来判断是否在array中
if array[i][j] == target:
return True # 如果等于输出True
elif array[i][j] > target:
i -= 1 # 如果大于则往上移一格
else:
j += 1 # 如果小于则往右移一格
return False #如果最后走到了边界仍没有,则输出False

回到目录

5.替换空格

题目描述

请实现一个函数,将一个字符串中的每个空格替换成“%20”。例如,当字符串为 We Are Happy .则经过替换之后的字符串为 We%20Are%20Happy 。

解法:

1
2
3
4
5
6
7
8
9
10
class Solution:
# s 源字符串
def replaceSpace(self, s):
res = []
for i in s:
if i == ' ':
res.extend(['%', '2', '0'])
else:
res.append(i)
return ''.join(res)

回到目录

6.从尾到头打印链表

题目描述

输入一个链表,按链表从尾到头的顺序返回一个ArrayList。

解法:

1
2
3
4
5
6
7
8
9
10
11
12
13
# class ListNode:
# def __init__(self, x):
# self.val = x
# self.next = None

class Solution:
# 返回从尾部到头部的列表值序列,例如[1,2,3]
def printListFromTailToHead(self, head):
res = []
while head:
res.append(head.val)
head = head.next
return res[::-1]

还可以递归实现:

1
2
3
4
5
6
7
8
9
10
11
class Solution:
def printListReversingly(self, head):
self.res = []

def helper(p):
if p:
helper(p.next)
self.res.append(p.val)

helper(head)
return self.res

回到目录

7.重建二叉树

题目描述

输入某二叉树的前序遍历和中序遍历的结果,请重建出该二叉树。假设输入的前序遍历和中序遍历的结果中都不含重复的数字。例如输入前序遍历序列{1,2,4,7,3,5,6,8}和中序遍历序列{4,7,2,1,5,3,8,6},则重建二叉树并返回。

解法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# class TreeNode:
# def __init__(self, x):
# self.val = x
# self.left = None
# self.right = None
class Solution:
# 返回构造的TreeNode根节点
def reConstructBinaryTree(self, pre, tin):
if not pre or not tin:
return None
root = TreeNode(0)
root.val = pre[0]
idx = tin.index(pre[0])
root.left = self.reConstructBinaryTree(pre[1: idx + 1], tin[:idx])
root.right = self.reConstructBinaryTree(pre[idx + 1:], tin[idx + 1:])
return root
1
2
3
4
5
6
7
8
9
10
11
12
13
class Solution:
def buildTree(self, preorder, inorder):

def dfs(stop):
if preorder and inorder[-1] != stop:
root = TreeNode(preorder.pop())
root.left = dfs(root.val)
inorder.pop()
root.right = dfs(stop)
return root

preorder, inorder = preorder[::-1], inorder[::-1]
return dfs(None)

回到目录

8.二叉树的下一个节点

题目描述

给定一个二叉树和其中的一个节点,请找出中序遍历顺序的下一个节点并且返回。注意,树中的节点不仅包含左右子节点,同时包含指向父节点的指针。

解法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# Definition for a binary tree node.
# class TreeNode(object):
# def __init__(self, x):
# self.val = x
# self.left = None
# self.right = None
# self.father = None
class Solution:
def inorderSuccessor(self, q):
if not q: return None
if q.right:
q = q.right
while q.left:
q = q.left
return q
while q.father and q.father.right == q:
q = q.father
return q.father

回到目录

9.用两个栈实现队列

题目描述

用两个栈来实现一个队列,完成队列的Push和Pop操作。 队列中的元素为int类型。

解法:

1
2
3
4
5
6
7
8
9
10
11
12
class Solution:
def __init__(self):
self.s1 = []
self.s2 = []
def push(self, node):
while self.s1:
self.s2.append(self.s1.pop())
self.s1.append(node)
while self.s2:
self.s1.append(self.s2.pop())
def pop(self):
return self.s1.pop()

回到目录

10.斐波那契数列

题目描述

大家都知道斐波那契数列,现在要求输入一个整数n,请你输出斐波那契数列的第n项(从0开始,第0项为0)。
n<=39

解法:

1
2
3
4
5
class Solution:
def Fibonacci(self, n):
if n <= 1:
return n
return self.Fibonacci(n - 1) + self.Fibonacci(n - 2)
1
2
3
4
5
6
class Solution:
def Fibonacci(self, n):
res=[0, 1, 1]
for _ in range(n):
res[0], res[1], res[2] = res[1], res[2], res[1] + res[2]
return res[0] if n > 2 else res[n]
1
2
3
4
5
6
class Solution:
def Fibonacci(self, n):
a, b = 1, 0
for _ in range(n):
a, b = a+b, a
return b

回到目录

11.旋转数组中的最小数字

题目描述

把一个数组最开始的若干个元素搬到数组的末尾,我们称之为数组的旋转。
输入一个非递减排序的数组的一个旋转,输出旋转数组的最小元素。
例如数组{3,4,5,1,2}为{1,2,3,4,5}的一个旋转,该数组的最小值为1。
NOTE:给出的所有元素都大于0,若数组大小为0,请返回0。

解法:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Solution:
def findMin(self, nums):
if not nums: return -1
l, r = 0, len(nums) - 1
while l < r:
mid = (l + r) >> 1
if nums[mid] > nums[r]:
l = mid + 1
elif nums[mid] == nums[r]:
r -= 1
else:
r = mid
return nums[l]

回到目录

12.矩阵中的路径

题目描述

请设计一个函数,用来判断在一个矩阵中是否存在一条包含某字符串所有字符的路径。路径可以从矩阵中的任意一个格子开始,每一步可以在矩阵中向左,向右,向上,向下移动一个格子。如果一条路径经过了矩阵中的某一个格子,则之后不能再次进入这个格子。

注意:

输入的路径不为空;
所有出现的字符均为大写英文字母;

解法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Solution(object):
def hasPath(self, matrix, string):
if not matrix or not matrix[0] or not string:
return False
m, n = len(matrix), len(matrix[0])
state = [[True] * n for _ in range(m)]

def dfs(i, j, pos):
if 0 <= i < m and 0 <= j < n and state[i][j]:
state[i][j] = ret = False
if matrix[i][j] == string[pos]:
if pos == len(string) - 1:
return True
ret = dfs(i, j-1, pos+1) or dfs(i, j+1, pos+1) or dfs(i-1, j, pos+1) or dfs(i+1, j, pos+1)
if not ret:
state[i][j] = True
return ret

for i in range(m):
for j in range(n):
if matrix[i][j] == string[0]:
if dfs(i, j, 0):
return True
return False

回到目录

13.机器人的运动范围

题目描述

地上有一个 m 行和 n 列的方格,横纵坐标范围分别是 0∼m−1 和 0∼n−1。一个机器人从坐标0,0的格子开始移动,每一次只能向左,右,上,下四个方向移动一格。但是不能进入行坐标和列坐标的数位之和大于 k 的格子。请问该机器人能够达到多少个格子?

解法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Solution:
def movingCount(self, threshold, rows, cols):

def dfs(x, y):
if 0<=x<rows and 0<=y<cols and not dp[x][y]:
dp[x][y] = 1
if threshold >= sum(map(int, list(str(x)) + list(str(y)))):
self.res += 1
for dx, dy in delta:
dfs(x+dx, y+dy)

dp = [[0] * cols for _ in range(rows)]
self.res = 0
delta = ((-1, 0), (0, 1), (1, 0), (0, -1))
dfs(0, 0)
return self.res

回到目录

14.剪绳子

题目描述

给你一根长度为 n 绳子,请把绳子剪成 m 段(m、n 都是整数,2≤n≤58 并且 m≥2)。每段的绳子的长度记为k[0]、k[1]、……、k[m]。k[0]k[1] … k[m] 可能的最大乘积是多少?例如当绳子的长度是8时,我们把它剪成长度分别为2、3、3的三段,此时得到最大的乘积18。

解法:

1
2
3
4
5
6
7
class Solution:
def maxProductAfterCutting(self,n):
dp = [0]*(n+1)
for i in range(2,n+1):
for j in range(1,i):
dp[i] = max(dp[i], max(dp[j]*(i-j), j*(i-j)))
return dp[n]
1
2
3
class Solution:
def maxProductAfterCutting(self,n):
return n - 1 if n < 4 else 3 ** ((n-2) // 3) * ((n-2) % 3 + 2)

回到目录

15.二进制中 1 的个数

题目描述

输入一个整数,输出该数二进制表示中1的个数。其中负数用补码表示。

解法:

1
2
3
4
5
6
7
8
9
class Solution:
def NumberOf1(self, n):
count = 0
if n < 0:
n &= 0xffffffff
while n:
count += 1
n &= (n - 1)
return count

回到目录

16.数值的整数次方

题目描述

实现函数double Power(double base, int exponent),求base的 exponent次方。不得使用库函数,同时不需要考虑大数问题。

解法:

1
2
3
4
5
6
7
8
9
10
class Solution:  # 简单快速幂解法
def Power(self, base, exponent):
exp = abs(exponent)
r = 1
while exp:
if exp & 1:
r *= base
base *= base
exp >>= 1
return r if exponent >= 0 else 1/r

回到目录

18.删除列表中重复的节点

题目描述

在一个排序的链表中,存在重复的节点,请删除该链表中重复的节点,重复的节点不保留,返回链表头指针。 例如,链表1->2->3->3->4->4->5 处理后为 1->2->5

解法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# class ListNode:
# def __init__(self, x):
# self.val = x
# self.next = None
class Solution:
def deleteDuplication(self, pHead):
dummy = tmp = ListNode(0)
tmp.next = pHead
while pHead and pHead.next:
if pHead.val == pHead.next.val:
while pHead.next and pHead.val == pHead.next.val:
pHead = pHead.next
tmp.next = pHead.next
else:
tmp = tmp.next
pHead = pHead.next
return dummy.next

回到目录

19.正则表达式匹配

题目描述

请实现一个函数用来匹配包括’.’和’‘的正则表达式。模式中的字符’.’表示任意一个字符,而’‘表示它前面的字符可以出现任意次(含0次)。在本题中,匹配是指字符串的所有字符匹配整个模式。例如,字符串”aaa”与模式”a.a”和”abaca”匹配,但是与”aa.a”和”ab*a”均不匹配。

解法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Solution(object):
def isMatch(self, s, p):
dp = [[False] * (len(p)+1) for _ in range(len(s)+1)]
dp[0][0] = True
for j in range(1, len(p)+1):
if p[j-1] == '*':
dp[0][j] = dp[0][j-2]
for i in range(1, len(s)+1):
for j in range(1, len(p)+1):
if p[j-1] != '*':
dp[i][j] = dp[i-1][j-1] and p[j-1] in (s[i-1], '.')
else:
dp[i][j] = dp[i][j-2] or dp[i-1][j] and p[j-2] in (s[i-1], '.')
return dp[-1][-1]

回到目录

20.表示数值的字符串

题目描述

请实现一个函数用来判断字符串是否表示数值(包括整数和小数)。例如,字符串”+100”,”5e2”,”-123”,”3.1416”和”-1E-16”都表示数值。但是”12e”,”1a3.14”,”1.2.3”,”+-5”和”12e+4.3”都不是。

解法:

两种写法: 第一种是一次遍历所有条件判断,第二种就好理解很多。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Solution(object):
def isNumber(self, s):
s = s.strip()
met_dot = met_e = met_digit = False
for i, char in enumerate(s):
if char in '+-':
if i > 0 and s[i-1] not in 'eE':
return False
elif char == '.':
if met_dot or met_e: return False
met_dot = True
elif char == 'e' or char == 'E':
if met_e or not met_digit:
return False
met_e, met_digit = True, False
elif char.isdigit():
met_digit = True
else:
return False
return met_digit
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class Solution:
def isNumber(self, s: str) -> bool:
s = s.strip()
validList = set(['+', '-', '.', 'e', 'E'])
isFirst = True

for c in s:
if c.isdigit():
isFirst = False
continue
if c not in validList:
return False
if c == 'e' or c == 'E':
if isFirst:
return False
isFirst = True
validList = set(['+', '-'])
if c == '.':
validList = set(['e', 'E'])
if c == '+' or c == '-':
if not isFirst:
return False
validList.remove('+')
validList.remove('-')

return True and not isFirst

回到目录

21.调整数组顺序使奇数位于偶数前面

题目描述

输入一个整数数组,实现一个函数来调整该数组中数字的顺序,使得所有的奇数位于数组的前半部分,所有的偶数位于数组的后半部分。

解法:

1
2
3
4
5
6
7
8
9
10
11
12
class Solution:
def reOrderArray(self, array):
if not array:
return None
i, j = 0, len(array) - 1
while i <= j:
while i <= len(array) - 1 and array[i] % 2 == 1:
i += 1
while j >= 0 and array[j] % 2 == 0:
j -= 1
array[i], array[j] = array[j], array[i]
return array

回到目录

22.链表中倒数第 k 个节点

题目描述

输入一个链表,输出该链表中倒数第k个节点。

解法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# class ListNode:
# def __init__(self, x):
# self.val = x
# self.next = None

class Solution:
def findKthToTail(self, head, k):
if not head or k <= 0:
return None
fast = slow = head
for _ in range(k): # 快慢指针来走,之所以先判断是为了防止 k 等于链表长度的情况。
if not fast: return None
fast = fast.next
while fast:
fast, slow = fast.next, slow.next
return slow

回到目录

23.链表中环的入口节点

题目描述

给一个链表,若其中包含环,请找出该链表的环的入口节点,否则,输出null。

解法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# class ListNode:
# def __init__(self, x):
# self.val = x
# self.next = None
class Solution:
def EntryNodeOfLoop(self, pHead):
pre = post = pHead
while pre and pre.next: # 确保快指针有意义没有到头
post = post.next # 慢指针走一步
pre = pre.next.next # 快指针走两步
if pre == post: # 相遇的时候即是有环
post = pHead # 慢指针再从头走
while pre != post: # 两个指针都是每次一步直到相遇
pre = pre.next
post = post.next
return post # 相遇的地方即是环的入口
return None

回到目录

24.反转链表

题目描述

输入一个链表,反转链表后,输出新链表的表头。

解法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# class ListNode:
# def __init__(self, x):
# self.val = x
# self.next = None
class Solution:
# 返回ListNode
def ReverseList(self, pHead):
pre, cur = None, pHead
while cur:
tmp = cur.next
cur.next = pre
pre = cur
cur = tmp
return pre
# 简化如下
class Solution:
# 返回ListNode
def ReverseList(self, pHead):
pre, cur = None, pHead
while cur:
pre, pre.next, cur = cur, pre, cur.next
return pre
# 递归法
class Solution:
# 返回ListNode
def ReverseList(self, pHead):
if not pHead or not pHead.next:
return pHead
else:
newHead = self.ReverseList(pHead.next)
pHead.next.next = pHead
pHead.next = None
return newHead
1
2
3
4
5
6
7
8
9
10
11
12
13
class Solution:
def reverseList(self, head: ListNode) -> ListNode:
if not head or not head.next:
return head
dummy = ListNode(0)
dummy.next = head
while head.next:
cur = head.next
head.next = cur.next
cur.next = dummy.next
dummy.next = cur
return dummy.next

回到目录

25.合并两个排序的链表

题目描述

输入两个单调递增的链表,输出两个链表合成后的链表,当然我们需要合成后的链表满足单调不减规则。

解法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# class ListNode:
# def __init__(self, x):
# self.val = x
# self.next = None
class Solution:
# 返回合并后列表
def Merge(self, pHead1, pHead2):
dummy = tmp = ListNode(-1) # 创建一个新链表来合并两个旧链表
p1, p2 = pHead1, pHead2
while p1 and p2: # 都不为空的情况
if p1.val <= p2.val:
tmp.next = p1
p1 = p1.next
else:
tmp.next = p2
p2 = p2.next
tmp = tmp.next
tmp.next = p1 or p2 # 一开始都为空或者其中一个为空或者经过while循环后其中一个为空的情况都包含了
return dummy.next

回到目录

26.树的子结构

题目描述

输入两棵二叉树A,B,判断B是不是A的子结构。我们规定空树不是任何树的子结构。

解法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# Definition for a binary tree node.
# class TreeNode(object):
# def __init__(self, x):
# self.val = x
# self.left = None
# self.right = None

class Solution:
def hasSubtree(self, p1, p2):
if not p1 or not p2:
return False
if self.isPart(p1, p2):
return True
return self.hasSubtree(p1.left, p2) or self.hasSubtree(p1.right, p2)

def isPart(self, p1, p2):
if not p2:
return True
if not p1 or p1.val != p2.val:
return False
return self.isPart(p1.left, p2.left) and self.isPart(p1.right, p2.right)

回到目录

27.二叉树的镜像

题目描述

操作给定的二叉树,将其变换为源二叉树的镜像。

输入描述

1
2
3
4
5
6
7
8
9
10
11
12
二叉树的镜像定义:源二叉树
8
/ \
6 10
/ \ / \
5 7 9 11
镜像二叉树
8
/ \
10 6
/ \ / \
11 9 7 5

解法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# class TreeNode:
# def __init__(self, x):
# self.val = x
# self.left = None
# self.right = None
class Solution:
# 返回镜像树的根节点
def Mirror(self, root):
if not root:
return None
root.left, root.right = root.right, root.left
if root.left: # 简洁的话可以不加下面这两个判断,
self.Mirror(root.left) # 因为递归调用后第一个判断和这个等效,
if root.right: # 但是涉及函数调用,会让速度更慢
self.Mirror(root.right)
return root

回到目录

28.对称的二叉树

题目描述

请实现一个函数,用来判断一棵二叉树是不是对称的。

如果一棵二叉树和它的镜像一样,那么它是对称的。

输入描述

1
2
3
4
5
6
7
8
9
10
11
12
13
如下图所示二叉树[1,2,2,3,4,4,3,null,null,null,null,null,null,null,null]为对称二叉树:
1
/ \
2 2
/ \ / \
3 4 4 3

如下图所示二叉树[1,2,2,null,4,4,3,null,null,null,null,null,null]不是对称二叉树:
1
/ \
2 2
\ / \
4 4 3

解法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# Definition for a binary tree node.
# class TreeNode(object):
# def __init__(self, x):
# self.val = x
# self.left = None
# self.right = None

class Solution:
def isSymmetric(self, root):
if not root: return True

def isSym(p, q):
if p and q:
return p.val == q.val and isSym(p.left, q.right) and isSym(p.right, q.left)
return p is q

return isSym(root.left, root.right)

回到目录

29.顺时针打印矩阵

题目描述

输入一个矩阵,按照从外向里以顺时针的顺序依次打印出每一个数字。

解法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Solution:
def printMatrix(self, matrix):
res = []
if not matrix: return res
x = y = i = 0
delta = ((0, 1), (1, 0), (0, -1), (-1, 0))
pos = [0, 0, len(matrix[0])-1, len(matrix)-1] # 左、上、右、下。后面懒得判断推出来的。
while pos[0] <= pos[2] and pos[1] <= pos[3]:
while pos[0] <= y <= pos[2] and pos[1] <= x <= pos[3]:
res.append(matrix[x][y])
x, y = x+delta[i][0], y+delta[i][1]
x, y = x-delta[i][0], y-delta[i][1]
i = (i+1) % 4
pos[i] += sum(delta[i])
x, y = x+delta[i][0], y+delta[i][1]
return res

动用了hin多空间

1
2
3
4
5
6
7
class Solution:
def printMatrix(self, matrix):
res = []
while matrix:
res += matrix[0]
matrix = list(zip(*matrix[1:]))[::-1]
return res

回到目录

30.包含min函数的栈

题目描述

定义栈的数据结构,请在该类型中实现一个能够得到栈中所含最小元素的min函数(时间复杂度应为O(1))。

解法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class MinStack:

def __init__(self):
self.stack = []
self.stkmin = []

def push(self, x: int) -> None:
if not self.stkmin or self.getMin() > x:
self.stkmin.append(x)
else:
self.stkmin.append(self.getMin())
self.stack.append(x)

def pop(self) -> None:
if self.stack:
self.stkmin.pop()
return self.stack.pop()

def top(self) -> int:
if self.stack:
return self.stack[-1]

def getMin(self) -> int:
if self.stkmin:
return self.stkmin[-1]

进阶牛逼版 O(1)空间复杂度 O(1)时间复杂度

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class MinStack:

def __init__(self):
self.stack = []
self.mins = 0

def push(self, x: int) -> None:
if not self.stack:
self.mins = x
self.stack.append(0)
else:
compare = x - self.mins
self.stack.append(compare)
self.mins = x if compare < 0 else self.mins

def pop(self) -> None:
top1 = self.stack.pop()
if top1 < 0:
self.mins = self.mins - top1

def top(self) -> int:
if self.stack[-1] > 0:
return self.mins + self.stack[-1]
else:
return self.mins

def getMin(self) -> int:
return self.mins

回到目录

31.栈的压入弹出序列

题目描述

输入两个整数序列,第一个序列表示栈的压入顺序,请判断第二个序列是否可能为该栈的弹出顺序。

假设压入栈的所有数字均不相等。

例如序列1,2,3,4,5是某栈的压入顺序,序列4,5,3,2,1是该压栈序列对应的一个弹出序列,但4,3,5,1,2就不可能是该压栈序列的弹出序列。

注意:若两个序列长度不等则视为并不是一个栈的压入、弹出序列。若两个序列都为空,则视为是一个栈的压入、弹出序列。

解法:

1
2
3
4
5
6
7
8
9
10
class Solution:
def isPopOrder(self, pushV, popV):
if len(pushV) != len(popV): return False
stack, i = [], 0 # 用 stack 来模拟进出栈。
for v in pushV:
stack.append(v)
while stack and stack[-1] == popV[i]:
stack.pop()
i += 1
return not stack

回到目录

32.从上到下打印二叉树

题目描述

从上往下打印出二叉树的每个节点,同层节点从左至右打印。

解法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# class TreeNode:
# def __init__(self, x):
# self.val = x
# self.left = None
# self.right = None
class Solution:
# 返回从上到下每个节点值列表,例:[1,2,3]
def PrintFromTopToBottom(self, root):
res = []
if root:
level = [root]
while level:
res.extend([x.val for x in level])
level = [leaf for node in level for leaf in (node.left, node.right) if leaf]
return res

回到目录

33.二叉搜索树的后序遍历序列

题目描述

输入一个整数数组,判断该数组是不是某二叉搜索树的后序遍历的结果。如果是则返回true,否则返回false.假设输入的数组的任意两个数字都互不相同。

解法:

1
2
3
4
5
6
7
8
9
10
11
class Solution:
def verifySequenceOfBST(self, sequence):
if not sequence:
return True # 看 OJ 要求 True or False
def dfs(Max, stop):
if sequence and Max >= sequence[-1] >= stop:
x = sequence.pop()
dfs(Max, x)
dfs(x, stop)
dfs(float('inf'), float('-inf'))
return not sequence

回到目录

34.二叉树中和为某一值的路径

题目描述

输入一棵二叉树和一个整数,打印出二叉树中结点值的和为输入整数的所有路径。
从树的根结点开始往下一直到叶结点所经过的结点形成一条路径。

解法:

分析: 分为两个解法,一种是递归的做法,另外一种是迭代的做法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# Definition for a binary tree node.
# class TreeNode:
# def __init__(self, x):
# self.val = x
# self.left = None
# self.right = None

class Solution:
def pathSum(self, root: TreeNode, tsum: int) -> List[List[int]]:
if not root :
return []

temp, res = [], []

def DFTS(root):
temp.append(root.val)
if not root.left and not root.right and sum(temp) == tsum:
res.append(temp.copy())
if root.left:
DFTS(root.left)
if root.right:
DFTS(root.right)
temp.pop()

DFTS(root)
return res

迭代法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Solution:
def pathSum(self, root: TreeNode, Psum: int) -> List[List[int]]:
if not root:
return []
stack, res = [(root, [root.val])], []
while stack:
node, cur = stack.pop()
if not node.left and not node.right and sum(cur) == Psum:
res.append(cur)
if node.left:
stack.append((node.left, cur + [node.left.val]))
if node.right:
stack.append((node.right, cur + [node.right.val]))
return res

返回目录

35.复杂链表的复制

题目描述

输入一个复杂链表(每个节点中有节点值,以及两个指针,一个指向下一个节点,另一个特殊指针指向任意一个节点),返回结果为复制后复杂链表的head。(注意,输出结果中请不要返回参数中的节点引用,否则判题程序会直接返回空)

解法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# class RandomListNode:
# def __init__(self, x):
# self.label = x
# self.next = None
# self.random = None
class Solution:
# 返回 RandomListNode
def Clone(self, pHead):
if not pHead:
return None
p1 = p2 = p3 = pHead
while p1:
tmp = RandomListNode(p1.label)
tmp.next = p1.next
tmp.random = None
p1.next = tmp
p1 = p1.next.next
while p2:
tmp = p2.next
tmp.random = p2.random.next if p2.random else None
p2 = p2.next.next
dummy = tmp = p3.next
while p3:
p3.next = p3.next.next
tmp.next = p3.next.next if p3.next else None
p3, tmp = p3.next, tmp.next
return dummy

回到目录

36.二叉搜索树与双向链表

题目描述

输入一棵二叉搜索树,将该二叉搜索树转换成一个排序的双向链表。要求不能创建任何新的结点,只能调整树中结点指针的指向。
注意:需要返回双向链表最左侧的节点。

解法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# Definition for a binary tree node.
# class TreeNode(object):
# def __init__(self, x):
# self.val = x
# self.left = None
# self.right = None

class Solution:
def convert(self, root):
if not root: return None
prev = None
stack = []
while stack or root:
while root:
stack.append(root)
root = root.left
node = stack.pop()
node.left = prev
if prev:
prev.right = node
root = node.right
prev = node
cur = prev
while cur.left:
cur = cur.left
return cur

回到目录

37.序列化二叉树

题目描述

请实现两个函数,分别用来序列化和反序列化二叉树。
您需要确保二叉树可以序列化为字符串,并且可以将此字符串反序列化为原始树结构。

解法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# Definition for a binary tree node.
# class TreeNode(object):
# def __init__(self, x):
# self.val = x
# self.left = None
# self.right = None

class Solution:

def serialize(self, root):
def doit(node):
if node:
vals.append(str(node.val))
doit(node.left)
doit(node.right)
else:
vals.append('#')
vals = []
doit(root)
return ','.join(vals)


def deserialize(self, data):
def doit():
val = next(vals)
if val == '#':
return None
node = TreeNode(int(val))
node.left = doit()
node.right = doit()
return node
vals = iter(data.split(','))
return doit()

返回目录

38.字符串的排列

题目描述

输入一组数字(可能包含重复数字),输出其所有的排列方式。

解法:

1
2
3
4
5
6
7
8
9
class Solution:
def permutation(self, nums):
perms = [[]]
for n in nums:
perms = [
p[:i] + [n] + p[i:]
for p in perms
for i in range((p+[n]).index(n)+1)]
return perms

回到目录

39.数组中出现次数超过一半的数字

题目描述

数组中有一个数字出现的次数超过数组长度的一半,请找出这个数字。例如输入一个长度为9的数组{1,2,3,2,2,2,5,4,2}。由于数字2在数组中出现了5次,超过数组长度的一半,因此输出2。如果不存在则输出0。

解法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Solution:
def MoreThanHalfNum_Solution(self, numbers):
if not numbers:
return 0
candidate = numbers[0]
count = 1
for i in range(1,len(numbers)):
if numbers[i] == candidate:
count += 1
else:
count -= 1
if count == 0:
candidate = numbers[i]
count = 1
# 上面是摩尔投票法,下面为验证,这样可以保证时间复杂度在 O(n) 。
if numbers.count(candidate) * 2 > len(numbers):
return candidate
else:
return 0

回到目录

40.最大的k个数

题目描述

在未排序的数组中找到前k个大的元素。 请注意,它们是排序顺序中前k个大的元素,而不是前k个不同的元素。

解法:

1

回到目录

41.数据流的中位数

题目描述

如何得到一个数据流中的中位数?如果从数据流中读出奇数个数值,那么中位数就是所有数值排序之后位于中间的数值。如果从数据流中读出偶数个数值,那么中位数就是所有数值排序之后中间两个数的平均值。

解法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from heapq import *
class Solution:

def __init__(self):
self.heaps = [], []

def insert(self, num):
small, large = self.heaps
heappush(small, -heappushpop(large, num))
if len(large) < len(small):
heappush(large, -heappop(small))

def getMedian(self):
small, large = self.heaps
if len(large) > len(small):
return float(large[0])
return (large[0] - small[0]) / 2.0

回到目录

42.连续子数组的最大和

题目描述

一个整数数组中的元素有正有负,在该数组中找出一个连续子数组,要求该连续子数组中各元素的和最大,这个连续子数组便被称作最大连续子数组。比如数组{2,4,-7,5,2,-1,2,-4,3}的最大连续子数组为{5,2,-1,2},最大连续子数组的和为5+2-1+2=8。

解法:

1
2
3
4
5
6
7
class Solution:
def FindGreatestSumOfSubArray(self, array):
best = cur = array[0]
for i in range(1, len(array)):
cur = max(array[i], array[i] + cur)
best = max(best, cur)
return best

回到目录

43.1~n整数中1出现的次数

题目描述

输入一个整数n,求从1到n这n个整数的十进制表示中1出现的次数。例如输入12,从1到12这些整数中包含“1”的数字有1,10,11和12,其中“1”一共出现了5次。

解法:

1
2
3
4
5
6
7
8
class Solution:
def numberOf1Between1AndN_Solution(self, n):
count, i = 0, 1
while i <= n:
a, b = n // i, n % i
count += (a+8) // 10 * i + (a%10 == 1) * (b+1)
i *= 10
return count

回到目录

44.数字序列中某一位的数字

题目描述

数字以0123456789101112131415…的格式序列化到一个字符序列中。在这个序列中,第5位(从0开始计数)是5,第13位是1,第19位是4,等等。请写一个函数求任意位对应的数字。

解法:

1
2
3
4
5
6
7
8
class Solution(object):    # 快速跳过不用检查的位数,确定区间。
def digitAtIndex(self, n):
n -= 1
for digit in range(1, 11):
first = 10 ** (digit - 1)
if n < 9 * first * digit:
return int(str(first + n // digit)[n % digit])
n -= 9 * first * digit

回到目录

45.把数组排成最小的数

题目描述

输入一个正整数数组,把数组里所有数字拼接起来排成一个数,打印能拼接出的所有数字中最小的一个。例如输入数组[3, 32, 321],则打印出这3个数字能排成的最小数字321323。

解法:

1
2
3
4
5
6
class Solution:
def printMinNumber(self, nums):
if not nums: return ''
if set(nums) == {0}: return "0"
diff = len(str(max(nums))) - len(str(min(nums))) + 1
return "".join(sorted(map(str,nums),key= lambda x: x*diff))

回到目录

46.把数字翻译成字符串

题目描述

给定一个数字,我们按照如下规则把它翻译为字符串:
0翻译成”a”,1翻译成”b”,……,11翻译成”l”,……,25翻译成”z”。
一个数字可能有多个翻译。例如12258有5种不同的翻译,它们分别是”bccfi”、”bwfi”、”bczi”、”mcfi”和”mzi”。
请编程实现一个函数用来计算一个数字有多少种不同的翻译方法。

解法:

1
2
3
4
5
6
7
8
9
class Solution:
def getTranslationCount(self, s):
if not s: return 0
dp = [1] * len(s)
for i in range(len(s)-2, -1, -1):
dp[i] = dp[i+1]
if s[i] == '1' or s[i] == '2' and s[i+1] < '6':
dp[i] += dp[i+2]
return dp[0]

可简化为 O(1) 空间

1
2
3
4
5
6
7
8
9
10
class Solution:
def getTranslationCount(self, s):
if not s: return 0
l = r = 1
for i in range(len(s)-2, -1, -1):
if s[i] == '1' or s[i] == '2' and s[i+1] < '6':
l, r = l + r, l
else:
r = l
return l

回到目录

47.礼物的最大价值

题目描述

在一个m×n的棋盘的每一格都放有一个礼物,每个礼物都有一定的价值(价值大于0)。
你可以从棋盘的左上角开始拿格子里的礼物,并每次向右或者向下移动一格直到到达棋盘的右下角。
给定一个棋盘及其上面的礼物,请计算你最多能拿到多少价值的礼物?

解法:

1
2
3
4
5
6
7
8
9
10
class Solution:
def getMaxValue(self, grid):
dp = [0] * len(grid[0])
for i in range(len(grid)):
for j in range(len(grid[0])):
if j > 0:
dp[j] = grid[i][j] + max(dp[j-1], dp[j])
else:
dp[j] = grid[i][j] + dp[j]
return dp[-1]

回到目录

48.最长不含重复字符的子字符串

题目描述

给定一个字符串,找到最长子字符串的长度而不重复字符。

解法:

分析: 滑动窗口解决问题,如果遍历一遍 s,如果遍历到没有出现的元素,窗口右端立马扩张,并计算最大长度。如果遍历到之前出现的元素,则将窗口左端置为上次出现的位置的后一位。只有出现没有遍历过的元素才会计算最大长度。因为一旦是遍历过的元素,只有可能是保持不变或者缩小。

1
2
3
4
5
6
7
8
9
10
11
class Solution:
def lengthOfLongestSubstring(self, s):
start = maxLength = 0
used = {}
for i, c in enumerate(s):
if c in used and start <= used[c]:
start = used[c] + 1
else:
maxLength = max(maxLength, i - start + 1)
used[c] = i
return maxLength

返回目录

49.丑数

题目描述

我们把只包含质因子2、3和5的数称作丑数(Ugly Number)。例如6、8都是丑数,但14不是,因为它包含质因子7。求第n个丑数的值。

解法:

1
2
3
4
5
6
7
8
9
10
class Solution:
def nthUglyNumber(self, n):
ugly = [1]
i2 = i3 = i5 = 0
while len(ugly) < n:
while ugly[i2] * 2 <= ugly[-1]: i2 += 1
while ugly[i3] * 3 <= ugly[-1]: i3 += 1
while ugly[i5] * 5 <= ugly[-1]: i5 += 1
ugly.append(min(ugly[i2] * 2, ugly[i3] * 3, ugly[i5] * 5))
return ugly[-1]
1
2
3
4
5
class Solution:
ugly = sorted(2**a * 3**b * 5**c
for a in range(32) for b in range(20) for c in range(14))
def nthUglyNumber(self, n):
return self.ugly[n-1]

回到目录

50.第一个只出现一次的字符

题目描述

在字符串中找出第一个只出现一次的字符。如输入”abaccdeff”,则输出b。如果字符串中不存在只出现一次的字符,返回#字符。

解法:

1
2
3
4
5
6
7
8
9
class Solution:
def firstNotRepeatingChar(self, s):
if not s: return '#'
import collections
count = collections.Counter(s)
for i in set(s):
if count[i] == 1:
return i
return '#'

回到目录

52.两个链表的第一个公共节点

题目描述

输入两个链表,找出它们的第一个公共节点。

解法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# class ListNode:
# def __init__(self, x):
# self.val = x
# self.next = None
class Solution:
def FindFirstCommonNode(self, pHead1, pHead2):
if not pHead1 or not pHead2:
return None
l1, l2 = 0, 0
p1, p2 = pHead1, pHead2
while p1:
p1 = p1.next
l1 += 1
while p2:
p2 = p2.next
l2 += 1
p1, p2 = pHead1, pHead2
if l1 >= l2:
while l1 - l2:
p1 = p1.next
l1 -= 1
else:
while l2 - l1:
p2 = p2.next
l2 -= 1
while p1 and p2:
if p1 == p2:
return p1
p1 = p1.next
p2 = p2.next
return None
1
2
3
4
5
6
7
class Solution:
def FindFirstCommonNode(self, pHead1, pHead2):
p1, p2 = pHead1, pHead2
while p1 != p2: # 1.判断是否为同一个相交处 2.判断是否走完了一整遍
p1 = p1.next if p1 else pHead2
p2 = p2.next if p2 else pHead1
return p1

回到目录

53.在排序数组中查找数字

题目描述

统计一个数字在排序数组中出现的次数。如果不存在返回 0。
例如输入排序数组[1, 2, 3, 3, 3, 3, 4, 5]和数字3,由于3在这个数组中出现了4次,因此输出4。

解法:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Solution:
def getNumberOfK(self, nums, k):
if not nums: return 0
def search(lo, hi):
if nums[lo] == k == nums[hi]:
return [lo, hi]
if nums[lo] <= k <= nums[hi]:
mid = (lo + hi) // 2
l, r = search(lo, mid), search(mid+1, hi)
return max(l, r) if -1 in l+r else [l[0], r[1]]
return [-1, -1]
l, r = search(0, len(nums)-1)
return 0 if l == -1 else r - l + 1

回到目录

54.二叉搜索树的第k个结点

题目描述

给定一棵二叉搜索树,请找出其中的第k小的结点。

你可以假设树和k都存在,并且1≤k≤树的总结点数。

解法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# class TreeNode(object):
# def __init__(self, x):
# self.val = x
# self.left = None
# self.right = None

class Solution:
def kthNode(self, root, k):
stack = []
while stack or root:
while root:
stack.append(root)
root = root.left
node = stack.pop()
k -= 1
if not k: return node
root = node.right
return None

回到目录

55.二叉树的深度

题目描述

输入一棵二叉树,求该树的深度。从根节点到叶节点依次经过的节点(含根、叶节点)形成树的一条路径,最长路径的长度为树的深度。

解法:

1
2
3
4
5
6
7
8
9
10
11
12
# class TreeNode:
# def __init__(self, x):
# self.val = x
# self.left = None
# self.right = None
class Solution:
def TreeDepth(self, pRoot):
if not pRoot:
return 0
depthleft = self.TreeDepth(pRoot.left)
depthright = self.TreeDepth(pRoot.right)
return max(depthleft, depthright) + 1

回到目录

56.数组中数字出现的次数

56-1.数组中只出现一次的两个数字

题目描述

一个整型数组里除了两个数字之外,其他的数字都出现了两次。请写程序找出这两个只出现一次的数字。你可以假设这两个数字一定存在。

解法:

1
2
3
4
5
6
7
8
9
10
11
12
13
import functools
class Solution:
def findNumsAppearOnce(self, nums):
if len(nums) < 2: return []
diff = functools.reduce(lambda r, x: r ^ x, nums)
idx = len(bin(diff)) - bin(diff).rfind('1') - 1
num1 = num2 = 0
for num in nums:
if (num >> idx) & 1:
num1 ^= num
else:
num2 ^= num
return [num1, num2]

回到目录

56-2.数组中唯一只出现一次的数字

题目描述

在一个数组中除了一个数字只出现一次之外,其他数字都出现了三次。请找出那个只出现一次的数字。你可以假设满足条件的数字一定存在。
思考题:
如果要求只使用 O(n) 的时间和额外 O(1) 的空间,该怎么做呢?

解法:

1
2
3
4
5
6
7
class Solution:  # 面试用装x解法
def findNumberAppearingOnce(self, nums):
a = b = 0
for n in nums:
a = (a ^ n) & ~b
b = (b ^ n) & ~a
return a
1
2
3
4
5
6
7
8
9
10
11
class Solution:  # 常规解法
def findNumberAppearingOnce(self, nums):
ans = 0
for i in range(32):
cnt = 0
for n in nums:
if (n >> i) & 1:
cnt += 1
if cnt % 3:
ans |= 1 << i
return ans if ans < 2**31 else ans - 2**32

回到目录

57.和为S的两个数

题目描述

输入一个递增排序的数组和一个数字S,在数组中查找两个数,使得他们的和正好是S,如果有多对数字的和等于S,输出两个数的乘积最小的。

解法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Solution:
def FindNumbersWithSum(self, array, tsum):
if not array:
return []
i = 0
j = len(array) - 1
while i < j:
if array[i] + array[j] > tsum:
j -= 1
elif array[i] + array[j] < tsum:
i += 1
else:
return array[i], array[j]
return []
57 - 1.和为S的连续正数序列
题目描述

输入一个正数s,打印出所有和为s的连续正数序列(至少含有两个数)。例如输入15,由于1+2+3+4+5=4+5+6=7+8=15;所以打印出三个连续序列1 ~ 5, 4 ~ 6, 7 ~ 8

解法:
1
2
3
4
5
6
7
8
9
10
11
12
13
class Solution:
def FindContinuousSequence(self, tsum):
low, high = 1, 2
res = []
while low <= tsum // 2:
if sum(range(low, high + 1)) == tsum:
res.append(list(range(low, high + 1)))
low += 1
elif sum(range(low, high + 1)) < tsum:
high += 1
else:
low += 1
return res

回到目录

58.翻转字符串

题目描述

输入一个英文句子,翻转句子中单词的顺序,但单词内字符的顺序不变。为简单起见,标点符号和普通字母一样处理。例如输入字符串“I am a student.”,则输出“student. a am I”

解法:

1
2
3
class Solution:
def ReverseSentence(self, s):
return ' '.join(s.split(' ')[::-1])

回到目录

59.队列的最大值

题目描述

给定一个数组和滑动窗口的大小,请找出所有滑动窗口的最大值。例如,输入数组{2,3,4,2,6,2,5,1}和数字3,那么一共存在6个滑动窗口,他们的最大值分别为{4,4,6,6,6,5}。

解法:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Solution:
def maxSlidingWindow(self, nums: List[int], k: int) -> List[int]:
dq = collections.deque() # 使用双向队列解决本题
res = []
for i, v in enumerate(nums):
while dq and nums[dq[-1]] < v: # dq中如果存在多个元素
dq.pop() # 一定是降序排列的
dq += i,
if dq[0] == i - k: # 判断dq中第一位是否有效
dq.popleft()
if i >= k - 1: # 满足滑动窗口长度才有输出
res += nums[dq[0]],
return res

回到目录

60.n个骰子的点数

题目描述

将一个骰子投掷n次,获得的总点数为s,s的可能范围为n~6n。掷出某一点数,可能有多种掷法,例如投掷2次,掷出3点,共有[1,2],[2,1]两种掷法。请求出投掷n次,掷出n~6n点分别有多少种掷法。

解法:

1
2
3
4
5
6
7
8
9
10
11
12
class Solution(object):
def numberOfDice(self, n):
dp = [0] * (6 * n)
dp[0:6] = [1] * 6
for time in range(2,n+1): # time 是次数,从 2 开始是因为下一行要计算本次的上限索引。
for i in range(6*time-1, -1, -1):
dp[i]=0 # 因为上一轮的计算到这没有用了,因为点不为 0,所以清空重新计算。
for j in range(6, 0, -1):
if i < j:
continue
dp[i] += dp[i - j]
return dp[n-1:]

回到目录

61.扑克牌中的顺子

题目描述

从扑克牌中随机抽5张牌,判断是不是一个顺子,即这5张牌是不是连续的。2~10为数字本身,A为1,J为11,Q为12,K为13,大小王可以看做任意数字。为了方便,大小王均以0来表示,并且假设这副牌中大小王均有两张。

解法:

1
2
3
4
5
class Solution:
def isContinuous(self, numbers):
if not numbers: return False
nums = [x for x in numbers if x]
return max(nums) - min(nums) < 5 if len(nums) == len(set(nums)) else False

回到目录

62.圆圈中最后剩下的数字

题目描述

题目:0,1,…,n-1这n个数字拍成一个圆圈,从数字0开始,每次从这个圆圈里删除第m个数字。求出这个圆圈里身下的最后一个数字。

解法:

递归法 代码不好理解并且递归深度大,不推荐。在牛客网上无法 AC 。但思路正确。

1
2
3
4
5
class Solution:
def LastRemaining_Solution(self, n, m):
if n < 1 or m < 1:
return -1
return 0 if n == 1 else (self.LastRemaining_Solution(n - 1, m) + m) % n

循环迭代法

1
2
3
4
5
6
7
8
class Solution:
def LastRemaining_Solution(self, n, m):
if n < 1 or m < 1:
return -1
last = 0
for i in range(2,n + 1):
last = (last + m) % i
return last

回到目录

63.股票的最大利润

题目描述

给定一个数组,它的第 i 个元素是一支给定股票第 i 天的价格。

如果你最多只允许完成一笔交易(即买入和卖出一支股票),设计一个算法来计算你所能获取的最大利润。

注意你不能在买入股票前卖出股票。

解法:

1
2
3
4
5
6
7
class Solution:
def maxProfit(self, prices):
min_p, max_p = float('inf'), 0
for i in range(len(prices)):
min_p = min(min_p, prices[i])
max_p = max(max_p, prices[i] - min_p)
return max_p

回到目录

64.求 1+2+3+…+n

题目描述

求1+2+…+n,要求不能使用乘除法、for、while、if、else、switch、case等关键字及条件判断语句(A?B:C)。

解法:

1
2
3
4
5
class Solution:
def getSum(self, n): # 递归也可以实现,但是数大了以后会超出递归栈的上限,所以 reduce 很棒。
import functools
return functools.reduce(lambda x, y: x+y, range(n+1))

回到目录

65.不用加减乘除做加法

题目描述

写一个函数,求两个整数之和,要求在函数体内不得使用四则运算符号。

解法:

1
2
3
4
5
6
class Solution:
def Add(self, num1, num2):
mask = 0xffffffff # 因为 Python 没有整型溢出,所以需要规定个范围掩码
while num2 & mask: # 当 num2 超过 mask 时,num1 也要和 mask 做 与
num1, num2 = num1 ^ num2, (num1 & num2) << 1
return num1 & mask if num2 > mask else num1

回到目录

66.构建乘积数组

题目描述

给定一个数组A[0,1,…,n-1],请构建一个数组B[0,1,…,n-1],其中B中的元素B[i]=A[0] A[1] A[i-1] A[i+1] A[n-1]。不能使用除法。

解法:

1
2
3
4
5
6
7
8
9
10
class Solution:
def multiply(self, A):
B = [1] * len(A)
for i in range(1, len(A)):
B[i] = B[i - 1] * A[i- 1]
tmp = 1
for j in range(len(A) - 2, -1, -1):
tmp *= A[j + 1]
B[j] *= tmp
return B

回到目录

体系结构技术发展

流量强调的是在一定时间内完成的工作量,又称之为带宽;响应时间强调的是在一个请求提出之后得到回复的时间间隔,又称之为延迟。二者的核心内容是时间。

两个不同的计算机,X比Y快n倍,表示一个程序在X上的执行时间比在Y的执行时间快n倍。

  • 墙钟时间,wall time不一定是单调递增的。因为wall-time是指现实中的实际时间,如果系统要与网络中某个节点时间同步、或者由系统管理员觉得这个wall-time与现实时间不一致,有可能任意的改变这个wall-time。
  • response time响应时间
  • elapsed time

benchmark用来评估计算机性能,有五种:真实程序、核心程序(应用内挖出来的典型应用)、简单程序(素数筛选等)、synthetic benchmark(组合程序)、benchmark suites(测试组件)。

最著名的测试组件:SPEC(system performance and evaluation cooperative),是一个benchmark suite,侧重于CPU内部的性能,是研究计算机的人员所侧重的,用来测试Unix工作站。

CPU performance:大部分计算机在一个特定时钟周期下工作,Clock time(CPU时钟)越高越快。

一个程序的CPU时间表示为占用的时钟周期乘以时钟周期数,或者占用的CPU时钟周期/CPU时钟工作频率,得到一共用了多少拍。

Instruction Count(IC)是指令数,与机器的指令系统和编译系统有关。
Cycles Per Instruction(CPI)用(CPU时钟周期数)除以指令数,是每条指令占用的节拍数,与硬件组织有关。IPC是二者倒过来。

所以,CPU时间 = IC * CPI * Clock cycle time,或者CPU time = IC * CPI / clock_rate

3个基本原则:

  • 大概率事件优先,让最常见的事执行的最快。
  • Amdahl定律,可以找到系统的瓶颈在什么地方,且系统的性能是由系统最差的部分决定,一个系统是均衡优化的系统。
  • 程序局部性原理,时间局部性(将来用的东西最大概率是我现在用的)、空间局部性(这次访问的下次可能再访问,出现内存中的热点部分)。

指令系统和基本流水线

计算机指令系统:

  • 所有机器的指令系统相似,但都不一样;
  • 桌面计算、服务器和嵌入式系统的指令系统有差别
    • 桌面系统要求同时有整数和浮点数,对容量的消耗不敏感
    • 服务器中,浮点数运算不如整数运算和字符串处理重要
    • 嵌入式系统对容量和大小更敏感

指令集分类是根据CPU访问存储器的方式分类的

  • 堆栈型,少见,最大的好处是程序员事先组织好数据,之后不需要管;
  • 累加器型,部件少;
  • 寄存器型,有寄存器-存储器、寄存器-寄存器型
  • 存储器型。

指令的四个方面:

  • 存储器地址
  • 操作类型
  • 操作数类型
  • 指令编码

访问内存时,首先要告诉,访问哪个地址,访问地址有多长。注意字节对准,对一个地址的访问需要成块成块的访问。

大端小端:如果数据超过一个字节,则数据的第一个字节放在哪个位置?第一个字节放的是高八位,则是大端。

寻址模式:可以减少指令数,但是增加了复杂性。越丰富的寻址方式给程序设计人员带来更大的便利,过于复杂的寻址方式降低了利用率。

现在所使用的数据有8位的、16位的、32位的、64位的。

常用的指令系统:

  • 逻辑运算指令,ADD,AND,OR
  • 数据传输,LOADS、STORES
  • 控制类指令,指令执行方向的改变所需的指令,jump、call、trap
  • 系统类指令,实模式切换到保护模式等一些系统调用;进程切换时需要cache的清空指令
  • 浮点类指令
  • 字符串类指令
  • 图形类指令,即数字图像处理类指令。MX、MXR等

指令功能设计中,有一类指令是改变控制流的:

  • 条件分支,有条件转移
  • 跳转,无条件转移
  • 子程序调用
  • 子程序返回

这一类指令会影响到系统的性能,导致机器在运行中频繁执行切换,可以:

  • 直接形成条件码,设置特殊标志位
  • 条件特别多,做一个条件寄存器
  • 比较完直接拿结果

操作数问题:最常见的操作数有如下几种:

  • 字符型
  • 整数型,半字、字。
  • 浮点型,32位短浮点和64位长浮点,尽量使用64位的,因为浮点数的误差和累计误差很大
  • 十进制数,不紧缩的可以看成串,紧缩的是按照BCD码调整的,直接进行运算,一个字节放两位十进制数

指令编码:经典的RISC机器都是固定四字节的指令长度,80x86指令长度从8位到48位,译码的时间过长,需要使常用的指令较短。

编译器大概有如下的过程:

  • 前端的语言处理
  • 高级优化,与机器关联,考虑如何优化
  • 全局优化,考虑寄存器分配和全局变量的存储
  • 代码生成,依据机器进行生成代码

一个地址会有很多中表示方法,这叫做“别名”,给编译优化带来了很大的问题。

MIPS实现:

  • MIPS整数部分的子集,还包括存取字,整数ALU,基本浮点功能部件
  • 过程
    • Instruction Fetch:取出指令放到指令寄存器中,生成下一条指令的地址,当前指令地址+4,
    • Instruction Decode:译码,读取寄存器
    • execution/effective address:处理地址。
      • 访存指令,ALU得到地址,将结果放到寄存器中
      • register-register运算类指令, ALU执行操作码指定的运算,将两个寄存器中的值进行运算
      • register-immediate运算类指令,
      • 分支指令,
    • memory access:访存,访存阶段如果是运算类指令,则绕过访存直接进入寄存器,如果是分支类指令则有其他操作。
    • write back:写回,结果写入寄存器中,不管是从memory sytem或者ALU中来的。目标寄存器在两个位置之一(rd或rt),取决于操作码
    • 这是一个五拍的工作过程
  • 流水线MIPS是再流水阶段增加流水线寄存器(锁存器),寄存器名字与它们连接的状态有关:
    • IF/ID — ID/EX — EX/MEM — MEM/WB
    • 共有四个。

提高并行化有三种出路:资源的大量使用、时间重叠、资源的共享。流水线以时间重叠实现并行。

流水线的操作步骤

  • 取指令:
    • 把存储器中的当前需要执行的指令,取出来放到IF/ID寄存器中
    • 如果操作码是分支类指令,并且分支条件为true,把后边流水线计算的结果放到寄存器中,这个地方可能出现等待;否则就PC+4
  • 译码段:
    • 两个源操作数寄存器送到ID/EX
    • 指令和下一个PC从IF/ID传到ID/EX
    • 立即数进行扩展,因为立即数都是8/16位的,需要扩展后参加运算
  • 执行段:
  • 访存段:
    • 对load/store指令,把锁存器的指令传过来
    • 存储器的输出接过来
  • 写回段:
    • 写回到rd或者rt,因为其中一个是register-register指令,一个是register-immediate,所涉及的寄存器不同。

指令流出:指令从译码段进入执行段,并不是所有指令都可以流出,有的译码之后不能执行。

数据冒险:在一个指令执行的时候,所需要的数据(状态)对前边指令产生依赖关系,只有在流水线上才发生如此的依赖关系。

  • 四种可能的组合
    • read after write
    • write after read
    • write after write
    • read after read(不是冒险)

空转(Stall):机器并没有停但是没有干什么,出现冒险时控制器插入stall,避免某些指令的提前,可以通过比较流水线寄存器来检测冒险。

定向(旁路,bypass):为了尽早获得数据,减少因为数据冒险而导致的空转,越早拿到数据,空转的周期越少。

流水线的分支会产生问题,已经有一组指令进来了,但是可能会进入另一个分支,在译码阶段就知道了,将需要进行计算的分支条件进行提前判断。

例外/异常(Exception):

  • IO设备请求
  • 调用操作系统服务,通过和异常类似的方法处理操作系统的使用
  • 断点
  • 整数上下溢出
  • 浮点计算异常
  • 缺页
  • 寄存器访问未对准
  • 使用了非法指令
  • 硬件故障

有一些例外是同步的,有一些是异步的(网络请求,IO请求);
可屏蔽的中断和不可屏蔽的中断;
指令间的和指令内部的;

机器能够在碰到例外后进入一种有序的状态,作为体系结构设计的时候,当发生例外并被处理之后,可以实现状态“可预测”,有很多种策略:

  • 强迫指令流中止,但是如果流水线长且可以乱序执行时难以实现;
  • 不允许产生例外的指令把结果放入寄存器;
  • 例外处理程序将PC保存下来。

精确异常:明确地确定哪一条指令导致了例外,这种情况下称为精确的异常处理。机器内部导致的例外可以精确定位,外部请求导致的例外不用定位。例外和指令处理的各个步骤均相关:

  • IF:取指令时的缺页中断,内存访问的不对齐,存储器保护错误
  • ID:译出来的指令不知道是什么
  • EX:计算意外
  • MEM:页失效,不对准等

流水线中的多周期:有些指令耗时长,有些耗时短,如果指令执行时间差距不大倒还好,可以均切分。指令过长导致流水线出现:指令在执行中间出现机器调度不确定性;导致浮点部件在结构上出现冒险,搞不清楚指令到底执行完了没有,或者在等待结果的时候不知道能不能等到。

如果不能把所有部件设计成等长,则设计成不等长,在MIPS中,把执行段设计为4个部件:整数部件、浮点加、乘、除四个部件,其他不变。

流水线的延迟和执行下一条指令要等多久,这两个时间是流水线的重要属性。

流水线中结构不足所导致的风险,和流水线密度所导致的风险(每一拍所产生的结果与前后都有依赖,具有反馈性,需要保证旁路通道多)

指令结束的次序与指令输入的顺序不一定一样,先写后读的风险大得多。

动态调度和静态调度:静态调度又称为编译器调度,在程序执行之前对程序的指令进行排序;动态调度是开始执行发现执行顺序不好,则重新进行排序,通过硬件办法重新排序减少机器空转,优点是对于程序静态分析时看不出来的情况可以进行调度,编译器也可以简化,且硬件的事可以交给硬件自己去做,但是硬件成本大大增加,复杂性增加。

为实现动态调度,流水线必须具备以下功能:

  • 允许按序取多条指令和发射多条指令——取指(IF)流水级允许按顺序取多条指令进入单口暂存器(single-entry latch)或队列(queue), 指令然后从latch或queue取出,进入ID节拍。
  • 能检查并消除hazards——将ID流水级分为独立的两级:Issue级和Read operand级:
    • Issue级功能——指令译码,检查是否存在结构冲突(即在这一流水级解决结构冲突问题);
    • Read operands级功能——等到无数据冲突(RAW)后, 读出操作数,即在这一流水级解决数据冲突问题。

记分牌算法。需要足够资源和没有数据相关,记分牌是枢纽,所有的指令都要经过它留下执行的记录和依赖条件,如果记分牌决定指令不能立即执行,会将指令进行重排并决定何时可以执行。

记分牌是一集中控制部件,其功能是控制数据寄存器与处理部件之间的数据传送。在记分牌中保存有与各个处理部件相联系的寄存器中的数据装载情况。当一个处理部件所要求的数据都已就绪(装载完毕),记分牌允许处理部件开始执行。当执行完成后,处理部件通知记分牌释放相关资源。记分牌中记录了数据寄存器和多个处理部件状态的变化情况,通过它来检测和消除或减少数据相关性,加快程序执行速度。

如果在MIPS上做记分牌,要在指令的译码阶段检查结构和数据冒险。可以解决:写后读相关,解决乱序结束。把指令译码阶段分成两个部分,指令的结构冒险和数据冒险给它分开,所以要拆成两步。

  • 第一步:指令流出,条件是它所使用的功能部件是空闲的且所要写的目标寄存器没有被别人写,检查了结构冒险和数据冒险中的写后写冒险,因为前边可能有超长的指令还没完成。
  • 第二步:读操作数,指令流出之后,所要的数据还没来,这条指令读操作数就读不出来,就要等结果。
  • 第三步:运算,直到运算完成。
  • 第四步:写回,检查读后写冒险,等别人把数据读走之后再写。

记分牌并没有发挥定向通道的优势,必须读写分开。

总结一下:
动态调度技术需要将ID译码段分成两个阶段:1是发射,2是读取操作数。发射阶段对指令进行译码,检查结构冒险(例如有四个运算器:整数运算、加法器、乘法器、除法器,检查该指令需要使用的运算器是否正在被占用)读取操作数阶段检查数据冒险(读之前检查寄存器的值是否已经写回,或者是否会覆盖了之前的值)。数据冒险的解决方法(读写冒险(RAW):将指令和操作数保存起来,然后只能在读操作数阶段进行读取;写写冒险(WAW):检测是否有其它指令会写回到相同的寄存器(检测到冒险),有则等待,直到其它的完成)

发射阶段:假如检测到没有结构冒险和数据冒险,那么记分板将会将指令发射到相关的运算器,假如结构冒险或者写写冒险发生了,那么该指令将会等待,直到冒险消失为止。我要使用的功能部件忙标志位为否且指令所要写的状态是空。填写三个寄存器(目标寄存器,第一、二操作数寄存器,填写的是寄存器编号)

读取操作数:没有数据相关了以后(之前的指令不会写回源寄存器或者正在写寄存器的情况时,读写冒险),读取操作数。读取操作数后将交给运算器,之后开始运算。发送到运算器的顺序可能是乱序的。

之后就是执行段以及写回段了。没啥好说的。执行段在完成计算以后会通知记分板。记分板直到计算已经完成了,那么它进行读写冒险检验(即写之前是否已经读取了寄存器的值,例如 ADD F10,F0,F8 SUB F8,F8,F14,这里SUB指令写回时要检查ADD指令的F8是否已经读取了,仅此而已)假如检测到冒险,则等待,不然就可以写寄存器了。

记分牌的构成:

  • 指令状态,
  • 功能部件状态,很多个域
    • Busy 标识该器件是否正被使用
    • OP 该器件正在执行的运算 例如 + - * / 等等
    • FI 目标寄存器
    • Fj,Fk:源操作寄存器
    • Qj,Qk: 如果这两个数据没有谁将生成这个数据(源操作寄存器正在被什么单元所处理),如果是NO的话说明已经拿到数据了或者数据尚未准备好
    • Rj, Rk 表示Fj Fk是否准备好的标志位
  • 寄存器状态,标识哪一个存储器将会被写回

这是一种以记分牌电路为核心的设计方法。

指令级并行(ILP)

指令之间有一种特征,可能会并行地执行而不影响结果,正是要挖掘这个特点使指令并行地执行。一种是动态办法(依赖硬件定位并行性),一种是静态办法(依赖软件)。

流水线CPI = 理想流水CPI + structural stalls + data hazard stalls + control stalls

先进的流水线:不区分动态静态和软硬件,所有的技术都与编译器结合。

ILP的概念:

  • 基本块:一个没有分支的指令块
  • 串行代码:只有少量的并行性。
  • 操作系统代码的基本块较长

跨越多个基本块的指令级并行主要是在循环级探讨并行性,这是最常用的提高并行性的方法。需要将循环级并行转成指令级并行。最常用的方法是循环展开,可以通过编译器或者硬件实现。循环的每一次迭代执行可以与其他迭代重叠,需要确保循环中涉及的数据不会干扰。

向量处理器作为专用部件应用在图形处理器中。

数据相关和数据冒险:相关导致冒险,冒险导致空转,空转导致流水线效率下降。数据相关可能会产生冒险,尽可能减少机器的空转。

名相关:分为两条指令都写相同寄存器和读后写两种,读后写可以通过名字的改变消除。

区分数据相关和名相关:是否在指令之间发生了数据传输,数据相关发生了,名相关没发生。克服名相关可以寄存器重命名。

控制依赖的调度有两个基本原则:与分支指令控制相关的指令不能调度到分支指令之前去,与分支指令无关的指令不能调度到分支之后。

数据流前后的依赖关系需要数据依赖和控制依赖的协调,且数据流依赖设计链式依赖。

Tomasulo算法

核心思想:硬件的动态指针技术。动态解决RAW,允许指令乱序流出。

两点显著不同:冒险检测机制不像记分牌一样集中在电路上,而是分布在算法中的。不检查WAR和WAW,因为已经被算法消除了。

重要概念:保留站(一种虚拟功能部件)。就是一个缓冲,每个保留站中保存一条已经流出并等待到本功能部件执行的指令(相关信息)。里边保存了指令和操作数,以及等待执行的所有条件。

在一条指令流出到保留站的时候,如果该指令的源操作数已经在寄存器中就绪,则将之取到该保留站中。如果操作数还没有计算出来,则在该保留站中记录将产生这个操作数的保留站的标识。

也发挥了寄存器重命名的功能,原来访问寄存器a,缓冲以后,不再访问寄存器a,而是去访问缓冲。

记录和检测指令间的相关,操作数一旦就绪就立即执行,把发生RAW冲突的可能性减少到最小;通过寄存器换名来消除WAR冲突和WAW冲突。

过程:

  • 从指令队列的头部取一条指令。
    • 如果其操作数在寄存器中已经就绪,就将这些操作数送入保留站r。
    • 如果其操作数还没有就绪,就把将产生该操作数的保留站的标识送入保留站r。
    • 一旦被记录的保留站完成计算,它将直接把数据送给保留站r。
    • 如果没有空闲的保留站,指令就不能流出。
  • 操作数来了之后运算。两个操作数都就绪后,本保留站就用相应的功能部件开始执行指令规定的操作。
  • 得到结果后放到共用区域(common data bus),共用区域链接所有可能需要数据的部件,cdb发出广播,所有需要这个结果的部件将同时拿到这个结果,这样可以大大减少连线量。在具有多个执行部件且采用多流出(即每个时钟周期流出多条指令)的流水线中,需要采用多条CDB。每个保留站都有一个标识字段,唯一地标识了该保留站。
  • 拿到结果之后结果再消失,数据放到了公共区域,那么要写的目标寄存器也要到公共寄存器去拿,减少了仲裁机构调派部件拿数据的过程。
  • 所有保留站有各种标志,用来进行各种数据状态的检查。

执行步骤:

  • 首先把浮点指令送到指令队列
  • 没有结构风险的时候就把指令流出
  • 根据需要送到确定的运算部件或访存部件
  • load或store把指令送到访存的缓冲中去
  • 如果访存操作数没有被取到,就把产生这个数的浮点功能部件的编号取过来,实现了寄存器到保留站的重命名过程,把寄存器重命名到保留站,消除了同名。
  • 结果有效时,写到cdb中。

每个保留站有以下6个字段:

  • Op:要对源操作数进行的操作。
  • Qj,Qk:将产生源操作数的保留站号。
    • 等于0表示操作数已经就绪且在Vj或Vk中,或者不需要操作数。
  • Vj,Vk:源操作数的值。
    • 对于每一个操作数来说,V或Q字段只有一个有效。
    • 对于load来说,Vk字段用于保存偏移量。
  • Busy:为“yes”表示本保留站或缓冲单元“忙”。
  • A:仅load和store缓冲器有该字段。开始是存放指令中的立即数字段,地址计算后存放有效地址。
  • Qi:寄存器状态表。
    • 每个寄存器在该表中有对应的一项,用于存放将把结果写入该寄存器的保留站的站号。
    • 为0表示当前没有正在执行的指令要写入该寄存器,也即该寄存器中的内容就绪;非全0的时候时保留站的编号。

分支预测

当指令流出速度快时,流水线中的指令是多指令流出,如果遇到分支则会出现问题。前边讲过静态的分支预测,假设分支总是成功/不成功等条件,很好的帮助程序提高性能。动态分支预测使用硬件对程序进行预测,依赖于程序的动态特征和执行过程。在分支预测时,假设分支指令是成功或不成功。

分支预测的精确程度、预测正确和不正确的开销比较都会影响分支预测是否成功。最简单的分支预测方法是看上次的分支结果。

一种办法是BPB(Branch Prediction Buffer),记录分支历史的进入分支数,用于下一次分支特征的预测。记录一个分支指令成功或不成功,放入缓冲中,下一次指令再来的时候查这个缓冲,有多大程序就有多大缓冲,因为程序中哪个是分支不知道。通过指令地址进行记录的同等检索,下次再进入的时候再检查是成功还是不成功。BPB的buffer还是个小的寄存器,用指令的地址进行同等检索,所记录的是分支指令发生还是不发生。

绝大部分指令不是分支指令,如何把分支预测缓冲做的小,比如做到一半那么大。采用地址折半,上半截主存和下半截主存映射到同一块地址,并且实际预测正确率不降低。减到一半还嫌多,那就先做一个512个入口的缓冲,使用指令地址的低位来访问缓冲,效果也不错。这样就是取模了,可能会冲突,如果问题严重的话就加大分支预测缓冲,分支预测错了则将那一位反转。根据被预测指令是否成功画出状态转移图:

上图只有一位,比较浅薄。可以让历史更深一点,再加一个位,构成两位的分支预测。一个预测必须失败两次后才能改变。对4k的缓冲,最高命中率达到82-99%,这么高的命中是因为可能有的程序有很多循环。

浮点指令猜错的概率要比整数指令猜错的概率小,因为浮点计算主要面向科学计算,循环多,所以猜错概率小。

预测器的位数在2位和n位的时候差别不大,所以很多系统只使用2位的分支预测。做4k个入口和更多的入口效果差不多,所以只有4k个入口就够了。

相关分支预测,现在的分支预测是不是成功要根据上一个分支预测是不是成功,实际上是做两级,即做两个1位分支预测器,如果上一次预测成功则使用一个预测器,如果上一次预测不成功则使用另一个预测器,使得本条分支预测的结果基于上一次分支预测。

多指令流出:每个中期发出4-8条指令,必须要使得指令流得到更大的带宽,有三种方法:

  • 分支目标缓冲(BTB),另外一种动态分支预测方法,在取指令阶段对btb进行搜索,如果不在表中就看是不是分支指令,而且是不是成功的分支指令,如果是,但是表中没有,则放进去,如果表中有了就直接拿出来执行;如果不在btb中且不是成功分支(不是分支或不是成功的分支),这种情况下IF没有困难,都是PC+1,。通过查表提前知道对应的pc值和next program counter,只要之前这个指令来过,就记下来分支地址和它的转移目标地址,下一个IF到来的时候就不需要取指令了。
  • 集成化指令分派部件
  • 预测返回地址

分支预测的一个变种:branch folder,不仅有PC和next PC,还把指令放到表中,直接在译码段就开始比对。

两种办法可以使得机器一拍流出一堆指令,使CPI小于1,超级标量处理器,一种是VLIW(very long instruction word)。超标量是标量集合,把彼此不太关联的一些指令组合起来,一次发出的是若干个指令,每拍流出的指令数是变化的。可以采用动静态方法实现超标量,机器不存在在执行中调整指令顺序的能力,编译器对指令顺序进行调配,调整了优化参数打开超标量之后可能会不对!

VLIW指的是一条超长指令字,经典的概念是不允许乱序流出的。它是一个拥有固定数量指令的指令包,有若干种特定类型的指令组成,机器一次发出的是一个指令包。

静态调度超标量:指令有序流出,所有的流水线冒险都必须在编译时预先检查,在流出时如果有冒险就不允许流出。编译器的工作量非常大。

现在使用的超标量是把指令打一个包,让他去执行,这个指令包一般会对指令有要求,指令流出时一拍内流出多条,指令总线做宽一点,大家排好队一起往前走,这样很简单,但是电路实现很复杂,指令要控制好先后顺序,不能出现无序流出。还可以把指令流出这一步切成流水线,把流水线本身的一站作为子流水线。

超标量机器指令取:取指令并用64位译码器译码。

  • 从cache中取两条指令;
  • 确定是没有指令、1条指令还是两条指令;
  • 把它们送到正确的功能部件。

静态超标量不允许乱序流出,有一些特定的顺序不可乱。

指令流出的过程中,允许两个指令同时流出,有一个限制,浮点指令流出时需要一个整数指令寄存器,在进行整数和浮点调度时,不能分开调度,要统筹考虑。

现在所使用的超标量寄存器每个时钟4条以上,包括了上述两种方法。实际上对RISC机器流出4条多。像x86机器可能流出3条,但是这三条CISC指令可以拆出20多条指令。

超标量的时候有一些限制:

  • 浮点功能部件不能被充分利用,需要更少的整数操作;
  • 需要大量的指令级并行(相关性很小的部分);
  • 超标量时一个循环的判断分支带来了不能并行的阻碍。

控制的相关性引发的控制冒险可能会导致指令的“空槽”,超标零的性能被限制,需要前瞻执行,用来克服类似分支指令导致的麻烦。最好能够把分支指令当成普通指令直接扔进去执行。

基于硬件的前瞻和预测:允许指令提前流出执行。必须有动态分支预测,前瞻是一种保证,保证预测不会影响全局。加上动态调度。必须要有undo的功能,来处理前瞻执行不正确的情况,这样实质上是基于大量缓冲的功能。

基于硬件的前瞻性执行做了一个确认段,实行基于数据流的检查,只要数据流是正确的,就可以保证执行是正确的,因此要添加确认段,确认正确了再写进去。这里隐含了一点,指令可以乱序流出执行,但是确认是顺序的,这说明指令在通过指令流出部件的时候被打上了某种标记来标志它的顺序。所有的结果包括例外都要得到确认。

机器基于硬件实现前瞻,采用复杂电路解决的是分支问题(控制相关问题),指令级并行开发的深的话,如果不能提供足够的并行度,则造成浪费。硬件的前瞻执行是分支指令的预测过程,很多指令通过这种办法在控制相关未解决的情况下执行了。确认执行错了之后,可以回退,撤销之前的执行。

执行乱序、确认有序是一个排队等待的过程,要有一种排队机制来支持确认,它实际上是一个缓冲过程,这个排队过程称为ROB(再定序缓冲,Reorder Buffer),保存对机器执行有影响的状态。保存已经执行完但是没有提交的指令的结果,提供额外的寄存器作为保留站。

三个重要的ROB域:

  • 操作域,用来保存指令,例如分支指令、load/store指令、寄存器操作指令;
  • 目标域,记录目标寄存器,可能是寄存器也可能是存储器的地址,这个结果要被写到哪里;
  • 值域,在确认的时候保存结果,直到指令真正被执行。

工作流程:

  • 指令流出
    • 如果有保留站且再定序缓冲有空,指令流出,一条指令流出至少要占用两个资源。如果指令所需数据在寄存器中或再定序缓冲中存在,则取出来送到保留站。
    • 指令流出的时候先做一些分类,之后做译码,决定做什么操作,扔到运算部件去。
    • 进行标志状态的修改
  • 指令执行
    • 如果有操作数未准备好,就监视CDB(common data bus),这个过程检查数据相关;
    • 指令可能会等很多拍
  • 写结果
  • 确认过程

一个store指令的确认:前提是被确认的指令到了再定序缓冲的顶部,要把结果寄存器更新掉,把它从ROB种清除掉。

一个不正确的分支预测表明前瞻执行是错误的,刷新ROB,重新从正确的分支开始执行,一个正确的分支预测则使得这个分支与正常的指令类似。

浮点程序中的分支是有极强的规律性的,整数程序中的分支不明显。

基于存储器地址的前瞻性执行:减少对于顺序地址计算的限制,使用硬件预测依赖。

堆栈、寄存器的使用对ILP的效率影响。
别名分析:对程序执行的并行性有影响。

只有发现依赖之后才前瞻,首先找到相关性,相关性基础上处理器进入前瞻状态。

数据值预测/地址值预测:很难,是一种很精确的前瞻方法,不允许有误差,。如果能进行完美的值预测,则不需要编程啦。

线程级并行:可以以线程的方式组织程序运行,一个线程是一个独立拥有数据和指令的实体。可以根据需要派生线程。多个线程并发执行有一个重要概念:同时多线程(SMT),既能同时执行,也要同步。

编译调度

使用编译技术提高流水线性能,减少因为数据冒险导致的阻塞和分支预测。

假定使用5站流水线,已经完全流水,如果没有相关性则会顺畅地流下去,没有任何阻塞;如果有分支指令则在分支指令及其前一个指令之间有1拍延迟,整数部件load有1拍延迟,整数部件无延迟。

如果是分支指令,取指令1拍,指令译码1拍,产生结果得到分支目标1拍,这个结果不经过任何过程再返回。如果采用锁定机制(发现是分支指令就不取下下一条指令了),这时已经到指令译码了,刚好已经取进来一条了,这就叫做分支延迟槽,再往后的指令先停下,跟进来的这条指令就允许向下流,或者更彻底,只要发现了是分支指令,跟着进来的那个也不管了,这会导致两拍的开销。

循环展开、指令调度、寄存器重命名。

  • 确定指令的调整是否是有效的,移动的指令不影响执行结果;
  • 确定循环和循环之间不存在相关性;
  • 使用不同寄存器避免在使用相同寄存器时的不必要约束;
  • 在循环展开时注意处理结尾的迭代;
  • 明确load和store在循环展开时是否可以交换,不同迭代的load/store相互独立,分析内存地址明确是否是同一地址
  • 如果存在相关性,必须确定和原始代码的相关性一致。

通过使用寄存器重命名,在两次迭代之间减少相关性,而不影响一个迭代内部的相关性,也有代价,比如多用几个寄存器,或者代码体积会增大,编译器更复杂。

有的情况下编译时就能预测分支是否成功,它的成功率分布很离散,从9%到59%。改成基于方向的,如果程序到了分支,如果程序往前走(可能是if),认为不成功的概率居多,如果往回走(循环),成功的概率居多,猜错率在30%-40%。

改一下,使用程序的上下文信息,每次预测的时候使用上一次预测的结果,可能会生成更精确的预测;再进一步地,基于统计信息,先得到一些统计信息,基于统计预测分支的走向,这种方法的指导性很不强。

超长指令字机器,首先要确定机器的最大并行度,全靠软件做,先把指令打包,确定封装包之间的相关性,在所有的指令中间,确定一条指令跟正在被处理的所有指令是否相关。

静态超标量通过编译器调度来帮助处理器达到更高的性能,动态超标量不需要编译器调度,但是需要硬件的开销。

超长指令字是对编译器及其依赖的一种技术,最小化潜在的数据冒险延迟,将一些指令封装进一个流出包中,也不需要检查潜在的相关性,执行中认为去拿的数据一定是对的,如果没有一定的保障,则可能会拿不对的数据,需要编译器进行控制,编译器需要控制指令包内、包之间的相关性,好处就是硬件会很简单,不需要考虑前瞻和相关性,仍然能达到很高的性能,VLIW使用多个独立的功能部件,把一组指令按某种方式组合,构成一个长指令字向外发送。

每一个VLIW功能部件需要16-24个二进制位来表述功能部件完成的工作和寄存器。可能包含七个部件:2个整型部件,2个浮点部件,2个内存,1个分支部件。一个长指令字里出去的指令应该都是无关的,部件之间不存在数据交换通道。

早期超长指令字格式非常死板,这个地方是一个整数就是一个整数,指令部件就是一条一条往运行部件送,代码二进制不兼容,必须要依靠硬件和软件的适配。所以很难见到超长指令字机器。如果打包的时候没有要求的操作往里填,则填空指令,指令槽的利用率可能会比较低。

VLIW问题:

  • 代码数量增加了;
  • 每个指令之间是互锁的,在执行的时候如果一个指令被卡住,后边都会被卡住;
  • 二进制代码不兼容,如果有部件的增删,则要重新编译代码。
  • VLIW对循环展开的次数要求很高,可能不够展开的;
  • 对功能部件利用率比较低,需要插入很多的空指令。

VLIW有压缩的余地:把立即数提出来生成一个立即数域;程序在执行之前可以压缩,从存储器中取出来的时候再解压缩。

互锁机制:所有功能部件操作是同步的,不用判断数据相关,编译器解决,硬件就不用解决了,有一条指令阻塞了,其他的都会被堵住。如果在访存的时候碰到了,访存时间可能很长,指令之间的互锁机制会使性能不可忍受。很多机器在处理时将一些部件从互锁机制解开了,编译器也会解决互锁。对指令流出之后,可以不同步执行。

二进制代码的兼容性跟指令集、流水线结构、功能部件的结构/数量相关,这是超标量机器占主导的主要原因。

代码迁移过程:实用的是对代码进行调整和转换,例如在执行的时候把串行转换成VLIW。

指令的多流出和向量处理器:成本是相当的,向量处理器性能高些;多指令流出对代码要求比较低,不需要向量化,且多存储器的带宽要求比较低。向量往往作为处理器的加速部件。

开发ILP的高级编译技术

通过一部分硬件的支持(前瞻),通过软件技术的方法(编译)提高并行性。

  • 循环级并行:检测和减少迭代之间的依赖,找到并行性。
  • 软件流水线:一种循环展开的过程,解决面向不同应用的问题,不用根据体系结构进行优化。
  • 路径调度:控制指令相关的调度策略,将执行过程看成一个路径。

检测相关性:

  • 进行代码调度
  • 检测循环是否有并行性,检测在执行中时间上的概念
  • 减少名相关

一旦涉及到循环,数组和指针是最头疼的,一个有效工作的循环一般都有数组和指针,也就存在别名(alias)问题,这往往因为数组或向量下标引起的。也需要去找是否存在环状的相关性。

相关发生在两遍循环之间的问题经常存在,一次迭代使用了上一次迭代的结果。只要不存在这种相关性,即使存在其他的相关性,也可以同时流出。

如果一个for循环存在两边循环之间的相关,需要破坏掉相关才能实现并行性,如果没有相关环的话就可以破坏,如果能把上一遍循环的计算拉到这一次循环中计算,这样就能不依赖于另外一次循环。对于两次相关的爹地啊,相关的语句放到一起,不相关的语句拆开放到两次迭代中。语句之间影响并行的因素就清除掉了,但是循环之间必须保存的有序性也要保存。

相关性的检测:可以获得的并行性收到循环次数的限制,循环展开的次数越多越好,有的循环没有那么多次可以供你展开,循环展开也需要更多的硬件资源。需要知道循环不同遍之间是不是访问的相同的地址?更复杂的分析需要知道两次访存是不是请求的相同的(多个)地址。

递归:存在某种相关性,关联很确定,存在比较多的并行性。

两边循环出现循环相关的距离即为相关元素的间隔。相关距离越大,相关的冒险越小,导致机器阻塞的概率也越小,通过循环展开获得的潜在并行性也越大。如果相关距离是5,那可以循环展开得到4个副本,循环距离变为1,如果把循环距离为1的循环展开的话,循环距离不会改变。

如果展开的话可能会增加一些相关性,因为要把一些计算提前,越是循环次数远的,相关性就越长,这样就给并行以机会。

编译器检测相关性:水平极其有限,假设下标函数构成仿射函数,就是一个线性函数,被写成a ( x * i + b)的形式,a和b是常数。

检测循环中是否有相关性,即检测可能数据相关的两个语句所代表的两个仿射函数是否有整数解,如果有,则可能相关。

从理论上说,编译时不能确定认为变量相关,可能会存在一组整数解,但是可能取不到这一组解,可能与加载的运行负载有关。相关检测可能会成本很高,基本就是程序执行的一个过程,每一次迭代之间都可能存在这个问题。

GCD Test:如果不是存在整数解,而在两个仿射函数a ( x * i + b)c ( x * i + d)中,GCD(c, a)能被(d-b)整除,则可能存在相关性。

编译器如何工作:在检测相关性时进行分类,识别名相关并通过重命名或副本技术消除掉,在分析时主要分析真相关(先写后读相关)、输出相关(写后写相关)、反相关(先读后写相关),其他的都是伪相关。

1
2
3
4
5
6
for (i = 1; i <= 100; i++) {
Y[i] = X[i] / c; /* S1 */
X[i] = X[i] + c; /* S2 */
Z[i] = Y[i] + c; /* S3 */
Y[i] = c - Y[i]; /* S4 */
}

Y[i]存在很多相关,写后读、写后写等,S1 S3与S4中的Y[i]存在相关,可以消除,S1中的Y[i]使用中间变量替代;X[i]存在读后写相关,S2中的左边X[i]是最终结果了,不能用临时变量替换,要生成一个临时数组。

1
2
3
4
5
6
for (i = 1; i <= 100; i++) {
T[i] = X[i] / c; /* S1 */
X1[i] = X[i] + c; /* S2 */
Z[i] = T[i] + c; /* S3 */
Y[i] = c - T[i]; /* S4 */
}

现在的结果是都用了数组,其实对Y的处理可以使用临时变量。编译器可以把替换Y的临时变量使用寄存器搞。

编译器可以:做指令的相关性分析,确定访存地址和循环展开的成本;对循环级并行,是不是循环有利于并行;访存是不是存在相关。

软件流水和路径调度

对硬件需求少。
软件流水是一种展开技术,相关性更少一些,得到更大的并行性。是一种对循环的重组技术,从每一遍循环里面提取公用的指令,构成新的循环,这个循环过程中间,从循环体来看,看不到一遍一遍的循环展开,但是从执行上看是在一遍一遍循环执行。

循环的每次迭代是一个指令序列,按照每个循环指令序列平行展开,认为一次循环内的相关指令的相关距离小于两次循环之间的相关指令的相关距离。

竖着的四条指令更可能相关,横着的四条指令相关指令距离更大,相关距离越大则相关冒险更小,所以可以横着实现并行且指令的顺序跟竖着是一样的。对循环重组,横向取指令,总的想法是把原本竖着的循环翻转过来。构成了一个像新的循环像流水线一样的相关距离更大的循环。


把“load、add、store”三条指令展开,开始三句是补偿代码,称为填充期,最后三句称为排空期。循环指令越多,排空期和填充期的指令也就越多。一边循环的指令的长度决定了补偿代码的长度,第一遍循环的最后一条指令作为软件循环的第一条指令,所以第一遍循环的前边所有指令作为补偿代码,类似的,后边作为排空代码。都有一个问题是偏移量的计算都必须要单独计算。

两条指令之间由于一条指令延迟过长,耽误了下一条指令的计算,就可以用常规循环展开进行展开。

软件流水的代码空间比循环展开小一些,没有大量的展开。循环展开有效的减少控制变量造成的损耗,软件流水降低空转、阻塞。

循环展开减少了循环控制变量的修正,如果是多层嵌套的循环,会乘上上一层循环的循环次数,更优化。软件流水主要减少每次循环引发的阻塞,在机器以峰值计算的时候更有效,腾出更多的空间使两条指令的相关距离更大。

基本代码调度:基本块本身是没有分支的程序块,超越基本块研究指令集并行。全局代码调度是跨越分支指令的调度,在循环体内部存在分支指令,调动循环体内部的控制流,从非循环的指令之间也存在并行性,对内部非循环控制流的代码比控制指令调度更复杂。

关键路径:全局代码调度的目的是把代码压缩,压缩到最短的指令序列,不包含分支指令的序列,形成的代码就叫关键路径,这段代码的所有指令会以最大概率从头到尾顺一遍,最可能没有分支指令。

代码的调度:数据的生产消费关系流(写后读)不能改变,代码的例外特征不能改变。在全局指令调度的时候,尽量减少可能产生例外的指令的调度。

全局代码调度实际上需要权衡,是否将一个语句调度到前方,需要进一步分析变量的依赖,这种调度可能会好,可能会坏。

路径调度产生一段可以并行执行的代码,针对分支,首先把程序中的分支抽离,剩下的是路径,这样找到了一个主干,认为主干有并行性,真正执行效果跟程序有关。路径调度比循环展开更进一步,发现跨越分支指令的并行性。路径调度的主要原因是每一拍都要流出大量的指令。

第一步是路径选择,选到一条路分为两步,路径选择大概有一条指令序列可以产生最短指令代码序列完成功能,经过选择的这一段代码就是路径,如果把循环连着控制指令一起展开,如果不成功就出去,呐循环展开本身就是一个关机按路径,产生一个代码序列,如果把控制变量去掉,则变成代码块;第二步是路径压缩,进行代码调整等一系列的修正,得到一串代码,就是可以执行的代码,之后压缩完的代码可以组合成超长指令字的指令包,它是一个全局的代码调度过程,在产生关键路径的中间,保证相关性不被破坏。

路径调度比简单的流水线调度获得更高的并发性,在控制相关上有特点;通过代码的调度跨越非循环的分支指令来预测程序的分支特征。如果对分支指令的信息足够精确,就能够得到非常快的代码。

路径选择第一步需要选择可能正确的路径,认为为真的概率比较高。代码不管经过什么调整,保证结果正确。

路径压缩需要挑出一个指令序列,填满机器所需要的指令。做分支指令调度的时候,有分支指令做好代码补偿,在路径的出入口做好。其中一个关键假设是关键路径执行概率最高,否则做代码补偿就得不偿失。

代码的移动可能导致控制相关的局部特征发生变化,导致某些指令的控制相关性发生变化。

软件ILP策略:

  • 循环展开
  • 软件流水
  • 路径调度

编译器完成指令级并行的实现所需要的硬件支持,把精力集中到分支指令上。软件需要更多了解分支指令的特征,分支指令的特征并不好预测,它的执行是动态的,硬件可以提供一定的支持,例如前瞻性的执行,特别是对指令出故障的时候,对故障断定很精确,也提供一种机制,需要软件来执行这种机制,入条件指令,把if语句转成单条的指令,可以消除相关,把控制相关转成数据相关,在分支指令出现的代码段中也不出现调度问题了,只是数据操作问题,也没有相关问题。最后一种支持是前瞻的,一种是静态的在编译时就处理,设置抑制标志,不让前瞻性的过程扩散,如果扩散了则挂起,或者硬件提供寄存器,软件使用寄存器;动态调度是前边讲的算法。

条件指令是使用编译克服分支相关的一种方法,任何一条指令都带一个条件,如果条件是真的话就执行,是假的话就是一个空操作。这个过程内含了一个控制分支,完成后边数据的操作与前边相关,不再存在控制相关,而是转变成数据相关。第二个是把程序条件处理变成后端。

条件分支指令不允许产生意外,仍然占用运行时间,控制变量必须要预先产生,只有一个条件,只能做很简单的操作,最后是导致机器总体性能受影响。大部分机器支持条件传送指令。

指令作废:程序在执行时,条件应该尽早产生,以避免数据冒险等问题,否则应该作废。

条件指令的限制:

  • 作废的指令依然占用资源
  • 指令的控制变量应该尽早产生
  • 只能对简单的指令采用条件指令
  • 产生性能阻碍

前瞻操作的三种功能:

  • 数据流正确性
  • 例外正确处理
  • 访存的冲突应该被正确识别

执行前瞻的四种方法:

  • 硬件和OS对指令前瞻执行,对某些应用很难实现,需要OS作标志
  • 前瞻指令不允许产生意外,需要编译器在编译时确认哪条指令是前瞻指令
  • 一定范围的指令,如果是前瞻的话,打上指令,抑制影响范围
  • 后援机制,在执行的时候,把结果放到后援存储中,数据也不最终写回,直到前瞻被确认。

指令进行全局调度的时候,例外和相关性不能改变,如果前瞻出现错误且对机器状态产生影响了,那这个前瞻就不能被采纳。

例外大概两种,一种是终止性的,程序不能被继续执行,如访存的保护错误;一种是可恢复的,当例外发生后,机器的机制对其处理,处理完之后可以正常执行,比如缺页。

软硬件联合前瞻,由操作系统和硬件可以处理可恢复的故障。如果前瞻的指令导致了一次终止性的故障,那就返回一个没有定义的值,当OS因为前瞻的故障发现返回一个无意义的值,则认为这是一个不可恢复的故障,进行一系列处理。如果引起终止的指令不是前瞻的,且引起了终止性的意外,那就终止。

既然是前瞻的过程,那最后的时候被前瞻的指令可能会系统忽视掉,它引发的终止性意外也可能被忽视掉。正常情况下一个程序返回一个无定义的值,则会崩掉;但是前瞻执行的时候会加上一个标志,增加确认过程,不会遇到例外就终止。或者加上一个前缀,当加上前缀之后,说明这条指令是前瞻的。或者所有寄存器加上抑制标志,每一条指令都有一条附加标志,告诉系统是不是前瞻的,如果是前瞻的,当碰到例外的时候,这个例外并不是马上就处理,先放一会,一直放到前瞻指令被确认的时候再处理。

这个例外在执行的时候可能会影响到一大批寄存器,也可能带来例外,但是这里的例外只能在指令被确认的时候再处理,所以记录下已经影响到的寄存器,如果一个前瞻指令使用了抑制位被置为抑制的寄存器,即某个操作数被抑制,那这个指令的另一个操作数也会被抑制,这是抑制的传递关系。如果正常指令访问被抑制的寄存器,机器就出故障了。

抑制位带来问题:操作系统需要单独指令控制抑制位。

后援问题:在分支指令之间调整指令,把这条指令定义为前瞻,提供一个reorder buffer,指令执行结束之后进行确认,把结果送到寄存器中。

设置专用check指令,用它顶替要进行前瞻执行的load,这个load就可以到处挪,这个check和load是一对,check检查保存在手里的load的地址和之前的地址,看前边是不是有写后读地址。

多处理器和线程级并行

并行体系结构的分类:

  • single instruction stream, single data stream(SISD)
  • single instruction stream, multiple data stream(SIMD)
  • multiple instruction stream, single data stream(MISD)
  • multiple instruction stream, multiple data stream(MIMD)

多指令流多数据流处理机采用的通用的芯片,提供了一种灵活性,通过软件和硬件的支持,对操作者来讲等价于一个单用户的多处理机,通过多个芯片提供很高的加速比,由于有多个处理器,对n个处理器来说至少有n个线程才能发挥处理能力。

依据互联策略,现有的MIMD有两类,一个是集中共享存储器体系结构,对于处理器和存储器,采用共享特征,多个处理器采用共享的存储器,总线把处理器和存储器联系在一起。由于多个处理器共享存储器,在时间和优先权上是一样的,因此总线采用仲裁机制判断哪个处理器使用了存储器,表现出一种对称。通过对称的策略,把这种机器叫做对称多处理机(SMP),实现了均匀访问,又可以叫做UMA。

把多个存储器分布到节点上,多个节点互联形成机器,带来的系统规模和可扩展性比较好,每个处理器访问存储器的时候大部分时候访问节点上局部的存储器,只有有必要的时候才访问远程存储器,远程访问经过互联网络,有较大时延,每个节点都有处理器、存储器、IO、互连网络接口,形成一个大的存储空间。两个好处:扩大带宽规模,局部存储器的访问是整个系统的大部分,远程访问量小得多,充分利用节点上的带宽,延迟小。缺陷是处理器之间的通信通过互联网络完成,变得比较复杂且有比较大的延迟。

多个处理器之间通信的话,一个是共享地址空间进行通信,多个处理器访问同一个地址单元,假如物理存储器是分布的,则叫做distributed shared memory(DSM),也叫做NUMA,对存储器访问不一样。

对共享地址空间来说,地址空间是共享的,一个空间只有一个地址单元,访问所采用的指令就是直接操作。对于多个地址空间来说,一个逻辑地址可能指的是多个地址空间,实际上是信息传递的多处理器,通过显式的数据传输完成。

对信息传递来说,通过同步方式实现,首先要传送一个请求,才得到一个应答。从另外一种角度,先把数据写过去再发送通知,当处理器之间的通信比较清楚的时候这种方式更简洁,这是异步的方式,提高运行效率。

通讯结构的性能问题:三个影响性能的主要因素:

  • 通信带宽,有处理器、内存和互联机构影响
  • 通信延迟,包括发送开销(数据送上通信的端口)、飞行时间(第一个二进制位从发送端口到接收端口的时间)、传送时间(所有数据除以速率)、接收开销。
  • 通信延迟隐藏,假如是一个串行程序改成并行,在通信延迟期间做其他的事情,把通信延迟隐藏掉,这个隐藏过程是一个重叠过程,把通信延迟的影响降到最低。

算法决定了各个处理器之间的通信量,通常计算和通信之比随着处理器数量增加逐渐降低,处理器数量增加了,通信代价大了。处理的数据增大的时候,计算量增大,通信量也增大,二者之比增大。

小规模时,集中共享存储器是最简单的;围绕处理共享数据解决同步问题。

通过多级cache解决提高整体性能,私有数据一定要进cache,共享数据涉及多个处理器共享,在多个cache都有拷贝,需要解决cache相关性问题,着重从多个备份之间的关系。

如果系统是相关的,必须可以读出最近写入的正确数据,两个方面,相关性(能够读出来哪个值,指的是内容的问题,正确的还是错误的?)和一致性(什么时候能把写进去的值读出来,时间上的问题)。

满足以下条件一个存储系统才是相关的:

  • 一个处理器P对x进行写之后进行读,其他处理器不对x进行写操作,这时返回的数据是P写进去的。
  • 一个处理器对x进行读是在另一个处理器对x进行写之后,两次操作相隔时间很长且没有其他处理器对x进行写操作,读出来的值是另一个处理器写进去的值。
  • 对同一个单元的写必须是串行化的,同一单元被两个不同的处理器执行的两个
    写被所有处理器看上去都是相同的顺序。

相关性(coherence)定义了对同一单元的读和写问题,指内容上的问题,一致性(consistency)定义了读和写相对于其他存储单元的访问的行为问题,指时间上的问题。

读可以是乱序,但是写必须是按照程序规定顺序的。

相关性cache提供了迁移功能,是指数据项能够从远程移动到局部的cache中使用,为了减低延迟和带宽需求。复制指的是把当前的数据拷贝同时在多个cache中存在,也能从降低延迟方面获得好处。

cache相关性协议的关键问题是跟踪任何共享数据的状态,根据状态采取策略,有两类协议。首先是基于目录的方法,专门有一个物理存储器用于保留共享数据状态,查阅目录存储器就能找到共享数据的状态,适合分布式共享存储器结构。snooping(监听)使cache块中既存在数据,又包含了状态,状态是分布式存放的。

通过两种方式完成跟踪,维护相关性,为了保证处理器在对数据进行写之前,进入“专有”状态,把除了我要写的拷贝之外的所有其他拷贝都作废,称为“写作废”,当前要写的处理器对当前要写的数据进行专有访问。由于这是最简单的方法,广泛使用。可以用于监听或目录策略。专有状态保证没有其他的处理器可以读写,所有其他的cache拷贝都作废。通过仲裁把多个要写的请求进行仲裁,把其他的拷贝进行作废,其他的处理器重新读入一个拷贝,基于新写入的数据再写。强制所有的写操作串行化。

写广播(写更新)方式:当新的数据项写入一个拷贝的时候,要更新所有其他的拷贝,这里需要把所有的拷贝都更新。

对于同一个字的多次写,写广播代价大,每次写都要广播,对写作废来说只需要一次广播,这时已经进入独占状态,再次写的话不需要广播了。对于cache块中间的每一个字的写,在写更新协议中多次广播,在写作废中只有第一次需要写。不管是对一个字的多次写还是对多个字的写,写作废都只需要一次广播。

读和写一个数值之间,一个处理器写进入,另一个处理器要看到的话,写广播的延迟比较短,写作废需要重新调入数据块,更慢。

写广播占用带宽多,写作废的带宽需求比较小。

为了完成写作废,首先完成总线的访问,并且把共享数据地址送上总线,让其他的拷贝作废掉,其他处理机监听总线,检测到这个地址在其他处理机的cache内存在,则作废掉。对总线的访问串行化导致了对写的串行化。对共享数据的写要等到获得总线访问权之后。

对于写直达cache,写进数据之后存储器也要生效,如果其他的处理机获得最新值比较容易,从存储器里边取到最新值送到需要的处理器上。

写回cache,大部分最新的数据保存在cache中,存储器和cache不一样。监听总线上的地址,处理器发现总线上的地址和内部的一个cache一致,就把已经修改的数据提供给需要的存储器,因为现在数据已经被修改了,这里的是最新的。

通常的cache块都有自己的tag,标志当前的共享数据状态。有个无效位,说明是不是有效的。增加一个额外的共享状态位表示这个块是否是共享的,增加一个脏位看是否被修改过。

当只有一个拷贝时,这个处理器就是这个cache块的拥有者。cache地址和总线地址需要对比,地址的对比是串行的,cache只能满足一个的请求,所以监听控制器的操作会影响访问速度,如何提高对比速度,分成两份,一份由CPU对比,一份由总线对比;或者多级cache,CPU访问一级cache,总线访问其他cache,CPU和总线互不干扰,提高效率。

如果CPU操作了一级cache,总线可以操作其他cache。基于总线的相关性协议通过有限状态控制器实现的,控制器分布在每一个节点上,相应来自处理器和总线的不同请求,对两边的不同操作进行处理,并比较地址。对写命中和写失效都要出现总线事务,合并起来共同看作写失效的状态,减少要处理的事务。

最重要的是事务的处理是原子性的,操作必须是一气呵成不能中断的,全部过程不能有其他的插入。非原子性的事务可能会引入死锁风险。只要保证原子性的逻辑性,中间可以插入其他操作,只要不改变数据值,特别是写操作。为了充分利用并行性,且保证原子性,执行一些状态转移操作。

把状态变迁用状态转移图表示出来。

处理机之间的通信引起的失效叫相关性失效,可以分成两个部分,真共享失效、假共享失效,区别在于对同一块中间同一个字/不同一个字的共享,因为cache的共享是基于块的。

失效可分为三种:

  • 强制性失效:第一次读入这个块的时候一定会失效
  • 相关性失效:对相关数据处理引起的失效
  • 容量失效:cache容量不足导致替换引起的失效

cache增大失效率降低,块大的话失效率也降低。

在监听协议里,每一个cache的状态分布到每一个cache块中,这种情况对分布可变规模的存储器有影响,因为分布可变的存储器使用互连网络,再使用监听协议就不适合了。一种可能的替代方法是目录存储器,把所有存储器共享的状态存下来。

现有的目录协议将每个块设置一个目录项,目录项的数量是存储块和存储器数量的乘积。为了防止目录成为整个系统的瓶颈,把目录存储器分布到系统中间,每个节点增加一个目录存储器。

目录协议两种必须实现的操作:处理读失效,处理对于共享的干净的cache的写,处理对共享块的写失效是上述二者的结合。目录要跟踪cache块的状态。首先在共享状态下,一个或多个处理器都有拷贝,未缓冲状态没有处理器有这个cache块的拷贝,专有状态是只有一个处理器有这个cache块的拷贝,如果这时候写的话,存储器的拷贝就是旧的,写完之后处理器是这个块的拥有者。

考虑到要写,分布共享存储器结构中,更需要写作废的支持,因为通过互联网络进行广播的话代价更大。共享状态最简单的支持是位向量,当块被共享的时候,每一位都标志出这个处理机是否有这个块的拷贝。

使用互连网络无法使用仲裁功能,仲裁是总线特有的;互连网络是面向信息传递的,总线是面向事务的,因此互连网络必须采用发送确认的方法。

局部节点是请求产生的节点。home节点是请求的存储单元和目录项所在的节点。远程节点是有cache块拷贝的节点。物理地址空间是竞态分布的,存储器地址清楚的话,节点号也清楚了。例如,地址高位代表节点号,低位代表位移。

目录协议的实现:目录存储器中,cache状态是反应数据状态的真实状态的,为了解决相关性,存储器中每个数据项变化时都引起目录项的变化,所以分布共享存储器中目录的操作占了总操作数的一半。送到目录中的信息导致2个操作,首先是更新目录的状态,由共享进入专有等;然后发送相应的信息,以满足请求。存储块可能是未缓冲的,可能是有多个缓冲的,或者专有的。三个状态下,目录所执行的操作不同。

未缓冲时,即在数据块还在存储器中时,读失效的时候,请求的处理器要求存储器将数据送到处理器cache中,置为唯一共享节点,块置为共享状态。当写失效时,首先要送给请求方处理器这个数据块的值,把它在共享集合上置1,变成共享状态。

处于共享状态时:读失效时,请求处理器从存储器中收到数据,请求的处理器被添加到共享集合中,写失效时,请求的处理器要进行写,首先拿到数据,所有在共享集合中的处理器收到失效信息,只留下请求的这个处理器,共享集合仅包含请求的处理器。

在专有状态下,所有处理器中只有一个拷贝。读失效意味着这个数据块将进入共享状态,发送取数据信息到拥有这个块的处理器,导致了块的状态变成共享状态,拥有者把数据发送到存储器,在从存储器把数据发送到请求的处理器,把标识进行更新。数据写回执行把cache中的脏数据写回到存储器中,拥有者把块送回地址所在的节点,这个块成了未缓冲的。写失效意味着专有的写状态要转移到另一个处理器,这个块将有一个新的拥有者,首先要发送信息到老的拥有者,使得cache作废,然后把原来的数值送回到目录,在把数据送回到请求方,请求方拿到了数据,成为新的拥有者。

当块处在专有状态时,读写失效时,先把数据送回到目录,从目录再存储再送到请求的节点。为了提高效率,把数据直接送到请求节点,再送到存储器节点,这个操作可以把间接的变成直接的,但是在实现中增加了复杂性,同时使得死锁可能性增加了,在送给使用方的同时送给存储器。

基于目录的方法用空间换时间,减少了访问量但是增加了目录存储器,大小与系统规模N的平方成正比,为了改进,提出了有限映射和链式结构两种。

  • 有限映射假定在不同cache中的拷贝数量小于一个常数,可以通过比较少的位向量标识块,但是有m个的限制。
  • 链表结构不存在有限映射中m的限制。

数据分布引起整个系统中的带宽使用效率的不同。

同步

多个处理器在时间上协调一致。典型的同步机构时系统在硬件原语的支持下通过软件实现的例程。高竞争状态中同步起到一致协调的作用,也成为系统的瓶颈。

硬件原语是面对用户的一些汇编指令,与基本指令不同的是涉及硬件操作多。实现同步的最主要能力是使用一组硬件原语,自动读取修改共享数据。硬件原语是构成面向用户的同步操作的主要组成。对存储单元的读写需要多条语句,如果在这多条语句中插入其他的指令,可能会造成同步失败,使用原语可以降低出错减少时间。

典型的硬件原语操作是“自动交换”,把寄存器的数据和存储单元的数据交换,通过交换,把存储单元中的数值拿到。假设建立一个锁,0代表这个锁可用,1代表不可用,要实现最后读出来是0,表明得到了锁。

存储单元的读写通过仲裁,只能有一个首先完成,多个处理器竞争单元时不会产生冲突。

使用交换指令使用原语的根源在这个原语的操作是一气呵成的,没有其他间隔打断。读和写这两点在实现同步上是不可缺的,如果没有这两点构成一个同步原语,不能实现的。

test-and-set:首先读出来一个数据,测试是不是满足,如果满足则置入一个新数据。

fetch-and-increment:取出来单元数据,自动加一,然后再写进去。

使用一致性实现一个旋转锁,一个处理器不停的测试看是否能获得锁,直至成功,修改锁为占有状态。旋转的过程通常在用户希望这个锁持有的时间很短,低延迟使用时间短的时候适合旋转锁。

很多处理器竞争一个锁的延迟和复杂度不是线性增长的,几乎是二次方,也造成比较大的流量。串行化是锁开销大的最主要原因。竞争大的时候降低串行化,形成有序的通信。软件实现的方法:所有进程争抢这个锁但是只有一个进程能抢到,第一次获得锁失败的话第二次就要延迟一会再去试探。

或者排队锁方法,通过软件构造等待处理器队列,通过队列进行排序,通过顺序有序使用资源。

组合树方法:在软件上实现对大规模机器同步的方法,把大量的竞争化解为对多个小点的竞争,使用n元树结构,一般来说使用k表示树的扇入(fan-in)。在k元树的最底层开始同步,逐级向上,直至根节点。如16个节点的话,就是一个二层的树,之前的代价是16的平方,现在是两倍的4的平方。

总线上10个处理器,同时完成对锁的竞争,假设每个总线事务100个时钟周期,忽略读写占用时间,只计算在竞争锁的时候的代价,对10个处理器获得锁的时候要占用多少总线事务。当i个处理器在竞争时,有i个链取获得锁、i个条件写来尝试获得锁,1个写,一共2i+1个总线事务,要全部通过要进行累加,对n个处理器来说,一共n(n+1)个总线事务。在同步点上进行同步造成相当长的延迟,同时影响了总线的访问。

栅栏同步(barrier):强制所有进程等待,直到所有进程到达栅栏,再一起释放。这个过程通过两个旋转锁实现,一个是保护计数器,计算到达栅栏的进程数;一个是用于把所有进程卡在这,一旦所有进程都到了就释放。可能会出现进程组中的一个进程永远离不开barrier。

通过sense-reversing区分不同进程到达的barrier是否是同一次进入barrier,如果不是同一次的话可能会造成死锁。

硬件对大规模同步的支持:有些机器通过硬件实现了栅栏同步,类似组合树的方法。保存关于同步的处理器并对其排队,叫做排队锁,硬件上使用位阵列,把先后到达的每一个处理器进行排队,通常是与目录结构结合在一起。

排队锁:当对锁变量第一次失效的时候,这次失效被送同步控制器,如果锁被释放,直接从队列里返回下一个处理器,如果锁不可用则创建一个排队记录。当锁释放的时候,选择下一个处理器进入使用状态,把下一个处理器拿到队列前端。

区别是首次访问锁还是一直在锁里边,这样可以实现排队操作或者释放锁的操作;

另外一个原语是fetch-and-incement,自动取出一个变量并增加数值。这个指令会使barrier指令有改进的空间,因为它将取并增量两个操作结合在一起。现有的MPP机器都采用的是硬件barrier方法。

一致性问题:什么时候能看到被其他处理器更新过的数字,什么时间生效,通常使用共享变量通信。通过读出被写进去的数据来检测是否已经更新过。

两个代码段:

1
2
3
4
5
6
7
P1:
A = 0;
...
A = 1;

L1:
if (B==0) ...

1
2
3
4
5
6
7
P2:
B = 0;
...
B = 1;

L2:
if (A==0) ...

如果这个程序能同步正确执行的话,A和B都在cache里,如果写总是能马上生效,那两个if都绝不可能同时为真,因为如果到达两个if,A和B都被赋值为1了。假设写作废被延迟,处理机允许继续向前推进,可能出现P1和P2两个都没看到作废,这种情况就与预计的正确程序行为相违背。

最简单的一致性模型是顺序一致性,需要任何访存顺序一致的程序运行结果一致,它消除了含糊的执行方式,处理器延迟对任何存储器访问的过程,直到所有的写生效;同样的可以将下次访存延迟直到本次访存结束。

一致性涉及到不同变量和不同时间的问题,因此对两个变量的访问必须按照一定的顺序。

在上边的代码中,必须在写操作完成后在进行读A或B,在顺序一致性中也不能简单地把写放到缓存中而已就继续往下执行。

一个处理机对变量的写和另一个处理机对变量的访问通过一对同步操作进行排序,意味着同步操作把数据引用进行了排序,同步把顺序定下来,这就确定了一致性,两个处理器的操作通过同步确定了顺序。如果没有同步操作,变量在读写期间出现顺序不定的情况,称为数据竞争,因为此时对数据的访问基于处理器之间的相对速度,输出是不可预计的,结果正确性不能保证。

同步原语在实现上提供了较为宽松的顺序一致性,即便系统提供了较为宽松的一致性模型,一个同步的程序也会按照标准顺序一致性那样执行,这实际上提供了一种时间上的重叠,为并行的开发提供了条件。

松弛一致性模型的主要思想是允许读写无序进行,而是使用同步操作来强制实现有序,这样的话处理器的操作就像顺序一致性一样。依据松弛的情况分为3种主要的类型。

写后读:写完全生效之后才能进行读,这叫做全存序模型,或者处理器一致性,因为只保留了写的一致性,许多程序在这个模型下保持顺序一致性。旁路和写缓冲是两种主要方法。写缓冲中如果有要读的地址,就先在缓冲中拿到。

写后写:多次写的一个模型,部分存序,在流水线中,第一个写还没有完成的时候第二个写已经启动,两个写之间存在并行,因为两个写之间存在节拍的差距。

读后读/写:弱排序模型。

顺序松弛可以使处理器获得明显的性能提升,在实现上需要硬件的支撑。

线程级并行

多线程使多个线程共享处理机,处理机必须对每个线程的状态进行复制,便于进程切换,没有硬件支撑条件也谈不上多线程并行。比如,对线程所需要的文件来说,有寄存器文件,分开的PC等,提供对不同线程的切换能力,不同节拍可以切换到不同线程,线程切换也要比进程切换更快。

通常有两种方式实现多线程,一个是细粒度的,在指令之间就能完成线程的切换,使多线程的执行是交错的,切换经常采用时间片轮转的方式,优点是隐藏吞吐率上的损失,充分利用CPU的时间,如果线程执行IO时就可以先被切换先来,但是它的总执行时间被延长了。粗粒度的线程级并行在比较长的停顿出现时才进行切换,比如局部cache的失效,它依赖于程序执行的特点,缺点是受限于吞吐率,在有比较大的输入输出时才切换,没有办法充分利用短停顿。粗粒度多线程经常要填充流水线,产生一个起步时间,存在局限性,只有在停顿时间比较长的时候才有效。

SMT(simultaneous multithreading)是多线程的一种,使用处理器的多流出和动态调度能力,在指令级并行的同时实现线程级并行。SMT的基础是处理器有多个功能部件可以并行执行。寄存器换名和动态调度是为了支持不同线程的指令级并行。同时多线程是在多流出支持下,每拍流出的指令可以是来自多个线程的,第一拍是来自两个不同的线程,第二拍是来自另外的线程,以此类推,在线程和指令两个层面实现并行。

有多少个活跃的线程?有多少缓冲区?取指能力是否能满足流水线的需要?等都是SMT的问题,不能完全百分百的利用每一个流水槽。每个线程都要有自己的寄存器组、缓冲等。各个线程的指令要能够独立提交,结果要回到各个线程本身,这要求在逻辑上要提供每个线程的独立重排序缓冲区。

优先线程基于同时多线程,把执行的时间最小化,在多个线程并行的时候,只要有可能,首先流出的就是优先线程的指令,优先线程调度不出的空槽填充其他线程的指令。为最大化单个线程的性能,优先线程的取指、分支预测等应尽可能往前。如果有两个优先线程的话,两个线程就都要优先,两条指令流都要优先服务。

多线程每个都有寄存器,寄存器文件需要很大,保存多现场;保持系统低开销,优先线程需要优先取指;由SMT引起的cache冲突上需要良好处理,引起系统性能下降反而得不偿失。

交叉问题

许多多处理器使用多级cache较少对全局通信的需求,如果cache提供了多级包含特性,即近一级的cache一定是远一级cache的子集,一级cache中的内容一定是二级cache中的子集。

如果L2是L1容量的4倍,在L2中1个起始地址的块,在L1中是4个块。如果L1中的块大小是b,L2中的块大小是4b,则如果在L2中作废一个块x,需要作废以x,x+b,x+2b,x+3b为起始地址的小块(这在L2中被看作是一个块,在L1中被看作是4个块),在L1中同样要作废起始地址为x的一个块,但是没有作废起始地址为x+b的块(如果有的话),这就违背了包含原则。任何时候都要遵守包含特性。

非封锁cache和延迟隐藏:多处理机的失效处罚比较大,延迟也较大,这意味着有更大的延迟可以被隐藏,还有因为流水线失效的延迟可以被隐藏;cache使用非封锁cache支撑了弱一致性模型实现,弱一致性模型可以实现对访存的重排序,重复利用;非封锁cache对实现预取很有必要,利用尽可能空的时候实现存储的迁移,充分利用时间,实现多端口的并行访问。

非绑定是指一个cache的数值要根据其最新数值的变化而变化,不跟随某一个局部拷贝,对全局来说都是一致的。如果是预取到寄存器中,就是绑定的,因为如果数据进入了寄存器就是脱离了地址空间,跟存储地址空间的数据就没有关系了,存储器里的变化跟寄存器里没有关联了。非绑定是在硬件预取设计中不可获取的,只能在地址空间中实现预取。

有几个问题:局部节点需要对多个未完成的访问进行跟踪,跟踪预取地址;流出请求之前,节点必须保证在流出请求之前,对同一个块没有流出其他请求。

定义一个内存一致性模型的另一个原因是针对共享数据,确定合法的编译优化范围。最简单的来说是实现同步(硬件支撑下的对存储器访问的同步)。

通过虚拟存储器实现共享内存,不必从物理内存上考虑容量。共享数据如何从共享cache块移向更大的单元。通过OS进行调度页面。

Why new programming language

Taichi is a high-performance programming language for computer graphics applications. The goals are:

  • performance
  • productivity
  • spatially sparse computation,空间稀疏计算,CG中的提速需要
  • differentiable programming,可微编程,dl里的导数求解
  • meta programming

design decisions

  • 计算和数据结构的解耦
  • 领域特定编译器自动优化,广义编译器没有领域支持
  • megakernels,我的计算并不是表示成一个kernel中有一个很简单的操作
  • 自动微分中的两个尺度
  • 包到python中。

把python的AST通过TaiChi前端编译到TaiChi的AST,或者说执行一段代码,输出TaiChi的AST。得到前端AST后输入AST Lowering,所有中间变量只复制一次,对CPU做循环向量化等优化,对GPU则不用;之后在CPU上对内存访问优化。自动微分之后到LLVM。把数据结构的信息很显式的使用,因为很多优化如果不知道数据结构就很难优化。

High-Performance Spatially Sparse Computation

之前做类似模拟的时候需要开辟一个大的buffer,即使使用到的只是其中一小部分,这就是空间稀疏性。这里的空间稀疏性在局部比较稠密。

VDB用于处理类似的结构,类似文件系统的B-Tree,一个哈希表,底部是一些指针数组,第一层的指针数组有64个孩子,第二层的指针数组有16个孩子,降低访问延迟。

SPGrid使用了Virtral Memory中的TLB做了硬件的Hash Table。

使用稀疏的数据结构是很难的:

  • Boundary Conditions,边界条件对么
  • Maintaining,维持这个数据结构而存在的
  • 内存管理
  • 并行和负载均衡
  • 数据结构的开销
    • 可能会去查哈希表
    • 可能会使用指针,cache miss
    • 节点的分配,barrier
    • 分支预测,misprediction

底层的工程减少了数据结构的开销,但是降低了生产了,把算法和数据结构耦合在一起,让不同数据结构的使用产生了困难。稀疏数据结构
的开销比核心计算更大,cache miss更多,先过Hash Table,然后去某个数组查询。

TaiChi的方法:

Decouple computation from data structures

提供了命令式的编程语言,转换成中间表示,并做优化,然后有一套runtime system做内存管理。

如何描述数据结构?

  • dense:固定长度连续数组
  • hash:使用哈希表维护坐标映射
  • dynamic:预定义长度的数组,用来维护块中的粒子

Access Simplification

TaiChi是怎样针对数据结构优化使计算变快的。

access lowering,common subexpression elimination:把端到端的看起来稠密的访问(i到j),分解成比较小的指令并做优化,像传统编译器中的“表达式消除“。例如在AST中时,不需要每次都从root向leaf搜索,在子节点开始搜索,省略不必要的遍历和检查。

对AOS(array of structure)和SOA(structure of array),如果顺序访问的话SOA确实对cache很友好,后来发现AOS更好?数据上是这样的。

vectorized FEM Access Optimization

在做有限元运算时如何从内存中load一些element,比如,在对矩阵进行访问时,预先加载一个块中的数据,在访问时就可直接从块中进行查找,避免多次的访问。

为什么传统的编译器做不了这样的优化?

  • Index analysis,下标分析,利用某些数据结构信息使下标计算满足一些性质,就可以针对这些下标进行预取优化
  • 合适指令粒度,可以把一个访问表示成x[i, j],指令粒度大了存在大量优化空间;也可以把访问表示成更low level的代码指令,这样比较难分析。指令越来越细就越难分析,但是如果指令大,则隐藏潜在优化空间。
  • data access semantics
    • no pointer aliasing: a[x, y] and b[i, j] never overlaps if a != b,pointer alias是阻止编译器进行优化的东西,注意避免,传进参数的时候加上restrict告诉编译器两个指针从来不会overlap
    • all memory accesses are done through sparse_grid[indices]
    • the only way data structures get modified, is through write accesses of form sparse_grid[indices]
    • 读取操作不会修改任何变量。

differentiable programming on Taichi

可微编程,是在Taichi中的一个模块(Reverse Mode Autodiff),比deep learning更general。

ILP

指令级并行(ILP)是用于在同一CPU内核中同时执行多个指令的一组技术。
(请注意,ILP与多核无关。)
问题:CPU内核有很多电路,并且在任何给定时间,大多数都处于空闲状态,这很浪费。 解决方案:让CPU内核的不同部分同时执行不同的操作:如果CPU内核能够一次执行10次操作,则该程序原则上可以运行多达10次。 (尽管实际上并没有那么多)。

指令好像是必须按照程序顺序来执行,但是独立的指令可以同时执行,不会影响程序正确性。

超标量执行:处理器在指令序列中动态选择独立的指令并并行执行他们。

  • 超标量:同时执行多项运算(例如,同时执行加,乘和加运算)。
  • 流水线:开始对一个数据执行操作,同时对另一数据完成相同的操作-同时对不同的操作数集执行同一操作的不同阶段(如组装线)。
  • 超流水线:超标量和流水线的结合–同时执行多个流水线操作。
  • 向量:将多个数据加载到特殊寄存器中,并同时对所有这些数据执行相同的操作。使用SSE,AVX等,一条指令产生多个结果。

编译器优化

Copy Propagation复制传播

1
2
x = y
z = 1 + x

转换成:
1
2
x = y
z = 1 + y

消除数据依赖。

Constant Folding常量折叠

1
2
3
add = 100;
aug = 200;
sum = add + aug;

变为:
1
sum = 300;

注意,sum实际上是两个常量的和,因此编译器可以对其进行预先计算,从而消除了否则会在运行时执行的加法运算。

删除死代码

1
2
3
4
var = 5;
printf("%d", var);
exit(-1);
printf("%d", var * 2);

变为:
1
2
3
var = 5;
printf("%d", var);
exit(-1);

强度降低

1
2
x = pow(y, 2.0);
a = c / 2.0;

变为:
1
2
x = y * y;
a = c * 0.5;

计算一个值的乘方或进行除法要比乘法更昂贵。 如果编译器可以判断出幂是一个小整数,或者分母是一个常数,那么它将使用乘法。

常见子表达消除

1
2
d = c * (a / b);
e = (a / b) * 2.0;

变为:
1
2
3
adivb = a / b;
d = c * adivb;
e = adivb * 2.0;

子表达式(a / b)出现在两个赋值语句中,因此没有必要进行两次计算。通常只有在通用子表达式的计算成本很高的情况下,才值得这样做。

变量重命名

1
2
3
x = y * z;
q = r + x * 2;
x = a + b;

变为:
1
2
3
x0 = y * z;
q = r + x0 * 2;
x = a + b;

原始代码具有输出依赖性,而新代码则没有输出依赖性,但是x的最终值仍然正确。

循环优化

  • 循环内不变的代码称为循环不变式。 不需要一遍又一遍地计算。
  • 我们可以通过剥离特殊的迭代来消除IF
  • 分组迭代消除IF
  • 数组元素a[i][j]a[i][j+1]在内存中彼此靠近,而a[i+1][j]可能很远,因此使j循环为内循环。 (在Fortran中是相反的。)
  • 循环展开。上次我们看到,具有很多操作的循环可以获得更好的性能(在某种程度上),特别是在有很多算术操作但主存储器加载和存储很少的情况下。展开会创建多个操作,这些操作通常从相同或相邻的缓存行加载。 因此,展开的循环可以执行更多的操作,而不会增加太多的内存访问。同样,展开将减少循环计数器变量上比较的次数,并减少到循环顶部的分支数。

循环融合

1
2
3
4
5
6
7
8
9
for (i = 0; i < n; i++) {
a[i] = b[i] + 1;
}
for (i = 0; i < n; i++) {
c[i] = a[i] / 2;
}
for (i = 0; i < n; i++) {
d[i] = 1 / c[i];
}

变为:
1
2
3
4
5
for (i = 0; i < n; i++) {
a[i] = b[i] + 1;
c[i] = a[i] / 2;
d[i] = 1 / c[i];
}

与展开一样,这具有较少的分支。 它还具有较少的总内存引用。

从理论上讲,编译器和硬件可以“理解”所有这些内容,并可以优化您的程序;实际上,他们没有。

  • 他们不会知道与处理器更好的“匹配”的不同算法
  • 但是实际上编译器可能需要您的帮助- 选择其他编译器,优化标志等,其中包括控制单处理器优化的选项:超标量,流水线,矢量化,标量优化,循环优化,内联等。
  • 重新排列代码以使事情变得更明显
  • 使用特殊功能(“固有”)或编写汇编

高级优化:过程间优化 (IPO)

  • ip: 源程序文件内部的过程间优化
  • ipo: 多个源程序的过程间优化
  • 函数内联是ipo中最重要性能优化手段

为什么循环不向量化

  • 独立
  • 循环迭代通常必须独立
  • 一些相关的限定词:
  • 某些依赖循环可以向量化。
  • 大多数函数调用无法向量化。
  • 一些条件分支会阻止矢量化。
  • 循环必须是可数的。
  • 无法对嵌套的外循环进行矢量化处理。
  • 混合数据类型无法向量化

处理内存延迟的方法

  • 通过将值保存在小型快速内存(缓存)中并重新使用它们来消除内存操作
  • 在程序中需要时间局部性
  • 通过获取一块内存并将其保存在小型快速内存(高速缓存)中并使用整个内存块,来利用更好的带宽
  • 带宽改善快于延迟
  • 在程序中需要空间局部性
  • 通过允许处理器一次向存储系统发出多次读取来利用更好的带宽
  • 指令流中的并发,例如 像矢量处理器一样加载整个数组; 或预取
  • 重叠计算和内存操作
  • 预取

如果有两个以上的内存级别怎么办?

  • 需要最小化所有级别之间的沟通
    • 在L1和L2缓存,缓存和DRAM,DRAM和磁盘之间…
  • 算法需要找到合适的块大小
    • 机器相关
    • 需要在最里面的循环中“阻止” b x b矩阵乘法
      • 1级内存->3个嵌套循环(幼稚算法)
      • 2级内存->6个嵌套循环
      • 3级内存->9个嵌套循环…
  • 缓存遗忘算法提供了另一种选择
    • 将nxn矩阵乘法视为一组较小的问题
    • 最终,这些将适合缓存
    • 将最小化在每个内存级别之间移动的#个单词
    • “遗忘”级别的数量和大小

向量化通用准则

  • 优先考虑可计数的单入口和单出口“for”循环。它可以作为外部循环索引的功能,也可以作为嵌套循环中最里面的循环的函数。
  • 编写序列代码(避免使用诸如switch,goto或return语句之类的分支,大多数函数调用或不能视为掩码分配的“if”构造)。
  • 避免循环迭代之间的依赖关系,或者至少避免读后写依赖关系。
  • array首选使用数组表示法而不是指针,尤其是对于C语言。尽可能在数组下标中直接使用循环索引,而不是增加单独的计数器以用作数组地址。
  • 使用有效的内存访问,例如连续访问和对齐访问(16/32字节边界)。并最大程度地减少间接寻址
  • 首选结构阵列(struct of array, SoA)优于结构阵列(array of structure, AoS)
  • 尝试使用矢量化库,包括英特尔®MKL和英特尔®IPP

profiling
profiling意味着收集有关程序执行的数据。两种主要的性能分析是:

  • Subroutine profiling:插装
  • Hardware counters:统计

假设您有一个hot循环,您认为应该进行矢量化,但没有进行矢量化(如在Vtune,SDE或其他热循环工具中发现的那样)。首先使用适当的编译器选项尝试基本的自动矢量化。使用“ -vec-report2”或更高版本可获取有关循环是否正在向量化的调试信息。要为Intel Xeon E5-2680要求AVX自动矢量化,请使用编译器命令行选项“ -xAVX –O3”。要为Intel Xeon Phi本机可执行文件要求Xeon Phi自动矢量化,请使用编译器命令行选项“ -mmic –O3”。离线编译通常应该给您提供–mmic自动矢量化功能,但是如果需要,您可以使用主机编译器选项-offload-option,compiler,mic,“将任何其他选项传递给Xeon Phi编译器。
其他可尝试的内容包括:
1.尝试使用“#pragma vector”来禁用编译器的矢量化成本模型。引入此选项后,请始终检查性能。您需要知道的主要事情是向量化器使用启发式算法。根据定义,启发式方法并不总是正确的。
2.如果您知道没有真正的依赖关系可以阻止矢量化,请尝试使用“ #pragma ivdep”。引入此选项后,请始终检查正确性和可能的​​崩溃。
3.尝试“ #pragma simd”。如果以上两种方法都无法为您提供矢量化代码,请尝试使用此选项。引入此选项后,请始终检查性能,正确性和可能的​​崩溃。
不要忘记通过使用编译器–S选项检查汇编并交叉检查源代码行号来“检查工作”。如果您从未编写过汇编文件,那么“检查汇编”听起来可能是一项艰巨的任务。

向量化建议:

  • 首先找到您的热循环/热基本块(Vtune,SDE等)
  • 确保数组边界对齐(如果可能)
  • 确保您没有不良依赖关系(即算法可向量化)
  • 首先尝试自动矢量化,例如:–O3 –xAVX –vec-report2 –openmp
  • 分析为什么编译器无法进行矢量化,然后尝试:#pragma vector always
  • 然后尝试:#pragma ivdep
  • 使用:#pragma vector aligned,如果编译器未注意到您的数组已对齐。
  • 然后尝试:#pragma simd:如果它是可向量化的循环,通常将对其向量化。
    • 无论安全性如何,都强制进行矢量化:检查正确性并进行彻底测试
  • 在工作时定期查阅编译器的–S程序集列表
  • 您可能需要专门标记减少操作,例如
    • 如果您知道行程计数(循环计数),请使用#pragma loop_count帮助编译器。
    • 在循环周围测试#pragma unroll(N),以查看它是否有助于提高性能。

CP是基本3d形状匹配算法。 ICP基准相对而言一个简单的500行程序,该程序执行ICP算法的复杂度是O(N^2)。由于程序的简单性,我们不仅可以测量单精度和双精度结果,测量阵列结构(structure of array, SoA)和结构阵列(array of structure, AoS)的性能也非常容易。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#if defined(AOS)
#define AOSFLAG 1 // 1 for AoS
#else
#define AOSFLAG 0 // 0 for SoA (default)
#endif
#if defined(DOUBLEPREC)
#define FPPRECFLAG 2 // 2 for Double precision
#define FLOATINGPTPRECISION double
#else
#define FPPRECFLAG 1 // 1 for Single precision (default)
#define FLOATINGPTPRECISION float
#endif

// AoS
typedef struct Point3d
{
FLOATINGPTPRECISION x,y,z,t; // use of t padding is optional
} Point3d,*Point3dPtr;

#if AOSFLAG == 1
Point3dPtr org = NULL;
Point3dPtr tfm = NULL;
#endif
#if AOSFLAG == 0 // SoA (set of arrays here, structure of arrays normally)
FLOATINGPTPRECISION *orgx = NULL;
FLOATINGPTPRECISION *orgy = NULL;
FLOATINGPTPRECISION *orgz = NULL;
FLOATINGPTPRECISION *tfmx = NULL;
FLOATINGPTPRECISION *tfmy = NULL;
FLOATINGPTPRECISION *tfmz = NULL;
#endif


源代码如下,因此我们将列出一个简单的循环以显示程序中的代码类型。 点集的简单旋转和平移明确表示如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#if AOSFLAG == 1
#pragma omp parallel for shared(tfm,Rf,Tf,nxfmpts) private(x,y,z)
for(i=0;i<nxfmpts;i++)
{
x = tfm[i].x; y = tfm[i].y; z = tfm[i].z;
tfm[i].x = Rf[0][0]*x + Rf[0][1]*y + Rf[0][2]*z + Tf[0];
tfm[i].y = Rf[1][0]*x + Rf[1][1]*y + Rf[1][2]*z + Tf[1];
tfm[i].z = Rf[2][0]*x + Rf[2][1]*y + Rf[2][2]*z + Tf[2];
}
#endif
#if AOSFLAG == 0
#pragma omp parallel for shared(tfmx,tfmy,tfmz,Rf,Tf,nxfmpts) private(x,y,z)
for(i=0;i<nxfmpts;i++)
{
x = tfmx[i]; y = tfmy[i]; z = tfmz[i];
tfmx[i] = Rf[0][0]*x + Rf[0][1]*y + Rf[0][2]*z + Tf[0];
tfmy[i] = Rf[1][0]*x + Rf[1][1]*y + Rf[1][2]*z + Tf[1];
tfmz[i] = Rf[2][0]*x + Rf[2][1]*y + Rf[2][2]*z + Tf[2];
}
#endif

SoA is better than AoS on “Intel Xeon Phi and Intel Xeon_E5-2680 for both Single and Double Precision.
该代码的属性可帮助实现此应用程序的源代码,如下所示:

  • 几乎所有读和写都发生第1步访问。
  • 所有数组都是64字节对齐的,并且编译器知道它们是64字节对齐的。
  • 编译器成功地向量化了所有热循环。
  • 循环是高速缓存友好的,以减少内存访问。
  • 主循环中没有除法。
  • 循环非常简单。

MPI编程模型:全局地址空间

  • 程序由一组命名线程组成。
    • 通常在程序启动时固定
    • 本地和共享数据,如共享内存模型中一样
    • 但是,共享数据在本地进程中分区
    • 成本模型表明远程数据非常昂贵
  • 示例:UPC,Co-Array Fortran
  • 全局地址空间编程是消息传递和共享内存之间的中间点
    • 线程等全局地址空间(可编程性)
    • SPMD并行性,例如MPI(性能)
    • 本地/全局区别,即布局很重要(性能)

主流并行程序模型-数据并行

  • 单线程模式
    • 并行操作于聚合数据结构上,一般是数组
    • 隐式相互作用,不需要显式同步
    • 隐式数据分配
  • 缺点
    • 相对严格的计算结构要求,不是所有应用模式都适合这种模型
    • 在粗粒度的并行机上难于映射

现代机器中的“自动”并行性

  • 位级并行
    • 在浮点运算等中
  • 指令级并行性(ILP)
    • 每个时钟周期执行多个指令
  • 内存系统并行
    • 内存操作与计算重叠
  • OS并行性
    • 在商品SMP上并行运行多个作业

常见的并行开销有哪些?

  • 创建和销毁并行进程、线程的开销
    • 创建和销毁进程本身是高开销的工作
      • PowerPC 700MHz(每个周期 15ns 执行4flops; 创建一个进程1.4ms,可执行372,000flops)
    • 创建和销毁多个进程的开销在系统中随进程数增加
      • 启动万规模进程需要s级时间
  • 通信开销是并行开销的主要部分
    • 多机间的通信开销相对于计算很大
    • 通信模型参数会因很多因素不同而变化
      • 同时通信的进程数
      • 同时发送的消息数
      • 消息的大小
      • 网络的拓扑结构
      • 网络的拥挤程度
      • 不同的MPI实现
      • 群集消息通信算法
      • 之前发送的消息情况
  • 并行化过程中引入的空间和相应的时间开销
    • 多进程并行化过程中引入的空间和相应的时间开销
      • 消息缓冲区准备
      • 交叠数据的分配和使用

回顾编程模型1:共享内存

  • 程序是控制线程的集合。
  • 可以在某些语言中执行时动态创建
  • 每个线程都有一组私有变量,例如局部堆栈变量
  • 还有一组共享变量,例如静态变量,共享公共块,全局堆。
  • 线程通过读写共享来隐式通信变量。
  • 线程通过共享变量同步来协调。

几个线程库/系统

  • PTHREADS是POSIX标准
    • 相对较低的水平
    • 便携式但可能很慢; 相对较重
  • 用于应用程序级编程的OpenMP标准
    • 支持对共享内存进行科学编程

POSIX线程概述

  • POSIX:便携式操作系统接口
    • 与操作系统实用程序的接口
  • PThreads:POSIX线程接口
    • 系统调用以创建和同步线程
    • 在类似UNIX的OS平台上应该相对统一
  • PThread包含对以下几点的支持
    • 创建并行
    • 同步
    • 不明确支持通信,因为共享内存是隐式的;指向共享数据的指针被传递给线程

OpenMP的主要特点

  • 面向共享存储体系结构,特别是SMP系统
  • 显式并行方法
  • 基于fork-join的多线程执行模型,但同样可以开发SPMD(Single Program Multi-Data )类型的程序
  • 可以进行增量式并行开发( Incremental development ),支持条件编译( Conditional Compilation )和条件并行
  • 允许嵌套的并行性(nested Parallelism )和动态线程
    • 并不是在所有的编译器实现中支持

线程数目的讨论

  • 通常情况下线程组内线程数目由环境变量OMP_NUM_THREADS控制
  • 如果parallel语句有num_threads子句,或者用户调用了omp_set_num_threads函数,线程数目由它们给出,num_threads具有高优先级
  • 上述三种设置方法作用域分别为系统、并行块级以及程序级
  • 这里给出的线程数目可以大于系统中处理器个数,它是一个上限值
  • 系统实际产生的线程数目可能由于资源的限制而比上限值要小

并行结构:Work-sharing Construct(1) - loop

  • 为线程分配了一组独立的迭代
  • 线程必须在工作共享结构的末尾等待
1
2
3
4
#pragma omp parallel
#pragma omp for
for(i = 1, i < 13, i++)
c[i] = a[i] + b[i]

Work-sharing Construct(2): Parallel Sections
section中代码的独立部分可以同时执行。

1
2
3
4
5
6
7
8
9
#pragma omp parallel sections
{
#pragma omp section
phase1();
#pragma omp section
phase2();
#pragma omp section
phase3();
}

Work-sharing Construct(3): Single Construct
表示仅由一个线程执行的代码块

  • 选择第一个到达的线程
  • 隐式障碍
1
2
3
4
5
6
7
8
9
#pragma omp parallel
{
DoManyThings();
#pragma omp single
{
ExchangeBoundaries();
} // threads wait here for single
DoManyMoreThings();
}

分配迭代:schedule子句影响循环迭代如何映射到线程上

  • schedule(static [,chunk])
    • 线程大小为“块”的迭代块
    • 循环分配
    • 默认值= N / t
    • 对于Ni个迭代和Nt个线程,每个线程获得Ni/Nt个循环迭代的一个块:
  • schedule(dynamic[,chunk])
    • 线程获取“块”迭代
    • 完成迭代后,线程将请求下一组请求
    • 默认值= 1
    • 对于Ni个迭代和Nt个线程,每个线程都会获得k个循环迭代的固定大小的块,当特定线程完成其迭代块时,将为其分配新的块。因此,迭代与线程之间的关系是不确定的。
      • 优势:非常灵活
      • 缺点:高开销–关于哪个线程获取每个块的大量决策
  • schedule(guided [,chunk])
    • 动态计划以大块开始
    • 块的尺寸缩小; 不小于“块”
    • 默认值= 1
    • 对于Ni迭代和Nt线程,最初,每个线程都会获得k <Ni/Nt循环迭代的固定大小的块:
    • 每个线程完成其k次迭代的块之后,它将获得k / 2次迭代的块,然后是k / 4个,依此类推。当线程完成其先前的块时,将动态分配块。
      • 优于静态:可处理不平衡负载
      • 动态优势:更少的决策,因此更少的开销
  • schedule(runtime)
    • OMP_SCHEDULE

现在的消息传递系统多使用三种通信模式:

  • 同步的消息传递 (Synchronous Message Passing)
  • 阻塞的消息传递 (Blocking Message Passing)
  • 非阻塞的消息传递 (Nonblocking Message Passing)

非阻塞模式为计算和通信重叠带来机会,但本身也会带来一些额外开销:

  • 作为临时缓冲区用的内存空间
  • 分配缓冲区的操作
  • 将消息拷入和拷出临时缓冲区
  • 执行一个额外的检测和等待函数

消息缓冲(message buffer, 简称buffer), 在不同的消息传递使用场合有不同的含义. 下面给出一些例子:

  • 消息缓冲指的是由程序员定义的应用程序的存储区域, 用于存放消息的数据值.例如, 在Send(A, 16, Q, tag)中, 缓冲A是在用户应用程序中声明的变量.
  • 缓冲的起始地址在消息例程中被使用.
  • 消息缓冲也可以指由消息传递系统(而非用户)创建和管理的一些内存区, 它用于发送消息时暂存消息. 这种缓冲不在用户的应用程序中出现, 有时被称为(消息传递)统消息缓冲(或系统缓冲).
  • MPI允许第三种可能的定义. 用户可以划出一定大小的内存区, 作为出现在其应用中的任意消息的中间缓冲.

用在MPI中的通信模式(communication mode):

  • 同步的(synchronous):直到相应的接收已经启动,发送才返回
    • 阻塞的同步发送:发送缓冲区可用,发送完成
    • 非阻塞的同步发送:它的返回不意味着消息已经被发出! 它的实现不需要在接收端有附加的缓冲, 但需要在发送端有一个系统缓冲. 为了消除额外的消息拷贝, 应使用阻塞的同步发送
  • 缓冲的(buffered):缓冲的发送假定能得到一定大小的缓冲空间, 它必须事先由用户程序分配和管理。通过调用子例程MPI_Buffer_attch(buffer,size)来定义, 由它来分配大小为size的用户缓冲. 这个缓冲可以用MPI_Buffer_detach(*buffer, *size )来实现.无缓冲区时,返回错误.
  • 就绪的(ready):
    • 在肯定相应的接收已经开始才进行发送. 它不像在同步模式中那样需要等待. 这就允许在相同的情况下实际使用一个更有效的通信协议.使用较少,程序员要保证程序正确性
  • 标准的(standard):最常用的模式。发送可以是同步的或缓冲的(系统缓冲), 取决于实现,给予系统以灵活选择的机会;发送的返回意味着消息缓冲区可用

常见错误调试心得

  • 确保栈空间分配的有效性
    • ulimit –s unlimited (可以根据需要调整)
    • export KMP_STACKSIZE=16000000 (可以根据需要调整)
  • 确保串行程序的正确执行(OMP_NUM_THREADS=1)
  • 验证private变量使用的正确性
  • 逐项确保变量的使用了然于胸,特别是f90: SAVE, DATA, default(none), private(…), shared(…)
  • 利用增量级开发的特性进行代码二分查找
  • 确认是否由于舍入误差导致
  • 对于连加等可能由于计算次序导致不同计算结果的操作,不使用reduction子句,将加法部分放到串行区完成
  • 借助Intel Inspector,totalview等工具寻找数据竞争问题

处理器:多核时代

  • 想法1:使用增加的晶体管数量添加更多处理器核心,而不是使用晶体管来增加。先进的处理器逻辑加速单个指令流(例如,乱序和投机操作)
  • 想法2:添加ALU以提高计算能力。摊销跨多个ALU管理指令流的成本/复杂性。SIMD处理,一条指令,多个数据向所有ALU广播相同的指令在所有ALU上并行执行

指令流一致性(“一致性执行”)

  • 相同的指令序列适用于同时操作的所有元素
  • 要有效利用SIMD处理资源,必须执行一致的执行
  • 由于每个内核都具有获取/解码不同指令流的能力,因此对于内核之间的高效并行化而言,一致性执行不是必需的
  • “分散”执行
    • 缺乏指令流一致性

在现代CPU上执行SIMD

  • SSE指令:128位操作:4x32位或2x64位(4宽格式向量)
  • AVX2指令:256位操作:8x32位或4x64位(8宽格式向量)
  • AVX512指令:512位操作:16x32位…
  • 指令由编译器生成
    • 程序员使用内在函数明确要求的并行性
    • 使用并行语言语义传达的并行性(例如,forall示例)
    • 通过对循环的依赖性分析推断出并行性(困难的问题,即使是最好的编译器也不能在任意C / C ++代码上使用)
  • 术语:“显式SIMD”:SIMD并行化在编译时执行

在许多现代GPU上执行SIMD

  • “隐含SIMD”
    • 编译器生成标量二进制(标量指令)
    • 但是程序的N个实例始终在处理器上一起运行execute(my_function,N),执行my_function N次
  • 换句话说,硬件本身的接口是数据并行的
  • 硬件(不是编译器)负责同时从多个实例对SIMD ALU上的不同数据执行同一条指令
  • 大多数现代GPU的SIMD宽度为8到32
  • 分歧可能是个大问题(写得不好的代码可能以机器峰值能力的1/32执行!)

摘要:并行执行

  • 现代处理器中的几种并行执行形式
    • 多核:使用多个处理核
      • 提供线程级并行性:在每个内核上同时执行完全不同的指令流
      • 软件决定何时创建线程(例如,通过pthreads API)
    • SIMD:使用同一指令流(在内核内)控制的多个ALU
      • 高效的数据并行工作负载设计:控制可摊销许多ALU
      • 矢量化可以由编译器(显式SIMD)完成,也可以在运行时由硬件完成
      • [缺乏]依赖关系在执行之前就已经知道(通常由程序员声明,但可以通过高级编译器的循环分析来推断)
    • 超标量:在指令流中利用ILP。 处理来自相同的指令流并行(在内核内)
      • 硬件在执行过程中自动动态发现并行性(程序员看不到)

多线程减少了停顿

  • 想法:对同一核心上的多个线程进行交错处理以隐藏停顿
  • 与预取一样,多线程隐藏了延迟,而不是减少延迟的技术

硬件支持的多线程

  • Core管理多个线程的执行上下文
    • 从可运行线程运行指令(处理器决定每个时钟运行哪个线程,而不是操作系统的运行)
    • 核心仍然具有相同数量的ALU资源:多线程仅在面对诸如内存访问之类的高延迟操作时才有助于更有效地使用它们
  • 交错多线程(也称为时间多线程)
    • 每个时钟,内核都会选择一个线程,并在ALU上运行来自该线程的指令(交织多线程)
  • 同时多线程(SMT,同时多线程)
    • 每个时钟,内核从多个线程中选择指令以在ALU上运行
    • 扩展超标量CPU设计
    • 示例:英特尔超线程(每个内核2个线程)

多线程摘要

  • 优势:更有效地利用核心的ALU资源
    • 隐藏内存延迟
    • 填充超标量架构的多个功能单元(当一个线程的ILP不足时)
  • 劣势
    • 需要额外存储线程上下文
    • 增加任何单线程的运行时间(通常不是问题,我们通常关心并行应用程序中的吞吐量)
    • 需要程序中的其他独立工作(比ALU更加独立!)
    • 严重依赖内存带宽
    • 更多线程→更大的工作集→每个线程更少的缓存空间
    • 可能会更频繁地进入内存,但可以隐藏延迟

带宽是至关重要的资源。高性能并行程序将:

  • 组织计算以减少从内存中获取数据的频率
    • 重用先前由同一线程加载的数据(传统的线程内时间局部性优化)
    • 跨线程共享数据(线程间协作)
  • 减少请求数据的频率(取而代之的是做更多的算术:“免费”)
    • 有用的术语:“算术强度” —指令流中数学运算与数据访问运算的比率
    • 要点:程序必须具有很高的算术强度才能有效利用现代处理器

使用C++11让程序更简洁

类型推导

引入auto和decltype。

auto

1
2
3
4
auto i = 10; // i是int
auto pu = new auto(1); // pu是int*
const auto *v = &i, u = 6; // v是const int*,u是const int
auto s; //错误,无法推断

初始化不能使编译器推导产生二义性,如把u写成u=6.0则不予通过。
使用auto声明的变量必须马上初始化,以让编译器推断出类型并在编译时将auto替换为真正的类型。

1
2
3
4
5
6
7
8
9
10
int x = 0;  //
auto * a = &x; // a是int*,auto被推导为int
auto b = &x; // b是int*,auto被推导为int*
auto & c = x; // c是int&,auto被推导为int
auto d = c; // d是int,auto被推导为int

const auto e = x; // e是const int
auto f = e; // f是int
cosnt auto & g = x; // g是const int&
auto & h = g; // h是const int&

a和c的类型推导结果很明显,auto在编译时被替换为int,b的推导结果表明auto不声明为指针,也可以推导出指针类型;d的推导结果表明当表达式是一个引用类型时,auto会把引用类型抛弃,直接推导成原始类型int。f的推导结果表明表达式带有const时,auto会把const属性抛弃掉,推导成non-const的int。规则如下:

  • 当不声明为指针或引用时,auto的推导结果和初始化表达式抛弃引用和cv限定符(const和volatile)后类型一致
  • 当声明为指针或引用时,auto的推导结果将保留初始化表达式的cv属性。
  • auto不能用为函数参数。
  • auto无法定义数组!

auto的推导和函数模板参数的自动推导有相似之处。

1
2
3
4
5
6
7
8
template <typename T> void func(T x) {} // T -> auto
template <typename T> void func(T *x){} // T -> auto*
template <typename T> void func(T& x){} // T&-> auto&

template <typename T> void func(const T x) {} // const T -> const auto
template <typename T> void func(const T* x){} // const T*-> const auto *
template <typename T> void func(const T& x){} // const T&-> const auto &
注意:auto是不能用于函数参数的。

何时使用auto?看一个例子,在一个unordered_multimap中查找一个范围,代码如下

1
2
3
4
5
6
#include <map>
int main()
{
std::unordered_multimap<int, int> resultMap;
std::pair<std::unordered_multimap<int, int>::iterator, std::unordered_multimap<int, int>::iterator> range = resultMap.equal_range(key);
}

这个 equal_ange返回的类型声明显得烦琐而冗长,而且实际上并不关心这里的具体类型(大概知道是一个std::pair就够了)。这时,通过auto就能极大的简化书写,省去推导具体类型的过程
1
2
3
4
5
6
7
#include <map>
int main()
{
std::unordered_multimap<int, int> map;
auto range_map.equal_range(key);
return 0;
}

decltype

auto所修饰的变量必须被初始化,C++11新增了decltype关键字,用来在编译时推导出一个表达式的类型。decltype(exp),exp是一个表达式。

1
2
3
4
5
6
7
8
9
10
int x = 0;
decltype(x) y = 1; // y -> int
decltype(x + y) z = 0; // z -> int

const int& i = x;
decltype(i) j = y; // j -> const int &

const decltype(z) * p = &z; // *p -> const int, p -> const int *
decltype(z) * pi = &z; // * pi -> int, pi -> int*
decltype(pi) * pp = &pi; // *pp -> int *, pp -> int **

y和z的结果表明,decltype可以根据表达式直接推导出它的类型本身。这个功能和上节的auto很像,但又有所不同。auto只能根据变量的初始化表达式推导出变量应该具有的类型。若想要通过某个表达式得到类型,但不希望新变量和这个表达式具有同样的值,此时auto就显得不适用了。
j的结果表明decltype通过表达式得到的类型,可以保留住表达式的引用及const限定符。实际上,对于一般的标记符表达式(id-expression),decltype将精确地推导出表达式定义本身的类型,不会像auto那样在某些情况下舍弃掉引用和cv限定符。p、pi的结果表明decltype可以像auto一样,加上引用和指针,以及cv限定符。

pp的推导则表明,当表达式是一个指针的时候,decltype仍然推导出表达式的实际类型(指针类型),之后结合pp定义时的指针标记,得到的pp是一个二维指针类型。这也是和auto推导不同的一点。

推导规则:

  • exp是标识符、类访问表达式,decltype(type)和exp的类型一致;
  • exp是函数调用,decltype(type)和函数返回值类型一致;
  • 若exp是一个左值,则decltype(type)是exp类型的左值引用,否则和exp类型一致。
1
2
3
4
5
struct Foo {int x;};
const Foo foo = Foo();

decltype(foo.x) a = 0; // a -> int
decltype((foo.x)) b = a; // b -> const int &

a的类型就是foo.x的类型,foo.x是一个左值,可知括号表达式也是一个左值,decltype的类型是一个左值引用。

在泛型编程中,可能需要通过参数运算获得返回值类型:

1
2
3
4
5
6
7
8
template <typename R, typename T, typename U>
R add(T t, U u) {
return t+u;
}

int a = 1;
float b = 2.0;
auto c = add<decltype(a+b)>(a, b);

改成:
1
2
3
4
template <typename T, typename U>
decltype(T()+U()) add(T t, U u) {
return t+u;
}

考虑到T、U可能是没有无参构造函数的类,可以如下:
1
2
3
4
template <typename T, typename U>
decltype((*(T*)0) + (*(U*)0)) add(T t, U u) {
return t+u;
}

返回类型后置语法通过auto和decltype结合使用,可以写成:

1
2
3
4
template <typename T, typename U>
auto add(T t, U u) -> decltype(t + u) {
return t+u;
}

返回类型后置语法解决了返回值类型依赖于参数而导致难以确定返回值类型的问题。
1
2
3
4
5
6
7
int& foo(int& i);
float foo(float& f);

template <typename T>
auto func(T& val) -> decltype(foo(val)) {
return foo(val);
}

模板的细节改进

模板的右尖括号

尽可能将多个右尖括号解析成模板参数结束符。

模板的别名

重定义一个模板

1
2
3
4
template <typename Val>
using str_map_t = std::map<std::string, Val>;

str_map_t<int> map1;

使用新的using别名语法定义了std::map的模板别名str_map_t。
实际上,using的别名语法覆盖了typedef的全部功能,两种使用方法等效。
1
2
3
4
5
typedef unsigned int uint_t;
using uint_t = unsigned int;

typedef std::map<std::string, int> map_int_t;
using map_int_t = std::map<std::string, int>;

using定义模板别名:

1
2
3
template <typename T>
using func_t = void(*)(T, T);
func_t<int> xx_2;

函数模板的默认模板参数

1
2
template <typename T = int>
void func(void) { ... }

当所有模板参数都有默认参数时,函数模板的调用如同一个普通参数,对于类模板而言,哪怕所有参数都有默认参数,在使用时也要在模板名后跟一个“<>”实例化。

1
2
3
4
5
6
7
8
9
template <typename R = int, typename U>
R func(U val) {
val
}

int main() {
func(123);
return 0;
}

在调用函数模板时,若显式指定模板参数,由于参数填充顺序是从左往右的,因此,像下面这个调用,func<long>(123),func的返回值是long,而不是int。

列表初始化

在C++98/03中的对象初始化方法有多种。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int i_arr[3] = {1, 2, 3};

struct A {
int x;
struct B {
int i;
int j;
} b;
} a = { 1, {2, 3} };

int i = 0;
class Foo {
public:
Foo(int) {}
} foo = 123;

C++11中提出了列表初始化的概念。

1
2
3
4
5
Foo a3 = {123};
Foo a4 {123};

int a5 = {3};
int s6 {3};

a3虽然使用了等于号,但是仍然是列表初始化,因此,私有的拷贝构造不会影响到它。
a4和a6的写法是C++98/03不具备的,可以直接在变量名后跟上初始化列表,来进行对象的初始化。

new操作符等可以用圆括号初始化的地方可以使用初始化列表:

1
2
3
int* a = new int {123};
double b = double {123};
int* arr = new int[3] {1, 2, 3};

聚合类型:

  • 类型是一个普通数组
  • 类型是一个类,且
    • 无用户定义的构造函数
    • 无私有或保护的非静态数据成员
    • 无基类
    • 无虚函数
    • 不能有{}和=直接初始化的非静态数据成员

对数组而言,只要该类型是一个普通数组,哪怕数组的元素并非聚合类型,这个数组本身也是一个聚合类型:

1
2
3
4
5
6
int x[] = {1, 3, 5};
float y[4][3] = {
{1, 3, 5},
{2, 4, 6},
{3, 5, 7}
}

当类型是一个类时,首先是存在用户自定义构造函数时,

1
2
3
4
5
6
7
struct Foo {
int x;
double y;
int z;
Foo(int, int) {}
};
Foo foo{1, 2.5, 1}; // ERROR!

这时无法将Foo看成一个聚合类型,必须以自定义构造函数构造对象。
如果受保护(protected)成员是一个static的,则可以不放在初始化列表里。
如果类定义里的成员变量已经有了赋值,则不可以使用初始化列表。

上述不可使用初始化列表的情况可以通过自定义构造函数实现使用初始化列表

初始化列表

任意长度初始化列表

C++11中的stl容器拥有和未显示指定长度的数组一样的初始化能力:

1
2
3
4
int arr[] = {1, 2, 3};
std::map<std::string, int> mm = { {"1", 1}, {"2", 2}, {"3", 3} };
std::set<int> ss = {1, 2, 3};
std::vector<int> arr = {1, 2, 3, 4, 5};

这里arr未显式指定长度,因此它的初始化列表可以是任意长度。
实际上stl中的容器是通过使用std::initializer_list这个类模板完成上述功能的,如果在类Foo中添加一个std::initializer_list构造函数,它也将拥有这种能力。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Foo {
std::vector<int> content;
public:
Foo(std::initializer_list<int> list) {
for(auto it = list.begin(); it != list.end(); it ++){
content.push_back(*it);
}
}
}

class Foo1 {
std::map<int, int> content;
using pair_t = std::map<int, int>::value_type;
public:
Foo1(std::initializer_list<int> list) {
for(auto it = list.begin(); it != list.end(); it ++){
content.insert(*it);
}
}
}

Foo foo = {1, 2, 3, 4, 5, 6};
Foo1 foo1 = { {1, 2}, {2, 3}, {3, 4} };

用来传递同类型的数据集合:
1
2
3
4
5
void func(std::initializer_list<int> list) {
for(auto it = list.begin(); it != list.end(); it ++){
std::cout << *it << std::endl;
}
}

std::initializer_list的一些特点:

  • 轻量级容器类型,内部定义了iterator等;
  • 对于std::initializer_list<T>,可以接受任意长度的初始化列表,但要求必须时同种类型;
  • 有三个成员接口:size()begin()end()
  • 只能被整体初始化或赋值。
  • 只能通过begin和end循环遍历,遍历时取得的迭代器是只读的,因此无法修改其中一个值。
  • 实际上,std::initializer_list非常高效,内部并不负责保存初始化列表中元素的拷贝,而是只储存列表中元素的引用而已。

防止类型收窄

类型收窄指导致数据内容发生变化或精度损失的隐式类型转换,包含以下几种:

  • 从浮点数隐式转换为整型;
  • 从高精度浮点数转换为低精度浮点数,如从long double转换为double或float;
  • 从整型数隐式转换为浮点数,并超过了浮点数表示范围;
  • 从整型数隐式转换为长度较短的整型数。

初始化列表不会允许类型收窄的转换发生。

基于范围的for循环

for循环的新用法

<algorithm>中有for_each可以用于遍历:

1
2
3
4
5
6
7
8
9
10
11
12
#include <algorithm>
#include <iostream>
#include <vector>

void do_cout(int n) {
std::cout << n << std::endl;
}
int main() {
std::vector<int> arr;
std::for_each(arr.begin(), arr.end(), do_cout);
return 0;
}

可以改成:
1
2
3
4
5
6
7
8
9
10
#include <iostream>
#include <vector>

int main() {
std::vector<int> arr;
for(auto n : arr) {
std::cout << n << std::endl;
}
return 0;
}

n表示arr中的一个个元素,auto则是让编译器自动推导n的类型,在这里n的类型被自动推导为vector中的元素类型int。
基于范围的for循环对于冒号前边的局部变量声明只要求能支持容器类型的隐式转换。
如果需要在遍历时修改容器中的值,则需要使用引用:
1
2
3
for(auto& n : arr) {
std::cout << n++ << std::endl;
}

基于范围的for循环的细节

auto自动推导出的类型是容器中的value_type,而不是迭代器:

1
2
3
4
5
std::map<std::string, int> mm = { {"1", 1}, {"2", 2}, {"3", 3} };
for(auto ite = mm.begin(); ite != mm.end(); ite ++)
std::cout << ite->first << "->" << ite->second << std::endl;
for(auto& val : mm)
std::cout << ite.first << "->" << ite.second << std::endl;

从这里就可以看出,在基于范围的for循环中每次迭代时使用的类型和普通for循环有何不同。

对基于范围的for循环而言,冒号后边的表达式只会被执行一次

基于范围的for循环倾向于在循环开始之前确定好迭代的范围,而不是在每次迭代之前都调用一次arr.end()

让基于范围的for循环支持自定义类型

基于范围的for循环将以以下方式查找容器的begin和end:

  • 若容器是一个普通的array对象,那么begin将为array的首地址;
  • 若容器是一个类对象,那么range-based for将试图通过查找类的begin()和end()方法来定位begin和end迭代器;
  • 否则range-based for将试图使用全局的begin和end函数定位begin和end;

对于自定义类型来说,实现begin和end方法即可,通过定义一个range对象看看具体的实现方法。
首先需要一个迭代器实现范围取值:

1
2
3
4
5
6
7
8
9
10
11
template <typename T>
class iterator {
public:
using value_type = T;
using size_type = size_t;

iterator(size_type cur_start, value_type begin_val, value_type step_val);
value_type operator*() const;
bool operator!=(const iterator& rhs);
iterator& operator++(void);
}

构造函数传递三个参数初始化,分别是开始的迭代次数,初始值和迭代步长。operator*用于取得迭代器中的值;operator!=用于和另一个迭代器比较;operator++用于对迭代器做正向迭代。

迭代器类的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
template <typename T>
class iterator {
private:
size_type cursor_;
const value_type step_;
value_type value_;
public:
using value_type = T;
using size_type = size_t;

iterator(size_type cur_start, value_type begin_val, value_type step_val):
cursor_(cur_start), step_(step_val), value_(begin_val) {
value_ += (step_ * cursor_);
}

value_type operator*() const { return value_; }
bool operator!=(const iterator& rhs) const { return (cursor_ != rhs.cursor_); }
iterator& operator++(void) {
value_ += step_;
++ cursor_;
return (*this);
}
}

std::function和bind绑定器

可调用对象

可调用对象有如下几种定义:

  • 是一个函数指针
  • 是一个具有operator()成员函数的类对象
  • 是一个可被转换为函数指针的类对象
  • 是一个类成员指针

应用如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
void func(void) { ... }

struct Foo {
void operator()(void) { ... }
};

struct Bar {
using fr_t = void(*)(void);

static void func(void) { ... }
operator fr_t(void) { return func; }
};

struct A {
int a_;
void mem_func(void) { ... }
};

int main(){
void(* func_ptr)(void) = &func; // 1.函数指针
func_ptr();

Foo foo;
foo(); // 2. 仿函数

Bar bar;
bar(); // 3. 可被转换为函数指针的类对象

void (A::*mem_func_ptr)(void) = &A::mem_func; // 4. 类成员函数指针
int A::*mem_obj_ptr = &A::a_; // 或是类成员指针

A aa;
(aa.*mem_func_ptr)();
aa.*mem_obj_ptr = 123;

return 0;
}

可调用对象包装器-std::function

std::function是可调用对象包装器。它是一个类模板,可以容纳除了类成员指针之外的所有可调用对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#include <iostream>
#include <functional>

void func(void) {
std::cout << __FUNCTION__ << std::endl;
}

class Foo {
public:
static int foo_func(int a) {
std::cout << __FUNCTION__ << "(" << a << ") ->: ";
return a;
}
};

class Bar {
public:
int operator()(int a) {
std::cout << __FUNCTION__ << "(" << a << ") ->: ";
return a;
}
};

int main(){
std::function<void(void)> fr1 = func;
fr1();

std::function<int(int)> fr2 = Foo::foo_func;
std::cout << fr2(123) << std::endl;

Bar bar;
fr2 = bar;
std::cout << fr2(123) << std::endl;
}


结果是:
1
2
3
func
foo_func(123) ->: 123
operator()(123) ->: 123

给std::function填入合适的函数名,它就变成一个可以容纳所有这一类调用方式的函数包装器。

std::function作为函数入参:void call(int x, std::function<void(int)>& f)

std::bind绑定器

std::bind绑定器用来将可调用对象与其参数一起进行绑定,绑定后的结果使用std::function保存,并延迟调用到任何我们需要的时候,用途为:

  • 将可调用对象与其参数一起绑定成为一个仿函数;
  • 将多元可调用对象转成一元或者(n-1)元可调用对象,即只绑定部分参数。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
int main() {
auto fr = std::bind(output, std::placeholders::_l);
for( int i = 0; i < 10; i ++) {
call_when_even(i, fr);
}
std::cout << std::endl;

auto fr2 = std::bind(output_2, std::placeholders::_l);
for( int i = 0; i < 10; i ++) {
call_when_even(i, fr);
}
std::cout << std::endl;
return 0;
}

在这里,我们使用了std::bind,在函数外部通过绑定不同的函数,控制了最后的执行结果。我们使用auto fr保存std::bind的返回结果,是因为我们并不关心std::bind真正的返回类型(实际上std::bind的返回类型是一个stl内部定义的仿函数类型),只需要知道它是一个仿函数,可以直接赋值给一个std::function。当然,这里直接使用std::function类型来保存std::bind的返回值也是可以的。

std::placeholders::_1是一个占位符,代表这个位置将在函数调用时,被传入的第一个参数所替代。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <iostream>
#include <functional>

void output(int x, int y) {
std::cout << x << " " << y << std::endl;
}

int main() {
std::bind(output, 1, 2)();
std::bind(output, std::placeholders::_1, 2)(1);
std::bind(output, 2, std::placeholders::_1)(1);
std::bind(output, 2, std::placeholders::_2)(1);
std::bind(output, 2, std::placeholders::_2)(1, 2);
std::bind(output, std::placeholders::_1, std::placeholders::_2)(1, 2);
std::bind(output, std::placeholders::_2, std::placeholders::_1)(1, 2);
return 0;
}

lambda表达式

lambda表达式有如下优点:

  • 声明式编程风格:就地匿名定义目标函数或函数对象,不需要额外写一个命名函数或者函数对象。以更直接的方式去写程序,有更好的可读性和可维护性。
  • 简洁:不需要额外再写一个函数或者函数对象,避免了代码膨胀和功能分散,让开发者更加集中精力在手边的问题,同时也获取了更高的生产率。
  • 在需要的时间和地点实现功能闭包,使程序更灵活。

lambda表达式的概念和基本用法

lambda表达式定义了一个匿名函数,并且可以捕获一定范围内的变量。 lambda表达式的语法形式可简单归纳如下:

1
[capture] (params) opt -> ret { body; }

其中:capture是捕获列表; params是参数表;opt是函数选项;ret是返回值类型;body是函数体。一个完整的lambda表达式看起来像这样:
1
2
auto f = [](int a)-> int { return a + 1; };
std::cout << f(1) << std::endl;

输出:2。可以看到,上面通过一行代码定义了一个小小的功能闭包,用来将输入加1并返回。

在C++11中,lambda表达式的返回值是通过前面介绍的返回值后置语法来定义的。其实很多时候,lambda表达式的返回值是非常明显的,比如上例。因此,C++中允许省略lambda表达式的返回值定义:

1
auto f= [] (int a) return a + 1; };

这样编译器就会根据 return语句自动推导出返回值类型。

另外,lambda表达式在没有参数列表时,参数列表是可以省略的。因此像下面的写法都是正确的:

1
2
3
auto fl =[](){ return 1; };
auto f2 = []{ return 1; };
//省略空参数表

lambda表达式可以通过捕获列表捕获一定范围内的变量:

  • []不捕获任何变量。
  • [&]捕获外部作用域中所有变量,并作为引用在函数体中使用(按引用捕获)。
  • [=]捕获外部作用域中所有变量,并作为副本在函数体中使用(按值捕获)。
  • [=, &foo]按值捕获外部作用域中所有变量,并按引用捕获foo变量。
  • [bar]按值捕获bar变量,同时不捕获其他变量。
  • [this]捕获当前类中的this指针,让lambda表达式拥有和当前类成员函数同样的访问权限。如果已经使用了&或=,就默认添加此选项。捕获this的目的是可以在lamda中使用当前类的成员函数和成员变量。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class A {
public:
int i_ = 0;

void func(int x, int y) {
auto x1 = []{ return i_; }; // error,没有捕获外部变量
auto x2 = [=]{ return i_ + x + y; }; // ok,捕获所有外部变量
auto x3 = [&]{ return i_ + x + y; }; // ok,捕获所有外部变量
auto x4 = [this]{ return i_; }; // ok,捕获this指针
auto x5 = [this]{ return i_ + x + y; }; // error,没有捕获x、y
auto x6 = [this, x, y]{ return i_ + x + y; }; // ok,捕获this指针、x、y
auto x7 = [this]{ return i_ ++; }; // ok,捕获this指针,并修改成员变量。
}
}

int a =0, b=1;
auto fl = [] { return a; }; // error,没有捕获外部变量
auto f2 = [&]{ return a++; }; // OK,捕获所有外部变量,并对a执行自加运算
auto f3 = [=]{ return a;}; //OK,捕获所有外部变量,并返回a
auto f4 = [=]{ return a++;}; //error,a是以复制方式捕获的,无法修改
auto f5 = [a]{ return a + b;}; //error,没有捕获变量b
auto f6=[a,&b]{ return a+(b++);}; //OK,捕获a和b的引用,并对b做自加运算
auto f7=[=,&b]{ return a+(b++);}; //OK,捕获所有外部变量和b的引用,并对b做自加运算

一个容易出错的细节是,

1
2
3
4
5
6
int a = 0;
auto f = [=] {return a;};

a += 1;

std::cout << f() << std::endl;

在这个例子中,lambda表达式按值捕获了所有外部变量,在捕获的一瞬间a的值就被复制到f中了,之后a被修改,但此时f中存储的a的值仍然是捕获时候的值,因此,最终输出结果是0。希望修改这些变量的话,我们需使用引用方式捕获。

按值捕获得到的外部变量值是在lambda表达式定义时的值。此时所有外部变量均被复制了一份存储在lambda表达式变量中。此时虽然修改 lambda表达式中的这些外部变量并不会真正影响到外部,我们却仍然无法修改它们。那么如果希望去修改按值捕获的外部变量应当怎么办呢?这时,需要显式指明lambda表达式为 mutable:

1
2
3
int a = 0;
auto f1 = [=] { return a++; }; // error,修改按值捕获的外部变量
auto f2 = [=]() mutable { return a++; }; // ok

被mutable修饰的lambda表达式就算没有参数也要写明参数列表。

lambda表达式的类型在C++11中被称为“闭包类型”。它是一个特殊的匿名的非nunion的类类型。因此,我们可以认为它是一个带有 operator()的类,即仿函数。因此,我们可以使用std::function和std::bind来存储和操作lambda表达式。

1
2
std::function<int(int)> f1 = [](int a) { return a; };
std: function<int(void)>f2 = std::bind([](int a) { return a;}, 123);

另外,对于没有捕获任何变量的lambda表达式,还可以被转换成一个普通的函数指针:
1
2
3
using func_t= int(*)(int);
func_t f = [](int a){ return a; };
f(123);

lambda表达式可以说是就地定义仿函数闭包的“语法糖”。它的捕获列表捕获住的任何外部变量,最终均会变为闭包类型的成员变量。而一个使用了成员变量的类的 operator(),如果能直接被转换为普通的函数指针,那么lambda表达式本身的this指针就丢失掉了。而没有捕获任何外部变量的 lambda表达式则不存在这个问题。

这里也可以很自然地解释为何按值捕获无法修改捕获的外部变量。因为按照C++标准,lambda表达式的operator默认是const的。一个const成员函数是无法修改成员变量的值的。而mutable的作用,就在于取消operator()的const。需要注意的是,没有捕获变量的lambda表达式可以直接转换为函数指针,而捕获变量的lambda表达式则不能转换为函数指针。看看下面的代码:

1
2
3
4
5
typedef void(*Ptr)(int*);
//正确,没有捕获的lambda表达式可以直接转换为函数指针
Ptr p = [](int* p) {delete p;};
Ptr pl = [&](int* p) {delete p;};
∥错误,有状态的1 ambda不能直接转换为函数指针

上面第二行代码能编译通过,而第三行代码不能编译通过,因为第三行的代码捕获了变量,不能直接转换为函数指针

tuple元组

tuple元组是一个固定大小的不同类型值的集合。

1
tuple<const char*, int> tp = make_tuple(sendPack, nSendSize);

等价于一个结构体:
1
2
3
4
struct A {
char* p;
int len;
};

还有一种方法也可创建元组:

1
2
3
4
int x = 1;
int y = 2;
string s = "aa";
auto tp = std::tie(x, s, y);

tp的类型是std::tuple<int&, string&, int&>

再看看如何获取元组的值:

1
2
3
4
//获取第一个值
const char* data = tp.get<0>();
//获取第二个值
int len = tp.get<1>();

还有一种方法也可以获取元组的值,通过std::tie解包tuple
1
2
3
int x, y;
string a;
std::tie(x, a, y) = tp;

通过tie解包后,tp中3个值会自动赋值给3个变量。解包时,如果只想解某个位置值时,可以用std::ignore占位符来表示不解某个位置的值。比如我们只想解第3个值:
1
std::tie(std::ignore, std::ignore, y) = tp;

还有一个创建右值的引用元组方法: forward_as_tuple
1
2
std::map<int, std::string> m;
m.emplace(std::piecewise_construct, std::forward_as_tuple(10), std::forward_as_tuple (20, 'a));

它实际上创建了一个类似于std::tuple<int&&,std::string&&>类型的tuple。

我们还可以通过tuple_cat连接多个tuple,代码如下:

1
2
3
4
5
6
7
int main() {
std::tuple<int, std::string, float> t1(10, "Test", 3.14);
int n = 7;
auto t2 = std::tuple_cat(t1, std::make_pair("Foo", "bar"), t1, std::tie(n));
n = 10;
print(t2);
}

结果是:
1
(10, Test, 3.14, Foo, bar, 10, Test, 3.14, 10)

总结

本章主要介绍了通过一些C++11的特性简化代码,使代码更方便、简洁和优雅。首先讨论了自动类型推断的两个关键字auto和decltype,通过这两个关键字可以化繁为简,使我们不仅能方便地声明变量,还能获取复杂表达式的类型,将二者和返回值后置组合起来能解决函数的返回值难以推断的难题。

模板别名和模板默认参数可以使我们更方便地定义模板,将复杂的模板定义用一个简短更可读的名称来表示,既减少了烦琐的编码又提高了代码的可读性。

range-based for循环可以用更简洁的方式去遍历数组和容器,它还可以支持自定义的类型,只要自定义类型满足3个条件即可。

初始化列表和统一的初始化使得初始化对象的方式变得更加简单、直接和统一。

std::function不仅是一个函数语义的包装器,还能绑定任意参数,可以更灵活地实现函数的延迟执行。

lambda表达式能更方便地使用STL算法,就地定义的匿名函数免除了维护一大堆函数对象的烦琐,也提高了程序的可读性。

tuple元组可以作为一个灵活的轻量级的小结构体,可以用来替代简单的结构体,它有一个很好的特点就是能容纳任意类型和任意数量的元素,比普通的容器更灵活,功能也更强大。但是它也有复杂的一面, tuple的解析和应用往往需要模板元的一些技巧,对使用者有一定的要求。

使用C++11改进程序性能

右值引用

右值引用标记为T &&

左值是指表达式结束后仍然存在的持久对象,右值是指表达式结束后就不再存在的临时对象。所有的具名变量或对象都是左值,而右值不具名。在C++11中,右值由两个概念构成,一个是将亡值,另一个则是纯右值。比如,非引用返回的临时变量、运算表达式产生的临时变量、原始字面量和lambda表达式等都是纯右值。而将亡值是C++1l新增的、与右值引用相关的表达式,如将要被移动的对象、T&&函数返回值,std::move返回值。

C+11中所有的值必属于左值、将亡值、纯右值三者之一,将亡值和纯右值都属于右值。区分表达式的左右值属性有一个简便方法:若可对表达式用&符取址,则为左值,否则为右值。比如,简单的赋值语句int i = 0,在这条语句中,i是左值,0是字面量,就是右值。在上面的代码中,i可以被引用,0就不可以了。字面量都是右值。

&&的特性

右值引用就是对一个右值进行引用的类型。因为右值不具名,所以我们只能通过引用的方式找到它。
无论声明左值引用还是右值引用都必须立即进行初始化,因为引用类型本身并不拥有所绑定对象的内存,只是该对象的一个别名。通过右值引用的声明,该右值又“重获新生”,其生命周期与右值引用类型变量的生命周期一样,只要该变量还活着,该右值临时量将会直存活下去。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <iostream>
using namespace std;

int g_constructCount = 0;
int g_copyConstructCount = 0;
int g_destructCount = 0;
struct A {
A() {
cout << "construct: " << ++g_constructCount << endl;
}
A(const A& a) {
cout << "copy construct: " << ++g_copyConstructCount << endl;
}
~A() {
cout << "destruct: " << ++g_destructCount << endl;
}
};
A getA() {
return A();
}
int main()
{
A a = getA();
return 0;
}

1
2
3
4
5
6
7
8
g++ -fno-elide-constructors -std=c++0x -O0 1.cpp -o 1

construct: 1
copy construct: 1
destruct: 1
copy construct: 2
destruct: 2
destruct: 3

在没有返回值优化的情况下,拷贝构造函数调用了两次,一次是getA()函数内部创建的对象返回后构造一个临时对象产生的,一次是在main构造a对象产生的。得如此的destruct是因为临时对象在构造a对象之后就销毁了。修改程序:
1
2
3
4
5
int main()
{
A&& a = getA();
return 0;
}

输出为:
1
2
3
4
construct: 1
copy construct: 1
destruct: 1
destruct: 2

通过右值引用,少了一次拷贝构造和一次析构,原因在于右值引用绑定了右值,让临时右值的生命周期延长了。避免临时对象的拷贝构造和析构,事实上,在C++98/03中,也通过常量左值引用来做性能优化。

实际上T&&并不是一定表示右值,它绑定的类型是未定的,既可能是左值又可能是右值。看看这个例子:

1
2
3
4
5
6
7
8
9
template<typename T>
void f(T&& param);

f(10);
// 10是右值

int x = 10;
f(x);
//x是左值

从这个例子可以看出, param有时是左值,有时是右值,因为在上面的例子中有&&,这表示param实际上是一个未定的引用类型。这个未定的引用类型称为 universal references(可以认为它是一种未定的引用类型),它必须被初始化,它是左值还是右值引用取决于它的初始化,如果&&被一个左值初始化,它就是一个左值;如果它被一个右值初始化,它就是个右值。

需要注意的是,只有当发生自动类型推断时(如函数模板的类型自动推导,或auto关键字),&&才是一个
universal references。

1
2
3
4
5
6
7
8
9
10
11
template <typename T>
void f(T&& param);
//这里T的类型需要推导,所以&&是一个 universal references

template<typename T>
class Test {
Test(Test&& rhs); // 已经定义了一个特定的类型,没有类型推断
// &&是一个右值引用
};
void f(Test&& param);
// 已经定义了一个确定的类型,没有类型推断,&&是一个右值引用

由于存在T&&这种未定的引用类型,当它作为参数时,有可能被一个左值引用或右值引用的参数初始化,这时经过类型推导的T&&类型,相比右值引用(&&)会发生类型的变化,这种变化被称为引用折叠:

  • 所有的右值引用叠加到右值引用上还是一个右值引用;
  • 所有的其他引用类型之间的叠加都将变成左值引用。

编译器会将已命名的右值引用视为左值,而将未命名的右值引用视作右值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void PrintValue(int& i) {
std::cout << "lvalue: " << i << std::endl;
}
void PrintValue(int&& i) {
std::cout << "rvalue: " << i << std::endl;
}
void Forward(int&& i) {
PrintValue(i);
}
int main() {
int i = 0;
PrintValue(i);
PrintValue(1);
Forward(2);
return 0;
}

输出:
1
2
3
lvalue: 0
rvalue: 1
lvalue: 2

Forward函数接收的是一个右值,但是在转发给PrintValue时,因为右值i变成一个命名对象,所以变成了左值。

&&的总结如下:

  • 左值和右值是独立于它们的类型的,右值引用类型可能是左值也可能是右值。
  • auto&&或函数参数类型自动推导的T&&是一个未定的引用类型,被称为universal references,它可能是左值引用也可能是右值引用类型,取决于初始化的值类型。
  • 所有的右值引用叠加到右值引用上仍然是一个右值引用,其他引用折叠都为左值引用,当T&&为模板参数时,输入左值,它会变成左值引用;输入右值时变为具名的右值引用。
  • 编译器会将已命名的右值引用视为左值,将未命名的右值引用视为右值。

在编写拷贝函数时,应该提供深拷贝的拷贝构造函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class A {
public:
A() : m_ptr(new int(0)) {
cout << "construct" << endl;
}
A(const A& a) : m_ptr(new int(*a.m_ptr)) {
cout << "copy construct" << endl;
}
~A() {
cout << "destruct" << endl;
delete m_ptr;
}
public:
int* m_ptr;
};
A get(bool flag) {
A a;
A b;
if (flag)
return a;
else
return b;
}
int main()
{
A a = get(false);
}

输出:
1
2
3
4
5
6
construct
construct
copy construct
destruct
destruct
destruct

这样可以保证拷贝的安全性。但这样的开销很大,get函数返回临时变量,然后通过这个临时变量拷贝构造了新的对象b,临时变量在拷贝完之后就销毁了,可以避免这种性能损耗:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class A {
public:
A() : m_ptr(new int(0)) {
cout << "construct" << endl;
}
A(const A& a) : m_ptr(new int(*a.m_ptr)) {
cout << "copy construct" << endl;
}
A(A&& a) : : m_ptr(a.m_ptr) {
a.m_ptr = nullptr;
cout << "move construct: "<< endl;
}
~A() {
cout << "destruct" << endl;
delete m_ptr;
}
public:
int* m_ptr;
};
A get(bool flag) {
A a;
A b;
if (flag)
return a;
else
return b;
}
int main()
{
A a = get(false);
}

输出:
1
2
3
4
5
6
construct
construct
move construct
destruct
destruct
destruct

上面的代码中没有了拷贝构造,取而代之的是移动构造( Move Construct)。从移动构造函数的实现中可以看到,它的参数是一个右值引用类型的参数A&&,这里没有深拷贝,只有浅拷贝,这样就避免了对临时对象的深拷贝,提高了性能。这里的A&&用来根据参数是左值还是右值来建立分支,如果是临时值,则会选择移动构造函数。移动构造函数只是将临时对象的资源做了浅拷贝,不需要对其进行深拷贝,从而避免了额外的拷贝,提高性能。也就是所谓的移动语义(move语义),右值引用的一个重要目的是用来支持移动语义的。

移动语义可以将资源(堆、系统对象等)通过浅拷贝方式从一个对象转移到另一个对象,这样能够减少不必要的临时对象的创建、拷贝以及销毁,可以大幅度提高C++应用程序的性能,消除临时对象的维护(创建和销毁)对性能的影响。
以代码清单22所示为示例,实现拷贝构造函数和拷贝赋值操作符。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct Element {
Element() {}
Element(Element&& other) : m_children(std::move(other.m_children)) {}
Element(const Element& other) : m_children(other.m_children) {}
private:
vector<ptree> m_children;
}

void Test() {
Element t1 = Init();
vector<Element> v;
v.push_back(t1);
v.push_back(std::move(t1));
}

先构造了一个临时对象t1,这个对象中存放了很多对象,数量可能很多,如果直接将这个t1用 push_back插入到vector中,没有右值版本的构造函数时,会引起大量的拷贝,这种拷贝会造成额外的严重的性能损耗。通过定义右值版本的构造函数以及std::move(t1)就可以避免这种额外的拷贝,从而大幅提高性能。

有了右值引用和移动语义,在设计和实现类时,对于需要动态申请大量资源的类,应该设计右值引用的拷贝构造函数和赋值函数,以提高应用程序的效率。需要注意的是,我们般在提供右值引用的构造函数的同时,也会提供常量左值引用的拷贝构造函数,以保证移动不成还可以使用拷贝构造。

关于左值和右值的定义

左值和右值在C中就存在,不过存在感不高,在C++尤其是C++11中这两个概念比较重要,左值就是有名字的变量(对象),可以被赋值,可以在多条语句中使用,而右值呢,就是临时变量(对象),没有名字,只能在一条语句中出现,不能被赋值。

在 C++11 之前,右值是不能被引用的,最大限度就是用常量引用绑定一个右值,如 :

1
const int& i = 3;

在这种情况下,右值不能被修改的。但是实际上右值是可以被修改的,如 :
1
T().set().get();

T 是一个类,set 是一个函数为 T 中的一个变量赋值,get 用来取出这个变量的值。在这句中,T() 生成一个临时对象,就是右值,set() 修改了变量的值,也就修改了这个右值。
既然右值可以被修改,那么就可以实现右值引用。右值引用能够方便地解决实际工程中的问题,实现非常有吸引力的解决方案。

右值引用

左值的声明符号为”&”, 为了和左值区分,右值的声明符号为”&&”。

给出一个实例程序如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <iostream>

void process_value(int& i)
{
std::cout << "LValue processed: " << i << std::endl;
}

void process_value(int&& i)
{
std::cout << "RValue processed: " << i << std::endl;
}

int main()
{
int a = 0;
process_value(a);
process_value(1);
}

结果如下
1
2
3
4
wxl@dev:~$ g++ -std=c++11  test.cpp
wxl@dev:~$ ./a.out
LValue processed: 0
RValue processed: 1

Process_value 函数被重载,分别接受左值和右值。由输出结果可以看出,临时对象是作为右值处理的。

下面涉及到一个问题:
x的类型是右值引用,指向一个右值,但x本身是左值还是右值呢?C++11对此做出了区分:

Things that are declared as rvalue reference can be lvalues or rvalues. The distinguishing criterion is: if it has a name, then it is an lvalue. Otherwise, it is an rvalue.

对上面的程序稍作修改就可以印证这个说法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <iostream>

void process_value(int& i)
{
std::cout << "LValue processed: " << i << std::endl;
}

void process_value(int&& i)
{
std::cout << "RValue processed: " << std::endl;
}

int main()
{
int a = 0;
process_value(a);
int&& x = 3;
process_value(x);
}

1
2
3
4
wxl@dev:~$ g++ -std=c++11  test.cpp
wxl@dev:~$ ./a.out
LValue processed: 0
LValue processed: 3

x 是一个右值引用,指向一个右值3,但是由于x是有名字的,所以x在这里被视为一个左值,所以在函数重载的时候选择为第一个函数。

右值引用的意义

直观意义:为临时变量续命,也就是为右值续命,因为右值在表达式结束后就消亡了,如果想继续使用右值,那就会动用昂贵的拷贝构造函数。(关于这部分,推荐一本书《深入理解C++11》)
右值引用是用来支持转移语义的。转移语义可以将资源 ( 堆,系统对象等 ) 从一个对象转移到另一个对象,这样能够减少不必要的临时对象的创建、拷贝以及销毁,能够大幅度提高 C++ 应用程序的性能。临时对象的维护 ( 创建和销毁 ) 对性能有严重影响。
转移语义是和拷贝语义相对的,可以类比文件的剪切与拷贝,当我们将文件从一个目录拷贝到另一个目录时,速度比剪切慢很多。
通过转移语义,临时对象中的资源能够转移其它的对象里。
在现有的 C++ 机制中,我们可以定义拷贝构造函数和赋值函数。要实现转移语义,需要定义转移构造函数,还可以定义转移赋值操作符。对于右值的拷贝和赋值会调用转移构造函数和转移赋值操作符。如果转移构造函数和转移拷贝操作符没有定义,那么就遵循现有的机制,拷贝构造函数和赋值操作符会被调用。
普通的函数和操作符也可以利用右值引用操作符实现转移语义。

转移语义以及转移构造函数和转移复制运算符
以一个简单的 string 类为示例,实现拷贝构造函数和拷贝赋值操作符。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
class MyString { 
private:
char* _data;
size_t _len;
void _init_data(const char *s) {
_data = new char[_len+1];
memcpy(_data, s, _len);
_data[_len] = '\0';
}
public:
MyString() {
_data = NULL;
_len = 0;
}

MyString(const char* p) {
_len = strlen (p);
_init_data(p);
}

MyString(const MyString& str) {
_len = str._len;
_init_data(str._data);
std::cout << "Copy Constructor is called! source: " << str._data << std::endl;
}

MyString& operator=(const MyString& str) {
if (this != &str) {
_len = str._len;
_init_data(str._data);
}
std::cout << "Copy Assignment is called! source: " << str._data << std::endl;
return *this;
}

virtual ~MyString() {
if (_data)
free(_data);
}
};

int main() {
MyString a;
a = MyString("Hello");
std::vector<MyString> vec;
vec.push_back(MyString("World"));
}

1
2
Copy Assignment is called! source: Hello 
Copy Constructor is called! source: World

这个 string 类已经基本满足我们演示的需要。在 main 函数中,实现了调用拷贝构造函数的操作和拷贝赋值操作符的操作。MyString(“Hello”) 和 MyString(“World”) 都是临时对象,也就是右值。虽然它们是临时的,但程序仍然调用了拷贝构造和拷贝赋值,造成了没有意义的资源申请和释放的操作。如果能够直接使用临时对象已经申请的资源,既能节省资源,有能节省资源申请和释放的时间。这正是定义转移语义的目的。

我们先定义转移构造函数。

1
2
3
4
5
6
7
 MyString(MyString&& str) { 
std::cout << "Move Constructor is called! source: " << str._data << std::endl;
_len = str._len;
_data = str._data;
str._len = 0;
str._data = NULL;
}

有下面几点需要对照代码注意:

  1. 参数(右值)的符号必须是右值引用符号,即“&&”。
  2. 参数(右值)不可以是常量,因为我们需要修改右值。
  3. 参数(右值)的资源链接和标记必须修改。否则,右值的析构函数就会释放资源。转移到新对象的资源也就无效了。

现在我们定义转移赋值操作符。

1
2
3
4
5
6
7
8
9
10
 MyString& operator=(MyString&& str) { 
std::cout << "Move Assignment is called! source: " << str._data << std::endl;
if (this != &str) {
_len = str._len;
_data = str._data;
str._len = 0;
str._data = NULL;
}
return *this;
}

这里需要注意的问题和转移构造函数是一样的。
增加了转移构造函数和转移复制操作符后,我们的程序运行结果为 :

由此看出,编译器区分了左值和右值,对右值调用了转移构造函数和转移赋值操作符。节省了资源,提高了程序运行的效率。
有了右值引用和转移语义,我们在设计和实现类时,对于需要动态申请大量资源的类,应该设计转移构造函数和转移赋值函数,以提高应用程序的效率。

但是这几点总结的不错

  • std::move执行一个无条件的转化到右值。它本身并不移动任何东西;
  • std::forward把其参数转换为右值,仅仅在那个参数被绑定到一个右值时;
  • std::move和std::forward在运行时(runtime)都不做任何事。

move语义

std::move将左值转换为右值,从而方便应用移动语义。move是将对象的状态或者所有权从一个对象转移到另一个对象,只是转移没有拷贝。

move实际上并不移动任何东西,它唯一的功能是将一个左值强制转换为一个右值引用,是我们可以通过右值引用使用该值,以用于移动语义。

仅仅转移资源的所有者,将资源的拥有者改为被赋值者。假设一个临时容器很大,赋值给另一个容器:

1
2
3
4
5
std::list<std::string> tokens;
std::list<std::string> t = tokens;

std::list<std::string> tokens;
std::list<std::string> t = std::move(tokens);

如果不用std::move,拷贝的代价很大,性能较低,使用move几乎没有任何代价,只是转换了资源的所有权,实际上是将左值转换为右值引用,然后应用move语义调用构造函数,就避免了拷贝。

forward和完美转发

需要一种方法能按照参数原来的类型转发到另一个函数,这种转发被称作完美转发,即在函数模板中,完全依照模板的参数的类型(保持参数的左右值特征),将参数传递给函数模板中调用的另一个函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
void PrintT(int& t) {
cout << "lvalue" << endl;
}

void PrintT(int&& t) {
cout << "rvalue" << endl;
}
template<typename T>
void TestForward(T && t) {
PrintT(t);
PrintT(std::forward<T>(t));
PrintT(std::move(t));
}

int main() {
TestForward(1);
int x = 1;
TestForward(x);
TestForward(std::forward<int>(x));
return 0;
}

输出:
1
2
3
4
5
6
7
8
9
lvalue
rvalue
rvalue
lvalue
lvalue
rvalue
lvalue
rvalue
rvalue

TestForward(1)时,1是右值,所以未定义的引用类型T&& t被一个右值初始化后变成一个右值引用。但是在TestForward中调用PrintT(t)时,t变成一个左值。调用PrintT(std::forward<T>(t))时,std::forward会按照原来的参数类型转发,所以它还是一个右值。

TestForward(x)未定的引用类型T&& t被一个左值初始化后变成一个左值引用,因此,在调用PrintT(std::forward<T>(t))时它会被转发到PrintT(T& t)

emplace_back减少内存拷贝和移动

emplace_back能就地通过参数构造对象,不需要拷贝或者移动内存,相比push_back能更好地避免内存的拷贝与移动,使容器插入元素的性能得到进一步提升。在大多数情况下应该优先使用emplace_back来代替push_back。

所有的标准库容器(array除外,因为它的长度不可改变,不能插入元素)都增加了类似的方法: emplace、 emplace_hint、 emplace_front、emplace_after和emplace_back。

vector的emplace_back的基本用法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <vector>
#include <iostream>
using namespace std;

struct A {
int x;
double y;
A(int a, double b): x(a), y(b);
};
int main() {
vector<A> v;
v.emplace_back(1, 2);
cout<<v.size()<<endl;
return 0;
}

可以看出, emplace_back的用法比较简单,直接通过构造函数的参数就可以构造对象。因此,也要求对象有对应的构造函数,如果没有对应的构造函数,编译器会报错。

其他容器相应的 emplace方法也是类似的。
相对 push_back而言, emplace_back更具性能优势。

在引入右值引用,转移构造函数,转移复制运算符之前,通常使用push_back()向容器中加入一个右值元素(临时对象)的时候,首先会调用构造函数构造这个临时对象,然后需要调用拷贝构造函数将这个临时对象放入容器中。原来的临时变量释放。这样造成的问题是临时变量申请的资源就浪费。
引入了右值引用,转移构造函数(请看这里)后,push_back()右值时就会调用构造函数和转移构造函数。

unordered container无序容器

C++11增加了无序容器 unordered_map/unordered_multimap和unordered_set/unordered_multiset,由于这些容器中的元素是不排序的,因此,比有序容器 map/multimap和set/multiset效率更高。

map和set内部是红黑树,在插入元素时会自动排序,而无序容器内部是散列表( Hash Table),通过哈希(Hash),而不是排序来快速操作元素,使得效率更高。由于无序容器内部是散列表,因此无序容器的key需要提供hash_value函数,其他用法和map/set的用法是一样的。不过对于自定义的key,需要提供Hash函数和比较函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#include <unordered_map>
#include <vector>
#include <bitset>
#include <string>
#include <utility>

struct Key {
std::string first;
std::string second;
};

struct KeyHash {
std::size_t operator()(const Key& k) const {
return std::hash<std::string>()(k.first)^(std::hash<std::string>()(k.second) << 1);
}
};
struct keyEqual {
bool operator()(const Key& lhs, const Key& rhs) const {
return lhs.first == rhs.first && lhs.second == rhs.second;
}
};

int main() {
std::unordered_map<std::string, std::string> m1;

std::unordered_map<int, std::string> m2 = {
{1, "foo"},
{2, "bar"},
{3, "baz"},
};

std::unordered_map<int, std::string> m3 = m2;
std::unordered_map<int, std::string> m4 = std::move(m2);

std::vector<std::pair<std::bitset<8>, int>> v = { {0x12, 1}, {0x01, -1} };
std::unordered_map<std::bitset<8>, double> m5(v.begin(), v.end());

// constructor for a custom type
std::unordered_map<Key, std::string, KeyHash, KeyEqual> m6 = {
{ {"john", "doe"}, "example"},
{ {"mary", "Sue"}, "another"},
};
return 0;
}

使用C++11消除重复,提高代码质量

type_traits——类型萃取

type_traits的类型判断功能在编译期就可以检查出是否是正确的类型,以便能编写更安全的代码。

基本的type_traits

在之前的C++中,在类中定义编译期常量的方法是:

1
2
3
4
template<typename T>
struct GetLeftSize {
static const int value = 1;
};

在C++11中定义编译期常量,无需自己定义static const intenum类型,只需要从std::integral_constant派生:

1
2
template<typename T>
struct GetLeftSize : std::integral_constant<int, 1> { };

将编译期常量包装为一个类型的type_trait——integral_constant:

1
2
3
4
5
6
7
template<class T, T v>
struct integral_constant {
static const T value = v;
typedef T value_type;
typedef integral_constant<T, v> type;
operator value_type() { return value;}
}

常见的用法是从integral_constant派生从而可以通过继承得到value

派生的type_traits可用于检查模板类型是否为某种类型,通过这些trait可以获取编译期检查的bool值结果。

1
2
template<class T>
struct is_integral;

这是用来检查T是否为bool、char、int、long、long long等整型类型的,派生于std::integral_constant,因此可以通过std::is_xxx::value是否为true判断模板类型是否为目标类型。

1
2
3
4
5
6
7
8
9
10
11
#include <iostream>
#include <type_traits>
using namespace std;

int main()
{
cout << "int" << std::is_const<int>::value << endl;
cout << "const int" << std::is_const<const int>::value << endl;
cout << "const int*" << std::is_const<const int*>::value << endl;
cout << "const int&" << std::is_const<const int&>::value << endl;
}

C++提供了判断类型之间的关系的traits:

1
2
3
4
template<class T, class U>
struct is_same // 判断两个类型是否相同
struct is_base_of // 判断base类型是否是derived类型的积累
struct is_convertible // 判断模板参数类型是否能转换

C++提供了类型转换traits,包括对const的修改,引用的移除和添加,指针和数组的修改等。

1
2
3
template <typename T>
struct remove_const
strcut add_const

有时需要添加引用类型,比如从智能指针中获取对象的引用时

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <iostream>
#include <type_traits>
#include <memory>
using namespace std;

template<class T>
struct Construct {
typedef typename std::remove_reference<T>::type U;
Construct() : m_ptr(new U) { }

typename std::add_lvalue_reference<U>:: type
Get() const { return *m_ptr.get(); }
private:
std::unique_ptr<U> m_ptr;
};

int main() {
Construct<int> c;
int a = c.Get();
cout << a << endl;
return 0;
}

移除引用和cv符:

1
2
3
4
5
6
template<typename T> 
typename std::remove_cv<typename std::remove_reference<T>::type>::type*
Create() {
typedef typename std::remove_cv<typename std::remove_reference<T>::type>::type U;
return new U();
}

先移除引用,再移除cv符,最终获得原始类型,这样可以解决问题,但是较为繁琐,用decay来简化代码:

1
2
3
4
5
template<typename T> 
typename std::decay<T>::type* Create() {
typedef typename std::decay<T>::type U;
return new U();
}

对于普通类型来说,std::decay是移除引用和cv符,大大简化了我们的书写。除了普通类型之外,std::decay还可以用于数组和函数,具体的转换规则如下:

  • 先移除T类型的引用,得到类型U,U定义为remove_reference<T>::type
  • 如果is_array<U>::value为true,修改类型type为remove_extent<U>::type*
  • 否则,如果is_function<U>::value为true,修改类型type为add_pointer<U>::type
  • 否则,修改类型type为remove_cv<U>::type

根据上面的规则,再对照用法示例,就能清楚地理解std::decay的含义了。下面是std::decay的基本用法:

1
2
3
4
5
6
typedef std::decay<int>::type A; // int
typedef std::decay<int&>::type B; // int
typedef std::decay<int&&>::type C; // int
typedef std::decay<const int&>::type D; // int
typedef std::decay<int[2]>::type E; //int*
typedef std::decay<int(int)>::type F: //int(*)(int)

由于std::decay对于函数来说是添加指针,利用这一点,我们可以将函数变成函数指针类型,从而将函数指针变量保存起来,以便在后面延迟执行。

std::conditional在编译期根据一个判断式选择两个类型中的一个,和条件表达式的语义类似,类似一个三元表达式。它的原型如下:

1
2
template< bool B, class T, class F>
struct conditional

std::conditional模板参数中,如果B为true,则conditional::type为T,否则为F。
std::conditional测试代码如下:

1
2
3
4
5
typedef std::conditional<true, int, float>::type A; // int
typedef std::conditional<false, int, float>::type B, // float

typedef std::conditional<std::is_integral<A>::value, long, int>:: type C; // long
typedef std::conditional<std::is_integral<B>::value, long, int>:: type D; // int

比较两个类型,输出较大的那个类型:

1
2
typedef std::conditional<(sizeof(long long) > sizeof(long double)), long long, long double>::type max_size_t;
cout<<typeid(max_size_t).name()<<endl;

将会输出: long double
我们可以通过编译期的判断式来选择类型,这给我们动态选择类型提供了很大的灵活性,在后面经常和其他的C++11特性配合起来使用,是比较常用的特性之一。

有时要获取函数的返回类型是一件比较困难的事情,C++提供了std::result_of,用来在编译期获取一个可调用对象。

1
std::result_of<A(int)>::type i = 4;

等价于

1
decltype(std::declval<A>()(std::declval<int>()));

std::result_of原型如下:

1
2
template<class F, class ... ArgTypes>
class result_of<F(ArgTypes...)>;

第一个模板参数为可调用对象的类型,第二个模板参数为参数的类型。

1
2
3
4
5
6
7
8
9
10
11
int fn(int) { return int(); }
typedef int(&fn_ref)(int);
typedef int(*fn_ref)(int);
struct fn_class { int operator()(int i) {return i;} };

int main {
typedef std::result_of<decltype(fn)&(int)>:: type A; // int
typedef std::result_of<fn_ref(int)>::type B; // int
typedef std::result_of<fn_ptr(int)>::type C; // int
typedef std::result_of<fn_class(int)>::type D; // int
}

std::result_of<Fn(ArgTypes...)>要求Fn为一个可调用对象(不能是个函数类型,因为函数类型不是一个可调用对象,因此,下面这种方式是错误的:

1
typedef std::result_of<decltype(fn)(int)>::type A;

如果要对某个函数使用std::result_of,要先将函数转换为可调用对象。可以通过以下方式来获取函数返回类型

1
2
3
4
typedef std::result_of<decltype(fn)&(int)>::type A;
typedef std::result_of<decltype(fn)*(int)>::type B;
typedef std::result_of<typename std::decay<decltype(fn)>::type(int)>::type C;
A B C 类型相同

可变参数模板

声明可变参数模板时需要在typename或class后边带上’…’。

  • 声明一个参数包,这个参数包中可以包含0到任意个模板参数
  • 在模板定义的右边,可以把参数包展开成一个一个独立的参数

可变参数模板函数

1
2
3
4
5
6
7
template <class ... T>
void f(T... args) {
cout << sizeof...(args) << endl;
}
f(); // 0
f(1, 2); // 2
f(1, 2.5, ""); // 3

如果要用参数包中的参数,则一定要将参数包展开,有两种展开参数包的方法,一种是递归的模板函数展开,一种是通过逗号表达式和初始化列表方式展开。

通过递归函数展开参数包,需要提供一个参数包展开的函数和一个递归终止函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <iostream>
using namespace std;
//递归终止函数
void print() {
cout << "Empty" << endl;
}
//展开函数
template <class T, class... Args>
void print(T head, Args... rest) {
cout << "parameter " << head << endl;
print(rest...);
}

int main() {
print(1,2,3,4);
return 0;
}

输出:

1
2
3
4
5
parameter 1
parameter 2
parameter 3
parameter 4
Empty

递归终止函数可以写成如下形式:

1
2
3
4
5
6
7
8
template<typename T, typename T1, typename T2>
void print(T t, T1 t1) {
cout << t << " " << t1 << endl;
}
template<typename T, typename T1, typename T2>
void print(T t, T1 t1, T2 t2) {
cout << t << " " << t1 << " " << t2 << endl;
}

另一种方法是:

1
2
3
4
5
6
7
8
9
template <class T> 
void printarg(T t) {
cout << t << endl;
}
template <class ...Args>
void expand(Args... args) {
int arr[] = { (printarg(args), 0)...}
}
expand(1,2,3,4);

这种就地展开参数包的方式关键是逗号表达式,它会按顺序执行逗号前边的表达式。expand()函数中的(printarg(args), 0),先执行printarg(args),再得到逗号表达式的结果0。同时用到了初始化列表,通过初始化列表来初始化一个变长数组。{(printargs(args), 0)...}将会展开成((printargs(arg1), 0), (printargs(arg2), 0), (printargs(arg3), 0), etc...),最终会创建一个所有元素为0的数组int arr[sizeof(Args)],会先执行表达式前面的printarg打印出参数。

可变参数模板类

1
2
template <class... Types>
class tuple;

这个可变参数模板类可以携带任意类型任意个数的模板参数:

1
2
3
std::tuple<int> tp1 = std::make_tuple(1);
std::tuple<int, double> tp2 = std::make_tuple(1, 2.5);
std::tuple<int, double, string> tp3 = std::make_tuple(1, 2.5, "hello");

模板递归和特化方式展开参数包

可变参数模板类的展开一般需要定义2 ~ 3个类,包含类声明和特化的模板类

1
2
3
4
5
6
7
8
9
10
11
12
13
template <typename... nums> struct Sum;// 变长模板的声明

template <typename First, typename... last>
struct Sum<first, last...> // 变长模板类
{
static const long val = first * Sum<last...>::val;
};

template<>
struct Sum<> // 边界条件
{
static const long val = 1;
};

一个基本的可变参数模板应用类由三部分组成:

第一个是template<typename... Args> struct Sum,这是前向声明,声明这个类是可变参数模板类

第二个是类的定义,它定义了一个部分展开的可变参数模板类,告诉编译器如何递归展开参数包

1
2
3
4
5
template <typename First, typename... last> 
struct Sum<first, last...> // 变长模板类
{
static const long val = first * Sum<last...>::val;
};`

第三个是特化的递归终止类,这是在展开到0个参数时终止,也可以在展开到2个时终止。

1
2
3
4
5
6
7
8
9
template<>
struct Sum<> // 边界条件
{
static const long val = 1;
};
template<typename First, typename Last>
struct sum<First, Last> {
static const long val = First * Last;
}

可变参数消除重复代码

1
2
3
4
5
6
7
8
9
template<typename T>
void Print(T t) {
cout << t << endl;
}
template<typename T, typename ... Args>
void Print(T t) {
cout << t ;
Print(args...);
}

通过可变模板参数可以消除重复,同时去掉参数个数限制:

1
2
3
4
5
6
template<typename ... Args>
T* Instance(Args... args) {
return new T(args...);
}
A* pa = Instance<A>(1);
B* pb = Instance<B>(1, 2);

上边的代码T* Instance(Args... args)的Args是值拷贝的,存在性能损耗,可以通过完美转发来消除损耗:

1
2
3
4
template<typename ... Args>
T* Instance(Args&&... args) {
return new T(std::forward<Args >(args)...);
}

可变参数模板和type_traits的综合应用

optional的实现

C+14中将包含一个std::optional类,它的功能及用法和boost的optional类似。optional<T>内部存储空间可能存储了T类型的值也可能没有存储T类型的值,只有当optional被T初始化之后,这个optional才是有效的,否则是无效的,它实现了未初始化的概念。

optional可以用于解决函数返回无效值的问题,有时根据某个条件去查找对象时,如果查找不到对象,就会返回一个无效值,这不表明函数执行失败,而是表明函数正确执行了,只是结果不是有用的值。这时就可以返回一个未初始化的optional对象,判断这个optional对象是否是有效对象需要判断它是否被初始化,如果没有被初始化就表明这个值是无效的。boost中的optional就实现了这种未初始化的概念。 boost.optional的基本用法很简单:

1
2
3
4
5
6
optional<int> op;
if(op)
cout << *op << endl;
optional<int> op1 = 1;
if(op1)
cout << *op1 << endl;

第一个op由于没有被初始化,所以它是一个无效值,将不会输出打印信息;第二个op被初始化为1,所以它是一个有效值,将会输出1。optional经常用于函数返回值。

由于optional需要容纳T的值,所以需要一个缓冲区保存这个T,这个缓冲区不可用普通的char数组,需要使用内存对齐的缓冲区std::aligned_storage,原型如下,其中,Len表示所存储类型的size,Align表示该类型内存对齐的大小,通过sizeof(T)可以获取T的size,通过alignof(T)可以获取T内存对齐的大小:

1
2
3
4
5
template< std::size_t Len, std::size_t Align = /* default-alignment */ >
struct aligned_storage;

std::aligned_storage<sizeof(T), alignof(T)> 或
std::aligned_storage<sizeof(T), std::alignment_of<T>::value>

需要注意拷贝和赋值时,内部状态和缓冲区销毁的问题。内部状态用来标示该optional是否被初始化,当已经初始化时需要先将缓冲区清理一下。需要增加右值版本优化效率。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
#include <type_traits>

template<typename T>
class Optional
{
using data_t = typename std::aligned_storage<sizeof(T), std::alignment_of<T>::value>::type;
public:
Optional() : m_hasInit(false) {}
Optional(const T& v)
{
Create(v);
}

Optional(T&& v) : m_hasInit(false)
{
Create(std::move(v));
}

~Optional()
{
Destroy();
}

Optional(const Optional& other) : m_hasInit(false)
{
if (other.IsInit())
Assign(other);
}

Optional(Optional&& other) : m_hasInit(false)
{
if (other.IsInit())
{
Assign(std::move(other));
other.Destroy();
}
}

Optional& operator=(Optional &&other)
{
Assign(std::move(other));
return *this;
}

Optional& operator=(const Optional &other)
{
Assign(other);
return *this;
}

template<class... Args>
void emplace(Args&&... args)
{
Destroy();
Create(std::forward<Args>(args)...);
}

bool IsInit() const { return m_hasInit; }

explicit operator bool() const { return IsInit(); }

T& operator*()
{
return *((T*) (&m_data));
}

T const& operator*() const
{
if (IsInit())
{
return *((T*) (&m_data));
}

throw std::exception("is not init");
}

bool operator == (const Optional<T>& rhs) const
{
return (!bool(*this)) != (!rhs) ? false : (!bool(*this) ? true : (*(*this)) == (*rhs));
}

bool operator < (const Optional<T>& rhs) const
{
return !rhs ? false : (!bool(*this) ? true : (*(*this) < (*rhs)));
}

bool operator != (const Optional<T>& rhs)
{
return !(*this == (rhs));
}
private:
template<class... Args>
void Create(Args&&... args)
{
new (&m_data) T(std::forward<Args>(args)...);
m_hasInit = true;
}

void Destroy()
{
if (m_hasInit)
{
m_hasInit = false;
((T*) (&m_data))->~T();
}
}

void Assign(const Optional& other)
{
if (other.IsInit()) {
Copy(other.m_data);
m_hasInit = true;
}
else {
Destroy();
}
}

void Assign(Optional&& other)
{
if (other.IsInit()) {
Move(std::move(other.m_data));
m_hasInit = true;
other.Destroy();
}
else {
Destroy();
}
}

void Move(data_t&& val)
{
Destroy();
new (&m_data) T(std::move(*((T*)(&val))));
}

void Copy(const data_t& val)
{
Destroy();
new (&m_data) T(*((T*) (&val)));
}

private:
bool m_hasInit;
data_t m_data;
};

惰性求值类lazy的实现

惰性求值(Lazy Evaluation)是相对常用的编程语言中的及早求值而言的另一种求值策略,也被称之为按需调用(call-by-need),或者叫延时求值。简单地讲,惰性求值是在谋求一种平衡,一种在节省开发与节约计算资源的一种平衡策略。一个庞大的类实例可能一次只有一小部分会发生更改,如果把其他的东西都盲目的添加进来,就会额外造成不少的计算资源的浪费。因此,在开发时,开发人员不仅要知道高级语言的语法糖,也需要一定的底层 AST 的实现原理,这样能够避免很多不必要的运行时开销。所以,这里的惰性,更多的是指等待的意思:一旦等到了明确的调用命令,自然会把运行结果正确送出。

借助lambda表达式,将函数封装到lambda表达式中,而不是马上求值,是在需要的时候再调用lambda表达式来求值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
template<typename T>
struct Lazy {
Lazy() {}

template <typename Func, typename ... Args>
Lazy(Func& f, Args && ... args) {
m_func = [&f, &args...]{ return f(args...); };
}

T& value() {
if (!m_value.IsInit()) {
m_vlaue = m_func();
}
return *m_value;
}

bool IsValueCreated() const {
return m_value.IsInit();
}
private:
std::function<T()> m_func;
Optional<T> m_value;
};

template<class Func, typename... Args>
Lazy<typename std::result_of<Func(Args...)>::type> lazy(Func && fun, Args && ... args) {
return Lazy<typename std::result_of<Func(Args...)>::type>(std::forward<Func>(fun), std::forward<Args>(args)...);
}

Lazy类用到了std::function和optional,其中std::function用来保存传入的函数,不马上执行,而是延迟到后面需要使用值的时候才执行,函数的返回值被放到一个optional对象中,如果不用optional,则需要增加一个标识符来标识是否已经求值,而使用optional对象可以直接知道对象是否已经求值,用起来更简便。

通过optional对象我们就知道是否已经求值,当发现已经求值时直接返回之前计算的结果,起到缓存的作用。
代码清单后一部分定义了一个辅助函数,该辅助函数的作用是更方便地使用Lazy,因为Lazy类需要一个模板参数来表示返回值类型,而type_traits中的std::result_of可以推断出函数的返回值类型,所以这个辅助函数结合std::result_of就无须显式声明返回类型了,同时可变参数模板消除了重复的模板定义和模板参数的限制,可以满足所有的函数入参,在使用时只需要传入一个函数和其参数就能实现延迟计算。

Lazy内部的std::function用来保存传入的函数,以便在后面延迟执行,这个function定义是没有参数的,因为可以通过一个lambda表达式去初始化一个function,而lambda表达式可以捕获参数,所以无须定义function的参数,当然还可以通过std::bind绑定器来将N元的入参函数变为sdtd::function

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
struct BigObject {
BigObject() { cout << "lazy load big object" << endl; }
};
struct MyStruct {
MyStruct() {
m_obj = lazy( [] { return std::make_shared<BigObject>(); });
}
void Load() {
m_obj.Value();
}
Lazy< std::shared_ptr<BigObject> > m_obj;
};

int Foo(int x) { return x * 2; }

void TestLazy() {
int y = 4;
auto lazyer1 = lazy(Foo, y);
cout << lazyer1.Value() << endl;

Lazy<int> lazyer2 = lazy([] {return 12;});
cout << lazyer2.Value() << endl;

std::function <int(int)> f = [](int x) { return x + 3; };
auto lazyer3 = lazy(f, 3);
cout << lazyer3.Value() << endl;

MyStruct t;
t.Load();
}

输出:

1
2
3
4
8
12
6
lazy load big object

dll帮助类

如果要按照

1
Ret CallDllFunc(const string& funName, T arg)

这种方式调用,则首先要把函数指针转换成一种函数对象或泛型函数,这样可以用std::function做这件事。

封装GetProcAddress,将函数指针转换成std::function

1
2
3
4
5
template<typename T>
std::function<T> GetFunction(const string& funName) {
FARPROC funAddress = GetProcAddress(m_hMod, funcName.c_str());
return std::function<T>((T*)funAddress)
}

T是std::function的模板参数,即函数类型的签名。

1
2
auto fmax = GetFunction<int(int, int)>("Max");
auto fget = GetFunction<int(int)>("Get");

解决函数返回值与入参不一样的问题,通过result_of和可变参数模板解决:

1
2
3
4
5
6
7
template <typename T, typename ... Args>
typename std::result_of<std::function<T>(Args...)>::type ExecuteFunc(const string& funcName, Args&& ... args) {
return GetFunction<T>(funcName)(args...);
}

auto max = ExecuteFunc<int(int, int)>("Max", 5, 8);
auto ret = ExecuteFunc<int(int)>("Get", 5);

lambda链式调用

将多个函数按照前一个的输出作为下一个输入串起来再推迟到某个时刻计算。

首先创建一个task对象,然后连续调用then的函数,只需要保证前一个函数的输出为后一个的输入即可。最后在需要的时候调用计算结果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
template<typename T>
class Task;

template<typename R, typename...Args>
class Task<R(Args...)>
{
public:
Task(std::function<R(Args...)>&& f) : m_fn(std::move(f)) {}
Task(std::function<R(Args...)>& f) : m_fn(f) {}

R run(Args&&... args)
{
return m_fn(std::forward<Args>(args)...);
}

template <typename F>
auto Then(F&& f) -> Task<typename std::result_of<F(R)>::type(Args...)>
{
using return_type = typename std::result_of<F(R)>::type;

auto func = std::move(m_fn);
return Task<return_type(Args...)>([func, &f](Args&&...args) {
return f(func(std::forward<Args>(args)...));
});
}

private:
std::function<R(Args...)> m_fn;
};

void tesk()
{
Task<int(int)> task([](int i) {return i; });

auto f = task
.Then([](int i) {return i + 1; })
.Then([](int i) {return i + 2; })
.Then([](int i) {return i + 3; });

auto result = f.run(0);

cout << "run task result:" << result << endl;
}

输出:

1
run task result:6

any类的实现

boost库有一个Any类,是一个特殊的只能容纳一个元素的容器,他可以擦除类型,给任何类型赋值。

1
2
3
4
5
6
7
8
9
boost::any a = 1;
boost::any a = 1.1;

std::vector<boost::any> v;
v.push_back(a);
v.push_back(b);

int va = boost::any_cast<int>(a); // 1
double vb = boost::any_cast<double>(b); // 2.5

vector中可以存放int和double,因为any擦除了int和double的类型,当通过any_cast取出实际类型时,如果T不是原来的类型,会报错。

any能容纳所有类型的数据,因此,当赋值给any时,需要将值的类型擦除,即以一种通用的方式保存所有类型的数据。这里可以通过继承去擦除类型,基类是不含模板参数的,派生类中才有模板参数,这个模板参数类型正是赋值的类型。在赋值时,将创建的派生类对象赋值给基类指针,基类的派生类携带了数据类型,基类只是原始数据的一个占位符,通过多态的隐式转换擦除了原始数据类型,因此,任何数据类型都可以赋值给它,从而实现能存放所有类型数据的目标。当取数据时需要向下转换成派生类型来获取原始数据,当转换失败时打印详情,并抛出异常。由于向any赋值时需要创建一个派生类对象,所以还需要管理该对象的生命周期,这里用unique_ptr智能指针去管理对象的生命周期。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
class Any
{
public:
//默认构造函数
Any() : m_tpIndex(std::type_index(typeid(void))) {}
Any(const Any& other) : m_ptr(other.clone()), m_tpIndex(other.m_tpIndex) {}
Any(Any&& other) : m_ptr(std::move(other.m_ptr)), m_tpIndex(std::move(other.m_tpIndex)) {}

//通用的右值构造
template<typename T, class = typename std::enable_if<!std::is_same<typename std::decay<T>::type, Any>::value, T>::type>
Any(T && value) : m_ptr(new Derived<typename std::decay<T>::type>(std::forward<T>(value)))
, m_tpIndex(std::type_index(typeid(std::decay<T>::type))) {}

//判断是否为空
bool isNull() {
return !bool(m_ptr);
}

//是否可以类型转换
template<class T>
bool is() const {
return m_tpIndex == std::type_index(typeid(T));
}

//类型转换
template<class T>
T& cast()
{
if (!is<T>())
{
cout << "can not cast " << typeid(T).name() << " to "
<< m_tpIndex.name() << endl;
throw bad_cast();
}
auto ptr = dynamic_cast<Derived<T>*>(m_ptr.get());
return ptr->m_value;
}

Any& operator=(const Any& other)
{
if (m_ptr == other.m_ptr)
{
return *this;
}
m_ptr = other.clone();
m_tpIndex = other.m_tpIndex;
return *this;
}

private:
struct Base;
using BasePtr = std::unique_ptr<Base>;

//非模板擦除类型
struct Base
{
virtual BasePtr clone() const = 0;
};

template<typename T>
struct Derived : public Base
{
template<typename...Args>
Derived(Args&&...args) : m_value(std::forward<Args>(args)...)
{
}
BasePtr clone() const
{
return BasePtr(new Derived(m_value));
}

T m_value;
};

//拷贝使用
BasePtr clone() const
{
if (m_ptr)
{
return m_ptr->clone();
}
return nullptr;
}

BasePtr m_ptr; //具体数据
std::type_index m_tpIndex; //数据类型
};

function_traits

可以获得普通函数、函数指针、std::function、函数对象和成员函数的函数类型、返回类型、参数个数和参数的具体类型。
例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int func(int a, string b);

//获取函数类型
function_traits<decltype(func)>::function_type; //int __cdecl(int, string)

//获取函数返回值
function_traits<decltype(func)>::return_type; //int

//获取函数的参数个数
function_traits<decltype(func)>::arity; //2

//获取函数第一个入参类型
function_traits<decltype(func)>::arg_type<0>; //int

//获取函数第二个入参类型
function_traits<decltype(func)>::arg_type<1>; //string

通过function_traits可以很方便地获取所有函数语义类型丰富的信息,对于实际开发很有用。

实现 function_traits的关键技术

实现function_traits关键是要通过模板特化和可变参数模板来获取函数类型和返回类型。
先定义一个基本的function_traits的模板类

1
2
template<typename T>
struct function traits

再通过特化,将返回类型和可变参数模板作为模板参数,就可以获取函数类型、函数返回值和参数的个数了。基本的特化版本如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
template<typename Ret, typename... Args>
struct function_traits<Ret(Args...)>
{
public:
enum { arity = sizeof...(Args) };
typedef Ret function_type(Args...);
typedef Ret return_type;
using stl_function_type = std::function<function_type>;
typedef Ret(*pointer)(Args...);

template<size_t I>
struct args {
using type = typename std::tuple_element<I, std::tuple<Args...>>::type;
};
};

variant的实现

variant类似于union,它能代表定义的多种类型,允许将不同类型的值赋给它。它的具体类型是在初始化赋值时确定。boost中的variant的基本用法:

1
2
3
4
typedef variant<int,char, double> vt;
vt v = 1;
v = '2';
v = 12.32;

用variant一个好处是可以擦除类型,不同类型的值都统一成一个variant,虽然这个variant只能存放已定义的类型,但这在很多时候已经够用了。 取值的时候,通过get(v)来获取真实值。然而,当T类型与v的类型不匹配时,会抛出一个bad_cast的异常来。boost的variant抛出的异常往往没有更多的信息,不知道到底是哪个类型转换失败,导致发生异常调试时很不方便。因此,就考虑用c++11去实现一个vairiant, 这个variant可以很容易知道取值时,是什么类型转换失败了。

打造variant需要解决的问题

第一,要在内部定义一个char缓冲区。缓冲区用来存放variant的值,这个值是variant定义的多种类型中的某种类型的值,因此,这个缓冲区要足够大,能够存放类型最大(sizeof(Type))的值才可以,这个缓冲区的大小还必须在编译期计算出来。因此需要首先要解决的是variant值存放的缓冲区定义的问题。同时注意内存对齐,使用std::aligned_storage作为variant值存放的缓冲区。

第二,要解决赋值的问题。将值赋给vairiant时,需要将该值的类型ID记录下来,以便在后面根据类型取值。将值保存到内部缓冲区时,还需要用palcement new在缓冲区创建对象。另外,还要解决一个问题,就是赋值时需要检查variant中已定义的类型中是否含有该类型,如果没有则编译不通过,以保证赋值是合法的。

variant的赋值函数要做两件事:第一是从原来的variant中取出缓冲区中的对象;第二是通过缓冲区中取出的对象构造出当前variant中的对象。赋值函数的左值和右值的实现如下:

1
2
3
4
5
6
Variant(Variant<Types...>&& old) : m_typeIndex(old.m_typeIndex) {
Helper_t::move(old.m_typeIndex, &old.m_data, &m_data);
}
Variant(const Variant<Types...>& old) : m_typeIndex(old.m_typeIndex) {
Helper_t::copy(old.m_typeIndex, &old.m_data, &m_data);
}

第三,解决取值的问题,通过类型取值时,要判断类型是否匹配,如果不匹配,将详情打印出来,方便调试。

打造variant的关键技术:

找出最大的typesize。第一个问题中需要解决的问题是如何找出多种类型中,size最大的那个类型的size。看看如何从多种类型中找出最大类型的size。

1
2
3
4
5
6
7
8
template<typename T, typename... Args>
struct MaxType : std::integral_constant<int,
(sizeof(T)>MaxType<Args...>::value ? sizeof(T) : MaxType<Args...>::value) >

{};

template<typename T>
struct MaxType<T> : std::integral_constant<int, sizeof(T) >{};

通过这个MaxType就可以在编译期获取类型中最大的maxsize了:MaxType<Types...>::value

这里通过继承和递归方式来展开参数包,在展开参数包的过程中将第一个参数的size和后面一个参数的size做比较,获取较大的那个size,直到比较完所有的参数,从而获得所有类型中最大的size,比较的过程和冒泡排序的过程类似。内存对齐的缓冲区aligned_storage需要两个模版参数,第一个是缓冲区大小,第二个是内存对齐的大小。 variant中的aligned_storage中的缓冲区大小就是最大类型的sice,我们已经找出,下一步是找出最大的内存对齐大小。我们可以在MaxType的基础上来获取MaxAligin。

1
2
3
4
5
6
7
8
template<typename... Args>
struct MaxAlign : std::integral_constant<int, IntegreMax<std::alignment_of<Args>::value...>::value>{};

enum {
data_size = MaxType<sizeof(Types)...>::value;
align_size = MaxAlign<Types...>::value;
};
using data_t = typename std::aligned_storage<data_size, align_size>::type;

类型检查和缓冲区中创建对象

第二个问题中需要解决两个问题,1.检查赋值的类型是否在已定义的类型中;2.在缓冲区中创建对象及析构;

1
2
3
4
5
6
7
8
9
template < typename T, typename... List >
struct Contains : std::true_type {};

template < typename T, typename Head, typename... Rest >
struct Contains<T, Head, Rest...>
: std::conditional< std::is_same<T, Head>::value, std::true_type, Contains<T, Rest...>>::type{};

template < typename T >
struct Contains<T> : std::false_type{};

通过bool值Contains::vaule就可以判断是否含有某种类型。

再看看如何在缓冲区中创建对象。

通过placement new在该缓冲区上创建对象,new(data) T(value);,其中data表示一个char缓冲区,T表示某种类型。在缓冲区上创建的对象还必须通过~T去析构,因此还需要一个析构vairiant的帮助类:

1
2
3
4
5
6
7
8
9
10
11
12
13
template<typename T, typename... Args>
struct VariantHelper<T, Args...> {
inline static void Destroy(type_index id, void * data) {
if (id == type_index(typeid(T)))
((T*) (data))->~T();
else
VariantHelper<Args...>::Destroy(id, data);
}
};

template<> struct VariantHelper<> {
inline static void Destroy(type_index id, void * data) { }
};

取值问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
template<typename T>
typename std::decay<T>::type& Get() {
using U = typename std::decay<T>::type;
if (!Is<U>())
{
cout << typeid(U).name() << " is not defined. " << "current type is " << m_typeIndex.name() << endl;
throw std::bad_cast();
}
return *(U*) (&m_data);
}

template<typename T>
int GetIndexOf() {
return Index<T, Types...>::value;
}

template<typename F>
void Visit(F&& f)
{
  using T = typename function_traits<F>::arg<0>::type;
  if (Is<T>())
    f(Get<T>());
}

template<typename F, typename... Rest>
void Visit(F&& f, Rest&&... rest)
{
  using T = typename function_traits<F>::arg<0>::type;
  if (Is<T>())
    Visit(std::forward<F>(f));
  else
    Visit(std::forward<Rest>(rest)...);
}

测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void TestVariant()
{
typedef Variant<int, char, double> cv;
int x = 10;

cv v =x;
v = 1;
v = 1.123;
v = "";//compile error
v.Get<int>(); //1
v.Get<double>(); //1.23
v.Get<short>(); //exception: short is not defined. current type is int
v.Is<int>();//true
}

ScopeGuard

ScopeGuard的作用是确保资源面对异常时总能被成功释放,就算没有正常返回。惯用法让我们在构造函数里获取资源,当因为异常或者正常作用域结束,那么在析构函数里释放资源。总是能释放资源。如果没有异常抛出则正常结束,只是有异常发生或者没有正常退出时释放资源。

通过局部变量析构函数来管理资源,根据是否正常退出来确定是否需要清理资源。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
template <typename F>
class ScopeGuard
{
public:
explicit ScopeGuard( F && f) : m_func(std::move(f)), m_dismiss(false){}
explicit ScopeGuard(const F& f) : m_func(f), m_dismiss(false){}

~ScopeGuard() {
if (!m_dismiss && m_func != nullptr)
m_func();
}

ScopeGuard(ScopeGuard && rhs) : m_func(std::move(rhs.m_func)), m_dismiss(rhs.m_dismiss) {rhs.Dismiss();}

void Dismiss() {
m_dismiss = true;
}

private:
F m_func;
bool m_dismiss;

ScopeGuard();
ScopeGuard(const ScopeGuard&);
ScopeGuard& operator=(const ScopeGuard&);
};

template <typename F>
ScopeGuard<typename std::decay<F>::type> MakeGuard(F && f)
{
  return ScopeGuard<typename std::decay<F>::type>(std::forward<F>(f));
}

tuple_helper

std::tuple作为一个泛化的std::pair,它的一个独特特性是能容纳任意个数任意类型的元素。

tuple还需要一些常用操作,比如打印、遍历、根据元素值获取索引位置、反转和应用于函数。

  • 打印:由于tuple中的元素是可变参数模板,外面并不知道内部到底是什么数据,有时调试需要知道其具体值,希望能打印出tuple中所有的元素值。
  • 根据元素值获取索引位置: tuple接口中有根据索引位置获取元素的接口,根据元素值来获取索引位置是相反的做法。
  • 获取索引:在运行期根据索引获取索引位置的元素。
  • 遍历:类似于std::for_each算法,可以将函数对象应用于tuple的每个元素。
  • 反转:将tuple中的元素逆序。
  • 应用于函数:将tuple中的元素进行一定的转换,使之成为函数的入参。

打印tuple

tuple不同于数组和集合,不能通过for循环的方式枚举并打印元素值,需要借助可变参数模板的展开方式来打印出元素值。但是 tuple又不同于可变参数模板不能直接通过展开参数包的方式来展开,因为tuple中的元素需要用std::get<T>(tuple)来获取,展开tuple需要带索引参数。有两种方法可以展开并打印tuple,第一种方法是通过模板类的特化和递归调用结合来展开 tuple;另一种方法是通过一个索引序列来展开tuple。

(1)通过模板特化和递归来展开并打印tuple
因为tuple内部的元素个数和类型是不固定的,如果要打印tuple中的元素,需要在展开tuple时一一打印,展开并打印tuple的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
template<class Tuple, std::size_t N>
struct TuplerPrinter {
static void print (const Tuple& t) {
TuplerPrinter<Tuple, N - 1 >::print(t);
std::cout << ", " << std::get<N - 1>(t);
}
};

template<class Tuple>
struct TuplerPrinter<Tuple, 1> {
static void print(const Tuple& t) {
std::cout << std::get(0)<t>;
}
};

template<class... Args>
void PrintTuple(const std::tuple<Args...>& t) {
std::cout << "(";
TuplePrinter<decltype(t), sizeof...(Args)>::print(t);
std::cout << ")" << std::endl;
}

模板类TuplePrinter带有一个模板参数std::size_t N,这个N是用来控制递归调用的,每调用一次,这个N就减1,直到减为1为止。 PrintTuple是一个帮助函数,目的是为了更方便地调用TuplePrinter,因为Tupleprinter需要两个参数,一个是tuple,另一个是tuple的size。tuple的size是可以通过sizeof来获取的,在帮助函数中获取tuple的size并调用TuplePrinter,就可以减少外面调用的入参。测试代码如下:

1
2
3
4
void TestPrint() {
std::tuple<int, short, double, char> tp = std: make tuple(1, 2, 3, 'a');
PrintTuple(tp);
}

输出:(1, 2, 3, 'a')

调用过程如下:

1
2
3
4
Tupleprinter<std::tuplecint, short, double, char>, 4>:: print(tp);
TuplePrinter<std::tuple<int, short, double, char>, 3>:: print(tp);
TuplePrintersstd::tuple<int, short, double, char>, 2>:: print(tp);
TuplePrintersstd::tuple<int, short, double, char>, 1>:: print(tp);

当递归终止时,打印第一个元素的值:

1
std::cout << std::get<0>(t);

接着返回上一层递归打印第二个元素:

1
2
3
std::cout << std::get<1>(t);
std::cout << std::get<2>(t);
std::cout << std::get<3>(t);

(2)根据索引序列展开并打印tuple
将tuple变为一个可变参数模板需要一个可变索引序列:

1
2
template<int...>
struct IndexTuple{};

再通过std::get<IndexTuple>(tuple)...来获取参数序列,从而将tuple转换为可变参数模板Args...
先创建一个索引序列,通过这个索引序列来取tuple中对应位置的元素:

1
2
3
4
5
6
7
8
9
10
template<int...>
struct IndexTuple{};

template<int N, int... Indexes>
struct MakeIndexes : MakeIndexes<N-1, N-1, Indexes...>{};

template<int... indexes>
struct MakeIndexes<0, indexes...> {
typedef IndexTuple<indexes...> type;
};

在生成一个元素对应的索引位置序列之后,就可以通过std::get来获取tuple中的所有元素并将其变为可变参数模板。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
template <typename T>
void Print(T t) {
cout << t << endl;
}
template <typename T, typename... Args>
void Print(T t, Args... args) {
cout << t << endl;
Print(args...);
}
template <typename Tuple, int... Indexes>
void Transform(IndexTuple< Indexes... >& in, Tuple& tp) {
Print(get<indexes>(tp)...);
}

int main(){
using Tuple = std::tuple<int, double>;
Tuple tp = std::make_tuple<1, 2>;
Transform(MakeIndexes<std::tuple_size<Tuple>::value>::type(), tp);
}

反转Tuple

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
template<int I, int... Indexes, typename T, typename... Types>
struct make_indexes_reverse_impl<I, IndexTuple<Indexes...>, T, Types...>
{
using type = typename make_indexes_reverse_impl<I-1, IndexTuple<Indexes..., I-1>, Types...>::type;
};

//递归终止
template<int I, int... Indexes>
struct make_indexes_reverse_impl<I, IndexTuple<Indexes...>>
{
using type = IndexTuple<Indexes...>;
};

//类型萃取
//调用方法如:make_indexes<double, char, int>
template<typename... Types>
struct make_reverse_indexes : make_indexes_reverse_impl<sizeof...(Types), IndexTuple<>, Types...>
{};

template<class... Args, int... Indexes>
auto reverse_impl(std::tuple<Args...>&& tup, IndexTuple<Indexes...>&&) ->
decltype(std::make_tuple(std::get<Indexes>(std::forward<std::tuple<Args...>>(tup))...))
{
return std::make_tuple(std::get<Indexes>forward<tuple<Args...>>(tup))...);
}

template<class... Args>
auto tuple_reverse(std::tuple<Args...>&& tup)->
decltype(reverse_impl(std::forward<std::tuple<Args...>>(tup),typename make_reverse_indexes<Args...>::type()))
{
return reverse_impl(std::forward<std::tuple<Args...>>(tup), typename make_reverse_indexes<Args...>::type());
}

应用于函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
template<int...>
struct IndexTuple{};

template<int N, int... Indexes>
struct MakeIndexes : MakeIndexes<N-1, N-1, Indexes...>{};

template<int... indexes>
struct MakeIndexes<0, indexes...> {
typedef IndexTuple<indexes...> type;
};

template<typename F, typename Tuple, int... Indexes>
auto apply_helper(F&& f, IndexTuple<Indexes...>&& in, Tuple&& tup)->
decltype(std::forward<F>(f)(std::get<Indexes>(tup)...))
{
return std::forward<F>(f)(std::get<Indexes>(tup)...);
}

void TestF(int a, double b) {
cout << a + b << endl;
}

void Test() {
apply_helper(TestF, MakeIndexes<2>::type(), std::make_tuple(1, 2));
}

输出:3

使用C++11解决内存泄漏的问题

智能指针可以自动删除分配的内存,是存储指向动态分配(堆)对象指针的累,用于生存期控制,能够确保在离开指针所在作用域时能够自动正确地销毁动态分配的对象,防止内存泄漏。它的一种通用实现技术是引用计数,每使用它一次内部的引用计数加一,每析构一次内部的引用计数减一,减为0时,删除所指向的堆内存。

shared_ptr共享的智能指针

shared_ptr使用引用计数,每一个shared_ptr的拷贝都指向同一个内存,在最后一个shared_ptr析构时,内存才被释放。

基本用法

通过构造函数、std::make_shared<T>辅助函数和reset方法来初始化。

1
2
3
4
std::shared_ptr<int> p(new int(1));
std::shared_ptr<int> p2 = p;
std::shared_ptr<int> ptr;
ptr.reset(new int(1));

优先使用make_shared来构造智能指针。
不能将一个原始指针直接赋值给一个智能指针:

1
std::shared_ptr p = new int(1) ;   // 编译报错,不允许直接赋值

通过get方法来返回原始指针

1
2
std::shared_ptr<int> ptr( new int(1) ) ;
int* p = ptr.get() ;

智能指针初始化可以指定删除器

1
2
3
4
void DeleteIntPtr ( int * p ) {
delete p ;
}
std::shared_ptr<int> p( new int , DeleteIntPtr ) ;

当p的引用技术为0时,自动调用删除器来释放对象的内存。删除器也可以是一个lambda表达式,例如:

1
std::shared_ptr<int> p( new int , [](int * p){delete p} ) ;

当我们使用shared_ptr管理动态数组时,需要指定删除器,因为std::shared_ptr默认的删除器不能处理数组对象:

1
std::shared_ptr<int> p(new int[10], [](int* p){delete[] p;});

或者通过封装一个make_shared_array方法来让shared_ptr支持数组:

1
2
3
4
template<typename T>
shared_ptr<T> make_shared_array(size_t size) {
return shared_ptr<T>(new T[size], default_delete<T[]>());
}

不要用一个原始指针初始化多个shared_ptr,以下是错误的。

1
2
3
int* ptr = new int;
shared_ptr<int> p1(ptr);
shared_ptr<int> p2(ptr);

不要在函数实参中创建shared_ptr,在调用函数之前先定义以及初始化它。

不要将this指针作为shared_ptr返回出来,因为this指针是一个裸指针,这样做可能会重复析构。正确返回this的shared_ptr的做法是:让目标类通过派生std::enable_shared_from_this<A>类,然后使用基类的成员函数shared_from_this来返回this的shared_ptr:

1
2
3
4
5
class A : public std::enable_shared_from_this<A> {
std::shared_ptr<A> GetSelf() {
return shared_from_this();
}
}

要避免循环引用,循环引用会导致内存泄漏。

unique_ptr独占的智能指针

unique_ptr是一个独占的智能指针,他不允许其他的智能指针共享其内部的指针,不允许通过赋值将一个unique_ptr赋值给另外一个unique_ptr,虽然不允许复制,但可以通过函数返回给其他的unique_ptr,还可以通过std::move来转移到其他的unique_ptr,这样它本身就不再拥有原来指针的所有权了。

1
2
3
unique_ptr<T> my_ptr(new T);
unique_ptr<T> my_other_ptr = std::move(my_ptr);
unique_ptr<T> ptr = my_ptr; // ERROR

可以自己实现一个make_unique,C++尚未提供这个函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
template<class T, class... Args> inline
typename enable_if<!is_array<T>::value, unique_ptr<T>>::type
make_unique(Args&& ... args) {
return unique_ptr<T>(new T(std::forward<Args>(args)...));
}

template<class T> inline
typename enable_if<is_array<T>::value && extent<T>::value==0, unique_ptr<T>>::type
make_unique(size_t size) {
typedef typename remove_extent<T>::type U;
return unique_ptr<T>(new U[size]());
}
template<class T, class... Args>
typename enable_if<extent<T>::value!=0, void>::type
make_unique(Args&&& ...) = delete;

如果不是数组,则直接创建unique_ptr,如果是数组,则先判断是否为定长数组,如果是定长数组则编译不通过,若为非定常数组,则获取数组中的元素类型,再根据入参size创建动态数组的unique_ptr。

unique_ptr还可指向一个数组:

1
2
std::unique_ptr<int []> ptr(new int[10]);
ptr[9] = 9;

unique_ptr指定删除器需要确定删除器的类型:

1
std::unique_ptr<int, void(*)(int*)> ptr(new int(1), [](int* p){ delete p; });

如果lambda表达式没有捕获变量,这样写是对的,因为可以直接转换成函数指针。捕获了变量后:

1
std::unique_ptr<int, std::function<void(int*)>> ptr(new int(1), [&](int* p){ delete p; });

如果希望只有一个智能指针管理资源或管理数组就用unique_ptr,如果希望多个智能指针管理同一个资源就用shared_ptr。

weak_ptr弱引用的智能指针

弱引用的智能指针weak_ptr是用来监视shared_ptr的,不会使引用计数加一,它不管理shared_ptr内部的指针,主要是为了监视shared_ptr的生命周期,更像是shared_ptr的一个助手。

weak_ptr没有重载运算符*->,因为它不共享指针,不能操作资源,主要是为了通过shared_ptr获得资源的监测权,它的构造不会增加引用计数,它的析构不会减少引用计数,纯粹只是作为一个旁观者来监视shared_ptr中管理的资源是否存在。weak_ptr还可以用来返回this指针和解决循环引用的问题。

基本用法

通过use_count()获得当前观测资源的引用计数:

1
2
3
4
shared_ptr<int> sp(new int(10));
weak_ptr<int> wp(sp);

cout << wp.use_count() << endl;

通过expired()方法判断观测的资源是否已经释放:

1
2
3
4
shared_ptr<int> sp(new int(10));
weak_ptr<int> wp(sp);
if (wp.expired())
cout << "weak_ptr useless" << endl;

通过lock方法来获取所监视的shared_ptr

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
std::weak_ptr<int> gw;
void f() {
if (gw.expired()) {
cout << "already expired" << endl;
}
else {
auto spt = gw.lock();
cout << *spt << endl;
}
}

int main() {
{
auto sp = std::make_shared<int>(43);
gw = sp;
f(); // 43
}
f(); // already expired
}

之前提到不能直接将this指针返回为shared_ptr,需要通过派生std::enable_shared_from_this类,并通过其方法shared_from_this来返回智能指针,原因是std::enable_shared_from_this类中有一个weak_ptr,这个weak_ptr用来观测this智能指针,调用shared_from_this()方法时,会调用内部这个weak_ptrlock()方法,将所观测的shared_ptr返回。

1
2
3
4
5
6
7
8
9
10
struct A : public std::enable_shared_from_this<A> {
std::shared_ptr<A> Getself() {
return shared_from_this();
}
~A() {
cout << "A is delete" << endl;
}
};
std::shared_ptr<A> spy(newA);
std::shared_ptr<A> p = spy->Getself();

解决循环引用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct A;
struct B;
struct A {
std::shared_ptr<B> bptr;
~A() { cout << "A is deleted!" << endl; }
}
struct B {
std::shared_ptr<A> aptr;
~B() { cout << "B is deleted!" << endl; }
}
void TestPtr() {
{
std::shared_ptr<A> ap(new A);
std::shared_ptr<B> bp(new B);
ap->bptr = bp;
bp->aptr = ap;
} // Objects should be destroyed
}

在这个例子中,由于循环引用导致ap和bp的引用计数都是2,离开作用域后减为1,不会去删除指针,导致内存泄漏,通过weak_ptr解决这个问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct A;
struct B;
struct A {
std::shared_ptr<B> bptr;
~A() { cout << "A is deleted!" << endl; }
}
struct B {
std::weak_ptr<A> aptr; // 改为weak_ptr
~B() { cout << "B is deleted!" << endl; }
}
void TestPtr() {
{
std::shared_ptr<A> ap(new A);
std::shared_ptr<B> bp(new B);
ap->bptr = bp;
bp->aptr = ap;
} // Objects should be destroyed
}

通过智能指针管理第三方库分配的内存

第三方库分配的内存一般需要通过第三方库提供的释放接口才能释放,由于第三方库返回的指针一般都是原始指针,用完之后如果没有调用第三方库的释放接口,就很容易造成内存泄露。

1
2
3
void *p = GetHandle()->Create();
//do something
GetHandle()->Realease(p);

用智能指针来管理第三方库的内存就比较方便,不用担心中途返回或者发生异常导致无法调用释放接口的问题。

1
2
3
void *p = GetHandle()->Create();
//do something
std::shared_ptr<void> sp(p, [this](void* p) {GetHandle()->Realease(p); });

将其提炼成函数

1
2
3
4
5
6
7
8
9
10
std::shared_ptr<void> Guard(void*p)
{
std::shared_ptr<void> sp(p, [this](void* p) {GetHandle()->Realease(p); });
return sp;
}

//在使用时
void* p = GetHandle()->Create();
Guard(p); //危险,这句结束后p就被释放了
//do something

执行Guard();这句后,函数返回的是一个右值,没有被存储,用完就把p释放了。

可以用宏的方式来解决这个问题:

1
2
3
4
5
#define GUARD(p) std::shared_ptr<void> p##p(p, [](void *p){release(p);})

//使用时
void* p = GetHandle()->Create();
GUARD(p); //安全

也可以用unique_ptr来管理第三方的内存:

1
#define GUARD(p) std::unique_ptr<void> p##p(p, [](void *p){release(p);})

对于宏中的##,其实也很好理解,就是将##前后的字符串连接起来

1
2
3
4
5
6
7
#define GUARD(p) std::shared_ptr<void> p##p(p, [](void *p){release(p);})

//使用时
void* p = GetHandle()->Create();
GUARD(p); //安全
//会有一个std::shared_ptr<void> pp的智能指针,不信就进行测试。原因去找刚才的#define中有p##p
std::cout << pp.use_count() << std::endl;

为了验证原作者的这些,写一些demo来帮助理解,也有利于更好掌握:
创建一个Base类:
Base.h文件中:

1
2
3
4
5
6
7
8
9
#pragma once
class Base
{
public:
Base();
~Base();

void print();
};

Base.cpp文件中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include "Base.h"
#include <iostream>

Base::Base()
{
std::cout << "Base constructor" << std::endl;
}

Base::~Base()
{
std::cout << "Base desctructor" << std::endl;
}

void Base::print()
{
std::cout << "print something" << std::endl;
}

在main.cpp中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#include "Base.h"
#include <memory>
#include <iostream>

#define GUARD(p) std::shared_ptr<Base> p##p(p, [](Base*p){release(p);})

Base* create()
{
return new Base();
}

void release(Base* base)
{
delete base;
}

std::shared_ptr<Base> Guard(Base *p)
{
std::shared_ptr<Base> sp(p, [](Base*p) {release(p); });
return sp;
}

int main()
{
{
Base* p = create();

std::shared_ptr<Base> sp(p, [](Base*p) {release(p); });
//Guard(p);

//GUARD(p);
//std::cout << "sp.use_count():" << sp.use_count() << std::endl;;
p->print();
}

getchar();
return 0;
}

此时的输出为:

1
2
3
Base constructor
print something
Base desctructor

【修改一】 当我们对main()中修改为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int main()
{
{
Base* p = create();

//std::shared_ptr<Base> sp(p, [](Base*p) {release(p); });
Guard(p);

//GUARD(p);
//std::cout << "sp.use_count():" << sp.use_count() << std::endl;;
p->print();
}

getchar();
return 0;
}

运行结果:

1
2
3
Base constructor
Base desctructor
print something

发现这时候的p被提前释放了,print something已经是在Base类析构之后做的,此时已经出问题了。

【修改二】将main函数进行修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int main()
{
{
Base* p = create();

//std::shared_ptr<Base> sp(p, [](Base*p) {release(p); });
//Guard(p);

GUARD(p);
std::cout << "pp.use_count(): " << pp.use_count() << std::endl;;
p->print();
}

getchar();
return 0;
}

运行结果:

1
2
3
4
Base constructor
pp.use_count(): 1
print something
Base desctructor

果然如我们所想,一切正常。

使用C++11让多线程开发变得简单

线程

std::thread创建线程非常简单,只需要提供线程函数或者函数对象即可。

1
2
3
4
5
6
7
8
9
10
11
#include <thread>
#include <iostream>
using namespace std;
void func() {
cout << "thread test" << endl;
}
int main() {
thread t(func);
t.join();
return 0;
}

函数func会运行于线程对象t中,join函数会阻塞线程,直到线程函数执行结束,如果线程函数有返回值,返回值被忽略。如果不希望线程被阻塞执行,调用detach将线程和线程对象分离,让线程作为后台线程去执行,当前线程也不会阻塞了。需要注意的是detach()之后就无法再和线程发生联系了,比如detach之后就不能通过join来等待线程执行完,线程何时执行完我们也无法控制了。

线程可以接受任意个数的参数。

1
2
3
4
5
6
7
8
9
void func(int i, double d, const std::string& s) {
std::cout << i << d << s << endl;
}

int main() {
std::thread t(func, 1, 2.0, "heoo");
t.join();
return 0;
}

std::thread出了作用域后会析构,保证线程函数的生命周期在线程变量的生命周期之内

线程不能复制,但是可以移动:

1
2
3
4
5
6
int main() {
std::thread t(func);
std::thread t1(std::move(t));
t.join(); // error
t1.join();
}

线程被移动之后,线程对象t就不再代表任何线程。另外可以通过std::bind和lambda表达式来创建线程:

1
2
3
4
int main() {
std::thread t(std::bind(func));
std::thread t1([](int a, double b){}, 1, 2);
}

可以将线程存放到容器中,保证线程对象的生命周期:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <thread>
using namespace std;

vector<thread> g_list;
vector<shared_ptr<thread>> g_list2;

void CreateThread() {
thread t(func);
g_list.push_back(move(t));
g_list2.push_back(make_shared<thread>(func));
}
int main() {
CreateThread();
for (auto& thread : g_list) {
thread.join();
}
for (auto& thread : g_list2) {
thread->join();
}
return 0;
}

线程可以获取当前线程的ID,还可以获取CPU核心数量:

1
2
3
4
5
6
7
8
9
10
void func() {}
int main() {
thread t1(func);
cout << t1.get_id() << endl;

t1.join();
cout << t1.get_id() << endl;//获取当前线程id,0,表示已经执行结束了.
cout << std::thread::hardware_concurrency() << endl;//8核
return 0;
}

互斥量

互斥量是一种同步原语,是一种线程同步的手段,用来保护多线程同时访问的共享数据。

  • std::mutex: 独占的互斥量,不能递归使用.
  • std::timed_mutex: 带超时的独占互斥量,不能递归使用.
  • std::recursive_mutex: 递归互斥量,不带超时功能.
  • std::recursive_timed_mutex: 带超时的递归互斥量.

这些互斥量的基本接口十分相近,都是通过lock()来阻塞线程,直到获得互斥量的所有权为止。在线程获得互斥量并完成任务后,就必须使用unlock()来解除对互斥量的占用,lock和unlock必须成对出现。try_lock()尝试锁定互斥量,成功返回true,失败返回false,他是非阻塞的。

1
2
3
4
5
6
7
8
9
10
11
12
std::mutex g_lock;

void lock_unlock()
{
//上锁
g_lock.lock();
cout << "in id: " << this_thread::get_id() << endl;
this_thread::sleep_for(chrono::seconds(1));
cout << "out id: " << this_thread::get_id() << endl;
//解锁
g_lock.unlock();
}

使用lock_guard可以简化lock/unlock的写法,因为lock_guard在构造时可以自动锁定互斥量,在退出作用域后进行析构时会自动解锁,从而保证了互斥量的正确操作。

1
2
3
4
5
6
7
8
void f_lock_guard()
{
//lock_guard在构造时会自动锁定互斥量,而在退出作用域后进行析构时就会自动解锁.
lock_guard<std::mutex> lock(g_lock);
cout << "in id: " << this_thread::get_id() << endl;
this_thread::sleep_for(chrono::seconds(1));
cout << "out id: " << this_thread::get_id() << endl;
}

递归的独占互斥量std::recursive_mutex允许同一线程多次获得该互斥锁,可以用来解决同一线程需要多次获取互斥量时死锁的问题,来获得对互斥量对象的多层所有权,std::recursive_mutex 释放互斥量时需要调用与该锁层次深度相同次数的 unlock(),可理解为 lock() 次数和 unlock() 次数相同,除此之外,std::recursive_mutex 的特性和 std::mutex 大致相同。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
struct Complex {
std::recursive_mutex mutex;
int i;
Complex() : i(0) {}
void mul(int x) {
std::lock_guard<std::recursive_mutex> lock(mutex);
i *= x;
}
void div(int x) {
std::lock_guard<std::recursive_mutex> lock(mutex);
i /= x;
}
void both(int x, int y) {
std::lock_guard<std::recursive_mutex> lock(mutex);
// 因为同一线程可以多次获取同一互斥量,不会发生死锁。
mul(x);
div(y);
}
}

尽量不要使用递归锁,因为:

  • 需要用到递归锁定的多线程互斥处理往往本身就是可以简化的,允许递归互斥很容易放纵复杂逻辑的产生,从而导致一些多线程同步引起的晦涩问题
  • 递归锁比起非递归锁,效率会低一些。
  • 递归锁虽然允许同一个线程多次获得同一个互斥量,可重复获得的最大次数并未具体说明,一旦超过一定次数,再对lock进行调用就会抛出std::system错误。

带超时的互斥量std::timed_mutexstd::recursive_timed_mutexstd::timed_mutex是超时的独占锁,std::recursive_timed_mutex是超时的递归锁,主要用在获取锁时增加超时等待功能,因为有时不知道获取锁需要多久,为了不至于一直在等待获取互斥量,就设置一个等待超时时间,在超时后还可以做其他的事情。

std::timed_mutexstd::mutex多了两个超时获取锁的接口:try_lock_fortry_lock_until,这两个接口是用来设置获取互斥量的超时时间,使用时可以用一个while循环去不断地获取互斥量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
std::timed_mutex mutex;
void work() {
std::chrono::milliseconds timeout(100);
while (true) {
if (mutex.try_lock_for(timeout)) {
std::cout << std::this_thread::get_id() << ": do work with the mutex" << endl;
std::chrono::milliseconds sleepDuration(250);
std::this_thread::sleep_for(sleepDuration);
mutex.unlock();
std::this_thread_sleep_for(sleepDuration);
}
else {
std::cout << std::this_thread::get_id() << ": do work without the mutex" << endl;
std::chrono::milliseconds sleepDuration(100);
std::this_thread::sleep_for(sleepDuration);
}

条件变量

<condition_variable>头文件主要包含了与条件变量相关的类和函数。相关的类包括std::condition_variablestd::condition_variable_any,还有枚举类型std::cv_status。另外还包括函数std::notify_all_at_thread_exit()

condition_variable配合std::unique_lock<std::mutex>进行wait操作。condition_variable_any,和任意带有lock、unlock语义的mutex搭配使用,比较灵活,但效率比condition_variable差一些。条件变量的使用过程如下:

  • 拥有条件变量的线程获取互斥量。
  • 循环检查某个条件,如果条件不满足,则阻塞直到条件满足;如果条件满足,则向下执行。
  • 某个线程满足条件执行完之后调用notify_onenotify_all唤醒一个或者所有的等待线程。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
template<typename T> 
class SyncQueue {
bool isFull() const {
return m_queue.size() == m_maxSize;
}
bool isEmpty() const {
return m_queue.empty();
}
public:
SyncQueue(int maxSize) : m_maxSize(maxSize) {}
void Put(const T& x) {
std::lock_guard<std::mutex> locker(m_mutex);
while(isFull()){
m_notFull.wait(m_mutex);
}
m_queue.push_back(x);
m_notEmpty.notify_one();
}

void Take(T& x) {
std::lock_guard<std::mutex> locker(m_mutex);
while(isEmpty()){
m_notEmpty.wait(m_mutex);
}
x = m_queue.front();
m_queue.pop_front();
m_notFull.notify_one();
}

bool Empty() {
std::lock_guard<std::mutex> locker(m_mutex);
return m_queue.empty();
}

bool Full() {
std::lock_guard<std::mutex> locker(m_mutex);
return m_queue.size() == m_maxSize;
}

size_t Size() {
std::lock_guard<std::mutex> locker(m_mutex);
return m_queue.size();
}

int Count() {
return m_queue.size();
}

private:
std::list<T> m_queue;
std::mutex m_mutex;
std::condition_variable_any m_notEmpty;
std::condition_variable_any m_notFull;
int m_maxSize;
}

这个同步队列在没有满的情况下可以插入数据,如果满了,则会调用m_notFull阻塞等待,待消费线程取出数据之后发一个未满的通知,然后前面阻塞的线程就会被唤醒继续往下执行;如果队列为空,就不能取数据,会调用m_notEmpty条件变量阻塞,等待插入数据的线程发出不为空的通知时,才能继续往下执行。以上过程是同步队列的工作过程。

std::condition_variable对象的某个 wait 函数被调用的时候,它使用std::unique_lock(通过std::mutex) 来锁住当前线程。当前线程会一直被阻塞,直到另外一个线程在相同的std::condition_variable对象上调用了 notification 函数来唤醒当前线程。

std::condition_variable对象通常使用std::unique_lock<std::mutex>来等待,如果需要使用另外的lockable类型,可以使用std::condition_variable_any类,本文后面会讲到std::condition_variable_any的用法。

首先我们来看一个简单的例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
#include <iostream>                // std::cout
#include <thread> // std::thread
#include <mutex> // std::mutex, std::unique_lock
#include <condition_variable> // std::condition_variable

std::mutex mtx; // 全局互斥锁.
std::condition_variable cv; // 全局条件变量.
bool ready = false; // 全局标志位.

void do_print_id(int id)
{
std::unique_lock <std::mutex> lck(mtx);
while (!ready) // 如果标志位不为 true, 则等待...
cv.wait(lck); // 当前线程被阻塞, 当全局标志位变为 true 之后,
// 线程被唤醒, 继续往下执行打印线程编号id.
std::cout << "thread " << id << '\n';
}

void go()
{
std::unique_lock <std::mutex> lck(mtx);
ready = true; // 设置全局标志位为 true.
cv.notify_all(); // 唤醒所有线程.
}

int main()
{
std::thread threads[10];
// spawn 10 threads:
for (int i = 0; i < 10; ++i)
threads[i] = std::thread(do_print_id, i);

std::cout << "10 threads ready to race...\n";
go(); // go!

for (auto & th:threads)
th.join();

return 0;
}

执行结果如下:

1
2
3
4
5
6
7
8
9
10
11
10 threads ready to race...
thread 1
thread 0
thread 2
thread 3
thread 4
thread 5
thread 6
thread 7
thread 8
thread 9

好了,对条件变量有了一个基本的了解之后,我们来看看std::condition_variable的各个成员函数。

std::condition_variable提供了两种 wait() 函数。当前线程调用 wait() 后将被阻塞(此时当前线程应该获得了锁(mutex),不妨设获得锁 lck),直到另外某个线程调用notify_*唤醒了当前线程。

在线程被阻塞时,该函数会自动调用lck.unlock()释放锁,使得其他被阻塞在锁竞争上的线程得以继续执行。另外,一旦当前线程获得通知(notified,通常是另外某个线程调用notify_*唤醒了当前线程),wait() 函数也是自动调用lck.lock(),使得 lck 的状态和 wait 函数被调用时相同。

在第二种情况下(即设置了 Predicate),只有当 pred 条件为 false 时调用 wait() 才会阻塞当前线程,并且在收到其他线程的通知后只有当 pred 为 true 时才会被解除阻塞。因此第二种情况类似以下代码:

1
while (!pred()) wait(lck);

请看下面例子(参考):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
#include <iostream>                // std::cout
#include <thread> // std::thread, std::this_thread::yield
#include <mutex> // std::mutex, std::unique_lock
#include <condition_variable> // std::condition_variable

std::mutex mtx;
std::condition_variable cv;

int cargo = 0;
bool shipment_available()
{
return cargo != 0;
}

// 消费者线程.
void consume(int n)
{
for (int i = 0; i < n; ++i) {
std::unique_lock <std::mutex> lck(mtx);
cv.wait(lck, shipment_available);
std::cout << cargo << '\n';
cargo = 0;
}
}

int main()
{
std::thread consumer_thread(consume, 10); // 消费者线程.

// 主线程为生产者线程, 生产 10 个物品.
for (int i = 0; i < 10; ++i) {
while (shipment_available())
std::this_thread::yield();
std::unique_lock <std::mutex> lck(mtx);
cargo = i + 1;
cv.notify_one();
}

consumer_thread.join();

return 0;
}

程序执行结果如下:

1
2
3
4
5
6
7
8
9
10
11
concurrency ) ./ConditionVariable-wait 
1
2
3
4
5
6
7
8
9
10

std::condition_variable::wait_for() 介绍

unconditional (1):

1
2
3
template <class Rep, class Period>
cv_status wait_for (unique_lock<mutex>& lck,
const chrono::duration<Rep,Period>& rel_time);

predicate (2)

1
2
3
template <class Rep, class Period, class Predicate>
bool wait_for (unique_lock<mutex>& lck,
const chrono::duration<Rep,Period>& rel_time, Predicate pred);

与 std::condition_variable::wait() 类似,不过 wait_for 可以指定一个时间段,在当前线程收到通知或者指定的时间 rel_time 超时之前,该线程都会处于阻塞状态。而一旦超时或者收到了其他线程的通知,wait_for 返回,剩下的处理步骤和 wait() 类似。

另外,wait_for 的重载版本(predicte(2))的最后一个参数 pred 表示 wait_for 的预测条件,只有当 pred 条件为 false 时调用 wait() 才会阻塞当前线程,并且在收到其他线程的通知后只有当 pred 为 true 时才会被解除阻塞,因此相当于如下代码:

1
return wait_until (lck, chrono::steady_clock::now() + rel_time, std::move(pred));

请看下面的例子(参考),下面的例子中,主线程等待 th 线程输入一个值,然后将 th 线程从终端接收的值打印出来,在 th 线程接受到值之前,主线程一直等待,每个一秒超时一次,并打印一个 “.”:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include <iostream>           // std::cout
#include <thread> // std::thread
#include <chrono> // std::chrono::seconds
#include <mutex> // std::mutex, std::unique_lock
#include <condition_variable> // std::condition_variable, std::cv_status

std::condition_variable cv;

int value;

void do_read_value()
{
std::cin >> value;
cv.notify_one();
}

int main ()
{
std::cout << "Please, enter an integer (I'll be printing dots): \n";
std::thread th(do_read_value);

std::mutex mtx;
std::unique_lock<std::mutex> lck(mtx);
while (cv.wait_for(lck,std::chrono::seconds(1)) == std::cv_status::timeout) {
std::cout << '.';
std::cout.flush();
}

std::cout << "You entered: " << value << '\n';

th.join();
return 0;
}

std::condition_variable::wait_until 介绍

unconditional (1)

1
2
3
template <class Clock, class Duration>
cv_status wait_until (unique_lock<mutex>& lck,
const chrono::time_point<Clock,Duration>& abs_time);

predicate (2)

1
2
3
4
template <class Clock, class Duration, class Predicate>
bool wait_until (unique_lock<mutex>& lck,
const chrono::time_point<Clock,Duration>& abs_time,
Predicate pred);

std::condition_variable::wait_for类似,但是 wait_until 可以指定一个时间点,在当前线程收到通知或者指定的时间点 abs_time 超时之前,该线程都会处于阻塞状态。而一旦超时或者收到了其他线程的通知,wait_until 返回,剩下的处理步骤和 wait_until() 类似。

另外,wait_until 的重载版本(predicte(2))的最后一个参数 pred 表示 wait_until 的预测条件,只有当 pred 条件为 false 时调用 wait() 才会阻塞当前线程,并且在收到其他线程的通知后只有当 pred 为 true 时才会被解除阻塞,因此相当于如下代码:

1
2
3
4
while (!pred())
if ( wait_until(lck,abs_time) == cv_status::timeout)
return pred();
return true;

std::condition_variable::notify_one() 介绍
唤醒某个等待(wait)线程。如果当前没有等待线程,则该函数什么也不做,如果同时存在多个等待线程,则唤醒某个线程是不确定的(unspecified)。

请看下例(参考):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#include <iostream>                // std::cout
#include <thread> // std::thread
#include <mutex> // std::mutex, std::unique_lock
#include <condition_variable> // std::condition_variable

std::mutex mtx;
std::condition_variable cv;

int cargo = 0; // shared value by producers and consumers

void consumer()
{
std::unique_lock < std::mutex > lck(mtx);
while (cargo == 0)
cv.wait(lck);
std::cout << cargo << '\n';
cargo = 0;
}

void producer(int id)
{
std::unique_lock < std::mutex > lck(mtx);
cargo = id;
cv.notify_one();
}

int main()
{
std::thread consumers[10], producers[10];

// spawn 10 consumers and 10 producers:
for (int i = 0; i < 10; ++i) {
consumers[i] = std::thread(consumer);
producers[i] = std::thread(producer, i + 1);
}

// join them back:
for (int i = 0; i < 10; ++i) {
producers[i].join();
consumers[i].join();
}

return 0;
}

std::condition_variable::notify_all() 介绍
唤醒所有的等待(wait)线程。如果当前没有等待线程,则该函数什么也不做。请看下面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
#include <iostream>                // std::cout
#include <thread> // std::thread
#include <mutex> // std::mutex, std::unique_lock
#include <condition_variable> // std::condition_variable

std::mutex mtx; // 全局互斥锁.
std::condition_variable cv; // 全局条件变量.
bool ready = false; // 全局标志位.

void do_print_id(int id)
{
std::unique_lock <std::mutex> lck(mtx);
while (!ready) // 如果标志位不为 true, 则等待...
cv.wait(lck); // 当前线程被阻塞, 当全局标志位变为 true 之后,
// 线程被唤醒, 继续往下执行打印线程编号id.
std::cout << "thread " << id << '\n';
}

void go()
{
std::unique_lock <std::mutex> lck(mtx);
ready = true; // 设置全局标志位为 true.
cv.notify_all(); // 唤醒所有线程.
}

int main()
{
std::thread threads[10];
// spawn 10 threads:
for (int i = 0; i < 10; ++i)
threads[i] = std::thread(do_print_id, i);

std::cout << "10 threads ready to race...\n";
go(); // go!

for (auto & th:threads)
th.join();

return 0;
}

std::condition_variable_any 介绍
std::condition_variable类似,只不过std::condition_variable_any的 wait 函数可以接受任何 lockable 参数,而std::condition_variable只能接受std::unique_lock<std::mutex>类型的参数,除此以外,和std::condition_variable几乎完全一样。

std::cv_status 枚举类型介绍

cv_status::no_timeout:wait_for 或者 wait_until 没有超时,即在规定的时间段内线程收到了通知。
cv_status::timeout:wait_for 或者 wait_until 超时。

std::notify_all_at_thread_exit
函数原型为:

1
void notify_all_at_thread_exit (condition_variable& cond, unique_lock<mutex> lck);

当调用该函数的线程退出时,所有在 cond 条件变量上等待的线程都会收到通知。请看下例(参考):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#include <iostream>           // std::cout
#include <thread> // std::thread
#include <mutex> // std::mutex, std::unique_lock
#include <condition_variable> // std::condition_variable

std::mutex mtx;
std::condition_variable cv;
bool ready = false;

void print_id (int id) {
std::unique_lock<std::mutex> lck(mtx);
while (!ready) cv.wait(lck);
// ...
std::cout << "thread " << id << '\n';
}

void go() {
std::unique_lock<std::mutex> lck(mtx);
std::notify_all_at_thread_exit(cv,std::move(lck));
ready = true;
}

int main ()
{
std::thread threads[10];
// spawn 10 threads:
for (int i=0; i<10; ++i)
threads[i] = std::thread(print_id,i);
std::cout << "10 threads ready to race...\n";

std::thread(go).detach(); // go!

for (auto& th : threads) th.join();

return 0;
}

原子变量

C++11提供了一个原子类型std::atomic,可以使用任意类型作为模板参数,C++11内置了整型的原子变量,可以更方便地使用原子变量,使用原子变量就不需要使用互斥量来保护该变量了,因为对该变量的操作保证其是原子的,是不可中断的。

1
2
3
4
5
6
7
8
9
10
11
12
13
int value;
std::mutex mutex;
void increment() {
std::lock_guard<std::mutex> lock(mutex);
++value;
}
void decrement() {
std::lock_guard<std::mutex> lock(mutex);
--value;
}
void get() {
return value;
}

可以改成:

1
2
3
4
5
6
7
8
9
10
std::atmoic<int> value;
void increment() {
++value;
}
void decrement() {
--value;
}
void get() {
return value.load();
}

call_once/once_flag的使用

为了保证在多线程环境中某个函数仅被调用一次,比如,需要初始化某个对象,而这个对象只能初始化一次时,就可以用std::call_once来保证函数在多线程环境中只被调用一次。使用std::call_once时,需要一个once_flag作为call_one的入参,它的用法比较简单。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include<iostream>
#include<thread>
#include<mutex>
using namespace std;

std::once_flag flag;

void do_once()
{
std::call_once(flag,[]{std::cout<<"Called once"<<endl;});
}


int main()
{
std::thread t1(do_once);
std::thread t2(do_once);
std::thread t3(do_once);

t1.join();
t2.join();
t3.join();
}

运行结果:

1
Called once

异步操作类

C++11 提供了异步操作相关的类:

  • std::future作为异步结果的传输通道,用于获取线程函数的的返回值;
  • std::promise用于包装一个值,将数据和future绑定起来,方便线程赋值;
  • std::package_task将函数和future绑定起来,以便异步调用。

std::future

thread库提供了future用来访问异步操作的结果,因为一个异步操作的结果不能马上获取,只能在未来某个时候从某个地方获取,这个异步操作的结果是一个未来的期待值,所以被称为future,future提供了获取异步操作结果的通道。可以以同步等待的方式获取结果,可以通过查询future的状态(future_status)来获取异步操作的结果。future_status有如下3种状态:

  • Deferred:异步操作还没开始
  • Ready:异步操作已经完成
  • Timeout:异步操作超时

我们可以查询future状态,通过它内部的状态可以知道异步任务的执行情况:

1
2
3
4
5
6
7
std::future_status status;
do{
status=future.wait_for(std::chrono::seconds(1));
if(status==std::future_status::deferred){}
else if(status==std::future_status::timeout){}
else if(status==std::future_status::ready){}
}while(status!=std::future_status::ready);

获取future结果有三种方式:

  • get: 等待异步操作结束并返回结果
  • wait:只是等待异步操作完成,没有返回值
  • wait_for:是超时等待返回结果

std::promise

std::promise将数据和future绑定起来,在线程函数中为外面传进来的promise赋值,在线程函数执行完之后就可以通过promise的future获取该值了。取值是间接地通过promise内部提供的future来获取的。

1
2
3
4
std::promise<int> pr;
std::thread t([](std::promise<int> &p){p.set_value_at_thread_exit(9);},std::ref(pr));
std::future<int> f=pr.get_future();
auto f=f.get();

std::packaged_task

std::packaged_task包装了一个可调用对象的包装类(如function、lambda expression、bind expression和another function object),将函数和future绑定起来,以便异步调用,它和std::promise在某种程度上有点像,promise保存了一个共享状态的值,而packaged_task保存的是一个函数。

1
2
3
4
std::packaged_task<int()> task([](){return 7;});
std::thread t1(std::ref(task));
std::future<int> f1=task.get_future();
auto r1=f1.get();

std::promisestd::packaged_taskstd::future三者之间的关系

std::future提供了一个访问异步操作结果的机制,它和线程是一个级别的,属于低层次的对象。std::promisestd::packaged_task,它们内部都有future以便访问异步操作结果,std::packaged_task包装的是一个异步操作,而std::promise包装的是一个值,都是为了方便异步操作的返回值。

std::promise:需要获取线程中的某个值
std::packaged_task:需要获取一个异步操作的返回值

future被promise和packaged_task用来作为异步操作或者异步结果的连接通道,用std::futurestd::shared_future来获取异步调用的结果。future是不可拷贝的,只能移动,shared_future是可以拷贝的,当需要将future放到容器中则需要用shared_future。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
#include <iostream>
#include <utility>
#include <future>
#include <thread>
#include <vector>
#include <algorithm>
#include <cassert>
#include <random>

namespace parallel
{
template <class InputIt, class T>
InputIt find(InputIt first, InputIt last, const T& value)
{
/*
* 计算合适的线程数
* std::thread::hardware_concurrency()用于返回当前系统支持的并发数
*/
auto count = std::distance(first, last);
auto avaThreadNums = std::thread::hardware_concurrency();
auto perThreadMinNums = 20;
auto maxThreadNums = ((count + (perThreadMinNums - 1)) & (~(perThreadMinNums - 1))) / perThreadMinNums;
auto threadNums =
avaThreadNums == 0 ?
maxThreadNums :
std::min(static_cast<int>(maxThreadNums), static_cast<int>(avaThreadNums));
auto blockSize = count / threadNums;

/* 主线程创建std::promise实例,模板参数是返回值类型 */
std::promise<InputIt> result;
/* 因为不同线程会并发查找,当一个线程找到后其他线程就可以停止查找了,原子变量done用于标记是否找到 */
std::atomic<bool> done(false);
{
std::vector<std::thread> threads;
auto front = first;
for (int i = 0; i < threadNums; ++i)
{
auto back = front;
if (i != threadNums - 1)
std::advance(back, blockSize);
else
back = last;
threads.emplace_back(
[front, back, &value, &result, &done]
{
/* 当一个线程找到后所有线程都会退出,通过done标记管理 */
for (auto it = front; !done && it != back; ++it)
{
if (*it == value)
{
done.store(true);
/* 如果找到,记录找到的值 */
result.set_value(it);
return;
}
}
}
);
}
/* 回收线程资源 */
for (auto &th : threads)
th.join();
}
/* 通过std::promise::get_future获得std::future对象,然后调用get获取结果 */
return done ? result.get_future().get() : last;
}
}

int main()
{
std::vector<int> v(100000000);
int n = 0;
std::generate(v.begin(), v.end(), [&n] { return ++n; });
auto value = std::random_device()() % 65536;
auto it1 = parallel::find(v.begin(), v.end(), value);
auto it2 = std::find(v.begin(), v.end(), value);
assert(it1 == it2);
return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <iostream>
#include <utility>
#include <future>
#include <thread>
using namespace std;

int func(int x) { return x + 2; }

int main() {
packaged_task<int(int)> tsk(func);
future<int> fut = tsk.get_future();

thread(move(tsk), 2).detach();

int value = fut.get();
cout << "The result is " << value << endl;

vector<shared_future<int>> v;
auto f = async(launch::async, [](int a, int b){return a + b;}, 2, 3);
v.push_back(f);
cout << "The shared_future result is " << v[0].get() << endl;

return 0;
}

输出:

1
2
The result is 4
The shared_future result is 5

线程异步操作函数async

std::asyncstd::promisestd::package_taskstd::thread更上层,它可以用来直接创建异步的task,异步任务返回的结果保存在future中,当需要获取线程执行的结果,可以通过future.get()来获取,如果不关注异步任务的结果,只是简单的等待任务执行完成,则调用future.wait()即可。

std::async是更高层次的异步操作,使我们不关心线程创建的内部细节,就能方便的获取线程异步执行的结果,还可以指定线程创建策略,更多的时候应该使用 std::async来创建线程,成为异步操作的首选。

std::async原型为

1
std::async(std::launch::async | std::launch::deferred,f,args...)

第一个参数为线程的创建策略,第二个为线程函数,其他的为线程函数的参数。

关于创建策略有两种:

  • std::launch::async:在调用async就开始创建线程;
  • std::launch::deferred:延迟加载的方式创建线程,调用async的时候不创建线程,直到调用了future的get或者wait方法来创建线程。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
#include <thread>    
#include <iostream>
#include <mutex>
#include <future>

int main()
{
std::future<int> f1 = std::async(std::launch::async, [](){ return 8; });
std::cout << f1.get() << std::endl; //output 8

std::future<void> f2 = std::async(std::launch::async, [](){ std::cout << 8 << std::endl; });
f2.wait(); //output 8

std::future<int> f3 = std::async(std::launch::async, []()
{
std::this_thread::sleep_for(std::chrono::seconds(3));
return 8;
});

std::cout << "Wating..." << std::endl;
std::future_status status;
do
{
status = f3.wait_for(std::chrono::seconds(1));
if (status == std::future_status::deferred)
{
std::cout << "deferred." << std::endl;
}
else if (status == std::future_status::timeout)
{
std::cout << "timeout." << std::endl;
}
else
{
std::cout << "ready." << std::endl;
}
} while (status != std::future_status::ready);
std::cout << "result:" << f3.get() << std::endl;

return 0;
}
//执行结果:
8
8
Wating...
timeout.
timeout.
ready.
result:8
  • 线程的创建和使用简单方便,可以通过多种方式创建,还可以根据需要获取线程的一些信息及休眠线程。
  • 互斥量可以通过多种方式来保证线程安全,既可以用独占的互斥量保证线程安全,又可以通过递归的互斥量来保护共享资源以避免死锁,还可以设置获取互斥量的超时时间,避免一直阻塞等待。
  • 条件变量提供了另外一种用于等待的同步机制,它能阻塞一个或多个线程,直到收到另外一个线程发出的通知或者超时,才会唤醒当前阻塞的线程。条件变量的使用需要配合互斥量。
  • 原子变量可以更方便地实现线程保护。
  • call_once保证在多线程情况下函数只被调用一次,可以用在在某些只能初始化一次的场景中。
  • future、promise和std::package_task用于异步调用的包装和返回值。
  • async更方便地实现了异步调用,应该优先使用async取代线程的创建。

使用C++11中的便利工具

处理日期和时间的chrono库

chrono库主要包含了三种类型:时间间隔Duration、时钟Clocks和时间点Time point。

记录时长的duration

duration表示一段时间间隔,用来记录时间长度,可以表示几秒钟、几分钟或者几个小时的时间间隔,duration的原型是:

1
template<class Rep, class Period = std::ratio<1>> class duration;

第一个模板参数Rep是一个数值类型,表示时钟个数;第二个模板参数是一个默认模板参数std::ratio,它的原型是:

1
template<std::intmax_t Num, std::intmax_t Denom = 1> class ratio;

它表示每个时钟周期的秒数,其中第一个模板参数Num代表分子,Denom代表分母,分母默认为1,ratio代表的是一个分子除以分母的分数值,比如ratio<2>代表一个时钟周期是两秒,ratio<60>代表了一分钟,ratio<60*60>代表一个小时,ratio<60*60*24>代表一天。而ratio<1, 1000>代表的则是1/1000秒即一毫秒,ratio<1, 1000000>代表一微秒,ratio<1, 1000000000>代表一纳秒。标准库为了方便使用,就定义了一些常用的时间间隔,如时、分、秒、毫秒、微秒和纳秒,在chrono命名空间下,它们的定义如下:

1
2
3
4
5
6
typedef duration <Rep, ratio<3600,1>> hours;
typedef duration <Rep, ratio<60,1>> minutes;
typedef duration <Rep, ratio<1,1>> seconds;
typedef duration <Rep, ratio<1,1000>> milliseconds;
typedef duration <Rep, ratio<1,1000000>> microseconds;
typedef duration <Rep, ratio<1,1000000000>> nanoseconds;

通过定义这些常用的时间间隔类型,我们能方便的使用它们,比如线程的休眠:

1
2
std::this_thread::sleep_for(std::chrono::seconds(3)); //休眠三秒
std::this_thread::sleep_for(std::chrono::milliseconds (100)); //休眠100毫秒

chrono还提供了获取时间间隔的时钟周期个数的方法count(),它的基本用法:

1
2
3
4
5
6
7
8
9
10
11
#include <chrono>
#include <iostream>
int main()
{
std::chrono::milliseconds ms{3}; // 3 毫秒
// 6000 microseconds constructed from 3 milliseconds
std::chrono::microseconds us = 2*ms; //6000微秒
// 30Hz clock using fractional ticks
std::chrono::duration<double, std::ratio<1, 30>> hz30(3.5);
std::cout << "3 ms duration has " << ms.count() << " ticks\n"<< "6000 us duration has " << us.count() << " ticks\n"
}

输出:

1
2
3 ms duration has 3 ticks
6000 us duration has 6000 ticks

时间间隔之间可以做运算,比如下面的例子中计算两端时间间隔的差值:

1
2
3
4
std::chrono::minutes t1( 10 );
std::chrono::seconds t2( 60 );
std::chrono::seconds t3 = t1 - t2;
std::cout << t3.count() << " second" << std::endl; // 540 second

其中,t1 是代表 10 分钟、 t2 是代表 60 秒,t3 则是 t1 減去 t2,也就是 600 - 60 = 540 秒。通过t1-t2的count输出差值为540个时钟周期即540秒(因为每个时钟周期为一秒)。我们还可以通过duration_cast<>()来将当前的时钟周期转换为其它的时钟周期,比如我可以把秒的时钟周期转换为分钟的时钟周期,然后通过count来获取转换后的分钟时间间隔:

1
cout << chrono::duration_cast<chrono::minutes>( t3 ).count() <<” minutes”<< endl;

将会输出:

1
9 minutes

Time point

time_point表示一个时间点,用来获取1970.1.1以来的秒数和当前的时间, 可以做一些时间的比较和算术运算,可以和ctime库结合起来显示时间。time_point必须要clock来计时,time_point有一个函数time_since_epoch()用来获得1970年1月1日到time_point时间经过的duration。下面的例子计算当前时间距离1970年1月一日有多少天:

1
2
3
4
5
6
7
8
9
10
11
12
#include <iostream>
#include <ratio>
#include <chrono>

int main ()
{
using namespace std::chrono;
typedef duration<int,std::ratio<60*60*24>> days_type;
time_point<system_clock,days_type> today = time_point_cast<days_type>(system_clock::now());
std::cout << today.time_since_epoch().count() << " days since epoch" << std::endl;
return 0;
}

time_point还支持一些算术元算,比如两个time_point的差值时钟周期数,还可以和duration相加减。下面的例子输出前一天和后一天的日期:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <iostream>
#include <iomanip>
#include <ctime>
#include <chrono>

int main()
{
using namespace std::chrono;
system_clock::time_point now = system_clock::now();
std::time_t last = system_clock::to_time_t(now - std::chrono::hours(24));
std::time_t next= system_clock::to_time_t(now - std::chrono::hours(24));
std::cout << "One day ago, the time was "<< std::put_time(std::localtime(&last), "%F %T") << '\n';
std::cout << "Next day, the time was "<< std::put_time(std::localtime(&next), "%F %T") << '\n';
}

输出:

1
2
One day ago, the time was 2014-3-2622:38:27
Next day, the time was 2014-3-2822:38:27

Clocks

表示当前的系统时钟,内部有time_point, duration, Rep, Period等信息,它主要用来获取当前时间,以及实现time_t和time_point的相互转换。Clocks包含三种时钟:

  • system_clock:从系统获取的时钟;
  • steady_clock:不能被修改的时钟;
  • high_resolution_clock:高精度时钟,实际上是system_clock或者steady_clock的别名。

可以通过now()来获取当前时间点:

1
2
3
4
5
6
7
8
9
10
#include <iostream>
#include <chrono>

int main()
{
std::chrono::steady_clock::time_point t1 = std::chrono::system_clock::now();
std::cout << "Hello World\n";
std::chrono::steady_clock::time_point t2 = std::chrono:: system_clock::now();
std::cout << (t2-t1).count()<<” tick count”<<endl;
}

输出:

1
2
Hello World
20801 tick count

通过时钟获取两个时间点之相差多少个时钟周期,我们可以通过duration_cast将其转换为其它时钟周期的duration:

1
cout << std::chrono::duration_cast<std::chrono::microseconds>( t2-t1 ).count() <<” microseconds”<< endl;

输出:

1
20 microseconds

system_clock的to_time_t方法可以将一个time_point转换为ctime:

1
std::time_t now_c = std::chrono::system_clock::to_time_t(time_point);

而from_time_t方法则是相反的,它将ctime转换为time_point。

steady_clock可以获取稳定可靠的时间间隔,后一次调用now()的值和前一次的差值是不因为修改了系统时间而改变,它保证了稳定的时间间隔。它的用法和system用法一样。

system_clock和std::put_time配合起来使用可以格式化日期的输出,std::put_time能将日期格式化输出。下面的例子是将当前时间格式化输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <chrono>
#include <ctime>
#include <iomanip>
#include <string>
using namespace std;

int main()
{
auto t = chrono::system_clock::to_time_t(std::chrono::system_clock::now());
cout<< std::put_time(std::localtime(&t), "%Y-%m-%d %X")<<endl;
cout<< std::put_time(std::localtime(&t), "%Y-%m-%d %H.%M.%S")<<endl;
return 0;
}

上面的例子将输出:

1
2
2014-3-27 22:11:49
2014-3-27 22.11.49

timer

可以利用high_resolution_clock来实现一个类似于boost.timer的定时器,这样的timer在测试性能时会经常用到,经常用它来测试函数耗时,它的基本用法是这样的:

1
2
3
4
5
6
7
8
9
10
void fun()
{
cout<<"hello word"<<endl;
}
int main()
{
timer t; //开始计时
fun()
cout<<t.elapsed()<<endl; //打印fun函数耗时多少毫秒
}

c++11中增加了chrono库,现在用来实现一个定时器是很简单的事情,还可以移除对boost的依赖。它的实现比较简单,下面是具体实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
#include<chrono>
usingnamespace std;
usingnamespace std::chrono;

classTimer
{
public:
Timer() : m_begin(high_resolution_clock::now()) {}
void reset() { m_begin = high_resolution_clock::now(); }

//默认输出秒
  double elapsed() const
  {
    return duration_cast<duration<double>>(high_resolution_clock::now() - m_begin).count();
  }

//默认输出毫秒
int64_t elapsed() const
{
//return duration_cast<chrono::milliseconds>(high_resolution_clock::now() - m_begin).count();
}

//微秒
int64_t elapsed_micro() const
{
return duration_cast<chrono::microseconds>(high_resolution_clock::now() - m_begin).count();
}

//纳秒
int64_t elapsed_nano() const
{
return duration_cast<chrono::nanoseconds>(high_resolution_clock::now() - m_begin).count();
}

//秒
int64_t elapsed_seconds() const
{
return duration_cast<chrono::seconds>(high_resolution_clock::now() - m_begin).count();
}

//分
int64_t elapsed_minutes() const
{
return duration_cast<chrono::minutes>(high_resolution_clock::now() - m_begin).count();
}

//时
int64_t elapsed_hours() const
{
return duration_cast<chrono::hours>(high_resolution_clock::now() - m_begin).count();
}

private:
time_point<high_resolution_clock> m_begin;
};

测试代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void fun()
{
cout<<”hello word”<<endl;
}

int main()
{
timer t; //开始计时
fun()
cout<<t.elapsed()<<endl; //打印fun函数耗时多少毫秒
cout<<t.elapsed_micro ()<<endl; //打印微秒
cout<<t.elapsed_nano ()<<endl; //打印纳秒
cout<<t.elapsed_seconds()<<endl; //打印秒
cout<<t.elapsed_minutes()<<endl; //打印分钟
cout<<t.elapsed_hours()<<endl; //打印小时
}

数值类型和字符串的相互转换

C++11提供了to_string方法,可以方便地将各种数值类型转换为字符串类型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
std::string to_string(int value);
std::string to_string(long int value);
std::string to_string(long long int value);
std::string to_string(unsigned int value);
std::string to_string(unsigned long long int value);
std::string to_string(float value);
std::string to_string(double value);

std::wstring to_wstring(int value);
std::wstring to_wstring(long int value);
std::wstring to_wstring(long long int value);
std::wstring to_wstring(unsigned int value);
std::wstring to_wstring(unsigned long int value);
std::wstring to_wstring(unsigned long long int value);
std::wstring to_wstring(float value);
std::wstring to_wstring(double value);
std::wstring to_wstring(long double value);

还提供了stoxxx方法,将string转换为各种类型的数据:

1
2
3
4
std::string str = "1000";
int val = std::stoi(str);
long val = std::stol(str);
float val = std::stof(str);

c++11还提供了字符串(char*)转换为整数和浮点类型的方法:

  • atoi: 将字符串转换为 int
  • atol: 将字符串转换为long
  • atoll:将字符串转换为 long long
  • atof: 将字符串转换为浮点数

宽窄字符转换

c++11增加了unicode字面量的支持,可以通过L来定义宽字符。

1
std::wstring wide_str = L"中国人"; //定义了宽字符字符串 

将宽字符转换为窄字符需要用到condecvt库中的std::wstring_convert,它需要如下几个转换器:

  • std::codecvt_utf8,封装了UTF-8与UCS2及UTF-8与UCS4的编码转换;
  • std::codecvt_utf16,封装了UTF-16与UCS2及UTF-16与UCS4的编码转换;
  • std::codecvt_utf8_utf16,封装了UTF-8与UTF-16的编码转换;

std::wstring_convert使std::stringstd::wstring之间的相互转换变得很方便,如代码:

1
2
3
4
5
6
7
8
9
10
11
std::wstring wide_str = L"中国人";
std::wstring_convert<std::condecvt<wchar_t, char, std::mbstate_t>> converter(new std::codecvt<wchar_t, char, std::mbstate_t>("CHS");

std::string narrow_str = converter.to_bytes(wide_str);
std::wstring wstr = converter.from_bytes(narrow_str);
std::cout << narrow_str << std::endl;

wcout.imbue(std::locale("chs"));
std::wcout << wstr << std::endl;
std::cout << wstr.size() << " " << wstr.length() << endl;
std::cout << narrow_str.size() << " " << narrow_str.length() << endl;

输出:

1
2
中国人
中国人

C++11的其他特性

委托构造函数和继承构造函数

委托构造函数允许在同一个类中一个构造函数可以调用另一个构造函数,从而可以在初始化时简化变量的初始化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class class_c {
public:
int max;
int min;
int middle;

class_c() {}
class_c(int my_max) {
max = my_max > 0 ? my_max : 10;
}
class_c(int my_max, int my_min) {
max = my_max > 0 ? my_max : 10;
min = my_min > 0 && my_min < my_max ? my_min : 1;
}
class_c(int my_max, int my_min, int my_middle) {
max = my_max > 0 ? my_max : 10;
min = my_min > 0 && my_min < my_max ? my_min : 1;
middle = my_middle < max && my_middle > min ? my_middle : 5;
}
}

通过委托构造函数简化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class class_c {
public:
int max;
int min;
int middle;

class_c() {}
class_c(int my_max) {
max = my_max > 0 ? my_max : 10;
}
class_c(int my_max, int my_min) : class_c(my_max) {
min = my_min > 0 && my_min < my_max ? my_min : 1;
}
class_c(int my_max, int my_min, int my_middle) : class_c(my_max, my_min) {
middle = my_middle < max && my_middle > min ? my_middle : 5;
}
}

需要注意,如果使用了委托构造函数,则不能使用类成员初始化,比如:

1
2
3
4
5
6
7
8
class A{
public:
A(int a):a_(a){}; //单独使用类成员初始化,可以
A(int a, int b):A(a), b_(b){}; //同时使用了委托构造函数和类成员初始化,错误!
private:
int a_;
int b_;
}

如果一个派生类继承自一个基类,如果其构造函数想要使用和基类相同的构造函数,如果构造函数有多个,则在派生类中要写多个构造函数,每个都用基类构造, 在c++11中,可以使用继承构造函数来简化这一操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
class Base{
public:
Base(int a);
Base(int a, int b);
Base(int a, int b, double c);
~Base();
};
struct Derived : Base {
using Base::Base; //声明使用基类构造函数
};
int main() {
...
}

原始的字面量

原始字面量可以直接表示字符串的实际含义,因为有些字符串带一些特殊字符,比如在转义字符串中,我们往往要专门处理。如windows路径名:D:\A\B\test.txt
在c++11中,使用R"xx(string)xx"来获得括号中的string部分的字符串形式,不需要使用转义字符等附加字符,比如:

1
string a = R"(D:\A\B\test.txt)" 

注意,R"xxx(raw string)xxx",其中原始字符串必须用括号()括起来,括号前后可以加其他字符串,所加的字符串是会被忽略的,而且加的字符串必须在括号两边同时出现。

1
2
string str = R"test(D:A\B\test.test)test";
// 实际上是“D:A\B\test.test”

final和override标识符

c++11中增加了final关键字来限制某个类不能被继承(类似java)或者某个虚函数不能别重写(类似c#中的sealed)。如果修饰函数,final只能修饰虚函数,并且要放到类或者函数的后面。

1
2
3
4
5
6
7
8
9
struct A{
virtual void foo() final; // foo 声明为final的虚函数,不能被重写
void test() final; // 错误,final只能修饰虚函数
};
struct B final : A{ //B声明为final,表示不能被继承
void foo(); // 错误,foo不可被重写
};
struct C : B{ //错误,B不能被继承
};

c++11中还增加了override关键字确保在派生类中声明的重写函数与基类的虚函数有相同的签名,同时也明确表明将会重写基类的虚函数,还可以防止因疏忽把原来想重写基类的虚函数声明为重载。override关键字要放到方法的后面

1
2
3
4
5
6
7
struct A{
virtual void func();
};
struct D:A{
void func() override{
};
};

内存对齐

内存对齐介绍

cpu访问内存的时候,起始地址并不是随意的,例如有些cpu访问内存起始地址要是4的倍数,因为内存总线的宽度为32位,每次读写操作都4个字节4个字节进行。如果某个数据在内存中不是字节对齐的,则会在访问的时候比较麻烦,比如4字节的int32类型,没有4字节对齐,则需要访问两次内存才能读到完整的数据。因此,内存对齐可以提高程序的效率。

因为有了内存对齐,所以数据在内存中的存放就不是紧挨着的,而是会出现一些空隙。C++数据内存对齐的含义是,数据在内存中的起始地址是数据size的倍数。c++结构体内存对齐的原则是:结构体内的每个变量都自身对齐,按照字节对齐,中间加入padding,;整个结构体按照结构体内的最大size变量的对齐方式对齐,比如:

1
2
3
4
5
struct{
int a;
char c;
double d;
};

结构体按照最大size的变量对齐,即按照double的8字节对齐。

堆内存的内存对齐

malloc一般使用当前平台默认的最大内存对齐数对齐内存。当我们需要分配一块特定内存对齐的内存块时,使用memalign等函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <assert.h>

inline void* aligned_malloc(size_t size, size_t alignment) {
// 检查alignment是否是2^N
assert(!alignment & (alignment - 1));
// 计算最大offset
size_t offset = sizeof(void*) + (-- alignment);

// 分配一块带有offset的内存
char* p = static_cast<char*>(malloc(offset + size));
if (!p) return nullptr;

// 通过“&(~alignment)”把多计算的offset减掉
void* r = reinterpret_cast<void*>(reinterpret_cast<size_t>(p + offset) & (~alignment));

// 将r作为一个指向void*的指针,在r当前地址前面放入原始地址
static_cast<void**>(r)[-1] = p;
return r;
}

利用alignas指定内存对齐大小

1
alignas(32) long long a = 0; 

指定a为32字节对齐。 alignas可以将内存对齐改大,而不能改小,因此,可以有 alignas(32) long long a; 而不能有alignas(1) long long a;

1
2
#define XX 1
struct alignas(XX) MyStruct{ }

指定为1字节对齐,因为MyStruct内部没有数据,自然为1字节对齐。如果内部含有int类型数据,则alignas只能将对齐方式改大不能改小,故不能为1字节对齐。

1
alignas(int) char c;

这个char就按照int的方式对齐了。

利用alignof和std::alignment_of获取内存对齐大小

alignof用来获取内存对齐大小,只能返回size_t。

1
2
3
MyStruct xx;
cout << alignof(xx) << endl;
cout << alignof(MyStruct) << endl;

alignment_of继承自std::integral_constant,因此拥有value_type、type和value成员

1
cout << std::alignment_of<MyStruct>::value << std::endl;

内存对齐的类型std::aligned_storage

aligned_storage可以看成一个内存对齐的缓冲区,原型如下:

1
2
template<std::size_t Len, std::size_t Align = /*default-alignment*/>
struct aligned_storage;

Len代表所存储类型的size,Align代表所存储类型的对齐大小,通过sizeof(T)获取T的size,通过alignof(T)获取T内存对齐的大小,所以std::aligned_storage的声明是这样的:std::aligned_storage<sizeof(T), align(T)>或者std::aligned_storage<sizeof(T), std::alignment_of(T)::value>

1
2
3
4
5
6
7
8
9
10
11
struct A{
int a;
double c;
A(int aa, double cc):a(aa), c(cc){};
};
typedef std::aligned_storage<sizeof<A>, alignof(A)>::type Aligned_A;
int main(){
Aligned_A a, b; //声明一块内存对齐的内存
new (&a)A(10, 20.0); //原地构造函数
return 0;
}

为什么要使用std::aligned_storage呢?很多时候需要分配一块单纯的内存块,之后再使用placement new在这块内存上构建对象:

1
2
char xx[32];
::new xx MyStruct;

但是char[32]是1字节对齐的,xx很有可能不在指定的对齐位置上,这是调用placement new构造内存块引起效率问题,所以应该使用std::aligned_storage构造内存块:

1
2
typedef std::aligned_storage<sizeof<A>, alignof(A)>::type Aligned_A;
::new (&Aligned_A) A;

std::max_align_tstd::align操作符

std::max_align_t返回当前平台的最大默认内存对齐类型。通过下面这个方式获得当前平台的默认最大内存对齐数:

1
cout << alignof(std::max_align_t) << endl;

std::align用来在一大块内存中获取一个符合指定内存要求的地址。

1
2
3
4
char buffer[] = "---------------";
void* pt = buffer;
std::size_t space = sizeof(buffer) - 1;
std::align(alignof(int), sizeof(char), pt, space);

在buffer这个大内存中,指定内存对齐为align(int),找一块sizeof(char)大小的内存,并在找到这块内存后把地址放入pt中。

新增的便利算法

all_ofany_ofnone_of

算法库新增了三个用于判断的算法all_of、any_of和none_of:

1
2
3
4
5
6
7
8
template< class InputIt, class UnaryPredicate >
bool all_of( InputIt first, InputIt last, UnaryPredicate p );

template< class InputIt, class UnaryPredicate >
bool any_of( InputIt first, InputIt last, UnaryPredicate p );

template< class InputIt, class UnaryPredicate >
bool none_of( InputIt first, InputIt last, UnaryPredicate p );
  • all_of:检查区间[first, last)中是否所有的元素都满足一元判断式p,所有的元素都满足条件返回true,否则返回false。
  • any_of:检查区间[first, last)中是否至少有一个元素都满足一元判断式p,只要有一个元素满足条件就返回true,否则返回true。
  • none_of:检查区间[first, last)中是否所有的元素都不满足一元判断式p,所有的元素都不满足条件返回true,否则返回false。

下面是这几个算法的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <iostream>
#include <algorithm>
#include <vector>
using namespace std;
int main()
{
vector<int> v = { 1, 3, 5, 7, 9 };
auto isEven = [](int i){return i % 2 != 0;};
bool isallOdd = std::all_of(v.begin(), v.end(), isEven);
if (isallOdd)
cout << "all is odd" << endl;

bool isNoneEven = std::none_of(v.begin(), v.end(), isEven);
if (isNoneEven)
cout << "none is even" << endl;

vector<int> v1 = { 1, 3, 5, 7, 8, 9 };
bool anyof = std::any_of(v1.begin(), v1.end(), isEven);
if (anyof)
cout << "at least one is even" << endl;
}

输出:

1
2
3
all is odd
none is odd
at least one is even

find_if_not

算法库的查找算法新增了一个find_if_not,它的含义和find_if是相反的,即查找不符合某个条件的元素,find_if也可以实现find_if_not的功能,只需要将判断式改为否定的判断式即可,现在新增了find_if_not之后,就不需要再写否定的判断式了,可读性也变得更好。下面是它的基本用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <iostream>
#include <algorithm>
#include <vector>
using namespace std;

int main()
{
vector<int> v = { 1, 3, 5, 7, 9, 4 };
auto isEven = [](int i){return i % 2 == 0;};
auto firstEven = std::find_if(v.begin(), v.end(), isEven);
if (firstEven!=v.end())
cout << "the first even is " <<* firstEven << endl;

//用find_if来查找奇数则需要重新写一个否定含义的判断式
auto isNotEven = [](int i){return i % 2 != 0;};
auto firstOdd = std::find_if(v.begin(), v.end(),isNotEven);

if (firstOdd!=v.end())
cout << "the first odd is " <<* firstOdd << endl;

//用find_if_not来查找奇数则无需新定义判断式
auto odd = std::find_if_not(v.begin(), v.end(), isEven);
if (odd!=v.end())
cout << "the first odd is " <<* odd << endl;
}

将输出:

1
2
3
the first even is 4
the first odd is 1
the first odd is 1

可以看到使用find_if_not不需要再定义新的否定含义的判断式了,更简便了。

copy_if

算法库还增加了一个copy_if算法,它相比原来的copy算法多了一个判断式,用起来更方便了,下面是它的基本用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <iostream>
#include <algorithm>
#include <vector>
using namespace std;

int main()
{
vector<int> v = { 1, 3, 5, 7, 9, 4 };
std::vector<int> v1(v.size());
//根据条件拷贝
auto it = std::copy_if(v.begin(), v.end(), v1.begin(), [](int i){return i%2!=0;});
//缩减vector到合适大小
v1.resize(std::distance(v1.begin(),it));
for(int i : v1)
{
cout<<i<<" ";
}

cout<<endl;
}

iota

算法库新增了iota用来方便的生成有序序列,比如我们需要一个定长数组,这个数组中的元素都是在某一个数值的基础之上递增的,那么用iota可以很方便的生成这个数组了。下面是它的基本用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include <numeric>
#include <array>
#include <vector>
#include <iostream>
using namespace std;

int main()
{
vector<int> v(4) ;
//循环遍历赋值来初始化数组
//for(int i=1; i<=4; i++)
//{
// v.push_back(i);
//}

//直接通过iota初始化数组,更简洁
std::iota(v.begin(), v.end(), 1);
for(auto n: v) {
cout << n << ' ';
}
cout << endl;

std::array<int, 4> array;
std::iota(array.begin(), array.end(), 1);
for(auto n: array) {
cout << n << ' ';
}
std::cout << endl;
}

将输出:

1
2
1 2 3 4
1 2 3 4

可以看到使用iota比遍历赋值来初始化数组更简洁,需要注意的是iota初始化的序列需要指定大小,如果上面的代码中:vector v(4) ;没有指定初始化大小为4的话,则输出为空。

minmax_element

算法库还新增了一个同时获取最大值和最小值的算法minmax_element,这样我们如果想获取最大值和最小值的时候就不用分别调用max_element和max_element算法了,用起来会更方便,minmax_element会将最小值和最大值的迭代器放到一个pair中返回,下面是它的基本用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <iostream>
#include <algorithm>
#include <vector>
using namespace std;

int main() {
// your code goes here
vector<int> v = { 1, 2, 5, 7, 9, 4 };
auto result = minmax_element(v.begin(), v.end());

cout<<*result.first<<" "<<*result.second<<endl;

return 0;
}

将输出:

1
1 9

is_sorted和is_sorted_until

算法库新增了is_sorted和is_sorted_until算法,is_sort用来判断某个序列是否是排好序的,is_sort_until则用来返回序列中前面已经排好序的部分序列。下面是它们的基本用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <iostream>
#include <algorithm>
#include <vector>
using namespace std;

int main() {
vector<int> v = { 1, 2, 5, 7, 9, 4 };
auto pos = is_sorted_until(v.begin(), v.end());

for(auto it=v.begin(); it!=pos; ++it)
{
cout<<*it<< " ";
}
cout<<endl;

bool is_sort = is_sorted(v.begin(), v.end());
cout<< is_sort<<endl;
return 0;
}

将输出:

1
2
1 2 5 7 9
0

总结:这些新增的算法让我们用起来更加简便,也增强了代码的可读性。

C++11改进我们的模式

改进单例模式

单例模式保证一个类仅有一个实例,并提供一个访问它的全局访问点。在c++11之前,我们写单例模式的时候会遇到一个问题,就是多种类型的单例可能需要创建多个类型的单例,主要是因为创建单例对象的构造函数无法统一,各个类型的形参不尽相同,导致我们不容易做一个所有类型都通用的单例。现在c+11帮助我们解决了这个问题,解决这个问题靠的是c++11的可变模板参数。

将原有的多个构造函数合并:

1
2
3
4
5
6
template <typename T0, typename T1, typename T2, typename T3, typename T4, typename T5>
static T* Instance(T0 arg0, T1 arg1, T2 arg2, T3 arg3, T4 arg4, T5 arg5) {
if (m_pInstance == nullptr)
m_pInstance = new T(arg0, arg1, arg2, arg3, arg4, arg5);
return m_pInstance;
}

改为

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
template <typename T>
class Singleton
{
public:
template<typename... Args>
  static T* Instance(Args&&... args)
  {
if(m_pInstance==nullptr)
m_pInstance = new T(std::forward<Args>(args)...);
return m_pInstance;
}
  static T* GetInstance() {
    if (m_pInstance == nullptr)
      throw std::logic_error("the instance is not init, please initialize the instance first");
    return m_pInstance;
  }
static void DestroyInstance()
{
delete m_pInstance;
m_pInstance = nullptr;
}

private:
Singleton(void);
virtual ~Singleton(void);
Singleton(const Singleton&);
Singleton& operator = (const Singleton&);
private:
static T* m_pInstance;
};

template <class T> T* Singleton<T>::m_pInstance = nullptr;

/*更新说明**/

由于原来的接口中,单例对象的初始化和取值都是一个接口,可能会遭到误用,更新之后,初始化和取值分为两个接口,单例的用法为:先初始化,后面取值,如果中途销毁单例的话,需要重新取值。如果没有初始化就取值则会抛出一个异常。

增加Multiton的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
#include <map>
#include <string>
#include <memory>
using namespace std;

template < typename T, typename K = string>
class Multiton
{
public:
template<typename... Args>
static std::shared_ptr<T> Instance(const K& key, Args&&... args)
{
return GetInstance(key, std::forward<Args>(args)...);
}

template<typename... Args>
static std::shared_ptr<T> Instance(K&& key, Args&&... args)
{
return GetInstance(key, std::forward<Args>(args)...);
}
private:
template<typename Key, typename... Args>
static std::shared_ptr<T> GetInstance(Key&& key, Args&&...args)
{
std::shared_ptr<T> instance = nullptr;
auto it = m_map.find(key);
if (it == m_map.end())
{
instance = std::make_shared<T>(std::forward<Args>(args)...);
m_map.emplace(key, instance);
}
else
{
instance = it->second;
}

return instance;
}

private:
Multiton(void);
virtual ~Multiton(void);
Multiton(const Multiton&);
Multiton& operator = (const Multiton&);
private:
static map<K, std::shared_ptr<T>> m_map;
};

template <typename T, typename K>
map<K, std::shared_ptr<T>> Multiton<T, K>::m_map;

改进观察者模式

观察者模式定义对象间一种一对多关系,当一个对象的状态发生改变时,所有依赖于它的对象都得到通知并被自动更新。和单例模式面临的是同样的问题,主题更新的接口难以统一,很难做出一个通用的观察者模式,还是用到可变模板参数解决这个问题,其次还用到了右值引用,避免多余的内存移动。c++11版本的观察者模式支持注册的观察者为函数、函数对象和lamda表达式,也避免了虚函数调用,更简洁更通用。

主要改进的地方有两个:通过被通知接口参数化和std::function来代替继承,通过可变参数模板和完美转发来消除接口变化产生的影响。直接看代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
class NonCopyable {
protected:
NonCopyable() = default;
~NonCopyable() = default;
NonCopyable(const NonCopyable&) = delete;
NonCopyable& operator = (const NonCopyable&) = delete;
// 禁用复制构造和赋值构造
};

template<typename Func>
class Events : NonCopyable
{
public:
Events() {}
~Events(){}

int Connect(Func&& f) {
return Assgin(std::forward<Func>(f));
}

int Connect(const Func& f) {
return Assgin(f);
}

void Disconnect(int key) {
m_connections.erase(key);
}

template<typename... Args>
void Notify(Args&&... args) {
for (auto& it : m_connections) {
it.second(std::forward<Args>(args)...);
}
}

int operator += (Func&& f) {
return Connect(std::forward<Func>(f));
}

int operator += (Func& f) {
return Connect(f);
}

template<typename... Args>
void operator()(Args&&... args) {
Notify(std::forward<Args>(args)...);
}

Events& operator -= (int key) {
Disconnect(key);
return *this;
}

void Clear() {
m_connections.clear();
}

private:

template<typename F>
int Assgin(F&& f) {
int index = m_nextKey++;
m_connections.emplace(index, std::forward<F>f);
return index;
}

int m_nextKey;
std::map<int, Func> m_connections;
};

增加了+=和-=运算符,使用法更接近c#,这里+=会返回一个key,这个key用来-=删除委托时用到,这种做法不太好,只是一个简单的处理。如果内部用vector的话,-=时,根据function去删除指定的委托的话,用法就和c#完全一致了,不过,这里遇到的问题是function不支持比较操作,导致将function存入容器后,后面再根据function去删除时就找不到对应的function了。

改进访问者模式

访问者表示一个作用于某对象结构中的各元素的操作,可用于不改变各元素的类的前提下定义作用于这些元素的新操作。

访问者模式需要注意定义对象结构的类很少改变,但经常需要在此结构上定义新的操作。改变对象结构类需要重定义对所有访问者的接口,这可能需要很大的代价。定义一个稳定的访问者接口层,即不会因为增加新的被访问者而修改接口层。通过可变参数模板实现一个稳定的接口层,利用可变参数模板可以支持任意个数的参数的特点,可以让访问者接口层访问任意个数的被访问者。

访问者模式是GOF23个设计模式中比较复杂的模式之一,但是它的功能也很强大,非常适合稳定的继承层次中对象的访问,可以在不修改被访问对象的情况下,动态添加职责,这正是访问者模式强大的地方,但是它的实现又造成了两个继承层次的强烈耦合,这也是被人诟病的地方,可以说是让人爱又让人恨的模式。c++11实现的访问者模式将会解决这个问题。我们将在c++11版本的访问者模式中看到,定义新的访问者是很容易的,扩展性很好,被访问者的继承层次也不用做任何修改。具体代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
template<typename... Types>
struct Visitor;

template<typename T, typename... Types>
struct Visitor<T, Types...> : Visitor<Types...>
{
using Visitor<Types...>::Visit;
virtual void Visit(const T&) = 0;
};

template<typename T>
struct Visitor<T>
{
virtual void Visit(const T&) = 0;
};

上面的代码为每个类型都定义了一个纯虚函数Visit。

下面看看被访问的继承体系如何使用Visitor访问该继承体系的对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
struct stA;
struct stB;

struct Base
{
typedef Visitor<stA, stB> MytVisitor;
virtual void Accept(MytVisitor&) = 0;
};

struct stA: Base
{
double val;
void Accept(Base::MytVisitor& v)
{
v.Visit(*this);
}
};

struct stB: Base
{
int val;
void Accept(Base::MytVisitor& v)
{
v.Visit(*this);
}
};

struct PrintVisitor: Base::MytVisitor
{
void Visit(const stA& a)
{
std::cout << "from stA: " << a.val << std::endl;
}
void Visit(const stB& b)
{
std::cout << "from stB: " << b.val << std::endl;
}
};

测试代码:

1
2
3
4
5
6
7
8
9
10
11
12
void TestVisitor()
{
PrintVisitor vis;
stA a;
a.val = 8.97;
stB b;
b.val = 8;
Base* base = &a;
base->Accept(vis);
base = &b;
base->Accept(vis);
}

 测试结果:

1
2
from stA: 8.97
from stB: 8

typedef Visitor<stA, stB> MytVisitor;会自动生成stA和stB的visit虚函数:

1
2
3
4
struct Visitor<stA, stB> {
virtual void Visit(const stA &) = 0;
virtual void Visit(const stB &) = 0;
}

当被访者需要增加stC、stD时,增加就行:

1
`typedef Visitor<stA, stB, stC, stD> MytVisitor;

类型自动生成接口:

1
2
3
4
5
6
struct Visitor<stA, stB, stC, stD> {
virtual void Visit(const stA &) = 0;
virtual void Visit(const stB &) = 0;
virtual void Visit(const stC &) = 0;
virtual void Visit(const stD &) = 0;
}

改进命令模式

命令模式的作用是将请求封装为一个对象,将请求的发起者和执行者解耦,支持对请求排队以及撤销和重做。将请求封装成一个个命令对象,使得我们可以集中处理或延迟处理这些命令请求,而且不同的客户对象可以共享命令,控制请求的优先级、排队、支持请求命令撤销和重做。

命令模式的这些好处是显而易见的,但是,在实际使用过程中它的问题也暴露出来了。随着请求的增多,请求的封装类—命令类也会越来越多,尤其是GUI应用中,请求是非常多的。越来越多的命令类会导致类爆炸,难以管理。关于类爆炸这个问题,GOF很早就意识到了,他们提出了一个解决方法:对于简单的不能取消和不需要参数的命令,可以用一个命令类模板来参数化该命令的接收者,用接收者类型来参数化命令类,并维护一个接收者对象和一个动作之间的绑定,而这一动作是用指向同一个成员函数的指针存储的。具体代码是这样的:
简单命令类的定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
template <class Receiver>
class SimpleCommand: public Command {
public :
typedef void (Receiver:: *Action) ();
SimpleCormnand(Receiver* r, Action a) : _receiver (r) , _action (a) { }
virtual void Execute ();
private :
Action _action;
Receiver* _receiver ;
};
template <class Receiver>
void SimpleCommand<Receiver>::Execute() {
(_receiver->*_action)();
}

测试代码如下:

1
2
3
4
5
6
7
8
9
class MyClass {
public:
void Action();
}
void dummy() {
MyClass* receiver = new MyClass;
Command* aCommand = new SimpleCommand<MyClass>(receiver, &MyClass::Action);
aCommand->Execute();
}

通过一个泛型的简单命令类来避免不断创建新的命令类,是一个不错的办法,但是,这个办法不完美,即它只能是简单的命令类,不能对复杂的,甚至所有的命令类泛化,这是它的缺陷,所以,它只是部分的解决了问题。我想我可以改进这个办法缺陷,完美的解决类爆炸的问题。在c++11之前我不知道有没有人解决过这个问题,至少我没看到过。现在可以通过c++11来完美的解决这个问题了。

要完美的解决命令模式类爆炸问题的关键是如何定义个通用的泛化的命令类,这个命令类可以泛化所有的命令,而不是GOF提到的简单命令。我们再回过头来看看GOF中那个简单的命令类的定义,它只是泛化了没有参数和返回值的命令类,命令类内部引用了一个接收者和接收者的函数指针,如果接收者的行为函数指针有参数就不能通用了,所以我们要解决的关键问题是如何让命令类能接受所有的成员函数指针或者函数对象。

我们需要一个函数包装器,它可以接受所有的函数对象、fucntion和lamda表达式等。接受function、函数对象、lamda和普通函数的包装器:

1
2
3
4
5
template< class F, class... Args, class = typename std::enable_if<!std::is_member_function_pointer<F>::value>::type>
void Wrap(F && f, Args && ... args)
{
return f(std::forward<Args>(args)...);
}

接受成员函数的包装器:

1
2
3
4
5
template<class R, class C, class... DArgs, class P, class... Args>
void Wrap(R(C::*f)(DArgs...), P && p, Args && ... args)
{
return (*p.*f)(std::forward<Args>(args)...);
}

通过重载的Wrap让它能接收成员函数。这样一个真正意义上的万能的函数包装器就完成了。现在再来看,它是如何应用到命令模式中,完美的解决类爆炸的问题。

一个通用的泛化的命令类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include <functional>
#include <type_traits>
template<typename R=void>
struct CommCommand
{
private:
std::function < R()> m_f;
public:
template< class F, class... Args, class = typename std::enable_if<!std::is_member_function_pointer<F>::value>::type>
void Wrap(F && f, Args && ... args)
{
m_f = [&]{return f(args...); };
}

template<class C, class... DArgs, class P, class... Args>
void Wrap(R(C::*f)(DArgs...) const, P && p, Args && ... args)
{
m_f = [&, f]{return (*p.*f)( args...); };
}

// non-const member function
template<class C, class... DArgs, class P, class... Args>
void Wrap(R(C::*f)(DArgs...), P && p, Args && ... args)
{
m_f = [&, f]{return (*p.*f)( args...); };
}

R Excecute()
{
return m_f();
}
};

测试代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
struct STA
{
int m_a;
int operator()(){ return m_a; }
int operator()(int n){ return m_a + n; }
int triple0(){ return m_a * 3; }
int triple(int a){ return m_a * 3 + a; }
int triple1() const { return m_a * 3; }
const int triple2(int a) const { return m_a * 3+a; }
void triple3(){ cout << "" <<endl; }
};

int add_one(int n) {
return n + 1;
}

void TestWrap() {

CommCommand<int> cmd;
// free function
cmd.Wrap(add_one, 0);

// lambda function
cmd.Wrap([](int n){return n + 1; }, 1);

// functor
cmd.Wrap(bloop);
cmd.Wrap(bloop, 4);

STA t = { 10 };
int x = 3;
// member function
cmd.Wrap(&STA::triple0, &t);
cmd.Wrap(&STA::triple, &t, x);
cmd.Wrap(&STA::triple, &t, 3);

cmd.Wrap(&STA::triple2, &t, 3);
auto r = cmd.Excecute();

CommCommand<> cmd1;
cmd1.Wrap(&Bloop::triple3, &t);
cmd1.Excecute();
}

我们在通用的命令类内部定义了一个万能的函数包装器,使得我们可以封装所有的命令,增加新的请求都不需要重新定义命令了,完美的解决了命令类爆炸的问题。

改进对象池模式

对象池对于创建比较大的对象来说很有意义,为了避免重复创建开销比较大的对象,可以通过对象池来优化,实现创建好一批对象,放到一个集合里,每当程序需要新对象时,就从对象池中获取,程序用完该对象后会把对象归还给对象池。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
#include <string>
#include <functional>
#include <memory>
#include <map>
using namespace std;

const int MaxObjectNum = 10;

template<typename T>
class ObjectPool : NonCopyable
{
template<typename... Args>
using Constructor = std::function<std::shared_ptr<T>(Args...)>;
public:
//默认创建多少个对象
template<typename... Args>
void Init(size_t num, Args&&... args)
{
if (num<= 0 || num> MaxObjectNum)
throw std::logic_error("object num out of range.");

auto constructName = typeid(Constructor<Args...>).name(); //不区分引用
for (size_t i = 0; i <num; i++)
{
m_object_map.emplace(constructName, shared_ptr<T>(new T(std::forward<Args>(args)...), [this, constructName](T* p) //删除器中不直接删除对象,而是回收到对象池中,以供下次使用
{
m_object_map.emplace(std::move(constructName), std::shared_ptr<T>(p));
}));
}
}

//从对象池中获取一个对象
template<typename... Args>
std::shared_ptr<T> Get()
{
string constructName = typeid(Constructor<Args...>).name();

auto range = m_object_map.equal_range(constructName);
for (auto it = range.first; it != range.second; ++it)
{
auto ptr = it->second;
m_object_map.erase(it);
return ptr;
}

return nullptr;
}

private:
multimap<string, std::shared_ptr<T>> m_object_map;
};

使用C++11实现一个半同步半异步线程池

实际中,主要有两种方法处理大量的并发任务,一种是一个请求由系统产生一个相应的处理请求的线程(一对一);另外一种是系统预先生成一些用于处理请求的进程,当请求的任务来临时,先放入同步队列中,分配一个处理请求的进程去处理任务,线程处理完任务后还可以重用,不会销毁,而是等待下次任务的到来。(一对多的线程池技术)线程池技术,能避免大量线程的创建和销毁动作,节省资源,对于多核处理器,由于线程被分派配到多个cpu,会提高并行处理的效率。线程池技术分为半同步半异步线程池和领导者追随者线程池。

一个半同步半异步线程池分为三层。

  • 同步服务层:它处理来自上层的任务请求,上层的请求可能是并发的,这些请求不是马上就会被处理的,而是将这些任务放到一个同步排队层中,等待处理。
  • 同步排队层: 来自上层的任务请求都会加到排队层中等待处理,排队层实际就是一个std::queue。
  • 异步服务层: 这一层中会有多个线程同时处理排队层中的任务,异步服务层从同步排队层中取出任务并行的处理。

上层只需要将任务丢到同步队列中,主线程也不会阻塞,还能继续发起新的请求。排队曾居于核心地位,实现时,排队曾就是一个同步队列,允许多个线程同时去添加或取出任务。线程池有两个活动过程,一个是往同步队列中添加任务的过程,一个是从同步队列中取任务的过程。

一开始线程池会启动一定数量的线程,这些线程属于异步层,主要用来并行处理排队层中的任务,如果排队层中的任务数为空,则这些线程等待任务的到来,如果发现排队层中有任务了,线程池则会从等待的这些线程中唤醒一个来处理新任务。同步服务层则会不断地将新的任务添加到同步排队层中,这里有个问题值得注意,有可能上层的任务非常多,而任务又是非常耗时的,这时,异步层中的线程处理不过来,则同步排队层中的任务会不断增加,如果同步排队层不加上
限控制,则可能会导致排队层中的任务过多,内存暴涨的问题。因此,排队层需要加上限的控制,当排队层中的任务数达到上限时,就不让上层的任务添加进来,起到限制和保护的作用。

同步队列即为线程中三层结构中的中间那一层,它的主要作用是保证队列中共享数据线程安全,还为上一层同步服务层提供添加新任务的接口,以及为下一层异步服务层提供取任务的接口。同时,还要限制任务数的上限,避免任务过多导致内存暴涨的问题。同步队列的实现比较简单,我们会用到C++11的锁、条件变量、右值引用、std::move以及std::forwardo。move是为了实现移动语义,forward是为了实现完美转发。同步队列的锁是用来线程同步的,条件变量是用来实现线程通信的,即线程池空了就要等待,不为空就通知一个线程去处理;线程池满了就等待,直到没有满的时候才通知上层添加新任务。

这三个层次之间需要使用std::mutex、std::condition_variable来进行事件同步,线程池的实现代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
#include <list>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <utility>
#include <iostream>

template<typename T>
class SyncQueue
{
public:
SyncQueue(int maxSize) : m_maxSize(maxSize),m_needStop(false){}

void Put(const T& x) {
Add(x);
}

void Put(T&& x) {
Add(std::forward<T>(x));
}

void Take(std::list<T>& list) {
std::unique_lock<std::mutex> locker(m_mutex);
m_notEmpty.wait(locker,[this]{return m_needStop || NotEmpty();});
if(m_needStop)
return;
list = std::move(m_queue); //move semantics,avoid copy.
m_notFull.notify_one();
}

void Take(T& x) {
std::unique_lock<std::mutex> locker(m_mutex);
m_notEmpty.wait(locker,[this]{return m_needStop || NotEmpty();});
if(m_needStop)
return;
x=m_queue.front();
m_queue.pop_front();
m_notFull.notify_one();
}

void Stop() {
{
std::lock_guard<std::mutex> locker(m_mutex);
m_needStop = true;
}
m_notFull.notify_all();
m_notEmpty.notify_all();
}

bool Empty() {
std::lock_guard<std::mutex> locker(m_mutex);
return m_queue.empty();
}

bool Full() {
std::lock_guard<std::mutex> locker(m_mutex);
return m_queue.size() == m_maxSize;
}

std::size_t Size()
{
std::lock_guard<std::mutex> locker(m_mutex);
return m_queue.size();
}

int Count() {
return m_queue.size();
}
private:
bool NotFull() const {
bool full = m_queue.size() >= m_maxSize;
if(full)
std::cout << "the buffer is full,waiting...\n";
return !full;
}
bool NotEmpty()
{
bool empty = m_queue.empty();
if(empty)
std::cout << "the buffer is empty,waiting...\n";
return !empty;
}

template<typename F>
void Add(F&& x) {
std::unique_lock<std::mutex> locker(m_mutex);
m_notFull.wait(locker, [this]{ return m_needStop || NotFull(); });
if (m_needStop)
return ;
m_queue.push_back(std::forward<F>(x));
m_notEmpty.notify_one();
}

private:
std::list<T> m_queue;
std::mutex m_mutex;
std::condition_variable m_notEmpty;
std::condition_variable m_notFull;
int m_maxSize;
bool m_needStop; //stop flag
};

Take函数先创建一个unique_lock获取,然后再通过条件变量m_notEmpty来等待判断式,判断式由两个条件组成,一个是停止的标志,另一个是不为空的条件,当不满足任何一个条件时,条件变量会释放mutex并将线程置于waiting状态,等待其他线程调用notify_one/notify-all将其唤醒;当满足任何一个条件时,则继续往下执行后面的逻辑,即将队列中的任务取出,并唤醒一个正处于等待状态的添加任务的线程去添加任务。当处于waiting状态的线程被或notify_all唤醒时,条件变量会先重新获取mutex,然后再检查条件是否满足,如果满足,则往下执行,如果不满足,则释放mutex继续等待。

Add函数的过程与Take类似,先获取mutex,不满足条件时,释放继续等待,如果满足条件,则将新的任务插人到队列中,并唤醒取任的线程去取数据。

Stop函数先获取mutex,然后将停止标志置为true。注意,为了保证线程安全,这里需要先获取mutex,在将其标志置为之后,再唤醒所有等待的线程,因为等待的条件是m_needStop,并且满足条件,所以线程会继续往下执行。由于线程在m_needStop为true时会退出,所以所有的等待线程会相继退出。另外一个值得注意的地方是,我们把m_notFull.notify_all()放到lock_guard保护范围之外了,这里也可以将m_notFull.notify_all()放到lock_guard保护范围之内,放到外面是为了做一点优化。因为notify-one或notify-all会唤醒一个在等待的线程,线程被唤醒后会先获取mutex再检查条件是否满足,如果这时被lock_guard保护,被唤醒的线程则需要lock_guard析构释放mutex才能获取。如果在lock_guard之外notify_one或notify_all,被唤醒的线程获取锁的时候不需要等待lock-guard释放锁,性能会
好一点,所以在执行notify-one或notify-all时不需要加锁保护。

线程池:
一个完整的线程池包括三层:同步服务层、排队层和异步服务层,其实这也是一种生产者一消费者模式,同步层是生产者,不断将新任务丢到排队层中,因此,线程池需要提供一个添加新任务的接口供生产者使用;消费者是异步层,具体是由线程池中预先创建的线程去处理排队层中的任务。排队层是一个同步队列,它内部保证了上下两层对共享数据的安全访问,同时还要保证队列不会被无限制地添加任务导致内存暴涨,这个同步队列将使用上一节中实现的线程池。另外,线程池还要提供一个停止的接口,让用户能够在需要的时候停止线程池的运行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
const int MaxTaskCount = 100;
class ThreadPool
{
public:
using Task = std::function<void()>;

ThreadPool(int numThreads = std::thread::hardware_concurrency()) :
m_taskQueue(MaxTaskCount)
{
Start(numThreads);
}

~ThreadPool(){ Stop();};
void Stop() {
std::call_once(m_once_flag,[this]{StopThreadGroup();});
}

void AddTask(Task&& task) {
m_queue.Put(std::forward<Task>(task));
}

void AddTask(const Task& task) {
m_taskQueue.Put(task);
}

std::size_t SyncQueueSize() {
return m_taskQueue.Size();
}
private:
void Start(int numThreads) {
m_running = true;
for( int i = 0;i < numThreads; ++i) {
m_threadGrop.push_back(std::make_shared<std::thread>(&ThreadPool::RunInThread,this));
}
}

void RunInThread() {
while(m_running) {
std::list<Task> list;
m_taskQueue.Take(list);

for(auto& task : list) {
if(!m_running)
return;
task();
}
}
return;
}

void StopThreadGroup() {
m_taskQueue.Stop();
m_running = false;
for(auto thread : m_threadGrop) {
if(thread)
thread->join();
}
m_threadGrop.clear();
}
private:
std::list<std::shared_ptr<std::thread>> m_threadGrop; //thread group
SyncQueue<Task> m_taskQueue;
std::atomic_bool m_running;
std::once_flag m_once_flag;
};

C++11实现一个轻量级的AOP框架

AOP(Aspect-Oriented Programming,面向方面编程),可以解决面向对象编程中的一些问题,是OOP的一种有益补充。面向对象编程中的继承是一种从上而下的关系,不适合定义从左到右的横向关系,如果继承体系中的很多无关联的对象都有一些公共行为,这些公共行为可能分散在不同的组件、不同的对象之中,通过继承方式提取这些公共行为就不太合适了。使用AOP还有一种情况是为了提高程序的可维护性,AOP将程序的非核心逻辑都“横切”出来,将非核心逻辑和核心逻辑分离,使我们能集中精力在核心逻辑上,如图所示的这种情况。

在图中,每个业务流程都有日志和权限验证的功能,还有可能增加新的功能,实际上我们只关心核心逻辑,其他的一些附加逻辑,如日志和权限,我们不需要关注,这时,就可以将日志和权限等非核心逻辑“横切”出来,使核心逻辑尽可能保持简洁和清晰,方便维护。这样“横切”的另外一个好处是,这些公共的非核心逻辑被提取到多个切面中了,使它们可以被其他组件或对象复用,消除了重复代码。

AOP把软件系统分为两个部分:核心关注点和横切关注点。业务处理的主要流程是核心关注点,与之关系不大的部分是横切关注点。横切关注点的一个特点是,它们经常发生在核心关注点的多处,而各处都基本相似,比如权限认证、日志、事务处理。AOP 的作用在于分离系统中的各种关注点,将核心关注点和横切关注点分离开来。

实现AOP的技术分为:静态织入和动态织入。静态织入一般采用抓们的语法创建“方面”,从而使编译器可以在编译期间织入有关“方面”的代码,AspectC++就是采用的这种方式。这种方式还需要专门的编译工具和语法,使用起来比较复杂。我将要介绍的AOP框架正是基于动态织入的轻量级AOP框架。动态织入一般采用动态代理的方式,在运行期对方法进行拦截,将切面动态织入到方法中,可以通过代理模式来实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
#include<memory>
#include<string>
#include<iostream>
using namespace std;
class IHello
{
public:

IHello() { }

virtual ~IHello() { }

virtualvoid Output(const string& str) {
}
};

class Hello : public IHello {
public:
void Output(const string& str) override {
cout <<str<< endl;
}
};

class HelloProxy : public IHello {
public:
HelloProxy(IHello* p) : m_ptr(p) { }

~HelloProxy() {
delete m_ptr;
m_ptr = nullptr;
}

void Output(const string& str) final {
cout <<"Before real Output"<< endl;
m_ptr->Output(str);
cout <<"After real Output"<< endl;
}

private:
IHello* m_ptr;
};


void TestProxy()
{
std::shared_ptr<IHello> hello = std::make_shared<HelloProxy>(newHello());
hello->Output("It is a test");
}

测试代码将输出:

1
2
3
Before real Output
It is a test
Before real Output

可以看到我们通过HelloProxy代理对象实现了对Output方法的拦截,这里Hello::Output就是核心逻辑,HelloProxy实际上就是一个切面,我们可以把一些非核心逻辑放到里面,比如在核心逻辑之前的一些校验,在核心逻辑执行之后的一些日志等。

要实现灵活组合各种切面,一个比较好的方法是将切面作为模板的参数,这个参数是可变的,支持1到N(N>0)切面,先执行核心逻辑之前的切面逻辑,执行完之后再执行核心逻辑,然后再执行核心逻辑之后的切面逻辑。这里,我们可以通过可变参数模板来支持切面的组合。AOP实现的关键是动态织入,实现技术就是拦截目标方法,只要拦截了目标方法,我们就可以在目标方法执行前后做一些非核心逻辑,通过继承方式来实现拦截,需要派生基类并实现基类接口,这使程序的耦合性增加了。为了降低耦合性,这里通过模板来做解耦,即每个切面对象需要提供Before(Args…)或After(Args…)方法,用来处理核心逻辑执行前后的非核心逻辑。

为了实现切面的充分解耦合,我们的切面不必通过继承方式实现,而且也不必要求切面必须具备Before和After方法,只要具备任意一个方法即可,给使用者提供最大的便利性和灵活性。实现这个功能稍微有点复杂,复杂的地方在于切面可能具有某个方法也可能不具有某个方法,具有就调用,不具有也不会出错。问题的本质上是需要检查类型是否具有某个方法,在C++中是无法在运行期做到这个事情的,因为C++像不托管语言c#或java那样具备反射功能,然而,我们可以在编译期检查类型是否具有某个方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
#define HAS_MEMBER(member)\
template<typename T, typename... Args>struct has_member_##member\
{\
private:\
template<typename U> static auto Check(int) -> decltype(std::declval<U>().member(std::declval<Args>()...), std::true_type()); \
template<typename U> static std::false_type Check(...);\
public:\
enum{value = std::is_same<decltype(Check<T>(0)), std::true_type>::value};\
};\

HAS_MEMBER(Foo)
HAS_MEMBER(Before)
HAS_MEMBER(After)

#include <NonCopyable.hpp>
template<typename Func, typename... Args>
struct Aspect : NonCopyable
{
Aspect(Func&& f) : m_func(std::forward<Func>(f)) {
}

template<typename T>
typename std::enable_if<has_member_Before<T, Args...>::value&&has_member_After<T, Args...>::value>::type Invoke(Args&&... args, T&& aspect)
{
aspect.Before(std::forward<Args>(args)...);//核心逻辑之前的切面逻辑
m_func(std::forward<Args>(args)...);//核心逻辑
aspect.After(std::forward<Args>(args)...);//核心逻辑之后的切面逻辑
}

template<typename T>
typename std::enable_if<has_member_Before<T, Args...>::value&&!has_member_After<T, Args...>::value>::type Invoke(Args&&... args, T&& aspect)
{
aspect.Before(std::forward<Args>(args)...);//核心逻辑之前的切面逻辑
m_func(std::forward<Args>(args)...);//核心逻辑
}

template<typename T>
typename std::enable_if<!has_member_Before<T, Args...>::value&&has_member_After<T, Args...>::value>::type Invoke(Args&&... args, T&& aspect)
{
m_func(std::forward<Args>(args)...);//核心逻辑
aspect.After(std::forward<Args>(args)...);//核心逻辑之后的切面逻辑
}

template<typename Head, typename... Tail>
void Invoke(Args&&... args, Head&&headAspect, Tail&&... tailAspect)
{
headAspect.Before(std::forward<Args>(args)...);
Invoke(std::forward<Args>(args)..., std::forward<Tail>(tailAspect)...);
headAspect.After(std::forward<Args>(args)...);
}

private:
Func m_func; //被织入的函数
};
template<typenameT> using identity_t = T;

//AOP的辅助函数,简化调用
template<typename... AP, typename... Args, typename Func>
void Invoke(Func&&f, Args&&... args)
{
Aspect<Func, Args...> asp(std::forward<Func>(f));
asp.Invoke(std::forward<Args>(args)..., identity_t<AP>()...);
}

实现思路很简单,将需要动态织入的函数保存起来,然后根据参数化的切面来执行Before(Args…)处理核心逻辑之前的一些非核心逻辑,在核心逻辑执行完之后,再执行After(Args…)来处理核心逻辑之后的一些非核心逻辑。上面的代码中的has_member_Before和has_member_After这两个traits是为了让使用者用起来更灵活,使用者可以自由的选择Before和After,可以仅仅有Before或After,也可以二者都有。

需要注意的是切面中的约束,因为通过模板参数化切面,要求切面必须有Before或After函数,这两个函数的入参必须和核心逻辑的函数入参保持一致,如果切面函数和核心逻辑函数入参不一致,则会报编译错误。从另外一个角度来说,也可以通过这个约束在编译期就检查到某个切面是否正确。

下面看一个简单的测试AOP的例子,这个例子中我们将记录目标函数的执行时间并输出日志,其中计时和日志都放到切面中。在执行函数之前输出日志,在执行完成之后也输出日志,并对执行的函数进行计时。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
struct TimeElapsedAspect
{
void Before(int i) {
m_lastTime = m_t.elapsed();
}

void After(int i) {
cout <<"time elapsed: "<< m_t.elapsed() - m_lastTime << endl;
}

private:
double m_lastTime;
Timer m_t;
};

struct LoggingAspect
{
void Before(int i) {
std::cout <<"entering"<< std::endl;
}

void After(int i) {
std::cout <<"leaving"<< std::endl;
}
};

void foo(int a) {
cout <<"real HT function: "<<a<< endl;
}

int main()
{
Invoke<LoggingAspect, TimeElapsedAspect>(&foo, 1); //织入方法
cout <<"-----------------------"<< endl;
Invoke<TimeElapsedAspect, LoggingAspect>(&foo, 1);

return 0;
}

使用C++开发一个轻量级的IoC容器

让对象不再直接依赖于外部对象的创建,而是依赖于某种机制,这种机制可以让对象之间的关系在外面组装,外界可以根据需求灵活地配置这种机制的对象创建策略,从而获得想要的目标对象,这种机制被称为控制反转。控制反转就是应用本身不负责依赖对象的创建和维护,而交给一个外部容器来负责。这样控制权就由应用转移到了外部容器,即实现了所谓的控制反转。IoC用来降低对象之间直接依赖产生的耦合性。

具体做法是将对象的依赖关系从代码中移出去,放到一个统一的配置文件中或者在IoC容器中配置这种依赖关系,由容器来管理对象的依赖关系。比如可以这样来初始化:

1
2
3
4
5
6
7
8
9
10
11
12
void IocSample() {
//通过IOC容器来配A和Base对象的关系
IocContainer ioc;
ioc.RegisterType<A, DerivedB>("B");
ioc.RegisterType<A, DerivedC>("C");
ioc.RegisterType<A, DerivedD>("D");

//由IoC容器去初始化A对象
A* a = ioc.Resolve<A>("B");
a->Func();
delete a;
}

在上面的例子中,我们在外面通过IoC容器配置了A和Base对象的关系,然后由IoC容器去创建A对象,这里A对象的创建不再依赖于工厂或者Base对象,彻底解耦了二者之间的关系。

IoC使得我们在对象创建上获得了最大的灵活性,大大降低了依赖对象创建时的耦合性,即使需求变化了,也只需要修改配置文件就可以创建想要的对象,而不需要修改代码了。我们一般是通过依赖注人(Dependency Injection)来将对象创建的依赖关系注人到目标类型的构造函数中。

IoC容器实际上具备两种能力,一种是对象工厂的能力,不仅可以创建所有的对象,还能根据配置去创建对象;另一种能力是可以去创建依赖对象,应用不需要直接创建依赖对象,由IoC容器去创建,实现控制反转。

IoC创建对象

因为IoC容器本质上是为了创建对象及依赖的对象,所以实现loc容器第一个要解决的问题是如何创建对象。IoC容器要创建所有类型对象的能力,并且还能根据配置来创建依赖对象。我们先看看如何实现一个可配置的对象工厂。

一个可配置的对象工厂实现思路如下:先注册可能需要创建的对象类型的构造函数,将其放到一个内部关联容器中,设置键为类型的名称或者某个唯一的标识,值为类型的构造函数,然后在创建的时候根据类型名称或某个唯一标识来查找对应的构造函数并最终创建出目标对象。对于外界来说,不需要关心对象具体是如何创建的,只需要告诉工厂要创建的类型名称即可,工厂获取了类型名称或唯一标识之后就可以创建需要的对象了。由于工厂是根据唯一标识来创建对象,所以这个唯一标识是可以写到配置文件中的,这样就可以根据配置动态生成所需要的对象了,我们一般是将类型的名称作为这个唯一标识。

类型擦除就是将原有类型消除或者隐藏。为什么要擦除类型?因为很多时候我们不关心只体类型是什么或者根本就不需要这个类型。类型擦除可以获取很多好处,比如使得程序有更好的扩展性,还能消除耦合以及消除一些重复行为,使程序更加简洁高效。下面是一些常用的类型擦除方式:

  • 通过多态来擦除类型。
  • 通过模板来擦除类型。
  • 通过某种类型容器来擦除类型。
  • 通过某种通用类型来擦除类型。
  • 通过闭包来擦除类型。

第一种类型擦除方式是最简单的,也是经常用的,通过将派生类型隐式转换成基类型,再通过基类去调用虚函数。在这种情况下,我们不用关心派生类的具体类型,只需要以一种统一的方式去做不同的事情,所以就把派生类型转成基类型隐藏起来,这样不仅可以多态调用,还使程序具有良好的可扩展性。然而这种方式的类型擦除仅是将部分类型擦除,因为基类型仍然存在,而且这种类型擦除的方式还必须继承这种强耦合的方式。正是因为这些缺点,通过多态来擦除类型的方式有较多局限性,并且效果也不好。这时通过第二种方式来擦除类型,可以以解决第一种方式的一些问题。通过模板来擦除类型,本质上是把不同类型的共同行为进行了抽象,这时不同类型彼此之间不需要通过继承这种强耦合的方式去获得共同的行为,仅仅是通过模板就能获取共同行为,降低了不同类型之间的耦合,是一种很好的类型擦除方式。然而,第二种方式虽然降低了对象间的耦合,但是还有一个问题没解决,就是基本类型始终需要指定,并没有消除基本类型,例如,不可能把一个T本身作为容器元素,必须在容器初始化时指定T为某个具体类型。

有时,希望有一种通用的类型,可以让容器容纳所有的类型,作为所有类型的基类,可以当作一种通用的类型。之前实现的Variant类可以把不同的类型抱起来,获得一种统一的类型,而且不同类型之间没有耦合关系。比如,可以通过Variant这样来擦除类型:

1
2
3
4
5
//定义通用的类型,这个类型可能容纳多种类型
typedef Variant<double, int, uint32_t, char*>Value;
vt.pushback(l);
vt.pushback("test");
vt.pushback(1.22);

上面的代码擦除了不同类型,使得不同的类型都可以放到一个容器中了,如果要取出来就很简单,通过Get()就可以获取对应类型的值。这种方式是通过类型容器把类型包起来了,从而达到类型擦除的目的。这种方式的缺点是通用的类型必须事先定义好,它只能容
纳声明的那些类型,是有限的,超出定义的范围就不行了。

通过某种通用类型来擦除原有类型的方式可以消除这个缺点,这种通用类型就是Any类型,下面介绍怎么用Any来擦除类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
vector<Any> v;
v.pushback(1);
v.pushback("test");
v.pushback(2.35);
auto r1 = v[0].AnyCast<int>();
auto r2 = v[1].AnyCast<const char*>();
auto r3 = v[2].AnyCast<double>();

Any a = 1;
if(a.Is<int>()) {
int I = a.AnyCast<int>();
}

在上面的代码中,不需要预先定义类型的范围,允许任何类型的对象都赋值给Any对象,消除了Variant类型只支持有限类型的问题,但是Any的缺点是:在取值的时候仍然需要具体的类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
#include <string>
#include <map>
#include <memory>
#include <functional>
using namespace std;
#include <Any>
#include<NonCopyable>

class IocContainer {
public:
IocContainer(void){}
~IocContainer(void){}

template <class T, typename Depend>
void RegisterType(const string& strKey) {
std::function<T*()> function = []{ return new T(new Depend());};
RegisterType(strKey, function);
}

template <class I>
I* Resolve(string strKey)
{
if (m_creatorMap.find(strKey) == m_creatorMap.end())
return nullptr;

Any resolver = m_creatorMap[strKey];
std::function<I* ()> function = resolver.AnyCast<std::function<I*()>>();
return function();
}

template <class I>
std::shared_ptr<I> ResolveShared(const string& strKey)
{
auto b = Resolve<I>(strKey);
return std::shared_ptr<I>(b);
}
private:
void RegisterType(const string& strKey, Any constructor)
{
if (m_creatorMap.find(strKey) != m_creatorMap.end())
throw std::logic_exception("this key has already exist!");

m_creatorMap.insert(make_pair(strKey, constructor));
}

private:
unordered_map<string, Any> m_creatorMap;
};
int main() {
IocContainer ioc;
ioc.RegisterType<A, DerivedB>("B");
ioc.RegisterType<A, DerivedC>("C");
ioc.RegisterType<A, DerivedD>("D");

auto pa = ioc.ResolveShared<A>("B");
pa->Func();
}

这样仍然不太方便,但是可以改进,可以借助闭包,将一些类型信息保存在闭包中,闭包将类型隐藏起来了,从而实现了类型擦除的目的。由于闭包本身的类型是确定的,所以能放到普通的容器中,在需要的时候从闭包中取出具体的类型。下面看看如何通过闭包来擦除类型,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
template<typename T>
void Func(T t) {
cout<<t<<endl;
}
void TestErase() {
int x = 1;
char y = 's';

vector<std::function<void()>> v;
v.push_back([x]{Func(x);});
v.push_back([y]{Func(y);});

for(auto item : v) {
item();
}
}

最后的可变参数模板改进IoC容器,支持带参数对象的创建。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
#include <string>
#include <map>
#include <memory>
#include <functional>
using namespace std;
#include <Any>
#include<NonCopyable>

class IocContainer : NonCopyable
{
public:
IocContainer(void){}
~IocContainer(void){}

template <class T, typename Depend, typename ... Args>
void RegisterType(const string& strKey)
{
std::function<T*(Args...)> function = [](Args... args){ return new T(new Depend(args...));};
RegisterType(strKey, function);
}

template <class T, typename ... Args>
I* Resolve(const string& strKey, Args ... args)
{
if (m_creatorMap.find(strKey) == m_creatorMap.end())
return nullptr;

Any resolver = m_creatorMap[strKey];
std::function<T*(Args...)> function = resolver.AnyCast<std::function<T*(Args...)>>();
return function(args...);
}

template <class I, typename... Args>
std::shared_ptr<I> ResolveShared(const string& strKey, Args... args)
{
I* i = Resolve<I>(strKey, args...);
return std::shared_ptr<I>(i);
}

private:
void RegisterType(const string& strKey, Any constructor)
{
if (m_creatorMap.find(strKey) != m_creatorMap.end())
throw std::logic_exception("this key has already exist!");

m_creatorMap.emplace(strKey, constructor);
}

private:
unordered_map<string, Any> m_creatorMap;
};

int main() {
IocContainer ioc;
ioc.RegisterType<A, DerivedC>("C");
auto c = ioc.ResolveShared<A>("C");

ioc.RegisterType<A, DerivedB, int, double>("C");
auto b = ioc.ResolveShared<A>("C", 1, 2.0);
b->Func();
}

类型注册分成三种方式注册,一种是简单方式注册,它只需要具体类型信息和key,类型的构造函数中没有参数,从容器中取也只需要类型和key;另外一种简单注册方式需要接口类型和具体类型,返回实例时,可以通过接口类型和key来得到具体对象;第三种是构造函数中带参数的类型注册,需要接口类型、key和参数类型,获取对象时需要接口类型、key和参数。返回的实例可以是普通的指针也可以是智能指针。需要注意的是key是唯一的,如果不唯一,会产生一个断言错误,推荐用类型的名称作为key,可以保证唯一性,std::string strKey = typeid(T).name()。

让自己习惯C++

视C++为一个语言联邦

  1. C语言
  2. 面对对象:构造函数、析构函数、封装、继承、多态、virtual函数
  3. C++模板:template metaprogramming
  4. STL容器:对容器、迭代器、算法以及函数对象的规约有极佳的紧密配合与协调

尽量以const,enum,inline替换#define

const的好处:

  • define直接常量替换,出现编译错误不易定位(不知道常量是哪个变量)
  • define盲目的将宏名替换,导致目标码出现多份
  • define没有作用域,const有作用域提供了封装性

定义常量指针:有必要将指针(而不只是指针所指之物)声明为const:

1
const char* const authorName = "Scott Meyers"

enum的好处:

  • 提供了封装性
  • 编译器肯定不会分配额外内存空间(其实const也不会)

inline的好处:

  • define宏函数容易造成误用(下面有个例子)
1
2
3
4
5
#define MAX(a, b) a > b ? a : b

int a = 5, b = 0;
MAX(++a, b) //a++调用2次
MAX(++a, b+10) //a++调用一次

使用template inline 函数:

1
2
3
4
5
template<typename T>
inline void callWithMax(const T& a, const T& b)
{
f(a > b ? a : b)
}

对单纯常量,最好以const对象或enums替换#define
形似函数的宏,最好改用inline函数替换#define

宏实现工厂模式

需要一个全局的map用于存储类的信息以及创建实例的函数
需要调用全局对象的构造函数用于注册

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
using namespace std;

typedef void *(*register_fun)();

class CCFactory{
public:
static void *NewInstance(string class_name){
auto it = map_.find(class_name);
if(it == map_.end()){
return NULL;
}else
return it->second();
}
static void Register(string class_name, register_fun func){
map_[class_name] = func;
}
private:
static map<string, register_fun> map_;
};

map<string, register_fun> CCFactory::map_;

class Register{
public:
Register(string class_name, register_fun func){
CCFactory::Register(class_name, func);
}
};

#define REGISTER_CLASS(class_name); \
const Register class_name_register(#class_name, []()->void *{return new class_name;});

尽可能使用const

const指定一个语义约束,编译器会强制实施这项约束。可以用const在class外部修饰global或namespace作用域中的常量,可以指出指针自身、指针所指物,或者两者都是const。

  • char greeting[] = "hello"
  • char* p = greeting:non-const pointer,non-const data
  • const char* p = greeting:non-const pointer,const data
  • char* const p = greeting:const point,non-const data
  • const char* const p = greeting:const pointer,const data

如果关键字const出现在星号左边,表示被指物是常量;如果出现在星号右边,表示指针自身是常量。
STL迭代器系以指针为根据塑模出来,所以迭代器的作用也像是T*指针,声明迭代器为const表示这个迭代器不得指向不同的东西,但它所指的东西的值是可以改动的。

1
2
3
const std::vector<int>::iterator iter = vec.begin()
可以:*iter=10
不可以:++iter

  • const定义接口,防止对返回值误用
  • const成员函数,代表这个成员函数承诺不会改变对象值,可以操作const对象
  • 两个函数如果只是常量值不同,可以被重载
1
2
3
4
5
6
7
8
9
10
11
class CTextBlock {
public:
char& operator[](std::size_t position) const
{ return pText[position]; }
private:
char* pText;
}

const CtextBlock cctb("Hello");
char* pc = &ccb[0];
*pc = 'C'

这个class不适当的将其operator[]声明为const成员函数,但是该函数却返回一个reference指向对象内部值。
上述代码调用了const成员函数,但是允许修改值。

const和non-const成员函数中避免重复

常量性转除:将常量性消除掉,比如const operator[]实现了non-const版本的一切,唯一不同是其返回类型多了一个const资格修饰。转除的方法如下:

1
2
3
char& operator[] (std::size_t position) {
return const_cast<char&>(static_cast<const TextBlock&>(*this)[position]);
}

这份代码有两个转型动作,让non_const operator[]调用其const兄弟,而且明确指出调用的是const operator[],因此第一次为*this添加const,第二次从const operator[]返回值中移除const。

如果在const函数中调用了non-const函数,则打破了不改变其对象的承诺。

const成员只能调用const成员函数(加-fpermissive编译选项就可以了)。
非const成员可以调用所有成员函数

确定对象使用前已被初始化

永远在使用对象之前将其初始化。
对于无任何成员的内置类型,需要在定义时初始化,C++不保证初始化它们。

至于内置类型之外的其他,初始化责任落在构造函数上,C++规定,对象的成员变量的初始化动作发生在进入构造函数本体之前,因此最好使用初始化序列(序列顺序与声明顺序相同),而不是在构造函数中赋值。

1
2
3
4
5
6
7
8
9
ABEntry::ABEntry(const std::string& name, 
const std::string& address,
const std::list<PhoneNumber>& phones)
: theName(name),
theAddress(address),
thePhones(phones),
numTimesConsulted(0)
{
}

这个版本的构造函数效率较高,基于赋值的构造函数首先调用default构造函数为theName,theAddress等设初值,然后再对他们赋值,成员初值列的做法避免了这一问题。

如果有的变量是const或static的,就一定要赋初值,使用初值列,最简单的做法是使用初值列,又比赋值更为高效。

C++有着固定的成员初始化次序,base calss总是早于其derived class被初始化,而class的成员变量总是以其声明次序被初始化。

不同编译单元内定义的non-local static对象的初始化次序

static对象,其寿命从被构造出来直到程序结束为止,这种对象包括global对象,定义于namespace作用域内的对象,在class内、在函数内被声明为static的对象。函数内的static对象称为local static对象,其他的是non-local static对象。

编译单元是指产出同一目标文件的源码,基本上是单一源码文件加上其所含入的头文件。

如果某编译单元内的某个non-local static对象的初始化动作使用了另一编译单元内的某个non-local static对象,它所用到的这个对象可能未被初始化。

C++对不同编译单元内定义的non-local static对象的初始化次序并无规定。

将每个non-local static对象放入一个函数,该对象在函数中被声明为static,这些函数返回一个reference指向它所含的对象,因为C++保证函数内的local static对象会在“函数被调用期间”“首次遇上该对象之定义式”时被初始化。(Singleton模式)

1
2
3
4
Fuck& fuck(){
static Fuck f;
return f;
}

构造/析构/赋值运算

了解C++默默编调用了哪些函数

如果类中没有定义,程序却调用了,编译器会产生一些函数(public且inline):

  • 一个 default 构造函数
  • 一个 copy 构造函数
  • 一个 copy assignment 操作符
  • 一个析构函数(non virtual)

default构造函数和析构函数主要是给编译器一个地方用来放置“藏身幕后”的代码,编译器产生的析构函数时non-virtual函数。至于copy和copy assignment函数,单纯将来源对象的每一个non-static成员变量拷贝到目标对象。

如果要在一个内含reference成员的class内支持赋值操作,则必须自己定义一个copy assignment操作,因为reference不能随意的重新赋值。因此,含有引用成员变量或者const成员变量不产生赋值操作符。

如果自己构造了带参数的构造函数,编译器不会产生default构造函数。

base class如果把拷贝构造函数或者赋值操作符设置为private,则不会产生这两个函数。

1
2
3
4
5
class Fuck{
private:
std::string& str;//引用定义后不能修改绑定对象
const std::string con_str;//const对象定义后不能修改
};

若不想使用编译器自动生成的函数,就该明确拒绝

将默认生成的函数声明为private,由明确声明一个成员函数,阻止编译器自动生成。

1
2
3
4
5
class Uncopyable{
private:
Uncopyable(const Uncopyable&);
Uncopyable& operator= (const Uncopyable&);
}

为多态基类声明virtual析构函数

当derived class对象经由一个base calss指针被删除,而该base class自带一个non-virtual析构函数,其结果未有定义,实际执行时通常发生的是对象的derived成分未被删除,而derived class的析构函数也未被执行。

因此给多态基类应该主动声明virtual析构函数。非多态基类,没有virtual函数,不要声明virtual析构函数。

欲实现出virtual函数,对象必须携带某些信息用来在运行期决定那一个virtual函数被调用,通常是由一个vptr指针指出,它指向一个由函数指针构成的数组,成为vtbl,每一个带有virtual函数的class都有一个vtbl。

如果class中带有virtual函数,则对象的体积会增加,因此当class内至少一个virtual函数,才为它声明virtual析构函数。

pure virtual函数导致abstract class——也就是不能被实体化的class。为希望成为抽象的那个class提供一个pure virtual析构函数,并为析构函数提供一份定义。

析构函数的运作:最深层派生的那个class其析构函数最早被调用,然后是其每一个base calss的析构函数被调用。

别让异常逃离析构函数

构造函数可以抛出异常,析构函数不能抛出异常。

因为析构函数有两个地方可能被调用。一是用户调用,这时抛出异常完全没问题。二是前面有异常抛出,正在清理堆栈,调用析构函数。这时如果再抛出异常,两个异常同时存在,异常处理机制只能terminate()。

构造函数抛出异常,会有内存泄漏吗?
不会!

1
2
3
4
5
6
7
8
try {
// 第二步,调用构造函数构造对象
new (p)T; // placement new: 只调用T的构造函数
}
catch(...) {
delete p; // 释放第一步分配的内存
throw; // 重抛异常,通知应用程序
}

绝不在构造和析构过程中调用virtual函数

derived calss对象的base class成分会在derived class自身成分被构造之前先妥善构造,如果在构造base class成分之后即调用virtual function,则这个virtual function指向的可能是base class中的function,不是derived class中的function,即在base class构造期间,virtual函数不是virtual函数。

构造和析构过程中,虚表指针指向的虚表在变化。调用的是对应虚表指针指向的函数。

一种可行的做法是:在base class中将函数改为non-virtual函数,然后要求derived class构造函数传递必要信息给base class构造函数,而后那个构造函数会安全地调用non-virtual的函数。

令operator= 返回一个reference to *this

连锁赋值:赋值操作符必须返回一个reference指向操作符的左侧实参。

1
2
3
Widget& operator=(const Widget& rhs) {
return *this;
}

在operator= 里处理自我赋值

传统做法是借由operator=最前面的一个“证同测试”达到“自我赋值”的检验目的

1
2
3
4
5
6
7
Widget& Widget::operator== (const Widget& rhs){
if(this == &rhs) return *this

delete pb;
pb = new Bitmap(*rhs.pb);
return *this;
}

或者使用copy and swap技术:
1
2
3
4
5
Widget& Widget::operator== (const Widget& rhs) {
Widget temp(rsh);
swap(temp); // 将this同上述复件的副本交换
return *this;
}

其原理是某class的copy assignment操作符可能被声明为“以by value的方式接受实参”;以by value方式传递东西会生成一份复件

复制对象时务忘其每一个成分

记得实现拷贝构造函数和赋值操作符的时候,调用base的相关函数
可以让拷贝构造函数和赋值操作符调用一个共同的函数,例如init()
如果为derived class撰写copying 函数,必须也很小心地复制其base class成分,应该让derived class的copying函数调用相应的base class。

资源管理

以对象管理资源

为了确保资源总是被释放,需要将资源放进对象内,当控制流离开函数,对象的析构函数将自动释放那些资源,这实际上是依赖了C++的“析构函数自动调用机制”。
auto_ptr正是用于在控制流离开函数时释放对象用的,其析构函数自动对其所指的对象调用delete。

1
2
3
void f() {
std::auto_ptr<Investment> pInv(createInvestment());
}

  • 获得资源后立刻放进管理管理对象,createInvestment()返回的资源被当作其管理者auto_ptr的初值,实际上“以对象管理资源”的观念被称为“资源取得时机便是初始化时机(RAII)”
  • 管理对象运用析构函数确保资源被释放。不论控制流如何离开函数,一旦对象被销毁其析构函数自然会被调用,于是资源被释放。
  • 别让多个auto_ptr同时指向同一对象,这样的话对象会被删除一次以上。所以它并不是管理动态分配资源的利器。

auto_ptr的替代方案是“引用计数型智慧指针(RCSP)”,持续追踪共有多少对象指向某笔资源,并在无人指向它时自动删除该对象,类似垃圾回收,但是无法打破环状引用。

shared_ptr是RCSP

1
std::tr1::shared_ptr<Investment> pInv(createInvestment());

auto_ptr和tr1::shared_ptr两者都在其析构函数内做delete而不是delete[]动作,在动态分配而得的array身上使用auto_ptr或tr1::shared_ptr不可以,还是使用vector或者string吧。

在资源管理类小心copy行为

常见的RAII对象copy行为:

  • 禁止copy:可以将copying操作声明为private
  • 引用计数:保有资源直到它的最后一个使用者被销毁

tr1::shared_ptr允许指定所谓的“删除器”,那是一个函数或函数对象,当引用次数为0时便被调用。

  • 深度复制:复制资源管理对象也要复制其包覆的资源
  • 转移底部资源拥有权:某些场景下可能希望确保永远只有一个RAII对象指向一个未加工资源,即使RAII对象被复制之后依然如此。

  • 复制RAII对象必须一并赋值它所管理的资源,所以资源的copying行为决定RAII对象的copying行为。

  • 普遍而常见的RAII class copying行为是:抑制copying,实行引用计数法。

在资源管理类中提供对原始资源的访问

如果需要一个Investment*指针,但是函数返回一个tr1::shared_ptr对象,则需要一个函数将RAII class对象转换为其所含的原始资源。

  • 提供显示调用接口:auto_ptr和tr1::shared_ptr都提供一个get成员函数,用来执行显式转换。
  • 提供隐式转换接口(不推荐):auto_ptr和tr1::shared_ptr也重载了指针取值操作符(operator->operator*

成对使用new和delete要采用相同的格式

当使用new时,两件事发生:内存被分配出来,针对此内存会有多个构造函数被调用。当使用delete时,也有两件事发生:针对此内存会有一个或多个析构函数被调用,然后内存被释放。

分清即将被释放的内存是单一对象还是对象数组?即保证new和delete对应;new []和delete []对应。

1
2
3
4
5
//在分配的内存块前面还分配了4个字节代表数组的个数
int *A = new int[10];

//在分配的内存块前面分配了8个字节,分别代表对象的个数和Object的大小
Object *O = new Object[10];

以独立的语句将newd对象置入智能指针

1
2
int priority();
void processWidget(std::tr1::shared_ptr<Widget> pw, int priority);

processWidget(new Widget, priority())函数中,tr1::shared_ptr需要一个原始指针,但是该构造函数是个explicit构造函数,无法进行隐式转换,将得自new Widget的原始指针转换为processWidget所要求的tr1::shared_ptr。可以写成这样:
processWidget(std::tr1::shared_ptr<Widget>(new Widget), priority())

但是在调用processWidget之前,需要做以下三件事:

  • 调用priority()
  • 执行new Widget
  • 调用tr1::shared_ptr构造函数

万一对priority调用导致异常,new Widget返回的指针会遗失,因为它尚未被置入tr1::shared_ptr内。避免这类问题只需要使用分离语句:

  • std::tr1::shared_ptr<Widget> pw(new Widget)
  • processWidget(pw, priority())

设计与声明

让接口容易被正确使用,不易被误用

好的接口很容易被正确使用,不容易被误用。努力达成这些性质(例如 explicit关键字)
明智而审慎地导入新类型对预防“接口被误用”有奇效。例如,一年只有12个有效月份,因此class Month应该反应这一事实,办法之一是利用enum表现月份,或者预先定义所有有效的Month:

1
2
3
4
5
6
7
8
9
10
11
12
class Month {
public:
static Month Jan() { return Month(1); }
static Month Feb() { return Month(2); }
static Month Mar() { return Month(3); }
...
static Month Dec() { return Month(12); }
private:
explicit Month(int m);
...
};
Date d(Month::Mar(), Day(30), Year(1995))

tr1::shared_ptr提供地某个构造函数接受两个实参,一个是被管理的指针,一个是引用次数变为0的时候将被调用的“删除器”,这启发我们创建一个null tr1::shared_ptr并以某函数变为其删除器。

“促进正确使用”的办法包括接口的一致性,以及与内置类型的行为兼容
“防治误用”包括建立新类型,限制类型上的操作,束缚对象值,以及消除用户的资源管理责任
shared_ptr支持定制deleter,需要灵活使用

设计class犹如设计type

  • 新type的对象应该如何被创建和销毁?构造函数和析构函数应该好好设计
  • 对象的初始化和赋值应该有什么区别?
  • 新type的对象如果被pass-by-value该如何?
  • 什么是新type的合法值?维护约束条件
  • 新type需要配合某个继承图系么?如果继承自某些既有的class,就需要受到那些class设计的限制,特别是受到“他们的函数是virtual或者non-virtual的”
  • 新type需要什么样的转换?是否需要在class T1内写一个class T2的类型转换函数

宁以pass-by-refrence-to-const替换pass-by-value

缺省情况下C++以by-value的方式传递对象到函数,除非另外指定,否则参数都是以实际实参的复件为初值。
尽量以pass-by-reference-to-const替换pass-by-value,比较高效,无需调用额外的copy构造函数或者构造函数/析构函数,加入了const也避免了可能的修改。

避免切割问题:当一个derived class对象以by-value的方法传递并被视为一个base class对象,调用base class的构造函数使得derived class的特性被切割,pass-by-refrence-to-const避免了这一问题。

references往往以指针的形式实现,因此pass-by-refrence-to-const真正传递的是指针。pass-by-value比pass-by-refrence-to-const效率高些,尤其是对内置类型而言。

以上规则并不适用内置类型,以及STL迭代器,和函数对象。它们采用pass-by-value更合适(其实采用pass-by-reference-to-const也可以)

必须返回对象时,别妄想返回其reference

如果定义一个local变量,就是在stack上,不要返回pointer或者reference指向一个on stack对象,在函数返回时就被析构。
不要返回pointer或者reference指向一个on heap对象(需要用户delete,我觉得必要的时候也不是不可以)
不要返回pointer或者reference指向local static对象,却需要多个这样的对象(static只能有一份)

让诸如operator*这样的函数返回reference,只是浪费时间吧。

一个必须返回新对象的函数的正确写法:让那个函数返回一个新对象,例如:

1
2
3
inline const Rational operator*(const Rational &lhs, const Rational &rhs) {
return Rational(lhs.n*rhs.n, lhs.d*rhs.d);
}

当然,这样需要承受构造成本和析构成本。

将成员变量申明为private

切记将成员变量申明为private,使用getter和setter实现对private变量的操作,将成员变量隐藏在函数接口的背后。
protected并不比public更有封装性(用户可能继承你的base class)

宁以non-member,non-friend替换member

作者说多一个成员函数,就多一分破坏封装性,好像有点道理,但是我们都没有这样遵守。直接写member函数方便一些。
面向对象守则要求,数据以及操作数据的那些函数应该捆绑在一起,这意味着建议member函数是合适的,但是提供non-member函数可允许对相关机能有更好的封装性。

若所有参数都需要类型转换,请为此采用non-member函数

如果调用member函数,就使得第一个参数的类失去一次类型转换的机会。
当实现一个Rational类时,(构造函数刻意不为explicit,允许int-to-Rational的隐式转换。

1
2
3
4
5
6
7
8
9
10
class Rational {
public:
Rational(int numerator = 0,
int denominator = 1);
int numerator() const;
int denominator() const;
}

Rational oneEight(1, 8), oneHalf(1, 2);
Rational result = oneHalf * oneEight; // 正确

如果希望能实现混合运算,即:
1
2
result = oneHalf * 2; // 正确
result = 2 * oneHalf; // 错误

上述两式变成:
1
2
result = oneHalf.operator*(2);
result = 2.operator*(oneHalf);

这里第二个式子之所以会出错,是因为发生了隐式类型转换,编译器知道正在传递一个int,但是函数需要的是Rational,而且它也知道只要调用Rational的构造函数并赋予所提供的int即可,但是这样是不对的。

只有当参数被列于参数列表,这个参数才是隐式类型转换的合格参与者。让operator*成为一个non-member函数,允许在每一个实参上执行隐式类型转换。

考虑写一个不抛出异常的swap函数

std::swap置换两对象值,只要类型T支持copying(通过copy构造函数和copy assignment操作符完成)缺省的swap代码就会帮你置换类型为T的对象。

一种方法是“以指针指向一个对象,内含真正数据”,一旦要置换两个对象值,唯一要做的事置换其指针,但缺省的swap函数不知道这一点,将swap函数针对该类特化。

1
2
3
4
5
6
namespace std {
template<>
void swap<Widget>(Widget& a, Widget& b) {
swap(a.pImpl, b.pImpl);
}
}

函数一开始的template<>表示它是std::swap的一个全特化版本,函数名称后的代表针对这一类特化。

当std::swap效率不高(std::swap调用拷贝构造函数和赋值操作符,如果是深拷贝,效率不会高),提供一个swap成员函数,并确定不会抛出异常。

1
2
3
4
5
6
7
8
9
10
class Obj{
Obj(const Obj&) {
//深拷贝
}
Obj& operator= (const Obj&) {
//深拷贝
}
private:
OtherClass *p;
};

如果提供一个member swap做置换工作,然后将std::swap特化,令他调用该函数
1
2
3
4
5
6
7
8
9
10
11
12
13
class Widget {
public:
void swap(Widget& other) {
std::swap(pImpl, other.pImpl);
}
};

namespace std {
template<>
void swap<Widget> (Widget& a, Widget b) {
a.swap(b);
}
}

调用swap时应该针对std::swap使用using声明式,然后调用swap不带任何”命名空间修饰”
1
2
3
4
5
6
void doSomething(Obj& o1, Obj& o2){
//这样可以让编译器自己决定调用哪个swap,万一用户没有实现针对Obj的swap,还能调用std::swap
using std::swap;

swap(o1, o2);
}

如果swap缺省实现的效率不足,则:

  1. 提供一个public swap成员函数,让它高效地置换那个类型的两个对象值;
  2. 在你的class或namespace所在的命名空间中提供一个non-member swap,并令它调用上述swap成员函数;
  3. 如果正在编写一个class,为class特化一个std::swap,并令他调用你的swap成员函数;
  4. 如果调用swap,请确定包含一个using声明式,以便让std::swap在你的函数内曝光可见,然后不加任何namespace修饰符,赤裸裸的调用swap;
  5. 成员版swap不可抛出异常。

实现

尽可能延后变量定义式出现的时间

C语言推荐在函数开始的时候定义所有变量(最开始的C语言编译器要求,现在并不需要),C++推荐在使用对象前才定义对象,尽量延后变量的定义,直到确实需要它,避免没有用到这个变量但是却承担了构造和析构成本。
不止延后到真正使用这个变量,而且要延后到能够给这个变量一个初值实参为止,如果这样,不仅能避免构造和析构非必要对象,还能避免无意义的default构造行为。

尽量少做转型动作

转型的语法:

旧式转型:

  • (T)expression
  • T(expression)

新式转型:

  • const_cast (expression):用来将对象的常量性移除;
  • dynamic_cast (expression):执行安全向下转型,用来决定对象是不是归属继承体系的某个类型;
  • reinterpret_cast (expression):低级转型,例如将一个pointer to int转型为一个int;
  • static_cast (expression):强迫隐式转换,例如将non-const转为const,将int转为double等,但无法将const转为non-const。

例子:

1
2
Derived d;
Base* pb = &d;

这里建立一个base calss指针指向一个derived class对象,但是有时候上述两个指针并不相同,这时会有一个偏移量在运行期被施加到Derived指针上,用以取得正确的Base指针。因此,单一对象可能拥有一个以上的指针。

如果想要在子类中执行父类的函数,可以如下:

1
2
3
4
5
6
class SpecialWindow : public Window {
public:
virtual void onResize() {
Window::onResize();
}
}

如果可以,尽量避免转型,特别是在注重效率的代码中避免dynamic_cast。
之所以需要dynamic_cast是因为想要在一个你认为是derived class对象身上执行derived class操作函数,但是你手上只有一个指向base的pointer,有两个一般的方法可以解决这个问题:

  1. 使用容器并在其中直接存储指向derived class对象的指针,如此便消除了通过base class接口处理对象的需要。
  2. 在base class中提供virtual函数做你想对各个派生类做的事。
  • 如果转型是必要的,试着将它隐藏于某个函数后。客户可以随时调用该函数,而不需要将转型放入自己的代码。
  • 使用C++风格的转型。

避免返回handles指向对象内部成分

成员变量的封装性最多等于“返回其reference的函数”的访问级别。
如果const成员函数传出一个reference,后者所指数据与对象自身有关联,而它又被存储于对象之外,那么这个函数的调用者可以修改那笔数据。
简单说,就是成员函数返回指针或者非const引用不要指向成员变量,这样会破坏封装性

为“异常安全”而努力是值得的

当异常被抛出时,异常安全性函数会:

  • 不泄露任何资源
  • 不允许数据破坏

“异常安全函数”承诺即使发生异常也不会有资源泄漏。在这个基础下,它有3个级别

  • 基本保证:抛出异常,程序内的任何事物仍然保持在有效状态下,没有对象或数据结构会被破坏,所有对象处于一种内部前后一致的状态。需要用户处理程序状态改变(自己写代码保证这个级别就行了把)
  • 强烈保证:抛出异常,程序状态不改变,如果函数失败,程序状态恢复到调用前;
  • 不抛异常:承诺绝不抛出异常,因为他们总是能完成原先承诺的任务。内置类型的操作就绝不会抛出异常
1
2
3
4
5
6
7
8
class PrettyMenu {
std::tr1::shared_ptr<Image> bgImage;
};
void PrettyMenu::changeBackground(std::istream& imgSrc) {
Lock ml(&mutex);
bgImage.reset(new Image(imgSrc));
++imageChange;
}

上述代码使用一个用于资源管理的智能指针,重新排列了changeBackground的语句次序,使得在更换图像之后才累加imageChanges,一般而言这是个好策略,不要为了表示某件事发生而改变对象状态,除非这件事真的发生了。

另外,使用了Lock使得不需要在末尾手动unlock,在析构函数中已经自动unlock。使用智能指针也不需要再手动delete旧图像。

“强烈保证”往往可以通过copy-and-swap实现,为你打算修改的对象原件做一份副本,然后在那份副本上做修改,若有任何修改动作抛出异常,原对象仍保持未修改状态,待所有修改完成后再将修改后的副本和原对象在一个不抛出异常的操作中置换。

但是”强烈保证”并非对所有函数都具有实现意义

1
2
3
4
5
void doSomething(Object& obj){
Object new_obj(obj);
new_obj++;
swap(obj, new_obj);
}

透彻了解inline函数的里里外外

“免除函数调用成本”
当你inline某个函数,编译器或许可以对函数本体执行语境相关最优化,大部分编译器绝不会对着一个outline函数调用动作执行如此优化。
inline函数将“对此函数的每一个调用都用函数本体替换之”,这样做可能增加目标码的大小,即使拥有虚内存,inline造成的代码膨胀亦会造成额外的换页行为,降低指令高速缓存的命中率,以及伴随而来的效率损失。

inline只是对编译器的一个申请而不是强制命令。这项申请可以隐喻指出,也可以明确提出。隐喻方式是将函数定义于class定义式内:

1
2
3
4
5
6
class Person {
public:
int age() const {return theAge; }
private:
int theAge;
}

明确声明inline的做法则是在其定义式前加上关键字inline:
1
2
3
4
template<typename T>
inline const T& std::max(const T& a, const T& b) {
return a < b ? a : b;
}

inline函数通常被定义在头文件中,因为大多数build环境在编译过程中进行inlining,而为了将一个“函数调用”替换为“被调用函数本体”,编译器必须知道那个函数长啥样,某些build环境可以在链接的时候完成inline。
大部分编译器拒绝将太过复杂的函数inlining,而所有对virtual函数的调用都会使inline落空。
一个表面上看似inline的函数是否真的inline,取决于你的编译环境,主要取决于编译器。
构造函数和析构函数如果inline的话很麻烦。
inline无法随着程序库的升级而升级,换句话说如果f是程序库内的一个inline函数,客户将f函数本体编译进代码,一旦程序库改变,所有用到f的函数都需要重新编译。如果f是non-inline函数,则只需要重新编译f就好。

这里插播一个C++处理定义的重要原则,一处定义原则:

全局变量,静态数据成员,非内联函数和成员函数只能整个程序定义一次
类类型(class,struct,union),内联函数可以每个翻译单元定义一次

将文件的编译依存关系降到最低

C++并没有将接口从实现中分离。在定义文件和其含入文件之间形成了一种编译依存关系。如果头文件中有任何一个被改变或者这些头文件依赖的任何一个头文件改变,则任何使用这个类的文件都需要重新编译。

当编译器看到一个定义式时,它必须知道要给这个定义式分配多少内存才够维持一个对象,这个问题在Java里并不存在,因为Java编译器只分配一个足够指向该对象的指针那么大的空间。

支持”编译依存最小化”的一般构想是:相依于声明式,不要相依于定义式;现实中要让头文件尽可能地自我满足,万一做不到则让它与其他文件中的声明式相依。基于此构想的两个手段是Handle classes(impl对象提供服务)和Interface classes。
其实就是使用前置声明,在main class中只有一个指针指向其实现类,这样的设计使得那些classes的修改都不需要main class重新编译。

  • 如果使用object reference或者object pointer可以完成任务,则就不要使用object
  • 如果能够,尽量以class声明式替换class定义式
  • 为声明式和定义式提供不同的头文件,当然这些文件要保持一致性。

制作handler class的办法是,令基函数成为abstract baseclass, 称为interface class,这种函数的目的是详细叙述derived class的接口,因此它通常不带成员变量,只有一个virtual析构函数和一组pure virtual函数。一个针对Person而写的interface class也许是这样的:

1
2
3
4
5
6
7
class Person {
public:
virtual ~Persion();
virtual std::string name() const = 0;
virtual std::string date() const = 0;
virtual std::string address() const = 0;
}

不可能针对“内含pure virtual函数”的Person class具现出实例。

interface class的客户必须有办法为这种class创建新对象。他们调用一个特殊函数,此函数扮演真正将被具现化的derived class的构造函数的角色,这样的函数通常称为“工厂函数”。他们返回指针,指向动态分配所得对象,而该对象支持interface class的接口,这样的函数又往往在interface class中被声明为static:

1
2
3
4
5
6
7
8
9
10
class Persion {
public:
static std::tr1::shared_ptr<Person> create(const std::string& name, const Date& birthday, const Address& addr);
}

std::string name;
Date dateOfBirth;
Address address;

std::tr1::shared_ptr<Person> pp(Person::create(name, dateOfBirth, address));

支持interface class接口的那个具象类必须被定义出来,而且真正的构造函数必须被调用。一切都在virtual构造函数实现码所在的文件内秘密发生。假设interface class Person有个具象的derived class RealPerson,后者提供继承而来的virtual函数的实现。
1
2
3
4
5
6
7
8
9
10
11
12
class RealPerson: public Person {
public:
RealPerson(const std::string& name, const Date& birthday, const Address& addr) : theName(name), theBirthDate(birthday), theAddress(addr) {}
virtual ~RealPerson();
std::string name();
std::string date();
std::string address();
private:
std::string theName;
Date theBirthDate;
Address theAddress;
}

有了RealPerson后,写出Person::create就顺理成章了。
1
2
3
std::tr1::shared_ptr<Person> Person::create(onst std::string& name, const Date& birthday, const Address& addr) {
return std::tr1::shared_ptr<Person>(new RealPerson(name, birthday, addr));
}

在handler class上,成员函数必须通过implementation pointer取得对象数据,那会为每一次访问增加一层间接性,而每一个对象消耗的内存数量必须增加。至于interface class,由于每一个函数都是virtual,必须为每次函数调用付出一个间接跳跃的成本。

下面有个需要注意的点

1
2
3
4
5
6
7
//Obj.h
class ObjImpl;
class Obj{
public:
private:
std::shared_ptr<ObjImpl> pObjImpl;
};

上面的写法会报错,因为编译器会再.h文件里面产生默认的析构函数,
析构函数要调用ObjImpl的析构函数,然后我们现在只有声明式,不能调用ObjImpl的实现。
下面的实现才是正确的
1
2
3
4
5
6
7
8
9
//Obj.h
class ObjImpl;
class Obj{
public:
//声明
~Obj();
private:
std::shared_ptr<ObjImpl> pObjImpl;
};

1
2
3
4
5
//Obj.cpp
//现在可以看到ObjImpl的实现
#include<ObjImpl>
Obj::~Obj(){
}

继承与面对对象设计

确定你的public继承塑模出is-a模型

public继承意味着is-a。适用于base class身上的每一个函数也一定适用于derived class。
令class D以public形式继承class B,便是告诉C++编译器每一个类型为D的对象同时也是一个类型为B的对象,反之不成立。

避免遮掩继承而来的名称

当位于一个derived class成员函数内指涉base class内的某物的时候,编译器可以找到我们所指涉的东西,因为derived class继承了声明于base class的所有东西。实际运作方式是derived class作用域被嵌套进base class作用域内。

如果继承base class并加上重载函数,而你又希望重新定义或覆写其中一部分,那么你必须为那些原本会被遮掩的每个名称引入一个using声明式,否则某些你希望继承的名称会被遮掩。

子作用域会遮掩父作用域的名称。一般来讲,我们可以有以下几层作用域

  • global作用域
  • namespace作用域
  • Base class作用域
  • Derived class作用域
  • 成员函数
  • 控制块作用域

注意:遮掩的是上一层作用域的名称,重载(不同参数)的函数也会直接遮掩

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Base{
public:
void f1();
}

class Drive{
public:
//会遮掩f1(),子类并没有继承f1()
void f1(int);
}

Drive d;
d.f1(); //错误
d.f1(3); //正确

可以通过using声明式或者inline转交解决这一问题
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Base{
public:
void f1();
}

//using 声明式
class Drive{
public:
using Base::f1;
void f1(int);
}

//inline转交
class Drive{
public:
void f1(){
Base::f1();
}
void f1(int);
}

区分接口继承和实现继承

public继承由两部分组成,一个是函数接口继承,一个是函数实现继承。

1
2
3
4
5
6
7
8
class Shape {
public:
virtual void draw() const = 0;
virtual void error(const std::string& msg);
int objectID() const;
};
class Rectangle: public Shape { ... };
class Ellipse: public Shape { ... };

Shape是个抽象类,它的pure virtual函数draw使它成为一个抽象类,所以只能创建其derived class的对象。draw是个纯虚函数,error是个impure virtual函数,objectID是个non-virtual函数。

pure函数必须被任何“继承了它们”的class重新声明,且它们在抽象类中没有定义,所以声明一个纯虚函数的目的是让derived class只继承函数接口。竟然可以为纯虚函数提供定义,只是在调用时要指明。

1
2
3
4
Shape* ps = new Shape;
shape* ps1 = new Rectangle;
ps1->draw();
ps1->Shape::draw();

虚函数会提供一份定义代码,derived class可以覆写它,声明虚函数的目的是让derived class继承该函数的接口和缺省实现。
继承non-virtual函数的目的是让derived class继承函数的接口和一份强制实现。

纯虚函数:提供接口继承
Drived class必须实现纯虚函数
不能构造含有纯虚函数的类

考虑virtual函数以外的选择

借由non-virtual interface实现template method模式

1
2
3
4
5
6
7
8
9
10
class Object{
public:
void Interface(){
···
doInterface();
···
}
private/protected:
virtual doInterface(){}
}

让用户通过调用public non-virtual成员函数间接调用private virtual函数。
优点:

  • 可以在调用虚函数的前后,做一些准备工作(抽出一段重复代码)
  • 提供良好的ABI兼容性
  • 没有必要让这个函数一定是private

借由Function Pointer实现Strategy模式

某个实体的某个功能函数可以在运行期变更,且同一个类的不同实体可以有不同的功能函数。

借由tr1::function完成Strategy模式

可以不再使用函数指针而是使用类型为tr1::function的对象。

聊一聊ABI兼容性

我们知道,程序库的优势之一是库版本升级,只要保证接口的一致性,用户不用修改任何代码。一般一个设计完好的程序库都会提供一份C语言接口,为什么呢,我们来看看C++ ABI有哪些脆弱性。

虚函数的调用方式,通常是 vptr/vtbl 加偏移量调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//Object.h
class Object{
public:
···
virtual print(){}//第3个虚函数
···
}

//用户代码
int main(){
Object *p = new Object;
p->print(); //编译器:vptr[3]()
}

//如果加了虚函数,用户代码根据偏移量找到的是newfun函数
//Object.h
class Object{
public:
···
virtual newfun()//第3个虚函数
virtual print(){}//第4个虚函数
···
}

name mangling 名字粉碎实现重载

C++没有为name mangling制定标准。例如void fun(int),有的编译器定为fun_int_,有的编译器指定为fun%int%。

因此,C++接口的库要求用户必须和自己使用同样的编译器(这个要求好过分)

其实C语言接口也不完美
例如struct和class。编译阶段,编译器将struct或class的对象对成员的访问通过偏移量来实现

古典策略模式

用另外一个继承体系替代

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Object{
public:
void Interface(){
···
p->doInterface();
···
}
private/protected:
BaseInterface *p;
}

class BaseInterface{
public:
virtual void doInterface(){}
}

绝不重新定义继承而来的non-virtual函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class B {
public:
void mf();
}
class D: public B {
public:
void mf();
}

D x;
B* pb = &x;
D* pd = &x;

pb->mf();
pd->mf();

上边调用的一个是B的mf(),一个是D的mf(),因为mf是在两个类中都有定义的,所以尽管都是x的指针,但是两个调用的mf不一样。non-virtual函数如B::mf()和D::mf()是静态绑定的,由于pb是一个B类的指针,通过pb调用的non-virtual函数永远是B所定义的版本。

virtual函数却是动态绑定的,所以它们不受这个问题困扰,如果mf是个virtual函数,则通过pb还是pd调用到的都是D的mf()。

绝不重新定义继承而来的缺省参数值

virtual函数是动态绑定,而缺省参数值是静态绑定

静态类型是它在程序中被声明时所采用的类型。有缺省参数值的成员函数,不可以在子类中赋予不同的缺省参数值,但是如果在子类中实现这个函数时未赋予缺省参数,则当调用时要指定参数。

1
2
3
4
5
6
7
8
9
10
11
class Shape {
public:
virtual void draw(Shapecolor color = Red) const = 0;
}

class Circle : public Shape {
public:
virtual void draw(Shapecolor color) const;
}
这么写的话当客户调用此函数,一定要指定参数。
因为静态绑定下这个函数并不从其base中继承缺省参数。

缺省参数值是静态绑定
虚函数是动态绑定
遵守这条规定防止出错

动态类型指的是目前所指对象的类型,也就是说这个对象将会有什么行为。动态类型可以在程序执行过程中改变。
我们可能在调用一个定义于derived class中的virtual函数时,使用了base class中为它指定的缺省参数值。

通过复合塑模出has-a或者”根据某物实现出”

复合是当某种类型的对象内含它种类型的对象,如,Person类中有Address类和PhoneNumber类,意味着has-a的关系。
根据某物实现出和is-a的区别:
这个也是什么时候使用继承,什么时候使用复合。复合代表使用了这个对象的某些方法,但是却不想它的接口入侵。

明智而审慎地使用private继承

private继承是根据某物实现出,如果继承关系是private的,则编译器不会自动将一个derived class对象转换为一个base class对象。
由private继承来的所有成员在derived class中都会变成private的,而不管它在base class中是何种。

1
2
3
4
5
class Empty {}
class HoldInt {
int x;
Empty e;
}

C++ 设计者在设计这门语言要求所有的对象必须要有不同的地址(C语言没有这个要求)。C++编译器的实现方式是给让空类占据一个字节。

C++裁定凡是独立的对象都要有非0的大小,所以sizeof(HoldInt) > sizeof(int),一个Empty成员竟然要一些空间。实际上这个Empty类可能会被编译器默默加上一个char,然后由于对齐的缘故要再加上一些内存成为一个int。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Base{
public:
void fun(){}
}

//8个字节
class Object{
private:
int a;
Base b;
};

//4个字节
class Object : private Base{
private:
int a;
}

唯一一个使用private继承的理由就是,可以使用空白基类优化技术,节约内存空间
1
2
3
class HoldInt : private Empty {
int x;
}

这样的话sizeof(HoldInt) == sizeof(int),这就是所谓的空白基类最优化

明智而审慎地使用多重继承

程序有可能从一个以上的基类中继承相同名字(函数,typedef等)需要明确的指出调用哪一个基类中的函数,如a.B::bbb()
首先我们来了解一下多重继承的内存布局。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//包含A对象
class A{

};
//包含A,B对象
class B:public A{

};
//包含A,C对象
class C:public A{

};
//包含A,A,B,C,D对象
class D:public B, public C{

}

由于菱形继承,基类被构造了两次。其实,C++也提供了针对菱形继承的解决方案的
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//包含A对象
class A{

};
//包含A,B对象
class B:virtual public A{

};
//包含A,C对象
class C:virtual public A{

};
//包含A,B,C,D对象
class D:public B, public C{

}

使用虚继承,B,C对象里面会产生一个指针指向唯一一份A对象。这样付出的代价是必须再运行期根据这个指针的偏移量寻找A对象。

从正确行为的观点看,public继承应该总是virtual的。规则很简单:任何时候当你使用public继承,请改用virtual public继承。但是正确性并不是唯一观点。为避免继承得来的成员变量重复,编译器必须提供若干幕后戏法,而其后果是:

  • 使用 virtual继承的那些 classes所产生的对象往往比使用 non-virtual继承的兄弟们体积大;
  • 访问 virtual base classes的成员变量时,也比访问 non-virtual base classes的成员变量速度慢;

种种细节因编译器不同而异,但基本重点很清楚:你得为 virtual继承付出代价

virtual继承的成本还包括其他方面。支配“virtual base classes初始化”的规则比起 non-virtual bases的情况远为复杂且不直观。 virtual base的初始化责任是由继承体系中的最低层( most derived) class负责,这暗示:

  1. classes若派生自 virtual bases而需要初始化,必须认知其 virtual bases-不论那些 bases距离多远;
  2. 当一个新的 derived class加入继承体系中,它必须承担其 virtual bases(不论直接或间接)的初始化责任。
  3. 如果必须使用virtual,则尽可能避免在其中放置数据

模板与泛型编程

了解隐式接口和编译期多态

接口:强制用户实现某些函数
多态:相同的函数名,却有不同的实现
继承和模板都支持接口和多态
对继承而言,接口是显式的,以函数为中心,多态发生在运行期;显式接口由函数的签名式(函数名、参数类型、返回类型)构成,
对模板而言,接口是隐式的,多态表现在template具象化和函数重载,隐式接口基于“有效表达式”组成。如:

1
2
3
4
5
6
7
//这里接口要求T必须实现operator >
template<typename T>
void doProcessing(T& w){
if (w.size() > 10 && w != someNastyWidget) {
...
}
}

T的隐式接口提供一下约束:

  • 它必须提供一个名为size的函数,该函数返回一个数字
  • 它必须支持一个operator!=函数,用来比较两个T类型的对象。

加诸于template上的隐式接口,就像加诸于class对象身上的显式接口一样真实,而且二者都在编译期完成检查。

了解typename的双重意义

声明template参数时,前缀关键字class和typename可以互换

1
2
3
template<class T> class Widget;
template<typename T> class Widget;
一致

然而C++并不总是把class和typename看作等价,
1
2
3
4
5
6
7
8
9
template<typename C>
void print2nd(const C& container) {
if (container.size() > 2) {
C::const_iterator iter(container.begin());
++iter;
int value = *iter;
std::cout<<value;
}
}

iter的类型是C::const_iterator,它的类型取决于template参数C。template内出现的名称如果相依于某个参数,则称之为从属参数;如果从属名在class内成嵌套状,则称为嵌套从属名称。如iter。

嵌套从属名称可能造成解析困难。如果C命名空间中有一个变量叫做const_iterator,则就奇怪了。因此上述代码可能会造成错误。iter声明式只在C::const_iterator是个类型时才合理,我们必须告诉C++说C::const_iterator是个类型,只要加上typename即可:

1
2
if (container.size() > 2) {
typename C::const_iterator iter(container.begin());

任何时候如果想要在template中指涉一个嵌套从属类型名称,就必须在紧邻它的前一个位置放上关键字typename
typename只被用来验明嵌套从属类型名称。
1
2
3
template<typaname C>
void f(const C& container, // 不用使用typename
typename C::iterator iter); // 需要使用typename

使用typename表明嵌套类型(防止产生歧义)

学习处理模板化基类内的名称

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class A {
public:
void sendclear(const std::string& msg);
void sendencrypted(const std::string& msg);
};
class B {
public:
void sendclear(const std::string& msg);
void sendencrypted(const std::string& msg);
};
class MsgInfo { ... };

template<typename Company>
class MsgSender {
public:
void sendclear(const MsgInfo& info) {
std::string msg;
Company c;
c.sendclear(msg);
}
void sendencryted(const MsgInfo& info) {
... }
};
1
2
3
4
5
6
7
8
9
template<typename Company>
class LoggingMsgSender: public MsgSender<Company> {
public:
void sendclear(const MsgInfo& info) {
sendClearMsg(info);
}
void sendencryted(const MsgInfo& info) {
... }
};

derived class的信息传送函数有一个不同的名称,避免了遮掩继承而来的名称。问题是,当编译器遭遇class LoggingMsgSender: public MsgSender时,不知道继承的是哪个类,不到LoggingMsgSender具现化的时候,无法确切知道它是什么。

如果有个类Z,

1
2
3
4
class Z {
public:
void sendEncrypted(const std::string& msg);
}

针对Z产生一个特化版,这既不是template也不是class,而是特化版的MsgSender template。在template实参是Z时被使用,这就是所谓的模板全特化。
1
2
3
4
5
template<>
class MsgSender<Z> {
public:
void sendSecret(const MsgInfo& info);
}

考虑derived class LoggingMsgSender,如果在derived class中调用了MsgSender中因为被特化而不存在的函数(sendclear),则可以使用如下两种方法:

  • 在base class函数调用前加上this->
  • 使用using声明式,将被掩盖的base class名称带入一个derived class中。
  • 明白指出被调用的函数在哪:MsgSender<company>::sendclear
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
template <typename T>
class Base{
public:
void print(T a) {cout <<"Base "<< a <<endl;};
};

template<typename T>
class Drive : public Base<T>{
public:
void printf(T a){

//error 编译器不知道基类有print函数
print(a);
}
};
//解决方案
//this->print();
//using Base<T>::print
//base<T>::print直接调用

将参数无关代码抽离template

避免使用template导致的代码膨胀问题,其二进制代码带着几乎重复的代码、数据,结果可能使源码看起来合身或整齐,但是目标码却不是那么回事,使用“共性与变形分析”

编写template时,把共同部分抽离。
比如:

1
2
3
4
5
template<typename T, std::size_t n>
class SquareMatrix {
public:
void invert();
}

这个template接受一个类型参数T,还接受一个类型为size_t的参数,那个是个非类型参数,这种参数和类型参数不一样,考虑:
1
2
SquareMatrix<double, 5> sm1;
SquareMatrix<double, 10> sm2;

这会具现两份代码,可以将参数5和10抽象出来:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
template<typename T>
class SquareMatrixBase {
protected:
void invert(std::size_t n);
}

template<tempname T, std::size_t n>
class SquareMatrix: private SquareMatrixBase<T> {
private:
using SquareMatrixBase<T>::invert;
public:
void invert() { this->invert(n); }
}


SquareMatrixBase只对矩阵元素对象的类型进行具象化,不对矩阵的尺寸参数化。derived class的invert调用base class版本时用的inline调用,这些函数使用this->,因为如果若不这样做,模板化基类内的函数名会被derived class掩盖。

如何知道怎么得到数据?令SquareMatrixBase贮存一个指针,指向矩阵数值所在的内存:

1
2
3
4
5
6
7
8
9
template<typename T>
class SquareMatrixBase {
protected:
SquareMatrixBase(std::size_t n, T* pMem) : size(n), pData(pMem) { }
void setDataPtr(T* ptr) { pData = ptr; }
private:
std::size_t size;
T* pData;
}

现在可以用inline的方式调用base class的函数,后者由持有同型元素的所有矩阵共享。不同大小的矩阵只拥有单一版本的invert,可减少执行文件大小,也就因此降低程序的working set,并强化指令高速缓存区的引用集中化。

在大多数平台上,所有指针类型都有相同的二进制表述,因此凡templates持有指针者(例如list<int*>list<const int*>, list<SquareMatrix<long, 3>*>等等)往往应该对每一个成员函数使用唯一一份底层实现。这很具代表性地意味,如果你实现某些成员函数而它们操作强型指针( strongly yped pointers,即T*),你应该令它们调用另一个操作无类型指针(void*)的函数,由后者完成实际工作。
某些C+标准程序库实现版本的确为 vector、deque和1ist等 templates做了这件事。如果你关心你的 templates可能出现代码膨胀,也许你会想让你的 templates也做相同的事情。

非类型模板参数造成的代码膨胀:以函数参数或者成员变量替换
类型模板参数造成的代码膨胀:特化它们,让含义相近的类型模板参数使用同一份底层代码。例如int,long, const int

运用成员函数模版接收所有兼容类型

真实指针做得好的一件事是支持隐式转换:

1
2
3
4
5
6
7
8
9
10
11
class Top { ... };
class Middle: public Top {... };
class Bottom: public Middle { ... };

Top* ptl = new Middle
//将 Middle*转换为Top*
Top* pt2 = new Bottom;
//将 Bottom*转换为Top
const Top* pct2= ptl
//将Top*转换为 const Top*


但如果想在用户自定的智能指针中模拟上述转换,稍稍有点麻烦。我们希望以下代码通过编译:
1
2
3
4
5
6
7
8
9
10
11
12
template<typename T>
class SmartPtr
public:
explicit SmartPtr(T* reality); //智能指针通常以内置(原始)指针完成初始化
};

Smartptr<Top> ptl = SmartPtr<Middle>(new Middle);
//将 SmartPtr<Middle>转换为SmartPtr<Top>
SmartPtr<Top> pt2 = SmartPtr<Bottom>(new Bottom);
//将 SmartPtr<Bottom>转换为SmartPtr<Top>
SmartPtr<const Top> pct= ptl
//将 Smartptr<Top>转换为Smartptr<const Top>

但是,同一个 template 的不同具现体之间并不存在什么与生俱来的固有关系。

Template和泛型编程

我们来考虑一下智能指针的拷贝构造函数和赋值操作符怎么实现。它需要子类的智能指针能够隐式转型为父类智能指针.
写一个构造模板,叫做member function template,其作用是为class生成函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
template<typename T>
class shared_ptr{
public:
//拷贝构造函数,接受所有能够从U*隐式转换到T*的参数
template<typename U>
shared_ptr(shared_ptr<U> const &rh):p(rh.get()){
...
}
//赋值操作符,接受所有能够从U*隐式转换到T*的参数
template<typename U>
shared_ptr& operator= (shared_ptr<U> const &rh):p(rh.get()){
...
}

//声明正常的拷贝构造函数
shared_ptr(shared_ptr const &rh);
shared_ptr& operator= (shared_ptr const &rh);
private:
T *p;
}

以上对任何类型T和U,这里可以根据类型U生成一个类型T的shared_ptr,因为shared_ptr<T>有一个构造函数可以接受一个shared_ptr<U>的参数,根据对象u创建对象t,有时称为泛化copy构造函数。

member function template也常用于赋值操作,例如TR1的shared_ptr支持所有来自兼容之内置指针、tr1::shared_ptr、auto_ptr和tr1::weak_ptr的构造行为,以及所有来自上述各对象的赋值操作。

使用成员函数模版生成“可接受所有兼容类型”的函数
即使有了“泛化拷贝构造函数”和“泛化的赋值操作符”,仍然需要声明正常的拷贝构造函数和赋值操作符
在一个类模版内,template名称可被用来作为作为“template和其参数”的简略表达式

所有参数需要类型转换的时候请为模版定义非成员函数

当我们编写一个模版类,某个相关函数都需要类型转换,需要把这个函数定义为非成员函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
template <class T>
class Rational
{
public:
Rational(const T& numerator = 0,
const T& denominator = 1);
const T numerator() const;
const T denominator() const;
...
};

template<typename T>
const Rational<T> operator* (const Rational<T>& lhs,
const Rational<T>& rhs)
{ ... }

Rational<int> oneHalf(1,2);
Rational<int> result = oneHalf * 2;

但是模版的类型推导遇见了问题,以oneHalf进行推导,并不困难,operator*的第一参数被声明为Rational<T>,而传递给operator*的第一实参的类型是Rational<int>,所以T一定是int,operator*的第二参数被声明为Rational<T>,而传递给operator*的第二实参的类型是int,无法通过隐式类型转换将2转换成Rational<int>,需要把这个函数声明为友元函数帮助推导。

class Rational声明operator*为friend,模版函数只有声明,编译器不会帮忙具现化,所以我们需要实现的是友元模版函数。friend函数作为一个函数而非函数模板,编译器可以在调用它的时候使用隐式类型转换。

1
2
3
4
5
6
7
8
9
10
11
template <class T>
class Rational
{

friend Rational operator* (const Rational& a, const Rational& b)
{
return Rational (a.GetNumerator() * b.GetNumerator(),
a.GetDenominator() * b.GetDenominator());
}

}

这项技术的一个趣味点是,我们虽然使用friend,却与friend的传统用途“访问class的non-public成分”亳不相干。为了让类型转换可能发生于所有实参身上,我们需要一个 non-member函数;为了令这个函数被自动具现化,我们需要将它声明在class内部;而在class内部声明 non-member函数的唯一办法就是令它成为一个 friend。因此我们就这样做了。

当我们编写一个class template,而它所提供的与此template相关的函数支持所有参数之隐式类型转换时,将那些函数定义为class template内部的friend函数。

请使用traits classes表现类型信息

1
2
template<typename T, typename DistT>
void advance(IterT& iter, DistT d);

advance只做iter+=d的操作,但是只有随机访问的迭代器才支持+=操作。面对其他威力不那么强大的迭代器种类, advance必须反复施行++或—,共d次。

STL共有5种选代器分类,对应于它们支持的操作。

  • Input送代器只能向前移动,一次一步,客户只可读取(不能涂写)它们所指的东西,而且只能读取一次。它们模仿指向输入文件的阅读指针( read pointer);C++程序库中的istream Iterators是这一分类的代表。
  • Output迭代器情况类似,但一切只为输出,它们只向前移动,一次一步,客户只可涂写它们所指的东西,而且只能涂写一次。
    它们模仿指向输出文件的涂写指针( write pointer); ostream iterators是这一分类的代表。这是威力最小的两个迭代器分类。由于这两类都只能向前移动,而且只能读或写其所指物最多一次,所以它们只适合“一次性操作算法”(one-passalgorithms)。
  • 另一个威力比较强大的分类是forward迭代器。这种迭代器可以做前述两种分类所能做的每一件事,而且可以读或写其所指物一次以上。这使得它们可施行于多次性操作算法(muli-pass algorithms)。
  • Bidirectional迭代器比上一个分类威力更大:它除了可以向前移动,还可以向后移动。STL的list迭代器就属于这一分类,set, multiset,map和 multimap的迭代器也都是这一分类;
  • 最有威力的迭代器当属 random access迭代器。它可以执行“迭代器算术”,也就是它可以在常量时间内向前或向后跳跃任意距离。这样的算术很类似指针算术,那并不令人惊讶,因为 random access迭代器正是以内置(原始)指针为榜样,而内置指针也可被当做 random access迭代器使用。 vector,deque和string提供的选代器都是这一分类
    1
    2
    3
    4
    5
    struct input_iterator_tag {}
    struct output_iterator_tag {}
    struct forward_iterator_tag: public input_iterator_tag {}
    struct bidirectional_iterator_tag: public forward_iterator_tag {}
    struct random_access_iterator_tag: public bidirectional_iterator_tag {}

我们希望以这种方式实现advance函数:

1
2
3
4
5
6
7
8
9
10
template<typename T, typename DistT>
void advance(IterT& iter, DistT d) {
if (iter is a random access iterator) {
iter += d;
}
else {
if (d >=0) { while (d--) ++iter;}
else { while (d++) --iter;}
}
}

这种方法必须事先知道iter是否为random access迭代器,这就是traits让你得以进行的事,允许你在编译期间读取某些类型信息。
标准技术是把traits信息放入一个template及其一个或多个特化版本中,这样的templates有多个,其中针对迭代器的被命名为iterator_traits:
1
2
template<typename IterT>
struct iterator_traits;

iterator_traits的运作方式是,针对每一个类型IterT,在struct iterator_traits<IterT>内一定声明某个typedef名为iterator_category,用来确认IterT的迭代器分类。用户自定义的迭代器类型都要嵌套一个typedef,名为iterator_category。例如:
1
2
3
4
5
6
7
8
template< ... >
class deque {
public:
class iterator {
public:
typedef random_access_iterator_tag iterator_category;
};
};

为了支持指针迭代器,iterator_traits特别针对指针类型提供了一个偏特化版本:
1
2
3
4
template<typename IterT>
struct iterator_traits<IterT*> {
typedef random_access_iterator_tag iterator_category;
}

有了iterator_traits,可以对advance实现之前的伪代码:
1
2
3
4
5
6
7
8
9
10
template<typename T, typename DistT>
void advance(IterT& iter, DistT d) {
if (typeid(typename std::iterator_traits<IterT>::iterator_category) == typeid(std::random_access_iterator_tag)) {
iter += d;
}
else {
if (d >=0) { while (d--) ++iter;}
else { while (d++) --iter;}
}
}

利用重载实现编译器核定成功类型:
1
2
3
4
5
6
7
8
9
10
11
12
13
template<typename T, typename DistT>
void doadvance(IterT& iter, DistT d, std::random_access_iterator_tag) {
iter += d;
}
template<typename T, typename DistT>
void doadvance(IterT& iter, DistT d, std::biredirectional_iterator_tag) {
if (d >=0) { while (d--) ++iter;}
else { while (d++) --iter;}
}
template<typename T, typename DistT>
void advance(IterT& iter, DistT d) {
doadvance(iter, d, typename std::iterator_traits<IterT>::iterator_category());
}

建立一组重载函数(身份像劳工)或函数模板(例如 doAdvance),彼此间的差异只在于各自的traits参数。令每个函数实现码与其接受之 traits信息相应和。
建立一个控制函数(身份像工头)或函数模板(例如 advance),它调用上述那些“劳工函数”并传递 traits class所提供的信息。

模版元编程

本质上就是函数式编程

1
2
3
4
5
6
7
8
//上楼梯,每次上一步或者两步,有多少种
int climb(int n){
if(n == 1)
return 1;
if(n == 2)
return 2;
return climb(n - 1) + climb(n - 2);
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//元编程,采用类模版
template<int N>
class Climb{
public:
const static int n = Climb<N-1>::n + Climb<N-2>::n;
};

template<>
class Climb<2>{
public:
const static int n = 2;
};

template<>
class Climb<1>{
public:
const static int n = 1;
};

C++元编程可以将计算转移到编译期,执行速度迅速(缺陷?)

定制new和delete

了解new-handler的行为

STL容器使用的heap内存是由容器所拥有的分配器对象管理,不是被new和delete管理。
new和malloc对比:

  • new构造对象,malloc不会
  • new分配不出内存会抛异常,malloc返回NULL
  • new分配不出内存可以调用用户设置的new-handler,malloc没有。可以为每个类设置专属new handler
1
2
3
4
5
namespace std{
typedef void (*new_handler)();
//返回旧的handler
new_handler set_new_handler(new_handler p) throw();
}

new_handler是个typedef,定义出一个指针指向函数,该函数没有参数也不返回任何东西;set_new_handler则是获得一个new_handler并返回一个new_handler的函数。set_new_handler的参数是个指针,指向operator new无法分配足够内存时该被调用的函数,其返回值也是个指针,指向set_new_handler被调用前正在执行的那个new_handler函数。

当operator new无法满足内存申请时,就会不断调用new_handler函数直到找到足够的内存。
C++不支持class专属new-handler,只需令每个class提供自己的set_new_handler和operator new即可。

1
2
3
4
5
6
7
class Widget {
public:
static std::new_handler set_new_handler(std::new_handler p) throw();
stativ void* operator new(std::size_t size) throw(std::bad_alloc);
private:
static std::new_handler currentHandler;
}

Widget内的set_new_handler将它获得的指针存储起来,然后返回之前存储的指针:
1
2
3
4
5
std::new_handler Widget::set_new_handler(std::new_handler p) throw() {
std::new_handler oldHandler = currentHandler;
currentHandler = p;
return oldHandler;
}

operator new做以下事情:

  1. 调用标准set_new_handler告知类的错误处理函数;
  2. 调用global operator new执行实际的内存分配,如果分配失败则调用类的new handler,如果global new handler最终无法分配足够内存,会抛出一个bad_alloc异常;
  3. 如果global operator new能够分配足够一个类对象所用的内存,类的operator new则会返回一个指针,指向分配所得。

了解new和delete合理的替换时机

为何要替换编译器提供的operator new和operator delete:

  • 用来检测运用上的错误。如果将new的对象delete掉却不幸失败,会导致内存泄漏,以及其他的写入错误等;
  • 强化效能。对特定应用的内存分配进行优化
  • 收集使用上的统计数据。
  • 增加分配和归还的速度。泛用性分配器比定制性分配器慢。
  • 为了降低缺省内存管理器带来的空间额外开销。泛用性分配器在每一个分配区块上招引某些开销。
  • 为了弥补缺省分配器中的非最佳齐位,编译器自带的operator new并不保证对动态分配而得的double采取8-bytes对齐。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
static const int signature = OxDEADBEEF;
typedef unsigned char Byte;
// 这段代码还有若干小错误,详下。
void* operator new(std::size_t size) throw(std::bad_alloc) {
using namespace std;
size_t realSize = size + 2 * sizeof(int);
//增加大小,使能够塞入两个size

void* pMem = malloc(realSize);
//调用 malloc取得内存
if (!pMem) throw bad_alloc();

//将signature写入内存的最前段落和最后段落
*(static_cast<int*>(pMem)) = signature;
*(reinterpret_cast<int*>(static_cast<Byte*>(pMem)+ realSize-sizeof(int)))= signature;
//返回指针,指向恰位于第一个 signature之后的内存位置
return static_cast<Byte*>(pMem) + sizeof(int);
}

这个operator new的缺点主要在于它疏忽了身为这个特殊函数所应该具备的“坚持c++规矩”的态度。
举个例子,条款51说所有operator news都应该内含一个循环,反复调用某个new-handling函数,这里却没有。专注于一个比较微妙的主题:齐位。

许多计算机体系结构要求特定的类型必须放在特定的内存地址上。例如它可能会要求指针的地址必须是4倍数或doubles的地址必须是8倍数。如果没有奉行这个约束条件可能导致运行期硬件异常。
例如 Intel x86体系结构上的doubles可被对齐于任何byte边界,但如果它是8bye齐位,其访问速度会快许多。
C++要求所有operator news返回的指针都有适当的对齐(取决于数据类型)。malloc就是在这样的要求下工作,所以令 operator返回一个得自malloc的指针是安全的。

operator new, operator delete:分配和释放内存
调用构造函数,调用析构函数
替换new和delete的理由,就是需要收集分配内存的资源信息

编写符合常规的new和delete
operator new应该内含一个无穷循环尝试分配内存,如果无法满足,就调用new-handler。class版本要处理“比正确大小更大的(错误)申请”
operator deleter应该处理Null。classz专属版本还要处理“比正确大小更小的(错误)申请”
写了operator new也要写相应的operator delete
我们知道,new一个对象要经历两步。如果在调用构造函数失败,编译器会寻找一个“带相同额外参数”的operator delete,否则就不调用,造成资源泄漏

编写new和delete时需要固守常规

operator new的返回值十分单纯。如果它有能力供应客户申请的内存,就返回一个指针指向那块内存。如果没有那个能力,就遵循条款49描述的规则,并抛出个bad_alloc异常。
然而其实也不是非常单纯,因为operator new实际上不只一次尝试分配内存,并在每次失败后调用new-handling函数。这里假设new- handling函数也许能够做某些动作将某些内存释放出来。只有当指向 new-handling函数的指针是 null, operatornew才会抛出异常。

即使客户要求分配0byte的内存,operator也要返回一个合法指针。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void* operator new(std::size_t size) throw (std::bad_alloc) {
//你的 operator new可能接受额外参数
using namespace std;
if (size == 0) {
//处理0-byte申请
size = 1;
//将它视为1-byte申请
}
while (true)
// 尝试分配
if (分配成功)
return;
// 分配失败,找出目前的new_handling函数
new_handler globalHandler = set_new_handler(0);
set_new_handler(globanHandler);

if (globalHandler) (*globalHandler)();
else throw std::bad_alloc();
}

operator new内含一个无穷循环,而上述伪码明白表明出这个循环;”while(true)”就是那个无穷循环。退出此循环的唯一办法是内存成功分配或new- handling函数做了一件描述于条款49的事情:让更多内存可用、安装另一个 new-handler、卸除new-handler、抛出bad_a1oc异常(或其派生物),或是承认失败而直接 return。

operator new成员函数会被derived classes继承,这会导致某些有趣的复杂度。上述operator伪码中,函数尝试分配size bytes(除是0)。那非常合理,因为size是函数接受的实参。然而就像条款50所言,写出定制型内存管理器的一个最常见理由是为针对某特定class对象分配提供最优化,却不是为了其derived class,base class的operator new用于derived class时会有问题。

如果你决定写个operator new[],唯一要做的一件事就是分配一块未加工内存,因为你无法对array之内迄今尚未存在的元素对象做任何事情。实际上你甚至无法计算这个array将含多少个元素对象。首先你不知道每个对象多大,毕竟base class的operator new有可能经由继承被调用,将内存分配给“元素为 derived class对象”的array使用。

operator delete的情况更简单,C++保证删除null指针永远安全,所以我们必须兑现这个要求。

写了placement new也要写placement delete

举个例子,假设你写了一个class专属的operator new,要求接受一个ostream,用来志记(logged)相关分配信息,同时又写了一个正常形式的class专属operator delete:

1
2
3
4
5
6
7
class Widget {
public:
static void* operator new (std::size_t size, std::ostream& logstream) throw(std::bad_alloc);
//非正常形式的new
static void operator delete(void* pMemory, std::size_t size) throw();
//正常的 class专属 delete
};

这个设计有问题,但在探讨原因之前,我们需要先绕道,扼要讨论若干术语。
如果operator new接受的参数除了一定会有的那个size_t之外还有其他,这便是个所谓的placement new。因此,上述的operator new是个 placement版本。
众多placement new版本中特别有用的一个是“接受一个指针指向对象该被构造之处”,那样的operator new如下:
1
2
void* operator new(std::size_t, void* pMemory) throw();
//placement new

这个版本的new已被纳入C++标准程序库,你只要#include<new>就可以取用它。这个new的用途之一是负责在vector的末使用空间上创建对象。
实际上它正是这个函数的命名根据:一个特定位置上的new。
大多数时候他们谈的是此一特定版本,也就是“唯一额外实参是个void*”,少数时候才是指接受任意额外实参之operator new。

类似于new的placement版本,operator delete如果接受额外参数,便称为placement delete。

规则很简单:如果一个带额外参数的operator new没有“带相同额外参数”的对应版operator delete,那么当new的内存分配动作需要取消并恢复旧观时就没有任何operator delete会被调用。因此,为了消弭稍早代码中的内存泄漏,Widget有必要声明一个placement delete,对应于那个有志记功能的placement new:

1
2
3
4
5
6
class Widget{
public:
static void* operator new(std::size_t size, std::ostream& logstream) throw(std::bad_alloc);
static void operator delete(void* pMemory) throw();
static void operator delete(void* pMemory, std::ostream& logStream) throw();
}

如果以下语句引发异常,则placement delete自动调用,保证不泄露内存:
1
Widget* pw = new (std::cerr) Widget;

placement delete只有在伴随placement new调用而触发的构造函数出现异常时才会调用。

如果要对所有与placement new相关的内存泄漏宣战,必须同时提供一个正常的operator delete和一个placement delete分别用于构造时有/无异常抛出的情况。

STL使用小细节

为不同的容器选择不同删除方式:
删除连续容器(vector,deque,string)的元素
当c是vector、string,删除value

1
c.erase(remove(c.begin(), c.end(), value), c.end());

判断value是否满足某个条件,删除
1
2
bool assertFun(valuetype);
c.erase(remove_if(c.begin(), c.end(), assertFun), c.end());

有时候我们不得不遍历去完成,并删除
1
2
3
4
5
6
7
8
for(auto it = c.begin(); it != c.end(); ){
if(assertFun(*it)){
···
it = c.erase(it);
}
else
++it;
}

删除list中某个元素
1
c.remove(value);

判断value是否满足某个条件,删除
1
c.remove(assertFun);

删除关联容器(set,map)中某个元素
1
2
3
4
5
6
7
8
9
10
c.erase(value)

for(auto it = c.begin(); it != c.end(); ){
if(assertFun(*it)){
···
c.erase(it++);
}
else
++it;
}

用一句话解释动态规划就是 “记住你之前做过的事”,如果更准确些,其实是 “记住你之前得到的答案”。

我举个大家工作中经常遇到的例子。

在软件开发中,大家经常会遇到一些系统配置的问题,配置不对,系统就会报错,这个时候一般都会去 Google 或者是查阅相关的文档,花了一定的时间将配置修改好。

过了一段时间,去到另一个系统,遇到类似的问题,这个时候已经记不清之前修改过的配置文件长什么样,这个时候有两种方案,一种方案还是去 Google 或者查阅文档,另一种方案是借鉴之前修改过的配置,第一种做法其实是万金油,因为你遇到的任何问题其实都可以去 Google,去查阅相关文件找答案,但是这会花费一定的时间,相比之下,第二种方案肯定会更加地节约时间,但是这个方案是有条件的,条件如下:

之前的问题和当前的问题有着关联性,换句话说,之前问题得到的答案可以帮助解决当前问题

需要记录之前问题的答案

当然在这个例子中,可以看到的是,上面这两个条件均满足,大可去到之前配置过的文件中,将配置拷贝过来,然后做些细微的调整即可解决当前问题,节约了大量的时间。

不知道你是否从这些描述中发现,对于一个动态规划问题,我们只需要从两个方面考虑,那就是 找出问题之间的联系,以及 记录答案,这里的难点其实是找出问题之间的联系,记录答案只是顺带的事情,利用一些简单的数据结构就可以做到。

思考动态规划问题的四个步骤

一般解决动态规划问题,分为四个步骤,分别是

  • 问题拆解,找到问题之间的具体联系
  • 状态定义
  • 递推方程推导
  • 实现

这里面的重点其实是前两个,如果前两个步骤顺利完成,后面的递推方程推导和代码实现会变得非常简单。

这里还是拿 Quora 上面的例子来讲解,“1+1+1+1+1+1+1+1” 得出答案是 8,那么如何快速计算 “1+ 1+1+1+1+1+1+1+1”,我们首先可以对这个大的问题进行拆解,这里我说的大问题是 9 个 1 相加,这个问题可以拆解成 1 + “8 个 1 相加的答案”,8 个 1 相加继续拆,可以拆解成 1 + “7 个 1 相加的答案”,… 1 + “0 个 1 相加的答案”,到这里,第一个步骤 已经完成。

状态定义 其实是需要思考在解决一个问题的时候我们做了什么事情,然后得出了什么样的答案,对于这个问题,当前问题的答案就是当前的状态,基于上面的问题拆解,你可以发现两个相邻的问题的联系其实是 后一个问题的答案 = 前一个问题的答案 + 1,这里,状态的每次变化就是 +1。

定义好了状态,递推方程就变得非常简单,就是 dp[i] = dp[i - 1] + 1,这里的 dp[i] 记录的是当前问题的答案,也就是当前的状态,dp[i - 1] 记录的是之前相邻的问题的答案,也就是之前的状态,它们之间通过 +1 来实现状态的变更。

最后一步就是实现了,有了状态表示和递推方程,实现这一步上需要重点考虑的其实是初始化,就是用什么样的数据结构,根据问题的要求需要做那些初始值的设定。

1
2
3
4
5
6
7
8
public int dpExample(int n) {
int[] dp = new int[n + 1]; // 多开一位用来存放 0 个 1 相加的结果
dp[0] = 0; // 0 个 1 相加等于 0
for (int i = 1; i <= n; ++i) {
dp[i] = dp[i - 1] + 1;
}
return dp[n];
}

你可以看到,动态规划这四个步骤其实是相互递进的,状态的定义离不开问题的拆解,递推方程的推导离不开状态的定义,最后的实现代码的核心其实就是递推方程,这中间如果有一个步骤卡壳了则会导致问题无法解决,当问题的复杂程度增加的时候,这里面的思维复杂程度会上升。

接下来我们再来看看 LeetCode 上面的几道题目,通过题目再来走一下这些个分析步骤。

题目实战

爬楼梯

但凡涉及到动态规划的题目都离不开一道例题:爬楼梯(LeetCode 第 70 号问题)。

题目描述

假设你正在爬楼梯。需要 n 阶你才能到达楼顶。

每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?

注意:给定 n 是一个正整数。

示例 1:

1
2
3
4
5
6
输入:2
输出:2
解释: 有两种方法可以爬到楼顶。

1. 1 阶 + 1 阶
2. 2 阶

示例 2:
1
2
3
4
5
6
7
输入:3
输出:3
解释: 有三种方法可以爬到楼顶。

1. 1 阶 + 1 阶 + 1 阶
2. 1 阶 + 2 阶
3. 2 阶 + 1 阶

题目解析

爬楼梯,可以爬一步也可以爬两步,问有多少种不同的方式到达终点,我们按照上面提到的四个步骤进行分析:

问题拆解:
我们到达第 n 个楼梯可以从第 n - 1 个楼梯和第 n - 2 个楼梯到达,因此第 n 个问题可以拆解成第 n - 1 个问题和第 n - 2 个问题,第 n - 1 个问题和第 n - 2 个问题又可以继续往下拆,直到第 0 个问题,也就是第 0 个楼梯 (起点)

状态定义:
“问题拆解” 中已经提到了,第 n 个楼梯会和第 n - 1 和第 n - 2 个楼梯有关联,那么具体的联系是什么呢?你可以这样思考,第 n - 1 个问题里面的答案其实是从起点到达第 n - 1 个楼梯的路径总数,n - 2 同理,从第 n - 1 个楼梯可以到达第 n 个楼梯,从第 n - 2 也可以,并且路径没有重复,因此我们可以把第 i 个状态定义为 “从起点到达第 i 个楼梯的路径总数”,状态之间的联系其实是相加的关系。

递推方程:
“状态定义” 中我们已经定义好了状态,也知道第 i 个状态可以由第 i - 1 个状态和第 i - 2 个状态通过相加得到,因此递推方程就出来了 dp[i] = dp[i - 1] + dp[i - 2]

实现:
你其实可以从递推方程看到,我们需要有一个初始值来方便我们计算,起始位置不需要移动 dp[0] = 0,第 1 层楼梯只能从起始位置到达,因此 dp[1] = 1,第 2 层楼梯可以从起始位置和第 1 层楼梯到达,因此 dp[2] = 2,有了这些初始值,后面就可以通过这几个初始值进行递推得到。

参考代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public int climbStairs(int n) {
if (n == 1) {
return 1;
}

int[] dp = new int[n + 1]; // 多开一位,考虑起始位置

dp[0] = 0; dp[1] = 1; dp[2] = 2;
for (int i = 3; i <= n; ++i) {
dp[i] = dp[i - 1] + dp[i - 2];
}

return dp[n];
}

三角形最小路径和

LeetCode 第 120 号问题:三角形最小路径和。

题目描述

给定一个三角形,找出自顶向下的最小路径和。每一步只能移动到下一行中相邻的结点上。

例如,给定三角形:

[
[2],
[3,4],
[6,5,7],
[4,1,8,3]
]
自顶向下的最小路径和为 11(即,2 + 3 + 5 + 1 = 11)。

说明:

如果你可以只使用 O(n) 的额外空间(n 为三角形的总行数)来解决这个问题,那么你的算法会很加分。

题目解析

给定一个三角形数组,需要求出从上到下的最小路径和,也和之前一样,按照四个步骤来分析:

问题拆解:
这里的总问题是求出最小的路径和,路径是这里的分析重点,路径是由一个个元素组成的,和之前爬楼梯那道题目类似,[i][j] 位置的元素,经过这个元素的路径肯定也会经过 [i - 1][j] 或者 [i - 1][j - 1],因此经过一个元素的路径和可以通过这个元素上面的一个或者两个元素的路径和得到。

状态定义:
状态的定义一般会和问题需要求解的答案联系在一起,这里其实有两种方式,一种是考虑路径从上到下,另外一种是考虑路径从下到上,因为元素的值是不变的,所以路径的方向不同也不会影响最后求得的路径和,如果是从上到下,你会发现,在考虑下面元素的时候,起始元素的路径只会从[i - 1][j] 获得,每行当中的最后一个元素的路径只会从 [i - 1][j - 1] 获得,中间二者都可,这样不太好实现,因此这里考虑从下到上的方式,状态的定义就变成了 “最后一行元素到当前元素的最小路径和”,对于 [0][0] 这个元素来说,最后状态表示的就是我们的最终答案。

递推方程:
“状态定义” 中我们已经定义好了状态,递推方程就出来了

1
dp[i][j] = Math.min(dp[i + 1][j], dp[i + 1][j + 1]) + triangle[i][j]

实现

这里初始化时,我们需要将最后一行的元素填入状态数组中,然后就是按照前面分析的策略,从下到上计算即可

参考代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public int minimumTotal(List<List<Integer>> triangle) {
int n = triangle.size();

int[][] dp = new int[n][n];

List<Integer> lastRow = triangle.get(n - 1);

for (int i = 0; i < n; ++i) {
dp[n - 1][i] = lastRow.get(i);
}

for (int i = n - 2; i >= 0; --i) {
List<Integer> row = triangle.get(i);
for (int j = 0; j < i + 1; ++j) {
dp[i][j] = Math.min(dp[i + 1][j], dp[i + 1][j + 1]) + row.get(j);
}
}

return dp[0][0];
}

最大子序和

LeetCode 第 53 号问题:最大子序和。

题目描述

给定一个整数数组 nums ,找到一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。

示例:

输入: [-2,1,-3,4,-1,2,1,-5,4],
输出: 6
解释: 连续子数组 [4,-1,2,1] 的和最大,为 6。
进阶:

如果你已经实现复杂度为 O(n) 的解法,尝试使用更为精妙的分治法求解。

题目解析

求最大子数组和,非常经典的一道题目,这道题目有很多种不同的做法,而且很多算法思想都可以在这道题目上面体现出来,比如动态规划、贪心、分治,还有一些技巧性的东西,比如前缀和数组,这里还是使用动态规划的思想来解题,套路还是之前的四步骤:

问题拆解:
问题的核心是子数组,子数组可以看作是一段区间,因此可以由起始点和终止点确定一个子数组,两个点中,我们先确定一个点,然后去找另一个点,比如说,如果我们确定一个子数组的截止元素在 i 这个位置,这个时候我们需要思考的问题是 “以 i 结尾的所有子数组中,和最大的是多少?”,然后我们去试着拆解,这里其实只有两种情况:

  • 这个位置的元素自成一个子数组;
  • i 位置的元素的值 + 以 i - 1 结尾的所有子数组中的子数组和最大的值

你可以看到,我们把第 i 个问题拆成了第 i - 1 个问题,之间的联系也变得清晰

状态定义

通过上面的分析,其实状态已经有了,dp[i] 就是 “以 i 结尾的所有子数组的最大值”

递推方程

拆解问题的时候也提到了,有两种情况,即当前元素自成一个子数组,另外可以考虑前一个状态的答案,于是就有了

1
dp[i] = Math.max(dp[i - 1] + array[i], array[i])

化简一下就成了:
1
dp[i] = Math.max(dp[i - 1], 0) + array[i]

实现

题目要求子数组不能为空,因此一开始需要初始化,也就是 dp[0] = array[0],保证最后答案的可靠性,另外我们需要用一个变量记录最后的答案,因为子数组有可能以数组中任意一个元素结尾

参考代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public int maxSubArray(int[] nums) {
if (nums == null || nums.length == 0) {
return 0;
}

int n = nums.length;

int[] dp = new int[n];

dp[0] = nums[0];

int result = dp[0];

for (int i = 1; i < n; ++i) {
dp[i] = Math.max(dp[i - 1], 0) + nums[i];
result = Math.max(result, dp[i]);
}

return result;
}

上文解释了动态规划的一些基本特性和解题思路,也说了动态规划其实就是记住之前问题的答案,然后利用之前问题的答案来分析并解决当前问题,这里面有两个非常重要的步骤,就是拆解问题定义状态

矩阵类动态规划问题

这次来针对具体的一类动态规划问题,矩阵类动态规划问题,来看看针对这一类问题的思路和注意点。

矩阵类动态规划,也可以叫做坐标类动态规划,一般这类问题都会给你一个矩阵,矩阵里面有着一些信息,然后你需要根据这些信息求解问题。

其实 矩阵可以看作是图的一种,怎么说?你可以把整个矩阵当成一个图,矩阵里面的每个位置上的元素当成是图上的节点,然后每个节点的邻居就是其相邻的上下左右的位置,我们遍历矩阵其实就是遍历图,在遍历的过程中会有一些临时的状态,也就是子问题的答案,我们记录这些答案,从而推得我们最后想要的答案。

一般来说,在思考这类动态规划问题的时候,我们只需要思考当前位置的状态,然后试着去看当前位置和它邻居的递进关系,从而得出我们想要的递推方程,这一类动态规划问题,相对来说比较简单,我们通过几道例题来熟悉一下。

相关题目解析

LeetCode 第 62 号问题:不同路径。

题目描述

一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为“Start” )。

机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为“Finish”)。

问总共有多少条不同的路径?

例如,一个7 x 3 的网格。有多少可能的路径?

说明: m 和 n 的值均不超过 100。

示例 1:

1
2
3
4
5
6
7
8
输入: m = 3, n = 2
输出: 3
解释:
从左上角开始,总共有 3 条路径可以到达右下角。

1. 向右 -> 向右 -> 向下
2. 向右 -> 向下 -> 向右
3. 向下 -> 向右 -> 向右

示例 2:
1
2
输入: m = 7, n = 3
输出: 28

题目解析

给定一个矩阵,问有多少种不同的方式从起点(0,0) 到终点 (m-1,n-1),并且每次移动只能向右或者向下,我们还是按之前提到的分析动态规划那四个步骤来思考一下:

问题拆解:
题目中说了,每次移动只能是向右或者是向下,矩阵类动态规划需要关注当前位置和其相邻位置的关系,对于某一个位置来说,经过它的路径只能从它上面过来,或者从它左边过来,因此,如果需要求到达当前位置的不同路径,我们需要知道到达其上方位置的不同路径,以及到达其左方位置的不同路径

状态定义:
矩阵类动态规划的状态定义相对来说比较简单,只需要看当前位置即可,问题拆解中,我们分析了当前位置和其邻居的关系,提到每个位置其实都可以算做是终点,状态表示就是 “从起点到达该位置的不同路径数目”

递推方程:
有了状态,也知道了问题之间的联系,其实递推方程也出来了,就是

1
dp[i][j] = dp[i - 1][j] + dp[i][j - 1]

实现:
有了这些,这道题还没完,我们还要考虑状态数组的初始化问题,对于上边界和左边界的点,因为它们只能从一个方向过来,需要单独考虑,比如上边界的点只能从左边这一个方向过来,左边界的点只能从上边这一个方向过来,它们的不同路径个数其实就只有 1,提前处理就好。

参考代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public int uniquePaths(int m, int n) {
int[][] dp = new int[m][n];

for (int i = 0; i < m; ++i) {
dp[i][0] = 1;
}

for (int j = 0; j < n; ++j) {
dp[0][j] = 1;
}

for (int i = 1; i < m; ++i) {
for (int j = 1; j < n; ++j) {
dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
}
}

return dp[m - 1][n - 1];
}

LeetCode 第 63 号问题:不同路径II

题目描述

一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为“Start” )。

机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为“Finish”)。

现在考虑网格中有障碍物。那么从左上角到右下角将会有多少条不同的路径?

网格中的障碍物和空位置分别用 1 和 0 来表示。

说明:m 和 n 的值均不超过 100。

示例 1:

1
2
3
4
5
6
7
输入:
[
[0,0,0],
[0,1,0],
[0,0,0]
]
输出: 2

解释:
3x3 网格的正中间有一个障碍物。
从左上角到右下角一共有 2 条不同的路径:

  1. 向右 -> 向右 -> 向下 -> 向下
  2. 向下 -> 向下 -> 向右 -> 向右

题目解析

在上面那道题的基础上,矩阵中增加了障碍物,这里只需要针对障碍物进行判断即可,如果当前位置是障碍物的话,状态数组中当前位置记录的答案就是 0,也就是没有任何一条路径可以到达当前位置,除了这一点外,其余的分析方法和解题思路和之前 一样 。

参考代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public int uniquePathsWithObstacles(int[][] obstacleGrid) {
if (obstacleGrid.length == 0 || obstacleGrid[0].length == 0) {
return 0;
}

if (obstacleGrid[0][0] == 1) {
return 0;
}

int m = obstacleGrid.length, n = obstacleGrid[0].length;
int[][] dp = new int[m][n];

dp[0][0] = 1;

for (int i = 1; i < m; ++i) {
dp[i][0] = obstacleGrid[i][0] == 1 ? 0 : dp[i - 1][0];
}

for (int i = 1; i < n; ++i) {
dp[0][i] = obstacleGrid[0][i] == 1 ? 0 : dp[0][i - 1];
}

for (int i = 1; i < m; ++i) {
for (int j = 1; j < n; ++j) {
dp[i][j] = obstacleGrid[i][j] == 1 ? 0 : dp[i - 1][j] + dp[i][j - 1];
}
}

return dp[m - 1][n - 1];
}

LeetCode 第 64 号问题:最小路径和

题目描述

给定一个包含非负整数的 m x n 网格,请找出一条从左上角到右下角的路径,使得路径上的数字总和为最小。

说明:每次只能向下或者向右移动一步。

示例:

1
2
3
4
5
6
7
输入:
[
[1,3,1],
[1,5,1],
[4,2,1]
]
输出: 7

解释: 因为路径 1→3→1→1→1 的总和最小。

题目解析

给定一个矩阵,问从起点(0,0) 到终点 (m-1,n-1) 的最小路径和是多少,并且每次移动只能向右或者向下,按之四个步骤来思考一下:

问题拆解:
拆解问题的方式方法和前两道题目非常类似,这里不同的地方只是记录的答案不同,也就是状态不同,我们还是可以仅仅考虑当前位置,然后可以看到只有上面的位置和左边的位置可以到达当前位置,因此当前问题就可以拆解成两个子问题

状态定义:
因为是要求路径和,因此状态需要记录的是 “从起始点到当前位置的最小路径和”

递推方程:
有了状态,以及问题之间的联系,我们知道了,当前的最短路径和可以由其上方和其左方的最短路径和对比得出,递推方程也可以很快写出来:

1
dp[i][j] = Math.min(dp[i - 1][j] + dp[i][j - 1]) + grid[i][j]

实现

实现上面需要重点考虑的还是状态数组的初始化,这一步还是和前面两题类似,这里就不过多赘述

参考代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public int minPathSum(int[][] grid) {
int m = grid.length, n = grid[0].length;

int[][] dp = new int[m][n];

dp[0][0] = grid[0][0];

for (int i = 1; i < m; ++i) {
dp[i][0] = dp[i - 1][0] + grid[i][0];
}

for (int i = 1; i < n; ++i) {
dp[0][i] = dp[0][i - 1] + grid[0][i];
}

for (int i = 1; i < m; ++i) {
for (int j = 1; j < n; ++j) {
dp[i][j] = Math.min(dp[i - 1][j], dp[i][j - 1]) + grid[i][j];
}
}

return dp[m - 1][n - 1];
}

LeetCode 第 221 号问题:最大正方形。

题目描述

在一个由 0 和 1 组成的二维矩阵内,找到只包含 1 的最大正方形,并返回其面积。

示例:

1
2
3
4
5
6
7
8
输入: 

1 0 1 0 0
1 0 1 1 1
1 1 1 1 1
1 0 0 1 0

输出: 4

题目解析

题目给定一个字符矩阵,字符矩阵中只有两种字符,分别是 ‘0’ 和 ‘1’,题目要在矩阵中找全为 ‘1’ 的,面积最大的正方形。

刚拿道这道题,如果不说任何解法的话,其实并不是特别好想,我们先来看看切题的思路是怎么样的。

首先一个正方形是由四个顶点构成的,如果说我们在矩阵中随机找四个点,然后判断该四个点组成的是不是正方形,如果是正方形,然后看组成正方形的每个位置的元素是不是都是 ‘1’,这种方式也是可行的,但是比较暴力,这么弄下来,时间复杂度是 O((m*n)^4)

那我们就会思考,组成一个正方形是不是必须要四个点都找到?如果我们找出其中的三个点,甚至说两个点,能不能确定这个正方形呢?

你会发现,这里我们只需要考虑 正方形对角线的两个点 即可,这两个点确定了,另外的两个点也就确定了,因此我们可以把时间复杂度降为O((m*n)^2)

但是这里还是会有一些重复计算在里面,我们和之前一样,本质还是在做暴力枚举,只是说枚举的个数变少了,我们能不能记录我们之前得到过的答案,通过牺牲空间换取时间呢,这里正是动态规划所要做的事情!

问题拆解:
我们可以思考,如果我们从左到右,然后从上到下遍历矩阵,假设我们遍历到的当前位置是正方形的右下方的点,那其实我们可以看之前我们遍历过的点有没有可能和当前点组成符合条件的正方形,除了这个点以外,无非是要找另外三个点,这三个点分别在当前点的上方,左方,以及左上方,也就是从这个点往这三个方向去做延伸,具体延伸的距离是和其相邻的三个点中的状态有关

状态定义:
因为我们考虑的是正方形的右下方的顶点,因此状态可以定义成 “当前点为正方形的右下方的顶点时,正方形的最大面积”

递推方程:
有了状态,我们再来看看递推方程如何写,前面说到我们可以从当前点向三个方向延伸,我们看相邻的位置的状态,这里我们需要取三个方向的状态的最小值才能确保我们延伸的是全为 ‘1’ 的正方形,也就是

1
dp[i][j] = Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]) + 1

实现

在实现上,我们需要单独考虑两种情况,就是当前位置是 ‘1’,还有就是当前位置是 ‘0’,如果是 ‘0’ 的话,状态就是 0,表示不能组成正方形,如果是 ‘1’ 的话,我们也需要考虑位置,如果是第一行的元素,以及第一列的元素,表明该位置无法同时向三个方向延伸,状态直接给为 1 即可,其他情况就按我们上面得出的递推方程来计算当前状态。

参考代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public int maximalSquare(char[][] matrix) {
if (matrix.length == 0 || matrix[0].length == 0) {
return 0;
}

int m = matrix.length, n = matrix[0].length;

int[][] dp = new int[m][n];

int maxLength = 0;

for (int i = 0; i < m; ++i) {
for (int j = 0; j < n; ++j) {
if (matrix[i][j] == '1') {
if (i == 0 || j == 0) {
dp[i][j] = matrix[i][j] == '1' ? 1 : 0;
} else {
dp[i][j] = Math.min(dp[i - 1][j],
Math.min(dp[i][j - 1], dp[i - 1][j - 1])) + 1;
}

maxLength = Math.max(dp[i][j], maxLength);
}
}
}

return maxLength * maxLength;
}

序列类动态规划问题

这次再来看一类动态规划问题,序列类动态规划问题,这类动态规划问题较为普遍,分析难度相比之前也略有提升,通常问题的输入参数会涉及数组或是字符串。

在开始之前,先解释一下子数组(子串)和子序列的区别,你可以看看下面这个例子:

输入数组:[1,2,3,4,5,6,7,8,9]
子数组:[2,3,4], [5,6,7], [6,7,8,9], …
子序列:[1,5,9], [2,3,6], [1,8,9], [7,8,9], …
可以看到的是,子数组必须是数组中的一个连续的区间,而子序列并没有这样一个要求。

你只需要保证子序列中的元素的顺序和原数组中元素的顺序一致即可,例如,在原数组中,元素 1 出现在元素 9 之前,那么在子序列中,如果这两个元素同时出现,那么 1 也必须在 9 之前。

为什么要说这个?

不知道你有没有发现,这里的子数组的问题和我们前面提到的矩阵类动态规划的分析思路很类似,只需要考虑当前位置,以及当前位置和相邻位置的关系。

通过这样的分析就可以把之前讲的内容和今天要介绍的内容关联起来了,相比矩阵类动态规划,序列类动态规划最大的不同在于,对于第 i 个位置的状态分析,它不仅仅需要考虑当前位置的状态,还需要考虑前面 i - 1 个位置的状态,这样的分析思路其实可以从子序列的性质中得出。

对于这类问题的问题拆解,有时并不是那么好发现问题与子问题之间的联系,但是通常来说思考的方向其实在于 寻找当前状态和之前所有状态的关系,我们通过几个非常经典的动态规划问题来一起看看。

题目分析

最长上升子序列

LeetCode 第 300 号问题:最长上升子序列。

题目描述

给定一个无序的整数数组,找到其中最长上升子序列的长度。

示例:

1
2
3
输入: [10,9,2,5,3,7,101,18]
输出: 4
解释: 最长的上升子序列是 [2,3,7,101],它的长度是 4。

说明:

可能会有多种最长上升子序列的组合,你只需要输出对应的长度即可。
你算法的时间复杂度应该为 O(n2) 。
进阶: 你能将算法的时间复杂度降低到 O(n log n) 吗?

题目解析:
给定一个数组,求最长递增子序列。因为是子序列,这样对于每个位置的元素其实都存在两种可能,就是选和不选,如果我们用暴力的解法,枚举出所有的子序列,然后判断他们是不是递增的,选取最大的递增序列,这样做的话,时间复杂度是 O(2^n),显然不高效。

那这里我们就需要思考用动态规划进行优化,我们按之前的四个步骤来具体分析一下:

问题拆解:
我们要求解的问题是 “数组中最长递增子序列”,一个子序列虽然不是连续的区间,但是它依然有起点和终点,比如:

[10,9,2,5,3,7,101,18]

子序列 [2,3,7,18] 的起始位置是 2,终止位置是 18
子序列 [5,7,101] 的起始位置是 5,终止位置是 101
如果我们确定终点位置,然后去 看前面 i - 1 个位置中,哪一个位置可以和当前位置拼接在一起,这样就可以把第 i 个问题拆解成思考之前 i - 1 个问题,注意这里我们并不是不考虑起始位置,在遍历的过程中我们其实已经考虑过了。

状态定义:
问题拆解中我们提到 “第 i 个问题和前 i - 1 个问题有关”,也就是说 “如果我们要求解第 i 个问题的解,那么我们必须考虑前 i - 1 个问题的解”,我们定义 dp[i] 表示以位置 i 结尾的子序列的最大长度,也就是说 dp[i] 里面记录的答案保证了该答案表示的子序列以位置 i 结尾。

递推方程:
对于 i 这个位置,我们需要考虑前 i - 1 个位置,看看哪些位置可以拼在 i 位置之前,如果有多个位置可以拼在 i 之前,那么必须选最长的那个,这样一分析,递推方程就有了:

1
dp[i] = Math.max(dp[j],...,dp[k]) + 1, 

其中 inputArray[j] < inputArray[i], inputArray[k] < inputArray[i]

实现:
在实现这里,我们需要考虑状态数组的初始化,因为对于每个位置,它本身其实就是一个序列,因此所有位置的状态都可以初始化为 1。

最后提一下,对于这道题来说,这种方法其实不是最优的,但是在这里的话就不展开讲了,理解序列类动态规划的解题思路是关键。

参考代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public int lengthOfLIS(int[] nums) {
if (nums == null || nums.length == 0) {
return 0;
}

// dp[i] -> the longest length sequence from 0 - i, and must include nums[i]
int[] dp = new int[nums.length];

Arrays.fill(dp, 1);

int max = 0;

for (int i = 0; i < nums.length; ++i) {
for (int j = 0; j < i; ++j) {
if (nums[i] > nums[j]) {
dp[i] = Math.max(dp[j] + 1, dp[i]);
}
}

max = Math.max(max, dp[i]);
}

return max;
}

粉刷房子

LeetCode 第 256 号问题:粉刷房子。

注意:本题为 LeetCode 的付费题目,需要开通会员才能解锁查看与提交代码。

题目描述:

假如有一排房子,共 n 个,每个房子可以被粉刷成红色、蓝色或者绿色这三种颜色中的一种,你需要粉刷所有的房子并且使其相邻的两个房子颜色不能相同。
当然,因为市场上不同颜色油漆的价格不同,所以房子粉刷成不同颜色的花费成本也是不同的。每个房子粉刷成不同颜色的花费是以一个 n x 3 的矩阵来表示的。
例如,costs[0][0]表示第 0 号房子粉刷成红色的成本花费;costs[1][2]表示第 1 号房子粉刷成绿色的花费,以此类推。请你计算出粉刷完所有房子最少的花费成本。

注意:

所有花费均为正整数。

示例:

1
2
3
4
输入: [[17,2,17],[16,16,5],[14,3,19]]
输出: 10
解释: 将 0 号房子粉刷成蓝色,1 号房子粉刷成绿色,2 号房子粉刷成蓝色。
最少花费: 2 + 5 + 3 = 10。

题目解析

给 n 个房子刷油漆,有三种颜色的油漆可以刷,必须保证相邻房子的颜色不能相同,输入是一个 n x 3 的数组,表示每个房子使用每种油漆所需要花费的价钱,求刷完所有房子的最小价值。

还是按原来的思考方式走一遍:

问题拆解:
对于每个房子来说,都可以使用三种油漆当中的一种,如果说不需要保证相邻的房子的颜色必须不同,那么整个题目会变得非常简单,每个房子直接用最便宜的油漆刷就好了,但是加上这个限制条件,你会发现刷第 i 个房子的花费其实是和前面 i - 1 个房子的花费以及选择相关,如果说我们需要知道第 i 个房子使用第 k 种油漆的最小花费,那么你其实可以思考第 i - 1 个房子如果不用该油漆的最小花费,这个最小花费是考虑从 0 到当前位置所有的房子的。

状态定义:
通过之前的问题拆解步骤,状态可以定义成 dp[i][k],表示如果第 i 个房子选择第 k 个颜色,那么从 0 到 i 个房子的最小花费

递推方程:
基于之前的状态定义,以及相邻的房子不能使用相同的油漆,那么递推方程可以表示成:

1
dp[i][k] = Math.min(dp[i - 1][l], ..., dp[i - 1][r]) + costs[i][k], l != k, r != k

实现:
因为我们要考虑 i - 1 的情况,但是第 0 个房子并不存在 i - 1 的情况,因此我们可以把第 0 个房子的最小花费存在状态数组中,当然你也可以多开一格 dp 状态,其实都是一样的。

对于这道题目,你可能会问这不是和矩阵类动态规划类似吗?

如果单从房子来考虑的确是,但是对于颜色的话,我们必须考虑考虑相邻房子的所有颜色,这就有点序列的意思在里面了。

另外对于题目的分类其实没有严格的限定,主要是为了把相类似的问题放在一起,这样有便于分析问题思路。

参考代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public int minCost(int[][] costs) {
if (costs == null || costs.length == 0) {
return 0;
}
int n = costs.length;

int[][] dp = new int[n][3];

for (int i = 0; i < costs[0].length; ++i) {
dp[0][i] = costs[0][i];
}

for (int i = 1; i < n; ++i) {
dp[i][0] = Math.min(dp[i - 1][1], dp[i - 1][2]) + costs[i][0];
dp[i][1] = Math.min(dp[i - 1][0], dp[i - 1][2]) + costs[i][1];
dp[i][2] = Math.min(dp[i - 1][0], dp[i - 1][1]) + costs[i][2];
}

return Math.min(dp[n - 1][0], Math.min(dp[n - 1][1], dp[n - 1][2]));
}

粉刷房子II

LeetCode 第 265 号问题:粉刷房子II。

注意:本题为 LeetCode 的付费题目,需要开通会员才能解锁查看与提交代码。

题目描述

假如有一排房子,共 n 个,每个房子可以被粉刷成 k 种颜色中的一种,你需要粉刷所有的房子并且使其相邻的两个房子颜色不能相同。

当然,因为市场上不同颜色油漆的价格不同,所以房子粉刷成不同颜色的花费成本也是不同的。每个房子粉刷成不同颜色的花费是以一个 n x k 的矩阵来表示的。

例如,costs[0][0] 表示第 0 号房子粉刷成 0 号颜色的成本花费;costs[1][2] 表示第 1 号房子粉刷成 2 号颜色的成本花费,以此类推。请你计算出粉刷完所有房子最少的花费成本。

注意:

所有花费均为正整数。

示例:

1
2
3
4
输入: [[1,5,3],[2,9,4]]
输出: 5
解释: 将 0 号房子粉刷成 0 号颜色,1 号房子粉刷成 2 号颜色。最少花费: 1 + 4 = 5;
或者将 0 号房子粉刷成 2 号颜色,1 号房子粉刷成 0 号颜色。最少花费: 3 + 2 = 5.

进阶:
您能否在 O(nk) 的时间复杂度下解决此问题?

题目解析

上面那道题目的 follow up,现在不是三种油漆,而是 k 种油漆。

其实解题思路还是不变。

对于第 i 个房子的每种颜色,我们对比看第 i - 1 个房子的 k 种油漆,找到不相重的最小值就好,但是这里的时间复杂度是 O(n*k^2)

其实这是可以优化的,我们只需要在第 i - 1 个位置的状态中找到最大值和次大值,在选择第 i 个房子的颜色的时候,我们看当前颜色是不是和最大值的颜色相重,不是的话直接加上最大值,如果相重的话,我们就加上次大值,这样一来,我们把两个嵌套的循环,拆开成两个平行的循环,时间复杂度降至 O(n*k)

参考代码(优化前)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public int minCostII(int[][] costs) {
if (costs.length == 0 || costs[0].length == 0) {
return 0;
}

int n = costs.length, k = costs[0].length;
int[][] dp = new int[n][k];

for (int i = 1; i < n; ++i) {
Arrays.fill(dp[i], Integer.MAX_VALUE);
}

for (int i = 0; i < k; ++i) {
dp[0][i] = costs[0][i];
}

for (int i = 1; i < n; ++i) {
for (int j = 0; j < k; ++j) {
for (int m = 0; m < k; ++m) {
if (m != j) {
dp[i][m] = Math.min(dp[i][m], dp[i - 1][j] + costs[i][m]);
}
}
}
}

int result = Integer.MAX_VALUE;
for (int i = 0; i < k; ++i) {
result = Math.min(result, dp[n - 1][i]);
}

return result;
}

参考代码(优化后)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
public int minCostII(int[][] costs) {
if (costs.length == 0 || costs[0].length == 0) {
return 0;
}

int n = costs.length, k = costs[0].length;
int[][] dp = new int[n][k];

for (int i = 1; i < n; ++i) {
Arrays.fill(dp[i], Integer.MAX_VALUE);
}

for (int i = 0; i < k; ++i) {
dp[0][i] = costs[0][i];
}

for (int i = 1; i < n; ++i) {
// min1 表示的是最大值,min2 表示的是次大值
int min1 = Integer.MAX_VALUE, min2 = Integer.MAX_VALUE;
int minIndex = -1;
for (int l = 0; l < k; ++l) {
if (min1 > dp[i - 1][l]) {
min2 = min1;
min1 = dp[i - 1][l];
minIndex = l;
} else if (min2 > dp[i - 1][l]) {
min2 = dp[i - 1][l];
}
}

for (int j = 0; j < k; ++j) {
if (minIndex != j) {
dp[i][j] = Math.min(dp[i][j], min1 + costs[i][j]);
} else {
dp[i][j] = Math.min(dp[i][j], min2 + costs[i][j]);
}
}
}

int result = Integer.MAX_VALUE;
for (int i = 0; i < k; ++i) {
result = Math.min(result, dp[n - 1][i]);
}

return result;
}

打家劫舍

LeetCode 第 198 号问题:打家劫舍。

题目描述

你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。

给定一个代表每个房屋存放金额的非负整数数组,计算你在不触动警报装置的情况下,能够偷窃到的最高金额。

示例 1:

1
2
3
4
输入: [1,2,3,1]
输出: 4
解释: 偷窃 1 号房屋 (金额 = 1) ,然后偷窃 3 号房屋 (金额 = 3)。
偷窃到的最高金额 = 1 + 3 = 4 。

示例 2:
1
2
3
4
输入: [2,7,9,3,1]
输出: 12
解释: 偷窃 1 号房屋 (金额 = 2), 偷窃 3 号房屋 (金额 = 9),接着偷窃 5 号房屋 (金额 = 1)。
偷窃到的最高金额 = 2 + 9 + 1 = 12 。

题目解析

前面那道题目的 follow up,问的是如果这些房子的排列方式是一个圆圈,其余要求不变,问该如何处理。

房子排列方式是一个圆圈意味着之前的最后一个房子和第一个房子之间产生了联系,这里有一个小技巧就是我们线性考虑 [0, n - 2] 和 [1, n - 1],然后求二者的最大值。

其实这么做的目的很明显,把第一个房子和最后一个房子分开来考虑。实现上面我们可以直接使用之前的实现代码。

这里有一个边界条件就是,当只有一个房子的时候,我们直接输出结果即可。

参考代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public int rob(int[] nums) {
if (nums == null || nums.length == 0) {
return 0;
}

if (nums.length == 1) {
return nums[0];
}

int n = nums.length;

return Math.max(
robI(Arrays.copyOfRange(nums, 0, n - 1)),
robI(Arrays.copyOfRange(nums, 1, n))
);
}

public int robI(int[] nums) {
if (nums == null || nums.length == 0) {
return 0;
}

int n = nums.length;

int[] dp = new int[n + 1];

dp[1] = nums[0];

for (int i = 2; i <= n; ++i) {
dp[i] = Math.max(dp[i - 1], dp[i - 2] + nums[i - 1]);
}

return dp[n];
}

相关的「股票」算法题

概念

动态规划算法是通过拆分问题,定义问题状态和状态之间的关系,使得问题能够以递推(或者说分治)的方式去解决。在学习动态规划之前需要明确掌握几个重要概念。

  • 阶段:对于一个完整的问题过程,适当的切分为若干个相互联系的子问题,每次在求解一个子问题,则对应一个阶段,整个问题的求解转化为按照阶段次序去求解。
  • 状态:状态表示每个阶段开始时所处的客观条件,即在求解子问题时的已知条件。状态描述了研究的问题过程中的状况。
  • 决策:决策表示当求解过程处于某一阶段的某一状态时,可以根据当前条件作出不同的选择,从而确定下一个阶段的状态,这种选择称为决策。
  • 策略:由所有阶段的决策组成的决策序列称为全过程策略,简称策略。
  • 最优策略:在所有的策略中,找到代价最小,性能最优的策略,此策略称为最优策略。
  • 状态转移方程:状态转移方程是确定两个相邻阶段状态的演变过程,描述了状态之间是如何演变的。

使用场景

能采用动态规划求解的问题的一般要具有 3 个性质:

  • 最优化:如果问题的最优解所包含的子问题的解也是最优的,就称该问题具有最优子结构,即满足最优化原理。子问题的局部最优将导致整个问题的全局最优。换句话说,就是问题的一个最优解中一定包含子问题的一个最优解。
  • 无后效性:即某阶段状态一旦确定,就不受这个状态以后决策的影响。也就是说,某状态以后的过程不会影响以前的状态,只与当前状态有关,与其他阶段的状态无关,特别是与未发生的阶段的状态无关。
  • 重叠子问题:即子问题之间是不独立的,一个子问题在下一阶段决策中可能被多次使用到。(该性质并不是动态规划适用的必要条件,但是如果没有这条性质,动态规划算法同其他算法相比就不具备优势)

算法流程

  • 划分阶段:按照问题的时间或者空间特征将问题划分为若干个阶段。
  • 确定状态以及状态变量:将问题的不同阶段时期的不同状态描述出来。
  • 确定决策并写出状态转移方程:根据相邻两个阶段的各个状态之间的关系确定决策。
  • 寻找边界条件:一般而言,状态转移方程是递推式,必须有一个递推的边界条件。
  • 设计程序,解决问题

实战练习

下面的三道算法题都是来源于 LeetCode 上与股票买卖相关的问题 ,我们按照 动态规划 的算法流程来处理该类问题。

股票买卖这一类的问题,都是给一个输入数组,里面的每个元素表示的是每天的股价,并且你只能持有一支股票(也就是你必须在再次购买前出售掉之前的股票),一般来说有下面几种问法:

  • 只能买卖一次
  • 可以买卖无数次
  • 可以买卖 k 次

需要你设计一个算法去获取最大的利润。

买卖股票的最佳时机

题目来源于 LeetCode 上第 121 号问题:买卖股票的最佳时机。题目难度为 Easy,目前通过率为 49.4% 。

题目描述

给定一个数组,它的第 i 个元素是一支给定股票第 i 天的价格。

如果你最多只允许完成一笔交易(即买入和卖出一支股票),设计一个算法来计算你所能获取的最大利润。

注意你不能在买入股票前卖出股票。

示例 1:

输入: [7,1,5,3,6,4]
输出: 5
解释: 在第 2 天(股票价格 = 1)的时候买入,在第 5 天(股票价格 = 6)的时候卖出,最大利润 = 6-1 = 5 。
注意利润不能是 7-1 = 6, 因为卖出价格需要大于买入价格。

示例 2:

输入: [7,6,4,3,1]
输出: 0
解释: 在这种情况下, 没有交易完成, 所以最大利润为 0。

题目解析

我们按照动态规划的思想来思考这道问题。

状态:
有 买入(buy) 和 卖出(sell) 这两种状态。

转移方程:
对于买来说,买之后可以卖出(进入卖状态),也可以不再进行股票交易(保持买状态)。

对于卖来说,卖出股票后不在进行股票交易(还在卖状态)。

只有在手上的钱才算钱,手上的钱购买当天的股票后相当于亏损。也就是说当天买的话意味着损失-prices[i],当天卖的话意味着增加prices[i],当天卖出总的收益就是 buy+prices[i] 。

所以我们只要考虑当天买和之前买哪个收益更高,当天卖和之前卖哪个收益更高。

buy = max(buy, -price[i]) (注意:根据定义 buy 是负数)
sell = max(sell, prices[i] + buy)

边界:
第一天 buy = -prices[0], sell = 0,最后返回 sell 即可。

代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
class Solution {
public int maxProfit(int[] prices) {
if(prices.length <= 1)
return 0;
int buy = -prices[0], sell = 0;
for(int i = 1; i < prices.length; i++) {
buy = Math.max(buy, -prices[i]);
sell = Math.max(sell, prices[i] + buy);

}
return sell;
}
}

买卖股票的最佳时机 II

题目来源于 LeetCode 上第 122 号问题:买卖股票的最佳时机 II。题目难度为 Easy,目前通过率为 53.0% 。

题目描述

给定一个数组,它的第 i 个元素是一支给定股票第 i 天的价格。

设计一个算法来计算你所能获取的最大利润。你可以尽可能地完成更多的交易(多次买卖一支股票)。

注意:你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。

示例 1:

1
2
3
4
输入: [7,1,5,3,6,4]
输出: 7
解释: 在第 2 天(股票价格 = 1)的时候买入,在第 3 天(股票价格 = 5)的时候卖出, 这笔交易所能获得利润 = 5-1 = 4 。
随后,在第 4 天(股票价格 = 3)的时候买入,在第 5 天(股票价格 = 6)的时候卖出, 这笔交易所能获得利润 = 6-3 = 3 。

示例 2:
1
2
3
4
5
输入: [1,2,3,4,5]
输出: 4
解释: 在第 1 天(股票价格 = 1)的时候买入,在第 5 天 (股票价格 = 5)的时候卖出, 这笔交易所能获得利润 = 5-1 = 4 。
注意你不能在第 1 天和第 2 天接连购买股票,之后再将它们卖出。
因为这样属于同时参与了多笔交易,你必须在再次购买前出售掉之前的股票。

示例 3:
1
2
3
输入: [7,6,4,3,1]
输出: 0
解释: 在这种情况下, 没有交易完成, 所以最大利润为 0。

题目解析

状态:
有 买入(buy) 和 卖出(sell) 这两种状态。

转移方程:
对比上题,这里可以有无限次的买入和卖出,也就是说 买入 状态之前可拥有 卖出 状态,所以买入的转移方程需要变化。

1
2
buy = max(buy, sell - price[i])
sell = max(sell, buy + prices[i] )

边界:
第一天 buy = -prices[0], sell = 0,最后返回 sell 即可。

代码实现

1
2
3
4
5
6
7
8
9
10
11
12
class Solution {
public int maxProfit(int[] prices) {
if(prices.length <= 1)
return 0;
int buy = -prices[0], sell = 0;
for(int i = 1; i < prices.length; i++) {
sell = Math.max(sell, prices[i] + buy);
buy = Math.max( buy,sell - prices[i]);
}
return sell;
}
}

买卖股票的最佳时机 III

题目来源于 LeetCode 上第 123 号问题:买卖股票的最佳时机 III。题目难度为 Hard,目前通过率为 36.1% 。

题目描述

给定一个数组,它的第 i 个元素是一支给定的股票在第 i 天的价格。

设计一个算法来计算你所能获取的最大利润。你最多可以完成 两笔 交易。

注意: 你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。

示例 1:

1
2
3
4
输入: [3,3,5,0,0,3,1,4]
输出: 6
解释: 在第 4 天(股票价格 = 0)的时候买入,在第 6 天(股票价格 = 3)的时候卖出,这笔交易所能获得利润 = 3-0 = 3 。
随后,在第 7 天(股票价格 = 1)的时候买入,在第 8 天 (股票价格 = 4)的时候卖出,这笔交易所能获得利润 = 4-1 = 3 。

示例 2:
1
2
3
4
5
输入: [1,2,3,4,5]
输出: 4
解释: 在第 1 天(股票价格 = 1)的时候买入,在第 5 天 (股票价格 = 5)的时候卖出, 这笔交易所能获得利润 = 5-1 = 4 。
注意你不能在第 1 天和第 2 天接连购买股票,之后再将它们卖出。
因为这样属于同时参与了多笔交易,你必须在再次购买前出售掉之前的股票。

示例 3:
1
2
3
输入: [7,6,4,3,1] 
输出: 0
解释: 在这个情况下, 没有交易完成, 所以最大利润为 0。

题目解析

这里限制了最多两笔交易。

状态:
有 第一次买入(fstBuy) 、 第一次卖出(fstSell)、第二次买入(secBuy) 和 第二次卖出(secSell) 这四种状态。

转移方程:
这里可以有两次的买入和卖出,也就是说 买入 状态之前可拥有 卖出 状态,所以买入和卖出的转移方程需要变化。

fstBuy = max(fstBuy , -price[i])
fstSell = max(fstSell,fstBuy + prices[i] )
secBuy = max(secBuy ,fstSell -price[i]) (受第一次卖出状态的影响)
secSell = max(secSell ,secBuy + prices[i] )

边界:
一开始 fstBuy = -prices[0]

买入后直接卖出,fstSell = 0

买入后再卖出再买入,secBuy - prices[0]

买入后再卖出再买入再卖出,secSell = 0

最后返回 secSell 。

代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Solution {
public int maxProfit(int[] prices) {
int fstBuy = Integer.MIN_VALUE, fstSell = 0;
int secBuy = Integer.MIN_VALUE, secSell = 0;
for(int i = 0; i < prices.length; i++) {
fstBuy = Math.max(fstBuy, -prices[i]);
fstSell = Math.max(fstSell, fstBuy + prices[i]);
secBuy = Math.max(secBuy, fstSell - prices[i]);
secSell = Math.max(secSell, secBuy + prices[i]);
}
return secSell;

}
}

字符匹配类动态规划

字符匹配类动态规划,你一听名字就知道和字符串匹配相关,这类题型它其实是 序列类动态规划 的一个递进,它有时也被称为 双序列类动态规划。

在 序列类动态规划 中,题目的输入是一个数组或是字符串,然后让你基于这个输入数组或是字符串进行一系列的判断,往往我们拆解问题、分析状态的时候只需要考虑一个维度的状态,比如刷房子和抢房子相关的问题,我们只需要考虑此时的房子和之前考虑过的房子之间的联系,思维始终是在一条线上。

回到字符匹配类动态规划,题目要你分析的是两个序列彼此之间的联系,这里其实有一个动态规划状态维度的提升,在考虑当前子问题的时候,我们要同时考虑两个序列的状态,当然,一般说来,动态规划状态维度的提升,也意味着难度的提升,可能刚从一维变成二维,你会不太习惯,没关系,多思考就好了,对于字符匹配类动态规划,它的题目特征其实特别明显,比如:

输入是两个字符串,问是否通过一定的规则相匹配
输入是两个字符串,问两个字符串是否存在包含被包含的关系
输入是两个字符串,问一个字符串怎样通过一定规则转换成另一个字符串
输入是两个字符串,问它们的共有部分
。。。
另外说一下,这类问题的难点在于问题的拆解上面,也就是如何找到当前问题和子问题的联系。

往往这类问题的状态比较好找,你可以先假设状态 dp[i][j] 就是子问题 str1(0…i) str2(0…j) 的状态。拆解问题主要思考 dp[i][j] 和子问题的状态 dp[i - 1][j],dp[i - 1][j] 以及 dp[i - 1][j - 1] 的联系,因为字符串会存在空串的情况,所以动态规划状态数组往往会多开一格。

当然,对于这类问题,如果你还是没有什么思路或者想法,我给你的建议是 画表格,我们结合实际题目一起来看看。

题目分析

LeetCode 第 1143 号问题:最长公共子序列。

题目描述

给定两个字符串 text1 和 text2,返回这两个字符串的最长公共子序列。

一个字符串的 子序列 是指这样一个新的字符串:它是由原字符串在不改变字符的相对顺序的情况下删除某些字符(也可以不删除任何字符)后组成的新字符串。
例如,”ace” 是 “abcde” 的子序列,但 “aec” 不是 “abcde” 的子序列。两个字符串的「公共子序列」是这两个字符串所共同拥有的子序列。

若这两个字符串没有公共子序列,则返回 0。

示例 1:

1
2
3
输入:text1 = "abcde", text2 = "ace" 
输出:3
解释:最长公共子序列是 "ace",它的长度为 3。

示例 2:
1
2
3
输入:text1 = "abc", text2 = "abc"
输出:3
解释:最长公共子序列是 "abc",它的长度为 3。

示例 3:
1
2
3
输入:text1 = "abc", text2 = "def"
输出:0
解释:两个字符串没有公共子序列,返回 0。

题目分析

这里还是按之前的四个步骤来思考,当然这只是一个框架用来辅助你思考,不用特别拘泥于这四个步骤:

问题拆解:

我们要求解 str1(0,…m) 和 str2(0,…n) 的最长公共子序列,如果这是最终要求解的问题,那么它的子问题是什么呢?其实是 str1(0,…m-1) 和 str2(0,…n-1),以及 str1(0,…m-1) 和 str2(0,…n),还有 str1(0,…m) 和 str2(0,…n-1),如果要找它们之间的关系,那我们需要思考一个问题就是,这些子问题怎么变成最终要求解的问题,当前的问题考虑当前字符是否相等,很直接的一个发现就是,如果 str1(m)==str2(n),那么我们就可以将子问题中的 str1(0,…m-1) 和 str2(0,…n-1) 后面添加两个相同字符递进成当前问题;如果不相等,我们就需要考虑在三个子问题中选择一个较大值了。

说到这里,如果你还是不太清楚问题之间的联系,那我们一起来画画表格,熟悉一下这个过程:

题目求解 text1 = “abcde”, text2 = “ace” 的最长公共子序列

1
2
3
4
5
6
7
 ""  a  c  e
"" 0 0 0 0
a 0 如果其中一个字符串是空串
b 0 那么两个字符不存在公共子序列
c 0 对应的子问题状态初始化为 0
d 0
e 0

1
2
3
4
5
6
7
 ""  a  c  e
"" 0 0 0 0
a 0 1 1 1 text1 = "a" text2 = "a" || text2 = "ac" || text2 = "ace"
b 0 考虑当前状态 dp[i][j] 的时候
c 0 我们可以考虑子状态 dp[i - 1][j - 1]
d 0 dp[i][j - 1]
e 0 dp[i - 1][j]

1
2
3
4
5
6
7
 ""  a  c  e
"" 0 0 0 0
a 0 1 1 1
b 0 1 1 1 text1 = "ab" text2 = "a" || text2 = "ac" || text2 = "ace"
c 0
d 0
e 0

1
2
3
4
5
6
7
 ""  a  c  e
"" 0 0 0 0
a 0 1 1 1
b 0 1 1 1
c 0 1 2 2 text1 = "abc" text2 = "a" || text2 = "ac" || text2 = "ace"
d 0 画到这里,不知道你有没有发现当当前的字符不相同时
e 0 dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1])

1
2
3
4
5
6
7
 ""  a  c  e
"" 0 0 0 0
a 0 1 1 1
b 0 1 1 1
c 0 1 2 2
d 0 1 2 2 text1 = "abcd" text2 = "a" || text2 = "ac" || text2 = "ace"
e 0

1
2
3
4
5
6
7
 ""  a  c  e
"" 0 0 0 0
a 0 1 1 1
b 0 1 1 1
c 0 1 2 2
d 0 1 2 2
e 0 1 2 3 text1 = "abcde" text2 = "a" || text2 = "ac" || text2 = "ace"

3 就是我们要返回的答案
状态定义:
dp[i][j] 表示的就是 str1(0,…i) 和 str2(0,…j) 的答案,基本上字符串匹配类动态规划都可以先尝试这样去定义状态

递推方程:
在拆解问题中也说了,有两种情况,就是:

如果 str1(i) != str2(j):
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1])

如果 str1(i) == str2(j):
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1] + 1)

因为 dp[i - 1][j - 1] + 1 >= dp[i - 1][j] && dp[i - 1][j - 1] + 1 >= dp[i][j - 1]
所以第二项可以化简:

如果 str1(i) == str2(j):
dp[i][j] = dp[i - 1][j - 1] + 1

实现
通常来说字符相关的问题可以把状态数组多开一格用来存放空串匹配的情况,这道题空串的情况答案都是 0,使用 Java 语言也不需要考虑初始化

参考代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public int longestCommonSubsequence(String text1, String text2) {
int length1 = text1.length();
int length2 = text2.length();

int[][] dp = new int[length1 + 1][length2 + 1];

char[] textArr1 = text1.toCharArray();
char[] textArr2 = text2.toCharArray();

for (int i = 1; i <= length1; ++i) {
for (int j = 1; j <= length2; ++j) {
if (textArr1[i - 1] == textArr2[j - 1]) {
dp[i][j] = dp[i - 1][j - 1] + 1;
} else {
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
}
}
}

return dp[length1][length2];
}

LeetCode 第 72 号问题:编辑距离。

题目描述

给定两个单词 word1 和 word2,计算出将 word1 转换成 word2 所使用的最少操作数 。

你可以对一个单词进行如下三种操作:

插入一个字符
删除一个字符
替换一个字符
示例 1:

1
2
3
4
5
6
输入: word1 = "horse", word2 = "ros"
输出: 3
解释:
horse -> rorse (将 'h' 替换为 'r')
rorse -> rose (删除 'r')
rose -> ros (删除 'e')

示例 2:
1
2
3
4
5
6
7
8
输入: word1 = "intention", word2 = "execution"
输出: 5
解释:
intention -> inention (删除 't')
inention -> enention (将 'i' 替换为 'e')
enention -> exention (将 'n' 替换为 'x')
exention -> exection (将 'n' 替换为 'c')
exection -> execution (插入 'u')

题目分析

求解编辑距离,也是经典老题,编辑距离其实在实际工作中也会用到,主要用于分析两个单词的相似程度,两个单词的编辑距离越小证明两个单词的相似度越高。

题目说可以通过增加字符,删除字符,以及 替换字符 这三个操作来改变一个字符串,并且每个操作的 cost 都是 1,问一个单词转换成另一个单词的最小 cost,老样子,四个步骤分析一遍:

问题拆解:
我们考虑求解 str1(0…m) 通过多少 cost 变成 str2(0…n),还是来看看它的子问题,其实还是三个

str1(0…m-1) 通过多少 cost 变成 str2(0…n)
str1(0…m) 通过多少 cost 变成 str2(0…n-1)
str1(0…m-1) 通过多少 cost 变成 str2(0…n-1)

一般字符匹配类问题的核心永远是两个字符串中的字符的比较,而且字符比较也只会有两种结果,那就是 相等 和 不相等,在字符比较的结果之上我们才会进行动态规划的统计和推导。

回到这道题,当我们在比较 str1(m) 和 str2(n) 的时候也会有两种结果,即 相等 或 不相等,如果说是 相等,那其实我们就不需要考虑这两个字符,问题就直接变成了子问题 str1(0…m-1) 通过多少 cost 变成 str2(0…n-1),如果说 不相等,那我们就可以执行题目给定的三种变换策略:

将问题中的 str1 末尾字符 str1(m) 删除,因此只需要考虑子问题 str1(0…m-1),str2(0…n)

将问题中的 str1 末尾字符 str1(m) 替换 成 str2(n),这里我们就只需要考虑子问题 str1(0…m-1),str2(0…n-1)

将问题中的 str1 末尾 添加 一个字符 str2(n),添加后 str1(m+1) 必定等于 str2(n),所以,我们就只需要考虑子问题 str1(0…m),str2(0…n-1)

如果你还不是特别清楚问题之间的关系,那就画图表吧,这里我就略过。

状态定义

dp[i][j] 表示的是子问题 str1(0…i),str2(0…j) 的答案,和常规的字符匹配类动态规划题目一样,没什么特别

递推方程:
问题拆解那里其实说的比较清楚了,这里需要把之前的描述写成表达式的形式:

1
2
3
4
5
6
7
str1(i) == str2(j):
dp[i][j] = dp[i - 1][j - 1]
tip: 这里不需要考虑 dp[i - 1][j] 以及 dp[i][j - 1],因为
dp[i - 1][j - 1] <= dp[i - 1][j] +1 && dp[i - 1][j - 1] <= dp[i][j - 1] + 1

str1(i) != str2(j):
dp[i][j] = Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][i - 1]) + 1

你可以看到字符之间比较的结果永远是递推的前提

实现:
这里有一个初始化,就是当一个字符串是空串的时候,转化只能通过添加元素或是删除元素来达成,那这里状态数组中存的值其实是和非空字符串的字符数量保持一致。

参考代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public int minDistance(String word1, String word2) {
char[] arr1 = word1.toCharArray();
char[] arr2 = word2.toCharArray();

int[][] dp = new int[arr1.length + 1][arr2.length + 1];
dp[0][0] = 0;
for (int i = 1; i <= arr1.length; ++i) {
dp[i][0] = i;
}

for (int i = 1; i <= arr2.length; ++i) {
dp[0][i] = i;
}

for (int i = 1; i <= arr1.length; ++i) {
for (int j = 1; j <= arr2.length; ++j) {
if (arr1[i - 1] == arr2[j - 1]) {
dp[i][j] = dp[i - 1][j - 1];
} else {
dp[i][j] = Math.min(dp[i - 1][j],
Math.min(dp[i][j - 1], dp[i - 1][j - 1])) + 1;
}
}
}

return dp[arr1.length][arr2.length];
}

LeetCode 第 44 号问题:通配符匹配。

题目描述

给定一个字符串 (s) 和一个字符模式 (p) ,实现一个支持 ?* 的通配符匹配。

? 可以匹配任何单个字符。
* 可以匹配任意字符串(包括空字符串)。
两个字符串完全匹配才算匹配成功。

说明:

s 可能为空,且只包含从 a-z 的小写字母。
p 可能为空,且只包含从 a-z 的小写字母,以及字符 ?*
示例 1:

1
2
3
4
5
输入:
s = "aa"
p = "a"
输出: false
解释: "a" 无法匹配 "aa" 整个字符串。

示例 2:
1
2
3
4
5
输入:
s = "aa"
p = "*"
输出: true
解释: '*' 可以匹配任意字符串。

示例 3:
1
2
3
4
5
输入:
s = "cb"
p = "?a"
输出: false
解释: '?' 可以匹配 'c', 但第二个 'a' 无法匹配 'b'。

示例 4:
1
2
3
4
5
输入:
s = "adceb"
p = "*a*b"
输出: true
解释: 第一个 '*' 可以匹配空字符串, 第二个 '*' 可以匹配字符串 "dce".

示例 5:
1
2
3
4
输入:
s = "acdcb"
p = "a*c?b"
输入: false

题目分析:
题目给定两个字符串,一个字符串是匹配串,除了小写字母外,匹配串里面还包含 * 和 ? 这两个特殊字符,另一个是普通字符串,里面只包含小写字母。

题目问这个普通字符串是否和匹配字符串相匹配,匹配规则是 ? 可以匹配单个字符,* 可以匹配一个区间,也就是多个字符,当然也可以匹配 0 个字符,也就是空串。

依然是四个步骤走一遍:

问题拆解:
做多了,你发现这种问题其实都是一个套路,老样子,我们还是根据我们要求解的问题去看和其直接相关的子问题,我们需要求解的问题是 pattern(0…m) 和 str(0…n) 是否匹配,这里的核心依然是字符之间的比较,但是和之前不同的是,这个比较不仅仅是看两个字符相不相等,它还有了一定的匹配规则在里面,那我们就依次枚举讨论下:

pattern(m) == str(n):
问题拆解成看子问题 pattern(0…m-1) 和 str(0…n-1) 是否匹配
pattern(m) == ?:
问题拆解成看子问题 pattern(0…m-1) 和 str(0…n-1) 是否匹配

你发现弄来弄去,子问题依然是那三个:
pattern(0…m-1) 和 str(0…n-1) 是否匹配
pattern(0…m-1) 和 str(0…n) 是否匹配
pattern(0…m) 和 str(0…n-1) 是否匹配

不知道你是否发现了字符匹配类动态规划问题的共性,如果是画表格,你只需要关注当前格子的 左边、上边、左上 这三个位置的相邻元素,因为表格有实际数据做辅助,所以画表格有时可以帮助你找到问题与子问题之间的联系。

状态定义:
还是老样子,dp[i][j] 表示的就是问题 pattern(0…i) 和 str(0…j) 的答案,直接说就是 pattern(0…i) 和 str(0…j) 是否匹配

递推方程:
把之前 “问题拆解” 中的文字描述转换成状态的表达式就是递推方程:

1
2
3
4
5
pattern(i) == str(j) || pattern(i) == '?':
dp[i][j] = dp[i - 1][j - 1]

pattern(i) == '*':
dp[i][j] = dp[i - 1][j] || dp[i][j - 1]

实现
这类问题的状态数组往往需要多开一格,主要是为了考虑空串的情况,这里我就不赘述了。

我想说的是,关于初始化的部分,如果 str 是空的,pattern 最前面有 *,因为 * 是可以匹配空串的,因此这个也需要记录一下,反过来,如果 pattern 是空的,str 只要不是空的就无法匹配,这里就不需要特别记录。

参考代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public boolean isMatch(String s, String p) {
char[] sArr = s.toCharArray();
char[] pArr = p.toCharArray();

boolean[][] dp = new boolean[pArr.length + 1][sArr.length + 1];

dp[0][0] = true;
for (int i = 1; i <= pArr.length; ++i) {
if (pArr[i - 1] != '*') {
break;
} else {
dp[i][0] = true;
}
}

for (int i = 1; i <= pArr.length; ++i) {
for (int j = 1; j <= sArr.length; ++j) {
if (sArr[j - 1] == pArr[i - 1] || pArr[i - 1] == '?') {
dp[i][j] = dp[i - 1][j - 1];
} else if (pArr[i - 1] == '*') {
dp[i][j] = dp[i - 1][j] || dp[i][j - 1];
}
}
}

return dp[pArr.length][sArr.length];
}

LeetCode 第 97 号问题

题目描述

给定三个字符串 s1, s2, s3, 验证 s3 是否是由 s1 和 s2 交错组成的。

示例 1:

1
2
输入: s1 = "aabcc", s2 = "dbbca", s3 = "aadbbcbcac"
输出: true

示例 2:
1
2
输入: s1 = "aabcc", s2 = "dbbca", s3 = "aadbbbaccc"
输出: false

题目分析

题目的输入是三个字符串,问其中两个字符串是否能够交错合并组成第三个字符串,一个字符相对于其他字符的顺序在合并之后不能改变,这也是这道题的难点,不然的话你用一个哈希表就可以做了,三个字符串是否意味着要开三维的状态数组?还是四个步骤来看看:

问题拆解:
在拆解问题之前,我们必须保证前两个字符串的字符的总数量必须正好等于第三个字符串的字符总数量,不然的话,再怎么合并也无法完全等同。这里有一个点,当我们考虑 str1(0…i) 和 str2(0…j) 的时候,其实第三个字串需要考虑的范围也就确定了,就是 str3(0…i+j)。如果我们要求解问题 str1(0…m) 和 str2(0…n) 是否能够交错组成 str3(0…m+n),还是之前那句话,字符串匹配问题的核心永远是字符之间的比较:

如果 str1(m) == str3(m+n),问题拆解成考虑子问题 str1(0…m-1) 和 str2(0…n) 是否能够交错组成 str3(0…m+n-1)
如果 str2(n) == str3(m+n),问题拆解成考虑子问题 str1(0…m) 和 str2(0…n-1) 是否能够交错组成 str3(0…m+n-1)

你可能会问需不需要考虑子问题 str1(0…m-1) 和 str2(0…n-1)?

在这道题目当中,不需要!

千万不要题目做多了就固定思维了,之前说到这类问题可以试着考虑三个相邻子问题是为了让你有个思路,能更好地切题,并不是说所有的字符串匹配问题都需要考虑这三个子问题,我们需要遇到具体问题具体分析。

状态定义:
dp[i][j] 表示的是 str1(0…i) 和 str2(0…j) 是否可以交错组成 str3(0…i+j),这里再补充说明下为什么我们不需要开多一维状态来表示 str3,其实很简单,str3 的状态是由 str1 str2 决定的,str1 str2 定了,str3 就定了

递推方程:
把之前问题拆解中的文字描述转换成状态的表达式就是递推方程:

1
2
3
4
5
str1(i) == str3(i+j)
dp[i][j] |= dp[i - 1][j]

str2(j) == str3(i+j)
dp[i][j] |= dp[i - 1][j]

实现

初始化的时候需要考虑单个字符串能否组成 str3 对应的区间,这个比较简单,直接判断前缀是否相等即可。

参考代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public boolean isInterleave(String s1, String s2, String s3) {
int length1 = s1.length();
int length2 = s2.length();
int length3 = s3.length();

if (length1 + length2 != length3) {
return false;
}

boolean[][] dp = new boolean[length1 + 1][length2 + 1];

dp[0][0] = true;

char[] sArr1 = s1.toCharArray();
char[] sArr2 = s2.toCharArray();
char[] sArr3 = s3.toCharArray();

for (int i = 1; i <= length1; ++i) {
dp[i][0] = dp[i - 1][0] && sArr1[i - 1] == sArr3[i - 1];
}

for (int i = 1; i <= length2; ++i) {
dp[0][i] = dp[0][i - 1] && sArr2[i - 1] == sArr3[i - 1];
}

for (int i = 1; i <= length1; ++i) {
for (int j = 1; j <= length2; ++j) {
if (sArr3[i + j - 1] == sArr1[i - 1]) {
dp[i][j] |= dp[i - 1][j];
}

if (sArr3[i + j - 1] == sArr2[j - 1]) {
dp[i][j] |= dp[i][j - 1];
}
}
}

return dp[length1][length2];
}

背包问题

概述

背包问题是一类比较 特殊的动态规划 问题,这篇文章的侧重点会在答案的推导过程上,我们还是会使用之前提到的解动态规划问题的四个步骤来思考这类问题。

在讲述背包问题之前,首先提及一下,背包类动态规划问题和其他的动态规划问题的不同之处在于,背包类动态规划问题会选用值来作为动态规划的状态,你可以回顾下之前我们讨论过的动态规划问题,基本上都是利用数组或者是字符串的下标来表示动态规划的状态。

针对背包类问题,我们依然可以 画表格 来辅助我们思考问题,但是背包类问题有基本的雏形,题目特征特别明显,当你理解了这类问题的解法后,遇到类似问题基本上不需要额外的辅助就可以给出大致的解法,这也就是说,学习背包类问题是一个性价比很高的事情,理解了一个特定问题的解法,基本上一类问题都可以直接套这个解法。

问题雏形

首先我们来看看这样一个问题:

有 N 件物品和一个容量为 V 的背包。第 i 件物品的体积是 C[i],价值是 W[i]。求解将哪些物品装入背包可使价值总和最大。求出最大总价值

话不多说,我们还是按之前的分析四步骤来看看这个问题:

问题拆解:
我们要求解的问题是 “背包能装入物品的最大价值”,这个问题的结果受到两个因素的影响,就是背包的大小,以及物品的属性(包括大小和价值)。对于物品来说,只有两种结果,放入背包以及不放入背包,这里我们用一个例子来画画表格:

假设背包的大小是 10,有 4 个物品,体积分别是 [2,3,5,7],价值分别是 [2,5,2,5]。

1、如果我们仅考虑将前一个物品放入背包,只要背包体积大于 2,此时都可以获得价值为 2 的最大价值:


图一

2、如果我们仅考虑将前两个物品放入背包,如果背包体积大于或等于 5,表示两个物体都可放入,此时都可以获得价值为 2+5=7 的最大价值,如果不能全都放入,那就要选择体积不超,价值最大的那个:


图二

3、如果我们仅考虑将前三个物品放入背包,如果背包体积大于或等于 10,表示三个物体都可放入,此时都可以获得价值为 2+5+2=9 的最大价值,如果不能全都放入,那就要选择体积不超,价值最大的那个方案:


图三
4、如果我们考虑将所有物品放入背包,我们可以依据前三个物品放入的结果来制定方案:


图四
这样,我们就根据物品和体积将问题拆分成子问题,也就是 “前 n 个物品在体积 V 处的最大价值” 可以由 “前 n - 1 个物品的情况” 推导得到。

状态定义:
在问题拆解中,我们得知问题其实和背包的体积还有当前考虑的物品有关,因此我们可以定义 dp[i][j] 表示 “考虑将前 i 个物品放入体积为 j 的背包里所获得的最大价值”

递推方程:
当我们考虑是否将第 i 个物品放入背包的时候,这里有两种情况

不放入,也就是不考虑第 i 个物品,那么问题就直接变成了上一个子问题,也就是考虑将 i - 1 个物品放入背包中,这样当前问题的解就是之前问题的解:

1
dp[i][j] = dp[i - 1][j]

如果背包体积大于第 i 个物品的体积,我们可以考虑将第 i 个物品放入,这个时候我们要和之前的状态做一个比较,选取最大的方案:
1
dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - C[i]] + W[i])

实现
实现这一环节还是主要考虑状态数组如何初始化,你可以看到,我们每次都要考虑 i - 1,另外还要考虑背包体积为 0 的情况,因此初始化数组时多开一格可以省去不必要的麻烦
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public int zeroOnePack(int V, int[] C, int[] W) { 
// 防止无效输入
if ((V <= 0) || (C.length != W.length)) {
return 0;
}

int n = C.length;

// dp[i][j]: 对于下标为 0~i 的物品,背包容量为 j 时的最大价值
int[][] dp = new int[n + 1][V + 1];

// 背包空的情况下,价值为 0
dp[0][0] = 0;

for (int i = 1; i <= n; ++i) {
for (int j = 1; j <= V; ++j) {
// 不选物品 i 的话,当前价值就是取到前一个物品的最大价值,也就是 dp[i - 1][j]
dp[i][j] = dp[i - 1][j];

// 如果选择物品 i 使得当前价值相对不选更大,那就选取 i,更新当前最大价值
if ((j >= C[i - 1]) && (dp[i][j] < dp[i - 1][j - C[i - 1]] + W[i - 1])) {
dp[i][j] = dp[i - 1][j - C[i - 1]] + W[i - 1];
}
}
}

// 返回,对于所有物品(0~N),背包容量为 V 时的最大价值
return dp[n][V];
}

这里还有一个空间上面的优化,如果你回到我们之前画的表格,考虑前 i 个问题的状态只会依赖于前 i - 1 个问题的状态,也就是 dp[i][…] 只会依赖于 dp[i - 1][…],另外一点就是当前考虑的背包体积只会用到比其小的体积。

基于这些信息,我们状态数组的维度可以少开一维,但是遍历的方向上需要从后往前遍历,从而保证子问题需要用到的数据不被覆盖,优化版本如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public int zeroOnePackOpt(int V, int[] C, int[] W) { 
// 防止无效输入
if ((V <= 0) || (C.length != W.length)) {
return 0;
}

int n = C.length;

int[] dp = new int[V + 1];

// 背包空的情况下,价值为 0
dp[0] = 0;

for (int i = 0; i < n; ++i) {
for (int j = V; j >= C[i]; --j) {
dp[j] = Math.max(dp[j], dp[j - C[i]] + W[i]);
}
}

return dp[V];
}

这里,因为物品只能被选中 1 次,或者被选中 0 次,因此我们称这种背包问题为 01 背包问题。

还有一类背包问题,物品可以被选多次或者 0 次,这类问题我们称为 完全背包问题,这类背包问题和 01 背包问题很类似,略微的不同在于,在完全背包问题中,状态 dp[i][j] 依赖的是 dp[i - 1][j] 以及 dp[i][k] k < j,你可以看看下面的实现代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public int completePack(int V, int[] C, int[] W) {
// 防止无效输入
if (V == 0 || C.length != W.length) {
return 0;
}

int n = C.length;

// dp[i][j]: 对于下标为 0~i 的物品,背包容量为 j 时的最大价值
int[][] dp = new int[n + 1][V + 1];

// 背包空的情况下,价值为 0
dp[0][0] = 0;

for (int i = 1; i <= n; ++i) {
for (int j = 1; j <= V; ++j) {
// 不取该物品
dp[i][j] = dp[i - 1][j];

// 取该物品,但是是在考虑过或者取过该物品的基础之上(dp[i][...])取
// 0-1背包则是在还没有考虑过该物品的基础之上(dp[i - 1][...])取
if ((j >= C[i - 1]) && (dp[i][j - C[i - 1]] + W[i - 1] > dp[i][j])) {
dp[i][j] = dp[i][j - C[i - 1]] + W[i - 1];
}
}
}

// 返回,对于所有物品(0~N),背包容量为 V 时的最大价值
return dp[n][V];
}

类似的,我们还是可以对状态数组进行空间优化,依据我们之前讨论的状态之间的依赖关系,完全背包的空间优化我们直接把状态数组少开一维即可,遍历方式都不需要改变:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public int completePackOpt(int V, int[] C, int[] W) {
if (V == 0 || C.length != W.length) {
return 0;
}

int n = C.length;
int[] dp = new int[V + 1];
for (int i = 0; i < n; ++i) {
for (int j = C[i]; j <= V; ++j) {
dp[j] = Math.max(dp[j], dp[j - C[i]] + W[i]);
}
}

return dp[V];
}

下面,我们就根据这两类背包问题,看看遇到类似的问题我们是否可以套用上面我们介绍的解法。

相关题目实战

LeetCode 第 416 号问题:分割等和子集。

题目来源:https://leetcode-cn.com/problems/partition-equal-subset-sum/

题目描述

给定一个只包含正整数的非空数组。是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。

注意:

每个数组中的元素不会超过 100
数组的大小不会超过 200
示例 1:

1
2
3
输入: [1, 5, 11, 5]
输出: true
解释: 数组可以分割成 [1, 5, 5] 和 [11].

示例 2:
1
2
3
输入: [1, 2, 3, 5]
输出: false
解释: 数组不能分割成两个元素和相等的子集.

题目分析

题目给定一个数组,问是否可以将数组拆分成两份,并且两份的值相等,这里并不是说分成两个子数组,而是分成两个子集。

直观的想法是直接遍历一遍数组,这样我们可以得到数组中所有元素的和,这个和必须是偶数,不然没法分,其实很自然地就可以想到,我们要从数组中挑出一些元素,使这些元素的和等于原数组中元素总和的一半,“从数组中找出一些元素让它们的和等于一个固定的值”,这么一个信息能否让你想到背包类动态规划呢?

如果你能想到这个地方,再配上我们之前讲的 01 背包问题 的解法,那么这道题目就可以直接套解法了,这里我就不具体分析了。

参考代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public boolean canPartition(int[] nums) {
if (nums == null || nums.length == 0) {
return false;
}

int sum = 0;

int n = nums.length;

for (int i = 0; i < n; ++i) {
sum += nums[i];
}

if (sum % 2 != 0) {
return false;
}

int target = sum / 2;

boolean[] dp = new boolean[target + 1];

dp[0] = true;

for (int i = 0; i < n; ++i) {
for (int j = target; j >= nums[i]; --j) {
dp[j] |= dp[j - nums[i]];
}
}

return dp[target];
}

LeetCode 第 322 号问题:零钱兑换。

题目来源:https://leetcode-cn.com/problems/coin-change

题目描述

给定不同面额的硬币 coins 和一个总金额 amount。编写一个函数来计算可以凑成总金额所需的最少的硬币个数。如果没有任何一种硬币组合能组成总金额,返回 -1。

示例 1:

1
2
3
输入: coins = [1, 2, 5], amount = 11
输出: 3
解释: 11 = 5 + 5 + 1

示例 2:
1
2
3
4
输入: coins = [2], amount = 3
输出: -1
说明:
你可以认为每种硬币的数量是无限的。

题目分析

题目给定一个数组和一个整数,数组里面的值表示的是每个硬币的价值,整数表示的是一个价值,问最少选择多少个硬币能够组成这个价值,硬币可以重复选择。

虽然这里只有一个输入数组,但是我们还是可以看到背包的影子,这里的整数就可以看作是背包的体积,然后数组里面的值可以看作是物品的体积,那物品的价值呢?

在这里,你可以形象地认为每个物品的价值是 1,最后我们要求的是填满背包的最小价值,因为这里物品是可以重复选择多次的,因此可以归类于 完全背包问题,套用之前的解法就可以解题,唯一要注意的一点是,这里我们不在求最大价值,而求的是最小价值,因此我们需要先将状态数组初始化成无穷大。

参考代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public int coinChange(int[] coins, int amount) {
int[] dp = new int[amount + 1];

Arrays.fill(dp, Integer.MAX_VALUE);

dp[0] = 0;

for (int i = 0; i < coins.length; ++i) {
for (int j = coins[i]; j <= amount; ++j) {
if (dp[j - coins[i]] != Integer.MAX_VALUE) {
dp[j] = Math.min(dp[j - coins[i]] + 1, dp[j]);
}
}
}

return dp[amount] == Integer.MAX_VALUE ? -1 : dp[amount];
}

LeetCode 第 518 号问题:零钱兑换II。

题目来源:https://leetcode-cn.com/problems/coin-change-2/

题目描述

给定不同面额的硬币和一个总金额。写出函数来计算可以凑成总金额的硬币组合数。假设每一种面额的硬币有无限个。

示例 1:

1
2
3
4
5
6
7
输入: amount = 5, coins = [1, 2, 5]
输出: 4
解释: 有四种方式可以凑成总金额:
5=5
5=2+2+1
5=2+1+1+1
5=1+1+1+1+1

示例 2:
1
2
3
输入: amount = 3, coins = [2]
输出: 0
解释: 只用面额2的硬币不能凑成总金额3。

示例 3:
1
2
输入: amount = 10, coins = [10] 
输出: 1

注意:

你可以假设:

0 <= amount (总金额) <= 5000
1 <= coin (硬币面额) <= 5000
硬币种类不超过 500 种
结果符合 32 位符号整数

题目分析

这道题目是上一道题目的变形,题目的输入参数还是不变,变的是最后的问题,这里需要求的是 “有多少种组合方式能够填满背包”,我们还是可以套用 完全背包 的解法,只是最后求解的东西变了,那我们动态规划状态数组中记录的东西相应的改变即可,在这道题中,状态数组中记录组合成该价值的方案的个数即可。

参考代码

1
2
3
4
5
6
7
8
9
10
11
12
public int change(int amount, int[] coins) {
int[] dp = new int[amount + 1];

dp[0] = 1;
for (int i = 0; i < coins.length; ++i) {
for (int j = coins[i]; j <= amount; ++j) {
dp[j] += dp[j - coins[i]];
}
}

return dp[amount];
}

K Sum。

题目描述

给定一个输入数组 array,还有两个整数 k 和 target,在数组 array 中找出 k 个元素,使得这 k 个元素相加等于 target,问有多少种组合方式,输出组合方式的个数。

注:在一种组合方式中,一个元素不能够被重复选择

题目分析

我们之前讲过 Two Sum,也提到过 3 Sum,还有 4 Sum,那这道题是否可以套用之前的解法呢?

这里有一个细节不知道你是否发现,就是 这道题目仅仅是让你输出所有组合方式的个数,并没有让你输出所有的组合方式,这是决定是否使用动态规划很重要的一点。

如果没有这个 k,我相信你会很直接地想到使用 01 背包问题 的解法,那我们可以思考一下,基于原来的解法,如果增加了 k 这个限制,我们需要额外做些什么事情呢?

因为 k 会决定问题的状态,因此我们的状态数组中也要考虑 k,在考虑将第 k 个元素放入背包中,我们需要看的是背包中存放 k - 1 个元素的情况,这么看来,其实相比普通的 01 背包问题,这道题目仅仅是增加了一维状态,没有其他的变化。

参考代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public int kSum(int[] array, int k, int target) {
int[][] dp = new int[target + 1][k + 1];

dp[0][0] = 1;

for (int i = 0; i < array.length; ++i) {
for (int j = target; j >= array[i]; --j) {
// 和普通 01背包问题 相比,仅仅是多了一层状态需要考虑
// 这层状态记录的是背包里面元素的个数
// 我们放入第 r 个元素的时候,必须确保背包里面已经有 r - 1 个元素
for (int r = 1; r <= k; ++r) {
dp[j][r] += dp[j - array[i]][r - 1];
}
}
}

return dp[target][k];
}

动态规划之背包问题系列

背包问题是一类经典的动态规划问题,它非常灵活,需要仔细琢磨体会,本文先对背包问题的几种常见类型作一个总结,再给出代码模板,然后再看看LeetCode上几个相关题目。

根据维基百科,背包问题(Knapsack problem)是一种组合优化的NP完全(NP-Complete,NPC)问题。问题可以描述为:给定一组物品,每种物品都有自己的重量和价格,在限定的总重量内,我们如何选择,才能使得物品的总价格最高。NPC问题是没有多项式时间复杂度的解法的,但是利用动态规划,我们可以以伪多项式时间复杂度求解背包问题。一般来讲,背包问题有以下几种分类:

  • 01背包问题
  • 完全背包问题
  • 多重背包问题

此外,还存在一些其他考法,例如恰好装满、求方案总数、求所有的方案等。本文接下来就分别讨论一下这些问题。

01背包

题目

最基本的背包问题就是01背包问题(01 knapsack problem):一共有N件物品,第i(i从1开始)件物品的重量为w[i],价值为v[i]。在总重量不超过背包承载上限W的情况下,能够装入背包的最大价值是多少?

分析

如果采用暴力穷举的方式,每件物品都存在装入和不装入两种情况,所以总的时间复杂度是O(2^N),这是不可接受的。而使用动态规划可以将复杂度降至O(NW)。我们的目标是书包内物品的总价值,而变量是物品和书包的限重,所以我们可定义状态dp:

dp[i][j]表示将前i件物品装进限重为j的背包可以获得的最大价值, 0<=i<=N, 0<=j<=W
那么我们可以将dp[0][0…W]初始化为0,表示将前0个物品(即没有物品)装入书包的最大价值为0。那么当 i > 0 时dp[i][j]有两种情况:

  • 不装入第i件物品,即dp[i−1][j]
  • 装入第i件物品(前提是能装下),即dp[i−1][j−w[i]] + v[i]

即状态转移方程为

1
dp[i][j] = max(dp[i−1][j], dp[i−1][j−w[i]]+v[i]) // j >= w[i]

关于初始化,一定要和dp数组的定义吻合,否则到递推公式的时候就会越来越乱。

  • 首先从dp[i][j]的定义出发,如果背包容量j为0的话,即dp[i][0],无论是选取哪些物品,背包价值总和一定为0。
  • 状态转移方程dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);可以看出i 是由 i-1 推导出来,那么i为0的时候就一定要初始化。

dp[0][j]即:i为0,存放编号0的物品的时候,各个容量的背包所能存放的最大价值。那么很明显当 j < weight[0]的时候,dp[0][j] 应该是 0,因为背包容量比编号0的物品重量还小。当j >= weight[0]时,dp[0][j]应该是value[0],因为背包容量放足够放编号0物品。代码初始化如下:

1
2
3
4
5
6
7
for (int j = 0 ; j < weight[0]; j++) {  // 当然这一步,如果把dp数组预先初始化为0了,这一步就可以省略,但很多同学应该没有想清楚这一点。
dp[0][j] = 0;
}
// 正序遍历
for (int j = weight[0]; j <= bagweight; j++) {
dp[0][j] = value[0];
}

那么问题来了,先遍历 物品还是先遍历背包重量呢?其实都可以!!但是先遍历物品更好理解。那么我先给出先遍历物品,然后遍历背包重量的代码。

1
2
3
4
5
6
7
// weight数组的大小 就是物品个数
for(int i = 1; i < weight.size(); i++) { // 遍历物品
for(int j = 0; j <= bagweight; j++) { // 遍历背包容量
if (j < weight[i]) dp[i][j] = dp[i - 1][j];
else dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
}
}

先遍历背包,再遍历物品,也是可以的!(注意我这里使用的二维dp数组)例如这样:

1
2
3
4
5
6
7
// weight数组的大小 就是物品个数
for(int j = 0; j <= bagweight; j++) { // 遍历背包容量
for(int i = 1; i < weight.size(); i++) { // 遍历物品
if (j < weight[i]) dp[i][j] = dp[i - 1][j];
else dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
}
}

为什么也是可以的呢?要理解递归的本质和递推的方向。

dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);递归公式中可以看出dp[i][j]是靠dp[i-1][j]dp[i - 1][j - weight[i]]推导出来的。

由上述状态转移方程可知,dp[i][j]的值只与dp[i-1][0,...,j-1]有关,所以我们可以采用动态规划常用的方法(滚动数组)对空间进行优化(即去掉dp的第一维)。需要注意的是,为了防止上一层循环的dp[0,...,j-1]被覆盖,循环的时候 j 只能逆向枚举(空间优化前没有这个限制),伪代码为:

1
2
3
4
5
// 01背包问题伪代码(空间优化版)
dp[0,...,W] = 0
for i = 1,...,N
for j = W,...,w[i] // 必须逆向枚举!!!
dp[j] = max(dp[j], dp[j−w[i]]+v[i])

时间复杂度为O(NW), 空间复杂度为O(W)。由于W的值是W的位数的幂,所以这个时间复杂度是伪多项式时间。

动态规划的核心思想避免重复计算在01背包问题中体现得淋漓尽致。第i件物品装入或者不装入而获得的最大价值完全可以由前面i-1件物品的最大价值决定,暴力枚举忽略了这个事实。

完全背包

题目

完全背包(unbounded knapsack problem)与01背包不同就是每种物品可以有无限多个:一共有N种物品,每种物品有无限多个,第i(i从1开始)种物品的重量为w[i],价值为v[i]。在总重量不超过背包承载上限W的情况下,能够装入背包的最大价值是多少?

分析一

我们的目标和变量和01背包没有区别,所以我们可定义与01背包问题几乎完全相同的状态dp:

dp[i][j]表示将前i种物品装进限重为j的背包可以获得的最大价值, 0<=i<=N, 0<=j<=W
初始状态也是一样的,我们将dp[0][0…W]初始化为0,表示将前0种物品(即没有物品)装入书包的最大价值为0。那么当 i > 0 时dp[i][j]也有两种情况:

不装入第i种物品,即dp[i−1][j],同01背包;
装入第i种物品,此时和01背包不太一样,因为每种物品有无限个(但注意书包限重是有限的),所以此时不应该转移到dp[i−1][j−w[i]]而应该转移到dp[i][j−w[i]],即装入第i种商品后还可以再继续装入第种商品。
所以状态转移方程为

1
dp[i][j] = max(dp[i−1][j], dp[i][j−w[i]]+v[i]) // j >= w[i]

这个状态转移方程与01背包问题唯一不同就是max第二项不是dp[i-1]而是dp[i]

和01背包问题类似,也可进行空间优化,优化后不同点在于这里的 j 只能正向枚举而01背包只能逆向枚举,因为这里的max第二项是dp[i]而01背包是dp[i-1],即这里就是需要覆盖而01背包需要避免覆盖。所以伪代码如下:

1
2
3
4
5
// 完全背包问题思路一伪代码(空间优化版)
dp[0,...,W] = 0
for i = 1,...,N
for j = w[i],...,W // 必须正向枚举!!!
dp[j] = max(dp[j], dp[j−w[i]]+v[i])

由上述伪代码看出,01背包和完全背包问题此解法的空间优化版解法唯一不同就是前者的 j 只能逆向枚举而后者的 j 只能正向枚举,这是由二者的状态转移方程决定的。此解法时间复杂度为O(NW), 空间复杂度为O(W)。

分析二

除了分析一的思路外,完全背包还有一种常见的思路,但是复杂度高一些。我们从装入第 i 种物品多少件出发,01背包只有两种情况即取0件和取1件,而这里是取0件、1件、2件…直到超过限重(k > j/w[i]),所以状态转移方程为:

1
2
// k为装入第i种物品的件数, k <= j/w[i]
dp[i][j] = max{(dp[i-1][j − k*w[i]] + k*v[i]) for every k}

同理也可以进行空间优化,需要注意的是,这里max里面是dp[i-1],和01背包一样,所以 j 必须逆向枚举,优化后伪代码为

1
2
3
4
5
6
// 完全背包问题思路二伪代码(空间优化版)
dp[0,...,W] = 0
for i = 1,...,N
for j = W,...,w[i] // 必须逆向枚举!!!
for k = [0, 1,..., j/w[i]]
dp[j] = max(dp[j], dp[j−k*w[i]]+k*v[i])

相比于分析一,此种方法不是在O(1)时间求得dp[i][j],所以总的时间复杂度就比分析一大些了,为 O(NWWw¯)级别。

分析三、转换成01背包

01背包问题是最基本的背包问题,我们可以考虑把完全背包问题转化为01背包问题来解:将一种物品转换成若干件只能装入0件或者1件的01背包中的物品。

最简单的想法是,考虑到第 i 种物品最多装入 W/w[i] 件,于是可以把第 i 种物品转化为 W/w[i] 件费用及价值均不变的物品,然后求解这个01背包问题。

更高效的转化方法是采用二进制的思想:把第 i 种物品拆成重量为 wi2k、价值为 vi2k 的若干件物品,其中 k 取遍满足 wi2k≤W 的非负整数。这是因为不管最优策略选几件第 i 种物品,总可以表示成若干个刚才这些物品的和(例:13 = 1 + 4 + 8)。这样就将转换后的物品数目降成了对数级别。具体代码见3.4节模板。

多重背包

题目

多重背包(bounded knapsack problem)与前面不同就是每种物品是有限个:一共有N种物品,第i(i从1开始)种物品的数量为n[i],重量为w[i],价值为v[i]。在总重量不超过背包承载上限W的情况下,能够装入背包的最大价值是多少?

分析一

此时的分析和完全背包的分析二差不多,也是从装入第 i 种物品多少件出发:装入第i种物品0件、1件、…n[i]件(还要满足不超过限重)。所以状态方程为:

1
2
// k为装入第i种物品的件数, k <= min(n[i], j/w[i])
dp[i][j] = max{(dp[i-1][j − k*w[i]] + k*v[i]) for every k}

同理也可以进行空间优化,而且 j 也必须逆向枚举,优化后伪代码为

1
2
3
4
5
6
// 完全背包问题思路二伪代码(空间优化版)
dp[0,...,W] = 0
for i = 1,...,N
for j = W,...,w[i] // 必须逆向枚举!!!
for k = [0, 1,..., min(n[i], j/w[i])]
dp[j] = max(dp[j], dp[j−k*w[i]]+k*v[i])

分析二、转换成01背包

采用2.4节类似的思路可以将多重背包转换成01背包问题,采用二进制思路将第 i 种物品分成了 O(logni) 件物品,将原问题转化为了复杂度为 O(W∑ilogni) 的 01 背包问题,相对于分析一是很大的改进,具体代码见3.4节。

代码模板

此节根据上面的讲解给出这三种背包问题的解题模板,方便解题使用。尤其注意其中二进制优化是如何实现的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
https://tangshusen.me/2019/11/24/knapsack-problem/
01背包, 完全背包, 多重背包模板(二进制优化).
2020.01.04 by tangshusen.

用法:
对每个物品调用对应的函数即可, 例如多重背包:
for(int i = 0; i < N; i++)
multiple_pack_step(dp, w[i], v[i], num[i], W);

参数:
dp : 空间优化后的一维dp数组, 即dp[i]表示最大承重为i的书包的结果
w : 这个物品的重量
v : 这个物品的价值
n : 这个物品的个数
max_w: 书包的最大承重
*/
void zero_one_pack_step(vector<int>&dp, int w, int v, int max_w){
for(int j = max_w; j >= w; j--) // 反向枚举!!!
dp[j] = max(dp[j], dp[j - w] + v);
}

void complete_pack_step(vector<int>&dp, int w, int v, int max_w){
for(int j = w; j <= max_w; j++) // 正向枚举!!!
dp[j] = max(dp[j], dp[j - w] + v);

// 法二: 转换成01背包, 二进制优化
// int n = max_w / w, k = 1;
// while(n > 0){
// zero_one_pack_step(dp, w*k, v*k, max_w);
// n -= k;
// k = k*2 > n ? n : k*2;
// }
}

void multiple_pack_step(vector<int>&dp, int w, int v, int n, int max_w){
if(n >= max_w / w) complete_pack_step(dp, w, v, max_w);
else{ // 转换成01背包, 二进制优化
int k = 1;
while(n > 0){
zero_one_pack_step(dp, w*k, v*k, max_w);
n -= k;
k = k*2 > n ? n : k*2;
}
}
}

其他情形

恰好装满

背包问题有时候还有一个限制就是必须恰好装满背包,此时基本思路没有区别,只是在初始化的时候有所不同。

如果没有恰好装满背包的限制,我们将dp全部初始化成0就可以了。因为任何容量的背包都有一个合法解“什么都不装”,这个解的价值为0,所以初始时状态的值也就全部为0了。如果有恰好装满的限制,那只应该将dp[0,…,N][0]初始为0,其它dp值均初始化为-inf,因为此时只有容量为0的背包可以在什么也不装情况下被“恰好装满”,其它容量的背包初始均没有合法的解,应该被初始化为-inf。

求方案总数

除了在给定每个物品的价值后求可得到的最大价值外,还有一类问题是问装满背包或将背包装至某一指定容量的方案总数。对于这类问题,需要将状态转移方程中的 max 改成 sum ,大体思路是不变的。例如若每件物品均是完全背包中的物品,转移方程即为

1
dp[i][j] = sum(dp[i−1][j], dp[i][j−w[i]]) // j >= w[i]

二维背包

前面讨论的背包容量都是一个量:重量。二维背包问题是指每个背包有两个限制条件(比如重量和体积限制),选择物品必须要满足这两个条件。此类问题的解法和一维背包问题不同就是dp数组要多开一维,其他和一维背包完全一样,例如5.4节。

求最优方案

一般而言,背包问题是要求一个最优值,如果要求输出这个最优值的方案,可以参照一般动态规划问题输出方案的方法:记录下每个状态的最优值是由哪一个策略推出来的,这样便可根据这条策略找到上一个状态,从上一个状态接着向前推即可。

以01背包为例,我们可以再用一个数组G[i][j]来记录方案,设 G[i][j] = 0表示计算 dp[i][j] 的值时是采用了max中的前一项(也即dp[i−1][j]),G[i][j] = 1 表示采用了方程的后一项。即分别表示了两种策略: 未装入第 i 个物品及装了第 i 个物品。其实我们也可以直接从求好的dp[i][j]反推方案:若 dp[i][j] = dp[i−1][j] 说明未选第i个物品,反之说明选了。

LeetCode相关题目

本节对LeetCode上面的背包问题进行讨论。

Partition Equal Subset Sum(分割等和子集)

  1. Partition Equal Subset Sum(分割等和子集)

题目给定一个只包含正整数的非空数组。问是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。

由于所有元素的和sum已知,所以两个子集的和都应该是sum/2(所以前提是sum不能是奇数),即题目转换成从这个数组里面选取一些元素使这些元素和为sum/2。如果我们将所有元素的值看做是物品的重量,每件物品价值都为1,所以这就是一个恰好装满的01背包问题。

我们定义空间优化后的状态数组dp,由于是恰好装满,所以应该将dp[0]初始化为0而将其他全部初始化为INT_MIN,然后按照类似1.2节的伪代码更新dp:

1
2
3
4
5
6
int capacity = sum / 2;
vector<int>dp(capacity + 1, INT_MIN);
dp[0] = 0;
for(int i = 1; i <= n; i++)
for(int j = capacity; j >= nums[i-1]; j--)
dp[j] = max(dp[j], 1 + dp[j - nums[i-1]]);

更新完毕后,如果dp[sum/2]大于0说明满足题意。

由于此题最后求的是能不能进行划分,所以dp的每个元素定义成bool型就可以了,然后将dp[0]初始为true其他初始化为false,而转移方程就应该是用或操作而不是max操作。完整代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
bool canPartition(vector<int>& nums) {
int sum = 0, n = nums.size();
for(int &num: nums) sum += num;
if(sum % 2) return false;

int capacity = sum / 2;
vector<bool>dp(capacity + 1, false);
dp[0] = true;
for(int i = 1; i <= n; i++)
for(int j = capacity; j >= nums[i-1]; j--)
dp[j] = dp[j] || dp[j - nums[i-1]];

return dp[capacity];
}

另外此题还有一个更巧妙更快的解法,基本思路是用一个bisets来记录所有可能子集的和,详见我的Github。

Coin Change(零钱兑换)

  1. Coin Change

题目给定一个价值amount和一些面值,假设每个面值的硬币数都是无限的,问我们最少能用几个硬币组成给定的价值。

如果我们将面值看作是物品,面值金额看成是物品的重量,每件物品的价值均为1,这样此题就是是一个恰好装满的完全背包问题了。不过这里不是求最多装入多少物品而是求最少,我们只需要将2.2节的转态转移方程中的max改成min即可,又由于是恰好装满,所以除了dp[0],其他都应初始化为INT_MAX。完整代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
int coinChange(vector<int>& coins, int amount) {
vector<int>dp(amount + 1, INT_MAX);
dp[0] = 0;

for(int i = 1; i <= coins.size(); i++)
for(int j = coins[i-1]; j <= amount; j++){
// 下行代码会在 1+INT_MAX 时溢出
// dp[j] = min(dp[j], 1 + dp[j - coins[i-1]]);
if(dp[j] - 1 > dp[j - coins[i-1]])
dp[j] = 1 + dp[j - coins[i-1]];
}
return dp[amount] == INT_MAX ? -1 : dp[amount];
}

注意上面1 + dp[j - coins[i-1]]会存在溢出的风险,所以我们换了个写法。

另外此题还可以进行搜索所有可能然后保持一个全局的结果res,但是直接搜索会超时,所以需要进行精心剪枝,剪枝后可击败99%。详见我的Github。

Target Sum(目标和)

  1. Target Sum

这道题给了我们一个数组(元素非负),和一个目标值,要求给数组中每个数字前添加正号或负号所组成的表达式结果与目标值S相等,求有多少种情况。

假设所有元素和为sum,所有添加正号的元素的和为A,所有添加负号的元素和为B,则有sum = A + B 且 S = A - B,解方程得A = (sum + S)/2。即题目转换成:从数组中选取一些元素使和恰好为(sum + S) / 2。可见这是一个恰好装满的01背包问题,要求所有方案数,将1.2节状态转移方程中的max改成求和即可。需要注意的是,虽然这里是恰好装满,但是dp初始值不应该是inf,因为这里求的不是总价值而是方案数,应该全部初始为0(除了dp[0]初始化为1)。所以代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
int findTargetSumWays(vector<int>& nums, int S) {
int sum = 0;
// for(int &num: nums) sum += num;
sum = accumulate(nums.begin(), nums.end(), 0);
if(S > sum || sum < -S) return 0; // 肯定不行
if((S + sum) & 1) return 0; // 奇数
int target = (S + sum) >> 1;

vector<int>dp(target + 1, 0);

dp[0] = 1;
for(int i = 1; i <= nums.size(); i++)
for(int j = target; j >= nums[i-1]; j--)
dp[j] = dp[j] + dp[j - nums[i-1]];

return dp[target];
}

Ones and Zeros(一和零)

  1. Ones and Zeroes

题目给定一个仅包含 0 和 1 字符串的数组。任务是从数组中选取尽可能多的字符串,使这些字符串包含的0和1的数目分别不超过m和n。

我们把每个字符串看做是一件物品,把字符串中0的数目和1的数目看做是两种“重量”,所以就变成了一个二维01背包问题,书包的两个限重分别是 m 和 n,要求书包能装下的物品的最大数目(也相当于价值最大,设每个物品价值为1)。

我们可以提前把每个字符串的两个“重量” w0和w1算出来用数组存放,但是注意到只需要用一次这两个值,所以我们只需在用到的时候计算w0和w1就行了,这样就不用额外的数组存放。完整代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
int findMaxForm(vector<string>& strs, int m, int n) {
int num = strs.size();
int w0, w1;

vector<vector<int>>dp(m+1, vector<int>(n+1, 0));

for(int i = 1; i <= num; i++){
w0 = 0; w1 = 0;
// 计算第i-1个字符串的两个重量
for(char &c: strs[i - 1]){
if(c == '0') w0 += 1;
else w1 += 1;
}

// 01背包, 逆向迭代更新dp
for(int j = m; j >= w0; j--)
for(int k = n; k >= w1; k--)
dp[j][k] = max(dp[j][k], 1+dp[j-w0][k-w1]);
}

return dp[m][n];
}

总结

本文讨论了几类背包问题及LeetCode相关题目,其中01背包问题和完全背包问题是最常考的,另外还需要注意一些其他变种例如恰好装满、二维背包、求方案总数等等。除了本文讨论的这些背包问题之外,还存在一些其他的变种,但只要深刻领会本文所列的背包问题的思路和状态转移方程,遇到其它的变形问题,应该也不难想出算法。如果想更加详细地理解背包问题,推荐阅读经典的背包问题九讲。

空间优化 - 滚动数组

有一个比较通用的空间优化技巧没有在之前的文章中提到,很多的动态规划题目都可以套用这个技巧,我们就拿之前的 最长公共子序列 这道题目来举例说明,当时我们最终实现的代码是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public int longestCommonSubsequence(String text1, String text2) {
int length1 = text1.length();
int length2 = text2.length();

int[][] dp = new int[length1 + 1][length2 + 1];

char[] textArr1 = text1.toCharArray();
char[] textArr2 = text2.toCharArray();

for (int i = 1; i <= length1; ++i) {
for (int j = 1; j <= length2; ++j) {
if (textArr1[i - 1] == textArr2[j - 1]) {
dp[i][j] = dp[i - 1][j - 1] + 1;
} else {
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
}
}
}

return dp[length1][length2];
}

你仔细观察代码,会发现当前考虑的状态 dp[i][j] 仅仅依赖于 dp[i - 1][j] 和 dp[i][j - 1],如果画出表格,也就是当前行的格子只会和当前行以及前一行的格子有关,因此,保留两行数据就能够满足状态迭代更新的要求,我们可以得到下面的代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public int longestCommonSubsequence(String text1, String text2) {
int length1 = text1.length();
int length2 = text2.length();

int[][] dp = new int[2][length2 + 1];

char[] textArr1 = text1.toCharArray();
char[] textArr2 = text2.toCharArray();

for (int i = 1; i <= length1; ++i) {
for (int j = 1; j <= length2; ++j) {
if (textArr1[i - 1] == textArr2[j - 1]) {
dp[i%2][j] = dp[(i - 1)%2][j - 1] + 1;
} else {
dp[i%2][j] = Math.max(dp[(i - 1)%2][j], dp[i%2][j - 1]);
}
}
}

return dp[length1%2][length2];
}

这里我们成功将空间的维度降低了一维,当然如果你觉得取模的操作让代码变得不整洁,你也可以参考下面的代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public int longestCommonSubsequence(String text1, String text2) {
int length1 = text1.length();
int length2 = text2.length();

int[][] dp = new int[2][length2 + 1];

char[] textArr1 = text1.toCharArray();
char[] textArr2 = text2.toCharArray();

int cur = 0, prev = 1;
for (int i = 1; i <= length1; ++i) {
prev = cur; cur = 1 - cur;
for (int j = 1; j <= length2; ++j) {
if (textArr1[i - 1] == textArr2[j - 1]) {
dp[cur][j] = dp[prev][j - 1] + 1;
} else {
dp[cur][j] = Math.max(dp[prev][j], dp[cur][j - 1]);
}
}
}

return dp[cur][length2];
}

其实滚动数组的思想不难理解,就是只保存需要用到的子问题的答案(状态),覆盖那些不需要用到的子问题的答案,状态在同一块空间中不断翻滚迭代向前。

当然,有些动态规划的实现方式就不太容易使用这类优化,比如 记忆化搜索,还有些动态规划题型,比如 区间类动态规划,状态的更新不是逐行逐列的,使用滚动数组来优化也不是特别容易,因此使用滚动数组优化的时候还是需要结合实际情况考虑。

滚动数组一般来说都可以将状态数组的空间降低一维,比如三维变二维、二维变一维、一维变常数,当然有些具体题型的空间优化也可以做到这个,比如背包类型的动态规划问题中,我们通过改变遍历的顺序,直接就可以做到空间降维,但其实这是这类动态规划问题特有的优化,不属于滚动数组的范畴。

总结

动态规划系列内容算是结束了,虽然有关动态规划的知识点还有很多,但是我相信如果深刻掌握并理解了之前我们讲的内容,基本上 leetcode 上面 90% 以上的动态规划相关问题都可以很好解决。

当然了,要想达到熟能生巧的程度,还是需要多加练习,多思考,多对比,多总结,不然的话,学到的东西很快就会忘记。

最后的最后,希望动态规划不再是你面试中的拦路虎,看到它,也希望你能多一份亲切和自信。

引言

说到 C++ 的内存管理,我们可能会想到栈空间的本地变量、堆上通过 new 动态分配的变量以及全局命名空间的变量等,这些变量的分配位置都是由系统来控制管理的,而调用者只需要考虑变量的生命周期相关内容即可,而无需关心变量的具体布局。这对于普通软件的开发已经足够,但对于引擎开发而言,我们必须对内存有着更为精细的管理。

基础概念

在文章的开篇,先对一些基础概念进行简单的介绍,以便能够更好地理解后续的内容。

内存布局

内存分布(可执行映像)

如图,描述了C++程序的内存分布。

  • Code Segment(代码区)也称Text Segment,存放可执行程序的机器码。
  • Data Segment (数据区)存放已初始化的全局和静态变量, 常量数据(如字符串常量)。
  • BSS(Block started by symbol)存放未初始化的全局和静态变量。(默认设为0)
  • Heap(堆)从低地址向高地址增长。容量大于栈,程序中动态分配的内存在此区域。
  • Stack(栈)从高地址向低地址增长。由编译器自动管理分配。程序中的局部变量、函数参数值、返回变量等存在此区域。

函数栈

如上图所示,可执行程序的文件包含BSS,Data Segment和Code Segment,当可执行程序载入内存后,系统会保留一些空间,即堆区和栈区。堆区主要是动态分配的内存(默认情况下),而栈区主要是函数以及局部变量等(包括main函数)。一般而言,栈的空间小于堆的空间。

当调用函数时,一块连续内存(堆栈帧)压入栈;函数返回时,堆栈帧弹出。

堆栈帧包含如下数据:

  • 函数返回地址
  • 局部变量/CPU寄存器数据备份

函数压栈

全局变量

当全局/静态变量(如下代码中的x和y变量)未初始化的时候,它们记录在BSS段。

1
2
3
4
5
6
7
8
9
10
int x;
int z = 5;
void func()
{
static int y;
}
int main()
{
return 0;
}

处于BSS段的变量的值默认为0,考虑到这一点,BSS段内部无需存储大量的零值,而只需记录字节个数即可。

系统载入可执行程序后,将BSS段的数据载入数据段(Data Segment) ,并将内存初始化为0,再调用程序入口(main函数)。

而对于已经初始化了的全局/静态变量而言,如以上代码中的z变量,则一直存储于数据段(Data Segment)。

内存对齐

对于基础类型,如float, double, int, char等,它们的大小和内存占用是一致的。而对于结构体而言,如果我们取得其sizeof的结果,会发现这个值有可能会大于结构体内所有成员大小的总和,这是由于结构体内部成员进行了内存对齐。

为什么要进行内存对齐

① 内存对齐使数据读取更高效

在硬件设计上,数据读取的处理器只能从地址为k的倍数的内存处开始读取数据。这种读取方式相当于将内存分为了多个”块“,假设内存可以从任意位置开始存放的话,数据很可能会被分散到多个“块”中,处理分散在多个块中的数据需要移除首尾不需要的字节,再进行合并,非常耗时。

为了提高数据读取的效率,程序分配的内存并不是连续存储的,而是按首地址为k的倍数的方式存储;这样就可以一次性读取数据,而不需要额外的操作。

读取非对齐内存的过程示例

② 在某些平台下,不进行内存对齐会崩溃

内存对齐的规则

定义有效对齐值(alignment)为结构体中 最宽成员 和 编译器/用户指定对齐值 中较小的那个。

  1. 结构体起始地址为有效对齐值的整数倍
  2. 结构体总大小为有效对齐值的整数倍
  3. 结构体第一个成员偏移值为0,之后成员的偏移值为 min(有效对齐值, 自身大小) 的整数倍

相当于每个成员要进行对齐,并且整个结构体也需要进行对齐。

示例

1
2
3
4
5
6
7
8
9
10
11
12
struct A
{
int i;
char c1;
char c2;
};

int main()
{
cout << sizeof(A) << endl; // 有效对齐值为4, output : 8
return 0;
}


内存排布示例

内存碎片

程序的内存往往不是紧凑连续排布的,而是存在着许多碎片。我们根据碎片产生的原因把碎片分为内部碎片和外部碎片两种类型:

  1. 内部碎片:系统分配的内存大于实际所需的内存(由于对齐机制);
  2. 外部碎片:不断分配回收不同大小的内存,由于内存分布散乱,较大内存无法分配;


内部碎片和外部碎片

为了提高内存的利用率,我们有必要减少内存碎片,具体的方案将在后文重点介绍。

继承类布局

继承

如果一个类继承自另一个类,那么它自身的数据位于父类之后。

含虚函数的类

如果当前类包含虚函数,则会在类的最前端占用4个字节,用于存储虚表指针(vpointer),它指向一个虚函数表(vtable)。

vtable中包含当前类的所有虚函数指针。

字节序(endianness)

大于一个字节的值被称为多字节量,多字节量存在高位有效字节和低位有效字节 (关于高位和低位,我们以十进制的数字来举例,对于数字482来说,4是高位,2是低位),微处理器有两种不同的顺序处理高位和低位字节的顺序:

  • 小端(little_endian):低位有效字节存储于较低的内存位置
  • 大端(big_endian):高位有效字节存储于较低的内存位置

我们使用的PC开发机默认是小端存储。

一般情况下,多字节量的排列顺序对编码没有影响。但如果要考虑跨平台的一些操作,就有必要考虑到大小端的问题。如下图,ue4引擎使用了PLATFORM_LITTLE_ENDIAN这一宏,在不同平台下对数据做特殊处理(内存排布交换,确保存储时的结果一致)。ue4针对大小端对数据做特殊处理(ByteSwap.h)

用union判断大小端

  • 大端存储:字数据的高字节存储在低地址中
  • 小端存储:字数据的低字节存储在低地址中

了解了大小端存储的方式,如何在代码中进行判断呢?下面介绍两种判断方式:

方式一:使用强制类型转换

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#include <iostream>
using namespace std;
int main()
{
int a = 0x1234;
//由于int和char的长度不同,借助int型转换成char型,只会留下低地址的部分
char c = (char)(a);
if (c == 0x12)
cout << "big endian" << endl;
else if(c == 0x34)
cout << "little endian" << endl;
}
``

方式二:巧用union联合体
```C++
#include <iostream>
using namespace std;
//union联合体的重叠式存储,endian联合体占用内存的空间为每个成员字节长度的最大值
union endian
{
int a;
char ch;
};
int main()
{
endian value;
value.a = 0x1234;
//a和ch共用4字节的内存空间
if (value.ch == 0x12)
cout << "big endian"<<endl;
else if (value.ch == 0x34)
cout << "little endian"<<endl;
}

操作系统

对一些基础概念有所了解后,我们可以来关注操作系统底层的一些设计。在掌握了这些特性后,我们才能更好地针对性地编写高性能代码。

SIMD

SIMD,即Single Instruction Multiple Data,用一个指令并行地对多个数据进行运算,是CPU基本指令集的扩展。

例一:处理器的寄存器通常是32位或者64位的,而图像的一个像素点可能只有8bit,如果一次只能处理一个数据比较浪费空间;此时可以将64位寄存器拆成8个8位寄存器,就可以并行完成8个操作,提升效率。

例二:SSE指令采用128位寄存器,我们通常将4个32位浮点值打包到128位寄存器中,单个指令可完成4对浮点数的计算,这对于矩阵/向量操作非常友好(除此之外,还有Neon/FPU等寄存器)

高速缓存

一般来说CPU以超高速运行,而内存速度慢于CPU,硬盘速度慢于内存。

当我们把数据加载内存后,要对数据进行一定操作时,会将数据从内存载入CPU寄存器。考虑到CPU读/写主内存速度较慢,处理器使用了高速的缓存(Cache),作为内存到CPU中间的媒介。

引入L1和L2缓存后,CPU和内存之间的将无法进行直接的数据交互,而是需要经过两级缓存(目前也已出现L3缓存)。

① CPU请求数据:如果数据已经在缓存中,则直接从缓存载入寄存器;如果数据不在缓存中(缓存命中失败),则需要从内存读取,并将内存载入缓存中。

② CPU写入数据:有两种方案,(1) 写入到缓存时同步写入内存(write through cache) (2) 仅写入到缓存中,有必要时再写入内存(write-back)

为了提高程序性能,则需要尽可能避免缓存命中失败。一般而言,遵循尽可能地集中连续访问内存,减少”跳变“访问的原则(locality of reference)。这里其实隐含了两个意思,一个是内存空间上要尽可能连续,另外一个是访问时序上要尽可能连续。像节点式的数据结构的遍历就会差于内存连续性的容器。

虚拟内存

虚拟内存,也就是把不连续的物理内存块映射到虚拟地址空间(virtual address space)。使内存页对于应用程序来说看起来是连续的。一般而言,出于程序安全性和物理内存可能不足的考虑,我们的程序都会运行在虚拟内存上。

这意味着,每个程序都有自己的地址空间,我们使用的内存存在一个虚拟地址和一个物理地址,两者之间需要进行地址翻译。

缺页

在虚拟内存中,每个程序的地址空间被划分为多个块,每个内存块被称作页,每个页的包含了连续的地址,并且被映射到物理内存。并非所有页都在物理内存中,当我们访问了不在物理内存中的页时,这一现象称为缺页,操作系统会从磁盘将对应内容装载到物理内存;当内存不足,部分页也会写回磁盘。

在这里,我们将CPU,高速缓存和主存视为一个整体,统称为DRAM。由于DRAM与磁盘之间的读写也比较耗时,为了提高程序性能,我们依然需要确保自己的程序具有良好的“局部性”——在任意时刻都在一个较小的活动页面上工作。

分页

当使用虚拟内存时,会通过MMU将虚拟地址映射到物理内存,虚拟内存的内存块称为页,而物理内存中的内存块称为页框,两者大小一致,DRAM和磁盘之间以页为单位进行交换。

简单来说,如果想要从虚拟内存翻译到物理地址,首先会从一个TLB(Translation Lookaside Buffer)的设备中查找,如果找不到,在虚拟地址中也记录了虚拟页号和偏移量,可以先通过虚拟页号找到页框号,再通过偏移量在对应页框进行偏移,得到物理地址。为了加速这个翻译过程,有时候还会使用多级页表,倒排页表等结构。

置换算法

到目前为止,我们已经接触了不少和“置换”有关的内容:例如寄存器和高速缓存之间,DRAM和磁盘之间,以及TLB的缓存等。这个问题的本质是,我们在有限的空间内存储了一些快速查询的结构,但是我们无法存储所有的数据,所以当查询未命中时,就需要花更大的代价,而所谓置换,也就是我们的快速查询结构是在不断更新的,会随着我们的操作,使得一部分数据被装在到快速查询结构中,又有另一部分数据被卸载,相当于完成了数据的置换。

常见的置换有如下几种:

  • 最近未使用置换(NRU):出现未命中现象时,置换最近一个周期未使用的数据。
  • 先入先出置换(FIFO):出现未命中现象时,置换最早进入的数据。
  • 最近最少使用置换(LRU):出现未命中现象时,置换未使用时间最长的数据。

C++语法

位域(Bit Fields)

表示结构体位域的定义,指定变量所占位数。它通常位于成员变量后,用 声明符:常量表达式 表示。(参考资料)

声明符是可选的,匿名字段可用于填充。

以下是ue4中Float16的定义:

1
2
3
4
5
6
7
8
9
10
11
12
struct
{
#if PLATFORM_LITTLE_ENDIAN
uint16 Mantissa : 10;
uint16 Exponent : 5;
uint16 Sign : 1;
#else
uint16 Sign : 1;
uint16 Exponent : 5;
uint16 Mantissa : 10;
#endif
} Components;

new和placement new

new是C++中用于动态内存分配的运算符,它主要完成了以下两个操作:

  1. 调用operator new()函数,动态分配内存。
  2. 在分配的动态内存块上调用构造函数,以初始化相应类型的对象,并返回首地址。

当我们调用new时,会在堆中查找一个足够大的剩余空间,分配并返回;当我们调用delete时,则会将该内存标记为不再使用,而指针仍然执行原来的内存。

new的语法

1
::(optional) new (placement_params)(optional) ( type ) initializer(optional) 

● 一般表达式

1
p_var = new type(initializer); // p_var = new type{initializer};

● 对象数组表达式

1
2
p_var = new type[size]; // 分配
delete[] p_var; // 释放

● 二维数组表达式

1
2
auto p = new double[2][2];
auto p = new double[2][2]{ {1.0,2.0},{3.0,4.0} };

● 不抛出异常的表达式

1
new (nothrow) Type (optional-initializer-expression-list)

默认情况下,如果内存分配失败,new运算符会选择抛出std::bad_alloc异常,如果加入nothrow,则不抛出异常,而是返回nullptr。

● 占位符类型:我们可以使用placeholder type(如auto/decltype)指定类型:

1
auto p = new auto('c');

● 带位置的表达式(placement new):可以指定在哪块内存上构造类型。

它的意义在于我们可以利用placement new将内存分配和构造这两个模块分离(后续的allocator更好地践行了这一概念),这对于编写内存管理的代码非常重要,比如当我们想要编写内存池的代码时,可以预申请一块内存,然后通过placement new申请对象,一方面可以避免频繁调用系统new/delete带来的开销,另一方面可以自己控制内存的分配和释放。

预先分配的缓冲区可以是堆或者栈上的,一般按字节(char)类型来分配,这主要考虑了以下两个原因:

  • 方便控制分配的内存大小(通过sizeof计算即可)
  • 如果使用自定义类型,则会调用对应的构造函数。但是既然要做分配和构造的分离,我们实际上是不期望它做任何构造操作的,而且对于没有默认构造函数的自定义类型,我们是无法预分配缓冲区的。

以下是一个使用的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class A
{
private:
int data;
public:
A(int indata)
: data(indata) { }
void print()
{
cout << data << endl;
}
};
int main()
{
const int size = 10;
char buf[size * sizeof(A)]; // 内存分配
for (size_t i = 0; i < size; i++)
{
new (buf + i * sizeof(A)) A(i); // 对象构造
}
A* arr = (A*)buf;
for (size_t i = 0; i < size; i++)
{
arr[i].print();
arr[i].~A(); // 对象析构
}
// 栈上预分配的内存自动释放
return 0;
}

和数组越界访问不一定崩溃类似,这里如果在未分配的内存上执行placement new,可能也不会崩溃。

● 自定义参数的表达式

当我们调用new时,实际上执行了operator new运算符表达式,和其它函数一样,operator new有多种重载,如上文中的placement new,就是operator new以下形式的一个重载:

● placement new的定义:新语法(C++17)还支持带对齐的operator new:

● aligned new的声明:调用示例:

1
auto p = new(std::align_val_t{ 32 }) A;

new的重载

在C++中,我们一般说new和delete动态分配和释放的对象位于自由存储区(free store),这是一个抽象概念。默认情况下,C++编译器会使用堆实现自由存储。

前文已经提及了new的几种重载,包括数组,placement,align等。

如果我们想要实现自己的内存分配自定义操作,我们可以有如下两个方式:

  • 编写重载的operator new,这意味着我们的参数需要和全局operator new有差异。
  • 重定义operator new,根据名字查找规则,会优先在申请内存的数据内部/数据定义处查找new运算符,未找到才会调用全局::operator new()。

需要注意的是,如果该全局operator new已经实现为inline函数,则我们不能重定义相关函数,否则无法通过编译,如下:

1
2
3
4
5
6
7
// Default placement versions of operator new.
inline void* operator new(std::size_t, void* __p) throw() { return __p; }
inline void* operator new[](std::size_t, void* __p) throw() { return __p; }

// Default placement versions of operator delete.
inline void operator delete (void*, void*) throw() { }
inline void operator delete[](void*, void*) throw() { }

但是,我们可以重写如下nothrow的operator new:
1
2
3
4
void* operator new(std::size_t, const std::nothrow_t&) throw();
void* operator new[](std::size_t, const std::nothrow_t&) throw();
void operator delete(void*, const std::nothrow_t&) throw();
void operator delete[](void*, const std::nothrow_t&) throw();

为什么说new是低效的

① 一般来说,操作越简单,意味着封装了更多的实现细节。new作为一个通用接口,需要处理任意时间、任意位置申请任意大小内存的请求,它在设计上就无法兼顾一些特殊场景的优化,在管理上也会带来一定开销。

② 系统调用带来的开销。多数操作系统上,申请内存会从用户模式切换到内核模式,当前线程会block住,上下文切换将会消耗一定时间。

③ 分配可能是带锁的。这意味着分配难以并行化。

alignas和alignof

不同的编译器一般都会有默认的对齐量,一般都为2的幂次。

在C中,我们可以通过预编译命令修改对齐量:

1
#pragma pack(n)

在内存对齐篇已经提及,我们最终的有效对齐量会取结构体最宽成员 和 编译器默认对齐量(或我们自己定义的对齐量)中较小的那个。

C++中也提供了类似的操作:

1
alignas

用于指定对齐量。

可以应用于类/结构体/union/枚举的声明/定义;非位域的成员变量的定义;变量的定义(除了函数参数或异常捕获的参数);

alignas会对对齐量做检查,对齐量不能小于默认对齐,如下面的代码,struct U的对齐设置是错误的:

1
2
3
4
5
6
7
8
9
struct alignas(8) S 
{
// ...
};

struct alignas(1) U
{
S s;
};

以下对齐设置也是错误的:

1
2
3
struct alignas(2) S {
int n;
};

此外,一些错误的格式也无法通过编译,如:

1
struct alignas(3) S { };

例子:

1
2
3
4
5
6
7
8
9
// every object of type sse_t will be aligned to 16-byte boundary
struct alignas(16) sse_t
{
float sse_data[4];
};

// the array "cacheline" will be aligned to 128-byte boundary
alignas(128)
char cacheline[128];

alignof operator

返回类型的std::size_t。如果是引用,则返回引用类型的对齐方式,如果是数组,则返回元素类型的对齐方式。

例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
struct Foo {
int i;
float f;
char c;
};

struct Empty { };

struct alignas(64) Empty64 { };

int main()
{
std::cout << "Alignment of" "\n"
"- char :" << alignof(char) << "\n" // 1
"- pointer :" << alignof(int*) << "\n" // 8
"- class Foo :" << alignof(Foo) << "\n" // 4
"- empty class :" << alignof(Empty) << "\n" // 1
"- alignas(64) Empty:" << alignof(Empty64) << "\n"; // 64
}

std::max_align_t

一般为16bytes,malloc返回的内存地址,对齐大小不能小于max_align_t。

allocator

当我们使用C++的容器时,我们往往需要提供两个参数,一个是容器的类型,另一个是容器的分配器。其中第二个参数有默认参数,即C++自带的分配器(allocator):

1
template < class T, class Alloc = allocator<T> > class vector; // generic template

我们可以实现自己的allocator,只需实现分配、构造等相关的操作。在此之前,我们需要先对allocator的使用做一定的了解。

new操作将内存分配和对象构造组合在一起,而allocator的意义在于将内存分配和构造分离。这样就可以分配大块内存,而只在真正需要时才执行对象创建操作。

假设我们先申请n个对象,再根据情况逐一给对象赋值,如果内存分配和对象构造不分离可能带来的弊端如下:

  • 我们可能会创建一些用不到的对象;
  • 对象被赋值两次,一次是默认初始化时,一次是赋值时;
  • 没有默认构造函数的类甚至不能动态分配数组;

使用allocator之后,我们便可以解决上述问题。

分配

为n个string分配内存:

1
2
allocator<string> alloc; // 构造allocator对象
auto const p = alloc.allocate(n); // 分配n个未初始化的string

构造

在刚才分配的内存上构造两个string:

1
2
3
auto q = p;
alloc.construct(q++, "hello"); // 在分配的内存处创建对象
alloc.construct(q++, 10, 'c');

销毁

将已构造的string销毁:

1
2
while(q != p)
alloc.destroy(--q);

释放

将分配的n个string内存空间释放:

1
alloc.deallocate(p, n);

注意:传递给deallocate的指针不能为空,且必须指向由allocate分配的内存,并保证大小参数一致。

拷贝和填充

1
2
3
4
5
6
7
8
9
10
11
uninitialized_copy(b, e, b2)
// 从迭代器b, e 中的元素拷贝到b2指定的未构造的原始内存中;

uninitialized_copy(b, n, b2)
// 从迭代器b指向的元素开始,拷贝n个元素到b2开始的内存中;

uninitialized_fill(b, e, t)
// 从迭代器b和e指定的原始内存范围中创建对象,对象的值均为t的拷贝;

uninitialized_fill_n(b, n, t)
// 从迭代器b指向的内存地址开始创建n个对象;

为什么stl的allocator并不好用

如果仔细观察,我们会发现很多商业引擎都没有使用stl中的容器和分配器,而是自己实现了相应的功能。这意味着allocator无法满足某些引擎开发一些定制化的需求:

  • allocator内存对齐无法控制
  • allocator难以应用内存池之类的优化机制
  • 绑定模板签名

shared_ptr, unique_ptr和weak_ptr

智能指针是针对裸指针可能出现的问题封装的指针类,它能够更安全、更方便地使用动态内存。

shared_ptr

shared_ptr的主要应用场景是当我们需要在多个类中共享指针时。

多个类共享指针存在这么一个问题:每个类都存储了指针地址的一个拷贝,如果其中一个类删除了这个指针,其它类并不知道这个指针已经失效,此时就会出现野指针的现象。为了解决这一问题,我们可以使用引用指针来计数,仅当检测到引用计数为0时,才主动删除这个数据,以上就是shared_ptr的工作原理。

shared_ptr的基本语法如下:

初始化

1
shared_ptr<int> p = make_shared<int>(42);

拷贝和赋值

1
2
3
auto p = make_shared<int>(42);
auto r = make_shared<int>(42);
r = q; // 递增q指向的对象,递减r指向的对象

只支持直接初始化

由于接受指针参数的构造函数是explicit的,因此不能将指针隐式转换为shared_ptr:

1
2
shared_ptr<int> p1 = new int(1024); // err
shared_ptr<int> p2(new int(1024)); // ok

不与普通指针混用

  1. 通过get()函数,我们可以获取原始指针,但我们不应该delete这一指针,也不应该用它赋值/初始化另一个智能指针;
  2. 当我们将原生指针传给shared_ptr后,就应该让shared_ptr接管这一指针,而不再直接操作原生指针。

重新赋值

1
p.reset(new int(1024));

unique_ptr

有时候我们会在函数域内临时申请指针,或者在类中声明非共享的指针,但我们很有可能忘记删除这个指针,造成内存泄漏。此时我们可以考虑使用unique_ptr,由名字可见,某一时刻只有一个unique_ptr指向给定的对象,且它会在析构的时候自动释放对应指针的内存。

unique_ptr的基本语法如下:

初始化

1
unique_ptr<string> p = make_unique<string>("test");

不支持直接拷贝/赋值

为了确保某一时刻只有一个unique_ptr指向给定对象,unique_ptr不支持普通的拷贝或赋值。

1
2
3
4
unique_ptr<string> p1(new string("test"));
unique_ptr<string> p2(p1); // err
unique_ptr<string> p3;
p3 = p2; // err

所有权转移

可以通过调用release或reset将指针的所有权在unique_ptr之间转移:

1
2
3
unique_ptr<string> p2(p1.release());
unique_ptr<string> p3(new string("test"));
p2.reset(p3.release());

不能忽视release返回的结果

release返回的指针通常用来初始化/赋值另一个智能指针,如果我们只调用release,而没有删除其返回值,会造成内存泄漏:

1
2
p2.release(); // err
auto p = p2.release(); // ok, but remember to delete(p)

支持移动

1
2
3
unique_ptr<int> clone(int p) {
return unique_ptr<int>(new int(p));
}

weak_ptr

weak_ptr不控制所指向对象的生存期,即不会影响引用计数。它指向一个shared_ptr管理的对象。通常而言,它的存在有如下两个作用:

  1. 解决循环引用的问题
  2. 作为一个“观察者”:

详细来说,和之前提到的多个类共享内存的例子一样,使用普通指针可能会导致一个类删除了数据后其它类无法同步这一信息,导致野指针;之前我们提出了shared_ptr,也就是每个类记录一个引用,释放时引用数减一,直到减为0才释放。

但在有些情况下,我们并不希望当前类影响到引用计数,而是希望实现这样的逻辑:假设有两个类引用一个数据,其中有一个类将主动控制类的释放,而无需等待另外一个类也释放才真正销毁指针所指对象。对于另一个类而言,它只需要知道这个指针已经失效即可,此时我们就可以使用weak_ptr。

我们可以像如下这样检测weak_ptr所有对象是否有效,并在有效的情况下做相关操作:

1
2
3
4
5
6
7
auto p = make_shared<int>(42);
weak_ptr<int> wp(p);

if(shared_ptr<int> np = wp.lock())
{
// ...
}

分配与管理机制

到目前为止,我们对内存的概念有了初步的了解,也掌握了一些基本的语法。接下来我们要讨论如何进行有效的内存管理。设计高效的内存分配器通常会考虑到以下几点:

  • 尽可能减少内存碎片,提高内存利用率
  • 尽可能提高内存的访问局部性
  • 设计在不同场合上适用的内存分配器
  • 考虑到内存对齐

含freelist的分配器

我们首先来考虑一种能够处理任何请求的通用分配器。

一个非常朴素的想法是,对于释放的内存,通过链表将空闲内存链接起来,称为freelist。

分配内存时,先从freelist中查找是否存在满足要求的内存块,如果不存在,再从未分配内存中获取;当我们找到合适的内存块后,分配合适的内存,并将多余的部分放回freelist。

释放内存时,将内存插入到空闲链表,可能的话,合并前后内存块。

其中,有一些细节问题值得考虑:

① 空闲空间应该如何进行管理?

我们知道freelist是用于管理空闲内存的,但是freelist本身的存储也需要占用内存。我们可以按如下两种方式存储freelist:

  • 隐式空闲链表:将空闲链表信息与内存块存储在一起。主要记录大小,已分配位等信息。
  • 显式空闲链表:单独维护一块空间来记录所有空闲块信息。
  • 分离适配(segregated-freelist):将不同大小的内存块放在一起容易造成外部碎片,可以设置多个freelist,并让每个freelist存储不同大小的内存块,申请内存时选择满足条件的最小内存块。
  • 位图:除了freelist之外,还可以考虑用0,1表示对应内存区域是否已分配,称为位图。

② 分配内存优先分配哪块内存?

一般而言,从策略不同来分,有以下几种常见的分配方式:

  • 首次适应(first-fit):找到的第一个满足大小要求的空闲区
  • 最佳适应(best-fit) : 满足大小要求的最小空闲区
  • 循环首次适应(next-fit) :在先前停止搜索的地方开始搜索找到的第一个满足大小要求的空闲区

③ 释放内存后如何放置到空闲链表中?

  • 直接放回链表头部/尾部
  • 按照地址顺序放回

这几种策略本质上都是取舍问题:分配/放回时间复杂度如果低,内存碎片就有可能更多,反之亦然。

buddy分配器

按照一分为二,二分为四的原则,直到分裂出一个满足大小的内存块;合并的时候看buddy是否空闲,如果是就合并。

可以通过位运算直接算出buddy,buddy的buddy,速度较快。但内存碎片较多。

含对齐的分配器

一般而言,对于通用分配器来说,都应当传回对齐的内存块,即根据对齐量,分配比请求多的对齐的内存。

如下,是ue4中计算对齐的方式,它返回和对齐量向上对齐后的值,其中Alignment应为2的幂次。

1
2
3
4
5
6
7
template <typename T>
FORCEINLINE constexpr T Align(T Val, uint64 Alignment)
{
static_assert(TIsIntegral<T>::Value || TIsPointer<T>::Value, "Align expects an integer or pointer type");

return (T)(((uint64)Val + Alignment - 1) & ~(Alignment - 1));
}

其中~(Alignment - 1)代表的是高位掩码,类似于11110000的格式,它将剔除低位。在对Val进行掩码计算时,加上Alignment - 1的做法类似于(x + a) % a,避免Val值过小得到0的结果。

单帧分配器模型

用于分配一些临时的每帧生成的数据。分配的内存仅在当前帧适用,每帧开始时会将上一帧的缓冲数据清除,无需手动释放。

双帧分配器模型

它的基本特点和单帧分配器相近,区别在于第i+1帧适用第i帧分配的内存。它适用于处理非同步的一些数据,避免当前缓冲区被重写(同时读写)

堆栈分配器模型

堆栈分配器,它的优点是实现简单,并且完全避免了内存碎片,如前文所述,函数栈的设计也使用了堆栈分配器的模型。

双端堆栈分配器模型

可以从两端开始分配内存,分别用于处理不同的事务,能够更充分地利用内存。

池分配器模型

池分配器可以分配大量同尺寸的小块内存。它的空闲块也是由freelist管理的,但由于每个块的尺寸一致,它的操作复杂度更低,且也不存在内存碎片的问题。

tcmalloc的内存分配

tcmalloc是一个应用比较广泛的内存分配第三方库。对于大于页结构和小于页结构的内存块申请,tcmalloc分别做不同的处理。

小于页的内存块分配

使用多个内存块定长的freelist进行内存分配,如:8,16,32……,对实际申请的内存向上“取整”。

freelist采用隐式存储的方式。

大于页的内存块分配

可以一次申请多个page,多个page构成一个span。同样的,我们使用多个定长的span链表来管理不同大小的span。

对于不同大小的对象,都有一个对应的内存分配器,称为CentralCache。具体的数据都存储在span内,每个CentralCache维护了对应的spanlist。如果一个span可以存储多个对象,spanlist内部还会维护对应的freelist。

容器的访问局部性

由于操作系统内部存在缓存命中的问题,所以我们需要考虑程序的访问局部性,这个访问局部性实际上有两层意思:

  1. 时间局部性:如果当前数据被访问,那么它将在不久后很可能在此被访问;
  2. 空间局部性:如果当前数据被访问,那么它相邻位置的数据很可能也被访问;

我们来认识一下常用的几种容器的内存布局:

  • 数组/顺序容器:内存连续,访问局部性良好;
  • map:内部是树状结构,为节点存储,无法保证内存连续性,访问局部性较差(flat_map支持顺序存储);
  • 链表:初始状态下,如果我们连续顺序插入节点,此时我们认为内存连续,访问较快;但通过多次插入、删除、交换等操作,链表结构变得散乱,访问局部性较差;

碎片整理机制

内存碎片几乎是不可完全避免的,当一个程序运行一定时间后,将会出现越来越多的内存碎片。一个优化的思路就是在引擎底层支持定期地整理内存碎片。简单来说,碎片整理通过不断的移动操作,使所有的内存块“贴合”在一起。为了处理指针可能失效的问题,可以考虑使用智能指针。由于内存碎片整理会造成卡顿,我们可以考虑将整理操作分摊到多帧完成。

ue4内存管理

自定义内存管理

ue4的内存管理主要是通过FMalloc类型的GMalloc这一结构来完成特定的需求,这是一个虚基类,它定义了malloc,realloc,free等一系列常用的内存管理操作。其中,Malloc的两个参数分别是分配内存的大小和对应的对齐量,默认对齐量为0。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/** The global memory allocator's interface. */
class CORE_API FMalloc :
public FUseSystemMallocForNew,
public FExec
{
public:
virtual void* Malloc( SIZE_T Count, uint32 Alignment=DEFAULT_ALIGNMENT ) = 0;
virtual void* TryMalloc( SIZE_T Count, uint32 Alignment=DEFAULT_ALIGNMENT );
virtual void* Realloc( void* Original, SIZE_T Count, uint32 Alignment=DEFAULT_ALIGNMENT ) = 0;
virtual void* TryRealloc(void* Original, SIZE_T Count, uint32 Alignment=DEFAULT_ALIGNMENT);
virtual void Free( void* Original ) = 0;

// ...
};

FMalloc有许多不同的实现,如FMallocBinned,FMallocBinned2等,可以在HAL文件夹下找到相关的头文件和定义,如下:

内部通过枚举量来确定对应使用的Allocator:

1
2
3
4
5
6
7
8
9
10
11
12
13
/** Which allocator is being used */
enum EMemoryAllocatorToUse
{
Ansi, // Default C allocator
Stomp, // Allocator to check for memory stomping
TBB, // Thread Building Blocks malloc
Jemalloc, // Linux/FreeBSD malloc
Binned, // Older binned malloc
Binned2, // Newer binned malloc
Binned3, // Newer VM-based binned malloc, 64 bit only
Platform, // Custom platform specific allocator
Mimalloc, // mimalloc
};

对于不同平台而言,都有自己对应的平台内存管理类,它们继承自FGenericPlatformMemory,封装了平台相关的内存操作。具体而言,包含FAndroidPlatformMemory,FApplePlatformMemory,FIOSPlatformMemory,FWindowsPlatformMemory等。

通过调用PlatformMemory的BaseAllocator函数,我们取得平台对应的FMalloc类型,基类默认返回默认的C allocator,而不同平台会有自己特殊的实现。

在PlatformMemory的基础上,为了方便调用,ue4又封装了FMemory类,定义通用内存操作,如在申请内存时,会调用FMemory::Malloc,FMemory内部又会继续调用GMalloc->Malloc。如下为节选代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
struct CORE_API FMemory
{
/** @name Memory functions (wrapper for FPlatformMemory) */

static FORCEINLINE void* Memmove( void* Dest, const void* Src, SIZE_T Count )
{
return FPlatformMemory::Memmove( Dest, Src, Count );
}

static FORCEINLINE int32 Memcmp( const void* Buf1, const void* Buf2, SIZE_T Count )
{
return FPlatformMemory::Memcmp( Buf1, Buf2, Count );
}

// ...
static void* Malloc(SIZE_T Count, uint32 Alignment = DEFAULT_ALIGNMENT);
static void* Realloc(void* Original, SIZE_T Count, uint32 Alignment = DEFAULT_ALIGNMENT);
static void Free(void* Original);
static SIZE_T GetAllocSize(void* Original);

// ...
};

为了在调用new/delete能够调用ue4的自定义函数,ue4内部替换了operator new。这一替换是通过IMPLEMENT_MODULE宏引入的:

IMPLEMENT_MODULE通过定义REPLACEMENT_OPERATOR_NEW_AND_DELETE宏实现替换,如下图所示,operator new/delete内实际调用被替换为FMemory的相关函数。

FMallocBinned

我们以FMallocBinned为例介绍ue4中通用内存的分配。

基本介绍

(1) 空闲内存如何管理?

FMallocBinned使用freelist机制管理空闲内存。每个空闲块的信息记录在FFreeMem结构中,显式存储。

(2)不同大小内存如何分配?

FMallocBinned使用内存池机制,内部包含POOL_COUNT(42)个内存池和2个扩展的页内存池;其中每个内存池的信息由FPoolInfo结构体维护,记录了当前FreeMem内存块指针等,而特定大小的所有内存池由FPoolTable维护;内存池内包含了内存块的双向链表。

(3)如何快速根据分配元素大小找到对应的内存池?

为了快速查询当前分配内存大小应该对应使用哪个内存池,有两种办法,一种是二分搜索O(logN),另一种是打表(O1),考虑到可分配内存数量并不大,MallocBinned选择了打表的方式,将信息记录在MemSizeToPoolTable。

(4)如何快速删除已分配内存?

为了能够在释放的时候以O(1)时间找到对应内存池,FMallocBinned维护了PoolHashBucket结构用于跟踪内存分配的记录。它组织为双向链表形式,存储了对应内存块和键值。

内存池

● 多个小对象内存池(内存池大小均为PageSize,但存储的数据量不一样)。数据块大小设定如下:

● 两个额外的页内存池,管理大于一个页的内存池,大小为3PageSize和6PageSize

● 操作系统的内存池

分配策略

分配内存的函数为void* FMallocBinned::Malloc(SIZE_T Size, uint32 Alignment)。其中第一个参数为需要分配的内存的大小,第二个参数为对齐的内存数。

如果用户未指定对齐的内存大小,MallocBinned内部会默认对齐于16字节,如果指定了大于16字节的对齐内存大小,则对齐于用户指定的对齐大小。根据对齐量,计算出最终实际分配的内存大小。

MallocBinned内部对于不同的内存大小有三种不同的处理:

(1) 分配小块内存(0,PAGE_SIZE_LIMIT/2)

根据分配大小从MemSizeToPoolTable中获取对应内存池,并从内存池的当前空闲位置读取一块内存,并移动当前内存指针。如果移动后的内存指针指向的内存块已经使用,则将指针移动到FreeMem链表的下一个元素;如果当前内存池已满,将该内存池移除,并链接到耗尽的内存池。

如果当前内存池已经用尽,下次内存分配时,检测到内存池用尽,会从系统重新申请一块对应大小的内存池。

(2) 分配大块内存 [PAGE_SIZE_LIMIT/2, PAGE_SIZE_LIMIT*3/4]∪(PageSize,PageSize + PAGE_SIZE_LIMIT/2)

需要从额外的页内存池分配,分配方式和(1)一样。

(3) 分配超大内存

从系统内存池中分配。

Allocator

对于ue4中的容器而言,它的模板有两个参数,第一个是元素类型,第二个就是对应的分配器(Allocator):

1
2
3
4
5
template<typename InElementType, typename InAllocator>
class TArray
{
// ...
};

如下图,容器一般都指定了自己默认的分配器:

默认的堆分配器

1
2
3
4
5
template <int IndexSize>
class TSizedHeapAllocator { ... };

// Default Allocator
using FHeapAllocator = TSizedHeapAllocator<32>;

默认情况下,如果我们不指定特定的Allocator,容器会使用大小类型为int32堆分配器,默认由FMemory控制分配(和new一致)

含对齐的分配器

1
2
3
4
5
template<uint32 Alignment = DEFAULT_ALIGNMENT>
class TAlignedHeapAllocator
{
// ...
};

由FMemory控制分配,含对齐。

可扩展大小的分配器

1
2
3
4
5
template <uint32 NumInlineElements, typename SecondaryAllocator = FDefaultAllocator>
class TInlineAllocator
{
//...
};

可扩展大小的分配器存储大小为NumInlineElements的定长数组,当实际存储的元素数量高于NumInlineElements时,会从SecondaryAllocator申请分配内存,默认情况下为堆分配器。

对齐量总为DEFAULT_ALIGNMENT

不可重定位的可扩展大小的分配器

1
2
3
4
5
template <uint32 NumInlineElements>
class TNonRelocatableInlineAllocator
{
// ...
};

在支持第二分配器的基础上,允许第二分配器存储指向内联元素的指针。这意味着Allocator不应做指针重定向的操作。但ue4的Allocator通常依赖于指针重定向,因此该分配器不应用于其它Allocator容器。

固定大小的分配器

1
2
3
4
5
template <uint32 NumInlineElements>
class TFixedAllocator
{
// ...
};

类似于InlineAllocator,会分配固定大小内存,区别在于当内联存储耗尽后,不会提供额外的分配器。

稀疏数组分配器

1
2
3
4
5
6
7
8
template<typename InElementAllocator = FDefaultAllocator,typename InBitArrayAllocator = FDefaultBitArrayAllocator>
class TSparseArrayAllocator
{
public:

typedef InElementAllocator ElementAllocator;
typedef InBitArrayAllocator BitArrayAllocator;
};

稀疏数组本身的定义比较简单,它主要用于稀疏数组(Sparse Array),相关的操作也在对应数组类中完成。稀疏数组支持不连续的下标索引,通过BitArrayAllocator来控制分配哪个位是可用的,能够以O(1)的时间删除元素。

默认使用堆分配。

哈希分配器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
template<
typename InSparseArrayAllocator = TSparseArrayAllocator<>,
typename InHashAllocator = TInlineAllocator<1,FDefaultAllocator>,
uint32 AverageNumberOfElementsPerHashBucket = DEFAULT_NUMBER_OF_ELEMENTS_PER_HASH_BUCKET,
uint32 BaseNumberOfHashBuckets = DEFAULT_BASE_NUMBER_OF_HASH_BUCKETS,
uint32 MinNumberOfHashedElements = DEFAULT_MIN_NUMBER_OF_HASHED_ELEMENTS
>
class TSetAllocator
{
public:
static FORCEINLINE uint32 GetNumberOfHashBuckets(uint32 NumHashedElements) { //... }

typedef InSparseArrayAllocator SparseArrayAllocator;
typedef InHashAllocator HashAllocator;
};

用于TSet/TMap等结构的哈希分配器,同样的实现比较简单,具体的分配策略在TSet等结构中实现。其中SparseArrayAllocator用于管理Value,HashAllocator用于管理Key。Hash空间不足时,按照2的幂次进行扩展。

默认使用堆分配。

除了使用默认的堆分配器,稀疏数组分配器和哈希分配器都有对应的可扩展大小(InlineAllocator)/固定大小(FixedAllocator)分配版本。

动态内存管理

TSharedPtr

1
2
3
4
5
6
7
8
template< class ObjectType, ESPMode Mode >
class TSharedPtr
{
// ...
private:
ObjectType* Object;
SharedPointerInternals::FSharedReferencer< Mode > SharedReferenceCount;
};

TSharedPtr是ue4提供的类似stl sharedptr的解决方案,但相比起stl,它可由第二个模板参数控制是否线程安全。

如上所示,它基于类内的引用计数实现(SharedReferenceCount),为了确保多个TSharedPtr能够同步当前引用计数的信息,引用计数被设计为指针类型。在拷贝/构造/赋值等操作时,会增加或减少引用计数的值,当引用计数为0时将销毁指针所指对象。

TSharedRef

1
2
3
4
5
6
7
8
template< class ObjectType, ESPMode Mode >
class TSharedRef
{
// ...
private:
ObjectType* Object;
SharedPointerInternals::FSharedReferencer< Mode > SharedReferenceCount;
};

和TSharedPtr类似,但存储的指针不可为空,创建时需同时初始化指针。类似于C++中的引用。

TRefCountPtr

1
2
3
4
5
6
7
template<typename ReferencedType>
class TRefCountPtr
{
// ...
private:
ReferencedType* Reference;
};

TRefCountPtr是基于引用计数的共享指针的另一种实现。和TSharedPtr的差异在于它的引用计数并非智能指针类内维护的,而是基于对象的,相当于TRefCountPtr内部只存储了对应的指针信息(ReferencedType* Reference)。
基于对象的引用计数,即引用计数存储在对象内部,这是通过从FRefCountBase继承引入的。这也就意味着TRefCountPtr引用的对象必须从FRefCountBase继承,它的使用是有局限性的。

但是在如统计资源引用而判断资源是否需要卸载的应用场景中,TRefCountPtr可手动添加/释放引用,使用上更友好。

1
2
3
4
5
6
7
8
9
10
11
12
13
class FRefCountBase
{
public:
// ...
private:
mutable int32 NumRefs = 0;
};
TWeakPtr

template< class ObjectType, ESPMode Mode >
class TWeakPtr
{
};

类似的,TWeakObjectPtr是ue4提供的类似stl weakptr的解决方案,它将不影响引用计数。

TWeakObjectPtr

1
2
3
4
5
6
7
8
9
10
11
12
13
14
template<class T, class TWeakObjectPtrBase>
struct TWeakObjectPtr : private TWeakObjectPtrBase
{
// ...
};

struct FWeakObjectPtr
{
// ...

private:
int32 ObjectIndex;
int32 ObjectSerialNumber;
};

特别的,由于UObject有对应的gc机制,TWeakObjectPtr为指向UObject的弱指针,用于查询对象是否有效(是否被回收)

垃圾回收

C++语言本身并没有垃圾回收机制,ue4基于内部的UObject,单独实现了一套GC机制,此处仅做简单介绍。

首先,对于UObject相关对象,为了维持引用(防止被回收),通常使用UProperty()宏,使用容器(如TArray存储),或调用AddToRoot的方法。

ue4的垃圾回收代码实现位于GarbageCollection.cpp中的CollectGarbage函数中。这一函数会在游戏线程中被反复调用,要么在一些情况下手动调用,要么在游戏循环Tick()中满足条件时自动调用。

GC过程中,首先会收集所有不可到达的对象(无引用)。

之后,根据当前情况,会在单帧(无时间限制)或多帧(有时间限制)的时间内,清理相关对象(IncrementalPurgeGarbage)

SIMD

合理的内存布局/对齐有利于SIMD的广泛应用,在编写定义基础类型/底层数学算法库时,我们通常有必要考虑到这一点。

我们可以参考ue4中封装的sse初始化、加法、减法、乘法等操作,其中,__m128类型的变量需程序确保为16字节对齐,它适用于浮点数存储,大部分情况下存储于内存中,计算时会在SSE寄存器中运用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
typedef __m128 VectorRegister;

FORCEINLINE VectorRegister VectorLoad( const void* Ptr )
{
return _mm_loadu_ps((float*)(Ptr));
}

FORCEINLINE VectorRegister VectorAdd( const VectorRegister& Vec1, const VectorRegister& Vec2 )
{
return _mm_add_ps(Vec1, Vec2);
}

FORCEINLINE VectorRegister VectorSubtract( const VectorRegister& Vec1, const VectorRegister& Vec2 )
{
return _mm_sub_ps(Vec1, Vec2);
}

FORCEINLINE VectorRegister VectorMultiply( const VectorRegister& Vec1, const VectorRegister& Vec2 )
{
return _mm_mul_ps(Vec1, Vec2);
}

除了SSE外,ue4还针对Neon/FPU等寄存器封装了统一的接口,这意味调用者可以无需考虑过多硬件的细节。

我们可以在多个数学运算库中看到相关的调用,如球谐向量的相加:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/** Addition operator. */
friend FORCEINLINE TSHVector operator+(const TSHVector& A,const TSHVector& B)
{
TSHVector Result;
for(int32 BasisIndex = 0;BasisIndex < NumSIMDVectors;BasisIndex++)
{
VectorRegister AddResult = VectorAdd(
VectorLoadAligned(&A.V[BasisIndex * NumComponentsPerSIMDVector]),
VectorLoadAligned(&B.V[BasisIndex * NumComponentsPerSIMDVector])
);

VectorStoreAligned(AddResult, &Result.V[BasisIndex * NumComponentsPerSIMDVector]);
}
return Result;
}

makefile很重要

什么是makefile?或许很多Winodws的程序员都不知道这个东西,因为那些Windows的IDE都为你做了这个工作,但我觉得要作一个好的和professional的程序员,makefile还是要懂。这就好像现在有这么多的HTML的编辑器,但如果你想成为一个专业人士,你还是要了解HTML的标识的含义。特别在Unix下的软件编译,你就不能不自己写makefile了,会不会写makefile,从一个侧面说明了一个人是否具备完成大型工程的能力。因为,makefile关系到了整个工程的编译规则。一个工程中的源文件不计数,其按类型、功能、模块分别放在若干个目录中,makefile定义了一系列的规则来指定,哪些文件需要先编译,哪些文件需要后编译,哪些文件需要重新编译,甚至于进行更复杂的功能操作,因为makefile就像一个Shell脚本一样,其中也可以执行操作系统的命令。makefile带来的好处就是——自动化编译,一旦写好,只需要一个make命令,整个工程完全自动编译,极大的提高了软件开发的效率。make是一个命令工具,是一个解释makefile中指令的命令工具,一般来说,大多数的IDE都有这个命令,比如:Delphi的make,VisualC++的nmake,Linux下GNU的make。可见,makefile都成为了一种在工程方面的编译方法。

现在讲述如何写makefile的文章比较少,这是我想写这篇文章的原因。当然,不同产商的make各不相同,也有不同的语法,但其本质都是在文件依赖性上做文章,这里,我仅对GNU的make进行讲述,我的环境是RedHatLinux8.0,make的版本是3.80。必竟,这个make是应用最为广泛的,也是用得最多的。而且其还是最遵循于IEEE1003.2-1992标准的(POSIX.2)。

在这篇文档中,将以C/C++的源码作为我们基础,所以必然涉及一些关于C/C++的编译的知识,相关于这方面的内容,还请各位查看相关的编译器的文档。这里所默认的编译器是UNIX下的GCC和CC。

关于程序的编译和链接

在此,我想多说关于程序编译的一些规范和方法,一般来说,无论是C、C++、还是pas,首先要把源文件编译成中间代码文件,在Windows下也就是.obj文件,UNIX下是.o文件,即ObjectFile,这个动作叫做编译(compile)。然后再把大量的ObjectFile合成执行文件,这个动作叫作链接(link)。

编译时,编译器需要的是语法的正确,函数与变量的声明的正确。对于后者,通常是你需要告诉编译器头文件的所在位置(头文件中应该只是声明,而定义应该放在C/C++文件中),只要所有的语法正确,编译器就可以编译出中间目标文件。一般来说,每个源文件都应该对应于一个中间目标文件(O文件或是OBJ文件)。

链接时,主要是链接函数和全局变量,所以,我们可以使用这些中间目标文件(O文件或是OBJ文件)来链接我们的应用程序。链接器并不管函数所在的源文件,只管函数的中间目标文件(ObjectFile),在大多数时候,由于源文件太多,编译生成的中间目标文件太多,而在链接时需要明显地指出中间目标文件名,这对于编译很不方便,所以,我们要给中间目标文件打个包,在Windows下这种包叫库文件(LibraryFile),也就是.lib文件,在UNIX下,是ArchiveFile,也就是.a文件。

总结一下,源文件首先会生成中间目标文件,再由中间目标文件生成执行文件。在编译时,编译器只检测程序语法,和函数、变量是否被声明。如果函数未被声明,编译器会给出一个警告,但可以生成ObjectFile。而在链接程序时,链接器会在所有的ObjectFile中找寻函数的实现,如果找不到,那到就会报链接错误码(LinkerError),在VC下,这种错误一般是:Link2001错误,意思说是说,链接器未能找到函数的实现。你需要指定函数的ObjectFile.

好,言归正传,GNU的make有许多的内容,闲言少叙,还是让我们开始吧。

Makefile介绍

make命令执行时,需要一个Makefile文件,以告诉make命令需要怎么样的去编译和链接程序。

首先,我们用一个示例来说明Makefile的书写规则。以便给大家一个感性认识。这个示例来源于GNU的make使用手册,在这个示例中,我们的工程有8个C文件,和3个头文件,我们要写一个Makefile来告诉make命令如何编译和链接这几个文件。我们的规则是:

1)如果这个工程没有编译过,那么我们的所有C文件都要编译并被链接。

2)如果这个工程的某几个C文件被修改,那么我们只编译被修改的C文件,并链接目标程序。

3)如果这个工程的头文件被改变了,那么我们需要编译引用了这几个头文件的C文件,并链接目标程序。

只要我们的Makefile写得够好,所有的这一切,我们只用一个make命令就可以完成,make命令会自动智能地根据当前的文件修改的情况来确定哪些文件需要重编译,从而自己编译所需要的文件和链接目标程序。

Makefile的规则

在讲述这个Makefile之前,还是让我们先来粗略地看一看Makefile的规则。

1
2
3
4
5
6
7
target...: prerequisites...

command

...

...

target也就是一个目标文件,可以是ObjectFile,也可以是执行文件。还可以是一个标签(Label),对于标签这种特性,在后续的伪目标章节中会有叙述。

prerequisites就是,要生成那个target所需要的文件或是目标。

command也就是make需要执行的命令。(任意的Shell命令)

这是一个文件的依赖关系,也就是说,target这一个或多个的目标文件依赖于prerequisites中的文件,其生成规则定义在command中。说白一点就是说,prerequisites中如果有一个以上的文件比target文件要新的话,command所定义的命令就会被执行。这就是Makefile的规则。也就是Makefile中最核心的内容。

说到底,Makefile的东西就是这样一点,好像我的这篇文档也该结束了。呵呵。还不尽然,这是Makefile的主线和核心,但要写好一个Makefile还不够,我会以后面一点一点地结合我的工作经验给你慢慢到来。内容还多着呢。:)

一个示例

正如前面所说的,如果一个工程有3个头文件,和8个C文件,我们为了完成前面所述的那三个规则,我们的Makefile应该是下面的这个样子的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
edit: main.o kbd.o command.o display.o insert.o search.o files.o utils.o

cc -o edit main.o kbd.o command.o display.o insert.o search.o files.o utils.o

main.o: main.c defs.h

cc –c main.c

kbd.o: kbd.c defs.h command.h

cc –c kbd.c

command.o: command.c defs.h command.h

cc –c command.c

display.o: display.c defs.h buffer.h

cc –c display.c

insert.o: insert.c defs.h buffer.h

cc -c insert.c

search.o: search.c defs.h buffer.h

cc -c search.c

files.o: files.c defs.h buffer.h command.h

cc -c files.c

utils.o: utils.c defs.h

cc –c utils.c

clean:

rm edit main.o kbd.o command.odisplay.o insert.o search.o files.outils.o

反斜杠(\)是换行符的意思。这样比较便于Makefile的易读。我们可以把这个内容保存在文件为Makefilemakefile的文件中,然后在该目录下直接输入命令make就可以生成执行文件edit。如果要删除执行文件和所有的中间目标文件,那么,只要简单地执行一下make clean就可以了。

在这个makefile中,目标文件(target)包含:执行文件edit和中间目标文件(*.o),依赖文件(prerequisites)就是冒号后面的那些.c文件和.h文件。每一个.o文件都有一组依赖文件,而这些.o文件又是执行文件edit的依赖文件。依赖关系的实质上就是说明了目标文件是由哪些文件生成的,换言之,目标文件是哪些文件更新的。

在定义好依赖关系后,后续的那一行定义了如何生成目标文件的操作系统命令,一定要以一个Tab键作为开头。记住,make并不管命令是怎么工作的,他只管执行所定义的命令。make会比较targets文件和prerequisites文件的修改日期,如果prerequisites文件的日期要比targets文件的日期要新,或者target不存在的话,那么,make就会执行后续定义的命令。

这里要说明一点的是,clean不是一个文件,它只不过是一个动作名字,有点像C语言中的lable一样,其冒号后什么也没有,那么,make就不会自动去找文件的依赖性,也就不会自动执行其后所定义的命令。要执行其后的命令,就要在make命令后明显得指出这个lable的名字。这样的方法非常有用,我们可以在一个makefile中定义不用的编译或是和编译无关的命令,比如程序的打包,程序的备份,等等。

make是如何工作的

在默认的方式下,也就是我们只输入make命令。那么,

make会在当前目录下找名字叫Makefilemakefile的文件。

如果找到,它会找文件中的第一个目标文件(target),在上面的例子中,他会找到edit这个文件,并把这个文件作为最终的目标文件。

如果edit文件不存在,或是edit所依赖的后面的.o文件的文件修改时间要比edit这个文件新,那么,他就会执行后面所定义的命令来生成edit这个文件。

如果edit所依赖的.o文件也存在,那么make会在当前文件中找目标为.o文件的依赖性,如果找到则再根据那一个规则生成.o文件。(这有点像一个堆栈的过程)

当然,你的C文件和H文件是存在的啦,于是make会生成.o文件,然后再用.o文件声明make的终极任务,也就是执行文件edit了。

这就是整个make的依赖性,make会一层又一层地去找文件的依赖关系,直到最终编译出第一个目标文件。在找寻的过程中,如果出现错误,比如最后被依赖的文件找不到,那么make就会直接退出,并报错,而对于所定义的命令的错误,或是编译不成功,make根本不理。make只管文件的依赖性,即,如果在我找了依赖关系之后,冒号后面的文件还是不在,那么对不起,我就不工作啦。

通过上述分析,我们知道,像clean这种,没有被第一个目标文件直接或间接关联,那么它后面所定义的命令将不会被自动执行,不过,我们可以显示要make执行。即命令——make clean,以此来清除所有的目标文件,以便重编译。

于是在我们编程中,如果这个工程已被编译过了,当我们修改了其中一个源文件,比如file.c,那么根据我们的依赖性,我们的目标file.o会被重编译(也就是在这个依性关系后面所定义的命令),于是file.o的文件也是最新的啦,于是file.o的文件修改时间要比edit要新,所以edit也会被重新链接了(详见edit目标文件后定义的命令)。

而如果我们改变了command.h,那么,kdb.o、command.o和files.o都会被重编译,并且,edit会被重链接。

makefile中使用变量

在上面的例子中,先让我们看看edit的规则:

1
2
3
edit:main.o kbd.o command.odisplay.o insert.o search.o files.outils.o

cc –o edit main.o kbd.ocommand.o display.o insert.o search.o files.outils.o

我们可以看到[.o]文件的字符串被重复了两次,如果我们的工程需要加入一个新的[.o]文件,那么我们需要在两个地方加(应该是三个地方,还有一个地方在clean中)。当然,我们的makefile并不复杂,所以在两个地方加也不累,但如果makefile变得复杂,那么我们就有可能会忘掉一个需要加入的地方,而导致编译失败。所以,为了makefile的易维护,在makefile中我们可以使用变量。makefile的变量也就是一个字符串,理解成C语言中的宏可能会更好。

比如,我们声明一个变量,叫objects,OBJECTS,objs,OBJS,obj,或是OBJ,反正不管什么啦,只要能够表示obj文件就行了。我们在makefile一开始就这样定义:

1
2
3
objects=main.o kbd.o command.odisplay.o\

insert.o search.o files.outils.o

于是,我们就可以很方便地在我们的makefile中以$(objects)的方式来使用这个变量了,于是我们的改良版makefile就变成下面这个样子:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
objects = main.o kbd.o command.o display.o insert.osearch.o files.o utils.o

edit: $(objects)
cc–o edit$(objects)

main.o: main.cdefs.h
cc –c main.c

kbd.o: kbd.c defs.h command.h
cc –c kbd.c

command.o: command.c defs.h command.h
cc –c command.c

display.o: display.c defs.h buffer.h
cc -c display.c

insert.o: insert.c defs.h buffer.h
cc–c insert.c

search.o: search.c defs.h buffer.h
cc–c search.c

files.o: files.c defs.h buffer.h command.h
cc–c files.c

utils.o: utils.c defs.h
cc– c utils.c

clean:
rm edit$(objects)

于是如果有新的.o文件加入,我们只需简单地修改一下objects变量就可以了。

关于变量更多的话题,我会在后续给你一一道来。

让make自动推导

GNU的make很强大,它可以自动推导文件以及文件依赖关系后面的命令,于是我们就没必要去在每一个[.o]文件后都写上类似的命令,因为,我们的make会自动识别,并自己推导命令。

只要make看到一个[.o]文件,它就会自动的把[.c]文件加在依赖关系中,如果make找到一个whatever.o,那么whatever.c,就会是whatever.o的依赖文件。并且cc -c whatever.c也会被推导出来,于是,我们的makefile再也不用写得这么复杂。我们的是新的makefile又出炉了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
objects = main.o kbd.o command.o display.o insert.o search.o files.o utils.o

edit : $(objects)

cc-o edit $(objects)

main.o : defs.h

kbd.o : defs.hcommand.h

command.o : defs.hcommand.h

display.o : defs.hbuffer.h

insert.o : defs.hbuffer.h

search.o : defs.hbuffer.h

files.o : defs.hbuffer.h command.h

utils.o : defs.h

.PHONY : clean

clean :
rm edit $(objects)

这种方法,也就是make的隐晦规则。上面文件内容中,.PHONY表示,clean是个伪目标文件。

关于更为详细的隐晦规则伪目标文件,我会在后续给你一一道来。

另类风格的makefile

即然我们的make可以自动推导命令,那么我看到那堆[.o]和[.h]的依赖就有点不爽,那么多的重复的[.h],能不能把其收拢起来,好吧,没有问题,这个对于make来说很容易,谁叫它提供了自动推导命令和文件的功能呢?来看看最新风格的makefile吧。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
objects = main.o kbd.o command.o display.o insert.osearch.o files.o utils.o

edit : $(objects)

cc -o edit $(objects)

$(objects) : defs.h

kbd.o command.o files.o : command.h

display.o insert.osearch.o files.o : buffer.h

.PHONY : clean

clean :
rm edit $(objects)

这种风格,让我们的makefile变得很简单,但我们的文件依赖关系就显得有点凌乱了。鱼和熊掌不可兼得。还看你的喜好了。我是不喜欢这种风格的,一是文件的依赖关系看不清楚,二是如果文件一多,要加入几个新的.o文件,那就理不清楚了。

清空目标文件的规则

每个Makefile中都应该写一个清空目标文件(.o和执行文件)的规则,这不仅便于重编译,也很利于保持文件的清洁。这是一个修养(呵呵,还记得我的《编程修养》吗)。一般的风格都是:

1
2
3
clean:

rm edit $(objects)

更为稳健的做法是:
1
2
3
4
5
.PHONY: clean

clean:

-rm edit $(objects)

前面说过,.PHONY意思表示clean是一个伪目标,。而在rm命令前面加了一个小减号的意思就是,也许某些文件出现问题,但不要管,继续做后面的事。当然,clean的规则不要放在文件的开头,不然,这就会变成make的默认目标,相信谁也不愿意这样。不成文的规矩是——clean从来都是放在文件的最后

上面就是一个makefile的概貌,也是makefile的基础,下面还有很多makefile的相关细节,准备好了吗?准备好了就来。

Makefile总述

Makefile里有什么?

Makefile里主要包含了五个东西:显式规则、隐晦规则、变量定义、文件指示和注释。

  • 显式规则。显式规则说明了,如何生成一个或多的的目标文件。这是由Makefile的书写者明显指出,要生成的文件,文件的依赖文件,生成的命令。
  • 隐晦规则。由于我们的make有自动推导的功能,所以隐晦的规则可以让我们比较粗糙地简略地书写Makefile,这是由make所支持的。
  • 变量的定义。在Makefile中我们要定义一系列的变量,变量一般都是字符串,这个有点你C语言中的宏,当Makefile被执行时,其中的变量都会被扩展到相应的引用位置上。
  • 文件指示。其包括了三个部分,一个是在一个Makefile中引用另一个Makefile,就像C语言中的include一样;另一个是指根据某些情况指定Makefile中的有效部分,就像C语言中的预编译#if一样;还有就是定义一个多行的命令。有关这一部分的内容,我会在后续的部分中讲述。
  • 注释。Makefile中只有行注释,和UNIX的Shell脚本一样,其注释是用#字符,这个就像C/C++中的//一样。如果你要在你的Makefile中使用#字符,可以用反斜框进行转义,如:\#
  • 最后,还值得一提的是,在Makefile中的命令,必须要以[Tab]键开始。

Makefile的文件名

默认的情况下,make命令会在当前目录下按顺序找寻文件名为GNUmakefilemakefileMakefile的文件,找到了解释这个文件。在这三个文件名中,最好使用Makefile这个文件名,因为,这个文件名第一个字符为大写,这样有一种显目的感觉。最好不要用GNUmakefile,这个文件是GNU的make识别的。有另外一些make只对全小写的makefile文件名敏感,但是基本上来说,大多数的make都支持makefileMakefile这两种默认文件名。

当然,你可以使用别的文件名来书写Makefile,比如:Make.LinuxMake.SolarisMake.AIX等,如果要指定特定的Makefile,你可以使用make的-f--file参数,如:make –f Make.Linux或make –file Make.AIX。

引用其它的Makefile

在Makefile使用include关键字可以把别的Makefile包含进来,这很像C语言的#include,被包含的文件会原模原样的放在当前文件的包含位置。include的语法是:

include filename可以是当前操作系统Shell的文件模式(可以保含路径和通配符)

在include前面可以有一些空字符,但是绝不能是[Tab]键开始。include和可以用一个或多个空格隔开。举个例子,你有这样几个Makefile:a.mk、b.mk、c.mk,还有一个文件叫foo.make,以及一个变量$(bar),其包含了e.mk和f.mk,那么,下面的语句:

1
include foo.make *.mk$(bar)

等价于:
1
include foo.make a.mkb.mk c.mk e.mk f.mk

make命令开始时,会把找寻include所指出的其它Makefile,并把其内容安置在当前的位置。就好像C/C++的#include指令一样。如果文件都没有指定绝对路径或是相对路径的话,make会在当前目录下首先寻找,如果当前目录下没有找到,那么,make还会在下面的几个目录下找:

  1. 如果make执行时,有-I--include-dir参数,那么make就会在这个参数所指定的目录下去寻找。
  2. 如果目录/include(一般是:/usr/local/bin或/usr/include)存在的话,make也会去找。

如果有文件没有找到的话,make会生成一条警告信息,但不会马上出现致命错误。它会继续载入其它的文件,一旦完成makefile的读取,make会再重试这些没有找到,或是不能读取的文件,如果还是不行,make才会出现一条致命信息。如果你想让make不理那些无法读取的文件,而继续执行,你可以在include前加一个减号-。如:

1
-include<filename>

其表示,无论include过程中出现什么错误,都不要报错继续执行。和其它版本make兼容的相关命令是sinclude,其作用和这一个是一样的。

环境变量MAKEFILES

如果你的当前环境中定义了环境变量MAKEFILES,那么,make会把这个变量中的值做一个类似于include的动作。这个变量中的值是其它的Makefile,用空格分隔。只是,它和include不同的是,从这个环境变中引入的Makefile的目标不会起作用,如果环境变量中定义的文件发现错误,make也会不理。

但是在这里我还是建议不要使用这个环境变量,因为只要这个变量一被定义,那么当你使用make时,所有的Makefile都会受到它的影响,这绝不是你想看到的。在这里提这个事,只是为了告诉大家,也许有时候你的Makefile出现了怪事,那么你可以看看当前环境中有没有定义这个变量。

make的工作方式

GNU的make工作时的执行步骤入下:(想来其它的make也是类似)

  1. 读入所有的Makefile。
  2. 读入被include的其它Makefile。
  3. 初始化文件中的变量。
  4. 推导隐晦规则,并分析所有规则。
  5. 为所有的目标文件创建依赖关系链。
  6. 根据依赖关系,决定哪些目标要重新生成。
  7. 执行生成命令。

1-5步为第一个阶段,6-7为第二个阶段。第一个阶段中,如果定义的变量被使用了,那么,make会把其展开在使用的位置。但make并不会完全马上展开,make使用的是拖延战术,如果变量出现在依赖关系的规则中,那么仅当这条依赖被决定要使用了,变量才会在其内部展开。

当然,这个工作方式你不一定要清楚,但是知道这个方式你也会对make更为熟悉。有了这个基础,后续部分也就容易看懂了。

Makefile书写规则

规则包含两个部分,一个是依赖关系,一个是生成目标的方法。

在Makefile中,规则的顺序是很重要的,因为,Makefile中只应该有一个最终目标,其它的目标都是被这个目标所连带出来的,所以一定要让make知道你的最终目标是什么。一般来说,定义在Makefile中的目标可能会有很多,但是第一条规则中的目标将被确立为最终的目标。如果第一条规则中的目标有很多个,那么,第一个目标会成为最终的目标。make所完成的也就是这个目标。

好了,还是让我们来看一看如何书写规则。

规则举例

1
2
foo.o: foo.c defs.h #foo模块
cc –c –g foo.c

看到这个例子,各位应该不是很陌生了,前面也已说过,foo.o是我们的目标,foo.c和defs.h是目标所依赖的源文件,而只有一个命令cc –c –g foo.c(以Tab键开头)。这个规则告诉我们两件事:

  1. 文件的依赖关系,foo.o依赖于foo.c和defs.h的文件,如果foo.c和defs.h的文件日期要比foo.o文件日期要新,或是foo.o不存在,那么依赖关系发生。
  2. 如果生成(或更新)foo.o文件。也就是那个cc命令,其说明了,如何生成foo.o这个文件。(当然foo.c文件include了defs.h文件)

规则的语法

1
2
3
targets:prerequisites
command
...

或是这样:

1
2
3
targets:prerequisites;command
command
...

targets是文件名,以空格分开,可以使用通配符。一般来说,我们的目标基本上是一个文件,但也有可能是多个文件。

command是命令行,如果其不与target:prerequisites在一行,那么,必须以[Tab键]开头,如果和prerequisites在一行,那么可以用分号做为分隔。(见上)

prerequisites也就是目标所依赖的文件(或依赖目标)。如果其中的某个文件要比目标文件要新,那么,目标就被认为是过时的,被认为是需要重生成的。这个在前面已经讲过了。

如果命令太长,你可以使用反斜框(‘\’)作为换行符。make对一行上有多少个字符没有限制。规则告诉make两件事,文件的依赖关系和如何生成目标文件。

一般来说,make会以UNIX的标准Shell,也就是/bin/sh来执行命令。

在规则中使用通配符

如果我们想定义一系列比较类似的文件,我们很自然地就想起使用通配符。make支持三各通配符:*?[...]。这是和Unix的B-Shell是相同的。

~: 波浪号(~)字符在文件名中也有比较特殊的用途。如果是~/test,这就表示当前用户的$HOME目录下的test目录。而~hchen/test则表示用户hchen的宿主目录下的test目录。(这些都是Unix下的小知识了,make也支持)而在Windows或是MS-DOS下,用户没有宿主目录,那么波浪号所指的目录则根据环境变量HOME而定。

*通配符代替了你一系列的文件,如*.c表示所以后缀为c的文件。一个需要我们注意的是,如果我们的文件名中有通配符,如:*,那么可以用转义字符\,如\*来表示真实的*字符,而不是任意长度的字符串。

好吧,还是先来看几个例子吧:

1
2
clean:
rm –f *.o

上面这个例子我不不多说了,这是操作系统Shell所支持的通配符。这是在命令中的通配符。
1
2
3
4
5
print: *.c

lpr –p $?

touch print

上面这个例子说明了通配符也可以在我们的规则中,目标print依赖于所有的[.c]文件。其中的$?是一个自动化变量,我会在后面给你讲述。
1
objects=*.o

上面这个例子,表示了,通符同样可以用在变量中。并不是说[*.o]会展开,不!objects的值就是*.o。Makefile中的变量其实就是C/C++中的宏。如果你要让通配符在变量中展开,也就是让objects的值是所有[.o]的文件名的集合,那么,你可以这样:
1
objects:=$(wildcard*.o)

这种用法由关键字wildcard指出,关于Makefile的关键字,我们将在后面讨论。

文件搜寻

在一些大的工程中,有大量的源文件,我们通常的做法是把这许多的源文件分类,并存放在不同的目录中。所以,当make需要去找寻文件的依赖关系时,你可以在文件前加上路径,但最好的方法是把一个路径告诉make,让make在自动去找。

Makefile文件中的特殊变量VPATH就是完成这个功能的,如果没有指明这个变量,make只会在当前的目录中去找寻依赖文件和目标文件。如果定义了这个变量,那么,make就会在当当前目录找不到的情况下,到所指定的目录中去找寻文件了。

1
VPATH=src:../headers

上面的的定义指定两个目录,src../headers,make会按照这个顺序进行搜索。目录由冒号分隔。(当然,当前目录永远是最高优先搜索的地方)

另一个设置文件搜索路径的方法是使用make的vpath关键字(注意,它是全小写的),这不是变量,这是一个make的关键字,这和上面提到的那个VPATH变量很类似,但是它更为灵活。它可以指定不同的文件在不同的搜索目录中。这是一个很灵活的功能。它的使用方法有三种:

  1. vpath<pattern><directories>为符合模式<pattern>的文件指定搜索目录<directories>
  2. vpath<pattern>清除符合模式<pattern>的文件的搜索目录。
  3. vpath清除所有已被设置好了的文件搜索目录。

vapth使用方法中的<pattern>需要包含%字符。%的意思是匹配零或若干字符,例如,%.h表示所有以.h结尾的文件。<pattern>指定了要搜索的文件集,而<directories>则指定了的文件集的搜索的目录。例如:

1
vpath %.h ../headers

该语句表示,要求make在../headers目录下搜索所有以.h结尾的文件。(如果某文件在当前目录没有找到的话)

我们可以连续地使用vpath语句,以指定不同搜索策略。如果连续的vpath语句中出现了相同的<pattern>,或是被重复了的<pattern>,那么,make会按照vpath语句的先后顺序来执行搜索。如:

1
2
3
vpath %.c foo
vpath % blish
vpath %.c bar

其表示.c结尾的文件,先在foo目录,然后是blish,最后是bar目录。
1
2
vpath %.c foo:bar
vpath % blish

而上面的语句则表示.c结尾的文件,先在foo目录,然后是bar目录,最后才是blish目录。

伪目标

最早先的一个例子中,我们提到过一个clean的目标,这是一个伪目标

1
2
clean:
rm *.o temp

正像我们前面例子中的clean一样,即然我们生成了许多文件编译文件,我们也应该提供一个清除它们的目标以备完整地重编译而用。(以make clean来使用该目标)

因为,我们并不生成clean这个文件。伪目标并不是一个文件,只是一个标签,由于伪目标不是文件,所以make无法生成它的依赖关系和决定它是否要执行。我们只有通过显示地指明这个目标才能让其生效。当然,伪目标的取名不能和文件名重名,不然其就失去了伪目标的意义了。

当然,为了避免和文件重名的这种情况,我们可以使用一个特殊的标记.PHONY来显示地指明一个目标是伪目标,向make说明,不管是否有这个文件,这个目标就是伪目标

1
.PHONY:clean

只要有这个声明,不管是否有clean文件,要运行clean这个目标,只有make clean这样。于是整个过程可以这样写:
1
2
3
4
5
.PHONY:clean

clean:

rm *.o temp

伪目标一般没有依赖的文件。但是,我们也可以为伪目标指定所依赖的文件。伪目标同样可以作为默认目标,只要将其放在第一个。一个示例就是,如果你的Makefile需要一口气生成若干个可执行文件,但你只想简单地敲一个make完事,并且,所有的目标文件都写在一个Makefile中,那么你可以使用伪目标这个特性:
1
2
3
4
5
6
7
8
9
10
11
12
all:prog1 prog2 prog3

.PHONY:all

prog1:prog1.o utils.o
cc –o prog1 prog1.o utils.o

prog2:prog2.o
cc –o prog2 prog2.o

prog3:prog3.o sort.o utils.o
cc -o prog3 prog3.o sort.o utils.o

我们知道,Makefile中的第一个目标会被作为其默认目标。我们声明了一个all的伪目标,其依赖于其它三个目标。由于伪目标的特性是,总是被执行的,所以其依赖的那三个目标就总是不如all这个目标新。所以,其它三个目标的规则总是会被决议。也就达到了我们一口气生成多个目标的目的。.PHONY:all声明了all这个目标为伪目标

随便提一句,从上面的例子我们可以看出,目标也可以成为依赖。所以,伪目标同样也可成为依赖。看下面的例子:

1
2
3
4
5
6
7
8
9
10
.PHONY:cleanall cleanobj cleandiff

cleanall:cleanobj cleandiff
rm program

cleanobj:
rm *.o

cleandiff:
rm *.diff

make clean将清除所有要被清除的文件。cleanobjcleandiff这两个伪目标有点像子程序的意思。我们可以输入make cleanallmake cleanobjmake cleandiff命令来达到清除不同种类文件的目的

多目标

Makefile的规则中的目标可以不止一个,其支持多目标,有可能我们的多个目标同时依赖于一个文件,并且其生成的命令大体类似。于是我们就能把其合并起来。当然,多个目标的生成规则的执行命令是同一个,这可能会可我们带来麻烦,不过好在我们的可以使用一个自动化变量$@(关于自动化变量,将在后面讲述),这个变量表示着目前规则中所有的目标的集合,这样说可能很抽象,还是看一个例子吧。

1
2
3
bigoutput littleoutput:text.g

generate text.g -$(substoutput,,$@)>$@

上述规则等价于:
1
2
3
4
5
6
7
bigoutput:text.g

generate text.g -big >bigoutput

littleoutput:text.g

generate text.g -little> littleoutput

其中,-$(subst output,,$@)中的$表示执行一个Makefile的函数,函数名为subst,后面的为参数。关于函数,将在后面讲述。这里的这个函数是截取字符串的意思,$@表示目标的集合,就像一个数组,$@依次取出目标,并执于命令。

静态模式

静态模式可以更加容易地定义多目标的规则,可以让我们的规则变得更加的有弹性和灵活。我们还是先来看一下语法:

1
2
3
4
5
<targets...>:<target-pattern>:<prereq-patterns...>

<commands>

...

targets定义了一系列的目标文件,可以有通配符。是目标的一个集合。

target-parrtern是指明了targets的模式,也就是的目标集模式。

prereq-parrterns是目标的依赖模式,它对target-parrtern形成的模式再进行一次依赖目标的定义。

这样描述这三个东西,可能还是没有说清楚,还是举个例子来说明一下吧。如果我们的<target-parrtern>定义成%.o,意思是我们的集合中都是以.o结尾的,而如果我们的<prereq-parrterns>定义成%.c,意思是对<target-parrtern>所形成的目标集进行二次定义,其计算方法是,取<target-parrtern>模式中的%(也就是去掉了[.o]这个结尾),并为其加上[.c]这个结尾,形成的新集合。

所以,我们的目标模式或是依赖模式中都应该有%这个字符,如果你的文件名中有%那么你可以使用反斜杠\进行转义,来标明真实的%字符。

看一个例子:

1
2
3
4
5
6
7
objects=foo.o bar.o

all:$(objects)

$(objects):%.o:%.c

$(CC)–c $(CFLAGS) $< -o $@

上面的例子中,指明了我们的目标从$object中获取,%.o表明要所有以.o结尾的目标,也就是foo.o bar.o,也就是变量$object集合的模式,而依赖模式%.c则取模式%.o%,也就是foobar,并为其加下.c的后缀,于是,我们的依赖目标就是foo.c bar.c。而命令中的$<$@则是自动化变量,$<表示所有的依赖目标集(也就是foo.c bar.c),$@表示目标集(也就是foo.o bar.o)。于是,上面的规则展开后等价于下面的规则:
1
2
3
4
5
6
7
foo.o:foo.c

$(CC)–c $(CFLAGS) foo.c –o foo.o

bar.o:bar.c

$(CC)–c $(CFLAGS) bar.c –o bar.o

试想,如果我们的%.o有几百个,那种我们只要用这种很简单的“静态模式规则”就可以写完一堆规则,实在是太有效率了。“静态模式规则”的用法很灵活,如果用得好,那会一个很强大的功能。再看一个例子:
1
2
3
4
5
6
7
8
9
files=foo.elc bar.o lose.o

$(filter %.o,$(files)):%.o:%.c

$(CC)-c $(CFLAGS) $< -o $@

$(filter %.elc,$(files)):%.elc:%.el

emacs-f batch-byte-compile $<

$(filter %.o,$(files))表示调用Makefile的filter函数,过滤$filter集,只要其中模式为%.o的内容。其的它内容,我就不用多说了吧。这个例字展示了Makefile中更大的弹性。

自动生成依赖性

在Makefile中,我们的依赖关系可能会需要包含一系列的头文件,比如,如果我们的main.c中有一句#include"defs.h",那么我们的依赖关系应该是:

1
main.o:main.c defs.h

但是,如果是一个比较大型的工程,你必需清楚哪些C文件包含了哪些头文件,并且,你在加入或删除头文件时,也需要小心地修改Makefile,这是一个很没有维护性的工作。为了避免这种繁重而又容易出错的事情,我们可以使用C/C++编译的一个功能。大多数的C/C++编译器都支持一个-M的选项,即自动找寻源文件中包含的头文件,并生成一个依赖关系。例如,如果我们执行下面的命令:
1
cc -M main.c

其输出是:
1
main.o:main.c defs.h

于是由编译器自动生成的依赖关系,这样一来,你就不必再手动书写若干文件的依赖关系,而由编译器自动生成了。需要提醒一句的是,如果你使用GNU的C/C++编译器,你得用-MM参数,不然,-M参数会把一些标准库的头文件也包含进来。

gcc -M main.c的输出是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
main.o:main.cdefs.h/usr/include/stdio.h/usr/include/features.h\

/usr/include/sys/cdefs.h/usr/include/gnu/stubs.h\

/usr/lib/gcc-lib/i486-suse-linux/2.95.3/include/stddef.h\

/usr/include/bits/types.h/usr/include/bits/pthreadtypes.h\

/usr/include/bits/sched.h/usr/include/libio.h\

/usr/include/_G_config.h/usr/include/wchar.h\

/usr/include/bits/wchar.h/usr/include/gconv.h\

/usr/lib/gcc-lib/i486-suse-linux/2.95.3/include/stdarg.h\

/usr/include/bits/stdio_lim.h

gcc -MM main.c的输出则是:
1
main.o:main.c defs.h

那么,编译器的这个功能如何与我们的Makefile联系在一起呢。因为这样一来,我们的Makefile也要根据这些源文件重新生成,让Makefile自已依赖于源文件?这个功能并不现实,不过我们可以有其它手段来迂回地实现这一功能。GNU组织建议把编译器为每一个源文件的自动生成的依赖关系放到一个文件中,为每一个name.c的文件都生成一个name.d的Makefile文件,[.d]文件中就存放对应[.c]文件的依赖关系。

于是,我们可以写出[.c]文件和[.d]文件的依赖关系,并让make自动更新或自成[.d]文件,并把其包含在我们的主Makefile中,这样,我们就可以自动化地生成每个文件的依赖关系了。

这里,我们给出了一个模式规则来产生[.d]文件:

1
2
3
4
5
6
7
8
9
%.d: %.c

@set -e;rm -f $@;\

$(CC) -M $(CPPFLAGS) $<> $@.;\

sed 's,$*\.o[:]*,\1.o $@:,g'< $@. > $@;\

rm -f $@.

这个规则的意思是,所有的[.d]文件依赖于[.c]文件,rm -f $@的意思是删除所有的目标,也就是[.d]文件,第二行的意思是,为每个依赖文件$<,也就是[.c]文件生成依赖文件,$@表示模式%.d文件,如果有一个C文件是name.c,那么%就是name.意为一个随机编号,第二行生成的文件有可能是name.d.12345,第三行使用sed命令做了一个替换,关于sed命令的用法请参看相关的使用文档。第四行就是删除临时文件。

总而言之,这个模式要做的事就是在编译器生成的依赖关系中加入[.d]文件的依赖,即把依赖关系:

1
main.o:main.c defs.h

转成:
1
main.o main.d:main.c defs.h

于是,我们的[.d]文件也会自动更新了,并会自动生成了,当然,你还可以在这个[.d]文件中加入的不只是依赖关系,包括生成的命令也可一并加入,让每个[.d]文件都包含一个完赖的规则。一旦我们完成这个工作,接下来,我们就要把这些自动生成的规则放进我们的主Makefile中。我们可以使用Makefile的include命令,来引入别的Makefile文件(前面讲过),例如:
1
2
3
sources = foo.c bar.c

include $(sources:.c=.d)

上述语句中的$(sources:.c=.d)中的.c=.d的意思是做一个替换,把变量$(sources)所有[.c]的字串都替换成[.d],关于这个替换的内容,在后面我会有更为详细的讲述。当然,你得注意次序,因为include是按次来载入文件,最先载入的[.d]文件中的目标会成为默认目标

Makefile书写命令

每条规则中的命令和操作系统Shell的命令行是一致的。make会一按顺序一条一条的执行命令,每条命令的开头必须以[Tab]键开头,除非,命令是紧跟在依赖规则后面的分号后的。在命令行之间中的空格或是空行会被忽略,但是如果该空格或空行是以Tab键开头的,那么make会认为其是一个空命令。

我们在UNIX下可能会使用不同的Shell,但是make的命令默认是被/bin/sh——UNIX的标准Shell解释执行的。除非你特别指定一个其它的Shell。Makefile中,#是注释符,很像C/C++中的//,其后的本行字符都被注释。

显示命令

通常,make会把其要执行的命令行在命令执行前输出到屏幕上。当我们用@字符在命令行前,那么,这个命令将不被make显示出来,最具代表性的例子是,我们用这个功能来像屏幕显示一些信息。如:

@echo正在编译XXX模块……

当make执行时,会输出正在编译XXX模块......字串,但不会输出命令,如果没有@,那么,make将输出:

echo正在编译XXX模块……

正在编译XXX模块……

如果make执行时,带入make参数-n--just-print,那么其只是显示命令,但不会执行命令,这个功能很有利于我们调试我们的Makefile,看看我们书写的命令是执行起来是什么样子的或是什么顺序的。

而make参数-s--slient则是全面禁止命令的显示。

命令执行

当依赖目标新于目标时,也就是当规则的目标需要被更新时,make会一条一条的执行其后的命令。需要注意的是,如果你要让上一条命令的结果应用在下一条命令时,你应该使用分号分隔这两条命令。比如你的第一条命令是cd命令,你希望第二条命令得在cd之后的基础上运行,那么你就不能把这两条命令写在两行上,而应该把这两条命令写在一行上,用分号分隔。如:

示例一:

1
2
3
4
5
exec:

cd/home/hchen

pwd

示例二:
1
2
3
exec:

cd/home/hchen;pwd

当我们执行make exec时,第一个例子中的cd没有作用,pwd会打印出当前的Makefile目录,而第二个例子中,cd就起作用了,pwd会打印出/home/hchen

make一般是使用环境变量SHELL中所定义的系统Shell来执行命令,默认情况下使用UNIX的标准Shell——/bin/sh来执行命令。但在MS-DOS下有点特殊,因为MS-DOS下没有SHELL环境变量,当然你也可以指定。如果你指定了UNIX风格的目录形式,首先,make会在SHELL所指定的路径中找寻命令解释器,如果找不到,其会在当前盘符中的当前目录中寻找,如果再找不到,其会在PATH环境变量中所定义的所有路径中寻找。MS-DOS中,如果你定义的命令解释器没有找到,其会给你的命令解释器加上诸如.exe.com.bat.sh等后缀。

命令出错

每当命令运行完后,make会检测每个命令的返回码,如果命令返回成功,那么make会执行下一条命令,当规则中所有的命令成功返回后,这个规则就算是成功完成了。如果一个规则中的某个命令出错了(命令退出码非零),那么make就会终止执行当前规则,这将有可能终止所有规则的执行。

有些时候,命令的出错并不表示就是错误的。例如mkdir命令,我们一定需要建立一个目录,如果目录不存在,那么mkdir就成功执行,万事大吉,如果目录存在,那么就出错了。我们之所以使用mkdir的意思就是一定要有这样的一个目录,于是我们就不希望mkdir出错而终止规则的运行。

为了做到这一点,忽略命令的出错,我们可以在Makefile的命令行前加一个减号-(在Tab键之后),标记为不管命令出不出错都认为是成功的。如:

1
2
3
clean:

-rm -f *.o

还有一个全局的办法是,给make加上-i或是--ignore-errors参数,那么,Makefile中所有命令都会忽略错误。而如果一个规则是以.IGNORE作为目标的,那么这个规则中的所有命令将会忽略错误。这些是不同级别的防止命令出错的方法,你可以根据你的不同喜欢设置。

还有一个要提一下的make的参数的是-k或是--keep-going,这个参数的意思是,如果某规则中的命令出错了,那么就终目该规则的执行,但继续执行其它规则。

嵌套执行make

在一些大的工程中,我们会把我们不同模块或是不同功能的源文件放在不同的目录中,我们可以在每个目录中都书写一个该目录的Makefile,这有利于让我们的Makefile变得更加地简洁,而不至于把所有的东西全部写在一个Makefile中,这样会很难维护我们的Makefile,这个技术对于我们模块编译和分段编译有着非常大的好处。

例如,我们有一个子目录叫subdir,这个目录下有个Makefile文件,来指明了这个目录下文件的编译规则。那么我们总控的Makefile可以这样书写:

1
2
3
subsystem:

cd subdir &&$(MAKE)

其等价于:
1
2
3
subsystem:

$(MAKE) -C subdir

定义$(MAKE)宏变量的意思是,也许我们的make需要一些参数,所以定义成一个变量比较利于维护。这两个例子的意思都是先进入subdir目录,然后执行make命令。

我们把这个Makefile叫做总控Makefile,总控Makefile的变量可以传递到下级的Makefile中(如果你显示的声明),但是不会覆盖下层的Makefile中所定义的变量,除非指定了-e参数。

如果你要传递变量到下级Makefile中,那么你可以使用这样的声明:

1
export <variable...>

如果你不想让某些变量传递到下级Makefile中,那么你可以这样声明:
1
unexport <variable...>

如:

示例一:

1
export variable=value

其等价于:
1
2
3
variable=value

export variable

其等价于:
1
export variable :=value

其等价于:
1
2
3
variable :=value

export variable

示例二:
1
export variable +=value

其等价于:
1
2
3
variable +=value

export variable

如果你要传递所有的变量,那么,只要一个export就行了。后面什么也不用跟,表示传递所有的变量。

需要注意的是,有两个变量,一个是SHELL,一个是MAKEFLAGS,这两个变量不管你是否export,其总是要传递到下层Makefile中,特别是MAKEFILES变量,其中包含了make的参数信息,如果我们执行总控Makefile时有make参数或是在上层Makefile中定义了这个变量,那么MAKEFILES变量将会是这些参数,并会传递到下层Makefile中,这是一个系统级的环境变量。

但是make命令中的有几个参数并不往下传递,它们是-C,-f,-h``-o-W(有关Makefile参数的细节将在后面说明),如果你不想往下层传递参数,那么,你可以这样来:

1
2
3
subsystem:

cd subdir && $(MAKE)MAKEFLAGS=

如果你定义了环境变量MAKEFLAGS,那么你得确信其中的选项是大家都会用到的,如果其中有-t,-n,和-q参数,那么将会有让你意想不到的结果,或许会让你异常地恐慌。

还有一个在嵌套执行中比较有用的参数,-w或是--print-directory会在make的过程中输出一些信息,让你看到目前的工作目录。比如,如果我们的下级make目录是/home/hchen/gnu/make,如果我们使用make -w来执行,那么当进入该目录时,我们会看到:

1
make: Entering directory`/home/hchen/gnu/make'.

而在完成下层make后离开目录时,我们会看到:
1
make: Leaving directory`/home/hchen/gnu/make'

当你使用-C参数来指定make下层Makefile时,-w会被自动打开的。如果参数中有-s--slient)或是--no-print-directory,那么,-w总是失效的。

定义命令包

如果Makefile中出现一些相同命令序列,那么我们可以为这些相同的命令序列定义一个变量。定义这种命令序列的语法以define开始,以endef结束,如:

1
2
3
4
5
6
7
define run-yacc

yacc $(firstword$^)

mv y.tab.c $@

endef

这里,run-yacc是这个命令包的名字,其不要和Makefile中的变量重名。在defineendef中的两行就是命令序列。这个命令包中的第一个命令是运行Yacc程序,因为Yacc程序总是生成y.tab.c的文件,所以第二行的命令就是把这个文件改改名字。还是把这个命令包放到一个示例中来看看吧。
1
2
3
foo.c: foo.y

$(run-yacc)

我们可以看见,要使用这个命令包,我们就好像使用变量一样。在这个命令包的使用中,命令包run-yacc中的开头的特殊变量,我们会在后面介绍),make在执行命令包时,命令包中的每个命令会被依次独立执行。

使用变量

在Makefile中的定义的变量,就像是C/C++语言中的宏一样,他代表了一个文本字串,在Makefile中执行的时候其会自动原模原样地展开在所使用的地方。其与C/C++所不同的是,你可以在Makefile中改变其值。在Makefile中,变量可以使用在目标依赖目标命令或是Makefile的其它部分中。变量的命名字可以包含字符、数字,下划线(可以是数字开头),但不应该含有:#=或是空字符(空格、回车等)。变量是大小写敏感的,fooFooFOO是三个不同的变量名。传统的Makefile的变量名是全大写的命名方式,但我推荐使用大小写搭配的变量名,如:MakeFlags。这样可以避免和系统的变量冲突,而发生意外的事情。有一些变量是很奇怪字串,如$<$@等,这些是自动化变量,我会在后面介绍。

变量的基础

变量在声明时需要给予初值,而在使用时,需要给在变量名前加上字符,那么你需要用$$$$来表示。变量可以使用在许多地方,如规则中的目标依赖命令以及新的变量中。

先看一个例子:

1
2
3
4
5
6
7
objects=program.o foo.o utils.o

program:$(objects)

cc -o program $(objects)

$(objects):defs.h

变量会在使用它的地方精确地展开,就像C/C++中的宏一样,例如:
1
2
3
4
5
foo=c

prog.o:prog.$(foo)

$(foo)$(foo) -$(foo) prog.$(foo)

展开后得到:
1
2
3
prog.o:prog.c

cc -c prog.c

当然,千万不要在你的Makefile中这样干,这里只是举个例子来表明Makefile中的变量在使用处展开的真实样子。可见其就是一个替代的原理。另外,给变量加上括号完全是为了更加安全地使用这个变量,在上面的例子中,如果你不想给变量加上括号,那也可以,但我还是强烈建议你给变量加上括号。

变量中的变量

在定义变量的值时,我们可以使用其它变量来构造变量的值,在Makefile中有两种方式来在用变量定义变量的值。

先看第一种方式,也就是简单的使用=号,在=左侧是变量,右侧是变量的值,右侧变量的值可以定义在文件的任何一处,也就是说,右侧中的变量不一定非要是已定义好

的值,其也可以使用后面定义的值。如:

1
2
3
4
5
6
7
8
9
foo=$(bar)

bar=$(ugh)

ugh=Huh?

all:

echo $(foo)

我们执行make all将会打出变量$(foo)的值是Huh?($(foo)的值是$(bar),$(bar)的值是$(ugh),$(ugh)的值是Huh?)可见,变量是可以使用后面的变量来定义的。

这个功能有好的地方,也有不好的地方,好的地方是,我们可以把变量的真实值推到后面来定义,如:

1
2
3
CFLAGS=$(include_dirs) -O

include_dirs=-Ifoo -Ibar

CFLAGS在命令中被展开时,会是-Ifoo -Ibar -O。但这种形式也有不好的地方,那就是递归定义,如:
1
CFLAGS=$(CFLAGS) -O

或:
1
2
3
A=$(B)

B=$(A)

这会让make陷入无限的变量展开过程中去,当然,我们的make是有能力检测这样的定义,并会报错。还有就是如果在变量中使用函数,那么,这种方式会让我们的make运行时非常慢,更糟糕的是,他会使用得两个make的函数wildcardshell发生不可预知的错误。因为你不会知道这两个函数会被调用多少次。

为了避免上面的这种方法,我们可以使用make中的另一种用变量来定义变量的方法。这种方法使用的是:=操作符,如:

1
2
3
4
5
x:=foo

y:=$(x)bar

x:=later

其等价于:
1
2
3
y:=foobar

x:=later

值得一提的是,这种方法,前面的变量不能使用后面的变量,只能使用前面已定义好了的变量。如果是这样:
1
2
3
y:=$(x)bar

x:=foo

那么,y的值是bar,而不是foobar

上面都是一些比较简单的变量使用了,让我们来看一个复杂的例子,其中包括了make的函数、条件表达式和一个系统变量MAKELEVEL的使用:

1
2
3
4
5
6
7
8
9
10
11
ifeq(0,${MAKELEVEL})

cur-dir:=$(shell pwd)

whoami:=$(shell whoami)

host-type:=$(shell arch)

MAKE:= ${MAKE} host-type=${host-type}whoami=${whoami}

endif

关于条件表达式和函数,我们在后面再说,对于系统变量MAKELEVEL,其意思是,如果我们的make有一个嵌套执行的动作(参见前面的嵌套使用make),那么,这个变量会记录了我们的当前Makefile的调用层数。

下面再介绍两个定义变量时我们需要知道的,请先看一个例子,如果我们要定义一个变量,其值是一个空格,那么我们可以这样来:

1
2
3
nullstring:=

space:=$(nullstring) #endof the line

nullstring是一个Empty变量,其中什么也没有,而我们的space的值是一个空格。因为在操作符的右边是很难描述一个空格的,这里采用的技术很管用,先用一个Empty变量来标明变量的值开始了,而后面采用#注释符来表示变量定义的终止,这样,我们可以定义出其值是一个空格的变量。请注意这里关于#的使用,注释符#的这种特性值得我们注意,如果我们这样定义一个变量:
1
dir:=/foo/bar #directoryto put the frobs in

dir这个变量的值是/foo/bar,后面还跟了4个空格,如果我们这样使用这样变量来指定别的目录——$(dir)/file那么就完蛋了。

还有一个比较有用的操作符是?=,先看示例:

1
FOO ?=bar

其含义是,如果FOO没有被定义过,那么变量FOO的值就是bar,如果FOO先前被定义过,那么这条语将什么也不做,其等价于:
1
2
3
4
5
ifeq ($(originFOO),undefined)

FOO = bar

endif

变量高级用法

这里介绍两种变量的高级使用方法,第一种是变量值的替换。

我们可以替换变量中的共有的部分,其格式是$(var:a=b)或是${var:a=b},其意思是,把变量var中所有以a字串结尾a替换成b字串。这里的结尾意思是空格或是结束符

还是看一个示例吧:

1
2
3
foo:=a.o b.o c.o

bar:=$(foo:.o=.c)

这个示例中,我们先定义了一个$(foo)变量,而第二行的意思是把$(foo)中所有以.o字串结尾全部替换成.c,所以我们的$(bar)的值就是a.cb.cc.c

另外一种变量替换的技术是以静态模式(参见前面章节)定义的,如:

1
2
3
foo:=a.o b.o c.o

bar:=$(foo:%.o=%.c)

这依赖于被替换字串中的有相同的模式,模式中必须包含一个%字符,这个例子同样让$(bar)变量的值为a.c b.c c.c

第二种高级用法是——把变量的值再当成变量。先看一个例子:

1
2
3
x=y
y=z
a:=$($(x))

在这个例子中,$(x)的值是y,所以$($(x))就是$(y),于是$(a)的值就是z。(注意,是x=y,而不是x=$(y)

我们还可以使用更多的层次:

1
2
3
4
x=y
y=z
z=u
a:=$($($(x)))

这里的$(a)的值是u,相关的推导留给读者自己去做吧。

让我们再复杂一点,使用上在变量定义中使用变量的第一个方式,来看一个例子:

1
2
3
4
5
6
7
x=$(y)

y=z

z=Hello

a:=$($(x))

这里的$($(x))被替换成了$($(y)),因为$(y)值是z,所以,最终结果是:a:=$(z),也就是Hello

再复杂一点,我们再加上函数:

1
2
3
4
5
6
7
8
9
x=variable1

variable2:=Hello

y=$(subst 1,2,$(x))

z=y

a:= $($($(z)))

这个例子中,$($($(z)))扩展为$($(y)),而其再次被扩展为$($(subst 1,2,$(x)))。$(x)的值是variable1,subst函数把variable1中的所有1字串替换成2字串,于是,variable1变成variable2,再取其值,所以,最终,$(a)的值就是$(variable2)的值——Hello。(喔,好不容易)

在这种方式中,或要可以使用多个变量来组成一个变量的名字,然后再取其值:

1
2
3
4
5
6
7
first_second=Hello

a=first

b=second

all=$($a_$b)

这里的$a_$b组成了first_second,于是,$(all)的值就是Hello

再来看看结合第一种技术的例子:

1
2
3
4
5
a_objects:=a.o b.o c.o

1_objects:=1.o 2.o 3.o

sources:=$($(a1)_objects:.o=.c)

这个例子中,如果$(a1)的值是a的话,那么,$(sources)的值就是a.c b.c c.c;如果$(a1)的值是1,那么$(sources)的值是1.c 2.c 3.c

再来看一个这种技术和函数条件语句一同使用的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
ifdef do_sort

func :=sort

else

func :=strip

endif

bar :=a d b g q c

foo :=$($(func)$(bar))

这个示例中,如果定义了do_sort,那么:foo:=$(sort a d b g q c),于是$(foo)的值就是a b c d g q,而如果没有定义do_sort,那么:foo:=$(sort a d b g q c),调用的就是strip函数。

当然,把变量的值再当成变量这种技术,同样可以用在操作符的左边:

1
2
3
4
5
6
7
8
9
dir =foo

$(dir)_sources:=$(wildcard$(dir)/*.c)

define $(dir)_print

lpr $($(dir)_sources)

endef

这个例子中定义了三个变量:dirfoo_sourcesfoo_print

追加变量值

我们可以使用+=操作符给变量追加值,如:

1
2
3
objects=main.o foo.o bar.outils.o

objects+=another.o

于是,我们的$(objects)值变成:main.o foo.o bar.o utils.o another.o(another.o被追加进去了)

使用+=操作符,可以模拟为下面的这种例子:

1
2
3
objects=main.o foo.o bar.outils.o

objects:=$(objects) another.o

所不同的是,用+=更为简洁。

如果变量之前没有定义过,那么,+=会自动变成=,如果前面有变量定义,那么+=会继承于前次操作的赋值符。如果前一次的是:=,那么+=会以:=作为其赋值符,如:

1
2
3
variable :=value

variable +=more

等价于:
1
2
3
variable:=value

variable:=$(variable)more

但如果是这种情况:
1
2
3
variable =value

variable +=more

由于前次的赋值符是=,所以+=也会以=来做为赋值,那么岂不会发生变量的递补归定义,这是很不好的,所以make会自动为我们解决这个问题,我们不必担心这个问题。

override指示符

如果有变量是通常make的命令行参数设置的,那么Makefile中对这个变量的赋值会被忽略。如果你想在Makefile中设置这类参数的值,那么,你可以使用override指示符。其语法是:

1
2
3
override <variable>=<value>

override <variable>:=<value>

当然,你还可以追加:
1
override <variable>+= <moretext>

对于多行的变量定义,我们用define指示符,在define指示符前,也同样可以使用ovveride指示符,如:
1
2
3
4
5
override define foo

bar

endef

多行变量

还有一种设置变量值的方法是使用define关键字。使用define关键字设置变量的值可以有换行,这有利于定义一系列的命令(前面我们讲过命令包的技术就是利用这个关键字)。

define指示符后面跟的是变量的名字,而重起一行定义变量的值,定义是以endef关键字结束。其工作方式和=操作符一样。变量的值可以包含函数、命令、文字,或是其它变量。因为命令需要以[Tab]键开头,所以如果你用define定义的命令变量中没有以[Tab]键开头,那么make就不会把其认为是命令。

下面的这个示例展示了define的用法:

1
2
3
4
5
6
7
define two-lines

echo foo

echo $(bar)

endef

环境变量

make运行时的系统环境变量可以在make开始运行时被载入到Makefile文件中,但是如果Makefile中已定义了这个变量,或是这个变量由make命令行带入,那么系统的环境变量的值将被覆盖。(如果make指定了-e参数,那么,系统环境变量将覆盖Makefile中定义的变量)

因此,如果我们在环境变量中设置了CFLAGS环境变量,那么我们就可以在所有的Makefile中使用这个变量了。这对于我们使用统一的编译参数有比较大的好处。如果Makefile中定义了CFLAGS,那么则会使用Makefile中的这个变量,如果没有定义则使用系统环境变量的值,一个共性和个性的统一,很像全局变量局部变量的特性。当make嵌套调用时(参见前面的嵌套调用章节),上层Makefile中定义的变量会以系统环境变量的方式传递到下层的Makefile中。当然,默认情况下,只有通过命令行设置的变量会被传递。而定义在文件中的变量,如果要向下层Makefile传递,则需要使用exprot关键字来声明。(参见前面章节)

当然,我并不推荐把许多的变量都定义在系统环境中,这样,在我们执行不用的Makefile时,拥有的是同一套系统变量,这可能会带来更多的麻烦。

目标变量

前面我们所讲的在Makefile中定义的变量都是全局变量,在整个文件,我们都可以访问这些变量。当然,自动化变量除外,如$<等这种类量的自动化变量就属于规则型变量,这种变量的值依赖于规则的目标和依赖目标的定义。

当然,我们同样可以为某个目标设置局部变量,这种变量被称为Target-specific Variable,它可以和全局变量同名,因为它的作用范围只在这条规则以及连带规则中,所以其值也只在作用范围内有效。而不会影响规则链以外的全局变量的值。

其语法是:

1
2
3
<target...> :<variable-assignment>

<target...> :override<variable-assignment>

可以是前面讲过的各种赋值表达式,如=:=+=或是?=。第二个语法是针对于make命令行带入的变量,或是系统环境变量。

这个特性非常的有用,当我们设置了这样一个变量,这个变量会作用到由这个目标所引发的所有的规则中去。如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
prog:CFLAGS = -g

prog:prog.o foo.o bar.o

$(CC) $(CFLAGS) prog.ofoo.o bar.o

prog.o:prog.c

$(CC )$(CFLAGS) prog.c

foo.o:foo.c

$(CC) $(CFLAGS) foo.c

bar.o:bar.c

$(CC) $(CFLAGS) bar.c

在这个示例中,不管全局的$(CFLAGS)的值是什么,在prog目标,以及其所引发的所有规则中(prog.o foo.o bar.o的规则),$(CFLAGS)的值都是-g

模式变量

在GNU的make中,还支持模式变量(Pattern-specificVariable),通过上面的目标变量中,我们知道,变量可以定义在某个目标上。模式变量的好处就是,我们可以给定一种模式,可以把变量定义在符合这种模式的所有目标上。

我们知道,make的模式一般是至少含有一个%的,所以,我们可以以如下方式给所有以[.o]结尾的目标定义目标变量:

1
%.o:CFLAGS =-O

同样,模式变量的语法和目标变量一样:
1
2
3
<pattern...>:<variable-assignment>

<pattern...>:override<variable-assignment>

override同样是针对于系统环境传入的变量,或是make命令行指定的变量。

使用条件判断

使用条件判断,可以让make根据运行时的不同情况选择不同的执行分支。条件表达式可以是比较变量的值,或是比较变量和常量的值。

示例

下面的例子,判断$(CC)变量是否gcc,如果是的话,则使用GNU函数编译目标。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
libs_for_gcc=-lgnu

normal_libs=

foo:$(objects)

ifeq($(CC),gcc)

$(CC) -o foo $(objects)$(libs_for_gcc)

else

$(CC) -o foo $(objects)$(normal_libs)

endif

可见,在上面示例的这个规则中,目标foo可以根据变量$(CC)值来选取不同的函数库来编译程序。

我们可以从上面的示例中看到三个关键字:ifeq、else和endif。ifeq的意思表示条件语句的开始,并指定一个条件表达式,表达式包含两个参数,以逗号分隔,表达式以圆括号括起。else表示条件表达式为假的情况。endif表示一个条件语句的结束,任何一个条件表达式都应该以endif结束。

当我们的变量$(CC)值是gcc时,目标foo的规则是:

1
2
3
foo:$(objects)

$(CC) -o foo $(objects)$(libs_for_gcc)

而当我们的变量$(CC)值不是gcc时(比如cc),目标foo的规则是:
1
2
3
foo:$(objects)

$(CC) -o foo $(objects)$(normal_libs)

当然,我们还可以把上面的那个例子写得更简洁一些:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
libs_for_gcc=-lgnu

normal_libs=

ifeq($(CC),gcc)

libs=$(libs_for_gcc)

else

libs=$(normal_libs)

endif

foo:$(objects)

$(CC) -o foo $(objects)$(libs)

语法

条件表达式的语法为:

1
2
3
4
5
<conditional-directive>

<text-if-true>

endif

以及:
1
2
3
4
5
6
7
8
9
<conditional-directive>

<text-if-true>

else

<text-if-false>

endif

其中表示条件关键字,如ifeq。这个关键字有四个。

第一个是我们前面所见过的ifeq

1
2
3
4
5
6
7
8
9
ifeq(<arg1>,<arg2>)

ifeq'<arg1>''<arg2>'

ifeq"<arg1>""<arg2>"

ifeq"<arg1>"'<arg2>'

ifeq'<arg1>'"<arg2>"

比较参数arg1arg2的值是否相同。当然,参数中我们还可以使用make的函数。如:
1
2
3
4
5
ifeq($(strip $(foo)),)

<text-if-empty>

endif

这个示例中使用了strip函数,如果这个函数的返回值是空(Empty),那么就生效。

第二个条件关键字是ifneq。语法是:

1
2
3
4
5
6
7
8
9
ifneq(<arg1>,<arg2>)

ifneq'<arg1>''<arg2>'

ifneq"<arg1>""<arg2>"

ifneq"<arg1>"'<arg2>'

ifneq'<arg1>'"<arg2>"

其比较参数arg1arg2的值是否相同,如果不同,则为真。和ifeq类似。

第三个条件关键字是ifdef。语法是:

1
ifdef<variable-name>

如果变量的值非空,那到表达式为真。否则,表达式为假。当然,同样可以是一个函数的返回值。注意,ifdef只是测试一个变量是否有值,其并不会把变量扩展到当前位置。还是来看两个例子:

示例一:

1
2
3
4
5
6
7
8
9
10
11
12
13
bar=

foo=$(bar)

ifdef foo

frobozz=yes

else

frobozz=no

endif

示例二:
1
2
3
4
5
6
7
8
9
10
11
foo=

ifdef foo

frobozz=yes

else

frobozz=no

endif

第一个例子中,$(frobozz)值是yes,第二个则是no

第四个条件关键字是ifndef。其语法是:

1
ifndef<variable-name>

这个我就不多说了,和ifdef是相反的意思。

<conditional-directive>这一行上,多余的空格是被允许的,但是不能以[Tab]键做为开始(不然就被认为是命令)。而注释符#同样也是安全的。elseendif也一样,只要不是以[Tab]键开始就行了。

特别注意的是,make是在读取Makefile时就计算条件表达式的值,并根据条件表达式的值来选择语句,所以,你最好不要把自动化变量(如$@等)放入条件表达式中,因为自动化变量是在运行时才有的。

而且,为了避免混乱,make不允许把整个条件语句分成两部分放在不同的文件中。

使用函数

在Makefile中可以使用函数来处理变量,从而让我们的命令或是规则更为的灵活和具有智能。make所支持的函数也不算很多,不过已经足够我们的操作了。函数调用后,函数的返回值可以当做变量来使用。

函数的调用语法

函数调用,很像变量的使用,也是以$来标识的,其语法如下:

1
$(<function><arguments>)

或是
1
${<function><arguments>}

这里,就是函数名,make支持的函数不多。是函数的参数,参数间以逗号,分隔,而函数名和参数之间以空格分隔。函数调用以$开头,以圆括号或花括号把函数名和参数括起。感觉很像一个变量,是不是?函数中的参数可以使用变量,为了风格的统一,函数和变量的括号最好一样,如使用$(substa,b,$(x))这样的形式,而不是$(substa,b,${x})的形式。因为统一会更清楚,也会减少一些不必要的麻烦。

还是来看一个示例:

1
2
3
4
5
6
7
8
9
comma:=,

empty:=

space:=$(empty)$(empty)

foo:=a b c

bar:=$(subst $(space),$(comma),$(foo))

在这个示例中,$(comma)的值是一个逗号。$(space)使用了$(empty)定义了一个空格,$(foo)的值是abc,$(bar)的定义用,调用了函数subst,这是一个替换函数,这个函数有三个参数,第一个参数是被替换字串,第二个参数是替换字串,第三个参数是替换操作作用的字串。这个函数也就是把$(foo)中的空格替换成逗号,所以$(bar)的值是a,b,c

字符串处理函数

1
$(subst <from>,<to>,<text>)

名称:字符串替换函数——subst。

功能:把字串中的字符串替换成

返回:函数返回被替换过后的字符串。

示例:

1
$(subst ee,EE,feetonthestreet),

feetonthestreet中的ee替换成EE,返回结果是fEEtonthestrEEt
1
$(patsubst <pattern>,<replacement>,<text>)

名称:模式字符串替换函数——patsubst。

功能:查找<text>中的单词(单词以空格Tab回车``换行分隔)是否符合模式,如果匹配的话,则以替换。这里,可以包括通配符%,表示任意长度的字串。如果中也包含%,那么,中的这个%将是中的那个%所代表的字串。(可以用\来转义,以\%来表示真实含义的%字符)返回:函数返回被替换过后的字符串。

示例:

1
$(patsubst %.c,%.o,x.c.cbar.c)

把字串x.c.c bar.c符合模式[%.c]的单词替换成[%.o],返回结果是x.c.o bar.o

备注:

这和我们前面变量章节说过的相关知识有点相似。如:
$(var:<pattern>=<replacement>)

相当于
$(patsubst <pattern>,<replacement>,$(var))


$(var:<suffix>=<replacement>)

则相当于
$(patsubst %<suffix>,%<replacement>,$(var))

例如有:objects=foo.o bar.o baz.o,

那么,$(objects:.o=.c)$(patsubst %.o,%.c,$(objects))是一样的。

1
$(strip <string>)

名称:去空格函数——strip。

功能:去掉字串中开头和结尾的空字符。

返回:返回被去掉空格的字符串值。

示例:

1
$(strip abc)

把字串abc去到开头和结尾的空格,结果是abc
1
$(findstring<find>,<in>)

名称:查找字符串函数——findstring。

功能:在字串中查找字串。

返回:如果找到,那么返回,否则返回空字符串。

示例:

1
2
3
$(findstring a,a b c)

$(findstring a,b c)

第一个函数返回a字符串,第二个返回 字符串(空字符串)

1
$(filter <pattern...>,<text>)

名称:过滤函数——filter。

功能:以模式过滤字符串中的单词,保留符合模式的单词。可以有多个模式。

返回:返回符合模式的字串。

示例:

1
2
3
4
5
6
7
sources:=foo.c bar.c baz.sugh.h

foo:$(sources)

cc $(filter %.c %.s,$(sources))-o foo

$(filter%.c%.s,$(sources))返回的值是`foo.c bar.c baz.s`。

1
$(filter-out <pattern...>,<text>)

名称:反过滤函数——filter-out。

功能:以模式过滤字符串中的单词,去除符合模式的单词。可以有多个模式。

返回:返回不符合模式的字串。

示例:

1
2
3
4
5
objects=main1.o foo.omain2.o bar.o

mains=main1.o main2.o

$(filter-out $(mains),$(objects))返回值是`foo.o bar.o`。

1
$(sort <list>)

名称:排序函数——sort。

功能:给字符串中的单词排序(升序)。

返回:返回排序后的字符串。

示例:$(sort foobarlose)返回barfoolose

备注:sort函数会去掉中相同的单词。

1
$(word <n>,<text>)

名称:取单词函数——word。

功能:取字符串中第个单词。(从一开始)

返回:返回字符串中第个单词。如果中的单词数要大,那么返回空字符串。

示例:

1
$(word 2,foo bar baz)返回值是`bar`。

1
$(wordlist <s>,<e>,<text>)

名称:取单词串函数——wordlist。

功能:从字符串中取从开始到的单词串。是一个数字。

返回:返回字符串中从的单词字串。如果中的单词数要大,那

么返回空字符串。如果大于的单词数,那么返回从开始,到结束的单

词串。

示例:$(wordlist 2,3,foo bar baz)返回值是bar baz

1
$(words <text>)

名称:单词个数统计函数——words。

功能:统计中字符串中的单词个数。

返回:返回中的单词数。

示例:$(words,foo bar baz)返回值是3

备注:如果我们要取中最后的一个单词,我们可以这样:$(word $(words

),)。

1
$(firstword <text>)

名称:首单词函数——firstword。

功能:取字符串中的第一个单词。

返回:返回字符串的第一个单词。

示例:$(firstword foo bar)返回值是foo

备注:这个函数可以用word函数来实现:$(word 1,)。

以上,是所有的字符串操作函数,如果搭配混合使用,可以完成比较复杂的功能。这里,举一个现实中应用的例子。我们知道,make使用VPATH变量来指定依赖文件的搜索路径。于是,我们可以利用这个搜索路径来指定编译器对头文件的搜索路径参数CFLAGS,如:

override CFLAGS +=$(patsubst%,-I%,$(subst:,,$(VPATH)))

如果我们的$(VPATH)值是src:../headers,那么$(patsubst %,-I%,$(subst:,,$(VPATH)))将返回-Isrc-I../headers,这正是cc或gcc搜索头文件路径的参数。

文件名操作函数

下面我们要介绍的函数主要是处理文件名的。每个函数的参数字符串都会被当做一个或是

一系列的文件名来对待。

1
$(dir <names...>)

名称:取目录函数——dir。

功能:从文件名序列中取出目录部分。目录部分是指最后一个反斜杠(/)之

前的部分。如果没有反斜杠,那么返回./

返回:返回文件名序列的目录部分。

示例:$(dir src/foo.chacks)返回值是src/./

1
$(notdir <names...>)

名称:取文件函数——notdir。

功能:从文件名序列中取出非目录部分。非目录部分是指最后一个反斜杠(/)之后的部分。

返回:返回文件名序列的非目录部分。

示例:$(notdir src/foo.chacks)返回值是foo.chacks

1
$(suffix<names...>)

名称:取后缀函数——suffix。

功能:从文件名序列中取出各个文件名的后缀。

返回:返回文件名序列的后缀序列,如果文件没有后缀,则返回空字串。

示例:$(suffix src/foo.c src-1.0/bar.chacks)返回值是.c.c

1
$(basename <names...>)

名称:取前缀函数——basename。

功能:从文件名序列中取出各个文件名的前缀部分。

返回:返回文件名序列的前缀序列,如果文件没有前缀,则返回空字串。

示例:$(basename src/foo.c src-1.0/bar.chacks)返回值是src/foo src-1.0/bar hacks

1
$(addsuffix <suffix>,<names...>)

名称:加后缀函数——addsuffix。

功能:把后缀加到中的每个单词后面。

返回:返回加过后缀的文件名序列。

示例:$(addsuffix.c,foobar)返回值是foo.c bar.c

1
$(addprefix <prefix>,<names...>)

名称:加前缀函数——addprefix。

功能:把前缀加到中的每个单词后面。

返回:返回加过前缀的文件名序列。

示例:$(addprefix src/,foo bar)返回值是src/foosrc/bar

1
$(join<list1>,<list2>)

名称:连接函数——join。

功能:把中的单词对应地加到的单词后面。如果的单词个数要比的多,那么,中的多出来的单词将保持原样。如果的单词个数要比多,那么,多出来的单词将被复制到中。

返回:返回连接过后的字符串。

示例:$(join aaa bbb,111 222 333)返回值是aaa111 bbb222 333

foreach函数

foreach函数和别的函数非常的不一样。因为这个函数是用来做循环用的,Makefile中的foreach函数几乎是仿照于Unix标准Shell(/bin/sh)中的for语句,或是C-Shell(/bin/csh)中的foreach语句而构建的。它的语法是:

1
$(foreach<var>,<list>,<text>)

这个函数的意思是,把参数中的单词逐一取出放到参数所指定的变量中,然后再执行所包含的表达式。每一次会返回一个字符串,循环过程中,的所返回的每个字符串会以空格分隔,最后当整个循环结束时,所返回的每个字符串所组成的整个字符串(以空格分隔)将会是foreach函数的返回值。

所以,最好是一个变量名,可以是一个表达式,而中一般会使用

这个参数来依次枚举中的单词。举个例子:

1
2
3
names:=a b c d

files:=$(foreach n,$(names),$(n).o)

上面的例子中,$(name)中的单词会被挨个取出,并存到变量n中,$(n).o每次根据$(n)计算出一个值,这些值以空格分隔,最后作为foreach函数的返回,所以,$(files)的值是a.o b.o c.o d.o

注意,foreach中的参数是一个临时的局部变量,foreach函数执行完后,参数的变量将不在作用,其作用域只在foreach函数当中。

if函数

if函数很像GNU的make所支持的条件语句——ifeq(参见前面所述的章节),if函数的语法是:

1
$(if<condition>,<then-part>)

或是
1
$(if<condition>,<then-part>,<else-part>)

可见,if函数可以包含else部分,或是不含。即if函数的参数可以是两个,也可以是三个。参数是if的表达式,如果其返回的为非空字符串,那么这个表达式就相当于返回真,于是,会被计算,否则会被计算。

而if函数的返回值是,如果为真(非空字符串),那个会是整个函数的返回值,如果为假(空字符串),那么会是整个函数的返回值,此时如果没有被定义,那么,整个函数返回空字串。

所以,只会有一个被计算。

call函数

call函数是唯一一个可以用来创建新的参数化的函数。你可以写一个非常复杂的表达式,这个表达式中,你可以定义许多参数,然后你可以用call函数来向这个表达式传递参数。其语法是:

1
$(call <expression>,<parm1>,<parm2>,<parm3>...)

当make执行这个函数时,参数中的变量,如$(1),$(2),$(3)等,会被参数依次取代。而的返回值就是call函数的返回值。例如:
1
2
3
reverse=$(1) $(2)

foo=$(call reverse,a,b)

那么,foo的值就是a b。当然,参数的次序是可以自定义的,不一定是顺序的,如:
1
2
3
reverse=$(2) $(1)

foo=$(call reverse,a,b)

此时的foo的值就是b a

origin函数

origin函数不像其它的函数,他并不操作变量的值,他只是告诉你你的这个变量是哪里来的?其语法是:

1
$(origin <variable>)

注意,是变量的名字,不应该是引用。所以你最好不要在中使用$字符。Origin函数会以其返回值来告诉你这个变量的出生情况,下面,是origin函

数的返回值:

undefined

如果从来没有定义过,origin函数返回这个值undefined

default

如果是一个默认的定义,比如CC这个变量,这种变量我们将在后面讲述。

environment

如果是一个环境变量,并且当Makefile被执行时,-e参数没有被打开。

file

如果这个变量被定义在Makefile中。

command line

如果这个变量是被命令行定义的。

override

如果是被override指示符重新定义的。

automatic

如果是一个命令运行中的自动化变量。关于自动化变量将在后面讲述。

这些信息对于我们编写Makefile是非常有用的,例如,假设我们有一个Makefile其包了一个定义文件Make.def,在Make.def中定义了一个变量bletch,而我们的环境中也有一个环境变量bletch,此时,我们想判断一下,如果变量来源于环境,那么我们就把之重定义了,如果来源于Make.def或是命令行等非环境的,那么我们就不重新定义它。于是,在我们的Makefile中,我们可以这样写:

1
2
3
4
5
6
7
8
9
ifdef bletch

ifeq"$(origin bletch)""environment"

bletch=barf,gag,etc.

endif

endif

当然,你也许会说,使用override关键字不就可以重新定义环境中的变量了吗?为什么需要使用这样的步骤?是的,我们用override是可以达到这样的效果,可是override过于粗暴,它同时会把从命令行定义的变量也覆盖了,而我们只想重新定义环境传来的,而不想重新定义命令行传来的。

shell函数

shell函数也不像其它的函数。顾名思义,它的参数应该就是操作系统Shell的命令。它和反引号是相同的功能。这就是说,shell函数把执行操作系统命令后的输出作为函数返回。于是,我们可以用操作系统命令以及字符串处理命令awk,sed等等命令来生成一个变量,如:

1
2
3
contents:=$(shell catfoo)

files:=$(shell echo *.c)

注意,这个函数会新生成一个Shell程序来执行命令,所以你要注意其运行性能,如果你的Makefile中有一些比较复杂的规则,并大量使用了这个函数,那么对于你的系统性能是有害的。特别是Makefile的隐晦的规则可能会让你的shell函数执行的次数比你想像的多得多。

控制make的函数

make提供了一些函数来控制make的运行。通常,你需要检测一些运行Makefile时的运行时信息,并且根据这些信息来决定,你是让make继续执行,还是停止。

1
$(error <text...>)

产生一个致命的错误,是错误信息。注意,error函数不会在一被使用就会产生错误信息,所以如果你把其定义在某个变量中,并在后续的脚本中使用这个变量,那么也是可以的。例如:

示例一:

1
2
3
4
5
ifdef ERROR_001

$(error error is $(ERROR_001))

endif

示例二:
1
2
3
4
5
ERR=$(error found an error!)

.PHONY:err

err:;$(ERR)

示例一会在变量ERROR_001定义了后执行时产生error调用,而示例二则在目录err被执行时才发生error调用。

1
$(warning <text...>)

这个函数很像error函数,只是它并不会让make退出,只是输出一段警告信息,而make继续执行。

make的运行

一般来说,最简单的就是直接在命令行下输入make命令,make命令会找当前目录的makefile来执行,一切都是自动的。但也有时你也许只想让make重编译某些文件,而不是整个工程,而又有的时候你有几套编译规则,你想在不同的时候使用不同的编译规则,等等。本章节就是讲述如何使用make命令的。

make的退出码

make命令执行后有三个退出码:

0——表示成功执行。

1——如果make运行时出现任何错误,其返回1。

2——如果你使用了make的-q选项,并且make使得一些目标不需要更新,那么返回2。

Make的相关参数我们会在后续章节中讲述。

指定Makefile

前面我们说过,GNU make找寻默认的Makefile的规则是在当前目录下依次找三个文件——GNUmakefilemakefileMakefile。其按顺序找这三个文件,一旦找到,就开始读取这个文件并执行。

当前,我们也可以给make命令指定一个特殊名字的Makefile。要达到这个功能,我们要使用make的-f或是--file参数(--makefile参数也行)。例如,我们有个makefile的名字是hchen.mk,那么,我们可以这样来让make来执行这个文件:

1
make –f hchen.mk

如果在make的命令行是,你不只一次地使用了-f参数,那么,所有指定的makefile将会被连在一起传递给make执行。

指定目标

一般来说,make的最终目标是makefile中的第一个目标,而其它目标一般是由这个目标连带出来的。这是make的默认行为。当然,一般来说,你的makefile中的第一个目标是由许多个目标组成,你可以指示make,让其完成你所指定的目标。要达到这一目的很简单,需在make命令后直接跟目标的名字就可以完成(如前面提到的make clean形式)任何在makefile中的目标都可以被指定成终极目标,但是除了以-打头,或是包含了=的目标,因为有这些字符的目标,会被解析成命令行参数或是变量。甚至没有被我们明确写出来的目标也可以成为make的终极目标,也就是说,只要make可以找到其隐含规则推导规则,那么这个隐含目标同样可以被指定成终极目标。

有一个make的环境变量叫MAKECMDGOALS,这个变量中会存放你所指定的终极目标的列表,如果在命令行上,你没有指定目标,那么,这个变量是空值。这个变量可以让你使用在一些比较特殊的情形下。比如下面的例子:

1
2
3
4
5
6
7
sources=foo.c bar.c

ifneq ($(MAKECMDGOALS),clean)

include $(sources:.c=.d)

endif

基于上面的这个例子,只要我们输入的命令不是makeclean,那么makefile会自动包含foo.dbar.d这两个makefile。

使用指定终极目标的方法可以很方便地让我们编译我们的程序,例如下面这个例子:

1
2
3
.PHONY:all

all:prog1 prog2 prog3prog4

从这个例子中,我们可以看到,这个makefile中有四个需要编译的程序——prog1prog2prog3prog4,我们可以使用make all命令来编译所有的目标

(如果把all置成第一个目标,那么只需执行make),我们也可以使用make prog2来单独编译目标prog2

即然make可以指定所有makefile中的目标,那么也包括伪目标,于是我们可以根据这种性质来让我们的makefile根据指定的不同的目标来完成不同的事。在Unix世界中,软件发布时,特别是GNU这种开源软件的发布时,其makefile都包含了编译、安装、打包等功能。我们可以参照这种规则来书写我们的makefile中的目标。

all这个伪目标是所有目标的目标,其功能一般是编译所有的目标。

clean这个伪目标功能是删除所有被make创建的文件。

install这个伪目标功能是安装已编译好的程序,其实就是把目标执行文件拷贝到指定的目标中去。

print这个伪目标的功能是例出改变过的源文件。

tar这个伪目标功能是把源程序打包备份。也就是一个tar文件。

dist这个伪目标功能是创建一个压缩文件,一般是把tar文件压成Z文件。或是gz文件。

TAGS这个伪目标功能是更新所有的目标,以备完整地重编译使用。

checktest这两个伪目标一般用来测试makefile的流程。

当然一个项目的makefile中也不一定要书写这样的目标,这些东西都是GNU的东西,但是我想,GNU搞出这些东西一定有其可取之处(等你的UNIX下的程序文件一多时你就会发现这些功能很有用了),这里只不过是说明了,如果你要书写这种功能,最好使用这种名字命名你的目标,这样规范一些,规范的好处就是——不用解释,大家都明白。而且如果你的makefile中有这些功能,一是很实用,二是可以显得你的makefile很专业(不是那种初学者的作品)。

检查规则

有时候,我们不想让我们的makefile中的规则执行起来,我们只想检查一下我们的命令,或是执行的序列。于是我们可以使用make命令的下述参数:

-n

--just-print

--dry-run

--recon

不执行参数,这些参数只是打印命令,不管目标是否更新,把规则和连带规则下的命令打印出来,但不执行,这些参数对于我们调试makefile很有用处。

-t

--touch

这个参数的意思就是把目标文件的时间更新,但不更改目标文件。也就是说,make假装编译目标,但不是真正的编译目标,只是把目标变成已编译过的状态。

-q

--question

这个参数的行为是找目标的意思,也就是说,如果目标存在,那么其什么也不会输出,当然也不会执行编译,如果目标不存在,其会打印出一条出错信息。

-W <file>

--what-if=<file>

--assume-new=<file>

--new-file=<file>

这个参数需要指定一个文件。一般是是源文件(或依赖文件),Make会根据规则推导来运行依赖于这个文件的命令,一般来说,可以和-n参数一同使用,来查看这个依赖文件所发生的规则命令。

另外一个很有意思的用法是结合-p-v来输出makefile被执行时的信息(这个将在后面讲述)。

make的参数

下面列举了所有GNU make 3.80版的参数定义。其它版本和产商的make大同小异,不过其它产商的make的具体参数还是请参考各自的产品文档。

-b

-m

这两个参数的作用是忽略和其它版本make的兼容性。

-B

--always-make

认为所有的目标都需要更新(重编译)。

-C <dir>

--directory=<dir>

指定读取makefile的目录。如果有多个-C参数,make的解释是后面的路径以前面的作为相对路径,并以最后的目录作为被指定目录。如:make –C ~hchen/test–C prog等价于make –C ~hchen/test/prog

—debug[=<options>]

输出make的调试信息。它有几种不同的级别可供选择,如果没有参数,那就是输出最简单的调试信息。下面是的取值:

a——也就是all,输出所有的调试信息。(会非常的多)

b——也就是basic,只输出简单的调试信息。即输出不需要重编译的目标。

v——也就是verbose,在b选项的级别之上。输出的信息包括哪个makefile被解析,不需要被重编译的依赖文件(或是依赖目标)等。

i——也就是implicit,输出所以的隐含规则。

j——也就是jobs,输出执行规则中命令的详细信息,如命令的PID、返回码等。

m——也就是makefile,输出make读取makefile,更新makefile,执行makefile的信息。

-d相当于--debug=a

-e --environment-overrides 指明环境变量的值覆盖makefile中定义的变量的值。

-f=<file>--file=<file>--makefile=<file>指定需要执行的makefile。

-h--help 显示帮助信息。

-i--ignore-errors 在执行时忽略所有的错误。

-I<dir>--include-dir=<dir>指定一个被包含makefile的搜索目标。可以使用多个-I参数来指定多个目录。

-j[<jobsnum>]--jobs[=<jobsnum>]指同时运行命令的个数。如果没有这个参数,make运行命令时能运行多少就运行多少。如果有一个以上的-j参数,那么仅最后一个-j才是有效的。(注意这个参数在MS-DOS中是无用的)

-k--keep-going出错也不停止运行。如果生成一个目标失败了,那么依赖于其上的目标就不会被执行了。

-l<load>--load-average[=<load]—max-load[=<load>] 指定make运行命令的负载。

-n--just-print--dry-run--recon 仅输出执行过程中的命令序列,但并不执行。

-o<file>--old-file=<file>--assume-old=<file>不重新生成的指定的,即使这个目标的依赖文件新于它。

-p--print-data-base、输出makefile中的所有数据,包括所有的规则和变量。这个参数会让一个简单的makefile都会输出一堆信息。如果你只是想输出信息而不想执行makefile,你可以使用make -q p命令。如果你想查看执行makefile前的预设变量和规则,你可以使用make–p–f/dev/null。这个参数输出的信息会包含着你的makefile文件的文件名和行号,所以,用这个参数来调试你的makefile会是很有用的,特别是当你的环境变量很复杂的时候。

-q--question不运行命令,也不输出。仅仅是检查所指定的目标是否需要更新。如果是0则说明要更新,如果是2则说明有错误发生。

-r--no-builtin-rules禁止make使用任何隐含规则。

-R--no-builtin-variabes禁止make使用任何作用于变量上的隐含规则。

-s--silent--quiet在命令运行时不输出命令的输出。

-S--no-keep-going--stop取消-k选项的作用。因为有些时候,make的选项是从环境变量MAKEFLAGS中继承下来的。所以你可以在命令行中使用这个参数来让环境变量中的-k选项失效。

-t--touch相当于UNIX的touch命令,只是把目标的修改日期变成最新的,也就是阻止生成目标的命令运行。

-v--version输出make程序的版本、版权等关于make的信息。

-w--print-directory输出运行makefile之前和之后的信息。这个参数对于跟踪嵌套式调用make时很有用。

--no-print-directory禁止-w选项。

-W <file>--what-if=<file>--new-file=<file>--assume-file=<file>假定目标需要更新,如果和-n选项使用,那么这个参数会输出该目标更新时的运行动作。如果没有-n那么就像运行UNIX的touch命令一样,使得的修改时间为当前时间。

--warn-undefined-variables只要make发现有未定义的变量,那么就输出警告信息。

隐含规则

在我们使用Makefile时,有一些我们会经常使用,而且使用频率非常高的东西,比如,我们编译C/C++的源程序为中间目标文件(Unix下是[.o]文件,Windows下是[.obj]文件)。本章讲述的就是一些在Makefile中的隐含的,早先约定了的,不需要我们再写出来的规则。

隐含规则也就是一种惯例,make会按照这种惯例心照不喧地来运行,那怕我们的Makefile中没有书写这样的规则。例如,把[.c]文件编译成[.o]文件这一规则,你根本就不用写出来,make会自动推导出这种规则,并生成我们需要的[.o]文件。

隐含规则会使用一些我们系统变量,我们可以改变这些系统变量的值来定制隐含规则的运行时的参数。如系统变量CFLAGS可以控制编译时的编译器参数。

我们还可以通过模式规则的方式写下自己的隐含规则。用后缀规则来定义隐含规则会有许多的限制。使用模式规则会更回得智能和清楚,但后缀规则可以用来保证我们Makefile的兼容性。

我们了解了隐含规则,可以让其为我们更好的服务,也会让我们知道一些约定俗成了的东西,而不至于使得我们在运行Makefile时出现一些我们觉得莫名其妙的东西。当然,任何事物都是矛盾的,水能载舟,亦可覆舟,所以,有时候隐含规则也会给我们造成不小的麻烦。只有了解了它,我们才能更好地使用它。

使用隐含规则

如果要使用隐含规则生成你需要的目标,你所需要做的就是不要写出这个目标的规则。那么,make会试图去自动推导产生这个目标的规则和命令,如果make可以自动推导生成这个目标的规则和命令,那么这个行为就是隐含规则的自动推导。当然,隐含规则是make事先约定好的一些东西。例如,我们有下面的一个Makefile:

1
2
3
foo:foo.o bar.o

cc –o foo foo.o bar.o $(CFLAGS) $(LDFLAGS)

我们可以注意到,这个Makefile中并没有写下如何生成foo.o和bar.o这两目标的规则和命令。因为make的隐含规则功能会自动为我们自动去推导这两个目标的依赖目标和生成命令。

make会在自己的隐含规则库中寻找可以用的规则,如果找到,那么就会使用。如果找不到,那么就会报错。在上面的那个例子中,make调用的隐含规则是,把[.o]的目标的依赖文件置成[.c],并使用C的编译命令cc –c $(CFLAGS)[.c]来生成[.o]的目标。也就是说,我们完全没有必要写下下面的两条规则:

1
2
3
4
5
6
7
foo.o:foo.c

cc –c foo.c$(CFLAGS)

bar.o:bar.c

cc –c bar.c $(CFLAGS)

因为,这已经是约定好了的事了,make和我们约定好了用C编译器cc生成[.o]文件的规则,这就是隐含规则。

当然,如果我们为[.o]文件书写了自己的规则,那么make就不会自动推导并调用隐含规则,它会按照我们写好的规则忠实地执行。

还有,在make的隐含规则库中,每一条隐含规则都在库中有其顺序,越靠前的则是越被经常使用的,所以,这会导致我们有些时候即使我们显示地指定了目标依赖,make也不会管。如下面这条规则(没有命令):

1
foo.o:foo.p

依赖文件foo.p(Pascal程序的源文件)有可能变得没有意义。如果目录下存在了foo.c文件,那么我们的隐含规则一样会生效,并会通过foo.c调用C的编译器生成foo.o文件。因为,在隐含规则中,Pascal的规则出现在C的规则之后,所以,make找到可以生成foo.o的C的规则就不再寻找下一条规则了。如果你确实不希望任何隐含规则推导,那么,你就不要只写出依赖规则,而不写命令。

隐含规则一览

这里我们将讲述所有预先设置(也就是make内建)的隐含规则,如果我们不明确地写下规则,那么,make就会在这些规则中寻找所需要规则和命令。当然,我们也可以使用make的参数-r--no-builtin-rules选项来取消所有的预设置的隐含规则。

当然,即使是我们指定了-r参数,某些隐含规则还是会生效,因为有许多的隐含规则都是使用了后缀规则来定义的,所以,只要隐含规则中有后缀列表(也就一系统定义在目标.SUFFIXES的依赖目标),那么隐含规则就会生效。默认的后缀列表是:.out,.a,.ln,.o,.c,.cc,.C,.p,.f,.F,.r,.y,.l,.s,.S,.mod,.sym,.def,.h,.info,.dvi,.tex,.texinfo,.texi,.txinfo,.w,.ch.web,.sh,.elc,.el。具体的细节,我们会在后面讲述。

还是先来看一看常用的隐含规则吧。

1、编译C程序的隐含规则。

<n>.o的目标的依赖目标会自动推导为<n>.c,并且其生成命令是$(CC)–c $(CPPFLAGS) $(CFLAGS)

2、编译C++程序的隐含规则。

<n>.o的目标的依赖目标会自动推导为<n>.cc或是<n>.C,并且其生成命令是$(CXX)–c $(CPPFLAGS) $(CFLAGS)。(建议使用.cc作为C++源文件的后缀,而不是.C

3、编译Pascal程序的隐含规则。

<n>.o的目标的依赖目标会自动推导为<n>.p,并且其生成命令是$(PC)–c $(PFLAGS)

4、编译Fortran/Ratfor程序的隐含规则。

<n>.o的目标的依赖目标会自动推导为<n>.r<n>.F<n>.f,并且其生成命令是:

.f``$(FC)–c$(FFLAGS)

.F``$(FC)–c$(FFLAGS)$(CPPFLAGS)

.f``$(FC)–c$(FFLAGS)$(RFLAGS)

5、预处理Fortran/Ratfor程序的隐含规则。

<n>.f的目标的依赖目标会自动推导为<n>.r<n>.F。这个规则只是转换Ratfor或有预处理的Fortran程序到一个标准的Fortran程序。其使用的命令是:

.F``$(FC)–F $(CPPFLAGS) $(FFLAGS)

.r``$(FC)–F $(FFLAGS) $(RFLAGS)

6、编译Modula-2程序的隐含规则。

<n>.sym的目标的依赖目标会自动推导为<n>.def,并且其生成命令是:$(M2C) $(M2FLAGS)$(DEFFLAGS)<n.o>的目标的依赖目标会自动推导为<n>.mod,并且其生成命令是:$(M2C) $(M2FLAGS) $(MODFLAGS)

7、汇编和汇编预处理的隐含规则。

<n>.o的目标的依赖目标会自动推导为<n>.s,默认使用编译品as,并且其生成命令是:$(AS) $(ASFLAGS)<n>.s的目标的依赖目标会自动推导为<n>.S,默认使用C预编译器cpp,并且其生成命令是:$(AS)$(ASFLAGS)

8、链接Object文件的隐含规则。

<n>目标依赖于<n>.o,通过运行C的编译器来运行链接程序生成(一般是ld),其生成命令是:$(CC) $(LDFLAGS)<n>.o$(LOADLIBES) $(LDLIBS)。这个规则对于只有一个源文件的工程有效,同时也对多个Object文件(由不同的源文件生成)的也有效。例如如下规则:

x:y.o z.o

并且x.cy.cz.c都存在时,隐含规则将执行如下命令:

1
2
3
4
5
6
7
8
9
10
11
12
13
cc -c x.c -o x.o

cc -c y.c -o y.o

cc -c z.c -o z.o

cc x.o y.o z.o -o x

rm -f x.o

rm -f y.o

rm -f z.o

如果没有一个源文件(如上例中的x.c)和你的目标名字(如上例中的x)相关联,那么,你最好写出自己的生成规则,不然,隐含规则会报错的。

9、Yacc C程序时的隐含规则。

<n>.c的依赖文件被自动推导为n.y(Yacc生成的文件),其生成命令是:$(YACC) $(YFALGS)。(Yacc是一个语法分析器,关于其细节请查看相关资料)

10、LexC程序时的隐含规则。

<n>.c的依赖文件被自动推导为n.l(Lex生成的文件),其生成命令是:$(LEX)$(LFALGS)。(关于Lex的细节请查看相关资料)

11、LexRatfor程序时的隐含规则。

<n>.r的依赖文件被自动推导为n.l(Lex生成的文件),其生成命令是:$(LEX)$(LFALGS)

12、从C程序、Yacc文件或Lex文件创建Lint库的隐含规则。

<n>.ln(lint生成的文件)的依赖文件被自动推导为n.c,其生成命令是:$(LINT)$(LINTFALGS)$(CPPFLAGS)-i。对于<n>.y<n>.l也是同样的规则。

隐含规则使用的变量

在隐含规则中的命令中,基本上都是使用了一些预先设置的变量。你可以在你的makefile中改变这些变量的值,或是在make的命令行中传入这些值,或是在你的环境变量中设置这些值,无论怎么样,只要设置了这些特定的变量,那么其就会对隐含规则起作用。当然,你也可以利用make的-R--no–builtin-variables参数来取消你所定义的变量对隐含规则的作用。

例如,第一条隐含规则——编译C程序的隐含规则的命令是$(CC)–c $(CFLAGS) $(CPPFLAGS)。Make默认的编译命令是cc,如果你把变量$(CC)重定义成gcc,把变量$(CFLAGS)重定义成-g,那么,隐含规则中的命令全部会以gcc–c-g $(CPPFLAGS)的样子来执行了。

我们可以把隐含规则中使用的变量分成两种:一种是命令相关的,如CC;一种是参数相的关,如CFLAGS。下面是所有隐含规则中会用到的变量:

1、关于命令的变量。
AR:函数库打包程序。默认命令是ar

AS:汇编语言编译程序。默认命令是as

CC:C语言编译程序。默认命令是cc

CXX:C++语言编译程序。默认命令是g++

CO:从RCS文件中扩展文件程序。默认命令是co

CPP:C程序的预处理器(输出是标准输出设备)。默认命令是$(CC)–E

FC:Fortran和Ratfor的编译器和预处理程序。默认命令是f77

GET:从SCCS文件中扩展文件的程序。默认命令是get

LEX:Lex方法分析器程序(针对于C或Ratfor)。默认命令是lex

PC:Pascal语言编译程序。默认命令是pc

YACC:Yacc文法分析器(针对于C程序)。默认命令是yacc

YACCR:Yacc文法分析器(针对于Ratfor程序)。默认命令是yacc–r

MAKEINFO:转换Texinfo源文件(.texi)到Info文件程序。默认命令是makeinfo

TEX:从TeX源文件创建TeXDVI文件的程序。默认命令是tex

TEXI2DVI:从Texinfo源文件创建军TeXDVI文件的程序。默认命令是texi2dvi

WEAVE:转换Web到TeX的程序。默认命令是weave

CWEAVE:转换CWeb到TeX的程序。默认命令是cweave

TANGLE:转换Web到Pascal语言的程序。默认命令是tangle

CTANGLE:转换CWeb到C。默认命令是ctangle

RM:删除文件命令。默认命令是rm –f

2、关于命令参数的变量
下面的这些变量都是相关上面的命令的参数。如果没有指明其默认值,那么其默认值都是空。

ARFLAGS:函数库打包程序AR命令的参数。默认值是rv

ASFLAGS:汇编语言编译器参数。(当明显地调用.s.S文件时)。

CFLAGS:C语言编译器参数。

CXXFLAGS:C++语言编译器参数。

COFLAGS:RCS命令参数。

CPPFLAGS:C预处理器参数。(C和Fortran编译器也会用到)。

FFLAGS:Fortran语言编译器参数。

GFLAGS:SCCSget程序参数。

LDFLAGS:链接器参数。(如:ld

LFLAGS:Lex文法分析器参数。

PFLAGS:Pascal语言编译器参数。

RFLAGS:Ratfor程序的Fortran编译器参数。

YFLAGS:Yacc文法分析器参数。

隐含规则链

有些时候,一个目标可能被一系列的隐含规则所作用。例如,一个[.o]的文件生成,可能会是先被Yacc的[.y]文件先成[.c],然后再被C的编译器生成。我们把这一系列的隐含规则叫做隐含规则链

在上面的例子中,如果文件[.c]存在,那么就直接调用C的编译器的隐含规则,如果没有[.c]文件,但有一个[.y]文件,那么Yacc的隐含规则会被调用,生成[.c]文件,然后,再调用C编译的隐含规则最终由[.c]生成[.o]文件,达到目标。

我们把这种[.c]的文件(或是目标),叫做中间目标。不管怎么样,make会努力自动推导生成目标的一切方法,不管中间目标有多少,其都会执着地把所有的隐含规则和你书写的规则全部合起来分析,努力达到目标,所以,有些时候,可能会让你觉得奇怪,怎么我的目标会这样生成?怎么我的makefile发疯了?

在默认情况下,对于中间目标,它和一般的目标有两个地方所不同:第一个不同是除非中间的目标不存在,才会引发中间规则。第二个不同的是,只要目标成功产生,那么,产生最终目标过程中,所产生的中间目标文件会被以rm-f删除。

通常,一个被makefile指定成目标或是依赖目标的文件不能被当作中介。然而,你可以明显地说明一个文件或是目标是中介目标,你可以使用伪目标.INTERMEDIATE来强制声明。(如:.INTERMEDIATE:mid)

你也可以阻止make自动删除中间目标,要做到这一点,你可以使用伪目标.SECONDARY来强制声明(如:.SECONDARY:sec)。你还可以把你的目标,以模式的方式来指定(如:%.o)成伪目标.PRECIOUS的依赖目标,以保存被隐含规则所生成的中间文件。

隐含规则链中,禁止同一个目标出现两次或两次以上,这样一来,就可防止在make自动推导时出现无限递归的情况。

Make会优化一些特殊的隐含规则,而不生成中间文件。如,从文件foo.c生成目标程序foo,按道理,make会编译生成中间文件foo.o,然后链接成foo,但在实际情况下,这一动作可以被一条cc的命令完成(cc–o foo foo.c),于是优化过的规则就不会生成中间文件。

定义模式规则

你可以使用模式规则来定义一个隐含规则。一个模式规则就好像一个一般的规则,只是在规则中,目标的定义需要有”%”字符。”%”的意思是表示一个或多个任意字符。在依赖目标中同样可以使用”%”,只是依赖目标中的”%”的取值,取决于其目标。

有一点需要注意的是,”%”的展开发生在变量和函数的展开之后,变量和函数的展开发生在make载入Makefile时,而模式规则中的”%”则发生在运行时。

1、模式规则介绍
模式规则中,至少在规则的目标定义中要包含”%”,否则,就是一般的规则。目标中的”%”定义表示对文件名的匹配,”%”表示长度任意的非空字符串。例如:”%.c”表示以”.c”结尾的文件名(文件名的长度至少为3),而”s.%.c”则表示以”s.”开头,”.c”结尾的文件名(文件名的长度至少为5)。

如果”%”定义在目标中,那么,目标中的”%”的值决定了依赖目标中的”%”的值,也就是说,目标中的模式的”%”决定了依赖目标中”%”的样子。例如有一个模式规则如下:

1
%.o:%.c;<command......>

其含义是,指出了怎么从所有的[.c]文件生成相应的[.o]文件的规则。如果要生成的目标是”a.o b.o”,那么”%c”就是”a.c b.c”。

一旦依赖目标中的”%”模式被确定,那么,make会被要求去匹配当前目录下所有的文件名,一旦找到,make就会规则下的命令,所以,在模式规则中,目标可能会是多个的,如果有模式匹配出多个目标,make就会产生所有的模式目标,此时,make关心的是依赖的文件名和生成目标的命令这两件事。

2、模式规则示例
下面这个例子表示了,把所有的[.c]文件都编译成[.o]文件.

1
2
3
%.o:%.c

$(CC)-c $(CFLAGS) $(CPPFLAGS)$< -o $@

其中,”$@”表示所有的目标的挨个值,”$<”表示了所有依赖目标的挨个值。这些奇怪的变量我们叫”自动化变量”,后面会详细讲述。

下面的这个例子中有两个目标是模式的:

1
2
3
%.tab.c %.tab.h:%.y

bison -d $<

这条规则告诉make把所有的[.y]文件都以”bison -d .y”执行,然后生成”.tab.c”和”.tab.h”文件。(其中,”“表示一个任意字符串)。如果我们的执行程序”foo”依赖于文件”parse.tab.o”和”scan.o”,并且文件”scan.o”依赖于文件”parse.tab.h”,如果”parse.y”文件被更新了,那么根据上述的规则,”bison -d parse.y”就会被执行一次,于是,”parse.tab.o”和”scan.o”的依赖文件就齐了。(假设,”parse.tab.o”由”parse.tab.c”生成,和”scan.o”由”scan.c”生成,而”foo”由”parse.tab.o”和”scan.o”链接生成,而且foo和其[.o]文件的依赖关系也写好,那么,所有的目标都会得到满足)

3、自动化变量
在上述的模式规则中,目标和依赖文件都是一系例的文件,那么我们如何书写一个命令来完成从不同的依赖文件生成相应的目标?因为在每一次的对模式规则的解析时,都会是不同的目标和依赖文件。

自动化变量就是完成这个功能的。在前面,我们已经对自动化变量有所提涉,相信你看到这里已对它有一个感性认识了。所谓自动化变量,就是这种变量会把模式中所定义的一系列的文件自动地挨个取出,直至所有的符合模式的文件都取完了。这种自动化变量只应出现在规则的命令中。

下面是所有的自动化变量及其说明:

$@表示规则中的目标文件集。在模式规则中,如果有多个目标,那么,”$@”就是匹配于目标中模式定义的集合。

$%仅当目标是函数库文件中,表示规则中的目标成员名。例如,如果一个目标是”foo.a(bar.o)”,那么,”$%”就是”bar.o”,”$@”就是”foo.a”。如果目标不是函数库文件(Unix下是[.a],Windows下是[.lib]),那么,其值为空。

$<依赖目标中的第一个目标名字。如果依赖目标是以模式(即”%”)定义的,那么”$<”将是符合模式的一系列的文件集。注意,其是一个一个取出来的。

$?所有比目标新的依赖目标的集合。以空格分隔。

$^所有的依赖目标的集合。以空格分隔。如果在依赖目标中有多个重复的,那个这个变量会去除重复的依赖目标,只保留一份。

$+这个变量很像”$^”,也是所有依赖目标的集合。只是它不去除重复的依赖目标。

$*这个变量表示目标模式中”%”及其之前的部分。如果目标是”dir/a.foo.b”,并且目标的模式是”a.%.b”,那么,$*的值就是”dir/a.foo”。这个变量对于构造有关联的文件名是比较有较。如果目标中没有模式的定义,那么$*也就不能被推导出,但是,如果目标文件的后缀是make所识别的,那么$*就是除了后缀的那一部分。例如:如果目标是”foo.c”,因为”.c”是make所能识别的后缀名,所以,$*的值就是”foo”。这个特性是GNUmake的,很有可能不兼容于其它版本的make,所以,你应该尽量避免使用$*,除非是在隐含规则或是静态模式中。如果目标中的后缀是make所不能识别的,那么$*就是空值。

当你希望只对更新过的依赖文件进行操作时,$?在显式规则中很有用,例如,假设有一个函数库文件叫”lib”,其由其它几个object文件更新。那么把object文件打包的比较有效率的Makefile规则是:

1
2
3
lib:foo.o bar.o lose.owin.o

ar r lib $?

在上述所列出来的自动量变量中。四个变量($@$<$%$*)在扩展时只会有一个文件,而另三个的值是一个文件列表。这七个自动化变量还可以取得文件的目录名或是在当前目录下的符合模式的文件名,只需要搭配上”D”或”F”字样。这是GNUmake中老版本的特性,在新版本中,我们使用函数”dir”或”notdir”就可以做到了。”D”的含义就是Directory,就是目录,”F”的含义就是File,就是文件。

下面是对于上面的七个变量分别加上”D”或是”F”的含义:

$(@D)表示$@的目录部分(不以斜杠作为结尾),如果$@值是”dir/foo.o”,那么$(@D)就是”dir”,而如果$@中没有包含斜杠的话,其值就是”.”(当前目录)。

$(@F)表示$@的文件部分,如果$@值是”dir/foo.o”,那么$(@F)就是”foo.o”,$(@F)相当于函数$(notdir$@)

$(*D)$(*F)和上面所述的同理,也是取文件的目录部分和文件部分。对于上面的那个例子,$(*D)返回”dir”,而$(*F)返回”foo”

$(%D)$(%F)分别表示了函数包文件成员的目录部分和文件部分。这对于形同”archive(member)”形式的目标中的”member”中包含了不同的目录很有用。

$(<D)$(<F)分别表示依赖文件的目录部分和文件部分。

$(^D)$(^F)分别表示所有依赖文件的目录部分和文件部分。(无相同的)

$(+D)$(+F)分别表示所有依赖文件的目录部分和文件部分。(可以有相同的)

$(?D)$(?F)分别表示被更新的依赖文件的目录部分和文件部分。

最后想提醒一下的是,对于$<,为了避免产生不必要的麻烦,我们最好给$后面的那个特定字符都加上圆括号,比如,$(<)就要比$<要好一些。

还得要注意的是,这些变量只使用在规则的命令中,而且一般都是”显式规则”和”静态模式规则”(参见前面”书写规则”一章)。其在隐含规则中并没有意义。

4、模式的匹配
一般来说,一个目标的模式有一个有前缀或是后缀的”%”,或是没有前后缀,直接就是一个”%”。因为”%”代表一个或多个字符,所以在定义好了的模式中,我们把”%”所匹配的内容叫做”茎”,例如”%.c”所匹配的文件”test.c”中”test”就是”茎”。因为在目标和依赖目标中同时有”%”时,依赖目标的”茎”会传给目标,当做目标中的”茎”。

当一个模式匹配包含有斜杠(实际也不经常包含)的文件时,那么在进行模式匹配时,目录部分会首先被移开,然后进行匹配,成功后,再把目录加回去。在进行”茎”的传递时,我们需要知道这个步骤。例如有一个模式”e%t”,文件”src/eat”匹配于该模式,于是”src/a”就是其”茎”,如果这个模式定义在依赖目标中,而被依赖于这个模式的目标中又有个模式”c%r”,那么,目标就是”src/car”。(”茎”被传递)

5、重载内建隐含规则
你可以重载内建的隐含规则(或是定义一个全新的),例如你可以重新构造和内建隐含规则不同的命令,如:

1
2
3
%.o:%.c

$(CC) -c $(CPPFLAGS) $(CFLAGS)-D $(date)

你可以取消内建的隐含规则,只要不在后面写命令就行。如:
1
%.o:%.s

同样,你也可以重新定义一个全新的隐含规则,其在隐含规则中的位置取决于你在哪里写下这个规则。朝前的位置就靠前。

6、老式风格的”后缀规则”
后缀规则是一个比较老式的定义隐含规则的方法。后缀规则会被模式规则逐步地取代。因为模式规则更强更清晰。为了和老版本的Makefile兼容,GNUmake同样兼容于这些东西。后缀规则有两种方式:”双后缀”和”单后缀”。

双后缀规则定义了一对后缀:目标文件的后缀和依赖目标(源文件)的后缀。如”.c.o”相当于”%o:%c”。单后缀规则只定义一个后缀,也就是源文件的后缀。如”.c”相当于”%:%.c”。

后缀规则中所定义的后缀应该是make所认识的,如果一个后缀是make所认识的,那么这个规则就是单后缀规则,而如果两个连在一起的后缀都被make所认识,那就是双后缀规则。例如:”.c”和”.o”都是make所知道。因而,如果你定义了一个规则是”.c.o”那么其就是双后缀规则,意义就是”.c”是源文件的后缀,”.o”是目标文件的后缀。如下示例:

1
2
3
.c.o:

$(CC) -c $(CFLAGS) $(CPPFLAGS)-o $@ $<

后缀规则不允许任何的依赖文件,如果有依赖文件的话,那就不是后缀规则,那些后缀统统被认为是文件名,如:
1
2
3
.c.o:foo.h

$(CC) -c $(CFLAGS) $(CPPFLAGS)-o $@ $<

这个例子,就是说,文件”.c.o”依赖于文件”foo.h”,而不是我们想要的这样:
1
2
3
%.o:%.c foo.h

$(CC) -c $(CFLAGS) $(CPPFLAGS)-o $@ $<

后缀规则中,如果没有命令,那是毫无意义的。因为他也不会移去内建的隐含规则。

而要让make知道一些特定的后缀,我们可以使用伪目标”.SUFFIXES”来定义或是删除,如:

1
.SUFFIXES:.hack.win

把后缀.hack和.win加入后缀列表中的末尾。

.SUFFIXES:#删除默认的后缀

.SUFFIXES:.c .o .h#定义自己的后缀

先清楚默认后缀,后定义自己的后缀列表。

make的参数”-r”或”-no-builtin-rules”也会使用得默认的后缀列表为空。而变量”SUFFIXE”被用来定义默认的后缀列表,你可以用”.SUFFIXES”来改变后缀列表,但请不要改变变量”SUFFIXE”的值。

7、隐含规则搜索算法
比如我们有一个目标叫T。下面是搜索目标T的规则的算法。请注意,在下面,我们没有提到后缀规则,原因是,所有的后缀规则在Makefile被载入内存时,会被转换成模式规则。如果目标是”archive(member)”的函数库文件模式,那么这个算法会被运行两次,第一次是找目标T,如果没有找到的话,那么进入第二次,第二次会把”member”当作T来搜索。

1、把T的目录部分分离出来。叫D,而剩余部分叫N。(如:如果T是”src/foo.o”,那么,D就是”src/“,N就是”foo.o”)

2、创建所有匹配于T或是N的模式规则列表。

3、如果在模式规则列表中有匹配所有文件的模式,如”%”,那么从列表中移除其它的模式。

4、移除列表中没有命令的规则。

5、对于第一个在列表中的模式规则:

1)推导其”茎”S,S应该是T或是N匹配于模式中”%”非空的部分。

2)计算依赖文件。把依赖文件中的”%”都替换成”茎”S。如果目标模式中没有包含斜框字符,而把D加在第一个依赖文件的开头。

3)测试是否所有的依赖文件都存在或是理当存在。(如果有一个文件被定义成另外一个规则的目标文件,或者是一个显式规则的依赖文件,那么这个文件就叫”理当存在”)

4)如果所有的依赖文件存在或是理当存在,或是就没有依赖文件。那么这条规则将被采用,退出该算法。

6、如果经过第5步,没有模式规则被找到,那么就做更进一步的搜索。对于存在于列表中的第一个模式规则:

1)如果规则是终止规则,那就忽略它,继续下一条模式规则。

2)计算依赖文件。(同第5步)

3)测试所有的依赖文件是否存在或是理当存在。

4)对于不存在的依赖文件,递归调用这个算法查找他是否可以被隐含规则找到。

5)如果所有的依赖文件存在或是理当存在,或是就根本没有依赖文件。那么这条规则被采用,退出该算法。

7、如果没有隐含规则可以使用,查看”.DEFAULT”规则,如果有,采用,把”.DEFAULT”的命令给T使用。

一旦规则被找到,就会执行其相当的命令,而此时,我们的自动化变量的值才会生成。

使用make更新函数库文件

函数库文件也就是对Object文件(程序编译的中间文件)的打包文件。在Unix下,一般是由命令”ar”来完成打包工作。

函数库文件的成员

一个函数库文件由多个文件组成。你可以以如下格式指定函数库文件及其组成:

1
archive(member)

这个不是一个命令,而一个目标和依赖的定义。一般来说,这种用法基本上就是为了”ar”命令来服务的。如:
1
2
3
foolib(hack.o):hack.o

ar cr foolib hack.o

如果要指定多个member,那就以空格分开,如:
1
foolib(hack.o kludge.o)

其等价于:
1
foolib(hack.o) foolib(kludge.o)

你还可以使用Shell的文件通配符来定义,如:
1
foolib(*.o)

函数库成员的隐含规则

当make搜索一个目标的隐含规则时,一个特殊的特性是,如果这个目标是”a(m)”形式的,其会把目标变成”(m)”。于是,如果我们的成员是”%.o”的模式定义,并且如果我们使用”make foo.a(bar.o)”的形式调用Makefile时,隐含规则会去找”bar.o”的规则,如果没有定义bar.o的规则,那么内建隐含规则生效,make会去找bar.c文件来生成bar.o,如果找得到的话,make执行的命令大致如下:

1
2
3
4
5
cc-c bar.c -o bar.o

ar r foo.abar.o

rm -f bar.o

还有一个变量要注意的是”$%”,这是专属函数库文件的自动化变量,有关其说明请参见”自动化变量”一节。

函数库文件的后缀规则

你可以使用”后缀规则”和”隐含规则”来生成函数库打包文件,如:

1
2
3
4
5
6
7
.c.a:

$(CC) $(CFLAGS) $(CPPFLAGS)-c $< -o $*.o

$(AR) r $@ $*.o

$(RM) $*.o

其等效于:
1
2
3
4
5
6
7
(%.o):%.c

$(CC) $(CFLAGS) $(CPPFLAGS)-c $< -o $*.o

$(AR) r $@ $*.o

$(RM) $*.o

注意事项

在进行函数库打包文件生成时,请小心使用make的并行机制(”-j”参数)。如果多个ar命令在同一时间运行在同一个函数库打包文件上,就很有可以损坏这个函数库文件。所以,在make未来的版本中,应该提供一种机制来避免并行操作发生在函数打包文件上。但就目前而言,你还是应该不要尽量不要使用”-j”参数。

后序

终于到写结束语的时候了,以上基本上就是GNUmake的Makefile的所有细节了。其它的产商的make基本上也就是这样的,无论什么样的make,都是以文件的依赖性为基础的,其基本是都是遵循一个标准的。这篇文档中80%的技术细节都适用于任何的make,我猜测”函数”那一章的内容可能不是其它make所支持的,而隐含规则方面,我想不同的make会有不同的实现,我没有精力来查看GNU的make和VC的nmake、BCB的make,或是别的UNIX下的make有些什么样的差别,一是时间精力不够,二是因为我基本上都是在Unix下使用make,以前在SCOUnix和IBM的AIX,现在在Linux、Solaris、HP-UX、AIX和Alpha下使用,Linux和Solaris下更多一点。不过,我可以肯定的是,在Unix下的make,无论是哪种平台,几乎都使用了Richard Stallman开发的make和cc/gcc的编译器,而且,基本上都是GNU的make(公司里所有的UNIX机器上都被装上了GNU的东西,所以,使用GNU的程序也就多了一些)。GNU的东西还是很不错的,特别是使用得深了以后,越来越觉得GNU的软件的强大,也越来越觉得GNU的在操作系统中(主要是Unix,甚至Windows)”杀伤力”。

对于上述所有的make的细节,我们不但可以利用make这个工具来编译我们的程序,还可以利用make来完成其它的工作,因为规则中的命令可以是任何Shell之下的命令,所以,在Unix下,你不一定只是使用程序语言的编译器,你还可以在Makefile中书写其它的命令,如:tar、awk、mail、sed、cvs、compress、ls、rm、yacc、rpm、ftp……等等,等等,来完成诸如”程序打包”、”程序备份”、”制作程序安装包”、”提交代码”、”使用程序模板”、”合并文件”等等五花八门的功能,文件操作,文件管理,编程开发设计,或是其它一些异想天开的东西。比如,以前在书写银行交易程序时,由于银行的交易程序基本一样,就见到有人书写了一些交易的通用程序模板,在该模板中把一些网络通讯、数据库操作的、业务操作共性的东西写在一个文件中,在这些文件中用些诸如”@@@N、###N”奇怪字串标注一些位置,然后书写交易时,只需按照一种特定的规则书写特定的处理,最后在make时,使用awk和sed,把模板中的”@@@N、###N”等字串替代成特定的程序,形成C文件,然后再编译。这个动作很像数据库的”扩展C”语言(即在C语言中用”EXEC SQL”的样子执行SQL语句,在用cc/gcc编译之前,需要使用”扩展C”的翻译程序,如cpre,把其翻译成标准C)。如果

你在使用make时有一些更为绝妙的方法,请记得告诉我啊。

回头看看整篇文档,不觉记起几年前刚刚开始在Unix下做开发的时候,有人问我会不会写Makefile时,我两眼发直,根本不知道在说什么。一开始看到别人在vi中写完程序后输入”!make”时,还以为是vi的功能,后来才知道有一个Makefile在作怪,于是上网查啊查,那时又不愿意看英文,发现就根本没有中文的文档介绍Makefile,只得看别人写的Makefile,自己瞎碰瞎搞才积累了一点知识,但在很多地方完全是知其然不知所以然。后来开始从事UNIX下产品软件的开发,看到一个400人年,近200万行代码的大工程,发现要编译这样一个庞然大物,如果没有Makefile,那会是多么恐怖的一样事啊。于是横下心来,狠命地读了一堆英文文档,才觉得对其掌握了。但发现目前网上对Makefile介绍的文章还是少得那么的可怜,所以想写这样一篇文章,共享给大家,希望能对各位有所帮助。

现在我终于写完了,看了看文件的创建时间,这篇技术文档也写了两个多月了。发现,自己知道是一回事,要写下来,跟别人讲述又是另外一回事,而且,现在越来越没有时间专研技术细节,所以在写作时,发现在阐述一些细节问题时很难做到严谨和精练,而且对先讲什么后讲什么不是很清楚,所以,还是参考了一些国外站点上的资料和题纲,以及一些技术书籍的语言风格,才得以完成。整篇文档的提纲是基于GNU的Makefile技术手册的提纲来书写的,并结合了自己的工作经验,以及自己的学习历程。因为从来没有写过这么长,这么细的文档,所以一定会有很多地方存在表达问题,语言歧义或是错误。因些,我迫切地得等待各位给我指证和建议,以及任何的反馈。

我欢迎任何形式的交流,无论是讨论技术还是管理,或是其它海阔天空的东西。除了政治和娱乐新闻我不关心,其它只要积极向上的东西我都欢迎!

最最后,我还想介绍一下make程序的设计开发者。

首当其冲的是:Richard Stallman

开源软件的领袖和先驱,从来没有领过一天工资,从来没有使用过Windows操作系统。对于他的事迹和他的软件以及他的思想,我无需说过多的话,相信大家对这个人并不比我陌生,这是他的主页:http://www.stallman.org/。

第二位是:Roland McGrath

个人主页是:http://www.frob.com/~roland/
下面是他的一些事迹:

1)合作编写了并维护GNUmake。

2)和ThomasBushnell一同编写了GNUHurd。

3)编写并维护着GNUClibrary。

4)合作编写并维护着部分的GNUEmacs。

在此,向这两位开源项目的斗士致以最真切的敬意

Leetcode201. Bitwise AND of Numbers Range

Given two integers left and right that represent the range [left, right], return the bitwise AND of all numbers in this range, inclusive.

Example 1:

1
2
Input: left = 5, right = 7
Output: 4

Example 2:

1
2
Input: left = 0, right = 0
Output: 0

Example 3:

1
2
Input: left = 1, right = 2147483647
Output: 0

我们先从题目中给的例子来分析,[5, 7]里共有三个数字,分别写出它们的二进制为:

101  110  111

相与后的结果为100,仔细观察我们可以得出,最后的数是该数字范围内所有的数的左边共同的部分,如果上面那个例子不太明显,我们再来看一个范围[26, 30],它们的二进制如下:

11010  11011  11100  11101  11110

发现了规律后,我们只要写代码找到左边公共的部分即可,我们可以从建立一个32位都是1的mask,然后每次向左移一位,比较m和n是否相同,不同再继续左移一位,直至相同,然后把m和mask相与就是最终结果,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Solution {
public:
int rangeBitwiseAnd(int left, int right) {
int i = 0;
while(left > 0 && right > 0) {
if (left == right)
break;
left >>= 1;
right >>= 1;
i ++;
}
return left << i;
}
};

Leetcode202. Happy Number

Write an algorithm to determine if a number n is “happy”.

A happy number is a number defined by the following process: Starting with any positive integer, replace the number by the sum of the squares of its digits, and repeat the process until the number equals 1 (where it will stay), or it loops endlessly in a cycle which does not include 1. Those numbers for which this process ends in 1 are happy numbers.

Return True if n is a happy number, and False if not.

Example:

1
2
3
4
5
6
7
Input: 19
Output: true
Explanation:
12 + 92 = 82
82 + 22 = 68
62 + 82 = 100
12 + 02 + 02 = 1

简单直白的做法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Solution {
public:
bool isHappy(int n) {
int cycle = 100, sum;
vector<int> nums;
while(cycle--) {
int temp = n;
nums.clear();
while(temp) {
nums.push_back(temp%10);
temp /= 10;
}
sum = 0;
for(int i : nums)
sum += i*i;
if(sum == 1)
return true;
n = sum;
}
return false;
}
};

对于一个数n,如果n不是Happy Number,那么在求n各数位平方和以及求在n之后的每个数的各数位平方和的过程中,一定会产生循环,利用这个性质:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Solution {
public:
bool isHappy(int n) {
map<int,int> thash;
while(n && !thash[n]){
thash[n] = n;
int temp = 0, low;
while(n) {
low = n % 10;
temp += low * low;
n /= 10;
}
n = temp;
}
if(n == 1){
return true;
}
return false;
}
};

Leetcode203. Remove Linked List Elements

Remove all elements from a linked list of integers that have value val.

Example:

1
2
Input:  1->2->6->3->4->5->6, val = 6
Output: 1->2->3->4->5

删除列表中对应val的节点
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Solution {
public:
ListNode* removeElements(ListNode* head, int val) {
ListNode *ptr = new ListNode(-1);
ListNode *prev, *cur;
ptr->next = head;
cur = head;
prev = ptr;
while(cur != NULL) {
if(cur->val == val)
prev->next = cur->next;
else
prev = cur;
cur = cur->next;
}
return ptr->next;
}
};

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class Solution {
public:
ListNode* removeElements(ListNode* head, int val)
{
vector<int> v;
ListNode* a = head;
while(a != NULL) {
v.push_back(a->val);
a = a->next;
}
vector<int> y;
int s = v.size();
for(int i = 0; i < s; i ++) {
if(v[i] != val) {
y.push_back(v[i]);
}
}

reverse(y.begin(),y.end());
int n = y.size();
ListNode* z = new ListNode(0);
ListNode* p = z;
for(int i = 0; i < n; i ++) {
p->next = new ListNode(y.back());
y.pop_back();
p = p->next;
}
return z->next;
}
};

Leetcode204. Count Primes

Count the number of prime numbers less than a non-negative number, n.

Example:

1
2
3
Input: 10
Output: 4
Explanation: There are 4 prime numbers less than 10, they are 2, 3, 5, 7.

判断一定范围内有几个合数,下边这个简单的做法会超时
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Solution {
public:

bool isprimes(int n) {
for(int i = 2; i <= n/2; i ++){
if(n % i == 0)
return false;
}
return true;
}

int countPrimes(int n) {
int sum = 0;
for(int i = 2; i < n; i ++)
if(isprimes(i))
sum ++;
return sum;
}
};

所以要用其他的方法,比如素数筛
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Solution {
public:

int countPrimes(int n) {
int sum = 0;
if(n < 2)
return 0;
int nn = sqrt(n);
bool prime[n];
for(int i = 0; i < n; i ++)
prime[i] = true;
prime[0] = prime[1] = false;
for(int i = 2; i <= nn; i ++) {
for(int j = i*2; j < n; j += i) {
prime[j] = false;
}
}
for(int i = 2; i < n; i ++) {
if(prime[i])
sum ++;
}
return sum;
}
};

Leetcode205. Isomorphic Strings

Given two strings s and t, determine if they are isomorphic.

Two strings are isomorphic if the characters in s can be replaced to get t.

All occurrences of a character must be replaced with another character while preserving the order of characters. No two characters may map to the same character but a character may map to itself.

Example 1:

1
2
Input: s = "egg", t = "add"
Output: true

Example 2:
1
2
Input: s = "foo", t = "bar"
Output: false

Example 3:
1
2
Input: s = "paper", t = "title"
Output: true

这道题让我们求同构字符串,就是说原字符串中的每个字符可由另外一个字符替代,可以被其本身替代,相同的字符一定要被同一个字符替代,且一个字符不能被多个字符替代,即不能出现一对多的映射。根据一对一映射的特点,需要用两个 HashMap 分别来记录原字符串和目标字符串中字符出现情况,由于 ASCII 码只有 256 个字符,所以可以用一个 256 大小的数组来代替 HashMap,并初始化为0,遍历原字符串,分别从源字符串和目标字符串取出一个字符,然后分别在两个数组中查找其值,若不相等,则返回 false,若相等,将其值更新为 i + 1,因为默认的值是0,所以更新值为 i + 1,这样当 i=0 时,则映射为1,如果不加1的话,那么就无法区分是否更新了。
1
2
3
4
5
6
7
8
9
10
11
12
class Solution {
public:
bool isIsomorphic(string s, string t) {
int m1[256] = {0}, m2[256] = {0}, n = s.size();
for (int i = 0; i < n; ++i) {
if (m1[s[i]] != m2[t[i]]) return false;
m1[s[i]] = i + 1;
m2[t[i]] = i + 1;
}
return true;
}
};

另一种使用两个unorder_map
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Solution {
public:
bool isIsomorphic(string s, string t) {
int ssize = s.size(), tsize = t.size();
if(ssize != tsize)
return false;
map<char, char> mp, mp2;
for(int i = 0; i < ssize; i ++) {
if(mp.find(s[i]) == mp.end())
mp[s[i]] = t[i];
else if(mp[s[i]] != t[i])
return false;
if(mp2.find(t[i]) == mp2.end())
mp2[t[i]] = s[i];
else if(mp2[t[i]] != s[i])
return false;
}
return true;
}
};

Leetcode206. Reverse Linked List

Reverse a singly linked list.

Example:

Input: 1->2->3->4->5->NULL
Output: 5->4->3->2->1->NULL

假设存在链表 1 -> 2 -> 3 -> NULL,我们想要把它改成 NULL <- 1 <- 2 <- 3。

在遍历列表时,将当前节点的 next 指针改为指向前一个元素。由于节点没有引用其上一个节点,因此必须事先存储其前一个元素。在更改引用之前,还需要另一个指针来存储下一个节点。不要忘记在最后返回新的头引用!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Solution {
public:
ListNode* reverseList(ListNode* head) {
ListNode* prev,*next,*curr;
curr=head;
prev = NULL;
while(curr!=NULL){
next = curr->next;
curr->next = prev;
prev = curr;
curr = next;
}
return prev;
}
};

Leetcode207. Course Schedule

There are a total of numCourses courses you have to take, labeled from 0 to numCourses-1.

Some courses may have prerequisites, for example to take course 0 you have to first take course 1, which is expressed as a pair: [0,1]

Given the total number of courses and a list of prerequisite pairs, is it possible for you to finish all courses?

Example 1:

1
2
3
4
Input: numCourses = 2, prerequisites = [[1,0]]
Output: true
Explanation: There are a total of 2 courses to take.
To take course 1 you should have finished course 0. So it is possible.

Example 2:
1
2
3
4
5
Input: numCourses = 2, prerequisites = [[1,0],[0,1]]
Output: false
Explanation: There are a total of 2 courses to take.
To take course 1 you should have finished course 0, and to take course 0 you should
also have finished course 1. So it is impossible.

Constraints:

  • The input prerequisites is a graph represented by a list of edges, not adjacency matrices. Read more about how a graph is represented.
  • You may assume that there are no duplicate edges in the input prerequisites.
  • 1 <= numCourses <= 10^5

定义二维数组 graph 来表示这个有向图,一维数组 in 来表示每个顶点的入度。开始先根据输入来建立这个有向图,并将入度数组也初始化好。然后定义一个 queue 变量,将所有入度为0的点放入队列中,然后开始遍历队列,从 graph 里遍历其连接的点,每到达一个新节点,将其入度减一,如果此时该点入度为0,则放入队列末尾。直到遍历完队列中所有的值,若此时还有节点的入度不为0,则说明环存在,返回 false,反之则返回 true。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class Solution {
public:
bool canFinish(int numCourses, vector<vector<int>>& prerequisites) {
vector<vector<int>> graph(numCourses, vector<int>(numCourses, 0));
int in[numCourses];
for(int i = 0; i < numCourses; i ++)
in[i] = 0;
for(auto i : prerequisites) {
graph[i[1]][i[0]] = 1;
in[i[0]] ++;
}
queue<int> q;
for(int i = 0; i < numCourses; i ++)
if(in[i] == 0)
q.push(i);
while(!q.empty()) {
int temp = q.front();
q.pop();
for(int i = 0; i < numCourses; i ++) {
if(graph[temp][i] == 1) {
in[i]--;
if(in[i] == 0)
q.push(i);
}
}
}
for(int i = 0; i < numCourses; i ++)
if(in[i] != 0)
return false;
return true;
}
};

Leetcode208. Implement Trie (Prefix Tree)

Implement a trie with insert, search, and startsWith methods.

Example:

1
2
3
4
5
6
7
8
Trie trie = new Trie();

trie.insert("apple");
trie.search("apple"); // returns true
trie.search("app"); // returns false
trie.startsWith("app"); // returns true
trie.insert("app");
trie.search("app"); // returns true

Note:

You may assume that all inputs are consist of lowercase letters a-z.
All inputs are guaranteed to be non-empty strings.

实现一个字典树即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
class Trie {
private:
struct Node{
Node *next[26];
bool isleaf;
Node(){
for(int i=0;i<26;i++)
next[i]=NULL;
isleaf=false;
}
};
Node* head;

public:
/** Initialize your data structure here. */
Trie() {
head = new Node();
}

/** Inserts a word into the trie. */
void insert(string word) {
Node* current = head;
for(int i=0;i<word.size();i++){
int index = word[i]-'a';
if(current->next[index]==NULL){
current->next[index] = new Node();
}
current = current->next[index];
}
current->isleaf = true;
}

/** Returns if the word is in the trie. */
bool search(string word) {
Node* current = head;
for(int i=0;i<word.size();i++){
int index = word[i]-'a';
if(current->next[index]==NULL)
return false;
else
current = current->next[index];
}
return current->isleaf;
}

/** Returns if there is any word in the trie that starts with the given prefix. */
bool startsWith(string prefix) {
Node* current = head;
for(int i=0;i<prefix.size();i++){
int index = prefix[i]-'a';
if(current->next[index]==NULL)
return false;
else
current = current->next[index];
}
return true;
}
};

这个实现的内存占用有些高了,抄一下其他人的
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class TrieNode {

// R links to node children
private TrieNode[] links;

private final int R = 26;

private boolean isEnd;

public TrieNode() {
links = new TrieNode[R];
}

public boolean containsKey(char ch) {
return links[ch -'a'] != null;
}
public TrieNode get(char ch) {
return links[ch -'a'];
}
public void put(char ch, TrieNode node) {
links[ch -'a'] = node;
}
public void setEnd() {
isEnd = true;
}
public boolean isEnd() {
return isEnd;
}
}

Insertion of a key to a trie
We insert a key by searching into the trie. We start from the root and search a link, which corresponds to the first key character. There are two cases :

A link exists. Then we move down the tree following the link to the next child level. The algorithm continues with searching for the next key character.
A link does not exist. Then we create a new node and link it with the parent’s link matching the current key character. We repeat this step until we encounter the last character of the key, then we mark the current node as an end node and the algorithm finishes.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Trie {
private TrieNode root;

public Trie() {
root = new TrieNode();
}

// Inserts a word into the trie.
public void insert(String word) {
TrieNode node = root;
for (int i = 0; i < word.length(); i++) {
char currentChar = word.charAt(i);
if (!node.containsKey(currentChar)) {
node.put(currentChar, new TrieNode());
}
node = node.get(currentChar);
}
node.setEnd();
}
}

Search for a key in a trie
Each key is represented in the trie as a path from the root to the internal node or leaf. We start from the root with the first key character. We examine the current node for a link corresponding to the key character. There are two cases :

A link exist. We move to the next node in the path following this link, and proceed searching for the next key character.

A link does not exist. If there are no available key characters and current node is marked as isEnd we return true. Otherwise there are possible two cases in each of them we return false :

There are key characters left, but it is impossible to follow the key path in the trie, and the key is missing.
No key characters left, but current node is not marked as isEnd. Therefore the search key is only a prefix of another key in the trie.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Trie {
...

// search a prefix or whole key in trie and
// returns the node where search ends
private TrieNode searchPrefix(String word) {
TrieNode node = root;
for (int i = 0; i < word.length(); i++) {
char curLetter = word.charAt(i);
if (node.containsKey(curLetter)) {
node = node.get(curLetter);
} else {
return null;
}
}
return node;
}

// Returns if the word is in the trie.
public boolean search(String word) {
TrieNode node = searchPrefix(word);
return node != null && node.isEnd();
}
}

Search for a key prefix in a trie
The approach is very similar to the one we used for searching a key in a trie. We traverse the trie from the root, till there are no characters left in key prefix or it is impossible to continue the path in the trie with the current key character. The only difference with the mentioned above search for a key algorithm is that when we come to an end of the key prefix, we always return true. We don’t need to consider the isEnd mark of the current trie node, because we are searching for a prefix of a key, not for a whole key.

1
2
3
4
5
6
7
8
9
10
class Trie {
...

// Returns if there is any word in the trie
// that starts with the given prefix.
public boolean startsWith(String prefix) {
TrieNode node = searchPrefix(prefix);
return node != null;
}
}

Leetcode209. Minimum Size Subarray Sum

Given an array of positive integers nums and a positive integer target, return the minimal length of a contiguous subarray [numsl, numsl+1, …, numsr-1, numsr] of which the sum is greater than or equal to target. If there is no such subarray, return 0 instead.

Example 1:

1
2
3
Input: target = 7, nums = [2,3,1,2,4,3]
Output: 2
Explanation: The subarray [4,3] has the minimal length under the problem constraint.

Example 2:

1
2
Input: target = 4, nums = [1,4,4]
Output: 1

Example 3:

1
2
Input: target = 11, nums = [1,1,1,1,1,1,1,1]
Output: 0

需要定义两个指针 left 和 right,分别记录子数组的左右的边界位置,然后让 right 向右移,直到子数组和大于等于给定值或者 right 达到数组末尾,此时更新最短距离,并且将 left 像右移一位,然后再 sum 中减去移去的值,然后重复上面的步骤,直到 right 到达末尾,且 left 到达临界位置,即要么到达边界,要么再往右移动,和就会小于给定值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Solution {
public:
int minSubArrayLen(int target, vector<int>& nums) {
int len = nums.size();
if (len == 0)
return 0;
int left = 0, right = 0;
int sum = 0, res = INT_MAX;

while(right < len) {
while(right < len && sum < target) {
sum += nums[right++];
}
while(sum >= target) {
res = min(res, right-left);
sum -= nums[left++];
}
}

return res == INT_MAX ? 0 : res;
}
};

Leetcode210. Course Schedule II

There are a total of numCourses courses you have to take, labeled from 0 to numCourses - 1. You are given an array prerequisites where prerequisites[i] = [ai, bi] indicates that you must take course bi first if you want to take course ai.

For example, the pair [0, 1], indicates that to take course 0 you have to first take course 1.
Return the ordering of courses you should take to finish all courses. If there are many valid answers, return any of them. If it is impossible to finish all courses, return an empty array.

Example 1:

1
2
3
Input: numCourses = 2, prerequisites = [[1,0]]
Output: [0,1]
Explanation: There are a total of 2 courses to take. To take course 1 you should have finished course 0. So the correct course order is [0,1].

Example 2:

1
2
3
4
Input: numCourses = 4, prerequisites = [[1,0],[2,0],[3,1],[3,2]]
Output: [0,2,1,3]
Explanation: There are a total of 4 courses to take. To take course 3 you should have finished both courses 1 and 2. Both courses 1 and 2 should be taken after you finished course 0.
So one correct course order is [0,1,2,3]. Another correct ordering is [0,2,1,3].

Example 3:

1
2
Input: numCourses = 1, prerequisites = []
Output: [0]

这道题我们得找出要上的课程的顺序,即有向图的拓扑排序 Topological Sort,从 queue 中每取出一个数组就将其存在结果中,最终若有向图中有环,则结果中元素的个数不等于总课程数,那我们将结果清空即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class Solution {
public:
vector<int> findOrder(int numCourses, vector<pair<int, int>>& prerequisites) {
vector<int> res;
vector<vector<int> > graph(numCourses, vector<int>(0));
vector<int> in(numCourses, 0);
for (auto &a : prerequisites) {
graph[a.second].push_back(a.first);
++in[a.first];
}
queue<int> q;
for (int i = 0; i < numCourses; ++i) {
if (in[i] == 0) q.push(i);
}
while (!q.empty()) {
int t = q.front();
res.push_back(t);
q.pop();
for (auto &a : graph[t]) {
--in[a];
if (in[a] == 0) q.push(a);
}
}
if (res.size() != numCourses) res.clear();
return res;
}
};

Leetcode212.Word Search II

Given an m x n board of characters and a list of strings words, return all words on the board.

Each word must be constructed from letters of sequentially adjacent cells, where adjacent cells are horizontally or vertically neighboring. The same letter cell may not be used more than once in a word.

Example 1:

1
2
Input: board = [["o","a","a","n"],["e","t","a","e"],["i","h","k","r"],["i","f","l","v"]], words = ["oath","pea","eat","rain"]
Output: ["eat","oath"]

Example 2:

1
2
Input: board = [["a","b"],["c","d"]], words = ["abcb"]
Output: []

Constraints:

  • m == board.length
  • n == board[i].length
  • 1 <= m, n <= 12
  • board[i][j] is a lowercase English letter.
  • 1 <= words.length <= 3 * 104
  • 1 <= words[i].length <= 10
  • words[i] consists of lowercase English letters.
  • All the strings of words are unique.

这道题是在之前那道 Word Search 的基础上做了些拓展,之前是给一个单词让判断是否存在,现在是给了一堆单词,让返回所有存在的单词,在这道题最开始更新的几个小时内,用 brute force 是可以通过 OJ 的,就是在之前那题的基础上多加一个 for 循环而已,但是后来出题者其实是想考察字典树的应用,所以加了一个超大的 test case,以至于 brute force 无法通过,强制我们必须要用字典树来求解。LeetCode 中有关字典树的题还有 Implement Trie (Prefix Tree) 和 Add and Search Word - Data structure design,那么我们在这题中只要实现字典树中的 insert 功能就行了,查找单词和前缀就没有必要了,然后 DFS 的思路跟之前那道 Word Search 基本相同,请参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
class Solution {
public:
struct TrieNode {
TrieNode *child[26];
string str;
};
struct Trie {
TrieNode *root;
Trie() : root(new TrieNode()) {}
void insert(string s) {
TrieNode *p = root;
for (auto &a : s) {
int i = a - 'a';
if (!p->child[i]) p->child[i] = new TrieNode();
p = p->child[i];
}
p->str = s;
}
};
vector<string> findWords(vector<vector<char>>& board, vector<string>& words) {
vector<string> res;
if (words.empty() || board.empty() || board[0].empty()) return res;
vector<vector<bool>> visit(board.size(), vector<bool>(board[0].size(), false));
Trie T;
for (auto &a : words) T.insert(a);
for (int i = 0; i < board.size(); ++i) {
for (int j = 0; j < board[i].size(); ++j) {
if (T.root->child[board[i][j] - 'a']) {
search(board, T.root->child[board[i][j] - 'a'], i, j, visit, res);
}
}
}
return res;
}
void search(vector<vector<char>>& board, TrieNode* p, int i, int j, vector<vector<bool>>& visit, vector<string>& res) {
if (!p->str.empty()) {
res.push_back(p->str);
p->str.clear();
}
int d[][2] = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}};
visit[i][j] = true;
for (auto &a : d) {
int nx = a[0] + i, ny = a[1] + j;
if (nx >= 0 && nx < board.size() && ny >= 0 && ny < board[0].size() && !visit[nx][ny] && p->child[board[nx][ny] - 'a']) {
search(board, p->child[board[nx][ny] - 'a'], nx, ny, visit, res);
}
}
visit[i][j] = false;
}
};

Leetcode213. House Robber II

You are a professional robber planning to rob houses along a street. Each house has a certain amount of money stashed. All houses at this place are arranged in a circle. That means the first house is the neighbor of the last one. Meanwhile, adjacent houses have a security system connected, and it will automatically contact the police if two adjacent houses were broken into on the same night.

Given an integer array nums representing the amount of money of each house, return the maximum amount of money you can rob tonight without alerting the police.

Example 1:

1
2
3
Input: nums = [2,3,2]
Output: 3
Explanation: You cannot rob house 1 (money = 2) and then rob house 3 (money = 2), because they are adjacent houses.

Example 2:

1
2
3
4
Input: nums = [1,2,3,1]
Output: 4
Explanation: Rob house 1 (money = 1) and then rob house 3 (money = 3).
Total amount you can rob = 1 + 3 = 4.

Example 3:

1
2
Input: nums = [0]
Output: 0

现在房子排成了一个圆圈,则如果抢了第一家,就不能抢最后一家,因为首尾相连了,所以第一家和最后一家只能抢其中的一家,或者都不抢,那这里变通一下,如果把第一家和最后一家分别去掉,各算一遍能抢的最大值,然后比较两个值取其中较大的一个即为所求。那只需参考之前的 House Robber 中的解题方法,然后调用两边取较大值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Solution {
public:
int rob(vector<int>& nums) {
if (nums.size() == 0)
return 0;
if (nums.size() == 1)
return nums[0];
return max(robb(nums, 0, nums.size()-1), robb(nums, 1, nums.size()));
}

int robb(vector<int>& nums, int l, int r) {
if (r-l <= 1)
return nums[l];
vector<int> dp(r, 0);

dp[l] = nums[l];
dp[l+1] = max(nums[l], nums[l+1]);
for (int i = l+2; i < r; i ++) {
dp[i] = max(dp[i-1], dp[i-2]+nums[i]);
}
return dp[r-1];
}
};

当然,我们也可以使用两个变量来代替整个 DP 数组,讲解与之前的帖子 House Robber 相同,分别维护两个变量 robEven 和 robOdd,顾名思义,robEven 就是要抢偶数位置的房子,robOdd 就是要抢奇数位置的房子。所以在遍历房子数组时,如果是偶数位置,那么 robEven 就要加上当前数字,然后和 robOdd 比较,取较大的来更新 robEven。这里就看出来了,robEven 组成的值并不是只由偶数位置的数字,只是当前要抢偶数位置而已。同理,当奇数位置时,robOdd 加上当前数字和 robEven 比较,取较大值来更新 robOdd,这种按奇偶分别来更新的方法,可以保证组成最大和的数字不相邻,最后别忘了在 robEven 和 robOdd 种取较大值返回,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Solution {
public:
int rob(vector<int>& nums) {
if (nums.size() <= 1) return nums.empty() ? 0 : nums[0];
return max(rob(nums, 0, nums.size() - 1), rob(nums, 1, nums.size()));
}
int rob(vector<int> &nums, int left, int right) {
int robEven = 0, robOdd = 0;
for (int i = left; i < right; ++i) {
if (i % 2 == 0) {
robEven = max(robEven + nums[i], robOdd);
} else {
robOdd = max(robEven, robOdd + nums[i]);
}
}
return max(robEven, robOdd);
}
};

Leetcode215. Kth Largest Element in an Array

Find the kth largest element in an unsorted array. Note that it is the kth largest element in the sorted order, not the kth distinct element.

Example 1:

1
2
Input: [3,2,1,5,6,4] and k = 2
Output: 5

Example 2:

1
2
Input: [3,2,3,1,2,4,5,5,6] and k = 4
Output: 4

Note: You may assume k is always valid, 1 ≤ k ≤ array’s length.

这道题让我们求数组中第k大的数字,怎么求呢,当然首先想到的是给数组排序,然后求可以得到第k大的数字。先看一种利用 C++ 的 STL 中的集成的排序方法,不用我们自己实现,这样的话这道题只要两行就完事了,代码如下:

1
2
3
4
5
6
7
class Solution {
public:
int findKthLargest(vector<int>& nums, int k) {
sort(nums.begin(), nums.end());
return nums[nums.size() - k];
}
};

下面这种解法是利用了 priority_queue 的自动排序的特性,跟上面的解法思路上没有什么区别,当然我们也可以换成 multiset 来做,一个道理,参见代码如下:

1
2
3
4
5
6
7
8
9
10
class Solution {
public:
int findKthLargest(vector<int>& nums, int k) {
priority_queue<int> q(nums.begin(), nums.end());
for (int i = 0; i < k - 1; ++i) {
q.pop();
}
return q.top();
}
};

这道题最好的解法应该是下面这种做法,用到了快速排序 Quick Sort 的思想,这里排序的方向是从大往小排。对快排不熟悉的童鞋们随意上网搜些帖子看下吧,多如牛毛啊,总有一款适合你。核心思想是每次都要先找一个中枢点 Pivot,然后遍历其他所有的数字,像这道题从大往小排的话,就把大于中枢点的数字放到左半边,把小于中枢点的放在右半边,这样中枢点是整个数组中第几大的数字就确定了,虽然左右两部分各自不一定是完全有序的,但是并不影响本题要求的结果,因为左半部分的所有值都大于右半部分的任意值,所以我们求出中枢点的位置,如果正好是 k-1,那么直接返回该位置上的数字;如果大于 k-1,说明要求的数字在左半部分,更新右边界,再求新的中枢点位置;反之则更新右半部分,求中枢点的位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Solution {
public:
int findKthLargest(vector<int>& nums, int k) {
int left = 0, right = nums.size() - 1;
while (true) {
int pos = partition(nums, left, right);
if (pos == k - 1) return nums[pos];
if (pos > k - 1) right = pos - 1;
else left = pos + 1;
}
}
int partition(vector<int>& nums, int left, int right) {
int pivot = nums[left], l = left + 1, r = right;
while (l <= r) {
if (nums[l] < pivot && nums[r] > pivot) {
swap(nums[l++], nums[r--]);
}
if (nums[l] >= pivot) ++l;
if (nums[r] <= pivot) --r;
}
swap(nums[left], nums[r]);
return r;
}
};

Leetcode216. Combination Sum III

Find all valid combinations of k numbers that sum up to n such that the following conditions are true:

  • Only numbers 1 through 9 are used.
  • Each number is used at most once.

Return a list of all possible valid combinations. The list must not contain the same combination twice, and the combinations may be returned in any order.

Example 1:

1
2
3
4
5
Input: k = 3, n = 7
Output: [[1,2,4]]
Explanation:
1 + 2 + 4 = 7
There are no other valid combinations.

Example 2:

1
2
3
4
5
6
7
Input: k = 3, n = 9
Output: [[1,2,6],[1,3,5],[2,3,4]]
Explanation:
1 + 2 + 6 = 9
1 + 3 + 5 = 9
2 + 3 + 4 = 9
There are no other valid combinations.

n是k个数字之和,如果n小于0,则直接返回,如果n正好等于0,而且此时out中数字的个数正好为k,说明此时是一个正确解,将其存入结果res中,具体实现参见代码入下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Solution {
public:
vector<vector<int> > combinationSum3(int k, int n) {
vector<vector<int> > res;
vector<int> out;
combinationSum3DFS(k, n, 1, out, res);
return res;
}
void combinationSum3DFS(int k, int n, int level, vector<int> &out, vector<vector<int> > &res) {
if (n < 0) return;
if (n == 0 && out.size() == k) res.push_back(out);
for (int i = level; i <= 9; ++i) {
out.push_back(i);
combinationSum3DFS(k, n - i, i + 1, out, res);
out.pop_back();
}
}
};

Leetcode217. Contains Duplicate

Given an array of integers, find if the array contains any duplicates.

Your function should return true if any value appears at least twice in the array, and it should return false if every element is distinct.

Example 1:

1
2
Input: [1,2,3,1]
Output: true

Example 2:
1
2
Input: [1,2,3,4]
Output: false

Example 3:
1
2
Input: [1,1,1,3,3,4,3,2,4,2]
Output: true

构造一个set,不重复就加进去,重复返回true
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Solution {
public:
bool containsDuplicate(vector<int>& nums) {
set<int> s;
if(nums.size() == 0)
return false;
s.insert(nums[0]);
for(int i = 1; i < nums.size(); i ++) {
if(s.count(nums[i]))
return true;
else
s.insert(nums[i]);
}
return false;
}
};

还有一个是排序
1
2
3
4
5
6
7
8
9
10
11
12
13
class Solution {
public:
bool containsDuplicate(vector<int>& nums)
{
sort(nums.begin(),nums.end());
for(int i=1;i<nums.size();i++)
{
if(nums[i]==nums[i-1])
return true;
}
return false;
}
};

Leetcode218. The Skyline Problem

A city’s skyline is the outer contour of the silhouette formed by all the buildings in that city when viewed from a distance. Now suppose you are given the locations and height of all the buildings as shown on a cityscape photo (Figure A), write a program to output the skyline formed by these buildings collectively (Figure B).

The geometric information of each building is represented by a triplet of integers [Li, Ri, Hi], where Li and Ri are the x coordinates of the left and right edge of the ith building, respectively, and Hi is its height. It is guaranteed that 0 ≤ Li, Ri ≤ INT_MAX, 0 < Hi ≤ INT_MAX, and Ri - Li > 0. You may assume all buildings are perfect rectangles grounded on an absolutely flat surface at height 0.

For instance, the dimensions of all buildings in Figure A are recorded as: [ [2 9 10], [3 7 15], [5 12 12], [15 20 10], [19 24 8] ].

The output is a list of “key points” (red dots in Figure B) in the format of [ [x1,y1], [x2, y2], [x3, y3], … ] that uniquely defines a skyline. A key point is the left endpoint of a horizontal line segment. Note that the last key point, where the rightmost building ends, is merely used to mark the termination of the skyline, and always has zero height. Also, the ground in between any two adjacent buildings should be considered part of the skyline contour.

For instance, the skyline in Figure B should be represented as:[ [2 10], [3 15], [7 12], [12 0], [15 10], [20 8], [24, 0] ].

Notes:

  • The number of buildings in any input list is guaranteed to be in the range [0, 10000].
  • The input list is already sorted in ascending order by the left x position Li.
  • The output list must be sorted by the x position.
  • There must be no consecutive horizontal lines of equal height in the output skyline. For instance, […[2 3], [4 5], [7 5], [11 5], [12 7]…] is not acceptable; the three lines of height 5 should be merged into one in the final output as such: […[2 3], [4 5], [12 7], …]

这里用到了 multiset 数据结构,其好处在于其中的元素是按堆排好序的,插入新元素进去还是有序的,而且执行删除元素也可方便的将元素删掉。这里为了区分左右边界,将左边界的高度存为负数,建立左边界和负高度的 pair,再建立右边界和高度的 pair,存入数组中,都存进去了以后,给数组按照左边界排序,这样就可以按顺序来处理那些关键的节点了。在 multiset 中放入一个0,这样在某个没有和其他建筑重叠的右边界上,就可以将封闭点存入结果 res 中。下面按顺序遍历这些关键节点,如果遇到高度为负值的 pair,说明是左边界,那么将正高度加入 multiset 中,然后取出此时集合中最高的高度,即最后一个数字,然后看是否跟 pre 相同,这里的 pre 是上一个状态的高度,初始化为0,所以第一个左边界的高度绝对不为0,所以肯定会存入结果 res 中。接下来如果碰到了一个更高的楼的左边界的话,新高度存入 multiset 的话会排在最后面,那么此时 cur 取来也跟 pre 不同,可以将新的左边界点加入结果 res。第三个点遇到绿色建筑的左边界点时,由于其高度低于红色的楼,所以 cur 取出来还是红色楼的高度,跟 pre 相同,直接跳过。下面遇到红色楼的右边界,此时首先将红色楼的高度从 multiset 中删除,那么此时 cur 取出的绿色楼的高度就是最高啦,跟 pre 不同,则可以将红楼的右边界横坐标和绿楼的高度组成 pair 加到结果 res 中,这样就成功的找到我们需要的拐点啦,后面都是这样类似的情况。当某个右边界点没有跟任何楼重叠的话,删掉当前的高度,那么 multiset 中就只剩0了,所以跟当前的右边界横坐标组成pair就是封闭点啦,具体实现参看代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Solution {
public:
vector<pair<int, int>> getSkyline(vector<vector<int>>& buildings) {
vector<pair<int, int>> h, res;
multiset<int> m;
int pre = 0, cur = 0;
for (auto &a : buildings) {
h.push_back({a[0], -a[2]});
h.push_back({a[1], a[2]});
}
sort(h.begin(), h.end());
m.insert(0);
for (auto &a : h) {
if (a.second < 0) m.insert(-a.second);
else m.erase(m.find(a.second));
cur = *m.rbegin();
if (cur != pre) {
res.push_back({a.first, cur});
pre = cur;
}
}
return res;
}
};

Leetcode219. Contains Duplicate II

Given an array of integers and an integer k, find out whether there are two distinct indices i and j in the array such that nums[i] = nums[j] and the absolute difference between i and j is at most k.

Example 1:

1
2
Input: nums = [1,2,3,1], k = 3
Output: true

Example 2:
1
2
Input: nums = [1,0,1,1], k = 1
Output: true

Example 3:
1
2
Input: nums = [1,2,3,1,2,3], k = 2
Output: false

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution {
public:
bool containsNearbyDuplicate(vector<int>& nums, int k) {
map<int, int> mp;
if(nums.size() == 0)
return false;
for(int i = 0; i < nums.size(); i ++) {
if(mp.find(nums[i]) == mp.end())
mp[nums[i]] = i;
else if(i - mp[nums[i]] <= k)
return true;
else
mp[nums[i]] = i;
}
return false;
}
};

Leetcode221. Maximal Square

Given an m x n binary matrix filled with 0’s and 1’s, find the largest square containing only 1’s and return its area.

Example 1:

1
2
Input: matrix = [["1","0","1","0","0"],["1","0","1","1","1"],["1","1","1","1","1"],["1","0","0","1","0"]]
Output: 4

Example 2:

1
2
Input: matrix = [["0","1"],["1","0"]]
Output: 1

Example 3:

1
2
Input: matrix = [["0"]]
Output: 0

类似85题,注意是正方形。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class Solution {
public:
int maximalSquare(vector<vector<char>>& matrix) {
int width = matrix.size();
if (width == 0)
return 0;
int height = matrix[0].size();
int res = 0;
vector<vector<int>> dp(width, vector<int>(height, 0));
for (int i = 0; i < width; i ++) {
for (int j = 0; j < height; j ++) {
if (matrix[i][j] == '1') {
dp[i][j] = j == 0 ? 1 : dp[i][j-1]+1;
int length = dp[i][j];
for (int k = i; k >= 0; k --) {
length = min(length, dp[k][j]);
if ((i-k+1) == length)
res = max(res, (i-k+1)*length);
}
}
}
}
return res;
}
};

来个效率高的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Solution {
public:
int maximalSquare(vector<vector<char>>& matrix) {
int width = matrix.size();
if (width == 0)
return 0;
int height = matrix[0].size();
int res = 0;
vector<vector<int>> dp(width, vector<int>(height, 0));
for (int i = 0; i < width; i ++) {
for (int j = 0; j < height; j ++) {
if (i == 0 || j == 0)
dp[i][j] = matrix[i][j] - '0';
else if (matrix[i][j] == '1') {
dp[i][j] = min(dp[i-1][j-1], min(dp[i][j-1], dp[i-1][j])) + 1;
}
res = max(res, dp[i][j]);
}
}
return res*res;
}
};

Leetcode222. Count Complete Tree Nodes

Given the root of a complete binary tree, return the number of the nodes in the tree.

According to Wikipedia, every level, except possibly the last, is completely filled in a complete binary tree, and all nodes in the last level are as far left as possible. It can have between 1 and 2h nodes inclusive at the last level h.

Design an algorithm that runs in less than O(n) time complexity.

这道题给定了一棵完全二叉树,让我们求其节点的个数。最暴力的解法就是直接用递归来统计结点的个数,根本不需要考虑什么完全二叉树还是完美二叉树,

1
2
3
4
5
6
class Solution {
public:
int countNodes(TreeNode* root) {
return root ? (1 + countNodes(root->left) + countNodes(root->right)) : 0;
}
};

完美二叉树一定是完全二叉树,而完全二叉树不一定是完美二叉树。那么这道题给的完全二叉树就有可能是完美二叉树,若是完美二叉树,节点个数很好求,为2的h次方减1,h为该完美二叉树的高度。若不是的话,只能老老实实的一个一个数结点了。思路是由 root 根结点往下,分别找最靠左边和最靠右边的路径长度,如果长度相等,则证明二叉树最后一层节点是满的,是满二叉树,直接返回节点个数,如果不相等,则节点个数为左子树的节点个数加上右子树的节点个数再加1(根节点),其中左右子树节点个数的计算可以使用递归来计算。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution {
public:
int countNodes(TreeNode* root) {
int hLeft = 0, hRight = 0;
TreeNode *pLeft = root, *pRight = root;
while (pLeft) {
++hLeft;
pLeft = pLeft->left;
}
while (pRight) {
++hRight;
pRight = pRight->right;
}
if (hLeft == hRight) return pow(2, hLeft) - 1;
return countNodes(root->left) + countNodes(root->right) + 1;
}
};

Leetcode223. Rectangle Area

Find the total area covered by two rectilinearrectangles in a 2D plane.

Each rectangle is defined by its bottom left corner and top right corner as shown in the figure.

1
2
Input: A = -3, B = 0, C = 3, D = 4, E = 0, F = -1, G = 9, H = 2
Output: 45

Note:

Assume that the total area is never beyond the maximum possible value of int.

尝试先找出所有的不相交的情况,只有四种,一个矩形在另一个的上下左右四个位置不重叠,这四种情况下返回两个矩形面积之和。其他所有情况下两个矩形是有交集的,这时候只要算出长和宽,即可求出交集区域的大小,然后从两个矩型面积之和中减去交集面积就是最终答案。求交集区域的长和宽也不难,由于交集都是在中间,所以横边的左端点是两个矩形左顶点横坐标的较大值,右端点是两个矩形右顶点的较小值,同理,竖边的下端点是两个矩形下顶点纵坐标的较大值,上端点是两个矩形上顶点纵坐标的较小值。

1
2
3
4
5
6
7
8
class Solution {
public:
int computeArea(int A, int B, int C, int D, int E, int F, int G, int H) {
int sum1 = (C - A) * (D - B), sum2 = (H - F) * (G - E);
if (E >= C || F >= D || B >= H || A >= G) return sum1 + sum2;
return sum1 - ((min(G, C) - max(A, E)) * (min(D, H) - max(B, F))) + sum2;
}
};

我自己的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Solution {
public:
int computeArea(int ax1, int ay1, int ax2, int ay2, int bx1, int by1, int bx2, int by2) {
int inter_x1 = max(ax1, bx1);
int inter_x2 = min(ax2, bx2);
int inter_y1 = max(ay1, by1);
int inter_y2 = min(ay2, by2);

int total_area = (ax2-ax1)*(ay2-ay1) + (bx2-bx1)*(by2-by1);
if (bx1 > ax2 || by2 < ay1 || bx2 < ax1 || by1 > ay2)
return total_area;
else
return total_area - (inter_x2-inter_x1)*(inter_y2-inter_y1);
}
};

Leetcode224. Basic Calculator

Given a string s representing a valid expression, implement a basic calculator to evaluate it, and return the result of the evaluation.

Note: You are not allowed to use any built-in function which evaluates strings as mathematical expressions, such as eval().

Example 1:

1
2
Input: s = "1 + 1"
Output: 2

Example 2:

1
2
Input: s = " 2-1 + 2 "
Output: 3

Example 3:

1
2
Input: s = "(1+(4+5+2)-3)+(6+8)"
Output: 23

Constraints:

  • 1 <= s.length <= 3 * 105
  • s consists of digits, ‘+’, ‘-‘, ‘(‘, ‘)’, and ‘ ‘.
  • s represents a valid expression.
  • ‘+’ is not used as a unary operation (i.e., “+1” and “+(2 + 3)” is invalid).
  • ‘-‘ could be used as a unary operation (i.e., “-1” and “-(2 + 3)” is valid).
  • There will be no two consecutive operators in the input.
  • Every number and running calculation will fit in a signed 32-bit integer.

这道题让我们实现一个基本的计算器来计算简单的算数表达式,而且题目限制了表达式中只有加减号,数字,括号和空格,没有乘除,那么就没啥计算的优先级之分了。于是这道题就变的没有那么复杂了。我们需要一个栈来辅助计算,用个变量sign来表示当前的符号,我们遍历给定的字符串s,如果遇到了数字,由于可能是个多位数,所以我们要用while循环把之后的数字都读进来,然后用sign*num来更新结果res;如果遇到了加号,则sign赋为1,如果遇到了符号,则赋为-1;如果遇到了左括号,则把当前结果res和符号sign压入栈,res重置为0,sign重置为1;如果遇到了右括号,结果res乘以栈顶的符号,栈顶元素出栈,结果res加上栈顶的数字,栈顶元素出栈。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
class Solution {
public:
int calculate(string s) {
int res = 0, len = s.length();
stack<int> st;
int op = 1;
st.push(1);

for (int i = 0; i < len;) {
if (s[i] == ' ') {
i ++;
continue;
}

if (s[i] == '(') {
st.push(op);
i ++;
} else if (s[i] == ')') {
st.pop();
i ++;
} else if (s[i] == '+') {
op = 1 * st.top();
i ++;
} else if (s[i] == '-') {
op = -1 * st.top();
i ++;
} else {
long long val = 0;
while(i < len && '0' <= s[i] && s[i] <= '9')
val = val * 10 + s[i++] - '0';
res += (op * val);
}

}

return res;
}
};

Leetcode225. Implement Stack using Queues

Implement the following operations of a stack using queues.

  • push(x) — Push element x onto stack.
  • pop() — Removes the element on top of the stack.
  • top() — Get the top element.
  • empty() — Return whether the stack is empty.

Example:

1
2
3
4
5
6
7
MyStack stack = new MyStack();

stack.push(1);
stack.push(2);
stack.top(); // returns 2
stack.pop(); // returns 2
stack.empty(); // returns false

Notes:

  • You must use only standard operations of a queue — which means only push to back, peek/pop from front, size, and is empty operations are valid.
  • Depending on your language, queue may not be supported natively. You may simulate a queue by using a list or deque (double-ended queue), as long as you use only standard operations of a queue.
  • You may assume that all operations are valid (for example, no pop or top operations will be called on an empty stack).
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
class MyStack {
public:

queue<int> que;

/** Initialize your data structure here. */
MyStack() {

}

/** Push element x onto stack. */
void push(int x) {
que.push(x);
int temp = que.size();
while(temp > 1) {
int xx = que.front();
que.pop();
que.push(xx);
temp --;
}
}

/** Removes the element on top of the stack and returns that element. */
int pop() {
int tt = que.front();
que.pop();
return tt;
}

/** Get the top element. */
int top() {
return que.front();
}

/** Returns whether the stack is empty. */
bool empty() {
return que.empty();
}
};

Leetcode226. Invert Binary Tree

Invert a binary tree.

Example:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Input:

4
/ \
2 7
/ \ / \
1 3 6 9
Output:

4
/ \
7 2
/ \ / \
9 6 3 1

1
2
3
4
5
6
7
8
9
10
11
12
13
class Solution {
public:
TreeNode* invertTree(TreeNode* root) {
if(root == NULL)
return root;
TreeNode* temp = root->left;
root->left = root->right;
root->right = temp;
if(root->left) invertTree(root->left);
if(root->right) invertTree(root->right);
return root;
}
};

Leetcode227. Basic Calculator II

Implement a basic calculator to evaluate a simple expression string.

The expression string contains only non-negativeintegers, +, -, *, / operators and empty spaces ``. The integer division should truncate toward zero.

Example 1:

1
2
Input: "3+2*2"
Output: 7

Example 2:

1
2
Input: " 3/2 "
Output: 1

Example 3:

1
2
Input: " 3+5 / 2 "
Output: 5

Note:

  • You may assume that the given expression is always valid.
  • Do not use the eval built-in library function.

这道题是之前那道 Basic Calculator 的拓展,不同之处在于那道题的计算符号只有加和减,而这题加上了乘除,那么就牵扯到了运算优先级的问题,好在这道题去掉了括号,还适当的降低了难度,估计再出一道的话就该加上括号了。不管那么多,这道题先按木有有括号来处理,由于存在运算优先级,我们采取的措施是使用一个栈保存数字,如果该数字之前的符号是加或减,那么把当前数字压入栈中,注意如果是减号,则加入当前数字的相反数,因为减法相当于加上一个相反数。如果之前的符号是乘或除,那么从栈顶取出一个数字和当前数字进行乘或除的运算,再把结果压入栈中,那么完成一遍遍历后,所有的乘或除都运算完了,再把栈中所有的数字都加起来就是最终结果了,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class Solution {
public:
int calculate(string s) {
long res = 0, num = 0, n = s.size();
char op = '+';
stack<int> st;
for (int i = 0; i < n; ++i) {
if (s[i] >= '0') {
num = num * 10 + s[i] - '0';
}
if ((s[i] < '0' && s[i] != ' ') || i == n - 1) {
if (op == '+') st.push(num);
if (op == '-') st.push(-num);
if (op == '*' || op == '/') {
int tmp = (op == '*') ? st.top() * num : st.top() / num;
st.pop();
st.push(tmp);
}
op = s[i];
num = 0;
}
}
while (!st.empty()) {
res += st.top();
st.pop();
}
return res;
}
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class Solution {
public:
int calculate(string s) {
long res = 0, curRes = 0, num = 0, n = s.size();
char op = '+';
for (int i = 0; i < n; ++i) {
char c = s[i];
if (c >= '0' && c <= '9') {
num = num * 10 + c - '0';
}
if (c == '+' || c == '-' || c == '*' || c == '/' || i == n - 1) {
switch (op) {
case '+': curRes += num; break;
case '-': curRes -= num; break;
case '*': curRes *= num; break;
case '/': curRes /= num; break;
}
if (c == '+' || c == '-' || i == n - 1) {
res += curRes;
curRes = 0;
}
op = c;
num = 0;
}
}
return res;
}
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
class Solution {
public:
int calculate(string s) {
int len = s.length();
long long res = 0, last_val;
char op = '+';

for (int i = 0; i < len; ) {
if (s[i] == ' ') {
i ++;
continue;
}

if ('0' <= s[i] && s[i] <= '9') {
long long val = 0;
while(i < len && '0' <= s[i] && s[i] <= '9')
val = val* 10 + s[i++] - '0';

if (op == '+') {
res += val;
last_val = val;
} else if (op == '-') {
res -= val;
last_val = -val;
} else if (op == '*') {
res = res - last_val + last_val * val;
last_val = last_val * val;
} else if (op == '/') {
res = res - last_val + last_val / val;
last_val = last_val / val;
}
}
else {
op = s[i];
i ++;
}
}
return res;
}
};

Leetcode228. Summary Ranges

Given a sorted integer array without duplicates, return the summary of its ranges.

Example 1:

1
2
3
Input:  [0,1,2,4,5,7]
Output: ["0->2","4->5","7"]
Explanation: 0,1,2 form a continuous range; 4,5 form a continuous range.

Example 2:

1
2
3
Input:  [0,2,3,4,6,8,9]
Output: ["0","2->4","6","8->9"]
Explanation: 2,3,4 form a continuous range; 8,9 form a continuous range.

这道题给定我们一个有序数组,让我们总结区间,具体来说就是让我们找出连续的序列,然后首尾两个数字之间用个“->”来连接,那么我只需遍历一遍数组即可,每次检查下一个数是不是递增的,如果是,则继续往下遍历,如果不是了,我们还要判断此时是一个数还是一个序列,一个数直接存入结果,序列的话要存入首尾数字和箭头“->”。我们需要两个变量i和j,其中i是连续序列起始数字的位置,j是连续数列的长度,当j为1时,说明只有一个数字,若大于1,则是一个连续序列。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Solution {
public:
vector<string> summaryRanges(vector<int>& nums) {
vector<string> res;
int i = 0, n = nums.size();
while (i < n) {
int j = 1;
while (i + j < n && (long)nums[i + j] - nums[i] == j) ++j;
res.push_back(j <= 1 ? to_string(nums[i]) : to_string(nums[i]) + "->" + to_string(nums[i + j - 1]));
i += j;
}
return res;
}
};

Leetcode229. Majority Element II

Given an integer array of size n , find all elements that appear more than ⌊ n/3 ⌋ times.

Note: The algorithm should run in linear time and in O(1) space.

Example 1:

1
2
Input: [3,2,3]
Output: [3]

Example 2:

1
2
Input: [1,1,1,3,3,2,2,2]
Output: [1,2]

这道题让我们求出现次数大于 n/3 的数字,而且限定了时间和空间复杂度,那么就不能排序,也不能使用 HashMap,这么苛刻的限制条件只有一种方法能解了,那就是摩尔投票法 Moore Voting,这种方法在之前那道题 Majority Element 中也使用了。题目中给了一条很重要的提示,让先考虑可能会有多少个这样的数字,经过举了很多例子分析得出,任意一个数组出现次数大于 n/3 的数最多有两个,具体的证明博主就不会了,博主也不是数学专业的(热心网友用手走路提供了证明:如果有超过两个,也就是至少三个数字满足“出现的次数大于 n/3”,那么就意味着数组里总共有超过 3*(n/3) = n 个数字,这与已知的数组大小矛盾,所以,只可能有两个或者更少)。那么有了这个信息,使用投票法的核心是找出两个候选数进行投票,需要两遍遍历,第一遍历找出两个候选数,第二遍遍历重新投票验证这两个候选数是否为符合题意的数即可,选候选数方法和前面那篇 Majority Element 一样,由于之前那题题目中限定了一定会有大多数存在,故而省略了验证候选众数的步骤,这道题却没有这种限定,即满足要求的大多数可能不存在,所以要有验证,参加代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Solution {
public:
vector<int> majorityElement(vector<int>& nums) {
vector<int> res;
int a = 0, b = 0, cnt1 = 0, cnt2 = 0, n = nums.size();
for (int num : nums) {
if (num == a) ++cnt1;
else if (num == b) ++cnt2;
else if (cnt1 == 0) { a = num; cnt1 = 1; }
else if (cnt2 == 0) { b = num; cnt2 = 1; }
else { --cnt1; --cnt2; }
}
cnt1 = cnt2 = 0;
for (int num : nums) {
if (num == a) ++cnt1;
else if (num == b) ++cnt2;
}
if (cnt1 > n / 3) res.push_back(a);
if (cnt2 > n / 3) res.push_back(b);
return res;
}
};

Leetcode230. Kth Smallest Element in a BST

Given a binary search tree, write a function kthSmallest to find the kth smallest element in it.

Note:
You may assume k is always valid, 1 ≤ k ≤ BST’s total elements.

Example 1:

1
2
3
4
5
6
7
Input: root = [3,1,4,null,2], k = 1
3
/ \
1 4
\
2
Output: 1

Example 2:

1
2
3
4
5
6
7
8
9
Input: root = [5,3,6,2,4,null,null,1], k = 3
5
/ \
3 6
/ \
2 4
/
1
Output: 3

Follow up:
What if the BST is modified (insert/delete operations) often and you need to find the kth smallest frequently? How would you optimize the kthSmallest routine?

如果用中序遍历所有的节点就会得到一个有序数组。先来看一种非递归的方法,中序遍历最先遍历到的是最小的结点,只要用一个计数器,每遍历一个结点,计数器自增1,当计数器到达k时,返回当前结点值即可,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Solution {
public:
int kthSmallest(TreeNode* root, int k) {
int cnt = 0;
stack<TreeNode*> s;
TreeNode *p = root;
while (p || !s.empty()) {
while (p) {
s.push(p);
p = p->left;
}
p = s.top(); s.pop();
++cnt;
if (cnt == k) return p->val;
p = p->right;
}
return 0;
}
};

当然,此题我们也可以用递归来解,还是利用中序遍历来解,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Solution {
public:
int kthSmallest(TreeNode* root, int k) {
return kthSmallestDFS(root, k);
}
int kthSmallestDFS(TreeNode* root, int &k) {
if (!root) return -1;
int val = kthSmallestDFS(root->left, k);
if (k == 0) return val;
if (--k == 0) return root->val;
return kthSmallestDFS(root->right, k);
}
};

再来看一种分治法的思路,由于 BST 的性质,可以快速定位出第k小的元素是在左子树还是右子树,首先计算出左子树的结点个数总和 cnt,如果k小于等于左子树结点总和 cnt,说明第k小的元素在左子树中,直接对左子结点调用递归即可。如果k大于 cnt+1,说明目标值在右子树中,对右子结点调用递归函数,注意此时的k应为 k-cnt-1,应为已经减少了 cnt+1 个结点。如果k正好等于 cnt+1,说明当前结点即为所求,返回当前结点值即可,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Solution {
public:
int kthSmallest(TreeNode* root, int k) {
int cnt = count(root->left);
if (k <= cnt) {
return kthSmallest(root->left, k);
} else if (k > cnt + 1) {
return kthSmallest(root->right, k - cnt - 1);
}
return root->val;
}
int count(TreeNode* node) {
if (!node) return 0;
return 1 + count(node->left) + count(node->right);
}
};

这道题的 Follow up 中说假设该 BST 被修改的很频繁,而且查找第k小元素的操作也很频繁,问我们如何优化。其实最好的方法还是像上面的解法那样利用分治法来快速定位目标所在的位置,但是每个递归都遍历左子树所有结点来计算个数的操作并不高效,所以应该修改原树结点的结构,使其保存包括当前结点和其左右子树所有结点的个数,这样就可以快速得到任何左子树结点总数来快速定位目标值了。定义了新结点结构体,然后就要生成新树,还是用递归的方法生成新树,注意生成的结点的 count 值要累加其左右子结点的 count 值。然后在求第k小元素的函数中,先生成新的树,然后调用递归函数。在递归函数中,不能直接访问左子结点的 count 值,因为左子节结点不一定存在,所以要先判断,如果左子结点存在的话,那么跟上面解法的操作相同。如果不存在的话,当此时k为1的时候,直接返回当前结点值,否则就对右子结点调用递归函数,k自减1,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// Follow up
class Solution {
public:
struct MyTreeNode {
int val;
int count;
MyTreeNode *left;
MyTreeNode *right;
MyTreeNode(int x) : val(x), count(1), left(NULL), right(NULL) {}
};

MyTreeNode* build(TreeNode* root) {
if (!root) return NULL;
MyTreeNode *node = new MyTreeNode(root->val);
node->left = build(root->left);
node->right = build(root->right);
if (node->left) node->count += node->left->count;
if (node->right) node->count += node->right->count;
return node;
}

int kthSmallest(TreeNode* root, int k) {
MyTreeNode *node = build(root);
return helper(node, k);
}

int helper(MyTreeNode* node, int k) {
if (node->left) {
int cnt = node->left->count;
if (k <= cnt) {
return helper(node->left, k);
} else if (k > cnt + 1) {
return helper(node->right, k - 1 - cnt);
}
return node->val;
} else {
if (k == 1) return node->val;
return helper(node->right, k - 1);
}
}
};

Leetcode231. Power of Two

Given an integer, write a function to determine if it is a power of two.

Example 1:

1
2
Input: 1 Output: true 
Explanation: 2^0 = 1

Example 2:
1
2
Input: 16 Output: true
Explanation: 2^4 = 16

判断一个数是不是2的幂
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Solution {
public:
bool isPowerOfTwo(int n) {
if(n == 0)
return false;
while(n != 1) {
int temp = n & 1;
if(temp != 0)
return false;
n = n >> 1;
}
return true;
}
};

Leetcode232. Implement Queue using Stacks

Implement the following operations of a queue using stacks.

  • push(x) — Push element x to the back of queue.
  • pop() — Removes the element from in front of queue.
  • peek() — Get the front element.
  • empty() — Return whether the queue is empty.

Example:

1
2
3
4
5
6
MyQueue queue = new MyQueue();
queue.push(1);
queue.push(2);
queue.peek(); // returns 1
queue.pop(); // returns 1
queue.empty(); // returns false

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
class MyQueue {
public:

stack<int> s1, s2;
int front;
/** Initialize your data structure here. */
MyQueue() {

}

/** Push element x to the back of queue. */
void push(int x) {
if(s1.empty()) {
front = x;
}
s1.push(x);
}

/** Removes the element from in front of queue and returns that element. */
int pop() {
while(!s1.empty()){
s2.push(s1.top());
s1.pop();
}
int res = s2.top();
s2.pop();
while(!s2.empty()) {
s1.push(s2.top());
s2.pop();
}
return res;
}

/** Get the front element. */
int peek() {
while(!s1.empty()){
s2.push(s1.top());
s1.pop();
}
int res = s2.top();
while(!s2.empty()){
s1.push(s2.top());
s2.pop();
}
return res;
}

/** Returns whether the queue is empty. */
bool empty() {
return s1.empty();
}
};

Leetcode233. Number of Digit One

Given an integer n, count the total number of digit1 appearing in all non-negative integers less than or equal to n.

Example 1:

1
2
Input: n = 13
Output: 6

Example 2:

1
2
Input: n = 0
Output: 0

Constraints:

  • 0 <= n <= 2 * 109

这道题让我们比给定数小的所有数中1出现的个数,之前有道类似的题 Number of 1 Bits,那道题是求转为二进数后1的个数,博主开始以为这道题也是要用那题的方法,其实不是的,这题实际上相当于一道找规律的题。那么为了找出规律,就先来列举下所有含1的数字,并每 10 个统计下个数,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
1的个数  含1的数字                                          数字范围
1 1 [1, 9]
11 10 11 12 13 14 15 16 17 18 19 [10, 19]
1 21 [20, 29]
1 31 [30, 39]
1 41 [40, 49]
1 51 [50, 59]
1 61 [60, 69]
1 71 [70, 79]
1 81 [80, 89]
1 91 [90, 99]
11 100 101 102 103 104 105 106 107 108 109 [100, 109]
21 110 111 112 113 114 115 116 117 118 119 [110, 119]
11 120 121 122 123 124 125 126 127 128 129 [120, 129]

通过上面的列举可以发现,100 以内的数字,除了10-19之间有 11 个 ‘1’ 之外,其余都只有1个。如果不考虑 [10, 19] 区间上那多出来的 10 个 ‘1’ 的话,那么在对任意一个两位数,十位数上的数字(加1)就代表1出现的个数,这时候再把多出的 10 个加上即可。比如 56 就有 (5+1)+10=16 个。如何知道是否要加上多出的 10 个呢,就要看十位上的数字是否大于等于2,是的话就要加上多余的 10 个 ‘1’。那么就可以用 (x+8)/10 来判断一个数是否大于等于2。对于三位数区间 [100, 199] 内的数也是一样,除了 [110, 119] 之间多出的10个数之外,共 21 个 ‘1’,其余的每 10 个数的区间都只有 11 个 ‘1’,所以 [100, 199] 内共有 21 + 11 9 = 120 个 ‘1’。那么现在想想 [0, 999] 区间内 ‘1’ 的个数怎么求?根据前面的结果,[0, 99] 内共有 20 个,[100, 199] 内共有 120 个,而其他每 100 个数内 ‘1’ 的个数也应该符合之前的规律,即也是 20 个,那么总共就有 120 + 20 9 = 300 个 ‘1’。那么还是可以用相同的方法来判断并累加1的个数,参见代码如下:

解法一:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Solution {
public:
int countDigitOne(int n) {
int res = 0, a = 1, b = 1;
while (n > 0) {
res += (n + 8) / 10 * a + (n % 10 == 1) * b;
b += n % 10 * a;
a *= 10;
n /= 10;
}
return res;
}
};

Leetcode234. Palindrome Linked List

Given a singly linked list, determine if it is a palindrome.

Example 1:

1
2
Input: 1->2
Output: false

Example 2:
1
2
Input: 1->2->2->1
Output: true

使用反转链表。不同的是不反转整个链表,只反转回文的后半段链表。然后就是判断出哪里是回文的中间位置。

首先说反转链表函数。设置一个节点pre为空,是反转链表后的起始节点。设置节点next为空,是要反转链表当前节点的next节点。遍历,只要head不为空,则先将head->next保存在next节点中。然后head->next指向pre,然后head节点保存在pre中。最后head保存next节点。遍历结束,返回pre节点,即完成反转。

然后说判断回文中间位置。设置一个慢指针slow,一个快指针fast。遍历,只要fast->next和fast->next->next不为空,则slow往前走一步,fast往前走两步,slow = slow->next,fast = fast->next->next,这样当不满足遍历条件、结束遍历时,slow刚好指在中间位置,如果长度是计数,则刚好中间,长度是偶数,则中间前一个。

然后说反转后半部分链表。将slow->next开始反转,slow->next = reverselist(slow->next),然后将slow = slow->next。

最后是判断是否是回文。这时,可同时遍历head和slow,判断二者值是否相等即可,不相等直接返回false。遍历结束后,返回true。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class Solution {
public:

ListNode* reverseList(ListNode *head) {
ListNode *pre = NULL, *next = NULL;
while(head) {
next = head->next;
head->next = pre;
pre = head;
head = next;
}
return pre;
}

bool isPalindrome(ListNode* head) {
if(head == NULL || head->next == NULL)
return true;
ListNode *slow = head, *fast = head;
while(fast->next && fast->next->next) {
slow = slow->next;
fast = fast->next->next;
}
slow->next = reverseList(slow->next);
slow = slow->next;

while(slow) {
if (slow->val != head->val)
return false;
slow = slow->next;
head = head->next;
}
return true;
}
};

Leetcode235. Lowest Common Ancestor of a Binary Search Tree

Given a binary search tree (BST), find the lowest common ancestor (LCA) of two given nodes in the BST.

According to the definition of LCA on Wikipedia: “The lowest common ancestor is defined between two nodes p and q as the lowest node in T that has both p and q as descendants (where we allow a node to be a descendant of itself).”

Given binary search tree: root = [6,2,8,0,4,7,9,null,null,3,5]

Example 1:

1
2
3
Input: root = [6,2,8,0,4,7,9,null,null,3,5], p = 2, q = 8
Output: 6
Explanation: The LCA of nodes 2 and 8 is 6.

Example 2:
1
2
3
Input: root = [6,2,8,0,4,7,9,null,null,3,5], p = 2, q = 4
Output: 2
Explanation: The LCA of nodes 2 and 4 is 2, since a node can be a descendant of itself according to the LCA definition.

这道题我们可以用递归来求解,我们首先来看题目中给的例子,由于二叉搜索树的特点是左<根<右,所以根节点的值一直都是中间值,大于左子树的所有节点值,小于右子树的所有节点值,那么我们可以做如下的判断,如果根节点的值大于p和q之间的较大值,说明p和q都在左子树中,那么此时我们就进入根节点的左子节点继续递归,如果根节点小于p和q之间的较小值,说明p和q都在右子树中,那么此时我们就进入根节点的右子节点继续递归,如果都不是,则说明当前根节点就是最小共同父节点,直接返回即可。
1
2
3
4
5
6
7
8
9
10
11
class Solution {
public:
TreeNode* lowestCommonAncestor(TreeNode* root, TreeNode* p, TreeNode* q) {
if (!root) return NULL;
if (root->val > max(p->val, q->val))
return lowestCommonAncestor(root->left, p, q);
else if (root->val < min(p->val, q->val))
return lowestCommonAncestor(root->right, p, q);
else return root;
}
};

当然,此题也有非递归的写法,用个 while 循环来代替递归调用即可,然后不停的更新当前的根节点,也能实现同样的效果,代码如下:

1
2
3
4
5
6
7
8
9
10
11
class Solution {
public:
TreeNode* lowestCommonAncestor(TreeNode* root, TreeNode* p, TreeNode* q) {
while (true) {
if (root->val > max(p->val, q->val)) root = root->left;
else if (root->val < min(p->val, q->val)) root = root->right;
else break;
}
return root;
}
};

Leetcode236. Lowest Common Ancestor of a Binary Tree

Given a binary tree, find the lowest common ancestor (LCA) of two given nodes in the tree.

According to the definition of LCA on Wikipedia: “The lowest common ancestor is defined between two nodes p and q as the lowest node in T that has both p and q as descendants (where we allow a node to be a descendant of itself).”

Given the following binary tree: root = [3,5,1,6,2,0,8,null,null,7,4]

Example 1:

1
2
3
Input: root = [3,5,1,6,2,0,8,null,null,7,4], p = 5, q = 1
Output: 3
Explanation: The LCA of nodes 5 and 1 is 3.

Example 2:

1
2
3
Input: root = [3,5,1,6,2,0,8,null,null,7,4], p = 5, q = 4
Output: 5
Explanation: The LCA of nodes 5 and 4 is 5, since a node can be a descendant of itself according to the LCA definition.

Note:

  • All of the nodes’ values will be unique.
  • p and q are different and both values will exist in the binary tree.

在二叉树中来搜索p和q,然后从路径中找到最后一个相同的节点即为父节点,可以用递归来实现,在递归函数中,首先看当前结点是否为空,若为空则直接返回空,若为p或q中的任意一个,也直接返回当前结点。否则的话就对其左右子结点分别调用递归函数,由于这道题限制了p和q一定都在二叉树中存在,那么如果当前结点不等于p或q,p和q要么分别位于左右子树中,要么同时位于左子树,或者同时位于右子树,那么我们分别来讨论:

  • 若p和q分别位于左右子树中,那么对左右子结点调用递归函数,会分别返回p和q结点的位置,而当前结点正好就是p和q的最小共同父结点,直接返回当前结点即可,这就是题目中的例子1的情况。
  • 若p和q同时位于左子树,这里有两种情况,一种情况是 left 会返回p和q中较高的那个位置,而 right 会返回空,所以最终返回非空的 left 即可,这就是题目中的例子2的情况。还有一种情况是会返回p和q的最小父结点,就是说当前结点的左子树中的某个结点才是p和q的最小父结点,会被返回。
  • 若p和q同时位于右子树,同样这里有两种情况,一种情况是 right 会返回p和q中较高的那个位置,而 left 会返回空,所以最终返回非空的 right 即可,还有一种情况是会返回p和q的最小父结点,就是说当前结点的右子树中的某个结点才是p和q的最小父结点,会被返回,写法很简洁,代码如下:
1
2
3
4
5
6
7
8
9
10
class Solution {
public:
TreeNode* lowestCommonAncestor(TreeNode* root, TreeNode* p, TreeNode* q) {
if (!root || p == root || q == root) return root;
TreeNode *left = lowestCommonAncestor(root->left, p, q);
TreeNode *right = lowestCommonAncestor(root->right, p , q);
if (left && right) return root;
return left ? left : right;
}
};

上述代码可以进行优化一下,如果当前结点不为空,且既不是p也不是q,那么根据上面的分析,p和q的位置就有三种情况,p和q要么分别位于左右子树中,要么同时位于左子树,或者同时位于右子树。我们需要优化的情况就是当p和q同时为于左子树或右子树中,而且返回的结点并不是p或q,那么就是p和q的最小父结点了,已经求出来了,就不用再对右结点调用递归函数了,这是为啥呢?因为根本不会存在 left 既不是p也不是q,同时还有p或者q在 right 中。首先递归的第一句就限定了只要遇到了p或者q,就直接返回,之后又限定了只有当 left 和 right 同时存在的时候,才会返回当前结点,当前结点若不是p或q,则一定是最小父节点,否则 left 一定是p或者q。这里的逻辑比较绕,不太好想,多想想应该可以理清头绪吧,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
class Solution {
public:
TreeNode* lowestCommonAncestor(TreeNode* root, TreeNode* p, TreeNode* q) {
if (!root || p == root || q == root) return root;
TreeNode *left = lowestCommonAncestor(root->left, p, q);
if (left && left != p && left != q) return left;
TreeNode *right = lowestCommonAncestor(root->right, p , q);
    if (left && right) return root;
return left ? left : right;
}
};

Leetcode237. Delete Node in a Linked List

Write a function to delete a node (except the tail) in a singly linked list, given only access to that node.

Given linked list — head = [4,5,1,9], which looks like following:

Example 1:

1
2
3
Input: head = [4,5,1,9], node = 5
Output: [4,1,9]
Explanation: You are given the second node with value 5, the linked list should become 4 -> 1 -> 9 after calling your function.

Example 2:
1
2
3
Input: head = [4,5,1,9], node = 1
Output: [4,5,9]
Explanation: You are given the third node with value 1, the linked list should become 4 -> 5 -> 9 after calling your function.

这道题让我们删除链表的一个节点,更通常不同的是,没有给我们链表的起点,只给我们了一个要删的节点,跟我们以前遇到的情况不太一样,我们之前要删除一个节点的方法是要有其前一个节点的位置,然后将其前一个节点的next连向要删节点的下一个,然后delete掉要删的节点即可。这道题的处理方法是先把当前节点的值用下一个节点的值覆盖了,然后我们删除下一个节点即可。
1
2
3
4
5
6
7
class Solution {
public:
void deleteNode(ListNode* node) {
node->val = node->next->val;
node->next = node->next->next;
}
};

Leetcode238. Product of Array Except Self

Given an array nums of n integers where n > 1, return an array output such that output[i] is equal to the product of all the elements of numsexcept nums[i].

Example:

1
2
Input:  [1,2,3,4]
Output: [24,12,8,6]

Note: Please solve it without division and in O(n).

这道题给定我们一个数组,让我们返回一个新数组,对于每一个位置上的数是其他位置上数的乘积,并且限定了时间复杂度 O(n),并且不让我们用除法。如果让用除法的话,那这道题就应该属于 Easy,因为可以先遍历一遍数组求出所有数字之积,然后除以对应位置的上的数字。但是这道题禁止我们使用除法,那么我们只能另辟蹊径。我们想,对于某一个数字,如果我们知道其前面所有数字的乘积,同时也知道后面所有的数乘积,那么二者相乘就是我们要的结果,所以我们只要分别创建出这两个数组即可,分别从数组的两个方向遍历就可以分别创建出乘积累积数组。参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution {
public:
vector<int> productExceptSelf(vector<int>& nums) {
int n = nums.size();
vector<int> fwd(n, 1), bwd(n, 1), res(n);
for (int i = 0; i < n - 1; ++i) {
fwd[i + 1] = fwd[i] * nums[i];
}
for (int i = n - 1; i > 0; --i) {
bwd[i - 1] = bwd[i] * nums[i];
}
for (int i = 0; i < n; ++i) {
res[i] = fwd[i] * bwd[i];
}
return res;
}
};

我们可以对上面的方法进行空间上的优化,由于最终的结果都是要乘到结果 res 中,所以可以不用单独的数组来保存乘积,而是直接累积到结果 res 中,我们先从前面遍历一遍,将乘积的累积存入结果 res 中,然后从后面开始遍历,用到一个临时变量 right,初始化为1,然后每次不断累积,最终得到正确结果,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Solution {
public:
vector<int> productExceptSelf(vector<int>& nums) {
vector<int> res(nums.size(), 1);
for (int i = 1; i < nums.size(); ++i) {
res[i] = res[i - 1] * nums[i - 1];
}
int right = 1;
for (int i = nums.size() - 1; i >= 0; --i) {
res[i] *= right;
right *= nums[i];
}
return res;
}
};

Leetcode239. Sliding Window Maximum

You are given an array of integers nums, there is a sliding window of size k which is moving from the very left of the array to the very right. You can only see the k numbers in the window. Each time the sliding window moves right by one position.

Return the max sliding window.

Example 1:

1
2
3
4
5
6
7
8
9
10
11
Input: nums = [1,3,-1,-3,5,3,6,7], k = 3
Output: [3,3,5,5,6,7]
Explanation:
Window position Max
--------------- -----
[1 3 -1] -3 5 3 6 7 3
1 [3 -1 -3] 5 3 6 7 3
1 3 [-1 -3 5] 3 6 7 5
1 3 -1 [-3 5 3] 6 7 5
1 3 -1 -3 [5 3 6] 7 6
1 3 -1 -3 5 [3 6 7] 7

Example 2:

1
2
Input: nums = [1], k = 1
Output: [1]

给定一个数组 nums,有一个大小为 k 的滑动窗口从数组的最左侧移动到数组的最右侧。你只可以看到在滑动窗口内的 k 个数字。滑动窗口每次只向右移动一位。

返回滑动窗口中的最大值。

双端单调队列

本题要找长度为 k 的区间的最大值。模拟这个区间的移动过程,可以发现,右边增加一个数,左边必然会去掉一个数。

那么最大的数有什么性质呢?可以发现,如果扫描区间末尾,在已经遍历过的数之中,一个数 a 在 b 前面,并且 a 还比 b 小,那么 a 在之后的区间里永远无法成为最大值。

所以我们遍历到一个数时,它之前的所有比它小的数都可以去掉了,只保留比它大的数就行了。这就让人想到了之前介绍过的单调栈,但是本题中是先进先出,所以要改用单调队列。此外队列末尾不仅要增加元素,还得维护单调递减,适当去除一些元素,所以队列两端都得有插入和删除的功能。所以本题要使用双端队列,而队列中的元素又是单调递减的,所以又是双端单调队列。

这样思路就很明确了:

  • 遍历元素 nums[i] ,然后跟队列尾部元素比较,如果比尾部元素大,就出队,然后继续比较,直到 nums[i] 小于尾部元素,然后将它入队。
  • 然后用一下队列首部元素的下标,计算出队列中区间的长度,如果大于 k 了,那么队首元素就要出队。
  • 最后队首元素就是当前区间的最大值。

分块法

试想如果我们将数组划分为相同大小的若干块,每一块中最大值都是知道的话,那么要求区间最大值,只需要看它在哪几块里就行了。

那么块的大小应该设成多少呢?

如果块大小为 k ,就可以发现长度为 k 的区间 [i, j] 要么正好就是一个完整的块,要么跨越了两个相邻块。那么我们只需要知道 i 到它那块末尾元素中最大值,以及 j 到它那块开头最大值就行了,两个部分合并求最大值就是区间的最大值了。而每个元素到它自己那块的开头和末尾的最大值都可以预处理出来,方法和求前缀和类似。

那为什么块大小不能是其他值呢?如果块大小大于 k ,那么会出现区间完全包含于一块之中的情况,那就和不分块一样了。如果块大小小于 k ,那么就会出现区间横跨了好几块,那么还得遍历中间块的最大值。极端情况下如果块大小为 1 ,那么就等于暴力求解。

代码

双端单调队列(c++)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Solution {
public:
vector<int> maxSlidingWindow(vector<int>& nums, int k) {
int n = nums.size();
deque<int> Q;
vector<int> res;
for (int i = 0; i < n; ++i) {
while (!Q.empty() && nums[i] >= nums[Q.back()]) Q.pop_back();
Q.push_back(i);
if (i - Q.front() + 1 > k) Q.pop_front();
if (i >= k-1) res.push_back(nums[Q.front()]);
}
return res;
}
};

双端单调队列+数组实现(c++)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Solution {
public:
vector<int> maxSlidingWindow(vector<int>& nums, int k) {
int n = nums.size();
vector<int> Q(n, 0);
vector<int> res;
int l = 0, r = 0;
for (int i = 0; i < n; ++i) {
while (r-l > 0 && nums[i] >= nums[Q[r-1]]) r--;
Q[r++] = i;
if (i - Q[l] + 1 > k) l++;
if (i >= k-1) res.push_back(nums[Q[l]]);
}
return res;
}
};

分块法(c++)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Solution {
public:
vector<int> maxSlidingWindow(vector<int>& nums, int k) {
int n = nums.size();
vector<int> lmax(n, 0), rmax(n, 0);
vector<int> res;
if (!n) return res;
for (int i = 0; i < n; ++i) {
if (i%k == 0) lmax[i] = nums[i];
else lmax[i] = max(lmax[i-1], nums[i]);
}
for (int i = n-1; i >= 0; --i) {
if ((i+1)%k == 0 || i == n-1) rmax[i] = nums[i];
else rmax[i] = max(rmax[i+1], nums[i]);
}
for (int i = k-1; i < n; ++i) {
res.push_back(max(lmax[i], rmax[i-k+1]));
}
return res;
}
};

双端单调队列(python)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import collections

class Solution:
def maxSlidingWindow(self, nums: List[int], k: int) -> List[int]:
n = len(nums)
Q = collections.deque()
res = []
for i in range(n):
while len(Q) > 0 and nums[i] >= nums[Q[-1]]:
Q.pop()
Q.append(i)
if i - Q[0] + 1 > k:
Q.popleft()
if i >= k-1:
res.append(nums[Q[0]])
return res

双端单调队列+数组实现(python)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import collections

class Solution:
def maxSlidingWindow(self, nums: List[int], k: int) -> List[int]:
n = len(nums)
Q = [0] * n
res = []
l, r = 0, 0
for i in range(n):
while r-l > 0 and nums[i] >= nums[Q[r-1]]:
r -= 1
Q[r] = i
r += 1
if i - Q[l] + 1 > k:
l += 1
if i >= k-1:
res.append(nums[Q[l]])
return res

分块法(python)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import collections
class Solution:
def maxSlidingWindow(self, nums: List[int], k: int) -> List[int]:
n = len(nums)
lmax, rmax = [0] * n, [0] * n
res = []
if n == 0:
return res
for i in range(n):
if i%k == 0:
lmax[i] = nums[i]
else:
lmax[i] = max(lmax[i-1], nums[i])
for i in range(n-1, -1, -1):
if (i+1)%k == 0 or i == n-1:
rmax[i] = nums[i]
else:
rmax[i] = max(rmax[i+1], nums[i])
for i in range(k-1, n):
res.append(max(lmax[i], rmax[i-k+1]))
return res

Leetcode240. Search a 2D Matrix II

Write an efficient algorithm that searches for a value in an m x n matrix. This matrix has the following properties:

Integers in each row are sorted in ascending from left to right.
Integers in each column are sorted in ascending from top to bottom.
Example:

Consider the following matrix:

[
[1, 4, 7, 11, 15],
[2, 5, 8, 12, 19],
[3, 6, 9, 16, 22],
[10, 13, 14, 17, 24],
[18, 21, 23, 26, 30]
]
Given target = 5, return true.

Given target = 20, return false.

从右上角开始, 比较target 和 matrix[i][j]的值. 如果小于target, 则该行不可能有此数, 所以i++; 如果大于target, 则该列不可能有此数, 所以j—. 遇到边界则表明该矩阵不含target.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution {
public:
bool searchMatrix(vector<vector<int>>& matrix, int target) {
if(matrix.size()==0 || matrix[0].size()==0)
return false;
int i=0,j=matrix[0].size()-1;
while(i<matrix.size() && j>=0) {
if(matrix[i][j]==target)
return true;
if(matrix[i][j] < target)
i++;
else
j--;
}
return false;
}
};

然后,做一些简单的优化,就能从打败50%升到打败60%。比如尽量少的使用matrix[i][j],而是用变量把它存下来。

Leetcode241. Different Ways to Add Parentheses 添加括号的不同方式

Given a string of numbers and operators, return all possible results from computing all the different possible ways to group numbers and operators. The valid operators are +, - and *.

Example 1:

1
2
3
4
5
Input: "2-1-1"
Output: [0, 2]
Explanation:
((2-1)-1) = 0
(2-(1-1)) = 2

Example 2:

1
2
3
4
5
6
7
8
Input: "2*3-4*5"
Output: [-34, -14, -10, -10, 10]
Explanation:
(2*(3-(4*5))) = -34
((2*3)-(4*5)) = -14
((2*(3-4))*5) = -10
(2*((3-4)*5)) = -10
(((2*3)-4)*5) = 10

这道题让给了一个可能含有加减乘的表达式,让我们在任意位置添加括号,求出所有可能表达式的不同值。

先从最简单的输入开始,若 input 是空串,那就返回一个空数组。若 input 是一个数字的话,那么括号加与不加其实都没啥区别,因为不存在计算,但是需要将字符串转为整型数,因为返回的是一个整型数组。当然,input 是一个单独的运算符这种情况是不存在的,因为前面说了这道题默认输入的合法的。下面来看若 input 是数字和运算符的时候,比如 “1+1” 这种情况,那么加不加括号也没有任何影响,因为只有一个计算,结果一定是2。再复杂一点的话,比如题目中的例子1,input 是 “2-1-1” 时,就有两种情况了,(2-1)-1 和 2-(1-1),由于括号的不同,得到的结果也不同,但如果我们把括号里的东西当作一个黑箱的话,那么其就变为 ()-1 和 2-(),其最终的结果跟括号内可能得到的值是息息相关的,那么再 general 一点,实际上就可以变成 () ? () 这种形式,两个括号内分别是各自的表达式,最终会分别计算得到两个整型数组,中间的问号表示运算符,可以是加,减,或乘。那么问题就变成了从两个数组中任意选两个数字进行运算,瞬间变成我们会做的题目了有木有?而这种左右两个括号代表的黑盒子就交给递归去计算,像这种分成左右两坨的 pattern 就是大名鼎鼎的分治法 Divide and Conquer 了,是必须要掌握的一个神器。类似的题目还有之前的那道 Unique Binary Search Trees II 用的方法一样,用递归来解,划分左右子树,递归构造。

好,继续来说这道题,我们不用新建递归函数,就用其本身来递归就行,先建立一个结果 res 数组,然后遍历 input 中的字符,根据上面的分析,我们希望在每个运算符的地方,将 input 分成左右两部分,从而扔到递归中去计算,从而可以得到两个整型数组 left 和 right,分别表示作用两部分各自添加不同的括号所能得到的所有不同的值,此时我们只要分别从两个数组中取数字进行当前的运算符计算,然后把结果存到 res 中即可。当然,若最终结果 res 中还是空的,那么只有一种情况,input 本身就是一个数字,直接转为整型存入结果 res 中即可,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Solution {
public:
vector<int> diffWaysToCompute(string input) {
vector<int> res;
for (int i = 0; i < input.size(); ++i) {
if (input[i] == '+' || input[i] == '-' || input[i] == '*') {
vector<int> left = diffWaysToCompute(input.substr(0, i));
vector<int> right = diffWaysToCompute(input.substr(i + 1));
for (int j = 0; j < left.size(); ++j) {
for (int k = 0; k < right.size(); ++k) {
if (input[i] == '+') res.push_back(left[j] + right[k]);
else if (input[i] == '-') res.push_back(left[j] - right[k]);
else res.push_back(left[j] * right[k]);
}
}
}
}
if (res.empty()) res.push_back(stoi(input));
return res;
}
};

Leetcode242. Valid Anagram

Given two strings s and t , write a function to determine if t is an anagram of s.

Example 1:

1
2
Input: s = "anagram", t = "nagaram"
Output: true

Example 2:
1
2
Input: s = "rat", t = "car"
Output: false

判断异位词,即包含相同的字符的字符串。使用map记录每个字符串中的字符,判断map是否相同。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution {
public:
bool isAnagram(string s, string t) {
map<char, int> ss, tt;
for(int i = 0; i < s.size(); i ++) {
if(ss.find(s[i]) == ss.end())
ss[s[i]] = 1;
else ss[s[i]] ++;
}
for(int i = 0; i < t.size(); i ++) {
if(tt.find(t[i]) == tt.end())
tt[t[i]] = 1;
else tt[t[i]] ++;
}
return ss == tt;
}
};

Leetcode243. Shortest Word Distance

Given a list of words and two words word1 and word2, return the shortest distance between these two words in the list.

Example:

1
2
3
4
5
6
Assume that words = ["practice", "makes", "perfect", "coding", "makes"].

Input: word1 = “coding”, word2 = “practice”
Output: 3
Input: word1 = "makes", word2 = "coding"
Output: 1

Note:
You may assume that word1 does not equal to word2, and word1 and word2 are both in the list.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Solution {
public:
int shortestDistance(vector<string>& words, string word1, string word2) {
int idx = -1, res = INT_MAX;
for (int i = 0; i < words.size(); ++i) {
if (words[i] == word1 || words[i] == word2) {
if (idx != -1 && words[idx] != words[i]) {
res = min(res, i - idx);
}
idx = i;
}
}
return res;
}
};

Leetcode246. Strobogrammatic Number

A strobogrammatic number is a number that looks the same when rotated 180 degrees (looked at upside down). Write a function to determine if a number is strobogrammatic. The number is represented as a string.

Example 1:

1
2
Input:  "69"
Output: true

Example 2:
1
2
Input:  "88"
Output: true

Example 3:
1
2
Input:  "962"
Output: false

这道题定义了一种对称数,就是说一个数字旋转 180 度和原来一样,也就是倒过来看一样,比如 609,倒过来还是 609 等等,满足这种条件的数字其实没有几个,只有 0,1,8,6,9。这道题其实可以看做求回文数的一种特殊情况,还是用双指针来检测,首尾两个数字如果相等的话,只有它们是 0,1,8 中间的一个才行,如果它们不相等的话,必须一个是6一个是9,或者一个是9一个是6,其他所有情况均返回 false。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Solution {
public:
bool isStrobogrammatic(string num) {
int l = 0, r = num.size() - 1;
while (l <= r) {
if (num[l] == num[r]) {
if (num[l] != '1' && num[l] != '0' && num[l] != '8'){
return false;
}
} else {
if ((num[l] != '6' || num[r] != '9') && (num[l] != '9' || num[r] != '6')) {
return false;
}
}
++l; --r;
}
return true;
}
};

Leetcode247. Strobogrammatic Number II

A strobogrammatic number is a number that looks the same when rotated 180 degrees (looked at upside down). Find all strobogrammatic numbers that are of length = n.

For example, Given n = 2, return [“11”,”69”,”88”,”96”].

可以像是一层层的给字符串从里向外穿衣服一样DFS生成所有的解.

其中翻转之后和自身相等有0, 1, 8, 在n为奇数的情况下最里面的一个数可以为这三个数的任意一个. 再外边就一次给两端添加一个对称的字符. 如果是最外层的话需要注意不能是为0.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Solution {
public:
void DFS(int n, string str)
{
if(n==0) return result.push_back(str);
if(n%2==1) for(auto val: same) DFS(n-1, val);
if(n%2==1) return;
for(int i = (n==2)?1:0; i < two.size(); i++)
DFS(n-2, two[i].first + str + two[i].second);
}

vector<string> findStrobogrammatic(int n) {
if(n <= 0) return {};
DFS(n, "");
return result;
}
private:
vector<string> result;
vector<string> same{"0", "1", "8"};
vector<pair<char,char>> two{{'0','0'},{'1','1'},{'6','9'},{'8','8'},{'9','6'}};
};

Leetcode252. Meeting Rooms

Given an array of meeting time intervals consisting of start and end times [[s1,e1],[s2,e2],…] (si < ei), determine if a person could attend all meetings.

Example 1:

1
2
Input: [[0,30],[5,10],[15,20]]
Output: false

Example 2:
1
2
Input: [[7,10],[2,4]]
Output: true

NOTE: input types have been changed on April 15, 2019. Please reset to default code definition to get new method signature.

这道题给了我们一堆会议的时间,问能不能同时参见所有的会议,这实际上就是求区间是否有交集的问题,那么最简单暴力的方法就是每两个区间比较一下,看是否有 overlap,有的话直接返回 false 就行了。比较两个区间a和b是否有 overlap,可以检测两种情况,如果a的起始位置大于等于b的起始位置,且此时a的起始位置小于b的结束位置,则一定有 overlap,另一种情况是a和b互换个位置,如果b的起始位置大于等于a的起始位置,且此时b的起始位置小于a的结束位置,那么一定有 overlap,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
class Solution {
public:
bool canAttendMeetings(vector<vector<int>>& intervals) {
for (int i = 0; i < intervals.size(); ++i) {
for (int j = i + 1; j < intervals.size(); ++j) {
if ((intervals[i][0] >= intervals[j][0] && intervals[i][0] < intervals[j][1]) || (intervals[j][0] >= intervals[i][0] && intervals[j][0] < intervals[i][1])) return false;
}
}
return true;
}
};

我们可以先给所有区间排个序,用起始时间的先后来排,然后从第二个区间开始,如果开始时间早于前一个区间的结束时间,则说明会议时间有冲突,返回 false,遍历完成后没有冲突,则返回 true,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
class Solution {
public:
bool canAttendMeetings(vector<vector<int>>& intervals) {
sort(intervals.begin(), intervals.end(), [](const vector<int>& a, const vector<int>& b){return a[0] < b[0];});
for (int i = 1; i < intervals.size(); ++i) {
if (intervals[i][0] < intervals[i - 1][1]) {
return false;
}
}
return true;
}
};

Leetcode253. Meeting Rooms II

Given an array of meeting time intervals consisting of start and end times [[s1,e1],[s2,e2],…] (si < ei), find the minimum number of conference rooms required.

Example 1:

1
2
Input: [[0, 30],[5, 10],[15, 20]]
Output: 2

Example 2:

1
2
Input: [[7,10],[2,4]]
Output: 1

NOTE: input types have been changed on April 15, 2019. Please reset to default code definition to get new method signature.

这道题是之前那道 Meeting Rooms 的拓展,那道题只问我们是否能参加所有的会,也就是看会议之间有没有时间冲突,而这道题让求最少需要安排几个会议室,有时间冲突的肯定需要安排在不同的会议室。这道题有好几种解法,先来看使用 TreeMap 来做的,遍历时间区间,对于起始时间,映射值自增1,对于结束时间,映射值自减1,然后定义结果变量 res,和房间数 rooms,遍历 TreeMap,时间从小到大,房间数每次加上映射值,然后更新结果 res,遇到起始时间,映射是正数,则房间数会增加,如果一个时间是一个会议的结束时间,也是另一个会议的开始时间,则映射值先减后加仍为0,并不用分配新的房间,而结束时间的映射值为负数更不会增加房间数,利用这种思路可以写出代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Solution {
public:
int minMeetingRooms(vector<vector<int>>& intervals) {
map<int, int> m;
for (auto a : intervals) {
++m[a[0]];
--m[a[1]];
}
int rooms = 0, res = 0;
for (auto it : m) {
res = max(res, rooms += it.second);
}
return res;
}
};

第二种方法是用两个一维数组来做,分别保存起始时间和结束时间,然后各自排个序,定义结果变量 res 和结束时间指针 endpos,然后开始遍历,如果当前起始时间小于结束时间指针的时间,则结果自增1,反之结束时间指针自增1,这样可以找出重叠的时间段,从而安排新的会议室,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Solution {
public:
int minMeetingRooms(vector<vector<int>>& intervals) {
vector<int> starts, ends;
int res = 0, endpos = 0;
for (auto a : intervals) {
starts.push_back(a[0]);
ends.push_back(a[1]);
}
sort(starts.begin(), starts.end());
sort(ends.begin(), ends.end());
for (int i = 0; i < intervals.size(); ++i) {
if (starts[i] < ends[endpos]) ++res;
else ++endpos;
}
return res;
}
};

Leetcode256. Paint House

There are a row of n houses, each house can be painted with one of the three colors: red, blue or green. The cost of painting each house with a certain color is different. You have to paint all the houses such that no two adjacent houses have the same color.
The cost of painting each house with a certain color is represented by a n x 3 cost matrix. For example, costs[0][0] is the cost of painting house 0 with color red; costs[1][2] is the cost of painting house 1 with color green, and so on… Find the minimum cost to paint all houses.

Note:
All costs are positive integers.

Example:

1
2
3
4
Input: [[17,2,17],[16,16,5],[14,3,19]]
Output: 10
Explanation: Paint house 0 into blue, paint house 1 into green, paint house 2 into blue.
Minimum cost: 2 + 5 + 3 = 10.

解题思路:一道很明显的动态规划的题目. 每个房子有三种染色方案, 那么如果当前房子染红色的话, 最小代价将是上一个房子的绿色和蓝色的最小代价+当前房子染红色的代价. 对另外两种颜色也是如此. 因此动态转移方程为:

  • Sub-problem: find the minimum cost to paint the houses up to current house in red, blue or green.
  • Function:
    • Red: min(f[i - 11][1], f[i - 1][2]) + costs[i][0].
    • Blue: min(f[i - 1][0], f[i - 1][2]) + costs[i][1].
    • Green: min(f[i - 1][0], f[i - 1][1]) + costs[i][2].
    • Initialization: f[0][i] = 0.
    • Answer: min(f[costs.length][i]).
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Solution {
public:
int minCost(vector<vector<int>>& costs) {
int n = costs.size();
// 直接initialize成0更好
vector<vector<int>> dp(n + 1, vector<int>(3, INT_MAX));

for (int i = 0; i < 3; i ++)
dp[0][i] = 0;

for(int i = 1; i < n + 1; i++){
dp[i][0] = min(dp[i - 1][1], dp[i - 1][2]) + costs[i - 1][0];
dp[i][1] = min(dp[i - 1][0], dp[i - 1][2]) + costs[i - 1][1];
dp[i][2] = min(dp[i - 1][0], dp[i - 1][1]) + costs[i - 1][2];
}

return min(dp[n][0], min(dp[n][1], dp[n][2]));
}
};

Leetcode257. Binary Tree Paths

Given a binary tree, return all root-to-leaf paths.

Note: A leaf is a node with no children.

Example:

1
2
3
4
5
6
7
8
Input:
1
/ \
2 3
\
5
Output: ["1->2->5", "1->3"]
Explanation: All root-to-leaf paths are: 1->2->5, 1->3

中序遍历
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Solution {
public:

vector<string> res;

void dfs(TreeNode* root, string cur) {
if(root->left == NULL && root->right == NULL) {
res.push_back(cur);
return;
}

if(root->left)
dfs(root->left, cur+"->"+to_string(root->left->val));
if(root->right)
dfs(root->right, cur+"->"+to_string(root->right->val));
}

vector<string> binaryTreePaths(TreeNode* root) {
if(root == NULL)
return res;
dfs(root, to_string(root->val));
return res;
}
};

Leetcode258. Add Digits

Given a non-negative integer num, repeatedly add all its digits until the result has only one digit.

Example:

1
2
3
4
Input: 38
Output: 2
Explanation: The process is like: 3 + 8 = 11, 1 + 1 = 2.
Since 2 has only one digit, return it.

逐位相加直到小于10
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Solution {
public:
int addDigits(int num) {
int res = num;
while(res / 10) {
int temp = res, sum = 0;;
while(temp) {
sum += temp%10;
temp /= 10;
}
res = sum;
}
return res;
}
};

Leetcode259. 3Sum Smaller

Given an array of n integers nums and a target, find the number of index triplets i, j, k with 0 <= i < j < k < n that satisfy the condition nums[i] + nums[j] + nums[k] < target.

Example:

1
2
3
4
5
Input: nums = [-2,0,1,3], and target = 2
Output: 2
Explanation: Because there are two triplets which sums are less than 2:
[-2,0,1]
[-2,0,3]

这道题是 3Sum 问题的一个变形,让我们求三数之和小于一个目标值,那么最简单的方法就是穷举法,将所有的可能的三个数字的组合都遍历一遍,比较三数之和跟目标值之间的大小,小于的话则结果自增1,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Solution {
public:
int threeSumSmaller(vector<int>& nums, int target) {
int res = 0;
sort(nums.begin(), nums.end());
for (int i = 0; i < int(nums.size() - 2); ++i) {
int left = i + 1, right = nums.size() - 1, sum = target - nums[i];
for (int j = left; j <= right; ++j) {
for (int k = j + 1; k <= right; ++k) {
if (nums[j] + nums[k] < sum) ++res;
}
}
}
return res;
}
};

题目中的 Follow up 让我们在 O(n^2) 的时间复杂度内实现,那么借鉴之前那两道题 3Sum Closest 和 3Sum 中的方法,采用双指针来做,这里面有个 trick 就是当判断三个数之和小于目标值时,此时结果应该加上 right-left,因为数组排序了以后,如果加上 num[right] 小于目标值的话,那么加上一个更小的数必定也会小于目标值,然后将左指针右移一位,否则将右指针左移一位,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Solution {
public:
int threeSumSmaller(vector<int>& nums, int target) {
if (nums.size() < 3) return 0;
int res = 0, n = nums.size();
sort(nums.begin(), nums.end());
for (int i = 0; i < n - 2; ++i) {
int left = i + 1, right = n - 1;
while (left < right) {
if (nums[i] + nums[left] + nums[right] < target) {
res += right - left;
++left;
} else {
--right;
}
}
}
return res;
}
};

Leetcode260. Single Number III

Given an array of numbers nums, in which exactly two elements appear only once and all the other elements appear exactly twice. Find the two elements that appear only once.

Example:

1
2
Input:  [1,2,1,3,2,5]
Output: [3,5]

Note:

  • The order of the result is not important. So in the above example, [5, 3] is also correct.
  • Your algorithm should run in linear runtime complexity. Could you implement it using only constant space complexity?

这道题其实是很巧妙的利用了 Single Number 的解法,因为那道解法是可以准确的找出只出现了一次的数字,但前提是其他数字必须出现两次才行。而这题有两个数字都只出现了一次,那么我们如果能想办法把原数组分为两个小数组,不相同的两个数字分别在两个小数组中,这样分别调用 Single Number 的解法就可以得到答案。那么如何实现呢,首先我们先把原数组全部异或起来,那么我们会得到一个数字,这个数字是两个不相同的数字异或的结果,我们取出其中任意一位为 ‘1’ 的位,为了方便起见,我们用 a &= -a 来取出最右端为 ‘1’ 的位,具体来说下这个是如何操作的吧。就拿题目中的例子来说,如果我们将其全部 ‘异或’ 起来,我们知道相同的两个数 ‘异或’ 的话为0,那么两个1,两个2,都抵消了,就剩3和5 ‘异或’ 起来,那么就是二进制的 11 和 101 ‘异或’ ,得到110。然后我们进行 a &= -a 操作。首先变负数吧,在二进制中负数采用补码的形式,而补码就是反码 +1,那么 110 的反码是 11…1001,那么加1后是 11…1010,然后和 110 相与,得到了 10,就是代码中的 diff 变量。得到了这个 diff,就可以将原数组分为两个数组了。为啥呢,我们想阿,如果两个相同的数字 ‘异或’ ,每位都会是0,而不同的数字 ‘异或’ ,一定会有对应位不同,一个0一个1,这样 ‘异或’ 是1。比如3和5的二进制 11 和 101,如果从低往高看,最开始产生不同的就是第二位,那么我们用第二位来和数组中每个数字相与,根据结果的不同,一定可以把3和5区分开来,而其他的数字由于是成对出现,所以区分开来也是成对的,最终都会 ‘异或’ 成0,不会3和5产生影响。分别将两个小组中的数字都异或起来,就可以得到最终结果了,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Solution {
public:
vector<int> singleNumber(vector<int>& nums) {
int diff = accumulate(nums.begin(), nums.end(), 0, bit_xor<int>());
diff &= -diff;
vector<int> res(2, 0);
for (auto &a : nums) {
if (a & diff) res[0] ^= a;
else res[1] ^= a;
}
return res;
}
};

Leetcode261. Graph Valid Tree

Given n nodes labeled from 0 to n-1 and a list of undirected edges (each edge is a pair of nodes), write a function to check whether these edges make up a valid tree.

Example 1:

1
2
3
Input: 
n = 5, and edges = [[0,1], [0,2], [0,3], [1,4]]
Output: true

Example 2:

1
2
3
Input: 
n = 5, and edges = [[0,1], [1,2], [2,3], [1,3], [1,4]]
Output: false

Note: you can assume that no duplicate edges will appear in edges. Since all edges are undirected, [0,1] is the same as [1,0] and thus will not appear together in edges.

这道题给了一个无向图,让我们来判断其是否为一棵树,如果是树的话,所有的节点必须是连接的,也就是说必须是连通图,而且不能有环,所以焦点就变成了验证是否是连通图和是否含有环。首先用 DFS 来做,根据 pair 来建立一个图的结构,用邻接链表来表示,还需要一个一位数组v来记录某个结点是否被访问过,然后用 DFS 来搜索结点0,遍历的思想是,当 DFS 到某个结点,先看当前结点是否被访问过,如果已经被访问过,说明环存在,直接返回 false,如果未被访问过,现在将其状态标记为已访问过,然后到邻接链表里去找跟其相邻的结点继续递归遍历,注意还需要一个变量 pre 来记录上一个结点,以免回到上一个结点,这样遍历结束后,就把和结点0相邻的节点都标记为 true,然后再看v里面是否还有没被访问过的结点,如果有,则说明图不是完全连通的,返回 false,反之返回 true,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class Solution {
public:
bool validTree(int n, vector<pair<int, int>>& edges) {
vector<vector<int>> g(n, vector<int>());
vector<bool> v(n, false);
for (auto a : edges) {
g[a.first].push_back(a.second);
g[a.second].push_back(a.first);
}
if (!dfs(g, v, 0, -1)) return false;
for (auto a : v) {
if (!a) return false;
}
return true;
}
bool dfs(vector<vector<int>> &g, vector<bool> &v, int cur, int pre) {
if (v[cur]) return false;
v[cur] = true;
for (auto a : g[cur]) {
if (a != pre) {
if (!dfs(g, v, a, cur)) return false;
}
}
return true;
}
};

下面来看 BFS 的解法,思路很相近,需要用 queue 来辅助遍历,这里没有用一维向量来标记节点是否访问过,而是用了一个 HashSet,如果遍历到一个节点,在 HashSet 中没有,则加入 HashSet,如果已经存在,则返回false,还有就是在遍历邻接链表的时候,遍历完成后需要将结点删掉,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Solution {
public:
bool validTree(int n, vector<pair<int, int>>& edges) {
vector<unordered_set<int>> g(n, unordered_set<int>());
unordered_set<int> s{{0}};
queue<int> q{{0}};
for (auto a : edges) {
g[a.first].insert(a.second);
g[a.second].insert(a.first);
}
while (!q.empty()) {
int t = q.front(); q.pop();
for (auto a : g[t]) {
if (s.count(a)) return false;
s.insert(a);
q.push(a);
g[a].erase(t);
}
}
return s.size() == n;
}
};

我们再来看 Union Find 的方法,这种方法对于解决连通图的问题很有效,思想是遍历节点,如果两个节点相连,将其 roots 值连上,这样可以找到环,初始化 roots 数组为 -1,然后对于一个 pair 的两个节点分别调用 find 函数,得到的值如果相同的话,则说明环存在,返回 false,不同的话,将其 roots 值 union 上,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Solution {
public:
bool validTree(int n, vector<pair<int, int>>& edges) {
vector<int> roots(n, -1);
for (auto a : edges) {
int x = find(roots, a.first), y = find(roots, a.second);
if (x == y) return false;
roots[x] = y;
}
return edges.size() == n - 1;
}
int find(vector<int> &roots, int i) {
while (roots[i] != -1) i = roots[i];
return i;
}
};

Leetcode263. Ugly Number

Write a program to check whether a given number is an ugly number. Ugly numbers are positive numbers whose prime factors only include 2, 3, 5.

Example 1:

1
2
3
Input: 6
Output: true
Explanation: 6 = 2 × 3

Example 2:
1
2
3
Input: 8
Output: true
Explanation: 8 = 2 × 2 × 2

Example 3:
1
2
3
Input: 14
Output: false
Explanation: 14 is not ugly since it includes another prime factor 7.

检测一个数是否为丑陋数,所谓丑陋数就是其质数因子只能是 2,3,5。那么最直接的办法就是不停的除以这些质数,如果剩余的数字是1的话就是丑陋数了。
1
2
3
4
5
6
7
8
9
10
class Solution {
public:
bool isUgly(int num) {
if (num <= 0) return false;
while (num % 2 == 0) num /= 2;
while (num % 3 == 0) num /= 3;
while (num % 5 == 0) num /= 5;
return num == 1;
}
};

Leetcode264. Ugly Number II

An ugly number is a positive integer whose prime factors are limited to 2, 3, and 5.

Given an integer n, return the nth ugly number.

Example 1:

1
2
3
Input: n = 10
Output: 12
Explanation: [1, 2, 3, 4, 5, 6, 8, 9, 10, 12] is the sequence of the first 10 ugly numbers.

Example 2:

1
2
3
Input: n = 1
Output: 1
Explanation: 1 has no prime factors, therefore all of its prime factors are limited to 2, 3, and 5.

这道题是之前那道 Ugly Number 的拓展,这里让找到第n个丑陋数,还好题目中给了很多提示,基本上相当于告诉我们解法了,根据提示中的信息,丑陋数序列可以拆分为下面3个子列表:

(1) 1x2 , 2x2, 2x2 , 3x2, 3x2 , 4x2 , 5x2…

(2) 1x3, 1x3 , 2x3, 2x3, 2x3 , 3x3, 3x3…

(3) 1x5, 1x5, 1x5, 1x5 , 2x5, 2x5, 2x5…

仔细观察上述三个列表,可以发现每个子列表都是一个丑陋数分别乘以 2,3,5,而要求的丑陋数就是从已经生成的序列中取出来的,每次都从三个列表中取出当前最小的那个加入序列,请参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Solution {
public:
int nthUglyNumber(int n) {
vector<int> res(1, 1);
int i2 = 0, i3 = 0, i5 = 0;
while (res.size() < n) {
int m2 = res[i2] * 2, m3 = res[i3] * 3, m5 = res[i5] * 5;
int mn = min(m2, min(m3, m5));
if (mn == m2) ++i2;
if (mn == m3) ++i3;
if (mn == m5) ++i5;
res.push_back(mn);
}
return res.back();
}
};

Leetcode266. Palindrome Permutation

Given a string, determine if a permutation of the string could form a palindrome.

Example 1:

1
2
Input: "code"
Output: false

Example 2:
1
2
Input: "aab"
Output: true

Example 3:
1
2
Input: "carerac"
Output: true

Hint:

  • Consider the palindromes of odd vs even length. What difference do you notice?
  • Count the frequency of each character.

这道题让我们判断一个字符串的全排列有没有是回文字符串的,那么根据题目中的提示,我们分字符串的个数是奇偶的情况来讨论,如果是偶数的话,由于回文字符串的特性,每个字母出现的次数一定是偶数次,当字符串是奇数长度时,只有一个字母出现的次数是奇数,其余均为偶数,那么利用这个特性我们就可以解题,我们建立每个字母和其出现次数的映射,然后我们遍历 HashMap,统计出现次数为奇数的字母的个数,那么只有两种情况是回文数,第一种是没有出现次数为奇数的字母,再一个就是字符串长度为奇数,且只有一个出现次数为奇数的字母,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
class Solution {
public:
bool canPermutePalindrome(string s) {
unordered_map<char, int> m;
int cnt = 0;
for (auto a : s) ++m[a];
for (auto a : m) {
if (a.second % 2 == 1) ++cnt;
}
return cnt == 0 || (s.size() % 2 == 1 && cnt == 1);
}
};

Leetcode268. Missing Number

Given an array containing n distinct numbers taken from 0, 1, 2, …, n, find the one that is missing from the array.

Example 1:

1
2
Input: [3,0,1]
Output: 2

Example 2:
1
2
Input: [9,6,4,2,3,5,7,0,1]
Output: 8

随机从0到size()选取了n个数,其中只有一个丢失了(显然的)。
别人的算法:数学推出,0到size()的总和减去当前数组和sum.
1
2
3
4
5
6
7
8
9
10
11
class Solution {
public:
int missingNumber(vector<int>& nums) {
int sum = 0;
int n = nums.size();
for(int i = 0; i < n; i ++) {
sum += nums[i];
}
return n*(n+1)/2 - sum;
}
};

这道问题被标注为位运算问题:参考讨论区的位运算解法:

异或运算xor,
0 ^ a = a ^ 0 =a
a ^ b = b ^ a
a ^ a = 0
0到size()间的所有数一起与数组中的数进行异或运算,
因为同则0,0异或某个未出现的数将存活下来

1
2
3
4
5
6
7
8
9
class Solution {
public:
int missingNumber(vector<int>& nums) {
int res = 0;
for (int i = 1; i <= nums.size(); i++)
res =res ^ i ^ nums[i-1];
return res;
}
};

Leetcode270. Closest Binary Search Tree Value

Given a non-empty binary search tree and a target value, find the value in the BST that is closest to the target.

Note: Given target value is a floating point. You are guaranteed to have only one unique value in the BST that is closest to the target.

Example:

1
2
3
4
5
6
7
8
9
Input: root = [4,2,5,1,3], target = 3.714286

4
/ \
2 5
/ \
1 3

Output: 4

这道题让我们找一个二分搜索数的跟给定值最接近的一个节点值,由于是二分搜索树,所以博主最先想到用中序遍历来做,一个一个的比较,维护一个最小值,不停的更新,实际上这种方法并没有提高效率,用其他的遍历方法也可以,参见代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Solution {
public:
int closestValue(TreeNode* root, double target) {
double d = numeric_limits<double>::max();
int res = 0;
stack<TreeNode*> s;
TreeNode *p = root;
while (p || !s.empty()) {
while (p) {
s.push(p);
p = p->left;
}
p = s.top(); s.pop();
if (d >= abs(target - p->val)) {
d = abs(target - p->val);
res = p->val;
}
p = p->right;
}
return res;
}
};

实际我们可以利用二分搜索树的特点 (左<根<右) 来快速定位,由于根节点是中间值,在往下遍历时,根据目标值和根节点的值大小关系来比较,如果目标值小于节点值,则应该找更小的值,于是到左子树去找,反之去右子树找,参见代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
class Solution {
public:
int closestValue(TreeNode* root, double target) {
int res = root->val;
while (root) {
if (abs(res - target) >= abs(root->val - target)) {
res = root->val;
}
root = target < root->val ? root->left : root->right;
}
return res;
}
};

Leetcode273. Integer to English Words

Convert a non-negative integer to its english words representation. Given input is guaranteed to be less than 231 - 1.

For example,

1
2
3
123 -> "One Hundred Twenty Three"
12345 -> "Twelve Thousand Three Hundred Forty Five"
1234567 -> "One Million Two Hundred Thirty Four Thousand Five Hundred Sixty Seven"

这道题让我们把一个整型数转为用英文单词描述,就像在check上写钱数的方法,我最开始的方法特别复杂,因为我用了几个switch语句来列出所有的单词,但是我看网上大神们的解法都是用数组来枚举的,特别的巧妙而且省地方,膜拜学习中。题目中给足了提示,首先告诉我们要3个一组的进行处理,而且题目中限定了输入数字范围为0到231 - 1之间,最高只能到billion位,3个一组也只需处理四组即可,那么我们需要些一个处理三个一组数字的函数,我们需要把1到19的英文单词都列出来,放到一个数组里,还要把20,30,… 到90的英文单词列出来放到另一个数组里,然后我们需要用写技巧,比如一个三位数n,百位数表示为n/100,后两位数一起表示为n%100,十位数表示为n%100/10,个位数表示为n%10,然后我们看后两位数是否小于20,小于的话直接从数组中取出单词,如果大于等于20的话,则分别将十位和个位数字的单词从两个数组中取出来。然后再来处理百位上的数字,还要记得加上Hundred。主函数中调用四次这个帮助函数,然后中间要插入”Thousand”, “Million”, “Billion”到对应的位置,最后check一下末尾是否有空格,把空格都删掉,返回的时候检查下输入是否为0,是的话要返回’Zero’。参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Solution {
public:
string numberToWords(int num) {
string res = convertHundred(num % 1000);
vector<string> v = {"Thousand", "Million", "Billion"};
for (int i = 0; i < 3; ++i) {
num /= 1000;
res = num % 1000 ? convertHundred(num % 1000) + " " + v[i] + " " + res : res;
}
while (res.back() == ' ') res.pop_back();
return res.empty() ? "Zero" : res;
}
string convertHundred(int num) {
vector<string> v1 = {"", "One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine", "Ten", "Eleven", "Twelve", "Thirteen", "Fourteen", "Fifteen", "Sixteen", "Seventeen", "Eighteen", "Nineteen"};
vector<string> v2 = {"", "", "Twenty", "Thirty", "Forty", "Fifty", "Sixty", "Seventy", "Eighty", "Ninety"};
string res;
int a = num / 100, b = num % 100, c = num % 10;
res = b < 20 ? v1[b] : v2[b / 10] + (c ? " " + v1[c] : "");
if (a > 0) res = v1[a] + " Hundred" + (b ? " " + res : "");
return res;
}
};

Leetcode274. H-Index

Given an array of citations (each citation is a non-negative integer) of a researcher, write a function to compute the researcher’s h-index.

According to the definition of h-index on Wikipedia: “A scientist has index h if h of his/her N papers have at least h citations each, and the other N − h papers have no more than h citations each.”

Example:

1
2
3
4
5
6
Input: citations = [3,0,6,1,5]
Output: 3
Explanation: [3,0,6,1,5] means the researcher has 5 papers in total and each of them had
received 3, 0, 6, 1, 5 citations respectively.
Since the researcher has 3 papers with at least 3 citations each and the remaining
two with no more than 3 citations each, her h-index is 3.

Note: If there are several possible values for h , the maximum one is taken as the h-index.

这道题让我们求H指数,这个质数是用来衡量研究人员的学术水平的质数,定义为一个人的学术文章有n篇分别被引用了n次,那么H指数就是n。而且wiki上直接给出了算法,可以按照如下方法确定某人的H指数:1、将其发表的所有SCI论文按被引次数从高到低排序;2、从前往后查找排序后的列表,直到某篇论文的序号大于该论文被引次数。所得序号减一即为H指数。我也就没多想,直接按照上面的方法写出了代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Solution {
public:
int hIndex(vector<int>& citations) {
sort(citations.begin(), citations.end());
int res = 0, size = citations.size();
for (int i = 0; i < size; i ++) {
if (citations[i] >= size-i) {
res = max(res, size-i);
}
}
return res;
}
};

Leetcode275. H-Index II

Given an array of citations sorted in ascending order (each citation is a non-negative integer) of a researcher, write a function to compute the researcher’s h-index.

According to the definition of h-index on Wikipedia: “A scientist has index h if h of his/her N papers have at least h citations each, and the other N − h papers have no more than h citations each.”

Example:

1
2
3
4
5
6
Input: citations = [0,1,3,5,6]
Output: 3
Explanation: [0,1,3,5,6] means the researcher has 5 papers in total and each of them had
received 0, 1, 3, 5, 6 citations respectively.
Since the researcher has 3 papers with at least 3 citations each and the remaining
two with no more than 3 citations each, her h-index is 3.

Note: If there are several possible values for h , the maximum one is taken as the h-index.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution {
public:
int hIndex(vector<int>& citations) {
int size = citations.size();
int left = 0, right = size-1;
while(left <= right) {
int mid = left + (right-left)/2;
if (citations[mid] == size-mid)
return size-mid;
else if (citations[mid] > size-mid)
right = mid - 1;
else
left = mid + 1;
}
return size - left;
}
};

Leetcode278. First Bad Version

You are a product manager and currently leading a team to develop a new product. Unfortunately, the latest version of your product fails the quality check. Since each version is developed based on the previous version, all the versions after a bad version are also bad.

Suppose you have n versions [1, 2, …, n] and you want to find out the first bad one, which causes all the following ones to be bad.

You are given an API bool isBadVersion(version) which will return whether version is bad. Implement a function to find the first bad version. You should minimize the number of calls to the API.

Example:

Given n = 5, and version = 4 is the first bad version.

call isBadVersion(3) -> false
call isBadVersion(5) -> true
call isBadVersion(4) -> true

Then 4 is the first bad version.

  1. 找出一个序列中第一个出错的位置,可以理解位这个序列是有序的,利用二分查找找到第一个位置
  2. 二分查找在处理的时候,如果不是,start = mid + 1; 是的话应该直接赋值start, 因为这时候这个值有可能就是第一个值
  3. 为什么要用 start + (end - start)/2 这种写法,而不是直接用(start + end)/2?这是为了防止大数溢出,假设这时候start已经是一个很大的数了,就会产生溢出
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    public class Solution extends VersionControl {
    public int firstBadVersion(int n) {
    int start = 1, end = n;
    while (start < end){
    int mid = start + (end - start)/2;
    if(!isBadVersion(mid)) start = mid + 1;
    else end = mid;
    }
    return start;
    }
    }

Leetcode279. Perfect Squares

Given a positive integer n , find the least number of perfect square numbers (for example, 1, 4, 9, 16, …) which sum to n.

Example 1:

1
2
3
Input: n = 12
Output: 3
Explanation: 12 = 4 + 4 + 4.

Example 2:

1
2
3
Input: n = 13
Output: 2
Explanation: 13 = 4 + 9.

这道题说是给我们一个正整数,求它最少能由几个完全平方数组成。这道题是考察四平方和定理。先来看第一种很高效的方法,根据四平方和定理,任意一个正整数均可表示为4个整数的平方和,其实是可以表示为4个以内的平方数之和,那么就是说返回结果只有 1,2,3 或4其中的一个,首先我们将数字化简一下,由于一个数如果含有因子4,那么我们可以把4都去掉,并不影响结果,比如2和8,3和12等等,返回的结果都相同,读者可自行举更多的栗子。还有一个可以化简的地方就是,如果一个数除以8余7的话,那么肯定是由4个完全平方数组成,这里就不证明了,因为我也不会证明,读者可自行举例验证。那么做完两步后,一个很大的数有可能就会变得很小了,大大减少了运算时间,下面我们就来尝试的将其拆为两个平方数之和,如果拆成功了那么就会返回1或2,因为其中一个平方数可能为0。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Solution {
public:
int numSquares(int n) {
while (n % 4 == 0) n /= 4;
if (n % 8 == 7) return 4;
for (int a = 0; a * a <= n; ++a) {
int b = sqrt(n - a * a);
if (a * a + b * b == n) {
return !!a + !!b;
}
}
return 3;
}
};

这道题远不止这一种解法,我们还可以用动态规划 Dynamic Programming 来做,我们建立一个长度为 n+1 的一维dp数组,将第一个值初始化为0,其余值都初始化为INT_MAX,i从0循环到n,j从1循环到i+j<=n的位置,然后每次更新dp[i+j]的值,动态更新 dp 数组,其中dp[i]表示正整数i至少由多个完全平方数组成,那么我们求n,就是返回dp[n]即可,也就是 dp 数组的最后一个数字。需要注意的是这里的写法,i必须从0开始,j必须从1开始,因为我们的初衷是想用dp[i]来更新dp[i + j * j],如果i=0j=1了,那么dp[i]dp[i + j * j]就相等了。

1
2
3
4
5
6
7
8
9
10
11
12
13
class Solution {
public:
int numSquares(int n) {
vector<int> dp(n + 1, INT_MAX);
dp[0] = 0;
for (int i = 0; i <= n; ++i) {
for (int j = 1; i + j * j <= n; ++j) {
dp[i + j * j] = min(dp[i + j * j], dp[i] + 1);
}
}
return dp.back();
}
};

Leetcode282. Expression Add Operators

Given a string that contains only digits 0-9 and a target value, return all possibilities to add binaryoperators (not unary) +, -, or * between the digits so they evaluate to the target value.

Example 1:

1
2
Input: _num_ = "123", _target_ = 6
Output: ["1+2+3", "1*2*3"]

Example 2:

1
2
Input: _num_ = "232", _target_ = 8
Output: ["2*3+2", "2+3*2"]

Example 3:

1
2
Input: _num_ = "105", _target_ = 5
Output: ["1*0+5","10-5"]

Example 4:

1
2
Input: _num_ = "00", _target_ = 0
Output: ["0+0", "0-0", "0*0"]

Example 5:

1
2
Input: _num_ = "3456237490", _target_ = 9191
Output: []

这道题给了我们一个只由数字组成的字符串,让我们再其中添加+,-或号来形成一个表达式,该表达式的计算和为给定了target值,让我们找出所有符合要求的表达式来。看了题目中的例子1和2,很容易让人误以为是必须拆成个位数字,其实不是的,比如例子3中的 “105”, 5能返回”10-5”,说明连着的数字也可以。如果非要在过往的题中找一道相似的题,我觉得跟 Combination Sum II 很类似。不过这道题要更复杂麻烦一些。还是用递归来解题,我们需要两个变量diff和curNum,一个用来记录将要变化的值,另一个是当前运算后的值,而且它们都需要用 long 型的,因为字符串转为int型很容易溢出,所以我们用长整型。对于加和减,diff就是即将要加上的数和即将要减去的数的负值,而对于乘来说稍有些复杂,此时的diff应该是上一次的变化的diff乘以即将要乘上的数,有点不好理解,那我们来举个例子,比如 2+32,即将要运算到乘以2的时候,上次循环的 curNum = 5, diff = 3, 而如果我们要算这个乘2的时候,新的变化值diff应为 32=6,而我们要把之前+3操作的结果去掉,再加上新的diff,即 (5-3)+6=8,即为新表达式 2+32 的值,有点难理解,大家自己一步一步推算吧。

还有一点需要注意的是,如果输入为”000”,0的话,容易出现以下的错误:

Wrong:[“0+0+0”,”0+0-0”,”0+00”,”0-0+0”,”0-0-0”,”0-00”,”00+0”,”00-0”,”000”,”0+00”,”0-00”,”000”,”00+0”,”00-0”,”000”,”000”]

Correct:[“000”,”00+0”,”00-0”,”0+00”,”0+0+0”,”0+0-0”,”0-00”,”0-0+0”,”0-0-0”]

我们可以看到错误的结果中有0开头的字符串出现,明显这不是数字,所以我们要去掉这些情况,过滤方法也很简单,我们只要判断长度大于1且首字符是‘0’的字符串,将其滤去即可,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class Solution {
public:
vector<string> addOperators(string num, int target) {
vector<string> res;
helper(num, target, 0, 0, "", res);
return res;
}
void helper(string num, int target, long diff, long curNum, string out, vector<string>& res) {
if (num.size() == 0 && curNum == target) {
res.push_back(out); return;
}
for (int i = 1; i <= num.size(); ++i) {
string cur = num.substr(0, i);
if (cur.size() > 1 && cur[0] == '0') return;
string next = num.substr(i);
if (out.size() > 0) {
helper(next, target, stoll(cur), curNum + stoll(cur), out + "+" + cur, res);
helper(next, target, -stoll(cur), curNum - stoll(cur), out + "-" + cur, res);
helper(next, target, diff * stoll(cur), (curNum - diff) + diff * stoll(cur), out + "*" + cur, res);
} else {
helper(next, target, stoll(cur), stoll(cur), cur, res);
}
}
}
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
class Solution {
public:
string s;
int target;
vector<string> res;
void dfs(const string& exp, int pos, long long sum, long long lastval) {
if (pos == s.length()) {
if (sum == target)
res.push_back(exp);
return;
}

const string ops = pos == 0 ? "+" : "+-*";
long long val = 0;
for (int i = pos; i < s.length(); i ++) {
if (i > pos && s[pos] == '0')
break;
val = val * 10 + s[i] - '0';

// 整个s的第一个符号只能是+
for (char op : ops) {
string newexpr;
if (!pos)
newexpr = s.substr(pos, i-pos+1);
else
newexpr = exp + string(1, op) + s.substr(pos, i-pos+1);
if (op == '+') {
dfs(newexpr, i+1, sum+val, val);
}
else if (op == '-') {
dfs(newexpr, i+1, sum - val, -val);
} else {
dfs(newexpr, i+1, sum - lastval + lastval * val, lastval * val);
}
}
}

}
vector<string> addOperators(string num, int target) {
this->s = num;
this->target = target;

dfs("", 0, 0, 0);
return res;
}
};

Leetcode283. Move Zeroes

Given an array nums, write a function to move all 0’s to the end of it while maintaining the relative order of the non-zero elements.

Example:

1
2
Input: [0,1,0,3,12]
Output: [1,3,12,0,0]

Note:

  • You must do this in-place without making a copy of the array.
  • Minimize the total number of operations.

把0移动到数组末尾:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Solution {
public:
void moveZeroes(vector<int>& nums) {
int begin = 0, end = 0;
if(nums.size() == 1)
return;
while(begin < nums.size() && end < nums.size()) {
end = begin;
if(nums[begin] == 0) {
while(end < nums.size() && nums[end] == 0)
end ++;
if(end == nums.size())
break;
int temp = nums[begin];
nums[begin] = nums[end];
nums[end] = temp;
}
begin ++;
}
}
};

优化方法:
1
2
3
4
5
6
7
void moveZeroes(vector<int>& nums) {
for (int lastNonZeroFoundAt = 0, cur = 0; cur < nums.size(); cur++) {
if (nums[cur] != 0) {
swap(nums[lastNonZeroFoundAt++], nums[cur]);
}
}
}

Leetcode287. Find the Duplicate Number

Given an array nums containing n + 1 integers where each integer is between 1 and n (inclusive), prove that at least one duplicate number must exist. Assume that there is only one duplicate number, find the duplicate one.

Example 1:

1
2
Input: [1,3,4,2,2]
Output: 2

Example 2:

1
2
Input: [3,1,3,4,2]
Output: 3

Note:

  • You must not modify the array (assume the array is read only).
  • You must use only constant, O (1) extra space.
  • Your runtime complexity should be less than O ( n 2).
  • There is only one duplicate number in the array, but it could be repeated more than once.

这道题给了我们 n+1 个数,所有的数都在 [1, n] 区域内,首先让证明必定会有一个重复数,这不禁让博主想起了小学华罗庚奥数中的抽屉原理(又叫鸽巢原理),即如果有十个苹果放到九个抽屉里,如果苹果全在抽屉里,则至少有一个抽屉里有两个苹果,这里就不证明了,直接来做题吧。题目要求不能改变原数组,即不能给原数组排序,又不能用多余空间,那么哈希表神马的也就不用考虑了,又说时间小于 O(n2),也就不能用 brute force 的方法,那也就只能考虑用二分搜索法了,在区间 [1, n] 中搜索,首先求出中点 mid,然后遍历整个数组,统计所有小于等于 mid 的数的个数,如果个数小于等于 mid,则说明重复值在 [mid+1, n] 之间,反之,重复值应在 [1, mid-1] 之间,然后依次类推,直到搜索完成,此时的 low 就是我们要求的重复值,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Solution {
public:
int findDuplicate(vector<int>& nums) {
int left = 1, right = nums.size();
while (left < right){
int mid = left + (right - left) / 2, cnt = 0;
for (int num : nums) {
if (num <= mid) ++cnt;
}
if (cnt <= mid) left = mid + 1;
else right = mid;
}
return right;
}
};

另一种方法的基本思想是将数组抽象为一条线和一个圆环,因为1~n之间有n+1个数,所以一定有重复数字出现,所以重复的数字即是圆环与线的交汇点。然后设置两个指针,一个快指针一次走两步,一个慢指针一次走一步。当两个指针第一次相遇时,令快指针回到原点(0)且也变成一次走一步,慢指针则继续前进,再次回合时即是线与圆环的交汇点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Solution {
public:
int findDuplicate(vector<int>& nums) {
int fast = nums[nums[0]], slow = nums[0];
while(fast != slow) {
fast = nums[nums[fast]];
slow = nums[slow];
}
fast = 0;
while(fast != slow) {
fast = nums[fast];
slow = nums[slow];
}
return slow;
}
};

Leetcode289. Game of Life

According to the Wikipedia’s article: “The Game of Life, also known simply as Life, is a cellular automaton devised by the British mathematician John Horton Conway in 1970.”

Given a board with m by n cells, each cell has an initial state live (1) or dead (0). Each cell interacts with its eight neighbors (horizontal, vertical, diagonal) using the following four rules (taken from the above Wikipedia article):

  • Any live cell with fewer than two live neighbors dies, as if caused by under-population.
  • Any live cell with two or three live neighbors lives on to the next generation.
  • Any live cell with more than three live neighbors dies, as if by over-population..
  • Any dead cell with exactly three live neighbors becomes a live cell, as if by reproduction.

Write a function to compute the next state (after one update) of the board given its current state. The next state is created by applying the above rules simultaneously to every cell in the current state, where births and deaths occur simultaneously.

Example:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Input: 
[
[0,1,0],
[0,0,1],
[1,1,1],
[0,0,0]
]
Output:
[
[0,0,0],
[1,0,1],
[0,1,1],
[0,1,0]
]

Follow up:

  • Could you solve it in-place? Remember that the board needs to be updated at the same time: You cannot update some cells first and then use their updated values to update other cells.
  • In this question, we represent the board using a 2D array. In principle, the board is infinite, which would cause problems when the active area encroaches the border of the array. How would you address these problems?

这道题是有名的 康威生命游戏,这是一种细胞自动机,每一个位置有两种状态,1为活细胞,0为死细胞,对于每个位置都满足如下的条件:

  1. 如果活细胞周围八个位置的活细胞数少于两个,则该位置活细胞死亡
  2. 如果活细胞周围八个位置有两个或三个活细胞,则该位置活细胞仍然存活
  3. 如果活细胞周围八个位置有超过三个活细胞,则该位置活细胞死亡
  4. 如果死细胞周围正好有三个活细胞,则该位置死细胞复活

由于题目中要求用置换方法 in-place 来解题,所以就不能新建一个相同大小的数组,那么只能更新原有数组,题目中要求所有的位置必须被同时更新,但在循环程序中还是一个位置一个位置更新的,当一个位置更新了,这个位置成为其他位置的 neighbor 时,怎么知道其未更新的状态呢?可以使用状态机转换:

  • 状态0: 死细胞转为死细胞
  • 状态1: 活细胞转为活细胞
  • 状态2: 活细胞转为死细胞
  • 状态3: 死细胞转为活细胞

最后对所有状态对2取余,则状态0和2就变成死细胞,状态1和3就是活细胞,达成目的。先对原数组进行逐个扫描,对于每一个位置,扫描其周围八个位置,如果遇到状态1或2,就计数器累加1,扫完8个邻居,如果少于两个活细胞或者大于三个活细胞,而且当前位置是活细胞的话,标记状态2,如果正好有三个活细胞且当前是死细胞的话,标记状态3。完成一遍扫描后再对数据扫描一遍,对2取余变成我们想要的结果。参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class Solution {
public:
void gameOfLife(vector<vector<int> >& board) {
int m = board.size(), n = m ? board[0].size() : 0;
vector<int> dx{-1, -1, -1, 0, 1, 1, 1, 0};
vector<int> dy{-1, 0, 1, 1, 1, 0, -1, -1};
for (int i = 0; i < m; ++i) {
for (int j = 0; j < n; ++j) {
int cnt = 0;
for (int k = 0; k < 8; ++k) {
int x = i + dx[k], y = j + dy[k];
if (x >= 0 && x < m && y >= 0 && y < n && (board[x][y] == 1 || board[x][y] == 2)) {
++cnt;
}
}
if (board[i][j] && (cnt < 2 || cnt > 3)) board[i][j] = 2;
else if (!board[i][j] && cnt == 3) board[i][j] = 3;
}
}
for (int i = 0; i < m; ++i) {
for (int j = 0; j < n; ++j) {
board[i][j] %= 2;
}
}
}
};

Leetcode290. Word Pattern

Given a pattern and a string str, find if str follows the same pattern.

Here follow means a full match, such that there is a bijection between a letter in pattern and a non-empty word in str.

Example 1:

1
2
Input: pattern = "abba", str = "dog cat cat dog"
Output: true

Example 2:
1
2
Input:pattern = "abba", str = "dog cat cat fish"
Output: false

Example 3:
1
2
Input: pattern = "aaaa", str = "dog cat cat dog"
Output: false

Example 4:
1
2
Input: pattern = "abba", str = "dog dog dog dog"
Output: false

Notes:
You may assume pattern contains only lowercase letters, and str contains lowercase letters that may be separated by a single space.

给定一种规律 pattern 和一个字符串 str ,判断 str 中的单词和pattern的字母是否遵循相同的映射, pattern 里的每个字母和字符串 str 中的每个非空单词之间存在着双向连接的对应规律。

自己的代码,在测评的帮助下加了很多的boundary case,超过了双百:

  • Runtime: 0 ms, faster than 100.00% of C++ online submissions for Word Pattern.
  • Memory Usage: 6.7 MB, less than 100.00% of C++ online submissions for Word Pattern.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
class Solution {
public:
bool wordPattern(string pattern, string str) {
map<string, char> mapp;
string word;
str.push_back(' ');
int i=0, j=0, count=0;;
while(i<str.size()) {
word="";
while(str[i]!=' ') {
word += str[i];
i++;
}
i++;
count ++; // str一共有多少个单词
if (mapp.find(word)==mapp.end()) {
int temp=0;
while(temp<j) {
if (pattern[temp]==pattern[j])
break;
temp ++;
}
if(temp == j)
mapp[word] = pattern[j];
else
return false;
}
else
if (mapp[word] != pattern[j])
return false;
j++;
}
if(count != pattern.size())
return false;
return true;
}
};

看看人家的思路:这道题目主要考察哈希表和字符串的内容。可以将题目拆解为下面三步:

  • 设置pattern字符到单词(字符串 str)的映射(哈希),使用HashMap()存储;使用HashSet() 记录被使用过的单词 。
  • 若单词个数和pattern字符个数不匹配,返回false;
  • 遍历pattern,同时对应的向前移动 str 中单词的指针,每次拆分出pattern中的一个字符, 判断:
    • 如果该字符从未出现在哈希表中:
    • 如果该字符对应的单词已被使用过 ,即HashSet()中包含该字符对应的单词,则返回false;
    • 将该字符与其对应的单词做映射,加入哈希表中;标记该字符指向的单词为已使用,并加入HashSet()
    • 如果该字符在哈希表的映射单词与当前指向的单词不同,则返回false;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class Solution {
public boolean wordPattern(String pattern, String str) {
HashMap<Character, String> map = new HashMap<>();
HashSet<String> set = new HashSet<>();
String[] array = str.split(" ");

if (pattern.length() != array.length) {
return false;
}
for (int i = 0; i < pattern.length(); i++) {
char key = pattern.charAt(i);
if (!map.containsKey(key)) {
if (set.contains(array[i])) {
return false;
}
map.put(key, array[i]);
set.add(array[i]);
} else {
if (!map.get(key).equals(array[i])) {
return false;
}
}
}
return true;
}
}

Leetcode292. Nim Game

You are playing the following Nim Game with your friend: There is a heap of stones on the table, each time one of you take turns to remove 1 to 3 stones. The one who removes the last stone will be the winner. You will take the first turn to remove the stones.

Both of you are very clever and have optimal strategies for the game. Write a function to determine whether you can win the game given the number of stones in the heap.

Example:

1
2
3
4
5
Input: 4
Output: false
Explanation: If there are 4 stones in the heap, then you will never win the game;
No matter 1, 2, or 3 stones you remove, the last stone will always be
removed by your friend.

规律就是当有4,8,12,16….4n…时,我一定输;其他情况我一定赢。

因为当为4n时,我拿后剩下4n-1,4n-2,4n-3块,对方可以拿到4n-4=4(n-1)块。然后我再拿,对方再拿到4(n-2)块。。无论我怎么拿,对方总能拿到最后剩下4块。。。这样我就输了。同理,不为4n时,我总能拿到4n,这样对方就输了。

1
2
3
4
5
6
class Solution {
public:
bool canWinNim(int n) {
return n % 4 != 0;
}
};

Leetcode293. Flip Game

You are playing the following Flip Game with your friend: Given a string that contains only these two characters: + and -, you and your friend take turns to flip twoconsecutive “++” into “—“. The game ends when a person can no longer make a move and therefore the other person will be the winner.

Write a function to compute all possible states of the string after one valid move.

For example, given s = “++++”, after one move, it may become one of the following states:

1
2
3
4
5
[
"--++",
"+--+",
"++--"
]

If there is no valid move, return an empty list [].

这道题让我们把相邻的两个 ++ 变成 —,真不是一道难题,就从第二个字母开始遍历,每次判断当前字母是否为+,和之前那个字母是否为+,如果都为加,则将翻转后的字符串存入结果中即可,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
class Solution {
public:
vector<string> generatePossibleNextMoves(string s) {
vector<string> res;
for (int i = 1; i < s.size(); i ++) {
if (s[i] == '+' && s[i - 1] == '+') {
res.push_back(s.substr(0, i - 1) + "--" + s.substr(i + 1));
}
}
return res;
}
};

Leetcode295. Find Median from Data Stream

The median is the middle value in an ordered integer list. If the size of the list is even, there is no middle value and the median is the mean of the two middle values.

For example, for arr = [2,3,4], the median is 3.
For example, for arr = [2,3], the median is (2 + 3) / 2 = 2.5.
Implement the MedianFinder class:

MedianFinder() initializes the MedianFinder object.
void addNum(int num) adds the integer num from the data stream to the data structure.
double findMedian() returns the median of all elements so far. Answers within 10-5 of the actual answer will be accepted.

Example 1:

1
2
3
4
5
Input
["MedianFinder", "addNum", "addNum", "findMedian", "addNum", "findMedian"]
[[], [1], [2], [], [3], []]
Output
[null, null, null, 1.5, null, 2.0]

Explanation

1
2
3
4
5
6
MedianFinder medianFinder = new MedianFinder();
medianFinder.addNum(1); // arr = [1]
medianFinder.addNum(2); // arr = [1, 2]
medianFinder.findMedian(); // return 1.5 (i.e., (1 + 2) / 2)
medianFinder.addNum(3); // arr[1, 2, 3]
medianFinder.findMedian(); // return 2.0

这道题给我们一个数据流,让我们找出中位数,由于数据流中的数据并不是有序的,所以我们首先应该想个方法让其有序。如果我们用vector来保存数据流的话,每进来一个新数据都要给数组排序,很不高效。所以之后想到用multiset这个数据结构,是有序保存数据的,但是它不能用下标直接访问元素,找中位数也不高效。这里用到的解法十分巧妙,我们使用大小堆来解决问题,其中大堆保存右半段较大的数字,小堆保存左半段较小的数组。这样整个数组就被中间分为两段了,由于堆的保存方式是由大到小,我们希望大堆里面的数据是从小到大,这样取第一个来计算中位数方便。我们用到一个小技巧,就是存到大堆里的数先取反再存,这样由大到小存下来的顺序就是实际上我们想要的从小到大的顺序。当大堆和小堆中的数字一样多时,我们取出大堆小堆的首元素求平均值,当小堆元素多时,取小堆首元素为中位数,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class MedianFinder {
public:

// Adds a number into the data structure.
void addNum(int num) {
small.push(num);
large.push(-small.top());
small.pop();
if (small.size() < large.size()) {
small.push(-large.top());
large.pop();
}
}

// Returns the median of current data stream
double findMedian() {
return small.size() > large.size() ? small.top() : 0.5 *(small.top() - large.top());
}

private:
priority_queue<long> small, large;
};

LeetCode296. Best Meeting Point

A group of two or more people wants to meet and minimize the total travel distance. You are given a 2D grid of values 0 or 1, where each 1 marks the home of someone in the group. The distance is calculated using Manhattan Distance, where distance(p1, p2) = |p2.x - p1.x| + |p2.y - p1.y|.

Example:

1
2
3
4
5
6
7
8
Input: 
1 - 0 - 0 - 0 - 1
| | | | |
0 - 0 - 0 - 0 - 0
| | | | |
0 - 0 - 1 - 0 - 0

Output: 6

Explanation: Given three people living at (0,0), (0,4), and (2,2), The point (0,2) is an ideal meeting point, as the total travel distance of 2+2+2=6 is minimal. So return 6.

这道题让我们求最佳的开会地点,该地点需要到每个为1的点的曼哈顿距离之和最小,题目中给了提示,让从一维的情况来分析,先看一维时有两个点A和B的情况,

1
______A_____P_______B_______

可以发现,只要开会为位置P在 [A, B] 区间内,不管在哪,距离之和都是A和B之间的距离,如果P不在 [A, B] 之间,那么距离之和就会大于A和B之间的距离,现在再加两个点C和D:

1
______C_____A_____P_______B______D______

通过分析可以得出,P点的最佳位置就是在 [A, B] 区间内,这样和四个点的距离之和为AB距离加上 CD 距离,在其他任意一点的距离都会大于这个距离,那么分析出来了上述规律,这题就变得很容易了,只要给位置排好序,然后用最后一个坐标减去第一个坐标,即 CD 距离,倒数第二个坐标减去第二个坐标,即 AB 距离,以此类推,直到最中间停止,那么一维的情况分析出来了,二维的情况就是两个一维相加即可,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Solution {
public:
int minTotalDistance(vector<vector<int>>& grid) {
vector<int> rows, cols;
for (int i = 0; i < grid.size(); ++i) {
for (int j = 0; j < grid[i].size(); ++j) {
if (grid[i][j] == 1) {
rows.push_back(i);
cols.push_back(j);
}
}
}
return minTotalDistance(rows) + minTotalDistance(cols);
}
int minTotalDistance(vector<int> v) {
int res = 0;
sort(v.begin(), v.end());
int i = 0, j = v.size() - 1;
while (i < j) res += v[j--] - v[i++];
return res;
}
};

我们也可以不用多写一个函数,直接对 rows 和 cols 同时处理,稍稍能简化些代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Solution {
public:
int minTotalDistance(vector<vector<int>>& grid) {
vector<int> rows, cols;
for (int i = 0; i < grid.size(); ++i) {
for (int j = 0; j < grid[i].size(); ++j) {
if (grid[i][j] == 1) {
rows.push_back(i);
cols.push_back(j);
}
}
}
sort(cols.begin(), cols.end());
int res = 0, i = 0, j = rows.size() - 1;
while (i < j) res += rows[j] - rows[i] + cols[j--] - cols[i++];
return res;
}
};

Leetcode297. Serialize and Deserialize Binary Tree

Serialization is the process of converting a data structure or object into a sequence of bits so that it can be stored in a file or memory buffer, or transmitted across a network connection link to be reconstructed later in the same or another computer environment.

Design an algorithm to serialize and deserialize a binary tree. There is no restriction on how your serialization/deserialization algorithm should work. You just need to ensure that a binary tree can be serialized to a string and this string can be deserialized to the original tree structure.

Clarification: The input/output format is the same as how LeetCode serializes a binary tree. You do not necessarily need to follow this format, so please be creative and come up with different approaches yourself.

Example 1:

1
2
Input: root = [1,2,3,null,null,4,5]
Output: [1,2,3,null,null,4,5]

Example 2:

1
2
Input: root = []
Output: []

二叉树的序列化与反序列化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
/**
* Definition for a binary tree node.
* struct TreeNode {
* int val;
* TreeNode *left;
* TreeNode *right;
* TreeNode(int x) : val(x), left(NULL), right(NULL) {}
* };
*/
const int N = 200000;
char buf[N];

class Codec {
public:
int length;

void dfs(TreeNode* root) {
if (length)
buf[length++] = ',';
if (!root) {
buf[length++] = '#';
return;
}

string val = to_string(root->val);
for (char c : val)
buf[length++] = c;

dfs(root->left);
dfs(root->right);
}

// Encodes a tree to a single string.
string serialize(TreeNode* root) {
length = 0;
dfs(root);
buf[length] = 0;
return string(buf);
}

TreeNode* gen(string data, int& cur) {
if (cur >= data.length())
return NULL;
if (data[cur] == '#') {
cur += 2;
return NULL;
}

int flag = 1, val = 0;
if (data[cur] == '-') {
cur ++;
flag = -1;
}

while(cur < data.length() && data[cur] != ',') {
val = val * 10 + data[cur] - '0';
cur ++;
}
TreeNode* root = new TreeNode(flag * val);
cur ++;
root->left = gen(data, cur);
root->right = gen(data, cur);
return root;
}

// Decodes your encoded data to tree.
TreeNode* deserialize(string data) {
int cur = 0;
return gen(data, cur);
}
};

// Your Codec object will be instantiated and called as such:
// Codec ser, deser;
// TreeNode* ans = deser.deserialize(ser.serialize(root));

Leetcode299. Bulls and Cows

You are playing the following Bulls and Cows game with your friend: You write a 4-digit secret number and ask your friend to guess it, each time your friend guesses a number, you give a hint, the hint tells your friend how many digits are in the correct positions (called “bulls”) and how many digits are in the wrong positions (called “cows”), your friend will use those hints to find out the secret number.

For example:

1
2
Secret number:  1807
Friend's guess: 7810

According to Wikipedia: “Bulls and Cows (also known as Cows and Bulls or Pigs and Bulls or Bulls and Cleots) is an old code-breaking mind or paper and pencil game for two or more players, predating the similar commercially marketed board game Mastermind. The numerical version of the game is usually played with 4 digits, but can also be played with 3 or any other number of digits.”

Write a function to return a hint according to the secret number and friend’s guess, use A to indicate the bulls and B to indicate the cows, in the above example, your function should return 1A3B.

You may assume that the secret number and your friend’s guess only contain digits, and their lengths are always equal.

这道题提出了一个叫公牛母牛的游戏,有一个四位数字,你猜一个结果,然后根据你猜的结果和真实结果做对比,提示有多少个数字和位置都正确的叫做bulls,还提示有多少数字正确但位置不对的叫做cows,根据这些信息来引导我们继续猜测正确的数字。这道题并没有让我们实现整个游戏,而只用实现一次比较即可。给出两个字符串,让我们找出分别几个bulls和cows。这题需要用哈希表,来建立数字和其出现次数的映射。我最开始想的方法是用两次遍历,第一次遍历找出所有位置相同且值相同的数字,即bulls,并且记录secret中不是bulls的数字出现的次数。然后第二次遍历我们针对guess中不是bulls的位置,如果在哈希表中存在,cows自增1,然后映射值减1,参见如下代码:

解法一:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution {
public:
string getHint(string secret, string guess) {
int m[256] = {0}, bulls = 0, cows = 0;
for (int i = 0; i < secret.size(); ++i) {
if (secret[i] == guess[i]) ++bulls;
else ++m[secret[i]];
}
for (int i = 0; i < secret.size(); ++i) {
if (secret[i] != guess[i] && m[guess[i]]) {
++cows;
--m[guess[i]];
}
}
return to_string(bulls) + "A" + to_string(cows) + "B";
}
};

我们其实可以用一次循环就搞定的,在处理不是bulls的位置时,我们看如果secret当前位置数字的映射值小于0,则表示其在guess中出现过,cows自增1,然后映射值加1,如果guess当前位置的数字的映射值大于0,则表示其在secret中出现过,cows自增1,然后映射值减1,参见代码如下:

解法二:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Solution {
public:
string getHint(string secret, string guess) {
int m[256] = {0}, bulls = 0, cows = 0;
for (int i = 0; i < secret.size(); ++i) {
if (secret[i] == guess[i]) ++bulls;
else {
if (m[secret[i]]++ < 0) ++cows;
if (m[guess[i]]-- > 0) ++ cows;
}
}
return to_string(bulls) + "A" + to_string(cows) + "B";
}
};

Leetcode300. Longest Increasing Subsequence

Given an unsorted array of integers, find the length of longest increasing subsequence.

Example:

1
2
3
Input: [10,9,2,5,3,7,101,18]
Output: 4
Explanation: The longest increasing subsequence is [2,3,7,101], therefore the length is 4.

Note:

  • There may be more than one LIS combination, it is only necessary for you to return the length.
  • Your algorithm should run in O( n2 ) complexity.

这道题让我们求最长递增子串 Longest Increasing Subsequence 的长度,简称 LIS 的长度。首先来看一种动态规划 Dynamic Programming 的解法,这种解法的时间复杂度为 O(n2),类似 brute force 的解法,维护一个一维 dp 数组,其中 dp[i] 表示以 nums[i] 为结尾的最长递增子串的长度,对于每一个 nums[i],从第一个数再搜索到i,如果发现某个数小于 nums[i],更新 dp[i],更新方法为 dp[i] = max(dp[i], dp[j] + 1),即比较当前 dp[i] 的值和那个小于 num[i] 的数的 dp 值加1的大小,就这样不断的更新 dp 数组,到最后 dp 数组中最大的值就是要返回的 LIS 的长度,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Solution {
public:
int lengthOfLIS(vector<int>& nums) {
vector<int> dp(nums.size(), 1);
int res = 0;
for (int i = 0; i < nums.size(); ++i) {
for (int j = 0; j < i; ++j) {
if (nums[i] > nums[j]) {
dp[i] = max(dp[i], dp[j] + 1);
}
}
res = max(res, dp[i]);
}
return res;
}
};

下面来看一种优化时间复杂度到 O(nlgn) 的解法,这里用到了二分查找法,所以才能加快运行时间哇。思路是,先建立一个数组 ends,把首元素放进去,然后比较之后的元素,如果遍历到的新元素比 ends 数组中的首元素小的话,替换首元素为此新元素,如果遍历到的新元素比 ends 数组中的末尾元素还大的话,将此新元素添加到 ends 数组末尾(注意不覆盖原末尾元素)。如果遍历到的新元素比 ends 数组首元素大,比尾元素小时,此时用二分查找法找到第一个不小于此新元素的位置,覆盖掉位置的原来的数字,以此类推直至遍历完整个 nums 数组,此时 ends 数组的长度就是要求的LIS的长度,特别注意的是 ends 数组的值可能不是一个真实的 LIS,比如若输入数组 nums 为 {4, 2, 4, 5, 3, 7},那么算完后的 ends 数组为 {2, 3, 5, 7},可以发现它不是一个原数组的 LIS,只是长度相等而已,千万要注意这点。参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Solution {
public:
int lengthOfLIS(vector<int>& nums) {
if (nums.empty()) return 0;
vector<int> ends{nums[0]};
for (auto a : nums) {
if (a < ends[0]) ends[0] = a;
else if (a > ends.back()) ends.push_back(a);
else {
int left = 0, right = ends.size();
while (left < right) {
int mid = left + (right - left) / 2;
if (ends[mid] < a) left = mid + 1;
else right = mid;
}
ends[right] = a;
}
}
return ends.size();
}
};